Как стать автором
Обновить

Игры в OLTP

Время на прочтение 23 мин
Количество просмотров 2.8K
В последнее время на Хабре стала популярной тема реализации высокопроизводительных приложений. Решили тоже немножко поэкспериментировать в этом направлении и поделиться текущими результатами наших изысканий.

Подопытный «Hello, world!» представляет собой простейшую OLTP систему:



Требования к производительности и отказоустойчивости являются ключевыми для подобных систем. Поэтому поиск решения поставленной задачи осуществлялся в направлении: C, C++, fastcgi, nginx, lighttpd, oracle. В первую очередь нам было любопытно попробовать различные варианты построения OLTP на данных технологиях, а так же измерить производительность и пиковые нагрузки.


И так, условия задачи


Необходимо обрабатывать транзакции вида: перевести X у.е. (денег, а вы что подумали) со счета A на счет B. Чтобы не придумывать велосипед и обеспечить масштабируемость, для взаимодействия с клиентом был выбран протокол HTTP.

Запросы имеют вид


server.com/paysys.request?account=123&operator=456&money=789
что значит: перевести 789 денег со счета оператора 456 на счет пользователя 123.

В базе данных всего три таблички:


(Рис 1. Структура БД)

Процедура транзакции не намного сложнее структуры базы данных:
  1.  procedure PROCESS_TRANS(p_account_id in number,
  2.                          p_operator_id in number,
  3.                          p_sum         in number,
  4.                          o_result     out number) is
  5.     
  6.     l_cnt  number;
  7.     
  8.     cursor cur_account is
  9.      select id
  10.         from pay_account
  11.       where id = p_account_id
  12.          for update of balance;
  13.  
  14.     cursor cur_operator is
  15.      select id
  16.         from pay_operator
  17.       where id = p_operator_id
  18.          and total >= p_sum
  19.          for update of total;
  20.  
  21.  begin
  22.  
  23.     o_result := -1;  -- tansaction begins
  24.     
  25.     select count(1)  -- if such operator exist?
  26.      into l_cnt
  27.      from pay_operator opr
  28.      where opr.id = p_operator_id;
  29.     
  30.     if (l_cnt != 1)
  31.     then
  32.      o_result := -2; -- Operator not found!
  33.      return;
  34.     end if;
  35.     
  36.      open cur_account;
  37.      open cur_operator;
  38.     
  39.     fetch cur_account into l_cnt; -- need to fetch to update rows later
  40.     fetch cur_operator into l_cnt; -- 0/1 row in cursor as selected by prim_key
  41.     
  42.     if cur_account%notfound then
  43.     
  44.      o_result := -3; -- Account not found!
  45.     
  46.     elsif cur_operator%notfound then
  47.     
  48.      o_result := -4; -- Operator has not enough money
  49.     
  50.     else
  51.     
  52.      update pay_account
  53.          set balance = (balance + p_sum)
  54.       where current of cur_account;
  55.  
  56.      update pay_operator
  57.          set total = (total - p_sum)
  58.       where current of cur_operator;
  59.      
  60.      insert
  61.         into pay_transaction
  62.           (
  63.              id,
  64.              account_id,
  65.              operator_id,
  66.              money,
  67.              datetime
  68.           )
  69.      values
  70.           (
  71.              pay_transaction_seq.nextval,
  72.              p_account_id,
  73.              p_operator_id,
  74.              p_sum,
  75.              sysdate
  76.           );
  77.      
  78.      commit;
  79.      o_result := 1;    -- transaction successfully finished!
  80.  
  81.     end if;
  82.     
  83.     close cur_account;
  84.     close cur_operator;
  85.     
  86.  end;
* This source code was highlighted with Source Code Highlighter.


Наиболее идеологически и практически правильным подходом к построению высоконагруженного серверного приложения с большим количеством одновременных подключений является реализация мультиплексора (The C10K problem).
В качестве БД решили взять «нетрадиционный» Oracle 10.2 Express Edition. Транзакции (update) в БД у нас выполнялись за ~2 ms. Временами, когда Oracle сбрасывал данные на диск (видимо dbwr записывал из буферного кэша измененные блоки данных), выполнение транзакции затягивалось до ~20 ms.

Для соединения с базой данных мы решили использовать библиотеки от производителя. Таковых, в рамках нашей задачи, набралось 2 шутки:
  • Oracle Call Interface ©
  • Oracle C++ Call Interface (C++)
OCCI, в лучших традициях ООП, гораздо более нагляден и прост в использовании. Однако, его низкоуровневый собрат обладает неоспоримым преимуществом: OCI позволяет выполнять неблокирующие (асинхронные) запросы к БД, что необходимо для реализации мультиплексирующего сервера при нескольких запросах к БД в рамках обработки одного клиента. Многие недовольны тем, что ради возможности неблокирующих вызовов необходимо писать достаточно громоздкий код и просят Oracle открыть исходники OCCI, но, насколько я знаю, пока безрезультатно. По производительности 2 брата отличаются крайне незначительно, что вполне ожидаемо.

Ингредиенты:

  • Oracle XE 10.2
  • Nginx 0.7.62
  • Lighttpd 1.4.23
обитали на Ubuntu 9.04 под VMWare на ноутбуке Acer 5920G, за неимением пока другого железа :). Надеемся нехватка ресурсов (в обоих смыслах) не отразилась на объективности результатов сравнительных тестов, ведь все испытания проводились в одной и той же среде.

Сразу оговоримся, что в настройке Oracle нам далеко до Тома Кайта, поэтому с тюнингом БД и ОС сильно не заморачивались. Займемся, когда переедем на полноценный сервер.

Для чистоты эксперимента в каждую табличку было внесено по ~100к записей.

Опыты


Как построить такую системку? Нам первым на ум пришло самое простое решение на базе протокола FastCGI. Думаю, не стоит в очередной раз объяснять, чем выгодно отличается FastCGI от своего прародителя CGI. Для высоконагруженного приложения эта разница приципиальна. Хотелось бы подробнее остановиться на другом нюансе, который мы выяснили далеко не сразу.

Спецификация FastCGI предусматривает сохранение пользовательского соединения для возможности реализации мультиплексированной обработки нескольких запросов одновременно. Говоря простым языком, в обработчике запроса можно вызвать асинхронную операцию и спокойно отдать процессорное время другим клиентам, а на следующей итерации цикла опроса получить результат вызванной операции и, передав ответ клиенту, закрыть соединение. К сожалению, nginx и lighttpd (а также библиотеки libfcgi и fastcgipp) пока не поддерживают подобное, требуя закрыть соединение перед обработкой следующего клиента.

Однако в данном примере мы ограничились лишь одним коротким запросом к БД, который вполне можно выполнить и синхронно, не опасаясь сколь либо значимых потерь в производительности.

Nginx и lighttpd позволяют взаимодействовать с FastCGI двумя методами: через TCP канал и Unix Domain Socket. Преимуществом первого подхода является возможность кластеризации FastCGI серверов, недостатком – относительно высокие накладные расходы на передачу данных (TCP-стек все же). UDS же работает только локально, за счет чего обеспечивается более высокая производительность.

О втором методе хочется сказать очень много «теплых» слов. После первых тестов обнаружилось, что до FastCGI приложения доходит едва ли десятая часть запросов при одновременных запросах более 200 клиентов (на нашей железяке). Загвоздка оказалась в переменной ядра net.unux.max_dgram_qlen, которая ограничивает размер очереди запросов при записи в сокет. По умолчанию данная опция равнялась 10, что и объяснило потери запросов. После установки значения в 10000, казалось бы, все должно было прийти в норму. Но не тут-то было. Гарантированно отваливалась почти десятая часть запросов, а nginx писал в логии страшные слова “connect() to socket failed (11: Resource temporary unavailable)”. Говоря русским языком, при большом количестве одновременных запросов сокет «захлебывался». Данную проблему нам пока решить не удалось. Хоть производительность и возросла, но кому она нужна такая, когда порядка 10% транзакций завершаются кодом «502».


