このエントリーをはてなブックマークに追加

SymfonyでWebアプリケーションを作るとき、HTTPリクエストから直接実行するには重い処理があったらどうするか?
以前の記事 でも書いた通り、非同期処理を実装することが多いですね。
Symfony2時代の非同期処理のスタンダードは JMSJobQueueBundle でした。

JMSJobQueueBundleも一応Symfony4に対応してくれたのですが、不具合があって利用できない時期が長く、メンテナーの方が忙しいようでプルリクエストへの返信も遅れがちでした。乗り換え先をどれにするのが良いかTwitterで聞いてみたところ、 Symfony\Component\Messenger を使うと良いよというアドバイスをもらい、使ってみたらとても良かったので、JMSJobQueueBundleとの違いを軸にご紹介したいと思います。

JMSJobQueueBundleからMessengerComponentへの移行方法

コマンドは作らなくて良い。代わりにMessageとHandlerを作る。

JMSJobQueueBundleは「コマンドを実行する」という縛りがあったので、非同期処理したい処理に関して専用のコマンド(Symfonyの Command を継承したコマンドクラス)を作る必要がありました。

Messengerでは、MessageクラスとHandlerクラスを作ります。
MessageクラスはピュアなPHPのクラスです。何の抽象クラスを継承する必要も何のinterfaceを実装する必要もありません。非同期処理に対して渡したいパラメータの入れ物として使います。Messageオブジェクトはキューに入れた時にシリアライズされ、非同期処理実行時にデシリアライズされるので、シリアライズして安全な値だけをもたせるように設計する必要があります。たとえば、Doctrineのエンティティをそのまま入れるのは危険なので、MessageにはIDだけを持たせて、実行時にEntityManagerからエンティティを取り直す方式が 公式に推奨 されています。
Handlerクラスは Symfony\Component\Messenger\Handler\MessageHandlerInterface を実装したクラスです。Messageを受け取って __invoke メソッドで処理します。Handlerクラスに対しては通常のSymfonyのDependencyInjectionを使って依存を注入することができるので、いろいろな仕事をさせることができます。 messenger.message_handler タグをつけてサービスとして定義しておく必要があります。

エンキューのやり方

JMSJobQueueBundleでは JMS\JobQueueBundle\Entity\Job を新規で作り、エンティティとしてDBに保存することで非同期処理キューに入れる仕組みでした。
MessengerComponentでは、対象のMessageクラスを async Transportに送る設定をした上で、Messageオブジェクトを MessageBus( @messenger.default_bus サービスでDIできます)にdispatchすることで非同期処理にエンキューします。

<?php

$message = new MyMessage();
$messageBus->dispatch($message);

キューのバックエンド

JMSJobQueueBundleではデータベース一択でしたが、MessengerComponentでは他のキューバックエンドにも対応しています。
デフォルトでAMQP、 Redisとデータベース保存の Transport が提供されており、 自作する こともできます。

実際に非同期処理を実行するランナーはどうやって起動するか?

JMSJobQueueBundleでは jms-job-queue:run を常駐させていました。
MessageComponentでは、代わりに messenger:consume を常駐させます。
常駐設定は公式ドキュメントにある通り supervisorを使う のが一般的ですが、一定時間ごとにcronで起動していく方式でも良いと思います。

並列実行したいとき

1つのキューに対して並列で実行したい(同時にN個処理したい)場合です。
JMSJobQueueBundleでは jms-job-queue:run--max-concurrent-jobs=N を渡すことで並列実行させることができました。
MessengerComponentでは並列実行したい場合は messenger:consume コマンドのプロセスを所望の数だけ起動します。supervisorを利用する場合は numprocs=N を指定すれば良いですし、cronの場合はcrontabに同じ設定をN行コピーして設定することで実現できます。

優先順位をつけたい・キュー名を指定したいとき

JMSJobQueueBundleでは JMS\JobQueueBundle\Entity\Job を作る時に $priority$queue を指定することで、コマンド実行時に他より優先させたり、キュー名ごとにランナーを振り分けたりできました。
MessengerComponentでは、 公式ドキュメント にある通り、必要なだけ優先度やキュー名によってTransportを予め分けて定義しておき、MessageクラスをそれぞれのTransportにルーティングします。同じクラスのMessageオブジェクト間で別々のキュー名を利用することはできません。優先度を分けたい・ランナーを分けたい場合はそれぞれMessageのクラスを分けておきましょう。

