Go Guide

Go, chapter by chapter · Lesson 9 of 62 · about 50 min

Servers, flags & your own packages

Build TCP and HTTP servers with the standard library, read command-line flags, and split your code into documented packages others can import.

On this page · 10 sections
  1. 01TCP: the raw connection
  2. 02HTTP: the web
  3. 03Command-line flags
  4. 04Creating your own packages
  5. 05Documentation
  6. 06More examples
  7. 07The full program
  8. 08Summary
  9. 09Check your understanding
  10. 10Practice in Go

TCP: the raw connection#

TCP gives you a reliable two-way byte stream between two programs. A server does three things:

  1. Listen on an address: ln, err := net.Listen("tcp", ":9000").
  2. Accept connections in a loop: conn, err := ln.Accept() blocks until a client connects.
  3. Handle each connection in its own goroutine, so other clients are not kept waiting.

A net.Conn is both an io.Reader and an io.Writer, so everything from the last lesson works on it. An echo server is literally io.Copy(conn, conn). Clients connect with net.Dial("tcp", "host:9000").

HTTP: the web#

HTTP is built on top of TCP. In Go you register handlers, functions that receive a request and write a response:

Go
mux := http.NewServeMux()
mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "halo, %s\n", r.PathValue("name"))
})
http.ListenAndServe(":8080", mux)
  • w is an io.Writer: anything you write becomes the response body.
  • r holds the method, URL, headers and body (an io.Reader).
  • Since Go 1.22, patterns can include a method (GET, POST) and wildcards like {name}, read with r.PathValue.
  • http.Error(w, msg, code) sends an error status.
  • encoding/json turns structs into JSON and back: json.NewDecoder(r.Body).Decode(&v) and json.NewEncoder(w).Encode(v).
  • Each request runs in its own goroutine automatically.

For tests, net/http/httptest starts a real server on a random port, or records a response without any network.

Command-line flags#

Go
port := flag.Int("port", 8080, "HTTP port")
verbose := flag.Bool("v", false, "verbose logging")
flag.Parse()
fmt.Println(*port, *verbose, flag.Args()) // Args: what's left after the flags

The functions return pointers because values are filled in only when flag.Parse() reads os.Args. Running the program with -h prints usage automatically.

Creating your own packages#

A package is a folder of .go files sharing one package name. Inside a module:

godsa/                      module github.com/you/godsa
├── go.mod
├── stats/
│   ├── stats.go            package stats
│   └── stats_test.go
└── cmd/server/
    └── main.go             package main, imports ".../godsa/stats"
  • Import by module path plus folder: import "github.com/you/godsa/stats".
  • Only capitalized names are visible outside the package. Lowercase helpers stay private.
  • A folder named internal/ can only be imported by code in its parent tree: private by design.
  • Keep main small: parse flags, wire things together, start the server. Logic goes in packages you can test.

Documentation#

Go documentation is just comments, written right above the declaration and starting with its name:

Go
// Package stats provides small numeric helpers.
package stats
 
// Median returns the middle value of xs, or 0 for an empty slice.
// xs is not modified.
func Median(xs []float64) float64 { ... }

Read it with go doc github.com/you/godsa/stats (or go doc ./stats). Once the module is public on GitHub, pkg.go.dev renders it automatically. Example functions in test files appear in those docs as runnable examples.

More examples#

RPC: calling a function in another process

The book's last server example uses net/rpc: a client calls a method on a server as if it were local. The method must have the shape func (t *T) Method(args A, reply *R) error. The program below connects the client and the server through net.Pipe, an in-memory connection, so it runs anywhere; over a network you would use rpc.Dial("tcp", addr).

rpc.go
Go
package main
 
import (
	"fmt"
	"net"
	"net/rpc"
)
 
type Server struct{}
 
// Negate is callable remotely: exported, two arguments, the second a pointer, returning error.
func (s *Server) Negate(i int64, reply *int64) error {
	*reply = -i
	return nil
}
 
type Pair struct{ A, B int }
 
func (s *Server) Div(p Pair, reply *int) error {
	if p.B == 0 {
		return fmt.Errorf("division by zero")
	}
	*reply = p.A / p.B
	return nil
}
 
func main() {
	srv := rpc.NewServer()
	if err := srv.Register(new(Server)); err != nil {
		panic(err)
	}
	serverConn, clientConn := net.Pipe()
	go srv.ServeConn(serverConn)
 
	client := rpc.NewClient(clientConn)
	defer client.Close()
 
	var neg int64
	err := client.Call("Server.Negate", int64(999), &neg)
	fmt.Println(neg, err)
 
	var q int
	err = client.Call("Server.Div", Pair{10, 0}, &q)
	fmt.Println("remote error:", err)
}
Output
-999 <nil>
remote error: division by zero

net/rpc is frozen: it still works, but new systems use gRPC (with Protocol Buffers) or plain HTTP with JSON, which every language can call. The idea is the same: serialize the arguments, send them, run the function remotely, send back the result or the error.

A production-shaped HTTP server

The servers in this lesson keep things minimal. Real services add three things: timeouts so slow clients cannot hold connections forever, middleware for logging and recovery, and graceful shutdown so in-flight requests finish when you deploy a new version. This program is marked to be built, not run, because it serves until you press Ctrl+C:

main.go
Go
package main
 
import (
	"context"
	"errors"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)
 
// logging is middleware: it wraps a handler and runs code around every request.
func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		slog.Info("request", "method", r.Method, "path", r.URL.Path, "took", time.Since(start))
	})
}
 
func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("ok"))
	})
 
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           logging(mux),
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
	}
 
	go func() {
		slog.Info("listening", "addr", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			slog.Error("server failed", "err", err)
			os.Exit(1)
		}
	}()
 
	// Wait for Ctrl+C or a termination signal from the platform.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	<-ctx.Done()
 
	// Give in-flight requests up to 10 seconds to finish.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		slog.Error("shutdown", "err", err)
	}
	slog.Info("stopped cleanly")
}

Run it with go run ., open http://localhost:8080/healthz, and watch the structured log/slog lines. You will reuse this skeleton for every project in the system design module.

The full program#

This program runs everything in one process so you can see the full round trip: it starts a TCP echo server and an HTTP API on random free ports, calls both as a client, and prints the replies.

servers.go
Go
package main
 
import (
	"bufio"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"slices"
	"strings"
)
 
// Median returns the middle value of xs, or 0 for an empty slice.
// In a real project this lives in package stats.
func Median(xs []float64) float64 {
	if len(xs) == 0 {
		return 0
	}
	s := slices.Sorted(slices.Values(xs))
	m := len(s) / 2
	if len(s)%2 == 0 {
		return (s[m-1] + s[m]) / 2
	}
	return s[m]
}
 
func newMux() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "halo, %s\n", r.PathValue("name"))
	})
	mux.HandleFunc("POST /median", func(w http.ResponseWriter, r *http.Request) {
		var xs []float64
		if err := json.NewDecoder(r.Body).Decode(&xs); err != nil {
			http.Error(w, "send a JSON array of numbers", http.StatusBadRequest)
			return
		}
		json.NewEncoder(w).Encode(map[string]float64{"median": Median(xs)})
	})
	return mux
}
 
func serveEcho(ln net.Listener) {
	for {
		conn, err := ln.Accept()
		if err != nil {
			return // listener closed
		}
		go func() {
			defer conn.Close()
			io.Copy(conn, conn)
		}()
	}
}
 
func main() {
	verbose := flag.Bool("v", false, "print extra detail")
	flag.Parse()
 
	// TCP
	ln, err := net.Listen("tcp", "127.0.0.1:0") // port 0: pick any free port
	if err != nil {
		fmt.Println(err)
		return
	}
	defer ln.Close()
	go serveEcho(ln)
 
	conn, err := net.Dial("tcp", ln.Addr().String())
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Fprintln(conn, "ping")
	reply, _ := bufio.NewReader(conn).ReadString('\n')
	conn.Close()
	fmt.Print("tcp echo: ", reply)
 
	// HTTP
	srv := httptest.NewServer(newMux())
	defer srv.Close()
 
	res, err := http.Get(srv.URL + "/hello/davi")
	if err != nil {
		fmt.Println(err)
		return
	}
	body, _ := io.ReadAll(res.Body)
	res.Body.Close()
	fmt.Print("GET: ", string(body))
 
	res, err = http.Post(srv.URL+"/median", "application/json", strings.NewReader("[3, 1, 4, 1, 5]"))
	if err != nil {
		fmt.Println(err)
		return
	}
	body, _ = io.ReadAll(res.Body)
	res.Body.Close()
	fmt.Print("POST: ", res.StatusCode, " ", string(body))
 
	res, _ = http.Post(srv.URL+"/median", "application/json", strings.NewReader("oops"))
	fmt.Println("bad input:", res.StatusCode)
	res.Body.Close()
 
	if *verbose {
		fmt.Println("server was at", srv.URL)
	}
}
Output
tcp echo: ping
GET: halo, davi
POST: 200 {"median":3}
bad input: 400

To run it as a real long-lived server instead, replace the httptest.NewServer part with http.ListenAndServe(":8080", newMux()), start it, and in another terminal run curl localhost:8080/hello/davi.

Summary#

  • TCP: net.Listen, an Accept loop, one goroutine per connection; net.Conn is a Reader and Writer.
  • HTTP: http.NewServeMux with method and wildcard patterns, encoding/json for bodies, httptest for tests.
  • flag parses command-line options into pointers after flag.Parse().
  • One folder is one package; capitalized names are exported; internal/ hides code.
  • Doc comments go directly above declarations, start with the name, and appear in go doc and pkg.go.dev.

Check your understanding

Answer before scrolling back up. If you miss one, reread the matching section, then try again.

  1. Q1.Which identifiers in package stats can another package use?

  2. Q2.Why does a TCP server start a goroutine for each accepted connection?

  3. Q3.What does flag.Int("port", 8080, "HTTP port") return?

  4. Q4.Where should a doc comment for function Median go?

Practice in Go

Write a table test first, then the solution. On LeetCode, choose Go as the language. Open a hint only after 15 minutes stuck.

  • TCP echo clientbuild

    Write a client that connects with net.Dial, sends three lines, and prints each echoed reply.

    Show hint

    Wrap the connection in bufio.NewReader(conn) and call ReadString('\n') after each write.

  • Two Sum as a servicebuild

    Add POST /twosum taking {"nums": [...], "target": n} and returning the two indexes. Test it with httptest.

    Show hint

    Decode into a struct with tags like json:"nums".

  • Document and publish a packagebuild

    Move Median into github.com/you/godsa/stats with doc comments and an ExampleMedian function. View it with go doc ./stats.

  • Word count with flagsbuild

    Turn last lesson's word counter into wordfreq -top 5 file.txt using flag.Int and flag.Args().

Finished this lesson?

Mark it done to track your progress. It is saved in this browser only.