Function ComputeでサーバレスなSlack Botを作る

作るもの

Alibaba CloudのFunction ComputeでサーバレスなSlack Botを作ります。

/my_echo [TEXT]と入力すると作ったBot[TEXT]を返すechoのコマンドを作ります。

f:id:asmsuechan:20180712212230p:plain

LambdaとSlack Botの連携をする公式ドキュメントを参考にしています。Slack側の設定はこちらを参照してください。

Handling events from the Events API using AWS Lambda | Slack

botの追加

App Deirectoryで「bots」と検索してbotを追加します。 Add Apps to Slack | Apps and Integrations | Slack App Directory

BotsのページでAdd Configurationを押します f:id:asmsuechan:20180712143814p:plain

適当なUsernameを入力します。私は安直にtest_botにしました。 f:id:asmsuechan:20180712143819p:plain

API Tokenをコピーします。 f:id:asmsuechan:20180712143822p:plain

ここの中程にあるcurlを実行して正常にbotが喋ったら成功です。

Slack Web API | Slack

$ curl -X POST -H 'Authorization: Bearer xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
-H 'Content-type: application/json' \
--data '{"channel":"random","text":"I hope the tour went well, Mr. Wonka.","attachments": [{"text":"Who wins the lifetime supply of chocolate?","fallback":"You could be telling the computer exactly what it can do with a lifetime supply of chocolate.","color":"#3AA3E3","attachment_type":"default","callback_id":"select_simple_1234","actions":[{"name":"winners_list","text":"Who should win?","type":"select","data_source":"users"}]}]}' \
https://slack.com/api/chat.postMessage

シャァベッタァァァァァァァ!! f:id:asmsuechan:20180712144622p:plain

Function Computeで関数の追加

一つ適当に作っておきます。以下を参照のこと。

qiita.com

Slash Commandsの追加

f:id:asmsuechan:20180712151039p:plain

my_echoってコマンドにします。 f:id:asmsuechan:20180712212639p:plain

実装

nodejsでソースコードを書きます。

こんな感じの構成にします。

$  tree
.
├── node_modules
....()
├── package.json
├── slack.js
└── yarn.lock

npmのライブラリであるrequestを使うので、/node_modulesも一緒にアップロードします。

※zipでアップロードしようとしましたがなんか壊れてるっぽい(2018/7/12)のでコンソール画面から「フォルダのアップロード」でアップロードしました。

const https = require('https')
const req = require('request')
const getRawBody = require('raw-body')

const VERIFICATION_TOKEN = "xxxxxxxxxxxxxxxxxxxxx"
const ACCESS_TOKEN = "xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

function post(query, request, response) {
  getRawBody(request, function(err, data){
    const params = {
      form: {
        token: ACCESS_TOKEN,
        channel: query.channel,
        text: query.text
      }
    }
    req.post('https://slack.com/api/chat.postMessage', params)
      .on('response', (res) => {
        const body = data
        const status = 200
        respBody = new Buffer(body)
        response.setStatusCode(status)
        response.setHeader('content-type', 'application/json')
        response.send(respBody)
      })
  })
}

function process(request, response) {
  const data = request.queries
  const message = {
    channel: data.channel,
    text: data.text
  }

  post(message, request, response)
}

function verify(request, response) {
  let status, body
  if (request.queries.token === VERIFICATION_TOKEN) {
    status = 200
    body = `challenge:${VERIFICATION_TOKEN}`
  } else {
    status = 500
    body = 'error'
  }
  getRawBody(request, function(err, data){
    respBody = new Buffer(body)
    response.setStatusCode(status)
    response.setHeader('content-type', 'application/json')
    response.send(respBody)
  })
}

exports.handler = (request, response, context) => {
  switch (request.queries.type) {
    case "url_verification": verify(request, response, '', ''); break
    case "event_callback": process(request, response); break
    default: process(request, response)
  }
}

verify()はSlack APIのサービス追加時のEvent Subscriptionsで使います。

url_verification event | Slack

handlerの第一引数であるrequestには以下のオブジェクトが入っています。

{
    "domain": null,
    "_events": {},
    "_eventsCount": 0,
    "method": "GET",
    "clientIP": "54.234.37.47",
    "url": "/2016-08-15/proxy/test/slack/?token=xxxxxxxxxxxxxxxxx&team_id=TBPAQJLHK&team_domain=asmsuechan&channel_id=CBNF2BSQG&channel_name=random&user_id=UBNF2BPB2&user_name=suenagaryoutaabc&command=%2Fmy_echo&text=asaaaaa&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands",
    "path": "/",
    "queries": {
        "channel_id": "CBNF2BSQG",
        "channel_name": "random",
        "command": "/my_echo",
        "response_url": "https://hooks.slack.com/commands/TBPAQJLHK/397188022723/xxxxxxxxxxxxxxxxxxxxxxx",
        "team_domain": "asmsuechan",
        "team_id": "TBPAQJLHK",
        "text": "asaaaaa",
        "token": "xxxxxxxxxxxxxxxxxxxxxxx",
        "user_id": "UBNF2BPB2",
        "user_name": "suenagaryoutaabc"
    },
    "headers": {
        "accept": "application/json,*/*",
        "user-agent": "Slackbot 1.0 (+https://api.slack.com/robots)"
    }
}

デバッグむずい。SlackへのPOSTリクエスト送信部とFunctionComputeのhandler部分を分けて実装したら作りやすいです。

特にBotsのIntegration Settingsのchallenge。

検証

Image from Gyazo

よっしゃできた。

まとめ

Slackさん、諸々のキーがどこにあるか分かりにくい。

FunctionComputeとTableStoreでサーバレスAPIを作る

FunctionComputeとTableStoreを連携します。

作るもの

f:id:asmsuechan:20180711144814p:plain スマートフォンから送信された位置情報をTableStoreに保存するPOST: /locationsというエンドポイントをFunctionComputeで作ります。

エンドポイントはswaggerだとこんな感じになります。

/locations:
  post:
    summary: "Create a new Location"
    consumes:
    - "application/json"
    produces:
    - "application/json"
    parameters:
    - in: "body"
      name: "location"
      description: "Location object"
      required: true
      schema:
        properties:
          latitude:
            type: "integer"
          longitude:
            type: "integer"
    responses:
      201:
        description: "created"
      # 40xなど他のレスポンスはputRow()のエラーから取得します。

ちなみに、実際にTableStoreに保存するのは地域メッシュデータと呼ばれる、緯度経度から算出した1km四方のメッシュ番号です。緯度経度から簡単に計算できます。

参考: 総務省統計局: 地域メッシュ統計について

準備

TableStoreにuuid: STRINGをプライマリキーとしたlocationsテーブルを作っておきます。

コンソールからポチポチするかコードから作るかしましょう。

コードから作成する場合は以下の記事が参考になります。 asmsuechan.hatenablog.com

実装

HTTPトリガーでTableStoreを叩くコードをnodejsで書いてこれをFunction Computeに乗せます。

Function ComputeとTableStoreそれぞれに関しては以下の記事が参考になると思います。 qiita.com

asmsuechan.hatenablog.com

さてコードです。最初は送られてきた緯度経度をそのままTableStoreに保存します。

// post_location.js
const TableStore = require('tablestore')
const getRawBody = require('raw-body')
const crypto = require('crypto')

const Long = TableStore.Long;
const instanceName = 'teststorage'
const tableName = 'locations'

module.exports.handler = function(request, response, context) {
  getRawBody(request, (err, data) => {
    const uuid = crypto.randomBytes(8).toString('hex')
    const params = {
      tableName: tableName,
      condition: new TableStore.Condition(TableStore.RowExistenceExpectation.IGNORE, null),
      primaryKey: [{ 'uuid': uuid }],
      attributeColumns: [
        { 'latitude': request.queries.latitude },
        { 'longitude': request.queries.longitude },
        { 'created_at': Date.now() }
      ],
      returnContent: { returnType: TableStore.ReturnType.Primarykey }
    }

    const client = new TableStore.Client({
      accessKeyId: ’LTAxxxxxxxxxxxx’,
      secretAccessKey: ’Rztxxxxxxxxxxxxxxxxxxxx’,
      endpoint: `https://${instanceName}.cn-xxxxxx.ots.aliyuncs.com`,
      instancename: instanceName,
    })

    let status = 500
    let body = ''
    client.putRow(params, (err, data) => {
      if (err) {
        status = err.code
        body = err.message
        console.log('error:', err)
      } else {
        status = 201
        body = 'success'
        console.log('success:', data)
      }
      const respBody = new Buffer(body)
      response.setStatusCode(status)
      response.setHeader('content-type', 'application/json')
      response.send(respBody)
    })
  })
}

