Building a real-time serverless chat application on AWS with Go and Vue 3: Part 1

James Kirk
Eureka Engineering
Published in
10 min readDec 23, 2021

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

Serverless chat application architecture

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

Top-level package structure

The top-level package structure will be split by specialty:

  • client — The frontend
  • infraIaC templates
  • sls — 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 tests
  • internal — Go code needed to implement the handlers in this service

Service Architecture

Serverless 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.

Example connection lifecycle sequence

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 use Query 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 ./bin
build:
env GOARCH=amd64 GOOS=linux go build -ldflags="-s -w" -o bin/connect handlers/connect/connect.go
test:
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.

References

--

--