Раздел 27 · Gleam на практике

Конфиг и build_repo: финальная сборка первого блока

middle~35 мин

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

Конфиг и build_repo: финальная сборка первого блока

Десятая ката. Финал первого блока серии. У нас есть домен, два adapter под одним портом, HTTP, composition root. Не хватает одного: решения, какой adapter поднять, и откуда взять это решение. Сегодня закрываем дыру через типизированный конфиг и фабрику.

Сцена · последний незаданный вопрос

В 24-composition-root-http · Composition root на Wisp server.main дёргает wiring.build_repo(cfg) и получает готовый repo. Но что внутри build_repo? И откуда берётся cfg? Это последние два кубика, без которых приложение не запустится.

Решений два, и оба про дисциплину:

  1. Конфиг это тип, а не словарь строк. Прочитали env, распарсили в Config, дальше по коду ходит валидированное значение. Битый env это отказ при запуске, а не Nil посреди обработки запроса.
  2. Выбор adapter живёт в одной фабрике. build_repo(config) это единственное место, которое знает про существование двух конкретных реализаций. Появится Postgres, меняется только она.

Это финальный аккорд composition root: конфиг сверху, фабрика по центру, порт вниз.

Карта урока · что заберёшь через 2 часа

  • Заведёшь типизированный Config и распарсишь его из env через envoy, по принципу parse, don’t validate.
  • Соберёшь build_repo(config): фабрику, которая по DatabaseKind поднимает нужный adapter и возвращает его вместе с ресурсом для освобождения.
  • Поймёшь, зачем фабрика отдаёт не только repo, но и handle на ресурс (соединение, actor).
  • Закроешь первый блок: десять кат от Email до работающего сервера.

Концепт · конфиг как тип, parse don’t validate

Соблазн читать env там, где он нужен: envoy.get("PORT") посреди старта сервера. Беда в том, что тогда невалидный конфиг всплывает поздно и частями. Порт оказался не числом? Узнаешь, когда сервер уже наполовину поднялся.

Дисциплина из статьи parse, don’t validate: разбери весь env один раз при запуске в значение Config. Если что-то не так, верни Error и не запускайся. После парсинга по коду ходит Config, в котором port: Int уже валиден, а database это уже выбранный вариант, а не строка.

Это та же идея, что в 17-value-object-email · Value Object и Email с opaque-типами: после smart constructor невалидное значение собрать нельзя. Здесь smart constructor для всего приложения это from_env.

Языковые механики Gleam · envoy и Result-цепочка

envoy даёт envoy.get(name) -> Result(String, Nil). Дальше парсинг твой. Цепочка через result.try останавливается на первой ошибке:

pub fn from_env() -> Result(Config, ConfigError) {
  use database <- result.try(read_database())
  use port <- result.try(read_port())
  Ok(Config(database: database, port: port))
}

use x <- result.try(...) это та же use-цепочка, что в декодерах урока 24, но над Result: если read_database вернул Error, from_env сразу возвращает его, до read_port дело не дойдёт. Один отказ останавливает всю сборку конфига.

Задача · сигнатуры

Конфиг в config.gleam:

pub type DatabaseKind {
  InMemory
  Sqlite(path: String)
}

pub type Config {
  Config(database: DatabaseKind, port: Int)
}

pub type ConfigError {
  MissingVar(name: String)
  InvalidVar(name: String, reason: String)
  WiringFailed(reason: String)
}

pub fn from_env() -> Result(Config, ConfigError)

Фабрика в wiring.gleam:

pub type Resource {
  InMemoryResource(stop: Subject(in_memory_repo.Message))
  SqliteResource(connection: sqlight.Connection)
}

