Kengo's blog

Technical articles about original projects, JVM, Static Analysis and TypeScript.

オブジェクト指向か関数型か、という話題に私達はどう接するべきか

私がコードを書くときには「オブジェクト指向でいくか、それとも関数型か?」みたいなことはほとんど気にしていません。特にオブジェクト指向については人によって定義から違うこともままあるため、この手の議論がとても遠回りになることも多いと感じます。

ただきしださんのLT資料を拝見して、もしかしたらまだ需要があるのかなということで、この話題にどう接するべきか考えていることを書いてみます。

どう書くべきかはコンテキスト次第

結論から書くと、どのようにコードを書くべきかはチームや解決したい課題、利用言語や既存資産などのコンテキストによって変わります。 ので「何がオワコンでこれからは何が来る」みたいな議論は、チーム内という限られたスコープでのみ有効なはずです。 チームよりも広い場で議論する場合は、「どういったコンテキストにおいてどのような書き方をするか」のように若干抽象的なテーマが適切でしょう。

言い換えると、コードの書き方において絶対善や絶対悪は存在しないはずと考えています。例えばバッチ処理ないしウェブアプリケーションでは、複数スレッドから同一データを共有することで性能を高めるため、メソッドの戻り値をキャッシュしたりメモ化を施したりするかもしれません。このためにはデータが不変であると便利でしょう。 しかしこうした実装に登場するデータすべてが不変であるべきかというとそうではなく、むしろ可変データによって性能や可読性が向上することだってあるはずです。

私の経験した範囲でいうと、Repository内部で扱うデータを完全に不変にした結果コピーコンストラクタやシリアライズ・デシリアライズが頻出する読みにくいコードになったことがあります。 今ならライブラリの力を借りてビルダーを実装するなどもっとうまくできる気もしますが、単体テストによる品質担保を厚めにしつつ可変データを導入する手もあったはずでした。

トレンドの書き方が良いソフトウェアを届けるのに必須ということはない

OSSから例を出すと、OpenJDKやKotlin, Gradleといった著名なプロジェクトで使われているObjectWeb ASMはオブジェクト指向で書かれており、継承や配列といった今ならまず採用を避けるであろう書き方も頻出しています。また異なる意味を持つ intString も多く登場し、「この文字列はクラス名だっけティスクリプタだっけ?」といった注意を払いながらコードを読む必要があります。一部ではTypeTypePathみたいな型が用意されていて取り回すこともできますが、そのAPIはカプセル化やTellDontAskといった近年プログラマが慣れ親しんだものとは程遠いものです。

この面ではObjectWeb ASMは「プログラマの認知負荷を下げる」トレンドからは大きく離れていると言えます。

ですが、ObjectWeb ASMは事実上オワコンでなく、JVMエキスパートからの支持を集めて止まないわけです。 加速したJavaのバージョンアップにも速やかに追随し、コミュニティからの貢献を受け付けて修正をデリバリするとともに、リファクタリングや性能改善も行っています。今日SonarQubeを見たところではカバレッジ96.6%でした。まさに「質とスピード」を地で行くプロジェクトです。 *1

この面ではObjectWeb ASMは「ユーザに高い品質とかけがえのない価値を継続的に提供する」ソフトウェアの理想像に限りなく近いと言えます。 だいぶ極端な例ではありますが、トレンドの書き方が良いソフトウェアを届けるのに必須ということはないことを説明する良い事例だと思います。

曖昧な定義や由来を明らかにすることが重要

何がいいかはコンテキストによるのでコンテキストを明らかにしないまま議論をするのはやめましょう、というのが私の主張ではありますが、コンテキストを限定せずとも行うべき重要な議論・問題提起はあります。不明瞭な定義や由来に補足をしてただすものがそうです。言葉が曖昧だと議論が噛み合わず、建設的な議論になりません。例えばまさに今日読んだ Value Objectについて整理しよう - Software Transactional Memo はまさにこの貢献をするもので、とても勉強になりました。

定義や由来を明確にすることは、コンテキストが明らかなチームにおいても重要です。 例えばオブジェクト指向だと 2021年の「オブジェクト指向」を考える で指摘されるように、様々な定義が想起されます。 今話しているオブジェクト指向が何を意味しているのか、議論に参加する各々がきちんとすり合わせる必要があります。

まとめ

コードの書き方は結局、チームが望む働きを実現する道具のひとつです。チームの中で合意が取れているか、コードの理解と変更が容易か、APIや性能が利用者にも受け入れられているか……そういった要件に目を向けるべきです。オブジェクト指向や関数型も私達の道具箱に入っている道具のひとつとして、それぞれ尊重して理解につとめていきたいです。

