第5个项目 - URL短链接服务
嘿,朋友们!我是长安。
今天我们要做的第五个实战项目是URL短链接服务。这是一个类似于bit.ly的服务,可以将长URL转换为短链接,方便分享和统计。
说实话,短链接服务看似简单,但涉及很多技术要点:分布式ID生成、Redis缓存、高并发处理、数据统计等。我当年面试的时候被问到过这个问题,如何设计一个短链接系统。这是一个典型的高并发场景实战!
🎯 项目目标
实现一个完整的短链接服务,支持以下功能:
| 功能 | 说明 | 技术点 |
|---|---|---|
| 生成短链接 | 将长URL转换为短码 | Base62编码、雪花算法 |
| 链接跳转 | 短链接重定向到原始URL | HTTP重定向、缓存 |
| 访问统计 | 记录访问次数、来源 | Redis计数器 |
| 链接管理 | 查看、删除自己的链接 | CRUD操作 |
| 过期时间 | 设置链接有效期 | TTL管理 |
📁 项目结构
url-shortener/
├── main.go # 程序入口
├── config/
│ └── config.go # 配置管理
├── models/
│ └── url.go # URL数据模型
├── services/
│ ├── shortener.go # 短链接服务
│ └── snowflake.go # ID生成器
├── handlers/
│ └── url.go # HTTP处理器
├── storage/
│ ├── mysql.go # MySQL存储
│ └── redis.go # Redis缓存
├── middleware/
│ └── ratelimit.go # 限流中间件
└── go.mod
🚀 第一步:项目初始化
# 创建项目目录
mkdir url-shortener
cd url-shortener
# 初始化Go模块
go mod init github.com/yourusername/url-shortener
# 安装依赖
go get -u github.com/gin-gonic/gin
go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql
go get -u github.com/go-redis/redis/v8
go get -u github.com/bwmarrin/snowflake
# 创建目录结构
mkdir config models services handlers storage middleware
📦 第二步:配置管理
文件: config/config.go
package config
import (
"os"
)
// Config 应用配置
type Config struct {
Server ServerConfig
MySQL MySQLConfig
Redis RedisConfig
Domain string // 短链接域名
CodeLen int // 短码长度
}
type ServerConfig struct {
Port string
}
type MySQLConfig struct {
Host string
Port string
User string
Password string
Database string
}
type RedisConfig struct {
Host string
Port string
Password string
DB int
}
// LoadConfig 加载配置
func LoadConfig() *Config {
return &Config{
Server: ServerConfig{
Port: getEnv("SERVER_PORT", "8080"),
},
MySQL: MySQLConfig{
Host: getEnv("MYSQL_HOST", "localhost"),
Port: getEnv("MYSQL_PORT", "3306"),
User: getEnv("MYSQL_USER", "root"),
Password: getEnv("MYSQL_PASSWORD", ""),
Database: getEnv("MYSQL_DATABASE", "url_shortener"),
},
Redis: RedisConfig{
Host: getEnv("REDIS_HOST", "localhost"),
Port: getEnv("REDIS_PORT", "6379"),
Password: getEnv("REDIS_PASSWORD", ""),
DB: 0,
},
Domain: getEnv("DOMAIN", "http://localhost:8080"),
CodeLen: 6,
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
📊 第三步:数据模型
文件: models/url.go
package models
import (
"time"
"gorm.io/gorm"
)
// URL 短链接模型
type URL struct {
ID uint `gorm:"primaryKey" json:"id"`
ShortCode string `gorm:"uniqueIndex;size:10" json:"short_code"`
OriginalURL string `gorm:"type:text;not null" json:"original_url"`
Clicks int64 `gorm:"default:0" json:"clicks"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// CreateURLRequest 创建短链接请求
type CreateURLRequest struct {
URL string `json:"url" binding:"required,url"`
ExpiresIn int `json:"expires_in"` // 过期时间(秒),0表示永不过期
}
// CreateURLResponse 创建短链接响应
type CreateURLResponse struct {
ShortCode string `json:"short_code"`
ShortURL string `json:"short_url"`
LongURL string `json:"long_url"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// URLStats 链接统计
type URLStats struct {
ShortCode string `json:"short_code"`
ShortURL string `json:"short_url"`
OriginalURL string `json:"original_url"`
Clicks int64 `json:"clicks"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// IsExpired 检查是否过期
func (u *URL) IsExpired() bool {
if u.ExpiresAt == nil {
return false
}
return time.Now().After(*u.ExpiresAt)
}
🔢 第四步:ID生成器(雪花算法)
文件: services/snowflake.go
package services
import (
"github.com/bwmarrin/snowflake"
"log"
)
var node *snowflake.Node
// InitSnowflake 初始化雪花算法
func InitSnowflake(nodeID int64) error {
var err error
node, err = snowflake.NewNode(nodeID)
if err != nil {
return err
}
log.Printf("雪花算法初始化成功, Node ID: %d", nodeID)
return nil
}
// GenerateID 生成唯一ID
func GenerateID() int64 {
return node.Generate().Int64()
}
🔗 第五步:短链接服务
文件: services/shortener.go
package services
import (
"fmt"
"math"
"strings"
)
const (
// Base62字符集
base62Chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
)
// Base62Encode 将数字编码为Base62
func Base62Encode(num int64) string {
if num == 0 {
return string(base62Chars[0])
}
var result strings.Builder
base := int64(len(base62Chars))
for num > 0 {
remainder := num % base
result.WriteByte(base62Chars[remainder])
num = num / base
}
// 反转字符串
encoded := result.String()
runes := []rune(encoded)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
// Base62Decode 将Base62解码为数字
func Base62Decode(encoded string) (int64, error) {
var num int64
base := int64(len(base62Chars))
for i, char := range encoded {
pos := strings.IndexRune(base62Chars, char)
if pos == -1 {
return 0, fmt.Errorf("invalid character: %c", char)
}
power := len(encoded) - i - 1
num += int64(pos) * int64(math.Pow(float64(base), float64(power)))
}
return num, nil
}
// GenerateShortCode 生成短码
func GenerateShortCode() string {
id := GenerateID()
return Base62Encode(id)
}
💾 第六步:存储层
文件: storage/mysql.go
package storage
import (
"fmt"
"time"
"github.com/yourusername/url-shortener/config"
"github.com/yourusername/url-shortener/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type MySQLStorage struct {
db *gorm.DB
}
// NewMySQLStorage 创建MySQL存储
func NewMySQLStorage(cfg config.MySQLConfig) (*MySQLStorage, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
return nil, err
}
// 自动迁移
if err := db.AutoMigrate(&models.URL{}); err != nil {
return nil, err
}
return &MySQLStorage{db: db}, nil
}
// Create 创建短链接
func (s *MySQLStorage) Create(url *models.URL) error {
return s.db.Create(url).Error
}
// GetByShortCode 根据短码查询
func (s *MySQLStorage) GetByShortCode(code string) (*models.URL, error) {
var url models.URL
err := s.db.Where("short_code = ?", code).First(&url).Error
return &url, err
}
// GetByOriginalURL 根据原始URL查询
func (s *MySQLStorage) GetByOriginalURL(originalURL string) (*models.URL, error) {
var url models.URL
err := s.db.Where("original_url = ?", originalURL).First(&url).Error
return &url, err
}
// IncrementClicks 增加点击次数
func (s *MySQLStorage) IncrementClicks(code string) error {
return s.db.Model(&models.URL{}).
Where("short_code = ?", code).
UpdateColumn("clicks", gorm.Expr("clicks + ?", 1)).
Error
}
// Delete 删除短链接
func (s *MySQLStorage) Delete(code string) error {
return s.db.Where("short_code = ?", code).Delete(&models.URL{}).Error
}
// GetAll 获取所有链接(分页)
func (s *MySQLStorage) GetAll(page, pageSize int) ([]models.URL, int64, error) {
var urls []models.URL
var total int64
offset := (page - 1) * pageSize
if err := s.db.Model(&models.URL{}).Count(&total).Error; err != nil {
return nil, 0, err
}
err := s.db.Offset(offset).Limit(pageSize).
Order("created_at desc").
Find(&urls).Error
return urls, total, err
}
文件: storage/redis.go
package storage
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/go-redis/redis/v8"
"github.com/yourusername/url-shortener/config"
"github.com/yourusername/url-shortener/models"
)
type RedisStorage struct {
client *redis.Client
ctx context.Context
}
// NewRedisStorage 创建Redis存储
func NewRedisStorage(cfg config.RedisConfig) *RedisStorage {
client := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port),
Password: cfg.Password,
DB: cfg.DB,
})
ctx := context.Background()
// 测试连接
if err := client.Ping(ctx).Err(); err != nil {
panic(fmt.Sprintf("Redis连接失败: %v", err))
}
return &RedisStorage{
client: client,
ctx: ctx,
}
}
// Set 缓存URL
func (r *RedisStorage) Set(code string, url *models.URL, expiration time.Duration) error {
data, err := json.Marshal(url)
if err != nil {
return err
}
key := fmt.Sprintf("url:%s", code)
return r.client.Set(r.ctx, key, data, expiration).Err()
}
// Get 获取缓存的URL
func (r *RedisStorage) Get(code string) (*models.URL, error) {
key := fmt.Sprintf("url:%s", code)
data, err := r.client.Get(r.ctx, key).Bytes()
if err != nil {
return nil, err
}
var url models.URL
if err := json.Unmarshal(data, &url); err != nil {
return nil, err
}
return &url, nil
}
// Delete 删除缓存
func (r *RedisStorage) Delete(code string) error {
key := fmt.Sprintf("url:%s", code)
return r.client.Del(r.ctx, key).Err()
}
// IncrementClicks 增加点击计数
func (r *RedisStorage) IncrementClicks(code string) error {
key := fmt.Sprintf("clicks:%s", code)
return r.client.Incr(r.ctx, key).Err()
}
// GetClicks 获取点击次数
func (r *RedisStorage) GetClicks(code string) (int64, error) {
key := fmt.Sprintf("clicks:%s", code)
return r.client.Get(r.ctx, key).Int64()
}
🎮 第七步:HTTP处理器
文件: handlers/url.go
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/yourusername/url-shortener/config"
"github.com/yourusername/url-shortener/models"
"github.com/yourusername/url-shortener/services"
"github.com/yourusername/url-shortener/storage"
"gorm.io/gorm"
)
type URLHandler struct {
mysql *storage.MySQLStorage
redis *storage.RedisStorage
config *config.Config
}
func NewURLHandler(mysql *storage.MySQLStorage, redis *storage.RedisStorage, cfg *config.Config) *URLHandler {
return &URLHandler{
mysql: mysql,
redis: redis,
config: cfg,
}
}
// CreateShortURL 创建短链接
// POST /api/shorten
func (h *URLHandler) CreateShortURL(c *gin.Context) {
var req models.CreateURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "参数错误: " + err.Error(),
})
return
}
// 检查URL是否已存在
existingURL, err := h.mysql.GetByOriginalURL(req.URL)
if err == nil && !existingURL.IsExpired() {
// URL已存在且未过期,直接返回
c.JSON(http.StatusOK, models.CreateURLResponse{
ShortCode: existingURL.ShortCode,
ShortURL: h.config.Domain + "/" + existingURL.ShortCode,
LongURL: existingURL.OriginalURL,
})
return
}
// 生成短码
shortCode := services.GenerateShortCode()
// 创建URL记录
url := &models.URL{
ShortCode: shortCode,
OriginalURL: req.URL,
}
// 设置过期时间
if req.ExpiresIn > 0 {
expiresAt := time.Now().Add(time.Duration(req.ExpiresIn) * time.Second)
url.ExpiresAt = &expiresAt
}
// 保存到数据库
if err := h.mysql.Create(url); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "创建失败",
})
return
}
// 缓存到Redis
cacheDuration := 24 * time.Hour
if url.ExpiresAt != nil {
cacheDuration = time.Until(*url.ExpiresAt)
}
h.redis.Set(shortCode, url, cacheDuration)
// 返回响应
response := models.CreateURLResponse{
ShortCode: shortCode,
ShortURL: h.config.Domain + "/" + shortCode,
LongURL: url.OriginalURL,
}
if url.ExpiresAt != nil {
response.ExpiresAt = url.ExpiresAt.Format(time.RFC3339)
}
c.JSON(http.StatusCreated, response)
}
// Redirect 短链接跳转
// GET /:code
func (h *URLHandler) Redirect(c *gin.Context) {
code := c.Param("code")
// 先从Redis获取
url, err := h.redis.Get(code)
if err != nil {
// Redis未命中,从MySQL获取
url, err = h.mysql.GetByShortCode(code)
if err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{
"error": "短链接不存在",
})
} else {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "查询失败",
})
}
return
}
// 缓存到Redis
cacheDuration := 24 * time.Hour
if url.ExpiresAt != nil {
cacheDuration = time.Until(*url.ExpiresAt)
}
h.redis.Set(code, url, cacheDuration)
}
// 检查是否过期
if url.IsExpired() {
c.JSON(http.StatusGone, gin.H{
"error": "短链接已过期",
})
return
}
// 异步增加点击次数
go func() {
h.redis.IncrementClicks(code)
h.mysql.IncrementClicks(code)
}()
// 重定向
c.Redirect(http.StatusMovedPermanently, url.OriginalURL)
}
// GetStats 获取链接统计
// GET /api/stats/:code
func (h *URLHandler) GetStats(c *gin.Context) {
code := c.Param("code")
url, err := h.mysql.GetByShortCode(code)
if err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{
"error": "短链接不存在",
})
} else {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "查询失败",
})
}
return
}
stats := models.URLStats{
ShortCode: url.ShortCode,
ShortURL: h.config.Domain + "/" + url.ShortCode,
OriginalURL: url.OriginalURL,
Clicks: url.Clicks,
CreatedAt: url.CreatedAt,
}
if url.ExpiresAt != nil {
stats.ExpiresAt = url.ExpiresAt.Format(time.RFC3339)
}
c.JSON(http.StatusOK, stats)
}
// GetAllURLs 获取所有链接
// GET /api/urls?page=1&page_size=10
func (h *URLHandler) GetAllURLs(c *gin.Context) {
page := 1
pageSize := 10
if p, ok := c.GetQuery("page"); ok {
if parsed, err := time.ParseDuration(p); err == nil {
page = int(parsed)
}
}
if ps, ok := c.GetQuery("page_size"); ok {
if parsed, err := time.ParseDuration(ps); err == nil {
pageSize = int(parsed)
}
}
urls, total, err := h.mysql.GetAll(page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "查询失败",
})
return
}
c.JSON(http.StatusOK, gin.H{
"data": urls,
"total": total,
"page": page,
"page_size": pageSize,
"total_page": (total + int64(pageSize) - 1) / int64(pageSize),
})
}
// DeleteURL 删除短链接
// DELETE /api/urls/:code
func (h *URLHandler) DeleteURL(c *gin.Context) {
code := c.Param("code")
// 从MySQL删除
if err := h.mysql.Delete(code); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "删除失败",
})
return
}
// 从Redis删除
h.redis.Delete(code)
c.JSON(http.StatusOK, gin.H{
"message": "删除成功",
})
}
🚦 第八步:限流中间件
文件: middleware/ratelimit.go
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// RateLimiter 简单的限流器
type RateLimiter struct {
requests map[string]*requestInfo
mu sync.Mutex
limit int // 限制请求数
window time.Duration // 时间窗口
}
type requestInfo struct {
count int
resetTime time.Time
}
// NewRateLimiter 创建限流器
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
requests: make(map[string]*requestInfo),
limit: limit,
window: window,
}
// 定期清理过期记录
go rl.cleanup()
return rl
}
// Middleware 限流中间件
func (rl *RateLimiter) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
info, exists := rl.requests[ip]
if !exists || now.After(info.resetTime) {
// 新的时间窗口
rl.requests[ip] = &requestInfo{
count: 1,
resetTime: now.Add(rl.window),
}
c.Next()
return
}
if info.count >= rl.limit {
// 超过限制
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "请求过于频繁,请稍后再试",
})
c.Abort()
return
}
info.count++
c.Next()
}
}
// cleanup 清理过期记录
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
now := time.Now()
for ip, info := range rl.requests {
if now.After(info.resetTime) {
delete(rl.requests, ip)
}
}
rl.mu.Unlock()
}
}
🌐 第九步:主程序
文件: main.go
package main
import (
"log"
"github.com/gin-gonic/gin"
"github.com/yourusername/url-shortener/config"
"github.com/yourusername/url-shortener/handlers"
"github.com/yourusername/url-shortener/middleware"
"github.com/yourusername/url-shortener/services"
"github.com/yourusername/url-shortener/storage"
"time"
)
func main() {
// 加载配置
cfg := config.LoadConfig()
// 初始化雪花算法
if err := services.InitSnowflake(1); err != nil {
log.Fatalf("初始化雪花算法失败: %v", err)
}
// 初始化MySQL
mysql, err := storage.NewMySQLStorage(cfg.MySQL)
if err != nil {
log.Fatalf("连接MySQL失败: %v", err)
}
log.Println("✅ MySQL连接成功")
// 初始化Redis
redis := storage.NewRedisStorage(cfg.Redis)
log.Println("✅ Redis连接成功")
// 创建处理器
urlHandler := handlers.NewURLHandler(mysql, redis, cfg)
// 创建Gin引擎
router := gin.Default()
// 限流中间件(每分钟100次请求)
rateLimiter := middleware.NewRateLimiter(100, time.Minute)
router.Use(rateLimiter.Middleware())
// API路由
api := router.Group("/api")
{
api.POST("/shorten", urlHandler.CreateShortURL)
api.GET("/stats/:code", urlHandler.GetStats)
api.GET("/urls", urlHandler.GetAllURLs)
api.DELETE("/urls/:code", urlHandler.DeleteURL)
}
// 短链接跳转
router.GET("/:code", urlHandler.Redirect)
// 健康检查
router.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
})
})
// 启动服务器
log.Printf("🚀 服务器启动在 http://localhost:%s", cfg.Server.Port)
if err := router.Run(":" + cfg.Server.Port); err != nil {
log.Fatal(err)
}
}
🧪 第十步:测试
1. 准备环境
# 启动MySQL
docker run -d --name mysql \
-e MYSQL_ROOT_PASSWORD=root \
-e MYSQL_DATABASE=url_shortener \
-p 3306:3306 \
mysql:8.0
# 启动Redis
docker run -d --name redis \
-p 6379:6379 \
redis:7
# 或者直接安装MySQL和Redis
2. 运行程序
go run main.go
3. 测试API
# 1. 创建短链接
curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \
-d '{"url":"https://github.com/golang/go"}'
# 响应:
# {
# "short_code": "a1B2c3",
# "short_url": "http://localhost:8080/a1B2c3",
# "long_url": "https://github.com/golang/go"
# }
# 2. 访问短链接
curl -L http://localhost:8080/a1B2c3
# 3. 查看统计
curl http://localhost:8080/api/stats/a1B2c3
# 4. 创建带过期时间的短链接(7天)
curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com","expires_in":604800}'
# 5. 删除短链接
curl -X DELETE http://localhost:8080/api/urls/a1B2c3
💡 扩展思考
1. 自定义短码
允许用户自定义短码:
type CreateURLRequest struct {
URL string `json:"url" binding:"required,url"`
CustomCode string `json:"custom_code"`
ExpiresIn int `json:"expires_in"`
}
2. 访问分析
记录更详细的访问信息:
type ClickLog struct {
URLCode string
IP string
UserAgent string
Referer string
CreatedAt time.Time
}
3. QR码生成
为短链接生成二维码:
go get -u github.com/skip2/go-qrcode
import "github.com/skip2/go-qrcode"
func GenerateQRCode(url string) ([]byte, error) {
return qrcode.Encode(url, qrcode.Medium, 256)
}
4. 链接预览
抓取原始URL的标题和描述:
import "github.com/PuerkitoBio/goquery"
func FetchMetadata(url string) (*Metadata, error) {
// 抓取并解析HTML
// 提取title, description, og:image等
}
💪 练习题
- 实现短链接的访问密码保护功能
- 添加链接分类和标签功能
- 实现短链接的批量导入导出
- 添加链接的访问地理位置统计
💡 参考答案
// 密码保护示例
type URL struct {
// ... existing fields ...
Password string `json:"-"` // 访问密码
}
func (h *URLHandler) Redirect(c *gin.Context) {
code := c.Param("code")
password := c.Query("password")
url, err := h.mysql.GetByShortCode(code)
if err != nil {
c.JSON(404, gin.H{"error": "链接不存在"})
return
}
// 检查密码
if url.Password != "" && url.Password != password {
c.JSON(401, gin.H{"error": "密码错误"})
return
}
// 继续跳转...
}
🎯 小结
恭喜你完成了第五个实战项目!通过这个项目,你学会了:
✅ 分布式ID生成(雪花算法)
✅ Base62编码算法
✅ Redis缓存策略
✅ MySQL数据持久化
✅ 高并发场景处理
✅ 限流中间件实现
下一个项目是最后一个综合项目——完整的博客系统!
下一步: 第6个项目 - 完整博客系统 →
💬 遇到问题了吗?
- 确保MySQL和Redis已正确安装
- 检查数据库连接配置
- 注意并发安全问题
- 访问 GORM文档 了解更多
