WSL2 + Ubuntu 22.04.1 LTS上のDjangoアプリを、JetBrains Gateway + PyCharmにて開発し、Herokuにpushできるようにしてみた

しばらく前に ThinkPad P14s AMD Gen2 を購入し、使い始めました。

一通りのPCセットアップができたため、次は開発環境を構築することにしました。

そこで、WSL2 + Ubuntu 22.04 上に、過去作成したDjangoアプリの開発環境を構築してみたため、流れをメモに残します。

 
目次

 

環境

  • ThinkPad P14s AMD Gen2 の状態
    • OS Windows11
    • CPU AMD Ryzen 7 PRO 5850U
    • メモリ 48GB
      • 16GB + 後付で32GB
    • ディスク
      • SSD SKHynix 512GB HFS512GDE9X081N
    • 構築済の開発環境
  • PyCharm Professionalライセンス

 

この記事でやること

WSL2 + Ubuntu 22.04.1 LTS上で、Djangoアプリの開発を行えるようにします。

具体的には以下のとおりです。

   

Windows Terminalのセットアップ

Windows Terminal のインストール

Windows上で Windows Terminal アプリを検索したところ、アプリ自体は出てきたものの、クリックしても起動しない状態でした。

そこで、以下のページ経由で Windows Store を開いて、 Windows Terminal をインストールしました。
Windows ターミナルのインストール | Microsoft Docs

 

Windows Terminal の設定変更

Windows Terminalのデフォルトは PowerShell になっていました。

ただ、Windowsをメインで使っていた時期の関係上、PowerShellよりもコマンドプロンプトに慣れていることから、デフォルトをコマンドプロンプトにします。

 
Windows Terminal を起動し、 Ctrl + , で設定を開き、以下の内容で設定します。

項目
既存のプロファイル コマンドプロンプト
既存のターミナルアプリケーション Windowsターミナル

 
また、Windows Terminalで exit を入力したときにWindows Terminalを閉じてほしいことから、 設定 > 既定値 > 詳細設定 より、以下の設定にします。

項目
プロファイルの終了動作 プロセスの終了、失敗、クラッシュ時に閉じる

 

WSL2のセットアップ

Windows Terminal の準備ができたので、次はWSL2をセットアップします。

 

WSL2 のインストール

以下の記事に従い、使用可能なディストリビューションを確認します。

Windows Terminal を管理者で開き、 wsl --list --online を実行します。

>wsl --list --online

インストールできる有効なディストリビューションの一覧を次に示します。
既定の分布は ' * ' で表されます。
 'wsl --install -d <Distro>'を使用してインストールします。

  NAME            FRIENDLY NAME
* Ubuntu          Ubuntu
  Debian          Debian GNU/Linux
  kali-linux      Kali Linux Rolling
  openSUSE-42     openSUSE Leap 42
  SLES-12         SUSE Linux Enterprise Server v12
  Ubuntu-16.04    Ubuntu 16.04 LTS
  Ubuntu-18.04    Ubuntu 18.04 LTS
  Ubuntu-20.04    Ubuntu 20.04 LTS

 
wsl --list --online には、Ubuntu 22.04 がまだ来ていないようです。

そのため、今回は wsl --install でのセットアップを諦め、以下の手順を参考にして、手動で作業を進めます。

 

Windowsの機能の有効化

Windows11のメニュー構成に慣れていないため、直接 Windowsの機能の有効化または無効化 を開きます。

Win + ROptionalFeatures.exe を入力します。
参考: Windows 10ミニTips(158) コマンドラインからWindows 10の機能を有効・無効にする | マイナビニュース

 
以下の2つにチェックを入れます。

画面に従い、Windowsを再起動します。

 

WSLの状態を確認する

Windows Terminal を管理者として起動し、 wsl --help を入力すると、ヘルプの内容が増えていました。

次に、 wsl --status で現在の状態を確認します。

>wsl --status
既定のバージョン: 2

Windows Subsystem for Linux カーネルは、'wsl --update' を使用して手動で更新できますが、システム設定が原因で自動更新が発生することはありません。
カーネルの自動更新を受け取るには、 Windows Update の設定を有効にしてください:' Windowsの更新に、その他のMicrosoftの製品の更新情報を受け取る'。
詳細については、 https://aka.ms/wsl2kernel. を参照してください
WSL 2 カーネル ファイルが見つかりません。カーネルを更新または復元するには、'wsl.exe --update' を実行してください。

 
既定のバージョンが 2 になっているようです。そのため、 wsl --set-default-version 2 な設定は不要そうでした。

一方、WSL2のカーネルが見つからないようなので、画面の表示に従い wsl --update を実行します。

>wsl --update
更新をチェック中...
更新をダウンロード中...
更新をインストール中...
この変更は、次回の WSL 再起動時に有効になります。強制的に再起動するには、'wsl --shutdown' を実行してください。
カーネル バージョン: 5.10.102.1

 
画面にある通り、WSL2を強制的に再起動します。

>wsl --shutdown

 
再度 status を確認してみると、カーネルがインストールされたようです。

>wsl --status
既定のバージョン: 2

Linux 用 Windows サブシステムの最終更新日: 2022/09/06
Windows Subsystem for Linux カーネルは、'wsl --update' を使用して手動で更新できますが、システム設定が原因で自動更新が発生することはありません。
カーネルの自動更新を受け取るには、 Windows Update の設定を有効にしてください:' Windowsの更新に、その他のMicrosoftの製品の更新情報を受け取る'。
詳細については、 https://aka.ms/wsl2kernel. を参照してください
カーネル バージョン: 5.10.102.1

 
カーネルがインストールされたので、念のため wsl --list --online を実行しますが、Ubuntu 22.04 は表示されませんでした。

>wsl --list --online
インストールできる有効なディストリビューションの一覧を次に示します。
'wsl --install -d <Distro>' を使用してインストールします。

NAME            FRIENDLY NAME
Ubuntu          Ubuntu
Debian          Debian GNU/Linux
kali-linux      Kali Linux Rolling
openSUSE-42     openSUSE Leap 42
SLES-12         SUSE Linux Enterprise Server v12
Ubuntu-16.04    Ubuntu 16.04 LTS
Ubuntu-18.04    Ubuntu 18.04 LTS
Ubuntu-20.04    Ubuntu 20.04 LTS

 
そこで、 Microsoft Storeよりダウンロードして Ubuntu 22.04 をインストールすることにします。

 

WSL2にUbuntuをインストール

Microsoft StoreよりUbuntu 22.04.1 LTS をダウンロード

Microsoft Storeにて ubuntu を検索すると複数の結果が表示されます。今回は Ubuntu 22.04.1 LTS を選びます。

Ubuntu 22.04.1 LTS のダウンロードが終わったところで、表示されている 開く をクリックします。

 

Ubuntu 22.04.1 LTS をインストール

Ubuntu 22.04.1 LTSのインストーラーが起動します。ただ、インストーラーが文字化けしていました。

いちおう画面はそれとなく分かるようになっていたため、以下のように画面ごとの操作をしました。

  • 言語選択っぽい画面
    • Japanese を選択
      • クリックすると、 Select your language の表示になった
    • 画面の右下にカーソルを合わせるとポインタの表示が変わる部分があるので、クリック
      • ボタンが隠れているらしい
  • ユーザー情報の入力画面 (Profile setup)
    • 以下の項目について、適切な内容を入力。入力後は、また右下のボタンのありそうな位置をクリック
      • your name
      • Pick a username
      • Choose a password
      • Confrim your password
  • Advanced setup
    • デフォルトの内容のままにして、右下のボタンのありそうな位置をクリック
    • Mount location
      • デフォルト通り、 /mnt/ のまま
    • Mount option
      • デフォルト通り、空白
    • Enable Host Generation
      • デフォルト通り、チェックを入れたままにする
    • Enable resolv.conf Generation
      • デフォルト通り、チェックを入れたままにする

以上の設定を終えると、 Setup complete 画面になります。

その画面の裏側で設定を適用しているようで、画面がローディング表示になるためしばらく待ちます。

 
設定の適用が完了すると、

Hi, <user_name> You have successfully completed the setup.

It is suggested to run the following command to update Ubuntu to the latest version:

$ sudo apt update $ sudo apt upgrade

  • All settings will take effect after restarting Ubuntu

と表示されました。

そこで、右下のボタンがありそうな位置をクリックすると、ダイアログが閉じました。

 
なお、裏側では別のWindows Terminalが起動しており

Installation successful!
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

Welcome to Ubuntu 22.04.1 LTS (GNU/Linux 5.10.102.1-microsoft-standard-WSL2 x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage
/etc/update-motd.d/50-landscape-sysinfo: 17: cannot create /var/lib/landscape/landscape-sysinfo.cache: Permission denied
27 updates can be applied immediately.
7 of these updates are standard security updates.
To see these additional updates run: apt list --upgradable



This message is shown once a day. To disable it please create the
/home/<user_name>/.hushlogin file.

のような内容が表示されています。

 

WSL2にインストールされたUbuntuを確認

続いて、管理者のWindows Terminalを起動し wsl --list を入力すると、Ubuntu-22.04 が認識されていました。

>wsl --list
Linux 用 Windows サブシステム ディストリビューション:
Ubuntu-22.04 (既定)

 
WSLのバージョンも合わせて確認します。WSL2で設定されているようです。

>wsl -l -v
  NAME            STATE           VERSION
* Ubuntu-22.04    Running         2

 
ここまでで、WSL2にUbuntu22.04.1 LTSをインストールできました。

 

Ubuntuのアップデート

別途開かれた Installation successful! と表示されているWindows Terminalを使い、Ubuntuをアップデートします。

$ sudo apt -y update
[sudo] <user> のパスワード:
ヒット:1 http://archive.ubuntu.com/ubuntu jammy InRelease
ヒット:2 http://security.ubuntu.com/ubuntu jammy-security InRelease
ヒット:3 http://archive.ubuntu.com/ubuntu jammy-updates InRelease
ヒット:4 http://archive.ubuntu.com/ubuntu jammy-backports InRelease
取得:5 http://archive.ubuntu.com/ubuntu jammy/main Translation-ja [295 kB]
取得:6 http://archive.ubuntu.com/ubuntu jammy/universe Translation-ja [1,534 kB]
取得:7 http://archive.ubuntu.com/ubuntu jammy/multiverse Translation-ja [7,160 B]
1,837 kB を 4秒 で取得しました (471 kB/s)
パッケージリストを読み込んでいます... 完了
依存関係ツリーを作成しています... 完了
状態情報を読み取っています... 完了
アップグレードできるパッケージが 27 個あります。表示するには 'apt list --upgradable' を実行してください。

 
引き続き upgrade の方も実行します。

$ sudo apt -yV upgrade

 
これで Ubuntu 22.04.1 LTSが最新化されました。

 

WSL2の設定

続いて、WSL2の設定を行います。

 

設定方針

メモリサイズ固定について

WSL2の場合、Windowsホストのメモリが枯渇してしまう問題があるようです。
WSL2によるホストのメモリ枯渇を防ぐための暫定対処 - Qiita

ただ、

現在のWSL2ではデフォルトのメモリサイズがPC搭載メモリの50%または8GBのうち、少ない方の値に変更された

とのことなので、今のところは何も設定せず様子を見ようと思います。

 

プロセッサ数について

WSL2のデフォルトでは、プロセッサ数は

The same number of processors on Windows

https://docs.microsoft.com/en-us/windows/wsl/wsl-config#configuration-setting-for-wslconfig

のようです。

ただ、JetBrains Gateway 2022.2.2 RC では、Windowsと同じプロセッサ数のままだとCPU使用率が100%になってしまいました。

そこで、プロセッサ数は、実機の半分(今回の場合 8 ) にしてみます。

 

WSL2の設定を変更する

Windows上で %USERPROFILE%\.wslconfig ファイルを作成します。

公式ドキュメントに従い設定を行います。なお、日本語版のドキュメントだと key も日本語翻訳されているので注意が必要です。
Configuration setting for .wslconfig | Advanced settings configuration in WSL | Microsoft Docs

今回はCPUの数を8に固定するため、以下の設定となります。

[wsl2]
processors=8

 
設定を適用するために、以下のコマンドを入力してWSL2をシャットダウンします。

wsl --shutdown

その後 Ubuntu 22.04.1 LTS をクリックすることで、WSL2が起動します。

 

WSL2上にDockerをセットアップ

WSL2上にDockerをインストール

今回、DockerはWSL2上で起動します。そこで、以下を参考にWSL2にDockerをインストールします。

# 1. Set up the repository
## install packages to allow apt to use a repository over HTTPS
$ sudo apt install -y ca-certificates curl gnupg lsb-release
パッケージリストを読み込んでいます... 完了
依存関係ツリーを作成しています... 完了
状態情報を読み取っています... 完了
ca-certificates はすでに最新バージョン (20211016) です。
ca-certificates は手動でインストールしたと設定されました。
lsb-release はすでに最新バージョン (11.1.0ubuntu4) です。
lsb-release は手動でインストールしたと設定されました。
curl はすでに最新バージョン (7.81.0-1ubuntu1.4) です。
curl は手動でインストールしたと設定されました。
gnupg はすでに最新バージョン (2.2.27-3ubuntu2.1) です。
gnupg は手動でインストールしたと設定されました。
以下のパッケージが自動でインストールされましたが、もう必要とされていません:
  libfreetype6
これを削除するには 'sudo apt autoremove' を利用してください。
アップグレード: 0 個、新規インストール: 0 個、削除: 0 個、保留: 0 個。


# 2. Add Docker’s official GPG key:
## 以下の2つのコマンドは、特に結果は表示されず
$ sudo mkdir -p /etc/apt/keyrings

$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

$ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list


# Install Docker Engine
## 1. Update the apt package index
$ sudo apt update
取得:1 https://download.docker.com/linux/ubuntu jammy InRelease [48.9 kB]
取得:2 https://download.docker.com/linux/ubuntu jammy/stable amd64 Packages [6,439 B]
ヒット:3 http://security.ubuntu.com/ubuntu jammy-security InRelease
ヒット:4 http://archive.ubuntu.com/ubuntu jammy InRelease
ヒット:5 http://archive.ubuntu.com/ubuntu jammy-updates InRelease
ヒット:6 http://archive.ubuntu.com/ubuntu jammy-backports InRelease
55.3 kB を 1秒 で取得しました (60.2 kB/s)
パッケージリストを読み込んでいます... 完了

## install the latest version of Docker Engine, containerd, and Docker Compose
$ sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
パッケージリストを読み込んでいます... 完了
依存関係ツリーを作成しています... 完了
状態情報を読み取っています... 完了
以下のパッケージが自動でインストールされましたが、もう必要とされていません:
docker-scan-plugin (0.17.0~ubuntu-jammy) を展開しています...
以前に未選択のパッケージ libltdl7:amd64 を選択しています。
.../7-libltdl7_2.4.6-15build2_amd64.deb を展開する準備をしています ...
libltdl7:amd64 (2.4.6-15build2) を展開しています...
以前に未選択のパッケージ libslirp0:amd64 を選択しています。
.../8-libslirp0_4.6.1-1build1_amd64.deb を展開する準備をしています ...
libslirp0:amd64 (4.6.1-1build1) を展開しています...
以前に未選択のパッケージ slirp4netns を選択しています。
.../9-slirp4netns_1.0.1-2_amd64.deb を展開する準備をしています ...
slirp4netns (1.0.1-2) を展開しています...
docker-scan-plugin (0.17.0~ubuntu-jammy) を設定しています ...
containerd.io (1.6.8-1) を設定しています ...
Created symlink /etc/systemd/system/multi-user.target.wants/containerd.service → /lib/systemd/system/containerd.service.
docker-compose-plugin (2.6.0~ubuntu-jammy) を設定しています ...
libltdl7:amd64 (2.4.6-15build2) を設定しています ...
docker-ce-cli (5:20.10.17~3-0~ubuntu-jammy) を設定しています ...
libslirp0:amd64 (4.6.1-1build1) を設定しています ...
pigz (2.6-1) を設定しています ...
docker-ce-rootless-extras (5:20.10.17~3-0~ubuntu-jammy) を設定しています ...
slirp4netns (1.0.1-2) を設定しています ...
docker-ce (5:20.10.17~3-0~ubuntu-jammy) を設定しています ...
Created symlink /etc/systemd/system/multi-user.target.wants/docker.service → /lib/systemd/system/docker.service.
Created symlink /etc/systemd/system/sockets.target.wants/docker.socket → /lib/systemd/system/docker.socket.
invoke-rc.d: could not determine current runlevel
man-db (2.10.2-1) のトリガを処理しています ...
libc-bin (2.35-0ubuntu3.1) のトリガを処理しています ...
Scanning processes...
Scanning processor microcode...
Scanning linux images...

Failed to retrieve available kernel versions.

The processor microcode seems to be up-to-date.

No services need to be restarted.

No containers need to be restarted.

No user sessions are running outdated binaries.

No VM guests are running outdated hypervisor (qemu) binaries on this host.

 

Dockerの設定変更

WSL2 + Ubuntu 22.04では iptables を legacy に差し替える

通常であれば sudo service docker start して docker run hello-world すれば hello world コンテナが起動します。

しかし、WSL2 + Ubuntu 環境のデフォルトのままだと、 sudo service docker start してOKと表示されても、docker run に失敗します。

# 起動したっぽく表示される
$ sudo service docker start
 * Starting Docker: docker                                                                                       [ OK ]

# しかし docker run しようとしても失敗する
$ docker run hello-world
docker: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?.
See 'docker run --help'.

 
原因は以下のようです。

The docker installer uses iptables for nat. Unfortunately Debian uses a modified version of nftables. You can convert the entries over to nftables or just setup Debian to use the legacy iptables.

Failed to start service Docker on WSL2 · Issue #485 · WhitewaterFoundry/Pengwin

 
そこで、以下を参考に、iptableをlegacyに切り替えます。
WSL2 (Ubuntu 20.04) + docker が動作しなかったことと解決策 - Qiita

# 切り替え
$ sudo update-alternatives --set iptables /usr/sbin/iptables-legacy
update-alternatives: /usr/sbin/iptables (iptables) を提供するためにマニュアルモードで /usr/sbin/iptables-legacy を使います

$ sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-legacya
update-alternatives: /usr/sbin/ip6tables (ip6tables) を提供するためにマニュアルモードで /usr/sbin/ip6tables-legacy を使 います


# 設定が切り替わったかの確認
$ sudo update-alternatives --config iptables
alternative iptables (/usr/sbin/iptables を提供) には 2 個の選択肢があります。

  選択肢    パス                     優先度  状態
------------------------------------------------------------
  0            /usr/sbin/iptables-nft      20        自動モード
* 1            /usr/sbin/iptables-legacy   10        手動モード
  2            /usr/sbin/iptables-nft      20        手動モード

現在の選択 [*] を保持するには <Enter>、さもなければ選択肢の番号のキーを押してください:

 
設定が切り替わったので、再度Dockerの動作確認を行います。

# Dockerのサービスを起動
$ sudo service docker start
 * Starting Docker: docker    [ OK ]

# hello-worldコンテナを起動
$ sudo docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
...

うまく動作しているようです。

 

dockerグループへの追加とIPアドレス帯変更

以下の記事を参考に、引き続き作業を行います。
Docker Desktopを使わずにWindowsでDocker | IIJ Engineers Blog

  • dockerグループへの追加
    • sudo usermod -aG docker (ユーザ名)
  • Docker bridge networkのIPアドレス帯変更
    • /etc/docker/daemon.json を作成
    • sudo service docker restart

 
なお、Dockerの自動起動については、後ほど /usr/libexec/wsl-systemd による systemd での自動起動により対応します。そのため、ここでは設定しません。

 

WSL2上にGithub環境の構築

以下を参考に、WSL2にGithub環境を構築します。
WSL2(Ubuntu)でGitHubを使用する - Qiita

 

Gitの設定

WSL2では、まだGitの設定を行っていないため、グローバル設定から行います。

$ git config --global user.name <ユーザ名>
$ git config --global user.email <メアド>
$ git config --global pull.rebase false

 

Github用のSSH鍵を生成

Githubの公式ドキュメントを読みながら、SSH鍵を生成します。
Generating a new SSH key and adding it to the ssh-agent - GitHub Docs

なお、 ssh-keygen する時の t オプション、以前は rsa と書かれていましたが、現在は ed25519 になっています。

$ ssh-keygen -t ed25519 -f ~/.ssh/id_github_ed25519

Generating public/private ed25519 key pair.
Created directory '/home/user/.ssh'.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/user/.ssh/id_github_ed25519
Your public key has been saved in /home/user/.ssh/id_github_ed25519.pub

 

鍵をkeychainで管理

ssh-agent でも管理できますが、準備が大変そうでした。
WSL2のUbuntuでkeychain経由でssh-agentを使う

そこで、上記の記事を参考に、 keychain を使って管理することにしました。

 
aptでkeychainをインストールします。

$ sudo apt install keychain
[sudo] <user> のパスワード:
パッケージリストを読み込んでいます... 完了
依存関係ツリーを作成しています... 完了
状態情報を読み取っています... 完了
以下のパッケージが自動でインストールされましたが、もう必要とされていません:
  libfreetype6
これを削除するには 'sudo apt autoremove' を利用してください。
提案パッケージ:
  ssh-askpass
以下のパッケージが新たにインストールされます:
  keychain
アップグレード: 0 個、新規インストール: 1 個、削除: 0 個、保留: 0 個。
39.0 kB のアーカイブを取得する必要があります。
この操作後に追加で 86.0 kB のディスク容量が消費されます。
取得:1 http://archive.ubuntu.com/ubuntu jammy/universe amd64 keychain all 2.8.5-2 [39.0 kB]
39.0 kB を 1秒 で取得しました (30.9 kB/s)
以前に未選択のパッケージ keychain を選択しています。
(データベースを読み込んでいます ... 現在 34382 個のファイルとディレクトリがインストールされています。)
.../keychain_2.8.5-2_all.deb を展開する準備をしています ...
keychain (2.8.5-2) を展開しています...
keychain (2.8.5-2) を設定しています ...
man-db (2.10.2-1) のトリガを処理しています ...
...

 
keychainに秘密鍵を登録します。

$ keychain -q --nogui $HOME/.ssh/id_github_ed25519

(何も結果が表示されない)

 
実行後、 $HOME/.keychain/ にファイルが作成されています。

$ ls $HOME/.keychain/
<実際のホスト名>-csh  <実際のホスト名>-fish  <実際のホスト名>-sh

 
使用中のシェルによりkeychainのファイルが異なるため、WSL2の現在のシェルを確認します。 bash のようでした。

$ echo $SHELL
/bin/bash

 
そこで、 ~/.bashrc の末尾に以下を追記します。

# keychain
source $HOME/.keychain/<実際のホスト名>-sh

 

GithubSSH用の公開鍵を登録

clip.exeのエイリアスとして pbcopy を追加

GithubSSH用の公開鍵を登録するには、手動で鍵ファイルを開いて中身をコピーすることもできます。

ただ、コピーミスがこわいので、クリップボード経由でコピーすることにします。

WSL2において、 pbcopy のようなクリップボードを利用できるコマンドを調べたところ、WSL2では clip.exe が使えるようでした。
Windows (WSL2) で Mac の pbcopy / pbpaste のようなクリップボード利用を再現する方法

 
そこで、 ~/.bashrc の末尾にエイリアスを追加し、 clip.exepbcopy として使えるよう設定します。

# pbcopy alias
alias pbcopy='clip.exe'

 
.bashrc を更新したので、再読込します。

$ source ~/.bashrc

 

SSH用の公開鍵をクリップボードにコピー

先ほど生成したSSH用の公開鍵 id_github_ed25519.pubクリップボードにコピーし、Githubの該当ページにコピペできるようにします。

$ pbcopy < ~/.ssh/id_github_ed25519.pub

 

GithubSSH用の公開鍵を登録

https://github.com/settings/keys にてSSH鍵として先ほどの公開鍵を登録します。

 

接続確認

Githubの公式ドキュメントに従い、接続確認を行います。
SSH 接続をテストする - GitHub Docs

$ ssh -T git@github.com
The authenticity of host 'github.com (***.***.***.***' can't be established.
ED25519 key fingerprint is ***
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'github.com' (ED25519) to the list of known hosts.
Hi thinkAmi! You've successfully authenticated, but GitHub does not provide shell access.

接続は成功したようです。

 

ssh config を作成

GithubSSH接続する時の手間を減らすため、Githubへの接続設定を ~/.ssh/config に追加します。
GitHub 接続時の ~/.ssh/config の書き方

Host github.com
    IdentityFile ~/.ssh/id_github_ed25519
    User git
    IdentitiesOnly yes

 

プライベートリポジトリでcloneやpushをテスト

ここまででGithubとの疎通確認はできているものの、git clone や git push を試してみます。

今回は、README.md のある private リポジトリを作成して、

  • git clone
  • git push

ができることを確認できればOKです。

 

Djangoアプリのリポジトリを git clone

今回は ~/dev/projects/ 以下に git cloneして開発することとします。
https://github.com/thinkAmi/dj_ringo_tabetter

 

WSL2にHeroku環境をセットアップ

現在のアプリはHerokuで動作しているため、WSL2にHeroku環境をセットアップします。

Heroku CLIのインストール

Herokuの公式ドキュメントに従い、 Standalone版をインストールします。
The Heroku CLI | Heroku Dev Center

ちなみに、Standalone版の場合、Heroku CLIの自動アップデートも付属しているようです。

$ curl https://cli-assets.heroku.com/install.sh | sh

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1894  100  1894    0     0   5979      0 --:--:-- --:--:-- --:--:--  5993
This script requires superuser access.
You will be prompted for your password by sudo.
[sudo] <user> のパスワード:
Installing CLI from https://cli-assets.heroku.com/heroku-linux-x64.tar.xz
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 27.2M  100 27.2M    0     0  2693k      0  0:00:10  0:00:10 --:--:-- 2201k
v14.19.0
heroku installed to /usr/local/bin/heroku
 ›   Warning: Our terms of service have changed: https://dashboard.heroku.com/terms-of-service
heroku/7.63.0 wsl-x64 node-v14.19.0

 
インストールできているか確認をします。

$ heroku --version
heroku/7.63.0 wsl-x64 node-v14.19.0

 

Heroku CLIで、Herokuにログイン

heroku login コマンドでHerokuにログインします。

手元ではブラウザが開かなかったため、表示されていたURLをブラウザで開いてログインしました。

$ heroku login

heroku: Press any key to open up the browser to login or q to exit:
Opening browser to https://cli-auth.heroku.com/auth/cli/browser/***  # これ

 
Heroku CLIでの準備は完了です。

 

Jetbrains Gatewayまわりのセットアップ

今回はJetBrains Gatewayを使って、WSL2上のPyCharmに接続し、WSL2上でDjangoアプリの開発ができるようにします。

 

Windows上にJetBrains Gatewayをセットアップ

まずは、JetBrains GatewayWindowsにインストールすることにします。

ただ、JetBrains GatewayはJetBrains Toolboxには表示されませんでした。

そこで、以下からダウンロード・インストールします。
JetBrains Gateway - JetBrains IDE 向けリモート開発

インストールオプションは Add "bin" folder to PATH のみチェックを入れます。

PCの再起動が求められるので、再起動します。

 
PCの再起動後、WSL2のUbuntu 22.04.1 LTS を起動します。

 

WSL2で sshd が起動するよう設定

Windows上のJetBrains GatewayからWSL2に接続するときには、SSH接続が使われます。

そこで、WSL2で sshd が起動するよう設定を行います。

 

sshdを起動するために、WSL2上でホスト鍵を生成

WSL2上で sshd 向けのホスト鍵を生成するため、以下のコマンドを実行します。実行後、複数のホスト鍵が生成されます。

$ sudo ssh-keygen -A

[sudo] <user> のパスワード:
ssh-keygen: generating new host keys: RSA DSA ECDSA ED25519

 

SSHでパスワード認証を許可する

続いて、WindowsからWSL2へSSH接続する時の認証まわりを設定します。

今回は開発環境ということもあり、Windowsからの接続はSSH鍵認証ではなく、パスワード認証にします。

設定ファイルを開きます。

$ sudo vi /etc/ssh/sshd_config

PasswordAuthentication 設定を yes に変更します。

# PasswordAuthentication no
PasswordAuthentication yes

 

WSL2にてsshdを起動

準備ができたため、WSL2にて sshd を起動します。

$ sudo service ssh start
 * Starting OpenBSD Secure Shell server sshd   [ OK ]

 

/usr/libexec/wsl-systemd を使った systemdによる自動起動設定

現時点では、WSL2の起動後に手動で

などのサービスを起動する必要があります。

ただ、WSL2上での開発で使うサービスは自動で起動してほしいです。

 
調べてみたところ、WSL2上のUbuntu22.04では /usr/libexec/wsl-systemd というシェルスクリプトが導入されたため、systemdによる自動起動ができそうです。

 
上記の記事に従い、設定を行います。

WSL2のUbuntu上で、 /etc/wsl.conf を開きます(なければ新規作成します)。

$ sudo vi /etc/wsl.conf

 
以下を記載します。

[boot]
command=/usr/libexec/wsl-systemd

 
WSL2を再起動するため、Windows Terminalで wsl --shutdown 後、スタートから Ubuntu 22.04.1 LTS をクリックして起動します。

 
状況を確認すると、systemdで各サービスが起動していました。

# systemdがPID 1 で動作していること
$ pidof systemd
1

# 色々起動していること
$ ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.2  0.0 166200 11116 ?        Ss   10:58   0:00 /lib/systemd/systemd --unit=multi-user.target
root          42  0.0  0.0  31472 11684 ?        S<s  10:58   0:00 /lib/systemd/systemd-journald
root          69  0.1  0.0  22480  6248 ?        Ss   10:58   0:00 /lib/systemd/systemd-udevd
systemd+     100  0.0  0.0  16112  7916 ?        Ss   10:58   0:00 /lib/systemd/systemd-networkd
message+     101  0.0  0.0   8760  4676 ?        Ss   10:58   0:00 @dbus-daemon --system --address=systemd: --nofork --n
root         104  0.1  0.0  33828 19032 ?        Ss   10:58   0:00 /usr/bin/python3 /usr/bin/networkd-dispatcher --run-s
root         105  0.0  0.0 234480  6656 ?        Ssl  10:58   0:00 /usr/libexec/polkitd --no-debug
syslog       106  0.0  0.0 222396  5220 ?        Ssl  10:58   0:00 /usr/sbin/rsyslogd -n -iNONE
root         107  0.1  0.1 1688080 32596 ?       Ssl  10:58   0:00 /usr/lib/snapd/snapd
root         108  0.0  0.0  15016  7148 ?        Ss   10:58   0:00 /lib/systemd/systemd-logind
root         162  0.0  0.0 316924 11756 ?        Ssl  10:58   0:00 /usr/sbin/ModemManager
systemd+     164  0.0  0.0  25256 12564 ?        Ss   10:58   0:00 /lib/systemd/systemd-resolved
root         297  0.0  0.0   7936  1264 ?        Ss   10:58   0:00 /usr/sbin/cron -f -P
root         306  0.0  0.0 110784 21672 ?        Ssl  10:58   0:00 /usr/bin/python3 /usr/share/unattended-upgrades/unatt
root         307  0.2  0.2 1725736 56456 ?       Ssl  10:58   0:00 /usr/bin/containerd
root         328  0.0  0.0   7216  1116 tty1     Ss+  10:58   0:00 /sbin/agetty -o -p -- \u --noclear --keep-baud consol
root         330  0.0  0.0   7216  1092 ?        Ss   10:58   0:00 /sbin/agetty -o -p -- \u --noclear tty1 linux
root         331  0.0  0.0  15416  8904 ?        Ss   10:58   0:00 sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startu
root         368  0.1  0.3 1679532 84656 ?       Ssl  10:58   0:00 /usr/bin/dockerd -H ...


# 念のためDockerの状況を確認
$ service docker status
● docker.service - Docker Application Container Engine
     Loaded: loaded (/lib/systemd/system/docker.service; enabled; vendor preset: enabled)
     Active: active (running) since Sun 2022-09-11 10:59:00 JST; 1min 54s ago
...

 

JetBrains Gatewayを設定

JetBrains Gatewayを起動します。

ただ、まだJetBrains Gatewayの設定が終わっていないため、まずは設定から行います。

 
JetBrains Gatewayを起動後、左ペインの SSH を選択し、中央ペインの New Connection をクリックします。

右側にある歯車マークをクリックし、SSH Configurations を開きます。

左上の + をクリックし、中央ペインに以下を入力します。

項目
Host localhost
Username WSL2のuser名
Password WSL2のパスワード
Port 22
Save password チェックする
Parse config file ~/.ssh/config チェックする

 
この状態で Test Connection ボタンをクリックし、 Successfully connected! と表示されれば接続できています。

なお、初回接続はフィンガープリントの確認があります。

すべての入力が終わったら、 OK ボタンをクリックし、SSH Configurations 画面を閉じます。

Connect to SSH 画面に戻るため、Connection を SSH Configurations 画面で作成したものに変更し、 Check connection and Continue ボタンをクリックします。

Choose IDE and Project 画面が表示されるので、以下を選択します。

項目
IDE version PyCharm
Project directory 先ほど git clone したディレクトリ (/home//dev/projects/dj_ringo_tabetter)

 
入力が終わったら、 Download IDE and Connect ボタンをクリックします。

  • JetBrains Client
  • IDE background on remote

のダウンロードが始まります。

 
ダウンロードが終わると、JetBrains Gatewayに指定したプロジェクトが表示されます。

 

JetBrains Gatewayの動作確認

JetBrains Gatewayのリンクをクリックします。

初回なので「JetBrains Team Tools User Agreement」が表示されます。 acceptします。

その後、PyCharmが起動し、git cloneしたプロジェクトを開くことができました*1

 

WSL2上のPython環境構築

ここまでで JetBrains Gateway + PyCharmによる開発ができるようになりました。

次は、WSL2上で既存のDjangoアプリを開発するために必要な環境設定を行います。

 

anyenvのセットアップ

今後WSL2上で色々開発するかもしれないため、今回は anyenv 経由で pyenv の環境を構築します。
anyenv/anyenv: All in one for **env

READMEや以下の記事を参考に、WSL2上でセットアップします。
anyenvの環境構築 - Qiita

# git cloneする
$ git clone https://github.com/anyenv/anyenv ~/.anyenv
Cloning into '/home/<user>/.anyenv'...
remote: Enumerating objects: 505, done.
remote: Counting objects: 100% (109/109), done.
remote: Compressing objects: 100% (66/66), done.
remote: Total 505 (delta 54), reused 77 (delta 36), pack-reused 396
Receiving objects: 100% (505/505), 89.55 KiB | 274.00 KiB/s, done.
Resolving deltas: 100% (234/234), done.


# .bashrc に設定追加
$ echo 'export PATH="$HOME/.anyenv/bin:$PATH"' >> ~/.bashrc


# initする
$ ~/.anyenv/bin/anyenv init
# Load anyenv automatically by adding
# the following to ~/.bash_profile:

eval "$(anyenv init -)"


# 画面に表示された内容を、 .bashrc に追加
$ echo 'eval "$(anyenv init -)"' >> ~/.bashrc


# シェルを再起動
$ exec $SHELL -l
ANYENV_DEFINITION_ROOT(/home/<user>/.config/anyenv/anyenv-install) doesn't exist. You can initialize it by:
> anyenv install --init


# メッセージの通りに実行し、マニフェストディレクトリを作成する
$ anyenv install --init
Manifest directory doesn't exist: /home/<user>/.config/anyenv/anyenv-install
Do you want to checkout https://github.com/anyenv/anyenv-install.git? [y/N]: y
Cloning https://github.com/anyenv/anyenv-install.git master to /home/<user>/.config/anyenv/anyenv-install...
Cloning into '/home/<user>/.config/anyenv/anyenv-install'...
remote: Enumerating objects: 71, done.
remote: Counting objects: 100% (14/14), done.
remote: Compressing objects: 100% (13/13), done.
remote: Total 71 (delta 4), reused 3 (delta 1), pack-reused 57
Receiving objects: 100% (71/71), 13.15 KiB | 708.00 KiB/s, done.
Resolving deltas: 100% (11/11), done.

Completed!


# anyenvがインストールされているか確認
$ anyenv --version
anyenv 1.1.5-1-g5c58783

 

anyenv-updateのセットアップ

また、 ***env をバージョンアップしやすくするため、 anyenv-update をREADMEに従いセットアップします。
znz/anyenv-update: anyenv plugin that provides anyenv update command to update all **env and all plugins

# ディレクトリ追加
$ mkdir -p $(anyenv root)/plugins


# git clone
$ git clone https://github.com/znz/anyenv-update.git $(anyenv root)/plugins/anyenv-update
Cloning into '/home/<user>/.anyenv/plugins/anyenv-update'...
remote: Enumerating objects: 87, done.
remote: Total 87 (delta 0), reused 0 (delta 0), pack-reused 87
Receiving objects: 100% (87/87), 13.33 KiB | 975.00 KiB/s, done.
Resolving deltas: 100% (33/33), done.

 

pyenv のインストール

WSL2上ではPythonの複数バージョンを扱うかもしれないため、anyenv経由で pyenv をインストールします。

# インストール
$ anyenv install pyenv
/tmp/pyenv.20220910214424.10064 ~
Cloning https://github.com/pyenv/pyenv.git master to pyenv...
Cloning into 'pyenv'...
remote: Enumerating objects: 21895, done.
remote: Counting objects: 100% (653/653), done.
remote: Compressing objects: 100% (235/235), done.
remote: Total 21895 (delta 460), reused 554 (delta 389), pack-reused 21242
Receiving objects: 100% (21895/21895), 4.41 MiB | 1.12 MiB/s, done.
Resolving deltas: 100% (14803/14803), done.
~

Install pyenv succeeded!
Please reload your profile (exec $SHELL -l) or open a new session.


# リロード
$ exec $SHELL -l

 

Pythonをビルドするためのツールをインストール

以下のドキュメントに従い、必要なツール類をインストールします。
Ubuntu環境のPython: Python環境構築ガイド - python.jp

なお、今回は tkinter は使わないため、 tk-dev はインストールしません。

$ sudo apt install build-essential libbz2-dev libdb-dev \
  libreadline-dev libffi-dev libgdbm-dev liblzma-dev \
  libncursesw5-dev libsqlite3-dev libssl-dev \
  zlib1g-dev uuid-dev

パッケージリストを読み込んでいます... 完了
...
No VM guests are running outdated hypervisor (qemu) binaries on this host.

 

Djangoアプリのruntime.txt と同じPythonをインストール

Djangoアプリの runtime.txt ファイルを見ると、Python 3.8.6 で動いています。

そのため、pyenvで3.8.6をインストールします。

$ pyenv install 3.8.6
Downloading Python-3.8.6.tar.xz...
...
WARNING: The Python tkinter extension was not compiled and GUI subsystem has been detected. Missing the Tk toolkit?
Installed Python-3.8.6 to /home/<user>/.anyenv/envs/pyenv/versions/3.8.6

 
リポジトリのあるディレクトリではPython3.8.6で動作するように設定します。

# 設定
$ pyenv local 3.8.6

# 確認
$ python --version
Python 3.8.6

 

必要なパッケージのインストール

pg_config のインストール

リポジトリには requirements.txt があります。ただ、 pip install -r requirements.txt すると、 pg_config でエラーになります。

Error: pg_config executable not found.

pg_config is required to build psycopg2 from source.  Please add the directory
containing pg_config to the $PATH or specify the full executable path with the
option:

    python setup.py build_ext --pg-config /path/to/pg_config build ...

or with the pg_config option in 'setup.cfg'.

If you prefer to avoid building psycopg2 from source, please install the PyPI
'psycopg2-binary' package instead.

 
そこで、以下を参考に状況を確認します。
psycopg2 インストールエラー - Qiita

$ pg_config --version
コマンド 'pg_config' が見つかりません。次の方法でインストールできます:
sudo apt install libpq-dev          # version 14.5-0ubuntu0.22.04.1, or
sudo apt install postgresql-common  # version 238

 
エラーメッセージに従い、 libpq-dev をインストールします。

$ sudo apt install libpq-dev

No VM guests are running outdated hypervisor (qemu) binaries on this host.

 
再度確認します。

$ pg_config --version
PostgreSQL 14.5 (Ubuntu 14.5-0ubuntu0.22.04.1)

インストールされたようです。

 

pip install

改めて pip install すると、エラーが出ています。

$ pip install -r requirements.txt

...
ERROR: After October 2020 you may experience errors when installing or updating packages. This is because pip will change the way that it resolves dependency conflicts.

We recommend you use --use-feature=2020-resolver to test your packages with the new resolver before it becomes the default.

faker 2.0.1 requires text-unidecode==1.2, but you'll have text-unidecode 1.3 which is incompatible.

 
インストール状況を確認してみると、インストール自体はできているようです。

$ pip list

Package            Version
------------------ -----------
atomicwrites       1.3.0
...
Faker              2.0.1  # インストールできている
...

 
ためしにオプションを付けて実行してみると、エラーメッセージが変わりました。

$ pip install -r requirements.txt --use-feature=2020-resolver

ERROR: Cannot install text-unidecode==1.3 and faker 2.0.1 because these package versions have conflicting dependencies.

The conflict is caused by:
    The user requested text-unidecode==1.3
    faker 2.0.1 depends on text-unidecode==1.2

To fix this you could try to:
1. loosen the range of package versions you've specified
2. remove package versions to allow pip attempt to solve the dependency conflict

ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/user_guide/#fixing-conflicting-dependencies

 
調べてみると、以前開発していた時と現在では、pipの依存性チェッカーが異なっているようです。

 
現時点ではFakerがエラーになっていますが、

  • Fakerはテストで使うライブラリなので、プロダクションコード自体には影響しない
  • 開発する中で、各パッケージはバージョンアップする予定

なことから、現時点ではひとまずこのままで進めます。

 

Docker Compose でPostgreSQLを構築

PythonはWSL2上に直接インストールしました。

一方、アプリのデータベース PostgreSQL はWSL2上に直接インストールせず、プロジェクトごとに別のPostgreSQLを参照したいです。

そこで、WSL2上に、Docker ComposeでPostgreSQLを立てることにします。

 
なお、WSL2上でDockerをインストールした際にDocker Composeもインストール済なため、 docker compose コマンドは使える状態になっています。

$ docker compose version
Docker Compose version v2.6.0

 

WSL2上のDockerでPostgreSQLを構築

Docker Composeを設定

WSL2上のDockerでPostgreSQLを構築するため、今回は Docker Compose を利用します。

docker-compose.yml ファイルを作成します。

version: '3'
services:
  db:
    image: postgres:10.6
    # portsの指定、quoteなしだと、Unquoted port mapping not recommended と出る
    ports:
      - "19876:5432"
    environment:
      POSTGRES_USER: ringo
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: ringo_tabetter_py
      # データベースファイルの置き場所をサブディレクトリにする
      PGDATA: /var/lib/postgresql/data/pgdata
    # データベースファイルの格納先を名前付きボリュームにする
    volumes:
      - ringo_data:/var/lib/postgresql/data

# 名前付きボリュームの設定
volumes:
  ringo_data: {}

 

Docker ComposeでPostgreSQLを起動

docker compose up -dPostgreSQLを起動します。

$ docker compose up -d
[+] Running 15/15
 ⠿ db Pulled                                                                                                      15.6s
...
[+] Running 3/3
 ⠿ Network dj_ringo_tabetter_default      Created                                                                  0.0s
 ⠿ Volume "dj_ringo_tabetter_ringo_data"  Created                                                                  0.0s
 ⠿ Container dj_ringo_tabetter-db-1       Started                                                                  0.6s

 

ローカルで dj_ringo_tabetter を起動

ここまででDjangoアプリを起動するための準備が一通りできたので、WSL2上でアプリを起動してみます。

 

エラー「A 'django.template.backends.django.DjangoTemplates' instance must be configured in TEMPLATES in order to use the admin application」への対応

PyCharmで dj_ringo_tabetter を起動したところ、以下のエラーになり起動しませんでした。

/home/<user>/dev/projects/dj_ringo_tabetter/env/bin/python /home/<user>/dev/projects/dj_ringo_tabetter/manage.py runserver 8000 
Watching for file changes with StatReloader
Performing system checks...

Exception in thread django-main-thread:
Traceback (most recent call last):
  File "/home/<user>/.anyenv/envs/pyenv/versions/3.8.6/lib/python3.8/threading.py", line 932, in _bootstrap_inner
    self.run()
  File "/home/<user>/.anyenv/envs/pyenv/versions/3.8.6/lib/python3.8/threading.py", line 870, in run
    self._target(*self._args, **self._kwargs)
  File "/home/<user>/dev/projects/dj_ringo_tabetter/env/lib/python3.8/site-packages/django/utils/autoreload.py", line 54, in wrapper
    fn(*args, **kwargs)
  File "/home/<user>/dev/projects/dj_ringo_tabetter/env/lib/python3.8/site-packages/django/core/management/commands/runserver.py", line 117, in inner_run
    self.check(display_num_errors=True)
  File "/home/<user>/dev/projects/dj_ringo_tabetter/env/lib/python3.8/site-packages/django/core/management/base.py", line 436, in check
    raise SystemCheckError(msg)
django.core.management.base.SystemCheckError: SystemCheckError: System check identified some issues:

ERRORS:
?: (admin.E403) A 'django.template.backends.django.DjangoTemplates' instance must be configured in TEMPLATES in order to use the admin application.

System check identified 1 issue (0 silenced).

 
似たような事例がないかを調べたところ、JetBrainsのyoutrackに記載がありました。
"DjangoTemplates instance must be configured in TEMPLATES in order to use the admin application" trying to run the server from Django project template with Jinja2 : PY-39296

 
手元のソースコードを見たところ、youtrackの記載同様、 TEMPLATES の設定に django.template.backends.django.DjangoTemplates が足りていないようでした。
https://github.com/thinkAmi/dj_ringo_tabetter/blob/97a9f5be02/dj_ringo_tabetter/settings.py#L67

以前、Django自体のJinja2 Engineを使うようにしてdjango-jinjaを削除をしましたが、その対応不足だったのかもしれません。
https://github.com/thinkAmi/dj_ringo_tabetter/commit/97a9f5be0239745882a3e932b31a232a705f3ee0

 
そこで、youtrackの記載通り、 django.template.backends.django.DjangoTemplates の設定も追加しました。

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.jinja2.Jinja2',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
            'environment': 'dj_ringo_tabetter.jinja2.environment',
        }
    },

    # 以下を追加
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [os.path.join(BASE_DIR, "templates")],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

 

