はじめに
この記事はウェブクルー Advent Calendar 2018 の17日目の記事になります。
昨日は @maiunderwood さんの「CSSのみでクリスマスツリーを書いてみる」 でした。
最近、ScalaでWebAPI系の処理を書くことが多く、主にPlayframework上にAPIの実装をしているのですが、Play以外でWebAPIを実装できるフレームワークについても何があるのか調べていました。その中で、FinchというFinagleベースのCombinator Libraryを見つけました。色々調べてみると、シンプルにAPIが実装できそうなライブラリであり、circeやrefinedなど色々面白い周辺技術も見つかったので、今回はその時調べた内容についてご紹介しようと思います。
構成
- Scala (2.12.7)
- finch(0.26.0)
- finch/circe (0.26.0)
- finch/refined(0.26.0)
- finagle-mysql(18.10.0)
- featherbed(0.3.3)
Finch は、Twitterが開発したFinagleをベースとしたマイクロフレームワーク(Http API)です。CatsとShapelessが使われており、Heterogeneous Listでroutingをまとめるなど、Scalaの型機能と関数型言語の性質を活かしたラッパーになります。
circe ですが、これはJsonライブラリですね。Finchとうまく連携することで、Jsonを扱う感覚を一切感じずに、Jsonでやり取りをするWebAPIが書けてしまいます。
refined はScalaでRefinement Types(篩型)を扱うためのライブラリです。Refinement Typesと書くと聞き慣れない単語ですが、静的型付けをより細かく設定するような述語を付与した型になります。詳細は後述します。今回は使うタイミングが中々難しいのですが、こちらも少しだけ使ってみました。うまく使いこなす事ができれば、静的型チェッカとして強力なツールになるかと思います。
finagle-mysql は、名前の通り(?)mysql driverです。今回は、Finch(finagleベース)なので、これを使います。featherbed 、これはhttpクライアントです。今回はぐるなびのAPIを叩くのでこの辺も必要になります。
Finch
簡単に、finchでWebAPIを実装する上での構成を説明します。エンドポイントの実装、routing、API起動時の処理の記述があれば、ミニマムなAPIが作れます。
Finchのエンドポイント
以下が、jsonを表すcase classとそのjsonを返すエンドポイントの実装です。/recomend/1
へGETでリクエストを送ると、{"restaurant_id":"g848900","user_name":"anonymous","recommend_text":"日替わりのカレーランチがオススメ","created_at":"2018-12-15 17:30:53.0"}
といった、JSON形式のデータを返してくれます。
case class Recommend(restaurant_id: String, user_name: String, text: String, created_at: String)
val showRecommend: Endpoint[Recommend] = get("recommend" :: path[Long]) { id: Long =>
Recommends.find(id).map {
case Some(rec) => Ok(rec)
case _ => NotFound(new Exception("Record Not Found"))
}
}
Recommends.find(id)
の箇所は、いわゆるDBアクセスの処理です。DBから、idに紐づくFuture[Recommend]型を返す処理になります。OK(やCreatedなど)の時はJsonを返しますが、NotFoundやBadRequest(400番系、500番系)は、Exceptionを引数にとります。case classからJsonへのマッパーの処理が明示的に書かず、finch/circeにより隠蔽されます。
EndpointのレスポンスのJSONを表す型、メソッド(GET, POST ...)、パス(/users)、が綺麗に一箇所にまとまっていて、シンプルに分かりやすいですね。Akka HTTPのようなgetやエンドポイントをネストさせて纏めるスタイルではなく、基本的に1つのエンドポイント+メソッドに対して、1オブジェクトという作りになるようです。
個人的に好みなのは、get関数にpathとして渡しているオブジェクトがHeterogeneous Listになっている箇所でしょうか。エンドポイントのpathに一定のパターンがある場合は、上記のように直接渡すスタイルとして定義するだけでなく、pathの構成を抽象化してFactoryを作ってget関数に渡すといった方法も考えられます。path自体をファーストクラスオブジェクトのように扱う事も可能なのです。
routerを構成する
以下のように書きます。エンドポイントオブジェクトをつなげて言って、最後にfinagleのServiceオブジェクトに変換します。
val service = (showRecommend :+: postRecommend :+: updateRecommend :+: deleteRecommend).toService
showRecommendやpostRecommendは様々なリクエストを処理する各エンドポイント(Endpoint型のオブジェクト)です。他のエンドポイントと同様、列挙して、Finagleのservceオブジェクトに変換します。ここもHetelogenus Listになっている箇所ですね。
どんなエンドポイントがあるのか、一箇所にまとめて記述するので、一覧性も良いかなと思います。
アプリケーションの起動
あとはアプリケーションのエントリーポイントに、サーバーを立てる処理を書くだけです。
def main(args: Array[String]): Unit = {
Await.ready(Http.serve(":8080", service))
}
circe (Jsonのシリアライズ/デシリアライズ)
以下のエンドポイントのコードですが、Jsonを返しているのに、case classからJsonへのMappingが隠蔽されていますね。
case class Recommend(restaurant_id: String, user_name: String, text: String, created_at: String)
val showRecommend: Endpoint[Recommend] = get("recommend" :: path[Long]) { id: Long =>
Recommends.find(id).map {
case Some(rec) => Ok(rec)
case _ => NotFound(new Exception("Record Not Found"))
}
}
余計なボイラープレートが無くアプリケーションのコードとしてはなるべく本質的な部分だけを記述できます。全体的にすっきりした感じを味わえるのが良いですね。そしてその機能を担っているのは、上記には記載していませんでしたが、実は、
import io.circe.generic.auto._
というimport文です。コードの表面上には複雑な型周りの設定は現れませんが、implicitな型定義がauto._下に定義されており、その型定義を読み込む事で自動的にcase classからjsonのstringへシリアライズ/デシリアライズすることになります。なので、auto下の型がファイル内で明示的に使用されていない場合でも、上記のimport文が無いと、コンパイルエラーになります。この辺は後述するrefinedも同様です。
packageからクラスや関数定義を読み込むためにimportするのではなく、型推論をさせるためにimportするという少し変わったimportの仕方になるという見方もでき、面白いですね。
refined (篩型による型検査)
refinedは、ScalaにRefinement Types(篩型)の機能を追加します。Scalaはデフォルトでも静的型検査の機能を持ってはいますが、Refinement Typesにより通常の型検査よりもさらに厳密な型検査を実現します。selaed traitとcase class, case objectを使用することで、ADTや列挙型として、複数のバリエーションを持つ型を定義することはできますが、1字以上の文字を含むString型や、100〜200までの範囲のみのInt型などプリミティブ型内で細かい型定義を行う事はできません。Refinement Typeでは、このような特定の性質を持った型に対して、「述語」を付ける事で変数の中身を詳細に指定した型を付与します。
詳細は以下のリンクが参考になります。
無理矢理感がありますが、今回はこんなふうに使ってみました。そもそも、URLはconfigファイルで管理する感じかもしれませんが。。。
/** ぐるなびAPI */
val GurunaviURL : String Refined Url= "https://api.gnavi.co.jp"
変数GurunaviURLは単なる文字列ではなく、URL型の文字列のみを値として持つことができる変数になりました。そもそもURLの値を格納している変数の型は、URLの正規表現にマッチする文字列のみを保持しているというのが本来のあり方だと言えます。通常のString型であれば、それを保証するものは何もないのですが、Refinedとその述語による制約をつける事により、必ずこの変数(とはいえ、厳密にいえば定数ですが)の値がURLの形をした文字列であるという事を保証してくれます。試しにURLの形式でない文字列を指定するとコンパイル時に弾かれます。
定数のtypoの検査だけでなく、例えば、ライブラリのインターフェースとしてRefinement Typeを使用する事で、パラメータ指定のミスや誤解をコンパイル時に発見することが可能になります。また、refinedでは、URLだけでなく、数値の値の範囲(1以上の整数とか、正数のみ、)、文字列に対して自由に正規表現のチェックがかけられます。String型の定数やコード値をプログラム内に埋め込んでいる場合は、表記ゆれなどを静的にチェックするツールとしても使えますね。
featherbed (Httpクライアント)
featherbedは、基本的にPlayframeworkのWSClientやAkka HTTPのClientと使い方にはそこまで違いはありません。circeと連携することで、case classにjsonのシリアライズ/デシリアライズができるようになります。
case class Rest(id: String, name: String)
case class GurunaviResponse(rest: Seq[Rest])
val GurunaviURL : String Refined Url= "https://api.gnavi.co.jp"
val gurunaviAPI = new featherbed.Client(new URL(GurunaviURL))
def callSearchRestaurant(name: String) = {
val searchRestaurant = gurunaviAPI
.get(s"/RestSearchAPI/v3/?keyid=${apiKey}&areacode_m=${areacode_m_yebis}&name=${name}")
.accept("application/json")
for {
response <- searchRestaurant.send[GurunaviResponse]()
} yield Responseに対する処理...
}
恵比寿おすすめランチスポットAPI
FinchでAPIを実装するお題として、今回は、ぐるなびのAPIを使い恵比寿のおすすめランチスポットAPIを実装してみようと思います。APIの仕様としては、店舗名とおすすめ情報を投稿/管理するようなシンプルなCRUDを提供するものとします。
ランチスポット(店舗)の情報は、ぐるなびのAPIで取得できるため、店舗の詳細な情報はAPI経由で情報を取得する事とし、
おすすめ投稿の情報だけをテーブルに持たせるようにします。テーブルとしては以下のような感じです。
create table recommend_restaurants (
`recommend_id` Int not null primary key auto_increment comment 'おすすめ投稿ID',
`gnavi_id` varchar(255) not null comment 'ぐるなびAPI 店舗ID' ,
`user_name` varchar(30) not null comment '投稿者名',
`recommend_text` varchar(500) not null comment '推薦文',
`created_at` datetime default current_timestamp comment '投稿日時'
);
店舗名とぐるなびの店舗IDを紐付ける箇所ですが、ぐるなび自体は、検索用のAPIのみの提供となるので、少し強引ですが、APIで検索をかけ、検索結果のトップの店舗をおすすめの店舗と考えましょう。
全体のコード
今回使用したライブラリは以下のような感じになります。
build.sbt
lazy val root = (project in file(".")).
settings(
inThisBuild(List(
organization := "com.example",
scalaVersion := "2.12.7",
version := "0.1.0-SNAPSHOT"
)),
name := "yebis_lunch",
)
resolvers += Resolver.sonatypeRepo("snapshots")
libraryDependencies ++= Seq(
"com.twitter" %% "finagle-mysql" % "18.10.0",
"com.github.finagle" %% "finch-core" % "0.26.0",
"com.github.finagle" %% "finch-circe" % "0.26.0",
"com.github.finagle" %% "finch-refined" % "0.26.0",
"io.circe" %% "circe-core" % "0.10.1",
"io.circe" %% "circe-generic" % "0.10.1",
"io.github.finagle" %% "featherbed-core" % "0.3.3",
"io.github.finagle" %% "featherbed-circe" % "0.3.3"
)
アプリケーションのコードとしては、以下のような感じです。
Main.scala
package yebislunch
import java.net.URL
import com.twitter.finagle.{Http, Mysql}
import com.twitter.util.{Await,Future}
import io.finch._
import io.finch.circe._
import io.finch.syntax._
import io.circe.generic.auto._
import featherbed.circe._
import eu.timepit.refined._
import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.string._
case class CrudResult(id: Long, result: Boolean)
case class Rest(id: String, name: String)
case class GurunaviResponse(rest: Seq[Rest])
case class Recommend(restaurant_id: String, user_name: String, recommend_text: String, created_at: String)
case class RecommendPost(restaurant_name: String, user_name: String, recommend_text: String)
case class RecommendPatch(user_name: String, recommend_text: String)
object Main {
implicit val client = Mysql.client
.withCredentials("root", "")
.withDatabase("yebis_lunch")
.newRichClient("127.0.0.1:3307")
val apiKey = ""
/** ぐるなびAPI */
val GurunaviURL : String Refined Url= "https://api.gnavi.co.jp"
/** 恵比寿のエリアコード */
val areacode_m_yebis = "AREAM2178"
def restaurantSearch(name: String): String = {
s"/RestSearchAPI/v3/?keyid=${apiKey}&areacode_m=${areacode_m_yebis}&name=${name}"
}
val callEndpoint = new featherbed.Client(new URL(GurunaviURL))
val showRecommend: Endpoint[Recommend] = get("recommend" :: path[Long]) { id: Long =>
Recommends.find(id).map {
case Some(rec) => Ok(rec)
case _ => NotFound(new Exception("Record Not Found"))
}
}
val postRecommend: Endpoint[CrudResult] = post("recommend" :: jsonBody[RecommendPost]) { rec: RecommendPost =>
val accepter = callEndpoint
.get(restaurantSearch(rec.restaurant_name))
.accept("application/json")
for {
res <- accepter.send[GurunaviResponse]()
response <- res.rest.headOption match {
case Some(Rest(id, name)) => for {
n <- Recommends.create(id, rec.user_name, rec.recommend_text)
} yield Ok(CrudResult(n, true))
case None => Future(Ok(CrudResult(-1, true)))
}
} yield response
}
val updateRecommend: Endpoint[CrudResult] = patch("recommend" :: path[Long] :: jsonBody[RecommendPatch]) { (id: Long, p: RecommendPatch) =>
for {
rows <- Recommends.update(id, p.user_name, p.recommend_text)
} yield Ok(CrudResult(id, rows == 1))
}
val deleteRecommend: Endpoint[CrudResult] = delete("recommend" :: path[Long]) { id: Long =>
for {
rows <- Recommends.delete(id)
} yield Ok(CrudResult(id, rows == 1))
}
val service = (showRecommend :+: postRecommend :+: updateRecommend :+: deleteRecommend).toService
def main(args: Array[String]): Unit = {
Await.ready(Http.serve(":8081", service))
}
}
Recommends.scala
package yebislunch
import com.twitter.finagle.mysql._
import com.twitter.util.Future
object Recommends {
def find(id: Long)(implicit client: Client): Future[Option[Recommend]] =
client.prepare("select gnavi_id, user_name, recommend_text, created_at from recommend_restaurants where recommend_id = ?")(id)
.map( _.asInstanceOf[ResultSet].rows.map(convertToEntity).headOption )
def convertToEntity(row: Row): Recommend = {
val StringValue(gnavi_id) = row("gnavi_id").get
val StringValue(user_name) = row("user_name").get
val StringValue(recommend_text) = row("recommend_text").get
val TimestampValue(created_at) = row("created_at").get
Recommend(gnavi_id, user_name, recommend_text, created_at.toString)
}
def create(gnavi_id: String, user_name: String, recommend_text: String)(implicit client: Client): Future[Long] =
client
.prepare("insert into recommend_restaurants(gnavi_id, user_name, recommend_text) values(?, ?, ?)")
.modify(gnavi_id, user_name, recommend_text)
.map(_.insertId)
def delete(id: Long)(implicit client: Client): Future[Long] =
client
.prepare("delete from recommend_restaurants where recommend_id = ?")
.modify(id)
.map(_.affectedRows)
def update(id: Long, user_name: String, recommend_text: String)(implicit client: Client): Future[Long] =
client
.prepare("update recommend_restaurants set user_name = ?, recommend_text = ? where recommend_id = ?")
.modify(user_name, recommend_text, id)
.map(_.affectedRows)
}
リクエストとレスポンスの例
以下のようなJsonリクエストを送ると、
POST /recommend HTTP/1.1
Content-Length: 155
Host: localhost:8081
Content-Type: application/json
{
"restaurant_name": "ローカルインディア",
"user_name": "anonymous",
"recommend_text": "日替わりのカレーランチがオススメ"
}
以下のようなレスポンスが返ってきます。
HTTP/1.1 200 OK
Date: Sat, 15 Dec 2018 10:36:31 GMT
Server: Finch
Content-Type: application/json
content-encoding: gzip
content-length: 48
{"id":5,"result":true}
おわりに
Finchを使い、DBアクセス、外部サービス連携ができるシンプルなCRUDアプリを実装してみました。一覧表示や、バリデーション、ぐるなびAPIと連携して色々情報を取ってくるAPIの所までは、などは説明を簡略化するため、今回は実装していませんが、上記の内容で一通りのWebAPIとしての機能が実現できそうな事が分かりました。
個人的な課題感のある箇所としては、refinedで値が満たすべき条件を型で表現する | DevelopersIO にあるような、circe/refinedの連携をFinchを使いつつ実現していく所や、型エラーが起きた時のハマりポイントのノウハウ化ができれば良かったかなと思います。
Finchは、いくつもの企業のプロダクション環境での採用実績のあるフレームワークです。日本語情報が少ないのとakkaと比べて若干ググらビリティが劣るのが中々辛いですが、コードの記述自体は短く、シンプルで分かりやすい形にまとめられるため、素早くWebAPIを開発するのにFinchは格好のフレームワークなのではないでしょうか!
明日は @yuko-tsutsui さんになります。よろしくおねがいします!