Julia のパッケージシステムによって既存コードの動作を保証する

# はじめに

## 背景

Julia のパッケージは仕様の変更が多いようで、既存のコードが動作しないといった声をよく耳にする。自力で解決できる見通しが立たない場合は大変だろう。

この記事では、開発者の立場でパッケージシステムをうまく運用することで、そのような事態をあらかじめ防ぐことを主眼に置く。設定の目的や変更時の影響などを理解しておくことは、エンドユーザーにとっても有用だろう。

私は discourse や slack での議論を追っているわけではない。現状の仕様をもとに、「動作保証」というアイデアを軸にして、整合的な枠組として捉えることを目指した。叩き台ぐらいに考えて、気軽にコメントしてほしい。

もし前提となるプロジェクトに慣れていないなら、この記事あたりを参考にしてほしい。

Juliaのプロジェクトと環境
https://qiita.com/mametank/items/9fc1c9227303d6ca304b

## 目標

この記事では、環境の変化に伴うパッケージやコードの動作不能を*予防する*ことを目的とする。個別具体的な過去のパッケージやコードを動作させることを主眼にはおいていない。もちろん、理解を深めることで、現状の解析には役立つかもしれないが。

目的を実現するために、「既存コードを継続的にアップデートする仕組み」を構築する。

「過去に動作した環境への巻き戻し」という要望もあるとは思うが、

- 環境が常に最新に保たれるため、ユーザーが
    - 過去のバージョンを参照する必要がなくなる
    - 古い環境に対応していないパッケージも導入できる
- 依存するパッケージの消滅にも対応できる
- 巻き戻したい場合は
    - git で可能
    - 巻き戻しに必要な機能だけに絞り込むことも可能


# パッケージの互換性

## パッケージの設定ファイル

```julia:Project.toml
name = "ImageContainers"
uuid = "d7fcca20-80f2-11e9-0c11-e932b7d58cd9"
authors = ["Lirimy <31124605+Lirimy@users.noreply.github.com>"]
version = "0.1.0"

[deps]
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
FileIO = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549"
ImageMagick = "6218d12a-5da1-5696-b52f-db25d2ecc6d1"
QuartzImageIO = "dca85d43-d64c-5e67-8c65-017450d5d020"

[compat]
FileIO = "1.2"
ImageMagick = "0.7, 1.1"
QuartzImageIO = "0.7"
julia = "1.0, 1.4"

[extras]
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[targets]
test = ["Test"]
```

https://github.com/Lirimy/ImageContainers.jl/blob/master/Project.toml

Project.toml にパッケージの設定が記述されている。本記事と関係のある項目を説明していく。

**version**
パッケージのバージョンを設定する。公式に登録する際にはこの情報が利用される。

**[deps]**
依存するパッケージの UUID が記録される。環境に `Pkg.add("PackageName")` すると自動的に追加される。stdlib に含まれるパッケージも対象となる（ここでは `Base64`）。

**[compat]**
依存するパッケージの互換性（*動作を保証する*バージョン）を記述する。julia 本体のバージョン指定も必要となる。
https://julialang.github.io/Pkg.jl/dev/compatibility/

### Manifest.toml について

Project.toml と似たファイルとして Manifest.toml がある。Pkg.jl によって自動生成されるファイルで、人が編集するものではない。Project.toml では直接的に依存するパッケージのみを記述するが、Manifest.toml は環境にインストールされたすべてのパッケージのリストになっている。

設定ファイルとして利用することも可能ではあるが、私はあくまでもログの類として扱っている。PkgTemplates.jl のデフォルトでも .gitignore に追加され、共有されないようになっている。`Pkg.instantiate()` では Project.toml より優先されるようなので、あらかじめ取り扱いを決めておかないとトラブルの元になる。

https://julialang.github.io/Pkg.jl/v1/toml-files/index.html#Manifest.toml-1

## 互換性の意義

