This is the full developer documentation for FEAT # 快速开始 > 连接一个可用模型,让 Feat AI 真正返回第一段文本,理解整体学习路径 Feat AI 适合已经会写 Java 服务,现在想把模型能力接进来的开发者。 ## 编写第一个应用 [Section titled “编写第一个应用”](#编写第一个应用) 你将构建一个最小的 AI 对话应用,让模型返回一段关于 Feat 框架的介绍文本。 **前置要求**:JDK 8+、Maven 3.6+、以下二选一: * **本地 Ollama**:已安装并运行,且本地拉取了可用模型 * **云端模型**:已准备好环境变量 `FEAT_AI_API_KEY` ### 1. 添加依赖 [Section titled “1. 添加依赖”](#1-添加依赖) 在 `pom.xml` 中加入: pom.xml ```xml tech.smartboot.feat feat-ai ${feat.version} ``` 注意 `${feat.version}` 请替换为实际版本。最新版本可以在 [Maven Central](https://central.sonatype.com/artifact/tech.smartboot.feat/feat-ai) 查看。 ### 2. 编写程序 [Section titled “2. 编写程序”](#2-编写程序) * 本地 Ollama(最快验证) 先确认本地模型可用: ```shell ollama run qwen2.5:7b ``` 然后创建 `HelloFeatAI.java`: HelloFeatAI.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.chat.ChatModel; import tech.smartboot.feat.ai.chat.ChatStreamListener; public class HelloFeatAI { public static void main(String[] args) { ChatModel model = FeatAI.chatModel(opts -> opts .baseUrl("http://localhost:11434/v1") .model("qwen2.5:7b") ); model.chatStream("用一句话介绍 Feat 框架", new ChatStreamListener() { @Override public void onStreamResponse(String content) { System.out.print(content); } }); } } ``` * 云端模型 如果你已有云端 API Key,创建 `HelloFeatAI.java`: HelloFeatAI.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.chat.ChatModel; public class HelloFeatAI { public static void main(String[] args) { ChatModel model = FeatAI.chatModel(opts -> opts .model("Qwen2.5-72B-Instruct") ); model.chat("用一句话介绍 Feat 框架").thenAccept(response -> { System.out.println(response.getContent()); }); } } ``` 警告 云端模型默认读取环境变量 `FEAT_AI_API_KEY`。如果未设置,请求阶段会直接失败。 ### 3. 运行并验证 [Section titled “3. 运行并验证”](#3-运行并验证) 运行 `HelloFeatAI.main()`,控制台应该出现一段真实文本,类似: ```text Feat 是一个轻量级、高性能的 Java Web 服务开发框架。 ``` 只要满足以下三点,就算成功: * 没有抛异常 * 控制台有输出 * 不是空字符串 提示 如果程序运行完立刻退出、没看到输出:`chatStream` 是异步的,如果 `main` 方法直接结束,程序可能在回调触发前就退出了。在 quickstart 阶段可以简单加一行 `Thread.sleep(30000)` 观察效果,生产代码应使用更成熟的异步等待机制。 ## 两种调用方式的区别 [Section titled “两种调用方式的区别”](#两种调用方式的区别) Feat AI 提供两种与模型交互的方式,选择取决于你的场景: | 方法 | 返回值 | 适用场景 | | ----------------- | ------------------------------------------------------------------------------- | ---------------------- | | `chat(...)` | `CompletableFuture` | 需要完整结果后再处理,如后端任务、数据提取 | | `chatStream(...)` | `void`(通过 `ChatStreamListener` 回调;v2.5.0 起返回 `CompletableFuture`) | 需要实时消费片段,如终端输出、前端打字机效果 | ## 继续学习 [Section titled “继续学习”](#继续学习) 完成第一个应用后,按以下顺序深入: [对话与流式 ](/feat/ai/chat/)学会配置模型、同步调用和流式输出。 [Agent ](/feat/ai/agent/)当模型开始需要调用工具时,从这里继续。 [Embedding ](/feat/ai/embedding/)当你开始做检索增强、RAG 或语义搜索时,再进入这一页。 [Agent 记忆 ](/feat/ai/memory/)当 Agent 需要记住上一轮对话、跨会话记住用户偏好时,从这里继续。 ## 常见问题 [Section titled “常见问题”](#常见问题) ### 本地 Ollama 没有输出 [Section titled “本地 Ollama 没有输出”](#本地-ollama-没有输出) 1. 确认 Ollama 服务正在运行 2. 确认本地已拉取对应模型(`ollama list`) 3. 确认 `baseUrl` 与本机环境一致(默认端口 11434) ### 云端模型报错 [Section titled “云端模型报错”](#云端模型报错) 优先检查 `FEAT_AI_API_KEY` 是否在当前运行环境中可见,而不是只在另一个终端窗口 `export` 过。 # Agent 工具 > 用 Feat AI 的 Agent 机制组织模型与工具协作,而不是只做单次问答 如果说前面的对话模型是在回答”模型能不能说话”,那么 Agent 这页解决的是下一步问题: > 当模型需要去读网页、调工具、分步完成任务时,该怎么组织它? Feat AI 的 Agent 是建立在 ReAct 思路上的:模型先做判断,再调用工具,再根据工具结果继续推进。 Agent 让模型能够调用工具、读取外部信息、分步完成任务。当普通对话模型回答不了需要实时数据的问题,或者任务本身需要多步骤执行时,就需要引入 Agent 机制。如果你当前只是普通问答、摘要、翻译或单次生成,[对话模型](/feat/ai/chat/) 已经足够。 ## 先看一个最小 Agent [Section titled “先看一个最小 Agent”](#先看一个最小-agent) AgentDemo.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.agent.FeatAgent; import tech.smartboot.feat.ai.chat.ChatModelVendor; public class AgentDemo { public static void main(String[] args) throws Exception { FeatAgent agent = FeatAI.agent(opts -> { opts.chatOptions().model(ChatModelVendor.GiteeAI.Qwen2_5_72B_Instruct); opts.tool(new tech.smartboot.feat.ai.agent.tools.WebPageReaderTool()); }); String result = agent.execute( "从 https://smartboot.tech/smart-mqtt/ 中给我看看这款产品的定价是多少" ).get(); System.out.println(result); System.out.println(agent.getState()); } } ``` 这段代码的重点只有两件事: * 先给 Agent 一个对话模型 * 再给它至少一个它能调用的工具 没有工具时,Agent 只是”会多想一点”的模型;有了工具之后,它才真正开始具备完成复杂任务的能力。 ## Agent 和普通 ChatModel 的区别 [Section titled “Agent 和普通 ChatModel 的区别”](#agent-和普通-chatmodel-的区别) ### ChatModel [Section titled “ChatModel”](#chatmodel) 适合: * 单次问答 * 提示词生成 * 普通对话 ### FeatAgent [Section titled “FeatAgent”](#featagent) 适合: * 需要外部信息的任务 * 多步骤任务 * 要把”推理”和”执行”串起来的场景 所以不要把 Agent 理解成”更强的对话模型”,它更像是”对话模型 + 工具调度层”。 ## 什么时候该开始注册工具 [Section titled “什么时候该开始注册工具”](#什么时候该开始注册工具) 这是 Agent 是否真正有用的分界点。 常见工具包括: * 网页读取 * 搜索 * 文件操作 在 Feat AI 里,你可以把工具注册到 `AgentOptions` 上: RegisterTools.java ```java import tech.smartboot.feat.ai.agent.tools.SearchTool; import tech.smartboot.feat.ai.agent.tools.WebPageReaderTool; import tech.smartboot.feat.ai.agent.tools.FileOperationTool; opts.tool(new SearchTool()); opts.tool(new WebPageReaderTool()); opts.tool(new FileOperationTool()); ``` Feat AI 内置了这些工具,所以你完全可以先从现成工具开始,而不是立刻写自定义工具。 ## 一个容易踩的坑 [Section titled “一个容易踩的坑”](#一个容易踩的坑) 做 Agent 时,不要一开始就追求”大而全工具箱”。更稳的方式通常是: 1. 先让一个 Agent 只接一两个工具 2. 确认它真的能稳定完成任务 3. 再继续加工具和更复杂的提示策略 否则你最后很可能不知道到底是模型问题、工具问题,还是调度策略问题。 ## 常见问题 [Section titled “常见问题”](#常见问题) ### Agent 总是在空想,不调用工具 [Section titled “Agent 总是在空想,不调用工具”](#agent-总是在空想不调用工具) 优先检查: 1. 你是否真的注册了工具 2. 任务本身是否明确暗示需要外部信息 3. 模型能力是否足以理解当前工具描述 ### Agent 能调用工具,但结果质量一般 [Section titled “Agent 能调用工具,但结果质量一般”](#agent-能调用工具但结果质量一般) 这通常不是一个单点问题。可能涉及: * 模型本身 * 工具描述是否清楚 * 工具返回内容是否可消费 * 提示策略是否过弱或过乱 ### 我是不是应该先学 Agent 再学 Chat [Section titled “我是不是应该先学 Agent 再学 Chat”](#我是不是应该先学-agent-再学-chat) 不建议。如果你还没把普通模型调用用熟,Agent 的排错成本会高很多。 # 对话模型 > 掌握 ChatModel 的完整配置、两种调用模式、多轮对话与错误处理 在 [快速入门](/feat/ai/) 中,你已经跑通了最基本的模型调用。这页系统讲解 `ChatModel` 的配置项、两种调用模式的完整用法、多轮对话的实现方式,以及如何处理异常。 提示 **Feat AI 的底层支撑**:`ChatModel` 基于 Feat Core 的 [`HttpClient`](/feat/client/http_client/) 构建,同步调用使用标准 HTTP 请求,流式调用则基于 [`SseClient`](/feat/client/sse_client/) 实现 Server-Sent Events 协议。这意味着你获得的是 Feat Core 的高性能异步 I/O、连接池复用和完整的 HTTP 协议支持。 这里会详细介绍 `ChatModel` 的配置选项、两种调用方式的区别、多轮对话的实现,以及异常处理。如果你刚跑通第一个示例,想深入了解如何控制模型行为,从这里继续。 ## 创建 ChatModel [Section titled “创建 ChatModel”](#创建-chatmodel) `FeatAI.chatModel(...)` 接受一个 `Consumer`,所有配置都通过链式调用完成: ChatModelConfig.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.chat.ChatModel; ChatModel chatModel = FeatAI.chatModel(opts -> opts .model("Qwen2.5-72B-Instruct") .system("你是一个专业的 Java 工程师。") .debug(false) ); ``` ### 核心配置项 [Section titled “核心配置项”](#核心配置项) | 配置项 | 说明 | 默认值 | | ---------------- | --------------- | ---------------------------------------- | | `model(...)` | 模型名称 | 必填 | | `system(...)` | 系统提示词,定义角色和输出风格 | `null` | | `baseUrl(...)` | 服务地址 | 从环境变量 `FEAT_AI_BASE_URL` 读取,或默认 Gitee AI | | `apiKey(...)` | API 密钥 | 从环境变量 `FEAT_AI_API_KEY` 读取 | | `debug(...)` | 是否打印请求/响应详情 | `false` | | `extraBody(...)` | 模型特有的额外参数 | `null` | ### 配置系统提示词 [Section titled “配置系统提示词”](#配置系统提示词) `system(...)` 是最值得优先使用的配置。它定义了模型的整体角色和输出边界,往往比反复修改用户提示词更有效: SystemPromptExample.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.chat.ChatModel; ChatModel chatModel = FeatAI.chatModel(opts -> opts .model("Qwen3-06B") .system("你是一个擅长生成藏头诗的诗人,只输出诗句,不解释。") ); ``` ### 传递模型特有参数 [Section titled “传递模型特有参数”](#传递模型特有参数) 某些模型或厂商支持标准参数之外的自定义字段,通过 `extraBody(...)` 传入: ExtraBodyExample.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.chat.ChatModel; ChatModel chatModel = FeatAI.chatModel(opts -> opts .model("qwen2.5:7b") .extraBody(body -> { body.put("seed", 42); body.put("temperature", 0.7); }) ); ``` ## 同步调用:chat(…) [Section titled “同步调用:chat(…)”](#同步调用chat) `chat(...)` 系列方法返回 `CompletableFuture`,适合需要完整响应后再处理的场景。 ### 最简单的单轮对话 [Section titled “最简单的单轮对话”](#最简单的单轮对话) SyncChatExample.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.chat.ChatModel; ChatModel chatModel = FeatAI.chatModel(opts -> opts.model("Qwen2.5-72B-Instruct")); chatModel.chat("你好,请自我介绍一下。").thenAccept(rsp -> { System.out.println("内容: " + rsp.getContent()); System.out.println("Token 消耗: " + rsp.getUsage().getTotalTokens()); }); ``` ### 检查响应状态 [Section titled “检查响应状态”](#检查响应状态) `ChatResponse` 包含响应状态和错误信息: CheckResponseExample.java ```java chatModel.chat("你好").thenAccept(rsp -> { if (rsp.isSuccess()) { System.out.println(rsp.getContent()); } else { System.err.println("请求失败: " + rsp.getError()); } }); ``` ## 流式调用:chatStream(…) [Section titled “流式调用:chatStream(…)”](#流式调用chatstream) `chatStream(...)` 通过 `ChatStreamListener` 回调实时接收模型生成的片段,适合需要逐字展示的场景。 注意 自 v2.5.0 起,`chatStream(...)` 的返回值由 `void` 变为 `CompletableFuture`,可用于等待流式调用完成。回调用法不变,现有代码无需调整。 ### 基础用法 [Section titled “基础用法”](#基础用法) StreamChatExample.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.chat.ChatModel; import tech.smartboot.feat.ai.chat.ChatStreamListener; ChatModel chatModel = FeatAI.chatModel(opts -> opts.model("Qwen2.5-72B-Instruct")); chatModel.chatStream("根据以下关键词生成一首藏头诗:情,人,节,快,乐", new ChatStreamListener() { @Override public void onStreamResponse(String content) { System.out.print(content); } }); ``` ### 完整回调:接收完成事件和推理内容 [Section titled “完整回调:接收完成事件和推理内容”](#完整回调接收完成事件和推理内容) `ChatStreamListener` 提供四个回调方法,按需重写: FullListenerExample.java ```java chatModel.chatStream("介绍一下 Feat 框架", new ChatStreamListener() { @Override public void onStreamResponse(String content) { // 每收到一个文本片段触发 System.out.print(content); } @Override public void onReasoning(String content) { // 某些模型的推理过程(如 DeepSeek-R1 的思考链) System.err.print("[思考] " + content); } @Override public void onCompletion(ChatResponse chatResponse) { // 流完全结束时触发,可获取完整 usage System.out.println("\n\n流结束,总 tokens: " + chatResponse.getUsage().getTotalTokens()); } @Override public void onError(Throwable throwable) { // 网络异常或流式传输错误 System.err.println("出错了: " + throwable.getMessage()); } }); ``` ## 多轮对话 [Section titled “多轮对话”](#多轮对话) 多轮对话的本质是:把历史消息一并发送给模型。Feat AI 不替你维护对话状态,你需要自己管理消息列表。 ### 使用消息列表 [Section titled “使用消息列表”](#使用消息列表) MultiTurnExample.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.chat.ChatModel; import tech.smartboot.feat.ai.chat.entity.Message; import java.util.ArrayList; import java.util.List; ChatModel chatModel = FeatAI.chatModel(opts -> opts.model("Qwen2.5-72B-Instruct")); List messages = new ArrayList<>(); messages.add(Message.ofSystem("你是一个专业的 Java 工程师")); messages.add(Message.ofUser("什么是 Feat?")); chatModel.chat(messages).thenAccept(rsp -> { System.out.println("AI: " + rsp.getContent()); // 把 AI 的回复也加入历史,供下一轮使用 messages.add(Message.ofAssistant(rsp.getContent())); }); ``` 注意 这个示例中,两轮请求是独立的。模型不会自动记住上一轮的内容。如需真正的上下文连贯,请使用消息列表显式传递历史。 ## 错误处理 [Section titled “错误处理”](#错误处理) ### 同步调用的错误 [Section titled “同步调用的错误”](#同步调用的错误) `chat(...)` 返回 `CompletableFuture`,异常会封装在 Future 中: SyncErrorHandling.java ```java chatModel.chat("你好").whenComplete((rsp, ex) -> { if (ex != null) { System.err.println("请求异常: " + ex.getMessage()); } else if (!rsp.isSuccess()) { System.err.println("业务错误: " + rsp.getError()); } else { System.out.println(rsp.getContent()); } }); ``` ### 流式调用的错误 [Section titled “流式调用的错误”](#流式调用的错误) 流式错误通过 `ChatStreamListener.onError(...)` 回调处理。默认实现是 `printStackTrace`,生产环境务必重写: StreamErrorHandling.java ```java chatModel.chatStream("你好", new ChatStreamListener() { @Override public void onStreamResponse(String content) { System.out.print(content); } @Override public void onError(Throwable throwable) { // 自定义错误处理,如记录日志、通知用户 logger.error("流式调用失败", throwable); } }); ``` ### HTTP 状态码异常 [Section titled “HTTP 状态码异常”](#http-状态码异常) Feat AI 底层复用 Feat Core 的 HTTP 错误处理机制,自动识别以下场景: * **401/403**:API Key 无效或权限不足 * **429**:请求频率过高 * **5xx**:服务端错误 这些错误会触发 `onError` 回调,或在同步调用中导致 `CompletableFuture` 异常完成。得益于 Feat Core 的完整 HTTP 协议实现,你能获得准确的错误信息和状态码。 ## 常见问题 [Section titled “常见问题”](#常见问题) ### 流式输出为什么是碎片化的 [Section titled “流式输出为什么是碎片化的”](#流式输出为什么是碎片化的) 这是预期行为。大语言模型按 token 生成内容,流式传输每次推送一个或几个 token,不会等待完整句子。 ### 为什么模型似乎”不记得”之前的对话 [Section titled “为什么模型似乎”不记得”之前的对话”](#为什么模型似乎不记得之前的对话) 因为每个 `chat(...)` / `chatStream(...)` 调用都是独立的 HTTP 请求。模型本身是无状态的,上下文需要通过消息列表显式传递。 ### `onReasoning` 一直没有触发 [Section titled “onReasoning 一直没有触发”](#onreasoning-一直没有触发) 并非所有模型都输出推理内容。目前 DeepSeek-R1、Qwen3 等部分模型支持,普通模型不会触发此回调。 # Embedding > 用 Feat AI 的 Embedding 能力为检索、RAG 和语义搜索准备向量数据 Embedding 不是”另一种聊天模型”,它解决的是另一类问题:如何把文本变成可计算相似度的向量表示。 如果你没有要做检索、RAG、去重、聚类或语义搜索,就不用急着看这页。 Embedding 把文本转成向量,用来解决语义相似度问题。如果你在做 RAG、语义搜索、文本去重或聚类,需要先把文本变成可计算的向量表示。如果你现在只是想调模型输出文本,可以先看 [对话模型](/feat/ai/chat/)。 ## 最小用法:单条文本向量化 [Section titled “最小用法:单条文本向量化”](#最小用法单条文本向量化) EmbeddingDemo.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.embedding.EmbeddingModel; import tech.smartboot.feat.ai.embedding.EmbeddingModelVendor; import java.util.Arrays; public class EmbeddingDemo { public static void main(String[] args) { EmbeddingModel model = FeatAI.embedding(opt -> opt.model(EmbeddingModelVendor.GiteeAI.bce_embedding_base_v1) ); float[] vector = model.embed("你好!"); System.out.println(Arrays.toString(vector)); } } ``` 这里最重要的不是打印出来的向量长什么样,而是你开始接受这件事: * 一段文本会被映射成一组数值 * 后续检索和相似度计算都基于这组数值 ## 批量向量化 [Section titled “批量向量化”](#批量向量化) 实际项目里更常见的是一次处理一批文本: BatchEmbedding.java ```java import java.util.Arrays; import java.util.List; List texts = Arrays.asList("Hello World", "你好"); List vectors = model.embed(texts); ``` 这通常比一条一条请求更实用,也更接近真实索引构建场景。 ## 模型来源怎么选 [Section titled “模型来源怎么选”](#模型来源怎么选) 和对话模型一样,这里通常也有两类来源: * 云端模型 * 本地模型(例如 Ollama) 示例里可以直接用: ```java EmbeddingModelVendor.GiteeAI.bce_embedding_base_v1 EmbeddingModelVendor.Ollama.nomic_embed_text ``` 如果你只是先验证能力有没有打通,选任何一个你当前最容易连上的来源即可。 ## 你应该怎样理解这些向量 [Section titled “你应该怎样理解这些向量”](#你应该怎样理解这些向量) 这页不要求你马上写出完整向量检索系统。你只需要先接受两件事: 1. 不同文本会变成不同向量 2. 相近语义通常会对应更接近的向量空间位置 这也是为什么 Embedding 能用来做: * 语义搜索 * 相似问题召回 * 文档块检索 ## Embedding 和 RAG 的关系 [Section titled “Embedding 和 RAG 的关系”](#embedding-和-rag-的关系) 很多人第一次看 Embedding,就是因为想做 RAG。可以把它们的关系理解成: * Embedding 负责把问题和文档都变成向量 * 向量检索负责找出最相关的文档块 * ChatModel 再基于这些文档块生成最终答案 所以 Embedding 不是 RAG 的全部,但它通常是入口之一。 ## 常见问题 [Section titled “常见问题”](#常见问题) ### 我已经有 ChatModel,为什么还要 Embedding [Section titled “我已经有 ChatModel,为什么还要 Embedding”](#我已经有-chatmodel为什么还要-embedding) 因为这两者解决的问题不同。 * ChatModel:生成文本 * Embedding:表示文本 ### 向量维度和数值范围为什么不一样 [Section titled “向量维度和数值范围为什么不一样”](#向量维度和数值范围为什么不一样) 因为不同模型的向量空间不同。所以不同 Embedding 模型生成的向量一般不要混用。 ### 我是不是应该一开始就接向量库 [Section titled “我是不是应该一开始就接向量库”](#我是不是应该一开始就接向量库) 不一定。如果你只是验证 Embedding 能力,先完成单条和批量向量化就够了。等你真正开始做检索链路时,再引入向量库。 # Jina AI 工具 > 用 Jina AI 把网页转换成结构化文本,再作为 Feat Agent 的一个工具来使用 这页讲的是一个非常具体的场景:当 Agent 需要读网页,而模型本身又拿不到实时网页内容时,如何补一个”网页读取工具”。 Jina AI 提供了一个很直接的能力:把网页 URL 转成结构化文本。对于 Agent 来说,这种能力刚好适合被包装成工具。 Jina AI 可以把网页 URL 转成结构化文本,适合给 Agent 当工具用。当问题依赖网页上的实时信息,而你又不想自己维护复杂的抓取逻辑时,这个方案很直接。如果你的任务根本不需要联网,或者只依赖本地知识库,这页的方案可能用不上。 ## 核心思路 [Section titled “核心思路”](#核心思路) 思路其实很简单: 1. 实现一个 `AgentTool` 2. 工具里接收 URL 参数 3. 调用 Jina AI 的网页读取接口 4. 把返回文本交回 Agent ## 最小工具实现 [Section titled “最小工具实现”](#最小工具实现) JinaReaderTool.java ```java import com.alibaba.fastjson2.JSONObject; import tech.smartboot.feat.Feat; import tech.smartboot.feat.ai.agent.AgentTool; import tech.smartboot.feat.core.common.FeatUtils; import java.util.concurrent.CompletableFuture; public class JinaReaderTool implements AgentTool { @Override public CompletableFuture execute(JSONObject parameters) { String url = parameters.getString("url"); if (FeatUtils.isBlank(url)) { return CompletableFuture.completedFuture("错误:必须提供 url 参数"); } String jinaUrl = buildJinaUrl(url); return Feat.httpClient(jinaUrl, opts -> opts.debug(false)) .get() .submit() .thenApply(response -> response.body()) .exceptionally(e -> "执行失败: " + e.getMessage()); } private String buildJinaUrl(String targetUrl) { if (targetUrl.startsWith("http://")) { return "https://r.jina.ai/http://" + targetUrl.substring(7); } else if (targetUrl.startsWith("https://")) { return "https://r.jina.ai/https://" + targetUrl.substring(8); } return "https://r.jina.ai/https://" + targetUrl; } @Override public String getName() { return "jina_web_reader"; } @Override public String getDescription() { return "读取任意网页内容并返回结构化文本"; } @Override public String getParametersSchema() { return "{\n" + " \"type\": \"object\",\n" + " \"properties\": {\n" + " \"url\": {\n" + " \"type\": \"string\",\n" + " \"description\": \"要读取的网页 URL\"\n" + " }\n" + " },\n" + " \"required\": [\"url\"]\n" + "}"; } } ``` ## 挂到 Agent 上 [Section titled “挂到 Agent 上”](#挂到-agent-上) AgentWithJinaTool.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.agent.FeatAgent; import tech.smartboot.feat.ai.chat.ChatModelVendor; FeatAgent agent = FeatAI.agent(opts -> { opts.chatOptions().model(ChatModelVendor.GiteeAI.Qwen2_5_72B_Instruct); opts.tool(new JinaReaderTool()); opts.maxIterations(10); }); String result = agent.execute( "查看 https://jina.ai 并告诉我这是做什么的公司" ).get(); ``` 这就够了。Agent 现在已经拥有了一个”读取网页正文”的工具能力。 警告 运行前请确保已设置 `FEAT_AI_API_KEY`,并且当前网络环境能够访问 Jina AI 服务。 ## 什么时候它有价值 [Section titled “什么时候它有价值”](#什么时候它有价值) 这个工具最适合拿来做: * 新闻摘要 * 产品调研 * 文档检索 * 实时网页内容分析 它不适合解决: * 需要完整浏览器执行 JavaScript 的场景 * 需要登录态或复杂反爬处理的页面 * 高频、大规模抓取任务 ## 一个实践建议 [Section titled “一个实践建议”](#一个实践建议) 第一次接这类工具时,不要一口气加太多外部工具。先只给 Agent 一个网页读取工具,看它能不能稳定完成任务,再决定要不要继续加搜索、文件读写等能力。 # Agent 记忆 > 用 Memory 为 Agent 增加会话记忆:内存实现快速上手,向量记忆支撑长期记忆与语义检索 默认情况下,[Agent](/feat/ai/agent/) 每次调用都是独立的——它不记得上一轮你说过什么。`Memory` 机制让 Agent 具备跨轮次的记忆能力:自动存下交互历史,并在后续调用时检索相关记忆注入上下文。 ## 最小用法:内存记忆 [Section titled “最小用法:内存记忆”](#最小用法内存记忆) MemoryDemo.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.agent.FeatAgent; public class MemoryDemo { public static void main(String[] args) throws Exception { FeatAgent agent = FeatAI.agent(opts -> { opts.chatOptions().model(/* 你的模型配置 */); // 启用内存记忆 opts.memory(memoryOpts -> memoryOpts .maxMessages(100) // 最多保留 100 条 .defaultTopK(5)); // 每次检索 5 条注入上下文 opts.sessionId("user-session-123"); }); agent.execute("你好,我是张三").get(); agent.execute("请问我叫什么名字?").get(); // Agent 能答上来 } } ``` 三件事各自的作用: * `opts.memory(...)`:启用记忆并配置策略 * `opts.sessionId(...)`:会话隔离——不同用户的记忆互不可见 * `opts.memoryTopK(n)`:每次调用检索多少条历史记忆注入提示词 内存实现(`InMemoryMemory`)数据存在 JVM 里,**重启即失**,适合开发测试和单机会话场景。 ## 记忆的工作机制 [Section titled “记忆的工作机制”](#记忆的工作机制) 理解这两点,可以避免对记忆系统的误解: 1. **检索是按相关性注入,不是全量回放**。每次调用 Agent 时,会用当前输入检索最相关的 topK 条历史记忆注入上下文——`maxMessages` 只是存储上限,不是上下文长度 2. **会话隔离默认开启**(`sessionIsolation=true`),检索范围限定在当前 `sessionId` 内;跨会话共享知识请关闭隔离或使用向量记忆的全局集合 其他常用配置: | 配置 | 默认值 | 说明 | | --------------------- | ----- | -------------------- | | `similarityThreshold` | 0.5 | 相似度低于阈值的记忆不参与检索 | | `enableDeduplication` | false | 合并内容高度相似的消息(阈值 0.95) | | `importanceThreshold` | 0.0 | 按消息重要性过滤,低价值记忆不检索 | | `includeThoughts` | false | 是否把 Agent 的思考过程也存入记忆 | ## 长期记忆:向量记忆 [Section titled “长期记忆:向量记忆”](#长期记忆向量记忆) 内存记忆按时间窗口保留,适合”本次会话内记得住”。要让 Agent 跨会话长期记住用户偏好、历史结论,用向量记忆——它把记忆交给向量库,检索按语义相似度进行: VectorMemoryDemo.java ```java import tech.smartboot.feat.ai.FeatAI; import tech.smartboot.feat.ai.agent.FeatAgent; import tech.smartboot.feat.ai.agent.memory.Memory; import tech.smartboot.feat.ai.vector.ChromaVectorOptions; import tech.smartboot.feat.ai.vector.ChromaVectorStore; public class VectorMemoryDemo { public static void main(String[] args) { FeatAgent agent = FeatAI.agent(opts -> { opts.chatOptions().model(/* 你的模型配置 */); opts.memory(Memory.vector(vectorOpts -> { vectorOpts.vectorStore(new ChromaVectorStore(new ChromaVectorOptions() .setUrl("http://localhost:8000"))); vectorOpts.embeddingModel(/* EmbeddingModel 实例,见 Embedding 章节 */); vectorOpts.collectionName("agent_memory"); })); opts.memoryTopK(5); }); // ... } } ``` 向量记忆需要两个外部依赖:一个 **EmbeddingModel**(把文本转向量,见 [Embedding](/feat/ai/embedding/))和一个 **VectorStore**。内置支持 Chroma 和 Milvus(`ChromaVectorStore` / `MilvusVectorStore`)。 提示 选型判断很简单:会话内记忆用 `Memory.inMemory(...)`,跨会话/海量历史用 `Memory.vector(...)`。不要一开始就上向量库——先确认”记住上次说过什么”对你的产品是真的需求。 ## 直接操作 Memory API [Section titled “直接操作 Memory API”](#直接操作-memory-api) 不经过 Agent 也可以独立使用记忆存取: MemoryApiDemo.java ```java import tech.smartboot.feat.ai.agent.memory.Memory; import tech.smartboot.feat.ai.agent.memory.MemoryMessage; Memory memory = Memory.inMemory(opts -> opts.maxMessages(50)); memory.add(MemoryMessage.ofUser("用户喜欢简洁的回答")); memory.add(MemoryMessage.ofAssistant("好的,已记住")); // 按相关性检索 List related = memory.search("回答风格", 3); // 取最近 N 条 List recent = memory.getRecent(5); // 清除当前会话 memory.clear(); ``` 适合把记忆能力嵌入自己的编排逻辑,而不是完全交给 ReAct 循环。 ## 生产注意事项 [Section titled “生产注意事项”](#生产注意事项) * **多实例部署**时,内存记忆各节点独立,用户一轮在 A 节点、下一轮到 B 节点就”失忆”。多实例必须用向量记忆 + 外部向量库 * **隐私合规**:记忆内容是用户交互原文,落地向量库前确认保留策略与用户知情机制 * **成本控制**:每次调用都会做一次向量检索,记忆规模大时关注向量库的查询延迟 # AOT 机制 > 从编译期代码生成到 CloudService 运行期加载,理解 Feat Cloud 的 AOT 机制到底在做什么 Feat Cloud 里所谓的 AOT,不是一个抽象口号。\ 它的含义很具体:在编译期把一部分原本可能在运行期做的事情提前做掉,生成可直接加载的服务代码。 ``` flowchart LR A["源码里的 @Controller / @Bean / @Mapper"] --> B["FeatAnnotationProcessor"] B --> C["生成 CloudService 实现"] C --> D["写入 META-INF/services/CloudService"] D --> E["运行期 ApplicationContext"] E --> F["ServiceLoader 加载 CloudService"] F --> G["加载 Bean / 注入依赖 / 注册路由"] ``` 如果你只是正常使用 Feat Cloud,其实不需要先理解这一页。\ 但如果你想知道它为什么能以这种方式组织 Bean、Controller、Mapper 和运行时加载流程,这一页会有帮助。 ## 先把问题说清楚 [Section titled “先把问题说清楚”](#先把问题说清楚) 在很多常见 Java Web 框架里,运行时通常要做这些事: * 扫描类和注解 * 解析 Controller * 建立路由映射 * 注入依赖 * 生成或缓存一些元数据 Feat Cloud 选择把其中很大一部分提前到编译期完成。\ 这也是它 AOT 设计的起点。 ## 编译期到底生成了什么 [Section titled “编译期到底生成了什么”](#编译期到底生成了什么) 从仓库结构可以直接看出这套机制的几个关键模块: * `feat-cloud-aot` * `feat-cloud-starter` * `feat-cloud` 其中: * `feat-cloud-aot` 提供编译期的注解处理器(APT)和代码生成逻辑 * `feat-cloud-starter` 是你在工程里引入的编译期依赖,内部包装了 `feat-cloud-aot` * `feat-cloud` 负责运行期加载和执行这些服务 编译期最关键的入口是: * `tech.smartboot.feat.cloud.aot.FeatAnnotationProcessor` 它会处理 Feat Cloud 关心的注解,并生成对应的服务类和服务描述文件。 ## 它生成的不是“元数据”,而是“可执行服务类” [Section titled “它生成的不是“元数据”,而是“可执行服务类””](#它生成的不是元数据而是可执行服务类) 在 Feat Cloud 的运行模型里,最终被加载的是 `CloudService` 实现。\ 运行时的 `ApplicationContext` 会通过 `ServiceLoader.load(CloudService.class)` 去加载这些服务。 这意味着 AOT 做的核心事情不是只存一份配置,而是生成一批真正可以被运行期直接执行的类。 这也是为什么在你查看 `target/generated-sources/annotations` 时,会看到诸如: * `UserControllerCloudService` * `UserMapperCloudService` * `FeatApplication` 这类名字的类。 ## 为什么这件事重要 [Section titled “为什么这件事重要”](#为什么这件事重要) 如果编译期已经生成好了这些服务类,运行期就不需要再去做同样级别的动态分析。\ 它可以更直接地进入下面这些动作: * 加载服务 * 初始化 Bean * 注入依赖 * 注册路由 这会带来几个实际效果: * 运行期逻辑更直接 * 启动过程的工作量更可控 * 对 Native Image 这类场景更友好 ## 以 Controller 为例理解 AOT [Section titled “以 Controller 为例理解 AOT”](#以-controller-为例理解-aot) 当你写下一个普通控制器: ```java @Controller("users") public class UserController { @RequestMapping("/hello") public String hello() { return "hello"; } } ``` 在 Feat Cloud 的 AOT 体系里,编译期不会只记住“这里有个 Controller”。\ 它会生成对应的 CloudService 类,并在其中明确写出: * 如何实例化这个 Controller * 如何把它注册进应用上下文 * 如何把 `/users/hello` 这条路由挂到 Router 上 所以从运行期视角看,它接手到的已经不是“一个待解析的 Controller 类”,而是“一个已经具备执行逻辑的 CloudService”。 ## Bean、Mapper、CloudOptions 也在做同样的事 [Section titled “Bean、Mapper、CloudOptions 也在做同样的事”](#beanmappercloudoptions-也在做同样的事) 从 `feat-cloud-aot` 里的实现可以看到,AOT 处理并不只针对 Controller。 它还会围绕这些方向生成代码: * Bean * Mapper * CloudOptions 相关扩展 这也是为什么 `MyBatis`、`CloudOptions`、`Controller` 这几块内容在 Feat Cloud 里能一起被纳入同一套编译期体系里理解。 ## 运行期是怎么把这些东西串起来的 [Section titled “运行期是怎么把这些东西串起来的”](#运行期是怎么把这些东西串起来的) 真正串联这套机制的是 `ApplicationContext`。 它的大致运行路径可以理解成: 1. 用 `ServiceLoader` 找到所有 `CloudService` 2. 按顺序加载这些服务 3. 让服务完成 Bean 加载、注入、路由注册等动作 4. 最终把应用挂到运行时的 Router 和上下文里 所以从外部看你只是调用了: ```java FeatCloud.cloudServer().listen(); ``` 但内部真正工作的,是一套已经在编译期准备好的服务装配流程。 ## 和 Native Image 的关系 [Section titled “和 Native Image 的关系”](#和-native-image-的关系) AOT 编译期生成带来的另一个好处,是让 Native Image 变得可行:生成代码把反射和动态代理的使用压到了最低,静态分析更容易收敛。 但 AOT 编译期机制和 Native Image 是两件事:前者是 Feat Cloud 自己的编译期代码生成,后者是 GraalVM 对整个应用的提前编译。如果你关心的是交付形态,请直接阅读[部署与交付](/feat/cloud/deployment/)中的 Native 优化路线。 ## 这套机制对你意味着什么 [Section titled “这套机制对你意味着什么”](#这套机制对你意味着什么) 从使用者角度,AOT 最重要的意义不是“编译期很高级”,而是它会影响你怎么理解下面这些事: * 为什么 Feat Cloud 的启动过程长这样 * 为什么某些能力更适合编译期决定,而不是运行期反射 * 为什么它和传统运行期扫描框架的运行模型不一样 换句话说,这页的价值不在于教你“怎么手写 AOT”,而在于帮你理解 Feat Cloud 这套架构为什么是现在这个样子。 ## 什么时候需要回来看这页 [Section titled “什么时候需要回来看这页”](#什么时候需要回来看这页) 这页最适合在下面这些时机阅读: * 你已经用过 Feat Cloud,但想理解它的底层机制 * 你在看 `target/generated-sources/annotations`,想知道这些类为什么存在 * 你准备继续研究 Native Image 相关内容 提示 如果你现在还在学习主线教程,这页可以先略过。等你已经完成 [Feat Cloud 导览](/feat/cloud/)、[编译期工作模型](/feat/cloud/runtime/) 和 [CloudOptions 配置](/feat/cloud/options/) 之后,再回来读它会更顺。 # 性能测试 > 记录当前一组 Feat、Vert.x、Quarkus、Spring Boot 的简单基准测试结果,并说明应如何解读这些数据 这一页记录的是一组基准测试结果,而不是“永远正确的性能结论”。\ 如果你要引用这些数据,最重要的不是只看数字,而是先理解测试对象、边界条件和它不包含什么。 ## 测试概述 [Section titled “测试概述”](#测试概述) 本报告比较了四个Java Web框架的性能:Feat、Vert.x、Quarkus和Spring Boot。测试使用wrk工具,针对每个框架的Hello World和JSON响应接口进行了性能测试。 测试参数:4个线程,100个连接,持续60秒,开启延迟统计。 **测试结论** 根据测试结果,可以得出以下结论: * 在JSON响应接口测试中,Feat框架表现最佳,每秒处理请求数最多。 * 在Hello World接口测试中,Feat框架表现最佳,每秒处理请求数最多。 ## 详细图表 [Section titled “详细图表”](#详细图表) ### 构建耗时对比 [Section titled “构建耗时对比”](#构建耗时对比) 构建耗时说明: Feat、Vert.x 和 Spring Boot 统计的是各自执行 mvn clean install -DskipTests 的实际时间。 Quarkus 当前使用仓库中已有的 quarkus-app.zip 预构建包,因此不计入编译耗时。 ![编译耗时统计](/feat/_astro/bench_build_time.DSPi_mHg.png) | 框架 | 构建方式 | 耗时 | | ----------- | ------------------- | ---------------- | | Feat | Maven clean install | 2.82 秒 (2819 ms) | | Vert.x | Maven clean install | 2.59 秒 (2586 ms) | | Spring Boot | Maven clean install | 2.42 秒 (2419 ms) | | Quarkus | 预构建 quarkus-app.zip | 未统计编译耗时 | ### Hello World [Section titled “Hello World”](#hello-world) ![Hello World 每秒请求数](/feat/_astro/bench_hello_world_rps.C118A5l_.png) ![Hello World 平均响应时间](/feat/_astro/bench_hello_world_avg.DtrbHPvf.png) ### JSON 响应 [Section titled “JSON 响应”](#json-响应) ![JSON 响应每秒请求数](/feat/_astro/bench_json_rps.BNN_1cHR.png) ![JSON 响应平均响应时间](/feat/_astro/bench_json_avg.Bu8txEq0.png) ## 结果表 [Section titled “结果表”](#结果表) | 测试类型 | 框架 | 每秒请求数 | 平均响应时间 (ms) | 错误率 (%) | | ----------- | ----------- | --------- | ----------- | ------- | | Hello World | Feat | 158588.49 | 0.83 | 0.00 | | Hello World | Quarkus | 80539.40 | 1.55 | 0.00 | | Hello World | Spring Boot | 39952.10 | 3.87 | 0.00 | | Hello World | Vert.x | 84842.99 | 1.29 | 0.00 | | JSON 响应 | Feat | 158186.41 | 0.92 | 0.00 | | JSON 响应 | Quarkus | 81771.06 | 1.60 | 0.00 | | JSON 响应 | Spring Boot | 40531.54 | 2.47 | 0.00 | | JSON 响应 | Vert.x | 77179.81 | 1.40 | 0.00 | ## 这组数据应该怎么解读 [Section titled “这组数据应该怎么解读”](#这组数据应该怎么解读) 这组基准测试最适合支持下面这种判断: * 在“非常轻的 HTTP 接口”场景下,Feat 的底座性能没有成为瓶颈 * Feat 在简单请求路径上的吞吐能力是它的重要卖点之一 它不适合直接支持下面这些过度推断: * “所有真实业务场景里 Feat 都一定更快” * “换成 Feat 就一定能立刻省掉同等比例的机器成本” * “复杂业务系统的整体延迟一定会按同样比例下降” 警告 真实项目里,数据库、缓存、序列化、线程模型、网络拓扑和业务逻辑复杂度,往往比这个基准测试本身更能决定最终表现。 ## 什么时候这页有价值 [Section titled “什么时候这页有价值”](#什么时候这页有价值) 这页最有价值的使用方式通常有三种: 1. 你在做技术选型,需要知道 Feat 的底层性能大致处在什么级别 2. 你已经确定要用 Feat,希望知道它适不适合高吞吐、低延迟场景 3. 你在给团队解释为什么 Feat 的设计会从服务底座出发 ## 如果你要做自己的性能测试 [Section titled “如果你要做自己的性能测试”](#如果你要做自己的性能测试) 不要照搬这页里的结论,直接套到自己系统上。\ 更合理的做法是: 1. 保持业务逻辑尽量等价 2. 固定硬件、JDK、并发和网络环境 3. 区分“框架基准”和“业务基准” 4. 先看吞吐,再看尾延迟和资源占用 如果你真正关心的是你的系统,而不是框架宣传页,那么自己的压测结果一定比这页更重要。 # 公众号文章 > 补充阅读材料,适合在你已经完成主线学习之后进一步了解项目背景与实践案例 这一页不属于主教程线。你已经完成主线文档后,可以从这里继续看项目在外部传播里是如何被介绍和讨论的。 [![当所有框架都在拥抱 AI 写代码时,Feat 为什么坚持"人写"?](https://fastly.jsdelivr.net/gh/bucketio/img16@main/2026/08/15/1786790724399-e0581986-ff43-464b-920a-d32b3e7d10f0.png)](https://mp.weixin.qq.com/s/farHoKrrkClJxMaJPSsi6A) [2026/08/15](https://mp.weixin.qq.com/s/farHoKrrkClJxMaJPSsi6A) ## [当所有框架都在拥抱 AI 写代码时,Feat 为什么坚持"人写"?](https://mp.weixin.qq.com/s/farHoKrrkClJxMaJPSsi6A) [代码的终极质量,从来不取决于它能否通过测试,而取决于它是否承载了设计者的思考与审美。](https://mp.weixin.qq.com/s/farHoKrrkClJxMaJPSsi6A) [阅读原文](https://mp.weixin.qq.com/s/farHoKrrkClJxMaJPSsi6A) [![集成 Nacos,我们拒绝了 Spring Starter](https://fastly.jsdelivr.net/gh/bucketio/img17@main/2026/08/04/1785846106367-0a47ca62-9acb-43c0-97b7-74d4478da719.png)](https://mp.weixin.qq.com/s/aSKwqknJJmQ5sjmsN9MTIg) [2026/08/05](https://mp.weixin.qq.com/s/aSKwqknJJmQ5sjmsN9MTIg) ## [集成 Nacos,我们拒绝了 Spring Starter](https://mp.weixin.qq.com/s/aSKwqknJJmQ5sjmsN9MTIg) [如果一个库需要别人包一层才能用,那是这个库作者的耻辱,不是使用者的责任。](https://mp.weixin.qq.com/s/aSKwqknJJmQ5sjmsN9MTIg) [阅读原文](https://mp.weixin.qq.com/s/aSKwqknJJmQ5sjmsN9MTIg) [![Feat 为什么能这么快?答案藏在 2000 行代码里](https://fastly.jsdelivr.net/gh/bucketio/img19@main/2026/07/19/1784420195384-77f10846-0af8-4cb2-bae1-2f69c989d60c.png)](https://mp.weixin.qq.com/s/BuJbAGI3q4YLGpF1avWIXQ) [2026/07/19](https://mp.weixin.qq.com/s/BuJbAGI3q4YLGpF1avWIXQ) ## [Feat 为什么能这么快?答案藏在 2000 行代码里](https://mp.weixin.qq.com/s/BuJbAGI3q4YLGpF1avWIXQ) [真正决定 Feat 性能上限的,并不是 Feat 本身,而是它脚下那个只有约 2000 行核心代码的通信内核——smart-socket。](https://mp.weixin.qq.com/s/BuJbAGI3q4YLGpF1avWIXQ) [阅读原文](https://mp.weixin.qq.com/s/BuJbAGI3q4YLGpF1avWIXQ) [![Feat vs Quarkus:结果比我预想的还要大](https://fastly.jsdelivr.net/gh/bucketio/img7@main/2026/07/09/1783561263484-7fa06cb8-bb3b-409f-8a3c-c71058292748.png)](https://mp.weixin.qq.com/s/7goVCSPWksdCbm66rKPZWA) [2026/07/09](https://mp.weixin.qq.com/s/7goVCSPWksdCbm66rKPZWA) ## [Feat vs Quarkus:结果比我预想的还要大](https://mp.weixin.qq.com/s/7goVCSPWksdCbm66rKPZWA) [本次测试涵盖两个经典场景:JSON 响应 和 Hello World,参与对比的框架包括 Feat、Quarkus、Spring Boot 和 Vert.x。](https://mp.weixin.qq.com/s/7goVCSPWksdCbm66rKPZWA) [阅读原文](https://mp.weixin.qq.com/s/7goVCSPWksdCbm66rKPZWA) [![为什么 Java 社区挑战 Spring 的框架,最后都输给了"用户体验"?](https://fastly.jsdelivr.net/gh/bucketio/img0@main/2026/07/04/1783134695747-bd5d9efb-b8b1-4197-9606-969989dd7caf.png)](https://mp.weixin.qq.com/s/_O0h3vFFPmY9mAXqNxiaXA) [2026/07/04](https://mp.weixin.qq.com/s/_O0h3vFFPmY9mAXqNxiaXA) ## [为什么 Java 社区挑战 Spring 的框架,最后都输给了"用户体验"?](https://mp.weixin.qq.com/s/_O0h3vFFPmY9mAXqNxiaXA) [几乎每个 Java 开发者都吐槽过 Spring:启动慢、吃内存、依赖臃肿。改一行代码,等五秒重启...](https://mp.weixin.qq.com/s/_O0h3vFFPmY9mAXqNxiaXA) [阅读原文](https://mp.weixin.qq.com/s/_O0h3vFFPmY9mAXqNxiaXA) [![启动 8ms,内存仅 6MB! 这款 Java 框架把云原生做到了极致](data:image/svg+xml;charset=UTF-8,%0A%20%20%20%20%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201200%20675%22%20fill%3D%22none%22%20role%3D%22img%22%20aria-hidden%3D%22true%22%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D%22g5%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23111827%22%20%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20offset%3D%2255%25%22%20stop-color%3D%22%232563eb%22%20%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%2338bdf8%22%20%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D%221200%22%20height%3D%22675%22%20fill%3D%22url\(%23g5\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Ccircle%20cx%3D%221018%22%20cy%3D%22130%22%20r%3D%22164%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.14\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Ccircle%20cx%3D%221080%22%20cy%3D%22492%22%20r%3D%22240%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.08\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M0%20458C206%20405%20343%20355%20506%20368C662%20381%20780%20463%201200%20412V675H0V458Z%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.09\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M0%20516C174%20463%20371%20449%20524%20486C703%20530%20849%20607%201200%20550%22%20stroke%3D%22rgba\(255%2C255%2C255%2C0.16\)%22%20stroke-width%3D%2210%22%20stroke-linecap%3D%22round%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M80%20102H292%22%20stroke%3D%22rgba\(255%2C255%2C255%2C0.26\)%22%20stroke-width%3D%226%22%20stroke-linecap%3D%22round%22%20%2F%3E%0A%20%20%20%20%20%20%3Ctext%20x%3D%2280%22%20y%3D%22160%22%20fill%3D%22%23ffffff%22%20font-size%3D%2272%22%20font-weight%3D%22800%22%20font-family%3D%22Inter%2C%20-apple-system%2C%20BlinkMacSystemFont%2C%20'Segoe%20UI'%2C%20sans-serif%22%3EFeat%3C%2Ftext%3E%0A%20%20%20%20%20%20%3Ctext%20x%3D%2280%22%20y%3D%22228%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.92\)%22%20font-size%3D%2234%22%20font-weight%3D%22600%22%20font-family%3D%22Inter%2C%20-apple-system%2C%20BlinkMacSystemFont%2C%20'Segoe%20UI'%2C%20sans-serif%22%3E%E5%85%AC%E4%BC%97%E5%8F%B7%E6%96%87%E7%AB%A0%3C%2Ftext%3E%0A%20%20%20%20%20%20%3Ctext%20x%3D%2280%22%20y%3D%22548%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.9\)%22%20font-size%3D%2230%22%20font-weight%3D%22600%22%20font-family%3D%22Inter%2C%20-apple-system%2C%20BlinkMacSystemFont%2C%20'Segoe%20UI'%2C%20sans-serif%22%3E2025-08-24%3C%2Ftext%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20)](https://mp.weixin.qq.com/s/Jn0akkWfw5-XUohILN5QpQ) [2025/08/24](https://mp.weixin.qq.com/s/Jn0akkWfw5-XUohILN5QpQ) ## [启动 8ms,内存仅 6MB! 这款 Java 框架把云原生做到了极致](https://mp.weixin.qq.com/s/Jn0akkWfw5-XUohILN5QpQ) [从启动速度、内存占用和部署体验切入,介绍 Feat 面向云原生场景的轻量化能力。](https://mp.weixin.qq.com/s/Jn0akkWfw5-XUohILN5QpQ) [阅读原文](https://mp.weixin.qq.com/s/Jn0akkWfw5-XUohILN5QpQ) [![也许,现在正是重塑 Java 生态最好的时候](data:image/svg+xml;charset=UTF-8,%0A%20%20%20%20%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201200%20675%22%20fill%3D%22none%22%20role%3D%22img%22%20aria-hidden%3D%22true%22%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D%22g6%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%231e1b4b%22%20%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20offset%3D%2255%25%22%20stop-color%3D%22%237c3aed%22%20%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23c084fc%22%20%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D%221200%22%20height%3D%22675%22%20fill%3D%22url\(%23g6\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Ccircle%20cx%3D%221018%22%20cy%3D%22130%22%20r%3D%22164%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.22\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Ccircle%20cx%3D%221080%22%20cy%3D%22492%22%20r%3D%22240%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.08\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M0%20458C206%20405%20343%20355%20506%20368C662%20381%20780%20463%201200%20412V675H0V458Z%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.09\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M0%20516C174%20463%20371%20449%20524%20486C703%20530%20849%20607%201200%20550%22%20stroke%3D%22rgba\(255%2C255%2C255%2C0.16\)%22%20stroke-width%3D%2210%22%20stroke-linecap%3D%22round%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M80%20102H292%22%20stroke%3D%22rgba\(255%2C255%2C255%2C0.26\)%22%20stroke-width%3D%226%22%20stroke-linecap%3D%22round%22%20%2F%3E%0A%20%20%20%20%20%20%3Ctext%20x%3D%2280%22%20y%3D%22160%22%20fill%3D%22%23ffffff%22%20font-size%3D%2272%22%20font-weight%3D%22800%22%20font-family%3D%22Inter%2C%20-apple-system%2C%20BlinkMacSystemFont%2C%20'Segoe%20UI'%2C%20sans-serif%22%3EFeat%3C%2Ftext%3E%0A%20%20%20%20%20%20%3Ctext%20x%3D%2280%22%20y%3D%22228%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.92\)%22%20font-size%3D%2234%22%20font-weight%3D%22600%22%20font-family%3D%22Inter%2C%20-apple-system%2C%20BlinkMacSystemFont%2C%20'Segoe%20UI'%2C%20sans-serif%22%3E%E5%85%AC%E4%BC%97%E5%8F%B7%E6%96%87%E7%AB%A0%3C%2Ftext%3E%0A%20%20%20%20%20%20%3Ctext%20x%3D%2280%22%20y%3D%22548%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.9\)%22%20font-size%3D%2230%22%20font-weight%3D%22600%22%20font-family%3D%22Inter%2C%20-apple-system%2C%20BlinkMacSystemFont%2C%20'Segoe%20UI'%2C%20sans-serif%22%3E2025-03-12%3C%2Ftext%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20)](https://mp.weixin.qq.com/s/lHVY3Vam9TBlMfU-CVo_Gg) [2025/03/12](https://mp.weixin.qq.com/s/lHVY3Vam9TBlMfU-CVo_Gg) ## [也许,现在正是重塑 Java 生态最好的时候](https://mp.weixin.qq.com/s/lHVY3Vam9TBlMfU-CVo_Gg) [围绕 Java Web 开发生态的演进机会,讨论 Feat 希望解决的问题和长期方向。](https://mp.weixin.qq.com/s/lHVY3Vam9TBlMfU-CVo_Gg) [阅读原文](https://mp.weixin.qq.com/s/lHVY3Vam9TBlMfU-CVo_Gg) [![轻量级 Java 框架的性能之王](data:image/svg+xml;charset=UTF-8,%0A%20%20%20%20%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201200%20675%22%20fill%3D%22none%22%20role%3D%22img%22%20aria-hidden%3D%22true%22%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D%22g7%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%237c2d12%22%20%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20offset%3D%2255%25%22%20stop-color%3D%22%23ea580c%22%20%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23f59e0b%22%20%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D%221200%22%20height%3D%22675%22%20fill%3D%22url\(%23g7\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Ccircle%20cx%3D%221018%22%20cy%3D%22130%22%20r%3D%22164%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.14\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Ccircle%20cx%3D%221080%22%20cy%3D%22492%22%20r%3D%22240%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.08\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M0%20458C206%20405%20343%20355%20506%20368C662%20381%20780%20463%201200%20412V675H0V458Z%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.09\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M0%20516C174%20463%20371%20449%20524%20486C703%20530%20849%20607%201200%20550%22%20stroke%3D%22rgba\(255%2C255%2C255%2C0.16\)%22%20stroke-width%3D%2210%22%20stroke-linecap%3D%22round%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M80%20102H292%22%20stroke%3D%22rgba\(255%2C255%2C255%2C0.26\)%22%20stroke-width%3D%226%22%20stroke-linecap%3D%22round%22%20%2F%3E%0A%20%20%20%20%20%20%3Ctext%20x%3D%2280%22%20y%3D%22160%22%20fill%3D%22%23ffffff%22%20font-size%3D%2272%22%20font-weight%3D%22800%22%20font-family%3D%22Inter%2C%20-apple-system%2C%20BlinkMacSystemFont%2C%20'Segoe%20UI'%2C%20sans-serif%22%3EFeat%3C%2Ftext%3E%0A%20%20%20%20%20%20%3Ctext%20x%3D%2280%22%20y%3D%22228%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.92\)%22%20font-size%3D%2234%22%20font-weight%3D%22600%22%20font-family%3D%22Inter%2C%20-apple-system%2C%20BlinkMacSystemFont%2C%20'Segoe%20UI'%2C%20sans-serif%22%3E%E5%85%AC%E4%BC%97%E5%8F%B7%E6%96%87%E7%AB%A0%3C%2Ftext%3E%0A%20%20%20%20%20%20%3Ctext%20x%3D%2280%22%20y%3D%22548%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.9\)%22%20font-size%3D%2230%22%20font-weight%3D%22600%22%20font-family%3D%22Inter%2C%20-apple-system%2C%20BlinkMacSystemFont%2C%20'Segoe%20UI'%2C%20sans-serif%22%3E2025-03-09%3C%2Ftext%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20)](https://mp.weixin.qq.com/s/r4IgzoqHCKzYbATTI44anw) [2025/03/09](https://mp.weixin.qq.com/s/r4IgzoqHCKzYbATTI44anw) ## [轻量级 Java 框架的性能之王](https://mp.weixin.qq.com/s/r4IgzoqHCKzYbATTI44anw) [聚焦框架吞吐、资源效率和轻量运行时,展示 Feat 在高性能 Web 服务场景中的优势。](https://mp.weixin.qq.com/s/r4IgzoqHCKzYbATTI44anw) [阅读原文](https://mp.weixin.qq.com/s/r4IgzoqHCKzYbATTI44anw) [![DeepSeek 助力打造的国产 Java Web 服务开发框架](data:image/svg+xml;charset=UTF-8,%0A%20%20%20%20%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201200%20675%22%20fill%3D%22none%22%20role%3D%22img%22%20aria-hidden%3D%22true%22%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D%22g8%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%230f172a%22%20%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20offset%3D%2255%25%22%20stop-color%3D%22%230f766e%22%20%2F%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%2314b8a6%22%20%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D%221200%22%20height%3D%22675%22%20fill%3D%22url\(%23g8\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Ccircle%20cx%3D%221018%22%20cy%3D%22130%22%20r%3D%22164%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.22\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Ccircle%20cx%3D%221080%22%20cy%3D%22492%22%20r%3D%22240%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.08\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M0%20458C206%20405%20343%20355%20506%20368C662%20381%20780%20463%201200%20412V675H0V458Z%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.09\)%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M0%20516C174%20463%20371%20449%20524%20486C703%20530%20849%20607%201200%20550%22%20stroke%3D%22rgba\(255%2C255%2C255%2C0.16\)%22%20stroke-width%3D%2210%22%20stroke-linecap%3D%22round%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M80%20102H292%22%20stroke%3D%22rgba\(255%2C255%2C255%2C0.26\)%22%20stroke-width%3D%226%22%20stroke-linecap%3D%22round%22%20%2F%3E%0A%20%20%20%20%20%20%3Ctext%20x%3D%2280%22%20y%3D%22160%22%20fill%3D%22%23ffffff%22%20font-size%3D%2272%22%20font-weight%3D%22800%22%20font-family%3D%22Inter%2C%20-apple-system%2C%20BlinkMacSystemFont%2C%20'Segoe%20UI'%2C%20sans-serif%22%3EFeat%3C%2Ftext%3E%0A%20%20%20%20%20%20%3Ctext%20x%3D%2280%22%20y%3D%22228%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.92\)%22%20font-size%3D%2234%22%20font-weight%3D%22600%22%20font-family%3D%22Inter%2C%20-apple-system%2C%20BlinkMacSystemFont%2C%20'Segoe%20UI'%2C%20sans-serif%22%3E%E5%85%AC%E4%BC%97%E5%8F%B7%E6%96%87%E7%AB%A0%3C%2Ftext%3E%0A%20%20%20%20%20%20%3Ctext%20x%3D%2280%22%20y%3D%22548%22%20fill%3D%22rgba\(255%2C255%2C255%2C0.9\)%22%20font-size%3D%2230%22%20font-weight%3D%22600%22%20font-family%3D%22Inter%2C%20-apple-system%2C%20BlinkMacSystemFont%2C%20'Segoe%20UI'%2C%20sans-serif%22%3E2025-02-11%3C%2Ftext%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20)](https://mp.weixin.qq.com/s/azMX0KA6RzdkPaLC5YG7Ng) [2025/02/11](https://mp.weixin.qq.com/s/azMX0KA6RzdkPaLC5YG7Ng) ## [DeepSeek 助力打造的国产 Java Web 服务开发框架](https://mp.weixin.qq.com/s/azMX0KA6RzdkPaLC5YG7Ng) [介绍 Feat 与 AI 原生开发体验的结合,以及国产 Java Web 框架的新探索。](https://mp.weixin.qq.com/s/azMX0KA6RzdkPaLC5YG7Ng) [阅读原文](https://mp.weixin.qq.com/s/azMX0KA6RzdkPaLC5YG7Ng) # 快速开始 > 用 Feat HttpClient 发送第一个 HTTP 请求,理解异步回调模型 Feat Core 客户端提供轻量级、高性能的 HTTP/SSE/WebSocket 客户端实现,适合需要从 Java 程序主动连接外部服务、处理流式响应或建立双向实时通信的场景。 ## 客户端类型选择 [Section titled “客户端类型选择”](#客户端类型选择) | 特性 | HTTP | SSE | WebSocket | | ----- | -------- | ----- | --------- | | 通信模式 | 请求-响应 | 服务端推送 | 双向实时 | | 连接方式 | 短连接 | 长连接 | 长连接 | | 客户端发送 | ✅ | ❌ | ✅ | | 服务端发送 | ✅ | ✅ | ✅ | | 协议开销 | 高(每次请求头) | 中 | 低(首次握手后) | | 适用场景 | REST API | 通知推送 | 聊天、协作 | 提示 选择建议:简单数据获取 → HTTP;服务端单向推送 → SSE;双向实时交互 → WebSocket ## 第一个 HTTP 请求 [Section titled “第一个 HTTP 请求”](#第一个-http-请求) ### 前置要求 [Section titled “前置要求”](#前置要求) * **JDK**: 8 或更高版本 * **构建工具**: Maven 3.6+ 或 Gradle * **依赖**: `feat-core` ### 添加依赖 [Section titled “添加依赖”](#添加依赖) * Maven pom.xml ```xml tech.smartboot.feat feat-core ${feat.version} ``` * Gradle build.gradle ```groovy dependencies { implementation 'tech.smartboot.feat:feat-core:${feat.version}' } ``` 注意 `${feat.version}` 请替换为实际版本。最新版本可在 [Maven Central](https://central.sonatype.com/artifact/tech.smartboot.feat/feat-core) 查看。 ### 编写代码 [Section titled “编写代码”](#编写代码) HttpGetDemo.java ```java import tech.smartboot.feat.Feat; public class HttpGetDemo { public static void main(String[] args) { Feat.httpClient("https://smartboot.tech") .get("/feat/") .onSuccess(response -> System.out.println(response.body())) .onFailure(Throwable::printStackTrace) .submit(); } } ``` ### 核心概念 [Section titled “核心概念”](#核心概念) ```plaintext Feat.httpClient(baseUrl) → 创建客户端 ↓ .get(path) → 定义请求 ↓ .onSuccess(callback) → 成功回调 .onFailure(callback) → 失败回调 ↓ .submit() → 执行请求 ``` 提示 `submit()` 返回 `CompletableFuture`,可与其它异步流程组合使用。 ## 继续学习 [Section titled “继续学习”](#继续学习) [HTTP 客户端 ](/feat/client/http_client/)完整掌握请求头、查询参数、POST JSON、文件上传和响应处理。 [SSE 客户端 ](/feat/client/sse_client/)处理服务端持续推送的流式事件。 [WebSocket 客户端 ](/feat/client/ws_client/)建立双向实时通信连接。 ## 适用场景 [Section titled “适用场景”](#适用场景) Feat 客户端的设计哲学是**简单高效**,采用异步回调模型,适合以下场景: * **高并发服务端** - 异步非阻塞模型,不会阻塞线程 * **微服务间通信** - 轻量级,启动快,内存占用低 * **嵌入式场景** - 不依赖 Spring 等重量级框架 * **需要精细控制** - 支持流式处理、连接池、代理等高级配置 ### 什么时候考虑其他方案 [Section titled “什么时候考虑其他方案”](#什么时候考虑其他方案) * **需要声明式 HTTP 客户端**(如 Spring 的 `@FeignClient`)→ 考虑 OpenFeign * **需要完整的 Reactive Streams 支持**(Mono/Flux)→ 考虑 WebFlux 的 WebClient * **团队已深度使用 Spring** 且需要无缝集成 → Spring 生态的客户端集成更好 提示 相比 WebFlux 的 `Mono`/`Flux`,Feat 使用标准的 `CompletableFuture`,学习成本更低。如果你需要异步 HTTP 客户端但不想引入复杂的响应式编程模型,Feat 是一个很好的选择。 ## 常见问题 [Section titled “常见问题”](#常见问题) ### 连接失败:“localhost:8080” 报错 [Section titled “连接失败:“localhost:8080” 报错”](#连接失败localhost8080-报错) **原因**: `HttpClient(String url)` 需要完整 URL,包含协议头。 **解决**: ```java // ❌ 错误 new HttpClient("localhost:8080"); // ✅ 正确 new HttpClient("http://localhost:8080"); ``` ### 如何开启调试日志 [Section titled “如何开启调试日志”](#如何开启调试日志) ```java httpClient.options().debug(true); ``` ### 异步结果如何转为同步 [Section titled “异步结果如何转为同步”](#异步结果如何转为同步) ```java HttpResponse response = Feat.httpClient("https://api.example.com") .get("/data") .submit() .get(); // 阻塞等待结果 ``` # HTTP 客户端 > 掌握 Feat HttpClient 的完整功能:请求构建、响应处理、连接配置和高级特性 HttpClient 支持 GET、POST、PUT、DELETE 等常用请求,以及文件上传、流式响应处理等高级功能。 ## 创建与配置 [Section titled “创建与配置”](#创建与配置) ### 创建客户端 [Section titled “创建客户端”](#创建客户端) 使用 Feat 工厂方法创建客户端实例: ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.client.HttpClient; // 基础创建 HttpClient client = Feat.httpClient("https://api.example.com"); // 带配置选项 HttpClient client = Feat.httpClient("https://api.example.com", options -> { options.debug(true) .connectTimeout(5000) .idleTimeout(30000); }); ``` 警告 `HttpClient(String url)` 必须包含协议头(`http://` 或 `https://`),否则将抛出异常。 ### 连接配置 [Section titled “连接配置”](#连接配置) ```java HttpClient client = Feat.httpClient("https://api.example.com", options -> { options.debug(true) // 开启调试日志 .connectTimeout(5000) // 连接超时(毫秒) .idleTimeout(30000) // 空闲超时(毫秒) .maxConnections(64) // 连接池上限,高并发调用方按需调整 .readBufferSize(8192) // 读缓冲区大小 .setWriteBufferSize(8192); // 写缓冲区大小 }); ``` ### HTTP 代理 [Section titled “HTTP 代理”](#http-代理) ```java // 基础代理 HttpClient client = Feat.httpClient("https://api.example.com", options -> { options.proxy("proxy.example.com", 8080); }); // 带认证的代理 HttpClient client = Feat.httpClient("https://api.example.com", options -> { options.proxy("proxy.example.com", 8080, "username", "password"); }); ``` ### 连接池 [Section titled “连接池”](#连接池) HttpClient 内部自动维护连接池,通过 `keepalive` 配置启用连接复用: ```java client.get("/users") .header(header -> header.keepalive(true)) // 启用连接复用 .submit(); ``` ## 发送请求 [Section titled “发送请求”](#发送请求) ### GET 请求 [Section titled “GET 请求”](#get-请求) GET 请求用于获取资源,支持链式添加查询参数: ```java client.get("/users") .addQueryParam("page", "1") .addQueryParam("size", "20") .onSuccess(response -> System.out.println(response.body())) .submit(); ``` ### POST 请求 [Section titled “POST 请求”](#post-请求) POST 请求支持多种数据格式: * JSON ```java import java.util.HashMap; import java.util.Map; Map payload = new HashMap<>(); payload.put("name", "feat"); client.post("/users") .postJson(payload) // 自动序列化并设置 Content-Type .onSuccess(response -> System.out.println(response.body())) .submit(); ``` 提示 `postJson()` 会自动序列化对象并设置 Content-Type 为 application/json。 * 表单 ```java import java.util.HashMap; import java.util.Map; Map formData = new HashMap<>(); formData.put("name", "feat"); formData.put("version", "1.0"); client.post("/users") .postBody(body -> body.formUrlencoded(formData)) .submit(); ``` * 原始 Body ```java client.post("/users") .body(body -> body.write("raw data".getBytes())) .header(header -> header.set(HeaderName.CONTENT_TYPE, "text/plain")) .submit(); ``` ### PUT、DELETE 等请求 [Section titled “PUT、DELETE 等请求”](#putdelete-等请求) 使用 `rest()` 方法发送 PUT、DELETE 等请求: ```java client.rest("PUT", "/users/1") .postJson(userData) .onSuccess(response -> System.out.println(response.body())) .submit(); ``` ### 请求头配置 [Section titled “请求头配置”](#请求头配置) ```java import tech.smartboot.feat.core.common.HeaderName; client.get("/users") .header(header -> header .set(HeaderName.USER_AGENT, "Feat-Client/1.0") .set(HeaderName.AUTHORIZATION, "Bearer token123") .keepalive(true)) .submit(); ``` **set() vs add():** * `set()` - 覆盖同名 header,适合设置唯一值(如 Content-Type) * `add()` - 追加 header,支持同名多值(如 Set-Cookie) ```java client.post("/upload") .header(header -> { header.set(HeaderName.CONTENT_TYPE, "application/json"); // 覆盖 header.add("X-Custom-Header", "value1"); // 追加 header.add("X-Custom-Header", "value2"); // 同名追加 }) .submit(); ``` ## 处理响应 [Section titled “处理响应”](#处理响应) ### 异步回调 [Section titled “异步回调”](#异步回调) Feat 采用异步回调模型处理响应: ```java client.get("/users") .onResponseHeader(response -> { // 收到响应头时触发 System.out.println("Status: " + response.statusCode()); }) .onSuccess(response -> { // 收到完整响应体时触发 System.out.println("Body: " + response.body()); }) .onFailure(error -> { // 请求失败时触发 error.printStackTrace(); }) .onClose(() -> { // 连接关闭时触发 System.out.println("Connection closed"); }) .submit(); ``` 以下泳道图展示了请求过程中各回调的触发时机: ``` sequenceDiagram autonumber participant App as 应用程序 participant Client as HttpClient participant Server as 服务器 App->>Client: submit() 发送请求 Client->>Server: HTTP Request alt 请求成功 Server-->>Client: HTTP Response Headers Client-->>App: onResponseHeader() Server-->>Client: Response Body (分块接收) Client-->>App: onResponseBody() (可选) Server-->>Client: 响应完成 Client-->>App: onSuccess() else 请求失败 Client-->>App: onFailure() end Client-->>App: onClose() ``` **回调触发顺序说明:** 1. **请求发送** - 调用 `submit()` 后,请求开始发送 2. **收到响应头** - 服务器返回响应头时触发 `onResponseHeader()`,此时可以获取状态码和响应头 3. **接收响应体** - 如果注册了 `onResponseBody()`,会分块接收响应体数据 4. **请求成功** - 响应体完整接收后触发 `onSuccess()` 5. **请求失败** - 网络异常或超时等情况下触发 `onFailure()` 6. **连接关闭** - 无论成功或失败,连接关闭时都会触发 `onClose()` 提示 `onClose()` 是**最终回调**,无论请求成功还是失败都会执行,适合用于资源清理操作。 ### 读取响应数据 [Section titled “读取响应数据”](#读取响应数据) **响应体:** ```java client.get("/users") .onSuccess(response -> { String body = response.body(); // 获取响应体字符串 }) .submit(); ``` **响应头:** ```java client.get("/users") .onResponseHeader(response -> { // 获取单个头 String contentType = response.getHeader(HeaderName.CONTENT_TYPE); // 获取所有头名称 Collection headerNames = response.getHeaderNames(); // 获取指定头的所有值(支持多值) Collection values = response.getHeaders("Set-Cookie"); System.out.println("Status: " + response.statusCode()); System.out.println("Protocol: " + response.getProtocol()); }) .submit(); ``` ### 同步等待 [Section titled “同步等待”](#同步等待) 如需阻塞等待响应,使用 `CompletableFuture.get()`: ```java HttpResponse response = client.get("/users") .submit() .get(); // 阻塞等待 System.out.println(response.body()); ``` 提示 异步模式不会阻塞线程,适合高并发服务端;同步模式适合简单脚本和测试场景。 ## 高级用法 [Section titled “高级用法”](#高级用法) ### 文件上传(Multipart) [Section titled “文件上传(Multipart)”](#文件上传multipart) 上传文件使用 `multipart` 表单: ```java import tech.smartboot.feat.core.client.Multipart; import java.io.File; import java.util.Arrays; File avatar = new File("/path/to/avatar.jpg"); client.post("/upload") .postBody(body -> body.multipart(Arrays.asList( Multipart.newFormMultipart("name", "feat"), Multipart.newFormMultipart("version", "1.0"), Multipart.newFileMultipart("avatar", avatar) ))) .onSuccess(response -> System.out.println(response.body())) .submit(); ``` ### 流式响应处理 [Section titled “流式响应处理”](#流式响应处理) 如需处理二进制数据或大文件,使用 `onResponseBody` 流式处理: ```java client.get("/large-file.zip") .onResponseBody((response, bytes, end) -> { // 分块接收响应体数据 // bytes: 当前数据块 // end: 是否为最后一块 }) .submit(); ``` ### 大文件下载 [Section titled “大文件下载”](#大文件下载) 使用流式 API 处理大文件,避免内存溢出: ```java import java.io.FileOutputStream; FileOutputStream fos = new FileOutputStream("download.zip"); client.get("/large-file.zip") .onResponseBody((response, bytes, end) -> { try { fos.write(bytes); } catch (Exception e) { // 处理写入异常 } if (end) { try { fos.close(); } catch (Exception ignore) {} } }) .submit(); ``` ### SSE 客户端 [Section titled “SSE 客户端”](#sse-客户端) 处理 Server-Sent Events 流式响应。详见 [SSE 客户端](/feat/client/sse_client/) 章节。 ```java client.get("/events") .onSSE(sse -> { sse.onOpen(response -> System.out.println("SSE connection opened")); sse.onData(event -> System.out.println("Received: " + event.getData())); }) .submit(); ``` ### 动态路径构造 [Section titled “动态路径构造”](#动态路径构造) 使用 `HttpClient(String host, int port)` 构造方式,适合需要动态指定路径的场景: ```java HttpClient client = new HttpClient("api.example.com", 443); client.get("/users").submit(); // 必须指定 path ``` 警告 使用 `HttpClient(host, port)` 构造后,必须调用带参的 `get(String uri)` / `post(String uri)` 方法,调用无参方法将抛出 `UnsupportedOperationException`。 ## 完整示例 [Section titled “完整示例”](#完整示例) HttpClientDemo.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.client.HttpClient; import tech.smartboot.feat.core.client.HttpResponse; import tech.smartboot.feat.core.common.HeaderName; public class HttpClientDemo { public static void main(String[] args) throws Exception { // 1. 创建客户端 HttpClient client = Feat.httpClient("https://api.github.com", options -> { options.debug(true).connectTimeout(5000); }); // 2. 发送请求 client.get("/users/smartboot") .header(header -> header .set(HeaderName.USER_AGENT, "Feat-Client") .set(HeaderName.ACCEPT, "application/json")) .onResponseHeader(response -> { System.out.println("Status: " + response.statusCode()); }) .onSuccess(response -> { System.out.println("Body: " + response.body()); }) .onFailure(Throwable::printStackTrace) .submit() .get(); } } ``` # SSE 客户端 > 掌握 Feat SSE 客户端的完整功能:事件监听、连接管理和错误处理,轻松接收服务端推送的流式事件 SSE 客户端基于 HttpClient 构建,用于接收服务端推送的流式事件,适合实时通知、日志流、监控指标等场景。 ## 创建与配置 [Section titled “创建与配置”](#创建与配置) ### 基础连接 [Section titled “基础连接”](#基础连接) 使用 Feat 工厂方法创建 SSE 连接: ```java import tech.smartboot.feat.Feat; Feat.httpClient("http://localhost:8080") .get("/events") .onSSE(sse -> sse .onOpen(response -> { System.out.println("连接成功,状态码: " + response.statusCode()); }) .onData(event -> { System.out.println("收到事件: " + event.getData()); })) .onFailure(error -> { System.err.println("连接失败: " + error.getMessage()); }) .submit(); ``` 警告 SSE 端点 URL 必须包含协议头(`http://` 或 `https://`),否则将抛出异常。 ### 带配置的连接 [Section titled “带配置的连接”](#带配置的连接) ```java Feat.httpClient("http://localhost:8080", options -> { options.debug(true) .connectTimeout(5000) .idleTimeout(30000); }) .get("/events") .onSSE(sse -> sse.onData(event -> System.out.println(event.getData()))) .submit(); ``` ## 事件处理 [Section titled “事件处理”](#事件处理) ### 基本事件监听 [Section titled “基本事件监听”](#基本事件监听) SSE 事件对象包含三个核心字段: ```java public interface SseEvent { String getId(); // 事件 ID(用于断点续传) String getType(); // 事件类型(event 字段) String getData(); // 事件数据(data 字段) } ``` ### 处理命名事件 [Section titled “处理命名事件”](#处理命名事件) SSE 支持通过 `event:` 字段定义事件类型,客户端可以为不同类型注册不同处理器: ```java Feat.httpClient("http://localhost:8080") .get("/events") .onSSE(sse -> sse .onOpen(response -> System.out.println("已连接")) // 默认事件处理器 .onData(event -> { System.out.println("默认事件: " + event.getData()); }) // 命名事件处理器 .onEvent("message", event -> { System.out.println("消息事件: " + event.getData()); }) .onEvent("notification", event -> { System.out.println("通知事件: " + event.getData()); })) .submit(); ``` 注意 如果收到未注册的事件类型,会落入默认的 `onData` 处理器。 ### 回调触发顺序 [Section titled “回调触发顺序”](#回调触发顺序) 以下泳道图展示了 SSE 连接过程中各回调的触发时机: ``` sequenceDiagram autonumber participant App as 应用程序 participant Client as SSE Client participant Server as SSE 服务器 App->>Client: submit() 发起连接 Client->>Server: HTTP Request (Accept: text/event-stream) alt 连接成功 Server-->>Client: HTTP Response Headers Client-->>App: onOpen() loop 持续推送 Server-->>Client: SSE Event (data: ...) Client-->>App: onData() / onEvent() end Server-->>Client: 连接关闭 Client-->>App: onClose() else 连接失败 Client-->>App: onFailure() Client-->>App: onClose() end ``` **回调触发顺序说明:** 1. **发起连接** - 调用 `submit()` 后,客户端发送 HTTP 请求 2. **连接成功** - 收到响应头时触发 `onOpen()`,此时可以获取状态码和响应头 3. **接收事件** - 服务端推送事件时触发 `onData()` 或对应的 `onEvent()` 4. **连接失败** - 网络异常或超时等情况下触发 `onFailure()` 5. **连接关闭** - 无论成功或失败,连接关闭时都会触发 `onClose()` 提示 `onClose()` 是**最终回调**,无论连接成功还是失败都会执行,适合用于资源清理操作。 ## 请求配置 [Section titled “请求配置”](#请求配置) ### 添加请求头 [Section titled “添加请求头”](#添加请求头) ```java import tech.smartboot.feat.core.common.HeaderName; Feat.httpClient("http://localhost:8080") .get("/events") .header(header -> header .set(HeaderName.AUTHORIZATION, "Bearer token123") .add("Last-Event-ID", "event-100")) // 断点续传 .onSSE(sse -> sse.onData(event -> { System.out.println(event.getData()); })) .submit(); ``` ### 常用 SSE 请求头 [Section titled “常用 SSE 请求头”](#常用-sse-请求头) | 请求头 | 说明 | | --------------------------- | -------------------- | | `Accept: text/event-stream` | 必需,声明接受 SSE 格式 | | `Last-Event-ID` | 可选,断点续传时指定上次接收的事件 ID | | `Authorization` | 可选,认证令牌 | | `Cache-Control: no-cache` | 可选,禁用缓存 | ## 连接管理 [Section titled “连接管理”](#连接管理) ### 手动关闭连接 [Section titled “手动关闭连接”](#手动关闭连接) SSE 是长连接,程序不会自动退出,需要显式关闭。 ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.client.HttpRest; HttpRest rest = Feat.httpClient("http://localhost:8080") .get("/events") .onSSE(sse -> sse.onData(event -> System.out.println("收到: " + event.getData()))) .onFailure(Throwable::printStackTrace); rest.submit(); // 需要时手动关闭 // rest.close(); ``` 如果你想在收到某条特定消息后自动关闭,可以先声明变量再赋值,避免在 lambda 里引用尚未初始化的局部变量: ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.client.HttpRest; HttpRest rest; rest = Feat.httpClient("http://localhost:8080") .get("/events") .onSSE(sse -> sse.onData(event -> { System.out.println("收到: " + event.getData()); // 收到特定消息后关闭 if ("complete".equals(event.getData())) { rest.close(); } })) .onFailure(Throwable::printStackTrace); rest.submit(); ``` 也可以用定时器在指定时间后关闭连接: ```java import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; Executors.newSingleThreadScheduledExecutor() .schedule(rest::close, 30, TimeUnit.SECONDS); ``` ## 完整示例 [Section titled “完整示例”](#完整示例) ### 本地测试服务端 [Section titled “本地测试服务端”](#本地测试服务端) SimpleSseServer.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.upgrade.sse.SSEUpgrade; import tech.smartboot.feat.core.server.upgrade.sse.SseEmitter; import java.io.IOException; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class SimpleSseServer { public static void main(String[] args) { Feat.httpServer().httpHandler(req -> { if (!"/events".equals(req.getRequestURI())) { req.getResponse().write("use /events"); return; } req.upgrade(new SSEUpgrade() { @Override public void onOpen(SseEmitter emitter) throws IOException { emitter.send(SseEmitter.event() .name("connected") .data("Welcome to SSE")); // 定时发送消息 Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> { try { emitter.send(SseEmitter.event() .name("message") .id(String.valueOf(System.currentTimeMillis())) .data("Server time: " + System.currentTimeMillis())); } catch (IOException e) { e.printStackTrace(); } }, 1, 1, TimeUnit.SECONDS); } }); }).listen(8080); } } ``` ### 客户端完整代码 [Section titled “客户端完整代码”](#客户端完整代码) SseClientDemo.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.client.HttpRest; import tech.smartboot.feat.core.common.HeaderName; public class SseClientDemo { public static void main(String[] args) throws Exception { HttpRest rest = Feat.httpClient("http://localhost:8080") .get("/events") .header(header -> header .set(HeaderName.ACCEPT, "text/event-stream") .set(HeaderName.CACHE_CONTROL, "no-cache")) .onSSE(sse -> sse .onOpen(response -> { System.out.println("连接成功!"); }) .onEvent("connected", event -> { System.out.println("[连接事件] " + event.getData()); }) .onEvent("message", event -> { System.out.println("[消息事件] ID: " + event.getId() + ", Data: " + event.getData()); }) .onData(event -> { System.out.println("[默认处理] Type: " + event.getType()); })) .onFailure(error -> { System.err.println("连接失败: " + error.getMessage()); }); rest.submit(); // 保持程序运行 Thread.sleep(60000); rest.close(); } } ``` # WebSocket 客户端 > 掌握 Feat WebSocketClient 的完整功能:连接管理、消息收发和生命周期处理,轻松建立双向实时通信 WebSocket 客户端用于建立双向实时通信连接,支持文本和二进制消息收发,以及完整的生命周期管理。 ## 创建与配置 [Section titled “创建与配置”](#创建与配置) ### 创建客户端 [Section titled “创建客户端”](#创建客户端) 使用 Feat 工厂方法创建 WebSocket 连接: ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.client.WebSocketClient; import tech.smartboot.feat.core.client.WebSocketListener; import tech.smartboot.feat.core.client.WebSocketResponse; Feat.websocket("ws://localhost:8080/chat", new WebSocketListener() { @Override public void onOpen(WebSocketClient client, WebSocketResponse response) { System.out.println("连接已建立"); } @Override public void onMessage(WebSocketClient client, String message) { System.out.println("收到消息: " + message); } }); ``` 警告 WebSocket URL 必须使用 `ws://`(非加密)或 `wss://`(加密)协议头。 ### 带配置的连接 [Section titled “带配置的连接”](#带配置的连接) ```java WebSocketClient client = new WebSocketClient("ws://localhost:8080/chat"); client.options() .debug(true) .connectTimeout(5000) .readBufferSize(8192); client.connect(new WebSocketListener() { @Override public void onOpen(WebSocketClient client, WebSocketResponse response) { System.out.println("连接成功!"); } @Override public void onMessage(WebSocketClient client, String message) { System.out.println("收到: " + message); } @Override public void onClose(WebSocketClient client, WebSocketResponse response, CloseReason reason) { System.out.println("连接关闭: " + reason); } @Override public void onError(WebSocketClient client, Throwable error) { error.printStackTrace(); } }); ``` ### 连接配置 [Section titled “连接配置”](#连接配置) ```java WebSocketClient client = new WebSocketClient("ws://localhost:8080"); client.options() .debug(true) // 开启调试日志 .connectTimeout(5000) // 连接超时(毫秒) .readTimeout(30000) // 读取超时(毫秒) .readBufferSize(8192) // 读缓冲区大小 .writeBufferSize(8192) // 写缓冲区大小 .maxFrameSize(65536); // 最大帧大小 ``` ### 自定义请求头 [Section titled “自定义请求头”](#自定义请求头) ```java import tech.smartboot.feat.core.common.HeaderName; WebSocketClient client = new WebSocketClient("ws://localhost:8080"); client.header(header -> header .set(HeaderName.USER_AGENT, "Feat-WebSocket-Client") .set(HeaderName.AUTHORIZATION, "Bearer token123")); client.connect(listener); ``` ## 消息收发 [Section titled “消息收发”](#消息收发) ### 发送消息 [Section titled “发送消息”](#发送消息) ```java @Override public void onOpen(WebSocketClient client, WebSocketResponse response) { try { // 发送文本消息 client.sendMessage("Hello, WebSocket!"); client.sendMessage("{\"type\":\"chat\",\"content\":\"hello\"}"); // 发送二进制消息 byte[] data = "Binary data".getBytes(StandardCharsets.UTF_8); client.sendBinary(data); } catch (IOException e) { e.printStackTrace(); } } ``` ### 接收消息 [Section titled “接收消息”](#接收消息) ```java @Override public void onMessage(WebSocketClient client, String message) { // 处理文本消息 System.out.println("收到文本: " + message); } @Override public void onMessage(WebSocketClient client, byte[] message) { // 处理二进制消息 System.out.println("收到二进制: " + message.length + " bytes"); } ``` ## 生命周期管理 [Section titled “生命周期管理”](#生命周期管理) ### 回调触发顺序 [Section titled “回调触发顺序”](#回调触发顺序) 以下泳道图展示了 WebSocket 连接过程中各回调的触发时机: ``` sequenceDiagram autonumber participant App as 应用程序 participant Client as WebSocket Client participant Server as WebSocket 服务器 App->>Client: connect() 发起连接 Client->>Server: WebSocket Handshake alt 连接成功 Server-->>Client: Handshake Response (101) Client-->>App: onOpen() loop 双向通信 App->>Client: sendMessage() Client->>Server: WebSocket Frame Server-->>Client: WebSocket Frame Client-->>App: onMessage() end Server-->>Client: Close Frame Client-->>App: onClose() else 连接失败 Client-->>App: onError() Client-->>App: onClose() end ``` **回调触发顺序说明:** 1. **发起连接** - 调用 `connect()` 后,客户端发送 WebSocket 握手请求 2. **连接成功** - 握手成功(HTTP 101)时触发 `onOpen()`,此时可以开始发送消息 3. **接收消息** - 收到服务端消息时触发 `onMessage()` 4. **连接失败** - 握手失败或网络异常时触发 `onError()` 5. **连接关闭** - 无论成功或失败,连接关闭时都会触发 `onClose()` 提示 `onClose()` 是**最终回调**,无论连接成功还是失败都会执行,适合用于资源清理操作。 ### 完整生命周期示例 [Section titled “完整生命周期示例”](#完整生命周期示例) ```java import tech.smartboot.feat.core.client.WebSocketClient; import tech.smartboot.feat.core.client.WebSocketListener; import tech.smartboot.feat.core.common.codec.websocket.CloseReason; public class ChatClient { private WebSocketClient client; public void connect(String url) throws IOException { client = new WebSocketClient(url); client.options().debug(true).connectTimeout(5000); client.connect(new WebSocketListener() { @Override public void onOpen(WebSocketClient client, WebSocketResponse response) { System.out.println("[连接成功] " + response.getStatusCode()); try { client.sendMessage("{\"type\":\"join\",\"room\":\"lobby\"}"); } catch (IOException e) { e.printStackTrace(); } } @Override public void onMessage(WebSocketClient client, String message) { System.out.println("[收到消息] " + message); } @Override public void onClose(WebSocketClient client, WebSocketResponse response, CloseReason reason) { System.out.println("[连接关闭] Code: " + reason.getCode()); } @Override public void onError(WebSocketClient client, Throwable error) { System.err.println("[错误] " + error.getMessage()); } }); } public void send(String message) throws IOException { if (client != null && client.isConnected()) { client.sendMessage(message); } } public void close() throws IOException { if (client != null) { client.close(1000, "Normal closure"); } } } ``` ## 连接管理 [Section titled “连接管理”](#连接管理) ### 手动关闭连接 [Section titled “手动关闭连接”](#手动关闭连接) WebSocket 是长连接,程序不会自动退出,需要显式关闭。 ```java // 正常关闭 client.close(1000, "Normal closure"); // 强制关闭 client.close(); ``` ### 实现重连 [Section titled “实现重连”](#实现重连) ```java public void onClose(WebSocketClient client, WebSocketResponse response, CloseReason reason) { if (reconnectAttempts < MAX_RECONNECT) { reconnectAttempts++; System.out.println("连接断开,尝试重连 (" + reconnectAttempts + "/" + MAX_RECONNECT + ")"); try { Thread.sleep(3000); doConnect(); // 重新建立连接 } catch (Exception e) { e.printStackTrace(); } } } ``` ## 关闭码说明 [Section titled “关闭码说明”](#关闭码说明) | 代码 | 名称 | 说明 | | ---- | ---------------- | ------------ | | 1000 | Normal Closure | 正常关闭 | | 1001 | Going Away | 服务端或客户端离开 | | 1002 | Protocol Error | 协议错误 | | 1006 | Abnormal Closure | 异常关闭(连接意外断开) | | 1009 | Message Too Big | 消息太大 | | 1011 | Internal Error | 服务端内部错误 | ## 完整示例 [Section titled “完整示例”](#完整示例) ### 回声服务端 [Section titled “回声服务端”](#回声服务端) EchoWebSocketServer.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.WebSocketRequest; import tech.smartboot.feat.core.server.WebSocketResponse; import tech.smartboot.feat.core.server.upgrade.websocket.WebSocketUpgrade; public class EchoWebSocketServer { public static void main(String[] args) { Feat.httpServer().httpHandler(req -> { req.upgrade(new WebSocketUpgrade(65536) { @Override public void handleTextMessage(WebSocketRequest request, WebSocketResponse response, String data) { System.out.println("收到: " + data); response.sendTextMessage("Echo: " + data); } }); }).listen(8080); } } ``` ### 交互式客户端 [Section titled “交互式客户端”](#交互式客户端) InteractiveWebSocketClient.java ```java import tech.smartboot.feat.core.client.WebSocketClient; import tech.smartboot.feat.core.client.WebSocketListener; import tech.smartboot.feat.core.common.codec.websocket.CloseReason; import java.io.BufferedReader; import java.io.InputStreamReader; public class InteractiveWebSocketClient { private static WebSocketClient client; public static void main(String[] args) throws Exception { client = new WebSocketClient("ws://localhost:8080"); client.options().debug(true); client.connect(new WebSocketListener() { @Override public void onOpen(WebSocketClient client, WebSocketResponse response) { System.out.println("已连接到服务器,输入消息(输入 'exit' 退出):"); } @Override public void onMessage(WebSocketClient client, String message) { System.out.println("服务器: " + message); } @Override public void onClose(WebSocketClient client, WebSocketResponse response, CloseReason reason) { System.out.println("连接已关闭: " + reason); } }); // 读取用户输入 BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); String line; while ((line = reader.readLine()) != null) { if ("exit".equalsIgnoreCase(line)) { client.close(1000, "User requested"); break; } client.sendMessage(line); } } } ``` ## 常见问题 [Section titled “常见问题”](#常见问题) **连接失败** 检查清单: 1. URL 是否为 `ws://` 或 `wss://` 2. 服务端是否支持 WebSocket 升级 3. 端口和路径是否正确 **`sendMessage` 抛出 IOException** 连接未就绪或已断开。只在 `onOpen` 回调后发送消息: ```java if (client.isConnected()) { client.sendMessage("data"); } ``` **程序不退出** WebSocket 是长连接,需要手动关闭: ```java // 正常关闭 client.close(1000, "Normal closure"); // 强制关闭 client.close(); ``` # 快速开始 > 用 Feat Cloud 写出第一个注解式 Web 应用,理解整体学习路径 警告 Feat 主线仓库采用 Apache 2.0;其中 Feat Cloud 通过赞助支持模式开放。如需使用 Feat Cloud,请先[成为赞助者](/feat/sponsors/)获取授权。 ![最小 Feat Cloud 应用从编写到浏览器成功响应的流程插画](/feat/_astro/index_1.Do-6e5K__2cP9CI.webp) Feat Cloud 面向这样一种场景:你已经接受 Feat Core 的轻量、高性能路线,但在业务项目里仍然希望保留 Controller、Bean、自动装配这些更熟悉的组织方式。 如果你还在评估 Feat,建议先从 [Feat Core 快速开始](/feat/server/) 开始。 这章先不展开配置、数据库或部署。我们只做一件事:写出一个能被编译期识别、启动后可以访问的最小 Feat Cloud 应用。 ## 一个最小应用 [Section titled “一个最小应用”](#一个最小应用) 最终效果很小:访问 `http://localhost:8080/hello` 返回 `hello Feat Cloud`。\ 但这条链路会经过 Feat Cloud 最重要的机制:编译期扫描注解、生成服务代码、启动时注册路由。 开始前确认本地已经有 JDK 8+、Maven 3.6+ 和一个能正常运行 Maven 项目的 IDE,详见[系统要求](/feat/guides/system-requirements/)。 ### 添加依赖 [Section titled “添加依赖”](#添加依赖) 在 `pom.xml` 中加入: pom.xml ```xml tech.smartboot.feat feat-cloud ${feat.version} tech.smartboot.feat feat-cloud-starter ${feat.version} provided ``` 注意 `${feat.version}` 请替换为实际版本。最新版本可以在 [Maven Central](https://central.sonatype.com/artifact/tech.smartboot.feat/feat-cloud-starter) 查看。 这里有一个容易忽略的分工:`feat-cloud` 是运行期依赖,`feat-cloud-starter` 负责在编译期生成代码。\ 如果少了后者,应用可以编译到某一步,但 Controller 不会被转成可加载的 `CloudService`。 ### 编写入口和控制器 [Section titled “编写入口和控制器”](#编写入口和控制器) 创建 `Bootstrap.java`: Bootstrap.java ```java import tech.smartboot.feat.cloud.FeatCloud; import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.RequestMapping; @Controller public class Bootstrap { @RequestMapping("/hello") public String helloWorld() { return "hello Feat Cloud"; } public static void main(String[] args) { FeatCloud.cloudServer().listen(); } } ``` 这个类同时承担两个角色: * `main` 方法是应用启动入口 * `@Controller` 和 `@RequestMapping` 让编译器知道这里有一个 HTTP 路由 在真实项目里,你通常会把启动类和 Controller 拆开。这里合在一起,是为了让第一条链路尽量短。 ### 运行并验证 [Section titled “运行并验证”](#运行并验证) 提示 如果你修改控制器后发现路由没有更新,建议检查 IDEA 设置:`Preferences` → `Build, Execution, Deployment` → `Build Tools` → `Maven` → `Runner`,勾选 `Delegate IDE build/run actions to Maven`。 ![IDEA 配置截图](/feat/_astro/idea_setting.B62ffbrI.png) 只有遇到 APT 生成代码不同步时才需要调整。 运行 `Bootstrap.main()`,控制台出现以下内容说明路由注册成功: 控制台输出 ```text Feat Router: |-> /hello ==> Bootstrap@helloWorld http://0.0.0.0:8080/ ``` 访问 `http://localhost:8080/hello`: * 浏览器 ![Feat Cloud 浏览器访问效果图](/feat/_astro/demo.DZJShVgu.png) * curl curl 验证 ```shell curl -i http://localhost:8080/hello HTTP/1.1 200 OK Content-Type: text/plain; charset=utf-8 hello Feat Cloud ``` 到这里,你已经跑通了 Feat Cloud 的基本闭环。后面的章节会把这个最小应用拆开:先理解编译期生成的工作模型,再进入 Controller 请求处理、返回响应、Bean 装配、配置与环境、数据库集成和最终部署。 如果你来自 Spring Boot,[从 Spring Boot 迁移](/feat/practices/from-spring/)提供了一张注解与配置的对照地图,可以和主线教程并行阅读。 # Bean 与依赖注入 > 用 @Bean、@Autowired、@Value 组织业务对象,并理解 Feat Cloud 的生命周期顺序 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 Controller 负责接住 HTTP 请求,但真实业务不会只写在 Controller 里。你通常会把规则、数据库访问、第三方客户端和配置对象拆成不同的 Bean,再由 Feat Cloud 在编译期生成装配代码。 这一章讲的是 Feat Cloud 应用的骨架:哪些对象由容器创建,依赖什么时候注入,初始化和销毁方法在什么阶段执行。 ## Bean 声明与注入 [Section titled “Bean 声明与注入”](#bean-声明与注入) 先看最常见的三件事:声明 Bean、在 Controller 中注入 Bean、从配置中注入值。 ### 声明一个 Bean [Section titled “声明一个 Bean”](#声明一个-bean) 把类标记为 `@Bean` 后,Feat Cloud 会在编译期识别它,并在启动时创建实例。 UserService.java ```java import tech.smartboot.feat.cloud.annotation.Bean; @Bean public class UserService { public String findDisplayName(String username) { return "User " + username; } } ``` 默认情况下,Bean 名称来自类名首字母小写后的结果。例如 `UserService` 会注册成 `userService`。你也可以显式指定一个稳定名称: UserService.java ```java @Bean("userService") public class UserService { } ``` 稳定命名适合跨模块协作,或者你希望启动报错里出现更清楚的业务对象名称。 ### 在 Controller 中注入 Bean [Section titled “在 Controller 中注入 Bean”](#在-controller-中注入-bean) `@Autowired` 用在字段上。编译期会生成对应的注入代码,运行时不需要扫描字段注解。 UserController.java ```java import tech.smartboot.feat.cloud.RestResult; import tech.smartboot.feat.cloud.annotation.Autowired; import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.PathParam; import tech.smartboot.feat.cloud.annotation.RequestMapping; @Controller("users") public class UserController { @Autowired private UserService userService; @RequestMapping("/:username") public RestResult getName(@PathParam("username") String username) { return RestResult.ok(userService.findDisplayName(username)); } } ``` 这段代码表达的结构是: * Controller 只处理 HTTP 入参和响应 * UserService 承担业务动作 * Feat Cloud 负责在启动阶段把两者接起来 如果启动时报 Bean 不存在,优先检查目标类是否带了 `@Bean`,以及它是否在扫描范围内。 ### 从配置注入值 [Section titled “从配置注入值”](#从配置注入值) `@Value` 可以把配置值注入字段。写法上必须使用 `${}` 占位符,配置来自 `feat.yml` 或当前激活环境合并后的配置;还支持 `${key:默认值}` 在配置缺失时提供兜底值。 feat.yml ```yaml app: name: demo max-users: 100 ``` AppConfig.java ```java import tech.smartboot.feat.cloud.annotation.Bean; import tech.smartboot.feat.cloud.annotation.Value; @Bean public class AppConfig { @Value("${app.name}") private String appName; @Value("${app.max-users:50}") private int maxUsers; public String getAppName() { return appName; } public void setAppName(String appName) { this.appName = appName; } public int getMaxUsers() { return maxUsers; } public void setMaxUsers(int maxUsers) { this.maxUsers = maxUsers; } } ``` 注意不要省略 `${}`:`@Value("app.name")` 注入的是字面量字符串 `"app.name"`,而不是从配置解析出的值。`${}` 占位符的完整解析规则(优先级、环境变量命名等)见 [启动配置与环境配置](/feat/cloud/options/)。 `@Value` 注入会调用字段对应的 setter。配置参与编译期生成,修改 `feat.yml`、`feat-dev.yml` 或 `feat-prod.yml` 后,需要重新构建应用,生成代码才会更新。 ## 生命周期与创建顺序 [Section titled “生命周期与创建顺序”](#生命周期与创建顺序) Bean 进入容器之后,还会经历初始化、依赖顺序和方法级 Bean 创建这些阶段。 ### 初始化和销毁 [Section titled “初始化和销毁”](#初始化和销毁) 如果 Bean 创建后需要建立连接、预热缓存或检查依赖,可以使用 `@PostConstruct`。应用关闭时需要释放资源,则使用 `@PreDestroy`。 ClientHolder.java ```java import tech.smartboot.feat.cloud.annotation.Bean; import tech.smartboot.feat.cloud.annotation.PostConstruct; import tech.smartboot.feat.cloud.annotation.PreDestroy; @Bean public class ClientHolder { @PostConstruct public void init() { // 建立连接或检查必要资源 } @PreDestroy public void close() { // 释放连接、线程池或文件句柄 } } ``` 生命周期顺序可以简化理解成: 1. 创建 Bean 2. 注入 `@Value` 3. 注入 `@Autowired` 4. 执行 `@PostConstruct` 5. 注册 Controller 路由 6. 应用关闭时执行 `@PreDestroy` 这个顺序决定了一个重要边界:不要在字段声明或构造阶段依赖 `@Autowired` 字段。需要使用依赖对象时,放到业务方法或 `@PostConstruct` 之后。 ### 控制创建顺序 [Section titled “控制创建顺序”](#控制创建顺序) `@Bean` 支持 `order` 属性。少数情况下,某个 Bean 必须先于另一个 Bean 准备好,可以显式调整顺序。 OrderedBean.java ```java @Bean(value = "dataSource", order = -10) public class DataSourceHolder { } ``` 警告 `order` 是解决启动依赖顺序的工具,不应该成为业务编排手段。大量依赖顺序问题通常说明对象边界需要重新拆分。 ### 方法级 Bean [Section titled “方法级 Bean”](#方法级-bean) `@Bean` 也可以标在方法上,用来把一个返回对象交给容器管理。方法级 Bean 的名称来自方法名。它适合创建第三方客户端、配置对象或已经封装好的外部资源。 ClientFactory.java ```java import tech.smartboot.feat.cloud.annotation.Bean; @Bean public class ClientFactory { @Bean("paymentClient") public PaymentClient paymentClient() { return new PaymentClient("https://api.example.com"); } } ``` 如果对象必须在 Feat Cloud 容器启动前由外部代码准备好,可以改用 `CloudOptions.registerBean(...)`。这部分放在启动配置章节里讲。 # 业务缓存 > Feat 不自带缓存抽象,但提供了 redisun 与编译期配置。这一页给出默认内存、可切 Redis 的最小实现,以及三个必须提前想清楚的点 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 Feat 没有 `@Cacheable` 这类缓存注解,也没有内置的缓存抽象层。这是有意的:缓存的有效性取决于业务怎么读、怎么写、能容忍多旧的数据,框架层面能标准化的只有「客户端」和「配置」,再往上就变成了猜测。 所以这一页不讲框架能力,讲**怎么在这套机制上搭一层够用的缓存**,以及三个一旦做错就很难查的点。 ## 可以先复用的三样东西 [Section titled “可以先复用的三样东西”](#可以先复用的三样东西) | 能力 | 来源 | 说明 | | --------- | -------------------------- | -------------------------------------------------------- | | Redis 客户端 | `tech.smartboot:redisun` | Feat 生态自带的客户端,`feat-cloud` 里声明为 `optional`,用到时需在业务工程显式依赖 | | 读取配置 | `@Value("${key:默认值}")` | 编译期从 `feat.yml` 取值并写进生成代码,带默认值时缺配置也能启动 | | 装配 | `@Bean` + `@PostConstruct` | `@PostConstruct` 在 `@Autowired` 之后执行,适合做「按配置建后端 + 探活」 | `redisun` 的依赖写法: ```xml tech.smartboot redisun 1.4.0 ``` ## 一个够用的结构 [Section titled “一个够用的结构”](#一个够用的结构) 分三层,各管一件事: ```text CacheStore 后端契约:get / put / evict / stats / providerName ├── MemoryCacheStore 进程内,默认 └── RedisCacheStore 走 redisun,多实例共享 CacheStoreProvider @Bean("cacheStore"),按配置选后端、探活、启动打印实际后端 ``` 业务只依赖 `CacheStore`,切换后端不动业务代码。 ### 接口:值统一是 String [Section titled “接口:值统一是 String”](#接口值统一是-string) CacheStore.java ```java public interface CacheStore { String get(String key); /** ttlSeconds <= 0 时用配置的默认有效期 */ void put(String key, String value, int ttlSeconds); boolean evict(String key); CacheStats stats(); String providerName(); } ``` 值不放 `Object` 有三个理由,都是踩过的: * Redis 只能存字节。内存实现放对象、Redis 实现放字节,两边行为就不一致——开发环境正常、生产开始报序列化错。 * 序列化交给调用方(项目里用 fastjson2),缓存层不必引入新的序列化契约,实体加字段也不用改缓存。 * 键值都是字符串,`redis-cli` 能直接看懂现场。 ### 内存实现:惰性删除 + 定量清扫 [Section titled “内存实现:惰性删除 + 定量清扫”](#内存实现惰性删除--定量清扫) 只用惰性删除会出事:写了一万个键、之后一次都没被读过,这些键永远留在堆里,表现为内存慢慢涨、GC 变频繁,而缓存本身「看起来没占多少」。 ```java // 读的时候顺手判过期 if (entry.expireAtMillis <= System.currentTimeMillis()) { entries.remove(key, entry); // 带值删除,避免并发下删掉别的线程刚写入的新值 return null; } ``` 再配一个计数,每写满若干次做一次全量扫描。不必启动后台线程——缓存是给业务加速的,不该反过来给应用引入一条常驻线程和它的启停顺序问题。 ### Redis 实现:用 dbsize 探活 [Section titled “Redis 实现:用 dbsize 探活”](#redis-实现用-dbsize-探活) RedisCacheStore.java ```java this.redisun = Redisun.create(opt -> { opt.setAddress(address); // host:port,也接受 redis:// 与 rediss:// opt.setPassword(password); opt.setDatabase(database); }); // 启动时验证「连得上且认证通过」,而不是等第一个业务请求 redisun.dbsize(); ``` `address` 的格式是 `host:port`,省略端口默认 `6379`(`rediss://` 时 `6380`)。挑 `dbsize` 探活是因为它开销近似 O(1),又确实走了一次完整的连接与认证。 ## 三个必须提前想清楚的点 [Section titled “三个必须提前想清楚的点”](#三个必须提前想清楚的点) ### 一、TTL 和主动失效要同时有,不是二选一 [Section titled “一、TTL 和主动失效要同时有,不是二选一”](#一ttl-和主动失效要同时有不是二选一) | 只有主动失效 | 只有 TTL | | ----------------- | ----------------------- | | 一次漏调就永久读到旧数据,直到重启 | 改完要等 TTL 到点才生效,期间读到的是错的 | 正确做法是两个都做:写路径落库后立即 `evict`,同时给一个不短的 TTL 兜底。TTL 只在漏失效时起作用,所以可以设得比较长。 警告 凡是**绕过 Service 直接改库**的路径,都必须自己负责清缓存。典型的是「重置演示数据」这类运维接口:它 TRUNCATE 后重新插入,主键全变,而缓存里还是上一批 id,之后所有按 id 算范围的查询都会落到空集合上——不报错,只是查不到人。 ### 二、写路径内部的读不要走缓存 [Section titled “二、写路径内部的读不要走缓存”](#二写路径内部的读不要走缓存) 写方法里通常要回读刚插入的行,或校验某个 id 是否还存在。这些读如果命中旧缓存,会得到两种后果: * 看不到自己刚插入的行; * 拿已删除的数据当有效数据——如果这段是数据权限校验,范围会被悄悄放宽。 规则简单:**读方法用缓存,写方法内一律直读 mapper**。 ### 三、配置写错要启动失败,不要静默降级 [Section titled “三、配置写错要启动失败,不要静默降级”](#三配置写错要启动失败不要静默降级) 配了 `provider: redis` 却连不上,如果降级到内存继续跑,应用照常工作、只是慢,没有人会发现配置根本没生效;而在多实例部署下,它表现为「偶发读到旧数据」,比启动失败难查一个数量级。 两件事一起做:`@PostConstruct` 里探活,失败就抛异常;启动日志打印实际生效的后端,因为探活只挡得住一部分错误。 ```text [feat-plus] cache provider=memory defaultTtl=300s ``` ## 配置化切换 [Section titled “配置化切换”](#配置化切换) feat.yml ```yaml feat-plus: cache: provider: memory # memory | redis default-ttl-seconds: 300 key-prefix: "plus:cache:" redis: address: 127.0.0.1:6379 database: 0 ``` ```java @Value("${feat-plus.cache.provider:memory}") private String provider; ``` 带默认值的 `@Value` 有两个直接好处:缺配置也能启动,不必在代码里再写一遍兜底;而值本身参与编译期生成,配置和代码一起进版本库。 警告 `feat.yml` 属于编译期输入。改完只跑 `mvn compile` **不一定**会重新生成装配代码——Maven 判定为增量构建时,注解处理器可能不重新执行,跑起来仍是上一套配置。改配置后请用 `mvn clean compile`,或者在日志里确认打印出来的值和预期一致。 `key-prefix` 不是可选项:多套环境共用一个 Redis 时,不带前缀会互相覆盖,而缓存读错不会报错,只会返回旧数据。 ## 与 Session 的区别 [Section titled “与 Session 的区别”](#与-session-的区别) | | Session | 业务缓存 | | --- | ------------------------------------- | ---------- | | 存什么 | 当前访问者的临时状态 | 业务数据的读结果 | | 谁写 | 框架按 Cookie 维护 | 业务代码显式读写 | | 存哪 | `server.session.store-type: redis` 切换 | 自己搭,配置自己管 | | 失效 | 超时或 `invalidate()` | TTL + 主动失效 | 两者可以共用一个 Redis,但建议分库或分前缀——Session 的读写频率远高于业务缓存,混在一起会让「缓存慢了」和「会话慢了」分不清。Session 的细节见 [Session 与请求状态](/feat/cloud/session/)。 ## Feat 里写缓存会踩的三个坑 [Section titled “Feat 里写缓存会踩的三个坑”](#feat-里写缓存会踩的三个坑) 这三个都来自框架的编译期模型,不是缓存本身的问题。 **1. 计算属性不会被序列化。** 返回值在编译期被拼成 JSON 字节,逐字段调用 getter,**没有对应字段的 getter 会被整条丢掉**。命中率这种「只在 getter 里算」的值必须落成真实字段,否则接口里静默少一个键,前端拿到 `undefined`。 **2. 启动异常的中间层 message 会消失。** 启动失败时抛的是 `new FeatException("application start exception", e)`,而它会把 cause 一路解包到最底层再赋值。所以 `new IllegalStateException(msg, e)` 里精心写的提示一个字都打不出来,控制台只剩一句 `Connection refused`。 要让提示显示出来,就把原始信息拼进自己的 message,并且**不设 cause**: ```java } catch (RuntimeException e) { throw new IllegalStateException("缓存连不上 Redis:" + address + "(" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")"); } ``` **3. `@Value` 的字段必须有 setter。** 注入是编译期生成的 `bean.setXxx(...)` 调用,没有 setter 会直接编译失败(`no setter method for field`)。而且字段类型要在支持列表里:`String`、`int`、`boolean`、`int[]`、`String[]` 及对应的 `List`。 ## 什么时候不该用缓存 [Section titled “什么时候不该用缓存”](#什么时候不该用缓存) * 数据量小、查询本身就是内存操作——加一层只是多一次拷贝; * 一致性要求高于性能,且没有可靠的失效路径——先想清楚失效再谈缓存; * 想用它替代数据库设计——把「查得慢」藏起来,等数据量再涨一个数量级时问题会一起爆发。 缓存是加速手段,不是正确性手段。上线前先确认:**缓存失效时,业务结果是否仍然正确**。 # Controller 与请求处理 > 用 @Controller 和 @RequestMapping 组织 HTTP 接口,处理路径、参数、请求体和少量运行时能力 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 Feat Cloud 的控制器写起来和 Spring Boot 很像:一个类加 `@Controller`,方法加 `@RequestMapping`。但底层实现不同:路由映射在编译期生成,运行时不再反射扫描。 这一章集中讲 HTTP 请求进入 Controller 的完整过程: * 先建立 URL 到方法的映射 * 再处理查询参数、路径参数和 JSON 请求体 * 最后补充少数需要直接接触运行时上下文的能力 ![Controller 将 URL 映射到处理方法的示意图](/feat/_astro/controller_1.Dnvxh20e_Z1LjAWK.webp) ## 第一个控制器 [Section titled “第一个控制器”](#第一个控制器) 下面是一个最小但完整的控制器。它只做一件事:访问 `http://localhost:8080/users/hello` 时返回一段文本。 UserController.java ```java package com.example.controller; import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.RequestMapping; import tech.smartboot.feat.cloud.annotation.RequestMethod; @Controller("users") public class UserController { @RequestMapping(value = "/hello", method = RequestMethod.GET) public String hello() { return "hello controller"; } } ``` 启动应用后,控制台会输出路由注册信息: 路由注册日志 ```text Feat Router: |-> /users/hello ==> UserController@hello http://0.0.0.0:8080/ ``` 验证: curl 验证 ```shell curl http://localhost:8080/users/hello ``` 响应结果 ```text hello controller ``` ### 基础路径 [Section titled “基础路径”](#基础路径) `@Controller` 的 `value` 属性定义该控制器下所有方法的 URL 前缀。 | 控制器路径 | 方法路径 | 完整 URL | | -------- | -------- | --------------- | | `users` | `/hello` | `/users/hello` | | `api/v1` | `/users` | `/api/v1/users` | | (空) | `/hello` | `/hello` | 基础路径为空时,方法路径就是完整 URL。 ## 常用请求输入 [Section titled “常用请求输入”](#常用请求输入) Controller 最常见的输入来源有三种:查询字符串、路径变量和 JSON 请求体。 从这里开始,示例代码默认写在前面的 `UserController` 里,只展示当前接口方法和必要的局部类型。 ![HTTP 路径中的 query 参数与 path 参数如何到达 Controller 的示意图](/feat/_astro/request_mapping_1.B21JxNuj_Z10KHfn.webp) ### 查询参数 [Section titled “查询参数”](#查询参数) 使用 `@Param` 注解从 URL 查询字符串提取参数值。 UserController.java ```java @RequestMapping(value = "/search", method = RequestMethod.GET) public String search(@Param("keyword") String keyword, @Param("page") Integer page) { int currentPage = page == null ? 1 : page; return "搜索: " + keyword + ", 页码: " + currentPage; } ``` * 请求 curl 请求 ```shell curl "http://localhost:8080/users/search?keyword=feat&page=1" ``` * 响应 响应结果 ```text 搜索: feat, 页码: 1 ``` 警告 可能缺失的查询参数建议声明为包装类型,例如 `Integer`、`Long`、`Boolean`。如果用 `int` 这类基本类型,缺失参数在拆箱时可能触发 `NullPointerException`。 ### 路径参数 [Section titled “路径参数”](#路径参数) 使用 `@PathParam` 注解从 URL 路径提取变量值。 UserController.java ```java @RequestMapping(value = "/:userId", method = RequestMethod.GET) public String getUser(@PathParam("userId") String userId) { return "用户ID: " + userId; } ``` curl 请求 ```shell curl http://localhost:8080/users/12345 ``` Feat Cloud 支持两种路径参数写法: * 冒号形式:`/:userId` * 花括号形式:`/{userId}` 两种写法功能等价,团队内部统一即可。如果团队来自前端路由或 Feat Core 路由体系,`:id` 会更自然;如果团队长期使用 Spring MVC,`{id}` 往往更容易迁移。 ### JSON 请求体 [Section titled “JSON 请求体”](#json-请求体) 创建和更新资源时,参数通常不会继续塞在 URL 里,而是放进 JSON 请求体。Controller 方法可以直接声明一个自定义 POJO 参数,不需要额外注解。 User.java ```java public class User { private String username; private String password; private String role; public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getRole() { return role; } public void setRole(String role) { this.role = role; } } ``` UserController.java ```java @RequestMapping(value = "/", method = RequestMethod.POST) public String create(User user) { return "created user: " + user.getUsername(); } ``` 请求时带上 `Content-Type: application/json`: curl 请求 ```shell curl -X POST http://localhost:8080/users/ \ -H 'Content-Type: application/json' \ -d '{"username":"Bob","password":"123456","role":"admin"}' ``` 进入正式业务接口后,常见做法是用路径表达资源位置,用请求体承载要创建或更新的数据。 ## HTTP 方法与 REST 风格 [Section titled “HTTP 方法与 REST 风格”](#http-方法与-rest-风格) `RequestMethod` 覆盖常见 HTTP 方法: RequestMethod.java ```java public enum RequestMethod { GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE } ``` 为同一路径配置不同方法: UserController.java ```java @RequestMapping(value = "/", method = RequestMethod.GET) public String list() { return "list all users"; } @RequestMapping(value = "/", method = RequestMethod.POST) public String create(User user) { return "created user: " + user.getUsername(); } @RequestMapping(value = "/:id", method = RequestMethod.GET) public String get(@PathParam("id") String id) { return "get user: " + id; } @RequestMapping(value = "/:id", method = RequestMethod.PUT) public String update(@PathParam("id") String id, User user) { return "updated user " + id + " to " + user.getUsername(); } @RequestMapping(value = "/:id", method = RequestMethod.DELETE) public String delete(@PathParam("id") String id) { return "deleted user: " + id; } ``` ## 运行时能力 [Section titled “运行时能力”](#运行时能力) 大部分接口只需要 `@RequestMapping`、`@Param`、`@PathParam` 和请求体绑定。遇到下面这些场景时,再使用更靠近运行时的能力。 这部分不是日常接口的起点,更像工具箱:只有当普通参数绑定不够用时,再取对应能力。 ![Controller 高级能力(Context、异步处理、Gzip 压缩)按需取用的示意图](/feat/_astro/controller_advanced_1.D8XJk1sT_My97U.webp) ### 注入 Context [Section titled “注入 Context”](#注入-context) 需要直接读取请求头、设置响应头或访问完整 URI 时,注入 `Context`。 UserController.java ```java import tech.smartboot.feat.router.Context; @RequestMapping(value = "/context", method = RequestMethod.GET) public String context(Context context) { String uri = context.Request.getRequestURI(); String method = context.Request.getMethod(); return "URI: " + uri + ", Method: " + method; } ``` ### 异步响应 [Section titled “异步响应”](#异步响应) 当方法内部需要调用远程服务或执行耗时操作时,可以返回 `AsyncResponse`,避免阻塞 Feat 的工作线程。 UserController.java ```java import tech.smartboot.feat.cloud.AsyncResponse; import tech.smartboot.feat.cloud.RestResult; import java.util.concurrent.CompletableFuture; @RequestMapping(value = "/async", method = RequestMethod.GET) public AsyncResponse async() { AsyncResponse response = new AsyncResponse(); CompletableFuture.runAsync(() -> { try { Thread.sleep(1000); response.complete(RestResult.ok("异步响应完成")); } catch (InterruptedException e) { response.complete(RestResult.fail(e.getMessage())); } }); return response; } ``` 生产环境请把 `CompletableFuture.runAsync` 换成自定义线程池,避免占满 common pool。 ### Gzip 压缩 [Section titled “Gzip 压缩”](#gzip-压缩) 在 `@Controller` 上开启 Gzip,可以压缩返回的大段文本或 JSON。默认阈值是 256 字节,低于该值的响应不会压缩。 ApiController.java ```java import tech.smartboot.feat.cloud.annotation.Controller; @Controller( value = "api", gzip = true, gzipThreshold = 256 ) public class ApiController { @RequestMapping(value = "/large", method = RequestMethod.GET) public String largeResponse() { return "这是一段很长的文本内容,重复多次使其超过 256 字节。".repeat(10); } } ``` 可以用带 `Accept-Encoding: gzip` 的请求验证压缩效果: Gzip 请求 ```shell curl -H "Accept-Encoding: gzip" http://localhost:8080/api/large --compressed ``` ## 编译期路由映射 [Section titled “编译期路由映射”](#编译期路由映射) 传统框架在运行时才去扫描类路径、解析注解、生成代理。Feat Cloud 在编译期就做完了这些事。 当你运行 `mvn compile` 时,`feat-cloud-starter` 中的注解处理器会: 1. 扫描 `@Controller` 类 2. 解析 `@RequestMapping` 的路径和方法 3. 生成 `CloudService` 实现类 4. 把路由注册到 `Router` 带来的直接好处:调用开销接近直接方法调用、内存占用更低、启动更快,也更容易构建 Native Image。 # 部署与交付 > 将 Feat Cloud 应用从本地运行推进到 Fat Jar、JRE 容器和 Native Image 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 ![](/feat/_astro/deployment_1.BzQG9YoT_Z1vnAuN.webp) 到这一页,应用已经不只是“能在 IDE 里跑”。部署要回答的是另一个问题:怎样把编译期生成的 `CloudService`、运行期依赖和启动入口一起打进可交付产物里。 Feat Cloud 的交付路线可以分成三层: * **Fat Jar**:最短路径,适合普通服务器或快速验证 * **JRE 容器镜像**:适合标准化交付和容器平台 * **Native Image**:稳定之后再评估的优化路线 建议顺序是先跑通普通 jar,再容器化,最后再决定是否投入 Native。 ## 常规交付路线 [Section titled “常规交付路线”](#常规交付路线) 先把应用作为普通 Java 服务交付。这个阶段的目标很明确:生成可执行 jar,确认 `CloudService` 服务发现文件没有丢,再把同一个 jar 放进 JRE 容器。 ### 打包前先确认服务发现文件 [Section titled “打包前先确认服务发现文件”](#打包前先确认服务发现文件) Feat Cloud 依赖 Java `ServiceLoader` 加载编译期生成的 `CloudService`。这意味着打包时必须保留并合并这个文件: 服务发现文件 ```text META-INF/services/tech.smartboot.feat.cloud.CloudService ``` 如果 Fat Jar 打包时把它覆盖掉,应用能启动,但路由会全部丢失。所以 `maven-shade-plugin` 里必须配置 `AppendingTransformer`。 pom.xml ```xml org.apache.maven.plugins maven-shade-plugin ${maven-shade-plugin.version} package shade META-INF/services/tech.smartboot.feat.cloud.CloudService com.example.Bootstrap ``` `AppendingTransformer` 保留生成服务,`ManifestResourceTransformer` 指定 `java -jar` 的启动入口。两者缺一不可。 ### 构建 Fat Jar [Section titled “构建 Fat Jar”](#构建-fat-jar) 执行: 构建 ```bash mvn clean package ``` `target/` 下会生成一个可执行 Fat Jar: 构建产物 ```text target/yourapp-1.0.jar ``` 直接运行: 运行 ```bash java -jar target/yourapp-1.0.jar ``` 验证: 验证 ```bash curl http://localhost:8080/hello ``` 后台运行: 后台运行 ```bash nohup java -jar yourapp-1.0.jar > app.log 2>&1 & ``` ### 构建 JRE 容器镜像 [Section titled “构建 JRE 容器镜像”](#构建-jre-容器镜像) 容器化时,Fat Jar 仍然是交付物。Dockerfile 只负责提供 JRE 环境并启动它: Dockerfile ```dockerfile FROM eclipse-temurin:21-jre-alpine WORKDIR /app COPY target/yourapp-1.0.jar app.jar EXPOSE 8080 CMD ["java", "-jar", "app.jar"] ``` 构建并运行: 构建并运行容器 ```bash docker build -t yourapp:latest . docker run -p 8080:8080 yourapp:latest ``` 到这里,已经完成了最常见的服务交付路线。Native Image 是另一条优化路线,不建议一开始就作为默认目标。 ## Native 优化路线 [Section titled “Native 优化路线”](#native-优化路线) 只有当 JRE 版本已经稳定,且启动速度、内存或镜像体积真的成为问题时,再进入 Native Image。 ![普通 JRE 交付与更小的 Native Image 交付对比示意图](/feat/_astro/native_image_1.CfjD89k5_oPwlP.webp) ### 先稳定 JRE 版本 [Section titled “先稳定 JRE 版本”](#先稳定-jre-版本) 如果你当前只是: * 刚完成第一个 Controller * 还在调整 `feat.yml` * 还没有稳定的 Fat Jar * 还没有跑通过 JRE 容器镜像 那就先停在 JRE 路线。普通 `java -jar` 能更快暴露业务错误、配置错误和依赖冲突。等应用的启动入口、生成代码、资源文件和打包方式都稳定之后,再进入 Native,排查范围会小很多。 ### 适合投入 Native 的场景 [Section titled “适合投入 Native 的场景”](#适合投入-native-的场景) * 你非常在意启动速度 * 你运行在资源受限环境 * 你部署在 Serverless 或弹性伸缩场景 * 你希望容器镜像里不再携带完整 JRE * 你的依赖边界相对清晰,反射、动态代理和运行时扫描较少 如果这些收益对当前项目不关键,Native 往往不是优先级最高的工作。它能带来明显收益,也会把构建链路、调试方式和兼容性验证一起变复杂。 ### Native 推荐构建节奏 [Section titled “Native 推荐构建节奏”](#native-推荐构建节奏) Native Image 的关键不是记住某条命令,而是把验证顺序安排对。 1. 先用 Maven 构建普通 jar,确认 `CloudService` 服务发现文件存在 2. 用 `java -jar` 启动,确认 HTTP 路由、Profile、MyBatis、Session 等能力正常 3. 再使用 GraalVM 构建 native executable 4. 对 Native 产物跑同一组接口验证 5. 只有收益明确时,再把 Native 构建加入 CI 或发布流水线 一个常见的容器化构建可以写成多阶段 Dockerfile: Dockerfile.native ```dockerfile FROM container-registry.oracle.com/graalvm/native-image:21 AS builder WORKDIR /app COPY target/yourapp-1.0.jar app.jar RUN native-image --no-fallback -jar app.jar yourapp FROM debian:bookworm-slim WORKDIR /app COPY --from=builder /app/yourapp /app/yourapp EXPOSE 8080 CMD ["/app/yourapp"] ``` 构建镜像: 构建 Native 镜像 ```bash docker build -f Dockerfile.native -t yourapp:native . ``` 运行验证: 验证 Native 镜像 ```bash docker run --rm -p 8080:8080 yourapp:native curl http://localhost:8080/hello ``` 警告 不同依赖对 Native Image 的支持程度不同。只要引入了运行时反射、动态代理、资源扫描或 JNI,就可能需要额外配置。Feat Cloud 能减少框架自身的动态行为,但不能替第三方库消除所有 Native 约束。 ### 验证什么才算通过 [Section titled “验证什么才算通过”](#验证什么才算通过) Native 产物能启动,只是第一步。真正应该验证的是完整业务路径: * 控制台能打印预期路由 * `feat.profiles.active` 选择的环境正确 * 静态资源、MyBatis Mapper、SQL 初始化脚本等资源能被读取 * Session 的本地或 Redis 存储行为与 JRE 版本一致 * MCP 端点能被客户端发现并调用 如果 Native 版本和 JRE 版本行为不一致,先回到普通 jar 确认问题是否已经存在。只有 JRE 路线稳定而 Native 异常时,才把排查焦点放到 Native Image 的资源、反射和初始化配置上。 ## 常见问题 [Section titled “常见问题”](#常见问题) **启动后路由全部 404**:`AppendingTransformer` 未配置,导致 `CloudService` 服务发现文件被覆盖。检查 `pom.xml` 确认配置完整。 **`java -jar` 启动报错**:`mainClass` 写错或未配置。可用以下命令验证 Manifest: 检查 Manifest ```bash unzip -p target/yourapp-1.0.jar META-INF/MANIFEST.MF ``` 输出里应包含 `Main-Class: com.example.Bootstrap`。 **Docker `COPY` 找不到文件**:确认 `docker build` 的执行目录,`COPY` 路径相对于构建上下文,不能使用绝对路径。 如果问题不止发生在部署阶段,可以继续看 [故障排查](/feat/cloud/troubleshooting/)。 # 方法拦截器 > 用 @Interceptor、@AroundInvoke 和 @Interceptors 为 Controller 与 Bean 添加日志、鉴权、计时和短路逻辑 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 日志、鉴权、计时、事务边界这类逻辑有一个共同点:它们需要包围业务方法执行,却不应该散落在每个业务方法里。 Feat Cloud 提供了一组精简的方法拦截器 API。它参考了 Jakarta Interceptors 的核心设计:用 `@Interceptor` 声明拦截器,用 `@AroundInvoke` 定义环绕方法,用 `InvocationContext.proceed()` 推进调用链,再用 `@Interceptors` 把拦截器绑定到类或方法。 Feat 没有完整实现 Jakarta Interceptors 规范。它只保留了方法拦截所需的部分,并结合 Feat Cloud 的 AOT 工作模型,在编译期生成普通 Java 调用代码。 注意 Feat 的注解位于 `tech.smartboot.feat.cloud.annotation.interceptor`,不是 `jakarta.interceptor`。两者设计思路相近,但 API 不可直接互换。 ## 第一个拦截器 [Section titled “第一个拦截器”](#第一个拦截器) 先实现一个记录方法名称、参数和耗时的拦截器。 LoggingInterceptor.java ```java package com.example.interceptor; import tech.smartboot.feat.cloud.annotation.interceptor.AroundInvoke; import tech.smartboot.feat.cloud.annotation.interceptor.Interceptor; import tech.smartboot.feat.cloud.interceptor.InvocationContext; import java.util.Arrays; @Interceptor public class LoggingInterceptor { @AroundInvoke public Object intercept(InvocationContext context) throws Exception { String methodName = context.getMethod().getDeclaringClass().getSimpleName() + "." + context.getMethod().getName(); long start = System.nanoTime(); System.out.println("进入 " + methodName + ", 参数=" + Arrays.toString(context.getParameters())); try { Object result = context.proceed(); System.out.println("完成 " + methodName + ", 返回值=" + result); return result; } finally { long elapsed = System.nanoTime() - start; System.out.println("耗时 " + elapsed + " ns"); } } } ``` 一个 Feat 拦截器由两部分组成: * `@Interceptor` 把类注册为拦截器 * `@AroundInvoke` 标记真正执行环绕逻辑的方法 每个 `@Interceptor` 类必须且只能有一个 `@AroundInvoke` 方法。建议固定使用下面的签名: ```java public Object intercept(InvocationContext context) throws Exception ``` 调用 `context.proceed()` 时,控制权会进入下一个拦截器;如果当前已经是链尾,则执行目标业务方法。 ## 把拦截器绑定到方法 [Section titled “把拦截器绑定到方法”](#把拦截器绑定到方法) 通过 `@Interceptors` 指定要执行的拦截器类型。 OrderController.java ```java package com.example.controller; import com.example.interceptor.LoggingInterceptor; import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.RequestMapping; import tech.smartboot.feat.cloud.annotation.interceptor.Interceptors; @Controller("orders") public class OrderController { @RequestMapping("/42") @Interceptors(LoggingInterceptor.class) public String findOrder() { return "order-42"; } } ``` 重新编译并访问: 重新编译 ```shell mvn compile ``` 发起请求 ```shell curl http://localhost:8080/orders/42 ``` 控制台中的执行过程类似: ```text 进入 OrderController.findOrder, 参数=[] 完成 OrderController.findOrder, 返回值=order-42 耗时 325100 ns ``` 拦截器声明属于 AOT 输入。新增、删除或调整 `@Interceptor`、`@AroundInvoke`、`@Interceptors` 后,需要重新编译,旧的生成代码不会在运行时自动更新。 ## 调用链如何执行 [Section titled “调用链如何执行”](#调用链如何执行) 假设一个方法声明了三个拦截器: ```java @Interceptors({AuthenticationInterceptor.class, LoggingInterceptor.class, TransactionInterceptor.class}) public String createOrder() { return "created"; } ``` 每个拦截器在 `proceed()` 之前的代码按声明顺序执行,在 `proceed()` 之后的代码按相反顺序返回。 ``` sequenceDiagram participant Caller as 调用方 participant Auth as Authentication participant Log as Logging participant Tx as Transaction participant Method as 目标方法 Caller->>Auth: 调用 Auth->>Log: proceed() Log->>Tx: proceed() Tx->>Method: proceed() Method-->>Tx: 返回结果 Tx-->>Log: 返回结果 Log-->>Auth: 返回结果 Auth-->>Caller: 返回结果 ``` 这就是典型的“洋葱模型”。最先进入的拦截器最后退出,因此通常可以这样安排: 1. 鉴权或限流放在前面,尽早拒绝无效调用 2. 日志、链路追踪放在外层,覆盖完整执行过程 3. 事务等资源边界靠近目标方法 ## 类级与方法级拦截器 [Section titled “类级与方法级拦截器”](#类级与方法级拦截器) `@Interceptors` 可以标在类上,也可以标在方法上。 OrderService.java ```java import tech.smartboot.feat.cloud.annotation.Bean; import tech.smartboot.feat.cloud.annotation.interceptor.Interceptors; @Bean @Interceptors(AuthenticationInterceptor.class) public class OrderService { public String find(String id) { return "order-" + id; } @Interceptors({LoggingInterceptor.class, TransactionInterceptor.class}) public String create(String id) { return "created-" + id; } } ``` 合并规则如下: | 调用方法 | 实际拦截器顺序 | | ------------- | ----------------------------------------------------------------------------- | | `find(...)` | `AuthenticationInterceptor` | | `create(...)` | `AuthenticationInterceptor` → `LoggingInterceptor` → `TransactionInterceptor` | 方法级拦截器不会覆盖类级拦截器,而是追加在类级列表之后。数组顺序就是进入顺序,Feat 不会按类名或注册时间重新排序。 除了 Controller,普通 `@Bean` 的公共实例方法也可以使用同样的方式拦截。这样可以把事务、审计等规则放在业务服务边界,而不是只放在 HTTP 入口。 ## 使用 `InvocationContext` [Section titled “使用 InvocationContext”](#使用-invocationcontext) `InvocationContext` 表示当前这一次方法调用。Feat 保留了四个核心操作: | 方法 | 用途 | | ----------------- | ----------------------------------- | | `getTarget()` | 获取当前被调用的 Bean 增强实例 | | `getMethod()` | 获取被拦截方法的 `java.lang.reflect.Method` | | `getParameters()` | 按形参顺序读取本次调用的参数数组 | | `proceed()` | 调用下一个拦截器或最终目标方法 | 例如,可以通过方法和参数信息生成审计日志: ```java @AroundInvoke public Object audit(InvocationContext context) throws Exception { System.out.println("target=" + context.getTarget().getClass().getName()); System.out.println("method=" + context.getMethod().getName()); System.out.println("arguments=" + Arrays.toString(context.getParameters())); return context.proceed(); } ``` 警告 `getParameters()` 用于读取调用参数。Feat 不提供 `setParameters(...)`,也不应依赖修改返回数组来改变目标方法的实际入参。需要调整数据时,请在进入拦截链前完成,或把转换逻辑放进业务方法。 ## 短路调用链 [Section titled “短路调用链”](#短路调用链) `proceed()` 不是自动执行的。如果拦截器直接返回,后续拦截器和目标方法都不会运行。这可以用于权限拒绝、限流、缓存命中或维护模式。 MaintenanceInterceptor.java ```java import tech.smartboot.feat.cloud.RestResult; import tech.smartboot.feat.cloud.annotation.interceptor.AroundInvoke; import tech.smartboot.feat.cloud.annotation.interceptor.Interceptor; import tech.smartboot.feat.cloud.interceptor.InvocationContext; @Interceptor public class MaintenanceInterceptor { private boolean maintenance = true; @AroundInvoke public Object intercept(InvocationContext context) throws Exception { if (maintenance) { return RestResult.fail("系统维护中,请稍后再试"); } return context.proceed(); } } ``` 目标方法也使用 `RestResult` 作为返回类型: ```java @RequestMapping("/create") @Interceptors(MaintenanceInterceptor.class) public RestResult createOrder() { return RestResult.ok("created"); } ``` 短路返回值必须与目标方法的返回类型兼容。否则生成代码在接收调用链结果时可能出现类型转换错误。对于 `void` 方法,拦截器返回值会被忽略。 ## 与 Jakarta Interceptors 的关系 [Section titled “与 Jakarta Interceptors 的关系”](#与-jakarta-interceptors-的关系) Feat 采用了 Jakarta Interceptors 最容易理解、也最常用的环绕调用模型,但没有把完整规范搬进来。 | 能力 | Jakarta Interceptors | Feat Cloud | | --------- | ----------------------- | -------------------------- | | 拦截器声明 | `@Interceptor` | `@Interceptor` | | 环绕方法 | `@AroundInvoke` | `@AroundInvoke` | | 显式绑定 | `@Interceptors` | `@Interceptors` | | 调用上下文 | 完整 `InvocationContext` | 仅保留目标、方法、参数读取和 `proceed()` | | 参数替换 | 支持 `setParameters(...)` | 不支持 | | 拦截器绑定注解 | 支持 | 不支持,直接使用 `@Interceptors` | | 优先级与默认拦截器 | 支持规范定义的排序机制 | 使用数组声明顺序 | | 生命周期与构造拦截 | 支持更多拦截类型 | 当前只支持方法调用拦截 | | 发现方式 | 由 Jakarta 容器管理 | Feat AOT 编译期生成并注册 | 这种取舍让 API 保持很小:没有绑定注解、优先级体系、上下文数据 Map、Timer 或参数替换。需要理解的核心只有“声明拦截器、绑定拦截器、调用 `proceed()`”三件事。 ## AOT 约束 [Section titled “AOT 约束”](#aot-约束) Feat 通过生成子类覆盖业务方法来接入调用链,因此被拦截代码需要满足一些条件: * 目标方法必须是 `public` 实例方法 * `static`、`final` 和非 `public` 方法不会生成拦截逻辑 * 包含被拦截方法的类不能声明为 `final` * 拦截器类需要可实例化,建议提供可访问的无参构造方法 * 每个拦截器类只能有一个 `@AroundInvoke` 方法 * `@AroundInvoke` 方法应接收一个 `InvocationContext` 并返回 `Object` * 当前 AOT 调用链不处理构造方法拦截 提示 如果拦截器没有执行,先运行 `mvn clean compile`,再检查目标方法是否为 `public`、是否带有 `final` 或 `static`,以及拦截器类中是否存在唯一的 `@AroundInvoke` 方法。 ## 使用建议 [Section titled “使用建议”](#使用建议) 适合放进拦截器的逻辑通常具有明显的横切特征: * 权限校验和调用准入 * 统一日志、审计与链路追踪 * 方法耗时统计 * 限流、幂等与缓存命中 * 事务或资源边界 不要把具体业务流程藏进拦截器。订单状态变更、库存扣减、价格计算这类逻辑应继续留在业务方法中。一个简单判断标准是:如果移除拦截器后,业务规则本身就不完整,那么这段代码通常不属于横切逻辑。 最后记住调用链最重要的规则: ```text 调用 proceed() -> 继续执行后续拦截器和目标方法 不调用 proceed() -> 在当前拦截器短路并直接返回 ``` # MCP 能力暴露 > 用 Feat Cloud 的注解式方式暴露 Tool、Prompt 和 Resource,并决定是否需要独立 MCP 端点 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 前面的章节一直在讲 HTTP:Controller 接收请求,调用业务代码,再返回文本或 JSON。MCP 让同一类业务能力多了一个入口:模型客户端可以发现它、理解它的参数,并把它当成工具、提示模板或资源来调用。 Feat Cloud 的优势仍然来自编译期生成。你在 Controller 上声明 `@Tool`、`@Prompt`、`@Resource` 或 `@McpEndpoint`,编译期会把这些声明转成 MCP Server 的注册代码。运行时要做的事情很少:加载生成类,把能力挂到对应端点上。 ![模型客户端通过 MCP 调用 Feat Cloud 中 Tool、Prompt、Resource 能力的示意图](/feat/_astro/mcp_1.BNrtrZ9J_N2wzD.webp) ## 先把一个方法变成工具 [Section titled “先把一个方法变成工具”](#先把一个方法变成工具) 如果只是把少量业务方法开放给 MCP 客户端,不需要一开始就设计完整端点。给方法加 `@Tool`,给参数补上 `@Param` 元信息,就能先跑通最小链路: DefaultMcpController.java ```java import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.mcp.Tool; import tech.smartboot.feat.cloud.annotation.mcp.Param; @Controller public class DefaultMcpController { @Tool(description = "简单的问候工具") public String greeting(@Param(required = true, description = "用户姓名") String name) { return "你好," + name + "!"; } } ``` 这里的 `@Param` 不是 HTTP 查询参数,而是 MCP 参数描述。它告诉客户端这个工具需要什么输入、参数是否必填、参数含义是什么。对模型客户端来说,这些描述会直接影响工具能不能被正确选择和调用。 ## 设计 MCP 端点 [Section titled “设计 MCP 端点”](#设计-mcp-端点) Feat Cloud 提供两种 MCP 暴露方式:默认 MCP 服务和显式 `@McpEndpoint`。 ### 默认服务还是独立端点 [Section titled “默认服务还是独立端点”](#默认服务还是独立端点) 默认 MCP 服务适合早期接入:项目里出现 `@Tool`、`@Prompt`、`@Resource` 后,Feat Cloud 会生成默认 MCP Server,把这些能力挂进去。它的价值是低成本验证:先确认客户端能发现工具、能发起调用、能拿到结果。 当 MCP 已经成为应用边界的一部分,就应该使用 `@McpEndpoint` 显式定义端点。它让服务名和 Streamable HTTP 路径都写在代码里,部署、网关和客户端配置也更容易保持稳定。 McpDemoController.java ```java import tech.smartboot.feat.ai.mcp.enums.PromptType; import tech.smartboot.feat.ai.mcp.model.ToolResult; import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.mcp.McpEndpoint; import tech.smartboot.feat.cloud.annotation.mcp.Param; import tech.smartboot.feat.cloud.annotation.mcp.Prompt; import tech.smartboot.feat.cloud.annotation.mcp.Resource; import tech.smartboot.feat.cloud.annotation.mcp.Tool; import java.util.HashMap; import java.util.Map; @Controller @McpEndpoint( name = "demo-mcp-service", title = "Demo MCP Service", streamableEndpoint = "/mcp/demo/stream" ) public class McpDemoController { @Tool(name = "getUserInfo", description = "根据用户ID获取用户信息") public Map getUserInfo( @Param(required = true, description = "用户ID") Long userId, @Param(required = false, description = "是否包含详细信息") Boolean detailed ) { Map user = new HashMap<>(); user.put("id", userId); user.put("name", "User " + userId); user.put("email", userId + "@example.com"); user.put("detailed", Boolean.TRUE.equals(detailed)); return user; } @Prompt( name = "codeReviewPrompt", description = "代码审查提示词", type = PromptType.TEXT ) public String codeReviewPrompt( @Param(required = true, description = "编程语言") String language, @Param(required = true, description = "代码片段") String codeSnippet ) { return String.format("请审查以下%s代码并提供改进建议:%s", language, codeSnippet); } @Resource( uri = "/resources/coding-standards.md", name = "编码规范", description = "团队编码规范文档", mimeType = "text/markdown", isText = true ) public String codingStandards() { return "# 编码规范"; } @Tool(description = "获取文本信息") public ToolResult.TextContent getTextInfo() { return ToolResult.ofText("这是文本内容"); } } ``` 这段代码里有三类能力: * `@Tool` 暴露可调用能力 * `@Prompt` 暴露提示模板 * `@Resource` 暴露文本或二进制资源 `@McpEndpoint` 则负责把这组能力放进一个明确的 MCP 服务边界里。到了这个阶段,你应该像设计 HTTP API 一样设计 MCP 端点:路径要稳定,名称要能表达服务意图,参数描述要足够让调用方理解。 ### Tool、Prompt、Resource 的分工 [Section titled “Tool、Prompt、Resource 的分工”](#toolpromptresource-的分工) `@Tool` 适合执行动作或查询数据。如果你的目标是“让模型调用这段业务逻辑”,从 Tool 开始最自然,例如查用户、算价格、生成报表摘要。 `@Prompt` 适合暴露提示模板。它不是普通 Controller 里直接返回一段文本,而是把一套可复用的提示结构交给 MCP 客户端,让客户端在合适时填入参数并使用。 `@Resource` 适合暴露稳定材料,例如团队规范、产品说明、接口文档、图片或二进制资源。资源一般不表达“做一件事”,而是让客户端读取一份内容。 ### 不要把 MCP Controller 写成大杂烩 [Section titled “不要把 MCP Controller 写成大杂烩”](#不要把-mcp-controller-写成大杂烩) MCP 能力应该围绕调用方的任务组织,而不是围绕注解类型堆叠。一个面向代码审查的 MCP 服务,可以包含代码审查 Prompt、读取编码规范的 Resource、查询项目元数据的 Tool;另一个面向运维的 MCP 服务,则应该独立成另一组 Controller 和端点。 如果你是第一次接入 MCP,比较稳的顺序是: 1. 先暴露一个最小 Tool,确认客户端能调用 2. 再补充必要的参数描述,观察模型是否能正确选择工具 3. 需要复用提示结构时再加入 Prompt 4. 需要稳定材料时再加入 Resource 5. 服务边界稳定后,再迁移到显式 `@McpEndpoint` ## 什么时候直接操作 `McpServer` [Section titled “什么时候直接操作 McpServer”](#什么时候直接操作-mcpserver) 注解方式适合大多数静态能力:工具、提示和资源在编译期就能确定,Feat Cloud 可以直接生成注册代码。 如果你的工具集合需要在运行时动态变化,例如从数据库、插件系统或外部配置加载工具,就可以注入 `McpServer` 并在代码里管理。这个方式更灵活,也意味着你要自己维护更多边界:何时注册、何时下线、如何避免重复、如何处理并发变化。 所以默认选择很简单:能用注解清楚表达的能力,优先交给编译期生成;只有当能力本身就是动态的,再直接操作 `McpServer`。 # 生态集成:MyBatis > 把 MyBatis Mapper 接入 Feat Cloud 的 Bean 生命周期,完成一条可验证的 CRUD 数据访问链路 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 这一章用 MyBatis 给 Feat Cloud 应用搭一条数据访问链路:建表、Mapper、Service、Controller,跑通一组 CRUD 接口。Mapper 仍按 MyBatis 的方式写 SQL,Feat Cloud 只在编译期把它接进 Bean 生命周期——Controller 用 `@Autowired` 就能拿到,不用手写装配。 ## 一条完整的数据访问链路 [Section titled “一条完整的数据访问链路”](#一条完整的数据访问链路) 这一章会搭出一个最小的 CRUD 链路: * 访问 `/users` 能查到所有用户 * 访问 `/users/{username}` 能查到单个用户 * 访问 `/users/role/{role}` 能按角色查用户 * `POST /users` 能创建用户 * `PUT /users` 能更新用户 * `DELETE /users/{username}` 能删除用户 最终会涉及这些文件: 示例结构 ```text src/main/resources/ ├── feat.yaml ├── mybatis/mybatis-config.xml └── mybatis/ddl/schema.sql src/main/java/tech/smartboot/feat/demo/mybatis/ ├── Bootstrap.java ├── entity/User.java ├── mapper/UserMapper.java ├── service/UserService.java └── controller/UserController.java ``` 你可以按这个顺序理解后面的内容:先准备资源文件,再写 Mapper 和 Service,最后把能力暴露成 HTTP 接口。 ## 准备资源与启动入口 [Section titled “准备资源与启动入口”](#准备资源与启动入口) 这一部分先把项目跑起来所需的资源准备好:Maven 依赖、`feat.yaml`、MyBatis 配置、初始化 SQL,以及一个干净的启动类。 ### 准备依赖 [Section titled “准备依赖”](#准备依赖) 在已有 Feat Cloud 应用基础上,引入 MyBatis 和数据库驱动。下面的示例使用 H2 内存数据库,适合先把链路跑通: pom.xml ```xml org.mybatis mybatis ${mybatis.version} com.h2database h2 ${h2.version} ``` 示例中 MyBatis 和 H2 的版本号使用占位符,实际使用时请替换为当前稳定版本。`feat-cloud` 与 `feat-cloud-starter` 仍按快速开始中的方式保留:前者提供运行期,后者负责生成 MyBatis 接入代码。 ### 让 Feat Cloud 找到 MyBatis [Section titled “让 Feat Cloud 找到 MyBatis”](#让-feat-cloud-找到-mybatis) 在 `src/main/resources/feat.yaml` 里声明 MyBatis 主配置和初始化 SQL: feat.yaml ```yaml feat: mybatis: path: mybatis/mybatis-config.xml initial-sql: mybatis/ddl/schema.sql ``` `path` 指向 MyBatis 主配置;`initial-sql` 指向启动时要执行的初始化脚本。它们都属于编译期配置输入,和前一章讲的 `feat.yml` / `feat-{env}.yml` 一样,修改后需要重新构建。 ### 准备 MyBatis 自己的资源 [Section titled “准备 MyBatis 自己的资源”](#准备-mybatis-自己的资源) 这一段只处理 `src/main/resources` 下的文件。它们负责告诉 Feat Cloud 和 MyBatis:配置在哪里,数据库怎么初始化,Mapper 包从哪里加载。 示例里的 `mybatis-config.xml` 只做三件事: 1. 打开 SQL 日志 2. 配置 H2 数据源 3. 注册 mapper 包 mybatis-config.xml ```xml ``` `schema.sql` 负责建表并插入几条初始数据。这样后面的 Controller 和 Service 能立刻验证,不需要再手动准备数据库。 schema.sql ```sql DROP TABLE IF EXISTS user_info; CREATE TABLE user_info ( username VARCHAR(64) PRIMARY KEY, password VARCHAR(128), `desc` VARCHAR(255), role VARCHAR(32) ); INSERT INTO user_info(username, password, `desc`, role) VALUES ('feat', '123456', 'Feat admin user', 'admin'), ('guest', '123456', 'Guest user', 'user'); ``` 到这里,配置和数据库初始化资源已经就位。接下来开始写 Java 代码。 ### 保持启动入口干净 [Section titled “保持启动入口干净”](#保持启动入口干净) 示例里的启动类非常简单: Bootstrap.java ```java import tech.smartboot.feat.cloud.FeatCloud; import tech.smartboot.feat.cloud.annotation.Bean; @Bean public class Bootstrap { public static void main(String[] args) { FeatCloud.cloudServer().listen(); } } ``` 这个类本身几乎不做业务事,但它给了 Feat Cloud 一个明确的启动入口。 MyBatis 的配置接入,主要是靠前面的 `feat.yaml` 和资源文件完成的。 ## 编写数据访问代码 [Section titled “编写数据访问代码”](#编写数据访问代码) 这一段只负责数据访问层:`User` 表示一行业务数据,`UserMapper` 仍然按 MyBatis 的方式声明 SQL。 ### User 实体 [Section titled “User 实体”](#user-实体) User.java ```java package tech.smartboot.feat.demo.mybatis.entity; public class User { private String username; private String password; private String desc; private String role; public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getDesc() { return desc; } public void setDesc(String desc) { this.desc = desc; } public String getRole() { return role; } public void setRole(String role) { this.role = role; } } ``` MyBatis 用字段映射数据库列,Feat Cloud 用 getter 做 JSON 序列化,所以实体里需要同时有字段和 getter。 ### UserMapper [Section titled “UserMapper”](#usermapper) UserMapper.java ```java package tech.smartboot.feat.demo.mybatis.mapper; import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import tech.smartboot.feat.demo.mybatis.entity.User; import java.util.List; @Mapper public interface UserMapper { @Select("SELECT * FROM user_info WHERE username = #{username}") User selectByUsername(@Param("username") String username); @Select("SELECT * FROM user_info") List selectAll(); @Select("SELECT * FROM user_info WHERE role = #{role}") List selectByRole(@Param("role") String role); @Insert("INSERT INTO user_info(username, password, `desc`, role) VALUES(#{username}, #{password}, #{desc}, #{role})") int insert(User user); @Update("UPDATE user_info SET password=#{password}, `desc`=#{desc}, role=#{role} WHERE username=#{username}") int update(User user); @Delete("DELETE FROM user_info WHERE username = #{username}") int deleteByUsername(@Param("username") String username); } ``` 这里的关键不是 MyBatis 注解语法,而是职责边界: * Mapper 仍然是纯 MyBatis 思维 * Feat Cloud 负责把它接进应用上下文 ### UserService [Section titled “UserService”](#userservice) 示例项目没有直接在 Controller 里操作 Mapper,而是先过一层 Service: UserService.java ```java package tech.smartboot.feat.demo.mybatis.service; import tech.smartboot.feat.cloud.annotation.Autowired; import tech.smartboot.feat.cloud.annotation.Bean; import tech.smartboot.feat.demo.mybatis.entity.User; import tech.smartboot.feat.demo.mybatis.mapper.UserMapper; import java.util.List; @Bean public class UserService { @Autowired private UserMapper userMapper; public User findByUsername(String username) { return userMapper.selectByUsername(username); } public List findAll() { return userMapper.selectAll(); } public List findByRole(String role) { return userMapper.selectByRole(role); } public boolean insert(User user) { // 不要 try/catch 吞掉异常:吞掉之后调用方拿到 false, // 以为「这次没插进去,重试一次就好」,而实际上事务早就该回滚了。 // 想让接口返回业务错误,就在 Controller 层把异常翻译成状态码。 return userMapper.insert(user) > 0; } public boolean update(User user) { return userMapper.update(user) > 0; } public boolean deleteByUsername(String username) { return userMapper.deleteByUsername(username) > 0; } } ``` 这一层让职责保持清楚: * Controller 负责 HTTP * Service 负责业务动作 * Mapper 负责数据库访问 这样后面继续扩展权限、校验时,结构不会乱。事务相关的注意事项单独放在下一节,它不是「扩展」,而是这条链路本身就有的东西。 ## 暴露并验证 HTTP 接口 [Section titled “暴露并验证 HTTP 接口”](#暴露并验证-http-接口) 资源文件、Mapper 和 Service 都准备好之后,最后一步是把这条链路接到 HTTP 上。 ### UserController [Section titled “UserController”](#usercontroller) 最后在 Controller 里把这条链路接到 HTTP 上: UserController.java ```java package tech.smartboot.feat.demo.mybatis.controller; import tech.smartboot.feat.cloud.RestResult; import tech.smartboot.feat.cloud.annotation.Autowired; import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.PathParam; import tech.smartboot.feat.cloud.annotation.RequestMapping; import tech.smartboot.feat.cloud.annotation.RequestMethod; import tech.smartboot.feat.demo.mybatis.entity.User; import tech.smartboot.feat.demo.mybatis.service.UserService; import java.util.List; @Controller public class UserController { @Autowired private UserService userService; @RequestMapping("/users") public RestResult> getAllUsers() { return RestResult.ok(userService.findAll()); } @RequestMapping("/users/{username}") public RestResult getUserByUsername(@PathParam("username") String username) { User user = userService.findByUsername(username); return user != null ? RestResult.ok(user) : RestResult.fail("User not found"); } @RequestMapping("/users/role/{role}") public RestResult> getUsersByRole(@PathParam("role") String role) { return RestResult.ok(userService.findByRole(role)); } @RequestMapping(value = "/users", method = RequestMethod.POST) public RestResult createUser(User user) { return userService.insert(user) ? RestResult.ok("User created successfully") : RestResult.fail("Failed to create user"); } @RequestMapping(value = "/users", method = RequestMethod.PUT) public RestResult updateUser(User user) { return userService.update(user) ? RestResult.ok("User updated successfully") : RestResult.fail("Failed to update user"); } @RequestMapping(value = "/users/{username}", method = RequestMethod.DELETE) public RestResult deleteUser(@PathParam("username") String username) { return userService.deleteByUsername(username) ? RestResult.ok("User deleted successfully") : RestResult.fail("Failed to delete user"); } } ``` 注意 `createUser(User user)` 和 `updateUser(User user)` 的参数没有加任何注解。这个写法在 Controller 与请求处理章节已经出现过:Feat Cloud 检测到参数类型是自定义 POJO 时,会自动从 JSON 请求体里反序列化出这个对象。 到这一步,链路已经完整了: `HTTP -> Controller -> Service -> Mapper -> DB` ### 验证整条链路 [Section titled “验证整条链路”](#验证整条链路) 启动应用后,可以先验证读接口: 查询接口验证 ```shell curl http://localhost:8080/users curl http://localhost:8080/users/feat curl http://localhost:8080/users/role/admin ``` 再验证写接口: 写接口验证 ```shell curl -X POST http://localhost:8080/users \ -H 'Content-Type: application/json' \ -d '{"username":"new_user","password":"123456","desc":"created from docs","role":"user"}' curl -X PUT http://localhost:8080/users \ -H 'Content-Type: application/json' \ -d '{"username":"new_user","password":"654321","desc":"updated from docs","role":"admin"}' curl -X DELETE http://localhost:8080/users/new_user ``` 如果这些请求都能返回合理结果,说明: * Feat Cloud 启动成功 * MyBatis 配置被正确读取 * 初始化 SQL 生效 * Mapper / Service / Controller 整条链路都打通了 ## 会话与事务 [Section titled “会话与事务”](#会话与事务) `@Autowired` 注入进来的 Mapper 看起来是个普通对象,其实是编译期生成的包装类:**每个方法都自己 `openSession(true)`**,也就是「开一条会话 → 执行 SQL → 自动提交 → 关闭」。 只有一条语句时这没有问题,一条语句本身就是一个事务。一旦一次业务动作要写两张表, 这个默认行为就会出事——第一张表已经提交、第二张表失败,接口返回错误,但脏数据留下了。 ### sessionFactory 是一个命名 Bean [Section titled “sessionFactory 是一个命名 Bean”](#sessionfactory-是一个命名-bean) 生成的装配代码在 `loadBean` 阶段创建 `SqlSessionFactory` 并以 `"sessionFactory"` 注册, `autowired` 阶段才做字段注入,因此业务 Bean 里可以直接声明它: ```java @Autowired private SqlSessionFactory sessionFactory; public void moveUser(long fromDept, long toDept, long userId) { try (SqlSession session = sessionFactory.openSession(false)) { // false = 手动提交 UserMapper users = session.getMapper(UserMapper.class); users.updateDepartment(userId, toDept); users.decreaseDeptCount(fromDept); session.commit(); } } ``` 不需要注解,不需要代理,也不需要额外的容器支持。`openSession()` 的默认值本来就是手动提交, 显式写 `false` 只是为了让读代码的人一眼看到边界在哪。 ### 三个必须知道的坑 [Section titled “三个必须知道的坑”](#三个必须知道的坑) 1. **注入的 Mapper 与手开的会话是两套东西。** 事务体里必须全部换成 `session.getMapper(...)`。 混用一个,那一笔写就落在事务外,接口照常返回成功,回滚时也不会被收回。 2. **跨 Service 不传播。** A 里开了会话去调 B,B 里注入的 Mapper 会自己再开一条会话并提交。 要么把 `SqlSession` 当参数传下去,要么用模板把整段调用包住。 3. **必须 close。** 默认数据源是 MyBatis 的 `PooledDataSource`,漏 close 会耗尽连接池, 表现是服务慢慢假死,而不是抛异常——所以用 try-with-resources,别手动配对。 ### 建议包成模板 [Section titled “建议包成模板”](#建议包成模板) 每个写方法都手写 try / commit / catch / rollback / close,迟早会漏一个 `close()`, 或者漏掉 catch 里的 rollback。更稳的做法是把这段固定逻辑收敛成一个模板: ```java @Bean public class TxTemplate { @Autowired private SqlSessionFactory sessionFactory; public T call(Function work) { try (SqlSession session = sessionFactory.openSession(false)) { T result = work.apply(session); session.commit(); return result; } } } ``` 业务代码只剩一行边界,rollback 与 close 只写一次。`demo/feat-plus` 里的 `tx/TxTemplate` 就是这个形状的完整版,多了「内层加入外层」和「回滚失败不盖掉业务异常」两件事。 ### 不要在事务体里吞异常 [Section titled “不要在事务体里吞异常”](#不要在事务体里吞异常) ```java // 反例:事务失去意义 try { userMapper.insert(user); } catch (Exception e) { e.printStackTrace(); return false; } ``` 吞掉异常等于告诉框架「一切正常,请提交」。要返回业务错误,让异常抛到 Controller 层再翻译成状态码; 要记录日志,记完继续抛。**回滚的唯一触发条件是异常穿出事务边界。** ## Feat Cloud 负责接线,MyBatis 仍负责访问数据 [Section titled “Feat Cloud 负责接线,MyBatis 仍负责访问数据”](#feat-cloud-负责接线mybatis-仍负责访问数据) 在 Feat Cloud 里,MyBatis 集成不是重写一套数据访问层。Mapper 仍按 MyBatis 的方式定义,SQL 仍由 MyBatis 执行,Service 仍组织业务动作。 Feat Cloud 负责的是另一半:读取 `feat.yaml`,在编译期生成 Mapper 接入和 Bean 装配代码,让 Controller 可以通过 `@Autowired` 使用数据访问层。理解这个边界之后,后续切换数据库、拆分环境配置或进入部署阶段都会更清晰。 # 生态集成:Nacos > 把 Nacos 接入 Feat Cloud,覆盖配置中心、服务注册、服务发现,以及生产环境鉴权与优雅关闭 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 单体应用里,配置跟着代码一起发布,调用方直接写死对端地址。进入多环境、多实例部署后,数据库地址、功能开关、限流阈值必须独立管理,实例上下线也得自动感知——配置中心和服务注册发现就是干这两件事的。 Nacos 把配置管理(Config)和服务管理(Naming)做在了同一个客户端里。本文用一个类对应一个能力:`ConfigBootstrap` 管配置,`RegisterBootstrap` 管注册,`DiscoverBootstrap` 管发现。三个类结构同构,套路一致。 ![](/feat/_astro/nacos.BjXb1Gh9_Z1EB0IF.webp) ## 准备依赖与基础配置 [Section titled “准备依赖与基础配置”](#准备依赖与基础配置) **Feat Cloud 没有内置 Nacos starter。** 本文直接使用官方 `nacos-client`,不额外封装。多写几十行胶水代码,换来的是完全透明的链路——连接在哪建立、配置在哪回调、坏配置为什么被拦截,全部一目了然。 ### 引入 Nacos 客户端 [Section titled “引入 Nacos 客户端”](#引入-nacos-客户端) 在已有 Feat Cloud 应用基础上,加上 `nacos-client`: pom.xml ```xml com.alibaba.nacos nacos-client 3.2.3 ``` `nacos-client` 是运行期依赖,`feat-cloud-starter` 仍然负责编译期生成代码。两者各管各的,互不替代。 ### 在 feat.yml 里声明连接信息 [Section titled “在 feat.yml 里声明连接信息”](#在-featyml-里声明连接信息) Nacos 服务地址、各能力的 dataId / serviceName / group 这类”环境之间会变、但启动后不变”的参数,放进 `feat.yml` 最合适。下面这份配置同时承载了配置中心、服务注册、服务发现三组参数: feat.yml ```yaml nacos: serverAddr: 127.0.0.1:8848 config: dataId: config1 group: feat timeout: 1000 register: serviceName: feat-nacos-demo group: DEFAULT_GROUP ip: 127.0.0.1 port: 8082 discover: serviceName: feat-nacos-demo group: DEFAULT_GROUP ``` 这些值会在编译期通过 `@Value` 解析并注入到字段里。`@Value` 支持 `String`、`int`、`String[]`、`List` 等少量类型,把 Nacos 连接参数统一用字符串或整数声明最省事。 提示 `@Value` 注入依赖字段对应的 setter 方法。字段叫 `serverAddr`,就得有 `setServerAddr(...)`;编译时找不到 setter 会直接报错。嵌套配置用 `nacos.config.dataId` 这种点号路径,自动映射到 `nacos` 下的 `config` 节点。具体规则可以回看 [Bean 与依赖注入](/feat/cloud/bean/) 里的「从配置注入值」。 ### 按环境拆分 Nacos 连接 [Section titled “按环境拆分 Nacos 连接”](#按环境拆分-nacos-连接) 本地开发连本机 Nacos,测试环境连测试集群,生产环境连生产集群。这是典型的 Profile 场景。 feat-dev.yml ```yaml nacos: serverAddr: dev-nacos.example.com:8848 config: dataId: config1 ``` feat-prod.yml ```yaml nacos: serverAddr: prod-nacos.example.com:8848 register: ip: 10.0.1.23 port: 8082 ``` 启动时通过 `feat.profiles.active` 选择当前环境: 选择生产环境启动 ```bash java -Dfeat.profiles.active=prod -jar yourapp.jar ``` 警告 生产环境的 Nacos 用户名和口令不要硬编码进 `feat-prod.yml` 提交到代码仓库。可以用 `${}` 占位符引用,效果类似 Spring: feat-prod.yml ```yaml nacos: username: ${nacos.username} password: ${nacos.password} ``` `${nacos.username}` 会同时兼容 JVM 参数 `-Dnacos.username` 和环境变量 `NACOS_USERNAME` 两种来源,解析优先级是系统属性 > 环境变量。占位符的完整规则见[启动配置与环境配置](/feat/cloud/options/)。运行时注入方式: ```bash # 任选其一 java -Dnacos.username=admin -Dnacos.password=xxx -Dfeat.profiles.active=prod -jar yourapp.jar # 或者用环境变量 NACOS_USERNAME=admin NACOS_PASSWORD=xxx java -Dfeat.profiles.active=prod -jar yourapp.jar ``` ## 配置中心接入 [Section titled “配置中心接入”](#配置中心接入) 配置中心最核心的需求不是”能读到配置”,而是”配置变更时应用能安全地用上新值”。`ConfigBootstrap` 一个类完成连接、拉取、监听和对外暴露,保持最小结构。 ConfigBootstrap.java ```java package tech.smartboot.feat.nacos; import com.alibaba.nacos.api.NacosFactory; import com.alibaba.nacos.api.PropertyKeyConst; import com.alibaba.nacos.api.config.ConfigService; import com.alibaba.nacos.api.config.listener.AbstractListener; import tech.smartboot.feat.cloud.FeatCloud; import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.PostConstruct; import tech.smartboot.feat.cloud.annotation.PreDestroy; import tech.smartboot.feat.cloud.annotation.RequestMapping; import tech.smartboot.feat.cloud.annotation.Value; import java.util.Properties; @Controller public class ConfigBootstrap { @Value("${nacos.serverAddr}") private String serverAddr; @Value("${nacos.config.dataId}") private String configDataId; @Value("${nacos.config.group}") private String configGroup; @Value("${nacos.config.timeout}") private int configTimeout; private volatile String config = ""; private ConfigService configService; private final AbstractListener configListener = new AbstractListener() { @Override public void receiveConfigInfo(String configInfo) { config = configInfo == null ? "" : configInfo; } }; @PostConstruct public void init() throws Exception { Properties properties = new Properties(); properties.put(PropertyKeyConst.SERVER_ADDR, serverAddr); try { configService = NacosFactory.createConfigService(properties); // 一次调用完成"拉取初始配置 + 注册监听器",避免拉取与注册之间的空窗期 String initialConfig = configService.getConfigAndSignListener( configDataId, configGroup, configTimeout, configListener ); config = initialConfig == null ? "" : initialConfig; } catch (Exception e) { destroy(); throw e; } } @PreDestroy public void destroy() throws Exception { if (configService == null) { return; } configService.removeListener(configDataId, configGroup, configListener); configService.shutDown(); configService = null; } @RequestMapping("/nacos") public String nacos() { return config; } public static void main(String[] args) { FeatCloud.cloudServer(opt -> opt.setPackages(ConfigBootstrap.class.getName())).listen(8081); } // setter public void setServerAddr(String serverAddr) { this.serverAddr = serverAddr; } public void setConfigDataId(String configDataId) { this.configDataId = configDataId; } public void setConfigGroup(String configGroup) { this.configGroup = configGroup; } public void setConfigTimeout(int configTimeout) { this.configTimeout = configTimeout; } } ``` 这段代码做了四件事: 1. **建立连接**:用 `nacos.serverAddr` 创建 `ConfigService` 2. **拉取 + 监听**:通过 `getConfigAndSignListener` 一次调用拿到初始值并注册监听器,省去先 `getConfig` 再 `addListener` 的两步操作,也避免两者之间的空窗期 3. **异常自愈**:初始化失败时先 `destroy()` 释放已建好的连接,再抛出异常让启动失败暴露问题 4. **优雅关闭**:停机时先 `removeListener` 再 `shutDown`,断开长连接并清理回调 几个值得注意的细节: * **`volatile`**:监听器回调由 Nacos 客户端的长轮询线程触发,`config` 字段被主请求线程读取,加 `volatile` 保证可见性。 * **`AbstractListener` 而非裸 `Listener`**:`Listener` 接口要求实现 `getExecutor()`,多数场景用不上自定义线程池;`AbstractListener` 给了默认实现,省一个空方法。 * **dataId / group / timeout 全部走配置**:不写死成常量,换环境只改 `feat.yml`,不用动代码。 启动后: 验证配置读取 ```shell curl http://localhost:8081/nacos ``` 然后去 Nacos 控制台修改 `config1` / `feat` 这个配置的内容,稍等片刻再请求一次,应该能看到新值。 提示 生产环境要开鉴权时,把 `namespace`、`username`、`password` 也声明到 `feat.yml`,在 `init()` 里一并塞进 `Properties`: ```java properties.put(PropertyKeyConst.NAMESPACE, namespace); if (username != null && !username.isEmpty()) { properties.put(PropertyKeyConst.USERNAME, username); properties.put(PropertyKeyConst.PASSWORD, password); } ``` 口令不要硬编码进仓库,用环境变量注入。 ## 服务注册 [Section titled “服务注册”](#服务注册) 实例启动后把自己登记到 Nacos,消费者才能发现并调用。`RegisterBootstrap` 的结构和配置中心几乎一致,只是把 `ConfigService` 换成了 `NamingService`,把”拉取 + 监听”换成了”注册”。 RegisterBootstrap.java ```java package tech.smartboot.feat.nacos; import com.alibaba.nacos.api.NacosFactory; import com.alibaba.nacos.api.PropertyKeyConst; import com.alibaba.nacos.api.naming.NamingService; import tech.smartboot.feat.cloud.FeatCloud; import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.PostConstruct; import tech.smartboot.feat.cloud.annotation.PreDestroy; import tech.smartboot.feat.cloud.annotation.RequestMapping; import tech.smartboot.feat.cloud.annotation.Value; import java.util.Properties; /** * 服务注册示例:将当前实例注册到 Nacos,供服务消费者发现。 */ @Controller public class RegisterBootstrap { @Value("${nacos.serverAddr}") private String serverAddr; @Value("${nacos.register.serviceName}") private String serviceName; @Value("${nacos.register.group}") private String namingGroup; @Value("${nacos.register.ip}") private String namingIp; @Value("${nacos.register.port}") private int namingPort; private NamingService namingService; @PostConstruct public void init() throws Exception { Properties properties = new Properties(); properties.put(PropertyKeyConst.SERVER_ADDR, serverAddr); namingService = NacosFactory.createNamingService(properties); namingService.registerInstance(serviceName, namingGroup, namingIp, namingPort); } @PreDestroy public void destroy() throws Exception { if (namingService != null) { namingService.shutDown(); namingService = null; } } @RequestMapping("/nacos/register") public String register() { return "registered: " + serviceName + " -> " + namingIp + ":" + namingPort; } public static void main(String[] args) { FeatCloud.cloudServer(opt -> opt.setPackages(RegisterBootstrap.class.getName())).listen(8082); } // setter public void setServerAddr(String serverAddr) { this.serverAddr = serverAddr; } public void setServiceName(String serviceName) { this.serviceName = serviceName; } public void setNamingGroup(String namingGroup) { this.namingGroup = namingGroup; } public void setNamingIp(String namingIp) { this.namingIp = namingIp; } public void setNamingPort(int namingPort) { this.namingPort = namingPort; } } ``` `registerInstance` 把 `ip:port` 绑定到 `serviceName` / `group` 上。停机时 `shutDown` 会自动反注册,Nacos 通过心跳超时也会摘除实例,两条保险一起兜底。 警告 `namingIp` 在容器化部署里不能写死成 `127.0.0.1`。容器外调用方访问不到回环地址,需要注入容器/宿主机的真实可达 IP,否则注册上去的实例谁也连不上。 ## 服务发现 [Section titled “服务发现”](#服务发现) 消费者订阅服务,实例上下线时 Nacos 主动推送变更列表。`DiscoverBootstrap` 的监听模式和配置中心如出一辙——`subscribe` 注册回调,`NamingEvent` 里带最新实例列表。 DiscoverBootstrap.java ```java package tech.smartboot.feat.nacos; import com.alibaba.nacos.api.NacosFactory; import com.alibaba.nacos.api.PropertyKeyConst; import com.alibaba.nacos.api.naming.NamingService; import com.alibaba.nacos.api.naming.listener.EventListener; import com.alibaba.nacos.api.naming.listener.NamingEvent; import com.alibaba.nacos.api.naming.pojo.Instance; import tech.smartboot.feat.cloud.FeatCloud; import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.PostConstruct; import tech.smartboot.feat.cloud.annotation.PreDestroy; import tech.smartboot.feat.cloud.annotation.RequestMapping; import tech.smartboot.feat.cloud.annotation.Value; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Properties; /** * 服务发现示例:订阅 Nacos 中的服务实例变更,并对外暴露当前可用实例列表。 */ @Controller public class DiscoverBootstrap { @Value("${nacos.serverAddr}") private String serverAddr; @Value("${nacos.discover.serviceName}") private String serviceName; @Value("${nacos.discover.group}") private String namingGroup; private volatile List instances = Collections.emptyList(); private NamingService namingService; private final EventListener namingListener = event -> { if (event instanceof NamingEvent) { updateInstances(((NamingEvent) event).getInstances()); } }; @PostConstruct public void init() throws Exception { Properties properties = new Properties(); properties.put(PropertyKeyConst.SERVER_ADDR, serverAddr); namingService = NacosFactory.createNamingService(properties); namingService.subscribe(serviceName, namingGroup, namingListener); } private void updateInstances(List changedInstances) { if (changedInstances == null || changedInstances.isEmpty()) { instances = Collections.emptyList(); } else { // 拷贝一份不可变快照,避免回调线程和请求线程并发读写同一个列表 instances = Collections.unmodifiableList(new ArrayList<>(changedInstances)); } } @PreDestroy public void destroy() throws Exception { if (namingService != null) { namingService.unsubscribe(serviceName, namingGroup, namingListener); namingService.shutDown(); namingService = null; } } @RequestMapping("/nacos/instances") public List instances() { return instances; } public static void main(String[] args) { FeatCloud.cloudServer(opt -> opt.setPackages(DiscoverBootstrap.class.getName())).listen(8083); } // setter public void setServerAddr(String serverAddr) { this.serverAddr = serverAddr; } public void setServiceName(String serviceName) { this.serviceName = serviceName; } public void setNamingGroup(String namingGroup) { this.namingGroup = namingGroup; } } ``` 把 `RegisterBootstrap`(8082)和 `DiscoverBootstrap`(8083)都跑起来,先请求发现接口确认能看到注册的实例,然后停掉注册端,过几秒再请求发现接口,实例会自动消失——这就是动态感知。 验证服务发现 ```shell curl http://localhost:8083/nacos/instances # 停掉 RegisterBootstrap 后等待 10-30 秒,实例列表自动清空 curl http://localhost:8083/nacos/instances ``` 注意 `updateInstances` 里做了一次拷贝并包成不可变列表。Nacos 的回调直接传的是内部列表引用,不拷贝的话请求线程遍历时正好被回调线程改写,会抛 `ConcurrentModificationException`。 ## 生产环境注意事项 [Section titled “生产环境注意事项”](#生产环境注意事项) 上线前记住几件事就够了: * **开鉴权**:Nacos 服务端开启鉴权,`namespace` / 用户名 / 口令通过环境变量注入,不要写死在仓库里 * **先校验再刷新**:监听到新配置后先检查格式和必填字段,坏配置不要覆盖内存值 * **拷贝回调数据**:`receiveConfigInfo` / `NamingEvent.getInstances()` 返回的是 Nacos 内部引用,回调线程和请求线程并发访问时务必拷贝快照 * **容器里别用回环 IP**:注册的 `ip` 必须是调用方可达地址,容器化部署要注入真实 IP * **优雅关闭**:`@PreDestroy` 里先 `removeListener` / `unsubscribe` 再 `shutDown`,避免停机时还在回调已释放的资源 这几条基本就覆盖了大多数生产问题。 ## 一条可验证的完整链路 [Section titled “一条可验证的完整链路”](#一条可验证的完整链路) 把三个类拼起来,你应该能验证下面三条链路: ```text 配置: Nacos 配置中心 -> ConfigBootstrap -> /nacos 注册: 本实例 -> RegisterBootstrap -> Nacos 实例列表 发现: Nacos 实例变更 -> DiscoverBootstrap -> /nacos/instances ``` 可以用这些命令验证: 配置中心验证 ```shell curl http://localhost:8081/nacos # 修改 Nacos 里的 config1 / feat,等待 1-3 秒后再请求 curl http://localhost:8081/nacos ``` 服务注册与发现验证 ```shell # 注册端运行时,发现端能看到实例 curl http://localhost:8083/nacos/instances # 停掉注册端,等待 10-30 秒,实例自动消失 curl http://localhost:8083/nacos/instances ``` 如果两次请求分别返回修改前后的配置内容、实例列表随上下线动态变化,说明 Nacos 已经完整接入了。 ## 边界与套路 [Section titled “边界与套路”](#边界与套路) Feat 不包一层 starter,也不重写配置中心和服务发现。Nacos 的 `ConfigService`、`NamingService`、`Listener`、长轮询全是官方原生的东西;Feat 只负责把它接进生命周期:编译期注入、启动时初始化、停机时释放。 三个 Bootstrap 放在一起看,套路完全一致:**依赖 + `@Value` + `@PostConstruct` + `@PreDestroy`,中间按官方文档来。** 多写几十行胶水代码,换来的是每一行都看得见——连接在哪建立、配置在哪回调、坏配置为什么被拦截、实例为什么被摘除。排查线上问题时,你能立刻定位是 Feat 的生命周期、Nacos 客户端,还是配置内容本身出了问题。 接 Sentinel、Redis、RocketMQ 时,套路一样。 # 启动配置与环境配置 > 区分 CloudOptions 与 feat.yml 的职责,理解启动选项、Profile 合并和多环境生成方式 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 Feat Cloud 里有三类配置容易混在一起: * `CloudOptions`:启动应用时传给 Feat Cloud 的运行时选项 * `feat.yml` / `feat-{env}.yml`:编译期生成代码时读取的配置输入 * `${...}` 占位符:写在 `feat.yml` 的值里,运行时从系统属性/环境变量取值 这章分两段展开: * 前半段讲 `CloudOptions`:启动入口里本次应用怎么跑 * 后半段讲 `feat.yml`:不同环境如何参与编译期生成,以及值如何在运行时注入 核心判断:**启动入口里本次怎么跑,看 `CloudOptions`;会影响生成代码的环境差异,看 `feat.yml`;要在运行时注入密钥等值,用 `${}`。** ## CloudOptions 启动配置 [Section titled “CloudOptions 启动配置”](#cloudoptions-启动配置) 这一部分只处理启动入口里的运行时选项:扫描哪些包、提前注册哪些对象、是否托管静态资源。 ### 先划清边界 [Section titled “先划清边界”](#先划清边界) `CloudOptions` 继承自 [`ServerOptions`](/feat/server/serveroptions/),所以端口、线程数、调试模式、HTTPS 这类基础能力仍然在 `ServerOptions` 里配置。 遇到这些问题时,通常看 `CloudOptions`: * 控制器已经写好,但你想限制哪些包会被接受 * 有对象必须在 Feat Cloud 容器启动前就准备好 * 应用需要顺手托管 `classpath` 或文件系统里的静态资源 * 本次启动要临时调整端口、debug 或 Router 遇到这些问题时,通常看 `feat.yml` 或 Profile: * 不同环境使用不同端口、数据库或 Redis * Session 存储方式在本地和生产不同 * MyBatis、Redis 等扩展需要参与编译期生成 * 修改后需要重新构建才生效 ### 启动时最常碰到的三件事 [Section titled “启动时最常碰到的三件事”](#启动时最常碰到的三件事) | 配置项 | 方法 | 典型用途 | | ------- | ------------------------------ | -------------------------- | | 包扫描 | `setPackages(String...)` | 控制 Controller / Bean 的扫描范围 | | 外部 Bean | `registerBean(String, Object)` | 把启动前已有对象注册进容器 | | 静态资源 | `setStaticLocations(String)` | 托管类路径或本地目录里的静态文件 | ### 收窄扫描边界 [Section titled “收窄扫描边界”](#收窄扫描边界) 如果你的启动类和控制器不在同一个合理的根包下,或者你只想限制扫描范围,可以显式设置包路径。 Bootstrap.java ```java import tech.smartboot.feat.cloud.FeatCloud; public class Bootstrap { public static void main(String[] args) { FeatCloud.cloudServer(options -> { options.setPackages( "com.example.controller", "com.example.service" ); }).listen(); } } ``` 什么时候应该用它: * 多模块项目里,控制器不在启动类同包或子包 * 你只想扫描少量包,提高启动阶段的可控性 * 你要避免把不相关 demo 或测试类扫进来 如果启动类本身已经位于合理根包下,很多情况下不需要写 `setPackages(...)`。 ### 把已有对象交给容器 [Section titled “把已有对象交给容器”](#把已有对象交给容器) 有些对象不是通过 Feat Cloud 自己创建的,比如数据源、第三方客户端、配置对象。这时可以在启动时手动注册。 ExternalBeanDemo.java ```java import tech.smartboot.feat.cloud.FeatCloud; public class ExternalBeanDemo { public static void main(String[] args) { FeatCloud.cloudServer(options -> { options.registerBean("dataSource", createDataSource()); options.registerBean("buildVersion", "1.0.0"); }).listen(); } private static Object createDataSource() { return new Object(); } } ``` `registerBean(...)` 的 key 不能重复。源码里如果发现同名 key,会直接抛 `FeatException`。 重复注册同名 Bean 会失败 ```java options.registerBean("dataSource", ds1); options.registerBean("dataSource", ds2); // 会失败 ``` ### 托管静态资源 [Section titled “托管静态资源”](#托管静态资源) Feat Cloud 默认静态资源目录是 `classpath:static`。如果你项目里有前端页面、上传后的公开文件或静态文档,可以直接改位置。 StaticLocationDemo.java ```java import tech.smartboot.feat.cloud.FeatCloud; public class StaticLocationDemo { public static void main(String[] args) { FeatCloud.cloudServer(options -> { options.setStaticLocations("classpath:public"); }).listen(); } } ``` 你可以使用两种路径形式: 静态资源配置 ```java options.setStaticLocations("classpath:static"); options.setStaticLocations("classpath:public"); options.setStaticLocations("/var/www/static"); options.setStaticLocations("./public"); ``` 当你配置静态资源目录后,Feat Cloud 会使用静态资源处理器提供目录首页、常见文件类型识别、`Last-Modified` 缓存和 404 返回。 警告 `setStaticLocations(...)` 不只是记住一个路径,它会顺手创建一个新的 `Router` 来挂载静态资源处理器。所以如果你还要自定义底层路由,请明确知道谁是最终生效的 Router。 ### 把启动策略写在入口 [Section titled “把启动策略写在入口”](#把启动策略写在入口) 实际项目里,这几个配置项经常一起出现: CloudOptionsDemo.java ```java import tech.smartboot.feat.cloud.FeatCloud; public class CloudOptionsDemo { public static void main(String[] args) { FeatCloud.cloudServer(options -> { options.port(8080); options.debug(true); options.setPackages("com.example.controller", "com.example.service"); options.registerBean("dataSource", createDataSource()); options.setStaticLocations("classpath:public"); }).listen(); } private static Object createDataSource() { return new Object(); } } ``` 这段代码表达的是本次应用如何启动。它不会替代 `feat.yml` 的环境配置,也不会改变已经生成好的代码结构。 ## Profile 环境配置 [Section titled “Profile 环境配置”](#profile-环境配置) 从这里开始,关注点从“本次启动怎么跑”切到“不同环境如何生成代码”。 ![Profile 环境配置与默认 feat.yml 深度合并的示意图](/feat/_astro/profiles_1.VVv7RMA8_2mcJ0m.webp) ### 文件命名 [Section titled “文件命名”](#文件命名) 业务应用很少只有一个运行环境。本地开发可能连本机数据库,测试环境要打开更多日志,生产环境则使用独立的数据库、Redis 和端口。 默认配置文件有两种写法: * `feat.yml` * `feat.yaml` 环境配置文件也有两种写法: * `feat-dev.yml` * `feat-dev.yaml` * `feat-prod.yml` * `feat-prod.yaml` 其中 `dev`、`prod` 就是环境名。环境名只能包含字母和数字,例如 `dev`、`test`、`prod`、`gray1`。 警告 不要使用 `feat-prod-us.yml` 这类带横线的环境名。当前环境名只允许字母和数字,横线、下划线和点号都不会被识别为合法环境名。 ### 最小可用结构 [Section titled “最小可用结构”](#最小可用结构) 一个典型项目可以这样组织资源文件: src/main/resources ```text src/main/resources/ ├── feat.yml ├── feat-dev.yml └── feat-prod.yml ``` `feat.yml` 放所有环境都共享的默认值: feat.yml ```yaml server: port: 8080 feat: datasource: url: jdbc:mysql://localhost:3306/app username: app password: app ``` `feat-dev.yml` 只写开发环境要覆盖的内容: feat-dev.yml ```yaml server: port: 8081 feat: datasource: url: jdbc:mysql://localhost:3306/app_dev username: dev password: dev ``` ### `server:` 块支持的配置项 [Section titled “server: 块支持的配置项”](#server-块支持的配置项) `feat.yml` 的 `server:` 块并不是所有 `ServerOptions` 选项都能配。AOT 只会绑定 `int` 和 `String` 类型的字段,因此 `boolean`、`long` 类字段写在这里不生效。 | 配置项 | 类型 | 说明 | | ------------------------------------ | ------ | ------------------------- | | `port` | int | 监听端口 | | `host` | String | 绑定地址 | | `threadNum` | int | 工作线程数 | | `headerLimiter` | int | 请求头数量上限 | | `readBufferSize` / `writeBufferSize` | int | 读写缓冲区大小 | | `autoSSL` | 特殊 | 设为 `true` 启用自动 HTTPS | | `session.store-type` | 特殊 | 设为 `redis` 切换为 Redis 会话存储 | | `session.timeout` | 特殊 | 会话超时时间 | 这些值同样支持 `${}` 占位符,例如 `port: ${port}`。 警告 `debug` 看起来像 `server:` 的一项,但它不是字段、而是方法,且 `boolean` 不在可绑定类型内,写在 `feat.yml` 里不会生效。调试模式只能通过启动入口的 `options.debug(true)` 打开。 ### 合并规则 [Section titled “合并规则”](#合并规则) Feat Cloud 会先读取默认配置,再读取环境配置。 对 `dev` 环境来说,顺序是: 1. 读取 `feat.yml` 2. 读取 `feat-dev.yml` 3. 将 `feat-dev.yml` 的内容覆盖到默认配置上 覆盖粒度遵循一个规则:**对象深度合并,普通值和数组整体替换。** feat.yml ```yaml server: port: 8080 host: 0.0.0.0 ``` feat-dev.yml ```yaml server: port: 8081 ``` 合并后,`server.port` 会被覆盖,而 `host` 会继续保留: dev 环境最终配置 ```yaml server: port: 8081 host: 0.0.0.0 ``` 如果环境文件里的值是数组,则以环境配置为准,不会和默认数组拼接。 ### 用 `${}` 引用系统属性和环境变量 [Section titled “用 ${} 引用系统属性和环境变量”](#用--引用系统属性和环境变量) `feat.yml` 里的值不一定要写死。只要把值写成 `${...}` 形式,Feat Cloud 就不会把它当成字面量,而是在运行时从系统属性和环境变量里解析。这正是把敏感信息(数据库口令、Nacos 凭据、第三方 Token)从代码仓库里挪出去的官方做法。 feat.yml ```yaml feat: datasource: url: jdbc:mysql://localhost:3306/app username: ${db.username} password: ${db.password} ``` `username`、`password` 整条值都是 `${...}`,运行时会解析。打包后,配置文件本身可以安全提交到仓库;真正的密钥在启动时注入: 运行时注入 ```bash java -Ddb.username=app -Ddb.password=secret -jar yourapp.jar # 或者用环境变量 DB_USERNAME=app DB_PASSWORD=secret java -jar yourapp.jar ``` 警告 `${}` 只在**整个值**恰好是 `${...}` 时才解析,不能嵌在更长字符串中间。`url: jdbc:mysql://${db.host}/app` 不会被替换,`db.host` 会原样留在地址里。如果整条地址也要注入,就把它作为一个完整变量:`url: ${db.url}`,再通过 `-Ddb.url=...` 或 `DB_URL` 提供。 #### 支持的写法 [Section titled “支持的写法”](#支持的写法) | 写法 | 含义 | | ----------------- | ------------------------------------ | | `${name}` | 运行时解析 `name`,找不到则字符串为 `null`、整数为 `0` | | `${name:default}` | 找不到时使用 `default` 作为字面默认值,按第一个 `:` 拆分 | `default` 是一段普通文本,不会被再次解析。`:` 只在第一个出现时作为分隔符,所以含 `:` 的默认值可以放心写,例如 `${db.url:jdbc:mysql://localhost:3306/app}` 的默认值就是整条地址。 警告 整数类型的占位符只用 `${name}` 这一种写法,不要带非零默认值。带非零默认值的整数占位,在系统属性/环境变量确实提供值时会解析失败。需要默认端口,直接把数字写进 `feat.yml` 更稳妥。 #### 解析来源与优先级 [Section titled “解析来源与优先级”](#解析来源与优先级) `feat.yml` 里的 `${name}` 不是引用另一条配置项,而是去外部取值,顺序是: 1. **JVM 系统属性**:名字原样使用,例如 `-Ddb.password=secret` 匹配 `${db.password}` 2. **操作系统环境变量**:名字按“点号转下划线、全大写”规则转换,例如 `${db.password}` 对应环境变量 `DB_PASSWORD` 3. 都没有时,使用 `:default`,没有默认值则字符串为 `null`、整数为 `0` 警告 这不是 Spring 那种“配置项之间互相引用”的占位符。`${db.password}` 不会去读 `feat.yml` 里的另一条 `db.password`,它只看系统属性和环境变量。如果你要的是“把 `feat.yml` 里某个值注入到某个 Bean 字段”,那是 `@Value("${...}")` 注解的职责,见 [Bean 注入](/feat/cloud/bean/),两者名字像但机制不同。 #### 哪些配置项支持 [Section titled “哪些配置项支持”](#哪些配置项支持) `${}` 只对 Feat Cloud 在编译期消费的配置值生效,典型包括: * `server.*` 启动项,例如 `server.port: ${port}` * `feat.datasource.*` 的 `url`、`username`、`password`、`driver` * `feat.redis.*` 的 `username`、`password`、`database` * 被 `@Value("${...}")` 注入的字段所引用的配置值 自己用代码直接从配置里读出来的值不会被替换。所以 `${}` 应该写在 Feat Cloud 已经接管的那几个配置块里,而不是任意 YAML 键上。 #### 和 Profile 的关系 [Section titled “和 Profile 的关系”](#和-profile-的关系) `${}` 解析发生在运行时,而 `feat.profiles.active` 选择环境也发生在运行时,但两者互不依赖:每个环境的 `feat-{env}.yml` 都可以各自使用 `${}`。常见做法是把结构性的环境差异(端口、地址)交给 Profile,把同一份配置里需要保密的值交给 `${}`,配合使用: feat-prod.yml ```yaml feat: datasource: url: jdbc:mysql://prod-db.example.com:3306/app username: ${db.username} password: ${db.password} ``` 这样生产环境的地址走 Profile 覆盖,凭据走运行时注入,仓库里既看不到口令,也不必为每个环境硬编码账号。Nacos 凭据的同一种用法可以参考 [Nacos 接入](/feat/cloud/nacos/)。 ### 编译期会生成什么 [Section titled “编译期会生成什么”](#编译期会生成什么) 如果项目里只有 `feat.yml`,Feat Cloud 只会生成默认环境的一套代码。 如果同时存在 `feat-dev.yml`、`feat-prod.yml`,Feat Cloud 会为每个环境生成一套完整代码: | 配置文件 | 生成类名示例 | | --------------- | --------------------- | | `feat.yml` | `FeatApplication` | | `feat-dev.yml` | `FeatApplicationDev` | | `feat-prod.yml` | `FeatApplicationProd` | Controller、Bean、Mapper 等生成类也会追加同样的环境后缀。这样做是为了让每个环境都有独立的配置结果,同时避免生成类同名冲突。 ### 运行时如何选择环境 [Section titled “运行时如何选择环境”](#运行时如何选择环境) 打包后的应用里可能已经包含多套环境代码。启动时通过 `feat.profiles.active` 选择其中一套: 使用 JVM 参数 ```bash java -Dfeat.profiles.active=dev -jar yourapp.jar ``` 也可以使用环境变量: 使用环境变量 ```bash FEAT_PROFILES_ACTIVE=prod java -jar yourapp.jar ``` 如果两者都没有设置,Feat Cloud 会使用默认环境,也就是 `feat.yml` 对应的那套生成结果。 ### 什么时候应该拆环境配置 [Section titled “什么时候应该拆环境配置”](#什么时候应该拆环境配置) 适合拆分的内容通常有这些: * 端口 * 数据库地址 * Redis 地址 * 会话存储方式 * 第三方服务地址 * 只在某个环境启用的 Feat Cloud 扩展配置 不建议把业务开关、灰度策略或用户级动态规则放进 `feat-{env}.yml`。这些配置更适合放进数据库、配置中心或业务自己的配置系统,因为它们通常需要运行时调整。 ### 重新构建才会生效 [Section titled “重新构建才会生效”](#重新构建才会生效) 多环境配置影响的是编译期生成结果。如果你修改了 `feat-dev.yml`,只重启已经打好的 jar 不会生效,需要重新执行构建。 重新生成代码并打包 ```bash mvn clean package ``` 这也是 Feat Cloud profile 与传统运行期 profile 最大的区别:它追求的是启动时少扫描、少反射、少动态决策,而不是运行时随时重载配置。 # 返回值与错误响应 > 理解 Feat Cloud 如何把 Controller 返回值写回客户端,并用 RestResult 统一业务响应 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 前面的章节已经解决了“请求如何进入方法”。这一章换到出口:Controller 方法返回什么,Feat Cloud 就如何把它写回客户端。 对业务接口来说,返回值不只是“能输出 JSON”。它还承担三个职责:让调用方知道请求是否成功,拿到业务数据,并在失败时得到可处理的错误信息。 ![Controller 响应被封装为 success、data、message 三要素的流水线示意图](/feat/_astro/response_1.BIezyJe6_15MMhC.webp) ## 基础返回值 [Section titled “基础返回值”](#基础返回值) 先看最直接的两类返回值:文本和对象。 ### 直接返回文本 [Section titled “直接返回文本”](#直接返回文本) 最短的接口可以直接返回 `String`。这种方式适合健康检查、简单文本响应或非常小的演示接口。 HealthController.java ```java import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.RequestMapping; import tech.smartboot.feat.cloud.annotation.RequestMethod; @Controller("health") public class HealthController { @RequestMapping(value = "/ping", method = RequestMethod.GET) public String ping() { return "pong"; } } ``` 验证: curl 验证 ```shell curl http://localhost:8080/health/ping ``` 返回: 响应结果 ```text pong ``` ### 返回对象或集合 [Section titled “返回对象或集合”](#返回对象或集合) 如果返回的是自定义对象、集合或 `Map`,Feat Cloud 会把它序列化为 JSON。 UserController.java ```java import java.util.HashMap; import java.util.Map; @RequestMapping(value = "/info", method = RequestMethod.GET) public Map info() { Map data = new HashMap<>(); data.put("framework", "Feat Cloud"); data.put("type", "aot-web"); return data; } ``` 返回结果类似: 响应结果 ```json { "framework": "Feat Cloud", "type": "aot-web" } ``` 这种写法适合很小的内部接口。只要接口会被前端、移动端或其他服务长期依赖,就建议使用统一响应对象。 ## 统一业务响应 [Section titled “统一业务响应”](#统一业务响应) 当接口会被前端、移动端或其他服务长期依赖时,建议使用稳定响应结构。 ![RestResult\ 统一响应结构被网页、小程序、微服务多端消费的示意图](/feat/_astro/response_2.NK3rE-iW_Z18vzOF.webp) ### 用 RestResult 统一业务响应 [Section titled “用 RestResult 统一业务响应”](#用-restresult-统一业务响应) `RestResult` 是 Feat Cloud 提供的通用响应封装。它把一次业务调用拆成四个字段: | 字段 | 含义 | | --------- | -------- | | `success` | 业务是否成功 | | `code` | 业务响应码 | | `message` | 失败说明 | | `data` | 成功时返回的数据 | UserController.java ```java import tech.smartboot.feat.cloud.RestResult; import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.PathParam; import tech.smartboot.feat.cloud.annotation.RequestMapping; @Controller("users") public class UserController { @RequestMapping("/{username}") public RestResult getUser(@PathParam("username") String username) { User user = findUser(username); if (user == null) { return RestResult.fail("User not found"); } return RestResult.ok(user); } } ``` 示例里的 `User` 和 `findUser(...)` 可以替换成你自己的领域对象和查询逻辑。这里重点看响应结构。 * 成功响应 响应结果 ```json { "success": true, "code": 200, "data": { "username": "feat", "role": "admin" } } ``` * 失败响应 响应结果 ```json { "success": false, "code": 500, "message": "User not found" } ``` `RestResult.fail(...)` 表达的是业务失败,不等同于 HTTP 传输失败。也就是说,客户端仍然拿到一个结构稳定的 JSON 响应,可以按 `success` 和 `code` 做统一处理。 ### 什么时候不用 RestResult [Section titled “什么时候不用 RestResult”](#什么时候不用-restresult) 不是所有接口都必须包一层 `RestResult`。 * 健康检查、纯文本页面、简单调试接口,可以直接返回 `String` * 文件下载、图片、静态资源这类响应,更适合交给底层响应能力或静态资源处理 * 对外稳定业务 API,建议使用 `RestResult` 关键不是形式统一,而是调用方能不能稳定理解你的响应。 ## 编译期序列化如何工作 [Section titled “编译期序列化如何工作”](#编译期序列化如何工作) ![编译期展开序列化代码、替代运行时反射的示意图](/feat/_astro/response_3.BAerttmc_X5dUy.webp) 把它先理解成一件很具体的事:**你写业务返回值,Feat Cloud 在编译期替你补上“怎么写到响应流里”这段样板代码**。 如果只说“编译期生成代码”,读者很容易觉得抽象。更直观的方式,是直接看它生成出来大概长什么样。 以一个返回 `String` 的接口为例,源码里你只写: ```java public String helloWorld() { return "hello world"; } ``` 但编译后,框架实际做的事情大致类似下面这样: ```java String rst = bean.helloWorld(); byte[] bytes = rst.getBytes("UTF-8"); if (bytes.length > 256) { bytes = tech.smartboot.feat.core.common.FeatUtils.gzip(bytes); ctx.Response.setHeader("Content-Encoding", "gzip"); } ctx.Response.setContentLength(bytes.length); ctx.Response.write(bytes); ``` 换句话说,编译期序列化不是“神秘地帮你返回 JSON”,而是把“把返回值转成字节并写出去”这件事提前展开成了普通 Java 代码。 注解处理器会分析 Controller 方法的返回类型,并为常见类型生成直接写出逻辑。 | 类型 | 序列化方式 | | ------------------ | --------------- | | `String`、基本类型、包装类型 | 直接写输出流 | | `Date`、`Timestamp` | 直接格式化后写输出流 | | 自定义 POJO | 按 getter 生成访问代码 | | `List`、`Map` | 生成遍历代码 | | 数组、`Object`、嵌套过深对象 | 回退到 FastJSON2 | 如果返回的是 `RestResult>>` 这种更复杂的结构,生成出来的代码会更像“按字段逐个写出”: ```java RestResult>> rst = bean.hello1ss2(); os.write(b_success_true); os.write(b_code); writeInt(os, rst.getCode()); os.write(b_message); writeJsonValue(os, rst.getMessage()); os.write(b_data); ``` 警告 “嵌套过深”按**已经展开的 DTO 层数**算,不是按字段数量。实际踩到的分界线是三层: * `RestResult → LoginResponse → CurrentUserResponse → List`:元素回退到 FastJSON2; * `RestResult → CurrentUserResponse → List`:同一批元素逐字段写出。 回退不只是“慢一点”,两种路径的**字段集合可能不同**——FastJSON2 默认省略值为 `null` 的字段,而生成的代码会老老实实写出 `"permission": null`。结果是同一个 DTO 在两个接口上的 JSON 形状不一致,客户端往往只对着其中一个写解析。 两条建议:**DTO 嵌套不要超过两层**;需要表达“没有值”时用空串或空集合,别用 `null`。 这也是 Feat Cloud 性能模型的一部分:框架尽量在编译期确定响应结构,运行时少做反射和动态判断。对读者来说,真正能感受到的不是术语,而是“我写的一个返回值,最后会被展开成一段具体的写出逻辑”。 警告 `AsyncResponse` 的完成结果发生在运行时,编译期无法提前知道具体对象结构,因此会走运行时 JSON 序列化。异步响应的写法放在 [Controller 与请求处理](/feat/cloud/controller/) 的运行时能力部分。 # 编译期工作模型 > 理解 Feat Cloud 为什么把注解、配置和装配提前生成成 CloudService,以及修改后为什么需要重新构建 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 快速开始里,一个 `@Controller` 方法变成了可访问的 HTTP 路由。 这背后最重要的不是某个 API,而是 Feat Cloud 的工作模型:开发时用注解表达意图,编译期生成普通 Java 代码,运行时加载这些生成结果。 理解这一点之后,后面的 Controller、Bean、Profile、MyBatis、MCP 和部署问题都会更容易判断。 ![注解代码经编译期处理生成 CloudService 并注册到 Router 的示意图](/feat/_astro/runtime_1.DxUT0Oyp_ZVN0U5.webp) ## 从注解到 `CloudService` [Section titled “从注解到 CloudService”](#从注解到-cloudservice) 当项目执行 `mvn compile` 时,`feat-cloud-starter` 中的注解处理器会读取 `@Controller`、`@Bean`、`@Autowired`、`@RequestMapping` 等注解,并生成 `CloudService` 实现类。 这些生成类承担几类工作: * 创建 Controller 和 Bean 实例 * 为字段注入依赖 * 调用初始化方法 * 把 URL 路由注册到 `Router` * 在应用关闭时释放资源 所以运行时并不需要重新理解一遍你的注解。\ 它只要通过 `ServiceLoader` 找到这些生成类,然后按固定生命周期调用即可。 ## 启动生命周期 [Section titled “启动生命周期”](#启动生命周期) Feat Cloud 启动时,`ApplicationContext` 会按顺序调用所有 `CloudService`: 1. `loadBean(...)`:创建 Bean,并注册到应用上下文 2. `loadMethodBean(...)`:处理通过方法声明的 Bean 3. `autowired(...)`:完成依赖注入 4. `postConstruct(...)`:执行初始化逻辑 5. `router(...)`:注册 HTTP 路由 6. `destroy()`:应用关闭时释放资源 ![](/feat/_astro/runtime_2.CrIlgO7X_Z1QymDA.webp) 这个顺序解释了很多行为:为什么 Bean 要先创建再注入,为什么路由要等初始化完成后才注册,为什么启动日志里能直接打印出最终路由。 ## 这对开发方式意味着什么 [Section titled “这对开发方式意味着什么”](#这对开发方式意味着什么) 编译期生成带来的好处很直接: * 启动时少做扫描和反射 * 路由和装配逻辑更接近普通方法调用 * 更容易进入 Native Image 路线 * 配置错误更容易在构建阶段暴露 它也带来一个边界:当你修改 Controller、Bean 或 `feat.yml` 这类编译期输入后,需要重新编译。\ 如果只重启已经打好的 jar,生成代码不会自动变化。 ## 修改后为什么要重新构建 [Section titled “修改后为什么要重新构建”](#修改后为什么要重新构建) Controller、Bean、`feat.yml` 和 MCP 声明都是编译期输入。你改了这些内容之后,旧 jar 里的生成代码不会自动变化。 所以本地开发时,IDE 要真正触发 Maven 编译;部署前,也要重新执行构建命令: 重新生成并打包 ```bash mvn clean package ``` 如果你遇到“代码明明改了,路由或配置却没变化”,第一反应应该是检查生成代码有没有更新,而不是只重启应用。 # Session 与请求状态 > 在 Feat Cloud 里使用 Session 保存少量请求状态,并判断什么时候应该换成其他状态模型 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 HTTP 请求天然是无状态的,但很多业务并不是:登录后的用户标识、一次向导流程里的临时选择、后台系统里的少量上下文,都需要在多次请求之间保留。 Feat Cloud 的 Session 解决的就是这种“小而短”的请求状态。它不是默认认证架构,也不应该替代数据库、缓存或权限系统;它更像一层贴近 HTTP 的会话能力,让 Controller 可以在需要时读写当前访问者的状态。 ## 基本用法 [Section titled “基本用法”](#基本用法) 先看 Session 如何进入 Controller,以及什么内容适合放进会话里。 ### 在 Controller 中读写会话 [Section titled “在 Controller 中读写会话”](#在-controller-中读写会话) Controller 方法声明 `Session` 参数后,Feat Cloud 会把当前请求对应的会话对象传入方法。下面的接口用它记录访问次数: UserController.java ```java import tech.smartboot.feat.cloud.annotation.Controller; import tech.smartboot.feat.cloud.annotation.RequestMapping; import tech.smartboot.feat.cloud.annotation.RequestMethod; import tech.smartboot.feat.core.server.Session; @Controller public class UserController { @RequestMapping(value = "/visit", method = RequestMethod.GET) public String visitCount(Session session) { String count = session.get("visitCount"); int visits = (count == null ? 0 : Integer.parseInt(count)) + 1; session.put("visitCount", String.valueOf(visits)); return "Visit count: " + visits; } } ``` 第一次访问时,Feat 会创建会话并通过 Cookie 把会话 ID 返回给客户端。之后客户端带着同一个 Cookie 再请求,`session.get("visitCount")` 就能读到前一次写入的值。 ### 只放少量状态 [Section titled “只放少量状态”](#只放少量状态) Session 适合保存能用字符串表达、体积很小、过期后可重新建立的状态,例如: * 用户 ID * 用户名 * 简单角色信息 * 访问计数或短期状态 不要把下面这些内容放进 Session: * 大对象 * 敏感明文数据 * 需要长期可靠持久化的业务信息 * 复杂权限模型或用户完整资料 Session 的生命周期跟访问者和过期时间相关,而不是跟业务数据的生命周期相关。订单、账户、审计记录这类信息应该进入数据库;访问者当前是谁、短时间内走到哪一步,才是 Session 的职责范围。 ## 存储与边界 [Section titled “存储与边界”](#存储与边界) Session 的风险通常不在 API 本身,而在存储位置、数据大小和认证模型边界。 ### Session 存在哪里 [Section titled “Session 存在哪里”](#session-存在哪里) Feat Cloud 会在编译期根据 `feat.yml` 生成 SessionManager 的初始化代码。默认情况下使用本地内存 Session;当配置为 Redis 存储时,会生成 `ClusterSessionManager`,让多实例共享同一份会话状态。 本地 Session 适合开发环境、单机部署和简单应用。它最快,也最少依赖,但应用重启后会话会丢失,多实例之间也不能共享。 Redis Session 适合多实例部署、容器平台和需要跨节点保持登录态的场景。它引入了 Redis 依赖,但能让不同应用实例读写同一份会话。 feat.yml ```yaml server: session: timeout: 1800 ``` 上面只设置过期时间,仍然使用本地 Session。单位为秒。 如果要使用 Redis Session,需要同时声明 Session 存储类型和 Redis 连接信息: feat-prod.yml ```yaml server: session: store-type: redis timeout: 1800 feat: redis: address: redis.example.com:6379 ``` `feat.redis.address` 写成 `host:port`;省略端口时默认 `6379`。也接受 `redis://` / `rediss://` 前缀,其中 `rediss://` 会启用 TLS 并把默认端口换成 `6380`。 除 `address` 外还有三个可选键: | 键 | 类型 | 说明 | | --------------------- | ------ | ---------------------------- | | `feat.redis.username` | String | ACL 用户名,旧版 Redis 不用填 | | `feat.redis.password` | String | 口令,支持 `${db.password}` 这类占位符 | | `feat.redis.database` | int | 库号,默认 0 | 危险 `store-type: redis` 必须和 `feat.redis` 块一起出现。这两处配置是分别读取的:`store-type` 决定生成 `applicationContext.getBean("redisun")`,而 `redisun` 这个 Bean 只有在 `feat.redis` 存在时才会被注册。只写 `store-type` 能编译通过,启动时取不到 Bean。 反过来只配 `feat.redis` 而没有 `store-type: redis`,会话仍然走内存,只是多初始化了一个 Redis 客户端——这个组合不会报错,但达不到共享会话的目的,容易误判。 警告 `address` 是在编译期直接写进生成代码的字面量,**不支持 `${}` 占位符**;`username`、`password`、`database` 支持。所以同一份 `feat.yml` 想在多套环境复用 Redis 地址时,用 Profile 拆文件(`feat-prod.yml`),不要指望 `${redis.address}`。 这类配置属于编译期输入。修改 `feat.yml` 或 `feat-prod.yml` 后,需要重新构建应用,生成代码才会更新。多环境项目里,本地环境可以继续用内存 Session,生产环境再通过 `feat-prod.yml` 覆盖为 Redis Session。 ### 会话 API 的边界 [Section titled “会话 API 的边界”](#会话-api-的边界) Session 常用操作 ```java session.put("username", "john"); String username = session.get("username"); session.setTimeout(1800); String sessionId = session.getSessionId(); session.invalidate(); ``` `put` 和 `get` 读写字符串;`setTimeout` 设置当前会话的过期时间;`invalidate` 清理会话并移除客户端 Cookie。 警告 Redis Session 读取和写入时会刷新过期时间。不要把高频、大体积的数据放进 Session,否则很容易把会话存储变成隐蔽的性能热点。 ### 什么时候该换一种状态模型 [Section titled “什么时候该换一种状态模型”](#什么时候该换一种状态模型) 如果你的应用已经开始走向下面这些方向: * 前后端分离 * Token 认证 * 多端登录 * 更复杂的权限和身份体系 那就不要把 Session 当成认证体系的唯一中心。更稳的做法通常是:身份认证交给 Token 或专门的认证服务,用户资料和权限放在数据库或缓存里,Session 只保留少量与当前 HTTP 会话有关的临时状态。 Session 最适合让简单应用保持简单。一旦部署拓扑、登录模型和权限边界开始复杂,优先重新审视状态划分,而不是继续往 Session 里塞更多内容。 # 故障排查 > 用现象定位 Feat Cloud 常见问题:APT、路由、配置、打包、Profile、Session 与 Native Image 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 Feat Cloud 的很多行为发生在编译期。排查问题时,不要只盯着“应用有没有重启”,还要确认“生成代码有没有更新、打包有没有保留、运行时有没有选中正确环境”。 这一页按现象组织。遇到问题时,先找最接近的症状,再顺着检查项缩小范围。 ## 开发期问题 [Section titled “开发期问题”](#开发期问题) 开发期问题通常和注解处理、生成代码、参数绑定有关。先确认 IDE 或 Maven 是否真的重新生成了代码。 ### Controller 写了,访问却是 404 [Section titled “Controller 写了,访问却是 404”](#controller-写了访问却是-404) 最常见原因是路由没有被生成或没有被加载。 先看启动日志里有没有类似路由输出: 启动日志 ```text Feat Router: |-> /hello ==> Bootstrap@helloWorld http://0.0.0.0:8080/ ``` 如果没有对应路由,按顺序检查: * Controller 类是否加了 `@Controller` * 方法是否加了 `@RequestMapping` * 项目是否引入了 `feat-cloud-starter` * IDE 是否真的执行了 Maven 编译 * `CloudOptions.setPackages(...)` 是否把 Controller 所在包排除掉了 * 修改 Controller 后是否重新执行了 `mvn compile` 或 `mvn package` 如果本地运行正常,打成 Fat Jar 后变成 404,优先看部署章节里的 `AppendingTransformer`。服务发现文件被覆盖后,应用可以启动,但生成的 `CloudService` 不会被加载。 ### 改了 feat.yml,但运行结果没变化 [Section titled “改了 feat.yml,但运行结果没变化”](#改了-featyml但运行结果没变化) `feat.yml`、`feat-dev.yml`、`feat-prod.yml` 是编译期输入。只重启已经打好的 jar,不会重新生成代码。 重新生成代码 ```bash mvn clean package ``` 如果你只是在 IDE 里点运行,也要确认 IDE 的构建动作会触发 Maven 注解处理器。否则代码变了,生成类仍然是旧的。 ### Profile 没有按预期生效 [Section titled “Profile 没有按预期生效”](#profile-没有按预期生效) 运行时通过 `feat.profiles.active` 选择环境(Profile 合并规则的完整说明见[启动配置与环境配置](/feat/cloud/options/)): JVM 参数 ```bash java -Dfeat.profiles.active=dev -jar yourapp.jar ``` 也可以使用环境变量: 环境变量 ```bash FEAT_PROFILES_ACTIVE=prod java -jar yourapp.jar ``` 检查顺序: * 环境文件名是否是 `feat-dev.yml`、`feat-prod.yml` 这种格式 * 环境名是否只包含字母和数字 * 构建产物里是否已经包含该环境对应的生成代码 * 启动命令里是否拼错了 `feat.profiles.active` * JVM 参数是否放在 `-jar` 之前 如果你修改了环境文件,仍然需要重新构建。 ### @Autowired 字段是 null [Section titled “@Autowired 字段是 null”](#autowired-字段是-null) 这通常说明目标 Bean 没有进入应用上下文,或者注入发生前就提前使用了字段。 检查项: * 被注入的类是否加了 `@Bean` * 类是否在包扫描范围内 * 是否存在同名 Bean 冲突或加载顺序问题 * 是否在构造方法、字段初始化表达式里使用了 `@Autowired` 字段 * 是否需要用 `@Bean(order = ...)` 调整少量启动顺序 业务代码里使用依赖对象,放在请求方法或 `@PostConstruct` 之后更稳。 ### JSON 请求体解析失败 [Section titled “JSON 请求体解析失败”](#json-请求体解析失败) 如果 Controller 方法接收自定义 POJO,调用方需要发送 JSON 请求体,并带上正确的 `Content-Type`。 正确示例 ```shell curl -X POST http://localhost:8080/users \ -H 'Content-Type: application/json' \ -d '{"username":"feat","role":"admin"}' ``` 常见问题: * 请求头没有设置 `Content-Type: application/json` * JSON 字段名和 Java 属性名不一致 * POJO 缺少 setter,导致字段无法写入 * 请求体不是对象,而是数组或纯字符串 如果只是接收 URL 查询参数,继续使用 `@Param` 即可,不需要 JSON 请求体。 ### 查询参数缺失后报 NullPointerException [Section titled “查询参数缺失后报 NullPointerException”](#查询参数缺失后报-nullpointerexception) `@Param` 绑定到基本类型时,如果参数缺失,内部值可能是 `null`,拆箱到 `int`、`long`、`boolean` 时会触发异常。 更稳的写法 ```java public String search(@Param("page") Integer page) { int currentPage = page == null ? 1 : page; return "page: " + currentPage; } ``` 可能缺失的参数优先使用包装类型,再在业务代码里给默认值。 ## 交付期问题 [Section titled “交付期问题”](#交付期问题) 交付期问题通常发生在 Fat Jar、容器、Redis Session 或 Native Image 上。它们的共同点是:本地 IDE 运行正常,但换成构建产物后行为不同。 ### Fat Jar 启动后路由全部丢失 [Section titled “Fat Jar 启动后路由全部丢失”](#fat-jar-启动后路由全部丢失) Feat Cloud 依赖 `ServiceLoader` 加载生成的 `CloudService`。Fat Jar 打包时必须合并下面这个文件: 服务发现文件 ```text META-INF/services/tech.smartboot.feat.cloud.CloudService ``` 使用 `maven-shade-plugin` 时,需要配置 `AppendingTransformer`(完整配置见[部署与交付](/feat/cloud/deployment/))。如果这个文件被覆盖,应用仍可能启动,但 Controller 路由不会注册。 可以用下面的命令检查 jar 内文件: 检查服务发现文件 ```bash jar tf target/yourapp-1.0.jar | grep 'META-INF/services/tech.smartboot.feat.cloud.CloudService' ``` ### Redis Session 没有共享 [Section titled “Redis Session 没有共享”](#redis-session-没有共享) 先确认当前环境真的启用了 Redis Session(配置项含义见 [Session 与请求状态](/feat/cloud/session/)): feat-prod.yml ```yaml server: session: store-type: redis timeout: 1800 feat: redis: address: redis.example.com:6379 ``` `feat.redis` 块不能省(`store-type` 只负责生成 `getBean("redisun")`,Bean 本身要靠这个块注册), 地址写在 `address` 里,`host` / `port` 两个键不存在,写了也不会生效。 再检查: * 启动时是否选择了 `prod` 环境 * 修改配置后是否重新构建 * 多个实例是否连接同一个 Redis * 客户端请求是否带上同一个 Session Cookie * 是否把过大的数据写进 Session,导致 Redis 压力异常 本地内存 Session 不能跨实例共享,应用重启后也会丢失。 ### Native Image 下行为和 JRE 不一致 [Section titled “Native Image 下行为和 JRE 不一致”](#native-image-下行为和-jre-不一致) 先用普通 JRE 路线确认业务本身是稳定的: 先验证 JRE 版本 ```bash java -jar target/yourapp-1.0.jar curl http://localhost:8080/hello ``` 只有 JRE 正常、Native 异常时,再重点排查 Native Image: * 资源文件是否被打进镜像 * 第三方库是否依赖反射、动态代理或 JNI * MyBatis Mapper、SQL 初始化脚本是否可读取 * MCP、Session、Profile 是否跑过同一组接口验证 Native Image 是优化路线,不应该用来替代普通 jar 的基础验证。 # 生态集成:XXL-Job > 把 XXL-Job 执行器接入 Feat Cloud,完成分布式任务调度的注册、执行与优雅关闭 注意 Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。基础用法见 [Feat Cloud 导览](/feat/cloud/)。 单体应用里,一个 `@Scheduled` 就能跑定时任务。到了分布式环境,同一任务可能在多个实例上同时触发,失败重试、分片执行、动态启停都得靠调度中心统一编排。XXL-Job 把这件事拆成了两个角色:Admin 负责任务编排和触发,Executor 负责真正执行业务逻辑。 Feat Cloud 没有内置 XXL-Job starter,本文直接用官方 `xxl-job-core`,在编译期把配置注入 Bean、在启动期把执行器跑起来、在停机期把它干净地关掉。多写几十行胶水代码,换来的是每一行都看得见——执行器在哪启动、任务怎么注册、停机时怎么反注册。 ![](/feat/_astro/xxljob_1.CG5Yext1_BbX0.webp) ## 添加依赖 [Section titled “添加依赖”](#添加依赖) 在 `pom.xml` 中加入 `xxl-job-core`: pom.xml ```xml com.xuxueli xxl-job-core 2.4.0 ``` `xxl-job-core` 管运行期,`feat-cloud-starter` 管编译期生成代码,两者互不替代。 ## 配置执行器参数 [Section titled “配置执行器参数”](#配置执行器参数) 在 `feat.yml` 里声明调度中心地址和执行器参数: feat.yml ```yaml xxl: job: admin: addresses: http://127.0.0.1:8080 timeout: 3 executor: enabled: true appname: feat-executor accessToken: default_token ip: port: 9999 address: http://host.docker.internal:9999 logpath: ./xxl-job/jobhandler logretentiondays: 30 ``` 这些值会在编译期通过 `@Value` 注入到字段里。`@Value` 依赖字段对应的 setter 方法,字段叫 `adminAddresses`,就得有 `setAdminAddresses(...)`。嵌套配置用 `xxl.job.admin.addresses` 这种点号路径,自动映射到 `xxl` 下的 `job` 节点。具体规则可以回看 [Bean 与依赖注入](/feat/cloud/bean/)。 ![](/feat/_astro/xxljob_2.Fju9kzAL_oAQy.webp) 警告 生产环境的 `accessToken` 不要硬编码进配置文件提交到仓库。可以用 `${xxl.job.accessToken}` 占位符,运行时通过 JVM 参数 `-Dxxl.job.accessToken=xxx` 或环境变量 `XXL_JOB_ACCESSTOKEN=xxx` 注入。 ## 写代码 [Section titled “写代码”](#写代码) 整个集成只需要四个类:一个配置对象收拢 YAML 参数,两个任务处理器执行业务逻辑,一个启动类把它们串起来。 ### 配置对象 [Section titled “配置对象”](#配置对象) `XxlJobConfig` 把 `feat.yml` 里的值收拢到一个 Bean 里,启动类只需要注入这一个对象,不用面对十几个散落的 `@Value` 字段。 XxlJobConfig.java ```java package tech.smartboot.feat.xxljob; import tech.smartboot.feat.cloud.annotation.Bean; import tech.smartboot.feat.cloud.annotation.Value; @Bean public class XxlJobConfig { @Value("${xxl.job.admin.addresses}") private String adminAddresses; @Value("${xxl.job.admin.timeout}") private int timeout; @Value("${xxl.job.executor.enabled}") private boolean enabled; @Value("${xxl.job.executor.appname}") private String appname; @Value("${xxl.job.executor.accessToken}") private String accessToken; @Value("${xxl.job.executor.ip}") private String ip; @Value("${xxl.job.executor.port}") private int port; @Value("${xxl.job.executor.address}") private String address; @Value("${xxl.job.executor.logpath}") private String logPath; @Value("${xxl.job.executor.logretentiondays}") private int logRetentionDays; // getter & setter } ``` ### 任务处理器 [Section titled “任务处理器”](#任务处理器) 任务处理器继承 `IJobHandler`,重写 `execute()`。Feat Cloud 用 `@Bean` 把它们注册进容器,启动时再手动挂到执行器上。 FirstJobHandler.java ```java package tech.smartboot.feat.xxljob; import com.xxl.job.core.handler.IJobHandler; import tech.smartboot.feat.cloud.annotation.Bean; @Bean public class FirstJobHandler extends IJobHandler { @Override public void execute() throws Exception { System.out.println("execute first job"); } } ``` SecondJobHandler.java ```java package tech.smartboot.feat.xxljob; import com.xxl.job.core.handler.IJobHandler; import tech.smartboot.feat.cloud.annotation.Bean; @Bean public class SecondJobHandler extends IJobHandler { @Override public void execute() throws Exception { System.out.println("execute second job"); } } ``` 提示 如果任务需要接收参数,在 `execute()` 里通过 `XxlJobHelper.getJobParam()` 获取 Admin 下发的任务参数。分片任务则通过 `XxlJobHelper.getShardIndex()` 和 `XxlJobHelper.getShardTotal()` 获取分片信息。这些 API 来自 `xxl-job-core`,和 Feat Cloud 无关。 ### 启动类 [Section titled “启动类”](#启动类) ![](/feat/_astro/xxljob_3.BcnFPhAl_Z19P5CT.webp) `Bootstrap` 负责执行器的完整生命周期:启动时创建 `XxlJobSimpleExecutor`、设置参数、启动服务、注册任务处理器;停机时销毁执行器,断开与 Admin 的心跳。 Bootstrap.java ```java package tech.smartboot.feat.xxljob; import com.xxl.job.core.executor.XxlJobExecutor; import com.xxl.job.core.executor.impl.XxlJobSimpleExecutor; import com.xxl.job.core.handler.IJobHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import tech.smartboot.feat.cloud.FeatCloud; import tech.smartboot.feat.cloud.annotation.Autowired; import tech.smartboot.feat.cloud.annotation.Bean; import tech.smartboot.feat.cloud.annotation.PostConstruct; import tech.smartboot.feat.cloud.annotation.PreDestroy; @Bean public class Bootstrap { private static final Logger logger = LoggerFactory.getLogger(XxlJobConfig.class); @Autowired private XxlJobConfig xxlJobConfig; @Autowired private IJobHandler firstJobHandler; @Autowired private IJobHandler secondJobHandler; private XxlJobSimpleExecutor xxlJobSpringExecutor; @PostConstruct public void init() { logger.info(">>>>>>>>>>> xxl-job config init."); xxlJobSpringExecutor = new XxlJobSimpleExecutor(); xxlJobSpringExecutor.setAdminAddresses(xxlJobConfig.getAdminAddresses()); xxlJobSpringExecutor.setTimeout(xxlJobConfig.getTimeout()); xxlJobSpringExecutor.setEnabled(xxlJobConfig.isEnabled()); xxlJobSpringExecutor.setAppname(xxlJobConfig.getAppname()); xxlJobSpringExecutor.setAccessToken(xxlJobConfig.getAccessToken()); xxlJobSpringExecutor.setIp(xxlJobConfig.getIp()); xxlJobSpringExecutor.setPort(xxlJobConfig.getPort()); xxlJobSpringExecutor.setAddress(xxlJobConfig.getAddress()); xxlJobSpringExecutor.setLogPath(xxlJobConfig.getLogPath()); xxlJobSpringExecutor.setLogRetentionDays(xxlJobConfig.getLogRetentionDays()); xxlJobSpringExecutor.start(); XxlJobExecutor.registryJobHandler("feat-first-handler", firstJobHandler); XxlJobExecutor.registryJobHandler("feat-second-handler", secondJobHandler); } @PreDestroy public void destroy() { if (xxlJobSpringExecutor != null) { xxlJobSpringExecutor.destroy(); } } // setter public static void main(String[] args) { FeatCloud.cloudServer().listen(8888); } } ``` 这里用了 `XxlJobSimpleExecutor` 而不是 `XxlJobSpringExecutor`,因为后者依赖 Spring 的 `ApplicationContext` 做自动扫描,Feat Cloud 没有 Spring 容器,只能手动设置参数并启动。 `registryJobHandler` 里硬编码的字符串 `"feat-first-handler"` 和 `"feat-second-handler"` 是 Admin 配置任务时填写的 `JobHandler` 名称,两边必须一致,否则 Admin 触发任务时执行器找不到对应的处理器。 ## 跑起来验证 [Section titled “跑起来验证”](#跑起来验证) ![](/feat/_astro/xxljob_4.UrDt2Cfv_1Kwzy8.webp) 执行器跑起来之前,需要先有一个 Admin。demo 目录下提供了完整的 Docker Compose 配置,可以直接启动: 启动调度中心 ```bash cd demo/xxljob docker compose up -d ``` Admin 启动后访问 `http://localhost:8080`,默认账号密码是 `admin / 123456`。然后启动 Feat Cloud 执行器: 启动执行器 ```bash mvn clean compile exec:java -Dexec.mainClass="tech.smartboot.feat.xxljob.Bootstrap" ``` 进入 Admin 的「任务管理」,新增一条任务: * 执行器选 `feat-executor` * JobHandler 填 `feat-first-handler` * 调度类型选 CRON,表达式填 `0/5 * * * * ?`(每 5 秒一次,仅用于验证) 保存后点击「执行一次」,观察执行器控制台,如果出现 `execute first job`,说明 Admin -> 执行器 -> JobHandler 的整条链路已经打通。 如果执行器没有自动注册到 Admin,检查这几点: * `xxl.job.admin.addresses` 是否能从执行器网络访问到 Admin * `appname` 是否与 Admin 里配置的执行器名称一致 * `accessToken` 是否匹配 * 执行器端口(默认 9999)是否被占用或拦截 ## 几个要注意的点 [Section titled “几个要注意的点”](#几个要注意的点) * **任务幂等**:分布式调度下,网络超时或 Admin 重试可能导致同一任务被执行多次。`execute()` 里的业务逻辑必须是幂等的,或者通过数据库唯一索引、分布式锁等手段去重。 * **端口规划**:执行器的 `port` 是 Admin 回调触发任务的入口,容器化部署时要暴露这个端口,并确保与宿主机端口映射正确。 * **日志清理**:`logretentiondays` 建议设置为 7\~30 天,避免任务日志无限增长撑爆磁盘。 * **优雅关闭**:`@PreDestroy` 里调用 `destroy()`,确保停机时先从 Admin 反注册,避免 Admin 继续向已下线的实例发调度请求。 * **鉴权**:生产环境的 Admin 必须修改默认密码,并设置独立的 `accessToken`,通过环境变量注入执行器,不要写死在配置文件里。 Feat 不包一层 starter,也不重写任务调度。XXL-Job 的 `XxlJobSimpleExecutor`、`IJobHandler`、`registryJobHandler`、心跳注册全是官方原生的东西;Feat 只负责把它接进生命周期:编译期注入配置、启动时初始化执行器、停机时释放资源。接其他调度框架时,套路也一样。 # Feat 简介 > 理解 Feat 的定位、三层架构,以及如何根据你的目标选择学习路径 如果你已经会用 Java 写 Web 服务,但希望有一个比 Spring Boot 更轻量、启动更快、资源占用更低的选项;或者你想把 AI 能力直接接入 Java 后端而不引入 Python 服务,Feat 很可能是你要找的工具。 Feat 是一个从底层网络服务能力生长出来的 Java Web 框架。它不是 Servlet 容器的包装层,也不是单纯的注解框架。它把 Core、Cloud、AI 三条线收拢到同一套技术栈里,每一层都可以独立使用。 ![Feat 架构图](/feat/_astro/architecture.DloDrBU7.svg) | 层级 | 定位 | 适合场景 | | -------------- | ---------- | -------------------------- | | **Feat Core** | 底层 HTTP 服务 | 直接控制请求处理,关注性能和资源占用 | | **Feat Cloud** | 注解式开发模型 | 快速构建业务应用,类似 Spring Boot 体验 | | **Feat AI** | AI 能力接入层 | 统一接入对话、Embedding、Agent、MCP | 警告 Feat 主线仓库采用 Apache 2.0。Feat Core 与 Feat AI 可直接使用;Feat Cloud 通过赞助支持模式开放,如需使用请先[成为赞助者](/feat/sponsors/)获取授权。 ## 根据目标选择起点 [Section titled “根据目标选择起点”](#根据目标选择起点) 不要从第一页顺序读到最后一页。先判断你当前最想解决什么问题,然后选择一条路径跑通第一个示例: [Feat Core ](/feat/server/)如果你想先理解 Feat 最底层的 HTTP 服务模型,从这里开始。 [Feat Cloud ](/feat/cloud/)如果你更习惯注解式开发,从这里开始。 [Feat AI ](/feat/ai/)如果你的目标是把模型能力接入 Java 应用,从这里开始。 跑通第一个示例后,你会对 Feat 的编码风格有一个直观印象。之后再深入同一层级的其他主题,会比从头读理论轻松得多。 # 请求处理管线 > 从字节到业务:理解 Feat 的解码状态机、字节树缓存、WAF 挂载点与线程模型 这一页回答一个问题:**一个 HTTP 请求从网络字节到你的业务代码,中间发生了什么**。理解这条管线,ServerOptions 的每个参数、每个错误码、每个”反直觉”的行为就都有了出处。 ## 全景图 [Section titled “全景图”](#全景图) ``` flowchart LR A["smart-socket AIO
读网络字节"] --> B["HttpRequestProtocol
解码状态机"] B --> C["WAF 方法/URI 校验
(解码期)"] C --> D["onHeaderComplete
(BasicAuth 等在此拦截)"] D --> E["请求体读取
maxRequestSize 限制"] E --> F["handle(request, future)
你的业务代码"] F --> G["响应写出 / gzip"] B -.解析失败.-> H["responseError
统一错误页"] F -.未捕获异常.-> H ``` 整条管线跑在 smart-socket 的 AIO 回调线程上,没有 Servlet 容器、没有过滤器链、没有每请求一个线程。 ## 第一站:解码状态机 [Section titled “第一站:解码状态机”](#第一站解码状态机) `HttpRequestProtocol` 把 HTTP 报文解析实现为一个显式状态机,按阶段推进: | 状态 | 做什么 | 失败时返回 | | -------- | ----------------------- | ---------------------- | | METHOD | 从字节树匹配请求方法 | —(空方法最终 501) | | URI | 从路由字节树匹配 URI,WAF URI 校验 | 400 / WAF 405 | | QUERY | 解析查询串 | 400 | | PROTOCOL | 解析协议版本 | 400(不支持的版本) | | HEADER | 逐行解析请求头 | 431(单个头超长) | | BODY | 异步累积请求体 | 413(超过 maxRequestSize) | 两个值得注意的细节: * **headerLimiter 的行为是”跳过”而不是”报错”**:超过上限的头部直接进入忽略状态,不参与后续处理。恶意超多头的请求不会得到 4xx,直接被静默丢弃 * **URI 超长返回 414**,协议版本解析用 8 字节 long 直接比较(`HTTP/1.1` 的字节恰好构成一个 long),一次比较完成版本判断,全程没有字符串分配 ## 第二站:字节树缓存 [Section titled “第二站:字节树缓存”](#第二站字节树缓存) Feat 性能的根基是 `ByteTree`:把 HTTP 报文里反复出现的内容(方法名、Header 名、协议版本、常见 Header 值)组织成字节前缀树,解码时按字节在树上匹配,命中即取回缓存的字符串对象,全程没有字符串分配和 HashMap 查找。 启动时 `HttpServer.initByteCache()` 会预热常用条目。另有一个公开的预留扩展点:`options.getUriByteTree()` 允许你把**完全固定的 URI** 提前注册到解码期——URI 在解码阶段就能命中并直接挂上处理器,连 Router 匹配都省掉。当前框架内置组件没有使用它,适合”路径完全固定、QPS 极高”的极端场景手动挂载。 注意区分两条路由路径: * **Router 的路由匹配(含精确路由)发生在 `handle` 阶段**,用的是 Router 自己的 `NodePath` 前缀树 * 上述 `uriByteTree` 是解码期的可选旁路,不是 Router 的一部分 对使用者的意义: * Router 匹配本身也是前缀树(不是逐条遍历),路由数量多不会线性变慢 * 自定义协议解析或高频字符串场景可以复用 `options.getByteCache()` ## 第三站:头部完成回调 [Section titled “第三站:头部完成回调”](#第三站头部完成回调) 所有请求头解析完成后,`onHeaderComplete` 被触发。这是一个**装饰器挂载点**:`BasicAuthServerHandler` 就是在这一层拦截的——认证失败时连接在这里被 401 关闭,根本不会进入业务 `handle`。WAF 的最终校验也在进入回调前完成。 理解这一层,你就明白 Feat 的安全原语为什么廉价:**能在解码期拒绝的请求,永远不会消耗业务逻辑**。 ## 第四站:请求体与业务分发 [Section titled “第四站:请求体与业务分发”](#第四站请求体与业务分发) * 请求体**异步累积**,`maxRequestSize` 在累积过程中检查,超限返回 413 * 头部带 `Host` 校验(HTTP/1.1 必须有 Host,缺失 400) * 随后进入 `handle(request, future)`: * **同步路径**:你的处理器直接在 AIO 回调线程执行 * **异步路径**:处理器立即返回,`future.complete(...)` 时才写响应——耗时操作必须走这条路径,原理见[异步处理](/feat/server/async/) ## 错误的统一出口 [Section titled “错误的统一出口”](#错误的统一出口) 管线任何阶段抛出的 `HttpException`(含 `WafException`)都汇入 `responseError`,渲染统一的错误页。这就是为什么: * 业务代码只需要抛异常,不需要自己写错误响应 * 错误页的形态全站一致(状态码 + 描述 + 版本签名) * 解码期错误(如 Header 超长)也有与业务错误一致的表现 ## 这套设计给你的使用边界 [Section titled “这套设计给你的使用边界”](#这套设计给你的使用边界) | 设计决策 | 对应的使用规则 | | -------------------- | ----------------------------------------------------- | | 缓冲区按池分配 | `readBufferSize` 至少容纳一个 URL 或 Header 值,否则解析直接失败 | | headerLimiter 静默丢弃 | 不要依赖”超限报错”来发现头部滥用,监控要做在入口 | | 精确路由解码期命中 | 极端热点可用 `uriByteTree()` 旁路;常规场景 Router 前缀树已足够快 | | 同步 handler 占用 AIO 线程 | 超过 10ms 的操作走异步(见[性能调优](/feat/practices/performance/)) | | gzip 在响应写出阶段 | 压缩阈值之下的响应不值得开 gzip(同上) | # 系统要求 > 确认 JDK、Maven、运行环境是否满足 Feat 的最低要求 ![](/feat/_astro/system_requirement.9Dyj2-ZW_1eYR7M.webp) > 在继续教程之前,先确认你的环境满足本章列出的最低要求。 Feat 的设计目标之一是保持轻量,系统要求并不高。生产环境部署时,再根据实际情况调整。 ## 最低要求 [Section titled “最低要求”](#最低要求) | 项目 | 要求 | | ----- | ------------------ | | JDK | 8 或更高 | | Maven | 3.6 或更高 | | 操作系统 | 任何支持标准 Java 运行时的环境 | 验证命令: 检查 JDK 和 Maven ```bash java -version mvn -v ``` Feat 的主线模块以 Java 8 为编译目标,因此你可以在 JDK 8、11、17、21 等更高版本上运行。如果你要扩展 Feat 或查看源码,需要理解它的 Java 8 兼容边界。 ## 运行环境 [Section titled “运行环境”](#运行环境) Feat **不依赖传统 Servlet 容器**。不需要准备 Tomcat、Jetty 或 Undertow,只要有 Java 运行时即可。 更具体地说,运行环境需要满足: * 能运行普通 Java 应用 * 能打开本机监听端口(默认 8080) * 能加载项目依赖和资源文件 大多数示例可以直接用 `main()` 启动: Bootstrap.java ```java import tech.smartboot.feat.Feat; public class Bootstrap { public static void main(String[] args) { Feat.httpServer() .httpHandler(req -> req.getResponse().write("Hello Feat")) .listen(); } } ``` ## 各模块的额外要求 [Section titled “各模块的额外要求”](#各模块的额外要求) 不同模块对额外环境的需求不同: ### Feat Core [Section titled “Feat Core”](#feat-core) 只需要 JDK + Maven + 浏览器或 `curl`。 验证 Core 示例 ```bash curl http://localhost:8080/ ``` ### Feat Cloud [Section titled “Feat Cloud”](#feat-cloud) 需要支持 Maven 和注解处理的 IDE(推荐 IntelliJ IDEA)。Feat Cloud 使用编译期注解处理(APT)生成代码,因此 IDE 需要正确配置注解处理器。 ### Feat AI [Section titled “Feat AI”](#feat-ai) 需要额外准备模型服务端: * **本地模型**:Ollama 或其他本地推理服务 * **云端 API**:OpenAI、Anthropic、Gitee AI 等平台的 API Key 检查 Ollama 是否运行 ```bash ollama list ``` ## Native Image(可选) [Section titled “Native Image(可选)”](#native-image可选) 如果你只是想使用 Feat,可以跳过这一部分。只有在你明确准备构建原生镜像时,才需要关注。 建议的使用路径: ```text 1. 先用 JRE 部署跑通 → 2. 确认需要 Native Image → 3. 投入 Native Image 优化 ``` 对大多数项目来说,先用 JRE 交付是更稳的路径。Native Image 更适合在你已经确认收益(启动速度、内存占用)之后再投入。 ## 快速自检清单 [Section titled “快速自检清单”](#快速自检清单) 在继续之前,确认你能完成以下操作: * [ ] 运行 `java -version`,显示 Java 8 或更高版本 * [ ] 运行 `mvn -v`,显示 Maven 3.6+ * [ ] 运行 `mvn clean package`,成功构建一个 Java 项目 * [ ] 能在本机打开一个 HTTP 端口(如 8080) * [ ] 有浏览器、`curl` 或其他 HTTP 调试工具 确认以上条件后,根据你的目标选择对应教程。 # 版本历史 > Feat 的版本演进、升级提示和兼容性变化记录 升级前,请先查看对应版本的兼容性说明。 版本号遵循语义化版本规范:`主版本.次版本.修订号` * **主版本**:可能包含破坏性变更 * **次版本**:新增功能,向后兼容 * **修订号**:问题修复,向后兼容 *** ## v2.5.0 (孵化中) [Section titled “v2.5.0 (孵化中)”](#v250-孵化中) ### 新特性 [Section titled “新特性”](#新特性) #### Feat Core [Section titled “Feat Core”](#feat-core) * **安全**:防止 HTTP 响应头 CR/LF 注入攻击,`setHeader` 校验 header 名值中的非法控制字符,异常时抛出 `FeatException` * **安全**:静态资源处理器新增路径穿越攻击防护,覆盖明文、URL 编码及部分编码等攻击向量,命中时返回 403 * **安全**:会话 ID 生成改用 `SecureRandom`,移除时间戳依赖,提升不可预测性 * **Session**:重构会话管理器,采用模板方法模式,`SessionManager#getSession` 统一编排 Cookie 解析、会话查找、创建与下发流程 * **Session**:修复会话过期污染无关请求响应的问题,会话对象不再持有请求/响应引用 * **IO**:修复输出流 `reset` 未清理跨请求残留状态的问题(remaining 计数、chunked trailer) * **IO**:修复 chunked 编码下 ByteBuffer 就地写入越界问题 * **IO**:优化输出流回调机制,回调参数由 `Consumer` 简化为 `Runnable` * **Client**:重构客户端请求体传输接口,`RequestBody` 的 `transferFrom` 方法替换为 `write` 方法 * **Client**:抽取 `HttpUrl` 统一解析连接地址,`HttpClient` 与 `WebSocketClient` 复用同一套解析逻辑 * **Client**:修复无路径 URL 的查询参数解析(如 `http://host?a=1`) * **Client**:Host 头省略协议默认端口,scheme 统一归一化为小写 * **Client**:修复连接异常关闭时请求 `future` 永久挂起的问题,改为以 `IOException` 结束 * **Router**:修复 `ByteTree` 超过最大深度时节点互相覆盖的问题 * **静态资源**:修复异步写入问题,补充空文件请求的提前返回处理 * **认证**:补充 BasicAuth 处理器的异步处理方法,确保被包装的异步 Handler 正常工作 * **依赖**:升级 smart-socket 至 `2.2.0` #### Feat Cloud / AOT [Section titled “Feat Cloud / AOT”](#feat-cloud--aot) * `feat.yaml` 新增 bool 类型的配置值 * 优化 Feat Cloud 启动流程,外部 Bean 统一由 `ApplicationContext` 管理 #### Feat AI [Section titled “Feat AI”](#feat-ai) * **Chat**:`chatStream` 返回类型由 `void` 调整为 `CompletableFuture`,支持获取流式终态响应 * **Chat**:`ChatStreamListener` 新增 `onChunk(ChatChunk)` 回调,暴露上游数据帧的完整元数据(`id`、`model`、`finish_reason`、`tool_calls` 增量、`usage`),网关与代理类场景可原样转发 * **Chat**:新增 `ChatOptions#includeUsage`,流式响应末尾返回 token 用量 * **MCP**:协议版本对齐至 `2025-11-25`,同时保留对 `2025-06-18` 的兼容,客户端与服务端按对方声明协商,不再绑定单一版本。最新规范 `2026-07-28` **尚未支持**(见下方说明) * **MCP**:新增 `Icon` 图标元数据,覆盖 Tool、Resource、ResourceTemplate、Prompt 与 Implementation(SEP-973),服务端侧提供 `icons(...)` 链式方法 * **MCP**:新增 URL 模式 elicitation 与枚举 Schema 支持,覆盖 `enum`、`oneOf`、多选等形态,新增错误码 `-32042` * **MCP**:Sampling 支持工具调用,新增 `ToolChoice`、`ToolUseContent`、`ToolResultContent` 与 `CreateMessageRequest` / `CreateMessageResult` * **MCP**:`tools/list`、`prompts/list`、`resources/list`、`resources/templates/list` 支持 `cursor` / `nextCursor` 分页 * **MCP**:补齐 `resources/subscribe` / `resources/unsubscribe`,与服务端能力声明保持一致 * **MCP**:修正 JSON-RPC 错误码归属,`INTERNAL_ERROR` 由错误的 `-32602` 改为 `-32603`,未注册方法返回 `-32601`,`RESOURCE_NOT_FOUND` 改为标准 `-32602` * **MCP**:`JsonRpc#id` 支持字符串类型并原样回传;客户端请求自动携带 `MCP-Protocol-Version` 头 警告 **MCP 协议尚未对齐最新规范。** 本版本对齐到 `2025-11-25`,当前最新的 `2026-07-28` 未纳入。 `2026-07-28` 是一次破坏性重构,核心是**无状态化**: * 移除 `initialize` 握手与 `Mcp-Session-Id`,改由 `server/discover` 与请求级 `_meta` 协商版本 * 通知改为 `subscriptions/listen`,服务端反向请求改为 MRTR(`input_required`) * 新增 `resultType`、`ttlMs` / `cacheScope`、`Mcp-Method` / `Mcp-Name` 路由头 * `inputSchema` 升级至 JSON Schema 2020-12,移除 `ping` 与 `logging/setLevel` 该改造需重写会话状态机与传输层,本版本未包含。目前主流客户端也仍以 `2025-11-25` 及更早版本为主,短期内不影响互操作性。后续计划以双世代(dual-era)方式引入:legacy 路径保持不变,modern 路径单独接入。 ### 升级步骤 [Section titled “升级步骤”](#升级步骤) 1. 更新版本号:`2.5.0` 2. 若自定义了 `RequestBody` 实现,将 `transferFrom` 方法改为 `write` 3. 若继承了 `SessionManager`,适配新增的 `getSession(String)` 与 `createSession()` 抽象方法 4. 若使用 MCP,检查 `JsonRpc#getId()` 的返回值使用处,按需显式转换类型 5. MCP 列表默认不分页,行为与旧版一致;如需分页,通过服务端 `McpOptions#setPageSize` 开启 ### 兼容性说明 [Section titled “兼容性说明”](#兼容性说明) | 变更项 | 影响 | 处理方式 | | ----------------------------- | --------------------------------------------- | -------------------------------------------- | | smart-socket 版本 | 默认依赖升级至 `2.2.0` | 如项目显式锁定版本,建议同步升级 | | `RequestBody` 接口 | `transferFrom` 替换为 `write` | 自定义请求体实现需同步调整 | | `SessionManager` 接口 | 采用模板方法重构 | 自定义会话管理器需适配新抽象方法 | | `chatStream` 返回类型 | 由 `void` 变为 `CompletableFuture` | 可选接收返回值,原调用方式不受影响 | | MCP 协议版本 | 默认声明 `2025-11-25`,同时兼容 `2025-06-18` | 无需处理,旧版本客户端仍可正常协商 | | `JsonRpc#getId()` | 返回类型由 `Integer` 变为 `Object` | 赋值处显式转换,如 `(Integer) request.getId()` | | `McpException.INTERNAL_ERROR` | 数值由 `-32602` 修正为 `-32603` | 按字面量匹配错误码的代码需同步调整,建议改用常量 | | `Transport#asyncRequest` | 失败时抛出异常,不再返回 `null` | 依赖 `null` 判断的分支改用 `exceptionally` 或 `handle` | | MCP 列表分页 | 默认关闭(服务端 `pageSize` 为 `Integer.MAX_VALUE`) | 需要分页时调用服务端 `McpOptions#setPageSize` 开启 | *** ## v2.4.0 (2026-08-22) [Section titled “v2.4.0 (2026-08-22)”](#v240-2026-08-22) ### 新特性 [Section titled “新特性”](#新特性-1) #### Feat Core [Section titled “Feat Core”](#feat-core-1) * 升级 fastjson 版本至 `2.0.64` #### Feat Cloud / AOT [Section titled “Feat Cloud / AOT”](#feat-cloud--aot-1) * 修复 `feat.yaml` 缺失 `feat.redis.username` 配置解析的问题。 * Controller 增加返回类型适配: `List`、`List`、`List`、`List` 以及直接继承 Map 的POJO 类。 * 修复通过反射注入代理 Bean 属性字段报 `NoSuchFieldException` 的问题。 * 优化 AOT 编译失败的异常提示 * 环境变量与系统属性支持 * feat.yml 支持 `${}` 语法:`${property}` 或 `${property:defaultValue}` * 解析优先级:系统属性 > 环境变量 > 默认值 * 支持数据源、Redis、服务器等所有配置项 * 序列化工具优化 * 新增 SerializerUtils 工具类统一属性解析 * 重构序列化器,使用 IntegerValuesSerializer 和 StringValuesSerializer 统一处理数组和列表 * 增强 null 值处理 ### 升级步骤 [Section titled “升级步骤”](#升级步骤-1) 1. 更新版本号:`2.4.0` 2. 在 `feat.yml` 中使用 `${}` 语法(可选) 3. 通过系统属性或环境变量传入配置值: ```bash java -Ddatasource.url=jdbc:mysql://localhost/feat -jar app.jar ``` ### 兼容性说明 [Section titled “兼容性说明”](#兼容性说明-1) * ✅ 完全向后兼容 v2.3.0 * ✅ 旧配置方式继续有效 * ✅ 新旧配置方式可混合使用 * ✅ 无需修改业务代码 *** ## v2.3.0 (2026-08-01) [Section titled “v2.3.0 (2026-08-01)”](#v230-2026-08-01) ### 新特性 [Section titled “新特性”](#新特性-2) #### Feat Core [Section titled “Feat Core”](#feat-core-2) * **HTTP**:优化 `PostInputStream` 读取逻辑,修复完整 body 读取时可能触发的 `StackOverflowError` 异常 * **HTTP**:调整异步请求体读取缓冲区判断逻辑,提升小 body 场景的读取性能 * **依赖**:`smart-socket` 升级至 `2.1.3` #### Feat Cloud / AOT [Section titled “Feat Cloud / AOT”](#feat-cloud--aot-2) * **拦截器**:新增方法拦截器能力,支持 `@Interceptor`、`@AroundInvoke` 和 `@Interceptors` * **拦截器**:优化拦截器链异常处理,并完善 AOT 序列化支持 * **示例**:新增 Nacos 配置中心接入示例,补充多环境配置与动态刷新用法 * **Bean**:`@Bean` 默认加载顺序调整为 `Byte.MAX_VALUE`,方便基础设施 Bean 提前初始化 ### 升级步骤 [Section titled “升级步骤”](#升级步骤-2) 1. **更新版本号** ```xml 2.3.0 ``` 2. **检查 Bean 初始化顺序** `@Bean` 的默认 `order` 从 `0` 调整为 `Byte.MAX_VALUE`。如果基础设施 Bean 需要优先初始化,请显式设置较小的顺序: ```java @Bean(order = 0) public class NacosConfigService { } ``` 3. **重新编译 AOT 生成代码** 如果新增或调整了 `@Interceptor`、`@AroundInvoke`、`@Interceptors`,需要重新执行 `mvn compile` 或重新打包,让 AOT 代码重新生成。 ### 兼容性说明 [Section titled “兼容性说明”](#兼容性说明-2) | 变更项 | 影响 | 处理方式 | | ------------------ | -------------------------------------- | ------------------------------ | | `@Bean` 默认 `order` | 默认 Bean 初始化顺序由 `0` 变为 `Byte.MAX_VALUE` | 依赖初始化顺序的 Bean 显式声明 `order` | | 方法拦截器 | 新增编译期 AOT 能力,不影响未使用拦截器的应用 | 使用拦截器后重新编译生成代码 | | `smart-socket` 版本 | 默认依赖升级至 `2.1.3` | 如项目显式锁定 smart-socket 版本,建议同步升级 | *** ## v2.2.0 (2026-07-07) [Section titled “v2.2.0 (2026-07-07)”](#v220-2026-07-07) ### 新特性 [Section titled “新特性”](#新特性-3) #### Feat Core [Section titled “Feat Core”](#feat-core-3) * **Client**:新增 `HttpOptions#maxConnections`,支持配置 HTTP 连接池最大连接数 * **Client**:优化 HTTP 客户端长链接的判定逻辑。 * **Server**:修复 upgrade 场景下解码异常的问题。 * **HTTP/2**:优化推送请求中的 Cookie 处理逻辑 * `ReadListener` 和 `WriteListener` 标记为已弃用,该功能仅用于适配 Jakarta Servlet 规范。 * smart-socket 升级至 `2.1.2` #### Feat Cloud / AOT [Section titled “Feat Cloud / AOT”](#feat-cloud--aot-3) * 支持 `feat.yml` / `feat-{env}.yml` 多环境配置生成 ### 升级步骤 [Section titled “升级步骤”](#升级步骤-3) 无 ### 兼容性说明 [Section titled “兼容性说明”](#兼容性说明-3) 无 *** ## v2.1.0 (2026-05-29) [Section titled “v2.1.0 (2026-05-29)”](#v210-2026-05-29) ### 新特性 [Section titled “新特性”](#新特性-4) #### Feat Core [Section titled “Feat Core”](#feat-core-4) * **依赖**:升级 smart-socket 至 2.0.0 * **Server**:`ServerOptions#setMaxRequestSize` 方法调整为链式风格,支持流式配置 * **协议**:修复文件上传场景中文文件名解析乱码问题【ISSUE:IJPD9F】 #### Feat AI [Section titled “Feat AI”](#feat-ai-1) * **模型**:新增 Anthropic 接口规范适配,支持 Claude 系列模型 * **MCP**:废弃 MCP Server 对于 SSE 的适配(规范已过时),仅保留 Streamable HTTP 传输方式 * **Chat**:移除 chat 接口的模型定制化逻辑,优先遵循 OpenAI 接口规范,简化多模型适配 * **Chat**:新增图像消息支持,支持多模态对话 * **Chat**:重构工具调用实体类,优化工具调用流程 #### Feat Cloud [Section titled “Feat Cloud”](#feat-cloud) * **模块**:移除 feat-cloud-aot-vm 模块 ### 升级步骤 [Section titled “升级步骤”](#升级步骤-4) 1. **更新版本号** ```xml 2.1.0 ``` 2. **检查 ServerOptions 链式调用** `setMaxRequestSize` 现在支持链式调用: ```java // v2.1.0 推荐写法 server.options() .setMaxRequestSize(1024 * 1024) .setPort(8080); ``` 3. **Feat AI 接口调整** * 若使用 Anthropic 模型,通过 `apiSpec(ApiSpec.ANTHROPIC)` 切换 API 规范 * SSE 传输方式已移除,请使用 Streamable HTTP 方式 ### 兼容性说明 [Section titled “兼容性说明”](#兼容性说明-4) | 变更项 | 影响 | 处理方式 | | ------------------------ | ------- | ------------------- | | `setMaxRequestSize` 返回类型 | 支持链式调用 | 可按需链式配置 | | feat-cloud-aot-vm 模块移除 | 模块不存在 | 移除相关依赖 | | MCP SSE 传输方式移除 | 功能废弃 | 迁移至 Streamable HTTP | | Feat AI Chat 接口重构 | 定制化逻辑移除 | 遵循标准 OpenAI 规范 | *** ## v2.0.0 (2026-04-07) [Section titled “v2.0.0 (2026-04-07)”](#v200-2026-04-07) ### 重要变更:开源协议升级 [Section titled “重要变更:开源协议升级”](#重要变更开源协议升级) 这是 Feat 项目的一个重要里程碑。我们将开源协议从 AGPL 升级至 **Apache 2.0**。 **为什么切换协议?** 在项目初期,我们选择 AGPL 旨在保护开源项目的完整性。但随着项目成熟,AGPL 的”传染性”条款对企业用户构成了法律风险,成为阻碍广泛采用的主要因素。 Apache 2.0 已成为企业级开源项目的事实标准(Spring Framework、Kubernetes、TensorFlow 等均采用)。它在保护创作者权益与促进生态繁荣之间取得了良好平衡。 **对企业用户的价值**: | 方面 | 变化 | | ---- | ------------------ | | 商业使用 | 可自由用于商业闭源项目,无需开源义务 | | 专利授权 | 明确的专利使用权,降低知识产权风险 | | 技术选型 | 降低法务审批门槛,加速决策流程 | | 生态参与 | 可自由定制和扩展,促进第三方库发展 | ### 升级指南 [Section titled “升级指南”](#升级指南) v2.0.0 与 v1.x **完全兼容**,只需更新版本号: ```xml 2.0.0 ``` 无需修改任何代码,升级后自动适用 Apache 2.0 协议。 提示 发版详情:[Feat 2.0 发布:Apache 2.0 协议升级](https://mp.weixin.qq.com/s/uXmXC8QiNt848_3fKqD98w) *** ## v1.5.0 (2026-02-24) [Section titled “v1.5.0 (2026-02-24)”](#v150-2026-02-24) ### 新特性 [Section titled “新特性”](#新特性-5) #### Feat Core [Section titled “Feat Core”](#feat-core-5) * **Client**:重构 GZIP 输入流,支持完整头部解析和异步解码 * **Client**:修复 SSE 事件数据换行符转义问题 * **Server**:修复静态资源处理器路径解析、HEAD 请求、空指针等问题 * **Server**:支持配置静态资源路径 * **Router**:支持花括号 `{}` 形式的路径参数匹配 * **Core**:升级 smart-socket 至 1.8.3 #### Feat AI [Section titled “Feat AI”](#feat-ai-2) * **Agent**:实现 ReAct Agent 智能体,支持系统提示词、工具执行钩子 * **Agent**:AI 代理工具改为异步执行模式 * **模型**:新增 Kimi-K2.5-Instruct 支持 * **工具**:新增 WebReader 网页阅读器功能 #### Feat Cloud [Section titled “Feat Cloud”](#feat-cloud-1) * **数据库**:新增 MyBatis 集成支持 * **会话**:新增分布式会话管理,集成 Redis * **部署**:新增 Docker 部署支持(JRE 和 Native Image) * **打包**:新增 Fat Jar 打包支持 * **路由**:RequestMapping 支持 `{}` 花括号路径参数 ### 升级步骤 [Section titled “升级步骤”](#升级步骤-5) 1. **更新版本号** ```xml 1.5.0 ``` 2. **处理编译报错** 升级后若出现编译错误,根据错误提示调整代码。 3. **检查 AsyncResponse 使用** `AsyncResponse.complete()` 方法返回类型从 `void` 变更为 `AsyncResponse`: v1.5+ ```java // v1.4 及之前 asyncResponse.complete(); return asyncResponse.complete(); ``` ### 兼容性说明 [Section titled “兼容性说明”](#兼容性说明-5) | 变更项 | 影响 | 处理方式 | | ------------------------------- | ---- | -------- | | `AsyncResponse.complete()` 返回类型 | 有返回值 | 按需要接收返回值 | | `ApplicationContext` 设为 final | 无法继承 | 使用组合而非继承 | | Feat AI Agent 接口重构 | 多处变更 | 参考新文档调整 | 提示 发版详情:[Feat 1.5 发布:ReAct Agent、MyBatis 集成、Docker 支持](https://mp.weixin.qq.com/s/fi0EmKhN9GHbUtZ6FfK3LA) *** ## v1.4.0 (2025-11-28) [Section titled “v1.4.0 (2025-11-28)”](#v140-2025-11-28) ### 新特性 [Section titled “新特性”](#新特性-6) #### Feat Core [Section titled “Feat Core”](#feat-core-6) * **Client**:新增友好的 SSE 接口,更好的赋能 AI 开发 * **Client**:HttpClient 支持链路复用和连接资源自动释放 * **Client**:支持解析 `Content-Encoding: dcz` 响应 * **Server**:Cookie Name 默认采用 RFC2109 规范 * **Server**:支持非 UTF-8 字符集的请求 * **Core**:升级 smart-socket 至 1.7.5 #### Feat AI [Section titled “Feat AI”](#feat-ai-3) * **SSE**:适配全新 SSE 接口,提升代码可读性 * **Agent**:初步实现通用 Agent 能力,内置多款工具 * **模型**:新增 DeepSeek-V3.2-Exp、DeepSeek-V3\_1、Qwen3-8B、Qwen3-32B #### Feat Cloud [Section titled “Feat Cloud”](#feat-cloud-2) * **性能**:异步接收 Form 表单数据,并发性能大幅提升 * **HTTPS**:新增 `feat.yaml` 配置项 `$.server.autoSSL` 自动启用 HTTPS * **AOT**:优化 AOT VM 模块,对齐 AOT 运行表现 ### 升级步骤 [Section titled “升级步骤”](#升级步骤-6) 1. 更新版本号至 1.4.0 2. 处理编译报错(主要是包路径迁移) 3. 若使用 Feat AI Agent,注意接口存在不兼容重构 ### 兼容性说明 [Section titled “兼容性说明”](#兼容性说明-6) * feat-core 模块中多个工具类进行包路径迁移 * feat-ai 模块 Agent 相关接口存在多处重构,未来一段时期内该情况持续存在 *** ## v1.3.0 (2025-09-14) [Section titled “v1.3.0 (2025-09-14)”](#v130-2025-09-14) ### 新特性 [Section titled “新特性”](#新特性-7) #### Feat Core [Section titled “Feat Core”](#feat-core-7) * 静态资源服务迁移至示例模块 * `FeatUtils` 增加 `isEmpty` 和 `isNotEmpty` 方法 * 优化 Router 路径参数解析性能 #### Feat AI [Section titled “Feat AI”](#feat-ai-4) * 更加规范、统一的接口定义 #### Feat Cloud [Section titled “Feat Cloud”](#feat-cloud-3) * 引入编译期 LICENSE 授权验证机制 * 剥离 AOT 相关代码作为独立模块 * 新增 AOT VM 模块,提升本地开发体验 ### 兼容性说明 [Section titled “兼容性说明”](#兼容性说明-7) * Feat AI 模板存在多处接口定义及配置参数重构 * AOT 相关代码剥离成独立模块,保留原包路径定义,基本保持兼容性 *** ## v1.2.0 (2025-07-24) [Section titled “v1.2.0 (2025-07-24)”](#v120-2025-07-24) ### 新特性 [Section titled “新特性”](#新特性-8) #### Feat Core [Section titled “Feat Core”](#feat-core-8) * 优化 HttpClient 实现,修复已知 bug * 启用 SSE 时默认禁用 chunked 响应 header * 扩充 ByteTree 初始化缓存值,提升服务性能 * 版本号定义从 `ServerOptions.java` 迁移至 `Feat.java` #### Feat AI [Section titled “Feat AI”](#feat-ai-5) * 新增 MCP 规范实现 #### Feat Cloud [Section titled “Feat Cloud”](#feat-cloud-4) * 新增 `@HttpClient` 注解,用于发起 HTTP 请求 * 支持响应结果为 bool 值的 HTTP 接口 * `@Controller` 增加 gzip、gzipThreshold 配置项 * 新增基于注解的 MCP Server 开发能力 *** ## v1.1.0 (2025-06-13) [Section titled “v1.1.0 (2025-06-13)”](#v110-2025-06-13) ### 新特性 [Section titled “新特性”](#新特性-9) #### Feat Core [Section titled “Feat Core”](#feat-core-9) * **Router**:支持定义相同 URL、不同 HTTP Method 的接口 * **日志**:RunLogger 自动感应日志级别变化,支持动态调整 * **日志**:日志级别环境变量由 `smarthttp.log.level` 调整为 `feat.log.level` * **工具类**:将 DateUtils、GzipUtils、HttpUtils 等统一汇集至 `FeatUtils` * **Banner**:限制单进程内 Feat Banner 最多输出一次 #### Feat Cloud [Section titled “Feat Cloud”](#feat-cloud-5) * **配置**:新增 `feat.yaml`/`feat.yml` 配置文件解析,支持 `@Value` 注入 * **日志**:支持运行时动态调整指定 loggerName 的日志级别 * **性能**:编译器自动将非通配 URI 注册至 ByteTree,提升解析性能 ### 升级步骤 [Section titled “升级步骤”](#升级步骤-7) **POM 升级**: ```xml tech.smartboot.feat feat-core 1.1.0 ``` **代码调整**: * `CloudOptions.addExternalBean()` 重命名为 `registerBean()` * 废弃工具类:DateUtils、GzipUtils、HttpUtils、NumberUtils、StringUtils、CollectionUtils、Constant、SHA1、WebSocketUtil *** ## v1.0.0 (2025-05-24) [Section titled “v1.0.0 (2025-05-24)”](#v100-2025-05-24) Feat 首个正式发布版本,提供完整的企业级 Web 服务开发框架功能。 ### 核心能力 [Section titled “核心能力”](#核心能力) * **性能**:基于智能异步通信框架,轻松应对高并发场景 * **协议**:支持 HTTP/1.0、HTTP/1.1、HTTP/2、WebSocket、SSE * **功能**:内置静态资源服务器、灵活路由、RESTful API 支持 * **部署**:快速启动(<1秒),适合容器化和 Serverless 场景 * **企业级**:Feat Cloud 提供类似 Spring Boot 的开发体验 * **AI**:Feat AI 模块支持 AI 应用开发 *** ## 获取帮助 [Section titled “获取帮助”](#获取帮助) * **问题反馈**:[Gitee Issues](https://gitee.com/smartboot/feat/issues) * **版本咨询**:查看对应版本的微信公众号发布文章 * **升级支持**:在 Issue 中描述你的升级场景,获取针对性建议 # 部署 Feat Core 应用 > 把 Feat Core 应用从本地运行推进到 Fat Jar、Docker 容器和优雅关闭的生产形态 [Feat Cloud 的部署与交付](/feat/cloud/deployment/)覆盖了注解式应用的完整交付链路。这一页聚焦 **Feat Core**:不依赖编译期生成的纯代码式应用,怎么从 `main` 方法走到生产环境。 ## 打包方式 [Section titled “打包方式”](#打包方式) * Fat Jar 用 `maven-shade-plugin` 打可执行 jar。Core 应用没有 `CloudService` 服务发现文件需要合并,配置比 Cloud 应用更简单: pom.xml 关键配置 ```xml org.apache.maven.plugins maven-shade-plugin com.example.Application package shade ``` ```bash mvn clean package java -jar target/app.jar ``` 提示 如果应用同时引入了依赖 `META-INF/services` 的库(如 Nacos 客户端),仍需按[部署与交付](/feat/cloud/deployment/)的方式为对应服务发现文件加 `AppendingTransformer`。纯 Core 应用没有这条负担。 * 普通 jar + classpath 开发期直接在 IDE 运行 `main` 方法即可。也可以用 `mvn dependency:copy-dependencies` 把依赖拷出来,用 `java -cp` 启动,适合不想打 Fat Jar 的场景: ```bash mvn dependency:copy-dependencies -DoutputDirectory=lib java -cp "target/classes:lib/*" com.example.Application ``` ## Docker 交付 [Section titled “Docker 交付”](#docker-交付) Feat 应用是普通 Java 进程,容器化没有任何特殊要求: Dockerfile ```dockerfile FROM eclipse-temurin:8-jre-alpine WORKDIR /app COPY target/app.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] ``` 几个与 Feat 相关的容器实践: * **监听地址保持默认** `0.0.0.0`,改成 `127.0.0.1` 会容器网络不通 * **端口参数化**:端口默认 8080,代码里用 `System.getenv("SERVER_PORT")` 之类的环境变量读取后传给 `listen(port)`,即可同一镜像多环境复用 * **日志走 stdout**:内置日志输出到控制台,容器运行时自动收集,见[日志与可观测性](/feat/practices/logging/) * **不需要 JRE 之外的基础镜像层**:Feat 只依赖 JDK 标准 API 和少量第三方库(如 fastjson2) 基镜像选择 JRE 8 还是更高版本都可以,Feat 主线以 Java 8 编译目标运行,见[系统要求](/feat/guides/system-requirements/)。 ## 优雅关闭 [Section titled “优雅关闭”](#优雅关闭) `HttpServer.shutdown()` 的执行顺序是固定的,理解它才能写出正确的关闭逻辑: 1. 先执行 `shutdownHook` 里注册的清理动作 2. 然后关闭监听端口、断开连接 3. 最后释放缓冲区池 GracefulShutdownDemo.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.HttpServer; public class GracefulShutdownDemo { public static void main(String[] args) { HttpServer server = Feat.httpServer(options -> { // 进程收到 SIGTERM(如 docker stop)后,JVM shutdown 钩子触发这里 options.shutdownHook(() -> { System.out.println("flush pending tasks, release resources..."); // 在这里完成:停止接收新任务、刷写缓冲、关闭连接池等 }); }).httpHandler(ctx -> ctx.write("ok")); server.listen(8080); Runtime.getRuntime().addShutdownHook(new Thread(server::shutdown)); } } ``` 使用要点: * **业务清理放 `shutdownHook`**,不要放 JVM 钩子里重复写;`shutdownHook` 在连接关闭前执行,还能访问正在处理的请求上下文 * 容器场景下记得把 JVM 钩子挂上 `server::shutdown`,`docker stop` 默认给 10 秒宽限期 * `shutdown()` 可重入,多次调用安全(内部会防止重复关闭) ## 生产检查清单 [Section titled “生产检查清单”](#生产检查清单) * [ ] 监听地址 `0.0.0.0`,端口未与其他服务冲突 * [ ] `debug(true)` 已关闭,`feat.log.level` 按环境设置 * [ ] `maxRequestSize`、`headerLimiter`、`setIdleTimeout` 按业务压过默认值(见[安全实践](/feat/practices/security/)) * [ ] 优雅关闭链路验证过:`docker stop` 后日志里能看到 hook 执行 * [ ] 健康检查接口就位(最简实现:一个返回 200 的路由) * [ ] 配置通过环境变量或启动参数注入,镜像内不含环境敏感信息 ## 下一步 [Section titled “下一步”](#下一步) * 容器内的日志收集与级别管理:[日志与可观测性](/feat/practices/logging/) * 注解式应用的 Fat Jar / Native Image 交付:[部署与交付](/feat/cloud/deployment/) # FAQ > 按场景汇总 Feat 使用中最常见的问题与答案,每条附权威章节链接 这一页按”你想干什么”组织,每条只给结论和入口,细节在对应章节。没找到答案时,先看[故障排查](/feat/cloud/troubleshooting/)。 ## 快速上手 [Section titled “快速上手”](#快速上手) ### Feat 和 Spring Boot 是什么关系? [Section titled “Feat 和 Spring Boot 是什么关系?”](#feat-和-spring-boot-是什么关系) 没有关系,也不是包装层。Feat 从底层网络能力自建(基于 smart-socket 的 AIO),不走 Servlet 规范。Feat Cloud 提供类似 Spring Boot 的注解式开发体验,但装配发生在编译期而不是运行期反射。选型参考见 [Feat 简介](/feat/guides/)。 ### 该从 Core 还是 Cloud 学起? [Section titled “该从 Core 还是 Cloud 学起?”](#该从-core-还是-cloud-学起) 习惯注解式开发(Controller/Bean)从 [Feat Cloud](/feat/cloud/) 开始;想理解底层模型或做极致性能优化从 [服务端](/feat/server/) 开始。两边共享同一套路由与会话模型。 ### 支持哪些 JDK 和服务器环境? [Section titled “支持哪些 JDK 和服务器环境?”](#支持哪些-jdk-和服务器环境) JDK 8+,主线以 Java 8 编译目标构建,8/11/17/21 均可运行。完整清单见[系统要求](/feat/guides/system-requirements/)。 ## 请求与路由 [Section titled “请求与路由”](#请求与路由) ### Controller 写了,访问却是 404? [Section titled “Controller 写了,访问却是 404?”](#controller-写了访问却是-404) 按顺序查五件事,完整排查清单见[故障排查](/feat/cloud/troubleshooting/): 1. 类上有 `@Controller`、方法上有 `@RequestMapping` 2. 引入了 `feat-cloud-starter`(编译期生成依赖) 3. 包在 `setPackages(...)` 扫描范围内 4. 改代码后重新执行过 Maven 编译(IDE 直接 Run 不会触发 APT) 5. 启动日志里有 `Feat Router:` 输出 ### 路径参数怎么写? [Section titled “路径参数怎么写?”](#路径参数怎么写) 冒号形式:`/:userId`,通过 `@PathParam("userId")` 注入。**花括号形式 `{userId}` 不支持**——曾经支持过但已移除,不要从 Spring 的习惯直接照搬。 ### 怎么接收 JSON 请求体? [Section titled “怎么接收 JSON 请求体?”](#怎么接收-json-请求体) Controller 方法直接声明 POJO 参数,不需要注解,框架按字段名绑定。调用方必须带 `Content-Type: application/json`。常见坑(setter 缺失、基本类型拆箱 NPE)见[Controller 与请求处理](/feat/cloud/controller/)。 ### 怎么统一返回结构? [Section titled “怎么统一返回结构?”](#怎么统一返回结构) 用 `RestResult` 封装 success/code/message/data 四要素,见[返回值与错误响应](/feat/cloud/response/)。 ### 想在方法前后做鉴权/日志怎么办? [Section titled “想在方法前后做鉴权/日志怎么办?”](#想在方法前后做鉴权日志怎么办) 用方法拦截器 `@Interceptor` + `@AroundInvoke`,调用 `proceed()` 继续链路、不调用即短路,见[方法拦截器](/feat/cloud/interceptor/)。 ## 配置与部署 [Section titled “配置与部署”](#配置与部署) ### 改了 feat.yml 不生效? [Section titled “改了 feat.yml 不生效?”](#改了-featyml-不生效) `feat.yml` 是**编译期输入**,只重启不重新构建不会重新生成代码。执行 `mvn clean package` 后再启动,原理见[编译期工作模型](/feat/cloud/runtime/)。 ### 配置里的 $ 占位符怎么用? [Section titled “配置里的 $ 占位符怎么用?”](#配置里的--占位符怎么用) `@Value("${key}")` 从配置解析,同时兼容 JVM 参数 `-Dkey` 和环境变量 `KEY`(点号转下划线、全大写),优先级:系统属性 > 环境变量。支持 `${key:默认值}`。完整规则见[启动配置与环境配置](/feat/cloud/options/)。 注意:`@Value("key")` 不带 `${}` 注入的是字面量字符串,见 [Bean 与依赖注入](/feat/cloud/bean/)。 ### Fat Jar 启动后路由全部丢失? [Section titled “Fat Jar 启动后路由全部丢失?”](#fat-jar-启动后路由全部丢失) 服务发现文件 `META-INF/services/...CloudService` 被 shade 覆盖了,需要 `AppendingTransformer`,配置见[部署与交付](/feat/cloud/deployment/)。 ### 怎么选择 dev/prod 环境? [Section titled “怎么选择 dev/prod 环境?”](#怎么选择-devprod-环境) `java -Dfeat.profiles.active=prod -jar app.jar`,环境文件命名 `feat-prod.yml`,合并规则与排查见[启动配置与环境配置](/feat/cloud/options/)。 ## 网络与协议 [Section titled “网络与协议”](#网络与协议) ### 怎么开 HTTPS? [Section titled “怎么开 HTTPS?”](#怎么开-https) 用 `SslPlugin`,证书加载与 SSLEngine 高级用法见 [HTTPS 配置](/feat/server/https/)。同一个服务还想跑 WebSocket/SSE/HTTP/2 见[协议升级](/feat/server/upgrade/)。 ### 大文件上传报 413 或内存暴涨? [Section titled “大文件上传报 413 或内存暴涨?”](#大文件上传报-413-或内存暴涨) 默认 `maxRequestSize` 很大但内存缓冲策略保守。文件上传用 Multipart 处理并调好上限,见[ServerOptions 配置](/feat/server/serveroptions/)与[Controller 与请求处理](/feat/cloud/controller/)。 ### 接口耗时高、并发上不去? [Section titled “接口耗时高、并发上不去?”](#接口耗时高并发上不去) 先分清负载类型:外部调用和数据库访问必须走[异步处理](/feat/server/async/),调线程数解决不了 IO 等待。完整调优思路见[性能调优](/feat/practices/performance/)。 ### 服务器在 Nginx 后面拿不到真实 IP? [Section titled “服务器在 Nginx 后面拿不到真实 IP?”](#服务器在-nginx-后面拿不到真实-ip) 启用代理协议支持:`options.proxyProtocolSupport()`(PROXY protocol)。前提是前置代理配置了对应协议转发,见[ServerOptions 配置](/feat/server/serveroptions/)。 ## AI 能力 [Section titled “AI 能力”](#ai-能力) ### 接模型前需要准备什么? [Section titled “接模型前需要准备什么?”](#接模型前需要准备什么) 一个可用的模型服务(OpenAI 兼容接口或 DashScope 等)及其 API Key,其余见 [Feat AI 快速开始](/feat/ai/)。 ### 流式输出怎么实现? [Section titled “流式输出怎么实现?”](#流式输出怎么实现) `chatStream(...)` + `ChatStreamListener` 回调逐段接收,见[对话模型](/feat/ai/chat/)。注意 v2.5.0 起返回值变为 `CompletableFuture`。 ### 工具调用(Function Calling)怎么做? [Section titled “工具调用(Function Calling)怎么做?”](#工具调用function-calling怎么做) 用 Agent 机制组织模型与工具,`@Tool` 注册业务能力,见 [Agent 工具](/feat/ai/agent/)。把业务系统暴露为 MCP 服务见 [MCP 能力暴露](/feat/cloud/mcp/)。 ## 安全 [Section titled “安全”](#安全) ### 有内置的请求过滤吗? [Section titled “有内置的请求过滤吗?”](#有内置的请求过滤吗) 有,WAF 支持方法白名单/黑名单和 URI 前后缀白名单,解析阶段即拦截,见[安全实践](/feat/practices/security/)。 ### BasicAuth 怎么加? [Section titled “BasicAuth 怎么加?”](#basicauth-怎么加) `new BasicAuthServerHandler(user, pass, realHandler)` 包装业务处理器。必须配 HTTPS 使用,见[安全实践](/feat/practices/security/)。 ### 静态资源会被路径穿越攻击吗? [Section titled “静态资源会被路径穿越攻击吗?”](#静态资源会被路径穿越攻击吗) 内置处理器已拦截 `../`/`..\`。自研文件下载接口不受这层保护,需自行校验,见[静态资源服务](/feat/practices/static-files/)。 # 从 Spring Boot 迁移 > 给 Spring 背景开发者的对照地图:注解、配置、拦截器、返回值逐项对应,以及不能照搬的地方 Feat 的目标用户很多来自 Spring Boot。好消息是 Feat Cloud 的开发模型刻意保持了熟悉的形状:Controller、Bean、依赖注入、配置文件都在。这一页给你一张逐项对照的地图,并标出**不能照搬**的差异点——这些才是迁移时的真实成本所在。 ## 注解对照表 [Section titled “注解对照表”](#注解对照表) | Spring Boot | Feat Cloud | 说明 | | -------------------------------- | -------------------------------------------------------------- | ----------------------------------------------- | | `@RestController` | `@Controller` | Feat 不区分两类,返回值按类型自动处理(POJO 转 JSON) | | `@RequestMapping("/path")` | `@RequestMapping("/path")` | 方法级路径写法一致;类级基准路径直接写在 `@Controller("users")` 的值里 | | `@GetMapping` / `@PostMapping` 等 | `@RequestMapping(value = "/path", method = RequestMethod.GET)` | 没有 HTTP 方法专用的简化注解,方法限定写在 `method` 属性里 | | `@PathVariable` | `@PathParam` | 名字变了,用法一致;路径参数只支持 `:param` 形式 | | `@RequestParam` | `@Param` | 查询参数绑定 | | `@Autowired` | `@Autowired` | 字段注入,用法一致 | | `@Component` / `@Service` | `@Bean` | 一个注解覆盖组件分层;见 [Bean 与依赖注入](/feat/cloud/bean/) | | `@Value("${key}")` | `@Value("${key}")` | 写法一致,支持 `${key:默认值}`;**不带 `${}` 是字面量** | | `@PostConstruct` / `@PreDestroy` | `@PostConstruct` / `@PreDestroy` | 用法一致 | | `@CrossOrigin` 等 CORS | 无内置 | 见[安全实践](/feat/practices/security/)的自定义方案 | 一个最小对照示例: * Spring Boot ```java @RestController @RequestMapping("/users") public class UserController { @Autowired private UserService userService; @GetMapping("/{id}") public ResponseEntity get(@PathVariable Long id) { return ResponseEntity.ok(userService.find(id)); } } ``` * Feat Cloud ```java @Controller("users") public class UserController { @Autowired private UserService userService; @RequestMapping("/:id") public RestResult get(@PathParam("id") Long id) { return RestResult.ok(userService.find(id)); } } ``` ## 配置体系对照 [Section titled “配置体系对照”](#配置体系对照) | Spring Boot | Feat Cloud | | ------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `application.yml` | `feat.yml` | | `application-prod.yml` + `spring.profiles.active` | `feat-prod.yml` + `feat.profiles.active` | | `@ConfigurationProperties` | 配置对象 Bean + `@Value("${...}")` 逐字段绑定(参考 [XXL-Job 集成](/feat/cloud/xxljob/)的 `XxlJobConfig` 写法) | | 环境变量 `MY_KEY` | `${my.key}` 自动兼容环境变量 `MY_KEY`,规则一致 | 核心差异:**`feat.yml` 是编译期输入**。修改配置文件后必须重新构建(`mvn clean package`),而不是像 Spring 那样重启即可。这是 Feat 把装配提前到编译期的代价,换来的是启动时零扫描、零反射。机制见[编译期工作模型](/feat/cloud/runtime/)。 ## 横切能力对照 [Section titled “横切能力对照”](#横切能力对照) | Spring Boot | Feat Cloud | 备注 | | ------------------------ | -------------------------------------------- | --------------------------------------------- | | AOP / `@Aspect` | `@Interceptor` + `@AroundInvoke` | 方法级拦截,调用 `proceed()` 继续、不调用即短路;无切点表达式,按注解标注目标 | | `@ControllerAdvice` 统一异常 | 抛 `HttpException` 或返回 `RestResult.fail(...)` | 框架级错误统一渲染;业务级错误建议在方法内捕获后转 `RestResult` | | Spring Session / Redis | 内置 Redis Session(`store-type: redis`) | 见 [Session 与请求状态](/feat/cloud/session/) | | `DispatcherServlet` | 无 Servlet | 请求直接进入 AIO 管道,没有过滤器链概念 | | BeanFactory 后置处理 | 无 | 装配在编译期生成,运行期无容器扩展点 | ## 生态缺位的部分 [Section titled “生态缺位的部分”](#生态缺位的部分) Spring 的”全家桶”在 Feat 里没有一一对应物,按现状规划: * **ORM**:无内置 JPA,集成 MyBatis 有官方文档([生态集成:MyBatis](/feat/cloud/mybatis/)) * **配置中心/注册中心**:Nacos 官方集成([生态集成:Nacos](/feat/cloud/nacos/)) * **任务调度**:XXL-Job 官方集成([生态集成:XXL-Job](/feat/cloud/xxljob/)) * **安全框架**:无 Spring Security 对应物,内置 BasicAuth + WAF 覆盖基础场景([安全实践](/feat/practices/security/)),复杂鉴权需要自建 * **测试切片**:无 `@SpringBootTest` 等价物,集成测试思路见[测试实践](/feat/practices/testing/) ## 迁移步骤建议 [Section titled “迁移步骤建议”](#迁移步骤建议) 1. **先用 Feat Cloud 写一个新接口**,跑通[快速开始](/feat/cloud/)的最小闭环,建立直观感受 2. **按模块迁移而不是整体迁移**:把一个内聚的业务模块(Controller + Service)搬过来,验证注解对照表中的每一项 3. **配置逐项翻译**:`application.yml` → `feat.yml`,注意每改一次就要重新构建 4. **拦截器重写**:Spring AOP 的切点表达式需要换成显式注解标注,逻辑本身平移 5. **性能回归**:迁移完成的模块用[性能调优](/feat/practices/performance/)的流程确认收益 ## 什么时候不建议迁移 [Section titled “什么时候不建议迁移”](#什么时候不建议迁移) 坦率地说,以下情况 Spring Boot 仍是更好的选择: * 深度依赖 Spring Security 的鉴权体系 * 项目大量使用 Spring 生态的 starter(消息队列、监控、Sleuth 链路追踪等) * 团队没有意愿维护”非全家桶”的生态组合 Feat 的甜点区是:接口服务、高性能网关类应用、资源敏感的容器化部署、需要把 AI 能力直接嵌入 Java 后端的场景。 # 日志与可观测性 > 掌握 Feat 的日志门面、feat.log.level 配置和调试手段,让服务在生产环境可观察、可排查 Feat 框架自身不带重量级日志依赖,内置了一套轻量日志门面(`tech.smartboot.feat.core.common.logging`),默认基于 JDK 内置的 `java.util.logging` 输出。理解它的工作方式,才能在生产环境里正确地调整日志级别、接入自己的日志体系。 ## 日志门面的工作方式 [Section titled “日志门面的工作方式”](#日志门面的工作方式) 框架内部所有日志都通过 `LoggerFactory.getLogger(...)` 获取 `Logger` 实例,接口与 SLF4J 风格一致,支持 `{}` 占位符: ```java import tech.smartboot.feat.core.common.logging.Logger; import tech.smartboot.feat.core.common.logging.LoggerFactory; Logger LOGGER = LoggerFactory.getLogger(MyService.class); LOGGER.info("user {} login from {}", userId, ip); LOGGER.error("process failed", throwable); ``` 底层实现是 `RunLogger`,要点有四个: 1. **级别映射到 JUL**:`trace → FINE`、`debug → CONFIG`、`info → INFO`、`warn → WARNING`、`error → SEVERE` 2. **默认级别 INFO**:未配置时自动设为 `INFO` 3. **级别可动态调整**:`RunLogger` 每 5 秒检查一次系统属性,改级别不需要重启进程 4. **输出到控制台**:默认使用 ConsoleHandler + 自定义 `LogFormatter`,UTF-8 编码 ## 配置日志级别 [Section titled “配置日志级别”](#配置日志级别) 通过系统属性 `feat.log.level` 控制,启动时传入: 设置全局日志级别 ```bash java -Dfeat.log.level=INFO -jar app.jar ``` 警告 `feat.log.level` 的合法取值是 JUL 的级别名:`SEVERE`、`WARNING`、`INFO`、`CONFIG`、`FINE`、`FINER`、`FINEST`(也接受整数值)。注意 **`DEBUG`、`TRACE` 不是合法值**——想打开 debug 日志要写 `CONFIG`,想打开 trace 日志要写 `FINE`。 级别判断对性能友好:`isInfoEnabled()` 不通过时,占位符格式化不会发生。所以业务代码里可以放心打 debug 日志。 ## 按模块单独设置级别 [Section titled “按模块单独设置级别”](#按模块单独设置级别) 排障时通常只想打开某个类的日志。`feat.log.level.` 可以覆盖全局级别,loggerName 就是类全名: 单独打开某个类的 debug 日志 ```bash java -Dfeat.log.level=INFO \ -Dfeat.log.level.tech.smartboot.feat.core.server.impl.HttpMessageProcessor=CONFIG \ -jar app.jar ``` 因为是每 5 秒轮询一次系统属性,运行期通过 JMX、Arthas 之类工具改这个属性,级别也会随之生效,**无需重启**。 ## debug(true) 不是日志 [Section titled “debug(true) 不是日志”](#debugtrue-不是日志) `ServerOptions.debug(true)` 很容易被误解成”打开 debug 日志”。它做的是另一件事:给服务器挂上 `StreamMonitorPlugin`,把**经过连接的原始字节流**(请求红色、响应蓝色)直接打印到控制台。 报文级调试 ```java Feat.httpServer(options -> options.debug(true)) .httpHandler(ctx -> ctx.write("hello")) .listen(8080); ``` | 手段 | 看到什么 | 适用场景 | | ----------------------- | ------------- | -------------------------- | | `feat.log.level=CONFIG` | 框架的结构化日志 | 排查路由注册、加载失败等框架行为 | | `debug(true)` | 原始 HTTP 报文字节流 | 排查协议问题:Header 异常、编码问题、流式边界 | 警告 `debug(true)` 输出的是完整报文内容,包含 Header 里的鉴权信息和 Cookie,**严禁在生产环境开启**。它也不走日志级别控制,只能通过代码配置关闭。 ## 生产环境接入自有日志体系 [Section titled “生产环境接入自有日志体系”](#生产环境接入自有日志体系) 内置实现输出到控制台,没有文件滚动、没有对接 ELK。生产环境的推荐做法: 1. **保持 Feat 日志输出到控制台**,用 Docker / systemd / 重定向统一收集 stdout——容器化部署下这是最省事的路 2. 如果必须接入 Logback/Log4j2,注意 `LoggerFactory` 目前没有提供实现替换的公开接口(内部是固定的 `RunLogger` + 私有缓存)。框架日志量不大,通常不必强行对接;业务代码请直接使用 SLF4J 等门面,把 Feat 内置日志当”框架行为输出”看待 3. 有强需求时,可以在仓库提 issue,或自行 fork 改造——`Logger` 接口很小,只有级别判断加各级别输出方法 ## 排查问题的观察点 [Section titled “排查问题的观察点”](#排查问题的观察点) 出问题时按这个顺序收集信息: 1. **启动日志**:路由是否注册成功(`Feat Router: |-> /hello ==> ...`)、启动耗时 2. **错误页内容**:框架错误页会带状态码和描述,`WafException` 触发说明请求在接入层就被拦了 3. **日志级别**:临时把 `feat.log.level` 调到 `CONFIG` 观察 5 秒轮询是否生效 4. **报文层**:本地复现时开 `debug(true)` 对比客户端实际发出的字节 ## 下一步 [Section titled “下一步”](#下一步) * 用故障现象反查原因:[故障排查](/feat/cloud/troubleshooting/) * 交付形态与容器化:[部署 Feat Core 应用](/feat/practices/deploy-core/) # 性能调优 > 基于 ServerOptions 的线程、缓冲区与缓存机制,按负载特征给 Feat 服务做针对性调优 Feat 的性能基线很高(基准数据见[性能测试](/feat/appendix/benchmark/)),大多数服务不需要调优。这一页的目标不是”把参数调大”,而是帮你理解 Feat 的执行模型,知道**什么症状该动哪个参数**。 ## 先理解线程模型 [Section titled “先理解线程模型”](#先理解线程模型) Feat 基于 smart-socket 的异步 IO(AIO)构建,`threadNum` 指定的是**处理业务回调的线程数**: * 请求解析在 IO 线程完成,你的 `HttpHandler.handle()` 也在这些线程上被调用 * 默认值 = CPU 核心数(至少 2) * **同步处理器里执行耗时操作会直接占住业务线程**,拖慢整个服务的吞吐 所以第一条调优原则不是调参数,而是分清负载类型: | 负载特征 | 做法 | | ----------------------- | ---------------------------------------------- | | 纯内存操作(少于 1ms) | 同步处理器即可,线程数保持默认 | | 外部调用、数据库、文件 IO(大于 10ms) | 用[异步处理](/feat/server/async/)把耗时操作移出主线程,业务自管线程池 | | 既有快接口又有慢接口 | 快接口同步、慢接口异步,避免相互拖累 | `threadNum` 只在同步处理占主导、且确认瓶颈在 CPU 时才值得调大。异步模型下线程数与业务线程池解耦,调 `threadNum` 基本无感。 ## 缓冲区:吞吐与大请求的权衡 [Section titled “缓冲区:吞吐与大请求的权衡”](#缓冲区吞吐与大请求的权衡) 读、写缓冲区默认都是 8KB: 缓冲区配置 ```java Feat.httpServer(options -> { options.readBufferSize(16 * 1024); // 读:请求解析用 options.writeBufferSize(16 * 1024); // 写:响应输出用 }) ``` * `readBufferSize` 必须**至少能容纳一个完整的 URL 或 Header 值**,否则解析直接报错。带超大 Cookie 或长 URL 的服务需要调大 * `writeBufferSize` 影响响应写盘的网络批次。大文件响应场景适当调大可以减少系统调用次数 * 两个缓冲区都按 `threadNum` 相关的池化方式分配,盲目调大**直接放大内存占用** 症状对照:日志里出现解析异常(URL/Header 被截断)→ 调 `readBufferSize`;大量小响应场景 CPU 高在 write 系统调用 → 试着调 `writeBufferSize`。没有对应症状就不要动。 ## 内存受限环境 [Section titled “内存受限环境”](#内存受限环境) `setLowMemory(true)` 关闭读缓冲区的保留策略,用吞吐换内存。适合嵌入式设备、Sidecar 这类内存紧张的场景;常规服务器不要开。 ## gzip 的收益边界 [Section titled “gzip 的收益边界”](#gzip-的收益边界) gzip 压缩对 JSON/HTML 这类文本收益明显,但要留意两点: 1. **压缩阈值**:小于阈值的内容不压缩(压缩后可能更大)。Cloud 侧在 `feat.yml` 的 `server.gzip` / `server.gzipThreshold` 配置(见[启动配置与环境配置](/feat/cloud/options/)) 2. **静态资源 classpath 模式**输出固定走 gzip(`Content-Encoding: gzip`),压缩在内存中逐块完成,这是为”jar 内小资源”设计的路径,不适合当大文件下载服务器用 CPU 紧张的服务可以关掉 gzip,把压缩交给前置的 nginx——它有专门的压缩优化和缓存。 ## 缓存树:被低估的解析加速 [Section titled “缓存树:被低估的解析加速”](#缓存树被低估的解析加速) `ServerOptions` 内部维护三棵字节缓存树(`ByteTree`),把 HTTP 报文里高频出现的字符串(方法名、协议版本、Header 名、常见 Header 值)缓存在树里,解析时按字节匹配而不是反复 new String。这是 Feat 基准成绩的重要来源之一。 对使用者的含义: * 自定义 Header 的名称种类有限且稳定的服务,可以预热:`options.getByteCache().addNode(...)` * 请求 URL 高度随机(如带随机 token 路径)的服务,URI 缓存命中率低,不要期望这部分收益 ## 调优流程建议 [Section titled “调优流程建议”](#调优流程建议) 1. **先测基线**:用 wrk 之类工具压出当前 RPS 和延迟,改任何参数前先有数字 2. **看线程栈**:`jstack` 确认业务线程在忙什么——大量 `Thread.sleep`、锁等待、外部调用等待,走异步化而不是加线程 3. **一次只改一个参数**:改完复测,没有可复现的改善就回滚 4. **对比基准时注意边界**:固定硬件、JDK、并发与网络环境(详见[性能测试](/feat/appendix/benchmark/)的解读说明) # 安全实践 > 用 WAF、BasicAuth、请求限制和错误处理,把 Feat 服务的安全面收拢到可控范围 Feat 没有提供”一键安全”的开关,但把安全所需的原语都放在了正确的位置。这一页按请求进入的顺序,逐层说明每个原语解决什么问题、怎么用、边界在哪。 | 层 | 能力 | 解决的问题 | | --- | ---------------------- | ---------------------- | | 传输层 | SslPlugin(HTTPS) | 链路加密,防窃听与篡改 | | 接入层 | WAF | 请求方法与 URI 白名单,拦截明显非法流量 | | 认证层 | BasicAuthServerHandler | 最小成本的接口认证 | | 限制层 | ServerOptions 各项上限 | 大请求、畸形 Header、慢连接攻击 | | 应用层 | HttpException / 拦截器 | 业务内的鉴权与错误响应 | ## WAF:方法与 URI 白名单 [Section titled “WAF:方法与 URI 白名单”](#waf方法与-uri-白名单) `ServerOptions` 内置了一个轻量 WAF(`tech.smartboot.feat.core.server.waf`),在 HTTP 报文解析阶段就完成校验,不消耗业务线程: WafDemo.java ```java import tech.smartboot.feat.Feat; Feat.httpServer(options -> { // 方法白名单:不在名单内的方法返回 405 options.getWafOptions() .addAllowMethod("GET") .addAllowMethod("POST"); }) .httpHandler(ctx -> ctx.Response.write("ok")) .listen(8080); ``` 提示 调用任何一个 `addAllowMethod` / `addAllowUriPrefix` / `addAllowUriSuffix` 都会自动启用 WAF,不需要单独的开关。全部不配置时 WAF 不生效,行为与之前完全一致。 除了方法,还可以限制 URI 的形状。下面的配置表示:只放行以 `/api/` 开头、或以 `.json` 结尾的请求,其余一律返回 400: ```java options.getWafOptions() .addAllowUriPrefix("/api/") .addAllowUriSuffix(".json"); ``` WAF 触发时抛出 `WafException`(`HttpException` 的子类),由框架统一渲染错误页:方法不匹配返回 `405 Method Not Allowed`,URI 不匹配返回 `400 Bad Request`。 两点使用建议: * 方法校验和 URI 校验是相互独立的,可以只用其中一个 * URI 白名单适合”API 网关收口”型服务;面向浏览器的页面服务通常不该开 URI 白名单,否则静态资源会被误拦 ## BasicAuth:最小成本的接口认证 [Section titled “BasicAuth:最小成本的接口认证”](#basicauth最小成本的接口认证) `BasicAuthServerHandler` 是一个装饰器:包装真正的业务处理器,只有请求头里的 Basic 凭据匹配时才放行,否则返回 `401` 并携带 `WWW-Authenticate: Basic realm="feat"`: BasicAuthDemo.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.handler.BasicAuthServerHandler; import tech.smartboot.feat.router.Router; Router router = new Router(); router.route("/admin/dashboard", ctx -> ctx.write("secret data")); Feat.httpServer() .httpHandler(new BasicAuthServerHandler("admin", "s3cret", router)) .listen(8080); ``` 验证一下: ```shell curl -i http://localhost:8080/admin/dashboard # HTTP/1.1 401 Unauthorized # WWW-Authenticate: Basic realm="feat" curl -i -u admin:s3cret http://localhost:8080/admin/dashboard # HTTP/1.1 200 OK ``` 警告 Basic Auth 的凭据只是 Base64 编码,**不是加密**。明文链路上任何中间节点都能读到用户名和密码,所以 BasicAuth 必须搭配 HTTPS 使用,只适合内网工具、管理后台这类低敏感场景。面向终端用户的产品请使用 Token 方案,BasicAuth 最多用来保护 Token 的颁发入口。 ## 请求限制:给恶意流量设置上限 [Section titled “请求限制:给恶意流量设置上限”](#请求限制给恶意流量设置上限) `ServerOptions` 提供了三组与安全直接相关的上限,默认值对大多数服务够用,但**对外暴露的服务应该显式检查一遍**: | 选项 | 默认值 | 作用 | | ------------------------- | ------------------- | ----------------------------------------------- | | `setMaxRequestSize(long)` | `Integer.MAX_VALUE` | 请求报文大小上限,超限返回 `413`;文件上传场景调大,纯 API 服务建议压到实际需要的值 | | `headerLimiter(int)` | `100` | 请求头数量上限,超出部分被忽略,用于抵御头部注入类攻击 | | `setIdleTimeout(long)` | `60000` 毫秒 | 连接闲置超时,回收慢连接,防止连接被占满 | HardenedOptions.java ```java Feat.httpServer(options -> { options.setMaxRequestSize(2 * 1024 * 1024); // 2MB options.headerLimiter(50); options.setIdleTimeout(30_000); }) ``` 这三项在[ServerOptions 配置](/feat/server/serveroptions/)里有更完整的参数说明。 ## 路径穿越:静态资源已内置防护 [Section titled “路径穿越:静态资源已内置防护”](#路径穿越静态资源已内置防护) 静态资源处理器会对请求路径做规范化检查,包含 `../` 或 `..\` 的请求直接返回 `403`,不会落到文件系统: 内置防护 ```text GET /static/../../etc/passwd → 403 Forbidden ``` 自研文件下载类接口时不能依赖这层保护——那是在静态资源处理器里做的,不在通用请求管道里。自己拼接文件路径时,务必先 `getCanonicalFile()` 再校验规范化结果是否仍在预期目录内。详见[静态资源服务](/feat/practices/static-files/)。 ## 错误响应与信息泄露 [Section titled “错误响应与信息泄露”](#错误响应与信息泄露) Feat 对错误响应的处理是统一的:处理器抛出的 `HttpException`(含 `WafException`)会由框架渲染成带状态码的错误页,`desc` 部分显示在页面上。 这对安全设计的含义是: * **想给客户端看的错误**,用 `HttpException(HttpStatus.XXX, "给客户端的描述")` 表达,状态码和文案都可控 * **不想给客户端看的错误**,抛普通 `RuntimeException` 即可,框架返回 500;但注意默认错误页会带上异常的 `toString()`,生产环境自定义兜底逻辑时避免把异常细节拼进响应 另外 `options.debug(true)` 会把请求和响应的原始字节流打印到控制台(红蓝两色),**只能用于本地排查,生产环境必须关闭**——它输出的不是日志,是完整的报文内容。 ## Session 安全要点 [Section titled “Session 安全要点”](#session-安全要点) 使用 [Session 与请求状态](/feat/cloud/session/)时记住三条底线: 1. Session Cookie 承载的是会话凭据,全站 HTTPS 是前提 2. Session 里只放与当前 HTTP 会话相关的临时状态,用户资料和权限放数据库/缓存,降低泄露后的爆炸半径 3. 部署多实例时用 Redis 存储,单机内存 Session 重启即失效(详见[故障排查](/feat/cloud/troubleshooting/)) ## 上线前安全检查清单 [Section titled “上线前安全检查清单”](#上线前安全检查清单) * [ ] HTTPS 已启用(SslPlugin 配置见 [HTTPS 配置](/feat/server/https/)),BasicAuth 未在明文链路上使用 * [ ] WAF 方法白名单已配置,或确认服务需要接受所有方法 * [ ] `maxRequestSize` 已压到业务实际需要的值 * [ ] `debug(true)` 已关闭 * [ ] 错误响应中不包含堆栈与内部实现细节 * [ ] 静态资源目录不含敏感文件,且没有把整个磁盘目录配成 `baseDir` ## 下一步 [Section titled “下一步”](#下一步) * 静态资源的安全配置细节:[静态资源服务](/feat/practices/static-files/) * 生产环境的日志与排查手段:[日志与可观测性](/feat/practices/logging/) # 静态资源服务 > 用内置的静态资源处理器提供文件服务:文件目录与 classpath 两种模式、目录列表、缓存与安全 Feat 内置了一个静态资源处理器 `HttpStaticResourceHandler`,对标 nginx 的静态文件能力:支持文件目录和 classpath 两种资源来源、`If-Modified-Since` 缓存协商、目录列表、内置路径穿越防护。Feat Cloud 的静态资源能力也是基于它实现的。 ## 两种资源来源 [Section titled “两种资源来源”](#两种资源来源) 创建处理器时通过 `baseDir` 指定资源位置,支持两种前缀: * 文件目录 FileServerDemo.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.handler.HttpStaticResourceHandler; Feat.httpServer() .httpHandler(new HttpStaticResourceHandler(opt -> opt.baseDir("./static"))) .listen(8080); ``` 从磁盘目录读取文件。启动时会校验目录存在性,不存在直接启动失败,避免带病上线。 * classpath ClassPathServerDemo.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.handler.HttpStaticResourceHandler; Feat.httpServer() .httpHandler(new HttpStaticResourceHandler(opt -> opt.baseDir("classpath:static"))) .listen(8080); ``` 从 classpath 读取资源,`classpath:` 后面是资源根路径。适合把前端构建产物打进 Fat Jar 的单文件交付场景。 两种模式下的请求规则一致: * `GET /` → 返回根目录下的 `index.html` * `GET /docs/` → 返回 `docs/index.html` * `GET /about`(无扩展名)→ 依次尝试 `/about`、`/about/index.html`、`/about.html`,适配”目录和同名 html 并存”的站点结构 ## 目录列表 [Section titled “目录列表”](#目录列表) 默认情况下访问目录返回 `index.html`(不存在则 404)。开发期想浏览目录内容,可以打开 `autoIndex`: ```java new HttpStaticResourceHandler(opt -> opt .baseDir("./static") .autoIndex(true)) ``` 打开后会渲染一个简单的目录列表页。**生产环境保持默认关闭**——目录列表会把服务器上的文件名全部暴露给访问者。 ## 缓存协商 [Section titled “缓存协商”](#缓存协商) 处理器实现了基于 `Last-Modified` / `If-Modified-Since` 的缓存协商: * 首次响应携带 `Last-Modified` 头 * 浏览器再次请求时带上 `If-Modified-Since`,资源未变化则返回 `304 Not Modified`,不传文件体 注意 classpath 模式下资源来自 jar 包,`Last-Modified` 取的是**服务启动时间**——jar 内容在运行期不会变,这是合理的简化。文件目录模式取的是文件真实的修改时间。 ## 内置安全防护 [Section titled “内置安全防护”](#内置安全防护) 路径穿越攻击在处理器内被直接拦截: 内置防护 ```text GET /../../etc/passwd → 403 Forbidden ``` `../` 与 `..\` 两种写法都会被拒绝,请求不会触达文件系统。这是处理器自带的兜底,但不要因此放松对自己代码的要求:自研的文件下载接口不走这条管道,需要自己校验(见[安全实践](/feat/practices/security/))。 其他两个细节也和资源安全有关: * `favicon.ico` 缺失时会尝试从 classpath 提供内置图标,避免大量 404 噪音 * 404 时优先渲染内置的 `feat-404.html` 错误页 ## Feat Cloud 中的静态资源 [Section titled “Feat Cloud 中的静态资源”](#feat-cloud-中的静态资源) Feat Cloud 默认就启用了静态资源处理,指向 `classpath:static`。你可以在 `CloudOptions` 里改: CloudOptions 定制静态资源 ```java import tech.smartboot.feat.cloud.FeatCloud; FeatCloud.cloudServer(options -> { // 改成磁盘目录 options.setStaticLocations("./static"); // 或换成 jar 内其他资源目录 // options.setStaticLocations("classpath:webapp"); }).listen(); ``` 警告 `setStaticLocations(...)` 不只是记住一个路径——它会**创建一个新的 `Router`** 来挂载静态资源处理器。如果你的应用还依赖自定义的底层路由,请注意最终生效的是哪一个 Router,必要时在 `setStaticLocations` 之后再设置路由。 静态资源配置只能通过 `CloudOptions` 的 Java API 完成(不走 `feat.yml`),编译期即确定。 ## 什么时候不适用 [Section titled “什么时候不适用”](#什么时候不适用) 静态资源处理器覆盖的是”文件映射”场景。以下需求需要自己扩展: * 带 ETag、`Cache-Control: max-age` 等更精细的缓存策略 * 断点续传(Range 请求) * 上传(上传请用 Multipart 处理,见 [HTTP 请求处理](/feat/cloud/controller/)) 这些场景可以在处理器外再包一层 `HttpHandler` 自行实现。 ## 下一步 [Section titled “下一步”](#下一步) * 给静态资源加访问控制:[安全实践](/feat/practices/security/) * 把静态资源打进 Fat Jar 交付:[部署 Feat Core 应用](/feat/practices/deploy-core/) # 测试实践 > 不依赖 Servlet 与容器,用真实端口加 Feat httpClient 给服务写可靠的集成测试 Feat 不走 Servlet 规范,所以 MockMvc、`@SpringBootTest` 这类基于 Servlet 体系的测试设施都不适用。但这不构成测试劣势——Feat 应用启动快(毫秒级)、无外部容器,**直接起真实服务做集成测试**反而是最可靠的路径。 ## 测试策略分层 [Section titled “测试策略分层”](#测试策略分层) | 层 | 测什么 | 手段 | | ----- | ---------------- | ---------------------------- | | 业务逻辑 | Service/工具类的纯逻辑 | JUnit 普通单元测试,与 Web 层解耦 | | 接口行为 | 路由、参数绑定、状态码、响应体 | 起真实服务 + Feat httpClient 请求断言 | | 编译期装配 | Bean 装配、路由注册是否生成 | 集成测试自然覆盖(启动失败即红) | Feat Cloud 的装配发生在编译期,“装配正确性”本身就该被测试覆盖——这也是为什么推荐集成测试优先:`main` 启动失败、路由缺失、Bean 注入异常,都会在测试阶段直接暴露。 ## 集成测试模板 [Section titled “集成测试模板”](#集成测试模板) 用 JUnit 5 写一个可复用的基座:`@BeforeAll` 起服务,测试用例里用 Feat 自带的 httpClient 发请求断言。 ApiTestBase.java ```java import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.HttpServer; public class ApiTestBase { static final int PORT = 18080; static HttpServer server; @BeforeAll static void setUp() { server = Feat.httpServer(options -> { options.port(PORT); }).httpHandler(ctx -> ctx.write("ok")); server.listen(PORT); } @AfterAll static void tearDown() { if (server != null) { server.shutdown(); } } } ``` HelloApiTest.java ```java import org.junit.jupiter.api.Test; import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.client.HttpResponse; import static org.junit.jupiter.api.Assertions.assertEquals; public class HelloApiTest extends ApiTestBase { @Test public void shouldReturnOk() { // submit() 返回 CompletableFuture,测试里用 join() 同步等待 HttpResponse response = Feat.httpClient("http://localhost:" + PORT) .get("/hello") .submit() .join(); assertEquals(200, response.statusCode()); assertEquals("ok", response.body()); } } ``` 写法要点: * **端口避开 8080**,防止与本机其他服务冲突;用固定端口即可,Feat 启动是毫秒级,不需要端口复用技巧 * `submit()` 返回 `CompletableFuture`,测试里 `join()` 同步等待后在主线程断言,避免异步回调里做断言的时序坑 * 每个 `@BeforeAll` 起一个服务、`@AfterAll` 里 `shutdown()`——`shutdown()` 会先执行 `shutdownHook` 再关连接,测试间互不干扰 ## Feat Cloud 应用的测试 [Section titled “Feat Cloud 应用的测试”](#feat-cloud-应用的测试) Cloud 应用入口是 `FeatCloud.cloudServer(...)`,测试基座把启动逻辑抽成方法: CloudTestBase.java 片段 ```java @BeforeAll static void setUp() { Thread serverThread = new Thread(() -> FeatCloud.cloudServer(options -> { options.port(18080); options.setPackages("com.example"); }).listen()); serverThread.setName("feat-test-server"); serverThread.setDaemon(true); // 守护线程:测试异常退出不悬挂 CI serverThread.start(); // 轮询等待端口就绪,再开始跑用例 awaitPort(18080, 5000); } ``` 两个 Cloud 特有的注意点: * `feat.yml` 是编译期输入,测试环境想用不同配置时,写独立的 `feat-test.yml` 并在测试启动参数里指定 `-Dfeat.profiles.active=test`(或用 Maven 的 surefire 系统属性) * 装配相关的测试失败通常表现为**启动抛异常**(Bean 冲突、`@Value` 无法解析),而不是某个用例失败——看到这类错误先查[故障排查](/feat/cloud/troubleshooting/) ## 什么值得测 [Section titled “什么值得测”](#什么值得测) 结合 Feat 的机制,集成测试优先覆盖这些点: 1. **路由与状态码**:每个接口的 Happy path 和主要错误分支(404、400、401) 2. **参数绑定边界**:缺失参数、类型不匹配、`@Param` 缺失时基本类型的拆箱问题 3. **配置装配**:`@Value` 占位符能否在测试 Profile 下解析 4. **拦截器行为**:鉴权拦截器的放行/短路两个分支 5. **异步接口**:确认 `future.complete` 一定被调用,否则响应会悬挂到超时 ## 下一步 [Section titled “下一步”](#下一步) * 异步接口的陷阱与正确写法:[异步处理](/feat/server/async/) * CI 里的容器化运行:[部署 Feat Core 应用](/feat/practices/deploy-core/) # 快速开始 > 从零开始构建 Feat HTTP 服务,理解请求处理模型,掌握路由、异步和配置 Feat Core 服务端适合需要直接操作 HTTP 协议、精确控制每个字节,或者希望从零开始理解 Feat 服务端模型的开发者。 如果你希望用更接近 Spring Boot 的注解式开发体验,建议从 [Feat Cloud 快速开始](/feat/cloud/) 开始。 ## 编写第一个应用 [Section titled “编写第一个应用”](#编写第一个应用) 你将构建一个最小的 HTTP 服务,访问 `http://localhost:8080` 返回 `Hello Feat`。 **前置要求**:JDK 8+、Maven 3.6+、`curl` 或浏览器。环境准备细节见[系统要求](/feat/guides/system-requirements/)。 ### 1. 添加依赖 [Section titled “1. 添加依赖”](#1-添加依赖) 在 `pom.xml` 中加入: pom.xml ```xml tech.smartboot.feat feat-core ${feat.version} ``` 注意 `${feat.version}` 请替换为实际版本。最新版本可以在 [Maven Central](https://central.sonatype.com/artifact/tech.smartboot.feat/feat-core) 查看。 ### 2. 编写服务 [Section titled “2. 编写服务”](#2-编写服务) 创建 `HelloFeat.java`: HelloFeat.java ```java import tech.smartboot.feat.Feat; public class HelloFeat { public static void main(String[] args) { Feat.httpServer().listen(); } } ``` 关键点:`Feat.httpServer()` 创建服务,`.listen()` 启动监听。 ### 3. 运行并验证 [Section titled “3. 运行并验证”](#3-运行并验证) 运行 `HelloFeat.main()`,访问 `http://localhost:8080`: * 浏览器 ![Feat 默认响应](/feat/_astro/1_2_hello_feat.DvgeTW7P.png) * curl ```shell curl -i http://localhost:8080 HTTP/1.1 200 OK Server: feat Content-Type: text/html; charset=utf-8 Hello Feat ``` 看到 `Hello Feat` 说明依赖、服务和链路都正常。 ### 自定义响应 [Section titled “自定义响应”](#自定义响应) 接管请求处理,返回自定义响应: HelloWorld.java ```java import tech.smartboot.feat.Feat; public class HelloWorld { public static void main(String[] args) { Feat.httpServer() .httpHandler(request -> request.getResponse().write("Hello World")) .listen(8081); } } ``` * 浏览器 ![自定义响应](/feat/_astro/1_2_hello_world.DHHDPsNH.png) * curl ```shell curl -i http://localhost:8081 HTTP/1.1 200 OK Server: feat Content-Type: text/html; charset=utf-8 Hello World ``` Feat Core 的模型:创建服务 → 注册处理器 → 启动监听。 ## 下一步:配置你的服务 [Section titled “下一步:配置你的服务”](#下一步配置你的服务) 刚才的示例使用了默认配置(端口 8080、线程数自动等)。在实际开发中,你几乎总是需要调整这些参数:换端口、调线程、开调试、配 HTTPS… 继续阅读 [ServerOptions 配置](/feat/server/serveroptions/),了解如何按场景定制服务参数。 # 异步处理 > 掌握 Feat 服务端异步处理模型:将耗时操作移出主线程,提升并发处理能力 当请求处理涉及数据库查询、远程 HTTP 调用、文件 IO 等耗时操作时,应该使用异步处理模式。这样可以将工作移出 Feat 的主处理线程,避免阻塞其他请求,显著提升并发处理能力。 ## 同步 vs 异步 [Section titled “同步 vs 异步”](#同步-vs-异步) | 特性 | 同步处理 | 异步处理 | | ----- | ---------- | ---------------------- | | 执行线程 | 主处理线程 | 自定义线程池 | | 阻塞影响 | 阻塞主线程,降低并发 | 不阻塞主线程 | | 适用场景 | 简单、快速的操作 | 耗时操作(>10ms) | | 代码复杂度 | 简单 | 需要管理 CompletableFuture | 提示 选择建议:纯内存操作、简单数据转换 → 同步;数据库查询、外部 API 调用、文件 IO → 异步 ## 异步处理模型 [Section titled “异步处理模型”](#异步处理模型) Feat 的异步处理依赖 `HttpHandler` 接口的双参数方法: ```java void handle(HttpRequest request, CompletableFuture future) ``` 使用异步模式只需做两件事: 1. **将耗时逻辑放到自定义线程池执行** 2. **完成后调用 `future.complete(null)` 通知 Feat** 警告 如果忘记调用 `future.complete(null)`,请求将一直挂起,客户端收不到响应。 ## 基础用法 [Section titled “基础用法”](#基础用法) ### 创建异步处理器 [Section titled “创建异步处理器”](#创建异步处理器) ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.HttpHandler; import tech.smartboot.feat.core.server.HttpRequest; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class AsyncDemo { public static void main(String[] args) { // 创建业务线程池 ExecutorService executor = Executors.newFixedThreadPool(4); Feat.httpServer() .httpHandler(new HttpHandler() { @Override public void handle(HttpRequest request, CompletableFuture future) { executor.execute(() -> { try { // 模拟耗时操作 Thread.sleep(100); request.getResponse().write("Async response"); } catch (Exception e) { e.printStackTrace(); } finally { // 必须调用,否则请求不会结束 future.complete(null); } }); } @Override public void handle(HttpRequest request) { // 异步模式下这里不处理主逻辑 } }) .listen(8080); } } ``` ### 简化写法 [Section titled “简化写法”](#简化写法) 如果不需要复杂的线程池管理,可以按同样的模式组织异步处理器: ```java Feat.httpServer() .httpHandler(new HttpHandler() { @Override public void handle(HttpRequest request, CompletableFuture future) { CompletableFuture.supplyAsync(() -> { // 耗时操作 return fetchDataFromDatabase(); }).thenAccept(result -> { request.getResponse().write(result); future.complete(null); // 完成后通知 Feat }); } @Override public void handle(HttpRequest request) { } }) .listen(8080); ``` ## 回调触发流程 [Section titled “回调触发流程”](#回调触发流程) 以下泳道图展示了异步请求的处理流程: ``` sequenceDiagram autonumber participant Client as 客户端 participant Feat as Feat 服务器 participant ThreadPool as 业务线程池 participant Handler as HttpHandler Client->>Feat: HTTP Request Feat->>Handler: handle(request, future) Handler->>ThreadPool: 提交耗时任务 Handler-->>Feat: 立即返回(不阻塞) Feat->>Feat: 继续处理其他请求 ThreadPool->>ThreadPool: 执行耗时操作 ThreadPool->>Feat: 写入响应 ThreadPool->>Handler: future.complete(null) Handler-->>Feat: 通知请求完成 Feat-->>Client: HTTP Response ``` **流程说明:** 1. **接收请求** - Feat 主线程接收 HTTP 请求 2. **调用处理器** - 调用 `handle(request, future)` 方法 3. **提交任务** - 将耗时操作提交到业务线程池 4. **立即返回** - 主线程立即返回,继续处理其他请求 5. **异步执行** - 业务线程池执行耗时操作 6. **完成通知** - 调用 `future.complete(null)` 通知 Feat 请求完成 7. **发送响应** - Feat 将响应发送给客户端 ## 完整示例 [Section titled “完整示例”](#完整示例) ### 数据库查询场景 [Section titled “数据库查询场景”](#数据库查询场景) AsyncDatabaseDemo.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.HttpHandler; import tech.smartboot.feat.core.server.HttpRequest; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class AsyncDatabaseDemo { // 业务线程池 private static final ExecutorService executor = Executors.newFixedThreadPool(8); public static void main(String[] args) { Feat.httpServer(options -> options.debug(true)) .httpHandler(new HttpHandler() { @Override public void handle(HttpRequest request, CompletableFuture future) { String userId = request.getParameter("id"); executor.execute(() -> { try { // 模拟数据库查询 User user = queryDatabase(userId); if (user != null) { request.getResponse().write( "{\"id\":\"" + user.id + "\",\"name\":\"" + user.name + "\"}" ); } else { request.getResponse().setHttpStatus(404); request.getResponse().write("User not found"); } } catch (Exception e) { request.getResponse().setHttpStatus(500); request.getResponse().write("Internal error"); e.printStackTrace(); } finally { // 无论成功失败,都要完成 future future.complete(null); } }); } @Override public void handle(HttpRequest request) { } }) .listen(8080); } static User queryDatabase(String userId) throws InterruptedException { // 模拟数据库查询耗时 Thread.sleep(50); return new User(userId, "User_" + userId); } static class User { String id, name; User(String id, String name) { this.id = id; this.name = name; } } } ``` ### 外部 API 调用场景 [Section titled “外部 API 调用场景”](#外部-api-调用场景) AsyncHttpCallDemo.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.HttpHandler; import tech.smartboot.feat.core.server.HttpRequest; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class AsyncHttpCallDemo { private static final ExecutorService executor = Executors.newFixedThreadPool(4); public static void main(String[] args) { Feat.httpServer() .httpHandler(new HttpHandler() { @Override public void handle(HttpRequest request, CompletableFuture future) { executor.execute(() -> { try { // 调用外部 API String result = callExternalApi(); request.getResponse().write(result); } catch (Exception e) { request.getResponse().setHttpStatus(502); request.getResponse().write("External service error"); } finally { future.complete(null); } }); } @Override public void handle(HttpRequest request) { } }) .listen(8080); } static String callExternalApi() throws InterruptedException { // 模拟 HTTP 调用耗时 Thread.sleep(200); return "{\"status\":\"ok\",\"data\":[]}"; } } ``` # HTTPS 配置 > 掌握 Feat HTTPS 服务器的完整配置:证书加载、SSL 插件配置和 SSLEngine 高级用法 Feat 通过 `SslPlugin` 插件为 HTTP 服务提供 HTTPS 支持。你不需要重写业务代码,只需添加 SSL 插件即可将 HTTP 服务升级为 HTTPS。 ## 创建与配置 [Section titled “创建与配置”](#创建与配置) ### 基础 HTTPS 服务 [Section titled “基础 HTTPS 服务”](#基础-https-服务) 使用 PEM 格式证书创建 HTTPS 服务: ```java import io.github.smartboot.socket.extension.plugins.SslPlugin; import io.github.smartboot.socket.extension.ssl.factory.PemServerSSLContextFactory; import tech.smartboot.feat.Feat; import java.io.InputStream; public class HttpsDemo { public static void main(String[] args) throws Exception { // 加载证书文件 InputStream certPem = HttpsDemo.class.getClassLoader() .getResourceAsStream("example.org.pem"); InputStream keyPem = HttpsDemo.class.getClassLoader() .getResourceAsStream("example.org-key.pem"); // 创建 SSL 插件 SslPlugin sslPlugin = new SslPlugin( new PemServerSSLContextFactory(certPem, keyPem) ); // 启动 HTTPS 服务 Feat.httpServer(opt -> opt.addPlugin(sslPlugin)) .httpHandler(req -> req.getResponse().write("Hello HTTPS")) .listen(); } } ``` 警告 证书文件必须通过 `getResourceAsStream()` 从 classpath 加载,确保文件已打包进 resources 目录。 ### 证书准备 [Section titled “证书准备”](#证书准备) 本地开发推荐使用 [mkcert](https://github.com/FiloSottile/mkcert) 生成本地可信证书: ```shell # 安装 mkcert brew install mkcert # macOS # 或下载对应系统的二进制文件 # 生成证书 mkcert example.com "*.example.com" localhost 127.0.0.1 ::1 ``` 执行后会生成两个文件: * `example.com+4.pem` - 证书文件 * `example.com+4-key.pem` - 私钥文件 将这两个文件放入项目的 `src/main/resources` 目录。 ## SSL 插件配置 [Section titled “SSL 插件配置”](#ssl-插件配置) ### 自动生成的证书 [Section titled “自动生成的证书”](#自动生成的证书) 测试环境可以使用 `AutoServerSSLContextFactory` 自动生成自签名证书: ```java import io.github.smartboot.socket.extension.ssl.factory.AutoServerSSLContextFactory; SslPlugin sslPlugin = new SslPlugin(new AutoServerSSLContextFactory()); Feat.httpServer(opt -> opt.addPlugin(sslPlugin)) .httpHandler(req -> req.getResponse().write("Auto HTTPS")) .listen(); ``` 提示 `AutoServerSSLContextFactory` 适合快速测试,生产环境请使用正式签发的证书。 ### 多证书配置 [Section titled “多证书配置”](#多证书配置) 支持为不同域名配置不同证书: ```java import io.github.smartboot.socket.extension.ssl.factory.PemServerSSLContextFactory; import java.util.HashMap; import java.util.Map; // 配置多域名证书 Map certMap = new HashMap<>(); certMap.put("example.com", new PemServerSSLContextFactory( certPem1, keyPem1 )); certMap.put("api.example.com", new PemServerSSLContextFactory( certPem2, keyPem2 )); SslPlugin sslPlugin = new SslPlugin( (socketAddress, engine) -> { // 根据域名返回对应证书 return certMap.get(engine.getPeerHost()); } ); ``` ## 处理 HTTPS 请求 [Section titled “处理 HTTPS 请求”](#处理-https-请求) ### 基础响应 [Section titled “基础响应”](#基础响应) HTTPS 请求的处理方式与 HTTP 完全相同: ```java Feat.httpServer(opt -> opt.addPlugin(sslPlugin)) .httpHandler(req -> { // 获取请求信息 String method = req.getMethod(); String uri = req.getRequestURI(); // 设置响应 req.getResponse() .setContentType("application/json") .write("{\"status\":\"ok\"}"); }) .listen(8443); ``` ### 获取 SSL 信息 [Section titled “获取 SSL 信息”](#获取-ssl-信息) 通过 `SSLEngine` 获取 TLS 连接详情: ```java import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLSession; Feat.httpServer(opt -> opt.addPlugin(sslPlugin)) .httpHandler(req -> { SSLEngine engine = req.getSslEngine(); if (engine != null) { SSLSession session = engine.getSession(); // 获取协议版本 String protocol = session.getProtocol(); // TLSv1.3 // 获取加密套件 String cipher = session.getCipherSuite(); // TLS_AES_256_GCM_SHA384 req.getResponse().write("Protocol: " + protocol); } else { req.getResponse().write("Not HTTPS"); } }) .listen(); ``` 注意 默认情况下 `req.getSslEngine()` 可能返回 `null`。如需确保获取到 `SSLEngine`,需要在创建 `SslPlugin` 时通过回调设置 ThreadLocal。 ## 高级用法 [Section titled “高级用法”](#高级用法) ### 启用 SSLEngine 访问 [Section titled “启用 SSLEngine 访问”](#启用-sslengine-访问) 如需在业务代码中访问 `SSLEngine`,需要在插件初始化时进行设置: ```java import io.github.smartboot.socket.extension.ssl.factory.AutoServerSSLContextFactory; import tech.smartboot.feat.core.server.HttpRequest; import javax.net.ssl.SSLEngine; import java.util.function.Consumer; SslPlugin sslPlugin = new SslPlugin( new AutoServerSSLContextFactory(), (Consumer) sslEngine -> { // 将 SSLEngine 存入 ThreadLocal HttpRequest.SSL_ENGINE_THREAD_LOCAL.set(sslEngine); } ); Feat.httpServer(opt -> opt.addPlugin(sslPlugin)) .httpHandler(req -> { SSLEngine engine = req.getSslEngine(); if (engine != null) { req.getResponse().write("Cipher: " + engine.getSession().getCipherSuite()); } }) .listen(); ``` ### 客户端认证(mTLS) [Section titled “客户端认证(mTLS)”](#客户端认证mtls) 启用双向 TLS 认证,要求客户端提供证书: ```java import io.github.smartboot.socket.extension.ssl.ClientAuth; SslPlugin sslPlugin = new SslPlugin( new PemServerSSLContextFactory(certPem, keyPem), ClientAuth.REQUIRE // 要求客户端证书 ); Feat.httpServer(opt -> opt.addPlugin(sslPlugin)) .httpHandler(req -> { SSLEngine engine = req.getSslEngine(); if (engine != null) { // 获取客户端证书信息 javax.security.cert.X509Certificate[] certs = engine.getSession().getPeerCertificateChain(); req.getResponse().write("Client: " + certs[0].getSubjectDN()); } }) .listen(); ``` **ClientAuth 模式:** | 模式 | 说明 | | --------- | ------------ | | `NONE` | 不验证客户端证书(默认) | | `REQUEST` | 请求客户端证书,但不强制 | | `REQUIRE` | 强制要求客户端证书 | ## 完整示例 [Section titled “完整示例”](#完整示例) ### 基础 HTTPS 服务 [Section titled “基础 HTTPS 服务”](#基础-https-服务-1) HttpsBasicDemo.java ```java import io.github.smartboot.socket.extension.plugins.SslPlugin; import io.github.smartboot.socket.extension.ssl.factory.PemServerSSLContextFactory; import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.common.HeaderName; import tech.smartboot.feat.core.common.HttpStatus; import java.io.InputStream; public class HttpsBasicDemo { public static void main(String[] args) throws Exception { // 1. 加载证书 InputStream certPem = HttpsBasicDemo.class.getClassLoader() .getResourceAsStream("server.pem"); InputStream keyPem = HttpsBasicDemo.class.getClassLoader() .getResourceAsStream("server-key.pem"); if (certPem == null || keyPem == null) { System.err.println("证书文件未找到"); return; } // 2. 创建 SSL 插件 SslPlugin sslPlugin = new SslPlugin( new PemServerSSLContextFactory(certPem, keyPem) ); // 3. 启动 HTTPS 服务 Feat.httpServer(opt -> { opt.addPlugin(sslPlugin); opt.port(8443); }) .httpHandler(req -> { // 获取协议信息 String protocol = req.getProtocol(); // HTTPS/1.1 req.getResponse() .setHeader(HeaderName.CONTENT_TYPE, "application/json") .write("{\"message\":\"Secure connection established\"}"); }) .listen(); System.out.println("HTTPS server started on https://localhost:8443"); } } ``` ### 带 SSL 信息输出的服务 [Section titled “带 SSL 信息输出的服务”](#带-ssl-信息输出的服务) HttpsInfoDemo.java ```java import io.github.smartboot.socket.extension.plugins.SslPlugin; import io.github.smartboot.socket.extension.ssl.factory.AutoServerSSLContextFactory; import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.HttpRequest; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLSession; import java.util.function.Consumer; public class HttpsInfoDemo { public static void main(String[] args) throws Exception { // 创建带 SSLEngine 回调的 SSL 插件 SslPlugin sslPlugin = new SslPlugin( new AutoServerSSLContextFactory(), (Consumer) sslEngine -> { HttpRequest.SSL_ENGINE_THREAD_LOCAL.set(sslEngine); } ); Feat.httpServer(opt -> opt.addPlugin(sslPlugin)) .httpHandler(req -> { SSLEngine engine = req.getSslEngine(); StringBuilder info = new StringBuilder(); if (engine != null) { SSLSession session = engine.getSession(); info.append("Protocol: ").append(session.getProtocol()).append("\n"); info.append("Cipher Suite: ").append(session.getCipherSuite()).append("\n"); info.append("Peer Host: ").append(session.getPeerHost()).append("\n"); } req.getResponse().write(info.toString()); }) .listen(); } } ``` ## 常见问题 [Section titled “常见问题”](#常见问题) ### 浏览器提示证书不受信任 [Section titled “浏览器提示证书不受信任”](#浏览器提示证书不受信任) **原因**:使用的是自签名证书。 **解决**: * 本地开发使用 `mkcert` 生成可信证书 * 生产环境使用正规 CA 签发的证书 * 测试环境可手动将证书添加到浏览器信任列表 ### 证书文件加载失败 [Section titled “证书文件加载失败”](#证书文件加载失败) **排查清单**: 1. 确认文件名与代码中一致 2. 确认文件在 `src/main/resources` 目录下 3. 确认文件已打包进 jar(检查构建输出) 4. 使用绝对路径测试: ```java InputStream cert = new FileInputStream("/absolute/path/to/cert.pem"); ``` ### HTTPS 无法访问 [Section titled “HTTPS 无法访问”](#https-无法访问) **检查项**: 1. 确认访问的是 `https://` 而非 `http://` 2. 确认端口正确(默认 8080,除非显式指定) 3. 确认防火墙允许该端口 4. 检查控制台是否有 SSL 相关错误日志 ### SSLEngine 返回 null [Section titled “SSLEngine 返回 null”](#sslengine-返回-null) **原因**:默认情况下 `SSLEngine` 不会自动设置到请求对象。 **解决**:创建 `SslPlugin` 时添加回调: ```java SslPlugin sslPlugin = new SslPlugin( sslContextFactory, (Consumer) sslEngine -> { HttpRequest.SSL_ENGINE_THREAD_LOCAL.set(sslEngine); } ); ``` ### HTTP/2 支持 [Section titled “HTTP/2 支持”](#http2-支持) Feat 的 HTTPS 支持 HTTP/2 协议,无需额外配置。客户端使用 ALPN 协商协议版本: ```java // 服务端代码无需修改,自动支持 HTTP/2 Feat.httpServer(opt -> opt.addPlugin(sslPlugin)) .httpHandler(req -> { // req.getProtocol() 返回 HTTP/2.0 或 HTTP/1.1 req.getResponse().write("Protocol: " + req.getProtocol()); }) .listen(); ``` # Router 路由 > 掌握 Feat Router 的完整功能:路由注册、路径参数、Session 管理和拦截器机制 Router 是 Feat 提供的路由管理器,让你可以在一个服务里处理多个路径,支持路径参数、Session 管理和拦截器等高级功能。 ## 基础用法 [Section titled “基础用法”](#基础用法) ### 创建 Router [Section titled “创建 Router”](#创建-router) 使用 Router 注册多个路由处理器: ```java Router router = new Router(); router.route("/", ctx -> ctx.Response.write("Welcome")); router.route("/users", ctx -> ctx.Response.write("User list")); Feat.httpServer().httpHandler(router).listen(8080); ``` ### 路径参数 [Section titled “路径参数”](#路径参数) 使用 `:paramName` 语法捕获 URL 中的动态值: ```java // 捕获 /users/123 中的 123 router.route("/users/:id", ctx -> { String userId = ctx.pathParam("id"); ctx.Response.write("User ID: " + userId); }); // 多个路径参数 router.route("/users/:userId/orders/:orderId", ctx -> { ctx.Response.write("User: " + ctx.pathParam("userId")); }); ``` 提示 路径参数名称区分大小写,`pathParam("id")` 和 `pathParam("ID")` 是不同的参数。 ### HTTP 方法路由 [Section titled “HTTP 方法路由”](#http-方法路由) 同一路径支持不同 HTTP 方法: ```java router.route("/articles", "GET", ctx -> { /* ... */ }); router.route("/articles", "POST", ctx -> { /* ... */ }); router.route("/articles", "PUT", ctx -> { /* ... */ }); router.route("/articles", "DELETE", ctx -> { /* ... */ }); ``` ## 路由匹配机制 [Section titled “路由匹配机制”](#路由匹配机制) ### 匹配模式 [Section titled “匹配模式”](#匹配模式) | 模式 | 匹配示例 | 说明 | | ------------ | ------------------- | ------------ | | `/users` | `/users` | 精确匹配 | | `/users/:id` | `/users/42` | `:id` 捕获路径参数 | | `/static/*` | `/static/js/app.js` | `*` 匹配任意后续路径 | | `*.html` | `/page.html` | 后缀匹配 | ### 匹配优先级 [Section titled “匹配优先级”](#匹配优先级) 当多个规则可能匹配同一请求时,Router 按以下优先级选择: 1. **精确匹配**(最高) 2. **路径参数匹配** 3. **通配符匹配**(最低) ```java router.route("/users/profile", ctx -> { /* 精确匹配优先 */ }); router.route("/users/:id", ctx -> { /* 路径参数次之 */ }); router.route("/users/*", ctx -> { /* 通配符兜底 */ }); ``` 访问 `/users/profile` 时,**精确匹配**胜出。 提示 将更具体的路径放在前面注册,有助于代码可读性。 ## 请求处理 [Section titled “请求处理”](#请求处理) Router 支持同步和异步两种处理模式: * 同步处理 ```java // 适合简单操作 router.route("/sync", ctx -> { ctx.Response.write("同步响应"); }); ``` **适合场景**:纯内存操作、简单数据转换、快速状态查询 * 异步处理 ```java // 适合耗时操作 router.route("/async", (ctx, future) -> { CompletableFuture.supplyAsync(() -> fetchData()) .thenAccept(result -> { ctx.Response.write(result); future.complete(null); // 必须调用! }); }); ``` **适合场景**:耗时操作(>10ms)、外部 API 调用、数据库查询、文件 IO 提示 异步模式不会阻塞线程,适合高并发场景。同步模式代码更简洁,适合简单接口。异步处理的完整模型(线程池管理、回调触发流程、常见陷阱)见[异步处理](/feat/server/async/)。 ## 中间件机制 [Section titled “中间件机制”](#中间件机制) ### Session 管理 [Section titled “Session 管理”](#session-管理) Router 内置基于 Cookie 的 Session 管理: ```java // 登录:创建 Session router.route("/login", ctx -> { Session session = ctx.session(); session.put("user", ctx.Request.getParameter("username")); ctx.Response.write("登录成功"); }); // 读取 Session router.route("/profile", ctx -> { String user = ctx.session().get("user"); if (user == null) { ctx.Response.setHttpStatus(401); return; } ctx.Response.write("用户: " + user); }); // 注销:销毁 Session router.route("/logout", ctx -> { ctx.session().invalidate(); }); ``` 验证流程: ```shell # 登录(-c 保存 Cookie) curl -c cookies.txt "http://localhost:8080/login?username=alice" # 访问个人资料(-b 携带 Cookie) curl -b cookies.txt http://localhost:8080/profile ``` 警告 默认的内存 Session 在应用重启后数据丢失,且不支持分布式共享。生产环境建议实现 `SessionManager` 接口,使用 Redis 等外部存储。 ### 拦截器 [Section titled “拦截器”](#拦截器) 拦截器让你可以在请求到达处理器前执行鉴权、日志等逻辑: ```java // 拦截 /admin/* 路径 router.addInterceptor("/admin/*", (ctx, future, chain) -> { String token = ctx.Request.getHeader("X-Token"); if (!"secret-token".equals(token)) { ctx.Response.setHttpStatus(HttpStatus.FORBIDDEN); future.complete(null); return; // 不调用 chain.proceed(),请求中断 } chain.proceed(ctx, future); // 鉴权通过,继续执行 }); ``` #### 多拦截器链式处理 [Section titled “多拦截器链式处理”](#多拦截器链式处理) 拦截器按注册顺序执行,可以形成处理链: ```java // 拦截器 1:请求日志 router.addInterceptor("/*", (ctx, future, chain) -> { System.out.println(ctx.Request.getMethod() + " " + ctx.Request.getRequestURI()); chain.proceed(ctx, future); }); // 拦截器 2:CORS 处理 router.addInterceptor("/api/*", (ctx, future, chain) -> { ctx.Response.setHeader("Access-Control-Allow-Origin", "*"); chain.proceed(ctx, future); }); ``` 以下泳道图展示了多拦截器的执行流程: ``` sequenceDiagram participant Client as 客户端 participant I1 as 日志拦截器 participant I2 as CORS拦截器 participant Handler as 路由处理器 Client->>I1: HTTP Request I1->>I2: chain.proceed() I2->>Handler: chain.proceed() Handler-->>I2: 处理完成 I2-->>I1: 返回 I1-->>Client: HTTP Response ``` 提示 拦截器执行顺序遵循”先进先出”原则。轻量级拦截器(如日志)建议放在前面,尽早中断无效请求。 ## 实战示例 [Section titled “实战示例”](#实战示例) ### RESTful API 服务 [Section titled “RESTful API 服务”](#restful-api-服务) UserApiDemo.java ```java Router router = new Router(); // 获取所有用户 router.route("/api/users", "GET", ctx -> { ctx.Response.write(users.toString()); }); // 获取单个用户 router.route("/api/users/:id", "GET", ctx -> { String user = users.get(ctx.pathParam("id")); ctx.Response.write(user != null ? user : "Not found"); }); // 创建用户 router.route("/api/users", "POST", ctx -> { users.put(ctx.Request.getParameter("id"), ctx.Request.getParameter("name")); ctx.Response.setHttpStatus(HttpStatus.CREATED); }); Feat.httpServer().httpHandler(router).listen(8080); ``` ### 测试命令 [Section titled “测试命令”](#测试命令) ```shell # 创建用户 curl -X POST "http://localhost:8080/api/users?id=1&name=Alice" # 获取用户 curl http://localhost:8080/api/users/1 # 获取所有用户 curl http://localhost:8080/api/users ``` ## 参考指南 [Section titled “参考指南”](#参考指南) ### 常见问题 [Section titled “常见问题”](#常见问题) #### 路由返回 404 [Section titled “路由返回 404”](#路由返回-404) 排查清单: 1. 确认 `router` 已传给 `httpHandler()` 2. 检查路径是否完全一致(注意末尾 `/`) 3. 路径参数格式是 `:paramName`,不是 `{param}` 4. 检查 HTTP 方法是否匹配 ```java // 注意:这两个路由是不同的 router.route("/users", handler); // 匹配 /users router.route("/users/", handler); // 匹配 /users/ ``` #### 拦截器不生效 [Section titled “拦截器不生效”](#拦截器不生效) `/admin/*` 匹配 `/admin/users` 但不匹配 `/admin` 本身: ```java // 如需包含 /admin,额外注册 router.addInterceptor("/admin", interceptor); // 匹配 /admin router.addInterceptor("/admin/*", interceptor); // 匹配 /admin/xxx ``` #### Session 数据丢失 [Section titled “Session 数据丢失”](#session-数据丢失) 1. curl 需显式指定 `-c` 和 `-b` 参数保存/携带 Cookie 2. 浏览器可能禁用了第三方 Cookie 3. Session 默认 30 分钟过期 #### 异步请求不返回 [Section titled “异步请求不返回”](#异步请求不返回) 异步处理必须调用 `future.complete(null)`,否则请求不会结束: ```java router.route("/async", (ctx, future) -> { doAsyncWork(result -> { ctx.Response.write(result); future.complete(null); // 必须调用! }); }); ``` ### 性能建议 [Section titled “性能建议”](#性能建议) 1. **路由设计**:避免过度使用通配符,合理规划 API 层级 2. **拦截器**:轻量级拦截器放前面,尽早中断无效请求 3. **Session**:仅存储必要数据,分布式环境使用外部存储 4. **异步选择**:耗时操作使用异步,简单操作使用同步 # ServerOptions 配置 > 掌握 Feat HTTP 服务器的配置选项,按场景调整端口、线程、缓冲区、安全等参数 在[快速开始](/feat/server/)中,我们用默认配置启动了服务。但在实际开发中,你需要根据场景调整端口、线程数、缓冲区大小等参数。本章介绍 Feat 提供的所有配置选项。 ## 两种配置方式 [Section titled “两种配置方式”](#两种配置方式) Feat 提供两种配置 `ServerOptions` 的方式: OptionsDemo.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.ServerOptions; public class OptionsDemo { public static void main(String[] args) { // 方式 1:Lambda 配置(推荐) Feat.httpServer(options -> { options.port(9090); options.threadNum(8); options.debug(true); }).listen(); // 方式 2:直接创建配置对象 ServerOptions options = new ServerOptions(); options.port(9090); options.threadNum(8); Feat.httpServer(options).listen(); } } ``` 两种方式完全等价,Lambda 方式更简洁,是推荐的写法。 ## 基础配置 [Section titled “基础配置”](#基础配置) ### 端口与主机 [Section titled “端口与主机”](#端口与主机) PortHostDemo.java ```java import tech.smartboot.feat.Feat; public class PortHostDemo { public static void main(String[] args) { Feat.httpServer(options -> { // 监听端口,默认 8080 options.port(9090); // 监听地址,默认 "0.0.0.0"(所有网卡) options.host("127.0.0.1"); }).httpHandler(req -> { req.getResponse().write("Server running on 127.0.0.1:9090"); }).listen(); } } ``` 验证: ```shell curl http://127.0.0.1:9090 ``` 提示 `host("127.0.0.1")` 表示只接受本机连接,适合开发环境。生产环境通常保持默认 `"0.0.0.0"`。 ## 性能配置 [Section titled “性能配置”](#性能配置) ### 线程数 [Section titled “线程数”](#线程数) ThreadConfigDemo.java ```java import tech.smartboot.feat.Feat; public class ThreadConfigDemo { public static void main(String[] args) { Feat.httpServer(options -> { // 设置服务线程数为 8 // 默认值为 CPU 核心数(至少为 2) options.threadNum(8); }).httpHandler(req -> { req.getResponse().write("Thread config demo"); }).listen(); } } ``` 线程数的选择建议: | 应用类型 | 建议线程数 | 说明 | | ------- | ------------ | -------------------- | | IO 密集型 | CPU 核数 × 2-4 | 等待网络/数据库时线程可切换处理其他请求 | | CPU 密集型 | CPU 核数 × 1-2 | 避免过多线程导致上下文切换开销 | | 混合场景 | 从默认值开始压测调整 | 根据实际负载测试确定最佳值 | ### 缓冲区大小 [Section titled “缓冲区大小”](#缓冲区大小) BufferConfigDemo.java ```java import tech.smartboot.feat.Feat; public class BufferConfigDemo { public static void main(String[] args) { Feat.httpServer(options -> { // 读缓冲区大小,默认 8KB options.readBufferSize(16 * 1024); // 写缓冲区大小,默认 8KB options.writeBufferSize(16 * 1024); }).httpHandler(req -> { req.getResponse().write("Buffer config demo"); }).listen(); } } ``` 警告 读缓冲区大小至少要能容纳一个完整的 URL 或 Header 值,否则将触发异常。处理大请求体(如文件上传)时需要适当增大。 ## 安全与限制配置 [Section titled “安全与限制配置”](#安全与限制配置) ### 请求大小限制 [Section titled “请求大小限制”](#请求大小限制) RequestLimitDemo.java ```java import tech.smartboot.feat.Feat; public class RequestLimitDemo { public static void main(String[] args) { Feat.httpServer(options -> { // 最大请求报文大小,默认 Integer.MAX_VALUE // 这里限制为 10MB options.setMaxRequestSize(10 * 1024 * 1024); // HTTP 请求头数量上限,默认 100 options.headerLimiter(50); }).httpHandler(req -> { req.getResponse().write("Request limit demo"); }).listen(); } } ``` ### 连接超时 [Section titled “连接超时”](#连接超时) TimeoutDemo.java ```java import tech.smartboot.feat.Feat; public class TimeoutDemo { public static void main(String[] args) { Feat.httpServer(options -> { // 连接闲置超时时间(毫秒),默认 60000(1分钟) options.setIdleTimeout(30000); // 30秒 }).httpHandler(req -> { req.getResponse().write("Timeout demo"); }).listen(); } } ``` ## 调试配置 [Section titled “调试配置”](#调试配置) ### 启用调试模式 [Section titled “启用调试模式”](#启用调试模式) DebugDemo.java ```java import tech.smartboot.feat.Feat; public class DebugDemo { public static void main(String[] args) { Feat.httpServer(options -> { // 启用调试模式,控制台会打印请求和响应详情 options.debug(true); }).httpHandler(req -> { req.getResponse().write("Debug mode enabled"); }).listen(); } } ``` 启动后,控制台会输出类似: ```text [DEBUG] Request: GET / HTTP/1.1 [DEBUG] Headers: {Host=localhost:8080, User-Agent=curl/7.64.1} [DEBUG] Response: HTTP/1.1 200 OK ``` 警告 生产环境务必关闭调试模式,避免性能下降和敏感信息泄露。 ## 高级配置 [Section titled “高级配置”](#高级配置) ### 代理协议支持 [Section titled “代理协议支持”](#代理协议支持) 当服务器部署在 Nginx、HAProxy 等代理后面时,启用此选项可以获取客户端真实 IP: ProxyProtocolDemo.java ```java import tech.smartboot.feat.Feat; public class ProxyProtocolDemo { public static void main(String[] args) { Feat.httpServer(options -> { // 启用代理协议支持 options.proxyProtocolSupport(); }).httpHandler(req -> { // 可以通过 req.getRemoteAddr() 获取真实客户端 IP req.getResponse().write("Real client IP: " + req.getRemoteAddr()); }).listen(); } } ``` ### 低内存模式 [Section titled “低内存模式”](#低内存模式) 在资源受限环境(如嵌入式设备)中启用: LowMemoryDemo.java ```java import tech.smartboot.feat.Feat; public class LowMemoryDemo { public static void main(String[] args) { Feat.httpServer(options -> { // 启用低内存模式 options.setLowMemory(true); }).httpHandler(req -> { req.getResponse().write("Low memory mode"); }).listen(); } } ``` ### 关闭钩子 [Section titled “关闭钩子”](#关闭钩子) ShutdownHookDemo.java ```java import tech.smartboot.feat.Feat; public class ShutdownHookDemo { public static void main(String[] args) { Feat.httpServer(options -> { // 设置服务器关闭钩子 options.shutdownHook(() -> { System.out.println("Server is shutting down..."); // 执行资源清理、状态保存等操作 }); }).httpHandler(req -> { req.getResponse().write("Shutdown hook demo"); }).listen(); } } ``` ## 配置参数速查表 [Section titled “配置参数速查表”](#配置参数速查表) | 配置项 | 方法 | 默认值 | 说明 | | ----------- | ------------------------- | ------------------ | ---------- | | 端口 | `port(int)` | 8080 | 服务监听端口 | | 主机 | `host(String)` | ”0.0.0.0” | 服务监听地址 | | 线程数 | `threadNum(int)` | CPU 核心数 | IO 处理线程数 | | 读缓冲区 | `readBufferSize(int)` | 8192 (8KB) | 请求数据缓冲区 | | 写缓冲区 | `writeBufferSize(int)` | 8192 (8KB) | 响应数据缓冲区 | | 最大请求大小 | `setMaxRequestSize(long)` | Integer.MAX\_VALUE | 请求体大小限制 | | Header 数量限制 | `headerLimiter(int)` | 100 | 请求头数量上限 | | 连接超时 | `setIdleTimeout(long)` | 60000ms | 连接闲置超时 | | 调试模式 | `debug(boolean)` | false | 打印请求响应详情 | | 低内存模式 | `setLowMemory(boolean)` | false | 减少内存占用 | | 代理协议 | `proxyProtocolSupport()` | - | 获取真实客户端 IP | ## 场景化配置建议 [Section titled “场景化配置建议”](#场景化配置建议) ### 开发环境 [Section titled “开发环境”](#开发环境) ```java Feat.httpServer(options -> { options.port(8080); options.debug(true); // 开启调试便于排查问题 }) ``` ### 生产环境(通用) [Section titled “生产环境(通用)”](#生产环境通用) ```java Feat.httpServer(options -> { options.port(80); options.threadNum(Runtime.getRuntime().availableProcessors() * 2); options.setMaxRequestSize(10 * 1024 * 1024); // 10MB 请求限制 options.headerLimiter(100); // debug 保持默认 false }) ``` ### 高并发场景 [Section titled “高并发场景”](#高并发场景) ```java Feat.httpServer(options -> { options.port(8080); options.threadNum(32); // 根据压测结果调整 options.readBufferSize(32 * 1024); // 增大缓冲区 options.writeBufferSize(32 * 1024); options.setIdleTimeout(60000); // 保持长连接 }) ``` ### 嵌入式/IoT 设备 [Section titled “嵌入式/IoT 设备”](#嵌入式iot-设备) ```java Feat.httpServer(options -> { options.port(8080); options.threadNum(2); // 减少线程数 options.setLowMemory(true); // 启用低内存模式 options.readBufferSize(2048); // 减小缓冲区 options.writeBufferSize(2048); }) ``` ## 常见问题 [Section titled “常见问题”](#常见问题) ### 端口被占用 [Section titled “端口被占用”](#端口被占用) ```plaintext Exception in thread "main" java.net.BindException: Address already in use ``` 解决方案:更换端口或查找占用进程: ```shell # 查找占用 8080 端口的进程 lsof -i :8080 # 或更换端口 Feat.httpServer(options -> options.port(9090)).listen(); ``` ### 请求体过大被截断 [Section titled “请求体过大被截断”](#请求体过大被截断) 增大 `setMaxRequestSize` 的值,特别是处理文件上传时: ```java options.setMaxRequestSize(100 * 1024 * 1024); // 100MB ``` ### Header 数量超限 [Section titled “Header 数量超限”](#header-数量超限) 如果收到 400 Bad Request,可能是 Header 数量超过限制: ```java options.headerLimiter(200); // 增大限制 ``` ### 调试模式没有输出 [Section titled “调试模式没有输出”](#调试模式没有输出) 确保在 `httpHandler` 之前配置 `debug(true)`: ```java Feat.httpServer(options -> options.debug(true)) // 先配置 .httpHandler(...) // 再设置处理器 .listen(); ``` # 协议升级 > 在同一个 Feat 服务里启用 WebSocket、SSE 或 HTTP/2,并理解它们各自解决的问题 在 Feat 里,协议升级的意义不是“多学一个 API”,而是让同一个 HTTP 服务入口可以承接不同的实时通信模式。\ 这页的重点不是讲协议标准,而是回答:什么时候该用哪种升级方式,以及在 Feat 里怎么落地。 ## 先做选择 [Section titled “先做选择”](#先做选择) 如果你现在只是知道“我要实时通信”,先不要急着写代码。先判断是哪一类: * **WebSocket**:双向实时通信,客户端和服务端都要主动发消息 * **SSE**:服务端单向推送,客户端主要负责监听 * **HTTP/2**:更偏向协议层能力和服务器推送,不是实时交互的默认入口 如果你的目标只是客户端接收事件流,先看 [SSE 客户端](/feat/client/sse_client/)。 如果你的目标是聊天室、协作或实时控制,先看 [WebSocket 客户端](/feat/client/ws_client/)。 ## WebSocket:把 HTTP 请求升级成双向连接 [Section titled “WebSocket:把 HTTP 请求升级成双向连接”](#websocket把-http-请求升级成双向连接) 这是最常见的协议升级场景。\ 服务端核心写法就是在请求里调用 `upgrade(new WebSocketUpgrade())`: WebSocketDemo.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.WebSocketRequest; import tech.smartboot.feat.core.server.WebSocketResponse; import tech.smartboot.feat.core.server.upgrade.websocket.WebSocketUpgrade; public class WebSocketDemo { public static void main(String[] args) { Feat.httpServer().httpHandler(request -> { request.upgrade(new WebSocketUpgrade() { @Override public void handleTextMessage(WebSocketRequest request, WebSocketResponse response, String message) { response.sendTextMessage("接受到客户端消息:" + message); } }); }).listen(); } } ``` 这段代码表达的其实很简单: * 客户端先发一个普通 HTTP 请求 * 服务端决定把它升级成 WebSocket * 升级成功后,后续处理就进入 WebSocket 消息收发模型 完整示例代码: WebSocketServer.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.WebSocketRequest; import tech.smartboot.feat.core.server.WebSocketResponse; import tech.smartboot.feat.core.server.upgrade.websocket.WebSocketUpgrade; public class WebSocketServer { public static void main(String[] args) { Feat.httpServer().httpHandler(request -> { request.upgrade(new WebSocketUpgrade() { @Override public void handleTextMessage(WebSocketRequest request, WebSocketResponse response, String message) { response.sendTextMessage("收到消息:" + message); } }); }).listen(8080); } } ``` ## SSE:把 HTTP 请求升级成事件流 [Section titled “SSE:把 HTTP 请求升级成事件流”](#sse把-http-请求升级成事件流) SSE 更适合“服务端持续发、客户端被动收”的模式。\ 在 Feat 里它的写法同样是一次升级,只是升级目标换成了 `SSEUpgrade`。 SSEDemo.java ```java import tech.smartboot.feat.Feat; import tech.smartboot.feat.core.server.upgrade.sse.SSEUpgrade; import tech.smartboot.feat.core.server.upgrade.sse.SseEmitter; import java.io.IOException; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class SSEDemo { public static void main(String[] args) { Feat.httpServer(serverOptions -> serverOptions.debug(true)) .httpHandler(req -> { req.upgrade(new SSEUpgrade() { public void onOpen(SseEmitter sseEmitter) { Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(new Runnable() { int i = 0; @Override public void run() { try { sseEmitter.send( SseEmitter.event() .name("update") .comment("aaa") .id(String.valueOf(i++)) .data("hello world") ); if (i == 10) { sseEmitter.complete(); } } catch (IOException e) { throw new RuntimeException(e); } } }, 1, 1, TimeUnit.SECONDS); } }); }).listen(8080); } } ``` 这更适合: * 日志流 * 通知流 * 状态更新流 * 构建输出流 ## HTTP/2:更偏协议层能力,而不是默认业务入口 [Section titled “HTTP/2:更偏协议层能力,而不是默认业务入口”](#http2更偏协议层能力而不是默认业务入口) 和 WebSocket、SSE 不同,HTTP/2 不一定意味着你要做一个“实时消息协议”。\ 很多时候你更关心的是: * 多路复用 * 头部压缩 * 服务器推送 如果你确实要自己处理 HTTP/2 升级流程,仓库里也有对应示例: Http2Demo.java ```java import io.github.smartboot.socket.extension.plugins.SslPlugin; import io.github.smartboot.socket.extension.plugins.StreamMonitorPlugin; import io.github.smartboot.socket.extension.ssl.factory.AutoServerSSLContextFactory; import tech.smartboot.feat.core.common.HeaderValue; import tech.smartboot.feat.core.server.HttpRequest; import tech.smartboot.feat.core.server.HttpResponse; import tech.smartboot.feat.core.server.HttpServer; import tech.smartboot.feat.core.server.PushBuilder; import tech.smartboot.feat.core.server.upgrade.http2.Http2Upgrade; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLParameters; import java.util.function.Consumer; public class Http2Demo { public static void main(String[] args) throws Exception { HttpServer bootstrap = new HttpServer(); bootstrap.httpHandler(request -> request.upgrade(new Http2Upgrade() { @Override public void handle(HttpRequest request) throws Throwable { HttpResponse response = request.getResponse(); PushBuilder pushBuilder = request.newPushBuilder(); if (pushBuilder != null) { pushBuilder.path("/aa.css").addHeader("aa", "bb").method("GET").push(); } response.write("hello feat".getBytes()); } })); SslPlugin sslPlugin = new SslPlugin(new AutoServerSSLContextFactory(), (Consumer) sslEngine -> { SSLParameters sslParameters = new SSLParameters(); sslEngine.setUseClientMode(false); sslParameters.setApplicationProtocols(new String[]{HeaderValue.Upgrade.H2}); sslEngine.setSSLParameters(sslParameters); HttpRequest.SSL_ENGINE_THREAD_LOCAL.set(sslEngine); }); bootstrap.options() .addPlugin(sslPlugin) .addPlugin(new StreamMonitorPlugin<>()) .debug(true); bootstrap.listen(8080); } } ``` 但从日常业务角度,这通常不是你使用 Feat 的第一个升级场景。\ 绝大多数团队会先用 WebSocket 或 SSE,只有在明确需要 HTTP/2 特性时才会深入到这层。 ## 该怎么选 [Section titled “该怎么选”](#该怎么选) ### 选 WebSocket,如果: [Section titled “选 WebSocket,如果:”](#选-websocket如果) * 双向通信是必须的 * 客户端不仅接收,还要频繁主动发消息 * 你在做聊天室、实时协作、游戏、控制面板 ### 选 SSE,如果: [Section titled “选 SSE,如果:”](#选-sse如果) * 主要是服务端向客户端持续推送 * 客户端不需要复杂双向通信 * 你更希望保持 HTTP 语义简单 ### 看 HTTP/2,如果: [Section titled “看 HTTP/2,如果:”](#看-http2如果) * 你真的要用服务器推送 * 你在关注协议层性能和连接模型 * 你已经知道自己为什么不是用 WebSocket / SSE ## 下一步 [Section titled “下一步”](#下一步) * 要看客户端怎么接收 WebSocket:去 [WebSocket 客户端](/feat/client/ws_client/) * 要看客户端怎么接收 SSE:去 [SSE 客户端](/feat/client/sse_client/) * 如果你其实还没写过最基本的服务:先回到 [构建第一个 Feat HTTP 服务](/feat/server/) # 赞助支持 > 感谢所有赞助者的支持,让 Feat 能够持续发展 Feat 主线仓库采用 Apache 2.0。Feat Core 与 Feat AI 可直接使用;Feat Cloud 等赞助能力的持续演进离不开赞助者支持。\ 这一页只做一件事:公开展示赞助名单,并明确感谢这些支持。 如果你正在评估 Feat 的商业使用方式,这页是理解项目支持模式的入口之一。\ 如果你只是学习主线文档,可以直接跳过,不影响使用和理解教程本身。 ## 赞助者名单 感谢所有赞助者的支持 ### smartboot开源组织 ### 合肥东灵科技有限公司 ### 陕西点三信息技术有限公司 ## 成为赞助者 如果 Feat 对您的项目有帮助,欢迎赞助支持项目的持续发展。您的支持将帮助我们: * 持续优化框架性能 * 开发新功能特性 * 完善文档和示例 * 提供更好的社区支持 赞助支持 ## License 生成器 为已赞助的企业/组织生成 Feat Cloud AOT 编译 License 名称 请输入企业/组织名称 编号 企业统一社会信用代码或自定义编号 自动生成 示例:`91110000123456789X`(统一社会信用代码) `20250101000001`(时间戳编号) 生成 License ### feat.yml 配置 ``` ``` 复制 ### 环境变量配置 编译时使用 配置环境变量后,在项目编译时自动验证 License Linux/macOS Windows Shell 配置 Bash / Zsh ``` export FEAT_LICENSE="" ``` 复制 添加到 `~/.bashrc` 或 `~/.zshrc` 永久生效 执行 `source ~/.bashrc` 立即生效 CMD PowerShell 命令提示符 CMD ``` set FEAT_LICENSE= ``` 复制 通过"系统属性" → "高级" → "环境变量"设置永久生效 PowerShell 推荐 ``` $env:FEAT_LICENSE="" ``` 复制 通过系统环境变量设置永久生效 ### 提交至官方仓库 请将以下配置发送给管理员,用于添加到 Feat 官方仓库 ``` ``` 复制