ある非同期処理を他の非同期処理に依存させたい(他の非同期処理が終わってから発動させたい非同期処理がある)

JMSJobQueueBundleではJobのDependencyを指定することができました。
MessengerComponentにはDependency機能は無いですが、1つ目のMessageのHandlerの最後で次のMessageをMessageBusにdispatchするという方法で実現できます。

非同期処理が成功したか失敗したかを見たい

JMSJobQueueBundleでは JMS\JobQueueBundle\Entity\Job のレコードがジョブ実行完了後も保存されていたので、後からそれを見て成功・失敗をチェックすることができました。Jobは単なるエンティティだったので、アプリケーションのエンティティからリレーションを張っておくことでJobエンティティの成功・失敗を参照する作りにすることもできたほどです。
MessengerComponentではMessageは処理が終わった後は保存されることがないので、成功・失敗は別の箇所(アプリケーションのエンティティに直接 state フィールドを作ってMessageHandlerの側から保存する等)に保存する必要があります。最初は不便に感じましたが、最近では、アプリケーションの側が「非同期処理される」ことに依存する構造を作りにくくなったことで依存関係が混乱することがなくなって良いと思うようになりました。

まとめ

MessengerComponentに移行して数ヶ月経ちますが、全般的にシンプルになっているので、実装も運用は楽だと感じます。特に、JMSJobQueueBundleの頃にあった、暴走して数時間走りっぱなしの非同期コマンドを止めたり、古いJobレコードを定期削除したり…というメンテナンスが不要になったのは大きいです。

なお、私は直近で、MessageのRoutingをasyncに指定するのを忘れたために非同期で処理しているつもりの重い処理がいつまでも同期処理されていたというウッカリがありました :sweat_drops: ルーティング設定は忘れやすいのでそこだけ要注意かなと思います!


このエントリーをはてなブックマークに追加

もうすぐ産休をいただく志賀です :baby:

今回は、「設計ができるようになる!」という目標のもと取り組んでいる「todoレビュー」についてお伝えします。

当たり前のような内容ですが、何かお役に立てばと思います!

1. issueにアサインされる

(例)issue名「お問い合わせフォームの作成」

※開発部では、GitHubを使って開発をしています。

細かい仕様もコメントで共有してもらいます。わからないことがあれば随時issue上で確認をします。

2. todoを考え、書き出す

(例)

todo
  - FormTypeの作成
  - Twigテンプレートの作成
  - Controllerの作成
  - サービス定義
  - 動作検証

(PHPチームではSymfonyを使用しているためこんな感じになります。)

私はこの時、既存コードやテストを見たりしたりして追加するコードを整理しています。

似た機能が既にあれば、それを参考に考えることが多いです :bulb:

3. todoレビュー依頼を出す

todoをレビュアーにレビューしてもらいます。

ここで、助言や意見があればコメントをもらいます。

スクリーンショット 2020-06-09 11 46 03

4. 実装

okをもらったtodoに沿って、実際にコードを書いていきます。

todoリストのリスト単位(FormTypeの作成・テンプレートの作成…)でプルリクを出すと大きなプルリクになりにくいです。

メリット :ok_woman:

  • 設計(todo)と実装をきっちり分けることで、それぞれをじっくり考えて取り組める。
  • 設計(todo)を言語化する機会が生まれるので、必然的に頭の中を整理ができる。
  • あとで見返せて、振り返りができる。
  • 答え合わせ感覚で設計(todo)の確認ができるので、モチベーションがあがる。
  • 予想外の事が起こらない限りレビュー済みのtodo通りにコードを書いていくので、コードレビューでの手戻りは少なくなる。
  • todoとして書き出すことで、設計だけでなくリリースまでに必要な作業系のやるべきことも明確にできる。
  • todoを考える時に、どう設計したらいいかわからないなどの相談がしやすい。

デメリット :no_good:

  • レビューが2回(todoとコード)となるので、レビュアーの負担が大きくなる。

さいごに

