839の日記

趣味の話を書くブログです。

X-DEN物語

注意

この話はIIDX Score Tableというサービスを使っていないと非常に理解が難しい話です。*1 プログラムの話ですができるだけ簡略化して書いています。


前提条件として悪いX-DENと良いX-DENがいます。 悪いX-DENはXがアルファベットではなく特殊文字です。 良いX-DENは普通のアルファベットで構築されています。

また、スクリプトで取得したユーザは良いX-DEN、 CSVで取得したユーザは悪いX-DENで登録されています。

この状態のときにどのような分岐をするか考えてみましょう。

第一に、悪いX-DENを登録しているユーザを良いX-DENデータに変換する必要があります。 これは擬似的には以下のような処理で移行できます。

Score.find_all(name: 悪いX-DEN).update_all(name: 良いX-DEN)

しかしこれを実行するとエラーになってしまいました。 エラーの理由は既に「良いX-DENで登録されている」という内容です。

これはなぜ起きたのでしょうか? 答えとしてはデータに対してある制約を持たせていたためです。*2 その制約とは、「あるユーザが、あるバージョンで、ある譜面(SPN, SPH, SPA, DPN, DPH, DPA)に対して1つの記録しか持つことができない」というものです。*3

例えばあなた(Aさん)がRootageで冥SPAをハードしていた場合は1行に以下のように記録されます。

バージョン: Rootage, ユーザ: Aさん、 譜面: 冥SPA、ランプ: ハード

こういうデータ存在するときに

バージョン: Rootage, ユーザ: Aさん、 譜面: 冥SPA、ランプ: エクハ

といった新しいデータを「追加」することはできません。 既にあるデータを「更新」する必要があるのです。

状態として

バージョン: Rootage, ユーザ: Aさん、 譜面: 冥SPA、ランプ: ハード
バージョン: Rootage, ユーザ: Aさん、 譜面: 冥SPA、ランプ: エクハ

というデータ群は不整合とみなされるように制約をかけている、ということです。 元々のデータとしては以下のようになっていました。

バージョン: Rootage, ユーザ: Aさん、 譜面: 悪い-XDEN SPA、ランプ: ハード
バージョン: Rootage, ユーザ: Aさん、 譜面: 良い-XDEN SPA、ランプ: ハード

このデータの悪いX-DEN SPAを良いX-DEN SPAに更新しようとし…

バージョン: Rootage, ユーザ: Aさん、 譜面: 良い-XDEN SPA、ランプ: ハード
バージョン: Rootage, ユーザ: Aさん、 譜面: 良い-XDEN SPA、ランプ: ハード

「良いX-DEN SPAは既に登録されているデータです」と言われて弾かれてしまったというわけです。 このように書くとわかりやすいのですが、当初はそもそも悪いX-DENを登録している人が良いX-DENも登録しているかもしれない、という発想がありませんでした。*4 具体的には以下に該当するユーザでは上記のような状態になりえます。

スクリプトCSVの両方でスコアを送信したことがあるユーザ」

上記のようなユーザが存在する、ということを認知した今、データの移行方法を再考しなければいけません。 このようなユーザにどのような対処をするべきか? 最初に以下のようなアプローチを思いつきました。

  1. スクリプトCSVの両方でX-DENを登録しているユーザを抽出する
  2. スクリプトCSVでどちらが新しいスコアなのかを検証する
  3. スクリプトのほうが新しい場合、CSVのデータを消すだけで解決する
  4. CSVのほうが新しい場合、スクリプトの方で登録された楽曲名のほうに付け替える

これを行った結果、次に出たエラーは「移行先の良いX-DEN が見つかりません」でした。

最初に私は良い or 悪いX-DENを登録しているという定義にSPN, SPH, SPA, DPN, DPH, DPAのいずれかが1つでも登録されているかを用いていました。 その結果以下のような現象に遭遇しました。

「DPのX-DENではスクリプトで登録しているが、SPのX-DENはCSVで登録しているケース」

このようなケースでは、4.の付け替えをするときにSPN,SPH,SPA,DPN,DPH,DPAをまるごと付け替えようとしているので、SPの悪いX-DENを良いX-DENに付け替えようとした際に付け替え元が見つからない、といったことになります。*5

これを考慮すると、最初の良い or 悪いX-DENが登録されている定義を見直す必要があります。 プレイスタイルごとに分けて検討する必要があるわけです。

それを踏まえて再度ロジックを考えると以下のようになります。

  1. SPの良いX-DENと悪いX-DENを登録しているユーザを抽出する
  2. スクリプトCSVでどちらが新しいスコアなのかを検証する
  3. スクリプトのほうが新しい場合、CSVのデータを消すだけで解決する
  4. CSVのほうが新しい場合、スクリプトの方で登録された楽曲名のほうに付け替える
  5. DPにも1. から同じ操作を繰り返すようにする

くぅ~疲れましたw これにてX-DEN物語は完結です!*6 実は、X-DENが表記揺れしているのは前から認知していたのですがリプで困っている報告をされたのが移行のきっかけでした。 本当はこの記事のネタにするつもりはなかったのですが← ご厚意を無駄にするわけには行かないので流行りのネタで挑んでみた所存ですw 以下、表記ゆれブラザーズの達のみんなへのメッセジをどぞ

炎影「みんな、見てくれてありがとう ちょっと腹黒なところも見えちゃったけど・・・気にしないでね!」

火影「いやーありがと! 私のかわいさは二十分に伝わったかな?」

焱影「見てくれたのは嬉しいけどちょっと恥ずかしいわね・・・」

旋律のドグマ~Miserables~「見てくれありがとな! 正直、作中で言った私の気持ちは本当だよ!」

旋律のドグマ ~Misérables~「・・・ありがと」ファサ

では、

炎影、火影、焱影、旋律のドグマ~Miserables~、旋律のドグマ ~Misérables~、俺「皆さんありがとうございました!」

炎影、火影、焱影、旋律のドグマ~Miserables~、旋律のドグマ ~Misérables~「って、なんで俺くんが!? 改めまして、ありがとうございました!」*7

本当の本当に終わり*8

refs: dic.nicovideo.jp

蛇足

この後、X-DENだけ何故かスコア一覧に出てこないという問題に遭遇し、10分ほど頭を悩ませました。 スクリプトで送られてきたデータはLevel情報が取得できないのでLevel0という扱いで登録しているのですが、 Level0の曲は表示しない、というロジックを入れているためでした。 最後の最後までやってくれますねぇ。

あと、もちろんCSVで送ってくる悪いX-DENさんは送られてきたときに良いX-DENに更生させて登録するように変更しました。 そうしないと今まで書いた内容が無限ループしますからね!


おまけ

移行に使ったスクリプト

最初は本番サーバでちょちょっとコマンド打って終わりのつもりが思ったよりだいぶ根深かったので、手元にDBを落としてきて色々試行錯誤しました。 検証中はtransactionで囲ってました(いけたと思ったら落ちたりすることが多すぎて中途半端に移行されるとDBの入れ直しが面倒なので)。

%w[SP DP].each do |style|
  good_x_den = Sheet.find(良いX-DEN).music_scores.where(play_type_status: style).order(:difficulty_type_status).pluck(:id)
  bad_x_den = Sheet.find(悪い-XDEN).music_scores.where(play_type_status: style).order(:difficulty_type_status).pluck(:id)

  mapping = {}
  bad_x_den.each_with_index do |bad, index|
    mapping[bad] = good_x_den[index]
  end

  User.accepted.find_each do |user|
    goods = user.scores.where(music_score_id: good_x_den)
    bads = user.scores.where(music_score_id: bad_x_den)

    # NOTE: 両方存在しているユーザは移行する
    # 悪い方が良い方より古い場合は古い方を削除して終わり
    # 悪い方が良い方より新しい場合は古い方の要素を取り出して古い方を削除したあと、新しい方に更新する
    if goods.present? && bads.present?
      if goods.order(updated_at: :desc).first.updated_at > bads.order(updated_at: :desc).first.updated_at
        bads.destroy_all
      else
        bads.each do |bad|
          good = goods.find_by!(music_score_id: mapping[bad.music_score_id])
          good_id = good.id
          good.attributes = bad.attributes
          good.id = good_id
          good.music_score_id = mapping[bad.music_score_id]
          good.save!
          bad.destroy!
        end
      end
    end
  end
  # NOTE: ここに来るということは両方存在するわけではなく悪いX-DENしか存在しないユーザのはず
  # そういった人は無条件に良いX-DENに転生させる
  bad_x_den.each do |bad|
    Score.where(music_score_id: bad).update_all(music_score_id: mapping[bad])
  end
end

感動の結果*9

irb(main):007:0> Score.where(music_score_id: Sheet.find(悪いX-DEN).music_scores.pluck(:id)).count
=> 0

