AI应用的微前端架构:前后端分离的AI功能集成
AI应用的微前端架构:前后端分离的AI功能集成
四个团队,各写一套AI聊天框
2025年8月,某上市互联网公司技术负责人刘燕在季度复盘会上皱着眉头。
公司有四个前端团队:营销平台、客服系统、数据分析平台、内部OA。这四个系统都需要接入AI能力——AI聊天助手、AI搜索、AI内容生成。
问题来了:四个团队各自实现了一套AI聊天框。
- 营销平台:React + 自研流式输出组件,代码2000行
- 客服系统:Vue + 第三方组件库,集成了AI,代码1500行
- 数据分析:Angular + 自写WebSocket客户端,代码1800行
- 内部OA:React + 从营销平台复制粘贴,代码2200行(复制后又各自改)
总计:7500行的重复代码,四套独立的AI后端接口调用,四套不同的流式处理逻辑。
当产品要求"所有系统的AI聊天体验保持一致"时,意味着要改四个地方。当AI后端接口调整时,四个团队各自联调,光沟通协调就要浪费一周。
刘燕的决策:引入微前端架构,把AI功能封装成共享组件。
改造后三个月的结果:
- AI相关前端代码:7500行减少到1800行(共享组件+各系统集成代码)
- 新功能上线速度:需要改四个系统 → 改一个地方,四个系统同时生效
- 新业务接入AI的时间:原来2-4周 → 3天
- 前端开发效率:主观估算提升约50%(减少了大量重复和协调工作)
这篇文章,我们来完整讲解AI应用的微前端架构设计和实现。
微前端架构与AI功能集成的核心挑战
什么是微前端
微前端(Micro Frontends)是将前端应用拆分成多个独立可部署的小应用,类似后端微服务的理念。每个小应用(微应用)可以:
- 独立开发、独立部署
- 使用不同的技术栈(React/Vue/Angular混用)
- 通过统一的容器应用(Shell)组合在一起
AI功能带来的特殊挑战
普通微前端已经有成熟方案(Module Federation、qiankun、single-spa),但AI功能引入了三个新的复杂度:
- 流式输出跨应用边界:SSE建立在某个微应用内,但流式数据可能需要展示在另一个微应用的UI上
- AI会话状态跨应用共享:用户在客服系统里开始的AI对话,跳转到OA系统后希望继续
- BFF层统一管理:四个前端系统各自调用AI API → 一个BFF统一代理,鉴权/限流/日志统一管理
Module Federation:实现AI组件共享
Module Federation是Webpack 5引入的联邦模块系统,允许一个应用直接加载另一个应用运行时导出的模块。
架构设计
AI组件库的Webpack配置
// ai-components/webpack.config.js
const { ModuleFederationPlugin } = require('@module-federation/enhanced');
const path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.ts',
devServer: {
port: 3001,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
output: {
publicPath: process.env.AI_COMPONENTS_URL || 'http://localhost:3001/',
filename: '[name].[contenthash].js',
uniqueName: 'aiComponents',
},
plugins: [
new ModuleFederationPlugin({
name: 'aiComponents',
filename: 'remoteEntry.js',
exposes: {
'./AiChat': './src/components/AiChat',
'./AiSearch': './src/components/AiSearch',
'./AiContentGenerator': './src/components/AiContentGenerator',
'./useAiStream': './src/hooks/useAiStream',
'./useAiSession': './src/hooks/useAiSession',
'./AiStore': './src/store/aiStore',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0',
eager: false,
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
},
'zustand': {
singleton: true,
requiredVersion: '^4.0.0',
},
},
}),
],
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
};消费方(营销平台)的配置
// marketing-platform/webpack.config.js
const { ModuleFederationPlugin } = require('@module-federation/enhanced');
module.exports = {
mode: 'development',
plugins: [
new ModuleFederationPlugin({
name: 'marketingPlatform',
remotes: {
aiComponents: `aiComponents@${
process.env.AI_COMPONENTS_URL || 'http://localhost:3001'
}/remoteEntry.js`,
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
zustand: { singleton: true, requiredVersion: '^4.0.0' },
},
}),
],
};AI聊天组件的微前端封装
核心AI Store(跨应用状态共享)
// src/store/aiStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
isStreaming?: boolean;
metadata?: {
model?: string;
latencyMs?: number;
tokenCount?: number;
};
}
export interface AiSession {
id: string;
title: string;
messages: Message[];
createdAt: number;
updatedAt: number;
}
interface AiState {
activeSessionId: string | null;
sessions: Record<string, AiSession>;
isStreaming: boolean;
streamingSessionId: string | null;
error: string | null;
createSession: (title?: string) => string;
setActiveSession: (sessionId: string) => void;
addMessage: (sessionId: string, message: Omit<Message, 'id' | 'timestamp'>) => string;
updateStreamingMessage: (sessionId: string, messageId: string, delta: string) => void;
finalizeStreamingMessage: (sessionId: string, messageId: string, metadata?: Message['metadata']) => void;
setStreaming: (isStreaming: boolean, sessionId?: string | null) => void;
setError: (error: string | null) => void;
clearSession: (sessionId: string) => void;
deleteSession: (sessionId: string) => void;
}
export const useAiStore = create<AiState>()(
persist(
immer((set, get) => ({
activeSessionId: null,
sessions: {},
isStreaming: false,
streamingSessionId: null,
error: null,
createSession: (title = '新对话') => {
const id = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
set(state => {
state.sessions[id] = {
id,
title,
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
state.activeSessionId = id;
});
return id;
},
setActiveSession: (sessionId) => {
set(state => { state.activeSessionId = sessionId; });
},
addMessage: (sessionId, messageData) => {
const id = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
set(state => {
const session = state.sessions[sessionId];
if (session) {
session.messages.push({ id, timestamp: Date.now(), ...messageData });
session.updatedAt = Date.now();
}
});
return id;
},
updateStreamingMessage: (sessionId, messageId, delta) => {
set(state => {
const session = state.sessions[sessionId];
if (session) {
const msg = session.messages.find(m => m.id === messageId);
if (msg) msg.content += delta;
}
});
},
finalizeStreamingMessage: (sessionId, messageId, metadata) => {
set(state => {
const session = state.sessions[sessionId];
if (session) {
const msg = session.messages.find(m => m.id === messageId);
if (msg) {
msg.isStreaming = false;
if (metadata) msg.metadata = metadata;
}
}
state.isStreaming = false;
state.streamingSessionId = null;
});
},
setStreaming: (isStreaming, sessionId = null) => {
set(state => {
state.isStreaming = isStreaming;
state.streamingSessionId = sessionId ?? null;
});
},
setError: (error) => {
set(state => { state.error = error; });
},
clearSession: (sessionId) => {
set(state => {
if (state.sessions[sessionId]) {
state.sessions[sessionId].messages = [];
state.sessions[sessionId].updatedAt = Date.now();
}
});
},
deleteSession: (sessionId) => {
set(state => {
delete state.sessions[sessionId];
if (state.activeSessionId === sessionId) {
const remainingIds = Object.keys(state.sessions);
state.activeSessionId = remainingIds.length > 0 ? remainingIds[0] : null;
}
});
},
})),
{
name: 'ai-sessions-storage',
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({
sessions: state.sessions,
activeSessionId: state.activeSessionId,
}),
}
)
);useAiStream Hook(流式输出核心逻辑)
// src/hooks/useAiStream.ts
import { useCallback, useRef } from 'react';
import { useAiStore } from '../store/aiStore';
interface StreamOptions {
sessionId: string;
onChunk?: (delta: string) => void;
onComplete?: (fullContent: string, metadata?: { tokenCount?: number; latencyMs?: number }) => void;
onError?: (error: Error) => void;
}
interface SendMessageOptions {
message: string;
systemPrompt?: string;
}
/**
* AI流式输出Hook
*
* 使用SSE(Server-Sent Events):
* - 单向推送,HTTP协议,穿透企业防火墙
* - 浏览器原生支持自动重连
* - Spring支持完善
*/
export function useAiStream(options: StreamOptions) {
const { sessionId, onChunk, onComplete, onError } = options;
const { addMessage, updateStreamingMessage, finalizeStreamingMessage, setStreaming, setError } =
useAiStore();
const abortControllerRef = useRef<AbortController | null>(null);
const startTimeRef = useRef<number>(0);
const sendMessage = useCallback(async ({ message, systemPrompt }: SendMessageOptions) => {
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
setError(null);
setStreaming(true, sessionId);
startTimeRef.current = Date.now();
// 添加用户消息
addMessage(sessionId, { role: 'user', content: message });
// 添加占位AI消息(等待流式填充)
const aiMessageId = addMessage(sessionId, {
role: 'assistant',
content: '',
isStreaming: true,
});
try {
const response = await fetch('/api/ai/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}`,
},
body: JSON.stringify({ sessionId, message, systemPrompt }),
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let fullContent = '';
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
const latencyMs = Date.now() - startTimeRef.current;
finalizeStreamingMessage(sessionId, aiMessageId, { latencyMs });
onComplete?.(fullContent, { latencyMs });
setStreaming(false);
return;
}
try {
const parsed = JSON.parse(data);
if (parsed.type === 'delta' && parsed.content) {
fullContent += parsed.content;
updateStreamingMessage(sessionId, aiMessageId, parsed.content);
onChunk?.(parsed.content);
} else if (parsed.type === 'error') {
throw new Error(parsed.message || 'Stream error');
}
} catch (parseError) {
if (!line.startsWith(': ')) {
console.warn('Failed to parse SSE data:', data);
}
}
}
}
}
} catch (error) {
if ((error as Error).name === 'AbortError') {
finalizeStreamingMessage(sessionId, aiMessageId);
return;
}
const err = error as Error;
setError(err.message);
setStreaming(false);
finalizeStreamingMessage(sessionId, aiMessageId);
onError?.(err);
}
}, [sessionId, addMessage, updateStreamingMessage, finalizeStreamingMessage, setStreaming, setError]);
const abort = useCallback(() => {
abortControllerRef.current?.abort();
setStreaming(false);
}, [setStreaming]);
return { sendMessage, abort };
}AiChat组件
// src/components/AiChat/index.tsx
import React, { useEffect, useRef, useState } from 'react';
import { useAiStore } from '../../store/aiStore';
import { useAiStream } from '../../hooks/useAiStream';
interface AiChatProps {
sessionId?: string;
systemPrompt?: string;
placeholder?: string;
height?: string | number;
theme?: 'light' | 'dark';
onSessionCreated?: (sessionId: string) => void;
onMessageSent?: (message: string) => void;
onResponseComplete?: (content: string) => void;
className?: string;
style?: React.CSSProperties;
}
export const AiChat: React.FC<AiChatProps> = ({
sessionId: externalSessionId,
systemPrompt = '你是一个专业的AI助手,善于回答技术问题。',
placeholder = '输入问题...',
height = '600px',
theme = 'light',
onSessionCreated,
onMessageSent,
onResponseComplete,
className,
style,
}) => {
const {
activeSessionId,
sessions,
isStreaming,
error,
createSession,
setActiveSession,
clearSession,
} = useAiStore();
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
externalSessionId || activeSessionId
);
const [inputValue, setInputValue] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (externalSessionId) {
setCurrentSessionId(externalSessionId);
setActiveSession(externalSessionId);
} else if (!currentSessionId) {
const newId = createSession();
setCurrentSessionId(newId);
onSessionCreated?.(newId);
}
}, [externalSessionId]);
const { sendMessage, abort } = useAiStream({
sessionId: currentSessionId || '',
onComplete: (content) => onResponseComplete?.(content),
});
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [sessions[currentSessionId || '']?.messages.length]);
const handleSend = async () => {
const message = inputValue.trim();
if (!message || isStreaming || !currentSessionId) return;
setInputValue('');
onMessageSent?.(message);
await sendMessage({ message, systemPrompt });
};
const currentSession = currentSessionId ? sessions[currentSessionId] : null;
return (
<div
className={className}
style={{ display: 'flex', flexDirection: 'column', height, ...style }}
data-testid="ai-chat"
data-theme={theme}
>
{/* 头部 */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid #eee', display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontWeight: 600 }}>{currentSession?.title || 'AI 助手'}</span>
<div>
<button onClick={() => {
const newId = createSession();
setCurrentSessionId(newId);
onSessionCreated?.(newId);
}}>+ 新对话</button>
{currentSession && (
<button onClick={() => currentSessionId && clearSession(currentSessionId)}>清空</button>
)}
</div>
</div>
{/* 消息列表 */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px' }}>
{currentSession?.messages.map(msg => (
<div key={msg.id} style={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
marginBottom: '12px',
}}>
<div style={{
maxWidth: '80%',
padding: '10px 14px',
borderRadius: '12px',
background: msg.role === 'user' ? '#1677ff' : '#f0f0f0',
color: msg.role === 'user' ? '#fff' : '#000',
whiteSpace: 'pre-wrap',
}}>
{msg.content}
{msg.isStreaming && <span style={{ animation: 'blink 1s infinite' }}>▋</span>}
</div>
</div>
))}
{error && (
<div style={{ color: 'red', padding: '8px', background: '#fff0f0', borderRadius: '4px' }}>
出错了:{error}
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div style={{ padding: '12px 16px', borderTop: '1px solid #eee', display: 'flex', gap: '8px' }}>
<input
type="text"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}
placeholder={placeholder}
disabled={isStreaming}
style={{ flex: 1, padding: '8px 12px', borderRadius: '6px', border: '1px solid #d9d9d9' }}
/>
{isStreaming ? (
<button onClick={abort} style={{ padding: '8px 16px', background: '#ff4d4f', color: '#fff', border: 'none', borderRadius: '6px' }}>
停止
</button>
) : (
<button
onClick={handleSend}
disabled={!inputValue.trim()}
style={{ padding: '8px 16px', background: '#1677ff', color: '#fff', border: 'none', borderRadius: '6px' }}
>
发送
</button>
)}
</div>
</div>
);
};
export default AiChat;后端BFF设计:Spring Boot实现
BFF(Backend For Frontend)是专门为前端层服务的后端层,在微前端场景下统一处理所有AI服务调用。
BFF核心实现
// AiBffController.java
package com.laozhang.bff.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.StreamingChatClient;
import org.springframework.ai.chat.messages.*;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Slf4j
@RestController
@RequestMapping("/api/ai")
@RequiredArgsConstructor
@CrossOrigin(origins = {
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:3002",
"http://localhost:3003",
})
public class AiBffController {
private final StreamingChatClient streamingChatClient;
private final ChatSessionService sessionService;
private final RateLimitService rateLimitService;
private final MetricsService metricsService;
private final ExecutorService sseExecutor =
Executors.newVirtualThreadPerTaskExecutor();
/**
* SSE流式聊天接口
*
* 为什么用SSE而不是WebSocket:
* - SSE是HTTP,穿透企业防火墙更容易
* - 单向推送足够(AI生成不需要双向通信)
* - 浏览器原生自动重连支持
*/
@PostMapping(value = "/chat/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(
@RequestBody ChatStreamRequest request,
@AuthenticationPrincipal UserPrincipal principal) {
// 限流:每用户每分钟最多20次请求
if (!rateLimitService.allowRequest(principal.getId(), 20, 60)) {
throw new RateLimitException("请求频率超限,请稍后再试");
}
// 验证会话归属
sessionService.validateSession(request.getSessionId(), principal.getId());
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
long startTime = System.currentTimeMillis();
sseExecutor.execute(() -> {
StringBuilder fullContent = new StringBuilder();
try {
// 保存用户消息
sessionService.saveUserMessage(request.getSessionId(), request.getMessage());
// 构建含历史的Prompt
Prompt prompt = buildPrompt(request, principal);
// 发送START事件
emitter.send(SseEmitter.event()
.name("start")
.data("{\"type\":\"start\",\"sessionId\":\"" + request.getSessionId() + "\"}")
);
// 流式调用AI
streamingChatClient.stream(prompt)
.doOnNext(response -> {
String delta = response.getResult().getOutput().getContent();
if (delta != null && !delta.isEmpty()) {
fullContent.append(delta);
try {
String escaped = delta
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n");
emitter.send(SseEmitter.event()
.name("delta")
.data("{\"type\":\"delta\",\"content\":\"" + escaped + "\"}")
);
} catch (IOException e) {
log.error("Failed to send SSE delta", e);
}
}
})
.doOnComplete(() -> {
try {
long latencyMs = System.currentTimeMillis() - startTime;
sessionService.saveAssistantMessage(
request.getSessionId(),
fullContent.toString(),
latencyMs
);
emitter.send(SseEmitter.event()
.name("done")
.data("{\"type\":\"done\",\"latencyMs\":" + latencyMs + "}")
);
emitter.send("data: [DONE]\n\n");
emitter.complete();
metricsService.recordChatLatency(latencyMs);
log.info("Chat stream completed: session={}, latency={}ms",
request.getSessionId(), latencyMs);
} catch (IOException e) {
emitter.completeWithError(e);
}
})
.doOnError(e -> {
log.error("Chat stream error", e);
try {
emitter.send(SseEmitter.event()
.name("error")
.data("{\"type\":\"error\",\"message\":\"" + e.getMessage() + "\"}")
);
} catch (IOException ioException) {
log.error("Failed to send error event", ioException);
}
emitter.completeWithError(e);
})
.subscribe();
} catch (Exception e) {
log.error("Failed to start chat stream", e);
emitter.completeWithError(e);
}
});
emitter.onTimeout(() -> {
log.warn("SSE timeout for session: {}", request.getSessionId());
emitter.complete();
});
return emitter;
}
/**
* 获取会话消息列表
*/
@GetMapping("/sessions/{sessionId}/messages")
public List<ChatMessageDto> getMessages(
@PathVariable String sessionId,
@AuthenticationPrincipal UserPrincipal principal) {
sessionService.validateSession(sessionId, principal.getId());
return sessionService.getMessages(sessionId);
}
/**
* 创建新会话
*/
@PostMapping("/sessions")
public ChatSessionDto createSession(
@RequestBody CreateSessionRequest request,
@AuthenticationPrincipal UserPrincipal principal) {
return sessionService.createSession(principal.getId(), request.getTitle());
}
private Prompt buildPrompt(ChatStreamRequest request, UserPrincipal principal) {
List<Message> messages = new ArrayList<>();
String systemPrompt = request.getSystemPrompt() != null
? request.getSystemPrompt()
: """
你是一个专业的AI助手,运行在企业内部平台上。
- 专业、准确、简洁
- 优先给出可以直接使用的答案
- 涉及代码时,提供可以直接运行的完整代码
""";
messages.add(new SystemMessage(systemPrompt));
// 历史对话(最近10轮)
sessionService.getRecentMessages(request.getSessionId(), 10)
.forEach(msg -> {
if ("user".equals(msg.getRole())) {
messages.add(new UserMessage(msg.getContent()));
} else if ("assistant".equals(msg.getRole())) {
messages.add(new AssistantMessage(msg.getContent()));
}
});
messages.add(new UserMessage(request.getMessage()));
return new Prompt(messages);
}
@lombok.Data
public static class ChatStreamRequest {
String sessionId;
String message;
String systemPrompt;
String appId;
}
@lombok.Data
public static class CreateSessionRequest {
String title;
}
}限流服务实现
// RateLimitService.java
package com.laozhang.bff.service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
@RequiredArgsConstructor
public class RateLimitService {
private final StringRedisTemplate redisTemplate;
/**
* 基于Redis的滑动窗口限流
*
* @param userId 用户ID
* @param maxCount 窗口内最大请求数
* @param windowSec 窗口大小(秒)
* @return 是否允许请求
*/
public boolean allowRequest(String userId, int maxCount, int windowSec) {
String key = "ratelimit:ai:" + userId;
Long current = redisTemplate.opsForValue().increment(key);
if (current == 1) {
redisTemplate.expire(key, Duration.ofSeconds(windowSec));
}
return current <= maxCount;
}
/**
* 获取用户当前请求计数
*/
public long getCurrentCount(String userId) {
String key = "ratelimit:ai:" + userId;
String value = redisTemplate.opsForValue().get(key);
return value != null ? Long.parseLong(value) : 0;
}
}状态共享:多微应用间共享AI会话状态
// src/utils/crossAppCommunication.ts
/**
* 跨微应用通信方案说明:
*
* 我们采用 Module Federation + Zustand singleton 方案:
* - webpack配置 singleton: true 确保全局只有一个Zustand实例
* - 所有微应用共享同一Store,状态自然同步
*
* 辅以 BroadcastChannel 做跨标签页同步
*/
export function setupCrossTabSync() {
const channel = new BroadcastChannel('ai-session-sync');
channel.addEventListener('message', (event) => {
const { type, payload } = event.data;
if (type === 'SESSION_CLEARED') {
const { useAiStore } = require('../store/aiStore');
useAiStore.getState().clearSession(payload.sessionId);
}
});
return () => channel.close();
}
/**
* 微应用切换时保持AI会话
* 从一个微应用跳转到另一个时,通过URL参数或事件传递sessionId
*/
export function navigateWithSession(targetAppUrl: string, sessionId: string) {
// 方式1:URL参数
const url = new URL(targetAppUrl);
url.searchParams.set('aiSessionId', sessionId);
// 方式2:全局事件(同一Shell内的微应用)
window.dispatchEvent(new CustomEvent('ai:session-handoff', {
detail: { sessionId }
}));
window.location.href = url.toString();
}安全隔离:微前端架构下的AI功能权限控制
// AiPermissionController.java
package com.laozhang.bff.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/ai/permissions")
@RequiredArgsConstructor
public class AiPermissionController {
private final AiPermissionService permissionService;
/**
* 前端调用此接口获取当前用户的AI功能权限
* 权限基于用户角色和订阅套餐
*/
@GetMapping
public AiPermissionService.AiPermissions getMyPermissions(
@AuthenticationPrincipal UserPrincipal principal) {
return permissionService.getPermissions(principal.getId());
}
/**
* 管理员设置用户权限
*/
@PutMapping("/{userId}")
@PreAuthorize("hasRole('ADMIN')")
public void updatePermissions(
@PathVariable String userId,
@RequestBody UpdatePermissionsRequest request) {
permissionService.updatePermissions(userId, request);
}
@lombok.Data
public static class UpdatePermissionsRequest {
boolean canUseAiChat;
boolean canUseAiSearch;
boolean canUseAiGenerate;
int maxDailyMessages;
}
}// AiPermissionService.java
package com.laozhang.bff.service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDate;
@Service
@RequiredArgsConstructor
public class AiPermissionService {
private final UserRepository userRepository;
private final StringRedisTemplate redisTemplate;
public AiPermissions getPermissions(String userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
boolean isPremium = "PREMIUM".equals(user.getSubscriptionLevel());
boolean isAdmin = user.getRoles().contains("ADMIN");
return AiPermissions.builder()
.canUseAiChat(true)
.canUseAiSearch(isPremium || isAdmin)
.canUseAiGenerate(isPremium || isAdmin)
.maxDailyMessages(isPremium ? 200 : 20)
.build();
}
public boolean checkDailyLimit(String userId) {
String key = "ai:daily:" + userId + ":" + LocalDate.now();
Long current = redisTemplate.opsForValue().increment(key);
if (current == 1) {
redisTemplate.expire(key, Duration.ofDays(1));
}
return current <= getPermissions(userId).getMaxDailyMessages();
}
public void updatePermissions(String userId, AiBffController.UpdatePermissionsRequest request) {
// 更新数据库中的用户权限配置(省略具体实现)
}
@lombok.Builder @lombok.Data
public static class AiPermissions {
boolean canUseAiChat;
boolean canUseAiSearch;
boolean canUseAiGenerate;
int maxDailyMessages;
}
}性能优化:AI组件的懒加载和预加载
// src/components/LazyAiChat.tsx
import React, { Suspense, lazy } from 'react';
/**
* AI组件懒加载策略
*
* 三种预加载时机:
* 1. requestIdleCallback:浏览器空闲时预加载(不影响首屏)
* 2. 悬停预加载:用户悬停在AI按钮上立即开始加载
* 3. 路由预加载:进入包含AI功能的路由时加载
*/
const AiChatLazy = lazy(() =>
import('aiComponents/AiChat').catch(() => import('./FallbackAiChat'))
);
export function preloadAiComponents() {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
import('aiComponents/AiChat');
import('aiComponents/AiSearch');
}, { timeout: 5000 });
} else {
setTimeout(() => {
import('aiComponents/AiChat');
}, 2000);
}
}
export function useHoverPreload() {
const [preloaded, setPreloaded] = React.useState(false);
const handleMouseEnter = React.useCallback(() => {
if (!preloaded) {
import('aiComponents/AiChat');
setPreloaded(true);
}
}, [preloaded]);
return { onMouseEnter: handleMouseEnter };
}
interface LazyAiChatProps {
sessionId?: string;
height?: string | number;
}
export const LazyAiChat: React.FC<LazyAiChatProps> = (props) => {
return (
<Suspense fallback={
<div style={{
height: props.height || '600px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f9f9f9',
borderRadius: '8px',
border: '1px solid #eee',
}}>
<span>AI助手加载中...</span>
</div>
}>
<AiChatLazy {...props} />
</Suspense>
);
};部署方案:微前端AI应用的独立发布流水线
微前端的核心价值之一是独立部署。AI组件库更新后,所有消费方微应用无需重新发布。
# .github/workflows/ai-components-deploy.yml
name: Deploy AI Components Library
on:
push:
branches: [main]
paths:
- 'ai-components/**'
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
working-directory: ai-components
run: npm ci
- name: Run tests
working-directory: ai-components
run: npm test -- --coverage --ci
- name: Build
working-directory: ai-components
run: npm run build
env:
AI_COMPONENTS_URL: ${{ vars.AI_COMPONENTS_CDN_URL }}
# 上传到CDN(版本化 + latest两份)
- name: Upload versioned build
run: |
VERSION=$(cat ai-components/package.json | jq -r '.version')
aws s3 sync ai-components/dist/ \
s3://your-cdn-bucket/ai-components/v${VERSION}/ \
--cache-control "public, max-age=31536000, immutable"
- name: Update latest pointer
run: |
aws s3 sync ai-components/dist/ \
s3://your-cdn-bucket/ai-components/latest/ \
--cache-control "public, max-age=300"
- name: Notify dependent apps
run: |
VERSION=$(cat ai-components/package.json | jq -r '.version')
curl -X POST ${{ secrets.DEPLOY_WEBHOOK_URL }} \
-H "Content-Type: application/json" \
-d "{\"component\":\"aiComponents\",\"version\":\"${VERSION}\"}"
- name: Smoke test
run: |
sleep 10
curl -f https://cdn.example.com/ai-components/latest/remoteEntry.js \
-o /dev/null --silent --head --write-out "%{http_code}" | grep 200灰度发布支持
// shell/src/remoteRegistry.ts
// 在Shell应用中管理所有远程模块的地址,支持灰度
interface RemoteConfig {
name: string;
url: string;
rolloutPercent: number; // 灰度比例 0-100
}
const remoteConfigs: RemoteConfig[] = [
{
name: 'aiComponents',
url: 'https://cdn.example.com/ai-components/v2.0.0/remoteEntry.js',
rolloutPercent: 20, // 20%用户使用新版
},
];
export async function initRemotes() {
for (const config of remoteConfigs) {
const useNew = shouldUseNewVersion(config.name, config.rolloutPercent);
const url = useNew
? config.url
: 'https://cdn.example.com/ai-components/v1.9.0/remoteEntry.js'; // 旧版
await loadScript(config.name, url);
}
}
function shouldUseNewVersion(featureName: string, rolloutPercent: number): boolean {
// 用用户ID做哈希,确保同一用户始终进同一个组
const userId = localStorage.getItem('userId') || 'anonymous';
const hash = simpleHash(userId + featureName);
return (hash % 100) < rolloutPercent;
}
function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
function loadScript(name: string, url: string): Promise<void> {
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[data-remote="${name}"]`);
if (existing) { resolve(); return; }
const script = document.createElement('script');
script.src = url;
script.dataset.remote = name;
script.onload = () => {
const container = (window as any)[name];
if (container?.init) {
container.init((window as any).__webpack_share_scopes__?.default || {});
}
resolve();
};
script.onerror = () => reject(new Error(`Failed to load remote: ${name} from ${url}`));
document.head.appendChild(script);
});
}性能数据对比
| 指标 | 改造前(重复开发) | 改造后(微前端共享) | 提升 |
|---|---|---|---|
| AI相关前端代码量 | 7500行(4份重复) | 1800行(1份共享) | -76% |
| 新AI功能上线时间(4系统) | 2-4周 | 3天 | -80% |
| AI接口Bug影响范围 | 各自修复 | 修一处全部生效 | -75%工时 |
| 首屏加载(含AI组件) | 2.8s | 1.2s(懒加载) | -57% |
| 组件行为一致性 | 各有差异 | 完全一致 | 100% |
| 新团队接入AI时间 | 2-4周 | 3天 | -85% |
FAQ
Q:微前端的CSS隔离会影响AI组件样式吗?
A:会有影响。Module Federation共享的组件运行在父应用DOM上,样式可能相互污染。推荐方案:①AI组件使用CSS Modules(类名哈希隔离);②或者用styled-components/emotion做CSS-in-JS(样式完全内联)。避免在共享AI组件中使用全局CSS类名。
Q:Zustand singleton跨微应用共享状态的关键配置是什么?
A:核心是webpack配置中 shared.zustand.singleton: true。所有微应用和AI组件库都必须声明这个配置,否则各自会加载独立的Zustand实例,状态无法共享。这是微前端共享状态最常踩的坑,调试时可以通过 window.__zustand_stores__ 检查是否只有一个Store实例。
Q:AI组件库更新有破坏性变更时怎么处理?
A:采用语义化版本(SemVer):主版本号变更(v1.x → v2.x)表示不兼容的API变更,消费方必须主动升级。实践中:①提前通知各团队;②在新版本中保留旧API(deprecation警告);③设定升级截止日期;④通过灰度发布验证稳定性后再全量切换。
Q:BFF层会成为性能瓶颈吗?
A:合理设计的BFF不会成为瓶颈。关键点:①使用响应式(WebFlux)或虚拟线程处理并发;②不做不必要的数据转换;③SSE是流式代理,不需要缓冲完整响应。实测中,BFF的额外延迟通常在5-15ms,完全可以接受。真正的瓶颈永远是AI服务本身的推理时间。
Q:没有BFF,前端能直接调用AI API吗?
A:技术上可以,但不建议。问题:①前端暴露AI API Key,安全风险极高;②跨域问题(OpenAI API不允许浏览器直接调用);③无法做限流和成本控制;④日志和监控不完整。BFF是微前端AI架构中不可缺少的基础设施。
总结
刘燕后来在公司内部分享这次架构改造时说:"微前端不是为了技术先进,是为了解决真实的组织问题——多个团队如何共享一套AI能力,而不是各自造轮子。"
这次改造的核心价值:
- 代码复用:从7500行重复代码到1800行共享组件,维护成本大幅降低
- 独立发布:AI组件更新不依赖其他团队,快速迭代
- 统一BFF:鉴权、限流、日志、成本控制集中管理
- 一致体验:所有系统AI功能行为完全一致,用户体验统一
- 快速接入:新业务接入AI时间从4周缩短到3天
对于Java工程师:BFF层是你的主战场。Spring Boot的SSE流式代理、会话管理、限流、权限控制、监控——这些都是你擅长的领域。理解微前端的整体架构,是让你和前端团队高效协作的关键。
