タマネギプログラマーの雑記

たまねぎ剣士的なニュアンス

Vaporで作る簡単webサービス 〜ビューもあるよ!〜

この記事はRecruit Engineers Advent Calendar 2018の記事です。

Swiftといえば、iOSアプリケーションを作成するための言語、というイメージが強いと思うが、実際はiOSとは独立した言語である。
(ちょっと嘘。実質多くの部分iOSのエコシステムにロックインされてる感は否めない)
事実、Swift製のコマンドラインツールなども作られており、様々な用途に(頑張れば)使うことができる。
そこで今回は、サーバーサイドSwiftのフレームワークであるVaporで、簡単なウェブサービスを作ってみたいと思う。

vapor.codes

VaporはKituraなどと並び、最近どこかで話題のSwift製Webフレームワークである。
基本的にはRailsフォローのよくあるフレームワークの体裁に感じる。
Swift4.0, XCode10.1 で実装した。

今回実装したコードはこちら

github.com

導入

まず、vaporをインストールする。

brew install vapor/tap/vapor

プロジェクトの作成は

vapor new <project name>

で行うことができる。
また、このとき --template オプションで、どの種類のテンプレートを用いるか指定できる。
今回はweb オプションを用いてプロジェクトを作成してみる。
実際に発行したコマンドは
vapor new VaporWebExample --template=web
である。

f:id:gaoxin-xixxix:20181218000643p:plain
vapor new後の表示
これを実行すると、こんな感じでファイルが生成される。
f:id:gaoxin-xixxix:20181210221728p:plain
生成直後の様子

普段xcodeを使っている人なら、見慣れた ~.xcodeproj が存在しないことに気づくだろう。
なので、xcodeで開発を進めたい場合、これを生成する必要がある。
vapor xcode コマンドを使うと生成される。
なんで標準でついてないんや・・・って思ったけど、XCodeVaporが採用してるテンプレート言語であるLeafに対応していないため、XCode以外を使うっていう選択肢が割とあるんじゃないかと思う。

何はともあれ、骨組みはできたので実装に入っていく。

今回は、やってみた系の定番である、Todoサービスを作っていこうと思う。
最低限ではあるが、以下の機能を実装する。

  • Todo一覧表示
  • Todoの追加
  • Todoの削除

ルーティングの設定

まずは、get/todos のルーティングを追加していく。
vaporにおいて、ルーティングの設定は、Routes.swift に記述する。
プロジェクト生成初期には以下のように記述されており、get /get/hello/:parameter が設定されている。

public func routes(_ router: Router) throws {
    // "It works" page
    router.get { req in
        return try req.view().render("welcome")
    }
    
    // Says hello
    router.get("hello", String.parameter) { req -> Future<View> in
        return try req.view().render("hello", [
            "name": req.parameters.next(String.self)
        ])
    }
}

とりあえず、ここに get todos を足してみよう。

router.get("todos") { req -> Future<View> in
        return try req.view().render("hello", [
            "name": "todo"
        ])
}

こんな感じで。ビューをまだ用意してないので、hello のビューを拝借する。
ビューに対して渡すパラメータはString "todo" に固定する。
これで get todos を行えば、Hello, todos と表示されるはずである。
XCode上からrunし、localhost:8080/todos にアクセスすると

f:id:gaoxin-xixxix:20181210225630p:plain
Hello, todo!

ちゃんと出た。

DBの設定

次に、DBを使えるようにしていく。
今回は手軽にsqliteを使うこととする。

Vaporでデータベースを扱うときは vapor/fluent を使うと良いだろう。
Fluentは、Vapor公式が作ったORMフレームワークなので、簡単に導入することができる。
Vapor謹製のDBフレームワークとしては、DatabaseKit というというものもあるが、Fluentはこれをラップし、使いやすい形にしたものだとか。

Fluentを使用するために、依存管理にFluentを追加する。
VaporはSPMによる依存管理を行っているため、これに従う。
Package.swiftを以下のように記述する。
ついでにvapor/leaf (後述)も追加しておく。
Fluentは、mysqlpostgresql に対応しているが、それぞれに対応したFluentのライブラリがあるため、自分が使うものをしっかり入れる必要がある。(今回はFluentSQLite)

// swift-tools-version:4.0
import PackageDescription

let package = Package(
    name: "VaporWebExample",
    dependencies: [
        // 💧 A server-side Swift web framework.
        .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0"),

        // 🍃 An expressive, performant, and extensible templating language built for Swift.
        .package(url: "https://github.com/vapor/leaf.git", from: "3.0.0"),
        .package(url: "https://github.com/vapor/fluent-sqlite.git", from: "3.0.0")
    ],
    targets: [
        .target(name: "App", dependencies: ["Leaf", "Vapor", "FluentSQLite"]),
        .target(name: "Run", dependencies: ["App"]),
        .testTarget(name: "AppTests", dependencies: ["App"])
    ]
)

今回のように、何か依存を追加した際は、xcodeprojファイルを更新するためにvapor xcode を実行する必要がある。

次に、Todo モデルを実装する。 クラスを作成し、SQLiteModelプロトコルに準拠させれば良い。
今回は以下のようにした。
初期生成されるディレクトリであるModels直下に配置する。

import FluentSQLite
import Vapor

final class Todo: SQLiteModel {
    var id: Int?
    var name: String
    var priority: Int?
    
    init(id: Int? = nil, name: String, priority: Int) {
        self.id = id
        self.name = name
        self.priority = priority
    }
}

extension Todo: SQLiteMigration {}
extension Todo: Content {}

ちなみにFluentのモデルを作る際、var id: Int? だけは必須で定義する必要がある。
SQLiteMigration プロトコルにも準拠させておくと、自動でマイグレーションを行ってくれるようになる。
また、後ほどフォームで送られてきたデータを、Todoにバインディングするために、Content プロトコルにも準拠させておく。

以下をconfigure.swiftに記述し、FluentProviderとマイグレーションを登録する。

// sqliteのセットアップ
try services.register(FluentSQLiteProvider())
let db = try SQLiteDatabase()
var dbconfig = DatabasesConfig()
dbconfig.add(database: db, as: .sqlite)
services.register(dbconfig)

// マイグレーションの登録
var migrations = MigrationConfig()
migrations.add(model: Todo.self, database: .sqlite)
services.register(migrations)

これで、とりあえずsqliteが使えるようになった。
SQLiteDatabase() の引数に何も渡さなかった場合、in memoryのSQLiteが使われるようになる。
ちなみに、今回は詳しくは触れないが、services はVaporが持つDIコンテナフレームワークである。
services.register() で依存を登録することができる。

コントローラの追加

次にコントローラを実装する。
まずは動作確認のために、固定でTodoをいくつかViewに渡し表示するようにする。

import Vapor

final class TodosController {
    func index(_ req: Request) throws -> EventLoopFuture<View> {
        let todos = [Todo(name: "a", priority: 1), Todo(name: "b", priority: 1), Todo(name: "c", priority: 1)]
        return try req.view().render("Todos/index", ["todos": todos])
    }
}

Controller上では、(Request) throws -> RequestEncodable となるfunctionを定義する必要がある。
この型で定義しないと、Routes.swift で登録することができない。

引数として渡されてくるreq: RequestはDIコンテナとしての側面を有しており、 req.make(SomeService.self) を行えば、services.register() で登録したサービスを取得することができる。
DBのコネクションなどもこのRequest から取得する方式となっている。
個人的にはリクエストという形でアプリケーションのコンテキストが渡ってくるのはどうなんだろうと思う。

req.view().render で、ビューの描画処理を行う。
引数として指定しているTodos/index は、描画に使うleafファイルのパスである(後述)。第二引数は、第一引数で指定したleafファイルにパラメータとして渡される。

ビューの追加

次に、前項で指定したビューを実装する。
Vaporでは、Leaf というテンプレートフレームワークを用いてビューを実装する。
残念なことに、XCodeシンタックスハイライトに対応していない。
頼みの綱のAppCodeもプラグインが完成しておらず(Javaに興味ないからごめんな!!と公式は言っている)、現状VSCodeのみがLeafに対応したエディタとなっている。
なので、諦めてXCodeVSCodeの両刀使いとなるか、我慢してシンタックスハイライトなしで開発をしよう。
僕は後者を選んだ。一応、leafファイルを選び、Editor > Syntax Coloring > HTML でHTMLシンタックスハイライトを設定すれば多少マシになる。
設定してて悲しい気持ちになってはくるが。

