R言語で実装されたシステムのCI構築

## システム化するということ

R言語は、データ分析や統計モデリングにおいて非常に強力なツールですが、分析を単に行う場合と、その実装をシステム化する際には、求められるものが大きく異なります。単なる分析では、分析コードが実行できれば十分ですが、システム化する場合には、コードの品質、再現性、保守性の確保が不可欠となります。

R言語での実装をシステム化する際に、エンジニアが特に意識すべきものとして
- パッケージ管理
- lint & format
- test
の3つの側面について解説していきます。

## サンプルリポジトリ

本記事で記載している実装を実際にリポジトリに書いているので、こちらを参考にしながら読んでいただくと幸いです。
https://github.com/Yashikab/sample_r_system

## ディレクトリ構成

今回のサンプルリポジトリでは下記のディレクトリ構成になっています。

```bash
.
├── Dockerfile
├── Makefile
├── README.md
├── hack
│   ├── check_status.R
│   ├── fmt.R
│   ├── install.R
│   ├── lint.R
│   ├── synchronize.R
│   └── test.R
├── renv
│   ├── activate.R
│   ├── library
│   ├── settings.json
│   └── staging
├── renv.lock
└── src
    ├── fibonacci.R
    ├── sample.R
    └── tests
```

- `hack`ディレクトリ: 以下で紹介するCIで実行されるスクリプトを格納します
- `src` ディレクトリ: コードの実装とテストはすべてここで行います。`hack`スクリプトのチェック対象ディレクトリです
- `renv`ディレクトリ: パッケージ管理ツールの`renv`を初期化すると作られるディレクトリです
- `renv.lock`ファイル: `renv`のステータスを管理するlockファイルです

### リポジトリをクローンしたときのまずやること

`make install`を実行することで `renv.lock`のステータスのライブラリバージョンのインストールなどができ、リポジトリのライブラリ状態でローカルの環境構築ができます。
ただ、R自体のバージョンは合わせておく必要があります。(ここも管理できるようにしたいですが僕の力ではわからなかった。。)

## パッケージ管理

### パッケージ管理の必要性

R言語のエコシステムは非常に活発であり、数多くのパッケージが開発されています。これらのパッケージを活用することで、複雑なデータ分析を効率的に行うことができます。しかし、パッケージのバージョンが異なることで、コードの動作が不安定になったり、再現性が失われたりする問題が発生する可能性があります。

また、チームで共同で開発を行う場合、メンバー間で使用するパッケージのバージョンが異なることで、環境の差異が生じ、エラーの原因となることがあります。

### Rでのパッケージ管理

Rのパッケージ管理ツールはいくつかありますが、今回は `renv`を利用したいと思います。
renv は、Rのパッケージ管理ツールです。プロジェクトごとにパッケージのバージョンを固定し、再現性のある開発環境を構築することができます。
- snapshot: プロジェクトの現在のライブラリ状態を「lockファイル」に保存します。このファイルは、プロジェクトの依存関係を記録したもので、いわば「レシピ」のようなものです。
- restore: lockファイルの内容に基づいて、プロジェクトのライブラリ環境を再現します。これにより、異なるマシンや、時間の経過によっても、同じ開発環境を構築することができます。

