Quantcast
Channel: Node.jsタグが付けられた新着記事 - Qiita
Viewing all 8833 articles
Browse latest View live

【Node.js】NW.jsでnodeなしで動くアプリを作りたい!

$
0
0

大幅に遅れましたが、この記事は 株式会社ピーアールオー(あったらいいな!を作ります) Advent Calendar 2019の19日目の記事です。

はじめに

最近、お仕事でもNode.jsを使うことが増えてきました。
JSベースなので結構雑にツールを作れてありがたいのですが、そのツールを誰かに連携する時に相手方もNode.jsを入れてもらう必要があって連携は中々ハードル高めです。
という訳で、今回はWindowsでNode.jsで作成したものをスタンドアロンアプリとして出力できるNW.jsを触ってみました。

実行環境

OS: Windows 10 Pro v1903
node.js: v10.13.0
NW.js: 0.43.2
NW-Builder: 3.5.7

導入手順…と言いたいところですが

NW.jsの導入についてはQiita上にかなりしっかりまとめていただいている記事が存在します。

NW.jsでデスクトップアプリの夢を見る!

大まかな流れは上記通りなので、細かい手順は省略して、実際に触ってみて躓いたりしたところをまとめてみます。

まずはNW.jsを動か…せない!

もう新しいものに触ると必ず起こる現象ですね。
今回は以下を通らせるために苦戦しました。

nw ./src

当然といえば当然ですが、npm install --saveでインストールしたモジュールにはパスが通っていません。

nwjs, nw-builderの各実行ファイルはプロジェクト直下の./node_modules/.bin/配下にインストールされてますので、
実際に実行する場合はパスを指定して叩く必要があります。
例えば、プロジェクトフォルダ直下で実行する場合は

NW.js
./node_modules/.bin/nw

nw-builder
./node_modules/.bin/nwbuilder

という感じですね。global installしてしまえば解決しちゃいますが…。

package.jsonのscriptに実行エイリアスを書く場合は、プロジェクト直下のpackage.jsonに記載する必要があります。
また、mainをsrc/.../index.htmlのように直下からの相対パスに直しておきましょう。

よく見るおすすめ構成ではpackage.jsonが二重になっているため、そこ起因で変な事が起きやすいです。
上手くコマンドが通らないときはまずpackage.jsonを疑ってみるのが良さそうです。

沈黙を保つDevTools

NW.jsはChromiumベースなので、Chromiumで使用できるDevToolがそのまま使えるとのこと。
F12を押せばお馴染みのDevToolが…出てこない!

NW.jsではDevToolsさんは通常のNW.jsをインストールしただけでは働いてくれません。(2019/12時点)

https://github.com/nwjs/nw.js/issues/4383
上記issueで回答されているように、DevToolsを使用したい場合は以下のコマンドを使用してSDKモードでインストールする必要があります。

npm install nw --nwjs_build_type=sdk

ちなみに、このモードのままビルドを実施すると出力されたアプリでもDevToolsが利用可能になります。
出力されたアプリでDevToolsを使われたくない場合はビルド前に切り替えておきましょう。

ビルド時に失踪するNPMパッケージ

開発環境で動作確認OK!ビルドも正常完了!当然、出力されたアプリも動かない!ヨシ!…あれ?

こういう時は落ち着いてSDKモードで再度アプリを出力してDevToolsを起動してみましょう。
今回の場合は以下のエラーが発生していました。

Uncaught Error: Cannot find module '(モジュール名)'

なぜか開発環境では動いていたはずモジュールが失踪してしまってますね…。
この場合、疑うべきはNPMパッケージのインストール場所です。

先ほど紹介した手順でプロジェクトを作成した場合、大体以下のプロジェクト構成になっていると思われます。

/project
  + /node_modules
  + /src
    + (index.js などなど)
    + package.json
  + package.json

このプロジェクト構成では/project直下は、NW.jsでbuildするために必要な外殻であり、
動かしたいプロジェクトの実体は/src配下です。

ここまで言えばもうわかる方も多いと思いますが、今回のケースでは/project 直下でNPMインストールをしてました。
nw-builderでビルドするのは当然/src配下なのでビルド後のアプリには./project/node_modulesに含まれてるNPMパッケージは含まれていません。そのため、今回の事象が発生しました。でもやりたくなるじゃん!

正しく動かすためには、npm installは、/src配下で実施する必要があります。
(package.jsonが/src配下にあるんですから当然と言えば当然ですが…。思い込みって怖い。)

MacOS向けのビルドが通らない

NW-builderではWindows, MaxOSX, Linux用のアプリをそれぞれ出力することが可能ですが、
MaxOSX用のビルドは以下のエラーでこけます。

 UnhandledPromiseRejectionWarning: Error: EPERM: operation not permitted

nw-builderのissueを確認する限り、どうやらWindows10のシンボリックリンクの権限の問題のようです。
https://github.com/nwjs-community/nw-builder/issues/463

即時解決は難しかったので、今回はdocker上のlinuxで動かすことで解決してしまいました…。やはりdockerはすべてを解決する

アプリケーションサイズが大きすぎる

これはそのままです。Chromiumを動かすためのドライバーがセットで入ることになるので、非常にサイズが大きくなります。
Hello Worldを出力するだけの簡単なWindowsアプリでどのくらい大きくなるかを確認してみると、

プロジェクト本体:108 B → 出力後:298 MB

これだけ膨れます。増えるワカメみたいですね。

地がこれだけ大きいので、重い事で有名なNPMパッケージをアプリ内に大量に取り入れるのはあまり得策ではなさそうです。
NPMパッケージをあまり使わない、小規模なツールを作成するために使用するのが向いていそうに思えます。

おわりに

色々躓いたり引っかかったところもありましたが、Node.jsの資産を活かしつつ相手の環境を縛らない上にUIまで作れちゃうNW.js、中々いい感じです。一方で、大きくなりがちなサイズやChromiumに依存している所を見ると、頒布向けというより内製ツール向けな印象も受けました。
とにかくプロジェクト作成から出力までが手軽なので、簡単な内製ツールで、UIが必要な場合などは使ってみるのどうでしょうか?


NodeでAPIを使わずにYouTubeLiveのチャットを取得する

$
0
0

本記事はVTuber Tech #1 Advent Calendar 2019の24日目の記事です。

YouTubeLiveのチャットを取得するやつを作った話

そういうことです。YouTube、クリスマスカラーっぽいでしょ。
非公式手法なので自己責任重点な。

TL;DR

作ったので使って、どうぞ。
youtube-chat
GitHub

何故作ったのか

自分で配信するようのコメビュを作っていろいろやろうと思い、最初は普通にAPIで取得すればいいやと考えていた。が、ウカツ!
毎秒取得したら数分でAPI上限に達してしまうではないか!ブッダシット!

使用技術

  • Node.js
  • TypeScript

使い方

  1. 普通にインストールする

    npm i youtube-chat
    
  2. require(import)する

    import{LiveChat}from'youtube-chat'
  3. チャンネルIDか、ライブIDを入力してインスタンス化

    // If channelId is specified, liveId in the current stream is automatically acquired.constliveChat=newLiveChat({channelId:'UCxkOLgdNumvVIQqn5ps_bJA?'})// Or specify LiveID in Stream manually.constliveChat=newLiveChat({liveId:'bc5DoKBZRIo'})
  4. イベント登録

    // Emit at start of observation chat.liveChat.on('start',(liveId:string)=>{})// Emit at end of observation chat.liveChat.on('end',(reason:string)=>{})// Emit at receive chat.liveChat.on('comment',(comment:CommentItem)=>{})// Emit when an error occursliveChat.on('error',(err:Error)=>{})

イベントをもうちょいkwsk

start

  • チャット読み取り開始時に呼び出し
  • 引数
    • liveId: string
      • 読み取り開始したライブのID

end

  • 読み取り終了時に呼び出し
  • 引数
    • reason: string
      • 終了した理由の文字列

comment

  • チャットがされた時に呼び出し
  • 引数
    • comment: CommentItem
      • チャットのデータ

error

  • エラー時に呼び出し
  • 引数
    • err: Error
      • エラーオブジェクト

コメントデータの型

interfaceCommentItem{id:stringauthor:{name:stringthumbnail?:ImageItemchannelId:stringbadge?:{thumbnail:ImageItemlabel:string}}message:MessageItem[]superchat?:{amount:stringcolor:number}membership:booleanisOwner:booleantimestamp:number}

※詳しくはReadMe重点な

使用例

  • エアスパチャ
    • チャットからコマンドを解析してアラートを鳴らす
  • 上記を始めとした簡単なチャット内コマンドの実装等

どうやってるのか

おまけとしてどうやってるか書こうと思う

コメント取得

  • チャットをポップアウトしたURLに&pbj=1を足してチャットのjsonを取得
  • addChatItemActionのitemをCommentItem型にパース
  • コメント1つごとにcommentイベントを発火
    • 以上をintervalの値(1000ms)ごとに繰り返す

チャンネルIDからライブ配信のURL特定

  • https://www.youtube.com/channel/(チャンネルID)/liveをGET
  • metaタグにあるサムネイル画像のURLからライブIDを特定

課題

  • パーサーしか自動テストされてないお酒飲みながら書いてたので無かったことになってた
    • CIとかやってない
  • コメント投稿や、アーカイブのチャットを取得等の機能
    • やる気次第

最後に

  • 初めてのプルリクに小躍りした
  • 感謝された
    • 一部の人には刺さるものを作れた気がして嬉しい
  • こいつを使って今度は自作コメビュを作る予定
    • MultiCommentViewerさんを超えたい

スペシャルサンクス

tsconfig の path alias 解決に tsconfig-paths/register を node で使う方法と TS 依存の分離方法

$
0
0

この記事は TypeScript アドベントカレンダー 2019の 24 日目です。

はじめに

Webpack 等でビルドせずに node で実行する際に tsconfig の path alias が解決されなくて困る方も多いと思います。
一方 ts-node じゃなくても tsconfig-paths/register で path alias が解決できることは意外と知られておらず、実は $ node -r tsconfig-paths/register dist/main.jsで解決します。
しかし、 Production で動く node に TypeScript 由来の何かに依存しているのは怖いということもあるので、 tsconfig-paths の中身を読んだので何をしているかを説明します。

サンプルプロジェクト構成

以下の構成で実行します。サンプルリポジトリは以下になります。

https://github.com/euxn23/how-tsconfig-paths-work-sample

$ tree ..├── package.json
├── src
│   ├── main.ts
│   └── path
│       └── to
│           └── nested
│               └── lib
│                   └── hello.ts
├── tsconfig.json
└── yarn.lock
tsconfig.json
{"compilerOptions":{"target":"es2018","module":"commonjs","declaration":true,"declarationMap":true,"sourceMap":true,"outDir":"./dist","rootDir":"./src","strict":true,"noUnusedLocals":true,"noUnusedParameters":true,"noImplicitReturns":true,"noFallthroughCasesInSwitch":true,"moduleResolution":"node","baseUrl":"./","typeRoots":["./node_modules/@types"],"types":["node"],"allowSyntheticDefaultImports":true,"esModuleInterop":true,"experimentalDecorators":true,"emitDecoratorMetadata":true,"resolveJsonModule":true,"paths":{"@lib/*":["src/path/to/nested/lib/*","dist/path/to/nested/lib/*"]}},"include":["src/**/*.ts*"],"exclude":["node_modules","dist"]}
main.ts
import{sayHello}from'@lib/hello'sayHello();
hello.ts
exportfunctionsayHello(){console.log('Hello tsconfig-paths demo')}

ts-node / node で実行する

ts-node で実行する場合でも tsconfig-paths が必要なので、以下のように実行します。

$ yarn ts-node -r tsconfig-paths/register src/main.ts

node で実行する場合も同様です。

$ yarn tsc
$ bode -r tsconfig-paths/register dist/main.js

ここでポイントとなるのは、 tsconfig の baseUrl と paths の設定です。
tsconfig-paths/register の path 解決は baseUrl を元に解決されます。
そのため、 baseUrl が ./srcの場合、この config をそのまま使って上記のように node で実行すると、 src/path/to/nested/lib/hello.ts を見に行ってしまい、 .jsでないので Error: Cannot find module '@lib/hello'となってしまいます。

そのために、 path の設定に srcdistの両方を設定しています。(なお、 bash の正規表現 {src,dist}は使えないようでした。)

tsconfig-paths/register は ts に依存しないのか

簡単な動作確認として、typescript, ts-node 等を devDependencies に、 tsconfig-paths/register のみ dependencies に定義し、動作を確認します。
yarn install --production するため、事前にビルドをしておきます。

package.json
"devDependencies":{"@types/node":"^13.1.0","ts-node":"^8.5.4","typescript":"^3.7.4"},"dependencies":{"tsconfig-paths":"^3.9.0"}
$ rm-rf dist && yarn tsc
$ rm-rf node_modules
$ yarn install--production

依存ツリーを確認し、 typescript や ts-node が含まれていないことを確認します。

$ yarn list --production
yarn list v1.21.1
├─ @types/json5@0.0.29
├─ json5@1.0.1
│  └─ minimist@^1.2.0
├─ minimist@1.2.0
├─ strip-bom@3.0.0
└─ tsconfig-paths@3.9.0
   ├─ @types/json5@^0.0.29
   ├─ json5@^1.0.1
   ├─ minimist@^1.2.0
   └─ strip-bom@^3.0.0

この状態で node で実行します。

$ node -r tsconfig-paths/register dist/main.js
Hello tsconfig-paths demo

動作することから、実行時に typescript に依存していないだろうことが分かります。
念の為以下で確認します。

tsconfig-paths/register が何をしているのか実装を確認する

該当関数は以下になります。

https://github.com/dividab/tsconfig-paths/blob/master/src/register.ts#L52

