Swift
Vapor

実案件でVaporを採用したときの知見まとめ

仕事でAPI・CMSサーバーをそれぞれVaporで作る機会がありました。
その際に蓄積されたノウハウや、VaporやSwiftでサーバーを書くことについて感じたことをまとめて記事にしました。

前提

本記事の想定読者

本記事の想定する読者はマニュアルを読んでサンプルを触ってみて、じゃあもう少し規模の大きいものを……と思っているような方です。特にマニュアルに載っていないが実用上では必要になるような知識を埋めることを目指します。
基本的な事項はマニュアルのリンクを貼っていきますので、適宜参照してください。

Vaporの基本的な特徴や長所をお求めの方は公式を御覧ください(Vapor3かつ適当にまとまった記事が見つからなかったので)。
https://vapor.codes/

案件の概要

  • 受託スマートフォンアプリのためのJSONベースのAPIサービスとHTMLベースのCMSサービス 機能数的には小規模
  • デプロイ環境は一般公開向けの本番用、お客様向けの確認用、内部の開発者用と複数ある
  • デプロイ先のサービスバージョンに従ってデータベース定義がずれているので、その更新作業は自動化したい
  • 今回の着手以前に作られたプロトタイプ版が稼働していたが、メンテナンス性を考慮してVaporへの移行となった
  • プロトタイプ版のテーブル定義やデータを破損することなく移行したい

着手以前の私のスキル

Swift歴は4年ありますが、サーバーサイドはプライベートでPHPやNode.jsを触っていたくらいで、仕事で取り組むのは初めてでした。
SwiftNIOの早期導入等に感化されてVaporありきで始めたため、Kituraや他のメジャーなフレームワークとの比較等はできませんので記事でも触れません。

TL;DR

項目が多岐に渡るため、それぞれは各論として先に総評を提示します。

Vapor開発に興味を持つ人の多くは、Swiftで開発できることに魅力を感じていると思います。この点については完全に当たりでした。
今回はiOSアプリと連携するため、APIの型やユーティリティを共有するようにしたのですが、これによりサーバー・クライアント間で定義の食い違いによって起こる問題を排除できました。またVaporのソースコードを読みに行く必要がしばしばあったのですが、その際もSwiftの可読性の高さに助けられました。型が明確なので今自分のしたいことのためにどの機能を調べれば良いかということが容易に分かります。

逆にもともとSwiftに慣れていない人にとっては、必要となる知識が少々多いかもしれません。Vapor自体の知識の他に、言語自体の知識、SwiftPMの知識が必要になり、特に後者は踏み込んでいくと厄介です。
Vapor自体も2系から大きく変わった3系のリリースが最近なので、古い情報が参考にならないことが多いです。そのためソースコードを読みに行くのが一番良いという事になりがちなので、ある程度Swift慣れしていないと開発効率が落ちてしまいます。

デプロイ先はLinux環境が多いと思いますが、Appleデバイスではいつも使っているフレームワークがLinux環境では使えなかったり、クロスプラットフォームのFoundationでもLinuxでは未実装の関数があったりしました。
Vapor導入を決める前に必要な機能の洗い出しを十分に行い、開発着手後はデプロイ先と同じ環境で随時テストできる環境を早いうちから用意するのが望ましいです。

以上、短所のほうが多いのでは?という気がしますが、Swiftユーザー的にはSwiftを使えることによる開発効率向上が目覚ましいので、少々の短所は塗りつぶしてしまいます。
Swiftを使ったことがないという人もサンプルから入ってみて、Swiftの魅力に取り憑かれたら苦よりも楽が多い状態で開発を進めていけると思います。

では各機能については以下各論に譲ります。

コア機能

Vaporは数多くのSwiftパッケージで構成されています。最初にvapor updateで依存関係を取得する際にはその量に圧倒されるかもしれませんが、直接触る必要がある部分はそこまで多くないです。
この項ではどの機能を使うにしても必ず出てくるVapor及びCoreの内容を扱います。

Future

Vaporはネットワーク機能の実装のため、SwiftNIOを下請けとして用いています。
SwiftNIOはNon-Blocking IOを扱うためにFutureパターンを採用しており、VaporのAPIもほとんどがFutureを通して扱うようになっています。

基本的な使い方はドキュメントに書いてあるので、そこで学ぶのが良いです。
ドキュメントに書いてあること以外で、出現頻度が高いのに記述が難しかったり、毎度書き下すには長かったりするような用途については、いくつかユーティリティ関数が用意されていますので以下に紹介します。

複数のFutureの結果を待ち合わせて何かしたい

トップレベル関数のmap/flatMapが使えます。

以下HTTPリクエストを送るclient.get(_ url: URL) -> Future<Response>を例にとって説明します。

map(to: Void.self, client.get(url1), client.get(url2)) { res1, res2 in 
    // 両方が完了した段階で実行
    print("\(res1), \(res2)") // それぞれの結果出力
}

2つ以上のFutureを引数にとり、全ての終了を待って、それらの結果を引数にとるクロージャを実行します。
Futureの返す型はそれぞれ自由になっているので、Future<Int>Future<String>を渡すようなこともできます。

