319 lines
12 KiB
Markdown
319 lines
12 KiB
Markdown
# Lesson 1 — Project Skeleton & chi Routing
|
|||
|
|
|
||
|
|
> **New Go concepts in this lesson:** packages/imports/modules, structs,
|
||
|
|
> pointers, interfaces (implicitly satisfied), goroutines. If any of those
|
||
|
|
> feel shaky, review `00-go-basics-3-interfaces-errors-concurrency-packages.md`
|
||
|
|
> first.
|
||
|
|
|
||
|
|
## What we're building
|
||
|
|
|
||
|
|
By the end of this lesson you'll have a real, runnable HTTP server with:
|
||
|
|
- A standard Go project layout you'll keep extending for the rest of the course
|
||
|
|
- A router (using the `chi` library) that maps URLs to handler functions
|
||
|
|
- A `/health` endpoint that returns JSON
|
||
|
|
- A **graceful shutdown** sequence — the server finishes in-flight
|
||
|
|
requests before exiting on Ctrl+C, instead of just dying mid-request
|
||
|
|
|
||
|
|
## Project structure (final shape, built up over the whole course)
|
||
|
|
|
||
|
|
```
|
||
|
|
go-simple-api/
|
||
|
|
├── cmd/api/main.go
|
||
|
|
├── internal/
|
||
|
|
│ ├── config/config.go
|
||
|
|
│ ├── handlers/health.go
|
||
|
|
│ └── router/router.go
|
||
|
|
├── go.mod
|
||
|
|
```
|
||
|
|
(More folders get added lesson by lesson — this is just what exists after
|
||
|
|
Lesson 1.)
|
||
|
|
|
||
|
|
## Setup
|
||
|
|
|
||
|
|
```bash
|
||
|
|
mkdir go-simple-api && cd go-simple-api
|
||
|
|
go mod init git.hamidsoltani.com/hamid/go-simple-api
|
||
|
|
go get github.com/go-chi/chi/v5@latest
|
||
|
|
```
|
||
|
|
|
||
|
|
A quick word on that module path: `git.hamidsoltani.com/hamid/go-simple-api`
|
||
|
|
isn't a real, fetchable URL — it's just a naming convention (commonly your
|
||
|
|
Git host + username + project name). It becomes the prefix for every
|
||
|
|
internal import in this project, e.g.
|
||
|
|
`git.hamidsoltani.com/hamid/go-simple-api/internal/config`. If you're
|
||
|
|
following along with your own project, use your own path here — just stay
|
||
|
|
consistent with it everywhere.
|
||
|
|
|
||
|
|
`go get github.com/go-chi/chi/v5@latest` downloads
|
||
|
|
[chi](https://github.com/go-chi/chi), a small, popular HTTP router for Go.
|
||
|
|
Why use a router library instead of the standard library's own
|
||
|
|
`http.ServeMux`? chi gives us URL parameters (`/users/{id}`), route
|
||
|
|
groups, and a large ecosystem of compatible middleware (rate limiting,
|
||
|
|
CORS, request logging) that we'll use throughout this course — the
|
||
|
|
standard library's router is fine for very simple cases but doesn't have
|
||
|
|
these built in.
|
||
|
|
|
||
|
|
## `internal/config/config.go`
|
||
|
|
|
||
|
|
```go
|
||
|
|
package config
|
||
|
|
|
||
|
|
import "os"
|
||
|
|
|
||
|
|
type Config struct {
|
||
|
|
Port string
|
||
|
|
}
|
||
|
|
|
||
|
|
func Load() Config {
|
||
|
|
return Config{
|
||
|
|
Port: getEnv("PORT", "8080"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func getEnv(key, fallback string) string {
|
||
|
|
if v := os.Getenv(key); v != "" {
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Line by line:
|
||
|
|
- `package config` — its own package, so both `main.go` and any future
|
||
|
|
file can import it and call `config.Load()`.
|
||
|
|
- `type Config struct { Port string }` — a plain struct holding settings.
|
||
|
|
We'll add many more fields to this over the course (database settings,
|
||
|
|
Redis settings, OAuth settings...) — this is the ONE place all of the
|
||
|
|
app's configuration lives.
|
||
|
|
- `func Load() Config` — returns a `Config` **by value** (not a pointer)
|
||
|
|
since it's small and, once built, nothing needs to mutate it in place.
|
||
|
|
- `getEnv` is unexported (lowercase — see Go Basics Part 2 on
|
||
|
|
capitalization) — nothing outside this file needs to call it directly.
|
||
|
|
`os.Getenv(key)` reads an environment variable; if it's empty (unset),
|
||
|
|
we return `fallback` instead. This is how you avoid hardcoding things
|
||
|
|
like ports directly in your code.
|
||
|
|
|
||
|
|
## `internal/handlers/health.go`
|
||
|
|
|
||
|
|
```go
|
||
|
|
package handlers
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
)
|
||
|
|
|
||
|
|
func Health(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
w.WriteHeader(http.StatusOK)
|
||
|
|
json.NewEncoder(w).Encode(map[string]string{
|
||
|
|
"status": "ok",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- Every HTTP handler in Go (with or without chi) has this exact function
|
||
|
|
signature: `func(w http.ResponseWriter, r *http.Request)`.
|
||
|
|
- `w http.ResponseWriter` is how you write the response back to the
|
||
|
|
client — it's an interface (see Go Basics Part 3) with methods like
|
||
|
|
`Write`, `WriteHeader`, and `Header()`.
|
||
|
|
- `r *http.Request` is a pointer to a struct describing the incoming
|
||
|
|
request — method, URL, headers, body, etc.
|
||
|
|
- `w.Header().Set("Content-Type", "application/json")` — sets a response
|
||
|
|
header. This **must** happen before `w.WriteHeader(...)` is called —
|
||
|
|
once you write the status code, the headers are locked in and can't be
|
||
|
|
changed afterward.
|
||
|
|
- `w.WriteHeader(http.StatusOK)` — writes the HTTP status code (`200`).
|
||
|
|
`http.StatusOK` is just a predefined constant equal to `200` — using the
|
||
|
|
named constant instead of the raw number is more readable and less
|
||
|
|
error-prone.
|
||
|
|
- `json.NewEncoder(w).Encode(map[string]string{"status": "ok"})` — from Go
|
||
|
|
Basics Part 3: creates a JSON encoder that writes directly to `w`
|
||
|
|
(which is a stream, an `io.Writer`), then encodes our map as JSON and
|
||
|
|
writes it out. `map[string]string` is a map with `string` keys and
|
||
|
|
`string` values — see Go Basics Part 3 on maps.
|
||
|
|
|
||
|
|
## `internal/router/router.go`
|
||
|
|
|
||
|
|
```go
|
||
|
|
package router
|
||
|
|
|
||
|
|
import (
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
"github.com/go-chi/chi/v5/middleware"
|
||
|
|
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
|
||
|
|
)
|
||
|
|
|
||
|
|
func New() *chi.Mux {
|
||
|
|
r := chi.NewRouter()
|
||
|
|
|
||
|
|
r.Use(middleware.RequestID)
|
||
|
|
r.Use(middleware.Logger)
|
||
|
|
r.Use(middleware.Recoverer)
|
||
|
|
|
||
|
|
r.Get("/health", handlers.Health)
|
||
|
|
|
||
|
|
return r
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- `chi.NewRouter()` returns a `*chi.Mux` — a pointer to chi's router type.
|
||
|
|
`*chi.Mux` happens to have a `ServeHTTP(w, r)` method, which means it
|
||
|
|
automatically satisfies the standard library's `http.Handler` interface
|
||
|
|
(see Go Basics Part 3 on interfaces) — no explicit declaration needed,
|
||
|
|
it "just works" because the method exists.
|
||
|
|
- `r.Use(...)` registers **middleware**: a function that wraps every
|
||
|
|
request passing through the router. Each of these has the shape
|
||
|
|
`func(http.Handler) http.Handler` — takes the "next" handler in the
|
||
|
|
chain, returns a new handler that does something extra before/after
|
||
|
|
calling it.
|
||
|
|
- `middleware.RequestID` — tags each request with a unique ID (useful
|
||
|
|
later once we add structured logging, in Lesson 2).
|
||
|
|
- `middleware.Logger` — chi's built-in logger; prints a line per
|
||
|
|
request to your terminal (we'll replace this with our own structured
|
||
|
|
JSON version in Lesson 2).
|
||
|
|
- `middleware.Recoverer` — catches panics inside any handler and turns
|
||
|
|
them into a `500` response, instead of crashing the entire server
|
||
|
|
process over one bad request.
|
||
|
|
- `r.Get("/health", handlers.Health)` — registers `handlers.Health` to run
|
||
|
|
for `GET` requests to `/health`. Note we pass the function itself
|
||
|
|
(`handlers.Health`), not a call to it (`handlers.Health()`) — chi will
|
||
|
|
call it later, once per matching request.
|
||
|
|
|
||
|
|
## `cmd/api/main.go`
|
||
|
|
|
||
|
|
```go
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"log"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"os/signal"
|
||
|
|
"syscall"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/router"
|
||
|
|
)
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
cfg := config.Load()
|
||
|
|
r := router.New()
|
||
|
|
|
||
|
|
srv := &http.Server{
|
||
|
|
Addr: ":" + cfg.Port,
|
||
|
|
Handler: r,
|
||
|
|
}
|
||
|
|
|
||
|
|
go func() {
|
||
|
|
log.Printf("server starting on port %s", cfg.Port)
|
||
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
|
|
log.Fatalf("server error: %v", err)
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
|
||
|
|
quit := make(chan os.Signal, 1)
|
||
|
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||
|
|
<-quit
|
||
|
|
|
||
|
|
log.Println("shutting down gracefully...")
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
if err := srv.Shutdown(ctx); err != nil {
|
||
|
|
log.Fatalf("forced shutdown: %v", err)
|
||
|
|
}
|
||
|
|
log.Println("server stopped")
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
This is the most concept-dense file in the lesson. Take it slowly:
|
||
|
|
|
||
|
|
- `cfg := config.Load()` then `r := router.New()` — build our two pieces
|
||
|
|
using the constructors we just wrote.
|
||
|
|
- `srv := &http.Server{Addr: ":" + cfg.Port, Handler: r}` — instead of
|
||
|
|
calling the simpler `http.ListenAndServe(addr, handler)` directly, we
|
||
|
|
build an `*http.Server` struct ourselves (note the `&` — we want a
|
||
|
|
pointer, since we're going to call methods on it later that need to
|
||
|
|
operate on this exact instance). We do this specifically so we can call
|
||
|
|
`.Shutdown()` on it further down — `http.ListenAndServe` alone gives you
|
||
|
|
no way to stop it gracefully.
|
||
|
|
- `go func() { ... }()` — **this is a goroutine** (Go Basics Part 3,
|
||
|
|
section 5). `srv.ListenAndServe()` blocks forever, serving requests
|
||
|
|
until the server stops. If we called it directly here (without `go`),
|
||
|
|
the code below it — the part that waits for Ctrl+C — would never run;
|
||
|
|
the program would just sit inside `ListenAndServe` permanently. Running
|
||
|
|
it as a goroutine lets it serve requests in the background while
|
||
|
|
`main()`'s primary execution moves on to the next lines.
|
||
|
|
- `if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed`
|
||
|
|
— `ListenAndServe` always returns a non-nil error when it stops (even on
|
||
|
|
a normal, intentional shutdown) — `http.ErrServerClosed` specifically
|
||
|
|
means "this was a deliberate `Shutdown()` call, not a real problem," so
|
||
|
|
we only treat OTHER errors as fatal.
|
||
|
|
- `quit := make(chan os.Signal, 1)` — a **channel**, Go's built-in
|
||
|
|
mechanism for goroutines to communicate. We're using it here in its
|
||
|
|
simplest form: as a way to "wait for a signal to arrive." (We don't go
|
||
|
|
deeper into channels in this course — this is the only one you'll need.)
|
||
|
|
- `signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)` — tells the Go
|
||
|
|
runtime "when the OS sends this process a SIGINT (Ctrl+C) or SIGTERM
|
||
|
|
(e.g. `docker stop`), send a value into the `quit` channel instead of
|
||
|
|
just killing the process outright."
|
||
|
|
- `<-quit` — this is the **receive** operation on a channel: it blocks
|
||
|
|
(pauses) the current goroutine — here, `main()`'s own execution — until
|
||
|
|
something arrives on `quit`. This is what actually keeps the program
|
||
|
|
alive and waiting, instead of exiting immediately after starting the
|
||
|
|
server goroutine.
|
||
|
|
- `context.WithTimeout(context.Background(), 5*time.Second)` — builds a
|
||
|
|
`context.Context` (we'll use these a lot more starting in Lesson 8) that
|
||
|
|
automatically expires after 5 seconds. `context.Background()` is the
|
||
|
|
standard "empty starting point" for building a new context.
|
||
|
|
- `defer cancel()` — `defer` schedules a function call to run right before
|
||
|
|
the surrounding function (`main`, here) returns, regardless of how it
|
||
|
|
returns. `cancel` releases resources associated with the timeout context
|
||
|
|
once we're done with it — always pair `WithTimeout`/`WithCancel` with a
|
||
|
|
`defer cancel()` immediately after creating them.
|
||
|
|
- `srv.Shutdown(ctx)` — tells the server to stop accepting new
|
||
|
|
connections, and wait for existing in-flight requests to finish, up to
|
||
|
|
the 5-second deadline in `ctx`. This is what "graceful" shutdown means:
|
||
|
|
requests that were already in progress get to complete normally instead
|
||
|
|
of being cut off mid-response.
|
||
|
|
|
||
|
|
## Try it
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go run ./cmd/api
|
||
|
|
```
|
||
|
|
|
||
|
|
In another terminal:
|
||
|
|
```bash
|
||
|
|
curl http://localhost:8080/health
|
||
|
|
```
|
||
|
|
You should get back `{"status":"ok"}`.
|
||
|
|
|
||
|
|
Now go back to the terminal running the server and press **Ctrl+C**. You
|
||
|
|
should see:
|
||
|
|
```
|
||
|
|
shutting down gracefully...
|
||
|
|
server stopped
|
||
|
|
```
|
||
|
|
instead of the process just vanishing instantly — that's the graceful
|
||
|
|
shutdown sequence working.
|
||
|
|
|
||
|
|
## Common mistakes at this stage
|
||
|
|
|
||
|
|
- **Forgetting the parentheses when calling a function**: writing
|
||
|
|
`r := router.New` (assigns the function itself) instead of
|
||
|
|
`r := router.New()` (calls it and gets the `*chi.Mux` back). The
|
||
|
|
compiler error looks like: `cannot use r (variable of type func() *chi.Mux)
|
||
|
|
as http.Handler value` — if you see that shape of error, check for a
|
||
|
|
missing `()`.
|
||
|
|
- **Forgetting `defer db.Close()` / `defer cancel()`** on things that need
|
||
|
|
cleanup — not an issue yet in this lesson, but a habit to build now,
|
||
|
|
since it appears constantly starting in Lesson 3.
|
||
|
|
|
||
|
|
Once `/health` works and Ctrl+C shuts down cleanly, move on to Lesson 2 —
|
||
|
|
structured JSON logging.
|