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

RubyでHTTPクライアント→HTTPプロキシ→サーバの流れを実装してみる

Ruby

● HTTPクライアント (httpclient)
● HTTPプロキシ (webrick
● サーバ (CGI

を書いて、HTTP クライアントからプロキシ経由でリクエストを投げてサーバで受け取る、という一連の流れを実装してみようと思った時のメモ。
受け取り側のサーバだけ CGI を使っていて、 HTTP クライアントから POST で CGI サーバにファイルを飛ばしてみます。

HTTP クライアント

require 'httpclient'

proxy = 'http://127.0.0.1:8080'
client = HTTPClient.new(proxy)
boundary = 'boundary'
open('postfile.txt') do |file|
    postdata = {'file' => file}
    client.post_content(
        'http://192.168.99.99/cgi-bin/file.cgi', #ここに受け取り側サーバを指定
        postdata,
        'content-type' => 'multipart/form-data, boundary=#{boundary}'
    )
end

HTTP クライアントには、httpclient を使います。
このあたりの記事に Ruby の HTTP クライアントの比較について書かれています。

Ruby HTTPクライアントの比較表 - Qiita
Ruby の HTTP リクエストを送る方法の性能比較 - Qiita


HTTP プロキシ

require 'webrick'
require 'webrick/httpproxy'

class TestProxyServer < WEBrick::HTTPProxyServer
    def proxy_service(req, res)
        req.body =~ %r{filename=\"(.+?)\"} #受け取ったファイル名抽出
        puts $~[1]
        super #プロキシのメイン処理
    end
end

server = TestProxyServer.new(
    :BindAddress => '127.0.0.1',
    :Port => 8080,
    :Logger => nil,
)
Signal.trap('INT') do
    server.shutdown
end
server.start

プロキシには、webrick を使います。
今回はプロキシで処理したいことが特に思い浮かばなかったので、受け取ったファイル名を正規表現で抽出して出力しています。
ここには様々な処理を書くことが可能で、バナー広告等を除去するコンテンツフィルタを作ったり、開発用に特定の通信の接続先を書き換えたりする例を見つけたので載せておきます。

Rubyist Magazine - WEBrickでプロキシサーバを作って遊ぶ
開発用HTTPプロキシを簡単に立てる裏技 - Qiita


受け取り側サーバ

#! /usr/local/bin/ruby ここに ruby へのパス
require 'cgi'
cgi = CGI.new
cgi.out("type"=>"text/plain") {
  cgi["file"].read
}

こいつだけ CGI で書いています。 今回は Apache で動かしました。
ApacheCGI の連携は下の記事に簡潔に書いてあります。

Apache:CGIの利用設定をする - Qiita


おわり

あとは、HTTP プロキシと受け取り側サーバを起動して、HTTP クライアントを実行すれば、受け取り側サーバが送信ファイルの内容を出力するはずです。

【Twitter4J】特定のキーワードを呟いた人をフォローする的なやつ【Bot】

Java

※ 追記

こんな記事を書いておいてアレですが、どうやら Twitter では自動フォローは規約で禁止されているみたいです。
ここに書いてあるようなことを実行してバレると、アカウントを凍結されます。注意。

support.twitter.com

=========================

Twitter 上で行えることを自動化してみようと思ったときのメモ。
例として、標題の機能だけここに書いていきます。

TwitterAPI をそのまま使用するのはなにかと面倒なので、今回は Java でその辺をチョロチョロっと書けるようにしてくれる Twitter4J というライブラリを使います。

Twitter アカウントの用意

当然動作させるアカウントが必要です。 作ります。

アプリケーション登録

以下の記事に分かりやすく書いてあるので引用。

Twitter APIの使い方まとめ

一通り読み進めれば下の値が得られているはずなので、メモしておきます。(アプリケーション管理ページの [Keys and Access Tokens] というページに記載)

これらは TwitterAPI を活用する上で必要な OAuth という認証方式で使用する値ですが、説明すると長くなるので割愛します。

OAuthプロトコルの中身をざっくり解説してみるよ - ( ꒪⌓꒪) ゆるよろ日記

OAuth 2.0 の仕組みと認証方法 | murashun.jp

Twitter4J 用意

Twitter4J - A Java library for the Twitter API ここから jar ファイル群をダウンロードできるので、適当に配置した後プログラムから使用できるようにパスを通しておきます。

コード書く

package twitter;

import twitter4j.*;
import twitter4j.auth.*;
import java.util.*;

public class Test {
    final static Twitter twitter = TwitterFactory.getSingleton();

    public static void main(String[] args) {
        // OAuth 認証
        twitter.setOAuthConsumer("ここにConsumer Key (API Key)", "ここにConsumer Secret (API Secret)");
        AccessToken token = new AccessToken("ここにAccess Token", "ここにAccess Token Secret");
        twitter.setOAuthAccessToken(token);

        List<Status> tweets = search("キーワードです");
        for (Status tweet : tweets) {
            try{
                twitter.createFriendship(tweet.getUser().getId()); // フォロー
            }catch(Exception e){
                e.printStackTrace();
            }
        }
    }

    public static List<Status> search(String keyword) {
        Query query = new Query();
        query.setQuery(keyword);
        query.setCount(100); // 100 -> 1度のリクエストで取得するツイート数
        List<Status> tweets = new ArrayList<>();
        try {
            for (int i = 0 ; i < 15 ; i++) { // 15 -> 検索するページ数 , この場合だと100件×15ページで最大1500件取得
                QueryResult result = twitter.search(query);
                tweets.addAll(result.getTweets());
                if (result.hasNext()) {
                    query = result.nextQuery();
                } else {
                    break;
                }
            }
        } catch (TwitterException e) {
            e.printStackTrace();
        }

        return tweets;
    }
}

おわり

今回は特定のキーワードを呟いた人をフォローするだけでしたが、Twitter上で手動で出来ることならほぼTwitter4Jからでも可能かと思います。

DebianにNVIDIAのドライバを入れたかったけど躓いた


いくつか躓くポイントがあったのでメモ。
以下、手順を書いていきます。
 

ドライバのダウンロード

これが無ければ始まりません。
以下から自分のビデオカードに対応するドライバを適当な場所にダウンロードします。

NVIDIAドライバダウンロード

 

ドライバのインストール

sh NVIDIA-Linux-xx-xxx.xx.run (ここはダウンロードしてきたファイル名)

するとエラーが。

ERROR: You appear to be running an X server; please exit X before            
         installing.  For further details, please see the section INSTALLING   
         THE NVIDIA DRIVER in the README available on the Linux driver         
         download page at www.nvidia.com.

Xサーバーが動いているからインストールできない的なメッセージが表示されます。

まずは必要なものをインストール。

sudo aptitude install build-essential linux-source linux-headers-`uname -r` xserver-xorg-dev


そしてgdmを停止します。

sudo /etc/init.d/gdm3 stop

ここで画面が真っ暗になります。
その状態で、[Control]+[Alt]+[F2] でコンソール画面に切り替えます。

適当なユーザでログインして、先程のインストールコマンドを再度入力します。

sh NVIDIA-Linux-xx-xxx.xx.run

するとまたしてもエラーが。

ERROR: The Nouveau kernel driver is currently in use by your system.  This
         driver is incompatible with the NVIDIA driver, and must be disabled
         before proceeding.  Please consult the NVIDIA driver README and your
         Linux distribution's documentation for details on how to correctly
         disable the Nouveau kernel driver.

NVIDIAドライバと競合するような既存のドライバが入っているようです。
恐らくデフォでオープンソースのドライバが入ってしまっているので、削除します。
/etc/modprobe.d/nvidia-installer-disable-nouveau.conf を開いて、以下を記載します。

blacklist nouveau
options nouveau modeset=0


保存したら再起動します。

sudo reboot


三度目の正直。 まずは gdm を停止します。

sudo /etc/init.d/gdm3 stop


インストール。

sh NVIDIA-Linux-xx-xxx.xx.run


やっとエラーなくインストーラーに入ることが出来ました。
あとは適当にOKとか選択していけば完了です。

おわり。

AWSのAutoScalingの設定をしてみる

AWS

AWS の AutoScaling 機能を使う機会があったので、忘れないうちに設定方法を書いておきます。

AutoScaling とは

負荷に合わせて EC2 インスタンスの数を自動的に増減してくれる機能です。
増減はさせずに、インスタンスの数を常に一定にしておくためだけに利用したりもできます。
AutoScaling の利用には追加費用は発生しません。(当然ですが、AutoScaling によって自動的に追加された分のインスタンスの利用料はかかります。)
負荷に合わせて、と書きましたが、トリガーには CloudWatch のメトリクスなどが使用できます。

以下の記事に分かりやすく概要が書いてあります。
【AWS】Auto Scalingまとめ - Qiita

設定

今回はマネジメントコンソールを使ってGUIで設定をしていきます。
AutoScaling を利用するためには、[起動設定] と [AutoScalingGroup] を作成する必要がありあす。

[AutoScalingGroup] では、インスタンスを起動するAZやトリガーなどの設定します。
[起動設定] では、インスタンスサイズやセキュリティグループ等の、起動するインスタンスの情報を設定します。

起動設定の作成

AutoScalingGroup を作成する前に、あらかじめ起動設定を作成しておく必要があります。

だいぶ適当ですがこんな感じです。
これで起動設定の作成は終わりです。

AutoScalingGroup の作成

  • [EC2 Management Console] のサイドバーにある [AutoScalingグループ] → [AutoScalingグループの作成] をクリックします。

  • 先程作成した起動設定を選択します。

  • [AutoScalingグループの詳細設定] の [高度な詳細] をクリックすると色々出てくるので設定していきます。
    ELB 配下のインスタンスを AutoScaling 対象にする場合には [ロードバランシング] にチェックを入れて、使用する ELB を選択します。
    [ヘルスチェックのタイプ] を [ELB] に設定すると、「EC2 インスタンス自体は生きているけど ELB のヘルスチェックが通らない」状況の時などに異常ありと判断され、インスタンスの置き換えが行われるようになります。
    [ヘルスチェックの猶予期間] には、インスタンスの起動後、サービス開始できるようになるまでの時間(ビルドやデプロイ等に掛かる時間)以上の値を入力します。
    ELB ヘルスチェックを正しく返せない状態でこの AutoScaling ヘルスチェックが開始されてしまうと、起動と削除が繰り返されていつまでたってもサービスが提供できない状態に陥るので注意が必要です。(確か EC2 インスタンスは起動した時点で1時間分の課金が行われるので、起動と削除が繰り返されると厄介です。)

  • [スケーリングポリシーの設定] では、トリガーの設定等を行います。
    負荷に合わせてインスタンスの増減をする場合は、[スケーリングポリシーを使用して、このグループのキャパシティを調整する] を選択し、インスタンス数の範囲と、インスタンス増減のタイミングを指定します。
    [グループサイズの増加] と [グループサイズの減少] のそれぞれで [新しいアラームの追加] を行い、閾値を指定します。
    [ステップを追加] で、負荷の度合いに応じて増減させるインスタンス数に変化をつけることも可能です。
    [ウォームアップ] の秒数を指定すると、インスタンス増加のイベントが発生した後、指定した時間が経つまで次の増加イベントが発生しなくなります。 (デフォルトでは5分)

  • [通知の設定] では、AutoScaling で発生したイベントを指定したメールアドレス等に通知する設定ができます。
    通知が必要な場合は [通知の追加] をクリックします。

  • 最後に起動するインスタンスへのタグ付けをして終わりです。

おわり

他にも色々な設定が可能ですが、一応これだけで AutoScaling グループが起動しインスタンスが作成されます。

重複行をまとめた上でのカウントはGROUP BYではなくDISTINCTを使う

MySQL

"重複したカラムをまとめた上でのカウント" がしたい時に、いつもどおりGROUP BYを使ったらうまくカウントできませんでした。
そりゃそうだろ、という話ではありますが。
以下、MySQLを使っています。


例えば、ユーザが何かしらの投稿をするサイトがあって、以下の様なデータベースが存在する場合を考えます。 f:id:norikone:20160409030058p:plain f:id:norikone:20160409030523p:plain f:id:norikone:20160409030520p:plain


ここで、1回でも投稿したことがあるユーザの数を求めたいとします。
この場合だと、投稿したことがあるのは ichirou と jirou の2人です。

直観的に posts テーブルを GROUP BY user_id でまとめてカウントしても意図した結果は返ってきません。
f:id:norikone:20160409034804p:plain ここに表示されている行数が返ってきて欲しいのです。


このような場合は、GROUP BY ではなく DISTINCT でまとめると上手くいきます。
f:id:norikone:20160409035448p:plain


DISTINCT でまとめれば上手くいきますが、場合によってはクエリ最適化が働いて、以下のように GROUP BY したものをカウントしたほうが早くなることがあるようです。 f:id:norikone:20160409040820p:plain



おわり。

Slack上で日程調整するアプリをGASで書いてみる

GAS JavaScript Slack

最近 GAS(Google Apps Script) や SlackBot の存在を知って、慣れていけばいろいろ捗りそうな気がしたので触ってみた時のメモ。

今は特に GAS で自動化したいようなものは無かったので、SlackBot と連携して簡易的な日程調整、出席管理アプリを作って遊んでみました。
JavaScript 自体も全然触ったことがなかったので、書いてみて少しは勉強になったかなと思います。
尚、実用性は皆無だと思われ(ry。

簡単にアプリの紹介

Slack 上で日程調整ができるアプリです。
特定のチャンネルから特定のワードを付けてメッセージを投稿すると、Google Spread Sheet と連携して出席状況の管理などを行ってくれます。

イベントの登録

新しくイベント(予定)を追加するには以下の形式でメッセージを投稿します。

新: [イベント名] [日付(M/d)] ... [日付(M/d)]

こんな感じ。

f:id:norikone:20160219182520p:plain:h95

そうすると、GAS 側で指定したフォルダに、イベント名の Spread Sheet を作成してくれます。

f:id:norikone:20160219182749p:plain:h120

参加可能日の提出

参加可能日を提出する方法は以下の4種類です。

出: [イベント名]                              #全ての日OK
出: [イベント名] [日付(M/d)] ... [日付(M/d)]   #指定した日だけOK
欠: [イベント名]                              #全ての日NG
欠: [イベント名] [日付(M/d)] ... [日付(M/d)]   #指定した日だけNG

こんな感じ。

f:id:norikone:20160219182548p:plain:h95

Spread Sheet にフラグが入ります。

f:id:norikone:20160219182938p:plain:h120

参加状況の確認

参加状況の確認方法は以下の2種類です。

確認: [イベント名]   #人数だけ表示
詳細: [イベント名]   #ユーザ名も表示

こんな感じ。

f:id:norikone:20160219183951p:plain:w350

リマインド

以下の入力で、未提出のイベントがあるユーザに DM でリマインドができます。

リマインド:

こんな感じ。

f:id:norikone:20160219184942p:plain:h80

また、GAS のトリガーとしてリマインドする関数を指定すれば、一定時間ごとにリマインドしてくれるようになります。
GAS プロジェクトを開いて、

[リソース] -> [現在のプロジェクトのトリガー] -> [トリガー追加リンクをクリック]

表示されたダイアログの [実行] に [remind()] を指定して、[イベント] の項目に任意のリマインドタイミングを指定します。

備忘録とかコードとか

以下、今回行った流れ。

GAS プロジェクトを用意

Google Drive の適当な場所に新規 GAS プロジェクトを作成します。

[Google Drive] -> [新規] -> [その他] -> [Google Apps Script]

SlackApp ライブラリ導入

Slack Bot を GAS で簡単に書くためのライブラリを導入します。
下の記事で紹介されています。

qiita.com

Slack API Token の取得

APIへアクセスするためのトークンを取得します。 下のリンクを開いて表示されるページの下の方に、自分が所属しているSlackチームやユーザ名、トークンが表示されています。
まだトークンを生成していない場合は、Slack Bot を作成するチームのトークンを [Create token] で生成します。

Slack Web API | Slack

Slack API Token を GAS プロジェクトに保存

上で取得したトークンを、先程作成したGAS プロジェクトのプロパティに格納します。
GAS プロジェクトを開いて、

[ファイル] -> [プロジェクトのプロパティ] -> [スクリプトのプロパティ]

に、今回は "token" というプロパティ名で登録しました。

Slack Outgoing WebHooks 設定

Slack の特定のチャンネルに特定の投稿があった際に、Slack から GAS へリクエストを渡してあげる設定です。
Trigger Word と呼ばれる項目に設定した値で始まる文字列の投稿があった際に、投稿データが指定した URL に POST されるようになります。

App Directory | Slack

上記 URL の検索窓に「outgoing」とか入力すると [Outgoing WebHooks] のメニューが出てくると思います。
クリックするとページの下の方に、所属している Slack チームが表示されるので 、ここから Slack Bot を作成するチームの Outgoing WebHooks を設定していきます。

[Install] -> [Add Outgoing WebHooks Integration]

で設定画面にたどり着きます。

「Integration Settings」の以下の項目を設定します。

  • Channel
    ここで指定したチャンネルに Trigger Word で始まる投稿があると、指定した URL にデータを POST するようになります。
    適当なチャンネルを指定します。
  • Trigger Word
    ここで指定した文字列で始まる投稿が指定した URL に POST されるようになります。
    今回作成したアプリでは Trigger Word として以下の6つを設定します。
    新:,出:,欠:,確認:,詳細:,リマインド:
  • URL
    上で「指定したURL」と書いていた部分がここです。
    今回は、後述する GAS アプリの URL をここに入力します。
  • Token
    Slack から GAS にデータが POST される際に、ここに記載されているトークンが付加されます。
    不正なアクセスでないかどうかを確かめるために GAS 側でこのトークンをチェックします。
    今回は、ここに記載されている値を "verifyToken" というプロパティ名で、先程 "token" を設定した時と同じように、スクリプトのプロパティに保存しました。

設定が完了したら [Save Setting] で保存します。

コードを書く

あとは実際に GAS でコードを書いていきます。
GAS では、POST リクエストがあった場合 doPost() が呼ばれるようになっています。
今回は、Outgoing Web Hooks によって Slack への投稿データが doPost() に入ってきます。
doPost(request) のように、第一引数を指定してあげれば投稿データを受け取る事ができます。

以下、コードをまとめてそのままここに掲載しておきます。
色々試しながらやっていたので冗長コードが入ってたりします(放置)。
1行目の FOLDER_ID には、このアプリで使用するシートを保存しておく Google Drive フォルダの ID を指定します。
フォルダ ID は、ブラウザで該当フォルダを開いた状態の時にアドレスバーで確認することも可能です。

var FOLDER_ID = "XXXXXXXXXXXXXXXXXXXXXXXXXXX";
var DATE_REGEXP = "^[0-1]?[0-9]\.[0-3]?[0-9]$";
var DATE_FORMAT = "M/d";
var MENTION = "@";
var UNIT = "名";
var START_ROW = 1;
var START_COL = 1;
var ATTEND_FLAG = 1;
var ABSENT_FLAG = 0;
var COL_IS_DATE = true;

var prop =  PropertiesService.getScriptProperties().getProperties();
var slackApp = SlackApp.create(prop.token);

function doPost(request) {

  if (prop.verifyToken != request.parameter.token) {
    throw new Error("invalid token.");
  }
  
  var queryTokens = request.parameter.text.split(":")[1].trim().replace(/ /g, " ").split(" ");
  queryTokens = removeOnlySpaceToken(queryTokens);
  try{
    switch(request.parameter.trigger_word){
      case "新:":
        tokenValidation(queryTokens,DATE_REGEXP,2)
        register(queryTokens);
        response(request.parameter.channel_id,"新しいイベントを追加しました",":new:");
        break;
      case "出:":
        tokenValidation(queryTokens,DATE_REGEXP,1)
        submit(queryTokens,true,request.parameter.user_name);
        response(request.parameter.channel_id,"出席登録が完了しました",":ok:");
        break;
      case "欠:":
        tokenValidation(queryTokens,DATE_REGEXP,1)
        submit(queryTokens,false,request.parameter.user_name);
        response(request.parameter.channel_id,"出席登録が完了しました",":ok:");
        break;
      case "確認:":
        show(queryTokens,false,request);
        break;
      case "詳細:":
        show(queryTokens,true,request);
        break;
      case "リマインド:":
        remind();
        response(request.parameter.channel_id,"リマインドが完了しました",":information_source:");
        break;
    }
  }catch(e){
    response(request.parameter.channel_id,e,":ng:");
  }
  
  return null;
}

function register(queryTokens){  
  var folder = DriveApp.getFolderById(FOLDER_ID); 
  var fileId = createSheet(folder,queryTokens[0]);
  var spreadSheet = SpreadsheetApp.openById(fileId);
  var sheet = spreadSheet.getSheets()[0];
  var userList = slackApp.usersList()["members"];
  
  userList.some(function(v, i){
    if (v.name=="slackbot") userList.splice(i,1);    
  });
  
  queryTokens.shift(); 
  var options = {
    rowHeader: userList,
    rowMember: "name",
    colHeader: queryTokens,
  };
  setHeaders(sheet,options);
  
  return null;
}

function submit(queryTokens,isAttend,userName){
  var sheet = getAttendanceSheet(queryTokens[0]);
  var data = sheet.getDataRange().getValues();
  var position = getPosition(sheet,userName);
  if(!position){
    throw new Error("ユーザが存在しません、名前が変更されている可能性があります");
  }
  
  queryTokens.shift(); 
  setValues(sheet,queryTokens,position,isAttend);
  
  return null;
}

function show(queryTokens,detail,request){
  var sheet = getAttendanceSheet(queryTokens[0]);
  var data = sheet.getDataRange().getValues();
  
  var sb = new StringBuffer("===============================");
  sb.append("\n【イベント名】 ").append(queryTokens[0]);
  var state = createSubmitStateData(sheet);
  sb.append("\n【提出者】   ").append(state.submitted.length).append(UNIT);
  sb.append("\n【未提出者】  ").append(state.unsubmitted.length).append(UNIT).append(" ");
  if(detail){
    for(var i=0 ; i<state.unsubmitted.length ; i++){
      sb.append(" ").append(MENTION).append(state.unsubmitted[i]);
    }
  }
  sb.append("\n===============================");
  
  for(date in state.dateState){
    sb.append("\n【").append(date).append("】").append(" ").append(state.dateState[date].length).append(UNIT).append(" ");
    if(detail){
      for(var j=0; j<state.dateState[date].length ; j++){
        sb.append(" ").append(MENTION).append(state.dateState[date][j]);
      }
    }
    sb.append("\n- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -");
  }
  
  response(request.parameter.channel_id,sb.toString(),":information_source:");
  
  return null;
}

function createSubmitStateData(sheet){
  var edge = sheet.getRange(START_ROW,START_COL).getValue().split(",");
  var rows = Number(edge[0]);
  var cols = Number(edge[1]);
  
  var submittedUsers = [];
  var unsubmittedUsers = [];
  var dateState = {};
  
  if(COL_IS_DATE){
    for(var i=0 ; i<rows ; i++){
      var filled = sheet.getRange(START_ROW+i+1,START_COL+cols+1).getValue() == cols;
      var user = sheet.getRange(START_ROW+i+1,START_COL).getValue();
      
      if(filled){
        submittedUsers.push(user);
      }else{
        unsubmittedUsers.push(user);
      }
      
      for(var j=0 ; j<cols ; j++){
        var date = Utilities.formatDate(sheet.getRange(START_ROW,START_COL+j+1).getValue(),"JST",DATE_FORMAT);
        if(!dateState[date]){
          dateState[date] = [];
        }
        var attendFlag = sheet.getRange(START_ROW+i+1,START_COL+j+1).getValue();
        if(attendFlag){
          dateState[date].push(user);
        }
      }
    }
  }else{
    for(var i=0 ; i<cols ; i++){
      var filled = sheet.getRange(START_ROW+rows+1,START_COL+1+i).getValue() == rows;
      if(filled){
        submittedUsers.push(user);
      }else{
        unsubmittedUsers.push(user);
      }
    }
  }
  
  return {submitted:submittedUsers, unsubmitted:unsubmittedUsers, dateState:dateState};
}

function getPosition(sheet,target){
  var data = sheet.getDataRange().getValues();
  for(var i=0 ; i<data.length ; i++){
    for(var j=0 ; j<data[i].length ; j++){
      if(data[i][j] == target){
        return {row:i+1 ,col:j+1};
      }
    }
  }
  
  return null;
}

function setHeaders(sheet,options){
  var rowHeader = options.rowHeader || [1];
  var rowMember = options.rowMember || null;
  var colHeader = options.colHeader || [1];
  var colMember = options.colMember || null;
  var bgcolor = options.bgcolor || "#f5deb3";
  
  sheet.getRange(START_ROW,START_COL)
  .setValue(rowHeader.length + "," + colHeader.length);
  
  for(var i=0 ; i<rowHeader.length; i++){
    sheet.getRange(START_ROW+i+1,START_COL)
    .setValue(rowMember ? rowHeader[i][rowMember] : rowHeader[i])
    .setBackgroundColor(bgcolor);
    
    sheet.getRange(START_ROW+i+1,START_COL+colHeader.length+1)
    .setFormulaR1C1("=COUNT(R[0]C[-"+colHeader.length+"]:R[0]C[-1])");
  }

  for(var i=0 ; i<colHeader.length; i++){
    sheet.getRange(START_ROW,START_COL+i+1)
    .setValue(colMember ? colHeader[i][colMember] : colHeader[i])
    .setBackgroundColor(bgcolor);
    
    sheet.getRange(START_ROW+rowHeader.length+1,START_COL+i+1)
    .setFormulaR1C1("=SUM(R[-"+rowHeader.length+"]C[0]:R[-1]C[0])");
  }
  
  return null;
}

function setValues(sheet,tokens,position,isAttend){
  var edge = sheet.getRange(START_ROW,START_COL).getValue().split(",");
  var rows = Number(edge[0]);
  var cols = Number(edge[1]);
  
  if(COL_IS_DATE){
    for(var i=0 ; i<cols ; i++){
      var date = Utilities.formatDate(sheet.getRange(START_ROW,START_COL+1+i).getValue(), "JST", DATE_FORMAT);    
      if(tokens.indexOf(date) >= 0 || tokens.length == 0){
        sheet.getRange(position.row,position.col+1+i).setValue(isAttend ? ATTEND_FLAG : ABSENT_FLAG);
      }else{
        sheet.getRange(position.row,position.col+1+i).setValue(isAttend ? ABSENT_FLAG : ATTEND_FLAG);
      }
    }
  }else{
    for(var i=0 ; i<rows ; i++){
      var date = Utilities.formatDate(sheet.getRange(START_ROW+1+i,START_COL).getValue(), "JST", DATE_FORMAT);    
      if(tokens.indexOf(date) >= 0 || tokens.length == 0){
        sheet.getRange(position.row+1+i,position.col).setValue(isAttend ? ATTEND_FLAG : ABSENT_FLAG);
      }else{
        sheet.getRange(position.row+1+i,position.col).setValue(isAttend ? ABSENT_FLAG : ATTEND_FLAG);
      }
    }
  }
  
  return null;
}

function getAttendanceSheet(sheetName){
  var folder = DriveApp.getFolderById(FOLDER_ID);
  var files = folder.getFilesByName(sheetName);
  if(!files.hasNext()){
    throw new Error("入力されたイベントは存在しません");
  }
  var spreadSheet = SpreadsheetApp.open(files.next());
  var sheet = spreadSheet.getSheets()[0];
  
  return sheet;
}

function createSheet(targetFolder,sheetName){
  var files = targetFolder.getFiles();
  while (files.hasNext()) {
    var file = files.next();
    if(file.getName() == sheetName){
      throw new Error("そのイベント名は既に存在しています");
    }
  }
  
  var fileId = SpreadsheetApp.create(sheetName).getId();
  var file = DriveApp.getFileById(fileId);
  targetFolder.addFile(file);
  DriveApp.getRootFolder().removeFile(file);
  
  return fileId;
}

function remind(){
  var folder = DriveApp.getFolderById(FOLDER_ID);
  var files = folder.getFiles();
  
  while (files.hasNext()) {
    var file = files.next();
    var spreadSheet = SpreadsheetApp.open(file);
    var sheet = spreadSheet.getSheets()[0];
    
    var edge = sheet.getRange(START_ROW,START_COL).getValue().split(",");
    var rows = Number(edge[0]);
    var cols = Number(edge[1]);
    
    if(COL_IS_DATE){
      for(var i=0 ; i<rows ; i++){
        var filled = sheet.getRange(START_ROW+i+1,START_COL+cols+1).getValue() == cols;
        var user = sheet.getRange(START_ROW+i+1,START_COL).getValue();
        if(!filled){
          var sb = new StringBuffer("以下のイベントへの出席登録が完了していません!");
          sb.append("\n【イベント名】  ").append(file.getName());
          response(MENTION+user,sb.toString(),":warning:");
        }
      }
    }else{
      for(var i=0 ; i<cols ; i++){
        var filled = sheet.getRange(START_ROW+rows+1,START_COL+i+1).getValue() == rows;
        var user = sheet.getRange(START_ROW,START_COL+i+1).getValue();
        if(!filled){
          var sb = new StringBuffer("以下のイベントへの出席登録が完了していません!");
          sb.append("\n【イベント名】  ").append(file.getName());
          response(MENTION+user,sb.toString(),":warning:");
        }
      }
    }
  }
  
  return null;
}

function response(target,text,icon){
  slackApp.chatPostMessage(target, text, {
    username : "出席管理人",
    icon_emoji : icon 
  });
  
  return null;
}

function removeOnlySpaceToken(tokens){
  var result = [];
  for(var i=0 ; i<tokens.length ; i++){
    if(tokens[i].match(/\S/g)){
      result.push(tokens[i]);
    }
  }
  
  return result;
}

function tokenValidation(tokens,regexp,minLength){
  if(tokens.length < minLength){
    throw new Error("フォーマットが正しくありません");
  }
  for(var i=1 ; i<tokens.length ; i++){
    if(!tokens[i].match(regexp)){
      throw new Error("フォーマットが正しくありません");
    }
  }
  
  return false;
}

var StringBuffer = function(string) {
    this.buffer = [];

    this.append = function(string) {
        this.buffer.push(string);
        return this;
    };

    this.toString = function() {
        return this.buffer.join('');
    };

    if (string) {
        this.append(string);
    }
};

アプリを公開

GAS プロジェクトを開き、

[公開] -> [ウェブアプリケーションとして導入] -> [アプリケーションにアクセスできるユーザー] -> [全員(匿名ユーザーを含む)]
に設定して、[導入] をクリックします。

すると、ダイアログが表示されるので、[現在のウェブアプリケーションのURL] という欄に記載されている URL を、Outgoing WebHooks 設定ページの URL 欄にコピペします。

これで動くようになりました。

おわり

ということで、GAS を使って簡単なアプリを作成してみました。
仕様もコードも荒削りではありますが、飽きたので 一応動くものが出来たので、この辺で手を止めておきます。

【Elasticache】ELB配下の複数インスタンス間でのセッション管理【Laravel】

Laravel AWS KVS

AWS の ELB に複数のインスタンスをぶら下げて負荷分散をしようと思った際に、Laravel アプリのセッション管理について考えたメモです。

ユーザにログインさせる必要があるアプリなどは、ELB によって接続先インスタンスを振り分けられたとしても、そのセッションを維持することが必要になってきます。
単に ELB にインスタンスを沢山ぶら下げるだけだと、デフォルトでは1クライアントからの各リクエストが別々のインスタンスに割り振られてしまいます。
つまり、インスタンス間でセッションを共有できていないので「さっきログインしたはずなのにまたログインしろ画面が出てきたんだけど」ということになってしまいます。

この記事では、この問題を解決する方法の紹介と、そのうちの一つを Laravel で設定してみます。

解決策

先に書いた問題を解決するための方法をいくつか簡単に紹介します。

スティッキーセッションを使う

ELB には、クライアントとリクエスト先のインスタンスを固定するスティッキーセッションという機能が備わっています。
この機能を使用した場合、一度クライアントがインスタンスに接続すると有効期限が切れるまで同じインスタンスに接続され続けます。
これによって、インスタンス間でセッション情報を共有する必要もなく問題を解決できます。
ただ、接続先のインスタンスがダウンした場合などは、やむを得ず他のインスタンスに接続する必要があります。
この場合、新しく接続されたインスタンスは新しく接続してきたクライアントのセッション情報を保持していないことに注意が必要です。
f:id:norikone:20160208023613p:plain:h300

セッション情報をDBで管理する

セッションをDBで管理することによってWebサーバ間で情報を共有できるので、スティッキーセッションを使用した際に注意しなければならない問題は発生しません。
しかし、DBで管理する際にはセッション情報の更新や削除処理が頻繁に行われるようになり、パフォーマンスの低下に繋がる可能性があります。
f:id:norikone:20160208023611p:plain:h300

セッション情報をKVSで管理する

memcached や Redis などの KVS を使用することで、DB で管理した場合に必要となるセッション破棄のための削除処理の実装が要らなくなります。
また、DB を使用する際に発生するディスクI/Oによる影響を減らすことにも期待ができます。
セッション情報は多くの場合で永続化する必要のないデータなので、揮発性が高くてもそこまで問題になりません。

f:id:norikone:20160208023612p:plain:h300

ということで、単純なセッション管理には KVS が向いていると判断したので、Laravel から KVS を使用してセッション管理をする設定をしてみます。

Laravel で Elasticache を使ってセッション管理をしてみる

セッション管理の KVS にElasticache 上の Redis を使います。
ここでは memcached か Redis かの検討については書きません。

まずは、Elasticache で Redis クラスタを作成します。
作成されると、エンドポイントを確認することができるので、それを控えておきます。

次に、Laravel 側の設定です。
config/session.php で、セッションドライバに Redis を使用するよう指定します。

'driver' => 'redis',

config/database.php に、接続するクラスタの情報を記述します。

'redis' => [
        'cluster' => false,
        'default' => [
            'host'     => 'hoge-cluster.XXXX.XXXX.XXXX.cache.amazonaws.com',
            'port'     => 6379,
            'database' => 0,
        ],
],

host には先程控えたエンドポイントを指定します。

設定はこれだけです。
実際にセッションが Redis に格納されるようになっているか確認していきます。
あらかじめ Laravel アプリにアクセスしてセッションを作成しておきましょう。

  • 接続用クライアントツールインストール
sudo wget http://download.redis.io/redis-stable.tar.gz
sudo tar xvzf redis-stable.tar.gz
cd redis-stable
sudo make
  • 接続
src/redis-cli -h hoge-cluster.XXXX.XXXX.XXXX.cache.amazonaws.com' -p 6379
  • 既存のキーを確認
keys *

ここで、"laravel:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" のように表示されていればセッションが格納できています。

  • 切断
quit

おわり

Elasticache(Redis) を使用してセッション管理をしてみました。
今のままだと、Redis が単一障害点となってしまっているのでよろしくありませんが、Elasticache の設定でレプリケーションをすることでこれを回避できます。
memcahced の場合はレプリケーションは行えません。
Redis とは冗長化の方式が異なるので、興味のある方はそちらも調べてみるといいかもしれません。