Skip to content

18|路由配置与加载

最小代理只能把请求转发到单一后端。实际网关需要按路径把请求分发到不同服务:/api/users 转发到用户服务,/api/orders 转发到订单服务,/static/ 转发到静态文件服务器。这种映射关系叫路由表。本章把硬编码的后端地址改成可配置的路由表,并添加一个简单的管理 API 来查看和修改路由配置。

一、路由表数据结构

路由的核心是"请求路径匹配后端地址"。最简单的匹配方式是前缀匹配:请求路径以某个前缀开头,就转发到对应的后端。

go
package main

import (
	"net/url"
	"sync"
)

type Route struct {
	Prefix  string // 路径前缀,如 "/api/users"
	Backend string // 后端地址,如 "http://127.0.0.1:8081"
}

type Router struct {
	mu     sync.RWMutex
	routes []Route
}

func NewRouter() *Router {
	return &Router{
		routes: []Route{
			{Prefix: "/api/users", Backend: "http://127.0.0.1:8081"},
			{Prefix: "/api/orders", Backend: "http://127.0.0.1:8082"},
		},
	}
}

func (r *Router) Match(path string) *url.URL {
	r.mu.RLock()
	defer r.mu.RUnlock()

	for _, route := range r.routes {
		if len(path) >= len(route.Prefix) && path[:len(route.Prefix)] == route.Prefix {
			u, _ := url.Parse(route.Backend)
			return u
		}
	}
	return nil
}

Routersync.RWMutex 保护路由表,支持并发读取和独占写入。Match 按顺序遍历路由表,找到第一个匹配的前缀就返回对应的后端地址。

前缀匹配的顺序很重要。如果把 / 放在最前面,所有请求都会匹配到它,后面的规则永远不会生效。通常按前缀长度从长到短排序,或者让更具体的规则排在前面。

二、代理改造

把固定代理改成按路由表匹配:

go
func main() {
	router := NewRouter()

	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		target := router.Match(r.URL.Path)
		if target == nil {
			http.Error(w, "no route matched", http.StatusNotFound)
			return
		}

		proxy := httputil.NewSingleHostReverseProxy(target)
		// ... 设置 Director、ErrorHandler

		proxy.ServeHTTP(w, r)
	})

	http.ListenAndServe(":8080", handler)
}

每次请求都创建新的 ReverseProxy 实例,这在性能上不是最优的——创建代理对象有开销。更优的做法是缓存代理实例,或者自己实现 Director 来动态修改目标地址。当前版本为了代码清晰,先保持简单。

三、管理 API

网关需要提供一个管理接口,让运维人员查看当前路由配置。管理 API 和代理服务可以跑在同一个端口上(用路径区分),也可以分开端口。分开端口更安全——管理 API 不暴露在公网,只在内网访问。

本章先跑在同一个端口上,后续再加端口分离和认证。

go
func (r *Router) ListRoutes() []Route {
	r.mu.RLock()
	defer r.mu.RUnlock()

	// 返回副本,避免调用方修改内部状态
	result := make([]Route, len(r.routes))
	copy(result, r.routes)
	return result
}

注册管理路由:

go mux

	// 代理转发
	mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
		// 管理 API 路径不走代理
		if req.URL.Path == "/admin/routes" {
			http.Error(w, "not found", http.StatusNotFound)
			return
		}
		target := router.Match(req.URL.Path)
		// ... 代理转发逻辑
	})

	// 管理 API
	mux.HandleFunc("/admin/routes", func(w http.ResponseWriter, req *http.Request) {
		if req.Method != http.MethodGet {
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
			return
		}
		routes := router.ListRoutes()
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(routes)
	})

	http.ListenAndServe(":8080", mux)

http.NewServeMux 是路由表,按精确路径匹配请求。/admin/routes 走管理 API,其他路径走代理转发。ServeMux 不支持前缀匹配,它只匹配完整路径。

四、验证

启动两个后端服务:

bash
# 用户服务
cat > backend1.go <<'EOF'
package main
import ("fmt"; "net/http")
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "user service: %s\n", r.URL.Path)
    })
    http.ListenAndServe(":8081", nil)
}
EOF
go run backend1.go

# 订单服务
cat > backend2.go <<'EOF'
package main
import ("fmt"; "net/http")
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "order service: %s\n", r.URL.Path)
    })
    http.ListenAndServe(":8082", nil)
}
EOF
go run backend2.go

测试代理:

bash
curl http://127.0.0.1:8080/api/users/list
# user service: /api/users/list

curl http://127.0.0.1:8080/api/orders/123
# order service: /api/orders/123

curl http://127.0.0.1:8080/admin/routes
# [{"Prefix":"/api/users","Backend":"http://127.0.0.1:8081"},...]

五、路由设计注意事项

问题处理
多个前缀重叠按前缀长度从长到短排序,或按注册顺序匹配第一个
没有匹配的路由返回 404,或设置默认后端
后端地址无效解析 URL 时检查格式,启动时校验连通性
路由表并发修改sync.RWMutex 保护读写

当前路由表是内存中的硬编码数据。下一讲会引入配置文件,让路由规则可以从文件加载和动态更新。

六、常见错误

前缀匹配顺序错误

go
routes := []Route{
	{Prefix: "/", Backend: "http://default"},
	{Prefix: "/api", Backend: "http://api"},
}

/api/users 会匹配到 /,因为 / 排在前面。应该把更具体的前缀排在前面,或按长度排序。

不加锁并发读写路由表

go
// goroutine A
route := router.Match("/api/users")

// goroutine B
router.routes = append(router.routes, newRoute)

并发读写切片会触发数据竞争,可能导致不可预期的结果。sync.RWMutexsync.Map 是必须的。

返回内部状态引用

go
func (r *Router) ListRoutes() []Route {
	return r.routes // 返回了内部切片的引用
}

调用方可以修改返回的切片,影响 Router 的内部状态。应该返回副本。