FastCGI сервер:


  1. const char *bind_address = ":9000";
  2. const char *db_user_name = "orauser";
  3. const char *db_password = "pass";
  4. const char *db_conn_str = "comp:1521/xe";
  5.  
  6. int main(int argc, char* const argv[] )
  7. {
  8.     using namespace oracle::occi;
  9.  
  10.     int listenQueueBacklog = 4000;
  11.     FCGX_Request request;
  12.  
  13.     if(FCGX_Init())
  14.         exit(1);
  15.  
  16.     int listen_socket = FCGX_OpenSocket(bind_address, listenQueueBacklog);
  17.     if (listen_socket < 0)
  18.         exit(1);
  19.  
  20.     if (fchmod(listen_socket, S_IROTH | S_IWOTH))
  21.         exit(1);
  22.  
  23.     if(FCGX_InitRequest(&request, listen_socket, 0)) exit(1);
  24.  
  25.     Environment*env = Environment::createEnvironment(Environment::DEFAULT);
  26.     Connection *conn = env->createConnection(db_user_name, db_password, db_conn_str);
  27.     Statement *stmt = conn->createStatement(
  28.                       "BEGIN PAY_SYS.PROCESS_TRANS(:v1,:v2,:v3,:v4); END;");
  29.  
  30.     while(FCGX_Accept_r(&request) == 0)
  31.     {
  32.         bool noException = false, succesful = false;
  33.  
  34.         long start = GetTickCount();
  35.         char *params = FCGX_GetParam("QUERY_STRING", request.envp);
  36.         int  operatorID,accountID,moneySum, result=-1;
  37.  
  38.         operatorID = GetIntParam(params, "operator=");
  39.         accountID = GetIntParam(params, "account=");
  40.         moneySum  = GetIntParam(params, "money=");
  41.  
  42.         if (operatorID <= 0 || accountID <= 0 || moneySum <= 0)
  43.         {
  44.             FCGX_FPrintF(request.out, "HTTP/1.0 503 Params Error\n");
  45.             FCGX_FPrintF(request.out, "Content-type: text/html\r\n\r\n");
  46.             FCGX_FPrintF(request.out, "wrong params<br>\n");
  47.             FCGX_Finish_r(&request);
  48.             continue;
  49.         }
  50.  
  51.         try
  52.         {
  53.             stmt->setInt(1, accountID);
  54.             stmt->setInt(2, operatorID);
  55.             stmt->setInt(3, moneySum);
  56.             stmt->registerOutParam(4,OCCIINT,sizeof(result));
  57.             stmt->execute();
  58.             result = stmt->getInt(4);
  59.  
  60.             if (result == 1)
  61.                 succesful = true;
  62.         }
  63.         catch(SQLException &sqlExcp)
  64.         {
  65.             error_log(sqlExcp.getMessage().c_str());
  66.         }
  67.  
  68.         if (succesful)
  69.         {
  70.             FCGX_FPrintF(request.out, "HTTP/1.0 200 OK\n");
  71.             FCGX_FPrintF(request.out, "Content-type: text/html\r\n\r\n");
  72.             FCGX_FPrintF(request.out, "SQL result = %d", result);
  73.         }
  74.         else
  75.         {
  76.             FCGX_FPrintF(request.out, "HTTP/1.0 503 DatabaseError\n");
  77.             FCGX_FPrintF(request.out, "Content-type: text/html\r\n\r\n");
  78.             FCGX_FPrintF(request.out, "Database error occured");
  79.             error_log("db error");
  80.         }
  81.  
  82.         FCGX_Finish_r(&request);
  83.     }
  84.  
  85.     conn->terminateStatement(stmt);
  86.     env->terminateConnection(conn);
  87.     Environment::terminateEnvironment(env);
  88.  
  89.     return 0;
  90. }
* This source code was highlighted with Source Code Highlighter.


Итого.


Использовались siege и ab. Из-за проблем с памятью siege наотрез отказался производить более 380 запросов в секунду, ab же «выжал» до 1000 запросов, но далее сослался на чрезмерное число открытых сокетов.

Для достоверности результатов, тестирование проводилось по схеме 10000 запросов при 100, 200, 300, 400 одновременных. Для FastCGI на этой машине с дефолтными настройками ядра нагрузок хватило для преодоления критического порога в 10% потерянных запросов. Связка lighttpd+fastcgi у нас начала «спотыкаться» при отметке ~200 запросов, связка же с nginx держалась до ~300 одновременных подключений. При нагрузке более 400 одновременных подключений оба варианта FastCGI отваливались с сообщением «apr_poll: The timeout specified has expired (70007)», тогда как модуль nginx без криков расправлялся и с 1000 одновременных коннектов через ab. Тесты опять-таки уперлись в «железо». Для наглядности результаты измерения мы решили представить в виде графиков.

