CrazyAirhead

疯狂的傻瓜,傻瓜也疯狂——傻方能执著,疯狂才专注!

0%

说明

公司里面原来的项目都是 Spring Boot 和 Spring Cloud 框架的,自己手动迁移完一个项目后,发现迁移的过程有些还是能代码化的东西,于是整理了 SpringConverter 这个工具。

这个工具不是说你转换完就能无痛的启动,你还是需要手动处理一些错误。虽然工具内置了一部分的 Spring 与 Solon 的对照关系,但你仍然可能需要修改这个工具的代码,配置遗漏的对照关系,以便能将自己的项目进行迁移。

思路

在手动替换的过程中,会首先创建新项目,然后把旧代码复制到新项目中,之后对相关的包和类进行替换,然后逐步修复其中的错误。

这个工具做的事情就是从实现了复制代码和替换包名、类名的过程。这个过程当中,我发现通常是一个包对另一个包,一个类对一个类,当然也会有多对一的情况,和无法对应的情况,于是注意的工作就是配置类名、包名的映射关系,然后就可以通过代码实现自动替换,从而实现减少工作量的目的。

技巧

  • 自己手动转换 pom 或者 gradle 文件,调整好依赖,不要交给转换工具。
  • 替换原项目地址,原项目包名,新项目地址,新项目包名。
  • 转化工具放置在另一个项目(或者模块中),这样方便多次转换新项目(边调整映射配置边替换)。必要时也可以方便的整个删除新项目。
  • 如果类名从短变长(通常是后缀相同),需要先替换类名,再通过旧包名+新类名的方式,替换新包名。
  • 如果无法对应的包,可以直接替换为空串或者回车(\n)
  • 如果是类上无法使用注解,可以增加回车(\n),替换从空串或者回车(\n)。
  • 注意字符串匹配是通过正则匹配的,替换的原字符串中包含中括弧(()),大括号({}),星号(*)等时需要进行转移。
  • 根据不同的类库进行替换,方便维护。必要时可以自己修改成配置文件从配置文件中读取对照关系。

项目地址

https://gitee.com/CrazyAirhead/porpoise-demo/tree/ray-admin/

ray-tools/ray-spring-converter

代码

代码本身无特别之处,就是根据配置的信息不断地替换源代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
package com.goldsyear.ray.converter;

import static java.io.File.separator;

import com.jfinal.kit.Kv;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.Matcher;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.dromara.hutool.core.collection.set.SetUtil;
import org.dromara.hutool.core.io.file.FileTypeUtil;
import org.dromara.hutool.core.io.file.FileUtil;

