LoginSignup
3
0

自作言語でWebサーバーを作るまで【Pangaea】

Last updated at Posted at 2023-12-12

言語実装 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)のみ

また、ServerSClientC と書ける等の細かい補助もあります。

ワンライナーを省略せずに書くと...
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アプリ作成」を達成しましたが、同時に過去の設計方針による言語の限界も見えてきました。
すぐ直すかは決めていませんが、他にも同じ原因で詰まることがあれば改良していきたいです。

  1. といいつつ、JavaScript, Elixir, Io等からも大きな影響を受けています。

  2. 理由は、単に私がオブジェクトの状態変化を追うのが苦手だからです。 また、厳密にはIteratorのみ状態が変化します(後述)

  3. immutableであること自体は問題ではありません。

  4. Goの =, Pythonのglobal, nonlocal のイメージです。

  5. 詳細はドキュメント参照: https://github.com/Syuparn/Pangaea/blob/master/docs/reference/iterator.md#iterator

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0