雑にupdate_allとかかましてますが、業務ならちゃんとデータが移行できているか検証する必要があります。*10 なんでこんなことにそんなにお金がかかるの?というシステムの改築の裏にはこういう話が沢山隠れていると思います。

*1:ISTを使っていなくてもiidxを知っていれば何とかなるかも

*2:後述していますがこの制約は鬱陶しいものではなく必要なものです

*3:この制約がないとユーザにスコア一覧を見せるときに重複して見せざるを得ないケースが出てきます。表示する側で絞ろうとするとまともにページネーションもできません

*4:あー、こういうケースもあるのかぁという気持ちになりました

*5:これ、遭遇したとき最初はまじで意味がわからなくてどういうこと??って5分ぐらい悩んでました

*6:やたらエラーで詰まっていたように見えるかもしれませんが、これは期待している挙動です。実際変なデータができてしまう可能性はたびたびありましたがすべて制約のおかげで回避しています。人間の操作は簡単に信じてはいけない。

*7:過去にIST関係で遭遇したことがある表記ゆれたちですが、参考表を含めるともっとたくさんあります。明らかな文字化けはコナミに報告すると直してくれます。言われないと気づかないよ本当、なのでみんな報告してください

*8:表記ゆれに苦しんだことはたびたびありますが、ここまで苦しめられたのは今回が初めてです

*9:感動の結果に辿り着くまでに2時間ぐらいかかりました

*10:ISTは5000万レコードぐらいあるので、ちゃんと検証すると死ぬなと思ってやってません。該当しそうなやつをちゃんと絞ればもっと少ないけど無料サービスだし

cloud runでpuppeteerを使ってクローラーを作る

もともと借りていたVPS(ubuntu)の上でchromeとchromedriverを入れてクローラを動かしていたが、 何気ないapt-get upgradeで動かなくなってしまい、今後も発生することを考えると憂鬱になったので移行先を探すことに…。

cloud runは感覚的にはdocker imageを投げつけられるlambdaみたいなもので、手元で動くものを作ればimage投げるだけでいけるかな、という算段で選択した。 rest api的にurlにアクセスしたらcrawlした結果を返すようにしたかったため、expressを間に挟んでいる。 常に使いたいわけでもなかったので、使うときだけ起動するcloud runは要件とマッチしていた。

サンプルリポジトリとしては以下で公開している。 github.com

内容としては、GAEでpuppeteerを動かしているサンプルのcloud run版といった感じ。 cloud.google.com

const express = require('express');
const puppeteer = require('puppeteer');
const app = express();

app.use(async (req, res) => {
  const url = req.query.url;

  if (!url) {
    return res.send('Please provide URL as GET parameter, for example: <a href="/?url=https://example.com">?url=https://example.com</a>');
  }

  const browser = await puppeteer.launch({
    args: ['--no-sandbox']
  });
  const page = await browser.newPage();
  await page.goto(url);
  const imageBuffer = await page.screenshot();
  browser.close();

  res.set('Content-Type', 'image/png');
  res.send(imageBuffer);
});

const server = app.listen(process.env.PORT || 8080, err => {
  if (err) return console.error(err);
  const port = server.address().port;
  console.info(`App listening on port ${port}`);
});

ちょっとハマったところはデフォルトはmemory 256MBでデプロイされるが、512MBはないとchromeが起動してくれない点。 上記のgithubのREADMEにも書いているが、 --memory 引数で指定できるので512Mi以上を指定したほうがいい。

f:id:husq:20190514213823p:plain
cloud run log
上記のような感じでログも出るので大体すぐ問題はわかる。

更新はもう一度 gcloud beta run deploy を叩くといい感じにバージョンを切り替えて反映してくれるし、カスタムドメインも利用可能。 ドメインを登録するとCNAMEにこれを指定しろ、と言われるのでそれを指定すればSSLの証明書も勝手にletsencryptで取得して設定してくれる。 lambdaとかでホスティングする場合はapi gatewayの設定をいろいろとしないといけないのでだるいが、cloud runはこの辺がかなりお手軽。

運営しているサイトでchromeを使ってクロールできればかなり効率が上がる刺し身たんぽぽ作業があり、 chrome crawlを使わず手作業でやるのは相当時間的にもしんどかったので、メンテナンスフリーな形で移行できて助かった。

使ってみた感想としてはdocker imageを投げるだけでいい感じに使えるので、statelessなものはガンガン投げていってもいいなと感じた。

carrierwaveからactivestorageに移行した

