| @技術/プログラミング

先週末と今日ガチャガチャやって、ようやく Ruby 3 にアップグレードすることができた。 Ruby 2.7.3 → Ruby 3.1.6 。ただ Ruby 3.1 は今年の 5 月に EOL を迎えるみたいなのでこちらもさっさと新しいバージョンの Ruby に上げないといけない。

やったことは一つ前の記事に加えて以下。

kaminari-sinatra の SinatraHelpers が Ruby 3 & ActionView v6 対応していなかったのでちょこちょこと修正した。

次に padrino-helpers が Ruby 3 と Haml v6 に対応していないのを対応させた。具体的には form_tag の中身のタグが過剰に escape されてしまうので、あんまり良くないかもだが capture_html したやつを html_safe した。 form_tag の内側に来るものはユーザー投稿コンテンツではないはずなのでエスケープはサイト管理者側でできるはず。

ドキュメントでは

= form_tag

!= form_tag

にしろとは言われているが、 form_tag の中身で concat_contet してる片方( capture_html(&block) の結果)が html_safe? => false になるので、 View テンプレート側で何かやっても意味がない( Buffer が汚染されると View で html_safe しても汚染された部分の文字列はエスケープ済みになっている)。

kaminari-sinatra も padrino-helpers も本家にパッチを送ると良いのだろうが、職業プログラマーではなくなったのでなかなか腰が重い。 kaminari-sinatra はテストが通らないし、 padrino-helpers は git clone で submodule の clone に失敗するのでテストが実行すらできないかもしれない。

心の余裕ができたらやってみる。

ちなみに Ruby 3 化するにあたり sinatra-cache を完全に捨てたので負荷が上がるかも。オリジナルの gem は 15 年くらいコミットされてなくて fork して使い続けてきたけど Sinatra や Haml の変更に追従できる気がしないのでいったん捨ててみる。

| @技術/プログラミング

このブログは Ruby 2.7 でずっと動かしていた。コミットログをたどると 2020 年の 1 月から Ruby 2.7 のようだ。 Ruby 2.7 は 2023 に EOL を迎えている。

さすがにまずいと思ったので Ruby 3 にしようと一日頑張ってみたが、なかなかうまくいかない。Ruby 3 の キーワード引数の仕様変更はかなり対応がきつい。どこで ArgumentError が起こっているのかが極めて追いかけづらい。他のメソッドに委譲している場合などは特に。

ガチャガチャやってトップページと個別記事ページまでは Ruby 3 化できたので Ruby 3 でデプロイしてみたが、動かない画面があることに気がついたので Ruby 2.7 に戻した。 Kaminari がちゃんと動かない(具体的には kaminari-sinatra と actionview v6 系の互換性がない)のが原因でページネーションするページがちゃんと動かなそうだったので Ruby 3 化は諦めた。 kaminari-sinatra の ActionViewTemplateProxy#initialize を actionview v6 対応させないと無理っぽい。

kaminari のような有名な gem の派生 gem ならきちんとメンテされてるかなーと思っていたが、 kaminari-sinatra の最終コミットは 4 年前だった。

Ruby で View まで作る人たちはほとんどいなくなってるのだろう。

なお動かないところのデバッグは ChatGPT と対話しながらやった。めっちゃ便利。一人だと気がつかないような部分のコードを見てみろと ChatGPT が言ってくれて、そこにデバッグコードを入れてみるとビンゴだったりする。便利な世の中になった。

