Разместить здесь вашу рекламу


А вы умеете готовить nested_flatbuffers?

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.
У протокола FlatBuffers имеется интересная возможность — использовать вложенную структуру внутри другой структуры, но хранить ее, как массив сырых данных. Такая оптимизация позволяет уменьшить затраты на память и производительность при чтении/записи данных. Для этого необходимо использовать специальный атрибут — nested_flatbuffers.

Правда, как это часто водится за разработчиками протоколов, на нормальные примеры сил им уже не хватает. И даже на тематических форумах типа stackoverflow, groups.google и т.п. сложно найти полную информацию — приходится буквально по крупицам собирать все части паззла, чтобы в конце концов понять, как именно написать рабочий код.

В статье я раскрою проблему подробнее и приведу примеры на C, C++ и Rust.

In concept this is very simple: a nested buffer is just a chunk of binary data stored in a ubyte vector, typically with some convenience methods generated to access a stored buffer. In praxis it adds a lot of complexity.


image

Intro


Во многих проектах часто встречается ситуация, когда необходимо использовать какой-то протокол для передачи данных между компонентами — например, по сети. Конечно, можно использовать самописный вариант, но что если в проекте бэкенд на C, сервер на C++, а фронтенд на JS? Одним языком ограничиться уже не получается, и тогда на помощь могут прийти сторонние библиотеки, например — Protocol Buffers (или Protobuf) и FlatBuffers (FB), обе от Google.

Как это устроено? Программист создает специальный файл со схемой данных, где описывает, какие типы и структуры будут использоваться в качестве сообщений. Затем с помощью отдельного компилятора протокола генерируются файлы на нужном языке. После чего они импортируются в проект: сгенерированный код содержит необходимые типы, структуры и функции (классы и методы), с помощью которых создается сообщение с данными. После чего производится сериализация — это превращение данных в байтовый буффер типа uint8_t*. Этот буффер можно отправлять куда-нибудь по сети, и на приёмной стороне распаковывать обратно в человекочитаемые данные — это десериализация.
Для справки: у Protobuf схема хранится в файле формата .proto, компилятор – protoc; у FlatBuffers соответственно файлы с расширением .fbs, компилятор flatc.

И хотя FlatBuffers является официально более новым протоколом по сравнению с Protobuf — первый релиз в 2014 году против 2008 года соответственно, — возможности для написания кода как будто бы сильнее ограничены. Например, из-за отсутствия таких, казалось бы, жизненно важных функций, как CopyMessage, во FlatBuffers приходится долго курить документацию и сгенерированные файлы. С другой стороны FB считается более быстрым в плане сериализации/десериализации, а данные занимают меньше памяти.

Минутка самолюбования
Сразу скажу, что я не являюсь экспертом в этой области, хотя у меня был опыт с обоими протоколами. Например, на одной работе я делал визуальный редактор сообщений для Protobuf на wxPython — это были прекрасные человеко-часы дебага и поиска ошибок, потому что вот эта вот динамическая типизация, отсутствие компиляции, рекурсия, рефлексия… всё, как вы любите. Бывшие коллеги кстати говорят, что до сих пор пользуются, успех что ли.

В этой статье я не собираюсь сравнивать плюсы-минусы этих библиотек — за меня это уже сделали другие люди и не один раз: от небольшого обзора до целой научной статьи. Здесь я хочу рассмотреть решение конкретной задачи с использованием FlatBuffers, которая может вам встретиться в проекте: надеюсь, что для кого это окажется полезным.

Problem Setting


Обычно для обмена между компонентами используют сообщения разных типов — например, данные пользователя (имя, уникальный идентификатор, местоположение), значения с датчиков (гироскоп, акселерометр) и так далее. В таком случае сообщения стандартно заключают в объединение union, которое хранится внутри какого-то основного сообщения:

Файл client.fbs:
namespace my_project.client;

table Request
{
    name:string;
}

table Response
{
    x:int;
    y:int;
    z:int;
}

union CommonMessage
{
    Request,
    Response
}

table Message
{
    content:CommonMessage;
}

root_type Message;

Здесь тип Message является основным для передачи данных. Автоматически сгенерированный код будет храниться в файлах «client_reader.h», «client_builder.h» и «client_verifier.h» для C, для C++ — в «client_generated.h» и т.д.
В чем недостатки такого подхода? Допустим, клиент отправил на сервер сообщение типа Response, а на сервере его не надо читать — только переслать дальше без изменений. Предположим, что сервер использует собственную схему данных.

Файл server.fbs:
namespace my_project.server;
table Response
{
    extra_info: string;
    data: my_project.client.Response;
}

Получили сообщение my_project.client.Response от клиента, хотим добавить к нему какие-то данные и отправить my_project.server.Response куда-нибудь дальше (например, клиенту на JS). В таком случае придётся собирать это сообщение как-то так (если сервер написан на C++):
void processClientResponse(const my_project::client::Response* msg)
{
    flatbuffers::FlatBufferBuilder fbb;
    auto clientResponse = my_project::client::CreateResponse(fbb, msg->x(), msg->y(), msg->z());
    auto extraInfo = fbb.CreateString("SomeInfo");
    auto serverResp = my_project::server::CreateResponse(fbb, extraInfo, clientResponse);
//…
}

Обратили внимание на clientResponse? Мы пересоздаём заново сообщение, которое только что получили! Причем надо полностью перечислить все поля для копирования из старого в новое. Почему бы просто не написать как-то так: auto clientResponse {*msg} или вообще использовать msg напрямую?
Увы, но так хорошо жить API флэтбуфферов нам просто не позволяет.
А тем более на C, откуда там взяться конструктору копирования.



Итак, в чём именно мы здесь проигрываем:
  • время выполнения программы — надо сначала прочитать сообщение, а потом запаковать его обратно
  • время работы программиста — затраты на написание кода для перегона сообщения из старого в новое. Я здесь рассмотрел простой пример с типом Response: но что если поля сами являются сложными структурами, а их ещё и много — и всё это по новой, да ещё и на другом языке программирования! Бррр, мы разве за этим здесь, в IT?
  • память — по сути мы храним внутри Message специальную структуру данных с какими-то внутренними особенностями, которые могут раздувать размер сообщения

И какой выход?

Attribute nested_flatbuffers


На помощь приходят специальные возможности — можно заменить тип сообщения на массив байтов [ubyte] и добавить к нему атрибут nested_flatbuffers указывающий на тип, который раньше соответствовал сообщению… ну почти. Тогда возвращаясь к схеме client.fbs:
union CommonMessage
{
    Request,
    Response
}

table MessageHolder
{
    data: CommonMessage;
}

table Message
{
    content: [ubyte](nested_flatbuffer: "MessageHolder");
}

Почему нам понадобился MessageHolder, и мы не могли обойтись просто CommonMessage? Дело в том, что nested_flatbuffer не может иметь тип union, поэтому нужна промежуточная обертка.

Хорошо, у нас теперь есть обновлённое сообщение типа Message: но как узнать, что за данные хранятся внутри массива content?
Для этого можно завести вспомогательный enum Type, завернуть его в заголовок Header и добавить как новое поле в типе Message. Перечисление Type будет по сути повторять объединение CommonMessage.
На самом деле промежуточная структура Header в данном примере необязательна, но в общем случае удобна, если вы захотите добавить что-нибудь ещё.

Файл client.fbs (финальные правки):
// остальная часть схемы без изменений
enum Type:ubyte
{
    request,
    response
}
table Header
{
    type: Type;
}
table Message
{
    header: Header;
    content: [ubyte](nested_flatbuffer: "MessageHolder");
}

Круто! А как этим пользоваться?

It's coding time


Наконец мы добрались до практики — а как именно писать код для сериализации и десериализации данных с помощью нового атрибута? Сразу скажу, что я приведу полные примеры кода без подробных разъяснений, — надеюсь, этого будет достаточно для понимания общей идеи.

Как это делается на C


В C используется особый подход в работе с FB: это не ООП язык, у него даже компилятор другой – flatcc вместо общего flatc (и не всем это оказалось удобно). А ещё на C принято использовать специальный макрос для сокращения неймспейса:
#define ns(x) FLATBUFFERS_WRAP_NAMESPACE(my_project_client, x).
Основывался я на этом примере от разработчиков и на этом от одного из пользователей.

Конструирование и сериализация сообщений
#include "client_builder.h"
#include "client_verifier.h"
#include <stdio.h>