/**
* SpringBoot 项目迁移 Solon 项目工具,并不能迁移完就直接启动,只是减少迁移工作量
*
* @author Airhead
*/
@Slf4j
public class RayConverterApp {

/** 白名单文件,不进行重写,避免出问题 */
private static final Set<String> WHITE_FILE_TYPES =
SetUtil.of("gif", "jpg", "svg", "png", "eot", "woff2", "ttf", "woff");

public static void main(String[] args) {
long start = System.currentTimeMillis();

// 修改路径
String oldProjectDir = "旧项目路";
String oldPackageName = "旧包名";
String newProjectDir = "新项目路";
String newPackageName = "新包名";

log.info("原项目目录:{}, 原基础包名:{}", oldProjectDir, oldPackageName);
log.info("新项目目录:{}, 新基础包名:{}", newProjectDir, newPackageName);

// ========== 配置,需要你手动修改 ==========
List<Kv> mappingList = new ArrayList<>();
mappingList.add(Kv.by("old", oldPackageName).set("new", newPackageName));

// Spring 包调整
mappingSpringBoot(mappingList);

// Hutool 包调整
mappingHutool(mappingList);

// FastJson
mappingFastJson(mappingList);

// Mybatis 包调整
mappingMybatis(mappingList);

// TODO: 根据自己依赖的项目模块进行替换

// 业务包调整
mappingBusiness(mappingList);

// 获得需要复制的文件
log.info("开始获得需要重写的文件,预计需要 10-20 秒");
Collection<File> files = listFiles(oldProjectDir);
log.info("需要重写的文件数量:{},预计需要 15-30 秒", files.size());

// 写入文件
files.forEach(
file -> {
// 如果是白名单的文件类型,不进行重写,直接拷贝
String fileType = FileTypeUtil.getType(file);
if (WHITE_FILE_TYPES.contains(fileType)) {
copyFile(file, oldProjectDir, oldPackageName, newProjectDir, newPackageName);
return;
}

// 如果非白名单的文件类型,重写内容,在生成文件
String content = replaceFileContent(file, mappingList);

writeFile(file, content, oldProjectDir, oldPackageName, newProjectDir, newPackageName);
});

log.info("重写完成,共耗时:{} 秒", (System.currentTimeMillis() - start) / 1000);
}

private static void mappingBusiness(List<Kv> mappingList) {
// TODO
}

private static void mappingFastJson(List<Kv> mappingList) {
// TODO
}

private static void mappingMybatis(List<Kv> mappingList) {
// Page
mappingList.add(
Kv.by("old", "com.baomidou.mybatisplus.extension.plugins.pagination.Page")
.set("new", "com.baomidou.mybatisplus.solon.plugins.pagination.Page"));
// Model
mappingList.add(
Kv.by("old", "com.baomidou.mybatisplus.extension.activerecord.Model")
.set("new", "com.baomidou.mybatisplus.solon.activerecord.Model"));

// TenantLineHandler
mappingList.add(
Kv.by("old", "com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler")
.set("new", "com.baomidou.mybatisplus.solon.plugins.handler.TenantLineHandler"));
// MybatisPlusInterceptor
mappingList.add(
Kv.by("old", "com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor")
.set("new", "com.baomidou.mybatisplus.solon.plugins.MybatisPlusInterceptor"));
// PaginationInnerInterceptor
mappingList.add(
Kv.by("old", "com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor")
.set("new", "com.baomidou.mybatisplus.solon.plugins.inner.PaginationInnerInterceptor"));
// TenantLineInnerInterceptor
mappingList.add(
Kv.by("old", "com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor")
.set("new", "com.baomidou.mybatisplus.solon.plugins.inner.TenantLineInnerInterceptor"));

// FastjsonTypeHandler
mappingList.add(
Kv.by("old", "com.baomidou.mybatisplus.extension.handlers.FastjsonTypeHandler")
.set("new", "com.baomidou.mybatisplus.solon.handlers.FastjsonTypeHandler"));
}

private static void mappingHutool(List<Kv> mappingList) {
// StrUtil;
mappingList.add(
Kv.by("old", "cn.hutool.core.util.StrUtil")
.set("new", "org.dromara.hutool.core.text.StrUtil"));

// Convert;
mappingList.add(
Kv.by("old", "cn.hutool.core.convert.Convert")
.set("new", "org.dromara.hutool.core.convert.ConvertUtil"));
mappingList.add(Kv.by("old", "Convert\\.").set("new", "ConvertUtil."));

// IdUtil;
mappingList.add(
Kv.by("old", "cn.hutool.core.util.IdUtil")
.set("new", "org.dromara.hutool.core.data.id.IdUtil"));

// DateUtil;
mappingList.add(
Kv.by("old", "cn.hutool.core.date.DateUtil")
.set("new", "org.dromara.hutool.core.date.DateUtil"));

// ReUtil
mappingList.add(
Kv.by("old", "cn.hutool.core.util.ReUtil")
.set("new", "org.dromara.hutool.core.regex.ReUtil"));

// Spring CollectionUtils;
mappingList.add(
Kv.by("old", "org.springframework.util.CollectionUtils")
.set("new", "org.dromara.hutool.core.collection.CollUtil"));
mappingList.add(Kv.by("old", "CollectionUtils").set("new", "CollUtil"));

// CollectionUtil;
mappingList.add(
Kv.by("old", "cn.hutool.core.collection.CollectionUtil")
.set("new", "org.dromara.hutool.core.collection.CollUtil"));
mappingList.add(Kv.by("old", "CollectionUtil").set("new", "CollUtil"));

// LocalDateTimeUtil
mappingList.add(
Kv.by("old", "cn.hutool.core.date.LocalDateTimeUtil")
.set("new", "org.dromara.hutool.core.date.TimeUtil"));
mappingList.add(Kv.by("old", "LocalDateTimeUtil").set("new", "TimeUtil"));

// FileUtil
mappingList.add(
Kv.by("old", "cn.hutool.core.io.FileUtil")
.set("new", "org.dromara.hutool.core.io.file.FileUtil"));

// IoUtil
mappingList.add(
Kv.by("old", "cn.hutool.core.io.IoUtil").set("new", "org.dromara.hutool.core.io.IoUtil"));
}

private static void mappingSpringBoot(List<Kv> mappingList) {
// annotation
mappingList.add(
Kv.by("old", "org.springframework.web.bind.annotation\\.\\*")
.set("new", "org.noear.solon.annotation.*"));

// Component
mappingList.add(
Kv.by("old", "org.springframework.stereotype.Component")
.set("new", "org.noear.solon.annotation.Component"));

// Service
mappingList.add(
Kv.by("old", "org.springframework.stereotype.Service")
.set("new", "org.noear.solon.annotation.Component"));
mappingList.add(Kv.by("old", "@Service").set("new", "@Component"));

// Autowired
mappingList.add(
Kv.by("old", "org.springframework.beans.factory.annotation.Autowired")
.set("new", "org.noear.solon.annotation.Inject"));
mappingList.add(Kv.by("old", "@Autowired").set("new", "@Inject"));

// Controller
mappingList.add(
Kv.by("old", "org.springframework.web.bind.annotation.RestController")
.set("new", "org.noear.solon.annotation.Controller"));
mappingList.add(Kv.by("old", "@RestController").set("new", "@Controller"));

// RequestMapping
mappingList.add(
Kv.by("old", "org.springframework.web.bind.annotation.RequestMapping")
.set("new", "org.noear.solon.annotation.Mapping"));
mappingList.add(Kv.by("old", "@RequestMapping").set("new", "@Mapping"));

// MediaType;
mappingList.add(Kv.by("old", "import org.springframework.http.MediaType;").set("new", ""));
mappingList.add(
Kv.by("old", ", produces = MediaType.APPLICATION_JSON_UTF8_VALUE").set("new", ""));

mappingList.add(
Kv.by("old", "import org.springframework.core.env.Environment;").set("new", ""));

// PostMapping
mappingList.add(
Kv.by("old", "org.springframework.web.bind.annotation.PostMapping")
.set("new", "org.noear.solon.annotation.Post"));
mappingList.add(Kv.by("old", "@PostMapping").set("new", "@Post\n@Mapping"));

// GetMapping
mappingList.add(
Kv.by("old", "org.springframework.web.bind.annotation.GetMapping")
.set("new", "org.noear.solon.annotation.Get"));
mappingList.add(Kv.by("old", "@GetMapping").set("new", "@Post\n@Mapping"));

// PutMapping
mappingList.add(
Kv.by("old", "org.springframework.web.bind.annotation.PutMapping")
.set("new", "org.noear.solon.annotation.Put"));
mappingList.add(Kv.by("old", "@PutMapping").set("new", "@Put\n@Mapping"));

// DeleteMapping
mappingList.add(
Kv.by("old", "org.springframework.web.bind.annotation.DeleteMapping")
.set("new", "org.noear.solon.annotation.Delete"));
mappingList.add(Kv.by("old", "@DeleteMapping").set("new", "@Delete\n@Mapping"));

// @RequestBody
mappingList.add(
Kv.by("old", "org.springframework.web.bind.annotation.RequestBody")
.set("new", "org.noear.solon.annotation.Body"));
mappingList.add(Kv.by("old", "@RequestBody").set("new", "@Body"));

// @RequestParam
mappingList.add(
Kv.by("old", "org.springframework.web.bind.annotation.RequestParam")
.set("new", "org.noear.solon.annotation.Param"));
mappingList.add(Kv.by("old", "@RequestParam").set("new", "@Param"));

// RequestPart
mappingList.add(
Kv.by("old", "org.springframework.web.bind.annotation.RequestPart")
.set("new", "org.noear.solon.annotation.Param"));
mappingList.add(Kv.by("old", "@RequestPart").set("new", "@Param"));

// Repository;
mappingList.add(
Kv.by("old", "import org.springframework.stereotype.Repository;").set("new", ""));
mappingList.add(Kv.by("old", "@Repository").set("new", ""));

// Validated
mappingList.add(
Kv.by("old", "org.springframework.validation.annotation.Validated")
.set("new", "org.noear.solon.validation.annotation.Validated"));
mappingList.add(Kv.by("old", "@Validated\n").set("new", ""));

// Configuration;
mappingList.add(
Kv.by("old", "org.springframework.context.annotation.Configuration")
.set("new", "org.noear.solon.annotation.Configuration"));

// RefreshScope;
mappingList.add(
Kv.by("old", "import org.springframework.cloud.context.config.annotation.RefreshScope;")
.set("new", ""));
mappingList.add(Kv.by("old", "@RefreshScope\n").set("new", ""));

// EnableAsync;
mappingList.add(
Kv.by("old", "import org.springframework.scheduling.annotation.EnableAsync;")
.set("new", ""));
mappingList.add(Kv.by("old", "@EnableAsync").set("new", ""));

// Bean;
mappingList.add(
Kv.by("old", "org.springframework.context.annotation.Bean")
.set("new", "org.noear.solon.annotation.Bean"));

// ConfigurationProperties;
mappingList.add(
Kv.by("old", "org.springframework.boot.context.properties.ConfigurationProperties")
.set("new", "org.noear.solon.annotation.Inject"));
mappingList.add(Kv.by("old", "@ConfigurationProperties").set("new", "@Inject"));

// CommandLineRunner
mappingList.add(
Kv.by("old", "import org.springframework.boot.CommandLineRunner;")
.set(
"new",
"import org.noear.solon.core.event.AppLoadEndEvent;\nimport org.noear.solon.core.event.EventListener;"));
mappingList.add(Kv.by("old", "CommandLineRunner").set("new", "EventListener<AppLoadEndEvent>"));

// Resource;
mappingList.add(
Kv.by("old", "javax.annotation.Resource").set("new", "org.noear.solon.annotation.Inject"));
mappingList.add(Kv.by("old", "@Resource\\(name").set("new", "@Inject\\(value"));

// Lazy;
mappingList.add(
Kv.by("old", "import org.springframework.context.annotation.Lazy;\n").set("new", ""));
mappingList.add(Kv.by("old", "@Lazy").set("new", ""));

// Valid
mappingList.add(
Kv.by("old", "javax.validation.Valid")
.set("new", "org.noear.solon.validation.annotation.Valid"));
mappingList.add(
Kv.by("old", "javax.validation.constraints.NotEmpty")
.set("new", "org.noear.solon.validation.annotation.NotEmpty"));
mappingList.add(
Kv.by("old", "javax.validation.constraints.NotNull")
.set("new", "org.noear.solon.validation.annotation.NotNull"));
// TODO: 补充更多

// HttpServletResponse;
mappingList.add(
Kv.by("old", "javax.servlet.http.HttpServletResponse")
.set("new", "org.noear.solon.core.handle.Context"));
mappingList.add(Kv.by("old", "HttpServletResponse").set("new", "Context"));

// MultipartFile;
mappingList.add(
Kv.by("old", "org.springframework.web.multipart.MultipartFile")
.set("new", "org.noear.solon.core.handle.UploadedFile"));
mappingList.add(Kv.by("old", "MultipartFile").set("new", "UploadedFile"));

// consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}
mappingList.add(
Kv.by("old", ",\n\\s+consumes = \\{MediaType.MULTIPART_FORM_DATA_VALUE\\}").set("new", ""));
mappingList.add(
Kv.by("old", ", consumes = \\{MediaType.MULTIPART_FORM_DATA_VALUE\\}").set("new", ""));
}

private static Collection<File> listFiles(String projectBaseDir) {
Collection<File> files = FileUtil.loopFiles(new File(projectBaseDir));
// 移除 IDEA、Git 自身的文件、Node 编译出来的文件
files =
files.stream()
.filter(
file ->
!file.getPath().contains(separator + "target" + separator)
&& !file.getPath().contains(separator + "node_modules" + separator)
&& !file.getPath().contains(separator + ".idea" + separator)
&& !file.getPath().contains(separator + ".git" + separator)
&& !file.getPath().contains(separator + "dist" + separator)
&& !file.getPath().contains(separator + "build" + separator)
&& !file.getPath().contains(separator + "out" + separator)
&& !file.getPath().contains(".iml")
&& !file.getPath().contains(".html.gz")
&& !file.getPath().contains("build.gradle")
&& !file.getPath().contains("pom.xml"))
.collect(Collectors.toList());
return files;
}

private static String replaceFileContent(File file, List<Kv> mappingList) {
String content = FileUtil.readString(file, StandardCharsets.UTF_8);
// 如果是白名单的文件类型,不进行重写
String fileType = FileTypeUtil.getType(file);
if (WHITE_FILE_TYPES.contains(fileType)) {
return content;
}

for (Kv kv : mappingList) {
content = content.replaceAll(kv.getStr("old"), kv.getStr("new"));
}

return content;
}

private static void writeFile(
File file,
String fileContent,
String oldProjectDir,
String oldPageName,
String newProjectDir,
String newPackageName) {
String newPath =
buildNewFilePath(file, oldProjectDir, oldPageName, newProjectDir, newPackageName);
FileUtil.writeUtf8String(fileContent, newPath);
}

private static void copyFile(
File file,
String oldProjectDir,
String oldPackageName,
String newProjectDir,
String newPackageName) {
String newPath =
buildNewFilePath(file, oldProjectDir, oldPackageName, newProjectDir, newPackageName);
FileUtil.copy(file, new File(newPath), true);
}

private static String buildNewFilePath(
File file,
String oldProjectDir,
String oldPackageName,
String newProjectDir,
String newPackageName) {
return file.getPath()
.replace(oldProjectDir, newProjectDir)
.replace(
oldPackageName.replaceAll("\\.", Matcher.quoteReplacement(separator)),
newPackageName.replaceAll("\\.", Matcher.quoteReplacement(separator)));
}
}

注意

再次提醒这个工具不是无缝迁移的,也就是说不能说迁移完就可以直接启动的,这个工具只是减少工作量。

说明

当前大模型与外部打交道的方式有两种,一种是 Prompt,一种是 Fuction Call。在 Prompt 方面,应用系统可以通过 Prompt 模版和补充上下文的方式,调整用户输入的提示语,使得大模型生成的回答更加准确。

