~ read.
Thrift API Gateway. Часть 1: препарация протокола

Thrift API Gateway. Часть 1: препарация протокола

В одной из своих недавних статей я писал про то, что Apache Thrift стимулирует писать тесты, потому что его протокол не так уж просто протестировать классическим средствами ручного тестирования (curl, ui). Но порой приходится сталкиваться с куда более сложной задачей - препарацией протокола для замены одних бинарных данных другими. Об одной из таких редких задач и пойдёт речь.

Откуда растут ноги

Микросервисы, как ни крути, - наше всё. Можно сопротивляться SOAP 2.0 сколь угодно долго, но рано или поздно или придут за тобой и обратят в свою веру, или ты придёшь к ним сам и попросишь крестить себя огнём и мечом. Как и у любого архитектурного решения у микросервисов есть свои минусы. Одним из них является необходимость в каждый микросервис включать какую-то логику по авторизации запросов от внешних систем или других микросервисов. Эта логика может быть напрямую "зашита" внутри микросервиса (и не важно, что это отдельная библиотека), делегирована другому микросервису, а может быть объявлена декларативно. Что значит декларативно? Например, можно договориться, что в каждый микросервис приходит особый HTTP-заголовок, или какая-то структура данных, в которой есть информация о пользователе, делающем какой-либо запрос. И данным в этой структуре необходимо однозначно доверять. У всех трёх вариантов есть свои недостатки, но в рамках статьи мы разберём последний. Для его реализации обычно используется шаблон проектирования API Gateway: API В общем случае API Gateway ограничивает количество запросов к внутренним сервисам, авторизует запросы клиентов, производит логирование и аудит, распределяет запросы между клиентами и преобразовывает данные, если это нужно. В качестве API Gateway-я может быть использован обычный nginx. Рассмотрим функцию авторизации запросов пользователей. Если используется HTTP-протокол, то общепринятой практикой считается добавление некоего токена (не важно как мы его получили) в заголовок Authorization:

Authorization: Bearer <some token>  

На стороне API Gateway этот заголовок каким-то образом проверяется и обменивается на другой заголовок, содержащий некое знание о пользователе, которому токен был выписан, например его идентификатор, и уже его можно пробросить внутренним сервисам:

Customer: <id>  

Всё кажется простым и понятным, но беда в том, что Apache Thrift состоит из нескольких частей:

+-------------------------------------------+
| Server                                    |
| (single-threaded, event-driven etc)       |
+-------------------------------------------+
| Processor                                 |
| (compiler generated)                      |
+-------------------------------------------+
| Protocol                                  |
| (JSON, compact, binary etc)               |
+-------------------------------------------+
| Transport                                 |
| (raw TCP, HTTP etc)                       |
+-------------------------------------------+

В общем случае мы не можем завязаться на протокол или транспорт. Можно конечно выбрать что-то одно, всем договориться, что мы используем только HTTP, но это ограничивает возможности по замене транспорта и заставляет делать некие внешние обработчики/фильтры уже внутри самих микросервисов (ведь для них то http-заголовки не являются нативными).

И тут в голову приходит безумная идея: что если использовать возможности самого протокола, чтобы в процессе прохождения запроса через наш gateway подменять внешний авторизационный токен на внутренний?

Convention over configuration

Итак, пусть у нас есть следующий внутренний сервис:

service InternalTestService {  
    SomeReturnData getSomeData(
        1: UserData userData,
        2: RequestData requestData
    ) throws (1: SomeException e);
}

UserData - это некие сведения о пользователе, от лица которого вызывается сервис, чтобы последний мог понять, а чьи данные тянуть. Понятно, что такой сервис выставлять наружу нельзя. А какой можно? Например такой:

service ExternalTestService {  
    SomeReturnData getSomeData(
        1: AuthToken authData,
        2: RequestData requestData
    ) throws (1: SomeException e);
}

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

Кишочки

К сожалению документации по Thrift-у кот наплакал. Почти все гайды, включая, пожалуй, лучший из них, не касаются вопросов внутреннего устройства тех или иных протоколов. И это понятно. В 99% случаев, лезть внутрь протокола разработчику не придётся, но нам то нужно.

Есть три наиболее популярных протокола:

  • Binary - просто бинарный протокол данных (строки, например, передаются как есть в UTF-8)
  • Compact - тот же бинарный только компактный
  • JSON - очень своеобразный JSON

