- 投稿日:2019-04-30T20:31:18+09:00
Azure Functions (Node.js) で npm パッケージを追加して利用する方法 (コンソール利用)
Azure Functions の関数作成画面の「コンソール」を使ってお手軽に npm install する方法です。
今回は HTTP リクエストをする request をインストールしてみます。npm install をする
Azure Functions の関数作成画面の [コンソール] で以下コマンドを入力していきます。
> npm init --yes > npm install requestおしまいです。ポイントは
npm init --yesでpackage.jsonを作成しておく必要があります。この辺は npm の運用ノウハウですね。Azure Functions の実装
PostBin に POST するコードを実装してみます。
const request = require('request'); module.exports = async function (context, req) { const url = `https://postb.in/XQh4ljyY`; const payload = {value1: "a", value2: "b", value3: "c"}; request.post(url, (error, response, body) => { context.log(response); }).json(payload); context.done(); };あとがき
Azure Functions で Node.js を利用する際、npmパッケージを利用したくなる時があります。Kudu を使ってインストールする方法もあるそうですが、ポータル内で小さい関数を作りたいときは強烈に面倒なので、それを回避してみたという話です。
もう平成もおわりですね。
参考資料
EoT
- 投稿日:2019-04-30T19:40:52+09:00
令和を迎える時につぶやかれているツイートをGoogle Map上で眺めてみる
平成最後に滑り込み投稿。
突発的に書きたくなったので、色々荒くてすみません。概要
Twitterが公開しているAPIの中に、「status/filter」というものがあり、これを使えば、指定された範囲のTweetをリアルタイムで取得することができます。
そのAPIを使い、日本国内で呟かれたTweetを収集し、↑のようにGoogle Map上に(ほぼ)リアルタイムで表示してみました。令和を迎えるときに、日本各地ではどのようなつぶやきがされているのか、眺めてみるのはどうでしょう。
こちらが作成したページです。
http://japan-tweet-map.net/処理の概要
バッチ側
Twitter API の 「status/filter」 を使用し、日本内でつぶやかれている位置情報付きのTweetを取得。DBに登録。
Webサーバー側
リクエストがあった際に、直近のTweetをDBから取得し、まとめてJson形式で返す。
フロント側
Webサーバーに数秒ごとにデータを取得問い合わせ。
取得できたデータをGoogle Map上にinfoWindowで表示。技術詳細
Twitter API の 「status/filter」 について
「status/filter」のドキュメントはこちら。
https://developer.twitter.com/en/docs/tweets/filter-realtime/api-reference/post-statuses-filter取得できるTweetデータの内容はここなどを参照。
https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/intro-to-tweet-jsonつぶやきの位置は、下記の「bounding_box」から範囲を取得して、中央の値を計算した。
https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/geo-objectsTwitterアプリケーション登録
Twitter APIを使用するには、Twitterアプリケーションに登録してコンシューマーキーなどを取得する必要あり。
下記などを参考に登録する。
https://yosiakatsuki.net/blog/create-twitter-application/
https://qiita.com/tdkn/items/521686c240b0c5bc6207バッチ側
「status/filter」 APIをNodeから使用する
今回はNode.jsのライブラリの「Twit」というものを使用した。
https://github.com/ttezel/twitこのような処理で簡単に使える。便利。
// 左下・右上 の緯度経度で範囲を指定したもの const area_points = [ // 北海道 139.317626, 41.356195, 149.08, 45.802496, // 本州・四国 132.194566, 32.642942, 143.005085, 41.366301, // 九州 128.562011, 30.20087, 132.130459, 34.773467, // 沖縄 123.367657, 23.840705, 130.48596, 30.20087, ]; const twitterConf = { consumer_key: '...', consumer_secret: '...', access_token: '...', access_token_secret: '...', timeout_ms: 60*1000, // optional HTTP request timeout to apply to all requests. strictSSL: true, // optional - requires SSL certificates to be valid. }; const T = new Twit(twitterConf); const stream = T.stream('statuses/filter', {locations: area_points}); stream.on('tweet', function (tweet) { // APIからデータが送られてくる度に、こちらの処理が呼び出される try { // DBへの書き込み処理等をここでやる console.log(tweet); } catch (e) { console.log(e); } });Webサーバー側
登録したデータをDBから取得して返すだけ。なんでもいい。
フロント側
GoogleMap上への表示方法について
今回はGoogle MapのinfoWindow上にTweetを表示した。
実際には「infobox」というライブラリを使っている。infoboxについては下記などを参考に。
https://github.com/googlemaps/v3-utility-library/tree/master/infobox
http://htmlpreview.github.io/?https://github.com/googlemaps/v3-utility-library/blob/master/infobox/docs/reference.html
https://liginc.co.jp/344083(ただ、指定した位置を中央にして表示する方法がわからなかったので、そのあたりをちょっと調整して使用している。)
データ取得・更新処理について
setInterval で数秒ごとにデータを取得し、最初はfadeinのCSSクラスをつけて表示させ、ある程度時間がたったらfadeoutのCSSクラスをつけて消していくようにした。
.tm-giw-fadein { -webkit-transition: opacity 1.0s; -moz-transition: opacity 1.0s; -ms-transition: opacity 1.0s; -o-transition: opacity 1.0s; transition: opacity 1.0s; opacity: 1; } .tm-giw-fadeout { -webkit-transition: opacity 1.5s; -moz-transition: opacity 1.5s; -ms-transition: opacity 1.5s; -o-transition: opacity 1.5s; transition: opacity 1.5s; opacity: 0; }以上です。令和もよろしくお願いします。
追記
- 投稿日:2019-04-30T18:22:51+09:00
Unityで使うランキング表示用サーバーを作る話
どうも洋梨?です。
ゲームによくある「スコアランキング」ですが、この機能を実装していくまでの話を簡単に書いていきます。
こちらも是非ご覧ください。環境
テスト環境としてXAMPP
データーベースはMySQL (MariaDB)
サーバーはNode.js
フロント側はUnityで行っています。
データーベースを構築する
今回構築するデーターベースは
id(プレイヤー のID) ,name (プレイヤー の名前) ,score (プレイヤーの点数) ,date(記録した日時)
を保存するものとします。
今回はテストとしてデーターをいくつか追加しておきます。なお、テーブル名は”RANK“としてます。
Node.jsでサーバーを構築
今回フレームワークとして Express を使用します。
なお、サーバーはポート番号:3000で待ち受け、 localhost:3000/Getにアクセスされ、データーの一覧をjson形式で返すものとします。
main.jsconst exp = require("express"); const app = exp(); app.listen(3000,function(){ console.log("Start Server."); }) const mysql = require('mysql'); app.get("/Get",function(req,res,nex){ let con = mysql.createConnection({ host : 'localhost', user : 'root', port : 3306, database : 'test' }); con.query("SELECT * FROM RANK",function(e,r){ console.log(r); response(res,"OK",r); }) }) function response(res,result,data){ let resJson = {status:result,data:data} res.json(resJson); }
実行した後、ブラウザなどでアクセスすると動いているのを確認できると思います。Unityでサーバから情報を受け取る
まずはUnity上でサーバーから受信できるかをテストします。
画面のGETボタンをクリックしたら受信をするようにします。
Test.cspublic void OnPushGET() { var req = WebRequest.Create("http://localhost:3000/Get"); var res = req.GetResponse(); using (Stream stm = res.GetResponseStream()) using(StreamReader str = new StreamReader(stm)) { string json = str.ReadToEnd(); Debug.Log(json); } }取得できている場合、ログに出力されると思います。
デシリアライズし、使える形式にする
JsonData.cs[Serializable] public class Json { public string status; public Data[] data; } [Serializable] public class Data { public string id; public string name; public int score; public string date; }シリアライズに使うクラス(構造)はこんな感じです。
サーバーからのリスポンス(JSON)はこのようなデーターですTest.cspublic void OnPushGET() { var req = WebRequest.Create("http://localhost:3000/Get"); var res = req.GetResponse(); using (Stream stm = res.GetResponseStream()) using(StreamReader str = new StreamReader(stm)) { string json = str.ReadToEnd(); Debug.Log(json); Json j = JsonUtility.FromJson<Json>(json); string s = "結果\n"; foreach (Data data in j.data) s += "[" + data.name + "] Score:" + data.score + "\n"; text.text = s; } }先ほど書いたソースコードを受信したJSONデーターを表示するように書き換えます。
成功するとこのように表示されます。
これでUnity上でサーバーから取得した情報が使えるようになりました。
データーベースに情報を登録する
次はUnityからサーバーにスコア(情報)を登録できるようにしていきたいと思います。
リクエスト構造(JSON)
今回サーバーに送信するリクエスト形式は次のようにしました。
JSON { id , action, name , score }
id : ユーザー識別用ID
action : 操作内容(例:追加 = ADD)
name : ユーザー名
score : スコアリクエストを処理する(サーバー側)
まずはNode.jsでリクエストを処理するためのプログラムを作成します。
今回は追加(ADD)リクエストが来たらとりあえず何も考えずデーターベースに登録するとします。
なお、リクエスト先は localhost:3000/post とします。main.jsapp.post("/Post",function(req,res,nex){ let b = req.body; console.log("POST Receive. ["+b+"]"); database_action(res,b.id,b.action,b.name,b.score); }) function database_action(res,id,action,name,score){ switch(action){ case "ADD": console.log("Run Query : INSERT INTO RANK VALUES("+id+","+name+","+score+",now())"); con.query("INSERT INTO RANK VALUES('"+id+"','"+name+"',"+score+",now())",function(e,r){ if(e) response(res,"ERROR",null); else response(res,"OK",null); }) break; } }リクエストを送信する(Unity)
テストデーターとして
{ id:”a003” , action:”ADD” , name:”MyUser” , score:300 }
を送信するとします。
なお、送信はPOSTで行います。Test.cspublic void OnPushPost() { var send = "{ \"id\":\"a003\" , \"action\" : \"ADD\" ,\"name\":\"MyUser\" ,\"score\": 300 }"; var bytes = System.Text.Encoding.UTF8.GetBytes(send); var req = WebRequest.Create("http://localhost:3000/Post"); req.Method = "POST"; req.ContentType = "application/json; charset=utf-8"; req.ContentLength = bytes.Length; req.Timeout = 3000; var reqStm = req.GetRequestStream(); reqStm.Write(bytes, 0, bytes.Length); var res = req.GetResponse(); using (Stream stm = res.GetResponseStream()) using (StreamReader str = new StreamReader(stm)) { string json = str.ReadToEnd(); Debug.Log(json); } Debug.Log("Send POST:" + bytes.Length); reqStm.Close(); res.Close(); }実行してみるとデーターベースに登録されているのがわかると思います。
↑ リクエスト送信前と送信後でデーターが追加されているのがわかる
送信後 /Get でリストを取得すると確かに登録されていることが確認できます。これでUnityからサーバーに登録もできるようになり、ランキング表示機能として使えるようになりました?
- 投稿日:2019-04-30T18:22:51+09:00
Unityで使うランキング表示用サーバーを作った話
どうも洋梨?です。
ゲームによくある「スコアランキング」ですが、この機能を実装していくまでの話を簡単に書いていきます。
こちらも是非ご覧ください。環境
テスト環境としてXAMPP
データーベースはMySQL (MariaDB)
サーバーはNode.js
フロント側はUnityで行っています。
データーベースを構築する
今回構築するデーターベースは
id(プレイヤー のID) ,name (プレイヤー の名前) ,score (プレイヤーの点数) ,date(記録した日時)
を保存するものとします。
今回はテストとしてデーターをいくつか追加しておきます。なお、テーブル名は”RANK“としてます。
Node.jsでサーバーを構築
今回フレームワークとして Express を使用します。
なお、サーバーはポート番号:3000で待ち受け、 localhost:3000/Getにアクセスされ、データーの一覧をjson形式で返すものとします。
main.jsconst exp = require("express"); const app = exp(); app.listen(3000,function(){ console.log("Start Server."); }) const mysql = require('mysql'); app.get("/Get",function(req,res,nex){ let con = mysql.createConnection({ host : 'localhost', user : 'root', port : 3306, database : 'test' }); con.query("SELECT * FROM RANK",function(e,r){ console.log(r); response(res,"OK",r); }) }) function response(res,result,data){ let resJson = {status:result,data:data} res.json(resJson); }
実行した後、ブラウザなどでアクセスすると動いているのを確認できると思います。Unityでサーバから情報を受け取る
まずはUnity上でサーバーから受信できるかをテストします。
画面のGETボタンをクリックしたら受信をするようにします。
Test.cspublic void OnPushGET() { var req = WebRequest.Create("http://localhost:3000/Get"); var res = req.GetResponse(); using (Stream stm = res.GetResponseStream()) using(StreamReader str = new StreamReader(stm)) { string json = str.ReadToEnd(); Debug.Log(json); } }取得できている場合、ログに出力されると思います。
デシリアライズし、使える形式にする
JsonData.cs[Serializable] public class Json { public string status; public Data[] data; } [Serializable] public class Data { public string id; public string name; public int score; public string date; }シリアライズに使うクラス(構造)はこんな感じです。
サーバーからのリスポンス(JSON)はこのようなデーターですTest.cspublic void OnPushGET() { var req = WebRequest.Create("http://localhost:3000/Get"); var res = req.GetResponse(); using (Stream stm = res.GetResponseStream()) using(StreamReader str = new StreamReader(stm)) { string json = str.ReadToEnd(); Debug.Log(json); Json j = JsonUtility.FromJson<Json>(json); string s = "結果\n"; foreach (Data data in j.data) s += "[" + data.name + "] Score:" + data.score + "\n"; text.text = s; } }先ほど書いたソースコードを受信したJSONデーターを表示するように書き換えます。
成功するとこのように表示されます。
これでUnity上でサーバーから取得した情報が使えるようになりました。
データーベースに情報を登録する
次はUnityからサーバーにスコア(情報)を登録できるようにしていきたいと思います。
リクエスト構造(JSON)
今回サーバーに送信するリクエスト形式は次のようにしました。
JSON { id , action, name , score }
id : ユーザー識別用ID
action : 操作内容(例:追加 = ADD)
name : ユーザー名
score : スコアリクエストを処理する(サーバー側)
まずはNode.jsでリクエストを処理するためのプログラムを作成します。
今回は追加(ADD)リクエストが来たらとりあえず何も考えずデーターベースに登録するとします。
なお、リクエスト先は localhost:3000/post とします。main.jsapp.post("/Post",function(req,res,nex){ let b = req.body; console.log("POST Receive. ["+b+"]"); database_action(res,b.id,b.action,b.name,b.score); }) function database_action(res,id,action,name,score){ switch(action){ case "ADD": console.log("Run Query : INSERT INTO RANK VALUES("+id+","+name+","+score+",now())"); con.query("INSERT INTO RANK VALUES('"+id+"','"+name+"',"+score+",now())",function(e,r){ if(e) response(res,"ERROR",null); else response(res,"OK",null); }) break; } }リクエストを送信する(Unity)
テストデーターとして
{ id:”a003” , action:”ADD” , name:”MyUser” , score:300 }
を送信するとします。
なお、送信はPOSTで行います。Test.cspublic void OnPushPost() { var send = "{ \"id\":\"a003\" , \"action\" : \"ADD\" ,\"name\":\"MyUser\" ,\"score\": 300 }"; var bytes = System.Text.Encoding.UTF8.GetBytes(send); var req = WebRequest.Create("http://localhost:3000/Post"); req.Method = "POST"; req.ContentType = "application/json; charset=utf-8"; req.ContentLength = bytes.Length; req.Timeout = 3000; var reqStm = req.GetRequestStream(); reqStm.Write(bytes, 0, bytes.Length); var res = req.GetResponse(); using (Stream stm = res.GetResponseStream()) using (StreamReader str = new StreamReader(stm)) { string json = str.ReadToEnd(); Debug.Log(json); } Debug.Log("Send POST:" + bytes.Length); reqStm.Close(); res.Close(); }実行してみるとデーターベースに登録されているのがわかると思います。
↑ リクエスト送信前と送信後でデーターが追加されているのがわかる
送信後 /Get でリストを取得すると確かに登録されていることが確認できます。これでUnityからサーバーに登録もできるようになり、ランキング表示機能として使えるようになりました?
- 投稿日:2019-04-30T18:12:47+09:00
nodebrew install-binary latestを実行したときにFailed to create the fileが発生する場合
nodebrewのインストール後に最新版のnode.jsとmpnをインストールしようとしたときのエラー対応の備忘録です。
環境
macOS High Sierra(10.3.6)
nodebrew 1.0.1実行コマンド
- brew install nodebrew
hogePC:~ hoge$ brew install nodebrew Updating Homebrew... ==> Auto-updated Homebrew! Updated 2 taps (homebrew/core and homebrew/cask). ==> New Formulae 〜〜〜中略〜〜〜 ==> Summary ? /usr/local/Cellar/nodebrew/1.0.1: 8 files, 38.6KB, built in 6 seconds
- nodebrew -v
hogePC:~ hoge$ nodebrew -v nodebrew 1.0.1nodebrewのインストールは成功したようです。
- nodebrew install-binary latest
hogePC:~ hoge$ nodebrew install-binary latest Fetching: https://nodejs.org/dist/v12.1.0/node-v12.1.0-darwin-x64.tar.gz Warning: Failed to create the file Warning: /Users/hoge/.nodebrew/src/v12.1.0/node-v12.1.0-darwin-x64.tar.gz: No Warning: such file or directory 0.0% curl: (23) Failed writing body (0 != 1058) download failed: https://nodejs.org/dist/v12.1.0/node-v12.1.0-darwin-x64.tar.gzうーん、node.jsとnpmのインストールに失敗してますね。
原因と解決
brew install nodebrew を実行したときのメッセージを再度読んでみると以下のような記述がありました。
You need to manually run setup_dirs to create directories required by nodebrew: /usr/local/opt/nodebrew/bin/nodebrew setup_dirs Add path: export PATH=$HOME/.nodebrew/current/bin:$PATH手動でnodebrew setup_dirsを実行してね。との記載がありました。
なので、以下のコマンドを実行しました。hogePC:~ hoge$ cd /usr/local/opt/nodebrew/bin/ hogePC:bin hoge$ nodebrew setup_dirs再度、nodebrew install-binary latestを実行。
hogePC:~ hoge$ nodebrew install-binary latest Fetching: https://nodejs.org/dist/v12.1.0/node-v12.1.0-darwin-x64.tar.gz 〜〜〜中略〜〜〜〜 ######## 100.0% Installed successfully無事、インストール完了!
- 投稿日:2019-04-30T13:41:24+09:00
Cisco Jabberのチャット履歴を覗いてみる
Cisco Jabberとは
Jabberというと、XMPPの方が一般的かもしれません。
しかし、今回のターゲットはCisco Jabberの方です。企業で導入しているところもあると思います。Cisco Jabberは、インスタントメッセージ、音声/ビデオ通話、ボイスメッセージ、デスクトップ共有や会議などが出来る、とても優れたツールです。
ですが、1点だけ腑に落ちないところがあります。
それはチャット履歴がデフォルトでは自動保存されないところです。
(チャット履歴を個別に手動保存する機能はあります)過去のやり取りをさかのぼって調べたいということはよくあります。
一般的なメッセンジャーではよくある機能がなぜ標準で無いのかはわかりません。
Cisco Jabberにもその機能は有るのですが、電話機能とセットになった追加オプションで月額別料金が別途必要だそうです。チャット履歴の保存場所
Cisco Jabberは、チャットウィンドウを表示したときに過去の会話を表示させるために、1人毎に100件ほど過去の履歴を持っています。
保存場所は"C:\Users\[ユーザー名]\AppData\Local\Cisco\Unified Communications\Jabber\CSF\History\[アカウント名].db"です。
保存形式はSQLite3です。(テキストエディタで開いてみると、先頭が「SQLite format 3」となっているのでわかります)保存ファイルの形式
中を覗いてみると、以下のテーブルがあることが判りました。(バージョンによっては違うかも)
- filter_matches
- filter_view
- history_item
- history_message
- history_participant
- im_label
メッセージ履歴は、
history_messageにあります。
SQLite3が普通に使える人ならば、この状態でも十分かもしれませんね。
history_messageからは、誰がいつ何を送ったという情報が取得できるのですが、誰に送ったのかの情報はありません。
自分以外の発言ならば自分へ送っているので良いですが、自分の発言が誰に送ったのかはわかりません。
そこでhistory_participantを使います。ここには誰から誰への紐づきがあるのでこれで取得できます。送信者と受信者SELECT DATE ,SENDER ,JID AS RECEIVER ,PAYLOAD FROM HISTORY_MESSAGE A INNER JOIN HISTORY_PARTICIPANT B ON A.ITEM = B.ITEM AND A.SENDER <> B.JIDチャット履歴を参照するクライアントアプリ
というわけで、作ってみました。(自分用なので汚いです)
JavaScriptで書きたかったので、Electronで作っています。package.json{ "name": "jabberhistory", "version": "1.0.0", "description": "Cisco Jabber Chat History", "private": true, "main": "index.js", "scripts": { "start": "electron ." }, "keywords": [], "author": "KAJIKEN <kajiken@kajiken.jp> (http://kajiken.jp)", "license": "MIT", "dependencies": { "electron-search-dialog": "^0.2.2", "sqlite3": "^4.0.6" }, "devDependencies": { "electron-rebuild": "^1.8.4" } }index.jsconst electron = require("electron"); const sqlite = require("sqlite3"); const fs = require("fs"); const app = electron.app; const ipc = electron.ipcMain; const BrowserWindow = electron.BrowserWindow; // DBファイル取得 const basedir = `${ app.getPath("home") }\\AppData\\Local\\Cisco\\Unified Communications\\Jabber\\CSF\\History`; const config = {}; let mainWindow = null; app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } }); app.on("ready", () => { mainWindow = new BrowserWindow({ width: 800, height: 600, resizable: false, useContentSize: true }); mainWindow.loadURL(`file://${ __dirname }/index.html`); mainWindow.on("closed", () => mainWindow = null); }); // データベースアクセスサービス const das = { "connect": id => new sqlite.Database(config.dbfile), "execute": async (obj, connection) => { const db = connection || das.connect(); const exec = (obj, conn) => new Promise((resolve, reject) => conn.serialize(() => conn.all(obj.sql, obj.bind || {}, (err, res) => err ? reject(err) : resolve(res)))); try { return await exec(obj, db); } finally { if (!connection) { db.close(); } } } }; // 受信メソッド const method = { // データベース一覧取得 "get_database_list": async (e, obj) => e.sender.send("get_database_list", fs.readdirSync(basedir).filter(val => /\.db$/.test(val))), // データベース選択 "select_database": async (e, obj) => { config.dbfile = `${ basedir }\\${ obj }`; config.myaccount = obj.replace(/\.db$/, ""); method.get_chatlist(e, obj); }, // チャット履歴対象者の一覧を取得 "get_chatlist": async (e, obj) => e.sender.send("get_chatlist", await das.execute({ "sql": "SELECT SENDER FROM HISTORY_MESSAGE WHERE SENDER <> ? GROUP BY SENDER ORDER BY SENDER", "bind": [ config.myaccount ] })), // チャット履歴選択 "select_chatlist": async (e, obj) => e.sender.send("get_payload", { "data": await das.execute({ "sql": "SELECT DATE, SENDER, JID AS RECEIVER, PAYLOAD FROM HISTORY_MESSAGE A INNER JOIN HISTORY_PARTICIPANT B ON A.ITEM = B.ITEM AND A.SENDER <> B.JID AND (A.SENDER = ? OR B.JID = ?)", "bind": [ obj, obj ] }), "user": obj, "my": config.myaccount }), "chat_search": async (e, obj) => obj ? e.sender.send("get_payload", { "data": await das.execute({ "sql": "SELECT DATE, SENDER, PAYLOAD FROM HISTORY_MESSAGE WHERE PAYLOAD LIKE ?;", "bind": [ `%${ obj }%` ] }), "user": obj, "my": config.myaccount }) : undefined, }; // イベントバインド for (let key of Object.keys(method)) { ipc.on(key, method[key]); }index.html<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Cisco Jabber Chat History</title> <script> if (typeof module !== 'undefined') { window.__tempModuleExports__ = module.exports; module.exports = false; } </script> <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"></script> <script> if (window.__tempModuleExports__) { module.exports = window.__tempModuleExports__; window.__tempModuleExports__ = null; delete window.__tempModuleExports__; } </script> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.0/css/bootstrap.min.css"> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.1/css/all.css"> <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.0/js/bootstrap.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.1.2/handlebars.min.js"></script> <style> .balloon-right::before{ content: ''; position: absolute; display: block; width: 0; height: 0; right: -15px; top: 20px; border-left: 15px solid #d4edda; border-top: 15px solid transparent; border-bottom: 15px solid transparent; } .balloon-left::before{ content: ''; position: absolute; display: block; width: 0; height: 0; left: -15px; top: 20px; border-right: 15px solid #e2e3e5; border-top: 15px solid transparent; border-bottom: 15px solid transparent; } </style> <!-- プロファイル選択画面 --> <script id="profile" type='text/x-handlebars-template'> <div class="row my-3"> <div class="col text-center">データベースを選択してください。</div> </div> <div class="row"> <div class="col"> <div class="list-group"> {{#each .}} <button type="button" class="list-group-item list-group-item-action" onclick="ipcRenderer.send('select_database', '{{ . }}')">{{ . }}</button> {{/each}} </div> </div> </div> </script> <!-- 履歴選択画面 --> <script id="chatlist" type='text/x-handlebars-template'> <div class="row my-3"> <div class="col-1"> <button type="button" class="btn btn-sm" onclick="ipcRenderer.send('get_database_list')"><i class="fas fa-arrow-left"></i></button> </div> <div class="col-10 text-center">履歴を表示するユーザーを選択するかキーワードを入力してください。</div> </div> <div class="row"> <div class="col"> <form onsubmit="ipcRenderer.send('chat_search', $('#search').val()); return false;"> <div class="form-row align-items-center"> <div class="col my-1"> <input type="text" class="form-control" id="search" placeholder="検索"> </div> </div> </form> </div> </div> <div class="row"> <div class="col"> <div class="list-group"> {{#each .}} <button type="button" class="list-group-item list-group-item-action" onclick="ipcRenderer.send('select_chatlist', '{{ sender }}')">{{ sender }}</button> {{/each}} </div> </div> </div> </script> <!-- チャット内容表示画面 --> <script id="payload" type='text/x-handlebars-template'> <div class="row my-3"> <div class="col-1"> <button type="button" class="btn btn-sm" onclick="ipcRenderer.send('get_chatlist')"><i class="fas fa-arrow-left"></i></button> </div> <div class="col-10 text-center">{{ user }}</div> <div class="col-1"> <button type="button" class="btn btn-sm" onclick="dialog.openDialog()"><i class="fas fa-search"></i></button> </div> </div> <div class="row"> <div class="col"> {{#each data}} <div class="alert alert-{{#isMyChat sender ../my}}{{/isMyChat}} balloon-{{#isMyChat2 sender ../my}}{{/isMyChat2}} text-wrap text-break" role="alert"> {{ sender }}:<br> {{{ payload }}} <small>{{ get_date date }}</small> </div> {{/each}} </div> </script> <script type="text/javascript"> const electron = require("electron"); const SearchDialog = require("electron-search-dialog").default; const dialog = new SearchDialog(electron.remote.getCurrentWindow()); dialog.width = 470; dialog.height = 170; const ipcRenderer = electron.ipcRenderer; // 受信メソッド const method = { // データベース一覧受信 "get_database_list": (e, obj) => $("#main").html(Handlebars.compile($('#profile').html())(obj)), // チャット履歴一覧受信 "get_chatlist": (e, obj) => $("#main").html(Handlebars.compile($('#chatlist').html())(obj)), // チャット内容受信 "get_payload": (e, obj) => $("#main").html(Handlebars.compile($('#payload').html())(obj)) }; // イベントバインド for (let key of Object.keys(method)) { ipcRenderer.on(key, method[key]); } // onReady $(() => { // 日付フォーマット関数 Handlebars.registerHelper("get_date", val => { const date = new Date(val / 1000); return `${ date.getFullYear() }/${ ("0" + (date.getMonth() + 1)).slice(-2) }/${ ("0" + date.getDate()).slice(-2) } ${ ("0" + date.getHours()).slice(-2) }:${ ("0" + date.getMinutes()).slice(-2) }:${ ("0" + date.getSeconds()).slice(-2) }`; }); // 自分の発言かチェック Handlebars.registerHelper("isMyChat", (sender, my) => sender == my ? 'success' : 'secondary'); Handlebars.registerHelper("isMyChat2", (sender, my) => sender == my ? 'right' : 'left'); // DB一覧取得 ipcRenderer.send("get_database_list"); }); </script> </head> <body> <div class="container my-2"> <div id="main"></div> </div> </body> </html>参考
作ってから見つけましたが、DBファイルから内容を読み取ってHTMLに保存するスクリプトを作成された方がいますので、単純に保存したいだけならば、これを定期的に動かしておけば良さそう。
https://github.com/imrandomizer/jabbersync
- 投稿日:2019-04-30T12:57:09+09:00
Nodeバージョン管理のnを試してみる@mac
docker内でnpm使ってコンパイルしているのだが遅くて、macで行うことにした。
で必要になるのはnodeのバージョン管理。いろいろ調べたところnが良さそうだったので試してみる。
homebrewが入っていることが前提
nのインストール
~ $ brew install n ==> Downloading https://homebrew.bintray.com/bottles/n-3.0.2.mojave.bottle.tar.gz ######################################################################## 100.0% ==> Pouring n-3.0.2.mojave.bottle.tar.gz ? /usr/local/Cellar/n/3.0.2: 6 files, 30.2KB ~ $ n -V 3.0.2nodeのインストール
~ $ n latest install : node-v12.1.0 mkdir : /usr/local/n/versions/node/12.1.0 mkdir: /usr/local/n/versions/node/12.1.0: Permission denied Error: sudo required
/usr/local配下にインストールしようとするが管理者権限なく怒られる。
/usr/localにインストールしたくないのでドキュメントに書かれているようにN_PREFIXを設定.bash_profile# n export N_PREFIX="$HOME/.n" export PATH="$PATH:$N_PREFIX/bin"再度、最新版をインストール
~ $ n latest install : node-v12.1.0 mkdir : /Users/hoge/.n/n/versions/node/12.1.0 fetch : https://nodejs.org/dist/v12.1.0/node-v12.1.0-darwin-x64.tar.gz ######################################################################## 100.0% installed : v12.1.0 ~ $ node -v v12.1.0指定のバージョンをインストール
~ $ n 9.11.1 install : node-v9.11.1 mkdir : /Users/hoge/.n/n/versions/node/9.11.1 fetch : https://nodejs.org/dist/v9.11.1/node-v9.11.1-darwin-x64.tar.gz ######################################################################## 100.0% installed : v9.11.1 ~ $ node -v v9.11.1切り替え
~ $ n 12.1.0 ~ $ node -v v12.1.0無事切り替えらられる様になりました。
参考
- 投稿日:2019-04-30T12:57:09+09:00
macのNodeバージョン管理でnを試してみる
docker内でnpm使ってコンパイルしているのだが遅くて、macで行うことにした。
で必要になるのはnodeのバージョン管理。いろいろ調べたところnが良さそうだったので試してみる。
homebrewが入っていることが前提
nのインストール
~ $ brew install n ==> Downloading https://homebrew.bintray.com/bottles/n-3.0.2.mojave.bottle.tar.gz ######################################################################## 100.0% ==> Pouring n-3.0.2.mojave.bottle.tar.gz ? /usr/local/Cellar/n/3.0.2: 6 files, 30.2KB ~ $ n -V 3.0.2nodeのインストール
~ $ n latest install : node-v12.1.0 mkdir : /usr/local/n/versions/node/12.1.0 mkdir: /usr/local/n/versions/node/12.1.0: Permission denied Error: sudo required
/usr/local配下にインストールしようとするが管理者権限なく怒られる。
/usr/localにインストールしたくないのでドキュメントに書かれているようにN_PREFIXを設定.bash_profile# n export N_PREFIX="$HOME/.n" export PATH="$PATH:$N_PREFIX/bin"再度、最新版をインストール
~ $ n latest install : node-v12.1.0 mkdir : /Users/hoge/.n/n/versions/node/12.1.0 fetch : https://nodejs.org/dist/v12.1.0/node-v12.1.0-darwin-x64.tar.gz ######################################################################## 100.0% installed : v12.1.0 ~ $ node -v v12.1.0指定のバージョンをインストール
~ $ n 9.11.1 install : node-v9.11.1 mkdir : /Users/hoge/.n/n/versions/node/9.11.1 fetch : https://nodejs.org/dist/v9.11.1/node-v9.11.1-darwin-x64.tar.gz ######################################################################## 100.0% installed : v9.11.1 ~ $ node -v v9.11.1切り替え
~ $ n 12.1.0 ~ $ node -v v12.1.0無事切り替えらられる様になりました。
参考
- 投稿日:2019-04-30T12:08:04+09:00
ReduxのcreateStore()の処理を追う(Middleware有りの場合)
Node.js初心者がRedux勉強中です。よく分からなかったのでソースを読んでます。
前の記事では、"Middlewareを使わない場合"を書きました。この記事では"Middlewareを使う場合"を書きます。
この記事で解説する流れは、呼び出し元がこんな風に呼び出している場合を想定しています。
index.js// stateの初期状態無し let store = createStore(reducerA, applyMiddleware( middlewareA, middlewareB )); // stateの初期状態あり let store = createStore(reducerA, preloadedState, applyMiddleware( middlewareA, middlewareB ));applyMiddlewareの第1段階
まず最初にapplyMiddleware.jsのapplyMiddleware()を呼び出します。この関数は「関数を返す関数」なので、関数を返します。
applyMiddleware.jsexport default function applyMiddleware(...middlewares) { return createStore => (...args) => { } }applyMiddleware()の返す関数は、「createStoreを引数に取り、「...argsを引数に取って何かしらを返す関数」を返す関数」です。日本語にするとややこしいです。このへんを上手に表現する良い方法は無いのでしょうか?
createStore()の処理
reducerとapplyMiddleware()で作成した関数(=enhancer)がcreateStore()に入ってきます。
引数の整理と型チェックとenhancerの実行
ここではすこし「泥臭く」、引数のチェックと入れ替えを行っています。
createStore()の引数のうち、preloadedStateとenhancerは省略されてる場合があります。preloadedStateが省略されてenhancerが有る場合、preloadedStateの位置にenhancerが格納されているので、これを入れ替えています。
最後に、enhancerを呼び出し、結果を返しています。createStore.jsexport default function createStore(reducer, preloadedState, enhancer) { if ( (typeof preloadedState === 'function' && typeof enhancer === 'function') || (typeof enhancer === 'function' && typeof arguments[3] === 'function') ) { throw new Error( 'It looks like you are passing several store enhancers to ' + 'createStore(). This is not supported. Instead, compose them ' + 'together to a single function' ) } if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') { enhancer = preloadedState preloadedState = undefined } if (typeof enhancer !== 'undefined') { if (typeof enhancer !== 'function') { throw new Error('Expected the enhancer to be a function.') } return enhancer(createStore)(reducer, preloadedState) }enhancer(createState)の処理内容
enhancerはapplyMiddleware()の戻り値である関数です。
applyMiddleware.jsexport default function applyMiddleware(...middlewares) { return createStore => (...args) => {↑の中で、enhancerは"createStore => (...args) => {}"部分、createStoreはcreateStore部分、reducerとpreloadedStateは...args部分に対応しています。
createStateは、呼び出し元から渡ってきた変数ですが、大本ではcreateState.jsのcreateState()関数を指定しています。
enhancer(createState)は結果として「...argsを引数として取り何かを返す関数」を返しています。enhancer(createStore)(reducer, preloadedState)の処理内容
ここではStoreオブジェクトの生成と、Middlewareの初期化(?)とチェインの作成し、Storeオブジェクトのdispatch関数を上書きしたものを返します。
applyMiddleware.jsconst store = createStore(...args) let dispatch = () => { throw new Error( `Dispatching while constructing your middleware is not allowed. ` + `Other middleware would not be applied to this dispatch.` ) } const middlewareAPI = { getState: store.getState, dispatch: (...args) => dispatch(...args) } const chain = middlewares.map(middleware => middleware(middlewareAPI)) dispatch = compose(...chain)(store.dispatch) return { ...store, dispatch }まず
const store = createStore(...args)にてstoreオブジェクトが生成されます。ここの..argsは呼び出し元のreducer, preloadedStateなので、ReduxのcreateStore()の処理を追う(Middlewareを使わない場合)の処理に入っていきます。面白い事に、最初dispatchには「エラーを飛ばす関数」が割り当てられています。チェインを作成した後、チェインを作成した後に、その先頭をdispatchに上書きしています。
この辺不要なんじゃないか?とは思うのですが、何か理由がありそうです。また別の機会に調べようと思います。また、
dispatch = compose(...chain)(store.dispatch)ここの理解も大変でした。結果としては「actionを受け取りnext(action)で次に渡っていく関数のチェイン」が出来上がるのですが、composeとMiddlewareの書き方両方でどう動くのかを理解するまで時間がかかりました。このへんもそのうち別記事で書きたいと思います。最後のreturnですが、storeオブジェクトの各要素を展開した上で、dispatchは別のものに書き換えています。これによって、store.dispatch()の再に、まずMiddleware層が動き、その後にcreateStore()で生成されたdispatch関数にたどり着くようになります。
このretuenで戻されるstoreオブジェクトが、呼び出し元まで戻っていきます。
結果どのような形のオブジェクトが生成されるのか
createStore()で生成されるstoreオブジェクトのうち、dispatch関数が、applyMiddlewareで組み立てられたチェインの先頭に置き換わります。
applyMiddlewareは各Middlewareの中でnext(action)を呼び出して次に行きますが、最後にはcreateStore()で生成されるdispatchにたどり着きます。
- 投稿日:2019-04-30T10:40:05+09:00
Web Audio API で取得したユーザ音声を Node.js でリアルタイム処理
Web Audio API を使用して録音されるユーザの音声をリアルタイムにサーバに送信し、音声認識などの処理を逐次行うケースを想定しています。
今回作成したソースは GitHub に置いてあります。
動作確認環境
- macOS Mojave
- Node.js 11.6.0
- Express.js 4.16.4
- Google Chrome 73
1. 必要なパッケージのインストール
はじめに今回使用するパッケージのインストールを行います。
サーバとして Express を、サーバ・クライアント間の通信に socket.io を、最後に受信したバッファを WAVE ファイル形式で保存する為に wav-encoder を使用しています。$ npm init $ npm install --save express $ npm install --save socket.io $ npm install --save socket.io-client $ npm install --save wav-encoder2. サーバ側の実装
はじめに HTTP サーバを起動します。
なお、localhost 以外からサーバにアクセスする場合には HTTPS が必要となりますのでご注意ください。const express = require('express') const http = require('http') const path = require('path') const app = express() app.use('/', express.static(path.join(__dirname, 'public'))) server = http.createServer(app).listen(3000, function() { console.log('Example app listening on port 3000') })次に、WebSocket サーバを起動し、クライアントが接続したときの処理を記述します。今回はクライアントから受け付けるメッセージとして以下の3種類を定義します。
メッセージ 説明 start 録音開始の合図。サンプリング周波数を受け取る。 send_pcm 録音中のPCMデータを受け取るAPI。 stop 録音停止の合図を受け取るAPI。受信した音声を public/wav の下に保存する。 今回の例では省略していますが、リアルタイムに音声認識などの処理を行いたい場合には
send_pcmの中で行うことになります。app.js// WebSocket サーバを起動 const socketio = require('socket.io') const io = socketio.listen(server) // クライアントが接続したときの処理 io.on('connection', (socket) => { let sampleRate = 48000 let buffer = [] // 録音開始の合図を受け取ったときの処理 socket.on('start', (data) => { sampleRate = data.sampleRate console.log(`Sample Rate: ${sampleRate}`) }) // PCM データを受信したときの処理 socket.on('send_pcm', (data) => { // data: { "1": 11, "2": 29, "3": 33, ... } const itr = data.values() const buf = new Array(data.length) for (var i = 0; i < buf.length; i++) { buf[i] = itr.next().value } buffer = buffer.concat(buf) }) // 録音停止の合図を受け取ったときの処理 socket.on('stop', (data, ack) => { const f32array = toF32Array(buffer) const filename = `public/wav/${String(Date.now())}.wav` exportWAV(f32array, sampleRate, filename) ack({ filename: filename }) }) })クライアントから送られてくる PCM データは以下のオブジェクトの形式になっているため、配列に変換して
bufferに格納しています。{ "1": 11, "2": 29, "3": 33, ... }WAVE ファイルへの書き出しは WavEncoder を使用しています。WavEncoder へ渡すデータは Float32Array である必要があるため、下記の関数で変換を行っています。
app.js// Convert byte array to Float32Array const toF32Array = (buf) => { const buffer = new ArrayBuffer(buf.length) const view = new Uint8Array(buffer) for (var i = 0; i < buf.length; i++) { view[i] = buf[i] } return new Float32Array(buffer) }app.jsconst WavEncoder = require('wav-encoder') const fs = require('fs') // data: Float32Array // sampleRate: number // filename: string const exportWAV = (data, sampleRate, filename) => { const audioData = { sampleRate: sampleRate, channelData: [data] } WavEncoder.encode(audioData).then((buffer) => { fs.writeFile(filename, Buffer.from(buffer), (e) => { if (e) { console.log(e) } else { console.log(`Successfully saved ${filename}`) } }) }) }3. クライアント側の実装
はじめにサンプリング周波数を
startを指定してサーバに送信します。次に、
getUserMediaを使用してマイクにアクセスします。アクセス後はprocessor.onaudioprocessにコールバック関数を指定することで、リアルタイムに音声を取得し、send_pcmを指定してサーバに送信しています。public/javascripts/client.jsconst socket = io.connect() let processor = null let localstream = null function startRecording() { console.log('start recording') context = new window.AudioContext() socket.emit('start', { 'sampleRate': context.sampleRate }) navigator.mediaDevices.getUserMedia({ audio: true, video: false }).then((stream) => { localstream = stream const input = this.context.createMediaStreamSource(stream) processor = context.createScriptProcessor(4096, 1, 1) input.connect(processor) processor.connect(context.destination) processor.onaudioprocess = (e) => { const voice = e.inputBuffer.getChannelData(0) socket.emit('send_pcm', voice.buffer) } }).catch((e) => { // "DOMException: Rrequested device not found" will be caught if no mic is available console.log(e) }) }録音終了時には
getUserMediaを停止させ、サーバに対してstopを送信しますpublic/javascripts/client.jsfunction stopRecording() { console.log('stop recording') processor.disconnect() processor.onaudioprocess = null processor = null localstream.getTracks().forEach((track) => { track.stop() }) socket.emit('stop', '', (res) => { console.log(`Audio data is saved as ${res.filename}`) }) }最後にテスト用として録音の開始・停止ボタンのみの画面を作成しておきます。
public/index.html<!DOCTYPE html> <html> <head> <meta charset="utf-8"/> <script src="/socket.io/socket.io.js" ></script> <script src="./javascripts/client.js" ></script> </head> <body> <button onClick="startRecording()">start</button> <button onClick="stopRecording()">stop</button> </body> </html>4. 動作確認
以下のコマンドでサーバを起動してブラウザから
localhost:3000にアクセスして、"start" ボタンをクリックしてマイクへ音声を入力後 "stop" ボタンをクリックすると、public/wavフォルダ内に WAVE ファイルが保存されます。$ node app.js
- 投稿日:2019-04-30T07:52:19+09:00
now v2でのtextlint読み込みエラー 2019/04 現在
now-textlint-test$ ll index.js node_modules/ now.json package-lock.json package.jsonpackage.json{ "name": "now-textlint-test", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "ISC", "dependencies": { "textlint": "^11.2.3" } }now.json{ "version": 2, "name": "now-textlint-test", "builds": [{ "src":"index.js", "use":"@now/node", "config": { "maxLambdaSize": "40mb" } }], "alias":"texttest.now.sh" }index.jsconst { parse } = require('url') module.exports = (req, res) => { const { query } = parse(req.url, true) const { name = 'World' } = query //const pkgConf = require("read-pkg-up"); //console.log(pkgConf.sync({ cwd: __dirname })) const TextLintEngine = require('textlint').TextLintEngine; const engine = new TextLintEngine(); res.end(`Hello ${name}! ${__dirname}`) }単純なサンプルにtextlintの読み込みを加えたもの。
アクセス結果のログが(
now log)2019-04-29T21:46:51.508Z TypeError: Cannot read property 'version' of undefined at Config.get [as hash] (/var/tmp/1daede63/node_modules/textlint/lib/textlint/src/config/config.js:295:56) at new CacheBacker (/var/task/index.js:19028:36) at TextLintEngine.AbstractTextLintEngine (/var/task/index.js:44961:26) at new TextLintEngine (/var/task/index.js:33699:47) at Server.module.exports.module.exports (/var/task/index.js:5626:18) at emitTwo (events.js:126:13) at Server.emit (events.js:214:7) at parserOnIncoming (_http_server.js:619:12) at HTTPParser.parserOnHeadersComplete (_http_common.js:115:23)関係ないが、このエラーによりレスポンスは
502: An error occurred with your deployment Code: NO_STATUS_CODE_FROM_LAMBDA (more)に。
get hash() { const pkgConf = require("read-pkg-up"); const version = pkgConf.sync({ cwd: __dirname }).pkg.version; const toString = JSON.stringify(this.toJSON()); return md5(`${version}-${toString}`); }ここかな?ローカルのjsならば
Object.defineProperty(Config.prototype, "hash", { /** * Return hash string of the config and textlint version * @returns {string} */ get: function () { var pkgConf = require("read-pkg-up"); var version = pkgConf.sync({ cwd: __dirname }).pkg.version; var toString = JSON.stringify(this.toJSON()); return md5(version + "-" + toString); }, enumerable: true, configurable: true });
index.jsでは__dirnameは参照できるし、console.log(pkgConf.sync({ cwd: __dirname }))は自作のrootのpackage.jsonの内容が出力されるので、
ビルドの問題か不明。now側でyarnを使ったbuildをせずにローカルのnode_modulesをアップロードできないか調べてみたが、うまくヒットしない。
昔はwebpack時点でのエラーだった記憶があるので、これもnow側のバージョンアップでまた変化があるかな。
去年試したときは今回とは別のエラーだった気がするが記録がないので、今回はまた半年後ぐらいに再挑戦するときの参照情報として残しておく。
- 投稿日:2019-04-30T03:53:27+09:00
Cloud Functions (Node.js 8) 等を Terraform で立ててサーバレスにGCPの課金額をSlackに自動通知する
背景
GCP を使っていると、単純に忘れたり、認識していないリソースがあったり、リソースの落とし忘れで
いつの間にか課金されていることが多い。
意識しなくても把握できるように自動通知したい。現状、Cloud Billing のアラートは閾値をこえたときに発報されるルールであるなど制限が多い。
利用者からすると、予算のある値を超えたときでは遅くて、
金額の傾向を知りたいので金額ベースでなく日次でほしい。概要
今回は Cloud Functions をメインに使って、 GCP 課金額を日次で Slack に通知できるようにする。
Cloud Functions を採用したのは、 GCP サービス内なので権限管理しやすく、
また無料枠もあってランニングコストを抑えられるからである。さらに、 Terraform と GitHub を構成管理に使うことで、そもそも何のリソースが何に使われているか把握しやすくする。
使用した Cloud Functions のソースコードは
https://github.com/iijimakazuyuki/GCPBillingReport
に、 Terraform のコンフィギュレーションは本文中に記載している。環境の説明
GCP 内のリソース管理は、今回は Terraform を使う。
GUI や CLI ベースだと手続き的になるので、手順が煩雑になりやすいためである。
Terraform を使うと、宣言的にリソースを記述できる。Terraform を使う上では、コンフィギュレーションファイルを GitHub で管理し、
Atlantis によるワークフローを適用する。Atlantis については、今回は深く触れない。
GitHub でプルリクエストを作ると Terraform を動かしてくれる人がいる、というイメージ。![]()
その人は、 GCP の強い権限を持っている前提で話を進める。今回使用するリソースは、次の図のようになる:
+---------------------- G C P ----------------------------+ | +-----------------+ | | | Cloud Scheduler | | | +-----------------+ | | | | | pub | | v | | +---------------+ | | | Cloud Pub/Sub | | | +---------------+ | | ^ | | | +---------------------------+ | +--------+ | sub | Cloud Source Repositories |---------------mirror------------->| GitHub | | | +---------------------------+ | +--------+ | | | | | | deploy | | | v | | +-----------------+ +----------+ +---------------+ | | | Cloud Functions |--query-->| BigQuery |<--export--| Cloud Billing | | | +-----------------+ +----------+ +---------------+ | +---------|-------------------------------------------------------------+ post \ v \ +-------+ +-----------------------+ +--------+ | Slack | | Terraform Atlantis |-----------| GitHub | +-------+ +-----------------------+ +--------+
- Cloud Functions の日次実行には Cloud Scheduler と Cloud Pub/Sub を用いる。
- Cloud Functions は GitHub 上のリポジトリを直接参照してデプロイはできず、 Google Source Repositories によるミラーリングリポジトリを介することになる。
ステップ0: 必要な環境を準備する
- Slack に Incoming Webhook を作る。
- GitHub に Terraform 用コンフィギュレーションリポジトリを作る。
- Terraform 用コンフィギュレーションをプッシュして、プルリクエストを作成する。
次のようなコンフィギュレーションをプッシュする:
provider "google" { project = "<project_id>" region = "asia-northeast1" zone = "asia-northeast1-c" } resource "google_bigquery_dataset" "billing" { // 課金データエクスポート用の BigQuery データセット。 dataset_id = "billing" location = "asia-northeast1" } data "google_client_config" "current" {} resource "google_pubsub_topic" "billing" { // Cloud Functions を定期実行させるために使用する。 name = "billing_trigger" } resource "google_cloud_scheduler_job" "billing_report_trigger" { // Cloud Functions を定期実行させるために使用する。 name = "billing_trigger" description = "Trigger billing report" // 日本時間で朝9時。 schedule = "0 0 * * *" // 私のプロジェクトだと App Engine がどうこう言われて // asia-northeast1 に作れなかったので、 us-central1 を指定している。 // プロジェクトの設定によっては、わざわざ指定しなおさなくてよいかもしれない。 region = "us-central1" pubsub_target { topic_name = "${google_pubsub_topic.billing.id}" data = "${base64encode("BillingReportTriggered")}" } } resource "google_service_account" "billing_report" { // Cloud Functions 用のサービスアカウント。 account_id = "billing-report" } resource "google_project_iam_binding" "billing_report_bigquery_data_viewer" { // Cloud Functions 用のサービスアカウントの権限。 role = "roles/bigquery.dataViewer" members = ["serviceAccount:${google_service_account.billing_report.email}"] } resource "google_project_iam_binding" "billing_report_bigquery_user" { // Cloud Functions 用のサービスアカウントの権限。 // クエリ打つだけならなくてもよさそうだが、 NodeJS クライアントライブラリの実行に必要な模様。 role = "roles/bigquery.user" members = ["serviceAccount:${google_service_account.billing_report.email}"] }Atlantis はプルリクエストを作成すると
terraform planを実行してくれ、
atlantis applyとコメントするとが実際に適用されて便利だが、
ともかく、terraform applyまで実行できて、上記リソースが作成済みになった、とする。Terraform で作成したサービスアカウント
billing_reportの秘密鍵は開発に必要なので、
サービスアカウント画面 から作成し、ダウンロードする。課金データのエクスポートは Terraform では設定できないようなので、 GUI から操作する。
メニューから「お支払い」、「課金データのエクスポート」と辿り、
課金データを BigQuery にエクスポートする設定で、
Terraform で作成したデータセットbillingを選択すればよい。実際に課金データがエクスポートされるまで時間がかかるようなので、1日ゲームでもして過ごす。
ステップ1: ローカルで作ってみる
今回は私の好みで Node.js ランタイムを使用することにした。
セットアップ方法を参考に、Node.js の開発環境は準備済みとする。まずはディレクトリを切って、
npm initを実行し、適当に答えつつ Enter を連打する。
BigQuery を使用するので、インストール:npm install @google-cloud/bigqueryこのクライアントライブラリは、先ほどダウンロードした秘密鍵を環境変数でパス指定すれば認証が通る。
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.jsonテストを Mocha で書きたいので、インストール:
npm install --save-dev mochaCloud Functions では、実行する関数を指定する。
開発中はテストを書いて実行するのがよいだろう。例えば
package.json記載のエントリーポイントがindex.jsで、
mainという名前の関数を Cloud Functions から指定するなら、index.jsfunction main() { console.log("hello"); } module.exports.main = main;test/test_index.jsconst index = require('../index'); describe('index', function () { this.timeout(10000); describe('#main()', function () { it('should be executed without exceptions', function () { index.main(); }); }); });を作り、
npm testとすれば
mainを実行させられる。今回は BigQuery にクエリを発行するところと Slack の Incoming Webhook を叩くところでクラスを分け、
billing_reporter.jsconst { BigQuery } = require('@google-cloud/bigquery'); const query = billingAccountId => ` SELECT invoice.month, SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS total, (SUM(CAST(cost * 1000000 AS int64)) + SUM(IFNULL((SELECT SUM(CAST(c.amount * 1000000 as int64)) FROM UNNEST(credits) c), 0))) / 1000000 AS total_exact FROM \`billing.gcp_billing_export_v1_${billingAccountId}\` GROUP BY 1 ORDER BY 1 ASC ; `; // cf. https://cloud.google.com/billing/docs/how-to/bq-examples?hl=ja const billingReport = data => data[0].map( row => `month:${row.month} total:${row.total} total_exact:${row.total_exact}` ).join('\n'); class BillingReporter { constructor(projectId, billingAccountId) { this.bigquery = new BigQuery({ projectId: projectId }); this.billingAccountId = billingAccountId; } query() { return this.bigquery.query({ query: query(this.billingAccountId), useLegacySql: false, }).then(data => new Promise( resolve => resolve(billingReport(data)) )); } } module.exports = BillingReporter;slack_webhooker.jsconst https = require('https'); const { URL } = require('url'); class SlackWebhooker { constructor(webhookUrl) { this.webhookUrl = new URL(webhookUrl); } post(text) { return new Promise((resolve, reject) => { const request = https.request({ method: 'POST', hostname: this.webhookUrl.hostname, path: this.webhookUrl.pathname, headers: { 'Content-Type': 'application/json' }, }, response => { let rawData = ''; response.on('data', (chunk) => { rawData += chunk; }); response.on('end', () => { if (response.statusCode === 200) resolve(rawData); else reject(rawData); }); }); request.write(JSON.stringify({ text: text })); request.on('error', reject); request.end(); }); } } module.exports = SlackWebhooker;プロジェクト ID と課金アカウント ID 、 Incoming Webhook の URL を環境変数から指定するようにした。
課金アカウント ID は、xxxxxx-xxxxxx-xxxxxxのような形式をしていて、
課金データエクスポート先のテーブル名ではハイフンがアンダースコアとなっているので、置換している。index.jsconst BillingReporter = require('./billing_reporter'); const SlackWebhooker = require('./slack_webhooker'); const PROJECT_ID = process.env.PROJECT_ID; const BILLING_ACCOUNT_ID = process.env.BILLING_ACCOUNT_ID.replace(/-/g, '_'); const WEBHOOK_URL = process.env.WEBHOOK_URL; function main() { const billingReporter = new BillingReporter(PROJECT_ID, BILLING_ACCOUNT_ID); const slackWebhooker = new SlackWebhooker(WEBHOOK_URL); return billingReporter.query().then( result => slackWebhooker.post(result) ).then(console.log).catch(console.error); } module.exports.main = main;記事用に、なんとなく雰囲気が伝わるように載せているだけであり、
全体のソースコードは https://github.com/iijimakazuyuki/GCPBillingReport にある。ステップ2: デプロイする
Google Source Repositories によるミラーリングリポジトリを用意してから、デプロイする。
- GitHub に Cloud Functions 用リポジトリを作る。
- ソースコードをプッシュする。
- 1.0.0などタグを付与する。
- Google Source Repositories で上記リポジトリを複製する。
github_<user_name>_<project_name>のような名前になる。- Terraform 用コンフィギュレーションをプッシュして、プルリクエストを作成する。
次のようなコンフィギュレーションを追加してプッシュする:
variable "billing_account" { // 課金データエクスポート先のテーブル名に使用される課金アカウントID。 // Cloud Functions からテーブル名を特定するために使用する。 default = "<billing_account_id>" } resource "google_cloudfunctions_function" "billing_report" { name = "billing_report" available_memory_mb = 128 runtime = "nodejs8" entry_point = "main" source_repository { url = "https://source.developers.google.com/projects/${data.google_client_config.current.project}/repos/<repository_name>/fixed-aliases/<tag>/paths/" } service_account_email = "${google_service_account.billing_report.email}" event_trigger { event_type = "providers/cloud.pubsub/eventTypes/topic.publish" resource = "${google_pubsub_topic.billing.name}" } environment_variables = { PROJECT_ID = "${data.google_client_config.current.project}" BILLING_ACCOUNT_ID = "${var.billing_account}" } }これで、Cloud Scheduler が実行される毎日朝9時に Slack に次のようなメッセージが投稿される。
month:201904 total:0 total_exact:0・・・無料枠しか使っていなかったので0だが、ちゃんと表示されるはず。
なお、朝9時を待たずとも、 Cloud Scheduler の画面から実行すれば、その時点でメッセージを投稿させられる。
ステップ3: 機能改善
機能を追加したいとか、メッセージをもうちょっと丁寧にしたいとかで、アップデートしたい場合は、次のような手順を踏む。
- ソースコードをプッシュする。
- 1.1.0などタグを付与する。
- Terraform 用コンフィギュレーションをプッシュして、プルリクエストを作成する。
下記
<tag>を更新して、applyすればよい。source_repository { url = "https://source.developers.google.com/projects/${data.google_client_config.current.project}/repos/<repository_name>/fixed-aliases/<tag>/paths/" }まとめ
GCP の課金額を意識せずとも把握できるよう、日次で Slack に通知されるようにした。
- その際、 Cloud Functions を中心としたサーバレス構成とすることで、通知システム自体のランニングコストを抑えた。
- Terraform (+ Atlantis) と GitHub を構成管理に使うことで、システムに使用しているリソースを把握しやすくした。
付録:躓いたところ
- Terraform のデバッグ方法 を知らず苦戦した。
- 初めに
applyに失敗したときBadRequestとだけ表示されるので何が悪いのかわからなかった。DEBUGログを取ると、リクエスト・レスポンスの内容が分かるようになる。export TF_LOG=DEBUGとexport TF_LOG_PATH=$PWD/tf.logを指定するとよいだろう。- Terraform は、コンフィギュレーションを GCP の API に適当に変換してくれているようなのだが、 そのロジックが分からず、
event_triggerに PubSub のnameを入れるのかidを入れるのかで延々とミスり続けた。
- 投稿日:2019-04-30T01:37:03+09:00
GoogleカレンダーAPIを用いてカレンダー一覧を取得する
はじめに
いよいよ平成最後の日になりました。GoogleカレンダーのAPIを使う機会があったので、Google Calendar APIに関する記事を投稿します。
前提
- Googleの開発者アカウントが作成済みであること
- Calendar APIを有効化して、APIキーの取得ができていること
やりたいこと
- Googleカレンダーで所有しているカレンダーの一覧を取得する(共有カレンダー含む)
一つ一つのカレンダーにIDが割り振られているため、動的にカレンダーの一覧を取得して、その中から特定のカレンダーを指定して操作することが可能になります。
実装
本記事では、カレンダーの一覧を取得してログに出力するための実装を記載します。
getAllCalendarList.js'use strict'; const url = require('url'); const https = require('https'); const HTTP_RESPONSE_OK = 200; const ACCESS_TOKEN = 'API実行に必要なアクセストークン'; // カレンダー一覧取得のためのAPI let options = url.parse("https://www.googleapis.com/calendar/v3/users/me/calendarList"); options.method = "GET"; options.headers = { "Authorization":"Bearer " + ACCESS_TOKEN, }; let req = https.request(options, (res) => { let data = ""; res.setEncoding("utf8"); res.on("data", (d) => { data += d; }); res.on("end", () => { if (res.statusCode === HTTP_RESPONSE_OK) { let resBody = JSON.parse(data); // カレンダー一覧が存在する場合に、一覧をコンソールログに出力 if(resBody.items) { // 取得するカレンダーリストの最後2つは「誕生日」と「日本の祝日」カレンダーのため無視する for(let i=0, len=resBody.items.length; i < len-2; i++) { console.log(`${(i+1)}:${resBody.items[i].summary}`); } } } else { // エラー(HTTPレスポンスコード200以外) console.log('HTTPレスポンスコード : ' + res.statusCode); } }); }); req.on("error", (e) => { console.log('エラー発生'); }); req.end();おわりに
GoogleカレンダーAPIだけでもさまざまな機能が提供されています。Google開発者コンソールではAPIに必要なパラメータを入力して実行する環境が提供されているので試しながら実装を進められました。またGoogleAPI関連の投稿ができればと思います。
それではまた令和の時代に。。。











