【フロントエンド学習記録】Ember.js でトーストメッセージを出す

今回はEmber.jsでトーストメッセージを出す部分を見ていきます。

前回の記事はこちらです。

maimux2x.hatenablog.com

トーストメッセージの実装では当初、Rails のように画面遷移をベースに考えてしまってパラメータをトリガーにするなどちょっと迷走したりしました...。

Rails の場合、画面遷移時(リダイレクト時)に flash メッセージを出すことが簡単に対応できると思います。

しかし、Ember.js などの SPA を実現するフレームワークでは画面遷移がありません。

つまり、Railsflash がサーバーからクライアントへの通知を実現しているのに対して、クライアントだけで完結しているということであり、トーストメッセージ自体が一つの「状態」であると捉える必要がありました。

Ember.js での実装

トーストメッセージを出したい操作は以下になります。

  • Admin 画面へのログイン・ログアウト時
  • ブログ記事の新規作成成功時
  • ブログ記事の更新成功時
  • ブログ記事の削除成功時

ブログ記事の投稿・更新に関連するエラーはトーストではなく、各フォーム下部に表示させる形で実装したため、今回は想定していません。

トースト自体は Bootstrap のトーストプラグインを利用する形で進めます。

getbootstrap.jp

実装を進めるにあたって、トーストを出すタイミングやトーストで何を出すかなどは実装を一まとめにして使いたい場所で呼び出せた方が便利です。

まず、「呼び出し」を共通化するために service に呼び出し時に使用する関数を定義します。 service は Ember.js においてアプリケーション全体や複数のコンポーネント・コントローラーから共通して利用されるオブジェクトを定義することができます。

// web/app/services/toast.js

import Service from '@ember/service';
import { getOwner } from '@ember/owner';

export default class ToastService extends Service {
  show(body, bgColor) {
    const controller = getOwner(this).lookup('controller:application');

    controller.showToast({ body, bgColor });
  }
}

この部分はトーストの呼び出しAPIを提供すると考えると分かりやすそうです。 つまり、「何を出すか」だけを service は知っている状態になっています。

ここでポイントとなるのが上記コードの

const controller = getOwner(this).lookup('controller:application');

です。 getOwner(this)thisToastService インスタンスです。ToastService は「何を出すか」だけを知っていて「どう出すか」は知りません。詳細はこのあと触れるのですが「どう出すか」の部分は web/app/controllers/application.js に詳細を定義します。つまり ToastServiceApplicationController に依存していて、getOwner(this).lookup('controller:application') の部分で明示的に「どう出すか」を管理しているコントローラを見つけ出すようにしています。

web/app/controllers/application.js を見ていく前に、service を呼び出している部分を見ていきたいと思います。

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

import Controller from '@ember/controller';
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,
          tag_names: this.model.tags.split(','),
        },
      }),
    });

    if (!response.ok) {
      const json = await response.json();
      this.model.errors = json.errors;
    } else {
      this.router.transitionTo('admin.posts');

      this.toast.show('Post created successfully', 'success');
    }
  }
}

トースト呼び出しを実行しているのは一番最後の this.toast.show('Post created successfully', 'success'); の部分ですね。POST リクエストのレスポンスが ok だった場合にトーストが表示されるようになっています。

続いて、トーストを「どう出すか」の役割を担っている web/app/controllers/application.js の内容を見ていきたいと思います。

// web/app/controllers/application.js

import Controller from '@ember/controller';
import { action } from '@ember/object';
import { modifier } from 'ember-modifier';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

import { Toast } from 'bootstrap';
import { scheduleTask } from 'ember-lifeline';

export default class ApplicationController extends Controller {
  @service toast;

  @tracked toastData = [];

  toasts = new Map();

  setToast = modifier((el) => {
    const toast = new Toast(el);

    this.toasts.set(el, toast);

    const handler = () => {
      this.toasts.delete(el);
      this.toastData = this.toastData.filter(({ id }) => id !== el.id);
    };

    el.addEventListener('hidden.bs.toast', handler);

    return () => {
      el.removeEventListener('hidden.bs.toast', handler);
    };
  });

  @action
  showToast(data) {
    const id = crypto.randomUUID();

    this.toastData = [...this.toastData, { id, ...data }];

    scheduleTask(this, 'render', () => {
      const entry = [...this.toasts].find(([el]) => el.id === id);

      if (!entry) return;

      const [, toast] = entry;

      toast.show();
    });
  }
}

まず、setToast = modifier() の部分を見ていきます。modifier とは、要素(DOM)に対して、ライフサイクルに応じた特定の動作や振る舞いを紐づけるための仕組みのことです。

この setToast = modifier() は、画面上に表示される各トースト要素に対して、Bootstrapのトースト動作を紐付けし、ライフサイクル管理(初期化・監視・後始末)を行う役割を持っています。

