Joe Davidson

Micro Frontends with Golang and Hotwire 2: Local Development Environment

gomicro frontends

Introduction #

I want to give a quick start guide on how I arranged my development environment for micro frontends in the Go Feed Me [0] project.
This isn't the only way this could be done or necessarily the best, I just found it the simplest to get going.

Prerequisites #

Structure #

For reference here is the structure of files that relate to developing locally:

docker
├── docker-compose.yml
├── Dockerfile
├── variables.env
└── moxy-config.toml
src
└── [some-micro-frontend]
├── app
│ ├── app.go
│ └── [some-handler].go
├── cmd
│ └── main.go
├── templates
│ └── [some-template].go
├── types
│ └── config.go
├── .air.toml
├── local.toml
├── docker.toml
└── go.mod

Creating a new frontend #

Since the idea was to have multiple frontend apps to drive the application, I had to carry out this process multiple times. I'd say that if your system would require you to do this a lot, create a generic started template to fork each app from.

I also made the decision to build this in one git repo, this could be separated and the docker folder could be in a separate local-development repo.

For this example I'll show the process for creating two micro frontends, hello and goodbye. I'll also show how I did routing between them in my local development environment.

Create a new Go module. #

# Create and navigate to a new folder in the src dir.
mkdir src/hello
cd src/hello

# Create a new go mod
go mod init hello

Install the relevant dependencies. #

Additional packages may be required depending on the function of the frontend, but the core dependencies required are a-h/templ [1], BurntSushi/toml [2], and gorilla/mux [3].

go get github.com/BurntSushi/toml github.com/a-h/templ github.com/gorilla/mux

The templ CLI tool will also be required:

go install github.com/a-h/templ/cmd/templ

Create a basic template. #

The templating language used is templ — a strongly typed, Go based language. For now, we will just use a basic template to verify the site is working.

<!-- This snippet will not compile as is, html comments are not currently supported in templ -->
<!-- src/hello/templates/hello.templ -->

<!-- This is the templ way of defining what Go package the file is in -->
{% package templates %}

<!-- Create a new component that takes a string -->
{% templ HelloTemplate(message string) %}
<!-- Print the string as a p tag -->
<p>{%= message %}</p>
<!-- End the templ component -->
{% endtempl %}

This template can now be compiled into Go code, just run the following command in your root src directory:

templ generate

This should result in a file that can be used in other parts of your code now:

// src/hello/templates/hello_templ.go
// Code generated by templ DO NOT EDIT.

package templates

import "github.com/a-h/templ"
import "context"
import "io"

func HelloTemplate(string message) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
_, err = io.WriteString(w, "<p>")
if err != nil {
return err
}
_, err = io.WriteString(w, templ.EscapeString(message))
if err != nil {
return err
}
_, err = io.WriteString(w, "</p>")
if err != nil {
return err
}
return err
})
}

Render the template in a handler. #

Now that we have our simple template we need an HTTP handler to render it as an HTTP response.

// src/hello/app/hello.go
package app

// Import the generated template package.
import (
"hello/templates"
"net/http"
"fmt"
"time"
)

// Create a basic HTTP handler.
func HandleRequest(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "text/html")
// Create a message to pass to the template.
nowTime := time.Now()
m := fmt.Sprintf("Hello, the time is %s", nowTime.String())

// Templ works by initially priming the template with the data it requires.
t := templates.HelloTemplate(m)

// Then using it to write a response to any io.Writer in our case http.ResponseWriter
err := t.Render(r.Context(), w)

// Finally throw any errors returned by the template generation
if err != nil {
http.Error(w, "failed to render", http.StatusInternalServerError)
}
}

Make our app executable. #

Start with some config.

// src/hello/types/config.go

package types

type Config struct {
Port string
}
# src/hello/local.toml

Port = ":80"

An app file to serve the handler.

// src/hello/app/app.go

package app

import (
"net/http"

"hello/templates"
"hello/types"

"github.com/gorilla/mux"
)

func Run(env string) {
var c types.Config

// Generate the config file path.
config := "local.toml"
if env != "" {
config = env + ".toml"
}

// Load config.
if _, err := toml.DecodeFile(config, &c); err != nil {
log.Fatal(err)
}

// Route all traffic on the path /hello to our request handler.
r := mux.NewRouter()
r.PathPrefix("/apps/hello").HandlerFunc(HandleRequest)

// Listen for traffic.
err := http.ListenAndServe(c.Port, r)
log.Fatal(err)
}

Create an executable cmd file.

// src/hello/cmd/main.go
package main

import (
"hello/app"
"os"
)

func main() {
app.Run(os.Getenv("ENV"))
}

Give it a run. #

You should now be able to run this as is without docker.

go run hello/cmd

Now navigate to http://localhost/apps/hello and you should see:

“Hello, the time is [current time]”

Run it in Docker #

Containerize it. #

For local development I've been using air [4] to run and hot-reload my code.

This requires a config file to outline how to compile and run our application.

# src/hello/.air.toml

root = "."
tmp_dir = "tmp"

[build]
# Just plain old shell command. You could use `make` as well.
cmd = "templ generate && go build -o ./tmp/app/cmd ./cmd"
# Binary file yields from `cmd`.
bin = "tmp/app"

# Customize binary.
# Run the app.
full_bin = "./tmp/app/cmd"
# This log file places in your tmp_dir.
log = "air_errors.log"
# Watch these filename extensions.
include_ext = ["go", "yaml", "templ", "toml"]
# Ignore these filename extensions or directories.
exclude_dir = ["tmp"]
# Ensure you exlude generated templ files otherwise there will be an infinite loop of hot reloads!
exclude_regex = ["_templ.go"]
# It's not necessary to trigger build each time file changes if it's too frequent.
delay = 1000 # ms

[log]
# Show log time
time = true

[misc]
# Delete tmp directory on exit
clean_on_exit = true

I also separated out the environmental config for docker into a separate file, even though in this case it's the same.

# src/hello/docker.toml

Port = ":80"

A Dockerfile to run the app, this is generic, so I placed it in the docker/ dir.

# docker/Dockerfile
# Builder from the golang standard dockerfile.
FROM golang:latest

# Install air's depenencies.
RUN apt update && apt upgrade -y && \
apt install -y git \
make openssh-client


# Install the templ CLI tool.
RUN go install github.com/a-h/templ/cmd/templ@latest

# Set current DIR.
WORKDIR /app

# Install air.
RUN curl -fLo install.sh https://raw.githubusercontent.com/cosmtrek/air/master/install.sh \
&& chmod +x install.sh && sh install.sh && cp ./bin/air /bin/air


# Run air.
CMD air

This Dockerfile does nothing on its own, it requires that you mount your source code in the volume of /app. For this I used docker-compose. First, I define the variables file.

# docker/variables.env
ENV=docker
# docker/docker-compose.yaml

version: "3"
services:
hello:
build: .
env_file:
- 'variables.env'
volumes:
- ../src/hello/:/app
ports:
- "80:80"

docker-compose up. #

Now you can run this via docker-compose up. And access the container again via http://localhost/apps/hello.

Routing between frontends. #

Now that we have one frontend we need to be able to serve multiple at a time from endpoint. This will allow us to tie them together as if they were one web app.

To do this we will require another frontend app, so once again follow the above steps but create a frontend called goodbye. Replace any references to hello with goodbye.

Your project should now look like the following:

docker
├── docker-compose.yaml
├── Dockerfile
└── variables.env
src
├── hello
└── goodbye

To route between our two apps locally we're going to use a proxy container. I built a lightweight, code-free docker image for this purpose named Moxy [5].

Add routing rules. #

Moxy works using a toml config file to load the default routes.

# docker/moxy-config.toml

# The default path that the moxy admin portal will appear on
moxy_path = "/moxy"

# The upstream traffic will default to (we aren't using a root page yet so this can be anything)
default_upstream = "http://localhost:8081"

# A list of upstreams (order matters they will be evaluated top down)
[[upstream]]
path_prefix = "/apps/hello"
host = "http://hello"
[[upstream]]
path_prefix = "/apps/goodbye"
host = "http://goodbye"

Update the docker-compose. #

Now we can add our proxy to the docker-compose.yaml and expose its ports. hello no longer needs this as its traffic is served through Moxy.

# docker/docker-compose.yaml

version: "3"
services:
hello:
build: .
env_file:
- 'variables.env'
volumes:
- ../src/hello/:/app
goodbye:
build: .
env_file:
- 'variables.env'
volumes:
- ../src/goodbye/:/app
proxy:
image: joedavidson1802/moxy:latest
volumes:
- ./moxy-config.toml:/app/defaults.toml
ports:
- "80:80"

Run it again. #

Now if you run docker-compose up again in the docker/ directory, you should be able to access both apps through http://localhost/apps/hello and http://localhost/apps/goodbye.

Next up. #

Although this website is two separate apps being proxy-ed to be on the same host not one unified website, it puts us in a great position to get stuck in with building some micro frontends quickly.

Next we will look at creating a container front end, this is a simple micro frontend that is key to navigating all of our other frontends in a unified way.

Reference Code #

I've created a github repo [6] for reference while following along.