次にfcliから新しくサービス、関数、トリガーを作成します。

fcliがない方はGItHub Releasesから最新版をダウンロードしてください。

Releases · aliyun/fcli · GitHub

$ fcli shell
>>> mks data_collector_api
>>> ls
data_collector_api
>>> cd data_collector_api
>>> mkf post_location -h post_location.handler -t nodejs6
>>> ls
post_location
>>> cd post_location
>>> mkt post_handler -t nodejs6 -c httpTrigger.yml

httpTriggerはこんな感じにしています。ちなみにトリガーの設定ファイルはトリガー種別により違います。

# httpTrigger.yml
triggerConfig:
    authType: anonymous
    methods: ["POST"]

参考: トリガーとイベントの設定 - ユーザーガイド| Alibaba Cloud ドキュメントセンター

地域メッシュデータを保存するようにする

地域メッシュコードを計算する関数を作ってこっちを代入するように変更します。

function calc_mesh_code (latitude, longitude) {
  p = parseInt((latitude * 60 / 40))
  a = (latitude * 60) % 40
  q = parseInt((a / 5))
  b = a % 5
  r = parseInt((b * 60 / 30))
  c = (b * 60) % 30

  u = parseInt((longitude - 100))
  f = longitude - (u + 100)
  v = parseInt((f * 60 / 7.5))
  g = (f * 60) % 7.5
  w = parseInt((g * 60 / 45))
  h = (g * 60) % 45

  return parseInt(`${p}${u}${q}${v}${r}${w}`)
}

変更できたらupfで関数をアップデートします。

>>> upf -d code -h post_location.handler -t nodejs6

画面からサクッとテストします。latitudelongitudeというパラメーターを作って値を入れ、「呼び出し」を押します。 f:id:asmsuechan:20180711143613p:plain

TableStoreのコンソール画面からlocationsテーブルを見てみると、ちゃんと地域メッシュコードが計算されてストアされています。 f:id:asmsuechan:20180711143450p:plain

まとめ

規模が小さいAPIを作る時、HerokuなどのPaaSを使うかこんな感じでサーバレスとして実装するか少し悩みますね。

TableStoreの使い方

Alibaba CloudのNoSQLであるTableStoreを使います。

目標

以下に示すTableStoreの基本的な操作をnodejsから行います。

  • テーブルの新規作成
  • テーブル一覧の表示
  • 行の作成
  • 行の表示
  • 複数行の表示

TableStoreとは

Alibaba CloudのNoSQLデータベースのサービスです。なんかもう公式の概要がめちゃくちゃ分かりやすいのでこっち載せておきます。

Table Storeの概要 - プロダクト概要| Alibaba Cloud ドキュメントセンター

準備

ドキュメントが最強なので基本はドキュメントに沿って進めます。
初期化 - 開発ガイド| Alibaba Cloud ドキュメントセンター

まずはRAMのユーザーに適切な権限を付与しておいてください。

次にTableStoreに新しくインスタンスを作ります。

画面でポチポチしてインスタンスを作ります。 f:id:asmsuechan:20180709135153p:plain

「sampleStore」をクリックしてインスタンスの画面に入ります。 f:id:asmsuechan:20180709135131p:plain

次に、VPCをバインドすれば準備完了です。 f:id:asmsuechan:20180709135239p:plain

クライアントの作成

TableStoreの操作に使うクライアントオブジェクトを作成します。

// client.js

const TableStore = require('tablestore')

const instanceName = 'sampleStore'

const client = new TableStore.Client({
  accessKeyId: ’LTAxxxxxxxxxxxx’,
  secretAccessKey: ’Rztxxxxxxxxxxxxxxxxxxxx’,
  endpoint: `https://${instanceName}.cn-xxxxxx.ots.aliyuncs.com`,
  instancename: instanceName,
})

console.log(client)

node clinet.jsでこのプログラムを実行します。

$ node list.js
Client {
  config:
   Config {
     accessKeyId: 'LTAxxxxxxxxxxxx',
     secretAccessKey: 'Rzxxxxxxxxxxxxxxxxxxxxxxx',
     accessKeySecret: null,
     stsToken: null,
     securityToken: null,
     logger: null,
     endpoint: 'https://teststorage.cn-xxxxxxx.ots.aliyuncs.com',
     httpOptions: {},
     maxRetries: undefined,
     instancename: 'teststorage',
     computeChecksums: true } }

