把前面四篇的东西缝合起来:一个「从需求到 Docker 部署」的完整短链接服务,走一遍 Go 工程化的真实流程


一、简述

短链接服务是一个完美的「毕业设计」:需求明确、覆盖路由/存储/并发/测试/构建全链路、不引入任何第三方 Web 框架也能跑(正适合巩固 net/http)。本文按真实开发顺序走:需求分析 → 目录设计 → 存储接口 → 短码生成 → HTTP 路由 → 计数 → 单元测试 → go mod → 构建与 Docker。

核心要点:这个项目刻意只用标准库写 HTTP(把 02-原生HTTP服务-net-http 练扎实),存储接口先给内存实现、日后可平移到 04-数据库接入-database-sql-gorm-sqlx 的 database/sql/gorm。它演示的是 Go 工程化的结构:cmd/ 装入口、internal/ 装业务、store 是抽象边界。


二、C++ 对照速查表

概念C++Go关键差异
工程布局src/ + CMake targetscmd/ + internal/目录即包,internal 强制隔离
抽象接口抽象基类/接口类interface{} 接口(store.URLStore)鸭子类型式,一个接口多个实现
短码生成自写 base62自写(本项目实现)逻辑两端一致,关注冲突处理
重定向手写响应头 Locationhttp.Redirect(w, r, url, 302)标准库一行
并发安全std::mutex / std::atomicsync.RWMutex + map读写锁分离,并发读不死锁
单元测试gtest/test frameworktesting + go testtable-driven 是惯例
构建CMake/ninjago build ./cmd/server单二进制,静态链接
容器化手写 Dockerfile 依赖编译环境golang builder 镜像 + 多阶段官方镜像成熟,CGO 关掉即纯静态

三、逐主题详解

本项目按「需求 → 结构 → 代码 → 测试 → 构建 → 部署」的顺序推进,每一小步都有可直接运行的代码片段。

3.1 需求分析

**短链接服务(shortlink)**的核心场景:把 https://a-very-long-url-with-lots-of-params?id=... 缩短成 http://localhost:8080/AbC3x9,点击短链时 302 跳回原长链接。

需求拆成 MVP:

  1. POST /api/shorten:收 JSON {"url":"https://..."},返回 {"short":"/AbC3x9"};
  2. GET /{code}:用短码找到长链接,302 Found 重定向;
  3. 跳转时给短码访问计数 +1(MVP 用内存 map,注明日后可落库);
  4. 短码不冲突、长度固定 6 位、字符集用 base62(数字+大小写字母,URL 安全);
  5. 未知短码返回 404。

技术约束(刻意选):只用标准库,无框架;存储先内存版(进程重启即丢失,MVP 可接受,接口留好换数据库的缝)。

C++ 对照:这就是典型「C++ 实战课不教、但真实服务每天都见」的项目。C++ 拿 Boost.Beast + 自写 store 也能做,但你会明显感受到初始化/绑定/连接池要写多少胶水——Go 版下面每一步都是标准答案。


3.2 目录结构与 go mod

shortlink/                  # 项目根
├── go.mod                  # 模块文件(本机执行 go mod init shortlink)
├── cmd/
│   └── server/
│       └── main.go         # 入口:装配 Store、路由、监听
├── internal/               # 不对外导出(别的模块 import 不了,强制封装)
│   ├── handler/
│   │   ├── handler.go      # HTTP 处理器(路由注册、Create、Redirect)
│   │   ├── handler_test.go # 单测
│   │   └── codec.go        # 短码生成
│   └── store/
│       ├── store.go        # URLStore 接口(抽象边界)
│       └── memory.go       # 内存实现
└── Dockerfile              # 多阶段构建
mkdir -p shortlink/cmd/server shortlink/internal/handler shortlink/internal/store
cd shortlink
go mod init shortlink

为什么是 cmd/ + internal/:这是 Go 官方推荐的标准工程布局。cmd/ 下每个子目录是一个可执行程序入口;internal/ 里的包只能被本模块引用(外部模块 import 会编译报错),从语言层面封住「意外被当库用」的路径。对应 C++ 的「src/ + include/ 分离 + 私有头文件纪律」。


3.3 核心代码实现

3.3.1 存储接口(internal/store/store.go)

存储是系统里最容易换的部分(内存 → MySQL → 达梦),所以先定接口:

