読者です 読者をやめる 読者になる 読者になる

argius note

プログラミング関連

開発しています



Skinny FrameworkのTIPSまとめ (1) #skinnyjp

Skinny Framework(以降skinnyと表記)を使っていく中で、こういう時どうする?みたいなのがいくつか蓄積されたので、まとめてみました。
TIPSと言って良いものかどうかはさておき。

※この記事は、Skinny Framework 1.1.0時点のものです。また、Skinny Framework 1.0.18でも同様に使えます。


目次

  • 画像ファイルをアプリケーションに追加する
  • カスタムcontrollerのMIMEマッピングをデフォルトに合わせる
  • Javaファイルをアプリケーションに追加する
  • ページごとのタイトルをlayoutテンプレートの中で切り替える
  • ファイルアップロード機能のサンプル
  • エラーなどのメッセージを表示する

画像ファイルをアプリケーションに追加する

runモードかwarで動かしている場合は、例えば./src/main/webapp/img.pngにファイルを置けば、localhost:8080/img.pngで参照できます。
standaloneの場合は、ちょっとした仕掛けが必要になります。

まず、standaloneでは、standalone jarファイルを作るときに、./src/main/webapp/の下がそのままjarに格納されるようになっていますので、./src/main/webapp/images/の下に画像ファイルを配備することにしましょう。

それから、standalone jarファイルの中の./src/main/webapp/images/*にアクセスするためのアクセス用controllerを作ってマウントします。これをマウントすると、runモードかwarで使う場合でもこれが呼び出されてしまいますが、./src/main/webapp/がクラスパスディレクトリーになっていますので、同様に処理されるようになります。


ちなみに、このcontrollerは、skinny-assetsAssetsControllerを参考にしています。特に、このあたり(GitHub)コンパイラー以外の部分を見てみてください。

  • imagesの画像ファイルにアクセスするcontroller(Controllers.scalaの一部)
  // ... //

  // mountメソッドに追加
  images.mount(ctx)

  // ... //

  // imagesにアクセスするためのcontrollerオブジェクトを追加
  object images extends SkinnyController with Routes {

    // MIMEマッピング
    addMimeMapping("image/png", "png")
    addMimeMapping("image/jpeg", "jpg")
    addMimeMapping("image/gif", "gif")

    // ルーティング
    get("/images/*")(images).as('images)

    // アクション
    def images(): Any = {
      val resOpt: Option[(String, ClassPathResource)] = for {
        name <- multiParams("splat").headOption
        resource <- ClassPathResourceLoader.getClassPathResource(s"/images/${name}")
      } yield (name, resource)
      resOpt match {
        case Some((name, resource)) if (isModified(resource.lastModified)) =>
          contentType = name.split('.').lastOption.flatMap(formats.get(_)).getOrElse("application/octet-stream")
          setLastModified(resource.lastModified)
          resource.stream
        case Some(_) => halt(304)
        case None => pass()
      }
    }

    // HTTPレスポンスヘッダーに最終更新日時を設定
    private[this] def setLastModified(lastModified: Long): Unit = { /* dummy */ }

    // HTTPリクエストヘッダーをチェックしてリソースが更新されたかどうかを判定
    private[this] def isModified(resourceLastModified: Long): Boolean = true // dummy

  }

  // ... //

setLastModifiedisModifiedは、処理が少し煩雑になるので、ここには書いていません。先に挙げたAssetsControllerを参考にして実装すると良いと思います。

MIMEマッピングについては、次項もご覧ください。


カスタムcontrollerのMIMEマッピングをデフォルトに合わせる

さまざまな種類のファイル(のダウンロード)を扱うアプリを作るとしたら、ファイルの種類ごとにMIMEの設定が必要になります。ですが、あらゆるファイル種別に対応するMIMEマッピングを設定するのはちょっと手間がかかります。

それならば、controllerを介さないでアクセスするのと同じようにすれば良いのでは? さらに、アプリが直接jettyに依存してもかまわないのであれば、jettyの設定を拝借してしまえば良いのでは?
ということで、jetty-httpにあるmime.propertiesを読み込んで設定する処理を書いてみました。

