[AWSLambda x LINEBot] GoとRustでも連携がしたい

# [AWSLambda] GoとRustでも連携がしたい
新しいシステムをRustでがりがり書きたいけど、
Line BotのSDKはGoの方がライブラリやノウハウがあったりして、既存のGoリソースも使いたい。
そんなことありますよね。

そんな時はGoとRustをそれぞれLambda Functionでデプロイして連携させてあげれば良さそうです。
(RESTなどで連携しても良いけど、Lambdaの方がちょっとだけ便利、かも)

今回は環境構築とソースコードの連携部分について記載します。

ざっくり機能を書くとこんな感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91710/5260e0b5-63a7-79b0-19c4-2f4d7d4b4ff1.png)




# プロジェクトとディレクトリ構造
今回はLine BotをGoで書いて、バックエンドの処理はRustで書きます。
こうしておくとLine以外のbotを増やす場合にもGoのライブラリを活用できるはずです。
Go Rust共にServerless Frameworkを使用しています。

なお、GoとRustを一つのServerless.ymlでビルドできないか試して見ましたが無理でした。

```bash:~/src/github.com/my_user/my_project/
$ tree -L 2
├── bot ← GoのServerless Frameworkのプロジェクト
│   ├── bin
│   ├── conf
│   ├── docker-compose.yml
│   ├── Dockerfile
│   ├── line
│   ├── Makefile
│   ├── package-lock.json
│   └── serverless.yml
├── frontend　← LIFF用のフロントエンド。React。詳細は省く
│   ├── deploy
│   └── lichmenu
├── go.mod
├── go.sum
├── LICENSE
└── backend ← RustのServerless Frameworkのプロジェクト
    ├── Cargo.lock
    ├── Cargo.toml
    ├── conf
    ├── node_modules
    ├── package.json
    ├── package-lock.json
    ├── serverless.yml
    ├── src
    └── target
```

# 環境構築
## Go側の設定
alpineベースのdockerからAWS周りの環境変数を指定してやるくらいです。
情報も多いし、特にハマりどころもありません。
今回は複数のbotがあるものの、バックエンドを呼び出すロジックは同一なので、
複数のエンドポイントに対してバイナリは一つだけで良い形になっています。

デプロイ用のAWSのアカウント情報

```bash:.env
AWS_ACCESS_KEY_ID=アクセスキー
AWS_SECRET_ACCESS_KEY=シークレットキー
```

ここでAWS用の環境変数を読み込んでおく

 ```dockerfile:Dockerfile
FROM golang:1.12.17-alpine3.11
LABEL maintainer ""

ARG AWS_ACCESS_KEY_ID
ARG AWS_SECRET_ACCESS_KEY

RUN apk update
RUN apk --update-cache \
    add musl musl-dev \
    linux-headers \
    gcc g++ make git curl\
    gfortran \
    openblas-dev libressl-dev pkgconfig \
    python3 python3-dev \
    nodejs npm\
    musl-dev

RUN pip3 install --upgrade pip
RUN pip3 install awscli
RUN pip3 install boto3
RUN npm install -g serverless

RUN sls config credentials --provider aws --key $AWS_ACCESS_KEY_ID --secret $AWS_SECRET_ACCESS_KEY
```

docker-compose.ymlの方でも環境変数の読み込みを忘れずに

```docker:docker-compose.yml
version: '3.3'
services:
  api:
    build:
      context: .
      args:
        - AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
        - AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
    image: お好きなイメージ名
    container_name: お好きなコンテナ名
    tty: true
    stdin_open: true
    env_file:
      - .env
    restart: always
    ports:
      - "3002:3002"
    volumes:
      - ./:/go/src/github.com/my_user/my_project/bot:cached
    working_dir: /go/src/github.com/my_user/my_project/bot
```

Makefileはこんな感じ。alpineなのでstaticリンクのフラグをつけておく

```makefile:Makefile
.PHONY: build clean deploy

build:
	cd line && go get .
	env GOOS=linux CGO_ENABLED=0 go build -ldflags='-d -s -w -extldflags "-static"' -a -tags netgo -installsuffix netgo -o bin/line line/*.go

clean:
	rm -rf ./bin/*

deploy: clean build
	sls deploy --verbose
```

confに予めbot毎の環境変数を入れておく

```yml:conf/line-bot-a-dev.yml
CHANNEL_SECRET: "シークレットキー"
CHANNEL_TOKEN: "アクセストークン"
```

### serverless.yml
環境ごとのconfの読み分けと、複数エンドポイントを一つのバイナリに紐づけている。

```yaml:serverless.yml
service: bot
frameworkVersion: '>=1.28.0 <2.0.0'

provider:
  name: aws
  region: ap-northeast-1
  stage: ${opt:stage, self:custom.defaultStage}

  iamRoleStatements:
    - Effect: 'Allow'
      Action:
        - 'lambda:InvokeFunction'
      Resource:
        - '*'

custom:
  defaultStage: dev
  otherfile:
    lineBotA:
      dev: ${file(./conf/line-bot-a-dev.yml)}
    lineBotB:
      dev: ${file(./conf/line-bot-b-dev.yml)}

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

functions:
  lineBotB:
    handler: bin/line
    runtime: go1.x
    environment:
      CHANNEL_SECRET: ${self:custom.otherfile.lineBotA.${self:provider.stage}.CHANNEL_SECRET}
      CHANNEL_TOKEN: ${self:custom.otherfile.lineBotA.${self:provider.stage}.CHANNEL_TOKEN}
    events:
      - http:
          path: line-bot-a
          method: post
          cors: true

  lineBotB:
    handler: bin/line
    runtime: go1.x
    environment:
      CHANNEL_SECRET: ${self:custom.otherfile.lineBotB.${self:provider.stage}.CHANNEL_SECRET}
      CHANNEL_TOKEN: ${self:custom.otherfile.lineBotB.${self:provider.stage}.CHANNEL_TOKEN}
    events:
      - http:
          path: line-bot-b
          method: post
          cors: true

```

ここまで設定すれば `docker-compose exec api make deploy` でデプロイできます。

