読者です 読者をやめる 読者になる 読者になる

Slackの「Interactive buttons」を使ってアンケートをしてみた

お久しぶりです。本間です。
またSlackのBotを作ったので紹介したいと思います。 今回はまだ使ったことのない「Interactive buttons」を使ってアンケートができるBotを作ってみました。 sadf

使い方

  1. HubotのいるチャンネルにJSON形式で作成したアンケートを投稿します。
    f:id:AdwaysEngineerBlog:20170210154538p:plain

  2. ダイレクトメッセージでアンケートが届きます。
    f:id:AdwaysEngineerBlog:20170210154546p:plain

  3. ボタンを押してアンケートに答えます。
    f:id:AdwaysEngineerBlog:20170210154553p:plain

  4. Hubotのいるチャンネルでアンケートの集計を行います。
    集計結果はJSONで出力されます。
    f:id:AdwaysEngineerBlog:20170210154602p:plain

事前知識

Slack Appの作成

ここからSlack Appを作成します。
次に、Bot Userのtokenを発行するために。認証後のリダイレクトURLを設定します。
f:id:AdwaysEngineerBlog:20170210154610p:plain

Bot Userの名前とオンラインの設定を行います。
f:id:AdwaysEngineerBlog:20170210154620p:plain

Bot Userのtokenを発行

ここの手順に従ってtokenを発行します。

1.認証 下記URLを作成して認証を行います。

https://slack.com/oauth/authorize?client_id={client_id}&scope=bot&redirect_uri={redirect_uri}

2.token発行 1で取得したコードを利用してtokenを発行します。 下記URLにアクセスするとtokenを取得できます。

https://slack.com/api/oauth.access?client_id={client_id}&client_secret={client_secret}&code={code}&redirect_uri={redirect_uri}

redirect_uriは先ほど設定したリダイレクトURLを使用してください。
client_idとclient_secretはSlack Appから取得できます。
f:id:AdwaysEngineerBlog:20170210154629p:plain

Slack Appのエンドポイントの作成

今回はNode.jsのExpressを使って実装します。
このエンドポイントには「Interactive buttons」が押される度にリクエストがきます。
エンドポイントのURLはSlack Appに設定します。
f:id:AdwaysEngineerBlog:20170210154640p:plain

JSON形式のアンケートから「Interactive buttons」を作成する処理

アンケートの作成はHubotを通してSlackから行います。

メイン処理

module.exports = (robot) => {
  // ここの正規表現でJSONを取得します。
  robot.respond(/enquete[\s\S]```[\s\S]((?:[\s\S]?.*)+)[\s\S]```/i, async (res) => {
    let slack = new Slack(config.slack);
    let inquiryJson = res.match[1];
    try {
      let inquiry = JSON.parse(inquiryJson);
      // ダイレクトメッセージのチャンネルを作成します。
      // 今回はアンケート作成者にアンケートを投稿します。
      let channel_id = (await slack.openDirectMessage(res.envelope.user.id)).channel.id;
      // アンケート回答用のAttachmentsを投稿します。
      await slack.postAttachments(Inquiry.object2attachments(inquiry), channel_id);
      // アンケート集計用のAttachmentsを投稿します。
      await slack.postAttachments(Inquiry.getAggregateAttachments(), res.envelope.room);
      res.send();
    } catch(e) {
      console.log(e);
    }
  });
}

JSONからAttachmentsに変換する処理と集計用のAttachmentsを作成する処理

export class Inquiry {
  static object2attachments(object) {
    return object.questions.map( (question, index) => { return {
      fallback: object.notice,
      title: question.title,
      callback_id: index,
      color: '#0000FF',
      attachment_type: 'default',
      actions: question.choices.map( (choice, index) => { return {
          name: index,
          text: choice,
          type: 'button',
          value: choice
      }})
    }});
  }
  static getAggregateAttachments() {
    return [
      {
        fallback: '集計する',
        title: '集計する',
        callback_id: 'aggregate',
        color: '#0000FF',
        attachment_type: 'default',
        actions: [
          {
            name: 'aggregate',
            text: '集計する',
            type: 'button',
            value: 'aggregate'
          }
        ]
      }
    ];
  }
}

アンケート回答時の処理

アンケート回答時の処理は作成したエンドポイントで行います。

メイン処理

let answers = {};
router.post('/', async (req, res) => {
  let params = JSON.parse(req.body.payload);

  // 集計時の処理
  if (/^aggregate$/.test(params.callback_id)) {
    await aggregateProcess(params);
  }
  // アンケート回答時の処理
  else {
    await answerProcess(params);
  }
  res.send();
});

アンケート回答時の処理

async function answerProcess(params) {
  let index = params.callback_id;
  let original_attachments = params.original_message.attachments;
  // アンケートの質問を取得します。
  let question = original_attachments[index].title;
  // アンケートの回答を取得します。
  let answer   = params.actions[0].value;
  
  // アンケート結果をオブジェクトに保持しておきます。
  let userAnswers = answers[params.user.name] || {};
  userAnswers[question] = answer;
  answers[params.user.name] = userAnswers;

  // アンケート回答箇所だけSlackの表示を書き換えます。
  original_attachments[index] = {
    title: question,
    text: answer,
    color: '#00FF00'
  }
  // Slack Web APIにリクエストを送ってSlackのメッセージを更新します。
  await new Slack(config.slack).updateAttachments(
    params.message_ts,
    original_attachments,
    params.channel.id
  );
}

アンケート集計時の処理

アンケート集計のトリガーを「Interactive buttons」にしているので、集計の処理は作成したエンドポイントで行います。

メイン処理

let answers = {};
router.post('/', async (req, res) => {
  let params = JSON.parse(req.body.payload);

  // 集計時の処理
  if (/^aggregate$/.test(params.callback_id)) {
    await aggregateProcess(params);
  }
  // アンケート回答時の処理
  else {
    await answerProcess(params);
  }
  res.send();
});

アンケート集計時の処理

async function aggregateProcess(params) {
  // JSONを整形してSlackに投稿します。
  await new Slack(config.slack).postMessage(
    "```\n" + JSON.stringify(answers, null, '  ') + "\n```",
    params.channel.id
  );
  answers = {};
}

おわりに

今回はHubotとExpressで実装をしましたが、HubotはHTTPのリッスンもできるのでHubotにまとめたいと思いました。しかし、HubotのtokenとSlack Appのtokenが別ものなので個人的にあまりしっくりきません。。(Hubotから「Interactive buttons」を投稿する方法をご存知の方がいたら教えていただきたいです)
SlackのBot作成フレームワークにはHubotの他にもBotkitというものがあり、こちらはBot Userのtokenを使用するのでSlack Appのtoken一つでできそうです。また、HTTPのリッスンもHubot同様にできるので一つにまとまってすごいしっくりきます。今度はBotkitを使ってこのアンケートBotをさらに使いやすくしてみたです。

Scala * PlayでWebアプリ制作 ~バージョンの罠にはまる~

始めまして、Adways Engineers Diaryでお世話になっているさんちゃんです。
今回は、業務でScalaを使うことになったので、チュートリアルも兼ねて、
イベント管理Webアプリを作成しました。
基本的には下記のサイトを参考にさせて頂きました。

tech-sketch.jp

作成にあたり、バージョンの違いなどで個人的にハマった箇所があったので、
今回はそのあたりを中心に紹介していこうと思います。

今回の環境

  • Scala 2.11.8
  • Slick 3.1.1
  • Play 2.5.10
  • Docker 1.12.5
  • Docker-compose 1.8.0

イベント管理Webアプリ

機能としては大きく分けて以下の4つです。

  • イベント登録
  • イベント検索
  • イベント編集
  • イベント削除

その中でも、参考にしたサイトと異なるポイントが多かったイベント作成機能と
イベント検索機能を中心に紹介していきます。
最後には今回開発したコードも載せておきますので、興味がある方はご覧ください。

イベント登録

さて、イベントを管理するにあたり、最初にイベントを登録しないといけません。
その為、イベントを登録できる機能を開発しました。
そこで、ハマったポイントをここで紹介します。
/app/controllers/event/EventCreate.scala

import play.api.i18n.{I18nSupport, MessagesApi}

class EventCreate @Inject() (val messagesApi: MessagesApi) extends Controller with I18nSupport {

/app/views/eventCreate.scala.html

@(eventForm: Form[EventForm])(implicit messages: Messages)

まずはこの部分。
参考にしたサイトでは特に記載がありませんでしたが、これはPlayの2.4以降から
国際化APIに対応したので、その為の記述です。
私の実行している環境はPlay2.5になるので、この部分を見つけ出すのに
結構苦労しましたorz

qiita.com

こちらに詳しく載っているので良かったらご覧ください。

また、viewの部分でも苦労しました。
/app/views/eventCreate.scala.html

@import helper.twitterBootstrap._

参考にしたサイトでは、helperメソッドのtwitterBootstrapというものを使って
いましたが、Playのバージョン2.3以降は使えないようで、今回は使いませんでした。
https://www.playframework.com/documentation/2.3.x/Migration23#Twitter-Bootstrap
2.3への移行ガイドに載っていました。

バージョンに翻弄され、イベント登録するのも一苦労でしたorz

イベント検索

続いては登録したイベントを確認するために、イベント検索機能が必要です。
イベント検索機能でも、落とし穴がありました・・・
/app/models/Event.scala (参考サイト)

  def find(eventId: String, eventNm: String): List[Event] = database.withTransaction { implicit session: Session =>
    var q = events.sortBy(_.eventNm)
    q = if (!(eventId.isEmpty)) q.filter(_.eventId === eventId) else q
    q = if (!(eventNm.isEmpty)) q.filter(_.eventNm like ("%" + eventNm + "%")) else q
    return q.invoker.list
  }

参考にしたサイトのこの部分ですが、invokerが罠でした。
まったく分からなかったので、とりあえず ‘Slick3 invoke’ で検索w
するとアップグレードガイドがありました!

http://krrrr38.github.io/slick-doc-ja/v3.0.out/%E3%82%A2%E3%83%83%E3%83%97%E3%82%B0%E3%83%AC%E3%83%BC%E3%83%89%E3%82%AC%E3%82%A4%E3%83%89.html

これによると、invokerはSlick3以降では使えないようです。
Slick3からはクエリの実行を非同期で行うことが出来るようになったので、
その影響かも知れませんね。
/app/models/Event.scala (自分)

