ECS + FargateでgRPCを動かす
gRPCでリクエストを受けるアプリをECS + Fargateで動かしつつ、ちゃんと負荷分散するために調べたことのメモ。
先に結論
リバースプロキシとしてenvoyを走らせて、ECS Service Discovery経由でつなぐのが良さそう。インターネットからの通信を受けたいならALBではなくNLBを使う。
検証用プログラム
gRPCでUUIDを返す。UUIDはサーバの起動時に1回だけ生成するので、UUIDの値を比較すれば負荷分散ができているかがわかる。
hello.proto
syntax = "proto3"; package hello; option go_package = ".;main"; message HelloRequest { } message HelloReply { string msg = 1; } service Hello { rpc SayHello (HelloRequest) returns (HelloReply) {} }
server.go
package main import ( "context" "github.com/google/uuid" "google.golang.org/grpc" "log" "net" ) type Service struct { id string } func (service *Service) SayHello(ctx context.Context, message *HelloRequest) (*HelloReply, error) { return &HelloReply{ Msg: "Hello from " + service.id, }, nil } func main() { port, err := net.Listen("tcp", ":50051") if err != nil { log.Fatalln(err) } server := grpc.NewServer() helloService := &Service{ id: uuid.New().String(), } RegisterHelloServer(server, helloService) err = server.Serve(port) if err != nil { log.Fatalln(err) } }
client.go
package main import ( "context" "flag" "fmt" "google.golang.org/grpc" "log" ) func main() { flag.Parse() conn, err := grpc.Dial(flag.Arg(0), grpc.WithInsecure()) if err != nil { log.Fatalln(err) } defer conn.Close() client := NewHelloClient(conn) msg := &HelloRequest{} res, err := client.SayHello(context.TODO(), msg) if err != nil { fmt.Printf("error::%#v \n", err) } fmt.Println(res.Msg) }
ECSで動かしたいのでDockerfileも用意した。単にgo build
をするだけでなく、マルチステージビルドにしてdockerイメージを軽くするとかAlpine Linuxにglibcを入れるとかもやっている。
FROM golang:latest as builder WORKDIR /go/src/grpc_sample COPY server.go hello.proto ./ RUN apt update \ && apt install -y protobuf-compiler \ && go get google.golang.org/grpc \ && go get github.com/golang/protobuf/protoc-gen-go RUN protoc --go_out=plugins=grpc:. hello.proto RUN go get -v ./... RUN go build -o server server.go hello.pb.go FROM alpine:latest COPY --from=builder /go/src/grpc_sample/server server RUN apk update && apk add libc6-compat EXPOSE 50051
ビルドしたdockerイメージをECRにプッシュしたらECSの検証に移る。
ECSとロードバランサーとgRPC
ECSの負荷分散だとApplication Load Balancer(ALB)が使われることが多いが、ALBのターゲット側はHTTP/1.1にしか対応していないためgRPC(HTTP/2)では使えない。
ではどうするかというと、ALBの代わりにNetwork Load Balancer(NLB)を使う。NLBはレイヤー4(TCP)で処理を行うので、gRPCの通信も問題なく通過できる。これで一見良さそうに見えるが、設定を進めてみるといくつかの問題点が判明した。
- HTTP/2は1つのTCPコネクションを長く使うため、適切な負荷分散が行われない可能性がある(らしい)
- 特定のコンテナに負荷が偏る
- 今回使用したサンプルプログラムでは1回1回通信を張るので確認できず
- セキュリティグループの設定ができない
- コンテナにはクライアントの送信元IPがそのまま届くので 0.0.0.0/0 を許可するしかない
- SSLの終端ができない
セキュリティグループは頑張ってIPを設定する、SSLはいったん諦めるとして、負荷分散はちゃんとやりたいということでたどり着いた構成が以下。
NLBとサーバの間にEnvoyというプロキシを挟む。envoyからサーバへ通信するためには各サーバコンテナのIPを知る必要があるので、ECS Service Discoveryを使って server.grpc.local
がコンテナのIPを返すようにしておく。
NLBのDNS名に対してクライアントからリクエストした様子。c9cf2091-
で始まるUUIDと 62aa1e41-
のUUIDが返ってきているのでちゃんと動いているはず。
envoy.yaml
envoy 1.16.0-dev-0b24c6で動作確認。envoy API v3。ネット上で見つかる記事だとv2の記述が多いので、調べるときは記事の日付を見た方が良い。
admin: access_log_path: '/dev/null' address: socket_address: address: 127.0.0.1 port_value: 9901 static_resources: listeners: - name: listner_0 address: socket_address: protocol: TCP address: 0.0.0.0 port_value: 5000 filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: ingress_http codec_type: AUTO route_config: name: local_route virtual_hosts: - name: backend domains: - '*' routes: - match: prefix: '/' route: cluster: grpc_sample access_log: - name: envoy.access_loggers.file typed_config: "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog path: '/dev/stdout' http_filters: - name: envoy.filters.http.router clusters: - name: grpc_sample connect_timeout: 0.25s type: LOGICAL_DNS lb_policy: ROUND_ROBIN http2_protocol_options: {} health_checks: - timeout: 5s interval: 10s unhealthy_threshold: 2 healthy_threshold: 2 tcp_health_check: {} load_assignment: cluster_name: grpc_sample endpoints: - lb_endpoints: - endpoint: address: socket_address: address: server.grpc.local port_value: 50051
Amazon Auroraで"Unknown MySQL error"が出る
DjangoのチュートリアルをDockerで動かしていて、DBにAmazon Aurora (MySQL 5.7)を使おうとしたらERROR 2000 (HY000): Unknown MySQL error
が出た話。
先に結論
クエリキャッシュを無効にする (query_cache_type = 0) と解決する。
環境
- masOS Catalina 10.15.2
- docker desktop community 2.1.0.5 (40693)
問題のSQLクエリ
トップページ用にQuestionの一覧を取得する。
SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date` DESC LIMIT 5;
調査
手元のMac + brewで入れたMySQL 8.0のクライアント → OK
% mysql -V mysql Ver 8.0.18 for osx10.15 on x86_64 (Homebrew) % % mysql -uadmin -p -h <Auroraのクラスターエンドポイント> -D mysite_db Enter password: mysql> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date` DESC LIMIT 5; +----+---------------+----------------------------+ | id | question_text | pub_date | +----+---------------+----------------------------+ | 1 | What's up? | 2020-01-16 07:45:11.757886 | +----+---------------+----------------------------+ 1 row in set (0.02 sec)
Alpine LinuxのDockerコンテナ + mariadb-client(10.4.10) → ERROR 2000 (HY000): Unknown MySQL error
% docker run -it --rm alpine:latest /bin/ash / # apk update / # apk add mariadb-client / # / # mysql -V mysql Ver 15.1 Distrib 10.4.10-MariaDB, for Linux (x86_64) using readline 5.1 / # / # mysql -uadmin -p -h <Auroraクラスターのエンドポイント> -D mysite_db Enter password: MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date` DESC LIMIT 5; ERROR 2000 (HY000): Unknown MySQL error
クエリの一部(DESC LIMIT 5
)を消すとエラーコードすら出なくなるが、何度か再実行すると正常な結果が返ってきたりして不安定。
MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date`; ERROR: MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date`; ERROR: MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date`; +----+---------------+----------------------------+ | id | question_text | pub_date | +----+---------------+----------------------------+ | 1 | What's up? | 2020-01-16 07:45:11.757886 | +----+---------------+----------------------------+ 1 row in set (0.000 sec) MySQL [mysite_db]> MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date` DESC LIMIT 5; +----+---------------+----------------------------+ | id | question_text | pub_date | +----+---------------+----------------------------+ | 1 | What's up? | 2020-01-16 07:45:11.757886 | +----+---------------+----------------------------+ 1 row in set (0.000 sec)
UbuntuのDockerコンテナ + mariadb-client(10.1.43) → ERROR 2027 (HY000): Malformed packet
% docker run -it --rm ubuntu:latest /bin/bash root@cc00c89acc69:/# apk update root@cc00c89acc69:/# apk upgrade root@cc00c89acc69:/# apk install mariadb-client root@cc00c89acc69:/# root@cc00c89acc69:/# mysql -V mysql Ver 15.1 Distrib 10.1.43-MariaDB, for debian-linux-gnu (x86_64) using readline 5.2 root@cc00c89acc69:/# root@cc00c89acc69:/# mysql -uadmin -p -h <Auroraクラスターのエンドポイント> -D mysite_db Enter password: MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date` DESC LIMIT 5; ERROR 2027 (HY000): Malformed packet
解決
"Unknown MySQL error"では有益な情報にたどり着けなかったので"Malformed packet"で調べたところ、クエリキャッシュを切ったら直るという情報を発見。
Auroraのパラメータグループを変更したあと、Alpineのコンテナで確認。
/ # mysql -uadmin -p -h <Auroraクラスターのエンドポイント> -D mysite_db Enter password: MySQL [mysite_db]> SHOW VARIABLES LIKE '%query_cache_type%'; +------------------+-------+ | Variable_name | Value | +------------------+-------+ | query_cache_type | OFF | +------------------+-------+ 1 row in set (0.019 sec) MySQL [mysite_db]> MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date` DESC LIMIT 5; +----+---------------+----------------------------+ | id | question_text | pub_date | +----+---------------+----------------------------+ | 1 | What's up? | 2020-01-16 07:45:11.757886 | +----+---------------+----------------------------+ 1 row in set (0.013 sec)
直った。少なからずパフォーマンスは落ちると思われる。
Auroraが変なレスポンスを返しているのか、MariaDBのクライアントがちゃんと解釈できてないのか、それ以外のところが悪いのか、よくわからない。
i3-9100Fで自宅PCを組んだ
メモリとSSDが安くなってたので勢いで。
構成
まず、いままで使っていたPCの構成がこちら。Netflixで映画を見たりETS2でヨーロッパを走り回る分には特に問題ないが、組んでから4年半が経過してるのでそろそろ入れ替え時。
パーツ | 型番 |
---|---|
CPU | Intel Core i5-4460 |
CPUクーラー | サイズ 虎徹 (SCKTT-1000) |
メモリ | Patriot DDR3-1600 4GB x2 (PSD38G1600KH) |
マザーボード | ASUS H97-PRO |
GPU | MSI GTX 1060 AERO ITX 6G OC |
SSD | OCZ Arc 100 240GB |
HDD | WD Green 2TB (WD20EZRX) |
ケース | Fractal Design Define R5 |
電源 | ENERMAX Platimax EPM750AWT |
で、新しいマシンの構成がこちら。CPU/メモリ/マザーの3点セットとSSD以外は流用した。
パーツ | 型番 |
---|---|
CPU | Intel Core i3-9100F |
CPUクーラー | サイズ 虎徹 (SCKTT-1000) |
メモリ | CFD Crucial DDR4-2666 8GB x2 (W4U2666CM-8G) |
マザーボード | ASRock B365M Pro4 |
GPU | MSI GTX 1060 AERO ITX 6G OC |
SSD | WD Blue SN500 NVMe M.2 SSD 500GB (WDS500G1B0C) |
ケース | Fractal Design Define R5 |
電源 | ENERMAX Platimax EPM750AWT |
現行のi3は4コア4スレッドかつターボブーストも効く*1のでかつてのi5より性能が良い。グラボがあるので内蔵GPUなしのFモデルにしたのが今回のポイント。
組み立て
数年ぶりにケースをサイドパネルをオープン。Define R5は空気を取り入れるところにフィルタがついているので、ケース内のホコリは少なめ。
マザーボードから外してグリスをきれいにしたi5-4460。グリスの塗りなおしはしなかったので、表面の刻印を見たのは組んだ日とバラす日の2回だけ。
i3-9100F。ヒートスプレッダの形状が少し変わった。
マザーボードの説明書に従ってCPUクーラーを先に付けたら、M.2のヒートシンク取り付けがやりにくい。
マザーボードが一回り小さくなったのと、HDDがなくなったのとでケース内部がスッキリした。この写真を見ていて気付いたが、ケースファンが吸気x2 排気x1の構成なのでケース内が陽圧になっていてホコリが少なかったのかもしれない。
自作PCの鉄の掟「BIOS画面が出るまでサイドパネルを閉めない」を守ったので1発でブートした pic.twitter.com/82o8wYNO9j
— あみだ (@_nibral) 2019年12月24日
ベンチマーク
FF14ベンチは「非常に快適」判定。
CPUに100%負荷をかけても40℃ちょっとで安定している。(室温20℃)
お手頃価格とはいえさすがNVMeという速度。
まとめ
トータル3万円ちょっとでPCの世代交代ができてよかった。
*1:9100Fは最大4.2GHz
オフィスにCisco Meraki MR33を導入してみた
NTTから貸し出されるホームゲートウェイ(RS-500KI)の無線LANがどうにも不安定なので、ちゃんとしたアクセスポイントを導入した話。
Cisco Merakiとは
数年前にCiscoが買収したプロダクトで、無線LANのアクセスポイントやスイッチ、ファイヤーウォールなどを揃える。設定は全てクラウド上で一元管理されているのが特徴で、一般的なネットワーク機器のようにそれぞれの設定画面にログインして設定する必要がない。
Merakiシリーズはエンタープライズ向けなので一般消費者向けの小売りはされておらず、代理店経由で購入する必要がある。より小規模なネットワーク向けにMeraki Goというシリーズもあり、機能面では本家Merakiに及ばないものの、こちらはAmazonで買える。

