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

ヒトデさんの以下のツイートを目にして便利そうだと思ったので fish + peco + vim でやってみることにした。

以下のような fish 関数を追加した上でショートカットキーを bind しておいた。

function peco_gitlsfiles_vim
  git ls-files | peco --query "$LBUFFER" | read selected
  if [ $selected ]
    vim $selected
  end
end
function fish_user_key_bindings
  fish_vi_key_bindings
  bind \cg\cv peco_gitlsfiles_vim
  bind -M insert \cg\cv peco_gitlsfiles_vim
end

これまで一旦 vim を閉じてしまうとファイルを開きたいときには vim . して Unite で調べててたけど、いきなり git ls-files して peco して絞り込めるようになってとても便利になった。

2018-06-20 16.48.40.gif

| @WWW

| @ブログ

Facebook にウェブサイトの URL をはっつけるとき参照される HTML メタ情報の仕組みに Open Graph Protocol ってのがある。 Facebook に URL を貼ると bot が URL の内容を読みに行ってページの概要や画像を取得し Facebook 内に埋め込み表示するというもの。 Facebook を見ている人はリンク先の内容をクリックする前に概要を把握できるので、リンクをクリックして見たい情報じゃなかった、ということを避けられる。 Facebook が考案して策定した仕組みだけど、 Facebook に限らずいろんなサイトで OGP タグを出力してるし読み込んでる。 Twitter にも似た仕組みあって Twitter Card という。この辺の対応は結構前にやってた。

ただ自分のサイトが OGP タグを提供するだけではつまんないなと思ったので自分のブログにペロッと URL を貼ったときに相手先に OGP タグがあればそれを出力するようにしてみた。こんな感じ。

OGP Preview

しかしここで困ったことがあって、↑でリンクしてる Circle のサイトは HTTPS で配信されておらず、単純に Circle のサイトで og:image に指定されている画像を SSL 化されているこのブログで読み込むと Mixed Content になってしまう。せっかく HTTP/2 で配信していたのに台なしになってしまう。またそもそも og:image は Facebook でシャアされることを想定されていることがほとんどなので、画像サイズがデカすぎていい感じにスクエアに表示するためには CSS の小技を駆使したりする必要があった。

いい感じに解決する方法ないかなと調べていたら良いのが見つかった。

Go で書かれた Image Proxy Server で、 HTTPS Proxy は当然のこと動的リサイズもできる。使い方は簡単でバイナリを落としてきて動かすだけ。 Go なんで ImageMagick をどうしたりとかを考えなくて良い。 そもそも Docker イメージも提供されているので Docker をインストール済みなら docker run するだけでも動く。 めっちゃお手軽。

こいつのおかげで HTTPS で配信されていないサイトの OGP タグを読み込んでも Mixed Content にならずに済むようになった。また og:image は適切にリサイズできるようになった。画像変換サーバーとかは結構難しいやつで個人のブログでこんなに簡単に使えるものだとは思ってなかったので正直ビックリした。

AWS の登場で大企業じゃなくても CDN 使ったり仮想サーバーでウェブシステムを構築したりできるようになった。さらには Go や Docker といった技術のおかげで複数の込み入ったソフトウェアを組み合わせて構築していく必要があったシステムが、まるで jQuery を使うような感覚でポン付けで使える時代になってきている。とても素晴らしい。

ちなみに OGP の取得には open_graph_reader という gem を使っている(昔からある opengraph という gem はメンテナンスされておらず最近の Nokogiri で動かない)。 open_graph_reader の作者が結構 Opinionated な人で、以下のような Anti-featurs を掲げている。

open_graph_reader Anti-features

http://ogp.me/ の仕様に準拠していないサイトのことは完全無視というつくり。個人的にはこういう思想は好みだが、現実問題として使い勝手が悪い。例えば hitode909 さんのブログの OGP タグを取得しようとしたところ以下のようなエラーを出して取ってくれなかった。

スクリーンショット 2018-05-26 10.08.47.png