  // 検索
  def find(eventId: String, eventNm: String): Seq[Event] = {
    var q = events.sortBy(_.eventNm)
    q = if (!(eventId.isEmpty)) q.filter(_.eventId === eventId) else q
    q = if (!(eventNm.isEmpty)) q.filter(_.eventNm like ("%" + eventNm + "%")) else q
    Await.result(database.run(q.result), Duration.Inf)
  }

先ほどの問題を解決するべく、このようなコードで実装しました。
ほとんど同じですが、実際にクエリを実行するrunの部分で、resultを呼んでいます。
これは、クエリから実際に実行するアクションを生成する為のものです。
また、runだけではなく、Awaitを先頭に付けているのは、Slick3で非同期になったので、結果が返ってくるまで待機する為です。

ここを乗り越えたらイベント検索機能は完成です。

実行&挙動確認

イベント管理Webアプリにはほかにも更新、削除がありましたが、ここまでで紹介した内容を
うまく使えば、それほど苦労することは無かったので割愛します。
ということで、いよいよ今回のイベント管理Webアプリ動作確認です。

早速実行しましょう。

f:id:AdwaysEngineerBlog:20170203124514p:plain

ここで使用した、activator ~runですが、ここにもバージョンの違いが・・・
playのバージョン2.3以降からはactivatorを使うようになったようです。

www.task-notes.com

こちらに詳しい導入方法等があります。
~run に関しては、プロジェクトが更新されたタイミングで自動的に再読み込み
してくれるやつです。
さて、successが出たので早速登録してみましょう。

f:id:AdwaysEngineerBlog:20170203124537p:plain

これで検索すると・・・

f:id:AdwaysEngineerBlog:20170203124547p:plain

いい感じですね。
しっかりと登録されていました。
今回は検索条件を絞っていなかったので、全件表示されています。
なので、検索条件を絞って検索してみます。

f:id:AdwaysEngineerBlog:20170203124605p:plain

インターンを含むものにヒットするので、上手く絞れています。
ついでに、このインターンを2回目に編集してみます。
編集は、×アイコンの隣にある、鉛筆マークのアイコンを押下することで可能です。

f:id:AdwaysEngineerBlog:20170203124615p:plain

更新ボタンを押下したら、本当に変更されているか検索で見てみます。

f:id:AdwaysEngineerBlog:20170203124625p:plain

アドウェイズインターン2回目になっていますね。
最後は、アドウェイズインターン2回目を削除します。 先ほどの編集アイコンの隣にある×アイコンです。
本当に削除されたか検索で確認してみましょう。

f:id:AdwaysEngineerBlog:20170203124634p:plain

条件は絞っていませんが、最初から入っていたエンジニアダイアリー・・・
だけになっていますね。上手く削除出来たようです。

ということで、無事に挙動が確認できました。

おわりに

さて、いかがだったでしょうか?
今までは業務でPerlを使っていたので、Scalaは今回初めてでした。
関数型というところで文法的な難しさもありましたが、それ以上に
バージョン違いで、なかなか上手く実行できないことが苦しかったですw
資料に関してもそれほど多くなく、最新の環境での資料が少なかった印象です。
今回私が試した環境に近い環境でScala * Playフレームワークを試そう!
と思われた方の参考になれば幸いです。
今後も ADWAYS ENGINEERS BLOGとAdways Engineers Diaryを
よろしくお願いしますっ!

今回のコード

/app/controllers/event/EventCreate.scala

package controllers.event

import play.api._
import play.api.mvc._
import play.api.data.Form
import play.api.data.Forms._
import javax.inject.Inject
import play.api.i18n.{I18nSupport, MessagesApi}
import models.{ EventForm, Event, Events }


class EventCreate @Inject() (val messagesApi: MessagesApi) extends Controller with I18nSupport {

  // イベントフォーム
  val eventForm = Form(
    mapping(
      "eventId"   -> nonEmptyText,
      "eventNm"   -> nonEmptyText)(EventForm.apply)(EventForm.unapply))

  // 初期表示
  def index = Action {
    Ok(views.html.eventCreate(eventForm))
  }

  // 登録
  def create = Action { implicit request =>
    eventForm.bindFromRequest.fold(
      hasErrors = { form =>
        Ok(views.html.eventCreate(form))
      },
      success = { form =>
        val event = Event(0, form.eventId, form.eventNm)
        Events.create(event)
        Redirect(controllers.event.routes.EventCreate.index())
      }
    )
  }

  // テーブル作成
  def createTable = Action {
    Events.createTable
    Ok(views.html.eventCreate(eventForm))
  }

  // テーブル削除
  def dropTable = Action {
    Events.dropTable
    Ok(views.html.eventCreate(eventForm))
  }
}

/app/controllers/event/EventSearch.scala

package controllers.event

import javax.inject.Inject
import play.api.data.Form
import play.api.data.Forms._
import play.api.i18n.{I18nSupport, MessagesApi}
import play.api.mvc._
import models.{EventForm, Events}


class EventSearch @Inject() (val messagesApi: MessagesApi) extends Controller with I18nSupport {

  // イベントフォーム
  val eventForm = Form(
    mapping(
      "eventId" -> text,
      "eventNm" -> text)(EventForm.apply)(EventForm.unapply))

  // 初期表示
  def index = Action {
    Ok(views.html.eventSearch(eventForm, null))
  }

  // 検索
  def search = Action { implicit request =>
    val form = eventForm.bindFromRequest
    val events = Events.find(form.get.eventId, form.get.eventNm)
    Ok(views.html.eventSearch(form, events))
  }

  // 削除
  def delete(id: Int) = Action { implicit request =>
    Events.delete(id)
    Ok(views.html.eventSearch(eventForm, null))
  }

}

/app/controllers/event/EventUpdate.scala

package controllers.event

import javax.inject.Inject
import models.{EventForm, Event, Events}
import play.api.data.Form
import play.api.data.Forms._
import play.api.i18n.{I18nSupport, MessagesApi}
import play.api.mvc._


class EventUpdate @Inject()(val messagesApi: MessagesApi) extends Controller with I18nSupport {

  // イベントフォーム
  val eventForm = Form(
    mapping(
      "eventId" -> text,
      "eventNm" -> text)(EventForm.apply)(EventForm.unapply))

  // 初期表示
  def index(id: Int) = Action {
    val event = Events.findById(id)
    val form  = EventForm(event.eventId, event.eventNm)
    Ok(views.html.eventUpdate(eventForm.fill(form), id))
  }

  // 更新
  def update(id: Int) = Action { implicit request =>
    val form  = eventForm.bindFromRequest.get
    val event = Event(id, form.eventId, form.eventNm)
    Events.update(event)
    Ok(views.html.eventUpdate(eventForm, id))
  }

}

/app/models/Event.scala

package models

import slick.driver.MySQLDriver.api._
import scala.concurrent.{Await, Future}
import scala.concurrent.duration.Duration
import scala.language.postfixOps


case class Event(id: Int, eventId: String, eventNm: String)

object Events {

  // DBコネクション
  val database = Database.forConfig("db_master")

  // テーブル定義
  class EventTag(tag: Tag) extends Table[Event](tag, "EVENT") {
    def id      = column[Int]("ID", O.PrimaryKey, O.AutoInc)
    def eventId = column[String]("EVENT_ID")
    def eventNm = column[String]("EVENT_NM")
    def * = (id, eventId, eventNm) <> (Event.tupled, Event.unapply)
  }
  val events = TableQuery[EventTag]

  // 登録
  def create(e: Event) = {
    Await.result(database.run(events += Event(0,e.eventId, e.eventNm)), Duration.Inf)
  }

  // テーブル作成
  def createTable = {
    val schema = events.schema
    Await.result(database.run(schema.create), Duration.Inf)
  }

  // テーブル削除
  def dropTable = {
    val schema = events.schema
    Await.result(database.run(schema.drop), Duration.Inf)
  }

  // 検索
  def find(eventId: String, eventNm: String): Seq[Event] = {
    var q = events.sortBy(_.eventNm)
    q = if (!(eventId.isEmpty)) q.filter(_.eventId === eventId) else q
    q = if (!(eventNm.isEmpty)) q.filter(_.eventNm like ("%" + eventNm + "%")) else q
    Await.result(database.run(q.result), Duration.Inf)
  }

  // ID検索
  def findById(id: Int): Event = {
    val q = events.filter(_.id === id).result.head
    Await.result(database.run(q), Duration.Inf)
  }

  // 更新
  def update(e: Event) {
    val q = events.filter(_.id === e.id).update(e)
    Await.result(database.run(q), Duration.Inf)
  }

