20191204のNode.jsに関する記事は13件です。

obnizと圧電スピーカーを使ってキーボードをキーボード(音楽)に変えてみた

はじめに

いまOTTOというArduinoベースのオープンソースロボットをobnizに移植する個人プロジェクトを進めています(IoTLT vol.54LTしたやつです)。
OTTOには圧電スピーカーも搭載されており、これを使ってロボっぽいピロピロ音を出すことができます。

先日、この圧電スピーカーを使ってobniz版OTTOに今流行りのパプリカを歌わせてみました。

圧電スピーカーで音を鳴らすのは周波数を渡すだけととても単純だったので、これを応用してobnizと圧電スピーカーを使って普段コードを打ち込んでるキーボードをキーボード(音楽用)に変えてみたいと思います。

ハードウェア実装

まずは圧電スピーカーを用意します。
obnizのパーツライブラリページに秋月やSwitch Scienceの通販リンクがあります。
大きさとかで30円~100円と割と種類ありますが、なんでもいいと思います。
私は手元にあったやつを使いました。

IMG_20191204_225038.jpg

このうち左下のやつは飛び抜けて音がでかかったため除外しました。
他のは大体同じ音量ですが、左上のは音が悪かったです。

そしてこれらをobnizに直挿しします。
ブレッドボードを使えば12個繋げられますが、obnizと言えばモーター然り直差しなので、このままいきます。

IMG_20191204_221332.jpg

ソフトウェア実装

キーボードのイベント拾うならHTMLで実装するのが手軽ですが、画面上にキーボードの画像表示してどこが押されてるかとかの描画もないといけない感じがするので、HTMLは使いません。
代わりにNode.jsで実装します。

Node.jsでリアルタイムにキーイベントを拾うには↓のパッケージが良さそうです。
iohook

こんな感じでキーイベントだけでなくマウスポインターとかも拾えます。

index.js
'use strict';
const ioHook = require('iohook');

ioHook.on("mousemove", event => {
  console.log(event);
  // result: {type: 'mousemove',x: 700,y: 400}
});
ioHook.on("keypress", event => {
  console.log(event);
  // result: {keychar: 'f', keycode: 19, rawcode: 15, type: 'keypress'}
});
//Register and stark hook 
ioHook.start();

あとは圧電スピーカーの制御コードをobnizのパーツライブラリからコピって、keydownで音鳴らしてkeyupで音止めるようにすればいいわけですね。

完成したコード

こちらが完成したコードです。
キーボードのキーコードとスピーカーで鳴らす周波数のマップオブジェクトを用意しています。
それと各圧電スピーカーの割当の制御とかも書いてます。
あとは上記で書いたような実装です。

npm install obniz iohookしたうえで、obniz IDと各スピーカーのピン番号を書き換えて実行してみてください。

index.js
"use strict"
const ioHook = require("iohook")
const Obniz = require("obniz")

const keymap = {
    "16": 370,  // F#3
    "30": 392,  // G3
    "17": 415,  // G#3
    "31": 440,  // A4
    "18": 466,  // A#4
    "32": 494,  // B4

    "33": 523,  // C4
    "20": 554,  // C#4
    "34": 587,  // D4
    "21": 622,  // D#4
    "35": 659,  // E4

    "36": 698,  // F4
    "23": 740,  // F#4
    "37": 784,  // G4
    "24": 831,  // G#4
    "38": 880,  // A5
    "25": 932,  // A#5
    "13": 988,  // B5

    "39": 1047, // C5
    "41": 1109, // C#5
    "27": 1175, // D5
    "26": 1245, // D#5
}

const obniz = new Obniz("xxxx-xxxx")
obniz.onconnect = async () => {
    const speakers = []
    speakers.push(  // スピーカーは繋げられるだけここに列挙
        { assign: 0, obniz: obniz.wired("Speaker", {signal: 0, gnd: 1}) },
        { assign: 0, obniz: obniz.wired("Speaker", {signal: 2, gnd: 3}) },
        { assign: 0, obniz: obniz.wired("Speaker", {signal: 4, gnd: 7}) },
    )

    ioHook.on("keydown", event => {
        if (!keymap[event.keycode]) return

        if (speakers.some(speaker => speaker.assign === event.keycode)) return

        for (const speaker of speakers) {
            if (speaker.assign) continue

            speaker.assign = event.keycode
            speaker.obniz.play(keymap[event.keycode])
            return
        }

    })

    ioHook.on("keyup", event => {
        if (!keymap[event.keycode]) return

        for (const speaker of speakers) {
            if (speaker.assign !== event.keycode) continue

            speaker.assign = 0
            speaker.obniz.stop()
            return
        }
    })

    ioHook.start()
}

キー割り当て

こんな感じで音程を割り当てています。

assign.png

演奏してる様子

ひとまずドレミファソラシド。

スピーカー3つ繋いでるので3和音まで出せます。
(直差しじゃなければ12和音まで出せます)

最後に一曲演奏してみます。
なんの曲かわかったらまじ神です。

ちなみに所々音出てないのは回路やパーツやプログラムが悪いわけではなく、多分指を離すのが遅くて3和音鳴ってる状態のまま次のキー押しちゃってるのが問題です。
つまり演奏が下手なだけです。

おわりに

ここまで書いてそもそもこれはIoTなのか疑問になりましたが、obnizはネットにつながってないと動かないのでこれはきっとIoT。

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

NestJS でダミーの Service を注入し、外部依存のないテストを実行する

この記事は NestJS アドベントカレンダー 4 日目の記事です。

はじめに

先日は Module と DI について説明しましたが、本日はもう一歩進んだ DI を活用したテストを実施してみます。
なお、サンプルでは MySQL に接続したり Docker を使用したりしていますが、怖がらないでください。
この記事では MySQL や Docker に依存せずにテストできるようにするテクニックを説明します。

サンプルコードのリポジトリは以下になります。

https://github.com/nestjs-jp/advent-calendar-2019/tree/master/day4-inject-dummy-service-to-avoid-external-dependency

なお、環境は執筆時点での Node.js の LTS である v12.13.1 を前提とします。

サンプルアプリの雛形を作る

今回のサンプルとなるアプリケーションの雛形を cli を用いて作ってゆきます。

$ nest new day4-inject-dummy-service
$ nest g module items
$ nest g controller items
$ nest g service items

ItemsController には以下のように Post と Get を実装していきます。

items/items.controller.ts
@Controller('items')
export class ItemsController {
  constructor(private readonly itemsService: ItemsService) {}

  @Post()
  async createItem(@Body() { title, body, deletePassword }: CreateItemDTO) {
    const item = await this.itemsService.createItem(
      title,
      body,
      deletePassword,
    );

    return item;
  }

  @Get()
  async getItems() {
    const items = await this.itemsService.getItems();

    return items;
  }
}

ItemsService も雛形を作成します。

items/items.service.ts
@Injectable()
export class ItemsService {
  async createItem(title: string, body: string, deletePassword: string) {
    return;
  }

  async getItems() {
    return [];
  }
}

MySQL にデータを書き込む箇所を実装する

今回は Service の外部依存先として、 MySQL を例にあげます。
MySQL に接続するため、以下のライブラリをインストールします。

$ yarn add typeorm mysql

なお、今回は TypeORM の複雑な機能は極力使用せずにサンプルを記述します。
TypeORM についての説明や NestJS との組み合わせ方については別の記事で説明します。
また、本来は constructor で非同期の初期化を行うべきではないのですが、回避策は複雑なので、こちらも別途説明します。

items/items.service.ts
@Injectable()
export class ItemsService {
  connection: Connection;

  constructor() {
    createConnection({
      type: 'mysql',
      host: '0.0.0.0',
      port: 3306,
      username: 'root',
      database: 'test',
    })
      .then(connection => {
        this.connection = connection;
      })
      .catch(e => {
        throw e;
      });
  }

  // connection が確立していないタイミングがあるため待ち受ける
  private async waitToConnect() {
    if (this.connection) {
      return;
    }
    await new Promise(resolve => setTimeout(resolve, 1000));
    await this.waitToConnect();
  }

  async createItem(title: string, body: string, deletePassword: string) {
    if (!this.connection) {
      await this.waitToConnect();
    }
    await this.connection.query(
      `INSERT INTO items (title, body, deletePassword) VALUE (?, ?, ?)`,
      [title, body, deletePassword],
    );
  }

  async getItems() {
    if (!this.connection) {
      await this.waitToConnect();
    }
    const rawItems = await this.connection.query('SELECT * FROM items');
    const items = rawItems.map(rawItem => {
      const item = { ...rawItem };
      delete item.deletePassword;

      return item;
    });

    return items;
  }
}

また、 MySQL を Docker で立ち上げます。

$ docker-compose up

Docker ではない MySQL で実行する場合、 MySQL に test データベースを作り、 create-table.sql を流してください。

この状態でアプリケーションを起動してみましょう。MySQL が起動していれば、無事起動するはずです。

$ yarn start:dev

続いて curl でアプリケーションの動作確認をしてみます。

$ curl -XPOST -H 'Content-Type:Application/json' -d '{"title": "hoge", "body": "fuga", "deletePassword": "piyo"}' localhost:3000/items
$ curl locaohost:3000/items
[{"title":"hoge","body":"fuga"}]

無事保存できるアプリケーションができました。

MySQL がない状態でもテストできるようにする

アプリケーションができたので、Mock を使ってテストを記述します。

前回までのサンプルでは特に DI を意識する必要がなかったため new ItemsService() としてテストを記述していましたが、
今回は DI に関連するため、 cli で自動生成される雛形にも用いられている Test モジュールを使用します。

describe('ItemsController', () => {
  let itemsController: ItemsController;
  let itemsService: ItemsService;

  beforeEach(async () => {
    const testingModule: TestingModule = await Test.createTestingModule({
      imports: [ItemsModule],
    }).compile();

    itemsService = testingModule.get<ItemsService>(ItemsService);
    itemsController = new ItemsController(itemsService);
  });

  describe('/items', () => {
    it('should return items', async () => {
      expect(await itemsController.getItems()).toHaveLength(1);
    });
  });
});

さて、この状態でテストを実行するとどうなるでしょうか。
MySQL を起動している場合はそのままテストが通りますが、 MySQL を停止すると以下のようにテストが落ちてしまいます。

$ jest
 PASS  src/app.controller.spec.ts
 FAIL  src/items/items.controller.spec.ts
  ● ItemsController › /items › should return items

    connect ECONNREFUSED 0.0.0.0:3306

          --------------------
      at Protocol.Object.<anonymous>.Protocol._enqueue (../node_modules/mysql/lib/protocol/Protocol.js:144:48)
      at Protocol.handshake (../node_modules/mysql/lib/protocol/Protocol.js:51:23)
      at PoolConnection.connect (../node_modules/mysql/lib/Connection.js:119:18)
      at Pool.Object.<anonymous>.Pool.getConnection (../node_modules/mysql/lib/Pool.js:48:16)
      at driver/mysql/MysqlDriver.ts:869:18
      at MysqlDriver.Object.<anonymous>.MysqlDriver.createPool (driver/mysql/MysqlDriver.ts:866:16)
      at MysqlDriver.<anonymous> (driver/mysql/MysqlDriver.ts:337:36)
      at step (../node_modules/tslib/tslib.js:136:27)
      at Object.next (../node_modules/tslib/tslib.js:117:57)

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.204s, estimated 3s

ItemsService を Mock していますが、 ItemsService の初期化自体はされており、初期化処理の中で MySQL への接続しようとしているのが原因です。
このような、 外部へ依存する Provider の初期化 をテストから除外するために、 ItemsService を上書きした状態で testingModule を生成する機能が NestJS には備わっています。

以下のように DummyItemsService class を定義し、 overrideProvider を使って上書きします。

class DummyItemsService {
  async createItem(title: string, body: string, deletePassword: string) {
    return;
  }
  async getItems() {
    const item = {
      id: 1,
      title: 'Dummy Title',
      body: 'Dummy Body',
    };
    return [item];
  }
}

describe('ItemsController', () => {
  let itemsController: ItemsController;
  let itemsService: ItemsService;

  beforeEach(async () => {
    const testingModule: TestingModule = await Test.createTestingModule({
      imports: [ItemsModule],
    })
      .overrideProvider(ItemsService)
      .useClass(DummyItemsService)
      .compile();

    itemsService = testingModule.get<ItemsService>(ItemsService);
    itemsController = new ItemsController(itemsService);
  });

  describe('/items', () => {
    it('should return items', async () => {
      expect(await itemsController.getItems()).toHaveLength(1);
    });
  });
});

useClass() の代わりに useValue() を使うことで、 class ではなく変数で上書きすることもできます。

この状態でテストを実行すると、 MySQL が起動していなくても問題なく通過します。

yarn run v1.19.0
$ jest
 PASS  src/items/items.controller.spec.ts
 PASS  src/app.controller.spec.ts

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.406s
Ran all test suites.
✨  Done in 2.94s.

おわりに

この記事で NestJS の持つ強力な DI の機能をお伝えできたかと思います。
より詳細な内容は公式のドキュメントの E2E テストの項にあるので、合わせてご確認ください。
https://docs.nestjs.com/fundamentals/testing#end-to-end-testing

また、今回説明できなかった TypeORM との合わせ方や、非同期の初期化を必要とする Service の扱い方については、後日別の記事で説明します。

明日は @potato4d さんが ExceptionFilter についてお話する予定です。

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

Node.jsのHTTPリクエストヘッダの最大サイズでハマった話

現象

  • Node.js(v12.3.1)で立てたWebサーバにアクセスすると、時折HTTPリクエストに失敗する
  • Cookieを削除したり、ブラウザを再起動すると治ることもあるが、根本的な原因がわからない
サンプルコード
const http = require('http');

const server = http.createServer((req, res) => {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Hello World');
});

server.listen(8080);

原因

  • Node.jsの最大HTTPリクエストヘッダサイズのデフォルト値である8kBを越えるHTTPリクエストヘッダサイズを送信していたことが原因だった

  • Node.jsは、2018/11にDoS攻撃の脆弱性対応として、デフォルトのHTTPリクエストヘッダの最大サイズを変更前の80kBから8kB(8192Bytes)に変更する修正が加えられた

  • デフォルトでは、HTTPリクエストヘッダのサイズが8kBを越えるとソケットが強制破棄されて「431 Request Header Fields Too Large」を返す

$ npm start

> sample-nodejs-header-overflow@1.0.0 start /../../../sample-nodejs-header-overflow
> node index.js

// curlで8kB以上のHTTPリクエストを送信
ErrorCode:  HPE_HEADER_OVERFLOW
BytesParsed:  8559

// curlで8kB以上のHTTPリクエストを送信
ErrorCode:  HPE_HEADER_OVERFLOW
BytesParsed:  8559

// curlで8kB以上のHTTPリクエストを送信
ErrorCode:  HPE_HEADER_OVERFLOW
BytesParsed:  9085

対策

  • HTTPリクエストヘッダのサイズが超えた場合に起こるclientErrorイベントを補足して、ソケットが強制的に破棄されないようにエラーハンドリングを行う
const http = require('http');

const server = http.createServer((req, res) => {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Hello World');
});

server.on('clientError', (err, socket) => {
    console.log('ErrorCode: ', err.code);
    console.log('BytesParsed: ', err.bytesParsed);
    socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});

server.listen(8080);

  • アプリケーション起動時に「--max-http-header-size」という起動オプションを設定して、Node.jsが受け取る最大のHTTPリクエストヘッダサイズを増やす
$ node --max-http-header-size=16384 index.js

おわりに

今回リクエストヘッダのサイズが8kBを越えた主な原因は、多数のCookieを使ってWebサーバにアクセスしていたことでした。
仕様上Cookieの数が多くなり、HTTPリクエストのサイズに不安がある場合は、エラーハンドリングを正しく実装して、起動オプションでNode.jsが受け取るHTTPリクエストサイズの最大値を上げておくと良いかと思います。

参考

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

ソフトウェア初心者がtoio.jsで作ってみた 5つの作例紹介

これは「toio™(ロボットトイ | toio(トイオ)) Advent Calendar 2019」の8日目の記事になります。

はじめに

はじめまして。ヒラノユウヤです。
普段はハードウェアエンジニア(電気)として暮らしています。
この記事では、ソフトウェア初心者の私がtoio.jsを使って作ってみたtoio作品を紹介したいと思います。

ソフトウェアスキル

  • C言語
    • 学校の授業では真面目に取り組んでいました
    • 社会人になってからも、Arduinoを使いこなすくらいには使っていた感じ

以上。なんとも貧弱で泣けてきます。
なんですが、toio core cubeを使ったプログラミングがどうしてもやりたくて。
toio.jsの環境を友人に手伝って構築してもらったところからスタートしました。
始めてみると、サンプルコードもあるので、苦労はしながらも意外といろんなものができました。

参考にしたもの

1にも2にも、公式情報が命でした。
用意されているtoio.jsの使い方はtoio.jsのページで。
buzzerの音階やtoio IDの情報など、toio自体に対しての情報は技術仕様のページで。

あとはサンプルプログラムの読み解きと、ちょい変でのトライ&エラーを繰り返しました。

作例紹介

早速紹介始めます。
実際に作ってtwitterに上げたのは結構昔なので、記憶を辿りながら文章書いてみます。
ソースコードもまんま貼り付けるので、批判称賛なんでもコメントいただければ嬉しいです。

1.モールス信号発生器

パソコンのキーボード入力の取得と、toio.jsのplaySound()の組み合わせです。

キーボード入力の取得はtoio.jsのサンプルプログラム keyboard-control から拝借しました。

入力されたアルファベットをcase文で場合分けします。
対応するモールス信号の構造体を生成して、それをCubeのブザーから鳴らしています。

モールス信号は法則性がないので、このようなcase文での力技しか方法が思いつきませんでした。

const keypress = require('keypress')
const { NearestScanner } = require('@toio/scanner')

const TONE = 64
const TONE_SILENT = 127
const DURATION_SHORT = 200
const DURATION_LONG = DURATION_SHORT * 3

var morse_short = [
  { durationMs: DURATION_SHORT, noteName: TONE }, 
  { durationMs: DURATION_SHORT, noteName: TONE_SILENT }, 
]
var morse_long = [
  { durationMs: DURATION_LONG, noteName: TONE }, 
  { durationMs: DURATION_SHORT, noteName: TONE_SILENT }, 
]

var morse

async function main() {
  // start a scanner to find nearest cube
  const cube = await new NearestScanner().start()

  // connect to the cube
  await cube.connect()

  keypress(process.stdin)
  process.stdin.on('keypress', (ch, key) => {
    if ((key && key.ctrl && key.name === 'c')) {
      process.exit()
    }

    switch (key.name) {
      case 'a':
        morse = morse_short.concat(morse_long)
        cube.playSound(morse ,1)
        break
      case 'b':
        morse = morse_long.concat(morse_short).concat(morse_short).concat(morse_short)
        cube.playSound(morse ,1)
        break
      case 'c':
        morse = morse_long.concat(morse_short).concat(morse_long).concat(morse_short)
        cube.playSound(morse ,1)
        break
      case 'd':
        morse = morse_long.concat(morse_short).concat(morse_short)
        cube.playSound(morse ,1)
        break
      case 'e':
        morse = morse_short
        cube.playSound(morse ,1)
        break
      case 'f':
        morse = morse_short.concat(morse_short).concat(morse_long).concat(morse_short)
        cube.playSound(morse ,1)
        break
      case 'g':
        morse = morse_long.concat(morse_long).concat(morse_short)
        cube.playSound(morse ,1)
        break
      case 'h':
        morse = morse_short.concat(morse_short).concat(morse_short).concat(morse_short)
        cube.playSound(morse ,1)
        break
      case 'i':
        morse = morse_short.concat(morse_short)
        cube.playSound(morse ,1)
        break
      case 'j':
        morse = morse_short.concat(morse_long).concat(morse_long).concat(morse_long)
        cube.playSound(morse ,1)
        break
      case 'k':
        morse = morse_long.concat(morse_short).concat(morse_long)
        cube.playSound(morse ,1)
        break
      case 'l':
        morse = morse_short.concat(morse_long).concat(morse_short).concat(morse_short)
        cube.playSound(morse ,1)
        break
      case 'm':
        morse = morse_long.concat(morse_long)
        cube.playSound(morse ,1)
        break
      case 'n':
        morse = morse_long.concat(morse_short)
        cube.playSound(morse ,1)
        break
      case 'o':
        morse = morse_long.concat(morse_long).concat(morse_long)
        cube.playSound(morse ,1)
        break
      case 'p':
        morse = morse_short.concat(morse_long).concat(morse_long).concat(morse_short)
        cube.playSound(morse ,1)
        break
      case 'q':
        morse = morse_long.concat(morse_long).concat(morse_short).concat(morse_long)
        cube.playSound(morse ,1)
        break
      case 'r':
        morse = morse_short.concat(morse_long).concat(morse_short)
        cube.playSound(morse ,1)
        break
      case 's':
        morse = morse_short.concat(morse_short).concat(morse_short)
        cube.playSound(morse ,1)
        break
      case 't':
        morse = morse_long
        cube.playSound(morse ,1)
        break
      case 'u':
        morse = morse_short.concat(morse_short).concat(morse_long)
        cube.playSound(morse ,1)
        break
      case 'v':
        morse = morse_short.concat(morse_short).concat(morse_short).concat(morse_long)
        cube.playSound(morse ,1)
        break
      case 'w':
        morse = morse_short.concat(morse_long).concat(morse_long)
        cube.playSound(morse ,1)
        break
      case 'x':
        morse = morse_long.concat(morse_short).concat(morse_short).concat(morse_long)
        cube.playSound(morse ,1)
        break
      case 'y':
        morse = morse_long.concat(morse_short).concat(morse_long).concat(morse_long)
        cube.playSound(morse ,1)
        break
      case 'z':
        morse = morse_long.concat(morse_long).concat(morse_short).concat(morse_short)
        cube.playSound(morse ,1)
        break
    }
  })

  process.stdin.setRawMode(true)
  process.stdin.resume()
}

main()

2.電子ピアノ

1.でBuzzerが鳴らせたので、今度は読み取りセンサと合わせたものが作りたいと言うことで、作ったものです。

読み取りセンサでトイオ・コレクションのマット座標を読み取って、対応する音をブザーから鳴らしています。
読み取りセンサの値はそのまま使うのではなく、トイオ・コレクションのマットの格子単位の単位で検出するように丸めています。
ここの丸めかた、実物合わせで採寸しながらやりました。

