Wiz テックブログ

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

Next.js Serverless Functions APIを使ってみました

f:id:thunder_fury:20210512113531p:plain

はじめに

皆さんこんにちは、フロントエンドエンジニアのWooです。⚡️🌪

Next.jsの簡単なサーバー内の処理の実装ができたので、Vercelデプロイ基準でNext.jsのServerless Functionsの実装方法を調べてみました。

Serverless Functionsとは?

Serverlessを直訳すると、「サーバーがない」という意味ですが、実際はサーバーがない訳ではありません。

特定のタスクを実行するために、コンピュータを、あるいは仮想マシンにサーバーを設定していますが、実装者はサーバーの管理や運営をする必要がありません。つまり、サーバーリソースを意識することがなく、プログラムのみの実装に集中することができます。

VercelのServerless Functionsではユーザーの認証、フォームの送信、簡単なバックエンド処理からデータベースも実装でき、Vercelで完結することができます。

vercel.com

本来であればVercelのインストール/vercel.json作成/rootにAPIデータを作成するなど設定が少し必要ですが、Next.jsの良いところはprojectを作成したらpageフォルダの中にapiデータが自動生成されますのでjstsファイルを作成して処理を書いていくだけです。

環境構築

$ npx create-next-app serverless-test
$ cd serverless-test
$ npm run dev

ディレクトリー

pageフォルダ内にはapiフォルダが生成されhello.jsが作成されることが確認できました。

.
├── .next
├── page
│   ├── api
│   │    └── hello.js
│   ├── _app.js
│   └── index.js
└── ...省略...

hello.js中身はnameクエリを受信し、文字列を返すNode.jsの関数がデフォルトで含まれています。

export default (req, res) => {
  res.status(200).json({ name: 'John Doe' })
}

apiフォルダのjsファイルがそのままapiのurlになる仕様であり、http:localhost:3000/api/helloブラウザ接続でJSONが確認できました。

f:id:thunder_fury:20210511120413p:plain

TypeScriptで書きたい場合はimport type { NextApiRequest, NextApiResponse } from 'next' でTypeScriptが使えます。

import type { NextApiRequest, NextApiResponse } from 'next' // <-追加
export default (req: NextApiRequest, res: NextApiResponse) => {
  res.status(200).json({ name: 'John Doe' })
}

結果は同じです。

f:id:thunder_fury:20210511120413p:plain

GET & POST

各関数は、requestを受信し、responseを返す必要があります。 そうでなければ、タイムアウトしてしまいます。

データを返す最も簡単な方法としては、response.sendを使用します。このメソッドは、テキスト、オブジェクト、またはバッファを受け取ります。

export default (request, response) =>
  response.send({
    data: 'hello world',
  });

requeseは基本的に200を返しくれるのでPOSTとGET用のコードにstatusを変更する必要がありresponse.statusを使用してstatus codeを変更することができます。http-status-codes というパッケージを利用してもっと簡単に実装することができます。

$ npm i http-status-codes

GETの場合、get successの文字列を返してくれるサンプルです。

import Status from 'http-status-codes'

// GET -> http://localhost:3000/api/hello

export default (request, response) => {
  if (request.method !== 'GET') {
    return response.status(Status.BAD_REQUEST).send('')
  }
  return response.json({
    msg: `get success`,
  });
};

POSTの場合、post succesの文字列を返してくれるサンプルコードです。

import type { NextApiRequest, NextApiResponse } from 'next'
import Status from 'http-status-codes'

// POST -> `http://localhost:3000/api/hollo`
export default (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method !== 'POST') {
    return res.status(Status.BAD_REQUEST).send('')
  }
  return response.json({
    msg: `post succes`,
  });
};

POSTではなく、直でapiにアクセスする場合は、400エラーになります。

f:id:thunder_fury:20210511120814p:plain

まとめ

以上、Next.jsでServerless Functionsを利用する使い方を調べてみました。

使った感想としてはdatabaseまで用意すると学習コストがかかりそうですが、簡単なメール送信機能が必要な小規模サイト(ランディングページ)はServerless FunctionsでPOSTができるのでNext.jsでスピーディーに実装するのも便利そうだと思いました。


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

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

careers.012grp.co.jp

第4回LT会を行いました。

第4回LT会レポ

今回のLT会の内容は

発表者: 3名

制限時間: 自由

テーマ: 自由

コメントツール:CommentScreen

で行いました。

それでは1つずつ発表を紹介していきます。

