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

壁打ちnodeサーバーをローカル上に立ててスクレイピングする方法(ExpressJSコードサンプルもあり)

件の通り。初心者向けだと思います。nodeサーバーをローカルに立てて、そのサーバーへスクレイピングを走らせます。
(時短でとにかく立てたい人は記事の一番下にコードサンプルを置いているExpressJSのやり方がオススメです。学習もかねてであれば最初から読んだ方が良いと思います。)

nodeで色々とやる上でlocal上でできればサクッと動かしてみたい人は多いと思いますがnodeサーバーサイドでそうした気の利いた?記事はなかったのでまとめました。

献立
①. nodeを実行する環境を準備する
②. nodeサーバーでホスティングするページを準備する
③. nodeサーバーを立てるコードを書いて実行する
④. スクレイピング用のコードを書く

①. nodeを実行する環境を準備する

任意のところでnodeServerなど適当な名前でファイルを作成してそのディレクトリへ移動します。

mkdir nodeServer
cd nodeServer

node自体が入っていない場合は「node 実行環境」などでググりましょう。

今回はシンプルに扱えるscraperjsのライブラリを使用します。
作成したディレクトリ上でインストールします。

yarn add scraperjs
or
npm install --save scraperjs

インストールが完了したらファイルをリロードします

②. nodeサーバーでホスティングするページを準備する

pagesファイルを作成してその中にsample1.htmlとsample2.htmlを置きます

mkdir pages
cd pages

touch sample1.html
touch sample2.html

cd ..

中身は以下のようにします。

sample1.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <h1>サンプル1のページです</h1>
  <h2>このページはaboutとします。</h2>
</body>
</html>
sample2.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <h1>サンプル2のページです</h1>
  <h2>このページはprofileとします。</h2>
</body>
</html>

③. nodeサーバーを立てるコードを書いて実行する

sample1.htmlのみを読み込んでみる

上記のhtmlを読み込むnodeサーバーを立てます。まずは簡易的に説明したいのでsample1.htmlのみを読み込むnodeサーバーを立てます。

まずは親ディレクトリからnodeserver.jsファイルを作成します。

touch nodeserver.js
nodeserver.js
const http = require('http'); // nodeにデフォルトで入っている http ライブラリ
const fs = require('fs'); // nodeにデフォルトで入っているファイル操作の fs ライブラリ

const server = http.createServer(function(req, res) { //サーバーを作成
  if(req.url === '/about') { // リクエストされたページがaboutの場合にsample1.htmlを呼び出す
    fs.readFile('./pages/sample1.html','utf-8', function(error, data){ //fs.readFile(オプション)でsample1.htmlをutf-8の文字コードとして呼び出す。
      if(error) throw error;
      res.writeHead(200, {'Content-Type': 'text/html'});
      res.write(data); //帰ってきたデータを /about ページへ書き込む
      res.end();
    })
  } else {
    const msg = 'このページは存在しません';
    res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
    res.write(msg);
    res.end();
  }
});
server.listen(3000); // ポート番号3000でサーバーを起動

次に親ディレクトリから上記コードを実行します

node nodeserver.js
or
node nodeserver

次にブラウザのURLに http://localhost:3000/about と打ってみましょう。

image.png

上記の通りHTMLが出力されていればOKです。

switch構文と共にページ毎にHTMLを読み込んでみる

次に各ページを読み込めるようリファクタリングします。

pagesのディレクトリに404.htmlを新たに作成しました。sample1.htmlなどをコピペして文章を好きに改変してください(このページは存在しませんやNOT FOUNDなど)

nodeserver.js
const http = require('http');
const fs = require('fs');

const renderFunc = (res, data) => { //何度も呼び出す処理を関数へ
  res.writeHead(200, {'Content-Type': 'text/html'});
  res.write(data);
  res.end();
  return;
}

const server = http.createServer(function(req, res) {
  switch(req.url) {
    case '/about':
      fs.readFile('./pages/sample1.html','utf-8',function(error, data){
        if(error) throw error;
        renderFunc(res, data);
      })
      break;
    case '/profile':
      fs.readFile('./pages/sample2.html','utf-8',function(error, data){
        if(error) throw error;
        renderFunc(res, data);
      })
      break;
    default:
      fs.readFile('./pages/404.html','utf-8',function(error, data){ //例外ページ
        if(error) throw error;
        renderFunc(res, data);
      })
      break;
  }
});
server.listen(3000);

再度立ち上げ直しましょう。以下を読み込めていればOKです。

image.png
image.png

このサーバーは立てたままにします。
当然ながら立てたサーバーに対して任意のアクションを起こせます。
今回はスクレイピングしてみましょう。

④. スクレイピング用のコードを書く

立てたサーバーのHTMLへ向けてスクレイピングの準備をします。そのためのファイルを作成しましょう。

touch scraping.js

親ディレクトリにscraping.jsを作ります。

scraping.js
const scraperjs = require('scraperjs'); 
const pages = ['about', 'profile']; // 対象にしたいページの配列

pages.map(page => { // map関数で順番にpagesを取り出す
  const url = `http://localhost:3000/${page}`;
  scraperjs.StaticScraper.create(url).scrape(($) => { // scraperjsのStaticScraperメソッドを使いスクレイピング
    let title = $('h1').text(); // jQueryを使うr
    let description = $('h2').text();
    return {title, description};
  }).then(result => {
    return  console.log(result);
  }).catch((error) => {
      console.error('Error:', error);
  })
})

コマンドラインに以下のオブジェクトが出力されました。

node scraping.js
{ title: 'サンプル1のページです', description: 'このページはaboutとします。' }
{ title: 'サンプル2のページです', description: 'このページはprofileとします。' }

壁打ちにする

他に使ってみたいライブラリやモジュールがあれば別にファイルを作って壁打ちできます。

おまけ:ExpressJSでサーバーを立てる場合

同じことをしているわけですがかなりスッキリと書けます。このサーバーでもscraping.jsを走らせることができます。
(yarn add express もしくは npm install --save express でライブラリはインストールしましょう。)

expressServer.js
const express = require('express'),
  app = express();

app.set("port", 3000);

app.get("/about", (req, res) => {
  res.sendFile(__dirname + "/pages/sample1.html");
});
app.get("/profile", (req, res) => {
  res.sendFile(__dirname + "/pages/sample2.html");
});
app.get('*', function(req, res){
  res.sendFile(__dirname + "/pages/404.html");
});

app.listen(app.get("port"), ()=>{
  console.log(`Server リスナ:
  ${app.get("port")}監視中`);
})
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

壁打ちnodeサーバーをローカル上に立ててスクレイピングする方法

件の通り。初心者向けだと思います。nodeサーバーをローカルに立てて、そのサーバーへスクレイピングを走らせます。
nodeで色々とやる上でlocal上でできればサクッと動かしてみたい人は多いと思いますがnodeサーバーサイドでそうした気の利いた?記事はなかったのでまとめました。

献立
①. nodeを実行する環境を準備する
②. nodeサーバーでホスティングするページを準備する
③. nodeサーバーを立てるコードを書いて実行する
④. スクレイピング用のコードを書く

①. nodeを実行する環境を準備する

任意のところでnodeServerなど適当な名前でファイルを作成してそのディレクトリへ移動します。

mkdir nodeServer
cd nodeServer

node自体が入っていない場合は「node 実行環境」などでググりましょう。

今回はシンプルに扱えるscraperjsのライブラリを使用します。
作成したディレクトリ上でインストールします。

yarn add scraperjs
or
npm install --save scraperjs

インストールが完了したらファイルをリロードします

②. nodeサーバーでホスティングするページを準備する

pagesファイルを作成してその中にsample1.htmlとsample2.htmlを置きます

mkdir pages
cd pages

touch sample1.html
touch sample2.html

cd ..

中身は以下のようにします。

sample1.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <h1>サンプル1のページです</h1>
  <h2>このページはaboutとします。</h2>
</body>
</html>
sample2.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <h1>サンプル2のページです</h1>
  <h2>このページはprofileとします。</h2>
</body>
</html>

③. nodeサーバーを立てるコードを書いて実行する

sample1.htmlのみを読み込んでみる

上記のhtmlを読み込むnodeサーバーを立てます。まずは簡易的に説明したいのでsample1.htmlのみを読み込むnodeサーバーを立てます。

まずは親ディレクトリからnodeserver.jsファイルを作成します。

touch nodeserver.js
nodeserver.js
const http = require('http'); // nodeにデフォルトで入っている http ライブラリ
const fs = require('fs'); // nodeにデフォルトで入っているファイル操作の fs ライブラリ

const server = http.createServer(function(req, res) { //サーバーを作成
  if(req.url === '/about') { // リクエストされたページがaboutの場合にsample1.htmlを呼び出す
    fs.readFile('./pages/sample1.html','utf-8', function(error, data){ //fs.readFile(オプション)でsample1.htmlをutf-8の文字コードとして呼び出す。
      if(error) throw error;
      res.writeHead(200, {'Content-Type': 'text/html'});
      res.write(data); //帰ってきたデータを /about ページへ書き込む
      res.end();
    })
  } else {
    const msg = 'このページは存在しません';
    res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
    res.write(msg);
    res.end();
  }
});
server.listen(3000); // ポート番号3000でサーバーを起動

