Xem các bài viết khác thuộc Series Serverless

Tác giả Huỳnh Minh Quân là giảng viên khóa học AWS thực hành và luyện thi chứng chỉ : Learn AWS the Hard Way


Giới thiệu

Chào các bạn tới với series về Serverless, ở bài trước chúng ta đã nói về cách sử dụng AWS API Gateway kết hợp với AWS Lambda để xây dựng REST API theo mô hình Serverless. Tuy nhiên, Lambda functions là stateless application nên nó không thể lưu trữ dữ liệu được, nên ở bài này ta sẽ tìm hiểu về thành phần thứ ba để xây dựng mô hình Serverless trên môi trường AWS Cloud, là DynamoDB.

Kết thúc bài trước thì ta đã xây được REST API với API Gateway + Lambda theo minh họa sau đây.

image.png

Ở bài này thì mình sẽ dùng Terraform để tạo ra hệ thống ở trên, nên ta không cần phải tạo từ đầu, nếu các bạn muốn biết cách tạo bằng tay theo từng bước thì các bạn đọc ở bài trước nhé. Bước tiếp theo ta cần làm cho hệ thống trên là gắn thêm DynamoDB vào để lưu trữ data, minh họa như sau.

image.png

DynamoDB

DynamoDB là database dạng NoSQL, được thiết kế và phát triển bởi AWS. Đây là một trong những service thuộc dạng Serverless của AWS, nó có thể tự động scale tùy thuộc vào dữ liệu ta ghi và đọc vào DB, dữ liệu có thể được lưu trữ dưới dạng encryption để tăng độ bảo mật, có thể tự động backup và restore dữ liệu.

image.png

Ta sẽ xem qua một vài khái niệm chính của DynamoDB mà ta cần hiểu trước khi sử dụng nó với Lambda.

Kiến trúc của DynamoDB

Gồm có Table là tập họp của nhiều items (rows), với mỗi item là tập họp của nhiều attributes (columns) và values.

image.png

Trong table thì sẽ có primary keys, và primary keys thì có hai loại là:

  • Partition key: là một hash key, giá trị của nó sẽ là unique ID trong một bảng.
  • Partition key + sort key: là một cặp primary key, với partition key dùng để định nghĩa item đó trong một bảng và sort key dùng để sort item theo partition key.

image.png

Ngoài ra trong table còn có Index, thì giống với các loại database khác nó dùng để tăng tốc độ query của một table, có hai loại index là Global Secondary Index (GSI) với Local Secondary Index (LSI).

Tương tác với DynamoDB

Ta sẽ có các operations sau để tương tác với DynamoDB:

  • Scan: operation này sẽ duyệt qua toàn bộ table để tìm kiếm item theo điều kiện nào đó.
  • Query: operation này sẽ kiếm item theo primary key và trả về một list.
  • PutItem: operation này dùng sẽ tạo mới hoặc cập nhật lại một item.
  • GetItem: operation này sẽ kiếm item theo primary key và chỉ trả về kết quả đầu tiên.
  • DeleteItem: operation này sẽ xóa item trong table dựa vào primary key.

Đây là những hàm cơ bản để ta tương tác với DynamoDB, để hiểu rõ hơn về DynamoDB thì còn rất nhiều thứ để học 😂, ở bài này ta chỉ xem qua một số cái cơ bản để ta có thể làm việc với nó, mình cũng không có biết nhiều lắm về DynamoDB 😂. Giờ ta sẽ tiến hành tạo bảng và sẽ viết code để Lambda có thể lưu dữ liệu vào trong DynamoDB.

Tạo bảng

Truy cập lên AWS Web Console, kiếm DynamoDB và bấm vào create table, bạn sẽ thấy giao diện sau đây, ở chỗ Table name điền vào là books, ở chỗ Partition key điền vào id.

image.png

Các giá trị còn lại bạn để mặc định.

image.png

Bấm tạo và chờ một lát và bạn sẽ thấy table của ta.

image.png

Ta cũng có thể tạo bằng câu CLI sau đây cho nhanh nếu bạn không muốn dùng UI.

$ aws dynamodb create-table --table-name books --attribute-definitions AttributeName=id,AttributeType=S --key-schema AttributeName=id,KeyType=HASH --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1

Sau khi tạo bảng xong thì ta sẽ ghi vào một vài dữ liệu mẫu.

Ghi dữ liệu vào DynamoDB

Bấm vào books table, ở mục action, chọn create item.

image.png

Xong điền vào giá trị như sau và bấm tạo.

image.png

Sau khi tạo xong, kéo xuống mục Item summary, bấm vào View items.

image.png

Ta sẽ thấy item ta vừa tạo.