  // 削除
  def delete(id: Int): Unit = {
    val q = events.filter(_.id === id)
    Await.result(database.run(q.delete),Duration.Inf)
  }

}

/app/models/Form.scala

package models

case class EventForm(eventId: String, eventNm: String)

/app/views/eventCreate.scala.html

@(eventForm: Form[EventForm])(implicit messages: Messages)
@import helper._

@header("イベント登録"){
  @helper.form(action = controllers.event.routes.EventCreate.create()){
    <div class="container">
      <ul class="nav nav-tabs">
        <li>
          <a href="@controllers.event.routes.EventSearch.index()" data-toggle="tab">検索</a>
        </li>
        <li class="active">
          <a href="#" data-toggle="tab">登録</a>
        </li>
      </ul>
      <fieldset>
        <legend>イベントの情報</legend>
        @helper.inputText(eventForm("eventId"))
        @helper.inputText(eventForm("eventNm"))
        <input type="submit" value="登録" class="btn btn-primary">
      </fieldset>
      <a href="@controllers.event.routes.EventCreate.createTable()" class="btn btn-default">
        CREATE TABLE
      </a>
      <a href="@controllers.event.routes.EventCreate.dropTable()" class="btn btn-default">
        DROP TABLE
      </a>
    </div>
  }
}

/app/views/eventSearch.scala.html

@(eventForm: Form[EventForm], events: Seq[Event])(implicit messages: Messages)

@import helper._

@header("イベント検索"){
  @helper.form(action = controllers.event.routes.EventSearch.search()){
    <div class="container">
      <ul class="nav nav-tabs">
        <li class="active">
          <a href="#" data-toggle="tab">検索</a>
        </li>
        <li>
          <a href="@controllers.event.routes.EventCreate.index()" data-toggle="tab">登録</a>
        </li>
      </ul>
      <fieldset>
        <legend>検索条件</legend>
        @helper.inputText(eventForm("eventId"))
        @helper.inputText(eventForm("eventNm"))
        <button type="submit" value="登録" class="btn btn-primary">検索</button>
      </fieldset>

      @if(events) {
        <legend>イベント一覧</legend>
        <table class="table table-striped table-bordered table-hover">
          <thead>
            <tr>
              <th>イベントID</th>
              <th>イベント名</th>
            </tr>
          </thead>
          <tbody>
            @for((event) <- events) {
              <tr>
                <td>@event.eventId</td>
                <td>@event.eventNm</td>
                <td>
                  <a href="@controllers.event.routes.EventUpdate.index(event.id)">
                    <i class="glyphicon glyphicon-edit"></i>
                  </a>
                  <a href="@controllers.event.routes.EventSearch.delete(event.id)">
                    <i class="glyphicon glyphicon-remove"></i>
                  </a>
                </td>
              </tr>
            }
          </tbody>
        </table>
      }
    </div>
  }
}

/app/views/eventUpdate.scala.html

(eventForm: Form[EventForm], id: Int)(implicit messages: Messages)

@import helper._

@header("イベント情報更新"){
  @helper.form(action = controllers.event.routes.EventUpdate.update(id)){
    <div class="container">
      <ul class="nav nav-tabs">
        <li>
          <a href="@controllers.event.routes.EventSearch.index()" data-toggle="tab">検索</a>
        </li>
        <li>
          <a href="@controllers.event.routes.EventCreate.index()" data-toggle="tab">登録</a>
        </li>
        <li class="active">
          <a href="#" data-toggle="tab">更新</a>
        </li>
      </ul>
      <fieldset>
        <legend>イベントの情報</legend>
        @helper.inputText(eventForm("eventId"))
        @helper.inputText(eventForm("eventNm"))
        <input type="submit" value="更新" class="btn btn-primary">
      </fieldset>
    </div>
  }
}

/app/views/header.scala.html

@(title: String)(content: Html)

<!DOCTYPE html>

<html>
<head>
    <title>@title</title>
    <link href="//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet">
    <link href="//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap-glyphicons.css" rel="stylesheet">
  </head>
  <body>
    @content
  </body>
</html>

/app/conf/routes

GET     /                           controllers.SampleController.index
GET     /event/                     controllers.event.EventSearch.index
POST    /event/search               controllers.event.EventSearch.search
GET     /event/:id/delete/          controllers.event.EventSearch.delete(id: Int)
GET     /event/create               controllers.event.EventCreate.index
POST    /event/create/create        controllers.event.EventCreate.create
GET     /event/create/createTable   controllers.event.EventCreate.createTable
GET     /event/create/dropTable     controllers.event.EventCreate.dropTable
POST    /event/:id/                 controllers.event.EventUpdate.update(id: Int)
GET     /event/:id/update/          controllers.event.EventUpdate.index(id: Int)

/app/conf/messages

constraint.required = 必須
error.required = 必須項目です。

eventId = イベントID
eventNm = イベント名

人工知能APIを用いて、Slack Botを作って遊んでみた

はじめまして、16新卒システムエンジニアの高木です。
所属している部署は解析ユニットです。よろしくお願いします。

今回はユーザーローカル社が提供している人工知能APIを用いて、Slack Botを作って遊んでみました。
使用言語はみんな大好き「Ruby」です。
圧倒的に素早い実装をするために簡単に作れるgemを利用しました。

使用したgem

github.com

github.com

ソースコード

# app.rb
require 'slappy'
require 'user_local_chat_api'

Slappy.configure do |config|
  config.token            = 'xoxp-XXXXXXXXXXXXXXXXXXXXXX' # Slack APIのTOKEN
  config.robot.username   = '人工無能BOT' # BOTの名前
  config.robot.icon_emoji = ':robot_face:' # BOTのアイコン
end

client = UserLocalChatApi::Client.new('XXXXXXXXXXXXXX') # 人工知能APIのTOKEN

Slappy.hear '(.*)' do |event|
  next if event.bot_message?
  res = client.chat(event.text)
  if res['status'] == 'success'
    Slappy.say res['result'], channel: event.channel
  end
end

Slappy.start

最低限のものしか書いていないので、たった20行で作れてしまいました。簡単ですね!
本当は特定のチャンネルのみにしか反応しないようにする等条件を加えた方が良いと思います。

ちなみにAPIのTOKENは
Slack APIはこちら
人工知能APIはこちら
より取得できます。

実際に遊んでみる

ruby app.rbで実行できます。
何も表示されませんが、実行されているはずです。

f:id:AdwaysEngineerBlog:20170127114341p:plain

f:id:AdwaysEngineerBlog:20170127114352p:plain

f:id:AdwaysEngineerBlog:20170127114357p:plain

感想

いろいろな言葉を覚えさせて成長していくと思っていたら、そういうわけではないみたいです。
人工知能の精度はそんなに高くないですが、APIで人工知能を簡単に扱えるのは嬉しいですね。
良かったら皆さんも試してみてください。
以上です。ありがとうございました。

1年目による1年目のためのMySQLチューニング手順

MySQL version : 5.5.38

目次

