API Key認証付きのAPI GatewayからS3へリダイレクトしたい(できない)

こんにちは、AWS担当のwakです。

概要

表題の通りです。できない理由を書きます。また、ついでにSame-Origin PolicyとCORS (Cross-Origin Resource Sharing)についても簡単に解説します。

f:id:nurenezumi:20170310124638j:plain

猫もおてあげ

やりたかったこと

とあるデータ(刻々と追加されていきます。更新はなし)をDynamoDBに溜め込んでいます。そのデータを検索して返すWeb APIAPI Gateway + Lambdaで実装しました。

ところで、これらのデータは1日単位でS3にJSON形式で書き出してアーカイブとして保存してあります。もし検索条件が日付だけなら、わざわざDynamoDBを見るまでのこともありません。「S3のこのURLを見てね」と言えばいいだけです。つまり、こういうことができると都合がいいわけです。

  1. API Gateway経由でLambdaを起動する
  2. 条件が複雑な場合は普通にDynamoDBを検索して返す
  3. 条件が日付だけの場合はHTTP 303 See OtherでS3にリダイレクトする

3番目は無理でした、というのが今回の記事の趣旨になります。以下、その説明をします。

Same-Origin Policy

ブラウザには、RFC 6454で定められたSame-Origin Policy(同一オリジンポリシー、同一生成源ポリシー)というルールがあります。ここでは「オリジン」が何かはちょっと後回しにして、次のようなシナリオを考えてみます。

  • mycompany.local という社内Webサーバーがあるとします。このWebサーバーは外部には公開されていません
  • 私はここなら安心だと思って、秘密のデータを http://mycompany.local/himitsu.json として置いておきます。
  • 悪い人がいて、どこからかこのURLを入手したとします。
  • 悪い人は http://evil.com/dorobou.html というWebページをインターネット上に作り、何らかの方法*1で私にアクセスさせます。
  • このWebページにはJavaScriptが書いてあります。スクリプトはWebページを開くと同時に勝手に上記のURLにXHRでアクセスし、取得したデータを全て外部に送信してしまいます。
  • 私は死にます。
f:id:nurenezumi:20170310123858p:plain

このようなことではいけません(私は死にたくありません)。もちろんこの秘密のデータを他のWebサイトから参照したいような状況もありますが、少なくとも全ての外部Webサイトのスクリプトからアクセスできる必要はまったくありません。そこで、スクリプトは原則として「同一のオリジン(場所)」にあるリソースにしかアクセスできないというルールができました。これが同一オリジンポリシーです。

オリジンとは

オリジンという言葉が突然出てきましたが、これは大ざっぱに言えばドメインのことだと理解すればいいです。厳密にはRFC 6454で定義されていて、

  • HTTP/HTTPSの区分
  • FQDNexample.comとか、www.example.comとか)
  • ポート番号

をセットにしたものです*2。つまりhttp://evil.com上にあるスクリプトは、次のような場所にあるリソースにはアクセスできないわけです。

  • http://mycompany.localドメインが全然違う)
  • https://evil.com(同じに見えるけどHTTPとHTTPSが違う)
  • http://evil.com:8080(ポート番号が80番と8080番で違う)
  • http://www.evil.com(これも別扱い)

これだけ厳密なチェックが入るなら安心ですね。めでたしめでたし。

CORS (Cross-Origin Resource Sharing)

ところが、異なるオリジンをまたいで外部リソースを参照することが一切許されないのでは不都合なことも多々出てきます。そこで、リソースが置いてある側、つまりmycompany.local側で事前に設定を行い、どのようなタイプのリクエストなら許可するかをホワイトリスト形式で指定することになりました。これがCORS (Cross-Origin Resource Sharing)です。

具体的には、ブラウザからのリクエストに応じ、リソースを置いてあるサーバーがHTTPヘッダで「どこからのアクセスなら許可するか」「リクエスト時に含まれていても良いHTTPヘッダ」のような項目を回答します。ブラウザはこの値をチェックし、リクエストの内容がそれとマッチするときにだけアクセスを許可します(そうでないならアクセスをブロックしてネットワークエラーを発生させます)。この制御により、リソースの所有者の意図、ブラウザの利用者の意図に反してアクセスが行われてしまうことを防ぐわけです。

余談:CORSは何でないか

CROSはあくまでブラウザが(いわば)自主規制をするだけのもので、ユーザーが意図的に発生させたリクエストをブロックするようなものではありません。悪意を持って作成されたスクリプトを実行してしまっても、リソースの所有者やユーザーが意図しないうちに勝手にリクエストが発生してしまう(発生させられてしまう)ことを防ぐための仕組みです。セキュリティについてはサーバーサイドできちんと考える必要があります。

2種類のクロスオリジンリクエス