Каждый из представленных протоколов имеет свою реализацию, скрытую за одним и тем же API. Если рассмотреть бинарный протокол, то для нашего сервиса он будет с точки зрения API выглядеть так:

Protocol

TMessage - мета информация о сообщении. Состоит из имени метода, типа и порядкого номера метода в сервисе. Тип сообщения может быть следующим:

  • CALL = 1 - входящее сообщение
  • REPLY = 2 - ответ
  • EXCEPTION = 3 - в процессе выполнения произошла ошибка
  • ONEWAY = 4 - сообщение не требует ответа

Всё что не TMessage - полезная информация, которая обёрнута в структуру входящего сообщения.

Все представленные протоколы читают пришедший байтовый массив данных последовательно и хранят его текущий индекс, чтобы продолжить чтение с нужного места.

Поэтому наш алгоритм должен быть следующим:

  1. Прочитать TMessage
  2. Прочитать начало общей структуры сообщения
  3. Прочитать метаинформацию о первом поле в сообщении
  4. Запомнить текущую позицию в байтовом массив
  5. Прочитать информацию о токене
  6. Запомнить текушую позицию в байтовом массиве
  7. Обменять токен на данные о пользователе
  8. Сериализовать данные о пользовате
  9. Сформировать новый бинарный массив из трёх частей
    1. От начала исходного сообщения до индекса из пункта 4
    2. Байтовый массив структуры данных о пользователе
    3. От индекса из пункта 6 до конца оригинального сообщения

Пишем тест

Без тестирования в разведку не ходим, поэтому сначала пишем тест. Для теста нам понадобится следующие thrift-сервисы:

namespace java ru.aatarasoff.thrift

exception SomeException {  
    1: string code
}

service ExternalTestService {  
    SomeReturnData getSomeData(
        1: AuthToken authData,
        2: RequestData requestData
    ) throws (1: SomeException e);
}

service InternalTestService {  
    SomeReturnData getSomeData(
        1: UserData userData,
        2: RequestData requestData
    ) throws (1: SomeException e);
}

struct SomeReturnData {  
    1: string someStringField,
    2: i32 someIntField
}

struct RequestData {  
    1: string someStringField,
    2: i32 someIntField
}

struct AuthToken {  
    1: string token,
    2: i32 checksum
}

struct UserData {  
    1: string id
}

Создадим и заполним внешний сервис тестовыми данными:

TMemoryBuffer externalServiceBuffer = new TMemoryBufferWithLength(1024);

ExternalTestService.Client externalServiceClient  
= new ExternalTestService.Client(protocolFactory.getProtocol(externalServiceBuffer));

externalServiceClient.send_getSomeData(  
    new AuthToken().setToken("sometoken").setChecksum(128),
    new RequestData().setSomeStringField("somevalue").setSomeIntField(8)
);

TMemoryBufferWithLength - специально созданный класс, который который устраняет фатальный недостаток транспорта TMemoryBuffer. Последний не умеет отдавать настоящую длину сообщения. Вместо этого отдаёт длину всего буфера, которая обычно больше длины сообщения поскольку часть байтового массива резервируется под будущие данные.

Метод send_getSomeData сериализовывает сообщение в наш буфер.

Аналогичные действия сделаем и со внутренним сервисом:

internalServiceClient.send_getSomeData(  
  new UserData().setId("user1"),
  new RequestData().setSomeStringField("somevalue").setSomeIntField(8)
);

Получим байтовый массив нашего сообщения:

byte[] externalServiceMessage = Arrays.copyOf(  
    externalServiceBuffer.getArray(),
    externalServiceBuffer.length()
);

Введём класс, который будет транслировать наше сообщение из представления для внешнего сервиса в представление для внутреннего: MessageTransalator.

public MessageTransalator(TProtocolFactory protocolFactory, AuthTokenExchanger authTokenExchanger) {  
        this.protocolFactory = protocolFactory;
        this.authTokenExchanger = authTokenExchanger;
    }

public byte[] process(byte[] thriftBody) throws TException {  
    //some actions
}

Реализация обмена токена (AuthTokenExchanger) может быть разной в разных проектах, поэтому сделаем отдельный интерфейс:

public interface AuthTokenExchanger<T extends TBase, U extends  TBase> {  
    T createEmptyAuthToken();
    U process(T authToken) throws TException;
}

createEmptyAuthToken должен вернуть некий объект, который представляет пустой токен, который заполнит MessageTransalator. В методе process нужно реализовать обмен авторизационного токена на данные о пользователе. Для нашего теста у нас будет простая реализация:

@Override
public AuthToken createEmptyAuthToken() {  
    return new AuthToken();
}

@Override
public UserData process(AuthToken authToken) {  
    if ("sometoken".equals(authToken.getToken())) {
        return new UserData().setId("user1");
    }
    throw new RuntimeException("token is invalid");
}

Пишем проверку:

assert.assertTrue(  
    "Translated external message must be the same as internal message",
    Arrays.equals(
      new MessageTransalator(
          protocolFactory, 
          new AuthTokenExchanger<AuthToken, UserData>() {}
      ).process(externalServiceMessage),
      internalServiceMessage
    )
)

Запускаем тесты, и ничего не работает. Это хорошо!

Зеленый свет

Реализуем метод process согласно алгоритму:

TProtocol protocol = createProtocol(thriftBody);

int startPosition = findStartPosition(protocol);

TBase userData = authTokenExchanger.process(  
    extractAuthToken(protocol, authTokenExchanger.createEmptyAuthToken())
);

int endPosition = findEndPosition(protocol);

return  ArrayUtils.addAll(  
        ArrayUtils.addAll(
            getSkippedPart(protocol, startPosition),
            serializeUserData(protocolFactory, userData)
        ),
        getAfterTokenPart(protocol, endPosition, thriftBody.length)
);

В качестве протокола используем TMemoryInputTransport, который позволяет читать сообщение напрямую из переданного в него байтового массива.

private TProtocol createProtocol(byte[] thriftBody) {  
    return protocolFactory.getProtocol(new TMemoryInputTransport(thriftBody));
}

Реализуем нахождение границ токена в байтовом массиве:

private int findStartPosition(TProtocol protocol) throws TException {  
    skipMessageInfo(protocol); //пропускаем TMessage
    skipToFirstFieldData(protocol); //ищем начало данных в первом поле
    return protocol.getTransport().getBufferPosition();
}

private int findEndPosition(TProtocol protocol) throws TException {  
    return protocol.getTransport().getBufferPosition();
}

private void skipToFirstFieldData(TProtocol protocol) throws TException {  
    protocol.readStructBegin();
    protocol.readFieldBegin();
}

private void skipMessageInfo(TProtocol protocol) throws TException {  
    protocol.readMessageBegin();
}

Сериализуем пользовательские данные:

TMemoryBufferWithLength memoryBuffer = new TMemoryBufferWithLength(1024);  
TProtocol protocol = protocolFactory.getProtocol(memoryBuffer);

userData.write(protocol);

return Arrays.copyOf(memoryBuffer.getArray(), memoryBuffer.length());  

Запускаем тесты, и...

Включаем Шерлока

Итак тесты для Binary и Compact проходят, но JSON сопротивляется. Что же не так? Уходим в дебаг и смотрим, что какие же массивы мы сравниванием:

//JSON обычного человека
[1,"getSomeData",1,1,{"1":{"rec":{"1":{"str":"user1"}}},"2":{"rec":{"1":{"str":"somevalue"},"2":{"i32":8}}}}]

JSON курильщика  
[1,"getSomeData",1,1,{"1":{"rec"{"1":{"str":"user1"}}},"2":{"rec":{"1":{"str":"somevalue"},"2":{"i32":8}}}}]

Не заметили разницы? А она есть. После первого "rec" нет двоеточия. API используем один и тот же, а результат разный. Разгадка пришла только после внимательного чтения кода класса TJSONProtocol. В этом замечательном классе есть чудесное поле:

TJSONProtocol.JSONBaseContext context_ = new TJSONProtocol.JSONBaseContext();  

Этот самый контекст и хранит различные разделители в стеке, когда обходит JSON-структуру для чтения или записи. И когда мы читаем структуру, мы читаем и символ ":", а вот обратно он не возвращается, потому что контекста в самом объекте нет.

Вставляем костыль в метод seriaizeUserData:

if (protocol instanceof TJSONProtocol) {  
    memoryBuffer.write(COLON, 0, 1); //добавляем ":"
}

Запускаем тесты, и теперь то всё ок.

Резюме

Итак, мы сделали первую, но важную часть нашего gateway-я, теперь мы можем подменять содержимое сообщений для самых популярных протоколов, если эти сообщения придерживаются нашей конвенции.

Ссылки

Проект на GitHub-е: https://github.com/aatarasoff/thrift-api-gateway-core

Bintray: https://bintray.com/aatarasoff/maven/thrift-api-gateway-core/view

comments powered by Disqus