Colelction<Future<T>>の結果を待ってFuture<Colelction<T>>にしたい

flatten及びmap/flatMapが使えます。

let urls: [URL] = ...
// 全URLに対しリクエストを発火する
let requests: [Future<Response>] = urls.map { client.get($0) }
// 全リクエストの終了を待ち、`[Response]`に変換する
let flat: Future<[Response]> = requests.flatten(on: worker)

[Future<T>]を使うため型が統一されている必要があります。
[Response]として得られる順番はもとのurlsの順番通りになるのですが、Futureの処理順は分からないような実装になっているため、呼び出し時にworkerを渡す必要があります。workerEventLoopへの参照を持っており、最終的に得られるFutureがどのEventLoopに所属するかを指定するのに使います。

その他注意点として、Futureは作られた瞬間処理を開始するため、urlsが100件のURLを持っていたら100件のリクエストが同時に始まってしまいます。
直列に実行したい場合については次の項で説明します。

コレクションの各要素について直列に非同期処理を行いたい

typealias LazyFuture<T> = () throws -> Future<T>が用意されており、[LazyFuture<Void>]に対してsyncFlattenが使えます。

let urls: [URL] = ...
// 全URLに対し`LazyFuture`を作成する
let requests: [LazyFuture<Void>] = urls.map { url in { client.get(url).transform(to: ()) } }
let allEnd: Future<Void> = requests.syncFlatten(on: worker)

LazyFutureは評価されるまでFutureが作られないので、実行開始が遅延されます。
この例だとrequests[0]を評価して得られたFutureを待ち、終わったらrequests[1]を評価して得られたFutureを待ち……というように続いていきます。
シグネチャを見れば分かりますが、なぜか[LazyFuture<Void>]にしか用意されていないので、[LazyFuture<T>]で使いたい場合は自分で書く必要があります。

基本的な部品はCore/Asyncにまとまっており、量も少ないので目を通しておくと良いかもしれません。私は読まずに突貫したのでこれらの再発明をしてしまいました。

Futureパターンによる記述は、部品が揃っているとはいえ同期的なコードと比べるとどうしても複雑になりがちです
現在Swiftにasync/awaitを導入しようという議論が進んでおり、これが導入されるとFutureパターンを同期的なコードと同じような構文で書けるため、Vapor開発においては恩恵が大きいです。
Swiftにおけるasync/awaitについては以下の資料が詳しいです。

サービスとコンテナ

VaporフレームワークではDependency InjectionのためにDIコンテナを用いており、Serviceプロトコルに準拠したインスタンスをContainerプロトコルに準拠したコンテナから取り出すというコードが頻出します。

コンテナとして一番頻繁に使うのはRequestです。RequestはHTTPリクエストが届くたびに生成されるオブジェクトですが、実際のコンテナアクセスは共有のものに委譲されています。
取り出すサービスはコンテナにキャッシュされるため、次に別のリクエストが来て同じサービスを取り出す場合にも効率的に動作します。
またこの仕組みによってDatabaseConnectionPool等複数のリクエストに跨って作用するタイプのサービスも上手く共有できるようになっています。

コンテナアクセスの委譲はContainerAliasというプロトコルで実現されており、Requestの場合はEventLoop単位で存在するコンテナに委譲されています。

一方で、認証情報等のようにリクエスト固有のものの扱いには注意が必要です。
例えば後述するミドルウェアで認証情報を確認し、それをRequestに付加して続きの処理に進むというコードを書きました。
この認証情報をrequestコンテナに入れると他人のリクエストの認証情報と混ざってしまうことになります。
そのような個々のリクエスト固有のものについてはRequest.privateContainerというのがあるので、こちらに入れるようにします。

自分で作ったサービスを使いたい場合、configure.swiftにあるようにServicesに登録しておく必要があります。

ミドルウェア

ミドルウェアのドキュメントは現在のところComing soonとなっています。

ミドルウェアは個別のリクエストハンドラの前後で何か処理をしたいときに用います。
Middlewareプロトコルは以下です。

public protocol Middleware {
    func respond(to request: Request, chainingTo next: Responder) throws -> Future<Response>
}

https://github.com/vapor/vapor/blob/10dd1d8772c855c9d9afa327d850c55918eefb84/Sources/Vapor/Middleware/Middleware.swift#L1-L15

Requestを使った処理を行う場合引数のrequestを、Responseを使う場合next.respond(to: Request)で得られるFuture<Response>を用います。またnext.respondthrowsで、各ルートで発生するエラーを一括で捉えるのにも使えます。

Middlewareはアプリケーション全体か、特定のルート群に対して登録できます。