package store

// URLStore 定义短链接存储的动作集。
// 提供内存实现 MemoryStore;将来可用 database/sql 实现 SQLStore 平替。
type URLStore interface {
	Save(short, long string) error              // 保存一对映射
	Get(short string) (string, bool)            // 取长链接;不存在返回 ok=false
	Exists(short string) bool                   // 短码是否已存在(生成冲突检测用)
	Incr(short string) int                      // 访问计数 +1,返回最新计数
	Count(short string) int                     // 读计数(只读,健康检查/调试用)
}

3.3.2 内存实现(internal/store/memory.go)

内存版用两个 map + RWMutex。并发安全是重点——每个 HTTP 请求一个 goroutine,map 并发读写会直接 panic(详见 01-goroutine 的并发纪律):

package store

import "sync"

// MemoryStore 是 URLStore 的内存实现:并发安全、进程内生效。
type MemoryStore struct {
	mu     sync.RWMutex
	urls   map[string]string // short -> long
	counts map[string]int    // short -> 访问次数
}

var _ URLStore = (*MemoryStore)(nil) // 编译期断言:确保 MemoryStore 实现了接口

func NewMemoryStore() *MemoryStore {
	return &MemoryStore{
		urls:   make(map[string]string),
		counts: make(map[string]int),
	}
}

func (s *MemoryStore) Save(short, long string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.urls[short] = long
	if _, ok := s.counts[short]; !ok {
		s.counts[short] = 0
	}
	return nil
}

func (s *MemoryStore) Get(short string) (string, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	long, ok := s.urls[short]
	return long, ok
}

func (s *MemoryStore) Exists(short string) bool {
	s.mu.RLock()
	defer s.mu.RUnlock()
	_, ok := s.urls[short]
	return ok
}

func (s *MemoryStore) Incr(short string) int {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.counts[short]++
	return s.counts[short]
}

func (s *MemoryStore) Count(short string) int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.counts[short]
}

⚠️ 关键设计:Get/Exists/Count 用 RLock(读多),Save/Incr 用 Lock(写少)。读写锁显著提升并发读吞吐,这是并发 store 的标配姿势。

3.3.3 短码生成(internal/handler/codec.go)

短码要短、要 URL 安全、要够随机。base62 字符集是行业标准:

package handler

import "math/rand"

// base62 字符集:数字 + 小写 + 大写。62^6 = 56 亿种组合,冲突概率可接受。
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

// genCode 生成长度为 6 的随机短码。
// Go 1.20+ 全局 rand 自动播种,无需手动 rand.Seed。
func genCode() string {
	const n = 6
	b := make([]byte, n)
	for i := range b {
		b[i] = alphabet[rand.Intn(len(alphabet))]
	}
	return string(b)
}

⚠️ math/rand 的 Intn 不保证加密强度,但它快、够用,适合短码这种「低价值、只防碰撞」场景。若短码要抗猜测(比如带付费信息),要换 crypto/rand(见练习任务)。另外注意 rand.Intn(len(alphabet)) 的索引上限是字符集长度——别写越界。

3.3.4 HTTP 处理器(internal/handler/handler.go)

使用 Go 1.22 方法路由 GET /{code},处理器只依赖 store.URLStore 接口:

package handler

import (
	"encoding/json"
	"net/http"
	"strings"

	"shortlink/internal/store"
)

// Server 持有 store 引用,路由都挂在它上面。
type Server struct {
	store store.URLStore
}

// NewServer 构造函数:依赖注入 store。
func NewServer(s store.URLStore) *Server {
	return &Server{store: s}
}

// Routes 返回 http.Handler,serve 时代替裸 mux,方便测试复用。
func (s *Server) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /api/shorten", s.Create)   // 方法路由:只认 POST
	mux.HandleFunc("GET /{code}", s.Redirect)       // 通配符:GET /AbC3x9
	// 注意 /api/shorten 被 POST 限定,不会与 GET /{code} 的 GET 语义冲突
	return mux
}

// ----- 请求/响应结构体 -----

type createReq struct {
	URL string `json:"url"` // 必填长链接
}

type createResp struct {
	Short  string `json:"short"`  // 返回短码形式 /AbC3x9
	Long   string `json:"long"`   // 原链接回显
	Visits int    `json:"visits"` // 当前计数(新链接恒为 0)
}

// ----- 处理器 -----

// Create 处理 POST /api/shorten:收 URL,返回短码。
func (s *Server) Create(w http.ResponseWriter, r *http.Request) {
	var req createReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, `{"error":"bad json"}`, http.StatusBadRequest)
		return
	}
	if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
		http.Error(w, `{"error":"url must start with http(s)://"}`, http.StatusBadRequest)
		return
	}

	// 生成短码,冲突则重试(62^6 空间下几乎不重试)
	var code string
	for {
		code = genCode()
		if !s.store.Exists(code) {
			break
		}
	}
	if err := s.store.Save(code, req.URL); err != nil {
		http.Error(w, `{"error":"store save failed"}`, http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(createResp{Short: "/" + code, Long: req.URL})
}

// Redirect 处理 GET /{code}:查 store,302 跳转,并计数。
func (s *Server) Redirect(w http.ResponseWriter, r *http.Request) {
	// r.PathValue 是 Go 1.22 ServeMux 通配符取值方式
	code := strings.TrimPrefix(r.PathValue("code"), "/")
	long, ok := s.store.Get(code)
	if !ok {
		http.NotFound(w, r) // 404
		return
	}

	s.store.Incr(code) // 计数 +1(read path 的一个写操作)

	// 302 Found:浏览器/客户端跟随跳转
	http.Redirect(w, r, long, http.StatusFound)
}

C++ 对照:PathValue 对应 Crow/Drogon 的路由参数;http.Redirect(..., 302) 一行搞定 Location 头;而 C++ 版你要 resp.set(http::field::location, url) + 手写状态码。接口抽象的收益也体现在这里——handler 不关心 store 是内存还是数据库。

3.3.5 入口(cmd/server/main.go)

package main

import (
	"log"
	"net/http"
	"time"

	"shortlink/internal/handler"
	"shortlink/internal/store"
)

func main() {
	// 装配:store 目前是内存实现,换成 SQLStore 只改这一行
	s := store.NewMemoryStore()
	h := handler.NewServer(s)

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           h.Routes(),
		ReadHeaderTimeout: 5 * time.Second, // 生产级:显式施加超时
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	log.Printf("shortlink server listening on %s", srv.Addr)
	if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		log.Fatal(err)
	}
}

⚠️ 这个版本没做优雅关闭(为了聚焦),跑通后建议按 02-原生HTTP服务-net-http 的 3.6 补上 Shutdown + 信号监听——那是上生产的及格线。


3.4 单元测试要点(internal/handler/handler_test.go)

Go 的测试三件套:testing 包、httptest.NewRecorder 模拟响应、table-driven 表驱动。测试不真起端口:

package handler

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"shortlink/internal/store"
)

// 组装被测对象:真实 handler + 内存 store,全内存跑测试
func newTestServer() *Server {
	return NewServer(store.NewMemoryStore())
}

func TestCreateReturnsShortCode(t *testing.T) {
	h := newTestServer()

	body := strings.NewReader(`{"url":"https://example.com/very/long/path?q=1"}`)
	req := httptest.NewRequest(http.MethodPost, "/api/shorten", body)
	w := httptest.NewRecorder()

	h.Create(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("status = %d, want %d", w.Code, http.StatusCreated)
	}
	var resp createResp
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("bad json: %v", err)
	}
	if len(resp.Short) != 7 { // "/" + 6 位短码
		t.Fatalf("short = %q, want 7 chars", resp.Short)
	}
}

func TestCreateRejectsBadURL(t *testing.T) {
	h := newTestServer()

	req := httptest.NewRequest(http.MethodPost, "/api/shorten",
		strings.NewReader(`{"url":"javascript:alert(1)"}`))
	w := httptest.NewRecorder()

	h.Create(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", w.Code)
	}
}

func TestRedirectAndCount(t *testing.T) {
	h := newTestServer()
	const longURL = "https://example.com/page"

	// 先造一个短链
	req := httptest.NewRequest(http.MethodPost, "/api/shorten",
		strings.NewReader(`{"url":"`+longURL+`"}`))
	w := httptest.NewRecorder()
	h.Create(w, req)

	var resp createResp
	json.Unmarshal(w.Body.Bytes(), &resp)
	code := strings.TrimPrefix(resp.Short, "/")

	// 访问短链两次,验证 302 + 计数
	for i := 0; i < 2; i++ {
		r := httptest.NewRequest(http.MethodGet, "/"+code, nil)
		rec := httptest.NewRecorder()
		h.Redirect(rec, r)

		if rec.Code != http.StatusFound { // 302
			t.Fatalf("redirect status = %d, want 302", rec.Code)
		}
		if loc := rec.Header().Get("Location"); loc != longURL {
			t.Fatalf("Location = %q, want %q", loc, longURL)
		}
	}

	if n := h.store.Count(code); n != 2 {
		t.Fatalf("count = %d, want 2", n)
	}
}

func TestUnknownCodeIs404(t *testing.T) {
	h := newTestServer()

	r := httptest.NewRequest(http.MethodGet, "/NoSuchCode", nil)
	w := httptest.NewRecorder()
	h.Redirect(w, r)

	if w.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", w.Code)
	}
}
go test ./...
# ok  shortlink/internal/handler  0.031s

测试要点小结:

  1. httptest.NewRequest + httptest.NewRecorder 全内存,不占端口、毫秒级跑完;
  2. 状态码 + 响应体 + header(Location)三样都要断言,别只查 code;
  3. table-driven(表驱动)适合「一组输入期望一组输出」的用例;
  4. store 内存版测试同样可以加(TestMemoryStoreConcurrent:并发 Incr 100 次,最终计数准确——验证 RWMutex 有用)。

3.5 go mod 管理与构建

# 初始化(上面已做)
go mod init shortlink

# 拉依赖(本项目全标准库,实际会生成 go.sum 记录 checksum)
go mod tidy

# 构建:输出单一可执行文件
go build -o bin/shortlink ./cmd/server

# 静态检查 + 测试
go vet ./...
go test ./... -race    # -race 开着跑一遍,抓并发数据竞争(强烈推荐)

# 本地跑起来
./bin/shortlink
curl -X POST localhost:8080/api/shorten -d '{"url":"https://go.dev/doc"}' -H 'Content-Type: application/json'
# {"short":"/x3Kq9Z","long":"https://go.dev/doc","visits":0}
curl -L -v localhost:8080/x3Kq9Z   # 跟随 302 跳到 go.dev

go mod 的三个高频子命令:

命令作用
go mod tidy按代码 import 增删 go.mod 依赖并生成 go.sum
go mod download下载模块到本地缓存(Docker 构建分层用它)
go build ./...构建整个模块的所有包

3.6 Docker 部署简述

多阶段构建是最佳实践:builder 阶段用完整的 golang 镜像编译,runtime 阶段只拷贝产物(CGO 关掉即可纯静态):

# ---- 阶段 1:builder ----
FROM golang:1.23-alpine AS builder
WORKDIR /app

# 先只拷 go.mod/go.sum,利用 Docker 层缓存避免每次重下依赖
COPY go.mod go.sum ./
RUN go mod download

COPY . .
# CGO_ENABLED=0:编译纯静态二进制,可不带 glibc 跑在精简镜像
RUN CGO_ENABLED=0 GOOS=linux go build -o /shortlink ./cmd/server

# ---- 阶段 2:runtime ----
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /shortlink /usr/local/bin/shortlink
EXPOSE 8080
ENTRYPOINT ["shortlink"]
docker build -t shortlink:latest .
docker run --rm -p 8080:8080 shortlink:latest
# 再 curl 同一个接口即可验证容器内服务

要点:依赖层(COPY go.mod go.sum + RUN go mod download)单独成层,改代码不会让 Docker 重下依赖;CGO_ENABLED=0 关掉 C 依赖是 Go 服务容器化的招牌姿势——镜像小、启动快、无 glibc 版本地狱。这在 C++ 里等价于「静态链接 libstdc++/openssl,镜像从 2GB 瘦到 200MB」,但 Go 是内置能力。


3.7 后续毕业方向(承接 Database 区)

把内存 store 换成数据库,接口都不用动:

  1. 用 04-数据库接入-database-sql-gorm-sqlx 的 database/sql 建 SQLStore 实现 URLStore;
  2. 你正在学的达梦 DM8 有 Go 驱动,短链表结构用标准 SQL 建,Save/Get/Incr 改成 INSERT/SELECT/UPDATE 语句即可——handler 层零改动;
  3. 计数定时刷盘、短码换 crypto/rand 抗枚举、加过期时间(TTL)……都是加分项。

这就是 URLStore 接口的回报:业务不感知存储变脏了没。实战项目练到这一步,Go 的工程化闭环(结构 → 并发 → 测试 → 构建 → 部署)就算真正串起来了。


四、常见坑与误区

坑 1:map 并发读写直接 panic

  • 现象:fatal error: concurrent map writes,进程崩溃。
  • 原因:HTTP 每请求一个 goroutine,多个 goroutine 同时读写 map 是未定义行为。
  • 正确写法:sync.RWMutex 包住读写(见 3.3.2);或升级为 sync.Map/redis 等外部存储;go test -race 提前抓。

坑 2:http.Redirect 用的状态码语义记混

  • 现象:用 301,用户浏览器的历史记录里永远缓存了旧短链,改短链目标后用户还去旧地址。
  • 原因:301 Moved Permanently 会长期缓存;302 Found 是临时跳转。
  • 正确写法:短链接场景用 302(临时跳转),保持短码可维护;只有确定「永久有效」才考虑 301。

坑 3:短码冲突死循环 / 生成不随机

  • 现象:某段时间生成的短码老重复;或重启进程后一批短码和上次一模一样。
  • 原因:Go 1.20 之前全局 math/rand 不调 rand.Seed 时默认种子固定,每次运行产生相同序列;或字符集索引写错。
  • 正确写法:Go 1.20+ 全局 rand 自动播种,直接 rand.Intn(len(alphabet));冲突用 for { gen; if !Exists break } 兜底;需要加密级用 crypto/rand。

坑 4:Go 1.22 方法路由写成旧式 pattern

  • 现象:mux.HandleFunc("/{code}", ...),POST /api/shorten 请求也被分进这个 handler;或 PathValue("code") 取不到值。
  • 原因:/api/shorten 与 /{code} 都是单段,旧式 mux 只按路径前缀转发,方法限定与通配符是 1.22 特性。
  • 正确写法:明确写 "POST /api/shorten" 与 "GET /{code}" 方法前缀;确认 go.mod 里 go 1.22+。

坑 5:测试只查了状态码

  • 现象:TestRedirectAndCount 里只断言 302,Location 写错、body 写错都测不出来。
  • 原因:把「能跑」当「正确」。
  • 正确写法:状态码 + 关键 header(Location)+ 响应体三样都断言;必要时用 table-driven 覆盖边界(空 url、非 http 前缀、未知码)。

坑 6:defer rows.Close() 等资源释放漏在数据库版

  • 现象:换成 SQLStore 后连接池耗尽。
  • 原因:3.3.2 的 map 版没连接;一旦落库,QueryRow/rows 忘关就泄漏连接。
  • 正确写法:落库版本所有 rows 紧跟 defer rows.Close(),事务 defer tx.Rollback()(见 04-数据库接入-database-sql-gorm-sqlx 的坑列表)。

坑 7:构建时把不需要的目录/文件塞进镜像

  • 现象:镜像几百 MB、含测试代码与源码。
  • 原因:runtime 阶段直接 COPY . .。
  • 正确写法:多阶段 + COPY --from=builder 只拷编译产物;或 .dockerignore 排除 testdata/.git/bin。

五、练习任务

  • 完整实现本笔记的 shortlink 项目(store/memory + handler + main),curl 跑通 创建 → 302 → 计数
  • 把 go test -race ./... 跑起来,确认无数据竞争;故意去掉 Mutex 观察 panic 表现
  • 补 3 个测试用例:空 url 字段、{"url":""}、未登录的非法方法(如 PATCH /api/shorten)
  • 把短码生成换成 crypto/rand 版本,写出 genCodeSecure,讨论两者适用场景
  • 对照 C++ 重写:把 shortlink 用你熟悉的 C++ 方案(Boost.Beast/Crow + 内存 map + Mutex)重写一次,对比核心路由与存储代码行数;再用 Go 把「重定向 302、计数、404」三个行为写成表格清单逐项验证
  • 用 database/sql 写 SQLStore 实现同一 URLStore 接口,跑通后 handler 不改一行
  • 写 Dockerfile,构建并 docker run 验证容器内 curl 通过(有 Docker 环境再做)
  • 进阶:给短链加 30 天过期字段,跳转时若过期返回 410 Gone

六、延伸与参考