Wiz テックブログ

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

Puppeteerを使ったツールの構成について

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

先日Puppeteerを使った業務効率化のツールを作成しました。 その時参考にさせていただいた記事と一緒にその構成についてまとめたいと思います!

Puppeteerをこれから触る方に少しでもお役に立てればと思います…!

何を作るのか

あまりオープンにできないのですが、webでの操作を半自動化するツールです! 作りたいものの流れとしては以下の通りです。

  • 必要な情報をフォームで入力
  • データをどこかにためておく
  • 入力した値を元にPuppeteerを動かす
  • 結果をSlackで通知する

では構成についてまとめていきたいと思います。

構成

早速ですが、以下のような構成になっております!

アーキテクチャ

順番に何をやっているかをまとめていきます。

①フォームからGASを動かす

1

フォーム作成

まずは必要情報を書き込むためのフォームを作成します。

Google Formを使うことも考えましたが、データを加工する必要がありました。 次のGAS上やCloudFunctions上でもできますが処理が複雑になってしまうため、送信前に加工することにしました。

個人的にReactが好きなのでReactで作成し、フォームはReact Hook Formで作成しました。工数削減のためにCSSは書かずChakra UIを使用しています。

React Hook Form と Chakra UIを組み合わせると本当に爆速でフォームが出来上がります。実は公式でもサンプルが載っていますので気になる方はぜひご確認ください!

GAS連携

おなじみのGASでAPIを公開します。 詳細は以下の記事を参照ください!

①で参考にした/使用したサイト

React Hook Form formbuilder

Chakra UI公式

今から10分ではじめる Google Apps Script(GAS) で Web API公開

②GASからスプレッドシートに保存

2

GASのSpreadsheetAppクラスを使用してSpreadsheetを更新します。

// シート取得
const ss = SpreadsheetApp.openById(SpreadsheetApp.getActiveSpreadsheet().getId());
// dataというシートを取得
const dataSheet = ss.getSheetByName("data");

// データ(res)をシートに入れる
await dataSheet.appendRow([res]);
②で参考にしたサイト

Google Apps Script で Spreadsheet にアクセスする方法まとめ

Spreadsheet-app

③GASからCloudFunctionsに保存している関数を呼び出す

3

こちらもAPI連携できますので、上記のシート追加の後にCloudFunctionsのAPIを呼び出します。

  const url = 'CloudFunctionsのAPI URL'
  const result = await UrlFetchApp.fetch(url, option)

GAS上ではHTTPリクエスト用にUrlFetchAppというClassが用意されているので、そちらを使用します。

③で参考にしたサイト

url-fetch-app

④⑤CloudFunctionsをVPCアクセスコネクタ経由で動かす

4-7

関数を用意する

一番大変な部分ですが、全てを語ると尽きないので以下の参照記事をご確認いただければと思います!

VPCアクセスコネクタ設定をする

VPCとはVirtual Private Cloudの略で、プライベート仮想ネットワーク空間のことです。アクセスコネクタを設定をすることでVPCに接続することができます。

こちらを使用した経緯は、IPアドレスの固定のためです。 今回Puppeteerで操作するサイトがIPアドレスで制限をかけていたりするので、こちらの設定が必須でした。(これに気づけずかなり時間を浪費しました。。)

料金もある程度かかってしまいますので、事前に料金も試算しておいた方がいいかと思います!

④⑤で参考にしたサイト
  • Puppeteer

公式

バージョンによってエラーが出る

ヘッドレスモード時のみエラーが出てしまう時

imageUploadについて

  • Cloud Functions

CloudFunctions実行環境

  • VPCアクセスコネクタ設定

公式 料金について

VPCアクセスコネクタ 設定方法

⑥⑦結果(成功 or 失敗)を返す

最後にSlackで結果を通知する必要があるので、関数内で処理を分ける必要があります。 今回はtry~catchで実装しました。

 try {
    const browser = await puppeteer.launch(options)
    const page = await browser.newPage()
    :
    await browser.close()
    // 完了時の処理
  }catch(e){
    //例外が発生した場合の処理
  }
⑥⑦で参考にしたサイト

Pupeteerを使うときは try~finallyでbrowserをcloseする。

⑧結果をSlackで通知

8

最後にSlackとの連携をしていきます。 登録の流れは下記の記事を参考にしていただければと思います。

関数内からSlackへメッセージを送るには@slack/web-apiというライブラリが便利なのでそちらを使用します。

以下のように書けばメッセージを送ることができます。

const {WebClient} = require("@slack/web-api");
:

const slackClient = new WebClient('slackのtoken');
const slackParams = {
      channel: '#チャンネル名',
      text: 'テキスト'
};
await slackClient.chat.postMessage(slackParamsFinish);

かなり簡単に書くことができました! こちらを先程のtry~catchに反映させていきます。

 try {
    const browser = await puppeteer.launch(options)
    const page = await browser.newPage()
    await browser.close()
    // 処理完了メッセージ送る
    const slackParamsFinish = {
     channel: '#チャンネル名',
     text: 'テキスト'
    };
    await slackClient.chat.postMessage(slackParamsFinish);

  }catch(e){
    //例外が発生した場合の処理
    const slackParamsError = {
     channel: '#チャンネル名',
     text: 'テキスト'
    };
    await slackClient.chat.postMessage(slackParamsError);
  }

こちらでSlackへの連携も完了です!

⑧で参考にしたサイト

Slack APIと連携する

Slack APIを使用してメッセージを送信する

最後に

難しいツールではなかったのですが、初挑戦の部分も多く所々で苦戦していました。相談に乗っていただいたメンバーには感謝です。 構成に関しても改善できる点があると思うので、何か思うことがあればコメントいただけますと幸いです…!

現在Wizではエンジニアを募集中です。 興味のある方はぜひ覗いてみてください!↓

careers.012grp.co.jp

リモート環境におけるエンジニアの新人研修と新人メンターの話

f:id:kdm012:20210716114128j:plain

こんにちは、フロントエンドエンジニア小玉です。

