20200221のNode.jsに関する記事は4件です。

AzureServiceBusを色々と試してみた

はじめに

アーキテクチャに弾力性を持たせるためにはキューの存在は必須なわけですがAzureにもAWS SQSと同じようなサービスが存在します。それがAzureServiceBusです。
今回も仕事で使うためにAzureServiceBusについて調べてみました。
似たものとしてBlobStorageQueueがあるのですが、そのあたりの違いについては公式のドキュメントをご覧ください。

ServiceBusの各プランの機能と料金についてはこちらをご覧ください。

本記事で作ったソースはこちらにあります。

準備

サンプルプログラムを試すためのServiceBusとキューはAzurePortalから予め作っておいてください。
キューの名前は何でも構いませんがサンプルプログラム内ではqueueという名前で作成されたものを使っています。

また、キューにアクセスするためのポリシーの作成と接続文字列はプログラム内で使いますのでメモしておいてください。
私は「管理」「送信」「リッスン」それぞれのポリシーを作っておきました。

image.png

簡単なメッセージの送信

簡単なプログラムを使ってメッセージを送ってみます。

send-message.js
const {ServiceBusClient} = require("@azure/service-bus");

const connectionString = process.env.SERVICE_BUS_CONNECTION_STRING;
const queueName = process.env.QUEUE_NAME;

const main = async () => {

    const sbClient = ServiceBusClient.createFromConnectionString(connectionString);
    const queueClient = sbClient.createQueueClient(queueName);
    const sender = queueClient.createSender();

    const message = {
        body: 'Hello. Service Bus',
        label: 'test',
        userProperties: {
            testPropertyName: 'property value'
        }
    };
    console.log('send message start');
    try {
        await sender.send(message);
        await queueClient.close();
        console.log('send message end');
    } finally {
        await sbClient.close();
    }
};

main().catch(err => {
    console.log(err);
});

実行してみましょう

set SERVICE_BUS_CONNECTION_STRING=<ConnectionString>
set QUEUE_NAME=<QueueName>
C:\Users\uzres\products\azure-servicebus-transaction>node send-message.js
send message start
send message end

ServiceBus Explorerで確認

こちらからServiceBus Explorerをダウンロードします

File → Connectを選択し、ConnectionStringを入力します。
ConnectionStringのポリシーは「管理」である必要があります。

image.png

左メニューのqueueを選択し、Message→OKをクリックするとメッセージを見ることができます。

image.png

カスタムプロパティもみることができますね。

image.png

メッセージの受信

今度はメッセージを受信してみましょう。

receive-message.js
const {ServiceBusClient, ReceiveMode} = require("@azure/service-bus");

const connectionString = process.env.SERVICE_BUS_CONNECTION_STRING;
const queueName = process.env.QUEUE_NAME;

const main = async () => {

    const sbClient = ServiceBusClient.createFromConnectionString(connectionString);
    const queueClient = sbClient.createQueueClient(queueName);
    const receiver = queueClient.createReceiver(ReceiveMode.receiveAndDelete)

    console.log('receive message start');
    try {
        const messages = await receiver.receiveMessages(5);
        console.log(messages.map(message => message.body));

        await queueClient.close();
        console.log('receive message end');
    } finally {
        await sbClient.close();
    }
};

main().catch(err => {
    console.log(err);
});

実行するためには、「リッスン」権限が必要です。

C:\Users\uzres\products\azure-servicebus-transaction>node receive-message.js
receive message start
[ 'Hello. Service Bus' ]
receive message end

実行してみるとわかりますが結構待たされます。
これはメッセージを受信するときに60秒間待つからです。
短くするにはreceveMessagesの2番目の引数に待つ秒数を指定します。

const messages = await receiver.receiveMessages(5, 1);

Azure Functionで受信

先ほどの例ではとあるタイミングでプログラムを実行しQueueからメッセージを受信しましたが、Queueに入ったタイミングでFunctionを動かすことができます。
これでQueueをポーリングする手間がなくなりますね。素晴らしい・・・。

関数の引数であるmySbMsgにメッセージが入ってきます。

module.exports = async function(context, mySbMsg) {
    context.log('JavaScript ServiceBus queue trigger function processed message', mySbMsg);
    context.log('EnqueuedTimeUtc =', context.bindingData.enqueuedTimeUtc);
    context.log('DeliveryCount =', context.bindingData.deliveryCount);
    context.log('MessageId =', context.bindingData.messageId);
};
functions.json
{
  "bindings": [
    {
      "name": "mySbMsg",
      "type": "serviceBusTrigger",
      "direction": "in",
      "queueName": "queue",
      "connection": ""
    }
  ]
}

Functionを設定するときの注意点

  • connectionのところを空にした場合は、アプリケーション設定の追加で、AzureWebJobsServiceBusという名前でQueueへの接続文字列を設定しておきます。
  • function.jsonにqueueNameを指定しているので接続文字列からはEntityPath=queueを削除しておかないとエラーになります。
  • このへんの設定はトリガーとバインド ServiceBusを確認してください。

Azure Functionで送信

Bindingという機能を使うとServiceBusにメッセージを送ることができます。
先ほど作ったfuctionで受け取ったメッセージをoutputqueueというQueueに投入してみたいと思います。

まずはfunction.jsonにdirectionがoutの要素を追記します。

function.json
{
  "bindings": [
    {
      "name": "mySbMsg",
      "type": "serviceBusTrigger",
      "direction": "in",
      "queueName": "queue",
      "connection": ""
    },
    {
      "name": "output",
      "type": "serviceBus",
      "queueName": "outputqueue",
      "connection": "OutputConnection",
      "direction": "out"
    }
  ]
}

プログラムも少し変えましょう。受信したメッセージにoutput を付けて、outputqueueにメッセージを入れてみます。
bindingsの後の文字列はfunction.jsonのnameで指定した文字列になります。

index.js
module.exports = async function(context, mySbMsg) {
    context.log('JavaScript ServiceBus queue trigger function processed message', mySbMsg);
    context.log('EnqueuedTimeUtc =', context.bindingData.enqueuedTimeUtc);
    context.log('DeliveryCount =', context.bindingData.deliveryCount);
    context.log('MessageId =', context.bindingData.messageId);

    const outputMessage = 'output' + mySbMsg;
    context.bindings.output=outputMessage;
};

今回はfunction.jsonのconnectionにOutputConnectionを指定したので、Functionの環境変数にっはAzureWebJobsOutputConnectionという名前で接続文字列を指定します。

image.png

実行してoutputqueueの中身を見るとメッセージが配信されていることがわかりますね。

image.png

PeekLockとReceiveAndDelete

AzureFunctionでメッセージを受信したとき既定の動作はPeekLockです。
https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-service-bus?tabs=csharp#trigger---peeklock-behavior

処理が正常に終了すれば、FunctionからCompleteを呼び出しQueueからメッセージが削除されるようになっています。ReceiveAndDeleteに設定することもできますが、これは受信した瞬間にメッセージが削除されるモードです。これはエラー発生時にメッセージがロストしてしまう可能性があります。

DeadLetter

MaxDeliveryCountに指定されている回数分処理が失敗することや、TimeToLiveの超過が起こるとDeadLetterQueue(DLQ)にメッセージが送信されます。
これはアプリケーションで明示的に指定することも可能です。

DLQの中身は自動的にクリーンアップされないため、DLQに移ったメッセージはログに出力するなどして確実に処理する必要があります。

早速試してみましょう。

DeadLetterQueueにメッセージを移す簡単な方法は、先ほど作ったFunctionの出力先であるoutputqueueを削除したあとにメッセージを送りましょう。

少し経ってFunctionのログを見ると10回失敗していることがわかります。

image.png

ExplorerでDeadLetterQueueに移っていることも確認できます。

image.png

DLQのメッセージを取得してみたいと思います。

receive-DLQ-message.js
const {ServiceBusClient, ReceiveMode} = require("@azure/service-bus");

const connectionString = process.env.SERVICE_BUS_CONNECTION_STRING;
const queueName = process.env.QUEUE_NAME + "/$DeadLetterQueue";

const main = async () => {

    const sbClient = ServiceBusClient.createFromConnectionString(connectionString);
    const queueClient = sbClient.createQueueClient(queueName);
    const receiver = queueClient.createReceiver(ReceiveMode.peekLock)

    console.log('receive DLQ message start');
    try {
        const messages = await receiver.receiveMessages(5, 1);
        for (let i = 0; i < messages.length; i++) {
            const message = messages[i];
            console.log(message.body)
            await message.complete();
        }
        await queueClient.close();
        console.log('receive DLQ message end');
    } finally {
        await sbClient.close();
    }
};

main().catch(err => {
    console.log(err);
});

DLQはQueueNameの後ろに/$DeadLetterQueueを付けただけです。
今回は、PeekLockで取得するのでメッセージ取得後complete()を呼び出しDLQからメッセージを削除しています。

おわりに

シンプルなメッセージのやり取りからFunctionを使った送受信、DLQまで試してみましたがいかがだったでしょうか。割と簡単に処理できることがお分かりいただけたかなと思います。

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

NW.jsでHTMLをWindowsデスクトップアプリにする

NW.jsとは?

ざっくりとした理解としては、Chromiumエンジン使ってhtmlを表示するデスクトップアプリを作れるフレームワークです。

Electronとの違いなど、詳しい記事は、こちら
NW.jsでデスクトップアプリの夢を見る!

1分でWindowsデスクトップアプリをつくる

いろいろ複雑なことやっている記事が多いですが、本当に簡単なので、1分でアプリ起動できます!

手順

  1. 公式サイト( https://nwjs.io/ )から、左側の「NORMAL」をダウンロードします。
    image.png

  2. zip展開し、src というフォルダを作成。そこに起動するHTMLファイル一式を格納します。

  3. package.json を作成して同フォルダに入れます。最低限の設定は以下。

package.json
{
  "name":"test.application",
  "version":"1.0.0",
  "main":"src/index.html"
}

4.nw.exeを実行!!
image.png

以上。超簡単です。

NW.js Tips

package.json

ウインドウの最低サイズとアイコンの指定

package.json
{
  "name":"test.application",
  "version":"1.0.0",
  "main":"src/index.html"
  "window":{
    "min_width":1200,
    "min_height":760,
    "icon":"assets/icon32.png"
  }
}

JavaScript

いろいろな情報の寄せ集めですが、動いたコードを紹介。

exeをキックする

    var spawn = require('child_process').spawn,
    child    = spawn('C:\\windows\\notepad.exe', ["C:/Windows/System32/Drivers/etc/hosts"]);

    child.stdout.on('data', function (data) {
        console.log('stdout: ' + data);
    });

    child.stderr.on('data', function (data) {
        console.log('stderr: ' + data);
    });

    child.on('close', function (code) {
        console.log('child process exited with code ' + code);
    });

エクスプローラーでフォルダーを開く

    require('child_process').exec('start "" "c:\\windows"');

htmlと同フォルダーにあるファイルを関連付けられたアプリケーションで開く

    var path = require('path');
    var startDir = path.dirname(process.execPath);

    var gui = window.require( 'nw.gui' );
    gui.Shell.openItem( startDir+'\\src\\test.pdf' );

標準のブラウザでURLを開く

    var gui = window.require( 'nw.gui' );
    gui.Shell.openExternal( 'https://google.com/' );
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SORACOM GPS マルチユニット のデータを kintone に保存する

概要

発表と同時に注文していた「GPS マルチユニット SORACOM Edition スターターキット」が届いたので早速 kintone へのデータ保存を試してみました。
GPS マルチユニット
SORACOMエンジニアブログによると、GPSマルチユニットは Unified Endpoint を経由して、Harbest Data や、Beame、Funnel、Funk で利用できます。
Qiita
SORACOMエンジニアブログ(4 つのセンサーと省電力通信 LTE-M 内蔵「GPS マルチユニット SORACOM Edition」発売開始!)より引用

そこで、今回は以下のようにGPSマルチユニットから、Unified Endpoint、SORACOM Funk を経由して、AWS Lambda をキックして kintone にレコードを追加する方法を試してみました。
GPS マルチユニット->Unified Endpoint->SORACOM Funk->AWS Lambda->kintone

GPS マルチユニットの準備

「GPS マルチユニット SORACOM Edition スターターキット」には、GPS マルチユニット SORACOM Edition、SORACOM 特定地域向け IoT SIMカード (plan-D サイズ:ナノ / データ通信のみ) 、充電/給電用マイクロUSBケーブル 1本が同梱されています。
GPS マルチユニット
すでにSORACOMのサイトに一通りのドキュメントがあり、それを参考に本体にSIMを入れた後に前面にあるボタンを1秒以上押すか、同梱されているUSBケーブルで充電すると本体が起動します。

GPS マルチユニット SORACOM Edition 製品仕様
https://dev.soracom.io/jp/gps_multiunit/specification/
GPS マルチユニット SORACOM Edition デバイスの使い方
https://dev.soracom.io/jp/gps_multiunit/how-to-use/

kintone の準備

今回はGPS マルチユニットのデータをkintoneに保存しますので、以下のドキュメントを参考にGPSマルチユニットのデータを全て保管するkintoneのアプリを用意します。

GPS マルチユニット SORACOM Edition 機能の説明
https://dev.soracom.io/jp/gps_multiunit/how-it-works/

kintoneに以下のようなフォームを持つアプリを追加します。
q01.png

フィールド名 タイプ フィードコート・要素ID
日時 日時 datetime
緯度(Y) 数値(少数9桁) lat
経度(X) 数値(少数9桁) lon
電池残量 数値(少数0桁) bat
アンテナ感度 数値(少数0桁) rs
温度 数値(少数1桁) temp
湿度 数値(少数1桁) humi
加速度X 数値(少数1桁) x
加速度Y 数値(少数1桁) y
加速度Z 数値(少数1桁) z
タイプ 数値(少数0桁) type

あとアプリの設定画面で、レコード追加権限を持つAPIトークンを追加、控えておきます。
完成したアプリは以下です。
q02.png

AWS アカウントの追加

次に、GPS マルチユニットからのデータを受信処理する AWS Lambda をキック(起動)するための、AWS アカウントを追加します。AWS コンソール IAM のユーザ画面で、ユーザを追加します。
q09.png
ユーザの詳細設定で「ユーザ名」を入力し、「アクセスの種類」にプログラムによるアクセスを選択し、次のステップに移ります。
q10.png
アクセス許可の設定で「既存ポリシーを直接アタッチ」を選択し、「ポリシーのフィルタ」に AWSLambdaFullAcess を選択して、次のステップに進みます。
q11.png
本来は先に AWS Lambda 側の設定を行った後に以下のような「ポリシーの作成」を行った方が良いのですが、今回は試験的に試すだけですのでAWSLambdaFullAcessにしています。
q12.png
ユーザの追加に成功したら、アクセスキーIDと、シークレットアクセスキーを控えておきます。
q13.png
以上でアカウントの追加は完了です。

AWS Lambda の準備

続いて、GPS マルチユニットからのデータを受信処理する AWS Lambda の設定を行います。「関数の作成」で「関数名」を入力し、ランタイムが Node.js 12.x、その他の項目は変更せず関数を作成します。(写真はソースコード他の設定を終えた状態。)
画面右上の ARN(写真はグレーで消している)は、GPS マルチユニットの通信設定に必要なので控えておきます。
q03.png
作成した関数の基本設定の「メモリ(MB)」を256MBに、「タイムアウト」を30秒に設定します。(メモリは初期値の128MBでも良いですが、タイムアウト初期値3秒は kintone API のレスポンスに遅延が発生する場合も想定して10秒以上に変更した方が良いでしょう。)
q04.png
作成した関数の非同期呼び出しの「イベントの最大有効時間」は1分程度に、「再試行」は0で行なわないようにします。(今回は、通信のタイムアウトなどで処理が中断した場合は再実行しない設定にしています。)
q05.png
GPS マルチユニット SORACOM Edition 機能の説明で紹介しているGPS マルチユニットから送信されるbase64をデコードしたフォーマットを参考に、関数のテストを追加します。
q06.png

{
    "lat":35.000000,
    "lon":139.000000,
    "bat":3,
    "rs":4,
    "temp":16.0,
    "humi":32.4,
    "x":0.0,
    "y":-64.0,
    "z":-960.0,
    "type":0
}

最初うっかりGPS マルチユニット SORACOM Edition 機能の説明で紹介している base64 ペイロードデータを処理するプログラムを作成し、base64 ペイロードデータテストを設定していましたが、実際は SORACOM Funkから Lambda にデータが渡される時にはbase64デコードしているため上記の設定になります。
q07.png
テスト実行後、kintone にレコードが追加されているのが確認できます。
q08.png

AWS Lambda のプログラム

AWS Lambda のプログラムは、以下の node.js のパッケージ request-promise と moment を追加する必要があります。
https://www.npmjs.com/package/request-promise
https://www.npmjs.com/package/moment

Lambda で node.js のパッケージを使う説明は割愛しますが、以下などを参考に設定すると良いでしょう。
Lambda の Node.js でもっといろんなパッケージを使いたいとき
https://tech-lab.sios.jp/archives/9017
AWS Lambda Layersでnode_modulesを使う
https://xp-cloud.jp/blog/2019/01/12/4630/

Lambda ではGPS マルチユニットからのデータは Json 形式で簡単に受け取れるため、実装は kintone API の Json フォーマットに変換して POST するだけです。

index.js
'use strict';

const request = require('request-promise');
const moment  = require("moment");

const Domain     = "cybozu.com";
const Subdomain  = "SUBDOMAIN";
const Path       = "/k/v1/record.json";
const Protocol   = "https://";
let   Url        = Protocol + Subdomain + '.' + Domain + Path;

const AppId  = "KINTONE_APP_ID";
const Token  = "KINTONE_TOKEN";

exports.handler = async function(event, context, callback) {
    console.log('Function Start.');

    var dateTime = moment().format("YYYY-MM-DDTHH:mm:ssZ");
    var json = {
        "dateTime" : { "value" : dateTime },
        "lat"      : { "value" : event.lat },
        "lon"      : { "value" : event.lon },
        "bat"      : { "value" : event.bat },
        "rs"       : { "value" : event.rs },
        "temp"     : { "value" : event.temp },
        "humi"     : { "value" : event.humi },
        "x"        : { "value" : event.x },
        "y"        : { "value" : event.y },
        "z"        : { "value" : event.z },
        "type"     : { "value" : event.type },
    };
    await PostKintoneRecode(request, Url, AppId, Token, json);

    console.log('Function Stop.');
};

// kintone のデータを追加する
async function PostKintoneRecode(request, url, appId, token, json)
{
    try {
        var options = {
            url: url,
            method: 'POST',
            headers: {
                'Content-type': 'application/json',
                'X-Cybozu-API-Token': token
            },
            json: { app : appId, record: json },
        };
        await request(options);
        return true;
    } catch (err) {
        console.error(JSON.stringify(err));
        return false;
    }
}

GPS マルチユニットの設定

最後に、いよいよGPS マルチユニットの設定に入ります。(翌日気づいたのですが、すでにSORACOMさんの丁寧なドキュメントが公開されていましたので、以下を参考にすると良いでしょう。)
GPS マルチユニット SORACOM Editionを使用して、定期的に位置情報を送信する
https://dev.soracom.io/jp/start/gps_multiunit_location/

SIMの有効化

SORACOM のコンソールにログインして、以下の画面に遷移してGPS マルチユニットに付属していた SIM(plan-D ナノサイズ)を有効化します。
Menu->発注->注文履歴->「受け取り確認」ボタン
q14.png

SIMグループの追加と設定

SIM 有効化後、SIM グループの画面でグループを追加します。
q15.png
追加したグループの Unified Endpoint を設定します。今回は SORACOM Funk を利用しますので「フォーマット」に SORACOM Funk を選択します。
q16.png
追加したグループの SORACOM Funk を以下のように設定します。「サービス」は AWS Lambda を選択、「送信データ作成」は JSON を選択、「認証情報」を追加(追加方法は後述)、「関数のARN」は先に控えておいた Lambda の ARN を入力します。
q17.png
「認証情報」の追加は、「認証情報」の項目にマウスを移動すると以下が表示されるので、クリックすると追加画面が表示されます。
q18.png
「認証情報」追加画面の「認証情報ID」と「概要」を入力し、「識別」はAWS 認証情報を選択、「AWS Access Key ID」と「AWS Secret Access Key」はAWS アカウントの追加で保管したアクセスキーIDとシークレットアクセスキーを入力し、登録します。
q19.png
以上のグループの設定が完了したら SIM 管理画面に戻り、先に有効化した SIM にこのグループを紐づけます。
q20.png

GPS マルチユニット本体の設定

SIM と SIMグ ループの登録・紐づけ後、以下の画面に遷移して GPS マルチユニット本体を設定します。
Menu->ガジェット管理->GPS マルチユニット
GPS マルチユニットの画面から、新規デバイス設定を行います。
使用するSIMを選択する画面で先に登録したSIMを選択し「次へ」遷移、設定を保存するグループを選択する画面で既存のグループを利用を選択し先ほど作成したグループを選択して「設定を編集」画面に遷移します。

GPS マルチユニットの設定画面で、送信内容を全てチェックします。
q21.png
送信先の「SORACOM Harvest(Lagoon)」をチェックします。(SORACOM Funk は最初からチェックされて、こちらでは変更できません。)
q22.png
送信モードは「定期送信 - 手動モード」を選択します。
q23.png
定期送信 - 手動モード詳細設定は毎日10分毎に設定します。(最初のテスト時は最短の1分毎に設定していました。)
q24.png
加速度割り込みはお好みで良いでしょう。
q25.png
以上の設定を完了したら「保存」します。

GPS マルチユニットからデータ送信の確認

GPS マルチユニット本体の設定が完了した後、「データを確認」で GPS マルチユニット本体から送られてきたデータを確認できます。
q26.png
加速度Z の値が極端なため自動ではちょっと見ずらいですが、データが取得できていることが確認できます。
q27.png

結果

kintone アプリと AWS Lambda、GPS マルチユニットの全ての設定を終えると、フィールドにGPS マルチユニットの値がセットされたkintoneアプリのレコードが追加されます。(2020/02/21 自宅の地図は面白くないので外出時に差し替え。)
q104.png

折角 GPS 情報が保管されていますので、住所/緯度経度変換プラグインで地図を表示させてみました。(2020/02/21 こちらの地図も差し替え。)
q101.png
q103.png
住所/緯度経度変換プラグイン
https://www.tis2010.jp/geocoding/

これで以前からやってみたいと思っていた kintone で移動環境計測のトレースができるので、2020/02/21朝に車の移動で早速試してみました。
IMG_7197.jpg
その結果が以下です。
q102.png
う~ん、車の移動ではデータが取得できない場合が多いようですね。週末はラーメン博があるので、食べ歩きで試してみるつもりです。

参考

「GPS マルチユニット SORACOM Edition スターターキット」の出荷を開始いたしました!
https://blog.soracom.jp/blog/2020/02/18/shipping-gps-multiunit-soracom-edition/
GPSマルチユニットSORACOM Edition ユーザーガイド
https://dev.soracom.io/jp/gps_multiunit/what-is-gps_multiunit/
SORACOM Funk を利用して AWS Lambda を呼び出し Slack へ通知する
https://dev.soracom.io/jp/start/funk_aws_lambda/
SORACOM LTE-M Button を押したログを kintone に保管
https://qiita.com/yukataoka/items/9f9daac2dc05194bbc63

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

huskyを使ってgit hookを楽に扱う

前提

  • Node.js限定です
  • prettier, eslintは導入済みとする
  • gitコマンドを使わないと走らないので注意
    SorceTreeでcommitしたら動きました

やること

pre-commitでprettier --write,eslint --fix,jestを走らせる。

方法

huskyを入れてpackage.jsonにちょろっと書いて終わり。便利。

huskyとlint-stagedをインストール

~$ npm i -D husky lint-staged

package.json編集

nameやversionと同階層に以下を追加。対象コードが格納されてるディレクトリは環境に合わせて修正。

"husky": {
  "hooks": {
    "pre-commit": "lint-staged; jest"
  }
},
"lint-staged": {
  "*.{js,jsx}": [
    "prettier --write './src/**/*.js'",
    "prettier --write './__tests__/**/*.js'",
    "eslint --fix './src/**/*.js'",
    "eslint --fix './__tests__/**/*.js'"
  ]
}

おわり

後は適当に編集してgit add -Aしてgit commitすればlint-stagedに書いた内容が実行されてからjestが実行されます。
sh書かなくても良いのは楽ですね。ReactやVue.js環境でももちろん動きます。
VSCode環境ならprettierもeslintもAutoSaveで走らせればあまり必要はない気もしますが。

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