Building Web Servers in Go
Go is an excellent choice for building web servers due to its built-in net/http package and impressive performance. This guide will take you from creating a basic HTTP server to building a complete web application with middleware, routing, and database integration.
Why Go for Web Development?
- Performance: Compiled to native code, excellent concurrency support
- Simplicity: Clean syntax and standard library batteries
- Concurrency: Goroutines make handling many connections easy
- Static Binaries: Single executable with no external dependencies
- Cross-platform: Build for any platform from any machine
Basic HTTP Server
Let’s start with the simplest possible web server:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!")
})
fmt.Println("Server starting on port 8080...")
http.ListenAndServe(":8080", nil)
}Save this as main.go and run it:
go run main.goNow visit http://localhost:8080 in your browser. You’ll see “Hello, World!”.
Understanding Handlers
In Go, web servers use handlers to process HTTP requests. A handler is any type that implements the http.Handler interface:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}Handler Functions
The most common way to create handlers is using functions with the signature func(http.ResponseWriter, *http.Request):
package main
import (
"fmt"
"net/http"
)
// Handler function for the home page
func homeHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Welcome to the Home Page!")
}
// Handler function for about page
func aboutHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "This is the About Page")
}
func main() {
// Register handlers
http.HandleFunc("/", homeHandler)
http.HandleFunc("/about", aboutHandler)
fmt.Println("Server starting on port 8080...")
http.ListenAndServe(":8080", nil)
}Struct-Based Handlers
For more complex applications, struct-based handlers provide better organization:
package main
import (
"fmt"
"net/http"
"strconv"
)
// Server represents our web server
type Server struct {
Port string
Counter int
}
// ServeHTTP implements the http.Handler interface
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
s.homeHandler(w, r)
case "/count":
s.countHandler(w, r)
case "/increment":
s.incrementHandler(w, r)
default:
http.NotFound(w, r)
}
}
func (s *Server) homeHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Counter Value: %d", s.Counter)
fmt.Fprintf(w, "\nVisit /count to see the counter")
fmt.Fprintf(w, "\nVisit /increment to increment it")
}
func (s *Server) countHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Current count: %d", s.Counter)
}
func (s *Server) incrementHandler(w http.ResponseWriter, r *http.Request) {
s.Counter++
fmt.Fprintf(w, "Count incremented to: %d", s.Counter)
}
func main() {
server := &Server{Port: ":8080", Counter: 0}
fmt.Printf("Server starting on port %s...\n", server.Port)
http.ListenAndServe(server.Port, server)
}Working with HTTP Methods
Web applications need to handle different HTTP methods (GET, POST, PUT, DELETE, etc.):
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
var users []User
var nextID int = 1
func userHandler(w http.ResponseWriter, r *http.Request) {
// Extract user ID from URL path
path := strings.TrimPrefix(r.URL.Path, "/users/")
if path == "" {
// No ID specified - list users or create new user
switch r.Method {
case http.MethodGet:
getUsers(w, r)
case http.MethodPost:
createUser(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
} else {
// ID specified - get, update, or delete specific user
switch r.Method {
case http.MethodGet:
getUser(w, r, path)
case http.MethodPut:
updateUser(w, r, path)
case http.MethodDelete:
deleteUser(w, r, path)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
}
func getUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
func createUser(w http.ResponseWriter, r *http.Request) {
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
user.ID = nextID
nextID++
users = append(users, user)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
func getUser(w http.ResponseWriter, r *http.Request, idStr string) {
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
for _, user := range users {
if user.ID == id {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
return
}
}
http.Error(w, "User not found", http.StatusNotFound)
}
func updateUser(w http.ResponseWriter, r *http.Request, idStr string) {
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
var updatedUser User
if err := json.NewDecoder(r.Body).Decode(&updatedUser); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
for i, user := range users {
if user.ID == id {
updatedUser.ID = id
users[i] = updatedUser
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(updatedUser)
return
}
}
http.Error(w, "User not found", http.StatusNotFound)
}
func deleteUser(w http.ResponseWriter, r *http.Request, idStr string) {
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
for i, user := range users {
if user.ID == id {
users = append(users[:i], users[i+1:]...)
w.WriteHeader(http.StatusNoContent)
return
}
}
http.Error(w, "User not found", http.StatusNotFound)
}
func main() {
// Initialize with sample data
users = append(users, User{ID: nextID, Name: "John Doe", Email: "[email protected]"})
nextID++
http.HandleFunc("/users", userHandler)
http.HandleFunc("/users/", userHandler)
fmt.Println("Server starting on port 8080...")
fmt.Println("Available endpoints:")
fmt.Println(" GET /users - List all users")
fmt.Println(" POST /users - Create new user")
fmt.Println(" GET /users/{id} - Get user by ID")
fmt.Println(" PUT /users/{id} - Update user")
fmt.Println(" DELETE /users/{id} - Delete user")
http.ListenAndServe(":8080", nil)
}Adding Middleware
Middleware wraps handlers to add cross-cutting concerns like logging, authentication, and CORS.
Basic Middleware Pattern
package main
import (
"fmt"
"log"
"net/http"
"time"
)
// Logging middleware
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Call the next handler
next.ServeHTTP(w, r)
// Log the request
log.Printf(
"%s %s %s %v",
r.Method,
r.RequestURI,
r.RemoteAddr,
time.Since(start),
)
})
}
// CORS middleware
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set CORS headers
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Handle preflight requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
// Authentication middleware
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip authentication for health check
if r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized)
return
}
// Simple token validation (in production, use proper JWT validation)
if token != "Bearer valid-token" {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func homeHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Welcome to the protected home page!")
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "OK")
}
func main() {
// Create routes
mux := http.NewServeMux()
mux.HandleFunc("/", homeHandler)
mux.HandleFunc("/health", healthHandler)
// Apply middleware
handler := loggingMiddleware(corsMiddleware(authMiddleware(mux)))
fmt.Println("Server starting on port 8080...")
http.ListenAndServe(":8080", handler)
}Custom Middleware Chain
For more complex applications, you might want a middleware chain system:
package main
import (
"fmt"
"net/http"
"time"
)
// Middleware type
type Middleware func(http.Handler) http.Handler
// Chain creates a middleware chain
func Chain(handler http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}
// Request ID middleware
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = fmt.Sprintf("%d", time.Now().UnixNano())
}
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r)
})
}
// Content type middleware
func jsonMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
next.ServeHTTP(w, r)
})
}
func apiHandler(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"message": "Hello from API",
"method": r.Method,
"path": r.URL.Path,
}
fmt.Fprintf(w, `{"message":"Hello from API","method":"%s","path":"%s"}`, r.Method, r.URL.Path)
}
func main() {
handler := Chain(
http.HandlerFunc(apiHandler),
requestIDMiddleware,
jsonMiddleware,
)
fmt.Println("Server starting on port 8080...")
http.ListenAndServe(":8080", handler)
}Template Rendering
Go includes a powerful template engine for generating HTML responses.
Basic Template Usage
package main
import (
"html/template"
"net/http"
)
type PageData struct {
Title string
Message string
Items []string
}
func homeHandler(w http.ResponseWriter, r *http.Request) {
data := PageData{
Title: "Welcome Page",
Message: "Welcome to our website!",
Items: []string{"Go", "Web Development", "Templates"},
}
tmpl, err := template.ParseFiles("templates/layout.html", "templates/home.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = tmpl.ExecuteTemplate(w, "layout", data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func main() {
http.HandleFunc("/", homeHandler)
http.ListenAndServe(":8080", nil)
}Create template files:
templates/layout.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.container { max-width: 800px; margin: 0 auto; }
.nav { background: #f4f4f4; padding: 10px; margin-bottom: 20px; }
.nav a { margin-right: 20px; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<nav class="nav">
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
{{template "content" .}}
</div>
</body>
</html>templates/home.html:
{{define "content"}}
<h1>{{.Message}}</h1>
<h2>Featured Topics:</h2>
<ul>
{{range .Items}}
<li>{{.}}</li>
{{end}}
</ul>
{{end}}Template Functions
You can add custom functions to your templates:
package main
import (
"html/template"
"net/http"
"strings"
"time"
)
// Custom template functions
var templateFunctions = template.FuncMap{
"upper": strings.ToUpper,
"lower": strings.ToLower,
"formatDate": func(t time.Time) string {
return t.Format("January 2, 2006")
},
}
type Article struct {
Title string
Content string
Author string
Published time.Time
Tags []string
}
func loadTemplates() *template.Template {
return template.Must(
template.New("").Funcs(templateFunctions).ParseGlob("templates/*.html"),
)
}
func articleHandler(w http.ResponseWriter, r *http.Request) {
article := Article{
Title: "Getting Started with Go Web Development",
Content: "Go is a powerful language for building web applications...",
Author: "John Doe",
Published: time.Now(),
Tags: []string{"go", "web", "tutorial"},
}
tmpl := loadTemplates()
err := tmpl.ExecuteTemplate(w, "article", article)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func main() {
http.HandleFunc("/article", articleHandler)
http.ListenAndServe(":8080", nil)
}templates/article.html:
{{define "content"}}
<article>
<h1>{{.Title | upper}}</h1>
<div class="meta">
By {{.Author}} on {{formatDate .Published}}
</div>
<div class="tags">
Tags:
{{range .Tags}}
<span class="tag">{{. | lower}}</span>
{{end}}
</div>
<div class="content">
<p>{{.Content}}</p>
</div>
</article>
{{end}}Database Integration
Let’s integrate a database using a simple in-memory store pattern.
Repository Pattern
package main
import (
"encoding/json"
"net/http"
"sync"
"time"
)
type Post struct {
ID int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Author string `json:"author"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PostRepository struct {
posts []Post
mu sync.RWMutex
nextID int
}
func NewPostRepository() *PostRepository {
return &PostRepository{
posts: make([]Post, 0),
nextID: 1,
}
}
func (r *PostRepository) GetAll() []Post {
r.mu.RLock()
defer r.mu.RUnlock()
return r.posts
}
func (r *PostRepository) GetByID(id int) (*Post, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, post := range r.posts {
if post.ID == id {
return &post, true
}
}
return nil, false
}
func (r *PostRepository) Create(post *Post) *Post {
r.mu.Lock()
defer r.mu.Unlock()
post.ID = r.nextID
post.CreatedAt = time.Now()
post.UpdatedAt = time.Now()
r.posts = append(r.posts, *post)
r.nextID++
return post
}
func (r *PostRepository) Update(id int, updated *Post) (*Post, bool) {
r.mu.Lock()
defer r.mu.Unlock()
for i, post := range r.posts {
if post.ID == id {
updated.ID = id
updated.CreatedAt = post.CreatedAt
updated.UpdatedAt = time.Now()
r.posts[i] = *updated
return updated, true
}
}
return nil, false
}
func (r *PostRepository) Delete(id int) bool {
r.mu.Lock()
defer r.mu.Unlock()
for i, post := range r.posts {
if post.ID == id {
r.posts = append(r.posts[:i], r.posts[i+1:]...)
return true
}
}
return false
}
// HTTP Handlers
func (r *PostRepository) getPostsHandler(w http.ResponseWriter, r *http.Request) {
posts := r.GetAll()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(posts)
}
func (r *PostRepository) createPostHandler(w http.ResponseWriter, r *http.Request) {
var post Post
if err := json.NewDecoder(r.Body).Decode(&post); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
created := r.Create(&post)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(created)
}
func (r *PostRepository) getPostHandler(w http.ResponseWriter, r *http.Request) {
id := extractIDFromURL(r.URL.Path)
if id == -1 {
http.Error(w, "Invalid post ID", http.StatusBadRequest)
return
}
post, found := r.GetByID(id)
if !found {
http.Error(w, "Post not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(post)
}
func extractIDFromURL(path string) int {
// Simple extraction logic - in production, use proper URL parsing
// This assumes URL format: /posts/123
parts := strings.Split(path, "/")
if len(parts) < 3 {
return -1
}
id, err := strconv.Atoi(parts[2])
if err != nil {
return -1
}
return id
}
func main() {
repo := NewPostRepository()
// Create some initial data
repo.Create(&Post{
Title: "Getting Started with Go",
Content: "Go is a powerful programming language...",
Author: "Alice",
})
repo.Create(&Post{
Title: "Web Development in Go",
Content: "Building web applications with Go is straightforward...",
Author: "Bob",
})
mux := http.NewServeMux()
mux.HandleFunc("/posts", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
repo.getPostsHandler(w, r)
case http.MethodPost:
repo.createPostHandler(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/posts/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
repo.getPostHandler(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
fmt.Println("Blog API starting on port 8080...")
fmt.Println("Available endpoints:")
fmt.Println(" GET /posts - List all posts")
fmt.Println(" POST /posts - Create new post")
fmt.Println(" GET /posts/{id} - Get specific post")
http.ListenAndServe(":8080", mux)
}Configuration and Environment
Environment Variables
package main
import (
"fmt"
"log"
"net/http"
"os"
"strconv"
)
type Config struct {
Port string
DatabaseURL string
LogLevel string
Debug bool
}
func loadConfig() *Config {
config := &Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: getEnv("DATABASE_URL", "sqlite:app.db"),
LogLevel: getEnv("LOG_LEVEL", "info"),
Debug: getEnvBool("DEBUG", false),
}
return config
}
func getEnv(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}
func getEnvBool(key string, defaultValue bool) bool {
if value, exists := os.LookupEnv(key); exists {
if parsed, err := strconv.ParseBool(value); err == nil {
return parsed
}
}
return defaultValue
}
func main() {
config := loadConfig()
if config.Debug {
log.Printf("Debug mode enabled")
log.Printf("Port: %s", config.Port)
log.Printf("Database: %s", config.DatabaseURL)
}
handler := setupRoutes(config)
addr := ":" + config.Port
log.Printf("Server starting on %s", addr)
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
func setupRoutes(config *Config) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Server running on port %s", config.Port)
})
mux.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Port: %s\n", config.Port)
fmt.Fprintf(w, "Database: %s\n", config.DatabaseURL)
fmt.Fprintf(w, "Log Level: %s\n", config.LogLevel)
fmt.Fprintf(w, "Debug: %t\n", config.Debug)
})
return mux
}Graceful Shutdown
Handle server shutdown gracefully to close connections properly:
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
server := &http.Server{
Addr: ":8080",
Handler: setupRoutes(),
}
// Start server in a goroutine
go func() {
log.Printf("Server starting on %s", server.Addr)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Create deadline for shutdown
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Attempt graceful shutdown
if err := server.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}
func setupRoutes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Server running... Try stopping with Ctrl+C")
})
return mux
}Testing Your Web Server
Go’s testing package makes it easy to test HTTP handlers:
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHomeHandler(t *testing.T) {
// Create a request to pass to our handler
req, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
// Create a ResponseRecorder to record the response
rr := httptest.NewRecorder()
// Create handler and serve the request
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Hello, World!"))
})
handler.ServeHTTP(rr, req)
// Check the status code
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
}
// Check the response body
expected := "Hello, World!"
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected)
}
}
func TestAPIEndpoint(t *testing.T) {
req, err := http.NewRequest("GET", "/api/users", nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[{"id":1,"name":"John"}]`))
})
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("API returned wrong status code: got %v want %v", status, http.StatusOK)
}
contentType := rr.Header().Get("Content-Type")
expectedContentType := "application/json"
if contentType != expectedContentType {
t.Errorf("API returned wrong content type: got %v want %v", contentType, expectedContentType)
}
}Best Practices
- Use Interfaces: Define interfaces for your services to enable testing and flexibility
- Handle Errors: Always handle errors appropriately
- Validate Input: Validate all incoming data
- Use Context: Pass context through your request chain
- Implement Logging: Add comprehensive logging
- Secure Your Server: Use HTTPS, authentication, and input sanitization
- Test Your Code: Write unit tests for handlers and middleware
External Resources:
Related Tutorials: