patorashのブログ

方向性はまだない

2020年を振り返る

はい、というわけで、今年も振り返っていこうと思います。去年のはこれ。

patorash.hatenablog.com

なんか、来年の目標は来年考えるとか書いてたけれど、それをブログにしてなかった。

2020年序盤

たしか、OpenID Connectの技術検証をしていたので、それをそのまま第22回Ruby, Ruby on Rails勉強会で発表したのだが、発表後に公開した資料についてOpenID Connectに詳しい方々から資料が間違っているというご指摘をいただき、直したりしていた。大変勉強になったのでやはり発表駆動勉強はしんどいけどよかった。

patorash.hatenablog.com

あと確か中国地方DB勉強会でLT2本やったかな…。まぁまぁ受けてた気がする。

その中でも仕事では後輩氏に1人でアプリを設計してもらって実装してもらうっていうのをやってて、そのコードレビューとかやっていた。4月頃には、OIDCのアプリは一旦検証完了となった。

この頃のプライベートでは、夫婦で4月から長男の通うところをどうするか?を話し合っていて、2019年の間に見学に行っていた療育施設に通わせるために、療育手帳をもらうための検査したり申請したり、特別児童扶養手当をもらうための手続きをしたりとかしていたと思う。この時点で長男は週3で2つの療育施設に通っていた。そこは2つとも送迎をしてくださるところだったので、その点は非常に助かっていた。しかし、結構家から遠かったり、小さい子だけを預かっているところではなかったので小学校等が長期休暇になったら大きな子たちが来るので、身の安全を確保できないので休んでほしい、と向こうからお願いされたりもしていた。

そのため、4月から妻が調べてくれた近隣で週5~6で行ける同世代のみのところに変更することにした。ただし、送迎はない…。そのため、次男がまだ幼いので妻には次男を見ていてほしかったので、私が送迎することにした。会社に相談して、変則的な勤務になることを許可してもらって、なんとか今のところはうまくいっている。会社には圧倒的感謝。ということで、4月からの私の勤務体系のデフォルトは、こう(現在も)。

時刻 行動
09:00 長男を園に送る
09:30 勤務開始
14:30 会社を出て迎えに行く
15:00 勤務再開
19:00 勤務終了

とはいえ、当然ながらこんなにうまくいくことはないので、時々間に合わなかったりしていた。

あと、長男氏が私の髪の毛をめちゃくちゃ引っ張って痛いので、物理的に対策するために坊主にした(今も坊主頭は継続中)。

5~8月あたり

在宅勤務はしつつも週に何日かは後輩氏と日程を合わせて出社するべしということになっていたので、週2くらいで出社して、あとは在宅ワークだったかな?後輩氏とペアプロをして教えたりしていたと思う。後輩氏と私は4月から特命を受けていて、私が監修のもと、後輩氏が実装するという形で静的サイトを作っていた。静的サイトなのでRailsではなく、Middlemanで作っていたのだが、そのセットアップは私がやった。webpackを使えるようにするのが結構大変だったと思う。RailsはWebpackerがあるから楽だ。

うちの会社は期末が8月で、来期の方針として配置転換が行われて私の担当プロジェクトから1人外れて後輩氏が入ることになったので、後輩氏が配属になるまでに面倒そうなところは片付けておこうと思い、Rails6へのバージョンアップと脱CoffeeScriptをしておいた。その頃の記事。

patorash.hatenablog.com

これで、CoffeeScriptを教えなければならないという手間を防ぐことができた。そしてその後、SprocketsもやめてWebpackerに移行した。これはかなり頑張った。これに関しては id:luccafort に公開してほしいと言われたので記事を書いた。

patorash.hatenablog.com

その頃に後輩氏と特命を受けていたサイトがリリース。弊社の30周年記念事業として、バッティングセンターが広島県福山市にオープンしたのですが、そのサイトを作ってました。

www.bpark.jp

オープンが決まっていたので、そこまでに必要なコンテンツのみ実装して、リリース後も実装という形で結局全部やるのに9月末くらいまでかかったような…。とはいえ、このサイトの実装のおかげで後輩氏のBootstrap力は、そこそこ上がりました。

あとは期末までに試験を受けようと思っていたので、OSS-DB Gold v2を受けたけれど、敢えなく不合格…。そのあたりはまとめた。まだ再試験受けていないので、どこかで受け直したい。

patorash.hatenablog.com