次に親ディレクトリから上記コードを実行します

node nodeserver.js
or
node nodeserver

次にブラウザのURLに http://localhost:3000/about と打ってみましょう。

image.png

上記の通りHTMLが出力されていればOKです。

switch構文と共にページ毎にHTMLを読み込んでみる

次に各ページを読み込めるようリファクタリングします。

pagesのディレクトリに404.htmlを新たに作成しました。sample1.htmlなどをコピペして文章を好きに改変してください(このページは存在しませんやNOT FOUNDなど)

nodeserver.js
const http = require('http');
const fs = require('fs');

const renderFunc = (res, data) => { //何度も呼び出す処理を関数へ
  res.writeHead(200, {'Content-Type': 'text/html'});
  res.write(data);
  res.end();
  return;
}

const server = http.createServer(function(req, res) {
  switch(req.url) {
    case '/about':
      fs.readFile('./pages/sample1.html','utf-8',function(error, data){
        if(error) throw error;
        renderFunc(res, data);
      })
      break;
    case '/profile':
      fs.readFile('./pages/sample2.html','utf-8',function(error, data){
        if(error) throw error;
        renderFunc(res, data);
      })
      break;
    default:
      fs.readFile('./pages/404.html','utf-8',function(error, data){ //例外ページ
        if(error) throw error;
        renderFunc(res, data);
      })
      break;
  }
});
server.listen(3000);

再度立ち上げ直しましょう。以下を読み込めていればOKです。

image.png
image.png

このサーバーは立てたままにします。
当然ながら立てたサーバーに対して任意のアクションを起こせます。
今回はスクレイピングしてみましょう。

④. スクレイピング用のコードを書く

立てたサーバーのHTMLへ向けてスクレイピングの準備をします。そのためのファイルを作成しましょう。

touch scraping.js

親ディレクトリにscraping.jsを作ります。

scraping.js
const scraperjs = require('scraperjs'); 
const pages = ['about', 'profile']; // 対象にしたいページの配列

pages.map(page => { // map関数で順番にpagesを取り出す
  const url = `http://localhost:3000/${page}`;
  scraperjs.StaticScraper.create(url).scrape(($) => { // scraperjsのStaticScraperメソッドを使いスクレイピング
    let title = $('h1').text(); // jQueryを使うr
    let description = $('h2').text();
    return {title, description};
  }).then(result => {
    return  console.log(result);
  }).catch((error) => {
      console.error('Error:', error);
  })
})

コマンドラインに以下のオブジェクトが出力されました。

node scraping.js
{ title: 'サンプル1のページです', description: 'このページはaboutとします。' }
{ title: 'サンプル2のページです', description: 'このページはprofileとします。' }

壁打ちにする

他に使ってみたいライブラリやモジュールがあれば別にファイルを作って壁打ちできます。

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

GitHub Actions を使って Next.js × AWS EC2 を自動デプロイした話

※ この記事は K-Ruby #25 のLT資料として書かれた記事です。

こんにちは!

先日、GMOペパボの東証一部上場が決まったことで「東証一部上場の Web 系企業に未経験転職した29歳」になって怪しさに磨きがかかりましたよしこ @k2_yoshikouki です。そろそろエンジニアになれる石売ります。

最近 yoshikouki.net という個人サイトを作っている最中で、勉強も兼ねて以下の要件で作っています

  • Infrastructure as Code で環境構築
    • Chef(ホスト内の実装について定義)
    • Terraform(各ホストの関係について定義)
  • AWS を使用
    • EC2 に nginx (リバースプロキシ)を載せる
    • ECS, EKSなどのコンテナサービスは使わない
  • フレームワークは Next.js (React) を使用
    • バックエンドは node.js (しばらくは Next.js のルーティングを使用したベタ書きかなあと考えている)
    • CI/CD は GitHub Actions を利用

苦節一週間の結果、GitHub Actions で自動デプロイ(Continuous Delivery)を実装できたのでその内容を紹介いたします。

Ruby? 知らない子ですね...

ゴール

GitHub Actions を使って AWS EC2 への自動デプロイを導入します

  • テストがないのでCIの優先順位が低い

課題

  1. AWS へのデプロイでいい感じにしてくれるサービスはECS, EKS関係が潮流で情報が豊富
    (しかし、今回のコンテナ不使用という仕様には合わない)

  2. 候補のデプロイサービスは AWS CodeDeploy や Capistrano がある

    • CodeDeploy はAWS専用サービスかつ情報が少ない
    • Capistrano は Ruby環境が必要なので、node.js な今回の仕様でスマートじゃない
  3. コードの移設は rsync なりscp なりでできるが、プロセスの起動 next start がややこしい

    • 色々試行しているとき、ワークフローで ssh ~~~ "npm run start" を実行してCI/CDが一生終わらないバグを埋め込んだ
      (プロセスが待機状態になるため)
    • nohup npm run start & などで裏プロセスとして動かせたが、2度目以降のデプロイではプロセスが生きているのでエラーになる
      -> 前回のプロセスを kill しないと行けない

対応策

  • コード移設には rsync コマンドで対応
  • Next.js のプロセス管理に PM2 を採用

GitHub Actions ワークフロー

./.github/workflows/main.yml

name: Deploy to yoshiko.net

on:
  push:

env:
  ssh_key_path: ~/.ssh/yoshikouki.net.pem
  app_path: /var/www/

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Cache multiple paths
        uses: actions/cache@v2
        with:
          path: |
            ~/.npm
            **/node_modules
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-
      - uses: actions/setup-node@v1
        with:
          node-version: '12.x'
      - run: npm install
      - run: npm build

  deploy:
    if: github.ref == 'refs/heads/main'
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Generate SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ${{ env.ssh_key_path }}
          chmod 400 ${{ env.ssh_key_path }}
          eval "$(ssh-agent -s)"
          ssh-add ${{ env.ssh_key_path }}
      - name: Deploy with "rsync" command
        run: |
          rsync -avL --progress --exclude ".git/" \
          -e "ssh -i ${{ env.ssh_key_path }} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" \
          ./ \
          ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ env.app_path }}
      - name: Build and Start Next.js
        run: |
          ssh -i ${{ env.ssh_key_path }} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no \
          ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
          'cd ${{ env.app_path }} \
          && sudo npm install \
          && sudo npm install pm2 -g\
          && sudo npm run build \
          && pm2 startOrReload app.json --env production'

PM2 のプロセス設定ファイル

./app.json

{
  "name" : "app",
  "script" : "./node_modules/next/dist/bin/next",
  "env" : {
    "NODE_ENV" : "development"
  },
  "env_production" : {
    "NODE_ENV" : "production"
  }
}

PM2 is 何

image.png

まとめ

GitHub Actions の便利アクションやコンテナ技術を使用することで、実際の処理やデプロイ先の環境を意識することなく簡単にCI/CDできることができますが、今回のこの実装で「便利なラッパーが実際どういう処理をしているのか」ということが学べました。

Capistrano のデフォルト挙動では、アップロードしたディレクトリへのシンボリックリンクを公開パスに配置するという頭良すぎる工夫を知ることもできて(なのでロールバックする場合は以前アップロードしたディレクトリにシンボリックリンクを張り直すだけ)、「便利なライブラリをただ利用するだけでなく、その仕組がどうなっているのかまで理解すると応用できる」というエンジニアとしての重要な気付きも得ることができたのでした。

P.S.
Ruby on Rails で最速CDする記事も上げましたので、そちらもよろしくお願いします。本来このLTでやろうと思っていた内容でした(やれよ)

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

いまさらだけど簡単にFirebase触ってみる(Firebase Cloud Functionsでハローワールド)

概要

これまでAWSでごにょごにょ、はしてきたのですがFirebaseにはほぼ触れてこなかったので簡単に触ってみます。

手順

プロジェクトの作成

・言われるがままにデフォルトっぽい設定で次へ次へ
スクリーンショット 2020-11-26 15.29.56.png
できました。

ウェブアプリの作成

上記スクリーンショットではもう作成された表示になっていますが、ウェブアプリの追加を選択します。
・適当な名前を付けて
・前記事のriot.jsのroot直下のindex.htmlにスクリプトをコピーしてペー。
・何の疑問も持たずにnpm install

$ npm install -g firebase-tools

・とりあえず全部今やる
image.png

・まずはログイン
YesするとブラウザでGoogleのログインフォームが出てくるのでログイン、完了するとサクセスと表示されます。

$ firebase login
i  Firebase optionally collects CLI usage and error reporting information to help improve our products. Data is collected in accordance with Google's privacy policy (https://policies.google.com/privacy) and is not used to identify you.

? Allow Firebase to collect CLI usage and error reporting information? Yes
i  To change your data collection preference at any time, run `firebase logout` and log in again.

