shinchoku-tairiku.satyh をリファクタしている話

これは SATySFi Advent Calendar 23 日目の記事です (なぜか12月12日付の記事になっていますが23日目の記事です…)。 22日目は zr_tex8r さんの徹底検証! SATySFiはLaTeXの代わりになるかでした。 24日目は monaqa さんの予定です。

私は進捗大陸という技術同人サークルに所属していて、進捗大陸では1年半ほど前から SATySFi を使って同人誌を書いています。 クラスファイルも自作していて、shinchoku-tairiku.satyh という名前で公開しています。

github.com

今回、shinchoku-tairiku.satyh をリファクタしようとしている (途中) ので、その話を書きたいと思います。

2021/03/25追記

リファクタをしたPRです

github.com

現状の shinchoku-tairiku.satyh の問題点

まずは現状の問題点から整理していきます。

モジュール密結合問題

進捗大陸では、2019年春に頒布した進捗大陸05 (05はナンバリング) から SATySFi を使っています。

進捗大陸05では stdjabook (だったかな。記憶が曖昧) をベースにいろいろと改変したものを使っていました。 とても大きな1つのファイルの中にごちゃごちゃと詰め込んでいたので、どことどこが結合しているのかわからず見通しが悪いコードになってしまっていました。

進捗大陸05当時のコード

nomaddo さんの記事

no-maddojp.hatenablog.com

そこで、進捗大陸06 では一つの大きなファイルを分割して見通しをよくするために (あとは見た目をいじるために) フルスクラッチでクラスライブラリを作りました。

進捗大陸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) のテスト方法は、

などいろいろありそうです。

このへんしっかりやられているのが yabaitech.tokyo さんのクラス https://github.com/yabaitechtokyo/satysfi-class-yabaitech です。

他のクラスライブラリ

他にもクラスライブラリはたくさんありますが、あまり読めていません 🙇‍♂️

複数のファイルに分かれているクラスライブラリは、自分が見つけた範囲だと、

(他にもあったら教えて下さい)

特に 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にて頒布される予定です。

techbookfest.org

techbookfest.org

ぜひよろしくお願いします🙏

なお今回も内容は GitHub で公開されています GitHub - shinchoku-tairiku/book08