上位パッケージの仕様変更による下位パッケージの動作不能を防ぎたい、というのは自然な要望だろう。互換性の記述はそのために存在しており、互換性がなければパッケージがインストールされない。開発者が上位パッケージのバージョンアップに追従しなければならないかわりに、パッケージがインストールされたならば、理論的には動作が保証される。

互換性の記述は公式レポジトリ登録のガイドラインで示されている。はるかな未来での動作保証は当然ながら不可能であり、バージョンの上界を適切に定めなければならない。通常はマイナーバージョン単位で更新するようである。
https://github.com/JuliaRegistries/General#automatic-merging-of-pull-requests

## コンフリクト

パッケージの依存関係は互換性を伴う。つまり、インストールできる上位パッケージのバージョンに制約が生ずる。そのため、依存関係によっては適切なバージョンが存在しない場合もありうる。

```
pkg> add A
Unsatisfiable requirements detected for package D [756980fe]:
 D [756980fe] log:
 ├─possible versions are: [0.1.0, 0.2.0-0.2.1] or uninstalled
 ├─restricted by compatibility requirements with B [f4259836] to versions: 0.1.0
 │ └─B [f4259836] log:
 │   ├─possible versions are: 1.0.0 or uninstalled
 │   └─restricted to versions * by an explicit requirement, leaving only versions 1.0.0
 └─restricted by compatibility requirements with C [c99a7cb2] to versions: 0.2.0 — no versions left
   └─C [c99a7cb2] log:
     ├─possible versions are: [0.1.0-0.1.1, 0.2.0] or uninstalled
     └─restricted by compatibility requirements with A [29c70717] to versions: 0.2.0
       └─A [29c70717] log:
         ├─possible versions are: 1.0.0 or uninstalled
         └─restricted to versions * by an explicit requirement, leaving only versions 1.0.0
```

https://julialang.github.io/Pkg.jl/dev/managing-packages/#conflicts-1

![パッケージの依存関係](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202534/771c0fbc-3862-d5b0-0ff8-e3fa72be32f3.png)

Aをインストールする際に、DはBとCの両方に依存されている。このように、依存関係をグラフで表したときに、あるノード（この場合はD）に至る経路が複数存在すると、コンフリクトが起こりうる。この例のように、双方から要求されるバージョンに重なりがないとインストールに失敗する。

この例で改善すべきは、Dの新しいバージョンに対応していない（ために古いバージョンを要求している）Bである。Project.toml を変更すれば、インストール自体は可能になる。動作は保証されない。

```julia:B/Project.toml
[compat]
#D = "0.1"         # before
D = "0.1, 0.2"     # after
```

エコシステム内のすべてのパッケージが速やかにアップデートされる理想的な世界ならば、コンフリクトは起こらない。次節では、環境の変化（上位パッケージのアップデート）に対してパッケージ開発者が対応すべきことを示したい。

## 疑問点

回答を求めているわけではなく、今の段階での関心を個人的に記録しておきたい。

- 依存関係で閉路を作らないようなパッケージの運用方法はありうるか？　現仕様と比較してメリットはあるか？
- 単一のバージョンしかパッケージをインストールできない仕様なのはなぜか？　複数のバージョンが共存すると問題が生ずるのか？ `@reexport` すると名前がバッティングするから？


# パッケージのアップデート

動作保証に対するコストとして、開発者はパッケージをこまめにアップデートしなければならない。ここでは、アップデートのやり方、そしてアップデートをいかに省力化していくかを述べる。

パッケージ・エコシステム全体で見て、 Julia 本体を大本にして、上位パッケージから下位パッケージへ依存関係をたどって、アップデートが連鎖していくようなイメージを持ってもらうとわかりやすいと思う。

## 互換性の記述

### 初回

Project.toml の [compat] を書く際の、とりあえず妥当と思われる方針を示しておこう。

1. パッケージの環境で `]st` として、現在インストールされているパッケージのバージョンを知る
2. [compat] に、パッチバージョンを省いてマイナーバージョンまでを記述する

注意点としては、

- stdlib に含まれるパッケージは不要
- julia 本体は必要

```yaml:Project.toml
# [deps] などは省略

[compat]
FileIO = "1.2"
ImageMagick = "1.1"
QuartzImageIO = "0.7"
julia = "1.0, 1.4"
```

https://github.com/Lirimy/ImageContainers.jl/commit/c7cc3100f713ef15026a11ce168fd3d5d420b3d9

CompatHelper （後述）を使わずに手動で入力すべき理由は、パッケージの記述順に任意性があって、マージ時にコンフリクトして面倒だからである。

このように、「パッチバージョンは省いてマイナーバージョンまでしか指定しない」ことは、依存するパッケージのパッチアップデートでは、パッケージの動作不能が起こるような仕様変更はなされないだろうと信頼することで、必要となるメンテナンスの頻度を減らしている。

Semver では、「パブリックAPIが廃止予定」ならばマイナーバージョンを上げること、とされている。依存するパッケージがこれに従っていれば、マイナーバージョンごとのアップデートで十分である。

セマンティック バージョニング 2.0.0
https://semver.org/lang/ja/

とりあえずこの手順で Julia stable への対応はできるはずなので、 Julia LTS や他 OS への対応は、CI が出すエラーメッセージを見て適宜やってほしい。

### アップデートへの対応

依存するパッケージがマイナーバージョンアップした際、仕様変更の影響を受けた場合にはパッケージに修正を加えなければならないが、ほとんどは問題ないだろう。そのような場合に、[compat] に対応バージョンを追記するだけの作業を補助してくれるのが CompatHelper である。

CompatHelper は GitHub Actions としてインストールされ、依存するパッケージの最新バージョンを定期的にチェックし、互換性がなくなれば、 [compat] 欄に追記するような PR を出してくれる。動作を確認して問題なければ、PR のマージだけ手動で行えばよい。この記述が、最新バージョンでの「動作保証」を意味する。
https://github.com/bcbi/CompatHelper.jl

CompatHelper の実体は .github/workflows/CompatHelper.yml である。 PkgTemplates.jl v0.7 ならば自動生成してくれるので、インストールする必要はないはず。
https://github.com/Lirimy/ImageContainers.jl/blob/master/.github/workflows/CompatHelper.yml （例）

## テスト

テストとは、コードが想定通りに動作しているならば pass し、動作していないならば fail するような検証手順のことである。「動作保証」を具体的な手続きに落とし込んだものと言える。

パッケージの動作不能はテストによって検出されるのが望ましい。「アップデートに際して動作が保証されるようなパッケージの仕様」がテストによって規定されるとも考えられる。

厳密な運用ならば、すべてのメソッドや条件分岐に対してテストを書くようだ。テストが書かれている割合を調べてくれるのが Coverage で、 PkgTemplates.jl のプラグインから簡単に導入できる。

パッケージ内の test/runtests.jl にテストを書くことになる。
https://docs.julialang.org/en/v1/stdlib/Test/

Julia v1.0 でユニットテスト
https://qiita.com/antimon2/items/5222f4f773bf1944b745

## CI

依存関係のチェックやテストなどをオンラインで実行してくれるサービスである。オープンソースならば無料で利用できるところが多い。PR 時にも行われるので、結果を見るだけで動作確認が済む。

ログを見ると、これらの工程が行われているようだ。

- OS 上に Julia をインストールする
- 依存するパッケージを新規環境にインストールする
- パッケージのテストを実行する
- Document や Coverage などを生成する

https://travis-ci.com/github/Lirimy/ImageContainers.jl （ログ例）
https://docs.travis-ci.com/user/languages/julia/

PkgTemplates.jl のプラグインを設定すれば README.md に CI のバッジが生成される。バッジをクリックすれば CI の設定画面に行けるので適当に許可を出せばよい。

## Julia 本体との兼ね合い

### Julia 本体のバージョン

Julia 本体は LTS (v1.0) と Stable （現在 v1.4.1）しかメンテナンスされない。

> Only the LTS and Stable releases are maintained.
> https://julialang.org/downloads/#older_releases

