[譯] 使用 Go 和 AWS Lambda 構建無服務 API

sisibeloved發表於2018-05-10

早些時候 AWS 宣佈了他們的 Lambda 服務將會為 Go 語言提供首要支援,這對於想要體驗無服務技術的 GO 語言程式設計師(比如我自己)來說前進了一大步。

所以在這篇文章中我將討論如何一步一步建立一個依賴 AWS Lambda 的 HTTPS API。我發現在這個過程中會有很多坑 — 特別是你對 AWS 的許可權系統不熟悉的話 — 而且 Lamdba 介面和其它 AWS 服務對接時有很多磕磕碰碰的地方。但是一旦你弄懂了,這些工具都會非常好使。

這篇教程涵蓋了許多方面的內容,所以我將它分成以下七個步驟:

  1. 構建 AWS CLI
  2. 建立並部署一個 Lambda 函式
  3. 連結到 DynamoDB
  4. 構建 HTTPS API
  5. 處理事件
  6. 部署 API
  7. 支援多種行為

通過這篇文章我們將努力構建一個具有兩個功能的 API:

方法 路徑 行為
GET /books?isbn=xxx 展示帶有指定 ISBN 的 book 物件的資訊
POST /books 建立一個 book 物件

一個 book 物件是一條像這樣的原生 JSON 記錄:

{"isbn":"978-1420931693","title":"The Republic","author":"Plato"}
複製程式碼

我會保持 API 的簡單易懂,避免在特定功能的程式碼中陷入困境,但是當你掌握了基礎知識之後,怎樣擴充套件 API 來支援附加的路由和行為就變得輕而易舉了。

構建 AWS CLI

  1. 整個教程中我們會使用 AWS CLI(命令列介面)來設定我們的 lambda 函式和其它 AWS 服務。安裝和基本使用指南可以在這兒找到,不過如果你使用了一個基於 Debian 的系統,比如 Ubuntu,你可以通過 apt 安裝 CLI 並使用 aws 命令來執行它:

    $ sudo apt install awscli
    $ aws --version
    aws-cli/1.11.139 Python/3.6.3 Linux/4.13.0-37-generic botocore/1.6.6
    複製程式碼
  2. 接下來我們需要建立一個帶有允許程式訪問許可權的 AWS IAM 以供 CLI 使用。如何操作的指南可以在這兒找到。出於測試的目的,你可以為這個使用者附加擁有所有許可權的 AdministratorAccess 託管策略,但在實際生產中我建議你使用更嚴格的策略。建立完使用者後你將獲得一個訪問金鑰 ID 和訪問私鑰。留意一下這些 —— 你將在下一步使用它們。

  3. 使用你剛建立的 IAM 使用者的憑證,通過 configure 命令來配置你的 CLI。你需要指定預設地區和你想要 CLI 使用的輸出格式

    $ aws configure
    AWS Access Key ID [None]: access-key-ID
    AWS Secret Access Key [None]: secret-access-key
    Default region name [None]: us-east-1
    Default output format [None]: json
    複製程式碼

    (假定你使用的是 us-east-1 地區 —— 如果你正在使用一個不同的地區,你需要相應地修改這個程式碼片段。)

建立並部署一個 Lambda 函式

  1. 接下來就是激動人心的時刻:建立一個 lambda 函式。如果你正在照著做,進入你的 $GOPATH/src 資料夾,建立一個含有一個 main.go 檔案的 books 倉庫。

    $ cd ~/go/src
    $ mkdir books && cd books
    $ touch main.go
    複製程式碼
  2. 接著你需要安裝 github.com/aws-lambda-go/lambda 包。這個包提供了建立 lambda 函式必需的 Go 語言庫和型別。

    $ go get github.com/aws/aws-lambda-go/lambda
    複製程式碼
  3. 然後開啟 main.go 檔案,輸入以下程式碼:

    檔案:books/main.go

    package main
    
    import (
        "github.com/aws/aws-lambda-go/lambda"
    )
    
    type book struct {
        ISBN   string `json:"isbn"`
        Title  string `json:"title"`
        Author string `json:"author"`
    }
    
    func show() (*book, error) {
        bk := &book{
            ISBN:   "978-1420931693",
            Title:  "The Republic",
            Author: "Plato",
        }
    
        return bk, nil
    }
    
    func main() {
        lambda.Start(show)
    }
    複製程式碼

    main() 函式中我們呼叫 lambda.Start() 並傳入了 show 函式作為 lambda 處理程式。在這個示例中處理函式僅簡單地初始化並返回了一個新的 book 物件。

Lamdba 處理程式能夠接收一系列不同的 Go 函式簽名,並通過反射來確定哪個是你正在用的。它所支援的完整列表是……

```
func()
func() error
func(TIn) error
func() (TOut, error)
func(TIn) (TOut, error)
func(context.Context) error
func(context.Context, TIn) error
func(context.Context) (TOut, error)
func(context.Context, TIn) (TOut, error)
```

…… 其中的 `TIn` 和 `TOut` 引數是可以通過 Go 的 `encoding/json` 包構建(和解析)的物件。
複製程式碼
  1. 下一步是使用 go buildbooks 包構建一個可執行程式。在下面的程式碼片段中我使用 -o 標識來把可執行程式存到 /tmp/main ,當然,你也可以把它存到你想存的任意位置(同樣地可以命名為任意名稱)。

    $ env GOOS=linux GOARCH=amd64 go build -o /tmp/main books
    複製程式碼

    重要:作為這個命令的一部分,我們使用 env 來設定兩個命令執行期間的臨時的環境變數(GOOS=linuxGOARCH=amd64)。這會指示 Go 編譯器建立一個適用於 amd64 架構的 linux 系統的可執行程式 —— 就是當我們部署到 AWS 上時將會執行的環境。

  2. AWS 要求我們以 zip 格式上傳 lambda 函式,所以建立一個包含我們剛才建立的可執行程式的 main.zip 檔案:

    $ zip -j /tmp/main.zip /tmp/main
    複製程式碼

需要注意的是可執行程式必須在 zip 檔案的根目錄下 —— 不是在 zip 檔案的某個資料夾中。為了確保這一點,我在上面的程式碼片段中用了 -j 標識來丟棄目錄名稱。

  1. 下一步有點麻煩,但是對於讓我們的 lambda 正確執行至關重要。我們需要建立一個 IAM 角色,它定義了 lambda 函式執行時需要的許可權。

    現在讓我們來建立一個 lambda-books-executor 角色,並給它附加 AWSLambdaBasicExecutionRole 託管政策。這會給我們的 lambda 函式執行和輸出日誌到 AWS 雲監控服務所需的最基本的許可權。

    首先我們需要建立一個信任策略 JSON 檔案。這會從根本上指示 AWS 允許 lambda 服務扮演 lambda-books-executor 角色:

    檔案:/tmp/trust-policy.json

    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Principal": {
                    "Service": "lambda.amazonaws.com"
                },
                "Action": "sts:AssumeRole"
            }
        ]
    }
    複製程式碼

    然後使用 aws iam create-role 命令來建立帶有這個信任策略的使用者:

    $ aws iam create-role --role-name lambda-books-executor \
    --assume-role-policy-document file:///tmp/trust-policy.json
    {
        "Role": {
            "Path": "/",
            "RoleName": "lambda-books-executor",
            "RoleId": "AROAIWSQS2RVEWIMIHOR2",
            "Arn": "arn:aws:iam::account-id:role/lambda-books-executor",
            "CreateDate": "2018-04-05T10:22:32.567Z",
            "AssumeRolePolicyDocument": {
                "Version": "2012-10-17",
                "Statement": [
                    {
                        "Effect": "Allow",
                        "Principal": {
                            "Service": "lambda.amazonaws.com"
                        },
                        "Action": "sts:AssumeRole"
                    }
                ]
            }
        }
    }
    複製程式碼

    關注一下返回的 ARN(亞馬遜資源名)—— 在下一步中你需要用到它。

    現在這個 lambda-books-executor 已經被建立,我們需要指定這個角色擁有的許可權。最簡單的方法是用 aws iam attach-role-policy 命令,像這樣傳入 AWSLambdaBasicExecutionRole 的 ARN 和許可政策:

    $ aws iam attach-role-policy --role-name lambda-books-executor \
    --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
    複製程式碼

    提示:你可以在這裡找到一系列其他的許可政策,或許能對你有所幫助。

  2. 現在我們可以真正地把 lambda 函式部署到 AWS 上了。我們可以使用 aws lambda create-function 命令。這個命令接收以下標識,並且需要執行一到兩分鐘。

    --function-name 將在 AWS 中被呼叫的 lambda 函式名
    --runtime lambda 函式的執行環境(在我們的例子裡用 "go1.x"
    --role 你想要 lambda 函式在執行時扮演的角色的 ARN(見上面的步驟 6)
    --handler zip 檔案根目錄下的可執行檔案的名稱
    --zip-file zip 檔案的路徑

    接下去嘗試部署:

    $ aws lambda create-function --function-name books --runtime go1.x \
    --role arn:aws:iam::account-id:role/lambda-books-executor \
    --handler main --zip-file fileb:///tmp/main.zip
    {
        "FunctionName": "books",
        "FunctionArn": "arn:aws:lambda:us-east-1:account-id:function:books",
        "Runtime": "go1.x",
        "Role": "arn:aws:iam::account-id:role/lambda-books-executor",
        "Handler": "main",
        "CodeSize": 2791699,
        "Description": "",
        "Timeout": 3,
        "MemorySize": 128,
        "LastModified": "2018-04-05T10:25:05.343+0000",
        "CodeSha256": "O20RZcdJTVcpEiJiEwGL2bX1PtJ/GcdkusIEyeO9l+8=",
        "Version": "$LATEST",
        "TracingConfig": {
            "Mode": "PassThrough"
        }
    }
    複製程式碼
  3. 大功告成!我們的 lambda 函式已經被部署上去並可以用了。你可以使用 aws lambda invoke 命令來試驗一下(你需要為響應指定一個輸出檔案 —— 我在下面的程式碼片段中用了 /tmp/output.json)。

    $ aws lambda invoke --function-name books /tmp/output.json
    {
        "StatusCode": 200
    }
    $ cat /tmp/output.json
    {"isbn":"978-1420931693","title":"The Republic","author":"Plato"}
    複製程式碼

    如果你一路照著做,你很有可能得到一個相同的響應。注意到了我們在 Go 程式碼中初始化的 book 物件是怎樣被自動解析成 JSON 的嗎?

連結到 DynamoDB

  1. 在這一章中要為 lambda 函式存取的資料新增持久層。我將會使用 Amazon DynamoDB(它跟 AWS lambda 結合得很出色,並且免費用量也不小)。如果你對 DynamoDB 不熟悉,這兒有一個不錯的基本綱要。

    首先要建立一張 Books 表來儲存 book 記錄。DynanmoDB 是沒有 schema 的,但我們需要在 ISBN 欄位上定義分割槽鍵(有點像主鍵)。我們只需用以下這個命令:

    $ aws dynamodb create-table --table-name Books \
    --attribute-definitions AttributeName=ISBN,AttributeType=S \
    --key-schema AttributeName=ISBN,KeyType=HASH \
    --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5
    {
        "TableDescription": {
            "AttributeDefinitions": [
                {
                    "AttributeName": "ISBN",
                    "AttributeType": "S"
                }
            ],
            "TableName": "Books",
            "KeySchema": [
                {
                    "AttributeName": "ISBN",
                    "KeyType": "HASH"
                }
            ],
            "TableStatus": "CREATING",
            "CreationDateTime": 1522924177.507,
            "ProvisionedThroughput": {
                "NumberOfDecreasesToday": 0,
                "ReadCapacityUnits": 5,
                "WriteCapacityUnits": 5
            },
            "TableSizeBytes": 0,
            "ItemCount": 0,
            "TableArn": "arn:aws:dynamodb:us-east-1:account-id:table/Books"
        }
    }
    複製程式碼
  2. 然後用 put-item 命令新增一些資料,這些資料在接下來幾步中會用得到。

    $ aws dynamodb put-item --table-name Books --item '{"ISBN": {"S": "978-1420931693"}, "Title": {"S": "The Republic"}, "Author":  {"S": "Plato"}}'
    $ aws dynamodb put-item --table-name Books --item '{"ISBN": {"S": "978-0486298238"}, "Title": {"S": "Meditations"},  "Author":  {"S": "Marcus Aurelius"}}'
    複製程式碼
  3. 接下來更新我們的 Go 程式碼,這樣我們的 lambda 處理程式可以連線並使用 DynamoDB 層。你需要安裝 github.com/aws/aws-sdk-go 包,它提供了使用 DynamoDB(和其它 AWS 服務)的相關庫。

    $ go get github.com/aws/aws-sdk-go
    複製程式碼
  4. 接著是敲程式碼環節。為了保持程式碼分離,在 books 倉庫中建立一個新的 db.go 檔案:

    $ touch ~/go/src/books/db.go
    複製程式碼

    並新增以下程式碼:

    檔案:books/db.go

    package main
    
    import (
        "github.com/aws/aws-sdk-go/aws"
        "github.com/aws/aws-sdk-go/aws/session"
        "github.com/aws/aws-sdk-go/service/dynamodb"
        "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
    )
    
    // 宣告一個新的 DynamoDB 例項。注意它在併發呼叫時是
    // 安全的。
    var db = dynamodb.New(session.New(), aws.NewConfig().WithRegion("us-east-1"))
    
    func getItem(isbn string) (*book, error) {
        // 準備查詢的輸入
        input := &dynamodb.GetItemInput{
            TableName: aws.String("Books"),
            Key: map[string]*dynamodb.AttributeValue{
                "ISBN": {
                    S: aws.String(isbn),
                },
            },
        }
    
        // 從 DynamoDB 檢索資料。如果沒有符合的資料
        // 返回 nil。
        result, err := db.GetItem(input)
        if err != nil {
            return nil, err
        }
        if result.Item == nil {
            return nil, nil
        }
    
        // 返回的 result.Item 物件具有隱含的
        // map[string]*AttributeValue 型別。我們可以使用 UnmarshalMap helper
        // 解析成對應的資料結構。注意:
        // 當你需要處理多條資料時,可以使用
        // UnmarshalListOfMaps。
        bk := new(book)
        err = dynamodbattribute.UnmarshalMap(result.Item, bk)
        if err != nil {
            return nil, err
        }
    
        return bk, nil
    }
    複製程式碼

    然後用新的程式碼更新 main.go

    檔案:books/main.go

    package main
    
    import (
        "github.com/aws/aws-lambda-go/lambda"
    )
    
    type book struct {
        ISBN   string `json:"isbn"`
        Title  string `json:"title"`
        Author string `json:"author"`
    }
    
    func show() (*book, error) {
        // 從 DynamoDB 資料庫獲取特定的 book 記錄。在下一章中,
        // 我們可以讓這個行為更加動態。
        bk, err := getItem("978-0486298238")
        if err != nil {
            return nil, err
        }
    
        return bk, nil
    }
    
    func main() {
        lambda.Start(show)
    }
    複製程式碼
  5. 儲存檔案、重新編譯並打包壓縮 lambda 函式,這樣就做好了部署前的準備:

    $ env GOOS=linux GOARCH=amd64 go build -o /tmp/main books
    $ zip -j /tmp/main.zip /tmp/main
    複製程式碼
  6. 重新部署一個 lambda 函式比第一次建立輕鬆多了 —— 我們可以像這樣使用 aws lambda update-function-code 命令:

    $ aws lambda update-function-code --function-name books \
    --zip-file fileb:///tmp/main.zip
    複製程式碼
  7. 試著執行 lambda 函式看看:

    $ aws lambda invoke --function-name books /tmp/output.json
    {
        "StatusCode": 200,
        "FunctionError": "Unhandled"
    }
    $ cat /tmp/output.json
    {"errorMessage":"AccessDeniedException: User: arn:aws:sts::account-id:assumed-role/lambda-books-executor/books is not authorized to perform: dynamodb:GetItem on resource: arn:aws:dynamodb:us-east-1:account-id:table/Books\n\tstatus code: 400, request id: 2QSB5UUST6F0R3UDSVVVODTES3VV4KQNSO5AEMVJF66Q9ASUAAJG","errorType":"requestError"}
    複製程式碼

    啊,有點小問題。我們可以從輸出資訊中看到,我們的 lambda 函式(注意了,用的 lambda-books-executor 角色)缺少在 DynamoDB 例項上執行 GetItem 的許可權。我們現在就把它改過來。

  8. 建立一個許可權策略檔案,給予 GetItemPutItem DynamoDB 相關的許可權:

    檔案:/tmp/privilege-policy.json

    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Action": [
                    "dynamodb:PutItem",
                    "dynamodb:GetItem",
                ],
                "Resource": "*"
            }
        ]
    }
    複製程式碼

    然後使用 aws iam put-role-policy 命令把它附加到 lambda-books-executor 使用者:

    $ aws iam put-role-policy --role-name lambda-books-executor \
    --policy-name dynamodb-item-crud-role \
    --policy-document file:///tmp/privilege-policy.json
    複製程式碼

    講句題外話,AWS 有叫做 AWSLambdaDynamoDBExecutionRoleAWSLambdaInvocation-DynamoDB 的託管策略,聽起來挺管用的,但是它們都不提供 GetItemPutItem 的許可權。所以才需要組建自己的策略。

  9. 再執行一次 lambda 函式看看。這一次應該順利執行了並返回 ISBN 為 978-0486298238 的書本的資訊:

    $ aws lambda invoke --function-name books /tmp/output.json
    {
        "StatusCode": 200
    }
    $ cat /tmp/output.json
    {"isbn":"978-0486298238","title":"Meditations","author":"Marcus Aurelius"}
    複製程式碼

構建 HTTPS API

  1. 到現在為止,我們的 lambda 已經能夠執行並與 DynamoDB 互動。接下來就是建立一個通過 HTTPS 獲取 lamdba 函式的途徑,我們可以通過 AWS API 閘道器服務來實現。

    但是在我們繼續之前,考慮一下專案的架構還是很有必要的。假設我們有一個巨集偉的計劃,我們的 lamdba 函式將是一個更大的 bookstore API 的一部分,這個API 將會處理書本、客戶、推薦和其它各種各樣的資訊。

    AWS Lambda 提供了三種架構的基本選項:

    • 微服務式 —— 每個 lambda 函式只響應一個行為。舉個例子,展示、建立和刪除一本書會對應 3 個獨立的 lambda 函式。
    • 服務式 —— 每個 lambda 函式響應一組相關的行為。舉個例子, 用一個 lambda 來處理所有跟書相關的行為,但是使用者相關行為會被放到另一個獨立的 lambda 函式中。
    • 整體式 —— 一個 lambda 函式管理書店的所有行為。

    每個選項都是有效的,這裡有一些關於每個選項優缺點的不錯的討論。

    在這篇教程中我們會用服務式進行操作,並用一個 books lambda 函式處理不同的書本相關行為。這意味著我們需要在我們的 lambda 函式內部實現某種形式的路由,這一點我會在下文提到。不過現在……

  2. 我們繼續,使用 aws apigateway create-rest-api 建立一個 bookstore API:

    $ aws apigateway create-rest-api --name bookstore
    {
        "id": "rest-api-id",
        "name": "bookstore",
        "createdDate": 1522926250
    }
    複製程式碼

    記錄下返回的 rest-api-id 值,我們在接下來幾步中會多次用到它。

  3. 接下來我們需要獲取 API 根目錄("/")的 id。我們可以使用 aws apigateway get-resources 命令來取得:

    $ aws apigateway get-resources --rest-api-id rest-api-id
    {
        "items": [
            {
                "id": "root-path-id",
                "path": "/"
            }
        ]
    }
    複製程式碼

    同樣地,記錄返回的 root-path-id 值。

  4. 現在我們需要在根目錄下建立一個新的資源 —— 就是 URL 路徑 /books 對應的資源。我們可以使用帶有 --path-part 引數的 aws apigateway create-resource 命令:

    $ aws apigateway create-resource --rest-api-id rest-api-id \
    --parent-id root-path-id --path-part books
    {
        "id": "resource-id",
        "parentId": "root-path-id",
        "pathPart": "books",
        "path": "/books"
    }
    複製程式碼

    同樣地,記錄返回的 resource-id,下一步要用到。

    值得一提的是,可以使用大括號將部分路徑包裹起來來在路徑中包含佔位符。舉個例子,books/{id}--path-part 引數將會匹配 /books/foo/books/bar 的請求,並且 id 的值可以通過一個事件物件(下文會提到)在你的 lambda 函式中獲取。你也可以在佔位符後加上字尾 +,使它變得貪婪。如果你想匹配任意路徑的請求,一種常見的做法是使用引數 --path-part {proxy+}

  5. 不過我們不用這麼做。我們回到 /books 資源,使用 aws apigateway put-method 命令來註冊 ANY 的 HTTP 方法。這意味著我們的 /books 將會響應所有請求,不論什麼 HTTP 方法。

    $ aws apigateway put-method --rest-api-id rest-api-id \
    --resource-id resource-id --http-method ANY \
    --authorization-type NONE
    {
        "httpMethod": "ANY",
        "authorizationType": "NONE",
        "apiKeyRequired": false
    }
    複製程式碼
  6. 現在萬事俱備,就差把資源整合到我們的 lambda 函式中了,這一步我們使用 aws apigateway put-integration 命令。關於這個命令的一些引數需要簡短地解釋一下:

    • The --type 引數應該為 AWS_PROXY。當使用這個值時,AWS API 閘道器會以 『事件』的形式將 HTTP 請求的資訊傳送到 lambda 函式。這也會自動將 lambda 函式的輸出轉化成 HTTP 響應。

    • --integration-http-method 引數必須為 POST。不要把這個和你的 API 資源響應的 HTTP 方法混淆了。

    • --uri 引數需要遵守這樣的格式:

      arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/your-lambda-function-arn/invocations
      複製程式碼

    記住了這些以後,你的命令看起來應該是這樣的:

    $ aws apigateway put-integration --rest-api-id rest-api-id \
    --resource-id resource-id --http-method ANY --type AWS_PROXY \
    --integration-http-method POST \
    --uri arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:account-id:function:books/invocations
    {
        "type": "AWS_PROXY",
        "httpMethod": "POST",
        "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:account-id:function:books/invocations",
        "passthroughBehavior": "WHEN_NO_MATCH",
        "cacheNamespace": "qtdn5h",
        "cacheKeyParameters": []
    }
    複製程式碼
  7. 好了,我們來試一試。我們可以使用 aws apigateway test-invoke-method 命令來向我們剛才建立的資源傳送一個測試請求:

    $ aws apigateway test-invoke-method --rest-api-id rest-api-id --resource-id resource-id --http-method "GET"
    {
        "status": 500,
        "body": "{\"message\": \"Internal server error\"}",
        "headers": {},
        "log": "Execution log for request test-request\nThu Apr 05 11:07:54 UTC 2018 : Starting execution for request: test-invoke-request\nThu Apr 05 11:07:54 UTC 2018 : HTTP Method: GET, Resource Path: /books\nThu Apr 05 11:07:54 UTC 2018 : Method request path: {}[TRUNCATED]Thu Apr 05 11:07:54 UTC 2018 : Sending request to https://lambda.us-east-1.amazonaws.com/2015-03-31/functions/arn:aws:lambda:us-east-1:account-id:function:books/invocations\nThu Apr 05 11:07:54 UTC 2018 : Execution failed due to configuration error: Invalid permissions on Lambda function\nThu Apr 05 11:07:54 UTC 2018 : Method completed with status: 500\n",
        "latency": 39
    }
    複製程式碼

    啊,沒有成功。如果你瀏覽了輸出的日誌,你應該可以看出問題出在這兒:

    Execution failed due to configuration error: Invalid permissions on Lambda function

    這是因為我們的 bookstore API 閘道器沒有執行 lambda 函式的許可權

  8. 最簡單的修復問題的方法是使用 aws lambda add-permission 命令來給 API 呼叫的許可權,像這樣:

    $ aws lambda add-permission --function-name books --statement-id a-GUID \
    --action lambda:InvokeFunction --principal apigateway.amazonaws.com \
    --source-arn arn:aws:execute-api:us-east-1:account-id:rest-api-id/*/*/*
    {
        "Statement": "{\"Sid\":\"6d658ce7-3899-4de2-bfd4-fefb939f731\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"apigateway.amazonaws.com\"},\"Action\":\"lambda:InvokeFunction\",\"Resource\":\"arn:aws:lambda:us-east-1:account-id:function:books\",\"Condition\":{\"ArnLike\":{\"AWS:SourceArn\":\"arn:aws:execute-api:us-east-1:account-id:rest-api-id/*/*/*\"}}}"
    }
    複製程式碼

    注意,--statement-id 引數必須是一個全域性唯一的識別符號。它可以是一個 random ID 或其它更加容易說明的值。

  9. 好了,再試一次:

    $ aws apigateway test-invoke-method --rest-api-id rest-api-id --resource-id resource-id --http-method "GET"
    {
        "status": 502,
        "body": "{\"message\": \"Internal server error\"}",
        "headers": {},
        "log": "Execution log for request test-request\nThu Apr 05 11:12:53 UTC 2018 : Starting execution for request: test-invoke-request\nThu Apr 05 11:12:53 UTC 2018 : HTTP Method: GET, Resource Path: /books\nThu Apr 05 11:12:53 UTC 2018 : Method request path: {}\nThu Apr 05 11:12:53 UTC 2018 : Method request query string: {}\nThu Apr 05 11:12:53 UTC 2018 : Method request headers: {}\nThu Apr 05 11:12:53 UTC 2018 : Endpoint response body before transformations: {\"isbn\":\"978-0486298238\",\"title\":\"Meditations\",\"author\":\"Marcus Aurelius\"}\nThu Apr 05 11:12:53 UTC 2018 : Endpoint response headers: {X-Amz-Executed-Version=$LATEST, x-amzn-Remapped-Content-Length=0, Connection=keep-alive, x-amzn-RequestId=48d29098-38c2-11e8-ae15-f13b670c5483, Content-Length=74, Date=Thu, 05 Apr 2018 11:12:53 GMT, X-Amzn-Trace-Id=root=1-5ac604b5-cf29dd70cd08358f89853b96;sampled=0, Content-Type=application/json}\nThu Apr 05 11:12:53 UTC 2018 : Execution failed due to configuration error: Malformed Lambda proxy response\nThu Apr 05 11:12:53 UTC 2018 : Method completed with status: 502\n",
        "latency": 211
    }
    複製程式碼

    還是報錯,不過訊息已經變了:

    Execution failed due to configuration error: Malformed Lambda proxy response

    如果你仔細看輸出你會看到下列資訊:

    Endpoint response body before transformations: {\"isbn\":\"978-0486298238\",\"title\":\"Meditations\",\"author\":\"Marcus Aurelius\"}

    這裡有明確的過程。API 和 lambda 函式互動並收到了正確的響應(一個解析成 JSON 的 book 物件)。只是 AWS API 閘道器將響應當成了錯誤的格式。

    這是因為,當你使用 API 閘道器的 lambda 代理整合,lambda 函式的返回值 必須 是這樣的 JSON 格式:

    {
        "isBase64Encoded": true|false,
        "statusCode": httpStatusCode,
        "headers": { "headerName": "headerValue", ... },
        "body": "..."
    }
    複製程式碼

    是時候回頭看看 Go 程式碼,然後做些轉換了。

處理事件

  1. 提供 AWS API 閘道器需要的響應最簡單的方法是安裝 github.com/aws/aws-lambda-go/events 包:

    go get github.com/aws/aws-lambda-go/events
    複製程式碼

    這個包提供了許多有用的型別(APIGatewayProxyRequestAPIGatewayProxyResponse),包含了輸入的 HTTP 請求的資訊並允許我們構建 API 閘道器能夠理解的響應.

    type APIGatewayProxyRequest struct {
        Resource              string                        `json:"resource"` // API 閘道器中定義的資源路徑
        Path                  string                        `json:"path"`     // 呼叫者的 url 路徑
        HTTPMethod            string                        `json:"httpMethod"`
        Headers               map[string]string             `json:"headers"`
        QueryStringParameters map[string]string             `json:"queryStringParameters"`
        PathParameters        map[string]string             `json:"pathParameters"`
        StageVariables        map[string]string             `json:"stageVariables"`
        RequestContext        APIGatewayProxyRequestContext `json:"requestContext"`
        Body                  string                        `json:"body"`
        IsBase64Encoded       bool                          `json:"isBase64Encoded,omitempty"`
    }
    複製程式碼
    type APIGatewayProxyResponse struct {
        StatusCode      int               `json:"statusCode"`
        Headers         map[string]string `json:"headers"`
        Body            string            `json:"body"`
        IsBase64Encoded bool              `json:"isBase64Encoded,omitempty"`
    }
    複製程式碼
  2. 回到 main.go 檔案,更新 lambda 處理程式,讓它使用這樣的函式簽名:

    func(events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error)
    複製程式碼

    總的來講,處理程式會接收一個包含了一串 HTTP 請求資訊的 APIGatewayProxyRequest 物件,然後返回一個 APIGatewayProxyResponse 物件(可以被解析成適合 AWS API 閘道器的 JSON 響應)。

    檔案:books/main.go

    package main
    
    import (
        "encoding/json"
        "fmt"
        "log"
        "net/http"
        "os"
        "regexp"
    
        "github.com/aws/aws-lambda-go/events"
        "github.com/aws/aws-lambda-go/lambda"
    )
    
    var isbnRegexp = regexp.MustCompile(`[0-9]{3}\-[0-9]{10}`)
    var errorLogger = log.New(os.Stderr, "ERROR ", log.Llongfile)
    
    type book struct {
        ISBN   string `json:"isbn"`
        Title  string `json:"title"`
        Author string `json:"author"`
    }
    
    func show(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
        // 從請求中獲取查詢 `isbn` 的字串引數
        // 並校驗。
        isbn := req.QueryStringParameters["isbn"]
        if !isbnRegexp.MatchString(isbn) {
            return clientError(http.StatusBadRequest)
        }
    
        // 根據 isbn 值從資料庫中取出 book 記錄
        bk, err := getItem(isbn)
        if err != nil {
            return serverError(err)
        }
        if bk == nil {
            return clientError(http.StatusNotFound)
        }
    
        // APIGatewayProxyResponse.Body 域是個字串,所以
        // 我們將 book 記錄解析成 JSON。
        js, err := json.Marshal(bk)
        if err != nil {
            return serverError(err)
        }
    
        // 返回一個響應,帶有代表成功的 200 狀態碼和 JSON 格式的 book 記錄
        // 響應體。
        return events.APIGatewayProxyResponse{
            StatusCode: http.StatusOK,
            Body:       string(js),
        }, nil
    }
    
    // 新增一個用來處理錯誤的幫助函式。它會列印錯誤日誌到 os.Stderr
    // 並返回一個 AWS API 閘道器能夠理解的 500 伺服器內部錯誤
    // 的響應。
    func serverError(err error) (events.APIGatewayProxyResponse, error) {
        errorLogger.Println(err.Error())
    
        return events.APIGatewayProxyResponse{
            StatusCode: http.StatusInternalServerError,
            Body:       http.StatusText(http.StatusInternalServerError),
        }, nil
    }
    
    // 加一個簡單的幫助函式,用來傳送和客戶端錯誤相關的響應。
    func clientError(status int) (events.APIGatewayProxyResponse, error) {
        return events.APIGatewayProxyResponse{
            StatusCode: status,
            Body:       http.StatusText(status),
        }, nil
    }
    
    func main() {
        lambda.Start(show)
    }
    複製程式碼

注意到為什麼我們的 lambda 處理程式返回的所有 error 值變成了 nil?我們不得不這麼做,因為 API 閘道器在和 lambda 代理整合外掛結合使用時不接收 error 物件 (這些錯誤會再一次引起『響應殘缺』錯誤)。所以我們需要在 lambda 函式裡自己管理錯誤,並返回合適的 HTTP 響應。其實 error 這個返回引數是多餘的,但是為了保持正確的函式簽名,我們還是要在 lambda 函式裡包含它。

  1. 儲存檔案,重新編譯並重新部署 lambda 函式:

    $ env GOOS=linux GOARCH=amd64 go build -o /tmp/main books
    $ zip -j /tmp/main.zip /tmp/main
    $ aws lambda update-function-code --function-name books \
    --zip-file fileb:///tmp/main.zip
    複製程式碼
  2. 再試一次,結果應該符合預期了。試試在查詢字串中輸入不同的 isbn 值:

    $ aws apigateway test-invoke-method --rest-api-id rest-api-id \
    --resource-id resource-id --http-method "GET" \
    --path-with-query-string "/books?isbn=978-1420931693"
    {
        "status": 200,
        "body": "{\"isbn\":\"978-1420931693\",\"title\":\"The Republic\",\"author\":\"Plato\"}",
        "headers": {
            "X-Amzn-Trace-Id": "sampled=0;root=1-5ac60df0-0ea7a560337129d1fde588cd"
        },
        "log": [TRUNCATED],
        "latency": 1232
    }
    $ aws apigateway test-invoke-method --rest-api-id rest-api-id \
    --resource-id resource-id --http-method "GET" \
    --path-with-query-string "/books?isbn=foobar"
    {
        "status": 400,
        "body": "Bad Request",
        "headers": {
            "X-Amzn-Trace-Id": "sampled=0;root=1-5ac60e1c-72fad7cfa302fd32b0a6c702"
        },
        "log": [TRUNCATED],
        "latency": 25
    }
    複製程式碼
  3. 插句題外話,所有傳送到 os.Stderr 的資訊會被列印到 AWS 雲監控服務。所以如果你像上面的程式碼一樣建立了一個錯誤日誌器,你可以像這樣在雲監控上查詢錯誤:

    $ aws logs filter-log-events --log-group-name /aws/lambda/books \
    --filter-pattern "ERROR"
    複製程式碼

部署 API

  1. 既然 API 能夠正常工作了,是時候將它上線了。我們可以執行這個 aws apigateway create-deployment 命令:

    $ aws apigateway create-deployment --rest-api-id rest-api-id \
    --stage-name staging
    {
        "id": "4pdblq",
        "createdDate": 1522929303
    }
    複製程式碼

    在上面的程式碼中我給 API 命名為 staging,你也可以按你的喜好來給它起名。

  2. 部署以後你的 API 可以通過 URL 被訪問:

    https://rest-api-id.execute-api.us-east-1.amazonaws.com/staging
    複製程式碼

    用 curl 來試一試。它的結果應該跟預想中一樣:

    $ curl https://rest-api-id.execute-api.us-east-1.amazonaws.com/staging/books?isbn=978-1420931693
    {"isbn":"978-1420931693","title":"The Republic","author":"Plato"}
    $ curl https://rest-api-id.execute-api.us-east-1.amazonaws.com/staging/books?isbn=foobar
    Bad Request
    複製程式碼

支援多種行為

  1. 我們來為 POST /books 行為新增支援。我們希望它能讀取並校驗一條新的 book 記錄(從 JSON 格式的 HTTP 請求體中),然後把它新增到 DynamoDB 表中。

    既然不同的 AWS 服務已經聯通,擴充套件我們的 lambda 函式來支援附加的行為可能是這個教程最簡單的部分了,因為這可以僅通過 Go 程式碼實現。

    首先更新 db.go 檔案,新增一個 putItem 函式:

    檔案:books/db.go

    package main
    
    import (
        "github.com/aws/aws-sdk-go/aws"
        "github.com/aws/aws-sdk-go/aws/session"
        "github.com/aws/aws-sdk-go/service/dynamodb"
        "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
    )
    
    var db = dynamodb.New(session.New(), aws.NewConfig().WithRegion("us-east-1"))
    
    func getItem(isbn string) (*book, error) {
        input := &dynamodb.GetItemInput{
            TableName: aws.String("Books"),
            Key: map[string]*dynamodb.AttributeValue{
                "ISBN": {
                    S: aws.String(isbn),
                },
            },
        }
    
        result, err := db.GetItem(input)
        if err != nil {
            return nil, err
        }
        if result.Item == nil {
            return nil, nil
        }
    
        bk := new(book)
        err = dynamodbattribute.UnmarshalMap(result.Item, bk)
        if err != nil {
            return nil, err
        }
    
        return bk, nil
    }
    
    // 新增一條 book 記錄到 DynamoDB。
    func putItem(bk *book) error {
        input := &dynamodb.PutItemInput{
            TableName: aws.String("Books"),
            Item: map[string]*dynamodb.AttributeValue{
                "ISBN": {
                    S: aws.String(bk.ISBN),
                },
                "Title": {
                    S: aws.String(bk.Title),
                },
                "Author": {
                    S: aws.String(bk.Author),
                },
            },
        }
    
        _, err := db.PutItem(input)
        return err
    }
    複製程式碼

    然後修改 main.go 函式,這樣 lambda.Start() 方法會呼叫一個新的 router 函式,根據 HTTP 請求的方法決定哪個行為被呼叫:

    檔案:books/main.go

    package main
    
    import (
        "encoding/json"
        "fmt"
        "log"
        "net/http"
        "os"
        "regexp"
    
        "github.com/aws/aws-lambda-go/events"
        "github.com/aws/aws-lambda-go/lambda"
    )
    
    var isbnRegexp = regexp.MustCompile(`[0-9]{3}\-[0-9]{10}`)
    var errorLogger = log.New(os.Stderr, "ERROR ", log.Llongfile)
    
    type book struct {
        ISBN   string `json:"isbn"`
        Title  string `json:"title"`
        Author string `json:"author"`
    }
    
    func router(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
        switch req.HTTPMethod {
        case "GET":
            return show(req)
        case "POST":
            return create(req)
        default:
            return clientError(http.StatusMethodNotAllowed)
        }
    }
    
    func show(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
        isbn := req.QueryStringParameters["isbn"]
        if !isbnRegexp.MatchString(isbn) {
            return clientError(http.StatusBadRequest)
        }
    
        bk, err := getItem(isbn)
        if err != nil {
            return serverError(err)
        }
        if bk == nil {
            return clientError(http.StatusNotFound)
        }
    
        js, err := json.Marshal(bk)
        if err != nil {
            return serverError(err)
        }
    
        return events.APIGatewayProxyResponse{
            StatusCode: http.StatusOK,
            Body:       string(js),
        }, nil
    }
    
    func create(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
        if req.Headers["Content-Type"] != "application/json" {
            return clientError(http.StatusNotAcceptable)
        }
    
        bk := new(book)
        err := json.Unmarshal([]byte(req.Body), bk)
        if err != nil {
            return clientError(http.StatusUnprocessableEntity)
        }
    
        if !isbnRegexp.MatchString(bk.ISBN) {
            return clientError(http.StatusBadRequest)
        }
        if bk.Title == "" || bk.Author == "" {
            return clientError(http.StatusBadRequest)
        }
    
        err = putItem(bk)
        if err != nil {
            return serverError(err)
        }
    
        return events.APIGatewayProxyResponse{
            StatusCode: 201,
            Headers:    map[string]string{"Location": fmt.Sprintf("/books?isbn=%s", bk.ISBN)},
        }, nil
    }
    
    func serverError(err error) (events.APIGatewayProxyResponse, error) {
        errorLogger.Println(err.Error())
    
        return events.APIGatewayProxyResponse{
            StatusCode: http.StatusInternalServerError,
            Body:       http.StatusText(http.StatusInternalServerError),
        }, nil
    }
    
    func clientError(status int) (events.APIGatewayProxyResponse, error) {
        return events.APIGatewayProxyResponse{
            StatusCode: status,
            Body:       http.StatusText(status),
        }, nil
    }
    
    func main() {
        lambda.Start(router)
    }
    複製程式碼
  2. 重新編譯、打包 lambda 函式,然後像平常一樣部署它:

    $ env GOOS=linux GOARCH=amd64 go build -o /tmp/main books
    $ zip -j /tmp/main.zip /tmp/main
    $ aws lambda update-function-code --function-name books \
    --zip-file fileb:///tmp/main.zip
    複製程式碼
  3. 現在當你用不同的 HTTP 方法訪問 API 時,它應該呼叫合適的方法:

    $ curl -i -H "Content-Type: application/json" -X POST \
    -d '{"isbn":"978-0141439587", "title":"Emma", "author": "Jane Austen"}' \
    https://rest-api-id.execeast-1.amazonaws.com/staging/books
    HTTP/1.1 201 Created
    Content-Type: application/json
    Content-Length: 7
    Connection: keep-alive
    Date: Thu, 05 Apr 2018 14:55:34 GMT
    x-amzn-RequestId: 64262aa3-38e1-11e8-825c-d7cfe4d1e7d0
    x-amz-apigw-id: E33T1E3eIAMF9dw=
    Location: /books?isbn=978-0141439587
    X-Amzn-Trace-Id: sampled=0;root=1-5ac638e5-e806a84761839bc24e234c37
    X-Cache: Miss from cloudfront
    Via: 1.1 a22ee9ab15c998bce94f1f4d2a7792ee.cloudfront.net (CloudFront)
    X-Amz-Cf-Id: wSef_GJ70YB2-0VSwhUTS9x-ATB1Yq8anWuzV_PRN98k9-DkD7FOAA==
    
    $ curl https://rest-api-id.execute-api.us-east-1.amazonaws.com/staging/books?isbn=978-0141439587
    {"isbn":"978-0141439587","title":"Emma","author":"Jane Austen"}
    複製程式碼

掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章