pub fn build_repo(cfg: Config) -> Result(#(ReservationRepo, Resource), String)
pub fn shutdown(resource: Resource) -> Nil

Контракт:

  • from_env: нет DATABASE это дефолт InMemory (удобно для разработки). PORT нет это 8000. PORT не число или вне 1..65535 это InvalidVar.
  • build_repo: по cfg.database поднимает нужный adapter. Для SQLite открывает соединение и накатывает миграцию перед возвратом.
  • build_repo возвращает пару repo и Resource. Resource это handle для shutdown (закрыть соединение, остановить actor).

Подсказки

  • DatabaseKind это ADT, а не строка. Это значит, что build_repo делает case cfg.database, и компилятор заставит покрыть все варианты. Добавишь Postgres, забудешь ветку, не скомпилируется.
  • Зачем Resource отдельно от repo: repo это контракт работы, Resource это владение ресурсом. Кто открыл соединение, тот его и закрывает. Repo про это знать не должен, потому он чистый интерфейс.
  • build_repo возвращает Result(..., String), а from_env возвращает Result(..., ConfigError). В server.main ошибку wiring заворачивают в config.WiringFailed(reason), чтобы наверх шёл один тип ошибки.
  • Для SQLite не забудь sqlite_repo.migrate(conn) между open и make. Без миграции первый load упадёт на отсутствии таблицы.

Разбор · фабрика build_repo

// examples/ddd-hotel/gleam/src/wiring.gleam

pub fn build_repo(cfg: Config) -> Result(#(ReservationRepo, Resource), String) {
  case cfg.database {
    InMemory ->
      case in_memory_repo.start() {
        Ok(#(repo, subject)) -> Ok(#(repo, InMemoryResource(stop: subject)))
        Error(_) -> Error("не удалось запустить in-memory actor")
      }
    Sqlite(path) ->
      case sqlight.open(path) {
        Error(error) -> Error("sqlite open: " <> error.message)
        Ok(conn) ->
          case sqlite_repo.migrate(conn) {
            Error(error) -> Error("sqlite migrate: " <> error.message)
            Ok(Nil) ->
              Ok(#(sqlite_repo.make(conn), SqliteResource(connection: conn)))
          }
      }
  }
}

Вся вариативность приложения сжата в один case. Это единственная функция, которая знает, что adapter-ов два и какие они. web/router.gleam, домен, тесты домена видят только ReservationRepo. Появится Postgres, добавится ветка Postgres(url) ->, и компилятор сам напомнит об этом через проверку полноты case.

shutdown симметричен: освобождает ресурс по его типу.

pub fn shutdown(resource: Resource) -> Nil {
  case resource {
    InMemoryResource(stop: _subject) -> Nil
    // OTP actor завершится сам по уходу владельца subject.
    SqliteResource(connection: conn) ->
      case sqlight.close(conn) {
        Ok(Nil) -> Nil
        Error(error) -> {
          let _ = string.inspect(error)
          Nil
        }
      }
  }
}

Разбор · парсинг конфига

// examples/ddd-hotel/gleam/src/config.gleam (фрагмент)

fn read_database() -> Result(DatabaseKind, ConfigError) {
  case envoy.get("DATABASE") {
    Error(Nil) -> Ok(InMemory)
    Ok(":memory:") -> Ok(InMemory)
    Ok("memory") -> Ok(InMemory)
    Ok(path) -> Ok(Sqlite(path: path))
  }
}

fn read_port() -> Result(Int, ConfigError) {
  case envoy.get("PORT") {
    Error(Nil) -> Ok(8000)
    Ok(value) ->
      case int.parse(value) {
        Ok(port) ->
          case port > 0 && port < 65_536 {
            True -> Ok(port)
            False -> Error(InvalidVar("PORT", "ожидается 1-65535"))
          }
        Error(Nil) -> Error(InvalidVar("PORT", "ожидается целое число"))
      }
  }
}

read_database превращает строку env в вариант ADT: пусто или memory это InMemory, любой другой путь это Sqlite(path). read_port парсит число и проверяет диапазон. После from_env по коду гуляет Config, в котором эти инварианты уже выполнены и проверять их заново не нужно.

Как это собирается вместе

Полная цепочка composition root, которую мы строили четыре урока:

env-переменные
   |  config.from_env()          (урок 26: parse, don't validate)
   v
Config(database, port)
   |  wiring.build_repo(cfg)      (урок 26: выбор adapter)
   v
#(ReservationRepo, Resource)
   |  router.make_app(repo)       (урок 24: инъекция в хендлер)
   v
fn(Request) -> Response
   |  mist.start(...)             (урок 24: точка входа)
   v
работающий HTTP-сервер

Каждая стрелка это переход на уровень ниже, и на каждом конкретика растворяется: env это строки, Config это типы, build_repo знает про adapter-ы, make_app видит уже только порт, домен под ним не видит вообще ничего инфраструктурного. Это онион в одном вертикальном разрезе.

Тест · build_repo собирает правильный adapter

// examples/ddd-hotel/gleam/test/wiring_test.gleam

pub fn build_sqlite_in_memory_works_test() {
  let cfg = Config(database: Sqlite(path: ":memory:"), port: 8000)
  let assert Ok(#(repo, resource)) = wiring.build_repo(cfg)

  let #(id, state) = fixture()
  let assert Ok(Nil) = repo.save(state)
  let assert Ok(loaded) = repo.load(id)
  loaded |> should.equal(state)

  wiring.shutdown(resource)
}

Тест собирает граф из Config, прогоняет save-load, освобождает ресурс. Симметричный тест с InMemory проверяет вторую ветку. Сам конфиг (from_env) тоже стоит покрыть, подменяя env, это ДЗ.

Критика · что не покрыто

  • secret_key_base захардкожен в server.gleam. Должен ехать из конфига и падать при запуске, если не задан в проде (ДЗ).
  • shutdown возвращает Nil. Ошибку закрытия соединения он глотает. В проде это Result, чтобы main мог залогировать и выйти с кодом (ДЗ).
  • Нет Postgres. Архитектура к нему готова: новый вариант DatabaseKind, новая ветка build_repo, новый adapter под тот же порт. Остальное не шевелится (ДЗ, заглушкой).
  • Конфиг плоский. Реальное приложение делит env по подсистемам (db, http, mail). Структуру конфига дробят, как только полей становится больше десятка.

Takeaway · и итог первого блока

Одна фраза:

Конфиг это тип, распарсенный из env один раз при запуске. Выбор adapter живёт в одной фабрике build_repo, и это единственное место, которое знает про конкретные реализации. Всё остальное видит только порт.

Десять кат позади. На руках: Value Object (Email, Money), Entity (Customer), Aggregate (Reservation как FSM), доменные события, Repository как порт, два adapter под ним, bounded context с границей по типу, HTTP через composition root, реальный SQLite и типизированный конфиг. Это полный тактический DDD на чистом ФП, без единого класса. Второй блок (event modeling, Decider, event sourcing, CQRS, saga) начинается с урока 27.

ДЗ

Дальше

Первый блок серии закрыт. Следующая ката открывает второй · 27. Event Modeling воркшоп. Отложим код и научимся проектировать систему на бумаге: свимлейн с командами, событиями, read-моделями и экранами. Это словарь, без которого Decider в уроке 28 бьёт в пустоту.

Параллельно полезно перечитать:

  • 16. Functional DDD intro, раздел про onion и North Star. Сегодня мы собрали онион целиком: env снаружи, домен в центре.
  • 24. Composition root на Wisp, там начало цепочки сборки, которую сегодня мы довели до env.