大多数前端工程师并不会主动选择使用微服务架构,而是继承了这种架构。有一天你可能只需要从单个API获取数据,而第二天却需要整合来自五个不同服务的响应信息——这些服务各自拥有独立的接口规范、故障处理机制,以及对“用户”概念的不同理解。
后端团队在讨论有界上下文、最终一致性以及服务网格等相关技术,而你却在思考状态加载问题、数据过期问题,以及为什么当库存服务响应缓慢时,结账页面会出错。
本文专为在微服务环境中工作的前端工程师编写。通过阅读本文,你将学会如何合理使用多个服务的API接口而不导致系统混乱,如何在用户界面中优雅地处理部分功能故障,如何管理跨服务的数据分布情况,以及如何与后端团队有效协作制定API规范——因为沟通才是成功的关键,而非代码本身。
我们的目标并不是让你成为一名后端工程师,而是帮助你掌握那些能够让在微服务环境下进行前端开发的工作变得更加顺利的思维模式和开发技巧。
先决条件
为了充分理解本文的内容,你需要具备以下基础知识:
-
React或类似的组件框架(示例中使用了React和TypeScript)
-
对REST API和HTTP的基本了解
-
具有在前端应用程序中获取数据的相关经验(使用fetch、Axios或React Query等工具)
-
对微服务的基本概念有所了解(无需亲自开发过微服务项目即可)
目录
前端面临的微服务挑战
在单体架构中,前端仅需要与一个API进行交互。这个API负责管理数据库、处理业务逻辑,并返回UI所需的数据结构。这样的设计非常简单明了。
在微服务架构中,那个单一的API会被拆分为多个独立的API:
单体应用:
浏览器 → API → 数据库
微服务架构:
浏览器 → API网关 → 用户服务
→ 订单服务
→ 库存服务
→ 支付服务
→ 通知服务
这些服务中的每一个都由不同的团队负责开发,它们会独立部署,而且可能会使用不同的数据格式或规范。作为前端工程师,你现在面临着几个新的问题:
-
多种接口协议: 每个服务都有自己独特的API接口结构。在库存服务中,“产品”这个对象所包含的字段与目录服务中的“产品”对象的字段是不同的。
-
部分服务可能出现故障: 订单服务可能在50毫秒内完成响应,而推荐服务却可能会超时。你的用户界面必须能够同时处理这两种情况。
-
数据一致性问题: 用户更新了自己的地址信息,但由于订单服务尚未同步这些新数据,因此仍然显示旧地址。
-
延迟增加: 构建一个简单的页面现在可能需要调用三到四个API接口,而不是之前只需要一个。
这些问题并非后端系统出现的问题,而是直接影响前端体验的问题,因此需要从前端的角度来解决它们。
模式1:后端为前端服务(Backend-for-Frontend,简称BFF)
在微服务环境中,对前端团队来说最具影响力的模式就是后端为前端服务。BFF是一种位于浏览器与微服务之间的轻量级API层,它由前端团队负责开发,其存在的目的就是为了满足前端的特定需求。
不使用BFF时:
浏览器 → 用户服务 (调用1次)
浏览器 → 订单服务 (调用2次)
浏览器 → 库存服务 (调用3次)
共需进行3次请求,需要管理3种不同的接口协议
使用BFF时:
浏览器 → BFF → 用户服务
→ 订单服务
→ 库存服务
只需要进行1次请求,只需管理1种接口协议
BFF负责汇总各种请求,将响应数据转换成前端组件所需的形式,并处理诸如认证令牌转发之类的跨服务协作问题。
// BFF接口:GET /api/order-summary/:orderId
// 从三个服务中获取数据,生成适合前端展示的响应结果
import express from "express";
const router = express.Router();
router.get("/api/order-summary/:orderId", async (req, res) => {
const { orderId } = req.params;
const token = req.headersauthorization;
try {
const [order, customer, shipment] = await Promise.allSettled([
fetch(`\({ORDER_SERVICE}/orders/\){orderId}`, {
headers: { Authorization: token },
}).then((r) => r.json()),
fetch(`\({USER_SERVICE}/users/\){req.userId}`, { // userId由认证中间件提供
headers: { Authorization: token },
}).then((r) => r.json()),
fetch(`\({SHIPPING_SERVICE}/shipments?orderId=\){orderId}`, {
headers: { Authorization: token },
}).then((r) => r.json()),
});
res.json({
order: order.status === "fulfilled" ? order.value : null,
customer: customer.status === "fulfilled" ? customer.value : null,
shipment: shipment.status === "fulfilled" ? shipment.value : null,
errors: [order, customer, shipment]
.filter((r) => r.status === "rejected")
.map((r) => r.reason.message),
});
} catch (error) {
res.status(500).json({ error: "无法生成订单摘要信息" });
}
});
请注意,这里使用了Promise.allSettled而不是Promise.all。在微服务环境中,这种区别至关重要。Promise.all会很快失败:只要有任何一个服务出现故障,整个请求就会失败。Promise.allSettled则允许返回部分数据,而这正是接下来要讨论的内容。
何时使用BFF
在以下情况下,投资开发BFF是值得的:
-
当你的前端页面需要从三个或更多的服务中获取数据时
-
不同的客户端(网页、移动端、管理后台)需要从相同的服务中获取不同格式的数据时
-
当你希望前端团队能够自行控制响应数据的格式,而无需等待后端团队的支持时
在以下情况下,使用BFF并不是必要的:
-
如果你已经拥有一个能够处理数据聚合的API网关(例如用于GraphQL的Apollo Federation)
-
如果你只使用了一两个服务
-
如果你的后端团队已经提供了专为前端优化过的接口
模式2:在用户界面中处理部分故障
在单体应用中,请求要么成功,要么失败。而在微服务环境中,请求可能会部分成功——例如,订单数据能够正常加载,但推荐系统出现故障;产品详情可以获取,但评论功能响应缓慢。
你的用户界面需要优雅地处理这类情况。关键原则是:绝不能让非核心服务的故障影响到核心的用户体验流程。
// 用于表示部分数据加载的情况
interface ServiceResult {
data: T | null;
status: "loaded" | "error" | "loading";
error?: string;
}
interface OrderPageData {
order: ServiceResult;
recommendations: ServiceResult
你应该根据实际可用的数据来独立渲染各个组件:
function OrderPage({ orderId }: { orderId: string }) {
const { order, recommendations, reviews } = useOrderPageData(orderId);
// 核心部分:订单数据必须加载完毕,否则页面将无法正常显示
if (order.status === "loading") return ;
if (order.status === "error") return
return (
{/* 核心内容:始终会被渲染 */}
{/* 非核心内容:可以降级显示 */}
{recommendations.status === "loaded" ? (
) : recommendations.status === "error" ? (
) : (
)}
{reviews.status === "loaded" ? (
) : reviews.status === "error" ? (
) : (
)}
);
}
区分关键数据与非关键数据
页面上的所有数据并非都具有同等的重要性。在构建任何从多个服务获取数据的页面之前,首先需要对每一项数据源进行分类:
| 数据来源 | 是否属于关键数据? | 出现故障时应采取的措施 |
|---|---|---|
| 订单详情 | 是 | 显示错误页面,屏蔽整个页面内容 |
| 客户信息 | 是 | 显示错误页面 |
| 推荐信息 | 否 | 隐藏该部分内容,显示空白状态 |
| 评论 | 否 | 显示“评论不可用”的提示信息 |
| 最近查看的内容 | 否 | 悄悄隐藏该部分内容 |
这种分类工作应当由你的产品团队经过慎重考虑后做出决定,而不是在某个服务在生产环境中出现故障时才去发现这些问题。
模式3:管理分布式状态
在单体应用的环境中,服务器是所有数据的唯一来源;而在微服务架构中,数据分布在不同的服务节点上。用户服务掌握用户的当前地址信息,而订单服务则保存着订单生成时的地址信息——这两者很可能并不一致。
// 根据数据的更新频率来设置缓存时长 const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, // 默认值:30秒 }, }, }); // 产品目录信息:更新频率较低 function useProduct(productId: string) { return useQuery({ queryKey: ["product", productId], queryFn: () => fetchProductproductId), staleTime: 5 * 60_000, // 5分钟:产品目录信息更新不频繁 }); } // 库存数量:变化非常频繁 function useStockLevel(productId: string) { return useQuery({ queryKey: ["stock", productId], queryFn: () => fetchStockLevelproductId), staleTime: 10_000, // 10秒:每次购买后库存数量都会发生变化 refetchInterval: 30_000, // 活动页面每30秒重新查询一次数据 }); } // 用户自己的订单信息:必须保持最新状态 function useOrder(orderId: string) { return useQuery({ queryKey: ["order", orderId], queryFn: () => fetchOrder(orderId), staleTime: 0, // 不设置过期时间:用户希望看到自己最新的操作结果 }); }
一个常见的错误就是将所有被缓存的数据一视同仁地对待。来自产品目录服务的数据可以缓存数分钟,而来自库存服务的数据则需要更频繁地更新;至于用户自己的订单信息,则必须始终保持最新状态,因为用户刚刚完成了某个操作,自然希望立即看到结果。
跨服务失效处理
在分布式系统中,最棘手的问题就是如何确定何时需要使某些数据失效。当用户下订单时,你需要执行以下操作:
-
使订单列表失效(订单服务)
-
使库存数量失效(库存服务)
-
使用户的积分失效(用户服务)
// 在订单成功提交后,需要在跨服务的范围内使相关数据失效
async function placeOrder(cart: Cart): Promise {
const order = await api.post("/api/orders", { items: cart.items });
// 使受到此操作影响的多个服务中的数据失效
queryClientinvalidateQueries({ queryKey: ["orders"] });
queryClientinvalidateQueries({ queryKey: ["stock"] });
queryClient.invalidateQueries({ queryKey: ["loyalty-points"] });
// 更新前端所持有的购物车信息
queryClient.setQueryData(["cart"], { items: [] });
return order;
}
这种处理方式属于手动操作,且容易出错。每当有新的服务需要处理订单相关事件时,你就必须记得为这些服务添加相应的失效处理逻辑。
为了实现更可靠的处理方案,你可以使用服务器发送的事件或WebSocket连接,让后端将失效信号推送给前端;或者在客户端的状态管理机制中采用发布/订阅模式,让缓存键能够监听相关的事件。
这些方法超出了本文的讨论范围,但当你的失效处理逻辑涉及的条目数量超过十几个时,确实值得尝试这些方法。
在此之前,将这些跨服务之间的依赖关系记录在表格中会很有帮助:
| 用户操作 | 受影响的服务 | 需要失效的缓存键 |
|---|---|---|
| 下订单 | 订单服务、库存服务、用户服务 | orders, stock, loyalty-points, cart |
| 更新地址 | 用户服务、配送服务 | user-profile, shipping-estimates |
| 写评论 | 评论服务、产品服务 | reviews, product (仅当评分发生变化时) |
模式4:应对多种API契约
在微服务架构中,每个服务都会定义自己的API契约。用户服务返回的是firstName和lastName;订单服务返回的是customerName这个字符串;而通知服务则期望接收fullName。虽然概念相同,但字段名称却各不相同。
适配层
需要创建一个适配层,将每个服务的响应数据转换成你的组件能够使用的统一格式的领域模型:
// 领域模型:前端实际处理的数据结构
interface User {
id: string;
fullName: string;
email: string;
address: Address;
}
// 用户服务的适配器函数
function adaptUserServiceResponse(raw: UserServiceResponse): User {
return {
id: raw.userId,
fullName: `\({raw.firstName} \){raw.lastName}`,
email: raw.emailAddress,
address: {
line1: raw.address.street,
city: raw.address.city,
postcode: raw.address.zipCode,
country: raw.address.countryCode,
},
};
}
// 订单服务的适配器函数(该服务返回的用户信息结构不同)
function adaptOrderCustomer(raw: OrderServiceCustomer): User {
return {
id: raw.customerId,
fullName: raw.customerName,
email: raw.email,
address: {
line1: raw.shippingAddress.addressLine1,
city: raw.shippingAddress.city,
postcode: raw.shippingAddress.postalCode,
country: raw.shippingAddress.country,
},
};
}
你的组件仅适用于User类型的数据。它们从未接触到原始的服务响应结果。当某个服务修改了其API接口时,你只需要更新相应的适配器,而无需修改所有用于显示用户名称的组件。
适配器的放置位置
如果你使用了BFF架构,那么适配器应该被放在该架构中。浏览器根本看不到原始的服务响应数据。如果你是直接从前端调用服务,那么应该将适配器放置在数据获取层中,也就是在HTTP请求与缓存之间:
// 适配器会在数据进入缓存之前被执行
function useUser(userId: string) {
return useQuery({
queryKey: ["user", userId],
queryFn: async () => {
const raw = await fetch `/api/users/${userId}`).then((r) => r.json());
return adaptUserServiceResponse(raw);
},
});
}
模式5:页面组装的超时时间设置
当一个页面依赖于多个服务时,就需要制定相应的超时策略。如果没有这样的策略,页面的加载时间就会受到最慢的那个服务的影响,而在微服务环境中,总会有某个服务运行得比较慢。
超时时间设置可以为页面所需获取的所有数据分配一个最大的处理时间。如果某个非关键服务没有在规定的时间内响应,那么就可以忽略它的返回结果,继续渲染页面的其他部分。
在实际开发中,这种超时处理逻辑通常会被放在一个共享的服务层中(例如lib/api.ts),而不是直接嵌入到每个页面的组装代码中。以下是具体的实现示例:
// lib/api.ts:共享的超时处理模块
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response.json();
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
console.warn(`请求 \({url} 在 \){timeoutMs} 毫秒后超时`);
}
return null;
} finally {
clearTimeout(timeout);
}
}
// 使用分层超时策略来组装页面数据
async function assembleProductPageproductId: string): Promise {
// 关键数据:设置较长的超时时间,如果获取失败则页面无法正常显示
const product = await fetchWithTimeout(`
`/api/products/${productId}`,
{},
3000 // 关键数据的最大处理时间为3秒
);
if (!product) {
throw new Error("产品未找到");
}
// 非关键数据:设置较短的超时时间,即使获取失败也不会影响页面显示
const [reviews, recommendations, relatedProducts] = await Promise.all([
fetchWithTimeout[].then((response) => {
reviews = response_reviews ?? [];
}),
fetchWithTimeout[].then((response) => {
recommendations = response.recommendations ?? [];
}),
fetchWithTimeout[].then((response) => {
relatedProducts = responserelatedProducts ?? [];
}),
});
return {
product,
reviews: reviews ?? [],
recommendations: recommendations ?? [],
relatedProducts: relatedProducts ?? [],
};
}
请注意这些不同的预算安排:关键数据(即产品本身)的处理时间为3秒,而非关键数据(如评论、推荐信息)的处理时间则为1至1.5秒。如果推荐功能的响应速度较慢,那么就可以直接展示产品信息,而无需显示推荐内容——用户根本不会等待那些他们可能根本不会查看的信息。
模式6:每项服务的错误处理边界
在微服务前端环境中,React的错误处理机制显得尤为强大。与其在整个页面层面设置一个统一的错误处理边界,不如为那些对应于不同后端服务的部分分别设置独立的错误处理边界。
如果你之前还没有使用过错误处理边界功能,下面是一个简单的实现示例。需要注意的是,错误处理边界必须是由类组件构成的——React目前还不支持将函数组件用于实现这一功能(更多详细信息请参阅React官方文档):
```typescript
class ErrorBoundary extends React.Component {
{ fallback: React.ReactNode; children: ReactGraphNode },
{ hasError: boolean }
} {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("ErrorBoundary捕获到错误:", error, info);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
```
一旦设置了这样的错误处理边界,就可以将它们应用到具体的服务模块中:
```typescript
function ProductPage({ productId }: {productId: string }) {
return (
{/* 如果评论相关服务出现故障,就隐藏评论部分 */}
{/* 如果推荐相关服务超时响应,就默默地不显示推荐内容 */}
);
}
```
每个错误处理边界都会独立地捕获来自其对应数据源的错误。如果评论服务出现故障,也不会影响到产品详情的显示;如果推荐功能超时响应,那么该部分内容就根本不会被渲染出来。
这种设计方式正好符合我们对关键信息与非关键信息的区分标准:对于关键服务,会设置带有可见错误提示的错误处理边界;而对于非关键服务,则会让它们在出现故障时默默地失效,或者仅显示简单的空状态提示。
与后端团队合作处理合同相关事宜
上述技术解决方案仅能缓解表面问题。在微服务环境中,前端面临的大部分难题其实源于前端团队与后端团队在API合同方面的沟通不畅。
应尽早进行的合同相关讨论
1. 前端实际会使用哪些字段?
后端服务通常会公开其全部数据模型。但如果前端仅使用了其中三个字段,那么后端团队就能更加有针对性地维护这些字段,并及时淘汰那些无人使用的字段。
2. 这个端点的预期延迟时间是多少?
如果产品页面的总体延迟时间为2秒,而推荐系统的平均响应时间为1.8秒,那么在编写任何前端代码之前,你就应该意识到这个问题。必须尽早发现并解决这类问题。
3>当这个服务出现故障时会发生什么?
应该向每个后端团队询问:“如果你的服务在一段时间内持续返回500错误代码,前端应该如何显示相应的信息?”这个问题往往能揭示出其实没人考虑过这类情况,而正是因此才需要提出这样的问题。
4>如何传达那些可能会破坏系统稳定性的变更?
需要事先确定一种沟通机制。无论是通过拉取请求中的OpenAPI规范差异对比,还是使用Slack频道来讨论API变更事宜,又或者是为各个接口分配不同的版本号,都必须选择一种明确的方式,并严格遵守它。
// 根据OpenAPI规范自动生成的类型定义,始终与后端保持同步
import type { components } from "./generated/inventory-api";
type Product = components["schemas"]["Product"];
type StockLevel = components["schemas"]["StockLevel"];
// 如果后端将“available”字段的名字改为“inStock”,
// 这段代码在编译阶段就会失败,而不会在生产环境中出现问题
function formatStockMessage(stock: StockLevel): string {
if (stock.available > 10) return "有货";
if (stock.available > 0) return `仅剩${stock.available}件`;
return "缺货";
}
针对多个服务的测试
契约测试能够发现后端代码中出现的破坏性变更,但当某些服务以意料之外的方式响应时,你也需要测试前端的应用程序在这些情况下的行为。Mock Service Worker (MSW)允许你在测试环境中为每个服务创建模拟处理程序:
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
// 为每个服务分别创建模拟处理程序
const server = setupServer(
http.get("/api/products/:id", () =>
HttpResponse.json({ productId: "abc-123", name: "Widget", price: 49.99 })
),
http.get("/api/reviews", () =>
HttpResponse.json([{ rating: 5, body: "Great product" }))
)
);
// 测试:当评论服务出现故障时,页面会如何显示?
test("当评论服务失败时,产品页面仍能正常显示", async () => {
server.use(
http.get("/api/reviews", () => HttpResponse.error())
);
render(<ProductPage productId="abc-123" />>);
expect(await screen.findByText("Widget")).toBeInTheDocument();
expect.await screen.findByText("Reviews unavailable")).toBeInTheDocument();
});
这种方式能帮助你在测试用例中模拟“模式2”中提到的部分功能故障场景。对于适配层(“模式4”),你可以使用单元测试来验证它如何处理原始服务返回的数据;而对于集成测试,则可以利用MSW来检查当个别服务运行缓慢、出现故障或返回异常数据时,整个页面是否能正确显示。
何时应该提出反对意见
并非所有的微服务相关问题都有前端解决方案。有时候,正确的做法就是对现有的架构设计提出质疑。
当下端代码在渲染单个页面时需要调用超过5个API接口时,就应该提出反对意见。这通常说明这些服务的设计过于细致,或者系统中缺少某种聚合层。解决这个问题的方法是使用BFF或复合API,而不是在浏览器中继续增加Promise.all的调用次数。
当两个服务针对同一个实体返回相互矛盾的数据时,也应该提出反对意见。如果用户服务显示用户的名字是“Jane”,而订单服务却显示为“Janet”,这就是一个数据一致性问题,前端程序无法解决这类问题。必须从源头上进行修复,要么通过服务之间的事件驱动同步机制来协调数据,要么指定某个服务作为该字段的权威数据来源。
当下端开发团队在未提前通知的情况下对代码进行破坏性修改时,也应当及时提出反对意见。如果你的生产环境中的应用程序因为某个服务在版本更新中更改了某个字段的名称而出现故障,那显然就是流程设计上的问题。因此,我们应该倡导使用带有版本号的API接口、发布弃用通知,并进行契约测试。
你不仅仅是这些API接口的消费者,更是这些接口设计方案的参与者。越早参与到API设计的讨论中,你在生产环境中遇到的意外情况就会越少。
结论
本文介绍的各种模式为你提供了结构化的起点,但所有这些模式所遵循的基本原则都是一致的:
关键要点:
-
掌控聚合层:通过采用这种架构,前端团队能够控制响应数据的格式,并且可以在服务器端处理部分故障,而无需让浏览器来承担这些任务。
-
将所有数据源分为关键类型和非关键类型:
这一决策会直接影响你的错误处理机制、超时设置以及页面各部分的加载策略。
-
在接口层进行数据规范化处理:
原始服务响应与前端组件之间的适配层能够保护你免受上游API变更的影响,同时确保你的应用程序使用统一的领域模型。
-
重视契约设计:
机器可读的API契约、自动生成的数据类型以及契约测试能够帮助你在开发阶段发现潜在的问题,而不会等到生产环境才出现问题。
-
在必要时提出反对意见:
并非所有微服务相关问题都有前端解决方案。如果某种架构设计会给用户界面层带来不必要的负担,应尽早表达自己的担忧。
微服务虽然属于后端架构层面的决策,但它们的影响在前端层面最为明显。本文介绍的各种模式虽然无法彻底消除这种复杂性,但能够帮助你以更加有条理的方式来应对它。