argius note

プログラミング関連

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のクラス)]totalPagessetして、renderを呼べば、index.htmlのページが表示できます。


ここでは、元のScaffoldのアクションを活かしつつ、リソースの取得は個別処理で、rendershowResourcesと同じにしたい、またはその反対に、リソースの取得は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を呼びたくないなら、ApplicationControllershowResourcesrender抜きメソッドを移植してしまうのも有りかと思います。


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
  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}


  • 表示したところ(背景色は変えてます)

f:id:argius:20140807232904p:plain

こうしておけば、テンプレートを変更することなく、ボタンやラベルの文字や配置をあとから変更しやすくなると思います。


ユーザーの言語設定に応じてメッセージの言語を自動選択する(簡易実装)

英語ユーザーからアクセスされたら英語のメッセージを、日本語ユーザーからアクセスされたら日本語のメッセージを表示する、という機能を実装してみます。


HTTPリクエストヘッダーには、Accept-Languageというフィールドがあります。これは、RFC3282で定義されている仕様で、一般的なWebブラウザーでは「言語設定」で設定された情報を元にフィールド値が決まります。
手元のFirefoxでは、言語設定が順番に「日本語,英語/米国,英語」となっています。この場合、Accept-Languageには

ja,en-us;q=0.7,en;q=0.3

のような値が設定されます。


これを先頭から見て行って、アプリで用意しているものが見つかったら、その言語で表示するようにセッティングすれば、実現できそうです。


ただし、下記リンクのページで主張されているように、この方法で自動選択するだけでは適切とは言えません。一般的なサイトでは、自動選択したとしても、あとで手動で切り替えられるようになっています。

本格的な実装ではもう少し練る必要がありそうですが、ここでは、用意されている言語の中で最も優先度の高い言語に決め打ちして、メッセージを出すようにします。


具体的には、HTTPヘッダーのAccept-Languageの値から選定したロケールを元にI18nを生成して*2RequestScopeに登録する関数(createLocalizedI18nIfPossible)を作ります。これをApplicationControllerbeforeActionで呼ぶようにします。

  • 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でアプリ作るときの参考になれば幸いです。
私個人のネタは出尽くした感がありますが、またネタがストックできたら、公開させていただきます。

*1:HTTP接続ができればWebアプリでなくても可能です。

*2:ロケールが選定できない場合は生成しない