まず全体への登録ですが、configure.swift内でMiddlewaresに登録していきます。
第一にサンプルでも用いられているMiddlewareConfig.use(Middleware.Type)で登録する方法があります。この方法で登録する場合、必要になった時点でミドルウェアはインスタンス化されますが、そのインスタンス化はサービスの流儀に則って行われるため、ミドルウェアをServiceに準拠させてServicesにも登録する必要があります。
テンプレートに入っているErrorMiddlewareなどは別の場所で登録されているので、真似して書いて動かなくて少し詰まりました。
インスタンスを直接渡すMiddlewareConfig.use(Middleware)もあって、こちらはServiceへの準拠が不要なのですが、コメントにあるようにインスタンスがスレッド間で共有されるので、スレッドセーフであることを確認する必要があります。

特定のルート群のみに適用したい場合、Routergrouped(Middleware.Type) -> Routergrouped(Middleware...) -> Routerというメソッドを持っており、これらで得られるRouterに登録してやったルートではミドルウェアが適用されます。
例えばログインAPIとログイン後にだけ使えるAPIを分けたい場合に、AuthUserMiddlewareを使うとすると、routes.swiftの記述は以下のようになります。

public func routes(_ router: Router) throws {
    // 認証なし
    router.post("login", use: loginHandler)

    // ミドルウェアで認証要求
    let authed = router.grouped(AuthUserMiddleware.self)

    authed.get("user/info", user: userInfoHandler)
    authed.post("user/edit", user: userEditHandler)
    ...
}

DB周り

Vaporはデータベースを簡単に扱うためFluentというライブラリを提供しています。
Fluentはデータベースエンジンごとに実装が別れていますが、今回利用するのはMySQLだったのでFluentMySQLを使いました。

マニュアルのGetting Startedに従って、必要なパッケージをPackage.swiftに追加することで使用できるようになります。

モデル

Fluentでデータを読み書きするため、各テーブルに対応するModelプロトコルに適合した型を定義します。
Modelプロトコルに適合することによって対応するテーブル名やID用のフィールドの実装が要求され、後述するQueryBuilderと組み合わせることによって、自分でSQLを書かずにクエリを発行することができます。

ドキュメントではモデルをclassで書いてありますが、私は全部structで書きました。値型を用いることによりオブジェクトの共有で発生しがちな問題を回避することができるので、私はSwiftを使う上では積極的にstructを採用するようにしています。

structを使う場合注意点があり、ドキュメントのConformanceの項に以下のように書いてあります。

however you must pay special attention to Fluent's return types if you use a struct. Since Fluent works asynchronously, any mutations to a value-type (struct) model must return a new copy of the model as a future result.

Fluentは非同期に働くので、structだとクエリを呼び出す元のモデルインスタンスを書き換えることができないため、代わりにコピーを返すと書いてあります。

一例としては、FluentではINSERTしたいときはidnilに設定したモデルインスタンスのsaveを呼び出すのですが、INSERTしてどのidが振られたかはsaveの結果返ってくるモデルに設定されています。structの場合saveを呼び出すモデルインスタンスとsaveの結果返ってくるモデルインスタンスが別物になるので、save後にidを使った処理をしたい場合は結果のほうのインスタンスを使う必要があります。
とはいえstructはそういうものなので、日頃からstructを活用しているSwiftユーザーなら別に気にならないかと思います。

クエリビルダ

モデル型自体に用意されたメソッド群で単純なSELECT, INSERTなどはできますが、更に複雑なクエリを発行したい場合QueryBuilderを用いることでタイプセーフに記述することができます。
https://docs.vapor.codes/3.0/fluent/querying/

生クエリを書くと、単純にクエリの構文を間違って動かないことがあるだけでなく、テーブル定義を更新した際にはそれに伴う修正を目視で確認する必要があります。
一方モデルとQueryBuilderを使う場合、テーブル更新に従ってモデルだけ更新すれば修正が必要な箇所はコンパイルエラーとして現れるので、こちらを用いていくことでメンテナンス性が向上します。

ドキュメントで書かれている分で多くのケースは事足りるのですが、更に複雑なクエリを発行したいとなると対応しきれないということが出てきました。

GROUP BYとCOUNT

次のクエリはあるusersテーブルに登録されたユーザー全員と、各ユーザーが投稿したpostsテーブルのレコード数を合わせて取得するものです。
このようなクエリはどうもQueryBuilderでは実現できないようでした。

SELECT users.*, IFNULL(posts.count, 0) FROM users
LEFT JOIN (SELECT user_id, COUNT(*) AS count FROM posts GROUP BY user_id) AS posts ON users.id = posts.user_id;

上クエリではGROUP BY及びCOUNTが必要になるのですが、QueryBuilderのメソッドgroupByを呼び出した後にどうやって積算させるようにするのかがわかりませんでした。
(マニュアルにはgroupBy自体が載っていないので、恐らくGROUP BYクエリの部分だけ実装されてその先は未実装という状態なのだと思っています。)

各ユーザーの投稿数を知りたいだけならcountというメソッドがあるので、それをユーザーIDごとに呼び出せば得られます。
しかしユーザー数だけクエリを叩く必要があるので、いわゆるN+1問題になってしまいます。

このようなクエリをQueryBuilderで実行するため、データベース側にビューを作成するようにしました。
CREATE VIEW文で作成するビューはあるクエリの結果をテーブルのように扱う機能です。
https://dev.mysql.com/doc/refman/5.6/ja/create-view.html

