【フロントエンド学習記録】ActiveStorageを使ってブログ記事に画像を1枚添付できるようにする

今回は Rails と Ember.js で作っているブログアプリで記事を作成・更新する際に画像を添付できるようにしていきたいと思います。

最終的には記事をマークダウンで書いて、その中の任意の箇所に画像を挿入できるようにすることを目指していて、その前段階の実装です。

前回はトーストメッセージの実装について書きました。

maimux2x.hatenablog.com

ActiveStorageを使って画像を1枚添付できるようにする

画像添付にあたっては Rails の ActiveStorage を使っていきます。

railsguides.jp

ここでは ActiveStorage の細かな導入手順には触れません。 画像を複数枚添付できるようにすることを目指しますが、まずは1枚だけ添付できるようにすることからやっていきます。

まず、Rails 側の実装を進めていきます。 モデルとコントローラを以下のように修正します。

# app/models/post.rb

class Post < ApplicationRecord
  has_one_attached :image # 追加

  validates :title, presence: true
  validates :body, presence: true
end
# app/controllers/posts_controller.rb

# 省略

private

  def post_params
    params.expect(post: [ :title, :body, :image ]) # :image を追加
  end
end

続いて、Ember.js 側の実装を見ていきます。

ブログの新規投稿画面と更新画面に画像添付のボタンが出るようにします。 input タグに設定している action の詳細は後ほど確認します。

<!-- web/app/components/post-form.hbs -->

<form {{on "submit" @onSubmit}}>

<!-- 省略 -->
  
  <button type="submit" class="btn btn-primary me-2">{{@submitLabel}}</button>
  <!-- 以下を追記 -->
  <input type="file" id="file" name="file" multiple {{on "change" this.uploadImage}} />
</form>

これでひとまず画像添付のためのボタンが表示されるようになりました。

これだけでは Rails 側へ画像添付のリクエストを送れないため、そちらの実装も見ていきます。

www.npmjs.com

その際、npm として提供されている ActiveStorage のパッケージを利用します。ダイレクトアップロード機能を使ってEmber.jsから画像ファイルをサーバーに直接送信できるようにしていきます。 ここでちょっと困ったのがどのようにダイレクトアップロードの機能を使えばいいかが具体的に書かれたドキュメントが見つけられませんでした...。

そのため、ActiveStorage の実装を確認して、ダイレクトアップロードに必要な処理をEmber.js側で実装していきます。

// node_modules/@rails/activestorage/src/direct_upload.js

import { FileChecksum } from "./file_checksum"
import { BlobRecord } from "./blob_record"
import { BlobUpload } from "./blob_upload"

let id = 0

export class DirectUpload {
  constructor(file, url, delegate, customHeaders = {}) {
    this.id = ++id
    this.file = file
    this.url = url
    this.delegate = delegate
    this.customHeaders = customHeaders
  }

  create(callback) {
    FileChecksum.create(this.file, (error, checksum) => {
      if (error) {
        callback(error)
        return
      }

      const blob = new BlobRecord(this.file, checksum, this.url, this.customHeaders)
      notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr)

      blob.create(error => {
        if (error) {
          callback(error)
        } else {
          const upload = new BlobUpload(blob)
          notify(this.delegate, "directUploadWillStoreFileWithXHR", upload.xhr)
          upload.create(error => {
            if (error) {
              callback(error)
            } else {
              callback(null, blob.toJSON())
            }
          })
        }
      })
    })
  }
}

function notify(object, methodName, ...messages) {
  if (object && typeof object[methodName] == "function") {
    return object[methodName](...messages)
  }
}

ダイレクトアップロードをしたいため、node_modules/@rails/activestorage/src/direct_upload.js の実装内容を見ていきます。 まず、Ember.js 側で DirectUpload オブジェクトを作成するために最低限必要な引数を確認します。 constructor の実装を見てみると customHeaders = {} は省略可能で、file, url, delegate を引数として指定する必要があることが分かります。delegate について簡単に調べてみると、これを使用するとアップロードの際の進捗や独自のエラーハンドリングなどをフックすることが可能なようです。今回はそこまでは求めないため、{} を引数にして省略することにします。Ember.js側で考慮しなければいけない引数はfile, urlの2つになりそうです。

constructor(file, url, delegate, customHeaders = {}) {
    this.id = ++id
    this.file = file
    this.url = url
    this.delegate = delegate
    this.customHeaders = customHeaders
  }

また、node_modules/@rails/activestorage/src/direct_upload.js の実装から作成した DirectUpload オブジェクトに対して、createerrorを第1引数、blobを第2引数にもつコールバック関数を渡して呼び出せば良さそうなことが分かりました。

