この記事はリクルートライフスタイル Advent Calendar 2015 - Qiita の18日目です。
こんにちは、nirazoです。iOSエンジニアです。
Swiftのお話
先日Swiftがオープンソース化され、業界はにわかに盛り上がっていますね。そして早くもSwift3.0に向けたリポジトリも公開され、これからまた更に盛り上がっていくことは想像に難くありません。
しかし、Swiftといえば「iOSとかMacのアプリ作るときしか使えないじゃん」「汎用性がね…」などと言われ、私としては少し悲しい思いをしていたりします。
そんな中!11月末に、遂にSwift向けのWebアプリケーションサーバ・フレームワークの大本命といえそうなプロダクトが発表されました。
その名もPerfect。
Swiftのサーバサイドフレームワークといえば
TaylorやSwifterといったものもありますが、Perfectは12/17 20:00時点で既にStar数が3900を超えており、(Taylorは約560, Swifterは約1200)公式ページを見ても本気度が伝わってきます。
そこで本日はこのアプリケーションサーバとフレームワークについてつらつらと書いていきたいと思います。
少し説明
Perfectは、カナダのNewmarketという町にあるスタートアップ企業が開発しており、いくつか海外の記事でも取り上げられています。
Apache + FastCGIと組み合わせて使うこともできますし、スタンドアロンでも動作します。
DBとの接続については、MySQL, PostgreSQL, MongoDB, SQLiteのconnecterが実装されています。
Linux(Ubuntu), MacOSで動作するとのことですが、Macではうまくビルドできず、現状はXCodeでの動作確認しかできないようです。
詳しい情報についてはPerfectのライブラリのREADMEをご覧下さい。
まだまだ公開されたばかりということもあり、チュートリアル的なドキュメントもまだ十分に揃っていません。そしてネット上にも情報が少ない。。。
と、まだまだ発展途上感は否めませんが、私としてはわくわくしているのでひとまずサンプルアプリを動かしてみました!
ちなみに今回はローカル環境での動作確認と、アプリ全体の挙動について説明したいと思います。
現在サーバサイド、クライアントサイド共にアプリを開発していますが、如何せん情報が少なくつまづく部分が多く。。。
XCodeで1からPerfectアプリを作る準備は完了したので、アプリを作ったら実装編を書こうと思います。
サンプルアプリを動かしてみた
PerfectのExamplesプロジェクトには3種類のサンプルアプリが入っています。
今回は一番シンプルなTap Trackerを動かしてみます
-
Perfectのリポジトリからfork & 任意のディレクトリにclone
https://github.com/PerfectlySoft/Perfect.git -
Perfect/Examples/Examples.xcworkspace をXcodeで開く
-
Build Targetに「Tap Tracker Server」を指定してRun
- 下の画像のように、Perfect Server Appが立ち上がればサーバサイドの準備完了
これでアプリが動きました!簡単!
では、コードを見ながら挙動を確認してみましょう。ざっと流れを書くと
- ボタンをタップして現在の緯度経度、時刻をPOSTで送信
- POSTされた緯度経度、時刻をサーバ(ここではlocalhost)のSQLiteに格納
- POSTのレスポンスとして前回タップ時の緯度経度、時刻がJSON形式で返却される
- レスポンスをパースし、返って来た緯度経度を中心にしたマップを表示(マップ上のピンをタップすると時刻も表示)
という感じです。
緯度経度、時刻の送信
下記のコードで通信先のエンドポイントを指定します。
let END_POINT_HOST = "localhost"
let END_POINT_PORT = 8181
let END_POINT = "http://\(END_POINT_HOST):\(END_POINT_PORT)/TapTracker"
ここではlocalhostのTapTrackerですね。
そしてボタンがタップされた時のメソッド(の抜粋)は下記です。
let lat = loc.coordinate.latitude
let long = loc.coordinate.longitude
let postBody = "lat=\(lat)&long=\(long)"
let req = NSMutableURLRequest(URL: NSURL(string: END_POINT)!)
req.HTTPMethod = "POST"
req.HTTPBody = postBody.dataUsingEncoding(NSUTF8StringEncoding)
let session = NSURLSession.sharedSession()
let task = session.dataTaskWithRequest(req, completionHandler: {
(d:NSData?, res:NSURLResponse?, e:NSError?) -> Void in
if let _ = e {
print("Request failed with error \(e!)")
} else {
let strData = String(data: d!, encoding: NSUTF8StringEncoding)
print("Request succeeded with data \(strData)")
do {
if let strOk = strData {
let jsonDecoded = try JSONDecode().decode(strOk)
if let jsonMap = jsonDecoded as? JSONDictionaryType {
if let sets = jsonMap.dictionary["resultSets"] as? JSONArrayType {
// just one result in this app
if let result = sets.array.first as? JSONDictionaryType {
if let timeStr = result.dictionary["time"] as? String,
let lat = result.dictionary["lat"] as? Double,
let long = result.dictionary["long"] as? Double {
self.timeStr = timeStr
self.lat = lat
self.long = long
dispatch_async(dispatch_get_main_queue()) {
self.performSegueWithIdentifier("showMap", sender: self)
}
}
}
}
}
}
} catch let ex {
print("JSON decoding failed with exception \(ex)")
}
}
})
task.resume()
だらだらとコピーしましたが、lat=hoge&long=fuga&
をパラメータに指定してPOSTしているだけです。
POSTされた緯度経度、時刻をサーバ(ここではlocalhost)のSQLiteに格納
ここからがPerfectのサーバサイドプログラムの挙動です。
まず、サーバサイドプログラムの構成ですが
- TTHandlers.swift
- TapTracker.mustache
- Tap Tracker Server.h
- Info.plist
- index.html
となっています。
1からプロジェクトを作る時にも出てきますが、PerfectのXcodeテンプレートをXcodeに追加してプロジェクトを作ると、これらのファイルが生成されます(.swift, .mustache, .h のファイル名はデフォルトのものになります)。
ここで肝になるのが上の2つのファイルです。ここではTTHandlers.swiftの処理を説明します。もう一つの説明は後ほど。
TTHandlers.swift
TapTrackerリクエストを受け取った際の処理を行うクラス(TTHandler)が記述されています。
なお、全てのHandlerはPageHandlerメソッドを継承している必要があります。
ちなみに、このプロジェクトでは、PerfectServerモジュールのInitメソッドで、プロジェクト内で使用する全Handlerの登録を行うPerfectServerModuleInit()
がこのファイル内に書かれています。
他のサンプルを見ると分かりますが、複数のHandlerが存在する場合は、下記のようにHandler群を管理するためのファイルを別に切り出すと良さそうです。
import PerfectLib
// This is the function which all Perfect Server modules must expose.
// The system will load the module and call this function.
// In here, register any handlers or perform any one-time tasks.
public func PerfectServerModuleInit() {
// Register our handler class with the PageHandlerRegistry.
// The name "TTHandler", which we supply here, is used within a mustache template to associate the template with the handler.
PageHandlerRegistry.addPageHandler("LoginHandler") {
// This closure is called in order to create the handler object.
// It is called once for each relevant request.
// The supplied WebResponse object can be used to tailor the return value.
// However, all request processing should take place in the `valuesForResponse` function.
(r:WebResponse) -> PageHandler in
return LoginHandler()
}
// This handler takes the new user information and puts it in the database.
PageHandlerRegistry.addPageHandler("RegistrationHandler") {
return RegistrationHandler()
}
// This handler does literally nothing.
PageHandlerRegistry.addPageHandler("NullHandler") {
return NullHandler()
}
}
HandlerはvaluesForResponse(context: MustacheEvaluationContext, collector: MustacheEvaluationOutputCollector)
メソッドを実装する必要があります。
では、TTHandlerのvalueForResponse
メソッドでリクエストをどう処理しているか見てみましょう。
func valuesForResponse(context: MustacheEvaluationContext, collector: MustacheEvaluationOutputCollector) throws -> MustacheEvaluationContext.MapType {
// The dictionary which we will return
var values = [String:Any]()
print("TTHandler got request")
// Grab the WebRequest
if let request = context.webRequest {
// Try to get the last tap instance from the database
let sqlite = try SQLite(TRACKER_DB_PATH)
defer {
sqlite.close()
}
// Select most recent
// If there are no existing taps, we'll just return the current one
var gotTap = false
try sqlite.forEachRow("SELECT time, lat, long FROM taps ORDER BY time DESC LIMIT 1") {
(stmt:SQLiteStmt, i:Int) -> () in
// We got a result row
// Pull out the values and place them in the resulting values dictionary
let time = stmt.columnDouble(0)
let lat = stmt.columnDouble(1)
let long = stmt.columnDouble(2)
do {
let timeStr = try ICU.formatDate(time, format: "yyyy-MM-d hh:mm aaa")
let resultSets: [[String:Any]] = [["time": timeStr, "lat":lat, "long":long, "last":true]]
values["resultSets"] = resultSets
} catch { }
gotTap = true
}
// If the user is posting a new tap for tracking purposes...
if request.requestMethod() == "POST" {
// Adding a new ta[ instance
if let lat = request.param("lat"), let long = request.param("long") {
let time = ICU.getNow()
try sqlite.doWithTransaction {
// Insert the new row
try sqlite.execute("INSERT INTO taps (time,lat,long) VALUES (?,?,?)", doBindings: {
(stmt:SQLiteStmt) -> () in
try stmt.bind(1, time)
try stmt.bind(2, lat)
try stmt.bind(3, long)
})
}
// As a fallback, for demo purposes, if there were no rows then just return the current values
if !gotTap {
let timeStr = try ICU.formatDate(time, format: "yyyy-MM-d hh:mm aaa")
let resultSets: [[String:Any]] = [["time": timeStr, "lat":lat, "long":long, "last":true]]
values["resultSets"] = resultSets
}
}
}
}
// Return the values
// These will be used to populate the template
return values
}
前半の30行くらいはSQLiteに接続し、tapsテーブルの中から最新のデータの時刻・緯度・経度を取得してvalues["resultSets"]
に格納しています。
そして後半部分ですが、
- リクエストパラメータから緯度・経度を取得
-
ICU.getNow()
で現在時刻を取得(ICUについてはこちら) - SQLiteに緯度・経度・時刻をcommit
- データが1件も無ければ、現在(リクエストを投げた時点)の緯度・経度・時刻を
values
に格納
なお、XCodeで動かした場合、SQLiteのDBは下記パスにあるので中身を見てみます
~/Library/Developer/Xcode/DerivedData/Examples-hogehogehogehoge/Build/Products/Debug/SQLiteDBs
sqlite> select * from taps;
id|time|lat|long
1|1450353969907.0|40.759211|-73.984638
2|1450353972774.0|40.759211|-73.984638
3|1450353974768.0|40.759211|-73.984638
うん、入ってますね。緯度経度はシミュレータの固定の値です。
レスポンスとして前回タップ時の緯度経度、時刻がJSON形式で返却される
先ほどvalues
というDictionaryにレスポンスを格納しましたが、ではどうやってJSON形式で返却するのでしょう。
その役割を担っているのがTapTracker.mustacheです。
Perfectでは、mustacheというテンプレートエンジンでレスポンスを作成しています。中身はこんな感じです。
{{% handler:TTHandler}}{{
}}{"resultSets":[{{#resultSets}}{"time":"{{time}}","lat":{{lat}},"long":{{long}} }{{^last}},{{/last}}{{/resultSets}}]}
1行目の{{% handler:TTHandler}}
で対応するHandler名を指定しているのがわかります。
なお、クライアントサイドからリクエストを投げる際のエンドポイント(TapTracker
)の部分をmustacheのファイル名と同一にする必要があるようですね。
テンプレートの中で、resultSetsからtime, lat, longをセットしているのがわかります。
このテンプレートで返却されるJSONが下記です。
{
"resultSets" : [
{
"lat" : 40.759211,
"time" : "2015-12-17 09:06 PM",
"long" : -73.984638
}
]
}
あとはクライアントサイドでJSONパースして表示するだけですね。
最後に
さて、随分長々と書いてしまいましたが、ざっくり全体の動きはわかって頂けたでしょうか。
まだまだ世に出たばかりで情報も少なく、今後実用に耐えうるものになるかは未知数ですが、個人的には期待できるかなと勝手に思っているので、引き続きウォッチしていきたいと思います!
記事もちょこちょこ書いていく予定です!