本記事は、今年度4月〜6月までリモート環境下で行われたフロントエンドエンジニア新人研修について、
その内容と振り返りに加えて、エンジニア歴約1年半の自分がメンターとして教育に携わった所感を記したいと思います。

研修概要

研修目的JavaScriptを伴わないサイトの運用ができるようなレベルになる』
研修対象:新卒社員1名(メンティー
研修実施者:メンター2人(うち一人が小玉) オブザーバー1名
研修期間:2021年4月〜6月
研修場所:oVice(オンラインオフィス)or GoogleMeet

研修内容

新卒社員が入社してからの大まかな研修の流れとして、以下のようになっています。

  1. 会社概要研修
  2. 技術研修
  3. OJT

会社概要研修

こちらでは自分が所属している部署(エンジニア組織)が会社においてどのような役割を担っているのかを理解すること、また社会人としての基礎的なマナーを身につけることを目的としています。

技術研修

以下のような内容を座学と実践的な課題を交えて研修して行きます。

□Webの基礎知識

  • Webページが表示されるまでの仕組み

□開発環境について

□フロントエンドの基礎知識

また、技術研修の中ではスキルの習得に加えてフロントエンドとしての心構えや振る舞いについても研修します。
エンジニアとして仕事をしていると、どうしてもわからない課題やエラーに直面し、思ったように作業が進まないことがあるかと思います。そんな時に上司やチームのメンバーに報告・連絡・相談することの大切さや時間管理の重要性などを研修します。

OJT

メンティーに対しメンターが一人付き、メンティーは実際の案件をメンターと一緒にこなしていきます。
その中では、他人の書いたコードを読み解くことができるようになることや、 複数人で1つのプロジェクトを運用できるようになることが求められます。

研修の振り返り

リモート環境下での研修

今回実施した研修では一度も実際にあって研修を行うことはなく、全てオンラインでの研修となり、
その中で以下のような点に注意するべきだと感じました。

  • 雑談の時間を積極的に取る
  • 日々の振り返りシートなどを活用する

【雑談の時間を積極的に取る】
顔の見えない研修となると、淡々と座学や実習を進めがちになってしまいます。
メンティーがどのようなリアクションをとっているのかは声からでしか判断できないため、
仮に課題に詰まってしまった時にメンティーが質問のしやすい雰囲気を作る必要があります。

私たちの研修の中では朝イチから研修にすぐ入るわけではなく、
仕事と関係のない雑談をしてから研修に入るようにしていました。

【日々の振り返りシートなどを活用する】
雑談を取り入れることに通ずる部分はありますが、
メンティーの表情や仕草から課題への理解度は測りきれないのが正直なところです。
そこはメンティー本人にアウトプットしてもらう仕組みが必要です。

私たちは、日々の研修後にメンティーに振り返りシートを記載してもらい、それに対しメンターがコメントを返すようにしていました。そこから次の研修時不足分を補うようなカリキュラムに変更するという柔軟な対応ができたかと思います。

メンター制度について

今回の研修はメンティー1人に対し、メンターが2人付く体制で実施しました。 初めて教育をする側の立場になり感じたこととして、『ティーチング < コーチングの意識』が重要だと感じました。
どのような意図でコードを書いたのか、何に悩んでいるのか、どうしたらできるのか、しっかりヒアリングし、すぐに回答を与えるのではなく、考える方法や何かヒントを与えることでメンティーのさらなる理解が望めると思いました。

まとめと今後について

リモート環境下での研修内容と、その振り返りに加えメンターをやってみた所感をまとめてみました。
メンティーとの密なコミュニケーションを取り心理的安全性を担保した研修が実施できるよう今後も施策を考えて行きたいと思います。
また、新卒社員を研修カリキュラムを事前に作成し研修をしっかり行うのが組織として今回が初めてだったこともありメンティーやメンターの評価制度も改善する余地があります。 引き続きみんながハッピーになれる研修、組織にできるようがんばります。

今回あげた研修内容とは別に、新人教育の一環としてリアルタイムコーディング大会を開いたりもしています。
詳細はこちら↓

tech.012grp.co.jp

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

careers.012grp.co.jp

Laravel×クリーンアーキテクチャ

どうもバックエンドエンジニアの小室です。

最近クリーンアーキテクチャについて理解が以前に比べ深まってきました。

まとめがてら、簡単なユーザー作成機能をクリーンアーキテクチャを使い実装していきたいと思います。

まずクリーンアーキテクチャといえば

f:id:shuto_komuro:20210706151155j:plain
クリーンアーキテクチャ
お馴染みの円の図です。

レイヤーの説明

・Enterprise Business Rules

ビジネスロジックを表現するレイヤーになります。DDDによる設計が最も影響し、

DDDでいうEntity, Value Object, Domain Serviceが該当します。

・Application Business Rules

ビジネスロジックを組み合わせ、ソフトウェアは何ができるのか(UseCase)を表現するレイヤーになります。

・Interface Adapters

入力、永続化、出力を司るレイヤーになります。

・Frameworks & Drivers

フレームワークやDBなど、詳細な技術についてを置くためのレイヤーです。 このレイヤーでは、Interface Adapterが理解できる形へ変換される必要があります。

レイヤー間の依存方向

f:id:shuto_komuro:20210709001032p:plain
依存方向

この矢印の意味は依存方向を表します。 依存方向は上位レベルのレイヤーに向かっていきます。

f:id:shuto_komuro:20210709002232p:plain
flow of control

大事なのはここ、リクエストからレスポンスまでの流れを表しています。 もっと詳しく表したが図こちら。

f:id:shuto_komuro:20210709002909j:plain
flow of control

クリーンアーキテクチャのメリット

フレームワーク独立

アーキテクチャは、ソフトウェアのライブラリに依存しない。

フレームワークを道具として使うことを可能にし、システムはフレームワークの限定された制約に縛られない。

・テスト可能

ビジネスルールは、UI、データベース、ウェブサーバー、その他外部の要素なしにテストできる。

・UI独立

ビジネスルールの変更なしに、UIを置き換えられる。

・データベース独立

OracleあるいはSQL Serverを、Mongo, BigTable, CouchDBあるいは他のものと交換することができる。

ビジネスルールは、データベースに拘束されない。

・外部機能独立

ビジネスルールは、単に外側についてなにも知らない。

原文: Clean Coder Blog

クリーンアーキテクチャにおいて大事(コア)な考え

重要なものを些細なものに依存させない。

重要なもの=ビジネスルール

ビジネスルールは図でいう

・赤色の層(Entity: Application Business Rules)

・黄色の層(UseCase: Enterprise Business Rules)

の2層の部分です。

些細なもの = DB, UI, フレームワークなど

重要なものを些細なものに依存させないために「依存関係の逆転」を行う。

※「依存関係の逆転」後述してます。

実装コード

ルーティング

<?php

Route::post('/createUser', [\App\Http\Controllers\UserController::class, 'createUser']);

Controller

<?php

namespace App\Http\Controllers;

use App\Packages\Domain\User\Dto\UserInputData;
use App\Http\Requests\UserCreateRequest;
use App\Packages\UseCase\User\CreateUserUseCaseInterface;

class UserController extends Controller
{
    /**
     * @param UserCreateRequest $request
     * @param CreateUserUseCaseInterface $userCreateUseCase
     * @return \Illuminate\Http\JsonResponse
     */
    public function createUser(
        UserCreateRequest $request,
        //②Input Boundary<I>
        CreateUserUseCaseInterface $userCreateUseCase
    )
    {
        //①Input Data<DS>
        $userInputData = new UserInputData($request['name'], $request['tel']);
        //③UseCase Interactor
        $userOutputDataResponse = $userCreateUseCase->handle($userInputData);

        return response()->json([
            'uuid' => $userOutputDataResponse->getUuid()
            ],200);
    }
}

①でリクエスデータをアプリケーション用Input Dataに変換します。

②インターフェースクラスを定義してメソッドインジェクション(DI)をしています。

DIについては Laravelで始める依存性の注入(DI) - Qiita

環境変数に応じてRepositoryをInMemoryに切り替えたりもできます。

<?php

namespace App\Providers;

class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        if(app()->environment() == 'local') {
            $this->app->bind(
                'App\Packages\Infrastructure\User\RepositoryInterface',
                'App\Packages\Infrastructure\User\InMemoryRepository'
            );
            $this->app->bind(
                'App\Packages\UseCase\User\CreateUserUseCaseInterface',
                'App\Packages\UseCase\User\CreateUserInteractor'
            );
        }else{
            $this->app->bind(
                'App\Packages\Infrastructure\User\RepositoryInterface',
                'App\Packages\Infrastructure\User\UserRepository'
            );
        }
    }