忘れないようにやったこと・気づいたことをメモっておく。

  • sinatra は v4 にあげないといけないのでパスの正規表現から ^$ は消さないといけない
  • better_errors の REPL がちゃんと動かないので Backtrace を見たいときはログを開くか better_errors を使うのをやめる
  • fork していた sinatra-cache は sinatra 4 では動かないので外した(キャッシュできない部分をどうするかは要検討)
  • capistrano-puma も Ruby 3 対応させないといけない(期待される systemd のフォーマットが変わっているので単にデプロイするだけではだめで一部手作業が必要)
  • tilt は v2.1.0 に固定( Tilt::ErubisTemplate クラスが消えるため、 padrino-helper がエラーを出す)
  • concurrent-ruby は 1.3.5 未満に固定
  • compass は 1.0.3 に固定
  • SESSION_SECRET は 64 文字以上にする
  • haml の過剰な escape を抑制
  • kaminari-sinatra の ActionViewTemplateProxy#initialize を actionview v6.1.7.8ActionView::Base#initialize に対応させる

| @雑談

天山食堂

しばらくブログを書いてなくてなんか執筆のエンジンがかからないのでただの日記でも。

運転免許の更新だった。今回、 10 年ぶりにゴールド免許に復帰できたので南区の試験場まで行かずに済んだ。最近、渡辺通のサンセルコから千代に移動してきた県の合同庁舎内にある千代ゴールド免許センターに行った。

千代県庁口駅は地下鉄空港線沿線ではなく、中洲川端で貝塚線に乗り換えないといけない。なので遠くはないがちょっと行くのが面倒だ。

駅に着いて合同庁舎まで 3, 4 分歩く。千代の街にはほとんど来ない。福岡市民は天神のアクロスでパスポートが取れるので、県庁に来ることも基本的にないんじゃないだろうか。

合同庁舎前に着くと懐かしい感じの名称が。以前博多駅の筑紫口にあって、勉強会でよく通っていた福岡県Ruby・コンテンツ産業振興センターがここに移動してきていた。免許の更新が目的ではあったがRubyコンテンツセンターの看板をパシャリと撮った。

福岡県Ruby・コンテンツ産業振興センター

ゴールド免許センターは二階だった。優良講習は事前予約制で、予約したときに表示される QR コードを保存して持ってきて端末にかざさないといけない。 QR コードをキャプチャして Fantastical に保存していたのに表示されなくて焦る。 Fantastical の iPhone アプリは添付ファイルを開けない仕様だったのを思い出した。 iOS 標準カレンダーは添付ファイルを開けたが、今度は電波のつながりが悪くてなかなか画像が表示されない。自分が端末を操作する直前になって画像が読み込まれ、 QR コードを無事機械に読み取らせることができた。高齢の人にはこれは難しいだろう。事実、年寄りの人には係員の人がずっと付き添っていて渋滞していた。

優良講習の内容はいつもの内容に加えて、最近は電動キックボードが制度として組み込まれたことと、自転車のヘルメット着用が努力義務化されたことを伝えられた。前の人の頭が邪魔であまり映像がよく見えず集中できなかった。

講習が終わって新しい免許証を受け取ってもまだ 10:20 くらいだった。仕事は午後からやろうと決めていた。渡辺通のゴールド免許センターだったら講習後に寄れる店がいくらでもあるけど、千代はなかなか店がない。朝は急いでいて朝食を抜いていたのでお腹がすいている。どこか入ろうと思ったが時間が中途半端すぎる。合同庁舎の横にスターバックスがあったので、そこに入ってとりあえず時間を潰せばよかったのに、火と人という西部ガスがやってそうなレストランが気になり駅に戻ったら、ランチは 11 時からでまだ食事はできなかった。いよいよ店に入るには中途半端な時間になった。せっかく来たのだからと店名が気になった天山食堂という食堂に行くことにした。 Google マップで写真を見ると実に店構えがいい。 15 分前に店に着いたのでコンビニ行ってコーヒーを買って時間を潰す。千代のローソンはすさんでいて、ゴミ箱は持ち込みゴミ禁止の警告がペタペタと貼られ、ゴミ箱の投入口は木の板を貼られて加工され、小さなゴミしか捨てられなくなっていた。監視カメラ撮影中と至る所に掲示があったが、どこにも監視カメラは設置されてなさそうだった。カフェラテを買って車止め柵にもたれかかった飲む。朝は紅茶しか飲んでなかったので初めてカロリーを摂取する。寒かったのでほっとする。