このように、アクセス元のオリジン(上記の例ですとhttp://evil.com)、リソースのあるオリジン(同じくhttp://mycompany.local)とが異なる場合のアクセスはクロスオリジンリクエスと呼ばれます。ブラウザはこのリクエストを2種類に分けて扱います。

シンプルなクロスオリジンリクエス

シンプルなクロスオリジンリクエスト(Simple cross-origin request)とは、特定のHTTPヘッダのみ・特定のContent-Typeの値のみを持つGET/POST/HEADリクエストです(詳しくはリンク先を読んでください)。jQueryでこのようなコードを書いた場合がこれに該当します*3

$.ajax({
    url: "https://...",
    type: "GET",
    headers: {}
}).done(function(data) {
    console.log(data);
});

スクリプトが「シンプルなクロスオリジンリクエスト」を送信した場合、ブラウザはまずレスポンスヘッダの中からAccess-Control-Allow-Originヘッダの値を確認します。その値が元のオリジンと一致すれば(*https://*.example.comなどのワイルドカードも使用可)そのアクセスは認められることになります。逆に一致しないか、そもそもAccess-Control-Allow-Originヘッダがなかった場合、そのアクセスはブロックされてしまいます。

シンプルではないリクエス

前項のシンプルなクロスオリジンリクエストではないリクエストは全てこれです。認証のためにAuthorizationヘッダやX-Api-Keyヘッダを付加する場合、PATCHリクエストやDELETEリクエストを送信する場合などが該当します。たとえばBASIC認証が必要なURLにアクセスするため、jQueryで次のようなコードを書いたとします。

$.ajax({
    url: "https://...",
    type: "GET",
    headers: {"Authorization": "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="}
}).done(function(data) {
    console.log(data);
});

この場合、ブラウザはGETリクエストに先立ち、同じURLに対して自動的にOPTIONSリクエストを送信します。これはプリフライト(preflight)と呼ばれ、スクリプト側からは制御できず強制的に行われます。クロスオリジンリクエストを受け付けたいWebサーバーは、このOPTIONSリクエストに対して「どのようなリクエストなら受け付けても構わないか」を返答しなければなりません。返答は全てレスポンスヘッダで行います*4

API Gateway

以下、API GatwayからLambda (Node.js)のコードを呼び出し、そのまま結果を返すか、またはS3へリダイレクトすることを考えます。また、API Gateway側は「Enable CORS」の設定を済ませていて、さらにLambda Proxy integrationを使うことを前提にします。リクエストはGETを利用します。

f:id:nurenezumi:20170224071730p:plain

A. シンプルなクロスオリジンリクエスト&リダイレクトを行わない場合

一番単純なパターンです。ブラウザが前述の「シンプルなクロスオリジンリクエスト」を送信してきて、かつリダイレクトを行わない場合です。Lambda側の処理はこうなります。

callback(null, {
    statusCode: 200,
    body: JSON.stringify({message: "nya-!"}),
    headers: {
        "Access-Control-Allow-Origin": "http://example.com",
        "Content-Type": "application/json; charset=utf-8"
    }
});

ブラウザはここでセットしたAccess-Control-Allow-Originヘッダの値を確認し、アクセス元のオリジンと一致するかどうかを調べて、問題がなければアクセスを許可します。API Gateway側のCORS設定は使用されません。

B. シンプルではないクロスオリジンリクエスト&リダイレクトを行わない場合

最初にブラウザがプリフライトリクエストを送ってきますが、これにはAPI Gatewayが勝手に応答します*5。レスポンスヘッダにはAccess-Control-Allow-Origin: *という内容が含まれていて*6、ブラウザは最初のチェックを行います。

このチェックをパスしたら、ブラウザは改めてGETリクエストを送りますが、後の処理は前項(A)と同じです。再度Access-Control-Allow-Originのチェックがかかるところも変わりません。

C. シンプルなクロスオリジンリクエスト&リダイレクトを行う場合

可能ですが制約が厳しくなります。

callback(null, {
    statusCode: 303, // See Other
    body: "",
    headers: {
        "Access-Control-Allow-Origin": "http://example.com",
        Location: `https://s3-ap-northeast-1.amazonaws.com/BUCKET-NAME/path/name.json`
    }
});

最初のアクセスに対してAPI Gatwayは303を返します。ブラウザはAccess-Control-Allow-Originをチェックした上でリダイレクト、つまり指定されたS3へ再度GETを送ります。

ところがこの2回目のGETでは、ブラウザはオリジンとしてnullを使用します*7。この挙動はRFCで規定されています*8。したがって、リダイレクト先のS3側ではAccess-Control-Allow-Origin: *を返す必要があります。CORSでのアクセス制御がかけられないことを意味していて、重要なデータを扱うには非現実的になります。

cors - Are there any browsers that set the origin header to "null" for privacy-sensitive contexts? - Stack Overflow

D. シンプルではないクロスオリジンリクエスト&リダイレクトを行う場合

ダメです。

Chrome cancels CORS XHR upon HTTP 302 redirect - Stack Overflow

こちらで紹介されているW3C Recommendationにはこのような記述があります。

If the response has an HTTP status code of 301, 302, 303, 307, or 308 Apply the cache and network error steps.

つまり、このような処理が行われます。

  1. 最初にブラウザはプリフライトリクエストとしてOPTIONSリクエストを送信します。
  2. API GatewayはHTTP 200 OKを返します。レスポンスにはAccess-Control-Allow-Originも含まれます。
  3. 次にブラウザはGETリクエストを送信します。
  4. API GatewayはLambdaを実行し、上記のコードに従ってHTTP 303 See Otherを返します。
  5. ブラウザは無条件で通信をキャンセルします。

API GatewayAPI Key認証を行うためにはリクエスト時にX-Api-Keyヘッダを含める必要があります。このヘッダを含めた場合、リクエストは「シンプルなクロスオリジンリクエスト」ではなくなってしまいます。するとCではなく)のパターンになり、ブラウザは仕様上通信を行うことができません。

結論

お疲れ様でした。

*1:同僚のSlackのアカウントを乗っ取って「これを見てください」と言ってURLを送るとか

*2:データURIなどについても記述がありますが省略します

*3:本当は jquery.get() で書けますが比較のために jquery.ajax() で書いています

*4:OPTIONSリクエストですからボディ部はありません

*5:「Enable CORS」の設定を行ったときに追加されたOPTIONSメソッドの設定に従ってレスポンスが送信されます

*6:"*"はデフォルト値です。もちろん変更も可能です

*7:Chrome 56.0.2924.87, Firefox 51.0.1で確認

*8:ただし「リダイレクトのときはこうなる」と明示されているわけではなく、ブラウザの実装に依存します

Slackにスレッド表示機能が追加されました

こんにちは、Slack大好きなwakです。Slackにスレッド表示機能が追加されたと聞いてさっそく使ってみた雑感を書きます。

概要

これまでと画面が大きく変わるわけではありません。

  1. スレッドの始まりとなる発言(誰のものでもOK)を選択する
  2. コメント1を追記する(この時点でスレッド化される)と、画面右にスレッド画面が表示される
  3. コメント2, コメント3, ……を書き込んでいく
  4. 元の発言の下に「1 reply」「2 replies」「3 replies」とカウンタが増えていく。書き込んだ人のアイコンも表示される
f:id:nurenezumi:20170120200154p:plain

スレッドに参加する方法

そのスレッドに参加していれば、新しく増えた「All Threads」に通知が来ます。参加するためにはいくつかの方法があります。

  • スレッドを作った人は自動的にそのスレッドに参加する
  • スレッドの開始点となった発言の発言主も自動的にそのスレッドに参加する
  • それ以外の人は、自分で明示的に「follow this thread」をクリックすればスレッドに参加できる
  • スレッドの中でメンションを飛ばされた人も自動的にそのスレッドに参加する
    • ただし @channel@here は使えない

たとえば雑談チャンネルで【今日のお昼はどこで食べたいか】を話したいときは、

  1. 「お昼どこにする? このスレッドで決めましょう」と書き、
  2. その発言を開始点としてスレッドを作って、
  3. その中で参加者にメンションを飛ばす

とすればいいことになります。

ピン留めされたスニペットとの違い

弊社ではゴルフ場様向けに様々なサービスを開発・提供しており、お客様からのお問い合わせも数多くいただきます。そのインシデント管理はSalesforceを活用しているのですが、担当者だけでは解決しなかった場合、別の詳しいメンバーや開発担当等にエスカレーションし、ヘルプを求めます。それを多数のメンバーでスピーディに行うためにSlackを活用しており、サポートチャンネルにその旨をスニペット*1書き込んでピン留めし、簡易的な課題管理のような使い方をしています。

ピン留めされたスニペットや画像は、

  • そのチャンネルに入っている全員が見える
    • 現在未解決のまま残っている問い合わせが一目で分かる*2
  • コメントをスレッド形式で書き込んでいける
    • 進捗や担当・開発者間のやり取りも当初の問い合わせ内容の下にぶら下げて書ける

というメリットがあります。こんな感じになります。

f:id:nurenezumi:20170120195507p:plain

スレッド表示もこんな感じで使えると良いかと思ったのですが、スレッドに参加する・参加させるというステップが必要なこともあり、現在のところはもっと軽い使い方になりそうです。また、チャンネルに参加している人数の多寡によってもありがたみは変わってきそうだと感じました。数百人といった大きなチャンネルでの使い勝手も聞いてみたいところです。

それでは良いチャットライフを。

*1:一言二言で終わるような問い合わせはないため、これは自然な行動です

*2:インシデント管理は別途Salesforce+Remedyforceで行っているため、これはあくまで現状を簡単に確認するためのものです。また言うまでもありませんが、解決したらピン留めは外します。

石器時代みたいなWebアプリ開発をしていたエンジニアがReact.jsに導かれて2016年にやってきた話

はじめに

社内向けのシンプルなWebアプリケーションを作成することになりました。 求められているものはサービスそのものであり、実装方式については全て自分で決めていいわけですが、この際なので2016年のモダンな開発方法で書こうと思い立ちました。そこでReact.jsを採用します。今回の記事はその日記のようなものです。

この記事のまとめ

React.jsを使い始めました。そのためにnpmとBabelを入れました。ついでにgulpとBrowerifyを入れました。快適かつモダンな開発環境を手に入れました。

f:id:nurenezumi:20161220160056j:plain

2016年を代表する猫

まずReact.jsについて

React.jsってなに

Facebookの偉大なエンジニアが作ったフロントエンド用のフレームワークです。特徴として、

  1. DOMを全てJavaScriptで構築・操作する
  2. (基本的には)データの流れが一方通行
  3. コードをReact.js独自の文法「JSX」で記述する

が挙げられます。

DOM

HTMLを書くのはやめます。DOMは全てJavaScript側で構築してHTMLに流し込みます。動的に操作するのももちろんJavaScriptです。したがって、HTMLは

<body>
<div id="app">読み込み中...</div>
<script src="./lib/app.js"></script>
</body>

こんな風に超シンプル(というより空っぽ)になります。React.jsが動き出すと、この<div id="app">読み込み中...</div>が丸ごとJavaScript側で生成したDOMに置き換わるわけです。

一方通行

jQueryを使ったDOM操作でWebアプリケーションを作るとき、たとえば画面の右側(例:アイテムの絞り込み条件)と左側(例:アイテムの一覧表示)で不整合が起きるようなバグに苦しむことがあります。これは画面の要素のあちこちが独自に状態を抱え込み、しかもその要素が複雑に絡み合って情報を受け渡しするため、その管理を正しく行うのが難しくなるからです。

これに対してReact.jsでは、

  1. 画面に表示すべき全てのデータを1個のオブジェクトにまとめてしまう
  2. そのオブジェクトを受け取って画面表示する(=DOMに変換する)処理を書く

という基本原則があります。画面を書き換えたい場合は、

  1. オブジェクトの値を更新する
  2. 全ての画面要素を一括で描画し直す

で済ませてしまいます。このルールにより、画面表示と内部データは常に一貫性を持っていることが保証されます。jQueryでこんなことをやっていたら重くてまともに操作できなくなりそうですが、React.jsはまず画面更新後のDOMを内部的に算出し、発生した差分のみに対して実際のDOM操作を行うというテクニックを採用しており(これはVirtual DOMと呼ばれています)、処理は高速に行われます。差分がなければDOM操作は発生しません。

JSX

ここからが本題です。

まず、React.jsのコードはJSXという文法で記述します。これはJavaScriptを拡張した独自の文法で、JavaScriptに似ていますがJavaScriptではありません(明らかに文法エラーになります)。簡単な例を示します。

function App() { return <span>hello world!</span>; }
ReactDOM.render(<App />, document.getElementById('app'));

JavaScriptのコード中にHTMLのタグが出現しています。これは、

という処理です。コンポーネントはいくらでも作ることができます。React.jsでは、Webアプリケーションを構成する要素をこのコンポーネントに分解し、それらを組み立てることでDOMを構築していきます。

JSX→JavaScriptの変換をしたい

JSXはFacebookの偉大なエンジニアが勝手に定義した言語ですので、ブラウザは解釈できません。したがって、JSX→素のJavaScriptへの変換を誰かが行う必要があります。

1つめの方法は手動で変換をかけるものです。オンラインの変換ツールがありますので、ここにJSXをコピペすれば通常のJavaScriptに変換してもらえます。実際にやることはないでしょうが、一度このリンクを開いておいてください。

2つめの方法は毎回動的に変換をかけるものです。HTML側で

<script src="https://fb.me/JSXTransformer-0.13.3.js"></script>
<script type="text/jsx" src="app.js"></script>

と書いておくと(type="text/jsx"に注目しましょう)、JSXで書かれたapp.jsをその場でJSXTransformerがJavaScriptに変換してくれます。これはお手軽なのですが、当然ながらパフォーマンス上の問題が生じますし、JSXTransformerは既にdeprecatedになっています

最後の方法は、何らかのツールでトランスパイル*1をかける方法です。

ここでちょっとnpmについて

.NETにNuGetがあるように、PHPPEARやComposerがあるように、またRubyにgemがあるように、JavaScript*2にもnpm (Node.js Package Manager)というパッケージマネージャーがあります。多数の人々により様々なパッケージが提供されているわけですが、そのパッケージをダウンロード・インストールしてくれたり、その際に依存関係を解決してくれたり、また新しいコマンドをインストールしてくれたりする優れものです。

インストール

npmはsudo apt install npmで簡単に入ります。すみませんがAmazon LinuxなどFedora系では入れるのが地味に面倒くさいので自分で調べてください。

プロジェクト作成

npmでは、まず最初にプロジェクトを作成する必要があります。空のディレクトリに対して

npm init

を実行すると、プロジェクト名などの基本情報を入力するように要求され*3、これが終わるとこのディレクトリはプロジェクトとして管理対象になります。管理対象と言っても、単に管理用ファイルpackage.jsonが新規作成されるだけです。

パッケージ取得・インストール

これができたら次のコマンドでパッケージを取得・インストールすることができます。

npm intall --save <パッケージ名>
npm intall --save-dev <パッケージ名>

ここで--saveと書いているのは、package.jsonの中に「このプロジェクトが依存するパッケージ」としてインストールしたパッケージを記録しておくことを示します。開発時にのみ必要なパッケージをインストールする際は--save-devオプションを指定します。他の人は後からpackage.jsonを見ればそのプロジェクトのために必要なパッケージが分かりますし、package.jsonだけ手元にあれば、npm installでその中に記載されたパッケージを一括でダウンロード・インストールすることができます。

さてインストールを行うと、プロジェクトのディレクトリ直下にnode_modulesディレクトリが作成され、ダウンロードしたファイル(多くの場合はJavaScriptのコード)が入ります。これらのコードは、

var hoge = require("MODULE_NAME");

などの文法で参照することができます。これについては後述します。

また、インストールと同時にnode_modules/.bin/以下に実行可能ファイルが置かれるものもあります。これはプロジェクトごとにインストールされるものですが、プロジェクトによらず、OS全体で(グローバルに)利用したいこともあるでしょう。後述のgulpなどがそれにあたります。この場合は、

sudo npm install --global <パッケージ名>

でインストールができます。OS全体に影響が及ぶのでsudoが必要なことがあります。

Babel導入

さて、上の方で試しに利用したオンライン変換ツールの名前はBabelとありました。そもそもBabelってなんでしょう?

Babelの名前は、(おそらく)聖書のバベルの塔の記述、「全ての地は、同じ言葉と同じ言語を用いていた」から来ています。JavaScript*4にはES5, ES2015(ES6), ES2017(ES7)といくつかのバージョンがあり、また正式採用前ながらも開発者コミュニティによく使われている文法もあり、さらにはJSXのような方言も存在します。そのサポート状況はブラウザによって異なります。先進的なバージョンの文法で書かれたJavaScriptはどれ一つとしてブラウザが対応していないことだって考えられますし、現時点ではJSXを理解できるブラウザは存在しません(これから先もないでしょう)。このようなJavaScriptを古いバージョンのJavaScriptに変換し、どのようなブラウザでも解釈できるようにしてくれるのがBabelです。「同じ言葉と同じ言語」が実現するわけですね。

Babelはオンラインで使うこともできますが、普通はコマンドとして使いたいでしょう。そこでnpmを使います。このような流れで実行します。

  1. 空のプロジェクトを作る
  2. このプロジェクトにnpmでBabelをインストールする(babelコマンドも入る)
  3. このプロジェクトにnpmでBabelのreactプリセット(プラグインのようなもの)をインストールする
  4. 準備完了

順に実行します。まず空のディレクトリを作り、そこに空のプロジェクトを作成します。

mkdir helloworld
cd helloworld
npm init

次にBabelとBabelのプリセットをインストールします。

npm install --save-dev babel-cli babel-preset-react

変換が終わってしまえば稼働時には必要ないので--save-devを指定しています。これで準備は完了です。次のコマンドでJSXのファイルを読み込み、JavaScriptに変換して標準出力に吐き出します。

function App() { return <span>hello world!</span>; }
ReactDOM.render(<App />, document.getElementById('app'));
neko@ubuntu:/workdir$ node_modules/.bin/babel --presets react app.js
function App() {
  return React.createElement(
    'span',
    null,
    'hello world!'
  );
}
ReactDOM.render(React.createElement(App, null), document.getElementById('app'));

先ほどオンラインで試したものと同じコードが出力されました。

ES2015? ES2016???

上の方で出てきた「ES2015」「ES2016」について説明します。

まず、JavaScriptECMAScriptという規格に沿った原語です。ECMAScriptには、

  • ECMAScript 5th Edition (ES5)
    • IE6とかのいにしえの時代はこれ準拠のJavaScriptが使われていた
  • ECMAScript 2015 (ES2015)
    • ES6とも呼ばれる
  • ECMAScript 2016 (ES2016)
    • ES7とも呼ばれる

というバージョンがあり、更新されるたびに便利な(あるいは複雑な)機能が追加されていっています。テンプレートリテラル(文字列の組み立て)などはもうES5の時代には戻れません。

var name = "neko";

// ES5
console.log("my name is " + name + ".");

// ES2015
console.log(`my name is ${name}.`);

主要なブラウザが搭載しているJavaScriptエンジンはES2015に対応していますが、ES2016は新しすぎるためまったくと言っていいほど未対応です。

gulp導入

さて話を戻して、ここではgulpというツールを投入します。

gulpとは「ビルドシステム」で、あらかじめ定義したタスクをまとめてコマンド一発で実行してくれるツールです。たとえばJSXをJavaScriptに変換するだけではなく、ついでに圧縮とかもしたいですよね? それもコマンド一発でやって欲しいですよね? さらにはファイルの更新を監視し、それらの処理を自動で済ませたりしてほしいですよね? gulpがやってくれます。

試しに使ってみる

まず、グローバルでgulp-cliをインストールします。どこのディレクトリで実行しても構いません。

sudo npm install --global gulp-cli

これでどこからでもgulpコマンドが実行できるようになりました。次に、プロジェクトのディレクトリに移動してgulpとgulpが利用するパッケージをインストールします。ここではJavaScriptのコードを圧縮するgulp-minifyを入れてみます。

npm install --save-dev gulp gulp-minify

gulpは「あらかじめ定義したタスク」を自動で行うものだと言いました。この定義はgulpfile.jsというファイルで行います。プロジェクトディレクトリの直下にgulpfile.jsを作成し、以下のように書きます。

var gulp = require('gulp');
var minify = require('gulp-minify');

gulp.task("neko", function() {
  gulp.src("src/*.js")
    .pipe(minify())
    .pipe(gulp.dest("lib"));
});

gulp.task("default", ["neko"]);

ここではnekoという名前のタスクを定義していて、その内容はsrcディレクトリ以下の*.jsを対象にminify()して、結果をlibディレクトリに出力するというものになっています。また、タスク名が指定されなかった場合のdefaultタスクの内容としてはnekoを使ってね、ということも書いてあります。

試しにsrc/app.jsとしてこんなJavaScriptを書いて、

// tama-san is kawaii
var name = "tama";
console.log("hello, my name is " + name + " desu.");

コマンドラインgulpとだけ実行すると(明示的にgulp nekoと実行しても構いません)、libディレクトリ(存在しなければ自動的に作成されます)にオリジナルのapp.js、圧縮済みのapp-min.jsが出力されます。中身を見てみます。

var name="tama";console.log("hello, my name is "+name+".");

確かにコメントや空白が削除されて圧縮されています。

gulp + Babel

これでgulpが何者かは分かりました。gulpからBabelを使えるようにしましょう。まずgulpが利用するパッケージをインストールします。

npm install --save-dev gulp babel-cli babelify browserify vinyl-source-stream babel-preset-react babel-preset-es2015

見知らぬ名前がたくさん出てきました。

  • gulp : gulp本体
  • babel-cli : Babel CLI
  • babelify : Babel
  • browserify : (後述)
  • vinyl-source-stream : gulpが利用する
  • babel-preset-react : BabelのReact.js用プリセット
  • babel-preset-es2015 : BabelのES2015用プリセット

分かってしまえばどうと言うことはありませんね!*5

gulpfile.jsの内容はこうなります。

var gulp = require("gulp");
var babelify = require("babelify");
var browserify = require("browserify");
var source = require("vinyl-source-stream");

gulp.task("neko", function() {
  browserify({entries: "src/app.js"})
    .transform(babelify)
    .bundle()
    .on("error", function (err) { console.log("Error : " + err.message); })
    .pipe(source("app.js")) 
    .pipe(gulp.dest("./lib/"))
});

gulp.task("default", ["neko"]);

gulp.task("watch", function() {
  gulp.watch("src/*.js", ["neko"]);
});

さらにプロジェクトディレクトリの直下に.babelrcというファイルを新規作成します。これはBabelの動作内容を決める設定ファイルです。ReactのJSXとES2015を使いたいのでこのように書きます。

{"presets": ["react", "es2015"]}

これで下準備が全て整いました。srcディレクトリにapp.jsを配置します。

const App = props => <span>hello world!!!!</span>; // ES2015の新文法
ReactDOM.render(<App />, document.getElementById('app'));

これでgulpを実行すると、libディレクトリに変換されたJavaScriptが出力されます。これを次のHTMLで読み込めば動作します。

<!doctype html>
<html>
<head>
<script src="https://unpkg.com/react@15/dist/react.js"></script>
<script src="https://unpkg.com/react-dom@15/dist/react-dom.js"></script>
</head>
<body>
  <div id="app">読み込み中...</div>
  <script src="./lib/app.js"></script>
</body>
</html>

ファイル監視

gulpとだけ実行すると、gulpはタスクを1回だけ行って終了します。gulp watchとすると、gulpはファイルの更新を監視し、更新されたらその場その場でタスクを繰り返し実行してくれます。つまり、もう手動でコマンドを実行する必要はなくなるのです。

Browserify

次に、上の項で唐突に出てきたBrowserifyの解説をします。

古き良き時代

ここでjQuery UIのことを思い出してみましょう。ドキュメントを読むと「jQuery UIはjQueryに依存している」と書いてあります。したがって、HTMLに

<script src="https://code.jquery.com/jquery-3.1.1.slim.min.js"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js"></script>

のように書かないといけないのでした。このような依存関係は人間がドキュメントを読んだり書いたりして管理しないといけません。また、jQueryグローバル変数$を利用するため、同じ変数を利用するライブラリとは同時に利用できません*6。これも利用者が気を配ってやる必要があります。

Node.jsではどうやっているか

このような牧歌的な時代は終わりました。現代のJavaScriptではこんなのどかなことを言っていてはいけません。

サーバーサイドJavaScript実行環境であるNode.jsには他モジュールのインポート機能があります。たとえばAWS APIを使いたいのであれば、まずnpmを使ってaws-sdkパッケージをインストールします。

npm install --save aws-sdk

プログラムコード中でaws-sdkをインポートするにはrequire()文を使います。

var AWS = require("aws-sdk"); // aws-sdkという名前のモジュールを読み込む
var ec2 = new AWS.EC2();
ec2.startInstances(...); // EC2インスタンスを起動

ブラウザでも同じことがしたいと誰もが思います。しかし(現在の)ブラウザに許されるのは<script>タグでHTTPリクエストを発生させてソースコードを読み込むことだけ。このような仕事はブラウザの領分を超えています。

そこでBrowserify

Browserifyは、npmと組み合わせてこのインポート構文をブラウザ用JavaScriptにも持ち込むためのツールです。このように使います。

  1. npm install --save <パッケージ名>で必要なパッケージをインストールする
  2. JavaScriptのコードの中にrequire()文を書く
  3. Browserifyを通すと、require()が消滅し、代わりに参照したパッケージが埋め込まれる

この結果、ユーザーは参照しているライブラリ(パッケージ)が全て結合された1つの大きな.jsファイルを得ます。名前空間の汚染がないような工夫も行われています。

例1

JavaScriptで日次を任意の書式(たとえばYYYY/mm/dd HH:mmとか)で出力したいというとき、整数のフォーマットが面倒でいつも頭を悩ませていました。従来はそのためのjQueryプラグインなどに頼っていたわけです。

我々はもうjQueryと手を切ると決めたので、ここでnpmパッケージを探すとdateformatというものがあります。便利そうなので使わせてもらいましょう。インストールは簡単です。

npm install --save dateformat

あとはrequire()で読み込むだけです。

var dateFormat = require("dateformat"); // これ!

var timestamp = dateFormat(new Date(), "yyyy/mm/dd HH:MM");
console.log(timestamp); // "2016/12/21 12:34"

これをgulpでビルドすると、require()部分が消えてdateformatが取り込まれ、単体で動作するJavaScriptソースコードが出てきます。

例2

最初の方の例では、react.js, react-dom.jsを直接CDNから読み込んでいました。これを統合してみましょう。

まず、npmでreact, react-domを追加します。

npm install --save react react-dom

次にapp.jsを修正します(上2行を追加します)。

var React = require("react");
var ReactDOM = require("react-dom");

const App = props => <span>hello world!!!!</span>; // ES6の文法
ReactDOM.render(<App />, document.getElementById('app'));

そしてgulpでこのapp.jsをビルドすると、生成されたlib/app.jsはライブラリを取り込んで700KBになりました*7。これならもうスクリプトは1個で済みます。

<!doctype html>
<html>
<head>
</head>
<body>
  <div id="app">読み込み中...</div>
  <script src="./lib/app.js"></script>
</body>
</html>

終わりに

ここまで準備してしまえば、その他のライブラリを導入するのも、別のトランスパイラを利用するのもぐっと障壁が低くなります。特にJavaScriptの開発環境は異様なほど流行廃りが速いのですが、なるべく便利なものをどんどん取り入れて快適に開発を進めていきたいものです。

*1:コンパイルのようなものですが、コンパイルのようにバイナリを吐くわけではないのでこう呼ばれることがあります

*2:正確にはNode.js

*3:全てEnter空打ちでOKですが、プロジェクト名に大文字は含められないため、ディレクトリ名に大文字が入っている場合だけは手で入力する必要があります

*4:正しくはECMAScript

*5:こういうところでは気になってもとりあえず先に進み、しかし後から戻ってきてちゃんと理解するのが惑わずにいるコツです

*6:近年ではあまり名前を見なくなりましたが、prototype.jsが該当します

*7:圧縮すればもっと縮みます

LINEのMessaging API WebhookをAPI Gatewayで受ける

こんにちは、AWS担当のwakです。

最近LINE botがMessaging APIとしてリニューアルしたとのことで、同僚が面白がって触っていたので説明してもらいました。聞くところによると、LINEのbotを友達登録したりメッセージを送ったりアンケートに答えたりすると、その内容がリアルタイムで自分のサーバーに送られてくるWebhookがあるそうです。

f:id:nurenezumi:20161102104059p:plain

そしてLINEのMessaging API WebhookはHTTPSのエンドポイントを要求します。このためだけにサーバーを立てたり証明書を取ったりするのも面倒ですし、AWSAPI Gateway+Lambda(Node.js)で受けることにしました。今回はその話を書きます。

f:id:nurenezumi:20161102113329j:plain

概要

話はシンプルです。LINEからのメッセージをAPI Gatewayで受け、署名(後述)をチェックし、それ以外のものをどこか(SNS, DynamoDB, アプリケーションサーバーなど)に投げます。こうすることにより、仮に一気にアクセスが集中した場合でも応答ができなくなることは防げますし、API Gateway側で流量制御もできます。また、アプリケーションが直接インターネットに露出する必要がなくなるので気を遣う点が少なくなります。

準備

Lambda

Node.jsで書きます。コードはこの記事の末尾にあります。先に作成しておいてください。

API Gateway

いつも通りResources→Create Resource→Create MethodとたどってPOSTのエンドポイントを用意します。ここでLambdaを指定するのですが、"Use Lambda Proxy integration"のチェックをONにしてください。

f:id:nurenezumi:20161027171436p:plain

Use Lambda Proxy integrationがONなので、その他の細かい設定は不要です。API Gatewayがアクセスを受け付けると、HTTPヘッダ、ボディ等がまとめてLambdaに渡されます。

問題なければデプロイしてエンドポイントを確定させておきます。

LINE Messaging API Webhook

LINE Business Centerにログインし、「アカウントリスト」タブからMessaging APIを探して設定画面を開きます。ここにある「Channel Secret」を取得し、Lambdaのコード中にコピペして更新しておきます*1

f:id:nurenezumi:20161027172503p:plain

同じ画面の下の方にWebhookのエンドポイントを登録する場所があります。

f:id:nurenezumi:20161027173056p:plain

ここにAPI Gatewayのエンドポイントを書いて「VERIFY」ボタンを押してみましょう。ここまでの設定が全て正しく行っていれば、API Gateway経由でLambdaが実行され、ログが出力されるはずです。

正しくログが出力されればこれで終わりです。あとはbotを友達登録したりチャットをしたりしてください。

メッセージの検証

さて、ちょっと別のことを考えます。

LINE(メッセージを送信する側)の立場から見た場合、WebhookのエンドポイントはHTTPS限定ですから、登録したエンドポイントにメッセージを送信すれば確実に私に届くことは保証されています。なりすましに騙されて違う相手にメッセージが届いてしまうこと、送信経路でメッセージが盗聴・改竄を受ける可能性を気にする必要はありません*2。ところが私(メッセージを受ける側)から見た場合は話が変わります。たとえば誰かがLINEのサーバーを装って本物と同じフォーマットで偽のメッセージを送ってくるかもしれません。こういった攻撃は正しく見破って破棄する必要があります。

HMACってなに

このように、メッセージの改竄・捏造を検出する手段の一つがHMAC: Hash-based Message Authentication Codeです。HMACでは、事前に共有した秘密鍵とメッセージを混ぜ合わせてハッシュを取り、MAC値と呼ばれる値を算出します。送信側はこのMAC値をメッセージに添えて送ります。受信側はメッセージの内容と秘密鍵*3から同じ手順でMAC値を算出し、添えられたMAC値と比較して、一致すればこのメッセージは本物であると判断します。なぜならば、メッセージに対応した正しいMAC値が分かるのは秘密鍵を知っている2人だけだからです。秘密鍵が分からないのにメッセージをでっち上げようとしてもうまくいきません。

なお、HMACの利点は秘密鍵そのものをメッセージに含める必要がないことで、つまりSSLを通さず平文でメッセージをやり取りすることも可能です(覗き見は避けられませんが、改竄は不可能だということになります)。

算出方法

ドキュメント | LINE Developers に従ってメッセージのMAC値(をBASE64エンコードした文字列)を算出します*4

var crypto = require('crypto');
var hmacsha256 = crypto.createHmac("sha256", "deadbeefdeadbeefdeadbeefdeadbeef"); // 第2引数はChannel Secret

var body = "..."; // 受信したbody部
var bodyStream = new stream.Readable();
bodyStream.push(body);
bodyStream.push(null);

bodyStream.on("data", chunk => {
    hmacsha256.update(chunk);
});
bodyStream.on("end", () => {
    var digest = hmacsha256.digest("base64");
    console.log(`digest: ${digest}`);
});

このdigestの値と、X-Line-Signatureヘッダの値とを比較すれば検証は終わりです。

var signature = (event.headers || {})["X-Line-Signature"];

Lambdaのコード

aws-sdk, crypto, streamはいずれも標準の環境に含まれているため、特に複雑なことをする必要はありません。このコードではログを出力するだけですが、もちろんなんでも好きなことができます。

'use strict';

var AWS = require("aws-sdk");
var lambda = new AWS.Lambda({region: "ap-northeast-1"});
var crypto = require('crypto');
var stream = require("stream");

exports.handler = function(event, context) {
    console.log(event);

    var signature = (event.headers || {})["X-Line-Signature"];
    var body = event.body || "";
    var bodyStream = new stream.Readable();
    bodyStream.push(body);
    bodyStream.push(null);
    
    var hmacsha256 = crypto.createHmac("sha256", "YOUR-CHANNEL-SECRET"); // TODO: 管理画面からChannel Secretを取得して書き換える
    bodyStream.on('data', chunk => {
        hmacsha256.update(chunk);
    });
    
    bodyStream.on('end', () => {
        var digest = hmacsha256.digest("base64");
        if (digest === signature)
        {
            console.log("署名検証結果OK");
            // TODO: 何かする
        }
        else 
        {
            console.log("署名検証結果NG");
            // TODO: 何かする
        }
        var response = {
            statusCode: 200,
            headers: {
                "x-custom-header" : "my custom header value"
            },
            body: '{"result":"owari"}'
        };
        context.succeed(response);
    });
};

*1:もちろん本当はDynamoDBとかに入れておきます

*2:そのためのSSLなので

*3:上述の通り、秘密鍵は事前にこっそり共有します。メッセージには含まれていません

*4:もちろん本来はバイトストリームから直接MAC値を算出すべきですが、API Gatewayでは文字列しか扱えません。LINEがメッセージを送信する際の文字エンコーディングも、Streamのデフォルトの文字エンコーディングUTF-8であることを前提に処理を書いています

ASP.NET Webフォームプロジェクトでwebフォームの内容をhtmlファイルにしてダウンロードさせる

井上です。
いまだASP.NET Webフォームを用いているシステムも多々あると思います。
弊社もWebフォームのシステムがあり、そこで普段やらないことをやりましたのでそれをまとめておきます。
こんな処理を書くことはそうそう無いのですが何かの役に立つかもしれません。

環境

.NET Framework 4.5.1
ASP.NET Web Form
C#

目的

他システム(Linux/Apache)にアップロードするためのhtmlファイルをWebフォームを元に作成する。

方法

  1. HTML作成テンプレートとなるWebフォームを作成
  2. HTML作成用テンプレートを呼び出すWebフォームを作成
  3. 2で1に渡すパラメータの取得、session経由での引き渡しを行い、1を実行する
  4. 結果を加工しダウンロードさせる

Web Formを使うことによりListView等にバインドできたり、編集が比較的楽というメリットがあります。コードも見やすいです。
当然ですが、Web Formの結果であるため不要なタグを端折る必要があります。

コード

テンプレート

<html xmlns="http://www.w3.org/1999/xhtml">
<!-- headタグは出力対象 -->
<head>  
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>    
    <title>hogehoge</title>
</head>
<body>
    <form id="form1" runat="server">
        <!-- body start -->
        <div class="templatestart"/>

        <!-- ここにいつも通りコードを書く。ー -->

        <div class="templateend"/>
        <!-- body end -->
    </form>
</body>
</html>

テンプレートのコードビハインド

using System;

public partial class hogehoge
{
    protected void Page_Load(object sender, EventArgs e)
    {        
        // セッションからパラメータ取得しバインド
        var data = (HogeHoge)Session["hogehoge"];

        this.DataBind();

        Session.Remove("HogeHoge");   
    }
}

HTML作成するWebフォームのコードビハインド

using System;
using System.Text;
using System.Web;

protected void CreateHtml_Click(object sender, EventArgs e)
{
    string url = "/template/hogehoge.aspx";

    // セッションに引き渡しパラメータを入れる
    Session.Add("hogehoge", new HogeHoge());

    // 実行・出力
    using (System.IO.StringWriter writer = new System.IO.StringWriter())
    {
        StringBuilder outputhtml = new StringBuilder();

        // テンプレートファイルを実行する
        HttpContext.Current.Server.Execute(url, writer, false);
        string html = writer.ToString().Replace(System.Environment.NewLine, "");

        // headタグ取得
        Regex regex = new Regex("<head>.*</head>");
        Match match = regex.Match(html);
        string headerhtml = match.Value;

        // 出力タグ取得
        regex = new Regex("<div class=\\\"templatestart\\\"/>(?<body>.*?)<div class=\\\"templateend\\\"/>");
        match = regex.Match(html);
        string bodyhtml = match.Groups["body"].Value;

        // html組み立て
        outputhtml.Append("<html>");
        outputhtml.Append(headerhtml);
        outputhtml.Append("<body>");
        outputhtml.Append(bodyhtml);
        outputhtml.Append("</body></html>");

        // ダウンロード
        Response.ClearContent();
        Response.ContentType = "text/html";
        Response.AddHeader("Content-Disposition", "attachment;filename=" + url.Substring(url.LastIndexOf('/') + 1).Replace(".aspx", ".html"));
        Response.Write(outputhtml.ToString());
        Response.End();
        Response.Flush();
    }
}

HttpContext.Current.Server.Executeで実行結果を受け取れますので、不要なタグを端折った後、ダウンロードさせています。

ASP.NET Webフォームプロジェクトでオートコンプリートのドロップダウンを実現する

井上です。
ASP.NET Webフォームのドロップダウンコントロールでオートコンプリートをやろうというお話です。 ちょっとと面倒だったのでまとめておきます。

環境

.NET Framework 4.5.1
ASP.NET Web Form
C#

参考

jqueryui.com

やりたいこと

オートコンプリート化したいDropDownListコントロールを含むページで、お手軽にオートコンプリート化させる

コード

オートコンプリート関数の作成(jquery.autocomplete.js)

;(function ($) {
    $.fn.autocompleteExtend = function () {
        return this.each(function() {
            $.widget("custom.combobox", {
                _create: function () {
                    this.wrapper = $("<span>")
                      .addClass("custom-combobox")
                      .insertAfter(this.element);

                    this.element.hide();
                    this._createAutocomplete();
                    this._createShowAllButton();
                },

                _createAutocomplete: function () {
                    var selected = this.element.children(":selected"),
                      value = selected.val() ? selected.text() : "";

                    this.input = $("<input>")
                      .appendTo(this.wrapper)
                      .val(value)
                      .attr("title", "")
                      .attr("onKeydown", "return InvalidInput(event)") // enterキーの挙動を打ち消す
                      .attr("style", "float:left;") // ブラウザ差異対応
                      .addClass("custom-combobox-input ui-widget ui-widget-content ui-state-default ui-corner-left autocomplete")
                      .autocomplete({
                          delay: 0,
                          minLength: 0,
                          source: $.proxy(this, "_source")
                      })
                      .tooltip({
                          tooltipClass: "ui-state-highlight"
                      });

                    this._on(this.input, {
                        autocompleteselect: function (event, ui) {
                            ui.item.option.selected = true;
                            this._trigger("select", event, {
                                item: ui.item.option
                            });
                        },

                        autocompletechange: "_removeIfInvalid"
                    });
                },

                _createShowAllButton: function () {
                    var input = this.input,
                      wasOpen = false;

                    $("<a>")
                      .attr("tabIndex", -1)
                      .attr("title", "")
                      .tooltip()
                      .appendTo(this.wrapper)
                      .button({
                          icons: {
                              primary: "ui-icon-triangle-1-s"
                          },
                          text: false
                      })
                      .removeClass("ui-corner-all")
                      .addClass("custom-combobox-toggle ui-corner-right autocomplete")
                      .mousedown(function () {
                          wasOpen = input.autocomplete("widget").is(":visible");
                      })
                      .click(function () {
                          input.focus();

                          // Close if already visible
                          if (wasOpen) {
                              return;
                          }

                          // Pass empty string as value to search for, displaying all results
                          input.autocomplete("search", "");
                      });
                },

                _source: function (request, response) {
                    var matcher = new RegExp($.ui.autocomplete.escapeRegex(request.term), "i");
                    response(this.element.children("option").map(function () {
                        var text = $(this).text();
                        if (this.value && (!request.term || matcher.test(text)))
                            return {
                                label: text,
                                value: text,
                                option: this
                            };
                    }));
                },

                _removeIfInvalid: function (event, ui) {

                    // Selected an item, nothing to do
                    if (ui.item) {
                        // 選択値でポストバックさせる
                        setTimeout('__doPostBack(\' + $(this).attr("name") + \' ,\'\')', 0);
                        return;
                    }

                    // Search for a match (case-insensitive)
                    var value = this.input.val(),
                      valueLowerCase = value.toLowerCase(),
                      valid = false;
                    this.element.children("option").each(function () {
                        if ($(this).text().toLowerCase() === valueLowerCase) {
                            this.selected = valid = true;
                            return false;
                        }
                    });

                    // Found a match, nothing to do
                    if (valid) {

                        return;
                    }

                    // Remove invalid value
                    this.input
                      .val("")
                      .attr("title", "")
                      .tooltip("open");
                    this.element.val("");
                    this._delay(function () {
                        this.input.tooltip("close").attr("title", "");
                    }, 2500);
                    this.input.autocomplete("instance").term = "";
                },

                _destroy: function () {
                    this.wrapper.remove();
                    this.element.show();
                }
            });

            // 挙動設定
            $('#' + $(this).attr("id") ).combobox();

            // ドロップダウンのスクロール指定
            $('.ui-autocomplete').css('overflow-y', 'auto');
            $('.ui-autocomplete').css('overflow-x', 'hidden');
            $('.ui-autocomplete').css('height', '200px');
        });
    }
})(jQuery);

$(function () {
    $(".autocomplete").autocompleteExtend();
});

参考先のコードを元に作成。
入力値/選択値がリスト内の要素と完全一致するときのみポストバックさせます。
また動的に生成されるドロップダウンのスクロール指定等を行っています。
最後の処理はautocompleteクラスを持つ要素に対してautocompleteExtendを呼び出すためのものです。

呼び出し元コード(sample.aspx)

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="sample.aspx.cs" Inherits="WebApplication1.sample" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <title></title>
    <script src="Scripts/jquery-1.12.4.min.js"></script>
    <script src="Scripts/jquery-ui-1.12.0.min.js"></script>
    <script src="Scripts/jquery.autocomplete.js"></script>
    <link rel="stylesheet" href="Content/themes/base/jquery-ui.min.css" />
    <style>
    .custom-combobox {
     position: relative;
     display: inline-block;
    }
    .custom-combobox-toggle {
     position: absolute;
     top: 0;
     bottom: 0;
        margin-left: -1px;
     padding: 0;
    }
    .custom-combobox-input {
     margin: 0;
     padding: 5px 10px;
    }
    </style>
</head>
<body>
    <form id="form1" runat="server">
    <div>
        <asp:DropDownList ID="HogeHogeList" runat="server" OnSelectedIndexChanged="HogeHogeList_SelectedIndexChanged" AutoPostBack="true" CssClass="autocomplete"></asp:DropDownList>   
    </div>
    </form>
</body>
</html>

DropDownListコントロールautocompleteクラスを指定しているのみです。

呼び出し元コード(sample.aspx.cs)

using System;
using System.Collections.Generic;

namespace WebApplication1
{
    public partial class sample : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            if (!IsPostBack)
            {
                var list = new Dictionary<string, string> { 
                { "", "0" }, 
                { "Aゴルフ場", "1" }, 
                { "Bカントリー倶楽部", "2" },
                { "Cゴルフコース", "3" },
                { "Dカントリークラブ", "4" },
                { "Eゴルフリゾート", "5" } 
            };
                this.HogeHogeList.DataSource = list;
                this.HogeHogeList.DataTextField = "Key";
                this.HogeHogeList.DataValueField = "Value";
                this.HogeHogeList.DataBind();
            }
        }

        protected void HogeHogeList_SelectedIndexChanged(object sender, EventArgs e)
        {
            // 選択時の処理
        }
    }
}