ビューからデータを取得するのにはテーブルと同じクエリを用いることができるため、ここで作ったビューに対応するモデルを定義してやれば、QueryBuilderが発行できるクエリの範囲で必要なデータを取得できます。

ビューに対するINSERTDELETEはできないのですが、モデルがテーブルを前提としている都合上savedeleteが生えてしまうのがちょっと気になるところです。

HAVING

HAVING句を用いたフィルタリング処理が必要になりました。

SELECT *, ABS(weight - ?) AS weight_diff FROM users HAVING weight_diff > 10 ORDER BY weight_diff;

これはユーザーの体重weightとある値(プレースホルダの?)の差weight_diffを計算し、その差が10を超えるユーザーを、差の昇順で取得するというクエリです。
weight_diffは再利用されるためカラムにすることで計算の記述を一度にしていますが、その場合WHERE句を用いたフィルタリングができず、代わりにHAVING句を使うことができます。
https://stackoverflow.com/questions/16068662/calculated-column-in-where-clause-performance/16068737#16068737

このクエリで問題になるのはweight_diffの計算に外部からの入力値が必要なことで、これによりビューを作って対応ということができません。
このケースはどうしようもなさそうだったので、仕方なく生クエリで書くことにしました。

生クエリからのデコードは以下のように簡単にできるようになっています。

let conn: MySQLConnection
return conn.raw("SELECT @@version as version")
            .all(decoding: MySQLVersion.self)

https://docs.vapor.codes/3.0/mysql/getting-started/#query

デコード部分のカスタマイズ

自分の話ではないですが、Discordで以下のような話を見かけました。

  • 同じテーブルを二重でJOINしたい
  • LEFT JOINを使っていて対応する行が存在しない可能性がある

モデルにデコードする前の状態の行を取得する方法として、QueryBuilder.decodeRaw()や、生クエリを発行するMySQLConnection.raw(_:)というのがあります。MySQLの場合だと[[MySQLColumn: MySQLData]]という形で取得できます。
これらをMySQLConnectiondecodeメソッドを用いて自分でデコードするという手段が取れます。

以下の例はMessageに、その送信元、送信先のPersonを二重にJOINして取得するコードです。

let conn: MySQLConnection
conn.raw("""
    SELECT * FROM messages
    JOIN persons AS from_persons ON messages.from_person_id = from_persons.id
    JOIN persons AS to_persons ON messages.to_person_id = to_persons.id
    """).all().map { rows in
        try rows.map { row -> (Message, Person, Person) in
            let msg = try conn.decode(Message.self, from: row, table: "messages")
            let from = try conn.decode(Person.self, from: row, table: "from_persons")
            let to = try conn.decode(Person.self, from: row, table: "to_persons")
            return (msg, from, to)
        }
}

https://github.com/t-ae/fluent-example/blob/sqlite-decoder-bug/Tests/AppTests/AppTests.swift#L96-L107
(上リポジトリはSQLiteとMySQLでふるまいが違うことを報告するために作ったもので、SQLiteでは同じコードが正しく動かないです。)

もちろんビューを作っても対応できますが、必要な箇所のたびに作成しているとビューとモデルが増えて管理が煩雑になるので、こういう方法もあるんだと覚えておくと役立つと思います。

マイグレーション

データベースマイグレーションとはデータベースのセットアップやアップデートを自動で管理できるようにする機能です。私は今回はじめて使いました。
データベースの変更をパッチとして蓄積していき、サーバーの立ち上げ時に現在のDBのバージョンから必要なパッチを順に適用してくれます。

マイグレーションについてはドキュメントが揃ってます。
https://docs.vapor.codes/3.0/fluent/migrations/

ここに書いてあるのはテーブル定義の変更までですが、以下のようにやりたいことがいろいろ出てきました。

  • NullableなカラムをNOT NULLにしつつNULLだったものには特定値を埋める
  • UNIQUE制約の追加
  • Viewを作る

単純にテーブルにデフォルトのデータをインサートしたいというような場合にも、モデルの型を使ってsaveするのは不適ではないかと思っています。
モデル定義の方は常に最新のものになって行くので、インサート後にテーブル定義変更のマイグレーションが続く場合、インサートするマイグレーションの時点ではモデルとテーブルの定義が不一致になってしまうためです。またモデルのシグネチャが変化するとそれを以前から使っているマイグレーションにも変化が及び、デプロイのタイミングによってテーブル構造が変化してしますことも考えられます。

Fluentのモデルとマイグレーションを協調させるのは相性が悪そうだったので、今回はマイグレーションについては全て生クエリで管理することにしました。
ドキュメントの記述は単一のデプロイ環境で作業することを想定しているようで、私のようにデプロイ環境が複数あり、テーブル定義のバージョンがずれている場合については考慮されていないような印象を感じました。テストコードも各fluent-*リポジトリにあるのですが、ドキュメントと同じような内容しかないので、もっと様々なケースについてのドキュメントやサンプルがほしいところです。

ちなみにマイグレーションの実行管理は、管理用にfluentというテーブルが作られて、そこに実行済みマイグレーションの行が記録されていきます。
マイグレーションが間違っていたときは手で変更を戻して、fluentテーブルに記録された行を削除して再度立ち上げという手順を取っていました。

既成DBの移行

私の着手時点で(Vaporとは関係なくマイグレーションもない)プロトタイプ版のテーブルが存在し、データもいくらか入っていました。
これらのデータを消さずに引き継ぐ必要がありました。

一方でプロトタイプ版が動いていないまっさらなサーバーにおいても、起動時にはデータベースを構築するようにしなければなりません。

このように開始点が2種類あり、どちらもVaporサーバーを起動した段階では同じ状態になっているように構築する必要があります。
これを実現するのには少し工夫が必要でした。

今の問題はプロトタイプ版のDBと新規のDBで定義が異なっていることなので、この差分を埋めるマイグレーションを用意します。一旦同一の状態に持っていければ、以降のマイグレーションは共通のものとして書いていけます。
具体的には、プロトタイプ版のテーブル構造をSQLとしてダンプし、それをそのまま実行するマイグレーションを作りました。これを実行することにより、新規DBをプロトタイプ版とおなじ状態に持っていけます。

一方で、この差分を埋めるマイグレーションはプロトタイプ版が構築済みのDBには作用しないようにする必要があります。
私は前述のfluentテーブルを手動で編集し、このマイグレーションは実行済みであるという扱いにしました。これを行うことでプロトタイプ版が動いていた環境では該当のマイグレーションがスキップされ、結果としてまっさらな状態から始めたのとおなじ状態になります。

このスキップの設定はプロトタイプ版が動いているサーバー全てに行う必要がありますが、動いているサーバーが少数でありこれ以上増える予定もなかったため、今回は手動で済ませました。
大量にある場合はテーブルの存在判定を入れて、マイグレーションが自身を実行するかどうか分岐するようなコードを書くという手段も取れそうです。

ところで、MySQLには複数のクエリを一度の通信で行わせるために、CLIENT_MULTI_STATEMENTSという機能が存在します。
最初はこれを使って、ダンプしたSQLを一つのStringとして実行させようとしていたのですが、実際にやってみたところデコーダー周りが対応していないようでクラッシュしてしまいました。この件についてはIssueを立てており、将来的にサポート予定とのことです。
https://github.com/vapor/mysql/issues/178
現在のところクエリを一つ一つ分割して、順に実行するという手順が必要になります。

Automatic Model Migration

Vaporにはマイグレーションをモデル自身に適合させるAutomatic Model Migrationという機能もあります。
これはモデルの定義から自動的に適切なテーブルを作成するマイグレーションを生成してくれる機能です。

一見便利な機能ですが、ドキュメントには以下のように書かれています。

This method is especially useful for quick prototyping and simple setups. For most other situations you should consider creating a normal, custom migration.

プロトタイピングに便利だが、多くの場合マイグレーションを自分で書くCustom migrationsを使うことを考えるべきだと示されています。
開発の上でモデル定義が変化していくので、Automatic Model Migrationで生成されるマイグレーションもそれに合わせて変化していきます。そしてその変更差分を埋めてくれる機能があるわけでもないので、これをベースに始めてしまうとバージョン管理の煩雑化が危惧されます。

Vaporを始める際に最初にvapor newで作るであろうAPI Templateではこれが使われていますが、特に説明も無く使われているので、テンプレートを見ながら始める人にとってはちょっとミスリーディングかなと思いました。

タイムゾーン

前記のプロトタイプ版の事情で着手以前からデータがいくらか入っていたのですが、それらはJSTで格納されていました。
一方Fluentにはタイムスタンプを設定する仕組みが入っており、この仕組みに乗っかってcreated_at, updated_atを自動設定するようにできます。しかしこの機能でDBに記録する際のタイムゾーンはUTC固定となっており、JSTのデータをどうしても上手く扱えないという状態になってしまいました。

この対応のため、JSTだった全データをUTCに変換するという手をとりました。これについてもマイグレーションにすることでうまく取り扱うことができました。

UTC以外のタイムゾーンの取扱についてのIssueは上がっているのですが、特に進捗がないので、新規開発で選択の余地がある場合は最初からUTCを選択するのが良いと思います。
https://github.com/vapor/fluent/issues/464

DB周り総評

基本的なことはQueryBuilder経由でできるのですが、よくありそうなケースでも生クエリが必要になることが少なからずあるのがちょっと気になりました。
特にマイグレーションを全て生クエリにしたため、クエリが間違っているのに気付かないまま実行してあとでやり直す作業が数回発生してしまいました。
ユニットテスト用のDBを用意してマイグレーションを流し、情報が正しく取得できるようになっているかまでテストするのが良いです。

プロトタイプ版時代は手作業でテーブル構造を管理していましたが、マイグレーションの整備が終わると必要な手作業はほとんど排除でき、結果目覚ましい発展となったので結構満足しています。

マイグレーション以外で生クエリを書く必要が出てきたのはHAVINGで説明した1パターンのみだったので、メンテナンス性はそこまで悪くなっていないと思っています。