コーヒーを飲み終わると 11:00 になっていたので天山食堂へ移動する。自分の前に一人先客がいて二番目だった。場所柄、韓国風の店なのかと思っていたが、店内は普通の和食の店という感じ。メニューに韓国風ののもは豚キムチくらい。天山はきっと韓国の山の名前なのではないかと思っていたが、そうではないようだった。佐賀にゆかりがある人がやってる店なのかもしれない。

天山食堂のメニュー

Google マップや Instagram の投稿を見ると、焼肉定食を頼んでご飯をチャーハンに変更するのが通の頼み方のようだが、裏メニューでメニューに書いていないものを一見の客が頼むのは気恥ずかしく、普通に焼肉定食を頼んだ。比較的すぐ出てきた。付け合わせの野菜炒めがとてもうまかった。厨房でガコンガコンと中華鍋を振る音が聞こえたので本格的に炒めてあるのだと思う。ラードで強火を使い短時間で炒めてあり、野菜はシャキシャキしていた。肉は甘辛い味付けで、昔ながらの家で食べる焼肉という感じだった。小鉢の冷や奴が印象に残っている。豆腐の上にネギと鰹節がかかっていた。味噌汁もちゃんとだしをとったやつでうまかった。これで 800 円。最近は福岡も外食の値段が上がっているので、ほとんど手作りでこの値段は安い。 Google マップを見ると以前はもっと安かったみたいだ。

天山食堂の焼肉定食

分量もちょうど良く、食べ終えてあたたかいお茶を飲み、店を出た。ここから歩いて呉服町までも行ける。呉服町は以前のオフィスがあったところなので行ってみるのも一興だったが、午後の仕事が気になったのでおとなしく千代県庁口駅まで戻って地下鉄を乗り継いで会社へ向かった。孤独のグルメのような一日だった。

| @Mac/iPhone

iA Writer

iPad Air を買ったことで iPad で文章を書くときに何を使うか考え始めた。 iOS と iPad で iA Writer は同一アプリとなっているようで、 iPhone 用に買った iA Writer をそのまま iPad Air でも使うことができた。

iPad Air で iA Writer を使ってみて、ファイル管理っぽい機能があることに初めて気が付いた。 iOS では小さく表示されるのでそんな機能があることに気がつかなかった。それで Mac 版を試してみたところ結構良かったので、ブログなどの文章書きを Vim から iA Writer に乗り換えることにした。

iA Writer は iPhone では 6 年くらい前から使ってる。その頃は 1200 円くらいで買えたがいまはめっちゃ値上がりしてて 8000 円もする1( iPhone 用アプリで 8000 円ですよ!)。ドルベースの価格も上がっているが円安が効いていて高くなっている(ドルでの価格は 49.99 ドル)。

iA Writer for iOS は電車の中で文章を編集するのに使ってた。通勤してた頃、混んだ電車の中でパソコンを取り出せず、 iPhone の iA Writer で Vim で書き起こしたポエムの続きを書いたりしてた。

まぁまぁ便利だったのだが、やっぱりスマートフォンのキーボードでは書きづらいしまぁこんなもんかなぁと思いながら、どうしても iPhone で文章を編集したいときだけ起動するような使い方をしていた。

iPad Air で iA Writer を使ってみて、ファイル管理っぽい機能があることに初めて気が付いた。

iA Writer のファイル管理機能

スマートフォルダという機能もあって、バラバラに散らばったファイルをいい感じに整理できることにも気が付いた。 Apple Music のスマートプレイリストみたいな機能だ。 macOS の Finder の機能を転用してるのだと思う。

iA Writer のスマートフォルダ

"メモを取り、書き、編集するための集中できる環境。それ以外には何もありません。"

