Caveman kills ruby on rails - Chapter 3

Meta info

対象読者

NOTE

筆者はcavemanを捨てsnoozeを使うようになった。 詳細はここに記してある。

Introduction

本稿は原著の各章をCommon Lispに翻訳するシリーズの第3章である。 本章ではCavemanの基礎とDjulaの基礎を修めていく。

ルーティングの追加

web.lispに以下の宣言を追加しよう。

(defroute "/about"()
  (render #P"about.html" '(:page-title "About")))

templatesディレクトリ下にabout.htmlファイルを作る。 中身は以下である。

<p>This is demo of caveman.</p>

Screen shot of example

CAVEMAN2.ROUTE:DEFROUTEの詳細

bnfはだいたい以下の通り。

ここで「だいたい」と言っているのは、これが公式のドキュメントではなく僕がソースコードをなめまわして書いたものだからだ。 僕個人はあらゆるマクロはbnfを持つべきと思っているので、公式にbnfがないのは遺憾である。 Common Lispのマクロが分かりにくいと不評を買うのは、マクロという形で新しい言語が作られているのに、その言語のbnfがないからだと思っている。 bnfすら存在しないプログラミング言語を習いたいかと自問自答すれば、僕の答えはNOである。

(defroute name? routing-rule routing-lambda-list body*)

name := symbol ; not evaluated.

routing-rule := [ rule-string | full-rule ]
rule-string := string
full-rule := (rule-generate-form &key method identifier regexp &allow-other-keys)
rule-generate-form := form ; which generate rule-string, evaluated.
method := [ :get | :post | :put | :delete | :options ]
identifier := internal-use ; ignorable.
regexp := boolean ; you need specify T when rule string has regexp.

routing-lambda-list := list

body := form\*

name

これは通常無視できる。 すなわち指定しなくともよい。 指定するとそのシンボルに一引数関数が定義され、デバッグが少し楽になる。

定義された関数が受け取る引数は、routing-lambda-listへと渡す引数をまとめたものである。

rule-string

もっとも単純なものは、ただのパスである。 (e.g. “/”)

キーワードを含むと&KEYで受けられるようになる。

(defroute "/hello/:name" ; <--- Specified keyword
          (&key name)    ; <--- is passed as parameter.
  (format nil "Hello, ~A" name))

wildcardを含むと&KEY SPLATで受けられるようになる。

(defroute "/say/*/to/*" (&key splat)
  ; matches /say/hello/to/world
  (format nil "~A" splat))
;=> (hello world)

(defroute "/download/*.*" (&key splat)
  ; matches /download/path/to/file.xml
  (format nil "~A" splat)) 
;=> (path/to/file xml)

キーワード引数で:REGEXPを指定すれば正規表現も使える。

(defroute ("/hello/([\\w]+)" :regexp t) (&key captures)
  (format nil "Hello, ~A!" (first captures)))

'?'でキーワードを囲めば2種類のパスを同時に作れる。

(defroute "/hello/?:name?"(&key name)
  (format nil "Hello ,~A" name))

上記のルーティングには“/hello/hoge”でも“/hello?NAME=hoge”でもアクセスできる。 クエリ文字列のキーを大文字で指定しなければならない点要注意。 “/hello?name=hoge”にアクセスすると変数nameはNILに束縛されることとなる。

クエリ文字列のキーを小文字での指定にしたい場合は、&KEYで受ける変数を縦棒で囲めば対応できる。

(defroute "/hello/?:name?"(&key |name|)
  (format nil "Hello ,~A" |name|))

しかしそうすると“/hello/hoge”という形でアクセスした場合、変数|name|がNILに束縛されてしまう。

この'?'で囲む記法がcavemanのREADMEに載っていないのは、作者自身が使ってもらいたくないと思っているからなのかもしれない。

routing-lambda-list

通常のラムダリスト(ordinary-lambda-list)に見えて、その実、異なるものなので、ここではあえてrouting-lambda-listと名付けている。 このラムダリストはURIに含まれるクエリパラメタやキーワードで指定したパス変数などをキーワード引数として受け付ける用のものである。 ここで重要な点が3つある。

1つめはrouting-ruleで指定したキーワード、ないし&KEYで指定したクエリパラメタのみが引数として渡されてくるという点である。 たとえば以下のようなroutingがあったとしよう。

(defroute "/hello"(&rest args)
  (format nil "~S" args))

“/hello?a=1&b=2”にアクセスされたときARGSには何が入るだろうか? 答えはNIL、すなわち引数はない。 &KEYによる指定がないからである。

2つめは(前節の繰り返しになるが)クエリパラメタを&KEYで指定するばあい、そのSYMBOL-NAMEがキーとなる点だ。 たとえば以下のようなroutingがあったとしよう。

(defroute "/hello"(&key name)
  (format nil "~S"name))

この場合URIに含まれるクエリ変数キーは通常大文字でなければならない。 (すなわち“/hello?NAME=anonymous”) 「通常」としたのは、これがCL:READTABLE-CASEに依存しており、その値は設定可能だからだ。 多くの処理系は:UPCASEを規定値としているので、設定していないのなら通常、CL:SYMBOL-NAMEは大文字となる。

CL:READTABLE-CASEの影響を受けたくないのであれば、縦棒でシンボルを包むのが良い。 縦棒で包まれたシンボルはCL:READTABLE-CASEの値によらず、ケースが保持されるからだ。 変数名がたとえば|name|とされた場合、たとえCL:READTABLE-CASE:UPCASEであっても、NAMEと大文字に畳み込まれることなく|name|となりケースが保持される。

この点READMEに解説がなく、天下りな指示になっているのは不親切だと思う。

さて3つめの注意点だが、&KEYで渡す変数のシンタックスが通常のシンタックスとは異なる点である。 Common Lispのキーワード引数は(マニアックな機能ではあるが)公開キーと内部変数とを別のものにすることが可能である。

((lambda(&key((:external internal) :initial-value))
    internal)
  :external 3)
; => 3

これは主にスペシャル変数の初期値をキーワード引数で受け取りたい場合便利である。

((lambda(&key((:stream *standard-output*)*standard-output*))
    ...)
 :stream *debug-io*)

この記法がなければLETで明示的に束縛し直さなければならなくなる。

CAVEMAN2.ROUTE:DEFROUTEでこの記法はサポートされていない。

(defroute "/hello" (&key ((:|name| name)))
  (format nil "Hello ,~A" name))

いちいち縦棒で変数名をくくるのは面倒なので、上記のように書けたら嬉しいと思ったのだが、そんなことはできない。

READMEに説明がないのは不親切だと思う。

body

返り値は以下のいずれかである。

string

文字列がかえされた場合、それがContent-Type: text/htmlのボディとしてクライアントに送られる。

pathname

パスネームが返された場合、そのファイルの中身がContent-Type: text/htmlのボディとしてクライアントに送られる。

list

リストが返された場合、以下のフォーマットになっていなければならない。

(status-code http-headers &optional body)
status-code

整数で指定する。

http-headers

READMEに出てくる(:content-type "text/plain")以外の情報が皆無である。 httpレスポンスのヘッダを指定するものらしいのは分かるのだが、それしかわからない。 色々調べた結果、以下のことが判明した。

ドキュメントに何も記されていないのは不親切だと思う。

body

READMEに出てくる("Hello, Clack!")以外の情報が皆無である。 わざとエラーを起こして調べたところ、LISTかPATHNAMEか(VECTOR(UNSIGNED-BYTE 8))かのいずれかでないとだめらしい。

ドキュメントに何も記されていないのは不親切だと思う。

Controller and action

RailsではControllerはクラスであり、そのパブリックメソッドをアクションとする。 Cavemanでは前節で見てきたCAVEMAN2.ROUTE:DEFROUTEマクロがControllerであり、かつアクションであろう。

Railsのスタイルを褒めて言えば「きれいに整理整頓されている」となろう。 悪く言えば「過剰に分割されている」とも言えよう。 Railsのスタイルを受け入れてしまえば、一つ一つはコンパクトであり、どこに何があるかはかなり明確だ。 ただスタイルになれるまではどこに何があるか、覚えなければならないものは多い。

Cavemanのスタイルはその逆で、悪く言うなら「とっちらかっている」となろう。 褒めて言えば「ほどよくまとまっている」とも言えよう。 ファイルの分割などはプログラマの責任として放置されている。 よってCaveman自体は最低限の分割しかしておらず、Caveman-projectの初期構造を把握するのは容易である。

これは良し悪しの問題ではなく向き不向きの問題だろう。 この場合の「向き不向き」は、プログラマ個々人の向き不向き、プロジェクトの大小による向き不向き、そしてチーム単位による向き不向きとがあろう。

練習の準備

web.lispに以下のroutingを定義する。

(defroute "/lesson/step*"(&key splat)
  (case(parse-integer (car splat) :junk-allowed t)
    ))

STEP1 parameterの取得

Cavemanではrouting-rule次第でパラメタの取扱が異なってくる。

ディレクトリスタイル

URIを“/lesson/step1/Sato”という形で受けたいなら、以下のようにする。

(defroute "/lesson/step*/:name"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    (1 (format nil "Hello, ~A" name))
    ))

クエリスタイル

クエリパラメタで受けたい(e.g. “/lesson/step1?name=Sato”)なら以下のようにする。

(defroute "/lesson/step*"(&key splat (|name| "Anonymous"))
  (case(parse-integer (car splat) :junk-allowed t)
    (1 (format nil "Hello, ~A" |name|))
    ))

Railsスタイル

Railsのようにどちらでも受けたいという場合、'?'でキーを囲む記法を利用する。 ただしここではワイルドカードを使ってしまっているので、組み合わせては使えない。

STEP3 リダイレクション

以下のようにする。 なおコードが大きくなるので一部省略している点要注意。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (3 '(302 (:location "/lesson/step4")))
    (4 "Moved to step4")
    ))