※注意: warで使う場合は、jetty-httpの依存関係を明示的に指定する必要があります。

  • jetty-httpdependency設定(project/Build.scalaの一部)
  // libraryDependencies
      "org.eclipse.jetty" % "jetty-http" % jettyVersion % "compile",


  • jettyMIME設定をカスタムcontrollerに反映する(Controllers.scalaの一部)
    • ※注意 - 追記(2014-08-16): 本項の最後に注意を追記しました。
  case class MimeType(mime: String, extension: String)

  val jettyMimeTypes: Iterator[MimeType] = {
    import java.util.ResourceBundle
    import collection.JavaConverters._
    val rb = ResourceBundle.getBundle("org/eclipse/jetty/http/mime")
    rb.getKeys.asScala.map(ext => MimeType(rb.getString(ext), ext))
  }

  object customController extends SkinnyController with Routes {
    jettyMimeTypes.foreach { mime =>
      addMimeMapping(mime.mime, mime.extension)
    }
    // ... action ...
    // ... routing ...
  }

java.util.ResourceBundleを使って、org/eclipse/jetty/http/mime.propertiesを読み込み、それをMimeTypeの配列に変換しておきます。これを、カスタムcontrollerの初期化時に、すべての要素の対してaddMimeMappingを実行します。

これを実施しておけば、前項の画像ファイルのためのマッピングは不要になります。

※注意 - 追記(2014-08-16): 上記コード例のjettyMimeTypesIterator[MimeType]になっていますので、もしこれを複数Controllerで使ってしまうと、上手くいきません。念のため説明すると、Iteratorは一度しかTraverse(要素を全走査する処理全般)できません(実際に自分でワナに嵌ってしまいました...)。
もし複数で使うならば、jettyMimeTypesメソッドdefにする(毎回実行)か、Arrayにする(キャッシュ)か、対策してください。


Javaファイルをアプリケーションに追加する

これは、どちらかというと、SBTのTIPS(というか基本?)になるのかも知れません。

Play2のように、フレームワークのクラス自体はJavaでは書けませんが、Javaで書いたユーティリティークラスなどは、skinnyで使うことができます。
./src/main/javaディレクトリーを作って、この下にJavaファイルを置けば、SBTが一緒にビルドしてくれます。

  • ./src/main/java/lib/MyUtil.java
package lib;

public class MyUtil {
    public static String foo(String s) {
        return s.toUpperCase();
    }
}

  • MyUtilScalaから呼び出す
import lib.MyUtil
MyUtil.foo("skinny") // => SKINNY


わざわざ説明する必要はないような気もしますが、すべてをScalaで書かなくても良いなら敷居が低くなるんじゃないかな、と思ったので。


ページごとのタイトルをlayoutテンプレートの中で切り替える

skinny-blank-appの内容がそうなっているように、HTMLのBODYの外側をlayoutに持つようにした場合、何かしらの方法でタイトル文字列をlayoutに渡す必要があります。
公式では、Scalateの方法になっています。

これだとページを作るたびにattributes("title")を書かないといけないので、ちょっと面倒かな、と思いました。
そこで、layoutテンプレートとmessages.confを組み合わせて、ページに対応するタイトル文字列を切り替えられるような方法を考えてみました。


companiesmembersというcontrollerがあるとしましょう。
まず、companiesmembersのタイトルをmessages.confに書いておきます。

  • messages.confの一部
companies.title="*** Companies ***"
members.title="*** Members ***"

layoutテンプレートでは、skinny.Skinnyクラスのインスタンスが渡されるようになっていますので、これを使ってページのパスを特定してみます。Skinny.requestPathメソッドから、/companies/fooのような文字列が得られますので、この文字列からcompaniesを取り出して、メッセージのキーとして利用できるようにしてみます。

  • layouts/default.sspの一部
<%@val appId: String = s.requestPath.drop(uri("/").size).takeWhile(_ != '/') %>
<%@val title: String = s.i18n.get(appId + ".title").getOrElse(appId) %>
<title>${title}</title>

appIdには、Skinny.requestPathで取得した文字列から、basePathの後ろから次の/までを取り出したものを設定しています。これによって、companiesmembersが得られます。uri("/").sizeになっているのは、prefixに対応するためです。
titleは、s.i18n.get経由でmessages.confから取得した文字列を設定します。取得できなかったときは、appIdをそのまま設定しています。


もっと細やかに切り替えたければ、ヘルパークラス(object)を作って、テンプレートからはそれを呼び出すようにすれば良いでしょう。


ファイルアップロード(で実際にファイルを保存する)

公式では、下記リンクにあります。

ファイルアップロードについては、上2つのページが参考になると思います。
ただし、実際にファイルに保存する具体的な処理については書かれていませんので、ここではその辺を書いてみます。
exampleのコードに追加する想定で書いていますので、Controllersの変更とfileUpload/index.html.sspについては、最初に挙げたリンクのコードをそのまま使ってください。



