Go言語でつくるインタプリタを読み終えて,独自拡張した話
Go言語でつくるインタプリタを漸く読み終わりました.といっても実際には1月末には終わっていて単純に記事にするのを忘れていただけなんです.
実際にコードを書きながら読んでいたのですが,リポジトリのログを遡るとファーストコミットは2018/6/24になってました. およそ半年と1ヶ月ほどでしょうか.合間合間にすすめていたのですごい時間かかってますね.
でも本当に良い本でした. 本書でも述べられている通り,Brainf*ckとかじゃなく割とちゃんとした,でもそこまでヘヴィじゃない言語仕様のインタプリタをフルスクラッチで作るというのは中々ありませんでしたから.
独自拡張の話
で,本題なんですが本書では最終的にハッシュと組み込み関数の実装で終わっています(付録としてマクロの実装もある)
その時点で動くプログラムはこんな感じ
let map = fn(arr, f) { let iter = fn(arr, accumulated) { if (len(arr) == 0){ accumulated } else { iter(rest(arr), push(accumulated, f(first(arr)))); } }; iter(arr, []); }; let a = [1,2,3,4]; let double = fn(x) { x * 2 }; map(a, double);
配列要素をmap関数で2倍ずつしていくというプログラムです. これでも結構プログラムっぽいですが,次のようなコードは動きません.
let fizzbuzz = fn(n) { for (let i=1;i<n+1;++i) { switch { case i % 3 == 0 && i % 5 == 0: puts("FizzBuzz"); break; case i % 3 == 0: puts("Fizz"); break; case i % 5 == 0: puts("Buzz"); break; } } }; fizzbuzz(15);
典型的なFizzBuzzです.このプログラムを動かすのに何が足りないかというと for文
, switch case文
, インクリメント演算子(++)
, バイナリ演算子(&&)
です. あと %
演算子もなかった気がする
結構足りないですね.本のコードを写経して動きを理解するだけでもインタプリタの言語処理系の実装を理解するには十分だとは思いますが,折角なので以上の要素を頑張って実装してみました.
これらの要素を説明していくのに二段階に分けます.1つ目はASTの生成である構文解析と実際に処理を行う評価の部分です
For文
構文解析
まず, ASTの定義コードを示します
// ForExpression // original expression // for(<statement>;<expression>;<statement>) { // <Block Statements> // } type ForExpression struct { Token token.Token // 'for token InitStatement Statement // FinishCondition Expression LoopStatement Statement Consequence *BlockStatement }
初期化部分がstatementになっているのは let i = 1
みたいなのを許容したいからです.
逆に終了条件のところは必ず式にするように制限しています. ここはお好みで変更できるので実装者によるでしょう.
ループ条件も初期化部分と同様です.
中括弧の中にはループ中に実行される文が配列で格納されます.
評価
func evalForExpression(node *ast.ForExpression, env *object.Environment) object.Object { forEnv := object.NewEnclosedEnvironment(env) initEvalResult := Eval(node.InitStatement, forEnv) var result object.Object if isError(initEvalResult) { return initEvalResult } for { isFinishObj := Eval(node.FinishCondition, forEnv) isFinish, ok := isFinishObj.(*object.Boolean) if !ok { return newError("finish condition is not boolean") } if !isFinish.Value { return result } result = evalBlockStatement(node.Consequence, forEnv) loopEvalResult := Eval(node.LoopStatement, forEnv) if isError(loopEvalResult) { return loopEvalResult } } }
env変数はスコープ外の変数などを保持しているマップです. 先程のastで取得した初期化条件,終了条件ループ条件を元に実際にforループを回します. for文が終わった際にはresult変数に最後のループで実行された文の結果が格納されて返されます.
SwitchCase文
本書ではIf文を実装していますが,else ifのような無限に続けていく書き方はサポートしていませんでした.そちらを拡張しても良かったのですが折角なので綺麗に見えるSwitchCaseを実装してみました.
構文解析
まずはASTの定義を見ます
type SwitchStatement struct { Token token.Token Expression Expression Case []*CaseStatement } type CaseStatement struct { Token token.Token Condition Expression Statements []Statement }
さて,Switch文はSwitchとCaseそれぞれに分けました. なぜこうしたかは若干おぼろげですが,確か一つの構造体に詰め込むと解析がすごい面倒くさいことになりそうだったからだと思います.すいません
で,解析処理は以下のようになります
func (p *Parser) parseSwitchStatement() ast.Statement { stmt := &ast.SwitchStatement{ Token: p.curToken, Case: []*ast.CaseStatement{}, } if !p.peekTokenIs(token.LBRACE) { exp := p.parseExpression(LOWEST) stmt.Expression = exp } else { stmt.Expression = nil } p.nextToken() for !p.peekTokenIs(token.RBRACE) { p.nextToken() switch { case p.curTokenIs(token.CASE): s := p.parseCaseStatement() caseStmt, ok := s.(*ast.CaseStatement) if !ok { return nil } stmt.Case = append(stmt.Case, caseStmt) } } p.nextToken() return stmt } func (p *Parser) parseCaseStatement() ast.Statement { stmt := &ast.CaseStatement{ Token: p.curToken, Statements: []ast.Statement{}, } p.nextToken() exp := p.parseExpression(LOWEST) stmt.Condition = exp if p.peekTokenIs(token.COLON) { p.nextToken() } for !p.peekTokenIs(token.BREAK) { p.nextToken() caseStmts := p.parseStatement() stmt.Statements = append(stmt.Statements, caseStmts) } return stmt }
Switch文は switch <expression> { [case statement] }
のようになっていて最初のexpressionに関してはあってもなくても良いです.
単純にGoっぽくしたくて入れました
case文はbreakなしだと終了の判定が若干面倒くさかったので甘えて入れました.それ以外は基本的にFor文やIf文と解析の手法は変わりません.
評価
func evalSwitchStatement(node *ast.SwitchStatement, env *object.Environment) object.Object { for _, c := range node.Case { conditionValue := Eval(c.Condition, env) condition, ok := conditionValue.(*object.Boolean) if !ok { return newError("case condtion value is not boolean. got=%T", conditionValue) } if condition.Value { var result object.Object for _, s := range c.Statements { result = Eval(s, env) } return result } } return nil }
こちらの評価処理は全然難しくなかったです. C言語よろしく,Case文の条件を上から判定してマッチしていたらCaseAST内部のBlockStatementを評価していくという感じです.
その他の演算子
++
演算子は上記のFizzBuzzのように ++<Integer Object>
とするようにしています. 前置にした理由は後置にすると実質中置演算になって演算子の優先順位を考慮しなきゃいけなくて面倒くさかったからです.ごめんなさい
&&
演算子の解析もAST生成は既存のコードに条件を足すだけなので難しくありません
評価に関しては若干追加したので載せておきます.
func evalInfixExpression(operator string, left, right object.Object) object.Object { switch { case left.Type() == object.INTEGER_OBJ && right.Type() == object.INTEGER_OBJ: return evalIntegerInfixExpression(operator, left, right) case left.Type() == object.STRING_OBJ && right.Type() == object.STRING_OBJ: return evalStringInfixExpression(operator, left, right) case operator == "==": return nativeBoolToBooleanObject(left == right) case operator == "!=": return nativeBoolToBooleanObject(left != right) case operator == "&&" && left.Type() == object.BOOLEAN_OBJ && right.Type() == object.BOOLEAN_OBJ: return evalBinaryOperand(operator, left, right) case left.Type() != right.Type(): return newError("type mismatch: %s %s %s", left.Type(), operator, right.Type()) default: return newError("unknown operator: %s %s %s", left.Type(), operator, right.Type()) } }
この処理は中置演算式の評価部分ですが,&&
の左辺右辺は絶対にBoolオブジェクトであると制限して評価します.
あとは簡単で,
func evalBinaryOperand(operator string, left, right object.Object) object.Object { leftVal := left.(*object.Boolean).Value rightVal := right.(*object.Boolean).Value switch operator { case "&&": return &object.Boolean{Value: leftVal && rightVal} default: return newError("unknown operator: %s %s %s", left.Type(), operator, right.Type()) } }
こんな感じで実際の値を評価します. ||
演算子とか足したければtokenを追加してcase文も追加すればOK
%
演算子も追加しましたが他の +-*/
と同様なので省きます.
実際に実行してみる
これらを実装して,先程のFizzBuzzを実行してみると次のようになります
いい感じに出力されていますね. 最後のnullはputsの評価値を表示しているものです.
まとめ
非常に参考になり,翻訳も丁寧で原著のテンポの良い文章がそのまま楽しめた気がします. また,実際のコードもインタフェースを十全に使っているおかげで自前の拡張もかなりスムーズに行えました. 言語処理系を実際に作るというのは結構ハードルが高そうに見えますが,コンパイラとかはともかく,インタプリタは実装する言語でそのまま実行するので入門としてはかなり良い教材なのではないでしょうか?
作っている最中に普段「どうしてこんな構文なんだろ?」と思うプログラミング言語の仕様も実は実装者の苦悩の果に辿り着いたものなのだという一種の共感すら湧くようになりました.
皆さんも良ければインタプリタを作って見てください
最後に書籍のリンクと今回自分が実装したコードへのリンクを張っておきます.
2018年振り返り & 2019年で頑張ること & 退職エントリー
振り返りと豊富とお世話になったインターン先の退職エントリーをひとまとめにしました。
2018年
1月 卒業論文で死にかけてた 2月 卒業論文で略 & インターン復帰した 3月 インターンでバリバリ開発してた 4月 同上 & 授業始まった & GoConにLTで参加した 5月 同上 6月 同上 7月 そろそろ授業に対してフラストレーションが溜まってきた 8月 インターン 9月 アドテクコンペとインターン 10月 内定決まった 11月 内定承諾した 12月 インターン退職した
研究の方は新しいテーマをずっと探していて調査しては探し直して調査しては探し直してを繰り返して未だに決まりきってはいません。 でも沢山論文は読んだし一個一個の読むスピードや単語の意味も大分わかってきたので無駄ではないと思っています。
インターンの方は1年9ヶ月もお世話になった3-shakeを退職しました。 3-shakeでは最初は機械学習のR&Dエンジニアとして入りましたがいつの間にかReckoner-CDPのバックエンドエンジニアとして集計基盤やサーバサイド、フロントエンドなんかもやったりしてました。おかげで1年9ヶ月でWeb開発の知識、スキルはかなり跳ね上がった気がします。
本当に3-shakeの皆さんにはお世話になりました
次どこいくの
CyberAgentさんのどこかのプロジェクトに内定者インターンとして関わる可能性が高いです。 つまり渋谷に通うことになるのですがあの人混みには未だに慣れません。豊洲の綺麗さと交換してほしい。
2019年はどうするの
とりあえずソフトウェアエンジニアとしてもっとスキルを磨きたいです。具体的にはCコンパイラの完成、アセンブラ、リンカの自作、AtCoderの継続参加でしょうか。 AtCoderは土日なかなか予定や他にやることが多くて時間を取れなかったので過去問解くだけでも毎週するようにしておきたいです。
あとは英語です。DMM英会話を申込もうと思ったのですがお金がないのでPodCastでスクリプト見ながら発音して練習するというのを続けてみようと思います。
そんなわけで2019年もよろしくお願いします。
Goのプログラムで動的アップデートについてのトリビア
この記事は
Go3 Advent Calendar 2018 - Qiita の21日目の記事にあたります。
アドベントカレンダーに参加するのは初めてのため、作法なので間違っているところがあったらすいません。
動的アップデート
動的アップデートという言葉をご存知でしょうか?ホットリロードだったりモンキーパッチだったり似たような意味で色々言い方があるかもしれませんがACMやIEEEの論文を眺めているとDSU(Dynamic Software Update)と書いてることが多いです。
何をするかというと プログラムを中断させずにプログラムのアップデートを行う ことです。
具体的にはプロセスのメモリ空間を外部から書き換えて挙動を書き換えます。linuxにはlivepatchというツールがありますが、これはプロセスを指定し、アドレスを指定するとそのアドレスの値を書き換えることができます。要は黒魔術の一種です。
今回はGoで書かれたプログラムをGoでパッチ(厳密には違います)を書いて動的にプログラムの挙動を書き換える ということができるのかというのを検証してみます。
一般的な動的アップデートの手法
よく知られるDSUの手法を図に示します。
よくあるのが関数呼び出しで本来とは別の関数を呼び出すものです。
livepatchでは関数を呼び出したあと、直後のメモリ上の命令をjmp命令に書き換えてアップデート対象の関数アドレスに飛びます。 今回はこれを行います。
今回やってみるアップデートのフロー
- 自身を動的に書き換えるgoのプログラムを書く(アップデート対象のプログラム: target)
- 新しい関数を定義しただけのプログラムを書いてコンパイルする(パッチ: patch)
- targetはpatchのバイナリからアップデート対象となる関数のバイナリを抽出し、自身で呼び出している関数を書き変える
前提条件
実装
以上の手順を行うコードを見ていきます。
target.go
package main import ( "fmt" "log" "github.com/Bo0km4n/lupus" ) func f1() int { return 1 } func main() { targetFunc := f1 if err := lupus.PatchFunction( "/home/vagrant/go/src/github.com/Bo0km4n/lupus/example/patch", "main.f2", targetFunc, ); err != nil { log.Fatal(err) } fmt.Println(targetFunc()) }
patch.go
package main func f2() int { return 2 } func main() { f2() }
targetのプログラムは普通なら1が表示されますが、patchを当てることにより2が表示されます。
f2のアセンブラはこのようになります
00000000004ce1c0 <main.f2>: 4ce1c0: 48 c7 44 24 08 01 00 movq $0x2,0x8(%rsp) 4ce1c7: 00 00 4ce1c9: c3 retq
このバイト配列をうまいこと抽出して書き換えてあげればokです。あとこのアセンブラは後ほど関わってきますので少しだけ覚えていおいてください。
lupus.PatchFunction
はgoのmonkeypatchライブラリを参考に関数の具体的な置き換えを行います。
PatchFunction
アップデートのロジックを順に追っていきます。
func PatchFunction(patchObjPath string, funcName string, target interface{}) error { elfFile, _ := open(patchObjPath) newFuncBytes := getFuncBytes(elfFile, funcName) newFuncBytes, err := writeFuncVal(newFuncBytes) if err != nil { return err } patchValue(reflect.ValueOf(target), newFuncBytes) return nil }
まず、引数でもらったバイナリファイルを開いてElfファイルのセクションヘッダーを取れるようパースします。
open
関数の中では goの debug/elf
パッケージを用いてファイルを開いています。
getFuncBytes
でセクションヘッダとシンボルテーブルからバイナリ内での対象の関数のアドレスを算出して関数の実体となるバイト配列を返しています。
writeFuncVal
の内部でバイト配列をtmpファイルに書き出してファイルポインタをmmapシステムコールに渡してヒープ領域(多分)に確保します。
ここでnewFuncBytesを再代入しているのはwriteFuncVal実行後に返すバイト配列は中身は一緒ですがポインタが新しく確保したメモリのアドレスに書き換わっているからです。
つまり、関数の実行後にこの配列のアドレスにjmp命令でふっ飛ばせばokなわけです。
次の patchValue
に新しい関数のバイト配列と対象となる関数を渡して実行するとメモリが書き換わります。
パッチのコア部分の実装
ではパッチでメモリを書き換える処理の実装について見てみます。
patchValue
func patchValue(target reflect.Value, replaceValue []byte) { if target.Kind() != reflect.Func { log.Fatal("Target has to be a Func") } replace(target.Pointer(), replaceValue) }
ここで行うのはreflectパッケージで渡された関数ポインタがGoの関数であるかをチェックしているだけです。
replace
func replace(orig uintptr, replacement []byte) { replacePtr := *(*uintptr)(unsafe.Pointer(&replacement)) jumpData := assembleJump(replacePtr) copyToLocation(orig, jumpData) }
一行ずつ追っていきます。
replacePtrは先程確保したバイト配列のメモリ領域の先頭アドレスになります。
jumpDataは
func assembleJump(to uintptr) []byte { return []byte{ 0x48, 0xBA, byte(to), byte(to >> 8), byte(to >> 16), byte(to >> 24), byte(to >> 32), byte(to >> 40), byte(to >> 48), byte(to >> 56), // movabs rdx,to 0xFF, 0xe2, // jmp rdx } }
このような処理を行い、
movabs rdx, to_address(バイト配列のアドレス) jmp rdx
になる命令のバイト配列を示しています(ここはアーキテクチャ依存)。
最後にcopyToLocationでメモリ上の対象関数の命令を書き換えます。
func copyToLocation(location uintptr, data []byte) { f := rawMemoryAccess(location, len(data)) page := rawMemoryAccess(pageStart(location), syscall.Getpagesize()) err := syscall.Mprotect(page, syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC) if err != nil { panic(err) } copy(f, data[:]) }
fはアップデート対象の関数が始まるアドレスから、パッチの長さ分のメモリ領域を取得します。 そして、対象のアドレスからpageサイズ分のアドレスを取得し、mprotectシステムコールを用いて4096バイト分(linuxのページサイズ)の権限を書き換えさせてもらいます。
これでメモリの書き換えと書き換え後の命令実行ができるようになりました。
最後にcopy関数で対象関数に入ったあと実行するはずだった命令の部分をjmp命令に書き換えて終わりです。
内部での関数呼び出し
今回のパッチは返す値を1から2にしただけです。 では次にこのような関数に処理を書き換えることにします。
func newFunc() int { fmt.Println("new func called") return 2 }
fmt.Printlnが追加されました。
これでパッチを当てようとしてみます。
すると。。。
unexpected fault address 0x7f0de5040fb0 fatal error: fault [signal SIGSEGV: segmentation violation code=0x1 addr=0x7f0de5040fb0 pc=0x7f0de5040fb0] goroutine 1 [running]: runtime.throw(0x4e4521, 0x5) /usr/lib/go-1.10/src/runtime/panic.go:616 +0x81 fp=0xc420041dc0 sp=0xc420041da0 pc=0x426ca1 runtime: unexpected return pc for runtime.sigpanic called from 0x7f0de5040fb0 stack: frame={sp:0xc420041dc0, fp:0xc420041e10} stack=[0xc420040000,0xc420042000) 000000c420041cc0: 000000000044a012 <runtime.writeErr+66> 0000000000000002 000000c420041cd0: 00000000004e4207 0000000000000001 000000c420041ce0: 0000000000000001 000000c420041d20 000000c420041cf0: 0000000000427758 <runtime.gwrite+280> 00000000004e4207 000000c420041d00: 0000000000000001 0000000000000001 000000c420041d10: 0000000000427758 <runtime.gwrite+280> 00000000004e4521 000000c420041d20: 000000c420041d70 0000000000427f0d <runtime.printstring+125> 000000c420041d30: 00000000004e4207 0000000000000001 000000c420041d40: 0000000000000001 00000000004e4207 000000c420041d50: 0000000000000001 00000000004e4207 000000c420041d60: 0000000000426bca <runtime.dopanic+74> 000000c420041d70 000000c420041d70: 000000000044baa0 <runtime.dopanic.func1+0> 000000c420000180 000000c420041d80: 0000000000426ca1 <runtime.throw+129> 000000c420041da0 000000c420041d90: 000000c420041db0 0000000000426ca1 <runtime.throw+129> 000000c420041da0: 0000000000000000 0000000000000005 000000c420041db0: 000000c420041e00 00000000004392f1 <runtime.sigpanic+529> 000000c420041dc0: <00000000004e4521 0000000000000005 000000c420041dd0: 000000c420041e10 00000000004a864c <github.com/Bo0km4n/lupus.patchValue+124> 000000c420041de0: 00007f0de5040fb0 000000c420000180 000000c420041df0: 0000000000001000 0000000000001000 000000c420041e00: 000000c420041e78 !00007f0de5040fb0 000000c420041e10: >00007f0de5061091 000000c420041e50 000000c420041e20: 0000000000000001 0000000000000001 000000c420041e30: 0000000000000013 00007f0de5061000 000000c420041e40: 0000000000001000 000000c420041e50 000000c420041e50: 00007f0de5074a20 00007f0de50b0440 000000c420041e60: 000000c420041e50 0000000000000001 000000c420041e70: 0000000000000001 000000c420041f78 000000c420041e80: 00000000004a9054 <main.main+356> 0000000000000000 000000c420041e90: 0000000000000047 00000000004e59cf 000000c420041ea0: 000000000000000c 00000000004bc0e0 000000c420041eb0: 00000000004ec218 0000000000000000 000000c420041ec0: 0000000000000000 000000c420041ef8 000000c420041ed0: 00000000004ec218 0000000000000010 000000c420041ee0: 00000000004c9040 00000000004ec218 000000c420041ef0: 000000c42000e320 0000000000000000 000000c420041f00: 0000000000000000 00000000004c9040 runtime.sigpanic() /usr/lib/go-1.10/src/runtime/signal_unix.go:395 +0x211 fp=0xc420041e10 sp=0xc420041dc0 pc=0x4392f1
見事にsegvりました。
結論から言うと今回の手法では 新しい関数内で関数を呼び出していた場合は無理 ということです。正確には無理ではないかもしれません
まず、アップデート後に呼び出すnewFuncのアセンブラを見てみます。
00000000004a9140 <main.newFunc>: 4a9140: 64 48 8b 0c 25 f8 ff mov %fs:0xfffffffffffffff8,%rcx 4a9147: ff ff 4a9149: 48 3b 61 10 cmp 0x10(%rcx),%rsp 4a914d: 0f 86 91 00 00 00 jbe 4a91e4 <main.newFunc+0xa4> 4a9153: 48 83 ec 68 sub $0x68,%rsp 4a9157: 48 89 6c 24 60 mov %rbp,0x60(%rsp) 4a915c: 48 8d 6c 24 60 lea 0x60(%rsp),%rbp 4a9161: 48 c7 44 24 70 00 00 movq $0x0,0x70(%rsp) 4a9168: 00 00 4a916a: 0f 57 c0 xorps %xmm0,%xmm0 4a916d: 0f 11 44 24 38 movups %xmm0,0x38(%rsp) 4a9172: 48 8d 44 24 38 lea 0x38(%rsp),%rax 4a9177: 48 89 44 24 30 mov %rax,0x30(%rsp) 4a917c: 84 00 test %al,(%rax) 4a917e: 48 8d 05 db 39 01 00 lea 0x139db(%rip),%rax # 4bcb60 <type.*+0x12b60> 4a9185: 48 89 44 24 38 mov %rax,0x38(%rsp) 4a918a: 48 8d 05 ef f3 04 00 lea 0x4f3ef(%rip),%rax # 4f8580 <main.statictmp_1> 4a9191: 48 89 44 24 40 mov %rax,0x40(%rsp) 4a9196: 48 8b 44 24 30 mov 0x30(%rsp),%rax 4a919b: 84 00 test %al,(%rax) 4a919d: eb 00 jmp 4a919f <main.newFunc+0x5f> 4a919f: 48 89 44 24 48 mov %rax,0x48(%rsp) 4a91a4: 48 c7 44 24 50 01 00 movq $0x1,0x50(%rsp) 4a91ab: 00 00 4a91ad: 48 c7 44 24 58 01 00 movq $0x1,0x58(%rsp) 4a91b4: 00 00 4a91b6: 48 89 04 24 mov %rax,(%rsp) 4a91ba: 48 c7 44 24 08 01 00 movq $0x1,0x8(%rsp) 4a91c1: 00 00 4a91c3: 48 c7 44 24 10 01 00 movq $0x1,0x10(%rsp) 4a91ca: 00 00 4a91cc: e8 1f ff fd ff callq 4890f0 <fmt.Println> 4a91d1: 48 c7 44 24 70 02 00 movq $0x2,0x70(%rsp) 4a91d8: 00 00 4a91da: 48 8b 6c 24 60 mov 0x60(%rsp),%rbp 4a91df: 48 83 c4 68 add $0x68,%rsp 4a91e3: c3 retq 4a91e4: e8 27 3d fa ff callq 44cf10 <runtime.morestack_noctxt> 4a91e9: e9 52 ff ff ff jmpq 4a9140 <main.newFunc> 4a91ee: cc int3 4a91ef: cc int3
先程見せたf1のアセンブラとはめちゃくちゃ違います。 ただfmt.Printlnを追加しただけなのに。
完全な憶測ですが、goのコンパイラ側がスタックの調整やGC関連の命令を埋め込んでいるのではないかと思います。
ただ埋め込むだけなのは良いですが、問題は jbe 4a91e4 <main.newFunc+0xa4>
こんな感じの命令が散見されることです。
これらの命令は相対アドレスを参照しています。ここまで書けば あっ...(察し)ってなるでしょう。
当然先ほどの手法では上記のアセンブラをそのままヒープ領域に確保して書き込み実行します。
つまり jbe 4a91e4 <main.newFunc+0xa4>
のようなアドレスをそのまま踏んでセグフォってしまうのです。
流石にこの辻褄あわせを行う気力もどうやれば上手くやれるかも思いつきませんでした。
まとめ
返す値をちょろっと変えるだけならパッチを当てることができましたが、関数呼び出しを加えるのは難しいことがわかりました。
今回の記事は研究の一貫で調べていたものが検証していく中でかなり難しいことだとわかり、一旦保留になったので供養の意味も込めて書かせていただきました。 動的アップデートはそこまでホットな研究テーマではありませんが低レイヤなシステムプログラミングやソフトウェア工学等の観点から議論されている面白いテーマでもあります。 よかったら調べてみてください。
今回の実験したコードは下記のリポジトリにあります。 そのうちREADMEに実行のしかたとか書き加えると思います。 github.com
参考文献
Rustのモジュールシステムが厳格
goは1年半ぐらい結構書いてきて、シンタックスもそこまで複雑ではないので大分慣れてきた気がした。 なので最近はrustをちょくちょく書いてる。
今はgoで書いた8086のディスアセンブラをrustで少しずつ書き直そうと思ってる
クローラとかは若干飽きたため。monkeyインタプリタでもよかったけど、これは最近書いたばっかでしっかり記憶に残っちゃっているのでほぼ忘れたディスアセンブラにした。
で、rustはgoのパッケージシステムと似たようなモジュールシステムがある。というか書き方を見た感じC++とほぼ一緒なのだろうか。
rustのモジュールの切り方はgoのパッケージのように気楽に切ることはできない。なぜならimportに明確に親子関係が定められているから。
goの場合例えば下記のようなパッケージ構成のとき、
. ├── ast │ ├── ast.go │ └── ast_test.go ├── evaluator │ ├── builtins.go │ ├── evaluator.go │ └── evaluator_test.go ├── lexer │ ├── lexer.go │ └── lexer_test.go ├── main.go ├── object │ ├── environment.go │ └── object.go ├── parser │ ├── parser.go │ ├── parser_test.go │ └── parser_tracing.go ├── repl │ └── repl.go └── token └── token.go
当然だが、replパッケージからparser, astパッケージをimportできる。
しかし、rustのモジュールシステムの場合はそれができない。
例えばrustで以下のようにモジュールを切ってみる。
. ├── disassembler │ ├── mod.rs │ └── reader.rs ├── hoge │ └── mod.rs └── main.rs
disassemblerのreaderからhogeを参照しようとしてみる
コードは次のようになる
hoge/mod.rs
pub fn fuga() { println!("fuga"); }
disassembler/reader.rs
use std::vec::Vec; mod hoge; pub fn start(input: Vec<u8>) { hoge::fuga(); let mut da: DisAssembler8086 = DisAssembler::new(input); println!("{:?}", da.body); da.dump_cur_token(); da.next(); da.dump_cur_token(); while !da.is_end() { da.next(); } da.dump_cur_token(); } ・・・ 以下略
これをビルドしようとすると次のエラーが出る
error[E0583]: file not found for module `hoge` --> src/disassembler/reader.rs:2:5 | 2 | mod hoge; | ^^^^ | = help: name the file either reader/hoge.rs or reader/hoge/mod.rs inside the directory "src/disassembler" error: aborting due to previous error For more information about this error, try `rustc --explain E0583`.
エラーの通り mod hoge
とreader内で書くと src/disassembler/reader/hoge/mod.rs
か src/disassembler/reader/hoge.rs
を探そうとする。
つまり親ディレクトリを経由したmodの参照はできないということ(もし参照できる方法あったらすいません)。
これは同一階層でのディレクトリ同士では完全に実装が依存しないようになるので結構良い気がする。
まとめ
- rustでモジュールを切るときは依存関係をちゃんと考えて切るようにしよう
- 同一階層内で使いたい場合は同じディレクトリにファイルを分けるようにする
まだ全然知見がないけどgoとは全然違う厳しさがあるので面白い
Golangにおけるプロジェクトのレイアウトについて
僕がGolangを好きな理由はいくつかあるが、主なものは
- コードが皆フォーマッターによって大体綺麗になる
- 言語機能が充実しすぎていないので処理が追いやすい(コーディングする時不便に感じることもあるが、チームでの開発においては読まれる方を重視したほうが良い)
- サードパーティのライブラリの導入が非常に簡単(もちろんプロジェクトに導入するときは慎重に行うべき)
そんなGoだけど、プロジェクトを作る時にどうパッケージを切っていくか。これが以外と暗黙の了解みたいな感じになっている気がする。
APIであればMVCライクに切るか、最近だとクリーンアーキテクチャが自分の流行だ。
CLIツールやライブラリだとプロジェクトのルート直下にフラットにファイルが展開されているものもある。
ということで少し気になったのでGoでプロジェクトを作る時にデファクトとなるようなパッケージレイアウトはあるのか?もし、あるとしたらどういう風に切るのかを少し調べてみた。
まず golang project layout
みたいな感じでググるとこのリポジトリが先頭にあがった。
GitHub - golang-standards/project-layout: Standard Go Project Layout
スターは4000近くもある。それなりに認知度が高いらしい。
READMEにはこのようにかかれている
これはGoアプリケーションプロジェクトの基本レイアウトです。 Goデベロッパーチームが定義した公式の標準ではありません。 ただ、このリポジトリの構成はGoエコシステムにおける共通の歴史的かつ新しいプロジェクトレイアウトパターンのセットです。 この切り方は、他のパターンよりも人気があります。 この構成ではいくつかの小さな拡張機能と、十分な大きさのプロダクションレベルでのアプリケーションに共通するいくつかのサポートディレクトリを備えています。
とのこと。確かにこの構成はよく見るような気がする。特にinternalパッケージとpkgパッケージは他のライブラリやツールでも頻繁に登場する。
READMEにはそれぞれこのように注釈されている
internal
内部でのみ使われるアプリケーションとライブラリコード。 これは、他の人のアプリケーションやライブラリでインポートさせたくないコードです。
/ internal / pkgディレクトリ(例:/ internal / pkg / myprivlib)に、実際のアプリケーションコードを書いて、/ internal / appディレクトリ(/ internal / app / myapp)で更に共有される。
共有の部分がいまいちピンと来ないけど、コアのコードやヘルパー的なコードはこの中で書こうということなのだろう。
pkg
外部アプリケーション(たとえば、/ pkg / mypubliclib)で使用できるライブラリコード。 他のプロジェクトは、pkg内のコードが動作することを期待してパッケージwpインポートするので、ここに何かを入れる前にしっかり考えてください:-)
また、ルートディレクトリに多くの非Goコンポーネントとディレクトリが含まれていて、GopherCon EU 2018のインダストリアルプログラミングのベストプラクティスに記載されているように、さまざまなGoツールを簡単に実行できるように、Goコードを1か所にグループ化する方法です。
このプロジェクトレイアウトパターンを使用している人気のあるGoリポジトリを確認するには、/ pkgディレクトリを参照してください。 これは一般的なレイアウトパターンですが、普遍的に受け入れられているわけではなく、Goコミュニティの一部では推奨していません。
ふむふむ。基本的に外部で利用する時はこのpkgディレクトリが参照されることが多いということか。 試しにこの構成に心当たりのあるプロジェクトをいくつか挙げてみようと思う。
僕もよくお世話になっているkubernetesは今回のstandard repositoryに近い構成だ。internalパッケージはないが pkg
, cmd
, api
等他のツールでもよく見るような構成になっている。
次は、、、といきたいとこだったんだけど、以外にもgo-awesome経由でリポジトリを漁っているとpkgディレクトリはあるけどinternalはない。みたいなケースが多くて探すのが疲れた。
go-awesomeに入っているツールやライブラリの構成を眺めていて思ったのはフラットな構成がかなり多いってことだ。勿論小さなプロジェクトであればそれでいいしわかりやすいから全然いいと思う。
他の大きなプロジェクトも名称等は違えど、役割として共通しているのだろうなっていうディレクトリが多かった気がする。
ちなみに英語であれば今回のプロジェクトのレイアウトについて論じているブログ等はある
どっちも挙げている構成は最初に挙げたリポジトリと共通点が多い。ただ、Golangの公式からガイドライン等が発表されていないのであくまで全て非公式なものではある。 僕個人としては他のプロジェクトのコードを少し追いたい時などはこういう風に共通点があると非常に楽なので助かる。
まとめ
- プロジェクトのレイアウトの切り方は大きなプロジェクトにおいては結構共通点が多い
- でも明確にレイアウトのガイドラインが決定されているわけではないので好きにやればいい
Goのプロジェクト内でgRPCで自分で定義したprotoファイルをimportする
自分で作ったprotoファイルを他のprotoファイルから参照する時にハマったのでメモ
サンプルプロジェクトの構成
. ├── Makefile ├── team │ └── handler │ └── grpc │ └── team │ ├── team.pb.go │ └── team.proto └── user └── handler └── grpc └── user ├── user.pb.go └── user.proto 8 directories, 5 files
構成はクリーンアーキテクチャライクにしています
proto ファイル
userから
syntax = "proto3"; package user; service UserAPI { rpc GetUsers(User) returns (Users) {} rpc Show(User) returns (User) {} rpc Delete(User) returns (Empty) {} rpc Invite(User) returns (Empty) {} rpc Activate(ActivateRequest) returns (ActivateResponse) {} } message User { int64 id = 1; int64 team_id = 2; string name = 3; string email = 4; string password = 5; bool isOwner = 8; } message ActivateRequest { string code = 1; } message ActivateResponse { string token = 1; } message Users { repeated User Users = 1; } message Empty {}
普通のprotoファイルです
次はteam
syntax = "proto3"; package team; import "github.com/Bo0km4n/go-protobuf-ref-sample/user/handler/grpc/user/user.proto"; service TeamAPI { } message Team { int64 id = 1; string name = 2; user.Users users = 3; } message InviteRequest { string team = 1; string email = 2; string password = 3; } message ActivateRequest { string code = 1; } message ActivateResponse { string token = 1; } message Empty {}
userを内部で参照しています。
コンパイル
最初は以下のページを参考にコンパイルを行っていましたが生成されるpbファイルのimportパスが相対パスになっており go build
時に参照できません
そこで上記のように github.com/...
のように通常のgoのimportパスと同様にすることでこの問題を解決します
ビルドに使用するmakefileをつぎのように書きました
PROTO_TARGETS = user team PROJECT_PATH = github.com/Bo0km4n/go-protobuf-ref-sample proto: @echo $(PROTO_TARGETS); \ cd $$GOPATH/src; \ for t in $(PROTO_TARGETS); do \ echo "protobuf build ... $$t"; \ protoc --go_out=plugins=grpc:. $(PROJECT_PATH)/$$t/handler/grpc/$$t/$$t.proto; \ done
最初にechoしているのはしないと cd
のせいなのか Nothing to done
って言われるからです
上記のリンクではプロジェクトのルート以下のパスが生成されるimportパスになっていました。
つまり、$GOPATH/src
で上記のビルドを行えば生成されるimportパスが正常になります。
実際に生成されたteamのpbファイルは次のようになります
// Code generated by protoc-gen-go. DO NOT EDIT. // source: github.com/Bo0km4n/go-protobuf-ref-sample/team/handler/grpc/team/team.proto package team import proto "github.com/golang/protobuf/proto" import fmt "fmt" import math "math" import user "github.com/Bo0km4n/go-protobuf-ref-sample/user/handler/grpc/user" import ( context "golang.org/x/net/context" grpc "google.golang.org/grpc" ) // Reference imports to suppress errors if they are not otherwise used. var _ = proto.Marshal var _ = fmt.Errorf var _ = math.Inf // This is a compile-time assertion to ensure that this generated file // is compatible with the proto package it is being compiled against. // A compilation error at this line likely means your copy of the // proto package needs to be updated. const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package type Team struct { Id int64 `protobuf:"varint,1,opt,name=id" json:"id,omitempty"` Name string `protobuf:"bytes,2,opt,name=name" json:"name,omitempty"` Users *user.Users `protobuf:"bytes,3,opt,name=users" json:"users,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` } ... 以下省略
これでプロジェクトのビルドも上手くいきます
$GOPATH/src に移動するのは少々スマートではない気がしますが色々調べて他に良いやり方がなかったので今回は妥協しました
一応今回のサンプルをgithubにあげておきます
Ubuntu14.04上でglibcをbuildしてリンクする
glibc内のソースをいじりたかったのでその前準備にglibcソースからビルドしてコードにリンクするまでの手順をメモ
環境
vagrant@vagrant-ubuntu-trusty-64:~/libc$ lsb_release -a No LSB modules are available. Distributor ID: Ubuntu Description: Ubuntu 14.04.5 LTS Release: 14.04 Codename: trusty
vagrant上で動かしてます
glibcの取得
$ sudo apt-get update $ mkdir -p ~/libc/build # libcのビルド用ディレクトリを作成します。build用ディレクトリを分けないとconfigure時に怒られるため $ cd ~/libc $ sudo apt-get install -y dpkg-dev $ sudo apt-get source libc6
終わったときの~/libc
の中はこんな感じのはず
vagrant@vagrant-ubuntu-trusty-64:~/libc$ ll total 13360 drwxrwxr-x 4 vagrant vagrant 4096 Oct 17 09:21 ./ drwxr-xr-x 6 vagrant vagrant 4096 Oct 17 09:54 ../ drwxrwxr-x 60 vagrant vagrant 4096 Oct 17 09:42 build/ drwxr-xr-x 73 root root 4096 Oct 17 09:14 eglibc-2.19/ -rw-r--r-- 1 root root 1043124 Jan 17 2018 eglibc_2.19-0ubuntu6.14.debian.tar.xz -rw-r--r-- 1 root root 7195 Jan 17 2018 eglibc_2.19-0ubuntu6.14.dsc -rw-r--r-- 1 root root 12610800 Feb 21 2014 eglibc_2.19.orig.tar.xz
configureとmake
最終的に吐き出すディレクトリは /usr/eglibc-2.19
にします。これは好きなディレクトリで大丈夫です
$ sudo mkdir -p /usr/eglibc-2.19 $ ~/libc/build $ ../eglibc-2.19/configure --prefix=/usr/eglibc-2.19 --enable-add-ons=nptl --enable-all-warnings $ sudo make # かなり時間がかかります $ sudo make install
以上で /usr/eglibc-2.19
以下にビルドされたファイル群が存在すれば大丈夫です
テスト用のコードとビルド
試しに以下のコードを書いてコンパイルしてみます
#include <stdio.h> int main() { printf("hello\n"); exit(0); }
Makefileはこんな感じ
CC=gcc CFLAGS = -nostdinc -I/usr/eglibc-2.19/include -I/usr/lib/gcc/x86_64-linux-gnu/4.8/include LDFLAGS = -Wl,-rpath=/usr/eglibc-2.19/lib TARGET = test.exe OBJECTS = test.o all: $(TARGET) $(TARGET):$(OBJECTS) $(CC) $(LDFLAGS) $(OBJECTS) -o $(TARGET) .c.o: $(CC) $(CFLAGS) -c -o $@ $< clean: rm -f *.o $(TARGET)
CFLAGSとLDFLAGSに指定しているパスは各自がconfigure時に設定したprefixパスに合わせてください
makeして ldd
コマンドで指定したglibcにリンクできているか確認します
vagrant@vagrant-ubuntu-trusty-64:~$ ldd test.exe linux-vdso.so.1 => (0x00007ffe58f8d000) libc.so.6 => /usr/eglibc-2.19/lib/libc.so.6 (0x00007f34057a3000) /lib64/ld-linux-x86-64.so.2 (0x00007f3405b50000)
libc.so.6の部分が上のように指定したlibcになっていればokです