Pular para o conteúdo principal

2 postagens marcadas com "go"

Ver todas os Marcadores

Thiago Lages de Alencar
danger

Eu tenho meses de experiência em Go então é bem idiota da minha parte dar minha opnião sobre uma linguage que não utilizo muito.

Sem contar que error handling é um assunto muito sensível dentro da comunidade de Go, pessoas odeiam e pessoas amam.

Então este post vai servir como reflexão para mim.

Return the Error

Parte de mim adora a simplicidade de "se sua função pode falhar, retorne o error".

func doXXX() error {
// ...
return err
}

E já que Go permite retorna multiplos resultados, não existe perda de clareza.

func doXXX() (int, error) {
// ...
return number, err
}

Handling Error

Se a linguagem escolheu pelo design de retorna o error, quer dizer que a função que chamou precisa verificar erro.

func doYYY() error {
value, err := doXXX()

if err != nil {
return err
}

// ...

return nil
}

Por questão de simplicidade, neste exemplo estou apenas repassando o erro.

Dito isto, fica para cada um decidir o que vai fazer quando encontrar um erro. Por exemplo:

  • Escrever no log
  • Adicionar mais informações ao erro
  • Omitir detalhes do erro
  • Repassar o erro

Todos eles são válidos! A situação que você se encontra é o que dita o melhor para se usar.

There's a Catch

Muitas pessoas gostam disto pois força a lidar com o erro, em vez de ficar repassando o erro até alguma função decidir tratar.

https://go.dev/doc/faq#exceptions

Enquanto os argumentos são válidos, eu acho que Go falha em apontar uma alternativa elegante. Pois se cria um ambiente que incentiva a seguinte estrutura:

func doXXX() error {
err := doAAA()

if err != nil {
return err
}

err = doBBB()

if err != nil {
return err
}

err = doCCC()

if err != nil {
return err
}

err = doDDD()

if err != nil {
return err
}

// ...

return nil
}

Programadores normalmente seguem regras de no máximo X caracteres por linha. Go me faz desejar que existisse uma regra de X linhas por função...

Essa estrutura ocorre muito mais do que se imagina!

As pessoas esquecem que as funções mais básicas de programação (como abrir arquivo) podem falhar. O que vai causar uma reação em cadeia em sua funções.

A() 🟢
└── B() 🟠
└── C() 🟠
└── D() 🟠
└── F() 🔴

Agora todas as funções acima de F() teram o código:

if err != nil {
return err
}

A solução mais básica é tratar o mais cedo possível (em D()) mas isso só é possível quando você possue a solução do problema.

Imagina se enquanto o usuário está baixando um jogo na Steam acaba o espaço no HD. A Steam precisa se comunicar com o usuário sobre o problema e ela não pode tomar a decisão de como abrir espaço.

É a situação que você quer pausar a linha de raciocínio principal e borbulhar o erro para cima, até chegar no usuário. Olha que engraçado, parece até que existe uma razão por inventarem "try-catch"...

panic() & recover()

A necessidade de borbulhar o erro para cima é tão grande que Go possui dois métodos para isto panic() e recover().

Agora se estivermos em F() e iniciarmos um estado de panic, podemos solucionar em A().

func F() {
// ...

if problem {
panic(errors.New("Problem in F"))
}

// ...
}

Justamente pelo fato de que qualquer função chamada com defer, irá ser executada independente de como a função terminar (normalmente ou em estado de panic).

func A() {
defer solvePanic()

// ...

B()

// ...
}

E podemos sair do estado de panic e coletar o erro utilizando recover().

func solvePanic() {
if err := recover(); err != nil {
// ...
}
}

Seguindo essa lógica podemos reduzir drasticamente o número de "if-error" entre A() e F().

A() 🟢
└── B() 🟢
└── C() 🟢
└── D() 🟢
└── F() 🔴
info

Isto da ao tratamento de erro uma certa elegância comparado ao try-catch, pois a ramificação fica na mesma função:

func doXXX() {
try {
// Default logic
} catch XXX {
// Execute on error
}
}

Enquanto Go incentiva a deixar em outra função:

func doXXX() {
defer solveXXX()
// Default logic
}

func solveXXX() {
// Execute on error
}

Elegant Logic Flow

Ainda possuimos o problema em que uma função pode crescer verticalmente com diversas ocorrências de "if-error".

func doXXX() error {
err := doAAA()

if err != nil {
return err
}

err = doBBB()

if err != nil {
return err
}

err = doCCC()

if err != nil {
return err
}

err = doDDD()

if err != nil {
return err
}

// ...

return nil
}

Podemos utilizar a mesma lógica da seção acima para criar uma função que causa panic() quando um erro é encontrado:

func assertNoErr(err error) {
if err != nil {
panic(err)
}
}

func doXXX() error {
assertNoErr(doAAA())
assertNoErr(doBBB())
assertNoErr(doCCC())
assertNoErr(doDDD())

// ...

return nil
}

Na minha opnião, isto já melhora bastante a leitura.

Porém está função está causando um panic() e pode não ser o que a gente queira para ela. Como podemos continuar elegantes e retornar um erro normal? Utilizando named return.

func doXXX() (err error) {
// ...
}

Agora err vai ser a variável retornada pela função quando ela encerrar (e não estiver em estado de panic).

Tudo que falta é garantir que vamos sair do estado de panic e que err possua o valor do último error.

func catchErr(err *error) {
if r := recover(); r != nil {
*err = r.(error)
}
}

func assertNoErr(err error) {
if err != nil {
panic(err)
}
}