実行すると

f:id:ihisa:20160721154007p:plain

入力すると一致する候補がきっちり表示されます。

f:id:ihisa:20160721153935p:plain

リストから選択すればSelectedIndexChangedイベントが発生します。

grepコマンドとPowerShellのsls (Select-String)の比較

こんにちは、PowerShellが好きなwakです。以前書いたこちらの記事をリライトします。

tech.sanwasystem.com

はじめに

Windowsにはgrepコマンドがないとはよく言われることですが、Windowsに標準で備わっているPowerShellにはgrepよりも高性能な検索コマンドレット、Select-Stringが実装されています。この記事はgrepSelect-Stringとを比較し、慣れない人でもすぐに使えるようにすることを目的としています。また、「そもそもこのSelect-Stringコマンドをどうやって実行するの?」といったPowerShellの(超)基本的な使い方は本稿の末尾に記載してあります。

grepとの比較表

grep Select-String
大文字・小文字の区別 -i -CaseSensitive *1
正規表現 -E -SimpleMatch *2
マッチしない行を検索 -v -NotMatch
前後表示 -A 《行数》
-B 《行数》
-Context 《行数》
文字コード指定 不可*3 -Encoding 《文字エンコーディング名》
ディレクトリを
再帰的に検索
-r (dir -recurse 《ファイル名パターン》)
ファイル名のみ出力 -l
-L
(後述)

