HaskellのYesodでWebアプリ開発入門 (2)

前回の記事

HaskellでYesodを使ってWebアプリケーションを作ってみている際のメモ記事です。

前回はインストールと初回起動までやりました。

Yesodのディレクトリ構造

前回は何も考えずに起動しただけでしたので、次はお決まりの流れでScaffoldのディレクトリ構造の確認をしたいと思います。 現在プロジェクトディレクトリに含まれるファイルは以下の様な感じですね。Homebrewでインストールしたtreeコマンドの結果からdistディレクトリを省略したものです。

sample
├── Application.hs
├── Foundation.hs
├── Handler
│   └── Home.hs
├── Import.hs
├── Model.hs
├── Settings
│   ├── Development.hs
│   └── StaticFiles.hs
├── Settings.hs
├── app
│   ├── DevelMain.hs
│   └── main.hs
├── config
│   ├── client_session_key.aes
│   ├── favicon.ico
│   ├── keter.yaml
│   ├── models
│   ├── mysql.yml
│   ├── postgresql.yml
│   ├── robots.txt
│   ├── routes
│   └── settings.yml
├── deploy
│   └── Procfile
├── devel.hs
├── dist
│   ├─ ...
│   ...
├── messages
│   └── en.msg
├── static
│   ├── combined
│   │   └── rMJ_CwK2.css
│   ├── css
│   │   ├── bootstrap.css
│   │   └── normalize.css
│   └── img
│       ├── glyphicons-halflings-white.png
│       └── glyphicons-halflings.png
├── templates
│   ├── default-layout-wrapper.hamlet
│   ├── default-layout.hamlet
│   ├── homepage.hamlet
│   ├── homepage.julius
│   └── homepage.lucius
├── tests
│   ├── HomeTest.hs
│   ├── TestImport.hs
│   └── main.hs
└── yesod-devel
│   ├── arargs.txt
│   └── ghcargs.txt
└── yesod-sample.cabal

23 directories, 148 files

RoRをある程度触ったことのある人ならなんとなく想像がつくかと思いますが、ざっくりと分かる範囲でファイルの役割を書き出してみます。 ファイルの中身を見ながら読んでいただけるとなんとなくわかっていただけるかと。

(以下の表には想像も含まれていますので、正しいことが分かり次第更新します)

ディレクトリやファイル 役割
Application.hs app/main.hsから呼び出されるアプリケーションの本体を作る関数makeApplicationが定義されている
Foundation.hs Application.hsでインスタンスが作成されるアプリケーションの本体が定義されている
Handler/ いわゆるコントローラの働きをするHandlerの定義ファイルが置かれるディレクトリ。中のファイルの詳細は後で書くので省略
Import.hs プロジェクト内のすべてのモジュールで読み込まれるYesodなどのモジュールをimportしたモジュール
Model.hs, config/models O/R mapperであるPersistentで使用されるモデル関連ファイル
Settings/Development.hs Development環境用を区別するための関数が定義されている
Settings/StaticFiles.hs 静的ファイルの読み出しのための関数を定義したファイル
Settings.hs いろいろな設定が書かれているファイル。静的ファイルのURL設定やHTMLファイルなどの構文解析に関する情報が書かれている
app/DevelMain.hs ghciでアプリケーションの環境を使うためのメイン・エントリ・ポイント
app/main.hs 通常のアプリケーションのメイン・エントリ・ポイント
config/client_session_key.aes コンパイル時に自動生成されるユーザのクッキー情報を暗号化するためのファイル
config/favicon.ico, config/robots.txt 普通のファビコンやらrobots.txtやら
config/keter.yaml, deploy/Procfile デプロイに関するファイル。後者にはHerokuへデプロイするための情報が書かれている
config/mysql.yml, config/postgresql.yml 前回も紹介したデータベース設定に関するファイル
config/routes URLとコントローラの対応関係を記述するファイル
config/settings.yml ルートURLやポートなど、アプリケーション全体に関する情報が記述されたファイル
dist/build/sample/sample アプリケーションの実行ファイル。本番環境ではこの実行ファイルのプロセスからデーモンプロセスを作る
devel.hs デバッグ環境のアプリケーションのメイン・エントリ・ポイント
massages/ i18n(国際化対応)のための辞書ファイルを置くディレクトリ
sample.cabal 前回紹介したcabalのための設定ファイル
static/ CSSJavaScriptライブラリや画像などの静的ファイルを置くディレクトリ。デフォルトではBootstrapのバージョン2.3.2(古い!)のCSSと画像ファイル、Normallize.cssのバージョン2.1.2(古い!)とそれらCSSをつなげたCSSファイルが含まれている
templates 動的な部分を含む(テンプレートを利用する)HTMLやJavaScriptCSSファイルを置くディレクトリ。Yesodでは標準でHTMLにHamlet、JavaScriptにJulius、CSSにLuciusかCassiusという言語を使うようになっている
tests/ テストコードを置くディレクトリ。Ruby並に自然な形でテストコードが書かれていてワクワクする
yesod-devel/ デバッグ環境で実行する際の設定を記したファイル、かな

