くりにっき

フルスタックキュアエンジニアです

rbs_collection.lock.yamlをGitHub Actionsで自動updateする

ruby-jp slackでそういう需要があるので作った

ワークフローの設定

最低限いるのはこの辺。必要に応じてslack通知とか足してください *1

name: rbs-collection-updater

on:
  schedule:
    - cron: "0 0 1 * *" # Run monthly
  workflow_dispatch: # Run manually

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: ruby/setup-ruby@v1
        with:
          # TODO: 適宜バージョンを指定する。(リポジトリに.ruby-versionや.tool-versionsをコミットしているなら不要)
          # ruby-version: "3.2" 

          bundler-cache: true

      - run: bundle exec rbs collection update

      - uses: actions/create-github-app-token@v1
        id: app-token
        with:
          app-id: ${{ secrets.GH_APP_ID }}
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}

      - uses: peter-evans/create-pull-request@v5
        with:
          token: ${{ steps.app-token.outputs.token }}
          committer: GitHub <noreply@github.com>
          title: "Update rbs_collection.lock.yaml"
          commit-message: "Run `bundle exe rbs collection update`"
          labels: rbs-collection-updater

実際に作られたPR

github.com

botの名前がsue445-bundle-update-prになっているのはbundle update PR用途で使ってたGitHub Appを流用しているため

事前に必要なもの

GitHub App

GitHub Actionsでデフォルトで使える secrets.GITHUB_TOKEN でPRを作成するとそのPRに対するjobが実行されない*2ので、PRを作りたい時だけは使わない方がいいです。

そこで別のtokenを使うことになるのですがGitHub ActionsでPersonal Access Token(PAT)を使うのはセキュアじゃない*3のでGitHub App経由でTokenを発行する(通称App Token)のが定番です。

github.com

docs.github.com

GitHub Actions上でPRを作るためにはGitHub Appに下記のパーミッションがあるといいでしょう

  • Contents: Read and write
  • Pull requests: Read and write

作成したAppのIDと秘密鍵はsecrets経由で渡しています。

*1: https://github.com/sue445/doorkeeper_jp/blob/main/.github/workflows/rbs-collection-updater.yml だとslack通知を入れている

*2:jobの無限実行を防ぐためのGitHub Actionsの仕様。c.f. https://sue445.hatenablog.com/entry/2020/08/31/081447

*3:アクセス可能なリポジトリや有効期限が無制限のPATが漏れると死

ruby-wasm-vdomで雑なオレオレツールを作った

作ったもの

https://sue445.github.io/annict-vod-search/

github.com

モチベーション

最近縁があって Annict の編集者*1 になって、気がついた時に各アニメの配信サイトの情報を登録しています。

その時にいちいち複数の配信サイトでアニメのタイトルを検索するのが大変なので一箇所で検索できるようにしたくて作りました。(製作時間は2〜3時間)

使った技術

いつもだとこの手の雑なページはVue.jsを使うことが多いのですが、今回は面白全部 *2ruby.wasmを使ってみました。

ruby.wasmで仮想DOMを扱うためにruby-wasm-vdomを利用しています。

ruby-wasm-vdomについては下記を参照。

github.com

qiita.com

感想

  • 使い慣れたRubyで書けるのは便利
  • ヒアドキュメントでJSXっぽく書けるのは面白い *3
    • しかし入力値をそのまま出力するとXSSが発生して こういう対応 を自前でやる必要があって大変。(Railsだとデフォルトでいい感じにしてくれるのであまり気にしてなかった)
  • wasmの中でエラーが起きた時にどの行でエラーが出たかとかがぱっと分からないのでデバッグが大変だった
  • あまりドキュメントがなかったけど https://github.com/getty104/ruby-brainfuck-interpreter が参考になった

*1:データベースにアニメ情報を自由に追加できる人

*2:面白半分の2倍

*3:https://github.com/sue445/annict-vod-search/blob/be8a262271f5e26f4cfe0618e41f9e7f8ba6e008/docs/src/app.rb#L24-L42