*1:またAPIの認知負荷が高いのも、言い換えれば他のことに特化していると言えます。これは想像ですが、クラスファイルとプリミティブとの架け橋に徹し高い性能を実現することが設計の主目的かなと感じます。認知負荷については、各利用者が自分のコンテキストに最適化されたAPIを設計しそれでObjectWeb ASMを包むことで解決できます。ObjectWeb ASMはその用途が幅広いため、いたずらに抽象化してしまうと特定ユーザにとって使いにくいものになるでしょう。今のVisitorベースとTreeベースのAPIを提供するくらいがちょうどいいという判断かもしれません。

「非nullのint配列」をアノテーションで表すのは `@NonNull int[]` ではない

正解は int @NonNull [] です。な、なんだってー!

本当です。Java言語仕様書にも記載がありますが、配列を修飾する場合は [] の手前にアノテーションを書く必要があります。JVM仕様書に記載の例のほうがわかりやすいかもしれません:

@Foo String[][]   // Annotates the class type String
String @Foo [][]  // Annotates the array type String[][]
String[] @Foo []  // Annotates the array type String[]

組み合わせて考えると、「要素も配列自体も非nullのString配列」は @NonNull String @NonNull [] になります。コレクションは @NonNull List<@NonNull String> みたいにわかりやすいんですけどね。JavaのRecordでは配列を使わないほうが良いという話の時にも思いましたが、Javaは配列周りに非直感的な挙動が多い気がします。

なお配列だけでなく内部クラスでも同様で、パラメータが非nullの内部クラスを要求することをアノテーションで表現する場合は @NonNull Outer.Inner param ではなく Outer. @NonNull Inner param内部クラス名の手前にアノテーションを書く必要があります。これTwitterで11名にアンケートご協力いただいた限りでは、正答を答えられたのは1名だけという難問でした。

他にアノテーション周りで驚く機能としては、 this を修飾する方法も提供されています。receiver parameterと言います。Pythonのように第1引数にthisを書く形です:

class MyClass {
  void method(@Foo MyClass this, String param) {
    // ...
  }
}

いずれもJava8(2014年)からあった機能ですが、自分は今日まで知りませんでした。クラスファイルパーサを書くことが無ければ、このまま気づかなかったかもしれないです。Java、奥が深い。

リリース自動化の嬉しみとその手法

DevOpsやCIOps、GitOpsなどを通じて生産性向上を突き詰めていくと、コンパイルやテストだけではなくリリースまで自動したくなってきます。リリースには必要な作業が多く、また頻度も高くないため毎回思い出したり間違えたりが発生するためです。

特に変更内容をまとめて文書化する作業は、利用者に対する影響度もその煩雑さも高いため、自動化できれば文書の品質向上やリリース頻度の向上に大きく貢献できます。本記事では、筆者がNode/Java界隈でよく見るリリース自動化手法について紹介することで、リリース自動化の敷居を下げたいと思います。

なお本記事で言う「リリース」は、jarファイルやコンテナイメージなどビルドの成果物をリポジトリGitHub Releasesにアップロードして他プロジェクトやデプロイ環境で利用できるようにすることを指しています。環境に対する「デプロイ」や、エンドユーザへの公開を意味する「リリース」とは区別します。

自動化の前に

1. Changelogの要件を検討する

ソフトウェアのリリース時にその変更内容について説明する文書のことを、ChangelogまたはRelease Noteと呼びます。組織によってはChangelogとRelease Noteに異なる意味を持たせることもありますが、複数コミュニティから自動化手法を紹介する関係上、この記事では区別しません。

ChangelogにはKeep a Changelogというひろく知られた書式があります。自動化手法にはこの書式を意識したものも多いので、特に困らなければこれを採用します。

OSSでありがちなChangelogの保管場所としては、プロジェクトルートに CHANGELOG.md などのテキストファイルとして配置するか、GitHub Releasesの本文に記載する方法があります。Dependabotなどの依存管理手法はGitHub Releasesを参照して変更内容をユーザーに伝えるので、選択できるのであればOSSでなくともGitHub Releasesを使用することが望ましいでしょう。もちろんテキストファイルと併用しても構いませんし、GitHub PagesやWiki、チャットツールといった他の手法での公開も検討できます。

なおJava界隈ではChangelogに成果物のチェックサムを併記することもあります。これは利用者が成果物の整合性を検証する際に役立ちます。

自動化手法を選択する

1. Release Drafter

以下の記事にて紹介されているため詳細は割愛します。特徴はPRを使って開発しているプロジェクトであれば言語に依存せずほとんどのプロジェクトで採用できることでしょう。Maven開発のような非常に歴史の長いプロジェクトでも採用が検討されているようです

