Rustler precompiledの環境構築

最近，ZenohというRustのライブラリをElixirに移植し始めました．
https://github.com/b5g-ex/zenohex

最低限は動くようになったのですが，使ってもらうにはRust環境のインストールが必要ですし，ビルドもとても遅いです．
Rustler側もそのあたりは承知のようで，Rustler precompiledというプリコンパイルしたライブラリを利用できる仕組みを提供しています．
https://github.com/philss/rustler_precompiled

今回はZenohexのプロジェクトにRustler precompiledを導入した方法について紹介します．
基本的にドキュメントに従えば導入できるのですが，日本語の情報として残しておきたいと思います．

参照元ドキュメント
 - https://hexdocs.pm/rustler_precompiled/RustlerPrecompiled.html
 - https://hexdocs.pm/rustler_precompiled/precompilation_guide.html

既存のRustlerプロジェクトから移行する場合は，大まかに３つの手順が必要となります．
- Rustler Precompiledの導入と設定の変更
- Github Actionsの導入
- Hex Releaseの設定

## Rustler Precompiledの導入と設定の変更
何はともあれ，Rustler precompiledをdepsに追加します．2023/03/03現在のバージョンは0.6のようです．Rustlerのバージョン指定は緩めつつoptional: trueを付与します．Force buildをする際にここを変更していないといけないようです．

```elixir:mix.hex
  defp deps do
    [
      {:rustler_precompiled, "~> 0.6"},
      {:rustler, ">= 0.0.0", optional: true},
      {:ex_doc, "~> 0.10", only: :dev}
    ]
  end
```

次にElixirのNIFsを変更します．多分Diffを見た方が早いので以下を参照ください．
https://github.com/b5g-ex/zenohex/commit/af0d2dde9929982a696740a178111a38e409ce50
use RustlerだったところをRustlerPrecompiledに置き換えて，あれこれ設定を加えています．
otp_app:, crate:はRustlerで設定したものと同じです．base_url:はプロジェクトのgithubのURLに置き換えてください．targetsはクロスビルド対象となるアーキテクチャを指定しますが，とりあえずはデフォルトで大丈夫だと思います．多すぎる場合は適宜削ってください．

この変更を加えると以降のビルドはgithubのリリースからプリコンパイル済みのライブラリを取ってこようとしてコケます．ので，しばらくはForce Buildを有効にするため環境変数```RUSTLER_PRECOMPILATION_EXAMPLE_BUILD=1```を定義しておいてください．

## Github Actionsの導入
各アーキテクチャとNIFバージョン毎のクロスビルドはCIを前提としています．
手元でもできなくもないですが，辛いだけなのでGithub actionsに頼りましょう．

ワークフローを定義します．基本的に参照元を丸っとコピーしていますが，今回のプロジェクトと相性の悪かったアーキテクチャのjobは除外しています．変更が必要なのは，project-name:, project-dir:の二か所だけでいいはずです．
なお，Github Actions全体の設定のWorkflow permissionをRead and Write Permissionにしておく必要があります．

