2018-12-14

デバッガのための実行基盤の実装について

この記事は言語実装 Advent Calendar 2018 - Qiitaの14日目の記事です。

Salesforce上で動くプログラミング言語Apexをローカル環境で動かすLANDという実行基盤を作っています。

LANDを作った契機などはこちらの記事に書いてあります。ちなみにANTLR + Golang製です(最初はRacc/RexというRubyで書けるyacc/lexなツールで書いていたのですがJavaの文法がパースしづらく、ANTLRに切り替えたりと紆余曲折ありましたw)

言語実装のアドベントカレンダーとしては少し趣向が変わってきてしまうかもしれませんが、今回はLANDのデバッガの仕組みを紹介しようと思います。

デバッガ作りのために実行基盤が必要な機能

Rubyのデバッガとしてbyebugというgemが有名で、これはRubyのAPIとして提供されているTracePointという仕組みを使って実装されています。 TracePointはRubyの各種アクションをフックとして任意のコードを実行できる仕組みです。例えばステートメントの実行やメソッド・クラス定義の開始・終了、メソッド実行・終了などを契機として任意のコードを実行できます。 byebugではステートメントの実行やメソッド実行・終了などをフックして特定のタイミングでプロンプトを表示することでブレークポイントやステップ実行を実現しています。

PHPではzend_execute_exという関数ポインタをオーバーライドすることで、PHPの実行基盤であるZend EngineがPHPコードを実行するときの処理をカスタマイズできるため、デバッガはこれを利用しているみたいです。Xdebugはzend_execute_exのオーバーライドをした上で、PHPからdbgpプロトコルで任意のリモートサーバに接続することで、リモートサーバ上でデバッグができるようになっています。

このようにデバッガの仕様は完全に言語に依存しているのですが、LANDではRubyのTracePointのように各アクション実行時にイベントをPublishし、SubscriberがイベントをSubscribeする方法でデバッガを実装しています。

具体的には以下のような実装になっています(LANDはバイトコードを生成しないTree WalkなInterpreterでVisitorパターンで実装されています)

func (v *Interpreter) VisitBlock(n *ast.Block) (interface{}, error) {
	for _, stmt := range n.Statements {
		Publish("line", v.Context, stmt)
		res, err := stmt.Accept(v)
# ...
	}
	return nil, nil
}

Blockノードは複数のStatementノードを持っており、これらを順に実行しているのですが、Acceptで実行する際にlineイベントを発火しています。

Pub/Sub部分は以下のように定義しています。

type Subscriber func(ctx *Context, n ast.Node)

var subscribers = map[string][]Subscriber{}

func Publish(event string, ctx *Context, n ast.Node) {
	if subs, ok := subscribers[event]; ok {
		for _, s := range subs {
			s(ctx, n)
		}
	}
}

func Subscribe(event string, subscriber Subscriber) {
	if v, ok := subscribers[event]; ok {
		subscribers[event] = append(v, subscriber)
	} else {
		subscribers[event] = []Subscriber{subscriber}
	}
}

Subscribe()でイベントに対してSubscriberを定義できるようになっていて、 Publish()でイベント名に紐づくSubscriberを順に実行することになります。

デバッガの実装について

起動時にREPLを起動しています。
l, _ := readline.NewEx(&readline.Config{
	Prompt:          "\033[31m>>\033[0m ",
	HistoryFile:     "/tmp/land_debugger.tmp",
	InterruptPrompt: "^C",
	EOFPrompt:       "exit",
})
for {
	line, err := l.Readline()
	if err != nil {
		panic(err)
	}
	inputs := strings.Split(line, " ")
	// 各コマンドの処理

プロンプトを表示してユーザ入力を待ち、ユーザ入力を使ってコマンドを実行する、の繰り返しです。

ステップ実行はデバッガ本体に持たせているカウンタを利用しています。

type debugger struct {
	Enabled bool
	StepOut bool
	Step    int
	Frame   int
}

例えば next(ステップオーバー)のコマンドを実行すると内部の Stepカウンタが1にセットされ、REPLが終了してプログラムの実行が再開されます。ここで次のステートメントが実行された際に、lineイベントがPublishされ、Stepカウンタがデクリメントされます。Stepが0になればREPLが再度起動されることになるので、nextコマンドの「現在の行を処理して次の行に異動する」という処理を実現していることになります。

Subscribe("line", func(ctx *Context, n ast.Node) {
	if Debugger.Enabled {
		if Debugger.Step > 0 {
			if Debugger.StepOut && Debugger.Frame != 0 {
				return
			}
			Debugger.Step--
		}
		if Debugger.Step == 0 {
			Debugger.Debug(ctx, n)
		}
	}
})

StepOutやFrameの記述はメソッド呼び出しのステートメント実行をスルーするために記述しています。こちらはメソッド呼び出しで method_startイベントが発火されて Frameをインクリメントされ、 メソッドから抜けるときに method_endイベントが発火されて Frameがデクリメントされる=元のフレームに戻ります。

デバッガの起動について

デバッガの起動方法ですが、デバッグ用の関数を置いてそこからデバッガーのクライアントからコマンドを送ってステップ実行をしたりevalする方法が一番よく使われている手法だと思います。Rubyのbyebugだとこんな感じです。
#!/usr/bin/env ruby

require 'byebug'

puts 'hogehoge'
i = 3
byebug
i = 5
puts 'fugafuga'

LANDも起動をデバッグ用の関数の実行にしたかったのですが、以下のようにアノテーション(コメント)でデバッガを起動する方式にしました。

public class Foo {
    public static String action() {
        System.debug(1);
        // #debugger
        System.debug(2);
    }
}

これは、通常のメソッド呼び出しの形式で書いてしまうと、Salesforceの環境にデプロイする際にメソッドを何かしら定義する必要があり、取扱いが面倒になるためです。また、コメントであればこのままデプロイしても正常に動くため積極的にデバッガを利用することができます。これにより消し忘れでテストが通らなくなったりするのを防止してます。

またLANDはデバッガ以外にもクラウド上(Salesforce)では出来ないローカルのみの特殊機能を実装していく予定で、コメントはマクロ的なマジックを行うための簡易的な仕組みとしては良いと考えました。

上記の // #debuggerは実行時に Debugger.debug()というクラスメソッド呼び出しに置き換えられて実行されます。このクラスメソッドを呼び出したタイミングでデバッガを起動することになります。ここらへんは雑に正規表現で置換してます。

 

ということで、デバッガのための言語実装について紹介させていただきました!

来年はApexの文法を完全にフォローできるようにLANDを完成させたいですねー(言語実装はトーシロなのでまともに書けているか不安ですが…w

このエントリーをはてなブックマークに追加