バイナリエディタを作りました。
インストールはHomebrew
brew install itchyny/tap/bed
または以下のコマンドでできます。
go get github.com/itchyny/bed/cmd/bed
なぜ作ったのか
私は昔からファイルフォーマットに興味があり、画像ファイルやPDFファイルのフォーマットを調べるのが好きでした。
最近も圧縮ファイルのフォーマットを趣味で調べたりしています。
コンパイラ技術にも興味があり、ゆくゆくは実行ファイルを生成したりしたいなという思いもあります。
バイナリファイルをエディットするにはバイナリエディタが必要となるわけですが、自分の手に馴染むUIを持つエディタがありませんでした。
私は実はVimというエディタが好きなので、Vimのようなインターフェースを持ち、ターミナルの中で動くエディタを探したのですが、なかなかありません。
bviはかなりイメージに近く、かなり参考にさせていただきましたが、画面分割がないことやキーマッピングの挙動など細かいことが気になりました。
歴史あるソフトウェアなので敬意は抱いていますが、それは同時に自分で書き直したくなるのに十分な理由でした。
いつかはテキストエディタを自分で作ってみたいという思いがありますが、自分のワークフローが依存しているものが大きくて、なかなか実装し始めるまでのハードルが高いと感じています。
特にシンタックスハイライトなどの既存の資産をどう取り込むか、あるいは自前で実装するかといったことは悩ましい課題です。
一方で、バイナリエディタを作るのはとても簡単です。
テキストエディタよりも難しい点といえば、ファイル全体を読み込むのはよくなくて、例えば16GiBくらいの大きなファイルも読み書きできる必要はありますが、逆にここさえクリアしてしまえば、後は地道にUIを作るだけとなります。
少なくとも、ファイル構造を解析したり逆アセンブラ機能を作るまではそんなに難しくありません (まだこれは実装していないのでどれくらい難しいかはわかりません)。
最後に、自分の力を試したいという思いがありました。
UIを持つcliツールを作るのはよい勉強になります。
実際、いかにコアロジックとUI部分を疎結合に実装するか、端末への描画部分の実装、分割ウィンドウの管理やundo/redoの実装など、様々なことを学ぶことができました。
実装や学んだことと考えたこと
今回バイナリエディタを実装するにあたって学んだことや考えたことを書いておきます。
バッファの表現
バイナリエディタは、メモリーに乗らないような大きなファイルを高速に読み込んで表示する必要があります。
ファイルの内容は、部分的に読みながら表示しなくてはいけません。
画面はユーザーが上下にスクロールしたりいきなり最後に飛んだりします。
こういう時には、ファイルポインタを特定の位置に移動する lseek
がとても重要です。
現在の表示領域からファイルの絶対位置を計算して lseek
し、必要な分だけ read
して表示すれば終わりです。
エディタとしては、任意の場所に書き込めなくてはなりません。
一部を消して保存したいこともあるでしょう。
これらの操作をできるように、 bed の実装ではどの範囲はファイルで、どの範囲は入力されたバイト列かを保持するようにしています。
コードで示すと次のような感じです。
type Buffer struct {
rrs []readerRange
index int64
}
type readerRange struct {
r io.ReadSeeker
min int64
max int64
diff int64
}
バイト列をファイルポインタと同じように Read
と Seek
できるようにしておき、ファイルから読む範囲とバイト列から読む範囲を同じように扱えるようにしておきます。
diff
によって、エディタから見えるオフセットとファイルのオフセットの差を保持しておきます。
範囲の境界がわかればよいので max
だけあれば十分ですが、両方持たせたほうが様々なコードが書きやすいので上記のように実装しています。
例えば1バイト挿入する場合、 readerRange
を前後に分割して後ろの diff
を一つ減らし、間にバイト列の readerRange
を挿し込むのです。
削除する時もやはり前後に分割し、後ろの diff
を増やしたり、範囲の境界を表す min
や max
を減らしたりすれば実装できます。
ファイルポインタや入力されたバイト列を完全に管理するのは少し大変です。
しかし、これらをきちんと実装しさえすれば、編集した部分だけ他の色をつけて表示したり、別々の巨大なファイルを結合したり、大きなファイルを半分に割って前後を入れ替えるのも容易に実装できます。
また、undo/redoも Buffer
ごと保存していけば難しくありません。
なお bed ではスレッドセーフのために、 pread 相当 (Go言語のReadAt interface) を用いており、上記の io.ReadSeeker の部分は io.ReaderAt と io.Seeker をあわせた独自の interface によって実装しています。
なので、上記で lseek
が大事だよと書きましたが、実はファイルの内容を読み込むという用途では使われていません *1。
Seek
して Read
するという操作はスレッドセーフではないため、ファイル先頭からの絶対位置を指定して (whenceがio.SeekStart) 読み込みたい場合は ReadAt
を用いるほうがよいと思います。
いい本です
レイアウト分割
bed は Vim と同じようにウィンドウを分割することができます。
これは二分木のような構造体で実装されています。
type Horizontal struct {
Top Layout
Bottom Layout
}
type Vertical struct {
Left Layout
Right Layout
}
type Window struct {
Index int
Active bool
}
木構造の葉が Window
となります。
分割を行う場合は、 Active
な Window
を Horizontal
または Vertical
で置き換えます。
これは、再帰的なアルゴリズムで実装できます。
bed の実装では、上記に加えてウィンドウの絶対位置やサイズも保持しており、描画時に利用しています。
なお、この二分木によるレイアウト分割は四畳半分割できないことが知られていますが *2、実用上は問題ないと判断しました。
パッケージ間の依存関係
エディタのコアロジックは、表示ロジックに依存させたくないし、直接触れないようにしたい。
この思いは、エディタの構想を始めた当初からありました。
実装はまだしていませんが、webインターフェースも実装し、ウェブブラウザでバイナリファイルを操作できても面白そうだと考えています。
bedコマンドの実装では、表示部分を簡単に差し替えられるようにしています *3。
依存関係逆転の原則、抽象に依存させよという考え方は、とても重要だと思います。
レイヤーごとの実装して木構造のように依存させていくのは、一見「きれいな依存関係」に見えますが、モノリシックで下の実装を差し替えにくく、テストもしにくく、またどちらのレイヤーに書くべきかの判断を誤りがちです。
このことに気がつくのに何年もかかってしまいました。
bed の Editor
というメインの構造体は、ウィンドウを管理する Manager
、コマンドラインインターフェースや補完を管理する Cmdline
そして表示とキー入力のための UI
に依存していますが、これらは全て interface
にしています。
パッケージ間の依存関係を絶ち、コマンドの実装部分で注入しました *4。
これにより、実装の大きなパッケージがお互いに依存することなく、独立して実装を進めることができました。
コマンドラインの実装は、補完やコマンドのparseなど複雑になりがちで、うっかりメインロジックと結合していたら大変なことになっていたと思います。
パッケージ間の依存関係を描画すると次のようになります *5。
editorパッケージはwindow, cmdline, tuiパッケージに対して依存関係がありません。
bufferというエディタを支えるバッファを実装したパッケージも、コアロジックであるeditorから依存がない遠いところにあることがわかります。
SOLID Go Design | Dave Cheney というブログには大事なことが書かれていますので読みましょう。
All things being equal the import graph of a well designed Go program should be a wide, and relatively flat, rather than tall and narrow.
この言葉はとても共感できます。
端末インターフェース
今回、tcellというパッケージを使ってみました。
termbox-goよりも後発で、様々な改善が行われています。
SimulationScreen
というスクリーンのmockがあって、描画のテストをしやすいのはよいですね。
あと、テキストのスタイルをメソッドで更新していけるのは便利です (Goに三項演算子がないからかもしれません)。
Go言語という選択
最後になってしまいましたが、実装の最初に考えることは、どの言語を使うかということです。
バイナリを不安なくきちんと扱えること、環境にできるだけ依存せず実装できること、そして開発者にインストールしてもらいやすいことあたりが必要条件でした。
Go言語のデザインや型のシステムは決して私の好みではありませんが、言語の選択を左右するのは言語デザインだけではありません。
バイナリの扱いやすさ、言語の安定性、実行速度、ポータビリティー、コードの書きやすさ、メンテナンスのしやすさ、そして言語の人気とツールのインストールされやすさ (インストール時の心理的障壁の低さ) など、総合的に考えてGo言語はとてもよい言語だと思っています。
まだ実装していませんが、今後ファイルフォーマットを解析する機能を作ろうとしています。
画像ファイルや実行ファイル (ELF・Mach-Oフォーマット) などの解析コードが標準パッケージに入っているのは、Go言語のすごいところです。
他の言語だとライブラリをがんばって探すか、自前で解析するしかないでしょう。
goroutine間でデータを共有すると、簡単にraceが起きてしまうのが悩ましいところですが、これは丁寧にロックを取ってraceが起きないようにしなくてはいけません。
go test -race
で簡易なチェックをできるのはよいですね。
いい本です
まとめ
バイナリをエディットしたかったので、バイナリエディタを作りました。
作る過程で様々なことを学ぶことができました。
まだ実装したいことはたくさんあります。
diffモードやマーク、バイナリ列での検索などは実装する予定です。
実行ファイルのフォーマットを勉強して、ファイル構造を表示できるようにしたいと思っていますが、これは少し先の話になりそうです。
画像ファイルくらいから始めるかもしれません。
バイナリエディタ作りの旅は始まったばかりです。
各種ファイルフォーマットを調べながら、エディタ作りを楽しもうと思います。
宣伝
はてなでバイナリをエディットする仕事はあまりありませんが、プログラミングが好きで自分が使うものは一から作ってしまう、そんな情熱あふれるエンジニアを募集しています。