int main(int argc, char ** argv)
{
    printf("Testing on C\n");

    // builder необходим для сериализации: превращения сообщения в байтовый буффер
    flatbuffers_builder_t builder;
    flatcc_builder_init(&builder);

    size_t size = 0;
    void* buf;
    {
        createRequest(&builder);
        buf = flatcc_builder_finalize_aligned_buffer(&builder, &size);
        receiveBuffer(buf, size);
        flatcc_builder_aligned_free(buf);
    }
    {
        flatcc_builder_reset(&builder);
        createResponse(&builder);
        buf = flatcc_builder_finalize_aligned_buffer(&builder, &size);
        receiveBuffer(buf, size);
        flatcc_builder_aligned_free(buf);
    }
    flatcc_builder_clear(&builder);
    return 0;
}

void createRequest(flatbuffers_builder_t* builder)
{
    ns(Type_enum_t) type = ns(Type_request);
    ns(Header_ref_t) header = ns(Header_create(builder, type));
    ns(Message_start_as_root(builder));
        ns(Message_header_create(builder, type));
        ns(Message_content_start_as_root(builder));
            flatbuffers_string_ref_t name = flatbuffers_string_create_str(builder, "This is some string for tests!");
            ns(Request_ref_t) request = ns(Request_create(builder, name));
            ns(CommonMessage_union_ref_t) msg_union = ns(CommonMessage_as_Request(request));
            ns(MessageHolder_data_add(builder, msg_union));
        ns(Message_content_end_as_root(builder));
    ns(Message_end_as_root(builder));
}

void createResponse(flatbuffers_builder_t* builder)
{
    ns(Type_enum_t) type = ns(Type_response);
    ns(Header_ref_t) header = ns(Header_create(builder, type));
    ns(Message_start_as_root(builder));
        ns(Message_header_create(builder, type));
        ns(Message_content_start_as_root(builder));
            ns(Response_ref_t) response = ns(Response_create(builder, 2, 3, 9));
            ns(CommonMessage_union_ref_t) msg_union = ns(CommonMessage_as_Response(response));
            ns(MessageHolder_data_add(builder, msg_union));
        ns(Message_content_end_as_root(builder));
    ns(Message_end_as_root(builder));
}


Обработка полученного буффера и десериализация сообщений
void receiveBuffer(void* buf, size_t size)
{
    const int verification_result = ns(Message_verify_as_root(buf, size));
    if (flatcc_verify_error_ok != verification_result) {
        printf("Unable to verify flatbuffer message\n");
    }
    ns(Message_table_t) msg = ns(Message_as_root(buf));
    ns(Header_table_t) header = ns(Message_header(msg));
    ns(Type_enum_t) type = ns(Header_type(header));
    printf("Received Type: %u\n", type);
    switch(type) {
	case ns(Type_request):
	    processRequest(&msg);
	    break;
	case ns(Type_response):
	    processResponse(&msg);
	    break;
	default:
   	    printf("Unknown type!\n");
	}
}

void processRequest(ns(Message_table_t)* msg)
{
    ns(MessageHolder_table_t) content = ns(Message_content_as_root(*msg));
    ns(Request_table_t) request = (ns(Request_table_t)) ns(MessageHolder_data(content));
    const char* name = ns(Request_name(request));
    printf("Result request: %s\n", name);
}

void processResponse(ns(Message_table_t)* msg)
{
    ns(MessageHolder_table_t) content = ns(Message_content_as_root(*msg));
    ns(Response_table_t) response = (ns(Response_table_t)) ns(MessageHolder_data(content));
    int x = ns(Response_x(response));
    int y = ns(Response_y(response));
    int z = ns(Response_z(response));
    printf("Result response: %d.%d.%d\n", x, y, z);
}


В функции receiveBuffer используется верификация данных, потому что при ошибках могут съехать внутренние смещения и выравнивания данных — это позволит не обрабатывать заведомо сломанный буффер.

Как это делается на C++


Конструирование и сериализация сообщений
#include "flatbuffers/flatbuffers.h"
#include "client_generated.h"
#include "server_generated.h"
#include <iostream>
namespace cli = my_project::client;
namespace srv = my_project::server;

void createRequest(flatbuffers::FlatBufferBuilder& fbb)
{
    auto type = cli::Type::request;
    auto header = cli::CreateHeader(fbb, type);

    flatbuffers::FlatBufferBuilder fbb2;
    auto name = fbb2.CreateString("This is some string for tests!");
    auto rq = cli::CreateRequest(fbb2, name);
    auto data = cli::CreateMessageHolder(fbb2, cli::CommonMessage::Request, rq.Union());
    fbb2.Finish(data);

    auto content = fbb.CreateVector(fbb2.GetBufferPointer(), fbb2.GetSize());
    auto msg = cli::CreateMessage(fbb, header, content);
    cli::FinishMessageBuffer(fbb, msg);
} 