具体的には、

  • ターゲットとする要素(DOM)が出現した時に、new Toast(el) でトーストインスタンスを生成し、内部の toasts Map に保存する -トーストが閉じられたタイミング(hidden.bs.toast イベント)で、そのトーストインスタンスと対応する表示データ(toastData)を削除し、画面と内部状態の両方を最新化する
  • ターゲットとする要素(DOM)が削除されるときは、イベントリスナーを解除してクリーンアップする

という流れを管理しています。

続いて showToast アクションを見ていきます。この部分はトーストを画面上に表示するために、this.toastData の配列に対して表示させるトーストデータを登録して、タイミングを管理してDOM描画が完了した後にトーストを表示させる役割を持っています。このアクションは 一番初めに実装を確認した service で呼び出す形で使用されています。

詳細を順番に見ていくと、各トーストを一意に識別できるようにするために以下で一位なIDを生成しています。

const id = crypto.randomUUID();

toastData 配列に新しいトースト情報(メッセージ・トーストの背景色など)を追加している部分です。 テンプレート側でこのデータをもとにトースト用のHTMLが描画されます。

this.toastData = [...this.toastData, { id, ...data }];

ここでは Ember.js の scheduleTask(this, 'render', callback) を使ってDOM描画が完了した上でtoast.show() を実行するようにしています。 こうしないとDOM描画が完了前に toast.show() が実行されてしまう可能性があり、適切にトーストが表示されないことがあります。

scheduleTask(this, 'render', () => {
  const entry = [...this.toasts].find(([el]) => el.id === id);
  if (!entry) return;
  const [, toast] = entry;
  toast.show();
});

最後にトーストを表示するためのテンプレートの実装を見ていきます。

