WAFの実例を通じて実際の雰囲気を掴みましょう。
- 4.1 Scalatraとは
- 4.2 scala-Intern-Bookmarkとは
- 4.3 ブックマーク一覧を作ってみよう
- 4.3.1 URI設計
- 4.3.2 Controllerを書こう
- 4.3.3 Viewを書こう
- 4.3.4 テストを書こう
- 4.4 他の機能も作ってみよう
- 4.5 URIを変更してみよう
Ruby の Sinatra ライクな WAF です。簡単な Web アプリケーションを書くのには適しているといえるでしょう。
ではこれから、Scalatra を使ってWebアプリケーションを作っていきましょう。
その完成形のお手本を用意しました。この資料では説明しませんが、はてな OAuth によるユーザー認証まで作りこんでいるので資料のコードと若干内容が異なります。 以下の手順でcloneしてみてください。
git clone https://github.com/hatena/scala-Intern-Bookmark
Scalatra を利用して作成した Web アプリの例です。
$ tree scala-Intern-Bookmark
scala-Intern-Bookmark
├── README.md
├── build.sbt
├── db
│ └── schema.sql
├── project
│ ├── build.properties
│ └── plugins.sbt
└── src
├── main
│ ├── resources
│ │ ├── application.conf
│ │ └── logback.xml
│ ├── scala
│ │ ├── HatenaOAuth.scala
│ │ ├── ScalatraBootstrap.scala
│ │ └── internbookmark
│ │ ├── cli
│ │ │ └── BookmarkCLI.scala
│ │ ├── model
│ │ │ ├── Bookmark.scala
│ │ │ ├── Entry.scala
│ │ │ └── User.scala
│ │ ├── repository
│ │ │ ├── Bookmarks.scala
│ │ │ ├── Entreis.scala
│ │ │ ├── Identifier.scala
│ │ │ ├── TitleExtractor.scala
│ │ │ ├── TitleExtractorDispatch.scala
│ │ │ ├── Users.scala
│ │ │ └── package.scala
│ │ ├── service
│ │ │ ├── BookmarkApp.scala
│ │ │ ├── Error.scala
│ │ │ ├── Json.scala
│ │ │ └── package.scala
│ │ └── web
│ │ ├── AppContextSupport.scala
│ │ ├── BookmarkAPIWeb.scala
│ │ ├── BookmarkWeb.scala
│ │ └── BookmarkWebStack.scala
│ ├── twirl
│ │ └── internbookmark
│ │ ├── add.scala.html
│ │ ├── delete.scala.html
│ │ ├── edit.scala.html
│ │ ├── list.scala.html
│ │ └── wrapper.scala.html
│ └── webapp
│ ├── WEB-INF
│ │ └── web.xml
│ └── stylesheets
│ └── default.css
└── test
├── resources
│ └── test.conf
└── scala
└── internbookmark
├── helper
│ ├── EntriesDBForTest.scala
│ ├── Factory.scala
│ ├── SetupDB.scala
│ ├── UnitSpec.scala
│ └── WebUnitSpec.scala
├── service
│ └── BookmarkAppSpec.scala
└── web
└── BookmarkWebSpec.scala
src/main/scala以下の重要な項目としては以下のとおり。
- src/main/scala/ScalatraBootstrap.scala
- Scalatraの起動をおこなってるファイル
- src/main/resources/application.conf
- アプリケーションの設定はここ
- src/main/scala/internbookmark/web/BookmarkWeb.scala
- URLの設定はここ
- Model的なやつ
- src/main/scala/internbookmark/service
- メインアプリケーション
- src/main/scala/internbookmark/model
- データモデリング
- src/main/scala/internbookmark/repository
- 外部問い合わせ(データベースなど)
- service, repository, model を合わせて俗にいう Model っていう雰囲気です
- src/main/scala/internbookmark/service
- Intern Bookmark を動かすには事前にはてなにアプリケーション登録が必要です
$ mysqladmin create internbookmark
$ mysqladmin create internbookmark_test
$ cd /path/to/scala-Intern-Bookmark
$ cat db/schema.sql | mysql -uroot internbookmark
$ cat db/schema.sql | mysql -uroot internbookmark_test
$ sbt -Dhatenaoauth.consumerkey="hogekey" -Dhatenaoauth.consumersecret="fugasecret"
> container:start
# http://localhost:8080 でアクセスできる
実装に入る前にまずはURIを設計します。
Bookmarkアプリでの機能は以下のとおり。
- 一覧 (list)
- 表示
- 作成 (add)
- 削除 (del)
これらに対応するURIは以下のように設計できる。
パス | メソッド | 動作 |
---|---|---|
/bookmarks | GET | ブックマーク一覧 |
/bookmark/:id | GET | ブックマークの permalink (:idは追加時に採番される) |
/bookmark/add?url=url&comment=comment | POST | ブックマークの追加 |
/bookmark/:id/delete | POST | ブックマークの削除 |
上記のURI設計におけるブックマーク一覧(/bookmarks
)を例として、Controllerを作っていきます。
src/main/scala/internbookmark/web/BookmarkWeb.scala に URLのpathとそれに対応する処理を書くようになっているので以下のようにすれば簡単にHello Worldをブラウザに表示できます。
get("/") {
Ok("Welcome to the Hatena world!")
}
Ok("Welcome to the Hatena world!")で出力を直接指定します。
もう少し大きなアプリケーション用のWAFだと、ルーティング設定(URLと処理のマッピング)とコントローラーは分離されているものが多いです。Scalatraはもう少し手軽なWAFなので、ルーティング設定とControllerが一緒になっています。
例えば、Mackerelで使っているPlay Frameworkでは以下のようにURLとそれに対応する処理のメソッド名を記述するroutesファイルというものがあります。
GET / Application.index
GET /bookmarks Bookmarks.list
> run list
に対応- Controllerがやるべきこと
- ユーザのブックマーク一覧を取得
- 取得したブックマーク一覧を出力(Viewに渡す)
// src/main/scala/internbookmark/web/BookmarkWeb.scala
get("/bookmarks") {
// userはSongmu決め打ち
val currentUserName = "Songmu"
// Userを取得
val currentUser = repository.Users.findOrCreateByName(currentUserName)
// ブックマーク一覧を取得
val list = repository.Bookmarks.listAll(currentUser).toList
// Viewを指定し、ブックマーク一覧をViewに渡す
internbookmark.html.list(list)
}
- ユーザのブックマーク一覧の取得
- ビュー指定とデータの受け渡し
- internbookmark.html.*(...) でviewのファイルの指定と、データの受け渡しができる
- いろんなページで使うロジックはモデルに分離しておくべき
- Fat Controllerを避ける
- ユーザーが主体となってBookmarkの情報にアクセスするパターンは頻出
- ユーザー情報を持ったアプリケーションclassを定義する
// src/main/scala/internbookmark/service/BookmarkApp.scala
package internbookmark.service
import internbookmark.model.{Bookmark, User}
import internbookmark.repository
class BookmarkApp(currentUserName: String) {
def currentUser(implicit ctx: Context): User = {
repository.Users.findOrCreateByName(currentUserName)
}
def list()(implicit ctx: Context): List[Bookmark] =
repository.Bookmarks.listAll(currentUser).toList
}
先ほどのBookmarkWeb.scalaは以下のようにできる。
// src/main/scala/internbookmark/web/BookmarkWeb.scala
get("/bookmarks") {
app = new BookmarkApp("Songmu")
internbookmark.html.list(app.list())
}
- Controllerにはロジックを書かないくらいの気持ちでいると、綺麗にかける(かも)
- 例えばコントローラーでやっている処理を別の場所から再現可能か
- Intern-Bookmarkは俗にいうMVCのMの部分がもう少しレイヤー化されている
- service
- コアロジック
- model
- データのモデリング
- データベースへのアクセスは 行わない
- repository
- データベースへのアクセスを行う
- modelへのマッピングを行いオブジェクトを返却する
- Data Mapper Pattern
- Controllerがserviceにアクセスし、serviceはrepositroyにアクセスしmodelを受け取りそれをControllerに渡す
- service
なぜこうなっているのか?
- DDD的な設計アプローチ
- 適切なレイヤリングを行いそれぞれの責務が混在するのを避ける
- それぞれのレイヤー毎に再利用/差し替えしやすいように
- 静的言語だとこういう所しっかりやっておいたほうがいいみたいなのもありそう(私見)
- テストしやすいとか
- Controllerで
html.list()
を指定しているので、src/main/twirl/internbookmark/list.scala.htmlが使われる- このファイルはTwirl(読み方はトゥワール?)というscalaのテンプレート形式になっている
- play framework 標準のテンプレートエンジン
- 型安全なテンプレートエンジン
- つまりコンパイルされる -> ちょっと変更しただけでも再コンパイルの必要がある
- Mackerelで採用
- Scalaのテンプレートエンジンは他にも
- Lift template
- Scalate
the magic '@' character
@
以降に式が書ける。終了位置はよしなに判断してくれる。
- Controllerで渡した変数を受け取る
- ちゃんと型を書く
@(bookmarks: List[internbookmark.model.Bookmark])
- 配列に対する繰り返し
@
とfor
と(
の間はスペースを開けないように注意(これはif
などでも同様)
@for(bookmark <- bookmarks){
<li><a href="@bookmark.entry.url">@bookmark.entry.title</a> - @bookmark.comment - @bookmark.createdAt.toStrin<a href="/bookmarks/@bookmark.id/edit">edit</a> <a href="/bookmarks/@bookmark.id/delete">delete</a></li>
}
@if(true){ ... } else { ... }
@widget.socialButtons()
@* include widget/socialButtons.scala.html *@
コントローラーからの呼出し同様にテンプレート名を指定してそのまま呼び出せます。
// wrapper.scala.html
@(title: String)(content: Html)
<html><body>
@content
</body><content>
以下のようにして呼び出します。
@wrapper {
<p>Hello World!</p>
}
{}
の内部が、wrapperにcontent
として渡されます。
@* コメント! *@
参考
- Twirl: https://github.com/playframework/twirl
- https://www.playframework.com/documentation/2.3.x/ScalaTemplates
Controllerで指定したViewはsrc/main/twirl/internbookmark/list.scala.htmlでしたね。そこに追加して行きましょう
@(bookmarks: List[internbookmark.model.Bookmark])
@wrapper("Bookmarks"){
<a href="/bookmarks/add">Add</a>
<ul>
@for(bookmark <- bookmarks){
<li><a href="@bookmark.entry.url">@bookmark.entry.title</a> - @bookmark.comment - @bookmark.createdAt.toString <a href="/bookmarks/@bookmark.id/edit">edit</a> <a href="/bookmarks/@bookmark.id/delete">delete</a></li>
}
</ul>
}
- Controllerから渡したbookmarksにアクセスできている
- Twirlは自動でhtmlをエスケープしてくれている
- 逆にエスケープをオフにする時はXSSに注意
ここまでで機能は出来上がりましたが、作った機能にはテストを書きましょう。ここではHello Worldページの簡単なテストだけ書きます。詳しくはお手本コードを参照して、テストを書くようにしてください。
class BookmarkWebForTest extends BookmarkWeb {
override def createApp()(implicit request: HttpServletRequest ): BookmarkApp = new BookmarkApp(currentUserName()) {
override val entriesRepository = EntriesForTest
}
override def isAuthenticated(implicit request: HttpServletRequest): Boolean =
request.cookies.get("USER").isDefined
override def currentUserName()(implicit request: HttpServletRequest): String = {
request.cookies.getOrElse("USER", throw new IllegalStateException())
}
}
class BookmarkWebSpec extends WebUnitSpec with SetupDB {
addServlet(classOf[BookmarkWebForTest], "/*")
val testUserName = Random.nextInt().toString
def testUser()(implicit ctx: repository.Context): User =
repository.Users.findOrCreateByName(testUserName)
def withUserSessionHeader(headers: Map[String, String] = Map.empty) = {
headers + ("Cookie" -> s"USER=$testUserName;")
}
describe("BookmarkWeb") {
it("should redirect to login page for an unauthenticated access") {
get("/bookmarks") {
status shouldBe 302
header.get("Location") should contain("/auth/login")
}
}
it("should redirect to the list page when the top page is accessed") {
get("/",
headers = withUserSessionHeader()
) {
status shouldBe 302
header.get("Location") should contain ("/bookmarks")
}
}
it("should show list of bookmarks") {
get("/bookmarks",
headers = withUserSessionHeader()
) {
status shouldBe 200
}
}
}
}
テストは以下のようにして動かします。特定のSpecを動かしたい場合は、testOnlyを使いましょう。
$ sbt
> test
> testOnly internbookmark.web.BookmarkWebSpec
Scalatra での開発の流れは
- URIを決める
- URIとControllerの紐付けを定義する
- 紐付けたControllerを書いて、Viewにデータを渡す
- 渡されたデータを使って、対応するViewを書く(twirlなど)
今度はbookmark追加を作ってみましょう。要件は以下のようにしてみます。
- GET /bookmark/add -> bookmark追加のフォーム
- POST /bookmark/add -> bookmark追加 + redirect
get("/bookmarks/add") {
internbookmark.html.add()
}
post("/bookmarks/add") {
val app = createApp()
(for {
url <- params.get("url").toRight(BadRequest()).right
bookmark <- app.add(url, params.getOrElse("comment", "")).left.map(_ => InternalServerError()).right
} yield bookmark) match {
case Right(bookmark) =>
Found(s"/bookmarks")
case Left(errorResult) => errorResult
}
}
- parameterを取得したい時は
params.get("url")
みたいに- GET /bookmark?url=...とか、POSTのbody parameter(formのinputのやつとか)とかをとれる
- redirectは
Found
(303)
- Controllerでのエラー処理にはEitherを使うと便利です
- 正常系の場合と異常系の場合で処理の切り分けをすっきり書けます
- service層が返すエラーに応じて、クライアントに返却するエラーの内容を決定する
- service層がサーバーエラーの情報を返すのではなくConttrollerでマッピングするのが良い
- GET /bookmark/add にはテンプレートが必要
- Controllerで指定したテンプレートはsrc/main/twirl/internbookmark/add.scala.html
@()
@wrapper("Add Bookmark"){
<form action="/bookmarks/add" method="POST">
<dl>
<dt><label for="url">URL:</label></dt><dd><input type="text" name="url" size="80" value=""/></dd>
<dt><label for="comment">Comment:</label></dt><dd><input type="text" name="comment" size="80" value=""/></dd>
</dl>
<input type="submit" value="Add"/> <a href="/bookmarks">List</a>
</form>
}
- /bookmark/addにPostするform
- inputで指定されている、url, commentをparameterとしてPOST
他の機能はこれまで説明した機能を用いて実装できるので、scala-Intern-Bookmarkを見てください!
ScalatraのURLパスは単純な文字列だけではなくパターンを指定してその値をできます。例えば以下のようにidを取得することができます。
パターンの指定はいろいろ柔軟な書き方があるので、詳しくは以下をごらんください。 http://scalatra.org/2.4/guides/http/routes.html
例
get("/bookmarks/:id") {
val app = createApp()
(for {
id <- params.get("id").toRight(BadRequest()).right
...
}
以下の書籍などを参考にして、URIの設計をしてみましょう。
参考書籍
- Webを支える技術 5章
- Scalatraによる開発の流れは
- URIを決める
- URIとControllerを紐付ける
- それに対応するControllerを書いて、Viewにデータを渡す
- 渡されたデータを使って、対応するViewを書く
- フレームワークを用いれば面倒な部分を気にせずにWebアプリが書ける
- またフレームワーク自体もモジュールの組み合わせでシンプルに実装できる
- ビジネスロジックはできるだけModelに入れてControllerに書かないくらいの気持ちでいたほうがいい(かも)
例えば今回のBookmarkアプリの一覧表示(/)で、ブックマークのコメントをユーザ入力のまま表示させてしまったとします
@* twirlではHtml()を通すとhtmlエスケープされなくなる *@
<p>@Html(bookmark.comment)</p>
この場合以下のコメントに以下の文字が入っていると、jsが実行されてしまう
<script>alert('XSS')</script>
- 前述のとおり、出力時に適切なエスケープをすること
- Twirl は自動的にエスケープしてくれるので今回の場合は何もしなくて良い
Html()
を使うと明示的にでエスケープをなくせる- 何らかの理由で html タグを動的に出力したいときに使う
- 使った場合は注意が必要 -> script タグなどが入らないように!
Scalatraには、 Commands というユーザーの入力値をケースクラスにマッピングしたり、バリデーションを行なったりする機能があります。
また、標準ではありませんが、 scalatra-forms というPlay FrameworkのForm機能と似たような使い勝手のFormライブラリもあります。Scalatraのコミッターの方のライブラリなので、クオリティの心配はないでしょう。こちらをユーザー入力チェックのために使っても良いかもしれません。
- CLI版 Intern-Diary を Web アプリケーションにして下さい
ブラウザですでに書かれたdiaryの記事を読めるように
- ブラウザで読めるように
- テンプレートをちゃんと使って
- 設計を意識しよう
- 良いURI設計をしてみよう
- ページャを実装
- OFFSET / LIMIT と ?page=? というクエリパラメータを使います
- 明日課題に繋がるので必須です
- ブラウザで書けるように
- ブラウザで更新できるように
- ブラウザで削除できるように
以下の様な追加機能をできる限り実装してみてください。
例)
- 認証 (Hatena/Twitter OAuth)
- フィードを吐く (Atom, RSS)
- デザイン
- 管理画面
- いろいろ貼り付け機能
- その他自分で思いついたものがあれば
- 全然分からなかったらすぐに人に聞きましょう
- 以下のように複数のアプリケーションをmountできる
- 認証処理の実装自体はHatenaOAuth.scala参照のこと
// src/main/scala/ScalatraBootstrap.scala
import internbookmark._
import jp.ne.hatena.intern.scalatra.HatenaOAuth
import org.scalatra._
import javax.servlet.ServletContext
import internbookmark.service.Context
class ScalatraBootstrap extends LifeCycle {
override def init(context: ServletContext): Unit = {
Context.setup("db.default")
context.mount(new internbookmark.web.BookmarkWeb, "/*")
context.mount(new HatenaOAuth, "/auth")
}
override def destroy(context: ServletContext): Unit = {
Context.destroy()
}
}