Server-Side-Swift VaporでAPIを作って学んだことまとめ

  • 23
    いいね
  • 2
    コメント

全編mac版。Vaporのバージョンは2.0.4
Vaporの始め方やHello Worldまでの手順は記事がいっぱいあるので割愛します。

サーバーサイドに慣れてないSwift開発者だけど、自分でもできそう!
と思ってもらえるよう記事を書くことにしました。

作ったもの

Qiita Pocketという自作アプリのAPIです。(サーバー安定してます(小声))

仕様は下記です。

  • Qiitaの記事ランキングを返す
    • periodパラメータ(week/month)で指定した期間ごとのランキングを返す
    • tagパラメータ(ex. swift)で指定したタグごとのランキングを返す
    • ランキング生成のため、日次バッチ処理でQiita APIから記事を取得する

今までは、QiitaのAPIを使ってデータを表示するだけだったのですが、月間ランキングの生成を公式のAPIだけで賄うのは難しく(検索結果のイイね順ソートがサポートされていないため)
自作のAPIが必要になり、自作しました。

リポジトリはこちら
https://github.com/hirothings/qiita-pocket-API

なぜVapor?

Server-Side-Swiftのフレームワークでは、PerfectとVaporのスター数が拮抗しているのですが、Vaporは

  • toolboxというCLIが用意されている
  • ドキュメントが読みやすい
  • Herokuへのデプロイが可能(環境の構築にお金と時間をかけたくなかった)
  • デザインがカッコいい(重要)

ので、Vaporを選択しました。

xcode.png

Xcodeで開発できるのは嬉しい 

プロジェクトの生成

vaporプロジェクトの生成は、vaporをインストールして

vapor new <project name> [--template]

で生成できます。

[--template]で、テンプレートを指定すると、
テンプレートに合わせたルーティング, コントローラ, モデルが揃ったサンプルプロジェクトが生成されるので、それをベースに作ると良いかと思います。

テンプレートにはAPI開発向け、認証向けなど様々あります。
template一覧

今回はAPIなので、

vapor new qiita-pocket-API --template=api

でプロジェクトを生成しました。

Xcode

Xcode projectの生成もコマンド一発です。
これでXcodeでwebアプリケーションの開発ができます。

vapor xcode

なお、パッケージ管理は、Swift Package Managerをサポートしています。

フォルダ構成

vaporがレコメンドするフォルダ構成があります。
https://docs.vapor.codes/2.0/vapor/folder-structure/

前述したtemplateを使ったproject生成している場合は、すでにこのフォルダ構成に準拠しているので、特に気にする必要はありません。

Vaporの主な登場人物

APIの開発で主に使ったパッケージです。
ちなみに全てプリインストールされています。

パッケージ名 ロール
toolbox projectの作成やherokuへのビルドなどまとまった便利CLI
Routing ルーティング処理を担当
Fluent モデルに関わる処理を担当 (作成、読み取り、更新、削除、クエリ発行)
HTTP HTTPリクエスト・レスポンスなど担当
Node JSONデータなどからタイプセーフな型変換を行う
JSON JSONのエンコード・デコードを担当。内部で上記のNodeを使っている

toolbox

CLIです。コマンド一覧は、 vapor help で見れます。
主に使ったコマンドは下記でした。
build, run に関してはXcodeで開発していればcmd + R, cmd + Bでできます。

コマンド 説明
new Vaporアプリの作成時
build アプリのビルド
run アプリの起動
xcode Xcode projectの生成・更新
heroku herokuへのデプロイ

Routing

他のフレームワーク同様、ルーティングファイルにそのまま処理を書くのではなく、
ルーティングごとのアクションをControllerに定義することができます。

ルーティングごとのアクションをまず、Controller側のメソッドで定義します
その際、ResponseRepresentable プロトコル(文字列やJSON何かしらのレスポンスを返す取り決め)に準拠したメソッドである必要があります。

// ArticleController.swift
func index(_ req: Request) throws -> ResponseRepresentable {
    // リクエストパラメータは引数reqから取得できる
    print(req.query!["period"]!.string) // "week"

    // 中略

    return try articles
        .sort(Article.likesCount_key, .descending)
        .limit(20, offset: 1)
        .all()
        .makeJSON()
}

ルーティング側は、Controllerのインスタンスを生成し、上記で作成したアクションをハンドラーに渡すだけです。
これだけで、ルーティングが完成します

// Routes.swift
extension Droplet {
    func setupRoutes() throws {
        let articles = ArticleController(droplet: self)

        get("articles", handler: articles.index)
        post("articles", handler: articles.create)
    }
}

Vaporであらかじめ用意されているアクション名に準拠すれば、Controller側でルーティングを解釈させて、上記のようにルーティング側で1個1個のアクションを指定しない書き方もあります。
詳しくは:
https://docs.vapor.codes/2.0/vapor/controllers/#resources