なお，今回は特定のバージョンv*（ex: v0.0.1）のtagを切ってpushしたときに駆動するようにしています．結構重い処理ですし，ハッシュが異なると動かなくなるような代物なので，リリース毎に作成するくらいでいいと思います．
```yml:.github/workflows/nif_precompile.yml
name: Build precompiled NIFs

on:
  push:
    tags:
      - 'v*'

jobs:
  build_release:
    name: NIF ${{ matrix.nif }} - ${{ matrix.job.target }} (${{ matrix.job.os }})
    runs-on: ${{ matrix.job.os }}
    strategy:
      fail-fast: false
      matrix:
        nif: ["2.16", "2.15"]
        job:
          - { target: arm-unknown-linux-gnueabihf , os: ubuntu-20.04 , use-cross: true }
          - { target: aarch64-unknown-linux-gnu   , os: ubuntu-20.04 , use-cross: true }
          - { target: aarch64-apple-darwin        , os: macos-11      }
          - { target: x86_64-apple-darwin         , os: macos-11      }
          - { target: x86_64-unknown-linux-gnu    , os: ubuntu-20.04  }
          - { target: x86_64-unknown-linux-musl   , os: ubuntu-20.04 , use-cross: true }
          - { target: x86_64-pc-windows-gnu       , os: windows-2019  }
          - { target: x86_64-pc-windows-msvc      , os: windows-2019  }

    steps:
    - name: Checkout source code
      uses: actions/checkout@v3

    - name: Extract project version
      shell: bash
      run: |
        # Get the project version from mix.exs
        echo "PROJECT_VERSION=$(sed -n 's/^  @version "\(.*\)"/\1/p' mix.exs | head -n1)" >> $GITHUB_ENV
    - name: Install Rust toolchain
      uses: dtolnay/rust-toolchain@stable
      with:
        toolchain: stable
        target: ${{ matrix.job.target }}

    - name: Build the project
      id: build-crate
      uses: philss/rustler-precompiled-action@v1.0.0
      with:
        project-name: nifzenoh
        project-version: ${{ env.PROJECT_VERSION }}
        target: ${{ matrix.job.target }}
        nif-version: ${{ matrix.nif }}
        use-cross: ${{ matrix.job.use-cross }}
        project-dir: "native/nifzenoh"

    - name: Artifact upload
      uses: actions/upload-artifact@v3
      with:
        name: ${{ steps.build-crate.outputs.file-name }}
        path: ${{ steps.build-crate.outputs.file-path }}

    - name: Publish archives and packages
      uses: softprops/action-gh-release@v1
      with:
        files: |
          ${{ steps.build-crate.outputs.file-path }}
      if: startsWith(github.ref, 'refs/tags/')
```

上記workflowをCommitしたら，試しにTagを作成し，一緒にPushしてみてください．無事ワークフローが完了すれば，Relaseに各環境向けのプリコンパイル済みのライブラリがアップロードされます．

## Hex Releaseの設定
Rustler Precompiledでは，利用される環境でビルドする時に自動的にバージョンや利用環境に応じたプリコンパイル済みのRustのライブラリを自動的にダウンロードして組み込んでくれます．この時，ライブラリのElixir側と自動取得するRustライブラリのバージョンや内容に不整合があると予期せぬ動作となってしまいます．
Rustler Precompiledでは，Hexに公開するライブラリにGithubに公開したRustライブラリのハッシュ値を同梱することで不整合や改ざんを防いでいます．

この処理のためのコマンドも用意されており，現時点の設定を踏まえて自動的にGithubに公開したライブラリ一式を取得してハッシュ値を算出し，ファイルに書き出してくれます．
```
mix rustler_precompiled.download YourRustlerModule --all --print
# YourRustlerModuleの部分は自身の環境に合わせて，Rustlerを利用しているElixirのモジュール名を指定してください．
```
私の環境では，```checksum-Elixir.NifZenoh.exs```というファイルが生成されました．中を見るとGithubに公開したライブラリ一式の名前とハッシュ値が含まれています．
このファイルがHexへ公開するパッケージに含まれるようにmix.exsを変更します．
```elixir:mix.exs
  defp package() do
    [
      name: "zenohex",
      files: [
        "lib",
        "native/nifzenoh/.cargo",
        "native/nifzenoh/src",
        "native/nifzenoh/Cargo*",
        "checksum-*.exs",            <-- 追加
        "mix.exs"
      ],
```

これで公開準備が整いました．```mix hex.publish```で公開して完了です．

別途プロジェクトを作成し，公開したライブラリをdepsに追加してみてください．ビルド時にRustのコンパイル無しに動作すれば成功です．

## 公開手順まとめ
- gitのプロジェクトをリリースできる状況にする
- v*.*.*のタグを付与して，GithubにPushする
- Github Actionsが完了しプリコンパイル済みのライブラリが公開されていることを確認
- mix rustler_precompiled.downloadでChecksumファイルを生成する
- Checksumファイルを含めてmix hex.publishする

---
以上，Rustler Precompiledの使い方でした．
できれば後半の作業もすべてGithub Actions上に持っていきたいところなのですが，まだいい方法に落ち着いていません．導入例などがあれば教えて頂けるととても助かります．