思っていたよりも見通しはスッキリ。 Haskellの構文の柔軟さのお陰で全体的にファイルの可読性が高く、性能を追求したりし始めるまでは案外簡単に開発出来てしまうのでは? という期待が膨らみます。

ghci

表のDevelMain.hsの説明にちょろっと書きましたが、Yesodではアプリケーション実行時と同じ環境でghciを利用するための設定が追加されています。

DevelMain.hsの冒頭に説明がありますが、ghciを

$ cabal repl --ghc-options="-O0 -fobject-code"

として実行すると、アプリケーション使用時にロードされるパッケージを全てロードした状態でHaskellの対話環境を使えます。

とてつもなく便利ですね。

TemplateHaskell

ファイルを見ていくと、少ししかHaskellを学んだことがない人は「おややっ??」となったのではないでしょうか。 きっとこれを編集すればいいんじゃないかな、というHandler/Home.hsにも以下の様な見慣れない(というかjQueryみたいな)式があります。

$(widgetFile "homepage")

これはTemplateHaskellというHaskellにおけるマクロのようなもので、こいつを使うとLispのquoteとeval, funcallでやるようなことが書けてしまうというすぐれものです。

widgetFileはYesodの関数ではなく、Scaffoldで生成されたSettings.hsの中に含まれており、型を確認することができます。

widgetFile :: String -> Q Exp
widgetFile = (if development then widgetFileReload
                             else widgetFileNoReload)
             widgetFileSettings

つまり、先の式はwidgetFile関数が"homepage"というStringを元にQuoteモナド(Lispのquoteされた式みたいなもの)をつくり、それを$()で評価した結果を表していたわけですね。

Yesod公式のScaffoldに関するエントリによると、widgetFiletemplatesディレクトリ以下にある適用した文字列を名前として持つHaml, Julius, Lucius, Cassiusファイルをパースして1つのウィジェット(アプリケーション内でのレスポンスの表現?)にまとめてくれる関数のようです。

この関数はFoundation.hs内のAppインスタンスの定義にあるdefaultLayout関数でも使われています。

    defaultLayout widget = do
        master <- getYesod
        mmsg <- getMessage

        -- We break up the default layout into two components:
        -- default-layout is the contents of the body tag, and
        -- default-layout-wrapper is the entire page. Since the final
        -- value passed to hamletToRepHtml cannot be a widget, this allows
        -- you to use normal widget features in default-layout.

        pc <- widgetToPageContent $ do
            $(combineStylesheets 'StaticR
                [ css_normalize_css
                , css_bootstrap_css
                ])
            $(widgetFile "default-layout")
        giveUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet")

コメントにも書かれていますが、templates/default-layout-wrapper.hamletにページ全体のレイアウト、templates/default-layout.hamletにBODYのレイアウトが記述されているようです。

ここまで見てから再度Handler/Home.hsgetHomeR関数の全体を見ると

getHomeR :: Handler Html
getHomeR = do
    (formWidget, formEnctype) <- generateFormPost sampleForm
    let submission = Nothing :: Maybe (FileInfo, Text)
        handlerName = "getHomeR" :: Text
    defaultLayout $ do
        aDomId <- newIdent
        setTitle "Welcome To Yesod!"
        $(widgetFile "homepage")

ざっくり、default-layout-wrapper.hamlet, default-layout.hamlet, homepage.hamlet, homepage.julius, homepage.luciusをくっつけて、widgetFile関数を呼ぶまでに用意したformWidgetやらformEnctypeやらsubmissionやらやらが展開されて、レスポンスとして返されてるんだろうなあ、というくらいは分かるようになりました。

それではいよいよ、ここらへんをいじって静的ファイルを自分なりに作り替えてみたいと思います。

ちなみに、Template Haskellの簡単な説明ならruiccさんの記事がわかりやすかったです。

プロジェクトのGit管理

ところで、プロジェクトの生成の際に.gitignoreファイルも一緒に作成されるので、編集を始める前にプロジェクトのディレクトリをGitで管理しておくと、この後にScaffoldのコマンドで行なった変更点をgit diffで見れたり、簡単に変更をやめたりできるので便利です。

ただ、もしプロジェクトをオープンソースで管理したりするのであれば、パスワードが含まれるconfig/mysql.ymlconfig/postgresql.ymlはデフォルトでは.gitignoreに含まれていないので追加して、本番環境へデプロイする際に別個で送信するべきかと思います。 (Capistranoを使うのはなんだかちょっとなあ、っていう感じなんですけどね...)

Vimシンタックスハイライト

編集を始める前に準備をもう一点。 テンプレート用の言語達をハイライトしてくれるVimプラグインをインストールしておきます。

幸い、pbrisbinさんのhtml-template-syntaxというレポジトリプラグインを追加すれば、簡単にテンプレートファイル達をシンタックスハイライトすることができました。

