時間のかかるコマンドの標準出力と標準エラー出力を一定時間ごとに Slack に送信する

# やりたいこと

実行に時間のかかるコマンドがある。その進捗を確認するために、出力を n 秒ごとに Slack に送信したい。

- 要件
  - 標準出力と標準エラー出力をそれぞれ n 秒ごとに Slack に送信する。
  - 差分だけ送信する。つまり、一度 Slack に送信した内容は再度送信しないようにする。

# 前提

任意の Slack チャンネルにメッセージを送信するための Incoming Webhook URL を持っていること。もし持っていない場合は以下を参考に URL を生成してください。

https://api.slack.com/messaging/webhooks

# 方法

実行に 30 秒程度かかる `show_numbers.rb` という Ruby スクリプトがある。

```show_numbers.rb
require 'prime'

# 同期モードを有効にしないと、後で使用する Open3.popen3 で出力を読み込めないので注意。
$stdout.sync = true
$stderr.sync = true

(1..30).each_with_index do |n, i|
  sleep(1) unless i == 0

  if Prime.prime?(n)
    $stderr.puts("[WARN] #{n} is a prime number. It's a lonely number.")
    next
  end

  puts("[INFO] #{n}")
end
```
```shell-session
$ ruby show_numbers.rb
[INFO] 1
[WARN] 2 is a prime number. It's a lonely number.
[WARN] 3 is a prime number. It's a lonely number.
[INFO] 4
[WARN] 5 is a prime number. It's a lonely number.
[INFO] 6
(略)
```

このスクリプトを実行するために `ruby show_numbers.rb` というコマンドを外部プログラムのコマンドとして [Open3.popen3](https://docs.ruby-lang.org/ja/latest/method/Open3/m/popen3.html) を使って実行する。その出力を 10 秒ごとに Slack に送信する。

```notify_to_stack.rb
require 'json'
require 'net/http'
require 'open3'

# ここに Slack の Incoming Webhook URL を設定する。
SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/xxx'

# メッセージを Slack に送信する。
def send_to_slack(message)
  Net::HTTP.post_form(URI(SLACK_WEBHOOK_URL), payload: { text: message }.to_json)
end

# バッファ (このメソッドの下で定義) の内容を Slack に送信し、その後バッファをクリアする。
def send_and_clear_buffers(stdout_buffer:, stderr_buffer:, now: Time.now)
  messages = []

  messages << now
  messages << <<~TEXT.chomp
    stdout
    ```
    #{stdout_buffer.any? ? stdout_buffer.join.chomp : '(none)'}
    ```
  TEXT
  messages << <<~TEXT.chomp
    stderr
    ```
    #{stderr_buffer.any? ? stderr_buffer.join.chomp : '(none)'}
    ```
  TEXT

  send_to_slack(messages.join("\n"))

  stdout_buffer.clear
  stderr_buffer.clear
end

# 標準出力と標準エラー出力のためのバッファ (文字列の一時的な保存場所) を用意する。
stdout_buffer = []
stderr_buffer = []

command = 'ruby show_numbers.rb'
tick = 10

Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
  # 標準出力をバッファに書き込むスレッド。
  stdout_thread = Thread.new(stdout, stdout_buffer) do |out, buffer|
    out.each_line { |line| buffer << line }
  end
  # 標準エラー出力をバッファに書き込むスレッド。
  stderr_thread = Thread.new(stderr, stderr_buffer) do |out, buffer|
    out.each_line { |line| buffer << line }
  end
  # 10 秒ごとにバッファの内容を Slack に送信するスレッド。
  slack_thread = Thread.new(stdout_buffer, stderr_buffer) do |stdout_b, stderr_b|
    loop do
      sleep(tick)
      send_and_clear_buffers(stdout_buffer: stdout_b, stderr_buffer: stderr_b)
    end
  end

  send_to_slack("#{Time.now}: Start")
  # 実行したコマンドが終了するのを待つ。
  wait_thr.join

  # コマンドの実行が終了したら各スレッドを終了し、バッファに残った内容を Slack に送信する。
  [stdout_thread, stderr_thread, slack_thread].each(&:exit)
  send_and_clear_buffers(stdout_buffer: stdout_buffer, stderr_buffer: stderr_buffer)

  send_to_slack("#{Time.now}: Finish")
end
```

この Ruby スクリプトを実行すると、以下のように Slack にメッセージが送信される。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6697/e7295a62-574d-4b2d-a16c-609f44684979.png)

# 参考

- [IO#sync= (Ruby 3.4 リファレンスマニュアル)](https://docs.ruby-lang.org/ja/latest/method/IO/i/sync=3d.html)
- [Open3.#popen3 (Ruby 3.4 リファレンスマニュアル)](https://docs.ruby-lang.org/ja/latest/method/Open3/m/popen3.html)