以下、順に解説していきます。

1件のファイルから検索をする場合

grepで1件のファイルから指定したフレーズを検索する場合はこのように実行しますが、

grep pattern filename.txt

PowerShellでこちらに相当する処理はこうなります。

Select-String "pattern" filename.txt

Select-Stringにはslsという省略形が用意されています。エイリアスですのでどちらを使ってもまったく同じ結果が得られます。

sls "pattern" filename.txt

本稿では以降もSelect-Stringと表記しますが、もちろん実際に使うときにはslsで構いません。

-i : 大文字・小文字を区別したい/区別したくない

デフォルトでは大文字・小文字は区別されません。区別したい場合は-CaseSensitiveオプションを追加します。

# "Neko"にはマッチするが"neko"にはマッチしない
Select-String "Neko" filename.txt -CaseSensitive

-e : 正規表現を使いたい/使いたくない

パターンはデフォルトで正規表現とみなされます。正規表現として扱ってほしくない場合は-SimpleMatchオプションを追加します。

# "SELECT * FROM"にマッチ。-CaseSensitiveも指定しているので小文字ならマッチしない
Select-String "SELECT * FROM" filename.txt -SimpleMatch -CaseSensitive

-v : パターンにマッチしない行を探したい

-NotMatchオプションを使います。

