shinchoku-tairiku.satyh をリファクタしている話
これは SATySFi Advent Calendar 23 日目の記事です (なぜか12月12日付の記事になっていますが23日目の記事です…)。 22日目は zr_tex8r さんの徹底検証! SATySFiはLaTeXの代わりになるかでした。 24日目は monaqa さんの予定です。
私は進捗大陸という技術同人サークルに所属していて、進捗大陸では1年半ほど前から SATySFi を使って同人誌を書いています。 クラスファイルも自作していて、shinchoku-tairiku.satyh という名前で公開しています。
今回、shinchoku-tairiku.satyh をリファクタしようとしている (途中) ので、その話を書きたいと思います。
2021/03/25追記
リファクタをしたPRです
現状の shinchoku-tairiku.satyh の問題点
まずは現状の問題点から整理していきます。
モジュール密結合問題
進捗大陸では、2019年春に頒布した進捗大陸05 (05はナンバリング) から SATySFi を使っています。
進捗大陸05では stdjabook (だったかな。記憶が曖昧) をベースにいろいろと改変したものを使っていました。 とても大きな1つのファイルの中にごちゃごちゃと詰め込んでいたので、どことどこが結合しているのかわからず見通しが悪いコードになってしまっていました。
nomaddo さんの記事
そこで、進捗大陸06 では一つの大きなファイルを分割して見通しをよくするために (あとは見た目をいじるために) フルスクラッチでクラスライブラリを作りました。
(進捗大陸06に SATySFi のクラスを作ってみたよという拙著の記事がありますのでよければぜひ)
一応、ある程度は分割して機能毎のモジュールを作ることができましたが、うまく分割できずに結局大きくなってしまうモジュールがあることに気が付きました。
複数の機能で、「現在の章・節」の情報 (番号やタイトルやページ番号) を使っていました。
- 見出し
- 目次
- ヘッダ
- インデックスタブ
- キャプション
- 参考文献
これらの機能で、一見分けられそうなに見えるが同じモジュールになっていたり、謎の依存関係が発生していたりと結局密結合になってしまっていました。
なのでこれをどうにかしたいです。
Satyrographos に対応していない問題
進捗大陸06ではフォントのダウンロードスクリプトを走らせて .satysfi
以下に入れるようにしていて、フォントハッシュはリポジトリに直接埋め込んでいました。
ちょっと微妙感あります。
また、SATySFi の先駆者のみなさんが公開してくださっている便利なパッケージを導入するためには、git submodule から使う必要がありました。まあいいといえばいいのですが面倒ではあります。
これらは Satyrographos および Satyrographos Repo を使えばやってくれるもので、Satyrographos を使うようにしたいと長らく思っていつつ対応していなかったのでこの機会に対応したいです。
(というか satyrographos 入りの docker-satysfi を作っておきながら自分では使っていないのは何事かという感じですが、opam 入りの docker image が大きすぎる問題があって Satyrographos 対応を躊躇していたのです。が opam-slim タグができたのでやる気になりました)
解決方法を考える
以上のような問題がありました。解決方法を考えていきます。
最初のモチベーションにはなかったけどやってみると改善したほうがいいと気づいた点についてもいくつか書きます。
モジュール間の接続方法の見直し
前述の通り、現在の shinchoku-tairiku.satyh はモジュールが中途半端に結合してしまっています。
モジュールは小さく単一の責務にして、モジュール間は疎結合に保ちたいです。できるなら、いつでもモジュール単体で別パッケージに切り出して使えるようにしたいです。
じゃあどうするかということで、いくつか考えた方法があるので考えた順に説明します。 なお、ここでは背景にも書いたとおり「現在の章や節の情報 (番号、ページ番号、タイトル、など)」を使いたいモジュールがたくさんあるという状況を想定しています。 また、以降では SATySFi のコードが出てきますが適当に書いているのでシンタックスや型など間違っているかもしれません……。
ref を公開する
まずは一番ナイーブな (と思われる) 方法です。
現在の章番号や章タイトルを持つような ref の値を公開してしまい、その ref をみんなが見ます。
情報提供側の例 (見出しモジュールが現在の章番号や章タイトルなどを管理しているとします):
module Headings : sig val current-chapter-num-ref : string ref val current-chapter-title-ref : inline-text ref ... end = struct let-mutable current-chapter-num-ref <- 0 let current-chapter-title-ref <- {} ... end
情報利用側の例 (図などに、 {章番号}.{章毎の図などの番号}
+ キャプションをつけるモジュール):
@import: ./headings module Captioned : sig val scheme : context -> inline-text -> block-text -> block-boxes end = struct let-mutable num-ref <- 0 let scheme ctx it-caption bt = let () = increment num-ref in let num = !Headings.current-chapter-num ^ `.` ^ arabic !num-ref in let it-num = embed-string num in ... end
この方法は、外から ref の値を書き換えて壊すことができてしまうためあまりよくなさそうなのと、 別の見出しモジュールに切り替えたくなったときに、見出しモジュールに依存している各モジュールの中身をいじらないといけなくなってしまうので、できれば避けたいです。
イベント通知ライブラリ
章や節が変わったときにそのイベントを各モジュールに通知するような仕組みがあればいいのかなと思ったので、下のライブラリを作りました。
GitHub - amutake/satysfi-event-source: A simple synchronous event-source library for SATySFi
内容はこれだけです。
@require: list module EventSource : sig % `t` is the type of event-source. `'a` means the event type of the event-source. type 'a t % `make` creates a new event-source. val make : unit -> 'a t % `listen` registers the given callback function as a listener. val listen : ('a -> unit) -> 'a t -> unit % `dispatch` calls all listeners' callback immediately. val dispatch : 'a -> 'a t -> unit end = struct type 'a t = Listeners of (('a -> unit) list) ref let make () = let-mutable fs <- [] in Listeners fs let listen f (Listeners fs) = fs <- f :: !fs let dispatch e (Listeners fs) = List.iter (fun f -> f e) !fs end
使い方は以下のような感じです (リポジトリの example と同じです)。
情報提供側:
@import: ../event-source type page-event = (| page-number : int; point : (length * length); title : inline-text; |) module Headings : sig val section-page-event-source : page-event EventSource.t val section-scheme : inline-text -> context -> block-boxes end = struct let section-page-event-source = EventSource.make () let section-scheme it ctx = let page-hook pbinfo point = let e = (| page-number = pbinfo#page-number; point = point; title = it |) in EventSource.dispatch e section-page-event-source in let ib = read-inline ctx it ++ inline-fil ++ hook-page-break page-hook in line-break true false ctx ib end
情報利用側:
@import: ../event-source module Header : sig val init : 'a EventSource.t -> unit constraint 'a :: (| title : inline-text |) val scheme : context -> block-boxes end = struct let-mutable current-section-title <- {} let init title-events = let f e = current-section-title <- e#title in EventSource.listen f title-events let scheme ctx = let it = !current-section-title in let ib = inline-fil ++ read-inline ctx it ++ inline-fil in line-break false false ctx ib end
(init は constraint 付きなのでいらないフィールドが入っていても大丈夫)
モジュールを取りまとめてつなげるモジュール:
@import: ./headings @import: ./header module Class : sig val document : block-text -> document end = struct let document bt = % connects header and headings let () = Header.init Headings.section-page-event-source in % ...snip end
この方法は、情報提供側と情報利用側に依存関係がないので、各モジュールが疎結合になっていますが、 これだけのためにわざわざよくわからない型を使ってライブラリの読者を混乱させるのもな、と思ったのでやめました。
あとモジュール単体をライブラリとして切り出したとき、ライブラリのインタフェースがこれだったらちょっと「えっ」と思うと思います。 特に init がよくわからないです。
callback listener を登録させる
章や節の情報提供側は callback listener を登録できるインタフェースを作っておいて、
章や節の情報利用側には現在の章や節の情報を登録できるインタフェースを作っておいて、
document
関数の中でそれらをつなげる方法です。event-source と似ていますが、EventSource.t
はインタフェースには出てきません。
情報提供側:
type heading-changed = (| num : string; title : inline-text; |) module Headings : sig val chapter-scheme : context -> inline-text -> block-text -> block-boxes val add-chapter-changed-listener : (heading-changed -> unit) -> unit end = struct let-mutable current-chapter-num <- 0 let-mutable chapter-changed-listeners <- [] let chapter-scheme ctx it-title bt-inner = let () = increment current-chapter-num in let e = (| num = arabic !current-chapter-num; title = it-title |) in let () = List.iter (fun l -> l e) !chapter-changed-listeners in ... let add-chapter-changed-listener l = chapter-changed-listeners <- l :: !chapter-changed-listeners end
情報利用側:
module Captioned : sig val scheme : context -> inline-text -> block-text -> block-boxes val set-base-num : string -> unit end = struct let-mutable base-num-ref <- ` ` let-mutable num-ref <- 0 let scheme ctx it-caption bt = let () = increment num-ref in let num = !base-num-ref ^ `.` ^ arabic !num-ref in let it-num = embed-string num in ... let set-base-num base-num = let () = base-num-ref <- base-num in num-ref <- 0 end
document 関数:
@import: ./headings @import: ./captioned module Class : sig val document : block-text -> document end = struct let document bt = % モジュールを接続 let () = Headings.add-chapter-changed-listener (fun e -> Captioned.set-base-num e#num ) in % ...snip end
つまらないですがもうこれでいい気がしています。わかりやすいしモジュール間の謎の依存関係もなくて単体で切り出してもそんなに違和感ないと思います。
こういう形なら、モジュールを捨てたり他のモジュールに入れ替えたりするのもやりやすそうです。
というわけで shinchoku-tairiku.satyh ではこの方法でモジュールを疎結合に保っていきたいと思います。
Satyrographos 対応
これは特に難しいことはなく、適当なディレクトリで satyrographos new lib class-shinchoku-tairiku
して生成されたファイルをリポジトリにぶちこんで適当に編集するだけです。
(以降、satyrographos new lib class-shinchoku-tairiku
して生成される satysfi-class-shinchoku-tairiku
側をライブラリ、 satysfi-class-shinchoku-tairiku-doc
側をドキュメントと呼ぶことにします)
ただ、ライブラリの開発は opam を入れずに docker しか使わない開発方法と相性が悪いなと感じています。
ドキュメントであれば docker だけで開発できます。docker の中で依存ライブラリをインストールして $REPO/.satysfi
にコピーというのを一度すればあとは依存ライブラリの再インストールは必要ありません (依存ライブラリを増やしたりしない限り)。
つまり、
docker -it --rm -v $(pwd):/satysfi amutake/satysfi sh -c "opam pin add . --no-action && opam install satysfi-class-shinchoku-tairiku-doc --deps-only && satyrographos install --output .satysfi/dist --copy"
しておけばいいです。
が、docker オンリーでライブラリを開発する場合には問題があって、ライブラリの開発ではライブラリを編集してそのドキュメントを編集するという流れがあると思うのですが、ライブラリの変更を .satysfi に反映させるためには .satysfi を消してもう一度依存ライブラリを全部インストールし直す必要があります (なおここではドキュメントからライブラリを使う際は @import
ではなく @require
を使う想定)。shinchoku-tairiku.satyh は satysfi-fonts-noto-{sans,serif}-cjk-jp
に依存しているのですが、これをインストールするのに手元で実行すると数分かかってしまって大変です。
なので、ライブラリの開発ではおとなしく opam をホストにインストールして、時間がかかるけど switch もちゃんと作って使うのがよさそうです。
cross-reference のキーの扱い
shinchoku-tairiku.satyh では \ref
コマンドで章や図、参考文献などの番号を取得できるようになっています。
この \ref
コマンドは、典型的には各モジュールのなかで label ^ `:num`
のような文字列を cross-reference のキーとして番号を保存しておき、 \ref
コマンドの定義の中で label ^ `:num`
の番号を取得する、という作り方になると思います。
なので、この「キーの作り方は label ^ `:num`
である」というモジュール内部の知識が外側に漏れ出ていることになります。
これが思いの外つらくて、クラスライブラリが小さいうちはいいのですが大きくなってくるとどこで何がどうやって登録されていてどこから使われているのかよくわからなくなってきます。 \ref
だけならいいのですが別のモジュールから使われていたり…。cross-reference を使うとモジュールの依存関係として明示的に @import
として現れるわけではないので、それもつらい感じがします。
これに対する解決策ですが、モジュールの疎結合化のところにも書いたように callback listener を設定して document 側で cross reference の処理をする方法がひとつあります。
が、流石にそれはやりすぎなのかなという気もしていて、
そもそも val chapter-scheme : context -> string -> inline-text -> block-text -> block-boxes
という関数があったら、label (string) を渡すということはその label で章番号を保存することを期待しているので、callback listener 側で cross-reference を設定するのはあまりよくない気がします。では chapter-scheme
にはラベルを渡さないようにして、番号をタプルかなにかで返すようにして呼び出し側で設定するか?というと、それも微妙…。
ということで、(あまりいい解決方法ではない気もするのですが、) 以下のような RefNum モジュールを作って 外部仕様としてモジュールのインタフェースのコメントに「RefNum モジュールに番号が登録されます」と明記するようにしました。
module RefNum : sig val get : string -> string val set : string -> string -> unit end = struct let make-key label = `shinchoku-tairiku:` ^ label ^ `:num` let get label = match make-key label |> get-cross-reference with | None -> `?` | Some n -> n let set label num = let key = make-key label in register-cross-reference key num end
これでキーの生成については RefNum モジュールに隠蔽されるのでまあ、という感じです。
あるいは、章・図・参考文献などを参照するときは、専用のコマンド (\ref-chapter
, \ref-figure
, \ref-bib
など) を用意することにして、各モジュールでラベルから番号を取得する口を開けるようにしたほうが自然かもしれません。そうするとラベルの生成についてもモジュール内に閉じます。
ですが今回はリファクタリングということでいままでのコマンドを壊さないように直すことが目的だったのでとりあえず全部 \ref
のままにしています。
コマンドの定義を1ファイルに集める
いままでは各モジュールにインラインコマンドやブロックコマンドを定義していたのですが、クラスライブラリのインタフェースとなるファイル (class-shinchoku-tairiku/shinchoku-tairiku
) に全て置くようにしました。
各モジュールでは context
を受け取って block-boxes
または inline-boxes
を返すような関数だけ定義しておいて、インタフェースとなるファイルからそれらを呼び出すようにします。
利点としては、
- どういったコマンドが使えるかについてはインタフェースとなるファイルだけを見ればよくなる
- インタフェースとなるファイルだけインポートすればいい
- 現状はあるファイルをインポートするとそのファイルがインポートしているファイルに定義されているコマンド・関数・モジュールもすべて使えるようになるので、各モジュールのファイルにコマンドを定義するのでもインタフェースとなるファイルがそれらをインポートしていればコマンドは使えるようになりますが、SATySFi slack で「この挙動は今後変わるかも」という話がありました
といった点があります。
なお、この「context
を受け取って block-boxes
または inline-boxes
を返すような関数」はSATySFi の標準ライブラリにならってなんちゃら scheme という名前にしましたが、個人的には Flutter のように build-* のような名前が好みです。
設定値の問題
複数モジュールにまたがる設定値 (フォントやテキスト幅など) は config.satyh というファイルにハードコードされているのですが、すごく微妙感があって、これをモジュール毎に最小限だけ設定したいです。
なぜこうなっているかというと、各モジュールから設定にアクセスするのがこういう形でないととても面倒だからです (関数にいちいち渡したり ref で参照するとかになる)。
また、shinchoku-tairiku.satyh では標準ライブラリの itemize.satyh をコピーしてきてリストアイテムの間の幅だけ調整しているのですが (そのためLGPLライセンスになっている)、コードを再定義せずとも幅だけ外から渡せるようになるといいなと思います。
これらは残念ながらいい解決方法があまり思い浮かびません。いちいち関数にクソデカレコードを渡すわけにもいかないですし、ref で外から設定するのもなあという…。
ですが、モジュールタイプとモジュールファンクタが入ればだいぶきれいに書けるようになるのかなと思います。 例えば以下のようなものが書けるようになって再利用性が上がりそうです。
ライブラリ側:
module type ParagraphConfig = sig val indent : context -> length end module Paragraph (C : ParagraphConfig) : sig val scheme : context -> inline-text -> block-boxes end = struct let scheme ctx it = let ib = inline-skip (C.indent ctx) ++ read-inline ctx it ++ inline-fil in line-break true true ctx ib end
ライブラリの利用者側:
module MyParagraphConfig : ParagraphConfig = struct let indent = get-font-size end module MyParagraph = Paragraph MyParagraphConfig module MyClass : sig direct +p : [inline-text] block-cmd end = struct let-block ctx +p it = MyParagraph.scheme ctx it end
標準ライブラリが全部この形になるとそれはそれで気軽に使えなくなったりしてしまうのかなとは思うのですが、 個人的にはこの形になってくれると嬉しいなーと思います。
F-ing modules が入るとできるようになるのでしょうか?楽しみです。
(…ここまで書いてから gfn さんの資料 SATySFiのこれからの課題たち を改めて見直すと普通に同じことが書いてありました)
テストの存在
このようなリファクタをする前には、まずテストを書くことをおすすめします (自戒)。
テスト書かずに進めると、変更前と変更後で挙動を変えていないことを確かめるのがとても面倒になってしまいます。
組版結果 (PDF) のテスト方法は、
- GitHub - zeptometer/jest-pdf-snapshot: Jest matcher for pdf comparison. を使う
- GitHub - vslavik/diff-pdf: A simple tool for visually comparing two PDF files を直接使う (jest-pdf-snapshot は内部でこれを使っています)
- pdftotext を使って expected.txt との diff を取る
などいろいろありそうです。
このへんしっかりやられているのが yabaitech.tokyo さんのクラス https://github.com/yabaitechtokyo/satysfi-class-yabaitech です。
他のクラスライブラリ
他にもクラスライブラリはたくさんありますが、あまり読めていません 🙇♂️
複数のファイルに分かれているクラスライブラリは、自分が見つけた範囲だと、
- yabaitech.tokyo さんの satysfi-class-yabaitech
- abenori さんの satysfi-class-jlreq
- monaqa さんの slydifi
(他にもあったら教えて下さい)
特に yabaitech.tokyo さんのは状態を持つ部分と見た目の部分で分かれている実装になっていそうで参考になりそうです。
おわりに + 進捗大陸08の宣伝
以上、shinchoku-tairiku.satyh をリファクタしている話でした。
当初はリファクタ完了の状態でこの記事を出したかったので「〜しました」と言っている箇所が多いのですが、まだ shinchoku-tairiku.satyh に入っていないものもたくさんあります… 🙇♂️
やってみてこうすればできるだろうというところまではわかっているのですが、そこで時間が来てしまいました。 なにかあったらまた追記しようと思います。
追記: しました Refactor by amutake · Pull Request #10 · shinchoku-tairiku/shinchoku-tairiku.satyh · GitHub
そして、shinchoku-tairiku.satyh を使っているサークル進捗大陸の新刊が、12月26日から来年1月6日までに行われる技術書典10にて頒布される予定です。
ぜひよろしくお願いします🙏
なお今回も内容は GitHub で公開されています GitHub - shinchoku-tairiku/book08