article:published_time は ISO8601 形式の datetime であるべき、とのこと。はてなブログはかなりシェアが大きくリンクする機会が多いので残念。

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

A summer storm

問題点

Rails でデファクトスタンダードとなっているページネーション gem に Kaminari というのがある。

めっちゃ最高便利で大好きなのだけど、巨大なテーブルに対して COUNT 文を投げると遅いという問題にぶち当たった。このような巨大なテーブルで Kaminari を使うために COUNT 文を発行しない without_count というメソッドが用意されている( Kaminari 1.0.0 でやってくる 5 つの大きな変更 - Qiita )が、これを使うと next_pageprev_pagetotal_pages が取れなくなる(当たり前)。次のページがあるかどうかはばくち状態になってしまう。

本当は DB のスキーマを見直すべき(インデックスがちゃんと効くようにスキーマ変更するべき)だが、 Rails からもレガシーアプリからも同時に同じ DB にアクセスしており、並行運用しているような状況ではなかなか大胆な変更は実行できない。

DB 構造をなおせないとなるとキャッシュを思いつく。 HTML も Rails でレンダリングするのであれば partial cache などでページャー部分だけをキャッシュすれば良いが、 API 選任野郎と化した Rails ではビューのキャッシュはできない。

どうしたか

total_count をキャッシュする。公開範囲を設定できるようなリソースだと全員同一のキーでキャッシュするわけにはいかないのでユーザーごとにキーを作ってキャッシュする必要あり。全ユーザーの全リクエストでスロークエリになってたやつが 5 分に一回スロークエリになるくらいだったら何とか許容できる。

例えば以下のようなコントローラーがあったとする。 HeavyModel には数千万レコードあって、普通に COUNT 文を投げると遅い。 Paginatable という名前でモジュールを定義して、 render メソッドを上書きし、ページネーションを間に挟み込む。

class HeavyModelController < ApplicationController
  include Paginatable

  before_action :login_required

  def index
    resources = HeavyModel.all
    render json: resources, paginate: true
  end
end

モジュールはこんな感じ。車輪の再発明をしている感はあるが、 COUNT 文の結果が current_user 、コントローラーのクラス名、アクション名のそれぞれをつなげたものをキーにしてキャッシュされる。

module Paginatable
  def render(*args)
    options = args.extract_options!
    resources = options[:json]
    if options[:paginate]
      resources, meta = paginate(resources, cache_total_count: options[:cache_total_count])
      options[:json] = resources
      options[:meta] ||= {}
      options[:meta].merge!(meta)
    end
    args << options
    super(*args)
  end

  def paginate(resources, options = {})
    parse_params_for_pagination
    paginator = Paginator.new(
      resources:         resources,
      page:              @page,
      per:               @per,
      cache_total_count: options[:cache_total_count],
      cache_key:         total_count_cache_key
    )
    [paginator.resources, paginator.meta]
  end

  def total_count_cache_key
    @total_count_cache_key ||= "#{current_user&.id}_#{self.class.name}_#{action_name}_count"
  end

  class Paginator
    attr_reader :cache_total_count

    UNCOUNTABLE = -1

    def initialize(resources:, page:, per:, cache_total_count: false, cache_key: nil)
      @_resources        = resources
      @page              = page.to_i
      @per               = per.to_i
      @cache_total_count = cache_total_count
      @cache_key         = cache_key
    end

    def resources
      @resources ||= if cache_total_count?
                       @_resources.page(page).per(per).without_count
                     else
                       @_resources.page(page).per(per)
                     end
    end

    def page
      @page.zero? ? 1 : @page
    end

    def per
      @per.zero? ? Kaminari.config.default_per_page : @per
    end

    def meta
      {
        current_page: current_page,
        next_page:    next_page,
        prev_page:    prev_page,
        total_pages:  total_pages,
        total_count:  total_count
      }
    end

    alias cache_total_count? cache_total_count

    def paginatable?
      !cache_total_count? && resources.respond_to?(:total_count)
    end

    def current_page
      resources.current_page || page
    end

    def next_page
      paginatable? ? (resources.next_page || UNCOUNTABLE) : next_page_fallback
    end

    def next_page_fallback
      return UNCOUNTABLE if page < 1
      return UNCOUNTABLE if per > resources.length
      total_count_fallback > current_page * per ? current_page + 1 : UNCOUNTABLE
    end

    def prev_page
      paginatable? ? (resources.prev_page || UNCOUNTABLE) : prev_page_fallback
    end

    def prev_page_fallback
      return UNCOUNTABLE if page < 2
      (total_count_fallback.to_f / per).ceil >= page ? current_page - 1 : UNCOUNTABLE
    end

    def total_pages
      paginatable? ? resources.total_pages : (total_count.to_f / per).ceil
    end

    def total_count
      paginatable? ? resources.total_count : total_count_fallback
    end

    def total_count_fallback
      @total_count_fallback ||=
        begin
          cached_total_count = Rails.cache.read(@cache_key)
          if cached_total_count
            cached_total_count
          else
            real_total_count = @_resources.page(page).total_count
            Rails.cache.write(@cache_key, real_total_count, expires_in: 5.minutes)
            real_total_count
          end
        end
    end
  end