iA Writer のウェブサイトではフルスクリーン表示できて文章を書くことに集中できることをウリにしているが、自分にとってはファイルを一覧表示・管理できる機能(ファイラー)がめっちゃ便利だと思った。これまで Vim + memolist で書いた文章は一定期間経過したら Day One に取り込みつつ、年ごとアーカイブ用フォルダーを作って Ruby スクリプトでそこに待避していたが、そんなことをする必要なく iA Writer だけでファイルの管理ができてしまう(サブディレクトリに移動させたいファイルをドラッグ&ドロップで移動させればよい)。

Day One のカレンダーで過去の記事を見られる機能は便利だが、Day One はプレーンテキストを捨てて Markdown ベースの独自仕様リッチテキストに移行してしまったし、起動に時間がかかるのも微妙だと思っていて、シンプルにプレーンテキストのファイルとして管理できる iA Writer の方がよい気がしてきた。

Day One のカレンダー UI

iA Writer の良いところを列挙するとこんな感じ。

  1. 起動が速い
    起動してすぐ書き始められる
  2. ファイル管理機能
    Finder とテキストエディターが統合されたような使い勝手
  3. クイック検索
    Vim の Unite 検索のような機能があり、めっちゃ素早く対象のディレクトリ以下のファイルを検索できる

これまで Vim を使い続けてきたのは Unite での検索が便利すぎたからだった。メモ書きディレクトリに移動してテキトーに Vim を起動して UniteGrep すれば書きかけのファイルをぱっと探せる。

iA Writer には UniteGrep と似たクイック検索という機能があって、しかもショートカットで検索 UI を呼び出せるところが良い( + + o)。

iA Writer のクイック検索

