Wiz テックブログ

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

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

LINEBotをPHPで自作する

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

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

今回はPHP(Laravel)を使ってLINEBotを自作するための環境を作ってみようと思います。

実は、社内で先日リリースしたシステムがこれを実施しています。

LINE公式アカウントを作成し、LINE Official Account Managerで自動応答を作ることは可能です。

ですが、このコンソールで受け答えの設定ができるのは一部の機能で、返し方もある程度決められているため

より柔軟に自動応答させるにはbotの自作が必要なのです。

ライブラリをインストール

PHP(Laravel)のライブラリ管理ではおなじみの、Composerを使います。

composer require linecorp/line-bot-sdk

これでインストール完了です。

Tokenを設定する

LINEBot(LINE Messaging API)を利用するには「ビジネスアカウント」というものを作成し

LINE公式アカウント(コンソール内で「チャネル」と呼んでいるもの)を作成し

「チャネルトークン」「チャネルシークレット」というものを取得し、環境変数として設定する必要があります。

公式アカウントの作成方法はググれば沢山出てくるので、ここでは割愛します。

インストール後の状態だと、vendor/linecorp/line-bot-sdk/src/Laravel/config/line-bot.php

<?php

return [
    'channel_access_token' => env('LINE_BOT_CHANNEL_ACCESS_TOKEN'),
    'channel_secret' => env('LINE_BOT_CHANNEL_SECRET'),
];

とあるので、.envファイルで設定してあげれば良さそうです。

(私の場合はこのファイルの存在を知らず、プロジェクト内ファイルとしてconfig/line-bot.phpに作ってしまいました。笑)

LINEBot用のコントローラクラスを作成

通常のLaravelで、RESTfulな処理を作る時と同様にAPIを作成します。

php artisan make:controller LINEController

ルーティングも設定します(HTTP MethodはPOSTで作ります)

Route::post('/line/callback', [LINEController::class, 'callback']);

LINEControllerは全体的にこんな感じで作ります

public function callback(Request $request)
{
    $bot = app('line-bot');
    // LINE シグネチャのチェック
    $signature = $_SERVER['HTTP_' . LINEBot\Constant\HTTPHeader::LINE_SIGNATURE];
    if (!LINEBot\SignatureValidator::validateSignature($request->getContent(), config('line-bot.channel_secret'), $signature)) {
        abort(400);
    }

    // ここに自動応答処理を書く

    // JSONでステータスコード=200のレスポンスを返す
    response()->json(['return-data' => 'data'], 200);
}

ステータスコードは常に200を返すように作ります。

何故こんなことをしているかと言うと、開発ガイドラインで指示されているからです。

https://developers.line.biz/media/partner-docs/LINE_BOT_Development_Guidelines.pdf

ここまで作れると、こんな感じのイメージになるかと思います。 (下図のYour Systemの部分がここまで構築した内容になります)

https://cdn-ak.f.st-hatena.com/images/fotolife/s/shirakiya/20160530/20160530104247.png

まとめ

PHP(Laravel)で、自作のLINEBotプログラムを作る場合は

  • ComposerでSDKをインストールする
  • LINE公式アカウントを作成し、ChannelToken・ChannelSecretを取得。環境変数として設定する
  • APIを作る要領で、ルーティングの設定、Controllerを作成する
  • Controller以降を実装する(レスポンスは必ずHTTPステータス=200を返すようにする)

こんなところですね。

ここまでの内容はあくまで疎通レベルのものなので、実際はDBを使ったり、サービスクラスを作ったりしながら、特定のキーワードや挙動に反応する処理を作っていくことになります。

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


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

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

careers.012grp.co.jp

PHP(サーバ)上からWebSocketにアクセスする

https://koenig-media.raywenderlich.com/uploads/2020/08/Screenshot_2020-08-10_at_15.08.19.png

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

前回、WebSocketの導入・構築について書きました。

その中で、

onMessage() : クライアントのsend()時に発火するイベント。クライアントにメッセージを返すところまでここで実装する。

と書きました。

これで気づいた方がいたかもしれませんが、onMessage()はクライアントからの送信で発火するイベントであり、サーバから発火できるものではない、ということです。

「えー矛盾してるじゃん!?WebSocketはお互い任意のタイミングでやりとりできるんじゃないの?」

という声が聞こえて来そうです。

この辺りは私もまだ学習中の身でして、Google先生曰く「redisを使おう!」とか「ブロードキャストイベントが〜」

などのご教示を頂くことが多いのですが、私は「1クライアントとしてサーバ上でWebSocket接続する」という道を選択しました。

前回の記事の通りに構築すると、PHP(apache)が稼働しているポート(80)とは別にWebSocketサーバを立ててListenすることになります(8282)

ですので、PHPから8282ポートに向けて接続してやろう、という考え方です。

WebSocketクライアントソフトのインストール

方向性を決めたところで色々探していたら、先輩からこちらを紹介いただきました。

Textalk
https://github.com/Textalk/websocket-php

composer textalk/websocket

でインストール可能です。

PHPでWebSocket接続!

GitHubの例にあるように

$client = new WebSocket\Client("ws://localhost:8282/");
$client->text("Hello WebSocket.org!");
echo $client->receive();
$client->close();

WebSocketクラスをnewするタイミングでWebSocketのURLを渡してあげます。

前回構築した環境では、PHPを動かす環境とは別にサーバを立ててますので「ws://localhost:8282」とします。

将来的に、WebSocketサーバを独立して立てる場合のことを考えても、PHPからクライアント接続できることはメリットだと考えます。

おわりに

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

PHP(サーバ)を1クライアントとしてWebSocket接続する方法を紹介しました。

他に追加のミドルウェア(redisやfirebase等)を入れずに、シンプルな考え方で接続できるので、私はスッキリしました。

今後時間があれば、これらのミドルウェアの知見を伸ばしつつWebSocketを構成してみたいですね。


今回はここまでです。ありがとうございました。


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

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

careers.012grp.co.jp

LaravelでWebSocket(Ratchet)を使ってみた

https://image.slidesharecdn.com/asyncphpdrupalcampla-140908080505-phpapp02/95/asynchronous-php-and-realtime-messaging-39-638.jpg?cb=1410262331

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

今回開発案件で、WebSocketを使ったシステム開発に携わることになったので、導入時のメモや困ったことetc...を書いていこうと思います。

まず、WebSocketとは?

Socketを使った技術は、古くからJavaなどの世界で存在していましたが、WebSocketはこれをインターネット上で可能にしたものです。

【従来のWeb通信】

https://create-it-myself.com/wp-content/uploads/study-websoket-spec-image1-768x536.png

従来のWeb通信は「1回のリクエスト」に「1回のレスポンス」で処理を行なっていました。

キャッチボールをイメージしてもらえたら良いと思います。

【WebSocketによる通信】

https://create-it-myself.com/wp-content/uploads/study-websoket-spec-image7-768x535.png

これがWebSocketになると「双方向通信」となり、ソケットが開いている間は任意のタイミングでクライアント<-->サーバ間通信が行えます。

WebSocketを導入するために

今回実施した開発案件では、以下の環境・条件で構成を考えました。

  • サーバ側の開発言語はPHP(Laravel)であること
  • フロントエンド側にサーバ側の言語仕様を押し付けないこと
    • PHP(Laravel)の設計思想や概念に依存するコードをフロント側に強要しない。フロントはフロントで技術選定できる環境にする。
  • なるべく、サードパーティ製のツール(firebase等)を使わず、リアルタイム性を実現できること

以上のことから、今回は「Ratchet」というライブラリを選択しました。

LaravelでWebSocketを構築しようとすると、必ずと言っていいほど「Laravel-Echo」が検索ヒットします。

しかし上記に挙げた条件の通り、フロントエンドはフロントエンドで技術選定を行いコーディングするので、依存しないRatchetを選びました。

早速Ratchetを導入!

では、LaravelプロジェクトにRatchetを導入してみましょう。

LaravelではComposerを使ったライブラリ管理が可能ですが、Ratchetの公式ページに導入方法が記載されています。

http://socketo.me/docs

ComposerでRatchetをインストール

公式ページに書いている通り、composer.jsonを書き換えても良いですし

composer require cboden/ratchet

でも良いです。(執筆時点でのVer.はv0.4.3を使用しています)

WebSocketサーバを作る

Webサーバ内で別のサーバが動くイメージで実装します。

Webサーバ(apache)内でアプリサーバ(tomcat)が動く感覚に近いです。

Java使いはイメージしやすいかも。(厳密には異なる概念です。あくまでイメージね)

require 'vendor/autoload.php';


$app = require 'bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();

$server = IoServer::factory(
    new HttpServer(
        new WsServer(
            new Chat()
        )
    ),
    8282
);


$server->run();

ここでは、Laravelのメソッドや環境変数等を使うために「require 'vendor/autoload.php'」したり色々やっています。

これを自作artisanコマンド等で、自動実行できるようにするとこの記述は不要になります。