zenn.dev

2. GitHub ReleasesのRelease Note自動生成機能

GitHub Releases自体にも、Pull RequestをもとにRelease Noteを生成する機能が備わっています。 特に設定せずに導入が可能なため、とりあえず使ってみるには便利です。

docs.github.com

ラベルを使ってPRを分類するため、ラベルをPRに抜け漏れなく貼る運用が欲しくなるでしょう。actions/labelerのようなラベル管理を自動化する仕組みをあわせて検討すると良いかもしれません。

想定される利用手法は主に2つです:

  1. GitHub ReleasesからGUIを使ってリリースする手法。Releases作成時にGUI上に生成されるRelease Noteを目視確認できるため、安心して導入できるでしょう。Releasesのドラフトを作成→GitHub Actionsを発火し成果物をビルド→成果物をReleasesにアップロード→Releasesを公開 という流れです。
  2. CLIを使って自動化する手法。リリースプロセスから人手を廃するのに適しています。GitHub Actionsを発火し成果物をビルド→Releasesのドラフトをタグに対して作成、成果物をアップロード→Releasesを公開 という流れです。GitHub Actionsの発火にはタグ、あるいはリリースブランチへのpushを使うことが多いのではないでしょうか。

懸念があるとすれば、GitHubに対するロックインでしょうか。他の自動化手法と比べてバージョンが自動で決定されないのも特徴ですが、これはSemVer以外のバージョニングポリシー(例えばCalVer)を採用しやすいというメリットだと取ることも可能です。

3. semantic-release

SemVer2Conventional Commitsの利用を前提として、コミットメッセージをもとにリリースを自動実行する仕組みです。デフォルトブランチやリリースブランチに対するすべてのpushを契機としてリリースを行います。

semantic-release.gitbook.io

運用に柔軟性を持たせつつも、極力自動化し人の手を入れさせないための工夫が要所に見受けられるのが特徴です。なんせ、使っているロゴがこれです:

f:id:eller:20220216093216p:plain
「人間に作業させるとロクなことにならん」とか言ってそう

例えば運用には各開発者がConventional Commitsに従う必要がありますが、commitizenを使うことで導入障壁を下げることもできますし、commitlintを使うことでコミット時にコミットメッセージ書式の検証を行うこともできますし、semantic-pull-requestsを使うことでコミットメッセージが書式に従っていない場合にGitHub Checksを失敗させることもできます。またPRをsquash mergeすることで、コミットメッセージの決定をマージ時まで遅延することもできます。

またプラグイン機構による拡張も可能ですし、Shareable configurationsを使えば複数リポジトリをまたぐプロジェクトにも一貫した設定を行えますので、ある程度大きな規模の組織でも運用しやすいかもしれません。

利用方法はビルドのワークフローに npx semantic-release を埋め込むだけです。ブランチ名などの情報からリリースを行うべき状況だとsemantic-releaseが判断したら、Changelog生成やリリースが自動的に実行されます。リリースには npm publish./gradlew publish などのすでにコミュニティで利用されている手法が利用されるため、既存のリリース手順を再利用できます。

導入における主な課題は2つ。monorepoがbuilt-inではサポートされていないことと、Node.JS最新のLTSを必要とすることです。semantic-release自体はCI環境で実行するものなので開発者の手元にNode.JSを入れる必要はないのですが、前述のcommitizenやcommitlint, huskyといった関連ツールもほぼNode.JSコミュニティによって管理保守されているため、Node.JSを入れる判断をすることもあるでしょう。そのためNode.JS以外の環境を対象に開発しているプロジェクトではプロジェクトセットアップが若干複雑化するかもしれません。

なお似たものにstandard-versionrelease-itがあります。私は中の人を尊敬しスポンサーしているので、semantic-release推しです。spotbugs-gradle-pluginなど複数のOSSプロジェクトで使っていて、貢献受け入れやリリースを含め問題なく便利に回せています。

4. changesets

Atlassian発のmonorepoに特化した仕組みです。Node.JSを使ったmonorepoを開発しているのであれば検討しても良いかと思いますが、私はまだ試せていません。既存ユースケースもstandard-versionやsemantic-releaseと比べると1桁少ないです。

github.com

5. JReleaser

Node.JSではなくJVMを用いて動く仕組みです。他の仕組みと比べてまだ若いですが、既にMavenやGradleのサポートも用意されています。

jreleaser.org

f:id:eller:20220216093124p:plain
https://jreleaser.org/guide/latest/index.html より引用