RAG

RAG (Retrieval-Augmented Generation 检索增强生成)是一种结合了信息检索和大模型生成的技术框架,是通过相似度等算法从外部知识库中检索相关的信息,并将其作为 Prompt 输入大模型,以增强大模型处理知识密集型任务的能力。

系统是传统 RAG 和 智能体 RAG 的一个架构图(图片来源网络)。

img

知识库

FastGPT 的《知识库基础原理的介绍》说明挺好的,https://doc.fastgpt.cn/docs/guide/knowledge_base/rag/

相对于大模型,我们的系统称为应用。对于应用来说,主要做的是检索工作,就是从知识库(提供文档索检服务的对象)中找到相应的知识(文档)。

为了存储和读取文档,需要用到文档加载器和文档分割器。

  • 文档加载器,比如,纯文本,PDF,Word, Markdown, html, excel 等格式的处理与加载。
  • 文档分割器,当文档太大时,可以按文档的字数,段落,章节,主题等来分块。因此也就有了 JSON 格式拆分,正则表达式拆分,大小分割,拆分流水线等等。

为了查询相关文档,就需要检索算法的加持。主要分为向量检索和传统检索。在实际的应用是可以多种方式结合的,只要能检索到尽可能相关的数据即可。

  • 向量检索:如 BERT向量等,它通过将文档和查询转化为向量空间中的表示,并使用相似度计算来进行匹配。向量检索的优势在于能够更好地捕捉语义相似性,而不仅仅是依赖于词汇匹配。
  • 传统检索:如BM25,主要基于词频和逆文档频率(TF-IDF)的加权搜索模型来对文档进行排序和检索。BM25适用于处理较为简单的匹配任务,尤其是当查询和文档中的关键词有直接匹配时。

示例

这里我们继续使用 demo-ai02 中诊断的例子,尝试模拟一次简单的诊断,相对于demo-ai02,增加了获取病人的历史病人和病人自述相关的知识,用于演示知识库增加的例子。注意这里只是示例,并非真正的诊断流程。

依赖

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dependencies {
implementation platform(project(":demo-parent"))

implementation("org.noear:solon-web")
implementation("org.noear:solon-view-enjoy")
implementation("org.noear:solon-ai")
implementation("org.noear:solon-ai-repo-redis")
implementation("org.noear:solon-logging-logback")
implementation("org.noear:solon-openapi2-knife4j")
implementation("org.noear:solon-web-rx")
implementation("org.noear:solon-web-sse")
implementation("org.noear:solon-flow")
implementation("org.dromara.hutool:hutool-all")


testImplementation("org.noear:solon-test")
}

Redis

这里需要用的redis的向量查询,需要安装 redis-stack 版本。

1
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/redis/redis-stack:latest

模型

这里需要向量化,需要增加嵌入模型,这些选了nomic-embed-text,自己根据实际情况选项,deepseek-r1:32b,也是一样的,根据需要调整。

1
2
3
ollama run deepseek-r1:32b
ollama run qwen2.5:7b
ollama run nomic-embed-text

配置

app.yml

增加嵌入模型和 reids 知识库的配置,其他的模型配置在流程编排中获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
solon.flow:
- "classpath:flow/*"

demo.llm:
embed:
apiUrl: "http://127.0.0.1:11434/api/embed" # 使用完整地址(而不是 api_base)
provider: "ollama" # 使用 ollama 服务时,需要配置 provider
model: "nomic-embed-text"
repo:
redis:
server: "127.0.0.1:16379" # 改为你的 Redis 地址
db: 0
maxTotal: 200

LlmConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.example.demo.ai.llm.config;

import org.noear.redisx.RedisClient;
import org.noear.solon.ai.embedding.EmbeddingConfig;
import org.noear.solon.ai.embedding.EmbeddingModel;
import org.noear.solon.ai.rag.repository.RedisRepository;
import org.noear.solon.annotation.Bean;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.annotation.Inject;

/**
* @author airhead
*/
@Configuration
public class LlmConfig {
@Bean
public EmbeddingModel embeddingModel(@Inject("${demo.llm.embed}") EmbeddingConfig config) {
return EmbeddingModel.of(config).build();
}

@Bean
public RedisRepository repository(
EmbeddingModel embeddingModel, @Inject("${solon.llm.repo.redis}") RedisClient client) {
return new RedisRepository(embeddingModel, client.openSession().jedis());
}
}

流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
id: "ai-flow-01"
layout:
- id: "开始"
type: "start"
- id: "病史"
type: "execute"
task: "@searchTask"
- id: "诊断"
type: "execute"
meta.model: "deepseek-r1:32b"
meta.apiUrl: "http://127.0.0.1:11434/api/chat"
meta.provider: "ollama"
meta.input: "prompt"
meta.output: "intention"
meta.systemTpl: "## 上下文\n\n#(ctx)\n\n## 任务\n\n根据用户的描述,判断用户最可能的三个健康问题,只要诊断名称,不需要其他解释,用 Markdown 的列表格式返回。\n\n"
meta.userTpl: "## 病人自述\n\n#(prompt)\n\n## 历史病情\n\n#(history)\n\n"
task: "@intentionTask"
- id: "治疗建议"
type: "execute"
meta.model: "qwen2.5:7b"
meta.apiUrl: "http://127.0.0.1:11434/api/chat"
meta.provider: "ollama"
meta.input: "intention"
meta.output: "suggestion"
meta.system: "## 角色\n\n你是一个经验丰富的医生\n\n## 任务\n根据用户提供的诊断信息,提供治疗建议"
task: "@suggestionTask"
- type: "end"

知识库管理

RepositoryController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.demo.ai.llm.controller;

import com.example.demo.ai.llm.service.RepositoryService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.util.List;
import org.noear.solon.annotation.*;

/**
* @author airhead
*/
@Controller
@Mapping("/repository")
@Api("知识库")
public class RepositoryController {
@Inject private RepositoryService service;

@ApiOperation("addDoc")
@Post
@Mapping("addDoc")
public Boolean addDoc(String content) {
return service.addDoc(content);
}

@ApiOperation("search")
@Post
@Mapping("search")
public List<String> search(String query) {
return service.search(query);
}
}

RepositoryService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example.demo.ai.llm.service;

import java.io.IOException;
import java.util.Collections;
import java.util.List;
import org.noear.solon.ai.rag.Document;
import org.noear.solon.ai.rag.repository.RedisRepository;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;

/**
* @author airhead
*/
@Component
public class RepositoryService {
@Inject RedisRepository repository;

public Boolean addDoc(String content) {
try {
Document document = new Document(content);
repository.insert(Collections.singletonList(document));
return true;
} catch (IOException e) {
throw new RuntimeException(e);
}
}

public List<String> search(String query) {
try {
List<Document> list = repository.search(query);
return list.stream().map(Document::getContent).toList();
} catch (IOException e) {
throw new RuntimeException(e);
}
}

public String history() {
return "无其他慢性病";
}
}

诊断

LlmDiagController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.demo.ai.llm.controller;

import com.example.demo.ai.llm.service.LlmService;
import com.jfinal.kit.Kv;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.noear.solon.annotation.*;

/**
* @author airhead
*/
@Controller
@Mapping("/llm")
@Api("聊天")
public class LlmDiagController {
@Inject private LlmService service;

@ApiOperation("diag")
@Post
@Mapping("diag")
public Kv diag(String prompt) {
return service.diag(prompt);
}
}

LlmDiagService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.demo.ai.llm.service;

import com.jfinal.kit.Kv;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.flow.ChainContext;
import org.noear.solon.flow.FlowEngine;

/**
* @author airhead
*/
@Component
public class LlmDiagService {
@Inject private FlowEngine flowEngine;

public Kv diag(String prompt) {
try {
ChainContext chainContext = new ChainContext();
chainContext.put("prompt", prompt);
Kv kv = Kv.create();
chainContext.put("result", kv);
flowEngine.eval("ai-flow-01", chainContext);

return kv;
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
}

验证

知识管理

通过接口写入一些文档,这里是一些睡眠的方法。

img

知识查询

通过查询水名

img

诊断服务

img

中间的调试信息,我们可以看到我们添加的知识库的信息被追加到提示信息中,提交给大模型。

img

小结

示例中没有对文档的拆分和文档不同文档类型的加载,但我们可以看到 Solon-ai 结合 solon-flow 已经能完整的支持 RAG 的知识库的开发了。当然如果要做一个完整的知识库系统还需要很多工作要做。

说明

Solon 的流程编排,使用了 solon-flow 做流程编排,因此需要先对 solon-flow 有所了解,下面是 Solon flow的一些简单介绍,更具体的介绍可以参考官网 https://solon.noear.org/article/learn-solon-flow

solon-flow

Solon Flow 提供基础的流引擎能力,支持开放式的驱动定制(像 JDBC 有 MySQL 或 PostgreSQL 等不同驱动一样)。可用于业务规则、决策处理、计算编排、流程审批等场景。

核心概念:

  • 链、节点、连接。
  • 链上下文、链驱动器、任务组件接口、条件组件接口。
  • 流引擎。

概念关系:

  • 链(Chain),一个完整的流程,由多个节点(Node)连接(Link)组成。一个链有且只有一个 start 类型的节点。从 start 节点开始,顺着连接(Link)流出。
  • 节点(Node),流程处理的环节,会有多个连接(Link),有流入连接,流出连接。
  • 连接(Link),节点与节点之间的执行顺序。连接向其它节点,称为流出连接。被其它节点连接,称为流入连接。
  • 流引擎,提供执行时的环境(链上下文与对象引用等),驱动链的流动(执行)。链的流转过程,可以有上下文参数(ChainContext),可以被中断(可支持有状态的审批模式)。

通俗些,就是通过点(节点) + 线(连接)来描述一个流程图(链)。因此这里列出节点和连接的属性。

节点 Node 属性

属性 数据类型 需求 描述
id String 节点Id(要求链内唯一)。不配置时,会自动生成
type NodeType 节点类型,类型包括 start,execute,inclusive,exclusive,parallerl,end。不配置时,缺省为 execute 类型,
title String 显示标题
meta Map 元信息(用于应用扩展)
link String or Link String[] or Link[] 连接(支持单值、多值),不配置时,会自动生成。link 全写配置风格为 Link 类型结构;简写配置风格为 Link 的 nextId 值(即 String 类型)
task String 任务描述(会触发驱动的 handleTask 处理)
when String 执行任务条件描述(会触发驱动的 handleTest 处理)

通常可以不配置,或者用简写模式配置即可,只要当Node是包含网关(inclusive)或者排他网关(exclusive)时需要配置全选风格,从而配置条件。

属性 数据类型 需求 描述
nextId String 必填 后面的节点Id
title String 显示标题
meta Map 元信息(用于应用扩展)
condition String 流出条件描述(会触发驱动的 handleTest 处理)

节点类型 NodeType

描述 任务 连接条件 可流入 连接数 可流出 连接数 图例参考
start 开始 / / 0 1
execute 执行节点(缺省类型) 可有 / 1…n 1
inclusive 包容网关 / 支持 1…n 1…n
exclusive 排它网关 / 支持 1…n 1…n
parallel 并行网关 / / 1…n 1…n
end 结束 / / 1…n 0

依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dependencies {
implementation platform(project(":demo-parent"))

implementation("org.noear:solon-web")
implementation("org.noear:solon-view-enjoy")
implementation("org.noear:solon-ai")
implementation("org.noear:solon-logging-logback")
implementation("org.noear:solon-openapi2-knife4j")
implementation("org.noear:solon-web-rx")
implementation("org.noear:solon-web-sse")
implementation("org.noear:solon-flow")
implementation("org.dromara.hutool:hutool-all")


testImplementation("org.noear:solon-test")
}

配置

app.yml

这里需要指定配置的流配置文件的位置。

1
2
solon.flow:
- "classpath:flow/*"

流配置

这里尝试用一个模拟一次简单的诊断,从诊断到治疗建议等流程。注意这里只是示例,并非真正的诊断流程,更完整的流程是还需要更多的病人信息(比如病史,检查,检验等),检索相关知识库等。这里只是为了 solon-flow 针对 ai 相关的编排的逻辑。

在诊断的节点,我们使用了deepseek-r1,在治疗环境,我们使用qwen2.5。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
id: "ai-flow-01"
layout:
- id: "开始"
type: "start"
- id: "诊断"
type: "execute"
meta.model: "deepseek-r1:32b"
meta.apiUrl: "http://127.0.0.1:11434/api/chat"
meta.provider: "ollama"
meta.input: "prompt"
meta.output: "intention"
meta.system: "根据用户的描述,判断用户最可能的三个健康问题,只要诊断名称,不需要其他解释,用 Markdown 的列表格式返回。"
task: "@intentionTask"
- id: "治疗建议"
type: "execute"
meta.model: "qwen2.5:7b"
meta.apiUrl: "http://127.0.0.1:11434/api/chat"
meta.provider: "ollama"
meta.input: "intention"
meta.output: "suggestion"
meta.system: "#角色\n你是一个经验丰富的医生\n\n#任务\n根据用户提供的诊断信息,提供治疗建议"
task: "@suggestionTask"
- type: "end"

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.demo.ai.llm.controller;

import com.example.demo.ai.llm.service.LlmService;
import com.jfinal.kit.Kv;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.noear.solon.annotation.*;

/**
* @author airhead
*/
@Controller
@Mapping("/llm")
@Api("聊天")
public class LlmController {
@Inject private LlmService service;

@ApiOperation("flow")
@Post
@Mapping("flow")
public Kv flow(String prompt) {
return service.flow(prompt);
}

@ApiOperation("aiFlow")
@Post
@Mapping("aiFlow")
public Kv aiFlow(String prompt) {
return service.aiFlow(prompt);
}
}

这里的 flow 是演示基础的 solon flow 的调用,是我学习solon flow 时编写的一个简单例子,不熟悉的可以先看看这个示例的编写。

aiFlow 是对大模型的一个编排的调用。

Service

这里的重点就是注入 FlowEngine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.example.demo.ai.llm.service;

import com.jfinal.kit.Kv;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.flow.ChainContext;
import org.noear.solon.flow.FlowEngine;

/**
* @author airhead
*/
@Component
public class LlmService {
@Inject private FlowEngine flowEngine;

public Kv flow(String prompt) {

try {
ChainContext chainContext = new ChainContext();
chainContext.put("prompt", prompt);
Kv kv = Kv.create();
chainContext.put("result", kv);
flowEngine.eval("c1", chainContext);

return kv;
} catch (Throwable e) {
throw new RuntimeException(e);
}
}

public Kv aiFlow(String prompt) {
try {
ChainContext chainContext = new ChainContext();
chainContext.put("prompt", prompt);
Kv kv = Kv.create();
chainContext.put("result", kv);
flowEngine.eval("ai-flow-01", chainContext);

return kv;
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
}

IntentionTask

这里是诊断节点的处理,根据 meta 信息和用户的输入构建大模型,然后进行调用,并将结果通过 ChainContext 进行传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.example.demo.ai.llm.service;

import com.jfinal.kit.Kv;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;

import org.dromara.hutool.core.regex.ReUtil;
import org.dromara.hutool.core.text.StrUtil;
import org.noear.solon.ai.chat.ChatConfig;
import org.noear.solon.ai.chat.ChatModel;
import org.noear.solon.ai.chat.ChatResponse;
import org.noear.solon.ai.chat.message.ChatMessage;
import org.noear.solon.annotation.Component;
import org.noear.solon.flow.ChainContext;
import org.noear.solon.flow.Node;
import org.noear.solon.flow.TaskComponent;

/**
* @author airhead
*/
@Component("intentionTask")
public class IntentionTask implements TaskComponent {
@Override
public void run(ChainContext context, Node node) throws Throwable {
Kv meta = Kv.create().set(node.meta());
ChatConfig chatConfig = new ChatConfig();
chatConfig.setModel(meta.getStr("model"));
chatConfig.setProvider(meta.getStr("provider"));
chatConfig.setApiUrl(meta.getStr("apiUrl"));
chatConfig.setTimeout(Duration.ofSeconds(600));
ChatModel chatModel = ChatModel.of(chatConfig).build();

List<ChatMessage> chatMessageList = new ArrayList<>();
ChatMessage system = ChatMessage.ofSystem(meta.getStr("system"));
chatMessageList.add(system);

Kv model = Kv.create().set(context.model());
String inputKey = meta.getStr("input");
ChatMessage userMessage = ChatMessage.ofUser(model.getStr(inputKey));
chatMessageList.add(userMessage);
ChatResponse response = chatModel.prompt(chatMessageList).call();
String content = response.getMessage().getContent();

// 去掉think的部分。
String pattern = "<think>.*?</think>";
Pattern p = Pattern.compile(pattern, Pattern.DOTALL);
content = StrUtil.trim(ReUtil.replaceAll(content, p, ""));

String outputKey = meta.getStr("output");
context.put(outputKey, content);

Kv result = context.get("result");
result.set("data", content);
}
}

SuggestionTask

这里是治疗节点的处理,根据 meta 信息和上一级节点的输入构建大模型,然后进行调用,并将结果通过 ChainContext 进行返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.example.demo.ai.llm.service;

import com.jfinal.kit.Kv;
import java.util.ArrayList;
import java.util.List;
import org.noear.solon.ai.chat.ChatConfig;
import org.noear.solon.ai.chat.ChatModel;
import org.noear.solon.ai.chat.ChatResponse;
import org.noear.solon.ai.chat.message.ChatMessage;
import org.noear.solon.annotation.Component;
import org.noear.solon.flow.ChainContext;
import org.noear.solon.flow.Node;
import org.noear.solon.flow.TaskComponent;

/**
* @author airhead
*/
@Component("suggestionTask")
public class SuggestionTask implements TaskComponent {
@Override
public void run(ChainContext context, Node node) throws Throwable {
Kv meta = Kv.create().set(node.meta());
ChatConfig chatConfig = new ChatConfig();
chatConfig.setModel(meta.getStr("model"));
chatConfig.setProvider(meta.getStr("provider"));
chatConfig.setApiUrl(meta.getStr("apiUrl"));
ChatModel chatModel = ChatModel.of(chatConfig).build();

List<ChatMessage> chatMessageList = new ArrayList<>();
ChatMessage system = ChatMessage.ofSystem(meta.getStr("system"));
chatMessageList.add(system);

Kv model = Kv.create().set(context.model());
String inputKey = meta.getStr("input");
ChatMessage userMessage = ChatMessage.ofUser(model.getStr(inputKey));
chatMessageList.add(userMessage);
ChatResponse response = chatModel.prompt(chatMessageList).call();
String content = response.getMessage().getContent();

String outputKey = meta.getStr("output");
context.put(outputKey, content);

Kv result = context.get("result");
result.set("data", content);
}
}

验证

通过 swagger 直接调用aiFlow。

img

这里是中间环节的输出。

img

小结

Solon AI 通过 Solon Flow 进行编排。通过编排,我们可以整合普通的业务逻辑和大模型的生成式功能实现更复杂、更人性化的业务逻辑,当然要实现真实可用的业务逻辑,需要自己进一步的摸索。

说明

限流

限流是控制系统的的并发流量,通过限制请求流量的手段防止过度的流量导致系统崩溃。限流的指标有TPS, HPS、QPS等,限流的方法有流量计数器,滑动时间窗口,漏桶算法,令牌桶算法等。

熔断

熔断的概念来自电路保护,提供了断路开关,主要有三种状态,关闭状态,打开状态和半开状态。熔断可以防止应用程序不断地尝试可能超时和失败的服务,能达到应用程序执行而不必等待下游服务修正错误服务。

降级

降级是指当自身服务压力增大时,采取一些手段,增强自身服务的处理能力,以保障服务的持续可用。比如,下线非核心服务以保证核心服务的稳定、降低实时性、降低数据一致性等。

三者关系

限流、熔断和服务降级是系统容错的重要设计模式,从一定意义上讲限流和熔断也是一种服务降级的手段。

措施 产生原因 针对服务
熔断 下游服务不可用 下游服务
降级 自身服务的处理能力不够 自身服务
限流 上游服务请求增多 上游服务

Sentinel

Sentinel 是面向分布式、多语言异构化服务架构的流量治理组件,主要以流量为切入点,从流量路由、流量控制、流量整形、熔断降级、系统自适应过载保护、热点流量防护等多个维度来帮助开发者保障微服务的稳定性。

之前使用的是Spring Cloud 默认带的是 Hystrix,之前也没有用过Sentinel,这次按理是尝试使用Setinel。

依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
dependencies {
implementation platform(project(":demo-parent"))

implementation("org.noear:solon-web")
implementation("org.noear:solon-logging-logback")
implementation("org.noear:solon-openapi2-knife4j")
implementation("org.noear:sentinel-solon-cloud-plugin")
implementation("com.alibaba.csp:sentinel-transport-simple-http:1.8.8")

annotationProcessor("org.mapstruct:mapstruct-processor:${mapstructVersion}")

testImplementation("org.noear:solon-test-junit5")
}

配置

1
2
3
4
solon.cloud.local:
breaker:
root: 1000
hello: 10

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.demo.solon.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.annotation.Param;
import org.noear.solon.cloud.annotation.CloudBreaker;

/**
* @author airhead
*/
@Api("Demo")
@Controller
@Mapping
public class DemoController {
@ApiOperation("hello")
@Mapping("/hello")
@CloudBreaker("hello")
public String hello(@Param(defaultValue = "world") String name) {
return String.format("Hello %s!", name);
}
}

验证

需要通过多线程的方式来测试,写个单元测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.example.demo.solon.controller;

import static org.junit.jupiter.api.Assertions.*;

import com.example.demo.solon.DemoCloudBreakerApp;
import java.util.concurrent.CountDownLatch;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.noear.solon.Utils;
import org.noear.solon.test.HttpTester;
import org.noear.solon.test.SolonTest;

@Slf4j
@SolonTest(DemoCloudBreakerApp.class)
public class DemoControllerTest extends HttpTester {
@Test
public void test() throws Exception {
CountDownLatch count = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
log.info("get hello start. i:{}", i);
int finalI = i;
Utils.async(
() -> {
try {
path("/hello").get();
} catch (Exception e) {
log.error("", e);
}

log.info("get hello end. i:{}", finalI);
count.countDown();
});
}

count.await();
}
}

当超过限流时,提示错误。

img

问题

  1. 当前使用的版本3.0.9及 3.1.0-M3版本暂时不能把 CloudBreaker 注解在类上。
  2. 当前版本无法注册到 sentinel 的 dashboard 中,属于本地配置。
  3. 当前支持限流,没有熔断的支持。

小结

从测试使用的情况来看,Solon 目前只支持本地的限流处理,暂时不支持熔断,对 sentinel 的支持还不够完善,无法注册到 sentinel 的 dashboard 中。如果需要用于生产,需要自己扩展下对应的插件,或者再等一等官方的更新。

说明

关于 Solon Cloud 目前正在准备「熔断与限流」 的部分,想使用 sentinel 的插件,但我之前只用过 Spring 集成的 Hystrix,还需要先学习下 sentinel,目前还在准备当中,不会那么快更新。Solon AI 是 《Solon 实用教程》的第六部分,算是新增章节,恰好之前有使用过 agents-flex,准备起来更容易些,因此先开始更新 Solon AI 部分。

Feishu Docs - Image

Solon 将在 3.1.0 版本引入 AI 相关插件,现在已经发布 SNAPSHOT 版本,现在接口和功能还不稳定,只建议尝鲜。

从官网的介绍中(https://solon.noear.org/article/learn-solon-ai),可以看到Solon AI 对大模型的支持是比较完整的,聊天模型接口支持同步调用,流式调用,Function Call,记忆功能,多种消息角色和多种消息格式,提供 RAG 支持和流程编排。

在初体验中,我测试的是聊天模型的同步调用,流式调用,Function Call这几个功能。

前置

本地测试使用了 ollama,需要安装好 ollama (https://ollama.com/download),并下载好模型,我这里演示用的两个模型一个是deepseek-r1:7b(基于 qwen2.5 的蒸馏的一个推理模型)和 qwen2.5:7b(支持tools,在ollama 可以通过点击 tools 标签查看哪些模型支持 tools)。

1
2
ollama run deepseek-r1:7b
ollama run qwen2.5:7b

如果无法调用 ollama 接口时,可做如下配置,主要处理跨域或者局域网调用。

1
2
3
4
5
6
vi .zshrc

export OLLAMA_ORIGINS="*"
export OLLAMA_HOST=0.0.0.0:11434

source .zshrc

依赖

增加 solon-ai 的依赖,solon-web-rx 和 solon-web-sse 用于支持流式调用。

1
2
3
4
5
6
7
8
9
10
11
12
dependencies {
implementation platform(project(":demo-parent"))

implementation("org.noear:solon-web")
implementation("org.noear:solon-ai")
implementation("org.noear:solon-logging-logback")
implementation("org.noear:solon-openapi2-knife4j")
implementation("org.noear:solon-web-rx")
implementation("org.noear:solon-web-sse")

testImplementation("org.noear:solon-test")
}

配置

我这里使用 ollama 服务时,需要配置 provider。如果使用云服务时,设置 apiKey。

这里配置 timeout 是避免使用推理模型时,接口调用时间过长报错问题,可以根据自己的模型和机器的配置情况进行调整。

1
2
3
4
5
6
7
8
demo:
llm:
apiUrl: "http://127.0.0.1:11434/api/chat" # 使用完整地址(而不是 api_base)
# apiKey: "xxxx"
provider: "ollama"
# model: "deepseek-r1:7b"
model: "qwen2.5:7b"
timeout: 600s

实现

LlmConfig

通过注入的方式获取配置,并初始化ChatModel,这里可以根据自在的需求进一步配置ChatModel。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.demo.ai.llm.config;

import org.noear.solon.ai.chat.ChatConfig;
import org.noear.solon.ai.chat.ChatModel;
import org.noear.solon.annotation.Bean;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.annotation.Inject;

/**
* @author airhead
*/
@Configuration
public class LlmConfig {
@Bean
public ChatModel build(@Inject("${demo.llm}") ChatConfig config) {
return ChatModel.of(config).build();
}
}

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.example.demo.ai.llm.controller;

import com.example.demo.ai.llm.service.LlmService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.io.IOException;
import org.noear.solon.annotation.*;
import org.noear.solon.core.util.MimeType;
import reactor.core.publisher.Flux;

/**
* @author airhead
*/
@Controller
@Mapping("/llm")
@Api("聊天")
public class LlmController {
@Inject private LlmService service;

@ApiOperation("chat")
@Post
@Mapping("chat")
public String chat(String prompt) {
return service.chat(prompt);
}

@Produces(MimeType.TEXT_EVENT_STREAM_UTF8_VALUE)
@Mapping("stream")
public Flux<String> stream(String prompt) throws IOException {
return service.stream(prompt);
}

@ApiOperation("functionCall")
@Post
@Mapping("functionCall")
public String functionCall(String prompt) {
return service.functionCall(prompt);
}
}

Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.example.demo.ai.llm.service;

import java.io.IOException;
import org.noear.solon.ai.chat.ChatModel;
import org.noear.solon.ai.chat.ChatResponse;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import reactor.core.publisher.Flux;

/**
* @author airhead
*/
@Component
public class LlmService {
@Inject private ChatModel chatModel;

public String chat(String prompt) {
try {
ChatResponse response = chatModel.prompt(prompt).call();

return response.getMessage().getContent();
} catch (IOException e) {
throw new RuntimeException(e);
}
}

public Flux<String> stream(String prompt) throws IOException {
return Flux.from(chatModel.prompt(prompt).stream())
.filter(ChatResponse::hasChoices)
.map(resp -> resp.getMessage().getContent());
}

public String functionCall(String prompt) {
try {
ChatResponse response =
chatModel.prompt(prompt).options(o -> o.functionAdd(new Tools())).call();

return response.getMessage().getContent();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

Tools

Solon 提供了多种设置 Fuction Call 的方式,这里只是从官网拿过来的一个例子,更多内容看这里 https://solon.noear.org/article/921

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.demo.ai.llm.service;

import org.noear.solon.ai.chat.annotation.FunctionMapping;
import org.noear.solon.ai.chat.annotation.FunctionParam;

public class Tools {
@FunctionMapping(description = "获取指定城市的天气情况")
public String get_weather(
@FunctionParam(name = "location", description = "根据用户提到的地点推测城市") String location) {
if (location == null) {
throw new IllegalStateException("arguments location is null (Assistant recognition failure)");
}

return "晴,24度"; // 可使用 “数据库” 或 “网络” 接口根据 location 查询合适数据;
}
}

验证

同步调用

img

流式调用

可以在浏览器中直接进行测试,目前的情况是,如果使用了推理模型 think 可能不显示,如果推理时间过长,可能出现超时的情况。作者在最新版本的SNAPSHOT中已处理,但我暂时还拉取不到最新版本。

img

Function Call

我们可能看到这例子里面只简单返回了 “晴,24度” 的天气情况。经过大模型的处理,内容就更完整丰富了。

img

小结

这里我只测试了 Solon AI 的基础功能,可以说是非常容易上手,通过简单的配置就能调用本地的服务了,如果是云服务也是一样的,增加配置 apiKey 就可以了。后续我将继续测试 Solon AI 的 RAG 和 Flow 的功能。

广告

在 AI 来临的时候,越来越多的新奇的东西会占有我们的时间,我们的注意力,不少人甚至认为有了 AI 甚至可以不用学习了,然而现实有了 AI 能学的人越能学,不学的人越不会学,就像现在面对同样的 AI 有的人能做出精美的图片,有的人能写出生动的故事,但一些人面对 AI 还是不知所措。那么更关键的是即使没有 AI,自己也应该是一台学习机器、生产机器。不论学习还是生产都是需要占用时间的,或者更准确的说是注意力,这个注意力就好像是时间的利用率,注意力越高,时间利用率就越高,越能学习,越能生产。

说明

Solon Cloud Gateway 是 一个可 Java 编程的分布式网关,提供了服务路由的能力和各种拦截的支持,只要是 http 服务(不需要关心实现的语言)都可以通过 Solon Cloud Gateway 进行代理转发,代理转发的服务也不一定要注册到服务注册中心。

虽然 Solon Cloud 提供了网关的实现,但在其官网出于性能及资源等原因的考虑,推荐优先使用专业网关(ngix,apisix,kong, k8s ingress controller),其次才是 Java 实现的网关 Spring Cloud Gateway 和 Solon Cloud Gateway。因此,在实际的项目中需要结合自己的项目情况来选择。

以下是微服务框架配合网关的架构图,图片来自 Solon 官网。https://solon.noear.org/article/328

img

依赖

1
2
3
4
5
6
7
8
9
10
dependencies {
implementation platform(project(":demo-parent"))

implementation("org.noear:nacos2-solon-cloud-plugin")
implementation("org.noear:solon.cloud.gateway")

annotationProcessor("org.mapstruct:mapstruct-processor:${mapstructVersion}")

testImplementation("org.noear:solon-test-junit5")
}

实现

响应式

Solon Cloud Gateway 使用响应式接口,由 Solon-Rx 来实现,是基于 reactive-streams 封装的 RxJava 极简版。目前仅一个接口 Completable,意为:可完成的发布者。

接口 说明
Completable 作为返回类型
Completable::complete() 构建完成发布者
Completable::error(cause) 构建异常发布者
Completable::create((emitter)->{…}) 构建发射器发布者

路由检测器

内置路由检测器

匹配检测器工厂 本置前缀 说明与示例
AfterPredicateFactory After= After 时间检测器,ZonedDateTime 格式 (After=2017-01-20T17:42:47.789-07:00[America/Denver])
BeforePredicateFactory Before= After 时间检测器,ZonedDateTime 格式 (Before=2017-01-20T17:42:47.789-07:00[America/Denver])
CookiePredicateFactory Cookie= Cookie 检测器 (Cookie=token)(Cookie=token, ^user.)
HeaderPredicateFactory Header= Header 检测器 (Header=token)(Header=token, ^user.)
MethodPredicateFactory Method= Method 检测器 (Method=GET,POST)
PathPredicateFactory Path= Path 检测器(支持多路径匹配,以”,”号隔开) (Path=/demo/) ,(Path=/demo/,/hello/**)

通常情况下,我们使用 PathPredicateFactory 就够用,不需要额外的配置。

路由过滤器

内置过滤器

Solon 提供了一些内置的路由过滤器,可以通过直接的配置就可以使用。

过滤器工厂 本置前缀 说明与示例
AddRequestHeaderFilterFactory AddRequestHeader= 添加请求头 (AddRequestHeader=Demo-Ver,1.0)
AddResponseHeaderFilterFactory AddResponseHeader= 添加响应头 (AddResponseHeader=Demo-Ver,1.0)
PrefixPathFilterFactory PrefixPath= 附加路径前缀 (PrefixPath=/app)
RedirectToFilterFactory RedirectTo= 跳转到 (RedirectTo=302,http://demo.org/a,true)
RemoveRequestHeaderFilterFactory RemoveRequestHeader= 移除请求头 (RemoveRequestHeader=Demo-Ver,1.0)
RemoveResponseHeaderFilterFactory RemoveResponseHeader= 移除响应头 (RemoveResponseHeader=Demo-Ver,1.0)
StripPrefixFilterFactory StripPrefix= 移除路径前缀段数 (StripPrefix=1)

定制

通过RouteFilterFactory定制路由过滤规则,实现不同类型的接口过滤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package com.example.demo.solon.filter;

import io.vertx.core.buffer.Buffer;
import lombok.extern.slf4j.Slf4j;
import org.dromara.hutool.core.text.StrUtil;
import org.noear.solon.annotation.Component;
import org.noear.solon.cloud.gateway.exchange.ExContext;
import org.noear.solon.cloud.gateway.exchange.ExFilter;
import org.noear.solon.cloud.gateway.exchange.ExFilterChain;
import org.noear.solon.cloud.gateway.route.RouteFilterFactory;
import org.noear.solon.rx.Completable;

/**
* @author airhead
*/
@Component
@Slf4j
public class AuthFilterFactory implements RouteFilterFactory {
public static final String TOKEN = "token";
public static final String EMPTY = "empty";

@Override
public String prefix() {
return "Auth";
}

@Override
public ExFilter create(String config) {
if (TOKEN.equals(config)) {
return new TokenFilter();
} else {
return new EmptyAuthFilter();
}
}

/** 默认的空鉴权 */
public static class EmptyAuthFilter implements ExFilter {
public EmptyAuthFilter() {}

@Override
public Completable doFilter(ExContext ctx, ExFilterChain chain) {
log.info("empty filter, do nothing");

return chain.doFilter(ctx);
}
}

/** token 鉴权 ,如果鉴权逻辑比较复杂的,用独立类 */
public static class TokenFilter implements ExFilter {
@Override
public Completable doFilter(ExContext ctx, ExFilterChain chain) {
String token = ctx.rawHeader("token");
if (StrUtil.isBlank(token)) {
ctx.newResponse().status(401);
ctx.newResponse().body(Buffer.buffer("token is empty"));
return Completable.complete();
}

return chain.doFilter(ctx);
}
}
}

其中 EmptyFilter 只是简单的记录日志,而 TokenFilter 会从头中获取 token 信息判断是否为空,为空是返回错误信息,不为空时正常情况。

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
server.port: 8000

solon.app:
name: 'demo-cloud-gateway'
# group: "DEFAULT_GROUP" # 默认值为 DEFAULT_GROUP
# namespace: "public" # 默认值为 public

knife4j.enable: true

# 运行样例时,屏蔽,或者配置app-local.yml
solon.env: local

solon.cloud.nacos:
server: "localhost:8848" #nacos2 服务地址
config:
load: "demo-cloud-gateway.yml"

solon.cloud.gateway:
discover:
enabled: false
excludedServices:
- "self-service"
httpClient:
responseTimeout: 1800 #单位:秒
routes:
- id: demo
index: 0 #默认为0
target: "http://127.0.0.1:8081"
predicates:
- "Path=/demo/**"
filters:
- "Auth=empty"
- "StripPrefix=1"
timeout:
responseTimeout: 1800 #单位:秒
- id: service01
index: 0 #默认为0
target: lb://demo-cloud-service01
predicates:
- "Path=/service01/**"
filters:
- "Auth=token"
- "StripPrefix=1"
timeout:
responseTimeout: 1800 #单位:秒

这里使用 service01 作为上游服务,配置直接地址和使用服务注册的地址来分别测试。

使用直接地址时,使用了一个空的鉴权逻辑,然后利用 Solon 内置的StripPrefix 来移除url 中的第一段,然后请求实际的服务。

使用注册服务时,使用了一个简单的Token鉴权逻辑,然后利用 Solon 内置的StripPrefix 来移除url 中的第一段,然后请求实际的服务。

验证

启动 demo-cloud-gateway 和 demo-cloud-service01。

通过service01来调用,也就是通过注册服务来调用。

img

通过demo来调用,也就是通过直接地址来调用。

img

如果 servic01 没有启动的情况返回的是404的错误。

小结

Solon Cloud Gateway 通过配置的方式就可以对服务进行代理转发,在实际的业务使用中通常会与服务发现一起,实现负载均衡,也可以通过编程的方式进一步的定制自己的业务逻辑,实现统一鉴权,限流熔断,灰度发布等功能。

说明

服务注册与发现是一种动态管理服务实例的机制,其目的是让各个服务能够及时了解其他服务的状态与位置,以便能进行相互的通讯与协作。

依赖

添加 nacos2 的依赖 org.noear:nacos2-solon-cloud-plugin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dependencies {
implementation platform(project(":demo-parent"))

implementation("org.noear:solon-web")
implementation("org.noear:solon-logging-logback")
implementation("org.noear:solon-openapi2-knife4j")
implementation("org.noear:nacos2-solon-cloud-plugin")
// 使用 httputils
implementation("org.noear:solon-net-httputils")

// 使用 NamiClient
implementation("org.noear:nami-coder-snack3")
implementation("org.noear:nami-channel-http")

annotationProcessor("org.mapstruct:mapstruct-processor:${mapstructVersion}")

testImplementation("org.noear:solon-test-junit5")
}

配置

Nacos2 插件已经做了些默认值,所以我不需要过多的配置即可连接到 nacos 发现服务,如果使用了server 节点,意思是配置服务和发现服务使用同一个配置,或者单独使用solon.cloud.nacos.discovery.server配置。

1
2
3
4
5
6
7
8
9
10
11
solon.app:
name: "demo-cloud-config"
# group: "DEFAULT_GROUP" # 默认值为 DEFAULT_GROUP
# namespace: "public" # 默认值为 public

solon.cloud.nacos:
server: "localhost:8848"
config:
load: "demo-cloud-config.yml"
# discovery:
# server: "localhost:8848"

服务调用

测试服务

测试服务中我们提供一个hello接口,并增加表示是从 Service01 的标识。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 package com.example.demo.solon.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.annotation.Param;

/**
* @author airhead
*/
@Api("Demo")
@Controller
@Mapping
public class DemoController {
@ApiOperation("hello")
@Mapping("/hello")
public String hello(@Param(defaultValue = "world") String name) {
return String.format("Hello %s!, By Service01", name);
}
}

DemoController

Solon 的 NamiClient 或者 HttpUtils 可以通过服务名来调用服务,其中NamiClient可以通过类似FeignClient的注解的方式进行使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.example.demo.solon.controller;

import com.example.demo.solon.client.DemoClient;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.io.IOException;
import org.noear.nami.annotation.NamiClient;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.annotation.Param;
import org.noear.solon.net.http.HttpUtils;

/**
* @author airhead
*/
@Api("Demo")
@Controller
@Mapping
public class DemoController {
@NamiClient private DemoClient demoClient;

@ApiOperation("hello")
@Mapping("/hello")
public String hello(@Param(defaultValue = "world") String name) {
try {
return HttpUtils.http("demo-cloud-service01", "/hello").data("name", name).get();
} catch (IOException e) {
throw new RuntimeException(e);
}
}

@ApiOperation("hello2")
@Mapping("/hello2")
public String hello2(@Param(defaultValue = "world") String name) {
return demoClient.hello(name);
}
}

DemoClient

1
2
3
4
5
6
7
8
9
10
11
package com.example.demo.solon.client;

import org.noear.nami.annotation.NamiClient;

/**
* @author airhead
*/
@NamiClient(name = "demo-cloud-service01")
public interface DemoClient {
String hello(String name);
}

验证

运行 demo-cloud-discovery 和 demo-cloud-service01,我们可以通过 nacos 看到服务到已经注册上来了。

img

接下来通过 hello 和 hello2 接口测试 两种不同方式的调用。

通过 httputils 调用服务

img

通过 NamiClient

img

小结

通过简单的配置,我们就可以把服务注册的 Nacos 中,并可以通过服务名的方式调用其他服务。当我们在普通的服务中增加服务配置和服务注册与发现功能配置后,就可以算作系统中的一个分布式微服务了,可以关注于业务的开发了。而 Solon Cloud 的接下来的更多的面向系统的可用性,可维护性等。

说明

Nacos /nɑ:kəʊs/ 是 Dynamic Naming and Configuration Service的首字母简称,一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。

接下来的服务配置和服务注册,我们使用的是 Nacos2。 需要自己部署好 Nacos2 ,具体内容可以参看 Nacos 的官网 https://nacos.io/docs/latest/quickstart/quick-start。

依赖

添加 nacos2 的依赖 org.noear:nacos2-solon-cloud-plugin

1
2
3
4
5
6
7
8
9
10
11
12
dependencies {
implementation platform(project(":demo-parent"))

implementation("org.noear:solon-web")
implementation("org.noear:solon-logging-logback")
implementation("org.noear:solon-openapi2-knife4j")
implementation("org.noear:nacos2-solon-cloud-plugin")

annotationProcessor("org.mapstruct:mapstruct-processor:${mapstructVersion}")

testImplementation("org.noear:solon-test-junit5")
}

配置

Nacos2 插件已经做了些默认值,所以我不需要过多的配置即可连接到 nacos 配置中心。

1
2
3
4
5
6
7
8
9
solon.app:
name: "demo-cloud-config"
# group: "DEFAULT_GROUP" # 默认值为 DEFAULT_GROUP
# namespace: "public" # 默认值为 public

solon.cloud.nacos:
server: "localhost:8848"
config:
load: "demo-cloud-config.yml"

load 使用group:dataId的格式加载配置配置文件,当加载的配置文件与 solon.app.group 配置的 group 一致时可以不用配置 group 的部分,也就是只需要 dataId 的部分即可。如果需要加载多个配置文件,使用逗号(,)分隔,加载多个配置文件通用配置特别有用,避免了在多个地方管理配置文件。

通过 load 加载的配置文件会进入 Solon.cfg() 成为应用配置,这样就可以通过 @Injtect 的方式进行注入。

以下是一份更详细的配置,内容来自官网 https://solon.noear.org/article/400 ,可以根据实际的需要进行配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
solon.app:
name: "demoapp"
group: "demo"
meta: #添加应用元信息(可选)
version: "v1.0.2"
author: "noear"
tags: "aaa,bbb,ccc" #添加应用标签(可选)

solon.cloud.nacos:
server: "localhost:8848,localhost:8847" #nacos 服务地址
namespace: "3887EBC8-CD24-4BF7-BACF-58643397C138" #nacos 命名空间
contextPath: "nacosx" #nacos 服务的上下文路径(可选)
username: "aaa"
password: "bbb"
config:
load: "demoapp.yml,group:test.yml" #加载配置到应用属性(多个以","隔开)
discovery:
clusterName: "DEFAULT"

获取配置

使用 nacos 的配置是期望获取统一的,动态的配置,所以这里开启自动刷新 autoRefreshed = true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.demo.solon.config;

import java.io.Serializable;
import lombok.Data;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.annotation.Inject;

/**
* @author airhead
*/
@Configuration
@Inject(value = "${app}", autoRefreshed = true)
@Data
public class AppConfig implements Serializable {
private String appId;
private String appKey;
}

接下来就是通过 @Injtect 的方式进行注入使用对应的配置。

1
2
3
4
5
6
7
@Api("配置管理")
@Controller
@Mapping("/config")
public class DemoController {
@Inject private AppConfig appConfig;

}

获取配置文件

获取配置文件根据 dataId 获取配置,通过 @CloudConfig 进行注入。CloudConfig 暂时不支持注解的类上,也就是不能使用如下的方法配合 @Inject 进行注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.demo.solon.config;

import java.io.Serializable;
import lombok.Data;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.cloud.annotation.CloudConfig;

/**
* 通过类获取dataId的配置,暂不支持的。
*
* @author airhead
*/
@Configuration
@CloudConfig(value = "demo-db", autoRefreshed = true)
@Data
public class DemoDbConfig implements Serializable {
private String url;
private String username;
private String password;
}

@Inject 暂时暂时不到值的。

1
2
3
4
5
6
7
@Api("配置管理")
@Controller
@Mapping("/config")
public class DemoController {
/** 获取dataId的配置,获取不到值 */
@Inject private DemoDbConfig demoDbConfig;
}

正确的方式是,把 CloudConfig 注解在属性上。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DemoController {
/** 获取dataId的配置,获取不到值 */
@Inject private DemoDbConfig demoDbConfig;

@Inject private AppConfig appConfig;

@CloudConfig(value = "demo-db", autoRefreshed = true)
private Properties properties;

@CloudConfig(value = "demo-db", autoRefreshed = true)
private DemoDbConfig demoDbConfig2;

}

验证

通过 Swagger 测试可以正常获取到配置,并且修改 Nacos 的配置后,再次调用接口配置会更新(这里没有截图)。

img

小结

在 Solon Cloud 中通过简单的配置就可以使用 Nacos 配置中心了。在实际的使用过程中注意区分@Inject 和@CloudConfig 的不同,一个是针对key来的,一个是针对dataId来的。为了减少自己在开发过程中的混乱(有时过多的选择不是一件好事情),可以约定使用 load 的方式加载多个配置文件,使用 @Inject 的方式进行注入后使用。

说明

前面的章节,我们讲解了 Solon 的开发应用,接下来准备讲解 Solon Cloud 的的开发。Solon Cloud 是为微服务和云原生准备的分布式开发套件。

微服务

就像 MVC 一样,对于微服务的理解也是有不同的。微服务是一组协调工作的小而自治的服务。微服务是一组分布式的架构框架。

微服务有支持异构,弹性,易扩展,容易替换等优点,但也增加了开发、测试、部署、运维的复杂性。

云原生

为了解决微服务(或者系统)的开发、部署、运维的复杂性,CNCF (Cloud Native Computing Foudation)提出了云原生的概念,就是利用各组织在共有云、私有云和混合云等新型动态环境中,构建和运行可弹性扩展的应用。主要涉及DevOps,CI/CD,Micro Service,Contianer等四个大的方面。

Solon Cloud

img

从上图,我们可以看到 Solon Cloud 的主要组成部分:

  • Solon Cloud Gateway 分布式网关相关插件
  • Solon Cloud Config 分布式配置相关插件
  • Solon Cloud Discovery 分布式注册与发现相关插件
  • Solon Cloud Event 分布式时间总线相关插件
  • Solon Cloud Job 分布式任务调度插件
  • Solon Cloud File 分布式文件插件
  • Solon Cloud Log 分布式日志插件
  • Solon Cloud Trace 分布式跟踪插件
  • Solon Cloud Metrics 分布式监控插件
  • Solon Cloud Breaker 分布式熔断插件
  • Solon Cloud Id 分布式 ID 插件
  • Solon Cloud I18n 分布式国际化配置插件
  • Solon Cloud List 分布式名单,白名单、黑名单等
  • Solon Cloud Lock 分布式锁插件

我们可以看到Solon Cloud 其实是定义了一组接口规范,在这个接口规范的基础上实现不同分布式组件的插件。

在 Solon 的官网中也对分布式设计做了引导,我这里列出重点的部分,详细内容可查看官网 https://solon.noear.org/article/638。

  1. 构建可水平扩展的计算能力
    1. 服务无状态
    2. 服务透明化
    3. 容器弹性伸缩
  2. 构建可水平扩展的业务能力
    1. 基于业务领域拆分微服务
    2. 拆分业务的主线与辅线
    3. 基于实现总线交互

书籍推荐

《微服务架构设计模式》

img

《微服务设计》

img

The Twelve-factor App

https://12factor.net/

技术栈

在本教程的介绍中说到,本教程更面向使用,因此会限制技术栈,对于Solon Cloud 的使用也是一样的,基础的服务:

  • Nacos2 配置管理、服务发现。
  • Redis,分布式 ID,分布式锁等。
  • Skywalking,链路跟踪。
  • Kafka 消息服务。
  • Sentinel 熔断

说明

Solon 是典型的微内核架构,通过框架核心提供了插件的核心接口和生命周期的管理。在之前的应用的生命周期中讲到过插件的三个生命时机点。通过这三个时机点,插件开发人员可以管理插件的资源初始化与资源的销毁。

Solon 的插件支持三种形式的运行方式:

  1. Spi 方式。是基于插件和配置声明的扩展方式,类似Spring Factories,主应用需要增加对插件的依赖。
  2. E-Spi 方式,也叫体外扩展。是基于插件和动态配置的扩展方式,类似Springboot-plugin-fraework,主程序不需要增加对插件的依赖。
  3. H-Spi 方式,也叫热插拔扩展。是基于插件和手动管理的扩展方式,类似Springboot-plugin-fraework,主程序不需要增加对插件的依赖。

这三种扩展方式对于开发插件本身没有什么区别,主要的区别是主应用的依赖和配置等,和实现的效果的不同,具体的特性对比可以参看官网 https://solon.noear.org/article/441 相关的文章。

插件涉及的示例代码包含多个模块,具体参看 https://gitee.com/CrazyAirhead/porpoise-demo:

  • demo-solon03-plugin Solon 插件
  • demo-solon03-main01 Solon 主应用 Spi
  • demo-solon03-main02 Solon 主应用 E-Spi
  • demo-solon03-main03 Solon 主应用 H-Spi

插件 demo-solon03-plugin

实现 hello 的基础功能。

实现 Plugin 接口

这里为了简单,直接扫描了插件包的 Bean 对象,在实际的开发过程中可根据实际的实现使用Conetext的其他方法构建 Bean,或者订阅 Bean,或者加载配置文件等,来完成实际的插件初始化操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.example.demo.solon.plugin;

import org.noear.solon.core.AppContext;
import org.noear.solon.core.Plugin;

/**
* @author airhead
*/
public class DemoSolonPlugin implements Plugin {
@Override
public void start(AppContext context) throws Throwable {
// 简单起见,直接扫描插件所在包,可以根据实际的需要进行定制化的加载
context.beanScan(DemoSolonPlugin.class);
}

@Override
public void prestop() throws Throwable {
Plugin.super.prestop();
}

@Override
public void stop() throws Throwable {
// 移除http处理。在热插拔的插件中特别
Solon.app().router().remove("/hello");
}
}

实现 Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.demo.solon.plugin.controller;

import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.annotation.Param;

/**
* @author airhead
*/
@Controller
public class DemoController {
@Mapping("/hello")
public String hello(@Param(defaultValue = "world") String name) {
return String.format("Hello %s!", name);
}
}

配置插件文件

resources/META-INF/solon/ 目录下创建插件配置文件,文件名要在应用范围内全局唯一,为了避免冲突可以使用插件的包名做了文件名。

这里使用是META-INF/solon/com-example-demo-solon-plugin.properties,文件内容如下:

1
2
3
4
# 插件实现类
solon.plugin=com.jishunetwork.ficus.framework.server.XPluginImp
# 插件加载优先级,越大越优先,默认为0
solon.plugin.priority=1

这里常见的问题:文件目录错误,注解检查是二级目录,目录名是/META-INF/solon/;插件的实现类写错,可以通过点击的方式看下能否正常跳转到对应的类上来验证配置是否正确。

Spi demo-solon03-main01

只要添加对应的依赖即可不需要其他额外的配置。

添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
dependencies {
implementation platform(project(":demo-parent"))

implementation("org.noear:solon-web")
implementation("org.noear:solon-logging-logback")
implementation("org.noear:solon-openapi2-knife4j")
implementation(project(":demo-solon03-plugin"))

annotationProcessor("org.mapstruct:mapstruct-processor:${mapstructVersion}")

testImplementation("org.noear:solon-test-junit5")
}

验证

调用插件实现的接口,http://127.0.0.1:8080/hello,返回hello world!

E-Spi demo-solon03-main02

只要添加修改配置,把插件放入指定文件夹路径即可。

配置

此时把插件目录放在jar相对路径的plugin下。

1
solon.extend: "!plugin"

注意:在idea中可能一下不能区分plugin再那个目录,可以先运行一次让 Solon 把目录创建出来,然后再把插件放到 plugin 目录下,此时我们可以看到是在 build/classes/java/main 目录下。把demo-solon03-plugin的插件放入plugin 目录后重新启动程序(注意此时不能再clean了,否则 刚放入的文件又被清理了)。

img

验证

调用插件实现的接口,http://127.0.0.1:8080/hello,返回hello world!

H-Spi demo-solon03-main03

热插拔的就是允许应用可以更灵活的管理插件的生命周期,因为热插拔是完全隔离的,因此需要自己更严格的管理资源的,更独立,避免与别的组件交互,避免不可拔的情况。

依赖

1
2
3
4
5
6
7
8
9
10
11
12
dependencies {
implementation platform(project(":demo-parent"))

implementation("org.noear:solon-web")
implementation("org.noear:solon-logging-logback")
implementation("org.noear:solon-openapi2-knife4j")
implementation("org.noear:solon-hotplug")

annotationProcessor("org.mapstruct:mapstruct-processor:${mapstructVersion}")

testImplementation("org.noear:solon-test-junit5")
}

配置

这里使用 Solon 默认提供的配置来管理,在加载的插件的过程中,只要能找到插件就可以了,不一定需要使用这个配置,注意路径是绝对路径。

1
2
solon.hotplug:
demo: /plugin/demo-solon03-plugin-1.0.0.jar

实现管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.example.demo.solon.controller;

import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.hotplug.PluginManager;

/**
* @author airhead
*/
@Controller
@Slf4j
public class PluginController {
@Mapping("start")
public String start() {
PluginManager.start("demo");
return "ok";
}

@Mapping("stop")
public String stop() {
PluginManager.stop("demo");
return "ok";
}
}

验证

  1. 调用插件实现的接口,http://127.0.0.1:8080/hello,此时不可访问
  2. 调用启动插件接口,http://127.0.0.1:8080/start,返回ok
  3. 调用插件实现的接口,http://127.0.0.1:8080/hello,此时返回 hello world
  4. 调用停止插件接口,http://127.0.0.1:8080/stop,返回ok
  5. 调用插件实现的接口,http://127.0.0.1:8080/hello,此时不可访问

小结

通过 Solon 提供的插件机制能简单、弹性、自由的实现功能扩展,既可以做框架型的插件开发,也可以做业务性的插件开发。