Warningの修正

yesod initでダウンロードされるscaffoldではYesodの最新版に対応していないらしく、最初からいくつかのWarningがでてしまっているので、それを修正します。

まずはModel.hs内でimport Preludeと書かれていますが、Preludeはデフォルトでimportされるので消去します。

次に、Foundation.hs内で使用されているgiveUrlRendererがdeprecatedとなっているので、代わりにwithUrlRendererを使用します。

これで鬱陶しいWarningが消えたはずです。 なんで修正されないんだろう...

テンプレートファイルの書き換え

まずは単に、Scaffoldで用意されているHomeハンドラを編集して、Webのトップページらしくしてみます。

せっかく作ってくれた(といってもGithubからコピーしてきているだけだが)Homeハンドラやらやらなので少し躊躇しましたが、サンプルプログラムもそんなに面白く無いのでスパッと全てなくしちゃいます。

Homeハンドラに関する記述があるのはconfig/routes, Handler/Home.hs, templates/homepage.*, sample.cabal, Application.hsの5箇所のファイルですが、後者2つはimportなどの宣言を行っているだけなので、前の3箇所だけを変更します。

ひとまず、config/routesHandler/Home.hsは以下のように編集しました。

/static StaticR Static getStatic
/auth   AuthR   Auth   getAuth

/favicon.ico FaviconR GET
/robots.txt RobotsR GET

/ HomeR GET
{-# LANGUAGE OverloadedStrings #-}
module Handler.Home where

import Import

getHomeR :: Handler Html
getHomeR = defaultLayout $(widgetFile "home/index")

とりあえず表示するためにtemplates/home/index.hamletという名前でHello, World!だけのファイルをつくります。

<h1>Hello, World!

これらの変更を行ってからyesod develを叩けばlocalhost:3000でHello, World!の文字が見られると思います。

ルーティングはRoRみたいにStaticPagesとかいう名前のハンドラを作ってその中のメソッドでURLによってGETの結果を変える、という風にしようかとも考えましたが、それは別にRESTでもなんでもないし、widgetFile関数でうまくテンプレートファイル達をくっつけてくれることを考えたらトップページと概要ページ、コンタクトページとかで1つづつディレクトリを作るほうがいいように思えたのでこうしてみました。

よくよく考えて見れば、よくやるHTMLはviewとかのディレクトリに置いてCSSJavaScriptはassetsとかに置くという区別は、DSLを使うなら同じような階層構造を持つディレクトリが増殖するだけであまり意味ない気がするのですが、どうなんでしょうかね。VimでHTMLとCSSを並べて開きたい時とかも面倒なだけだし、Assets precompileするとしても拡張子で判断がつく上にむしろwidgetFileの形式のほうが依存関係はわかりやすいし...

ちなみに、サンプルプログラムのHandlerの一番上で宣言されていたTupleSectionsというエクステンションは(, True)\x -> (x, True)として解釈してくれるようなものらしいです。これはsampleForm関数で使用していただけなので消しても問題ありませんが、OverloadedStringsはダブルクオーテーションの文字列をByteStringとして扱ってくれ、性能改善になるので一応残しておきました。

Handlerの追加

早くテンプレートファイル達をいじってHamletやらやらと戯れたいところですが、さすがに見れるファイルが1つだけだとヘッダも作りようがないので、コンタクトページだけ追加しておきます。

Handlerを追加するには、Handler以下にモジュールを作るだけでなく、sample.cabalの依存関係に追加したモジュールを加えて、Application.hsで追加したモジュールをインポートした上で、config/routeでルーティングの設定をする必要があります。地味に面倒臭いですね。

というわけで、Scaffoldを使いました。

$ yesod add-handler
Name of route (without trailing R): Contact
Enter route pattern (ex: /entry/#EntryId): /contact
Enter space-separated list of methods (ex: GET POST): GET
$

作成したHandlerを見ると

module Handler.Contact where

import Import

getContactR :: Handler Html
getContactR = error "Not yet implemented: getContactR"

と、ただエラーを投げる関数になっていますので、これをHomeハンドラと同じように編集し、とりあえずのHamletファイルも追加しておきます。

{-# LANGUAGE OverloadedStrings #-}
module Handler.Contact where

import Import

getContactR :: Handler Html
getContactR = defaultLayout $(widgetFile "contact/index")

templates/contact/index.hamlet

<h1>Contact

Homeの変更からデバッグ環境のサーバを起動したままでいれば、変更が勝手に反映されてlocalhost:3000/contactでコンタクトページを表示できます。

Play! frameworkのActivatorなんかよりよっぽど高速でいいですね。

おわりに

だんだんそのユーザとしてはYesodの全体像が見えてきて楽しくなってきました。

本当はテンプレートファイルを綺麗に編集するところまで書こうと思っていたのですが、無意味に長くなってきてしまったのでそれらは次回に回します。