Closed7

A Tour of isowords: Part3 を読む

アイカワアイカワ

Introduction

このエピソードでは Swift で書かれているサーバーサイドについて見ていく

isowords ではサーバーは以下のような用途で使われている

  • クライアントがサーバーを認証することで、leaderboards に投稿されたスコアにユーザーを関連付けることができる
  • 世界中の人がプレイする毎日のランダムなチャレンジパズルを生成したり、複数回プレイしても不正ができないようにする作業も行っている
  • Push 通知の送信を担当している。現在は新しいデイリーチャレンジが利用可能になった時や、デイリーチャレンジが終了しそうでまだゲームを終えていない時に送信される
  • アプリ内課金を処理している。ゲームは無料でプレイできるが、何度かプレイしているうちに、開発努力への支援を促すために煩わしい interstitials を表示するようになる。サーバーはそのトランザクションを確認するために使用される

isowords のサーバーは、実験的に開発した Swift の Web ライブラリを使って、全て Swift で構築されている
Point-Free では、これらのコンセプトをゼロから構築することに時間を割きたいと考えているが、これらのトピックに深入りする前に Swift Evolution での concurrency の話が出るのを待っている

このコードベースのサーバー部分には、クライアントとサーバーの間でコードを共有する方法、サーバーと通信するための API クライアントをどのように設計したか、クライアントとサーバーの両方の integration tests をどのように書いたかなど、デモしてみたい非常にクールなことが沢山ある

アイカワアイカワ

Client-server debugging

isowords のサーバーをローカルで動かすことから始めていく

そのための bootstrap コマンドが以下になる

$ make bootstrap-server

このコマンドはローカルで Postgres が動作しているかどうかを確認し、動作していなければインストールするように指示する
インストールされていれば、開発やテストのためにマシン上にいくつかの isowords データベースを作成する

インストールが完了したら、Xcode で server target を選択し、cmd+Rを押してサーバーを起動できる
コンパイルが完了すると、コンソールにログが表示され、全てが稼働していることがわかる

⏳ Bootstrapping isowords...
  ⏳ Loading environment...
  ✅ Loaded!
  ⏳ Connecting to PostgreSQL
  ✅ Connected to PostgreSQL!
  -----------------------------
✅ isowords Bootstrapped!
Listening on 0.0.0.0:9876...

デフォルトでは、サーバーは 9786 ポートで動作しているのでブラウザで 0.0.0.0:9876 にアクセスすれば、isowords のページを見ることができる

サーバーを起動した状態でアプリを実行すると、実際にデータが表示されるようになる
何もしていない状態だと、デイリーチャレンジにまだ誰も参加していないことを UI は示す
これは、他の誰もアクセスしていないサーバーのローカルインスタンスを実行しているので、それほど驚くことではない

デイリーチャレンジの画面を掘り下げて、ゲームを開始しいくつかの単語をプレイして、強制的にゲームを終了させることができる
そして、ゲームオーバー画面での結果を得ることもできる
ここでも特に驚くことはなく、このローカル環境には他のプレイヤーがいないのでもちろん1位になる

このようなサーバーは確実に稼働しており、シミュレータはそのサーバーにアクセスしている
クライアントとサーバーの両方が Swift で書かれていることの面白い部分は、両方の Target を同時に実行させることができることである

つまりデバッグ可能な二つの実行ファイルが実行されているので、リクエストからレスポンスまでのライフサイクルのどの時点でもブレークポイントを置くことができる

例えば、isowords のゲームを終了した時、私たちは単にスコアを leaderborads に提出して記録を残すだけではない
そうすると不正なデータを送ることが容易になってしまう
代わりにパズル全体と、キューブ上で行った全ての動きを送信している
これには単語を見つけるためにプレーした文字や、キューブを取り除くためにダブルタップした回数などが含まれる

そしてサーバーはそのデータを検証し、ユーザーが正当なゲームをプレイしたかどうかを確認する
これはサーバーに渡されたユーザーの操作のリストを繰り返し確認し、その操作が可能であり有効な単語になっていることを確認することが行われる