void createResponse(flatbuffers::FlatBufferBuilder& fbb)
{
    auto type = cli::Type::response;
    auto header = cli::CreateHeader(fbb, type);

    flatbuffers::FlatBufferBuilder fbb2;
    auto rp = cli::CreateResponse(fbb2, 2, 3, 9);
    auto data = cli::CreateMessageHolder(fbb2, cli::CommonMessage::Response, rp.Union());
    fbb2.Finish(data);

    auto content = fbb.CreateVector(fbb2.GetBufferPointer(), fbb2.GetSize());
    auto msg = cli::CreateMessage(fbb, header, content);
    cli::FinishMessageBuffer(fbb, msg);
}


Обработка полученного буффера и десериализация сообщений
void receiveBuffer(std::uint8_t* buf, std::size_t size)
{
    flatbuffers::Verifier verifier(buf, size);
    if (!cli::VerifyMessageBuffer(verifier))
    {
        std::cerr << "Unable to verify flatbuffer message\n";
        return;
    }

    auto msg = cli::GetMessage(buf);
    auto header = msg->header();
    auto type = header->type();
    std::cout << "Received HeaderType from client: " << static_cast<uint16_t>(type) << "\n";

    switch (type)
    {
        case cli::Type::request:
            processRequest (msg);
            break;
        case cli::Type::response:
            processResponse(msg);
            break;
    }
}

void processRequest(const cli::Message* msg)
{
    auto content = msg->content_nested_root();
    auto rq = content->data_as_Request();
    auto name = rq->name();
    std::cout << "Result request: " << name->str() <<"\n";
}

void processResponse(const cli::Message* msg)
{
    auto content = msg->content_nested_root();
    auto rp = content->data_as_Response();
    auto x = rp->x();
    auto y = rp->y();
    auto z = rp->z();
    std::cout << "Result response: " << x << "."  << y << "." << z <<"\n";
}


Код практически не отличается по смыслу от написанного на C, за исключением того, что построение сообщения производится другим способом — с помощью дополнительного экземпляра fbb2 типа FlatBufferBuilder для вложенного сообщения. На самом деле разработчики заявляют, что вложенный флэтбуффер можно собирать и так, и так, но в C мне не удалось заставить такую конструкцию работать (а жаль — cо вторым экземпляром билдера код выглядит несколько читабельнее).

Level Up


А теперь самое главное — для чего всё это было нужно? Как именно воспользоваться преимуществом атрибута nested_flatbuffers?
Рассмотрим вариант, когда C++-сервер использует следующую схему данных:

Файл server.fbs:
include "client.fbs";
namespace my_project.server;

table ClientData
{
    extra_info:string;
    client_msg:[ubyte](nested_flatbuffer: "my_project.client.MessageHolder");
}

table ServerData
{
    server_name:string;
}

union CommonMessage
{
    ServerData,
    ClientData
}

table Message
{
    header: my_project.client.Header;
    content: CommonMessage;
}

root_type Message;

Здесь тоже используется сообщение типа Message, но хранящее просто union со своими собственными типами. Самое главное находится в таблице ClientData: сообщение с информацией от клиента, которое мы хотим переслать на сервере, содержит «вложенный флэтбуффер» client_msg — и он должен быть точно такого же типа, что отправил клиент. Под катом продемонстрировано, как правильно его скопировать не распаковывая (комментарии на русском я делал для статьи, на английском — для себя в коде):

example.cpp
// используем глобальный экземпляр для простоты 
flatbuffers::FlatBufferBuilder fbb_;
// общий обработчик сообщения, полученного от клиента
void processClientMessage(const cli::Message* msg)
{
    fbb_.Clear();
    // constructing srv::Message from cli::Message
    switch (msg->header()->type())
    {
        case cli::Type::request:
            forwardMessage(msg, "SERVER-REQUEST");
            break;

        case cli::Type::response:
            forwardMessage(msg, "SERVER-RESPONSE");
            break;
    }
    // processing constructed srv::Message to verify it is correct
    processServerMessage();
}