func doXXX() (err error) {
defer catchErr(&err)

assertNoErr(doAAA())
assertNoErr(doBBB())
assertNoErr(doCCC())
assertNoErr(doDDD())

// ...

return nil
}

References

Thiago Lages de Alencar

The Elm Architecture

(MVU, Model-View-Update)

A arquitetura se resume a 3 partes:

  • Model: Conjuntos de dados sobre o estado da aplicação
  • View: Converte os dados do model para algo visual
  • Update: Reage a atualizações para atualizar os dados do model

Seja qual for a sua aplicação (CLI, TUI, GUI, ...), ela é responsável por receber o que o usuário deve visualizar e por notificar atualizações vindas do usuário:

MVU

Bubbletea

É responsável por rodar em loop a arquitetura Elm. Todo o código para isto se resume a:

package main

import tea "github.com/charmbracelet/bubbletea"

func main() {
program := tea.NewProgram(
MyStruct{} // Here you pass your model.
)

_, err := program.Run()

if err != nil {
print(err.Error())
}
}

Entenda que Model é terminologia para representar uma coleção de dados e é por isto que Bubbletea utiliza structs para representar models.

Models Structure

Models dentro do Bubbletea devem seguir a seguinte estrutura:

type MyStruct struct {
// Fields.
}

func (m MyStruct) Init() tea.Cmd {
// Run on initialization.
}

func (m MyStruct) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Process a message.
}

func (m MyStruct) View() string {
// Write to screen.
}

Podemos ver que é dividido em 4 partes:

  • Uma struct responsável por representar o Model da arquitetura Elm
  • Um método Init() único da estrutura bubbletea
  • Um método Update() responsável por representar o Update da arquitetura Elm
  • Um método View() responsável por representar o View da arquitetura Elm
info

Em muitos tutoriais, pessoas também incluem uma função responsável por criar o model e configura-lo com os padrões desejados:

func newMyStruct() MyStruct {
return MyStruct{
// Set default fields values here
}
}

func main() {
program := tea.NewProgram(newMyStruct()) // Use here
}

Keys

Teclas pressionadas no teclado vão para o mesmo lugar que todas interações (Update()), lá resta a você reconhecer como sendo um pressionamento de tecla.

func (m MyStruct) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
}

return m, nil
}

No exemplo podemos ver que possuimos um switch para nos ajudar a identificar o tipo da mensagem, isso é necessário pois a mensagem pode ter qualquer tipo (não necessariamente uma mensagem de pressionamento de tecla).

Mensagens de teclas dentro do Bubbletea podem ser convertido para strings, o que nos da uma string de fácil entedimento porém nem sempre uma ação está ligada a apenas um atalho, então pode ser útil sabermos como ligar diversos atalhos a uma ação:

var quit = key.NewBinding(
key.WithKeys("ctrl+c", "ctrl+q", "q")
)

// ...

func (m MyStruct) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, quit):
return m, tea.Quit
}
}

return m, nil
}
tip

Em vez de criar uma variável para cada atalho, você pode agrupar todas em uma que simbolize todo o mapeamento dos atalhos:

var keyMap struct {
Up key.Binding
Down key.Binding
Quit key.Binding
}

var keys = keyMap{
Up: key.NewBinding(
key.WithKeys("up", "w"),
),
Down: key.NewBinding(
key.WithKeys("down", "s"),
),
Quit: key.NewBinding(
key.WithKeys("ctrl+c", "ctrl+q", "q"),
),
}

// ...

func (m MyStruct) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Quit):
return m, tea.Quit
case key.Matches(msg, keys.Down):
// Do something
case key.Matches(msg, keys.Up):
// Do something
}

return m, nil
}

Bubbles

É uma coleção de models com parte da lógica já preparada.

Olhando a assinatura das funções e structs do spinner, podemos ver que ele implementa:

  • Model: type Model struct
  • Update: func (m Model) Update(msg tea.Msg) (Model, tea.Cmd)
  • View: func (m Model) View() string

Ele implementa quase todas as partes necessárias para ser executado diretamente no Bubbletea, a única parte que está faltando é Init(). Por que isso?

Acontece que spinner não tem intenção de ser um modelo perfeito para ser utilizado diretamente pelo Bubbletea, pois não faz sentido criar um programa Bubbletea que apenas exibe um spinner.

Porém podemos criar nosso próprio model que utiliza este spinner:

type MyStruct struct {
spinner spinner.Model
}

func newSpinner() MyStruct {
return MyStruct{
spinner: spinner.New(),
}
}

func (m MyStruct) Init() tea.Cmd {
return m.spinner.Tick // spinner requires this to start
}

func (m MyStruct) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd

switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
default:
m.spinner, cmd = m.spinner.Update(msg)
}

return m, cmd
}

func (m MyStruct) View() string {
return m.spinner.View()
}

func main() {
program := tea.NewProgram(
MyStruct{
spinner: spinner.New(),
}
)

_, err := program.Run()

if err != nil {
print(err.Error())
}
}

Note como basicamente reutilizamos tudo do spinner para fazer nosso model:

  • Nossa struct é o spinner sozinho
  • Nossa View() apenas chama a View() do spinner
  • Nosso Update() chama Update() do spinner e detecta se o atalho para sair foi pressionado

Como podemos ver os componentes do Bubbles foram feitos para serem utilizados pelos seus models (e não pelo Bubbletea diretamente). Imagine o susto de alguém executando o spinner diretamente e ele nunca fechando pois ninguém processa ctrl+c para sair.

References