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

SGMLReader + XPathでスクレイピングする

こんにちは、今日もWebを見ながら生きているwakです。表題通りのことをやりました。

f:id:nurenezumi:20160620161016j:plain

はじめに

目的

任意のHTMLをSystem.Xml.Linq.XDocumentに格納してXPathで目的の要素を探してスクレイピングすることです。

HTMLはXMLじゃない

HTMLはXMLではありません。したがって、

<link href="mystyle.css" rel="stylesheet" type="text/css">

のように「終了タグがない」ことが定められていたり、条件付きで省略が認められていたりします。こういった文書はXMLとしては不正なので、System.Xml.Linq.XDocumentにパースさせてもエラーになってしまいます。

SgmlReader

そこで登場するのがSgmlReaderで、これは

SgmlReader is a versatile C# .NET library written by Chris Lovett for parsing HTML/SGML files using the XmlReader API.

とあるように、XMLとしては不正なHTMLを食べてXMLにしてくれるライブラリです。

github.com

同じような目的のライブラリとしてHtml Agility Packがあり、こちらの方がずっとメジャーなようなのですが、読み込んだドキュメントが独自のクラスになることに抵抗があって今回は利用しませんでした。

SgmlReaderの使い方

導入

NuGetで提供されているので、NuGetパッケージマネージャーコンソールから

Install-Package SgmlReader

と入力すれば導入できます。開発はGitHubで進められており、こちらの方が微妙に新しい*1ので、こちらを使っても良いと思います。まず上記のリポジトリを丸ごと落としてきてビルドし、出てきたSgmlReaderDll.dllを参照に追加して終わりです(あるいはSgmlReader以下の*.csを2件直接プロジェクトに突っ込むだけでも構いません)。またSgmlReaderはApache License 2.0でライセンスされていますので、もしアプリケーションを再配布する際はその旨明記しておきます。

読み込み

文字列→XDocument

次のようなコードを書くだけでOKです。

public static XDocument Parse(string content)
{
    using (var reader = new StringReader(content))
    using (var sgmlReader = new SgmlReader { DocType = "HTML", CaseFolding = CaseFolding.ToLower, IgnoreDtd = true, InputStream = reader })
    {
        return XDocument.Load(sgmlReader);
    }
}

ファイルでもWeb上のコンテンツでもStreamのことが多いと思いますし、それならば本当はTextReaderから直接流し込めるのですが、前もってHTMLの手直しをする必要に迫られたときのために一度stringにしています。

XPathEvaluate()で型を指定する

後でSystem.Xml.XPath.Extensions.XPathEvaluate()を多用しますが、このメソッドはXPathならなんでも渡せるので(つまり戻り値はXElement, XText(), XAttributeのどれでもあり得るので)戻り値はobjectになります。戻り値の型があらかじめ分かっているときにはこんな拡張メソッドを追加しておくと簡単になります。

public static IEnumerable<T> XPathEvaluate<T>(this XElement element, string xpath)
{
    return ((IEnumerable<object>)element.XPathEvaluate(xpath)).Cast<T>();
}
public static IEnumerable<T> XPathEvaluate<T>(this XElement element, string xpath, IXmlNamespaceResolver resolver)
{
    return ((IEnumerable<object>)element.XPathEvaluate(xpath, resolver)).Cast<T>();
}
public static IEnumerable<T> XPathEvaluate<T>(this XDocument document, string xpath)
{
    return ((IEnumerable<object>)document.XPathEvaluate(xpath)).Cast<T>();
}
public static IEnumerable<T> XPathEvaluate<T>(this XDocument document, string xpath, IXmlNamespaceResolver resolver)
{
    return ((IEnumerable<object>)document.XPathEvaluate(xpath, resolver)).Cast<T>();
}

ドキュメント全体から要素を検索する

System.Xml.XPath.Extensions.XPathSelectElement()またはXPathSelectElements()を使います。たとえばページ内の全てのa要素(そのうちhref属性があるもの)を取得したければこのようになります。

XDocument document = Parser.Parse(content);
IEnumerable<XElement> links = document.XPathSelectElements("//a[@href]");

マッチする要素のうち先頭1件だけで良ければXPathSelectElement()(単数形)を使います。0件ならnullが戻ります。

XElement firstLink = document.XPathSelectElement("//a[@href]");

ドキュメント全体から何かを検索する

要素(XElement)以外を検索するときはXPathEvaluate()を使います。このメソッドの戻り値の型はobjectで使い勝手が悪いので、さっきの拡張メソッドを使うと綺麗に書けます。たとえばページ内の全てのa要素のhref属性(つまりXAttributeです)の値を取り出したければこうなります。

IEnumerable<string> linkUris = document.XPathEvaluate<XAttribute>("//a[@href]/@href").Select(x => x.Value);

「ページ全体からh3タグの中身(テキスト、すなわちXText)をリストアップする。ただし最初の1個は不要」ならどうでしょうか。

IEnumerable<string> midashi1 = document.XPathEvaluate<XText>("/descendant::h3[position()>1]/text()").Select(x => x.Value);
IEnumerable<string> midashi2 = document.XPathEvaluate<XText>("//h3/text()").Skip(1).Select(x => x.Value);

どちらでも同じ結果が得られますが、XPath初心者の私にとってはLINQを使った後者の方が見やすく感じられます。なお//h3[position()>1]/text()ではダメです(各々の親要素の中で最初の1個が除外されてしまいます)。

特定の要素の下から何かを検索する

これも同じくXElementに対してXPathEvaluate()を呼びます。たとえば次のようなHTMLがあったとして、