Gradle用のクイックスタートを見た感じでは、 maven-publish プラグインではなく自前でリリース用の設定を持つようです。ここが ./gradlew publishnpm publish用の設定が完成されたプロジェクトに外付けする semantic-release とは大きく思想が異なる点です。コミュニティの進歩にJReleaserが自前でついていく必要があるため、保守コストが高くなる選択だと言えます。個人的には期待しつつもちょっと様子見です。

リリース自動化の果てに

1. 手動作業が残る部分

Changelog以外の文書は引き続き手動で作成する必要があります。例えば以下のようなものです:

  • エンドユーザ向けに変更内容を説明する文書
  • メジャーリリース時のマイグレーションガイド
  • マニュアル、プレス、その他

TwitterやSlackなどでの更新通知は自動化が可能ですが、もしブログ記事やメール通知のような手の込んだ文章を作成しているのであれば、それも残るでしょう。

ただこうした文書や通知はパッチリリース時にはあまり作らないはずで、パッチリリースの高速化・安定化・高頻度化は自動化によって充分に実現できると期待できます。

2. リリース自動化に向いたプロジェクト構成

CIやリリース自動化を推し進めると、ビルドやリリースに手作業が必要なプロジェクト構成はやりにくくなります。たとえば依存ライブラリを手でダウンロードしないとビルドできないとか、バージョン番号を手で書き換える必要があるとかです。

依存ライブラリについては、幸いJava界隈ではMaven Centralからほとんどのライブラリをダウンロードできます。昔では考えられなかったライブラリ、例えばOracle JDBC Driverもありますので、一度探してみるといいでしょう。
Maven Centralやその他のパブリックリポジトリに置いてないライブラリを使う場合は、自前でMaven Private Repositoryを管理してそこに置くことになるでしょう。これはNode.JSにおいても同様です。

バージョン番号の更新は、自動的に更新できるようにする必要があります。今回紹介した仕組みでサポートされているケースもありますし、Maven Release Pluginなどの機能を使うこともできます。ビルドツール設定以外のファイル、例えば META-INF/MANIFEST.MFBundle-Versionなどは手動ではなくビルドツールが自動で生成するようにしましょう。

3. リリース自動化に向いたブランチ戦略

リリース自動化は多くの場合、デフォルトブランチやリリースブランチが「常時リリース可能」であることを前提としています。 これは極力従うことが好ましいでしょう。

もしブランチが常時リリース可能でなかったら、リリース作業前に「リリース可能かどうか」を人間が検証する必要性が出てしまうためです。 もともと「自動化により人間の関与を減らしリリースの安定性と頻度を増やす」ことを目的に自動化しているのですから、ここに人間による作業を入れてしまうのは本末転倒です。少なくともリリース可能性検証のプロセスを自動化して、マージ前ビルドないしリリースビルドで自動的に検証されるようにするべきでしょう。

注意点として「マージしたらリリースされてしまう」ことを「完成するまでマージするべきではない」と受け止めない事が必要です。これはトピックブランチの寿命は短いほうが開発効率に良い影響があるためです。リリースできないとわかっている変更を公開することは避けつつ高頻度に変更をマージするために、Feature Toggleを使うなどの工夫が必要になるかもしれません。

まとめ

本記事では、筆者がNode/Java界隈でよく見るリリース自動化手法について紹介しました。

自動化手法 特徴 注意点
Release Drafter PRを使っていれば言語やビルドツールに関係なく利用可能。 リリースビルド用のワークフローを別に用意する必要がある。
GitHub ReleasesのRelease Note自動生成機能 設定不要。ラベルをもとにPRを分類してRelease Noteに反映。 バージョン番号を自分で決める必要がある。Release Note生成だけで、リリース作業自体は別に実行が必要。
semantic-release 柔軟な拡張性と徹底した自動化を両立。 実行にNode.JS最新のLTSが必要。標準ではmonorepoに非対応。
changesets 標準でmonorepoに対応。 Node.JSプロジェクト用。
JReleaser MavenやGradleといったビルドツールと統合。 JVM言語プロジェクト用。

リリース自動化はリリースの安定性と頻度を増やせる強力な仕組みです。ものによってはChangelogやコミットコメント、ブランチ戦略にリリース検証可能性検討の自動化といったものまで見直しをかける必要がありますが、それらも開発効率や生産性に寄与すると考えられているものがほとんどなので、開発体験向上のため検討してみてはいかがでしょうか。

Gradle/Kotlinで開発する私的ベストプラクティス2022

こちらのエントリーが素敵だなと思ったので、最近書いてるKotlinプロジェクトのベストプラクティスをまとめてみます。一部はJavaプロジェクトにおいても利用できるはずです。

zenn.dev