// Самая интересная часть – пересылка сообщения от клиента без распаковки деталей
void forwardMessage(const cli::Message* msg, const char* extra_info_str)
{
    // Yes, we should recreate header as FlatBuffers don't have API to just copy it from msg->header()
    auto header = cli::CreateHeader(fbb_, msg->header()->type());

    auto extra_info = fbb_.CreateString(extra_info_str);

    // The main part: copying nested buffer from client to server message
    auto client_msg = msg->content();
    auto client_msg_vec = fbb_.CreateVector(client_msg->Data(), client_msg->size());

    auto content = srv::CreateClientData(fbb_, extra_info, client_msg_vec);

    auto server_msg = srv::CreateMessage(fbb_, header, srv::CommonMessage::ClientData, content.Union());
    srv::FinishMessageBuffer(fbb_, server_msg);
}

// пример обработки сообщения, сгенерированного сервером
void processServerMessage() const
{
    std::uint8_t* buf = fbb_.GetBufferPointer();
    auto msg = srv::GetMessage(buf);

    auto header = msg->header();
    auto header_type = header->type();
    auto content_type = msg->content_type();

    std::cout << "Received HeaderType from server: " << static_cast<uint16_t>(header_type) << "\n";
    std::cout << "Received ContentType from server: " << static_cast<uint16_t>(content_type) << "\n";

    if (content_type != srv::CommonMessage::ClientData)
    {
        std::cerr << "Not implemented Handler for this content_type\n";
        return;
    }

    // process only ClientData for demonstration purposes
    auto content = msg->content_as_ClientData();
    auto extra_info = content->extra_info();
    auto client_msg = content->client_msg_nested_root();

    std::cout << "Result request from server: extra_info: " << extra_info->str() << "\n";

    switch(header_type)
    {
        case cli::Type::request:
        {
            auto client_rq = client_msg->data_as_Request();
            auto client_name = client_rq->name();
            std::cout << "- client_msg: " << client_name->str() << "\n";
            break;
        }

        case cli::Type::response:
        {
            auto client_rp = client_msg->data_as_Response();
            auto x = client_rp->x();
            auto y = client_rp->y();
            auto z = client_rp->z();
            std::cout << "- client_msg: " << x << "." << y << "." << z << "\n";
            break;
        }
    }
}


Отдельно ещё раз хочу сделать акцент на копировании:
auto client_msg = msg->content();
auto client_msg_vec = fbb_.CreateVector(client_msg->Data(), client_msg->size());

И всё! Не надо знать деталей, что именно к нам пришло в msg->content() — мы просто берём и копируем сырой буффер как есть.
Красиво и удобно

Bonus


Так случилось, что у меня для вас есть ещё и полноценный пример на Rust. Согласен, внезапно, но почему бы и нет. Сейчас язык набирает обороты, и уже всё чаще случается, что им заменяют C++. Наконец-то, теперь мы будем спасены! Короче говоря, who knows

main.rs
extern crate flatbuffers;

#[allow(dead_code, unused_imports, non_snake_case)]
#[path = "../../fbs/client_generated.rs"]
mod client_generated;
pub use client_generated::my_project::client::{
    get_root_as_message,
    Type,
    Request,
    Response,
    Header,
    CommonMessage,
    MessageHolder,
    Message,
    RequestArgs,
    ResponseArgs,
    HeaderArgs,
    MessageHolderArgs,
    MessageArgs};

fn create_request(mut builder: &mut flatbuffers::FlatBufferBuilder)
{
// в Rust слово type является ключевым, поэтому генератор автоматически добавляет _
    let type_ = Type::request;
    let header = Header::create(&mut builder, &mut HeaderArgs{type_});

    let data_builder = {
        let mut b = flatbuffers::FlatBufferBuilder::new();

        let name = b.create_string("This is some string for tests!");
        let rq = Request::create(&mut b, &RequestArgs{name: Some(name)});

        let data = MessageHolder::create(&mut b, &MessageHolderArgs{
            data_type: CommonMessage::Request,
            data: Some(rq.as_union_value())});

        b.finish(data, None);
        b
    };

    let content = builder.create_vector(data_builder.finished_data());

    let msg = Message::create(&mut builder, &MessageArgs{
        header: Some(header),
        content: Some(content)});

    builder.finish(msg, None);
}

