- 投稿日:2019-10-09T23:42:14+09:00
Alexa スキル内課金 第3回 スキル内商品購入後の処理
はじめに
前回はスキル内課金の購入やキャンセルなどの処理の説明をしました。
今回は、スキル内商品購入後の動作について説明します。今回実施する内容
スキル内商品を購入した後の動作について説明します。
今回作成しているサンプルスキルは、スキル内商品の中身があるわけではないため、あくまで購入後スキル内商品をどうやって扱うかを記載します。環境
OS:Windows 10 JP
Alexaスキル言語:Node.js
Editor:Visual Studio Code
Alexa developer console参考
・Alexaスキルを使用した商品の販売
Alexzオフィシャルドキュメントの説明です。・AlexaサービスAPIの呼び出し
Node.jsでスキル内課金サービスのAPIへアクセスする説明です。・Alexaデザインガイド スキル内課金
スキル内課金のデザインのベストプラクティスなどの説明です。・ASK SDK for Node.jsのリクエスト処理
Node.jsのリクエスト処理についての説明ですが、リクエストと応答のインターセプターに関する説明が今回の記事内では参照されます。・Alexa Skills Kitのリクエストと応答のインターセプター
Alexa Skills Kitのリクエストのインターセプターに関する説明です。・Alexa スキル内課金 第1回 スキル内課金の仕組みとスキル内商品レコード作成、読み込み
スキル内課金の仕組みの概要の説明です。・Alexa スキル内課金 第2回 スキル内商品の購入処理
スキル内商品の購入・キャンセルなどの説明です。用語
スキル内課金 (ISP)
スキルの中の課金の仕組みこと。
In-skill Purchasing前提条件
Alexa スキル内課金 第1回 スキル内課金の仕組みとスキル内商品レコード作成、読み込み
Alexa スキル内課金 第2回 スキル内商品の購入処理
の記事を読んでいる。スキル内商品購入後の処理
スキル内商品購入後の処理の概要
Alexaのスキル内商品は、その名の通り、スキル内の一部が課金商品になります。
スキルそのものが商品ではなく、一部です。
それがプレミアムなサービスという位置づけになっているようです。当たり前ですが、「スキル内商品を購入したユーザーには、その商品の使用権があり、スキル内商品を購入していないユーザーには、その商品の使用権がありません。」
購入後の処理は、これをしっかり実装する必要があります。
Alexaの仕組みでうまく実装してくれるわけではなく、自分のスキルコード内で、ユーザーが購入済みなのかを確認し、それに従ってスキルを実行します。でどうやって実現するかと言えば、第1回に説明したスキル内商品レコードなのです。
それをこれから再度説明します。スキル内商品レコードの購入前後の違い
スキル内商品レコードは、Node.jsからスキル内課金サービスのMonetizationServiceClient APIを通じてアクセスします。
スキル内商品は、地域(locale)ごとに管理されるため、localeを設定したうえで、情報を取得します。
AlexaサービスAPIの呼び出しに記載されている内容をベースに記載します。index.jsの一部const LaunchRequestHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'LaunchRequest'; }, handle(handlerInput) { const locale = handlerInput.requestEnvelope.request.locale; const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient(); return ms.getInSkillProducts(locale).then((result) => { console.log(`現在登録済みのスキル内商品: ${JSON.stringify(result.inSkillProducts)}`); return handlerInput.responseBuilder .speak("スキル内課金へようこそ") .withSimpleCard("スキル内課金", "スキル内課金へようこそ") .getResponse(); }); }, };上記のソースコードで、購入前と購入後に実行したときのConsoleの出力結果(整形済み)は以下です。
購入前のconsole.logの表示現在登録済みのスキル内商品: [ { "productId":"amzn1.adg.product.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "referenceName":"課金商品", "type":"ENTITLEMENT", "name":"課金商品", "summary":"説明です。", "entitled":"NOT_ENTITLED", "entitlementReason":"NOT_PURCHASED", "purchasable":"PURCHASABLE", "activeEntitlementCount":0, "purchaseMode":"TEST" } ]購入後のconsole.logの表示現在登録済みのスキル内商品: [ { "productId": "amzn1.adg.product.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "referenceName": "課金商品", "type": "ENTITLEMENT", "name": "課金商品", "summary": "説明です。", "entitled": "ENTITLED", "entitlementReason": "PURCHASED", "purchasable": "NOT_PURCHASABLE", "activeEntitlementCount": 1, "purchaseMode": "TEST" } ]表に整理すると以下の通りです。差分を強調で表示します。
要素名 購入前 購入後 productId amzn1.adg.product.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX amzn1.adg.product.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX referenceName 課金商品 課金商品 type ENTITLEMENT ENTITLEMENT name 課金商品 課金商品 summary 説明です。 説明です。 entitled NOT_ENTITLED ENTITLED entitlementReason NOT_PURCHASED PURCHASED purchasable PURCHASABLE NOT_PURCHASABLE activeEntitlementCount 0 1 purchaseMode TEST TEST 上記の通り、購入後に変更されるのは、
entitled
、entitlementReason
、purchasable
、activeEntitlementCount
の4つである。
購入済みかどうか判断するためには、entitled
を使うのがよいかと思います。
これとスキル内商品が複数ありうる可能性を加味して、referenceNameで識別すればよいかと思います。
消費型の場合は、消費した回数をカウントする必要がありそうですが、今回は説明を割愛します。
スキル内課金のドキュメントによれば、activeEntitlementCount
は購入回数を示すだけであり、消費回数は別途開発者が管理しなければならないようです。スキル内商品レコードのセッションアトリビュートへの追加
スキルの内容にも依存しますが、
「スキル内商品を購入したユーザーには、その商品の使用権があり、スキル内商品を購入していないユーザーには、その商品の使用権がありません。」
を実現するために、多くのIntentのHandlerに上記のようなスキル内商品レコードを取得する処理が必要になることが想定されます。Amazon JapanのAlexa道場においては、リクエストのインターセプターを使用して、スキル内商品レコードのうち、資格のある商品をハンドラーのセッションアトリビュートに付与する実装がソースに記載されていましたので、紹介します。
Alexa道場のサンプルソースコードで、リクエストのインターセプターを初めて知りました。なかなかすべてのドキュメントに目を通すのは難しいため、Alexa道場ででてくるようなソースコードはタメになります。実施方法は簡単で、
- スキル内商品レコードの取得
- リクエストのインターセプターを使って、ハンドラーにセッションアトリビュートを設定
だけです。
リクエストのインターセプターは、ASK SDK for Node.jsのリクエスト処理に説明がありますが、抜粋します。
SDKは、RequestHandlerの実行前と実行後に実行するリクエストと応答のインターセプターをサポートします。インターセプターは、RequestInterceptorインターフェースかResponseInterceptorインターフェースを使用して実装できます。
どちらのインターセプターインターフェースも、戻り値の型がvoidであるprocessメソッドを1つ実行します。リクエストのインターセプターはHandlerInputオブジェクトにアクセスでき、応答のインターセプターはHandlerInputと、RequestHandlerによって生成されるオプションのResponseにアクセスできます。interface RequestInterceptor { process(handlerInput: HandlerInput): Promise<void> | void; }リクエストのインターセプターは、受け取るリクエストのリクエストハンドラーが実行される直前に呼び出されます。リクエストアトリビュートは、リクエストのインターセプターがリクエストハンドラーにデータやエンティティを渡す方法を提供します。
以下の例は、SDKを使ってインターセプターを登録する方法を示しています。
const Alexa = require('ask-sdk-core'); const skill = Alexa.SkillBuilders.custom() .addRequestHandlers( FooHandler, BarHandler, BazHandler) .addRequestInterceptors( FooRequestInterceptor, BarRequestInterceptor) .addResponseInterceptors( FooResponseInterceptor, BarResponseInterceptor) .create();上記に基づいたコードを示します。
/** * 購入済みスキル内商品のobjectを取得・応答する。 * * @param {object} inSkillProductList スキル内商品レコードのオブジェクト。 * @return {object} 購入済みスキル内商品のオブジェクト。 */ const getAllEntitledProducts = (inSkillProductList) => { const entitledProductList = inSkillProductList.filter(record => record.entitled === 'ENTITLED'); console.log(`Currently entitled products: ${JSON.stringify(entitledProductList)}`); return entitledProductList; }; /** * Request Interceptor(addResponseInterceptors)により、すべてのリクエストハンドラーが呼び出される直前に処理するための関数。 * どの商品をすでに購入済みかを確認し、セッションアトリビュート(attributes)に付加する。 * 購入済みのスキル内商品は、attributes.entitleProductsに配列で付加される。 */ const addEntitleProductsToAttributes = { async process(handlerInput) { if (handlerInput.requestEnvelope.session.new === true) { try { const locale = handlerInput.requestEnvelope.request.locale; const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient(); const result = await ms.getInSkillProducts(locale); const entitledProducts = getAllEntitledProducts(result.inSkillProducts); const attributes = handlerInput.attributesManager.getSessionAttributes(); attributes.entitledProducts = entitledProducts; handlerInput.attributesManager.setSessionAttributes(attributes); } catch (error) { console.log(`Error calling InSkillProducts API: ${error} `); } } }, };const skillBuilder = Alexa.SkillBuilders.standard(); exports.handler = skillBuilder .addRequestHandlers( LaunchRequestHandler, BuyISPReqIntentHandler, BuyUpsellResponseHandler, RecommendISPReqIntentHandler, CancelISPReqIntentHandler, CancelResponseHandler, HelpIntentHandler, CancelAndStopIntentHandler, SessionEndedRequestHandler ) .addRequestInterceptors(addEntitleProductsToAttributes) .addErrorHandlers(ErrorHandler) .lambda();getAllEntitledProducts
種類 引数名 型 説明 引数 inSkillProductList object スキル内商品レコードのオブジェクト。 戻り値 なし オブジェクト 購入済みスキル内商品のオブジェクト。 処理は、
inSkillProducts
の中で、entitled
の値がENTITLED
となっている資格のある(購入済みの)商品だけをフィルターして、その商品の応答を返すというものです。addEntitleProductsToAttributes
これは、RequestInterceptorインターフェイスを実装する関数です。
promiseで戻り値はなしです。
処理は、handlerInputでセッションがnewとなっている場合、スキル内商品レコードを取得して、その値をセッションアトリビュートに設定するというもので、具体的には、attributes.entitledProducts
を設定します。
セッションがnewになるタイミングは、
- スキル起動時
- スキル内商品購入処理を実施して、AlexaからConnextions.Responseをもらうタイミング
が想定されます。
addRequestInterceptors
skillBuilderに
.addRequestInterceptors
をつけて、その引数にaddEntitleProductsToAttributes
をつけるだけで、ハンドラーのセッションアトリビュートに購入済みのスキル内商品レコードを貼り付けることができます。
おもに、スキルの起動時がセッションがnewとなるため、そのときに設定されるということです。セッションアトリビュートの確認
それでは、上記で作成したソースでセッションアトリビュートが設定されるか確認します。
まずは、スキル内商品が未購入状態で、スキルを起動(LaunchRequest)して、その応答がどうなるかです。
"sessionAttributes": { "entitledProducts": [] },続いてこのままこれを購入(BuyISPReqIntent)すると、その応答はやはり、entitledProductsはありません。
"sessionAttributes": { "entitledProducts": [] },スキルを購入すると、その応答に、entitledProductsが追加されます。
"sessionAttributes": { "entitledProducts": [ { "productId": "amzn1.adg.product.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "referenceName": "課金商品", "type": "ENTITLEMENT", "name": "課金商品", "summary": "説明です。", "entitled": "ENTITLED", "entitlementReason": "PURCHASED", "purchasable": "NOT_PURCHASABLE", "activeEntitlementCount": 1, "purchaseMode": "TEST" } ] },こうなればOKで、スキルを終了して再度スキルを起動(LaunchRequest)しても、購入済みのスキル内商品レコードは、セッションアトリビュートに設定されます。
このセッションアトリビュートの値を使って、ENTITLEDの場合、スキル内商品を提供すればよいのです。
ソースコード
第1回から第3回までで作成したソースコードを載せます。
const Alexa = require('ask-sdk'); /** * 購入済みスキル内商品のobjectを取得・応答する。 * * @param {object} inSkillProductList スキル内商品レコードのオブジェクト。 * @return {object} 購入済みスキル内商品のオブジェクト。 */ const getAllEntitledProducts = (inSkillProductList) => { const entitledProductList = inSkillProductList.filter(record => record.entitled === 'ENTITLED'); console.log(`Currently entitled products: ${JSON.stringify(entitledProductList)}`); return entitledProductList; }; /** * Request Interceptor(addResponseInterceptors)により、すべてのリクエストハンドラーが呼び出される直前に処理するための関数。 * どの商品をすでに購入済みかを確認し、セッションアトリビュート(attributes)に付加する。 * 購入済みのスキル内商品は、attributes.entitleProductsに配列で付加される。 */ const addEntitleProductsToAttributes = { async process(handlerInput) { if (handlerInput.requestEnvelope.session.new === true) { try { const locale = handlerInput.requestEnvelope.request.locale; const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient(); const result = await ms.getInSkillProducts(locale); const entitledProducts = getAllEntitledProducts(result.inSkillProducts); const attributes = handlerInput.attributesManager.getSessionAttributes(); attributes.entitledProducts = entitledProducts; handlerInput.attributesManager.setSessionAttributes(attributes); } catch (error) { console.log(`Error calling InSkillProducts API: ${error} `); } } }, }; const LaunchRequestHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'LaunchRequest'; }, handle(handlerInput) { const locale = handlerInput.requestEnvelope.request.locale; const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient(); return ms.getInSkillProducts(locale).then((result) => { console.log(`現在登録済みのスキル内商品: ${JSON.stringify(result.inSkillProducts)}`); const speechOutput = "スキル内課金へようこそ。課金商品を購入しますか?レコメンドしますか?キャンセルしますか?"; return handlerInput.responseBuilder .speak(speechOutput) .reprompt(speechOutput) .withSimpleCard("スキル内課金", "スキル内課金へようこそ") .getResponse(); }); }, }; const BuyISPReqIntentHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'BuyISPReqIntent' }, handle(handlerInput) { console.log("BuyISPReqIntentHandler"); const locale = handlerInput.requestEnvelope.request.locale; const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient(); return ms.getInSkillProducts(locale).then((result) => { // 購入する商品データを抽出 const product = result.inSkillProducts.filter(record => record.referenceName === "課金商品"); // ** ディレクティブにBuyを送信し購入フローに進む ** return handlerInput.responseBuilder .addDirective({ type: 'Connections.SendRequest', name: 'Buy', payload: { InSkillProduct: { productId: product[0].productId, }, }, token: 'token', }) .getResponse(); }); }, }; const BuyUpsellResponseHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'Connections.Response' && (handlerInput.requestEnvelope.request.name === 'Buy' || handlerInput.requestEnvelope.request.name === 'Upsell'); }, handle(handlerInput) { console.log(`BuyUpsellResponseHandler`); const locale = handlerInput.requestEnvelope.request.locale; const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient(); const productId = handlerInput.requestEnvelope.request.payload.productId; return ms.getInSkillProducts(locale).then((result) => { if (handlerInput.requestEnvelope.request.status.code === '200') { let speakOutput; let repromptOutput; switch (handlerInput.requestEnvelope.request.payload.purchaseResult) { case 'ACCEPTED': // 購入した speakOutput = `購入しました。`; repromptOutput = `購入しました。`; break; case 'DECLINED': // 購入しなかった if (handlerInput.requestEnvelope.request.name === 'Buy') { speakOutput = `また購入検討してくださいね。`; repromptOutput = `また購入検討してくださいね。`; break; } speakOutput = `またレコメンドしますね。`; repromptOutput = `またレコメンドしますね。`; break; case 'ALREADY_PURCHASED': // 購入済みだった speakOutput = `購入済みでした。`; repromptOutput = `購入済みでした。`; break; default: // 何らかの理由で購入に失敗した場合。 speakOutput = `購入できませんでした。音声ショッピングの設定やお支払い方法をご確認ください。`; repromptOutput = `購入できませんでした。音声ショッピングの設定やお支払い方法をご確認ください。`; break; } return handlerInput.responseBuilder .speak(speakOutput) .reprompt(repromptOutput) .getResponse(); } // 処理中にエラーが発生した場合 console.log(`Connections.Response indicated failure.error: ${handlerInput.requestEnvelope.request.status.message} `); return handlerInput.responseBuilder .speak('購入処理でエラーが発生しました。もう一度試すか、カスタマーサービスにご連絡ください。') .getResponse(); }); }, }; const RecommendISPReqIntentHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'RecommendISPReqIntent' }, handle(handlerInput) { console.log("RecommendISPReqIntentHandler"); const locale = handlerInput.requestEnvelope.request.locale; const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient(); return ms.getInSkillProducts(locale).then((result) => { // 購入する商品データを抽出 const product = result.inSkillProducts.filter(record => record.referenceName === "課金商品"); // ** ディレクティブにUpsellを送信し購入フローに進む ** return handlerInput.responseBuilder .addDirective({ type: 'Connections.SendRequest', name: 'Upsell', payload: { InSkillProduct: { productId: product[0].productId, }, upsellMessage: product[0].summary }, token: 'token', }) .getResponse(); }); }, }; const CancelISPReqIntentHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'CancelISPReqIntent'; }, handle(handlerInput) { console.log(`CancelISPReqIntentHandler`); const locale = handlerInput.requestEnvelope.request.locale; const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient(); return ms.getInSkillProducts(locale).then(function initiateCancel(result) { // 購入する商品データを抽出 const product = result.inSkillProducts.filter(record => record.referenceName === "課金商品"); // Cancelディレクティブを送信 return handlerInput.responseBuilder .addDirective({ type: 'Connections.SendRequest', name: 'Cancel', payload: { InSkillProduct: { productId: product[0].productId, }, }, token: 'token', }) .getResponse(); }); }, }; const CancelResponseHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'Connections.Response' && handlerInput.requestEnvelope.request.name === 'Cancel'; }, handle(handlerInput) { console.log(`CancelResponseHandler`); const speechText = "キャンセル" return handlerInput.responseBuilder .speak(speechText) .reprompt(speechText) .withSimpleCard("スキル内課金", speechText) .getResponse(); }, }; const HelpIntentHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent'; }, handle(handlerInput) { const speechText = 'You can say hello to me!'; return handlerInput.responseBuilder .speak(speechText) .reprompt(speechText) .withSimpleCard('Hello World', speechText) .getResponse(); }, }; const CancelAndStopIntentHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'IntentRequest' && (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent' || handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent'); }, handle(handlerInput) { const speechText = 'Goodbye!'; return handlerInput.responseBuilder .speak(speechText) .withSimpleCard('Hello World', speechText) .getResponse(); }, }; const SessionEndedRequestHandler = { canHandle(handlerInput) { return handlerInput.requestEnvelope.request.type === 'SessionEndedRequest'; }, handle(handlerInput) { console.log(`Session ended with reason: ${handlerInput.requestEnvelope.request.reason}`); return handlerInput.responseBuilder.getResponse(); }, }; const ErrorHandler = { canHandle() { return true; }, handle(handlerInput, error) { console.log(`Error handled: ${error.message}`); return handlerInput.responseBuilder .speak('エラーが発生しました') .reprompt('エラーが発生しました') .getResponse(); }, }; const skillBuilder = Alexa.SkillBuilders.standard(); exports.handler = skillBuilder .addRequestHandlers( LaunchRequestHandler, BuyISPReqIntentHandler, BuyUpsellResponseHandler, RecommendISPReqIntentHandler, CancelISPReqIntentHandler, CancelResponseHandler, HelpIntentHandler, CancelAndStopIntentHandler, SessionEndedRequestHandler ) .addRequestInterceptors(addEntitleProductsToAttributes) .addErrorHandlers(ErrorHandler) .lambda();おわりに
今回は、主に以下について説明しました。
- 購入前後で変化するスキル内商品レコード
- スキル内商品レコードの取得方法
- スキル内商品レコードのセッションアトリビュートへの追加
- 投稿日:2019-10-09T22:51:51+09:00
gRPC-Web + React + Node.js + TypeScriptでシンプルなチャットサービスを作る
概要
かねてよりgRPCおよびgRPC-Webに興味があり、これを用いてシンプルなリアルタイムチャットサービスを制作し、公開した。
本稿では、その開発工程について解説する。ゴール
gRPC-Webを用いて「わいわいチャット」を作る。
https://waiwai-chat-2019.aanrii.com/
内容はシンプルなチャットアプリケーションだ。サイトを開くとまず過去ログが表示され、ほかの入室者の投稿が随時流れてくる。任意の名前で入室すると投稿欄が出現し、発言ができる。発言した内容はサイトにアクセスしている全員に、即座に共有される。過去ログは無限スクロールで遡ることができる。
フロントエンドはReactを用いたSPAとし、Netlifyを使って静的サイト生成・配信する。また、バックエンドはGKE上で動くNode.jsアプリケーションとし、かつenvoyをプロキシとして挟んで外部と通信させる。そして、フロントエンド-バックエンド間はgRPC-Web (HTTPS) で通信する。
なお、コードはここで公開している。
https://github.com/aanrii/waiwai-chat-2019やること
完成に到るまで、主に以下のことに取り組む。
- バックエンド開発
- gRPC-Web + Node.js + TypeScriptによるアプリケーション開発 (+ grpcurlによるデバッグ)
- envoy-proxyを通したフロントエンドとの接続
- GCPでの実行 (GKEへのデプロイ、およびCloud SQL + Cloud Pub/Subの導入。別稿にて解説)
- SSL有効化 (cert-managerによる証明書自動取得。別稿にて解説)
- フロントエンド開発
- gRPC-Web + React + TypeScriptによるアプリケーション開発
- Netlifyを用いた静的コンテンツ配信 (別稿にて解説)
本稿では、バックエンド・フロントエンドアプリケーションがローカル上で動くことを目標として解説を進める。
GCP上での実行とgRPCサーバの冗長化、SSL有効化などについては、別稿にて説明する。開発
Service (protobuf) の定義
gRPCアプリケーションを作るため、まずgRPCサーバ (バックエンド) のI/Fを定義する.protoファイルを作る。
proto/MessageService.protosyntax = "proto3"; import "google/protobuf/empty.proto"; service MessageService { rpc GetMessageStream(google.protobuf.Empty) returns (stream Message); rpc PostMessage(Message) returns (PostMessageResponse); } message PostMessageResponse { string status = 1; // メッセージの処理結果 } message Message { string text = 1; // 発言内容 int64 create_time = 2; // 発言日時 string author_name = 3; // 投稿者名 }MessageServiceはGetMessageStreamとPostMessageという、ふたつのメソッドをもつ。PostMessageはフロントエンドからバックエンドへのメッセージ送信に用いる。バックエンドはMessageを受け取り、PostMessageResponse (受信に成功したらOK、失敗したらError、等) を返す。このように、1回のリクエストに1回のレスポンスを返すようなRPCは、Unary RPCと呼ばれる。
一方、GetMessageStreamはフロントエンドがバックエンドからメッセージを受信するのに使う。バックエンドはgoogle.protobuf.Empty (void型のようなもの) を受け取り、Messageのstreamを返す。ようは、フロントエンドは初期化時に一回だけGetMessageStreamRequestをバックエンドに送り、その後はMessageがどこかでPostされるたびに、それをstreamで随時受け取ることができる。このように、1回のリクエストに対し複数個のレスポンスを含むストリームを返却するRPCは、Server streaming RPCと呼ばれる。詳細は、公式ガイドを参照のこと。
バックエンド (gRPCサーバ) の開発
パッケージのインストール
バックエンドの開発に入る。必要なパッケージをインストールする。まず、TypeScriptとNode.js。
% yarn add typescript ts-node @types/node続いて、gRPCを利用するためのパッケージを追加する。
% yarn add grpc google-protobuf @types/google-protobuf最後に、TypeScript用protobufコンパイラを導入する。これらは、protoファイルからTypeScript+Node.jsで使える型定義ファイルを生成するために用いる。
% yarn add grpc-tools grpc_tools_node_protoc_ts --dev
そして、以下のスクリプトを書く。
backend/server/protoc.sh#!/usr/bin/env bash set -eu export PATH="$PATH:$(yarn bin)" # protoファイルがあるディレクトリへの相対パス PROTO_SRC=../../proto # 生成したjs、tsファイルを格納したいディレクトリへの相対パス PROTO_DEST=./src/proto mkdir -p ${PROTO_DEST} grpc_tools_node_protoc \ --js_out=import_style=commonjs,binary:${PROTO_DEST} \ --grpc_out=${PROTO_DEST} \ --plugin=protoc-gen-grpc=$(which grpc_tools_node_protoc_plugin) \ -I ${PROTO_SRC} \ ${PROTO_SRC}/* grpc_tools_node_protoc \ --plugin=protoc-gen-ts=$(yarn bin)/protoc-gen-ts \ --ts_out=${PROTO_DEST} \ -I ${PROTO_SRC} \ ${PROTO_SRC}/*これで、
bash protoc.sh
を実行することで、protoファイルをjs/tsコードにコンパイルすることが可能になる。gRPCサーバの実装
生成されたファイル、サーバーサイドのinterface (IMessageServiceServer) が自動生成されるので、このクラスを実装する。
backend/server/src/MessageService.tsimport { EventEmitter } from 'events'; import { Empty } from 'google-protobuf/google/protobuf/empty_pb'; import * as grpc from 'grpc'; import { IMessageServiceServer } from './proto/MessageService_grpc_pb'; import { Message, PostMessageResponse } from './proto/MessageService_pb'; class MessageService implements IMessageServiceServer { // PostMessageにより投稿されたメッセージをGetMessageStreamで返却するstreamに流すための中継器 private readonly messageEventEmitter = new EventEmitter(); // 過去ログを保存する配列 private readonly pastMessageList: Message[] = []; public getMessageStream(call: grpc.ServerWriteableStream<Empty>) { // 過去ログをstreamに流し込む this.pastMessageList.forEach(message => call.write(message)); // PostMessageが実行されるたびに、そのメッセージをstreamに流し込む const handler = (message: Message) => call.write(message); this.messageEventEmitter.on('post', handler); // streamが切断された時、上記Listenerを消去する call.on('close', () => { this.messageEventEmitter.removeListener('post', handler); }); } public postMessage(call: grpc.ServerUnaryCall<Message>, callback: grpc.sendUnaryData<PostMessageResponse>) { // 受け取ったメッセージを過去ログに保存する const message = call.request; this.pastMessageList.push(message); // messageEventEmitter経由で、getMessageStreamで返却するstreamにメッセージを送る this.messageEventEmitter.emit('post', message); // レスポンスを返す const response = new PostMessageResponse(); response.setStatus('ok'); callback(null, response); } } export default MessageService;一旦ローカルで動かすために、インメモリ上にメッセージを貯めることとする。
gRPCサービスの実装ができたので、これをサーバ上で動かす。backend/server/src/index.tsimport * as grpc from 'grpc'; import MessageService from './MessageService'; import { MessageServiceService } from './proto/MessageService_grpc_pb'; (() => { const server = new grpc.Server(); server.bind(`0.0.0.0:9090`, grpc.ServerCredentials.createInsecure()); server.addService(MessageServiceService, new MessageService()); server.start(); })();デバッグ
以下のコマンドにより、ローカル (0.0.0.0:9090) でサーバを起動できる。
% yarn ts-node src/index.ts今回は動作確認のため、grpcurlを使う。
% brew install grpcurl
以下コマンドでGetMessageStreamを呼び出すと、待機状態に入る。
% grpcurl -plaintext -import-path proto/ -proto MessageService.proto 0.0.0.0:9090 MessageService/GetMessageStreamこの状態でターミナルを別に立ち上げ、Messageを投稿してみる。成功すればレスポンスが返ってくる。
% grpcurl -d "{\"text\":\"hello\",\"create_time\":$(node -e 'console.log(Date.now())'),\"author_name\":\"aanrii\"}" -import-path proto/ -proto MessageService.proto -plaintext -v 0.0.0.0:9090 MessageService/PostMessage Resolved method descriptor: rpc PostMessage ( .Message ) returns ( .PostMessageResponse ); Request metadata to send: (empty) Response headers received: accept-encoding: identity,gzip content-type: application/grpc grpc-accept-encoding: identity,deflate,gzip Response contents: { "status": "ok" } Response trailers received: (empty) Sent 1 request and received 1 responseGetMessageStreamを実行したウィンドウに戻ると、受信したMessageが表示されている。
{ "text": "hello", "createTime": "1570468135968", "authorName": "aanrii" }proxyの準備・実行
現状のgRPC-Webの仕様だと、ブラウザから直接gRPCサーバに接続することはできず、プロキシを挟む必要がある (詳細) 。
ここでは、公式の例に倣って、envoyを利用する。まず、envoy.yamlに設定を記述する。backend/proxy/envoy.yamladmin: access_log_path: /tmp/admin_access.log address: socket_address: { address: 0.0.0.0, port_value: 9901 } static_resources: listeners: - name: listener_0 address: socket_address: { address: 0.0.0.0, port_value: 8080 } filter_chains: - filters: - name: envoy.http_connection_manager config: codec_type: auto stat_prefix: ingress_http route_config: name: local_route virtual_hosts: - name: local_service domains: ["*"] routes: - match: { prefix: "/" } route: cluster: message_service max_grpc_timeout: 0s cors: allow_origin: - "*" allow_methods: GET, PUT, DELETE, POST, OPTIONS allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout max_age: "1728000" expose_headers: custom-header-1,grpc-status,grpc-message http_filters: - name: envoy.grpc_web - name: envoy.cors - name: envoy.router clusters: - name: message_service connect_timeout: 0.25s type: logical_dns http2_protocol_options: {} lb_policy: round_robin hosts: [{ socket_address: { address: host.docker.internal, port_value: 9090 }}](Docker Desktop for Macで動かすためには、公式のenvoy.yamlのL45を
hosts: [{ socket_address: { address: host.docker.internal, port_value: 9090 }}]
と書き換える必要がある)そして、envoyを実行するためのDockerfileを記述する。
backend/proxy/DockerfileFROM envoyproxy/envoy:latest COPY ./envoy.yaml /etc/envoy/envoy.yaml CMD /usr/local/bin/envoy -c /etc/envoy/envoy.yaml EXPOSE 8080そして、以下のコマンドでDockerイメージのビルド、起動を行う。
% docker build -t waiwai-chat-grpc/envoy -f ./Dockerfile . % docker run -d -p 8080:8080 -p 9901:9901 waiwai-chat-grpc/envoy:latest前述のgrpcurlコマンドを、9090から8080にポートを変更して実行してみると、プロキシが機能していることがわかる。
% grpcurl -plaintext -import-path proto/ -proto MessageService.proto 0.0.0.0:8080 MessageService/GetMessageStream { "text": "hello", "createTime": "1570468135968", "authorName": "aanrii" }これで、最低限それらしい挙動をするgRPCサーバが完成した。
フロントエンドの開発
パッケージのインストール
今回はフロントエンドも、バックエンドと同じくTypeScriptで記述していく。
create-react-appを用いて、React + TypeScriptのボイラープレートから開発を始める。% yarn create react-app frontend --typescript
続いて、gRPCを利用するためのパッケージを追加する。
% yarn add @improbable-eng/grpc-web ts-protoc-genフロントエンドではts-protoc-genを用いて.protoファイルをjsファイルに変換する。ここでも、バックエンド同様protoc.shを用意する。
frontend/protoc.sh% #!/usr/bin/env bash set -eu # protoファイルがあるディレクトリへの相対パス PROTO_SRC=../proto # 生成したjs、tsファイルを格納したいディレクトリへの相対パス PROTO_DEST=./src/proto mkdir -p ${PROTO_DEST} # protoc-gen-tsへのパス PROTOC_GEN_TS_PATH="$(yarn bin)/protoc-gen-ts" protoc \ --plugin="protoc-gen-ts=${PROTOC_GEN_TS_PATH}" \ --js_out="import_style=commonjs,binary:${PROTO_DEST}" \ --ts_out="service=true:${PROTO_DEST}" \ -I ${PROTO_SRC} $(find ${PROTO_SRC} -name "*.proto")このスクリプトを動かすには別途protocのインストールが必要となる。
% brew install protoc
実装
まず、gRPCクライアントを生成し、あらゆるコンポーネントから利用できるようにするためのHOCを作る。
frontend/src/attachMessageServiceClient.tsximport React from 'react'; import { MessageServiceClient } from '../proto/MessageService_pb_service'; export type MessageServiceClientAttached = { client: MessageServiceClient; }; const client = new MessageServiceClient(`http://0.0.0.0:8080`); const attachMessageServiceClient = <P extends {}>(WrappedComponent: React.ComponentType<P & MessageServiceClientAttached>) => class MessageServiceAttached extends React.Component<P> { render() { return <WrappedComponent {...this.props} client={client} />; } }; export default attachMessageServiceClient;投稿フォームは次のようにする。フォームに入力された文字列をもとにMessageを生成し、postMessageを実行する。
frontend/src/components/PostForm.tsximport React, { useState, FormEvent } from 'react'; import { Message as ProtoMessage } from '../proto/MessageService_pb'; import attatchMessageServiceClient, { MessageServiceClientAttached } from './attatchMessageServiceClient'; const PostForm: React.FC<{ initialInputText?: string } & MessageServiceClientAttached> = ({ initialInputText = '', client, }) => { const [inputText, setInputText] = useState(initialInputText); const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); const currentDate = Date.now(); const message = new ProtoMessage(); message.setAuthorName('hoge'); // 一旦適当に埋める message.setCreateTime(currentDate); message.setText(inputText); client.postMessage(message, (error, response) => console.log(error == null ? error : response)); setInputText(''); }; return ( <div> <form onSubmit={handleSubmit}> <input type="text" name="inputText" value={inputText} onChange={e => setInputText(e.target.value)} /> <input type="submit" value="Submit" /> </form> </div> ); }; export default attatchMessageServiceClient(PostForm);ログの表示に再しては、まずgetMessageStreamによってstreamを得て、Messageが得られるたび随時更新するよう、ハンドラを登録しておく。
frontend/src/components/MessageList.tsximport React from 'react'; import Message from './Message'; import { Message as ProtoMessage } from '../proto/MessageService_pb'; import attatchMessageServiceClient, { MessageServiceClientAttached } from './attatchMessageServiceClient'; import { Empty } from 'google-protobuf/google/protobuf/empty_pb'; interface MessageListState { protoMessageList: ProtoMessage.AsObject[]; } class MessageList extends React.Component<void & MessageServiceClientAttached, MessageListState> { constructor(props: {} & MessageServiceClientAttached) { super(props); this.state = { protoMessageList: [] }; // message streamの取得 const messageStream = props.client.getMessageStream(new Empty()); // streamからmessageを受け取るたび、それをprotoMessageListに格納するハンドラを登録する messageStream.on('data', message => { const newProtoMessageList = [message.toObject()].concat(this.state.protoMessageList); this.setState({ protoMessageList: newProtoMessageList }); }); } render() { return ( <div> {this.state.protoMessageList.map(protoMessage => ( <Message {...protoMessage} key={protoMessage.createTime} /> ))} </div> ); } } export default attatchMessageServiceClient(MessageList);MessageのためのPresentational Componentも適当に作っておく。
frontend/src/components/Message.tsximport React from 'react'; import { Message as ProtoMessage } from '../proto/MessageService_pb'; const Message: React.SFC<ProtoMessage.AsObject> = protoMessage => ( <div> {protoMessage.text} ({new Date(protoMessage.createTime).toString()}) </div> ); export default Message;App,tsxも書き換えよう。
frontend/src/App.tsximport React from 'react'; import PostForm from './components/PostForm'; import MessageList from './components/MessageList'; const App: React.FC = () => { return ( <div> <PostForm /> <MessageList /> </div> ); }; export default App;ここで、
yarn start
を起動してみよう。ブラウザを確認すると、大量にエラーが出ているのがわかる。./src/proto/MessageService_pb.js Line 27: 'proto' is not defined no-undef Line 30: 'proto' is not defined no-undef Line 31: 'COMPILED' is not defined no-undef Line 36: 'proto' is not defined no-undefこれについては、実際のところ、protocで生成されたjsファイルをeslintのチェックから除外する方法が有効だ (参考)。ちょっとダサいが。
/* eslint-disable */再び
yarn start
を実行すると、問題なくフロントが表示されることがわかる。まとめ
ここまでで、gRPC-Web + React + Node.js + TypeScriptを用いて、少なくともローカルで動くチャットアプリケーションを作成した。続編 (今後書く予定) では、GCPへデプロイを行い、Kubernetes (GKE) 上でgRPCサーバを動かし、またその冗長化、負荷分散、SSL対応のための設定について紹介する。
参考文献
- 投稿日:2019-10-09T18:11:44+09:00
nodenvでNode.jsを複数バージョン管理する
nodenvについて
プロジェクトごとにNode.jsのバージョンが異なる場合に、複数のNode.jsを管理・切り替えをすることができます。
※nodeenvではないインストール ※for Mac
homebrewでインストールできます。
※nodeenvではないbrew install nodenv管理しているバージョンの一覧を確認
以下のコマンドで現在nodenvで管理しているNodeの一覧が確認できます。
$ nodenv versions * system (set by /Users/username/.nodenv/version) 10.14.1 10.16.0Nodeを追加する
まず、以下のコマンドでインストールできるNodeを確認できます。
$ nodenv install --list Available versions: ...一覧から欲しいバージョンを指定して、インストール。
$ nodenv install 12.8.0Nodeを切り替える
プロジェクトディレクトリに移動し、以下のコマンドで使いたいバージョンを指定。
$ nodenv local 10.14.1Nodeのバージョンを確認すると切り替わっています。
$ node -v v10.14.1(おまけ)nodenvを使っている時のnpmの場所
~/.nodenv/versions/10.16.0/lib/node_modules/npm
※バージョンは都度確認IDEでパッケージの指定をしたい場合にけっこう探したので、memo
- 投稿日:2019-10-09T16:55:20+09:00
Google App EngineでWebアプリを公開するまでの一連の流れ
はじめに
node.jsで作成したWebアプリをGoogle App Engine(以下、GAE)にデプロイするまでの一連の流れをまとめています。
手順
1. ローカルマシンにnode.jsのインストール
ローカルで作成したnode.jsアプリをテストしたい方は、node.jsをインストールしてください。
ローカルでテストをしない方はこの手順を省略しても問題ありません。ダウンロード先
https://nodejs.org/ja/download/2. Cloud SDKのインストール
インストール手順
https://cloud.google.com/sdk/install?hl=ja3. 必要なファイルの作成
適当なフォルダを作成し、
app.js
とapp.yaml
とpackage.json
を保存します。
参考までに、express(node.jsのWebアプリケーションフレームワーク)を用いて Hello, world! を表示するプログラムを記載しておきます。
なおapp.js
とapp.yaml
はGAEのチュートリアルで扱われているものです。app.js'use strict'; const express = require('express'); const app = express(); app.get('/', (req, res) => { res.status(200).send('Hello, world!'); }); if (module === require.main) { const server = app.listen(process.env.PORT || 8080, () => { const port = server.address().port; console.log(`App listening on port ${port}`); }); } module.exports = app;app.yamlruntime: nodejs10packege.json{ "scripts": { "start": "node app.js" }, "dependencies": { "express": "^4.16.4" } }4. アプリの有効化
GAEの画面で「設定を有効化」をクリックしてください。
次に「アプリケーションを有効にする」をクリックしてください。
5. デプロイ
3で作成したフォルダ内で
gcloud app deploy
コマンドを実行してください。6. Webアプリの表示
gcloud app browse
コマンドを実行してください。
Hello, world! が表示されます。もしくはGAEの画面からもWebアプリの表示ができます。
サービス名(ここではdefault)をクリックしても表示できます。バージョンをクリックしても表示できます。
トラブルシューティング
デプロイ時にエラーが出る
gcloud app deploy
コマンド実行時に以下のエラーが表示される場合は、「4. アプリの有効化」の設定をしていないものと思われます。
ERROR: (gcloud.app.deploy) Unable to deploy to application [プロジェクトID] with status [USER_DISABLED]: Deploying to stopped apps is not allowed.
Hello, world! が表示されない
Stackdriver Loggingでログを確認してください。
以下のメッセージが表示されている場合は、packege.jsonで"start": "node app.js"
の指定がされていないものと思われます。
この記載がないとデフォルトでserver.jsを実行しようとするようです。
Error: Cannot find module '/srv/server.js' at Function.Module.
- 投稿日:2019-10-09T16:18:29+09:00
Windows10にnobleをインストールする
nobleとは
Node.jsでBluetooth LEを扱うためのモジュールです。
https://github.com/noble/noble
環境
- Windows 10 Professional
- Node v10.16.3
- Bluetooth USB アダプタ エレコムLBT-UAN05C2
※Windowsの場合、Bluetoothアダプタのデバイスドライバを差し替える作業をします。なるべくPC内蔵のBluetoothを使用せず、外付けのものを使用することをお勧めします。
Node.jsのインストール
こちらの記事が非常にわかりやすいです。
https://qiita.com/maecho/items/ae71da38c88418b806ffPowershellでバージョンを確認します。(コマンドプロンプトでもいいです。)
> node -v v10.16.3nobleをインストール
試しにインストールしてみる
> npm install noble (中略) gyp ERR! configure error gyp ERR! stack Error: Command failed: C:\ProgramData\Anaconda3\python.EXE -c import sys; print "%s.%s.%s" % sys.version_info[:3]; gyp ERR! stack File "<string>", line 1 gyp ERR! stack import sys; print "%s.%s.%s" % sys.version_info[:3];Python2じゃなきゃダメみたいです。
調べてみると、
node-gyp
のインストールに失敗しているようです。
下記の記事で対策が紹介されています。
https://qiita.com/AkihiroTakamura/items/25ba516f8ec624e66ee7PowerShellを管理者で実行して
> npm install --global windows-build-tools ---------- Visual Studio Build Tools ---------- Successfully installed Visual Studio Build Tools. ------------------- Python -------------------- Successfully installed Python 2.7 Now configuring the Visual Studio Build Tools and Python... All done! + windows-build-tools@5.2.2 added 145 packages from 99 contributors in 244.329s再チャレンジ
> npm install noble (中略) + noble@1.9.1 added 63 packages from 44 contributors, updated 1 package and audited 111 packages in 9.459sうまくいきました。
デバイスドライバの差し替え
nobleのREADMEによると、Bluetoothアダプタのデバイスドライバを、ZadigというツールでWinUSBというドライバに書き換える必要があります。
Bluetoothアダプタを選択して
Replace Driver
をクリック
サンプルコードを実行
こちらの記事のコードで周辺のBLEデバイスを検索できれば成功です。
https://qiita.com/n0bisuke/items/00503d2b18fc4f413c4eインストールされたpython2のパス問題
windows-build-tools
を入れたときにpython2がインストールされましたが、
Windowsの環境変数からPATH
をみると
C:\Users\user\.windows-build-tools\python27
というパスが追加されていました。私の環境ではこれのせいでpythonのデフォルトがpython2になってしまいました。
解決策としては、python3のpython.exe
の名前をpython3.exe
とする方法があります。ただ、この方法ではなんとなく悔しいので、何かいい方法があれば教えていただけると嬉しいです。
- 投稿日:2019-10-09T12:37:27+09:00
node-sass 導入方法
node-sassの導入方法
まずnode-sassを使用するにはNode.jsがインストールされている必要があります。
homebrew等を使ってNode.jsのバージョン管理を行う方法もありますが、Sassのコンパイルのみでしか利用しない場合は公式サイトからファイルをダウンロードしてインストールするのが簡単なのでおすすめです。
インストールが完了したら、Windowsの場合はコマンドプロンプト、Macの場合はターミナルを起動して、下記のコマンドを入力してください。
node -vv10.16.3のように表示されたらインストール成功です。
(数字は上記と同じでなくても問題ありません)プロジェクト内にnode-sassをインストール
まずはpackage.jsonを作成する必要があります。
プロジェクト内でnpmを配置するディレクトリにcdコマンドで移動します。移動したら下記のコマンドを入力
npm init
入力すると
package name: (パッケージの名前) version:(1.0.0) でバージョンを生成するかどうか、問題なければEnter description: 概要説明 (例 sample Landing page) 無記載でもよければEnter entry point:(index.js) そのままエンターを押すと初期表示ファイルをindex.jsファイルのままにする test command: 無記載でEnterを押すとテストコマンドを設定せず進める git repository: Githubに保存するリポジトリ情報を登録するかどうか keywords: npm公開時に使用されるkeywords設定 無記載Enterで設定せず進める author: 作者情報 (例 Accio licens: defaultのISCで問題なければそのままEnter入力し終えると最後に上記の情報でいいか聞かれるので問題なければ
yes
上記の作業を終えるとディレクトリ内にpackage.jsonが作成されている。
作成されてるのを確認したら
npm install node-sass --save-devこれでフォルダ内にnode_moduleが作成される。
次のステップ
ディレクトリ内に
mkdir sass
でフォルダを作る
cd sass
で移動しtouch main.scss
ここまで出来たらpackage.jsonのscriptsを変更する
"scripts": { "compile:sass": "node-sass sass/main.scss css/style.css -w" }*自身のディレクトリ構造に合わせ変更してください
この状態で
npm run compile:sass
を実行するとコンパイルされます。
-w(watch)オプションをつけているので変更を加えた場合はその都度コンパイルが実行されます。-wオプションをつけないと変更の度にコマンドを実行せねばならずめんどくさいです。
終了するときは^Cです。まとめ
導入の流れは以上となります。
間違い、他にいい方法がありましたら教えていただけると助かります。この記事が少しでもお役に立てれば幸いです。
- 投稿日:2019-10-09T12:37:27+09:00
【node-sass】 導入方法
node-sassの導入方法
まずnode-sassを使用するにはNode.jsがインストールされている必要があります。
homebrew等を使ってNode.jsのバージョン管理を行う方法もありますが、Sassのコンパイルのみでしか利用しない場合は公式サイトからファイルをダウンロードしてインストールするのが簡単なのでおすすめです。
インストールが完了したら、Windowsの場合はコマンドプロンプト、Macの場合はターミナルを起動して、下記のコマンドを入力してください。
node -vv10.16.3のように表示されたらインストール成功です。
(数字は上記と同じでなくても問題ありません)プロジェクト内にnode-sassをインストール
まずはpackage.jsonを作成する必要があります。
プロジェクト内でnpmを配置するディレクトリにcdコマンドで移動します。移動したら下記のコマンドを入力
npm init
入力すると
package name: (パッケージの名前) version:(1.0.0) でバージョンを生成するかどうか、問題なければEnter description: 概要説明 (例 sample Landing page) 無記載でもよければEnter entry point:(index.js) そのままエンターを押すと初期表示ファイルをindex.jsファイルのままにする test command: 無記載でEnterを押すとテストコマンドを設定せず進める git repository: Githubに保存するリポジトリ情報を登録するかどうか keywords: npm公開時に使用されるkeywords設定 無記載Enterで設定せず進める author: 作者情報 (例 Accio licens: defaultのISCで問題なければそのままEnter入力し終えると最後に上記の情報でいいか聞かれるので問題なければ
yes
上記の作業を終えるとディレクトリ内にpackage.jsonが作成されている。
作成されてるのを確認したら
npm install node-sass --save-devこれでフォルダ内にnode_moduleが作成される。
次のステップ
ディレクトリ内に
mkdir sass
でフォルダを作る
cd sass
で移動しtouch main.scss
ここまで出来たらpackage.jsonのscriptsを変更する
"scripts": { "compile:sass": "node-sass sass/main.scss css/style.css -w" }*自身のディレクトリ構造に合わせ変更してください
この状態で
npm run compile:sass
を実行するとコンパイルされます。
-w(watch)オプションをつけているので変更を加えた場合はその都度コンパイルが実行されます。-wオプションをつけないと変更の度にコマンドを実行せねばならずめんどくさいです。
終了するときは^Cです。まとめ
導入の流れは以上となります。
間違い、他にいい方法がありましたら教えていただけると助かります。この記事が少しでもお役に立てれば幸いです。
- 投稿日:2019-10-09T00:26:37+09:00
Node.js で相対パス地獄(../../../../)を絶対パスっぽく解決させる
いつもつらい思いをしていたけどそこまでクリティカルじゃなかったから見て見ぬ振りをしてきたけどそろそろ不味くなってきたので解決策はないかと探してみたら結構良さそうなのを見つけたのでメモ。
こんな感じのやつで$ tree src src ├── lib │ └── dep1 │ └── dep2 │ └── dep3 │ └── dep4 │ └── dep5 │ └── index.js └── pages └── dep1 └── dep2 └── dep3 └── dep4 └── dep5 └── index.js
こういうやつを// src/pages/dep1/dep2/dep3/dep4/dep5/index.js import hey from "../../../../../../lib/dep1/dep2/dep3/dep4/dep5";こうしたいimport hey from "heyapp/lib/dep1/dep2/dep3/dep4/dep5";下記 npm module を使う
https://github.com/Rush/link-module-alias$ npm i -D link-module-aliaspackage.jsonに下記を追加{ "_moduleAliases": { "heyapp": "src" }, }$ link-module-alias
するとこれで参照できるようになるimport hey from "heyapp/lib/dep1/dep2/dep3/dep4/dep5";仕組みとしては、node_modules の下に dir を切って、指定された path に対して
fs.symlink
で シンボリックリンクを張っているだけっぽい。万が一競合していたら error 吐いてくれたりしそうなのでそんなに危険じゃなさそう。
https://github.com/Rush/link-module-alias/blob/master/index.js#L187node_modules 配下を多少なりとも荒らすので、postinstall にこいつをフックさせるようにと READEME に書かれているので従っておこう。
package.json{ "scripts": { "postinstall": "link-module-alias" }, }使い方次第では結構便利な感じになりそうなので良さそう
package.json{ "_moduleAliases": { "heyapp": "src", "heyapp:lib": "otherdir/some/lib", "heyapp:test": "__test__" }, }