<div class="toast-container position-fixed top-0 end-0 p-3">
  {{#each this.toastData as |toast|}}
    <div
      id={{toast.id}}
      class="toast align-items-center text-bg-{{toast.bgColor}} border-0"
      data-bs-delay="2000"
      role="alert"
      aria-live="assertive"
      aria-atomic="true"
      {{this.setToast}}
    >
      <div class="d-flex">
        <div class="toast-body">
          {{toast.body}}
        </div>
        <button
          type="button"
          class="btn-close btn-close-white me-2 m-auto"
          data-bs-dismiss="toast"
          aria-label="Close"
        ></button>
      </div>
    </div>
  {{/each}}
</div>

ここはBootstrapのトーストコンポーネントをベースに application.js で定義した toastData のデータを表示させる部分です。this.setToast を呼び出しているdivapplication.jssetToast = modifier((el) => {} がターゲットにしている el に該当します。 toastData は配列で複数のトーストが出ることを想定して(現状そのパターンはないけど・・・) #each で実行されています。

まとめ

ここまで実装を見てきたのですが、トーストが表示されるまでの流れを改めてまとめておきたいと思います。

  1. web/app/controllers/admin/posts/new.js から this.toast.show(...) が呼び出される ↓
  2. ApplicationController の showToast アクションが発火 ↓
  3. 配列 toastData にトーストデータが追加され、DOM に divレンダリングされる ↓
  4. setToast モディファイアが発動し、 new Toast(el) でBootstrapのトーストインスタンスを生成 ↓
  5. scheduleTaskで DOM レンダリング完了後、toast.show() を実行 ↓
  6. トーストが実際に表示される

「トースト」を表示するという要件一つに対して考えるべき点がたくさんあって、なかなかに難しかったです。 ですが、設計やDOM描画のタイミングについてなど勉強になる部分がたくさんありました。

引き続きフロントエンドの学習も頑張りたいと思います!

【フロントエンド学習記録】Rails と Ember.js で全文検索機能を実装

引き続き Rails と Ember.js でブログアプリを作りながらフロントエンドの開発を学習中です。

maimux2x.hatenablog.com

今回は全文検索機能を実装していきます。

対象が学習用のブログアプリになるので、一番シンプルにできる方法で進めていきます。

Rails 側の実装

gem は使用せず、where を使って検索を行う形をとりました。

def index
    @posts = @posts.where("title LIKE :query OR body LIKE :query", query: "%#{ActiveRecord::Base.sanitize_sql_like(params[:query])}%") if params[:query]
  end

where を使うことで上記のように LIKE 検索でシンプルに実装ができます。

ただこの実装には注意点があります。

まず、LIKE '%keyword%' での検索は前方一致でないため、インデックスが効かず全件スキャンされます。そのため、大量のデータを扱うような場合は適切ではないと言えます。

もう一つは LIKE 演算子を使う場合、位置指定ハンドラや名前付きハンドラを使って条件を組み立てるだけではサニタイズが行われない点です。

Posts.where("title LIKE :query OR body LIKE :query", query: "%#{params[:query]}%"})

上記のようにコードを書いてしまうとサニタイズが行われず、SQLインジェクションのリスクがあります。

最初のコード例にあるように ActiveRecord::Base.sanitize_sql_like を使って適切にサニタイズが行われるようにします。

Ember.js 側の実装

Bootstrap のフォームコントロールコンポーネントを使って検索フォームを作成します。

getbootstrap.jp

// web/app/templates/index.hbs

<div class="mb-3">
  <form class="d-flex" role="search" {{on "submit" this.search}}>
    <Input @value={{this._query}} class="form-control me-2" placeholder="Search" aria-labelledby="button-search" />
    <button class="btn btn-outline-success me-2" type="submit">Search</button>
    <button class="btn btn-outline-secondary" type="button" {{on "click" this.cancel}}>Cancel</button>
  </form>
</div>

上記 template 内では Ember.js の Controller で用意した検索用の search アクションと cancel アクションを呼び出しています。 また、Input コンポーネントでは @value={{this._query}} の部分で入力された文字列を Controller 側に同期してしています。

対応する Controller の実装は以下のようになりました。

// web/app/controllers/index.js

import Controller from '@ember/controller';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { runTask } from 'ember-lifeline';

export default class IndexController extends Controller {
  queryParams = [
    {
      page: { type: 'number' },
      query: { type: 'string' },
    },
  ];

  @service session;
  @service router;

  @tracked page = 1;
  @tracked query = '';
  @tracked _query = '';

  @action
  search(e) {
    e.preventDefault();
    this.query = this._query;
    this.page = 1;

    runTask(this, () => {
      this.router.refresh();
    });
  }

  @action
  cancel() {
    this.query = '';
    this._query = '';
    this.page = 1;

    runTask(this, () => {
      this.router.refresh();
    });
  }
}

まず queryParamsquery を追加してパラメータとして使えるようにします。

パラメータの query と分けて _query@tracked に指定をしているのは理由があります。

_query は template 側で @value={{this._query}} として利用されているのですが、この部分は検索フォームに入力された値をバインドしていて入力があるたびに更新がされます。 @value={{this._query}} の部分を query としてそのまま使ってしまうと入力があるたびにリアルタイムでURLのパラメータに反映がされてしまうため、入力された値を一時的に保持する内部変数として _query を用意し、検索ボタンが押されて search アクションが実行されるタイミングで _querythis.query に代入することでURLにパラメータが付与されるようにしています。

また、HTMLの form タグは submit イベントが発火するとリクエストが発生してページがリロードされるデフォルトの動作があります。 検索フォームに入力された文字を保持して検索結果を適切に描画するために search アクションでは e.preventDefault(); を指定しています。

this.page = 1; をアクション内で指定しているのは検索ボタンを押した際に1ページ目として描画するためです。 これを指定しないとページネーションで2ページ目以降にいた際にパラメータにページ番号が反映されたままリクエストが飛ぶため検索結果が適切に描画されなくなってしまいます。

runTask の部分については、 tracked で追跡している値の状態が変わるよう場合、状態が変わる前に次の処理が走ってしまい、うまく描画されない可能性を防いでいます。

runTaskember-lifeline というライブラリの API です。

github.com

後はパラメータを Rails 側にリクエストする URL にセットして一緒に送ることで全文検索が実行されます。

// web/app/routes/index.js

import Route from '@ember/routing/route';
export default class IndexRoute extends Route {
  queryParams = {
    page: {
      refreshModel: true,
    },
  };
  async model(args) {
    const url = new URL('http://localhost:3000/posts');
    url.searchParams.set('page', args.page);
    url.searchParams.set('query', args.query);

    return await fetch(url).then((res) => res.json());
  }

まとめ

今回は Rails 側で where を使う意図や注意点が大切なポイントだったなと思います。 Ember 側は SPA ならではの気をつけるべき点を学ぶことができました。 また、Bootstrap も使っているとやっぱり便利だなと思います。

完全に自分の理解を整理するためを目的としているフロントエンド学習記録ですが、もうしばらく続きそうです。

RubyKaigi 2025 に参加した

2年ぶり2回目のRubyKaigi参加でした。

2年前の松本の RubyKaigi に参加した時、自分はまだエンジニアとして働いていなくて今回がエンジニアとして働き始めて初めての参加となりました。

maimux2x.hatenablog.com

前回のブログに

分かることを聞くのではなく、分からないことだらけなんだな~という気持ちを受け止める前提で参加しました。

と書いていて、2年経った今回もこの前提はあまり変わらずです。 今回新たな発見だったのは「分からないことだらけ」の濃い3日間を過ごしたことで、この2年の間に自分の思考が凝り固まってきていたのに気がついたことでした。

あれこれ考えすぎず、もっと自由に好きなように Ruby もプログラミングも楽しんだらいいんだなと初心に帰った感じ。

聞いた発表

会期中は発表を聞くことをメインに、休憩時間に本屋さんを見にいったり Rubyist 同士で雑談したりしながら過ごしていました。

Day 1

  • Ruby Taught Me About Encoding Under the Hood
  • Compatible Using Automata Learning
  • Continuation is to be continued
  • Empowering Developers with HTML-Aware ERB Tooling
  • Ruby's Line Breaks
  • State of Namespace
  • dRuby on Browsere Again!
  • TRICK 2025: Episode 1

Day 2

  • Performance Bugs and Low-level Ruby Observability APIs
  • Benchmark and profile every single change
  • RuboCop:Modularity and AST Insights
  • Speeding up Class#new
  • The Omplementations of Advanced LR Parser Algorithm
  • Lightning Talks

Day 3

  • Ruby Comitters and the World
  • Running ruby.wasm on Pure Ruby Wasm Runtime
  • Eliminating Unnecessary Implicit Allocations
  • Toward Ractor local GC
  • Poring PicoRuby to Another Microcontroller:ESP32
  • On-the-fly Suggestions of Rewriting Method Deprecations
  • Matz Keynote

購入した本

会期全体をふりかえって

RubyKaigi に参加して、コンピューターサイエンス寄りの基本的な内容をちゃんと勉強しておきたい気持ちが強くなりました。

2月に基本情報を受けたので、次は応用情報の合格を目指しつつ勉強に取り組もうと思っています。

また、普段東京では会えない Ruby コミュニティの方達と再会できたこと、しんめ.rb でランチ会を開催して初めてオフラインで色々話すことが出来たのがすごく嬉しかったです。

最近、自分が主催しているしんめ.rb以外の地域Rubyのイベントにあまり行けていなかったので、今年はまた色々参加していきたいなとも思いました。

RubyKaigi 2025 が終わってしまって寂しい気持ちもありつつ、来年に向けて1年間やっていくぞ!!の気持ちをもらうことができました。

RubyKaigi 2025 のオーガナイザーとスタッフの皆さん、ありがとうございました! また、会期中交流していただいた Rubyist の皆さんもありがとうございました!

【フロントエンド学習記録】Rails と Ember.js でページネーションを実装

こちらのブログ記事の続きです。 今回はRails と Ember.js でページネーションを実装していきます。

maimux2x.hatenablog.com

kaminari を利用する

Rails でページネーションを実装する場合、 kaminari や pagy などの gem がありますが、今回は kaminari を使いました。

github.com

github.com

kaminari の導入方法については省略します。

Rails だけで View も実装している場合はページネーションの導入はスムーズに対応しやすいですが、フロントエンドが別のフレームワークだと Rails のみの場合よりも考える点が増えます。

まず、ページネーションをつけたい画面が全何ページになるのかという情報をフロントエンド側は知る必要があります。

kaminari にはページの情報を取得するメソッドが多数用意されていて、total_pages メソッドを使うとよさそうです。

# kaminari の README より

User.count                     #=> 1000
User.page(1).limit_value       #=> 20
User.page(1).total_pages       #=> 50
User.page(1).current_page      #=> 1
User.page(1).next_page         #=> 2
User.page(2).prev_page         #=> 1
User.page(1).first_page?       #=> true
User.page(50).last_page?       #=> true
User.page(100).out_of_range?   #=> true

フロントで使用している Ember.js は JSON を受け取って画面を描画するため、Rails 側では JSON を返すための API を用意しています。

JSON を構築するために jb という gem を使っています。

github.com

以下のようにして Ember.js で 全ページ数の情報を受け取れるようにします。

# app/controllers/posts_controller.rb

def index
    @posts = Post.order(created_at: :desc).page(params[:page])
  end
# app/views/posts/index.json.jb

{
  posts: render(partial: "post", collection: @posts),
  total_pages: @posts.total_pages
}

フロント側は Bootstrap を利用してページネーションを表示するようにしていきます。

getbootstrap.jp

Bootstrap のページネーションコンポーネントを使用して、Ember.jsでもコンポーネントとしてページネーションを使いまわせるようにしていきます。

以下はその一部です。

// web/app/components/pagination.hbs

<li class="page-item {{unless this.next 'disabled'}}">
  {{#if this.next}}
    <LinkTo @route={{@route}} @query={{hash page=@last}} class="page-link" aria-label="Next">
      <span aria-hidden="true">&raquo;</span>
    </LinkTo>
  {{else}}
    <span class="page-link" aria-hidden="true">&raquo;</span>
  {{/if}}
</li>
  • {{unless this.next 'disabled'}} は最後のページの場合を制御している
  • Ember.js が用意している LinkTo コンポーネントにルートとしてURLの情報とパラメータとしてページ番号を付与している

よく見るこんな感じのページネーションです。

コンポーネントは以下のようにテンプレート側で使用します。 ここで Rails から受け取った全ページ数の情報を最後のページ番号として利用しています。

// web/app/templates/admin/posts/index.hbs
<Pagination @route="admin.posts" @current={{this.page}} @last={{@model.total_pages}} />

続いてコンポーネントとセットになる JavaScript を用意していきます。

// web/app/components/pagination.js

import Component from '@glimmer/component';

export default class PaginationComponent extends Component {
  get pages() {
    return Array.from({ length: this.args.last }, (v, i) => i + 1);
  }

  get prev() {
    const { current } = this.args;
    return current === 1 ? undefined : current - 1;
  }

  get next() {
    const { current, last } = this.args;
    return current === last ? undefined : current + 1;
  }
}
  • Array.from で最後のページまでの連番を生成しています。
  • currentPagination コンポーネント@current で渡される情報
    • 最初のページか最後のページ下で前後のページを計算するかページが存在しないかを制御する

developer.mozilla.org

ここで少しハマったのが this.args ではページ数の情報にアクセスできますが、データ型がデフォルトだと string なため integer として扱えるようにする必要があります。

Ember.js のテンプレートと対になるコントローラーでデータ型を制御する必要がありました。

// web/app/controllers/index.js

export default class IndexController extends Controller {
  queryParams = [
    {
      page: { type: 'number' },
    },
  ];

  @tracked page = 1;
}

また、@tracked でページ番号の変更が Ember.js 側で認識されるようにすることで、ページ番号をクリックする際 <<, < 記号部分のdsiabled の制御が適切に実行されるようになります。

ここまででページネーションの表示自体と URL へパラメータとしてページ番号を付与する部分は完了です。 ですが、このままだとページ番号を押しても動きません。

パラメータとともに Rails 側へリクエストを送る必要があるため、その部分を実装していきます。

// web/app/routes/admin/posts/index.js

import Route from '@ember/routing/route';

export default class IndexRoute extends Route {
  async model(args) {
    const url = new URL('http://localhost:3000/posts');
    url.searchParams.set('page', args.page);

    return await fetch(url).then((res) => res.json());
  }
}

URLsearchParams.set() メソッドでパラメータを付与して fetch できるようにします。

developer.mozilla.org

developer.mozilla.org

ただ、これだけでも実はまだ実装が足りず、LinkTo コンポーネントを使ってパラメータを変化させてページを再描画させたい場合は以下を追加する必要があります。

// web/app/routes/admin/posts/index.js

import Route from '@ember/routing/route';

export default class IndexRoute extends Route {
// refreshModel を追記
  queryParams = {
    page: {
      refreshModel: true,
    },
  };

  async model(args) {
    const url = new URL('http://localhost:3000/posts');
    url.searchParams.set('page', args.page);

    return await fetch(url).then((res) => res.json());
  }
}

これでページネーションが動作するようになります。

まとめ

Rails のみでページネーションを導入する際は gem を入れて、その gem が提供する API を利用することで、あまり深く考えずにページネーションを実装することができていたことに気づかされました。

今回はこの部分に気がつくことができたのが一番の学びかなと思います。

JavScript を書くことにも少し慣れてきたような?

TokyoWomen.rbで発表した

speakerdeck.com

3月1日に開催された TokyoWomen.rb #1 で「Rails 1.0 のコードで学ぶ find_by_* と method_missing の仕組み」というタイトルで発表をしました。

去年、私が主催しているしんめ.rb で Rails 1.0 のコードリーディング会をやった際に、1系の頃の Railswherefind_by がなかったことを知り、find の役割が広かったこと、そこから find_by_*method_missing のことを知って面白いな〜と思っていたため、TokyoWomen.rb #1 のテーマであった「Ruby っておもしろい!」に合いそうだなと思ってプロポーザルを出してみたのでした。

実はプロポーザルを出して技術テーマで発表をするのは初めてでした。 ※申込制のLTは除いてます。

スライドを作成する際は、1系の頃の Rails と今の Railsfind メソッドの使い方の違いを紹介して、そこへの疑問を挙げた上で、 find_by_* と method_missing の話に繋がるようにしてみました。 発表後に、分かりやすかった!・おもしろかった!などたくさん感想をいただくことができてとても嬉しかったです。 他の皆さんの発表も仕組み系から現場での実践系まで内容が多岐に渡っていて、懇親会で感想を伝え合ったり、とても楽しく充実した1日を過ごすことができました。

オーガナイザーである大倉さんがTokyoWomen.rbの開催にあたってブログ記事を書かれていますが、女性の登壇者数の件など現実の課題は様々あることを自分も感じつつ、今回の TokyoWomen.rb は Ruby コミュニティにおける一つのイベントとしてとても楽しかったので、この「楽しかった」という気持ちと開催をしてくれた主催者の方々への「感謝」の気持ちを忘れずにいたいと思います。

okuramasafumi.hatenablog.jp

また、私自身がコミュニティを主催しているため、イベントの参加そのものや発表することへのハードルを感じている方がいたらまずは一緒に楽しもう〜!というのを伝えられたらなぁと思っています。

TokyoWomen.rbではオンライン上でしか話したことがなかった方と実際に会って話したりもできて、コミュニティの輪が広がったことも参加して良かったと感じていることの一つです。 RubyKaigi で再会できそうな方もたくさんいると思われるため、今から楽しみです〜!

【フロントエンド学習記録】RailsとEmber.jsでログイン機能の実装

前置き

最近、フロントエンドの学習を頑張っています。

仕事ではサーバーサイドの開発をすることのほうがたまたま多かったこともあって、元々苦手だった JavaScript の学習とフロントエンドの開発に距離ができてしまっていました。

3月から少しずつ仕事でもフロントエンドに触れる機会が出てきて、どのようにキャッチアップしていくか迷っていたのですが、RailsAPIモードとフロントエンドのフレームワークを組み合わせてブログアプリを作りながら必要な知識を学んでいく形で取り組んでいます。

今回、フロントエンドのフレームワークは Ember.js を使っていますが、フレームワーク固有の知識だけでなくフロントエンド開発のベースとなる部分の知識を理解しながら取り組むことを意識してやっているため、ブログに学習記録をまとめることにしてみました。

前提として、フロントエンド開発歴が浅めの私が学んでいく過程の記録であり、実際の業務アプリの開発ではもっと応用しないといけない内容が多く含まれることになると思います。

ログイン機能の実装

要件

  • ユーザーはメールアドレスとパスワードでログインができる
  • ログインしていないユーザーはブログ一覧と詳細画面のみアクセスできる
  • ログインしているユーザーは管理画面からブログの投稿・更新・削除ができる
  • ログアウトすることができる

アクセストークンを利用する

今回、RailsAPI モードでフロントエンドはフレームワークが異なるため、セッションベースの認証ではなくアクセストークンを利用して認証します。 そのために JWT を使いました。

Rails で JWT の仕組みを使いたい場合、ruby-jwt という gem があるため、これを利用してアクセストークンを発行できるようにします。

github.com

JWT について簡単にまとめておくと「JSON Web Token」の略でJSON形式のトークンを用いて、クライアントとサーバー間で認証や情報交換を行うための仕組みです。

JWTが発行するトークンは以下の3つに分かれていて

<ヘッダ>.<ペイロード>.<署名>

例を挙げるとこんな感じです。

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.q39x-wtLH11PSL9XE6F_HbvK81DbSOP4ipCUdKFP0lz

それぞれの詳細を以下にまとめてみました。

ヘッダ

トークンのタイプや使用されている署名アルゴリズムの情報を持っている。

eyJhbGciOiJIUzI1NiJ9
{"alg": "HS256",  "typ": "JWT"}

ペイロード

任意の情報を指定することが可能。 この部分にユーザー認証に必要な情報を入れる。

eyJ1c2VyX2lkIjoxfQ
{"user_id"=>1}

署名

署名情報が入っている。 トークンが変更されていないか確認するために使用される。

q39x-wtLH11PSL9XE6F_HbvK81DbSOP4ipCUdKFP0lz

この情報が漏れると誰でもトークンの発行と検証ができてしまうため、Rails.application.secret_key_baseを使う。

ruby-jwt の使い方

署名アルゴリズムはデフォルトで HS256 が指定されているため、ペイロードと署名部分を設定することでトークンの発行が可能です。

# JWT.encode メソッドを使ってトークンを発行する
 JWT.encode(payload, secret_key)
 
 # JWT.decode メソッドを使って発行したトークンを確認する
 JWT.decode(token, secret_key)
 
 # 不正なトークンや署名情報が指定されると例外が発生する
 > JWT.decode("abc", "secret")
 (ember-blog):1:in `<main>': Not enough or too many segments (JWT::DecodeError)

実装内容

まず、メールアドレスとパスワードでユーザーを認証してアクセストークンを発行する部分です。

# app/controllers/tokens_controller.rb

class TokensController < ApplicationController
  rescue_from ActiveRecord::RecordInvalid do |e|
    render json: { errors: e.record.errors }, status: :unprocessable_content
  end

  def create
    user = User.find_by(email: params[:email])

    if user&.authenticate(params[:password])
      data  = { user_id: user.id }
      token = JWT.encode(data, Rails.application.secret_key_base)

      render json: { token: }, status: :ok
    else
      head :unauthorized
    end
  end
end
  • create アクションで、送られてきたメールアドレスでユーザーを検索
  • ユーザーが見つかった場合は、送られてきたパスワードによる認証を authenticate メソッドで行う。
    • authenticate メソッドは、 User クラスにhas_secure_password と記述した際に自動で追加される。
  • 引数で受け取ったパスワードをハッシュ化して、その結果が User オブジェクト内部に保存されている digest と一致するかを調べる
  • メールアドレスに対応するユーザーが存在しなかった時のために authenticate メソッドの呼び出しではぼっち演算子を使う。
  • メールアドレスとパスワードで認証できた場合、 JWT のアクセストークンを発行する

続いて、フロントエンドからのリクエストに対して、認証されているユーザーかをアクセストークンを用いてチェックできるようにします。

# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  private

  def verify_token
    auth_header = request.headers["Authorization"]

    unless auth_header
      return head :unauthorized
    end

    token = auth_header.split(" ").last

    begin
      JWT.decode(token, Rails.application.secret_key_base)
    rescue JWT::DecodeError
      head :forbidden
    end
  end
end

auth_header

"Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.q39x-wtLH11PSL9XE6F_HbvK81DbSOP4ipCUdKFP0lz"

のようになっているため .split(" ").last をして token 変数に格納しています。

ruby-jwt の使い方」で見た通り、不正なトークンや署名情報が指定されると例外が発生するため、例外処理で対応しています。

verify_token メソッドは以下のように使います。

# app/controllers/posts_controller.rb

class PostsController < ApplicationController
  before_action :verify_token, only: [ :create, :update, :destroy ]

次はEmber.js側での実装です。 Ember.js側ではユーザーが認証されているかどうかについて、Rails側で発行されたアクセストークンで判断します。

ユーザーのログイン状態のような持続的な状態を扱う場合は service/以下にアプリケーション全体で再利用可能なロジックや状態を保持するためのオブジェクトとして定義します。

Rails 側で発行されたアクセストークンをローカルストレージに保存・取得・削除するための関数を定義して呼び出せるようにします。

// web/app/services/session.js


  storeToken(token) {
    this.token = token;
    localStorage.setItem('token', token);
  }

  restoreToken() {
    this.token = localStorage.getItem('token');
  }

  deleteToken() {
    this.token = null;
    localStorage.removeItem('token');
  }

  get isLogedIn() {
    return !!this.token;
  }

これを例えばログインする際に以下のように利用します。

// web/app/controllers/login.js

import Controller from '@ember/controller';
import { action } from '@ember/object';
import { service } from '@ember/service'; // Service を import する

export default class LoginController extends Controller {
  @service router;
  @service session;

  @action
  async createToken(event) {
    event.preventDefault();

    const response = await fetch('http://localhost:3000/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },

      body: JSON.stringify({
        email: this.model.email,
        password: this.model.password,
      }),
    });

    if (!response.ok) {
      this.model.error = 'Login failed';
    } else {
      const json = await response.json();
      this.session.storeToken(json.token); // アクセストークンをローカルストレージに保存

      this.router.transitionTo('admin.posts');
    }
  }
}

メールアドレスとパスワードでログインを実行し、レスポンスがOKだった場合にローカルストレージにアクセストークンを保存しています。

続いて web/app/routes/application.jsにページリロードや直リンクでもローカルストレージに保存されたトークンを読み込んで this.session.token に復元されるようにします。

// web/app/routes/application.js

import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class ApplicationRoute extends Route {
  @service session;

  beforeModel() {
    this.session.restoreToken();
  }
}

その上で認証が必要な API リクエストにおいて this.session.token をヘッダに付与することで、再ログイン不要でセッションを継続できます。

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,
        },
      }),
    });

ログイン済みのユーザーのみアクセスできる画面は以下のようにアクセストークンが存在しているかをチェックして制御します。

// web/app/routes/admin.js

import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class AdminRoute extends Route {
  @service router;
  @service session;

  beforeModel() {
    if (!this.session.isLogedIn) {
      this.router.transitionTo('index');
    }
  }
}

最後にログアウトする際は、アクセストークンを削除することで対応します。

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

@action
logout() {
    this.session.deleteToken();

    this.router.transitionTo('login');

まとめ

RailsとEmber.jsでログイン機能の実装を通してSPAにおける基本的な トークンベース認証の流れをまとめてみました。

セキュリティ面を考えると十分な実装とは言えませんが、基礎となる考え方はこの実装を通じて学ぶことができたと感じています。

このブログではロジック部分を中心にまとめましたが、HTMLのマークアップもかなり苦手で JavaScript 同様に学び中です・・・!

ブログアプリを作ってみることを通じてフロントエンドの学びだけでなく、これまで自分が主に取り組んでいたRailsにおける開発にも新たな学びを得ることができているため、今後しばらく気がむく限り学んだ内容をブログに書いていこうと思います。

クラス変数の参照の仕組みって難しい

去年の2月にRuby技術者認定試験Goldを受けたのですが、受験時は実は理解が微妙で、ある程度時間が経過してから少しずつ理解がついてくるということがちょくちょくあって面白いなと感じている今日この頃。

Ruby Goldの試験対策をしていた当時、どうしても分からなかったのがクラス変数の参照の仕組みでした。

class C
  @@val = 1
end

class C1 < C
end

p C.class_variables
p C1.class_variables

このコードの場合は

$ ruby test.rb
[:@@val]
[:@@val]

クラス変数を参照できます。

続いて別のパターンを見ていきます。

# test.rb

module M
  @@val = 10

  class C
    p @@val
  end
end

このコードを実行するとどうなるかというと

$ ruby test.rb
# => test.rb:5:in `<class:C>': uninitialized class variable @@val in M::C (NameError)

となります。 これはクラス C では @@val は定義されていないのでエラーになるというのは納得です。

では、以下の場合はどうかというと

# test.rb

class C
  @@val = 20
end

module B
  @@val = 30
end

module M
  include B
  @@val = 10

  class << C
    p @@val
  end
end
$ ruby test.rb
10

10 が返ってきました。

この例を見た時、なんで 10 が返ってくるのか理解できませんでした。 資格試験の学習に取り組んでいた当時の私は最初このコードは例外が発生すると思っていて、クラス変数が何れかの値を参照するという点がそもそも分からなかったという感じです。

続けて、少し変えたコードで試してみようと思います。

# test.rb

module M
  @@val = 10

  class C
  end

  class << C
    p @@val
  end
end

これも例外が発生しそうな気がしますが、実行してみると

$ ruby test.rb
10

10 が返ってきました!

もう1パターン試してみます。 これは1つ前の例を書き換えたものです。

# test.rb

module M
  @@val = 10

  class C
    class << self
      p @@val
    end
  end
end

実行してみると

$ ruby test.rb
test.rb:6:in `singleton class': uninitialized class variable @@val in M::C (NameError)

例外が発生! なんでーーー?

ここまで幾つかの例でクラス変数の参照がどうなるかを見てきましたが、結果に納得がいったのは最初の例2つだけで、残りはなんでそうなるのかが説明できない状態でした。

ちなみにRubyリファレンスマニュアルには、

クラス変数は、その場所を囲むもっとも内側の(特異クラスでない) class 式または module 式のボディをスコープとして持ちます。

と書いてあり、特異クラスを理解する必要があることがわかりました。

Rubyのしくみを読んだらちょっとずつ分かってきた

上で見てきたクラス変数の参照の結果がなぜそうなるのかについてRubyのしくみを読んだことでようやく自分の中で整理がついたため、備忘録も兼ねてまとめておきたいと思います。

Rubyのしくみの第9章:メタプログラミングを読んでいると「レキシカルスコープ内の特異クラスを使ってメソッドを定義する」という解説をしている部分があり、

class <<構文を使って新しいレキシカルスコープを宣言することもできる。

と書いてありました。

レキシカルスコープについては第6章:メソッド探索と定数探索の中に解説があり、

レキシカルスコープは、クラス階層でも他のいずれのスキームでもなく、プログラムの構文的な構造上のコードの区分を指す。

とあります。 現在のクラスまたはモジュールに対するスコープとそのコードを取り囲む親の部分をレキシカルスコープとするという理解です。

私が理解できていないと書いたコードに戻ってみたいと思います。

まず、以下の例では class << 構文は module M の中に書かれています。

# test.rb

module M
  @@val = 10

  class C
  end

  class << C
    p @@val
  end
end

p @@val が実行されている 現在のスコープ(self のクラス)は M::C の特異クラスであり、レキシカルスコープは module M ということになります。

そのため、M::C の特異クラスで実行される p @@v はレキシカルスコープを辿って モジュールMで定義されているクラス変数を参照することができると説明がつけられそうです。

続いて、例外が発生する以下の例です。

# test.rb

module M
  @@val = 10

  class C
    class << self
      p @@val
    end
  end
end

この場合、class << selfclass C の中で定義されています。

p @@val が実行されている 現在のスコープは M::C の特異クラスであり、レキシカルスコープは class C の中です。 そのため、module M にある @@val はこのスコープからは参照できず例外が発生します。

# test.rb

class C
  @@val = 20
end

module B
  @@val = 30
end

module M
  include B
  @@val = 10

  class << C
    p @@val
  end
end

この例についてもなぜ 10 が得られるのかがレキシカルスコープの点で見直すと納得がいきました。

ふりかえり

自分が以前理解できなかったことがわかった瞬間はとても嬉しくちゃんとアウトプットしておきたい思い、こちらのブログを書いてみました。

普段のコードでクラス変数を使うことはまずないし、クラス変数よりもクラスインスタンス変数を使った方がいいというのもあって、自分が長らく疑問を感じていたクラス変数の参照の仕組みが何かに役に立つことは多分ないだろうと思いつつ、今回Rubyの特異クラスやレキシカルスコープへの理解を深めるきっかけになったので良かったのかなと思っています。

説明や使っている用語が適切なのかがちょっと心配な部分もあり、間違っている箇所や見直した方が良い部分があればぜひ教えていただけると嬉しいです。