src/register.ts
exportfunctionregister(explicitParams:ExplicitParams):()=>void{constconfigLoaderResult=configLoader({cwd:options.cwd,explicitParams});if(configLoaderResult.resultType==="failed"){console.warn(`${configLoaderResult.message}. tsconfig-paths will be skipped`);returnnoOp;}constmatchPath=createMatchPath(configLoaderResult.absoluteBaseUrl,configLoaderResult.paths,configLoaderResult.mainFields,configLoaderResult.addMatchAll);// Patch node's module loading// tslint:disable-next-line:no-require-imports variable-nameconstModule=require("module");constoriginalResolveFilename=Module._resolveFilename;constcoreModules=getCoreModules(Module.builtinModules);// tslint:disable-next-line:no-anyModule._resolveFilename=function(request:string,_parent:any):string{constisCoreModule=coreModules.hasOwnProperty(request);if(!isCoreModule){constfound=matchPath(request);if(found){constmodifiedArguments=[found,...[].slice.call(arguments,1)];// Passes all arguments. Even those that is not specified above.// tslint:disable-next-line:no-invalid-thisreturnoriginalResolveFilename.apply(this,modifiedArguments);}}// tslint:disable-next-line:no-invalid-thisreturnoriginalResolveFilename.apply(this,arguments);};return()=>{// Return node's module loading to original state.Module._resolveFilename=originalResolveFilename;};}

この実装を読んでわかる通り、 TypeScript 文脈のものは何も出てきておらず、 node の moduleを拡張しているのみのようです。

また、上記の通り typescript / ts-node は dependencies にも peerDependencies にも入っていません。

実行時コンテキストを tsconfig.json に依存させたくない

上記で実行時に typescript への依存がないことは分かりましたが、 tsconfig.json への依存さえも無くしたいケースもあるかと思います。
単純に node で実行するのに tsconfig.json の変更に影響されることを嫌う場合や、 Firebase Functions などで tsconfig.json へのファイル参照を行いたくない場合などです。
これの解決のため、2つの方法を紹介します。

  1. tsconfig-paths の register にオプション引数を渡す

README の Bootstraping with explicit paramsにも紹介がありますが、
明示的にオプションを渡して以下のように実行できます。

tsconfig-paths-bootstrap.js
consttsConfigPaths=require("tsconfig-paths");constbaseUrl="./";constpaths={"@lib/*":["dist/path/to/nested/lib/*"]}tsConfigPaths.register({baseUrl,paths});
$ node -r ./tsconfig-paths-bootstrap.js main.js
Hello tsconfig-paths demo
  1. module-alias を使う

tsconfig-paths/register と似たことをしてくれる module-aliasというライブラリがあります。
こちらはそもそもプロジェクトに TypeScript を導入していなくても使えるものです。

package.json
"_moduleAliases":{"@lib/hello":"dist/path/to/nested/lib/hello.js"}
$ node -r module-alias/register dist/main.js

ただしこちらは alias に Array / ワイルドカードが指定できないという制約があります。
どうしても tsconfig-paths を使いたくない、という場合は、必要に応じて検討してください。

おわりに

Production で動く node に TypeScript 由来の何かに依存しているのは怖いという思いを解消するため、 tsconfig-paths/register の挙動や実装を確認し、回避策を紹介しました。
これで安心して node アプリケーションでも path alias を使用できると思います。

obniz-nobleでnoble使ってるプロジェクトをreplaceしてみる

$
0
0

サマリ

遠隔地のBLEデバイスをnobleで動かせるよ with obniz-noble

背景

最近BLE周りしか触ってないじゃないか!?っていうぐらいBLEのことばっかやってますが、先日、obniz-nobleなるものをリリースしました

どういうものかというと、nobleでobnizを操作しようよ!っていうものです。

node.jsでBLEといえばnoble一択、というほど強いんですが、このnoble、なんとnodejsのバージョン8でしか動きません。

正確にはnobleはバージョン依存していないのですが、nobleが使ってるライブラリがバージョン依存で動きません。nobleのgithubも2年以上放置されてますし、メンテナンスする予定ないんですかね・・・

いろいろなかたがこれに対応してnode12でも動くnobleとかを作っていたりシますが、ここはやっぱりobnizでしょ!ということでobnizで動くnobleを作ったのがobniz-nobleです

構成

会社とか、外部においたPC(もしくはクラウド)でnobleを動かし、家においたobnizを通じてBLEデバイスをコントロールします

image.png

nobleのリプレイス方法

いろんなプロジェクトやライブラリでnobleが使われているので、それをobniz-nobleにリプレイスしてみます。

通常のリプレイス方法

READMEに書いてありますがこの1行を2行に変えるだけです。

//before cosntnoble=require("noble");//afterconstobnizNoble=require("obniz-noble")cosntnoble=obnizNoble("OBNIZ_ID_HERE");

・・・でもちょっとまってください、直接noble使うときは自分のコード内にcosnt noble = require("noble");があるからいいですが、他人のライブラリの中にあるときはどうしましょう??

そんなときはyarnの選択的な依存関係の解決をつかいます。

yarnの選択的な依存関係の解決

npmを使ってる人が多いと思いますが、その上位互換のyarnというものがあり、
いろんな便利機能が実装されています。

そのうちの1つがyarnの選択的な依存関係の解決なのですが、この機能ちょっとずるくて、npm installで入るパッケージが使用しているライブラリをひっそりと入れ替えることができます。

ちょうどnode-linkingというのがnoble使っていたのですが、こんなふうに入れ替えることができます

image.png

node-linkingはnobleを使ってるつもりなのに、いつの間にかobniz-nobleをつかっていた!?ということができるわけです。

単純なnobleのリプレースだとobnizIdを入れるところがないので、obniz-nobleをラップしたnoble-replaceフォルダを作ってreplaceします.

ProjectRoot
├─┬ main@1.0.0
│ └─┬ node-linking@0.4.0
│   └── @abandonware/noble@>=1.9.2-5      // <- ここはnoble-replaceで置換される
│
└─┬ noble-replace@1.0.0
  └── obniz-noble@2.0.0

具体的なフォルダ構成はこちらのgithubにおいていますが、特徴的なところはmain/package.jsonです。

main/package.json
{..."dependencies":{"node-linking":"^0.4.0"},"resolutions":{"node-linking/@abandonware/noble":"file:../noble-replace"},}

このように書くことでnode-linkingが使ってる@abandonware/nobleのかわりにnoble-replaceを読み込みます。

noble-replaceの中にはindex.jsで

noble-replace/index.js
constobnizNoble=require("obniz-noble");module.exports=obnizNoble("86014802");

この2行だけのファイルを置いています。

動かしてみた

node-linkingのREADMEに書いてあるプログラムをデバイス名だけ変更して実行してみます。

// node-inking をロードし、`Linking` コンストラクタオブジェクトを取得constLinking=require('node-linking');// `Linking` オブジェクトを生成constlinking=newLinking();// `LinkingDevice` オブジェクトletdevice=null;// `LinkingDevice` オブジェクトを初期化linking.init().then(()=>{// 名前が `Tukeru` で始まるデバイスを 5 秒間発見を試みるreturnlinking.discover({duration:5000,nameFilter:'Sizuku'});}).then((device_list)=>{if(device_list.length>0){// 発見したデバイスを表す `LinkingDevice` オブジェクトdevice=device_list[0];// デバイス名letname=device.advertisement.localName;console.log('`'+name+'` was found.');// デバイスに接続console.log('Connecting to `'+name+'`...');returndevice.connect();}else{thrownewError('No device was found.');}}).then(()=>{console.log('Connected.');console.log('This device suports:');for(letservice_nameindevice.services){if(device.services[service_name]){console.log('- '+service_name);}}// デバイスを切断console.log('Disconnecting...');returndevice.disconnect();}).then(()=>{console.log('Disconnected');}).catch((error)=>{console.log('[ERROR] '+error.message);console.error(error);});

実行したら無事うごきました!
BLEデバイスは手元じゃなくてもobnizの近くにあれば発見できます

結果

`Sizuku_tha0142155` was found.
Connecting to `Sizuku_tha0142155`...
Connected.
This device suports:
- deviceName
- led
- battery
- temperature
- humidity
Disconnecting...
Disconnected

IMG_7045.JPG

↑これがSizuku_tha0142155です

ライブラリの中で使われてるライブラリをひっそり入れ替える事ができるのはすごいですね!

まとめ

obnizのこと書いてるのかnobleのこと書いてるのかyarnのこと書いてるのかよくわからなくなりますが、obnizでもnobleつかえるよ!って話でした

[Heroku Postgres] Herokuを使って無料でデータベースを利用する

$
0
0

はじめに🎄

メリークリスマス!!!!🎄🎄
クリスマスいかがお過ごしでしょうか??🎅
ふっけです。今回はNitKitアドベントカレンダーということで、高専祭のクラス展示で入退場システムを作成したときにHerokuのPostgresSQLを使用したので、その方法について書きます。

環境🎄

Node.js v12.8.0

データベースの導入🎄

Herokuでのプロジェクトの作成は省略します。
Herokuでプロジェクトを作成したあとResourcesのAdd-onsからHeroku Postgresを追加します。
スクリーンショット 2019-12-24 15.20.29.png
追加後 Heroku Postgresのリンクから管理画面に飛ぶことができます。
スクリーンショット 2019-12-24 15.28.10.png

データベースへのアクセス🎄

先程作成したデータベースにターミナルから接続します。
データベースの管理画面の Settings > Database Credentials > View Credentials のHeroku CLIをコピーして実行します。
すると以下のように接続することができます。接続ができればテーブルの作成等を行えます。
スクリーンショット 2019-12-24 20.58.35.png

PostgreSQLの基本的なコマンド
https://qiita.com/H-A-L/items/fe8cb0e0ee0041ff3ceb

プログラムの作成🎄

PostgresSQLへの接続のために使用するモジュールを追加します。

yarn add pg

接続用クラスファイルを作成します。

database.js
importPoolfrom'pg'module.exports=classDB{constructor(){this.pool=newPool({connectionString:DATABASE_URI,ssl:true,)}}asyncquery(param){constclient=awaitthis.pool.connect()const{row}=awaitclient.query(param)client.release()returnrow}}

Expressでサーバを立てます。

yarn add express
index.js
importexpressfrom'express'importdatabasefrom'./database'constapp=express()constPORT=process.env.PORT||3000app.use(function(req,res,next){res.header("Access-Control-Allow-Origin","*")res.header("Access-Control-Allow-Headers","Origin, X-Requested-With, Content-Type, Accept")next()})// jsonを扱えるようにするapp.use(express.json())app.use(express.urlencoded({extended:true}))constdb=newdatabase// メイン処理api.get('/',async(req,res)=>{constresult=awaitdb.query(SQL)returnres.status(200).send(result).end()})app.listen(PORT)console.log(`Server running at ${PORT}`)

このような感じでqueryにSQL文を渡すと結果が帰ってくるAPIサーバを作ることができます。

デプロイ🎄

GitHubのリポジトリと連携させると簡単にデプロイすることができます。
またデータベースのURIなどをHerokuの環境変数にしましょう。

heroku 初級編 - GitHub から deploy してみよう -
https://qiita.com/sho7650/items/ebd87c5dc2c4c7abb8f0

監視🎄

Heroku PostgresではDataclipsという機能でデータベースの中身を見ることができます。この機能とても便利です。
スクリーンショット 2019-12-24 21.18.37.png

最後に🎄

Herokuを使うと無料でデプロイからデータベースの利用までできるのでおすすめです。
ぜひ使ってみてください。

実際に使用したリポジトリ
https://github.com/FukeKazki/3i-entry-exit-server
入退場システムについてのブログ
https://bit.ly/35RHPDm

LINE WORKS Bot APIをひと通り触ってみる(node.js)#2

$
0
0

LINEWORKS Advent Calendar 2019 / 25日目の最終日の記事です。

本記事では、LINE WORKS Bot のメッセージ送信 APIをnode.jsでひと通り触ってみたいと思います。

前回:LINE WORKS Bot APIをひと通り触ってみる(node.js)#1

0. はじめに

記事の流れになります。

  1. こんなの作ります
  2. 環境準備
  3. 作ってみる
  4. 動かしてみる
  5. 気づいたこと
  6. まとめ

1. こんなの作ります

LINE WORKS Botのメッセージ送信には下表のタイプが存在し、そのタイプをすべて網羅するBotを作ります。
また、quick reply (クイックリプライ)も使用します。

送信タイプ説明
textテキストメッセージ送信
image画像送信
linkリンク送信
stickerスタンプ送信
button_templateボタンテンプレート送信
list_templateリストテンプレート送信
carouselカルーセル送信
image_carousel画像カルーセル送信
  • LINE WORKS アプリより、下記の値を入力するとそれぞれのメッセージを Bot が返します。
入力値(※1)Botが返すメッセージ
Qクイックリプライ
Bボタンテンプレート
Lリストテンプレート
Cカルーセル
I画像カルーセル
Uリンク + クイックリプライ
スタンプ(※2)同じスタンプ + クイックリプライ
画像同じ画像 + クイックリプライ
場所住所、経度緯度の文字 + クイックリプライ
その他の文字テキストメッセージ

  ※1)入力値は大文字小文字を意識しません
  ※2)使用できるスタンプはこちら

  • クイックリプライ、ボタンテンプレート、リストテンプレート、カルーセル、画像カルーセルが使用できる Action Object もすべて網羅します。
04.png
05.png
07.png
10.png

2. 環境準備

LINE WORKS Bot APIをひと通り触ってみる(node.js)#1」の 「2. 環境準備」を参照。

3. 作ってみる

LINE WORKS Bot APIをひと通り触ってみる(node.js)#1」の 「メッセージの送受信制御 (BotMessageService.js)」の BotMessageService クラスをカスタマイズします。
ベタ書き&すべての機能を網羅するため長いプログラムになってすいません。

BotMessageService.js
constrequest=require('request');/**
 * コールバックタイプ
 */constCALL_BACK_TYPE={/**
   * メンバーからのメッセージ
   */message:'message',/**
   * Bot が複数人トークルームに招待された
   * このイベントがコールされるタイミング
   *  ・API を使って Bot がトークルームを生成した
   *  ・API を使って Bot がトークルームを生成した
   *  ・メンバーが Bot を含むトークルームを作成した
   *  ・Bot が複数人のトークルームに招待された
   * ※メンバー1人と Bot のトークルームに他のメンバーを招待したらjoinがコールされる(最初の1回だけ)
   *  招待したメンバーを退会させ、再度他のメンバーを招待するとjoinedがコールされるこれ仕様?
   *  たぶん、メンバー1人と Botの場合、トークルームIDが払い出されてないことが原因だろう。。。
   */join:'join',/**
   * Bot が複数人トークルームから退室した
   * このイベントがコールされるタイミング
   *  ・API を使って Bot を退室させた
   *  ・メンバーが Bot をトークルームから退室させた
   *  ・何らかの理由で複数人のトークルームが解散した
   */leave:'leave',/**
   * メンバーが Bot のいるトークルームに参加した
   * このイベントがコールされるタイミング
   *  ・Bot がトークルームを生成した
   *  ・Bot が他のメンバーをトークルームに招待した
   *  ・トークルームにいるメンバーが他のメンバーを招待した
   */joined:'joined',/**
   * メンバーが Bot のいるトークルームから退室した
   * このイベントがコールされるタイミング
   *  ・Bot が属するトークルームでメンバーが自ら退室した、もしくは退室させられた
   *  ・何らかの理由でトークルームが解散した
   */left:'left',/**
   * postback タイプのメッセージ
   * このイベントがコールされるタイミング
   *  ・メッセージ送信(Carousel)
   *  ・メッセージ送信(Image Carousel)
   *  ・トークリッチメニュー
   */postback:'postback',};/**
 * コールバックコンテンツタイプ
 */constCALL_BACK_MESSAGE_CONTENT_TYPE={/**
   * テキスト
   */text:'text',/**
   * 場所
   */location:'location',/**
   * スタンプ
   */sticker:'sticker',/**
   * 画像
   */image:'image'};/**
 * メッセージコンテンツタイプ
 */constMESSAGE_CONTENT_TYPE={/**
   * テキスト
   */text:'text',/**
   * 画像
   */image:'image',/**
   * リンク
   */link:'link',/**
   * スタンプ
   */sticker:'sticker',/**
   * ボタンテンプレート
   */buttonTemplate:'button_template',/**
   * リストテンプレート
   */listTemplate:'list_template',/**
   * カルーセル
   */carousel:'carousel',/**
   * 画像カルーセル
   */imageCarousel:'image_carousel'};/**
 * BotMessageServiceクラス
 */module.exports=classBotMessageService{/**
   * BotMessageServiceを初期化します。
   * @param {string} serverToken Serverトークン
   */constructor(serverToken){this._serverToken=serverToken;this.imageIndex=0;}/**
   * LINE WORKS にBotメッセージを送信します。
   * @param {object} callbackEvent リクエストのコールバックイベント
   */asyncsend(callbackEvent){letres=this._getResponse(callbackEvent);if(!res)return;returnnewPromise((resolve,reject)=>{// LINE WORKS にメッセージを送信するリクエストrequest.post(this._createMessage(res),(error,response,body)=>{if(error){console.log('BotService.send error');console.log(error);}console.log(body);// 揉み消してます!resolve();});});}/**
   * LINE WORKS に送信するBotメッセージを作成して返します。
   * @param {object} res レスポンスデータ
   */_createMessage(res){return{url:`https://apis.worksmobile.com/r/${process.env.API_ID}/message/v1/bot/${process.env.BOT_NO}/message/push`,//url: `https://apis.worksmobile.com/${process.env.API_ID}/message/sendMessage/v2`,headers:{'Content-Type':'application/json;charset=UTF-8',consumerKey:process.env.CONSUMER_KEY,Authorization:`Bearer ${this._serverToken}`},json:res};}/**
   * メンバーIDを連結して返します。
   * @param {Array} memberList メンバーリスト
   * @return {string} メンバーIDリスト文字列
   */_buildMember(memberList){letresult='';if(!memberList)returnresult;memberList.forEach(m=>{if(result.length>0) result+=',';result+=m;});returnresult;}/**
   * Bot実装部
   * @param {object} callbackEvent リクエストのコールバックイベント
   * @return {string} レスポンスメッセージ
   */_getResponse(callbackEvent){console.log(callbackEvent);letres={};if(callbackEvent.source.roomId){// 受信したデータにトークルームIDがある場合は、送信先にも同じトークルームIDを指定します。res.roomId=callbackEvent.source.roomId;}else{// トークルームIDがない場合はBotとユーザーとの1:1のチャットです。res.accountId=callbackEvent.source.accountId;}switch(callbackEvent.type){caseCALL_BACK_TYPE.message:switch(callbackEvent.content.type){caseCALL_BACK_MESSAGE_CONTENT_TYPE.text:if(callbackEvent.content.postback=='start'){// メンバーと Bot との初回トークを開始する画面で「利用開始」を押すと、自動的に「利用開始」というメッセージがコールされるconsole.log(`start`);res.content={type:MESSAGE_CONTENT_TYPE.text,text:'ト〜クルームに〜〜。ボトやまが〜くる〜!\n下記を入力するとボトやまが特別な応答をします(大文字小文字を区別しません)。\n・b:button template\n・l:List template\n・c:carousel\n・i:image carousel\n・q:quick reply'};returnres;}letcontent=this._getButtonTemplateContent(callbackEvent.content.postback,callbackEvent.content.text)||this._getListTemplateContent(callbackEvent.content.postback,callbackEvent.content.text)||this._getCarouselContent(callbackEvent.content.postback,callbackEvent.content.text)||this._getImageCarouselContent(callbackEvent.content.postback,callbackEvent.content.text)||this._getQuickReplyContent(callbackEvent.content.postback,callbackEvent.content.text)||this._getLinkContent(callbackEvent.content.postback,callbackEvent.content.text);if(content){res.content=content;}else{console.log(CALL_BACK_TYPE.message);res.content={type:MESSAGE_CONTENT_TYPE.text,text:`ですよね〜〜〜。\n(受信データ:${callbackEvent.content.text})`};}break;caseCALL_BACK_MESSAGE_CONTENT_TYPE.location:// 場所のコールバックは場所データをテキストで返すres.content={type:MESSAGE_CONTENT_TYPE.text,text:`住所:${callbackEvent.content.address}\n緯度:${callbackEvent.content.latitude}\n経度:${callbackEvent.content.longitude}`,quickReply:this._getQuickReplyItems()};break;caseCALL_BACK_MESSAGE_CONTENT_TYPE.sticker:// スタンプのコールバックはおうむ返し(同じスタンプを返す)// ※使えないスタンプがあるようです(LINE WORKSぽいスタンプは使えない。。。)res.content={type:MESSAGE_CONTENT_TYPE.sticker,packageId:callbackEvent.content.packageId,stickerId:callbackEvent.content.stickerId,quickReply:this._getQuickReplyItems()};break;caseCALL_BACK_MESSAGE_CONTENT_TYPE.image:// 画像のコールバックはおうむ返し(同じ画像を返す)res.content={type:MESSAGE_CONTENT_TYPE.image,resourceId:callbackEvent.content.resourceId,quickReply:this._getQuickReplyItems()};break;default:console.log('知らないcontent.typeですね。。。');returnnull;}break;caseCALL_BACK_TYPE.join:console.log(CALL_BACK_TYPE.join);res.content={type:MESSAGE_CONTENT_TYPE.text,text:'うぃーん!'};break;caseCALL_BACK_TYPE.leave:console.log(CALL_BACK_TYPE.leave);break;caseCALL_BACK_TYPE.joined:{console.log(CALL_BACK_TYPE.joined);res.content={type:MESSAGE_CONTENT_TYPE.text,text:`${this._buildMember(callbackEvent.memberList)}いらっしゃいませ〜そのせつは〜`};break;}caseCALL_BACK_TYPE.left:{console.log(CALL_BACK_TYPE.left);res.content={type:MESSAGE_CONTENT_TYPE.text,text:`${this._buildMember(callbackEvent.memberList)}そうなります?`};break;}caseCALL_BACK_TYPE.postback:// QuickReply, Carousel, ImageCarouselからのPostback(このコールバック後、CALL_BACK_TYPE.messageのコールバックがコールされる)console.log(CALL_BACK_TYPE.postback);letcontent=this._getButtonTemplateContent(callbackEvent.data)||this._getListTemplateContent(callbackEvent.data)||this._getCarouselContent(callbackEvent.data)||this._getImageCarouselContent(callbackEvent.data)||this._getQuickReplyContent(callbackEvent.data);if(content)res.content=content;break;default:console.log('知らないコールバックですね。。。');returnnull;}returnres;}/**
   * Link コンテンツを返します。
   * @param {Array} conditions 条件
   * @return {object} コンテンツ
   */_getLinkContent(...conditions){if(!conditions.some(condition=>condition&&condition.toUpperCase()==='U'))return;return{type:MESSAGE_CONTENT_TYPE.link,contentText:'Link からの〜〜〜。',linkText:'LINE WORKS',link:'https://line.worksmobile.com/jp/',quickReply:this._getQuickReplyItems()};}/**
   * Quick reply コンテンツを返します。
   * @param {Array} conditions 条件
   * @return {object} コンテンツ
   */_getQuickReplyContent(...conditions){if(!conditions.some(condition=>condition&&condition.toUpperCase()==='Q'))return;return{type:MESSAGE_CONTENT_TYPE.text,text:'QuickReply からの〜〜〜。',quickReply:this._getQuickReplyItems()};}/**
   * Quick reply アイテムリストを返します。
   * @return {Array} アイテムリスト
   */_getQuickReplyItems(){return{items:[{imageUrl:`${process.env.IMAGE_FILE_HOST}/images/giraffe01.png`,action:{type:'postback',label:'ButtonTemp',data:'button_template',displayText:'button_template ください'}},{imageUrl:`${process.env.IMAGE_FILE_HOST}/images/panda01.png`,action:{type:'postback',label:'ListTemp',data:'list_template',displayText:'list_template ください'}},{imageUrl:`${process.env.IMAGE_FILE_HOST}/images/giraffe02.png`,action:{type:'postback',label:'Carousel',data:'carousel',displayText:'carousel ください'}},{imageUrl:`${process.env.IMAGE_FILE_HOST}/images/panda02.png`,action:{type:'postback',label:'ImageCarousel',data:'image_carousel',displayText:'image_carousel ください'}},{imageUrl:`${process.env.IMAGE_FILE_HOST}/images/sushi.png`,action:{type:'postback',label:'QuickReply',data:'q',displayText:'QuickReply ください'}},{imageUrl:`${process.env.IMAGE_FILE_HOST}/images/sushi.png`,action:{type:'message',label:'すし',text:'すし'}},{imageUrl:`${process.env.IMAGE_FILE_HOST}/images/lw.png`,action:{type:'uri',label:'LINE WORKS',uri:'https://line.worksmobile.com/jp/'}},{action:{type:'camera',label:'カメラ'}},{action:{type:'cameraRoll',label:'カメラロール'}},{action:{type:'location',label:'場所'}}]}}/**
   * Button template コンテンツを返します。
   * @param {Array} conditions 条件
   * @return {object} コンテンツ
   */_getButtonTemplateContent(...conditions){if(!conditions.some(condition=>condition&&(condition.toUpperCase()==='B'||condition===MESSAGE_CONTENT_TYPE.buttonTemplate)))return;return{type:MESSAGE_CONTENT_TYPE.buttonTemplate,contentText:'ButtonTemplate からの〜〜〜。',actions:this._getButtonActions(),//quickReply: this._getQuickReplyItems()};}/**
   * Buttonアクションリストを返します。
   * @return {Array} アクションリスト
   */_getButtonActions(){return[// button_templateのactionでは typeはmessageとuriしか使えない。つまり postback、camera、cameraRoll、locationは使えない{type:'message',label:'Message lable',text:'Message text'},{type:'message',label:'Button postback',text:'button_template ください',postback:'button_template'},{type:'message',label:'List postback',text:'list_template ください',postback:'list_template'},{type:'message',label:'Carousel postback',text:'carousel ください',postback:'carousel'},{type:'message',label:'Image carousel pb',text:'image_carousel ください',postback:'image_carousel'},{type:'message',label:'QuickReply postback',text:'QuickReply ください',postback:'q'},{type:'uri',label:'LINE WORKS',uri:'https://line.worksmobile.com/jp/'}];}/**
   * List template コンテンツを返します。
   * @param {Array} conditions 条件
   * @return {object} コンテンツ
   */_getListTemplateContent(...conditions){if(!conditions.some(condition=>condition&&(condition.toUpperCase()==='L'||condition===MESSAGE_CONTENT_TYPE.listTemplate)))return;return{type:MESSAGE_CONTENT_TYPE.listTemplate,coverData:{backgroundImage:`${process.env.IMAGE_FILE_HOST}/images/lw.png`,//backgroundResourceId: '',title:'ListTemplate からの〜〜〜。(title)',subtitle:'サブタイトル',},// 最大4つの要素を指定可能elements:this._getListElements(),// 最大2*2の配列でアクションを指定可能actions:this._getListActions(),//quickReply: this._getQuickReplyItems()};}/**
   * List要素リストを返します。
   * @return {Array} 要素リスト
   */_getListElements(){// list_template.elementsのactionでは typeはmessageとuriしか使えない。つまり postback、camera、cameraRoll、locationは使えないreturn[{title:'List message title',subtitle:'List message subtitle',image:`${process.env.IMAGE_FILE_HOST}/images/lw.png`,//resourceId: '',action:{type:'message',label:'Message',text:'Message text'}},{title:'Button postback title',subtitle:'Button postback subtitle',image:`${process.env.IMAGE_FILE_HOST}/images/lw.png`,//resourceId: '',action:{type:'message',label:'Button',text:'button_template ください',postback:'button_template'}},{title:'List postback title',subtitle:'List postback subtitle',image:`${process.env.IMAGE_FILE_HOST}/images/lw.png`,//resourceId: '',action:{type:'message',label:'List',text:'list_template ください',postback:'list_template'}},{title:'List uri title',subtitle:'List uri subtitle',image:`${process.env.IMAGE_FILE_HOST}/images/security.png`,//resourceId: '',action:{type:'uri',label:'LINE WORKS',uri:'https://line.worksmobile.com/jp/'}}];}/**
   * Listアクションリストを返します。
   * @return {Array} アクションリスト
   */_getListActions(){// list_template.actionsのactionでは typeはmessageとuriしか使えない。つまり postback、camera、cameraRoll、locationは使えないreturn[[{type:'message',label:'Carousel postback',text:'carousel ください',postback:'carousel'},{type:'message',label:'Image Car postback',text:'image_carousel ください',postback:'image_carousel'}],[{type:'message',label:'QuickReply',text:'QuickReply ください',postback:'q'},{type:'message',label:'No',text:'No'}]];}/**
   * Carousel コンテンツを返します。
   * @param {Array} conditions 条件
   * @return {object} コンテンツ
   */_getCarouselContent(...conditions){if(!conditions.some(condition=>condition&&(condition.toUpperCase()==='C'||condition===MESSAGE_CONTENT_TYPE.carousel)))return;return{type:MESSAGE_CONTENT_TYPE.carousel,//imageAspectRatio: '',//imageSize: '',columns:this._getCarouselColumns(),//quickReply: this._getQuickReplyItems()};}/**
   * Carousel カラムリストを返します。
   * @return {Array} カラムリスト
   */_getCarouselColumns(){// carousel.columnsのactionでは typeはmessageとuri、postbackしか使えない。つまり camera、cameraRoll、locationは使えない// carouselは、postbackをつかえる!!!!return[{thumbnailImageUrl:`${process.env.IMAGE_FILE_HOST}/images/giraffe01.png`,//thumbnailImageResourceId: '',title:'Carousel postback title',text:'Carousel postback text (default button)',defaultAction:{type:'postback',label:'ButtonTemp',data:'button_template',displayText:'button_template ください'},actions:[{type:'postback',label:'ListTemp',data:'list_template',displayText:'list_template ください'},{type:'postback',label:'Carousel',data:'carousel',displayText:'carousel ください'},{type:'postback',label:'QuickReply',data:'q',displayText:'QuickReply ください'}]},{thumbnailImageUrl:`${process.env.IMAGE_FILE_HOST}/images/panda01.png`,//thumbnailImageResourceId: '',title:'Carousel uri title',text:'Carousel uri text',defaultAction:{type:'uri',label:'LINE WORKS',uri:'https://line.worksmobile.com/jp/'},actions:[{type:'uri',label:'LINE WORKS',uri:'https://line.worksmobile.com/jp/'},{type:'uri',label:'bot Action Objects',uri:'https://developers.worksmobile.com/jp/document/1005050?lang=ja'}]},{thumbnailImageUrl:`${process.env.IMAGE_FILE_HOST}/images/sushi.png`,//thumbnailImageResourceId: '',title:'Carousel message title',text:'Carousel message text',defaultAction:{type:'message',label:'Message',text:'Message text'},actions:[{type:'message',label:'Yes',text:'Yes'},{type:'message',label:'No',text:'No'}]}];}/**
   * Image carousel コンテンツを返します。
   * @param {Array} conditions 条件
   * @return {object} コンテンツ
   */_getImageCarouselContent(...conditions){if(!conditions.some(condition=>condition&&(condition.toUpperCase()==='I'||condition===MESSAGE_CONTENT_TYPE.imageCarousel)))return;return{type:MESSAGE_CONTENT_TYPE.imageCarousel,columns:this._getImageCarouselColumns(),//quickReply: this._getQuickReplyItems()};}/**
   * Image carousel カラムリストを返します。
   * @return {Array} カラムリスト
   */_getImageCarouselColumns(){// image_carousel.columnsのactionでは typeはmessageとuri、postbackしか使えない。つまり camera、cameraRoll、locationは使えない// image_carousellは、postbackを使える!!!!// 最大3つまでのカラムしか使えない!!!return[{imageUrl:`${process.env.IMAGE_FILE_HOST}/images/giraffe01.png`,//imageResourceId: '',action:{type:'postback',label:'ButtonTemp',data:'button_template',displayText:'button_template ください'}},{imageUrl:`${process.env.IMAGE_FILE_HOST}/images/lw.png`,//imageResourceId: '',action:{type:'postback',label:'ListTemp',data:'list_template',displayText:'list_template ください'}},{imageUrl:`${process.env.IMAGE_FILE_HOST}/images/sushi.png`,//imageResourceId: '',action:{type:'postback',label:'Carousel',data:'carousel',displayText:'carousel ください'}}];}}

環境変数 (.env)

  1. .env.sample ファイルを .env に変更する
  2. 「LINE WORKS Bot APIの利用準備」で発行した接続情報を設定する
  3. 「IMAGE_FILE_HOST」に ngrog で取得したホスト ( https://xxxxx.io) を指定する
.env
API_ID="API ID"
CONSUMER_KEY="Consumer key"
SERVER_ID="Server ID"
PRIVATE_KEY="認証キー"
BOT_NO="Bot No"
IMAGE_FILE_HOST="ホスト名(ngrogで取得したホスト https://xxxxx.io)"

4. 動かしてみる

LINE WORKS Bot APIをひと通り触ってみる(node.js)#1」の 「いざデバッグ開始!」を参照して、Botを起動し、LINE WORKSアプリにBotを追加した状態にする。

シナリオ

  1. Botの利用開始
  2. 「I」を入力し送信(画像カルーセル要求)
  3. 画像カルーセルの「ButtonTemp」をクリック(ボタンテンプレート要求)
  4. ボタンテンプレートの「List postback」をクリック(リストテンプレート要求)
  5. リストテンプレートの「QuickReply」をクリック(クイックリプライ要求)
  6. クイックリプライの「場所」をクリック(マップ要求)
  7. マップの「位置を共有」をクリック
  8. クイックリプライの「Calousel」をクリック(カルーセル要求)
aaa.gif

5. 気づいたこと

camera、cameraRoll、location、postback Action は、すべての 送信タイプで指定できるわけではない

送信タイプcameracameraRolllocationpostback
quick reply(共通プロパティ):ok_woman_tone1::ok_woman_tone1::ok_woman_tone1::ok_woman_tone1:
text
image
link
sticker
button_template:no_good_tone1::no_good_tone1::no_good_tone1::no_good_tone1:
list_template:no_good_tone1::no_good_tone1::no_good_tone1::no_good_tone1:
carousel:no_good_tone1::no_good_tone1::no_good_tone1::ok_woman_tone1:
image_carousel:no_good_tone1::no_good_tone1::no_good_tone1::ok_woman_tone1:

※ ー は Action Object を使用できない送信タイプです

postback には2つの種類がある

  • 送信タイプ: button_template、list_template の Action Object の場合、type:message で postback プロパティにデータを乗せる
ActionObject
{type:'message',label:'Button',text:'button_template ください',postback:'button_template'}
  • 送信タイプ: quick reply(共通プロパティ)、carousel、image_carousel の Action Object の場合、type:postback で data プロパティにデータを乗せる

(この Action が実行された場合場合、postback と message の2つのイベントが Bot 側にコールバックされる)

ActionObject
{type:'postback',label:'ImageCarousel',data:'image_carousel',displayText:'image_carousel ください'}

6. まとめ

LINE WORKS Bot APIのメッセージ送信部分の動作をひと通り確認できました。
ベタ書きのコードですいません。でも、一通りは網羅したつもりです。
今回作成たコードは GitHub の line-works-bot01-nodeの tag:v2.0 で公開してま〜す。(issueがあればお知らせください。修正します。)

次回は、トーク固定メニューとリッチメニューをやってみたい!

Link

npm-audit-actionをマーケットプレイスに公開してみた

$
0
0

はじめに

CI/CD Advent Calendar 2019 1日目に毎日npm auditを実行して脆弱性対応する取り組みを紹介しました。このときはCircleCI上で実行していたのですが、GitHub Actionsを使ってみたい、自作Actionを開発してみたいと思ってやってみました。

GitHub Actions Advent Calendar 2019 16日目のphp-audit-action(β版)をマーケットプレイスに公開してみたを読んだときは、出だしからnpm auditと書いてあるし、ネタが丸かぶりかとヒヤヒヤしました。記事タイトルを拝借しました。

作ったもの

npm auditを実行するActionを作りました。

リポジトリ、マーケットプレイス

https://github.com/oke-py/npm-audit-action
https://github.com/marketplace/actions/npm-audit-action

仕様

v1.1.0時点では大きく2つの機能があります。いずれもnpm auditを実行して脆弱性があればレポートする点は同じですが、トリガーによって動作が異なります。

Pull Requestがトリガーの場合

脆弱性があれば該当のPRに対してコメントをつけます。また、ジョブ自体は失敗とします。

demo.png

Pull Request以外がトリガーの場合

Pushやスケジュール実行時に脆弱性があればIssueを作成します。

demo2.png

使い方

こんな感じです。

.github/workflows/audit.yml
name:npm auditon:pull_request:push:branches:-master-'releases/*'# on:#   schedule:#     - cron: '0 10 * * *'jobs:scan:name:npm auditruns-on:ubuntu-lateststeps:-uses:actions/checkout@v1-name:install dependenciesrun:npm ci-uses:oke-py/npm-audit-action@v1.1.0with:github_token:${{ secrets.GITHUB_TOKEN }}issue_assignees:oke-pyissue_labels:vulnerability,test

作り方

テンプレートの利用

6日目1に紹介されていた公式のTypeScriptテンプレート2を利用しました。

npm installのエラー(うろ覚え)

何か宣言が足りないとかでエラーになった気がします。以下のように自分で定義するとエラーが解消されました。

@types/octokit/index.d.ts
declaremodule'@octokit/graphql'{exporttypeVariables=anyexporttypeGraphQlQueryResponse=any}

GitHub contextの取得

トリガーに応じて動作を変えるため、githubコンテキスト3を利用しました。ユーザーが指定しなくて済むようにaction.ymlでデフォルト値として指定しました。

action.yml
...inputs:github_context:description:'The`github`context'default:${{ toJson(github) }}required:false...

ローカルでの動作確認

開発中はローカルでActionを実行して動作確認したかったのですが、inputsの渡し方がわかりませんでした。そこでソースコード4を読んでみたところ環境変数として指定できることがわかりました。

packages/core/src/core.ts
exportfunctiongetInput(name:string,options?:InputOptions):string{constval:string=process.env[`INPUT_${name.replace(/ /g,'_').toUpperCase()}`]||''if(options&&options.required&&!val){thrownewError(`Input required and not supplied: ${name}`)}returnval.trim()}
INPUT_ISSUE_ASSIGNEES=oke-py INPUT_ISSUE_LABELS=vulnerability,test node lib/main.js

PRコメント

GitHubの操作は@octokit/rest5を利用しましたが、単純なコメントをつけることができませんでした6。特定のコミット、行に対するコメントのみつけることができました。

To add a regular comment to a pull request timeline, see "Comments."

そこで、axiosを使ってGitHub API7を直接たたくようにしました。

テストコード

公式テンプレートからリポジトリを作成するとJestを使うようになっていました。モックの使い方がよくわからず悪戦苦闘しました。一応理解した気はしますが、あまり説明できません。

テストカバレッジの取得

19日目8に投稿したのでご参照ください。

pre-commit hookの設定

ソースコードを修正して動作確認したところで、意図した通りに動かないことが多々ありました。主な原因はTypeScriptのトランスパイル忘れでした。pre-commit hookを設定することで回避しました。

.git/hooks/pre-commit
#!/bin/sh
npm run all

マーケットプレイスのアイコン設定

action.ymlのbrandingでカラーとFeatherアイコンを指定できました9。絵心ないので助かりました。

action.yml
...branding:icon:'search'color:'orange'

おわりに

CI/CD Advent Calendar 2019 初日にnpm auditネタではじまり、GitHub Actions Advent Calendar 2019 最終日にnpm auditネタで終えることができました。

とりあえず自分が使いたいものはできたかなと思いますが、少しでもユーザーが増えてくれたら嬉しいです。マーケットプレイスに公開して流入はあるのでしょうか・・・? また、GitHubでスターをもらえたら嬉しいです。

【fly.io】愛を込めて花束を

$
0
0

image.png

タイトルは Superfly名曲ですが、本記事はネタ記事でもなく釣り記事でもなく、もちろんポエム記事でもありません。node の PaaS 環境である fly.io の真面目なお話です。

え?なぜこのタイトルかって?

 fly.io
  ↓
 フライ.アイオー
  ↓
 Super fly 愛を込めて…

ハイ!Merry Christmas!🎅  Σd(゚∀゚d) オゥイェ!!!

これはなに?

fly.io は、node.js に特化した PaaS で独自の CDN を持ち Edge サーバによるキャッシュで高速なレスポンスを提供するフルマネージドなサービスです。AWS にも Lambda というサービスがあり、それを CloudFront に乗せた Lambda@edge というのがありますが、それとほぼ同列です。もっと簡単に言うと node.js に特化した heroku って感じです。Lambda は AWS のサービスですので面倒な AWS の契約やロールの管理が必要ですが、fly.io は無料アカウントを作成すればすぐに使用可能な分とてもお手軽です。

また、fly.io ではローカルで動作するサーバがオープンソースで提供されています。これによって手元で開発、動作確認を行い、手軽にデプロイすることが可能です。

詳しくは公式サイトをご覧ください。随所に挿入されているイラストがゆるかわいいですね!

https://fly.io/
image.png

料金

fly.io は従量課金制ですが、毎月、最初の $10 までは無料で利用できます。
気になる金額ですが、TCP セッション時間に対しての課金で下記の金額がベースとなります。

$0.000000878 per connection, per second

24時間30日間ずっと接続が発生したとして、

0.000000878 * 24 * 60 * 60 * 30 = 2.275776 ドル

なので全然無料枠で遊べますね。
ただ、これは1コネクションでの話ですし、その他、帯域やCPUグレードなどで金額が変わり、実運用ではそうはいきませんが、少なくともカード登録不要でサインアップできるので、使いすぎて勝手に課金というのは無さそうです。(ただし自己責任で)

金額詳細はこちらから。
https://fly.io/docs/pricing/

登録

サインアップは下記URLから行えます。
https://fly.io/app/sign-up
私は GitHub のアカウントから登録したので30秒くらいでサインインできました。

後述のコマンドラインでの認証があるので、パスワードは設定しておきましょう。

インストール

fly.io 上のランタイムサーバは fly コマンドをインストールする事でローカルでシミュレートできます。fly コマンドではランタイムサーバの起動の他に、デプロイやログの確認、テストの実行なども行えます。

Mac だと Homebrew でパッケージが公開されていますので下記コマンドでインストールできます。(リポジトリ名がSuperfly!)

$ brew tap superfly/brew && brew install superfly/brew/fly

もちろん npm でも提供されているので、

$ npm install -g @fly/fly

でも OK です。

fly コマンドを実行し下記のようにヘルプが表示されれば成功です。

$ fly
Fly edge application runtime

VERSION
  @fly/cli/0.54.5 darwin-x64 node-v10.18.0

USAGE
  $ fly [COMMAND]

COMMANDS
  apps       list your apps
  build      Build your local Fly app
  deploy     Deploy your local Fly app
  help       display help for fly
  hostnames  list hostnames for an app
  login      login to fly
  logs       logs for an app
  new        create a new app
  orgs       list your organizations
  releases   list releases for an app
  secrets    manage app secrets
  server     run the local fly development server
  test       run unit tests

サンプル

さて何か作って動かしたいのですが、気の利いたサンプルも思いつかないので、休日を取得する node パッケージ @holiday-jp/holiday_jpを使用して、指定期間内の休日を JSON で返す API をサクッと作成してみましょう。

エンドポイントに from と to を GET で渡すと、

[{"date":"2020-01-01T00:00:00.000Z","week":"水","week_en":"Wednesday","name":"元日","name_en":"New Year's Day"},{"date":"2020-01-13T00:00:00.000Z","week":"月","week_en":"Monday","name":"成人の日","name_en":"Coming of Age Day"}]

という JSON が返るだけのクソしょうもないAPI です。

fly.io アプリの作成手順

それではアプリを作成しましょう。先ほどインストールした fly コマンドをベースに作業します。

ログイン (fly login)

まずはログインしてアクセストークンをローカルに保存しましょう。
これによってアプリの作成やデプロイの度に毎回認証をする事がなくスムーズに作業が行えます。

$ fly login
email: xxxxx@yyy.zzz # 登録時のメールアドレス
password: **********# パスワード
2FA code (if any)[n/a]: n    # 2ファクタ認証を有効にしている場合は y
Wrote credentials at: ~/.fly/credentials.yml

アクセストークンはホームディレクトリに保存されるので、最初の1回のみの実行で良いです。

アプリの作成 (fly apps:create)

アプリを新規作成します。fly.io のサーバ側にも登録が必要ですが、コマンドを実行する事でサーバ側の登録とローカルのセットアップも同時に行ってくれます。

最初にアプリの名称を入力しますが、アプリ名称がサブドメインとなり公開される (アプリ名称.edgeapp.net) ため、fly.io 全体でユニークな名前(S3のバケット名みたいな感じ)でなければいけません。とりあえず作ってみる場合はブランクとしてエンターキーを押すと超適当な名前を付けてくれます。

$ fly apps:create
{ data: { organizations: { nodes: [Array] }}}
? app name (leave blank to use a random name)#アプリ名称を入力
? select an organization #所属する fly.io 上の組織を選択。通常は1つ。
creating app... done
Created a new app: green-sky-8879
--> green-sky-8879.edgeapp.net
{ data:
   { createApp:
      { app:
         {id: 'green-sky-8879',
           name: 'green-sky-8879',
           runtime: 'NODEPROXY',
           appUrl: 'green-sky-8879.edgeapp.net'}}}}
Created a .fly.yml for you.

今回は、green-sky-8879 という素敵なアプリ名を付けてもらいました。
これで、https://green-sky-8879.edgeapp.netのドメインでの公開が準備できました。

スクリプトの記述

fly.io では npm パッケージをバンドルしてデプロイすることも可能なので、適宜 npm install したパッケージを import して、、、と、普通の node.js アプリケーションと同じようにコードを書きます。

今回は @holiday-jp/holiday_jp と moment の node パッケージを使用します。いつも通り npm コマンドでパッケージをインストールします。

$ npm install --save @holiday-jp/holiday_jp moment 

次に index.js にメインとなる処理を記述します。flyランタイムには予め用意されたAPIがあるのでそれらを使用してレスポンスを返します。

importholiday_jpfrom'@holiday-jp/holiday_jp'importmomentfrom'moment'fly.http.respondWith(async(req)=>{consturl=newURL(req.url)constfrom=newDate(url.searchParams.get('from'))constto=newDate(url.searchParams.get('to'))constholidays=holiday_jp.between(from,to)returnnewResponse(JSON.stringify(holidays),{status:404,contentType:'application/json'})})

GET で from と to を取得して、ライブラリ経由で休日を返すだけのコードです。

ローカルサーバの起動 (fly server)

ではローカルで fly.io のランタイムを実行して、ローカルサーバを立ち上げてみましょう。

$ fly server
Using ~/green-sky-8879 as working directory.
Generating Webpack config...
new runtime, app: ~/green-sky-8879
v8 snapshots enabled
Memory Cache Adapter: MemoryCacheStore
Blob Cache Adapter: Disk [path:/var/folders/0v/000000000000_qh000000000000000gn/T/fly-blobcache]
Compiled app in 721ms (source: 800.95KB sourceMap: 963.95KB hash: fa48f4b7f12d34f6883d048ef28eee147f84ee6a)
Server listening on :::3000
Updating app in local runtime...

これだけででローカルホストの 3000 ポートでアプリが起動しました。
なんだかメモリキャッシュとかエッジサーバっぽい感じのログが出てますね!

試しに curl でアクセスしてみましょう。

$ curl "http://localhost:3000/?from=2020-01-01&to=2020-01-31"[{"date": "2020-01-01T00:00:00.000Z",
    "week": "水",
    "week_en": "Wednesday",
    "name": "元日",
    "name_en": "New Year's Day"},
  {"date": "2020-01-13T00:00:00.000Z",
    "week": "月",
    "week_en": "Monday",
    "name": "成人の日",
    "name_en": "Coming of Age Day"}]

このように JSON が返ってくれば成功です!
fly server はライブリロード対応なので、コードの修正も即座に検知してビルドし直してくれます。
開発中は常に立ち上げておくと良いでしょう。

デプロイ (fly deploy)

では今度はこれを fly.io 上、つまりインターネット上に公開してみましょう。
デプロイは fly deploy コマンドで一瞬です。

$ fly deploy
Deploying green-sky-8879 (env: production)
Generating Webpack config...
Compiled app in 562ms (source: 800.95KB sourceMap: 963.95KB hash: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx)
Deploying v1 globally @ https://green-sky-8879.edgeapp.net
App should be updated in a few seconds.

公開されました!
先ほどはローカルホストの3000ポートでしたが、green-sky-8879.edgeapp.net のドメインで公開されました。curl でアクセスしてみましょう。

$ curl "https://green-sky-8879.edgeapp.net?from=2020-01-01&to=2020-01-31"[{"date": "2020-01-01T00:00:00.000Z",
    "week": "水",
    "week_en": "Wednesday",
    "name": "元日",
    "name_en": "New Year's Day"},
  {"date": "2020-01-13T00:00:00.000Z",
    "week": "月",
    "week_en": "Monday",
    "name": "成人の日",
    "name_en": "Coming of Age Day"}]

先ほどの JSON と同じ結果が返れば成功です!

まとめ

ちょっと駆け足になりましたが、今回は、ただ単に node の処理を fly.io 上に置いただけなので、何がすごいのかイマイチ伝わり辛かったと思います。サーバサイドレンダリングやもっと重い処理などを効率的にエッジサーバにキャッシュさせる事で fly.io の真価を発揮するので、時間があれば画像のリサイズ処理とか、mermaid.js でグラフ SVG をサーバサイドで生成とかやってみたいですね。
まだまだ情報も少ないので、皆さんも是非さわってレポートしてみて下さい。

みんなで flyer になりましょう!

私事ですが

本当に私事で恐縮ですが、私が qnote の CTO として参加するアドベントカレンダーは今年が最後です。
退職や引退ではなく、これからも現役で頑張っていきたいと考えた結果、学ぶ側の視点にスイッチし、若い世代にバトンを渡す、という結論に至りました。
来年からは若手が中心となってさらに技術面を引っ張っていってくれる事でしょう!

また、弊社は来年移転を予定しています。
人も一気に増え、いろんなことにチャレンジしようとしていますので、
今後とも猫会社qnoteをよろしくお願い致します!

それでは皆さん良いお年を!

参考


Webpackerが提供しているコマンドの内部処理を追ってみた

$
0
0

食べログ Advent Calendar 2019 24日目の記事です。

はじめまして。
好きな筋トレはバーベルシュラッグ。
好きな小説家は宮内悠介。
食べログのフロントエンドチームに所属している@sn_____です。
クリスマスイヴなのでWebpackerの話をします。

皆さんWebpacker使ってます?
個人的にはWebpackerは好みではありません。

Webpackerは面倒なwebpack回りの設定をやってくれるので、Railsアプリケーション開発では重宝されるケースも多いと思います。
しかし、提供されるコマンドの内部処理はブラックボックス化されており、詳細を把握していない人も多いのではないでしょうか。
フロントエンドエンジニア的にはそこらへんも抑えておきたいので、Webpackerが提供しているコマンドの内部処理を調査してみました。

調査対象

調査対象コマンド

  • ./bin/webpack
  • ./bin/webpack-dev-server
  • bundle exec rails webpacker:compile

./bin/webpack

github上にはここにコードがあります。

./bin/webpack
#!/usr/bin/env rubyENV["RAILS_ENV"]||=ENV["RACK_ENV"]||"development"ENV["NODE_ENV"]||="development"require"pathname"ENV["BUNDLE_GEMFILE"]||=File.expand_path("../../Gemfile",Pathname.new(__FILE__).realpath)require"bundler/setup"require"webpacker"require"webpacker/webpack_runner"APP_ROOT=File.expand_path("..",__dir__)Dir.chdir(APP_ROOT)doWebpacker::WebpackRunner.run(ARGV)end

./bin/webpackでは環境変数のRAILS_ENVNODE_ENVを規定し、Webpacker::WebpackRunner.run(ARGV)を実行しています。
RAILS_ENVNODE_ENVの中身は./bin/webpack実行時どちらもdevelopmentです。

ではWebpacker::WebpackRunner.run(ARGV)の処理を見に行きましょう。
アプリケーション上の/.bundle/ruby/x.x.x/gems/webpacker-x.x.x/lib/webpacker/webpack_runner.rbが該当します。

github上にはここにコードがあります。

webpack_runner.rb
require"shellwords"require"webpacker/runner"moduleWebpackerclassWebpackRunner<Webpacker::Runnerdefrunenv=Webpacker::Compiler.envcmd=ifnode_modules_bin_exist?["#{@node_modules_bin_path}/webpack"]else["yarn","webpack"]endifARGV.include?("--debug")cmd=["node","--inspect-brk"]+cmdARGV.delete("--debug")endcmd+=["--config",@webpack_config]+@argvDir.chdir(@app_path)doKernel.execenv,*cmdendendprivatedefnode_modules_bin_exist?File.exist?("#{@node_modules_bin_path}/webpack")endendend

パッと見で、webpackのビルドコマンドを構築していることがわかります。
ですが、@app_path@node_modules_bin_path@webpack_configといった不明なインスタンス変数が出てきましたね。
これらのインスタンス変数はこちらで宣言されています。
中身は以下です。

変数名説明サンプル
@app_pathアプリケーションの絶対パス****/app-root
@node_modules_bin_pathアプリケーション内に存在するnode_modulesの絶対パス****/app-root/node_modules
@webpack_config環境に応じたwebpack設定ファイルの絶対パス****/app-root/config/webpack/development.js
(NODE_ENVの中身がdevelopmentだった場合)

つまりKernel.exec env, *cmdで実行している内容は、以下と同一です。

****/app-root/node_modules/.bin/webpack --config****/app-root/config/webpack/development.js

Nodeコマンドで言い換えると

内部処理を追った結果./bin/webpackのビルド処理は以下と同一でした。

./node_modules/.bin/webpack --config ./config/webpack/development.js

yarnならば以下のように置き換えられます。

yarn webpack --config ./config/webpack/development.js

./bin/webpack-dev-server

github上にはここにコードがあります。

./bin/webpack-dev-server
#!/usr/bin/env rubyENV["RAILS_ENV"]||=ENV["RACK_ENV"]||"development"ENV["NODE_ENV"]||="development"require"pathname"ENV["BUNDLE_GEMFILE"]||=File.expand_path("../../Gemfile",Pathname.new(__FILE__).realpath)require"bundler/setup"require"webpacker"require"webpacker/dev_server_runner"APP_ROOT=File.expand_path("..",__dir__)Dir.chdir(APP_ROOT)doWebpacker::DevServerRunner.run(ARGV)end

ほぼ、./bin/webpackと同じですね。
こちらでは、最後にWebpacker::DevServerRunner.run(ARGV)をしているので、その中身を見に行きます。

アプリケーション上の./.bundle/ruby/x.x.x/gems/webpacker-x.x.x/lib/webpacker/dev_server_runner.rbが該当します。

github上にはここにコードがあります。

dev_server_runner.rb
require"shellwords"require"socket"require"webpacker/configuration"require"webpacker/dev_server"require"webpacker/runner"moduleWebpackerclassDevServerRunner<Webpacker::Runnerdefrunload_configdetect_port!execute_cmdendprivatedefload_configapp_root=Pathname.new(@app_path)@config=Configuration.new(root_path: app_root,config_path: app_root.join("config/webpacker.yml"),env: ENV["RAILS_ENV"])dev_server=DevServer.new(@config)@hostname=dev_server.host@port=dev_server.port@pretty=dev_server.pretty?rescueErrno::ENOENT,NoMethodError$stdout.puts"webpack dev_server configuration not found in #{@config.config_path}[#{ENV["RAILS_ENV"]}]."$stdout.puts"Please run bundle exec rails webpacker:install to install Webpacker"exit!enddefdetect_port!server=TCPServer.new(@hostname,@port)server.closerescueErrno::EADDRINUSE$stdout.puts"Another program is running on port #{@port}. Set a new port in #{@config.config_path} for dev_server"exit!enddefexecute_cmdenv=Webpacker::Compiler.envcmd=ifnode_modules_bin_exist?["#{@node_modules_bin_path}/webpack-dev-server"]else["yarn","webpack-dev-server"]endifARGV.include?("--debug")cmd=["node","--inspect-brk"]+cmdARGV.delete("--debug")endcmd+=["--config",@webpack_config]cmd+=["--progress","--color"]if@prettyDir.chdir(@app_path)doKernel.execenv,*cmdendenddefnode_modules_bin_exist?File.exist?("#{@node_modules_bin_path}/webpack-dev-server")endendend

ちょっとコードが長いですが、ざっくり処理を眺めると以下の流れが見えます。

  • load_configconfig/webpacker.ymlからhost,portの設定を取得
  • detect_portで同一hostname,portが使われていないか調査
  • execute_cmdwebpack-dev-server関連のコマンドを実行

ではexecute_cmdの処理は? と確認すると、webpacker/webpack_runner.rbとかなり似ていますね。
つまりexecute_cmdで実行している内容は、以下と同一です。

****/app-root/node_modules/.bin/webpack-dev-server --config****/app-root/config/webpack/development.js

Nodeコマンドで言い換えると

内部処理を追った結果./bin/webpack-dev-serverの実行処理は以下と同一でした。

./node_modules/.bin/webpack-dev-server --config ./config/webpack/development.js --port 3035

(port番号はwebpacker.ymlの初期値)

yarnならば以下のように置き換えられます。

yarn webpack-dev-server --config ./config/webpack/development.js --port 3035

bundle exec rails webpacker:compile

アプリケーション上の./.bundle/ruby/x.x.x/gems/webpacker-x.x.x/lib/tasks/webpacker/compile.rakewebpacker:compileと対応しています。
実行される処理のコードは以下です。

github上にはここにコードがあります。

compile.rake
$stdout.sync=truedefyarn_install_available?rails_major=Rails::VERSION::MAJORrails_minor=Rails::VERSION::MINORrails_major>5||(rails_major==5&&rails_minor>=1)enddefenhance_assets_precompile# yarn:install was added in Rails 5.1deps=yarn_install_available??[]:["webpacker:yarn_install"]Rake::Task["assets:precompile"].enhance(deps)doRake::Task["webpacker:compile"].invokeendendnamespace:webpackerdodesc"Compile JavaScript packs using webpack for production with digests"taskcompile: ["webpacker:verify_install",:environment]doWebpacker.with_node_env(ENV.fetch("NODE_ENV","production"))doWebpacker.ensure_log_goes_to_stdoutdoifWebpacker.compile# Successful compilation!else# Failed compilationexit!endendendendend# Compile packs after we've compiled all other assets during precompilationskip_webpacker_precompile=%w(no false n f).include?(ENV["WEBPACKER_PRECOMPILE"])unlessskip_webpacker_precompileifRake::Task.task_defined?("assets:precompile")enhance_assets_precompileelseRake::Task.define_task("assets:precompile"=>["webpacker:yarn_install","webpacker:compile"])endend

以下の処理がwebpack関連の実行処理ですね。

compile.rake
Webpacker.with_node_env(ENV.fetch("NODE_ENV","production"))doWebpacker.ensure_log_goes_to_stdoutdoifWebpacker.compile# Successful compilation!else# Failed compilationexit!endendend

Webpacker.with_node_env(ENV.fetch("NODE_ENV", "production"))という処理が出てきます。
処理はこちらに記載されていますが、引数で受け取った文字列をENV["NODE_ENV"]に突っ込むという処理を行っていますね。
次にensure_log_goes_to_stdoutというメソッドを実行した後にWebpacker.compileを実行しています。

では次にWebpacker.compileの処理内容を見に行きましょう。
処理はこちらに記載されています。

commands.rb
defcompilecompiler.compile.tapdo|success|manifest.refreshifsuccessendend

今度はcompiler.compileというメソッドを実行しているので、その処理を見に行きます。
こちらにに記載されています。

commands.rb
defcompileifstale?run_webpack.tapdo|success|# We used to only record the digest on success# However, the output file is still written on error, (at least with ts-loader), meaning that the# digest should still be updated. If it's not, you can end up in a situation where a recompile doesn't# take place when it should.# See https://github.com/rails/webpacker/issues/2113record_compilation_digestendelselogger.info"Everything's up-to-date. Nothing to do"trueendend

また色々やっていますが、run_webpack辺りが臭いですね。
なので、こちらに記載されているrun_webpackの中身を見に行きます。

compiler.rb
defrun_webpacklogger.info"Compiling..."stdout,stderr,status=Open3.capture3(webpack_env,"#{RbConfig.ruby} ./bin/webpack",chdir: File.expand_path(config.root_path))ifstatus.success?logger.info"Compiled all packs in #{config.public_output_path}"logger.error"#{stderr}"unlessstderr.empty?ifconfig.webpack_compile_output?logger.infostdoutendelsenon_empty_streams=[stdout,stderr].delete_if(&:empty?)logger.error"Compilation failed:\n#{non_empty_streams.join("\n\n")}"endstatus.success?end

Open3.capture3(webpack_env, "#{RbConfig.ruby} ./bin/webpack")がビルド実行箇所ですね。
変数webpack_env, RbConfig.rubyの中身を確認してみたところ、以下の結果でした。

  • webpack_env = {"WEBPACKER_ASSET_HOST"=>nil, "WEBPACKER_RELATIVE_URL_ROOT"=>nil}
  • RbConfig.ruby = /usr/local/ruby-x.x.x/bin/ruby

つまりbundle exec rails webpacker:compile実行時のビルド処理はざっくり言うと。

  • NODE_ENVproductionにし
  • ./bin/webpackを実行

と同一であると言えます。

Nodeコマンドで言い換えると

内部処理を追った結果bundle exec rails webpacker:compileのビルド処理は以下と同一でした。

NODE_ENV=production ./bin/webpack

前述のように./bin/webpackのコマンドは以下のように置き換えられます。

./node_modules/.bin/webpack --config ./config/webpack/production.js

更にyarnならば以下のように置き換えられます。

yarn webpack --config ./config/webpack/production.js

内部処理を追ってみての感想

どのコマンドも素直なyarnコマンドに置き換えられるなーと感じました。
なので、単純にビルドを実行させたい時は、yarnコマンドでそのまま実行してもよいですね。

最後に調査した各コマンドの対応表を貼っておきます。

Webpacker提供コマンドyarnコマンド
./bin/webpackyarn webpack --config ./config/webpack/development.js
./bin/webpack-dev-serveryarn webpack-dev-server --config ./config/webpack/development.js --port 3035
bundle exec rails webpacker:compileyarn webpack --config ./config/webpack/production.js

それでは皆さん良いwebpackライフを。

さてさて明日は、@tkyowaさんの「技術部門にOKRを導入したら3ヶ月で部の雰囲気がめちゃくちゃ良くなった話」です。
いよいよ最後ですね!
明日もよろしくおねがいします!

Node.js / Denoで始める手書きWebAssembly

$
0
0

Node.js / Denoで始める手書きWebAssembly

この記事は Deno Advent Calendar 2019 10 日目の記事(大遅刻)です。
最近 WebAssembly(以下、Wasm)の text format (wat) を少しだけ勉強しています。

Wasm を動かす環境として、一番ベーシックなのはブラウザ (Chrome / Firefox など) ですが、気軽に書いて試すにはやはり Terminal 上で完結させたいと思いました。
Terminal 上で Wasm を動かすにあたっての選択肢は、下記の 2 つがあります。

  • Node.js (フラグ付き)
  • Deno

本記事では、手書きWasmをコンパイルして上記の2つの環境で動かす方法を紹介します。

用意するもの

  • Node.js v13
  • Deno
  • wabt
    • WebAssembly のツールキット
    • wat を Wasm に変換する wat2wasm を使います

参考文献

初めに

Node.js / DenoでWebAssemblyを動かすには、

  • wasmファイル
  • wasmをロードするJSファイル

が必要となります。

また、wasmファイルを作成するには、

  • WebAssembly Text Format (以下、wat) で記述してWasmにコンパイルする
  • 何かしらの言語からWasmにコンパイルする

などが必要となります。
今回の記事で手書きWebAssemblyと呼んでいるのは、上記のwatのことを指しています。
(Wasmファイルの内容はバイナリなので、特殊な鍛錬を積んだ人以外は手書き出来ないと思います)

watからWasmへのコンパイル

上記の 用意するもので示したツールはインストール済みとします。
ここでは、watファイルの文法には触れず、コンパイルと実行のみを扱うこととします。

下記の内容のファイルを add.watとして保存してください。 

add.wat
(module
  (func (export "add") (param $lhs i32) (param $rhs i32) (result i32)
    local.get $lhs
    local.get $rhs
    i32.add)
)

内容は、2つの引数 ($lhs, $rhs)を受け取って、それらを足した結果を返すadd関数の定義となっています。

こちらを wabtに含まれるwat2wasmで変換します。

wat2wasmの使い方は簡単です。

$ wat2wasm add.wat

とすると、add.wasmが出力されます。
これでwasmファイルが得られたので、続いてこれを実行してみます。

wabtのインストールについての補足

詳細は割愛しますが、wabtのインストールはaptやbrewでサクッと!と言う感じではありません。
リポジトリをcloneした上で、CMakeでビルドする必要があります。
README.mdに記載の手順に従えば基本うまく行くはずなのでトライしてみてください。
こちらの記事ではUbuntuの環境をベースにしているのですが、ビルド結果のバイナリは wabt/out/clang/Debugに格納されていました。
(Clangをまだ入れていない環境だったので、インストールが必要だった気がします)

Wasmをどう実行するか

Node.js / DenoでのWasmの実行方法には2通りあります。
1. WebAssembly.instantiate()を使う
2. ES ModulesのWebAssembly integrationを使う

1. WebAssembly.instantiate()を使う

こちらの方がスタンダードなやり方で、方法としては事前にロードしたWasmのコードをArrayBufferに格納してWebAssembly.instantiate()関数に渡すというものです。
JavaScript側で事前に確保したメモリや、JavaScript側で定義した関数への参照を持たせた importObjectをセットで渡して初期化することも出来ます。

コードで見た方がより伝わりやすいと思いますので、MDNの例を下記に引用します。

varimportObject={imports:{imported_func:function(arg){console.log(arg);}}};fetch('simple.wasm').then(response=>response.arrayBuffer()).then(bytes=>WebAssembly.instantiate(bytes,importObject)).then(result=>result.instance.exports.exported_func());

MDN - WebAssembly.instantiate()

なお、ChromeやFirefox等のブラウザ上では、より効率的にWasmをロード可能なWebAssembly.instantiateStreaming()関数が存在するのでそちらを利用するのが推奨されています。

2. ES ModulesのWebAssembly integrationを使う

こちらはまだ仕様が確定していないものですが、より手軽なので今回はこちらを使います。
内容としては、Wasm側でexportされた関数やメモリへの参照などを直接ES Modules (以下、ESM) でimport出来ると言うものです。
詳しくは @bellbindさんの Qiita - 2019年のWebAssembly事情をご覧いただくのが参考になると思います。

今回作成したadd関数を使うには、次のようにします。

add.js
import{add}from'./add.wasm';console.log(add(1,2));// => 3

非常に簡単に使えることが伝わったかと思います。
この後実際にこのコードを実行してみるので、上記の内容を、add.jsとして保存しておきましょう。

Node.jsでWasmを動かす

Node.jsでは、WebAssemblyはまだフラグ付きでないと実行できません。
また、 .jsファイルでESMを使うには、package.jsonに設定を追加する必要があります(Node.js 12以前は.mjsでないと動きません)。

それでは、まず下記の内容を含んだpackage.jsonを用意しましょう。

{"type":"module"}

今回は、この内容しか含まないpackage.jsonを用意してしまっても大丈夫です。

続いて、Node.jsをフラグ付きで起動します。
すると、下記のとおりに結果の 3が表示されるはずです。

$ node --experimental-wasm-modules add.js
(node:771) ExperimentalWarning: The ESM module loader is experimental.
(node:771) ExperimentalWarning: Importing Web Assembly modules is an experimental feature. This feature could change at any time
3

もし起動しないようであれば、Node.jsのバージョンを確認してください。

DenoでWasmを動かす

DenoでのWasmの実行はもっと簡単です。
Denoは初めからESMにも、Wasmにもフラグ無しで対応しているので下記の内容を実行するだけで完了です。

$ deno add.js
3

とてもお手軽ですよね?

おわりに

この記事では、Node.js / DenoとESMでWasmを動かす方法を紹介しました。
最後に書いた通り、DenoでのWasmのロード・実行はとても簡単なので、Text Formatの実行環境として練習に適していると思います。
この冬休み、ぜひDenoとWebAssemblyで遊んでみてください!

自分に合った市販の花粉症薬を探すアプリの作成

$
0
0

概要

プログラムの勉強を始めて5か月ほどの開業医です。

そろそろスギ花粉の季節ですね。

医療費を削減する目的で一部の花粉症の薬が今後医療機関で処方できなくなるかもしれないといわれています。そうすると市販の薬で対応しなけらばならない花粉症患者さんが少なからず出てきそうです。そのような時に自分に合った花粉症の薬が探せるアプリがあれば良いかと思い作ってみました。

今回Electronでデスクトップアプリを作成する勉強をしたので、先日勉強したAuth0のユーザー認証も実装して自分に最適な花粉症薬を探すことが出来るデスクトップアプリを作成してみようと思いました。

花粉症の重症度が分かるWEBアプリの作成~Auth0でユーザー認証~

動作確認

今回デスクトップアプリは出来ましたが、Auth0によるユーザー認証が実装できませんでした。

実装内容

・眠気の強さや効き目の強さ、服用回数、値段など自分の好みの花粉症の薬を探すことが出来るデスクトップアプリ。
・ユーザー認証機能は実装できず。

作成方法

1.Electronの導入
Electronとは簡単にでデスクトップアプリを開発できるものです。
Electron

まず適当にフォルダを作り、以下のコマンドを打ちます。

git clone https://github.com/electron/electron-quick-start

フォルダに移動します。

cd electron-quick-start

インストールします。

npm i

起動します。

npm start

このような画面が出てくればOKです。

electron起動.png

2.HTMLの作成
サンプルのHTMLを変えていきます。
眠気の強さや効き目の強さ>服用回数>値段で花粉症薬を絞り込んでいくようにしました。
今回はmaterializeを使用しています。

<h3>あなたに最適な花粉症の薬を探そう!</h3><h3></h3><h5>眠気の出やすさで薬を探す</h5><formaction="#"><p><label><inputclass="with-gap"name="group1"type="radio"value="候補薬剤:フェキソフェナジン、ロラタジン"id="med1"/><span>眠気が出る可能性がほとんどない薬(使用中の自動車運転制限なし)</span></label></p><p><label><inputclass="with-gap"name="group1"type="radio"value="候補薬剤:エピナスチン、エバスチン"id="med2"/><span>眠気が出る可能性がすこしある薬(使用中の自動車運転は注意が必要)</span></label></p><p><label><inputclass="with-gap"name="group1"type="radio"value="候補薬剤:セチリジン、ケトチフェン"id="med3"/><span>眠気の出る可能性があるが、効果も期待できる薬(使用中の自動車運転はひかえる)</span></label></p></form><pclass="result1"id="res1">候補薬剤:</p><h5>服用回数で絞り込み</h5><formaction="#"><p><label><inputclass="with-gap"name="group2"type="radio"value="候補薬剤:ロラタジン、エピナスチン、エバスチン、セチリジン"id=""/><span>1日1回</span></label></p><p><label><inputclass="with-gap"name="group2"type="radio"value="候補薬剤:フェキソフェナジン、ケトチフェン"id=""/><span>1日2回</span></label></p></form><pclass="result2">候補薬剤:</p><h5>価格で絞り込み</h5><aclass="waves-effect waves-light btn-large"id="search">薬を検索</a><pclass="result3">あなたが探しているアレルギー薬は:</p><pclass="comment"></p>

Scriptは以下のように書きました。
今回はjqueryを使用しています。

//group1   letreco_medi1="";$('input[name="group1"]').change(function(){$('.result2').html("");$('input[name="group2"]').prop('checked',false);varresult=$(this).val();if(result=="候補薬剤:フェキソフェナジン、ロラタジン"){reco_medi1="フェキソフェナジン:ロラタジン";}elseif(result=="候補薬剤:エピナスチン、エバスチン"){reco_medi1="エピナスチン:エバスチン"}else{reco_medi1="セチリジン:ケトチフェン"}$('.result1').html(reco_medi1);console.log(reco_medi1);let[compo1,compo2]=reco_medi1.split(":");letcompo3,compo4;//group2    letreco_medi2="";$('input[name="group2"]').change(function(){varresult=$(this).val();if(result=="候補薬剤:ロラタジン、エピナスチン、エバスチン、セチリジン"){if(compo2=="ロラタジン"){reco_medi2="ロラタジン";}elseif(compo1=="エピナスチン"){reco_medi2="エピナスチン:エバスチン";compo2="エバスチン";//}elseif(compo1=="セチリジン"){reco_medi2="セチリジン";}else{reco_medi2="候補薬剤はありません。再選択して下さい。";};}else{if(compo1=="フェキソフェナジン"){reco_medi2="フェキソフェナジン"}elseif(compo2=="ケトチフェン")reco_medi2="ケトチフェン"else{reco_medi2="候補薬剤はありません。再選択して下さい。";}}$('.result2').html(reco_medi2);});//価格表示letreco_medi3="テスト";letcomment;letimg_src;$("#search").click(function(){console.log(reco_medi2,reco_medi3);if(reco_medi2=="フェキソフェナジン"){reco_medi3="アレルビ";comment="*この薬剤は通販でもお買い求め頂けます。"}elseif(reco_medi2=="ロラタジン"){reco_medi3="クラリチンEX";comment="*この薬剤は薬局でのみお買い求め頂けます。";console.log(reco_medi2,reco_medi3)}elseif(reco_medi2=="エピナスチン:エバスチン"){reco_medi3="アレジオン20";comment="*この薬剤は通販でもお買い求め頂けます。";}elseif(reco_medi2=="セチリジン"){reco_medi3="ストナリニZ";comment="*この薬剤は通販でもお買い求め頂けます。";}elseif(reco_medi2=="ケトチフェン"){reco_medi3="ザジテンAL鼻炎カプセル";comment="*この薬剤は通販でもお買い求め頂けます。";}$(".result3").html(`<h7>あなたが探しているアレルギー薬は:${reco_medi3}</h7>`);$('.comment').html(`${comment}`);// $('.image').src=img_src;$('.image').html(`${comment}`);console.log(reco_medi2,reco_medi3);});});

[View]の[Reload]をクリックするとリロードされます。

image.png

3.Auth0の導入(今回うまくいきませんでした。)

expressインストール

npm i express

public フォルダ作成
image.png

publicの中にindex.html を移動

main.jsを以下に書き換える

// Modules to control application life and create native browser windowconst{app,BrowserWindow}=require('electron')constpath=require('path')// express settingconstexpress=require('express');constappExpress=express();appExpress.use(express.static(__dirname+'/public'));appExpress.listen(3000);// Keep a global reference of the window object, if you don't, the window will// be closed automatically when the JavaScript object is garbage collected.letmainWindowfunctioncreateWindow(){// Create the browser window.mainWindow=newBrowserWindow({width:800,height:600,webPreferences:{preload:path.join(__dirname,'preload.js')}})// and load the index.html of the app.// mainWindow.loadFile('index.html')// ログインページにアクセスして、画面に表示するmainWindow.loadURL("http://localhost:3000/");// Open the DevTools.// mainWindow.webContents.openDevTools()// Emitted when the window is closed.mainWindow.on('closed',function(){// Dereference the window object, usually you would store windows// in an array if your app supports multi windows, this is the time// when you should delete the corresponding element.mainWindow=null})}// This method will be called when Electron has finished// initialization and is ready to create browser windows.// Some APIs can only be used after this event occurs.app.on('ready',createWindow)// Quit when all windows are closed.app.on('window-all-closed',function(){// On macOS it is common for applications and their menu bar// to stay active until the user quits explicitly with Cmd + Qif(process.platform!=='darwin')app.quit()})app.on('activate',function(){// On macOS it's common to re-create a window in the app when the// dock icon is clicked and there are no other windows open.if(mainWindow===null)createWindow()})// In this file you can include the rest of your app's specific main process// code. You can also put them in separate files and require them here.

サンプルファイル自動作成で 素のJavaScriptのVanillaJSログインファイル一式を持っておきます。
サンプルファイル自動作成で指示される、Application Settings の Allowed Callback URLs、Allowed Web Origins、Allowed Logout URLsでの http://localhost:3000の許可設定は忘れないようにしておきます。

参照:花粉症の重症度が分かるWEBアプリの作成~Auth0でユーザー認証~

Electron側の最上部のフォルダに、 Auth0サンプル側の auth_config.json を移植します。
publicフォルダに、Auth0サンプル側のpublicフォルダにあるcss,images,jsフォルダを移植します。

起動して完成ですが…

npm start

ログインボタンがありません。Auth0がうまく導入できていないようです。

image.png

考察
残念ながらログイン認証機能が実装できませんでした。
今度は自分に最適な花粉症薬を探すことが出来るLINE Botを作ってみようと思います。

Auth0ラボ - その2 : Calling an API

$
0
0

はじめに

この記事はAuth0のハンズオンラボでAuth0 Identity Labsを元に作成しています。Node.js + Express.jsで作成されたSample ApplicationとAPIを利用して、Auth0から払い出された認可情報を元にApplicationからAPIを呼び出します。Auth0ラボ - その1 : Web Sign-Inが完了していることが前提となっているため、未だの方はこちらからお試しお願いします。

検証環境

  • OS : macOS Catalina 10.15.2
  • node : 10.15.3
  • npm : 6.13.2
  • Git : 2.23.0

ラボ

Part1

Part1ではApplicationからAccess Tokenを使ってAPIを呼び出すようにします。Git Repoをローカルにクローンします。

$ git clone https://github.com/auth0/identity-102-exercises.git
$cd identity-102-exercises/lab-02/begin
$lsapi webapp

apiディレクトリに移動してNode.jsのパッケージをインストール、環境変数定義ファイルを作成します。

$pwd~/identity-102-exercises/lab-02/begin/api
$ npm install$cp .env-sample .env

webappディレクトリに移動してNode.jsのパッケージをインストール、環境変数定義ファイルを作成します。

$pwd~/identity-102-exercises/lab-02/begin/webapp
$ npm install$cp .env-sample .env

api, webappを起動してChromeでhttp://localhost:3000にアクセスします。任意のユーザでSign-upして"Expenses"をクリックするとAPIが呼び出されて経費情報が表示されます。
この時点ではAPIは認可情報を元に保護されていないため、誰でもアクセスできてしまいます。後続の手順で認可情報を含むトークン(Access Token)でAPIにアクセスできるように修正します。

$pwd~/identity-102-exercises/lab-02/begin/api
$ npm start &
$cd ../webapp
$ npm start &

webapp/server.jsを修正してAuth0からAccess Tokenを受け取るようにします。以下、修正後のコードです。
"response_type: 'code id_token'"を指定することで、Expressミドルウェアが認可コードを元にAccess TokenをApplicationに払い出します。"audience", "scope"はアクセス対象のAPI/ScopeをExpressミドルウェアに渡しています。

webapp/server.js
require('dotenv').config();constexpress=require('express');consthttp=require('http');constmorgan=require('morgan');constsession=require('cookie-session');constrequest=require('request-promise');const{auth,requiresAuth}=require('express-openid-connect');constappUrl=process.env.BASE_URL||`http://localhost:${process.env.PORT}`;constapp=express();app.set('view engine','ejs');app.use(morgan('combined'));app.use(session({name:'identity102-lab-02',secret:process.env.COOKIE_SECRET,}));app.use(express.urlencoded({extended:false}));/* 以下をコメントアウト
app.use(auth({
  required: false,
  auth0Logout: true
}));
*/// 下記を9行を追加app.use(auth({required:false,auth0Logout:true,authorizationParams:{response_type:'code id_token',audience:process.env.API_AUDIENCE,scope:'openid profile email read:reports'}}));app.get('/',(req,res)=>{res.render('home',{user:req.openid&&req.openid.user});});app.get('/user',requiresAuth(),(req,res)=>{res.render('user',{user:req.openid&&req.openid.user});});app.get('/expenses',requiresAuth(),async(req,res,next)=>{try{constexpenses=awaitrequest(process.env.API_URL,{json:true});res.render('expenses',{user:req.openid&&req.openid.user,expenses,});}catch(err){next(err);}});app.get('/logout',(req,res)=>{req.session=null;res.redirect('/');});app.use((err,req,res,next)=>{console.error(err.stack);res.status(500).send(err);});http.createServer(app).listen(process.env.PORT,()=>{console.log(`listening on ${appUrl}`);});

webapp/server.jsを修正してAuth0から払い出されたAccess Tokenを使ってAPIにアクセスできるようにします。以下、修正後のコードです。
これによって、Applicationにログインしたユーザに割り当てられた認可情報を持ったAccess Tokenを使って、ユーザの代わりにApplicationがAPIを呼び出せるようになります。

webapp/server.js
require('dotenv').config();constexpress=require('express');consthttp=require('http');constmorgan=require('morgan');constsession=require('cookie-session');constrequest=require('request-promise');const{auth,requiresAuth}=require('express-openid-connect');constappUrl=process.env.BASE_URL||`http://localhost:${process.env.PORT}`;constapp=express();app.set('view engine','ejs');app.use(morgan('combined'));app.use(session({name:'identity102-lab-02',secret:process.env.COOKIE_SECRET,}));app.use(express.urlencoded({extended:false}));app.use(auth({required:false,auth0Logout:true,authorizationParams:{response_type:'code id_token',audience:process.env.API_AUDIENCE,scope:'openid profile email read:reports'}}));app.get('/',(req,res)=>{res.render('home',{user:req.openid&&req.openid.user});});app.get('/user',requiresAuth(),(req,res)=>{res.render('user',{user:req.openid&&req.openid.user});});app.get('/expenses',requiresAuth(),async(req,res,next)=>{try{/* 以下をコメントアウト
    const expenses = await request(process.env.API_URL, {
      json: true
    });
    */// 以下5行を追加consttokenSet=req.openid.tokens;constexpenses=awaitrequest(process.env.API_URL,{headers:{authorization:"Bearer "+tokenSet.access_token},json:true});res.render('expenses',{user:req.openid&&req.openid.user,expenses,});}catch(err){next(err);}});app.get('/logout',(req,res)=>{req.session=null;res.redirect('/');});app.use((err,req,res,next)=>{console.error(err.stack);res.status(500).send(err);});http.createServer(app).listen(process.env.PORT,()=>{console.log(`listening on ${appUrl}`);});

webapp/.envを修正してAPIの識別子とApplicationのSecretを追加します。以下、修正後の.envです。
この時点でAPIはAuth0に登録されていないため識別子は任意で構いません。Part2でAPIをAuth0に登録する際に必要となるため控えておいて下さい。ApplicationのSecretはAuth0 Dashboardの"Applications"->"その1で作成したApplication"->"Settings"から確認できます。

webapp/.env
ISSUER_BASE_URL=https://kiriko.auth0.com
CLIENT_ID=rJbxtul1gfExXU9K5610LgBR4SpF2d4R
API_URL=http://localhost:3001
BASE_URL=http://localhost:3000
PORT=3000
COOKIE_SECRET=xOTLraNhuwMkwpxltUoKiOPjJyPtvYvSHz9EbsIQG7w
API_AUDIENCE=https://expenses-api
CLIENT_SECRET=xxxx

Chromeに戻りログアウト後、再度ログインを押します。"message": "access_denied (Service not found: https://expenses-api)"が表示されれば成功です。
この時点でAPIはAuth0に登録されていないためエラーになります。

Part2

Part2ではAPIをAuth0に登録してAuth0がAccess Tokenを払い出せるようにします。Auth0 Dashboardの左ペイン"APIs"をクリック、右上の"CREATE API"を押します。

"Name"に任意の名前を入力し、"Identifier"に"https://expenses-api"を入力して"CREATE"を押します。
IdentifierはPar1で.envに指定した識別子と同一である必要があります。

"Permissions"タブをクリック、"Define all the permissionsxxx"に"read:reports"を、"Description"に任意の説明文を入力して"ADD"を押します。
書式はOAuth2.0で規定されているxxxx:xxxxに従う必要があります。

ターミナルに戻りwebapp, apiを再起動してChromeからhttp://localhost:3000にアクセス、任意のユーザでログイン後、コンセント画面が表示されれば成功です。
Applicationが認可サーバが管理しているAPIにアクセスしようとしていますがよろしいですか?とリソースオーナーであるユーザに確認しています。

$pwd~/identity-102-exercises/lab-02/begin/api
$ npm start &
$cd ../webapp
$ npm start &

Chromeから直接API(http://localhost:3001)にアクセスして見ます。下記のメッセージが表示されます。
この時点では、APIがAccess Tokenに含まれているScopeをチェックしていないためアクセスできてしまいます。

[{"date":"2019-12-25T03:02:25.637Z","description":"Pizza for a Coding Dojo session.","value":102},{"date":"2019-12-25T03:02:25.637Z","description":"Coffee for a Coding Dojo session.","value":42}]

APIディレクトリでexpress-oauth2-bearerパッケージをインストールします。
このパッケージでApplicationから送信されてくるリクエストに含まれるAccess Tokenのチェックを行います。

$pwd~/identity-102-exercises/lab-02/begin/api
$ npm install express-oauth2-bearer

api/api-server.jsを修正してexpress-oauth2-bearerを読み込みます。以下、修正後のコードです。

api/api-server.js
require('dotenv').config();// 以下1行を追加const{auth,requiredScopes}=require('express-oauth2-bearer');constexpress=require('express');consthttp=require('http');constappUrl=process.env.BASE_URL||`http://localhost:${process.env.PORT}`;constapp=express();app.get('/',(req,res)=>{res.send([{date:newDate(),description:'Pizza for a Coding Dojo session.',value:102,},{date:newDate(),description:'Coffee for a Coding Dojo session.',value:42,}]);});http.createServer(app).listen(process.env.PORT,()=>{console.log(`listening on ${appUrl}`);});

api/api-server.jsを修正してExpressミドルウェアの認証処理を追加します。以下、修正後のコードです。

api/api-server.js
require('dotenv').config();const{auth,requiredScopes}=require('express-oauth2-bearer');constexpress=require('express');consthttp=require('http');constappUrl=process.env.BASE_URL||`http://localhost:${process.env.PORT}`;constapp=express();// 以下1行を追加app.use(auth());app.get('/',(req,res)=>{res.send([{date:newDate(),description:'Pizza for a Coding Dojo session.',value:102,},{date:newDate(),description:'Coffee for a Coding Dojo session.',value:42,}]);});http.createServer(app).listen(process.env.PORT,()=>{console.log(`listening on ${appUrl}`);});

api/api-server.jsを修正して、End Pointに送信されてくるAccess Tokenをチェックするようにします。以下、修正後のコードです。
これ以降、End Pointに送信されてくるAccess Tokenは必ずチェックされ、有効期限が切れている、Scopeが合っていない等の不正がある場合はエラーが返ってくるようになります。

api/api-server.js
require('dotenv').config();const{auth,requiredScopes}=require('express-oauth2-bearer');constexpress=require('express');consthttp=require('http');constappUrl=process.env.BASE_URL||`http://localhost:${process.env.PORT}`;constapp=express();app.use(auth());// 以下をコメントアウト// app.get('/', (req, res) => {// 以下1行を追加app.get('/',requiredScopes('read:reports'),(req,res)=>{res.send([{date:newDate(),description:'Pizza for a Coding Dojo session.',value:102,},{date:newDate(),description:'Coffee for a Coding Dojo session.',value:42,}]);});http.createServer(app).listen(process.env.PORT,()=>{console.log(`listening on ${appUrl}`);});

api/.envのISSUER_BASE_URLをAuth0のテナントドメイン名に修正します。

api/.env
PORT=3001
ISSUER_BASE_URL=https://kiriko.auth0.com
ALLOWED_AUDIENCES=https://expenses-api

ターミナルに戻りwebapp, apiを再起動してChromeからhttp://localhost:3000にアクセス、任意のユーザでログイン後、APIにアクセスできることを確認します。Chromeからダイレクトにhttp://localhost:3001にアクセスします。以下のエラーが返ってくれば成功です。
Access Tokenがリクエストに含まれていないためエラーになります。

UnauthorizedError: bearer token is missing
    at Object.createInvalidTokenError (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express-oauth2-bearer/lib/errors.js:15:12)
    at /Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express-oauth2-bearer/index.js:43:26
    at Layer.handle [as handle_request] (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/layer.js:95:5)
    at trim_prefix (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/index.js:317:13)
    at /Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/index.js:284:7
    at Function.process_params (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/index.js:335:12)
    at next (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/index.js:275:10)
    at expressInit (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/middleware/init.js:40:5)
    at Layer.handle [as handle_request] (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/layer.js:95:5)
    at trim_prefix (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/index.js:317:13)

Part3

Part3ではRefresh Tokenを利用してID Token, Access Tokenをリフレッシュできるようにします。ID TokenやAccess Tokenはネットワークを経由して複数のサービスで使いまわされるため常に漏洩の危険があります。各々に適切な有効期限を設けて、有効期限が切れた場合はRefresh Tokenを認可サーバのToken End Pointに送信し、Tokenを再発行するようにして漏洩を防止します。

Auth0 Dashbord左ペインの"APIs"->"作成したAPI"->"Settings"の"Allow Offline Access"フリップスイッチをオンにして"SAVE"を押します。
ユーザが認証されたタイミングでID Token, Access Tokenと一緒にRefresh Tokenが払い出されます。

webapp/server.jsを修正してExpressミドルウェアに渡すScopeを変更します。以下、修正後のコードです。

webapp/server.js
require('dotenv').config();constexpress=require('express');consthttp=require('http');constmorgan=require('morgan');constsession=require('cookie-session');constrequest=require('request-promise');const{auth,requiresAuth}=require('express-openid-connect');constappUrl=process.env.BASE_URL||`http://localhost:${process.env.PORT}`;constapp=express();app.set('view engine','ejs');app.use(morgan('combined'));app.use(session({name:'identity102-lab-02',secret:process.env.COOKIE_SECRET,}));app.use(express.urlencoded({extended:false}));app.use(auth({required:false,auth0Logout:true,authorizationParams:{response_type:'code id_token',audience:process.env.API_AUDIENCE,// 以下1行を修正scope:'openid profile email read:reports offline_access'}}));app.get('/',(req,res)=>{res.render('home',{user:req.openid&&req.openid.user});});app.get('/user',requiresAuth(),(req,res)=>{res.render('user',{user:req.openid&&req.openid.user});});app.get('/expenses',requiresAuth(),async(req,res,next)=>{try{/*
    const expenses = await request(process.env.API_URL, {
      json: true
    });
    */consttokenSet=req.openid.tokens;constexpenses=awaitrequest(process.env.API_URL,{headers:{authorization:"Bearer "+tokenSet.access_token},json:true});res.render('expenses',{user:req.openid&&req.openid.user,expenses,});}catch(err){next(err);}});app.get('/logout',(req,res)=>{req.session=null;res.redirect('/');});app.use((err,req,res,next)=>{console.error(err.stack);res.status(500).send(err);});http.createServer(app).listen(process.env.PORT,()=>{console.log(`listening on ${appUrl}`);});

webapp/server.jsを修正して、/expenses End PointにTokenの有効期限をチェックするコードを追加します。以下、修正後のコードです。

webapp/server.js
require('dotenv').config();constexpress=require('express');consthttp=require('http');constmorgan=require('morgan');constsession=require('cookie-session');constrequest=require('request-promise');const{auth,requiresAuth}=require('express-openid-connect');constappUrl=process.env.BASE_URL||`http://localhost:${process.env.PORT}`;constapp=express();app.set('view engine','ejs');app.use(morgan('combined'));app.use(session({name:'identity102-lab-02',secret:process.env.COOKIE_SECRET,}));app.use(express.urlencoded({extended:false}));app.use(auth({required:false,auth0Logout:true,authorizationParams:{response_type:'code id_token',audience:process.env.API_AUDIENCE,scope:'openid profile email read:reports offline_access'}}));app.get('/',(req,res)=>{res.render('home',{user:req.openid&&req.openid.user});});app.get('/user',requiresAuth(),(req,res)=>{res.render('user',{user:req.openid&&req.openid.user});});app.get('/expenses',requiresAuth(),async(req,res,next)=>{try{/*
    const expenses = await request(process.env.API_URL, {
      json: true
    });
    */// 以下1行をコメントアウト// const tokenSet = req.openid.tokens;// 以下6行を追加lettokenSet=req.openid.tokens;if(tokenSet.expired()){tokenSet=awaitreq.openid.client.refresh(tokenSet);tokenSet.refresh_token=req.openid.tokens.refresh_token;req.openid.tokens=tokenSet;}constexpenses=awaitrequest(process.env.API_URL,{headers:{authorization:"Bearer "+tokenSet.access_token},json:true});res.render('expenses',{user:req.openid&&req.openid.user,expenses,});}catch(err){next(err);}});app.get('/logout',(req,res)=>{req.session=null;res.redirect('/');});app.use((err,req,res,next)=>{console.error(err.stack);res.status(500).send(err);});http.createServer(app).listen(process.env.PORT,()=>{console.log(`listening on ${appUrl}`);});

Auth0 Dashboardの"APIs"->"作成したAPI"->"Settings"の"Token Expiration (Seconds)", "Token Expiration For Browser Flows (Seconds)"を各々10にして"SAVE"を押します。
この記事ではRefresh Tokenを使ったTokenの再発行を試すため有効期限を極端に短くしています。実際は、サービスの要件に従って適切な有効期限を設定して下さい。

api/api-server.jsを修正してIssu At Time(Tokenが発行された時間)をターミナルに出力します。以下、修正後のコードです。

api/api-server.js
require('dotenv').config();const{auth,requiredScopes}=require('express-oauth2-bearer');constexpress=require('express');consthttp=require('http');constappUrl=process.env.BASE_URL||`http://localhost:${process.env.PORT}`;constapp=express();app.use(auth());// app.get('/', (req, res) => {app.get('/',requiredScopes('read:reports'),(req,res)=>{// 以下1行を追加console.log(newDate(req.auth.claims.iat*1000));res.send([{date:newDate(),description:'Pizza for a Coding Dojo session.',value:102,},{date:newDate(),description:'Coffee for a Coding Dojo session.',value:42,}]);});http.createServer(app).listen(process.env.PORT,()=>{console.log(`listening on ${appUrl}`);});

ターミナルに戻りwebapp, apiを再起動してChromeからhttp://localhost:3000にアクセス、任意のユーザでログイン後、expenses End Pointにアクセスできることを確認します。何度かEnd Pointにアクセスして、ターミナルに出力されるIssue At Timeが異なる=Refresh Tokenを使ってID, Access Tokenが再発行されている, ことが確認できたら成功です。

2019-12-25T05:07:51.000Z
2019-12-25T05:08:11.000Z
2019-12-25T05:08:11.000Z
2019-12-25T05:08:26.000Z
2019-12-25T05:08:26.000Z

おわりに

最後までお付き合い頂きありがとうございます。Micro Service Architecture全盛の昨今、フロントエンドアプリケーションからバックエンドの複数のリソースサーバを呼び出すタイプのサービスアーキテクチャが標準になっており、可搬性・拡張性に優れたTokenベースの認証は不可欠かと思います。Auth0はTokenの有効期限を設定したり、Refresh Tokenを使ってTokenを再発行したりする機能を準備しているので、Application開発者様はTokenの管理を気にするこなく本来の開発作業に集中頂くことができます。

【Node.js + Sheets API v4】Googleスプレッドシートを読み書きする

$
0
0

はじめに

Node.jsでGoogleスプレッドシートを読み書きする。

使うもの:

  • Node.js v10.16.0
  • Google Sheets API v4
  • Visual Studio Code

Google Sheets API を使えるようにする

Node.js Quickstart  |  Sheets API  |  Google Developers

上記サイトを参考に、まず Google Sheets API を使えるようにする。「Enable the Google Sheets API」のボタンを押してしばらく待つと、Google Cloud PlatformにQuickstartという名前のプロジェクトが作成され、Client IDやシークレットと共に設定ファイルをダウンロードできるようになる。

↓クリックしてしばらく待った後の画面。

ダウンロードしたcredentials.jsonは、Node.jsプロジェクトのルートフォルダなど扱いやすい場所に置く。ダウンロードしそこねた場合は、Developer Console→認証情報からダウンロードできる。

ソース管理しているフォルダに配置した場合は、credentials.jsonをソース管理対象外にして、うっかりコミットすることを防いでおく。

ライブラリのインストール

VS Code のターミナル、またはWindowsコマンドウィンドウで以下を実行する。

npm install googleapis

Visual Studio Code で readline を使えるようにする

Quickstartのサンプルでは、OAuthトークンをreadlineで受け取ろうとしている。しかし、Visual Studio Codeのコンソールは出力専用なので、VS Codeでスクリプトを実行すると入力の受け取りができない。このままではデバッグできないので、launch.jsonの設定を変え、コンソールを外部のものにする必要がある。

launch.jsonを開くには、メニューの「デバッグ」→「構成を開く」を選ぶ。

するとlaunch.jsonが開くので、configurationsの中に以下を追加する。

"console": "externalTerminal"

これでデバック実行時にコマンドプロンプトが起動するようになり、コンソール入力の受け取りが可能になる。

サンプルの実行

Quickstartに載っているサンプルをコピーし、プロジェクトルートのindex.jsに貼り付ける。これでサンプルを実行できる状態になった。

↓実行できる状態のフォルダ

メニューの「デバッグ」→「デバッグの開始」で実行すると、Windowsの黒いコンソール画面が起動し、認証のためのURLが表示されるので、それをコピーしてブラウザに貼り付ける。

Quickstartのアプリを許可するアカウントを選ぶ。

Quickstartのアプリは先ほど作ったばかりでGoogleが安全と確認していない。そのためセキュリティ警告が表示されるが、詳細を表示して「Quickstartに移動」を選ぶ。

この後、スプレッドシートの読み取りの許可を与えるかどうか確認されるので、「許可」を選択する。許可が終わると、アプリ側へ返すためのコードが表示されるので、これをコピーする。

コピーしたコードをコンソールに貼り付けると、スプレッドシートの内容が読み取られてコンソールに表示される。

これでQuickstartの実行は成功である。

ちなみに元データとなったスプレッドシートは、Googleがサンプルとして提供しているものを使っている。
https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit

OAuth認証を得るまでのサンプル

Quickstartのものをそのまま使っても良いが、コールバック地獄が個人的に好きではないので、同期的にスクリプトを書けるようにしたものを以下に掲載する。spreadsheet.jsinit()メソッドを呼び出せば、必要な場合にOAuthトークンをリクエストし、リクエスト済みであれば、保存したトークンを読み込むようにしている。

spreadsheet.js
constfs=require("fs");constreadline=require("readline");const{google}=require("googleapis");constSCOPES=["https://www.googleapis.com/auth/spreadsheets"];// 読み書き可constCREDENTIALS_PATH="credentials.json";// アプリ側の認証情報constTOKEN_PATH="token.json";// トークン保存場所letoAuth2Client=null;// API実行に必要な認証情報/**
 * モジュール初期化。
 * token.jsonがあればそれを読込み、無ければユーザーに認証を求める。
 */module.exports.init=asyncfunction(){// 認証letcredentialContent=fs.readFileSync(CREDENTIALS_PATH);letcredentials=JSON.parse(credentialContent);oAuth2Client=newgoogle.auth.OAuth2(credentials.installed.client_id,credentials.installed.client_secret,credentials.installed.redirect_uris[0]);if(!fs.existsSync(TOKEN_PATH)){awaitgetNewToken();}lettokenContent=fs.readFileSync(TOKEN_PATH);lettoken=JSON.parse(tokenContent);oAuth2Client.setCredentials(token);};/**
 * 新しいトークンを取得する
 */asyncfunctiongetNewToken(){constauthUrl=oAuth2Client.generateAuthUrl({access_type:"offline",scope:SCOPES});console.log("このURLへアクセスしてアプリを承認してください:",authUrl);constrl=readline.createInterface({input:process.stdin,output:process.stdout});returnnewPromise(function(resolve,reject){rl.question("承認後に表示されたコードを入力してください:",async(code)=>{rl.close();let{tokens}=awaitoAuth2Client.getToken(code);// tokenを保存するfs.writeFileSync(TOKEN_PATH,JSON.stringify(tokens));console.log("トークンを以下のファイルへ保存しました。",TOKEN_PATH);});});}

スプレッドシートIDの取得方法

読み書きするスプレッドシートは、スプレッドシートIDというもので特定する。
ブラウザでスプレッドシートを開いたときのURLの、/d/から/editの間がスプレッドシートIDとなる。これをコピーしてスクリプトで使用する。

範囲(range)の指定方法

基本的にExcelと同じ。

例:

  • Sheet1!A1:B2
  • Sheet1!A:A A列すべてのセル
  • Sheet1!1:2 1~2行目のすべてのセル
  • Sheet1!A5:A A5以降のA列すべてのセル
  • A1:B2シート名を省略した場合、最初の可視なシートが対象になる。
  • Sheet1 Sheet1シートすべてのセル

セルの取得

sheets.spreadsheets.values.get()を使う。

Method: spreadsheets.values.get  |  Sheets API  |  Google Developers

以下のスプレッドシートから取得する場合のサンプル。

spreadsheet.js(一部)
const{google}=require("googleapis");constSPREADSHEET_ID="xxxxx";letoAuth2Client=null;// API実行に必要な認証情報module.exports.getSheetData=asyncfunction(){constsheets=google.sheets({version:"v4"});constparam={spreadsheetId:SPREADSHEET_ID,range:"シート1",auth:oAuth2Client};letresponse=awaitsheets.spreadsheets.values.get(param);letdata=response.data;console.log(JSON.stringify(data));}

↓実行結果(変数dataの中身)

{"range":"'シート1'!A1:Z1000","majorDimension":"ROWS","values":[["日付","商品","個数"],["2019/12/1","りんご","10"],["2019/12/2","バナナ","20"],["2019/12/3","みかん","30"]]}

データは2次元配列になっていて、日付も数値も文字列として取得される。

セルの更新

単純な更新

Method: spreadsheets.values.update  |  Sheets API  |  Google Developers

↓サンプル

spreadsheet.js(一部)
const{google}=require("googleapis");constSPREADSHEET_ID="xxxxx";letoAuth2Client=null;// API実行に必要な認証情報module.exports.addData=asyncfunction(){constsheets=google.sheets({version:"v4"});constparam={spreadsheetId:SPREADSHEET_ID,range:"シート1!A5",valueInputOption:"USER_ENTERED",auth:oAuth2Client,resource:{values:[["2019/12/4","もも","40"]]}};awaitsheets.spreadsheets.values.update(param);};

↓実行結果

表の末尾にデータを追加する

Method: spreadsheets.values.append  |  Sheets API  |  Google Developers

spreadsheet.js(一部)
const{google}=require("googleapis");constSPREADSHEET_ID="xxxxx";letoAuth2Client=null;// API実行に必要な認証情報module.exports.appendData=asyncfunction(){constsheets=google.sheets({version:"v4"});constparam={spreadsheetId:SPREADSHEET_ID,range:"シート1!A1",// 表を探索する場所を指定する。valueInputOption:"USER_ENTERED",insertDataOption:"INSERT_ROWS",auth:oAuth2Client,resource:{values:[["2019/12/4","もも","40"]]}};awaitsheets.spreadsheets.values.append(param);};

実行結果は、A5にupdateした場合と同じ。

InsertDataOption

新しいデータをどうやって挿入するかのオプション。

  • OVERWRITE - 単純に既存データの末尾のセルに新しいデータを書き込む。
  • INSERT_ROWS - 新しいデータを書き込む前に行が挿入される。そのため、既存データの位置がその分下がる。

花粉症の重症度を判定し自分に合う市販薬を教えてくれるLINE Botの作成

$
0
0

概要

プログラムの勉強を始めて半年ほどの開業医です。

そろそろスギ花粉の季節ですね。
2月の中旬ごろからスギ花粉は飛散開始します。花粉症の方はその前にお薬を準備しておくといいですね。

医療費削減のため一部の花粉症の薬が今後医療機関で処方できなくなるかもしれないといわれています。そうすると市販の薬で対応しなけらばならない花粉症患者さんが少なからず出てきそうです。そのような時に患者さんが困らないように、自分の花粉症の重症度が分かったり、自分に合った市販の花粉症薬が探せるサービスがあれば良いかと思い今回「花粉症の重症度を判定し自分に合う市販薬を教えてくれるLINE Bot」を作ってみました。

以前作ったWEBアプリはこちら
花粉症の重症度が分かるWEBアプリの作成~Auth0でユーザー認証~
自分に合った市販の花粉症薬を探すアプリの作成

動作確認

実装内容

・鼻アレルギー診療ガイドライン ―通年性鼻炎と花粉症― の診断アルゴリズムによる花粉症の重症度判定
・眠気の強さや効き目の強さ、服用回数、値段など自分の好みの市販のアレルギー薬の検索機能
・検索された薬剤とamazonのリンク

概念図

node.js expressでLINE bot APIを連携しました。
line nodejs.png

作成方法

1. Botアカウントを作成する

2. Node.jsでBot開発

3. ngrokでトンネリング

上記の1~3を以下の参考記事の通りに行います。
1時間でLINE BOTを作るハンズオン (資料+レポート) in Node学園祭2017

4. プログラム作成

重症度判定部分のコードの一部です。
今回は、LINE Developersのボタンテンプレートを使いました。

//重症度判定プログラムlet[mes,count]=event.message.text.split(":");if(event.message.type==='text'&&event.message.text==='判定'){returnclient.replyMessage(event.replyToken,{"type":"template","altText":"This is a buttons template","template":{"type":"buttons","thumbnailImageUrl":"https://www.doi-jibika.net/images/material/11_img_01.jpg","imageAspectRatio":"rectangle","imageSize":"cover","imageBackgroundColor":"#FFFFFF","title":"あなたの花粉症の重症度を判定しましょう!くしゃみの回数は1日何回?","text":"以下から選んでください","defaultAction":{"type":"uri","label":"View detail","uri":"http://example.com/page/123"},"actions":[{"type":"message","label":"21回以上","text":"くしゃみ:21回以上"},{"type":"message","label":"20~11回","text":"くしゃみ:20~11回"},{"type":"message","label":"10~6回","text":"くしゃみ:10~6回"},{"type":"message","label":"5~1回","text":"くしゃみ:5~1回"},]}})}elseif(event.message.type==='text'&&mes==='くしゃみ'){console.log(count);returnclient.replyMessage(event.replyToken,{"type":"template","altText":"This is a buttons template","template":{"type":"buttons","thumbnailImageUrl":"https://www.doi-jibika.net/images/material/11_img_02.jpg","imageAspectRatio":"rectangle","imageSize":"cover","imageBackgroundColor":"#FFFFFF","title":"鼻をかむ回数は1日何回?","text":"以下から選んでください","defaultAction":{"type":"uri","label":"View detail","uri":"http://example.com/page/123"},"actions":[{"type":"message","label":"21回以上","text":"鼻水:21回以上/"+count},{"type":"message","label":"20~11回","text":"鼻水:20~11回/"+count},{"type":"message","label":"10~6回","text":"鼻水:10~6回/"+count},{"type":"message","label":"5~1回","text":"鼻水:5~1回/"+count},]}})}elseif(event.message.type==='text'&&mes==='鼻水'){console.log(count);returnclient.replyMessage(event.replyToken,{"type":"template","altText":"This is a buttons template","template":{"type":"buttons","thumbnailImageUrl":"https://peta-eri.com/wp-content/uploads/2018/03/kafun_bottoshiteru.png","imageAspectRatio":"rectangle","imageSize":"cover","imageBackgroundColor":"#FFFFFF","title":"鼻づまりと口呼吸の程度はどのくらい?","text":"以下から選んでください","defaultAction":{"type":"uri","label":"View detail","uri":"http://example.com/page/123"},"actions":[{"type":"message","label":"完全に口呼吸","text":"鼻閉:完全に口呼吸/"+count},{"type":"message","label":"1日のうち、かなりの時間","text":"鼻閉:1日のうち、かなりの時間/"+count},{"type":"message","label":"1日のうち、ときどきあり","text":"鼻閉:1日のうち、ときどきあり/"+count},{"type":"message","label":"口呼吸ないが鼻閉あり","text":"鼻閉:口呼吸はないが鼻閉あり/"+count},]}})}elseif(mes==='鼻閉'){console.log(count);let[count3,count2,count1]=count.split("/");console.log(count1,count2,count3);if(count1==="21回以上"||count2==="21回以上"||count3==="完全に口呼吸"){returnclient.replyMessage(event.replyToken,{"type":"template","altText":"This is a buttons template","template":{"type":"buttons","thumbnailImageUrl":"https://peta-eri.com/wp-content/uploads/2018/03/kafun_bottoshiteru.png","imageAspectRatio":"rectangle","imageSize":"cover","imageBackgroundColor":"#FFFFFF","title":"あなたのアレルギーの重症度は最重症です。ステロイド点鼻薬の併用がおすすめです。","text":"以下から選んでください","defaultAction":{"type":"uri","label":"View detail","uri":"http://example.com/page/123"},"actions":[{'type':'datetimepicker','label':'日時を選択し重症度を記録','data':'重症度:最重症','mode':'datetime'},{"type":"message","label":"ステロイド点鼻薬はこちら","text":"ステロイド点鼻薬"},{"type":"message","label":"自分に合った内服薬を探す","text":"検索"},{"type":"message","label":"花粉飛散情報","text":"花粉飛散情報"},]}})}

考察

ボタンテンプレートを利用することによって、なかなかUIのよいBotを簡単に作ることが出来ました。今後、Firebaseを利用してデータの出し入れを行ったり、花粉飛散APIなどと連携していきたいと思っています。機能が増えたらまたご報告したいと思います。

Elastic Beanstalkで、Node.js+expressアプリを動かそう [ハマりポイント説明]

$
0
0

🔶 はじめに

Lambdaの方が使い勝手がいいので、あまり使わないElastic Beanstalkですが、いざ使おうと思うと、いくつか躓くポイントがあるので、その点も含めて、Node.js+expressで作ったサンプルアプリを動かすまでの手順を紹介します。

🔷 Elastic Beanstalk

https://aws.amazon.com/jp/elasticbeanstalk/
Java、.NET、PHP、Node.js、Python、Ruby、Go、Dockerなどで作られたプログラムをデプロイ、実行する環境。
実態としては、EC2の上にApacheやNginx等のサーバーを立てて提供してくれる。

🔶 作業手順

大きくは以下の手順になります。

  1. Node.js+expressでアプリを用意する。
  2. ソースコード+node.jsモジュールをZIPファイルにまとめる。
  3. Elastic Beanstalk実行環境構築、デプロイ。
  4. 実行。

🔷 Node.js+expressでアプリを用意する

今回は、express myappコマンド作成される、サンプルアプリを使います。
ゴールとしては、Elastic Beanstalkにデプロイして、以下の画面が表示する事です。

スクリーンショット 2019-12-25 2.34.24.png

アプリの生成手順の詳細は以下を参照してください。

参考:Node.js + ExpressでREST API開発を体験しよう for Windows[準備編]

✅ ポイント1 : アプリ起動時のポート番号の変更

myappプロジェクトを生成したら、binフォルダ配下のwwwファイルを開き、以下のようにアプリ実行時のポート番号を、デフォルトの3000番から8081番に修正する。

bin/www
#!/usr/bin/env node
/**
 * Module dependencies.
 */varapp=require('../app');vardebug=require('debug')('myapp:server');varhttp=require('http');/**
 * Get port from environment and store in Express.
 */// var port = normalizePort(process.env.PORT || '3000'); ←3000ポートは使えないvarport=normalizePort(process.env.PORT||'8081');// 起動時のポートを8081に変更app.set('port',port);

Beanstalk環境のnginxは、デフォルトでアプリケーションの8081ポートへリクエストを転送するので、上記のように変更する必要があります。

参考:プロキシサーバーを設定する

🔷 ソースコード+Node.jsモジュールをZIPファイルにまとめる

Beanstalkのデプロイパッケージは、ソースコードに必要なNode.jsモジュールをインポートして、ZIPファイルにまとめて作成します。

✅ ポイント2 : Linux環境でnpm installを実行する

まず、Lambdaのトピックですが、以下のように紹介されています。

注意: ほとんどの Node.js モジュールはプラットフォームに依存しませんが、一部のモジュールは特定のオペレーティングシステム環境に対してコンパイルされます。Lambda は、Linux 環境で動作します。npm でモジュールをインストールする場合、zip ファイルを Linux 環境で構築して、正しいプラットフォームに対する依存関係が必ず含まれるようにすることをお勧めします。

参考:Node.js の Lambda デプロイパッケージを作成するには、どうすればよいですか?

Beanstalkも、実態はEC2(Amazon Linux)インスタンスなので、Linux環境でnpm installを実行する方が無難です。
やり方はいくつかありますが、おすすめはmyappプロジェクトをCloud9にアップロードして、そこでnpm installを実行して、再度ローカルにダウンロードするやり方です。
詳しくは以下を参照。

参考:Lambda関数にNode.jsモジュールを入れるなら、Cloud9が簡単でおすすめ

✅ ポイント3 : ZIPファイルはファイル指定して圧縮する

デプロイパッケージを作る際、生成したmyappプロジェクトフォルダ自体をZIPで圧縮すると、デプロイ時にエラーになります。
必ず、myappプロジェクトフォルダ配下のファイルを指定して、ZIPで圧縮してください。

スクリーンショット 2019-12-25 18.15.08.png

🔶 Elastic Beanstalk実行環境構築、デプロイ

Elastic Beanstalk
https://console.aws.amazon.com/elasticbeanstalk/

上記サイトを開き、[今すぐ始める]をクリック。
スクリーンショット 2019-12-25 19.27.59.png

ウェブアプリケーションの作成画面で、
1. アプリケーション名に、任意の名前をセットする。(今回は'test')
2. プラットフォームは、「Node.js」を選択する。
3. コードのアップロードで、myappプロジェクトのファイル一式をZIPで圧縮したものを選択する。
4. [アプリケーションの作成]ボタンをクリックする。
スクリーンショット 2019-12-25 19.22.25.png

以下の画面に遷移する。
暫く待つと実行環境が構築され、画面右上のURLリンクから、アプリにアクセスできるようになるが、この時点では設定が終わってないのでアプリに接続できません。
スクリーンショット 2019-12-25 19.25.24.png

✅ ポイント4 : Node.jsの起動コマンドを設定する

今回のNode.js製のアプリは、npm startコマンドで実行するようにpackage.jsonに設定されています。しかし、Elastic Beanstalkは指定が無ければ、app.jsserver.jsnpm startの順に実行する事になります。

参考:Node.js 環境の設定

そこで、最初にnpm startを実行するように設定します。

画面上部の「すべてのアプリケーション > test > Test-env...」の、testリンクをクリックする。

Test-envリンクをクリックする。
スクリーンショット 2019-12-25 19.50.37.png

画面左メニューの設定リンクをクリックして、ソフトウェアのカテゴリで[変更]ボタンをクリックする。
スクリーンショット 2019-12-25 19.51.38.png

ソフトウェアの変更画面のノードコマンドに、npm startをセットして、画面下部の[適用]ボタンをクリックする。
スクリーンショット 2019-12-25 19.55.54.png

🔶 実行

変更設定が適用されると、以下の画面が表示されるので、再度画面右上にあるURLのリンクをクリクする。
スクリーンショット 2019-12-25 20.00.10.png

expressのサンプルアプリが表示されたら成功です。
スクリーンショット 2019-12-25 20.10.28.png

🔶 まとめ

このように、Elastic BeanstalkでNode.js+expressアプリを動かそうとすると、ローカルで実行する時と違って、いくつか詰まるポイントがあります。
しかし、この部分をうまく対応したら、後は開発に専念できるので、ぜひ使ってみてください。

🔶 参考


Webサービスのe2eテスト  〜メール認証編〜

$
0
0

はじめに

この記事は Goodpatch Advent Calendar 2019の22日目です.

私が現在担当しているWebサービスの開発において、Puppeteerを用いたe2eテストを用いてQAの効率化を図っています。
この記事では Node.js と Gmail API を使い、アカウント作成時のメール認証を自動化する方法について共有したいと思います。

注:この記事ではPuppeteerには触れません!

環境準備

メールをNode.jsで取得するためには、Gmail APIの設定と、各種ファイルの取得・生成が必要です。
基本的にはNode.js Quickstartに従って作業します。

フォルダの準備

あらかじめ、各種ファイルを保存するフォルダの準備しておきます。

例として、以下のような構造にします。

image.png

Gmail Credentialの取得と配置

あらかじめ、利用するGmailのアカウントでログインしておきます。
image.png

ログイン後、以下のページを開きます
Node.js Quickstart | Gmail API

ボタンを押して、APIをEnableにします
image.png

モーダルが表示されるので、ボタンを押してcredensial.jsonをダウンロードし、
image.png

e2e/envフォルダに保存します
image.png

トークンの取得

Node.js Quickstart | Gmail APIの Step2 に従って、以下をインストールします。

$ npm install googleapis@39 --save

Node.js Quickstart | Gmail API
中のStep 3 のコードをコピーし、 e2e/scripts/get-token.jsと名付けて保存します。

フォルダ構造を合わせるため、credentials.jsのファイルパスと、トークンの出力先パス TOKEN_PATHを以下のように修正します。

constTOKEN_PATH=__dirname+'/../env/token.json';// Load client secrets from a local file.fs.readFile(__dirname+'/../env/credentials.json',(err,content)=>{

保存後、以下のコマンドを実行します。

$ node e2e/scripts/get-token

すると、以下ようにURLがあらわれるので、言われた通りこのページを開きます。
image.png

ログインするアカウントを選択すると、以下の画面がでて来るので、詳細を表示Quickstartに移動します
image.png

権限の付与を許可します
image.png
image.png

コードが表示されるので、コピーし、
image.png

ターミナルに戻って Enter the code from that page here:のあとに貼り付け、Enterします。
image.png

すると、Gmailで利用しているラベルの一覧が表示され、e2e/envフォルダに token.jsファイルが生成されます。

これで、Node.jsからGmailを利用する準備は完了です。

Gmailから目的のメールを取得する

準備が長かった気がしますが、ここからが本番です。

数多くのメールの中から、認証リンクを含んだメールを探し、本文からリンク取得します。

取得すべきメール

取得すべきメールは以下のようなものです。
APIのフィルタ機能と、本文への正規表現を用いた検索を使ってこのメールを探します。

  • 未読状態である。
  • Webサービスのメール送信用アドレスから送信されている。
    (ここではhoge@piyo.jpとします)
  • 登録したメールアドレスに送信されている。
    (登録に利用したメールアドレスを引数として渡す)
  • 本文に認証リンクを含む。
    (ここでは https://hoge.piyo.jp/mail/XXXXXXのフォーマットであるとします)

環境準備

追加で以下のpackageをインストールします。

$ npm i google-auth-library -s

最終的なコード

先に最終的なコードを貼っておきます。
以下で説明していきます。

constfs=require('fs')const{promisify}=require('util')const{google}=require('googleapis')const{OAuth2Client}=require('google-auth-library')constgmail=google.gmail('v1')constTOKEN_PATH=__dirname+'/../env/token.json'constSECRET_PATH=__dirname+'/../env/credentials.json'constsleep=msec=>newPromise(resolve=>setTimeout(resolve,msec))constMAX_RETRY=10//Promise 化constreadFileAsync=promisify(fs.readFile)constgetMessageList=params=>{returnnewPromise((resolve,reject)=>{gmail.users.messages.list(params,(error,response)=>{if(error){reject(error)return}resolve(response.data)})})}constgetMessage=params=>{returnnewPromise((resolve,reject)=>{gmail.users.messages.get(params,(error,response)=>{if(error){reject(error)return}resolve(response.data)})})}constgetRegisterToken=async({email})=>{//クレデンシャル情報の取得constcontent=awaitreadFileAsync(SECRET_PATH)//クライアントシークレットのファイルを指定constcredentials=JSON.parse(content)//クレデンシャル//認証constclientSecret=credentials.installed.client_secretconstclientId=credentials.installed.client_idconstredirectUrl=credentials.installed.redirect_uris[0]constoauth2Client=newOAuth2Client(clientId,clientSecret,redirectUrl)consttoken=awaitreadFileAsync(TOKEN_PATH)oauth2Client.credentials=JSON.parse(token)//API経由でシートにアクセスtry{constgetToken=async()=>{// メッセージリスト取得constdata=awaitgetMessageList({auth:oauth2Client,userId:'me',q:`is:unread from:piyo@hoge.jp to:${email}`,})if(!data.messages||data.messages.length===0){console.log('no message')return}constmessage=awaitgetMessage({auth:oauth2Client,userId:'me',id:data.messages[0].id,})consttext=Buffer.from(message.payload.parts[1].body.data,'base64').toString('utf8')constregex=newRegExp(/https:\/\/hoge\.piyo\.jp\/mail\/([A-Za-z0-9]+)/)constresult=text.match(regex)if(!result){console.log('not matched')return}console.log('matched',result[1])returnresult[1]}letretry=MAX_RETRYlettoken=nullwhile(retry>0){token=awaitgetToken()if(token){break}retry--awaitsleep(10000)}console.log('token',token)returntoken}catch(err){return''}}module.exports={getRegisterToken}

Promise化

async/awaitで書きたいので、各種関数をPromiseでラップします。
promisifyが使えるものについては、promisifyを使い、そうでないものはベタに書いていきます。

//promisifyでプロミス化constreadFileAsync=promisify(fs.readFile)constgetMessageList=params=>{returnnewPromise((resolve,reject)=>{gmail.users.messages.list(params,(error,response)=>{if(error){reject(error)return}resolve(response.data)})})}constgetMessage=params=>{returnnewPromise((resolve,reject)=>{gmail.users.messages.get(params,(error,response)=>{if(error){reject(error)return}resolve(response.data)})})}

認証データの準備

認証データをファイルから読み出し、OAuth2の認証クライアントを生成します。

//クレデンシャル情報の取得constcontent=awaitreadFileAsync(SECRET_PATH)//クライアントシークレットのファイルを指定constcredentials=JSON.parse(content)//クレデンシャル//認証constclientSecret=credentials.installed.client_secretconstclientId=credentials.installed.client_idconstredirectUrl=credentials.installed.redirect_uris[0]constoauth2Client=newOAuth2Client(clientId,clientSecret,redirectUrl)consttoken=awaitreadFileAsync(TOKEN_PATH)oauth2Client.credentials=JSON.parse(token)

メッセージリストの取得

メッセージリストを検索クエリを付与してフィルタリングし、取得します。

指定意味
is:unread未読
from:hoge@piyo.jphoge@piyo.jpから送信されている
to:${email}${email} へ送信されている
// メッセージリスト取得constdata=awaitgetMessageList({auth:oauth2Client,userId:'me',q:`is:unread from:piyo@hoge.jp to:${email}`,})

メール本文の取得

メール本文を取得します。
ここでは、メッセージリストで複数候補があっても1つ目のメールのみを取得しています。

constmessage=awaitgetMessage({auth:oauth2Client,userId:'me',id:data.messages[0].id,//1つ目のメールを指定})

デコード

Gmailでは本文は、UTF-8の文字列バイトデータがBase64エンコードされたものになっています。
そのため、Base64からバイト配列に変換し、UTF-8に変換、といったデコードが必要です。

consttext=Buffer.from(message.payload.parts[1].body.data,'base64').toString('utf8')

文字列の抽出

ここまできたら正規表現で文字列を検索・抽出してあげるだけです!

constregex=newRegExp(/https:\/\/hoge\.piyo\.jp\/mail\/([A-Za-z0-9]+)/)constresult=text.match(regex)

リトライ

メールが直に送信されるとは限らないので、リトライの仕組みを入れています。
ここでは、10秒毎に最大10回リトライするようにしています。

letretry=MAX_RETRYlettoken=nullwhile(retry>0){token=awaitgetToken()if(token){break}retry--awaitsleep(10000)}

最後に

まだ追記するべき点がありますので、後ほど更新します!

時間切れにてここまで。何かのお役に立ちますように〜〜〜!

超短納期開発Tips vol.1 - JEST駆動開発で辛い試行錯誤フェーズを爆速で終わらせる

$
0
0

今年もヒーローズ・リーグに参戦させて頂きました。例年通り素敵な体験をさせてもらえたと思う一方、戦績としては決勝ステージには進出しつつも、何も賞を獲得することが出来きませんでした。
この悔しい気持ちを沈めたいという想いをこめつつ、散っていったアイデア達を作るうえで学習したことをアウトプットすることで、供養できたらと思います。ふ短期でガーー!っと作ってワイワイやるのは本当に楽しいので、皆さん来年は一緒に参加しましょう。

超短納期開発Tipsって何?

ヒーローズ・リーグ応募やハッカソン参加など、比較的短時間でアイディアを練ってプロトタイプを仕上げる、というような開発を、ここ4,5年を続けてきて知見が溜まってきた感があるので、言語化して共有しようと考え、書いてみたものです。

ハッカソンTipsと読み替えても差し支えありません。

こういうものこういうものを数時間で実装するためのTips集として、シリーズ展開しつつ、この手のイベントへの挑戦者増加に寄与できたら嬉しいです。
 
(とはいえ、フロントエンドネタや開発プロセスネタなど色々構想はありますが、第1弾で終わる可能性も十分あります)

また、筆者の得意領域がWebなので、フロントはWebで実装できるものはWebで実装しがちであり、Node.jsが好きなのでバックエンドはNode.jsで実装しがち。それゆえにTipsもその辺りみかたよりがちです。ご理解ください。

この記事で解決したい課題

触ったことのないAPIを実行するコードの実装や、少し複雑なロジックの実装にかかる試行錯誤の時間を短縮したい

ハッカソンにおける開発やヒーローズ・リーグなどのコンテスト締め切り直前に思い立って作り始めてしまった場合、3~6時間という短い時間で動くプロトタイプを仕上げる必要があります。加えて、こういった場合に限って「新しい触ったことのないAPIを利用する」というケースが多くあります。

競技の特性的には「どう見せるか」「DEMOに耐えられるか」「展示に耐えられるか」などの課題にリソースを割きたい一方で、癖の強いAPIを利用する必要があったり、実装しようとしているアイディアによって色々なAPIを組み合わせて少し複雑なことをする〜などが要求されていたりすると、ある程度実装しつつ動かしつつ正解を模索していく作業、いわゆる試行錯誤の作業にも時間をかなり奪われることになります。

本記事ではこの試行錯誤の作業を効率よく、ストレスフリーに、(3~6時間という短時間において)メンテナブルに構築する開発フローを紹介します。余談ですが、最近の筆者と同じように「ElasticSearchのクエリを投げるのも正直毎回ググってばかりで苦痛を感じている」という方にも多分効くはずです。

JEST駆動開発 - 概要

JEST駆動開発とは Jinriki-tEST-Driven-Development、つまり人力テスト駆動開発のことをさします(本当にすみません。でも、JESTと大文字表記していたので何かしらの嫌な予感のようなものは察していただけたとは思うのです)。

ポイントは以下の二つです。

  1. テスティングフレームワークとWatchツールを単なるメソッド自動実行機として使い、コンソールデバッグを最大限に効率化する(この場合はJest x watchオプション)
  2. テストコードと実装を同じファイルに書きつつ、なんならそのまま利用箇所にインポートして利用してしまうことで、作成するファイルやディレクトリの数、実装中のファイル移動の機会、実装/実装中断のスイッチングコストを削減する

自動テストによる安心感を感じることはできないが、コードを書いてからフィードバックを得るまでの時間と手間を短くすることと、コードを利用側から書くことで得られるメリットだけでも享受する、という主な狙いがあり、更に(2)の要素によって(1)を行う際に発生する手間を軽減し、短時間の開発でもペイするようにする、というのがお気持ちです。

ここまでで「ああ、そういうこと...」と理解されたかたはこの先は読まないで良いと思います :)

コンソールデバッグのつらみ

詳細に入る前に少し課題に戻ります。

(このレベルで)急いでプロトタイプを組むとなると、綺麗な土台を整える恩恵を得る時間がとても短いこともあり、なんだかんだでコンソールデバッグに頼るということが現実的になります。とりあえずAPIリファレンスを読んで、RESTクライアントやCURLなどで実行してみた後は、コードから実行してみよう!ちゃんととれるかな?というタイミングなどは特にそうです。

また、開発初期段階から見せ方についてはケアしていきたいため、全体として以下のような順で開発をすることが多く、開発を非効率にしやすい力学が働きます

  1. まずはダミーのデータで良いからWebAPI側だけを実装してフロントと繋げるようにしておく
  2. 実装の中身をダミーデータからちゃんとAPIにアクセスしたり、計算したりするようにする

このとき、「修正するたびに毎回アプリを再起動してAPIを叩いて確かめる..」ということをしている人をよく見かけます。または「app.jsにとりあえずAPIコールを実行するようなコードを書いてコンソールに出力しつつ、コード修正->app.js再起動で確認...を繰り返したのち、固まったらメソッド化してHttpリクエストハンドラから見えるところに移動させて~~」ということをする人も見かけます。普段そうしない人でも、なぜか時間制限がつくとそうする人が多くいます。

そして、終盤になればなるほどやり方を変更する時間も受けられる恩恵も減っていくので、TypoやHeader要素の追加漏れ、Credentialを環境変数で渡さないといけなかったぜ!などの変更をするたびに同じ事を繰り返すことになります。変更してみて、間違っていたら途中の箇所にconsole.logを仕込んで、直して..の繰り返しです。

今回の提案はこれをやめよう!というものです。
終盤での「見せ方にこだわりたい!」という欲求からの変更は経験上不可避なので、備えておいて損はないでしょう。

JEST駆動開発における試行錯誤の流れ

実際の作業のイメージをまずは文字ベースで列挙します。

  1. jest --watchAllを走らせて、保存するたびにテストコードが実行されるようにする
  2. 試行錯誤のためのコード(メソッド)と、それを実行してくれるテストコードを同じファイルに記述する
  3. とりあえず結果が得られるようにする。結果の確認はコンソールデバッグで良い(アサーションを頑張らなくていい)
  4. 動くようになったら、諸パラメータを引数化したり設定ファイルに切り出したりする
  5. 一通り満足したらdescribe.skipとして実行をスキップするようにする
  6. そのままmodule.exportsして、利用側から読み込んで利用する
  7. 次の試行錯誤にうつる

基本的にはこの繰り返しdえす。再び修正したくなったらdescribe.skipを外して、テストが動く状態で開発します。テストコード側に実装まで書いてしまうのはマストではありませんが、ディレクトリを行き来したり、試行錯誤途中の変更箇所を最小限にとどめたいという理由でそうしています。ディレクトリ構成を後から変えたいと思った時など、移動するファイルが二つにならずに済みます。

実際のコードイメージ

上記のフローで開発したコードのサンプルを貼っておきます。
GET : /issueTitles?repoName=<リポジトリ名>で筆者所有の公開リポジトリから、指定したリポジトリに紐づくIssue名の配列が取れるというあまり面白くないAPIですが、そこは勘弁してください。

./__tests__/github.test.js
constaxios=require('axios');// リポジトリ名をうけとり、リポジトリに紐づくIssueのタイトルの配列を返すasyncfunctiongetIssueTitles(repoName){const{data}=awaitaxios.get(`https://api.github.com/repos/rockymanobi/${repoName}/issues`);returndata.map(issue=>issue.title);}module.exports={getIssueTitles};// 実行側から読み込まれるときにエラーにならないように環境変数を参照する// ネストは増えるが、実装側にコピーする時間が惜しいif(process.env.NODE_ENV==='test'){describe('hoge',()=>{it('hoge',async()=>{constissueTitles=awaitgetIssueTitles('dm-keisatsu');// コンソールデバッグで良い// めちゃくちゃ余裕があればAssertionにすれば良いconsole.log(issueTitles);})});}

このファイルを保存するたびに、テストが実行されて、コンソールに結果が出力されます。

image.png

利用側のコードのイメージはコチラ。

./app.js
constexpress=require("express");constapp=express();// テストファイルから実装を読み込んで利用const{getIssueTitles}=require('./__tests__/github.test');// GitHubより、指定されたリポジトリに紐づくIssueを取得するapp.get('/issues',async(req,res,next)=>{constresponse=awaitgetIssueTitles(req.query.repoName);res.send(response);});constserver=app.listen(process.env.PORT||3000,()=>{console.log("listening on PORT:"+server.address().port);});

何が美味しいのか

出来上がったコードは別に綺麗でも何でもなく、ポイントは 「試行錯誤が必要だったであろうコード(この場合はGitHub APIの呼び出し, Issueタイトルの抽出等)を実装もろともテストファイルに書きつつ、保存と同時にコンソールデバッグで結果を確認できる状態を担保しながらコードを書いていった結果である」というところです。

ファイルを保存->実行を手動でやる場合よりも確実に試行錯誤は素早くでる上に、お試しコードをコメントアウトしたりそのためにimportしたりなどの作業も不要です。また、この例ではメソッド実行箇所が一つだけですが、複数の実行パターンを同時に試すこともできます(当たり前ですが)。そして、もし実装が固まって余裕があれば、アサーションを書いて本当にテストコードとして運用することも可能です。

変更してみる

変更したくなったときには特に恩恵を感じます。

筆者以外のユーザのリポジトリを対象とできるよう、試しにgetIssueTitlesの引数にuserNameをとるように修正するケースを考えます。メソッドの引数を加え、URL生成時に利用するようにしつつ、メソッドの呼び出し元からユーザ名を渡すようにする...このケースだと単純かもしれませんが、コードを実行している間は常に同じ結果が出力されながら作業ができるので、手動で実行する必要があるのはAPI経由で呼び出してみて動くかどうかを確かめたいときだけになります。

基本的にdescribe.skipで実装中のもの以外はテストを実行しないようにしていれば、確認が目視であってもさほど困りません(2日後以降は困るかもしれませんが)

違う呼び出しパターンを確認する

あたりまえですが、違う呼び出しパターンを確認したい時でも、エディタ上でコードをコピーするだけなので簡単です。この例は単純なものですが、引数が複雑な場合などは、やはりコード上にあるものをカジュアルにいじれて即時実行されるというのはとても便利なものです。

  describe('test', ()=>{
    it('hoge', async () => {
      const issueTitles = await getIssueNames('rockymanobi', 'dm-keisatsu');
      console.log(issueTitles);
    })
    // リポジトリ変えても大丈夫かな取れるかな...
    it('hoge', async () => {
      const issueTitles = await getIssueNames('rockymanobi', 'thanks-to-leave-bot');
      console.log(issueTitles);
    })
  });

その他の美味しいところ

普通にテストを書くことによるメリットに近いものがえられることがあります。変更したときの安全性はもちろん担保されませんが、あまり考えなくても(大事) コードが自然に、テスト可能な単位で分割される力学を得られます。

まとめ

まとめると...

  • Jest --watchAll環境で作業することで、実行を完全に自動化、弄りやすいコード化しよう
  • アサーションを書いたりするのは試行錯誤段階ではつらいので、とりあえずコンソールデバッグで作り込もう
  • すごく時間もないので、作成するファイルを削ったり、実装やめて他に行ってもう一度戻ってきたりしたときに楽なようにテストコードと同じファイルに実装も書いてしまうというてもあるよ

となります。以上です!

諸注意

ここまでの内容はあくまで手元の試行錯誤と、(ものすごく短いという意味での)短期間での変化への対応力を重視した開発方法であり、これをプロダクションコードにそのまま載せることは多分許されないので気をつけましょう。

また、すでにお分かりかとは思いますが、このエントリは半分はネタで書いています。 特にタイトルは完全にネタであり、単純にテスティングフレームワークをコンソールデバッグ最適化マシンとして使いましょういうだけの話で、目新しい話は何一つありません。本気な部分は、実際に筆者が手元の試行錯誤にこのフローを実践しているというところと、超短納期な開発であってもこのレベルの整備であればメリットの方が大きい、というところでしょうか。

それからjest --silent=trueとなっているとコンソールに何も吐かれないので注意しましょう。

終わりに

以外にやってみると使えると思います。
テスト書くのはちょっと、、、って思っている方でも、何かUtil的なものを作るときに試しにやってみて、勢いでちゃんとアサーションを書いて、最後に実装コードをちゃんとした場所に持っていけば、それがテストを書く第一歩になったりもするのかなぁ〜とか期待していたりもします。

おためしあれ。

Node.jsの"Error: Cannot find module 'cfn-response'"の解決方法

$
0
0

Error: Cannot find module 'cfn-response'

Lambda-Backedカスタムリソースにおいて、Node.js 8がEOLとなるため、コードはそのままでランタイムの指定だけをNode.js 12.xに変更したところ、このようなエラーが発生しました。

Response:
{
  "errorType": "Runtime.ImportModuleError",
  "errorMessage": "Error: Cannot find module 'cfn-response'",
  "trace": [
    "Runtime.ImportModuleError: Error: Cannot find module 'cfn-response'",
  云々

Node.js 8では以下の指定で同じ階層のファイルrequireできるのですが、

Node.js8
const response = require('cfn-response'); 

Node.js 12では、パスを明記する必要があるようです。

Node.js12
const response = require('./cfn-response'); 

[JavaScript][ES2017]0埋め(ゼロパディング)をするシンプルな記法(padStart, padEnd)

$
0
0

概要

ググるとまずslice()を使う方法が出てくるが、ES2017が使える環境ならばpadStart()の方が良い。

Node.jsならば8.0.0から使用可能。

使い方

一つ目の引数にパディング後の桁数、二つ目にパディングに使う文字(デフォルトは半角スペース)を指定する。

>'123'.padStart(5)'  123'>'123'.padStart(5,'0')'00123'>'123'.padStart(10,'*')'*******123'

右側を埋めるpadEnd()もある。

>'123'.padEnd(5,'0')'12300'

ドキュメント

LINE Payのオンライン決済を実装する前に知りたかったハマりどころ

$
0
0

LINE Payのオンライン決済を実装する機会があったので主なハマりどころを共有します。

Transaction IDが丸められる

決済要求のためにRequest APIを呼ぶ必要があるのですが、このAPIから返ってくるTransaction IDは19桁の数値で返ってきます。

決済要求の結果として受け取った取引番号(19桁)
https://pay.line.me/documents/online_v3_ja.html#confirm-api

19桁の巨大な数値なので、そのまま受け取ってしまうと丸められてしまう可能性があります。
その後のConfirm APIにTransaction IDを渡す必要があるのですが、丸められていると当然違う値なので「決済要求情報が存在しません。」となり決済が出来ません。
丸められていることに気づくまでかなりの時間を要しました。文字列で返すなどアップデートで対応してほしいところです。

JavaScriptで書かれたLINE Payのライブラリがあるのですが、こちらではlossless-jsonを利用してこの問題に対応しているようです。
https://github.com/nkjm/line-pay

自動決済キー(RegKey)の有効期限がわからない

サブスクリプション等のユーザーの操作を必要としない決済を実現するには自動決済キー(RegKey)を取得する必要があります。

このRegKeyには有効期限があります。
有効かチェックするAPIはありますが有効期限を知る手段はありません。
また有効期限の詳細についてはドキュメントには書いて無いので問い合わせる必要があります。

RegKeyには有効期限があります。詳細についてはお問い合わせください。
https://pay.line.me/documents/online_v3_ja.html#0de93db440

問い合わせたところ

  • 最後の決済から180日間でexpireされる
  • ユーザは設定画面からexpireできる

という仕様らしいです。

またsandbox環境では有効期限が1日に設定されているようなのでこれにも注意が必要です。

Sandbox環境ではLINEアプリ内の決済が使えない

LINE Payを実装するのにSandbox環境でまず試すケースがほとんどだと思うのですが、環境によって挙動が変わるので注意が必要です。

LINE Payの決済はWebで決済を行うか、LINEのアプリで決済を行うか選択が出来ますが、Sandbox環境ではLINEアプリ内の決済が利用できません。
ドキュメントにも書いていないと思うのでわからなかったですが、問い合わせした時に使えないとの回答をいただきました。
Sandbox環境ではWebの決済を利用するか、テスト加盟店環境を利用する必要があります。

Sandbox環境と他の環境の挙動が違う

LINE PayのはLINEのアプリ内にサービスを開発できるLIFF(LINE Front-end Framework)を使って実装していました。

Sandbox環境はWebシミュレーターとして実装されているそうなのでLIFFでの決済画面はそのままWebの遷移となります。
しかし、Sandbox以外の環境ではLINEのアプリ内にモーダルが立ち上がり決済画面が出ます。
決済後にRequest APIのredirectUrls.confirmUrlに設定したURLにリダイレクトするのですが、これは外部のブラウザで開かれます。

LIFFはLINEのアプリ内のブラウザで完結するのでUXが良いと思っていましたが、LINE Payを使う場合は決済終了後にアプリから離脱するのは避けられないようです

1秒間隔のAPIリクエスト推奨

Check Payment Status APIは1秒間隔で呼び出すことが推奨されています。

Check Payment Status APIのリクエスト頻度は1秒を推奨します。
https://pay.line.me/documents/online_v3_ja.html#check-payment-status-api

問い合わせで聞いたところでは自動決済を行うPay Preapproved APIも1秒間隔で呼び出すことが推奨されているようです。
リソースの関係でとのことですが、1秒間隔ということはもし10,000ユーザーの課金を処理しようとすると
最低でも10000秒なので2.8時間ほどかかる計算になります。ユーザー数が多いサービスには向かない決済なのかもしれませんね。。

まとめ

LINE Payはドキュメントに書かれていないことが結構あります
また環境ごとの動作が違うのでsandboxだけで技術検証するのは避けたほうが良いです。

サポートは充実しているのでわからないことはすぐに問い合わせましょう。
https://pay.line.me/jp/developers/techsupport/sandbox/contact?locale=ja_JP

Viewing all 8833 articles
Browse latest View live