STEP5 フラッシュ

Cavemanではフラッシュに相当する機能は提供されていないようだ。 必要なら泥臭く自作する。 幸いNINGLE/CONTEXT:*SESSION*が提供されているのでさほど難しくはない。 NINGLE/CONTEXT:*SESSION*はハッシュテーブルで、各セッションごとに値を保持できる。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (5 (setf(gethash :notice *session*)"Move to step6")
     `(302 (:location "/lesson/step6")))
    (6 (let((notice(gethash :notice *session*)))
         (remhash :notice *session*)
         notice))
    ))

Template

Cavemanではテンプレートエンジンとしてdjulaを採用している。

STEP7 Basic

Contoroller側で以下のようにする。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (7 (render "step7.html" `(:price ,(floor(* 2000 1.08)))))
    ))

templatesディレクトリ下に対応するhtmlファイルを作り以下のようにする。

<p>{{price}}yen</p>

STEP8 about YOUR-APP.VIEW:RENDER function.

RENDER関数はsrc/view.lispに定義されている。 第一引数はテンプレートファイルの所在を表すPATHNAMEないしSTRINGである。 第二引数はオプショナルでテンプレートに渡す引数をリストにくくったものとなる。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (8 (render "step7.html" '(:price 1000)))
    ))

STEP9 HTML特殊文字の変換