end

これで 5 分間はキャッシュが効くようになる。

| @WWW

先日書いた Day One のバックエンドで障害 - portal shit! について、 Day One のヘルプページで詳細を説明する記事が掲載されていました。

ウェブアプリケーションエンジニアの皆さんが読むと参考になるのではないかと思い、翻訳の可否をたずね許可をもらったので翻訳します。


2018 年 5 月の Day One 障害報告

執筆者: Paul Mayne (訳注:Day One の創業者)
今週更新(訳注:障害復旧日の午後に公開されました)

2018 年の 5 月 7 日から 10 日にかけて、 Day One は重大な Sync サービスの停止に陥りました。ユーザーの皆さんから堅牢な Sync サービスを期待されていることはわかっていますし、この出来事は我々の基準を満たすものでもありません。何が起こったのか、そして将来にわたってどのようにこの問題を回避していくかをユーザーの皆さんにお伝えしたいと思います。

簡単なまとめ

5 月 7 日にハードウェア障害が発生し、最初の Sync サービスの停止が発生しました。バックアップデータが不完全だったことが原因で、 5 月 8 日の Sync サービスの復旧処理中、一部のユーザーのアカウント ID が既存のユーザーのものと重複してしまいました。結果、一部の新規登録ユーザーは他人の記事を見ることができる状態となっていました。問題に気がついてすぐに Sync サービスを再び停止させました。対象のユーザーは 106 人で、これは我々のユーザー全体の 0.01% よりも少ない値です。

現在、 Sync サービスは正常に復旧しており、記事が他人に見られることはありません。近日中(訳註:すでにリリース済みです)に意図せず共有されてしまった記事を削除するアップデートを提供します。

End-to-end の暗号化を施していた記事は今回の事故の対象外です。

いかなる偶発的な個人情報の漏出も信用を毀損するものであると我々は認識しています。この不幸な状況にあって我々は、正直に状況を説明することが最善だと思っていますし、また信頼を回復するために全力を尽くしています。今回、個人情報の流出被害にあった 106 人のユーザーには終身の Premium メンバーシップを無償提供し、それぞれ個別に連絡を取っています。自分たちにできるあらゆることを行ってみなさんの信頼を回復していきたいと思っています。

障害の詳細

5 月 7 日月曜日、 Day One の社員が DB サーバーでハードウェア障害が発生していることに気がつきました。問題のあるサーバーをデータベースクラスターから取り除く作業とデータを残りのサーバーに分散する作業を始めました。

データの分散処理に失敗します。これが最初の障害です。すぐに Sync サービスを復旧させたかったため、新しいデータベースクラスターを作成して最新のバックアップデータを読み込むことにしました。新しいクラスターがセットアップされ、バックアップが読み込まれました。

