373 lines
11 KiB
Markdown
373 lines
11 KiB
Markdown
# Lesson 3 — Config & MySQL Connection
|
|||
|
|
|
||
|
|
> **New Go concepts in this lesson:** `defer`, blank imports (`_`),
|
||
|
|
> `context.WithTimeout` deadlines, working with `*sql.DB`/`*sql.Rows`.
|
||
|
|
> These build on pointers and error handling from the Go Basics lessons —
|
||
|
|
> review those if anything below feels unfamiliar.
|
||
|
|
|
||
|
|
## Part A — standalone playground
|
||
|
|
|
||
|
|
First, run a throwaway MySQL with Docker so you have something to connect
|
||
|
|
to:
|
||
|
|
```bash
|
||
|
|
docker run --name mysql-demo -e MYSQL_ROOT_PASSWORD=devpass -e MYSQL_DATABASE=demo -p 3306:3306 -d mysql:9
|
||
|
|
```
|
||
|
|
|
||
|
|
Then the playground project:
|
||
|
|
```bash
|
||
|
|
mkdir ~/go-playground/mysql-demo && cd ~/go-playground/mysql-demo
|
||
|
|
go mod init mysql-demo
|
||
|
|
go get github.com/go-sql-driver/mysql@latest
|
||
|
|
```
|
||
|
|
|
||
|
|
**`main.go`**
|
||
|
|
```go
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"database/sql"
|
||
|
|
"log"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
_ "github.com/go-sql-driver/mysql"
|
||
|
|
)
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
// 1. Open a connection pool (this does NOT actually connect yet!)
|
||
|
|
db, err := sql.Open("mysql", "root:devpass@tcp(127.0.0.1:3306)/demo?parseTime=true")
|
||
|
|
if err != nil {
|
||
|
|
log.Fatalf("sql.Open failed: %v", err)
|
||
|
|
}
|
||
|
|
defer db.Close()
|
||
|
|
|
||
|
|
// 2. Configure the pool
|
||
|
|
db.SetMaxOpenConns(10)
|
||
|
|
db.SetMaxIdleConns(5)
|
||
|
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||
|
|
|
||
|
|
// 3. Actually verify we can connect
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
if err := db.PingContext(ctx); err != nil {
|
||
|
|
log.Fatalf("ping failed: %v", err)
|
||
|
|
}
|
||
|
|
log.Println("connected to mysql")
|
||
|
|
|
||
|
|
// 4. Create a table and insert a row
|
||
|
|
_, err = db.ExecContext(ctx, `
|
||
|
|
CREATE TABLE IF NOT EXISTS notes (
|
||
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
|
|
body VARCHAR(255) NOT NULL,
|
||
|
|
created_at DATETIME NOT NULL
|
||
|
|
)`)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatalf("create table failed: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
res, err := db.ExecContext(ctx,
|
||
|
|
"INSERT INTO notes (body, created_at) VALUES (?, ?)",
|
||
|
|
"hello from go", time.Now(),
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatalf("insert failed: %v", err)
|
||
|
|
}
|
||
|
|
id, _ := res.LastInsertId()
|
||
|
|
log.Printf("inserted note with id %d", id)
|
||
|
|
|
||
|
|
// 5. Query rows back
|
||
|
|
rows, err := db.QueryContext(ctx, "SELECT id, body, created_at FROM notes")
|
||
|
|
if err != nil {
|
||
|
|
log.Fatalf("query failed: %v", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
for rows.Next() {
|
||
|
|
var (
|
||
|
|
noteID int
|
||
|
|
body string
|
||
|
|
createdAt time.Time
|
||
|
|
)
|
||
|
|
if err := rows.Scan(¬eID, &body, &createdAt); err != nil {
|
||
|
|
log.Fatalf("scan failed: %v", err)
|
||
|
|
}
|
||
|
|
log.Printf("note: id=%d body=%q created=%s", noteID, body, createdAt)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
log.Fatalf("rows error: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Run it:
|
||
|
|
```bash
|
||
|
|
go run .
|
||
|
|
```
|
||
|
|
|
||
|
|
### First, a quick primer on `defer`
|
||
|
|
|
||
|
|
You'll see `defer` constantly from this lesson onward. It schedules a
|
||
|
|
function call to run right when the **surrounding function** returns — no
|
||
|
|
matter how it returns (normal return, or via a `log.Fatal`-style exit,
|
||
|
|
etc., with some caveats). It's most commonly used for cleanup:
|
||
|
|
|
||
|
|
```go
|
||
|
|
func doSomething() {
|
||
|
|
f := open()
|
||
|
|
defer f.Close() // runs automatically when doSomething() returns, wherever that happens
|
||
|
|
// ... lots of code, maybe with early returns ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Without `defer`, you'd have to remember to call `f.Close()` before *every*
|
||
|
|
`return` in the function — easy to forget on one path. `defer` guarantees
|
||
|
|
it happens exactly once, right before the function actually exits.
|
||
|
|
|
||
|
|
### Line by line, the rest of the file
|
||
|
|
|
||
|
|
- `_ "github.com/go-sql-driver/mysql"` — a **blank import**. The
|
||
|
|
underscore means "import this package purely for its side effects; I'm
|
||
|
|
not calling any of its functions directly by name." This package's
|
||
|
|
`init()` function (a special function that runs automatically on
|
||
|
|
import) registers `"mysql"` as a driver name with `database/sql`. This
|
||
|
|
is a common pattern for database drivers and plugins in Go.
|
||
|
|
- `sql.Open("mysql", dsn)` — despite the name, this does **not** connect
|
||
|
|
yet. It validates the DSN format and returns a `*sql.DB`, which is
|
||
|
|
really a **connection pool manager**, not one live connection.
|
||
|
|
Connections open lazily, on first actual use.
|
||
|
|
- The DSN (data source name) `"root:devpass@tcp(127.0.0.1:3306)/demo?parseTime=true"`
|
||
|
|
is `user:password@tcp(host:port)/dbname?options`. `parseTime=true` tells
|
||
|
|
the driver to convert MySQL `DATETIME`/`TIMESTAMP` columns into Go
|
||
|
|
`time.Time` values automatically instead of raw bytes.
|
||
|
|
- `defer db.Close()` — closes the pool when `main` exits. In a real
|
||
|
|
server, this only runs once, at shutdown — you build ONE `*sql.DB` for
|
||
|
|
the whole app's lifetime, never one per request.
|
||
|
|
- `SetMaxOpenConns` / `SetMaxIdleConns` / `SetConnMaxLifetime` — pool
|
||
|
|
tuning. Max open caps simultaneous connections (protects the database
|
||
|
|
from being overwhelmed). Max idle keeps some connections warm instead of
|
||
|
|
reopening constantly. Conn max lifetime forces periodic recycling
|
||
|
|
(useful behind load balancers, or if MySQL itself closes long-idle
|
||
|
|
connections).
|
||
|
|
- `context.WithTimeout(context.Background(), 5*time.Second)` — builds a
|
||
|
|
context that automatically expires after 5 seconds (same construct used
|
||
|
|
for graceful shutdown in Lesson 1). Passing this into `PingContext`
|
||
|
|
means the ping gives up after 5 seconds instead of hanging forever if
|
||
|
|
the database host is unreachable.
|
||
|
|
- `db.PingContext(ctx)` — this is what actually forces a real connection
|
||
|
|
attempt, so bad credentials/host are caught immediately at startup,
|
||
|
|
instead of failing later on the first real query.
|
||
|
|
- `db.ExecContext(ctx, query, args...)` — for statements that don't return
|
||
|
|
rows (CREATE, INSERT, UPDATE, DELETE). The `?` placeholders are
|
||
|
|
**parameterized queries** — never build SQL by concatenating strings
|
||
|
|
with user input; this is what prevents SQL injection.
|
||
|
|
- `res.LastInsertId()` — returns the auto-increment ID of the row you just
|
||
|
|
inserted.
|
||
|
|
- `db.QueryContext(ctx, query)` — for SELECT statements, returns
|
||
|
|
`*sql.Rows`.
|
||
|
|
- `defer rows.Close()` — **always close rows**, or you leak the underlying
|
||
|
|
connection back to the pool. One of the most common Go/SQL bugs.
|
||
|
|
- `rows.Next()` — advances to the next row; returns `false` when there are
|
||
|
|
no more, or on error.
|
||
|
|
- `rows.Scan(¬eID, &body, &createdAt)` — copies the current row's
|
||
|
|
columns into your variables, **in order**, by pointer (note the `&` —
|
||
|
|
same reason as always: `Scan` needs to write into your variables, so it
|
||
|
|
needs their addresses). Types must match or be convertible.
|
||
|
|
- `rows.Err()` — check this after the loop. `Next()` returning `false`
|
||
|
|
doesn't tell you *why* it stopped — could just mean "no more rows"
|
||
|
|
(fine), or a connection error mid-scan (not fine). Always check.
|
||
|
|
|
||
|
|
Run the program twice and notice two notes get inserted, since we never
|
||
|
|
cleared the table.
|
||
|
|
|
||
|
|
## Part B — apply it to the project
|
||
|
|
|
||
|
|
**Extend `internal/config/config.go`** with DB settings:
|
||
|
|
```go
|
||
|
|
package config
|
||
|
|
|
||
|
|
import "os"
|
||
|
|
|
||
|
|
type Config struct {
|
||
|
|
Port string
|
||
|
|
|
||
|
|
DBHost string
|
||
|
|
DBPort string
|
||
|
|
DBUser string
|
||
|
|
DBPassword string
|
||
|
|
DBName string
|
||
|
|
}
|
||
|
|
|
||
|
|
func Load() Config {
|
||
|
|
return Config{
|
||
|
|
Port: getEnv("PORT", "8080"),
|
||
|
|
|
||
|
|
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||
|
|
DBPort: getEnv("DB_PORT", "3306"),
|
||
|
|
DBUser: getEnv("DB_USER", "root"),
|
||
|
|
DBPassword: getEnv("DB_PASSWORD", "devpass"),
|
||
|
|
DBName: getEnv("DB_NAME", "go_simple_api"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func getEnv(key, fallback string) string {
|
||
|
|
if v := os.Getenv(key); v != "" {
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
```
|
||
|
|
Same `getEnv` pattern from Lesson 1 — just more fields.
|
||
|
|
|
||
|
|
**`internal/database/mysql.go`**
|
||
|
|
```go
|
||
|
|
package database
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"database/sql"
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
_ "github.com/go-sql-driver/mysql"
|
||
|
|
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||
|
|
)
|
||
|
|
|
||
|
|
func NewMySQL(ctx context.Context, cfg config.Config) (*sql.DB, error) {
|
||
|
|
dsn := fmt.Sprintf(
|
||
|
|
"%s:%s@tcp(%s:%s)/%s?parseTime=true",
|
||
|
|
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName,
|
||
|
|
)
|
||
|
|
|
||
|
|
db, err := sql.Open("mysql", dsn)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("open mysql: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
db.SetMaxOpenConns(10)
|
||
|
|
db.SetMaxIdleConns(5)
|
||
|
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||
|
|
|
||
|
|
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
if err := db.PingContext(pingCtx); err != nil {
|
||
|
|
return nil, fmt.Errorf("ping mysql: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return db, nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
This is Part A's connection logic, reshaped into a function that returns
|
||
|
|
`(*sql.DB, error)` instead of calling `log.Fatal` directly — a
|
||
|
|
library-style function shouldn't kill the whole program itself; it should
|
||
|
|
return the error and let the **caller** (`main.go`) decide what to do
|
||
|
|
about it.
|
||
|
|
|
||
|
|
New here: `fmt.Errorf("open mysql: %w", err)` **wraps** the original error
|
||
|
|
(see Go Basics Part 3) — adding context ("open mysql: ...") while
|
||
|
|
preserving the original error so it can still be inspected further up the
|
||
|
|
call chain with `errors.Is`/`errors.As`. This is the idiomatic Go way to
|
||
|
|
add context to errors as they bubble up through layers.
|
||
|
|
|
||
|
|
**Update `cmd/api/main.go`** to connect on startup and close on shutdown:
|
||
|
|
```go
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"os/signal"
|
||
|
|
"syscall"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/database"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/logging"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/router"
|
||
|
|
)
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
cfg := config.Load()
|
||
|
|
logger := logging.New()
|
||
|
|
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
db, err := database.NewMySQL(ctx, cfg)
|
||
|
|
if err != nil {
|
||
|
|
logger.Error("failed to connect to database", "error", err)
|
||
|
|
os.Exit(1)
|
||
|
|
}
|
||
|
|
defer db.Close()
|
||
|
|
logger.Info("connected to database", "host", cfg.DBHost, "db", cfg.DBName)
|
||
|
|
|
||
|
|
r := router.New(logger)
|
||
|
|
|
||
|
|
srv := &http.Server{
|
||
|
|
Addr: ":" + cfg.Port,
|
||
|
|
Handler: r,
|
||
|
|
}
|
||
|
|
|
||
|
|
go func() {
|
||
|
|
logger.Info("server starting", "port", cfg.Port)
|
||
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
|
|
logger.Error("server error", "error", err)
|
||
|
|
os.Exit(1)
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
|
||
|
|
quit := make(chan os.Signal, 1)
|
||
|
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||
|
|
<-quit
|
||
|
|
|
||
|
|
logger.Info("shutting down gracefully")
|
||
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||
|
|
logger.Error("forced shutdown", "error", err)
|
||
|
|
os.Exit(1)
|
||
|
|
}
|
||
|
|
logger.Info("server stopped")
|
||
|
|
}
|
||
|
|
```
|
||
|
|
- `db, err := database.NewMySQL(ctx, cfg)` — if this fails, we log and
|
||
|
|
`os.Exit(1)` immediately; there's no point starting an HTTP server that
|
||
|
|
can't reach its own database.
|
||
|
|
- `defer db.Close()` — closes the connection pool when `main` returns,
|
||
|
|
i.e. after graceful shutdown finishes below.
|
||
|
|
- `db` isn't used by the router yet — that's Lesson 4, once we build a
|
||
|
|
user repository that needs it to run queries. For now we're just
|
||
|
|
proving the connection works at startup.
|
||
|
|
|
||
|
|
**Add a `.env` file** at the project root (we're not auto-loading it yet —
|
||
|
|
export these manually for now, or run
|
||
|
|
`export $(grep -v '^#' .env | xargs)`):
|
||
|
|
```
|
||
|
|
PORT=8080
|
||
|
|
DB_HOST=127.0.0.1
|
||
|
|
DB_PORT=3306
|
||
|
|
DB_USER=root
|
||
|
|
DB_PASSWORD=devpass
|
||
|
|
DB_NAME=go_simple_api
|
||
|
|
```
|
||
|
|
|
||
|
|
## Try it
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go get github.com/go-sql-driver/mysql@latest
|
||
|
|
|
||
|
|
docker run --name mysql-api -e MYSQL_ROOT_PASSWORD=devpass -e MYSQL_DATABASE=go_simple_api -p 3306:3306 -d mysql:9
|
||
|
|
|
||
|
|
go run ./cmd/api
|
||
|
|
```
|
||
|
|
|
||
|
|
You should see `"connected to database"` in the JSON logs, then `"server
|
||
|
|
starting"`. Ctrl+C should still shut down gracefully, closing the DB pool
|
||
|
|
cleanly along the way.
|
||
|
|
|
||
|
|
Once this works, move to Lesson 4 — the user model & repository pattern
|
||
|
|
(this is also where the pointer concepts from Go Basics Part 2 pay off in
|
||
|
|
full).
|