# 「A」「a」「B」「b」「C」「c」のいずれも含まない行にマッチ
Select-String "[A-C]" filename.txt -NotMatch

-A / -B : パターンにマッチした前後の行も出力したい

-Contextオプションで行数を指定します。残念ながら前後別々に行数を指定することはできません。

# "function", "Function"などがある行と、その上下3行ずつ(計7行)を検索
Select-String "function" HogeClass.cs -Context 3

文字エンコーディングを指定したい

検索対象のファイルがShift-JISだと文字エンコーディングの自動判定ができないので、明示的に指定してあげなければいけません。これは-Encodingオプションを使います。

Select-String "猫" filename.txt -Encoding oem

-Encodinggの後ろにスペース1個)と入力した後でTABキーを押せば指定可能な文字エンコーディングが順に表示されます。oemがShift-JISだと覚えておけば事足りるでしょう(UTF-8がデフォルトです)。

複数のファイルから検索する場合

ここまでは1件のファイルから検索を行うものでした。次は複数件のファイルを対象として検索を行う方法です。

ワイルドカードを使いたい

まず普通にワイルドカードが使えます。

Select-String "pattern" *.txt

特定の複数のファイルを指定して検索したい

ワイルドカードではなく、ファイル名を書き並べたいならこうなります。カッコとダブルクオートが少々見づらいのですが我慢してください。