Fluent

Vaporで開発するにあたって最も重要と言っても過言ではないパッケージです。

Fluentは、モデルの作成、読み取り、更新、削除などサポートしており、
DBの作成やクエリの発行もサポートしています。
ものすごく雑に言うとRealmのように、永続化データへのアクセスを簡単にしてくれるパッケージです。

まず、Modelプロトコルに準拠したModelを定義します。
※Modelが継承しているEntityがClass Only Protocolなので、Structは使えません

final class Article: Model {
    var storage: Storage = Storage()

    static let idType: IdentifierType = .int
    var title: String
    var likesCount: Int = 0

    init(
        title: String,
        likesCount: Int
        ) {
        self.title = title
        self.likesCount = likesCount
    }

テーブルの生成

modelをPreparation protocolに準拠させると、テーブルの作成をアプリをRunしたタイミングで行ってくれます。

extension Article: Preparation {
    static func prepare(_ database: Database) throws {
        try database.create(self) { (builder: Creator) in
            builder.id() // 主キー
            builder.string("title")    // テーブルのカラム名
            builder.int("likes_count") // テーブルのカラム名
        }
    }

    static func revert(_ database: Database) throws {
        try database.delete(self)
    }
}

テーブル名の指定

デフォルトはmodelの複数形がそのままtable name, collectionの名前になります。
この指定に沿っていない名前の場合、自分で名前をつけることもできます

final class Pet: Model {
    static let entity = "pets"
}

ハマったこと

上記でテーブル追加したあとに、Config+Setup.swiftsetupPreparations() メソッドにModelを追加しないと認識されません

extension Config {
    private func setupPreparations() throws {
        preparations.append(Article.self)
    }
}

modelの保存・更新

保存はRealmのように、Modelのインスタンスを作ってsaveメソッドを呼ぶだけです。

// Qiita APIのJSONデータを元にArticle Modelを生成し、保存
let article = try Article(json: json)
try article.save()

ただ、単純にsaveしていると重複したレコードが生成されるので、
Qiitaの記事のIDをキーに既存レコードを検索して存在していたら、上書きする処理を書きました。

extension Article {
    static func save(_ article: Article) throws {
        if let existedArticle = try Article.makeQuery().filter(Article.itemID_key == article.itemID).first() {
            existedArticle.title = article.title
            existedArticle.likesCount = article.likesCount
            try existedArticle.save()
        }
        else {
            try article.save()
        }
    }
}

Relation

Articleには複数のtagが紐づくため1対多のリレーションを実装しました。
手順としては下記です。

  1. TagのModelを作る
  2. テーブル生成のprepareメソッド内でTagから親のModelへのリレーションを張る
  3. 親のIdentify型キーと外部キー制約を張る
extension Tag: Preparation {
    static func prepare(_ database: Database) throws {
        try database.create(self) { builder in
            builder.id()
            builder.string(Tag.name_key)
            // 親とのリレーション
            builder.foreignKey(Tag.articleID_key, references: Article.idKey, on: Article.self)
            builder.parent(Article.self)
        }
    }
}

親側から子のデータの参照は計算型プロパティで定義できます

final class Article: Model {
    var tags: Children<Article, Tag> {
        return children()
    }
}
// let tags = article.tags

参考:
https://docs.vapor.codes/2.0/fluent/relations/

クエリ

DBのクエリ発行はRealmの記法に似てるかなと思いました。
子モデルまで探索してModelのデータを取得するなど色々なことが可能です。

// タグをキーにクエリを発行
private func searchArticles(with tag: String, articles: Query<Article>) throws -> Query<Article> {
    return try articles
        .makeQuery()
        .join(Tag.self) // 子ModelのTagとJoin
        .filter(raw: "upper(`tags`.`name`) = upper('\(tag)')")
}

ハマったこと
クエリについては、オペレーターである程度のサポートがされているのですが、
例:try query.filter("age" >= 21)
凝った検索をしたい場合は、生でSQL文を書く必要があります(上記のfilter文など)
SQL力がLV.1くらいしかないので、ここで詰まりました。。

ソート

ソートや検索件数の制限ももちろん可能です。

// イイね順の投稿20件を取得
Article
    .makeQuery()
    .sort(Article.likesCount_key, .descending)
    .limit(20, offset: 1)
    .all() // [Article]

参考:
https://docs.vapor.codes/2.0/fluent/query/

SQLite DBを使う

fluentで標準で入っているDBは、

  • SQLite in-memory DB (デフォルト)
  • SQLite

の2つです。
他にもパッケージを追加することでMySQLなどにも対応可能

デフォルトのin-memory DBは、アプリを止めるとデータが破棄されるので、開発時は便利ですが、ある程度開発が落ち着いたらSQLiteなどに移行します。

SQLiteの移行は下記の作業だけで済みました。