Leaf

VaporはテンプレートエンジンとしてLeafを提供しています。CMSについてはこれを用いて開発することにしました。
が微妙な点ばかり目についてしまいました。

  • テンプレート内で整数演算するとレンダリング結果が浮動小数点数になる #101で報告され解決済み
  • Dictionarysubscriptが使えない
  • enumの条件分岐ができない #if(someEnum == .someCase)が常にfalseと判定されていました

Leafテンプレートの中でロジックを書こうとすると問題が多いようだったので、それらはできるだけ使わないようにしました。
データの加工はSwift側で行い、テンプレート内ではドキュメントで明示されている条件式だけを用いて、あとは渡ってきたデータをそのまま出力する形にしたので、それ以降はLeafの問題を踏むことはありませんでした。

Leafテンプレートの編集は、ファイルツリーに出ているのでXcodeでやりたくなるところですが、Syntax Coloring:HTMLを選ばないとインデントが酷いことになります。さらにその設定はファイル個々に必要だったり、xcodeprojの再生成で設定したのが消えたりということがあるようです。設定を永続化できる手順もドキュメントのリンクに記載されていますが、なかなか面倒そうなので外部エディタを使うのが楽だと思います。
いくつかのエディタでシンタックスハイライトが提供されているのでこれらから選ぶと良いでしょう。

テスト

ミドルウェアを含めたテストを行うため、Requestを渡してResponseを得るという形のテストを書きたくなりました。

ApplicationRequestを作成し、Applicationから取り出したResponderRequestを渡すことで、実際に外からRequestが来たのと同じ条件でテストできます。
これは別件で書いたものですが、以下のコードがまさにそれをやっています。

...
let app = try Application(
    config: config,
    environment: env,
    services: services
)

let req = Request(http: HTTPRequest(method: .POST, url: "/path/to/api"), using: app)

let res: Future<Response> = try app.make(Responder.self).respond(to: req)

https://github.com/t-ae/TwoRequestsOneConnection/blob/master/Tests/AppTests/AppTests.swift

以上によりネットワーク通信を行わずに、自作のミドルウェアも含めた結合テストが書けました。

Swiftをつかうことについて

今回の案件はスマートフォンアプリのバックエンドでした。
アプリ側は先輩社員の @omochimetaru が担当しており、こちらもSwiftで書いていました。

双方をSwiftで書くことのメリットとして、APIを全て型で定義し、共通して使えることがあります。
もともとはAPI仕様書を管理していましたが、API型の定義を共有する手順が固まったことでAPI型=仕様という状態になり、仕様書は廃止しました。APIに変更を加えた場合はAPIリポジトリのプルリクエストで通知しあうという形にすることで、更新に気付かないミスを防ぐことができました。
またシグネチャが変わる類の更新は、チェックアウトした段階で使用箇所全てでコンパイルエラーになるため、アップデート対応漏れを確実に無くすことができます。
(このあたりの話をVapor meetup 2ndのLTで発表されていました。そのときに言及があったサンプルのリポジトリを見たら雰囲気が掴めるかと思います。)

もちろんAPI型にかかわらず値の計算、バリデーション等のユーティリティも共有できるので、サーバー・クライアントで処理が食い違って壊れるというような問題も防げます。

コードの共有でミスしてしまった点としては、iOS開発側で共有モジュールに入れた型が、Linuxでは使えないがmacOSでは使えたのでデプロイまで気付かなかったことがありました。macOSで開発を進めてLinuxにデプロイするタイミングが遅かったため、発覚が大分遅れてしまいました。
例えばCoreGraphicsのCGRectや、GLKitのGLKVector2のように、iOS/macOS開発ではよく使う型が使えないので気をつける必要があります。
今回はCIを導入していなかったのですが、導入していれば早期に気付けた問題だったと思います。

サーバー・クライアント間で共有するための型を作っていくのは良い方法ですが、GLKVector2のようなシンプルな型まで全部再定義していくというのも大変なので、必要なものはサーバーサイドにも対応した同名の型を用意するという手順を取りました。
同時にmacOSでも自前で用意した型を用いるようにすることで、Linuxデプロイ時にしか起こらない問題を減らすことにしました。

vapor toolboxの機能として、vapor.jsonというファイルに設定を書いておくことで、vapor buildvapor xcodeの時にビルドフラグを渡してくれます。
vapor.jsonについてはドキュメントに記述が見つからなかったのですが、以下のようなファイルをプロジェクトのルートディレクトリに置くことでVaporフラグを渡してくれるようになります。

vapor.json
{
    "name": "server-app-name",
    "flags": {
        "build": {
            "mac": [
                "-Xswiftc", "-DVapor"
            ],
            "linux": [
                "-Xswiftc", "-DVapor"
            ]
        },
        "test": {
            "mac": [
                "-Xswiftc", "-DVapor"
            ],
            "linux": [
                "-Xswiftc", "-DVapor"
            ]
        }
    }
}

このフラグを使って以下のように分岐させることで、iOSではGLKitの定義を、macOS/Linuxでは自前の定義を使うようにできました。

