把前面四篇的东西缝合起来:一个「从需求到 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 targets | cmd/ + internal/ | 目录即包,internal 强制隔离 |
| 抽象接口 | 抽象基类/接口类 | interface{} 接口(store.URLStore) | 鸭子类型式,一个接口多个实现 |
| 短码生成 | 自写 base62 | 自写(本项目实现) | 逻辑两端一致,关注冲突处理 |
| 重定向 | 手写响应头 Location | http.Redirect(w, r, url, 302) | 标准库一行 |
| 并发安全 | std::mutex / std::atomic | sync.RWMutex + map | 读写锁分离,并发读不死锁 |
| 单元测试 | gtest/test framework | testing + go test | table-driven 是惯例 |
| 构建 | CMake/ninja | go 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:
POST /api/shorten:收 JSON{"url":"https://..."},返回{"short":"/AbC3x9"};GET /{code}:用短码找到长链接,302 Found重定向;- 跳转时给短码访问计数 +1(MVP 用内存 map,注明日后可落库);
- 短码不冲突、长度固定 6 位、字符集用 base62(数字+大小写字母,URL 安全);
- 未知短码返回 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
测试要点小结:
httptest.NewRequest+httptest.NewRecorder全内存,不占端口、毫秒级跑完;- 状态码 + 响应体 + header(
Location)三样都要断言,别只查 code; - table-driven(表驱动)适合「一组输入期望一组输出」的用例;
- store 内存版测试同样可以加(
TestMemoryStoreConcurrent:并发Incr100 次,最终计数准确——验证 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 换成数据库,接口都不用动:
- 用 04-数据库接入-database-sql-gorm-sqlx 的
database/sql建SQLStore实现URLStore; - 你正在学的达梦 DM8 有 Go 驱动,短链表结构用标准 SQL 建,
Save/Get/Incr改成INSERT/SELECT/UPDATE语句即可——handler 层零改动; - 计数定时刷盘、短码换
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
六、延伸与参考
- Go 官方工程布局建议(internal/ 的意义):https://go.dev/doc/modules/layout
testing包文档(含 httptest):https://pkg.go.dev/net/http/httptest- Docker 官方 Go 指南(多阶段构建):https://docs.docker.com/language/golang/
- 真实短链服务参考:可在 GitHub 搜 “url shortener” 找开源实现,重点读它们的存储层抽象
- 相关笔记:01-CLI工具-flag与cobra · 02-原生HTTP服务-net-http · 04-数据库接入-database-sql-gorm-sqlx · 01-goroutine
豫公网安备41108202000982号