Twitter botをAPI v2移行する(Ruby編)

https://github.com/sue445/pribirthdaybotTwitter API v2移行した時のメモ

tl;dr;

移行先のgem

自分が当時調べた範囲だとAPI v2対応しているAPIクライアントのgemは下記の2つしかなかったです。

github.com

github.com

ちなみに前者のsimple_twitterは最近メンテナになりました。

sue445.hatenablog.com

その他に必要になりそうなgem

TwitterのOAuth2のリフレッシュトークンからアクセストークンを取得するのに必要だったgem

github.com

ウェブアプリでTwitterのOAuth2認証したい場合に必要なgem

github.com

botAPI v2対応する時の注意点

アクセストークン(厳密にはベアラートークン)には有効期限がある(約2時間)ので、botで利用する時には実行時にリフレッシュトークンからトークンリフレッシュを行い毎回新しいアクセストークンを生成する必要があるのですが、一度利用したリフレッシュトークンは使えなくなるのでトークンリフレッシュの時に返ってくるリフレッシュトークンをDBとかに保存しておく必要があります。(このリフレッシュトークンを次回実行時に使う)

追記: 2023/07/21 10:40

これ書いた後に知ったんだけどAPI v2を使う上でOAuth2は必須ではない(一部のエンドポイントはOAuth 1.0aのアクセストークンとかでTwitter API v2が使えるらしい *1 )ので、OAuth1.0aであればリフレッシュトークンは考えなくてよさそう

手順

リフレッシュトークンを作成する

一番最初にリフレッシュトークンを取得するところだけは手で作る必要があります。

自分用にはこういうアプリをローカルで動かして認証するようにしていました。

github.com

