| @ブログ

普段は 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 に移行しようとしている方の参考になれば幸いです。

| @Mac/iPhone

プログラミングをほとんどやらなくなったので iPad Air でよいだろうと思って買ってみたところ、結構使いづらい。

普段 US キーボードを使っているので何の気なしに US 配列の Smart Keyboard Folio ( MU8G2LL/A )を選んだが、日本語と英語の切り替えがハチャメチャにやりづらい。 macOS 標準の日本語入力も ATOK も Ctrl + Shift + j で日本語に、 Ctrl + Shift + ' で英語に切り替えるデフォルトショートカットキーが割り当てられているので、このショートカットで日本語・英語を切り替えていた。 iPad でもこのショートカットで切り替えたいが残念ながら使えない。 JIS キーボードであればおなじみの 英数かな キーがあるのでより簡単に日本語・英語を切り替えられる。

Smart Keyboard Folio のキーピッチの狭さも厳しい。 Apple キーボードぐらいの使い心地を想像していたが、各種キーが小さくなっておりブラインドタッチできない。ふむーという感じ。

Apple のサイトを見ると iPad Pro の 12.9 インチ版はフルキーピッチになってるようなので、プログラミングをしない場合であってもキーボードを使ってそれなりに文章を書くつもりなのであれば iPad Pro 大を選ぶべきであった。

しかし iPad Pro 大と Magic Keyboard の組み合わせとなると ¥172,800 + ¥53,800 = ¥226,600 となり、軽く MacBook Air くらい買えてしまう値段になる。重さ的にも MacBook Air の方が有利そうだ。

iPad Air であれば、コンテンツの消費をメインにしつつ、ある程度はブログ書きもこなせて自分のニーズにマッチするかと期待していたが、正直なところ微妙なようだった。

| @Mac/iPhone

iPad Air (第 5 世代)を買った。

10 年くらい前に第 3 世代の iPad を持っていたが、当時はあまりしっくりこなくてほどなくして使わなくなってしまった。当時の自分のなかの結論としては iPad は様々なアプリからピロピロ通知が来て気になるし、プログラミングできないし、キーボードがないからブログ書きにも使えないし自分にはノートパソコンの方が向いているというのものだった。

しかしプログラミングは卒業してしまったし、今は Apple からカバーと一体となったキーボードも発売されていて、ブログ書きにも使えそうな雰囲気になってきた。 Apple Pencil にも興味がある。そして何より一番気になっている点は iPad 版 Kindle アプリの出来だ。

iOS の Kindle アプリは比較的よくできているのに Mac の Kindle アプリは本当に出来が良くない。クラッシュしまくるし、線を引こうとすると余計なところに引かれてしまうし、使っていてストレスしか感じない。 App Store でのレビューを見ると iPad OS 版の Kindle アプリの評価は悪くないようなので、リストカット感覚で iPad Air を買って iPad 版の Kindle アプリを試してみることにした。

この記事は早速 iPad Air から書いているが、変換に慣れている ATOK を使えないこと以外は特に問題がない。キーボードは一世代前の Smart Keyboard Folio の US 配列( MU8G2LL/A )をフリマサイトで安く購入した。 iPad Air はカメラが一つしかないので現行の iPad Pro 向けに作られた穴が大きい Smart Keyboard Folio よりも一世代前のバージョンの方が向いている。ちゃんと使えるかは reddit で調べて確認した。

これで寝床でゴロゴロしながらブログを書けるようになったので、これまで以上に駄文を濫造していけそうだ。

| @登山/ランニング

阿蘇草原マラニックで走る著者近影

阿蘇草原マラニックというトレランのイベントに参加した。ここ一年間は比較的真面目に走ってるので(靴を一足履き潰した)、山でも走れるのではないかと思って申し込んでみた。ただレースだときつそうなので順位を争わないマラニックというピクニックとマラソンを足し合わせたようなやつを選んだ。

感想としては一人で参加したのにめっちゃ楽しかった。ほとんどの人がグループで来てたのでボッチだとさみしいとは思いはしたが、普段走れない絶景の場所(外輪山の牧野は普段立ち入り禁止)を知らない人たちとはいえ誰かと一緒に走るのは楽しかった。イベント運営の皆さんが醸し出す雰囲気もピースフルでとても良かった。

とはいえコースは結構ハードで、公式の情報で距離 28km で累積獲得標高は 1000m とある通り( Apple Watch のワークアウトログだと距離 29.8km で累積獲得標高は 1143m だった)、普段からそれなりに走っていて、久住くらいなら楽に登れるという人でないと制限時間内にゴールは厳しいと思う。はな阿蘇美からスタートして国造神社で折り返すコースだったが、国造神社のエイドステーション出発の制限時間が2時間50分で、上り500mある14kmの距離を3時間弱で到達するのは歩きでは間に合わず、平地や下りはちゃんと走らないといけない。外輪山は平らなので走りやすいが、阿蘇谷から外輪山に出るまでの登りが結構きつい。もし来年出ようという方はそれなりのトレーニングが必要だと思います。

今回自分は往路は2時間20分、復路は2時間15分(国造神社のエイドステーションで25分くらい休憩)かかってたようだ。往路の最後で足がつり、復路はあまり走れなかった。外輪山に上がったあとはぼちぼちジョギングみたいなペースで走っていたが、外輪山を降りきって樹林帯に入ったところからまた足がつり、最後の 3km くらいは走っては休みを繰り返しながらゴールした。これまで最長でも 10km しか走ったことなく明らかに準備不足だった。前週に叶岳周回 9km を走って足つりとか疲労とかなかったので 20km くらいまでなら余裕だろうと思っていたが折り返し地点手前で足がつったので普段からもうちょい長い距離を走らないとダメそうだ。

トレランは袖なしシャツとかサンバイザーとかハイソックスとかギラギラしたサングラスとかのイメージがあって苦手だったけど、最近はサンバイザーとギラギラサングラスの人は減っていて(袖なしシャツとハイソックスの人はいる)おしゃれな人が多くて華やかだった(女性も多い)。ピークを目指さずトレイルを走るのが目的なのも新鮮で良かった。

参加費は行政の助成を受けているようでわずか1000円で、エイドステーションおよびゴール後の振る舞いや参加賞の温泉入浴券がもらえることを考えるとはちゃめちゃに安い。来年は価格が上がるだろうけど数千円なら全然払う価値あるなと思えるイベントだった。何より阿蘇の外輪山の牧野を走れるのはとても貴重。もし来年も開催されるのなら是非また参加したい。

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

MySQL だけでお手軽に全文検索ができるということを知らなかった。 MySQL 5.6 から入っていたようだった。 Tantivy および Tantiny を使ったやり方を以前記事に書いてサイトで実装しているが、 MeCab によるトークナイズでは二文字の熟語がセットになって四文字になっているようなパターンを取り逃すことがあった(「関連記事」は「関連」と「記事」に分割され、「関連」や「記事」というキーワードで検索したときにはヒットするが「関連記事」で検索するとヒットしない)し、記事追加時の検索インデックス更新処理が不要( MySQL にレコードが追加されたときに勝手に更新される)なので試してみることにした。

やり方は以下の記事を参考にした。

最初にデータベースに全文検索用のインデックスを作成した。

ALTER TABLE `entries` ADD FULLTEXT INDEX index_entry_fulltext(title, body) WITH PARSER ngram;

その後、検索部分のコードを書き換えて以下のようにした。

class Entry < ActiveRecord::Base
  scope :search,
        ->(words) {
          return all if words.blank?
          where('MATCH (entries.title, entries.body) AGAINST (? in BOOLEAN MODE)', words)
        }
end

めっちゃ簡単。

このブログは記事数が 1500 記事くらいなのでぶっちゃけ LIKE 検索でも実用的な速度( 100msec 以内)で結果を取得できるが、 FULLTEXT インデックスを使うと 10msec 程度で結果を取得できる。

ただし Tantivy と比べて劣る点もあって以下は注意が必要。

  1. なぜかわからないが Vim で検索すると何もヒットしない。また Rails で検索すると Rails について触れていない記事もヒットする。 ngram によるインデックスというのはこんなものなのかもしれない。検索ワードが日本語のときはいい感じに結果が表示される。
  2. 複数のテーブルにまたがるデータを一個の検索インデックスにまとめることができない。例えば Tantivy のインデックスは記事のタイトル、本文、カテゴリー、タグをインデックス対象としているが、 MySQL の FULLTEXT インデックスだとテーブルごとにしかインデックスを作れないので(当たり前)、複数のテーブルにまたがる検索をするときにはテーブルを JOIN するしかない。 OR マッパーを使っている場合には利用しづらい。

1 の問題に関しては、 MySQL 5.7 からインデックス生成時の PARSER に MeCab などを指定できるようになったのでそうすると回避できるかもしれない。ただし MeCab のインストールや設定を行う必要があるので要注意。

2 の問題に関しては全文検索システムを入れた方が良さげ。 Tantivy であれば非常に簡単に導入できる。

現状、このサイトでは右上の検索窓から検索したときのインクリメンタルサーチとアーカイブページでの絞り込みは Tantivy を、インクリメンタルサーチの結果で必要な情報が得られなかったときの「全文検索する」と 404 Not Found ページの検索は MySQL の全文検索を使うようにしている。

二つの検索

| @ブログ

404 ページ、昔はそもそもなくて 404 Not Found ステータスを返すだけだったり、あっても「見つかりません」というだけのものが多かったけど、最近はサイトマップ的なコンテンツや代替となるコンテンツを表示するサイトも見かける。というわけでこのサイトでもやってみることにした。

このブログの URL は /YYYY/MM/DD/slug という形式になっている。パスの /YYYY/MM/DD の部分はお飾りで、実際は slug がユニークになっているので slug で表示すべき記事を判定している。

よくあるのが記事を公開後、 slug 部分にタイポを見つけて変更するというケース。しかしすでにその時点で記事が Twitter などでバズってたりすると、 Twitter で共有されている記事を見てやってきた人が 404 Not Found ページを見ることになる(この前の「不便になるインターネット」がまさにそうだった)。それはまずいので slug のタイポを修正すると同時に Nginx の設定ファイルをいじってタイポ修正前の URL から修正後の URL へリダイレクトするようにしていた。しかしリダイレクトごときでサーバーの設定ファイルを修正して root 権限でリロードするというのはめんどい。 SSH でログインもしなければならない。大げさすぎる。

というわけで思いついたのがこの機能で、 Ruby でクラス名やメソッド名をタイポしたときに正しい候補を表示する did_you_mean.gem を利用した。存在しない slug で URL を開くと以下のように候補が表示される。

404 Not Found

コードはこんな感じ。

# Helper
def not_found_candidates
  @not_found_candidates ||=
    begin
      slugs = Entry.published.where.not('slug REGEXP ?', '^[0-9]+$').pluck(:slug)
      spell_checker = DidYouMean::SpellChecker.new(dictionary: slugs)
      current_slug = request.path_info.split('/').last
      slug_candidate = spell_checker.correct(current_slug)
      Entry.published.where(slug: slug_candidate)
    end
end

# View
- if not_found_candidates.any?
    %p Did you mean?
    - not_found_candidates.each do |candidate|
      = link_to candidate.title, candidate.link

データベースから slug 一覧を取り出して辞書とし、 DidYouMean::SpellChecker に食わせて似たページの候補を取得して表示する。タイポありのページを訪れた人はワンクリックしなければならないという手間が増えるが、これでタイポを修正したときに面倒なリダイレクトの設定をする必要がなくなった。

なお 404 ページには検索窓や最近の記事、カテゴリー一覧も表示して回遊性を高めている。

404 Not Found ページ