上に挙げた通りレビュアーの負担が増えますが、確認が細かくできるのでコードレビュー時点での手戻りが減ったりとレビュアー・レビュイー双方のメリットが多いのでとってもいいと思います!!!


このエントリーをはてなブックマークに追加

もくじ

はじめに

カルテットコミュニケーションズ開発部ブログで Angular超入門 の連載をスタートしてから、かなりの間が空いてしまいました。🙇前回の投稿からすでに半年が経過してしまいましたが、ぼちぼち再開しようと思います。また、金本からのバトンタッチを受け、今回から松岡が執筆を担当します。

さて、前回の投稿 Todoアプリ開発開始!新しくコンポーネントを作ってみよう! では todo を入力するためのシンプルなコンポーネントを作成しました。

ブラウザにはこのような入力画面が表示されます。

ちょっと味気ないですね…。今回はこのコンポーネントをベースに todo をリスト管理できるようにしてみたいと思います!

まだチュートリアルを実践していない方は Hello Worldしてみよう! から試してみてくださいませ。10分足らずで終わるはずです。

今回のゴール

todo リストを表示し、追加ボタンでリストの要素を増やせるようにします。

前回作った TodoFormComponent に加え、今回は TodoListComponent を新たに追加します。この2つのコンポーネントの親である AppComponent を通して todo データをやり取りしてみましょう。

ちなみに本文中の「親」「子」とは、とあるコンポーネント(親)に別のコンポーネントを設置している(子)、という関係を指しています。

DOM のイベントを拾ってみよう!

ボタンを設置する

前回までのチュートリアルのソースコードをベースに TodoFormComponent に追加ボタンを設置してみましょう。

src/app/components/todo-form/todo-form.component.html

<input type="text" [(ngModel)]="todo.name">
<input type="checkbox" [(ngModel)]="todo.isDone">

+ <button (click)="clicked()">追加</button>

src/app/components/todo-form/todo-form.component.ts

@Component({
  selector: 'app-todo-form',
  ...
})
export class TodoFormComponent implements OnInit {
  ...
+  clicked() {
+    console.log(this.todo);
+  }

イベントバインディング

(click) のような書き方を イベントバインディング と呼びます。 (change) (focus) (blur) など () には DOM の標準イベント の名前が入ります。

(click)="clicked()" のように書くと イベント発火時にコンポーネントのメソッドを呼び出す 事ができます。

ユーザー入力イベントにバインドする(angular.jp)

これで、追加ボタンを押した時に任意の処理が実行できるように準備が整いました。

ng serve --open してブラウザで追加ボタンをクリックしてみてください。入力した todo の内容がデバッグコンソールに出力されるはずです。

親子関係のコンポーネントでデータを連携させてみよう!

子コンポーネントでイベント発火する

TodoFormComponent は親である AppComponent に「追加ボタンが押された」事を知らせる必要があります。

子コンポーネントから親コンポーネントに 何らかのタイミング を伝えるには @OutputEventEmitter を使います。

src/app/components/todo-form/todo-form.component.ts

- import { Component, OnInit } from '@angular/core';
+ import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { Todo } from 'src/app/models/todo';

@Component({
  selector: 'app-todo-form',
  ...
})
export class TodoFormComponent implements OnInit {

+  @Output() addTodo = new EventEmitter<Todo>();

  public todo: Todo = {
    name: '',
    isDone: false,
  };

  constructor() { }

  ngOnInit(): void {
  }

  clicked() {
-    console.log(this.todo);
+    this.addTodo.emit(this.todo);
  }

}

@Output() / EventEmitter

カスタムイベントを発火させる仕組みです。 @Output() addTodo はこのコンポーネントが addTodo というカスタムイベントを持っている事を宣言 しています。emit() を呼び出すと カスタムイベントが発火 します。

emit() には、カスタムイベントのパラメータを渡す事ができます。

