Common LispでWebアプリを作るときのパターン
ブログを例にWebアプリケーションを作っていきます。
ただし、これは個人的なメモみたいなもので、完全に動くことを目指して書いていくわけではありません。
サーバーサイド
サーバーサイドでのCommon Lisp実装について。
データ
ブログを定義します。ブログは、中身 (content
)、作成日時 (created-at
)、ブログを一意に特定する識別子 (id
) を持つ、とします。
ブログはdefstruct
で定義できます。
(defstruct blog id created-at content)
ブログのcontent
は、著者が発信したい内容を保持します。どのような形式で保持するか、今の段階では得に決めていません。
ブログのcreated-at
は、ブログの作成日時を保持します。created-at
は、content
が現在でも有効かどうかを判断する材料に使います。created-at
から時間がたっているブログのcontent
は有効で無い可能性がありえる、というような使い方を想定しています。
ブログのid
はいささか人為的に作ったもので、ブログを他のブログと区別するために使います。区別のために一番お手軽なのは、ブログ毎に一意な識別子を与えることでしょう。
id
は必ず毎回違ったものを生成される必要があり、これはアプリケーションが動いている間はずっとそうでないくてはいけません。さもなくば、違うブログのはずなのに、id
が同じなせいで、プログラムには同じブログと判定されかねません。
ブログのcreated-at
を後で変更することは上記の理由でないですし、id
に至っては絶対変更しちゃいけません。しかし、defstructで定義すると変更可能になってしまします。
おそらく変更不可能にするやり方はある気がしますが、私が調べきれてないです。。しばらくは紳士協定で…。
下にコードを記します。
;; path: b/entities/blog/blog.lisp
(defpackage :b.entities.blog
(:use :cl)
(:export :make-blog
:blog-id
:blog-created-at
:blog-content))
(in-package :b.entities.blog)
(defstruct blog id created-at content)
ルートのパッケージはb
です。適当な名前ですね。(いい名前をすぐに思いつきませんでした…。)
ルートパッケージ (b
) の下にentities
という名前空間を作り、今回のブログオブジェクトはここに置いています。Entity
は、アプリケーションの処理を記述するための言葉というようなイメージです。
一意なid
一意なidの生成にはUUIDを使えるでしょうし、MySQLでauto incrementする専用のテーブルを用意してもよいでしょう。
;; path: b/entities/id.lisp
(defpackage :b.entities.id
(:use :cl)
(:export :uuid-generator
:gen))
(in-package :b.entities.blog)
(defgeneric gen (generator) ())
(defclass uuid-generator () ())
(defmethod gen ((generator uuid-generator))
...)
永続化
Webアプリケーションといえば永続化 (ほんまか)。
何を永続化するのかわかってなくても永続化したくなることも?
日本語ではシンプルに 「ブログをデータベース (db) に保存する」と言うと思います。この文をそのままCommon Lispに直すと下のような式になるでしょう。
(save db blog)
「ブログ (blog
) を データベース (db
) に 保存する (save
)」という対応になっていて、この式は元の文を表現できてそうです。また、式からトークンを一つでも抜くと、もとの日本語の文を再現できなくなりそうです。
db
の実装にMySQLを使う場合、save
は下のように定義できるでしょう。
(defun save (db blog)
(let ((conn (mysql-db-connection db)))
(send-mysql-query conn "INSERT INTO ...")
(send-mysql-query conn "INSERT INTO ..."))
db)
mysql-db-connection
やsend-mysql-query
は、MySQLを使うAPIのイメージです。
戻り値としてdb
を返しておくのは個人的な好みです。状態変化がなくなったように見えるとか、threading macroが使えたりして、いろいろ便利だと思ってます。
threading macroで実装するのもありかもしれないです。
(-> (mysql-db-connection db)
(send-mysql-query "INSERT INTO ...")
(send-mysql-query "INSERT INTO ..."))
ただ、こんな感じに生クエリを書くのもいいですが、ORマッパー (Mitoとか) を使ったほうがお手軽ですね。
db
の実装が複数ありうるときは、defgeneric
で実装するのが良さそうです。
(defgeneric save (db blog)
(defmethod save ((db mysql-db) blog)
...
db)
(defmethod save ((db file-db) blog)
(with-open-file (stream (file-db-path db) :direction :output)
...)
db)
上の例では、db
の実装としてMySQLとファイルの例を書きました。ただ、これからこの先も、これら二つの違いを覆い隠せるくらいの抽象度で実装できるかはわからないです。アプリケーションの要件によってはfile-dbではその要件を満たせずに、MySQLに保存になるかもしれないです。実行時はどっちか1つしか選ばないのに最初からdefgeneric
で実装するのはムダになる可能性も。
db
の実装はMySQLかもしれないし、別の何かになる可能性もあります。
ブログアプリケーションでの処理の流れを自然に表現するときは「ブログをDBに保存する」というはずで、「ブログを〇〇テーブルにinsertして」とまで詳細化して言わないはずです。こういう時、抽象度の高い関数を定義して、永続化がどのように実装されているかはアプリケーション側からは気にしなくてすむようにしておくと、自然な表現とプログラムがほとんど一対一対応できて、可読性が上がるのかな~と考えたりしてます。(あやふやな日本語に対応させると、それはそれでシステムの振る舞いがカオスになるので、日本語のほうも日々見直す必要があるとは思います。)
一方、永続化の仕組みの詳細を露出させたほうが、より細かいチューニングが可能になって、パフォーマンスを向上しやすくなりそうです。とりあえず今は「ブログをDBに保存する」を素直に表現することを第一に、抽象度の高い関数を公開しておく。
永続化はrepositoryにやらせるのがよくあるパターンだと思います。しかし、ブログをDBに保存する」から始まったせいか、repositoryの出番がありませんでした。せっかくなので名前空間に登場させてみます。
;; path: b/entities/blog/repository.lisp
(defpackage :b.entities.blog.repository
(:use :cl)
(:export :save))
(in-package :b.entities.blog.repository)
(defun save (db blog)
(let ((conn (mysql-db-connection db)))
(send-mysql-query conn "INSERT INTO ...")
(send-mysql-query conn "INSERT INTO ..."))
db)
一意なidと永続化
MySQLを使ってblogを永続化する場合、おそらくblogテーブルを作ります。すると、blogテーブル自体にauto incrementなidカラムをつけて、ブログのidを生成させることも可能になります。
この場合blogテーブルはblogの永続化だけでなく、blogの一意なidを生成する責務を追うことになります。idの生成をDBにやらせると、全体のロジックがこんがらがりそうな気がするので、今回はblogの永続化とは独立してidを生成することにしています。blogテーブルが単一責務の原則に違反しそうというのもあります。
アプリケーション
誰かが喜ぶ処理を、Entity
等を使って処理するレイヤーというイメージです。
空ブログを作成する
とりあえず空ブログ (content
が空のブログ) を作成します。
空ブログを作ってもだれも喜ばない気がして、いきなり上のイメージと矛盾してはいますが…。とりあえず次で、ブログのcontent
を編集するということで。
idを振って、作成日時と一緒にブログを作って、dbに保存すると、空ブログを作成できます。
これをそのまま表現すると、下のようになるでしょう。
;; path: b.lisp
(defpackage :b
(:use :cl))
(in-package :b)
(defun blog-response (blog)
(list :id (b.entities.blog:blog-id blog)
:created-at (b.entities.blog:blog-created-at blog)
:content (b.entities.blog:blog-content)))
(defun create-empty-blog (db &key id-generator)
(let ((blog (b.entities.blog:make-blog
:id (b.entities.id:gen id-generator)
:created-at (get-universal-time)
:content "")))
(b.entities.blog.repository:save db blog)
(blog-response blog))
Entity
以下のオブジェクトは生で返さず、リストで返すか、ラップして返すようにしています。こうすることで、アプリケーションに依存するレイヤーと、アプリケーションが依存するレイヤー (Entity
レイヤー) とが、直に結合することを防ぐことができると考えています。たとえば、b.entities.blogのリファクタリングを
、create-empty-blogを使う人にまで影響を及ぼさないように、できるはずです。
変更されるオブジェクト (ここではdb
) を第一引数に持ってきています。これはなんとなく、オブジェクトの状態を変更するような関数を定義するとき、
(defun オブジェクトを変更する (変更されるオブジェクト 変更するためのパラメータ)
...)
という並びになっていると、読みやすいかなーと思っています。
Web API
APIレイヤーは、httpやフレームワークのお作法に則りデータをやり取りするだけにしたいところです。
;; path: b/ningle/route.lisp
(setf (ningle:route *app* "/api/blogs/" :method :put)
(lambda (params)
(declare (ignore params))
(blog->json
(b:create-empty-blog
(make-instance 'b.db.mysql:mysql-db :connection *connection*)
:id-generator
(make-instance 'b.entities.id:uuid-generator)))))
コンフィグファイルみたいなものから自動でmake-instanceされたりしたら便利そう。
ブログのcontent
を書き換える
空のブログを作っただけでうれしい人はほとんどいなはず。。
この後、一度作成したブログを編集できると、うれしい人がいるかもしれないです。
これを実現するために、新しい関数をアプリケーションレイヤーにつくります。
実現方法としては、
-
blog-id
に対応するblogをdb
から再構成 - 再構成したblogの
content
をnew-content
に置き換え - 置き換えたblogを保存
とすると、できそうです。これを実装すると、おそらく下のようになるでしょう。
;; path: b.lisp
(defun update-blog-content (db blog-id new-content)
(let ((blog (b.entities.blog.repository:load-by-id db blog-id)))
(if (not blog)
(error 'no-such-blog-error :blob-id blog-id)
(progn
(setf (b.entities.blog:blog-content blog) new-content)
(b.entities.blog.repository:update db blog)
(blog-response blog)))))
blogをdb
から再構成する関数と、blogを更新する関数を、
新たにrepositoryに追加しました。
;; path: b/entities/blog/repository.lisp
(defun update (db blog)
(let ((conn (mysql-db-connection db)))
(send-mysql-query conn "UPDATE ...")
(send-mysql-query conn "UPDATE ..."))
db)
(defun load-by-id (db blog-id)
...)
ただ、このような機能を作って本当に喜ばれるかはわからないです。
実際に使ってみて、やっぱり使えないってなったら、潔く削除して、(create-empty-blog
を含めて) 別の解決方法を考える必要があると思います。