基本方針

  • 参加障壁を下げる。OSSプロジェクトでもプロプライエタリ・ソフトウェアプロジェクトでも、新しい開発者が参加するコストを下げることには大きな意義がある。
  • 環境差異を吸収する。javaにPATHが通ってさえいればOSに関係なくビルドが通るようにする。
  • プロジェクト固有ルールを作らない。Conventional CommitsやKeep a changelogなど、ひろく世に使われているルールを採用する。

Gradleを設定する

Spotlessを使う

コードのフォーマットはformatterに任せて人間は細かいことを考えない、というのが不特定多数が参加するソフトウェアプロジェクトのあるべき姿だと考えています。ここを妙にこだわるとエディタ縛りだとかタブ幅だとかのいわゆる”地雷”の多い話題を避けて通れませんし、プロジェクト固有のルールができて敷居が高くなり保守コストが高くなるという課題もあります。

逆に言えば、フォーマットをツールに任せれば、人間はエディタやOSの選択の自由を享受できます。

Gradleの場合、最もシームレスに使えるツールはSpotlessでしょう。MarkdownとKotlin、Kotlin Buildscriptすべてのフォーマットをこのツールで管理できます。

github.com

pre-merge buildでは spotlessCheck タスクを使ってフォーマットを確認します。このタスクは通常 check タスクから依存されていますので、深いことを考えずに ./gradlew build すれば充分です。

Git hookを導入する

commit時にSpotlessを自動実行するには、git hookを使います。git hookの設定にはghooksを使っていたのですが、脆弱性対応含む更新が止まっており*1使いにくい状態です。後述するsemantic-releaseを使うのであれば、NodeJSにPATHが通っている前提でhuskyを使っても良いでしょう。

JVM toolchainを使う

現時点でJavaには8,11,17という3つのLTSリリースがあります。誰でも簡単にビルドできるプロジェクトを作るためには、どのバージョンにJAVA_HOME環境変数が通っていても問題なくビルドできるべきです。このためのアプローチには「Java 8で動くようにする」と「JVM toolchainを使う」の2択があります。

既にJava 8をサポートしていないGoogle Errorproneやサポート終了を予告しているApache Camelのようなツールもありますので、JVM toolchainを使って「JAVA_HOMEがJava 8を指していても常にJava 11あるいは17を使ってビルドする」方が現時点でしょう。最新のKotlinプラグインならtoolchainの利用は容易です:

kotlin {
    jvmToolchain {
        (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(17))
    }
}

.gitattributes を書く

GitHubを使っている場合、人間に差分をレビューさせたくないファイルがあれば linguist-generated=true を指定しておきます。

./gradlew init で生成されるファイルを参考に、Windows向けの改行コード設定 *.bat text eol=crlf を加えても良いでしょう。

*.bat text eol=crlf
gradlew linguist-generated=true
gradlew.bat linguist-generated=true

settings.gradle.kts を書く

公式ドキュメントで言及されているとおり、設定ファイルをプロジェクトルートに置くことが強く推奨されています。 ./gradlew initでプロジェクトを作っていれば自動的に作成されているはずです。

最低限rootProject.nameを設定しておきます。これでどのようなフォルダ名が使われていても同じプロジェクト名になります。 またビルドスキャンをする予定があるなら、このタイミングで設定をしておくと良いでしょう。

plugins { id("com.gradle.enterprise") version "3.8.1" }

rootProject.name = "foo-bar"

gradleEnterprise {
  buildScan {
    termsOfServiceUrl = "https://gradle.com/terms-of-service"
    termsOfServiceAgree = "yes"
  }
}

gradle.properties を書く

公式ドキュメントをもとに設定しておきます。Java併用時に google-java-format を使う場合、少なくとも Java 17では--add-exportsの指定が必要です。

org.gradle.caching=true
org.gradle.configureondemand=true
org.gradle.jvmargs=-XX:+HeapDumpOnOutOfMemoryError -Xmx1G --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
org.gradle.parallel=true

ドキュメンテーションコメントにはDokkaを使う

私はあまり語れるほど使っていないのですが、KotlinプロジェクトではKDocをドキュメンテーションコメントに使い、KDocからの文書生成にDokkaを使います。設定も単純なので特に迷うことなく使えるはずです。

github.com

build タスクに必要なタスクすべてを実行させる

Gradleプロジェクトでは基本的に、 ./gradlew build によってプロジェクトのビルドと検証に必要なタスクがすべて実行されるべきです。assemble タスクによって成果物が作られ、 check タスクによってすべてのテストや検証が実行される状態を保つことで、これを実現できます。これらのタスクの役割は ライフサイクルタスクとしてドキュメントに説明されています。

ただSpotlessを使っている場合、手元では spotlessApply も実行したいがCI環境ではspotlessApply を実行させたくない、というケースがあるかもしれません。