  • 数値 this.addTodo.emit(1)
  • 文字列 this.addTodo.emit("abc")
  • オブジェクト this.addTodo.emit({ id: 1 })

上記のソースコードでは this.addTodo.emit(this.todo) としているため、コンポーネントの持つ todo をパラメータとして渡している事になります。

親が子のイベントをリッスンする(angular.jp)

これで、TodoFormComponent が入力内容の todo を親コンポーネントに伝えられるようになりました。

親コンポーネントがイベントを拾う

次に親コンポーネントである AppComponent で「追加ボタンが押された」イベントを拾ってみましょう。

DOMの標準イベントをキャッチするのに (click)="method()" と書くように、カスタムイベントも (イベント名)="method()" という書き方をします。

src/app/app.component.html

- <app-todo-form></app-todo-form>
+ <app-todo-form (addTodo)="added($event)"></app-todo-form>

パラメータを受け取る時はイベントバインディングの引数に $event を指定します。

src/app/app.component.ts

import { Component } from '@angular/core';
+ import { Todo } from './models/todo';

@Component({
  selector: 'app-root',
  ...
})
export class AppComponent {
  title = 'angular-todo';

+  public appTodos: Todo[] = [];

+  added(todo: Todo) {
+    this.appTodos.push({
+      name: todo.name,
+      isDone: todo.isDone,
+    });
+  }
}

added() の引数名は、$event に縛られません。任意に変更できます。

これで、AppComponent が TodoFormComponent のイベントを拾って、受け取った todo をリストに追加するようになりました。

リスト表示用コンポーネントを作成

次に AppComponent が持っている todo のリストを画面に表示するために、専用のコンポーネントを作成します。

$ ng g c components/todo-list

ng generate component コマンドは頭文字を取ってこのように短くタイプする事もできます。

コマンドによって <todo-list> が追加されているはずです。

> $ tree src/app/components/
src/app/components/
├── todo-form
│   ├── ...
└── todo-list
    ├── todo-list.component.html
    ├── todo-list.component.scss
    ├── todo-list.component.spec.ts
    └── todo-list.component.ts

子コンポーネントが値を受け取る準備をする

TodoListComponent は親コンポーネントである AppComponent から todo のリストを受け取る必要があります。

子コンポーネントが 親コンポーネントから値を受け取る には @Input を使います。

src/app/components/todo-list/todo-list.component.ts

- import { Component, OnInit } from '@angular/core';
+ import { Component, Input, OnInit } from '@angular/core';
import { Todo } from 'src/app/models/todo';

@Component({
  selector: 'app-todo-list',
  templateUrl: './todo-list.component.html',
  styleUrls: ['./todo-list.component.scss']
})
export class TodoListComponent implements OnInit {

+  @Input() todos: Todo[] = [];

  constructor() { }

  ngOnInit(): void {
  }

}

@Input()

データバインディングでデータを受け取る仕組みです。 @Input() todos のように宣言すると親コンポーネントから [todos]="値" のように値を受け渡しする事ができます。

これで TodoListComponent が todo のリストを受け取る準備ができました。何が渡されたのか分かるようにオブジェクトの中身をデバッグ出力しておきましょう。

src/app/components/todo-list/todo-list.component.html

- <p>todo-list works!</p>
+ <pre>{{todos|json}}</pre>

親コンポーネントから子コンポーネントに値を渡す

さて、ようやく連携の準備が整いました。AppComponent の HTML に リスト表示コンポーネントを設置 してみましょう。

src/app/app.component.html

<app-todo-form (addTodo)="added($event)"></app-todo-form>
+ <app-todo-list [todos]="appTodos"></app-todo-list>

AppComponent の動作:

  • (addTodo) イベントバインディングを通して TodoFormComponent から todo を受け取ります
  • 受け取った todo を変数 appTodos に追加します
  • [todos] データバインディングを通して TodoListComponent に appTodos を渡します

動作確認

ブラウザで確認してみてください。 TodoFormComponent で入力した内容が、親である AppComponent を通して TodoListComponent に伝わるはずです。

まとめ

DOM の標準イベントを拾う。

<button (name)="method()">

子コンポーネントのイベントを拾う。

class ChildComponent {
  @Output() myEvent = new EventEmitter<ParamType>();

  method() {
    const param: ParamType = {...};
    this.myEvent.emit(param);
  }
}
// 親
<child-component (myEvent)="method($event)">

子コンポーネントに値を渡す。

class ChildComponent {
  @Input() name: Type;
}
// 親
<child-component [name]="value">

次回予告

次回は、繰り返しを制御する *ngFor や分岐を制御する *ngIf を使って、ビューをカスタマイズしてみたいと思います。

お楽しみに! :raised_hands: