20191221のNode.jsに関する記事は11件です。

Node.jsを使ったローカルHTTPSサーバーを走らせる

目的

OAuth2認可を使ってオンラインサービスと連携させることができるコマンドラインツールを作成中です。ユーザー認証後に認可コードをこのツールで受け取りたいと思っているので、127.0.0.1で走るHTTPSサーバーをリダイレクト先として用意したい。
その検証として、まずローカルHTTPSサーバーをNode.jsで走らせてみました。言語は実際の使う予定のTypeScriptとなっています。

何を使う?

  • Node.js
  • httpsモジュール
  • TypeScript

準備

まずはHTTPで地ならし

私はNode.jsローカル環境ではHTTPサーバーすらを立てた経験がないので、まずはHTTPから試してみる。

HTTPサーバーならばこれでOK。
import http from 'http';

const PORT = 9090;
const HOST = '127.0.0.1';

function startHttpServer(portNumber: number, hostName: string): Promise<http.IncomingMessage> {
  return new Promise((resolve, reject)=>{
    http.createServer((request, response)=>{
      response.writeHead(200, {
        'Content-Type': 'text/plain'
      });
      response.end(`welcome to local page!`);
      resolve(request);
    }).listen(portNumber, hostName);
  });
}

(async (portNumber, hostName)=>{
  const req = await startHttpServer(portNumber, hostName);
})(PORT, HOST);

HTTPは楽勝。
スクリーンショット 2019-12-21 18.16.43.png

単純にHTTP -> HTTPSで置き換えてやってみる

単純にhttpモジュールをhttpsモジュールに置き換えて見ました。結論から言いますと以下のコードは機能しません。

単純な置き換えでは機能しない。
import https from 'https';
import { IncomingMessage } from 'http';

const PORT = 9090;
const HOST = '127.0.0.1';

function startHttpServer(portNumber: number, hostName: string): Promise<IncomingMessage> {
  return new Promise((resolve, reject)=>{
    https.createServer((request, response)=>{
      response.writeHead(200, {
        'Content-Type': 'text/plain'
      });
      response.end(`welcome to local HTTPS page!`);
      resolve(request);
    }).listen(portNumber, hostName);
  });
}

(async (portNumber, hostName)=>{
  const req = await startHttpServer(portNumber, hostName);
})(PORT, HOST);

実行すると、当然ですが、エラーになります。
スクリーンショット 2019-12-21 20.09.56.png

何が必要なのか?

公式ドキュメントを読んでみます。目につくのがoptionsで指定している。keyとcertです。

https.createServer([options][,requestListener])
const options = {
  key: fs.readFileSync('test/fixtures/keys/agent2-key.pem'),
  cert: fs.readFileSync('test/fixtures/keys/agent2-cert.pem')
};

keyとcertはそれぞれ何かと言うと、同じく公式ドキュメントのTLSモジュールをみると下のように書いてありました。PEMフォーマットのプライベートキーですね。

key <string> | <string[]> | <Buffer> | <Buffer[]> | <Object[]>
Private keys in PEM format. PEM allows the option of private keys being encrypted. Encrypted keys will be decrypted with options.passphrase. Multiple keys using different algorithms can be provided either as an array of unencrypted key strings or buffers, or an array of objects in the form {pem: [, passphrase: ]}. The object form can only occur in an array. object.passphrase is optional. Encrypted keys will be decrypted with object.passphrase if provided, or options.passphrase if it is not.

certについて上記のページに下の説明があります。PEMフォーマットの証明書ですね。

cert <string> | <string[]> | <Buffer> | <Buffer[]>
Cert chains in PEM format. One cert chain should be provided per private key. Each cert chain should consist of the PEM formatted certificate for a provided private key, followed by the PEM formatted intermediate certificates (if any), in order, and not including the root CA (the root CA must be pre-known to the peer, see ca). When providing multiple cert chains, they do not have to be in the same order as their private keys in key. If the intermediate certificates are not provided, the peer will not be able to validate the certificate, and the handshake will fail.

幸いMacには元々opensslが入っていたので、これを使ってプライベートキーと証明書を作成します。
こちらのブログ、「秘密鍵、公開鍵、証明書、CSR生成のOpenSSLコマンドまとめ」 が大変参考になりました。このブログにあった「秘密鍵と自己署名証明書を一括で作成したい」を使います。
対話形式で入力する組織情報はどれが必須で、どれが省略可能なのかまでは調べていません。全部省略したらエラーになってしまいました。

プライベートキーと証明書の作成
$ openssl req -x509 -sha256 -nodes -days 9999 -newkey rsa:2048 -keyout localhttps_key.pem -out localhttps_cert.pem
Generating a 2048 bit RSA private key
............................................+++
...+++
writing new private key to 'localhttps_key.pem'
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) []:JP
State or Province Name (full name) []:Tokyo
Locality Name (eg, city) []:Tokyo
Organization Name (eg, company) []:FiftyFifty
Organizational Unit Name (eg, section) []:FortyNine
Common Name (eg, fully qualified host name) []:localhost
Email Address []:.

このコマンドが成功すると、localhttps_key.pemと言うプライベートキーとlocalhttps_cert.pemと言う証明書ファイルが生成されます。

機能するコード例

プライベートキーと証明書とをオプションで指定したら、HTTPSでアクセスできるようになりました。
実際にアクセすると、Chromeブラウザは強い警告を出してきますが、許可すればなんとかアクセスはできます。

機能するコード
import fs from 'fs';
import https from 'https';
import { IncomingMessage } from 'http';

const PORT = 9090;
const HOST = '127.0.0.1';

const options = {
  key: fs.readFileSync('./localhttps_key.pem'),
  cert: fs.readFileSync('./localhttps_cert.pem')
}

function startHttpServer(portNumber: number, hostName: string): Promise<IncomingMessage> {
  return new Promise((resolve, reject)=>{
    https.createServer(options, (request, response)=>{
      response.writeHead(200, {
        'Content-Type': 'text/plain'
      });
      response.end(`welcome to local HTTPS page!`);
      resolve(request);
    }).listen(portNumber, hostName);
  });
}

(async (portNumber, hostName)=>{
  const req = await startHttpServer(portNumber, hostName);
  console.log(`${JSON.stringify(req.headers)}`);
})(PORT, HOST);
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

BoxにあるファイルをLINE WORKSトークルーム(チャット)で共有

はじめに

BoxとLINE WORKSって連携してないんですか?
ってすっごいよく聞かれるので(仕事柄?)、Boxのウェブアプリ統合という機能を使って、BoxとLINE WORKSを連携機能を開発する記事を書きます。
連携と言っても、Boxから直接LINE WORKSのトークルームへファイルを共有するだけです。

完成動作イメージ

①Boxのファイルを選んで、[・・・]-[統合]を選んで、[Share with LW](←名前は任意で変えられます)をクリック。
image.png

②LINE WORKSのトークへ送信する画面が開きます。ここでは、[本文]にBoxの共有リンクを自動で挿入しています。送る相手はその都度変わると思うので、連絡先を検索するかトークルームを検索するかして宛先を設定し、[送信]ボタンをクリック。
image.png

③すると、LINE WORKSのトークでBoxの共有リンクが送られます!
image.png

開発手順

  1. Boxでウェブアプリ統合を作る
  2. HerokuでWebサーバーをたてる
  3. Boxからの通信を受けてLINE WORKSのトークルームへ投げるプログラムを書く
  4. プログラムをHerokuへデプロイ

Boxでウェブアプリ統合を作る

Box Developer アカウント作成

  1. Box Developerサイトへアクセスし、中央の[開始する]をクリックします。 image.png
  2. サインアップします。 image.png
  3. 確認メールが送られます。 image.png
  4. メールの確認をします。 image.png
  5. Boxのログイン画面が表示されるので、ログインします。
  6. 設定が完了したら、Developerコンソールへアクセスします。

新規アプリケーションの作成

1. [アプリの新規作成]をクリックします。
image.png
2.[カスタムアプリ]を選択して、[次へ]をクリックします。
image.png
3.認証方法では、[標準OAuth2.0(ユーザー認証)]を選択して、[次へ]をクリックします。
image.png
4.アプリケーションに適当な名前をつけて[アプリの作成]をクリックします。
5.DeveloperTokenが発行されますが、ここでは使わないので、[アプリの表示]をクリックします。
image.png

ウェブアプリ統合の作成

1.作成したアプリケーションを開きます。
2.左のメニュー[統合]をクリックし、[新しいWebアプリ統合を作成]をクリックします。
必要な情報を入力していきます。
詳細な情報はBoxのドキュメントを参照してください。

  • アプリ情報
    image.png
    image.png

  • コールバック設定
    [クライアントコールバックのURL]は後に作成するHerokuのURLを入れるため、ここではダミーでhttps://localhostなどとダミーの値を入れておきます。
    image.png

  • コールバックパラメータ
    ここで設定した値は、Boxから自分のWebアプリケーションへ統合メニューをクリックすると送られます。
    image.png

  • 認証
    image.png

  • 統合ステータス
    テストが完了して公開するまでは、開発者だけに統合メニューが表示されるよう「開発」を選んでおきましょう。
    image.png

全て設定が終わったら右上の[変更を保存]をクリックし保存します。

HerokuでWebサーバーをたてる

Heroku環境構築

Herokuは日本語の資料がありましたので、こちらのサイトを参考にして環境構築をしてください。

https://github.com/herokaijp/devcenter/wiki/quickstart

この時点では、「ステップ 3: ログイン」まで実施すれば大丈夫です。

Herokuアプリケーション作成

1.HerokuCLIを使ってHerokuにログインします。

$ heroku login
Enter your Heroku credentials.
Email: <your email address>
Password (typing will be hidden): 
Logged in as <your email address>

2.Herokuでアプリケーションを作成します。
アプリケーション名は、Herokuの中で一意である必要があります。

$ heroku apps:create <アプリケーション名>
Creating ⬢ <アプリケーション名>... done
https://<アプリケーション名>.herokuapp.com/ | https://git.heroku.com/<アプリケーション名>.git 

3.Gitリポジトリを初期化します。

$ git init

4.リモートリポジトリとしてHerokuを追加します。

$ git remote add heroku https://git.heroku.com/<アプリケーション名>.git

5.Git管理対象外ファイル(.gitignore)を作成します。

.DS_Store
.gitignore
npm-debug.log
node_modules

6.Herokuがプログラム起動する際に実行するファイル(Procfile)を作成します。

web: node index.js

Herokuアプリケーションへ環境変数をセット

Boxの認証情報をHeroku環境変数としてセットしておきます。
セットする資格情報は下記の通りです。
Boxアプリの「クライアントID」「クライアント秘密コード」はBoxDeveloperコンソールの作成したアプリの[構成]-[OAuth 2.0資格情報]欄にあります。

KEY 補足
BOX_CLIENT_ID BoxアプリのクライアントID
BOX_CLIENT_SECRET Boxアプリのクライアント秘密コード

Herokuへ環境変数を登録するコマンドは下記です。

$ heroku config:set <KEY>="<VALUE>"

Boxからの通信を受けてLINE WORKSのトークルームへ投げるプログラムを書く

次は、index.jsを新規作成して、コードを書き、デプロイします。

コーディング

BoxのNode.js SDKを利用します。

https://github.com/box/box-node-sdk/

以下はサンプルプログラムです。
(あくまで動作を確認するためのサンプルなので、業務で使う場合はセキュリティ等の検討を十分に行ってください。
実稼働環境におけるベスト・プラクティス: セキュリティー)

index.js
//Import Modules
var express = require('express');
var server = express();
var expressSession = require('express-session');
var BOX_CLIENT_ID = process.env.BOX_CLIENT_ID;
var BOX_CLIENT_SECRET = process.env.BOX_CLIENT_SECRET;
var Promise = require('bluebird'); 
var BoxSDK = require('box-node-sdk');
var bodyParser = require('body-parser');
var basicAuth = require('basic-auth-connect');
var helmet = require('helmet');
var request = require('request');

// Setup Restify Server
server.use(bodyParser.urlencoded({ extended: true }));
server.use(expressSession({ secret: 'keyboard cat', resave: true, saveUninitialized: false }));
server.use(helmet());

server.listen(process.env.port || process.env.PORT || 3978, function () {
   console.log('%s listening to %s', server.name, server.url);
});

server.get('/api/box/sharewithlw', function(req, res){
  try {
    if (!req.query.auth_code){
      return res.status(404).json({
        error: {
          message: 'Error!'
        }
      });
    }
    var authCode = req.query.auth_code;

    if (!req.query.file_id) {
      return res.status(404).json({
        error: {
          message: 'Error!'
        }
      });
    }
    var fileId = req.query.file_id;
  } catch(err){
    return res.status(400).json({
      error: {
        message: err.message
      }
    });
  }

  var sdk = new BoxSDK({
    clientID: BOX_CLIENT_ID,
    clientSecret: BOX_CLIENT_SECRET
  });

  var tokenInfo = {};

  var promise = new Promise(function(resolve, reject){
    sdk.getTokensAuthorizationCodeGrant(authCode, null, function(err, res) {
      if(err){
        reject(err);
      }
      tokenInfo = res;
      resolve();
    });
  });
  promise.then(function(){
    var client = sdk.getBasicClient(tokenInfo.accessToken);
    // 共有リンク作成
    client.files.update(fileId, {shared_link: client.accessLevels.DEFAULT})
        .then(function(file){
          var sharedLinkURL = file.shared_link.url;
          //LINE WORKSのトークルームへ共有する用のURLを作成
          var lwurl = "https://talk.worksmobile.com/sendMessage?initContent=" + encodeURIComponent("boxの共有リンクを送ります。\n") + "&initLink=" + encodeURIComponent(JSON.stringify({"url":sharedLinkURL,"text":sharedLinkURL})) + "&serviceId=works#!/";
          res.redirect(301,lwurl);
        });
  }).catch(function(err) {
    console.error(err);
  });
});

プログラムをHerokuへデプロイ

Herokuへデプロイします。

$ git add . && git commit -m "<your message>" && git push heroku master

ログを確認する場合は、下記コマンドです。

$ heroku logs --tail

