「言語実装 Advent Calendar 2023」の枠が空いていたので参加させていただきます。本記事は13日目の記事です。
TL; DR
- ワンライナーに最適化
- 通信機能はホスト言語(Go)のAPIサーバーを使用(Echo)
- 利用者向け関数とラッパー関数をモジュール分けしてカプセル化
# 1行でAPIサーバーを立てる!
pangaea -e 'invite!("http");S.serve(S.get("/"){{"msg":"Hello"}})'
$ curl localhost:8080
{"msg": "Hello"}
はじめに
2020年から「Pangaea」というプログラミング言語を自作しています。コンセプトは「P言語(Perl, Python, PHP, Ruby)の2次創作」1で、特にワンライナー特化のスクリプト言語です。
言語の詳細については過去の記事をご覧ください。
Pangaeaはテキスト処理のための言語として開発しました。しかし、P言語のパロディを目指すなら避けて通れない目標があります。
そう、Webサーバーです。
というわけで、PangaeaにWebサーバー機能を実現するまでにやったことをまとめました。
HTTP通信
PangaeaはGo言語で実装しているので、GoのWebフレームワーク「Echo」を使用しています。
ハンドラオブジェクトもサーバオブジェクトも、実態はEchoの構造体のラッパーです。
(実装は長いので割愛)
インターフェース
Pangaeaが重視するのは「ワンライナーの書きやすさ」です。そこで、ユースケースとして
- モックサーバーが1行で立てられる
ことを目標にしました。
pangaea -e 'invite!("http");S.serve(S.get("/"){{"msg":"Hello"}})'
$ curl localhost:8080
{"msg": "Hello"}
そのため、ある程度汎用性を捨てて以下の仕様にしています。
- APIサーバーの利用を想定
- JSONリクエスト、レスポンスに最適化
- レスポンスは暗黙的にレスポンスオブジェクトへ変換
- 明示的に指定も可能(例:
Response.new(status: 404, body: {msg: "User not found"}.S)
)
- 明示的に指定も可能(例:
- サーバーの設定はlistenするURL(port)のみ
また、Server
を S
、 Client
を C
と書ける等の細かい補助もあります。
invite!("http")
# サーバー起動
Server.serve(
# 引数にハンドラを指定
# ハンドラはHTTPメソッドと同名のメソッドで作成
Server.get("/"){|req|
# レスポンスを返却
return {"msg":"Hello"}
},
)
ハンドラではパスパラメータやリクエストボディも扱えます。このあたりの機能は完全にEchoに乗っかっています。
S.get("users/:id") {|req|
# ハンドラのパスで指定したパラメータを req.params.{パラメータ名} で取得可能
users.find {.id == req.params.id} || Response.new(status: 404, body: {msg: "User not found"}.S)
}
モジュール設計
ここまでの実装をまとめると以下のようになります。
- HTTP処理のオブジェクトはシンプルなEchoのラッパー
- 低レイヤーの組み込み関数
- インターフェースはユーザーが使いやすいメソッド形式
- 高レイヤーのネイティブオブジェクト
この2つは役割が異なるため、モジュール自体を階層構造にしました。
-
http
- Pangaeaで実装
- ユーザーが直接利用するため抽象化
-
http/internal
- Goで実装
-
http
内部でのみ利用
分けたことによって、http
モジュールはPangaea実装のみになり、オブジェクトとしていかに処理を抽象化するかに専念することができました。
課題
インメモリで状態管理しづらい(ほぼ無理)
Pangaeaではすべてのオブジェクトがimmutableです2。また、状態変化を表現できるような構文も用意していません。
そのため、CRUDをインメモリDBで実現するのが困難です3。
$ curl localhost:8081/users
{"users": []}
# ユーザーを新規作成したら...
$ curl -XPOST localhost:8081/users -d '{"name": "Taro"}'
{"id": "user_1", "name": "Taro"}
# 当然取得結果が変わる。でもどうやって表現する?
$ curl localhost:8081/users
{"users": [{"id": "user_1", "name": "Taro"}]}
ま、まあ、The Twelve Factor App でもWebアプリはステートレスにしましょうと言っていますし。
HTTPクライアントもあるので外部のDBにリクエストを投げればいいだけですし。
モックサーバーとして建てるならレスポンスは固定値でも問題ないですし...
...はい、完全に表現力不足です。すみません。
どうすべきか
解決策は1つではないと思いますが、再代入の設計を変えるのが一番手っ取り早いように見えます。
スコープの内側から外側の変数を再代入できるようになれば4、以下のようにシンプルに実装できます。
(※現状はシャドーイングされてしまうためスコープの外側には干渉できない)
# ※実際にはできない
users := {}
S.serve(
S.post("users") {|req|
user := {id: idGen.next, name: req.body.decJSON.name}
# usersに再代入(※実際のPangaeaではできない)
users = {user.id.S: user, **users}
Response.new(status: 201, body: user.S)
},
S.get("users") {|req|
# 参照するだけ
{users: users.values}
},
// ...
}
思えば、オブジェクトがimmutableであることと再代入に縛りがあることは関係ありませんでした。
=
の記号が衝突してしまっているため実装を後回しにしていたツケが回ってきました...
実は可能だった:インメモリで状態管理する方法(黒魔術)
最後におまけです。一応、現状でもインメモリで状態管理は可能でした。
ここから先の内容は「自作言語の仕様の穴を突く」というあまりにも誰得な内容です。物見遊山で覗いてみたい方だけご覧ください。
全体のソースコード
invite!("http")
idGen := <{|i| yield "user_#{i}"; recur(i+1)}>.new(1)
db := <{|users|
set := {|updated| recur(updated)}
yield [users, set]
}>.new({})
S.serve(
S.post("users") {|req|
db.next.{|users, set|
user := {id: idGen.next, name: req.body.decJSON.name}
set({user.id.S: user, **users})
Response.new(status: 201, body: user.S)
}
},
S.get("users") {|req|
db.next.{|users, _set|
{users: users.values}
}
},
S.delete("users/:id") {|req|
db.next.{|users, set|
set(users.exclude {|k, v| k == req.params.id}.O)
}
Response.new(status: 204)
},
url: ":8081",
)
やっていること
はじめに、Iteratorを作成します。
IteratorはPangaea唯一の状態変化するオブジェクトで、yield
で指定した値を Iter#next
で返します5。
# iteratorは関数の一種だが、 `recur` で次回呼び出された際の引数を指定できる
it := <{|i|
# nextの戻り値
yield i
# 次に呼ばれた際の引数
recur(i + 1)
}>.new(1) # newで初期値設定
it.next # 1
it.next # 2
it.next # 3
yield
の実体は return
の仲間で、「戻り値を設定するが呼び出し元に戻らない」という命令です。
戻り値を設定した後に recur
さえできれば、状態を更新することが可能です。
it := <{|i|
return i
}>.new(1)
it.next
# ※実際にはスコープ外で参照できないのでエラー
recur(9999)
そこで、recur
を呼び出す関数ごと yield
することで、好きな値に更新が可能になります。
it := <{|i|
# recurを呼び出し、次回呼び出し時のiをupdatedに更新する関数
# NOTE: recurは単なる関数(iterator呼び出し時に自動で定義される)なので、レキシカルスコープで参照可能
set := {|updated| recur(updated)}
# set関数ごと返す
return [i, set]
}>.new(1)
ret := it.next # [1, set]
ret[0].p # 1
ret[1](9999)
ret := it.next
ret[0].p # 9999
これをハンドラ内で行っているのが上記のソースコードです。
ちなみに、PangaeaのIteratorはJS, Python等のIteratorとかなり構文が異なります(処理の一時停止ではなく、引数が変化する関数呼び出しとして表現)が、このような実装にしたのは以下の2点が理由です。
- 関数の仕組みを使いまわせる
- for文を実装せずにすむ
おわりに
以上、Pangaeaのwebサーバー機能の紹介でした。
念願の「オレオレ言語でWebアプリ作成」を達成しましたが、同時に過去の設計方針による言語の限界も見えてきました。
すぐ直すかは決めていませんが、他にも同じ原因で詰まることがあれば改良していきたいです。