As a weekend project I created a github template that can be very handy for creating go services with relational databases.
Let's take a look at what is included.
Task Runner
For many years, GNU make
has been my to-go tool to run rules
and tasks
for any sort of project. It is
fairly simple to use but it can also become complex as some rules might require to execute external tools or
even declare bash functions with in a rule definition. I must admit that the developer experience can
be rough for those who haven't used make
before.
Developer experience is a very important topic to me and I decided to use this project to find a reliable
alternative to make
. And that's how I found Task:
Task is a task runner / build tool that aims to be simpler and easier to use than, for example, GNU Make.
After checking some examples and its API docs, I got convinced I should give it a try.
Although I'm not a fan of yaml
I found some neat features that I prefer over make
:
Import env variables
Makefile
include .env
$(eval export $(shell sed -ne 's/ *#.*$$//; /./ s/=.*$$// p' .env))
Taskfile
dotenv: ['.env']
Showing help
Although make
doesn't create a help
command, there is a very common pattern to define one:
help: ## print this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | sort
Task
provides a list of available commands when running task -l
, but you need to add a desc
field to each command:
run:
desc: Run go app
cmds:
- go run cmd/main.go
CLI args
When working with make
you can pass arguments/variables a target, for instance
hello:
@echo $(name)
To pass the argument you would call it like make hello name=dev
. This can become tedious when passing multiple args to the inner command.
A simple hack to allow cli args is to filter out those args
from the make goals
and also avoid errors when
targets are not found:
# Filter out make goals from CLI args
args = $(filter-out $@,$(MAKECMDGOALS))
# Do nothing if target not found
%:
@:
hello:
@echo $(args)
It works fine unless the arguments have a value that matches the name of any target.
On the other hand, task
provides a simple template variable with the arguments CLI_ARGS
hello:
cmds:
- echo {{.CLI_ARGS}}
The command can then be called as task hello -- world
Verbose file targets definition
Although make
file targets are not difficult to understand, I think task
syntax is easier to understand
for a new dev. Let's compare them:
pkg/db/%.go: db/queries/%.sql
@docker run --rm -v ${CURDIR}:/src -w /src kjconroy/sqlc generate
Taskfile
db-gen:
desc: Generate queries code using Sqlc
cmds:
- docker run --rm -v $pwd:/src -w /src kjconroy/sqlc generate
sources:
- db/queries/*.sql
generates:
- pkg/db/*
So far task
seems to be a very good alternative to make
, at least for my personal use case.
Folder structure
The folder structure of this template is based on folder structures I've seen across many go repos,
which I really like:
- `cmd/`: app entry points
- `db/`:
- `migrations/`: SQL migrations files
- `queries/`: SQL query files used by `sqlc`
- `pkg/`: app sources
- `db/`: code generated by `sqlc`
Database
As mentioned before, this template is for a go service with a relational database.
As a personal preference I chose postgres
as db engine.
Schema
I have recently adopted the practice of defining my db schemas using dbml
and generating the sql code using their CLI tool. Example:
This schema
Table users {
id uuid [pk]
user_name text [not null, unique]
password_hash bytea [not null]
created_at timestamptz [not null, default: `now() at time zone 'utc'`]
updated_at timestamptz [not null, default: `now() at time zone 'utc'`]
}
Will generate this SQL code:
CREATE TABLE "users" (
"id" uuid PRIMARY KEY,
"user_name" text UNIQUE NOT NULL,
"password_hash" bytea NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
"updated_at" timestamptz NOT NULL DEFAULT (now() at time zone 'utc')
);
Queries and migrations
After having worked with different ORMs (goent, gorm) and plain go sql
code, I find that having a
middle ground is always the most versatile option. This middle ground is about having control over raw SQL
queries and the ability to generate code to run all those queries and represent models in code.
For this matter I chose sqlc which offers a good amount of features like
support for different dbs and db drivers. Regarding db migrations, I prefer to run them isolated from the code. There are countless tools to manage
migrations but recently I've been sticking with go-migrate
which also provides a go library in case I want to integrate the migrations into the code.
To run both tools, I've created tasks
that will use their docker images.
Alternatively, there could be a task to install them into the system.
Docker
The template provides a multi-stage Dockerfile
. It uses the official golang:1.18
image for building and
a scrath
image to copy the binaries.
A docker-compose
file can be used to get a db instance and run migrations on it. In this cases the migrations
service waits for the postgres service to be ready, so we only need to run docker-compose up -d
.
CI
A github actions workflow is provided to run go fmt, vet, test
and gosec.
An initial configuration for dependabot
is also provided.
That's it, go take a look at the repo here.
Thanks for reading 👽
Other posts: