Photo by Lukáš Vaňátko on Unsplash
Go has always been one of my favorite languages due to its simplicity. Lately, I decided to figure out what it takes to create a simple boilerplate serverless project with lambda functions written in Go. I was curious about the tooling and developer experience.
I want to create a REST API, that uses postgres db as a data layer. My initial requirements are the following
You would need to have Go installed, as well as AWS SAM. If you deploy your project to AWS you might be billed for the resources you are creating, so remember to clear your resources when you don't need them.
For DB I use supabase
The code is available in this repo
Let's start by running sam init. I picked the Hello World template, Go with provided al.2023 env. Previously there was a managed runtime for Go, but nowadays it is deprecated.
Having API schema defined as OpenApi spec has a few obvious advantages. We can use it to generate documentation, create clients, servers, etc. I also use it for defining the shape of the AWS HttpApi Gateway.
My schema is straightforward. The only interesting part is x-amazon-apigateway-integration property, which allows connection with lambda integration. The setup is language-agnostic.
You can find schema file in the repo
# template.yaml # .... ItemsAPI: Type: AWS::Serverless::HttpApi Properties: StageName: Prod DefinitionBody: 'Fn::Transform': Name: 'AWS::Include' Parameters: Location: './api.yaml' FailOnWarnings: false DBSecret: Type: AWS::SecretsManager::Secret Properties: Name: my-db-secret Description: Postgres config string SecretString: 'host=172.17.0.1 port=5431 user=admin password=root dbname=lambdas sslmode=disable' # ....
As mentioned above, there is nothing specific to Go here. The HttpApi Gateway is created based on OpenApi.
There is also a secret for storing connection string. I will update its value after the deployment
AWS SAM support for Go is pretty awesome. I can point the CodeUri to the folder with lambda handler and define the build method as go1.x
Lambda functions built in Go use provided.al2023 runtime, as they produce a single self-containing binary.
The definition of the function looks like this:
# template.yaml # .... GetItemFunction: Type: AWS::Serverless::Function Metadata: BuildMethod: go1.x Properties: Tracing: Active CodeUri: lambda_handlers/cmd/get_item/ Handler: bootstrap Runtime: provided.al2023 Architectures: - x86_64 Environment: Variables: DB_SECRET_NAME: !Ref DBSecret API_STAGE: Prod Events: HttpApiEvents: Type: HttpApi Properties: Path: /item/{id} Method: GET ApiId: !Ref ItemsAPI Policies: - AWSLambdaBasicExecutionRole - AWSSecretsManagerGetSecretValuePolicy: SecretArn: !Ref DBSecret # ....
Thanks to the SAM magic, the connection between HttpApi Gateway and the lambda function will be established with all required permissions.
To be honest, the folder structure is probably not idiomatic. But I tried to follow general Go patterns
lambda_handlers |--/api |--/cmd |--/internal |--/tools |--go.mod |--go.sum
cmd is the main folder with actual lambda handlers
internal holds the code shared between handlers
tools defines additional tools to be used in the projects
api for openapi generator config and generated models
The initial boilerplate for the lambda handler looks like this:
// ... func handleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { // handler logic } func main() { lambda.Start(handleRequest) }
Usually, the first question to ask is where to put the initialization of AWS SDK clients, database connection, and other things, we want to deal with during the cold start.
We have options here. First is to follow the pattern from AWS documentation example and initialize services inside the init() function. I don't like this approach, because it makes it harder to use the handler in the unit tests.
Thanks to the fact that lambda.Start() method takes a function as an input, I can wrap it in the custom struct, and initialize it with the services I need. In my case, the code looks this way:
package main // imports ... type GetItemsService interface { GetItem(id int) (*api.Item, error) } type LambdaHandler struct { svc GetItemsService } func InitializeLambdaHandler(svc GetItemsService) *LambdaHandler { return &LambdaHandler{ svc: svc, } } func (h *LambdaHandler) HandleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { strId, err := helpers.GetPathParameter("id", request.PathParameters) if err != nil { return helpers.SendResponse("id is required", 400), nil } id, err := strconv.Atoi(*strId) if err != nil { return helpers.SendResponse("id must be an integer", 400), nil } log.Printf("id: %d", id) result, err := h.svc.GetItem(id) if err != nil { if err.Error() == "Item not found" { return helpers.SendResponse("Item not found", 404), nil } return helpers.SendResponse("error", 500), nil } jsonRes, err := json.Marshal(result) if err != nil { log.Printf("error marshalling response: %s", err.Error()) return helpers.SendResponse("internal server error", 500), nil } return helpers.SendResponse(string(jsonRes), 200), nil } func main() { dbSecretName := os.Getenv("DB_SECRET_NAME") log.Printf("dbSecretName: %s", dbSecretName) cfg, err := awssdkconfig.InitializeSdkConfig() if err != nil { log.Fatal(err) } secretsClient := awssdkconfig.InitializeSecretsManager(cfg) connString, err := secretsClient.GetSecret(dbSecretName) if err != nil { log.Fatal(err) } conn, err := db.InitializeDB(connString) if err != nil { log.Fatal(err) } defer conn.Close() log.Println("successfully connected to db") svc := items.InitializeItemsService(conn) handler := InitializeLambdaHandler(svc) lambda.Start(handler.HandleRequest) }
In the main() function (which runs during cold start) I get the secret from secretsmanager and then initialize the connection with DB. Both functionalities are defined inside internal folders as common helpers so they can be reused in other handlers. Finally my ItemsService is initialized with the created db connection, and used for creating a lambda handler.
HandleRequest parses ID from the path parameter, and calls ItemsService to get an item from DB.
As the function is simple, there is not much business logic around. The ItemsServise simply calls db for the specific item
package items import ( "database/sql" "errors" api "lambda_handlers/api" "log" ) type ItemsService struct { conn *sql.DB } func InitializeItemsService(conn *sql.DB) *ItemsService { return &ItemsService{ conn: conn, } } func (svc ItemsService) GetItem(id int) (*api.Item, error) { log.Printf("Getting item id %v", id) query := `SELECT * FROM items WHERE id = $1` var item api.Item err := svc.conn.QueryRow(query, id).Scan(&item.Id, &item.Name, &item.Price) log.Printf("Got item id %v", id) if err != nil { log.Printf("Error getting item %v: %v", id, err) if err == sql.ErrNoRows { return nil, errors.New("Item not found") } return nil, err } return &item, nil }
At this point, we don't need anything more here.
My goal is to use additional tools, which could be attached to the project dependencies, so there is no need to rely on tools installed on the developer's machine.
In Go one way of doing it is to keep oapi-codegen in the tools package
//go:build tools // build tools package main import ( _ "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen" )
And call it from inside api_gen.go
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=cfg.yaml ../../api.yaml package api
This way I can run go generate without installing oapi-codegen binaries separately.
My build process requires two steps: generating models from OpenAPI, and building the projects itself. I let AWS SAM deal with the latter.
Here is my Makefile
.PHONY: build local deploy generate: cd lambda_handlers && go generate ./... build: generate rm -rf .aws-sam sam build local: build sam local start-api --env-vars parameters.json deploy: build sam deploy
For me, the easiest way to test API Gateway locally is to run sam local start-api
Since our function relies on environment variables, I created paramters.json file to pass env vars to sam local
When developing for serverless, at some point you might want to start using cloud resources even for local development. In my case, I will utilize the secrets manager right away to store the connection string for DB. It means, that I need to deploy the stack first, so I can use it in the local development.
I run make deploy but for now I don't check the whole deployment, just grab a secret name from the console. I also need to update the secret in the console, so it holds the correct connection string.
For testing, I have created a DB on supabase and seeded it with a few dummy records
After running make local I can test API locally
Since Go is a compiled language, after each change I need to rebuild the project and run start-api again. Considering the Go compiler's amazing speed, it is not a big deal.
The API Gateway URL was printed out in the console after deployment, and it can be also grabbed from the AWS console directly.
I call the endpoint, and it works as expected:
Cold start is a bit long, as initialization takes ~300 ms, mostly because it includes taking the secret and establishing a connection to the DB. But to be honest it is more than a decent result.
The given project is a starting point for creating a serverless REST API in Go. It uses OpenAPI for the schema and AWS SAM for managing deployment and local testing.
I've used an external postgres db and AWS SDK for getting connection string from secrets manager.
There are also unit tests for lambda handler and items service
Most of the time I've spent configuring the AWS part, which would be the same for all languages. The Go code is pretty straightforward (for this simple use case).
Disclaimer: All resources provided are partly from the Internet. If there is any infringement of your copyright or other rights and interests, please explain the detailed reasons and provide proof of copyright or rights and interests and then send it to the email: [email protected] We will handle it for you as soon as possible.
Copyright© 2022 湘ICP备2022001581号-3