Twitter Appについているデフォルトのトークンでもいいんですが、Appを管理しているTwitterアカウントとbotTwitterアカウントが異なる場合にはこっちの方がいいと思います。( https://developer.twitter.com/ への登録は電話番号認証が必要なのでなるべく1つのTwitterアカウントに集約させたい)

権限はTwitter botの要件であればこれで十分。 *2

# https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code
TWITTER_V2_SCOPE = "tweet.read tweet.write users.read offline.access"

リフレッシュトークンを使う場合には最低限 offline.access は必須。他は用途に応じて調整してください。

このアプリをローカルで動かせば下記のようなjsonが返ってくるので、ここにあるtokenとrefresh_tokenを使います。

{
    "provider": "twitter2",
    "uid": "14540215",
    "info": {
        "name": "sue445",
        "email": null,
        "nickname": "sue445",
        "description": "フルスタックキュアエンジニア。 プリキュア/サザエさん/プリパラ/プリ☆チャン/プリマジ/オヤジギャグコミッタ 【警告】上級者向けアカウント。フォローすると実況ですごい勢いでTLが埋まるよ!【危険】",
        "image": "https://pbs.twimg.com/profile_images/378800000394619528/fb8a746f6955be08134050ba2adaa1f2_normal.png",
        "urls": {
            "Website": "https://t.co/fZVVmoHpLx",
            "Twitter": "https://twitter.com/sue445"
        }
    },
    "credentials": {
        "token": "XXXXXXXXXXXXXXXXXXXXXX",
        "refresh_token": "XXXXXXXXXXXXXXXXXXXXXX",
        "expires_at": 1689828440,
        "expires": true
    },
    "extra": {
        "raw_info": {
            "data": {
                "verified": true,
                "protected": false,
                "description": "フルスタックキュアエンジニア。 プリキュア/サザエさん/プリパラ/プリ☆チャン/プリマジ/オヤジギャグコミッタ 【警告】上級者向けアカウント。フォローすると実況ですごい勢いでTLが埋まるよ!【危険】",
                "entities": {
                    "url": {
                        "urls": [
                            {
                                "start": 0,
                                "end": 23,
                                "url": "https://t.co/fZVVmoHpLx",
                                "expanded_url": "http://sue445.hatenablog.com/",
                                "display_url": "sue445.hatenablog.com"
                            }
                        ]
                    }
                },
                "name": "sue445",
                "location": "Tokyo / Japan",
                "id": "14540215",
                "profile_image_url": "https://pbs.twimg.com/profile_images/378800000394619528/fb8a746f6955be08134050ba2adaa1f2_normal.png",
                "url": "https://t.co/fZVVmoHpLx",
                "created_at": "2008-04-26T05:33:43.000Z",
                "public_metrics": {
                    "followers_count": 2859,
                    "following_count": 1678,
                    "tweet_count": 456027,
                    "listed_count": 182
                },
                "username": "sue445"
            }
        }
    }
}

botでのAPI v2移行

自分がsimple_twitterのメンテナをしてるってことでsimple_twitterを例に出しますが全体的な流れはtweetkitでもそんなに変わらないと思います。

Gemfile に下記を追加。

gem "simple_twitter"
gem "twitter_oauth2"

こういうclassを追加 *3

# frozen_string_literal: true

class TwitterClient
  API_ENDPOINT = "https://api.twitter.com/2"

  class Error < StandardError
  end

  # @return [Hash]
  def get_me
    simple_twitter_client.get("#{API_ENDPOINT}/users/me")
  end

  # @param [String] text
  def post_tweet(text)
    simple_twitter_client.post("#{API_ENDPOINT}/tweets", json: { text: text })
  end

  private

  # @return [String]
  def access_token
    return @access_token if @access_token

    oauth2 = TwitterOAuth2::Client.new(
      identifier: ENV["TWITTER_V2_CLIENT_ID"],
      secret:     ENV["TWITTER_V2_CLIENT_SECRET"],
    )

    # FIXME: refresh_tokenをDBなどから取得する
    oauth2.refresh_token = load_refresh_token

    ret = oauth2.access_token!

    # FIXME: refresh_tokenをDBなどに保存する
    save_refresh_token(ret.refresh_token)

    @access_token = ret.access_token
  end

  # @return [SimpleTwitter::Client]
  def simple_twitter_client
    @simple_twitter_client ||= SimpleTwitter::Client.new(bearer_token: access_token)
  end
end

ここでポイントになるのがrefresh_tokenの読み込みと保存です。

priborthdaybotだと実行環境にCloud Functionsを使っているのでFirestoreに保存するようにしていますが、ここはアプリの要件によって変わると思います。(だいたいはRDBやKVSになるはず)

具体的な作業内容は https://github.com/sue445/pribirthdaybot/pull/61/files を見てください。

研鑽Rubyプログラミングを読んだ #研鑽Ruby

最近家で読書する習慣が薄れてたのですが *1 頑張って研鑽Rubyプログラミング(以下:研鑽Ruby)を読みました。

研鑽Rubyプログラミング ― 実践的なコードのための原則とトレードオフwww.lambdanote.com

雑感

リファクタリング:RubyエディションメタプログラミングRuby 第2版 をgemの開発やメンテナンス方面に特化させたような感じ。(2冊とも読んだのが結構前になるので厳密には違うかもしれない)

バージョンアップ時になるべく非互換を防ぐような機能開発を行ったりdeprecation warningを仕込んだりするのはgemに関係なく他の言語のライブラリ開発でも普通に参考になります。

僕は10年以上gemを開発してますが終始「わっかる~」って頷きながら読んでました。

そんな自分でもちょいちょい知らないこともあって紛れもなく中級者~上級者向けの1冊

読書中のメモ

  • ISUCONで使えそうなパフォーマンス改善ネタがちょいちょいあった
    • バックトレース無し例外とか
  • Array#firstArray#lastArray#[] に置き換えてパフォーマンス改善するやつはrubocop-performanceへのPRネタとしてもよさそう
  • sequelやsinatraなどの有名所のgemのDSLの実装方法が解説されていてむっちゃ参考になった
  • 今まで自分が何気なくやってきた作業の流れ(機能追加のPRを入れる前に、機能追加しやすくするためのリファクタリング系のPRを先にmainにマージする)に関して「系統的アプローチ」って名前がついてるの助かる
    • 名前がつくことで他の人に伝えやすくなる
  • パフォーマンス改善に関してトレードオフも提示してるのが好印象
  • Rubyでこんな書き方はしないだろーw」と思いつつも年1~2回くらい必要に迫られそうなので、また必要に応じて読み返しそう

宣伝

2023/5/11(木)~13(土)に開催予定の RubyKaigi 2023 に「Fix SQL N+1 queries with RuboCop」というタイトルで発表予定です。

rubykaigi.org

研鑽Rubyを読みながらちょいちょい「これ、RubyKaigiの発表資料に書いたやつだ~」「この発表資料をそのまま出すとPolished Rubyist *2からツッコミが入るから補足入れないと」ってのがあって焦りました。

実は今回の発表資料は2年前の社内勉強会の発表資料がベースになってるので研鑽Rubyの影響は受けてないです。 先に研鑽Rubyを読んでたらもっと違う形になってそう。

5/12(金)に僕のトークを聞きに来てくれる人は予習として研鑽Rubyを読んでるとそういった一致点を見つけられて面白いかもしれないです。

*1:主にマスターデュエルのせい

*2:研鑽Ruby読了済みのRubyist

gem_rbs_collectionを使ってるgemをいい感じにCIする

前置き

gemを作る時にはだいたい最初に bundle gem コマンドでgemの雛形を作りますが、最近のbundlerだと sig/rbsファイルを格納するディレクトリ)とrbsファイルの雛形も作られるようになっています。

