Criando um blog serverless eficiente e econômico com GO, HTMX e TailwindCSSImagem de um homem pobre codando em um computador antigo

🔗 Criando um Blog serverless com Golang, HTMX e TailwindCSS

Essa é uma stack que recentemente vem ganhando bastante popularidade, principalmente entre os devs que como eu, tem uma carreira mais focada em backend.

Já faz algum tempo que quero construir algo demonstrável usando Go, então resolvi criar esse blog usando essas tecnologias e compartilhar minha experiência na construção desse site, que também já servirá de base para no futuro compartilhar minhas outras empreitadas no mundo do desenvolvimento.

🔗 Requisitos

Quando comecei a implementar o blog, havia alguns requisitos (funcionais e não funcionais) que a implementação final deveria atender:

Para atender esses requisitos esses são os components mais importantes usados na implementação:

  1. Go
    • Templ - Linguagem de para construção de templates
    • Gin - Web framework
    • Bleve - Indexação e pesquisa full-text
    • AWS Lambda Go API Proxy - Adapter para eventos do AWS API Gateway para requisições válidas do web framework
    • Goldmark - Parser de markdown para HTML
  2. HTMX - Biblioteca de JavaScript que facilita HATEOAS
  3. TailwindCSS - Framework CSS
  4. AWS
    • API Gateway - HTTP Proxy gerenciado
    • Lambda - Runtime para várias das linguagens mais populares que abstrai o servidor
    • CDK - Kit de desenvolvimento para a nuvem da Amazon que permite declarar e provisionar recursos usando C#, Go, Java, JavaScript, Python e TypeScript

🔗 Overview da arquitetura

Overview da arquitetura do blog

A arquitetura do blog em sí é bastante simples, parte do trabalho fica no API Gateway que recebe as requisições HTTP e invoca a Lambda com um evento do tipo Proxy Integration.

Já a Lambda que recebe o evento do API gateway usa um adapter que transforma o evento em algo que o web framework usado nesse projeto (Gin) consegue entender, as rotas do serviço ficam descritas dentro do programa Go que executa na função Lambda.

🔗 Ineficiências

O API Gateway repassa todas as requisições do domínio para a função lambda de forma indiscriminada, mesmo de rotas que não existem. Isso na prática quer dizer que a função vai executar mesmo que seja só para responder com 404 - Not Found, ainda não encontrei uma boa maneira de replicar minha definição de rotas no web framework para rotas disponíveis no proxy para evitar execuções desnecessárias.

🔗 Critérios da escolha da stack

🔗 Orçamento

Funções lambda são baratas em pequena escala, inclusive, se sua aplicação gerar um tráfego menor que um 1 milhão de requisições por mês é de graça 🤑

Lambda free tier

Além disso Lambdas tem duas classes de armazenamento por padrão:

Persistente: Diretório /opt que é somente-leitura onde o conteúdos estáticos podem existir. Essa classe de armazenamento pode crescer até 75 GB e persiste entre execuções.

Temporário: Diretório /tmp que permite escritas. Essa classe de armazenamento começa em 512 MB e pode ser configurada para até 10 GB. Como o nome sugere, esse armazenamento existe somente durante a execução da lamba, tudo que existe nele é descartado quando a lambda termina.

Para esse site usarei a menor lambda disponibilizada pela AWS

RAM Ephemeral storage
128 MB 512 MB

🔗 Frontend rico em interações, com bom SEO e sem penalizar o navegador dos leitores:

Para esse fim escolhi a biblioteca HTMX, é bem compacta (≈ 14kb).

HTMX estende as funcionalidades básicas do HTML e dá acesso a funcionalidades como AJAX, SSE e WebSockets para qualquer elemento nos documentos de hipertexto.

1<script src="https://unpkg.com/htmx.org@1.9.10"></script>
2
3<!-- Um botão que não está associado a nenhum form pode 
4realizar uma requisição HTTP!!! -->
5<button hx-post="/clicked" hx-swap="outerHTML">
6Enviar
7</button>

A renderização das páginas acontece no backend, então páginas estiverem bem configuradas os os buscadores terão facilidade em rastrear e indexar o blog.

O documento final renderizado no navegador é praticamente um HTML estático, sem dependência de estado armazenado no navegador e executando muito pouco JavaScript o que garante uma boa performance.

Relatório da ferramenta Lighthouse do Google Chrome1

Além disso HTMX permite realizar update parcial das páginas com fragmentos de HTML, isso reduz dramaticamente o tamanho das respostas do servidor e elimina a necessidade de recarregar a página inteira para navegar no site.

🔗 Backend