<div id="top">
  <span class="a">A1</span>
  <span class="b">B1</span>
  <span class="b">B2</span>
</div>
<div id="middle">
  <span class="a">A2</span>
  <span class="b">B3</span>
</div>

まず1個目のdiv要素は

XElement top = document.XPathSelectElement("//div[@id='top']");

で取れます。この下からspan.bを検索したければ、

// 直下から検索
IEnumerable<XElement> spanB1 = top.XPathEvaluate<XElement>("span[@class='b']");
// 子要素または孫要素から検索
IEnumerable<XElement> spanB2 = top.XPathEvaluate<XElement>(".//span[@class='b']");

となるわけです。「#topの子要素または孫要素のspan.b」のつもりで//span[@class='b']と書いてしまうとドキュメント全体からの検索になるので気をつけましょう。

Chromeのコンソールと注意点

Chrome$x()でお手軽XPath

XPathは複雑になってくると暗号みたいになるのでなるべくお手軽にテストができた方が便利です。Chromeでは特にアドオンなどを必要とせず、開いているドキュメントに対してコンソールから$x("任意のXPath")でXPathのテストができる機能があります。

Command Line API Reference  |  Web  |  Google Developers

$x("任意のXPath")だけでなく、$$("任意のCSSセレクタ")というショートカットもあります。活用しましょう。

Chromeが勝手に要素を補う場合

この機能はXPathで要素を検索してスクレイピングに役立てる際に非常に便利なのですが、HTMLの内容によってはハマることがあります。たとえば次のようなHTMLがあったとします。

<!-- tbodyがない -->
<table id="neko">
  <tr><th>たま</th><td>3歳</td><td>黒と白</td></tr>
  <tr><th>ぶち</th><td>2歳</td><td>茶色と白</td></tr>
  <tr><th>しろ</th><td>8歳</td><td></td></tr>
</table>

これをChromeに読み込ませると、Chromeは「tableの直下にはtheadtbodyしか来ない」ことを知っているため、自動的にtbodyを補ってDOMを構築します。

f:id:nurenezumi:20160617110248p:plain

一方、SgmlReaderはそこまで細かい処理は行いません。したがって、要素はtable > tr > thのように並ぶことになり、tbodyがあることを前提にXPathを書くと失敗します。

SgmlReaderが補完に失敗する場合

別にこれはSgmlReaderが悪いわけではないのですが、HTMLに間違いがあるとさらに面倒なことになります。たとえばこんなHTMLを考えます。

<form action="post">
  <!-- inputタグ閉じ忘れ -->
  <input type="text" placeholder="猫の名前">
  <input type="text" placeholder="年齢">
  <input type="text" placeholder="色">
  <span>間違えないように入力してね!</span>
  <input type="submit" value="送信">
</form>

一昔前の古いサイトではありがちな間違いで、ブラウザはこの程度なら(とはいえ膨大なルールが集積された結果のはずですが)意図通り終了タグを補って解釈してくれます。

f:id:nurenezumi:20160617115351p:plain

しかしSgmlReaderはこれをこのように解釈してしまい、

<form action="post">
  <input type="text" placeholder="猫の名前">
    <input type="text" placeholder="年齢">
      <input type="text" placeholder="色">
        <span>間違えないように入力してね!</span>
        <input type="submit" value="送信"></input>
      </input>
    </input>
  </input>
</form>

//form/input[@type='text']というXPathを評価すると、最初の「猫の名前」1件がヒットし、他のinput要素は一見取得できていないように見えてしまいます(実際には順次入れ子になって取得できています)。これに気付かないと散々悩むことになります。

これを防ぐには、前もってHTMLを修正しておくか、XPathを工夫してロバストなクエリを書くように心がけるかしかありません。スクレイピングに付きものの悩みではあります。

おわりに

一頃流行した「セマンティック・ウェブ」という言葉はあまり聞かなくなってしまいましたが、Webページになるべく正しいHTMLで正しい情報を記載しておくことはその第一歩であり、より良いインターネットに繋がる道だと考えています。その間をXPathLINQで上手に乗り越えてゆきましょう。

*1:1.8.11と1.8.12(2016/6/27時点)

API Gateway + LambdaでSlackのCustom Commandを作る

こんにちは、AWS担当のwakです。先日このような記事を書いたので、表題通りAPI Gateway + Lambdaで実装するサンプルを作ります。

tech.sanwasystem.com

f:id:nurenezumi:20160613190357j:plain

鋭い眼光でエラーを見逃さない猫(多分)

はじめに

暇なWebサーバーは無駄

SlackのCustom Commandsは、言ってみればユーザーの操作に応じてWebサーバーにリクエストを送り、ごく短時間で終わる処理をしてもらってその結果を受け取るものです。少なければ1日数回、どんなに多くても1日に数百回のこのコマンドのためだけにWebサーバーを用意して24時間待ち受けを行うなんて無駄なことはしたくありません。API Gateway + Lambdaならこのような目的にぴったりで、EC2のt1.microインスタンスなどよりもずっと安価です。

何か作ろう

弊社ではCloudWatchで何かアラートが出たとき・復帰したときにそれぞれSlackへ通知する仕組みが既に稼働中です。ただ、境界値付近で値が行ったり来たりすると通知が何度も飛んできて、「今出ている警告は何か」を見失うことがありました。そこで、今回は【現在ALARM状態になっているメトリクスを取得して一覧表示する】コマンドをAPI Gateway + Lambdaで作ることにします。

Custom Command作成