これらを考慮して Ember.js 側で画像添付をする際の action を実装していきます。

// app/components/post-form.js

import Component from '@glimmer/component';
import { DirectUpload } from '@rails/activestorage/src/direct_upload';
import { action } from '@ember/object';

export default class PostFormComponent extends Component {
  @action
  uploadImage(e) {
    const upload = new DirectUpload(
      e.target.files[0],
      'http://localhost:3000/rails/active_storage/direct_uploads',
      {},
    );

    upload.create((error, blob) => {
      this.args.post.image = blob.signed_id;
    });
  }
}

DirectUpload オブジェクトの作成部分を見ていきます。先ほど調べた通り、file, urlを指定して、delegate は省略するようにしています。

fileを指している e.target.files の実態は FileList オブジェクトです。HTMLの <input type="file"> 要素でファイルが選択されると、イベントオブジェクト ee.target.files プロパティには FileList オブジェクトが格納されます。

console.log(e.target.files) を仕込んでブラウザーの開発者ツールで確認してみます。

FileList オブジェクトは配列のように扱うことが可能なため、 e.target.files[0] とすることで選択されたファイルにアクセスすることができます。

url に指定しているのはActive Storageが提供するアップロード用のエンドポイントです。※とりあえず localhost:3000 をベタ書きしています。

そして先ほど確認したcreate の呼び出し部分で、this.args.post.imagblob.signed_id をセットします。こうすることで、フォーム送信時に image パラメータとしてこの signed_id が送信され、Rails側で has_one_attached :image に添付されるようになります。

image パラメータはブログ記事の新規作成と更新時に一緒に送られるように修正します。

// web/app/controllers/admin/posts/new.js

import { action } from '@ember/object';
import { service } from '@ember/service';
export default class AdminPostsNewController extends Controller {
  @service router;
  @service toast;
  @service session;
  @action
  async createPost(event) {
    event.preventDefault();
    const response = await fetch('http://localhost:3000/posts', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${this.session.token}`,
      },
      body: JSON.stringify({
        post: {
          title: this.model.title,
          body: this.model.body,
          image: this.model.image, // 追加
        },
      }),
    });

これで Ember.js から Rails に対して画像の情報を一緒に送ることができるようになりました!

最後に画像の表示について見ていきます。

まず、Rails から画像の表示に必要な情報(URL)を提供するように修正します。

// app/views/posts/_post.json.jb

{
  id:         post.id,
  created_at: post.created_at,
  title:      post.title,
  body:       post.body,
  image_url:  post.image.attached? ? rails_blob_url(post.image) : nil
}

画像は添付されていない場合もあるため三項演算子で添付されている場合だけ、rails_blob_url ヘルパーでURLを作成しています。

rails_blob_url ヘルパーで作成される URL は rails c でコンソールからも確認できます。

> blob = ActiveStorage::Blob.last
> include Rails.application.routes.url_helpers
> rails_blob_path(blob, only_path: true)
=> "/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6ODQsInB1ciI6ImJsb2JfaWQifX0=--85409ae096792a2a9aa26383c7dc0d2dbc861d46/food_konbini_onigiri.png"
> blob.signed_id
=> "eyJfcmFpbHMiOnsiZGF0YSI6ODQsInB1ciI6ImJsb2JfaWQifX0=--85409ae096792a2a9aa26383c7dc0d2dbc861d46"

ここまで何度か signed_id というものが登場しましたが、これはActiveStorageのBlobのidを暗号化したものです。

また、余談になりますがブラウザから画像を選択した時点で ActiveStorage::Blob は作成されるため、保存・更新前の段階でも rails c でコンソールから確認が可能です。 今回のようにフロント側が Rails ではない場合にちゃんと ActiveStorage::Blob が作成されているかを段階を追って確認できます。

説明が長くなってしまいましたが、あとは image_url を使って画像を表示させるだけです。

<!-- web/app/components/post.hbs -->

<div class="body card-text">
    {{@post.body}}
    {{#if @post.image_url}}
      <img src={{@post.image_url}} alt="">
    {{/if}}
</div>

まとめ

Ember.js と Rails で ActiveStorage を使って画像を1枚添付するやり方を見てきました。

実装をしてみて、direct_upload.js の内容を確認した部分が面白くて勉強になりました。ActiveStorage への理解もだいぶ深まったと思います。

次は第2弾として ActiveStorage で複数枚の画像を添付できるようにする部分をまとめたいと思います。