image.png

Ta có thể dùng CLI để ghi dữ liệu vào bảng, như sau:

$ aws dynamodb put-item --table-name books --item file://item.json

{
"id": {
    "S": "2"
},
"name": {
    "S": "Golang"
},
"author": {
    "S": "Golang"
}
}

Kết quả.

image.png

Sau khi ghi dữ liệu mẫu vào trong DB xong, bây giờ ta sẽ chuyển sang integrate Lambda với DynamoDB, đầu tiên ta sẽ viết function list dữ liệu trong DynamoDB ra.

Integrate Lambda với DynamoDB

Đầu tiên ta sẽ tạo API Gateway + Lambda function trước, như đã nói ở trên thì ta sẽ dùng terraform, các bạn xem bài 2 để biết cách tạo bằng tay. Các bạn tải source code ở git repo này https://github.com/hoalongnatsu/serverless-series.git, di chuyển tới folder bai-3/terraform-start, mở file policies/lambda_policy.json ra.

{
"Version": "2012-10-17",
"Statement": [
    {
      "Sid": "1",
      "Action": "logs:*",
      "Effect": "Allow",
      "Resource": "*"
    },
    {
      "Sid": "2",
      "Effect": "Allow",
      "Action": "dynamodb:*",
      "Resource": "arn:aws:dynamodb:us-west-2:<ACCOUNT_ID>:table/books"
    }
]
}

Ở chỗ resource arn:aws:dynamodb:us-west-2:<ACCOUNT_ID>:table/books, thay ACCOUNT_ID bằng account AWS của bạn. Xong sau đó, bạn chạy câu lệnh:

terraform init
terraform apply -auto-approve

Xong khi tạo xong, bấm vào AWS Lambda và API Gateway, bạn sẽ thấy các function như sau.

image.png

API Gateway

image.png

Bấm vào books-api và bấm qua mục Stages, ta sẽ thấy URL của API ngay chỗ Invoke URL.

image.png

Oke, vậy là ta đã chuẩn bị xong, tiếp theo ta sẽ tiến hành viết code nào. Tạo thư mục như sau.

.
├── create
│   ├── build.sh
│   └── main.go
├── delete
│   ├── build.sh
│   └── main.go
├── get
│   ├── build.sh
│   └── main.go
└── list
    ├── build.sh
    └── main.go

#!/bin/bash

GOOS=linux go build -o main main.go
zip list.zip main
rm -rf main

Các file build.sh còn lại các bạn thay tên file zip tương ứng với tên thư mục, ví dụ ở thư mục list thì file zip build ra sẽ là list.zip. Đầu tiên là sẽ viết code cho API list trước.

List with scan operation

Cập nhật code của file list/main.go như sau:

package main

import (
	"context"
	"encoding/json"
	"net/http"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
)

func list(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       "Error while retrieving AWS credentials",
		}, nil
	}

	svc := dynamodb.NewFromConfig(cfg)
	out, err := svc.Scan(context.TODO(), &dynamodb.ScanInput{
		TableName: aws.String("books"),
	})
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       err.Error(),
		}, nil
	}

	res, _ := json.Marshal(out.Items)
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Headers: map[string]string{
			"Content-Type": "application/json",
		},
		Body: string(res),
	}, nil
}

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

Init code và upload code lên AWS Lambda.

go mod init list
go get
sh build.sh
aws lambda update-function-code --function-name books_list --zip-file fileb://list.zip --region us-west-2

Để tương tác được với DynamoDB, ta sẽ sử dụng hai package là github.com/aws/aws-sdk-go-v2/configgithub.com/aws/aws-sdk-go-v2/service/dynamodb.

Đầu tiên, ta sẽ load config mặc định khi Lambda function được thực thi bằng câu lệnh config.LoadDefaultConfig(context.TODO()).

Sau đó ta sẽ khởi tạo DynamoDB bằng config trên với câu lệnh dynamodb.NewFromConfig(cfg). Để lấy toàn bộ item trong bảng books, ta dùng lệnh scan ở đoạn code sau.

out, err := svc.Scan(context.TODO(), &dynamodb.ScanInput{
    TableName: aws.String("books"),
})

Gọi thử API của ta, copy Invoke URL ở API Gateway.

$ curl https://utp0mbdckb.execute-api.us-west-2.amazonaws.com/staging/books
[{"author":{"Value":"Golang"},"id":{"Value":"2"},"name":{"Value":"Golang"}},{"author":{"Value":"NodeJS"},"id":{"Value":"1"},"name":{"Value":"NodeJS"}}]

image.png