③UseCase InteractorにInput Dataを渡します。

Input Data<DS>

<?php

namespace App\Packages\Domain\User\Dto;

class UserInputData
{
    private $name;
    private $tel;

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

    /**
     * @return mixed
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * @return mixed
     */
    public function getTel()
    {
        return $this->tel;
    }
}

?>

いわゆるDTOってやつです。 ※ <DS>はData Structure

Input Boundary

<?php

namespace App\Packages\UseCase\User;

use App\Packages\Domain\User\Dto\UserInputData;

interface CreateUserUseCaseInterface
{
    public function handle(UserInputData $userInputData);
}

?>

Use Caseのインターフェースクラスになります。

実装クラス(UseCase Interactor)で必要となるメソッド名を定義します。

DIを使うことで、Controller層はUseCase層の実際の実装クラス(UseCase Interactor)ではなく、

インターフェースクラス(Input Boundary)に依存することになり、Use Case層への依存度を緩和します。

※ <I>はInterface

UseCase Interactor

<?php
namespace App\Packages\UseCase\User;

use App\Packages\Domain\User\User;
use App\Packages\Domain\User\Dto\UserInputData;
use App\Packages\Domain\User\Dto\UserOutputData;
use App\Packages\Domain\User\VO\Name;
use App\Packages\Domain\User\VO\Tel;
use App\Packages\Infrastructure\User\RepositoryInterface as UserRepositoryInterface;

class CreateUserInteractor implements CreateUserUseCaseInterface
{
    private $userRepository;

    //①Data Access Interface<I>
    public function __construct(UserRepositoryInterface $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    /**
     * @param UserInputData $userInputData
     * @return UserOutputData
     * @throws \Exception
     */
    public function handle(UserInputData $userInputData)
    {
        $uuid = uniqid("user_");
        //②Entity
        $user = new User(
            $uuid,
            new Name($userInputData->getName()),
            new Tel($userInputData->getTel()));
        //③Data Access
        $this->userRepository->save($user);
        //本来はPresenterを用意するが、MVCFWの都合上OutPutDataをレスポンス
        $userOutputData = new UserOutputData($uuid);
        return $userOutputData;
    }
}

?>

UseCase Interactorではアプリケーションロジックを実装します。

①Data Access InterfaceはRepositoryのインターフェースクラスになります。

実装クラスで必要となるメソッドを定義します。

②User Entiryのオブジェクトを生成しています。

③User EntiryのオブジェクトをRepositoryに渡し、保存します。

Entity

<?php

namespace App\Packages\Domain\User;

use App\Packages\Domain\User\VO\Name;
use App\Packages\Domain\User\VO\Tel;

class User
{
    private $uuid;
    private $name;
    private $tel;

    public function __construct(
        $uuid,
        Name $name,
        Tel $tel
    )
    {
        $this->uuid = $uuid;
        $this->name = $name;
        $this->tel = $tel;
    }

    /**
     * @return string
     */
    public function getUuid() :string
    {
        return $this->uuid;
    }

    /**
     * @return Name
     */
    public function getName() :Name
    {
        return $this->name;
    }

    /**
     * @return Tel
     */
    public function getTel() :Tel
    {
        return $this->tel;
    }

    /**
     * @param Name $name
     */
    public function changeName(Name $name)
    {
        $this->name = $name;
    }
}

?>

エンティティはビジネスルールをカプセル化したオブジェクトです。

クリーンアーキテクチャでいうEntityはDDDでいうEntityとは定義幅が異なります。

DDDドメインモデルはEntityとValue Objectとドメインサービスを含んだものを指します。

クリーンアーキテクチャのEntityは、DDDドメインモデルにあたります。

ドメイン駆動設計のエンティティと比べるとその定義幅はかなり広いです。

UseCase層で受け取ったデータをもとにEntityのオブジェクトを作成しています。

EntityのValueObject

<?php

namespace App\Packages\Domain\User\VO;

use Exception;

class Name
{
    private $value;

    public function __construct(string $value)
    {
        if(strlen($value) < 3) throw new Exception();
        if(strlen($value) > 20) throw new Exception();
        $this->value = $value;
    }

    /**
     * @return string
     */
    public function getValue() :string
    {
        return $this->value;
    }
}

?>

User Entiryを構成するValue Objectになります。

DataAccessInterface

<?php

namespace App\Packages\Infrastructure\User;

use App\Packages\Domain\User\User;

interface RepositoryInterface
{
    /**
     * @param User $user
     * @return mixed
     */
    public function save(User $user);

    /**
     * @param $userId
     * @return mixed
     */
    public function find($userId);
}

?>

Repositoryのインターフェースクラスを定義しています。

DataAccess

<?php

namespace App\Packages\Infrastructure\User;

use App\Packages\Domain\User\User;
use App\Models\User as UserModel;

class UserRepository implements RepositoryInterface
{
    /**
     * @param User $user
     * @return mixed|void
     */
    public function save(User $user)
    {
        $userModel = new UserModel();
        $userModel->uuid = $user->getUuid();
        $userModel->name = $user->getName();
        $userModel->tel = $user->getTel();
        $userModel->save();
    }

    public function find($userId)
    {
        $userModel = UserModel::findOrFail($userId);
        ...
    }
}
?>

Repositoryクラス(Data Access)を定義しています。

Repository Interface(Data Access Interface<I>)を継承しています。

DIにより、Data AccessはUseCase層であるData Access Interface<I>に依存することになります。

InMemoryRepository

<?php

namespace App\Packages\Infrastructure\User;

use App\Packages\Domain\User\User;

class InMemoryRepository implements RepositoryInterface
{
    protected $data;

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

    public function all()
    {
        return $this->data;
    }

    public function save(User $user)
    {
        $this->data->push([
            'name' => $user->getName(),
            'tel' => $user->getTel()
            ]);
    }

    public function find($userId){}
}

DIにより、ロジックが疎結合になります。

特定のインフラストラクチャに依存しないようにロジックを記述できます。

インメモリに切り替えることも容易になります。

まとめ

俯瞰してみると、重要なビジネスルールであるEntity層とUseCase層はフレームワークを変えたとしても、

特に変更はなくそのまま使うことができる様な形になり、フレームワークに依存していないと、見て取れます。

UIや、データベースなどの変化しやすいレイヤーと

ビジネスロジックという変化しづらいレイヤーを分離することができました。

最後に

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

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

careers.012grp.co.jp

面倒な作業をコマンドでできるようにする

こんにちは、バックエンドエンジニアの青山です。
今回はShellスクリプトを書いてコマンド化して業務を少し効率化してみたということについて書きます。今までShellスクリプトこういう下らないやつしか書いたことがなかったので、どうにか書く機会を作りたいと思ってやってみました。

前提

今開発に関わっている自社メディア(バックエンドはLaravel製)において、フロントエンドのソースコードの変更を
「ビルド済みのCSSとJSファイル、画像ファイルがフロントエンドのリポジトリ内にあるので、それらをLaravelのpublic以下に配置する」
という方法で反映させています。
数分で終わる作業なので正直全く苦ではないのですが、この作業をコマンド一発でできればきっと便利です。なのでそれ用のShellスクリプトを書いてコマンド化してみることにしました。
やりたいことは以下の通りです。

  • git pullで差分をローカルに反映
  • CSS、JS、画像ファイルをLaravelのpublicディレクトリ以下にコピー

ディレクトリ作成 & PATHを通す

ファイルはどこに置いてもいいと思いますが、管理しやすいように今回はディレクトリを作成してそこに置くことにします。

$ mkdir my_commands

今回の最終目的はコマンド一発で実行できるようにすることです。ディレクトリを作成したらbash_profileを編集してPATHを通しておきます。

$ vim ~/.bash_profile
#追記して変更を保存

export PATH=$PATH:~/my_commands

bash_profileの変更を反映します。

$ source .bash_profile

これでmy_commandsディレクトリ以下にあるファイル名で直接スクリプトが実行できます。

スクリプトを書く

ファイルを作成します。今回はfrontcpという名前にしました。これがコマンド名となります。
VSCodeなどのエディタで編集する場合は.shの拡張子を付けた方がやりやすいです(後で外してください)。

$ cd ~/my_commands

$ touch frontcp

スクリプト自体は以下のように記述しました。
今回は

  • 関数ごとに分けてmainから実行
  • 引数に配列を持たせて取り回す

という書き方をしていますが、やってみた感じ何となくShellスクリプトにおいては悪手な気がします。おそらくもっとそれらしい作法があるのだろうと思いますが、やりたいことが実現できているので一旦OKとします。

コマンドを実行した時にパーミッション周りでエラーが出ないよう、パーミッションを変更したら完了です。
これで、ターミナル上で

$ frontcp

とするだけでファイルが全てコピーされるようになりました。

所感

以下、今回やってみて感じたメリットです。

  • 手作業でやっていたことをShellに書き起こせばスクリプト自体をドキュメント代わりにできるので、作業手順などを資料にまとめて管理する手間が少し省ける
  • タイポや操作間違いなどの人為的ミスが減る
  • Shellスクリプトぐらい当然書けますよね、という無言の圧力に耐性が付く

逆にデメリットとしては以下の点が挙げられるかなと思います。

