はじめに
LINEBot&Clova Advent Calendar 2018 13日目の記事は@nori3tsuが担当します!
これまで様々な構成でLINEボットを開発する機会がありました。その中でNuxt.jsを使った開発が一番開発効率が良かったのでご紹介します。
最近作った構成例:
- AWS API Gateway + Lambda(Node.js) + DynamoDB
- Ruby on Rails + Sidekiq
- Node.js + Express + Bull
- Nuxt.js(Vue.js + Express)
※【東京】LINE Bot & Clova CEK開発者2018大忘年会 - connpassのLTで発表した内容の詳細でもあります。
Nuxt.jsとは
Nuxt.jsとはVue.jsアプリを作成するためのフレームワークです。すぐにフロントエンド開発を始められる機能がそろっていることが特徴で、今年はとても流行っていたという印象があります。
- シングルページアプリケーション(SPA)
- サーバーサイドレンダリング(SSR)
- ルーティング(Vue-Router)
- AltJS, AltCSSのトランスパイレーション
- HTTPサーバー(Node.js, Express, Koa, Hapi 等)
- 静的ファイルの配信
- サーバサイドレンダリング
なぜNuxt.jsでLINEボット?
1. Nuxt.jsですぐにLINEボット開発をはじめられる
Nuxt.jsはプロジェクトを作成するだけで開発に必要な様々な機能を備えたプロジェクトテンプレートが出来上がるので、すぐに開発を始めることができます。また、Vue.jsによるフロントエンド開発の機能に加えHTTPサーバの機能を備えているため、Messaging APIで利用する静的ファイル(画像・動画・音声等)の配信や、Webhook用のAPIを実装をすることも可能です。
2. Messaging APIでメッセージングキューが不要
LINE Messaging APIから送信されるWebhookリクエストには下記の2つの特徴があります。
- 1リクエストに複数のイベントが入ってくる(一説によると最大数百イベント)
- Webhookリクエストはなるべく速くHTTPステータス200を返却する必要がある(一説によると1秒)
これらの特徴からWebhookサーバーは基本的にメッセージングキューと組み合わせて実装することになりますが、Nuxt.jsに含まれるNode.jsはイベントループ・非同期IOで動作するため、リクエストに含まれる各イベントをイベントループに登録してすぐにレスポンスを返却すれば、ユーザへのリプライ送信等の処理は非同期で実行させることができます。
ある程度規模が大きくなったりCPUを使う処理が多く含まれている場合はメッセージングキューとワーカーを組み合わせる必要も出てきますが、Node.jsのみで問題ないケースも多いと思います。
3. SPAでLIFFを高速化できる
LIFFは、LIFF SDKを利用するために画面表示にSDKを初期化します。この初期化処理は、おそらく裏側でアクセストークンを取得するなどの処理が入っており、体感速度的に結構遅いです。LIFFアプリ上の複数の画面でSDKを利用する場合、都度初期化処理が実行されることになります。
Nuxt.jsはVue.jsアプリケーションを作るためのフレームワークであるため、Vue.jsを使ったSPAを実装することができます。
SPAだとLIFF起動時に一度だけSDKの初期化が実行すれば良いので、この影響を小さくすることができます。
実装の詳解
実際にNuxt.jsを使ってMessaging APIのWebhookとLIFFを実装していきます。
ここで実装するサンプルプロジェクトはGitHubにUPしていて、各章の区切り毎にコミットしているので、是非参考にしてみてください。
バージョン情報
本稿ではMacの以下のバージョンを利用してアプリケーションを作成しています。
$ node -v
v10.13.0
$ npm -v
6.4.1
$ yarn -v
1.12.3
$ sw_vers
ProductName: Mac OS X
ProductVersion: 10.14.1
BuildVersion: 18B75
Nuxt.jsプロジェクトの作成
アプリケーションの雛形となるNuxt.jsプロジェクトを作成します。
プロジェクトの作成
Nuxt.jsプロジェクトを作成するためのcreate-nuxt-appコマンドをインストールしてください。
$ npm i -g create-nuxt-app
Nuxt.jsプロジェクトを作ります。実行後、先頭が ?
になっている行で入力待ち受けになりますので、下記の通りに設定してください。
$ create-nuxt-app
> Generating Nuxt.js project in /Users/Nori/Project/line-bot-with-nuxtjs
? Project name line-bot-with-nuxtjs
? Project description My polished Nuxt.js project
? Use a custom server framework express
? Use a custom UI framework bulma
? Choose rendering mode Single Page App
? Use axios module no
? Use eslint yes
? Use prettier yes
? Author name [お好きに, そのままでもOK。]
? Choose a package manager yarn
デフォルトで作成されるプロジェクトにはESLintで警告が発生するコードが含まれていますので、下記のコマンドを実行して自動修正してください。
$ npx eslint --fix '**/*.js'
$ npx eslint --fix '**/*.vue'
開発用サーバの起動
開発用のサーバーを起動します。
$ yarn run dev
> line-bot-with-nuxtjs@1.0.0 dev /Users/Nori/Project/line-bot-with-nuxtjs
> cross-env NODE_ENV=development nodemon server/index.js --watch server
[nodemon] 1.18.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching: /Users/Nori/Project/line-bot-with-nuxtjs/server/**/*
[nodemon] starting `node server/index.js`
ℹ Preparing project for development 19:48:25
ℹ Initial build may take a while 19:48:25
✔ Builder initialized 19:48:25
✔ Nuxt files generated 19:48:25
✔ Client
Compiled successfully in 5.06s
ℹ Waiting for file changes 19:48:31
READY Server listening on http://127.0.0.1:3000
起動後にブラウザからhttp://127.0.0.1:3000にアクセスして、下記の画面が表示されれば成功です。
LINE Messaging APIを実装
Nuxt.jsのExpressを利用してLINE Messaging APIのWebhook機能を実装していきます。
ExpressにAPI機能を追加
API(server/api
)とエンドポイント(server/api/routes
)のディレクトリを作成。
$ mkdir -p server/api/routes
APIのエンドポイントを読み込むファイルを追加。
const express = require('express')
const app = express()
const router = express.Router()
// まずはルーティングのみ追加
router.post('/webhook', require('./routes/webhook'))
module.exports = router
Webhook APIのエンドポイントを追加。
module.exports = async (req, res, next) => {
res.json({ result: 'OK' })
}
(1)開始
から (1)終了
までを追加。
async function start() {
// Init Nuxt.js
const nuxt = new Nuxt(config)
// Build only in dev mode
if (config.dev) {
const builder = new Builder(nuxt)
await builder.build()
}
// ===(1)開始===
// API機能を追加
app.use(require('./api'))
// ===(1)終了===
// Give nuxt middleware to express
app.use(nuxt.render)
// Listen the server
app.listen(port, host)
consola.ready({
message: `Server listening on http://${host}:${port}`,
badge: true
})
}
下記のcurlコマンドを実行して、{"result":"OK"}
が返却されれば成功です。
$ curl -XPOST http://localhost:3000/webhook
{"result":"OK"}%
ngrokのインストール・起動
ローカルでLINEボットを開発するために、ngrok
をインストールして起動しておきます。
LINEのWebhookを受信するためにはhttps通信が必須です。ngrok
を利用すると、ngrok
のhttpsサーバを経由してローカルにリクエストが届きます。
$ brew install ngrok
ngrok
を起動します。Forwardingのhttpsから始まるURL(下記の例ではhttps://1ba069ea.ngrok.io
)にリクエストするとlocalhost:3000
にリクエストが届きます。
$ ngrok http 3000
ngrok by @inconshreveable (Ctrl+C to quit)
Session Status online
Account Norimitsu Yamashita (Plan: Free)
Version 2.2.8
Region United States (us)
Web Interface http://127.0.0.1:4040
Forwarding http://1ba069ea.ngrok.io -> localhost:3000
Forwarding https://1ba069ea.ngrok.io -> localhost:3000
Connections ttl opn rt1 rt5 p50 p90
21 0 0.00 0.00 4.50 29.36
下記のcurlコマンドを実行して、{"result":"OK"}
が返却されれば成功です。
$ curl -XPOST https://1ba069ea.ngrok.io/webhook
{"result":"OK"}%
※ httpsのURLはngrok
を起動する度に変わります。
LINEチャンネルの作成
LINE Messaging APIのチャンネルをフリープランで作成します。様々な記事で共有されているので詳細な作成方法は割愛しますね。例えば、下記の記事などはわかりやすいと思います。
【第1回】Messaging APIを使うためにチャンネルを作成する – cmblog
LINEチャンネル設定
作成したチャンネルを選択してチャンネル設定をします。
チャンネル基本設定のタブを開いてください。(チャンネルを開いた直後はこの画面になっているはずです。)
- Webhook送信:
利用する
に変更 - WebhookURL: Herokuで確認したURL+
/webhook
に変更。ngrok
でForwardingしているhttpsのURLを指定します。(例:https://1ba069ea.ngrok.io/webhook
- 自動応答メッセージ:
利用しない
に変更
Webhook URLの接続確認
ボタンを押して成功しました
のメッセージが表示されると成功です。
Webhookエンドポイントの実装を追加
LINE Messaging API用の Webhookエンドポイントを実装していきます。
依存ライブラリのインストール
実装に必要なライブラリをインストールします。
$ yarn add pino \
bluebird \
dotenv \
request \
request-promise \
querystring \
node-cache \
@line/bot-sdk
- pino ... ロガー
- bluebird ... Promise拡張
- dotenv ... ファイル(
.env
)から環境変数を読み込む - request ... HTTPクライアント
- request-promise ... requestのPromise拡張
- querystring ...
application/x-www-form-urlencoded
のエンコード用 - node-cache ... メモリキャッシュ, アクセストークンのキャッシュ用
- @line/bot-sdk ... LINE SDK
LINEチャンネルアクセストークンの取得・キャッシュ実装を追加
LINEチャンネルアクセストークンの取得・キャッシュ処理を実装します。
const logger = require('pino')()
const querystring = require('querystring')
const rp = require('request-promise')
const Promise = require('bluebird')
const NodeCache = require('node-cache')
const cache = require('../cache')
// アクセストークンキャッシュ有効期限
// 60*60*24*29 = 2505600 = 29日間
const ttl = 2505600
module.exports = async (client_id, client_secret) => {
// キャッシュを取得
const cacheKey = `access_token:${client_id}`
const accessToken = await cache.get(cacheKey)
if (accessToken) {
logger.info('アクセストークンをキャシュから取得', client_id)
return accessToken
}
// アクセストークンを発行
logger.info('アクセストークンを発行', client_id)
const options = {
method: 'POST',
uri: 'https://api.line.me/v2/oauth/accessToken',
body: querystring.stringify({
grant_type: 'client_credentials',
client_id,
client_secret
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
resolveWithFullResponse: true,
json: true
}
const res = await rp(options)
if (res.statusCode != 200) {
return Promise.reject(
new Error('LINEチャンネルアクセストークンの取得に失敗')
)
}
// キャッシュを設定
await cache.set(cacheKey, res.body.access_token, ttl)
return res.body.access_token
}
キャッシュ処理を実装します。
const NodeCache = require('node-cache')
module.exports = new NodeCache({ stdTTL: 600 })
LINEクライアント生成処理を実装
チャンネルアクセストークンを設定したLINEクライアントを生成する処理を追加します。
const line = require('@line/bot-sdk')
const getChannelAccessToken = require('./getChannelAccessToken')
module.exports = async () => {
const channelAccessToken = await getChannelAccessToken(
process.env.LINE_CHANNEL_ID,
process.env.LINE_CHANNEL_SECRET
)
return new line.Client({ channelAccessToken })
}
Webhookエンドポイントの実装
WebhookエンドポイントにLINEから送信されるJSONボディのパースに対応します。LINEから送信されたリクエストボディのローデータを元に署名検証する必要があるため、JSONにパースせずにテキストのまま受け取ります。
const express = require('express')
const app = express()
const router = express.Router()
const bodyParser = require('body-parser')
// Webhookエンドポイント
router.post(
'/webhook',
// 署名検証のためテキストでパース
bodyParser.text({ type: 'application/json' }),
require('./routes/webhook')
)
module.exports = router
Webhookエンドポイントの実装を追加します。
require('dotenv').config()
const logger = require('pino')()
const crypto = require('crypto')
const cache = require('../repositories/cache')
const createLineClient = require('../repositories/line/createLineClient')
// LINEの署名検証
const validateSignature = req => {
if (!req.body) {
logger.info('LINEの署名検証でリクエストボディが空')
return false
}
try {
const signature = crypto
.createHmac('SHA256', process.env.LINE_CHANNEL_SECRET)
.update(req.body)
.digest('base64')
const headersSignature = req.headers['x-line-signature']
if (headersSignature != signature) {
logger.info('LINEの署名検証で署名不一致', { headersSignature, signature })
return false
}
} catch (err) {
logger.info('LINEの署名検証でエラー発生', { msg: err.message })
return false
}
return true
}
module.exports = async (req, res, next) => {
logger.info({ headers: req.headers, body: req.body })
// LINEの署名検証
if (!validateSignature(req)) {
return res.status(403).json({})
}
// LINE Messaging APIのクライントを作成
const client = await createLineClient()
const events = JSON.parse(req.body).events
events.forEach(async event => {
let messages = [
{
type: 'text',
text: `${event.message.text}を受信しました`
}
]
// リプライを送信
// あえてawaitで待ち受けない
client.replyMessage(event.replyToken, messages).catch(err => {
logger.error('LINEのリプライに失敗', { msg: err.message })
})
})
// リプライより前にHTTPステータス200を返却
res.json()
}
LINEチャンネル接続情報を設定します。
# チャンネル基本設定の`Channel ID`の値を設定
LINE_CHANNEL_ID=xxx
# チャンネル基本設定の`Channel Secret`の値を設定
LINE_CHANNEL_SECRET=xxx
※.env
はgit管理しません。
.env
の設定後、Nuxt.jsを再起動します。
再起動完了したら動作確認をします。Webhookを設定したLINEチャンネルに文字を入力、{入力文字}を受信しました
とリプライが返ってきたら成功です。
LINE Front-end Framework(LIFF)の実装
Nuxt.jsのExpressとVue.jsを利用したLIFFを実装していきます。
LIFFアプリの追加
チャンネルを開き、LIFF
のタブを開いてください。追加
ボタンを押して下記の内容を設定してください。
- 名前:
LIFFとNuxt.js
(任意) - サイズ: Compact
- エンドポイントURL:
https://1ba069ea.ngrok.io
-
ngrok
で起動したhttpsのURLを指定してください。
-
LIFFの動作確認用にWebhookのリプライ処理を変更します。(1)開始
から (1)終了
まで追加してください。
let messages = [
{
type: 'text',
text: `${event.message.text}を受信しました`
}
// ===(1)開始===
,
{
type: 'text',
text: `LIFF動作確認用URL: ${process.env.LIFF_URL}`
}
// ===(1)終了===
]
LIFF URLの環境変数を追加します。(1)開始
から (1)終了
まで追加して、Nuxt.jsを再起動してください。
# チャンネル基本設定の`Channel ID`の値を設定
LINE_CHANNEL_ID=xxx
# チャンネル基本設定の`Channel Secret`の値を設定
LINE_CHANNEL_SECRET=xxx
# ===(1)開始===
# LIFF URLの値を設定
LIFF_URL=line://app/1629132446-jD4868kr
# ===(1)終了===
LINEチャンネルに文字を入力してください。{入力文字}を受信しました
と共にLIFF動作確認用URL: {LIFF URL}
が表示されれば成功です。
表示されたURLを押すと Nuxt.jsで作るLINEアプリ
の認可画面が表示されます。ここでは同意する
を押します。
Nuxt.jsのデフォルトページが表示されれば成功です。
LIFF SDKの組み込み
Nuxt.jsにLIFF SDKのjsファイルを組み込みます。Nuxt.jsでは、nuxt.config.js
ファイルのhead
に読み込むjs/cssを指定すると、全ての*.vue
で読み込んだリソースを利用することができます。
(1)開始
から (1)終了
までを追加します。
head: {
title: pkg.name,
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: pkg.description }
],
// ===(1)開始===
script: [{ src: 'https://d.line-scdn.net/liff/1.0/sdk.js' }],
// ===(1)終了===
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }]
},
LIFFの初期表示画面の実装
デフォルトのCSSを削除します。
<template>
<div>
<nuxt/>
</div>
</template>
<style>
</style>
LIFFの初期化と、次の項で実装するメッセージ送信・プロフィール取得のためのリンクを設定します。
<template>
<div class="container has-text-centered">
<div class="column is-4 is-offset-4">
<p class="subtitle has-text-grey">
Nuxt.jsで作るLINEアプリ[LIFF]
</p>
<ul>
<li>
<p class="subtitle">
<nuxt-link to="/send_message">
メッセージ送信
</nuxt-link>
</p>
</li>
<li>
<p class="subtitle">
<nuxt-link to="/show_profile">
プロフィール表示
</nuxt-link>
</p>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
mounted() {
// LIFFの初期化
liff.init(function(data) {
console.log(data)
})
},
}
</script>
LIFFからのメッセージ送信機能の実装
LIFFのSDKを経由して、LIFFアプリを開いているトークルーム上にメッセージを送信する機能を実装します。
<template>
<div class="container has-text-centered">
<div class="column is-4 is-offset-4">
<p class="subtitle has-text-grey">
LINEメッセージングAPI
</p>
<div>
<textarea
class="textarea"
placeholder="トークルームに送信する内容"
v-model="message"
/>
<br>
<button
class="button is-info is-block is-large is-fullwidth"
@click="send()"
>
送信
</button>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
message: ''
}
},
methods: {
send() {
if(this.message == '') return;
liff
.sendMessages([
{
type: 'text',
text: this.message
}
])
.then(function() {
liff.closeWindow()
})
.catch(function(error) {
liff.closeWindow()
})
}
}
}
</script>
LIFF動作確認用URLを開いてLINEメッセージングAPIのページに遷移します。テキストを送信して、トークルーム上に入力したテキストが表示されていれば成功です。
LIFFからのプロフィール表示機能の実装
LIFFのSDKを経由して、LIFFアプリを開いているユーザのプロフィール情報を表示する処理を実装します。
<template>
<div class="container has-text-centered">
<div>
<p class="subtitle has-text-grey">
LINEプロフィールAPI
</p>
</div>
<div class="card">
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img v-bind:src="pictureUrl">
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{ displayName }}</p>
<p class="subtitle is-6">@{{ userId }}</p>
</div>
</div>
<div class="content">
{{ statusMessage }}
</div>
</div>
</div>
<div class="column is-4 is-offset-4">
<div>
<button
class="button is-info is-block is-large is-fullwidth"
@click="getProfile()"
>
取得
</button>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
userId: '',
displayName: '',
pictureUrl: 'https://bulma.io/images/placeholders/128x128.png',
statusMessage: ''
}
},
methods: {
getProfile() {
liff.getProfile().then((profile) => {
this.userId = profile.userId
this.displayName = profile.displayName
this.pictureUrl = profile.pictureUrl
this.statusMessage = profile.statusMessage
}).catch(function (error) {
alert("Error getting profile: " + error);
});
}
}
}
</script>
LIFF動作確認用URLを開いてプロフィール表示ページに遷移します。取得を押して、LIFFを開いたユーザのプロフィールが表示されていれば成功です。
おわりに
Nuxt.jsを使えばMessaging API・LIFF(LINE PAY)等、LINEボットの開発を簡単にはじめられるので、とってもオススメです。