Building a real-time serverless chat application on AWS with Go and Vue 3: Part 1
Introduction
This is the 23rd December entry of the Eureka Advent Calendar 2021 🎅🏻
Yesterday Ren Kanai wrote about delivering Pairs’ real-time community chat feature with the help of AppSync. Today I’ll continue the real-time-on-AWS theme by exploring how to create a simple chat application using an API Gateway WebSocket API.
This article assumes little to no experience with Serverless Framework. All code can be found in the GitHub repository.
Application Overview
The application has both a frontend and backend that are completely serverless. Vue CLI, Serverless Framework, and a little CloudFormation make generating, deploying, and finally destroying our application a breeze.
The frontend is a minimal Vue SPA. Basic CSS is used for the highly-original design. The static assets are saved to S3 and served by CloudFront. We won’t be able to use SSR, but do get simple, fast, and cheap hosting without needing to maintain a server.
The backend is a group of Go Lambda functions implementing the WebSocket API spec behind an API Gateway instance. DynamoDB is used as a datastore.
Prerequisites
Node.js
The application has been tested with Node v16.13.1 and NPM v8.1.2. Head to the official downloads page or try a version manager like nvm to download. Serverless Framework can then be installed via NPM.
npm install -g serverless
Go
The Lambda functions have been tested with Go v1.17.5. The official downloads page explains how to get started. Go Modules automate everything else for us.
Top-level Package Structure
The top-level package structure will be split by specialty:
client
— The frontendinfra
—IaC templatessls
— Backend services
go.mod
is placed at the root of the sls
directory for simplicity. For a discussion about go.mod
in monorepos, see Catching Up with the World — Go Modules in a Monorepo.
Each backend service is confined to its own svc-
directory, with common code (sparingly) extracted to lib
. It’s generally better to copy small amounts of code to avoid coupling between services, as suggested in the Go Proverbs.
Each service has two main directories that are kept as flat as possible:
handlers
— Lambda handler functions and testsinternal
— Go code needed to implement the handlers in this service
Service Architecture
Packages are kept as flat as possible for simplicity, but effort is made to separate concerns and maintain testability.
The Service abstraction here helps us achieve the following two recommendations from the Best practices section of the Developer Guide:
- Separate the Lambda handler from your core logic
- Take advantage of execution environment reuse to improve the performance of your function
Each Handler constructs a Service and delegates to its methods, where the core logic is executed. The reference is maintained until the execution environment is terminated to avoid per-request allocations. The Service delegates to the Repository and API Client to access external resources like DynamoDB and the API Gateway Management API.
For more on structuring multiple Lambda functions, see Serverless Code Patterns on the Serverless Framework blog.
API Gateway WebSocket API Specification
Whereas SaaS like Pusher Channels abstract everything away to a few SDK method calls for both client and server, API Gateway provides lower-level tools for managing WebSocket connections.
Though active connections themselves are handled by API Gateway, the low-level nature of WebSocket APIs means we have to solve problems like how to deal with large numbers of connected users efficiently by ourselves. On the other hand, we gain the ability to customise authentication, logging, monitoring, and other aspects of our implementation.
API Gateway directs incoming JSON messages to routes that are connected to integrations (usually Lambda functions). The three predefined routes are:
$connect
—Called when a connection is initiated$disconnect
—Called when a connection is terminated$default
—Called when no route expression is provided or as a fallback
Custom routes can also be defined based on a route selection expression. For example, an expression of $request.body.action
will look for an action
property in the request body, and route to an integration with a route that matches the given value. In addition, a route response can be used to allow a Lambda function to return responses directly via the WebSocket connection.
A simplified sequence of the connection lifecycle could be as below.
API Gateway provides the endpoint to the user, and routes requests as explained above. The integrated Lambda functions may make use of a datastore like DynamoDB to store references to active connections. The API Gateway Management API is used to send messages to any connection, though route responses can be used to return messages directly to the current requesting user.
A Note about the AWS SDK for Go
We’ll be using the AWS SDK for Go v2 for the backend, and interface types commonly used for mocks in testing were removed in v0.25.0. The Unit Testing section of the Developer Guide recommends a common, though verbose, mocking pattern as described in Flexible mocking for testing in Go. This isn’t necessarily a popular decision as can be seen in the open discussion on the topic.
The lib/awsiface
package in the GitHub repository provides reusable mocks implemented in a similar way that cover all the necessary methods.
The Connection Datastore
API Gateway WebSocket APIs requires us to store and manage references to connections ourselves if we want to implement interactions between different connections.
DynamoDB requires just a few lines of CloudFormation to get started with in a serverless environment, and costs next to nothing to run for a sample application like this. We have two required access patterns:
- Get a connection by ID — Uses the partition key, which is a high-cardinality attribute (connection ID), as recommended in Choosing the Right DynamoDB Partition Key.
GetItem
can be used here for maximum efficiency. - Get the list of online users —Returns the entire table, so doesn’t use any key. The table will be small enough to use
Scan
without worrying. In a real application, we’d likely partition data by a channel or other concept and useQuery
instead.
DynamoDB can be a minefield of hot partitions and impossible-to-implement-after the fact queries. It’s essential to know all access patterns beforehand and verify their performance before deciding on DynamoDB. The best practices section of the Developer Guide is a good place for more information.
When implementing a repository with the DynamoDB SDK for Go, things can get quite verbose and hard to remember. The Complete Cheat Sheet over at Dynobase is highly recommended.
Implementing the Service Architecture
Next, we’ll see how the service architecture described above is implemented for the $connect
handler. Refer to the GitHub repository for the full code.
First, ConnectionRepository
is a fairly standard use of the DynamoDB SDK, and performs CRUD on the connections table. The attributevalue
package is used for marshalling/unmarshalling structs, and the dependency on awsiface.DynamoDB
and the extra New
function will make testing easier.
APIClient
is a minimal wrapper around apigatewaymanagementapi#Client
to make creating and using the type a little easier. We’ll use this type in Part 2 when implementing the $default
route.
WebSocketService
depends on ConnectionRepository
and APIClient
, and implements the core logic of the service, and prepares responses for the handler.
Finally, the $connect
route handler is found in svc-ws/handlers/connect
. The main
function — called once when the handler is initialised — itself initialises logger
and svc
package-level vars that will live until termination. The handler
function is called per request, and other than for logging it simply delegates to svc
.
We can build the code with the below command.
env GOARCH=amd64 GOOS=linux go build -ldflags="-s -w" -o bin/connect handlers/connect/connect.go
Note on Logging
zap
is used for structured logging, and a simple wrapper is defined in logger.go
that keeps the handler code clean.
To embed a request and connection ID in every log, as well as flush logs after each request, a simple middleware-style pattern is used. The handler function is wrapped in the below function before being passed to lambda.Start
.
For help on using context.WithValue
, check out the original blog post.
Testing the $connect Handler
Before deploying, we’ll add a sanity test for the handler. This is relatively straightforward thanks to depending on the interfaces in the awsiface
package and getting our handler to perform DI in its main
function.
Mocking methods like this does get incredibly verbose though, especially when using the signatures from the SDK. Time may be better spent developing full E2E tests or a middleware-based approach. The tests can be run with the below command.
go test ./handlers/...
Deploying the $connect Handler
Now that the code is built and tested, we need to make the below changes to serverless.yml
to get it ready for deployment to AWS. (See the comments for details.)
These 75 lines of YAML have achieved more than we may realise. By running sls package
and inspecting the generated .serverless
directory, we can verify the raw CloudFormation templates.
cloudformation-template-create-stack.json
contains resources for the deployment itself. Serverless Framework maintains an S3 bucket with a deployment history for us.
cloudformation-template-update-stack.json
is where the magic of our API Gateway WebSockets API is happening. All of the necessary resources and integrations are mapped out in JSON automatically for us, saving us having to write and test hundreds of lines of CloudFormation.
We’re finally ready to deploy, and we’ll do so by adding some new commands to our Makefile
.
clean:
rm -rf ./binbuild:
env GOARCH=amd64 GOOS=linux go build -ldflags="-s -w" -o bin/connect handlers/connect/connect.gotest:
go test ./handlers/...deploy: clean build test
sls deploy --verbose
Running make deploy
should build, test and deploy the code, leading to a large amount of output in the terminal.
If we head over to the CloudFormation Dashboard on the AWS console, we should see a stack named sls-svc-ws-dev
. The Resources tab provides a convenient set of links for accessing resources on the console, all of which you’ll recognise from cloudformation-template-update-stack.json
. The Events tab can also be useful for debugging when an sls deploy
fails.
From either the terminal output from make deploy
or the Resources tab shown above, find the value of ServiceEndpointWebsocket
, which should look something like below.
wss://id.execute-api.region.amazonaws.com/dev
A simple way to verify the endpoint locally is with wscat
as follows. If you see a connected prompt, then everything is running correctly. Alternatively, you can use JavaScript to create a WebSocket
as described in the MDN document.
npm install -g wscat
wscat -c "wss://id.execute-api.region.amazonaws.com/dev"
Since no $default
route is defined yet, sending a message within the wscat
prompt should return a default error message.
{"message": "Forbidden", "connectionId":"KwH-UcApNjMCLwQ=", "requestId":"KwINPHWmNjMFyww="}
Finally, we can run sls logs -f connect
to verify Lambda execution and the zap
info logs. We can see requestId
and connectionId
have been properly set by the middleware function.
START RequestId: 11811c92-db49-4aa6-907b-88eb42b4bae9 Version: $LATEST
{"level":"info","ts":1640177903.706682,"caller":"internal/logger.go:46","msg":"request to $connect","requestId":"KwIVeGxQNjMFRqQ=","connectionId":"KwIVeduPtjMCEsw="}
{"level":"info","ts":1640177903.7439616,"caller":"internal/logger.go:46","msg":"connect success","requestId":"KwIVeGxQNjMFRqQ=","connectionId":"KwIVeduPtjMCEsw="}
END RequestId: 11811c92-db49-4aa6-907b-88eb42b4bae9
REPORT RequestId: 11811c92-db49-4aa6-907b-88eb42b4bae9 Duration: 38.52 ms Billed Duration: 39 ms Memory Size: 1024 MB Max Memory Used: 40 MB
Cleaning Up
To avoid incurring any unnecessary costs, let’s remove the stack now that we’re finished. Our serverless.yml
file makes it easy to redeploy from scratch within a minute or two whenever we want.
Running sls remove
should generate some terminal output, and eventually see a Stack delete finished...
message. If you made it this far, then thanks for reading.
Summary
Serverless backends can greatly reduce provisioning overhead and running costs when designed appropriately for the right use cases. The dependency on multiple live AWS resources poses some development challenges, but I aimed to show that Serverless Framework, simple testing, and a few make
commands can make the process quite straightforward.
In Part 2, we’ll complete the backend implementation, and look at the API Gateway WebSockets API spec in a little more detail.