renvの公式ページは[こちら](https://rstudio.github.io/renv/articles/renv.html)を参考にしてください。
また下記のqiita記事が大変わかりやすく参考にさせていただきました。ありがとうございますmm
(参考: https://qiita.com/okiyuki99/items/688a00ca9a58e42e3bfa)

#### 同期チェックスクリプト

システム化する上で、ライブラリが変更された際に同期されている必要がありますが、これを忘れると意味がなくなります。そこで同期がとれているか確認するスクリプトを用意してCI実行時に同期されていない場合は失敗するようにします。

```R:hack/check.R
# 同期されているかチェックするスクリプト
library("renv")
# get status
status <- renv::status()

# check if there are any changes
if (status$synchronize) {
    message("There are no changes in the project library.")
    quit(status = 0)
} else {
    # If there are some change, report it and exit with error
    message("There are changes in the project library.")
    stop("Please run 'renv::snapshot()' to save the changes.")
}
```

#### 同期用のスクリプト

snapshotを実行して同期をとるスクリプトも用意します。

```R:hack/synchronize.R
# snapshotをとるスクリプト
library("renv")

# install on uninstalled packages
renv::install()

# take snapshot
renv::snapshot()
```

### installスクリプト

[リポジトリをクローンしたときのまずやること](#リポジトリをクローンしたときのまずやること)でも書きましたが、ローカルの環境構築をrenvを使って構築するためのスクリプトです。
中身はただ `renv::restore()`しているだけです。

```R:hack/install.R
library("renv")
renv::restore()
```

## Lint & Format

どの言語にも言えることですが、Lint & Formatは、コードの品質を保ち、開発効率を向上させるために非常に重要な役割を果たします。

### Lintの実装

`lintr` を利用してLintを記述します。

```R:hack/lint.R
library("lintr")

result <- lintr::lint_dir("src")

if (length(result) > 0 ) {
    print(result)
    stop("Linting failed")
} else {
    print("Linting passed")
}
```

### Formatの実装

`styler` を使用してformatterを実装します。

```R:hack/format.R
library("styler")

# format on src directory
styler::style_dir("src")
```

## Test

単体テストを実装することで、コード品質を担保しましょう。
テスト自体は `src/tests` フォルダに書かれることを想定して、これらをCIで実行されるようなスクリプトを用意しておきます。

```R:hack/test.R
library("testthat")

test_dir("src/tests")
```

## makeファイルの用意

今回はあまり必要性ないですが、他言語の管理もやるときなどはmakefileを使ってまとめたほうがCI実装時に役立ちます。

```makefile:Makefile
fmt:
	Rscript hack/fmt.R

lint:
	Rscript hack/lint.R

test:
	Rscript hack/test.R

check_R:
	Rscript hack/check_status.R

sync_R:
	Rscript hack/synchronize.R

install:
	Rscript hack/install.R
```

githubへのpush前に `make sync_R` を実行して常にライブラリの同期をとることを開発ルールに定めましょう。

## CI実装

同期チェックをCIに組み込むことで同期を忘れたままシステムがデプロイされることを防ぎます。
今回は、github actionsをCI環境として利用し、組み込み方法を2つ紹介します。

### github actionsのrunnerにR環境を構築してCI実行

actionsのライブラリを使用してR環境をrunnerに入れます。
これは github actionsを利用する場合しか参考にならないですが、こっちのほうが実行速度は早いのと手順は簡単です。
以下のようにworkflowを用意します。

```yaml
name: Renv use directory flow
on:
  push:


jobs:
  ci-check:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install R
        uses: r-lib/actions/setup-r@v2
        with:
          r-version: '4.4.1'

      - name: Install renv
        uses: r-lib/actions/setup-renv@v2

      - name: Check status
        run: |
          make check_R

```

### dockerコンテナにCI環境を入れる

dockerイメージにCI環境をつくって、github actionsでコンテナを実行するパターンです。
github actions以外のCIツールにも適用しやすいですが、dockerビルドに時間がかかり、少し遅いです。

まず Dockerfileをmultistageで用意します。
renvパッケージを引き継ぐには、renvフォルダをコピーし、cacheのパスを環境変数で指定します。

```dockerfile
# r-base
FROM r-base:4.4.1 AS base
RUN apt-get update \
    && apt-get install -y --no-install-recommends libxml2-dev \
    && rm -rf /var/lib/apt/lists/*

RUN R -e "install.packages('renv')"

RUN mkdir -p /basedir
WORKDIR /basedir
RUN mkdir -p renv
COPY renv.lock renv.lock
COPY .Rprofile .Rprofile
COPY renv/activate.R renv/activate.R
COPY renv/settings.json renv/settings.json

RUN mkdir renv/.cache
ENV RENV_PATHS_CACHE=renv/.cache
RUN R -e "renv::restore()"


FROM base AS ci

ENV GITHUB_WOKRDIR=/__w/sample_r_system/sample_r_system
RUN mkdir -p /${GITHUB_WOKRDIR}

ENV WORKSPACE=ci
RUN mkdir -p /${WORKSPACE}
WORKDIR /${WORKSPACE}
COPY --from=base /basedir .

COPY hack /${WORKSPACE}/hack
COPY Makefile /${WORKSPACE}/Makefile

ENTRYPOINT [ "make" ]
```
github actionsでは dockerのtarget ciを dockerhubにビルドしてこれを使って `make check_R` を実行します。

```yaml
name: R sample flow
on:
  push:

jobs:
  ci-image-build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to DockerHub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: yashikab/r-sample-ci:latest
          target: ci
          cache-from: type=registry,ref=yashikab/r-sample-ci:buildcache
          cache-to: type=registry,ref=yashikab/r-sample-ci:buildcache,mode=max

  ci-check:
    needs: ci-image-build
    runs-on: ubuntu-latest
    container:
      image: yashikab/r-sample-ci:latest
      credentials:
        username: ${{ secrets.DOCKERHUB_USERNAME }}
        password: ${{ secrets.DOCKERHUB_TOKEN }}
    defaults:
      run:
        working-directory: /ci
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: copy sources
        run: cp -r ${GITHUB_WORKSPACE}/src /ci/src
      - name: Check R status
        run: make check_R
```

## 開発風景

### パッケージ同期忘れ

githubでPRを作って何かを開発したとします。
ためしに、`sample2.R`に `stringr`ライブラリを読み込んだ場合を考えます。
このとき分析者のローカルにすでに`stringr`が入っていれば、動作確認では動きます。
そのまま同期を忘れてrenvの管理対象ではないまま githubにコードpushしたとします。
その場合は、`check_R`によってCIが失敗し、同期されていないことに気づくことができます。

加えたRスクリプト
```R:sample2.R
library("stringr")

x <- "Hello World"

print(stringr::str_length(x))
```
```bash
$ R -e 'install("stringr")'
$ Rscript src/sample2.R

# ----- 出力結果 ----
# - The project is out-of-sync -- use `renv::status()` for details.
# [1] 11
```

このまま `make sync_R` せずにコードpushするとCIが落ちる。
https://github.com/Yashikab/sample_r_system/actions/runs/10362721271/job/28685177409
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407322/f25e8ca6-6fb6-ca31-d09e-995ef61c7b0a.png">

ローカルで `make sync_R`を実行すると`renv.lock`が更新されて再度pushするとCIが通る。
https://github.com/Yashikab/sample_r_system/actions/runs/10362775516

```bash
$ make sync_R

# ---- 出力結果 ----
# Rscript hack/synchronize.R
# - The project is out-of-sync -- use `renv::status()` for details.
# 
# 次のパッケージを付け加えます: ‘renv’
# 
# 以下のオブジェクトは ‘package:stats’ からマスクされています:
#
#    embed, update
#
# 以下のオブジェクトは ‘package:utils’ からマスクされています:
# 
#     history, upgrade
#
# 以下のオブジェクトは ‘package:base’ からマスクされています:
#
#    autoload, load, remove, use
#
# - There are no packages to install.
# The following package(s) will be updated in the lockfile:

# # CRAN -----------------------------------------------------------------------
# - stringi   [* -> 1.8.4]
# - stringr   [* -> 1.5.1]

# - Lockfile written to "~/github/sample_r_system/renv.lock".
```

参考PR: https://github.com/Yashikab/sample_r_system/pull/1


### lintエラー

Rではコードをコメントアウトで残したままにするとコード規約違反になるようです。
ためしに、コードの一部をコメントアウトしてgithubにpushします。
その場合、LintチェックでCIが失敗します。
`src/sample.R`でコードをコメントアウトで追加します。

```R:sample.R
print("Hello World")
# print("Hey!")
```

この状態でpushするとCIが失敗します。
https://github.com/Yashikab/sample_r_system/actions/runs/10362841396/job/28685491998
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407322/b917d7aa-f76c-cf66-46e9-97e122b75c80.png">

参考PR: https://github.com/Yashikab/sample_r_system/pull/2

### Test

testに関しても同様にCIで管理されていますが、ちょっと書くのが面倒になったので割愛します笑

## 参考

- 私のサンプルリポジトリ: https://github.com/Yashikab/sample_r_system
- renv: https://rstudio.github.io/renv/articles/renv.html
  - renvのCIへの組み込み方法: https://rstudio.github.io/renv/articles/ci.html
  - renvのDockerへの組み込み方法: https://rstudio.github.io/renv/articles/docker.html
- renvに関して参考にさせていただいたqiita記事: https://qiita.com/okiyuki99/items/688a00ca9a58e42e3bfa