RENDER関数に渡された文字列は通常エスケープ処理される。

Controllerは以下の通り。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (9 (render "step9.html" '(:comment "<script>alert('danger')</script>Hello")))
    ))

Viewは以下の通り。

<p>{{comment}}</p>

Screen shot of example

あえてHTMLを埋め込みたい場合はView側で変数参照の後ろにsafeをつける。

Controllerは以下の通り。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (10 (render "step10.html" '(:comment "<strong>safe html</strong>")))
    ))

Viewは以下の通り。

<p>{{comment | safe}}</p>

Screen shot of example

philosophy of djula

Cavemanのテンプレートエンジンはdjulaであり、djulaの哲学は「Viewはロジックを担うべきではない」だ。

たとえばRailsのテンプレートエンジンと異なり、djulaではView側で変数を作ったり代入したりができない。 変数は必ずRENDER経由で渡されたものでなくてはならない。

formatも一引数フィルターとしてしか使えない。 複雑な文字列はContoller側で作って渡せという考えである。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (11 `(200 (:content-type "text/html; charset=utf-8")
          (,(let((population 704414)
                 (surface 141.31))
              (your-app.view:render "step11.html" '(:contents ,(format nil "人口~D人、面積~D平方キロ、人口密度~,,2F人/平方キロ"
                                                                       population (floor surface)(/ population surface))))))))
    ))

View側は以下の通り。

<p>{{contents}}</p>

Screen shot of example

日付については以下の通り。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (12 (render "step11.html"
                `(:contents ,(local-time:format-timestring
                                nil
                                (local-time:now)
                                :format
                                '((:year 4)"/"(:month 2)"/"(:day 2)"(" :short-weekday ") "
                                  (:hour 2)":"(:min 2)":"(:sec 2))))))
    ))

Screen shot of example

簡単なフィルターならdjula自身が持っている。

