LoginSignup
8
1

More than 1 year has passed since last update.

(GNU)AWKで簡易Webフレームワークを作る

Last updated at Posted at 2021-02-12

はじめに

「シェル芸」に効く!AWK処方箋を読んでいたところ、下の記述に目が留まりました。

Socket通信ができるということは、Webサーバーも構築できるということです。

AWKはサーバーサイド言語だった

つくったもの

というわけで、AWKの簡易Webフレームワークを作成しました。

GitHub - Syuparn/webawk: simple (mock) web server framework written in awk

REST APIサーバーをワンライナーで実行します。

AWKのWebサーバー実装は既にGitHubに大量にあったので、n番煎じなりにちょっと設計にオリジナリティを入れてます。

動作条件

  • GNU AWK (5.0.0+)
    • モジュールを使っているので4系では動きません...
  • jq
    • JSONのパース処理の実装完全にさぼりました:sweat_smile:

使い方

# 8000番ポートで起動
$ ./webawk.sh -p 8000 'GET("/names") {b["names"][1]="Taro"; res(200, b)}'
$ curl -v localhost:8000/names
*   Trying 127.0.0.1:8000...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8000 (#0)
> GET /names HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Connection: keep-alive
< Content-Length: 18
< Content-Type: application/json
< 
* Connection #0 to host localhost left intact
{"names":["Taro"]}

「AWKっぽさ」を残すため、ルーティングでパターンマッチしてレスポンスを返す設計にしています。

パスは複数指定可能で、最初にマッチしたものが実行されます。

example/multiple.awk
# body() でリクエストボディのマッチング
POST("/names") && (n=body(".name")) { b1["name"]=n; res(201, b1) }
POST("/names")                      { e["error"]="name required"; res(400, e) }
GET("/names")                       { b2["names"][1]="Taro"; res(200, b2) }
# パスパラメータは`:`で指定可能
DELETE("/names/:name")              { res(204) }
$ ./webawk.sh -p 8000 -f example/multiple.awk
$ curl -v -H 'Content-Type:application/json' -X POST -d '{"name": "Hanako"}' localhost:8000/names
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying 127.0.0.1:8000...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8000 (#0)
> POST /names HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.68.0
> Accept: */*
> Content-Type:application/json
> Content-Length: 18
> 
* upload completely sent off: 18 out of 18 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created
< Connection: keep-alive
< Content-Length: 17
< Content-Type: application/json
< 
* Connection #0 to host localhost left intact
{"name":"Hanako"}

$ curl -v -X DELETE localhost:8000/names/Hanako
*   Trying 127.0.0.1:8000...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8000 (#0)
> DELETE /names/Hanako HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 204 No Content
< Connection: keep-alive
* Connection #0 to host localhost left intact

設定していないパスを叩くと404が返ります。

$ curl -v -X DELETE localhost:8000/articles
*   Trying 127.0.0.1:8000...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8000 (#0)
> DELETE /articles HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 404 Not Found
< Connection: keep-alive
< Content-Length: 63
< Content-Type: application/json
< 
* Connection #0 to host localhost left intact
{"error":"Oops! Any of patterns did not match to the request."}

使い方については、詳しくは /example のサンプルコードをご覧ください。

仕組み

HTTP通信

双方向パイプで、コマンド /inet/tcp/${port}/0/0 を非同期に実行して入出力をやりとりすることでソケット通信を行っています。

functions.awk
# ソケット通信のコマンド文字列を生成
function http_service(    port) {
    # default port
    port = "8080"
    if (PORT) {
        port = PORT
    }
    return "/inet/tcp/" port "/0/0"
}
request/request_line.awk
function parse_request_line(req_reader, attrs) {
    # コマンド文字列からパイプ経由で、実行結果の標準出力(=クライアントからのリクエスト)を取得できる 
    req_reader |& getline

    attrs["method"]  = $1
    attrs["url"]    = $2
    attrs["version"] = $3
}
functions.awk
function res(statuscode, v,   res_str) {
    res_str = server::respond(statuscode, v)
    # 逆に、コマンド文字列の標準入力に渡す(=クライアントへのレスポンスを返す)
    printf res_str |& http_service()
    close(http_service())
}

詳細については、冒頭の「シェル芸」に効く!AWK処方箋の解説が分かりやすいのでおすすめです。

ルーティング

GET, POST等の関数はリクエストのHTTPメソッドとパスが指定された形式かどうかを判定しています。これらの関数は、既にレスポンスを返した場合問答無用で0を返します。

レスポンスを返したかどうかは、res()を読んだ際にグローバル変数_RESPONDEDのフラグを立てることで管理しています。

POST("/names") && (n=body(".name")) { b1["name"]=n; res(201, b1) }
POST("/names")                      { e["error"]="name required"; res(400, e) }
GET("/names")                       { b2["names"][1]="Taro"; res(200, b2) }
# パスパラメータは`:`で指定可能
DELETE("/names/:name")              { res(204) }

一連の流れ

webawkでは、入力されたawkプログラムを以下のようにラップして実行しています。

@include "functions.awk"

# 起動時に、リクエストをソケットから読み込みグローバル変数に代入
BEGIN {
    load_req()
}

## ユーザーのコードがここに挿入される ##

# いずれのパターンにもマッチしなかった場合に実行(Not Found)
!_RESPONDED {
    default_res()
}
# 次のリクエストを読み込み、リクエスト情報のグローバル変数を更新
1 {
    load_req()
}
# 次の入力を読み込み、1行目からもう一度実行
# 終了時にソケットを閉じる
END {
    close(http_service())
}

レスポンスを返した後は、再び次のリクエストを待つ必要があります。
for文による無限ループだと内部にパターン、アクション形式を書くことが出来ないため、ダミーの入力を複数行入れることでループを回しています。

webawk.sh
# seqでダミー行を作り、行数分リクエストを受けられるようにしている
seq $REQUEST_LIMIT |\
    $AWK_COMMAND -v PORT="$PORT" "$(cat main.former.awk; echo $1; cat main.latter.awk)"

レスポンス生成

レスポンスボディは、連想配列をJSONに変換して生成しています。
awkは配列と連想配列の区別が無いので、

  • 連番の数字添え字なら配列
  • それ以外はオブジェクト

にシリアライズしています。

json/marshal.awk
function marshal(v) {
    if (awk::typeof(v) == "number") {
        return v
    }
    if (awk::typeof(v) == "string") {
        return sprintf("\"%s\"", v)
    }
    if (awk::typeof(v) == "array") {
        if (is_numeric_array(v)) {
            return _marshal_numeric_array(v)
        } else {
            return _marshal_associative_array(v)
        }
    }
}

function _marshal_associative_array(v,    i, len, sorted, pair, json) {
    json = "{"
    # 連想配列の順序は保証されないので、キーの名前順にソート(Goのjson.Marshalと同じ仕様にした)
    len = awk::asorti(v, sorted)
    for (i = 1; i <= len; i++) {
        pair = marshal(sorted[i]) ":" marshal(v[sorted[i]])
        json = json pair
        # 最後に余分なコンマをつけない
        if (i < len) {
            json = json ","
        }
    }
    json = json "}"
    return json
}

function _marshal_numeric_array(v,    i, len, json) {
    json = "["
    len = length(v)
    for (i = 1; i <= len; i++) {
        json = json marshal(v[i])
        # NOTE: avoid trailing comma
        if (i < len) {
            json = json ","
        }
    }
    json = json "]"
    return json
}

function is_numeric_array(arr,    i, len) {
    if (awk::typeof(arr) != "array") {
        return 0
    }

    len = length(arr)
    for (i = 1; i <= len; i++) {
        # arr[i] がもし無いなら、代わりに別のキーがあるので配列ではない
        if (!(i in arr)) {
            return 0
        }
    }

    return 1
}

実装所感

namespace

gawk 5.0から名前空間が実装されました。 namespace::hoge() の形で、モジュールを構造化することが出来るので、コードの見通しが良くなります。

Namespace Example (The GNU Awk User’s Guide)

正直、AWKはワンライナーの印象が強かったので驚きです。

一点注意としては、includeのパスはコマンド実行時のカレントディレクトリで解決するので、別ディレクトリから実行すると読み込めなくなります。

(そのためwebawkでは、cdでカレントディレクトリをプロジェクトのルートに移動してからgawkを実行しています)

変数

AWKは、関数の中で宣言した変数もグローバル変数になってしまいます

ただし、仮引数だけは関数スコープなので、ローカル変数を仮引数に宣言しておけば外に漏れずに済みます。

普通の仮引数と区別するため、ローカル変数として使う仮引数はスペースを空けて定義するのが作法らしいです。

json/marshal.awk
#                  普通の仮引数 | ローカル変数
function is_numeric_array(arr,    i, len) {
    if (awk::typeof(arr) != "array") {
        return 0
    }

    len = length(arr)
    for (i = 1; i <= len; i++) {
        # if arr[i] is not found, arr has non-numeric key instead
        if (!(i in arr)) {
            return 0
        }
    }

    return 1
}

(裏技感満載ですが公式のコーディングスタイルです)

Variable Scope - The GNU Awk User's Guide

配列のdelete

gawkでは配列の配列を作ることが可能ですが、空配列を作る場合にはダミー要素を代入してからdeleteする必要があります。

https://man7.org/linux/man-pages/man1/gawk.1.html
NOTE: You may need to tell gawk that an array element is really a
subarray in order to use it where gawk expects an array (such as
in the second argument to split()). You can do this by creating
an element in the subarray and then deleting it with the delete
statement.

(split() の第2引数のように) gawkがarray型を期待する場所でsubarrayを使う場合、その配列要素が本当にsubarrayであることをgawkに伝える必要があります。これはsubarrayの要素を1つ作り、delete文でその要素を消すことで実現可能です。

request/request.awk
# NOTE: request["queries"]をarrayと認識させるために変数を初期化
request["queries"][""] = ""
delete request["queries"][""]

テスト

今回はTDDで手探りで実装をすすめました(普段のクセでかなりGoっぽくなってます)。

gawkは(意外にも)ユニットテストがかなり書きやすいと感じました。

双方向パイプにはコマンド文字列を渡すだけなので、ソケット通信の代わりにフィクスチャのコマンドを渡せば自然にモックを差し込めます。

request/request_line.awk
function parse_request_line(req_reader, attrs) {
    # ソケット通信のコマンドは文字列で受け取るので...
    req_reader |& getline

    attrs["method"]  = $1
    attrs["url"]    = $2
    attrs["version"] = $3
}
request/request_line_test.awk
function test_parse_request_line(    tests, f) {
    # ...
    for (i in tests) {
        # 代わりにフィクスチャのコマンドを渡せばモック化可能
        f = test::string_to_stdin(tests[i]["input"])

        err = _test_parse_request_line(tests[i], f)
        test::teardown_fixture(f)
        if (err) {
            return "test_parse_request_line: " err
        }
    }
}

フィクスチャのコマンドはcatで生成しています。

request/main_test.awk
function setup_fixture(filename,    filepath) {
    # テストデータファイルを出力する
    filepath =  "request/testdata/" filename
    return sprintf("cat %s", filepath)
}

エラーコード

ubuntuを日本語版にしたためかgawkのエラーが日本語で出てきて、ググっても中々情報が出てきませんでした。

エラーコードの対訳(↓リンク)を見ながら、英語で検索し直すのがおすすめです。

gawk/ja.po at master · gvlx/gawk · GitHub

リクエストボディの読み込み

awkの仕様なのか、Content-Lengthの文字数分読み込もうとすると次の文字の入力を待ってしまい処理が止まってしまいます。
(次の文字はセッションが切れた時に流れてきますが後の祭りです)

そこで、Content-Length - 1文字だけリクエストボディを読み込み、最後にJSONの閉じかっこ}を補っています。
(この実装は GitHub - kevin-albert/awkserver: A minimal HTTP server that runs in GAWK を参考にしました)

request/body.awk
function parse_body(req_reader, len, content_type,     previous_rs) {
    # HACK: 先頭のlen-1文字を改行区切り文字として読み込む(AWKは一文字ずつ読み込む方法が無いため)
    previous_rs = RS
    RS = sprintf(".{%d}", len - 1)
    req_reader |& getline

    RS = previous_rs

    # NOTE: 最後の文字が空白だったら何もしない
    # でないと'}'が複製されてしまう (ex: '{"a": "b"} ' -> '{"a": "b"}}')
    if (substr(RT, len - 1, 1) == _last_byte_of(content_type)) {
        return RT " "
    }

    # 無くなった最後の文字('}')を補う
    return RT _last_byte_of(content_type)
}

おわりに

「文字列成形くん」としてしか使っていなかったAWKですが、非常に表現力が高いと実感しました。「双方向パイプ」ではどんなコマンドも使えるので、その気になればSQLで永続化したりマイクロサービス化したりもできそうです。

実装さぼってjqに丸投げしたところは...乞うご期待:sweat_smile:

8
1
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
8
1