- 出版社/メーカー: CISCO SYSTEMS - ENTERPRISE
- 発売日: 2019/06/21
- メディア: Personal Computers
- この商品を含むブログを見る
無償検証用アクセスポイント
2019/10現在、Ciscoが提供するオンラインセミナー(ウェビナー)を受講すると検証用としてアクセスポイントがもらえるキャンペーンを展開している。
法人向けのためか多少のやりとりが必要なので、簡単に手順を。
まず、Merakiオンデマンドウェビナーのサイトにアクセスして「イントロダクション : クラウド管理型IT パワフルなITをよりシンプルに」の「視聴する」をクリック。
申し込みフォームが表示されるのですべて入力して「送信」。セミナー動画(1時間くらい)が再生できるようになるのでちゃんと見る。見終わると再度メールアドレスの入力フォームが表示されるので、メールアドレスを入力。
正常に受け付けられると、「アクセスポイントの送付先住所をMerakiの営業に送ってね」というメールが来る。
指定されたアドレスにアクセスポイントの送付を希望する旨と送付先をメール。ほどなくして相手方から電話があり、ネットワークの規模やアクセスポイントの使い方を聞かれたので素直に答える。問題なさそうだと判断されると(?)アクセスポイントが発送される。
今回はMR33というアクセスポイントと3年分のサブスクリプションがもらえることになった。定価で考えると機器代が99,800円、サブスクリプションが39,900円なのでかなり太っ腹。
発送されると「Your Meraki AP has shipped - xxxxxxxxx」というメールが来るので、大切に保存しておく。
外観
エンタープライズ向けらしくシンプルな箱。
MR33本体のほか、壁掛け用の台座と金具類が付属。
接続口はLANと電源のみ。底面が丸みを帯びていて端のほうは隙間ができるので、フラットでない普通のLANケーブルでも問題ない。
大きさはティッシュ箱と同じか一回り小さいくらい。構造的に縦置きはできず、平置きか壁掛けのどちらかを選ぶ必要がある。
小さいほうの箱にはACアダプターが入っていた。後で調べたところ、MR33はPoE給電が基本なのでACアダプターは別売とのこと。電話で「フレッツ光のホームゲートウェイ使ってます」という話をしたので、気を使ってくれたのかも。
初期設定
アクセスポイントのネットワーク設定
前述したようにMerakiのアクセスポイントはクラウド上で設定を行う。逆に言うとアクセスポイントがインターネットにつながっていないと何もできない。
DHCPでIPアドレスが配られているネットワークであればLANケーブルと電源をつなぐだけでよいが、静的IPを振る場合はいったんアクセスポイントに直接接続して設定を変える必要がある。詳細な手順は公式の設置ガイドを参照のこと。ステータスLEDが緑になればOK。
アクセスポイントを設置したら、以降はMerakiのWebサイトで設定を行う。
Meraki ダッシュボードアカウントの作成
MerakiのWebサイトにアクセスして右上の「Login」をクリック。
アカウントを作成するので「Create an account」。
リージョンは Asia を選択して「Next」。
続いてアカウントの情報を入力し「Create account」。
入力したメールアドレスに「Cisco Meraki Email Verification」というメールが届くので、本文中のURLを開いてメールアドレスの検証を行う。
デバイスとライセンスの登録
ダッシュボードを開くとWelcome的な画面が表示されるので「Register Meraki devices」を選択して「Next」。
ネットワーク名とネットワークに参加させるデバイスの選択画面。Merakiにおけるネットワーク機器の管理は 組織 → ネットワーク → デバイス という階層構造になっている。
のようなイメージ(たぶん)。また、ネットワークに所属していないデバイスは インベントリ にプールされる。
インベントリにデバイスを追加する際は、購入した際のオーダー番号か製品記載のシリアルナンバーを使う。今回は1台だけなのでシリアルナンバーを入力して「Add devices」した。
ネットワーク名を入力し、追加したMR33にチェックを入れたら「Create network」。
正常にネットワークが作成されるとネットワークに所属しているアクセスポイントの一覧画面が表示されるが、この段階ではデバイスを登録しただけでサブスクリプションが有効になっていない。
サブスクリプションを有効にするには「Your Meraki AP has shipped - xxxxxxxxx」のメールに記載のURLをクリックする。サブスクリプションが有効になると、左側のメニュー Organization → License info のLicense statusが Ok になる。
これで無線LANアクセスポイント1台 x 3年のライセンスが有効になった。
MR33の面白いところ
電波状態モニターの表示が細かい
各チャンネルの混雑具合と時間軸での変化が色分けで表示される。ほぼリアルタイムで更新されるので、チャンネルを割り当てるときの参考にするもよし、スピードテストを走らせて色が赤くなるのを楽しむのもよし。
NATとファイアウォールがついてる
IP割り当てをNAT modeにすると、アクセスポイントがDHCPサーバとして動作して配下の端末に10.0.0.0/8のIPアドレスを配るようになり、無線LANクライアント間での通信が行えなくなる。
ファイアウォールでは、IPアドレスやポートを指定して通信を拒否できる。デフォルトでは有線LAN側端末へのアクセスを拒否する設定なので、NASにアクセスする場合などは許可する必要がある。
2つの設定をうまく組み合わせるとゲスト用のネットワークのセキュリティを高められる。
ゲスト用SSIDにスプラッシュページが出せる
インターネットに出る前に「ご来社ありがとうございます」的なページを出したり、認証を求めることができる。試してはいないが、ページの種類をBillingにして課金制にすることもできるようだ。
導入して1週間、今のところは安定して動作しているしネットワークの管理もやりやすいと思う。家庭用の無線LANルータが3,000円ちょっとで買える時代ではあるが、エンタープライズ向けはいろいろ設定できて楽しい。
AWS SAMでDefaultAuthorizerとCORSが共存できるようになった
半年ほど前に以下のような記事を書いたが、その後のアップデートでDefaultAuthorizerとCORSが問題なく共存できるようになった。
関係しそうなところ
- AWS::Serverless::ApiのAuthプロパティに「AddDefaultAuthorizerToCorsPreflight」が追加され、CORSのプリフライトリクエストにDefaultAuthorizerを適用するかどうかを指定できるようになった (AWS SAM v1.13~)
- 以前テストをパスできなくて取り下げられていたもの
- 「sam local start-api」がCORSに対応した (AWS SAM CLI v0.21.0~)
- Authorizerには直接関係しないが、ローカルでプリフライトリクエストの動作確認ができるようになった
設定例
以下のような template.yml を作成すると、
- GET /
- CognitoAuthorizer
- GET /public
- Authorizerなし
- OPTIONS / および OPTIONS /public
- Authorizerなし (プリフライトリクエスト成功)
という動作が実現できる。AWS SAM CLI v0.22.0で確認。
Resources: MyApi: Type: AWS::Serverless::Api Properties: Auth: DefaultAuthorizer: CognitoAuthorizer AddDefaultAuthorizerToCorsPreflight: false Authorizers: CognitoAuthorizer: UserPoolArn: <Cognito UserPool ARN> Cors: AllowOrigin: "'*'" MyFunction1: Type: AWS::Serverless::Function Properties: Handler: index.handler CodeUri: src/ Events: GetIndex: Type: Api Properties: Path: / Method: get MyPublicFunction1: Type: AWS::Serverless::Function Properties: Handler: index.handler CodeUri: src/ Events: GetPublic: Type: Api Properties: Path: /public Method: get Auth: Authorizer: 'NONE'
もともとやりたかったことができるようになったので満足。
AWS SAMでDefaultAuthorizerを設定するとCORSに失敗する
-------- 2019/10/17追記 --------
AWS SAMのアップデートでこの問題は解消した。
-------- 追記ここまで --------
問題
AWS SAMのAWS::Serverless::Apiでは、AuthプロパティのDefaultAuthorizerをセットすることですべてのエンドポイントに対してオーソライザーを設定できる。
Resources: MyApi: Type: AWS::Serverless::Api Properties: Auth: DefaultAuthorizer: CognitoAuthorizer Authorizers: CognitoAuthorizer: UserPoolArn: !Ref CognitoUserPoolArn
また、Corsプロパティをセットすることですべてのエンドポイントに対してCORSを有効にできる。
Resources: MyApi: Type: AWS::Serverless::Api Properties: Cors: AllowOrigin: "'*'"
ところが、これら2つのプロパティを同時にセットすると、CORSのプリフライトリクエスト(OPTIONS)に対してもオーソライザーが有効になってしまい、CORSに失敗する。(401 Unauthorizedが返る)
解決策
AWS::Serverless::ApiのDefaultAuthorizerを削除し、AWS::Serverless::FunctionのEventsに対して個別にAuthプロパティを設定する。
Resources: MyApi: Type: AWS::Serverless::Api Properties: Auth: Authorizers: CognitoAuthorizer: UserPoolArn: !Ref CognitoUserPoolArn Cors: AllowOrigin: "'*'" MyFunction: Type: AWS::Serverless::Function Properties: Handler: index.handler CodeUri: src/ Events: GetIndex: Type: Api Properties: Path: / Method: get Auth: Authorizer: CognitoAuthorizer
Authorizerの設定を忘れると誰でもアクセス可能になってしまうので好ましい解決策とは言えないが、現時点ではこうするしかないようだ。
プリフライトリクエストに対してDefaultAuthorizerを適用するかどうかのプロパティ(AddDefaultAuthorizerToCorsPreflight)を追加するプルリクエストを作成した人がいたようだが、ユニットテストをパスできないとかで取り下げられてしまっている。
Writing An Interpreter In Go その9(終)
その8はこちら
この本に取り組み始めて1か月が経ってしまいそうだったので、日曜丸1日を使って残りを全部終わらせることにした。
4章では文字列型、配列、ハッシュの構文と、それに付随する組み込み関数をいくつか追加する。インタプリタの仕組みはすでに完成しているので、それぞれの構文について
- 構文に必要な記号の処理をLexerに追加
- 構文を表す構造体を定義し、AST上で表現できるようにParserを変更
- 文法的な意味を評価する処理をEvaluatorに追加
という作業を行うことになる。
4.2: 文字列型
4.2: Parse and evaluate strings · nibral/monkey_interpreter@aeaa9b1 · GitHub
Monkeyにおける文字列は、"foobar" のように対象をダブルクオーテーションで挟む形で表記する。
上で示した流れに沿って、
- Lexerに「 " が出現したら次の " までを文字列トークンとする」処理を追加
- Parserに文字列を表す構造体を追加
- Evaluatorでは構造体の持つ文字列をそのまま評価値とする
の順に改良すると文字列が扱えるようになる。
4.2: Concat strings · nibral/monkey_interpreter@782322f · GitHub
あわせて+演算子を使った文字列の連結も追加する。これは簡単で、演算子の両辺が文字列の場合は連結した文字列を評価値とすればよい。
>> "hello" + " world" hello world >> let greet = fn(first, last) { "Hello, " + first + " " + last + "!" } >> greet("John", "Doe") Hello, John Doe! >>
4.3: 組み込み関数 (len)
4.3: Add built-in function (len) · nibral/monkey_interpreter@0a22d78 · GitHub
文字列の長さを返す組み込み関数 len() を追加する。
長さを求める処理はgolangに同名の関数があるのでそのまま使うことにして、"len"というキーワードが出たら識別子ではなく組み込み関数の呼び出しとして評価するようにEvaluatorの処理を変更。
>> len("Hello World!") 12 >> len("") 0
4.4: 配列
4.4: Implement array · nibral/monkey_interpreter@92fcd31 · GitHub
Monkey以外の多くの言語と同じように、配列は大括弧とカンマを使って [var1, var2, var3] と表現する。
Lexerで [ と ] を処理できるようにするとParserで配列宣言の始まりと終わりが認識できるようになるので、あとはカンマ区切りの式を順番に評価していく。この動きは関数の機能を付けた時に引数の処理ですでに実装したものがあるので、宣言の終わりを意味する文字を指定できるように改良して流用できる。
配列の仕組みそのものはgolangの配列をそのまま使っているので目新しいことはなく、なんか消化試合っぽいなぁと思ったり。
4.4: Add built-in functions for array · nibral/monkey_interpreter@2630420 · GitHub
組み込み関数も改良。len()で配列の長さを取得できるようにしたのと、first/last/rest という配列特有の処理を行う関数を追加した。
この辺の関数と高階関数をうまいこと組み合わせるとmapとかreduceも実現できるらしい。すごい。
>> let a = [1, 2, 3, 4]; >> let double = fn(x) { x * 2 }; >> map(a, double); [2, 4, 6, 8]
4.5: ハッシュ
4.5: Implement hash · nibral/monkey_interpreter@166b489 · GitHub
最後に追加するのはハッシュ。言語によってはマップとか辞書とか呼ばれるが、要はkey-valueの形で値を保持する仕組みのこと。
ここで面白かったのは key の扱い。いまのつくりでは各行に現れた文字列を別々のオブジェクトとして扱うので、keyとなるオブジェクトをそのまま使ってしまうと同じ文字列なのにHashから値が取り出せなくなってしまう。
文章だとわかりにくいので本文のコードを抜粋。Javaの文字列を .equals じゃなくて == で比較してアレ?ってなるのと同じ。たぶん。
name1 := &object.String{Value: "name"} monkey := &object.String{Value: "Monkey"} pairs := map[object.Object]object.Object{} pairs[name1] = monkey fmt.Printf("pairs[name1]=%+v\n", pairs[name1]) // => pairs[name1]=&{Value:Monkey} name2 := &object.String{Value: "name"} fmt.Printf("pairs[name2]=%+v\n", pairs[name2]) // => pairs[name2]=<nil> fmt.Printf("(name1 == name2)=%t\n", name1 == name2) // => (name1 == name2)=false
この問題を解決するために、MonkeyではHashKeyという整数値を使う。booleanなら0/1、intならその値、文字列ならFNV-1というアルゴリズムで求めたハッシュ値をkeyにすることで、同じ値を示す別々のオブジェクトを通してHashの値を取り出せるようになった。
FNV-1って初めて聞いたけど、SHA-1やMD5と比べると軽くて速いらしい。(その代わり暗号化には使えない?)
4.6: puts
4.6: Add puts() · nibral/monkey_interpreter@6018c7f · GitHub
一番最後に文字列の出力を付けて「Hello, world!!」を出せるようになるのエモい。
おわりに
本を読みながら一通り手を動かしてみたことで、構文解析とかインタプリタに対する認識が「何してるかさっぱりわからない」から「どんな感じで解釈してるか何となく想像できる」くらいになった。
これをやったからといって既存言語の開発にコミットできるとか新しい言語を作れるというわけではないけど、苦手意識というかブラックボックス感を取り除くことはできた。
英語版については、そこまで難しい言い回しが出てこないので違和感なく読み進めることができた。たまに「?」ってなる文があると大体成句で決まった意味があるのでググればすぐ解決する。
golangの文法に関する説明がほとんどないので全くのプログラミング初心者にはおすすめできないが、いくつかプログラミング言語を使ったことがあって、でもどうやって実行されてるのかは知らないみたいな人には良い教材だと思う。