Skinny FrameworkのTIPSまとめ (2) #skinnyjp
Skinny Framework(以降skinnyと表記)のTIPS、第2弾です。
TIPSと言うより、レシピ集に近いかも知れません。
※この記事は、Skinny Framework 1.2.8
時点のものですが、おそらく、Skinny Framework 1.1.0
以上なら同様に使えると思います。なお、文中のソースコードへのリンクは、1.2.8
になっています。
※サンプルでテンプレートを使用している箇所は、Jadeでの例です。
目次
- ジョブ(バッチ)を作って実行する
- ScaffoldのSkinnyResourceアクションと個別処理を混在させる
- Web APIを作る (jQuery+JSON)
- ページボタンリストのカスタマイズ
- ユーザーの言語設定に応じてメッセージの言語を自動選択する(簡易実装)
ジョブ(バッチ)を作って実行する
裏でデータベースの更新をするジョブ(バッチ)をskinnyで動かす方法です。
skinnyでは、ジョブをスケジュール実行させるSkinnyWorkerService
が用意されています。
また、1.2.4
からは、task:run
コマンドが利用できます。
上記の機能が使えないような(レアな)事情、たとえば
- 外部のスケジューラーを利用したい
standalone-build
でsbtと切り離された環境で実行したい
といった場合の、ジョブの実現方法を考えてみました。
ちょっと邪道かも知れませんが...
基本的には難しいところはありません。
model
を使うので、DB初期化をする- おそらく
Logger
を使うので、使えるようにする
DB初期化するには、skinny.DBSettings.initialize()
を呼び出します。
Logger
を使うには、skinny.logging.Logging
トレイトを継承します。
あとは、main
クラスにするためにApp
トレイトを継承します。
- 例
(src/main/scala/batch/MyBatch.scala)
package batch object MyBatch extends skinny.logging.Logging with App { def runMain(): Unit = { // insert model.Member.createWithAttributes('name -> "johndoe") // update model.Member.updateById(1L).withAttributes('name -> "argius") } try { skinny.DBSettings.initialize() logger.info("start") runMain() logger.info("normal end") } catch { case e: Throwable => logger.error(e) logger.info("abnormal end") } }
これを含めたstandalone-jar
を作れば、以下のコマンドで実行できます。
standalone-jar
を使ってジョブを実行
$ java -Dskinny.env=production -cp "./skinny-my-standalone-app.jar" batch.MyBatch
develop
モードで実行する場合は、
$ sbt run
で実行できます。
mainクラスが複数ある時は、sbtが選択肢を表示してくれます。
1.2.4
以上ならば、task:run
を使っても良いでしょう。
TaskRunner
に追加
register("MyBatch", (params) => { batch.MyBatch.main(params.toArray) })
- 実行
$ sbt "task/run MyBatch"
参考:以前、Play2-scalaでも似たようなことをやっています。
ScaffoldのSkinnyResourceアクションと個別処理を混在させる
Scaffoldが作ってくれる一覧ページは、SkinnyResource#showResources
にルーティングされています。(skinnyresourceactions#showresources
およびSkinnyResourceRoutes
参照)
アクションを実装する場合、最低限、Seq[(modelのクラス)]
とtotalPages
をset
して、render
を呼べば、index.html
のページが表示できます。
ここでは、元のScaffoldのアクションを活かしつつ、リソースの取得は個別処理で、render
はshowResources
と同じにしたい、またはその反対に、リソースの取得はshowResources
で良くて、render
は個別にしたいというアクションを実装してみます。
showResources
とマニュアルを混在
def index = { // 実際はパラメーターなどで判定 val useSkinnyResource = false val useScaffoldTemplate = false if (useSkinnyResource) showResources() else { set(itemsName, model.findAllByIds(1L, 2L)) set(totalPagesAttributeName, 1) } val pageId = if (useScaffoldTemplate) "index" else "index2" render(s"${viewsDirectoryPath}/${pageId}") }
この場合、showResources
を呼び出すとrender
を2回呼ぶことになってしまいます。不要なrender
を呼びたくないなら、ApplicationController
にshowResources
のrender
抜きメソッドを移植してしまうのも有りかと思います。
Web APIを作る (jQuery+JSON)
skinnyでは、JSONを使ったWeb APIが簡単に構築できます。
入力は、通常のリクエストと同様にskinny.controller.Params
から取得できます。
出力は、toJSON
を使えばMapや配列などをJSONに変換できます。
JSONについての公式ドキュメントは下記。
以下は、サンプルです。
MemberController
の一部
// routeは /members/search def search = { contentType = formats("json") logger.debug("params=" + params) val foundRecords = params.get('keyword).map(findMember(_).map(_.name)).getOrElse(Nil) toJSON(Map('result -> foundRecords)) } private def findMember(keyword: String): Seq[Member] = { import scalikejdbc._ val m = model.defaultAlias model.findAllBy(sqls.like(m.name, s"%${keyword}%").and.eq(m.deleted, false)) }
Controllers
の一部
object members extends _root_.controller.MembersController with Routes { val searchUrl = get(s"${viewsDirectoryPath}/search")(search).as('search) }
JavaScriptは、デフォルトでjQueryが使えるようになっていますので、それを使います。
- JavaScriptのコード(
index.html.jade
)
:javascript var p = { "keyword": "Jo" }; var f = function(data, status) { if (status == "success") console.log("name=" + data["result"]); else console.log("error"); } button(onclick="$.getJSON('./search', p, f);") search
- レスポンスのJSONのイメージ
{"result":["John Smith","Jonathan White"]}
もちろん、呼び出し側はskinny-appである必要は無く、JSONさえ使えれば他のアプリ*1から呼び出すこともできます。
ページボタンリストのカスタマイズ
skinnyでは、ページ切替するためのPaginationが実装されていて便利です。
ページボタンも、scaffoldコマンドが作ってくれます。
今回は、ページボタンの処理をある程度統一して、あとからカスタマイズしやすくなるようにしてみます。
まず、Paginator
という名前(もちろん好きな名前でOK)の機能を作ります。
lib/Paginator.scala
package lib case class Paginator(totalCount: Long, countPerPage: Int = 10, currentPage: Int = 1) { case class Item(label: String, destination: Option[Int] = None) { val isLink = destination.isDefined } val totalPages: Int = (totalCount / countPerPage).toInt + (if (totalCount % countPerPage == 0) 0 else 1) val required = totalPages > 1 def itemsOfStyleNumber(n: Int) = n match { case 2 => relativeStyle case 1 | _ => basicStyle } lazy val basicStyle: Seq[Item] = Seq(firstPageButton) ++ numberItems ++ Seq(lastPageButton) lazy val relativeStyle: Seq[Item] = Seq(firstPageButton, prevPageButton, countAndPageLabel, nextPageButton, lastPageButton) lazy val numberItems: Seq[Item] = (1 to totalPages).map(x => Item(s"$x", if (x == currentPage) None else Some(x))) lazy val countAndPageLabel = Item(s"$totalCount records (page $currentPage/$totalPages)") lazy val firstPageButton = Item("<<", if (totalPages != 1 && currentPage != 1) Some(1) else None) lazy val prevPageButton = Item("<", if (currentPage != 1) Some(currentPage - 1) else None) lazy val nextPageButton = Item(">", if (currentPage < totalPages) Some(currentPage + 1) else None) lazy val lastPageButton = Item(">>", if (totalPages != 1 && currentPage < totalPages) Some(totalPages) else None) }
コントローラーでは、検索結果を元にPaginator
を生成し、出力パラメーターに設定します。
MemberController
の一部
// routeは /members/sample def sample = { val pageNo: Int = params.getAs[Int](pageNoParamName).getOrElse(1) val totalCount = model.countAllModels val paginator = lib.Paginator(totalCount, pageSize, pageNo) val items = model.findAllModels set(itemsName -> items) set(totalPagesAttributeName -> paginator.totalPages) set("paginator" -> paginator) render(s"${viewsDirectoryPath}/sample") }
テンプレートでは、こんな感じで使います。include
機能を使って分けておくのも良いかも知れません。
sample.html.jade
-@val s: skinny.Skinny -@val items: Seq[model.Member] -@val totalPages: Int -@val paginator: lib.Paginator // paginator.totalPagesがあるのでtotalPagesは消してもOK ul.pagination - for (item <- paginator.itemsOfStyleNumber(1)) li - if (item.isLink) a(href={s.url(Controllers.members.sampleUrl, "page" -> item.destination.get)}) #{item.label} - else span #{item.label} div ul.pagination - for (item <- paginator.itemsOfStyleNumber(2)) li - if (item.isLink) a(href={s.url(Controllers.members.sampleUrl, "page" -> item.destination.get)}) #{item.label} - else span #{item.label}
- 表示したところ(背景色は変えてます)
こうしておけば、テンプレートを変更することなく、ボタンやラベルの文字や配置をあとから変更しやすくなると思います。
ユーザーの言語設定に応じてメッセージの言語を自動選択する(簡易実装)
英語ユーザーからアクセスされたら英語のメッセージを、日本語ユーザーからアクセスされたら日本語のメッセージを表示する、という機能を実装してみます。
HTTPリクエストヘッダーには、Accept-Language
というフィールドがあります。これは、RFC3282で定義されている仕様で、一般的なWebブラウザーでは「言語設定」で設定された情報を元にフィールド値が決まります。
手元のFirefoxでは、言語設定が順番に「日本語,英語/米国,英語」となっています。この場合、Accept-Language
には
ja,en-us;q=0.7,en;q=0.3
のような値が設定されます。
これを先頭から見て行って、アプリで用意しているものが見つかったら、その言語で表示するようにセッティングすれば、実現できそうです。
ただし、下記リンクのページで主張されているように、この方法で自動選択するだけでは適切とは言えません。一般的なサイトでは、自動選択したとしても、あとで手動で切り替えられるようになっています。
本格的な実装ではもう少し練る必要がありそうですが、ここでは、用意されている言語の中で最も優先度の高い言語に決め打ちして、メッセージを出すようにします。
具体的には、HTTPヘッダーのAccept-Language
の値から選定したロケールを元にI18n
を生成して*2、RequestScope
に登録する関数(createLocalizedI18nIfPossible
)を作ります。これをApplicationController
のbeforeAction
で呼ぶようにします。
ApplicationController
(src/main/scala/controller/ApplicationController.scala
)の一部
beforeAction() { createLocalizedI18nIfPossible(Option(request.getHeader("Accept-Language"))) } // 用意されているmessage.confの言語(ロケール)リスト ※enはデフォルトなので除く val supportedLocaleList = Seq("ja", "en_US") def createLocalizedI18nIfPossible(acceptLanguageOpt: Option[String]): Unit = { acceptLanguageOpt.map { value => val langTags = value.split(",").map(_.takeWhile(_ != ';')) // 言語タグリストから利用可能な言語を探す langTags.find { tag => val localeString = java.util.Locale.forLanguageTag(tag).toString supportedLocaleList.contains(localeString) } map { x => // 切替可能な言語が見つかったらその言語でI18nを生成してRequestScopeに設定 set(skinny.controller.feature.RequestScopeFeature.ATTR_I18N, createI18n()(java.util.Locale.forLanguageTag(x))) } } }
supportedLangList
の内容は、便宜上、ハードコーディングしていますが、もし実際に使うとしたら、application.conf
に書いた方が良いと思います。
言語タグ文字列とロケール文字列は異なるので注意が必要です。たとえば、言語タグen-us
に対応するロケール文字列はen_US
になります。
これで、簡易的にですが、メッセージの言語が自動で切り替えられます。
以上、skinnyでアプリ作るときの参考になれば幸いです。
私個人のネタは出尽くした感がありますが、またネタがストックできたら、公開させていただきます。