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

Журнал без правил: архитектура и свои типы

middle-senior~30 мин

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

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

Сегодня три вопроса про устройство кода: где проходят границы сервисов линии, как запретить письму невозможные состояния и как объяснить линии, что t-02 и T-2 это одна башня.

Слайсы: код по делу, а не по слою

Раскладка, которую видишь в каждом втором проекте, режет код по слоям:

src/
  services/   letters.ts  towers.ts  journal.ts
  repos/      letters.ts  towers.ts
  handlers/   letters.ts  towers.ts
  types/      letter.ts   tower.ts

Чтобы поменять правило доставки, ты открываешь четыре папки. Папки отвечают на вопрос «каким механизмом это сделано», а приходишь ты с вопросом «где письма».

Слайс режет иначе, по делу:

src/
  letters/   letter.ts  step.ts  journal.ts  layer.ts  index.ts
  towers/    tower-id.ts  book.ts  layer.ts  index.ts
  mail/      handlers.ts  index.ts

Всё про письма лежит в letters/, а снаружи видно только то, что отдаёт index.ts: тип письма, сервис журнала и его слой. Правило доставки меняется в одной папке.

Сервисы и слои ты собирал с урока 47 по урок 49. Сколько их нарезать, решают три проверки:

  • Одна причина меняться. Журнал меняется, когда меняются правила писем. Книга башен, когда Магистраль меняет формат. Это два сервиса.
  • Подмена в тесте. Что тест подставляет, как слои в уроке 53, то просится в сервис. Книга башен ходит в сеть, её подменяют. Чистую функцию step подменять незачем.
  • Не сервис на функцию. Сервис с одним методом, который зовут из одного места, это лишняя бумага. Чистая функция в файле слайса проще.

step и есть сердце урока: правила письма, которым не нужен ни слой, ни эффект.

Флаги врут

Где тут дыра

interface Entry {
  delivered: boolean;
  returned: boolean;
  at?: string;
  reason?: string;
}

const onReturned = (entry: Entry, reason: string) => {
  entry.returned = true;
  entry.reason = reason;
};

const onDelivered = (entry: Entry, at: string) => {
  entry.delivered = true;
  entry.at = at;
};

На его примере всё верно: письмо вернулось, у записи returned: true и причина. Но T-5 прислала квитанцию с опозданием, уже после возврата, и onDelivered честно ставит второй флажок: delivered: true и returned: true сразу. Ровно та страница, которую принёс Пондер.

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

Состояния вместо флагов

Перевернём вопрос. Не «какие флажки у письма», а «в каком оно состоянии». Состояний четыре, и у каждого свои поля:

import { Data, type HashSet } from 'effect';

import type { TowerId } from '../towers';

export type Letter = Data.TaggedEnum<{
  Accepted: {};
  InTransit: {
    readonly at: TowerId;
    readonly path: HashSet.HashSet<TowerId>;
    readonly hops: number;
  };
  Delivered: { readonly at: TowerId; readonly hops: number };
  Returned: { readonly reason: string };
}>;
export const Letter = Data.taggedEnum<Letter>();

Data.TaggedEnum ты видел в уроке 44 на вердикте кадра. Доставленное письмо без башни не построить: у Delivered поле at обязательно. Доставленное и вернувшееся сразу не построить тем более: тег один. Двенадцать бессмысленных форм исчезли, потому что для них нет конструктора.

Письмо не меняется. Оно переходит в новое состояние по ходу:

export type Move = Data.TaggedEnum<{
  Hop: { readonly to: TowerId };
  Arrive: {};
  Fail: { readonly reason: string };
}>;
export const Move = Data.taggedEnum<Move>();

export class IllegalMove extends Data.TaggedError('IllegalMove')<{
  readonly from: Letter['_tag'];
  readonly move: Move['_tag'];
  readonly reason: string;
}> {}

Это машина состояний. Состояния, ходы и одна функция step(letter, move), которая отвечает новым письмом или IllegalMove. Таблица правил помещается на салфетку: из «принято» можно уйти в путь или вернуть, из «в пути» на следующую башню, к адресату или назад, из «доставлено» и «вернулось» никуда.

Файл на состояние

Пока состояний четыре, step пишется одним switch. Когда появятся «задержано» и «ждёт выкупа», switch разрастётся на экран, и правила разных состояний перемешаются. Поэтому машину кладут по файлу на состояние:

src/letters/
  letter.ts       Letter, Move, IllegalMove
  accepted.ts     ходы из «принято»
  in-transit.ts   ходы из «в пути»
  delivered.ts    ходы из «доставлено»
  returned.ts     ходы из «вернулось»
  step.ts         выбор файла по тегу

Каждый файл отвечает на один вопрос: что можно сделать с письмом в этом состоянии. Вот «принято»:

import { HashSet, Result, type Data } from 'effect';

import { IllegalMove, Letter, Move } from './letter';

