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.

Cesar Gimenes

Última modificação