5 月 8 日火曜日の早い時間に復旧処理が完了し、 Sync サービスを再開しました。当初は順調に動いているように見えました。しかし数時間で「自分の記事ではない記事が見える」という問い合わせが何人かのユーザーからありました。これは由々しき事態であり、我々はすぐに Sync サービスを再停止し、これ以上被害が拡大しないようにしました。

この時点で、問題と対応方法の調査を行う間、 Day One Sync を無期限に停止する旨をソーシャルメディアに投稿しました。何が原因なのか、何が起こっているのか、そしてもう問題が起こらないと確信できるまで Sync サービスは再開できませんした。

5 月 9 日水曜日の午前、問題の根本原因を突き止めました。復旧処理に用いたバックアップデータが不完全だったのです。記事データは完全なものでしたが、ユーザーアカウントデータに欠落がありました。具体的には、 3 月 22 日よりも後に作られたユーザーアカウントデータが含まれていなかったのです。その結果これらのユーザーはログインすることができていませんでしたし、特定の記事データが意図せず他人から見えてしまうという問題につながりました。

それぞれの記事データベースは “accountID” というフィールドを持ち、どのアカウントがその記事の所有者であるかを判断しています。全ての記事データは正しく復旧されましたが、ユーザーアカウントデータはそうではなかったため、データベースに所有者が存在しない記事ができてしまいました(例えば “My Travel Journal” という記事は 123456 というアカウントのものだったとしましょう。しかしそのアカウントが存在しなくなってしまったということです)。新しいユーザーアカウントの ID は連番で作られます。復旧されたデータは最新のユーザー情報を含んでいなかったため、 5 月 8 日に新規登録したユーザーは本来よりも小さな値の ID で登録され、既存のユーザーアカウントと重複することになったのです。その結果、これらの新規ユーザーはすでに存在する他人が書いた記事を読めるようになってしまったのです。

5 月 8 日の問題が発生していた期間のうちに、 326 アカウントが正しくない account ID で作成されました。その 326 個の ID のうち、 106 個が別人によって書かれた既存の記事データに結びついていました。 2018 年の 3 月 22 日から 23 日に作成されたアカウントは他人から記事が見られる状態になっていたということです。それらは account ID 1104506 から 1104831 の人たちでした。

現在のところそれらの記事のうちどれくらいが end-to-end の暗号化処理をされていたかはわかっていませんが、 end-to-end の暗号化をしていた記事は今回の問題でも他人に見られることはありませんした。

水曜日の調査の後、元のデータベースでデータの分散処理に失敗する問題を解決することが最善の選択肢だと判断しました。いくつか設定ミスがあり、データベースの負荷が高まる原因になっていることがわかりました。この負荷が原因でデータの分散処理が失敗していました。

水曜日の午後、この問題を修正し、元のデータベースクラスターで分散処理が正しく完了することを確認しました。しかしエンジニアチームとサポートスタッフが万全の態勢で臨めるよう、 Sync サービスの再開を木曜の朝まで遅らせることにしました。山岳時間で 5 月 10 日木曜の午前 8 時、 Sync サービスは再度有効化されました。

同期サーバーには大量の待機処理があったため、それらの処理が終わるまでは少しパフォーマンスに問題があるかもしれませんが、なるべく遅延が発生しないように対処しています。

今後どうするのか?

意図せず共有されてしまった記事を削除する機能が入った Day One.app のアップデート(バージョン 2.6.4 )をすぐにリリースします(訳注:すでにリリース済みです)。このアップデートをインストールすると、未ログイン状態のユーザーの端末からは非公開の記事はすべて削除されます。対象ユーザーはアップデートのインストール後 30 日以内に Day One アカウントにログインすることが必要で、このログインをもって Day One.app はログインユーザーを記事の所有者であると認定し、記事を復元します。 Day One.app は影響を受けるユーザーに対してこの変更内容を通知します。