Day One にも似たような検索機能はあるものの、検索窓を呼び出すショートカットがない。いちいちマウスカーソルを動かして🔍マークをタップしないと検索できない。検索窓はマウスやトラックパッドに手を持ち替えることなく表示されないとダメだと思う(一つ前の記事参照

デバイス間のファイル同期は iCloud を使っている。複数の端末で同時に開いてもコンフリクトが起こることはほぼなくファイルが同期される。敬虔な Apple 信者でいる限りファイルの同期のために Evernote や Notion を使う必要はない。

いまは Obsidian や Roam Research のような新手のソフトもあるようだが、自分の場合は iA Writer で満足している。使っていないが Wiki Link 記法も使えるのでファイル同士の繋がりを作ることもできるようだ。

職業プログラマーになって以来、テキストエディターは Vim 一択だろうという気持ちでいたが、 GUI でファイル検索・管理できるという一点で iA Writer を気に入ってしまった。コードを書くならいまでも Vim が良いが、ブログの文章や個人用メモは完全に iA Writer に乗り換えてしまった。世間的に大人気な Notion を使っていたらこのようにエディターを他のものに乗り換えることはできなかったのでプレーンテキスト万歳だ。


ピアソン版の『達人プログラマー』に GUI のファイル検索だと目的のファイルが見つかるのに時間がかかるので grepfind でファイルを探す方が高速だ、という記事がある(第3章)。 Unix 哲学的な話で、小さな機能を持つソフトを組み合わせて使う方が効率的だという話だった。 grepfind で検索して xargs -o vim するより、テキストエディターの中で一覧表示出来る方が便利だ。少なくとも普通の人にとっては。何でもシェルでやるのが良いという時代は終わったのかもしれない。

とはいえ iA Writer は macOS の仕組みの上で達人プログラマー的なやり方を継承しているようにも感じる。例えば先ほどのスマートフォルダは macOS の Finder にある機能だ。ひょっとしたら UI が同じだけでまるっきり別の実装がしてあるかもしれないが、 OS や SDK が提供しているシンプルな GUI を組み合わせて便利なものを作るのが今風の達人プログラマーなのかもしれない。


  1. Mac 版は同一ライセンスになっておらず Mac App Store から別途購入する必要がある。 8000 円は高いので一か月くらい悩んで買った。 

| @技術/プログラミング

OGP 読み込み君を作ってよそのサイト(自分のブログの過去記事含む)の OGP をいい感じに iframe で表示していた。

しかし最近この機能がスパマーに悪用されているようだ。自分がこのブログ内で言及した覚えのない URL へのアクセスが一杯あり、そのせいでめちゃくちゃにサイトの負荷が高くなっているようだった。 OGP 読み込み君はキャッシュする機能はあるが、基本的に Ruby でよそのサイトに HTTP リクエストを投げるので悪用されると負荷が高くなってしまう。

Nginx で悪用されたくないパスへのリクエストには Referer 制限をするようにしたのでこれで負荷が下がってほしい。

追記

ロードアベレージが平均 3 、高いときは 20 くらいになってたのが 0.1 くらいに戻った。

海外のスパマー、本当に色々酷いことやる。日本語読めないのに OGP 読み込み君の仕様を突き止めて悪用してる。多分、こんなことしても利益は 1 円もないのに何がしたいんだろう。他人にダメージを与えられればそれでいいんだろうか?

| @ブログ

普段は Vim などのテキストエディターで文章を書いていて、ブログの投稿画面にはできあがった内容をコピペするだけだったので、投稿画面の使いやすさやは気にしたことがなかった。画像のドラッグ・アンド・ドロップ・アップロードや、オン・ザ・フライでプレビューをできるようにしたが、テキスト自体の書きやすさを改善しようとはしてこなかった。

大筋はテキストエディターで書いたとしても、最後に推敲したり、推敲していて気が付いたおかしなところの修正は投稿画面で行うことが多い。やっぱり投稿画面の書きやすさは重要だ。特に長大な内容を編集しているときにテキストエリアが狭いととても使いづらい。ある程度大きさがあり、画面内に大量の文字が表示されるテキストエリアが好みだ。

Lokka が開発されていた 10 年以上前は解像度の小さいディスプレイが主流で、 Lokka の管理画面は小さいサイズのウィンドウで閲覧することを想定したテキストエリアのサイズになっている。今日の高解像度ディスプレイで見ると不便なので画面一杯にテキストエリアが広がるようにした。

投稿画面のレイアウトを改善

iPad からも投稿しやすいように投稿画面のレイアウトも修正した。本文のテキストエリアが広がったためスクロールしないと「スラッグ」以降の入力欄にアクセスできなくなったので、ある程度横幅のあるウィンドウのときにはこれらを右側に配置するようにした。

加えて、フォームを書きかけで保存したかどうかがはっきりせず、未保存の内容があるのに保存せずページから離脱してしまうことがあったので、変更点があるときは背景色を変えてわかるようにした。これにより記入内容を保存せずページを離脱してしまい、内容が失われるという悲劇を回避できるようになった。やり方は適当に検索して出てきた Stack Overflow を参考に、ページを読み込んだ時点で JavaScript でフィールド内の要素を JSON.stringify して data attribute に格納し、その後各フィールドの input イベントを監視して変更があるかどうかをチェックしている。

class FormObserver {
  constructor() {
    this.initializeFields();
    this.observeFieldsChange();
  }

  initializeFields() {
    const fields = Array.from(document.querySelectorAll('div.field'));
    for (const field of fields) {
      const inputElement = field.querySelector('input[type="text"], textarea, input[type="datetime-local"], select option:checked');
      if (inputElement === null) {
        continue;
      }
      field.dataset.serialize = JSON.stringify(inputElement.value);
    }
  }

  observeFieldsChange() {
    const fields = Array.from(document.querySelectorAll('div.field'));
    for (const field of fields) {
      const inputElement = field.querySelector('input[type="text"], textarea, input[type="datetime-local"], select');
      if (inputElement === null) {
        continue;
      }
      inputElement.addEventListener('input', () => {
        if (field.dataset.serialize != JSON.stringify(inputElement.value)) {
          inputElement.dataset.changed = 'true';
          field.classList.add('edited');
        } else {
          inputElement.dataset.changed = 'false';
          field.classList.remove('edited');
        }
      })
    }
  }
}

input イベントを監視すると動作がもっさりするのではないかと心配したが、そんなことはないようだ。普通に使えている。

いまのところ管理画面の HTML レンダリングはサーバーサイドで Ruby で行っているが、これ以上凝ったことをやるなら React などを使って JavaScript で HTML を組み立てる方式にしていく方が効率が良さそうだ。

| @技術/プログラミング

偶発的に puma のバージョンを上げたところ Encoding::CompatibilityError: incompatible character encodings: UTF-8 and ISO-8859-1 が多発して厳しい感じになった。

このブログでは puma は v4 系を使っていたが、調べると最近 v6 もリリースされたようで v5 系に上げてみることにした。すると忘れていたのだが puma は v5 系から daemonize する機能が削除され、デーモン化は systemd を使うべしということになっていた。プロセスのデーモン化は puma にやってもらわないと capistrano で deploy するときに面倒なので以前は v5 に上げるのを諦めて v4 を維持していたのだった。

capistrano3-puma が systemd に対応していたのでえいやっと puma を v5 に上げて deploy してみたところ、冒頭の Encoding::CompatibilityError: incompatible character encodings: UTF-8 and ISO-8859-1 が多発してページが全く表示されなくなってしまった。

一方で管理画面やアーカイブページは表示に問題がなかった。どうもファイルの読み込みが発生するページ(このブログではキャッシュを多用していて、ファイルに書き出したキャッシュを読み込んでいる)でエラーが発生しているようだった。

自分で fork した sinatra-cache.gem でファイル読み込みする部分で encoding オプションを指定してみたりしたが問題が直らない。 Haml や Sinatra のバージョンも古いのでこれらも上げてみようかと試みたが、そうするとより盛大にエラーが出てしまう( Haml を v6 にすると html_safe している出力もさらにエスケープされて HTML がぶっ壊れる)。

気になるのはローカル環境( Mac )ではこのエラーが発生しないこと。「これは環境起因では?」と思い至ってガチャガチャやってみたところ修正することができた。

Lokka では Encoding.default_external を参照しつつ String#force_encoding しているところがある。「ひょっとして Encoding.default_external の値がローカルとサーバーで異なるのでは?」試してみたところ、ローカルでは #<Encoding:UTF-8> となる Encoding.default_external の結果が、サーバーでは #<Encoding:ISO-8859-1> となっていた。

以下のブログを参考に、環境変数 RUBYOPT でエンコーディングを指定して puma を動かすことでエラーを回避できた。

systemd 経由で puma を動かすときに環境変数を設定するのは結構難しい。最初は puma が RACK_ENV=production で動かず困ったが、 systemd 用の設定ファイルで EnvironmentFile のパスを指定し、環境変数用のファイルの中で各種環境変数を定義してやる必要があった。こんな感じ。

systemd の設定ファイル

[Unit]
Description=Puma HTTP Server for portalshit (production)
After=network.target

[Service]
Type=simple

WorkingDirectory=/var/www/deploys/portalshit/current
# Support older bundler versions where file descriptors weren't kept
# See https://github.com/rubygems/rubygems/issues/3254
EnvironmentFile=/var/www/app/.config/systemd/user/portalshit_env
ExecStart=/var/www/app/.rbenv/bin/rbenv exec bundle exec --keep-file-descriptors puma -C /var/www/app/portalshit/config/puma.rb
ExecReload=/bin/kill -USR1 $MAINPID
StandardOutput=append:/var/www/deploys/portalshit/shared/log/puma_access.log
StandardError=append:/var/www/deploys/portalshit/shared/log/puma_error.log

Restart=always
RestartSec=1

SyslogIdentifier=puma

[Install]
WantedBy=default.target

環境変数の定義ファイル

RACK_ENV=production
RUBYOPT=-EUTF-8

puma v5 に移行しようとしている方の参考になれば幸いです。