Djangoアプリの動作確認

改めて dj_ringo_tabetter アプリを起動してみます。

マイグレーションを実行していないためその旨のメッセージは出ていますが、エラーになることなく起動したようです。

/home/<user>/dev/projects/dj_ringo_tabetter/env/bin/python /home/<user>/dev/projects/dj_ringo_tabetter/manage.py runserver 8000 
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).

You have 18 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions, tweets.
Run 'python manage.py migrate' to apply them.
September 11, 2022 - 11:42:31
Django version 2.2.16, using settings 'dj_ringo_tabetter.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

 
WindowsChromeで確認します。

PyCharmのログにリンクとして表示されている http://127.0.0.1:8000/ 自体、もしくは、PyCharmのリンクを踏むと表示される

Remote host wants to open the following URL: http://127.0.0.1:55659/

を開くと、画面が表示されました。

データベースが空っぽなので何も表示されませんが、ガワは表示できているようです。

 

Heroku Postgresからデータベースの内容をリストア

上記で見た通り、現在のローカルのPostgreSQLは空っぽです。

開発用にデータをイチから作成しても良いのですが、今回はHeroku PostgresにあるデータをローカルのPostgreSQLにリストアして使うことにします。
Importing and Exporting Heroku Postgres Databases | Heroku Dev Center

 

Heroku Postgresのバックアップ

WSL2上のHeroku CLIを使い、バックアップの作成とダウンロードを行います。

なお、ディレクトリは dj_ringo_tabetterリポジトリルートとします。

# 現在のディレクトリを確認
$ pwd
/home/<user>/dev/projects/dj_ringo_tabetter


# バックアップを取得
$ heroku pg:backups:capture --app ringo-tabetter

Starting backup of thinking-carefully-5183... done

Use Ctrl-C at any time to stop monitoring progress; the backup will continue running.
Use heroku pg:backups:info to check progress.
Stop a running backup with heroku pg:backups:cancel.

Backing up DATABASE to b005... done

 

バックアップファイルのダウンロード

上記で作成したバックアップファイルをダウンロードします。

# バックアップをダウンロード
$ heroku pg:backups:download --app ringo-tabetter
Getting backup from ⬢ ringo-tabetter... done, #5


# バックアップファイルの存在を確認
$ ls latest.dump
latest.dump

 

ローカルのPostgreSQLにリストア

pg_restoreのインストール

ローカルのPostgreSQLにリストアしようと pg_restore のバージョンを確認したところ、WSL2上にはインストールされていないようでした。

$ pg_restore --version
コマンド 'pg_restore' が見つかりません。次の方法でインストールできます:
sudo apt install postgresql-client-common

 
そこで、画面の表示に従い、 pg_restore をインストールします。

$ sudo apt install postgresql-client-common

...
以下のパッケージが新たにインストールされます:
  postgresql-client-common
アップグレード: 0 個、新規インストール: 1 個、削除: 0 個、保留: 0 個。
...
No VM guests are running outdated hypervisor (qemu) binaries on this host.

 
再度 pg_restore コマンドを実行するとエラーメッセージが変わっていました。

$ pg_restore --version
Warning: No existing cluster is suitable as a default target. Please see man pg_wrapper(1) how to specify one.
Error: You must install at least one postgresql-client-<version> package

 
大丈夫かなと思い、pg_restore を実行したところ、以下のエラーになりました。

$ pg_restore --verbose --clean --no-acl --no-owner -h localhost -p 19876 -U ringo -d ringo_tabetter_py latest.dump
...
 Error: You must install at least one postgresql-client-<version> package.

 
似た事例がないかを調べたところ、postgresql-client のインストールも必要そうなことがわかりました。
ruby on rails 3 - Get Error: You must install at least one postgresql-client- package when deploy to heroku - Stack Overflow

そこで、追加でインストールを行います。

$ sudo apt install postgresql-client

状態情報を読み取っています... 完了
以下の追加パッケージがインストールされます:
  postgresql-client-14
提案パッケージ:
  postgresql-14 postgresql-doc-14
以下のパッケージが新たにインストールされます:
  postgresql-client postgresql-client-14
アップグレード: 0 個、新規インストール: 2 個、削除: 0 個、保留: 0 個。
1,222 kB のアーカイブを取得する必要があります。
この操作後に追加で 3,963 kB のディスク容量が消費されます。
続行しますか? [Y/n]
...
No VM guests are running outdated hypervisor (qemu) binaries on this host.

 
インストール後にバージョンを確認すると、今度は表示されました。良さそうです。

$ pg_restore --version
pg_restore (PostgreSQL) 14.5 (Ubuntu 14.5-0ubuntu0.22.04.1)

 

pg_restoreの実行

pg_restore コマンドを使用し、ローカルのPostgreSQLにリストアします。

$ pg_restore --verbose --clean --no-acl --no-owner -h localhost -p 19876 -U ringo -d ringo_tabetter_py latest.dump
pg_restore: connecting to database for restore
Password: <PostgreSQLのパスワードを入力:今回は `postgres` >
...
pg_restore: warning: errors ignored on restore: 76

 
以上でPostgreSQLのデータも準備できました。

 

動作確認

再度、http://127.0.0.1:8000/ にアクセスすると、円グラフが表示されました。

 
続いて、 http://127.0.0.1:8000/hc/total-by-month にアクセスすると折れ線グラフが表示されました。

 
WSL2上のDjangoアプリが正常に動作していることを確認できました。

 

Djangoアプリにマイグレーションを適用

起動した時のログをみると

You have 7 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth.
Run 'python manage.py migrate' to apply them.

とありました。

そこで、マイグレーションを適用します。

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, tweets
Running migrations:
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK

適用に成功したようです。

 

Herokuアプリを更新する

remoteにHerokuを追加

Herokuにアプリをデプロイできるか、公式ドキュメントに従い確認します。
Deploying with Git | Heroku Dev Center

 
remote を追加します。

$ heroku git:remote -a ringo-tabetter
set git remote heroku to https://git.heroku.com/ringo-tabetter.git

 

push時のエラーに対応

Herokuにpushしたところ、エラーになりました。 pip install のときに見たエラーです。

$ git push heroku master
...
remote:        ERROR: Cannot install faker==2.0.1 and text-unidecode==1.3 because these package versions have conflicting dependencies.
remote:        
remote:        The conflict is caused by:
remote:            The user requested text-unidecode==1.3
remote:            faker 2.0.1 depends on text-unidecode==1.2
remote:        
remote:        To fix this you could try to:
remote:        1. loosen the range of package versions you've specified
remote:        2. remove package versions to allow pip attempt to solve the dependency conflict

 
そこで、Fakerのバージョンを上げ、Fakerが依存している text-unidecode のバージョン 1.3 で動くようにしてみます。

どのFakerのバージョンが良いかを見たところ、 8.1.0 ならば text-unidecode1.3 に依存してそうでした。

 
そこで、 requirements.txt を修正し、 Faker8.1.0 にした上でインストールしました。

$ pip install -r requirements.txt 

 
また、

$ pip install -r requirements.txt --use-feature=2020-resolver

としてもエラーは出なくなりました。

 

あらためて push

良さそうでしたので、改めてpushします。

$ git push heroku master
Enumerating objects: 11, done.
...
remote: Verifying deploy.... done.
To https://git.heroku.com/ringo-tabetter.git
   9169f8e..8b855c4  master -> master

 
デプロイに成功しました。

また、 https://ringo-tabetter.herokuapp.com/hc/total にアクセスし、動作していることが確認できました。

 
しばらく放置していたアプリなため、いろいろ修正しなければいけないことはあるものの、ひとまず開発環境の構築が完了しました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi/dj_ringo_tabetter

*1:画像はこの記事の最後まで到達した時のJetBrains Gateway + PyCharmの状態です。DBの中身も見えるため、PyCharm単体での開発と変わりありません

「作ればわかる!Androidプログラミング」をJetpack Composeで実装してみた

久しぶりに自分専用のAndroidアプリを作りたくなったため、最近のAndroid開発事情を調べてみたところ、

など、色々と学びたくなるような技術を知りました。

ただ、学びながら自分専用のアプリを作り始めても挫折するだろうと思い、まずは学ぶためのアプリを作ることにしました。

何か良い題材がないか本を探したところ、以前お世話になったシリーズの最新刊「作ればわかる!Androidプログラミング Kotlin対応」(金宏 和實 著、翔泳社) が良さそうでした。
作ればわかる!Androidプログラミング Kotlin対応 10の実践サンプルで学ぶAndroidアプリ開発入門(金宏 和實)|翔泳社の本

そこで、同書籍のアイデアを Kotlin + Jetpack Compose で実装してみることにしました。

この記事では、各アプリを実装したときに気づいたことなどをメモとして残しておきます。

なお、メモの内容に誤りがありましたらご指摘ください。

 
目次

 

環境

  • Android Studio 2021.2.1 Patch 1
  • Jetpack Compose 1.1系および1.2系
    • 最初はAndroid Studioでプロジェクトを作成したときにできる1.1系を使用
    • Roomを使うところで1.2系を使用
  • Room 2.4.3
  • Navigation Compose 2.5.1

 

やらないこと

今回の一番の目的は「Jetpack Compose の書き方に慣れる」としたため、今回やらないこととして以下を決めました。

  • Jetpack Composeを使った時の良いディレクトリ構成
    • ディレクトリ構成を真剣に考えるよりも動くものを作る
      • 現時点ではどんな構成が一番良いのかよくわからないため
  • MVVMを使うこと
    • 今回は画面が少ないこと、優先して学びたいことがあったため
  • Kotlinっぽい書き方をすること
    • 今回はKotlinを学ぶのが目的ではないため、Android Studioでエラーやサジェストが出なければOKとした
  • 書籍のアプリと同じデザイン・同じ実装とすること
    • どこまでJetpack Compose で作れるのか分かっていないため、おおよそできればOKとした
    • そのため、ConstraintLayout では作成していない
  • 書籍のアプリをすべて実装すること
    • 自分専用のアプリを作るにあたり、必要そうなアプリのみ実装することにした
    • そのため、センサー系や地図系は実装していない
    • 実装したくなったらそのときに実装する

 
以降、各章で調べたことを置いておきます。

 

4章 ハイ&ローゲーム

トランプの画像について

書籍通り、無料素材倶楽部さんよりトランプの画像をダウンロードしました。ありがとうございました。

なお、書籍の記載にある通り、自分の環境でも文字化けしています。

 

Scaffoldで topBarbottomBarcontent などをいい感じに定義する

参考にしたのは以下です。
androidx.compose.material  |  Android Developers

 
また、レイアウトについては以下の公式ドキュメントも参考にしました。

 

marginは Modifier を使って指定する

ふだんDBに近い生活をしているせいか、 RowColumn の置き方が逆な感覚がありました。

参考にしたドキュメントは以下です。

 

要素の中央寄せは、AlignmentとArrangementを使う

以下を参考にしました。
【Jetpack Compose】 要素を中央に配置する|yasukotelin|note

 

ローカルにある画像の表示は、 Image などで painterResource を使う

トランプ画像の表示が必要なため、以下を参考に painterResource を使いました。

 

状態は rememberrememberSaveable と、 mutableStateOf などで保持する

以下を参考にしました。
状態と Jetpack Compose  |  Android Developers

 

5章 名刺切らしてまして

画面を固定するには、 LocalContextrequestedOrientation を使う

書籍では AndroidManifest.xml で画面の向きを固定していました。

せっかくなので、Jetpack Composeでの画面の向きの固定方法を調べたところ、 LocalContextrequestedOrientation で作れば実装できました。
android - How to force orientation for some screens in Jetpack Compose? - Stack Overflow

 

TextFieldでのキーボード制御は KeyboardOptions を使う

「数字だけのキーボード」など、キーボード制御する方法を調べたところ、 KeyboardOptions を使えば実装できました。
android - How to set the inputType for a TextField in Jetpack Compose - Stack Overflow

 

Jetpack Compose での画面遷移は Navigation Compose を使う

ライブラリの最新のバージョンはこちらで確認できます。
Navigation  |  Android デベロッパー  |  Android Developers

なお、Navigation Compose の使い方については以下が参考になりました。

 

共有プリファレンスの PreferenceManager は、現時点では deprecated な模様

書籍では共有プリファレンスへ保存する際 PreferenceManager を使っていました。ただ、現時点では deprecated な模様です。
PreferenceManagerが@Deprecatedで困った話 - Qiita

そのため、以下を参考に、代替ライブラリを使いました。公式ドキュメントはこのあたりです。

 

TopAppBar の action でオプションメニューを実現する

書籍ではオプションメニューを作っていましたが、今回は TopAppBar の action を使うことにしました。
androidx.compose.material  |  Android Developers)

   

6章 ご飯なんにする?

オプションメニューのネストは、Composableを2つ作る

書籍ではオプションメニューを使っていました。

Jetpack Composeでのオプションメニュー実装を調べたところ、以下の方法のようにメインとネストのメニューを作れば実装できました。
android - What is the better or easier way to create "nested" menus in Jetpack Compose? - Stack Overflow

 

data classの値の更新は copy を使う

選択したメニューは Kotlin の data class として保持することを考えました。

ただ、選択したメニューを変更したときに、data class のプロパティをどのように更新すればよいか調べたところ、以下の回答がありました。
android - Jetpack Compose State: Modify class property - Stack Overflow

また、 MutableState オブジェクトの宣言は3パターンあると知り、setter を取得できる構文 val (value, setValue) = remember { mutableStateOf(default) } もあると知りました。
状態と Jetpack Compose  |  Android Developers

 

長押しイベントは combinedClickableonLongClick で設定できる

書籍では、長押しするとコンテキストメニューを表示するという仕様がありました。

Jetpack Compose で長押しイベントを設定する方法を調べたところ、以下の回答がありました。
kotlin - Button Long Press Listener in Android jetpack compose - Stack Overflow

ただし、公式ドキュメントを見ると、現時点では combinedClickable は Experimental な機能のようです。
Compose 修飾子のリスト  |  Jetpack Compose  |  Android Developers

そのため、Composableで使うときは @OptIn(ExperimentalFoundationApi::class) な指定も合わせて行う必要があるようです。

 

コンテキストメニューDropdownMenu で実装する

上記の onLongClick 時に、 DropdownMenu を表示すれば良さそうです。

 

intent は LocalContext.current の取得後に context.startActivity する

書籍では intent でメールやSMSを呼び出していました。

Composeで intent を使う方法を調べたところ、

  • LocalContext.current で context を取得
  • context を使って context.startActivity する

という流れで実装できました。

 
なお、Composableでない関数で context を使いたい場合は、Composableから context を渡してあげれば良さそうでした。
android - @composable invocations can only happen from the context of an @composable function - Stack Overflow

 

その他

 

10章 若くても血圧は記録せよ

書籍では血圧を記録するためのアプリを作ります。

ただ、自宅には血圧計がないため、今回は体重の記録アプリとしました。なお、プロジェクト名は Blood Pressure のままです。

また、現在のAndroidでデータベースを使うときは Room を使うことが多いようなので、書籍とは異なり Room を使って実装することにしました。

 

KSP版のRoomをインストールする

Roomを使うためには build.gradle に依存関係を追加する必要がありました。
セットアップ | Room を使用してローカル データベースにデータを保存する  |  Android デベロッパー  |  Android Developers

上記のドキュメントを見るとRoomには

  • kapt
  • ksp

の2つの版がありそうでした。

どちらの方を使うのが良いかを調べたところ、以下の記事類を見るに、KSP版のほうが良さそうでした。

QiitaではRoomのサポート状況はExperimentalと書かれていましたが、改めて現時点の対応状況を調べると、以下の通り Official Supported されていました。
Supported libraries | Kotlin Symbol Processing API | Kotlin

そのため、今回はKSP版を使うことにしました。

 
KSP版を使うには、 KSP自体もプロジェクトやアプリの build.gradle に追加する必要があります。
#68 Jetpack ComposeでToDoアプリを作る - Room | Mokelab Blog

KSPの最新バージョンを調べたところ、以下に記載がありました。
Maven Repository: com.google.devtools.ksp » symbol-processing-api

KSP 1.7系では、

  • 1.7.10-1.0.6
  • 1.7.0-1.0.6

の2つのバージョンがありました。

どちらのバージョンを使えばよいか迷ったため、Compose Compiler Versionを見ることにしました。
Pre-release Kotlin Compatibility | Compose to Kotlin Compatibility Map  |  Android Developers

1.3.0は出たばっかりなのか、英語版のみ rc が取れた表記になっていました。今回は出たばっかりのものでハマると大変だと思い、 Compose Compiler Version は 1.2.0 とすることにしました。

そのため、 Compatible Kotlin Version1.7.0 になることから、KSPも 1.7.0-1.0.6 を使えば良さそうでした。

なお、こちらのページでは、日本語版も安定版リリースが 1.3.0 となっています。
Compose Compiler  |  Android デベロッパー  |  Android Developers

 
これでKSPのバージョン指定ができたため、続いてRoomを指定します。Roomは最新バージョン 2.4.3 が使えそうです。
Room  |  Android デベロッパー  |  Android Developers

 

Roomを使うために Entity・DAO・Database を用意し、Applicationを差し替える

公式ドキュメントに従い、

  • Entity
  • DAO
  • Databaseクラス

の3つを用意します。
Room を使用してローカル データベースにデータを保存する  |  Android デベロッパー  |  Android Developers

 
なお、Databaseクラスのインスタンスの生成について、公式ドキュメントでは

val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "database-name"
        ).build()

と書かれていますが、この処理をどこでやるかが気になりました。

Codelabを読むと

アプリに含めるデータベースとリポジトリインスタンスは、それぞれ 1 つのみにする必要があります。そのための簡単な方法は、これらを Application クラスのメンバーとして作成することです。こうすれば、必要な際にはそのたびに作成する代わりに、アプリから取得するだけで済みます。

13. リポジトリとデータベースをインスタンス化する | Android Room とビュー - Kotlin

とあったため、

  • Applicationを継承したクラスを作成し、そこにDatabaseのインスタンス生成を実装する
  • AndroidManifest.xml で、Applicationを差し替える

ようにしました。
Android Jetpack(Room + Compose)でTodoアプリ作ろう【後編】 - Qiita

 

Dateの代わりにLocalDateTimeを使い、型コンバータで変換する

書籍では登録時間として Date() の値をDBに保存しています。

ただ、Dateについて調べると、例えば以下のように java.util.Date は deprecated という記事が散見されました。
ほとんどが非推奨メソッドとなったjava.util.Date、代替手段と非推奨メソッド利用していた場合の問題とは | セキュリティ対策のラック

そこで、今回は Date ではなく LocalDateTime を使うことにしました。
LocalDateTime  |  Android Developers

なお、 LocalDateTimeは API 26 で使えるようになったため、 app/build.gradledefaultConfigminSdk 26 へと変更します。

 
ただ、RoomではLocalDateTimeをそのまま保存することができないことから、型コンバータを使用してDB上はLong型として保存するようにします。
Room を使用して複雑なデータを参照する  |  Android デベロッパー  |  Android Developers

// (略)

class LocalDateTimeConverter {
    @TypeConverter
    fun fromTimeStamp(value: Long?): LocalDateTime? {
        val instant: Instant? = value?.let { Instant.ofEpochSecond(it) }
        return LocalDateTime.ofInstant(instant, ZoneId.systemDefault())
    }

    @TypeConverter
    fun toTimeStamp(value: LocalDateTime?): Long? {
        return value?.atZone(ZoneId.systemDefault())?.toEpochSecond()
    }
}

 
この型コンバータは、Databaseのところでアノテーション @TypeConverters として以下のように指定すれば良いようです。

@Database(entities = [WeightRecord::class], version = 1, exportSchema = false)
@TypeConverters(LocalDateTimeConverter::class)
abstract class MyDatabase: RoomDatabase() {
    abstract fun weightRecordDao(): WeightRecordDao
}

 

DateTimeFormatterを使ってLocalDateTimeを文字列表記にする

書籍だと登録日時を画面に表示しているので、LocalDateTimeを文字列表記にする必要がありました。

方法としては、DateTimeFormatterを使えば良いようです。
Kotlinで日付をフォーマットする方法 | 寝室コンピューティング

 

Entityで id を autoGenerate するならば、Insert時に渡す id の値は 0 にする

Insert時には各項目を埋める必要がありますが、idは自動生成するようにしたため、何の値を渡せばよいか迷いました。

公式ドキュメントによると

If the field type is Long or Int (or its TypeConverter converts it to a Long or Int), Insert methods treat 0 as not-set while inserting the item.

PrimaryKey  |  Android Developers

とあったため、 0 を渡せば良さそうです。

 

Roomでの読み書きは LaunchedEffect + CoroutineScope + withContext で行う

このあたりは理解しきれていないので、今はできた形だけ書いておきます。

以下を参考にして、Roomでの読み書きが LaunchedEffect + CoroutineScope + withContext で行えるようになりました。

var weight by rememberSaveable {
    mutableStateOf("")
}

if (recordId.isNotEmpty()) {
    LaunchedEffect(Unit) {
        CoroutineScope(Dispatchers.Main).launch {
            withContext(Dispatchers.Default) {
                val dao = MyApplication.database.weightRecordDao()
                val record = dao.findById(recordId.toInt())
                weight = record.weight.toString()
            }
        }
    }
}

本来は深掘りしないといけないんですが、今回はこの程度で...

なお、 LaunchedEffect については以下を参考にしました。
LaunchedEffect|サンプルで理解するJetpack Composeの副作用の仕組み

   

書籍では、新規登録と編集は同じ画面を使っていました。

今回は

  • recordId を渡さない場合、新規登録として扱う
  • recordId を渡す場合、編集として扱う

とします。

そのため、Navigation Composeへ recordId を渡す/渡さないで制御できれば良さそうでした。

調べてみると、以下に省略可能な引数の渡し方が書いてありました。
省略可能な引数の追加 | Compose を使用したナビゲーション  |  Jetpack Compose  |  Android Developers

そのため、編集側の定義を

NavHost(navController = navController, startDestination = "main") {
    composable(
        "edit?recordId={recordId}",
        arguments = listOf(navArgument("recordId") { defaultValue = "" })
    ) { backStackEntry ->
        backStackEntry.arguments?.getString("recordId")?.let { it ->
            EditScreen(
                navController = navController,
                setShowSnackBar = setShowSnackBar,
                setMessage = setMessage,
                recordId = it
            )
        }
    }
}

とします。

そして、新規登録ボタンでは

@Composable
fun AddButton(navController: NavController) {
    FloatingActionButton(onClick = { navController.navigate("edit") }) {
        Icon(Icons.Filled.Add, contentDescription = "追加")
    }
}

と、何もクエリパラメータ構文でクエリパラメータなしで定義します。

一方、編集時は

Column(
    modifier = Modifier.clickable {
        navController.navigate("edit?recordId=${weightRecord.id}")  // これ
    }
) {
    weightRecord.recordedAt?.let { Text(text= it.format(dtf)) }
    Text(text="No. ${weightRecord.id}")
    Text(text="Weight: ${weightRecord.weight}")
}

のようにクエリパラメータ付きで定義することで、実現できました。

 

Snackbarは、 LaunchedEffect とともに使う

「登録しました」というメッセージを出すために、今回は Snackbar を使います。

Snackbarの使い方を調べたところ、以下に LaunchedEffect とともに使う例がありました。

そのため、一番上の Composable で

val (message, setMessage) = remember {
    mutableStateOf("")
}

と、新規登録/編集画面から渡されるメッセージを保持し、メイン画面のComposableで

val scaffoldState = rememberScaffoldState()
if (showSnackBar) {
    LaunchedEffect(scaffoldState.snackbarHostState) {
        val result = scaffoldState.snackbarHostState.showSnackbar(
            message = message,
            actionLabel = "閉じる"
        )

        when (result) {
            SnackbarResult.Dismissed -> { setShowSnackBar(false) }
            SnackbarResult.ActionPerformed -> { setShowSnackBar(false) }
        }
    }
}

のように message の中身をSnackbarで表示させることで、やりたいことができました。

 

Compose 1.2.0から、Scaffoldの content に padding パラメータの利用が必須化した

体重記録アプリで初めて 1.2.0 系を使うようにしたところ、

Jetpack Compose: Content padding parameter it is not used

のようなエラーメッセージが出るようになりました。

調べてみたところ、1.2.0系からScaffoldのcontentには padding を渡す必要があるようです。
android - Content padding parameter it is not used - Stack Overflow

そのため、上記の回答通り、 padding を渡すようにしたところ、エラーメッセージは表示されなくなりました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/tsukuwaka_android_with_kotlin

Rails + React + OpenAPI な環境で、クエリパラメータに配列を指定する時に調べたことをまとめてみた

Rails + React + OpenAPI な環境で、クエリパラメータに配列を指定することがありました。

ただ、実装するまでにいろいろ調べたことがあったため、メモを残します。

 
目次

 

環境

 

わかったこと

以降で、今回分かったことについて記載していきます。

 

Railsでは ids[]=1&ids[]=2 な書式のクエリパラメータを受け取れる

Railsガイドに記載がありました。 /clients?ids[]=1&ids[]=2&ids[]=3 という書式でクエリパラメータを渡せば良いようです。

params ハッシュには、一次元のキーバリューペアの他に、ネストした配列やハッシュも保存できます。値の配列をフォームから送信するには、以下のようにキー名に空の角かっこ [] のペアを追加します。

GET /clients?ids[]=1&ids[]=2&ids[]=3

これで、受け取った params[:ids] の値は ["1", "2", "3"] になりました。ここで重要なのは、パラメータの値が常に「文字列」になることです。Railsはパラメータの型推測や型変換を行いません。

4.1 ハッシュと配列のパラメータ | Action Controller の概要 - Railsガイド

 

OpenAPIスキーマでは Parameter Object の style を使って表現する

OpenAPI v3.0 の定義によると、クエリパラメータに配列を渡す場合は style を使って表現できそうです。
https://swagger.io/specification/#parameter-object

Railsの場合は

  • style を form
  • explode を true

にすれば良さそうです。

具体的にはこんな感じです。

paths:
  /api/array_in_query_params/fruits:
    get:
      summary: |
        Arrayをクエリ文字列に入れてリクエストするAPI
      tags:
        - array_in_query_params
      parameters:
        - name: names[]
          in: query
          required: false
          description: |
            果物の名前のリスト
          schema:
            type: array
            items:
              type: string
          style: form
          explode: true
# ...

 
なお、RSpecからOpenAPIスキーマを生成するgem rswag のFAQにも同様の記載がありました。

For OpenApi 3 The style and explode keyword arguments allow you to choose how the array query parameters are serialized.

parameter name: 'widget', in: :query, type: :array, style: :form, explode: true, items: { type: string }
# /?widget[]=foo&widget[]=bar

https://github.com/rswag/rswag/wiki/FAQ#how-can-i-submit-arrays-through-query-parameters

 

ストロングパラメータでは id: [] のように指定する

ここまでで、クエリパラメータに配列を指定する場合でも、OpenAPI - Rails 間ではOpenAPIスキーマを共有できそうなことが分かりました。

次は、プロダクションコードとテストコードを実際に作っていきます。

 

まずはプロダクションコードとしてコントローラを作ります。

ストロングパラメータを使った時にクエリパラメータの配列を取得するには、 params.permit(id: []) のように指定します。

今回は

  • クエリパラメータ
    • names[] というキーで、各値が入ってくる
  • レスポンス
    • {"fruits":[{"id":<index>,"name": <各値> }, ...]} という形でレスポンスする

なコントローラを作ります。

class Api::ArrayInQueryParams::FruitsController < ApplicationController
  def index
    names = permitted_params[:names] || ['みかん']

    fruits = names.map.with_index { |name, i| { id: i, name: name } }

    render json: { fruits: fruits }
  end

  def permitted_params
    params.permit(names: [])
  end
end

 
route.rb も追加し、 /api/array_in_query_params/fruits というパスでリクエストを受け付けるようにします。

  namespace :api do
    namespace :array_in_query_params do
      resources :fruits, only: [:index]
    end
  end

 
curlでリクエストすると、想定通りのレスポンスがありました。ストロングパラメータの定義がうまくできたようです。

% curl "http://localhost:7100/api/array_in_query_params/fruits?names%5B%5D=りんご&names%5B%5D=なし"
{"fruits":[{"id":0,"name":"りんご"},{"id":1,"name":"なし"}]}

 

現時点では、Committee が Query parameter の explode に対応していない

プロダクションコードができたので、次はテストコードです。

をインストール後、RSpecでテストコードを書きます。

なお、テストコード中で期待値とするクエリパラメータの値は、 Active Support to_query メソッドを使って定義すれば良さそうでした。

配列に to_query メソッドを適用した場合、 to_query を配列の各要素に適用して key[] をキーとして追加し、それらを「&」で連結したものを返します。

[3.4, -45.6].to_query('sample')
# => "sample%5B%5D=3.4&sample%5B%5D=-45.6"

https://railsguides.jp/active_support_core_extensions.html#to-query

 
できあがったテストコードはこちらです。

context 'URLをハードコーディングする版' do
  it 'クエリ文字列で指定した配列の中身がレスポンスされる' do
    query_string = %w[りんご なし].to_query('names')
    get "/api/array_in_query_params/fruits?#{query_string}"

    assert_request_schema_confirm
    assert_response_schema_confirm(200)

    expect(actual).to eq({ fruits:[{id: 0, name: 'りんご'}, {id: 1, name: 'なし'}] })
  end
end

 
テストを実行するとパスします。

ただ、ためしにOpenAPIスキーマexplodefalse にしてもテストがパスしてしまいました。

調べてみたところ、Committee に以下の issue がありました。
No support for explode field in array query parameters · Issue #253 · interagent/committee

コメントによると、Committeeが関係しているgemの方での修正が必要なようです。
support array in query parameter · Issue #122 · ota42y/openapi_parser

そのため、現時点では、Committee が Query parameter の explode に対応していないことが分かりました。

 

OpenAPI generator typescript-axios は operationId を定義した上で使う

ひとまずRails APIは動作するようになったので、次はフロントエンドの React まわりを調べてみます。

React で OpenAPI に対応した axios クライアントを生成するには、 OpenAPI generator の typescript-axios を使います。
https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/typescript-axios.md

 
ちなみに、 typescript-axios を使う場合、デフォルトでは apiArrayInQueryParamsFruitsGet のような分かりづらいメソッド名を持つクライアントができてしまいます。

そんな中、同僚から「 operationId を指定すると良い」と聞いたため、今回定義してみます。

なお、 operationId は Operation Object にて以下のように定義されています。

Unique string used to identify the operation. The id MUST be unique among all operations described in the API. The operationId value is case-sensitive. Tools and libraries MAY use the operationId to uniquely identify an operation, therefore, it is RECOMMENDED to follow common programming naming conventions.

https://swagger.io/specification/#operation-object

 
今回のOpenAPIスキーマの場合、 operationIdfetchArrayInQueryParamsFruits として追加してみました。

paths:
  /api/array_in_query_params/fruits:
    get:
      summary: |
        Arrayをクエリ文字列に入れてリクエストするAPI

      # 追加
      # operationIdを設定しないと `apiArrayInQueryParamsFruitsGet` のような名前になるため分かりづらい
      operationId: fetchArrayInQueryParamsFruits

      tags:
        - array_in_query_params
      parameters:
        - name: names[]
          in: query
          required: false
          description: |
            果物の名前のリスト
          schema:
            type: array
            items:
              type: string
          style: form
          explode: true
# ...

 
あとは

  • docker-compose.yml に、OpenAPI generator 公式の生成用Dockerを定義
  • docker compose run --rm openapi_typescript_axios で typescript-axios クライアントを生成
  • React まわりを実装

します。

 
docker compose run --rm openapi_typescript_axios にて生成された axios クライアントで operationId を定義した結果を見てみると、 fetchArrayInQueryParamsFruits というメソッドが生えていました。

 
Reactでは、以下のようなコンポーネントを定義し、

  • フォームで検索条件を入力し、ボタンをクリック
  • axiosがRailsへリクエス
  • Railsからのレスポンスを画面に描画

します。

import {SubmitHandler, useForm} from "react-hook-form";
import {Fruit} from "@/types/typescript-axios";
import {arrayInQueryParamsApi} from "@/apiClient";
import {useState} from "react";

type FormInput = {
  name1: string
  name2: string
}


const Component = (): JSX.Element => {
  const [fruits, setFruits] = useState<Array<Fruit>>([])

  const {handleSubmit, register} = useForm<FormInput>({
    criteriaMode: 'all',
  })

  const onSubmit: SubmitHandler<FormInput> = async (formInput) => {
    const names = Object.values(formInput).filter(v => !!v)
    const {data} = await arrayInQueryParamsApi.fetchArrayInQueryParamsFruits(names)

    setFruits(data.fruits)
  }

  return (
    <>
      <h1>Array In Query Params Page</h1>

      <h2>検索条件</h2>

      <form onSubmit={handleSubmit(onSubmit)}>
        <p>
          <label>
            1つ目の果物を入れてください:
            <input type="text" {...register('name1')} />
          </label>
        </p>

        <p>
          <label>
            2つ目の果物を入れてください:
            <input type="text" {...register('name2')} />
          </label>
        </p>

        <button type="submit">検索</button>
      </form>

      <h2>結果</h2>

      {fruits && (
        <ul>
          {fruits.map((fruit) => <li key={fruit.id}>{fruit.id}: {fruit.name}</li>)}
        </ul>
      )}
    </>
  )
}

export default Component

 
また、axiosで生成するクエリパラメータを確認できるよう、OpenAPI generator で生成した axios クライアントのラッパーを作ります。

import * as api from './types/typescript-axios/api'
import axios, {AxiosInstance} from "axios";

const instance = axios.create({
  headers: {
    'Content-Type': 'application/json',
  },
  responseType: 'json'
})

instance.interceptors.request.use(request => {
  // どんなURLでリクエストしているかをconsole出力してみる
  console.log(request.url)
  return request
})

const options: [undefined, string, AxiosInstance] = [undefined, '', instance]

export const arrayInQueryParamsApi = new api.ArrayInQueryParamsApi(...options)

 
これらをためしてみたところ、画面が描画されました。React - Rails 間で正しく通信できているようです。

 
ブラウザのコンソールを見ても、 ?names%5B%5D=apple&names%5B%5D=banana のように Rails が期待する形でクエリパラメータが付与されていました。

 

OpenAPI generator の Ruby クライアントでは、希望する形での配列ができない

typescript-axios ではクライアントを生成できたので、次は Ruby クライアントを試してみます。
https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/ruby.md

 
Ruby クライアントは

  • typhoeus
  • faraday

の2つがベースになります。

なお、 faraday の方はベータサポートのようです。

 
まずは Ruby クライアントを利用するアプリを作成します。

といっても、Rubyクライアントの利用アプリを別途作成するのは手間です。

そのため、Active Job の中で Ruby クライアントを利用して動作を確認してみます。

 
Active Job でRubyクライアントを利用するまでの流れは以下のとおりです。

  • Rails API のコントローラに Active Job を起動するエンドポイントを作成し、そこに curl でPOST
  • Active Job の中で、Ruby クライアントを使って、 /api/array_in_query_params/fruits へアクセス
  • レスポンスを出力する

 
コントローラはこんな感じ。合わせて route.rb も修正しておきます。

class Api::ArrayInQueryParams::FruitsController < ApplicationController
  protect_from_forgery with: :null_session, only: :create

  # ...

  def create
    OpenapiClientJob.perform_later
  end

  # ...
end

 
ジョブはこんな感じです。

class OpenapiClientJob < ApplicationJob
  queue_as :default

  def perform(*args)
    # response = fetch_by_openapi_client

    puts response
  end

  private def fetch_by_openapi_client
    api_instance = OpenapiClient::ArrayInQueryParamsApi.new

    api_instance.fetch_array_in_query_params_fruits({'names': %w[バナナ マンゴー]})
  end
end

 
次に、Rubyクライアントを生成します。

まずは typhoeus を使うため、 docker-compose に以下のコマンドを指定します。

generate -i local/openapi.yaml -g ruby -o local/openapi/gems/typhoeus --library=typhoeus

 
生成された Ruby クライアントは gem になっているため、Gemfile に追加してインストールします。

gem 'openapi_client', path: './openapi/gems/typhoeus'

 
これで準備ができたため、curlでアクセスします。

curl -X POST "http://localhost:7100/api/array_in_query_params/fruits"

 
APIのログを見ると

"/api/array_in_query_params/fruits?names%5B%5D%5B0%5D=%E3%83%90%E3%83%8A%E3%83%8A&names%5B%5D%5B1%5D=%E3%83%9E%E3%83%B3%E3%82%B4%E3%83%BC"

と出力されていました。

クエリパラメータは分かりにくいですが、 params を見ると

"names"=>[{"0"=>"バナナ", "1"=>"マンゴー"}]

となっていました。

想定していたRailsのクエリパラメータの形式とは異なるようです。

 
次に faraday 版を試してみます。

command を以下に差し替えて Ruby クライアントを生成します。

generate -i local/openapi.yaml -g ruby -o local/openapi/gems/faraday --library=faraday

 
同様に curl で試してみると、APIのログには

/api/array_in_query_params/fruits?names%5B%5D%5B%5D=%E3%83%90%E3%83%8A%E3%83%8A&names%5B%5D%5B%5D=%E3%83%9E%E3%83%B3%E3%82%B4%E3%83%BC

と出力され、 params

"names"=>[["バナナ"], ["マンゴー"]]

となっていました。

こちらも想定していたRailsのクエリパラメータの形式とは異なるようです。

 

素の faraday を使う場合はデフォルトでRailsの書式になった

ここまでのRubyクライアントはOpenAPIスキーマを元にして生成しましたが、最後に素のfaradayを使って試してみます。

同様にActive Jobへ

Faraday.get('http://localhost:7100/api/array_in_query_params/fruits', names: %w[バナナ マンゴー])

と定義してつかってみます。

すると、クエリ文字列は

/api/array_in_query_params/fruits?names%5B%5D=%E3%83%90%E3%83%8A%E3%83%8A&names%5B%5D=%E3%83%9E%E3%83%B3%E3%82%B4%E3%83%BC

となり、 params

"names"=>["バナナ", "マンゴー"]}

と想定したRailsの書式になっていました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample/pull/6

React17 + MUI DateTimePicker + React Hook Form なアプリを yarn upgrade --latest したら破壊的変更が入っていたので修正した

以前、 React17 + MUI + React Hook Form なアプリを作り、以下のリポジトリとして保存していました。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample

 
公開から時間が経過していたこともあり、せっかくなので最新のReactなどに追随するべくアップグレードを考えました。

個人のリポジトリだし多少壊れてもいいだろうと考え yarn upgrade --latest したところ、いくつかの機能が動かなくなっていました。

そこで、動くように対応した時のメモを残します。

 
目次

 

環境

アップグレード前

GithubのREADMEのものを転載します。

  • React.js 17.0.2
  • React Router 6.2.1
  • @mui/material 5.2.4
  • @mui/lab 5.0.0-alpha.60
  • @mui/x-data-grid 5.0.1
  • date-fns 2.27.0
  • TypeScript 4.5.2
  • Vite.js 2.6.14
  • Jest 27.3.1
  • use-react-router-breadcrumbs 3.0.1
  • react-hook-form 7.22.2

 

アップグレード後

  • React.js 18.2.0
  • React Router 6.3.0
  • @mui/material 5.9.0
  • @mui/x-data-grid 5.13.1
  • @mui/x-date-pickers 5.0.0-beta.1
  • date-fns 2.28.0
  • @date-io/date-fns 2.14.0
  • TypeScript 4.7.4
  • Vite.js 3.0.0
  • Jest 28.1.3
  • use-react-router-breadcrumbs 3.2.1
  • react-hook-form 7.33.1

 

yarn upgrade --latest ですべて最新化

yarn upgrade --latest を実行して、各パッケージを最新化します。

 

破壊的変更への対応

アップグレード後、動作しなくなったり、エラー・ワーニングが出ていたりしたので、それらに対応していきます。

 

React 18 まわり

Client Rendering APIs の変更に対応

React 18から Client Rendering API に変更が入ったため、対応します。
https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#updates-to-client-rendering-apis

今回はドキュメント通りに修正すれば問題ありませんでした。

差分はこんな感じです。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/commit/6e51aa84c1a7ed8faeb1fa6a060a10bb9f12310c

 

MUI まわり 

DateTimePicker まわりの破壊的変更に対応

今回のアプリでは MUI の DateTimePicker を使っています。

MUIのドキュメントによると、 DateTimePicker 系のコンポーネントが書き直されたようです。

⚠️ The date picker components were rewritten. In most places, the logic was rewritten from scratch, so it isn't possible to maintain the whole list of changes. Here's an overview of the most important concepts that were changed. If you are going to upgrade, the easiest way might be to go through each picker usage in your codebase, and rewrite them one at a time. Don't forget to run your tests after each!

Migration from @material-ui-pickers - Material UI

 
以下で修正点をあげていきます。

 

importするパッケージ変更に対応

今までの @mui/lab パッケージではなく、固有のパッケージを import するようになりました。

  • DateTimePickerLocalizationProvider
    • 変更前
      • mui/lab
    • 変更後
      • @mui/x-date-pickers 関係
  • AdapterDateFns
    • 変更前
      • @mui/lab/AdapterDateFns
    • 変更後
      • @mui/x-date-pickers/AdapterDateFns
  • date-fns のアダプター
    • 追加
      • @date-io/date-fns

 
今までの package.json には記載されていないパッケージもあったため、以下のように追加でインストールします。

yarn add @mui/x-date-pickers

yarn add @date-io/date-fns

 
次に import を変更します。

- import {DateTimePicker, LocalizationProvider} from '@mui/lab'
- import AdapterDateFns from '@mui/lab/AdapterDateFns'

+ import {DateTimePicker} from '@mui/x-date-pickers/DateTimePicker'
+ import {LocalizationProvider} from '@mui/x-date-pickers'
+ import {AdapterDateFns} from '@mui/x-date-pickers/AdapterDateFns'

 
コミットだとこのあたりです。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/commit/166a8fa5bf76ee69cf200ffa4df7bd1e588f77f9#diff-230f0842f24ca99f8edf5e719bf17de385c572e313039903e9e396c644156191L3-R5

 

LocalizationProvider の locale を adapterLocale へ変更

import の変更だけを行った場合、 LocalizationProvider を使っているコンポーネントで以下のエラーが出ます。

LocalizationProvider's prop `locale` is deprecated and replaced by `adapterLocale`

 
MUIのドキュメントを見ると、現在の実装では locale ではなく adapterLocale になっていました。
https://mui.com/x/react-date-pickers/localization/#translation-keys

 
そこで手元の実装も、 adapterLocale を使うように修正します。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/commit/27889dd5b0fbdba69a3f5af162fbbcb56ebf2443

- <LocalizationProvider dateAdapter={AdapterDateFns} locale={ja}>

+ <LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={ja}>

 

React Hook Form まわり

React Hook Formではそこまで大きな変更がなく、型の追加となりました。

 

useForm に型を追加

React Hook Form をアップグレードしたところ、 useFrom のところでコンパイルエラーが出ていました。

React Hook Form の公式ドキュメント(TypeScript版)を見たところ、 useForm で型を指定していました。
https://react-hook-form.com/get-started/