Leafの文法はSwiftにインスパイアされており(公式はそう言い張っている)、Swiftに慣れていれば使い勝手は良いらしい(僕は感じなかった)。
基本的にはHTMLをそのまま記述していき、要所要所でLeafタグと言われる要素を記述することで、動的なHTML生成を行うことができる。
詳しくはこちらを参照してほしい。
Overview - Vapor Docs

今回は以下のようなテンプレートを実装した。

#set("title") { Todos }

#set("body") {

<div class="container">
    <div class="page-header">
        <h1>Todos</h1>
    </div>
    <ul class="list-group">
        #for(todo in todos) {
        <li class="list-group-item">#(todo.id): #(todo.name)</li>
        }
    </ul>
</div>
}

#embed("base")

#embed() は別のleafファイルの呼び出しを行い、今回は以下の base.leaf を呼び出している。

<!DOCTYPE html>
<html>
<head>
   <title>#get(title)</title>
   <link rel="stylesheet" href="/styles/app.css">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
</head>
<body>
    #get(body)
</body>
</html>

#set() は、leafファイル上で使う変数を作成し、#get() で呼び出す。
これで、一応のコントローラとビューは実装できた。
これらをRoutes.swift で登録する。

public func routes(_ router: Router) throws {
    let todos = TodosController()
    router.get("/", use: todos.index)
}

コントローラの登録は簡単である。インスタンスを生成し、そのファンクションをrouter.get() の第二引数として渡せばいい。
第一引数にはパスを渡す。
なお、この get はhttpリクエストのGETを表すので、 router.postrouter.delete のように、他のhttpリクエストを定義することももちろん可能である。
この状態でアプリを起動し、/ にアクセスする。

f:id:gaoxin-xixxix:20181216223641p:plain

無事に、固定のTodoを表示することができた。

Modelの操作

次に、動的にTodoを変化させられるように、Todoを追加する機能を実装する。
まず、新しいTodoを投稿するための画面を作成する。
new.leaf という名前で以下のファイルを作成した。

#set("title") { Add Todo }

#set("body") {
<div class="container">
    <div class="page-header">
        <h1>Add Todo</h1>
    </div>
    <form action="/todos" method="post">
        <div class="form-group">
            <label for="exampleInputEmail1">What you have to do?</label>
            <input type="text" class="form-control" name="name" placeholder="input a todo">
        </div>
        <button type="submit" class="btn btn-primary">Submit</button>
    </form>
</div>
}

#embed("base")

これを Routes.swift に追加する。
new.leafは、特に動的に変化する箇所がないので、コントローラに新しいファンクションを足すまでもないだろう。
その場合は以下のように記述することができる。

router.get("todos/new") { (req: Request) -> EventLoopFuture<View> in
        return try req.view().render("Todos/new")
 }

これで以下の画面が表示できるようになった。

f:id:gaoxin-xixxix:20181217001113p:plain
Todo登録画面

今度は、この画面から送られたフォームのデータから、新しい Todo をデータベースに保存する処理を記述する必要がある。
そのためにTodosControllerに、create ファンクションを追加する。

func create(_ req: Request) throws -> EventLoopFuture<Response> {
        return try req.content.decode(Todo.self).map(to: Response.self, { todo in
            Todo.query(on: req).create(todo)
            return req.redirect(to: "/", type: .normal)
        })
 }

formの値は、 Content プロトコルに準拠したstructかclassで受け取ることができる。
req.content.decode(Todo.self) で、上で Content プロトコルに準拠させておいたTodo を指定することで、そのプロパティ名と一致したformのデータをバインディングすることができる。
バインディングされた後は EventLoopFuture<Decodable> の形になる。
EventLoopFuture 型は、単純に Future と同様のものと考えて基本的には差し支えない。
そのため、結果に対する処理をコールバック内で定義する必要がある。
今回はTodoを追加した後にトップにリダイレクトさせようと思うので、新しいTodoを保存した後、リダイレクトを表すResponse 型に変換してやる。 こうすることによって、新しいTodo を保存した後、Todo一覧画面にリダイレクトさせることができる。
さらに、/todos へのPOSTをRoutes.swift へ登録することで、Todoの追加ができるようになる。

router.post("/todos", use: todos.create)

Todoの新規追加ができるようになったので、一覧画面も、ちゃんとTodoを読み込んで表示するように変更する。
TodoControllerのindexファンクションを以下のように変更する。

func index(_ req: Request) throws -> EventLoopFuture<View> {
        return try req.view().render("Todos/index", ["todos": Todo.query(on: req).all()])
}

Todo.query(on: req).all() で全てのTodoを取得することができる。

DELETEの実装

今度はTodoの削除を実装して実装については終わりにしたいと思う。
Routes.swift に以下を追加する。
DELETEなのにGETにしてたりするのはただの横着なので許していただきたい。

router.get("todo", Int.parameter, "delete", use: todos.destroy)

ここでは、第二引数に Int.parameter を指定している。
こうすることによって todo/:id/delete のようなパスを表現することができる。
コントローラの方は、追加と同様の要領で実装できる。

func destroy(_ req: Request) throws -> EventLoopFuture<Response> {
        return try Todo.find(req.parameters.next(Int.self), on: req).map(to: Response.self) { todo in
            todo?.delete(on: req)
             return req.redirect(to: "/", type: .normal)
        }
 }

一覧に削除ボタンを追加すれば、表示、追加、削除が完成する。

f:id:gaoxin-xixxix:20181217005111p:plain
完成形

公開

最後に、Vaporが用意している簡単な公開方法を紹介して終わりにしたいと思う。
VaporはVapor Cloudというホスティングサービスを提供している。
vapor.cloud こちらを利用すると、驚くほど簡単にVaporアプリケーションを公開することができる。
個人で試す程度なら無料で使えるので、是非一度触ってみていただきたい。
手順を以下に紹介する。

前提条件として、gitで管理されている必要があるので、もし行っていない場合はセットアップしなければならない。
もう一つ必要な準備として、cloud.yml というファイルを、プロジェクトルートに配置する必要がある。
今回、僕はSwift4.0で実装を行ったので、以下のような記述を行った。

type: "vapor"
swift_version: "4.0.0"

あとはCLIで以下の手順を行うだけである。

  1. vapor cloud signup を実行し、Vapor Cloudにサインアップする。
  2. vapor cloud login を実行し、ログインする。
  3. vapor cloud deploy を行う。

CLIからの指示に従い、情報を入れればデプロイが完了する。

と思ってたのだが、今回は何故か失敗した・・・
前回行ったときは問題なかったのだが、何がいけないんだろうか(調べるつもりはあまりない)。

まとめと感想

とりあえず、VaporによるTodoサービスを作成してみた。
触ってみた感じ、思ってたよりもまともに開発ができるなという気分だった。
RailsなどのWebフレームワークに触ったことがあれば、比較的簡単に習熟できるだろう。
各種設定も簡便で、心地よく実装することができる。
特にORMのFluentについては、ちょろっと使うという点についてはかなり楽に動かすことができた。
他にも、DIなどのWebフレームワーク定番の機能についても、多言語のフレームワークにあるものについては最低限備えられているように感じた。
また、Swiftの強力な言語機能をうまくつかっており、きちんと型設計をしながら運用すれば、安全に大規模なシステムを組むこともできるポテンシャルを感じた。

一方で、足りない箇所も多々あり、まだまだ実戦投入はしたくないという所感を持った。
特に、ビュー周りについては改善が望まれる。
テンプレートフレームワークLeaf周りは、エディタの支援を受けられないという点で非常にもどかしい。
また、ビューの生成を支援するようなhelpler的な機構が(少なくとも少し探した範囲では)見つからず、他ページへのリンクを書くに際しても、べた書きをせざるを得ないという体たらくである。
Leafのカスタムタグを使えば、helperのようなことはできるのだが、フレームワーク標準で用意されているタグが貧弱すぎる。
もっとも、カスタムタグを使ったとしても、バックエンドレイヤーとの連携を支援するような機構は無いため、やはり不便の感は否めない。
また、公式のドキュメントが不親切であることも痛感した。
今回この程度のサンプルを実装するだけでも、実装コードを読みに行ったり、サンプルコードを探し求めたりするなど、そこそこバカにならない時間がかかった。
サーバーサイドSwiftの普及を目指すならば、こういった点の改善が早急に求められる状況だと言えるだろう。

Swiftの言語機能から来る期待はやはり大きく、書いていて楽しいのは事実であり、趣味の開発には充分に堪えるものだと思う。
ビュー周りの不便さについても、APIを開発するのに使う分には問題にならないし。
ただし、足りない部分は依然多く、プロダクションで使えるようになるのはまだまだ先の話だろう。