エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

CSVファイルをフィルタリングする

「元のCSVファイルから特定の条件の行だけを抜き出した新しいCSVファイルを作成する」という仕事中の話題から。

Ruby

Rubyでしたら、不都合がない限り標準添付のcsvライブラリを利用すると思います。

docs.ruby-lang.org

onliner

「ファイルから読み込んで、行を選択して、ファイルに書き出す」という手続きをそのままコードにしたもの。

require 'csv'

File.write('dst.csv', CSV::Table.new(CSV.read('src.csv', headers: true).select {|row| row['max'].to_i >= 100 }))

手続きごとに分解すると。

require 'csv'

src_data = CSV.read('src.csv', headers: true)              # (1) ヘッダ情報付きでファイルから読み込む
dst_data = src_data.select {|row| row['max'].to_i >= 100 } # (2) 行を選択する
dst_csv = CSV::Table.new(dst_data)                         # (3) 行の集まりをCSVにする
File.write('dst.csv', dst_csv)                             # (4) ファイルに書き出す

このうち(4)の「行の集まりをCSVにする」部分は、CSV::Table.newが「CSV::Rowインスタンスの配列」を引数として受け取ることを利用しています。

docs.ruby-lang.org

CSV::Table.new([CSV::Row.new(%w(a b c), [1, 2, 3]), CSV::Row.new(%w(a b c), [4, 5, 6])])
# => #<CSV::Table mode:col_or_row row_count:3>

またCSV::Tableインスタンスは文字列に変換するとCSV形式の文字列になります。 ここではIOへの出力されたときに自動的に文字列に変換されることを利用しています。

csv = CSV::Table.new([CSV::Row.new(%w(a b c), [1, 2, 3]), CSV::Row.new(%w(a b c), [4, 5, 6])])

csv.to_s
# => "a,b,c\n1,2,3\n4,5,6\n"

puts csv
# => a,b,c
#    1,2,3
#    4,5,6

Stream

入力となるCSVと出力となるCSVを同時に開き、読み込む1行ごとに選択と書き込みをする、というアイディアです。

書き込み時にもヘッダをつける場合には:headersオプションの他に:write_headersオプションを指定する必要があります。

また足をすくわれた点として。 CSV#headersは、ヘッダの利用を指定していても、読み込み前の状態ではtrueしか返してくれません。