フロントエンドエンジニアがLaravelやってみて

f:id:wiz012:20210513123005p:plain
フロントエンドエンジニアがLaravelをやってみて
1人目の方には「フロントエンドエンジニアがLaravelやってみて」という内容を発表していただきました!

モチベーション

・CI/CD周りやアプリケーションの環境構築、構成などがバックエンドに任せきりだった

・技術的な障壁ができてしまう

やったこと

1. 基礎学習

 ・php, SQL, Dockerなどの基礎学習

2. やりたいアピールしてみる

 ・バックエンド課題に取り組む

 ・1on1などで上長に相談する

 ・アサインされているプロジェクトのバックの構成をみたり

3. 実装する

 ・家計の節約アプリをLaravelで構築してみる

 ・QR発行、通常の管理画面作成

得たこと

1. プロジェクトの構成考えやすい

 ・バック側、もしくはフロント側でプログラムを組んだ時の、

  実装スピードやパフォーマンスの違い

 ・データベースのリレーション構造への理解

2. フロントデータの受け渡し方について再度考えさせられた

 ・HTMLを圧縮しない方が良いこと

 ・bladeファイルのどの部分がバックエンドの変数に絡んでいるか

今後の課題

 ・pug-> html -> blade -> 配信の連携が難しい

 ・LaravelをAWSにデプロイする

前期成果発表

f:id:wiz012:20210423142638p:plain
ブランディング活動前期成果発表
2人目の方には「前期成果発表」について発表していただきました!

前期にやったこと

ブランディング施策

1. TechBlog運用

 ・前期(1~3月)のアンケート調査(全エンジニア向け)

  によると満足度はなり高かったです。

 ・「記事の投稿フローが難しかった」との意見をいただいたので、

  絶賛ドキュメント整理中です。

2. LT会

 ・同じく前期のアンケートをとったところ、半数以上の方から

  「やってよかった」というポシティブな意見をいただきました。

 ・その反面、「LT会の準備をする時間がない」という意見もいただきました。

エディタ作成

記事執筆にて文字を起こす時に使用するエディタ(WYSIWYGエディタ)のことです

1. 技術選定 ➡︎ Quill.js

2. ディレクターさん/ライターさんにヒアリング

今期はQuill.jsの学習とヒアリングをメインに行い、

来期からエディタ作成を行う予定です。

その他やったこと

1. コードレビュー会   tech.012grp.co.jp やったことがここに書かれています。是非ご覧ください!

2. アウトプット会

題材は自由で週に1回行いました。

3. Vue.js もくもく会

やっていることは、 backlogにある共有事項のストックアプリをチーム開発する(現在進行系)

WordPressメディア SSG化

f:id:wiz012:20210427113149p:plain
WordPressメディア SSG化
3人目の方には「WordPressメディア SSG化」について発表していただきました!

・コンテンツ増加によるサイトパフォーマンスの悪化

・WPデータ保持のためのステート管理の複雑化

 ➡︎これらの課題をSSGで解決できるのではと思ったことがきっかけだそうです。

f:id:wiz012:20210430111703p:plain
技術構成

f:id:wiz012:20210430112429p:plain
パーフォーマンス改善

f:id:wiz012:20210430112457p:plain
可読性改善

メリット

・パフォーマンス/可読性の向上

JavaScript統一による開発体験の向上 ➡︎ Lintによるエラー検出/コード整形が安易に

WordPressとViewが完全に分離される ➡︎ セキュリティリスクの低減 

デメリット

・コンテンツ反映までのタイムラグがある

・WP管理画面側の改修に手間がかかる

最後に~

第4回はこのような内容でした。

LT会の内容をもっと社外へ公開できるよう目指していきたいです。

また、

Wizではエンジニアを募集中です。

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

careers.012grp.co.jp

 

【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ではエンジニアを募集しております。 興味のある方、ぜひご覧下さい。

careers.012grp.co.jp

良いコードの書き方

こんにちは!

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

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

コードサンプルは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ではエンジニアを募集しております。

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

careers.012grp.co.jp

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ではエンジニアを募集中です!

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

careers.012grp.co.jp

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ではエンジニアを募集しております。 興味のある方、ぜひご覧下さい。

careers.012grp.co.jp

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

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

構造化データとは

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

構造化データのメリット

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

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

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

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

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

記事ページに 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ではエンジニアを募集しております。 興味のある方、ぜひご覧下さい。

careers.012grp.co.jp