iOS クライアントが leaderborads にゲームを送信した瞬間と、サーバーがペイロードを検証しようとした瞬間をライブでデバッグしたい場合は、いくつかのブレークポイントを設定することができる
Verification.swift にアクセスすると、検証を行う関数が用意されている
ここにブレークポイントを置き、iOS シミュレータで別のゲームをプレイして終了させると、ブレークポイントがトリガーされる

  public func verify(
    moves: Moves,
    playedOn puzzle: ArchivablePuzzle,
    isValidWord: (String) -> Bool
  ) -> VerifiedPuzzleResult? {
🔵  var puzzle = Puzzle(archivableCubes: puzzle)

  }

iOS アプリに関しては、まだ API リクエストの中間段階である
iOS クライアントがレスポンスを待っている間に、サーバーコードをライブステップで実行することができる
何度か繰り返してみると、verify move 関数に入ることができる
この関数では、一つの move が有効かどうかを検証する

この機能では、単語の中で選択された全ての面が一意であることを確認する作業を行う
これは一つのキューブの面を複数回使用して単語を形成することはできないからである

そしてそれが有効であることを確認する

  • 再生された単語に少なくとも三つの文字が含まれていることを確認する
  • その単語が辞書に載っているか
  • クライアントから送られてきたスコアが、locally に計算したスコアと一致すること
  • その単語が再生可能で、触れることのできる文字だけで構成されていること

同じ IDE で、クライアントとサーバーの両方を同時にライブデバッグできるのはとても素晴らしいことである

アイカワアイカワ

Sharing domain code

サーバーが起動し、シミュレータとサーバーが相互に通信している状態になったところで、この monorepo で実現した本当に素晴らしいことを紹介する

Swift でサーバーコードを書く主な理由は、クライアントとサーバーの間でコードを共有できるという期待があるからだろう
これは実際には難しいかもしれないが、絶対に可能である
Point-Free はサーバーとクライアントの間でかなり大きなコードの塊を共有することができ、潜在的な問題を早期に発見し、クライアントとサーバーをより簡単に同期させることができるようになり、自分たちのコードに自信を持つことができた

Package.swift に移動して、クライアントとサーバー間でモジュールを共有する方法を見てみよう

前述したように Package.swift はちょっと強烈である
Package.swift にはクライアントとサーバーの全てのモジュールが格納されていて、現在91個ある
このファイルは非常に長いが、標準的な SPM マニフェストとは少し異なる構造になっている
一番上の部分で、 package 変数が通常の let ではなく var として定義されていることから、何かが違うという最初ヒントが得られる

// MARK: - shared
var package = Package(
  ...
)

これはここで定義されている products がクライアントとサーバーの間で共有されるモジュールのみであるためである
以下の二つのセクションでは、この package 変数をさらに変更して、クライアントとサーバーの両方に追加の product や dependencies を追加している
実際に directory navigator で Package.swift の隣にある「No selection」リンクをクリックすると、ファイルの三つの主要セクションが表示される
今見ている shared コード専用の部分と、client、server 用のマーカーがある

client のマーカーをクリックすると、ここで TCA の Package に依存していることがわかる
また、たくさんの product や target が package に追加されていることもわかる

server マーカーに期待すると、AWS リクエストに署名するためのライブラリ、Postgres データベースを処理するためのライブラリ、実験的な Swift ウェブライブラリなど、さらにいくつかの dependencies が追加されている
また、サーバーやさまざまな cron ジョブを実行する実行ファイルや、leaderboards、デイリーチャレンジ、Apple の領収書の検証など、サーバーの特定の機能を公開する target など、いくつかの新しい product が package に追加されている

top に戻って、client と server の両方で共有されるコードを見てみよう

