В прошлом уроке Почтамт открыл окошки для горожан. Горожанин приходит с улицы, с любым браузером, и видит вывеску. Для него 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 и кодогенерацию раздела про бэкенд.
Дальше
Утром Липвиг приходит раньше всех, и лицо у него белее конверта.
Книги, которые переживают перезапуск, ждут в следующем уроке.