まず、アップロードしたファイルの、保存先のディレクトリーの処理について。
開発するPCと、稼働させるサーバーのディレクトリ構成が異なることを想定して、そのマシンのhostnameをキーとしてディレクトリーのパスを取得できるようにします。

設定ファイルには、以下のように設定します。skinny.envだけで切り替えるのであれば、hostnameを使わずにdevelopment.dir.fileUploadのようにしても良いと思います。

  • application.confの一部
dir {
  fileUpload {
    Winpc1 = "D:/fileupload"
    server1 = "/var/www/fileupload"
  }
}

  • application.confからディレクトリーパスを取り出す処理の例(FileUploadController.scalaの一部)
  import java.io.File
  import java.net.InetAddress
  import com.typesafe.config._

  private def getDir: Either[String, File] = {
    val hostName = InetAddress.getLocalHost.getHostName
    val propKey = s"dir.fileUpload.${hostName}"
    ConfigFactory.load.getString(propKey) match {
      case s if !s.trim.isEmpty => {
        new File(s) match {
          case f if !f.exists => Left(s"dir [${f}] does not exist")
          case f if !f.isDirectory => Left(s"[${f}] is not a directory")
          case f => Right(f)
        }
      }
      case _ => Left(s"""prop "${propKey}" is empty""")
    }
  }

この例では、Eitherを使って、ディレクトリーを取得できた場合はjava.io.Fileを、取得できなかった場合はその理由を書いたStringを返すようにしました。
呼び出す側では、case Left(x)のときはディレクトリーの取得が失敗したものと判断します。


アップロードされてきたファイルは、org.scalatra.servlet.FileUploadSupportfileParams.get("file")org.scalatra.servlet.FileItem(Option)を取得できるようになっています。
ですので、何も考えずに...といいつつ上書きのみガードして...保存するだけなら、次のようにします。

  • FileItemをファイルに書き出す
  // import java.io.File

  val dir: File = ... // getDir.right.get

  fileParams.get("file") match {
    case Some(fileItem) =>
      new File(dir, fileItem.name) match {
        case f if f.exists => // error: file ${f} already exists
        case f => fileItem.write(f)
      }
    case None => // file was not set
  } 



エラーなどのメッセージを表示する

Scaffoldされたコードや、examplesなどを見てみると、いくつか見つかります。

  • flash
  • Seq[String]
  • skinny.KeyAndErrorMessages (formのvalidation error用?)

flashは明示的にviewにパラメーターを渡す必要が無いので、手軽に使えます。
Seq[String]は、単に、パラメーターとして渡すだけです。
skinny.KeyAndErrorMessagesの使い方は、Scaffoldして生成されたviews/***/_form.html.sspにそのまま書いてあります。


flashを使った例です。
skinny.Skinny

  • controllerでメッセージを設定
flash += ("error" -> "foo")
  • sspでメッセージを表示
#for (e <- s.flash.error)
<p class="alert alert-danger">${e}</p>
#end

s.flashOption[Any]が設定できますが、Seqなどを使いたいときは、他の方法を使った方が良いと思います。s.flashはあくまでお手軽方式ということで。


と言いつつ、タイトルと同じようにlayoutテンプレートだけで処理できると楽ができるので、ちょっとした仕掛けを作ってしまいましょう。

  • ViewMessageflashへの設定
package controller

abstract class ViewMessage {
  def content: String
  def style: String
}
case class ErrorMessage(content: String, style: String = "alert-danger") extends ViewMessage
case class WarningMessage(content: String, style: String = "alert-warning") extends ViewMessage
case class SuccessMessage(content: String, style: String = "alert-success") extends ViewMessage
case class NoticeMessage(content: String, style: String = "alert-info") extends ViewMessage

// flashへの設定
flash += ("messages" ->
  Seq(ErrorMessage("error"),
      WarningMessage("warning"),
      SuccessMessage("success"),
      NoticeMessage("notice")))

  • テンプレート(ssp)の記述
#for (messages <- s.flash.messages; message <- messages.asInstanceOf[Seq[ViewMessage]])
  <p class="alert ${message.style}">${message.content}</p>
#end


これで、それぞれのページの先頭にメッセージを出すことができるようになりました。



以上、skinnyでアプリ作るときの参考になれば幸いです。
またネタがストックできたら、公開させていただきます。