~~~(省略)~~~
✔  Success! Logged in as [Your Name]

・(Riot.jsのルートディレクトリでよかったのか不安になりながら)init
何を作るか聞かれている気がするのでFunctionを選択、あとは適当に次へ次へ
結果的にはここで適当に選択しすぎてプロジェクトが新規追加されたけどご愛嬌。???

$ firebase init

     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You're about to initialize a Firebase project in this directory:

  [Your directory]

? Which Firebase CLI features do you want to set up for this folder? Press Space to select fea
tures, then Enter to confirm your choices. (Press <space> to select, <a> to toggle all, <i> to
 invert selection)
 ◯ Database: Deploy Firebase Realtime Database Rules
 ◯ Firestore: Deploy rules and create indexes for Firestore
❯◯ Functions: Configure and deploy Cloud Functions
 ◯ Hosting: Configure and deploy Firebase Hosting sites
 ◯ Storage: Deploy Cloud Storage security rules
 ◯ Emulators: Set up local emulators for Firebase features
 ◯ Remote Config: Get, deploy, and rollback configurations for Remote Config

・作成されたfunction/index.jsのコメントアウトを外してデプロイ!

$ firebase deploy

がっ……駄目っ……!

Error: HTTP Error: 400, Billing account for project 'XXXXXXXXXXXX' is not found. Billing must be enabled for activation of service(s) 'cloudbuild.googleapis.com,containerregistry.googleapis.com' to proceed.

突然のエラー

nodeのバージョンが課金対象となっていた(今回だと12)ため、支払いの設定を有効にする必要があるようです。
今回は動けばいいのでバージョンを8に。

package.json
  "engines": {
    "node": "8"
  },

すると、nodeのバージョン上げろよというメッセージは出つつも、Deploy complete!

✔  functions[helloWorld([location])]: Successful create operation. 
Function URL (helloWorld): [URL]/helloWorld

⚠  functions: Warning: Node.js 8 functions are deprecated and will stop running on 2021-03-15. Please upgrade to Node.js 10 or greater by adding an entry like this to your package.json:

    {
      "engines": {
        "node": "12"
      }
    }

The Firebase CLI will stop deploying Node.js 8 functions in new versions beginning 2020-12-15, and deploys from all CLI versions will halt on 2021-02-15. For additional information, see: https://firebase.google.com/support/faq#functions-runtime


✔  Deploy complete!

Function URLにアクセスすると
スクリーンショット 2020-11-26 18.27.33.png

やりました。

おわりに

途中、何も考えずに突っ走ってみましたが普通にハローワールドできました。Firebaseが優しくてよかった。
大体お察しですが、次は Firebase - Riot.js でごにょごにょします。

参考

Firebase公式
【Firebase】deployがうまくいかない時の対処方法

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

Puppeteer(ぱぺてぃあ)、Headress Recorderで自動化が進んだ

実現させたいこと

とあるサイトのマイページから、データを毎日ダウンロードしたい。しかし、ダウンロードには1,000クリック程度必要。
画面が遷移してもURLが変わらないので、SeleniumやPRAでの実現は難しい。
Chrome操作の自動化ができ、さらにExcelマクロのような録画ツールもあるそうなので、puppeteerを試してみた。
環境構築と習得に1日ほどかかったが、使いやすくて感動!

puppeteerとは

Chromeブラウザを操作できるNode.jsのライブラリ
ヘッドレスブラウザを使用できるので高速

Headless Recorderを使う

HeadlessRecorderをChromeの拡張機能に追加。
録画ボタンを押して、録画したい操作をすると、Puppeteerコードを出力してくれる。
puppeteerで必要なidやclassを調べる手間が省けて便利。

Headless Recorderでできなかったこと

①録画が上手くいかない箇所があった

idやclassが一意でない箇所で、コードに落とせていない操作があった。
お客様番号を使って指定できそうだったので、お客様番号を含むXpathで指定した。

お客様番号のクリック
const customerNumber = '123456'; //文字列型で店舗番号を指定
const customerLink = "//a[contains(@onclick,customerNumber)]";  //XPathを検索

await page.waitForXPath(customerLink);
const customerLinkClick = await page.$x(customerLink);
await customerLinkClick[0].click();

②ダウンロード先の指定

ダウンロード先を指定
  const downloadPath = 'C:\\yyy\\xxx\\test'; // 絶対パスで指定
  await page._client.send(
    'Page.setDownloadBehavior',
    { behavior: 'allow', downloadPath: downloadPath }
  );

感想

・学習コストは低め。PythonとGASしか使ったことがない私でも、環境の設定からはじめて、1日あれば使えた。Headless Recorderが偉大。

参考

Puppeteer入門(本)
HeadlessRecorder

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

Nodejsのコンテナを作成してみた。

はじめに

Nodejsのバックエンドサーバーをコンテナで作成する際、ベースイメージをnodejs(node:10) にしてDockerfileを作成したが、コンテナにアタッチができず、中の構成などを直接見る方法が分からなかった。
困ったときにはnodejsの設定などを見れるようにしたかったので、試しにUbuntuベースにnodejsをインストールする方法でDockerfileを作成してみた。
(記事の内容は、試しに動かすところまで記載あり。)

実行環境(前提)

【Docker導入環境】
  ・Ubuntu 20.04 LTS(GCP上)
  ・docker 19.03.13

今回やった事のメモ

今回の作業のためにテストフォルダを作成して、そこに移動。

$ sudo mkdir ./test_container
$ cd ./test_container

Dockerfileの作成

$ sudo nano ./Dockerfile
Dockerfile
# ベースイメージ
FROM ubuntu:20.04

# 必要パッケージのインストール
RUN apt update
RUN apt install -y tzdata
RUN apt install -y \
  nodejs \
  npm

# Nodejs関連のパッケージインストール
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
COPY ./package.json ./
RUN npm install

# index.jsファイルの設置
COPY ./index.js ./

# ポート開放
EXPOSE 8080

CMD ["node", "index.js"]

nodejsモジュールをインストールするためのpackage.jsonを作成
※上記のnpm installのタイミングで、package.jsonの情報を元にインストールが行われる。

$ sudo nano ./package.json
package.json
{
  "name": "test",
  "description": "test",
  "version": "0.0.1",
  "main": "index.js",
  "private": true,
  "license": "Apache Version 2.0",
  "author": "Google Inc.",
  "engines": {
    "node": "10"
  },

  "dependencies": {
    "express": "^4.17.1",
    "moment-timezone": "^0.5.31",
    "body-parser": "^1.19.0"
  }
}

index.jsを作成

$ sudo nano ./index.js
index.js
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const port = 8080;


app.use(bodyParser.urlencoded({ extended: true }));

app.get('/get_test1', function(req, res) {
  res.send('GET1パラメータ取得: ' + req.query.get1)
});

app.get('/get_test2', function(req, res) {
  res.send('GET2パラメータ取得: ' + req.query.get2)
});

app.post('/post_test1', function(req, res) {
  res.send('POST-URLへの送信です。')
});

app.post('/post_test2', function(req, res) {
  res.send('POSTパラメータ取得: ' + req.body.data1)
});

app.listen(port)

ビルド実行

$ docker image build -t test_container:v1 ./

コンテナ作成 & 起動

$ docker container run -it -d -p 80:8080 --name con1 test_container:v1

動作確認

GETの動きを確認
以下に接続して、上記で設定したレスポンスが返ってくればOK。

 外部IP/get_test1?get1=10
 外部IP/get_test2?get2=20

POSTの動きを確認
ローカル環境(自分のPC)に適当なhtmlファイルを作成して、それをブラウザで開きアクセス。

post_test1.html(POST送信のテスト用)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>POSTテスト用</title>
</head>

<body>

<form action="http://[外部IP]/post_tset" method="post">
  <input type="hidden" name="data1" value="aiueo">
  <input type="submit" value="送信">
</form>

</body>
</html>
post_test2.html(POSTパラメータ取得の確認用)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>POSTテスト用</title>
</head>

<body>

<form action="http://[外部IP]/post_tset2" method="post">
  <input type="hidden" name="data1" value="aiueo">
  <input type="submit" value="送信">
</form>

</body>
</html>

おまけ

はじめの背景の部分で 『コンテナにアタッチができず、中の構成などを直接見る方法が分からなかった』 と書いたが、コンテナ作成時の[CMD] コマンドを /bin/bash で上書きできていなかっただけだった・・・

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

[Node.js][Deno] オブジェクト合成各種 ベンチマーク比較

先ほどの クラスのベンチマーク の続編。
オブジェクト合成でより良い(というか速い)手法ないかなと悩みつつ。

実験環境

同じく

  • Node.js 14.15.1
  • Deno 1.5.4

実験設定

  • 10要素で構成されるオブジェクト2組を1つのオブジェクトにまとめる
  • 要素のうち5つは名前が重複しており、合成元を合成先に上書きする
  • 合成先はプロトタイプではなく、独立したオブジェクトとして新規に生成される

という前提で

  • 合成先オブジェクトを生成するファンクションと、合成元オブジェクトを用意
  • 実行時間計測
    • 合成先オブジェクト生成ファンクションだけの結果
    • 合成先オブジェクト生成ファンクション+各種アルゴリズムの結果
  • 結果表示
    • 各種アルゴリズムの結果部分を抽出表示

という手順。

オブジェクト合成アルゴリズム ここに集う

今回調査するアルゴリズムを御紹介いたしましょう。

assign
Object.assign()
spread
スプレッド構文
for
for inループ
forEach
keys forEachコールバック
map
keys mapコールバック
entries
entries列挙ループ
lowtech1
(対照用) オブジェクト要素として個別に代入
lowtech2
(対照用) 連想配列要素として個別に代入

多少のオーバーヘッドは仕方ないにしても、できるだけ対照用で挙げた方法に
近い(というか速い)アルゴリズムを採用したいという趣旨。

いざ、実験

今回は記述がコンパクトなので、1ソースでまとめていけます

bench_obj_merge.js
function CreateParent(){return {a:0,b:1,c:2,d:3,e:4,f:5,g:6,h:7,i:8,j:9};}
var sub={f:10,g:11,h:12,i:13,j:14,k:15,l:16,m:17,n:18,o:19};

// 実行内容 
var funx={
    dmy:()=>0, // 最初の実行項目は不利な結果が出るのでダミー 
    create:()=>CreateParent(),
    assign:()=>Object.assign(CreateParent(),sub),
    spread:()=>{return {...CreateParent(),...sub};},
    for:()=>{
        var t=CreateParent();
        for(var k in sub)t[k]=sub[k];
        return t;
    },
    forEach:()=>{
        var t=CreateParent();
        Object.keys(sub).forEach(k=>t[k]=sub[k]);
        return t;
    },
    map:()=>{
        var t=CreateParent();
        Object.keys(sub).map(k=>t[k]=sub[k]);
        return t;
    },
    entries:()=>{
        var t=CreateParent();
        for(var [k,v] of Object.entries(sub))t[k]=v;
        return t;
    },
    lowtech1:()=>{
        var t=CreateParent();
        t.f=sub.f;
        t.g=sub.g;
        t.h=sub.h;
        t.i=sub.i;
        t.j=sub.j;
        t.k=sub.k;
        t.l=sub.l;
        t.m=sub.m;
        t.n=sub.n;
        t.o=sub.o;
        return t;
    },
    lowtech2:()=>{
        var t=CreateParent();
        t['f']=sub['f'];
        t['g']=sub['g'];
        t['h']=sub['h'];
        t['i']=sub['i'];
        t['j']=sub['j'];
        t['k']=sub['k'];
        t['l']=sub['l'];
        t['m']=sub['m'];
        t['n']=sub['n'];
        t['o']=sub['o'];
        return t;
    },
};
// 計測結果を書き込むところ 
var rec={};

// 動作テスト 
//for(var k in funx)console.log([k,funx[k]()]);

// 繰り返し実行時間計測 
var loop=Array(1000000);
for(var k in funx){
    var f=funx[k];
    var bgn=new Date;
    for(var i of loop)f();
    var end=new Date;
    rec[k]=(end-bgn);
}

// 結果表示 
for(var k in rec)console.log(k+' :'+(rec[k]-rec.create));

ここで予想外の事態。当初は 前回 と同じ1千万ループだったのですが、なかなか処理終わらなかったので一旦止めて桁減らしちゃいました。
それでループ回数が1桁減っているので、前回の結果と比べるときは御注意ください。

Node.js Deno
assign 255.7 265.4
spread 6906.3 6503.3
for 289.2 484.4
forEach 413.6 463.1
map 445.5 505.7
entries 417.3 428.4
lowtech1 10.1 8
lowtech2 10.3 8

まず目を疑ったのが、今(一部で)流行りのスプレッド構文、なんと1桁遅い。
記述が一番シンプルなだけに残念なところ。
で、とりあえず Object.assign() がベストというか幾分マシということで。
もっと素敵な手法ないものかしらん。

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

Windows開発環境2020 - VScode/Docker/WSL2

WSL2

要件はバージョン 1903 以降、 ビルド 18362 以上。

手順 1 - Linux 用 Windows サブシステムを有効にする

管理者として PowerShell を開き、以下を実行します。

dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart

手順 2: 仮想マシンの機能を有効にする

管理者として PowerShell を開き、以下を実行します。

dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart

終わったらPC再起動

手順 3 - Linux カーネル更新プログラム パッケージをダウンロードする

以下をインストール
- x64 マシン用 WSL2 Linux カーネル更新プログラム パッケージ

手順 4 - WSL 2 を既定のバージョンとして設定する

管理者として PowerShell を開き、以下を実行します。

wsl --set-default-version 2

手順 5 - 選択した Linux ディストリビューションをインストールする

Microsoft Store を開き、希望する Linux ディストリビューションを選択します。基本Ubuntuで良いかと。

手順 6 - 新しいディストリビューションを設定する

新しくインストールした Linux ディストリビューションを初めて起動すると、コンソール ウィンドウが開き、ファイルが圧縮解除されて初期設定が走ります。途中でユーザー名とパスワードを決めてくれと言われますので入力。

Docker Desktop

前提としてWindows 10 ProであればHyper-Vの有効化が必須。
HomeであればUEFIから設定かも。詳しくは管理者に聞いてください。

インストール

下記からDocker Desktop Windows版 をダウンロード

ダウンロードが出来たらインストーラーを実行します。
必ず「Enable WSL 2 Windows Features」にチェックが入っている事を確認してインストールしてください。

開発環境導入

ここからWSL2上で開発環境を構築していきます。
PowerShellで wsl コマンドを打つ事でWSL2に切り替えできます。

最新の状態に更新

まずは最新にしておきましょう

sudo apt update && sudo apt upgrade -y

nvmをインストール

インストール

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.0/install.sh | bash

インストール出来たか確認するためターミナルを閉じて再度WSLに入ってから下記を叩く

command -v nvm

nvmと返ってきたらインストール完了

Node.jsをインストール

安定版(LTS)をインストール

nvm install node --lts

確認します

$node -v
v14.15.1

yarnをインストール

公式ドキュメント通りにインストールします

curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt update && sudo apt install -y yarn

確認します

$ yarn -v 
1.22.5

同時に入る古いNodejsを削除しておく

sudo apt purge -f libuv1 nodejs
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

OpenAPIドキュメントから全APIの自動テストを生成する

はじめに

グロースエクスパートナーズ Advent Calendar 2020 3日目。
yitoです。

API開発をしている中で、テストするのに毎回クライアントツールにリクエストを用意してAPIを呼び出すのが手間でした。
それを、コマンド一発で、API仕様書に定義した全てのAPIをテストできるようにしたので、その方法を紹介します。

概要

OpenAPIドキュメントをAPIクライアントツールで利用可能なJSONに変換し、それをCLIライブラリで実行します。
これを実現する、以下について順番に説明していきます。

  • Postmanについて
  • OpenAPI 3.0 to Postman Collection v2.1.0 Converter
  • Postman Collection SDK
  • newman

Postmanについて

APIリクエストのクライアントツールにPostmanがあります。
Postmanは、以下のようなことができます。

スクリーンショット 2020-11-26 0.00.35.png

書き出したリクエスト情報を保存すると、画面左側にあるPostman Collectionsに追加され、フォルダでの管理ができます。
このPostman Collectionsは、Webに公開する(制限をかけることも可能?)か、JSONでのexport/importで、開発チーム内で共有が可能です。

OpenAPI 3.0 to Postman Collection v2.1.0 Converter

OpenAPI 3.0 to Postman Collection v2.1.0 Converterは、OpenAPIドキュメントをPostman CollectionのJSONに変換する、NodeJSで実行可能なライブラリです。
使い方は簡単で、OpenAPIのyamlを読み込んで、このライブラリのクラスに渡して実行するだけです。

const fs = require('fs');
const readFileData = fs.readFileSync('OpenAPIドキュメントのファイルパス', {encoding: 'UTF8'});

const Converter = require('openapi-to-postmanv2');
Converter.convert({type: 'string', data: readFileData}, {},
    (err, conversionResult) => {
      if (!conversionResult.result) {
        console.error('API定義をPostman Collectionに変換できませんでした',
            conversionResult.reason);
        return;
      }

      const convertData = conversionResult.output[0].data;

      // Postman CollectionsのJSONファイルを生成する
      fs.writeFile('postman-collections.json', JSON.stringify(convertData, null, 4), (err) => {
        if(err) console.error(err)
      });

    }
);

この生成されたJSONファイルを読み込むと、Postmanにて利用ができます。

スクリーンショット 2020-11-26 0.43.57.png

ただ、このままだと、各APIのリクエストパラメータが <long> <string> のようになっていて、このままだと呼び出したいAPIのパラメータとしては不適切なので、使う際は書き換えが必要です。

手で1つ1つ直すのは面倒なので、Postman Collection SDKを使います

Postman Collection SDK

Postman Collection SDKは、Postman Collectionの生成・更新のための、NodeJSで実行可能なSDKです。
OpenAPI 3.0 to Postman Collection v2.1.0 Converterにて生成したPostman CollectionのJSONを、このSDKを使って編集します。

Postman CollectionのJSONファイルを読み込み、SDKにてCollectionをnewすると、以下のようなクラスが生成されます。
クラスについての詳細

  • Collection
    • Postman Collectionの1つに相当
    • メンバーにItemGroupを持つ
  • ItemGroup
    • Postman Collectionの1フォルダに相当
    • メンバーにItemを持つ
  • Item
    • Postman Collectionの1つのAPIに相当
    • メンバーにRequestを持つ
  • Request
    • APIのURL、リクエストパラメータに関する値
    • メンバーにHeaderList, EventList, VariableListなどを持つ

<long> <string> のような値を実際に使いたい値に変えるため、 Request の値を書き換えます。
こちらにあるOpenAPIドキュメント(api-spec.yml)、Postman Collection SDKを使った実装(postman-test/index.js)を使いながら説明します。

先ほどの実装に以下を追加します。

...
      const convertData = conversionResult.output[0].data;

      const collection = new Collection(convertData);

      collection.items.all().forEach(item => updateItem(item))

      // Postman CollectionsのJSONファイルを生成する
      fs.writeFile('postman-collections.json', JSON.stringify(collection, null, 4), (err) => {
        if(err) console.error(err)
      });

...

collection.itemsItemGroup または Item があります。そこから、 Request を取り出して、 <long> <string> のような値を、使いたい値に書き換えます。

...

const { v4: uuidv4 } = require('uuid');

const updateRequestVariable = (request, key, value) => {
  const findVariable = request.url.variables.find(
      variable => variable.key === key);
  if (findVariable) {
    findVariable.update({
      key: key,
      value: value
    })
  }
}

const updateRequestQuery = (request, key, value) => {
  const findQueryParam = request.url.query.find(
      queryParam => queryParam.key === key);
  if (findQueryParam) {
    findQueryParam.update({
      key: key,
      value: value
    })
  }
}

const updateRequest = (request) => {

  // Authorization
  if (request.auth && request.auth.type === 'apikey') {
    request.auth.update({
      "key": "api_key",
      "value": `api-key-${uuidv4()}`
    }, 'apikey')
  }

  // 書籍関連のAPI
  if (request.url.getPath().includes('books')) {
    updateRequestVariable(request, 'bookId', '100000001');
  }

  // ユーザー関連のAPI
  if (request.url.getPath().includes('users')) {
    updateRequestVariable(request, 'username', 'user-XXXX');
    updateRequestQuery(request, 'username', 'user-XXXX');
    updateRequestQuery(request, 'password', 'pass-XXXX');

  }

  return request;
}

const updateItem = (item) => {
  if (item.items) {
    item.items.all().forEach(item => updateItem(item));
  } else {
    item.request = updateRequest(item.request);

  }

  return item;
}

...

レスポンスに対するテストも実行したいので、 updateItem メソッドに以下のコードを追加します。

...

const updateItem = (item) => {
  if (item.items) {
    item.items.all().forEach(item => updateItem(item));
  } else {
    // 追加
    item.events.add({
      listen: 'test',
      script: {
        exec: "pm.test('response 200 test', () => {\n"
            + "    pm.response.to.have.status(200);\n"
            + "});"
      },
      type: 'text/javascript'
    })

...

これでリクエストパラメータの書き換えがされた状態で、Postman CollectionのJSONが生成されます。

毎回JSONをimportしてAPIを呼び出すのは面倒なので、CLIだけで実行できるように、newmanを使います.

newman

newmanは、CLIでPostman CollectionのJSONを読み込みAPIをリクエストする、NodeJSで実行可能なライブラリです。

先ほどのコードで生成したJSONを、newmanで実行するように変えます。

...
      const collection = new Collection(convertData);

      collection.items.all().forEach(item => updateItem(item))
      fs.writeFile('postman-collections.json', JSON.stringify(collection, null, 4), (err) => {
        if(err) console.error(err)
      });

      newman.run({
        collection: collection,
        reporters: 'cli',
        environment: require('./local.postman_environment.json')
      }, (err) => {
        if (err) console.error(err);
      });

...

  • newman.run()
    • collection: Postman CollectionのJSONを指定
    • reports: 実行結果の出力方法の指定。CLI、Junit、JSON等が可能
    • envioroment: Postmanには環境ごとの変数の指定があり、生成されたJSONには baseUrl の指定が必要なため、向き先を指定したJSONを用意して読み込む

実行すると以下のようになります。

% npm run start
> node index.js

newman

Book Management

❏ books
↳ 書籍更新API
  PUT http://localhost:8080/v1/books [200 OK, 123B, 235ms]
  ✓  response 200 test

↳ 書籍登録API
  POST http://localhost:8080/v1/books [200 OK, 123B, 19ms]
  ✓  response 200 test

↳ 書籍一覧取得API
  GET http://localhost:8080/v1/books [200 OK, 800B, 11ms]
  ✓  response 200 test

↳ タグ絞り込み検索API
  GET http://localhost:8080/v1/books/findByTags?tags=<string>&tags=<string> [200 OK, 800B, 14ms]
  ✓  response 200 test

❏ books / {book Id}
↳ 書籍詳細取得API
  GET http://localhost:8080/v1/books/100000001 [200 OK, 290B, 10ms]
  ✓  response 200 test

↳ 書籍削除API
  DELETE http://localhost:8080/v1/books/100000001 [200 OK, 123B, 7ms]
  ✓  response 200 test

❏ users
↳ ユーザー登録API
  POST http://localhost:8080/v1/users [200 OK, 123B, 8ms]
  ✓  response 200 test

↳ ユーザー一覧取得API
  GET http://localhost:8080/v1/users [200 OK, 123B, 6ms]
  ✓  response 200 test

↳ ログインAPI
  GET http://localhost:8080/v1/users/login?username=user-XXXX&password=pass-XXXX [200 OK, 123B, 8ms]
  ✓  response 200 test

↳ ログアウトAPI
  GET http://localhost:8080/v1/users/logout [200 OK, 123B, 6ms]
  ✓  response 200 test

❏ users / {username}
↳ ユーザー詳細取得APi
  GET http://localhost:8080/v1/users/user-XXXX [200 OK, 123B, 6ms]
  ✓  response 200 test

↳ ユーザー更新API
  PUT http://localhost:8080/v1/users/user-XXXX [200 OK, 123B, 5ms]
  ✓  response 200 test

↳ ユーザー削除API
  DELETE http://localhost:8080/v1/users/user-XXXX [200 OK, 123B, 7ms]
  ✓  response 200 test

→ タグ一覧取得API
  GET http://localhost:8080/v1/tags [200 OK, 123B, 6ms]
  ✓  response 200 test

┌─────────────────────────┬───────────────────┬──────────────────┐
│                         │          executed │           failed │
├─────────────────────────┼───────────────────┼──────────────────┤
│              iterations │                 1 │                0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│                requests │                14 │                0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│            test-scripts │                14 │                0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│      prerequest-scripts │                 0 │                0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│              assertions │                14 │                0 │
├─────────────────────────┴───────────────────┴──────────────────┤
│ total run duration: 654ms                                      │
├────────────────────────────────────────────────────────────────┤
│ total data received: 1.37KB (approx)                           │
├────────────────────────────────────────────────────────────────┤
│ average response time: 24ms [min: 5ms, max: 235ms, s.d.: 58ms] │
└────────────────────────────────────────────────────────────────┘

以上で、コマンド一発でAPI全てをテストできるようになりました。

参考

サンプルコード

https://github.com/yito0000/openapi-postman-test-example

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

mongoose の model と schema に TypeScript で型をつける

Migration mongoose models and schemas from JavaScript to TypeScript

今年は自宅にこもりがちになって腰椎椎間板ヘルニアと坐骨神経痛のコンボを決めた @algas です。
この記事は TypeScript Advent Calendar 2020 の1日目の記事として作成しました。
記事に登場するコードは github リポジトリで公開しています。
https://github.com/algas/typed-mongoose-example

概要

mongoose という mongoDB の Node.js では有名なライブラリを使うアプリケーションコードを JavaScript から TypeScript に移行する作業を行いました。
JavaScript で定義済みの mongoose のモデルとスキーマに「正しく」型をつけることができたのでそのノウハウを共有します。
基本的には @types/mongoose を使って型をつけています。
スキーマの型定義の一部が不十分だったので独自の定義をして補いました。

対象読者

この記事は次のような読者を対象に想定しています。

  • mongoose または mongoDB を使っている
  • TypeScript を書いたことがある

mongoDB や mongoose, TypeScript の詳しい説明はしません。

背景

この記事を書くに至った背景を説明します。

なぜ mongoose のコードに型をつけると良いのか

わざわざ書くまでもないとは思いますが、コードを静的型付きにすることでコンパイル時に不具合を見つけやすくなったり開発環境によるコード記述の補完を得られるようになります。適切に型をつけることができればスキーマやモデルに対して定義したフィールドやメソッドとして何が含まれているかやその型の情報を使って効率よく安心して開発をすることができます。

mongoose が TypeScript ネイティブではない

mongoose は JavaScript で古くから開発され続けているライブラリで、そのコードは TypeScript に対応していません。@types/mongoose で型情報を別途付与することはできますが、TypeScript のアプリケーションから呼び出すのに便利な実装になっているとは言えません。

mongoose のスキーマやモデルに型をつけるのは難しい

mongoose スキーマやモデルに後から TypeScript で型をつけるのには工夫が必要です。mongoose の Collection スキーマには単純にデータベースに値を保持するフィールドだけではなく Virtual Property, Instance Method, Static Method, Plugin などの機能があります。これらに対応するモデルに型をつけ、さらにスキーマ自体にフィールドの型を付与するのが本記事の試みです。

初めから TypeScript で書く場合やこれから新しく mongoose スキーマを定義する場合には別のライブラリを使うなどの手法をオススメします。

実装例

具体的に mongoose で定義したスキーマとモデルに TypeScript で型をつけてみます。
https://github.com/algas/typed-mongoose-example/blob/main/src/model.ts

執筆時点では次のバージョンのライブラリにそれぞれ対応しています。

  • typescript: 3.x
  • mongoose: 5.x
  • @types/mongoose: 5.x

mongoose の基本的な使い方は公式ドキュメントに書いてあります。
https://mongoosejs.com/docs/index.html
これを知っている前提で話を進めます。

Schema

mongoDB の Collection に入れるデータのスキーマを定義します。
User という Collection を作ることにします。
Instance method として foo という関数を追加しています。

import { Document, model, Model, Schema, Types } from 'mongoose';

interface UserSchemaFields {
  email: string;
  firstName: string;
  lastName: string;
  age?: number;
  friends?: Types.ObjectId[];
}
const userSchemaFields: SchemaDefinition<UserSchemaFields> = {
  email: {
    type: String,
    required: true,
    unique: true
  },
  firstName: {
    type: String,
    required: true
  },
  lastName: {
    type: String,
    required: true
  },
  age: {
    type: Number
  },
  friends: [Schema.Types.ObjectId],
};

const UserSchema: Schema<UserSchemaProperties> = new Schema(userSchemaFields);

// Instance methods
interface UserSchemaProperties extends UserSchemaFields {
  foo: () => void;
}
UserSchema.methods.foo = function() {};

ここではスキーマとそのフィールドの定義に明示的に型を与えているのが一般的な方法との違いです。
Instance method の定義は Schema だけに与えて SchemaDefinition には与えません。
独自に定義し直した SchemaDefinition は次のように書けます。
https://github.com/algas/typed-mongoose-example/blob/main/src/mongoose-util.d.ts

import { Schema, SchemaType, SchemaTypeOpts } from 'mongoose';

type SchemaPropType<T> = T extends string
  ? StringConstructor
  : T extends number
  ? NumberConstructor
  : T extends boolean
  ? BooleanConstructor
  : any;

type SchemaTypeOptions<T> = Omit<SchemaTypeOpts<T>, 'type'> & {
  type?: SchemaPropType<T>;
};

export type SchemaDefinition<T = any> = {
  [K in keyof T]: SchemaTypeOptions<T[K]> | Schema | SchemaType;
};

SchemaTypeOpts から 'type' の定義を取り除いて使いました。
元の実装は次のコードで定義されています。
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/mongoose/index.d.ts

Virtual properties (Document)

mongoose schema には virtual という機能を使って仮想的な要素を追加できます。
ここでは fullName を取得する機能を実装します。
UserDocumentDocumentUserSchemaProperties (fields + instance methods) を継承させて virtual property fullName の型を足します。

// Virtual properties
interface UserDocument extends Document, UserSchemaProperties {
  fullName: string;
}

UserSchema.virtual('fullName').get(function () {
  return [this.firstName, this.lastName].join(' ');
});

mongoose Document の詳細は mongoose の API reference に書かれています。
https://mongoosejs.com/docs/documents.html

Statics variables and functions

mongoose schema には static の変数や関数を追加できます。
UserModel に上で定義した UserDocument を指定した Model<UserDocument> を継承させて static method bar の型を足します。

// Static methods
interface UserModel extends Model<UserDocument> {
  bar: () => string;
}

UserSchema.statics.bar = function(){
  return 'bar';
}

mongoose Model の詳細は mongoose の API reference に書かれています。
https://mongoosejs.com/docs/models.html

Plugins

他のスキーマで定義された関数などを plugin として呼び出すことができます。
plugin で作った変数や関数の interface は本体とは別に定義して個別に呼び出せるようにすべきです。
たとえば SomePluginSchema という名前のスキーマで定義されている static 関数を呼び出す場合には次のように書きます。

interface SomePluginFunctions {
  somePluginFunction: () => void;
}

UserSchema.plugin(SomePluginSchema, {});
interface UserModel extends Model<UserDocument>, SomePluginFunctions { ... }

Model

mongoose model を定義します。
引数にはモデルの名前とスキーマを渡します。
型指定に UserDocument, UserModel を明示することで User モデルの型情報が使えるようになります。

export const User = model<UserDocument, UserModel>('User', UserSchema);

モデルとスキーマを使ってみる

ここまででスキーマとモデルの定義はおしまいです。
モデルを使うにはインスタンス化する必要があります。

const someUserData: UserSchemaFields {
  ...
}
const someUser = new User(someUserData);

someUserUserDocument 型になります。
User() の引数に渡すオブジェクトに UserSchemaFields の型を使えば厳密に定義できます。

また User データを mongoDB から取得するコードは次のように書きます。

User.find(function (err, users) {
  if (err) return console.error(err);
  console.log(users);
})

TypeScript が正しく設定された開発環境を使うと User やそのインスタンスにフィールドやメソッドが適切に補完されることを確認できます。

まとめ

  • 適切に型をつければ TypeScript でも mongoose を使った開発をできる
  • mongoose に TypeScript で適切な型をつけるのはちょっと大変
  • 新しく TypeScript + mongoDB を使った開発をするのであれば mongoose 以外のライブラリを使うべき

注意点

参考文献

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

[Node.js][Deno] クラス定義各種 ベンチマーク比較

JavaScriptのクラス定義手法はいっぱいあって、これを解説している記事もいっぱいありますが、パフォーマンスに言及しているところがなかったので、自前で実験してみた。

実験環境

  • Node.js 14.15.1
  • Deno 1.5.4

なお、webクライアントでの実行コストはあまり気にしてないので、各種ブラウザでの比較はパス。

実験設定

  • 継承のあるクラスで
    • インスタンス生成コストだけでなく、親メンバへのアクセスコストも重要
  • とにかくランタイムで高速なものを追求
    • プロトタイプ定義コストは、あまり気にしてない
  • 標準的なクラス定義方法との互換性も、あまり気にしてない
    • 各メンバの読み書きができれば充分
    • ただしもちろん、書き込みによってプロトタイプを破壊しないことは重要

そんなわけで、各手法それぞれ

  • TestClassA 定義
    • プロパティ a とメソッド getA() を含める
  • TestClassB 定義
    • TestClassA を継承
    • プロパティ b~i を含める
  • TestClassC 定義
    • TestClassB を継承
    • プロパティ j~z を含める (このぐらい余計に定義入れとけばプロトタイプへのアクセスコストに差が出てくること期待)
  • TestClassC で最初のインスタンス生成
    • ついでに正常にアクセスできるかテスト
  • 実行時間計測
    • 最初のインスタンスでget (プロパティ a 読み出し)
    • 最初のインスタンスでcall (メソッド getA() 呼び出し)
    • 新規生成インスタンスでget
    • 新規生成インスタンスでcall
  • 結果表示
    • get call それぞれ
    • 新規生成インスタンス版結果 - 最初のインスタンス流用版結果 → インスタンス生成コスト
    • 10回実行して平均をとる

な手順で。

実験: 標準的なprototype定義方式

まずは普通に。

bench_class_standard.js
// TestClassA 定義 
function TestClassA(){
    this.a=1;
};
TestClassA.prototype.getA=function(){return this.a;};

// TestClassB 定義 
function TestClassB(){
    TestClassA.call(this);
    this.b=2;
    this.c=3;
    this.d=4;
    this.e=5;
    this.f=6;
    this.g=7;
    this.h=8;
    this.i=9;
}
Object.setPrototypeOf(TestClassB.prototype,TestClassA.prototype);

// TestClassC 定義 
function TestClassC(){
    TestClassB.call(this);
    this.j=10;
    this.k=11;
    this.l=12;
    this.m=13;
    this.n=14;
    this.o=15;
    this.p=16;
    this.q=17;
    this.r=18;
    this.s=19;
    this.t=20;
    this.u=21;
    this.v=22;
    this.w=23;
    this.x=24;
    this.y=25;
    this.z=26;
}
Object.setPrototypeOf(TestClassC.prototype,TestClassB.prototype);

// 既存インスタンス準備 
var t=new TestClassC();
//console.log(t);
//console.log(t.getA());

// 実行内容 
var funx={
    dmy:()=>0, // 最初の実行項目は不利な結果が出るのでダミー 
    get:()=>t.a,
    call:()=>t.getA(),
    new_get:()=>new TestClassC().a,
    new_call:()=>new TestClassC().getA(),
};
// 計測結果を書き込むところ 
var rec={};

// 繰り返し実行時間計測 
var loop=Array(10000000);
for(var k in funx){
    var f=funx[k];
    var a=0;
    var bgn=new Date;
    for(var i of loop)a+=f();
    var end=new Date;
    rec[k]=end-bgn;
}

// インスタンス生成時間を抽出表示 
console.log(rec.new_get-rec.get);
console.log(rec.new_call-rec.call);
Node.js Deno
get 3.7 427.8
call 7.7 434.7

後発品なのになんでこんな遅いんだよDeno
…というわけで詳細に調べたところ、どうやらコンストラクタで親クラスのcall呼んでるところでやたらと時間かかってる模様。
そんなわけでDeno移行は時期尚早かな…って結論出す前に、まず次いってみましょうか。

実験: いまどきのclass方式

ECMAScript 2015以降の追加仕様ですね。
めんどいprototype定義ともおさらば、やったー
…って喜ぶ前に、パフォーマンスをみてみましょう。

bench_class_modern.js
// TestClassA 定義 
class TestClassA{
    constructor(){
        this.a=1;
    }
    getA(){return this.a;}
}

// TestClassB 定義 
class TestClassB extends TestClassA{
    constructor(){
        super();
        this.b=2;
        this.c=3;
        this.d=4;
        this.e=5;
        this.f=6;
        this.g=7;
        this.h=8;
        this.i=9;
    }
}

// TestClassC 定義 
class TestClassC extends TestClassB{
    constructor(){
        super();
        this.j=10;
        this.k=11;
        this.l=12;
        this.m=13;
        this.n=14;
        this.o=15;
        this.p=16;
        this.q=17;
        this.r=18;
        this.s=19;
        this.t=20;
        this.u=21;
        this.v=22;
        this.w=23;
        this.x=24;
        this.y=25;
        this.z=26;
    }
}

// 既存インスタンス準備 
var t=new TestClassC();
//console.log(t);
//console.log(t.getA());

// 実行内容 
var funx={
    dmy:()=>0, // 最初の実行項目は不利な結果が出るのでダミー 
    get:()=>t.a,
    call:()=>t.getA(),
    new_get:()=>new TestClassC().a,
    new_call:()=>new TestClassC().getA(),
};
// 計測結果を書き込むところ 
var rec={};

// 繰り返し実行時間計測 
var loop=Array(10000000);
for(var k in funx){
    var f=funx[k];
    var a=0;
    var bgn=new Date;
    for(var i of loop)a+=f();
    var end=new Date;
    rec[k]=end-bgn;
}

// インスタンス生成時間を抽出表示 
console.log(rec.new_get-rec.get);
console.log(rec.new_call-rec.call);
Node.js Deno
get 145.4 91.3
call 139.8 89.3

…遅っ。
だめだこりゃ、次いってみよー

実験: コンストラクタをinit()で代用方式

先ほど、親クラスのcallが遅いって書きました。
で、コンストラクタ使わずに自前で初期化メソッド書いちゃえばいんじゃね作戦。

bench_class_noctor.js
// TestClassA 定義 
function TestClassA(){}
TestClassA.prototype.init=function(){
    this.a=1;
};
TestClassA.prototype.getA=function(){return this.a;};

// TestClassB 定義 
function TestClassB(){}
Object.setPrototypeOf(TestClassB.prototype,TestClassA.prototype);
TestClassB.prototype.init=function(){
    Object.getPrototypeOf(this).init();
    this.b=2;
    this.c=3;
    this.d=4;
    this.e=5;
    this.f=6;
    this.g=7;
    this.h=8;
    this.i=9;
};

// TestClassC 定義 
function TestClassC(){}
Object.setPrototypeOf(TestClassC.prototype,TestClassB.prototype);
TestClassC.prototype.init=function(){
    Object.getPrototypeOf(this).init();
    this.j=10;
    this.k=11;
    this.l=12;
    this.m=13;
    this.n=14;
    this.o=15;
    this.p=16;
    this.q=17;
    this.r=18;
    this.s=19;
    this.t=20;
    this.u=21;
    this.v=22;
    this.w=23;
    this.x=24;
    this.y=25;
    this.z=26;
};

// 既存インスタンス準備 
var t=new TestClassC();
t.init();
//console.log(t);
//console.log(t.getA());

// 実行内容 
var funx={
    dmy:()=>0, // 最初の実行項目は不利な結果が出るのでダミー 
    get:()=>t.a,
    call:()=>t.getA(),
    new_get:()=>{var u=new TestClassC(); u.init(); u.a},
    new_call:()=>{var u=new TestClassC(); u.init(); u.getA()},
};
// 計測結果を書き込むところ 
var rec={};

// 繰り返し実行時間計測 
var loop=Array(10000000);
for(var k in funx){
    var f=funx[k];
    var a=0;
    var bgn=new Date;
    for(var i of loop)a+=f();
    var end=new Date;
    rec[k]=end-bgn;
}

// インスタンス生成時間を抽出表示 
console.log(rec.new_get-rec.get);
console.log(rec.new_call-rec.call);
Node.js Deno
get 1857.6 1949.5
call 1848.6 1949.3

OMG.
getPrototypeOf() さらに重かった。
しかもこれ、 console.log(t) でTestClassCのぶんしか出ないですよ。
console.log(t.getA()) はちゃんと1って出てるので、継承は正常っぽい。

実験: 親クラスのメソッド転記方式

では、 getPrototypeOf() にも頼らず、親クラスのメソッドを自前転記作戦。

bench_class_selflink.js
// TestClassA 定義 
function TestClassA(){}
TestClassA.prototype.init=function(){
    this.a=1;
};
TestClassA.prototype.getA=function(){return this.a;};

// TestClassB 定義 
function TestClassB(){}
Object.setPrototypeOf(TestClassB.prototype,TestClassA.prototype);
TestClassB.prototype.init_TestClassA=TestClassA.prototype.init;
TestClassB.prototype.init=function(){
    this.init_TestClassA();
    this.b=2;
    this.c=3;
    this.d=4;
    this.e=5;
    this.f=6;
    this.g=7;
    this.h=8;
    this.i=9;
};

// TestClassC 定義 
function TestClassC(){}
Object.setPrototypeOf(TestClassC.prototype,TestClassB.prototype);
TestClassC.prototype.init_TestClassB=TestClassB.prototype.init;
TestClassC.prototype.init=function(){
    this.init_TestClassB();
    this.j=10;
    this.k=11;
    this.l=12;
    this.m=13;
    this.n=14;
    this.o=15;
    this.p=16;
    this.q=17;
    this.r=18;
    this.s=19;
    this.t=20;
    this.u=21;
    this.v=22;
    this.w=23;
    this.x=24;
    this.y=25;
    this.z=26;
};

// 既存インスタンス準備 
var t=new TestClassC();
t.init();
//console.log(t);
//console.log(t.getA());

// 実行内容 
var funx={
    dmy:()=>0, // 最初の実行項目は不利な結果が出るのでダミー 
    get:()=>t.a,
    call:()=>t.getA(),
    new_get:()=>{var u=new TestClassC(); u.init(); u.a},
    new_call:()=>{var u=new TestClassC(); u.init(); u.getA()},
};
// 計測結果を書き込むところ 
var rec={};

// 繰り返し実行時間計測 
var loop=Array(10000000);
for(var k in funx){
    var f=funx[k];
    var a=0;
    var bgn=new Date;
    for(var i of loop)a+=f();
    var end=new Date;
    rec[k]=end-bgn;
}

// インスタンス生成時間を抽出表示 
console.log(rec.new_get-rec.get);
console.log(rec.new_call-rec.call);
Node.js Deno
get 5.6 6.9
call 9.5 1.4

Denoで見違える結果になりました。
で、getよりcallの方が速い謎が増えた。
あと、 console.log(t) は一通り出てきてます。

実験: メソッドをアロー演算子で簡略表記方式

prototypeでアロー演算子使ってもうまくいかないんだよねぃ。
というわけで、 getA() の定義を init() 内に移してみた。

bench_class_lambda.js
// TestClassA 定義 
function TestClassA(){}
TestClassA.prototype.init=function(){
    this.a=1;
    this.getA=()=>this.a;
};

// TestClassB 定義 
function TestClassB(){}
Object.setPrototypeOf(TestClassB.prototype,TestClassA.prototype);
TestClassB.prototype.init_TestClassA=TestClassA.prototype.init;
TestClassB.prototype.init=function(){
    this.init_TestClassA();
    this.b=2;
    this.c=3;
    this.d=4;
    this.e=5;
    this.f=6;
    this.g=7;
    this.h=8;
    this.i=9;
};

// TestClassC 定義 
function TestClassC(){}
Object.setPrototypeOf(TestClassC.prototype,TestClassB.prototype);
TestClassC.prototype.init_TestClassB=TestClassB.prototype.init;
TestClassC.prototype.init=function(){
    this.init_TestClassB();
    this.j=10;
    this.k=11;
    this.l=12;
    this.m=13;
    this.n=14;
    this.o=15;
    this.p=16;
    this.q=17;
    this.r=18;
    this.s=19;
    this.t=20;
    this.u=21;
    this.v=22;
    this.w=23;
    this.x=24;
    this.y=25;
    this.z=26;
};

// 既存インスタンス準備 
var t=new TestClassC();
t.init();
//console.log(t);
//console.log(t.getA());

// 実行内容 
var funx={
    dmy:()=>0, // 最初の実行項目は不利な結果が出るのでダミー 
    get:()=>t.a,
    call:()=>t.getA(),
    new_get:()=>{var u=new TestClassC(); u.init(); u.a},
    new_call:()=>{var u=new TestClassC(); u.init(); u.getA()},
};
// 計測結果を書き込むところ 
var rec={};

// 繰り返し実行時間計測 
var loop=Array(10000000);
for(var k in funx){
    var f=funx[k];
    var a=0;
    var bgn=new Date;
    for(var i of loop)a+=f();
    var end=new Date;
    rec[k]=end-bgn;
}

// インスタンス生成時間を抽出表示 
console.log(rec.new_get-rec.get);
console.log(rec.new_call-rec.call);
Node.js Deno
get 10.8 12.9
call 712.6 556.8

init() の度に再定義しているわけだから重いのは必然だし、
console.log(t) で getA まで出てくる代物なのですが、
定義するだけで呼ばないならさほど大きなコストではないらしい。

実験: proto詰め込み方式

console.log(t) を気にしないでいいなら、こんな手法もあり。
プロパティの初期値もprototypeにぶっ込んでしまえば、そのぶんインスタンス生成コストも軽くなるわけで。

bench_class_fullproto.js
// TestClassA 定義 
function TestClassA(){};
TestClassA.prototype.a=1;
TestClassA.prototype.getA=function (){return this.a;}

// TestClassB 定義 
function TestClassB(){}
Object.setPrototypeOf(TestClassB.prototype,TestClassA.prototype);
TestClassB.prototype.b=2;
TestClassB.prototype.c=3;
TestClassB.prototype.d=4;
TestClassB.prototype.e=5;
TestClassB.prototype.f=6;
TestClassB.prototype.g=7;
TestClassB.prototype.h=8;
TestClassB.prototype.i=9;

// TestClassC 定義 
function TestClassC(){}
Object.setPrototypeOf(TestClassC.prototype,TestClassB.prototype);
TestClassC.prototype.j=10;
TestClassC.prototype.k=11;
TestClassC.prototype.l=12;
TestClassC.prototype.m=13;
TestClassC.prototype.n=14;
TestClassC.prototype.o=15;
TestClassC.prototype.p=16;
TestClassC.prototype.q=17;
TestClassC.prototype.r=18;
TestClassC.prototype.s=19;
TestClassC.prototype.t=20;
TestClassC.prototype.u=21;
TestClassC.prototype.v=22;
TestClassC.prototype.w=23;
TestClassC.prototype.x=24;
TestClassC.prototype.y=25;
TestClassC.prototype.z=26;

// 既存インスタンス準備 
var t=new TestClassC();
//console.log(t);
//console.log(t.getA());

// 実行内容 
var funx={
    dmy:()=>0, // 最初の実行項目は不利な結果が出るのでダミー 
    get:()=>t.a,
    call:()=>t.getA(),
    new_get:()=>new TestClassC().a,
    new_call:()=>new TestClassC().getA(),
};
// 計測結果を書き込むところ 
var rec={};

// 繰り返し実行時間計測 
var loop=Array(10000000);
for(var k in funx){
    var f=funx[k];
    var a=0;
    var bgn=new Date;
    for(var i of loop)a+=f();
    var end=new Date;
    rec[k]=end-bgn;
}

// インスタンス生成時間を抽出表示 
console.log(rec.new_get-rec.get);
console.log(rec.new_call-rec.call);
Node.js Deno
get -6 7.2
call 1.5 3.5

Denoの結果がいまいち。
どうもprototypeが重い御様子。
てゆかNode.js、マイナスってなんだよおい。

実験: Object.create() 方式

prototypeが重いなら、 Object.create() ならどうだ。

bench_class_create.js
// TestClassA 定義 
var TestClassA={a:1};
TestClassA.getA=function(){return this.a;};

// TestClassB 定義 
var TestClassB=Object.create(TestClassA);
TestClassB.b=2;
TestClassB.c=3;
TestClassB.d=4;
TestClassB.e=5;
TestClassB.f=6;
TestClassB.g=7;
TestClassB.h=8;
TestClassB.i=9;

// TestClassC 定義 
var TestClassC=Object.create(TestClassB);
TestClassC.j=10;
TestClassC.k=11;
TestClassC.l=12;
TestClassC.m=13;
TestClassC.n=14;
TestClassC.o=15;
TestClassC.p=16;
TestClassC.q=17;
TestClassC.r=18;
TestClassC.s=19;
TestClassC.t=20;
TestClassC.u=21;
TestClassC.v=22;
TestClassC.w=23;
TestClassC.x=24;
TestClassC.y=25;
TestClassC.z=26;

// 既存インスタンス準備 
var t=Object.create(TestClassC);
//console.log(t);
//console.log(t.getA());

// 実行内容 
var funx={
    dmy:()=>0, // 最初の実行項目は不利な結果が出るのでダミー 
    get:()=>t.a,
    call:()=>t.getA(),
    new_get:()=>Object.create(TestClassC).a,
    new_call:()=>Object.create(TestClassC).getA(),
};
// 計測結果を書き込むところ 
var rec={};

// 繰り返し実行時間計測 
var loop=Array(10000000);
for(var k in funx){
    var f=funx[k];
    var a=0;
    var bgn=new Date;
    for(var i of loop)a+=f();
    var end=new Date;
    rec[k]=end-bgn;
}

// インスタンス生成時間を抽出表示 
console.log(rec.new_get-rec.get);
console.log(rec.new_call-rec.call);
Node.js Deno
get -2.8 74
call 2.7 74.9

没。

実験: 空オブジェクトに詰め込み方式

おまけ。
既にprototype以外でfunction定義するとすごい重いって結論出ちゃったからあまり意味なくなっちゃったんだけど…

bench_class_pureobj.js
// TestClassA 定義 
function TestClassA(){
    var t={a:1};
    t.getA=()=>t.a;
    return t;
}

// TestClassB 定義 
function TestClassB(){
    var t=TestClassA();
    t.b=2;
    t.c=3;
    t.d=4;
    t.e=5;
    t.f=6;
    t.g=7;
    t.h=8;
    t.i=9;
    return t;
}

// TestClassC 定義 
function TestClassC(){
    var t=TestClassB();
    t.j=10;
    t.k=11;
    t.l=12;
    t.m=13;
    t.n=14;
    t.o=15;
    t.p=16;
    t.q=17;
    t.r=18;
    t.s=19;
    t.t=20;
    t.u=21;
    t.v=22;
    t.w=23;
    t.x=24;
    t.y=25;
    t.z=26;
    return t;
}

// 既存インスタンス準備 
var t=TestClassC();
//console.log(t);
//console.log(t.getA());

// 実行内容 
var funx={
    dmy:()=>0, // 最初の実行項目は不利な結果が出るのでダミー 
    get:()=>t.a,
    call:()=>t.getA(),
    new_get:()=>TestClassC().a,
    new_call:()=>TestClassC().getA(),
};
// 計測結果を書き込むところ 
var rec={};

// 繰り返し実行時間計測 
var loop=Array(10000000);
for(var k in funx){
    var f=funx[k];
    var a=0;
    var bgn=new Date;
    for(var i of loop)a+=f();
    var end=new Date;
    rec[k]=end-bgn;
}

// インスタンス生成時間を抽出表示 
console.log(rec.new_get-rec.get);
console.log(rec.new_call-rec.call);
Node.js Deno
get 7.4 8.4
call 1173.1 803.3

ということで。
因みに、プロパティ定義で横着して
Object.assign(TestClassA(),{b:2,...})
みたいな書き方すると、さらに悲惨な結果になります。
JavaScriptでのオブジェクト合成って、 何やっても重い ので困ったもんだ。

結論

邪道実装でいい案件なら、proto詰め込み方式を推し進めたいところ。
あと、やはりDenoはパフォーマンス改善まで様子見。

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