そこで、今まで型がなかったuseForm に対し、型を追加しました。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/commit/71ea8381c1abadf5943da9f7cd038763d483ab55

- const {control, handleSubmit, setValue} = useForm()

+ const {control, handleSubmit, setValue} = useForm<FormInput>()

 

setValueの引数の型を修正

useForm に型を与えたことにより、 setValue の引数の型も明確になりました。

そこで、既存の型を修正し、 null 許可にしました。

type FormInput = {

- inputValue: Date

+ inputValue: Date | null

}

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/pull/8

Rails + Active Job + Delayed::Jobにて、ジョブを作ってみたり、メールジョブと組み合わせてみたり、テストを書いてみたりしてみた

Rails + Active Job + Delayed::Jobな構成でジョブを作ってみた時に、色々調べたためメモを残します。

なお、記事が長いため、途中のソースコードは説明使う部分以外を省略しています。

必要に応じて、実際のソースコードGithub リポジトリでご確認ください。
https://github.com/thinkAmi-sandbox/rails_delayed_job-sample

 
目次

 

環境

 

環境構築

rails new

今回は、RailsAPIアプリ + Delayed::Job の組み合わせで色々試してみます。

rails new して bundle installします。

% bundle exec rails new rails_delayed_job_sample --api --skip-bundle

% cd rails_delayed_job_sample

% bundle install

 

Delayed::Jobのセットアップ

Delayed::JobのREADMEに従いセットアップを行います。

今回は Delayed::Jobのバックエンドに Active Record を使うため、Gemfileに delayed_job_active_record を追加し、bundle installします。

gem 'delayed_job_active_record'

 
続いてジェネレートとマイグレーションを実行します。

# ジェネレート
% bin/rails generate delayed_job:active_record
      create  bin/delayed_job
       chmod  bin/delayed_job
      create  db/migrate/20220703062311_create_delayed_jobs.rb

# DB作成
 % bin/rails db:migrate
== 20220703062311 CreateDelayedJobs: migrating ================================
-- create_table(:delayed_jobs)
   -> 0.0020s
-- add_index(:delayed_jobs, [:priority, :run_at], {:name=>"delayed_jobs_priority"})
   -> 0.0007s
== 20220703062311 CreateDelayedJobs: migrated (0.0029s) =======================

 
次に、Active Job の queue_adapter として使えるよう、 config/application.rb に設定を追加します。

config.active_job.queue_adapter = :delayed_job

 

APIアプリのモデルを作成

annotate を使う

モデルにスキーマを明示するため、 annotate gemをGemfileに追加し、bundle installします。
https://github.com/ctran/annotate_models

 
インストールが終わったら、ジェネレータを使って annotate の初期設定を行います。

% bin/rails g annotate:install

 

モデルを作成

今回は name 列だけを持つ apple モデルを使います。

bin/rails g model apple name:string
      invoke  active_record
      create    db/migrate/20220703065930_create_apples.rb
      create    app/models/apple.rb
      invoke    test_unit
      create      test/models/apple_test.rb
      create      test/fixtures/apples.yml

 
以上で準備ができました。

 

Delayed::Jobを試す

初めてのジョブを作る

実装

初めてのジョブとして

  1. コントローラで、HTTPリクエストボディを元に、 apple モデルへデータを保存する
  2. コントローラで、ジョブをキューに登録する
  3. HTTPレスポンスを返す

を作ってみます。

 
まずはジョブから作成します。

ジョブ生成用のジェネレータを使います。
3 ジョブを作成する | Active Job の基礎 - Railsガイド

% bin/rails g job apple_instance       
      invoke  test_unit
      create    test/jobs/apple_instance_job_test.rb
      create  app/jobs/apple_instance_job.rb

 
ジョブの雛形ができたので、次はコントローラを作成します。

なお、ジョブにモデルのインスタンスを直接渡せるのは、GlobalIDという仕組みのようです。
10 GlobalID | Active Job の基礎 - Railsガイド

class Api::PassingInstance::ApplesController < ApplicationController
  def create
    # モデルへの保存
    apple = Apple.new(name: params[:name])
    apple.save

    # モデルのインスタンスを渡してジョブをキューに登録する
    AppleInstanceJob.perform_later(apple)

    render json: { status: params[:name]}
  end
end

 
続いて、ジョブを編集します。

今回は、受け取ったモデルのインスタンスをログ出力します。

class AppleInstanceJob < ApplicationJob
  queue_as :default

  def perform(apple)
    puts "[instance job] (#{apple.name}) #{Time.zone.now} start ============>"
    puts "[instance job] (#{apple.name}) #{Time.zone.now} end   <============"
  end
end

 
最後に routes.rb にルーティングを追加します。

namespace :api do
  namespace :passing_instance do
    resources :apples, only: :create
  end
end

 

動作確認

Railsを起動後、curlAPIエンドポイントにHTTPリクエストします。

% curl -X POST -H "Content-Type: application/json" -d '{"name":"ふじ"}' http://localhost:3000/api/passing_instance/apples
{"status":"ふじ"}

 
delayed_jobs テーブルの中身を見ると、データが登録されています。

 
bin/rails jobs:work で Delayed Job Worker を起動し、ワーカーのログを確認します。

作成したジョブが動作したようです。

[Worker(host:*** pid:97322)] Starting job worker
[Worker(host:***pid:97322)] Job AppleInstanceJob [d93ef4e6-9162-4cf3-b805-a03eb751e4c6] from DelayedJob(default) with arguments: [{"_aj_globalid"=>"gid://rails-delayed-job-sample/Apple/23"}] (id=114) (queue=default) RUNNING
[instance job] (ふじ) 2022-07-10 08:15:21 +0900 start ============>
[instance job] (ふじ) 2022-07-10 08:15:21 +0900 end   <============
[Worker(host:*** pid:97322)] Job AppleInstanceJob [d93ef4e6-9162-4cf3-b805-a03eb751e4c6] from DelayedJob(default) with arguments: [{"_aj_globalid"=>"gid://rails-delayed-job-sample/Apple/23"}] (id=114) (queue=default) COMPLETED after 0.0272
[Worker(host:*** pid:97322)] 1 jobs processed at 15.4619 j/s, 0 failed

 

引数としてサポートされていない、OpenStruct 型の引数を試してみる

Active Job でサポートされる引数の型の一覧は以下にあります。
9 引数でサポートされる型 | Active Job の基礎 - Railsガイド

 
上記に書かれていない型を引数として渡すとどうなるかを試してみます。

今回は OpenStruct を使ってみます。
class OpenStruct (Ruby 3.1 リファレンスマニュアル)

 
ジョブを生成します。

% bin/rails g job open_struct
      invoke  test_unit
      create    test/jobs/open_struct_job_test.rb
      create  app/jobs/open_struct_job.rb

 
コントローラを実装します。

class Api::PassingOpenStruct::ApplesController < ApplicationController
  def create
    apple = OpenStruct.new(
      {
        id: 1,
        name: params[:name]
      }
    )

    # OpenStruct を渡す
    OpenStructJob.perform_later(apple)

    render json: { status: apple.name}
  end
end

 
ジョブを修正します。

class OpenStructJob < ApplicationJob
  queue_as :default

  def perform(apple)
    puts "[open_struct job] (#{apple.name}) #{Time.zone.now} start ============>"
    puts "[open_struct job] (#{apple.name}) #{Time.zone.now} end   <============"
  end
end

 
routes を追加し、Railsを起動した後に curl でアクセスすると、エラーになりました。

% curl -X POST -H "Content-Type: application/json" -d '{"name":"ふじ"}' http://localhost:3000/api/passing_open_struct/apples
{"status":500,"error":"Internal Server Error", ... }

 
Railsのログには OpenStruct 型がサポートされていない旨が出力されていました。

Started POST "/api/passing_open_struct/apples" for 127.0.0.1 at 2022-07-10 08:31:51 +0900
   (0.0ms)  SELECT sqlite_version(*)
Processing by Api::PassingOpenStruct::ApplesController#create as */*
  Parameters: {"name"=>"ふじ", "apple"=>{"name"=>"ふじ"}}
[ActiveJob] Failed enqueuing OpenStructJob to DelayedJob(default): ActiveJob::SerializationError (Unsupported argument type: OpenStruct)

 
OpenStruct 型をサポートするには、シリアライザを定義すれば良いようです。
10.1 シリアライザ | Active Job の基礎 - Railsガイド

 
ただ、Ruby 3.0 から OpenStruct の使用に警告が入ったこともあり、今後新規で OpenStruct を使うのは控えることになるのかなと思っています。

 
そこで、今回は OpenStruct のシリアライザ定義をがんばるのではなく、 to_h でハッシュにします。

 
コントローラを修正します。

class Api::PassingOpenStruct::ApplesController < ApplicationController
  def create
    apple = OpenStruct.new(
      {
        id: 1,
        name: params[:name]
      }
    )

    # ハッシュにするよう修正
    OpenStructJob.perform_later(apple.to_h)

    render json: { status: apple.name}
  end
end

 
ハッシュから取り出すよう、ジョブも修正します。

class OpenStructJob < ApplicationJob
  queue_as :default

  def perform(apple)
    puts "[open_struct job] (#{apple[:name]}) #{Time.zone.now} start ============>"
    puts "[open_struct job] (#{apple[:name]}) #{Time.zone.now} end   <============"
  end
end

 
再度 curl でアクセスすると、正常なレスポンスが返ってきました。

% curl -X POST -H "Content-Type: application/json" -d '{"name":"ふじ"}' http://localhost:3000/api/passing_open_struct/apples
{"status":"ふじ"}

ワーカーのログにも正常に終了したことが出力されています。

[open_struct job] (ふじ) 2022-07-10 09:21:39 +0900 start ============>
[open_struct job] (ふじ) 2022-07-10 09:21:39 +0900 end   <============

 

Delayed::Jobにて、モデルのインスタンスメソッド実行をジョブ化する

Delayed::Job の README には、 .delay.method(params) とメソッドの前に .delay をはさむことでメソッドをジョブ実行できる旨が記載されているため、試してみます。
https://github.com/collectiveidea/delayed_job#queuing-jobs

 
モデルにインスタンスメソッド (post_api) を生やします。

class Apple < ApplicationRecord
  def post_api
    # 外部APIを呼んでいるとする
    puts "[instance] (#{name}) #{Time.zone.now} start ============>"
    puts "[instance] (#{name}) #{Time.zone.now} end   <============"
  end
end

 
コントローラにて、 .delay 付きでモデルのインスタンスメソッドを呼んでみます。

class Api::InstanceMethod::ApplesController < ApplicationController
  def create
    apple = Apple.new(name: params[:name])
    apple.save

    # モデルのインスタンスメソッドをジョブ実行
    apple.delay.post_api

    render json: { status: params[:name]}
  end
end

 
ルーティングの追加とRailsの起動を行い、curlで動作確認します。

% curl -X POST -H "Content-Type: application/json" -d '{"name":"ふじ"}' http://localhost:3000/api/instance_method/apples    
{"status":"ふじ"}

 
ワーカーのログに実行結果が出力されていました。

[Worker(host:*** pid:97322)] Job Apple#post_api (id=116) RUNNING
[instance] (ふじ) 2022-07-10 09:30:22 +0900 start ============>
[instance] (ふじ) 2022-07-10 09:30:22 +0900 end   <============
[Worker(host:*** pid:97322)] Job Apple#post_api (id=116) COMPLETED after 0.0044

 

キューを振り分ける

準備

Delayed::Job では、名前付きキューを使うことでキューを振り分けることができることから、試してみます。
https://github.com/collectiveidea/delayed_job#named-queues

 
まずは、名前付きキューを使うジョブを3つ用意します。

キュー名:default

class DefaultQueueJob < ApplicationJob
  queue_as :default

  def perform(apple_name)
    puts "[default queue job] (#{apple_name}) #{Time.zone.now} start ============>"
    sleep 1
    puts "[default queue job] (#{apple_name}) #{Time.zone.now} end   <============"
  end
end

 
キュー名:custom

このジョブは重く、途中で10秒かかるものとします

class CustomQueueJob < ApplicationJob
  queue_as :custom

  def perform(apple_name)
    puts "[custom queue job] (#{apple_name}) #{Time.zone.now} start ============>"
    sleep 10
    puts "[custom queue job] (#{apple_name}) #{Time.zone.now} end   <============"
  end
end

 
キュー名:another

class AnotherQueueJob < ApplicationJob
  queue_as :another

  def perform(apple_name)
    puts "[another queue job] (#{apple_name}) #{Time.zone.now} start ============>"
    puts "[another queue job] (#{apple_name}) #{Time.zone.now} end   <============"
  end
end

 
続いて、これらのジョブを使うようなコントローラ、およびルーティングを追加します。

コントローラはこんな感じです。

class Api::QueueName::DefaultQueuesController < ApplicationController
  def create
    DefaultQueueJob.perform_later(params[:name])

    render json: { status: params[:name]}
  end
end

 
準備が終わったため、キューやワーカーの条件を変えて動作確認してみます。

 

環境変数 QUEUE や QUEUES を使い、Delayed::Job でワーカーが扱えるキューを指定する

Delayed::Job でワーカーが扱えるキューの設定方法を調べたところ、READMEに記載がありました。

If you want to just run all available jobs and exit you can use rake jobs:workoff

Work off queues by setting the QUEUE or QUEUES environment variable.

QUEUE=tracking rake jobs:work
QUEUES=mailers,tasks rake jobs:work

 

2つの名前付きキューに入れて、すべてのキューを扱えるワーカー1つを動かす

最初に、ワーカーを停止しておきます。

次に、キュー名 custom -> another -> custom の順でキューに積んでおきます。

# custom
curl -X POST -H "Content-Type: application/json" -d '{"name":"ふじ"}' http://localhost:3000/api/queue_name/custom_queues

# another
curl -X POST -H "Content-Type: application/json" -d '{"name":"秋映"}' http://localhost:3000/api/queue_name/another_queues

# もう一回、custom
curl -X POST -H "Content-Type: application/json" -d '{"name":"ふじ"}' http://localhost:3000/api/queue_name/custom_queues

 
ここで delayed_job テーブルを確認すると、3件のレコードが登録されています。

 
続いて bin/rails jobs:work でワーカーを起動します。

ワーカーには環境変数を設定していないため、すべてのキューを扱うことができました。

また、優先度も設定していないため、キューに入った順番に処理が実行されました。

[Worker(host:*** pid:2223)] Job CustomQueueJob [1f86142c-58ec-401e-ae7a-5b9c528ded85] from DelayedJob(custom) with arguments: ["ふじ"] (id=121) (queue=custom) RUNNING
[custom queue job] (ふじ) 2022-07-10 10:04:04 +0900 start ============>
[custom queue job] (ふじ) 2022-07-10 10:04:14 +0900 end   <============
[Worker(host:*** pid:2223)] Job CustomQueueJob [1f86142c-58ec-401e-ae7a-5b9c528ded85] from DelayedJob(custom) with arguments: ["ふじ"] (id=121) (queue=custom) COMPLETED after 10.0265
[Worker(host:*** pid:2223)] Job AnotherQueueJob [c55ea7b9-8a13-4d6d-aea3-b8954ea910fa] from DelayedJob(another) with arguments: ["秋映"] (id=122) (queue=another) RUNNING
[another queue job] (秋映) 2022-07-10 10:04:14 +0900 start ============>
[another queue job] (秋映) 2022-07-10 10:04:14 +0900 end   <============
[Worker(host:*** pid:2223)] Job AnotherQueueJob [c55ea7b9-8a13-4d6d-aea3-b8954ea910fa] from DelayedJob(another) with arguments: ["秋映"] (id=122) (queue=another) COMPLETED after 0.0141
[Worker(host:*** pid:2223)] Job CustomQueueJob [6f69a43e-3a4b-42ae-9a79-2be09659f5f9] from DelayedJob(custom) with arguments: ["ふじ"] (id=123) (queue=custom) RUNNING
[custom queue job] (ふじ) 2022-07-10 10:04:14 +0900 start ============>
[custom queue job] (ふじ) 2022-07-10 10:04:24 +0900 end   <============
[Worker(host:*** pid:2223)] Job CustomQueueJob [6f69a43e-3a4b-42ae-9a79-2be09659f5f9] from DelayedJob(custom) with arguments: ["ふじ"] (id=123) (queue=custom) COMPLETED after 10.0187
[Worker(host:*** pid:2223)] 3 jobs processed at 0.1491 j/s, 0 failed

 

3つの名前付きキューに入れて、指定のキューを扱えるワーカー2つを動かす

先ほどと同様、curlでアクセスしてキューにためておきます。

# default
curl -X POST -H "Content-Type: application/json" -d '{"name":"つがる"}' http://localhost:3000/api/queue_name/default_queues

# custom
curl -X POST -H "Content-Type: application/json" -d '{"name":"ふじ"}' http://localhost:3000/api/queue_name/custom_queues

# another
curl -X POST -H "Content-Type: application/json" -d '{"name":"秋映"}' http://localhost:3000/api/queue_name/another_queues

 
次に、ワーカーを起動します。

localで複数のワーカーを起動する場合、 bin/rails jobs:work を別ターミナルで起動することになります。今回は worker1worker2 を用意します。

その際、ターミナルごとに環境変数を設定して起動します。

  • worker1は QUEUE=default
  • worker2は QUEUES=custom,another

 
worker1のログを見ると、キュー名 default だけを扱っています。

[Worker(host:*** pid:3308)] Job DefaultQueueJob [6659c50b-0182-4d69-9d16-83dd95213812] from DelayedJob(default) with arguments: ["つがる"] (id=127) (queue=default) RUNNING
[default queue job] (つがる) 2022-07-10 10:17:22 +0900 start ============>
[default queue job] (つがる) 2022-07-10 10:17:23 +0900 end   <============
[Worker(host:*** pid:3308)] Job DefaultQueueJob [6659c50b-0182-4d69-9d16-83dd95213812] from DelayedJob(default) with arguments: ["つがる"] (id=127) (queue=default) COMPLETED after 1.0250
[Worker(host:*** pid:3308)] 1 jobs processed at 0.9412 j/s, 0 failed

 
worker2のログを見ると、キュー名 customanother を扱っています。

worker1は空いているにも関わらず、キュー another は worker2 で動いています。

[Worker(host:*** pid:3672)] Job CustomQueueJob [0271bd52-abca-401d-bcc3-668def1447b7] from DelayedJob(custom) with arguments: ["ふじ"] (id=128) (queue=custom) RUNNING
[custom queue job] (ふじ) 2022-07-10 10:17:25 +0900 start ============>
[custom queue job] (ふじ) 2022-07-10 10:17:35 +0900 end   <============
[Worker(host:*** pid:3672)] Job CustomQueueJob [0271bd52-abca-401d-bcc3-668def1447b7] from DelayedJob(custom) with arguments: ["ふじ"] (id=128) (queue=custom) COMPLETED after 10.0248
[Worker(host:*** pid:3672)] Job AnotherQueueJob [f4a407d3-b34f-4c81-8701-6c650c1003d6] from DelayedJob(another) with arguments: ["秋映"] (id=129) (queue=another) RUNNING
[another queue job] (秋映) 2022-07-10 10:17:35 +0900 start ============>
[another queue job] (秋映) 2022-07-10 10:17:35 +0900 end   <============
[Worker(host:*** pid:3672)] Job AnotherQueueJob [f4a407d3-b34f-4c81-8701-6c650c1003d6] from DelayedJob(another) with arguments: ["秋映"] (id=129) (queue=another) COMPLETED after 0.0133
[Worker(host:*** pid:3672)] 2 jobs processed at 0.1986 j/s, 0 failed

 

ネストしたジョブの扱いについて

親ジョブから子ジョブを生成する

Delayed::Jobでは、あるジョブ(親)から別のジョブ(子) を生成できます。

試してみます。

class ParentJob < ApplicationJob
  queue_as :default

  def perform(prefix)
    puts "[parent job] (#{prefix}) #{Time.zone.now} start ============>"
    
    # 子のジョブを生成する
    ChildJob.perform_later(prefix)
    
    puts "[parent job] (#{prefix}) #{Time.zone.now} end   <============"
  end
end

 

class ChildJob < ApplicationJob
  queue_as :default

  def perform(prefix)
    puts "[child job] (#{prefix}デリシャス) #{Time.zone.now} start ============>"
    puts "[child job] (#{prefix}デリシャス) #{Time.zone.now} end   <============"
  end
end

 
コントローラでは、親ジョブのみ生成します。

class Api::ParentChild::StarkingsController < ApplicationController
  def create
    ParentJob.perform_later(params[:prefix])

    render json: { status: params[:prefix]}
  end
end

 
ルーティングを作成し、Railsとワーカーを起動した後、curlでアクセスします。

% curl -X POST -H "Content-Type: application/json" -d '{"prefix":"スターキング"}' http://localhost:3000/api/parent_child/starkings

 
delayed_jobs テーブルには親のみ登録されています。

 
ワーカーを起動すると、ワーカーのログに以下が出力されました。親→子の順番で処理されたようです。

[Worker(host:*** pid:4569)] Job ParentJob [84349539-8a02-4dd3-8139-93a13c9b73a0] from DelayedJob(default) with arguments: ["スターキング"] (id=130) (queue=default) RUNNING
[parent job] (スターキング) 2022-07-10 10:27:54 +0900 start ============>
[parent job] (スターキング) 2022-07-10 10:27:54 +0900 end   <============
[Worker(host:*** pid:4569)] Job ParentJob [84349539-8a02-4dd3-8139-93a13c9b73a0] from DelayedJob(default) with arguments: ["スターキング"] (id=130) (queue=default) COMPLETED after 0.0250
[Worker(host:*** pid:4569)] Job ChildJob [b7bb8376-a19a-47d4-bbab-5d8e2012e5bb] from DelayedJob(default) with arguments: ["スターキング"] (id=131) (queue=default) RUNNING
[child job] (スターキングデリシャス) 2022-07-10 10:27:54 +0900 start ============>
[child job] (スターキングデリシャス) 2022-07-10 10:27:54 +0900 end   <============
[Worker(host:*** pid:4569)] Job ChildJob [b7bb8376-a19a-47d4-bbab-5d8e2012e5bb] from DelayedJob(default) with arguments: ["スターキング"] (id=131) (queue=default) COMPLETED after 0.0086
[Worker(host:*** pid:4569)] 2 jobs processed at 31.0791 j/s, 0 failed

 

親のジョブと別ジョブがキューに登録されていた場合の挙動

今度は

  • 親ジョブ
  • 別のジョブ

が登録されていた場合、どの順番で実行されるかを試してみます。

 

# 親のジョブ
% curl -X POST -H "Content-Type: application/json" -d '{"prefix":"スターキング"}' http://localhost:3000/api/parent_child/starkings