## Rust側の設定
公式の[serverless Rust](https://www.serverless.com/plugins/serverless-rust/)プラグインを使う。
自分でdockerを立てたり、rustと他の言語を混ぜたりしたいのですが、どうもうまくいきません。(ライブラリのビルドが通らなかったり、lib-musl関連のリンクが解決できなかったり。。)

また、このプラグイン自体、dockerを使っているので自前でdockerを立てることもできません。
諦めて最新のnpmやらnodeやらを入れましょう。

serverless.ymlですが、customセクションの`dockerTag`を必ず指定しましょう。
指定しないとrust-1.32くらいの古いバージョンが入ってしまいます。

- [タグ一覧](https://h-ub.docker.com/r/softprops/lambda-rust/tags)

##### ディレクトリ構造
src/bin/*.rs にエンドポイントのソースコードを設置します。
(serverless-rustプラグインのサンプルは、Cargo.tomlが複数あるマルチアプリケーションのサンプルがありますが、こちらはCargo.tomlは一つです)


```bash:backend
tree -L 2 -I node_modules
.
├── Cargo.lock
├── Cargo.toml
├── conf
│   └── dev.yml
├── package.json
├── package-lock.json
├── serverless.yml
├── src
│   ├── application
│   ├── application.rs
│   ├── bin
│   │   ├-- echo.rs
│   │   ├-- analyze.rs
│   │   ├-- get_shop.rs
│   ├── lib.rs
│   ├── model
│   ├── model.rs
│   ├── repository
│   ├── repository.rs
│   ├── service
│   ├── service.rs
│   ├── util
│   └── util.rs
└── target
```

### serverless.yml

```yml:serverless.yml
service: backend
provider:
  name: aws
  runtime: rust
  memorySize: 128
  region: ap-northeast-1
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:*
      Resource: "*"
package:
  individually: true
custom:
  defaultStage: dev
  otherfile:
    environment:
      dev: ${file(./conf/dev.yml)}
  rust:
    dockerTag: '0.2.6-rust-1.42.0'

plugins:
  - serverless-rust

functions:
  echo:
    handler: backend.echo
    environment:
      ENV: ${self:custom.otherfile.environment.${self:provider.stage}.ENV}
      ENV_HOGE: ${self:custom.otherfile.environment.${self:provider.stage}.ENV_HOGE}
    events:
      - http:
          path: /echo
          method: POST
  analyze:
    handler: backend.analyze
    environment:
      ENV: ${self:custom.otherfile.environment.${self:provider.stage}.ENV}
    events:
      - http:
          path: /analyze
          method: POST

# おまけ。こっちは普通のREST API
  getShop:
    handler: backend.get_shop
    environment:
      ENV: ${self:custom.otherfile.environment.${self:provider.stage}.ENV}
    events:
      - http:
          path: /shop/{clientId}/{shopId}
          method: GET
          cors: true,
          request:
            parameters:
              paths:
                clientId: true,
                shopId: true,
```

あとは、`npx sls deploy`でデプロイでき、`npx sls package`でビルドできます。


# ソースコードのこと
## Go側
### エントリポイント
GoのエントリポイントはLINE BotなのでここはAPI gateway経由になります。
サンプルは多いので詳細は割愛します。

```go:line/main.go
package main

import (
	"errors"
	"fmt"
	"os"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/line/line-bot-sdk-go/linebot"
)

func Handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	line, err := NewLine(
		os.Getenv("CHANNEL_SECRET"),
		os.Getenv("CHANNEL_TOKEN"),
	)
        ... リクエストのパース処理など

	// イベントの処理など。

	return events.APIGatewayProxyResponse{Body: request.Body, StatusCode: 200}, nil
}

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

### GoからRustのLambdaを呼ぶ
リクエスト・レスポンス共にJSONでやりとりします。
FunctionNameで

```go:line/event.go
ackage main

import (
	"encoding/json"
	"log"
	"os"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/lambda"

...
)

const (
	ARN = "arn:aws:lambda:ap-northeast-1:[ACCOUNT_ID]:function:"
)

// リクエストパラメータ
type Event struct {
	Text    string `json:"text"`
}
// ;r-[yr
type Message struct {
	Type     string     `json:"type"`
	Message  string     `json:"message"`
}
func eventHandler(text string) {

	payload, _ := json.Marshal(Event{Text: text})

	res, err := lambda.New(session.New()).Invoke(&lambda.InvokeInput{
		FunctionName:   aws.String(ARN + "backend-dev-echo"),
		Payload:        payload,
		InvocationType: aws.String("RequestResponse"),
	})
	if err != nil {
		log.Println(err)
	}

	var result Message
	_ = json.Unmarshal(res.Payload, &result)

... あとは適当にLINEでリプライとかする
}
```

## Rust側
### Lambdaのエントリポイント
lambda_runtimeを使います。
structへのバインドはランタイム側でやってくれるようで楽チンです。

```rust:src/bin/echo.rs
#![deny(warnings)]

use log;
use simple_logger;

use lambda_runtime::{error::HandlerError, lambda, Context};
use serde::{Deserialize, Serialize};

use backend::application::echo;

#[derive(Deserialize, Debug)]
pub struct Event {
    text: String,
}

#[derive(Serialize, Debug)]
pub struct Response {
    r#type: String,
    message: String,
}

fn main() {
    simple_logger::init_with_level(log::Level::Debug).unwrap();
    lambda!(handler)
}

fn handler(e: Event, _: Context) -> Result<Response, HandlerError> {
    // この時点で独自に定義したEvent型にバインドされて呼び出される

    // 実際はアプリケーション層にデータを渡してごにょごにょする
    return Ok(Response {
            r#type: String::from("message"),
            message: format!("{}!", &e.text),
        });
}
```


### おまけ REST APIのエントリポイント
Goと同じくlambda_httpを使います。
lambda_runtimeと違い、自分でhttpリクエストからデータを取り出さないといけません。
`https://[lambda host]/api/v1/shop/(client_id)/(shop_id)`のようにアクセスされる想定。

```rust:src/bin/get_shop.rs
#![deny(warnings)]

use log;
use simple_logger;

use lambda_http::{lambda, IntoResponse, Request, RequestExt, Response};
use lambda_runtime::{error::HandlerError, Context};

use backend::application::get_shop::{self, Event};
use backend::repository::shop::dynamodb::DbRepo;

fn main() {
    simple_logger::init_with_level(log::Level::Debug).unwrap();
    lambda!(handler)
}

fn handler(e: Request, _: Context) -> Result<impl IntoResponse, HandlerError> {
    return match get_shop::main::<DbRepo>(Event {
        client_id: e.path_parameters().get("clientId").unwrap().to_string(),
        shop_id: e.path_parameters().get("shopId").unwrap().to_string(),
    }) {
        Ok(r) => Ok(Response::builder()
            .status(200)
            .body(serde_json::to_string(&r).unwrap())
            .expect("failed to render response")),
        Err(err) => Ok(Response::builder()
            .status(404)
            .body(err)
            .expect("failed to render response")),
    };
}
```

# CORSについて
上記の段階ではブラウザから叩く想定ではないのでCORS対策が入っていませんでしたが、
LIFFから連携できるようにCORS対策をまとめました。
[[Go,Rust,React]AWS APIGatewayでもCORSがしたい](https://qiita.com/alivelime/items/411a0ad36283e757f045)