#if Vapor

public struct GLKVector2 {
    public var x: Float
    public var y: Float

    public init(_ x: Float, _ y: Float) {
        self.x = x
        self.y = y
    }
}

# else

import GLKit

extension GLKVector2 {
    public init(_ x: Float, _ y: Float) {
        self = GLKVector2Make(x, y)
    }
}

#endif

なおvapor.jsonはあくまでvapor toolboxの機能なので、SwiftPM側のswift pacakge ~系のコマンドでは働きません。
常にvapor ~で行うようにしましょう。

SwiftPM

VaporプロジェクトはSwift Package Managerで管理します。
基本的なコマンド操作はvapor toolboxがラップしていますが、Package.swiftの記述などSwiftPM側の知識が必要になることも少なからずあります。

一例として、私のプロジェクトではAPIリポジトリを分離して共有していたこともあり、そちらの編集とVaporアプリ本体の編集を並行して行いたいという状況になりました。
そのような状況のためにSwiftPMにはpackage editという機能があり、これを利用して並行して作業を行っていました。
https://github.com/apple/swift-package-manager/blob/master/Documentation/Usage.md#editable-packages

Codable

Vaporを使う上でCodableが様々な場所に出てきます。
VaporからSwiftを始めるという人は以下の公式のドキュメントを読んで、どういうものなのか知っておくのが良いと思います。
Encoding and Decoding Custom Types

Codableは型宣言に対して適合させた場合、必要なメソッドを自動で実装してくれる機能があります。
extensionで後づけする場合は必要なメソッドを自分で定義する必要があり、これは避けたいところです。

さて、今回はAPI型を別のパッケージに分け、クライアントサイドとサーバーサイドで共有していたのでした。
APIパッケージには各APIのリクエスト型とレスポンス型が定義されています。
リクエスト型はクライアントサイドからは送信するのでEncodableに、サーバーサイドでは受信するのでDecodableにする必要があります。同様にレスポンス型についてもそれぞれDecodableEncodableに適合させる必要があります。

理想的には必要なプロトコルにだけ準拠するのが好ましいですが、以上のようにクライアントサイドとサーバーサイドで必要なものが分かれ、かつ個別に実装する場合は必然的にextensionで書くことになるため、自動実装が使えないという問題に行き当たります。
そのため今回は無駄なプロトコル適合に目を瞑り、APIパッケージ内でCodableに適合させることとしました。

その他

Docker

サーバー周りはdocker-composeを用いて管理するようにしました。
デプロイ環境固有の設定やDBパスワード等のセンシティブな情報はコミットに含めたくない情報なので、環境変数としてコンテナに渡すようにしました。
サーバーアプリ側ではそれを読み取って使用するように記述することで、リポジトリに各種設定を書かずに済むようにできました。

SwiftPM絡みの対応

Vaporのプロジェクトをビルドする際、SwiftPMの依存を解決できるようにしておく必要があります。
パブリックなGitHubのリポジトリをhttps経由で指定していれば認証が不要ですが、プライベートリポジトリ等認証が必要になる場所が依存に組み込まれている場合、適切に設定しておく必要があります。

Docker上で動かす構成は、ホスト側にリポジトリをクローンし、それをコンテナにマウントしてビルド&実行するという形にしました。
この場合vapor build時に依存解決が行われるため、コンテナ内部からプライベートリポジトリへのアクセス手段を用意する必要があります。
私はGitHubのデプロイキーを用いてアクセスさせることにしました。

プライベートリポジトリが複数ある場合の注意点として、同じデプロイキーを複数のリポジトリで使い回すことができないということがあります。
そのため同じGitHubに置かれたリポジトリでも、個別にキーとエイリアスを用意する必要があります。

例として、以下のようなconfigをdeploy-keyと共にコンテナの.sshディレクトリにマウントします。

ssh/config
Host vapor-private-module
    HostName github.com
    User git
    IdentityFile ~/.ssh/deploy-key
    StrictHostKeyChecking no

これによってvapor-private-moduleというエイリアスでアクセスする際にはIdentityFileで設定されたdeploy-keyが使われることになります。

vapor-private-moduleというホスト名は本来のリポジトリには出てこないため、対象のプライベートリポジトリのURLをこのホスト名を使ったものに置き換えるという手を取ります。
具体的には以下のようにDockerfileの中でgit configのinsteadOfという設定項目を追加しました。

Dockerfile
FROM ubuntu:16.04

WORKDIR /root

RUN apt-get update && \
    apt-get install -y wget && \
    wget -q https://apt.vapor.sh -O apt.vapor.sh && \
    /bin/bash -c "$(cat apt.vapor.sh)" && \
    apt-get update && \
    apt-get install -y \
        swift=4.1.0 \
        vapor \
        imagemagick \
        libpython2.7 && \
    git config --global url."git@vapor-private-module:hogehoge/private-module.git".insteadOf https://github.com/hogehoge/private-module.git && \
    mkdir -p .ssh && \
    chmod 700 /root && \
    chmod 700 .ssh