これにはDSLによりCI環境の場合に分岐する、CI環境では -x オプションにより特定タスクをスキップする、手元で ./gradlew spotlessApply を明示的に実行させる、などの手法があります。プロジェクト固有ルールを作らないという方針と、標準では build タスクが spotlessApply タスクに依存していない現状を踏まえると、手元で ./gradlew spotlessApply を明示的に実行するのが良いと考えています。

とはいえ人間に「push前に./gradlew spotlessApplyを実行してね」というプロジェクト固有ルールを作りたくはないので、前述のGit hookに実行させる方針を採ることになるでしょう。

GitHub Actionsを設定する

ごく一般的なワークフロー設定は以下のようになります。 なおWindowsでビルドする場合は、--no-daemonオプションでGradle daemonを無効化する必要があります

jobs:
  build:
    strategy:
      matrix:
        os: ['windows-latest', 'ubuntu-latest']
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v2
      - uses: gradle/wrapper-validation-action@v1
      - run: ./gradlew build --no-daemon
      - uses: actions/upload-artifact@v2
        if: always()
        with:
          name: reports (${{ matrix.os }})
          path: build/reports

Dependabotを設定する

現時点では buildSrc プロジェクトの依存は更新対象に入っていません。以下のように明示的に指定する必要があります。

version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "daily"
    commit-message:
      prefix: "build"
  - package-ecosystem: "gradle"
    directory: "/"
    schedule:
      interval: "daily"
    commit-message:
      prefix: "fix"
  - package-ecosystem: "gradle"
    directory: "/buildSrc"
    schedule:
      interval: "daily"
    commit-message:
      prefix: "build"

逆に言えば、ビルドに用いるツールの依存を buildSrc プロジェクトに集めることで、ビルドに用いるツールとプロジェクトそのものの依存とを分けて扱うことができます。上記設定ではプロジェクトそのものの依存にのみ fix 接頭辞を用いることで、semantic-releaseによるリリースを発火させています。

semantic-release を使う

以下の記事で以前紹介したので詳細は省きます。なお今なら拙作GradleプラグインがあるのでGradleでもsemantic-releaseのフル機能を用いて開発できます。

blog.kengo-toda.jp

PATHにNode最新のLTSが通っている状態にする必要がありますが、これはnvmやasdfを使うことになるでしょう。最新のactions/setup-node@v2はnode-version-fileでファイルからNodeのバージョンを読み込めます:

- uses: actions/setup-node@v2
  with:
    node-version-file: '.nvmrc'
    cache: 'npm'
- run: |
     npm ci
     npx semantic-release

以上です。なおJava用ではありますが、ビルドの細かい話やGitHub Actionsの使い方については以下の本にも書いてますのでご参考まで。

zenn.dev

*1:PRは作ったんですが見てもらえてなさそう

--add-exportsをMaven/Gradleで使う

--add-exports なんてオプションは使わないに越したことはないのですが、依存先ライブラリの都合でどうしても必要という私のような人のためのメモ。

ポイントは javac だけでなく javadoc ないし java (テスト実行)コマンドに対するオプション提供も必要という点です。Gradle用のサンプルプロジェクトMaven用のサンプルプロジェクトもあります。

コンパイラ用の設定

注意点として、--add-exports オプションは --release オプションと同時に使えません

Maven3

  <build>
    <plugins>
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.9.0</version>
        <configuration>
          <source>11</source>
          <target>11</target>
          <encoding>${project.build.sourceEncoding}</encoding>
          <forceJavacCompilerUse>true</forceJavacCompilerUse>
          <showWarnings>true</showWarnings>
          <compilerArgs>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
          </compilerArgs>

Gradle

// build.gradle.kts
val exportsArgs = listOf(
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
)

tasks.withType<JavaCompile>() {
    sourceCompatibility = "11"
    targetCompatibility = "11"
    options.compilerArgs.addAll(exportsArgs)
}

javadoc用の設定

いろいろとオプションを指定する手法が提供されていますが、以下のように<additionalOptions>を使う方法での動作を確認しました。内部的にはOptionファイルを経由してjavadocコマンドのオプションを設定しています。

Maven

  <build>
    <plugins>
      <plugin>
      <artifactId>maven-javadoc-plugin</artifactId>
      <version>3.3.1</version>
      <configuration>
        <additionalOptions>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
        </additionalOptions>
      </configuration>
    </plugin>

Gradle

一気にややこしくなります。 options.addStringOption()では動作しないので、Maven同様にOptionファイルを経由しての指定が望ましいです。

