我们的内部知识库,一个拥有超过一万篇技术文档的站点,最初选择Gatsby构建。看中的是它生成静态站点的极致访问性能和良好的SEO。后端数据存储在MySQL中,通过一套成熟的Java服务管理,其中数据访问层牢牢地绑定在MyBatis上。这个架构在初期运行良好,但随着文档数量的指数级增长,一个致命的瓶颈浮现了:构建时间。
任何一次微小的文档修订——哪怕只是修改一个错别字——都会触发一次完整的 gatsby build 流程。这个过程需要从数据库中拉取全部一万多篇文档的数据,然后在Node.js环境中处理、渲染成HTML。完整的构建流程耗时稳定在25分钟以上,这对于追求敏捷内容更新的团队来说,是完全无法接受的。等待半小时才能看到一个标点符号的修正上线,这种体验简直是灾难。
问题很明确:我们需要一种机制,让后端的数据变更能够以更精细化的方式通知前端构建流程,实现只更新受影响页面的“增量构建”。
初步构想与技术选型
理想状态是,当在后端通过MyBatis更新了一篇文章(ID为P123)后,只有与P123相关的页面(例如 /docs/p123)被重新生成,而不是整个站点。这听起来很像Next.js的Incremental Static Regeneration (ISR),但Gatsby本身并没有提供如此原生的支持,尤其是在数据源是传统数据库的情况下。
我们必须自己搭建这座桥梁。整个流程可以拆解为三个关键部分:
- 变更捕获 (Change Capture): 在Java后端,必须有一种低侵入性的方式来捕arco知由MyBatis执行的数据库写操作(INSERT, UPDATE, DELETE)。
- 事件通知 (Event Notification): 捕获到的变更需要被转化为一个事件消息,通过某种异步机制传递出去。
- 增量构建执行器 (Incremental Build Executor): 一个独立的Node.js服务需要消费这些事件,并以某种方式触发Gatsby来更新特定的页面。
对于变更捕获,最直接的想法是修改业务代码,在每次数据库更新后手动发送消息。但这严重违反了开闭原则,需要在上百个业务方法中添加重复代码,维护成本极高。一个更优雅的方案是利用MyBatis自身的扩展点。MyBatis的Interceptor插件机制允许我们在SQL执行的生命周期(如Executor的方法执行前后)织入自定义逻辑。这正是我们需要的低侵入性钩子。
对于事件通知,我们不需要一个像Kafka那样重的消息队列。考虑到系统的内部性质和对低延迟的要求,Redis的Pub/Sub功能是一个轻量级且高效的选择。后端作为发布者,构建执行器作为订阅者。
最棘手的是增量构建执行器。Gatsby的构建过程是一个整体。直接“重新构建单个页面”的公开API并不存在。这里的核心思路是欺骗Gatsby的缓存。我们可以维护一个全量数据的本地缓存(例如JSON文件),当收到变更事件时,只更新缓存中对应的那条数据,然后触发一次完整的gatsby build。由于sourceNodes阶段的数据获取被优化为读取本地缓存,除了受影响的数据需要重新从API获取外,其他数据都直接来自缓存,这将极大缩短数据源的拉取时间。整个构建流程的瓶颈将被转移到真正需要重新渲染的页面上,从而实现“伪增量”构建。
架构图如下:
graph TD
subgraph Java Backend
A[业务代码调用Mapper] --> B{MyBatis Executor};
B -- SQL执行 --> C[MySQL数据库];
B -- 拦截点 --> D[MyBatis变更捕获Interceptor];
D -- 提取变更信息 --> E{封装变更事件};
E --> F[Redis Publisher];
end
F -- PUBLISH channel:entity_changes --> G((Redis Pub/Sub));
subgraph Node.js Build Orchestrator
H[Redis Subscriber] -- SUBSCRIBE channel:entity_changes --> G;
H -- 收到事件 --> I{事件处理器};
I -- 更新本地数据缓存 & 设置环境变量 --> J[执行 gatsby build];
end
subgraph Gatsby Build Process
K[gatsby-node.js] -- 读取环境变量 --> L{智能数据源};
L -- 读取本地缓存 & API回源 --> M[Gatsby数据层];
M --> N[生成页面];
end
J --> K;
步骤化实现:代码是最好的诠释
1. 后端:利用MyBatis Interceptor捕获数据变更
我们首先需要创建一个Interceptor来监听写操作。这个拦截器将作用于MyBatis的Executor,特别是update方法,因为MyBatis将INSERT, UPDATE, DELETE都路由到这个方法。
EntityChangeCaptureInterceptor.java
package com.example.interceptor;
import com.example.event.ChangeEvent;
import com.example.event.EventPublisher;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.*;
import org.springframework.stereotype.Component;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Slf4j
@Component
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})})
public class EntityChangeCaptureInterceptor implements Interceptor {
private final EventPublisher eventPublisher;
private final ObjectMapper objectMapper = new ObjectMapper();
// 正则用于从 MappedStatement ID 中提取实体名称, e.g., com.example.mapper.ArticleMapper.updateById -> Article
private static final Pattern ENTITY_NAME_PATTERN = Pattern.compile("\\.([A-Za-z]+)Mapper\\.");
public EntityChangeCaptureInterceptor(EventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
// 1. 只关心写操作
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
if (sqlCommandType != SqlCommandType.INSERT && sqlCommandType != SqlCommandType.UPDATE && sqlCommandType != SqlCommandType.DELETE) {
return invocation.proceed();
}
// 执行原始数据库操作
Object returnValue = invocation.proceed();
// returnValue 是受影响的行数
try {
// 2. 构造并发布事件
ChangeEvent event = buildChangeEvent(mappedStatement, parameter, sqlCommandType);
if (event != null) {
String eventJson = objectMapper.writeValueAsString(event);
eventPublisher.publish("entity_changes", eventJson);
log.info("Published entity change event: {}", eventJson);
}
} catch (Exception e) {
// 这里的坑在于:不能因为事件发布失败而影响主业务流程。
// 必须捕获所有异常,只打印日志,确保数据库事务不会因此回滚。
log.error("Failed to publish entity change event after DB operation. This will cause static site to be out of sync.", e);
}
return returnValue;
}
private ChangeEvent buildChangeEvent(MappedStatement mappedStatement, Object parameter, SqlCommandType commandType) {
String statementId = mappedStatement.getId();
// 3. 从 MappedStatement ID 中提取实体类型
String entityType = extractEntityType(statementId);
if (entityType == null) {
log.warn("Could not determine entity type from statementId: {}", statementId);
return null;
}
// 4. 尝试从参数中获取实体ID。这是最脆弱的部分,高度依赖于DAO方法的参数设计。
// 在真实项目中,这里需要更健壮的逻辑,例如基于注解或统一的参数基类。
Long entityId = extractEntityId(parameter);
if (entityId == null) {
log.warn("Could not extract entity ID for statementId: {}", statementId);
// 对于INSERT操作,ID可能在操作后回填,此时需要特殊处理,但为简化示例,暂不展开
return null;
}
return new ChangeEvent(entityType, entityId, commandType.toString());
}
private String extractEntityType(String statementId) {
Matcher matcher = ENTITY_NAME_PATTERN.matcher(statementId);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
private Long extractEntityId(Object parameter) {
// 假设我们的参数是一个有 `getId()` 方法的POJO
if (parameter instanceof BaseEntity) {
return ((BaseEntity) parameter).getId();
}
// 可添加更多对Map等参数类型的支持
return null;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// NOP
}
// 假设所有实体类继承自一个基类
public interface BaseEntity {
Long getId();
}
}
EventPublisher.java (使用StringRedisTemplate的简单实现)
package com.example.event;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class RedisEventPublisher implements EventPublisher {
private final StringRedisTemplate redisTemplate;
public RedisEventPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public void publish(String channel, String message) {
redisTemplate.convertAndSend(channel, message);
}
}
配置MyBatis插件 (Spring Boot)
package com.example.config;
import com.example.interceptor.EntityChangeCaptureInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MyBatisConfig {
@Bean
public EntityChangeCaptureInterceptor entityChangeCaptureInterceptor(EventPublisher eventPublisher) {
return new EntityChangeCaptureInterceptor(eventPublisher);
}
}
注意,在application.yml中需要配置MyBatis扫描插件,或者如上所示,通过@Bean声明的插件会被自动应用。
2. 构建执行器:监听并响应变更
这是一个独立的Node.js脚本,它会常驻后台运行。
build-orchestrator.js
const redis = require('redis');
const { exec } = require('child_process');
const fs = require('fs/promises');
const path = require('path');
const GATSBY_PROJECT_PATH = '/path/to/your/gatsby/project';
const CACHE_FILE_PATH = path.join(GATSBY_PROJECT_PATH, '.build_cache', 'sourced-data.json');
const LOCK_FILE_PATH = path.join(GATSBY_PROJECT_PATH, '.build_cache', '.build.lock');
const DEBOUNCE_MS = 5000; // 在触发构建前,等待5秒以合并密集的事件
let buildQueue = new Map();
let debounceTimer = null;
async function main() {
await fs.mkdir(path.dirname(CACHE_FILE_PATH), { recursive: true });
const client = redis.createClient({
// url: 'redis://user:password@host:port'
});
client.on('error', (err) => console.error('Redis Client Error', err));
await client.connect();
console.log('Connected to Redis, subscribing to "entity_changes" channel...');
await client.subscribe('entity_changes', (message) => {
try {
const event = JSON.parse(message);
console.log(`Received event:`, event);
if (event.entityType && event.entityId) {
const key = `${event.entityType}:${event.entityId}`;
buildQueue.set(key, event); // 使用Map自动去重
// 重置防抖计时器
clearTimeout(debounceTimer);
debounceTimer = setTimeout(triggerBuild, DEBOUNCE_MS);
console.log(`Event queued. Build scheduled in ${DEBOUNCE_MS}ms.`);
}
} catch (e) {
console.error('Failed to parse event message:', message, e);
}
});
}
async function triggerBuild() {
if (buildQueue.size === 0) {
return;
}
// 1. 检查构建锁,防止并发构建
if (await isBuilding()) {
console.log('Build already in progress. Skipping trigger.');
// 在真实项目中,可以将当前队列中的事件暂存,待构建结束后再处理
buildQueue.clear();
return;
}
const eventsToProcess = Array.from(buildQueue.values());
buildQueue.clear();
try {
await createLockFile();
console.log(`Lock acquired. Starting incremental build for ${eventsToProcess.length} changes.`);
// 2. 将变更信息写入环境变量,供 gatsby-node.js 读取
const env = {
...process.env,
INCREMENTAL_BUILD_EVENTS: JSON.stringify(eventsToProcess)
};
const buildProcess = exec('gatsby build', {
cwd: GATSBY_PROJECT_PATH,
env: env
});
// 3. 实时输出构建日志
buildProcess.stdout.pipe(process.stdout);
buildProcess.stderr.pipe(process.stderr);
buildProcess.on('close', (code) => {
console.log(`Gatsby build process finished with code ${code}.`);
removeLockFile(); // 确保锁被释放
});
} catch (err) {
console.error('Error triggering build:', err);
await removeLockFile(); // 发生错误也要释放锁
}
}
async function isBuilding() {
try {
await fs.access(LOCK_FILE_PATH);
return true;
} catch {
return false;
}
}
async function createLockFile() {
await fs.writeFile(LOCK_FILE_PATH, process.pid.toString());
}
async function removeLockFile() {
try {
await fs.unlink(LOCK_FILE_PATH);
console.log('Lock released.');
} catch (e) {
// ignore if file doesn't exist
}
}
main().catch(console.error);
3. Gatsby端:实现智能数据源
现在,我们需要修改gatsby-node.js来利用INCREMENTAL_BUILD_EVENTS环境变量和本地缓存。
gatsby-node.js
const fs = require('fs').promises;
const path = require('path');
const axios = require('axios');
const CACHE_FILE_PATH = path.join(__dirname, '.build_cache', 'sourced-data.json');
const API_BASE_URL = 'http://localhost:8080/api';
// Helper to fetch data for a single entity
const fetchEntity = async (entityType, entityId) => {
try {
// 这里的API设计至关重要,需要有按ID获取单个实体的接口
const response = await axios.get(`${API_BASE_URL}/${entityType.toLowerCase()}/${entityId}`);
return response.data;
} catch (error) {
console.error(`Failed to fetch ${entityType} with ID ${entityId}`, error.message);
return null;
}
};
exports.sourceNodes = async ({ actions, createContentDigest, createNodeId }) => {
const { createNode } = actions;
let cachedData = {};
try {
const cacheContent = await fs.readFile(CACHE_FILE_PATH, 'utf-8');
cachedData = JSON.parse(cacheContent);
console.log(`Loaded ${Object.keys(cachedData).length} entities from local cache.`);
} catch (error) {
console.log('Local cache not found. Performing a full data fetch.');
}
// 解析环境变量中的变更事件
const incrementalEventsEnv = process.env.INCREMENTAL_BUILD_EVENTS;
const isIncrementalBuild = incrementalEventsEnv && incrementalEventsEnv.length > 0;
if (isIncrementalBuild) {
console.log('Incremental build detected.');
const events = JSON.parse(incrementalEventsEnv);
for (const event of events) {
const cacheKey = `${event.entityType}:${event.entityId}`;
if (event.action === 'DELETE') {
delete cachedData[cacheKey];
console.log(`Removed ${cacheKey} from cache.`);
} else { // INSERT or UPDATE
console.log(`Re-fetching data for ${cacheKey}...`);
const newData = await fetchEntity(event.entityType, event.entityId);
if (newData) {
cachedData[cacheKey] = newData;
console.log(`Updated cache for ${cacheKey}.`);
}
}
}
} else {
// 全量构建逻辑:从API获取所有数据
console.log('Full build detected. Fetching all data from API...');
// const allArticles = await axios.get(`${API_BASE_URL}/articles`);
// for(const article of allArticles.data) {
// cachedData[`Article:${article.id}`] = article;
// }
// ... 其他实体类型
// 为简化示例,假设全量数据已存在于缓存中
}
// 将最终的数据集写入缓存,供下次构建使用
await fs.mkdir(path.dirname(CACHE_FILE_PATH), { recursive: true });
await fs.writeFile(CACHE_FILE_PATH, JSON.stringify(cachedData, null, 2));
console.log('Updated local data cache file.');
// 从处理后的缓存数据创建Gatsby节点
for (const [key, entityData] of Object.entries(cachedData)) {
const [entityType] = key.split(':');
createNode({
...entityData,
id: createNodeId(key),
internal: {
type: entityType, // e.g., 'Article'
contentDigest: createContentDigest(entityData),
},
});
}
};
// createPages 部分保持不变,它会基于 sourceNodes 创建的节点来生成页面
exports.createPages = async ({ graphql, actions }) => {
const { createPage } = actions;
const result = await graphql(`
query {
allArticle {
nodes {
id
slug
}
}
}
`);
result.data.allArticle.nodes.forEach(node => {
createPage({
path: `/docs/${node.slug}`,
component: path.resolve('./src/templates/article-template.js'),
context: {
id: node.id,
},
});
});
};
部署这套系统后,我们的内容更新流程焕然一新。一次单篇文档的修改,从保存到线上可见,整个流程的时间从原来的25分钟缩短到了平均70秒左右。其中,MyBatis拦截器和Redis通知的耗时在毫秒级,构建执行器的5秒防抖延迟后,真正的gatsby build耗时大约在60秒左右。这60秒主要消耗在Gatsby的启动、Webpack打包等固定开销上,数据源拉取的时间已经可以忽略不计。
局限性与未来展望
这套方案并非银弹,它本质上是一种在Gatsby框架限制下的“优化”而非真正的“增量”。其局限性显而易见:
关联更新的复杂性: 当前实现只处理了单个实体的变更。如果修改一个作者的信息,需要更新该作者名下所有的文章页面,那么MyBatis拦截器需要能解析这种实体间的依赖关系,并发出多个变更事件。这需要引入一个依赖图,会显著增加后端的复杂度。
构建的固定开销: 无论变更多小,我们依然要启动一次完整的
gatsby build。Gatsby v4引入的Deferred Static Generation (DSG) 和 Server-Side Rendering (SSR) 功能可以部分规避这个问题,允许页面在请求时构建,但这改变了纯静态的部署模型。对参数的脆弱依赖: MyBatis拦截器中提取
entityId的逻辑目前非常简单,高度依赖于DAO方法的参数结构。一个更健壮的系统需要设计一套标准的参数协定,或者利用反射和注解来更可靠地获取ID。
尽管存在这些局限,但这套架构成功地解决了我们最痛苦的构建效率问题。它将一个传统的、紧耦合的Java后端与一个现代化的前端静态生成框架,通过事件驱动的方式解耦,实现了高效的协作。未来的迭代方向可能会探索将部分非核心或更新频繁的页面切换到Gatsby的DSG模式,或者在依赖关系变得极其复杂时,评估像Next.js这类原生支持ISR的框架的迁移成本。