Go
lambda
Alexa
serverless
AlexaSkillsKit

AlexaスキルをGoとServerlessで書いてみた

背景

昨年のAWS re:InventでアナウンスされたLambdaのGoサポートがリリースされ、これを機にGoに触れてみたいと思いつつ、Alexaも日本でリリースされてから作ってなかったので、Go/LambdaでAlexaスキルを作ってみることにしました。SAMを使ったブログ「Announcing Go Support for AWS Lambda」がリリースと同時にポストされましたが、今回はServerless Frameworkを使うことにしました。

作ったもの

好きな色を答えさせるサンプルスキルであるalexa-skills-kit-color-expert-nodejsalexa-skills-kit-color-expert-pythonをGoで書き換えてみることを今回やってみました。言語を替えての写経となりました^^;

最終的な成果物はGitHub(yamaryu0508/alexa-skills-kit-color-expert-go)にアップしています。

作成手順

今回の作成手順を辿っていきます。次のものはインストール・アカウント設定済みとします。

  • Go言語開発環境
  • AWS CLI
  • Serverless Framework

Serverless Frameworkの設定

Serverless Framework example for Golang and Lambda」を参考に進めます。

まずはサービス作成です。

$ serverless create -u https://github.com/serverless/serverless-golang/ -p alexa-skills-kit-color-expert-go

ディレクトリを移動しておきます。

$ cd alexa-skills-kit-color-expert-go

出来上がった serverless.yml を整えます。

serverless.yml
service: alexa-skills-kit-color-expert-go

provider:
  name: aws
  runtime: go1.x
  stage: dev
  region: ap-northeast-1

package:
 exclude:
   - ./**
 include:
   - ./bin/**

functions:
  ask-go:
    handler: bin/main
    memorySize: 256
    timeout: 10
    events:
      - alexaSkill

serverlessの準備はこれでOKです。

Alexaスキルの作成 (Goコーディング)

Go/Lambdaを書くために、まずはgithub.com/aws/aws-lambda-go/lambdago getします。

$ go get github.com/aws/aws-lambda-go/lambda

そして、まず最初にeventオブジェクトを入れずにレスポンスが思い通りに返せるかを簡単なコードで確認しました。

main.go
package main

import (
    "errors"
    "github.com/aws/aws-lambda-go/lambda"
)

// Response is object-type
type Response struct {
    Version           string `json:"version"`
    Response struct {
        ShouldEndSession bool `json:"shouldEndSession"`
        OutputSpeech     struct {
            Type string `json:"type"`
            Text string `json:"text"`
        } `json:"outputSpeech"`
    } `json:"response"`
}

// Handler is main
func Handler() (Response, error) {
    return Response{
        Version: "1.0",
        Response: {
            ShouldEndSession: true,
            OutputSpeech: {
                Type: "PlainText",
                Text: "Hello",
            },
        },
    }, nil
}

func main() {
    lambda.Start(Handler)
}

サンプルLambdaのGoモデルのドキュメントの見様見真似で書いてみましたが、返したいJSONに対応する構造体をreturnすれば良いことをここでまず理解しました。同時にHandlerへの引数もAlexaのリクエストのJSONに対応する構造体になることに気づきました(Javaもこんな感じなんだろうが、触ったことない)。Lambdaへのトリガーサービスから渡ってくるeventオブジェクトについては、github.com/aws/aws-lambda-go/eventsAPI Gateway等幾つか定義されているので、ここで定義されているものはそのまま使うと良いかと思います。ただ、Alexaのモデルは定義されてなかったので、自分で書いてあげる必要があります(request.goに相当)。

ということで、とりあえずレスポンスを返すことに成功したところで、写経を進めていきます(確認方法は後述のコンパイルとデプロイと同様です)。

リファレンス「カスタムスキルのJSONインターフェースのリファレンス」を確認しながらAlexaスキルのリクエストとレスポンスの構造体をそれぞれ準備します。今回は写経に必要ないobjectを結構省いています(音声再生のためのSSMLの扱い等)ので網羅的には出来ていません。

また、データ構造をJSONと整合させるための定義なので、JSONをGoの構造体に変換してくれる「JSON-to-Go」等を使いながら作業を進めると良さそうです。

alexa/request.go
package alexa

import "time"

/*
 * define structs for Alexa Request
 */

// Session is object-type
type Session struct {
    New         bool   `json:"new"`
    SessionID   string `json:"sessionId"`
    Application struct {
        ApplicationID string `json:"applicationId"`
    } `json:"application"`
    Attributes map[string]interface{} `json:"attributes"`
    User       struct {
        UserID      string `json:"userId"`
        Permissions struct {
            ConsentToken string `json:"consentToken"`
        } `json:"permissions"`
        AccessToken string `json:"accessToken"`
    } `json:"user"`
}