最近作った https://github.com/sue445/doorkeeper_jprbsをいい感じにCIするところまで持っていけたのでメモ。

やったこと

gem_rbs_collectionをsubmoduleとして追加する

https://github.com/ruby/gem_rbs_collection のREADMEに書かれているようにsubmoduleとして追加。

gemにsubmoduleを追加すると rake build した時に作られるgemファイルにも含まれないか心配だったのですが、調べたところsubmoduleの中身は含まれなかったのでgem_rbs_collectionを追加することで配布するgemファイルが肥大化することはなさそうです。

手元でrbs validateを叩けるようにする

Rakefile に下記のようなtaskを追加。 *1

desc "validate rbs"
task :rbs_validate do
  command = %w(
    rbs
    --repo vendor/rbs/gem_rbs_collection/gems/
    -r date
    -r forwardable
    -r uri
    -r faraday:2.5
    -r hashie:5.0
    -I sig/
    validate
    --silent
  ).join(" ")

  sh command
end

-r でrequireするものは作るgemによって変わりますが他の部分はだいたい使いまわしでよさそう。 -r を個別指定するのが面倒なんだけど今の所他にいい方法は見つからなかった...

CIでrbs validateを実行する

さっき追加した rake rbs_validateGitHub Actionsのworkflowで実行 *2

こんな感じ。

- run: bundle exec rake rbs_validate

submoduleとしてgem_rbs_collectionをdependabotで定期更新する

.github/dependabot.yml に下記のようなファイルを追加 *3 することでDependabotでsubmoduleも定期的に更新してくれるようになります。

version: 2
updates:
  - package-ecosystem: gitsubmodule
    directory: "/"
    schedule:
      interval: monthly

interval(daily or weekly or monthly)は適当なので適宜変えてください。

実際にDependanotでgem_rbs_collectionを更新した時のPRはこちら

github.com

datadog_thread_tracerを作った

ISUCON合わせで作ったやつ第n弾

github.com

モチベーション

ISUCONの素振りでパフォーマンスチューニングのために重い処理を

threads = []

threads << Thread.start do
  # 並列で実行したい処理1
end

threads << Thread.start do
  # 並列で実行したい処理2
end

threads.each(&:join)

みたいにスレッドで並列処理すると、

のようにスレッド内で実行した処理がddtraceでトレースできなくて困ったのでなんとかしたかったのが一番の理由です

実装までの経緯

公式ドキュメントを探してもどうやればスレッド内の呼び出しをトレースできるか見つからなかったのですが、 下記Issueをヒントにして実装しました。