プライベートは、長男が園に通い始めて、色々できるようになってきた。靴を1人で履いたりとか。送迎の時に色々と園での様子を聞いたり、家での様子を伝えたりという感じ。通園前に比べると、以前の通っていたところより同世代の子が増えたので、色々な刺激を受けているみたいだった。音程だけの歌を歌い始めるようになったりした。次男もこの頃には語彙が急に増え始めた頃だったかと思う。こういってはなんだが、次男は喋り始めたのですごく安心した。

9~12月

プライベートなことから書くと、9月~10月は大変だった。長男が重度の虫歯になっていた。時々急に不機嫌になったり泣いたり、周囲の人に噛みついたりしていた。私も何度も噛まれた。発達障害児は過敏な子が多くて偏食だったり、歯磨きを猛烈に嫌がったりというのはあるのだけれど、うちの子もまさにそれで、全然奥歯を磨かせてくれてなかった。近所の歯科の検診が10月だったので、そこで診てもらおうかと思っていたのだが、情緒の不安定さが尋常ではないので、発達障害児を専門に診ている歯医者さんのチラシを園で見かけたのでそこに行ってみたら、かなり虫歯が進行していることが発覚した。

「治療が必要だけれど、長男くんは大暴れして普通の歯医者では無理だから、全身麻酔で歯の治療をするところを紹介します」という話になった。しかし、県内の病院は半年待ち、県外の病院でも1か月待ちということだった…。もう世間は新型コロナで県をまたいだ移動は控えるようにと言われまくっていたのだけれど、もう選択の余地はなく、隣県で治療することとした。治療自体は1ヶ月待ちだが、初診は早めに行っていきたいということで、9月頭くらいに病院に行った。

9月下旬に治療できるという話になったので、まぁ1ヶ月の辛抱じゃ、頑張れ長男!と思っていて、園でも「もうすぐ歯の治療ですね~」と言われてたら、予定日1週間前くらいになって、病院から「麻酔科医の都合が治療予定日にどうしても都合がつかなくなったので予定日から3週間延期させてほしい」と言われてしまった。一番大変なのは長男だけれど、私もかなりショックだった。とはいえ、麻酔科医がいないのでは治療できないので受け入れるしかなく、虫歯が悪化しないことをただただ祈るのみ…。甘いものを極力取らせない生活が続いたので、すごく不機嫌にもなるし、偏食もあるので栄養的に心配でもあった。

10月中旬にようやく予定日を迎え、全身麻酔で治療に成功。治療後も問題なかった。情緒が不安定になる頻度は格段に減り、噛み癖もだいぶ収まったので本当によかった(でも油断してたら噛まれたことがある)。その後は、歯医者に慣れることから始めようということで、2週間毎に通院して歯医者で歯磨きと歯のチェックをしている(つまりこの通院も私のタスクとして追加されたということだ…)

後は、園で予定されていた行事が新型コロナの影響でなくなり、代わりのイベントが準備されてて移動動物園が園に来てくれて、それに保護者として私が参加して長男と一緒に色んな動物とのふれあいや、園が準備してくれたアトラクションに参加したりなどをした。

そして発達障害児の育児書を数冊読んで、公園に行く回数を増やしたり、高タンパク質な食生活にできないか工夫したり、テレビ・スマホタブレット・電子玩具類を禁止するようにした。これは禁止する決断をさせてくれた本の話。

patorash.hatenablog.com

テレビは見なくなってもうちょっとで2か月になるだろうか。次男はアンパンマンのアニメが大好きだったので、「アンパンマン見たい」と最初はよく言われたけれど、「テレビが壊れちゃった」と言ってます(実際に調子は悪いんだけど…)。今は一緒に遊ぶ回数を増やしたり、本を読んだりするのを増やしたりとかしてます。テレビ消してから、次男の語彙力がどんどん良くなっているので、効果はありそうだけれど、長男はまだ喋らず…。でも昔より確実に人に興味を持つようになってきてる…ような…。

あとISUCONに初挑戦したんだった。

patorash.hatenablog.com

他の人のブログとかチェックまだできてないので年末年始にチェックしておこうかな。

あー、そして、思い出した。DBスペシャリストを受けましたが、落ちました。OSS-DB Gold v2も落ちたし、全然データベースに詳しくない!もっと詳しくなりたい!!

仕事の面では、スクラムっぽいことをやっていて、後輩氏に機能開発を一任して、私は環境整備や調整やサブシステムの機能改善などを行っていた。特にサブシステムに関しては、長いこと機能改善に取り組めておらず、手がけた開発者も別のプロジェクトに配置転換になってしまったため、私が改修した。要望対応と、パフォーマンス改善が目下の課題だったが、要望対応はもちろんのこと、パフォーマンスはかなり良くなった。メモリ不足のアラートがしょっちゅう上がっていたが、全く来なくなった。このサブシステムではVue.jsを使っていたので、多少Vue.jsが分かるようになった。 あとElasticesearchのバージョンアップやった。こういうのに取り組めるようになったのは、機能開発は後輩氏に任せる!と決めていたからこそ。