WORKDIR /var/vapor
CMD vapor build --verbose && vapor run

git config --global url."git@vapor-private-module:hogehoge/private-module.git".insteadOf https://github.com/hogehoge/private-module.gitによって、プライベートリポジトリhttps://github.com/hogehoge/private-module.gitへのアクセスはgit@vapor-private-module:hogehoge/private-module.gitに置き換えられます。
置き換えたもののホスト名は.ssh/configに設定したものなので、これによってdeploy-keyを用いるようになり、プライベートリポジトリの取得ができます。

自動再起動

サーバーサイド開発ではプロセスがクラッシュするとサービスが停止してしまいます。
普段開発しているiOSアプリの場合、万が一クラッシュしても個々のユーザーが再起動できるため被害は軽微ですが、サーバーが停止していると全ユーザーがサービスを利用できないことになり被害は桁違いです。

とはいえ万が一にもクラッシュしないようにOptionalのアンラップや配列アクセスなど、プログラミングミスでクラッシュしうる箇所を全てチェックし、条件に外れたらthrowしていくというのはコーディングの手間が不必要にかかりますし、何より本当に処理すべきエラーの存在が霞んでしまい、余計にバグが入り込みやすいコードになってしまいかねません。
やはりクラッシュすべきところはクラッシュするようにしておくほうが、開発効率は高く保てると思います。

今回はクラッシュした場合には自動で再起動するように設定しました。
Dockerコンテナはvaporプロセスを起動しており、クラッシュ時にはコンテナも終了するため、restartオプションを付けておくことで自動でコンテナの再起動=Vaporプロセスの再起動を行うようにできます。

しかし、自動再起動ができるとはいえ、プロセス開始からサービスの動作まではそれなりに時間がかかりますし、クラッシュする箇所があるということは何らかのバグでサービスが不完全なものとなってしまっていることになります。
クラッシュが発生したら原因を追跡し、解消していくことは必須です。

画像処理

今回は画像アップロード機能があり、EXIFを消すなどの処理が必要でした。Linux環境ではCoreGraphicsを使えないので別の方法が必要です。
今回はProcessを用いてImageMagickのコマンドを叩くという手段に落ち着きました。

画像処理に限らない話ですが、他言語だとライブラリが豊富に存在する機能についても、Swift向けのライブラリはあまり提供されていなかったりするので、フレームワーク選定の際の検討事項に入れるべきです。

なおC APIでいいならSwiftPMのsystem moduleを使うという選択肢もあります。
一応ImageMagickのC APIであるMagickWandで使う方法も模索したのですが、SwiftPMのpkg-configサポート機能が不足していて問題が生じたため諦めました。その機能不足については公式にissueが立っています。
https://bugs.swift.org/browse/SR-7689

コミュニティ・ドキュメント

Discord

Vapor公式のコミュニティは最近SlackからDiscordに移りました。
Slackに入ったその日にDiscordへの移行がアナウンスされたのでそれ以前の話は知らないですが、Discordはけっこう盛り上がっています。
質問も気軽にできる雰囲気ではあるのですが、回答がないまま流れているのもよく見ます。
簡単な質問には比較的回答がついていますが、私が出したドキュメント化されていないVaporの仕様やバグに関わるトピックなどには反応が鈍かったです。
そのため結局ソースコードを読んで調べるほうが手っ取り早いという印象でした。

あとPenny Botなるものが導入されています。質問に答えてもらったら@someone ++と送りましょう。

GitHub

開発中に遭遇したバグ等について何本かIssue/PRを上げましたが、レスポンスがあるまで結構時間がかかる印象でした。
他のIssueを見渡してみても開発者のTannerさんのワンマン運営に近い状態で、素早い対応ができる状況ではなさそうなので、クリティカルなバグにあたったら自分で調査対応するくらいの気概を持って取り組むことが必要そうです。

ドキュメント

案件の着手時期が5月頃で、そのころはまだVapor3がベータだったこともあり、ドキュメントが全然整っていなくて苦労しました。現在は大方ページも埋まってきていますが、開発中にでてくるいろいろを網羅するには大分足りていないので、必要な機能が用意されているかどうかソースを当たりに行くことが必要そうです。
上ドキュメント以外ではコードのドキュメンテーションコメントが豊富な情報を含んでいるので、それらも読むようにするとより理解が深まると思います。

総括

最近3.0がリリースされたばかりということもあり、ネットを調べて見つかる情報が古くて役に立たないケースがままあるので、自分で調べながら進めていく覚悟と時間が必要そうです。それが受け入れられるなら、ベータが外れて安定してきたところなので、始めるにはちょうどいいタイミングだと思います。
iOSアプリとの連携については上述したとおりですが、そうでなくともSwiftで書けるというだけでモチベーションを高く保てました。

総評としては以下のような人にはおすすめできると思います。

  • サーバーサイドでSwiftを使うことにメリットを感じている
  • iOSアプリとコードを共有したい
  • 少々のバグは耐えられる・むしろバグ追跡を楽しめる

個人的には次回サーバーサイドの案件が回ってきたらまたVaporを採用しようと思っています。