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

【動画付き】Next.js の Server Side Rendering (SSR) を理解する。create-react-app と比べてみた。

image.png

Next.jsのサイト、かっこいいですよね ?
クールで、パフォーマンスにも優れていてエンジニアを魅了します。
日本では Nuxt.js が人気のようですが、個人的には Next.js を推しています。

さて、先日 Next.js のチュートリアル を通してサーバサイドレンダリングについて考えさせられる機会がありました。本記事では、そもそもサーバサイドレンダリングのメリットとは?というところから初めて、create-react-app によって実装された SPA と、nextによって実装された SSR ではどのような違いがあるのかを検証してみました。

以下の動画は本記事のサマリーです。
作成したアプリケーションへのリンクも貼っておきます。
右の方がちょっとだけ描画が遅いのがわかりますね。

next react

?(左)next.js で SSR、(右)create-react-app で SPA ?

サーバサイドレンダリング(SSR)とは

main.png

従来の React ベースのアプリケーションの構成を振り返ってみましょう(右図)。この構成の場合、ユーザからのリクエストは、まずはじめに React サーバ(S3 や Netlify)から JavaScript のソースと必要最小限のほとんど空っぽな HTML を返します。それからフロントエンドで HTML 要素をレンダリングする方法をとります。

このようにバックエンド API とフロントエンドの描画を完全に分離する事によって、開発体制を分離した生産性向上や、ユーザに優れた UX を提供できるようになりました。
その一方で、過度なネットワーク通信が発生したり、JavaScript によって生成された Web サイトを検索エンジンのクローラが検知できなくなりました。その結果として、Google の検索項目の上位に自サイトが表示されにくいなどのデメリットも招いてしました。※こちらの記事で紹介されていますが、最近ではあまり問題にならなくなっているようです。

さて、このような問題を解消するためのテクニックがサーバサイドレンダリング(ServerSideRendering)です(左図)。サーバサイドレンダリングは従来フロントエンドで行なっていたレンダリングをバックエンドの Node.js サーバにも移譲しようという考え方です。これにより、モバイル端末がどんなに脆弱でも、ハイパフォーマンスなサーバを使用してレンダリングできます。さらに無駄なネットワーク通信回数も最小限に減らせるでしょう。「バックエンドの Node.js サーバにも」と強調しているのは、フロントエンドでももちろん描画ができる、ということです。初期ページの一部だけはサーバサイドでレンダリングして、残りの要素はフロントエンドからフェッチしてきてレンダリングするといったように用途に応じて使い分けができます。

パフォーマンス

遅いデバイスを使用していると、最初のページのレンダリングに時間がかかり、ユーザ体験が低下します。計算をより強力なサーバーにオフロードすることで、ユーザーが待機する時間を最小限に抑えることができます。
また、サーバーで初期データをプリフェッチしてページを構築すると、サイトを表示するために必要なラウンドトリップの回数が大幅に削減されます。これにより、待ち時間の短縮と帯域幅消費の削減につながります。

fmp.png

SEO 対策

SSR を行なっているサイトは、ページが検索エンジンで簡単にインデックス化されます。クライアント側でルーティング制御を行なっていると、検索エンジンのウェブクロールを遅らせてしまいます。この結果、検索エンジンの上位にリンクを表示することが難しくなります。

Next.js ことはじめ

SSR を理解するために必要最小限の構成で Next.js アプリケーションを組み立てていきます。

必要なライブラリとアプリケーションの実行

$ mkdir next.ssr
$ cd next.ssr
$ yarn init -y

Next.js を最小構成で始めるために必要なライブラリは nextreact, react-dom だけです。早速 yarn でインストールしましょう(npm でもよいですよ)

$ yarn add react react-dom next

package.json には以下の npm scripts を記載しておいて開発を楽に進められるようにしておきましょう。

package.json
"scripts": {
  "dev": "next",
  "build": "next build",
  "start": "next start"
}

それぞれのコマンドは以下のように使用します。

  • dev - ローカルでアプリケーションを起動します。
  • build - プロダクション用にアプリケーションをビルドします。
  • start - プロダクション環境でアプリケーションを実行します。

ルーティング

Next.js は非常にシンプルな設計思想でフロント画面が作れるように構成されています。/pages ディレクトリ配下に配置されている js ファイルごとにパスルーティングが行われます。はじめの一歩として /pages/index.jsにファイルを配置して、/という URL で表示できるようにしてみましょう。詳細なドキュメントはこちら

$ mkdir pages
$ touch pages/index.js
pages/index.js
const Index = () => {
  return <h1>Hello World</h1>;
};
export default Index;

ファイルパスと URL パスには以下のような対応関係があります。

ファイルパス URL パス
pages/index.js /
pages/blog/index.js /blog
pages/blog/first-post.js /blog/first-post
pages/dashboard/settings/username.js /dashboard/settings/username
pages/blog/[slug].js /blog/:slug (/blog/hello-world)
pages/[username]/settings.js /:username/settings (/foo/settings)
pages/post/[...all].js /post/* (/post/2020/id/title)

さて、ここまでできれば準備完了です。アプリケーションを起動してみましょう。

$ yarn dev

ブラウザを起動し、/ にアクセスすると画面が表示されるはずです。

image.png

サーバサイドレンダリングの実装

ここから SSR ができるような機能を作っていきましょう。

<Link> コンポートを使用して、他ページに遷移します。以下の例だと /shows/[id] へ遷移させようとしています。また、Next.js には、ページのデータを取得するための標準 API が付属しています。 getInitialProps という非同期関数を使用して実行します。
getInitialProps を使用すると、特定のページのデータをフェッチしてページに渡すことができます。 getInitialProps はサーバーとクライアントの両方で動作します。
この getInitialProps の振る舞いを観測し、SSR を理解していきましょう。

index.js
import Link from "next/link";
import fetch from "isomorphic-unfetch";

const Index = props => (
  <div>
    <h1>Batman TV Shows</h1>
    <ul>
      {props.shows.map(show => (
        <li key={show.id}>
          <Link href="/shows/[id]" as={`/shows/${show.id}`}>
            <a>{show.name}</a>
          </Link>
        </li>
      ))}
    </ul>
  </div>
);

Index.getInitialProps = async function() {
  const res = await fetch("https://api.tvmaze.com/search/shows?q=batman");
  const data = await res.json();
  console.log(`Show data fetched. Count: ${data.length}`);
  return { shows: data.map(entry => entry.show) };
};

export default Index;

pages 配下に /shows/[id].js を配置し、Dynamic Routing ができるようにしておきます。

pages/[id].js
import fetch from "isomorphic-unfetch";

const Post = props => (
  <div>
    <h1>{props.show.name}</h1>
    <p>{props.show.summary.replace(/<[/]?[pb]>/g, "")}</p>
    {props.show.image ? <img src={props.show.image.medium} /> : null}
  </div>
);

Post.getInitialProps = async function(context) {
  const { id } = context.query;
  const res = await fetch(`https://api.tvmaze.com/shows/${id}`);
  const show = await res.json();
  console.log(`Fetched show: ${show.name}`);
  return { show };
};

export default Post;

/ を表示してみましょう。サーバサイドにログ Show data fetched: 10 が表示されるはずです。 index.js をサーバサイドでレンダリングしたという事になりますね。
次にリンクをクリックして /shows/975 に遷移するとブラウザのコンソールにログが表示されてます。これはフロントエンドでデータフェッチとレンダリングが行われたということを意味しています。

ssr.gif

デプロイ

最後にビルドして、デプロイします。ZEIT の now にデプロイします。素晴らしい DX(DeveloperExperimence)です。本当に必要な要素以外全て削ぎ落とした、最高の PaaS だと思ってます。いつも愛用しています。こちらの記事にて丁寧に解説されていました。

bash
$ yarn build # ビルド
$ now        # デプロイ

デプロイしたら動作を確認してパフォーマンスを検証しましょう。Chrome の開発者コンソールを開き、Audit を実行します。

https://batman-tv-shows.geeawa.now.sh/

First Meaningful Paint が 1.0s とでました。まずまずです。

image.png

create-react-app との比較

ここまでできたので Next.js で作成されたアプリケーションと create-react-app で作成されたアプリケーションを比較してみましょう。

以下のようにほぼ同様のソースを使用して、create-react-app アプリケーションを作成します。以下にデプロイしてあります。

https://badman-tv-shows-create-react-app.now.sh/

index.js
import React from "react";
import fetch from "isomorphic-unfetch";

class Index extends React.Component {
  constructor(props) {
    super(props);
    this.state = { shows: [] };
  }
  async componentDidMount() {
    const res = await fetch("https://api.tvmaze.com/search/shows?q=batman");
    const data = await res.json();

    console.log(`Show data fetched. Count: ${data.length}`);

    this.setState({ shows: data.map(entry => entry.show) });
  }

  render() {
    return (
      <div>
        <h1>Batman TV Shows</h1>
        <ul>
          {this.state.shows.map(show => (
            <li key={show.id}>
              <a href="">{show.name}</a>
            </li>
          ))}
        </ul>
      </div>
    );
  }
}

export default Index;

デプロイができたので Audit を実行します。
First Meaningful Paint は 1.4s となり、Next.js によって SSR できるようになったサイトと比較すると少しだけ遅い結果がでました。

image.png

さいごに

今回作成されたアプリケーションは非常にシンプルで、1つの API しか実行しませんし、レンダリングする DOM 要素も少なかったためパフォーマンスにそれほど大きな違いはみられませんでした。それでもアプリケーションが肥大したり、ネットワークの遅い環境、古くて脆弱なモバイルデバイスを使用するとパフォーマンスの違いは顕著になってくるでしょう。SSR の技術は適材適所を見極めて投下していきたいですね。

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

【比較検証】Next.js の Server Side Rendering (SSR) を理解する。create-react-app と比べてみた。

image.png

Next.jsのサイト、かっこいいですよね ?
クールで、パフォーマンスにも優れていてエンジニアを魅了します。
日本では Nuxt.js が人気のようですが、個人的には Next.js を推しています。

さて、先日 Next.js のチュートリアル を通してサーバサイドレンダリングについて考えさせられる機会がありました。本記事では、そもそもサーバサイドレンダリングのメリットとは?というところから初めて、create-react-app によって実装された SPA と、nextによって実装された SSR ではどのような違いがあるのかを検証してみました。

以下の動画は本記事のサマリーです。
作成したアプリケーションへのリンクも貼っておきます。
右の方がちょっとだけ描画が遅いのがわかりますね。

next react

?(左)next.js で SSR、(右)create-react-app で SPA ?

サーバサイドレンダリング(SSR)とは

main.png

従来の React ベースのアプリケーションの構成を振り返ってみましょう(右図)。この構成の場合、ユーザからのリクエストは、まずはじめに React サーバ(S3 や Netlify)から JavaScript のソースと必要最小限のほとんど空っぽな HTML を返します。それからフロントエンドで HTML 要素をレンダリングする方法をとります。

このようにバックエンド API とフロントエンドの描画を完全に分離する事によって、開発体制を分離した生産性向上や、ユーザに優れた UX を提供できるようになりました。
その一方で、過度なネットワーク通信が発生したり、JavaScript によって生成された Web サイトを検索エンジンのクローラが検知できなくなりました。その結果として、Google の検索項目の上位に自サイトが表示されにくいなどのデメリットも招いてしました。※こちらの記事で紹介されていますが、最近ではあまり問題にならなくなっているようです。

さて、このような問題を解消するためのテクニックがサーバサイドレンダリング(ServerSideRendering)です(左図)。サーバサイドレンダリングは従来フロントエンドで行なっていたレンダリングをバックエンドの Node.js サーバにも移譲しようという考え方です。これにより、モバイル端末がどんなに脆弱でも、ハイパフォーマンスなサーバを使用してレンダリングできます。さらに無駄なネットワーク通信回数も最小限に減らせるでしょう。「バックエンドの Node.js サーバにも」と強調しているのは、フロントエンドでももちろん描画ができる、ということです。初期ページの一部だけはサーバサイドでレンダリングして、残りの要素はフロントエンドからフェッチしてきてレンダリングするといったように用途に応じて使い分けができます。

パフォーマンス

遅いデバイスを使用していると、最初のページのレンダリングに時間がかかり、ユーザ体験が低下します。計算をより強力なサーバーにオフロードすることで、ユーザーが待機する時間を最小限に抑えることができます。
また、サーバーで初期データをプリフェッチしてページを構築すると、サイトを表示するために必要なラウンドトリップの回数が大幅に削減されます。これにより、待ち時間の短縮と帯域幅消費の削減につながります。

fmp.png

SEO 対策

SSR を行なっているサイトは、ページが検索エンジンで簡単にインデックス化されます。クライアント側でルーティング制御を行なっていると、検索エンジンのウェブクロールを遅らせてしまいます。この結果、検索エンジンの上位にリンクを表示することが難しくなります。

Next.js ことはじめ

SSR を理解するために必要最小限の構成で Next.js アプリケーションを組み立てていきます。

必要なライブラリとアプリケーションの実行

$ mkdir next.ssr
$ cd next.ssr
$ yarn init -y

Next.js を最小構成で始めるために必要なライブラリは nextreact, react-dom だけです。早速 yarn でインストールしましょう(npm でもよいですよ)

$ yarn add react react-dom next

package.json には以下の npm scripts を記載しておいて開発を楽に進められるようにしておきましょう。

package.json
"scripts": {
  "dev": "next",
  "build": "next build",
  "start": "next start"
}

それぞれのコマンドは以下のように使用します。

  • dev - ローカルでアプリケーションを起動します。
  • build - プロダクション用にアプリケーションをビルドします。
  • start - プロダクション環境でアプリケーションを実行します。

ルーティング

Next.js は非常にシンプルな設計思想でフロント画面が作れるように構成されています。/pages ディレクトリ配下に配置されている js ファイルごとにパスルーティングが行われます。はじめの一歩として /pages/index.jsにファイルを配置して、/という URL で表示できるようにしてみましょう。詳細なドキュメントはこちら

$ mkdir pages
$ touch pages/index.js
pages/index.js
const Index = () => {
  return <h1>Hello World</h1>;
};
export default Index;

ファイルパスと URL パスには以下のような対応関係があります。

ファイルパス URL パス
pages/index.js /
pages/blog/index.js /blog
pages/blog/first-post.js /blog/first-post
pages/dashboard/settings/username.js /dashboard/settings/username
pages/blog/[slug].js /blog/:slug (/blog/hello-world)
pages/[username]/settings.js /:username/settings (/foo/settings)
pages/post/[...all].js /post/* (/post/2020/id/title)

さて、ここまでできれば準備完了です。アプリケーションを起動してみましょう。

$ yarn dev

ブラウザを起動し、/ にアクセスすると画面が表示されるはずです。

image.png

サーバサイドレンダリングの実装

ここから SSR ができるような機能を作っていきましょう。

<Link> コンポートを使用して、他ページに遷移します。以下の例だと /shows/[id] へ遷移させようとしています。また、Next.js には、ページのデータを取得するための標準 API が付属しています。 getInitialProps という非同期関数を使用して実行します。
getInitialProps を使用すると、特定のページのデータをフェッチしてページに渡すことができます。 getInitialProps はサーバーとクライアントの両方で動作します。
この getInitialProps の振る舞いを観測し、SSR を理解していきましょう。

index.js
import Link from "next/link";
import fetch from "isomorphic-unfetch";

const Index = props => (
  <div>
    <h1>Batman TV Shows</h1>
    <ul>
      {props.shows.map(show => (
        <li key={show.id}>
          <Link href="/shows/[id]" as={`/shows/${show.id}`}>
            <a>{show.name}</a>
          </Link>
        </li>
      ))}
    </ul>
  </div>
);

Index.getInitialProps = async function() {
  const res = await fetch("https://api.tvmaze.com/search/shows?q=batman");
  const data = await res.json();
  console.log(`Show data fetched. Count: ${data.length}`);
  return { shows: data.map(entry => entry.show) };
};

export default Index;

pages 配下に /shows/[id].js を配置し、Dynamic Routing ができるようにしておきます。

pages/[id].js
import fetch from "isomorphic-unfetch";

const Post = props => (
  <div>
    <h1>{props.show.name}</h1>
    <p>{props.show.summary.replace(/<[/]?[pb]>/g, "")}</p>
    {props.show.image ? <img src={props.show.image.medium} /> : null}
  </div>
);

Post.getInitialProps = async function(context) {
  const { id } = context.query;
  const res = await fetch(`https://api.tvmaze.com/shows/${id}`);
  const show = await res.json();
  console.log(`Fetched show: ${show.name}`);
  return { show };
};

export default Post;

/ を表示してみましょう。サーバサイドにログ Show data fetched: 10 が表示されるはずです。 index.js をサーバサイドでレンダリングしたという事になりますね。
次にリンクをクリックして /shows/975 に遷移するとブラウザのコンソールにログが表示されてます。これはフロントエンドでデータフェッチとレンダリングが行われたということを意味しています。

ssr.gif

デプロイ

最後にビルドして、デプロイします。ZEIT の now にデプロイします。素晴らしい DX(DeveloperExperimence)です。本当に必要な要素以外全て削ぎ落とした、最高の PaaS だと思ってます。いつも愛用しています。こちらの記事にて丁寧に解説されていました。

bash
$ yarn build # ビルド
$ now        # デプロイ

デプロイしたら動作を確認してパフォーマンスを検証しましょう。Chrome の開発者コンソールを開き、Audit を実行します。

https://batman-tv-shows.geeawa.now.sh/

First Meaningful Paint が 1.0s とでました。まずまずです。

image.png

create-react-app との比較

ここまでできたので Next.js で作成されたアプリケーションと create-react-app で作成されたアプリケーションを比較してみましょう。

以下のようにほぼ同様のソースを使用して、create-react-app アプリケーションを作成します。以下にデプロイしてあります。

https://badman-tv-shows-create-react-app.now.sh/

index.js
import React from "react";
import fetch from "isomorphic-unfetch";

class Index extends React.Component {
  constructor(props) {
    super(props);
    this.state = { shows: [] };
  }
  async componentDidMount() {
    const res = await fetch("https://api.tvmaze.com/search/shows?q=batman");
    const data = await res.json();

    console.log(`Show data fetched. Count: ${data.length}`);

    this.setState({ shows: data.map(entry => entry.show) });
  }

  render() {
    return (
      <div>
        <h1>Batman TV Shows</h1>
        <ul>
          {this.state.shows.map(show => (
            <li key={show.id}>
              <a href="">{show.name}</a>
            </li>
          ))}
        </ul>
      </div>
    );
  }
}

export default Index;

デプロイができたので Audit を実行します。
First Meaningful Paint は 1.4s となり、Next.js によって SSR できるようになったサイトと比較すると少しだけ遅い結果がでました。

image.png

さいごに

今回作成されたアプリケーションは非常にシンプルで、1つの API しか実行しませんし、レンダリングする DOM 要素も少なかったためパフォーマンスにそれほど大きな違いはみられませんでした。それでもアプリケーションが肥大したり、ネットワークの遅い環境、古くて脆弱なモバイルデバイスを使用するとパフォーマンスの違いは顕著になってくるでしょう。SSR の技術は適材適所を見極めて投下していきたいですね。

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

node.js の --max-old-space-size のデフォルト値は 1400MB

node.js の V8 のヒープのメモリ容量を設定するオプション --max-old-space-size のデフォルト値は 1.4GB みたい。

https://github.com/nodejs/node/blob/master/deps/v8/src/heap/heap.cc

    max_old_generation_size_ = 700ul * (kSystemPointerSize / 4) * MB;

64bit OS だと kSystemPointerSize は 8 だろうから、1400MB になりそう。
実際の値をみると、--max-old-space-size=1400 の結果とマッチした。

node --max-old-space-size=1000 -e 'console.log(Math.floor(v8.getHeapStatistics().heap_size_limit/1024/1024))'
# 1049

node --max-old-space-size=1400 -e 'console.log(Math.floor(v8.getHeapStatistics().heap_size_limit/1024/1024))'
# => 1456

node -e 'console.log(Math.floor(v8.getHeapStatistics().heap_size_limit/1024/1024))'
# => 1456

node --max-old-space-size=2000 -e 'console.log(Math.floor(v8.getHeapStatistics().heap_size_limit/1024/1024))'
# => 2066

node --max-old-space-size=3000 -e 'console.log(Math.floor(v8.getHeapStatistics().heap_size_limit/1024/1024))'
# => 3083

まとめ:
--max-old-space-size=1000 にすると、デフォルトより3割くらい減る。
--max-old-space-size=2000 にすると、デフォルトより4割くらい増える。
--max-old-space-size=3000 にすると、デフォルトより2倍ちょっと増える。

他の記事で --max-old-space-size=2000 をよくみるのは、増やしすぎない按配なのか。

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

GraphQLでファイルアップロード

GraphQLでファイルアップロード

GraphQL(Appolo Server)でファイルをアップロードする。
サーバ側はnode.jsを使う。
クライアントはAltair GraphQL Clientと使う。

サンプルのソースコードは以下。

事前準備

Get started with Apollo Server - Apollo Server - Apollo GraphQL Docsに従う。

$ mkdir graphql-server-example ;\
  cd graphql-server-example
$ npm init --yes
$ npm install apollo-server graphql

サーバ

File uploads - Apollo Server - Apollo GraphQL Docsをほぼそのまま使う。
若干の変更として、アップロードが成功したときにメッセージを表示するように変更。

file-uploads/index.js
const { ApolloServer, gql } = require('apollo-server');

const typeDefs = gql`
  type File {
    filename: String!
    mimetype: String!
    encoding: String!
  }

  type Query {
    uploads: [File]
  }

  type Mutation {
    singleUpload(file: Upload!): File!
  }
`;

const resolvers = {
  Query: {
    uploads: (parent, args) => {},
  },
  Mutation: {
    singleUpload: (parent, args) => {
      return args.file.then(file => {
        console.log(`? File get ${file.filename}`);
        return file;
      });
    },
  },
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

server.listen().then(({ url }) => {
  console.log(`? Server ready at ${url}`);
});

サーバを起動する。

$ node file-uploads/index.js
? Server ready at http://localhost:4000/

クライアント

アップロードするファイルの準備。

echo "FOO" > file-uploads/foo.txt

Altair GraphQL Clientでファイルをアップロードする。

altair.gif

それぞれ以下を入力しSend Requestを実行。

  • URL
http://localhost:4000
  • graphQL query
mutation($file: Upload!){
  singleUpload(file: $file){
    filename
    mimetype
    encoding
  }
}
  • Choose file
foo.txt
  • File name
file

成功すればサーバに以下のログが出力される。

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

Bot開発(Node.js)のDBアクセスライブラリは knex がオススメ!

Bot開発でNode.jsを使うことが多く、DBアクセスがある要件で pg などで素のクエリを書いていて辛いなーと感じている時に、 knex に出会ったので紹介します。

公式ドキュメント http://knexjs.org/
GitHub https://github.com/knex/knex

使い方

インストール

$ npm install --save knex pg

knex初期設定

$ knex init

すると、以下のファイルが自動生成されます。

knexfile.js
// Update with your config settings.

module.exports = {

  development: {
    client: 'postgresql',
    connection: {
      database: 'linebot-dev',
      user:     'zyyx-kubo',
      password: ''
    },
    pool: {
      min: 2,
      max: 10
    },
    migrations: {
      directory:'./db/migrations',
      tableName: 'knex_migrations'
    }
  },

  staging: {
    client: 'postgresql',
    connection: {
      database: 'my_db',
      user:     'username',
      password: 'password'
    },
    pool: {
      min: 2,
      max: 10
    },
    migrations: {
      directory:'./db/migrations',
      tableName: 'knex_migrations'
    }
  },

  production: {
    client: 'postgresql',
    connection: {
      database: 'my_db',
      user:     'username',
      password: 'password'
    },
    pool: {
      min: 2,
      max: 10
    },
    migrations: {
      directory:'./db/migrations',
      tableName: 'knex_migrations'
    }
  }

};

マイグレーションファイルの作成

$ knex migrate:make create_user
Using environment: development
Created Migration: ./db/migrations/20190214205707_create_user.js

マイグレーション

実行

$ knex migrate:latest

ロールバック

$ knex migrate:rollback

シード

ファイル作成

$ knex seed:make test_users
Using environment: development
Created seed file: ./db/seeds/test_users.js

実行

$ knex seed:run

Herokuでの実行

$ heroku run knex migrate:latest --app app-name
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【WebAudioAPI】録音した音声をバイナリデータ化、PHPへ受け渡し

概要

Node.js上で、IBMのWatsonによって人が話した音声データを自動で文字起こしするスクリプトを作成しました。
その中で、結構苦労した
PCのマイクに直接アクセス→録音した音声データをバイナリデータ化、PHPへ受け渡し
の部分をメモがてら貼り付け。

環境

$php -v
PHP 7.1.23 (cli) (built: Feb 22 2019 22:19:32) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.1.0, Copyright (c) 1998-2018 Zend Technologies

録音部分

hogehoge.js
// 音声データのバッファをクリアする
    audioData = [];

     //様々なブラウザでマイクへのアクセス権を取得する
    navigator.mediaDevices = navigator.mediaDevices || navigator.webkitGetUserMedia;

    //audioのみtrue。Web Audioが問題なく使えるのであれば、第二引数で指定した関数を実行
    navigator.getUserMedia({
        audio: true,
        video: false
    }, successFunc, errorFunc);

    function successFunc(stream) {
        const audioContext = new AudioContext();
        sampleRate = audioContext.sampleRate;

        // ストリームを合成するNodeを作成
        const mediaStreamDestination = audioContext.createMediaStreamDestination();

        // マイクのstreamをMediaStreamNodeに入力
        const audioSource = audioContext.createMediaStreamSource(stream);
        audioSource.connect(mediaStreamDestination);

        // 接続先のstreamをMediaStreamに入力
        for(let stream of remoteAudioStream){
            try{
                audioContext.createMediaStreamSource(stream).connect(mediaStreamDestination);
            } catch(e){
                console.log(e);
            }
        }

        // マイクと接続先を合成したMediaStreamを取得
        const composedMediaStream = mediaStreamDestination.stream;
        // マイクと接続先を合成したMediaStreamSourceNodeを取得
        const composedAudioSource = audioContext.createMediaStreamSource(composedMediaStream);

        // 音声のサンプリングをするNodeを作成
        const audioProcessor = audioContext.createScriptProcessor(1024, 1, 1);
        // マイクと接続先を合成した音声をサンプリング
        composedAudioSource.connect(audioProcessor);

        audioProcessor.addEventListener('audioprocess', event => {
            audioData.push(event.inputBuffer.getChannelData(0).slice());
        });

        audioProcessor.connect(audioContext.destination);
    }

録音した音声をバイナリデータ化

hogehoge.js
//音声をエクスポートした後のwavデータ格納用配列
    const waveArrayBuffer = [];
    //仕様の関係で、大きなデータを分けたうちの1つのデータ容量が25MB以下になるよう制御
    if (audioData.length > 250){
        const num = audioData.length/250;
        const count = Math.round(num);

        for (let i=0; i < count; i++){
            const sliceAudioData = audioData.slice(0,249);
            audioData.pop(0,249);
            const waveData = exportWave(sliceAudioData);
            waveArrayBuffer.push(waveData);
        }   
    }else{
        waveArrayBuffer.push(exportWave(audioData));
    }
   //PHPへPOST
    var oReq = new XMLHttpRequest();
    oReq.open("POST", '任意のパス', true);
    oReq.onload = function (oEvent) {
    // Uploaded.
    };

    //複数のデータをblob化するための配列
    const blob = [];
    //waveArrayBufferに入っている複数のデータを1つずつ配列に格納
    waveArrayBuffer.forEach(function(waveBuffer){
        blob.push(new Blob([waveBuffer], {type:'audio/wav'}));
    })

    var fd = new FormData();
    for (let i=0; i < blob.length; i++){
        fd.append('blob'+i,blob[i]);
    }
    // oReq.setRequestHeader('Content-Type','multipart/form-data; name="blob" boundary=\r\n');
    //配列ごとリクエスト送信
    oReq.send(fd);

    function exportWave(audioData) {
    // Float32Arrayの配列になっているので平坦化
    const audioWaveData = flattenFloat32Array(audioData);
    // WAVEファイルのバイナリ作成用のArrayBufferを用意
    const buffer = new ArrayBuffer(44 + audioWaveData.length * 2);

    // ヘッダと波形データを書き込みWAVEフォーマットのバイナリを作成
    const dataView = writeWavHeaderAndData(new DataView(buffer), audioWaveData, sampleRate);

    return buffer;
    }

    // Float32Arrayを平坦化する
    function flattenFloat32Array(matrix) {
        const arraySize = matrix.reduce((acc, arr) => acc + arr.length, 0);
        let resultArray = new Float32Array(arraySize);
        let count = 0;
        for(let i = 0; i < matrix.length; i++) {
            for(let j = 0; j < matrix[i].length; j++) {
            resultArray[count] = audioData[i][j];
            count++;
            }
        }
        return resultArray;
    }
    // ArrayBufferにstringをoffsetの位置から書き込む
    function writeStringForArrayBuffer(view, offset, str) {
        for(let i = 0; i < str.length; i++) {
            view.setUint8(offset + i, str.charCodeAt(i));
        }
    }

    // 波形データをDataViewを通して書き込む
    function floatTo16BitPCM(view, offset, audioWaveData) {
        for (let i = 0; i < audioWaveData.length; i++ , offset += 2) {
            let s = Math.max(-1, Math.min(1, audioWaveData[i]));
            view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
        }
    }

    // モノラルのWAVEヘッダを書き込む
    function writeWavHeaderAndData(view, audioWaveData, samplingRate) {
        // WAVEのヘッダを書き込み(詳しくはWAVEファイルのデータ構造を参照)
        writeStringForArrayBuffer(view, 0, 'RIFF'); // RIFF識別子
        view.setUint32(4, 36 + audioWaveData.length * 2, true); // チャンクサイズ(これ以降のファイルサイズ)
        writeStringForArrayBuffer(view, 8, 'WAVE'); // フォーマット
        writeStringForArrayBuffer(view, 12, 'fmt '); // fmt識別子
        view.setUint32(16, 16, true); // fmtチャンクのバイト数(第三引数trueはリトルエンディアン)
        view.setUint16(20, 1, true); // 音声フォーマット。1はリニアPCM
        view.setUint16(22, 1, true); // チャンネル数。1はモノラル。
        view.setUint32(24, samplingRate, true); // サンプリングレート
        view.setUint32(28, samplingRate * 2, true); // 1秒あたりのバイト数平均(サンプリングレート * ブロックサイズ)
        view.setUint16(32, 2, true); // ブロックサイズ。チャンネル数 * 1サンプルあたりのビット数 / 8で求める。モノラル16bitなら2。
        view.setUint16(34, 16, true); // 1サンプルに必要なビット数。16bitなら16。
        writeStringForArrayBuffer(view, 36, 'data'); // サブチャンク識別子
        view.setUint32(40, audioWaveData.length * 2, true); // 波形データのバイト数(波形データ1点につき16bitなのでデータの数 * 2となっている)

        // WAVEのデータを書き込み
        floatTo16BitPCM(view, 44, audioWaveData); // 波形データ

        return view;
    }

リクエスト受け取り部分(超絶一部抜粋)

hogehoge.php
//リクエスト受け取り
$req = $_FILES
var_dump($req);

//出力結果
array(2) {
  ["blob0"]=>
  array(5) {
    ["name"]=>
    string(4) "blob"
    ["type"]=>
    string(9) "audio/wav"
    ["tmp_name"]=>
    string(14) "/tmp/ランダム文字列"
    ["error"]=>
    int(0)
    ["size"]=>
    int(509996)
  }

おわりに

ご指摘等ありましたら宜しくお願い致します!

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

gRPC の使い方 (Node.js)

参考ページ
Node Quick Start

必要なライブラリーのインストール

sudo npm install grpc
sudo npm install @grpc/proto-loader

設定ファイル、サーバープログラム、クライアントプログラムの3つが必要です。

設定ファイル
helloworld.proto こちらと同じ
gRPC の使い方 (python)

サーバープログラム

greeter_server.js
var PROTO_PATH = 'helloworld.proto';

var grpc = require('grpc');
var protoLoader = require('@grpc/proto-loader');
var packageDefinition = protoLoader.loadSync(
    PROTO_PATH,
    {keepCase: true,
     longs: String,
     enums: String,
     defaults: true,
     oneofs: true
    });
var hello_proto = grpc.loadPackageDefinition(packageDefinition).helloworld;

function sayHello(call, callback) {
  console.error("*** sayHello ***")
  const str_out = 'Test Hello ' + call.request.name
  console.error(str_out)
  callback(null, {message: str_out});
}

function sayHello2(call, callback) {
  console.error("*** sayHello2 ***")
  const str_out = 'Test2 Hello again ' + call.request.name
  console.error(str_out)
  callback(null, {message: str_out});
}


function main() {
  var server = new grpc.Server();
  server.addService(hello_proto.Greeter.service, {sayHello: sayHello,
        sayHello2: sayHello2});
  server.bind('0.0.0.0:50051', grpc.ServerCredentials.createInsecure());
  server.start();
}

main();

クライアントプログラム

greeter_client.js
var PROTO_PATH = 'helloworld.proto';

var grpc = require('grpc');
var protoLoader = require('@grpc/proto-loader');
var packageDefinition = protoLoader.loadSync(
    PROTO_PATH,
    {keepCase: true,
     longs: String,
     enums: String,
     defaults: true,
     oneofs: true
    });
var hello_proto = grpc.loadPackageDefinition(packageDefinition).helloworld;

function main() {
  var client = new hello_proto.Greeter('localhost:50051',
                                       grpc.credentials.createInsecure());
  var user;
  user = 'John';

  client.sayHello({name: user}, function(err, response) {
    console.log('Greeting:', response.message);
  });

  user = 'Tom';
  client.sayHello2({name: user}, function(err, response) {
    console.log('Greeting:', response.message);
  });


}

main();

サーバープログラムの起動

export NODE_PATH=/usr/lib/node_modules
node greeter_server.js

クライアントプログラムの実行

$ export NODE_PATH=/usr/lib/node_modules
$ node greeter_client.js
Greeting: Test2 Hello again Tom
Greeting: Test Hello John

サーバーのコンソールには次のようなメッセージが出ます。

$ export NODE_PATH=/usr/lib/node_modules
$ node greeter_server.js
*** sayHello2 ***
Test2 Hello again Tom
*** sayHello ***
Test Hello John
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

VSCodeを使って素朴に素朴にNode + TypeScript

学習用に。

必要なもの

事前に準備するもの

  • Node.js
  • VSCode

途中でインストールするもの

  • typescript

手順

1. npm init

いろいろ聞かれますが全部Enterで返事しておきます。

2. npm install typescript --save-dev でTypeScriptのインストール

3. npx typescript --inittsconfig.jsonを生成

4. 生成されたtsconfig.jsonをいじる

  • "sourceMap: true" の行は使いたいのでコメントアウト解除
  • "outDir": "./" の行はコメントアウト解除して"outDir": "./dist" とでもしておきましょう。
  • "rootDir": "./src"などとすることで、ソースファイルのルートディレクトリを設定できます。

あとはそのままでいいんじゃないかなと思います。必要になったら変更で。

5. VSCode上でビルドの設定をする

  1. エディタ上でコマンドパレットを開き(F1)、task configure taskと入力 => 「テンプレートからtasks.jsonを生成」でEnter => 「Others」を選びます。要は./.vscode/tasks.jsonができればいいです。
  2. 下記を参考にビルドタスクを追加します。labelはただの識別名なので適当で。
./.vscode/tasks.json
{
// tasks.json 形式の詳細についての資料は、
    // https://go.microsoft.com/fwlink/?LinkId=733558 をご覧ください
    "version": "2.0.0",
    "tasks": [
        {
            "label": "typescript build",
            "type": "shell",
            "command": "node ./node_modules/typescript/bin/tsc",
            "group": "build"
        }
    ]
}

6. VSCode上でデバッグの設定をする

  1. エディタ左側のメニューから虫アイコンをクリックし、launch.jsonファイルを作成する
  2. デバッグの種類を聞かれたらNode.jsを選択
  3. 生成されたlaunch.jsonをいじる
    "program": "${workspaceFolder}\\dist\\index.js" のように変更して、実行するJSファイルのパスを変更。
    typescriptにより生成されたファイルを実行したいので、distフォルダ内にある(これから作られるのでまだない)index.jsを指定。
    デバッグする前にビルドしたいので、"preLaunchTask": "先ほど設定したタスクの適当な名前"を追記。
./.vscode/launch.json
{
    // IntelliSense を使用して利用可能な属性を学べます。
    // 既存の属性の説明をホバーして表示します。
    // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "プログラムの起動",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "program": "${workspaceFolder}\\dist\\index.js",
            "preLaunchTask": "typescript build"
        }
    ]
}

ここまでで環境構築自体は終わりです。お疲れ様でした。参考までにHelloWorldします。

7. ./src/index.tsを作成

./src/index.ts
const hw: string = 'hello world';
console.log(hw);

あとはF5キーを押すことで、ビルド => 完了次第デバッグ開始されます。ブレークポイント置けば止まるし、普通にデバッグできます。

実体としてはかなり素朴な内容で、tscでビルドして、終わったらVSCode + Nodeのデバッガでデバッグするというものです。

VSCodeで「デバッグ前のタスク」を実行できるので、これを利用しています。

背景

Vue + TypeScriptをやったことがあるのですが、TypeScriptの設定はvue-cliに任せきりだったので、
Node.js + TypeScriptをシンプルに試してみたかったのでした。

参考

TypeScript + Node.js プロジェクトのはじめかた2019 - Qiita

筆者の環境

  • Windows 10
  • VSCode 1.14.1
  • Node.js v8.12.0
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

node.jsのライブラリを作成したが、import構文でエラーが出る場合の対処法

ライブラリのpackage.jsonに

"type":

が存在しない場合、そのライブラリは、commonjsとして扱われ、importを使用するとエラーを吐く。

そのため、node.jsでライブラリを作成するときは、

"type": "module"

をライブラリのpackage.jsonに記述する必要がある。
この記述によりライブラリ内のコードは、es moduleとして扱われ importを使用可能になる。

参考
Node.js v13.7.0 Documentation

尚、Typeフィールドの値に関わらず.mjsファイルは、ES modulesとして扱われ、.cjsファイルはcommonjsとして扱われる。

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

画像に対して顔検出を行いマスクする(opencv/opencv4nodejs/Node.js)

動機

インカメラで人物や顔写真入り証明書を撮影したエビデンス画像に対して、顔部分のマスクを行うツールを作成したかった。

言語選定

画像処理ライブラリ opencv の対応言語は C/C++ Java Python です。
今回の動機であるツール的に用いるならば、Pythonが適していると思います。サンプルコードもたくさんあります。しかし、自分がPythonに対する知識が少なく、時間がかかりそうで逡巡していました。
しかし、opencv を Node.js 環境から使えるライブラリopencv4nodejsを見つけたので、試してみることにしました。

落とし穴

opencv4nodejsはopencvの型やメソッドと全く同じ名称ではなく、JavaScriptの言語仕様に合わせて引数なども異なります。→ Contribution Guide
従って、Pythonのコードをそのまま置き換えればOK!といった形では実装できません。
自分はここを安易に考えていて若干ハマりました…

環境構築

Windows10(64bit)
Nodist(0.9.1) Node.js 11.13.0 上に
opencv4nodejs(5.5.0)をインストール

公式の手順はこちら

事前注意

  • スペースを含むパスにインストールしない → node-gyp でパスが読めない
  • 日本語を含むパスにインストールしない → opencvでパスが読めない

cmakeインストール

opencv4nodejs のインストール時に要求され、これが無いとエラーで進みません。

  1. cmake をインストールする
  2. 実行ファイルがあるフォルダにPATHを通す 例:G:\Program Files\CMake\bin

git

git ロングファイルネーム許可の設定(opencv4nodejs 内でのC++コンパイル用)
git config --system core.longpaths true

npm

  1. node-gypのインストール
    npm install --global node-gyp

  2. windows-build-tools インストール
    npm install --global windows-build-tools
    ※時間かかります

  3. opencv4nodejs のインストール
    npm install opencv4nodejs
    ※とても時間かかります

実装

TypeScriptで書いてます

顔検出

サンプルソースをそのまま使いました。opencv4nodejsではサンプルソースが充実していてます。

import * as cv from 'opencv4nodejs';

export const feceMaskBlur = (imagePath:string) => {

  // 対象画像読込
  const image = cv.imread(imagePath);
  if (!image) {
    throw new Error(`No file ${imagePath}`);
  }

  const classifier = new cv.CascadeClassifier(cv.HAAR_FRONTALFACE_ALT2);
  // detect faces
  const { objects, numDetections } = classifier.detectMultiScale(image.bgrToGray());


  if (!objects.length) {
    throw new Error(`No faces detected!  ${imagePath}`);
  }
  console.log('顔検出した領域:', objects);
  console.log('確度:', numDetections);

  // draw detection
  let blurImage:cv.Mat;
  blurImage = image.copy();
  objects.forEach((rect, i) => {
    // 顔検出した部分に対してマスクを実施
    blurImage = drawBlurRect(blurImage, rect, numDetections[i]);
  });

  // file 保存
  cv.imwrite(imagePath, blurImage);

}

検出した領域は、以下のような座標と大きさで取得できます

顔検出した領域: [ Rect { height: 525, width: 525, y: 1188, x: 1923 },
  Rect { height: 262, width: 262, y: 3214, x: 2298 },
  Rect { height: 584, width: 584, y: 878, x: 2714 } ]
確度: [ 8, 4, 11 ]

画像の一部上書き

上記で取得した領域に対して、マスクをかけます。
python だと、画像のデータは二次元配列として読み込まれるため、座標位置を指定して代入する形で記述できるようなのですが、opencv4nodejsだと形式が異なったため、ちょっと悩みました。

def mosaic_area(src, x, y, width, height, ratio=0.1):
    dst = src.copy()
    dst[y:y + height, x:x + width] = mosaic(dst[y:y + height, x:x + width], ratio)
    return dst

dst_area = mosaic_area(src, 100, 50, 100, 150)
cv2.imwrite('data/dst/opencv_mosaic_area.jpg', dst_area)

Python, OpenCVで画像にモザイク処理(全面、一部、顔など)より引用

issueを検索して見つけました。

// 対象のRectを塗りつぶし
const drawBlurRect = (image: cv.Mat, rect: cv.Rect, numDetection: number):cv.Mat  => {
  // 領域の切り出し
  const srcRoi:cv.Mat = image.getRegion(rect);
  // 切り出した部分にマスクをかける
  const masked = cv.blur(srcRoi, new cv.Size(rect.width,rect.height));
  // 切り出した部分を元画像に合成
  masked.copyTo(image.getRegion(rect));
  return image;
}

実行

この画像に対して実行すると

こうなります

画像取得元

まとめと気づき

画像から何かを識別する場合には識別器を用い、OpenCVにはこの識別に使う学習済みファイルが準備されています。

今回は、Haar Cascade識別器(分類器)のhaarcascade_frontalface_alt2.xml → コード中では cv.HAAR_FRONTALFACE_ALT2 を使いました(サンプルそのまま)。

今回はインカメラを使った正面を向いた写真が基本なので顔検出がマッチしたのですが、横向きの顔は検出されにくい点については把握しておいた方がよさそうです。

参考

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

なぜ僕たちはサーバレスでJavaを諦めTypescriptを採用したか

この記事は個人ブログのうち技術に関する箇所のみを抜粋した転載です。
なぜ僕たちはサーバレスでJavaを諦めTypescriptを採用したか -Junks, GC cannot sweep-

またブログには書いたのですが、諸事情により先に英語版が存在します。
こちらも書いたのは僕なので、剽窃などではないことはご了承ください。

[元記事]: Why we replaced Java with Typescript for Serverless in dev.to

はじめに

サーバレス(serverless)は昨今もっとも注目を集める設計手法の一つで、おそらく多くの開発者が大なり小なり自分のプロダクトに応用し始めているのではないでしょうか?

僕自身、完全にサーバレスに魅せられてしまい、昔ながらの自分でサーバやミドルウェアを管理しながら運用するみたいな世界には戻れる気がしません。

そもそもスケーリングや分散可能性をきちんと考えて設計されたアプリケーションであれば、旧来のサーバーアプリケーションの機能から受けられる恩恵も比較的少なくなりますし、サーバレスに切り替えるデメリットはそこまでありません。
最近は設計に関して相談された時は、必ずサーバレスの話題を出してみることにしています。

さて、サーバレスは既存の開発手法とは大きく異なるため、今持っている知識を刷新し、既存の手法や技術スタックを見直しながら開発していく必要があります。
見直しというからには、開発基盤として何の言語を使うかも、当然ながら見直さなくてはいけない技術スタックの対象になります。

タイトルにある通り、最終的に僕たちはTypeScriptを採用し、およそ一年半開発・メンテナンスを行ってきました。
そして一年半経った今、あくまで個人的な感想ではありますが、TypeScriptは僕たちが期待した以上に成果を出してくれました。

そこでこの記事では、以前使用していた言語にどんな問題があったのか、そしてなぜTypeScriptに切り替えたことでどんな恩恵があったのかをこの記事では解説していきたいと思います。

なぜJavaを諦めなくてはならなかったのか

さて、なぜTypeScriptを採用したかについて話す前に、まずなぜ以前使用していた非常に強力な言語であるJavaを諦めなくてはいけなかったかについてお話ししたいと思います。

先に述べておきますが、僕は結構なJava好きです。なんなら初めて触った言語もJavaでした。
JVMに関してもそれなりに勉強して、その神がかったランタイムの仕組みにかなり感銘を受けています。(てか多分作ったやつは神)
なので、どこかの大学生のようにJavaがクソだとかレガシーだとか使い物にならんとか、この記事でそういうことを言うつもりは一切ありません。
また、そういったコメントもあまり嬉しくないです。あくまでサーバレスという仕組みにJavaがあまり合わなかっただけなので。
その点だけはご了承いただければ幸いです。


なぜJavaを諦めなくてはならなかったのか

さて、なぜTypeScriptを採用したかについて話す前に、まずなぜ以前使用していた非常に強力な言語であるJavaを諦めなくてはいけなかったかについてお話ししたいと思います。

僕たちのサービスでは、サーバサイドはサービス設立当時から基本的にJavaだけで書かれていました。
当然ながらすでにJavaには多くの利点があり、特に

  • プラットフォームフリー
  • よくできたJITコンパイル
  • やばいGC
  • よく構成された文法
  • 静的型付け
  • 関数型サポート(最近は特に)
  • 多様なライブラリ
  • 信頼できるコミュニティ(Oracleではなく、開発者の方)

などなど挙げればきりがありません。

しかし、AWS Lambda上でコードを試していて気づいたのですが、Javaはあまりサーバレスに向かないことがわかりました。

理由としては以下のことが挙げられます。

  • JVMの起動オーバーヘッドが大きい
  • Springフレームワークを使用してるとさらにエグくなる
  • 最終的なパッケージアーカイブがでかすぎる(でかいのは100MB以上)
  • 関数が増えてくるとWebフレームワークなしでリクエストを捌くのがきつくなる
  • コンテナは30分程度しか走らないため、G1GCやJITなどのJavaの利点が生かせない
  • Lambdaは基本的にEC2上に建てられたAmazon Linuxのコンテナで動くため、プラットフォームフリーは関係ない。 (欠点ではないけど)

上述の点は全てなかなかに厄介ですが、今回は特に厄介だった問題についてもう少し書いてみたいと思います。

Cold Startがまじで厄介

一番厄介だったのは、圧倒的にCold Startのオーバーヘッドです。おそらく多くの開発者の方々もこいつに悩まされているのではないかと思います。。。

僕たちはコンピューティング基盤としてAWS Lambdaを使っていたのですが、AWS Lambdaはユーザからのリクエストが来るたびに新しいコンテナを立ち上げます。

一度立ち上がってしまえば、しばらくは同じコンテナインスタンスを再利用してくれるのですが、初回起動時にはJavaのランタイムに加え、フレームワークで利用されるDIコンテナやWebコンテナなども全て初期化する必要があります。

さらに言えば、一つのコンテナで処理できるのはあくまで一つのリクエストのみで、複数のリクエストを処理することはできません。(内部で数百のリクエストスレッドをプーリングしてたとしても同じです。)

つまりどういうことかというと、もし複数のユーザがリクエストを同時に送ってきた場合、Lambdaは起動中のコンテナの他に、別のコンテナを起動しなくてはいけなくなるということです。
通常、僕たちはどの時間に具体的に何軒のリクエストが同時に来るかを事前に予測することはできません。
つまり、何らかの仕組みを作ったとしても、事前に全てのLambdaをhot standbyさせることはできないのです。

これは必然的にユーザに数秒から10秒以上の待機時間を強制し、ユーザビリティを著しく下げることにつながります。

こんな感じでCold Startがえげつない事を理解した僕らは、これまでの数年かけて書かれた技術スタックを捨てて、他の言語を選択することを決めました。

なぜTypeScriptを選んだのか

めちゃくちゃ恥ずかしい話なのですが、正直Lambdaでサポートされている全ての言語をきちんと精査・比較して決めたわけではないのです。
ただ、正直な話、状況的にTypeScript以外の選択肢はなかったのです。

まず第一に、動的型付け言語は外しました。長期に渡ってスキルのバラバラな開発者によって保守・メンテ。拡張されるコードなので、動的型付けはあまり使いたくありません。

したがって、PythonRubyに関してはかなり序盤で選択肢から外れました。

C#Goに関しても、現在ほとんどのチームがJavaをメインに開発しているサービスなので、既存言語とあまりかけ離れた言語を使うと新規開発者のジョインが難しくなると判断し、今回は見送られました。

もちろん、昨今この二大言語は非常に注目度が高く、特にGolangに関しては徐々にシェアを伸ばしつつあるのは知っています。
しかし、急いでサーバレスに開発を移す必要があったため、僕たち自身のキャッチアップの時間も考慮し、見送らざるを得なかった感じでした。

TypeScriptの利点

という事で、僕たちはTypeScriptを使い始めたわけです。
TypeScriptの利点を挙げるとしたらこんな感じでしょうか?

  • 静的型付け
  • 小さいパッケージアーカイブ
  • ほぼ0秒の起動オーバーヘッド
  • JavaとJavaScriptの知識が再利用できる
  • NodeJSのライブラリやコミュニティが使える
  • JavaScriptと比べても関数型プログラミングがしやすい
  • ClassとInterfaceにより構造化されたコードが描きやすい

長期に渡って運用・開発が行われるプロジェクトにおいて静的型付け言語がどれだけ大きな恩恵を与えるかは今更語るまでもありませんので、ここには書きません。
ここでは主に、TypeScriptのどういった点がサーバレス開発によく馴染んだかについて書いていきたいと思います。
静的型付け以外にもTypeScriptを使う利点は非常に大きく、

小さいパッケージと小さい起動オーバーヘッド

おそらくサーバレスでTypeScriptを使う利点という観点からいうとこれが一番大事だった気がします。(なにせ他のメリットはほぼTypeScript自体のメリットなので・・・)

先ほど触れた通り、JavaはJVM本体やフレームワークが利用するDI/Webコンテナなどの起動にかかるオーバヘッドが非常に大きいです。
加えて、Javaの性質上、AWS Lambdaで流すには以下の弱点があります。

マルチスレッドとそれを取り巻くエコシステム

マルチスレッドは非常に強力な機能であり、事実として僕たちはこの機能のおかげで多くのパフォーマンス問題を解決してきました。
JVM自体もガーベージコレクションやJITコンパイルにおいて、デフォルトでにマルチスレッドを活用してあの素晴らしいランタイムを実現してます。
(詳しくはG1GCJIT Compileを参照)

しかし、起動時間単体で見ると、アプリケーションに使用する全てのスレッドを立て終わるまでに、100ミリ秒から数秒かかっていることがわかります。
この機能自体は旧来のいわゆるクラサバモデルでEC2上で動くアプリケーションならほぼ無視できるオーバーヘッドですが、LambdaなどのFaaS上で動くサーバレスアプリケーションでは決して無視できません。

TypeScriptはNodeJSベースであり、基本的にシングルスレッドです。非同期は別スレッドや別プロセスではなくあくまでジョブキュー、イベントループなどで管理されます。

したがって、ほとんどのライブラリやフレームワークは起動時にスレッド展開をする必要はありませんし、ランタイムを起動するためのオーバーヘッドもほとんどかかりません。

巨大なパッケージアーカイブ

サーバレスにおいてソースコードのパッケージアーカイブは、基本的に小さいに越したことはありません

Lambdaのコンテナは起動時、AWSにより管理されたソースコード用のS3バケットからソースをダウンロードし、コンテナに展開します。

S3からのダウンロード時間は通常非常に短時間ですが、100MBや200MBとなると無視はできません。

NodeJSのパッケージは基本的にJavaに比べて小さくなります。

正直なんでそうなるかに関しては不勉強でわかっていないのですが、以下の理由が関係してるんじゃないかなと思ったりします。(もしこれやでっていうのをご存知の方はコメントで教えてください)

  • Javaのフレームワークやライブラリは包括的なものも多く、本来使いたい機能に必要ない依存性を引き込んで来るが、JavaScriptは目的特化のライブラリが多く、必要最低限に依存を抑えられることが多い。
  • JavaScript(NodeJS)は1ファイルに複数のmoduleを書くことができ、それでいてメンテもしやすいが、Javaにおけるメンテナンス性の重要なポイントはファイル分割とパッケージ管理のためソースが肥大化しやすい。

実際Javaで書いていた時は最大で200MB以上のパッケージができることもあったのですが、NodeJSに変えてからは35MB程度で済んでいます。

この巨大なパッケージアーカイブは、僕たちがSpringで書かれた旧来のコードを再利用しようとしたのが大きな原因なのですが、実際これらのいらないフレームワークを除いて最適化したコードでも、どうしても50MBは必要になってしまいました。

JavaScriptの知識やエコシステムを利用できる

僕たちもWeb開発者のため、基本的にフロントエンドも書きます。したがって、ある程度のJavaScriptやNodeJSに関する知識は蓄えていました。

jQuery全盛時代からReact/Vueのようなモダンフレームワークでの開発までを通じて、言語的な特徴はある程度抑えていましたし、どうやって書けばいいコードになるかもある程度理解してるつもりです。

TypeScriptはJavaScriptの拡張言語であり、最終的にはJavaScriptにトランスパイルされます。

多くの文法やイディオムはJavaScriptから受け継がれているので、実際それほど準備期間を要さずにサービス開発を始められました。

加えて、ほとんどのメジャなNodeJSのライブラリはTypeScriptに必要な型定義を提供しているので、NodeJSのエコシステムのメリットをそのまま享受できたのも非常に嬉しいポイントでした。

関数型の実装が非常にしやすい

昨今の技術トレンドを語る上で、関数型の台頭はなくして語ることはできません。
関数型の実装はその性質上、シンプルでテスト可能で危険性の低い安定したコードを書くのに大きく寄与します。

特にAWS Lambdaの場合、常に状態を外部化するコードが求められるため、状態や副作用を隔離する関数型の実装は非常に相性が良く、メンテもしやすくなります。

そもそも、jQueryの生みの親であるJohn ResigがJavaScriptニンジャの極意で語ったように、JavaScriptはそもそも関数型のプログラミングをある程度サポートしています。
JavaScriptにおいて関数は第1級オブジェクトであり、jQueryも実は関数型で書かれることを期待して作られています。

しかし一方で、動的型付け言語で関数型のコードを書こうとすると、時折非常にめんどくさい事になることがあります。
例えば、プリミティブ型だけで表現できる関数は非常に限られますし、返り値や引数にObjectを取るのは普通に結構危険です。

しかしTypeScriptでは引数や返り値に型を指定することができます。

加えて、以下のTypeScriptの機能は、僕達の書く関数の表現の幅を広げ、より安全でシンプルなコードを書くのに寄与してくれます。

  • Type: 共通に使用される型をコンテクストに合わせて型付けできる。(stringUserIdPromiseResponseなど)
  • Interface/Class: Objectで表現されるの引数や返り値をコンテクストにあった型で表現できる。
  • Enum: よもや語る必要もあるまい
  • Readonly: 自分で作成した型をImmutableに出来る
  • Generics: 関数のインターフェイスの表現の幅が広がる

TypeScriptは他にも関数型で書こうとした時に非常に便利な機能をいろいろ備えていますが、全てをここであげることはしません。(っていうか、結構JavaScript由来のものが多い)

関数型とTypeScriptに関する記事は今後どこかで書いていきたいなと思っています。

Javaで学んだBest Practiceを再利用できる

TypeScriptの文法を学ぶと、かなりJavaやScalaに似通った記述ができることに驚きます。

僕たちはそもそも、それなりの期間をJavaで開発してくる中で、Javaにおけるいいコードのお作法をある程度蓄積してきました。
ClassやInterfaceをどう設計すべきか、enumはどう使うと効率的か、Stream APIはどう書くと保守性が上がるかなど、蓄積してきたノウハウはそれなりに捨てがたいものがありました。

TypeScriptはインターフェイスやクラスに加えて、アクセスモディファイアやreadonly(Javaでいうfinalのプロパティ)をサポートしており、僕たちは割とさらっとJavaで育んだノウハウをそのまま導入することができました。

これにはオブジェクト指向的なベストプラクティスやデザインパターンなども含まれます。
(関数指向とオブジェクト指向は二律背反ではないので、プロジェクト内で同時に使用されても問題ないと考えています。個人的には。)

もし僕たちがやや文法が独特なPythonやRubyを採用していたとしたら、より品質の高いコードを書くためのプラクティスをどうこの言語に応用すべきかに多くの時間を費やすこになったことかと思います。(それも楽しいんですよ、知ってます、ただ時間がね。。。。)

当然ながら全ての設計やロジックをコピペしたわけではないですし、むしろ大半のコードを書き直ししました。
ただ、おおよその部分をスクラッチで書き直した割に、それなりの品質でそれなりの短期間で書き直しが終わったんだよということは特筆しておくべきかと思います。

結論

僕たちもまだまだTypeScriptに関しては初心者といっていいレベルでまだまだ勉強が必要ですが、すでにそのメリットは全力で享受しています。

今聞かれれば、Golangもいいなあとか、MicronautとGraalVMとかも面白そうだなあとか、もっと他の選択肢もあったかもなあとか考えたりもするのですが、現状TypeScriptには非常に満足しており、最善の選択肢の一つではないかと信じています。

もちろん、処理遅いけどバッチ処理どうすんねんとか、並行処理とか分散処理どうすんねんとか、ワークフローどう設計すんねんとか、API Gatewayのタイムアウトどうハンドルするねんとか、データの一貫性どう担保すんねんとか、サーバレスやTypeScriptに起因する問題にはたくさんぶち当たりました。

ただ、それはそれでギークとして非常に楽しく取り組んできて、すでにいくつかのこれが今の所best practiceじゃね?っていう方法もいくつか見つけました。(これはのちのち記事にしていきたい。)

もし今Javaでサーバレスに取り組んでいて、サーバレスくそやん、きついやん、やっぱ普通にサーバ欲しいわってなっている方がいたら、ぜひTypeScriptも試してみてください。想像する以上に生産性出るんじゃないかなぁって期待してます。

長文おつきあいいただきありがとうございました。何かコメントや訂正があればぜひお願いします。

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