  • 文法は簡単だが見方や考え方が他言語と違うので、他言語の作法に引っ張られると変なところでハマる可能性が高い
  • 元々の作業にかかる時間とスクリプトを作成するのにかかる時間を比べると、時間的に元が取れない可能性がある(何百回と作業が発生して初めてスクリプト作成に使った時間がペイできたりとか)

ちなみに元を取るという話ですが、今回の場合は今後200回ぐらい作業が発生すればスクリプト作成に使った時間の元が取れる計算です...。

さいごに

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

careers.012grp.co.jp

AWSでSlackアプリを作ってみた ~開発環境構築からデプロイまで~

こんにちは、フロントエンドエンジニアの高野です。
最近AWSで簡単なSlackアプリを作成したので、その記録をまとめていきます。

今回話すこと/話さないこと

話すこと

  • Slack側のapp初期設定について
  • 開発環境の構築について
  • Lambdaのフォルダ構成について

話さないこと

  • DynamoDBの詳しいテーブル構成 / 実装方法について
  • Boltの細かい実装について

作りたいもの

Wizでは毎日の業務報告をSlack上で報告しています。 f:id:wiz012:20210706104237p:plain

この報告をGitHubやタスク管理ツールとの連携、 指定期間の業務内容の抽出などをできるようにして楽した〜い! というのが主なモチベーションです。
まずはその足掛かりとして、今回は 毎日の業務報告内容をDBに保存する という簡単なアプリを作成しました。

AWSについては初めての実装だったので、至らない点あるかもしれません...
コメントなどで指摘いただけると嬉しいです。

主な構成

f:id:wiz012:20210706110727p:plain

バックエンドアプリケーションとしては SlackのフレームワークであるBoltを採用しました。サーバーレス!なシンプルな構成です。
CloudWatch Logsを使用して各種ログを保存しています。
また、開発環境/デプロイツールとしてはServerless Frameworkを起用しています。

www.serverless.com

各種コマンドがSlackで叩かれる-> API Gatewayを介してLambdaが走り応答する->場合によってdynamoDBからデータを受け取ったり保存したりする
というのが基本的な流れです。

AWS側の構築

前述したようにServerless Frameworkを用いて構築していくので、serverless.ymlに下記のように記述していきます。

service: antenna

useDotenv: true
frameworkVersion: '2'
custom:
  dynamodb:
    start:
      port: 4000
      inMemory: true
      migrate: true
      seed: true
    seed:
      development:
        sources:
          - table: ${env:INITIAL_TABLE}
            sources: [./migrations/firstTable.json]

plugins:
  - serverless-offline
  - serverless-dynamodb-local
  - serverless-dotenv-plugin

provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
  lambdaHashingVersion: 20201221
  environment:
    SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET}
    SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN}
    INITIAL_TABLE: ${env:INITIAL_TABLE}
    ROOM_NAME: ${env:ROOM_NAME}

functions:
  app:
    handler: handler.app
    events:
      - http:
          method: post
          path: /slack/events
    role: boltLambdaRole

resources:
  Resources:
    usersTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${env:INITIAL_TABLE}
        AttributeDefinitions:
          - AttributeName: UserId
            AttributeType: S
          - AttributeName: SortKey
            AttributeType: N
        KeySchema:
          - AttributeName: UserId
            KeyType: HASH
          - AttributeName: SortKey
            KeyType: RANGE
        BillingMode: PAY_PER_REQUEST

    boltLambdaRole:
      Type: AWS::IAM::Role
      Properties:
        Path: /my/cust/path/
        RoleName: boltLambdaRole
        AssumeRolePolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
              Action: sts:AssumeRole
        Policies:
          - PolicyName: lambda
            PolicyDocument:
              Version: '2012-10-17'
              Statement:
                - Effect: Allow
                  Action:
                    - logs:*
                    - dynamodb:*
                  Resource:
                    - arn:aws:logs:*:*:*
                    - arn:aws:dynamodb:*:*:*

※簡単のために、ポリシーを一部簡略化しています。

プラグインとして、以下の三つを使用しています。

plugins:
  - serverless-offline
  - serverless-dynamodb-local
  - serverless-dotenv-plugin

plugin / 開発環境について

serverless-offline
こちらは、ローカルマシン上ででlambdaのエミュレートを実行 できるようにしてくれるありがたいpluginです。
デプロイや実行で課金されずにテストをすることができます。

github.com

serverless-dynamodb-local
serverless-offline はLambdaのみのエミュレートなので、dynamoDBを動かす場合はこのままではローカルテストできません。
なので、また別でserverless-dynamodb-localというプラグインを入れました。

www.serverless.com

serverless-dotenv-plugin
こちらのプラグインはenvファイルをserverless上で読み込むためのプラグインです。

www.serverless.com

また、SlackでAPIを叩くにはlocalhostは当然使用できないので、
ローカルのポートを外部公開するためにngrokを使用します。

ngrok.com

ダウンロード後、

$ ngrok http 3000

と叩けば、 ローカルの3000番ポートを外部公開してくれます。

f:id:wiz012:20210706130339p:plain