[https://docs.ruby-lang.org/ja/latest/method/CSV/i/headers.html:docs.ruby-lang.org

ヘッダの配列を取得するために、ここでは最初に1回CSV#getsを呼び出しています。

require 'csv'

CSV.open('src.csv', headers: true) do |src|
  CSV.open('dst.csv', 'w', headers: true, write_headers: true) do |dst|
    # 1行目を読み込む
    row = src.gets

    # ヘッダ行を書き込む
    dst.puts src.headers

    while row # nil だったら、ファイルの終端に達した
    # 条件に合う場合、その行を書き込む
    dst.puts row if row['max'].to_i >= 100

    # 次の行を読み込む
    row = src.gets
    end
  end
end

SQLite3

あるデータから条件にあったデータを取得するというのならば。 そもそもSQLなどを使うのがよいのでは? と思い浮かんだので書いてみました。

CSV SQLで検索すると色々出てきますが、今回はSQLite3を使うことにしました。

import

www.sqlite.org

まず、SQLite3 のコンソールを起動して動きを確認します。

$ sqlite3 temp.db
sqlite>

.modeコマンドでモードをCSVに変更し、.importコマンドでCSVファイルをテーブルにインポートします。

sqlite> .mode csv
sqlite> .import src.csv data

インポートされたデータはSQLite3のデータですので、当然SQLで検索することができます。

sqlite> select * from data;

ただし、型を指定せずにインポートしたばあい、textとして扱われてしまうため、数値として扱いたいばあい具合がよくありません。

sqlite> select typeof(max) from data limit 1;
text

テキストを数値に変換する関数が必要ですが、明確な変換関数は用意されていないようです。 調べたところround関数が目的のはたらきをしてくれることがわかりました。

www.sqlite.org

sqlite> select round('123');
123.0
sqlite> select typeof(round('123')); -- 型を確認
real
sqlite> select round('123.456');
123.0
sqlite> select round('123.456', 2); -- 桁数を指定
123.46

次のように書くことで目的を達成できました。

sqlite> select * from data where round(max) >= 100;

ヘッダ

SQLite3は、デフォルトでは結果にヘッダを出力しません。

sqlite> select 123 as value;
123

結果をCSVとして取得したいので、.headersコマンドでヘッダの出力を有効にします。

sqlite> .headers on
sqlite> select 123 as value;
value
123

SQLにまとめる

SQLite3にはファイルに出力する方法も用意されています。

www.sqlite.org

ですが今回はリダイレクトを使ってファイルに出力する方法を取ることにします。

まず、ここまでの作業をまとめたSQLファイルを作成します。

-- filtering.sql
.mode csv
.import src.csv data
.headers on
select * from data where round(max) >= 100;

これをリダイレクトでsqlite3コマンドに流し込み、結果もリダイレクトでファイルに出力します。

$ sqlite3 tmp.db < filtering.sql > dst.csv

Rubyでsqlite3コマンドを実行する

RubyからSQLite3を操作するなら、gem sqlite3 を利用するのがマットウな手段と理解していますが。

rubygems.org

手っ取り早く、sqlite3コマンドを実行することで解決してしまいます。

require 'tempfile'

src_filename = 'src.csv'
dst_filename = 'dst.csv'

sql = <<~SQL
.mode csv
.import #{src_filename} data
.headers on
select * from data where round(max) >= 100;
SQL

Tempfile.open do |tempfile|
  IO.popen("sqlite3 #{tempfile.path}", 'r+') do |io|
    io.write sql
    io.close_write

    File.open(dst_filename, 'w') do |dst_file|
      IO.copy_stream(io, dst_file)
    end
  end
end

データベースのファイルをテンポラリで指定し、IO.popensqlite3コマンドを実行します。 これでsqlite3コマンドを実行しているサブプロセスの標準入出力がioにつながった状態になります。

docs.ruby-lang.org

次に用意したSQLを流し込み、入力側を閉じます。

出力先のファイルを書き込みモードで開き、IO.copy_streamでサブプロセスから出力された内容をすべて書き込みます。

docs.ruby-lang.org

ここまで書いておいてなんですが。 どうしてもRubyから実行したいという理由がないかぎりは、シェルスクリプトなどで書いた方が早いですね。

いつか読むはずっと読まない:失敗の恐れ

さいころの経験が影響しているのか。 この年齢になっても失敗を恐ろしく感じることが多々あります。

失敗せずに済むのなら…といつも思いますが、失敗なく済ませられるほど世の中は簡単ではなく。

感情として怖いのはしかたがないとして。 失敗を糧にする心構えだけは、持っておきたいものです。

日ごとの更新件数をEctoで集計したいとき

日ごとの更新件数をEctoで集計したいとき

データベースへは時刻(日時)で記録しておき、利用するときに日付単位で集計したいときがあります。

例えば更新の頻度を日付単位で集計したいばあい、次のような SQL になると思います。

select
  date(updated_at) as updated_date,
  count(1)
from
  items
group by
  updated_date
order by
  updated_date

これを Ecto で実現したい、というのが今回のテーマです。

ちなみに補足をしておくと。 Ecto はデータベース操作のための Elixir のライブラリです。 ですので今回も Elixir の話題です。 念のため。

前提

ここで Repoリポジトリモジュール、Itemitems テーブルのスキーマモジュール、Ecto.Query モジュールは import 済みとします。

hexdocs.pm

hexdocs.pm

hexdocs.pm

日付のみを取得する

まず、データベース上で日付のみを取得したいので、時刻から日付を取得するデータベースの関数 date を利用することが前提になります。

データベースの関数を利用するには、記述した SQL 片を直接データベースに適用する Ecto.Query.API.fragment/1 を利用します。

hexdocs.pm

from(
  i in Item,
  select: fragment("date(?) as updated_date", i.updated_at)
)
|> Repo.all()

生成される SQL は次のようになります(読みやすいように整形しています)。

SELECT
  date(i0."updated_at") as updated_date
 FROM
   "items" AS i0

頻度を数える

頻度を得るためには group by を利用しますが、group_by: "updated_date"group_by: i.updated_date とは記述できません。 updated_dateSQL に「直書き」された名前なので、参照するばあいにも fragment を使う必要があります。

from(
  i in Item,
  group_by: fragment("updated_date"),
  select: %{
    updated_date: fragment("date(?) as updated_date", i.updated_at),
    count: count(1)
  }
)
|> Repo.all()

生成される SQL を確認すると、最初に書いた SQL と同じ構造になっていることがわかります。 これでまずは目的を達成することができました。

SELECT
  date(i0."updated_at") as updated_date,
  count(1)
FROM
  "items" AS i0
GROUP BY
  updated_date

…できましたが。 クエリとして融通が効きませんし、SQL 片を直接組み立てているので危うさがあります。

サブクエリを使う

もっとよい方法がないかと Elixir Forum を探した結果、サブクエリ Ecto.Query.subquery/2を使う方法に行き当たりました。

elixirforum.com

hexdocs.pm

まず、日付だけを抽出するクエリを組み立てます。 このとき select のパラメータはマップ形式にしてキーで値を引けるようにしておきます。

dates =
  from(
    i in Item,
    select: %{updated_date: fragment("date(?)", i.updated_at)}
  )

日付のクエリを subquery を使って頻度を取得するクエリに埋め込みます。 日付の値をキーで取得できるようにしておいたので、新しいクエリの中でもそのキーを利用することができます。

from(
  d in subquery(dates),
  group_by: d.updated_date,
  select: %{
    updated_date: d.updated_date,
    count: count(1)
  }
)
|> Repo.all()

生成される SQL も、当然ですが、サブクエリで構築されています。

SELECT
  s0."updated_date",
  count(1)
FROM
  (
    SELECT
      date(si0."updated_at") AS "updated_date"
     FROM
      "items" AS si0
  ) AS s0
GROUP BY
  s0."updated_date"

今回のケースではサブクエリを使うまでもないかもしれませんが、複数の手段を用意しておいて使い分けできるとよさそうです。

いつか読むはずっと読まない:没後20年の遺作

ティーブン・ジェイ・グールドが2002年に亡くなられて20年近くが経ちましたが。 亡くなる直前に刊行された書籍の邦訳が今秋刊行されました。

実は。 グールド最後のエッセイ集を、読んでしまうのが惜しいと、手をつけられずにいます。

ですが。 これを機に、ページを開こうかと思います。

選択したファイルをプレビュー表示する

ファイルをアップロードするときに利用する input タグ <input type="file"> ですが、ファイルを選択した時点でブラウザ上で画像データを取得することできることを利用してプレビュー表示させる方法です。

過去に使ったことがあったんですが、件のコードにしか情報がなかったので、あらためてブログに記録です。

アイディアとしては、ファイルが選択されたときの change イベントの中で、選択されたファイルを FileReader で読み出し、最終的に Base64エンコードして img タグに放り込む、というものです。

readAsArrayBuffer 関数は非同期なので、完了時に呼び出されるハンドラ onload 内に読み出したデータの処理を記述しています。

データの変換がなかなかケイオスなので、もう少しどうにかならないかという思いがあるところ。

<!DOCTYPE html>
<html>
  <body>
    <div>
      <input type="file" id="file-input">
    </div>

    <div>
      <img id="preview-image">
    </div>

    <script>
      const fileInput = document.getElementById('file-input')
      const previewImage = document.getElementById('preview-image')

      fileInput.addEventListener('change', (event) => {
        const reader = new window.FileReader()
        const file = event.target.files[0]

        reader.onload = (readerEvent) => {
          const data = new Uint8Array(readerEvent.target.result)

          const chunkSize = 0x8000;
          const chars = [];
          for (let i = 0; i < data.length; i += chunkSize) {
            chars.push(String.fromCharCode.apply(null, data.subarray(i, i + chunkSize)));
          }
          const encodedData = window.btoa(chars.join(''))

          previewImage.src = `data:${file.type};base64,${encodedData}`
        };

        reader.readAsArrayBuffer(file)
      })
    </script>
  </body>
</html>

Phoenix 1.6 と HEEx

先の日曜日、2021-09-26 に Phoenix 1.6 がリリースされました!

www.phoenixframework.org

…が、なぜかブログ記事の日付は August 26th, 2021 。

今回も目玉はいくつかあるのですが、その中でも気になる存在が HEEx 。

新しく追加されたシジル ~H を使ってコンポーネントを定義することができます。

lib/my_app_web/views/page_view.ex

defmodule MyAppWeb.PageView do
  use MyAppWeb, :view

  def greeting(assigns) do
    ~H"""
      Hello <%= @target %>
    """
  end
end

テンプレートファイルも、拡張子 .heex が標準になっていて、上で定義したコンポーネントを利用できるようになっています。

lib/my_app_web/templates/page/index.html.heex

<.greeting target="world" />

またパラメータも <%= %> に代わる構文として { } で記述することができるようになりました。

lib/my_app_web/controllers/page_controller.ex

defmodule MyAppWeb.PageController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    conn
    |> assign(:name, "Phoenix")
    |> render("index.html")
  end
end
<.greeting target={@name} />

…ただ、個人的には。 テンプレートがコードに混ざる構文はいまひとつ気になるところ。

このところ仕事が佳境で時間が取れていないのですが、追っていろいろと試してみようと思います。

Alpine.js が 3.x になり、いっそう利用しやすくなった

記事にするのが遅くなりましたが、6 月に Alpine.js が 3.0 にメジャーバージョンアップしました(ちなみに 2021-08-29 現在の最新は 3.2.4)。

alpinejs.dev

色々と改善されていますが、個人的に一番大きな利点はロジックをグローバルスコープに公開しなくてもよくなった点と思っています。

Alpine.js については昨年もブログに書いていますが、この時点では ActionCable のロジックと、グローバルスコープにある Alpine.js のロジックをどう繋げるか頭を捻りました。

blog.emattsan.org

3.x では Alpine.data という関数が追加されたことで、ロジックを分離して記述することが楽になりました。

Ajax を使ったサンプル

何かしらのユーザの一覧、ID と名前とメールアドレスの一覧、を Ajax で表示するサンプルとして書いてみました。

サーバ

単純にユーザの配列を JSON 形式で返すサーバです。

Sinatra で記述しています。 ユーザデータは Faker を使って生成しています。

require 'json'
require 'sinatra'
require 'faker'

users = 20.times.map {|i| {id: i, name: Faker::Name.name, email: Faker::Internet.email} }

get '/' do
  users.to_json
end

クライアント

イベントのハンドリングと状態の管理を Alpine.js で、HTTP リクエストを Aixos で記述しています。

3.x になり、明示的にスタートを記述することが必須になりました。 これも個人的にはタイミングを自分で管理できるのでよい変更と感じています。

スタートすると document のイベント alpine:init が発火するので Alpine.data 関数で HTML の要素と Alpine.js のデータを関連付けます。

初期化時に init() が呼び出されるので、ここで Axios を利用してサーバからユーザの一覧を取得します。

import Alpine from "alpinejs"
import axios from "axios"

function User() {
  return {
    users: [],

    fetch() {
      const url = new URL("http://localhost:4567")

      axios
        .get(url)
        .then((resp) => {
          this.users = resp.data
        })
    },

    init() {
      this.fetch()
    }
  }
}

document.addEventListener("alpine:init", () => {
  Alpine.data("User", User)
})

document.addEventListener("DOMContentLoaded", () => {
  Alpine.start()
})

HTML

ユーザの一覧を表示する HTML です。

x-data="User" を指定した HTML 要素に Alpine.data で指定したデータが関連付けられます。

x-for で Alpine.js のデータの users の内容を表示します。

    <div x-data="User">
      <table>
        <thead>
          <tr>
            <th>id</th>
            <th>name</th>
            <th>email</th>
          </tr>
        </thead>
        <tbody>
          <template x-for="user in users", :key="user.id">
            <tr>
              <td x-text="user.id"></td>
              <td x-text="user.name"></td>
              <td x-text="user.email"></td>
            </tr>
          </template>
        </tbody>
      </table>
    </div>

これだけで Ajax を使ったユーザの一覧表示が実現できました。

f:id:E_Mattsan:20210829194745p:plain

フィルタを追加してみる

次に Alpine.js を使ったフィルタを追加してみます。

サーバ

サーバ側では、パラメータで受け取った文字列にマッチしたユーザデータだけを返すようにします。

require 'json'
require 'sinatra'
require 'faker'

users = 20.times.map {|i| {id: i, name: Faker::Name.name, email: Faker::Internet.email} }

get '/' do
  filter = params['filter']
  users.select {|user| user[:name].include?(filter) }.to_json
end

クライアント

クライアントでは、フィルタの文字列を格納する filterString と、フィルタリング実行のイベントを受ける filtrate 関数を追加します。

fetch 関数は、クエリ文字列にフィルタの文字列を付けてリクエストを送るように修正します。

import Alpine from "alpinejs"
import axios from "axios"

function User() {
  return {
    users: [],
    filterString: '',

    fetch() {
      const url = new URL("http://localhost:4567")
      url.search = `filter=${this.filterString}`

      axios
        .get(url)
        .then((resp) => {
          this.users = resp.data
        })
    },

    init() {
      this.fetch()
    },

    filtrate() {
      this.fetch()
    }
  }
}

document.addEventListener("alpine:init", () => {
  Alpine.data("User", User)
})

document.addEventListener("DOMContentLoaded", () => {
  Alpine.start()
})

HTML

フィルタの文字列を入力するフォームを追加します。

フォームのイベント submit に、関数 filtrate を割り当てます。 ここで preventpreventDefault 関数と同じ働きをしています。

次に input には x-modelfilterStrign を割り当てます。 これによって inputvalueflterString が連動するようになるので、JavaScript のコードから filterString を参照することで inputvalue が参照でき、filterString を変更することで inputvalue を変更するすることができるようになります。 また同じように inputvalue を参照/変更すると、filterString を参照/変更できます。

    <div x-data="User">
      <form @submit.prevent="filtrate">
        <input type="text" x-model="filterString" />
      </form>
      <table>
        <thead>
          <tr>
            <th>id</th>
            <th>name</th>
            <th>email</th>
          </tr>
        </thead>
        <tbody>
          <template x-for="user in users", :key="user.id">
            <tr>
              <td x-text="user.id"></td>
              <td x-text="user.name"></td>
              <td x-text="user.email"></td>
            </tr>
          </template>
        </tbody>
      </table>
    </div>

このように振る舞いを手順ではなく宣言で記述することができるようになっています。

f:id:E_Mattsan:20210829200249p:plain

Alpine.js の利点

フロントエンドを構築する JavaScript のライブラリは多数存在していますが、個人的に Alpine.js を推す理由は、このロジックを分離できる点です。 個人的には、ロジックはロジック、ビューはビューで分離して考えたいという考えをしています。

わたしがフロントエンドに明るいわけでないので、誤った認識をしているかもしれませんが、どうもフロントエンドのライブラリというと、ロジックからビューまでをまとめたコンポーネント単位で開発する印象があります。 その点で、Alpine.js はその辺の結合が緩いため、他の単なる JavaScript のライブラリと同じようにロジックを .js ファイルに記述し、ビューを .html ファイルに記述するだけで利用することができ、コンポーネント形式で記述されたファイルを変換するような工程がいりません。

また、わたしの場合ウェブアプリケーションの開発には主に Ruby on Rails を利用していますが、そこでも HTML のレンダリングRails にまかせた上で、JavaScript のロジックだけに注力して利用できるという利点があります。

実のところ。Ruby on Rails でアプリケーション開発をしていく中、どこかで効果的に活用しようとタイミングを見計っているところです。

いつか読むはずっと読まない:余裕なき Slack

"Slack" というチャットサービスがありますが。 個人的に相性がよくないのか、使い勝手がよくありません。

なんか、使っていると余裕がなくなると言いますか、落ち着かなくなると言いますか、気楽にメッセージをポストできる感じでないのです。

なんか…こう…気楽に使えるチャットサービスが欲しいですね…。

PDF.js を使って PDF をブラウザに表示するための覚書

仕事で PDF をブラウザのページ中に埋め込んで表示したいことがあり、そのときに PDF.js を利用しました。

のちのち再び使いたくなったときのために、骨格を抜き出してまとめたものです。

サーバには Elixir の Phoenix framework を使っていますが、ほぼ静的なページで Phoenix らしいことは特に何もしていないので、Rails などでも同じように使える、はずです。

mozilla.github.io

assets/js/MyPdf.js

PDF.js の利用をこの一つのファイルに分離しています。

内容としては PDF.js の example にあるものと同じですが、class を使った記述に書き換えています。

class MyPdf {
  constructor(canvas, afterRendered) {
    this.canvas = canvas
    this.context = this.canvas.getContext('2d')
    this.pageNumber = 1
    this.rendering = false
    this.afterRendered = afterRendered
  }

  setAfterRendered(afterRendered) {
    this.afterRendered = afterRendered
  }

  loadDocument(path) {
    const renderPdf = (pdf) => {
      this.pdf = pdf
      this.getPage()
    }

    pdfjsLib
      .getDocument(path)
      .promise
      .then(renderPdf)
  }

  setPage(pageNumber) {
    if (1 <= pageNumber && pageNumber <= this.pdf.numPages && !this.rendering) {
      this.pageNumber = pageNumber
      this.getPage()
    }
  }

  nextPage() {
    this.setPage(this.pageNumber + 1)
  }

  prevPage() {
    this.setPage(this.pageNumber - 1)
  }

  getPage() {
    if (!this.rendering) {
      this.rendering = true
      this.pdf.getPage(this.pageNumber).then(this.renderPage.bind(this))
    }
  }

  renderPage(page) {
    const viewport = page.getViewport({scale: 1})

    this.canvas.height = viewport.height
    this.canvas.width = viewport.width

    page
      .render({ canvasContext: this.context, viewport })
      .promise
      .then(() => {
        if (this.afterRendered) { this.afterRendered(this.pdf.numPages, this.pageNumber) }
        this.rendering = false
      })
  }
}

export { MyPdf }

assets/js/app.js

MyPdf を利用します。 HTML の要素の操作はこちらで済ませて MyPdf ではそれらを意識せずに済むようにしています。

import "../css/app.scss"
import "phoenix_html"

import { MyPdf } from './MyPdf'

const myPdf = new MyPdf(document.getElementById('the-canvas'))

myPdf.setAfterRendered((numPages, pageNumber) => {
  document.getElementById('page').innerText = `${pageNumber} / ${numPages}`
})

document.getElementById('next').addEventListener('click', myPdf.nextPage.bind(myPdf))
document.getElementById('prev').addEventListener('click', myPdf.prevPage.bind(myPdf))

myPdf.loadDocument('sample.pdf')

lib/pdfjs_web/templates/layout/app.html.eex

レイアウトテンプレートです。

CDN から PDF.js を読み込んでいます。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>Pdfjs · Phoenix Framework</title>
    <link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
    <script defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.9.359/pdf.min.js" integrity="sha512-U5C477Z8VvmbYAoV4HDq17tf4wG6HXPC6/KM9+0/wEXQQ13gmKY2Zb0Z2vu0VNUWch4GlJ+Tl/dfoLOH4i2msw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  </head>
  <body>
    <main role="main" class="container">
      <%= @inner_content %>
    </main>
  </body>
</html>

lib/pdfjs_web/templates/page/index.html.eex

PDF の表示領域とページ送りのボタンを配置したテンプレートです。

先に書いた通り、このテンプレートの要素と MyPdf を、 assets/js/app.js が結びつけています。

<button id="prev">&lt;&lt;</button>
<span id="page"></span>
<button id="next">&gt;&gt;</button>

<canvas id="the-canvas"></canvas>

Discord Webhook で Ruby からファイルを POST する

昨年の 10 月に Discord Webhook で Elixir からファイルを POST する方法を記事に書きましたが、その Ruby 版です。

テキストをPOSTする

テキストのみの場合、net/httpNet::HTTP.post_form メソッドを使うことで簡単に POST できます。 パラメータとして、本文は content 、ユーザ名は username をキーにした Hash を渡します。 このとき content は必須です。

そのほかのフィールドは Discord のドキュメントを参照してください。

ここでは Webhook の URL は次のように環境変数 WEBHOOK_URL に設定してあることにします。

$ export WEBHOOK_URL=https://discord.com/api/webhooks/****/*******

あとは Webhook の URL 、JSON エンコードした body 、ヘッダにコンテントタイプを指定して POST すれば OK です。

require 'net/http'

Net::HTTP.post_form(
  URI.parse(ENV['WEBHOOK_URL']),
  {
    username: 'emattsan',
    content: 'Hello'
  }
)

ファイルを POST する

前回の Elixir の記事でも書きましたが、ファイルをアップロードするときは JSON でなくて multipart/form-data で POST する必要があります。

multipart-post gem を使う

ありがたいことに multipart を扱うための gem が公開されています。

multipart で POST するためのクラス Net::HTTP::Post::Multipart と、ファイルを扱うためのヘルパクラス UploadIO を使います。

まず POST したいファイルから UploadIO のオブジェクトを作成します。

次に New::HTTP::Post::Multipart のオブジェクトを作成します。 ここでドキュメントにあるように、パラメータ の Hash にキーを file にして UploadIO のオブジェクトを渡します。

あとは Net::HTTP::Post を利用するばあいと同じ手順で POST できます。

require 'json'
require 'net/http'
require 'net/http/post/multipart'

url = URI.parse(ENV['WEBHOOK_URL'])

file =
  UploadIO.new(
    File.new('./images/image.jpg'), # POST するファイル
    'image/jpeg',                   # ファイルの Content-Type
    'image.jpg'                     # POST されたときのファイル名
  )

req =
  Net::HTTP::Post::Multipart.new(
    url.path,
    username: 'emattsan',
    content: 'Hello',
    file: file
  )

http = Net::HTTP.new(url.host, url.port)

http.use_ssl = true

http.request(req)

自力で multipart を書く

有用な gem があるのでわざわざ自力で書かなくてもよいのですが、multipart の理解のため、また万が一公開されている gem を利用できないケースのため、一例を上げておきます。

multipart ではバウンダリで区切られた複数のコンテンツを POST の body に記述します。

えー…、詳しくは公式の資料とか Multipart messages - MIME - Wikipedia とか参照してみてください。

require 'net/http'

boundary = 'DiscordMultipartMessage'

url = URI.parse(ENV['WEBHOOK_URL'])

req =
  Net::HTTP::Post.new(
    url.path,
    'Content-Type': "multipart/form-data; boundary=#{boundary}"
  )

# body にバウンダリで区切ったフィールドを記述する
# このときファイルは読み込んだファイルの内容を挿入します
req.body = <<~BODY
--#{boundary}
Content-Disposition: form-data; name="username"

emattsan

--#{boundary}
Content-Disposition: form-data; name="content"

Hello

--#{boundary}
Content-Disposition: form-data; name="file"; filename="image.jpg"

#{File.read('./images/image.jpg')}

--#{boundary}--
BODY

http = Net::HTTP.new(url.host, url.port)

http.use_ssl = true

http.request(req)

いつか読むはずっと読まない:組合せ数学

組合せ論って面白いですよね。 読んでいるといつの間にか「どう書く」の問題を考えてしまっています。