type Accepted = Data.TaggedEnum.Value<Letter, 'Accepted'>;

export const fromAccepted = (letter: Accepted, move: Move): Result.Result<Letter, IllegalMove> =>
  Move.$match(move, {
    Hop: ({ to }) => Result.succeed(Letter.InTransit({ at: to, path: HashSet.make(to), hops: 1 })),
    Fail: ({ reason }) => Result.succeed(Letter.Returned({ reason })),
    Arrive: () =>
      Result.fail(
        new IllegalMove({ from: letter._tag, move: 'Arrive', reason: 'письмо ещё не в пути' }),
      ),
  });

А step.ts только выбирает файл:

export const step = (letter: Letter, move: Move): Result.Result<Letter, IllegalMove> =>
  Letter.$match(letter, {
    Accepted: (accepted) => fromAccepted(accepted, move),
    InTransit: (inTransit) => fromInTransit(inTransit, move),
    Delivered: (delivered) => fromDelivered(delivered, move),
    Returned: (returned) => fromReturned(returned, move),
  });

Разберём.

  • Data.TaggedEnum.Value<Letter, 'Accepted'> вырезает из объединения одно состояние. Функция fromAccepted не может по ошибке получить доставленное письмо: TypeScript не пустит.
  • $match требует все теги, как Match.exhaustive. Добавишь состояние Held, и step.ts перестанет собираться, пока не появится held.ts. Внутри файла состояния Move.$match так же требует все ходы.

Почему Result, а не Effect

step чистая: ни сети, ни времени, ни сервисов. Возвращает она Result, обычное значение: Result.succeed(письмо) или Result.fail(new IllegalMove(...)). Его проверяют в тесте без runPromise и складывают в цепочку Result.flatMap. Внутри эффекта ответ поднимают одной функцией:

const next = yield* Effect.fromResult(step(letter, move));

Успех даёт письмо, провал уходит в канал ошибок как IllegalMove. Правила остаются чистыми, а журнал, который пишет в базу, живёт в сервисе слайса.

Своя башня

Вторая беда журнала тише. Горожане пишут коды башен как придётся: t-02, T-2, T-002. Ринцвальд считал нагрузку в Map<string, number>, и у него вышло две башни T-2: с четырьмя письмами и с одним. Нормализовать строку можно, но про нормализацию должен помнить каждый, кто кладёт ключ в словарь. Кто-нибудь забудет.

Надёжнее сделать башню своим типом, у которого равенство значит то, что нужно линии. Пусть башня ещё и помнит, как её написал горожанин: это пригодится для ответа «вы писали t-02, это T-2».

import { Equal, Hash, Pipeable } from 'effect';

export class TowerId extends Pipeable.Class implements Equal.Equal {
  constructor(
    readonly number: number,
    readonly written: string,
  ) {
    super();
  }

  [Equal.symbol](that: unknown): boolean {
    return that instanceof TowerId && that.number === this.number;
  }

  [Hash.symbol](): number {
    return Hash.number(this.number);
  }

  toString(): string {
    return `T-${this.number}`;
  }
}

Три детали, и у каждой своя работа.

  • [Equal.symbol] решает, что две башни равны, если равны номера. Без него Effect 4 сравнил бы экземпляры по всем полям, как простые объекты из урока 44, и written сделал бы t-02 и T-2 разными башнями.
  • [Hash.symbol] считает хеш из того же номера. Хеш обязан смотреть ровно на то, на что смотрит равенство, и ни на что больше. Почему так строго, расскажет Пондер.
  • Pipeable.Class даёт метод pipe, как у всех типов Effect: tower.pipe(String) даёт 'T-2'.

Теперь коллекции Effect видят одну башню:

const seen = HashSet.make(new TowerId(2, 't-02'), new TowerId(2, 'T-2'), new TowerId(3, 'T-3'));
HashSet.size(seen); // 2
HashSet.has(seen, new TowerId(2, ' T-002')); // true

const load = HashMap.make([new TowerId(2, 't-02'), 4]);
HashMap.get(load, new TowerId(2, 'T-2')); // Option.some(4)

Машина состояний получает петли бесплатно: путь письма в InTransit это HashSet<TowerId>, и письмо, которое возвращается на t-02 после T-2, HashSet.has узнаёт.

Под капотом

Разминка

В src/tower-id.ts напиши TowerId.parse для кода в любом написании и равенство с хешем по номеру, в src/letter.ts step по таблице ходов из README (с петлёй через HashSet) и loadByTower.

Практика

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

Порты между ядром, хранилищем и сетью разобраны в уроке про гексагон, значения и агрегаты в уроке про DDD без фанатизма курса Python-бэкенда. Язык другой, мысль та же: правило живёт рядом с данными.

Дальше

К вечеру журнал линии переписан. Пондер пробует записать доставку после возврата и получает IllegalMove: «письмо уже вернулось». В дверь заглядывает почтмейстер.

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