Laravel上で動くようになるからですね。

コードを見ておや?と思った方がいるかもしれません。

WebSocketはHTTP技術を拡張したものなので、HttpServerクラスでListenを行います。

このHttpServerクラスはRatchetで作られたものですが、RequestオブジェクトはPSR-7 HTTP message interfacesで拾うことができます。

従って、その引数に渡しているWsServerインスタンスやChatインスタンスも同様です。

Chatクラスの中身は、公式ページにも記載がある通りMessageComponentInterfaceを実装する形にします。

これを実装することでWebSocketの各イベントが使用可能になります。

今回、私が実装したのは以下の4つです。

  • onOpen() : クライアントから初めてWebSocketに接続された時に発火するイベント
  • onMessage() : クライアントのsend()時に発火するイベント。クライアントにメッセージを返すところまでここで実装する。
  • onClose() : クライアントから切断(close()時、もしくはブラウザの×ボタン押した時)に発火するイベント
  • onError() : WebSocket通信中に何らかのエラーが発生した時に発火するイベント

まとめ

LaravelでRatchetを扱う時は

  • Composerでライブラリをインストール
  • ListenするWebSocketサーバ(クラス)を作る
  • 各イベントを理解する

をわかっていれば実装できると思います。

まだまだ資料は少なめですが、Ratchet関連の解説記事もそこそこあるので、調べたらいくつか出てきます。

今回はここまでです。良きSocketライフを!(笑)


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

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

careers.012grp.co.jp

Webアクセシビリティ検証フレームワーク「acot」を導入してみた。

acotのロゴ画像

はじめまして。フロントエンドエンジニアの内田です。

最近福岡では日中30℃を超える気温でぐったりです。アイスがとろけております。

さて突然ですが、この記事をご覧になっている皆様はアクセシビリティについて悩んだ事はありますでしょうか?

Wizでは残念な事にWebアクセシビリティに対して、まだまだ高水準な品質を維持出来ていないのが現状です。

アクセシビリティとは

どのような環境にいても人々が平等にアクセス可能で、全てのコンテンツや機能が利用できる状態の指標です。

また、似た言葉としてユーザビリティがあります。

ユーザビリティとは

ユーザビリティは、ユーザーにとっての使いやすさを表す概念です。

ここで言う使いやすさとは、ユーザーがストレスを感じない操作性、わかりやすい導線設計を施し、成約に至るまでに必要な労力の少なさなどを指します。


今回はアクセシビリティについてお話させていただきます。


Webアクセシビリティにおいて意識する必要のある代表的なハンディキャップ例としては下記が挙げられます。

全盲

全盲の方は画面上の文字を音声で読み上げるスクリーンリーダーや点字で表示する点字ディスプレイなどを利用して読み取っていますので画像とテキストの位置関係やどんな画像が表示されているのかが明確に把握できるような工夫をしなければいけません。

■ロービジョン

視力が低下している方や視野が狭くなっている場合(視野狭窄)、視野の真ん中が見えない場合(中心暗点)などの弱視の方を指します。

Webページを利用する際、ロービジョンのユーザーは次のような使い方をしている為、サイズ感やコントラストなどの視覚的表現、視覚以外の方法で情報取得ができる対応が必要になります。

  • 画面拡大ソフトやOSに標準で搭載されている機能を用いて画面表示を拡大する
  • 画面の色を反転する
  • OSやブラウザの設定によって、文字サイズを変更する
■色覚多様性

色の見え方は人によって異なり、その割合は日本人の男性で5%、女性で0.2%と言われています。

よく言われる例だと正常と異常の状態を赤と緑の色だけで表現しているUI等は区別が難しくなります。対応方法としては下記のような配色を目指すべきでしょう。

  • 赤と緑は見えづらいので青やオレンジを使う
  • 色に頼らない、色数を増やさない
  • コントラストを強くしすぎない
■上肢障害

手や腕に障害がある場合はマウスやトラックパッドなどが利用困難でありホバーやドラッグ&ドロップが難しかったりするのでトラックボールを利用するパターンが多いようです。 なので、クリックポイントを近くしすぎるとトラックボールのほんの僅かな動きで通り過ぎてしまったりするので、リンクやアイコンなどのクリックできる位置をある程度余白をつけて配置する等の配慮が必要です。

聴覚障害

近年では動画をコンテンツに含んでいる場合がありますが、その動画内で情報説明したりする場合には手話通訳をつけたり、字幕情報を付与したりして併用することが望ましいとされています。