products: [
  .library(name: "Build", targets: ["Build"]),
  .library(name: "DictionaryClient", targets: ["DictionaryClient"]),
  .library(name: "DictionarySqliteClient", targets: ["DictionarySqliteClient"]),
  .library(name: "FirstPartyMocks", targets: ["FirstPartyMocks"]),
  .library(name: "PuzzleGen", targets: ["PuzzleGen"]),
  .library(name: "ServerConfig", targets: ["ServerConfig"]),
  .library(name: "ServerRouter", targets: ["ServerRouter"]),
  .library(name: "SharedModels", targets: ["SharedModels"]),
  .library(name: "Sqlite", targets: ["Sqlite"]),
  .library(name: "TestHelpers", targets: ["TestHelpers"]),
  .library(name: "XCTestDebugSupport", targets: ["XCTestDebugSupport"]),
],
  • Build は iOS アプリのビルド番号を記述するための interface といくつかの型を保持している
  • DictionaryClient はゲームが使用している基本的な辞書表現への interface で、DictionarySqliteClient は food の下で SQLite を使用してその interface を live で実装している
  • PuzzleGen はランダムにパズルを生成するコードで、英文字の分布を考慮して単語を見つけやすいパズルを生成する。約3年前にオープンソース化した swift-gen ライブラリを使用してランダム性を合成している
  • ServerRouter はサーバーに送られてくるリクエストを解析して、どのようなロジックを実行すればよいかを知るためのコードを保持している。例えば /api/leaderboards-scores/vocab という GET リクエストが来た場合、データベースに vocabrary の leaderboards を照会して、その結果を送り返す必要があることを理解する必要がある

さて、なぜこれらのモジュールが shared モジュールに含まれているのか不思議に思うかもしれない
結局のところ、これはリクエストを解析するための純粋なサーバーの問題のようである
なぜならサーバーが処理するために受信したリクエストを解析したい場合もあれば、サーバーに送信するためにリクエストを生成したい場合もあるからである
特に iOS クライアントでは、サーバーから実際にデータを読み込むために API リクエストを作成する必要がある

これらはコインの裏表のようなもので、両方のタスクを達成するためのコードは ServerRouter モジュールと呼ばれる一箇所に集約されている
このモジュールについては後ほど詳しく説明する

SharedModels モジュールには、client と server の両方にとって重要な型や関数がたくさん含まれている
例えば isowords パズルの基本的な定義やパズルのスコアを計算するための関数、client と server がお互いに通信するために使用されるモデルなどである

これらの shared モジュールの中でも特に興味深いのが、SharedModels と ServerRouter なので、それぞれについて詳しく見ていく

例えば SharedModels ディレクトリをブラウズすると、isowords でパズルとは何かを正確に定義するコアドメインタイプがたくさんある
まず CubuFace.swift ファイルを見てみよう
このファイルには Face を記述するデータタイプが入っている

public struct CubeFace: Codable, Equatable {
  public var letter: String
  public var side: Side
  public var useCount: Int

  ...

  public enum Side: Int, CaseIterable, Codable, Equatable, Hashable {
    case top = 0
    case left = 1
    case right = 2
  }
}

これは Face に書かれた文字と、それがどの面にあるか(上、左、右など)、そしてその Face が何回使われたかによって定義される

次にレベルを戻して、パズルの中の一つのキューブを定義するデータタイプを見るために Cube.swift ファイルにアクセスする

public struct Cube: Codable, Equatable {
  public var left: CubeFace
  public var right: CubeFace
  public var top: CubeFace
  public var wasRemoved: Bool

  ...
}

これは、左、右、上の三つの立方体の面と、立方体が取り除かれたかどうかを判断する Bool 値を保持する
これは立方体をダブルタップした時に起こる可能性がある

さらにレベルを下げると、isowords パズル全体のデータタイプを定義する Puzzle.swift ファイルがある

public typealias Puzzle = Three<Three<Three<Cube>>>

これは単純な typealias で、まだ説明していない Three 型を使用している
Three 型は、三つの要素を持つ配列の型安全版である
パズルを正確に3x3x3の立方体にしたいので、Three を三つのフィールドを持つ一般的な構造体として定義していた

struct Three<A> {
  let first, second, third: A
}

しかし、Swift のコンパイラにはバグがあるようで、この型を使用してリリース用にビルドするとクラッシュしてしまった
そこで要素を private 配列にまとめ、この型の構築やアクセスの方法を制御することで修正した

@dynamicMemberLookup
public struct Three<Element>: Sequence {
  ...
  private var rawValue: [Element]
  ...
}

型が明確に三つの値を保持していることをコンパイル時に証明できるほど理想的ではないが、これで十分である

もう一つのコアなゲームデータタイプは、Move.swift にある Move タイプである

