Skip to content

Latest commit

 

History

History
646 lines (499 loc) · 22.4 KB

web-application-development-scala.md

File metadata and controls

646 lines (499 loc) · 22.4 KB

ScalaによるWebアプリケーション開発

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を変更してみよう

4.1 Scalatraとは

Ruby の Sinatra ライクな WAF です。簡単な Web アプリケーションを書くのには適しているといえるでしょう。

ではこれから、Scalatra を使ってWebアプリケーションを作っていきましょう。

その完成形のお手本を用意しました。この資料では説明しませんが、はてな OAuth によるユーザー認証まで作りこんでいるので資料のコードと若干内容が異なります。 以下の手順でcloneしてみてください。

git clone https://github.com/hatena/scala-Intern-Bookmark

4.2 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 っていう雰囲気です

はてなアプリケーションの登録

  • 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 でアクセスできる

4.3 ブックマーク一覧を作ってみよう

4.3.1 URI設計

実装に入る前にまずはURIを設計します。

Bookmarkアプリでの要件

Bookmarkアプリでの機能は以下のとおり。

  • 一覧 (list)
  • 表示
  • 作成 (add)
  • 削除 (del)

これらに対応するURIは以下のように設計できる。

パス メソッド 動作
/bookmarks GET ブックマーク一覧
/bookmark/:id GET ブックマークの permalink (:idは追加時に採番される)
/bookmark/add?url=url&comment=comment POST ブックマークの追加
/bookmark/:id/delete POST ブックマークの削除

4.3.2 Controllerを書こう

上記のURI設計におけるブックマーク一覧(/bookmarks)を例として、Controllerを作っていきます。

まずはHello Worldから

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

ブックマーク一覧のControllerを作る

  • > 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のファイルの指定と、データの受け渡しができる

Controllerのロジックを分離する

  • いろんなページで使うロジックはモデルに分離しておくべき
  • 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に渡す

なぜこうなっているのか?

  • DDD的な設計アプローチ
  • 適切なレイヤリングを行いそれぞれの責務が混在するのを避ける
  • それぞれのレイヤー毎に再利用/差し替えしやすいように
  • 静的言語だとこういう所しっかりやっておいたほうがいいみたいなのもありそう(私見)
    • テストしやすいとか

4.3.3 Viewを書こう

  • Controllerでhtml.list()を指定しているので、src/main/twirl/internbookmark/list.scala.htmlが使われる
    • このファイルはTwirl(読み方はトゥワール?)というscalaのテンプレート形式になっている

Twirl 入門

  • 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パターン

// wrapper.scala.html
@(title: String)(content: Html)
<html><body>
@content
</body><content>

以下のようにして呼び出します。

@wrapper {
    <p>Hello World!</p>
}

{} の内部が、wrapperにcontentとして渡されます。

コメント

@* コメント! *@

参考

ブックマーク一覧のViewを作る

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に注意

4.3.4 テストを書こう

ここまでで機能は出来上がりましたが、作った機能にはテストを書きましょう。ここでは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 での開発の流れは

  1. URIを決める
  2. URIとControllerの紐付けを定義する
  3. 紐付けたControllerを書いて、Viewにデータを渡す
  4. 渡されたデータを使って、対応するViewを書く(twirlなど)

4.4 他の機能も作ってみよう

今度はbookmark追加を作ってみましょう。要件は以下のようにしてみます。

  • GET /bookmark/add -> bookmark追加のフォーム
  • POST /bookmark/add -> bookmark追加 + redirect

Controllerを作る

  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)

Eitherを使ったエラー処理

  • Controllerでのエラー処理にはEitherを使うと便利です
  • 正常系の場合と異常系の場合で処理の切り分けをすっきり書けます
    • service層が返すエラーに応じて、クライアントに返却するエラーの内容を決定する
    • service層がサーバーエラーの情報を返すのではなくConttrollerでマッピングするのが良い

Viewを書く

  • 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を見てください!

4.5 URIを変更してみよう

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に書かないくらいの気持ちでいたほうがいい(かも)

セキュリティ

scala-Intern-Bookmark での XSS 対策

例えば今回の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のコミッターの方のライブラリなので、クオリティの心配はないでしょう。こちらをユーザー入力チェックのために使っても良いかもしれません。

課題3

  • 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()
  }
}

参考資料

クリエイティブ・コモンズ・ライセンス
この 作品 は クリエイティブ・コモンズ 表示 - 非営利 - 継承 2.1 日本 ライセンスの下に提供されています。