上述の記事のように、まずはCustom Commandを新規に作成し、トークンを取得します。URLは未定なので未設定のまま置いておきます。

IAMロール作成

まずLambdaを実行するためのロールを作ります(既にあるなら不要です)。今回はCloudWatchに対する権限(読み取り専用で十分)が必要です。Slackから渡されるトークンはコードに埋め込むことにしましたが、もしDynamoDBで管理するのであればDynamoDBの読み取り権限も必要になります。

f:id:nurenezumi:20160606102609p:plain
f:id:nurenezumi:20160606102645p:plain

Lambda作成

単にCloudWatchのメトリクスを取得して返すだけの簡単なコードになりました。response_typein_channelを入れておくと、コマンドの実行結果を他のユーザーも見ることができるようになります。8行目にあるxxxxxxxxxxxxxxxxxxxxxxxxはSlackから与えられたトークンで置き換えます。

"use strict";

var AWS = require("aws-sdk");
var cloudwatch = new AWS.CloudWatch();

exports.handler = function(event, context) {
    
    if (event.token !== "xxxxxxxxxxxxxxxxxxxxxxxx") {
        context.fail("トークンが変です");
        return;
    }

    cloudwatch.describeAlarms({StateValue: "ALARM"}, function(err, data) {
        if (err) {
            console.log(err, err.stack);
            context.succeed(err);
        } else {
            if (data.MetricAlarms.length === 0) {
                context.succeed({response_type: "in_channel", text: `状態がALARMの監視項目はありません。`});
                return;
            }
            
            context.succeed({
                response_type: "in_channel",
                text: `状態がALARMの監視項目が${data.MetricAlarms.length}件あります。`,
                attachments : data.MetricAlarms.map(x => {
                    return {
                        color : "#FF8888",
                        text : [
                            `名前: ${x.AlarmName}`,
                            `内容: ${x.AlarmDescription}`,
                            `状態: ${x.StateReason}`,
                            `ALARMになった時刻: ${x.StateUpdatedTimestamp}`
                        ].join("\n")
                    };
                })
            });
            return;
        }
    });
}

念のためパラメーターのtokenだけセットしてテスト実行してみましょう。どちらかの結果が戻ってくれば成功です。

f:id:nurenezumi:20160613184539p:plain

API Gatewayの準備

API作成

まずAPI Gatewayのトップ画面で「Create API」ボタンを押し、New APIを選んで新規APIを作ります。名前は変えられないので慎重に行きましょう。

f:id:nurenezumi:20160531155032p:plain
Resources作成

要するにURL設計です。Create Resourceで好きなだけ階層を掘り、掘ったところでCreate Methodを選んでGETやPOSTを作ります。それぞれのメソッドごとに別々の処理が作れます。

今回はGETでSlackに呼び出してもらうことにします。これだけ作ったら、

f:id:nurenezumi:20160613193305p:plain

Integration Typeとして"Lambda Function"を選び、先ほど作成したLambda functionを選択します。

Method Execution設定

ここからはAPI GatewayとLambdaをどのように繋げるかを設定していきます。つまり、全体として

  1. API Gatewayは外部からのリクエストを受け取る
  2. Lambdaはパラメーターとして1件のJSONオブジェクトを受け取り、1件のJSONオブジェクトを返す関数
  3. API Gatewayは何らかのデータをレスポンスとして返す

という処理の流れがあるわけですが、1→2, 2→3の間には何らかの変換ルールが必要です(後者ではもともとJSONをそのまま返すというルールが設定されています)。これを設定します。

Method Request設定

API呼び出し元から与えられる想定のパラメーターを設定します。数が多いのですがぽちぽち入力します(今回はtokenだけでも構いません)。

f:id:nurenezumi:20160606133558p:plain

Method Request設定

Body Mapping Templatesの設定で、API呼び出し元から与えられたパラメーターをどのような形でLambda functionに渡すかを決めます。

{
  "channel_name" : "$input.params('channel_name')",
  "user_id"      : "$input.params('user_id')",
  "command"      : "$input.params('command')",
  "text"         : "$input.params('text')",
  "team_id"      : "$input.params('team_id')",
  "token"        : "$input.params('token')",
  "channel_id"   : "$input.params('channel_id')",
  "team_domain"  : "$input.params('team_domain')",
  "response_url" : "$input.params('response_url')",
  "user_name"    : "$input.params('user_name')",
  "apiId"        : "$context.apiId"
}

テスト実行する

テスト実行しましょう。tokenにトークンを入力してテストボタンを押します。先ほどLambdaを実行したときと同様の結果が返ってくれば成功です。

デプロイする

これで動作に問題ないことが確認できたのでデプロイします。これでURLが決まります。

Slackの設定

