20191009のNode.jsに関する記事は8件です。

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

上記の通り、購入後に変更されるのは、entitledentitlementReasonpurchasableactiveEntitlementCountの4つである。
購入済みかどうか判断するためには、entitledを使うのがよいかと思います。
これとスキル内商品が複数ありうる可能性を加味して、referenceNameで識別すればよいかと思います。
消費型の場合は、消費した回数をカウントする必要がありそうですが、今回は説明を割愛します。
スキル内課金のドキュメントによれば、activeEntitlementCountは購入回数を示すだけであり、消費回数は別途開発者が管理しなければならないようです。

スキル内商品レコードのセッションアトリビュートへの追加

スキルの内容にも依存しますが、
「スキル内商品を購入したユーザーには、その商品の使用権があり、スキル内商品を購入していないユーザーには、その商品の使用権がありません。」
を実現するために、多くのIntentのHandlerに上記のようなスキル内商品レコードを取得する処理が必要になることが想定されます。

Amazon JapanのAlexa道場においては、リクエストのインターセプターを使用して、スキル内商品レコードのうち、資格のある商品をハンドラーのセッションアトリビュートに付与する実装がソースに記載されていましたので、紹介します。
Alexa道場のサンプルソースコードで、リクエストのインターセプターを初めて知りました。なかなかすべてのドキュメントに目を通すのは難しいため、Alexa道場ででてくるようなソースコードはタメになります。

実施方法は簡単で、

  1. スキル内商品レコードの取得
  2. リクエストのインターセプターを使って、ハンドラーにセッションアトリビュートを設定

だけです。

リクエストのインターセプターは、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();

おわりに

今回は、主に以下について説明しました。

  • 購入前後で変化するスキル内商品レコード
  • スキル内商品レコードの取得方法
  • スキル内商品レコードのセッションアトリビュートへの追加
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.proto
syntax = "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.ts
import { 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.ts
import * 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 response

GetMessageStreamを実行したウィンドウに戻ると、受信したMessageが表示されている。


{
  "text": "hello",
  "createTime": "1570468135968",
  "authorName": "aanrii"
}

proxyの準備・実行

現状のgRPC-Webの仕様だと、ブラウザから直接gRPCサーバに接続することはできず、プロキシを挟む必要がある (詳細) 。
ここでは、公式の例に倣って、envoyを利用する。まず、envoy.yamlに設定を記述する。

backend/proxy/envoy.yaml
admin:
  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/Dockerfile
FROM 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.tsx
import 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.tsx
import 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.tsx
import 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.tsx
import 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.tsx
import 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対応のための設定について紹介する。

参考文献

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

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.0

Nodeを追加する

まず、以下のコマンドでインストールできるNodeを確認できます。

$ nodenv install --list
Available versions:
...

一覧から欲しいバージョンを指定して、インストール。

$ nodenv install 12.8.0

Nodeを切り替える

プロジェクトディレクトリに移動し、以下のコマンドで使いたいバージョンを指定。

$ nodenv local 10.14.1

Nodeのバージョンを確認すると切り替わっています。

$ node -v
v10.14.1

(おまけ)nodenvを使っている時のnpmの場所

~/.nodenv/versions/10.16.0/lib/node_modules/npm
※バージョンは都度確認

IDEでパッケージの指定をしたい場合にけっこう探したので、memo

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

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=ja

3. 必要なファイルの作成

適当なフォルダを作成し、app.jsapp.yamlpackage.jsonを保存します。
参考までに、express(node.jsのWebアプリケーションフレームワーク)を用いて Hello, world! を表示するプログラムを記載しておきます。
なおapp.jsapp.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.yaml
runtime: nodejs10
packege.json
{
    "scripts": {
        "start": "node app.js"
    },
    "dependencies": {
        "express": "^4.16.4"
    }
}

4. アプリの有効化

GAEの画面で「設定を有効化」をクリックしてください。

image.png

次に「アプリケーションを有効にする」をクリックしてください。

image.png

5. デプロイ

3で作成したフォルダ内でgcloud app deployコマンドを実行してください。

6. Webアプリの表示

gcloud app browse コマンドを実行してください。
Hello, world! が表示されます。

もしくはGAEの画面からもWebアプリの表示ができます。
サービス名(ここではdefault)をクリックしても表示できます。

image.png

バージョンをクリックしても表示できます。

image.png

トラブルシューティング

デプロイ時にエラーが出る

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.

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

Windows10にnobleをインストールする

nobleとは

Node.jsでBluetooth LEを扱うためのモジュールです。

https://github.com/noble/noble

環境

※Windowsの場合、Bluetoothアダプタのデバイスドライバを差し替える作業をします。なるべくPC内蔵のBluetoothを使用せず、外付けのものを使用することをお勧めします。

Node.jsのインストール

こちらの記事が非常にわかりやすいです。
https://qiita.com/maecho/items/ae71da38c88418b806ff

Powershellでバージョンを確認します。(コマンドプロンプトでもいいです。)

> node -v
v10.16.3

nobleをインストール

試しにインストールしてみる

> 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/25ba516f8ec624e66ee7

PowerShellを管理者で実行して

> 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というドライバに書き換える必要があります。

ツールを起動して
image.png

Options -> List All Devices
image.png

Bluetoothアダプタを選択してReplace Driverをクリック
image.png

サンプルコードを実行

こちらの記事のコードで周辺の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とする方法があります。

ただ、この方法ではなんとなく悔しいので、何かいい方法があれば教えていただけると嬉しいです。

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

node-sass 導入方法

node-sassの導入方法

まずnode-sassを使用するにはNode.jsがインストールされている必要があります。

homebrew等を使ってNode.jsのバージョン管理を行う方法もありますが、Sassのコンパイルのみでしか利用しない場合は公式サイトからファイルをダウンロードしてインストールするのが簡単なのでおすすめです。

Node.js公式サイト

インストールが完了したら、Windowsの場合はコマンドプロンプト、Macの場合はターミナルを起動して、下記のコマンドを入力してください。

node -v

v10.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です。

まとめ

導入の流れは以上となります。
間違い、他にいい方法がありましたら教えていただけると助かります。

この記事が少しでもお役に立てれば幸いです。

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

【node-sass】 導入方法

node-sassの導入方法

まずnode-sassを使用するにはNode.jsがインストールされている必要があります。

homebrew等を使ってNode.jsのバージョン管理を行う方法もありますが、Sassのコンパイルのみでしか利用しない場合は公式サイトからファイルをダウンロードしてインストールするのが簡単なのでおすすめです。

Node.js公式サイト

インストールが完了したら、Windowsの場合はコマンドプロンプト、Macの場合はターミナルを起動して、下記のコマンドを入力してください。

node -v

v10.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です。

まとめ

導入の流れは以上となります。
間違い、他にいい方法がありましたら教えていただけると助かります。

この記事が少しでもお役に立てれば幸いです。

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

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-alias
package.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#L187

node_modules 配下を多少なりとも荒らすので、postinstall にこいつをフックさせるようにと READEME に書かれているので従っておこう。

package.json
{
  "scripts": {
    "postinstall": "link-module-alias"
  },
}

使い方次第では結構便利な感じになりそうなので良さそう

package.json
{
  "_moduleAliases": {
    "heyapp": "src",
    "heyapp:lib": "otherdir/some/lib",
    "heyapp:test": "__test__"
  },
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む