初期化 - 開発ガイド| Alibaba Cloud ドキュメントセンター

テーブルの新規作成(createTable())

sampleTableという名前のテーブルを作成します。プライマリキーにはid: INTEGERを設定します。

// createTable.js
const TableStore = require('tablestore')

const instanceName = 'sampleStore'

const client = new TableStore.Client({
  accessKeyId: ’LTAxxxxxxxxxxxx’,
  secretAccessKey: ’Rztxxxxxxxxxxxxxxxxxxxx’,
  endpoint: `https://${instanceName}.cn-xxxxxx.ots.aliyuncs.com`,
  instancename: instanceName,
})

const params = {
  tableMeta: {
    tableName: 'sampleTable',
    primaryKey: [
      {
        name: 'id',
        type: 'INTEGER'
      }
    ]
  },
  reservedThroughput: {
    capacityUnit: {
      read: 0,
      write: 0
    }
  },
  tableOptions: {
    timeToLive: -1,
    maxVersions: 1
  }
}

client.createTable(params, (err, data) => {
  if (err) {
    console.log('error:', err)
    return
  }
  console.log('success:', data)
})

今の仕様だと特に返ってくるものはありません。

$ node createTable.js
success: { RequestId: '00000000-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }

テーブル一覧の表示(listTable())

作ったテーブルの一覧を返します。

// listTable.js
const TableStore = require('tablestore')

const instanceName = 'sampleStore'

const client = new TableStore.Client({
  accessKeyId: ’LTAxxxxxxxxxxxx’,
  secretAccessKey: ’Rztxxxxxxxxxxxxxxxxxxxx’,
  endpoint: `https://${instanceName}.cn-xxxxxx.ots.aliyuncs.com`,
  instancename: instanceName,
})

client.listTable({}, (err, data) => {
  if (err) {
    console.log('error:', err)
    return
  }
  console.log('success:', data)
})

実行するとちゃんと先ほど作ったテーブルの名前が返ってきます。

$ node listTable.js
success: { table_names: [ 'sampleTable' ],
  RequestId: '00000000-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }

行の作成(putRow())

paramsのconditionにRowExistenceExpectation.IGNOREを指定すると、既に保存されている行の中に指定するprimaryKeyと同じものがある場合内容が上書きされます。

RowExistenceExpectation.EXPECT_NOT_EXISTだと同じものがない時のみデータが挿入されます。

primaryKeyは自動でインクリメントするかユニークキーを発行して欲しいのですが、ユーザー側でよしなにするしかないのでしょうかね

// createRow.js
const TableStore = require('tablestore')
const Long = TableStore.Long
const currentTimeStamp = Date.now()
const instanceName = 'sampleStore'

const client = new TableStore.Client({
  accessKeyId: ’LTAxxxxxxxxxxxx’,
  secretAccessKey: ’Rztxxxxxxxxxxxxxxxxxxxx’,
  endpoint: `https://${instanceName}.cn-xxxxxx.ots.aliyuncs.com`,
  instancename: instanceName,
})

const params = {
  tableName: 'sampleTable',
  condition: new TableStore.Condition(TableStore.RowExistenceExpectation.IGNORE, null),
  primaryKey: [{ 'id': Long.fromNumber(1) }],
  attributeColumns: [
    { 'name': 'Table Store' },
    { 'created_at': currentTimeStamp }
  ],
  returnContent: { returnType: TableStore.ReturnType.Primarykey }
}

client.putRow(params, (err, data) => {
  if (err) {
    console.log('error:', err)
    return
  }
  console.log('success:', data)
})

作ったデータの内容が返ってきます。

$  node createRow.js
success: { consumed: { capacity_unit: { read: 0, write: 1 } },
  row: { primaryKey: [ [Object] ], attributes: [] },
  RequestId: '00000000-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }

attributesが空なのはバグ?

行の表示(getRow())

conditionを使うと他の列での検索もできます。

// getRow.js
const TableStore = require('tablestore')
const Long = TableStore.Long
const currentTimeStamp = Date.now()
const instanceName = 'sampleStore'

const client = new TableStore.Client({
  accessKeyId: ’LTAxxxxxxxxxxxx’,
  secretAccessKey: ’Rztxxxxxxxxxxxxxxxxxxxx’,
  endpoint: `https://${instanceName}.cn-xxxxxx.ots.aliyuncs.com`,
  instancename: instanceName,
})

var params = {
  tableName: 'sampleTable',
  primaryKey: [{ 'id': Long.fromNumber(1) }],
  maxVersions: 2
}
client.getRow(params, (err, data) => {
  if (err) {
    console.log('error:', err)
    return
  }
  console.log('success:', data)
  console.log(data.row.attributes)
})

data.rowに取得した行が格納されています。

$ node getRow.js
success: { consumed: { capacity_unit: { read: 1, write: 0 } },
  row:
   { primaryKey: [ [Object] ], attributes: [ [Object], [Object] ] },
  next_token: null,
  RequestId: '00000000-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }

[ { columnName: 'created_at',
    columnValue: 1531110991789,
    timestamp:
     Int64 { buffer: <Buffer 59 a9 53 7d 64 01 00 00>, offset: 0 } },
  { columnName: 'name',
    columnValue: 'Table Store',
    timestamp:
     Int64 { buffer: <Buffer 59 a9 53 7d 64 01 00 00>, offset: 0 } } ]

複数行の表示(batchGetRow())

複数行を表示するbatchGetRow()の処理は、ちょっとクセがあります。以下のコードは内容はサンプル通りなのですがちょっとJSっぽく書き換えています。

複数行操作 - 開発ガイド| Alibaba Cloud ドキュメントセンター

const TableStore = require('tablestore')
const Long = TableStore.Long
const currentTimeStamp = Date.now()
const instanceName = 'sampleStore'
const maxRetryTimes = 3
const retryCount = 0

const client = new TableStore.Client({
  accessKeyId: ’LTAxxxxxxxxxxxx’,
  secretAccessKey: ’Rztxxxxxxxxxxxxxxxxxxxx’,
  endpoint: `https://${instanceName}.cn-xxxxxx.ots.aliyuncs.com`,
  instancename: instanceName,
})

const params = {
  tables: [{
    tableName: 'sampleTable',
    primaryKey: [
      [{ 'id': Long.fromNumber(1) }]
    ],
    startColumn: 'created_at',
    endColumn: 'name'
  }]
}

function batchGetRow(params) {
  client.batchGetRow(params, (err, data) => {
    if (err) {
      console.log('error:', err)
      return
    }

    var isAllSuccess = true
    var retryRequest = { tables: [] }
    data.tables.forEach(tables => {
      let failedRequest = { tableName: tables[0].tableName, primaryKey: [] };
      tables.forEach(table => {
        // if the action is failed
        if (!table.isOk && table.primaryKey !== undefined) {
          isAllSuccess = false
          let pks = []

          table.primaryKey.forEach(primaryKey => {
            const name = primaryKey.name
            const value = primaryKey.value
            let kp = {}
            kp[name] = value
            pks.push(kp)
          })
          failedRequest.primaryKey.push(pks)
        }
      })
    })

    if (!isAllSuccess && retryCount++ < maxRetryTimes) {
      batchGetRow(retryRequest)
    }

    console.log(data.tables[0][0])
    console.log(data.tables[0][0].capacityUnit)
    console.log(data.tables[0][0].attributes)
    console.log(data.tables[0][0].primaryKey)
  })
}
batchGetRow(params, maxRetryTimes)

えっリトライ処理もユーザーが書かなきゃいけないの

めっちゃ薄いなあ

$ node getBatchRow.js
{ isOk: true,
  errorCode: null,
  errorMessage: null,
  tableName: 'sampleTable',
  capacityUnit: { read: 1, write: 0 },
  primaryKey: [ { name: 'id', value: [Int64] } ],
  attributes:
   [ { columnName: 'created_at',
       columnValue: 1531110991789,
       timestamp: [Int64] } ] }
{ read: 1, write: 0 }
[ { columnName: 'created_at',
    columnValue: 1531110991789,
    timestamp:
     Int64 { buffer: <Buffer 59 a9 53 7d 64 01 00 00>, offset: 0 } } ]
[ { name: 'id',
    value:
     Int64 { buffer: <Buffer 01 00 00 00 00 00 00 00>, offset: 0 } } ]

data.tables[0][0].attributesに取得したデータが入っています。

環境

$ node --version
v10.6.0

まとめ

公式ドキュメントが分かりやすくて特に詰まることなくできた。

正しいけど難しいことがダラダラ書かれているドキュメントより最低限のことがズバッと書かれているドキュメントの方が分かりやすい。