HTMX não tem opiniões sobre qual linguagem usar no backend, então é possível usar qualquer uma das linguagens suportadas na AWS Lamba. A escolha de golang é mais pessoal do que qualquer coisa, mas mesmo assim, há algumas vantagens em usar Go com HTMX:

  1. Baterias incluídas: A biblioteca padrão do go já vem com a maior parte dos pacotes necessários para criar um web server;
  2. Templates: O pacote padrão html/template facilita muito a renderização de templates HTML, mas há uma opção ainda melhor que é a biblioteca templ que permite criar templates com checagem estática de tipos;
  3. Go compila rápido: Velocidade para realizar mudanças no código;

E o mais importante para os leitores desse blog, Go tem um dos melhores cold starts e uso de memória em AWS Lambdas, perdendo somente para linguagens sem garbage collector como Rust e C++:

benchmark dinâmico do cold start AWS Lambda @ https://maxday.github.io/lambda-perf/2

Isso garante uma ótima performance no carregamento das páginas do blog e baixo investimento de minha parte na infraestrutura do site, win win!

🔗 Estilização

TailwindCSS provê ótimos primitivos para a estilização de web app, especialmente facilitadores para a construção de um design responsivo onde existem mudanças dramáticas na interface dependendo do tamanho da tela do dispositivo.

O código a seguir é um documento HTML da página principal que vai listar todos os posts (com elementos sintaxe da linguagem de templates templ):

 1<!--Elemento da home page-->
 2<!--todas as classes css são providas pelo tailwind-->
 3<div class="flex flex-col">
 4    <!--breakpoint md: @media (min-width: 768px) { ... }-->
 5    <!--altera o layout para reorganizar os elementos a depender do tamanho da tela-->
 6    <div class="flex flex-col md:flex-row-reverse md:items-center">
 7        <a href={ templ.URL(post.Dir) } hx-get={ post.Dir + "?fragment=1" } class="mb-2 max-h-[160px] md:max-h-[210px] md:mb-0 md:ml-[10px]" hx-target="#main">
 8            <img class="max-h-[160px] md:max-h-[210px] w-full object-cover md:object-contain" src={ post.Dir + post.Metadata.Cover } alt={ post.Metadata.CoverAlt }/>
 9        </a>
10        <div>
11            //post date
12            <div class="text-sm font-thin">{ post.Metadata.Author } | { LocalizeTime(post.Metadata.CreatedAt, is.Language) }</div>
13            //post title
14            <div class="mb-[3px">
15                <h2 class="font-black cursor-pointer">
16                    <a href={ templ.URL(post.Dir) } hx-get={ post.Dir + "?fragment=1" } hx-target="#main">{ post.Metadata.Title }</a>
17                </h2>
18            </div>
19            //post description
20            <div class="text-base">
21                <p class="cursor-pointer">
22                    { post.Metadata.Description }
23                </p>
24            </div>
25        </div>
26    </div>
27    <div class="text-sm font-thin">
28        for _, tag := range post.Metadata.Tags {
29            //post tags
30            <span class="mr-[5px] rounded-full bg-zinc-950 px-[8px]">{ tag }</span>
31        }
32    </div>
33</div>

No mobile a interface deve ser apresentada assim:

Alt text

Já telas maiores que 768px:

Alt text

🔗 Deploy fácil com CDK

O CDK aqui tem multifunções

  1. Definir a infraestrutura
  2. Empacotar o binário que vai ser executado na Lambda
  3. Empacotar o conteúdo estático do site e os indices de busca full text
  4. Realizar deploy na conta AWS
 1package main
 2
 3import (
 4	"github.com/aws/aws-cdk-go/awscdk/v2"
 5	"github.com/aws/aws-cdk-go/awscdk/v2/awsapigateway"
 6	"github.com/aws/aws-cdk-go/awscdk/v2/awslambda"
 7	"github.com/aws/aws-cdk-go/awscdk/v2/awss3assets"
 8	"github.com/aws/aws-cdk-go/awscdklambdagoalpha/v2"
 9
10	"github.com/aws/constructs-go/constructs/v10"
11	"github.com/aws/jsii-runtime-go"
12)
13
14type CdkStackProps struct {
15	awscdk.StackProps
16}
17
18func GuigoesCdkStack(scope constructs.Construct, id string, props *CdkStackProps) awscdk.Stack {
19	var sprops awscdk.StackProps
20	if props != nil {
21		sprops = props.StackProps
22	}
23
24	stack := awscdk.NewStack(scope, &id, &sprops)
25
26    //Compila e empacota o binário da função lambda
27	lambda := awscdklambdagoalpha.NewGoFunction(stack, sptr("GuigoesLambda"), &awscdklambdagoalpha.GoFunctionProps{
28		Runtime: awslambda.Runtime_GO_1_X(),
29		Entry:   sptr("../../cmd/lambda/main.go"),
30		Environment: &map[string]*string{
31			"POSTS_PATH":     sptr("/opt/posts/"),
32			"DIST_PATH":      sptr("/opt/web/dist"),
33			"BLEVE_IDX_PATH": sptr("/opt/blog.bleve"),
34		},
35	})
36
37    //Empacota tudo exceto o que está explicitamente excluído em uma lambda layer acessível no diretório /opt
38	postsLayer := awslambda.NewLayerVersion(stack, sptr("GuigoesLayer"), &awslambda.LayerVersionProps{
39		Code: awslambda.AssetCode_FromAsset(sptr("../../"), &awss3assets.AssetOptions{
40			Exclude: &[]*string{
41				sptr("cmd"),
42				sptr("deployments"),
43				//[..] O resto da lista foi omitido para melhorar a leitura
44			},
45		}),
46	})
47
48	lambda.AddLayers(postsLayer)
49
50    //Cria o http proxy que repassa todas requisições para a lambda
51	api := awsapigateway.NewLambdaRestApi(stack, sptr("GuigoesApi"), &awsapigateway.LambdaRestApiProps{
52		Handler:          lambda,
53		BinaryMediaTypes: &[]*string{sptr("*/*")},
54	})
55
56    //Imprime ao final da execução propriedades dos recursos definidos na stack
57	awscdk.NewCfnOutput(stack, sptr("api-gateway-endpoint"),
58		&awscdk.CfnOutputProps{
59			ExportName: sptr("API-Gateway-Endpoint"),
60			Value:      api.Url()})
61
62	return stack
63}
64
65//[..] Funções utilitárias omitidas
66
67func main() {
68	defer jsii.Close()
69	app := awscdk.NewApp(nil)
70	GuigoesCdkStack(app, "GuigoesStack", &CdkStackProps{
71		awscdk.StackProps{
72			Env: env(),
73		},
74	})
75	app.Synth(nil)
76}
77
78func env() *awscdk.Environment {
79	// If unspecified, this stack will be "environment-agnostic".
80	// Account/Region-dependent features and context lookups will not work, but a
81	// single synthesized template can be deployed anywhere.
82	//---------------------------------------------------------------------------
83	return nil
84}
85

Para realizar o deploy de uma nova versão é só executar o comando $ cdk deploy, se a máquina tiver credencias AWS válidas e com as permissões adequadas o deploy deve acontecer sem problemas.

No futuro pretendo separar o deploy de conteúdo estático do binário, enquanto o projeto é pequeno funciona Ok (por volta de um minuto), mas a medida que os assets forem acumulando tende a demorar cada vez mais.

Fora da possibilidade de eu realizar deploy de uma versão quebrada da aplicação sem querer, quando na verdade só queria escrever um blog post. Bom, conteúdo para uma próxima!

🔗 Conclusão

Golang + HTMX me parece ser ótimo para criar uma webapp leve sem abrir mão da interatividade que é esperada de sites modernos, como o conteúdo é na sua maior parte HTML puro, o suporte nos mais diversos navegadores vem de graça, além da performance.

Falando em performance, ela é dependente somente de quão rápido seu servidor consegue renderizar HTML somado com a latência da rede, nesse exemplo as condições são ideais para boa performance, o conteúdo é lido diretamente do sistema de arquivos e sofre poucas alterações. Em aplicações integradas com banco de dados e/ou APIs, o tempo final para entrega do documento HTML renderizado pelo servidor deve aumentar significativamente, mas até ai, isso também é verdade para uma API que serve JSON ou XML.

O servidor precisa estar sempre alcançável para renderizar as páginas, então se você precisa servir algum tipo de funcionalidade offline HTMX não vai te ajudar 😢.

Caso você já tenha um API pronta com os recursos em algum formato de transporte popular com JSON, um framework que renderiza do lado do client parece ser uma melhor pedida ao invés de HTMX, vejo duas saídas caso quisesse MESMO usar HTMX nesse cenário:

  1. Criar rotas específicas para lidar somente com as request HTMX na sua aplicação pré existente, acho que seria um pesadelo tentar misturar num mesmo recurso da API a geração de um formato de transporte como JSON como um formato de “apresentação” como HTML, são dois mundos bem distintos que não deveriam se misturar.

  2. Criar um segundo serviço renderer que consome a API e renderiza HTML, nesse caso teria que levar a consideração a latência de rede adicional da comunicação do renderer com API. Há também um distanciamento da fonte de dados original, você só conseguiria gerar páginas tão boas quanto os dados providos pela API.

Nenhuma dessas opções me parece ideal, acho que React, Vue, Angular e frameworks parecidos seriam seriam uma solução mais direto ao ponto.

Para finalizar, o código do blog é aberto, se te interessou vai lá, dá uma fuçada e deixa uma 🌟: https://github.com/guilycst/guigoes


  1. Relatório da ferramenta Lighthouse do Google Chrome ↩︎

  2. Análise diária de cold start de várias runtimes suportadas em AWS Lambda @ https://maxday.github.io/lambda-perf/ ↩︎