最近画像管理にcarrierwaveを使っていたものをactivestorageに移行した。 移行のモチベーションはローカルに置いていた画像をGCSとかに置きたいなと思ったが、どうせ対応するならactivestorageに移行してからにしようかと思ったため。 今回はactivestorageに移行したがファイルはlocalに置いたままで、GCSに置くのはまたGKEに移すときにでも考えようと思っている。

5系のrailsで移行しようとしていたが、そろそろ6が出る && 6系で破壊的に変わる部分があるというのをissueでちら見していたので一応activestorageのmigrationファイルを確認。 当たり前だけど、DBに関しては破壊的変更はなさそうだったのでそのままやることにした。

移行にあたってはとりあえずraiseはしないことだけ主目的に移行した。 業務だったらもう少しいい感じにやらないといけないが、個人サービスなので多少UXが下がる瞬間があっても良いかな、という気持ち。

  1. carrierwaveで管理している画像をactivestorageに移行するためのメソッドを生やす
  2. 新規で画像が取り込まれるときにはactivestorage側に保存するようにする
  3. 画像を表示する部分はactivestorageで画像が管理されているものを表示するようにする
  4. capistranoを使っているならlinked_dirsでpublic/uploadsを指定していると思うが、storageに変更しておく(localに置いている人限定)

一旦ここまでやってデプロイ。 移行するためのメソッドは具体的には以下のようなものを生やした。

class User < ApplicationRecord
  mount_uploader :image, ProfileImageUploader
  has_one_attached :avatar
  
  def self.migrate_as!
    failed_user_ids = []
    where.not(image: nil).find_each do |user|
      next unless user.image.url

      user.avatar.attach(io: open(user.image.file.file), filename: 'avatar.png') # URI.openを使ったほうが無難
    rescue Errno::ENOENT
      failed_user_ids.push(user.id)
      user.remove_image!
    end
    failed_user_ids
  end
end

user.image にcarrierwaveをぶら下げていたが、たまになぜかimageがある扱いなのにないやつがいたので rescue Errno::ENOENT で救う。 今回はそういう人たちは画像はなかったこと扱いにした。 が、一応念のためにidだけメモっておきたかったので配列で確保しておく。

デプロイした後は上記メソッドを速やかに叩いて表面上の移行は完了。 ただし、ちゃんとやるなら以下のような点を注意する必要があるなと感じた。

  1. デプロイされる
  2. user Aが新しく画像を設定(activestorageに保存される)
  3. 移行スクリプトを叩く => user Aの画像がcarrierwave時代のものに戻ってしまう

まぁメンテナンスタイムを設けるのが無難だとは思う。

次に残ったcarrierwaveをいろいろと抹消していく。

  • carrierwaveで使っていたカラムの削除(今回であればuser.imageを消すmigrationファイルを書く)
  • uploads系のファイルとかを含めたcarrierwave依存のロジックを消す
  • デプロイ先のpublic/uploads削除(localに置いている人限定)
  • .migrate_as! の削除

今回ちょっと手間だったのは user.image.urlAPI経由で返している場所。 viewで使う分には

= image_tag(url_for(user.avatar)) if user.avatar.attached?

と書けばいいが、modelでurlを返すメソッドを生やしてるとこうは書けない。 自分の場合は以下のように書いて対応した。

Rails.application.routes.url_helpers.rails_blob_path(avatar, disposition: :inline, only_path: true)

only_path: true をつけているので、host名は含まれずにpathだけで返される。 なのでurlを実際に使う側でいい感じにhost名を補完する必要がある(もちろん上記にhost名を返すように追加ロジックを書いてもいい)。

おまけ

carrierwaveを消す際にactivestorageではいろいろテーブルを追加してファイルを管理しているが、carrierwaveではどのように管理しているのかが気になった。 今回はimageというカラムを生やしていたので、imageにいろいろ情報が突っ込まれてるのかな?と思ったがstring型である。 string型ということはserialize hashとかにして管理してるのか…と思って中身を見たら image という文字列しか入ってない。

これはどういうことなんだろう、と思ってuploaderの実装(数年前なので全然覚えてなかった)を見に行ったら以下のような感じだった。

class ProfileImageUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick
  storage :file

  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

  version :thumb do
    process resize_to_fit: [50, 50]
  end
end

カラムがnilじゃなかったらstore_dirから探索してファイルを置くような感じっぽい。 少しドキュメントを読んでみた感じだとserialize jsonとしてデータを保存することもできたようだ。

脳死でとりあえずcarrierwaveを入れてしまっており、挙動をあんまり理解していなかったのでactivestorageではもう少しこの辺も意識していきたいと思う。