今後、同様の問題が起こらないように、近々以下の改修を同期サーバーに対して施します。

  1. account ID の新規作成時、すでにその ID で記事が作成されていないかをチェックします
  2. 新規の account ID に対しては、連番の数字に加えてランダム生成された二桁の数字を末尾に付加することにします。将来、同じような問題が発生して連番 ID が若返ってしまったとしても ID の衝突が起こる可能性は非常に小さくなります。
  3. いくらかのユーザーアカウントがバックアップ対象から除外されてしまう問題を修正します。

他人に記事を見られてしまった 106 人のユーザーに対しては真摯に謝罪します。対象のユーザーには終身 Premium メンバーシップを提供するとともに、その他懸念点がないか個別に連絡をとっています。今回、大規模な情報流出は起こっていません。第三者がデータベースに侵入したということもありませんし、すべてのデータは我々のサーバーで安全に保管されています。しかしながら 106 人のユーザーの我々に対する信頼は失われてしまいました。信頼は獲得するものであり、与えられるものではないということを承知していますし、再び皆さんからの信頼を取り戻せる機会を得たいと思っています。ユーザーの皆さんには 2017 年 6 月にリリースした end-to-end の暗号化機能を利用することを推奨します。この機能は今回のような事故やその他の問題があったときにもあなたの個人情報を保護します。

今回の障害で Sync サービスを利用できなかった間、辛抱して下さったユーザーの皆さんに感謝します。皆さんが Day One を使って大事な思い出を記録していることを理解しています。今後、よりよくしていくことをお約束します。信頼を回復するため、全力を尽くします。

— Paul

| @散財

五円玉と五十円玉

真似して書いてみます。

キャッシュレス済み

Apple Pay

大手資本が関与しているものは基本的に Apple Pay ( iD )で払っている。めっちゃ快適。

  • コンビニ
  • ドンキホーテ
  • カルディ
  • ドラッグストア
    • ココカラファイン
    • マツモトキヨシ

カード払い

  • 酒のやまや
    毎週末酒を買いに行ってるやまやは Edy が使えるが、 Edy のポイント還元率はしょぼいのでクレジットカード払いしてる。やまやはドコモの d ポイントも貯まるのでカードのポイントと d ポイントでマイル乞食がはかどる。
  • ジュピターコーヒー
    カルディのパクリみたいな店でコーヒー豆を買うときにカード払いする。交通系電子マネーで払えるがポイントが付かないのでカード払いしてる。
  • 昼食
    普段は弁当なので昼飯に金を使わないが、稀に弁当を準備できなかった日は会社近くのスーパー( SUNNY )でカード払いする。
    マイル乞食したいのでカード払いできない飲食店に入って食べるということをしなくなった。
  • コストコ
    もう会員証が失効したけど行ってた頃はわざわざコストコ用に Saison Walmart American Express カードを作って払ってた(コストコは提携カードブランドでないとクレジットカード使えない)
    いまは提携カードブランドが AMEX から Master に変わってる

その他

  • ガソリン
    Esso で Express 給油。 Speedpass で Apple Pay 並みに高速に金が払える。
  • 高速代
    ETC カードで払う。
  • 税金
    去年までは楽天カードから nanaco にチャージしてポイント還元を受けながら納税してた。固定資産税と自動車税合わせて年間 14 万くらい。
    今年から楽天カードが nanaco チャージにポイントを付与しなくなったので SEVEN CARD を作って nanaco チャージして払う方法に切り替えた。
    nanaco は税金を払うのにしか使わずセブンイレブンでも買い物は基本的に Apple Pay ( iD )で払う。

キャッシュレス未完

  • 自宅近辺のスーパー
    どこもカード払いできないのでちまちま現金払いしてる。
    福岡のスーパーでカード払いできるのはサニーか西鉄のレガネットか高級スーパーのボンラパスくらいだと思う。
  • 近所のコーヒー豆屋、パン屋、飲食店
    個人商店ではカード払いできない。現金での買い物は極力したくないので結果的に本当に気に入っている店でしか買い物しなくなった。