  1. 前書き
  2. 遅いクエリの見つけ方
  3. 解決策の決定
  4. チューニングの方法
    1. クエリの改善
    2. INDEX
    3. パーティション
    4. コマンドの大文字小文字
  5. おわりに


1. 前書き

こんにちは、入社1年目の紺野です。
入社後、予約TOP10チームにJOINしてから、MySQL関連の改修を任されることが結構ありました。大学ではPostgreSQLを習っていたのですが、実務として取り組むと、ただ受け身でやっている時よりも知識のUPDATEが早いですね。
今回は1年目の自分が取り組んできた、遅いクエリの見つけ方から改善までの手順と解決法をSELECTしましたので、記事として書かせて頂きます。
どうでもいい前置きはここまで本題に入りましょう。


2. 遅いクエリの見つけ方

MySQLにはスロークエリログという、結果が返ってくるのが遅いクエリをログとして残してくれる機能が備わっています。まずはこのログを出力する設定をして、チューニングすべきクエリを洗い出しましょう。

スロークエリログの設定がどうなっているかは、MySQLコンソールからSHOW VARIABLESコマンドで確認できます。

mysql> SHOW VARIABLES LIKE 'slow_query%';
+---------------------+-------------------------------+
| Variable_name       | Value                         |
+---------------------+-------------------------------+
| slow_query_log      | OFF                           |
| slow_query_log_file | /var/log/mysql/mysql-slow.log |
+---------------------+-------------------------------+
2 rows in set (0.00 sec)

mysql> SHOW VARIABLES LIKE 'long%';
+-----------------+-----------+
| Variable_name   | Value     |
+-----------------+-----------+
| long_query_time | 10.000000 |
+-----------------+-----------+
1 row in set (0.00 sec)
  • slow_query_log
    ログを出力するか否かの設定です。OFFになっていますのでスロークエリログは吐き出されない状態です。
  • slow_query_log_file
    ログが書き込まれるファイルの場所です。
  • long_query_time
    この値を超えると遅いクエリとして判断され、ログに出力されます。単位は秒です。

スロークエリログの設定

コンソールから設定
コンソールからは、以下のコマンドを入力することにより設定できます。

mysql> SET GLOBAL slow_query_log_file = '/var/log/mysql/mysql-slow.log';
mysql> SET GLOBAL long_query_time = 1;
mysql> SET GLOBAL slow_query_log = ON;

my.cnfから設定
設定ファイルからは、rootユーザーで編集します。

# vi /etc/my.cnf

以下を追記

# slow query
##
slow_query_log
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1

これでlong_query_timeで設定した値以上の時間がかかったクエリをログとして残してくれます。

スロークエリログの見方

実際にログを見ていきましょう。

# less /var/log/mysql/mysql-slow.log
# Time: 170118 16:51:22
# User@Host: root[root] @ localhost []
# Query_time: 21.486755  Lock_time: 0.000033 Rows_sent: 10057  Rows_examined: 2256448
SET timestamp=1484725882;
SELECT t1.id, t1.fiald1, t2.fiald1 FROM table1 t1 INNER JOIN table2 t2 ON t1.id = t2.table1_id WHERE t2.fiald1 IS NOT NULL ORDER BY t2.fiald1;
※実際のDBの内容とはもちろん異なります
  • Time
    クエリが実行された日時です。この場合は2017年1月18日 16:51:22ですね。
  • User@Host
    ユーザーIDとリクエストした端末です。ここを見ればどのサーバーでクエリがたたかれたかが分かります。
  • Query_time
    クエリの結果が帰ってくるまでの時間です。先程設定したlong_query_timeの値以上の数値になっているはずです。21秒ってやばいですね。21秒間ページが表示されなければ一般的にユーザーはキレます。
  • Lock_time
    ロックされた時間です。ロックとは、異なるセッションによる競合を防ぐため、他のクエリは実行されないような状態になっていることです。UPDATEINSERT実行中にSELECTなどしてしまうと正確な結果でなくなってしまいます。
  • Rows_sent
    クエリにヒットした件数です。
  • Rows_examined
    処理対象となった行数です。
  • SQL文
    実行されたクエリです。これが改善すべきクエリということになります。


3. 解決策の決定

EXPLAINコマンドを使って調べていきましょう。このコマンドはクエリの先頭につけることによって、どういった手順でデータを取得するかの実行計画を確認できます。実際にクエリがたたかれ、結果が返ってくる訳ではないので安心して使ってください。
ただし、サブクエリのみ実行してみないと見積もりができないようで、重いサブクエリを使うとEXPLAINも返ってくるのが遅くなります。

実行例

f:id:AdwaysEngineerBlog:20170120181349p:plain

どういった項目をチェックしてチューニング方法を練るかを先に申しますと、以下のようになります。

①最初にデータを取ってくるテーブルのrowsを減らせそうか
②インデックスが使われているか否か
③ExtraにUsing filesortやUsing temporaryが入っていてそれを取り除けないか

ではこのチェック項目の判断を下すうえで必要な情報の説明をしていきましょう。

  • idとselect_type
    この2つはクエリ内での実行順を表しています。JOINのみから構成されるクエリの場合単純に上から順に実行されますが、サブクエリ、UNIONなどが絡んでくる場合は順番が変わってきます。最初に取得するデータ量(後述するrowsの数)が少ないほどクエリ全体の処理が早くなるため、実行順を理解することはチューニングの手助けになるでしょう。

  • table
    対象のテーブルを表します。

  • type
    かなり端折って説明すると、インデックス使っているか否か、インデックスをどう使っているかを表す項目です。

    • const
      PRIMARY KEYまたはユニークインデックスによる等価検索(例:WHERE id = 1)。最速。
    • eq_ref
      JOINONにおいてPRIMARY KEYまたはユニークインデックスが使われている状態。早い。
    • ref
      ユニークでないインデックスによる等価検索(例:WHERE age = 18)。いい感じに早い。
    • range
      インデックスによる範囲検索(例:WHERE age BETWEEN 13 AND 15)。わりと早い。
    • index
      indexとあるので早そうですが、これはインデックス全体をスキャンしているため、まぁまぁ早い程度です。
    • ALL
      インデックスを全く使わず、テーブル全体をスキャンするため最遅。改善すべき。
  • possible_keys
    使用する候補として挙げられたキーやインデックスです。