# 別のジョブ
% curl -X POST -H "Content-Type: application/json" -d '{"name":"つがる"}' http://localhost:3000/api/queue_name/default_queues

 
ワーカーを起動する前の delayed_jobs テーブルです。

2レコード登録され、2つ目が別のジョブになっています。

 
ワーカーを起動すると、ワーカーのログには 親 → 別 → 子 の順番で処理されました。

キューに入った順番でワーカーは処理するようです。

[Worker(host:*** pid:5208)] Job ParentJob [2276fe30-0102-4dfc-af2c-83fc77aa4169] from DelayedJob(default) with arguments: ["スターキング"] (id=132) (queue=default) RUNNING
[parent job] (スターキング) 2022-07-10 10:33:22 +0900 start ============>
[parent job] (スターキング) 2022-07-10 10:33:22 +0900 end   <============
[Worker(host:*** pid:5208)] Job ParentJob [2276fe30-0102-4dfc-af2c-83fc77aa4169] from DelayedJob(default) with arguments: ["スターキング"] (id=132) (queue=default) COMPLETED after 0.0241
[Worker(host:*** pid:5208)] Job DefaultQueueJob [65dbfba4-8047-4814-87ea-763ec390ef3a] from DelayedJob(default) with arguments: ["つがる"] (id=133) (queue=default) RUNNING
[default queue job] (つがる) 2022-07-10 10:33:22 +0900 start ============>
[default queue job] (つがる) 2022-07-10 10:33:23 +0900 end   <============
[Worker(host:*** pid:5208)] Job DefaultQueueJob [65dbfba4-8047-4814-87ea-763ec390ef3a] from DelayedJob(default) with arguments: ["つがる"] (id=133) (queue=default) COMPLETED after 1.0195
[Worker(host:*** pid:5208)] Job ChildJob [0bbda4f7-2bf8-4d34-8631-0fcacfe238c9] from DelayedJob(default) with arguments: ["スターキング"] (id=134) (queue=default) RUNNING
[child job] (スターキングデリシャス) 2022-07-10 10:33:24 +0900 start ============>
[child job] (スターキングデリシャス) 2022-07-10 10:33:24 +0900 end   <============
[Worker(host:*** pid:5208)] Job ChildJob [0bbda4f7-2bf8-4d34-8631-0fcacfe238c9] from DelayedJob(default) with arguments: ["スターキング"] (id=134) (queue=default) COMPLETED after 0.0128
[Worker(host:*** pid:5208)] 3 jobs processed at 2.7135 j/s, 0 failed

 

親のジョブと別ジョブがキューに登録され、かつ、別ジョブは子ジョブよりも優先度が低い場合の挙動

Active Jobでは、 queue_with_priority に値を設定することで、ジョブの優先度を付けることができます。
https://api.rubyonrails.org/v7.0/classes/ActiveJob/QueuePriority/ClassMethods.html

 
そこで、queue_with_priority に値を設定した別ジョブ LowPriorityJob を作成し、子のジョブより優先度を下げてみて、動作を確認してみます。

class LowPriorityJob < ApplicationJob
  queue_as :default
  queue_with_priority 30

  def perform(*args)
    puts "[low priority job] #{Time.zone.now} start ============>"
    puts "[low priority job] #{Time.zone.now} end   <============"
  end
end

 
コントローラーではジョブを呼ぶだけです。

class Api::ParentChild::LowPrioritiesController < ApplicationController
  def create
    LowPriorityJob.perform_later(params[:name])

    render json: { status: params[:name]}
  end
end

 
ルーティングを作成し、Railsとワーカーを起動した後、curlでアクセスします。

# 親ジョブ
% curl -X POST -H "Content-Type: application/json" -d '{"prefix":"スターキング"}' http://localhost:3000/api/parent_child/starkings

# 別ジョブ
% curl -X POST -H "Content-Type: application/json" -d '{"name":"国光"}' http://localhost:3000/api/parent_child/low_priorities

 
delayed_jobs テーブルを見ると、2つ目のジョブの priority が設定されていました。

 
この状態でワーカーを起動します。

ワーカーのログには 親 → 子 → 別 の順番で処理した結果が記録されていました。

[Worker(host:*** pid:6162)] Job ParentJob [ce97898f-c43d-43ba-bf5e-62b12de598cb] from DelayedJob(default) with arguments: ["スターキング"] (id=138) (queue=default) RUNNING
[parent job] (スターキング) 2022-07-10 10:44:22 +0900 start ============>
[parent job] (スターキング) 2022-07-10 10:44:22 +0900 end   <============
[Worker(host:*** pid:6162)] Job ParentJob [ce97898f-c43d-43ba-bf5e-62b12de598cb] from DelayedJob(default) with arguments: ["スターキング"] (id=138) (queue=default) COMPLETED after 0.0214
[Worker(host:*** pid:6162)] Job ChildJob [e18aa897-63c4-4e7d-8c84-c3c5d0c08172] from DelayedJob(default) with arguments: ["スターキング"] (id=140) (queue=default) RUNNING
[child job] (スターキングデリシャス) 2022-07-10 10:44:22 +0900 start ============>
[child job] (スターキングデリシャス) 2022-07-10 10:44:22 +0900 end   <============
[Worker(host:*** pid:6162)] Job ChildJob [e18aa897-63c4-4e7d-8c84-c3c5d0c08172] from DelayedJob(default) with arguments: ["スターキング"] (id=140) (queue=default) COMPLETED after 0.0079
[Worker(host:*** pid:6162)] Job LowPriorityJob [18f99e0e-f78d-493b-bd2e-ec335e4828f3] from DelayedJob(default) with arguments: ["国光"] (id=139) (queue=default) RUNNING
[low priority job] 2022-07-10 10:44:22 +0900 start ============>
[low priority job] 2022-07-10 10:44:22 +0900 end   <============
[Worker(host:*** pid:6162)] Job LowPriorityJob [18f99e0e-f78d-493b-bd2e-ec335e4828f3] from DelayedJob(default) with arguments: ["国光"] (id=139) (queue=default) COMPLETED after 0.0136
[Worker(host:*** pid:6162)] 3 jobs processed at 36.9590 j/s, 0 failed

 

トランザクションを使って、モデルの登録/更新とキューへの登録を同期する

今回の delayed_job_active_record では Delayed::Job のバックエンドにDBを使っています。

そのため、「モデルの登録/更新とキューへの登録を同期させる。モデルの更新が失敗したら、キューの登録も取り消す」という機能は、トランザクションを使うと簡単に実現できます。

 
ためしに以下のコントローラを作成し、動作を確認してみます。

今回は「ロールバックだけを行い、APIは正常終了する」よう、 ActiveRecord::Rollback を使ってロールバックを行います。
https://api.rubyonrails.org/classes/ActiveRecord/Rollback.html

class Api::Rollback::ApplesController < ApplicationController
  def create
    # トランザクションの開始
    ActiveRecord::Base.transaction do

      # モデルの作成
      Apple.create(name: params[:name])

      # キューへの登録
      DefaultQueueJob.perform_later(params[:name])

      # トランザクションロールバックの発生
      raise ActiveRecord::Rollback
    end

    render json: { status: params[:name]}
  end
end

 
処理前の状態を Rails console で確認したところ、両方とも0件でした。

>> Apple.count
  Apple Count (0.2ms)  SELECT COUNT(*) FROM "apples"
=> 0
>> Delayed::Job.count
  Delayed::Backend::ActiveRecord::Job Count (0.4ms)  SELECT COUNT(*) FROM "delayed_jobs"
=> 0

 
続いて curl でリクエストします。

% curl -X POST -H "Content-Type: application/json" -d '{"name":"国光"}' http://localhost:3000/api/rollback/apples

 
Railsの実行ログを見ると、トランザクションロールバックが発生しています。

Started POST "/api/rollback/apples" for 127.0.0.1 at 2022-07-10 11:00:43 +0900
   (0.2ms)  SELECT sqlite_version(*)
Processing by Api::Rollback::ApplesController#create as */*
  Parameters: {"name"=>"国光", "apple"=>{"name"=>"国光"}}

# トランザクションが開始された
  TRANSACTION (0.1ms)  begin transaction
  ↳ app/controllers/api/rollback/apples_controller.rb:7:in `block in create'

  Apple Create (0.3ms)  INSERT INTO "apples" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "国光"], ["created_at", "2022-07-10 02:00:44.074838"], ["updated_at", "2022-07-10 02:00:44.074838"]]
  ↳ app/controllers/api/rollback/apples_controller.rb:7:in `block in create'
[ActiveJob]   Delayed::Backend::ActiveRecord::Job Create (0.2ms)  INSERT INTO "delayed_jobs" ("priority", "attempts", "handler", "last_error", "run_at", "locked_at", "failed_at", "locked_by", "queue", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)  [["priority", 0], ["attempts", 0], ["handler", "--- !ruby/object:ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper\njob_data:\n  job_class: DefaultQueueJob\n  job_id: 6a8f57c6-9b36-4479-a64a-30b5f4959cdd\n  provider_job_id:\n  queue_name: default\n  priority:\n  arguments:\n  - 国光\n  executions: 0\n  exception_executions: {}\n  locale: en\n  timezone: Tokyo\n  enqueued_at: '2022-07-10T02:00:44Z'\n"], ["last_error", nil], ["run_at", "2022-07-10 02:00:44.082236"], ["locked_at", nil], ["failed_at", nil], ["locked_by", nil], ["queue", "default"], ["created_at", "2022-07-10 02:00:44.082260"], ["updated_at", "2022-07-10 02:00:44.082260"]]
[ActiveJob]   ↳ app/controllers/api/rollback/apples_controller.rb:10:in `block in create'
[ActiveJob] Enqueued DefaultQueueJob (Job ID: 6a8f57c6-9b36-4479-a64a-30b5f4959cdd) to DelayedJob(default) with arguments: "国光"

# ここでトランザクションのロールバックが走った
  TRANSACTION (0.6ms)  rollback transaction
  ↳ app/controllers/api/rollback/apples_controller.rb:4:in `create'
Completed 200 OK in 15ms (Views: 0.3ms | ActiveRecord: 3.0ms | Allocations: 4673)

 
Rails console を確認しても、何も登録されていません。

>> Apple.count
  Apple Count (0.3ms)  SELECT COUNT(*) FROM "apples"
=> 0
>> Delayed::Job.count
  Delayed::Backend::ActiveRecord::Job Count (0.2ms)  SELECT COUNT(*) FROM "delayed_jobs"
=> 0

 

ジョブごとに Active Job のバックエンドを切り替える

ここまで、Active Job のバックエンドは Delayed::Job を使ってきました。

ただ、「一部のジョブだけ別の Active Job バックエンドを使いたい」というケースがあるかもしれません。

Railsガイドによると、一部だけ別のバックエンドにしたい場合は、ジョブで queue_adapter を定義すれば良さそうです。
4.2 バックエンドを設定する | Active Job の基礎 - Railsガイド

 
そこで今回は、一部のジョブだけ、Active Jobのデフォルトバックエンドである async への切り替えて実行してみます。
https://api.rubyonrails.org/v7.0/classes/ActiveJob/QueueAdapters/AsyncAdapter.html

class AsyncBackendJob < ApplicationJob
  # キュー名は、Delayed::Job と同じ
  queue_as :default

  # バックエンドだけ async
  self.queue_adapter = :async

  def perform(*args)
    puts "[async job] #{Time.zone.now} start ============>"
    puts "[async job] #{Time.zone.now} end   <============"
  end
end

 
ルーティングを追加し、Railsアプリを起動します。また、ワーカーは停止しておきます。

その状態で curl でリクエストします。

# Delayed::Job バックエンド
curl -X POST -H "Content-Type: application/json" -d '{"name":"国光"}' http://localhost:3000/api/queue_name/default_queues

# async バックエンド
curl -X POST -H "Content-Type: application/json" -d '{"name":"国光"}' http://localhost:3000/api/queue_backend/async_backends

 
Railsサーバのログを見ると、コントローラへのアクセスの後に、ジョブを実行していることが記録されていました。

Started POST "/api/queue_backend/async_backends" for 127.0.0.1 at 2022-07-10 11:11:19 +0900
Processing by Api::QueueBackend::AsyncBackendsController#create as */*
  Parameters: {"name"=>"国光", "async_backend"=>{"name"=>"国光"}}
[ActiveJob] Enqueued AsyncBackendJob (Job ID: 3a28fdf8-2b39-4293-ac2d-250b011ff660) to Async(default)
Completed 200 OK in 4ms (Views: 0.2ms | ActiveRecord: 0.0ms | Allocations: 848)


[ActiveJob] [AsyncBackendJob] [3a28fdf8-2b39-4293-ac2d-250b011ff660] Performing AsyncBackendJob (Job ID: 3a28fdf8-2b39-4293-ac2d-250b011ff660) from Async(default) enqueued at 2022-07-10T02:11:19Z
[async job] 2022-07-10 11:11:19 +0900 start ============>
[async job] 2022-07-10 11:11:19 +0900 end   <============
[ActiveJob] [AsyncBackendJob] [3a28fdf8-2b39-4293-ac2d-250b011ff660] Performed AsyncBackendJob (Job ID: 3a28fdf8-2b39-4293-ac2d-250b011ff660) from Async(default) in 3.85ms

 
なお、ワーカーを停止しているため、Delayed::Job バックエンドはキューに残ったままになっています。

 

Delayed::Job のワーカーの設定を変更する

Delayed::Job の README に、ワーカーの設定変更方法が記載されています。
https://github.com/collectiveidea/delayed_job#gory-details

 
そこで、 config/initializers/delayed_job_config.rb を作成し、設定を追加して試してみます。

また、動作確認用に、必ず落ちるジョブを作っておきます。

class AlwaysFailJob < ApplicationJob
  queue_as :default

  def perform(*args)
    puts "[fail job] #{Time.zone.now} start ============>"

    # 常にエラー
    raise StandardError

    puts "[fail job] #{Time.zone.now} end   <============"
  end
end

 
このジョブを起動するコントローラーはこちら。

class Api::AlwaysFailJobs::ApplesController < ApplicationController
  def create
    AlwaysFailJob.perform_later

    render json: { status: params[:name] }
  end
end

 

max_attempt (リトライ回数)

デフォルトは 25 のようです。

そこで、数値を色々変更し、動作を確認してみます。

なお、リトライ間隔については、等間隔での再実行ではなく、

On error, the job is scheduled again in 5 seconds + N ** 4, where N is the number of attempts or using the job's defined reschedule_at method.

https://github.com/collectiveidea/delayed_job#gory-details

とのことです。

 

max_attempt = 2

設定します。

# config/initializers/delayed_job_config.rb
Delayed::Worker.max_attempts = 2

 
workerを再起動し、curlでリクエストします。

curl -X POST -H "Content-Type: application/json" -d '{"name":"国光"}' http://localhost:3000/api/always_fail_jobs/apples

 
2回実行されました。

2回目のエラー時には FAILED permanently because of 2 consecutive failures と出ています。

また、しばらく待ってもリトライはされませんでした。

[Worker(host:*** pid:9568)] Job AlwaysFailJob [4a7ac3b1-4d1f-4884-a481-7484fbf10af1] from DelayedJob(default) with arguments: [] (id=142) (queue=default) RUNNING
[fail job] 2022-07-10 11:21:18 +0900 start ============>
[Worker(host:*** pid:9568)] Job AlwaysFailJob [4a7ac3b1-4d1f-4884-a481-7484fbf10af1] from DelayedJob(default) with arguments: [] (id=142) (queue=default) FAILED (0 prior attempts) with StandardError: StandardError
[Worker(host:*** pid:9568)] 1 jobs processed at 30.6636 j/s, 1 failed
[Worker(host:*** pid:9568)] Job AlwaysFailJob [4a7ac3b1-4d1f-4884-a481-7484fbf10af1] from DelayedJob(default) with arguments: [] (id=142) (queue=default) RUNNING
[fail job] 2022-07-10 11:21:29 +0900 start ============>
[Worker(host:*** pid:9568)] Job AlwaysFailJob [4a7ac3b1-4d1f-4884-a481-7484fbf10af1] from DelayedJob(default) with arguments: [] (id=142) (queue=default) FAILED (1 prior attempts) with StandardError: StandardError
[Worker(host:*** pid:9568)] Job AlwaysFailJob [4a7ac3b1-4d1f-4884-a481-7484fbf10af1] from DelayedJob(default) with arguments: [] (id=142) (queue=default) FAILED permanently because of 2 consecutive failures
[Worker(host:*** pid:9568)] 1 jobs processed at 59.3155 j/s, 1 failed

 

max_attempt = 1

1回だけ実行された後、 FAILED permanently because of 1 consecutive failures が表示されています。

[Worker(host:*** pid:10246)] Job AlwaysFailJob [0789bf63-34b3-439f-89f9-549ce98208d0] from DelayedJob(default) with arguments: [] (id=143) (queue=default) RUNNING
[fail job] 2022-07-10 11:27:10 +0900 start ============>
[Worker(host:*** pid:10246)] Job AlwaysFailJob [0789bf63-34b3-439f-89f9-549ce98208d0] from DelayedJob(default) with arguments: [] (id=143) (queue=default) FAILED (0 prior attempts) with StandardError: StandardError
[Worker(host:*** pid:10246)] Job AlwaysFailJob [0789bf63-34b3-439f-89f9-549ce98208d0] from DelayedJob(default) with arguments: [] (id=143) (queue=default) FAILED permanently because of 1 consecutive failures
[Worker(host:*** pid:10246)] 1 jobs processed at 34.0634 j/s, 1 failed

 

max_attempt = 0

1回だけ実行された後、 FAILED permanently because of 1 consecutive failures が表示されています。

[Worker(host:*** pid:10660)] Job AlwaysFailJob [78821bed-0214-42d2-a121-0a3e7bda59f5] from DelayedJob(default) with arguments: [] (id=144) (queue=default) RUNNING
[fail job] 2022-07-10 11:28:34 +0900 start ============>
[Worker(host:*** pid:10660)] Job AlwaysFailJob [78821bed-0214-42d2-a121-0a3e7bda59f5] from DelayedJob(default) with arguments: [] (id=144) (queue=default) FAILED (0 prior attempts) with StandardError: StandardError
[Worker(host:*** pid:10660)] Job AlwaysFailJob [78821bed-0214-42d2-a121-0a3e7bda59f5] from DelayedJob(default) with arguments: [] (id=144) (queue=default) FAILED permanently because of 1 consecutive failures
[Worker(host:*** pid:10660)] 1 jobs processed at 20.7310 j/s, 1 failed

 

destroy_failed_jobs = false (ジョブが失敗しても削除しない)

デフォルトでは、 destroy_failed_jobs = true になっているため、エラーになったジョブは delayed_jobs テーブルから削除されます。

「失敗時のジョブの情報を残したい」などの要件がある場合は、失敗したジョブも残すよう false へと変更します。

# config/initializers/delayed_job_config.rb

Delayed::Worker.destroy_failed_jobs = false

 
常に失敗するジョブのエンドポイントへ curl でリクエストします。

% curl -X POST -H "Content-Type: application/json" -d '{"name":"国光"}' http://localhost:3000/api/always_fail_jobs/apples

ジョブは失敗します。

[Worker(host:*** pid:10660)] Job AlwaysFailJob [cd497d7b-d58f-4c0a-b096-874b9c52d18c] from DelayedJob(default) with arguments: [] (id=145) (queue=default) RUNNING
[fail job] 2022-07-10 11:31:45 +0900 start ============>
[Worker(host:*** pid:10660)] Job AlwaysFailJob [cd497d7b-d58f-4c0a-b096-874b9c52d18c] from DelayedJob(default) with arguments: [] (id=145) (queue=default) FAILED (0 prior attempts) with StandardError: StandardError
[Worker(host:*** pid:10660)] Job AlwaysFailJob [cd497d7b-d58f-4c0a-b096-874b9c52d18c] from DelayedJob(default) with arguments: [] (id=145) (queue=default) FAILED permanently because of 1 consecutive failures
[Worker(host:*** pid:10660)] 1 jobs processed at 58.3737 j/s, 1 failed

 
delayed_jobs テーブルを見ると、エラーレコードが残ったままになっています。

失敗したジョブについては failed_at などが設定されています。

次のスクリーンショットの場合は、1行目のジョブが失敗しています。

 

max_run_time (ジョブの実行時間)

デフォルトは 4.hours です。

そこで、max_run_time を2秒に変更して、中で10秒sleepするジョブを実行してみます。

# config/initializers/delayed_job_config.rb

# ジョブがエラーになった時の繰り返し回数
# 0は1と同じ
Delayed::Worker.max_attempts = 2

# ジョブの実行時間
Delayed::Worker.max_run_time = 2.seconds

curlでリクエストします。

curl -X POST -H "Content-Type: application/json" -d '{"name":"秋映"}' http://localhost:3000/api/queue_name/custom_queues

 
ワーカーのログを見ると、2秒経過したところでジョブが失敗していました。

[Worker(host:*** pid:11637)] Job CustomQueueJob [742d6182-d3ab-4942-b9da-763bc4dfed69] from DelayedJob(custom) with arguments: ["秋映"] (id=147) (queue=custom) RUNNING
[custom queue job] (秋映) 2022-07-10 11:40:01 +0900 start ============>
[Worker(host:*** pid:11637)] Job CustomQueueJob [742d6182-d3ab-4942-b9da-763bc4dfed69] from DelayedJob(custom) with arguments: ["秋映"] (id=147) (queue=custom) FAILED (0 prior attempts) with Delayed::WorkerTimeout: execution expired (Delayed::Worker.max_run_time is only 2 seconds)
[Worker(host:*** pid:11637)] 1 jobs processed at 0.4868 j/s, 1 failed
[Worker(host:*** pid:11637)] Job CustomQueueJob [742d6182-d3ab-4942-b9da-763bc4dfed69] from DelayedJob(custom) with arguments: ["秋映"] (id=147) (queue=custom) RUNNING
[custom queue job] (秋映) 2022-07-10 11:40:13 +0900 start ============>
[Worker(host:*** pid:11637)] Job CustomQueueJob [742d6182-d3ab-4942-b9da-763bc4dfed69] from DelayedJob(custom) with arguments: ["秋映"] (id=147) (queue=custom) FAILED (1 prior attempts) with Delayed::WorkerTimeout: execution expired (Delayed::Worker.max_run_time is only 2 seconds)
[Worker(host:*** pid:11637)] Job CustomQueueJob [742d6182-d3ab-4942-b9da-763bc4dfed69] from DelayedJob(custom) with arguments: ["秋映"] (id=147) (queue=custom) FAILED permanently because of 2 consecutive failures
[Worker(host:*** pid:11637)] 1 jobs processed at 0.4938 j/s, 1 failed

 

read_ahead 設定がDBエンジンによっては無視される

項目の説明として

The default behavior is to read 5 jobs from the queue when finding an available job. You can configure this by setting Delayed::Worker.read_ahead.

とありました。

この説明を軽く読んだ時、「 read_ahead の分だけジョブを読んで、その中の優先度が高いものを実行する」なのかもしれないと感じました。

 
そこで、

# config/initializers/delayed_job_config.rb

Delayed::Worker.read_ahead = 3

と先読みを3件にするよう設定した後、7個目に優先度が高いものがジョブがある時にどうなるかを見てみることにしました。

ジョブを2つ用意します。

優先度: 30 (低い)

class LowPriorityJob < ApplicationJob
  queue_as :default
  queue_with_priority 30

  def perform(*args)
    puts "[low priority job] #{Time.zone.now} start ============>"
    puts "[low priority job] #{Time.zone.now} end   <============"
  end
end

優先度:10 (高い)

class HighPriorityJob < ApplicationJob
  queue_as :default
  queue_with_priority 10

  def perform(*args)
    puts "[high priority job] #{Time.zone.now} start ============>"
    puts "[high priority job] #{Time.zone.now} end   <============"
  end
end

次に、これらのジョブに対応するAPIエンドポイントを用意します。

 
準備ができたため、動作を確認します。ワーカーを止めた状態で、

  • 優先度が低いジョブを6個キューに入れる
  • 優先度が高いジョブを1個キューに入れる

となるよう、curlでリクエストします。

# 6回実行
curl -X POST -H "Content-Type: application/json" -d '{"name":"秋映"}' http://localhost:3000/api/priority/low_priorities
curl -X POST -H "Content-Type: application/json" -d '{"name":"秋映"}' http://localhost:3000/api/priority/low_priorities
curl -X POST -H "Content-Type: application/json" -d '{"name":"秋映"}' http://localhost:3000/api/priority/low_priorities
curl -X POST -H "Content-Type: application/json" -d '{"name":"秋映"}' http://localhost:3000/api/priority/low_priorities
curl -X POST -H "Content-Type: application/json" -d '{"name":"秋映"}' http://localhost:3000/api/priority/low_priorities
curl -X POST -H "Content-Type: application/json" -d '{"name":"秋映"}' http://localhost:3000/api/priority/low_priorities

# 1回実行
curl -X POST -H "Content-Type: application/json" -d '{"name":"秋映"}' http://localhost:3000/api/priority/high_priorities

 
ワーカーを起動してログを確認したところ、優先度:高いから実行されているように見えました。

# 優先度:高が1回実行される
[Worker(host:*** pid:12532)] Job HighPriorityJob [a94294d8-5810-4902-87d7-120a1cd71b19] from DelayedJob(default) with arguments: [] (id=154) (queue=default) RUNNING
[high priority job] 2022-07-10 11:50:26 +0900 start ============>
[high priority job] 2022-07-10 11:50:26 +0900 end   <============
[Worker(host:*** pid:12532)] Job HighPriorityJob [a94294d8-5810-4902-87d7-120a1cd71b19] from DelayedJob(default) with arguments: [] (id=154) (queue=default) COMPLETED after 0.0183

# 続いて、優先度:低が6回実行される
[Worker(host:*** pid:12532)] Job LowPriorityJob [66de3de9-d61e-4fd6-a315-275bacd3a4d5] from DelayedJob(default) with arguments: [] (id=148) (queue=default) RUNNING
[low priority job] 2022-07-10 11:50:26 +0900 start ============>
[low priority job] 2022-07-10 11:50:26 +0900 end   <============
[Worker(host:*** pid:12532)] Job LowPriorityJob [66de3de9-d61e-4fd6-a315-275bacd3a4d5] from DelayedJob(default) with arguments: [] (id=148) (queue=default) COMPLETED after 0.0078
[Worker(host:*** pid:12532)] Job LowPriorityJob [db85ccb8-2fb6-4c27-84d1-c5e4afed5905] from DelayedJob(default) with arguments: [] (id=149) (queue=default) RUNNING
[low priority job] 2022-07-10 11:50:26 +0900 start ============>
[low priority job] 2022-07-10 11:50:26 +0900 end   <============
[Worker(host:*** pid:12532)] Job LowPriorityJob [db85ccb8-2fb6-4c27-84d1-c5e4afed5905] from DelayedJob(default) with arguments: [] (id=149) (queue=default) COMPLETED after 0.0075
[Worker(host:*** pid:12532)] Job LowPriorityJob [633dc530-94f4-43b6-aa6d-8257099c5110] from DelayedJob(default) with arguments: [] (id=150) (queue=default) RUNNING
[low priority job] 2022-07-10 11:50:26 +0900 start ============>
[low priority job] 2022-07-10 11:50:26 +0900 end   <============
[Worker(host:*** pid:12532)] Job LowPriorityJob [633dc530-94f4-43b6-aa6d-8257099c5110] from DelayedJob(default) with arguments: [] (id=150) (queue=default) COMPLETED after 0.0081
[Worker(host:*** pid:12532)] Job LowPriorityJob [9000f99a-6f67-4d55-ba96-558548245fd9] from DelayedJob(default) with arguments: [] (id=151) (queue=default) RUNNING
[low priority job] 2022-07-10 11:50:26 +0900 start ============>
[low priority job] 2022-07-10 11:50:26 +0900 end   <============
[Worker(host:*** pid:12532)] Job LowPriorityJob [9000f99a-6f67-4d55-ba96-558548245fd9] from DelayedJob(default) with arguments: [] (id=151) (queue=default) COMPLETED after 0.0080
[Worker(host:*** pid:12532)] Job LowPriorityJob [fa1401cf-5f9f-4a60-abac-90d13056376d] from DelayedJob(default) with arguments: [] (id=152) (queue=default) RUNNING
[low priority job] 2022-07-10 11:50:26 +0900 start ============>
[low priority job] 2022-07-10 11:50:26 +0900 end   <============
[Worker(host:*** pid:12532)] Job LowPriorityJob [fa1401cf-5f9f-4a60-abac-90d13056376d] from DelayedJob(default) with arguments: [] (id=152) (queue=default) COMPLETED after 0.0078
[Worker(host:*** pid:12532)] Job LowPriorityJob [1a71b2a8-39fb-4828-a063-33c29de9e503] from DelayedJob(default) with arguments: [] (id=153) (queue=default) RUNNING
[low priority job] 2022-07-10 11:50:26 +0900 start ============>
[low priority job] 2022-07-10 11:50:26 +0900 end   <============
[Worker(host:*** pid:12532)] Job LowPriorityJob [1a71b2a8-39fb-4828-a063-33c29de9e503] from DelayedJob(default) with arguments: [] (id=153) (queue=default) COMPLETED after 0.0075
[Worker(host:*** pid:12532)] 7 jobs processed at 56.1185 j/s, 0 failed

 
そのため、

read_ahead の分だけジョブを読んで、その中の優先度が高いものを実行する」

という目的の設定ではないと分かりました。

 
そこで、 read_ahead について詳しく知るため、 read_ahead についての記事を読みました。

 
次に、ソースコードを読んだところ、 reserve_with_scope_using_default_sql の中で read_ahead を使っていました。
https://github.com/collectiveidea/delayed_job_active_record/blob/v4.1.7/lib/delayed/backend/active_record.rb#L100-L167

そこではDBエンジンとして

あたりを使っていると reserve_with_scope_using_default_sql を使うロジックに入らないように見えました。

もし、上記DBを使っていて read_ahead を使う reserve_with_scope_using_default_sql ロジックに入りたい場合は、

Delayed::Backend::ActiveRecord.configuration.reserve_sql_strategy = :default_sql

な設定が必要そうでした。

 
今回試している環境のDBエンジンは SQLite なものの、自分の関係する本番環境では無視されるようだったため、 read_ahead についてはこれ以上追求することをやめました。

 

Action Mailer と Delayed::Job を一緒に使う

Delayed::Job の README によると、Delayed::Job は Action Mailer とともに使えるようでしたので、ためしてみます。
https://github.com/collectiveidea/delayed_job#rails-mailers

 
なお、実際にメールを送信すると確認が手間なので、今回は Letter Opener を使ってメールを受信してみます。
https://github.com/ryanb/letter_opener

Gemfileに追加して bundle install します。

group :development do
  # メールの受信
  gem "letter_opener"
end

 

Action Mailer と Delayed::Job を組み合わせてみる

使い方としては

の2パターンがあるようです。

今回は Active Job のインタフェースに合わせ、

Action Mailer の .deliver_later を使う

な方式で実装して試してみます。

 
まずは ActionMailer のジェネレータを実行します。

今回は HelloWorld という Mailer を作成します。

% bin/rails generate mailer HelloWorld
      create  app/mailers/hello_world_mailer.rb
      invoke  erb
      create    app/views/hello_world_mailer
      invoke  test_unit
      create    test/mailers/hello_world_mailer_test.rb
      create    test/mailers/previews/hello_world_mailer_preview.rb

 
Mailerを編集します。

# app/mailers/hello_world_mailer.rb

class HelloWorldMailer < ApplicationMailer
  def welcome_email
    @name = params[:name]
    mail(to: 'bar@example.com', subject: 'Hello, world!')
  end
end

 
メールのビュー (app/views/hello_world_mailer/welcome_email.text.erb) も作成します。

<%= @name %>

Hello, world!

 
コントローラーを作成します。

class Api::Email::OnlyEmailsController < ApplicationController
  def create
    HelloWorldMailer.with(name: params[:name]).welcome_email.deliver_later

    render json: { status: params[:name] }
  end
end

 
ルーティングを追加した後、Railsとジョブワーカーを起動し、curlでアクセスします。

% curl -X POST -H "Content-Type: application/json" -d '{"name":"国光"}' http://localhost:3000/api/email/only_emails

 
すると、ワーカーがメールのジョブを実行し、メールが受信できました。

メールジョブの設定を行う

キュー名や優先度をメールジョブ全体で設定

キュー名は、config/applicantion.rbconfig.action_mailer.deliver_later_queue_name にて設定できます。

 
一方、優先度については Mailer 用の initializer を作成し、その中で ActionMailer::MailDeliveryJob.priority を指定することになります。
How to set priority of Rails ActionMailer - Stack Overflow

 
それぞれ定義してみます。

キュー名

# config/application.rb

# ...
module RailsDelayedJobSample
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 7.0

    # ...

    # Action Mailerの設定
    # キュー名を設定
    # https://guides.rubyonrails.org/configuring.html#config-action-mailer-deliver-later-queue-name
    config.action_mailer.deliver_later_queue_name = 'mail_queue'
  end
end

 
優先度

# config/initializer/mail_delivery_job.rb

ActionMailer::MailDeliveryJob.priority = 30

 
準備ができたため、ワーカーを停止した後、Railsを起動して curl でリクエストします。

curl -X POST -H "Content-Type: application/json" -d '{"name":"国光"}' http://localhost:3000/api/email/only_emails

 
delayed_jobs テーブルを見ると、キュー名や優先度が指定されていました。

 
ワーカーを起動すると、メールが送信されました。

ログを見ると、ワーカーでメールを送信していることが分かります。

[Worker(host:*** pid:15576)] Job ActionMailer::MailDeliveryJob [e9f626e0-e971-4f15-978e-bb524a9c85ab] from DelayedJob(mail_queue) with arguments: ["HelloWorldMailer", "welcome_email", "deliver_now", {"params"=>{"name"=>"国光", "_aj_symbol_keys"=>["name"]}, "args"=>[], "_aj_ruby2_keywords"=>["params", "args"]}] (id=157) (queue=mail_queue) RUNNING
[Worker(host:*** pid:15576)] Job ActionMailer::MailDeliveryJob [e9f626e0-e971-4f15-978e-bb524a9c85ab] from DelayedJob(mail_queue) with arguments: ["HelloWorldMailer", "welcome_email", "deliver_now", {"params"=>{"name"=>"国光", "_aj_symbol_keys"=>["name"]}, "args"=>[], "_aj_ruby2_keywords"=>["params", "args"]}] (id=157) (queue=mail_queue) COMPLETED after 0.1572
[Worker(host:*** pid:15576)] 1 jobs processed at 5.1985 j/s, 0 failed

 

キューや優先度を送信するメールごとに設定

Action Mailer のdeliver_later() の引数にて指定することで、設定がオーバーライドされるようです。 https://api.rubyonrails.org/classes/ActionMailer/MessageDelivery.html#method-i-deliver_later

 
コントローラーで deliver_later の引数を設定します。

class Api::Email::DeliverOptionsController < ApplicationController
  def create
    HelloWorldMailer.with(name: params[:name]).welcome_email.deliver_later(
      queue: 'override_queue',
      priority: 45
    )

    render json: { status: params[:name] }
  end
end

 
ルーティングを追加、ワーカーを停止し、Railsを起動してから curl でリクエストします。

curl -X POST -H "Content-Type: application/json" -d '{"name":"国光"}' http://localhost:3000/api/email/deliver_options

 
delayed_jobs テーブルを見ると、データが入っています。

 
ジョブワーカーを起動するとメールも送信されました。

[Worker(host:*** pid:16625)] Job ActionMailer::MailDeliveryJob [6f78d26c-fc0d-4d67-9ec1-eb101707ed0d] from DelayedJob(override_queue) with arguments: ["HelloWorldMailer", "welcome_email", "deliver_now", {"params"=>{"name"=>"国光", "_aj_symbol_keys"=>["name"]}, "args"=>[], "_aj_ruby2_keywords"=>["params", "args"]}] (id=158) (queue=override_queue) RUNNING
[Worker(host:*** pid:16625)] Job ActionMailer::MailDeliveryJob [6f78d26c-fc0d-4d67-9ec1-eb101707ed0d] from DelayedJob(override_queue) with arguments: ["HelloWorldMailer", "welcome_email", "deliver_now", {"params"=>{"name"=>"国光", "_aj_symbol_keys"=>["name"]}, "args"=>[], "_aj_ruby2_keywords"=>["params", "args"]}] (id=158) (queue=override_queue) COMPLETED after 0.1779
[Worker(host:*** pid:16625)] 1 jobs processed at 4.5583 j/s, 0 failed

 

メールジョブと通常のジョブを一緒にロールバックする

メールジョブでも Delayed::Job を使う場合

  • モデルの保存
  • 一般のジョブをキューへ登録
  • メールジョブをキューへ登録

を1つのトランザクションとして処理できます。

そこで、トランザクション内で上記の各処理を実行し、最後にトランザクションロールバックするとどうなるかを確認してみます。

 
まずは、コントローラーでトランザクションを使って実装します。

class Api::Email::TransactionsController < ApplicationController
  def create
    ActiveRecord::Base.transaction do
      HelloWorldMailer.with(name: params[:name]).welcome_email.deliver_later

      Apple.create(name: params[:name])

      DefaultQueueJob.perform_later(params[:name])

      raise ActiveRecord::Rollback
    end

    render json: { status: params[:name] }
  end
end

 
Rails console を使って、事前のモデルの状況を確認します。いずれも登録されていません。

>> Apple.count
   (1.7ms)  SELECT sqlite_version(*)
  Apple Count (0.5ms)  SELECT COUNT(*) FROM "apples"
=> 0
>> Delayed::Job.count
  Delayed::Backend::ActiveRecord::Job Count (0.3ms)  SELECT COUNT(*) FROM "delayed_jobs"
=> 0

 
次に Rails を起動し、curlでリクエストします。

curl -X POST -H "Content-Type: application/json" -d '{"name":"国光"}' http://localhost:3000/api/email/transactions

 
Railsのログを見ると、トランザクションロールバックされています。

Started POST "/api/email/transactions" for 127.0.0.1 at 2022-07-10 13:16:35 +0900
Processing by Api::Email::TransactionsController#create as */*
  Parameters: {"name"=>"国光", "transaction"=>{"name"=>"国光"}}
   (0.1ms)  SELECT sqlite_version(*)
  ↳ app/controllers/api/email/transactions_controller.rb:3:in `create'

# トランザクション開始
[ActiveJob]   TRANSACTION (0.1ms)  begin transaction
[ActiveJob]   ↳ app/controllers/api/email/transactions_controller.rb:4:in `block in create'

[ActiveJob]   Delayed::Backend::ActiveRecord::Job Create (0.6ms)  INSERT INTO "delayed_jobs" ("priority", "attempts", "handler", "last_error", "run_at", "locked_at", "failed_at", "locked_by", "queue", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)  [["priority", 30], ["attempts", 0], ["handler", "--- !ruby/object:ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper\njob_data:\n  job_class: ActionMailer::MailDeliveryJob\n  job_id: c3555602-bac5-47f5-9038-8246b3194306\n  provider_job_id:\n  queue_name: mail_queue\n  priority: 30\n  arguments:\n  - HelloWorldMailer\n  - welcome_email\n  - deliver_now\n  - params:\n      name: 国光\n      _aj_symbol_keys:\n      - name\n    args: []\n    _aj_ruby2_keywords:\n    - params\n    - args\n  executions: 0\n  exception_executions: {}\n  locale: en\n  timezone: Tokyo\n  enqueued_at: '2022-07-10T04:16:35Z'\n"], ["last_error", nil], ["run_at", "2022-07-10 04:16:35.040988"], ["locked_at", nil], ["failed_at", nil], ["locked_by", nil], ["queue", "mail_queue"], ["created_at", "2022-07-10 04:16:35.041026"], ["updated_at", "2022-07-10 04:16:35.041026"]]
[ActiveJob]   ↳ app/controllers/api/email/transactions_controller.rb:4:in `block in create'
[ActiveJob] Enqueued ActionMailer::MailDeliveryJob (Job ID: c3555602-bac5-47f5-9038-8246b3194306) to DelayedJob(mail_queue) with arguments: "HelloWorldMailer", "welcome_email", "deliver_now", {:params=>{:name=>"国光"}, :args=>[]}
  Apple Create (0.2ms)  INSERT INTO "apples" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "国光"], ["created_at", "2022-07-10 04:16:35.052783"], ["updated_at", "2022-07-10 04:16:35.052783"]]
  ↳ app/controllers/api/email/transactions_controller.rb:6:in `block in create'
[ActiveJob]   Delayed::Backend::ActiveRecord::Job Create (0.1ms)  INSERT INTO "delayed_jobs" ("priority", "attempts", "handler", "last_error", "run_at", "locked_at", "failed_at", "locked_by", "queue", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)  [["priority", 0], ["attempts", 0], ["handler", "--- !ruby/object:ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper\njob_data:\n  job_class: DefaultQueueJob\n  job_id: c5477611-3075-4c1f-a8a2-f22e7a2b049f\n  provider_job_id:\n  queue_name: default\n  priority:\n  arguments:\n  - 国光\n  executions: 0\n  exception_executions: {}\n  locale: en\n  timezone: Tokyo\n  enqueued_at: '2022-07-10T04:16:35Z'\n"], ["last_error", nil], ["run_at", "2022-07-10 04:16:35.057817"], ["locked_at", nil], ["failed_at", nil], ["locked_by", nil], ["queue", "default"], ["created_at", "2022-07-10 04:16:35.057840"], ["updated_at", "2022-07-10 04:16:35.057840"]]
[ActiveJob]   ↳ app/controllers/api/email/transactions_controller.rb:8:in `block in create'
[ActiveJob] Enqueued DefaultQueueJob (Job ID: c5477611-3075-4c1f-a8a2-f22e7a2b049f) to DelayedJob(default) with arguments: "国光"

# ロールバックが発生
  TRANSACTION (0.4ms)  rollback transaction
  ↳ app/controllers/api/email/transactions_controller.rb:3:in `create'
Completed 200 OK in 25ms (Views: 0.2ms | ActiveRecord: 2.0ms | Allocations: 8209)

 
また、各テーブルには何も登録されていません。

>> Apple.count
  Apple Count (0.2ms)  SELECT COUNT(*) FROM "apples"
=> 0
>> Delayed::Job.count
  Delayed::Backend::ActiveRecord::Job Count (0.2ms)  SELECT COUNT(*) FROM "delayed_jobs"
=> 0

 
以上より、

  • モデルの保存
  • 一般のジョブをキューへ登録
  • メールジョブをキューへ登録

を1つのトランザクションとして処理できました。

 

Delayed::Job + RSpec によるテストコードを書く

ここまでで各実装方法を見てきました。

ここからは Delayed::Job を使った時のテストコードを書いてみます。

なお、今回のテストコードは RSpec で書いてみますので、 rails-rspec をインストールしておきます。
https://github.com/rspec/rspec-rails

 

Job specを書く

キューに入ることを確認する

RSpechave_been_enqueued マッチャを使い、 perform_later を実行したら、キュー名 heavy に1回入ることを確認します。
have_been_enqueued matcher - Matchers - RSpec Rails - RSpec - Relish

RSpec.describe 'HeavyQueueJob', type: :job do
  describe '#perform_later' do
    context 'RSpecの have_enqueued_job マッチャを使う' do
      it '名前付きキューに1回入ること' do
      it '名前付きキューに1回入ること' do
        expect {
          HeavyQueueJob.perform_later
        }.to have_enqueued_job(HeavyQueueJob).exactly(:once).on_queue(:heavy)
      end
    end
  end
end

 
実行するとテストが落ちます。

StandardError: To use ActiveJob matchers set `ActiveJob::Base.queue_adapter = :test`

 
queue_adapter:delayed_job のままではエラーになるようです。

そのため、 before:test に変更し、 after で元に戻す (:delayed_job) ようにすると、テストがパスします。

# ...
context 'RSpecの have_enqueued_job マッチャを使う' do
  # 追加
  before do
    ActiveJob::Base.queue_adapter = :test
  end

  after do
    ActiveJob::Base.queue_adapter = :delayed_job
  end

  # ...

 

Delayed::Job テーブルにデータが存在することを確認する

こちらは change を使って、テーブルにデータが増えていることを確認します。

また、今回は追加された中身( priority )も確認するため、 have_attributes マッチャを使います。
have_attributes matcher - Built in matchers - RSpec Expectations - RSpec - Relish

なお、こちらの場合はDBの中身を確認することから、キューの中身が実行されないよう queue_adapter:delayed_job となっている必要があります。

context '自分でテーブルを調べる' do
  it 'job specの場合は、ジョブがキューに入ること' do
    expect {
      HeavyQueueJob.perform_later
    }.to change(Delayed::Job, :count).from(0).to(1)
  end

  it '優先度も設定されていること' do
    HeavyQueueJob.perform_later

    actual = Delayed::Job.first
    expect(actual).to have_attributes(priority: 10)
  end
end

 

ジョブが失敗の上限に達しても、Delayed::Job テーブルからジョブが削除されないことを確認する

今回のRailsアプリでは、ジョブが失敗の上限に達しても、Delayed::Job テーブルからジョブが削除されないよう

Delayed::Worker.destroy_failed_jobs = false

となっているのが仕様とします。

そのため、ジョブが削除されないことをテストで確認します。

なお、 Delayed::Job Workerには

  • work_off
  • run

の2つのメソッドがあります。
https://www.rubydoc.info/gems/delayed_job/Delayed/Worker#work_off-instance_method

ただ、wrok_off の場合、失敗が2回目以降だと Delayed::Job テーブルの attempts が更新されません。

そのため、今回は run を使ってワーカーによるジョブ実行を行っています。

require 'rails_helper'

RSpec.describe 'HeavyQueueJob', type: :job do
  describe '#perform_now' do
    context '失敗の上限になった場合でも、delayed_jobsテーブルにレコードが残ったままになっているか' do
      it 'キューにデータが残り続けていること' do
        expect {
          # キューに入れる
          AlwaysFailJob.perform_later

          # ジョブを2回実行する
          Delayed::Worker.new.run(Delayed::Job.first)
          Delayed::Worker.new.run(Delayed::Job.first)

        }.to change(Delayed::Job, :count).from(0).to(1) # テーブルには1件だけ追加されていること

        # ジョブが残っており、2回実行されたこと
        actual = Delayed::Job.first
        expect(actual).to have_attributes(attempts: 2)

        # なお、実際の Delayed::Job Worker と異なり、3回目も実行できる
        Delayed::Worker.new.run(Delayed::Job.first)
        expect(actual.reload).to have_attributes(attempts: 3)
      end
    end
  end
end

 

Request spec を書く

次は Request spec で動作を確認します。

 

【注意】 Rails6以降、Request spec では Active Job の queue_adapter が TestAdapter になる

Rails6以降、Request specのデフォルトだと、テストを実行する時のデフォルトの queue_adapterTestAdapter (:test) で固定されます。

固定された原因として、以下では ActionDispatch::SystemTestCase にて ActiveJob::TestHelper を include したため、 TestAdapter が :test に固定されるとあります。

 
では、RSpecの Request spec は何に相当するかを調べたところ、READMEによると ActionDispatch::IntegrationTest でした。
https://github.com/rspec/rspec-rails#what-tests-should-i-write

ActionDispatch::IntegrationTest については、以下の issue やプルリクで ActiveJob::TestHelper が include されるようになりました。

そのため、 ActionDispatch::SystemTestCase 同様、 queue_adapter:test に固定されることとなっているようです。

 
queue_adapter:test となることの影響として、テストコード内ではジョブがインラインで実行されるようになります。

デフォルトでは ActiveJob::TestCase がキューアダプタを :test に設定してジョブがインラインで実行されるようにします。

13.1 基本のテストケース | Rails テスティングガイド - Railsガイド

 
つまり、Request specの中で「 Delayed::Job のキューテーブルにレコードが存在すること」のようなテストコードを書いていると、デフォルトのままでは失敗してしまいます。

これについては、RSpecのマッチャ have_enqueued_mail などで代替できそうに見えます。
https://relishapp.com/rspec/rspec-rails/v/5-1/docs/matchers/have-enqueued-mail-matcher

しかし、そのマッチャの挙動が

  • キューに入った形跡があることは確認できる
  • Delayed::Job のレコードがあるかどうかは確認できない

となることから、「Delayed::Job のテーブルにレコードが存在すること」まで確認したい場合に困ってしまいます。

 
queue_adapter を差し替える方法としては

などが見つかりました。

ただ、RSpechave_been_enqueued マッチャを使っている場合、 queue_adapter:test でないと動作しなくなります。

そこで、局所的に差し替えられる、上記の一番最後の案

TestAdapterを差し替えたいところだけ、 before でキューを差し替える

にて今回は実装していきます。

 

指定したキューに登録されることを確認する

Job specの場合は have_enqueued_job を使いましたが、今回は have_been_enqueued を使ってみます。
have_been_enqueued matcher - Matchers - RSpec Rails - RSpec - Relish

なお、先程の注意にある通り、Request spec の queue_adapter:test になりますが、 have_been_enqueued マッチャは :test のままで良いです。

RSpec.describe 'Api::Priority::HighPrioritiesController', type: :request do
  describe 'POST /api/priority/high_priorities' do
    context 'RSpecの have_been_enqueued マッチャを使う' do
      it 'POSTすると、キュー default に1回入る' do
        post api_priority_high_priorities_path, params: { name: 'test' }

        expect(HighPriorityJob).to have_been_enqueued.exactly(:once).on_queue(:default)
      end
    end
  end
end

 

Delayed::Job テーブルにデータが存在することを確認する

今回は優先度が想定通りかをテーブルの中を見て確認します。

なお、Reqeust spec でテーブルの中身を使う場合は、 queue_adapter の差し替えを行わないよう設定します。

context '自分でテーブルを調べる' do
  before do
    queue_adapter_changed_jobs.each(&:disable_test_adapter)
  end

  it 'ジョブが delayed_job テーブルに登録され、優先度が10であること' do
    expect {
      post api_priority_high_priorities_path, params: { name: 'シナノスイート' }
    }.to change(Delayed::Job, :count).from(0).to(1)

    actual = Delayed::Job.first
    expect(actual).to have_attributes(priority: 10)
  end
end

 

ジョブが失敗の上限に達しても、Delayed::Job テーブルからジョブが削除されないことを確認する

Job spec同様にして確認できます。

なお、テーブルの中身を確認しているため、 queue_adapter:test へ差し替わらないようにします。

require 'rails_helper'

RSpec.describe 'Api::AlwaysFailJobs::ApplesController', type: :request do
  describe 'POST /api/always_fail_jobs/apples' do
    context '失敗回数の上限に達した場合' do
      before do
        queue_adapter_changed_jobs.each(&:disable_test_adapter)
      end

      it 'キューにデータが残り続けていること' do
        expect {
          post api_always_fail_jobs_apples_path, params: { name: '秋映' }
        }.to change(Delayed::Job, :count).from(0).to(1)

        Delayed::Worker.max_attempts.times do |i|
          # work_off だと2回目以降でエラーになった場合に Delayed::Job への更新が行われないっぽいので、
          # run() でジョブを明示的に指定して実行する
          Delayed::Worker.new.run(Delayed::Job.first)

          expect(Delayed::Job.first).to have_attributes(attempts: i + 1)
        end

        # ちなみに、実際のワーカーと異なり、runではもう一回実行することもできる。この場合は attempts がインクリメントされる
        Delayed::Worker.new.run(Delayed::Job.first)
        expect(Delayed::Job.first).to have_attributes(attempts: 3)
      end
    end
  end
end

 

メールと組み合わせた時のspecを書く

メールがキューに入るかを確認する

【注意】 have_enqueued_mail マッチャは、Rails7 + rspec-rails 5系の環境では動かない

RSpecでは have_enqueued_mail マッチャを使うことで、メールがキューに入ったかを確認できます。
have_enqueued_mail matcher - Matchers - RSpec Rails - RSpec - Relish

 
しかし、Rails7 + rspec-rails 5系の環境で have_enqueued_mail マッチャを使うとエラーになります。

NameError:
  uninitialized constant ActionMailer::DeliveryJob

    job[:job] <= ActionMailer::DeliveryJob
                                ^^^^^^^^^^^^^
  Did you mean?  ActionMailer::MailDeliveryJob

 
以下のissueに原因が記載されています。
uninitialized constant ActionMailer::DeliveryJob with latest rails/master · Issue #2531 · rspec/rspec-rails

issueの最後に

This has been released as 6.0.0.rc1 theres still potential for the breaking change to the mailer api to do with params / args matching, but those needing support from a tagged release can now get this via rc1.

Rails 7 support is only via version 6.x per our versioning strategy.

https://github.com/rspec/rspec-rails/issues/2531#issuecomment-1086959613

とあるように、Rails7 では rspec-rails 6系を使ったほうが良さそうです。

 

have_enqueued_mailマッチャを使ってメールのキュー登録を確認する

以下のようにすることで、キューに入る回数とキュー名を確認できます。

RSpec.describe 'Api::Email::DeliverOptionsController', type: :request do
  describe 'POST /api/email/deliver_options' do
    context 'キューに入るかを確認' do
      it 'キュー名「override_queue」というキューに1回だけ入る' do
        expect {
          post api_email_deliver_options_path, params: { name: '王林' }
        }.to have_enqueued_mail(HelloWorldMailer, :welcome_email)
               .exactly(1) # 回数
               .on_queue(:override_queue) # キュー名
      end
    end
  end
end

 

メールが送信されることを確認する

ActiveJob::TestHelperperform_enqueued_jobs を使うことで、キューに入ったメールを送信できます。 https://api.rubyonrails.org/v7.0.3/classes/ActiveJob/TestHelper.html#method-i-perform_enqueued_jobs

また、テストでメールを送信すると ActionMailer::Base.deliveries に中身が含まれます。
Action Mailer の基礎 - Railsガイド

そのため、ActionMailer::Base.deliveries から取り出して確認します。

context '送信されたメールを確認' do
  it 'メールが1通送信され、中身も想定した通りであること' do
    expect {
      perform_enqueued_jobs do
        post api_email_deliver_options_path, params: { name: 'シナノスイート' }
      end
    }.to change(ActionMailer::Base.deliveries, :count).from(0).to(1)

    actual = ActionMailer::Base.deliveries[0]

    # Rails全体の設定値 config.action_mailer.default_options = { from: 'no-reply@example.com' } ではなく
    # ApplicationMailer で設定した from@example.com が指定されていること
    expect(actual.from).to eq ['from@example.com']

    expect(actual.to).to eq ['bar@example.com']
    expect(actual.subject).to eq 'Hello, world!'

    expect(actual.body).to include('シナノスイート')
    expect(actual.body).to include('Hello, world!')

  end
end

 

トランザクションロールバックしたら、何もDBに入っていないか確認する

ここでは、プロダクションコードでトランザクション & ロールバックを行っているコントローラに対するテストを書きます。

class Api::Email::TransactionsController < ApplicationController
  def create
    ActiveRecord::Base.transaction do
      HelloWorldMailer.with(name: params[:name]).welcome_email.deliver_later

      Apple.create(name: params[:name])

      DefaultQueueJob.perform_later(params[:name])

      raise ActiveRecord::Rollback
    end

    render json: { status: params[:name] }
  end
end

 

RSpec::Matchers.define_negated_matcherについて

HelloWorldMailerDefaultQueueJob の両方がキューに登録されていないことを確認するため、

expect {
  post api_email_transactions_path, params: { name: '王林' }
}.not_to have_enqueued_mail(HelloWorldMailer, :welcome_email)
       .and have_enqueued_job

のように書きたくなりますが、 NotImplementedError になります。

NotImplementedError:
  `expect(...).not_to matcher.and matcher` is not supported, since it creates a bit of an ambiguity.
  Instead, define negated versions of whatever matchers you wish to negate with `RSpec::Matchers.define_negated_matcher`
  and use `expect(...).to matcher.and matcher`.

エラーメッセージにある通り、 RSpec::Matchers.define_negated_matcher を定義すれば良さそうです。

 

キューに入らないことを確認する (が、ロールバックしてもキューに登録された形跡あり)

ファイルの冒頭に RSpec::Matchers.define_negated_matcher を追加してコードを書きます。

RSpec::Matchers.define_negated_matcher :not_have_enqueued_mail, :have_enqueued_mail
RSpec::Matchers.define_negated_matcher :not_have_enqueued_job, :have_enqueued_job

RSpec.describe 'Api::Email::TransactionsController', type: :request do
  describe 'POST /api/email/transactions' do
    context 'トランザクションでロールバックした場合' do
      context 'キューの確認' do
        it 'メールとジョブを確認すると、キューに形跡はある模様' do
          expect {
            post api_email_transactions_path, params: { name: '王林' }
          }.to not_have_enqueued_mail(HelloWorldMailer, :welcome_email)
                 .and not_have_enqueued_job
        end
      end
    end
  end
end

 
しかし、このテストはパスせず、以下のメッセージが出力されます。

ロールバックしているはずなのに、キューに登録されたことは検知されているようです。

   expected not to enqueue HelloWorldMailer.welcome_email at least 1 time but enqueued 1

...and:

   expected not to enqueue at least 1 jobs, but enqueued 2

そのため、 pending を使って、落ちることが正しいようなテストにしておきます。

it 'メールとジョブを確認すると、キューに形跡はある模様' do
  pending('以下のspecがfailするので、キューには入った形跡がある')

  expect {
    post api_email_transactions_path, params: { name: '王林' }
  }.to not_have_enqueued_mail(HelloWorldMailer, :welcome_email)
         .and not_have_enqueued_job
end

 

テーブルにジョブが登録されていないことを確認する

たとえキューに入っていた形跡があったとしても、Delayed::Job テーブルに登録されていなければワーカーによってジョブは実行されません。

そこで、 queue_adapter:test としないよう設定した上で、テーブルにジョブが登録されていないことを確認すると、テストがパスします。

context 'テーブルを確認' do
  before do
    queue_adapter_changed_jobs.each(&:disable_test_adapter)
  end

  it 'ジョブが delayed_job テーブルに登録されていないこと' do
    expect {
      post api_email_transactions_path, params: { name: 'シナノスイート' }
    }.not_to change(Delayed::Job, :count)
  end
end

 
以上でテストコードでも一通りの挙動を確認できました。

 

ソースコード

Gtihubに上げました。
https://github.com/thinkAmi-sandbox/rails_delayed_job-sample

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_delayed_job-sample/pull/1

RubyMine 2022.1系にて、複数プロジェクトを開くとRailwaysプラグインが1つのプロジェクトにしか表示されないので、暫定対応してみた

RubyMineには、 Railways という bin/rails routes の結果を分かりやすく表示してくれるプラグインがあります。

Path・Action・Nameなどがひと目で分かるため、Railsアプリを書く時には重宝しています。

 
ただ、RubyMine 2021.3系から 2022.1系へアップグレードしたところ、複数プロジェクトを開いた時に

  • 最初に開いたプロジェクトでは、Railwaysプラグインが表示される
  • 同時に開いた別プロジェクトでは、Railwaysプラグインが表示されない

という状態になりました。

 
最初に開いたプロジェクトでは表示されている

 
同時に開いた別プロジェクトでは表示されない

 
Githubのissueを見ると、同じような内容が掲載されていました。
Routes window not visible in IDEA 2022.1 · Issue #54 · basgren/railways

 
これでは使い勝手が悪いので、何とかならないかと色々やってみたところ、表示できるようになったためメモを残します。

 
目次

 

環境

  • mac
  • RubyMine 2022.1.2
    • 記事公開時点の最新版
    • RubyMine 2022.1 からこのような挙動になっていた記憶

 

対応

以下の手順を踏めば自分の環境では使えるようになりました。

ただ、「自分の環境ではできた」レベルのもののため、あくまでも暫定的な対応だと考えています。

 

1. 表示されない方のRubyMineにて、Railwaysプラグインをアンロードする

Preferences > Pluginプラグインページを開きます。

Railways プラグインの右側にあるチェックボックスからチェックを外し、 Apply ボタンをクリックします。

 
しばらくすると、Railways プラグインがアンロードされます。

 

2. 表示されない方のRubyMineにて、Railways プラグインを再度ロードする

再度、 Railways プラグインの右側にあるチェックボックスにチェックを入れ、 OK ボタンをクリックします。

 

すると、今まで表示されていなかった Railways プラグインが表示されるようになりました。

また、Railways プラグインをクリックすると、 bin/rails routes と同等の結果も表示されました。

 
ひとまずこれで様子を見ようと思います。

また、冒頭のissueにもこのことを報告してみました。
https://github.com/basgren/railways/issues/54#issuecomment-1163263532

 
過去にも、RubyMineをアップグレードするとRailwaysプラグインが動作しなくなることがあったため、RubyMine本体に取り込まれると嬉しいと思いました。
RubyMine 2021.3 RC: java.lang.NoClassDefFoundError: com/jgoodies/forms/layout/FormLayout · Issue #52 · basgren/railways

RubyMineで、「同一Rubyバージョンだけど、システム別に異なるgemバージョンを使いたい」場合の設定について調べてみた

RubyMineで開発をする中で、「同一Rubyバージョンだけど、システム別に異なるgemバージョンを使いたい」ことがあったため、メモを残します。

 
目次

 

環境

  • rbenvでRubyをインストール
    • 今回は 2.7.4 を使用
  • RubyMine 2021.3
    • Railwaysプラグインを使いたいため、手元では古いバージョンを使っています
    • JetBrainsのサイトを見る限り、最新バージョンでも同じはず
  • Rails 7.0.3
    • システム別に異なるgemバージョンを試すためのRailsアプリを用意

 
システムごとに異なるgemのバージョンを試すため、今回は自作gemを用意し、Githubに置いておきます。
https://github.com/thinkAmi-sandbox/ruby_hello_world_gem

 
このリポジトリには3つのタグを置いてあります。
https://github.com/thinkAmi-sandbox/ruby_hello_world_gem/tags

3つのタグの違いは、 HelloWorld::Message.call した時に

  • 1.0.0は Hello, world!
  • 2.0.0は hey
  • 3.0.0は bye

がそれぞれ返るものとします。

 
また、用意したRailsアプリは bundle install しない限り、画像のように RubyMine で Rails が起動できない状態であるとします。

 
他に、パターンごとにそれぞれ別の Rails アプリを用意します。

 

パターンごとの設定

rbenv-gemset を使用するパターン

RubyMineのドキュメントを見ると、

RubyMine provides the capability to work with gemsets for the RVM and rbenv version managers. Note that this functionality is supported only for local interpreters.

SDK gemsets | RubyMine

とありました。

そこで、RubyMineで rbenv-gemset を使って設定してみます。
jf/rbenv-gemset: KISS yet powerful gem / gemset management for rbenv

 
rbenv-gemset のREADMEに従い、Githubからインストールします。
https://github.com/jf/rbenv-gemset

 

rbenv-gemset の初期化を行います。

% rbenv gemset init 
created rubymine_sdk_gemsets for 2.7.4
created and initialized the following gemset for use with 2.7.4
=====
rubymine_sdk_gemsets
=====

 
続いて、Preferences > Languages & Frameworks > Ruby SDK and Gems より、プロジェクトの rbenv-gemset を有効化します。

今回は Ruby 2.7.4 を使うため、ラジオボタンRuby 2.7.4 を選択し、 rubymine_sdk_gemsets にチェックを入れます。

 
この状態で bundle install します。

% bundle install
...
Bundle complete! 7 Gemfile dependencies, 56 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

 
再度、RubyMinde の Ruby SDK and Gems を見ると、各 gem が rbenv-gemset にインストールされていることがわかります。

 
なお、bundle config に path が設定されている場合は、 rbenv-gemset よりも bundle config の設定が優先されます。

 

プロジェクトの vendor/bundle へ入れるパターン

RubyMineのドキュメントの目次にはありませんが、 Bundler の install gems の項目に

--path vendor/bundle : install gems to the vendor/bundle project directory. In this case, Bundler adds the .bundle/config file to a project’s root. RubyMine processes this configuration file to detect that gems are installed into vendor/bundle.

Bundler | RubyMine

とありました。プロジェクトで vendor/bundle に入れた場合にも対応していそうです。

 
そこで、まずはグローバルな bundle config にて path を設定してみます。

# 設定
% bundle config set --global path "vendor/bundle"

# 確認
% bundle config
...
path
Set for the current user (/Users/<user_name>/.bundle/config): "vendor/bundle"

 
続いて、 bundle install します。

# インストール
% bundle install
...
Bundle complete! 7 Gemfile dependencies, 56 gems now installed.
Bundled gems are installed into `./vendor/bundle`

# 確認
% bundle exec gem list

*** LOCAL GEMS ***

actioncable (7.0.3)
...
hello_world (2.0.0)
...
rails (7.0.3)
...
zeitwerk (2.6.0)

 
これで vendor/bundle に gem が入ったようですが、インストール直後は SDK としては認識されていないようです。

 
そこで、 bundle config にて、 local 設定を追加します。
Bundler: bundle config

# 設定
% bundle config set --local path "vendor/bundle"

# 確認
% bundle config
...
path
Set for your local app (/path/to/.bundle/config): "vendor/bundle"
Set for the current user (/Users/<user_name?/.bundle/config): "vendor/bundle"

 
すると、再度 RubyMineで index が走り、 vendor/bundle の gem が認識されました。

 
ちなみに、RubyMineでプロジェクトを開き直すと認識されました。

ただ、ドキュメントの記載とは異なるため、これが正しい挙動かは不明です。

 

グローバル (system) にgemを入れるパターン

まずは、bundle config で path が存在しないことを確認します。もし、bundle config で path が設定していた場合は、 unset します。

# 設定を確認
% bundle config

# もし path 設定があれば削除する
% bundle config unset --global path

 
続いて、 bundle install します。

# インストール
% bundle install
...
Bundle complete! 7 Gemfile dependencies, 56 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

 
インストール後、 RubyMine の index が走り終わると、Ruby SDKの各 gem が認識されます。また、Railsも問題なく起動できます。

 
ちなみに、システムにgemを入れた後、以前設定した rbenv-gemset プロジェクトにて Ruby SDK and Gems を確認すると、以下のような表示となっていました。

システムのgemと rbenv gemset の gem がそれぞれ認識されているようです。

 

動作確認

それぞれ別のポートで Rails を起動後、 curl でアクセスするとそれぞれ別バージョンの gem で動作しているようでした。

% curl http://localhost:3000/hello_worlds
{"message":"Hello, world!"}

% curl http://localhost:3002/hello_worlds
{"message":"hey"}

% curl http://localhost:3003/hello_worlds
{"message":"bye"}

 

その他:RubyMineの project SDK とは何か調べる

RubyMine の Run/Debug Configurations を見ると、 Use project SDK という記載があります。

 
project SDK とは何かを RubyMine のドキュメントで調べてみましたが、それらしい記述は見当たりませんでした。

ただ、今回色々動かしてみたところ、 Preferences の Ruby SDK and Gems で選択している Ruby SDK を指しているようでした。

例えば、Railsが入っていない SDK を選択してみると、Railsが起動しませんでした。一方、 Rails が入っている SDK を選択すると、Railsが起動しました。

 

ソースコード

今回使用した gem と Rails アプリはそれぞれGithubに上げました。