Controllerは以下の通り。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (13 `(200 (:content-type "text/html; charset=utf-8")
          (,(render "step13.html" '(:population 127767944)))))
    ))

Viewは以下の通り。

<p>人口{{ population | format: "~:D" }}人</p>

Screen shot of example

なお、数字の表示が'"127,767,944'というような形で、先頭にダブルクォートがついて表示されてしまうが、おそらくはこれはdjulaのバグである。 面倒なのでここでは無視する。

カスタムフィルターの定義。

djulaのドキュメントにはTODOとなっている。 公開されていない内部DSLだが、無理やり利用して自作フィルターを作ることとする。

view.lispに以下のコードを追加する。

(djula::def-filter :break(it)
  (cl-ppcre:regex-replace-all #\newline it "<br />"))

Controller側は以下の通り。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (14 (render "step14.html" `(:contents ,(format nil "foo~%bar~%bazz"))))
    ))

View側は以下の通り。

<p>{{contents|break|safe}}</p>

Screen shot of example

STEP15 Link

いい具合にリンクを処理してくれる機能などない。

Controller側は以下の通り。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    (15 (render "step15.html"))
    ))

View側は以下の通り。 手でごりごり書くよりほかない。

<p><a href="/">Home</a></p>

Image

画像をいい具合にリンクしてくれる機能などない。

まずはここから画像をダウンロードしておく。 保存箇所は“static/images/”とする。

Controllerは以下の通り。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (16 (render "step16.html"))
    ))

Viewは以下の通り。

<p>Powered by<img src="/images/lisplogo.svg" alt="lisplogo" align="top" width=60 height=20></p>

条件分岐

djulaで条件分岐は以下のようにして行う。

まずはControllerを。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (17 `(200 (:content-type "text/html; charset=utf-8")
          (,(let((stock 0))
              (render "step17.html" `(:stock-zerop ,(< 0 stock) :stock ,stock))))))
  ))

Viewは以下の通り。

{% if stock-zerop %}
残り{{stock}}個です。
{% else %}
品切れです。
{% endif %}

繰り返し

djulaでの繰り返しは以下のようにする。

まずはControllerを。