Итак, по оси абсцисс — число одновременных запросов, по оси ординат – в соответствии с подписью к графику.


(Рис 2. Самый длительный запрос, сек)


(Рис 3. Среднее время обработки запроса, сек)


(Рис 4. Количество обработанных за секунду запросов)


(Рис 5. Время, затраченное на обработку 10000 запросов, сек)

Вывод.


Ограниченность ресурса не позволила получить ошеломляющих результатов, но мы считаем, что сравнительное тестирование вполне удалось. Результаты тестирования не претендуют на полноту, но в целом, на наш взгляд, подтверждают ожидания. Уверенно лидирует отечественный спортсмен с минимумом дополнительной экипировки. За что ему и его тренеру огромный респект! В связке с FastCGI он опять же немного обходит заграничного соперника.

В наших опытах пока не был использован механизм асинхронных вызовов БД. Любопытно, как изменятся результаты тестирования, если усложнить обработку клиента до нескольких обращений к БД и реализовать полноценный мультиплексор. На FastCGI так не получится, зато это можно проделать с модулем nginx, а также для полноты экспериментов призвать в ряды «подопытных» собственного HTTP демона на основе библиотеки вроде poco, asio, pion…

В общем, to be continued…

UPD
Модули nginx — расширения сервера, статически компилируемые совместно с ним. Встроенный функционал типа SSL и FastCGI также реализован как модули. Плюс — скорость, минус — сложность обновления на работающей машине. Отличные мануалы есть здесь.
Урезанный пример — ниже. Для разнообразия, работа с ORACLE через С-библиотеку OCI.
  1. // вариант БЕЗ поддержки настроек через nginx.conf и ОБРАБОТКИ ошибок
  2.  
  3. #include <ngx_config.h>
  4. #include <ngx_core.h>
  5. #include <ngx_http.h>
  6. #include <string.h>
  7. #include <oci.h>
  8.  
  9. const text *db_user_name = (const text*)"orauser";
  10. const text *db_password = (const text*)"pass";
  11. const text *db_conn_str = (const text*)"comp:1521/xe";
  12. const text *command     = (const text*)
  13.     "BEGIN PAY_SYS.PROCESS_TRANS(:v1,:v2,:v3,:v4); END;";
  14.  
  15. OCIEnv        *env;
  16. OCISvcCtx    *context;
  17. OCISession    *session;
  18. OCIServer    *server;
  19. OCIError    *error;
  20. OCIStmt        *statement;
  21. OCIBind     *bnd1, *bnd2, *bnd3, *bnd4;
  22.  
  23. int            operatorID, accountID, sum, result;
  24. static char* ngx_http_payment_init (ngx_conf_t *cf,
  25.                                     ngx_command_t *cmd, void *conf);
  26.  
  27. // массив возможных опций модуля в nginx.conf
  28. static ngx_command_t ngx_http_payment_commands[] =
  29. {    // задаем только одну основную опцию, включающую модуль в указанном Location
  30.     { ngx_string("payment_enabled"),
  31.      NGX_HTTP_LOC_CONF|NGX_CONF_NOARGS,
  32.      ngx_http_payment_init,
  33.      NGX_HTTP_LOC_CONF_OFFSET,
  34.      0,
  35.      NULL },
  36.      ngx_null_command            // терминирует массив
  37. };
  38. // callback-и, не используем
  39. static ngx_http_module_t ngx_http_payment_module_ctx =
  40. {
  41.     NULL,    /* preconfiguration */
  42.     NULL,     /* postconfiguration */
  43.     NULL,    /* create main configuration */
  44.     NULL,     /* init main configuration */
  45.     NULL,    /* create server configuration */
  46.     NULL,    /* merge server configuration */
  47.     NULL,    /* create location configuration */
  48.     NULL    /* merge location configuration */
  49. };
  50. // дескриптор модуля, включает в себя все параметры
  51. ngx_module_t ngx_http_payment_module =
  52. {
  53.     NGX_MODULE_V1,
  54.     &ngx_http_payment_module_ctx, /* module context         */
  55.     ngx_http_payment_commands,     /* module directives     */
  56.     NGX_HTTP_MODULE,              /* module type         */
  57.     NULL,                         /* init master         */
  58.     NULL,                         /* init module         */
  59.     NULL,                         /* init process        */
  60.     NULL,                         /* init thread         */
  61.     NULL,                         /* exit thread        */
  62.     NULL,                         /* exit process        */
  63.     NULL,                         /* exit master        */
  64.     NGX_MODULE_V1_PADDING
  65. };
  66.  
  67. void SetCallParams(int oper, int account, int money)
  68. {
  69.     if (bnd1 != 0) OCIHandleFree((dvoid*)bnd1, OCI_HTYPE_BIND);
  70.     if (bnd2 != 0) OCIHandleFree((dvoid*)bnd2, OCI_HTYPE_BIND);
  71.     if (bnd3 != 0) OCIHandleFree((dvoid*)bnd3, OCI_HTYPE_BIND);
  72.     if (bnd4 != 0) OCIHandleFree((dvoid*)bnd4, OCI_HTYPE_BIND);
  73.  
  74.     // копируем из стека, чтобы иметь возможность передать валидный указатель
  75.     result     = 0;
  76.     operatorID = oper;
  77.     accountID = account;
  78.     sum        = money;
  79.  
  80.     // привязываем параметры к вызову SQL
  81.     OCIBindByPos(statement, &bnd1, error, 1, &accountID,
  82.                 sizeof(accountID), SQLT_INT,(dvoid *) 0,
  83.                 (ub2 *) 0, (ub2 *) 0, (ub4) 0, (ub4 *) 0, OCI_DEFAULT);
  84.     OCIBindByPos(statement, &bnd2, error, 2, &operatorID,
  85.                 sizeof(operatorID), SQLT_INT,(dvoid *) 0,
  86.                 (ub2 *) 0, (ub2 *) 0,(ub4) 0, (ub4 *) 0, OCI_DEFAULT);
  87.     OCIBindByPos(statement, &bnd3, error, 3, &sum, sizeof(sum), SQLT_INT,
  88.                 (dvoid *) 0, (ub2 *) 0, (ub2 *) 0, (ub4) 0,
  89.                 (ub4 *) 0, OCI_DEFAULT);
  90.     OCIBindByPos(statement, &bnd4, error, 4, &result, sizeof(result),SQLT_INT,
  91.                 (dvoid *) 0,(ub2 *) 0,(ub2 *) 0,(ub4) 0,(ub4 *)0, OCI_DEFAULT);
  92. }
  93. // основной обработчик, вся работа выполняется здесь
  94. static ngx_int_t ngx_http_payment_handler(ngx_http_request_t *r)
  95. {
  96.     // очищаем body-раздел
  97.     ngx_int_t rc = ngx_http_discard_request_body(r);
  98.  
  99.     if (rc != NGX_OK && rc != NGX_AGAIN)
  100.     {
  101.         ngx_log_error(NGX_LOG_ERR, r->connection->log, 0,
  102.                     "Failed ngx_http_discard_request_body()");
  103.         return rc;
  104.     }
  105.  
  106.     // строки в nginx хранятся как пара length-data,
  107.     // а не как традиционный C-style zero-end
  108.     // копируем для корректной работы парсера
  109.     const int buflen = 100;
  110.     char buf[buflen+1];
  111.     int len = (r->args.len < buflen)? r->args.len : buflen;
  112.     buf[len] = '\0';
  113.     ngx_memcpy(buf, r->args.data, r->args.len);
  114.  
  115.     int successful = 0;
  116.     int operator,abonent,money, result=-1;
  117.  
  118.     // вызов простейшего парсера
  119.     operator = GetIntParam(buf, "operator=");
  120.     abonent = GetIntParam(buf, "abonent=");
  121.     money    = GetIntParam(buf, "money=");
  122.  
  123.     if (operator > 0 && abonent > 0 && money > 0)
  124.     {
  125.         SetCallParams(operator, abonent, money);
  126.  
  127.         int retCode = OCIStmtExecute(
  128.                         context, statement, error, (ub4) 1, (ub4) 0,
  129.                         (OCISnapshot *) NULL, (OCISnapshot *) NULL,
  130.                         (ub4) OCI_COMMIT_ON_SUCCESS);
  131.         
  132.         if (retCode == OCI_SUCCESS)
  133.             successful = 1;
  134.     }
  135.  
  136.     r->headers_out.content_type.len        = sizeof("text/html") - 1;
  137.     r->headers_out.content_type.data    = (u_char *) "text/html";
  138.     r->headers_out.content_length_n        = 0;
  139.     
  140.     if (successful == 1)
  141.         r->headers_out.status = NGX_HTTP_OK;
  142.     else
  143.         r->headers_out.status = NGX_HTTP_INTERNAL_SERVER_ERROR;
  144.     
  145.     // ... формирование body ответа и передача его nginx-у.. см примеры emiller
  146. }
  147.  
  148. static char* ngx_http_payment_init(ngx_conf_t *cf,
  149.                                     ngx_command_t *cmd,
  150.                                     void *our_conf)
  151. {
  152.     // set nginx handler
  153.     ngx_http_core_loc_conf_t *core_conf =
  154.         ngx_http_conf_get_module_loc_conf(cf, ngx_http_core_module);
  155.     core_conf->handler = ngx_http_payment_handler;
  156.  
  157.     // oracle
  158.     bnd1 = (OCIBind *) 0;
  159.     bnd2 = (OCIBind *) 0;
  160.     bnd3 = (OCIBind *) 0;
  161.     bnd4 = (OCIBind *) 0;
  162.  
  163.     OCIEnvCreate((OCIEnv **)&env,(ub4)OCI_DEFAULT,
  164.                 (dvoid *)0,(dvoid * (*)(dvoid *, size_t))0,
  165.                 (dvoid * (*)(dvoid *, dvoid *, size_t))0,
  166.                 (void (*)(dvoid *, dvoid *))0,(size_t)0,(dvoid **)0);
  167.     /* allocate a server handle */
  168.     OCIHandleAlloc ((dvoid *)env, (dvoid **)&server,
  169.                     OCI_HTYPE_SERVER, 0, (dvoid **) 0);
  170.     /* allocate an error handle */
  171.     OCIHandleAlloc ((dvoid *)env, (dvoid **)&error,
  172.                     OCI_HTYPE_ERROR, 0, (dvoid **) 0);
  173.     /* create a server context */
  174.     int retCode = OCIServerAttach (server, error, db_conn_str,
  175.                             strlen((const char*)db_conn_str), OCI_DEFAULT);
  176.     
  177.     /* allocate a service handle */
  178.     retCode = OCIHandleAlloc ((dvoid *)env, (dvoid **)&context,
  179.                                 OCI_HTYPE_SVCCTX, 0, (dvoid **) 0);
  180.     /* set the server attribute in the service context handle*/
  181.     retCode = OCIAttrSet ((dvoid *)context, OCI_HTYPE_SVCCTX,
  182.                          (dvoid *)server, (ub4) 0, OCI_ATTR_SERVER, error);
  183.     /* allocate a user session handle */
  184.     retCode = OCIHandleAlloc ((dvoid *)env, (dvoid **)&session,
  185.                                 OCI_HTYPE_SESSION, 0, (dvoid **) 0);
  186.     // set up user & password for our session
  187.     retCode = OCIAttrSet ((dvoid *)session, OCI_HTYPE_SESSION,
  188.             (void*)db_user_name,(ub4)strlen((const char*)db_user_name),
  189.             OCI_ATTR_USERNAME, error);
  190.     retCode = OCIAttrSet ((dvoid *)session, OCI_HTYPE_SESSION,
  191.             (void*)db_password,(ub4)strlen((const char*)db_password),
  192.             OCI_ATTR_PASSWORD, error);
  193.     // start session
  194.     retCode = OCISessionBegin (context, error, session,
  195.                                 OCI_CRED_RDBMS, OCI_DEFAULT);
  196.  
  197.     /* set the user session attribute in the service context handle*/
  198.     retCode = OCIAttrSet ((dvoid *)context, OCI_HTYPE_SVCCTX,
  199.                          (dvoid *)session, (ub4) 0,
  200.                          OCI_ATTR_SESSION, error);
  201.     OCIHandleAlloc((dvoid *) env, (dvoid **) &statement,
  202.                     OCI_HTYPE_STMT, (size_t) 0, (dvoid **) 0);
  203.     OCIStmtPrepare(statement, error, command, (ub4)strlen((char*)command),
  204.                     (ub4) OCI_NTV_SYNTAX, (ub4) OCI_DEFAULT);
  205.     return NGX_CONF_OK;
  206. }
* This source code was highlighted with Source Code Highlighter.


Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+33
Комментарии 31
Комментарии Комментарии 31

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн