TL;DR (概要)
- Crystalのような破壊的変更がアクティブなプログラミング言語の本を作る場合、バージョンアップで本の内容が壊れることが よくある。
- この問題に対処するため、技術書典5で頒布する予定の本では、サンプルコードが正しく動作することをCIで確認するようにした。
- ソースコードのフォーマット忘れが無いかもチェックするようにした。
- ↑のようなことができたのはAsciidoctorのソースコードを
include
する機能の力が大きい。 - ついでにRedPenで文章の校正も行なうようにした。
- 長期間に渡ってメンテナンスする予定の本であればこのような工夫するのは当然だし、そうでなくても本の品質を高める意味でこの工夫には価値があると思う。
はじめに(ポエム)
Crystal-JPというプログラミング言語Crystalの日本語ユーザーグループで、Crystalの普及に勤しんでいる、ということになっている『さっき作った』という者です。
Crystal-JPでは過去に三回、技術書典でCrystalに関する小ネタをまとめた同人誌を頒布してきたのですが、その頒布数は芳しくないものがありました。
この活動を主催しているボク自身のやる気が足りないのもその理由の一つに挙げられますが、それ以上に そもそもCrystalというプログラミング言語があまり知られていないのに、小ネタ集を初めから手に取ってくれる人なんて中々いない ということを感じました。
そこで、 技術書典4 に合わせて、Crystal-JPではCrystalの入門書になるような一冊を書こう、ということになったのです。
せっかく入門書を執筆するのであれば、長く使えるような本にしたい、と考えるのは当然の流れです。
また、今までの本もCrystalのバージョンアップに追い付いていけず、本の内容が現在のバージョンでは動作しなくなっていることが問題でした。
長く使えるようにするためには、バージョンアップに付いていけるように、壊れていないか動作チェックができるような仕組みが必要なことは確定的に明らかです。
というわけで、文章中のサンプルコードの動作チェックができるような仕組みを作って、さらにそれをCircleCIで実行するような環境を整備しました。
今年の一月くらいに。
技術書典4 の当落が決まる前から行動していたんですね。偉い。
しかし、Crystal-JPは技術書典4に 敢え無く落選 したので、このシステムやこのために書かれた原稿は永らく放置されていました。
正直、技術書典4に応募したサークルの中で(応募時点で)一番やる気があったと思うので、かなりヘコみました。
一応原稿は集めて本になるようにはしたのですが、実際に印刷するところまで気が進まなかったのはこのせいです。
その後、技術書典5が開催される運びとなり、どうも会場が広くなったらしくCrystal-JPも参加できることになりました。
そこでようやくこのシステムの真価が発揮されることとなったのです。
記事が最初に書かれたのは今年の二月か三月の辺りで、その頃のCrystalのバージョンは0.23.0
くらいでした。
しかし十月現在の最新版のCrystalのバージョンは0.26.1
です。
この間にもいくつか破壊的変更があり、実際に原稿のいくつかのサンプルコードがコンパイルエラーになっていたり、実行結果が変わっていたりしました。
それらの変化を的確に見つけることができたのは、こうしたシステムを整備したおかげだと思っています(自画自賛)。
<!-- これより広告 -->
Crystal-JPは技術書典5で「か62」に配置されています。
「Introducing Crystal Programming Language」というCrystalの初心者〜中級者向けの解説書を頒布する予定です。
142ページで1000円。お買い得だね。
構文だけじゃなくてWeb開発とかCLI開発とか具体例も乗ってる良い本です。よろしくお願いします。
https://techbookfest.org/event/tbf05/circle/25970003
また、印刷された本でなければ、Webで無料で読むことができます。
購入の前に一度目を通してみて、良かったら買うのもいいかもしれません。
(ちょっとスタイルが微妙で読みづらいかもしれません。誰か直してください‥‥)
https://crystal-jp.github.io/introducing-crystal/
<!-- 広告終了 -->
さて、広告も終わったので本編です。
システム概観
「Introducing Crystal Programming Language」の原稿やソースコードは次のリポジトリにあります。
https://github.com/crystal-jp/introducing-crystal/
そして、コードの動作チェックを含めたシステムはこんな感じになってます。
- GitHubにコミットされると、
- CircleCIでサンプルコードの動作チェックやRedPenによる自動校正が実行されて、
- チェックが通ったらCrystal-JPのSlackに通知して、GitHub PagesにビルドしたWebページをpushする。
というのが大体の流れです。どうってことは無いのですががんばりました。
また、masterでのみGitHub Pagesへのデプロイが走るようにするためにCircleCIのworkflow機能を使ったりしました。
微妙にオーバーエンジニアリングな気もしますが、半分以上趣味なのでやりたいようにやれるのが技術系同人誌執筆のいいところかもしれません。
workflows: version: 2 build-and-deploy: jobs: - build: filters: branches: ignore: gh-pages - deploy: requires: - build filters: branches: only: master
サンプルコードの動作チェック
ソースコードの動作チェックを実装するに当たって、次の二つの事柄を強く考えていました。
- どうやってサンプルコードを原稿から取得するか。
- 動作チェックのためのアサーションはどのように記述するか。
これらについて説明していきます。
1. どうやってサンプルコードを原稿から取得するか。
最初はMarkdownのパーサーを使ってコードブロックの一覧を取得して、それらをファイルに保存して実行すればいいかと考えていたのですが、これだと原稿中のコードが部分だった場合に困るし、かと言って全てのコードブロックでソースコード全体を書くように強制するのも現実的ではないと感じたので、この方法は諦めることにしました。
そこで、発想を転換して、サンプルコードの完全なものは原稿のテキストファイルとは別のファイルに保存することにして、原稿からそれをinclude
する、という方針を取ることにしました。
ここで問題になるのは原稿のフォーマットです。
Markdownを独自に拡張してそのような機能を追加したものや、あるいはreStructuredTextやRE:VIEWを採用しても良かったのですが、調べたところAsciidoctorのinclude
機能が一番強力そうだったので、Asciidoctorを採用することにしました。
Asciidoctorのinclude
機能には、次のような特徴があります。
- ソースコードをファイルから読み込んで、コードブロックとして表示できる。
- 加えて、Asciidoctorのファイルを読み込んで文書の一部にすることもできる。
- ファイルの一部分だけを読み込むために、コメントとしてタグを埋め込んで、その範囲を指定することができる。
- (
include
の機能というかコードブロックの機能だけど)注釈をコードブロックの外に表示することができる。
かなり高機能なことが分かると思います。
具体例としては、次のようなCrystalのソースコードがあったとします。
# tag::decl[]
foo = true ? 1 : "foo"
# end::decl[]
# tag::body[]
# <1>
if foo.is_a?(Int32)
# <2>
puts "number"
else
# <3>
puts "string"
end
# end::body[]
察しの良い方なら既に気付いているかと思いますが、ソースコード中の # tag::
と # end::
から始まるコメントがタグです。
これをinclude
するAsciidoctorのコードはこんな感じです。
まず、普通にinclude
する例です。
[source,crystal]
----
include::./foo.cr[tags=decl]
----
この場合、# tag::decl[]
から# end::decl[]
までの範囲のみがinclude
されて表示されることになります。
次に、注釈付きでinclude
する例です。
[source,crystal]
----
include::./foo.cr[tags=body]
----
<1> この位置での`foo`の型は`Int32 | String`。
<2> この位置での`foo`の型は`Int32`。
<3> この位置での`foo`の型は`String`。
この場合、# tag::body[]
から# end::body[]
までの範囲が表示されて、さらに<1>
から<3>
までの注釈(callout)がいい感じに表示されます。
また、include
のパス指定が原稿ファイルの位置から相対座標で指定できるのは地味に便利で重要なところでした。
このタグで範囲を指定できるという特徴は個人的にはかなり重要でした。
というのも、もしコードの動作チェックを行う機能を実装したとしても、例えばコードの部分読み込みが行番号指定だったりすると、結果的に変更に弱いものになってしまいます。
バージョンが変わってコードが修正されたときに行番号を修正し忘れて表示がおかしくなったり、そもそもそういった事態を避けるために他の執筆者がコードの部分は原稿に直接書くようにしてしまったら元も子もありません。
他の軽量マークアップ言語にもこうしたinclude
機能が実装されたらいいな、と思います。
(もしかしたらあるかもしれません‥‥。reSTにはあったような気がするけど、Crystalの本でPython or RubyならRubyを取ったような‥‥)
2. 動作チェックのためのアサーションはどのように記述するか。
動作チェックするためには、チェックされるコードと期待される実行結果をどこかに書かなければいけません。
そのための方法はいくつかあると思います。例えば、そのプログラミング言語の標準ライブラリのアサーションを使う、という方法です。
Crystalの場合はspec
という標準ライブラリがあり、そこで様々なアサーションが定義されています。
ですが、それは過去のRSpecライクなfoo.should be_true
のような記法で、そのシンタックスを説明するだけで骨が折れます。
特にこの本は初心者もターゲットにしている本なので、そういった複雑な記法を最初から説明するのは得策と呼べません。
そこで、今回はコメントを使った方法を取ることにしました。
それらについて説明していきます。
コメント・タグを使ったアサーション
ソースコード中にコメントに// => 実行結果
や# => 実行結果
のように書いて、その行の実行結果を例示することはよくある表記ではないかと思います。
今回は、基本的にはこの記法を使ってアサーションを行うことにしました。
具体的には、このようなコメントが現れたときに上のspec
ライブラリの記法に変換するようなフィルタをRubyで実装して、サンプルコードにそれを適用したものを実行するようにしました。
サンプルコードは各章の原稿があるディレクトリのexamples
ディレクトリ以下に配置するという規則にして、そこにあるファイルを処理しています。
その実装は以下のファイルにあります。
例えば、次のようなコードは、
1 + 2 # => 3
"foo" # => "foo"
[1, 2][3] # raises IndexError (Index out of bounds)
実際には次のようなコードに変換されてから実行されます。
require "spec"
it "foo.cr" do
(1 + 2).inspect.should eq("3")
("foo").inspect.should eq("foo")
expect_raises(IndexError, "Index out of bounds") { [1, 2][3] }
end
読み手にも分かりやすくて、いい感じなのではないかと思います。
またCrystalは文法こそRubyに似ていますがRubyほど柔軟な言語ではないので、トップレベルやクラス・モジュール定義以外でメソッドを定義するとエラーになってしまいます。
具体的には、この変換はコード全体をit
で囲っているので、このままだとメソッド定義があるとエラーになります。
そこで、it
で囲むべき範囲を明示するためにもタグを使うことにしました。
タグはコードブロックには表示されないので、このような使い方もできるわけです。
def foo
42
end
# tag::main[]
foo # => 42
# end::main[]
上のコードはこんな風に変換されます。
def foo
42
end
require "spec"
it "code.cr" do
(foo).inspect.should eq("42")
end
他にもいくつか機能がありますが、さすがに全部紹介するのは面倒なので省略します。
README.md
に詳細に書いてあるので、そちらを参考にしてください。
プロジェクトに対するアサーション
他にも、章の内容によっては実際にCrystalのプロジェクトを用意して、そちらのコードを示したいという要望があるであろうことが予想できました。
そこで、原稿があるディレクトリのprojects
ディレクトリ以下にCrystalのプロジェクトを配置すると、依存関係のインストールを行ってから、そのプロジェクトのテストを実行するような処理も実装しました。
一応Makefile
を書くと依存関係のインストール方法やテストの方法をカスタマイズできるのですが、これと言って拘ったところが無いので適当に割愛します。
RedPenによる自動校正
一応、RedPenに自動校正もコードの動作チェックに合わせてCIに組み込みました。
ただ、RedPenが役に立ったのかは微妙です。どちらかと言えば微妙な指摘に悩まされることの方が多かった気がします。
特にInvalidSymbols
が曲者で、こいつがインラインコードの記号まで指摘してきて、かなりのストレスでした。
(これは普通にバグです)
こんな設定でやっていたのですが、もし何かもっと上手い設定などありましたら教えてください。
<redpen-conf lang="ja"> <validators> <!--Rules on sentence length--> <validator name="SentenceLength"> <property name="max_len" value="100"/> </validator> <validator name="CommaNumber"/> <validator name="HeaderLength"/> <!--Rules on expressions--> <validator name="SuccessiveWord" /> <validator name="JapaneseStyle" /> <validator name="InvalidExpression" /> <validator name="JapaneseExpressionVariation" level="Info"/> <validator name="DoubleNegative" /> <validator name="Okurigana"/> <validator name="JapaneseNumberExpression"/> <validator name="JapaneseAmbiguousNounConjunction" /> <validator name="JapaneseJoyoKanji" level="Warn"/> <validator name="LongKanjiChain" /> <validator name="DoubledConjunctiveParticleGa" /> <validator name="SuggestExpression"> <property name="dict" value="config/redpen/suggestion.txt" /> </validator> <!--Rules on symbols and terminologies--> <validator name="InvalidSymbol"> <symbols> <symbol name="NUMBER_SIGN" value="#" invalid-chars="#" /> <symbol name="COMMA" value="、" invalid-chars="," /> </symbols> </validator> <validator name="KatakanaEndHyphen"> <property name="list" value="ファイバー,コンパイルエラー" /> </validator> <validator name="KatakanaSpellCheck" level="info"/> <validator name="SpaceBetweenAlphabeticalWord" /> <validator name="ParenthesizedSentence"> <property name="max_count" value="3"/> <property name="max_nesting_level" value="1"/> <property name="max_length" value="10"/> </validator> <!--Rules on sections and paragraphs--> <validator name="SectionLength"> <property name="max_num" value="1500"/> </validator> <validator name="EmptySection" level="Info"/> <validator name="GappedSection" /> <validator name="SectionLevel" /> <validator name="ParagraphNumber"> <property name="max_num" value="30" /> </validator> <validator name="ListLevel" /> <!--Load JavaScript validators--> <validator name="JavaScript" /> </validators> </redpen-conf>
また、その他にcrystal tool format
でCrystalのソースコードのフォーマットをチェックしたり、RubocopでRubyのコードをチェックしたりもしていました。
PDF・HTMLへの変換
印刷用、Web用のPDFはasciidoctor-pdf
で、Web用のHTMLはJekyllにAsciidoctorを組み合わせて使いました。
JekyllでAsciidoctorを使うのは微妙にコツが入ります。が、Asciidoctorは思った以上に柔軟フォーマットなので案外どうにかなったりします。
Asciidoctorで日本語のPDFを作るのはそこまで大変ではないのですが、なぜかasciidoctor-pdf
のページサイズでb5
を指定しても正しくB5版になってくれなくて、[182mm, 257mm]
のように縦横をmm
単位で指定する必要があったので注意してください。
config/asciidoctor-pdf/themes/print-theme.yml
config/asciidoctor-pdf/themes/web-theme.yml
あとがき
何となく面白いことをやっているということが伝わったなら幸いです。
「Introducint Crystal Programming Language」はCrystalの解説書で、長く使えるものになることを目指しています。
その上で、こうした工夫をしてバージョンアップに適応できるようにすることは、とても大事なことだと考えています。
特に、今回期間を置いて印刷することになって、そのことを深く実感しました。
他にも、こうしたコードの実行チェックを行なうことで、原稿中のコードのタイポなども減るはずなので、これを取り入れることで単純に本のクオリティが高まるはずです。
そういう意味で、実装コストを除けば、実行チェックを行なわない理由はないはずです。
(個人的にはRedPenなどを入れるよりも価値があるのではないかな、と考えています。)
「Introducing Crystal Programming Language」は印刷できる段階までは行きましたが、内容的には若干欠けている部分があります。
そうした部分を埋めつつ、これからもCrystalのバージョンアップに合わせてメンテナンスしていく所存です。
また、もしこの記事を読むなりしてCrystalに興味を持った方がいて、欠けている内容を書いてみたいと思った方がいたら、issueやCrystal-JPのSlackで連絡してもらえば対応できると思います。
GitHubで公開しているので、誤字・脱字等を見つけたらissueやPRを送ってもらえると助かります。
気が向いたらWeb版のHTMLから直接issueを作るページへのリンクとか追加できたらいいのかな、とか考えていますが実行に移せていません。
最後に、繰り返しになりますが、Crystal-JPは明日(2018/10/8)に池袋で開催する技術書典5に「か62」のスペースで参加します。
もし良かったら、お手に取ってもらえると幸いです。
そうでなくとも、Web版で良いので読んでもらえたらな、と願っています。
https://techbookfest.org/event/tbf05/circle/25970003
こんな長い文章に最後まで目を通していただきありがとうございました。
追記 (2018/10/9)
技術書典当日、無事完売することができました。ありがとうございます。
#技術書典 crystal-jp か62、頒布していた本は完売しました。Web版は無料で公開しているので、よかったら閲覧ください https://t.co/atKy3spDrR pic.twitter.com/r3BSoMb8a4
— さっき作った (@make_now_just) 2018年10月8日