Раздел 46 · Effect: Тихая Академия

Служебная линия: RPC

middle~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке Почтамт открыл окошки для горожан. Горожанин приходит с улицы, с любым браузером, и видит вывеску. Для него HTTP с путями, методами и статусами правильный язык: его понимают прокси, кэши, браузеры и любой curl.

У башни другая жизнь. Обе стороны линии наши, код на обеих концах один, типы одни. Ей не нужны пути и статусы, ей нужно вызвать функцию по ту сторону провода, как свою. Это называется RPC, remote procedure call. В Effect он живёт в модуле effect/rpc.

Вызов вместо адреса

Договор RPC это список вызовов. Каждый вызов это имя, схема аргумента, схема ответа и схема ошибки:

export class Handover extends Rpc.make('Handover', {
  payload: NewLetter,
  success: Letter,
  error: UnknownAddress,
}) {}

Сравни с эндпоинтом send из урока 91. Там было POST /letters, тело, статус 201 и ошибка 422. Здесь только имя и схемы: ни пути, ни метода, ни статуса. Схемы те же самые, NewLetter, Letter и UnknownAddress, потому что башня говорит о тех же письмах. Ошибка UnknownAddress в RPC теряет смысл статуса 422, но остаётся типом: башня узнает её по классу, как и горожанин.

Вызовы собирают в группу:

export class TowerLine extends RpcGroup.make(Handover, Mailbag) {}

TowerLine это служебная линия целиком. Про Mailbag ниже, это вызов другого рода.

Обработчики

Обработчики линии задаёт toLayer. Правило то же, что у окошек: сервисы берут при сборке слоя, а обработчикам отдают готовые объекты.

export const TowerLineHandlers = TowerLine.toLayer(
  Effect.gen(function* () {
    const letters = yield* Letters;
    const book = yield* TowerBook;
    return TowerLine.of({
      Handover: (letter) => Effect.andThen(checkAddress(book, letter.to), letters.send(letter)),
      Mailbag: ({ to }) => Stream.fromIterableEffect(letters.forAddress(to)),
    });
  }),
);
  • TowerLine.of({...}) проверяет, что у каждого вызова группы есть обработчик и что каждый отвечает по своей схеме. Забыл Mailbag, и не соберётся.
  • Handover получает уже разобранное письмо и делает ровно то же, что окошко send: проверяет адрес по книге башен и кладёт письмо в книгу. checkAddress и Letters из урока 92 работают под обеими дверями.
  • Слой даёт обработчики Handover и Mailbag и требует Letters | TowerBook.

Окошко и линия это два входа в одно здание. Почтамту всё равно, как пришло письмо: с улицы через HTTP или с башни через RPC.

Клиент линии

Со стороны башни линия выглядит как объект с функциями. В тесте его даёт RpcTest.makeClient:

export const tower = Effect.gen(function* () {
  const line = yield* RpcTest.makeClient(TowerLine);
  const letter = yield* line.Handover({ from: 'T-5', to: 'T-2', text: 'с линии' });
  const bag = yield* Stream.runCollect(line.Mailbag({ to: 'T-2' }));
  return { letter, bag };
});

line.Handover(...) это эффект с ответом Letter и ошибкой UnknownAddress, всё по схемам. RpcTest соединяет клиент с обработчиками напрямую, без провода, но через те же механизмы: запрос, ответ, отмена, куски потока идут настоящим путём клиента и сервера, только без кодирования в байты. Обработчики клиент берёт из окружения, поэтому тест подаёт TowerLineHandlers с книгой писем и книгой башен в памяти.

Поток по линии

Башне мало сдать письма. Ей надо забрать мешок: все письма, которые Почтамт держит для её адреса. Можно ответить массивом, а можно потоком:

export class Mailbag extends Rpc.make('Mailbag', {
  payload: { to: Schema.String },
  success: Letter,
  stream: true,
}) {}

С stream: true ответ вызова это не одно письмо, а Stream писем из урока 85. Обработчик возвращает поток, Stream.fromIterableEffect делает его из списка, который отдала книга. Книга в памяти пока отдаёт список целиком: поток бережёт провод и башню, а не книгу. Клиент получает тоже поток и тянет его, как привык: Stream.runCollect, Stream.take, Stream.runForEach.