public struct Move: Codable, Equatable {
  public var playedAt: Date
  public var playerIndex: PlayerIndex?
  public var reactions: [PlayerIndex: Reaction]?
  public var score: Int
  public var type: MoveType

  ...
}

これには Player の手を説明するのに必要な全てのデータが含まれている
例えば、プレイされたタイムスタンプ、Player の Player インデックス(これはマルチプレイヤーゲームでのみ重要)、スコア、一手のタイプなどである
手のタイプは、単語がプレーされたか、キューブが取り除かれたかのいずれかであるため、列挙型で記述される

public enum MoveType: Codable, Equatable {
  case playedWord([IndexedCubeFace])
  case removedCube(LatticePoint)
}

.playedWord の動きは、indexedCubeFace と呼ばれる一連のもので構成されている
これはパズルのキューブの面を識別する方法であり、パズルの立方体の面を識別する方法でもある
インデックスは0、1、2のいずれかのトリプレットである LatticePoint と呼ばれるもので構成されている

public struct LatticePoint: Codable, Equatable, Hashable {
  public enum Index: Int, CaseIterable, Codable, Comparable {
    case zero = 0
    case one = 1
    case two = 2
  }

  public var x: Index
  public var y: Index
  public var z: Index

  ...
}

そしてキューブの面の側面。これらの二つの情報により、キューブのどの面も一意に指すことができる
一方キューブを取り除く際には、これらの LatticePoint のうち一つだけが必要である
なぜならこれがキューブ全体を識別する方法だからである

このパズルではかなり負荷の高いドメインモデリングを行っている
このモデリングが可能な限り完璧であることを確認するために、特別な時間を費やした
なぜなら facade に漏れがあるとゲームのコアロジックがより複雑になるからである

しかし幸いなことに、このドメインモデリングを client と server で共有することができる
これらのタイプは全て iOS アプリと server の間で行き来され、client で得られるメリットは全て server でも同じように適用できる
server 側で leaderboards のスコアを検証するために書いたコードは、簡潔なデータ型を利用することでアルゴリズムをよりシンプルでわかりやすいものにしている
もしこのドメインモデリングを、client 用と server 用の2回行わなければならないとしたら、そして、それが異なる単語で行われるとしたらさらに悪いことである

アイカワアイカワ

Sharing logic

client と server の間でコードを共有することは、単にモデルを共有するだけではない
実際に機能や動作を共有することができる
このクールな例を二つ紹介する

一つ目の例はパズルの検証コードである
Verification.swift にアクセスすると、どんなパズルに対しても与えられた手が妥当なセットであるかどうかを検証するコードが表示される
つまりプレイされた単語がプレイされた時点で実際に可能であり、提出されたスコアが server 側で計算されたものと一致していることを意味する

このファイルのコードの素晴らしいところは、実際に server と client の両方で使用されていることである
バックエンドにスコアが送信された時に実行されるので、server でどのように使われているかは、ブレークポイントを入れた時に確認した

しかし、これらの検証機能は client 側でも使用している
GameCore.swift にアクセスしてみると、removeCube というメソッドがあることがわかる

mutating func removeCube(at index: LatticePoint, playedAt: Date) {
  let move = Move(
    playedAt: playedAt,
    playerIndex: self.turnBasedContext?.localPlayerIndex,
    reactions: nil,
    score: 0,
    type: .removedCube(index)
  )

  let result = verify(
    move: move,
    on: &self.cubes,
    isValidWord: { _ in false },
    previousMoves: self.moves
  )

  guard result != nil
  else { return }

  self.moves.append(move)
}

このメソッドは、パズルからキューブを取り除きたい時に呼び出される
まず、現在のパズルの状態に適用する Move を作成し、その Move とパズルを verify 関数で実行し、問題なければその Move を Move の配列に追加している

playSelectedWord は選択された単語を再生する時に呼び出されるが、これも同様である
playSelectedWord はパズルに適用する手を作成し、 verify 関数を実行して問題なければ、その手を moves 配列に追加し、効果音を再生する

