Wiz テックブログ

Wizは、最新のIoTやICTサービスをお客様に届ける「ITの総合商社」です。

【Laravel】DDDで、テストの時はDBを使わないリポジトリに差し替える

こんにちは。バックエンドエンジニアの河内です。

LaravelでDDDを採用しテストを書くさいに、DBを使わないリポジトリに差し替える方法について書きます。DDDについて詳細は省きますが、今回は以下のように処理が移るものとします。

Controller → UseCase → Repository

メリット・デメリット

なぜ、そんなことをするのでしょうか。メリットとしては、以下が考えられます。

メリット

  1. DB設計〜マイグレーションの工程を後に回せる
  2. テスト時のコスト(負荷、時間)を低減できる

一方で以下のようなデメリットと言えるようなものもあるかと思います。

デメリット

  1. そのリポジトリを作る必要がある
  2. 結局、DB使うテスト結果も気になる

実例

というわけで、簡単な実例で考えていきます。 なんらかのアイテムを登録するユースケースです。

プロダクトコード

コントローラ

コントローラです。説明にフォーカスするため、いろいろ削ぎ落としています。PHPDocもありません。アクションでレスポンスしてません。 フォームリクエストでは、受け取った値をユースケースに渡すため、getDataなるメソッドで処理が適切におこなわれたものとします。

<?php

namespace App\Http\Controllers;

use App\Http\Requests\CreateItemRequest;
use App\Packages\AwesomeSystem\UseCase\CreateItem;

class ItemController extends Controller
{
    public function __construct(CreateItem $createItem)
    {
        $this->createItem = $createItem;
    }

    public function store(CreateItemRequest $request)
    {
        $this->createItem->handle($request->getData());
    }
}

ユースケース

ユースケースです。コンストラクタ引数のリポジトリがインターフェースです。handleメソッドで、受け取ったItemのエンティティをcreateメソッドへ渡しています。

<?php

namespace App\Packages\AwesomeSystem\UseCase;

use App\Packages\AwesomeSystem\Domain\Item;
use App\Packages\AwesomeSystem\Infrastructure\ItemRepositoryInterface;

class CreateItem
{
    protected $itemRepository;

    public function __construct(ItemRepositoryInterface $itemRepository)
    {
        $this->itemRepository = $itemRepository;
    }

    public function handle(Item $item)
    {
        $this->itemRepository->create($item);
    }
}

(DBを使う)リポジトリ

DBを使うリポジトリです。Eloquentに依存しているので、ファイル名をEloquentで始めています。依存しているので、いきなり呼び出してcreateメソッドで保存処理を実行してしまいます。今回は、アイテムの名前だけ登録するものとします。エンティティItemと名前が重複するので今回はモデルのほうをEloquentItemとしました。

<?php

namespace App\Packages\AwesomeSystem\Infrastructure;

use App\Models\Item as EloquentItem;
use App\Packages\AwesomeSystem\Domain\Item;

class EloquentItemRepository implements ItemRepositoryInterface
{
    public function create(Item $item)
    {
        EloquentItem::create(['name' => $item->getName()]);
    }

    public function first(): Item
    {
        return EloquentItem::first();
    }
}

リポジトリインターフェース

最低限、リポジトリに実装されるべきメソッドが羅列されています。

<?php

namespace App\Packages\AwesomeSystem\Infrastructure;

use App\Packages\AwesomeSystem\Domain\Item;

interface ItemRepositoryInterface
{
    public function create(Item $item);
    public function first(): Item;
}

サービスプロバイダ

ユースケースのコンストラクタ内で、ItemRepositoryInterfaceを指定したら、(プロダクトでは)EloquentItemRepositoryを呼び出してほしい。これは明示的にそう言ってあげないとLaravelは知るよしもありません。サービスプロバイダで結合します。

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Validator\CustomValidator;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->bind(
            \App\Packages\AwesomeSystem\Infrastructure\ItemRepositoryInterface::class,
            \App\Packages\AwesomeSystem\Infrastructure\EloquentItemRepository::class
        );
    }

エンティティ

ちなみに、Itemエンティティは以下のようなイメージです。$nameは値オブジェクトでもよさそうですがこれも今回は省きます。

<?php

namespace App\Packages\AwesomeSystem\Domain;

class Item
{
    protected $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }

    public function getName(): string
    {
        return $this->name;
    }
}

テストコード

ユースケーステスト

さて、このユースケースのテストを書いてみます。タイトルのとおり、テストはDBを使わないリポジトリでおこないます。

<?php

namespace Tests\Unit\Awesome\UseCase;

use App\Packages\AwesomeSystem\Domain\Item;
use App\Packages\AwesomeSystem\Infrastructure\InMemoryItemRepository;
use App\Packages\AwesomeSystem\UseCase\CreateItem;
use Tests\TestCase;

class CreateItemTest extends TestCase
{
    public function testアイテム新規登録()
    {
        // ①
        $itemRepository = app()->make(InMemoryItemRepository::class);
        // ②
        app()->bind(CreateItem::class, function () use ($itemRepository) {
            return new CreateItem($itemRepository);
        });

        $name = 'アイテムテスト';
        // ③
        (app()->make(CreateItem::class))->handle(new Item($name));
        // ④
        $item = $itemRepository->first();

        // ⑤
        $this->assertEquals($name, $item->getName());
    }
}

①で、DBを使わないリポジトリを生成しています。②が、先述のサービスプロバイダでの結合と異なり、DBを使わない①との結合となっています。③でその結合でもってユースケースを生成し、アイテムエンティティを渡して保存しました。④でデータを1つ取り出します。初投入されたことが明らかなので、firstメソッドで取り出しました。⑤で、投入データと保存データが合致するか確かめます。これで、DBを使わずテストをおこなえました。

(DBを使わない)リポジトリ

インメモリリポジトリについて触れておきます。内部では、プロパティ$dataを、データストアと見立て、createメソッドでは受け取ったアイテムエンティティをpushしているだけです。firstメソッドではコレクションの最初のエンティティを返すだけです。

<?php

namespace App\Packages\AwesomeSystem\Infrastructure;

use App\Packages\AwesomeSystem\Domain\Item;

class InMemoryItemRepository implements ItemRepositoryInterface
{
    private $data;

    public function __construct()
    {
        $this->data = collect();
    }

    public function create(Item $item)
    {
        $this->data->push($item);
    }

    public function first(): Item
    {
        return $this->data->first();
    }
}

実例からメリット・デメリットを考察する

あらためて、メリットをふり返ります。

メリット

1. DB設計〜マイグレーションの工程を後に回せる

実例ではプロダクトコードから紹介しましたが、テストを書くのであればTDD…そのTDDの教えるところはテストファーストです。

だとすると、以下の順番でコードを書いていくことになろうかと思います。

  1. ユースケーステスト
  2. ユースケース
  3. リポジトリインターフェース
  4. (DBを使わない)リポジトリ

これだけあればユースケースのテストがかないます。これまでDBについて考えることはありませんでした。したがって、マイグレーションも登場しません。

ただし、エンティティ作成にあたってはドメインモデルを深く考える必要があります。それはDDDで本質的な作業です。あくまで、DBについての関心はひとまず置いておける、ということです。

2. テスト時のコスト(負荷、時間)を低減できる

DBテストでは、テーブルをTRUNCATE〜INSERTを繰り返す必要があり、テストケースが増えるとそのコストが無視できません。インメモリリポジトリではDBにまつわる問題をわきに置いて、ロジックのテストに集中できていることになります。

デメリット

1. そのリポジトリを作る必要がある