所感

守銭奴なので基本的に快適性や利便性は二の次で、ポイントが貯まることを重視している。なので交通系電子マネーか Edy のみが使える店では電子マネーを使わずカード払いするしカード払いできないなら現金払いしてる。中途半端に残高が分散するのが嫌なのでチャージ式の電子マネーは nanaco だけ使うようにしてる。それも税金の支払いにしか使わない。一時期は Apple Pay で払うのが面白くて iPhone の Mobile Suica を多用していたけど ANA VISA カードによる Mobile Suica チャージではポイントが付かないことを知って使うのをやめた。

ベストエフォートキャッシュレス化のおかげでデカい財布いらなくなって Bellroy のカード入れに非常用現金数千円を畳んで忍ばせてあとは小銭入れと Apple Pay 付き iPhone だけ持ってうろついてる。 Bellroy のカード入れはめっちゃ薄いので身軽になって嬉しい。

Apple Pay やその他の支払い方法で挙げた ETC カードや Speedpass 、 nanaco も結局はクレジットカードが終端にあり、大意ではクレジットカード払いであると言える。これがネックになってキッャシュレス決済に店側の負担(決済手数料)が発生して大手資本以外でキッャシュレス決済が普及しない遠因になってる気がする。カード決済は個人商店だと 4.5% くらい決済手数料を持ってかれる。 Square とかを使ってもせいぜい 3.25% ( JCB に至っては 3.95% !)までしか落ちないはず。電子マネー決済も似たり寄ったりだと考えられる。クレジットカードを介さないキャッシュレス決済が普及したら手数料が下がり個人商店でも導入できて便利になりそう。

ちなみに中国人観光客が多いキャナルシティ博多では普通に中国人が WeChat Pay だか Alipay だかで QR コード払いしてるので、同じように外国人観光客が多く利用する中洲の屋台なんかの方がスーパーや個人商店よりも先にキャッシュレス化されるかも知れない。

福岡ではまだまだ完全キャッシュレスな生活を送ることは難しいが、頑張れば現金を使う機会を結構減らすことができる。現金は飲み会の割り勘とか祝儀・不祝儀を包むためだけに存在するようになるかもしれない。


東京の富裕なエンジニアのお二方にも書いてもらえたみたいだった。

| @Mac/iPhone

iMac 5K

三井住友 VISA カードで払うと 12 回まで分割手数料が無料だったのでそれで買った。税込み 25 万くらい。 SSD だけ 256GB から 512GB に変えた。あとはキーボードを Magic Keyboard の US 配列にした。マウスは Magic Mouse 2 を選んだけど正直使いづらい。 Magic Trackpad 2 にすればよかった。キーボードを売って Magic Trackpad を買おうか検討中。 MacBook Pro 2009 の方はデータを抜き取ったらメルカリに出そうと思う。 2 〜 3 万くらいでは売れそう。

MacBook Pro の Touch Bar なしのやつとどっちにするか迷ったけど、最近はアイフォ〜ンが進化してて帰省や旅行に Mac を持って行かなくなった(雑な日記書きやインターネット閲覧などはアイフォ〜ンで事足りる)。ほとんど家で使ってるだけ。仕事で必要なときは会社から借りてる MacBook Pro を使うので私物は据え置き型で良いなという結論に至った。同じくらいの値段ででかい 5K ディスプレイが手に入るなら iMac の方が良い。

CPU を Core i7 にしなかったのはミスったかなと思ったけど全く問題無く快適に動く。普通にコードを書いて写真を整理する程度であれば Core i5 で問題無いと思う。ディスプレイでかくて解像度高いのがなによりすばらしい。 MacBook Pro + 外部ディスプレイを使うのよりも省スペースになり机を広々と使えるのもよい。ディスプレイが一枚なので首を左右に動かす負担もなくなる。

メモリを増やしておこうと思って、いまはちょっと値段が高いが 16GB のやつを 2 枚 Amazon で注文した。これでまた 8 年くらいは粘りたい。