// build.gradle.kts
val addExportsFile = file("$buildDir/tmp/javadoc/add-exports.txt")
val createJavadocOptionFile by tasks.registering {
    outputs.file(addExportsFile)
    doLast {
        addExportsFile.printWriter().use { writer ->
            exportsArgs.chunked(2).forEach {
                writer.println("${it[0]}=${it[1]}")
            }
        }
    }
}
tasks {
    withType<Javadoc> {
        dependsOn(createJavadocOptionFile)
        options.optionFiles(addExportsFile)
    }
}

テスト実行

Maven

surefireプラグインJVMをforkするなら、forkするJVM側にオプションを設定します。そうではない場合、以下のような .mvn/jvm.config ファイルでMavenを実行するJVM自体に設定を行います。

--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED

Gradle

jvmArgs プロパティを経由して設定します。このプロパティは標準でnullなので、ビルドスクリプトの他の箇所で設定していないなら .addAll() ではなく = を使って設定します。

// build.gradle.kts
tasks {
    test {
        useJUnit()
        jvmArgs = exportsArgs
    }

JavaScript Actionsをnode16で動かすようにする

この記事はCI/CD Advent Calendar 2021に参加しています。


先日GitHub ActionsがNodeJS v12のみならずv16でも動くようになりました。

github.com

今まではNodeJS v12しかサポートされていませんでしたが、このv12は来年の4月でサポートが切れます。速やかにv16に移行したほうが良さそうですね。必要な作業は actions.ymlruns.using を書き換えるだけではありますが、他に必要になるであろう作業もいくつか紹介します。

package.jsonengines.node を更新

package.jsonで実行に利用するNodeJSのバージョンを指定していた場合、それを更新する必要があります。16.13.1 が現在の最新バージョンなので、望ましい設定は以下のようになるでしょう:

  "engines": {
    "node": "^16.13.1"
  }

tsconfig.json を更新

NodeJSを12から16に上げると、ES2019ではなくES2021を使えるようになります。TypeScript公式の推奨設定を参考に、compilerOptionsを更新します:

{
  "compilerOptions": {
    "lib": ["ES2021"],
    "module": "commonjs",
    "target": "ES2021"
  }
}

TypeScriptを更新

ES2021をサポートしていないTypeScriptを使っている場合、TypeScript自体の更新が必要になります。 npm add -D typescript@^4.5.4 などとして更新しましょう。

なおTypeScript 4.4で、catchされたオブジェクトの型をanyからunknownに変更されています。トップレベルでエラーをキャッチして core.setFailed() を呼んでいる場合は修正が必要です:

-     core.setFailed(error.message)
+     if (error instanceof Error) {
+       core.setFailed(error)
+     } else {
+       core.setFailed(JSON.stringify(error))
+     }

ESLintを更新

ESLintの現時点での最新は8.5.0です。私が更新した多くのリポジトリではいくつかのプラグインも導入していましたが、そのような場合は npm add -D eslint@^8.5.0 @typescript-eslint/parser@^5.8.0 eslint-plugin-github@^4.3.5 eslint-plugin-jest@^25.3.0 などとして同時にアップデートすることが良いと思います。

.eslintrc.json の更新はESLintのリリースノートに載っているマイグレーションガイド(例えば3.0.0用マイグレーション)に従っても良いですが、特に設定にこだわりがないならば actions/typescript-actionのデフォルトブランチ から最新の推奨設定を引っ張ってくる方が時間は短縮できます。

GitHub Actions WorkflowでNodeJS v16を使う

GitHub hosted runnersでworkflowを実行し、かつactions/setup-node を使っていない場合、今月10日ごろから既にv16を使ったビルドが走っています。特に対応不要です。

actions/setup-node を使っている場合は、node-versionを変更する必要があります。ただ個人的には .nvmrc.node-version に使うバージョンを書いておいて、GitHub Actionsではその値を読むのがメンテ箇所が減るためおすすめです。setup-nodev2.5.0からnode-version-file設定がサポートされています

  - uses: actions/setup-node@v2
    with:
      node-version-file: '.nvmrc'

まとめ

以上です。GitHub JavaScript ActionはいつNodeJSのバージョンを上げるんだろう……と不安になっていた方々、やっと来ましたのでバンバン上げていきましょう。私が実際に作成した変更をいくつか並べておきますので、ご参考まで。

2021年のOSS活動状況まとめ

昨年のに引き続きOSS活動状況をまとめます。2021年12月20日時点の情報です。

概要:昨年比30%増

GitHubのプロファイルページによると今年のpublic contributionsは1,865で、昨年が1,440だったので約30%増です。commit 63%のpull requests 12%なので、引き続き手を動かしてコードを書けたと思います。

f:id:eller:20211220102614p:plain
my GitHub profile (2021/Dec/20)

主なリリースはspotbugs-gradle-plugin 4.6.1~5.0.3SpotBugs 4.2.1~4.5.2gradle-semantic-release-plugin 1.4.14~1.6.0actions-setup-docker-compose 1.0.0~1.0.4でした。

SpotBugs周辺の開発

一番大きい貢献はsonar-findbugsのメンテナを引き渡したことだと思います。このプロダクトを開発する過程においてSonarQubeと距離を置きたいと思うようになったため、先日のv4.0.5リリースから新しいメンテナ2名にPRのレビューやマージ、リリースプロセスの扱いなど全般を引き渡しました。活発なプロダクトの引き渡しはこれが初めてだったのですが、いまのところうまく回っているようで安心しています。

またSpotBugsコアにおいてSARIFレポートのGitHub Code Scanning API対応(v4.4.1)と複数レポートの出力(v4.5)とを完了したため、人間向けのHTMLレポートと機械向けのXMLやSARIFレポートとを同時生成するという普遍的なニーズに応えられるようにできました。これユーザには地味に便利な更新なんじゃないかと思います。

なお偽陽性偽陰性は3つ修正しました。これらも歴史が長くユーザ基盤のあるプロダクトとしては望まれるものでしょう。

発展的内容としてはGraalVMのnative-imageでパフォーマンスが向上するか試しています。残念ながら優位な差は出ませんでしたが、picocliで作ったCLIツールのネイティブイメージを作るための必要な知見が溜まったのは良かったです。

GitHub Actions周りの開発

actions/setup-java v2.3.0dependency cacheを実装しました。JavaプロジェクトをGitHub Actionsでビルドしている人なら、この機能による高速化とWorkflow定義の短縮化の恩恵を受けているのではないでしょうか。名前こそ出ませんでしたがGitHub公式ブログで紹介されたのが嬉しかったです。

また5月にはwrapper-validation-actionの安定性を向上させています。このActionは通信断でわりとすぐ落ちていたので、この変更の恩恵を受けている開発チームはけっこうあるんじゃないでしょうか。

GitHub Actionsについてはブログ記事も多く投稿しました。Dependabot周りで変更が多かったため、Dependabotが作るPRでいかに(SonarQubeのために)Secretsを使うか、を探求していた気がします。

なおreadthedocs-action公式のPRレビュー機能が充分に実装されたため、そろそろ不要かなという気がしています。

プログラミング言語

2021年はGroovyとJavaをKotlinで置き換えた年になりました。Javaはもちろん良い言語で、自分の強みの大きな部分を構成する言語です。と同時に変化の速い言語環境に身を置くことはアンテナを高く保つために有用ですし、Gradleビルドスクリプトに静的型付けを持ち込めるという利点は大きいと判断しています。並列実行性能を上げる技術基盤としての価値も期待していますが、そちらはまだ検証段階に至れていません。

似た理由でPowerful Command-Line Applications in GoをベースにGolangのキャッチアップもはじめましたが、こちらは2022年の挑戦になりそうです。

また自分がとても尊敬しているstatic program analysisの講義をしているAarhus Universityが新しくFlix言語を開発中、ということを知ってキャッチアップしています。PureとImpureを明示的に扱うというアプローチが面白いです。なおPRはひとつ送りましたが、残念ながらinvalidでした。最新のリリースではなく、デフォルトブランチでの挙動を確認すべきでした。
Flixはまだツールチェインに乏しいのでflix-gradle-pluginを実装してみましたが、言語標準のPackagerに手を入れないと依存管理周りは良いものにならなさそうなため、最近はScalaを読んでいます。

その他

Vert.xでReactiveなドキュメントビルダーのPoCも書いたのですが、こちらは技術的に無理は無さそうだけど言うほどパフォーマンスに寄与しなさそうという結論を出しました。外部サービスにビルドを委ねる大型プロジェクトでは意義があるかも?ですが、新ツールを書き直す程ではないかなと。
ReactorやVert.xのようなReactiveにプログラムを動作させるための取り組みには、引き続き関心を払っていこうと思います。

Sponsorを募集しています

昨年2月からGitHub Sponsorsを始めています。特にSpotBugsないしそのGradle プラグインをご利用の皆さま、ご支援のほどよろしくご検討ください。 SpotBugsのような古く自分ではあまり使わないプロダクトの保守を続けるにあたって、ユーザーや開発者からのわかりやすく明示的な支持があるとありがたいです。

github.com

継続的に支援するほどでは……という場合、ぜひこちらのGitHub Discussionsにて「誰が、どこで、どのように使っているか」を共有していただけるだけでも助かります。よろしくお願いします。

github.com