Select-String "pattern" ("z:\log\filename1.txt", "c:\data\text\filename2.txt")

ディレクトリを再帰的にたどって検索したい

次のどちらでも好きなものを選んでください。c:\data以下にある*.cs全てを検索対象としています。

Select-String "pattern" (dir -recurse c:\data\*.cs)
dir -recurse c:\data\*.cs | Select-String "pattern"

Dir -recurse c:\data\*.csは、c:\data以下から*.csを全部探すという意味になります(1回このコマンドだけを実行してみるといいです)。ディレクトリ名を省略したらカレントディレクトリになります。

特定の拡張子のファイルは除外したい

-Excludeで除外できます。

# カレントディレクトリ以下の全てのファイルから検索、ただし*.exeと*.binは除く
Select-String "pattern" (dir -recurse *.* -Exclude *.exe, *.bin)
dir -recurse *.* -Exclude *.exe, *.bin | Select-String "pattern"

検索対象のファイルを別のテキストファイルから与えたい

1行に1件ファイル名(フルパス)が書いてあるテキストファイルfilelist.txtがあったとして、そこから検索をする場合です。

Get-Content filelist.txt -Encoding UTF8 | % { Select-String "pattern" $_ }

なお、カレントディレクトリ以下の全てのファイル名(フルパス)をファイルに書き出すにはこのように実行します。ワイルドカードの部分は必要に応じて*.txtなどと書き換えてください。

dir -Recurse -File *.* | % { $_.FullName } | Out-File filelist.txt -Encoding UTF8

出力の書式や出力先を変える

標準の出力の書式では、ファイル名・行数・行の内容(行全体)が出力されます。なぜかというと、この結果1行1行はそれぞれMicrosoft.PowerShell.Commands.MatchInfoクラスのインスタンスであり、このToString()メソッドがそのような書式の文字列を返すようになっているからです。

https://msdn.microsoft.com/ja-jp/library/microsoft.powershell.commands.matchinfo(v=vs.85).aspx

このオブジェクトはFileName, Lineといったプロパティを持っていますから、欲しいものを好きなように並べて結果を出力することができます。

結果だけを画面に出力したい

ファイル名はいらない場合です。

# 末尾に「| Select Line」と書き加える
Select-String "pattern" filename | Select Line
Select-String "pattern" (dir -recurse *.* -Exclude *.exe, *.bin) | Select Line

以下では例は1行ずつしか示しませんが、どのような検索を行ったかにかかわらず、検索を行ったコマンドの末尾に「| ~」と書き加える形でOKです。

結果だけを画面に出力したい

ファイル名はいらない場合です。

Select-String "pattern" filename | Select Line

少し書式は面倒ですが、こちらの方を使うと色々と融通がききます。

Select-String "pattern" filename | % { $_.Line }

マッチした部分だけを出力したい

Select-String "pattern" filename | % { $_.Matches.Value }

このMatchesは.NETのSystem.Text.RegularExpressions.Matchクラスのインスタンスです。パターンに()を使っていればGroupsプロパティでさらに部分文字列を取り出せたりします。*4

Select-String "^(\d{4})-(\d{2})-(\d{2})" (dir -Recurse *.*) | % { $g = $_.Matches.Groups; $g.Groups[1].Value }

ファイル名(フルパス)、行数、行の内容をタブ区切りで出力したい

Select-String "pattern" filename | % { [string]::Format("{0}`t{1}`t{2}", $_.Path, $_.LineNumber, $_.Line) }

.NETを使っている人にはお馴染みのSystem.String.Format()を使ってみました。`tは他言語の\tと同じタブ文字を意味します。PowerShellらしく書くなら次のどちらかになりそうです。

# 末尾に「|」以降を書き加える
Select-String "pattern" filename | % { "{0}`t{1}`t{2}" -f $_.Path, $_.LineNumber, $_.Line }
Select-String "pattern" filename | % { ( $_.Path, $_.LineNumber, $_.Line) -join "`t" }

色々と試行錯誤したい

検索結果をまず先に変数に格納しておけば毎回検索する手間が省けるので話が早くなります。

$searchresult = Select-String "pattern" filename

これでまず検索結果が変数に入ります(画面には何も出ません)。

$searchresult

とだけ入力して実行すれば検索結果が出力されますし、

$searchresult | Select Line
$searchresult | % { "{0}`t{1}`t{2}" -f $_.Path, $_.LineNumber, $_.Line }

のように色々と試すこともできます。

結果をファイルに書き出す

検索結果は画面に出力するだけではなくファイルに書き出すこともできます。これについてはgrepと同じようにリダイレクトで吐き出すのが一番簡単でしょう。

Select-String "pattern" filename | % { ... } > result.txt
$searchresult | Select Line > result.txt

文字エンコーディングUTF-16になります。これが気に食わない人は、末尾に> ファイル名と書き加えるのではなく、さらにパイプをつなげてOut-Fileコマンドレットに渡します。

Select-String "pattern" filename | % { ... } | Out-File result.txt -Encoding UTF8
$searchresult | Select Line | Out-File result.txt -Encoding UTF8

ここで出力されるファイルはBOMありUTF-8という微妙なフォーマットになるのですが、これは諦めてください(少しコードを書けば解決するのですが)。

PowerShellの基本

起動

まず、何はなくともPowerShellを立ち上げましょう。

なんでもいいです。

補完

dir, Select-Stringといったコマンドレットによって入力可能なオプションはあらかじめ決まっています。-まで入力してTABキーを連打すると、指定可能なオプションが列挙されます。たとえば-CaseSensitiveであれば、-cまで入力してTABキーを押せば一発で補完される、といった具合です。

また、ファイル名・ディレクトリ名も同じように補完が効きます。

大文字・小文字

コマンドレット・オプションの大文字・小文字は区別されません。Select-StringSELECT-STRINGselect-stringと書いても問題ありません。

\エスケープっていらないの?

PowerShellでは\文字は特別な意味を持ちません。AB.と同じ普通の文字です。したがって\エスケープは必要ありません。特に正規表現を書くときにはこれが楽です。

そのかわり、たとえば改行文字はn`、タブ文字は\t`と書くことになっています。バッククオートそのものを検索したいときはバッククオートを2つ連ねて書いてください。

*1:デフォルトで区別なし

*2:デフォルトで正規表現

*3:grep単体では不可能。nkfとか使う

*4:"?"の書式でのグループ名には対応していないようでした