このロジックを共有することで、client と server 間の検証コードが常に同期していることを確認できる
この検証ロジックを二つの場所で管理する必要はない
また、このロジックを一箇所に集約することで、isowords を立ち上げた当初にあった小さなバグも修正された
これは View コードの中に小さな競合状態があり、ユーザーが無効な一連の動きを作成できてしまうためであった
このロジックを共有することで、これらの問題は全て解決された

もう一つコードを共有する重要なポイントがある
それはパズルの生成である
client 側と server 側の両方でランダムなパズルを生成している
client 側では、ソロプレイやマルチプレイの際にパズルを生成し、server 側では、デイリーチャレンジの際にパズルを生成して、世界中の誰もが同じパズルをプレイできるようにしている

繰り返しになるが、パズルを生成するために必要なロジックを複製しなければならないとしたら、少し残念だが今はそうではない
PuzzleGen モジュールには client と server の両方で共有されるコードがあり、Point-Free で紹介し、約三年前にオープンソース化したライブラリを使用している
このライブラリは、ランダムという概念を snapshot testing 、parsing、architecutre など Point-Free で議論した他の概念と同様に composable な unit に変えるものである

English.swift ファイルを見てみると、英語のパズルを生成するのに必要なものがわかる
まず、ランダム性の基本である Gen 型の変換を行う
Three は三つのフィールドを持つ一般的な型で、Values の generator を Three values の generator に変換することができる

extension Gen {
  public var three: Gen<Three<Value>> {
    zip(self, self, self).map(Three.init)
  }
}

この定義に基づいて、ランダムなパズルのための generator を定義する
関数 randomCubes は引数として文字の generator を受け取る

public func randomCubes(for letter: Gen<String>) -> Gen<Puzzle> {
  ...
}

それは単純に英語の中から文字を均等に選んでいるわけではないからである
母音のように頻繁に表示される文字もあれば、Z や Q のようにあまり表示されない文字もあるはずである

これを実現するために、Gen のヘルパーを使っている
このヘルパーは、分布の表が与えられたときにたくさんの値の中からランダムに選択することができる
↓では英語の文字をどのように分布させるか示している

public let isowordsLetter = Gen.frequency(
  (16, .always("A")),
  (4, .always("B")),
  (6, .always("C")),
  (8, .always("D")),
  (24, .always("E")),
  (4, .always("F")),
  (5, .always("G")),
  (5, .always("H")),
  (13, .always("I")),
  (2, .always("J")),
  (2, .always("K")),
  (7, .always("L")),
  (6, .always("M")),
  (13, .always("N")),
  (15, .always("O")),
  (4, .always("P")),
  (2, .always("QU")),
  (13, .always("R")),
  (10, .always("S")),
  (15, .always("T")),
  (7, .always("U")),
  (3, .always("V")),
  (4, .always("W")),
  (2, .always("X")),
  (4, .always("Y")),
  (2, .always("Z"))
)

そこで、ある種の特殊な分布を持つランダムな文字を生成する方法を確立したら、すぐにその文字生成器を三つ用意する

zip(letter, letter, letter)

これにより、立方体の各面(上、左、右)に一つずつ、三つのランダムな文字が得られる
そして、この三つの文字をマッピングして Cube の値に埋め込んでいる

zip(letter, letter, letter)
  .map { left, right, top in
    Cube(
      left: .init(letter: left, side: .left),
      right: .init(letter: right, side: .right),
      top: .init(letter: top, side: .top)
    )
  }

これで、全てのフィールドが入力されたランダムな Cube の generator が得られる
これに先程の .three ヘルパーをかける

zip(letter, letter, letter)
  .map { left, right, top in
    Cube(
      left: .init(letter: left, side: .left),
      right: .init(letter: right, side: .right),
      top: .init(letter: top, side: .top)
    )
  }
  .three

これで三つのランダムな Cube のランダム generator ができた
次に再び .three ヘルパーを使っている

zip(letter, letter, letter)
  .map { left, right, top in
    Cube(
      left: .init(letter: left, side: .left),
      right: .init(letter: right, side: .right),
      top: .init(letter: top, side: .top)
    )
  }
  .three
  .three

これで 3x3 の格子状のランダムな Cube を生成することができる