patorash.hatenablog.com

ただこの頃はもうほぼフルリモートになっていたので、後輩氏の進捗というか状態がどうなのか確認しづらくなっていた。朝と夕方に確認を取るようにしたり、詰まっているところがないかを確認するようにしたりなどするようにはしたが、なかなか大変だった。それまでは画面共有でコード見たりもしていたのだけれど、12月からはvscodeのliveshareを使ってリモートペアプロをするようにして指導した。だんだん慣れてきてくれたのか、最近は詰まったら早めに相談してくれるようになったので、それはよかったと思う。あと、私自身が単一障害点となっていたあたりについて、引継ぎというか共有ができたので、安心感ができた。

そしてこの1ヶ月くらいはまだ明かせないけれど、やりたかった社内イベントのために準備を進めている。あと1ヶ月くらいはかかるかもしれないけれど、公開していい状態になったらまた情報解禁したいと思う。

総括

総括すると、家族ファーストで色々と取り組んでいて、大変な一年であった。特に送り迎えが発生するので、迎えの時間帯では会議できないので関係者に調整してもらったりと、色々と迷惑をかけつつも協力してもらえて助かったなと思います。 良かった点は、フルコミットの開発メンバーを増やせたこと。まだまだ教育途中ではあるけれど、1年前に比べるとだいぶ成長したなぁと思う。自分がずっとやっていた定期メンテナンス作業を私以外の人ができるようになったのは、普通に嬉しい。

悪かった点は、やはり長男氏の虫歯にもっと早く気づけていれば…という申し訳なさ。あとはマネジメント的なこともやらないといかんのに、ちょっとそちらが薄いかもしれないなぁ…というところ。そのあたりの手法については id:tech-kazuhisa に相談していきたいと考えている。あとはオンライン勉強会へのアンテナの低さ…。インプットの少なさ…。

来年の目標は、

…来年考えます。

stimulus 2.0.0の進化が凄い件(サンプルコードあり)

仕事で、とあるRailsアプリを作っているのですが、そこでstimulusを採用していました。そうしたらちょうど少し前にstimulusのバージョン2.0.0がリリースされていました。このバージョンアップによって、かなり書きやすくなりました。

今までと何が違うのか?

今までは、コントローラーを指定してから、そのコントローラーのターゲットを指定するのが面倒でした。

before

<div data-controller="vote">
  <button type="button" data-target="vote.button">投票</button>
</div>

after

属性名だけでどのコントローラーのターゲットか分かるので読みやすくなりました。

<div data-controller="vote">
  <button type="button" data-vote-target="button">投票</button>
</div>

新しく追加された機能

大きく変わる機能が、Value APICSS Class APIです。

Value API

Value APIは、そのまんまですが、値を持つことができます。 stimulusの公式サイトのリファレンスから引用しますと、以下のような感じです。

html

<div data-controller="loader"
     data-loader-url-value="/messages">
</div>

JavaScript

import { Controller } from "stimulus"

export default class extends Controller {
  static values = { url: String }

  connect() {
    fetch(this.urlValue).then(/* … */)
  }
}

この例だと、fetchする先のURLをValue APIで変えることができる、という感じです。valueの指定は、Hashのキーにvalueの名前を、値に型を書きます。そうすると、読み込んだ際に型に沿った形にキャストしてくれます。

これだけだと、値を渡せるようになっただけ?という感じですが、それだけではありません!Value APIは、値が変わった時にコールバック関数が呼ばれます。 CodePenで、Value APIと、そのコールバックを使って、送信ボタンの状態制御をしてみました。

See the Pen stimulus v.2.0.0 sample(Value API) by patorash (@patorash) on CodePen.

stimulusのコントローラーでinitialize, connect, disconnectの3つのメソッドが自動でコールバックされます。今回はこれらも使って、

  1. connectのタイミングでコメント入力エリアにinputのイベントリスナを定義する。
  2. 入力されたら、handleEventメソッドでイベントを拾い、入力エリアに1文字以上あったら、enableValueをtrueに変更。0文字ならfalseに変更。
  3. enableValueの値が変わったらenableValueChangedメソッドがコールバックで呼ばれ、submitボタンのdisabledの状態が変わる!
  4. disconnectのタイミングでイベントリスナを解除する。