github.com

このIssueだとddtrace v0系だけどv1系でシンタックスが大きく変わってるのでdatadog_thread_tracerはv1系前提で実装してます。

datadog_thread_tracerを使った時

下記のように書くことで

require "datadog_thread_tracer"

DatadogThreadTracer.trace do |t|
  t.trace do
    # 並列で実行したい処理1
  end

  t.trace do
    # 並列で実行したい処理2
  end
end

このようにトレースがDatadogで見れるようになります

頑張りポイント

rbsを書いた

最近のbundlerだと bundle gem した時に sig/ ディレクトリができていてrbsのスケルトンが生成されてるので真面目に書いてみました。

https://github.com/sue445/datadog_thread_tracer/tree/main/sig

mysql2-nested_hash_bindを作った

ISUCON合わせで作ったやつ第n弾です。*1

github.com

モチベーション

達人が教えるWebパフォーマンスチューニング 〜ISUCONから学ぶ高速化の実践:書籍案内|技術評論社(通称ISUCON本) を読んでた時に

SELECT
  `posts`.`id`,
  `posts`.`user_id`,
  `posts`.`body`,
  `users`.`account_name` AS `users.account_name`,
  `users`.`authority` AS `users.authority`,
  `users`.`del_flg` AS `users.del_flg`
FROM `posts`
INNER JOIN `users` ON `posts`.`user_id` = `users`.`id`

のようにカラム名ドットが含まれるSELECT文を https://github.com/jmoiron/sqlx に渡すと users の部分をいい感じにGoのstructに詰め替えてくれると書かれていたのですが、Rubyでも同じことをやりたくてモンキーパッチgemを作りました。

使い方

READMEが全てなんですが、 using Mysql2::NestedHashBind::QueryExtension を書いたスコープで Mysql2::Client#queryMysql2::Client#xquery にモンキーパッチを仕込みます。

require "mysql2-nested_hash_bind"

using Mysql2::NestedHashBind::QueryExtension

db = Mysql2::Client.new(
  host: ENV.fetch("MYSQL_HOST", "127.0.0.1"),
  port: ENV.fetch("MYSQL_PORT", "3306"),
  username: ENV.fetch("MYSQL_USERNAME"),
  database: ENV.fetch("MYSQL_DATABASE"),
  password: ENV.fetch("MYSQL_PASSWORD", ""),
  charset: "utf8mb4",
  database_timezone: :local,
  cast_booleans: true,
  symbolize_keys: true,
  reconnect: true,
)

rows = db.query(<<~SQL)
  SELECT
    `posts`.`id`,
    `posts`.`user_id`,
    `posts`.`body`,
    `users`.`account_name` AS `users.account_name`,
    `users`.`authority` AS `users.authority`,
    `users`.`del_flg` AS `users.del_flg`
  FROM `posts`
  INNER JOIN `users` ON `posts`.`user_id` = `users`.`id`
SQL

rows.first
#=> {:id=>1, :user_id=>445, :body=>"test", :users=>{:account_name=>"sue445", :authority=>false, :del_flg=>false}}

頑張りポイント

ISUCONで利用するということでパフォーマンスチューニングを頑張りました。

具体的には https://github.com/evanphx/benchmark-ipsベンチマークをとりつつ https://github.com/tmm1/stackprof でgemのボトルネックを調べてボトルネックになってた箇所を改善してます。

stackprofで見つかったボトルネックの1つに Symbol#to_s があったのでfreezeされたStringを返す Symbol#name を使おうとしたのですが、Symbol#name が使えるのがRuby 3.0以降だったのでこのgemもRuby 3.0以降でしか使えないようにしてます。

ベンチマークのコードとレポートは https://github.com/sue445/mysql2-nested_hash_bind/tree/main/benchmark に置いているのでどれくらいオーバーヘッドあるかはここを見てください。

*1:まだOSSにしてないものを含めたらこの手のツールを5〜6個作ってる気がするので何番目かなんて正直把握していない