(MVC) MVC (2015)

Bla-Bla-Car Server.

На ціей сторінці я розповім про мій сервер відомого французького сервісу BlaBlaCar. Для починаючих программістів цей код може бути цікавим у декількох аспектах.

  • По-перше, тут можна побачити, як зробити найпростішу аутентификацію у ASP.NET, яка взагалі не потребує бази. Це мабуть один на тисячу проєктів, який не потреьує бізи взагалі.
  • По-друге у моему коді починаючий программіст може побачити як зробити періодичне оновлення кешу. Яке працює у бекграунді до реквестів до хандлерів та сторінок серверу.
  • По-трете, починаючий программіст може побачити у моему коді як робляться реквести до серверу, що працює за протоколом REST - тобто це сервіс, який працює понад HTTP - як звічайний SOAP/WSDL сервіс, але не мае XML/SOAP тіла, але нерідко потребує досить спеціфічний хеадер.
  • Останнім невеликим цікавим моментом може бути техніка перекодування байтів, щоб зробити на бейсіці точно такі перетворювання стрічок, які роблять стандартні PHP-функціі.

    Отже почнемо з першого моменту - найпростішої технологіі аутентіфікаціі, що можлива у ASP.NET взагалі без бази. Та побіжно подивимося взагалі на структуру цього проєкту.



    Як ви можете бачити на скрині, усі публічно доступні сторінкі доступні у кореному каталозі проєкту. За винятком двох сторінок, ща знаходяться у папці Internal. Саме ці дві сторінки потребують аутентифікаціі. Зроблено це за допомогою такого простого конфігу:

       1:  <?xml version="1.0"?>
       2:   
       3:  <configuration>
       4:    <appSettings>
       5:      <add key="AU_TestMode" value="false"/>
       6:      <add key="AU_URL" value="https://api.blablacar.com/oauth/v2/access_token?grant_type=client_credentials"/>
       7:      <add key="ClientID" value="111111111111111111111111111111111111111111111111111111111111111111"/>
       8:      <add key="ClientSecret" value="22222222222222222222222222222222222222222222222222222222222222"/>
       9:      <add key="API_URL" value="https://api.blablacar.com/api/v2/trips"/>
      10:      </appSettings>
      11:    <system.web>
      12:      <compilation debug="true" strict="false" explicit="true" targetFramework="4.0" />
      13:      <customErrors mode="Off"/>
      14:   
      15:      <authentication mode="Forms" >
      16:        <forms loginUrl="~/Login.aspx" timeout="2880" name=".ASPXFORMSAUTH" defaultUrl="~/Default.aspx" >
      17:          <credentials passwordFormat="Clear">
      18:            <user name="User1" password="SecretPassword" />
      19:          </credentials>
      20:        </forms>
      21:      </authentication>
      22:   
      23:      <authorization>
      24:        <allow users="*"/>
      25:        <deny users="?" />
      26:      </authorization>
      27:      
      28:    </system.web>
      29:   
      30:    <location path="Internal">
      31:      <system.web>
      32:        <authorization>
      33:          <allow users="User1" />
      34:          <deny users="*, ?" />
      35:        </authorization>
      36:      </system.web>
      37:    </location>
      38:    
      39:    <system.webServer>
      40:       <modules runAllManagedModulesForAllRequests="true"/>
      41:    </system.webServer>
      42:   
      43:  </configuration>

    І дуже простої сторінці Login:


       1:  <%@ Page Title="" Language="vb" AutoEventWireup="false" MasterPageFile="~/Site.Master" CodeBehind="Login.aspx.vb" Inherits="BlaBlaCarTest.Login" %>
       2:   
       3:  <asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
       4:   
       5:      <h2>на эту страничку требуется вход</h2>
       6:      <br />
       7:      <asp:TextBox ID="Login1" runat="server" ></asp:TextBox>&nbsp; Логин
       8:      <br />
       9:      <asp:TextBox ID="Pass1" runat="server" TextMode="Password" ></asp:TextBox> &nbsp; Пароль
      10:      <br /><br />
      11:      <asp:Button ID="Button1" runat="server" Text="Войти" Width="100px" />
      12:   
      13:  </asp:Content>

       1:  Public Class Login
       2:      Inherits System.Web.UI.Page
       3:   
       4:      Protected Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
       5:          If FormsAuthentication.Authenticate(Login1.Text, Pass1.Text) Then
       6:              FormsAuthentication.RedirectFromLoginPage(Login1.Text, False)
       7:          End If
       8:      End Sub
       9:  End Class

    Таким простим чином ці дві сторінки віявляются захищенеми від публічного огляду. Власне захист прописан у стрічках 15-37 конфігу.



    Теперь подивимося на механізм оновлення кешу. Починається все з того, що прі старті веб-вузла запускається процес CacheTimer.ReStart


       1:  Public Class Global_asax
       2:      Inherits System.Web.HttpApplication
       3:   
       4:      Sub Application_Start(ByVal sender As Object, ByVal e As EventArgs)
       5:          Dim CacheTimer As New SetKeyToCache
       6:          CacheTimer.ReStart()
       7:      End Sub
       8:   
       9:  ...

       1:  Public Class SetKeyToCache
       2:   
       3:      Public Sub ReStart()
       4:          GetTicket()
       5:      End Sub
       6:   
       7:   
       8:      Public Sub GetTicket()
       9:          Dim expires_in As Integer
      10:          Dim access_token As String = ""
      11:          Dim ExperiTime As System.TimeSpan
      12:          Dim Err_MSG As String = ""
      13:          Dim ENCODED_CLIENTID_SECRET As String = ""
      14:          '
      15:          'тестирвоание разніх вариантов ответов сервиса
      16:          If CBool(System.Configuration.ConfigurationManager.AppSettings("AU_TestMode")) Then
      17:              'ENCODED_CLIENTID_SECRET = "{'expires_in':86400,'access_token':'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE0Mzc5OTgwNTcsImNsaWVudCI6IjFhODRhN2JlZmM5OTUyNGExOTM4MTg3YTZkZWEzMjE5Y2MyYTYxMWEwMWIwMWEwOGU4NmQwMjY1YWM4NTY4NTIiLCJzY29wZSI6IiIsInVzZXIiOm51bGx9.r3Y6I0_0VXKWKqjTK_1FOFLf9fY0x03PNs3Mb248zFE'}"
      18:              ENCODED_CLIENTID_SECRET = "{'error':{'code':'x','message':'This access token is no longer available !','developper-message':'access_token.violation','support-link':'https:\/\/api.blablacar.ru\/kontakt'}}"
      19:          Else
      20:              ENCODED_CLIENTID_SECRET = (New GetKeyFromPhpRest).GetSecretToken
      21:          End If
      22:          '
      23:          If Not ENCODED_CLIENTID_SECRET.StartsWith("{") Then
      24:              'если ответ вообще не похож на JSON - єто явная ошибка
      25:              Err_MSG = ENCODED_CLIENTID_SECRET
      26:              ENCODED_CLIENTID_SECRET = ""
      27:   
      28:          ElseIf ENCODED_CLIENTID_SECRET.StartsWith("{""error") Or ENCODED_CLIENTID_SECRET.StartsWith("{'error") Then
      29:              'если из токена прилетел Error
      30:              Try
      31:                  'и если Error нормально парсится
      32:                  Dim JSS = New Script.Serialization.JavaScriptSerializer()
      33:                  Dim JSS_Obj = JSS.DeserializeObject(ENCODED_CLIENTID_SECRET)
      34:                  Err_MSG = JSS.DeserializeObject(ENCODED_CLIENTID_SECRET)("error")("message")
      35:                  ENCODED_CLIENTID_SECRET = ""
      36:              Catch ex As Exception
      37:                  Err_MSG = ex.Message & ":" & ENCODED_CLIENTID_SECRET
      38:                  ENCODED_CLIENTID_SECRET = ""
      39:              End Try
      40:          Else
      41:              Try
      42:                  'если нет Error и expires_in и access_token нормально парсятся
      43:                  Dim JSS = New Script.Serialization.JavaScriptSerializer()
      44:                  Dim JSS_Obj = JSS.DeserializeObject(ENCODED_CLIENTID_SECRET)
      45:   
      46:                  expires_in = JSS_Obj("expires_in")
      47:                  access_token = JSS_Obj("access_token")
      48:                  ExperiTime = New System.TimeSpan(0, 0, expires_in - 10)
      49:                  Err_MSG = ""
      50:              Catch ex As Exception
      51:                  Err_MSG = ex.Message & ":" & ENCODED_CLIENTID_SECRET
      52:                  ENCODED_CLIENTID_SECRET = ""
      53:              End Try
      54:          End If
      55:          '
      56:          If ENCODED_CLIENTID_SECRET = "" Or Err_MSG <> "" Then
      57:              'хорошего токена нет, есть ошибка
      58:              System.Web.HttpRuntime.Cache.Add("SECRET_TOKEN_ERROR", Err_MSG, Nothing, System.Web.Caching.Cache.NoAbsoluteExpiration, New System.TimeSpan(0, 0, 30), CacheItemPriority.Normal, AddressOf ReStart)
      59:              System.Web.HttpRuntime.Cache.Add("SECRET_TOKEN", "", Nothing, System.Web.Caching.Cache.NoAbsoluteExpiration, New System.TimeSpan(10, 0, 0), CacheItemPriority.Normal, AddressOf ReStart)
      60:          Else
      61:              'есть хороший токен
      62:              System.Web.HttpRuntime.Cache.Add("SECRET_TOKEN_ERROR", "", Nothing, System.Web.Caching.Cache.NoAbsoluteExpiration, New System.TimeSpan(0, 0, 30), CacheItemPriority.Normal, AddressOf ReStart)
      63:              System.Web.HttpRuntime.Cache.Add("SECRET_TOKEN", access_token, Nothing, System.Web.Caching.Cache.NoAbsoluteExpiration, ExperiTime, CacheItemPriority.Normal, AddressOf ReStart)
      64:          End If
      65:      End Sub
      66:   
      67:   
      68:  End Class

    У цьому коді важливо зрозуміти його рекурсівну структуру. Тобто коли кєш устаріває, сам класс 58-63 візиває сам себе. Це не зовсім коректний метод, бо глубина стека визову буде наростати, і коли наросте до максімуму - мабуть 32 рекурсивних візова - прога сайт упаде і рестартує спочатку. Але реціклинг сайта IIS робить сам по собі раз у день, і при досить великому часу життя токена глубина наростання стека не доходить до 32-х. На практиці від цього сервісу приходить срок життя токену майже на день i до перезапуску веб-вузлу через надмірне наростання стеку рекурсіі викликов - не доходить.

    Взагалі це нійбільше спрощення у цьому сервері - розрахунок на реціклінг IIS до того, як переповніться стек рекурсіі. Більш корректно було б самому обробити переповення стеку і знов запустити рестарт оновлення кешу.

    Останній код цього класу, мабуть коментарів не потребує - (New GetKeyFromPhpRest).GetSecretToken - віддає чи токен, чи помилку. Це аналізується і формується кеш з токеном чи помилкою. У цьому класі є і тестовий режим, щоб не звертатися до сервіса за токеном занадто часто прі налагодженні цього сайту.


    Теперь подивимось далі на ключовий класс GetKeyFromPhpRest, який відає SecurityToken від серверу авторізаціі.


       1:  Public Class GetKeyFromPhpRest
       2:   
       3:      Dim BlaBlaCarAU_URL As String = System.Configuration.ConfigurationManager.AppSettings("AU_URL") '"https://api.blablacar.com/oauth/v2/access_token?grant_type=client_credentials"
       4:      Dim ClientIDString As String = System.Configuration.ConfigurationManager.AppSettings("ClientID") ' "1111111111111111111111111111111111111111111111111111111111111111"
       5:      Dim ClientSecretString As String = System.Configuration.ConfigurationManager.AppSettings("ClientSecret") '"2222222222222222222222222222222222222222222222222222222222222222"
       6:   
       7:      Public Function GetSecretToken() As String
       8:          Try
       9:              Dim AU As String = ClientIDString & ":" & ClientSecretString
      10:   
      11:              Dim byteArray As Byte()
      12:              byteArray = System.Text.Encoding.UTF8.GetBytes(AU)
      13:   
      14:              Dim POST_String As String = Convert.ToBase64String(byteArray)
      15:   
      16:              '========== System.NotSupportedException The URI prefix is not recognized.
      17:              Dim request As Net.HttpWebRequest = Net.HttpWebRequest.Create(BlaBlaCarAU_URL)
      18:              request.Method = "POST"
      19:              request.ContentType = "application/octet-stream"
      20:              request.Headers.Add("Authorization", "Basic " & POST_String)
      21:              request.ContentLength = 0
      22:              '========== System.Net.WebExceptionStatus.Timeout Unable to connect to the remote server
      23:              Dim POST_Stream As IO.Stream = request.GetRequestStream()
      24:              POST_Stream.Close()
      25:              'ждем
      26:              '========== System.Net.WebException.Timeout
      27:              '========== System.Net.WebException = "The remote server returned an error: (404) Not Found."
      28:              '========== The remote server returned an error: (403) Forbidden.
      29:              Dim response As Net.HttpWebResponse = request.GetResponse()
      30:              Dim GET_Stream As IO.Stream = response.GetResponseStream()
      31:              Dim reader As IO.StreamReader
      32:              reader = New IO.StreamReader(GET_Stream, System.Text.UTF8Encoding.GetEncoding("windows-1251"))
      33:              Dim ENCODED_CLIENTID_SECRET As String = reader.ReadToEnd
      34:   
      35:              reader.Close()
      36:              GET_Stream.Close()
      37:              response.Close()
      38:   
      39:              Return ENCODED_CLIENTID_SECRET
      40:          Catch ex As Exception
      41:              Return ex.Message
      42:          End Try
      43:      End Function
      44:   
      45:  End Class

    У цьому класі багато цікавих моментів. По перше, цікава технологія REST, у якій ключ передаеться до серверу не у тілі реквесту а у хеадері.



    У відповідь видається або JSON, який має SecurityToken та період валідності токену, або помилка.



    Клас, що ми розглядали попереду - аналізує цю відповідь і закладує у кеш або токен доступу, або помилку для діагностіки.


    Ще одна цікава особливість цього класу, що на бейсіке потрібно зробити точно таке перетворювання строк, яке роблять звічайні PHP-функціі string base64_encode ( string $str ). Але звідки мені знати, як працює PHP?

    Допоміг мені зрозуміти, як працює PHP - ось цей сервіс http://www.tools4noobs.com/online_php_functions/base64_encode/ - а ключове перекодування цього класу, єквівентне PHP-функціі - я зробив у стрічках 11-14 вище.


    Ну а далі все занадто просто і описувати вже особливо нічого. Я зробив ось такий хандлер BlaBlaCarInfo.ashx


       1:  Imports System.Web
       2:  Imports System.Web.Services
       3:   
       4:  Public Class BlaBlaCarInfo
       5:      Implements System.Web.IHttpHandler
       6:   
       7:      Sub ProcessRequest(ByVal context As HttpContext) Implements IHttpHandler.ProcessRequest
       8:          Dim fn As String = ""
       9:          Dim tn As String = ""
      10:          Dim db As String = ""
      11:          Dim de As String = ""
      12:          'перекодировка из unicode escape string в строки NET делается автоматически
      13:          fn = context.Request.Params("fn")
      14:          tn = context.Request.Params("tn")
      15:          db = context.Request.Params("db")
      16:          de = context.Request.Params("de")
      17:          Try
      18:              If System.Web.HttpRuntime.Cache("SECRET_TOKEN") Is Nothing Then
      19:                  Dim CacheTimer As New SetKeyToCache
      20:                  CacheTimer.ReStart()
      21:              End If
      22:              'сделали запрос - ошибки быть не должно - Nothing или ""
      23:              If System.Web.HttpRuntime.Cache("SECRET_TOKEN_ERROR") Is Nothing Then
      24:                  System.Web.HttpRuntime.Cache("SECRET_TOKEN_ERROR") = ""
      25:              End If
      26:              'если ошибки нет - обработка продолжается, иначе ошибка
      27:              If System.Web.HttpRuntime.Cache("SECRET_TOKEN_ERROR") = "" Then
      28:                  '
      29:                  Dim Info As New GetInfoFromPhpRest
      30:                  Dim HTML As String = Info.GetFullInfo(System.Web.HttpRuntime.Cache("SECRET_TOKEN"), fn, tn, db, de)
      31:   
      32:                  context.Response.ContentType = "application/json"
      33:                  context.Response.Write(HTML)
      34:              Else
      35:                  context.Response.ContentType = "text/plain"
      36:                  context.Response.Write(System.Web.HttpRuntime.Cache("SECRET_TOKEN_ERROR"))
      37:              End If
      38:          Catch ex As Exception
      39:              context.Response.ContentType = "text/plain"
      40:              context.Response.Write(ex.Message)
      41:          End Try
      42:      End Sub
      43:   
      44:      ReadOnly Property IsReusable() As Boolean Implements IHttpHandler.IsReusable
      45:          Get
      46:              Return False
      47:          End Get
      48:      End Property
      49:   
      50:  End Class

    До якого звертається код jQuery на головній формі Default.aspx


       1:  <%@ Page Title="" Language="vb" AutoEventWireup="false" MasterPageFile="~/Site.Master"
       2:      CodeBehind="Default.aspx.vb" Inherits="BlaBlaCarTest._Default_" %>
       3:   
       4:  <asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
       5:      <h2>
       6:          AJAX client</h2>
       7:      <br />
       8:      <asp:TextBox ID="fn" runat="server" ClientIDMode="Static" Text="Москва"></asp:TextBox>
       9:      &nbsp; откуда
      10:      <br />
      11:      <asp:TextBox ID="tn" runat="server" ClientIDMode="Static" Text="Архангельск"></asp:TextBox>
      12:      &nbsp; куда
      13:      <br />
      14:      <asp:TextBox ID="db" runat="server" ClientIDMode="Static" Text="13-08-2015"></asp:TextBox>
      15:      &nbsp; от
      16:      <br />
      17:      <asp:TextBox ID="de" runat="server" ClientIDMode="Static" Text="30-08-2015"></asp:TextBox>
      18:      &nbsp; до<br />
      19:      <br />
      20:      <script type="text/javascript">
      21:   
      22:          var callback = function parse(data) {
      23:              $('<div>всего предложений - ' + data.pager.total + '</div>').insertAfter('#res1');
      24:              var i = 1;
      25:              $(data.trips).each(
      26:                          function () {
      27:                              var one = this;
      28:                              i = i + 1;
      29:                              $('<div id="res' + i + '">' + one.price.string_value + ' : ' + one.arrival_place.address + ' : ' + one.links._front + '</div>').insertAfter('#res1');
      30:                          });
      31:          };
      32:   
      33:          function ajax_request(parseresult) {
      34:              $.ajax({
      35:                  url: 'BlaBlaCarInfo.ashx',
      36:                  method: "GET",
      37:                  data: {
      38:                      fn: fn.value,
      39:                      tn: tn.value,
      40:                      db: db.value,
      41:                      de: de.value
      42:                  },
      43:                  dataType: 'json',
      44:                  success: parseresult,
      45:                  error: function (data) {
      46:                      alert(data);
      47:                  }
      48:              });
      49:          };
      50:   
      51:      </script>
      52:      <input type="button" onclick="ajax_request(callback)" />
      53:      <br />
      54:      <div id="res1">
      55:      </div>
      56:  </asp:Content>

    Цей код на jQuery робить не тільки реквест до хандлеру BlaBlaCarInfo.ashx, але й парсить відповідь серверу (яку ви бачите нище), яку транзитом хандлер відправляє у канал браузеру.



    Слово транзитом тут підкресно, бо у мене були сумніви - що важливіше меня у цьому рішенні - швидкість чи гнучкість. Можна б було, наприклад, распарсити відповідь серверу прямо у хандлері BlaBlaCarKey.ashx за допомогою JSON-парсера бейсіка, як я це зробив у стрічках 43-44 класу SetKeyToCache - і відправити у браузер лише потрібні дані. Але хто знає, що потрібно буде змінити в цьому коді з часом. Взагалі, як ви розумієте, кліент тут умовний. Тому я відпавив усе і розпарсів відповідь серверу напрямки у браузері (у стрічці 29 jQuery).



    Comments ( )
    <00>  <01>  <02>  <03>  <04>  <05>  <06>  <07>  <08>  <09>  <10>  <11>  <12>  <13>  <14>  <15>  <16>  <17
    Link to this page: http://www.vb-net.com/BlaBlaCar/index.htm
    <SITEMAP>  <MVC>  <ASP>  <NET>  <DATA>  <KIOSK>  <FLEX>  <SQL>  <NOTES>  <LINUX>  <MONO>  <FREEWARE>  <DOCS>  <ENG>  <MAIL ME>  <ABOUT ME>  < THANKS ME>