という仕組みになっています。valueはhtmlの要素の属性として値がある状態なので、戻るボタンなどで戻った時は再びconnectが呼ばれてちゃんと動く、というわけです。

私的には非常にわかりやすくていいなぁ!と思いました。

4時を過ぎたので、今日はこの辺りまでにして、続きのCSS Class APIはこの記事に追記していきます。

続きを書いていきます!

CSS Class API

CSS Class APIは、stimulusで使うCSSに名前をつけることができる機能です。 stimulusの公式サイトのリファレンスから引用しますと、以下のような感じです。

JavaScript

// controllers/search_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  static classes = [ "loading" ]

  loadResults() {
    this.element.classList.add(this.loadingClass)

    fetch(/* … */)
  }
}

html

<form data-controller="search"
      data-search-loading-class="search--busy">
  <input data-action="search#loadResults">
</form>

アクションのloadResultsメソッドを呼び出したら、searchコントローラーの紐づいたformのcss.search--busyを追加します。

以前までは、クラス名をJavaScriptにハードコーディングしなければいけませんでした。

// 以前のstimulus
// controllers/search_controller.js
import { Controller } from "stimulus"

export default class extends Controller {

  loadResults() {
    this.element.classList.add('search--busy') // => ハードコーディング

    fetch(/* … */)
  }
}

これだと、CSSのクラス名が変わった場合にstimulusのコードに修正が必要になっていました。しかし、CSS Class APIの登場により、HTML側の修正だけでよくなりました。

CSS Class APIが活用できる具体的なシーン

例えばBootstrapのバージョンが上がったときを例にしましょう。

未だにBootstrap3を使い続けている環境があるとします(私の担当プロジェクトですが…)。そこでは、要素を隠すためのCSS Class名は、.hiddenです。

Bootstrap3を使っているフォーム

<div data-controller="comment"
    data-comment-close-class="hidden">
  <button type="button"
      data-action="comment#openForm">コメントする場合は押してください</button>
  <form class="hidden" onSubmit="return false"
      data-comment-target="form">
    <input type="text" name="content">
    <input type="submit" value="送信">
    <button type="button"
        data-action="comment#closeForm">キャンセル</button>
  </form>
</div>

JavaScript

// controllers/comment_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "form" ]
  static classes = [ "close" ]

  openForm() {
    this.formTarget.classList.remove(this.closeClass)
  }

  closeForm() {
    this.formTarget.classList.add(this.closeClass)
  }
}

これをBootstrap4にバージョンアップします。 data-comment-close-class="d-none"と、<form class="d-none">に修正します。

<div data-controller="comment"
    data-comment-close-class="d-none">
  <button type="button"
      data-action="comment#openForm">コメントする場合は押してください</button>
  <form class="d-none" onSubmit="return false"
      data-comment-target="form">
    <input type="text" name="content">
    <input type="submit" value="送信">
    <button type="button"
        data-action="comment#closeForm">キャンセル</button>
  </form>
</div>

stimulus側のコードは、変更することは何もありません!

このように、CSS Frameworkに依存しない形でJavaScriptのコーディングが可能になりました。

まとめ

stimulusが登場した時点でも、すごい!とは思っていたのですが、バージョン2になって、さらに使いやすくなりました!Value APIの登場で、他のMVVMライブラリのcomputedみたいなことがやりやすくなりましたし、CSS Class APIの登場で、CSS Frameworkに依存しないコーディングができるようになりました。

最近のフロントエンドとバックエンドを疎結合にしていく流れも、もちろんいいとは思いますが、少々(かなり?)複雑です。Railsで簡単なものを簡単に実装しやすいという点では、stimulusは最高のパートナーになってくれると思います。

simple_formを使ってActiveStorageのダイレクトアップロードを行う

引き続き、ActiveStorageネタです。 simple_formを使ってActiveStorageのdirect_uploadの設定を書くと、うまくいきません…。

= simple_form_for(@post) do |f|
  / 略
  .form-inputs
    / 動くけれどダイレクトアップロードにはならない…
    = f.input :images, as: :file, direct_upload: true, input_html: { multiple: true, accept: 'image/*' }

どうもdirect_upload: trueの部分が関係ないものとして無視されてしまうようです。 しかしもう既にActiveStorageが出てから随分経っているので、なんかいい方法あるだろうとググったら、stack overflowにありました。

stackoverflow.com

simple_formのカスタムインプットクラスを作る方法です。app/inputs/direct_upload_file_input.rbを作って、以下のコードを貼ります。