  • key
    どのキーやインデックスが使われたか。貼ったインデックスが実際に活用されているかがわかります。

  • key_len
    使われたキーの長さ。キーの長さが短いほど高速なのでインデックスをつけるカラムを選ぶ時などは注意してください。

  • rows
    そのテーブルから抽出したカラム数の大まかな値。EXPLAINは実際に実行するわけではないので予測数を出してくれます。また、この値はWHEREを適用する前なので、検索条件を設定している場合の実行結果はおおよそこれより少なくなります。ただ、先程書いたようにサブクエリは実際に実行してみないと結果の数がわからないため、この場合は正確な値が出てきます。

  • Extra
    クエリを実行するために使用する方法がここに記載されます。数が多いのでよく見るものをここでは説明します。

    • Using where
      WHEREを使っており、インデックスを使っただけでは絞り込めない場合にでます。
    • Using index
      インデックスだけをもって絞りこみ、ソートできている場合にでます。かなり早いはずです。
    • Using filesort
      テーブルからデータを取ってきた後にクイックソートを使ってソートしている場合にでます。クイックソートとかいう名前ですが取ってきたデータが多い場合、ソートに時間がかかるため殆どの場合遅いです。
    • Using temporary
      結果をソートしたりするのにテンポラリテーブル(仮のテーブル)を作る場合に出ます。Using filesortと同じく遅くなることが多いので注意が必要です。


4. チューニングの方法

さあいよいよチューニングに入っていきます。
EXPLAINで表示されたrowsを減らすことを目標とし、以下のような改修を加えましょう。

1. クエリの改善

JOINやサブクエリ、UNIONを使っている場合は、それぞれ対応するテーブルからデータを取ってくる順番が決まります。その時重要なのが、最初に取ってきたデータの量です。このデータ量が多いと、次に取ってくるデータとマージするときに見る行が増えるため実行速度が落ちます。
10万件のユーザーテーブルと100万件の予約テーブルをINNER JOINする時、予約テーブルから始めるよりもユーザーテーブルから始める方が早くなるでしょう。

2. INDEXを貼る

EXPLAINした時に、keyNULLだったり、WHEREで指定した条件のカラムにINDEXが貼られていない場合は検討しましょう。WHERE の他に、JOINの条件で指定されているカラムにも貼ると効果が出ます。

今までさんざん出てきたINDEXですが、とりあえず貼りまくれば良いというわけではありません。INDEXを貼ったカラムはメモリ上にのるため、貼りすぎるとMySQL以外のプロセスを圧迫し、メモリが溢れてしまう危険があります。よって、本当に使うものにのみINDEXを貼るようにしましょう。

3. パーティション

データ量が膨大になると、クエリの改善やINDEXを貼っても遅いときがあります。そんな時は、検索条件のカラムのパーティションを切ると良いでしょう。

4. コマンドの大文字小文字

MySQLはクエリの実行手順としてまずクエリの読み込みをし、その時コマンドが小文字で入力されていた場合、それを大文字に置き換える処理が入ります。なのでこの処理を削るため、SQLのコマンドは大文字で入力しましょう。とはいっても今のコンピュータの性能からすると無視しても問題ない程度らしいのでおまじない程度に考えておいてください。ECサイトの表示速度が1秒早くなっただけで売上1200億円上がったなんて話もあるので0.0000001秒早くなれば年1万円くらいの収入アップになるでしょう。

結果

チューニングを試してみた結果、rowsは減りましたでしょうか。そうしたら実際にクエリを叩いて実行速度を比べてみましょう。早くなってたら良いですね。

ただここで一つ注意してほしいことがあります。
MySQLではクエリの実行結果をキャッシュしておく機能があるので、同じクエリを叩くと0.0秒で返ってきます。クエリのテストは何回か叩くと思うので、いきなり早くなってワロタと混乱することを防ぐためにSQL_NO_CACHEを使いましょう。

SELECT SQL_NO_CACHE t1.id, t1.fiald1, t2.fiald1
  FROM table1 t1 INNER JOIN table2 t2 ON t1.id = t2.table1_id




5. おわりに

いかがだったでしょうか。初めてのエンジニアブログだったので要領を得ていないかもしれませんが、少しでも同胞の助けになればと思います。

新年なのでAWSのリソースだけで1年の目標管理マイクロサービスを作ったお話

久保田です。

2017年が始まりましたね。。。
早いもので今年の4月で3年目、今年はますます頑張りたいなと思います。

僕は毎年、年始に意識高く目標を立てているのですが、
だいたい3月くらいには忘れてしまっています。笑

なので今年は忘れないよう、目標管理をしてくれるマイクロサービス的なシステムを作ったので、書きたいと思います。
ぜひ真似してください。

今回はAWSのサービスのみで構築しました。構成は以下のような感じです。

f:id:AdwaysEngineerBlog:20170113150018p:plain

そういえばAWS、コンソール画面変わってましたね。これで伝わるのかな。。。

CloudWatchのスケジュール機能でLambdaの関数を動かし、3ヶ月に一度目標のメールを自分に送ります。
送られてくるメールは以下の画像のようなイメージです。

f:id:AdwaysEngineerBlog:20170113150108p:plain

そしてメールには目標とそれぞれの目標に対応したURLがあり、そのURLをクリックするとAPI GateWayにリクエストが送信され、Lambdaが動き、S3にある目標データが入っているjsonファイルを書き換えます。さらに目標が全て達成されたら、CloudWatchのスケジュールをdisableにします。

では早速作っていきます。

S3に目標データを作る

まずはS3にデータを置いておきます。
今年の目標を意識高く掲げておきましょう。

