astamuse Lab

astamuse Labとは、アスタミューゼのエンジニアとデザイナーのブログです。アスタミューゼの事業・サービスを支えている知識と舞台裏の今を発信しています。

クラウドにあるステージング環境のシステムコスト大幅削減を進めている話

こんにちは。並河(@namikawa)です。

このブログは持ち回り制にしているのですが、前回分でメンバーが一通り書き終えましたので、今日から2巡目になります。早いなぁ。

幸いにしてエキサイティングな毎日なので、ブログに書くようなネタはたくさんある(真顔)のですが、一緒に開発・デザインしてくれるメンバーが早くたくさん来てほしいと思う今日この頃です。

さて、今回の私のエントリは、タイトルの通り、弊社で持っているサービス群のステージング環境のシステムコストの削減を進めている話をします。

・・・本当は、弊社オフィスのある銀座近郊の美味しいラーメン屋を紹介するエントリとか書きたいんですけど、技術ブログという名目でありながら前回のエントリも技術っぽくない内容だったのと、おまえ会社のブログで何書いてるんだと上から怒られそうなので、ちゃんと真面目に(技術の話を)書こうかな、とw (冗談です。怒られません。)

その前に前提の話

少し話はそれますが、弊社のサービスの多くは所謂オンプレな環境で本番稼働しています。そして、私が入社した時点では、試験的な意味合いでステージング環境が AWS (Amazon Web Servoces) 上で動いていました。

その後、社内で検討を重ねた結果、サービス基盤の方向性として、 Google Cloud Platform (以下GCP) を利用する方針としました。なぜそうしたか、については話すと長くなりそうなので、またの機会に別途書こうかと思いますが、現状として、サービスのステージング環境を GCP (主にGCE) へと移行している段階です。

コスト削減をどのように行ったか

f:id:astamuse:20160810113214j:plain

で、ここから本題。セキュリティの都合上、書きにくいこともあるので、手法の一部は割愛しますが、

  • GCE (Google Compute Engine) を使うことにした
  • ステージング環境の稼働時間を平日日中だけにした
  • インスタンスのスペックを見直した

あたりをメインに見直しました。それぞれについて、簡単に説明します。

GCE (Google Compute Engine) を使うことにした

元々、ステージング環境は AWS で運用していましたが、前述の通り、本番環境をオンプレで運用している関係上、ステージング環境については Amazon EC2 をメインとして使っています。

気持ち的には、クラウドサービスを活用したサーバレスアーキテクチャだとかデータ解析基盤とか、こんな風に変えていきたいなーと色々考えましたが、大人の事情もあって、本番・ステージングともに、まずは現状の使い方の延長線上で考える方針としました。

それではと、単純に機能・コストを比較してみたときに、 AWS (EC2) よりは GCP (GCE) の方がコストメリットが出そうだという事がわかりました。

これは弊社サービスの特性上、それなりのデータ量を抱えたデータストアが多く、インスタンスのストレージコストでの差が大きく出た、という点が大きかったことに尽きます。 また、データがたくさんある以上、バックアップを保管する際にも、GCS の Nearline Storage のように安くてそこそこスペックの大容量ストレージがあることもポイントです。

機能的な面においても、比較的低スペックのインスタンスについても、他クラウドサービスと比べて、ネットワークスループットが良く、大量データをインサートするときなんかにもボトルネックになりづらい点も助かります。

クラウドサービスを活用する以上、将来的には、マネジメントサービスをもっと活用していきたいとは思いますが、現状、 GCP は AWS ほどのバリエーションはないので、そこに関してはクラウドサービスの今後の動きを注視しつつ、見極めていきたいなと考えています。

ステージング環境の稼働時間を平日日中だけにした

コスト削減効果としては、これが一番大きいのですが、ステージング環境の稼働時間を、平日日中だけ、つまり関係者が働いている時間帯だけに限定しました。

皆さん、帰宅の際にオフィスに誰もいなかったら、オフィスの電気を消して帰ったりしますよね?あれと同じで誰も使っていない時間はサーバを落としましょう、という話です。

とは言え、最後の人が全インスタンスを落とすのは大変だし、オペミスの温床になる可能性もあるため、 Jenkins で自動化していて、具体的には、以下のような感じで運用しています。

  • 平日の9時45分頃からインスタンスを自動起動、同じく平日の22時頃にインスタンスを自動停止しています
  • インスタンスの自動起動/停止は、 Jenkins のジョブで行っています
  • 22時以降も利用するために延長したい場合は、開発メンバーが Jenkins ジョブのスケジュール設定を変更します



その他、注意点や工夫した点は、以下の通りです。

  • 月〜金の中には、祝日が含まれるタイミングがあるので、祝日はインスタンスを起動しないようにしました
    • こちらの祝日判定ライブラリを活用させていただきました
    • 年末年始休暇とか、会社特有の休日は、上記ライブラリだけだと不十分なので、別途自前で対応が必要です
  • インスタンス自動停止ジョブについては、急遽休日にインスタンスを起動した場合などでも落とし忘れがない様、平日に限らず毎日実行しています
  • 延長などで、 Jenkins ジョブのスケジュールを変更した場合、設定の戻し忘れを考慮して、毎朝ジョブの設定を元に戻す"ジョブ"を実行しています
  • ジョブの単位は、サービスごとであったり、データクラスタなど、依存関係や起動順序を意識すべきインスタンス類をまとめて作るようにしました

この結果、ステージング環境の稼働時間は、元々の3分の1程度になりました。クラウドサービスを使うとサーバインスタンスなどは時間単位の従量課金になるので、その分のコストが浮く事になります。

インスタンスのスペックを見直した

これは、リプレースや移行をするタイミングなどで、必ず検討する内容だとは思いますが、実際に使われているリソース量などをベースに、インスタンスのスペックを決めます。

ステージング環境の位置付けにもよりますが、アプリケーションの機能テストを行う場、だとすると、リソースに大きな余裕を持たせる必要もないかと思います。 また、モノにもよりますが、不必要に冗長になっている構成も一部削減したりなど、見直しを実施しています。

基本的には前述の通りですが、弊社のステージング環境は、毎日シャットダウンする前提ですので、その前提だとインスタンスのスケールアップ/ダウンも容易です。

このように、まずはスモールスペックでスタートし、実際に使いながら適宜必要なものを使っていくという考えがクラウドサービスを活用する上では必要かと思います。

結果

その結果、コストはどうなったか、という話ですが、残念ながらまだ移行の途中ではあるので、正確な before/after な数値は出ていません。ごめんなさい。

ですが、現状の手応えとシミュレーションの結果をあわせて考えると、システムの稼働時間を大きくカットしたことと、元々の使い方に少々無駄があったこともあり、必要コストは約 75% 〜 80% 程度カットできる見込みであることを報告しておきます。(あくまでステージング環境の、ですけどね。でも結構大きいのよ。)