  1. fluent.json で、 "driver": "sqlite" を指定
  2. sqlite.jsonを作成
  3. sqlite.jsonにDBのPathを指定 { "path": "qiita-articleDB.sqlite" }

SQLite in-memory DBとは
プロセスの間だけデータを保持するDB。速度は抜群に速い
http://qiita.com/umisama/items/2014f8f09cee447c313f#sqlite%E3%81%A7in-memory-database

SQLiteは標準サポート
過去記事を見ると、vapor-community/sqlite-providerという追加パッケージを導入していますが、Deprecatedになっています。現在は、プリセットされたパッケージで対応可能です
https://github.com/vapor-community/sqlite-provider

HTTP

シンプルなHTTP Requestなら、HTTPパッケージに用意されているClientで簡単に実装できます。
下記のコードはQiitaのAPIをコールして、投稿データをArticle型で保存するまでの一連のコードです。
宣言的に書けます。なお、このHTTP通信ですが、内部でリクエストの順序を担保していて直列実行してくれるようです。

// Qiitaの投稿取得APIから100件、投稿データを取得
let response: Response = try drop.client.get("https://qiita.com/api/v2/items", query: [
    "page": 1,
    "per_page": 100,
    "query": "user:hirothings"
], [
    "Authorization": "Bearer hogehogehoge"
])

// ルートがarrayのJSONに変換
guard let jsonArray = response.json?.array else {
    return
}

// JSONをArticle型に変換し、保存
for json in jsonArray {
    let article = try Article(json: json)
    try Article.save(article)
    try saveEntities(itemID: article.itemID, json: json)
}

参考:
https://docs.vapor.codes/2.0/http/client/

herokuにデプロイ

toolboxにheroku用のコマンドが用意されているので、それを使うだけです
(もちろん、herokuへのユーザー登録は事前に必要です)

vapor heroku init
vapor heroku push

初期化時の設定ですが、自作APIだとこんな感じでした。

Would you like to provide a custom Heroku app name?
y/n> y
Custom app name:
> qiita-pocket-api
Would you like to deploy to a region other than the US?
y/n> n
https://qiita-pocket-api.herokuapp.com/ | https://git.heroku.com/qiita-pocket-api.git

Would you like to provide a custom Heroku buildpack?
y/n> n
Setting buildpack...
Are you using a custom Executable name?
y/n> n
Setting procfile...
Committing procfile...
Would you like to push to Heroku now?
y/n> y

Cron

Vaporには定期タスクを実行するパッケージはないです。
自作APIではHeroku Schedulerを使って、毎日Qiita投稿取得のエンドポイントをコールしてランキング記事を更新しています。

追記:
@mono0926 さんにコメントいただきましたが、VaporにはCommandを追加する機能があるので、それを活用して vapor run COMMAND_NAME をHeroku Schedulerなどでコールした方がスマートでした。

Vapor Cloud

最近、Version1.0がリリースされたVapor Cloudだと、モニタリングやCronジョブもサポートされているようです。
自分のサイトなりAPIを公開する際、heroku以外の選択肢を公式がサポートするのはVaporを選択する大きなアドバンテージになりそうです。
価格も20000リクエスト/月までなら無料と個人アプリ開発に優しく、導入しやすくなったのではないでしょうか。

実際に使ってみてどうだったか?

タイプセーフ
やはり型があるのは嬉しいです。あと、エラーハンドリングもtry catch文で綺麗に書けるので、堅牢なアプリケーション開発ができるのではないかと思います。
Xcodeで開発できるのもiOS開発者としては嬉しいですね。

やりたいことに対しての敷居は低い
正直SSSは敷居が高いかなと思っていましたが、そんなことはないです。
やりたかったことは一通り標準のパッケージで実現できました。
あと、projectの作成時、あらかじめtemplateが用意されているのが、大きかったです。
慣れない英語でドキュメントを読みながらもtemplateのサンプルコードと照らし合わせて、理解できることが多々ありました。

気になること
と言っても、サーバーサイド言語としてはまだまだ発展途上の状態なので、Railsなどで業務でサーバーサイド開発をしている人の要件を賄えるのか気になります。

Swiftの学習にも向いている
普段rubyなどを書いていて、Swiftを触ってみたいけど、iOSの文脈の勉強が億劫な人にもSSSはオススメです。
herokuないし、Vapor Cloudを使えば無料でwebサイトの公開までできるので、Swiftの初学に使ってみるのはいかがでしょうか

公式ドキュメント

Docs
https://docs.vapor.codes/2.0/

Projects
https://github.com/vapor/vapor/blob/master/Documents/PROJECTS.md

Tutorials
https://github.com/vapor/vapor/blob/master/Documents/TUTORIALS.md

参考記事

サーバーサイドSwiftをはじめてみよう