そして最後に再び .three ヘルパーを使ってみると 3x3x3 の立方体のランダムなパズルの generator を与えることもできる

zip(letter, letter, letter)
  .map { left, right, top in
    Cube(
      left: .init(letter: left, side: .left),
      right: .init(letter: right, side: .right),
      top: .init(letter: top, side: .top)
    )
  }
  .three
  .three
  .three

この正確なコードは、ローカルでパズルを生成する iOS client とパズルを生成する server の両方で実行される
この二つは同時に文字の配布ロジックや、将来組み込むかもしれないその他のファンシーなものを共有する

また、この小さなコードは Point-Free で何度も実証してきたパターンを示している
一つの問題を解決する核となる atomic な unit のコンセプトを開発し、そのタイプの composition を探究することで、大きくてより複雑な問題をより小さな問題に分割して接着することができている
ここでは、ランダム性がこのような状況の一つであることを示している
また Snapshot testing、parsing(構文解析)、architecture でもこのような状況を示している

アイカワアイカワ

Sharing design patterns

基本的なデータタイプを共有できるだけではなく、それらのデータタイプ間のデータ変換を共有することができ、それによって多くの力を得ることができる
また iOS client で使用する一般的なデザインパターンを server でも共有することができる

たとてば iOS client では Tagged 型を愛用している
これは異なる意味を持つ同一のタイプを区別することができるからである
IOS client ではさまざまなモデルで Tagged 型を使用している

Move.swift を見てみると Tagged が move の Player インデックスを区別するために使われている
マルチプレイヤーゲームでは、GameCenter はプレイヤーの配列の中での位置によってプレイヤーを一意に識別している

public typealias PlayerIndex = Tagged<Move, Int>

このインデックスはアプリケーション全体のさまざまなアルゴリズムで使用される
例えば Player のスコアを計算するために使用されるが、単に整数をあちこちに渡していたら特定の整数が何を表しているかを覚えるのが難しくなってしまう
Player のインデックスにタグをつけることで、正しい使い方をすることができるようになった

タグ付けは、対応する Swift のバックエンドがない iOS client では非常に便利だが、Swift のバックエンドがある場合は、ただの裸の primitives な型でしかないものに意味的な意味を与えることがさらに重要になる

例えば Postgres データベースには、Player という概念がある

public struct Player: Codable, Equatable {
  public typealias Id = Tagged<Self, UUID>

ここでは Tagged 型を使用して、他の Postgres テーブルと ID を区別している

GameCenter には独自の Player の概念がある
GameKit の API を軽量化してテストをしやすくするために、「Designing Dependencies」シリーズのテクニックを採用しているが、この機会に型に Tagged を付けて、より意味的な意味を持たせるようにしている

public struct Player: Equatable {
  public typealias Id = Tagged<Self, String>

これはもう一つの Player タイプであるが、Posgress テーブルの行を表すのではなく、GameKit の GKPlayer を表している

これらの PlayerID に対応するデータタイプの Tagged を付けることで、二つのデータモデルをより明確に区別することができる
仮にこれらの ID を関数間で渡したとしても、その ID が何を表しているのかを見失うことはない
なぜならそれはデータ型に直接エンコードされているからである

isowords のコードベースには client と server の両方で、これまでに26種類のタグ付きエンティティがある
このパターンを両方で共有できるのは非常に素晴らしいことである

client と server の間で共有したいパターンがもう一つある
それは依存関係の設計方法である
これは Point-Free で何度も取り上げているトピックがだが、やりたいことは基本的な依存関係の interface だけのための軽量なラッパータイプを置き、実際の思い依存関係のバージョンの実装を別途作成することである

コードベースにはこれらの依存関係が数多く定義されている
sources ディレクトリを見てみると以下のようになっている

  • ApiClient と ApiClientLive のモジュールが分かれていて、軽量な interface のモジュールとヘビーウェイトな実装のモジュールというパターンになっている
  • AudioPlayerClient のラッパーもあるが、これは単に CoreAudio を使用しているだけでコンパイラのオーバーヘッドを増やすようなヘビー級の依存関係ではないので、個別のライブモジュールはない
  • StoreKit、GameCenter、UserNotifications などのシステムフレームワークにも同様のラッパーを用意している

これらの例は全て client サイドのものだが、server でも全く同じパターンを使用している

例えば SnsClient モジュールと SnsClientLive モジュールがあり、server では Amazon の Simple Notification Service を使って Push 通知を送信している
Push 通知を送るための interface はとてもシンプルである

public struct SnsClient {
  public var createPlatformEndpoint: (CreatePlatformRequest) -> EitherIO<Error, CreatePlatformEndpointResponse>
  public var deleteEndpoint: (EndpointArn) -> EitherIO<Error, DeleteEndpointResponse>
  public var publish: (_ targetArn: EndpointArn, _ payload: AnyEncodable) -> EitherIO<Error, PublishResponse>

これはそれぞれ以下を実現している

  • platform のエンドポイントを作成し、保存するための push token を送信する
  • そのエンドポイントを削除する
  • push 通知を発行する

このモジュールは非常に早くコンパイルできるが、ライブクライアントには追加の依存関係が発生する
リクエストの署名を気にしなければならないので、コンパイルが必要な別のモジュールのコードを呼び出す必要がある

import SwiftAWSSignatureV4

このライブ実装を interface から分離することで、ライブ依存のコストを発生させることなく、必要な時に SnsClient に依存することができ、機能追加の作業をより迅速に行うことができる
このコストが発生するのは、ライブサーバーを実行するときや、本番環境にデプロイする時だけである

Point-Free はこのスタイルを Postgres client にも採用している
DatabaseClient と DatabaseLive モジュールがあり、interface と実装を分離している
interface は各エンドポイントのフィールドを持つ単純な構造体である

public struct DatabaseClient {
  public var completeDailyChallenge:
    (DailyChallenge.Id, Player.Id) -> EitherIO<Error, DailyChallengePlay>
  public var createTodaysDailyChallenge:
    (CreateTodaysDailyChallengeRequest) -> EitherIO<Error, DailyChallenge>
  public var fetchActiveDailyChallengeArns: () -> EitherIO<Error, [DailyChallengeArn]>
  public var fetchAppleReceipt: (Player.Id) -> EitherIO<Error, AppleReceipt?>
  public var fetchDailyChallengeById: (DailyChallenge.Id) -> EitherIO<Error, DailyChallenge>
  ...

この interface はほとんどすぐにコンパイルできるが、本番の実装はそうはいかない
PsogresKit に依存し、Vapor に依存し、NIO に依存しており、コンパイルに時間がかかる
しかし、機能コードを書く際には、NIO や他のライブの依存関係をコンパイルするコストを気にするコオなく、安心して DatabaseClient に依存することができる
そのようなコストがかかるのは、server を稼働させたりデプロイしたりする時だけなのである

iOS アプリの開発をモジュール化して効率化するために使っている原則は、server にも当てはまる
軽量なものと重量のあるものを分離することは非常に強力で、アプリケーションの重量のある live な依存関係のコンパイル時のコストを気にすることなく、超高速のフィードバックループで機能を構築しテストすることができる

アイカワアイカワ

Next time: shared routing

これで client と server がどのようにコードを共有しているかが少し見えてきた
最も簡単に共有できるのは Puzzle 型や Move 型などのデータ型やモデル、そして verify 関数のようにデータを簡単に変換する純粋な関数である

これらは全て既にかなり強力なものだが、さらにクールなコードの塊が isowords では共有されている

server の routing システム全体と、client 側の API サービス全体が完全に統一されている
つまり server 上で受信したリクエストをを解析するコードは、iOS アプリの API クライアントがサーバーにネットワークリクエストを行う際に使用するコードと全く同じなのである
server に新しい route を追加すると、すぐにその route にリクエストを出すことができるようになる
server のコードを読んだり、同僚にリクエストの組み立て方を教えてもらう必要もない
また間違ったリクエストを作ることもできない
URL リクエストで誤ってスペルミスをしたり、kebab case にすべき URL パスに camel case を使ったり、POST リクエストすべきところを GET リクエストしたり、API クライアントを構築する際に起こりうる様々な問題をコンパイル時に保証してくれる

そのコードについては次のエピソードで説明される

このスクラップは2021/06/26にクローズされました