開発者の負担を考えると、パッケージもそれに準じて、 LTS と Stable だけサポートすれば十分だと思う。その間のバージョンで動いたらラッキーぐらいな気持ちで。

### LTS のサポート

Artifacts とはバイナリなどを配布できるコンテナのこと。Julia 1.3 で導入され、パッケージの依存関係を大きく変化させた。
https://julialang.github.io/Pkg.jl/v1/artifacts/

Artifacts に関係する事例があるので紹介したい。
Stable で開発したパッケージが（CI上の） LTS で動作しないことがあった。Artifacts の導入に伴い、依存するパッケージの最新バージョンが Julia LTS をサポートしなくなったためであった。 LTS のために古いバージョンを [compat] に加えて解決できた。
https://github.com/Lirimy/ImageContainers.jl/pull/17/commits/8a855d2ea3df92254dfc7d40ae853b14b74a5181 （事例ログ）

Artifacts を例に挙げたが、Julia LTS のサポートについて私見を述べたい。
長く続くパッケージなら、昔のバージョンで LTS は自動的にサポートされることになる。最新バージョンで LTS がサポートされるかは個別の事情による？　新しく作るパッケージで、面倒ならば LTS をサポートしないという判断もありかと思う。
いずれにせよ、 [compat] と CI 設定にうまく反映させよう。

### Compat.jl

異なるバージョンの Julia で同一のコードを実行できるように、仕様の差異を吸収できる？
https://github.com/JuliaLang/Compat.jl

### Julia 本体のバージョンアップ

上位パッケージのバージョンアップは CompatHelper で捕捉されるが、 Julia 本体のバージョンアップは補足されなかったため、手動で対応する必要がある。

- Project.toml の [compat] julia
- CI 設定 (.travis.yml / .appvayor.yml / .cirrus.yml) 
の Julia バージョン

コミット例
https://github.com/Lirimy/ImageContainers.jl/commit/28e4bb9a558f8b6ab0ec37ae96f185782ea8e863

# パッケージ以外への応用

※ この内容はペンディングにする。有用な情報もあるので、中途半端だが記載は残す。興味がある人はやってみてほしい。

数値計算のコードは「アプリケーション」にあたる。
https://julialang.github.io/Pkg.jl/dev/glossary/

パッケージと同じように再現性を求められないか？
→ パッケージとアプリケーションの本質的な違いは何なのか？

動作すべきコードがあるので、そのままテストに放り込めば楽なのでは？

```julia:test/runtests.jl
using sample
using Test

@testset "sample.jl" begin
    @test_logs include(joinpath("..", "src", "sample.jl"))
end
```

最低限 deprecation warning はキャッチできる。
module は外してもよい。その場合 `using sample` も抜く。

アプリケーションに UUID は付けなくてもよいが、 UUID を外すとテストできなくなる。
同名で別 UUID だと warning が出て気持ち悪い。

UUID について調査中...
https://docs.julialang.org/en/v1/manual/code-loading/

PkgTemplates の Custom Plugins を利用できないか？
https://invenia.github.io/PkgTemplates.jl/dev/developer/#Plugin-Walkthrough-1

DrWatson.jl
https://juliadynamics.github.io/DrWatson.jl/dev/
環境再現（巻き戻し）するパッケージ

## 将来の可能性

とりあえず思いついたことを羅列する。

- CI を利用した出力の記録
- Jupyter notebook や Julia markdown に適用
- 他のサービス (Binder など) との連携
- Docker による環境構築に組み込み


# さいごに

ここまでシステムの構築方法を述べてきたが、それを利用すべきかは別の問題である[^me]。目的に照らし合わせて、どこまでやるかを決めればよい。

パッケージ作成ならばここで述べた一連の作業が必要となるが、エコシステムやコミュニティーに還元していると考えよう。

[^me]: 私自身は50-100行ぐらいの短いコードが主で、再利用も少ないため、全く管理せずにコードを書き捨てている。gitも使わずにファイル名に連番をつけている。