Cubeがマットに触れている間だけ音が鳴るように、
Cubeがマットに載った時に動く関数 cube.on('id:position-id' で音を鳴らして
Cubeがマットから離れた時に動く関数 cube.on('id:position-id-missed' で音を消す処理を入れています。

実はここで複数Cube接続できるようにコードを修正しています。
起動時にキーボード入力で入力した数自分のCubeを接続できるようにしています。
私の環境では最大6台までのCubeの接続ができました。

const keypress = require('keypress')
const { NearScanner } = require('@toio/scanner')

var midi_note = new Array()
var data_norm = new Array()
data_norm[0] = {x:0,y:0}

const DURATION = 3000

var MIDI_SCALE_C = [0,0,2,4,5,7,9,11,12,12,12]

const X_INI_TOICOLE = 555.5
const X_END_TOICOLE = 946.95
const Y_INI_TOICOLE = 53
const Y_END_TOICOLE = 44.95
const UNIT_TOICOLE = 43.2

var cube_number = 2

function cube_control(cube){
  var lastData = {x:0, y:0}
  var flag = 0

  cube.on('id:position-id', data1 => {
      var tmp = {x: Math.floor((data1.x - X_INI_TOICOLE) / UNIT_TOICOLE) + 1,
                 y: Math.floor((data1.y - Y_INI_TOICOLE) / UNIT_TOICOLE) + 1}

      if (tmp.x != lastData.x) flag = 0
      if (tmp.y != lastData.y) flag = 0

      midi_note = MIDI_SCALE_C[tmp.x] + (tmp.y -1)* 12

      if (flag==0){
        cube.playSound([{durationMs: DURATION, noteName: midi_note}] ,1)
        flag = 1
      }

      lastData = tmp
      console.log('[X_STEP]', tmp.x)
      console.log('[Y_STEP]', tmp.y)
      console.log('MIDI',midi_note)
    }
  )
  cube.on('id:position-id-missed', () => {
      flag = 0
      cube.stopSound()
      console.log('[POS ID MISSED]')
    }
  )
}

async function cube_connect(cube_number){
  // start a scanner to find the nearest cube
  const cubes = await new NearScanner(cube_number).start()

  // connect to the cube
  for(var i = 0; i < cube_number; i++) {await cubes[i].connect()}
  return cubes
}

async function main() {

  console.log('USE Rhythm and Go Mat')
  console.log('Press connect cube number')

  keypress(process.stdin)
  process.stdin.on('keypress', async (ch, key) => {
    // ctrl+c or q -> exit process
    if(key){
      if ((key && key.ctrl && key.name === 'c') || (key && key.name === 'q')) {
        process.exit()
      }
    }else{
      console.log('[Ch]',ch)
      cube_number = ch
      const cubes = await cube_connect(ch)
      for(var i = 0; i < cube_number; i++) {cube_control(cubes[i])}
   }
  }
  )

process.stdin.setRawMode(true)
process.stdin.resume()
}

main()

3.宝探しゲーム

今度はLEDの点灯と組み合わせを試してみた作品です。
ランダムに生成されるゴール位置をLEDの色を見ながら手探りで探し当てるといったゲームを作りました。

マット上にCubeを置くと、座標(X,Y)と姿勢(Θ)が取得できます。
ゴールの場所(X,Y,Θ)から遠ざかるほどLED色が強くなり、Target場所に一致すると消える という仕様。
つまり、LEDの光が消える場所をさがす というゲームです。

X方向は赤、Y方向は緑、Θ方向は青
といったように各軸で別の色のLEDが反応するので、色味を見ながらどっちの方向に動かすかを考えます。

ゴールの位置にみごCubeを持っていくことができたら勝利判定し、勝利のファンファーレを鳴らすようにしています。
melody_win, melody_lose のやたら長い構造体はこのファンファーレの音データです。

const keypress = require('keypress')
const { NearScanner } = require('@toio/scanner')

var midi_note = new Array()
var data_norm = new Array()
var ledData = new Array()
var target = new Array()
var diff = new Array()

data_norm[0] = {x:0,y:0}
const DURATION = 0

ledData = {durationMs:DURATION, red:255, green:255, blue:255}

const X_INI_TOICOLE = 555.5
const Y_INI_TOICOLE = 53
const UNIT_TOICOLE = 43.2
const X_BEGIN_TOICOLE = 45
const X_END_TOICOLE = 455
const Y_BEGIN_TOICOLE = 45
const Y_END_TOICOLE = 455
const ANGLE_FULLSCALE = 360

var cube_number = 2

target = {x: Math.round(Math.random()*(X_END_TOICOLE - X_BEGIN_TOICOLE))+X_BEGIN_TOICOLE,
          y: Math.round(Math.random()*(X_END_TOICOLE - X_BEGIN_TOICOLE))+X_BEGIN_TOICOLE,
          angle: Math.round(Math.random()*ANGLE_FULLSCALE)}
diff = {x:0,y:0,angle:0}

var melody_win = [
  { durationMs: 400, noteName: 127 }, 
  { durationMs: 400, noteName: 60 }, 
  { durationMs: 100, noteName: 72 }, 
  { durationMs: 100, noteName: 127 }, 
  { durationMs: 100, noteName: 67 }, 
  { durationMs: 100, noteName: 127 }, 
  { durationMs: 100, noteName: 72 }, 
  { durationMs: 100, noteName: 127 }, 
  { durationMs: 600, noteName: 75 }, 
  { durationMs: 100, noteName: 77 }, 
  { durationMs: 100, noteName: 127 }, 
  { durationMs: 100, noteName: 77 }, 
  { durationMs: 100, noteName: 127 }, 
  { durationMs: 100, noteName: 77 }, 
  { durationMs: 100, noteName: 127 }, 
  { durationMs: 1600, noteName: 79 }, 
];

var melody_lose = [
  { durationMs: 5000, noteName: 127 }, 
  { durationMs: 3000, noteName: 127 }, 
  { durationMs: 150, noteName: 71 }, 
  { durationMs: 150, noteName: 77 }, 
  { durationMs: 150, noteName: 127 }, 
  { durationMs: 150, noteName: 77 }, 
  { durationMs: 200, noteName: 77 }, 
  { durationMs: 200, noteName: 76 }, 
  { durationMs: 200, noteName: 74 }, 
  { durationMs: 200, noteName: 72 }, 
];

var flag_gloval = 0
var winnerCubeId =0

function cube_control(cube){
  var lastData = {x:0, y:0, angle:0}
  var lastData2 = {x:0, y:0, angle:0}
  var flag = 0
  var flag_2 = 0

  cube.on('id:position-id', data1 => {
      var tmp = {x: Math.floor((data1.x - X_INI_TOICOLE) / UNIT_TOICOLE) + 1,
                 y: Math.floor((data1.y - Y_INI_TOICOLE) / UNIT_TOICOLE) + 1,
                 angle: data1.angle}

      //angle calc
      diff.angle = Math.abs(target.angle - data1.angle)
      if(diff.angle > 180) diff.angle = 360 - diff.angle 

      //xy calc
      diff.x = Math.abs(target.x - data1.x)
      diff.y = Math.abs(target.y - data1.y)

      //Thinning
      if (Math.abs(data1.x - lastData2.x) > 3) flag_2 = 0
      if (Math.abs(data1.y - lastData2.y) > 3) flag_2 = 0
      if (Math.abs(data1.angle - lastData2.angle) > 3) flag_2 = 0

      if (flag_gloval==1 && flag ==0){
        if(cube.id == winnerCubeId) cube.playSound(melody_win,1)
        else cube.playSound(melody_lose,1)
        console.log('[WIN!]')
        flag = 1
      }

      if (flag_2==0){
        ledData.red = Math.floor(diff.angle / 360 *20)*25
        ledData.green = Math.floor(diff.x / 410 *20)*25
        ledData.blue = Math.floor(diff.y / 410 *20)*25

        //winner judge
        if ((ledData.red + ledData.green + ledData.blue) == 0) {
          winnerCubeId = cube.id
          flag_gloval = 1
        }
        cube.turnOnLight(ledData)
        flag_2 = 1

        //position store
        lastData2 = data1
      }

      console.log('[Winner,cubeID]',winnerCubeId,cube.id)
      console.log(target)
      console.log(ledData)
      console.log(diff)
      console.log(data1)
      console.log(lastData2)
    }
  )
  cube.on('id:position-id-missed', () => {
    flag = 0
    flag_2 = 0
    flag_gloval = 0
    cube.stopSound()
    //cube.turnOffLight()
      console.log('[POS ID MISSED]')
    }
  )
}

async function cube_connect(cube_number){
  // start a scanner to find the nearest cube
  const cubes = await new NearScanner(cube_number).start()

  // connect to the cube
  for(var i = 0; i < cube_number; i++) {await cubes[i].connect()}
  return cubes
}

async function main() {

  console.log('USE Craft fighter Mat')
  console.log('Press connect cube number')

  keypress(process.stdin)
  process.stdin.on('keypress', async (ch, key) => {
    // ctrl+c or q -> exit process
    if(key){
      if ((key && key.ctrl && key.name === 'c') || (key && key.name === 'q')) {
        process.exit()
      }
    }else{
      console.log('[Ch]',ch)
      cube_number = ch
      //connect cube
      const cubes = await cube_connect(ch)
      //control cube
      for(var i = 0; i < cube_number; i++) {cube_control(cubes[i])}
    }
  }
  )

process.stdin.setRawMode(true)
process.stdin.resume()
}

main()

4.和音プレイヤー

複数Cubeの連携制御に挑戦したく、作った作品です。
1つのCubeがマットに触れると、格子ごとに他の3つのCubeが異なるコードを演奏します。

和音なので、3台のCubeでタイミングを合わせた音再生をするのをどうしたらいいか? といろいろ考えましたが、
今回は
Cube1のマットON判定の関数の中でCube2/3/4のBuzzer音再生を行う
ことでこれを実現できました。

const { NearScanner } = require('@toio/scanner')

var midi_note = new Array()
var data_norm = new Array()
data_norm[0] = {x:0,y:0}

const DURATION = 3000

var MIDI_SCALE_C = [0,0,2,4,5,7,9,11,12,12,12]
var scaleList = ["C","C","D","E","F","G","A","B","C","D","D","D"]
var codeList = ["M","m","7","sus4","M7","m7-5","aug","add9","6"]

const X_INI_TOICOLE = 555.5
const X_END_TOICOLE = 946.95
const Y_INI_TOICOLE = 53
const Y_END_TOICOLE = 44.95
const UNIT_TOICOLE = 43.2

var cube_number = 4

var scale = 0
var type = 0

var midi_note =  [
    {uno:60, dos:64, tre:67}, //C major
    {uno:60, dos:63, tre:67}, //m
    {uno:58, dos:64, tre:67}, //7
    {uno:60, dos:65, tre:67}, //sus4
    {uno:59, dos:64, tre:67}, //M7
    {uno:60, dos:63, tre:66}, //m7-5
    {uno:60, dos:64, tre:68}, //aug
    {uno:60, dos:62, tre:67}, //add9
    {uno:60, dos:64, tre:69}, //6
]

function codeController(cubes){
var lastData = {x:0, y:0}
var flag = 0

  cubes[0].on('id:position-id', data1 => {
      var tmp = {x: Math.floor((data1.x - X_INI_TOICOLE) / UNIT_TOICOLE) + 1, y: Math.floor((data1.y - Y_INI_TOICOLE) / UNIT_TOICOLE) + 1}

      if (tmp.x != lastData.x) flag = 0
      if (tmp.y != lastData.y) flag = 0

      if (flag==0){
        scale = tmp.y 
        type = tmp.x - 1
        cubes[1].playSound([{durationMs: DURATION, noteName: midi_note[type].uno + MIDI_SCALE_C[scale]}] ,1)
        cubes[2].playSound([{durationMs: DURATION, noteName: midi_note[type].dos + MIDI_SCALE_C[scale]}] ,1)
        cubes[3].playSound([{durationMs: DURATION, noteName: midi_note[type].tre + MIDI_SCALE_C[scale]}] ,1)
        cubes[1].turnOnLight({durationMs:DURATION, red:0, green:255, blue:255})
        cubes[2].turnOnLight({durationMs:DURATION, red:255, green:0, blue:255})
        cubes[3].turnOnLight({durationMs:DURATION, red:255, green:255, blue:0})

        flag = 1
        console.log('[CODE]', scaleList[scale],codeList[type])
      }

      lastData = tmp
    }
  )
  cubes[0].on('id:standard-id', data2 => console.log('[STD ID]', data2))
  cubes[0].on('id:position-id-missed', () => {
      flag = 0
      cubes[1].stopSound()
      cubes[2].stopSound()
      cubes[3].stopSound()
      cubes[1].turnOffLight()
      cubes[2].turnOffLight()
      cubes[3].turnOffLight()
    }
  )
  cubes[0].on('id:standard-id-missed', () => console.log('[STD ID MISSED]'))
}

function init(cubes){
  cubes[0].turnOnLight({durationMs:DURATION, red:100, green:100, blue:100})
}

async function main() {

  console.log('4cubes')
  console.log('USE Rhythm and Go Mat')

  // start a scanner to find the nearest cube
  const cubes = await new NearScanner(cube_number).start()

  // connect to the cube
  for(var i = 0; i < cube_number; i++) {await cubes[i].connect()}

  init(cubes)
  codeController(cubes)

}

main()

5.マスゲーム

Cubeはやはり動かなきゃ!ということで、モーター制御が使いたくて作った作品です。
モーターを動かすところはtoio.jsのサンプルプログラム chase を参考にしています。

動きとしては極めて単純で、一定時間ごとに異なる目的地へCubeを制御しているだけ。
ただ、この「一定時間ことに」が曲者でした。
toio.jsはイベントドリブンなサンプルコードになっているので、「一定時間ごとに」実行するためのコードの書き方がわかりませんでした。

ここは友人に頼りまして、最強の武器
setinterval()
を教えてもらいました。これを使うことで「一定時間ごと」の処理が記述できました。

単純な動きでも、4つ組み合わさると、面白味が生まれますね。

const { NearScanner } = require('@toio/scanner')

var midi_note = new Array()
var data_norm = new Array()
var ledData = new Array()
var target = new Array()
var diff = new Array()
var cubePos = new Array()

const X_INI_TOICOLE = 555.5
const Y_INI_TOICOLE = 53
const UNIT_TOICOLE = 43.2
const X_BEGIN_TOICOLE = 45
const X_END_TOICOLE = 455
const Y_BEGIN_TOICOLE = 45
const Y_END_TOICOLE = 455
const ANGLE_FULLSCALE = 360
const CUBE_WIDTH = 32

target[0] = {x:145,y:145,angle:90}
target[1] = {x:355,y:145,angle:0}
target[2] = {x:355,y:355,angle:270}
target[3] = {x:145,y:355,angle:180}
cubePos[0] = {x:0,y:0,angle:0}
cubePos[1] = {x:0,y:0,angle:0}
cubePos[2] = {x:0,y:0,angle:0}
cubePos[3] = {x:0,y:0,angle:0}

data_norm[0] = {x:0,y:0}
const DURATION = 0

var cube_number = 4
var flag_gloval = 0

function MoveToTarget(target,mine){
  const diffX = target.x - mine.x
  const diffY = target.y - mine.y
  const distance = Math.sqrt(diffX * diffX + diffY * diffY)

  //calc angle
  var relAngle = (Math.atan2(diffY, diffX) * 180) / Math.PI - mine.angle
  relAngle = relAngle % 360
  if (relAngle < -180) {
    relAngle += 360
  } else if (relAngle > 180) {
    relAngle -= 360
  }

  const ratio = 1 - Math.abs(relAngle) / 90
  let speed = 60 * distance /210

  if (distance < 10) {
    return [0, 0] // stop
  }

  if (relAngle > 0) {
    return [speed, speed * ratio]
  } else {
    return [speed * ratio, speed]
  }
}

function cube_control(cube,cubePosition){
  var lastData = {x:0, y:0, angle:0}
  var lastData2 = {x:0, y:0, angle:0}
  var flag = 0
  var flag_2 = 0

  cube.on('id:position-id', data1 => {
    cubePosition.x = data1.x
    cubePosition.y = data1.y
    cubePosition.angle = data1.angle
    }
  )
}

function setTarget(){
  var tmp = target[0]
  target[0] = target[1]
  target[1] = target[2]
  target[2] = target[3]
  target[3] = tmp
}

async function main() {
  console.log('4cubes')
  console.log('USE Craft fighter Mat')

  // start a scanner to find the nearest cube
  const cubes = await new NearScanner(cube_number).start()

  // connect to the cube
  for(var i = 0; i < cube_number; i++) {await cubes[i].connect()}
  for(var i = 0; i < cube_number; i++) {cube_control(cubes[i],cubePos[i])}

  // loop
  setInterval(() => {
    for(var i = 0; i < cube_number; i++) 
    cubes[i].move(...MoveToTarget(target[i],cubePos[i]), 100)
  }, 50)
  setInterval(() => {
    setTarget()
  }, 3000)

}


main()

さいごに

友人達のサポートも多々ありましたが、初心者でもやればできるものですね。
javascriptはC言語と違って、イベントドリブンでの処理を書くのがとても簡単に出来ているように感じました。
C言語だと、割り込み処理で書かなきゃいけないところが、関数宣言しとけば勝手に実行される みたいな。
処理を『置いておく』感覚で簡単にプログラミングできるのが良かったです。

またいろいろと面白い動きを作っていきたいと思います。

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

Aurora Serverless DB を作って Node.js(TS) から使う

概要

Aurora Serverless DB を作成して、
Node.js (TypeScript) からアクセスしてみます。

実行時の環境 2019/12/04

  • MacOS 10.14.4
  • node v10.15.0
  • npm 6.6.0
  • ts-node v8.5.4
  • aws-sdk 2.584.0

DB の作成

Data API 公式ドキュメントを見ると、
現在、Data API が有効なリージョンは限られているらしいので注意
東京リージョンでつくる。

  • DB 作成方法
項目
テータベース作成方法 標準作成
  • エンジンのオプション
項目
エンジンのタイプ Amazon Aurora
エディション MySQL 互換
バージョン 現行最新: Aurora (MySQL)-5.6.10a
データベースロケーション リージョン別
  • データベースの機能
項目
データベースの機能 サーバーレス
  • 設定

    • マスターパスワード はあとでテストに出るのでノートにとること
  • キャパシティーの設定

    • ACU = Aurora キャパシティーユニット
      • 使用する ACU x 時間に応じて課金が発生する。デフォルト最大値 128 とかいってて怖いから 8 に下げて様子見る。
    • コールドスタート
      • 使ってない時間帯は勝手に止まってくれる
      • 使い始めは 1 分かけてゆるく起動するらしい
      • 開発環境とか社内向けサービスなのでゆるくていい
項目
最小 ACU 1
最大 ACU 8
追加設定 アイドルの場合、コンピューティングを一時停止: 15 分
  • 接続
    • Data API を有効にする
    • そのほかはてきとう
項目
ウェブサービスデータ API Data API
  • 追加設定
    • 基本的にデフォルトのままにした。
    • 最初のデータベース名 …DB 名とは違うのか?
      • MySQL でいう データベーススキーマ と同じと考えていいらしい
      • ややこしいわ
      • 複数のサービスで DB を利用する予定なので、はじめにのせるサービス名にした

DB ができた

  • 作成中ステータスで表示された
  • しばらくまつと 利用可能 になった

Query Editor を使って DB にユーザーを作る

Query Editor への接続

  • RDS メニューから Query Editor へアクセス
    • 作成した DB を選択
    • user: admin
    • password: さっきメモっといたマスターパスワード
      • メモっとかなかったおバカさん(俺)は、DB の設定変更から再設定
  • データベースに接続
  • コンソールが開き、デフォルトのクエリが実行できたら OK
  • せっかくなので、 SHOW DATABASES; してみる
    • DB 作成時に最初のデータベース名に入力していたデータベース(スキーマ)が表示されるはず

開発用ユーザーを作り、データベースへのアクセス権を与える

in-QueryEditor
CREATE USER 'devuser'@'%' IDENTIFIED BY 'YOUR_PASSWORD';
GRANT ALL ON (最初のデータベース名).* TO devuser;

成功を確認したら、作ったユーザーでアクセスしてみる

  • 「データベースを変更する」
    • user: devuser
    • password: YOUR_PASSWORD
    • データベースまたはスキーマ: (最初のデータベース名)
  • 接続してクエリが実行できたら OK

Node.js から DB に接続する

これがやりたかった

Data API 公式ドキュメント から必要な部分を実行していく

Data API にアクセスするためのシークレットを作る

  • Secret Managerから、MySQL ユーザーに対応する Secret を発行する必要がある。
  • しかし、なんと Query Editor からアクセスした時点で Secret が勝手に作られている。便利。

アクセスするサンプルコードを書いて実行してみる (TypeScript)

src/aurora-test.ts
import { RDSDataService } from "aws-sdk";
import { ExecuteStatementRequest } from "aws-sdk/clients/rdsdataservice";

(function testQuery() {
  const rds = new RDSDataService({
    region: "ap-northeast-1",
    accessKeyId: "***",
    secretAccessKey: "***"
  });

  const params: ExecuteStatementRequest = {
    resourceArn: "***", // RDS > データベース > 設定 から参照
    secretArn: "***", // SecretManager > 追加したユーザーのSecret > シークレットのARN
    database: "(最初のデータベース名)",
    sql: "select * from information_schema.tables",
    includeResultMetadata: true
  };

  rds.executeStatement(params, (err, data) => {
    if (err) {
      console.error(err, err.stack);
    } else {
      console.log(`Fetch ${data.records!.length} rows!`);
      console.log(data.columnMetadata!.map(col => col.name).join(","));
      for (const record of data.records!) {
        console.log(record.map(col => Object.values(col)[0]).join(","));
      }
    }
  });
})();

ts-node で実行

ts-node src/aurora-test.ts

> Fetch 69 rows!
> TABLE_CATALOG,TABLE_SCHEMA,TABLE_NAME,TABLE_TYPE,ENGINE,VERSION,ROW_FORMAT,TABLE_ROWS,AVG_ROW_LENGTH,DATA_LENGTH,MAX_DATA_LENGTH,INDEX_LENGTH,DATA_FREE,AUTO_INCREMENT,CREATE_TIME,UPDATE_TIME,CHECK_TIME,TABLE_COLLATION,CHECKSUM,CREATE_OPTIONS,TABLE_COMMENT
> def,information_schema,CHARACTER_SETS,SYSTEM VIEW,MEMORY,10,Fixed,true,384,0,16434816,0,0,true,2019-12-04 07:35:09,true,true,utf8_general_ci,true,max_rows=43690,
> def,information_schema,COLLATIONS,SYSTEM VIEW,MEMORY,10,Fixed,true,231,0,16704765,0,0,true,2019-12-04 07:35:09,true,true,utf8_general_ci,true,max_rows=72628,
> ...

UTF-8 を指定してUnicodeを扱えるようにする

デフォルトの character set が latin とかいうやつで、
日本語が全部 ??? になって困ったので設定を変える。
別記事に切り出した

SSH Tunnel (ec2踏み台) を使って直接接続する

Aurora Serverless はpublic ipを持てない。
ふつーに自由にクエリ書きたいときに困るよねってことで、
@hhrrwwttrr さんにおねがいして、踏み台EC2を作ってもらった。
踏み台を準備してもらうと、Sequel Pro などのクライアントからも直接SSH経由で接続できて便利。

作る手順とかは @hhrrwwttrr さんがわかりやすく書いてくれるって言ってた。

できあがり

認証情報の扱いにはきをつけてつかおうね

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

EventEmitterですべてのイベントを取得する(ワイルドカード)

Node.jsのEventEmitterは便利なんですがすべてのイベントを取得できません。
何で実装していないのかよくわかりません。

EventEmitter2というEventEmitterを便利にしたものがありこれを使えば良いのですが、更新が止まっています...

頑張ればできるんじゃねと思って書いたら数分でできたのメモ的な意味を込めて記事にしてます。

コード

index.js
// いつものEventEmitter
const EventEmitter = require("events");

// いつものEventEmitterを拡張
class ExtendEventEmitter extends EventEmitter {
    // emitされた内容を"*"に再emit
    emit(name, ...args) {
        return super.emit("*", name, ...args);
    }
}

// 拡張したEventEmitter
const event = new ExtendEventEmitter();

// ワイルドカードでイベントを受ける
event.on("*", (name, ...callback) => {
    console.log(`name: ${name} |`, ...callback);
});

/* emit */
event.emit("ready", "ready...");

event.emit("number", 1, 2, 3, 4);

event.emit("array", ["a", "b"]);

event.emit("object", { "abc": 123, "def": 456 });

結果

name: ready  | ready...
name: number | 1 2 3 4
name: array  | [ 'a', 'b' ]
name: object | { abc: 123, def: 456 }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React アプリケーションのボイラープレート CLI を作って使っている話

この記事は ミクシィグループ Advent Calendar 2019 の5日目の記事です。

React で CLI というと create-react-app が有名です。
格好良いベースを作ってくれるのですが個人的には依存 package が多いので、自分用の CLI を作ってそちらを使っています。

@yami-beta/create-ts-app

TypeScript を使ったアプリケーションのベースを作る対話型のインターフェースを持った CLI ツールです。
https://www.npmjs.com/package/@yami-beta/create-ts-app
create-ts-app.gif
意外と色々な package を用意する必要がある ESLint + Prettier の設定を含めていたり、author や LICENSE を設定できます。
(あくまで個人用なので自分の好みによせたボイラープレートになっています)

現在は React のシンプルなボイラープレートしかありませんが

  • React, React Router, Redux 等が含まれた Single Page Application
  • express によるサーバアプリケーション

のボイラープレートを追加していく予定です。

仕組み

この CLI ですが SAO というライブラリを使って実装しています。
create-nuxt-app も SAO を利用していたりします)

以下のようなコードを書くことで対話型のインターフェースを用意したり、テンプレートからファイルをコピーやリネームといったことが出来ます。

module.exports = {
  prompts() {
    return [
      {
        name: 'name',
        message: 'What is the name of the new project',
        default: this.outFolder,
        filter: val => val.toLowerCase()
      }
    ]
  },
  actions: [
    {
      type: 'add',
      files: '**'
    },
    {
      type: "move",
      patterns: {
        "LICENSE_*": "LICENSE"
      }
    }
  ],
  async completed() {
    this.gitInit()
    await this.npmInstall()
    this.showProjectTips()
  }
}

@yami-beta/create-ts-app では このような実装 になっています。
一部を抜粋すると、以下のようにコマンド実行時の回答に応じて package.json に記載する依存関係を編集することも可能です。

const config = {
  actions() {
    const { answers } = this;
    return [
      // 略
      {
        type: "modify",
        files: "package.json",
        handler(data: any, filepath: string) {
          return {
            name: answers.name || data.name,
            version: answers.version || data.version,
            main: data.main,
            author: answers.author,
            license: answers.license || data.license,
            scripts: data.scripts,
            dependencies: {
              ...data.dependencies
            },
            devDependencies: {
              ...data.devDependencies,
              "@typescript-eslint/eslint-plugin": answers.features.includes(
                "eslint"
              )
                ? data.devDependencies["@typescript-eslint/eslint-plugin"]
                : undefined,
              "@typescript-eslint/parser": answers.features.includes("eslint")
                ? data.devDependencies["@typescript-eslint/parser"]
                : undefined,
              eslint: answers.features.includes("eslint")
                ? data.devDependencies["eslint"]
                : undefined,
              "eslint-config-prettier":
                answers.features.includes("eslint") &&
                answers.features.includes("prettier")
                  ? data.devDependencies["eslint-config-prettier"]
                  : undefined,
              "eslint-plugin-prettier":
                answers.features.includes("eslint") &&
                answers.features.includes("prettier")
                  ? data.devDependencies["eslint-plugin-prettier"]
                  : undefined,
              prettier: answers.features.includes("prettier")
                ? data.devDependencies["prettier"]
                : undefined
            }
          };
        }
      },
      // 略
    ].filter(Boolean);
  }
};

CLI を作るほどでもない場合

ボイラープレートは欲しいけれども CLI を作るほどでは無い、という場合もあるかと思います。
そういう場合は GitHub のテンプレートリポジトリでボイラープレートを活用する方法があります。

詳細は上記のドキュメントを参照してください。

まとめ

  • React アプリケーションのボイラープレートを生成する CLI を作っている
    • テンプレートからファイルをコピー、リネーム、編集することが出来るので複数のボイラープレート生成が可能
  • 手軽にボイラープレートを作る場合は GitHub のテンプレートリポジトリが活用出来そう

備考

  • SAO という見覚えのある名前ですが egoist 氏のライブラリです
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

C10K問題とNode.js

C10K問題(クライアント1万台問題)

  • アクセスするクライアント数が1万を超えると、サーバーのスレッド(並列処理の単位)数が増え、サーバーのメモリーなどのリソースが不足してしまう問題
  • 処理能力に余裕があっても、クライアントの数が多くなると効率が悪化しサーバがパンクする
  • プロセッサの処理能力には余裕があっても、サーバの台数を増やさなければいけなくなってしまう

回避方法

  • サーバーサイドではイベント駆動方式を利用しているNode.jsなどを使用する
    • イベント駆動により大量のリクエストを同時に処理できるスケーラビリティを備えている
    • ノンブロッキングI/Oモデルにより、C10K問題に対応する

Node.jsとは

スケーラブルなネットワークアプリケーションを構築するために設計された非同期型のイベント駆動のJavaScript環境
Node.js

  • それぞれの意味
    • スケーラブル : 拡張性が高い
    • 非同期 : 各要求(request)の処理が完了するのを待たずに、それ以降の処理を行う方式
    • イベント駆動 : イベントと呼ばれるアプリや端末上で起きた出来事に対して処理を行うプログラムの実行形式
  • 特徴
    • サーバーサイドで使用できる
    • ノンブロッキングI/Oモデルを採用しており、I/Oの処理を待たずに次の処理を始めることができるので、大量のデータ処理が可能
      • ノンブロッキング : ある処理を行いながら、ほかの処理も同時進行で行えること
      • I/O : Input/Outputの略で、入出力の意

参照

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

Microsoft Custom Vision Serviceによる中耳炎画像認識LINE Botの作成

概要

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

前回、Microsoft Custom Vision Service を使用して鼓膜画像認識を試し、極めて高い診断精度でした。
Microsoft Custom Vision Service を使用した鼓膜画像認識

前回は「正常鼓膜」か、「急性中耳炎」か、「滲出性中耳炎」かを分けるためのタグだけでしたが、今回は急性中耳炎の重症度を判定できるようにするため「鼓膜の発赤の程度」、「鼓膜の腫脹の程度」、「耳漏の有無」に関するタグに追加しました。

さらに、LINE Botと連携しNowでデプロイしました。

実装

スマホから鼓膜の写真をLINE Bot宛てに送ると、中耳炎かどうか応えてくれるLINE Bot。

概念図

image.png

動作確認

作成方法

1.タグの付けなおし

以下のようにタグを付けなおしていきます。
正常鼓膜は「正常鼓膜」「発赤:なし」「腫脹:なし」「耳漏:なし」のタグを、
滲出性中耳炎は「滲出性中耳炎」「発赤:なし」「腫脹:なし」「耳漏:なし」のタグを、
急性中耳炎は「急性中耳炎」そして鼓膜発赤の程度により「発赤:なし」「発赤:一部」「発赤:全体」のタグを、鼓膜腫脹の程度により「腫脹:なし」「腫脹:一部」「腫脹:全体」のタグを、耳漏の有無により「耳漏:なし」「耳漏:あり」のタグを付けました。

2.再トレーニング

タグが多くなったためか全体の精度が落ちました。

image.png

正確な判定のためにはタグ毎に最低30枚の画像が必要なようですが、一部30枚未満のタグができてしまいました。

image.png

3.テスト

テストデータ30枚をテストします。
「正常鼓膜」か「急性中耳炎」か「滲出性中耳炎」かの診断は前回同様100%正解しました。
急性中耳炎の重症度判定に使用する「発赤の程度」「腫脹の程度」は間違っているところがありました。

発赤全体 ➡ 一部 が正解
image.png
発赤全体 ➡ 一部 が正解
image.png

正解
image.png
正解
image.png
正解
image.png
正解
image.png
腫脹一部 ➡ 全体 が正解
image.png
正解
image.png

4.LINE Bot との連携

Azure Custom Vision ServicesのPerformanceからPublishをクリックし、Prediction APIを発行します。

「If you have an image file:」のURLと
「Set Prediction-Key Header to :」のKeyを後で使うのでひかえておきます。

5.LINE BoTの作成

こちらの記事を参考にしました。
はやい!やすい!うまい!Custom Vision と LINE bot でお寿司の判定をしてみた
LINE動物図鑑の作り方

このようなコードを書きました。

'use strict';

const express = require('express');
const line = require('@line/bot-sdk');
const PORT = process.env.PORT || 3000;
const fs = require('fs');
const bodyParser = require('body-parser');
const Request = require('request');
const cv = require('customvision-api');

const config = {
    channelSecret: '自分のchannelSecret',
    channelAccessToken: '自分のchannelAccessToken'
};


const app = express();
app.use(bodyParser.json());
let middle = line.middleware(config);
const client = new line.Client(config);

app.post('/webhook', (req, res) => {
            console.log(req.body.events);

            if(req.body.events[0].message.type !== 'image') return;

// ユーザーがLINE Bot宛てに送った写真のURLを取得する
    const options = {
        url: `https://api.line.me/v2/bot/message/${req.body.events[0].message.id}/content`,
        method: 'get',
        headers: {
            'Authorization': 'Bearer 自分のchannelAccessToken'  ,
        },
        encoding: null
    };

    Request(options, function(error, response, body) {

        if (!error && response.statusCode == 200) {
            //保存

            console.log(options.url + '/image.jpg');
            let strURL = options.url + '/image.jpg';

            //Nowでデプロイする場合は、/tmp/のパスが重要
            fs.writeFileSync(`/tmp/` + req.body.events[0].message.id + `.png`, new Buffer(body), 'binary');

            const filePath = `/tmp/` + req.body.events[0].message.id + `.png`;

//Azure Custom Vision APIの設定
            const config = {
            "predictionEndpoint": "ひかえておいたURL",
            "predictionKey": 'ひかえておいたKey'
            };

            cv.sendImage(
                filePath,
                config,
                (data) => {
                  console.log(data); 
                  let Probability0 = data.predictions[0].probability * 100;
                  let Probability1 = data.predictions[1].probability * 100;
                  let Probability2 = data.predictions[2].probability * 100;
                  let Probability3 = data.predictions[3].probability * 100;
                  let Probability4 = data.predictions[4].probability * 100;

                    let strName0 = data.predictions[0].tagName;
                    let strProbability0 = Probability0.toFixed();
                    let strName1 = data.predictions[1].tagName;
                    let strProbability1 = Probability1.toFixed();
                    let strName2 = data.predictions[2].tagName;
                    let strProbability2 = Probability2.toFixed();
                    let strName3 = data.predictions[3].tagName;
                    let strProbability3 = Probability3.toFixed();
                    let strName4 = data.predictions[4].tagName;
                    let strProbability4 = Probability4.toFixed();

                    client.replyMessage(req.body.events[0].replyToken, {
                        type: 'text',
                      text:strName0 + ':'+strProbability0+'%,\n'+ strName1 + ':'+strProbability1+'%,\n'+ strName2 + ':'+strProbability2+'%,\n'+ strName3 + ':'+strProbability3+'%\n'+ strName4 + ':'+strProbability4+'%'  //実際に返信の言葉を入れる箇所
                      }); 

                      try {
                        fs.unlinkSync(filePath);
                        return true;
                      } catch(err) {
                        return false;
                      }


                    return; 

                },
                (error) => { console.log(error) }
            );

        } else {
            console.log('imageget-err');
        }
    });
});

(process.env.NOW_REGION) ? module.exports = app : app.listen(PORT);
console.log(`Server running at ${PORT}`);

6.Nowでデプロイ

こちらの記事を参考にしました。
1時間でLINE BOTを作るハンズオン (資料+レポート) in Node学園祭2017 #nodefest

考察

結構簡単にAIによる画像認識モデルとLINE BoTを連携できました。
今後は重症度判定に必要なタグを含んだ急性中耳炎の画像を増やし、精度を上げていきたいと思います。
そして以前作った中耳炎診療ガイドラインに沿った診断や治療選択ができるBOT
急性中耳炎診断支援LINE Botを改良しHerokuにデプロイ
に組み込んで、質問に返答し鼓膜の画像を送れば、自動で診断や治療方針が決定させるBOTを作成したいと思います。

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

AIによる中耳炎画像認識LINE Botの作成

概要

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

前回、Microsoft Custom Vision Service を使用して鼓膜画像認識を試し、極めて高い診断精度でした。
Microsoft Custom Vision Service を使用した鼓膜画像認識

前回は「正常鼓膜」か、「急性中耳炎」か、「滲出性中耳炎」かを分けるためのタグだけでしたが、今回は急性中耳炎の重症度を判定できるようにするため「鼓膜の発赤の程度」、「鼓膜の腫脹の程度」、「耳漏の有無」に関するタグに追加しました。

さらに、LINE Botと連携しNowでデプロイしました。

実装

スマホから鼓膜の写真をLINE Bot宛てに送ると、中耳炎かどうか応えてくれるLINE Bot。

概念図

image.png

動作確認

作成方法

1.タグの付けなおし

以下のようにタグを付けなおしていきます。
正常鼓膜は「正常鼓膜」「発赤:なし」「腫脹:なし」「耳漏:なし」のタグを、
滲出性中耳炎は「滲出性中耳炎」「発赤:なし」「腫脹:なし」「耳漏:なし」のタグを、
急性中耳炎は「急性中耳炎」そして鼓膜発赤の程度により「発赤:なし」「発赤:一部」「発赤:全体」のタグを、鼓膜腫脹の程度により「腫脹:なし」「腫脹:一部」「腫脹:全体」のタグを、耳漏の有無により「耳漏:なし」「耳漏:あり」のタグを付けました。

2.再トレーニング

タグが多くなったためか全体の精度が落ちました。

image.png

正確な判定のためにはタグ毎に最低30枚の画像が必要なようですが、一部30枚未満のタグができてしまいました。

image.png

3.テスト

テストデータ30枚をテストします。
「正常鼓膜」か「急性中耳炎」か「滲出性中耳炎」かの診断は前回同様100%正解しました。
急性中耳炎の重症度判定に使用する「発赤の程度」「腫脹の程度」は間違っているところがありました。

発赤全体 ➡ 一部 が正解
image.png
発赤全体 ➡ 一部 が正解
image.png

正解
image.png
正解
image.png
正解
image.png
正解
image.png
腫脹一部 ➡ 全体 が正解
image.png
正解
image.png

4.LINE Bot との連携

Azure Custom Vision ServicesのPerformanceからPublishをクリックし、Prediction APIを発行します。

「If you have an image file:」のURLと
「Set Prediction-Key Header to :」のKeyを後で使うのでひかえておきます。

5.LINE BoTの作成

こちらの記事を参考にしました。
はやい!やすい!うまい!Custom Vision と LINE bot でお寿司の判定をしてみた
LINE動物図鑑の作り方

このようなコードを書きました。

'use strict';

const express = require('express');
const line = require('@line/bot-sdk');
const PORT = process.env.PORT || 3000;
const fs = require('fs');
const bodyParser = require('body-parser');
const Request = require('request');
const cv = require('customvision-api');

const config = {
    channelSecret: '自分のchannelSecret',
    channelAccessToken: '自分のchannelAccessToken'
};


const app = express();
app.use(bodyParser.json());
let middle = line.middleware(config);
const client = new line.Client(config);

app.post('/webhook', (req, res) => {
            console.log(req.body.events);

            if(req.body.events[0].message.type !== 'image') return;

// ユーザーがLINE Bot宛てに送った写真のURLを取得する
    const options = {
        url: `https://api.line.me/v2/bot/message/${req.body.events[0].message.id}/content`,
        method: 'get',
        headers: {
            'Authorization': 'Bearer 自分のchannelAccessToken'  ,
        },
        encoding: null
    };

    Request(options, function(error, response, body) {

        if (!error && response.statusCode == 200) {
            //保存

            console.log(options.url + '/image.jpg');
            let strURL = options.url + '/image.jpg';

            //Nowでデプロイする場合は、/tmp/のパスが重要
            fs.writeFileSync(`/tmp/` + req.body.events[0].message.id + `.png`, new Buffer(body), 'binary');

            const filePath = `/tmp/` + req.body.events[0].message.id + `.png`;

//Azure Custom Vision APIの設定
            const config = {
            "predictionEndpoint": "ひかえておいたURL",
            "predictionKey": 'ひかえておいたKey'
            };

            cv.sendImage(
                filePath,
                config,
                (data) => {
                  console.log(data); 
                  let Probability0 = data.predictions[0].probability * 100;
                  let Probability1 = data.predictions[1].probability * 100;
                  let Probability2 = data.predictions[2].probability * 100;
                  let Probability3 = data.predictions[3].probability * 100;
                  let Probability4 = data.predictions[4].probability * 100;

                    let strName0 = data.predictions[0].tagName;
                    let strProbability0 = Probability0.toFixed();
                    let strName1 = data.predictions[1].tagName;
                    let strProbability1 = Probability1.toFixed();
                    let strName2 = data.predictions[2].tagName;
                    let strProbability2 = Probability2.toFixed();
                    let strName3 = data.predictions[3].tagName;
                    let strProbability3 = Probability3.toFixed();
                    let strName4 = data.predictions[4].tagName;
                    let strProbability4 = Probability4.toFixed();

                    client.replyMessage(req.body.events[0].replyToken, {
                        type: 'text',
                      text:strName0 + ':'+strProbability0+'%,\n'+ strName1 + ':'+strProbability1+'%,\n'+ strName2 + ':'+strProbability2+'%,\n'+ strName3 + ':'+strProbability3+'%\n'+ strName4 + ':'+strProbability4+'%'  //実際に返信の言葉を入れる箇所
                      }); 

                      try {
                        fs.unlinkSync(filePath);
                        return true;
                      } catch(err) {
                        return false;
                      }


                    return; 

                },
                (error) => { console.log(error) }
            );

        } else {
            console.log('imageget-err');
        }
    });
});

(process.env.NOW_REGION) ? module.exports = app : app.listen(PORT);
console.log(`Server running at ${PORT}`);

6.Nowでデプロイ

こちらの記事を参考にしました。
1時間でLINE BOTを作るハンズオン (資料+レポート) in Node学園祭2017 #nodefest

考察

結構簡単にAIによる画像認識モデルとLINE BoTを連携できました。
今後は重症度判定に必要なタグを含んだ急性中耳炎の画像を増やし、精度を上げていきたいと思います。
そして以前作った中耳炎診療ガイドラインに沿った診断や治療選択ができるBOT
急性中耳炎診断支援LINE Botを改良しHerokuにデプロイ
に組み込んで、質問に返答し鼓膜の画像を送れば、自動で診断や治療方針が決定させるBOTを作成したいと思います。

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

NestJSで始めるGraphQLサーバ開発(コードファースト編)

image.png
NestJSは、TypeScriptで記述するバックエンドアプリケーションフレームワークです。デフォルトで DI(Dependency Injection) の仕組みをサポートしており、テスト可能な構成を簡単に作ることができる特徴があります。
今回の記事ではNestJSを使用して最もシンプルなGraphQLサーバを構築します。
↓完成イメージ
nestjs-graphql.gif

GraphQLの基本

GraphQLは、RESTエンドポイントのように煩雑に管理されたエンドポイントではなく、1つのエンドポイントに対して厳密に型指定されたスキーマとしてAPIを実行します。

image.png

GraphQLについて深くは解説しませんが、以下のリンクがとても参考になります。初学者は一読しておくことをオススメします。

NestJSでGraphQL

NestJSを使用したGraphQLの開発には2つの方法があります。

  • スキーマファースト
  • コードファースト

スキーマファーストのアプローチでは GraphQL SDL(スキーマ定義言語)をもとにしてTypeScript定義を自動的に生成します。
一方でコードファーストのアプローチでは、デコレータとTypeScriptのクラスのみを使用して対応する GraphQL スキーマを生成します。

今回はコードファーストのアプローチでGraphQLサーバを作成していきます。
まず始めに nestjsのコマンドラインツール@nestjs/cli をインストールしましょう。インストールができたら nest コマンドが使用できます。早速 NestJSアプリケーションを作成します。

$ npm i -g @nestjs/cli
$ nest new nest-graphql

作成されたNestJSアプリケーションを起動しましょう。

$ cd nest-graphql/
$ npm run start

> nest-graphql@0.0.1 start /Users/daisuke/work/nest-graphql
> ts-node -r tsconfig-paths/register src/main.ts

[Nest] 5868   - 2019-12-03 21:36:33   [NestFactory] Starting Nest application...
[Nest] 5868   - 2019-12-03 21:36:33   [InstanceLoader] AppModule dependencies initialized +28ms
[Nest] 5868   - 2019-12-03 21:36:33   [RoutesResolver] AppController {/}: +10ms
[Nest] 5868   - 2019-12-03 21:36:33   [RouterExplorer] Mapped {/, GET} route +16ms
[Nest] 5868   - 2019-12-03 21:36:33   [NestApplication] Nest application successfully started +6ms

ブラウザで localhost:3000 にアクセスして Hello Wold! が表示されれば準備OKです。
この状態ではまだRESTAPIの形式になっていますね。
image.png

GraphQL 関連ライブラリのインストール

GraphQLサーバを実装していきますので、まずは必要なライブラリをインストールします。

$ npm i --save @nestjs/graphql \
               apollo-server-express \
               graphql-tools \
               graphql \
               type-graphql

REST API用に作られていた app.module.ts を書き換えましょう。
Controller, Service の箇所を GraphQLModule として書き換えました。
.forRoot() メソッドで playground: true を宣言することで ブラウザ(http://localhost:3000/graphql)で GraphQL IDEを表示できます。autoSchemaFile は自動的に生成されたスキーマが作成されるパスを示しています

app.module.ts
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';

@Module({
  imports: [
    GraphQLModule.forRoot({
      playground: true,
      autoSchemaFile: 'schema.graphql'
    }),
  ],
})
export class AppModule {}

playground はアプリケーションがバックグラウンドで実行されている間に、Webブラウザーを開いて http://localhost:3000/graphql にアクセスすると表示できます。npm run start を実行してアプリケーションを起動してからブラウザを開いてみましょう。

$ npm run start

> nest-graphql@0.0.1 start /Users/daisuke/work/nest-graphql
> ts-node -r tsconfig-paths/register src/main.ts

[Nest] 8832   - 2019-12-03 23:44:15   [NestFactory] Starting Nest application...
[Nest] 8832   - 2019-12-03 23:44:15   [InstanceLoader] AppModule dependencies initialized +26ms
[Nest] 8832   - 2019-12-03 23:44:15   [InstanceLoader] RecipesModule dependencies initialized +1ms
[Nest] 8832   - 2019-12-03 23:44:15   [InstanceLoader] GraphQLModule dependencies initialized +0ms
[Nest] 8832   - 2019-12-03 23:44:15   [NestApplication] Nest application successfully started +82ms

image.png

Moduleを作成

NestJSの流儀に従って、まずはModuleを作成します。例としてレシピの一覧が表示できるアプリケーションを想定しています。

$ nest generate module recipes
CREATE /src/recipes/recipes.module.ts (84 bytes)
UPDATE /src/app.module.ts (325 bytes)

app.module.ts に自動的に RecipesModule が追加されるので確認しておきましょう。

import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { RecipesModule } from './recipes/recipes.module';

@Module({
  imports: [
    GraphQLModule.forRoot({
      playground: true,
      autoSchemaFile: 'schema.graphql',
    }),
    RecipesModule, // <-- 自動的に追加される
  ],
})
export class AppModule {}

Modelを作成

次に Model を作成します。
type-graphql のライブラリから各種デコレータで宣言するものを import します。

$ nest generate class recipes/recipe
CREATE /src/recipes/recipe.spec.ts (147 bytes)
CREATE /src/recipes/recipe.ts (23 bytes)
import { Field, ID, ObjectType } from 'type-graphql';

@ObjectType()
export class Recipe {
  @Field(type => ID)
  id: string;

  @Field()
  title: string;
}

Resolverを作成

最後にクエリの操作を行うリゾルバを作成します。

$ nest generate resolver recipes
CREATE /src/recipes/recipes.resolver.spec.ts (477 bytes)
CREATE /src/recipes/recipes.resolver.ts (98 bytes)
UPDATE /src/recipes/recipes.module.ts (170 bytes)

このResolverに Query、Mutation、Subscriptionを実装していきます。
今回は簡単のため、データベースには接続せずにレシピの一覧を返却する処理(Query)を実装しています。

import { Resolver, Query, Args } from '@nestjs/graphql';
import { Recipe } from './recipe';

const recipeTable = [
  {
    id: '1',
    title: '鯖の味噌煮',
  },
  {
    id: '2',
    title: 'ミートソーススパゲティ',
  },
  {
    id: '3',
    title: '豚の生姜焼',
  },
];

@Resolver('Recipes')
export class RecipesResolver {
  @Query(returns => [Recipe])
  async recipes(): Promise<Recipe[]> {
    return recipeTable;
  }
}

ここまででディレクトリ構成は以下のようになっています。

src$ tree -L 2
.
├── app.module.ts
├── main.ts
└── recipes
    ├── recipe.spec.ts
    ├── recipe.ts
    ├── recipes.module.ts
    ├── recipes.resolver.spec.ts
    └── recipes.resolver.ts

スキーマの作成

あとはアプリケーションを起動するとスキーマが自動的に作成されます。

$ npm run start

scema.graphql にスキーマが自動的に作成されています。

# -----------------------------------------------
# !!! THIS FILE WAS GENERATED BY TYPE-GRAPHQL !!!
# !!!   DO NOT MODIFY THIS FILE BY YOURSELF   !!!
# -----------------------------------------------

type Query {
  recipes: [Recipe!]!
}

type Recipe {
  id: ID!
  title: String!
}

動作確認をしましょう。
http://localhost:3000/graphql にアクセスしてクエリを実行します。
確かにフィールドごとに選択されて Query が実行できていますね。
nestjs-graphql.gif

NestJSを使用することで、モデルに対してデコレータを付与するだけでシンプルかつ簡単に実装できました。

NestJSは適切にDIをすることでコードのテスタビリティをあげることができる強力なフレームワークです。
GraphQLサーバを組む場合にも威力を発揮できる可能性があり魅力的ですね。

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

SeleniumでSortableJS系ライブラリのDrag&Dropをテストする

前置き

前回の記事で、Vue.Draggableを使ったコンポーネントのドラッグ&ドロップを実行するCypressのテストコードについて書きました。
これをSeleniumで書いたらどうなるだろうと思い試してみたところCypress以上にハマったので、解決方法を記録しておきます。1

本記事内のドラッグ&ドロップのテストコードは、Vue.Draggableに限らずSortableJSベースのライブラリなら概ね動くものになります。
以下の公式サイトのデモにて検証しています。(2019/12/3時点)

※react-sortablejsと他の3種類とでは若干テストコードが変わります。
本文内ではSortableJSとreact-sortablejsのデモページに対するテストコードを掲載しています。使用言語はNode.jsとRubyです。

環境

  • OS: Mac OS X 10.14.6 Mojave
  • Node.js
    • Node.js: v12.13.1
    • selenium-webdriver: 4.0.0-alpha.5
    • Mocha: 6.2.2
  • Ruby
    • Ruby: 2.6.5
    • selenium-webdriver: 3.142.6
    • minitest: 5.13.0
  • Browser
    • Google Chrome: 78.0.3904.108(Official Build)
    • chromedriver: 78.0.3904.105(Homebrewにてインストール)
    • Firefox: 70.0.1 (64 ビット)
    • geckodriver: 0.26.0(Homebrewにてインストール)
    • Safari: 13.0.3
    • safaridriver: 1.0
  • Library(公式のデモで使用されていると思われるバージョン)
    • SortableJS: 1.10.0-rc3
    • Vue.Draggable: 2.23.2
    • react-sortablejs: 1.5.1
    • ngx-sortablejs: 3.1.3

ドラッグ&ドロップが動作するテストコード(Node.js版)

SortableJSの公式のデモページにアクセスし、Simple list example の Item 1 を Item 2 にドラッグ&ドロップして、テキストが入れ替わることを確認するテストコードです。
テストフレームワークはMochaを、アサーションはNode.jsのassertモジュールを使用しています。
マニュアル操作では以下のGIFアニメのようになります。
sortablejs.gif

test.js
const { Builder, By } = require('selenium-webdriver')
const assert = require('assert')

describe('Drag and Drop test', function () {
  // ブラウザの起動を待つあいだにMochaがタイムアウトしてしまうのを防止
  this.timeout(20 * 1000)

  let driver

  beforeEach(async () => {
    driver = await new Builder()
      .forBrowser('chrome') // Chromeを使う場合
      // .forBrowser('firefox') // Firefoxを使う場合
      // .forBrowser('safari')  // Safariを使う場合
      .build()
  })

  afterEach(async () => {
    await driver.quit()
  })

  it('SortableJS', async () => {
    // SortableJSの公式デモページにアクセス
    await driver.get('https://sortablejs.github.io/Sortable/#simple-list')

    // ドラッグ&ドロップの対象を含むdiv要素のリストを取得
    let elements
    elements = await driver.findElements(By.css('div#example1 > div.list-group-item'))
    // ドラッグ元(Item 1)とドロップ先(Item 2)のdiv要素を取得
    const sourceElement = await elements[0]
    const targetElement = await elements[1]

    // ドラッグ&ドロップを実行する関数の呼び出し
    await simulateDragAndDrop(sourceElement, targetElement)

    // Item 1 と Item 2 が入れ替わったことを確認
    elements = await driver.findElements(By.css('div#example1 > div.list-group-item'))
    assert.strictEqual(await elements[0].getText(), 'Item 2')
    assert.strictEqual(await elements[1].getText(), 'Item 1')
  })

  /**
   * ドラッグ&ドロップを実行する関数
   */
  async function simulateDragAndDrop(sourceElement, targetElement) {
    await driver.executeScript(
      async args => {
        // dragoverイベントの発火位置を計算
        const targetRect = args.targetElement.getBoundingClientRect()
        const targetPositionX = (targetRect.left + targetRect.right) / 2
        const targetPositionY = (targetRect.top + targetRect.bottom) / 2

        // ドラッグ&ドロップに必要な各イベントのインスタンスオブジェクトを作成
        const pointerDownEvent = new PointerEvent('pointerdown', {
          bubbles: true,
          cancelable: true,
        })

        const dragStartEvent = new MouseEvent('dragstart', {
          bubbles: true,
        })

        const dragOverEvent = new MouseEvent('dragover', {
          bubbles: true,
          clientX: targetPositionX,
          clientY: targetPositionY,
        })

        const dropEvent = new MouseEvent('drop', {
          bubbles: true,
        })

        // sleep処理用の関数を定義
        const sleep = msec => new Promise(resolve => setTimeout(resolve, msec))

        // イベントの発火
        args.sourceElement.dispatchEvent(pointerDownEvent)
        args.sourceElement.dispatchEvent(dragStartEvent)
        await sleep(1)
        args.targetElement.dispatchEvent(dragOverEvent)
        args.targetElement.dispatchEvent(dropEvent)

      }, { sourceElement, targetElement }
    )
  }
})

テストコードの解説

SortableJSを使用した要素のドラッグ&ドロップを実行するには、以下の4つのイベントの発火が必要になります。

  1. pointerdown
  2. dragstart
  3. dragover
  4. drop

selenium-webdriver本体にもドラッグ&ドロップ機能は実装されていますし(公式ドキュメント)、ドラッグ&ドロップ操作のための外部ライブラリもいくつか公開されています。
しかし試してみた範囲では、いずれも何かしらのイベントの発火が足りずドラッグ&ドロップは期待通りに動作しませんでした。

Cypressのように必要なイベントを個別に発火させることができればよさそうなのですが、selenium-webdriverにはそういった機能はないようです。
そのため、素のJavaScriptでイベントを発火させる処理を書き、それをselenium-webdriverの executeScript() を使って実行するという方法をとることになりました。

JavaScriptを書く際のポイントが何点かありましたので説明します。

ポイント1
dragover イベントのインスタンス作成時のコンストラクタで、イベントを発火させる位置を指定しておく必要があります。
getBoundingClientRect() でドロップ対象要素の viewport に対する位置を取得し、それをもとに対象要素の中央にあたる位置を計算して、その値をコンストラクタの clientXclientY に設定しました。

test.js
        // dragoverイベントの発火位置を計算
        const targetRect = args.targetElement.getBoundingClientRect()
        const targetPositionX = (targetRect.left + targetRect.right) / 2
        const targetPositionY = (targetRect.top + targetRect.bottom) / 2

        // 中略

        // dragoverイベントのコンストラクタでイベントの発火位置を指定
        const dragOverEvent = new MouseEvent('dragover', {
          bubbles: true,
          clientX: targetPositionX,
          clientY: targetPositionY,
        })

ポイント2
dragstart と dragover を順に dispatchEvent する際、あいだに sleep を挟む必要があります。
sleep が必要になる根本的な理由がまだ突き止められていないのですが、ひとまず動いたのでよしとしています。

test.js
        // sleep処理用の関数を定義
        const sleep = msec => new Promise(resolve => setTimeout(resolve, msec))

        // イベントの発火
        args.sourceElement.dispatchEvent(pointerDownEvent)
        args.sourceElement.dispatchEvent(dragStartEvent)
        // ここでsleepが必要
        await sleep(1)
        args.targetElement.dispatchEvent(dragOverEvent)
        args.targetElement.dispatchEvent(dropEvent)

ポイント3
MacのSafariをテスト対象とする場合ですが、Safariでは DragEvent をnewできません。(Chrome、Firefoxではできます)
そのためドラッグ系のイベントでも MouseEvent を使っています。
MDN にも Can I use... にもSafariはDragEventをサポートしていると書かれているのですが、Safariのコンソールで直接コードを叩いてみても ReferenceError: Can't find variable: DragEvent と返ってきてしまいました。

test.js
        // Safariでは new DragEvent と書くと動作しない
        const dragStartEvent = new MouseEvent('dragstart', {
          bubbles: true,
        })

        const dragOverEvent = new MouseEvent('dragover', {
          bubbles: true,
          clientX: targetPositionX,
          clientY: targetPositionY,
        })

        const dropEvent = new MouseEvent('drop', {
          bubbles: true,
        })

ポイント4
前置きにも書きましたがreact-sortablejsのデモの場合、前出のテストコードではドラッグ&ドロップが動作しません。

react-sortablejsでは、dragstart イベントが発火した際に、イベントターゲットとなった要素が2つに増えるという挙動をします。
react-sortablejs.gif
この要素の増加により、リスト内でのドロップ先要素の index がずれてしまい、目的のドロップ先に dragover できなくなるケースが発生します。それに対応するため処理に手を加えなければなりません。

要素数の増加に対応したテストコードの例が以下になります。

ドラッグ&ドロップが動作するテストコード(Node.js + react-sortablejs版)

react-sortablejsの公式のデモページにアクセスし、Simple List の List Item 1 を List Item 2 にドラッグ&ドロップしてテキストが入れ替わることを確認するテストコードです。

記事が長くなるので折りたたみます。

react-sortablejsのテストコード例
test.js
// requireやbefore/after部分は前出のテストコードと共通

  it('react-sortable', async () => {
    // react-sortablejsの公式デモページにアクセス
    await driver.get('http://sortablejs.github.io/react-sortablejs/#container')

    let elements, sourceElementIndex, targetElementIndex

    // ドラッグ&ドロップの対象を含むli要素のリストを取得
    elements = await driver.findElements(By.css('ul.block-list > li'))
    // ドラッグ元(List Item 1)とドロップ先(List Item 2)のli要素の、リスト内でのindexを定義
    sourceElementIndex = 0
    targetElementIndex = 1

    // ドラッグ&ドロップを実行する関数の呼び出し
    await simulateDragAndDropForReact(elements, sourceElementIndex, targetElementIndex)

    // List Item 1 と List Item 2 が入れ替わったことを確認
    elements = await driver.findElements(By.css('ul.block-list > li'))
    assert.strictEqual(await elements[0].getText(), 'List Item 2')
    assert.strictEqual(await elements[1].getText(), 'List Item 1')
  })

  /**
   * ドラッグ&ドロップを実行する関数
   */
  async function simulateDragAndDropForReact(elements, sourceElementIndex, targetElementIndex) {
    await driver.executeScript(
      async args => {
        // dragoverイベントの発火位置を計算
        const targetRect = args.elements[args.targetElementIndex].getBoundingClientRect()
        const targetPositionX = (targetRect.left + targetRect.right) / 2
        const targetPositionY = (targetRect.top + targetRect.bottom) / 2

        // ドラッグ&ドロップに必要な各イベントのインスタンスオブジェクトを作成
        const pointerDownEvent = new PointerEvent('pointerdown', {
          bubbles: true,
          cancelable: true,
        })

        const dragStartEvent = new MouseEvent('dragstart', {
          bubbles: true,
        })

        const dragOverEvent = new MouseEvent('dragover', {
          bubbles: true,
          clientX: targetPositionX,
          clientY: targetPositionY,
        })

        const dropEvent = new MouseEvent('drop', {
          bubbles: true,
        })

        // sleep処理用の関数を定義
        const sleep = msec => new Promise(resolve => setTimeout(resolve, msec))

        // ドラッグ元の要素よりもドロップ先の要素が要素リストの後ろにある場合、
        // dragover発火時にイベントターゲットとなるドロップ先要素のindexを+1する
        const adjustIndex = args.sourceElementIndex < args.targetElementIndex ? 1 : 0

        // イベントの発火
        args.elements[args.sourceElementIndex].dispatchEvent(pointerDownEvent)
        args.elements[args.sourceElementIndex].dispatchEvent(dragStartEvent)
        await sleep(1)
        args.elements[args.targetElementIndex + adjustIndex].dispatchEvent(dragOverEvent)
        args.elements[args.targetElementIndex].dispatchEvent(dropEvent)

      }, { elements, sourceElementIndex, targetElementIndex }
    )
  }

ドラッグ&ドロップが動作するテストコード(Ruby版)

Rubyでは以下のように書くことができます。2
テストフレームワークはminitestを使用しています。

記事が長くなるので折りたたみます。

Rubyのテストコード例
test.rb
require 'selenium-webdriver'
require 'minitest/autorun'

describe 'Drag and Drop test' do
  driver = nil

  before do
    driver = Selenium::WebDriver.for :chrome  # Chromeを使う場合
    # driver = Selenium::WebDriver.for :firefox # Firefoxを使う場合
    # driver = Selenium::WebDriver.for :safari  # Safariを使う場合
  end

  after do
    driver.quit
  end

  it 'SortableJS' do
    # SortableJSの公式デモページにアクセス
    driver.get 'https://sortablejs.github.io/Sortable/#simple-list'

    # ドラッグ&ドロップの対象を含むdiv要素のリストを取得
    elements = driver.find_elements(:css, 'div#example1 > div.list-group-item')
    # ドラッグ元(Item 1)とドロップ先(Item 2)のdiv要素を取得
    sourceElement = elements[0]
    targetElement = elements[1]

    # ドラッグ&ドロップを実行するメソッドの呼び出し
    simulateDragAndDrop(sourceElement, targetElement, driver)

    # Item 1 と Item 2 が入れ替わったことを確認
    elements = driver.find_elements(:css, 'div#example1 > div.list-group-item')
    assert_equal(elements[0].text, 'Item 2')
    assert_equal(elements[1].text, 'Item 1')
  end
end

#
# ドラッグ&ドロップを実行するメソッド
#
def simulateDragAndDrop(sourceElement, targetElement, driver)
  driver.execute_script(<<-EOL, sourceElement, targetElement)
    (async (sourceElement, targetElement) => {
      // dragoverイベントの発火位置を計算
      const targetRect = targetElement.getBoundingClientRect()
      const targetPositionX = (targetRect.left + targetRect.right) / 2
      const targetPositionY = (targetRect.top + targetRect.bottom) / 2

      // ドラッグ&ドロップに必要な各イベントのインスタンスオブジェクトを作成
      const pointerDownEvent = new PointerEvent('pointerdown', {
        bubbles: true,
        cancelable: true,
      })

      const dragStartEvent = new MouseEvent('dragstart', {
        bubbles: true,
      })

      const dragOverEvent = new MouseEvent('dragover', {
        bubbles: true,
        clientX: targetPositionX,
        clientY: targetPositionY,
      })

      const dropEvent = new MouseEvent('drop', {
        bubbles: true,
      })

      // sleep処理用の関数を定義
      const sleep = msec => new Promise(resolve => setTimeout(resolve, msec))

      // イベントの発火
      sourceElement.dispatchEvent(pointerDownEvent)
      sourceElement.dispatchEvent(dragStartEvent)
      await sleep(1)
      targetElement.dispatchEvent(dragOverEvent)
      targetElement.dispatchEvent(dropEvent)

    })(arguments[0], arguments[1])
  EOL
end

テスト対象がreact-sortablejsの場合は、Node.js版と同じように手を加える必要があります。(テストコード例は割愛)

後書き

個人的にはドラッグ&ドロップの挙動自体はUI観点も含めてマニュアルテストで見ておくのがよいだろうという考えでいます。
しかし、ドラッグ&ドロップ実行後の画面のテストを自動でまわしたいというケースは、もしかしたら出てくるかもしれません。そのようなときに今回調べた方法が役に立てばと思います。3


参考サイト


  1. あくまで書き手なりの解決方法であり、ベストプラクティスの保証はありませんのでご了承ください。 

  2. このところNode.jsばかり触っていて、Rubyを書きたい衝動に駆られました。 

  3. SeleniumでのSPAのテストは面倒なことも多いので、できればそれを避けたいところではありますが。 

  4. Seleniumの公式サイトがすっかりモダンな感じにリニューアルされていてサイト内で迷子になりました。内容が空のページやサンプルコードのない箇所が散見されるのでContributeしたい……。 

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

Angularスキル獲得のために始めたこと、始めること

お仕事だったり同期と作ったアドベントカレンダーだったりのおかげで、Angularを触る機会を得た小生でございます。
今までフロントどころか、Webアプリの制作もしたことがなかったので、これをいいことにいろいろと勉強していってる最中です。

Angularを触るにあたって何を知っていたか

  • HTML
  • CSS
  • Javascript
  • Node.js

HTML、CSSはお猿さんと同じくらいの知識がありました。
JavascriptはほぼNode.js触ってから覚えた感じ。
元々プログラミング経験があったので、ここらへんはなんとか理解しつつ進めております。

Angularを理解するためには

特に2020年のフロントエンドマスターになりたければこの9プロジェクトを作れはめちゃくちゃ面白いです。
Angularに限らず、フロントのフレームワークの基礎押さえたなら、それぞれ作っていくべきだと思います。
元記事ではフロントエンドマスターになるために様々なフレームワークを紹介していますが、まずは一本極めていくのが自分のやり方なので、Angularで絞ってやっていきます。

始めたこと:公式チュートリアル制覇

入門チュートリアルでは、Angularがどんな感じで動いているのかを理解できました。
基礎チュートリアルでは、コンポーネント指向に置いて説明がされている印象を受けました。
コピペだけで作れなくはないですが、用語が分からずともしっかり説明を読んで、ちょこちょこコードをいじったりするとより理解が深まります。

始めたこと:Build a movie search app

とっかかりとして、Angularで映画情報を検索するWebアプリを作りました。できたものはこんな感じです。

movielist01.png

ガッツリ参考URL載せてるくせに、実は一度も読みに行ってません…
貼られてたスクショを元に、機能を想像しながら、真似た物を作ってみました。

検索フォームにキーワードを入力すると…

movielist02.png

関連する映画が表示されます。

movielist03.png

ページ移動とかもちゃんと機能します。どれでもいいので映画をクリックすると、

movielist05.png

このような形で、映画の詳細情報がでます。

Angularの勉強は楽しいのですが、なにぶんCSSをしっかり書いたことがないもので…
詳細情報ページだけ、間に合わせのtableで凌いでます。
(検索フォームはなぜか真ん中に来ないのでおこです?)

始めたこと:AngularでWebアプリを設計するには

もうこれはWebデザイン全般に言えることかもしれないんですけど、
設計図を書きましょう もっというと、画面図を書きましょうですね

movielist06.png

Angularはコンポーネント指向でアプリを作るので、どのコンポーネントがどの部分に来るか、明確にイメージしていないとすぐこんがらがります(一人で作る場合)
最初に必要なコンポーネントをがーっと作って、その後設計を考えながら組み立てるのも悪くないですが、あとからたくさん修正が必要そうになるので、概要くらいは決めておいた方が良いです。

始めること:アウトプット、アウトプット、アウトプット

やっぱり手を動かさないと始まらない、ということで当面の目標はサンプルアプリを作り続けるです。
嬉しいことにコードを書くスピードが上がっているのを実感できているので、アドベントカレンダー最終日までにあと2つはサンプルを作りたいと思います。
併せて、Bootstrapについても勉強を始めようと思います。
とりあえず次回の記事は、今回紹介したサンプルアプリの詳細と、次に作るアプリの設計について書いていきます。

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