Cliente e servidor socket em Golang com goroutines e canais.
Dando continuidade ao exemplo sobre cliente socket persistente em Golang, fiz algumas alterações no código-fonte para ilustrar como melhorar nosso código para torná-lo assíncrono. Para isso, usaremos goroutines e canais.
Goroutines e Canais
Goroutines e canais são recursos avançados da linguagem Go. Goroutines são threads leves gerenciadas pelo próprio runtime da linguagem, e canais permitem a comunicação entre goroutines.
No nosso caso, usaremos canais para a comunicação entre as goroutines do nosso programa.
Esses dois recursos são um pouco avançados, por isso primeiro demonstrei como criar nosso pequeno cliente socket sem esses recursos.
Na verdade, é uma ótima prática resolver o problema da forma mais simples possível e só adicionar goroutines e canais se forem realmente necessários. Eles são fáceis de usar, mas têm um custo: o programa fica mais difícil de depurar.
Analisando o código-fonte
Lendo do standard input
Uma melhoria que fiz foi separar o código em pequenas funções, o que facilita a leitura.
Primeiro, a função responsável por ler as entradas do usuário.
func readStdin() {
for {
reader := bufio.NewReader(os.Stdin)
m, err := reader.ReadString('\n')
if err != nil {
panic(err)
}
input <- m
}
}
Essa função entra em um loop infinito lendo entradas do usuário. Quando o usuário pressiona enter, a função envia a string para o canal input. Se ocorrer algum erro na leitura de stdin, o programa é encerrado com um panic.
Lendo da rede
A função que lê da conexão de rede é muito parecida com a função que lê as entradas do usuário. As únicas diferenças são a origem da leitura, para qual canal a escrita ocorre e que, no caso de erro, enviamos o erro para o canal errorChan em vez de encerrar o programa com um panic.
func readConn(conn net.Conn) {
for {
reader := bufio.NewReader(conn)
m, err := reader.ReadString('\n')
if err != nil {
errorChan <- err
return
}
output <- m
}
}
Gerenciando a conexão
Finalmente, temos uma função para gerenciar a conexão. Ela fica em loop tentando conectar ao servidor e, quando a conexão é estabelecida, retorna a conexão para ser usada nas outras partes do sistema.
É praticamente a mesma rotina que usávamos no programa anterior, mas agora separada em uma função.
func connect() net.Conn {
var (
conn net.Conn
err error
)
for {
fmt.Println("Connecting to server...")
conn, err = net.Dial("tcp", ":8080")
if err == nil {
break
}
fmt.Println(err)
time.Sleep(time.Second * 1)
}
fmt.Println("Connection accepted")
return conn
}
Gerenciando os canais
Na função main, resta a tarefa de iniciar as goroutines e gerenciar os canais. Para isso, usamos a palavra-chave select, que é semelhante ao switch case. No entanto, select espera o retorno de múltiplos canais.
func main() {
go readStdin()
RECONNECT:
for {
conn := connect()
go readConn(conn)
for {
select {
case m := <-output:
fmt.Printf("Received: %q\n", m)
case m := <-input:
fmt.Printf("Sending: %q\n", m)
_, err := conn.Write([]byte(m + "\n"))
if err != nil {
fmt.Println(err)
conn.Close()
continue RECONNECT
}
case err := <-errorChan:
fmt.Println("Error:", err)
conn.Close()
continue RECONNECT
}
}
}
}
Basicamente, ficamos em loop esperando por mensagens de vários canais e tratamos o que chega em cada canal com a rotina apropriada.
Código-fonte
Aqui está o código-fonte do nosso servidor e cliente.
Vídeos com explicação
Conclusão
Agora, separado em funções e usando goroutines e canais, nosso cliente está muito mais responsivo. Ele não realiza apenas uma tarefa por vez; pode receber informações da rede ou do usuário e transmitir, tudo de forma assíncrona.
Na próxima versão, a melhoria mais óbvia será adicionar uma rotina de ping/keep alive para garantir que a conexão está funcionando, mesmo que o programa não esteja recebendo nem transmitindo nada.