・・・という見込みと大人な事情から、さっさと移行してしまって、もっと新しいことを進めていきたいので、色々とお手伝いしていただける方を大募集中です。是非、下部のバナーから(ry

それでは!=͟͟͞͞(๑•̀=͟͟͞͞(๑•̀д•́=͟͟͞͞(๑•̀д•́๑)=͟͟͞͞(๑•̀д•́

広告費用を自動取得し100時間分の作業をなくす話

初めまして、こんにちは。白木と申します。デザイナーです。

普段は開発・デザイン部の管理やマーケティング部門との橋渡しをしています。業務ではMTGの時間がもっとも多く、隙間でコード書いたりデザインしてます。

今日は弊社のマーケティング部門の作業を一部自動化したお話をします。

本稿は背景・経緯・設計・実装についてお話しますので、実装だけに興味ある方は少しスクロールして「STEP4. さぁ、実装だ! 」あたりからお読みいただくとよいかと思います。

Webマーケティングの作業はとても手間がかかる

みなさんご存知のとおり、Webサービスの運用はWebマーケティング、とりわけリスティングなどの広告とは無縁でいられません。依然としてSEOも強力な手法ですが、短期的に数字を重ねるならやはり広告が王道です。

弊社でもGoogle、Yahooをはじめとしたさまざまな広告を使っており、これらを機動的・効果的に運用する重要性はもはや説明不要でしょう

しかし、これら広告は運用規模が大きくなると手間が膨れ上がる傾向にあります。キャンペーンの設定、モニタリング、数値採取、レポート作成など。あっという間に事務的作業が大きな比率を占めるようになり、本来すべき将来予測、施策立案など生産性の高い仕事に時間が割けなくなります。そして徐々に全体として機動性が失われます(これに対するソリューションとして、広告システムが提供するAPIが思い浮かびますが、利用にお金がかかること、また、そもそもAPIが提供されていない広告システムも多数あることを考えると、ある程度は自力でどうにかせざるを得ません)。

弊社でもこういった状況に陥りつつあり、広告担当者のAさんとOさんが大変そうに見えたので隙間でお手伝いさせていただくことにしました。

STEP1. まずは担当者にユーザーヒアリング

さて、まずは広告担当のAさんOさんのお話しを聴きましょう。現地・現物・現認が基本ですね*1。彼らの話を聴き、何が大変かを洗い出し、システムでどう解決できるかを明るく話し合います。ポイントは、担当者が苦労している部分だけでなく、その前後関係・周辺・背景も理解することです

具体的にはこんな感じでヒアリングするとよさげです。

  • そもそも何が大変なのか?
  • その作業は何のためにやってるのか?
  • その作業はどれくらい時間かかってるのか?
  • 他で代替できないか?
  • その作業のあとそのデータをどうするのか?
  • それやる前にやっていることはあるか?
  • 他の方法を用いない理由は何かあるか?(具体的な方法あげながら)
  • それやってる時のモチベーションはどうか?
  • 本当にそれが必要なのか?

ヒアリングの隠れたポイント

隠れたポイントはモチベーションも丁寧に見ることです。もし作業に対してモチベーションや意義を感じていないならがっつり省力化します。何かしらの意義を感じているのであれば改めて本当に必要な作業か議論し、なお必要であれば、意義を満たしながら省力化するようにすればよいです。

当然ながら局所最適解に陥らないことにも留意します。

なお、ヒアリングするときは相手の気持ちに配慮を忘れずに。相手にしてみれば自分の仕事を否定される面もあるので、ムッとすることもあります。そういう時は「あなたが本質的なところに時間が割けるようにしたい」というメッセージを伝え、同じ方向を向いていることを理解してもらうことです(本当にそう思っていますし)。

作業風景を必ず見せてもらう

ヒアリング後は作業風景を見ます。話と実際の作業が異なることは十分にあります。また、言葉に現れていないタスクや無駄というも意外にあるものです。こういったことを取りこぼすと中途半端で思った以上に成果があがりません。作業に必ず同席を。言葉に現れていないポイントが必ず見つかるはずです。

STEP2. ヒアリング結果をまとめディスカッション。要件整理。

今回、ヒアリングを通してメモしたことをいくつか箇条書きにしておきます(全部ではないです)。

  • 上司から不定期で予測レポート作成の指示がおりてくる。
  • データの採取期間が適宜変わる。
  • 各広告サービスの管理画面を回ってアカウントごとの数値を採取する。
  • 取得した数値を、担当者の手元にあるファイルに転記。月次集計を行う。
  • 上司からの指示がなくても定期的に週3回、この予測を行う。
  • 一つの広告システムにはアカウントが複数あってログインログアウトが面倒そう。
  • 予測を作るときはその根拠となるデータ対象期間を複数とるので手間がある。

また作業風景を見て気になった点はこちら。

  • 数値を取得したあとに平均を毎回検索している(セルを複数選択するだけですが)。
  • 過去一週間の数字を予測のたびに算出している(セルを複数選択するだけですが)。
  • 集計するときは合計数値しか見ていないので、個々のキャンペーンのパフォーマンスまでは目が行っていない(別途確認する模様)。

作業時間は約2時間/週。年間にすると100時間の作業です。

さらにAさんOさんとディスカッションを行い、広告の数値を取得するところがボトルネックで、心的疲労も大きそうだったので、ここをまず自動化することで要件を固めました。

STEP3. さて実装・・の前にモック作りと仕様すり合わせ

次にモックを作ります。AさんOさんが確実に使いやすく、且つ汎用性を失わない形にすることが大事ですね。汎用性を持ち、誰でも理解・利用可能ならば、多くの人に利用してもらえますし他の人が同じ轍を踏まずにすみます

また、この段階のアウトプットは明確に。ここで認識違いがあると手戻りのリスクになります。アウトプットを固め、モックを見てもらい「本当にこれで使えるのか」をさまざまな切り口でくどいくらいヒアリングします

今回、私から提案したのはGoogleSpreadsheetを利用したアウトプットです。イメージとしては下図のようなものです(数字は適当)*2

f:id:astamuse:20160803010755j:plain

これはキャンペーンごとのクリック、コスト、CV、CPAが格納されている生データを持つシートです。すべてのデータの基礎になるとても大事なシートです。

そしてもう一つは、その生データをカテゴリごとに集計したものです(下図。数字は適当)。

f:id:astamuse:20160803012033p:plain

GoogleSpreadsheetは機能的で汎用性が高く、データと人とのインタフェースとしてはエクセルに相当するくらい馴染みやすいものです。

また、JavaScriptベースのGoogleAppsScriptによるデータの取り回しもでき、APIも兼ね備えているのでWebとの連携がしやすい。つまり利用側、実装側双方にとってメリットが大きいソリューションです。

「CSVにしてメールで送っておしまい」ということも検討できますが、受け手がメールを開いて、添付ファイル開いて、数字コピーして・・ということを考えると実効性が低いことのほうが多いです。こういうときは巨人の肩の上に乗って省力化しちゃいましょう。

STEP4. さぁ、実装だ!

前段が長くなりましたが、実装に入ります。ここからはコード記述が多くなります。 また今回はサンプルとしてYahoo!のスポンサードサーチを取り上げて説明します。

アーキテクチャ

まずはアーキテクチャを。今回は下図のようなものにしました。

f:id:astamuse:20160803192835p:plain

Webページからの数値取得はヘッドレスブラウザのCasperJSを使い、結果をTSVに吐き出します。それをnodeからGoogleSpreadsheetに投げる。GoogleSpreadsheet側では、データを集計・加工するGoogleAppsScriptのバッチ処理が一日一回まわります。で、その処理が終わったらslackにメッセージを投げるという形です。

やや複雑ですが、利用している言語がほぼJSであり、アプリケーションエンジニアだけでなくフロントエンドエンジニアもメンテナンスできる点が良いかなと個人的には思っています。

また取得したデータはテキスト保存してgitに管理させておきます。DBレスで極めて身軽、ポータビリティが高い構成です。

環境構築

サーバー側の実行環境はnodeです。他にインストールするものは、CasperJSとそのベースとなるPhantomJS、それとGoogleSpreadsheetにデータを投げるためのnodeのライブラリ(google-spreadsheet, async)などをインストールします。

今回はDocker使いましたのでDockerfileを以下に記載しておきます。

FROM ubuntu:trusty
RUN apt-get update \
 && apt-get -y upgrade \
 && apt-get -y install build-essential chrpath wget curl libssl-dev libxft-dev unzip python git libfreetype6 libfreetype6-dev libfontconfig1 libfontconfig1-dev
 

# PhantomJS インストール
RUN export PHANTOM_JS="phantomjs-2.1.1-linux-x86_64" \
 && wget https://bitbucket.org/ariya/phantomjs/downloads/$PHANTOM_JS.tar.bz2 \
 && tar xvjf $PHANTOM_JS.tar.bz2 \
 && mv $PHANTOM_JS /usr/local/share \
 && ln -sf /usr/local/share/$PHANTOM_JS/bin/phantomjs /usr/local/bin \
 && rm $PHANTOM_JS.tar.bz2

# CasperJS インストール
RUN git clone git://github.com/casperjs/casperjs.git \
 && cd casperjs \
 && ln -sf `pwd`/bin/casperjs /usr/local/bin/casperjs

WORKDIR /home/docker/work

# NodeJSのインストール
RUN wget -qO- https://deb.nodesource.com/setup_4.x | sudo bash - \
 && apt-get install -y nodejs \
 && npm install google-spreadsheet async

WORKDIR src

DockerのImageレイヤーはあまり増やしたくないので、適度に&&でつなぎ、キャッシュ依存度を高めておきます。

PhantomJSやCasperJSについてはnpmではなくソースをから展開します(npmだとインストールレベルの管理・把握が面倒なので)。npmに依存するのはGoogleSpreadsheetにデータを投げるときに使うgoogle-spreadsheetasyncだけにします。

CasperJSによるYahoo!から数値取得

CasperJSによる数値取得は特別なことはしていません。ここでは骨子だけ平易に書いておきます。かなり省略して書いていますのでそのままでは動きません。

/*
 * Yahoo!のスポンサードサーチから広告数値を取得します
 * @args  {String} 'daily' or null
 *      
 *       dailyを指定すると前日の数値を取得します。
 *       何も指定しないで実行するとグローバル変数の start_date と end_date を対象期間として
 *       各日付の数値を取得します。
 *
 * @usage `$ casperjs yahoo_ad_data_picker.js daily`
 *
 */


/*
 * モジュール読み込み
 */
var fs    = require('fs'),
    utils = require('../lib/utils.js'); // オレオレUtil


/*
 * グローバル変数定義
 */
var login_id = 'type your account',
    password = 'type your password';

var start_url = 'http://business.yahoo.co.jp/',
    login_url = 'https://login.bizmanager.yahoo.co.jp/login'
              + '?url=https://promotionalads.business.yahoo.co.jp/Advertiser/Accounts',
    adsys_url = 'https://promotionalads.business.yahoo.co.jp/';

var account_ids = [],
    crumb       = null,
    start_date  = new Date('2016/01/01'),
    end_date    = new Date('2016/01/07');

var casper = require('casper').create();
casper.echo("INFO : start.", "GREEN_BAR");


/*
 * 引数処理
 */
var tar_dates;
if(!casper.cli.has(0) || casper.cli.get(0) != 'daily') {
    tar_dates = utils.getDateArray(start_date, end_date);
}else{
    tar_dates  = utils.getDateArray();
    start_date = utils.getYesterday();
    end_date   = utils.getYesterday();
}


/*
 * エラー時の処理定義
 */
casper.on('error', function(msg, backtrace) {
    this.echo("Error : " + msg);
    this.capture('./error.png');
    fs.write('./error.html', this.getHTML(), 'w');
});


/*
 * 一度Yahoo内のページを経由しないと、
 * Loginページにてinput[type='hidden'][name='crumb']が付与されないのでstart_urlを踏む
 */ 
casper.start(start_url);


/*
 * 出力ファイルの初期化
 */
casper.then(function(){
    var i;
    for(i = 0; i < tar_dates.length; i++ ){
        fs.write('./out/' + utils.toLocaleString(tar_dates[i], '-') + '.tsv', '', 'w');
    }
});


/*
 * ログイン
 */
casper.thenOpen(login_url, function(){
        this.fillSelectors(
            "form[name='login_form']",
            {
                'input[name="user_name"]' : login_id,
                'input[name="password"]'  : password
            }, true
        );
    }
);


/*
 * キャンペーンのURL情報をAPIから取得
 */
casper.then(function(){

    // ログイン処理後にリダイレクトがあるのでwait
    casper.wait(5000, function(){

        crumb = this.getElementAttribute("input[name='crumb']", 'value');

        if(crumb === null){
            this.echo('Error : crumb is null').exit();
        }else{
            this.echo('INFO : crumb is ' + crumb);
        }

        // ページ内に表示されている広告キャンペーンアカウントの総数を取得
        var total_num       = this.getElementAttribute('table.PagingArea th div:nth-child(2)', 'data-total'),
            api_url         = adsys_url + 'Advertiser/Ajax/Accounts/AccountListApi.php',
            url_params      = { 
                                 '_'                  : null,
                                 'crumb'              : crumb
                               , 'isBundleAdvertiser' : 1
                               , 'divide'             : ''
                               , 'results'            : 100
                               , 'sort_item'          : 'accountId'
                               , 'sort_order'         : '-'
                               , 'searchType'         : 'accountName'
                               , 'searchKey'          : ''
                               , 'page'               : 1
                              },


        var i   = 1;
            len = Math.ceil(total_num / url_params['results']);
        
        
        // 全てのアカウント情報を取得するために、100件構成のアカウントリストを充足するまで呼び出す

        for(i = 1; i <= len; i++ ){

            (function(page_num, this_){

                url_params['_']    = (new Date).getTime();
                url_params['page'] = page_num;

                var tar_url = api_url
                            + '?'
                            + createGetParamsStrByObj(url_params) ;

                this_.thenOpen(tar_url, function(){
                   
                    var account_json = JSON.parse(this.getPageContent());
                        account_json = account_json.ResultSet.Result;

                    var ai  = 0,
                        len = account_json.length;

                    for(ai = 0; ai < len; ai++ ){
                        account_ids.push(account_json[ai]['ID'])
                    }
                });
            })(i, this);
        }
    });
});


/*
 * キャンペーンのコスト情報をAPIから取得
 */
casper.each(tar_dates, function(self, tar_date){
    self.then(function(){
        var error_ids = [];

        var i = 0,
            l = account_ids.length;
        
        // キャンペーンアカウントの数だけ回す
        for(i = 0; i < l; i++){
            (function(account_id, i, this_){

                // 気持ちwait
                this_.wait(1000, function(){

                    var api_url      = adsys_url + 'Advertiser/Ajax/Campaigns/SummaryApi.php',
                        enc_tar_date = encodeURIComponent(utils.toLocaleString(tar_date, '/')),
                        url_params   = { 
                                          '_'                  : (new Date).getTime()
                                        , 'accountId'          : account_id
                                        , 'campaignId'         : ''
                                        , 'dateRangeType'      : 'CUSTOM_DATE'
                                        , 'dateRangeStartDate' : enc_tar_date
                                        , 'dateRangeEndDate'   : enc_tar_date
                                        , 'crumb'              : crumb
                                        , 'ItemSelect'         : account_id
                                        , 'sort_item'          : 'Cost'
                                        , 'sort_order'         : '-'
                                        , 'results'            : '25'
                                        , 'useFilter'          : 0
                                        , 'searchIdEntity'     : ''
                                        , 'page'               : 1
                                    };

                    var tar_url = api_url
                                + '?'
                                + createGetParamsStrByObj(url_params);

                    this.thenOpen(tar_url, function(){

                        var summary_json = JSON.parse(this.getPageContent());
                            summary_json = summary_json.ResultSet.Total;

                        var output = [
                                      account_id
                                    , summary_json['Clicks']
                                    , summary_json['Cost']
                                    , summary_json['UniqueConversions']
                                    , summary_json['CostUniqueConversions']
                                 ];

                        fs.write('./out/' + utils.toLocaleString(tar_date, '-') + '.tsv', output.join('\t') + "\n", 'a');
                    });
                });
            })(account_ids[i], i, this);
        }
    });
});


/*
 * タスク終了
 */
casper.run(function() {
    this.echo('Done.', 'GREEN_BAR').exit();
});

で、このスクリプトを実行します。

$ casperjs yahoo_ad_data_picker.js daily

するとスクリプトと同じ階層のoutディレクトリに以下のようなファイルができます。

1528614877          1      77       0       0
8349966648          2      60       0       0
120390454           6     504       0       0
9496487401          4     187       0       0
1582964683          0       0       0       0
2466369134          2     199       0       0
738523464           0       0       0       0
1451325219          0       0       0       0
3450894908          2     149       0       0
1565386334         18   1,136       0       0
5916174850         17   1,126       2     563
9614577780          0       0       0       0
1297618051          8     550       1     550
225835569           8     452       0       0
8572431975          0       0       0       0
6279508966          0       0       0       0
1843037438          0       0       0       0
6691244345          7     535       0       0
8024417193          0       0       0       0
1453747705          1      99       0       0

左からキャンペーンID、クリック数、コスト、CV、CPAという順に並んでいます(数値は架空です)。今回はスペースでpaddingしましたが、実際はTSVです。

TSVの値をGoogleSpreadsheetへAPI経由で投げる

先ほどできたTSVをGoogleSpreadsheetに投げます。あらかじめGoogleSpreadsheetを用意したり、GoogleSpreadsheet API利用のための認証を行う必要がありますが、ここではその説明は割愛し参考リンクだけ貼っておきます。

参考

GoogleSpreadsheetにデータを投げるスクリプトは以下のようなものです。

/*
 * CasperJSで取得した数値をGoogleSpreadsheetに記録します
 * @arg1   {String}  cutom or daily
 * @usage  `$node post_to_spreadsheet.js daily`
 */

'use strict';
var GoogleSpreadsheet = require('google-spreadsheet'),
    credentials       = require('./lib/google-creds.json'),
    utils             = require('./lib/utils.js'), // オレオレutil
    async             = require('async'),
    startDate         = new Date('2016/06/27'),
    endDate           = new Date('2016/07/27');

/*
 * 引数チェック
 */ 
if(process.argv.length < 2) {
    console.log('ERROR : missing argument.');
    return;
}

if(process.argv[2] != 'custom' && process.argv[2] != 'daily'){
    console.log('ERROR : term is invalide ' + process.argv[3]);
    return;   
}else if(process.argv[2] == 'daily'){
    startDate = null;
    endDate   = null;
}

/*
 * 変数定義
 */
var targetAdService = process.argv[3],
    doc_id          = 'type your spreadsheet id',
    doc             = new GoogleSpreadsheet(doc_id),
    targetDates     = utils.getDateArray(startDate, endDate),
    rowHeader       = [],
    tsvJson         = null,
    dataSheetNum    = 1, // データを書き込むシートのindexを入れてください
    sheet,
    colIndex;


async.eachSeries(targetDates, function(targetDate, step_) {
    async.series([
        // 変数初期化
        function(step){
            sheet      = null;
            colIndex   = null;
            rowHeader  = [];
            var inputFile = './out/' + utils.toLocaleString(targetDate, '-') + ".tsv";
            tsvJson       = utils.convertToMyJson(utils.loadTSV(inputFile));
            step();
        },

        // 認証
        function(step) {
            doc.useServiceAccountAuth(credentials, step);
        },

        // 書き込む対象のシートを取得
        function(step) {
            doc.getInfo(function(err, info) {
                sheet = info.worksheets[dataSheetNum];
            });
        },

        // 書き込む対象となる日付のカラムインデックスと取得
        function(step) {
            sheet.getCells({
                'min-row'      : 1,
                'max-row'      : 1,
                'min-col'      : 1,
                'max-col'      : sheet.colCount,
                'return-empty' : true
            }, function(err, cells) {

                var i;
                for(i = 0; i < sheet.colCount; i++){
                    if(cells[i].value == utils.toLocaleString(targetDate, '/')){
                        colIndex = i + 1;
                        break;
                    }
                }
                step();
            });
        },


        // 書き込むキャンペーンIDがあるカラムのインデックスを取得
        function(step) {
            sheet.getCells({
                'min-row'      : 2,
                'max-row'      : sheet.rowCount,
                'min-col'      : 1,
                'max-col'      : 1,
                'return-empty' : true
            }, function(err, cells){
                var i;
                for(i = 0; i < cells.length; i++ ){
                    rowHeader.push(cells[i].value);
                }
                step();
            });
        },


        // キャンペーンIDの行インテックスと、日付カラムのインデックスをもとに値を埋めていきます
        function(step) {
            sheet.getCells({
                'min-row'      : 2,
                'max-row'      : sheet.rowCount,
                'min-col'      : colIndex,
                'max-col'      : colIndex,
                'return-empty' : true
            }, function(err, cells){
                var i;
                for(i = 0; i < rowHeader.length; i++ ){
                    if(rowHeader[i] == ''){
                        continue;
                    }

                    var rowHeaderItems = rowHeader[i].split("-");

                    if(rowHeaderItems.length == 2){
                        var campaignId = rowHeaderItems[0],
                            dataType   = rowHeaderItems[1];   

                    }else if(rowHeaderItems.length == 3){
                        var campaignId = rowHeaderItems[0] + '-' + rowHeaderItems[1],
                            dataType   = rowHeaderItems[2];
                    }
                    cells[i].value = tsvJson[campaignId][dataType];
                }

                try{
                    sheet.bulkUpdateCells(cells, step);
                }catch(e){
                    console.log(e);
                }
            });
        }
    ], function(err){
        if (err){
            console.log(err);
        }else{
            console.log("Done.");
            step_();
        }
    });
});
    

これで先ほどサンプルでお見せしたような形(下図再掲)にデータが埋まります。完全な生データですね。貴重なデータシートです。

f:id:astamuse:20160803010755j:plain

次はこの生データをもとに集計作業を行います。

GoogleAppsScriptで集計処理

データをGoogleSpreadsheetに渡し終わったらGoogleAppsScriptで集計処理を行います。

「nodeから投げるときに計算も済ませれば?」と思うかもしれません。しかしGoogleSpreadsheet側のAPI上限が決まっていること、GoogleSpreadsheet側で作ったほうが動作検証が早く開発効率が高いことなどを踏まえ、集計処理はGoogleSpreadsheet側(GoogleAppsScript)で行います。

コードは割愛します。先ほどの生データを用いて下図のようなアウトプットを目指して組めばよいです。ミニマム実装にすれば200行以下のコードで済むと思います。

f:id:astamuse:20160803012033p:plain

書いたスクリプトは一日一回実行するようにしておきます(GAS側でそういう設定ができます)。

結果をslackでお知らせ

最後に集計処理が終わったらslackにお知らせを流して終了です。成功した場合はかわいいクマがシートへのURLともにがお知らせしてくれます。

f:id:astamuse:20160803192841p:plain

失敗した場合は、私に「失敗してるぞ」というお知らせが来ます…orz

f:id:astamuse:20160804113726p:plain

STEP5. リリースと導入支援

やっと実装が終わりました。これでAさんOさんに引き渡しです。引き渡しはGoogleSpreadsheetのURLを渡して終了です。簡単。ほぼ説明なしで理解いただけました。

その後はしばらく様子見し、一週間後に使い心地をヒアリングします。不便はないか、当初想定した通りに使えているか、もっとこうしたほうがいいのではないか、など。一度、実物ができると考えが発展的・建設的になり、前向きな議論できていいですね。

今回も、デイリーだけでなく、マンスリー、ウィークリーの集計結果があるとよいとのことで追加実装しました。また単純集計だけでなく、分析作業も一部自動化できそうだったので、これも改めて検討していこうと思います。

今後の課題

結構ざっくり作ったためまだまだ改善の余地があり、大きな課題として以下の5点を認識しています。

  1. ほかの広告システムへの適用(横展開)
  2. バックアップの仕組みの構築
  3. 異常検知の構築
  4. プログラムの例外処理
  5. キャンペーン追加時のオペレーション自動化

1, 2はすでに取り組みつつあり、その一方で私が一番興味がある課題は3です。

イメージとしてはmonitによるサーバー監視ライクな仕組みができたらいいな、と。CPUやメモリの数値を定点観測し、異常数値が出たらお知らせがいく仕組みですね。広告数値が直近10日のトレンドから著しくずれていたらアラートを出す、的な。ここにはうまいこと機械学習を噛ませると面白いと思っています。

まとめ

今回の取り組みはこの記事がずっと頭に残っていたことから始まります。

フェイスブックは業務を自動化して社員を「過去の仕事」から解放した http://www.dhbr.net/articles/-/3542

「うちもこんな風にしたいなー」とほど良い課題を探していたら、AさんとOさんの悩み発掘した、というのが真の経緯です。Facebook程ではありませんが、それに近いことができたかなとは思っています。

また、AさんOさんの作業が軽くなったことも嬉しかったですが、さらに嬉しかったことがありました。今回の件を管理部の方がどこかで聞いたらしく、経理周りのお仕事でも一部自動化を試みる機運が出てきたことです。ここでもまた事務作業を自動化して生産性を高めることができれば、経理の方としても会社としてもハッピーだと思います。

最後に、こういったテーマを見つけたときに一緒に楽しく議論できるメンバーに恵まれて助けられました。「それ面白いね」とか「こうしたらスマートだよね」とか「どうせだったら新しい技術入れてみよう」とか言ってくれるメンバーには非常に力をもらいました。インフラ周りについては並河 (@namikawa) さんとDockerを用いた構成について議論できたのは刺激的で楽しかったですし、CasperJSでの処理についてkitoさんと有益な議論ができました。また、メモリリークの問題でaxtstar(@axtstart)さんに助けられました。

何より「こういう風にできるかもしれないけどどう?」と話したときに「やってみましょう」と言ってくれたAさんOさんには、大きなチャンスをいただけたと思っており感謝でいっぱいです。

さ、100時間分もっと楽しいことやっていきましょう!

*1:失敗学の提唱者である畑村洋太郎氏は「現地・現物・現人」を謳っており、こちらの方がしっくりきます

*2:当時のものをベースに一部修正を施しています

Web画面自動テストフレームワーク「Geb」の紹介

どうも、えいやです。

今回の弊社技術ブログを担当させていただきます。

会社では主に、ゴロゴロしたり、Asamuse.comのバックエンドのJavaを書いたり、AstaID.comのバックエンドとフロントエンドをJavaやJavaScriptで実装したり、他のWebアプリをたまにメンテしていたりしています。

その他には、

  • GitやJenkins、Redmine、テストフレームワーク、ビルドシステムなどを連動させた開発業務のフローを設計・導入したり
  • fluentdで集めた弊社Webサービスのアクセスログを、Elasticsearchに集約・解析する仕組みを設計、導入、維持したり
  • 解析したアクセスログをサービスとしてコンテンツにフィードバックさせる仕組みを作ったり
  • 発明者や出願人となっている法人の名前を、適当な文字列処理と判定アルゴリズムを書いて名寄せしたり
  • (9割がた趣味として)弊社のサービスと自然言語処理を組み合わせた外部サイトのニュースの分析システムを試作したり

などの仕事もやっています。

必要そうなものがなんとなく崩壊しない程度のレベルを維持しつつ、できそうな感じの方法でとりあえずやっているので、誰かちゃんとしたスペシャリストに任せてもっと安心してゴロゴロしていたい気持ちでいっぱいです。

よろしくお願いします。

記事の概要

さて、今回は弊社においてインテグレーションテストやシステムテストのために採用しているWeb画面自動テストフレームワークの「Geb」について、ハンズオンできる感じでわりと詳し目に紹介します。

Gebはブラウザ自動化ツールとして有名なSeleniumGroovyで記述できるようにしたフレームワークです。

この記事を読むにあたってGroovyやSeleniumに関する詳しい知識は必要としませんが、Javaについてある程度の理解があると読みやすいと思います。

また、Gebでのテストは、テスト対象のWebアプリケーションと完全に独立して作れますので、テスト対象のWebアプリケーションがJVM上で動作している必要はありません。

とはいえ、テスト対象がJVM系のものであれば、そのコードをGebからも利用できるのでJVM系の開発に対して有利です。

逆をいえば、現在JVM系のWebアプリのテストをJSやRuby、Pythonなどのコードで行っている場合には、Gebは乗り換え先の選択肢として有力になりえます。

なお、この記事は、先に筆者が個人として2014年に公開した Qiitaの記事*1から一部を抜粋し、加筆したものであり、過去の記事の執筆時点からのGebおよびSeleniumの変更点に対応したものです。

元の記事は、テストとしての体裁を整えることを主題としています。この部分については、元記事執筆時点から今までの間にはフレームワークの仕様に大きな変更はありません。

そのため、今回この記事では過去の記事との重複を極力避け、導入についての注意点とGebの機能と具体的な使い方に重点を置いて説明します。特に、今回の追記部分でフォームへの入力についての例は、初心者がGebやSeleniumを使う上でのコツやヒントになると思います。

テストとしての体裁の作り方が気になる方は、元の記事*2を参照してください。

Groovyについて

今回の紹介記事ではプログラムコードの全てをGroovyを使って記述しますので、最初にGroovyを知らない方に向けて簡単にGroovyの紹介をしておきます。

Groovy言語は、Java言語を動的型付け言語として簡素で柔軟な記述ができるように設計しなおしたような言語で、Javaの実行環境であるJVM上で動作します。

GroovyはJavaとまったく同じコードで書くことができます*3

加えて、

  • 末尾セミコロンやメソッド引数であることが自明なカッコの省略
  • ダブルクォート囲みでの変数の文字列展開(GStringリテラル)
  • バックスラッシュをエスケープ文字として認識しないスラッシュ囲みの文字列リテラル
  • 簡潔なクロージャの定義構文、ファーストクラス関数として扱えるクロージャオブジェクトなど関数言語的な文法
  • スクリプトとして実行が可能

などといった簡素に書ける仕組みが盛り込まれています。

また、Groovyには強力なメタプログラミングの機能が備わっており*4、Javaではやり難い、言語自身の文法の定義を変更してやりたいことに合わせた記法を作る、DSL*5が作りやすいという特徴があります。

今回紹介するGebは、Seleniumを扱うことに特化したGroovyのDSLです。

Groovyで作られたDSLの有名な例として、Androidアプリケーションのビルド自動化ツールとして広く使われているGradle*6があげられます。

弊社では、今回紹介するGeb以外に、Javaアプリケーションの単体テスト(Spock)やビルド(Gradle)、一部のバッチ処理にGroovyを採用しています。

環境の確認

今回の紹介記事が前提としている環境について、事前のインストールが必要なものと、注意が必要なものを記載します。

不具合の出るJavaバージョン

JDKのバグにより、java 7u71、またはそれ以前の近いバージョンの場合でGebの実行ができないことがわかっています。 このバグはu75以降で修正されています*7

このバグは以下のエラーを引き起こします。

Bad <init> method call from inside of a branch

なお、執筆時の筆者のJava環境は以下となっています。

aya_eiya$ java -version
java version "1.8.0_91"
Java(TM) SE Runtime Environment (build 1.8.0_91-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.91-b14, mixed mode)

Groovyの実行環境の準備

Windowsでは、ダウンロードサイトからZIPをダウンロードして解凍するとGroovy環境が丸ごと入ったディレクトリができます。 コマンドプロンプトから実行したい場合は、ディレクトリ中の/binディレクトリにパスを通してください。

MacOS,Cygwinを含む*nix環境では、それぞれのパッケージ管理システム、またはsdkmanを使ったインストールが便利です。 各自好みの方法でGroovyをインストールしておいてください。

GroovyといいsdkManといい、サイトのトップのセンスがヒップでポップですが、使う分にはたぶん影響はありません。

ブラウザの準備

今回の説明では、自動で操作するブラウザをFireFoxとしますので、FireFoxをインストールしておいてください。

FireFoxを自動操作する準備

FireFoxは、2016年6月にリリースされたVersion47.0以降の更新では、以前までのFireFoxで使われていたFireFoxDriverが(訂正:2016/08/03)これまで通りには(ココマデ)使用できなくりました*8

Version47.0以降では、FireFoxをSeleniumで操作するためには、Selenium WebDriverと同等の機能を持つ Marionetteを使います。

MarionetteとはMozillaが開発している、Geckoエンジンを搭載したアプリケーションの操作の自動化を行うことができるツールです。

GebのコードからMarionetteを使うためには、GeckoDriverをインストールする必要があります。

お使いのOS環境に合わせた最新のリリースをダウンロードします。

ダウンロードしたファイルを解凍してできたファイルを、使用OSごとに以下のパスにリネームして保存しておいてください。

Windows

C:¥tools¥geckoDirver¥marionette.exe

※)ダウンロードしたファイルのプロパティを表示して、ブロックの解除を行っておきます。

Mac / Linux

~/tools/geckDriver/marionette

GroovyスクリプトでGebを動かす

まずは、一番簡単な方法でGebを動かしてみましょう。

実行環境の準備

Groovyはスクリプトとして実行できます。

Groovyをインストールすると、Groovyスクリプトを記述して実行できるGUIをもつGroovyコンソールがインストールされます。

Windowsでは、Groovyのインストールディレクトリの下にあるbin/の中にバッチがありますので、Pathを通しておきます。

※nix環境では、パッケージや、sdkManを使ってインストールしておけばパスが通っているはずです。

インストールが終わったら、次のコマンドでGUIのコンソールが立ち上がります。

$ groovyConsole

※)注意:システムのデフォルトエンコードによっては、日本語が文字化けする可能性があります。その場合、次のようにJVM環境変数を設定します。

# *nix
$ export JAVA_OPTS='-Dfile.encoding=UTF-8 -Dgroovy.source.encoding=UTF-8'

#windows
> set JAVA_OPTS='-Dfile.encoding=UTF-8 -Dgroovy.source.encoding=UTF-8'

Gebを実行してみる

GebでFireFoxを操作する一番簡単なコードは以下のような形です。

Browser.drive(driver:new MarionetteDriver()) {
    go 'https://www.google.co.jp'
}

上のコードに、実行に必要な設定を加えたものが下記のスクリプトです。

Groovyコンソールの編集エリアに貼り付けて、メニューの Script > Run(または Ctrl+r )で実行してみてください。

@Grab('org.gebish:geb-core')
@Grab('org.seleniumhq.selenium:selenium-java')

import geb.Browser
import org.openqa.selenium.firefox.MarionetteDriver

{->
  def isWindows = System.properties['os.name'].toLowerCase().contains('windows')
  def home = System.properties['user.home']
  System.setProperty('webdriver.gecko.driver',
  isWindows ?
    /c:\tools\geckoDriver\marionette.exe/:
    "${home}/tools/geckoDriver/marionette"
  )
}()

Browser.drive(driver:new MarionetteDriver()) {
    go 'https://www.google.co.jp'
}

このスクリプトは、FireFoxを立ち上げてGoogle(日本)のページを表示します。

最初の2行はGroovyの機能の一つで、Grapeというライブラリ依存の解決を行う機能です。簡易なMavenやGradleのようなものと考えてください。

GrapeのGrabアノテーションの設定により、Gebフレームワーク本体とJava用のSelenium APIをインターネット上の公開リポジトリからダウンロードして、このスクリプト内で使用できるようにしています。

その後に続くブロックは、Groovyのクロージャ記法で宣言されています。このコードはMarionetteDriverのインストールパスをシステム変数に設定するものです。

このクロージャには名前をつけずにその場ですぐに実行しています。

こうしたクロージャの使い方には、クロージャ冒頭のローカル変数のスコープを限定する以外に意味はありませんが、Gebのコードと分けて説明するためにあえてこのように書いています。

説明のために単体のスクリプトでMarionetteの設定を行っていますが、GroovyConsoleの起動引数として-Dwebdriver.gecko.driver=/path/for/marionetteを設定しても大丈夫です。

起動引数として設定した場合、このクロージャ自体が不要となります。

なお、波かっこで囲まれた部分はJavaではブロックとなりますが、Groovyではifブロックなどを除いて、ほとんどの場合で無名のクロージャの定義となります。

しかしながら、今回はGroovyそのものが主題ではないため、その部分をクロージャとして特に意識しなければいけない場合を除いて、単にブロックと表現します。

Javaにおいてのifやforなどと同じようなものだと思って読んでください。

Browser.driveで始まるブロックがGebによるブラウザの自動化の部分となります。

動作の内容は書いてあるとおり、「ブラウザの動作は、FireFoxを動かすドライバ(Marionette)を指定して、Googleのトップページ(https://www.google.co.jp) に行く」という単純なもので、正常に実行できていればその通りに動いたはずです。

このプログラムが基本となりますが、ここでつまづいてしまうことがあるので、ここで起こりそうな問題を2点あげておきます。

問題 1:Grapeのライブラリダウンロードがエラーを起こす

Grapeを用いた解決では、たまにライブラリ(jarファイル)がダウンロードできない不具合が起こります。

原因と解決方法がいくつかあります。

Grapeの設定ファイルを編集して依存性の解決に使うリポジトリを変更する

セキュリティ設定などで社内環境からは見えないリポジトリがあったり、社内向けにセントラルなどの一部をクローンしてあるリポジトリを見るようになっている場合、この解決方法が良いでしょう。

Grapeの設定ファイル(~/home/.groovy/grapeConfig.xml)に、Grapeが依存性解決に使うリポジトリが書かれていますので、ここに、例えば次のような行を追加します。

<ibiblio name="m2central" root="http://central.maven.org/maven2" m2compatible="true"/>
<ibiblio name="my-repo-snapshot" root="https://my-repo1.net/snapshots/" m2compatible="true"/> <!-- 社内向け -->
<ibiblio name="my-repo-release" root="https://my-repo1.net/release/" m2compatible="true"/> <!-- 社内向け -->
ローカルm2リポジトリが壊れている

Grapeは最初にローカルm2リポジトリ(~/home/.m2/)を参照します。このリポジトリが壊れていたり、Ivyプロジェクト*9に変換できなかったりすると、ダウンロードが失敗したというエラーになります。

リポジトリが壊れている場合は、ダウンロードに失敗しているライブラリをm2リポジトリからフォルダごと削除します。

また、m2リポジトリの中にJarファイルが置いてある場合は、自分でJarファイルをGrapeのキャッシュディレクトリ(~/.groovy/grapes/.../jars/)に保存しても解決できます。

Grapeを使わない

Gradleプロジェクトとして、Grapeを使わずに依存性を解決するとうまくいくことがあります。

問題 2:ブラウザが立ち上がってこない

いくつかの原因が考えらえます。

立ち上がったブラウザを閉じた

GroovyConsoleを使っている場合には、自動的に立ち上がったブラウザは消さないほうが良いです。もし消してしまった場合は、メニュー > Script > Clear Script Contextでコンソールの状態を初期化してください。

運悪く動作しない組み合わせになった

今回の説明では、作業用のPCではFireFoxが自動的に最新状態にアップデートされている想定ですので、SeleniumやGebも最新のものを取得するようにしています。

ブラウザとライブラリの両方が最新の状態であれば大抵は動作しますが、たまに動作がうまくいかない組み合わせになる場合があります。そのときは、ブラウザかライブラリのバージョンを調整してください。

例えば、FireFox 47.0(2016/06/07 リリース版)ではブラウザ側にバグがあり、Seleniumが動作しないという不具合が報告されています。FireFox 47.0を使っている場合は、FireFoxのアップデートを待つか、一つ古いバージョンに戻す必要があります。

プロセスが残ってしまっている

今回の説明では、実行完了時にブラウザが閉じられると確認しづらいのでdriverやBrowserインスタンスをclose()していません。

この状態でも動くはずですが、MarionetteやFireFoxのプロセスが残ってしまうこともあるようです。プロセスが大量に閉じられずに残っている状態だと、ブラウザが起動しなかったりします。

GroovyConsoleを使っている場合はGroovyConsoleを閉じてください。

それでプロセスが消えるはずですが、もし残っているプロセスがあればKillしてください。

Groovyを直接スクリプトとして実行している場合は、最後尾に次のような無限ループを追加しておき、ブラウザを閉じる代わりにctrl+cでスクリプトを終わるようにするとプロセスは残らないはずです。

while(true){
  sleep(1000)
}

Gebの機能の基本

Gebを動作させることができたら、Gebの機能を試していきましょう。

要素をCSSセレクタで取得してみる

記事の簡便のため以降の例では、上記のスクリプトで示したBrowser.driveなどの変更があるブロックのみを載せますので、手で書き換えながら進めてください。

また、必要に応じてimportが出てきます。それらは上部のimport部分に追加してください。

では、以下のスクリプトを試してみましょう。

Browser.drive(new MarionetteDriver()) {
    go 'https://www.google.co.jp'
    println $('input')
}

このスクリプトでは、前の例に加えて「要素をセレクタ'input'で取得して表示する」という行が追加されています。

このセレクタの部分には、CSS3セレクタとほぼ同じものが使えます。

また$という記法から連想できるとおり、jQueryと似た感じで要素の取得や 、取得した要素への値の設定が行えます。

要素の属性を取得してみる

さらに、取得したエレメントの属性を取得してみます。

Browser.drive(driver:new MarionetteDriver()) {
    go 'https://www.google.co.jp'
    $('input').each {
      println it.attr('name')
    }
}

先ほど追加した内容を少し書き換えて、「セレクタ'input'に一致する要素を取得して、その全ての要素についての'name'属性を表示する」ようにしてあります。

「セレクタ'input'に対し該当する要素が複数あるため、取得した要素はリストとなっていること」、「eachがリストのそれぞれの要素に操作を行うメソッド」「操作はブロック(クロージャ)で与えられること」が分かれば難しくはないはずです。

なお、変数itはGroovyの文法で決まっているクロージャ内のスコープに定められる自動変数で、ここではリストの各要素を示すものです。

次のように、itを用いずにクロージャ記法で任意の変数名を明記することもできます。

$('input').each {elem->
  println elem.attr('name')
}

itはその時々により何が設定されているかが変わりますので、クロージャの記述が長くなったり、クロージャのネストが深くなったりする場合は、変数に名前をつけたほうがよいでしょう。

input要素の値を変更する

次は検索窓に検索単語を入れて検索する例です。

少しペースを上げて2つのことを同時に紹介します。

import org.openqa.selenium.Keys

Browser.drive(driver:new MarionetteDriver()) {
    go 'https://www.google.co.jp'
    $('input#lst-ib').value('astamuse') << Keys.RETURN
}

このコードでは、検索窓として使われているinputエレメントの一つをid(lst-ib)で特定して、その値をvalueメソッドで指定しています。

valueメソッドには、引数ありのものと引数なしのものがあります。引数がある場合は値を設定し、引数がない場合は値を取得します。

引数付きのvalueメソッドは、メソッドチェーン的な記述ができるようにメソッドの持ち主のエレメントを返します。

Gebではエレメントに対して、左シフト演算子(<<)を用いて、文字列やKeysに設定されているキーを送信することができます。

この例では、検索文字列'astamuse'を検索窓に入れて、そのままReturn Keyをストライクしたように振舞います。

うまくいっていれば、検索結果が表示されるはずです。

うまく動かない場合は、FireFoxのElementInspectorなどを使って要素の仕様が変更されていないかなどを調べて、適宜コードを書き直してみてください。

ここまでの例でGebの大体のところがつかめたと思います。

複雑なフォームの自動入力

先ほどの例では、Return Keyの送信によってフォームのサブミットを起こして検索ページへの遷移を行いました。

ここまでのことで大抵のことはできるようになったはずです。

では最後の例として、同じ画面の中でGUIのイベントを次々と起こす、見た目にわかりやすいブラウザ自動化を作ってみましょう。

対象となるサイトを、ここまでのGoogleのサイトから、弊社製のJSフレームワークであるAsta4.jsのサンプルサイトに変更します。

対象は、JSで動くインタラクティブなユーザ情報の登録フォームです。一人分の情報を入力したあとにAddボタンを押下するとページ下部に設置されたテーブルに一行追加されるという単純な作りのものです。

このページでは、サーバーへの情報の送信や保存はされませんので自由に試してもらって構いません。

イベントを実行させるわけですが、JSで動くページでは、イベントを引き起こすためには、ページの状態が望んだ状態になるまで待つ必要があることにも注意しましょう。

次のコードを実行してみましょう。これまでより少し長いです。

import org.openqa.selenium.Keys

def makeDataFor = {num->[
  name      :"User-$num",
  bloodType :['A','B','AB','O'][num%4],
  sex       :['0','1'][num%2],
  languages :[['Japanese'],['English'],['Chinese'],['English','Japanese']][num%4],
  'private' :[false,true][num%2],
  desc      :'hoge ' * num
]}

Browser.drive(driver:new MarionetteDriver()) {
    go 'http://astamuse.github.io/asta4js/examples/userList/userList.html'
    waitFor {
      $('select#bloodType>option').find{
        it.attr('value')=='O'
      }
    }
    def rowCount = 0
    10.times {
      def data = makeDataFor it
      $('select[name="bloodType"]') << data.bloodType << Keys.TAB
      $('input[name="sex"][value="' + data.sex + '"]') << Keys.SPACE
      $('.x-lang-group>label').findAll{
        it.text() in data.languages
      }.each{
        it.click()
      }
      data.private && $('label.onoffswitch-label').click()
      $('textarea[name="desc"]') << data.desc << Keys.TAB
      $('input[name="name"]').value(data.name) << Keys.TAB
      $('button#addBtn').click()
      rowCount++
      waitFor {
        $('.x-row').size() == rowCount
      }
      driver.executeScript('scroll(0, 1280);')
      sleep(1000)
    }
}

ブラウザが起動すると、フォームの入力が行われてデータの登録がされるという自動の操作が10回繰り返されます。

このコードでは、最初にmakeDataForというクロージャを定義して、フォームに入力するデータを作る準備をしています。とりあえず、このクロージャの実装は気にせずにいてください。数値を引数としてとって数値ごとに違うフォーム用のデータを返す処理が簡単に書いてあります。

これまでと同じく、Browser.driveで始まる部分からがブラウザに行わせる振舞いです。

指定したURLを表示させる部分は説明した通り、Googleのトップから対象のURLに変更してあります。

次の行で現れるWaitForは、与えたブロックの状態になるまで最大5秒間待ちます。内容は、今回の対象のページで読み込まれるべきJSが読み込まれ、画面が初期化された状態を想定した内容が書いてあります。

rowCountは後でイベントが成功したかどうかを確認するために準備した変数です。

10.timesは続くブロックを10回繰り返す操作です。

この操作と先に準備したmakeDataForを組み合わせて10回違うフォームデータを登録する操作を行います。makeDataForに渡しているitは、ここでは0から始まる何回目の繰り返しかを示す数値です。

繰り返させる動作は、当然フォームへの入力と入力した情報の確定です。

入力項目の種類には、Text、Select、RadioButton、CheckBox、カスタム入力項目、TextAreaが用意されています。

正常に入力を行うには、それぞれの種類にあったイベントを起こしてあげる必要があります。

この部分はブラウザや使うSeleniumのドライバごとに差異があったり、クライアントスクリプト(ひいてはそれを実行するマシン環境)の癖によって安定しなかったりするので、安定させるにはどうしても試行錯誤が必要です。

今回は、最初に名前の入力をさせると動きが怪しくなるという謎の知見が得られました。(そんなに深掘りしませんでしたが、Marionetteドライバの不具合な気がします)

Select項目(単一選択)

$('select[name="bloodType"]') << data.bloodType << Keys.TAB

要素をname属性で選び、上のように値を左シフト(左シフト演算子(<<))で送ると項目の中から値が選択されます。

ただし、この振舞いはブラウザごとの差異が大きいので、Selectについては安定する方法を自分で探したほうが良いです。

なお、Tab Keyを送っているのは、一行ごとでJavaScriptの値変更イベントを起こさせるためです。デバッグ時に、JSとGebのコードを同期しやすくする目的があります。

※) Selectには複数の選択を行うものもありますが、今回は説明しません。

RadioButton項目

性別の項目は、単一選択項目のRadioButtonです。

$('input[name="sex"][value="' + data.sex + '"]') << Keys.SPACE

RadioButtonの場合、セレクタで値のつけられた要素を選択して、Space Keyを送ることで選択できます。

今回の対象では性別の項目では、値と関連付けられたラベルのテキストが同じではないので、値を使って選択しました。

ラベルで選択したい場合は、値とラベルの対応表を持つなどして、ラベルを選択してclick()しても選択できます。

CheckBox項目

言語の項目は複数選択可能なCheckBoxです。

$('.x-lang-group>label').findAll{
  it.text() in data.languages
}.each{
  it.click()
}

CheckBoxには値とラベルのテキストが一致していますので、ラベル要素のうち、そのテキストが選択したい言語のリストに含まれているものを見つけ、それらの要素全ててclick()することで選択します。

カスタム入力項目

data.private && $('label.onoffswitch-label').click()

JSを使って作りこんだカスタム入力項目では、特定のやり方というのはありませんので、条件分岐やclick()などのイベントを駆使して値を設定します。

TextArea項目/testInput項目

名前と詳細のはテキスト入力項目です。

$('textarea[name="desc"]') << data.desc << Keys.TAB

のように要素にたいして左シフト演算子(<<)で文字列を送信しても、

$('input[name="name"]').value(data.name) << Keys.TAB

のように要素の値を設定するvalueメソッドを使ってもよいです。

ボタンクリック

$('button#addBtn').click()

普通に選択してclick()で大丈夫です。

イベント完了待ち

特にClickイベントを起こした後は大体、ページの状態が変化します。 先に説明したwaitForを使って待ちます。

rowCount++
waitFor {
  $('.x-row').size() == rowCount
}

今回は、ボタンをクリックした後で画面下部に一行追加されるはずなので、それを待ちます。

(おまけ)任意のJSの実行

今回の画面は、ボタンを押したらテーブルの最後に一行追加してくれるのですが、追加していくにつれて画面サイズによっては一番下が見えなくなります。

自動テストとしては問題はないのですが、今回は目で見ていますのでそれでは困ります。

次のようにdriver.executeScriptを使えば、任意のJSを実行できるので、今回はそれを使って画面の下のほうに強制的にスクロールしています。ついでに次の入力までに1秒間のスリープを入れています。

driver.executeScript('scroll(0, 1280);')
sleep(1000)

上記までの操作を10回繰り返します。

自動化されたブラウザの動きって眺めているだけでわりと楽しいですよね。

まとめ

さて、以上でGebの紹介は終わりとなります。

今回の記事で、Gebによるブラウザ画面の自動化の雰囲気はわかってもらえたかと思います。

今回の紹介では、導入時点で必要な環境や起こりうる問題について大きく記事の文面を割いて説明しました。実際にこれらをちゃんと確認しないと動かないです。

今日、世の中では普通にたくさんのWebアプリケーションが動いていますが、手元で使っているそれは、マシン環境、ブラウザ環境、ネットワーク環境などのクライアント環境。ミドルウェア環境、フレームワーク、アプリケーション、ネットワークといったサーバ環境。それら全てが正常に連動して初めて動くものです。その構成要素それぞれが、ユーザや開発者の意図に関わらず不定期かつ頻繁に変更されます。

これで問題が発生しないほうがおかしいですね。

「なんかわからんけど突然動かなくなった」とか、「不具合を調べようとしたら何もしてないのに不具合が出なくなっていた」とかの現象に出会うのはもはやチャメシ・インシデント*10ですね。

そういう報告がくる前に問題をいち早く自分たちで発見できるように、インテグレーションテストやシステムテストを常に自動で動かせる状態にしておくことについて、意義は大きいといえるでしょう。

しかし、環境も含めて全てテストしろ、とかだと流石に暴論です。

今回はGebを紹介したわけですが、こいつで自動化していても全ての環境が試せるわけでもありませんし、そもそもこいつやSeleniumが不具合を抱えていたりもします。

とはいえ、どの辺まで頑張って、どの辺から諦めるのかってのを決めておくためには指標となるものがあったほうがいいでしょう。

こうしたらいいとかいう答えを私が持っているわけでも、気合をいれて探そうとしてるわけでもないですけど。

Gebに限らず結合した状態でのテストは実に複雑で、わがままで、突然動かなくなったりするので、マジでテスト項目から外したくなることも多いです。

  • Gebを使っていない環境をGebでテストすることはできません。
  • WebDriverが挟まるとブラウザの挙動が変になることがたまにあります。

とりあえず、こんな面倒ごとを粛々とやってくれる人がいたらいいなぁ、と思いながら、今日も先日から息をしていないGebテストを直しています。

では、本日は以上となります。

(訂正及び追記)

(追記:2016/08/03)

org.openqa.selenium.firefox.MarionetteDriverは非推奨(deprecated)になっているようです。

以下のようにmarionetteを有効にしたうえでFireFoxDriverを使用する方が推奨されています。

import org.openqa.selenium.remote.*
import org.openqa.selenium.firefox.FirefoxDriver

DesiredCapabilities capabilities = DesiredCapabilities.firefox()
capabilities.setCapability("marionette", true)

Browser.drive(new FirefoxDriver(capabilities)){
}

Copyright © astamuse company, ltd. all rights reserved.