Boxへ[クライアントコールバックのURL]をセット

  1. BoxのDeveloperコンソールを開き、作成したアプリを開きます。
  2. [統合]メニューの[コールバック設定]の[クライアントコールバックのURL]に、HerokuのアプリケーションURL+エンドポイント(https://<アプリケーション名>.herokuapp.com/api/box/sharewithlw)を入力し、[変更を保存]します。 image.png

テスト

Box上でLINE WORKSへ共有したいファイルを選んで、[・・・]→[統合]→[Share with LW]をクリックしてみてください!
完成動作イメージと同様の動作をしたら成功です!

LINE WORKS + Box連携

今回は、BoxのファイルをLINE WORKSのトークルームを選んで共有するというメニューを開発してみました。
このメニューがない場合どうやって共有するかというと、
Boxの共有ボタンをクリックして、共有リンクを発行して、共有リンクをコピーして、LINE WORKSのトークルームを開いて、共有リンクを貼り付けて、送信!と、めんどくさいですよね・・・
これらの操作をBoxのWebアプリ統合メニューを使うと、自動化できるので、興味ある方は是非チャレンジしてみてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Spotify APIを使ってSlackから音楽を検索できるbotを作る

この記事はぷりぷりあぷりけーしょんず Advent Calendar 2019の23日目の記事です。

はじめに

好きなバンドとかアーティストを共有したいときってありませんか?わたしはあります。
そういったときにSlack上でサクッと検索ができたら幸せだなあと思い、アーティスト名を入力すると、そのアーティストの人気曲Top10を返すSlack botを作りました。

Infrastructure as Code の学習も兼ねて、Lambda + API Gateway はCDKで定義しています。

使用技術

Slack Outgoing Webhook
Spotify API
AWS Lambda
Amazon API Gateway
AWS CDK 1.19.0
Node.js 12.8.1

※ CDK, Node の環境構築は完了していることを前提とします。

アーキテクチャ

アーキテクチャはこのようなイメージです。

purivisual.png

ざっくり説明すると、SlackのOutgoing WebhookからAPI GatewayにPOSTし、受け取った文字列をもとにSpotify APIに検索をかけて、その結果をSlackに返すようになっています。

ディレクトリ構成

CDKでプロジェクトを作成するので、ディレクトリ構成はこのようになります。

├── bin
│   └── 〇〇-cdk.ts
├── lambda
│   ├── node_modeules
│   ├── package.json
│   ├── package-lock.json
│   └── search.js
├── lib
│   └── 〇〇-stack.ts
├── node_modeules
├── README.md
├── package-lock.json
├── package.json
└── tsconfig.json

Spotify APIを使えるようにする

Spotify APIを使用するためには、クライアントアプリケーションを作成して認証を通す必要があります。

下記URLからダッシュボードにサインアップ / ログインすることができます。
https://developer.spotify.com/dashboard/

ダッシュボードにログインができたら、CREATE AN APPをクリックしてクライアントアプリを作成します。

スクリーンショット 2019-12-21 14.41.52.png

スクリーンショット 2019-12-21 14.48.19.png

アプリが作成されたら、Client IdClient Secretをメモしておきます。

Spotify APIには3種類の認証方法がありますが、今回はクライアント側から直接APIを叩く構成ではないため、Client Credentials Flowという認証フローを採用しています。

各認証方法についての詳細はこちらをご参照ください。
https://developer.spotify.com/documentation/general/guides/authorization-guide/

CDKでAWSリソースを定義していく

AWSリソースと、Lambdaにデプロイするコードを書いていきます。

Nodeのプログラムを作る

/lambdaディレクトリを作成し、そのディレクトリでnpm initでpackage.jsonを作成したら、

npm install requestで request のパッケージをインストールしておきます。

/lambda/search.js
exports.handler = (event, context, callback) => {
    const request = require('request');

    const authOptions = {
        url: 'https://accounts.spotify.com/api/token',
        headers: {
            'Authorization': 'Basic ' + process.env.ACCESS_TOKEN
        },
        form: {
            grant_type: 'client_credentials'
        },
        json: true
    };

    request.post(authOptions, function (error, response, body) {
        if (error) {
            console.log('POST Error: ' + error.message);
            return;
        }

        const token = body.access_token;
        const artist = event.text.slice(5);
        const encoded = encodeURIComponent(artist);

        const options = {
            url: 'https://api.spotify.com/v1/search?q=' + encoded + '&type=artist&market=JP&limit=1&offset=0',
            headers: {
                'Authorization': 'Bearer ' + token
            },
            json: true
        };

        request.get(options, function (error, response, body) {
            let res = {};

            if (error) {
                console.log('GET Error: ' + error.message);
                res.text = '検索に失敗しました。ごめんなさい!';
                callback(null, res);
            } else {
                res.text = body.artists.items[0].external_urls.spotify;
                callback(null, res);
            }
        });
    });
};

SlackからPOSTされた文字列はevent.textで取得することができます。Outgoing Webhookのトリガーとなる文字列を除くためslice(5)としています。

あとはその文字列(アーティスト名)をエンコードして、https://api.spotify.com/v1/searchのクエリパラメータに含めてリクエストをするだけです。

APIの細かい仕様はこちらをご参照ください。

https://developer.spotify.com/documentation/web-api/reference/search/search/

レスポンスが複数の場合もありえますが、今回は1件目だけをSlackに返すようにしています。

Lambdaを定義する

npm install @aws-cdk/aws-lambda でLambdaのライブラリをインストールしたら、/lib配下のtsファイルにコードを書いていきます。

/lib/〇〇-stack.ts
import cdk = require('@aws-cdk/core');
import lambda = require('@aws-cdk/aws-lambda');
import { Duration } from '@aws-cdk/core';

export class PurivisualSearchCdkStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // lambda
    const search = new lambda.Function(this, 'SearchHandler', {
      runtime: lambda.Runtime.NODEJS_10_X,
      code: lambda.Code.fromAsset('lambda'),
      handler: 'search.handler',
      timeout: Duration.minutes(1),
      environment: {
        "ACCESS_TOKEN": "< your access token >"
      }
    });
  }
}

environment< your access token >には、Spotifyでアプリを作成したときに発行したClient IdClient Secretをbase64でエンコードした文字列を入れてください。

ターミナルで下記コマンド叩くとエンコードした文字列を出力できます。

echo -n < Client Id >:< Client Secret > | base64

API Gatewayを定義する

npm install @aws-cdk/aws-apigateway でAPI Gatewayのライブラリをインストールします。

/lib/〇〇-stack.ts
import cdk = require('@aws-cdk/core');
import lambda = require('@aws-cdk/aws-lambda');
import apigw = require('@aws-cdk/aws-apigateway');
import { Duration } from '@aws-cdk/core';