// Slot is object-type
type Slot struct {
    Name               string                 `json:"name"`
    Value              string                 `json:"value"`
    ConfirmationStatus string                 `json:"confirmationStatus"`
    Resolutions        map[string]interface{} `json:"resolutions"`
}

// RequestIntent is object-type
type RequestIntent struct {
    Name               string `json:"name"`
    ConfirmationStatus string `json:"confirmationStatus"`
    Slots              map[string]Slot
}

// RequestDetail is map-type
type RequestDetail struct {
    Locale    string        `json:"locale"`
    Timestamp time.Time     `json:"timestamp"`
    Type      string        `json:"type"`
    RequestID string        `json:"requestId"`
    Intent    RequestIntent `json:"intent"`
}

// Request is object-type
type Request struct {
    Version string  `json:"version"`
    Session Session `json:"session"`
    Context struct {
        System struct {
            Application struct {
                ApplicationID string `json:"applicationId"`
            } `json:"application"`
            User struct {
                UserID      string `json:"userId"`
                Permissions struct {
                    ConsentToken string `json:"consentToken"`
                } `json:"permissions"`
                AccessToken string `json:"accessToken"`
            } `json:"user"`
            Device struct {
                DeviceID            string `json:"deviceId"`
                SupportedInterfaces struct {
                    AudioPlayer struct {
                    } `json:"AudioPlayer"`
                } `json:"supportedInterfaces"`
            } `json:"device"`
            APIEndpoint string `json:"apiEndpoint"`
        } `json:"System"`
        AudioPlayer struct {
            Token                string  `json:"token"`
            OffsetInMilliseconds float32 `json:"offsetInMilliseconds"`
            PlayerActivity       string  `json:"playerActivity"`
        } `json:"AudioPlayer"`
    } `json:"context"`
    Request RequestDetail `json:"request"`
}

レスポンスについては、alexa-skills-kit-color-expert-pythonを参考にBuildSpeechletResponseBuildResponseと言った関数も定義しました。

alexa/response.go
package alexa

/*
 * define structs for Alexa Response
 */

// OutputSpeech is object-type
type OutputSpeech struct {
    Type string `json:"type"`
    Text string `json:"text"`
}

// Card is object-type
type Card struct {
    Type    string `json:"type"`
    Title   string `json:"title"`
    Content string `json:"content"`
}

// Reprompt is object-type
type Reprompt struct {
    OutputSpeech OutputSpeech `json:"outputSpeech"`
}

// SpeechletResponse is object-type
type SpeechletResponse struct {
    ShouldEndSession bool         `json:"shouldEndSession"`
    OutputSpeech     OutputSpeech `json:"outputSpeech"`
    Card             Card         `json:"card"`
    Reprompt         Reprompt     `json:"reprompt"`
}

// SessionAttributes is map-type
type SessionAttributes map[string]interface{}

// Response is object-type
type Response struct {
    Version           string            `json:"version"`
    SessionAttributes SessionAttributes `json:"sessionAttributes"`
    Response          SpeechletResponse `json:"response"`
}

// BuildSpeechletResponse is function-type
func BuildSpeechletResponse(title string, output string, repromptText string, shouldEndSession bool) SpeechletResponse {
    return SpeechletResponse{
        ShouldEndSession: shouldEndSession,
        OutputSpeech: OutputSpeech{
            Type: "PlainText",
            Text: output,
        },
        Card: Card{
            Type:    "Simple",
            Title:   "SessionSpeechlet - " + title,
            Content: "SessionSpeechlet - " + output,
        },
        Reprompt: Reprompt{
            OutputSpeech: OutputSpeech{
                Type: "PlainText",
                Text: repromptText,
            },
        },
    }
}

// BuildResponse is function-type
func BuildResponse(sessionAttributes SessionAttributes, speechletResponse SpeechletResponse) Response {
    return Response{
        Version:           "1.0",
        Response:          speechletResponse,
        SessionAttributes: sessionAttributes,
    }
}

そして、リクエストとレスポンスが定義できたら、Alexaスキルのメイン部分の写経に入ります。

main.go
package main

import (
    "errors"
    "fmt"

    "./alexa"
    "github.com/aws/aws-lambda-go/lambda"
)

var (
    // ErrInvalidIntent is error-object
    ErrInvalidIntent = errors.New("Invalid intent")
)

/*
 * Functions that control the skill's behavior
 */

// GetWelcomeResponse is function-type
func GetWelcomeResponse() alexa.Response {
    sessionAttributes := make(map[string]interface{})
    cardTitle := "Welcome"
    speechOutput := "Welcome to the Alexa Skills Kit sample. Please tell me your favorite color by saying, my favorite color is red"
    repromptText := "Please tell me your favorite color by saying, my favorite color is red."
    shouldEndSession := false
    return alexa.BuildResponse(sessionAttributes, alexa.BuildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession))
}

// HandleSessionEndRequest is function-type
func HandleSessionEndRequest() alexa.Response {
    sessionAttributes := make(map[string]interface{})
    cardTitle := "Session Ended"
    speechOutput := "Thank you for trying the Alexa Skills Kit sample. Have a nice day! "
    repromptText := ""
    shouldEndSession := true
    return alexa.BuildResponse(sessionAttributes, alexa.BuildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession))
}

// CreateFavoriteColorAttributes is function-type
func CreateFavoriteColorAttributes(favoriteColor string) alexa.SessionAttributes {
    sessionAttributes := make(map[string]interface{})
    sessionAttributes["favoriteColor"] = favoriteColor
    return sessionAttributes
}

// SetColorInSession is function-type
func SetColorInSession(intent alexa.RequestIntent, session alexa.Session) alexa.Response {
    cardTitle := intent.Name
    sessionAttributes := make(map[string]interface{})
    shouldEndSession := false
    speechOutput := ""
    repromptText := ""

    if color, ok := intent.Slots["Color"]; ok {
        favoriteColor := color.Value
        sessionAttributes = CreateFavoriteColorAttributes(favoriteColor)
        speechOutput = "I now know your favorite color is " + favoriteColor +
            ". You can ask me your favorite color by saying, " +
            "what's my favorite color?"
        repromptText = "You can ask me your favorite color by saying, " +
            "what's my favorite color?"
    } else {
        speechOutput = "I'm not sure what your favorite color is. " +
            "Please try again."
        repromptText = "I'm not sure what your favorite color is. " +
            "You can tell me your favorite color by saying, " +
            "my favorite color is red."
    }
    return alexa.BuildResponse(sessionAttributes, alexa.BuildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession))
}

// GetColorFromSession is function-type
func GetColorFromSession(intent alexa.RequestIntent, session alexa.Session) alexa.Response {
    cardTitle := intent.Name
    sessionAttributes := make(map[string]interface{})
    shouldEndSession := false
    speechOutput := ""
    repromptText := ""

    if favoriteColor, ok := session.Attributes["favoriteColor"].(string); ok {
        speechOutput = "Your favorite color is " + favoriteColor + ". Goodbye."
        shouldEndSession = true
    } else {
        speechOutput = "I'm not sure what your favorite color is, you can say, my favorite color is red"
    }
    return alexa.BuildResponse(sessionAttributes, alexa.BuildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession))
}

// GetNoEntityResponse is function-type
func GetNoEntityResponse() alexa.Response {
    cardTitle := ""
    sessionAttributes := make(map[string]interface{})
    shouldEndSession := false
    speechOutput := ""
    repromptText := ""
    return alexa.BuildResponse(sessionAttributes, alexa.BuildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession))
}

/*
 * Events
 */

// OnSessionStarted is function-type
func OnSessionStarted(sessionStartedRequest map[string]string, session alexa.Session) (alexa.Response, error) {
    fmt.Println("OnSessionStarted requestId=" + sessionStartedRequest["requestId"] + ", sessionId=" + session.SessionID)
    return GetNoEntityResponse(), nil
}

// OnLaunch is function-type
func OnLaunch(launchRequest alexa.RequestDetail, session alexa.Session) (alexa.Response, error) {
    fmt.Println("OnLaunch requestId=" + launchRequest.RequestID + ", sessionId=" + session.SessionID)
    return GetWelcomeResponse(), nil
}

// OnIntent is function-type
func OnIntent(intentRequest alexa.RequestDetail, session alexa.Session) (alexa.Response, error) {
    fmt.Println("OnLaunch requestId=" + intentRequest.RequestID + ", sessionId=" + session.SessionID)
    intent := intentRequest.Intent
    intentName := intentRequest.Intent.Name

    if intentName == "MyColorIsIntent" {
        return SetColorInSession(intent, session), nil
    } else if intentName == "WhatsMyColorIntent" {
        return GetColorFromSession(intent, session), nil
    } else if intentName == "AMAZON.HelpIntent" {
        return GetWelcomeResponse(), nil
    } else if intentName == "AMAZON.StopIntent" || intentName == "AMAZON.CancelIntent" {
        return HandleSessionEndRequest(), nil
    }
    return alexa.Response{}, ErrInvalidIntent
}