Slackに戻り、必要に応じて項目の設定を行います。

  • URLの設定(API Gatewayで決まったもの。これは必須)
  • Customize Name(BOTが発言するときの名前。分かりやすい名前にしましょう)
  • Customize IconBOTが発言するときのアイコン。Custom Emojiは使えないので、使いたければ画像ファイルを再度アップロードする必要があります)
  • Autocomplete help textの設定(Show this command in the autocomplete listにチェックを入れるとオートコンプリートが使えるようになります

これでもう終わりです。ブラウザからでもスマホからでも、コマンドを実行するだけでAWSAPIが実行されて結果が得られるようになりました。

まとめ

実に簡単にCustom Commandが、それもサーバーレスで実現できました。ややこしいことはAWSが引き受けてくれるので、サーバーが落ちたらどうしようといったことを考える必要がありません。お手軽で効果がある、コストパフォーマンスの高いコードはどんどん書いていきたいものですね。それでは。

SlackのSlash Commandで既存システムから必要な情報を取得する

弊社の1システムとしてゴルフシステムがあります。当然ユーザー様は日本全国のゴルフ場なわけですが、ゴルフ場は基本郊外にありますのでネットワーク回線が不安定なことがあります。そのような理由からネットワークが不通となっても動作できるようオンプレで夜間バッチで連携を取る方式を現在は取っています。とはいえリアルタイムで特定の情報が知りたい事もあり、その場合はいちいちログインして確認することになるわけですが、それは面倒だ!加えてSlackは常に見てるので何でもSlackでやりたい。

で既存システムにslackのSlach Sommandsを組み合わせてみました。

システム構成

IIS 7.5
ASP.NET Web API
ASP.NET WCFサービスアプリケーション
.NET Framework 4.5.1

手順

  1. ゴルフ場サーバーから定周期でAWS上に作成したWCFをたたき、データ連携させる。

  2. Slash Commandsを作成。上記同様にAWS上に作成したWEB APIを叩くようにする。

これだけ。

f:id:ihisa:20160531151607p:plain

こんな感じになります。Slach Commandsを打つとPOSTする先となるWeb APIを新規に作成します。

Slach Commandsの仕様は以下の通りです。
Slash Commands | Slack

この仕様に則りWEB APIを作成します。記載の通りですが以下注意点です。

  1. 戻りは必ずHttpStatusを200にします。それ以外だと表示されないです。
  2. コマンドのパラメータはtextに入ってるので自由自在。
  3. 3秒以内に応答を返さないとSlackは応答がねぇよ!とあきらめます。
  4. 実行結果をコマンドを叩いたチャネルに通知する場合はresponse_typein_channelにする。デフォルトはephemeral(自身のみ)

詳しくはこちらを参照ください。
tech.sanwasystem.com

コード~結果

作ったWeb APIはこんな感じ。

using Newtonsoft.Json;
using System;
using System.Configuration;
using System.Net;
using System.Net.Http;
using System.Runtime.Serialization;
using System.Web.Http;

namespace SlackAPI.Controllers.v1
{
    [RoutePrefix("v1/nt")]
    public class SlackController : ApiController
    {
        public SlackController()
        {
        }

        [HttpPost]
        [Route("information")]
        public HttpResponseMessage Information([FromBody]SlackRequestParameter parameter)
        {
            SlackResponseParameter response = new SlackResponseParameter();
            response.response_type = "ephemeral"; // slash command実行者のみ参照可能

            try
            {
                // token
                if (!parameter.token.Equals(ConfigurationManager.AppSettings["slacktoken"].ToString()))
                {
                    return Result(HttpStatusCode.OK, "認証がよろしくないです");
                }

                // optionに応じた処理
                if (parameter.text.ToLower().Equals("list"))
                {
                    response.text = "listオプションに応じた結果を返します";
                }
                else
                {
                    response.text = "そんなオプションは存在しません";
                }
                return Result(HttpStatusCode.OK, response);
            }
            catch (Exception)
            {
                response.text = "失敗しちゃった(´・ω・`)";
                return Result(HttpStatusCode.OK, response);
            }
        }

        private HttpResponseMessage Result<T>(HttpStatusCode status, T content)
        {
            HttpResponseMessage response = new HttpResponseMessage(status);
            if (content != null)
            {
                response.Content = new StringContent(JsonConvert.SerializeObject(content));
            }
            response.StatusCode = status;
            response.Content.Headers.ContentType.MediaType = "application/json";

            return response;
        }
    }

    [DataContract(Name = "SlackRequestParameter", Namespace = "http://hogehoge")]
    public class SlackRequestParameter
    {
        [DataMember]
        public string token { get; set; }
        [DataMember]
        public string team_id { get; set; }
        [DataMember]
        public string team_domain { get; set; }
        [DataMember]
        public string channel_id { get; set; }
        [DataMember]
        public string channel_name { get; set; }
        [DataMember]
        public string user_id { get; set; }
        [DataMember]
        public string user_name { get; set; }
        [DataMember]
        public string command { get; set; }
        [DataMember]
        public string text { get; set; }
        [DataMember]
        public string response_url { get; set; }
    }

    [DataContract(Name = "SlackResponseParameter", Namespace = "http://hogehoge")]
    public class SlackResponseParameter
    {
        [DataMember]
        public string response_type { get; set; }
        [DataMember]
        public string text { get; set; }
    }    
}

これをslash commandCommandで指定したコマンドをlistオプション付きで実行します。
f:id:ihisa:20160530155741p:plain

めでたく通知されました。
BYODが許可されている会社でしたらSlackを自身のスマホで利用されている方も多いでしょうから、Slackで自身が欲しい情報を取れるのは便利ですね!

SlackのCustom Commandsことはじめ

どうも、Slack大好きなwakです。今日はSlackで自作コマンドを作ることができるCustom Commandsの話の導入をします。

Slash Commandsってなに

Slackには/invite/remindなど、/から始まるいくつかのコマンドがあります。スラッシュから始まるのでSlackではSlash Commandsと呼んでいます。/remindコマンドの使い方については以前まとめました。

tech.sanwasystem.com

コマンドを自作しよう

さて、このSlash Commandsはユーザーが自作することもできます。これがCustom Commandsです。

自作するとどうなるのか? 公式ドキュメントを読むのが手っ取り早いのですが、動作イメージは

  1. 開発者が/addnekoコマンドを用意する
  2. ユーザーが/addneko tama buchi kuroと入力する
  3. Slackは予め設定されたURLにGETまたはPOSTを飛ばす
  4. WebサーバーはレスポンスとしてJSONドキュメントを返す(3秒以内)
  5. JSONの中身がSlackに表示される
  6. (もし必要なら)Webサーバーは追加情報や時間のかかる処理の結果をJSONドキュメントとしてSlackへURLへ送信する(30分以内)
  7. (もしあれば)JSONの中身がSlackに表示される

という流れになります。

リクエストに含まれる情報

Slackが送ってくるリクエストにはパラメーターがいくつかありますが、最低限チェックすべき重要なものはtokenぐらいです。他は必要に応じて参照してください。

token

Custom Commandごとに設定された秘密のトークン。このトークンの正当性をチェックしておかないと、エンドポイントが第三者にバレたときに呼び出し放題になってしまいます。

user_name

このコマンドを実行したユーザーのID(@なし)です。

command

スラッシュから始まるコマンド名です。複数のコマンドでエンドポイントを共有していないなら無視して構いません。

text

コマンドに続けて入力されたテキストです。上の例ならtama buchi kuroが入ります。これもコマンド側で参照する必要がないなら無視しましょう。

response_url

上述の6番の追加情報の送信先です。たとえばEC2インスタンスを起動する/start-ec2コマンドを作ったとします*1。この場合、コマンドは

  1. 処理を受け付けた旨をリクエストに対するレスポンスとして返し、
  2. 続いてインスタンスの立ち上げ処理を開始し、インスタンスの状態を数秒おきに監視する。正常に起動処理が完了したと確認できたところでこのURLに結果を送信する

という処理を行えば良いわけです。2回目のメッセージはユーザーがコマンドを実行したのと同じチャンネルに表示されます。

作ってみよう

Apps & integrations → Build → Make a Custom Integration とたどり、「Slash Commands」を選択します。

f:id:nurenezumi:20160531144353p:plain

どのようなコマンドを作るか聞かれますので、好きな文字列を入れます。多少長くても補完が効くようにできるので*2気にしなくてもいいでしょう。

f:id:nurenezumi:20160531144609p:plain

これでコマンドが作成され、続けてエンドポイントなどの設定に移ることになります。同僚の井上さんがさっそく素敵なコマンドを実装してくれたので交代することにしましょう。よろしく!

*1:もしちゃんと各種サービスが立ち上がったところまで見届けようとするとこの処理は3秒では終わりません

*2:デフォルトでは補完は効きません。コマンド一覧にも出てきません

Slackを使ってバグ管理ィィィィィ

こんにちは。ジョジョ4部アニメが開始して毎週金曜が楽しみでたまらないおいかわです。

ある自社サービスのリリース直前なのですが、現在最後のバグ出し合宿をしているところです。そこでやっているバグ管理について紹介します。

今回のバグ管理のポイント

  • 通常弊社ではBacklogを使っているが、もっとさくっと簡単にやりたい
  • スクショを簡単に貼り付けたい
  • スピード重視で管理と情報共有を同時にしたい

で、思いついたのがコレ。

Slackを使って簡易的なバグ管理をする!

以下説明します。

ルール

  • バグ管理用のチャネルを1つ作成
  • バグを上げる場合はどんなものでもスクショをとって画像で上げる(スレッド形式でコメントできるから)
  • 画像のタイトルでバグの名前をざっくり書く、またタイトルの先頭に【機能名】をつける
  • バグを上げたらピンでとめる
  • 基本的にはスクショのコメントでバグのやり取りをする
  • バグ修正後にテストサイトに上げたらピンを外す

バグの起票とそのバグに対するやり取りはこんな感じ

アップロードされた画像のコメント一覧。画像アップロード=バグ起票となり、そのバグの修正に関するやり取りがスレッド形式で行われます。

f:id:jabe20:20160514195337p:plain:w300

バグのリストはこんな感じ

バグ管理用チャネルのピン一覧、ピンの数が残バグ数。タイトルが機能名で整理されているのでけっこう見やすい。

f:id:jabe20:20160514195656p:plain:w300

ピンを外すと

起票者(ピンでとめた人)のところに通知が来ます。通知が来たら再テストすれば良いので、そのコミュニケーションもSlackでリアルタイムに実現!

f:id:jabe20:20160514200005p:plain:w300

Slackでバグ管理をしてみた感想

良い!!!

最初に挙げたポイントを全てクリアし、ものすごく簡単にバグ管理ができました。特に管理と情報共有のスピードが圧倒的。さらに、gitとも連携しているので、バグ管理と構成管理の情報が全てSlackで見れます。

プロジェクトによってバグ管理に求められるものって変わってきますよね。過去のバグの分析とかが求められる場合もあったりしますが、そういう場合にはもちろんSlackでのバグ管理は向いていません。プロジェクトの性質によって、効率の良いやり方をこれからもどんどん見つけていきたいですね。

AWS LambdaからGoogle APIを呼び出す

こんにちは、AWS担当のwakです。間が空いてしまったので、今回は簡単な記事を書いて隙間を埋めることにします。

f:id:nurenezumi:20160428191057j:plain

背景

世はSlackが大流行ですが、未だにエラー通知メールという仕組みも残っており、これを無視するわけにはいきません。そこで、Gmail(Google Apps)に届いたエラーメールを担当者がいるSlackのチャンネルに流す連携を作りました。今回はGoogle APIを呼ぶための仕組みと、AWS Lambdaから呼び出す手順について説明します。

何をするか

次のことをやります。

  1. Google APIを呼び出すアプリケーションを作り、ユーザーのGmailの情報を読み取れるようにする
  2. 自分のユーザーGoogleログイン→OAuth認証を行い、トークンを取得する
  3. トークンはDynamoDBに入れておく
  4. トークンは1時間で有効期限が切れてしまうため、Lambdaで自動的に更新する
  5. このトークンを使って何かする

プロジェクトの準備

プロジェクト作成

まずGoogle Developers Consoleにログインし、画面右上のメニューから「プロジェクトの作成」を行います。「概要」>「Google API」から、このアプリケーションが呼び出したいAPIを選択します。ここではGmail APIを選択しました(複数選べます)。

f:id:nurenezumi:20160426174114p:plain

APIが選択できたら「認証情報」から「認証情報を作成」をクリックし、「OAuthクライアントID」を選択しましょう。

f:id:nurenezumi:20160426174046p:plain

OAuth2.0のおさらい

ここで簡単にOAuth2.0のおさらいをします。一般にWebサービスが提供するOAuth認証を利用するアプリケーションは、

  • ユーザーが許可した権限でWebサービスAPIを利用することができる
  • APIを呼び出すときには、認証時に発行されたキーをリクエストに含める(ユーザーのIDやパスワードは分からない)
  • このキーには有効な寿命が設定されていることがある
  • 有効期限がある場合、認証時に与えられたリフレッシュトークンを使ってリクエスト時に更新しなければならない
    • 定期的に更新しても構わない

という流れなのでした(そしてGoogleOAuth認証では寿命が設定されています)。これを念頭に次のステップへ進みます。

認証画面設定

OAuthクライアントIDを作るためにはまず「同意画面」の設定が必要となります。これはユーザーに「このアプリケーションに権限を与えますか?」という確認を提示するための画面で、ユーザーがその可否を判断するために必要な連絡先やアプリケーション名を記入しておきます。

f:id:nurenezumi:20160426174024p:plain

最後に「アプリケーションの種類」として「その他」を選んで「作成」をクリックすれば完了です。その場でクライアントIDとクライアントシークレットが発行されます。これは後からでも確認できるのでメモを取る必要はありません。

f:id:nurenezumi:20160426174413p:plain

認証&トークン取得

初回認証

認証に使うアカウントでGoogleにログインした状態で、次のURLをブラウザから開きます(改行と空白は削除して1行にしてください)。

https://accounts.google.com/o/oauth2/v2/auth
  ?scope=https://www.googleapis.com/auth/gmail.readonly%20https://www.googleapis.com/auth/gmail.labels
  &redirect_uri=urn:ietf:wg:oauth:2.0:oob
  &response_type=code
  &client_id=999999999999-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com
  • scopeにはこのアプリケーションが要求するスコープ(下述)をスペース(%20)区切りで並べます。
  • redirect_urlは固定値です。ここでは認証後に外部へは遷移せず、認証コードを表示する画面を表示することを指示しています。詳しくはドキュメントを参照してください。
  • client_idには最初に取得したクライアントIDを書きます。
  • approval_prompt, access_typeを指定すると書いてある記事も多くありますが、現時点では必要ないようです。

スコープは様々な権限を適当な粒度で丸めたものです。たとえばメールを読み取る権限が必要なだけならhttps://www.googleapis.com/auth/gmail.readonlyと書けば良いのですが、メールにラベルを追加・削除したければhttps://www.googleapis.com/auth/gmail.labelsも指定しなければなりません。Gmailについてのスコープはドキュメントに一覧があります。

このURLを開くと、画面には権限のリクエストを許可するか拒否するかの選択肢が表示されます。

f:id:nurenezumi:20160426191729p:plain

要求されている権限は確かにスコープで指定したものであることが分かります。ここで「許可」を押すと秘密のコードが表示されます(これはWebページのタイトルにも出力されています)。このコードこそが、ユーザーがアプリケーションに権限を与えたという証拠になります。

f:id:nurenezumi:20160426175521p:plain

トークン2種類を取得

このコードを含めて再度リクエストを送るとaccess tokenとrefresh tokenが取得できます。curlで次のコマンドを叩きます。

curl -d client_id=999999999999-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com \
     -d client_secret=xxxxxxxxxxxxxxxxxxxxxxxx \
     -d grant_type=authorization_code \
     -d redirect_uri=urn:ietf:wg:oauth:2.0:oob \
     -d code=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \
     https://accounts.google.com/o/oauth2/token

client_id, client_secretはプロジェクト作成時に貰った値、codeは今ブラウザに表示された値です。うまく行けばJSON形式で結果が返ります。

{
  "access_token" : "xxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "token_type" : "Bearer",
  "expires_in" : 3600,
  "refresh_token" : "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}

欲しかったaccess tokenとrefresh tokenが取得できました!

APIコールを試す

権限を手に入れたので、後はAPIを好きに叩くことができます。GmailAPI一覧はドキュメントにありますので、たとえばこのコマンドではGmailで作成済みのラベル一覧を出力します。エンドポイントの中にあるuserIdmeに置き換えておくのを忘れないでください。

curl -H "Authorization: Bearer xxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    https://www.googleapis.com/gmail/v1/users/me/labels

同様に、今年1/1以降で「猫」が含まれるメールを検索し、そのメールIDのリストを取得するのならこうなります。スペースはエスケープしてください。またqの引数にはGmailの検索クエリと全く同等のものが使えます。

curl -H "Authorization: Bearer xxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    https://www.googleapis.com/gmail/v1/users/me/messages?q="after:2016-01-01%20猫"

access token更新

ここで最も重要な値は上で得たrefresh tokenです。これさえあればいつでも新しいaccess tokenを取得(=更新)できるからです。これはアプリケーション側で保存しておく必要があります。

curl -d client_id=999999999999-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com \
     -d client_secret=xxxxxxxxxxxxxxxxxxxxxxxx \
     -d refresh_token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
     -d grant_type=refresh_token \
     https://accounts.google.com/o/oauth2/token

更新を行ったとしても、更新前に使っていたaccess tokenは有効なまま残ります。つまり、

  • 何か処理を行う前に必ず更新するようにする

    • refresh tokenさえ保存しておけばいい。ここで得たキーは1時間ずっと使える
  • N分ごとに自動更新するようにする(Nは30分、55分など)

    • access tokenをDBか何かに入れておく
    • N分ごとにcronなどでrefresh tokenを使ってaccess tokenを取得し、DBの中のaccess tokenを更新する
    • アプリケーションはDBから取得したaccess tokenで処理をする。このaccess tokenは最短(60-N)分で無効になる

といった選択肢ができることになります。自動処理ができるなら後者の方が簡単そうですね。

AWSの準備

トークンをDynamoDBに入れる

access tokenとrefresh tokenをDynamoDBに入れておきましょう。各種外部サービス*1APIを叩くためのキーを管理するためのテーブルを元から作ってあったので、そこに追加することにしました。このテーブルのプライマリキーはkeyにしています。

f:id:nurenezumi:20160427202441p:plain

更新用Lambdaを用意する

どこかのサーバーからcronでバッチを回してもいいのですが、ここはLambdaで作って自動実行することにします。何をやっているかを明確にするためにライブラリは使っていません。

github.com

このコードを実行すると、DynamoDBの指定したテーブル・レコードからrefresh tokenなどの値を取得してaccess tokenを更新し、レコードとタイムスタンプを更新します。DynamoDBへのアクセス権を与えなければならないことには気をつけましょう。

定期的に実行する

作成したLambdaを再度開き、Event sources>Add event sourceと進んで、表示されるダイアログでEvent source typeとしてCloudWatch Events - Scheduleを選択します。

f:id:nurenezumi:20160427205218p:plain

Schedule expressionとして「rate(30 minutes)」を選べば30分ごとに自動実行してくれるようになります。簡単ですね。

トークンを使って何かする

これで常に有効なaccess tokenがDynamoDBに入っている状態となりました。先ほどと同じように、Lambdaから今あるラベルの一覧を取得してログに出力してみます。

var https = require("https");
var querystring = require("querystring");
var AWS = require("aws-sdk");
var dynamo = new AWS.DynamoDB.DocumentClient();
var TABLENAME = "YOUR-DYNAMODB-TABLE-NAME";
var KEYNAME = "YOUR-RECORD-KEY";

var getGoogleApiKey = function(callback) {
    var condition = {
        TableName : TABLENAME,
        KeyConditionExpression : "#k = :val",
        ExpressionAttributeValues : {":val" : KEYNAME},
        ExpressionAttributeNames  : {"#k" : "key"}
    };
    dynamo.query(condition, function(err, data) {
        if (err) { context.fail(err); }
        else { callback(data.Items[0].token); }
    });
};

var kickGet = function(path, token, success, fail) {
    var options = {
        hostname: "www.googleapis.com",
        port: 443,
        path: path,
        method: "GET",
        headers : { "Authorization" : "Bearer " + token }
    };

    var req = https.request(options, function(res) {
        body = "";
        console.log("[DEBUG] response received");
        res.setEncoding("utf8");
        res.on("data", function(chunk) { body += chunk; });
        res.on("end", function() {
            if (res.statusCode == 200) {
                console.log(body);
                var json = JSON.parse(body);
                success(json);
            } else {
                console.error("ERROR!");
                console.error(body);
                console.error(res.errorMessage);
                fail(res);
            }
        });
    });
    
    req.on("error", function(err) { fail(err); });
    req.end();    
};

exports.handler = function(event, context) {
    getGoogleApiKey(function(token) {
        kickGet(
            "https://www.googleapis.com/gmail/v1/users/me/labels",
            token,
            context.succeed,
            context.fail
        );
    });
};
f:id:nurenezumi:20160428164945p:plain

結果はこんな感じです。無事出力に成功しました。

まとめ

OAuthのフロー図を見ると若干面倒くさいように感じられますが、実はこれだけのコードで実現可能なシンプルな処理で構成されていることが分かります。これを使って次はもっと複雑なことにチャレンジしてみたいと思います*2。それでは!

*1:Google、Slack、Salesforceなど

*2:実装よりも記事を書く方が大変だったりしますが

Google Apps APIで新入社員対応(ユーザー追加+組織・グループに追加)

こんにちは井上です。
Google Appsでユーザーを追加する時って、大抵の場合グループにもそのユーザを参加させると思うんです。グループ管理は煩雑になりやすく、役割が重複したものやメンバーの差異がほとんどないものなど、数多くのグループが作られがちです。ですので1ユーザーは複数グループに追加する必要が出てきますが、Google AppsGUIを使ってグループにユーザーを追加しようとすると非常にめんどくさい!

という訳でGoogle Apps APIをたたいてユーザ追加と同時にグループに参加させるようにしてみました。ついでに組織にも所属させるようにしました。普通はGoogle Apps Scriptからやると思いますが、敢えてC#で。

なお、ユーザー削除時はユーザーを削除すればそのユーザーが属しているグループからも勝手に消えるのでGUIで困ってません。ユーザー削除が頻繁にあるなら別ですが、それって会社としてどーなのって感じですし(´・ω・`)

ご丁寧に公式にお手軽スタートマニュアルが用意されています。
.NET Quickstart  |  Directory API  |  Google Developers

APIドキュメントはこちら。
API Reference  |  Directory API  |  Google Developers

Google APIs Client Libraryをインストールします。 f:id:ihisa:20160401100908p:plain

簡単なフォームを作ります。(Google Appsのユーザー追加画面風)
f:id:ihisa:20160404134408p:plain

コードは以下の通り。
ちょっと気を付けなければいけないポイントは以下ぐらいです。

  • 組織はUser.OrganizationじゃなくてUser.OrgUnitPathで指定
  • パスワードはポリシーに従った値でないとHTTPステータス400になります。
  • グループ一覧取得時はCustomerプロパティ値を指定しないとHTTPステータス403になります。
using Google.Apis.Admin.Directory.directory_v1;
using Google.Apis.Admin.Directory.directory_v1.Data;
using Google.Apis.Auth.OAuth2;
using Google.Apis.Services;
using Google.Apis.Util.Store;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Windows.Forms;

namespace GoogleAppsManagement
{
    public partial class Main : Form
    {
        UserCredential credential;
        DirectoryService service;
        static string applicationName = "GoogleAppsApplication";
        static string customer = "my_customer"; // この文字列を指定すると実行者のcustomerIdが取得されます。
        static string domain = "@sanwasystem.com";
        IList<OrgUnit> orgunits;
        IList<Group> groups;

        public Main()
        {
            InitializeComponent();
        }

        private void Main_Load(object sender, EventArgs e)
        {
            // UserCredentialの作成
            this.CreateUserCredential();
            
            // DirectoryServiceの作成
            this.CreateDirectoryService();

            // 組織の取得。ここでは組織名称を表示しています。
            orgunits = this.GetOrgunits();
            this.OrgUnitList.DataSource = orgunits.Select(x => x.Name).ToList();

            // グループの取得。ここではグループアドレスを表示しています。
            groups = this.GetGroups();
            this.GroupList.DataSource = groups.Select(x => x.Email).ToList();
        }

        private void AddUser_Click(object sender, EventArgs e)
        {
            string mailAddress = this.mailAddress.Text + domain;

            // ユーザ追加
            User user = new User();
            user.Name = new UserName()
            {
                FamilyName = this.FamilyName.Text,
                GivenName = this.FirstName.Text
            };
            user.PrimaryEmail = mailAddress;
            user.Password = this.Password.Text;
            user.OrgUnitPath = orgunits.Where(x => x.Name == this.OrgUnitList.SelectedValue.ToString()).First().OrgUnitPath; // 選択した組織のOrgUnitPathを指定
            UsersResource.InsertRequest requestUser = service.Users.Insert(user);
            var responseUser = requestUser.Execute();
            
            // グループに追加
            foreach(var row in this.GroupList.SelectedItems)
            {
                MembersResource.InsertRequest requestMember = service.Members.Insert(
                    new Member()
                    {
                        Email = mailAddress,
                        Role = "MEMBER", // 役割。MANAGER / MEMBER / OWNER
                        Type = "USER" // メンバータイプ。CUSTOMER / GROUP / USER
                    },
                    row.ToString()  // グループのIDかメールアドレスを指定なのでメールアドレスを指定しています。
                    );
                var responseGroup = requestMember.Execute();
            }
        }

        private void CreateUserCredential()
        {
            // https://console.developers.google.com/project
            // Google API Consoleからあらかじめ発行したクライアントIDのJSONファイルを指定します。
            // これにより、プロジェクトを実行するとブラウザが立ち上がり認証が行われます。
            using (var stream = new FileStream("client_id.json", FileMode.Open, FileAccess.Read))
            {
                string credPath = System.Environment.GetFolderPath(
                    System.Environment.SpecialFolder.Personal);
                credPath = Path.Combine(credPath, ".credentials/admin-directory_v1-dotnet-quickstart.json");

                credential = GoogleWebAuthorizationBroker.AuthorizeAsync(
                    GoogleClientSecrets.Load(stream).Secrets,
                    new string[]{ 
                        // 実行したい処理に合わせてスコープを指定します。
                        DirectoryService.Scope.AdminDirectoryGroup,
                        DirectoryService.Scope.AdminDirectoryGroupMember,
                        DirectoryService.Scope.AdminDirectoryUser,
                        DirectoryService.Scope.AdminDirectoryCustomer,
                    },
                    "user",
                    CancellationToken.None,
                    new FileDataStore(credPath, true)).Result;
            }
        }

        private void CreateDirectoryService()
        {            
            service = new DirectoryService(new BaseClientService.Initializer()
            {
                HttpClientInitializer = credential,
                ApplicationName = applicationName, // 値は何でも良い
            });
        }
        
        private IList<OrgUnit> GetOrgunits()
        {
            OrgunitsResource.ListRequest orgunitsResourceRequest = service.Orgunits.List(customer);
            return orgunitsResourceRequest.Execute().OrganizationUnits;
        }
        
        private IList<Group> GetGroups()
        {
            GroupsResource.ListRequest groupsResourceRequest = service.Groups.List();
            groupsResourceRequest.Customer = customer;
            return groupsResourceRequest.Execute().GroupsValue;
        }
    }
}

Google AppsGUIは微妙ですがAPIは充実していますので、自分たちで便利にしていけばいいですね!