当然ですが、DBを使うリポジトリと別にDBを使わないインメモリリポジトリを作る必要があります。なければないでプロダクトコードを実装することもできます。テストケースの数によっては、インメモリリポジトリを作らないで済ませることもできそうですし、ケースバイケースです。

ただ、インターフェースを用意しインメモリリポジトリを作ってしまえば体感としては9割方完成はしていて、後はDBを使うリポジトリを用意しあてがうだけ…という感じです。各リポジトリを同じ工数かけて2つ作らねば…という感じではないのかな、と思います。

2. 結局、DB使うテスト結果も気になる

デメリットといいますか、プロダクトコードはDBを使うリポジトリで動くので、DB込でアプリケーションが本当に動くかの統合テストは別途必要になるかと思います。

まとめ

リポジトリの呼び出しのさいインターフェースに依存することで、プロダクトコードとテストコードで用いるリポジトリを使い分けることができました。インメモリリポジトリを使うことで、インフラ(=DB)の事情にとらわれず、ロジックのテストに集中できるかと思います。

最後に

Wizではエンジニアを募集しております。 興味のある方、ぜひご覧下さい。

【フロントエンドエンジニア】

場所にとらわれず自社メディア成長に貢献したいフロントエンドエンジニア募集! - 株式会社WizのWebエンジニアの求人 - Wantedly

【バックエンドエンジニア】

勤務地自宅を叶える!バックエンドエンジニアとして事業を成長させたい方募集 - 株式会社WizのWebエンジニアの求人 - Wantedly

良いコードの書き方

こんにちは!

フロントエンドエンジニアの松本です。

本日は「良いコードの書き方」と言うテーマでお話しさせていただきます。

コードサンプルはJavaScriptを採用しています。

本質的にはどの言語にも通ずるものがあるので、ぜひ最後までご覧ください。

そもそも良いコードとは🤔

まず、良いコードの条件は以下の2点です。

  • 他の人が見ても短時間で理解しやすいコードを書く
  • 他の人には"半年後"の自分も含まれる

オレオレ流の書き方ではなく、他人が短時間で理解できるようなコードがベストだと言う考え方です。

具体的なTipsを8つ紹介していきます。

1. 名前に情報を詰め込む📦

変数や関数などに適切な名前をつけましょう。

以下は卵かけご飯をオーダーする関数です。(卵かけご飯好き)

卵かけご飯好きには問題ないように見えますが、tkgは省略しすぎな気がします。

そもそも「何をするための関数なのか」が名前から全く想像できません。

const tkg = (hakumaiGram) => {
    return `白米${hakumaiGram}グラムで卵かけご飯をよろしく!`
}

tkg(200)  //白米200グラムで卵かけご飯をよろしく!

そこで、しかるべき「名前」を付けてあげました。

const tamagokakeGohanOrder = (hakumaiGram) => {
    return `白米${hakumaiGram}グラムで卵かけご飯をよろしく!`
}

tamagokakeGohanOrder(200)  //白米200グラムで卵かけご飯をよろしく!

関数名が多少長くなっても、名前から処理を想像できる関数名を付けましょう。

変数や関数名をやたらと省略せず、具体的で明確な単語を選ぶことで名前に情報を詰め込むことができます。

2. 美しさ🧝🏻‍♀️

見た目が美しい一貫性のあるレイアウトでコードを書きましょう。

具体的にはインデントを揃えるなどです。

フロントエンドであればPrettierを使いましょう。

prettier.io

3. コメントすべきことを知る💬

コメントの目的は、書き手の意図を読み手に知らせることです。

以下のように「見たら分かる」コメントは望ましくありません。

// 関数定義
const tkg = (hakumaiGram) => {
    return `白米${hakumaiGram}グラムで卵かけご飯をよろしく!`
}

// 実行
tkg(200)

以下のように後で手をつけたいところは、TODOとしてコメントを残しておくと良いでしょう。

// TODO:関数名がイケてない。もっと良い名前を
const tkg = (hakumaiGram) => {
    return `白米${hakumaiGram}グラムで卵かけご飯をよろしく!`
}

tkg(200)

TODOをハイライトしてくれる拡張機能も便利でオススメです。

marketplace.visualstudio.com

コメントには価値を持たせること、コードからすぐに分かることをコメントに書かないことを意識しましょう。

4. 制御フローを読みやすくする🚦

条件式の引数の並び順に気をつけましょう。

基本的に以下のような書き方が可読性も上がって良しです。

  • 左側:「調査対象の式」変化する。
  • 右側:「比較対象の式」あまり変化しない。
if (length >= 10)

直感的に判断できるものは、三項演算子で書いても良いでしょう。

time < 12 ? 'AM' : 'PM'

5. 変数と読みやすさ🔦

変数のスコープはできるだけ小さくしましょう。

以下は、トップレベルでグローバル変数を定義しているパターンです。

コードの行数が増えていくと、「どこで定義してどこで使われているのか」を探すだけでも時間がかかります。

const tamago = document.getElementById('tamago')
const gohan = document.getElementById('gohan')

const tamagoAction = () => {
    return tamago.textContent = '卵割れた'
}

const gohanAction = () => {
    return gohan.textContent = 'お米炊けた'
}

変数を実際に使われてる関数内に移動させて、スコープを小さくました。

「どこで使われているか」がより明確になりました。

const tamagoAction = () => {
    const tamago = document.getElementById('tamago')
    return tamago.textContent = '卵割れた'
}

const gohanAction = () => {
    const gohan = document.getElementById('gohan')
    return gohan.textContent = 'お米炊けた'
}

6. コードの再構成

エンジニアリングとは、大きな問題を小さな問題に分割することです。

以下のコードを見て見ましょう。

商品一覧のデータを取得して、DOMに描画する関数です。

一つの関数内で複数のタスクを行っており、見通しが悪いコードになっています。

基本的に一つの関数は一つの処理をすべきです。

export default async () => {
    const itemElement = document.getElementById('item')
    const ITEM = '/api/items'
    const res = await fetch(ITEM)
    const result = await res.json()
    /*
       ........
       ........
       resultを用いてDOMを組み立てるなが〜〜い記述
       ........
       ........
   */
    itemElement.innerHTML = renderDom
}

ここで役割を分割してみましょう。

以下はリファクタリングしたことで3つのコードに分割することができました。

  • 商品一覧データを取得する関数
  • 商品一覧のコンポーネントを描画する関数
  • DOMを生成する関数
const itemElement = document.getElementById('item')
const ITEM = '/api/items'

// 商品一覧のデータを取得する
const fetchItems = async (url) => {
  const res = await fetch(url)
  const result = await res.json()
  return result
}

// 商品一覧のコンポーネントを描画する関数
const renderItemComponent = async () => {
   const items = await fetchItems(ITEM)
   const renderDom = createItemDom(items)
   itemElement.innerHTML = renderDom
}

// オブジェクトからDOMを生成する
const createItemDom = (data) => {
 // ...DOMを組み立てるコード
}

役割を分割することで可読性も上がり、運用保守がしやすくなりました。

7. コードに思いを込める💖

ここからはマインドセット的なお話です。

基本的に自分の書いたコードは人に説明できるようにするべきです。

「なんかよくわからんけど動いた」は絶対避けましょう。

声に出して人に説明したり、ぬいぐるみに話しかけてみましょう。

and-engineer.com

8. 短いコードを書く👻

組み込みの標準APIに慣れ親しんでおきましょう。

実装したい機能を満たすコードを自力で書かずとも、既に組み込みの標準APIに実装されていることがあったりします。

また、「この要件を満たすならこのAPIは使えそうだな」とコードの見通しが立つこともあります。

技を磨いておくと言うことですね。

波動拳昇竜拳竜巻旋風脚も、いつでも出せるようにしておきましょう。

フロントエンドであればJavaScript標準APIに慣れ親しんでおくと良いかもしれません。

まとめ

いかがだったでしょうか。

「良いコードを書く」と言うことに終わりはなさそうです。

皆様の参考になれば幸いです。

それではまたお会いしょましょう。

参考書籍📚

リーダブルコード ―より良いコードを書くためのシンプルで実践的なテクニック


Wizではエンジニアを募集しております。

興味のある方、ぜひご覧下さい。

【フロントエンドエンジニア】 場所にとらわれず自社メディア成長に貢献したいフロントエンドエンジニア募集! - 株式会社WizのWebエンジニアの求人 - Wantedly

【バックエンドエンジニア】 勤務地自宅を叶える!バックエンドエンジニアとして事業を成長させたい方募集 - 株式会社WizのWebエンジニアの求人 - Wantedly

Vue.js + TSXの方法とそのメリット・デメリット

こんにちは、フロントエンドエンジニア小玉です。 今回はVue.jsTSXについて記事にしたいと思います。

といいますのも、昨年9月にリリースされたVue3.0からはTSXがサポートされると発表があったのです。
Vue3.0以前でもTSX自体は使用可能ではありましたが、パッケージ(vue-tsx-support,babel-plugin-transform-vue-jsx)が必要でした。
しかし、Vue3.0以降は公式にサポートされましたので、TSXの活用法とそのメリット・デメリットについて簡単に書いていきたいと思います。

まずはどうやって使うのか?

今回はViteを使って環境を構築します。 また、ViteではTSXコンパイルに当たってプラグインも併せて必要となるため、下記のようにインストールします。

$ yarn create @vitejs/app project-name --template vue
$ yarn add @vitejs/plugin-vue-jsx

こちらでVue3.0の環境が出来上がり、かつTSXコンパイルするまでの準備ができました。

App.vueApp.tsxに変更し、 composition api内部のsetup()Render functionを返します。

import { defineComponent } from 'vue'
export const App = defineComponent({
  setup() {
    return () => (
      <>
        <h1>Vue TSX Project</h1>
      </>
    )
  }
})

併せてmain.jsのファイル名も下記のように修正します。

import { createApp } from 'vue'
import { App } from './App.tsx'

createApp(App).mount('#app')

これだけ。 実際にブラウザでは、

f:id:kdm012:20210422102645p:plain
実行結果

このように問題なく表示されているのがわかります。

軽く中身の話をすると、Render functionはVueのtemplateレンダリングの代替になります。
templateRender functionは、内部的にはVNode(仮想ノード)という仮想DOMの要素を返します。

v3.ja.vuejs.org

なにが嬉しいの?

では実際にTSXを導入してなにが嬉しいのか、メリットとデメリットを見比べてみましょう!

メリット

型安全

まず第一のメリットとして、propsの型情報が検知できるようになります。 実際にコードをみてみましょう。

import { defineComponent, PropType } from 'vue'
interface Props {
  innerTxt: string | null;
}
export const TsxButton = defineComponent({
  props: {
    innerTxt: {
      type: String as PropType<Props['innerTxt']>
    }
  },
  setup(props) {
    return () => (
      <button>{props.innerTxt}</button>
    )
  }
})

このようなボタンコンポーネントがあり、それを親コンポーネントにて呼び出す際、