Ta sẽ thấy dữ liệu ở trong bảng books của ta đã được API trả về chính xác, vậy là ta đã kết nối được Lambda function với DynamoDB 😁. Nhưng mà kết quả ở trên nó trả về không được đẹp cho lắm, ta sẽ sửa lại để API của ta trả về kết quả với định dạng dễ xài hơn. Ta sẽ dùng package github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue để format dữ liệu, tải package.

go get github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue

Cập nhật lại file main.go với đoạn code sau

package main

import (
	"context"
	"encoding/json"
	"net/http"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
)

type Book struct {
	Id     string `json:"id"`
	Name   string `json:"name"`
	Author string `json:"author"`
}

func list(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       "Error while retrieving AWS credentials",
		}, nil
	}

	svc := dynamodb.NewFromConfig(cfg)
	out, err := svc.Scan(context.TODO(), &dynamodb.ScanInput{
		TableName: aws.String("books"),
	})
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       err.Error(),
		}, nil
	}

	books := []Book{}
	err = attributevalue.UnmarshalListOfMaps(out.Items, &books)
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       "Error while Unmarshal books",
		}, nil
	}

	res, _ := json.Marshal(books)
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Headers: map[string]string{
			"Content-Type": "application/json",
		},
		Body: string(res),
	}, nil
}

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

Ta sẽ format lại dữ liệu trả về với struct Book ở đoạn code.

books := []Book{}
err = attributevalue.UnmarshalListOfMaps(out.Items, &books)

Giờ khi gọi lại API, ta sẽ thấy kết quả trả về với định dạng dễ xài hơn.

$ curl https://utp0mbdckb.execute-api.us-west-2.amazonaws.com/staging/books
[{"id":"2","name":"Golang","author":"Golang"},{"id":"1","name":"NodeJS","author":"NodeJS"}]

image.png

Get one with GetItem operation

Tiếp theo ta sẽ implement API get one. Cập nhật lại file get/main.go như sau.

package main

import (
	"context"
	"encoding/json"
	"net/http"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

type Book struct {
	Id     string `json:"id"`
	Name   string `json:"name"`
	Author string `json:"author"`
}

func get(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       "Error while retrieving AWS credentials",
		}, nil
	}

	svc := dynamodb.NewFromConfig(cfg)
	out, err := svc.GetItem(context.TODO(), &dynamodb.GetItemInput{
		TableName: aws.String("books"),
		Key: map[string]types.AttributeValue{
			"id": &types.AttributeValueMemberS{Value: req.PathParameters["id"]},
		},
	})
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       err.Error(),
		}, nil
	}

	movie := Book{}
	err = attributevalue.UnmarshalMap(out.Item, &movie)
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body: "Error while marshal movies",
		}, nil
	}

	res, _ := json.Marshal(movie)
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Headers: map[string]string{
			"Content-Type": "application/json",
		},
		Body: string(res),
	}, nil
}

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

Init code và upload lên AWS Lambda.

go mod init get
go get
sh build.sh
aws lambda update-function-code --function-name books_get --zip-file fileb://get.zip --region us-west-2

Để kiếm item theo primary key, ta dùng hàm GetItem với đoạn code:

out, err := svc.GetItem(context.TODO(), &dynamodb.GetItemInput{
    TableName: aws.String("books"),
    Key: map[string]types.AttributeValue{
        "id": &types.AttributeValueMemberS{Value: req.PathParameters["id"]},
    },
})

Kiểm tra thử API.

$ curl https://utp0mbdckb.execute-api.us-west-2.amazonaws.com/staging/books/1
{"id":"1","name":"NodeJS","author":"NodeJS"}

image.png

Nếu các bạn in ra được kết quả như trên thì API get one của ta đã chạy đúng. Nếu bạn gọi đến API get one mà với id của item mà không có trong bảng, thì nó sẽ không trả về 404 mà sẽ là một object với giá trị của từng property là trỗng.

$ curl https://utp0mbdckb.execute-api.us-west-2.amazonaws.com/staging/books/3
{"id":"","name":"","author":""}

Nếu bạn muốn trả về lỗi 404 thì ta có thể làm bằng tay.

Create with with PutItem operation

Tiếp theo ta sẽ làm API create, cập nhật code ở file create/main.go như sau:

package main

import (
	"context"
	"encoding/json"
	"net/http"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

type Book struct {
	Id     string `json:"id"`
	Name   string `json:"name"`
	Author string `json:"author"`
}

func create(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	var book Book
	err := json.Unmarshal([]byte(req.Body), &book)
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: 400,
			Body:       err.Error(),
		}, nil
	}

	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       "Error while retrieving AWS credentials",
		}, nil
	}

	svc := dynamodb.NewFromConfig(cfg)
	newBook, err := svc.PutItem(context.TODO(), &dynamodb.PutItemInput{
		TableName: aws.String("books"),
		Item: map[string]types.AttributeValue{
			"id":     &types.AttributeValueMemberS{Value: book.Id},
			"name":   &types.AttributeValueMemberS{Value: book.Name},
			"author": &types.AttributeValueMemberS{Value: book.Author},
		},
        ReturnValues: types.ReturnValueAllOld,
	})
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       err.Error(),
		}, nil
	}

	res, _ := json.Marshal(newBook)
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Headers: map[string]string{
			"Content-Type": "application/json",
		},
		Body: string(res),
	}, nil
}

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

Init code và upload lên AWS Lambda.

go mod init create
go get
sh build.sh
aws lambda update-function-code --function-name books_create --zip-file fileb://create.zip --region us-west-2

Để insert dữ liệu được vào DynamoDB, ta dùng hàm PutItem với đoạn code:

newBook, err := svc.PutItem(context.TODO(), &dynamodb.PutItemInput{
    TableName: aws.String("books"),
    Item: map[string]types.AttributeValue{
        "id":     &types.AttributeValueMemberS{Value: book.Id},
        "name":   &types.AttributeValueMemberS{Value: book.Name},
        "author": &types.AttributeValueMemberS{Value: book.Author},
    },
    ReturnValues: types.ReturnValueAllOld,
})

Kiểm tra thử API create của ta.

$ curl -sX POST -d '{"id":"3", "name": "Java", "author": "Java"}' https://utp0mbdckb.execute-api.us-west-2.amazonaws.com/staging/books
{"Attributes":null,"ConsumedCapacity":null,"ItemCollectionMetrics":null,"ResultMetadata":{}}

Oke, sau đó ta gọi lại API list.

$ curl https://utp0mbdckb.execute-api.us-west-2.amazonaws.com/staging/books
[{"id":"3","name":"Java","author":"Java"},{"id":"2","name":"Golang","author":"Golang"},{"id":"1","name":"NodeJS","author":"NodeJS"}]

image.png

Bạn sẽ thấy dữ liệu ta mới insert vào database bằng API create ở trên đã xuất hiện, vậy là API create của ta dã hoạt động đúng.

Delete with DeleteItem operation

Tiếp theo ta sẽ làm API delete, cập nhật code ở file delete/main.go như sau:

package main

import (
	"context"
	"encoding/json"
	"net/http"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

type Book struct {
	Id     string `json:"id"`
	Name   string `json:"name"`
	Author string `json:"author"`
}

func get(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       "Error while retrieving AWS credentials",
		}, nil
	}

	svc := dynamodb.NewFromConfig(cfg)
	out, err := svc.DeleteItem(context.TODO(), &dynamodb.DeleteItemInput{
		TableName: aws.String("books"),
		Key: map[string]types.AttributeValue{
			"id": &types.AttributeValueMemberS{Value: req.PathParameters["id"]},
		},
	})
	if err != nil {
		return events.APIGatewayProxyResponse{
			StatusCode: http.StatusInternalServerError,
			Body:       err.Error(),
		}, nil
	}

	res, _ := json.Marshal(out)
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Headers: map[string]string{
			"Content-Type": "application/json",
		},
		Body: string(res),
	}, nil
}

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

Init code và upload lên AWS Lambda.

go mod init delete
go get
sh build.sh
aws lambda update-function-code --function-name books_delete --zip-file fileb://delete.zip --region us-west-2

Để delete dữ liệu trong DynamoDB, ta dùng hàm DeleteItem với đoạn code:

out, err := svc.DeleteItem(context.TODO(), &dynamodb.DeleteItemInput{
    TableName: aws.String("books"),
    Key: map[string]types.AttributeValue{
        "id": &types.AttributeValueMemberS{Value: req.PathParameters["id"]},
    },
})

Kiểm tra API delete của ta.

$ curl -sX DELETE -d '{"id":"3"}' https://utp0mbdckb.execute-api.us-west-2.amazonaws.com/staging/books
$ $ curl https://utp0mbdckb.execute-api.us-west-2.amazonaws.com/staging/books
[{"id":"2","name":"Golang","author":"Golang"},{"id":"1","name":"NodeJS","author":"NodeJS"}]

Oke, API delete của ta đã hoạt động đúng 😁.

Kết luận

Vậy là ta đã tìm hiểu xong cách integrate Lambda với DynamoDB. Nếu có thắc mắc hoặc cần giải thích rõ thêm chỗ nào thì các bạn có thể hỏi dưới phần comment. Hẹn gặp mọi người ở bài tiếp theo.

Xem các bài viết khác thuộc Series Serverless