export class PurivisualSearchCdkStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // lambda
    const search = new lambda.Function(this, 'SearchHandler', {
      runtime: lambda.Runtime.NODEJS_10_X,
      code: lambda.Code.fromAsset('lambda'),
      handler: 'search.handler',
      timeout: Duration.minutes(1),
      environment: {
        "ACCESS_TOKEN": "< your accsess token >"
      }
    });

    // api gateway
    const api = new apigw.LambdaRestApi(this, 'PurivisualSearchApi', {
      handler: search,
      proxy: false
    });

    // リソースの作成
    const postResouse = api.root.addResource("serach")

    const responseModel = api.addModel('ResponseModel', {
      contentType: 'application/json',
      modelName: 'ResponseModel',
      schema: {}
    });

    const template: string = '## convert HTML POST data or HTTP GET query string to JSON\n' +
        '\n' +
        '## get the raw post data from the AWS built-in variable and give it a nicer name\n' +
        '#if ($context.httpMethod == "POST")\n' +
        ' #set($rawAPIData = $input.path(\'$\'))\n' +
        '#elseif ($context.httpMethod == "GET")\n' +
        ' #set($rawAPIData = $input.params().querystring)\n' +
        ' #set($rawAPIData = $rawAPIData.toString())\n' +
        ' #set($rawAPIDataLength = $rawAPIData.length() - 1)\n' +
        ' #set($rawAPIData = $rawAPIData.substring(1, $rawAPIDataLength))\n' +
        ' #set($rawAPIData = $rawAPIData.replace(", ", "&"))\n' +
        '#else\n' +
        ' #set($rawAPIData = "")\n' +
        '#end\n' +
        '\n' +
        '## first we get the number of "&" in the string, this tells us if there is more than one key value pair\n' +
        '#set($countAmpersands = $rawAPIData.length() - $rawAPIData.replace("&", "").length())\n' +
        '\n' +
        '## if there are no "&" at all then we have only one key value pair.\n' +
        '## we append an ampersand to the string so that we can tokenise it the same way as multiple kv pairs.\n' +
        '## the "empty" kv pair to the right of the ampersand will be ignored anyway.\n' +
        '#if ($countAmpersands == 0)\n' +
        ' #set($rawPostData = $rawAPIData + "&")\n' +
        '#end\n' +
        '\n' +
        '## now we tokenise using the ampersand(s)\n' +
        '#set($tokenisedAmpersand = $rawAPIData.split("&"))\n' +
        '\n' +
        '## we set up a variable to hold the valid key value pairs\n' +
        '#set($tokenisedEquals = [])\n' +
        '\n' +
        '## now we set up a loop to find the valid key value pairs, which must contain only one "="\n' +
        '#foreach( $kvPair in $tokenisedAmpersand )\n' +
        ' #set($countEquals = $kvPair.length() - $kvPair.replace("=", "").length())\n' +
        ' #if ($countEquals == 1)\n' +
        '  #set($kvTokenised = $kvPair.split("="))\n' +
        '  #if ($kvTokenised[0].length() > 0)\n' +
        '   ## we found a valid key value pair. add it to the list.\n' +
        '   #set($devNull = $tokenisedEquals.add($kvPair))\n' +
        '  #end\n' +
        ' #end\n' +
        '#end\n' +
        '\n' +
        '## next we set up our loop inside the output structure "{" and "}"\n' +
        '{\n' +
        '#foreach( $kvPair in $tokenisedEquals )\n' +
        '  ## finally we output the JSON for this pair and append a comma if this isn\'t the last pair\n' +
        '  #set($kvTokenised = $kvPair.split("="))\n' +
        ' "$util.urlDecode($kvTokenised[0])" : #if($kvTokenised[1].length() > 0)"$util.urlDecode($kvTokenised[1])"#{else}""#end#if( $foreach.hasNext ),#end\n' +
        '#end\n' +
        '}'

    // POSTメソッドの作成
    postResouse.addMethod("POST", new apigw.LambdaIntegration(search, {
      // 統合リクエストの設定
      requestTemplates: {
        'application/x-www-form-urlencoded': template
      },
      // 統合レスポンスの設定
      integrationResponses: [
        {
          statusCode: '200',
          contentHandling: apigw.ContentHandling.CONVERT_TO_TEXT,
          responseTemplates: {
            'application/json': "$input.json('$')"
          }
        }
      ],
      passthroughBehavior: apigw.PassthroughBehavior.WHEN_NO_MATCH,
      proxy: false
    }),
        // メソッドレスポンスの設定
        {
          methodResponses: [
            {
              statusCode: '200',
              responseModels: {
                'application/json': responseModel
              }
            }
          ]
        })
  }
}

apigw.LambdaRestApi()のhandlerに先ほど定義したLambdaを指定してあげることで、LambdaをバックエンドとしたAPIを作成することができます。

SlackからPOSTされるデータはapplication/x-www-form-urlencoded形式のため、jsonに変換しています。AWSフォーラムで紹介されているマッピングテンプレートをまるっとコピーして使用しています。
https://forums.aws.amazon.com/thread.jspa?messageID=673012&tstart=0#673012

デプロイ

これでAWSリソースとLambdaにデプロイするプログラムが完成したので、デプロイします。

cdk diff
cdk deploy

SlackのOutgoing Webhookを設定する

スクリーンショット 2019-12-21 17.29.53.png

「引き金となる言葉」には被ることがないような文言を設定しておくのが無難です。わたしは推しの名前にしました。

あとは、URLに作成したAPI Gatewayのエンドポイントを指定し、名前やアイコンなどを設定して保存すればbotの完成です。

スクリーンショット 2019-12-21 12.15.05.png

このように、「SORA」のあとにスペース+アーティスト名で、そのアーティストの人気曲Top10が表示されるようになりました!残念ながらSlack上で再生ができるは視聴版のみとなっており、全曲フル尺で再生したい場合はリンクからSpotifyを開く必要があります。

最後に

こういうbotがあれば「このアーティストオススメだから聴いてみて!」が簡単にできて楽しいかなと思って作ってみました。
あとbotのアイコンを推しにするとかなり愛着が湧きます!
Spotify APIは他にも色んなエンドポイントが用意されているので、気になる方は是非使ってみてください!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Tips]AWS Lambdaにzipファイルアップロードをコマンド一発で行う

前提

  • Node.js
  • aws-cliインストール済みであること

説明

lambda関数をzipアップロードする場合、zipに固めて管理画面ポチポチするよりも、npm(Node.jsの場合)でスクリプト化しておくと良い。

コード

package.json
    ...
    "scripts": {
       "predeploy": "zip -r Lambda.zip * -x *.zip *.json",
       "deploy": "aws lambda update-function-code --function-name {{ Lambda関数のARN }} fileb://Lambda.zip"
     },
    ...

実行

npm run deploy

補足

npm-scriptsの「pre」プレフィックスを付けると、特定のコマンドの前に自動で実行される。
なので、上の例でpredeployを直接実行する必要はない。
「post」プレフィックスも同様で、事後処理を入れたい場合に使う。

まとめ

デプロイは何度も行うことになるので、トータルで見ると時間短縮につながると思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【webpack】npm run dev でbuild.jsが読み込めない

症状

Javascript + Vue.js + Webpack で開発しているプログラムで、npm run devが急に動かなくなった!!大変だー!!

「急に」という場合は大体急ではなく原因があるのですが、今回はいまだに原因が見つかっていません。

以下詳しい症状です。
1. 昨日までは動いていた
2. npm run dev 自体にエラーは出ない
3. npm run build は成功する
4. npm run dev を Ctrl + C でkillしてもポートが解放されない(killできていない)
5. localhost:8080などに置いてあるビルド後のファイル(build.jsなど)が読み込めない
6. Activity monitorでnodeのCPU占領率がすごいことになる。

と以上のような症状が出た人向けの記事です。

環境

  • OS: Mac OS Mojave
  • webpack: 4
  • node: v13.5.0
  • vue-cli: 2

お分かりの方もいると思いますが、vue-cliが2系だった頃のプロジェクトで、webpackやnodeを必要に駆られてアップグレードしています。
ここが問題で、@vue/cli(3系)で作ったプロジェクトではこうしたエラーは確認していません。

やったこと

パッケージのバージョン統一

こちらを参考にwebpack, webpack-cli, webpack-dev-serverのバージョンの組み合わせを適切なものにしましたが、失敗

以前のgitのバージョンをrevert

最悪これでいけるだろうという手段。
失敗
nodeのバージョンも変えていないのになぜ。。?

解決策

新たにgithubからcloneしてきてnpm run devしたらいけました。
違いはなんでしょうか。。

webpackやnodeのバージョンも同じなので、キャッシュがいたずらしている?
→ 削除しましたがダメなようです。

何かご存知の方いましたらコメントお願いします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Lambda+node.jsのREST APIをDocker+Rustに置き換えて高速化したい

はじめに

  • AWS Lambdaをnode.js(javascript/typescript)でよく使っている。
  • コスト、またはレスポンス改善のためにLambdaをECS+fargateなどDocker環境に移植したい。
  • もちろんRustに移植すれば速くなると思ってやっている。

Lambdaの問題

  • リクエスト課金のため、大規模利用では課金がヤバいことになる。
  • レスポンスタイムの揺らぎが大きい、コールドスタートが遅い。
  • このどっちの問題にも当てはまらないならLambdaはオススメです。

(最近、Provisioned Concurrencyとか追加されたけど、それでもコールドスタートは発生する)

なぜRust?

簡単なREST APIサーバーを書いてみる

数値を2つ含んだJSONをPOSTして、その和を返すREST APIを作る。

普段、fastifyを使っているので、node.jsはfastifyで比較する。

node.js(javascript) + fastify

main.js
const fastify = require('fastify');
const server = fastify({});

server.post('/', (request, reply) => {
    reply.send({answer: request.body.a + request.body.b});
});

server.listen(3000, (err, address) => {
    if (err) throw err;
    console.log(`server listening on ${address}`);
});

Rust + actix_web

main.rs
use actix_web::{web, App, HttpServer, Responder, post, HttpResponse};
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct AddResult {
    answer: i32,
}

#[derive(Deserialize)]
struct AddQuery{
    a: i32,
    b: i32,
}

#[post("/")]
fn post(query: web::Json<AddQuery>) -> impl Responder {
    HttpResponse::Ok().json(AddResult{answer: query.a  +  query.b})
}

fn main() {
    HttpServer::new(|| {
        App::new().service(post)
    })
    .bind("127.0.0.1:3000")
    .expect("Can not bind to port 3000")
    .run()
    .unwrap();

    println!("server listening on 3000");
}

測定

ローカルマシン(MacBook Pro 4コア)で、heyを使って測定する。

Rustはcargo run --releaseで実行する。

% hey -n 1000000 -c 100 -m POST -d '{"a":1,"b":2}' -T 'application/json' http://localhost:3000

node+fastify heyの結果

Summary:
  Total:    42.1554 secs
  Slowest:  0.0426 secs
  Fastest:  0.0001 secs
  Average:  0.0042 secs
  Requests/sec: 23721.7658

  Total data:   12000000 bytes
  Size/request: 12 bytes

Response time histogram:
  0.000 [1] |
  0.004 [723692]    |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.009 [271311]    |■■■■■■■■■■■■■■■
  0.013 [3823]  |
  0.017 [789]   |
  0.021 [286]   |
  0.026 [20]    |
  0.030 [3] |
  0.034 [22]    |
  0.038 [28]    |
  0.043 [25]    |


Latency distribution:
  10% in 0.0036 secs
  25% in 0.0036 secs
  50% in 0.0039 secs
  75% in 0.0044 secs
  90% in 0.0054 secs
  95% in 0.0058 secs
  99% in 0.0076 secs

Rust+actix heyの結果

Summary:
  Total:    11.0322 secs
  Slowest:  0.1170 secs
  Fastest:  0.0001 secs
  Average:  0.0011 secs
  Requests/sec: 90643.4012

  Total data:   12000000 bytes
  Size/request: 12 bytes

Response time histogram:
  0.000 [1] |
  0.012 [997956]    |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.023 [1203]  |
  0.035 [280]   |
  0.047 [182]   |
  0.059 [212]   |
  0.070 [89]    |
  0.082 [61]    |
  0.094 [14]    |
  0.105 [0] |
  0.117 [2] |


Latency distribution:
  10% in 0.0006 secs
  25% in 0.0009 secs
  50% in 0.0010 secs
  75% in 0.0011 secs
  90% in 0.0013 secs
  95% in 0.0015 secs
  99% in 0.0032 secs

結果

Requests/sec

node + fastify Rust + actix
23721 90643

Rustが速い。

node.js vs デフォルトでコア数だけスレッド立てるactixはフェアじゃないだろ

node側のコードをclusterを使って、マルチプロセス化する。

cluster.js
const cluster = require('cluster');
const os = require('os');
const fastify = require('fastify');

if(cluster.isMaster) {

    for(let i = 0; i < os.cpus().length; i++) {
        cluster.fork();
    }

}
else {

    const server = fastify({});

    server.post('/', (request, reply) => {
        reply.send({answer: request.body.a + request.body.b});
    });

    server.listen(3000, (err, address) => {
        if (err) throw err;
        console.log(`server listening on ${address}`);
    });

}

それに対するheyの結果

Summary:
  Total:    16.0576 secs
  Slowest:  0.1326 secs
  Fastest:  0.0001 secs
  Average:  0.0016 secs
  Requests/sec: 62275.7432

  Total data:   12000000 bytes
  Size/request: 12 bytes

Response time histogram:
  0.000 [1] |
  0.013 [977295]    |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.027 [17411] |■
  0.040 [4146]  |
  0.053 [812]   |
  0.066 [176]   |
  0.080 [67]    |
  0.093 [30]    |
  0.106 [32]    |
  0.119 [0] |
  0.133 [30]    |


Latency distribution:
  10% in 0.0003 secs
  25% in 0.0005 secs
  50% in 0.0007 secs
  75% in 0.0010 secs
  90% in 0.0020 secs
  95% in 0.0063 secs
  99% in 0.0208 secs

Requests/sec

node + fastify node + fastify + cluster Rust + actix
23721 62275 90643

Rustが1.5倍ほど速いけど、思ったより差がなくなった。

他のhttp framework crateではどうなのか

nickelでやってみる。

main.rs
#[macro_use] extern crate nickel;

use nickel::{Nickel, HttpRouter, JsonBody};
use serde::{Deserialize, Serialize};
use serde_json;

#[derive(Serialize)]
struct AddResult {
    answer: i32,
}

#[derive(Deserialize)]
struct AddQuery {
    a: i32,
    b: i32,
}

fn main() {

    let mut server = Nickel::new();

    server.post("/", middleware! { |request, response|

        let query = request.json_as::<AddQuery>().unwrap();
        let response = AddResult{answer: query.a  +  query.b};
        serde_json::to_string(&response).unwrap()

    });

    server.listen("127.0.0.1:3000").unwrap();
}

けど、heyの同じ負荷ではsocket: too many open filesが大量に出て耐えれなかった。
仕方なく、

% hey -n 100000 -c 10 -m POST -d '{"a":1,"b":2}' -T 'application/json' http://localhost:3000

同時接続数を減らして比較した

Rust + actix Rust + nickel
62275 67503

nickelがやや速い。が、多同時接続が不安。

まとめ

  • Rustはnode.jsの1.5倍速かった。
  • もうちょっとRustは速いと思ってた。
  • ここから処理を追加するから、差はついてくると思うが、REST APIのガワだけであれば大差なかった。
  • マジか。

補足

  • ローカル実行の雑なベンチマークなので参考程度でお願いします。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[AWS]APIGateway上でExpressのpublicルーティングを通したかった

はじめに

新卒ゆるゆるアドベントカレンダー21日担当です!
最近AWSを触らせてもらう機会があり、それについて少々。

背景

「あんまアクセスないサイトだし、ServerlessでWebサービス作れたら低コストな運用が出来るのでは!?」っていう流れで、AWS,Lambdaで動くWebサーバの構築をNode.js&Expressを使って行いました。
Expressのスケルトンコードを用意し、普段のようにpublic配下にクライアントJSやCSSのリソースを入れ込んで、下記のexample.htmlように自身のURLを指定してリソースを読み込んでいました。
ですが、Serverless.ymlで特定のルーティングをLambdaFunctionに紐づけて定義してしまっているため、example.htmlのようなリソースURLをリクエストされても対応するLambdaFunctionが無いと怒られるだけでした。(この問題の解明に3日ほど費やしました)

sample.html
<head>
<link rel="stylesheet" type="text/css" href="/public/css/example.css">
</head>

(以前書いた記事でExpressスケルトンコードでのサーバ構築手法も書いてるのでぜひご覧ください)

対応方法の検討

クライアントからのリソースのリクエストは「/public/」で始まる事から、以下の三つを検討しました。

  1. /public/でアクセスできるLambdaFunctionを定義し、そこで実際の/public/フォルダから検索して返す
  2. /public/でアクセスできるLambdaFunctionを定義し、そこでS3バケットから指定されたリソースを引っ張ってきて返す
  3. CloudFrontを使い、/public/で来たリクエストをAPIGatewayではなく直接S3バケットに向ける

純粋なAPIGatewayとLambdaの動作、Expressの動作を保証しつつ、可用性を求めた結果3を選択しました。CloundFrontはcdnとして元々導入予定且つS3の運用コストの低さからこの選択は最善であると判断しました。

対応

AWSConsole上からCloudFrontの設定をしていきます。
新しいDistributionを作り、OriginにはAPIGatewayのARNを、CustumOriginにはS3のARNを設定します。Originの設定例を図1に、Behhavioursの設定例を図2に示します。

図1(Originの設定例)cloudFront1.png

図2(Behaviorsの設定例)cloundfront2.png

おわりに

アドベントカレンダーのタイトルにそぐわない内容だったかもしれませんが、もしServerlessでWebサーバを作る人の参考になれば幸いです。
実際にはAWSConsoleから直接Distributionをいじるのではなく、Terraformを使ってドキュメント化を行っております。いずれそれを記事にすると思います。
更にs3へのリソースデプロイもshellで自動化したので、それもいつか記事にするかもしれません。
ご興味がある方は是非ご覧になってください。
最後まで読んでいただきありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Google Cloud Functions と Puppeteer で動的ウェブページを実行してコンテンツを返す API を作る

はじめに

  • ウェブページをスクレイピングして遊んでいたところ JavaScript を使った動的なページが出てきて困ったので、Google Cloud Functions と Puppetter を使ってウェブページのダウンロードと JavaScript の実行をしてコンテンツを返してくれる API を作りました。

できたもの

つかいかた

# ダウンロード
git clone https://github.com/nirasan/cloud-functions-dynamic-page-renderer.git

# 初期化
cd cloud-functions-dynamic-page-renderer
npm install

# ローカルで実行
npm start &
curl -G 'http://localhost:8080' --data-urlencode 'url=http://example.com/' 

# Google Cloud Functions にデプロイ(gcloud の初期化やプロジェクトの作成は済んでいるものとする)
npm run deploy

解説

  • Google Cloud Functions の nodejs8 ランタイムでは Puppetter をサポートしているので超手軽にヘッドレスブラウザを実行することが出来ます。

コンテンツのダウンロードと JavaScript の実行

  • Puppetter でウェブページをダウンロードして実行するのはこんな感じでほぼサンプルみたいなものです。
index.js
async function run(url) {
    const puppeteer = require('puppeteer');

    const browser = await puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox']});
    const page = await browser.newPage();

    await page.goto(url, {waitUntil: 'networkidle0'});
    await autoScroll(page);

    const content = await page.content();
    browser.close();
    return content;
}
  • autoScroll() は画面をスクロールさせることで画像の遅延読み込みや JavaScript の遅延実行が行われるようにしています。
index.js
async function autoScroll(page) {
    await page.setViewport({
        width: 1200,
        height: 800
    });
    await page.evaluate(async () => {
        await new Promise((resolve, reject) => {
            let totalHeight = 0;
            const distance = 100;
            const timer = setInterval(() => {
                const scrollHeight = document.body.scrollHeight;
                window.scrollBy(0, distance);
                totalHeight += distance;

                if (totalHeight >= scrollHeight) {
                    clearInterval(timer);
                    resolve();
                }
            }, 100);
        });
    });
}

Cloud Functions 関数本体

  • Cloud Functions に登録している関数本体は run() を実行してコンテンツを返すだけです。
index.js
exports.dynamicPageRenderer = (req, res) => {
    const url = req.query.url;
    if (!url) {
        return;
    }
    run(url)
        .then((content) => {
            res.status(200).send(content);
        })
        .catch((err) => {
            console.error(err);
            res.status(500).send("error: " + err);
        })
};

開発サーバー立ち上げ

  • ローカル実行は Functions Framework というのが用意されていて簡単に開発用サーバーを立ち上げることが出来ます。
functions-framework --target=dynamicPageRenderer

デプロイ

  • デプロイは gcloud コマンドです。メモリは少ないと実行時に死んじゃうのでとりあえず 1GB を指定します。
gcloud functions deploy dynamicPageRenderer --runtime nodejs8 --trigger-http --memory=1024MB --timeout=120s

認証

  • Cloud Functions の HTTP 関数はデフォルトで App Engine サービスアカウントなど一部の許可されたユーザーからのみアクセス可能になっています。
  • ユーザーの追加や未認証ユーザーからのアクセスを受け付けたい場合は関数の設定画面から設定をします。
  • 2019 年 11 月 1 日以前はデフォルトで未認証ユーザーからのアクセスを受け付けていたということで GCP 内で利用する場合はセキュアで便利になったようです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

終わらないLambda実行

この記事はAWS LambdaとServerless #1 Advent Calendar 2019
の21日目の記事です。

はじめに

API Gatewayと組み合わせてのAPIサービング、SQSトリガーからのジョブ、DynamoDBの更新をフックにした処理など、いろいろな活躍をしてくれるLambdaですが、API Gatewayからのレスポンスが返ってこないな、テストが終わらない?、という状況になったことはありませんか?

今回は私が開発中に出会った、いろんな意味でLambdaやLambdaを使った処理が終了していない、終了していないように見えるパターンについて紹介します。

  1. Postgresへの接続
  2. Postgresへ接続したFunctionのテスト
  3. DynamoDBトリガーのLambdaでの例外によるリトライ

Serverless FrameworkでNode.js + TypeScriptで検証しています。

1. Postgressへの接続

サンプルコード

pgクライアントを利用してPostgresにアクセスし、selectをします。以下のようなコードです。

import { Client } from "pg";

export const connectToRDB = async (_event, context, callback) => {
  const pgClient = new Client({
    host: process.env.DB_HOST,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: "postgres"
  });
  await pgClient.connect();
  const res = await pgClient.query("SELECT current_timestamp");
  console.info("res", res.rows);
  await callback(null, {
    statusCode: 200,
    body: JSON.stringify({
      message: JSON.stringify(res.rows)
    })
  });
};

エラーとログ

API Gateway接続し、APIを提供するようにしたうえでcurlでアクセスすると、しばらくレスポンスをまった後にInternal Server Errorのメッセージが返ってきます。
Lambdaがエラーになったようです。

% curl https://XXXXXXXX.execute-api.ap-northeast-1.amazonaws.com/dev/connect-to-rdb                                                                                         [10:39:49]
{"message": "Internal server error"} 

CloudWatch LogでLambdaのログを確認します。
image01.png

INFO res [ { current_timestamp: 2019-12-20T01:53:46.263Z } ]

とあるようにDBへの接続は正常に行われ、結果を取得できています。しかし以下の行が出力されています。

REPORT RequestId: 88a162b3-7b8f-4cdb-bf72-f7531ee9d29e Duration: 6006.19 ms Billed Duration: 6000 ms Memory Size: 1024 MB Max Memory Used: 75 MB Init Duration: 138.46 ms

6000msecはServerless Frameworkがデフォルトでセットするタイムアウト時間なので、処理がタイムアウトしていると考えられます。

ログ出力したselect結果は出力されていますが、Lambdaの実行が6000msecかかっており、タイムアウトしている様子です。

原因

この挙動は Node.js の AWS Lambda 関数ハンドラー のドキュメントを読むとヒントが得られます。

非同期以外の関数の場合は、イベントループが空になるか、関数がタイムアウトするまで、関数の実行が継続されます。

このうちの"イベントループが空になる"が満たせていないためにタイムアウトになっていないと考えられます。ライブラリ内部までは検証できていないのですが、Postgresとのコネクションの維持のためになんらかのイベントループが内部で起こっているのでは、と考えられます。

対策

ドキュメントにもある通り、context.callbackWaitsForEmptyEventLoop = false をセットします。

export const connectToRDB = async (_event, context, callback) => {
  context.callbackWaitsForEmptyEventLoop = false; // <- ここを追加!

  const pgClient = new Client({
    host: process.env.DB_HOST,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: "postgres"
  });
  await pgClient.connect();
  const res = await pgClient.query("SELECT current_timestamp");
  console.info("res", res.rows);
  await callback(null, {
    statusCode: 200,
    body: JSON.stringify({
      message: JSON.stringify(res.rows)
    })
  });
};

これで想定通りにレスポンスがかえるようになりました :tada:

% curl https://XXXXXX.execute-api.ap-northeast-1.amazonaws.com/dev/connect-to-rdb                                                                                         [10:53:24]
{"message":"[{\"current_timestamp\":\"2019-12-20T01:53:46.263Z\"}]"}

実行時間も数十msecに収まっています。

image02.png

2. Postgresへ接続したFunctionのテスト

(これはもはやLambdaというよりもNode.jsなのですが...)

サンプルコード

先のPostgresのケースをjestでテストします。テストケースを以下のように書きました。200ステータスが返されるのをチェックするだけのテストです。

import { connectToRDB } from "../handler";
const context = require("aws-lambda-mock-context");

const ctx = context();

beforeEach(async () => {
  const currentEnv = process.env;
  process.env = {
    ...currentEnv,
    DB_HOST: "YOUR_DB_HOST",
    DB_PORT: "YOUR_DB_PORT",
    DB_USER: "YOUR_DB_USER",
    DB_PASSWORD: "YOUR_DB_PASSWORD"
  };
});

test("connectToRDB", async () => {
  const callback = (error, response) => {
    expect(error).toBe(null);
    expect(response.statusCode).toBe(200);
  };
  await connectToRDB({}, ctx, callback);
});

問題

テストを実行します。テストは正常にパスするのですが、jestが実行したままcrtl + cするまで止まりません。

% npx jest                                                                                                                                                                    [10:57:30]
ts-jest[config] (WARN) TypeScript diagnostics (customize using `[jest-config].globals.ts-jest.diagnostics` option):
message TS151001: If you have issues related to imports, you should consider setting `esModuleInterop` to `true` in your TypeScript configuration file (usually `tsconfig.json`). See https://blogs.msdn.microsoft.com/typescript/2018/01/31/announcing-typescript-2-7/#easier-ecmascript-module-interoperability for more information.
 PASS  __tests__/handler.ts
   connectToRDB (339ms)

  console.info handler.ts:34
    res [ { current_timestamp: 2019-12-20T01:58:07.049Z } ]

----------|----------|----------|----------|----------|-------------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files |        0 |        0 |        0 |        0 |                   |
----------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        6.716s
Ran all test suites.
Jest did not exit one second after the test run has completed.

This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with `--detectOpenHandles` to troubleshoot this issue.

原因

ログの最終行にサジェストされているとおりに --detectOpenHandles optionをつけて再度実行すると、やはりPostgresへの接続関連でイベントハンドラが残っていることがわかります。

% npx jest --detectOpenHandles                                                                                                                                                [11:21:26]
ts-jest[config] (WARN) TypeScript diagnostics (customize using `[jest-config].globals.ts-jest.diagnostics` option):
message TS151001: If you have issues related to imports, you should consider setting `esModuleInterop` to `true` in your TypeScript configuration file (usually `tsconfig.json`). See https://blogs.msdn.microsoft.com/typescript/2018/01/31/announcing-typescript-2-7/#easier-ecmascript-module-interoperability for more information.
 PASS  __tests__/handler.ts
   connectToRDB (141ms)

  console.info handler.ts:34
    res [ { current_timestamp: 2019-12-20T02:21:33.556Z } ]

----------|----------|----------|----------|----------|-------------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files |        0 |        0 |        0 |        0 |                   |
----------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.721s, estimated 5s
Ran all test suites.

Jest has detected the following 1 open handle potentially keeping Jest from exiting:

    TCPWRAP

      30 |     database: "postgres"
      31 |   });
    > 32 |   await pgClient.connect();
         |                  ^
      33 |   const res = await pgClient.query("SELECT current_timestamp");
      34 |   console.info("res", res.rows);
      35 |   await callback(null, {

      at Connection.Object.<anonymous>.Connection.connect (node_modules/pg/lib/connection.js:54:17)
      at Client.Object.<anonymous>.Client._connect (node_modules/pg/lib/client.js:99:9)
      at _Promise (node_modules/pg/lib/client.js:290:10)
      at Client.Object.<anonymous>.Client.connect (node_modules/pg/lib/client.js:289:10)
      at Object.<anonymous>.exports.connectToRDB (handler.ts:32:18)
      at Object.<anonymous>.test (__tests__/handler.ts:22:9)

^C

対策

これを強制的に終了させるために --forceExit optionをつけて再度実行します。これで crtl + c で強制終了せずにテストが完了します :tada:

% npx jest --detectOpenHandles --forceExit                                                                                                                                    [11:25:12] ts-jest[config] (WARN) TypeScript diagnostics (customize using `[jest-config].globals.ts-jest.diagnostics` option):
message TS151001: If you have issues related to imports, you should consider setting `esModuleInterop` to `true` in your TypeScript configuration file (usually `tsconfig.json`). See https://blogs.msdn.microsoft.com/typescript/2018/01/31/announcing-typescript-2-7/#easier-ecmascript-module-interoperability for more information.
 PASS  __tests__/handler.ts
   connectToRDB (375ms)

  console.info handler.ts:34
    res [ { current_timestamp: 2019-12-20T02:25:45.770Z } ]

----------|----------|----------|----------|----------|-------------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files |        0 |        0 |        0 |        0 |                   |
----------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        7.667s
Ran all test suites.

Jest has detected the following 1 open handle potentially keeping Jest from exiting:

    TCPWRAP

      30 |     database: "postgres"
      31 |   });
    > 32 |   await pgClient.connect();
         |                  ^
      33 |   const res = await pgClient.query("SELECT current_timestamp");
      34 |   console.info("res", res.rows);
      35 |   await callback(null, {

      at Connection.Object.<anonymous>.Connection.connect (node_modules/pg/lib/connection.js:54:17)
      at Client.Object.<anonymous>.Client._connect (node_modules/pg/lib/client.js:99:9)
      at _Promise (node_modules/pg/lib/client.js:290:10)
      at Client.Object.<anonymous>.Client.connect (node_modules/pg/lib/client.js:289:10)
      at Object.<anonymous>.exports.connectToRDB (handler.ts:32:18)
      at Object.<anonymous>.test (__tests__/handler.ts:22:9)

3. DynamoDBトリガーのLambdaでの例外によるリトライ

サンプルコード

DynamoDBのデータ作成、更新イベントをトリガーにLambdaを起動するパターンで、Lambdaがエラー終了するパターンを用意します。

export const dynamoTriggered: Handler = async (
  event: DynamoDBStreamEvent,
  _context: Context,
  _callback: Callback
) => {
  console.info("event.Records", event.Records);
  throw new Error("Uncuaght Error!");
  console.info("SUCCESS");
  return {
    statusCode: 200,
    body: JSON.stringify({
      message: event.Records
    })
  };
};

問題

CloudWatch LogからLambdaのログを見ると、失敗したeventIDと同じIDで定期的にLambdaが再実行されており、リトライされている気配を感じます。アプリケーション的には想定外のエラーなので、リトライされてもエラーが出続けるため、これを抑制したいと思います。

image03.png

原因

ドキュメントを探していくと、以下に行き当たりました。

エラー処理と AWS Lambda での自動再試行

ストリームから読み取るイベントソースマッピングは、項目のバッチ全てに対して再試行を実施します。繰り返されるエラーは、そのエラーが解決されるか、項目が期限切れになるまで、影響を受けるシャードの処理を妨げます。停止しているシャードを検出するには、Iterator Age メトリクスをモニターすることができます。

ストリームはS3、SQS、DynamoDB etc.. それぞれの仕様による、ということだと思うので、DynamoDBについて調べます。
DynamoDB ストリーム を使用したテーブルアクティビティのキャプチャ > DynamoDB ストリーム のデータ保持期限

DynamoDB ストリーム 内のすべてのデータは、24 時間保持されます。特定のテーブルの直近 24 時間のアクティビティを取得して分析できます。ただし、24 時間を超えたデータはすぐにトリミング (削除) される可能性があります。

テーブルのストリームを無効にした場合、ストリーム内のデータは 24 時間読み込み可能な状態になります。この時間が経過すると、データは期限切れになり、ストリームレコードは自動的に削除されます。既存のストリームを手動で削除するためのメカニズムはありません。保持期限 (24 時間) が切れ、すべてのストリームレコードが削除されるまで待つ必要があります

対策

ストリーム内のデータは24時間維持され、自動的に削除されますが明示的に削除する方法はない、ということで、ストリームを止めることによってLambdaの再実行を抑制することは難しそうです。

Lambdaでの処理全体をtry-catchで囲んで、Lambdaとしては常に正常終了するようにし、エラー通知ツールなどで別途エラーを検知して対応する、というのが現実解、ということになるでしょうか。

補足

このストリームの期限については、最近DynamoDBのアップデートがありました。まだ実際に試せてないのですが、リトライ回数やストリームデータの保持期限をセットできるようなので、適切にリトライを制御することもできそうです。
AWS Lambda Supports Failure-Handling Features for Kinesis and DynamoDB Event Sources

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

babel

babel 困った時 Node.js environment

一番役にたったのは一番上のやつ

結論

npm install --save-dev @babel/core @babel/node

最初ずっと yarn でinstall してたんですけど, npm でしたら一発でした!!

エラー内容

Error: Requires Babel "^7.0.0-0", but was loaded with "6.26.3".

Error: Requires Babel "^7.0.0-0", but was loaded with "6.26.3". 
If you are sure you have a compatible version of @babel/core, 
it is likely that something in your build process is loading the wrong 
version. Inspect the stack trace of this error to look for 
the first entry that doesn't mention "@babel/core" or "babel-core" 
to see what is calling Babel. (While processing preset: 
"/Users/~/node_modules/@babel/preset-env/lib/index.js")
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

node.jsで、gcsにあるサイズの大きいjsonlファイルを、mongodbに登録する(メモリふっとばさずに)

node.jsでgcsからファイルを読み込む

gcsからファイルを読み込む方法を探すと、よくdownload()を使用する例が紹介されています。

const storage = new Storage();
const bucket = storage.bucket('test-20180903');
const file = bucket.file('sample');
file.download().then(function(data) {
  res.status(200).json({'result': data.toString('utf-8')});
});

download()だと、サイズの大きいファイルを読み込むとメモリ不足
cloud functionsだと2Gまでしかメモリ拡張できないので、gcs側にファイル配置する際に、ファイルサイズを小さく分割しながら.·゜゜·(/。\)·゜゜·.

@google-cloud/storageのソースを見ている

download()以外に、createReadStream()なるものが!

file.ts
  const storage = new Storage();
  const bucket = storage.bucket('my-bucket');

  const fs = require('fs');
  const remoteFile = bucket.file('image.png');
  const localFilename = '/Users/stephen/Photos/image.png';

  remoteFile.createReadStream()
   .on('error', function(err) {})
   .on('response', function(response) {
     // Server connected and responded with the specified status and headers.
   })
   .on('end', function() {
     // The file is fully downloaded.
   })
   .pipe(fs.createWriteStream(localFilename));

createReadStream()でらファイルを読み込む

gcsからファイルを読み込む方法を探すと、よくdownload()を使用する例が紹介されています。

const storage = new Storage();
const bucket = storage.bucket('test-20180903');
const file = bucket.file('sample');
file.download().then(function(data) {
  res.status(200).json({'result': data.toString('utf-8')});
});

download()だと、サイズの大きいファイルを読み込むとメモリ不足
cloud functionsだと2Gまでしかメモリ拡張できないので、gcs側にファイル配置する際に、ファイルサイズを小さく分割しながら.·゜゜·(/。\)·゜゜·.

@google-cloud/storageのソースを見ている

download()以外に、createReadStream()なるものが!

file.ts
  const storage = new Storage();
  const bucket = storage.bucket('my-bucket');

  const fs = require('fs');
  const remoteFile = bucket.file('image.png');
  const localFilename = '/Users/stephen/Photos/image.png';

  remoteFile.createReadStream()
   .on('error', function(err) {})
   .on('response', function(response) {
     // Server connected and responded with the specified status and headers.
   })
   .on('end', function() {
     // The file is fully downloaded.
   })
   .pipe(fs.createWriteStream(localFilename));

createReadStream()で、ストリーム処理に

↑サンプルをもとに、mongodbへの登録処理を実装してみると、なぞのエラーが・・・
responseイベントは、ノンブロッキング(非同期)処理されるので、mongodbへのアクセスが多すぎたみたい

gcsからサイズの大きいjsonlファイルをmongodbに登録する

createReadStream()で作成したストリームを、ブロッキング(同期)処理したら大丈夫でした。

exports.execute = async (event, context) => {
    const client = await mongo.connect(process.env.MONGODB_URL, { useNewUrlParser: true, useUnifiedTopology: true })

    let rs = null
    try {
        const db = client.db(process.env.MONGODB_DATABASE)
        rs = await storage.bucket(bucket).file(pubsubMessage.name).createReadStream();
        for await (const line of readLines(rs)) {
            const json = JSON.parse(line)
            json.lastModified = new Date()
            // 更新日時があたらしかった場合更新する 
            const result = await db.collection(collection).replaceOne({ _id: json._id, updateDttm: { $lte: json.updateDttm } }, json, { upsert: false })
            if (result.matchedCount == 0) {
                // 未登録の場合があるので登録してみる。 
                try {
                    await db.collection(collection).insertOne(json)
                } catch (err) {
                    if (err.message.indexOf("E11000") < 0) {
                        throw err
                    }
                }
            }
        }
    } catch (err) {
        throw err
    } finally {
        if (client) {
            client.close()
        }
        if (rs) {
            rs.destroy()
        }
    }

    function readLines(rs) {
        const output = new stream.PassThrough({ objectMode: true });
        const rl = readline.createInterface(rs, {});
        rl.on("line", line => {
            output.write(line);
        });
        rl.on("close", () => {
            output.push(null);
        });
        return output;
    }
}

for await...of 文を使うことで、ブロッキング(同期処理)にできました!
node v12からは、標準のreadline.createInterface()が、async iterable を返すようになるようなので、自前のreadLines()もいらなくなるようです。スッキリ書けますね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む