上記だと、localhost:3000の内容が伏せている部分(https://xxxxxxxxxx.ngrok.io) として外部公開されているということになります。 少し複雑になってきたので図式化します。

f:id:wiz012:20210706132216p:plain

Slack側で必要な設定

Slack側ではまず、開発者専用のページを開きアプリを登録する必要があります。

api.slack.com

f:id:wiz012:20210706134655p:plain

まずはBoltの認証部分で必要な

  • Signing Secret
  • Bot Token

の二つを探し出します。

Signing Secretは先程のappページのトップをスクロールしていくと見つけることができます。 f:id:wiz012:20210706135152p:plain

Showを押すと表示されるのでこちらをメモしておきます。

また、Bot Tokenは左サイドメニューの「OAuth & Permissions」を選ぶと 「Bot User OAuth Token」として表示されます。
こちらもメモしておきましょう。 f:id:wiz012:20210706135617p:plain

また、Slack側に先程ngrokで発行したURLを登録していきます。
登録箇所は主に3つあります。

  • Event Subscriptions (必須)
  • Interactivity & Shortcuts (応答が必要な場合必要)
  • Slash Commands(スラッシュコマンドを使う場合必要)

Event SubscriptionsではChallenge認証というものが行われ、これがパスされればURLが有効とみなされます。
Boltを使っていた場合、勝手にいい感じでやってくれるのであまり意識する必要はありません。 また、serverless + ngrokを使う場合は、 ngrok発行のURL + /ステージ名/slack/events となります。
ngrok発行以降のURLは、serverless offlineを実行したシェルから確認することができます。
以下の画像の赤枠、緑色の部分です。

f:id:wiz012:20210706140912p:plain

f:id:wiz012:20210706140510p:plain

Interactivity & Shortcutsでは、何かインタラクティブな動作を実行させる際に必要です。
例えばmodalによるフォーム送信、ボタンのアクション、セレクトメニューなどです。
slack側からAPI側に通信するのを許可します。
右上のトグルをOnにした後、URLを登録します。

f:id:wiz012:20210706141409p:plain

Slash Commands では、各種コマンドとその実行先のURLを登録します。
スラッシュコマンドというのは、Slackのメッセージ上から /foo で実行できるコマンドのことです。

f:id:wiz012:20210706141630p:plain

また、Slackアプリのスコープ(権限)を登録していく必要があります。 OAuth & Permission を下にスクロールしていくとScopesというセッションがあるので、必要なスコープを追加していきます。
実行する目的から自動でスコープを選択したい場合は、Event Subscriptionsの Subscribe to bot eventsセクションから選択することも可能です。

Lambdaの構成

普段慣れ親しんでいるといる理由からNode.jsにて記述しました。
特に何もしないままServerlessでアップロードすると、不要なnode_modulesなども含めてZIP化されてしまいます。
Lambdaは一度のアップロードで50MBを超える場合直接のアップロードができなくなってしまうので、なるべくファイルサイズを抑えたいところです。
なので今回はWebpackを入れてバンドルするようにしました。

.
├── build                                   // Serverlessでアップロードするフォルダ
├── node_modules
├── package-lock.json
├── package.json
├── readme.md
├── src                                      // 開発用フォルダ
 │   ├── boltFunc
 │   ├── dynamo.js                     //  ラムダ関数
 │   └── handler.js                     //  ラムダ関数
└── webpack.config.js

webpack.config.jsはこのように記述しています。

const path = require('path')
const TerserPlugin = require('terser-webpack-plugin')

module.exports = {
  mode: 'production',
  entry: {
    handler: './src/handler.js',
    dynamo: './src/dynamo.js',
  },
  target: 'node',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'build'),
    libraryTarget: 'commonjs2',
  },
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          mangle: false,
        },
      }),
    ],
  },
}

ポイントとしては、 TerserPluginを使用してWebpackの圧縮方法をカスタマイズし、 mangleをfalseにしています。
これは、API GatewayからLambdaを指定する際に関数名が変わってしまうことを防ぐためです。
例えば、handler.appを指定した場合、Webpackによってhandler.aなどに関数名をリネームされると、エラーになってしまいます。
また、アウトプット形式をlibraryTarget: 'commonjs2' にするのを忘れないようにします。

handler.js(ラムダ関数のトップ)では下記のように、Slack Boltで登録するイベントをまとめて実行しています。

'use strict'

// ------------------------
// dynamoDb
// ------------------------
const AWS = require('aws-sdk')
AWS.config.update({
  region: 'ap-northeast-1',
})
const docClient = new AWS.DynamoDB.DocumentClient()

// ------------------------
// Bolt App Initialization
// ------------------------
const { App, ExpressReceiver } = require('@slack/bolt')
const expressReceiver = new ExpressReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  processBeforeResponse: true,
})
const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  receiver: expressReceiver,
})

// ------------------------
// Application Logic
// ------------------------

// bolt
const funcRegister = require('./boltFunc/slash/register.js').default
const funcDaily = require('./boltFunc/slash/daily').default
const funcWeekly = require('./boltFunc/slash/weekly').default
const funcMonthly = require('./boltFunc/slash/monthly').default
const funcEditUser = require('./boltFunc/slash/edituser').default
const funcEditReport = require('./boltFunc/slash/editreport').default

funcRegister(app, docClient)
funcDaily(app, docClient)
funcWeekly(app, docClient)
funcMonthly(app, docClient)
funcEditUser(app, docClient)
funcEditReport(app, docClient)

app.message('goodbye', async ({ message, say }) => {
  // say() sends a message to the channel where the event was triggered
  await say(`See ya later, <@${message.user}> :wave:`)
})

// ------------------------
// AWS Lambda handler
// ------------------------
const awsServerlessExpress = require('@vendia/serverless-express')
module.exports.app = awsServerlessExpress({
  app: expressReceiver.app,
})

また、実際にSlackのUIを構築する際は公式のBlock Kit Builderが非常に重宝しました。(閲覧にはログインが必要です。)

app.slack.com

ぽちぽち必要なコンポーネントを選択していくと、右にblockのコードが出力されます。
これをいい感じにリファレンスを見ながら加工しつつ、自分のコードに落とし込んでいきました。
一例として一つのslash commandのファイルを紹介しておきます。

require('date-utils')

const { checkRegist } = require('../validate/checkRegist') // DBにユーザー登録があるかをチェックする関数
const { putReport } = require('../db/report/putReport')  // DBに業務報告を保存する関数