(defroute "/lesson/step*"(&key splat name)
  (case(parse-integer (car splat) :junk-allowed t)
    ...
    (18 (render "step18.html" '(:items ((:pan . 2680)(:glass . 2550)(:pepper-mill . 4515)(:peeler . 945)))))
  ))

Viewは以下の通り。

<table border="1" cellpadding="4">
        {% for (key . val) in items %}
        <tr>
                <th>{{key}}</th>
                <td style="text-align: right">{{val|format: "~:D"}}yen</td>
        </tr>
        {% endfor %}
</table>

Mockup

これまではテンプレートファイルを単独で使ってきた。

もちろんこれらは組み合わせて使うことができる。

下準備としてsrc/view.lispの末尾を以下のように編集する。

(defpackage your-app.djula
  (:use :cl)
  (:import-from :your-app.config
                :config
                :appenv
                :developmentp
                :productionp)
  (:import-from :caveman2
                :url-for)
  (:export #:title!) ; <--- Add.
  )

(in-package :your-app.djula)

(let(title)
  (defun title!(&optional sub)
    (if sub
      (format nil "~@[~A - ~]~:(~A~)"(setf title sub) #.(asdf:coerce-name(asdf:find-system :your-app)))
      title)))

なお、YOUR-APP.DJULAパッケージ下に関数を定義する場合、オブジェクトのスロット名と衝突しないよう注意すること。 これが衝突すると厄介なバグとなる。 バグの詳細についてはappendixで記す。

準備ができたらtemplates/layouts/下にレイアウトテンプレートファイルを作って以下のようにする。 ファイル名はdemo.htmlとした。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>{% block title %}Your app{% endblock %}</title>
  <link rel="stylesheet" type="text/css" media="screen" href="/css/main.css">
</head>
<body>
  {% block content %}{% endblock %}
</body>
</html>

レイアウトテンプレートを使うには、各テンプレートファイルの先頭で使用すべきレイアウトテンプレートを指定しなければならない。 トップページのレイアウトを変更するには対応するテンプレートファイル(ここではtemplates/index.html)を以下のように編集する。

{% extends "layouts/demo.html" %}
{% block title %}{% lisp (title! "subtitle") %}{% endblock %}
{% block content %}
<h2>{{message}}</h2>
<p>Here we go.</p>
{% endblock %}

レイアウトテンプレートで宣言されている名前付きブロックを、各テンプレートで指定することで置換するという振る舞いである。

レイアウトテンプレートの切り替え。

おそらくはできない。 djulaはそのための機能を提供していないように見える。 どうしてもやりたいなら泥臭く自作するしかない。 ここでは無視する。

部分テンプレート

includeタグを使う。 ただし、親のコンテクストとはことなる独自の引数を渡したいならRENDER関数を呼び出すことで対応できる。

templates/layouts/下にapp.htmlテンプレートを作ろう。

<!DOCTYPE html>
<html>
<head>
        <meta charset="utf-8">
        <title>{% block title %}Your app{% endblock %}</title>
        <link rel="stylesheet" type="text/css" media="screen" href="/css/main.css">
</head>
<body>
        <div id="container">
                <header>
                        {% include "shared/header.html" %}
                </header>
                <main>
                {% block content %}{% endblock %}
                </main>
                <aside id="sidebar">
                        {% lisp (your-app.view:render "shared/sidebar.html"
                                  '(:news (1 2 3 4 5)
                                    :blogs (1 2 3 4 5))) %}
                </aside>
                <footer>
                        {% include "shared/footer.html" %}
                </footer>
</body>
</html>

templates/下にsharedディレクトリを掘り、部分テンプレートを記述していく。

まずはheader.html

<img src="images/lisplogo.svg" width="272" height="48" alt="Your app">

<nav class="menubar">
        <ul>
                <li><a href="/">Home</a></li>
                <li><a href="#">News</a></li>
                <li><a href="#">Blog</a></li>
                <li><a href="#">Members</a></li>
                <li><a href="#">Settings</a></li>
        </ul>
</nav>

sidebar.html

{% include "shared/login_form.html" %}

<h2>Latest news</h2>
<ul>
        {% for n in news %}
        <li><a href="#">News header</a></li>
        {% endfor %}
</ul>

<h2>Member blog</h2>
<ul>
        {% for b in blogs %}
        <li><a href="#">Blog header</a></li>
        {% endfor %}
</ul>

login_form.html

<h2>Login</h2>
<form id="login_form">
    <div>
        <label>user name:</label>
        <input type="text">
    </div>
    <div>
        <label>password:</label>
        <input type="password">
    </div>
    <div>
        <input type="submit" value="Login">
    </div>
</form>

footer.html

<a href="/about">About your app</a>|
Copyright(C) <a href="#">example.com</a>
2007-2019

Controller側を以下のようにする。

(defroute "/" ()
  (render #P"index.html" '(:numbers (1 2 3 4 5))))

Screen shot of example

Stylesheet

Cavemanではstatic/css/下に配置する。 ここではapp.cssという名前とする。

/* whole pages */
body {
        background-color: white;
        color: black;
        margin: 0; padding: 0;
        font-family: Meiryo, sans-serif;
}

/* link */
a:link { color: #00c; }
a:visited { color: #00c; }
a:hover { color: #f00; }
a img { border: none; }

/* whole border */
div#container {
        margin: 0 auto;
        padding-top: 5px;
        width: 780px;
}

/* left pane */
main {
        float: left;
        width: 530px;
        padding: 10px, 10px, 10px, 0;
}

/* right pane */
aside#sidebar {
        float: left;
        width: 230px;
        background-color: #e8ffff;
        padding: 5px;
        font-size: 86%;
}

/* link of menubar */
nav.menubar a { text-decoration: none; }

/* link of menubar (not visited) */
nav.menubar a:link { color: #ccc; }

/* link of menubar (visited) */
nav.menubar a:visited { color: #ccc; }

Screen shot of example

まとめ

Appendix

YOUR-APP.DJULAパッケージにある関数名とオブジェクトのスロット名が衝突すると厄介なバグが仕込まれる。 これはDJULAが依存しているACCESSの振る舞いに起因している。

ACCESSは引数が関数名だった場合、スロット名と解釈せず関数としてオブジェクトに適用するという振る舞いをする。

(access:access '((list 1 2 3)) :list) ; ===> (1 2 3)
(access:access '((list 1 2 3)) 'list) ; ===> (((list 1 2 3)))

オブジェクトのスロット名が関数名と衝突した場合、そのスロットをACCESS経由で参照することは不可能となる。

(defclass object ()((list :initform '(1 2 3))))

(access:access (make-instance 'object) :list) ; ===> (#<OBJECT ...>)
(access:access (make-instance 'object) 'list) ; ===> (#<OBJECT ...>)
(access:access (make-instance 'object) "list") ; ===> (#<OBJECT ...>)
(access:access (make-instance 'object) "LIST") ; ===> (#<OBJECT ...>)