fn create_response(mut builder: &mut flatbuffers::FlatBufferBuilder)
{
    let type_ = Type::response;
    let header = Header::create(&mut builder, &mut HeaderArgs{type_});

    let data_builder = {
        let mut b = flatbuffers::FlatBufferBuilder::new();
        let rp = Response::create(&mut b, &ResponseArgs{x: 2, y: 3, z: 9});

        let data = MessageHolder::create(&mut b, &MessageHolderArgs{
            data_type: CommonMessage::Response,
            data: Some(rp.as_union_value())});

        b.finish(data, None);
        b
    };

    let content = builder.create_vector(data_builder.finished_data());

    let msg = Message::create(&mut builder, &MessageArgs{
        header: Some(header),
        content: Some(content)});

    builder.finish(msg, None);
}

fn process_request(msg: &Message)
{
    let content = msg.content_nested_flatbuffer().unwrap();
    let rq = content.data_as_request().unwrap();
    let name = rq.name().unwrap();
    println!("Result request: {:?}", name);
}

fn process_response(msg: &Message)
{
    let content = msg.content_nested_flatbuffer().unwrap();
    let rp = content.data_as_response().unwrap();
    println!("Result response: {}.{}.{}", rp.x(), rp.y(), rp.z());
}

fn receive_buffer(buf: &[u8])
{
    // NOTE: no verification exists in Rust yet
    let msg = get_root_as_message(buf);
    let header = msg.header().unwrap();
    let type_ = header.type_();
    println!("Received Type: {:?}", type_);

    match type_ {
        Type::request => {
            process_request(&msg);
        }

        Type::response => {
            process_response(&msg);
        }
    }
}

fn main() {
    println!("Testing on Rust");

    let mut builder = flatbuffers::FlatBufferBuilder::new_with_capacity(1024);
    {
    	create_request(&mut builder);
    	let buf = builder.finished_data();
	receive_buffer(&buf);
    }
    {
    	builder.reset();
    	create_response(&mut builder);
    	let buf = builder.finished_data();
	receive_buffer(&buf);
    }
}


FIN


Атрибут nested_flatbuffers является очень полезным и удобным способом для оптимизации передачи данных при использовании протокола FlatBuffers. Другое дело, что не так просто понять сходу, как именно его применять.

Вообще документация и примеры от разработчиков тех или иных протоколов иногда оставляют желать лучшего. Синтаксис таких библиотек является довольно специфичным, и по сути на его понимание приходится часто тратить не меньше времени и сил, чем на изучение просто нового языка программирования. Особенно часто это случается, когда упоминаются какие-то редкие возможности.
Например
Похожим образом помимо nested_flatbuffers обстоит дело с использованием собственных аллокаторов в C: «Да, юзеры, вы можете использовать свои аллокаторы аж двумя (2!) разными способами, но примеров мы вам, конечно, не дадим. А зачем?! Курите исходники» *звуки trolololo
Так что если вы вдруг озадачитесь такой же проблемой, то наверняка наткнётесь на моё обсуждение с разработчиками на одном из форумов, — жаль только, что их ответ был крайне развёрнутый, но почти бесполезный.

Поэтому хочется просто пожелать вам поменьше сталкиваться с такими неприятностями, а ещё иногда не жалеть время на написание доков для других программистов — мы же ведь коллеги по цеху, да?
Источник: https://habr.com/ru/post/529846/


Интересные статьи

Интересные статьи

Я начал заниматься сетями еще в школе, а работаю за деньги больше 16 лет. Я много куда устраивался, в большие компании и маленькие, потом открыл свой бизнес и регулярно сам нанимаю лю...
Преимущества кроссплатформенных игр уже долгое время обсуждаются в кругах геймеров, но мало освещаются в среде разработчиков. Существует не так много статей о базовых вещах, необходимых для созда...
На днях в Праге прошла встреча международного комитета по стандартизации C++. И-и-и-и… C++20 готов! Осталось поставить штампик от ISO, но это чисто формальный шаг, с которым не должно быть...
Сервис с распознаванием лиц «Look-A-Like» обслуживал тысячи пользователей одновременно Разработка на NodeJS в качестве хобби — сплошное удовольствие, но когда речь о продакшене для множества пол...
Реализация ORM в ядре D7 — очередная интересная, перспективная, но как обычно плохо документированная разработка от 1с-Битрикс :) Призвана она абстрагировать разработчика от механики работы с табл...