Implementando a feature defer do Remix em Go
Gabriel Vincent
Posted on May 27, 2024
Há dois meses e meio comecei a mexer com essas coisas de Go. Queria aprender o básico de Go fazendo algo que eu já sei fazer com as duas mãos amarradas pra trás, que é criar um site simples, só pra entender como funciona construir do zero um sistema nessa nova linguagem que eu queria conhecer.
O código contido nesse post está disponível em https://github.com/gabrielvincent/cat-facts
Antes de começar, importante avisar: nesse projeto eu uso Templ, uma biblioteca de componentes que achei muito útil. A primeira iteração desse projeto foi feita usando os HTML templates da standard lib do Go, mas é boilerplate demais pra o que eu queria mostrar aqui. Só pra dar um contexto, a cara do Templ é essa:
package main
templ Title(text string) {
<h1>{ text }</h1>
}
templ Header(props HeaderProps) {
<header class="main-header">
@Title(props.title)
</header>
}
Uma outra coisa que surgiu durante a implementação daquela primeira iteração foi o que eu quero mostrar aqui. Eu sou um fanzoca de Remix e uma das coisas que senti falta no meu site de Cat Facts foi a possibilidade de defer
uma parte do conteúdo da página.
Uma breve contextualização pra que você não fique perdidinho ou perdidinha no pagode: Remix é um framework JavaScript para a criação de aplicações web primeiramente SSR (server-side rendered). Ou seja o HTML é gerado no servidor e enviado pro navegador. Isso significa que, pra que você possa enviar o HTML, todos os dados que estiverem contidos nele precisam já terem sido carregados no servidor. É o caso aqui do nosso site de Cat Facts. Toda vez que alguém vai até a página inicial do site, acontece o seguinte:
- Navegador envia uma requisição ao servidor.
- Servidor aceita a requisição.
- Servidor faz uma requisição pra um outro servidor, o da API de fatos sobre gatos
- Servidor espera pela resposta da API.
- Servidor recebe a resposta e, com ela, monta o HTML da página.
- Servidor responde, enviando o HTML que contém excelentes fatos sobre gatos.
Ao longo desse processo todo, o usuário que surfou (eu gosto do termo "surfar na web" e é este que pretendo usar) até a página inicial viu apenas uma página em branco. Como não existe ainda um HTML pra exibir (ele tá lá no servidor, esperando a resposta da API de fatos sobre gatos, lembra?), tudo o que a pessoa pode fazer é aguardar e encarar o vazio, sem saber se o site algum dia vai carregar.
Aqui o Ryan Florence faz uma breve e excelente demonstração da funcionalidade defer
do Remix:
defer
é uma funcionalidade específica do Remix. Não é parte do HTTP (protocolo usado para troca de mensgens na Web) e também não é uma funcionalidade do JavaScript. Então tive que pensar um pouco em como eu poderia replicar essa experiência no meu app Go. E foi isso aqui o que eu fiz:
A estrutura do projeto
Esse é um projeto bem simples. Comecei com apenas uma página, servida a partir da rota /
. A estrutura é essa:
.
├── main.go
├── components
│ └── components.templ
└── public
└── css
└── tailwind.css
Em main.go
, fiz uma implementação básica do site:
package main
import (
components "catfacts/components"
"context"
"encoding/json"
"io"
"net/http"
"time"
)
type CatFact struct {
Fact string `json:"fact"`
Length int `json:"length"`
}
type CatFactsApiResponse struct {
Data []CatFact `json:"data"`
}
func getFacts() ([]CatFact, error) {
// Simula uma chamada lentona à API
time.Sleep(2 * time.Second)
apiUrl := "https://catfact.ninja/facts?limit=10"
resp, err := http.Get(apiUrl)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var facts CatFactsApiResponse
err = json.Unmarshal(body, &facts)
if err != nil {
return nil, err
}
return facts.Data, nil
}
func factsHandler(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
catFacts, err := getFacts()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var facts []string
for _, catFact := range catFacts {
facts = append(facts, catFact.Fact)
}
components.Index(facts).Render(ctx, w)
}
func main() {
http.HandleFunc("/", factsHandler)
http.HandleFunc(
"/tailwind.css",
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
http.ServeFile(w, r, "public/css/tailwind.css")
},
)
http.ListenAndServe(":3000", nil)
}
Aqui eu defini o que é um CatFact
e o que é que eu espero que a API responda, abstraído no type CatFactsApiResponse
. Defini também a função getFacts()
, que é a função que faz a chamada HTTP e busca os fatos sobre gatos da API. Repara nas primeiras linhas dessa função:
// Simula uma chamada lentona à API.
time.Sleep(2 * time.Second)
Isso faz com que a resposta do nosso servidor sempre leve no mínimo 2 segundos pra ser enviada. Isso é só pra simular uma operação demorada.
factsHandler
é a função responsável por chamar getFacts
e, uma vez que os dados tenham sido retornados pela API, renderiza o componente components.Index
, escrevendo o HTML gerado no objeto w
, que é o response writer. Esse é o objeto onde a resposta do servidor é escrita.
Por fim, a função main
define as rotas e inicia o servidor. Aqui temos apenas /
, que é página raiz do site, onde vamos exibir os fatos sobre gatos, e /tailwind.css
, pra que fique bem fácil fazer com que o site fique tão bonito quanto qualquer gato que existe no universo.
O componente Index
, que exibe os fatos sobre gatos, está no arquivo components.templ
:
package components
import "strconv"
templ layout() {
<!DOCTYPE html>
<html>
<head>
<title>Cat Facts</title>
<link href="/tailwind.css" rel="stylesheet"/>
</head>
<body>
<main>
{ children... }
</main>
</body>
</html>
}
templ Fact(fact string, factNum int) {
<li class="flex items-center gap-4">
<span class="font-semibold">{ strconv.Itoa(factNum) }{ "." }</span>
<span>
{ fact }
</span>
</li>
}
templ Facts(facts []string) {
<ul id="facts-list" class="w-full flex flex-col gap-2">
for idx, fact := range facts {
@Fact(fact, idx+1)
}
</ul>
}
templ Index(facts []string) {
@layout() {
<div class="p-8">
<h1 class="mb-4 font-bold text-3xl">Cat Facts</h1>
@Facts(facts)
</div>
}
}
Bastante simples, certo? Rodei o servidor pra ver se tava tudo certo:
Por conta daquele atraso de 2 segundos que eu simulo antes de bater na API de fatos sobre gatos, fica bem claro aqui o quão ruim é a experiência de quem acessa o site. Nenhum conteúdo é carregado até que todo o conteúdo tenha sido carregado. Péssimo. Péssimo. Especialmente porque temos elementos que poderiam ser enviados imediatamente enquanto os dados demorados são carregados. Por exemplo, temos um belíssimo título que diz Cat Facts. Isso poderia já aparecer de cara pra que a pessoa que acessou o site entenda que ele já carregou. Além disso, poderíamos ter uma mensagem ou uma animação que deixasse bem claro que ainda há informações sendo carregadas, mas que chegam já já.
Implementando a funcionalidade de defer
Defer, em inglês, significa "adiar", "postergar". É exatamente isso que queremos: uma forma de enviar dados assim que eles estiverem disponíveis, sem que seja preciso esperar por aqueles que ainda estão sendo carregados. A lógica aqui é: envia o que tem; adia o envio daquilo que ainda não tem.
A forma mais simples de fazer isso seria fazer com que o servidor enviasse o HTML da página duas vezes: na primeira, assim que a requisição chega, ele envia um HTML contendo tudo aquilo que não depende da resposta da API, ou seja, a estrutura da página, títulos, menus, etc. Na segunda, enviada após a resposta da API, o servidor envia tudo o que enviou na primeira e mais os fatos sobre gatos, agora contidos na página.
Pra isso, vamos modificar a função factsHandler
e usar a interface http.Flusher
para enviar as duas diferentes versões da página assim que cada uma estiver pronta.
"Flush" em inglês significa "descarga". Aqui, acho que dá pra entender como "despachar", "enviar". Se você parar pra pensar, é o que fazem as descargas das
sanitas no final das contas. http.Flusher
pega aquilo que está escrito no http.ResponseWriter
e envia para o cliente sem encerrar a conexão.
Depois de alterar a função factsHandler
, ela ficou assim:
func factsHandler(w http.ResponseWriter, r *http.Request) {
flusher, canFlush := w.(http.Flusher)
ctx := context.Background()
if canFlush {
// Se for possível usar o flusher, a gente logo de cara já renderiza o
// componente Index, passando nil como argumento, já que ainda não
// temos nenhum fato sobre gato pra exibir.
components.Index(nil).Render(ctx, w)
flusher.Flush()
}
catFacts, err := getFacts()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var facts []string
for _, catFact := range catFacts {
facts = append(facts, catFact.Fact)
}
// Renderiza o `Index`, agora com os fatos.
components.Index(facts).Render(ctx, w)
}
Nem toda instância de http.ResponseWriter
implementa a interface http.Flusher
, então é necessário checar se a implementação está disponível antes de tentar invocar flusher.Flush()
. Se não houver uma implementação de http.Flusher
, o código prossegue normalmente, e o cliente só vai receber a resposta uma vez que os dados da API estejam carregados. Aqui, que estou usando o pacote net/http
, eu sei que http.ResponseWriter
implementa http.Flusher
. Mas se você estiver usando alguma biblioteca de terceiros, saiba que é possível que o http.ResponseWriter
disponibilizado por ela pode não implementar.
Ok, já sabemos como fazer pra enviar HTML em partes. Mas agora precisamos alterar o componente Index
pra conseguir lidar com a renderização inicial, quando ainda não temos os fatos sobre gatos. Pra isso, basta adicionar um if
no componente que renderiza a lista de fatos sobre gatos:
templ Facts(facts []string) {
<ul id="facts-list" class="w-full flex flex-col gap-2">
if facts != nil {
for idx, fact := range facts {
@Fact(fact, idx+1)
}
}
</ul>
}
Maravilha! Agora, quando acessamos http://localhost:3000/
, é possível ver que o título da página é exibido imediatamente, mas a página continua carregando e, após dois segundos, a lista com os fatos sobre gatos é exibida.
Porém, temos aqui ainda dois problemas:
- Quando o servidor responde pela segunda vez enviando a lista de fatos, todo o conteúdo do componente
Index
é anexado ao HTML que já havia sido renderizado, resultando em uma duplição do conteúdo da página. - Essa não é a maneira mais eficiente de enviar os dados. Não faz muito sentindo enviar novamente elementos que já foram enviados no primeiro
flusher.Flush()
. O ideal é que a cadaflusher.Flush()
, a gente envie somente conteúdo inédito. Dessa forma, garantimos que o mínimo possível de dados seja transmitido através da rede.
Preparando o frontend para receber flushes subsequentes
Precisamos mudar a estratégia para que o front receba primeiro o conteúdo estático inicial e, depois, somente o conteúdo do componente Facts
. Vamos a isto. A primeira coisa que vou fazer é criar um componente chamado LazyComponent
. Esse
componente, quando instanciado com um fallback
não-nulo, vai renderizar uma <div>
com o atributo data-lazy-id
e contendo o componente @fallback
recebido como parâmetro. Isso é útil pra exibir alguma mensagem ou animação de carregamento enquanto o conteúdo real desse componente não é exibido.
Porém, se for instanciado sem fallback
, o componente simplesmente exibe o conteúdo de children
.
templ LazyComponent(lazyID string, fallback templ.Component) {
if fallback == nil {
<div>
{ children... }
</div>
} else {
<div
data-lazy-id={ lazyID }
>
@fallback
</div>
}
}
Agora, em Facts
precisamos verificar se a lista de fatos é nil
. Se for, renderiza o LazyComponent
exibindo a mensagem de carregamento (passando ela como fallback
). Caso contrário, exibe a lista de fatos.
templ FactsLoading() {
<span>Carregando...</span>
}
templ Facts(facts []string) {
if facts == nil {
@LazyComponent("facts", FactsLoading())
} else {
@LazyComponent("facts", nil) {
<ul id="facts-list" class="w-full flex flex-col gap-2">
if facts != nil {
for idx, fact := range facts {
@Fact(fact, idx+1)
}
}
</ul>
}
}
}
Ao acessar http://localhost:3000/
, dá pra ver que o conteúdo da página não é mais duplicado! Além disso, agora conseguimos ver uma mensagem informando que os fatos estão sendo carregados. Como Index
e Facts
são renderizados separadamente, o servidor não envia mais conteúdo duplicado.
Porém, esse ainda não é o comportamento ideal. Ainda precisamos fazer com que:
- A mensagem de carregamento seja escondida quando a lista de fatos for exibida.
- A lista de fatos seja renderizada dentro de
<div data-lazy-id="facts">
. Atualmente ela é simplesmente anexada ao<body>
:
Pra resolver isso, vamos precisar usar um pouco de JavaScript. Vou começar criando um script que vai ser carregado na <head>
da página. Para isso, vou criar um arquivo em public/js/lazy-component.js
. Antes de implementar o script, preciso rotear esse arquivo lá em main.go
, de modo que o browser vai conseguir baixá-lo:
func main() {
http.HandleFunc("/", factsHandler)
http.HandleFunc(
"/tailwind.css",
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
http.ServeFile(w, r, "public/css/tailwind.css")
},
)
// Isto é necessário para que o browser possa baixar o script.
http.HandleFunc(
"/lazy-component.js",
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
http.ServeFile(w, r, "public/js/lazy-component.js")
},
)
http.ListenAndServe(":3000", nil)
}
Outra coisa que é preciso fazer antes de implementar lazy-component.js
é carregar o script dentro de <head>
. Para isso, basta alterar o componente layout
:
templ layout() {
<!doctype html>
<html>
<head>
<title>Cat Facts</title>
<link href="/tailwind.css" rel="stylesheet" />
<!-- Carrega o script -->
<script src="/lazy-component.js"></script>
</head>
<body>
<main>{ children... }</main>
</body>
</html>
}
Tudo certo, agora já podemos implementar o script:
window.loadLazyComponent = function loadLazyComponent(lazyId, elementId) {
const suspenseEl = document.querySelector(`div[data-lazy-id="${lazyId}"]`);
const lazyEl = document.querySelector(`#${elementId}`);
if (suspenseEl == null || lazyEl == null) {
return;
}
suspenseEl.innerHTML = lazyEl.innerHTML;
lazyEl.remove();
};
Este script é bastante simples. Ele cria uma propriedade global em window
cujo valor é uma função chamada loadLazyComponent
. Um passo-a-passo do que essa função faz:
- Recebe
lazyId
eelementId
como argumentos. - Busca no DOM um elemento cujo atributo
data-lazy-id
é igual ao valor delazyId
recebido. Cria uma referência a esse elemento na variávelsuspenseEl
. - Busca no DOM um elemento cujo
id
seja igual aoelementId
recebido. Cria uma referência a esse elemento na variávellazyEl
. - Verifica se qualquer um dos elementos buscados não foi encontrado, ou seja, se é
null
. Se for, encerra a execução da função. - Substitui todo o conteúdo do elemento
suspenseEl
pelo conteúdo do elementolazyEl
. - Remove
lazyEl
do DOM.
Ok, a gente já sabe o que é data-lazy-id
. Mas e elementId
? Isso a gente ainda não implementou. Vamos voltar lá em LazyComponent
e alterá-lo pra que ele consiga começar a usar a função loadLazyComponent
.
// Utilidade do Templ que permite que a gente escreva JavaScript e consiga
// incluir esse script como se fosse um componente dentro de outro componente
// Templ.
script loadLazyComponent(lazyID string, elementID string) {
// Aqui a gente simplesmente faz uma chamada à função que foi criada lá em
// `/public/js/lazy-component.js`.
window.loadLazyComponent(lazyID, elementID)
}
templ LazyComponent(lazyID string, fallback templ.Component) {
if fallback == nil {
// Um id gerado aleatoriamente.
{{ elementID, err := generateLazyElementID() }}
if err != nil {
<div>Error</div>
}
// Agora, a div que envelopa children pode ser identificada pelo
// elementID.
<div id={ elementID }>
{ children... }
</div>
@loadLazyComponent(lazyID, elementID)
} else {
<div
data-lazy-id={ lazyID }
>
@fallback
</div>
}
}
Só pra recapitular rapidamente pra ficar clara a ordem em que as coisas
acontecem:
- Browser acessa "/".
- Servidor responde imediatamente com o conteúdo estático. Nesse conteúdo, o script
lazy-component.js
é executado e cria a funçãoloadLazyComponent
. Além disso, nessa primeira resposta, o componenteFacts
recebe apenasnil
como parâmetro, o que faz com queLazyComponent
seja renderizado com umfallback
, o que, por sua vez, faz com que apenas a mensagem de carregamento seja exibida. - Servidor faz uma chamada à API de fatos sobre gatos.
- Servidor dá a sua segunda resposta, com o conteúdo dos fatos sobre gatos. Agora, o componente
Facts
recebe uma lista de fatos e, por isso, renderizaLazyComponent
semfallback
. Semfallback
,LazyComponent
exibe o conteúdo recebido emchildren
e chama a função JavaScriptwindow.loadLazyComponent
, passandolazyID
eelementID
. -
window.loadLazyComponent
substitui a mensagem de carregamento pela lista de fatos.
Dessa forma, com pouco código e, principalmente, com apenas uma pitada de JavaScript baunilha a gente consegue melhorar muito a experiência do usuário. Tradicionalmente em sites estáticos ou renderizados no servidor, isso seria feito transferindo a lógica da chamada à API de fatos sobre gatos pro cliente. O browser carregaria o conteúdo estático e ficaria a cargo de algum JavaScript exibir uma mensagem de carregamento, chamar a API e, então, exibir a resposta. E qual é o problema em fazer dessa forma? O problema é que essa estratégia impede que coisas que não dependem umas das outras sejam paralelizadas. Por que deveríamos esperar o navegador baixar e interpretar o HTML da página, parsear e executar o JavaScript pra, só então, fazer a chamada à API? Enquanto o browser tá ocupado com as coisas de browser, o servidor pode se ocupar das coisas de servidor.
Apenas uma observação sobre esta implementação: ela tem um pequeno problema, mas decidi não endereçá-lo neste post porque acho que foge um pouco do escopo do que eu queria demonstrar. O problema é que, se a segunda resposta do servidor for enviada antes que o navegador termine de fazer o parse e a execução do JavaScript em lazy-component.js
, a mensagem de carregamento não vai ser removida, já que a função que o faz ainda não vai existir em window
. Deixo Aí como exercício pra você pensar. Como você resolveria esse problema?
Posted on May 27, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.