Swift製webアプリケーションフレームワーク Vapor を読むやつ #3

hkdnet.hatenablog.com

boot 以降にいくか、と思っていたのですが boot 以降よりも middleware まわりのほうがよさそうなのでそちらを読むことにします。

さて、 middleware ですが、とりあえず既存の middleware を読めばいいでしょう。 ErrorMiddleware を読みます。

vapor/ErrorMiddleware.swift at 3.0.2 · vapor/vapor · GitHub

superclass あるいは adopted protocols1 として Middleware, ServiceType を宣言しているようです。Swift は Vapor でしか読んでないので文法事項が普通にわからなくて困りますね。 すげー雑に見ると、 public static func makeService(for worker: Container) throws -> ErrorMiddleware でサービスとしての ErrorMiddleware を返しているようです。その実態は public func respond(to req: Request, chainingTo next: Responder) throws -> Future<Response> として実装されているように見えますね。つまり以下のようになってればよさそうです。

  • スタティックメソッド makeService でサービスをつくる
  • インスタンスメソッド responsd で実リクエストを捌く

では早速適当に middleware をつくりましょう。特定のヘッダがあったときに特定のヘッダを返す、という実リクエスト/レスポンスにあんまり影響を与えないような何かにしておきます。クラス名は適当に MyMiddleware とかにしておきましょう。

/// Sources/App/mymiddleware.swift
import Vapor

public final class MyMiddleware: Middleware, ServiceType {
    /// See `ServiceType`.
    public static func makeService(for worker: Container) throws -> MyMiddleware {
        return try .default(environment: worker.environment, log: worker.make())
    }
    
    /// Create a default `MyMiddleware`.
    ///
    /// - parameters:
    ///     - environment: The environment to respect when presenting errors.
    ///     - log: Log destination.
    public static func `default`(environment: Environment, log: Logger) -> MyMiddleware {
        return .init()
    }

    public init() {
    }
    
    /// See `Middleware`.
    public func respond(to req: Request, chainingTo next: Responder) throws -> Future<Response> {
        return try next.respond(to: req).map { res in
            if req.http.headers.contains(name: "X-NYA-N") {
                res.http.headers.add(name: "X-NYA-N", value: "RESPONSE")
            }
            return res
        }
    }
}

respond 内では req.http.headers を見て分岐しています。X-NYA-N ヘッダがあれば X-NYA-N: RESPONSE というヘッダを返すようにしています。

これを早速使いましょう。 configure.swift の middlewares に追加すればよさそうです。

/// Sources/App/configure.swift
    /// Register middleware
    var middlewares = MiddlewareConfig() // Create _empty_ middleware config
    /// middlewares.use(FileMiddleware.self) // Serves files from `Public/` directory
    middlewares.use(ErrorMiddleware.self) // Catches errors and converts to HTTP response
    middlewares.use(MyMiddleware.self) // 追加

しかしこれだけではエラーになってしまいます。

Run[81340:13566121] Fatal error: Error raised at top level: ⚠️ Service Error: No services are available for 'MyMiddleware'.
- id: ServiceError.make

対応するサービスがねーぞと言われてますね。うーん。DIする機構として Service というのがありそうなのはわかっているのですがいまいち把握できてません。とりあえず ErrorMiddleware は使えているので vapor/vapor 内で ErrorMiddleware に関して検索してみるとこんなんが出てきました。

vapor/Services+Default.swift at 3.0.2 · vapor/vapor · GitHub

services.register(ErrorMiddleware.self) とやってサービスとしても登録していますね。おそらくDIのほうでサービスクラスのリストをもっておいて、ミドルウェアが必要になったときに該当のサービスをリストから探索 → makeService を呼んでサービスのインスタンスを取得、としているのでしょう。MyMiddleware クラスもサービスとして登録してやります2

/// Sources/App/configure.swift
    /// Register middleware
    var middlewares = MiddlewareConfig() // Create _empty_ middleware config
    /// middlewares.use(FileMiddleware.self) // Serves files from `Public/` directory
    middlewares.use(ErrorMiddleware.self) // Catches errors and converts to HTTP response
    middlewares.use(MyMiddleware.self)
    services.register(middlewares)
    services.register(MyMiddleware.self) // これを追加

これで無事立ち上がりました。レスポンスもいい感じです。

~/.g/g/h/HelloVapor ❯❯❯ curl -i localhost:8080/todos
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 2
date: Sun, 13 May 2018 18:57:43 GMT

[]%
~/.g/g/h/HelloVapor ❯❯❯ curl -i -H "X-NYA-N: REQUEST" localhost:8080/todos
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 2
X-NYA-N: RESPONSE
date: Sun, 13 May 2018 18:57:46 GMT

[]% 

service の lookup とかはたぶんこっちのレポジトリなんですがちゃんと追えてないです。また次回かな。

github.com


  1. これまとめてなんていうんだろ。 ref: https://developer.apple.com/library/content/documentation/Swift/Conceptual/Swift_Programming_Language/Declarations.html#//apple_ref/swift/grammar/class-declaration

  2. これミドルウェアに登録したときに該当する Service がなかったら asyncRun よりも前で落ちてほしいんだけどなあ

Re: Rubyのdefの後に何を書けるか調べる実験

sinsoku.hatenablog.com

最後のHashが印象的。以下引用。

# no error
def ({}).foo
  puts "foo"
end

# 別インスタンスなのでメソッドは呼べない
{}.foo
#=> (NoMethodError)

引用終了。

さて、別インスタンスなので呼べないというが、まあ呼びたいじゃん?オブジェクト作ってるんだから ObjectSpace から引っ張ってくればええやん?

def show_hash(h)
  if h
    p h
    p (h.methods - ({}.methods))
  else
    puts "nil"
  end
end

def search_object_with_method(name:)
  ObjectSpace.each_object.find { |e| e.methods.include?(name) }
end

def ({}).foo
  $a = self
end

h = search_object_with_method(name: :foo)
show_hash(h)

試してみまして。

$ ruby foo.rb
{}
[:foo]

ok

ついでだから初期化されたやつも試してみまして。

def show_hash(h)
  if h
    p h
    p (h.methods - ({}.methods))
  else
    puts "nil"
  end
end

def search_object_with_method(name:)
  ObjectSpace.each_object.find { |e| e.methods.include?(name) }
end

def ({}).foo
  $a = self
end

h = search_object_with_method(name: :foo)
show_hash(h)

# ここから追記

puts '-' * 20

def ({a: 1}).bar
end

h = search_object_with_method(name: :bar)
show_hash(h)

まあ、出るっしょ。

$ ruby foo.rb
{}
[:foo]
--------------------
nil

nil !?!?!?!?

え、初期化するかどうかで振る舞い違うの!?ヤバくない?って思ったらGCのせいでした。どこにもバインドしてないからしょうがないね。

GC.disable # これを追加

def show_hash(h)
  if h
    p h
    p (h.methods - ({}.methods))
  else
    puts "nil"
  end
end

def search_object_with_method(name:)
  ObjectSpace.each_object.find { |e| e.methods.include?(name) }
end

def ({}).foo
  $a = self
end

h = search_object_with_method(name: :foo)
show_hash(h)

puts '-' * 20

def ({a: 1}).bar
end

h = search_object_with_method(name: :bar)
show_hash(h)
$ ruby foo.rb
{}
[:foo]
--------------------
{:a=>1}
[:bar]

hanachin さんが調べてくれてたけど NODE_HASH が載ってないですね。わざとなのかしら。

Swift製webアプリケーションフレームワーク Vapor を読むやつ #2

hkdnet.hatenablog.com

前回テンプレートの configure まで読んだので boot 以降を読もうかと思いましたが、routesのところを読み損ねていたことに気づいた ので読みます。

https://github.com/vapor/api-template/blob/3df9b5518b510f5959c75ad04c1b51996a75e367/Sources/App/configure.swift#L11

/// Sources/App/routes.swift
import Vapor

/// Register your application's routes here.
public func routes(_ router: Router) throws {
    // Basic "Hello, world!" example
    router.get("hello") { req in
        return "Hello, world!"
    }

    // Example of configuring a controller
    let todoController = TodoController()
    router.get("todos", use: todoController.index)
    router.post("todos", use: todoController.create)
    router.delete("todos", Todo.parameter, use: todoController.delete)
}

まあなんとなく各 path + method の組み合わせについて関数的ななにか(型的には closure: @escaping (Request) throws -> T where T: ResponseEncodable っぽい)を渡していけばいいんだな、と予想ができます。気になるのは Todo.parameter ですね。Todo はモデルのクラスです。Todo.parameter は何を返すのでしょうか。まずは router.deleteシグネチャから確認しましょう。

    @discardableResult
    public func delete<T>(_ path: PathComponentsRepresentable..., use closure: @escaping (Request) throws -> T) -> Route<Responder>
        where T: ResponseEncodable
    {
        return _on(.DELETE, at: path.convertToPathComponents(), use: closure)
    }

path が可変長引数ですね。なので ["todos", Todo.parameter] が path であることがわかります。

convertToPathComponents() の実態をさぐりにいくと / 区切りで flatmap してそれぞれを path の一部として見ていることがわかります。

extension String: PathComponentsRepresentable {
    /// See `PathComponentsRepresentable`.
    public func convertToPathComponents() -> [PathComponent] {
        return split(separator: "/").map { .constant(.init($0)) }
    }
}

extension Array: PathComponentsRepresentable where Element == PathComponentsRepresentable {
    /// Converts self to an array of `PathComponent`.
    public func convertToPathComponents() -> [PathComponent] {
        return flatMap { $0.convertToPathComponents() }
    }
}

だからたぶん router.delete("foo", "bar")router.delete("foo/bar") は同じ。 翻りまして DELETE の形式はおそらく DELETE /todos/:id なので Todo.parameter:id に相当する何かであろうという予想が立ちます。まあ :id のところはただのプレースホルダーというか名前なので実際に使うほうをみてみましょう。

    /// Deletes a parameterized `Todo`.
    func delete(_ req: Request) throws -> Future<HTTPStatus> {
        return try req.parameters.next(Todo.self).flatMap { todo in
            return todo.delete(on: req)
        }.transform(to: .ok)
    }

next...?

    /// Grabs the next parameter from the parameter bag.
    ///
    ///     let id = try req.parameters.next(Int.self)
    ///
    /// - note: the parameters _must_ be fetched in the order they appear in the path.
    ///
    /// For example GET /posts/:post_id/comments/:comment_id must be fetched in this order:
    ///
    ///     let post = try req.parameters.next(Post.self)
    ///     let comment = try req.parameters.next(Comment.self)
    ///
    public func next<P>(_ parameter: P.Type) throws -> P.ResolvedParameter
        where P: Parameter
    {
        return try request._parameters.next(P.self, on: request)
    }

あ、これ parameter の name でとらない形式のやつじゃん……1 request._parameters.next の実際の実装はこんな感じ。

    public mutating func next<P>(_ parameter: P.Type, on container: Container) throws -> P.ResolvedParameter
        where P: Parameter
    {
        guard values.count > 0 else {
            throw RoutingError(identifier: "next", reason: "Insufficient parameters.")
        }

        let current = values[0]
        guard current.slug == P.routingSlug else {
            throw RoutingError(identifier: "nextType", reason: "Invalid parameter type: \(P.routingSlug) != \(current.slug)")
        }

        let item = try P.resolveParameter(current.value, on: container)
        values = Array(values.dropFirst())
        return item
    }

なんかパラメータだけ取りたいんだけど、ってときは resolveParameter のIFを満たす型であればよさそうですね。String とかは Vapor 内で定義されていました。雑に検証すると以下。

    router.get("echo", String.parameter) { req in
        return try req.parameters.next(String.self)
    }
~/.g/g/h/HelloVapor ❯❯❯ curl localhost:8080/echo/foo
foo%
~/.g/g/h/HelloVapor ❯❯❯ curl localhost:8080/echo/foo-dayo-n
foo-dayo-n% 

ちなみに自分で /echo/:foo とか書いても Not Found です。これは String をかいても PathComponent 内では constant 扱いになるから。

    router.get("echo/:content") { req in
        return try req.parameters.next(String.self)
    }
~/.g/g/h/HelloVapor ❯❯❯ curl localhost:8080/echo/foo
Not found% 

routes に関しては 3.0.1 から .catchall とかが入ってるっぽいんですが、これは .anything の後ろにpathがあったときにぶっ壊れてたのをなおすためにいれたようです。

github.com

今日はここまで。


  1. 予想が外れるのもまたコードリーディングの楽しみではありますが

Swift製webアプリケーションフレームワーク Vapor を読むやつ #1

Vapor とは SSS(Server Side Swift) のwebアプリケーションフレームワークです。

docs: https://docs.vapor.codes/3.0/

セットアップ

CLI ツールの使用を推奨しているので brew でいれておく。プロジェクトの作成は vapor new で。Swift 初心者過ぎてよくわかっていないが XCode で開きたい場合は vapor xcode すると必要なファイルができるっぽい。