exports.default = (app, docClient) => {
  app.command('/aq-edituser', async ({ ack, body, client }) => {
    await ack()

    const query = await checkRegist(body, client, docClient, true)
    if (!Object.keys(query).length) return

    try {
      const result = await client.views.open({
        trigger_id: body.trigger_id,
        view: {
          type: 'modal',
          callback_id: 'aq-edituser',
          title: {
            type: 'plain_text',
            text: 'ユーザデータの編集',
          },
          submit: {
            type: 'plain_text',
            text: '編集📝',
            emoji: true,
          },
          blocks: [
            {
              type: 'input',
              block_id: 'type',
              element: {
                type: 'static_select',
                placeholder: {
                  type: 'plain_text',
                  text: 'ユウシャ',
                  emoji: true,
                },
                initial_option: {
                  text: {
                    type: 'plain_text',
                    text: query.Item.JobType,
                    emoji: true,
                  },
                  value: query.Item.JobType,
                },
                options: [
                  {
                    text: {
                      type: 'plain_text',
                      text: 'フロントエンドエンジニア',
                      emoji: true,
                    },
                    value: 'フロントエンドエンジニア',
                  },
                  {
                    text: {
                      type: 'plain_text',
                      text: 'バックエンドエンジニア',
                      emoji: true,
                    },
                    value: 'バックエンドエンジニア',
                  },
                ],
                action_id: 'type',
              },
              label: {
                type: 'plain_text',
                text: '種別',
                emoji: true,
              },
            },
          ],
        },
      })
    } catch (err) {
      console.error(err)
    }
  })

  // モーダル情報のlisten
  app.view('aq-edituser', async ({ ack, body, view, client }) => {
    await ack()
    const values = view.state.values

    const user = body.user
    const typeString = values.type.type.selected_option.text.text

    /**
     * DB保存
     */
    const user_id = user.id
    const user_name = user.name
    const type = values.type.type.selected_option.value
    await editUser(
      {
        user_id,
        user_name,
        type,
      },
      docClient
    )

    /**
     * メッセージ送信
     */
    try {
      await client.chat.postMessage({
        channel: process.env.ROOM_NAME,
        blocks: [
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: `冒険の書を上書きしました📝
              *【名前】*: ${user_name}
              *【種別】*: ${typeString}
              `,
            },
          },
        ],
      })
    } catch (err) {
      console.log(err)
    }
  })
}

動くとこんな感じです。 f:id:wiz012:20210706143142p:plain

デプロイ

serverless.ymlを配置しているフォルダにて、
$ serverless deploy
を実行すればOKです。Serverlessはデプロイがとてもお手軽ですね。
stageを指定したい場合は、$ serverless deploy --stage pro というようにオプションを追加します。
特定のLambdaだけをデプロイする場合は$serverless deploy function -f 関数名 で実行できます。

API GatewayのURLをメモって、先程ngrokのURLを登録したSlack APIのURL認証に登録しなおします。

f:id:wiz012:20210706171022p:plain

これで無事Slack上でアプリを稼働させることができました! f:id:wiz012:20210706171637p:plain

最後に

とても簡単なアプリですが、AWS初挑戦だったので覚えることが沢山あり、苦戦しましたがまた同時にとても楽しかったです。
今後外部サービス連携を強化してもっと実用的なアプリにしていきたいです。
また、Lambdaをモノリスで構成したので、アプリが大きくなってきたら切り分けなども要検討だと思っております。

お決まりの句にはなりますが、Wizではエンジニアを募集中です。

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

careers.012grp.co.jp

Reactコードレビュー会を行いました。

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

Wizの新プロジェクトにてフロントエンドをReactを使用して実装しました。

新プロジェクトのフェーズ1が終了とフェーズ2の間で一度リファクタリングを行おうと思い今回コードレビュー会を開きました。

コードレビュー以外にもNext.jsについての話も盛り上がったのですが、今回はコードレビューの内容のみお届けします。

コードレビューでご指摘していただいた中から2つほど紹介いたします。

使用技術

  • React
  • Redux-toolkit
  • Emotion

fetchの共通化

ある程度fetchをする回数が増えてくると、bodyを書いたりheadersでcontent-typeを毎回書くのはつらなくなってきます。

なので、fetchを共通化させます。

修正前

こちらがログイン処理などをまとめているsliceになります。

現在例で出している処理は、違うタイプの処理ではありますが認証タイプの処理は他のAPIで使用することもあるので共通化させておくと実装する際に楽になります。

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { apiURL } from '../../utils/constants'

export interface loginFormInput {
  mailAddress: string;
  passWord: string;
}

export const fetchAsyncLogin = createAsyncThunk('login', async (data: loginFormInput) => {
  //  ログインAPI
  const loginParams = {
    login_id: data.mailAddress,
    password: data.passWord
  }
  const res = await fetch(`${apiURL}/login`, {
    method: 'POST',
    body: JSON.stringify(loginParams),
    headers: {
      'Content-Type': 'application/json'
    },
  })
  return res.json()
})

export const fetchAsyncAuth = createAsyncThunk('auth', async (data: {token: string}) => {
  //  認証API
  const res = await fetch(`${apiURL}/auth`, {
    method: 'POST',
    mode: 'cors',
    headers: {
      Authorization: `Bearer ${data.token}`
    },
  })
  return res.json()
})
~~省略~~

修正後

通化用ファイルを作成します。

// post用 認証なし
export const fetchPostNoAuth = async (url: string, params) => {
  const res = await fetch(url, {
    method: 'POST',
    body: JSON.stringify(params),
    headers: {
      'Content-Type': 'application/json'
    },
  })
  return res.json()
}

// 認証付きAPI bodyなし
export const noBodyFetch = async (url: string, token: string, method: 'GET'|'PATCH'|'POST') => {
  const res = await fetch(url, {
    method: method,
    mode: 'cors',
    headers: {
      Authorization: `Bearer ${token}`
    }
  })
  return res.json()
}

// 認証付きAPI bodyあり
export const bodyFetch = async (url: string, token: string, method: 'POST' | 'PATCH', body: string) => {
  const res = await fetch(url, {
    method: method,
    mode: 'cors',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`
    },
    body: body
  })
  return res.json()
} 

通化を行ったら、先程のsliceも変更します。

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import Cookies from 'js-cookie'
import { apiURL } from '../../utils/constants'
import { fetchPostNoAuth, noBodyFetch } from '../../../ts/fetch'

// 非同期はSliceの外に出してcreateAsyncThunkを使用する
export interface loginFormInput {
  mailAddress: string;
  passWord: string;
};


export const fetchAsyncLogin = createAsyncThunk('login', async (data: loginFormInput) => {
  //  ログインAPI
  const loginParams = {
    login_id: data.mailAddress,
    password: data.passWord
  }
  const url = `${apiURL}/login`
  const fetchLogin = fetchPostNoAuth(url, loginParams)
  return fetchLogin
})

export const fetchAsyncAuth = createAsyncThunk('auth', async (data: {token: string}) => {
  //  認証API
  const url = `${apiURL}/auth`
  return noBodyFetch(url, data.token, 'POST')
})

結構スッキリしました。

Error Boundaryの追加

最後にErrorBoudaryの追加です。

ErrorBoudaryとは

自身の子コンポーネントツリーで発生した JavaScript エラーをキャッチし、エラーを記録し、クラッシュしたコンポーネントツリーの代わりにフォールバック用の UI を表示する React コンポーネントです。 (引用: React公式|Error Boudanry

React16からコンポーネント内でエラーが発生した場合、React コンポーネントツリー全体がアンマウントされてしまうので追加する必要があります。

実装

import React from 'react'
import { ErrorBlock } from './views/components/block/Error';


type Props = {
  children: JSX.Element
}

class ErrorBoundary extends React.Component<Props, { hasError: boolean }> {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  render() {
    if (this.state!.hasError) {
      return <ErrorBlock />;
    }

    return this.props.children;
  }
}

export default ErrorBoundary

上記がErrorBoudary検知用のコンポーネントになります。 コードの説明します。

あとはこちらをエラー時にマウントさせたい場所でimportします。

import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import store from './stores'
import { jsx } from '@emotion/react'
import GlobalStyle from './style/GlobalStyle'
import 'react-hot-loader'


const app = document.getElementById('app')
//component
import Routing from './Routing'
import ErrorBoundary from './ErrorBoundary'


ReactDOM.render(
  <ErrorBoundary>
    <Provider store={store}>
      <GlobalStyle />
      <Routing />
    </Provider>
  </ErrorBoundary>
  ,
  app
)

if (module.hot) {
  module.hot.accept()
}

おわりに

以上がコードレビュー会での指摘された内容でした。

今後はredux周りのパフォーマンスチューニングを行っていきたいと思っています。 またその際は記事にしてアップしたいと思います。

ここまで読んで頂き、ありがとうございます。


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

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

careers.012grp.co.jp

LINEBotのメッセージ形式あれこれ

https://kazzstorage.com/wp-content/uploads/2019/10/trial-messaging-api-00-300x300.png

こんにちは、バックエンドエンジニアの米山です。

前回、LINEBotの自作をするための環境構築について書きました。

LINE DevelopersサイトのMessaging APIの頁を見ていたければわかるのですが、「イベント」という概念があり、このイベントごとに自分で処理を作り「メッセージ」を返します。

今回はこの「メッセージ」についていくつかパターンがあるので紹介したいと思います。

開発言語は、前回に引き続きPHP(Laravel)を使います。

テキストメッセージを返す

一番の基本ですね。ユーザーが入力したメッセージをオウム返ししても良いですし、挨拶のようなbotを作っても良いかもしれません。

$bot->replyText($reply_token, 'おはよう');

一番簡単にテキストメッセージを返せるのはreplyText()メソッドを使った返し方です。

$reply_tokenにはLINEユーザーがbotに対して送ってきた時のトークンを取得し、返却時に使います。(以降同様)

実はテキストメッセージはいくつか返し方があり、一番簡単なのは上記の通りなのですが

$builder = new TextMessageBuilder('おはよう');
$bot->replyMessage($reply_token, $builder);

でも返せます。

仕組み的には、replyText()メソッドの中でもTextMessageBuilderが使われており、同じような処理をしているからです。

選択肢ボタンのメッセージ

アンケートなどで「どちらか選んでください」という選択肢のボタンをメッセージとして返すことができます。

$button1 = new MessageTemplateActionBuilder('ラベル1', 'ぼたん1');
$button2 = new MessageTemplateActionBuilder('ラベル2', 'ぼたん2');
$buttons = array($button1, $button2);
$builder = new TemplateMessageBuilder('タイトル', new ButtonTemplateBuilder(null, 'タイトル', null, $buttons));
$bot->replyMessage($reply_token, $builder);

ちょっと複雑ですが

  • 1つ1つのボタンを作成する
  • ButtonTemplateBuilderでまとめる
  • TemplateMessageBuilderで返す(中身はボタン) という作り方になります。

クリックリプライボタン

LINE Developersサイトでボタンとは別に解説されているのが「クイックリプライ」です。

最大13個までの横に並ぶボタンを定義できます。

こちらも選択肢と同じような使い方ができるものです。

$button1 = new QuickReplyButtonBuilder(new MessageTemplateActionBuilder('ラベル1', 'ぼたん1'));
$button2 = new QuickReplyButtonBuilder(new MessageTemplateActionBuilder('ラベル2', 'ぼたん2'));
$buttons = array($button1, $button2);
$builder = new TextMessageBuilder('タイトル', $buttons);
$bot->replyMessage($reply_token, $builder);

若干使い方が異なりますね。

他の返し方

テキストメッセージ、選択肢ボタン、クイックリプライと紹介してきましたが慣れてくると、他の返し方もできるようになります。

  • タップしたらLINE内部ブラウザでWebサイトにアクセス(返信メッセージにURLを埋め込む)
  • 日付を選択させるメッセージを返す(Date Picker)
  • 画像で返す、音声で返す、動画で返す
  • スタンプを返す(ただし使える範囲は限られている)
  • 位置情報を返す

などなど。また、これらは入れ子にすることができ

  • 選択肢ボタンの1つ目を選ぶとWebサイトに飛ぶ、2つ目を選択した時はメッセージを返す
  • クイックリプライの1つ目をタップするとメッセージが出る、2つ目の場合は画像が送信されてくる のような挙動も作ることができます。

おわりに

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

LINEというアプリとこのSDKを用いた技術は覚えておいて損はないと思います。

twitterなど、他のSNSのDeveloperにもなれる可能性がありますしSNS関連の知見も増えていくことでしょう。

ここまで読んで頂き、ありがとうございます。


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

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

careers.012grp.co.jp