f:id:kdm012:20210422102805p:plain
型補完(VSCode

上記の画像のように、「propsで定義した型と、親コンポーネントから流し込もうとしているデータの型が違うよ」とアラートを出してくれます。

コンポーネントファイルを小さく保てる

比較的肥大化しやすいSFC(単一ファイルコンポーネント)を、TSXで構成することで小さく保つことができるかと思います。

named exportができる

変更に強いですよね!!!笑

export defaultnamed exportの違いなどについては以下が参考になるかと思います。

jsnotice.com

デメリット

CSS関係

これをデメリットと感じるかは、それぞれかもしれないですが、
従来のSFCだとCSSも含めて直接記入できた分、スタイリングを効かせやすかった利点がありますが、TSXではそうはいきません。
下記のような形でスタイルを効かせていくようになるかと思います。

  • vue-styled-componentsを使う。
  • 別ファイルにしてimportする。

そのほかにもあれば教えていただけると嬉しいです。
従来の書き方で慣れている人は最初TSXに書き換えていく際にここで「ん?」となる方もいるかもしれません。

これから使う?

私の所属するフロントエンドチームではReactを使用しているメンバーが比較的多いです。
そのためTSXを一部導入することで、そういったメンバーにとってもVue.jsを使用したプロジェクトに参加しやすくなるといった効果が期待できます。
また、コードの肥大化を抑えられる、型補完が効くという点でもメリットが大きいと思いますので、
そういった点から少しずつ導入していくのはいいなと感じています。

最後に

今回はVue.jsTSXを導入する方法とそのメリットとデメリットを簡単に解説してみました。
エンジニアにとってはメリットも大きい反面、これまでのtemplate構文に慣れていた方にとってはややハードルの高いものなのかもしれません。
導入に当たって一度この記事を参考にしていただけると幸いです。


最後になりますが、Wizではエンジニアを募集中です!

興味のある方は是非覗いてみてください↓

【フロントエンドエンジニア】
場所にとらわれず自社メディア成長に貢献したいフロントエンドエンジニア募集! - 株式会社WizのWebエンジニアの求人 - Wantedly

【バックエンドエンジニア】
勤務地自宅を叶える!バックエンドエンジニアとして事業を成長させたい方募集 - 株式会社WizのWebエンジニアの求人 - Wantedly

SSRのバックエンドをServerlessFramework Laravelで構築する

こんにちは。バックエンドエンジニアの高橋です。

SSR(Serverless Next.js)のバックエンドを ServerlessFramework (Laravel) で作ってみました。

今回は以下の要件を満たす仕様

  • LaravelからS3へPutが出来る
  • Laravelからメール送信が出来る

また、GithubActionsによるCI/CDの実装と Cloud9を使ってクライアントからAuroraへ接続する方法までを紹介したいと思います。

アーキテクチャ

f:id:wiz_tak:20210420112420p:plain

セットアップ

ローカルでLaravelをインストールしてプロジェクトを作ったらbrefをインストールします。

$ composer require bref/bref bref/laravel-bridge
$ php artisan vendor:publish --tag=serverless-config

brefはLaravelアプリケーションのサーバレス用ライブラリで、全てを良き感じでやってくれます。

デプロイはServerlessFrameworkがCloudFormationを利用し実行します。

Lambdaで動作させるので、LaravelのSessionやStorage(filesystem)の設定に若干手を加えないといけません。

本家のドキュメント通りにやれば出来ると思いますのでここでは割愛します。

bref.sh

serverless.yml

環境変数を使うのにcustomをこんな感じにしています。

service: serverless-app

provider:
  name: aws
  runtime: provided.al2

  stage: ${opt:stage, self:custom.defaultStage}
  region: ${opt:region, self:custom.defaultRegion}

  environment:
    AWS_BUCKET: !Ref Storage
  iamRoleStatements:
    - Effect: Allow
      Action: s3:*
      Resource:
        - !Sub '${Storage.Arn}'
        - !Sub '${Storage.Arn}/*'

custom:
  defaultStage: develop
  defaultRegion: ap-northeast-1
  environments: ${file(./serverless_config/config.${opt:stage, self:custom.defaultStage}.yml)}
  secret: ${file(./serverless_config/secrets/secrets.${opt:stage, self:custom.defaultStage}.yml)}

package:
  exclude:
    - projdir/.env
    - projdir/node_modules/**
    - projdir/public/storage
    - projdir/resources/assets/**
    - projdir/storage/**
    - projdir/tests/**

envファイルは以下のようにそれぞれ環境毎に設置

/serverless_config/config.develop.yml

AWS_BUCKET_STORAGE: serverless-app-develop
AWS_BUCKET: serverless-app-assets-develop
DB_PORT: 3306
DB_DATABASE: serverless_app_develop

/serverless_config/secrets.develop.yml

USER_NAME: root
PASSWORD: password

APIとなるwebは使用するVPCとAuroraの接続情報を定義、artisanも同様に設定

タスクスケジュールを使う場合は、artisanのeventsに定義します。

〜〜
functions:

  # This function runs the Laravel website/API
  web:
    handler: public/index.php
    timeout: 28
    layers:
      - ${bref:layer.php-74-fpm}
    events:
      - httpApi: '*'

    vpc:
      securityGroupIds:
        - !Ref LambdaSecurityGroup
      subnetIds:
        - !Ref PrivateSubnetA
        - !Ref PrivateSubnetC

    environment:
      DB_PORT: ${self:custom.environments.DB_PORT}
      DB_HOST: !GetAtt DBCluster.Endpoint.Address
      DB_PASSWORD: ${self:custom.secret.PASSWORD}

  # This function lets us run artisan commands in Lambda
  artisan:
    handler: artisan
    timeout: 120
    layers:
      - ${bref:layer.php-74}
      - ${bref:layer.console}

    vpc:
      securityGroupIds:
        - !Ref LambdaSecurityGroup
      subnetIds:
        - !Ref PrivateSubnetA
        - !Ref PrivateSubnetC

    environment:
      DB_PORT: ${self:custom.environments.DB_PORT}
      DB_HOST: !GetAtt DBCluster.Endpoint.Address
      DB_PASSWORD: ${self:custom.secret.PASSWORD}

    # Cron Event
    events:
      - schedule:
        rate: cron(0 0 * * ? *)
        input: '"sample:command"'
〜〜

VPCリソースにはメインVPC/サブネット/LambdaとAuroraのセキュリティグループを記述

※ 後ほどVPCエンドポイントを使うのに必要なDNSホスト名とDNS解決を有効にしておきます。

〜〜
resources:
  Resources:

    # VPC
    VPC:
      Type: AWS::EC2::VPC
      Properties:
        EnableDnsHostnames: true
        EnableDnsSupport: true
        CidrBlock: 192.168.0.0/16
        Tags:
          - Key: Name
            Value: serverless-app-${opt:stage, self:custom.defaultStage}-vpc

    # Private Subnet A
    PrivateSubnetA:
      Type: AWS::EC2::Subnet
      Properties:
        VpcId: !Ref VPC
        CidrBlock: 192.168.3.0/24
        AvailabilityZone: ap-northeast-1a
        Tags:
          - Key: Name
            Value: serverless-app-${opt:stage, self:custom.defaultStage}-private-a

    # Private Subnet C
    PrivateSubnetC:
      Type: AWS::EC2::Subnet
      Properties:
        VpcId: !Ref VPC
        CidrBlock: 192.168.4.0/24
        AvailabilityZone: ap-northeast-1c
        Tags:
          - Key: Name
            Value: serverless-app-${opt:stage, self:custom.defaultStage}-private-c

    # Lambda Security Group
    LambdaSecurityGroup:
      Type: AWS::EC2::SecurityGroup
      Properties:
        GroupDescription: SecurityGroup for Lambda Functions
        VpcId: !Ref VPC
        Tags:
          - Key: Name
            Value: serverless-app-${opt:stage, self:custom.defaultStage}-lambda-sg

    # Aurora Security Group
    AuroraSecurityGroup:
      Type: AWS::EC2::SecurityGroup
      Properties:
        GroupDescription: SecurityGroup for Aurora
        VpcId: !Ref VPC
        SecurityGroupIngress:
          - IpProtocol: tcp
            FromPort: ${self:custom.environments.DB_PORT}
            ToPort: ${self:custom.environments.DB_PORT}
            CidrIp: 192.168.0.0/16
        Tags:
          - Key: Name
            Value: serverless-app-${opt:stage, self:custom.defaultStage}-aurora-sg
      DependsOn: VPC
〜〜

DBリソースはAuroraのクラスターやサブネットパラメータグループを記述

RDSProxyを使わなくてもいいのでここではAuroraServerlessエンジンを指定していますが、 スケールにかかる時間やコストの心配があるならAurora+RDSProxyが良いかもしれません。

〜〜
    # DB Subnet
    DBSubnetGroup:
      Type: AWS::RDS::DBSubnetGroup
      Properties:
        DBSubnetGroupDescription: Subnet Group for Aurora $ {opt:stage, self:custom.defaultStage}
        DBSubnetGroupName: serverless-app-${opt:stage, self:custom.defaultStage}-aurora-sbng
        SubnetIds:
          - !Ref PrivateSubnetA
          - !Ref PrivateSubnetC

    # DB Cluster
    DBCluster:
      Type: AWS::RDS::DBCluster
      Properties:
        DatabaseName: ${self:custom.environments.DB_DATABASE}
        Engine: aurora-mysql
        EngineMode: serverless
        MasterUsername: ${self:custom.secret.USER_NAME}
        MasterUserPassword: ${self:custom.secret.PASSWORD}
        DBClusterParameterGroupName: !Ref DBClusterParameterGroup
        DBSubnetGroupName: !Ref DBSubnetGroup
        VpcSecurityGroupIds:
          - !Ref AuroraSecurityGroup
        Tags:
          - Key: Name
            Value: serverless-app-aurora-${opt:stage, self:custom.defaultStage}
      DependsOn: DBSubnetGroup

    # Parameter Group
    DBClusterParameterGroup:
      Type: AWS::RDS::DBClusterParameterGroup
      Properties:
        Description: A parameter group for aurora
        Family: aurora-mysql5.7
        Parameters:
          time_zone: "Asia/Tokyo"
          character_set_client: "utf8"
          character_set_connection: "utf8"
          character_set_database: "utf8"
          character_set_results: "utf8"
          character_set_server: "utf8"
〜〜

S3リソースにはStorageとAssetで使うバケットの設定を記述

Storageバケットにも参照出来るようにGetObjectを追記しています。

〜〜
    # S3 Storage Bucket
    Storage:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:custom.environments.AWS_BUCKET_STORAGE}

    # S3 Storage Bucket Policy
    StorageBucketPolicy:
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket: !Ref Storage
        PolicyDocument:
          Statement:
            - Effect: Allow
              Principal: "*"
              Action: "s3:GetObject"
              Resource: !Join ["/", [!GetAtt Storage.Arn, "*"]]

    # S3 Asset Bucket
    Assets:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:custom.environments.AWS_BUCKET}

    # S3 Asset Bucket Policy
    AssetsBucketPolicy:
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket: !Ref Assets
        PolicyDocument:
          Statement:
            - Effect: Allow
              Principal: "*"
              Action: "s3:GetObject"
              Resource: !Join ["/", [!GetAtt Assets.Arn, "*"]]

plugins:
  # We need to include the Bref plugin
  - ./vendor/bref/bref

デプロイ

IAMでユーザーを作成し、Githubリポジトリのsecretsに以下を設定します。

AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY

.github/workflows/deploy.yml

name: DeployDevelop

on:
  push:
    branches:
      - stag
defaults:
  run:
    working-directory: ./projdir
jobs:
  deploy:
    name: deploy
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14.x]
    steps:
      - uses: actions/checkout@v2
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      - name: Install Dependencies
        run: composer install --prefer-dist --optimize-autoloader --no-dev

      - name: npm Install Dependencies
        run: npm ci

      - name: copy env file
        run: |
          cp .env.develop .env

      - name: Output file contents
        run: |
          cat .env

      - name: Generate Key
        run: php artisan key:generate

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}

      - name: Serverless Deploy Lambda
        run: |
          npm i -g serverless@2.x
          serverless deploy
        env:
          AWS_REGION: ap-northeast-1
          AWS_VERSION: latest
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Migrate
        run: vendor/bin/bref cli --region=ap-northeast-1 serverless-app-develop-artisan -- migrate --force

後で作成したLambdaFunctionsをAWSコンソールから確認するとすぐ理解出来るかと思いますが、migrateはLambda関数名で実行させます。

またserverless をインストールしておけばクライアントマシンからでもdeploy出来ます。

$ serverless deploy 

これでAPIGatewayからLambdaが実行出来る環境が出来ました。

APIGatewayのAPI URLを確認してブラウザで確認してみます。

f:id:wiz_tak:20210420120157p:plain

他の必要リソースをTerraformで構築

クライアントマシンからAuroraへ接続するのにEC2が必要になるかと思いますが、

サーバレスなのにEC2を構築してしまう事に全く気持ちが乗らなかったので、Cloud9を使ってEC2を管理します。

当然Publicサブネットなどが必要なのでCloud9含めTerraformで構築します。

また今のままだとLambdaがVPCの外に出れないのでS3へアクセスしたりメール送信などが出来ません。

これらをするにはVPCエンドポイントが必要になるので、これもTerraformで一緒に構築します。

SESはVPCエンドポイントに対応していなく、SESを使う場合はNAT Gatewayが必要になります。

今回はS3 Putの為にVPCエンドポイントを引くので、同様にVPCエンドポイントを使ったSMTPの構成でメール送信を実現します。

まず、Lambdaの実行ロールに以下のポリシーをアタッチ

  • SESFullAccess
  • LambdaVPCExecutionRole

/env/develop/main.tf

module "provider" {
  source = "../../modules/common/provider"

  region = var.region
}

##===========================
## VPC Module
##===========================
module "vpc" {
  source = "../../modules/common/vpc"

  project                  = var.project
  stage                    = var.stage
  region                   = var.region
  private_subnet_ids       = var.private_subnet_ids
  lambda_security_group_id = var.lambda_security_group_id
  route_table_id           = var.route_table_id  
}

// VPC ID
output "vpc_id" {
  value = module.vpc.vpc_id
}

// Subnet ID
output "subnet_id" {
  value = module.vpc.web_subnet_a
}

// IGW
output "igw_id" {
  value = module.vpc.internet_gateway
}

// Route Table
output "rtb_id" {
  value = module.vpc.route_table
}

##===========================
## Route53 Module
##===========================
module "route53" {
  source = "../../modules/common/route53"

  project           = var.project
  stage             = var.stage
  region            = var.region
  domain            = var.domain
  host_zone_domain  = var.host_zone_domain
}

##===========================
## Cloud9 Module
##===========================
module "cloud9" {
  source = "../../modules/common/cloud9"

  public_subnet_a    = module.vpc.web_subnet_a

  project            = var.project
  stage              = var.stage
  region             = var.region
  ec2_instance_type  = var.ec2_instance_type
}

/env/develop/variables.tf

漏れのないように変数定義

##===============================
## Project Name
##===============================
variable "project" {
  default = "serverless-app"
}

##===============================
## Stage Name
##===============================
variable "stage" {
  default = "develop"
}

##===============================
## Region
##===============================
variable "region" {
  default = "ap-northeast-1"
}

##===============================
## Domain
##===============================
variable "domain" {
  default = "dev.serverless-sample.jp"
}

##===============================
## Private Subnet Ids
##===============================
variable "private_subnet_ids" {
  type = list(string)
  default = [
    "subnet-*****",
    "subnet-*****"
  ]
}

##===============================
## Lambda Security Group Id
##===============================
variable "lambda_security_group_id" {
  default = "sg-*****"
}

##===============================
## Route53 Host Zone Domain
##===============================
variable "host_zone_domain" {
  default = "dev.serverless-sample.jp"
}

##===============================
## Cloud9 EC2 Instance Type
##===============================
variable "ec2_instance_type" {
  default = "t2.micro"
}

##===============================
## Route Table ID
##===============================
variable "route_table_id" {
  default = "rtb-*****"
}

/modules/vpc/main.tf

variable "stage" {}
variable "project" {}
variable "region" {}
variable "private_subnet_ids" {}
variable "lambda_security_group_id" {}
variable "route_table_id" {}

#==================================
#
# VPC
#
#==================================

// VPCの定義
resource "aws_vpc" "main_vpc" {
  cidr_block                       = "192.168.0.0/16"
  enable_dns_hostnames             = true
  enable_dns_support               = true
  instance_tenancy                 = "default"
  assign_generated_ipv6_cidr_block = false

  tags = {
    Name  = "${var.project}-${var.stage}-vpc"
    STAGE = var.stage
  }
}

#==================================
#
# Public Subnet For EC2
#
#==================================

// パブリックサブネットの定義
resource "aws_subnet" "public_subnet_a" {
  vpc_id                  = aws_vpc.main_vpc.id
  cidr_block              = "192.168.1.0/24"
  map_public_ip_on_launch = true
  availability_zone       = "${var.region}a"

  tags = {
    Name = "${var.project}-${var.stage}-public-a"
  }
}

#==================================
#
# IGW
#
#==================================

// インターネットゲートウェイの定義
resource "aws_internet_gateway" "main_igw" {
  vpc_id = aws_vpc.main_vpc.id
  tags = {
    Name = "${var.project}-${var.stage}-igw"
  }
}

#==================================
#
# Public Route Table
#
#==================================

// パブリックルートテーブルの定義
resource "aws_route_table" "rtb_public" {
  vpc_id = aws_vpc.main_vpc.id
  tags = {
    Name = "${var.project}-${var.stage}-public-rtb"
  }
}

// ルートの定義
resource "aws_route" "route_igw_public" {
  route_table_id         = aws_route_table.rtb_public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.main_igw.id
  depends_on             = [aws_route_table.rtb_public]
}

// ルートテーブルとサブネットの関連付け
resource "aws_route_table_association" "rtb_assoc_public_web" {
  count          = 2
  route_table_id = aws_route_table.rtb_public.id
  subnet_id      = element([aws_subnet.public_subnet_a.id], count.index)
}

#==================================
#
# SMTP Security Group
#
#==================================

// SMTPセキュリティグループの定義
resource "aws_security_group" "smtp_security_group" {
  name        = "${var.project}-${var.stage}-smpt-sg"
  description = "security group for SMTP"
  vpc_id      = aws_vpc.main_vpc.id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project}-${var.stage}-smpt-sg"
  }
}

// インバウンドルール
resource "aws_security_group_rule" "smtp_inbound_rule" {
  type                     = "ingress"
  from_port                = 587
  to_port                  = 587
  protocol                 = "tcp"
  source_security_group_id = var.lambda_security_group_id
  security_group_id        = aws_security_group.smtp_security_group.id
}

#==================================
#
# VPC Endpoint
#
#==================================

// S3
resource "aws_vpc_endpoint" "vpce_s3" {
  vpc_id              = aws_vpc.main_vpc.id
  service_name        = "com.amazonaws.ap-northeast-1.s3"
  vpc_endpoint_type   = "Gateway"
  private_dns_enabled = false
  route_table_ids     = [var.route_table_id]

  tags = {
    Name = "${var.project}-${var.stage}-vpce-s3"
  }
}

// SMTP
resource "aws_vpc_endpoint" "vpce_smtp" {
  vpc_id              = aws_vpc.main_vpc.id
  service_name        = "com.amazonaws.ap-northeast-1.email-smtp"
  vpc_endpoint_type   = "Interface"
  private_dns_enabled = true

  subnet_ids = var.private_subnet_ids

  security_group_ids = [
    aws_security_group.smtp_security_group.id,
  ]

  tags = {
    Name = "${var.project}-${var.stage}-vpce-smtp"
  }
}

/modules/cloud9/main.tf

Cloud9のarnはrootでコンソールにサインインしている都合上rootにしていますので適宜変更してください。

variable "stage" {}
variable "project" {}
variable "region" {}
variable "public_subnet_a" {}
variable "ec2_instance_type" {}

#==================================
#
# Cloud9 Enviroment
#
#==================================

data "aws_caller_identity" "self" {}

resource "aws_cloud9_environment_ec2" "cloud9_env" {
  instance_type = var.ec2_instance_type
  name          = "${var.project}-env"
  subnet_id     = var.public_subnet_a
  owner_arn     = "arn:aws:iam::${data.aws_caller_identity.self.account_id}:root"
}

Serverlessで構築したVPCリソースをインポートしてから実行

$ terraform import module.vpc.aws_vpc.main_vpc ****
$ terraform apply

クライアントマシンからAuroraへ接続

構築出来たら、クライアントからAuroraへ接続するのに以下の作業をします。

  1. RDS のセキュリティーグループのインバウンドに、Cloud9で作ったセキュリティグループを追加 f:id:wiz_tak:20210420122252p:plain

  2. Cloud9のセキュリティーグループのインバウンドを適宜設定 f:id:wiz_tak:20210420122446p:plain

  3. キーペア作成 f:id:wiz_tak:20210420122606p:plain

  4. クライアント側で、ssh-keygen -y 後にDLした鍵のフルパスをコピー f:id:wiz_tak:20210420123039p:plain

  5. 吐き出された公開鍵を Cloud9 EC2のauthorized_keysに設定 f:id:wiz_tak:20210420123452p:plain

  6. 接続確認

f:id:wiz_tak:20210420133540p:plain

メール送信設定

次にAWSコンソールSES画面のSMTP SettingからSMTPアカウントを作成して、 f:id:wiz_tak:20210420124438p:plain

Laravel の.envに以下のように設定します。

.env.dev

MAIL_MAILER=smtp
MAIL_HOST=email-smtp.ap-northeast-1.amazonaws.com
MAIL_PORT=587
MAIL_USERNAME=***********
MAIL_PASSWORD=***********
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=null
MAIL_FROM_NAME="${APP_NAME}"

これでS3 StorageへのPutとメール送信のテストコードを書いて動けばOKです。

最後に

Wizではエンジニアを募集しております。 興味のある方、ぜひご覧下さい。

【フロントエンドエンジニア】

場所にとらわれず自社メディア成長に貢献したいフロントエンドエンジニア募集! - 株式会社WizのWebエンジニアの求人 - Wantedly

【バックエンドエンジニア】

勤務地自宅を叶える!バックエンドエンジニアとして事業を成長させたい方募集 - 株式会社WizのWebエンジニアの求人 - Wantedly

記事及び求人ページに構造化データを導入

担当メディアで記事及び求人ページの構造化データを導入した実例を元に構造化データの導入例を紹介します。

構造化データとは

検索エンジンがページ内容を理解しやすくなるデータ形式のことです。 正しく記述することで検索エンジンがページの内容をより理解できるようになります。 構造化データの種類によっては検索結果のリッチ化(リッチリザルト)につながるものもあります。

構造化データのメリット

検索エンジンがサイトコンテンツを認識しやすくなる

・検索結果にリッチスニペットが表示されることがある

構造化データのデメリット

・構造化データのデメリットは、デザインの改修などの工数がかかる

記事ページの構造化データ

記事ページに Article 構造化データを追加すると、Google 検索結果での表示を強化することができます。 ページのコーディング方法に応じて下記の機能を利用可能です。

AMP(Accelerated Mobile Pages)とは

Googleが推進しているモバイルページを高速に表示させるための手法によって作成されているコンテンツです。

構造化データを含むAMPウェブページ

トップニュース カルーセル、リッチリザルトのホスト カルーセル、映像ニュース、モバイル検索結果のリッチリザルト内に表示できます。 検索結果には、画像、ページのロゴ、その他の魅力的な検索結果機能を含めることができます。

構造化データを含む非AMPウェブページ

非 AMPページにArticle 構造化データを含めると、Googleがウェブページの詳細を理解し、Article リッチリザルトで記事の見出しテキスト、画像、および公開日を適切に表示できるようになります。

実装例

今回はLaravelで構築された非AMPサイトで構造化データを導入した例を紹介します。

ControllerでJSON-LDに渡す記事データを用意します。                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                app/Controllers/BlogController.php

<?php 
class BlogController extends Controller
{
     public function detail(Blog $blog){
        $this->view = 'blog.detail';
        return $this->view('blog' => $blog);
    }
}

bladeにJSON-LDコードをincludeします。

resources/views/blog/detail.blade.php

<?php 
@section('script')
    @parent
    @include('seo.blog_json')
@stop

Article構造化データを含む非AMPページのJSON-LDコードの例です。

resources/views/seo/blog_json.blade.php

<?php 
<script type="application/ld+json">
{
  "@context": "http://schema.org",
  "@type": "NewsArticle",
  "headline": "{{$blog->title}}",    ←記事タイトル
  "image": [
    "{{$imgUrl}}"  ←記事画像
   ],
  "datePublished": "{{$blog->publishedDate}}",   ←公開日
  "dateModified": "{{$blog->modifiedDate}}",   ←更新日
  "author": {
    "@type": "Person",
    "name": xxxxx"
  },
  "publisher": {
    "@type": "Organization",
    "name": "OrganizationName",   ←ウェブサイトの運営会社
    "logo": {
      "@type": "ImageObject",
      "url": "$blog->logo_url",   ←ロゴ
    }
  },
"description": "{{$blog->description}"  ←現在表示されているページの概略
}
</script>

構造化データタイプの定義

以下のプロパティが適用されます。

推奨プロパティ

・dateModified (DateTime) 記事が最後に変更された日時(ISO 8601 形式)

・datePublished(DateTime) 記事が最初に公開された日時(ISO 8601 形式)。

・headline(Text) 記事の見出しは半角110 文字(全角 55 文字)を超えないようにします。

・image(ImageObject または URL) 記事を表す画像の URLです。記事に直接属するマークアップされた画像のみを指定する必要があります。 画像の幅は 696 ピクセル以上にする必要があります。

リッチリザルト テスト

Googleにインデックスされるまでは時間がかかるので、下記のURLで事前にリッチリザルトテストを使用して構造化データのテストが可能です。

https://search.google.com/test/rich-results

記事詳細ページのURLを入力後、「URLをテスト」ボタンを押すと下記のようなテスト結果が表示されます。 f:id:kerryri:20210412164548p:plain

対象外のものや、実装が正しくなかったりした場合は下記の対象外のページというテスト結果になるので修正が必要です。 f:id:kerryri:20210412164608p:plain

上記以外に下記のテストツールもあります。

https://search.google.com/structured-data/testing-tool/u/0/

URLだけでなく、「コード スニペット」にコードを貼り付けてもテストが可能です。

求人ページの構造化データ

ControllerでJSON-LDに渡す記事データを用意します。                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                app/Controllers/JobController.php

<?php
class JobController extends Controller
{
     public function detail(Job $job){
        $this->view = 'job.detail';
        return $this->view( 'job' => $job);
    }
}

bladeにJSON-LDコードをincludeします。

resources/views/job/detail.blade.php

<?php  
@section('script')
    @parent
    @include('seo.job_json')
@stop

構造化データを含む求人ページのJSON-LDコードの例です。

resources/views/seo/job_json.blade.php

<?php 
<script type="application/ld+json">
{
    "@context" : "http://schema.org/",
    "@type" : "JobPosting",
    "title" : "{{$job->title}}",        ←職種
    "description" : "{{$job->description}}",         ←求人について詳細を説明
    "identifier": {
        "@type": "PropertyValue",
        "name": "companyName",
        "value": "xxxxx"
    },
    "datePosted" : "{{$job->posted_at}}",           ←投稿日
    "validThrough" : "{{$job->valid_at}}",
    "employmentType" : "{{$job->employment_type}}",
    "hiringOrganization" : {
        "@type" : "Organization",
        "name" : "{{$job->organization_name}}",          ←会社名
        "sameAs" : "https://xxxxx.com/",         ←会社HPのURL
        "logo" : "{{$job->logo_url}}"         ←会社ロゴのURL
    },
    "jobLocation" : {
        "@type" : "Place",
        "address" : {
            "@type" : "PostalAddress",
            "streetAddress" : "{{$job->street}}",         ←番地
            "addressLocality" : "{{$job->Locality}}",        ←市区町村等
            "addressRegion" : "{{$job->Region}}",         ←都道府県
            "postalCode" : "{{$job->postal_code}}",        ←郵便番号
            "addressCountry": "JP"        ←国名
        }
    },
    "baseSalary": {
        "@type": "MonetaryAmount",
        "currency": "JPN",         ←通貨
        "value": {
            "@type": "QuantitativeValue",
            "value": "{{$job->salary}}",          ←金額
            "unitText": "MONTH"        
                 ←給与形態:HOUR:時給 DAY:日給 WEEK:週給 MONTH:月給 YEAR:年俸
        }
    }
}
</script>

記事と同じくGoogleにインデックスされるまでに時間がかかるので、事前にリッチリザルトテストを行い構造化データがで正しく実装できたかを確認します。

数日後Google検索結果で記事及び求人ページに構造化データが導入されていることが確認できます。

さいごに

Wizではエンジニアを募集しております。 興味のある方、ぜひご覧下さい。

場所にとらわれず自社メディア成長に貢献したいフロントエンドエンジニア募集! - 株式会社WizのWebエンジニアの求人 - Wantedly

勤務地自宅を叶える!バックエンドエンジニアとして事業を成長させたい方募集 - 株式会社WizのWebエンジニアの求人 - Wantedly

useReducerの活用法について

こんにちは、フロントエンドエンジニアの松尾です。

Reactにはフック(Hooks)と呼ばれる機能があり、useStateuseEffectuseCallbackといった様々な関数が用意されています。

その中の一つにuseReducerがありますが、useContextと併用して使うものとして認識されたり、useStateの影に隠れたりといった理由であまり活用されていない傾向があります。

しかし、useReducerは様々な場面で活用でき、可読性・パフォーマンス向上に大いに役立ちます。

本記事では、useReducerの基本的な使い方と様々な活用法について説明したいと思います。

useReducerとは?

useReducerとは、useStateと同様に状態管理ができるフックです。

特に複雑なロジックが絡んだ状態を管理するのに適しています。

useReducerは以下のように宣言することで使用できます。

const [state, dispatch] = useReducer(reducer, initialState)

必要な引数は、reducerinitialStateです。

  • reducer:stateとactionを受け取り、新しいstateを返す関数

  • initialState:stateの初期値

戻り値としてstatedispatchを返します。

  • state : 現在のstate値

  • dispatch : reducerを実行するための関数

では実際に、useReducerの使用例を見てみましょう。以下はReact公式ドキュメントから引用したコードになります。

const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

initialStateでカウント値の初期値を定義しており、reducerにてincrementdecrementのアクションを用意しています。

処理の流れは以下の通りです。

  1. ボタンを押すとdispatchが実行され、中身がreducerのactionに入る。

  2. switch文によって処理を分岐させ、新しいstate値を計算して返す。

  3. useReducerが更新されたstate値を受け取り、新しいstate値を描画させる。

reduxの書き方と非常に似ていますが、useReducerはdispatchにオブジェクトを返す必要はなく、値をそのまま入れることも可能です。

const initialState = {count: 0};

function reducer(state, action) {
  return state + action;
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch(-1)}>-</button>
      <button onClick={() => dispatch(+1)}>+</button>
    </>
  );
}

このようにシンプルに記述できることもuseReducerの魅力の一つです。

ここまでの説明で「useStateでもよくない?」と思われた方も多いのではないでしょうか?

ここからは、useReducerならではの強みについて説明したいと思います。

状態管理をuseReducerにまとめられる

useStateで状態管理していると、宣言が多すぎて複雑になってしまうことはありませんか?

例えば以下のような場合です。

const App = () => {
  const [isOpen, setIsOpen] = useState(false)
  const [type, setType] = useState('small')
  const [phone, setPhone] = useState('')
  const [email, setEmail] = useState('')
  const [error, setError] = useSatte(null)

  return (
    ...
  )
}

このように状態管理が多い場合、ロジックが複雑になるにつれ可読性が悪化してしまうケースが多々あります。

状態管理をuseReducerにまとめることで「何を状態管理しているか」「初期値はなんなのか」が明確になります。

const initialState = {
  isOpen: false,
  type: 'small',
  phone: '',
  email: '',
  error: null
}

const reducer = (state, action) => {
  switch (action.type) {
    ...
    default:
      return state
  }
}

const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState)

  return (
    ...
  )
}

ロジックとビューを切り離すことができる

useReducerのもう一つの強みは、ロジックとビューを切り離すことができる点です。

今回サンプルとして、以下を実装します。

3つのitemがあり、それぞれの値の更新・削除ができるといったシンプルな実装です。

上記をuseStateuseReducerそれぞれで実装し見比べてみましょう。

まずuseStateの場合です。

const App = () => {
  const [items, setItems] = useState([0, 0, 0]);

  const increment = (index) => {
    const newItems = items.slice()
    newItems[index]++
    setItems(newItems)
  }

  const decrement = (index) => {
    const newItems = items.slice()
    newItems[index]--
    setItems(newItems)
  }

  const deleteItem = (index) => {
    setItems(items.filter((_, i) => i !== index))
  }

  return (
    <>
      {
        items.map((v, i) => (
          <div key={i}>
            item{i}: {v}
            <button onClick={() => increment(i)}>+</button>
            <button onClick={() => decrement(i)}>-</button>
            <button onClick={() => deleteItem(i)}>削除</button>
          </div>
        ))
      }
    </>
  );
}

このように、useStateの場合は、ロジックとビューが混ざった状態となっています。

この程度の規模であれば問題ないかと思いますが、もしロジックが複雑になってきた場合、どの関数がどの状態を更新しているのかが分かりづらくなってしまいます。

次に、useReducerの場合です。

const myReducer = (state, action) => {
  const newState = state.slice()
  switch (action.type) {
    case 'increment':
      newState[action.index]++
      return newState
    case 'decrement':
      newState[action.index]--
      return newState
    case 'delete':
      return state.filter((_, i) => i !== action.index);
    default:
      throw new Error();
  }
};

const App = () => {
  const [items, dispatch] = useReducer(myReducer, [0, 0, 0]);

  return (
    <>
      {
        items.map((v, i) => (
          <div key={i}>
            item{i}: {v}
            <button onClick={() => dispatch({type: 'increment', index: i})}>
              +
            </button>
            <button onClick={() => dispatch({type: 'decrement', index: i})}>
              -
            </button>
            <button onClick={() => dispatch({type: 'delete', index: i})}>
              削除
            </button>
          </div>
        ))
      }
    </>
  );
}

このように、useReducer内にロジック処理を記述できるため、ビューと完全に切り離すことができます。

もし機能追加となった場合も、わざわざ関数を用意せずに、reducerのswitch文に新しく処理を追加するだけで済みます。

また、useReducerを用いるとdispatch関数はメモ化されるため、もし返ってくる値が変わらない場合はコンポーネントの再レンダリングを防ぐこともできます。

以上のことから、ロジックが複雑な処理に関しては、なるべくuseReducerを使用することをおすすめします。

まとめ

以上useReducerの基本的な使い方と活用法についての説明でした。

useReducerは、useStateと比べると使い方が馴染みづらく避けがちですが、活用することで様々な処理の可読性・パフォーマンス向上に役立ちます。

是非今回紹介した方法をお試し下さい!

最後になりますが、Wizではエンジニアを募集中です。

興味のある方は是非覗いてみてください↓

【フロントエンドエンジニア】
場所にとらわれず自社メディア成長に貢献したいフロントエンドエンジニア募集! - 株式会社WizのWebエンジニアの求人 - Wantedly

【バックエンドエンジニア】
勤務地自宅を叶える!バックエンドエンジニアとして事業を成長させたい方募集 - 株式会社WizのWebエンジニアの求人 - Wantedly

LaravelでStrategyパターン & Factory Methodパターン

こんにちは、バックエンドエンジニアの青山です。今回はデザインパターンをLaravelのプロジェクトに適用してみました。
実際にありそうな仕様を想定して、ナイーブな実装からデザインパターンを使った実装にリファクタリングする形で進めていこうと思います。
たとえば

  • ユーザーが単発の仕事に応募してその報酬をもらえるようなサイトがあるとする
  • 仕事情報は管理画面からCSVでアップロードする仕様になっており、アップされたCSVの内容はDBに保存される
  • 仕事にはいくつかの種類があり、扱うデータが異なる
  • ↑の理由からCSVのフォーマットは仕事の種類ごとに異なる
  • 管理画面にあるCSVアップロードフォームは1つで、フォーム内にはファイル用inputとファイルのタイプ(どんなジャンルの仕事が記載されているCSVなのか)を選択するselectが存在する

みたいな仕様があったとします。
フォームはこんな感じのものを想像してください。

仕事のジャンルはいくつもある想定ですが、ここでは一旦「オフィスワーク」「運送」「飲食」の3つが存在するとします。このどれかをselectで選択して、そのジャンルの仕事情報のCSVをアップロードするというわけです。
一旦そのまま仕様を満たしてみるとして、一番ナイーブな実装はこんな感じだと思います。

  リクエストの値を見てifで条件分岐させる単純な作りです。これをデザインパターンを適用してリファクタリングしてみます。

デザインパターンの適用

今回使用するのは

  • Strategyパターン
  • Factory Methodパターン

この2つです。

Strategyパターン

f:id:qingshanhuangye:20210401181113p:plain

状況に応じて処理を動的に切り替えることを可能にするパターンです。それぞれの処理をクラスとして定義し、共通の呼び出し部分から呼び出して処理を代替できるようにします。
今回の場合だと、"CSVを読み込んでDBにデータを保存する"という処理をそれぞれの種類で分けてクラス化することで実現します。

各Strategyの実装

まずそれぞれのクラスの共通のインターフェースを作成します。データをやりとりするためのDtoクラスもついでに作成しておきます。

CSVのデータを読み込んで配列を返すメソッドと、データをDBに保存するメソッドの2つを定義しました。実装内容は各実装クラスに委ねることになります。

これらのクラスが図中の各ConcreteStrategyに相当します。
これで具体的な処理を行うクラスを実装できました。次に必要なのはこれらのどのクラスを使用するか決めるクラス、上の図のContextに相当する部分です。今回の場合だと、フォームのselectで選択された値が何であるかによってどのクラスをインスタンス化するか判別する処理となります。
ifやswitchを使用して条件に引っかかれば指定のクラスをインスタンス化するという方法でもいいかもしれませんが、今回はここでもう一つデザインパターンを適用してみたいと思います。

Factory Methodパターン

f:id:qingshanhuangye:20210402143442p:plain

共通のインターフェースを実装したいくつかのクラスを作成し、どのクラスをインスタンス化するかをサブクラスに任せるパターンです。インスタンス生成処理と使用部分を分割することでメンテナンスしやすい状態にできるというメリットがあります。

インスタンス生成用クラスの実装

まず、Strategyパターン図中のConcreteStrategyに相当するクラスをインスタンス化するクラスを作成します。

インスタンス化される条件はリクエストのfile_typeの値が指定の値であった場合です。この条件判定を実現するために、FileImporterInterfaceに新しくtypeというメソッドを追加します。

おのずと各実装クラスもtypeメソッドを持たなければならなくなるので、修正します。

このような固有の数値を返すだけのメソッドを、残りの各クラスにも実装します。このtypeメソッドを使ってImporterFactoryクラスで条件判定とインスタンス生成を行います。

条件に応じて処理を切り替えるクラスの実装

Strategyパターン図中のContextにあたる部分です。インスタンスの作成はImporterFactoryが担っているので、かなりスッキリした作りになりました。

JoblistFileImportContextの呼び出し

こちらの処理をどこに置くかは好みが分かれると思います。これぐらいならControllerに書いてもよさそうな気がしますが、ビジネスロジック用にJoblistFileImportServiceクラスを作成してそちらに配置することにします。

この処理をコントローラーから呼び出します。

FormRequest

コントローラーを実装する前にFormRequestを作成します。このクラス内でバリデーションとリクエスト値をFileImportDtoに変換する処理を行います。

Controller

最後にコントローラーからJoblistFileImportServiceの処理を呼び出して完成です。とてもミニマルなコントローラーになりました。

メリット

最初に提示したifでの分岐による実装では、種別が増えるたびにどんどん分岐が追加されていくので、見通しが悪くなってしまうというデメリットがありました。
リファクタリング後の実装では種別が増えても新しくクラスを追加してImporterFactoryのimportersプロパティにクラスを登録するだけで済むので、見通しが良くメンテナンスしやすい状態になりました。
ちなみに元ネタとなる記事をちょっと前にQiitaに投稿していますので、よければそちらもご覧ください。

qiita.com

さいごに

Wizではエンジニアを募集しております。 興味のある方、ぜひご覧下さい。

場所にとらわれず自社メディア成長に貢献したいフロントエンドエンジニア募集! - 株式会社WizのWebエンジニアの求人 - Wantedly

勤務地自宅を叶える!バックエンドエンジニアとして事業を成長させたい方募集 - 株式会社WizのWebエンジニアの求人 - Wantedly