Implementando a feature defer do Remix em Go

gabrielvincent

Gabriel Vincent

Posted on May 27, 2024

Implementando a feature defer do Remix em Go

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>
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Navegador envia uma requisição ao servidor.
  2. Servidor aceita a requisição.
  3. Servidor faz uma requisição pra um outro servidor, o da API de fatos sobre gatos
  4. Servidor espera pela resposta da API.
  5. Servidor recebe a resposta e, com ela, monta o HTML da página.
  6. 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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
    }
}
Enter fullscreen mode Exit fullscreen mode

Bastante simples, certo? Rodei o servidor pra ver se tava tudo certo:

Image description

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)

}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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.

Lista de fatos é carregada, mas ainda temos um problema...

Porém, temos aqui ainda dois problemas:

  1. 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.
  2. 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 cada flusher.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>
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Image description

Porém, esse ainda não é o comportamento ideal. Ainda precisamos fazer com que:

  1. A mensagem de carregamento seja escondida quando a lista de fatos for exibida.
  2. A lista de fatos seja renderizada dentro de <div data-lazy-id="facts">. Atualmente ela é simplesmente anexada ao <body>:

Image description

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)
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

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:

  1. Recebe lazyId e elementId como argumentos.
  2. Busca no DOM um elemento cujo atributo data-lazy-id é igual ao valor de lazyId recebido. Cria uma referência a esse elemento na variável suspenseEl.
  3. Busca no DOM um elemento cujo id seja igual ao elementId recebido. Cria uma referência a esse elemento na variável lazyEl.
  4. Verifica se qualquer um dos elementos buscados não foi encontrado, ou seja, se é null. Se for, encerra a execução da função.
  5. Substitui todo o conteúdo do elemento suspenseEl pelo conteúdo do elemento lazyEl.
  6. 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>
    }
}
Enter fullscreen mode Exit fullscreen mode

Só pra recapitular rapidamente pra ficar clara a ordem em que as coisas
acontecem:

  1. Browser acessa "/".
  2. Servidor responde imediatamente com o conteúdo estático. Nesse conteúdo, o script lazy-component.js é executado e cria a função loadLazyComponent. Além disso, nessa primeira resposta, o componente Facts recebe apenas nil como parâmetro, o que faz com que LazyComponent seja renderizado com um fallback, o que, por sua vez, faz com que apenas a mensagem de carregamento seja exibida.
  3. Servidor faz uma chamada à API de fatos sobre gatos.
  4. 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, renderiza LazyComponent sem fallback. Sem fallback, LazyComponent exibe o conteúdo recebido em children e chama a função JavaScript window.loadLazyComponent, passando lazyID e elementID.
  5. window.loadLazyComponent substitui a mensagem de carregamento pela lista de fatos.

E este é o resultado final:
Image description

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?

💖 💪 🙅 🚩
gabrielvincent
Gabriel Vincent

Posted on May 27, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related