  • goal.json
{
  "1": "腹筋を割る",
  "2": "引っ越す"
}

このファイルをS3のどこかにuploadしておきます。

Lambda関数を作っておく

先にLambdaの関数を作っておきます。
先に作っておかないとCloudWatchのスケジュールが登録できないためです。

今回は、 S3から目標ファイルを読み込みメールを送る関数と、
API GatewayにリクエストがきたらS3の目標ファイルを更新する関数
の2つが必要となります。

適当に名前をつけてください。
僕は メールを送る関数を 「sendNotify」
更新する関数を 「clear」
としました。

実装はまた後で行います。

CloudWatchにスケジュールを登録する

次にCloudWatchにスケジュール登録します。

左ペインからイベントの下にある ルール を選び、以下のCron式を登録します。

0 10 1 3,6,9,12 ? *

そしてこのスケジュールに紐付けるLambda関数を選択します。

f:id:AdwaysEngineerBlog:20170113150131p:plain

API Gatewayでエンドポイントの作成

API Gatewayを使ってエンドポイントを作成します。
このエンドポイントにリクエストがきたらS3の目標ファイルを更新します。

今回は目標が達成されたら対象のデータを消すので、DELETEメソッドで作ります。
そして消す目標のIDをパスパラメータで受け取るようにします。

なのでリソースとメソッドは以下の画像のようになります。
(※目標更新の方のLambda関数を紐付けておいてください。)
(※ GETがある理由は後でわかります。)

f:id:AdwaysEngineerBlog:20170113151940p:plain

そしてマッピングテンプレートを設定し、パスパラメータをLambdaが受け取れるようにします。

f:id:AdwaysEngineerBlog:20170113150250p:plain

そしてDELETEなので、ステータスコードは204を返すようにしておきます。

メソッドレスポンスと統合レスポンスを編集します。

f:id:AdwaysEngineerBlog:20170113150231p:plain

f:id:AdwaysEngineerBlog:20170113150214p:plain

そしてデプロイしておきます。
デプロイしたら得られるエンドポイントをメモっておいてください。

これでDELETEメソッドは完成です。

次にGETメソッドの設定をしていきます。
なぜかというと、メールからURLをクリックした時に発行されるリクエストのメソッドはGETだからです。
なので、このGETを通って上で作ったDELETEメソッドに流します。

このようにしておきます。

f:id:AdwaysEngineerBlog:20170113151801p:plain

エンドポイントURLのところに上でメモしておいたエンドポイントを入れてください。

SNSの設定

Lambdaからメールを送る必要があるので、SNSにトピックを作ります。
ここも名前はなんでもいいです。僕は「goalNotify」としました。

購読の設定まで完了させておきます。

Lambda関数の実装

ここまでで型が完成したので、中身のLambda関数を作っていきます。
僕はPythonを使いました。

まずはS3から目標ファイルを読み込みメールを送る関数です。

import boto3
import json
import collections

BUCKET = 'バケット名'
KEY    = 'ファイル名'

def lambda_handler(event, context):
    # S3からファイルの読み込み、jsonからdict型のオブジェクトの生成
    s3 = boto3.client('s3')
    obj = s3.get_object(Bucket=BUCKET, Key=KEY)
    decoder = json.JSONDecoder(object_pairs_hook=collections.OrderedDict)
    jsonObj = decoder.decode(obj['Body'].read())
    

    # メール本文作成
    message = ""
    for k, v in jsonObj.iteritems():
        message += v + "\n" + " if you clear this goal, click under link. \n ここにエンドポイント"  + k + "\n\n"
    
    # SNSからトピックのarnを取得  
    sns = boto3.resource('sns')
    topic = sns.create_topic(Name="トピック名").arn
    # メール送信
    sns.Topic(topic).publish(
        Subject="goal of 2017.",
        Message=message,
    )


    return

次にAPI GatewayにリクエストがきたらS3の目標ファイルを更新する関数です。

import boto3
import json
import collections

BUCKET = 'バケット名'
KEY    = 'ファイル名'

def lambda_handler(event, context):
    # S3からファイルの読み込み、jsonからdict型のオブジェクトの生成
    s3 = boto3.client('s3')
    obj = s3.get_object(Bucket=BUCKET, Key=KEY)
    decoder = json.JSONDecoder(object_pairs_hook=collections.OrderedDict)
    jsonObj = decoder.decode(obj['Body'].read())
    
    # 対応するgoalIdがあったら消して更新
    if event['goalId'] in jsonObj:
        del jsonObj[event['goalId']]
        jsonStr = json.dumps(jsonObj)
        s3.put_object(Bucket=BUCKET, Key=KEY, Body=jsonStr)
        
        # もし全て完了したら、スケジュールを止める。
        if len(jsonObj) == 0:
            cloudwatch = boto3.client('events')
            cloudwatch.disable_rule(
            Name='sendNotify'
        )

        
    return

これで完成です。 試したい方はS3から目標ファイルを読み込みメールを送る関数を動かしてみてください。

さて、あとは3ヶ月後1つでも押せるように日々努力していくだけ、、、ですね!

今回は以上です。今年もよろしくお願いいたします。