// OnSessionEnded is function-type
func OnSessionEnded(sessionEndedRequest alexa.RequestDetail, session alexa.Session) (alexa.Response, error) {
    fmt.Println("OnSessionEnded requestId=" + sessionEndedRequest.RequestID + ", sessionId=" + session.SessionID)
    return GetNoEntityResponse(), nil
}

// Handler is main
func Handler(event alexa.Request) (alexa.Response, error) {
    fmt.Println("event.session.application.applicationId=" + event.Session.Application.ApplicationID)

    eventRequestType := event.Request.Type
    if event.Session.New {
        return OnSessionStarted(map[string]string{"requestId": event.Request.RequestID}, event.Session)
    } else if eventRequestType == "LaunchRequest" {
        return OnLaunch(event.Request, event.Session)
    } else if eventRequestType == "IntentRequest" {
        return OnIntent(event.Request, event.Session)
    } else if eventRequestType == "SessionEndedRequest" {
        return OnSessionEnded(event.Request, event.Session)
    }
    return alexa.Response{}, ErrInvalidIntent
}

func main() {
    lambda.Start(Handler)
}

やはり、実際の作業ではalexa/request.goalexa/response.gomain.goの間で行き来しながら、書きました。ただ、VSCodeに入れていたLintツールがコンパイルレベルのチェックはかけてくれていたので、実際にコンパイルやデプロイを経る途中確認はあまり行っていません。

ここまでの作業を終えると、次のようなディレクトリ・ファイル構造になっていると思います。

alexa-skills-kit-color-expert-go
┣ alexa
┃ ┣ request.go
┃ ┗ response.go
┣ main.go
┣ serverless.yml
┗ README.md

コンパイルとデプロイ

Lambda(Linux)の実行ファイルにコンパイルします。

$ GOOS=linux go build -o bin/main

ここで、試しにローカル実行してみたのですが、Goは未対応でした。

$ sls invoke local --function ask-go --path event.json

  Serverless Error ---------------------------------------

  You can only invoke Node.js, Python & Java functions locally.

  Get Support --------------------------------------------
     Docs:          docs.serverless.com
     Bugs:          github.com/serverless/serverless/issues
     Forums:        forum.serverless.com
     Chat:          gitter.im/serverless/serverless

  Your Environment Information -----------------------------
     OS:                     darwin
     Node Version:           6.10.0
     Serverless Version:     1.26.0

ですので、このままAWSにデプロイします。

$ sls deploy

AWSにデプロイできたら、出来上がったLambda関数のテストを行っておきましょう。Alexa Intent - MyColors を使います。

スクリーンショット 2018-01-31 18.05.43.png

テストの実行結果がこんな具合に返ってくればOKです。

スクリーンショット 2018-01-31 18.08.18.png

そして、出来上がったLambda関数のARNarn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:ask-kintone-dev-ask-go)をメモっておきます。

スクリーンショット_2018-01-31_18_11_47.jpg

Alexaスキルの設定

developer.amazon.com のコンソールから、Alexaスキルの設定を行って完成です。設定はチュートリアルのSTEP2からの手順を進めていきます。メモっておいたARNはSTEP2-14の設定で使います。

確認

無事全ての設定を終えたら、実機やEchosim.io等で、サンプル通りに設定できているか確認します。

所感とまとめ

Go/Lambda

  • 十数年振りの静的型付けがある言語はちょっとしんどかったが、新しい学びがあって楽しかった
  • Goの強みとされる速さ、軽さ、並行処理といった部分が生きてくるケースではLambda前提で実際に使ってみたい(ただ、まぁまぁな学習コストになりそう)
  • 逆にGoの強みが顕在化しない、必要ないケースでは、自分は書き慣れたNode.jsやPythonを選択すると思う
  • eventオブジェクトとreturnするオブジェクトは、interface{}にしておけば、JSONと構造体の突き合わせは不要だったかもしれないと思った(サンプルでは定義されていたので、同じようにやってみたものの)
  • go-kintoneをLambdaで試してみたい

Alexa

  • 久々にAlexaスキルのロジック書いて、理解が深まった
  • スキルビルダーでインテントスロットサンプル発話が設定しやすくなっていた
  • Alexa開発のための言語選択という点では、Node.jsについては、SDK(Alexa Skills Kit SDK for Node.js)の登場で非常に楽に書けるようになってきたので、あえてGoを使うというケースはレアケースになるように思う。作りたいスキルとそれを実現するための開発ライブラリの充実に寄るところもあると思うが(Lambdaを使わずにAlexaのサーバーサイドロジックを構築するというのであれば・・・いやそれでもどうだろう)

免責

こちらは個人の意見で、所属する企業や団体は関係ありません。

参考にした情報