И правда тянет. Поток по линии идёт кусками, и клиент, которому хватило трёх писем, пишет Stream.take(line.Mailbag({ to: 'T-2' }), 3). Сервер узнаёт, что поток больше не читают, и перестаёт слать. Не на третьем письме ровно: он успевает уйти вперёд на свой буфер, но не шлёт мешок до дна. Противодавление из урока 86 работает и через провод.

Где тут дыра

export class BigBag extends Rpc.make('BigBag', {
  payload: { to: Schema.String },
  success: Schema.Array(Letter),
}) {}

На его примере всё хорошо: у T-2 три письма, массив из трёх приходит одним ответом. Соседний пример: Липвиг неделю не отправлял письма на Пристань, у T-3 их одиннадцать тысяч. Массив сначала целиком собирается на сервере, целиком кодируется, целиком едет и целиком разбирается у башни. Первое письмо ждёт последнего. Башне, которой нужны были первые сто, всё равно приходит одиннадцать тысяч: в массив take(100) не напишешь, вызов уже ушёл с просьбой «всё». Ринцвальд кивает: «Как nightIntake с массивом ночи. Я опять ждал конца того, у чего его нет».

Под капотом

По проводу

В проде башня и Почтамт на разных машинах. Сервер линии вешается на тот же HttpRouter, что и окошки:

export const RpcRoute = RpcServer.layerHttp({ group: TowerLine, path: '/rpc', protocol: 'http' }).pipe(
  Layer.provide(TowerLineHandlers),
  Layer.provide(RpcSerialization.layerNdjson),
);

А башня собирает клиента с протоколом HTTP:

export const towerOverHttp = Effect.gen(function* () {
  const line = yield* RpcClient.make(TowerLine);
  return yield* line.Handover({ from: 'T-5', to: 'T-2', text: 'по проводу' });
}).pipe(
  Effect.scoped,
  Effect.provide(
    RpcClient.layerProtocolHttp({ url: 'https://pochtamt.ankmor/rpc' }).pipe(
      Layer.provide([RpcSerialization.layerNdjson, FetchHttpClient.layer]),
    ),
  ),
);

Код вызова не изменился: line.Handover(...), как в тесте. Поменялись только слои. Сериализация на обоих концах должна совпасть: NDJSON и там, и там. protocol: 'http' у сервера ставим явно: по умолчанию layerHttp ждёт веб-сокет.

Когда RPC, когда HTTP

Оба пути описывают вызов схемами, оба дают типизированного клиента, оба проверяются в памяти. Разница в том, кто на том конце.

HTTP API, когда на том конце чужие: горожане, браузеры, чужие сервисы, люди с curl. Им нужны пути, которые можно прочитать, статусы, которые понимают прокси и кэши, и вывеска OpenAPI. Договор публичный, меняется медленно, со старыми версиями.

RPC, когда оба конца твои и собираются из одного кода: башня и Почтамт, фронтенд и бэкенд одного приложения, воркер и главный поток. Тут пути и статусы это лишняя бумага, а потоки, отмена и много мелких вызовов по одному проводу это то, что нужно.

Почтамт выбирает оба: окошки на HTTP для города, линия на RPC для башен, а за ними одни Letters и одна checkAddress.

Нестабильно. Модуль effect/rpc в 4.1 помечен @stability unstable, включая Rpc, RpcGroup, RpcServer, RpcClient и RpcTest: имена и опции могут поменяться в минорной версии. Урок написан на 4.1.0.

Почтамт, этап 4

Почтамт заводит служебную линию. В src/rpc.ts два вызова, Handover и потоковый Mailbag, их группа TowerLine и обработчики. Тесты зовут линию клиентом RpcTest: письмо от башни ложится в ту же книгу, что и письмо из окошка, чужой адрес даёт ту же UnknownAddress, а мешок приходит потоком, по порядку и без чужих писем.

Практика

Глубже в архиве

Как RPC выглядит за пределами Effect, gRPC, GraphQL и кодогенерация по схеме, разобрано в уроке про GraphQL, gRPC и кодогенерацию раздела про бэкенд.

Дальше

Утром Липвиг приходит раньше всех, и лицо у него белее конверта.

Книги, которые переживают перезапуск, ждут в следующем уроке.