MDlog 是一个基于 Go 语言开发的现代化 Markdown 博客系统,专注于简单、高效、易部署的博客解决方案。系统支持实时搜索、分类导航、响应式设计,并实现了真正的单文件部署。
- 零依赖: 无需数据库,纯文件系统驱动
- 高性能: 内存缓存,毫秒级响应
- 易部署: 单文件部署,一键自动化
- 现代化: 响应式设计,移动端优化
- 开发友好: 热重载,Markdown 驱动
单一职责模块化设计:
internal/config/- 全局配置常量,避免硬编码internal/data/- 数据管理和缓存逻辑internal/markdown/- Markdown 解析专用模块internal/server/- Web 服务器和路由处理
内存优先策略:
- 启动时一次性加载所有文章到内存(适合中小型博客)
- 使用
sync.RWMutex保证并发安全 - 缓存文章内容、分类索引、搜索索引
Markdown 处理链:
// 使用 Goldmark + Chroma 组合
markdown := goldmark.New(
goldmark.WithExtensions(
extension.GFM, // GitHub Flavored Markdown
extension.Footnote, // 脚注支持
highlighting.NewHighlighting(
highlighting.WithStyle("github"),
highlighting.WithFormatOptions(),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(), // 自动生成标题ID
),
goldmark.WithRendererOptions(
html.WithHardWraps(), // 硬换行
html.WithXHTML(), // XHTML 兼容
),
)前端技术栈:
- 原生 JavaScript(无框架依赖,减少复杂度)
- CSS3 Flexbox/Grid(响应式布局)
- SVG 图标(矢量,内嵌,性能好)
关键实现:
//go:embed web
var EmbeddedAssets embed.FS
// 服务静态文件的关键代码
func (s *Server) setupStaticRoutes() {
// 嵌入的文件系统需要去掉前缀
webFS, _ := fs.Sub(s.assets, "web")
s.router.StaticFS("/static", http.FS(webFS))
}优势:
- 真正的单文件部署
- 避免静态文件丢失问题
- 版本一致性保证
问题描述: Gitee 的 Webhook 签名机制与 GitHub/GitLab 标准类似,但实现细节稍有不同。
技术分析:
- GitHub:
X-Hub-Signature-256: sha256=xxxxx(HMAC-SHA256) - Gitee:
X-Gitee-Token: xxxxx+X-Gitee-Timestamp: xxxxx(也使用 HMAC-SHA256,但算法稍有不同)
解决方案:
// Gitee 签名验证实现
func verifyGiteeSignature(signature, timestamp, secret string) bool {
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
// 构造待签名字符串:timestamp + "\n" + secret
stringToSign := fmt.Sprintf("%d\n%s", timestampInt, secret)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stringToSign))
signData := mac.Sum(nil)
encodedSign := base64.StdEncoding.EncodeToString(signData)
urlEncodedSign := url.PathEscape(encodedSign)
return urlEncodedSign == signature
}
// 验证逻辑
giteeToken := r.Header.Get("X-Gitee-Token")
giteeTimestamp := r.Header.Get("X-Gitee-Timestamp")
if !verifyGiteeSignature(giteeToken, giteeTimestamp, config.WebhookSecret) {
return false
}关键技术点:
- 签名算法:
HMAC-SHA256(timestamp + "\n" + secret)→ Base64 → URL 编码 - 时间戳验证:防止重放攻击,设置时间窗口(如 1 小时)
- 头部字段:同时需要
X-Gitee-Token和X-Gitee-Timestamp
生产环境安全策略:
- 设置复杂的 WebhookSecret(64 位随机字符串)
- 启用 HTTPS 加密传输
- 配置时间戳验证窗口,防重放攻击
- 生产环境使用 Nginx 作为反向代理,所有请求均经由 Nginx 转发到 Go 服务,HTTPS 证书统一配置在 Nginx 层
问题描述:
服务器上使用 bro 用户运行服务,确保 Git 操作具有正确的权限和认证。
实际部署架构:
- 服务运行用户:
bro(普通用户,非 root) - 用户家目录:
/bro - SSH 密钥配置: 本地电脑与服务器间已配置公钥认证
- Git 仓库认证: Gitee SSH 公钥使用相同密钥对
技术解决方案:
// Git 操作前检查权限
func (dm *DataManager) executeGitPull() error {
// 检查目录权限
if err := dm.checkGitPermissions(); err != nil {
return fmt.Errorf("权限检查失败: %v", err)
}
// 执行 git pull 带超时控制
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "pull", "origin", "main")
cmd.Dir = dm.postsDir
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git pull 失败: %v, 输出: %s", err, output)
}
return nil
}SSH 密钥配置详解:
-
本地密钥生成:
在本地电脑上使用如下命令生成 SSH 密钥对:
ssh-keygen -t rsa -b 4096 -C "your-email@example.com"生成后,可通过如下命令查看密钥文件:
ls ~/.ssh # 常见输出:id_rsa id_rsa.pub known_hosts
-
服务器端配置 (
/bro/.ssh/authorized_keys):# 将本地生成的公钥内容(id_rsa.pub)添加到服务器,允许免密码 SSH 连接 ssh-rsa AAAAB3NzaC1yc2E... your-local-computer -
Gitee SSH 公钥配置:
# 在 Gitee 设置中添加相同的公钥 # 路径:个人设置 → SSH 公钥 → 添加公钥
服务启动脚本准备:
在进行权限验证前,需要提前在服务器上配置好 Systemd 的 service 启动脚本(如 /etc/systemd/system/mdblog.service),用于以 bro 用户身份启动 mdblog 服务,并实现开机自启、自动重启等功能。
配置完成后,使用如下命令启动和管理服务:
sudo systemctl daemon-reload
sudo systemctl enable mdblog
sudo systemctl start mdblog
sudo systemctl status mdblog权限验证步骤:
# 1. 验证 SSH 连接
ssh bro@your-ip -p your-port
# 2. 验证 Git SSH 连接
ssh -T git@gitee.com
# 3. 验证 Git 仓库访问
cd /bro/mdblog && git pull origin main
# 4. 检查文件权限
ls -la /bro/mdblog/posts/最佳实践:
- 用户一致性: 服务运行用户
bro与 Git 仓库所有者保持一致 - SSH 密钥认证: 使用相同的 SSH 密钥对(本地 ↔ 服务器,服务器 ↔Gitee)
- 文件权限: 确保
bro用户对 posts 目录有完整的读写权限 - 安全策略: 使用普通用户运行服务,避免 root 权限风险
部署权限检查清单:
- ✅
bro用户可以正常 SSH 登录服务器 - ✅ 服务器可以通过 SSH 连接到 Gitee (
ssh -T git@gitee.com) - ✅
bro用户对/bro/mdblog/posts/目录有读写权限 - ✅ Git 仓库配置正确(origin 指向 Gitee SSH URL)
- ✅ Systemd 服务配置为
bro用户运行
核心数据结构:
type DataManager struct {
mu sync.RWMutex // 读写锁
posts map[string]*Post // 文章缓存
categories map[string][]string // 分类索引
searchIndex map[string][]string // 搜索索引
}
// 读操作(多个可并发)
func (dm *DataManager) GetPost(id string) *Post {
dm.mu.RLock()
defer dm.mu.RUnlock()
return dm.posts[id]
}
// 写操作(独占锁)
func (dm *DataManager) ReloadData() error {
dm.mu.Lock()
defer dm.mu.Unlock()
// 清空现有数据
dm.posts = make(map[string]*Post)
// 重新加载...
return nil
}关键点:
- 读多写少的场景,优先使用
sync.RWMutex - 避免在锁内进行耗时操作(如文件 I/O)
- Webhook 更新时使用完整重载而非增量更新(确保数据一致性)
防抖实现:
// 搜索防抖,避免频繁请求
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// 搜索实现
const debouncedSearch = debounce(async (query) => {
if (query.length < 2) return;
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const results = await response.json();
displaySearchResults(results);
} catch (error) {
console.error("搜索失败:", error);
}
}, 300);性能优化点:
- 输入长度限制(至少 2 个字符)
- 防抖延迟 300ms
- 结果数量限制
- 错误处理
问题描述: Windows 和 Linux 路径分隔符不同,需要统一处理。
解决方案:
import "path/filepath"
// 始终使用 filepath 包进行路径操作
func (dm *DataManager) getPostsDir() string {
// 获取可执行文件目录
execPath, _ := os.Executable()
execDir := filepath.Dir(execPath)
// 跨平台路径拼接
return filepath.Join(execDir, "posts")
}
// 生成文章 ID 时统一使用斜杠
func generatePostID(filePath, postsDir string) string {
relPath, _ := filepath.Rel(postsDir, filePath)
// 将路径分隔符统一为斜杠(用于 URL)
return strings.ReplaceAll(relPath, string(filepath.Separator), "/")
}统一错误处理:
// 定义错误类型
var (
ErrPostNotFound = errors.New("文章不存在")
ErrInvalidPath = errors.New("无效路径")
)
// 错误包装
func (dm *DataManager) LoadPost(filePath string) (*Post, error) {
if !filepath.IsAbs(filePath) {
return nil, fmt.Errorf("加载文章失败: %w", ErrInvalidPath)
}
content, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("读取文件 %s 失败: %w", filePath, err)
}
// 继续处理...
return post, nil
}
// 调用方处理
post, err := dm.LoadPost(path)
if err != nil {
if errors.Is(err, ErrPostNotFound) {
// 特殊处理文章不存在
}
log.Printf("加载文章失败: %v", err)
return
}集中配置管理:
// internal/config/config.go
package config
const (
// 服务配置
DefaultPort = 8091
DefaultHost = "0.0.0.0"
// 业务配置
SummaryLines = 3
PageSize = 10
MaxSearchResults = 100
// Webhook 配置
WebhookSecret = "your-secret"
WebhookBranch = "main"
WebhookDevMode = false // 生产环境设为 false
)环境配置支持:
// 支持环境变量覆盖
func GetPort() int {
if port := os.Getenv("MDBLOG_PORT"); port != "" {
if p, err := strconv.Atoi(port); err == nil {
return p
}
}
return DefaultPort
}结构化日志:
import "log"
// 统一日志格式
func logInfo(format string, args ...interface{}) {
log.Printf("[INFO] "+format, args...)
}
func logError(format string, args ...interface{}) {
log.Printf("[ERROR] "+format, args...)
}
func logWebhook(format string, args ...interface{}) {
log.Printf("[WEBHOOK] "+format, args...)
}
// 使用示例
logInfo("服务启动在端口 %d", port)
logError("加载文章失败: %v", err)
logWebhook("收到推送事件,分支: %s", branch)单元测试结构:
func TestMarkdownParser(t *testing.T) {
tests := []struct {
name string
input string
expected *Post
wantErr bool
}{
{
name: "有效的文章",
input: `---
title: "测试文章"
author: "测试作者"
---
# 内容`,
expected: &Post{
Title: "测试文章",
Author: "测试作者",
},
wantErr: false,
},
{
name: "无效的 Front Matter",
input: "invalid yaml",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewMarkdownParser()
result, err := parser.Parse(tt.input)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected.Title, result.Title)
assert.Equal(t, tt.expected.Author, result.Author)
})
}
}关键性能指标:
// 启动时间监控
func (dm *DataManager) LoadAllPosts() error {
start := time.Now()
defer func() {
logInfo("加载文章耗时: %v", time.Since(start))
}()
// 加载逻辑...
return nil
}
// 内存使用监控
func logMemoryUsage() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
logInfo("内存使用: Alloc=%d KB, Sys=%d KB", m.Alloc/1024, m.Sys/1024)
}
// 搜索性能监控
func (dm *DataManager) Search(query string) ([]*Post, error) {
start := time.Now()
defer func() {
logInfo("搜索 '%s' 耗时: %v", query, time.Since(start))
}()
// 搜索逻辑...
return results, nil
}Systemd 服务配置要点:
[Unit]
Description=MDBlog Service
After=network.target
[Service]
Type=simple
User=mdblog # 避免使用 root
Group=mdblog
WorkingDirectory=/opt/mdblog
ExecStart=/opt/mdblog/mdblog
Restart=always # 自动重启
RestartSec=10 # 重启间隔
# 安全设置
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/mdblog
[Install]
WantedBy=multi-user.target日志轮转配置:
# /etc/logrotate.d/mdblog
/opt/mdblog/logs/*.log {
daily
missingok
rotate 30
compress
delaycompress
notifempty
sharedscripts
postrotate
systemctl reload mdblog
endscript
}- 数据库支持:当文章数量超过 1000 篇时,考虑引入轻量级数据库
- 缓存策略:实现增量更新而非全量重载
- 多实例支持:为高可用部署做准备
- 插件系统:模块化扩展机制
- 国际化支持:多语言界面和内容
功能描述: 完整的 Markdown 文档解析和渲染系统
技术实现:
- 使用 Goldmark 解析器,支持 CommonMark 规范
- 集成 Chroma 语法高亮,支持 100+ 编程语言
- 支持 GFM 扩展(表格、任务列表、删除线等)
- 自定义 YAML Front Matter 解析
- HTML 安全渲染,防止 XSS 攻击
支持格式:
---
title: "文章标题"
author: "作者名称"
github_url: "https://github.com/username"
created_at: 2024-01-01T10:00:00
updated_at: 2024-01-01T12:00:00
description: "文章描述"
description: "技术博客文章"
---功能描述: 全内存数据存储和管理系统
技术特性:
- 启动时一次性加载所有文章到内存
- 使用
sync.RWMutex保证并发安全 - 多维度索引:分类索引、搜索索引
- 智能摘要生成(前 3 行文本内容)
- 实时数据更新机制(为 Webhook 预留)
性能指标:
- 文章加载:< 100ms(1000 篇文章)
- 搜索响应:< 10ms
- 内存占用:约 1MB/100 篇文章
功能描述: 快捷键驱动的实时搜索功能
交互设计:
- 快捷键:双击
Cmd(macOS)/Ctrl(Windows/Linux) - 搜索范围:文章标题、内容
- 搜索算法:模糊匹配,大小写不敏感
- 实时结果:输入即搜索,无需点击
技术实现:
- 前端:原生 JavaScript,防抖优化
- 后端:内存搜索,字符串包含匹配
- UI:弹窗式搜索界面,键盘导航
功能描述: 多端适配的现代化用户界面
设计规范:
- 桌面端(>768px): 侧边导航,多列布局
- 平板端(768px-480px): 折叠导航,双列布局
- 移动端(<480px): 汉堡菜单,单列布局
技术实现:
- CSS3 Flexbox/Grid 布局
- 媒体查询响应式设计
- 触摸友好的交互设计
- SVG 矢量图标
功能描述: 基于目录结构的自动分类
分类规则:
- 自动识别:
posts/go/article.md→ 分类 "go" - 根目录文章:默认分类 "其他"
- 特殊处理:
about.md仅在关于页面显示
分类功能:
- 分类页面:
/category/Go - 文章按分类组织展示
功能描述: 所有静态资源嵌入到可执行文件
技术实现:
//go:embed web
var EmbeddedAssets embed.FS优势:
- 真正的单文件部署
- 无需 web 目录依赖
- 版本一致性保证
- 部署简化
功能描述: 一键编译、部署、监控的完整 DevOps 流程
部署流程:
make scp # 一键部署执行步骤:
- 交叉编译 Linux 可执行文件
- SSH 连接服务器停止服务
- SCP 上传新的可执行文件
- SSH 启动服务
- 健康检查(端口 8091)
- 显示服务状态和日志
配置参数:
SERVER_HOST=123.56.186.148 # 服务器IP
SSH_PORT=21345 # SSH端口
APP_PORT=8091 # 应用端口功能描述: Systemd 集成的服务管理和日志监控
服务配置:
- Systemd 服务文件:
deploy/mdblog.service - 日志输出:
/bro/mdblog/logs/mdblog.log - 自动重启:服务异常时自动恢复
- 开机自启:系统重启后自动启动
功能描述: Git 仓库变更时自动更新博客内容
核心功能:
- HTTP 接口:
POST /webhook/gitee - 签名验证:HMAC-SHA256 安全校验
- Git 操作:自动执行
git pull更新内容 - 内存数据重新加载:实时更新博客数据
- 错误处理和日志记录:完整的错误追踪
- 健康检查接口:
GET /health - Webhook 测试工具:开发和生产环境测试
技术实现:
// Webhook 配置
const (
WebhookSecret = "c85b7544d37c89280afcf912ec70f4083b18065d9e89966cae6059798f0dadf5"
WebhookBranch = "main" // 监听的分支
)安全机制:
- HMAC-SHA256 签名验证: 使用密钥验证请求来源的合法性
- 分支过滤: 只处理来自
main分支的推送事件 - 输入验证: 验证请求方法、载荷格式和必需头部
- 错误处理: 全面的错误捕获和日志记录
工作流程:
- 接收 Gitee Webhook POST 请求
- 验证
X-Gitee-Token签名 - 解析 JSON 载荷并检查目标分支
- 执行
git pull origin main更新 posts 仓库 - 清空内存缓存并重新加载所有文章
- 返回同步结果和统计信息
Gitee 配置:
- Webhook URL:
https://broqiang.com/webhook/gitee - 密码:
c85b7544d37c89280afcf912ec70f4083b18065d9e89966cae6059798f0dadf5 - 触发事件: Push
- 分支过滤: main
性能优化:
- 使用读写锁保证并发安全
- 完整重载而非增量更新(确保数据一致性)
- 异步处理,不阻塞 Webhook 响应
- Git 操作超时控制和错误恢复
| 组件 | 技术选型 | 版本要求 | 用途说明 |
|---|---|---|---|
| 编程语言 | Go | 1.21+ | 高性能后端开发 |
| Web 框架 | Gin | v1.9+ | HTTP 路由和中间件 |
| Markdown 解析 | Goldmark | v1.5+ | Markdown 到 HTML 转换 |
| 语法高亮 | Chroma | v2.0+ | 代码块语法高亮 |
| 模板引擎 | html/template | 内置 | HTML 模板渲染 |
| 静态资源 | embed | 内置 | 资源嵌入 |
| 组件 | 技术选型 | 说明 |
|---|---|---|
| HTML | HTML5 | 语义化标签 |
| CSS | CSS3 | Flexbox/Grid 布局 |
| JavaScript | ES6+ | 原生 JS,无框架依赖 |
| 图标 | SVG | 矢量图标,内嵌 |
| 字体 | Inter | 现代化字体 |
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Markdown │ │ Data Manager │ │ Web Server │
│ Files │───▶│ (Memory) │───▶│ (Gin Router) │
│ (/posts/) │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Search Index │ │ HTTP Response │
│ Category Index│ │ (HTML/JSON) │
└─────────────────┘ └─────────────────┘
GET / # 首页
GET /post/:id # 文章详情页
GET /category/:category # 分类页面
GET /search # 搜索页面
GET /about # 关于页面
GET /api/posts # 文章列表API
GET /api/posts/:id # 文章详情API
GET /api/categories # 分类列表API
GET /api/search # 搜索API
POST /webhook/gitee # Gitee Webhook
GET /health # 健康检查
// Post 文章数据结构
type Post struct {
ID string `json:"id"` // 文章ID
Title string `json:"title"` // 标题
Author string `json:"author"` // 作者
GitHubURL string `json:"github_url"` // GitHub链接
Content string `json:"content"` // 原始内容
HTML string `json:"html"` // HTML内容
Summary string `json:"summary"` // 摘要
Category string `json:"category"` // 分类
CreateTime time.Time `json:"created_at"` // 创建时间
UpdateTime time.Time `json:"updated_at"` // 更新时间
Description string `json:"description"` // 描述
FilePath string `json:"file_path"` // 文件路径
}
// BlogData 全局数据结构
type BlogData struct {
Posts map[string]*Post // 文章索引
Categories map[string][]string // 分类索引
SearchIndex map[string][]string // 搜索索引
LastUpdate time.Time // 最后更新时间
}| 指标 | 目标值 | 当前值 | 说明 |
|---|---|---|---|
| 启动时间 | <3s | ~2s | 冷启动到可用 |
| 首页响应 | <50ms | ~20ms | 不含网络延迟 |
| 搜索响应 | <100ms | ~30ms | 1000 篇文章内搜索 |
| 内存占用 | <100MB | ~50MB | 1000 篇文章 |
| 可执行文件 | <50MB | ~25MB | 包含所有资源 |
# 并发测试(100并发,持续1分钟)
wrk -t10 -c100 -d60s http://localhost:8091/
Results:
Requests/sec: 2847.32
Transfer/sec: 892.45KB
Avg Latency: 35.12ms
Max Latency: 187.89ms- HTML 安全渲染: 防止 XSS 攻击
- 输入验证: 搜索参数验证
- 路径遍历保护: 文件路径安全检查
- CORS 配置: 跨域请求控制
- Webhook 签名验证: HMAC-SHA256 签名校验
- 分支过滤: 只处理指定分支的推送事件
- 服务状态: Systemd 服务监控
- 端口检查: TCP 8091 端口可用性
- 日志监控: 应用错误日志
- 内存使用: 进程内存占用
- 文章数量: 加载的文章总数
- 搜索频率: 搜索 API 调用统计
# 克隆项目
git clone https://github.com/your-username/mdblog.git
cd mdblog
# 安装依赖
go mod tidy
# 启动开发服务器
make dev- 遵循 Go 官方代码规范
- 使用
gofmt格式化代码 - 编写单元测试
- 提交前运行
go vet检查
- Fork 项目
- 创建功能分支
- 提交代码
- 创建 Pull Request
- 等待 Code Review
MIT License - 详见 LICENSE 文件
MDlog PRD - 详细的产品需求文档,指导项目开发和迭代方向。