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 | 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)
 
### JSON 响应
[Section titled “JSON 响应”](#json-响应)
 
## 结果表
[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. 先看吞吐,再看尾延迟和资源占用
如果你真正关心的是你的系统,而不是框架宣传页,那么自己的压测结果一定比这页更重要。
# 公众号文章
> 补充阅读材料,适合在你已经完成主线学习之后进一步了解项目背景与实践案例
这一页不属于主教程线。你已经完成主线文档后,可以从这里继续看项目在外部传播里是如何被介绍和讨论的。
[](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)
[](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)
[](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)
[](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)
[](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)
[%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)
[%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)
[%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)
[%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 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`。

只有遇到 APT 生成代码不同步时才需要调整。
运行 `Bootstrap.main()`,控制台出现以下内容说明路由注册成功:
控制台输出
```text
Feat Router:
|-> /hello ==> Bootstrap@helloWorld
http://0.0.0.0:8080/
```
访问 `http://localhost:8080/hello`:
* 浏览器

* 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 请求体
* 最后补充少数需要直接接触运行时上下文的能力

## 第一个控制器
[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` 里,只展示当前接口方法和必要的局部类型。

### 查询参数
[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` 和请求体绑定。遇到下面这些场景时,再使用更靠近运行时的能力。
这部分不是日常接口的起点,更像工具箱:只有当普通参数绑定不够用时,再取对应能力。

### 注入 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/)。

到这一页,应用已经不只是“能在 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 版本
[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 的注册代码。运行时要做的事情很少:加载生成类,把能力挂到对应端点上。 
## 先把一个方法变成工具
[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` 管发现。三个类结构同构,套路一致。

## 准备依赖与基础配置
[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-环境配置)
从这里开始,关注点从“本次启动怎么跑”切到“不同环境如何生成代码”。

### 文件命名
[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”。它还承担三个职责:让调用方知道请求是否成功,拿到业务数据,并在失败时得到可处理的错误信息。 
## 基础返回值
[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 统一业务响应
[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 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