# frozen_string_literal: true

class DirectUploadFileInput < SimpleForm::Inputs::FileInput
  def input_html_options
    super.merge({ direct_upload: true })
  end
end

そして、ビューを修正します。

= simple_form_for(@post) do |f|
  / 略
  .form-inputs
    / ダイレクトアップロードになった!
    = f.input :images, as: :direct_upload_file, input_html: { multiple: true, accept: 'image/*' }

エラーメッセージも綺麗に出るようになったので、こっちのほうがいいですね。

ActiveStorageで画像を追加アップロードする

さっきこんな記事を書きました。

patorash.hatenablog.com

今度は、更新で画像を追加しようとしたところ、上書きされてしまいました。

追加されると思っていたのでびっくりな挙動でしたが、その辺りはこのissueにあります。

github.com

Rails5までは、追加がデフォルトの動作でしたが、Rails6からは上書きがデフォルトの動作になります。

Rails5の動作と同じにしたい場合は、config/application.rbなどで、以下の設定が必要です。

config.active_storage.replace_on_assign_to_many = false

しかし、これは設定せずに、せっかくRails6で上書きがデフォルトの動作なので、それで対応したいと思います。

対応方法

実は先ほどのissueのコメントの中に対応方法がありました。

https://github.com/rails/rails/issues/35817#issuecomment-484158884

前回の記事のビューに追加で書くとすれば、こう。

.mb-3
  = f.label :images
  = f.file_field :images, direct_upload: true, multiple: true, accept: 'image/*'
  - @post.images.each do |image|
    = f.hidden_field :images, multiple: true, value: image.signed_id

signed_idを付けることで、追加アップロードにできるようになりました。

新たな不具合

しかし、新たな不具合が起きました。 ファイルを追加しつつ、既存の画像を削除しようとしたら、エラーになりました。

qiita.com

これと同じようなエラーなのですが、@post.imagesを追加してから削除して…という途中で、hidden_fieldに入れたsigned_idでは追加しようとするのに、削除フラグがONになっているからおかしくなるのでしょう。 これに関しては、スマートな回避方法を思いつきませんでした。

苦肉の策

Rails6からは上書き保存なところを利用して、削除対象のimageのsigned_idを取得して、それを上書き対象から除外しました。コントローラーのstrong_parametersでの対応となります。

class PostsController < ApplicationController
  # 略

  private

  def post_params
    permit_params = params.require(:post).permit(
        :title,
        :content,
        images: [],
        images_attachments_attributes: [ :id, :_destroy ],
    )
    images_attachments_attributes = permit_params.delete(:images_attachments_attributes)
    if images_attachments_attributes
      destroy_signed_ids = images_attachments_attributes.to_h.map do |_, attribute|
        @post.images.find_by(id: attribute[:id])&.signed_id if attribute.delete(:_destroy)
      end.compact
      permit_params[:images] -= destroy_signed_ids
    end
    permit_params
  end
end

これで、ファイルを追加しつつ、ファイルを削除可能になりました👍

まとめ

ActiveStorageは難しい…。

ActiveStorageで添付した画像をupdateで削除する

Rails 6.0でとあるアプリを作っているのですが、ActiveStorageを使って画像を添付しています。登録は比較的簡単に行えたのですが、画面から画像のみ削除する方法がいまいちわからなかったので調査しました。

コード(修正前)

モデル

複数の画像が添付できるように、has_many_attachedにしています。

class Post < ApplicationRecord
  has_many_attached :images
end

ビュー

simple_formを使っています。画像はActiveStorageのダイレクトアップロードでアップロードしています。

= simple_form_for(@post) do |f|
  = f.error_notification

  .form-inputs
    = f.input :title
    = f.input :content
    .mb-3
      = f.label :images
      = f.file_field :images, direct_upload: true, multiple: true, accept: 'image/*'
  .form-actions
    hr
    = f.button :submit, data: {disable_with: t('helpers.button.disable_with')}, class: 'btn-primary mr-2'
    = link_to t('helpers.links.cancel'), posts_path, class: 'btn btn-secondary'

コントローラー

特に変わりはないのでstrong_parametersのところだけ書いておきます。

class PostsController < ApplicationController
  # 略

  private

  def post_params
    params.require(:post).permit(
        :title,
        :content,
        images: [],
    )
  end
end

やろうとしたアイデア

こういうモデルの子になるデータの追加・削除はaccepts_nested_attributes_forを使うのが順当だろうと思いました。

試したこと(失敗したやつ)

モデルを以下のように修正。

class Post < ApplicationRecord
  has_many_attached :images
  accepts_nested_attributes_for :images, allow_destroy: true
end

ビューを以下のように修正。

= simple_form_for(@post) do |f|
  / 略
  .form-inputs
    / 略
    .mb-3
      = f.label :images
      = f.file_field :images, direct_upload: true, multiple: true, accept: 'image/*'
    .row.mb-3
      = f.fields_for :images do |image|
        .col.col-sm-6.col-md-4.mb-3
          = image.hidden_field :id
          .form-check
            label
              = image.check_box :_destroy, { class: 'form-check-input mr-1', checked: false }, true, false
              | 削除する
          = image_tag url_for(image.object), class: 'img-thumbnail rounded'
  / 略

こうすると、accepts_nested_attributes_for:imagesなんていう関連はない!と言われて落ちます。

しかし、AcitveStorage::Attachmentモデルと関連があるはず…なので、次を試みます。

試したこと(成功したやつ)

モデルを以下のように修正。

class Post < ApplicationRecord
  has_many_attached :images
  accepts_nested_attributes_for :images_attachments, allow_destroy: true
end

ビューを以下のように修正。

= simple_form_for(@post) do |f|
  / 略
  .form-inputs
    / 略
    .mb-3
      = f.label :images
      = f.file_field :images, direct_upload: true, multiple: true, accept: 'image/*'
    .row.mb-3
      = f.fields_for :images_attachments do |image|
        .col.col-sm-6.col-md-4.mb-3
          = image.hidden_field :id
          .form-check
            label
              = image.check_box :_destroy, { class: 'form-check-input mr-1', checked: false }, true, false
              | 削除する
          = image_tag url_for(image.object), class: 'img-thumbnail rounded'
  / 略

コントローラーもstrong_parametersを修正します。

class PostsController < ApplicationController
  # 略

  private

  def post_params
    params.require(:post).permit(
        :title,
        :content,
        images: [],
        images_attachments_attributes: [ :id, :_destroy ],
    )
  end
end

これで、更新時にファイルの削除ができるようになりました。

動作検証

紫陽花と桜の画像をアップ済みです。そこから、紫陽花の画像を削除してみます。

f:id:patorash:20201202154337p:plain

showの画面を都合で見せられないのですが、再び更新画面にきたときには桜の画像のみ残っています。

f:id:patorash:20201202154425p:plain

削除に成功です。

言葉の遅れが改善する方法を読んだ

長男が発達障害と診断され、療育手帳を取得していて、療育施設にも通っている状態です。

うちの子はまだ言葉の獲得ができていないので、それのヒントがあればと思い、購入。

本を読んでほしいので本の内容について書きすぎませんが、内容を見ると本当に身に覚えがありすぎる…。小さな頃からテレビ漬けになってしまっていたのだなぁと思うし、そのせいで人の話言葉を聞く能力が育っていないというのはめちゃめちゃ腑に落ちました。自分達の呼びかけが、テレビの音と同じ雑音だと判断されているとは思いもよりませんでした。

発達障害児の増え方についてもデータで語られていて、昔は5,000人に1人だったのが、今は100人に1人という割合になっている等、激増しているのがわかります。

アナログ子育てにしましょう、と綴られており、現代にとってかなりの難題ではありますが、常になにかしらの音や光に晒されるようなことをやめればいいわけだし、工夫次第でなんとかなると思うので、テレビは当分封印し、スマホもなるべく控えるように、絵本を増やして読み聞かせして、前よりももっと一緒に遊ぶようにしていこうと思います。

これは、著者の片岡先生のサイトに掲載されていたYouTubeの動画。


テレビの子守は危ない!自閉症と診断された言葉遅れの子どもたち

Rails6.0にアップグレードしたらActiveStorageでハマった件(追記あり)

最近は担当製品の関連アプリを改修しているのですが、Railsのバージョンを5.2系から6.0にアップグレードしました。そのときにActiveStorageでハマったので備忘録を残しておきます。なお、担当製品は既にRails6にしてあります。ただし、まだActiveStorage使ってない。その時の記事はこちら。

patorash.hatenablog.com

普通にRails6へのアップグレード

先ほどの記事を読みながらどういう対応をしたかを思い出して対応。一通り、手動で動かしてみたところ、問題なさそう。そして、RSpecを実行するとめちゃくちゃ落ちる!!びっくりするくらい落ちる!!

テストが落ちる原因を探る

テストが落ちた原因は、まぁRailsのバージョンを上げたのだから、バージョンの差異が原因でしょう。ActiveStorageが絡んでいるところが落ちていたので、調査しました。Qiitaに変更点がまとめられている記事がありました。ありがたい。

qiita.com

ここの中で「3. アップロードしたファイルがストレージに保存されるタイミングがsave時に変更」という見出しが…。

Rails5.2までは、インスタンスのattributeに代入した時点で、アップロードしたファイルがストレージに保存されていました。 Rails6.0では、saveしたタイミングでストレージに保存されるよう変更になっています。saveトランザクションがコミットされた後に、ストレージに保存されます。

おぉ…、まさにこれが原因でした…。

なぜsave後にアップロードでテストが落ちるのか?

理由は、バリデーションのためにActiveStorageにアップロードしたファイルの中身を参照して検証していたからです。

class Message < ApplicationRecord
  has_many_attached :files

  validates :files, presence: true
  validate :check_files, if: -> { self.files.attached? }

  private

  def valid_files?
    self.files.all? do |file|
      file.blob.open do |f| # => Rails6だと、ここでopenできずに落ちる!
        # 検証する
      end
    end
  end

  def check_files
    self.errors.add(:files, :invalid) unless valid_files?
  end
end

ActiveStorage::Blobモデルが保存されていないため、ActiveStorage::FileNotFoundErrorで落ちてました。

アップロード前にファイルの内容を検証できるのか?

blobをopenしても落ちるので、何かいい方法はないものかと調べましたら、Railsのissueに有力な情報がありました。

github.com

attachment_changes['files'].attachablesを使え、とあります。(has_many_attachedならattachablesで、has_one_attachedならattachableになる)

pryを使いながら検証してみたところ、attachableの中身は、{ io: Fileオブジェクト, filename: 'ファイル名' }というHashでした。 そこで、以下のように修正。

def valid_files?
  self.attachment_changes['files'].attachables.pluck(:io).all? do |file|
    # 検証する
  end
end

これでRSpecを実行したところ…。modelのテストは通りました!修正成功です!

SystemSpecが落ちる…

しかし、テストを全部流してみたらSystemSpecが落ちました。手動で画面を操作してみたところ、確かに落ちます。しかも、先ほどの変更したところで…。 self.attachment_changes['files'].attachablesの中身がHashの配列ではなく、Stringの配列になっていました。なんでやねん…。

そもそも最初は手動テストで通ってのに、RSpecのテストが落ちるから修正したら、手動テストが通らなくなったので、もしかしたら画面からの場合はモデルの保存前でもActiveStorageにファイルがアップロードされてる?と考えてpryを仕込みながら調査したところ、ビンゴ。

2020-11-21 追記

もしかしたら画面からの場合はモデルの保存前でもActiveStorageにファイルがアップロードされてる?

これは、ActiveStorageのダイレクトアップロードしているからでした…。それなら当然ですね…。

画面からファイルをアップロードした場合

Messageモデルを保存していない状態で、blobオブジェクトの中身を見た結果。idがあるので、保存済みの証拠です。つまり、ActiveStorageにアプロード済み。(2020-11-21 追記:ダイレクトアップロードしていたからです!)

[1] pry(#<Message>)> self.files.first.blob
=> #<ActiveStorage::Blob:0x00007fb56b9479b0
 id: 320,
 key: "************************",
 filename: "test.csv",
 content_type: "text/csv",
 metadata: {},
 byte_size: 1452,
 checksum: "**************************",
 created_at: Fri, 20 Nov 2020 13:45:17 JST +09:00>
RSpecでファイルをattachした場合

テストでは、attachメソッドで付けてます。

message = FactoryBot.build(:message)
file = File.open(Rails.root.join('spec', 'files', 'test.csv'), 'r')
message.files.attach(io: file, filename: File.basename(file))

pryで中身を見た結果は、以下の通り。idnilのため、ActiveStorageにはまだアップロードされていません。

[1] pry(#<Message>)> self.files.first.blob
=> #<ActiveStorage::Blob:0x00007fdcfd305d68
 id: nil,
 key: nil,
 filename: "test.csv",
 content_type: "text/csv",
 metadata: {"identified"=>true},
 byte_size: 600,
 checksum: "**************************",
 created_at: nil>

導かれる仮説は…

2020-11-21 追記

ダイレクトアップロードしていたからだったので、この仮説は間違っていました。すみません。

  • 画面を経由せずにActiveStorageにアップロードする場合は、モデルの保存が成功したタイミングでアップロード。
  • 画面を経由してActiveStorageにアップロードする場合は、モデルの保存をする以前にアップロード。(これが間違い。ダイレクトアップロードしてたから)

となると、画面を経由しているケースを想定した処理と、経由していないケースを想定した処理を書かないといけません…。ここにすごく違和感を感じる…。

修正

とりあえず、コードは以下のようになりました。

class Message < ApplicationRecord
  has_many_attached :files

  validates :files, presence: true
  validate :check_files, if: -> { self.files.attached? }

  private

  def valid_files?
    if uploaded? # => アップロード時は今まで同様の処理
      self.files.all? do |file|
        file.blob.open do |f|
          # 検証する
        end
      end
    else
      # テストにてattachで添付時はこちら
      self.attachment_changes['files'].attachables.pluck(:io).all? do |file|
        # 検証する
      end
    end
  end

  def check_files
    self.errors.add(:files, :invalid) unless valid_files?
  end

  def uploaded?
    self.files.all? { |file| file.blob.persisted? }
  end
end

これでテストは全て通るようになりました。やった!!!

不満点

違和感を感じると書いていますが、その違和感の正体は、「これだとモデルのユニットテストでActiveStorageにファイルが保存されているケースを検証できない」という点です。attachだと保存されないから…。画面からのアップロードでも、ActiveStorageのアップロード前の状態で、デフォルトで一時ファイルへのパス情報をどっかに保存していたらいいのに…。ダイレクトアップロードでなければ一時ファイルに保存されていることを確認したので、私が間違えていました。

何か他にいい方法をご存知の方がいらっしゃいましたらコメントでもツイッターでもいいので教えてください!!

2020-11-21 追記

画面からアップロードした際に先にクラウドにアップロードされていた原因はダイレクトアップロードをしていたためでした。先にコード読めよって話ですけど、自分が作ったところじゃなかったのでテストが落ちる原因にばかり考えが巡ってしまってActiveStorageのダイレクトアップロードの存在を忘れてました。このブログを書いた後に、急にピーン!ときました。

ダイレクトアップロードではない場合、当然ながらモデルが保存されるまではアップロードされず、一時ファイルに置かれていました。attachableの内容もHashではなく、ActionDispatch::Http::UploadedFileになっていたので、これに合わせて修正すればよさそうです。

>> self.attachment_changes['files'].attachables.first
=> #<ActionDispatch::Http::UploadedFile:0x00007f9d4a54ef18
 @tempfile=#<Tempfile:/var/folders/7m/9fg43zzx58lb4715wm1dkj00yj5swh/T/RackMultipart20201121-93972-xgi34u.csv>,
 @original_filename="test.csv",
 @content_type="text/csv",
 @headers="Content-Disposition: form-data; name=\"message[files][]\"; filename=\"test.csv\"\r\nContent-Type: text/csv\r\n">

となると………

  • ダイレクトアップロードの場合のバリデーション
  • ダイレクトアップロードじゃない場合のバリデーション
  • attachメソッドを使った場合のバリデーション

の3パターンを考えないといけないわけか…。うっ、頭が…。

(これはあくまでファイルの中身をチェックして保存したい場合の話なので、普通に画像をアップロードするだけ、とかだとこんな面倒な話にはなりませんのでActiveStorageが怖いわけではありません)

2020-11-26 追記

ダイレクトアップロードじゃないケースは実装上、ないので、そこはテストで放置して、一応attachメソッドも考慮したテストを追加して対応しました。 FactoryBotのコードは、こう。

FactoryBot.define do
  factory :message do
    sequence(:content) { |n| "メッセージ#{n}" }
  end

  trait :with_direct_upload_file do
    after :build do |message|
      valid_file = File.open(Rails.root.join('spec', 'files', 'valid_file.csv'), 'r')
      blob = ActiveStorage::Blob.create_and_upload!(
          io: valid_file,
          filename: File.basename(valid_file),
          identify: false
      )
      message.files.build(blob: blob)
    end
  end

  trait :with_attach_file do
    after :build do |message|
      valid_file = File.open(Rails.root.join('spec', 'files', 'valid_file.csv'), 'r')
      message.files.attach(io: valid_file, filename: File.basename(valid_file))
    end
  end
end

そして、RSpecはこう。

RSpec.describe Message, type: :model do
  context 'ダイレクトアップロードの場合' do
    subject { FactoryBot.build(:message, :with_direct_upload_file) }
    it '保存できること' do
      # 検証する
    end
  end

  context 'attachの場合' do
    subject { FactoryBot.build(:message, :with_attach_file) }
    it '保存できること' do
      # 検証する
    end
  end
end

とりあえず、これでモデルのテストでもダイレクトアップロードを考慮したテストを行うことができました。