基于MyBatis数据变更事件实现Gatsby站点的增量静态生成


我们的内部知识库,一个拥有超过一万篇技术文档的站点,最初选择Gatsby构建。看中的是它生成静态站点的极致访问性能和良好的SEO。后端数据存储在MySQL中,通过一套成熟的Java服务管理,其中数据访问层牢牢地绑定在MyBatis上。这个架构在初期运行良好,但随着文档数量的指数级增长,一个致命的瓶颈浮现了:构建时间

任何一次微小的文档修订——哪怕只是修改一个错别字——都会触发一次完整的 gatsby build 流程。这个过程需要从数据库中拉取全部一万多篇文档的数据,然后在Node.js环境中处理、渲染成HTML。完整的构建流程耗时稳定在25分钟以上,这对于追求敏捷内容更新的团队来说,是完全无法接受的。等待半小时才能看到一个标点符号的修正上线,这种体验简直是灾难。

问题很明确:我们需要一种机制,让后端的数据变更能够以更精细化的方式通知前端构建流程,实现只更新受影响页面的“增量构建”。

初步构想与技术选型

理想状态是,当在后端通过MyBatis更新了一篇文章(ID为P123)后,只有与P123相关的页面(例如 /docs/p123)被重新生成,而不是整个站点。这听起来很像Next.js的Incremental Static Regeneration (ISR),但Gatsby本身并没有提供如此原生的支持,尤其是在数据源是传统数据库的情况下。

我们必须自己搭建这座桥梁。整个流程可以拆解为三个关键部分:

  1. 变更捕获 (Change Capture): 在Java后端,必须有一种低侵入性的方式来捕arco知由MyBatis执行的数据库写操作(INSERT, UPDATE, DELETE)。
  2. 事件通知 (Event Notification): 捕获到的变更需要被转化为一个事件消息,通过某种异步机制传递出去。
  3. 增量构建执行器 (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框架限制下的“优化”而非真正的“增量”。其局限性显而易见:

  1. 关联更新的复杂性: 当前实现只处理了单个实体的变更。如果修改一个作者的信息,需要更新该作者名下所有的文章页面,那么MyBatis拦截器需要能解析这种实体间的依赖关系,并发出多个变更事件。这需要引入一个依赖图,会显著增加后端的复杂度。

  2. 构建的固定开销: 无论变更多小,我们依然要启动一次完整的gatsby build。Gatsby v4引入的Deferred Static Generation (DSG) 和 Server-Side Rendering (SSR) 功能可以部分规避这个问题,允许页面在请求时构建,但这改变了纯静态的部署模型。

  3. 对参数的脆弱依赖: MyBatis拦截器中提取entityId的逻辑目前非常简单,高度依赖于DAO方法的参数结构。一个更健壮的系统需要设计一套标准的参数协定,或者利用反射和注解来更可靠地获取ID。

尽管存在这些局限,但这套架构成功地解决了我们最痛苦的构建效率问题。它将一个传统的、紧耦合的Java后端与一个现代化的前端静态生成框架,通过事件驱动的方式解耦,实现了高效的协作。未来的迭代方向可能会探索将部分非核心或更新频繁的页面切换到Gatsby的DSG模式,或者在依赖关系变得极其复杂时,评估像Next.js这类原生支持ISR的框架的迁移成本。


  目录