$ brew install vapor/tap/vapor 
$ vapor new HelloVapor # HelloVapor ディレクトリができる
$ cd HelloVapor
$ vapor xcode

この時点でのフォルダはこんな感じ。最初から git 管理されているので git ls-files で。

$ git ls-files
.gitignore
Package.resolved
Package.swift
Public/.gitkeep
README.md
Sources/App/Controllers/.gitkeep
Sources/App/Controllers/TodoController.swift
Sources/App/Models/.gitkeep
Sources/App/Models/Todo.swift
Sources/App/app.swift
Sources/App/boot.swift
Sources/App/configure.swift
Sources/App/routes.swift
Sources/Run/main.swift
Tests/.gitkeep
Tests/AppTests/AppTests.swift
Tests/LinuxMain.swift
circle.yml
cloud.yml

vapor new 時に --template (あるいは --web, --auth もしくは --api ) を指定することで設定可能です。以下のようなオプションがあることから単純に git clone してきているのだということがなんとなく推測できます。

       --branch An optional branch to specify when cloning
          --tag An optional tag to specify when cloning

template を作るときはコミットは squash して1コミットにしておいたほうがよさそうですね。

XCode から run して動かしてみます ( https://docs.vapor.codes/3.0/getting-started/xcode/ )

以下適当に動作確認。

$ open http://localhost:8080/hello/
$ curl localhost:8080/todos/
[]
$ curl -XPOST -d "title=1" localhost:8080/todos/
{"id":1,"title":"1"}
$ curl localhost:8080/todos/
[{"id":1,"title":"1"}]
$ curl -XPOST -d '{"title": "2"}' localhost:8080/todos/
{"error":true,"reason":"Value of type 'String' required for key ''."}
$ curl -XPOST -d '{"title": "2"}' -H 'Content-Type: application/json' localhost:8080/todos/
{"id":1,"title":"2"}
$ curl localhost:8080/todos/
[{"id":1,"title":"1"},{"id":2,"title":"2"}]

とりあえずいわゆるふつーのAPIサーバが立ってますね。

テンプレートを読む

デフォルトでは api テンプレートなのでそれにします。

https://github.com/vapor/api-template

エントリポイントっぽいところから。

/// Sources/Run/main.swift
import App

try app(.detect()).run()

Swift 初心者的には .detect() がキモいのですが、これは implicit member expression という書き方のようです。今回の場合は Environment.detect() の省略記法。 https://developer.apple.com/library/content/documentation/Swift/Conceptual/Swift_Programming_Language/Expressions.html#//apple_ref/doc/uid/TP40014097-CH32-ID394

app 関数の定義が Sources/App/app.swift にあります。

/// Sources/App/app.swift
import Vapor

/// Creates an instance of Application. This is called from main.swift in the run target.
public func app(_ env: Environment) throws -> Application {
    var config = Config.default()
    var env = env
    var services = Services.default()
    try configure(&config, &env, &services)
    let app = try Application(config: config, environment: env, services: services)
    try boot(app)
    return app
}

ここから概念として Config, Service(s) が存在すること、 configure, boot を経て Application のインスタンス app が初期化されて返っていることがわかります。

configure から順に見ていきます。

/// Sources/App/configure.swift
import FluentSQLite
import Vapor

/// Called before your application initializes.
public func configure(_ config: inout Config, _ env: inout Environment, _ services: inout Services) throws {
    /// Register providers first
    try services.register(FluentSQLiteProvider())

    /// Register routes to the router
    let router = EngineRouter.default()
    try routes(router)
    services.register(router, as: Router.self)

    /// Register middleware
    var middlewares = MiddlewareConfig() // Create _empty_ middleware config
    /// middlewares.use(FileMiddleware.self) // Serves files from `Public/` directory
    middlewares.use(ErrorMiddleware.self) // Catches errors and converts to HTTP response
    services.register(middlewares)

    // Configure a SQLite database
    let sqlite = try SQLiteDatabase(storage: .memory)

    /// Register the configured SQLite database to the database config.
    var databases = DatabasesConfig()
    databases.add(database: sqlite, as: .sqlite)
    services.register(databases)

    /// Configure migrations
    var migrations = MigrationConfig()
    migrations.add(model: Todo.self, database: .sqlite)
    services.register(migrations)

}

FluentSQLite が ORM ですね。Vapor の内製ライブラリです。middleware の作り方も気になるので後で見ておきましょう。
マイグレーションは明示的に migrations.add してますけど、これ増えたときどうするんですかね。毎回増やすのかな。アノテーションとかでしれっと書けてほしいような1

マイグレーション自体は起動時に勝手にやってくれるようです。

[ INFO ] Migrating 'sqlite' database (FluentProvider.swift:28)
[ INFO ] Preparing migration 'Todo' (MigrationContainer.swift:50)
[ INFO ] Migrations complete (FluentProvider.swift:32)
Running default command: ...

具体的には try Application の中でやっているっぽい。ソース的にはここ。 https://github.com/vapor/vapor/blob/master/Sources/Vapor/Application.swift#L126-L140

今日はここまで。次回boot以降を読みます。

ハマったとこ

sqlite3 の path 問題

sqlite3 の .file("path") で path はディレクトリを含むと困ることがある。sqlite3側は mkdir などはしてくれないのでこちらでしてやる必要があるが、xcode の debug build が動くのは普段のプロジェクトとは違う場所なので mkdir db; touch db/.gitkeep とかしても無駄。

レポジトリ眺めてたらあとでこういう issue を発見した。 https://github.com/vapor/fluent-sqlite/issues/5

カラム追加

一回マイグレーションが動いたあとにモデルに属性を追加しても add_column とかはしてくれない。まあそりゃそうだ。どうなってるのが理想的なんだろう。


なお本記事は以下のエントリに触発されて書きました。

https://fiveteesixone.lackland.io/2018/05/06/addicted-to-vapor/


  1. Swift 的には attribute

macOS 10.13.3 -> 10.13.4 のアップデートをするとSEGVするやつ

概要

朝起きたら macOS のアプデがかかったらしくログインを要求された。
いつも通りユーザーを選択しパスワードをいれて FileVault の解除を待つと見たことない画面へ。
「アプデ適用に失敗しちゃったっぽいよてへぺろ とりあえず再起動する?」と聞かれたので再起動するが症状が改善しない。

TL;DR

  1. cmd + option + R を押しながら起動
  2. wifi を選択して接続、アプデ内容をDL
  3. OS再インストール

NOTE: この操作によりデータが消えたりはしてないです

試したこと

再起動

無限ループ

セーフモード + アプデ再適用

セーフモードで起動すると App Store には「OSアプデがあるよ」と出ている。ので適用。
症状改善せず。

リカバリモードで起動

cmd + R のやつ。アプデ内容が落ちてこなくて終わり。

mcd + option + R のやつ。上述の通りなんとかなった。

参考情報

症状としては下記エントリと同じ。

tidbits.com

SEGVしてるログが出る。
最終的に "failed to write private file" 的なログが出てるのがわかる。

ご参考までに。

Python3 script を雑に heroku で動かせるようにする

手元で動かしてた python スクリプトを web サービス化したときの備忘録です。
なお python 歴はゼロから deep learning 作ったくらいです。

モチベーション

秘伝の python script があった。
手元で動かすのがめんどくなったのでどうにかしたかったが、某サーバー群で python 動かすのとかがダルかったので heroku とかに適当にホストしておきたくなった。

要件は、heroku で動くこと、python3 であること、軽く認証っぽい何かがあることくらい。

実装

misc/python_simple_server.py at master · hkdnet/misc · GitHub

だいたい書いてある通り。 do_GET 潰すの忘れてデプロイしたら普通にディレクトリ見えてビビったのでマジで注意。
検証してないけど bind アドレスを忘れるとかもありそうである1

requirements.txt はレポジトリルートに置いといたら勝手に読んでくれた。助かる。

Procfile はこんな感じ。heroku は 2018-05-02 現在デフォでpython3なので特に気にしなくてよい。

web: python server.py

雑に検証した感じがこれ

$ curl localhost:8000
$ curl -XPOST -d "foo=bar" localhost:8000
$ curl -XPOST -H "Authorization: Bearer YOUR_TOKEN" -d "foo=bar" localhost:8000

# サーバー側のログ↓
127.0.0.1 - - [03/May/2018 04:44:15] "GET / HTTP/1.1" 400 -
127.0.0.1 - - [03/May/2018 04:44:26] "POST / HTTP/1.1" 400 -
127.0.0.1 - - [03/May/2018 04:44:41] "POST / HTTP/1.1" 200 -

蛇足

  • virtualenv -p python3 . で環境つくっておく
  • pip install autopep8autopep8 -i foo.py しとく

  1. 最初から '0.0.0.0' 指定したからそれで動かないのか自信がないが