例えば、動画の中で問い合わせ先などを示す場合には、問い合わせ電話番号などを音声だけで伝えず、視覚、聴覚の両方で具体的な情報を提供する必要があります。

また、最近では警告音の見える化などの対応もAppleでは配慮されています。 www.apple.com





ですが、これら全てを意識してエンジニアが対応していくのは困難です。

そこで、W3Cが標準化しているWCAGガイドラインを参照して全世界のエンジニアはこのガイドラインに準じて対応していく事になっているのですが、 このWCAGは正直何書いてるのか把握しづらく、「で?どうしたらいいの?」といった気持ちになる上、達成基準が設けられておりA (最低レベル)、AA、AAA (最高レベル)という基準があり、正直AAAに対応するにはかなりきびしい道のりです...

waic.jp

ではどうするか?

これらの問題を対応しなければいけない事はわかってはいるのですが、全ての項目を一つ一つ把握しつつDevToolsのLighthouseを実行して目視でチェックしていくのは非効率で地獄です。

そこで、弊社では一部メディアにてWebアクセシビリティー検証フレームワークであるacot-a11y/acotを導入してみました。

github.com

導入手順

acot 自体は puppeteer ではなく puppeteer-core へ依存するため、別途インストールが必要な構成となっています。

$ npm i --save-dev @acot/cli puppeteer

インストールが完了したら、以下のコマンドを実行する事で設定ファイルの構築を行うことができます。

$ npx acot init

すると下記のようにコンソール内にて対話形式で設定を進めていくと、acot.config.jsという設定ファイルが生成されます。カンタン!!!

✔ What is the origin for the audit target? · http://localhost:3000
✔ What kind of server do you want to connect to? · command
✔ What is the command to start the server? · npm run dev
✔ Do you want to use the config recommended by acot? · no / yes
✔ Which runner do you want to use? · default
✔ Which format do you prefer for the config file? · javascript
✔ Which is the npm client used to install the dependent packages? · npm

acot.config.js

module.exports = {
  extends: ['@acot'],
  connection: {
    command: 'npm run dev',
  },
  origin: 'http://localhost:3000',
  paths: ['/'],
};

では早速実行してみましょう!!

$ npx acot run

すると下記のようなサマリーが表示されました。

acotの結果キャプチャ

エラーの数が絶望的ですね。まぁこれは開発途中のものなのでご愛嬌。

さくっとErrorをPassできそうなものとしては@acot/wcag/img-has-nameあたりですかね。

ログを追うと

✖  img element or img role MUST has name.  @acot/wcag/img-has-name
   ├─ <img src="/_next/image?url=%2Fimg%xxxxxxxxxx.png&w=1920&q=75" decoding="async" style="visibility: visible;…
   ├─ at ".contentsBox--keyVisual__img > div > img"
   └─ see https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html

と表示されており、おそらく画像に対してalt属性による代替テキストの設置、またはロールを付与し忘れるといった基本的な箇所が抜け落ちていたようです。

尚、このときのLighthouseによるアクセシビリティの数値としては87でした。

f:id:romeoromen:20210629014150p:plain

では対象箇所をさくっと修正して再度実行してみます。

f:id:romeoromen:20210629021151p:plain

お!@acot/wcag/img-has-nameが見事Passになりました!🙌🏼

ちなみにLighthouseで再度計測してみたところ指し示した値としては94!!たったこれだけで爆上がり!!

f:id:romeoromen:20210629022056p:plain

今回は単体ページでのチェックとなりましたが、acotのrunnerにsitemapのパスを指定することで複数ページに渡ってチェックしてくれる事も可能です。 ですが、おそらく数百のページを対象としたメディアサイトの場合は相当処理が重くなるかと思いますので要注意です。

module.exports = {
  runner: {
    uses: '@acot/sitemap',
    with: {
      source: 'http://localhost:3000/sitemap.xml',
    },
  },
};

現段階の実行タイミングとしてはプレコミット時等のアクションを検知したオート実行ではなく任意のタイミングで実行する運用方法で対応しています。

まとめ

まだまだアクセシビリティについてはやらなければいけない事が山積みの状況ではありますがこのように一歩一歩着実にPassしていく事で本当のWebの世界というものが広がっていくような気がしてきます。

全世界のなるべく多くのユーザーに正しくわかりやすい情報を伝えていく事が自身の喜びでありWizの目標の1つです。

最後に


Wizではエンジニアとして一緒に働く仲間を絶賛募集しております。

ご興味のある方、是非ご覧下さい..!!

careers.012grp.co.jp