CrazyAirhead

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

0%

问题

升级 JDK 17 后,一个自定义请求,提示 The server selected protocol version TLS10 is not accepted的异常,这个问题,在之前应该是碰到过的,所以有记录了对应的链接,https://cloud.tencent.com/developer/article/2127522。但这次在客户那部署,虽然调整了对应的参数,但依然还是请求失败。趁着周末重新验证了这个 TLSv1 的请求。

img

处理

解封 TLSv1

因为 TLSv1,TLSv1.1 有已知的安全漏洞,所以在高版本的 JDK里面默认的禁用了 TLSv1,TLSv1.1 的安全算法。通过whereis 和 ll 等命令可以定位 JAVA_HOME 目录(也可以直接尝试 echo $JAVA_HOME 看看是否有配置对应的目录),接着定位到$JAVA_HOME/conf/security/java.security 文件。

找到 jdk.tls.disabledAlgorithms部分,旧内容如下:(不同JDK 版本可能略有不同)

1
2
jdk.tls.disabledAlgorithms=SSLv3, TLSv1, TLSv1.1, RC4, DES, MD5withRSA, \
DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL

去除 TLSv1, TLSv1.1, ,修改如下:

1
2
jdk.tls.disabledAlgorithms=SSLv3, RC4, DES, MD5withRSA, \
DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL

如果不想修改这个配置,也可以尝试的程序运行时指定参数。

1
java -Djdk.tls.client.protocols="TLSv1,TLSv1.1,TLSv1.2,TLSv1.3" -Djdk.tls.server.protocols="TLSv1,TLSv1.1,TLSv1.2,TLSv1.3" -jar app.jar

通常情况下,此时应该能正常使用,但发到现场后发现还是上面的错误。

模拟环境

从提示中我们可以看到,应该是对方的https 服务使用的是 TLSv1 版本的协议,于是修改自己的 nginx 服务强制指定ssl_protocols 为 TLSv1;

1
2
ssl_protocols TLSv1;
ssl_ciphers HIGH:!aNULL:!MD5;

此时浏览器范围页面也是异常的。

img

此时通过程序测试,提示的异常虽然有些不同,但基本可以看出是同一个问题引起的了。

img

尝试切换 hutool 的 httputil

切换 httputil 后发现服务请求自定义证书,提示下面的错误。

img

经过一番查看源代码后,发现 hutool 支持设置一个全局参数HttpGlobalConfig.setTrustAnyHost(true),设置了全局参数之后发现,请求正常了。

img

为什么 httputils 不行呢?

一开始以为是创建的 SSLContext 不同,更换成 hutool 工具创建的 SSLContext SSLContextUtil.createTrustAnySSLContext 依然还是不行。于是对比了下 hutool 和 solon 的是请求默认的类库不同,hutool 默认使用的是httpclient4,solon默认使用的是okhttp。当把 hutool 的默认请求切换到 okhttp 时,虽然一样也设置了*setTrustAnyHost*(true),但请求一样是报错的。

接着跟踪到 hutool 中 httpclient4 里面的一个关键设置。

1
2
3
private static SSLConnectionSocketFactory buildSocketFactory(SSLInfo sslInfo) {
return null == sslInfo ? SSLConnectionSocketFactory.getSocketFactory() : new SSLConnectionSocketFactory(sslInfo.getSslContext(), sslInfo.getProtocols(), (String[])null, sslInfo.getHostnameVerifier());
}

此时可以比较明确的确定是连接客户也需要设置的问题,于是找到 OkHttpClient 创建的位置,增加设置connectionSpecs

img

solon 作者说按上述设置会导致 http 无法访问,同时还发现 okhttp 还提供了一些常量,换成如下写法兼容性更好。

1
connectionSpecs(Arrays.asList(ConnectionSpec.COMPATIBLE_TLS, ConnectionSpec.CLEARTEXT))

临时处理

扩展 OkHttpUtilsFactory

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
public class OkHttpUtilsFactoryExt extends OkHttpUtilsFactory {
static final Logger log = LoggerFactory.getLogger(OkHttpUtilsFactory.class);
private static final OkHttpUtilsFactory instance = new OkHttpUtilsFactory();
private static final OkHttpDispatcher dispatcher = new OkHttpDispatcher();
private final OkHttpClient defaultClient = createHttpClient(null, null);

private static OkHttpClient createHttpClient(Proxy proxy, HttpSslSupplier sslProvider) {
if (sslProvider == null) {
sslProvider = HttpSslSupplierDefault.getInstance();
}

OkHttpClient.Builder builder =
(new OkHttpClient.Builder())
.connectTimeout(10L, TimeUnit.SECONDS)
.writeTimeout(60L, TimeUnit.SECONDS)
.readTimeout(60L, TimeUnit.SECONDS)
.dispatcher(dispatcher.getDispatcher())
.addInterceptor(OkHttpInterceptor.instance)
.sslSocketFactory(sslProvider.getSocketFactory(), sslProvider.getX509TrustManager())
.connectionSpecs(Arrays.asList(ConnectionSpec.COMPATIBLE_TLS, ConnectionSpec.CLEARTEXT))
.hostnameVerifier(sslProvider.getHostnameVerifier());

if (proxy != null) {
builder.proxy(proxy);
}

return builder.build();
}

@Override
protected OkHttpClient getClient(Proxy proxy, HttpSslSupplier sslProvider) {
return createHttpClient(proxy, sslProvider);
}
}

扩展 HttpSslSupplier

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
public class ExtensionHttp extends HttpSslSupplierDefault
implements HttpExtension, HttpSslSupplier {
// for HttpSslSupplier
private SSLContext sslContext;
private String[] protocols;

// for HttpExtension
@Override
public void onInit(HttpUtils httpUtils, String url) {
httpUtils.ssl(this);
}

@Override
public SSLContext getSslContext() {
return SSLContextUtil.createTrustAnySSLContext();
}
}

使用

如果指定了 HttpExtension的扩展为 Component,默认情况是会自动注册扩展的,测试时可能需要自己通过addExtension 的方式添加扩展。

1
2
HttpConfiguration.setFactory(new OkHttpUtilsFactoryExt());
HttpConfiguration.addExtension(new ExtensionHttp());

正式处理

等待 solon 3.5.0 版本的发布,之后切换 SSLContextUtil.createTrustAnySSLContext(),替换默认的SSLContext。

说明

最近已经陆续把公司的 Spring Boot 服务迁移到了 Solon 框架中来,期间碰到几次迁移后无法启动的问题或者启动慢问题。以下是问题的记录和处理办法。

问题 1

启动提示循环依赖,启动失败。

img

通常情况 Solon 的 Inject 是支持循环依赖的,有两种情况会因为循环依赖而启动失败,具体细节可以参考官网文档,「问题:产生 Bean 循环依赖怎么办?」

  1. 由构造函数产生的依赖
  2. 由初始化的依赖

而我碰的的刚好就是第二种情况,在就的 Spring Boot 服务中有一些 Spring Event 事件是通过过 PostConstruct 注解来注册。所以迁移过来的时候使用了 Init 的注解,如下代码。

img

但因为另一个类也存在 Init 的注解且相互依赖,所以无法正常启动,解决办法就是设置不同的优先级。

类 1

1
2
3
4
@Init(index = 2)
public void init() {
EventUtils.listen(ManageEvent.TOPIC_USER_UPDATE, new UpdateUserListener());
}

类 2

1
2
3
4
@Init(index = 1)
public void init() {
EventUtils.listen(ManageEvent.TOPIC_ORG_CREATE, new CreateOrgListener());
}

问题 2

应用启动慢

img

迁移了几个服务,通常情况下Solon 服务的启动会比 Spring Boot 的服务启动快。但有个服务器迁移过来的时候启动很慢,表现为扫描 Bean 对象很慢。

一开始时没有头绪的,后面问了作者,之后通过直接跟踪源代码,定位到慢的类,发现这个类是写得比较点怪的,是一个工具类,但为了获取其他 Bean 对象方便,把其他用的的类一起注入进来了,所以给这个类,用 PostConstruct 来做一次初始化,迁移过来的是自然就用了 Init 注解。这次扫描 Bean 慢,可能出现了较深的依赖关系,但还没形成循环依赖。

img

处理办法就是取消了 Init 的注解,AppLoadEndEvent 事件中,执行一次 Init 操作。

1
2
3
4
5
6
7
8
9
public class AppLoadEndEventListener implements EventListener<AppLoadEndEvent> {
@Inject private ESHelper esHelper;

@Override
public void onEvent(AppLoadEndEvent appLoadEndEvent) throws Throwable {

esHelper.init();
}
}

问题 3

旧的 Spring Boot 有个 Redis 消息队列的监听,于是修改成如下的代码。但发现项目启动的时候卡住了无法启动。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class RedisMessageConfig {

public static final String SCAN_JOB_MESSAGE = "scanJob-message";
/* 单次任务队列 */
public static final String SCAN_ONCE_MESSAGE = "scanOnce-message";

@Bean
public void init() {
RedisUtils.subscribe(new ScanJobSubscribe(), SCAN_JOB_MESSAGE);
RedisUtils.subscribe(new ScanJobSubscribe(), SCAN_ONCE_MESSAGE);
}
}

后面跟踪了代码才发现 redisx 的 subscribe,里面包含一个 process 其实是一个无限循环。在官网文档「应用生命周期」中有个提示:

重要提醒

  • 启动过程完成后,项目才能正常运行(启动过程中,不能把线程卡死了)
  • AppBeanLoadEndEvent 之前的事件,需要启动前完成订阅!!!(否则,时机错过了)

知道原因,处理起来就比较简单了,统一在 AppLoadEndEvent 事件中处理,并使用异步订阅。

1
2
3
4
5
6
7
8
9
10
@Slf4j
@Component
public class AppLoadEndEventListener implements EventListener<AppLoadEndEvent> {
@Inject private ScanJobSubscribe scanJobSubscribe;

@Override
public void onEvent(AppLoadEndEvent appLoadEndEvent) throws Throwable {
RedisUtils.subscribeFuture(scanJobSubscribe, SCAN_JOB_MESSAGE);
RedisUtils.subscribeFuture(scanJobSubscribe, SCAN_ONCE_MESSAGE);
}

小结

Solon 可能出于简单、快速等原因考虑,对待循环依赖是比较粗暴的,直接报错。但出现启动慢等问题时可能就不容易立刻定位问题,此时可以优先看看有没有使用 Init 注解,有没有使用 Bean 注解。这些相关的类是否有依赖,这些初始化函数是否把线程卡死了。

反过来说, Spring Boot 为了解决循环依赖方面还是做了很多的检查或者调整,所以可能导致启动速度慢一些。不过听说 Spring Boot 3.x 默认也禁用了循环依赖。

说明

最近在做的事情就是把 Spring Boot 的项目迁移到 Solon 上来。我们的有一个 OA 系统,里面使用到了 Flowable 6.5。照例我还是从官网入手,其中说到是不需要适配可以直接使用。然后附带了例子,https://gitee.com/hiro/flowable-solon-web。

看起来还是比较简单,但也没那么简单,还好之前有看网友提供了 easy-flowable,https://gitee.com/iajie/easy-flowable,中间也有请教作者几个问题,感谢。如果是新项目推荐使用,easy-flowable 不仅提供了封装和增强,还提供了不错的 UI编排界面,使得 flowable 更简单易用。

在统一看了相关的代码之后,主要的适配工作,就是根据配置设置ProcessEngineConfiguration, 并创建 ProcessEngine 及相关的 Service 的 bean 对象。

方法

在我们的系统中为一些基础组件采用 Solon 插件的方式封装一部分初始化工作。

配置

1
2
3
4
5
6
7
8
9
10
11
/**
* @author airhead
*/
@Configuration
@Inject(value = "${flowable}", autoRefreshed = true)
@Data
public class FlowableProperties {
private String databaseSchemaUpdate = "true";
private Boolean asyncExecutorActivate = false;
private String historyLevel = "audit";
}

构建 Bean

根据配置设置ProcessEngineConfiguration, 并创建 ProcessEngine 及相关的 Service 的 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
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
public class XPluginImp implements Plugin {
private AppContext appContext;

@Override
public void start(AppContext context) throws Throwable {
appContext = context;

appContext.beanScan(FlowableProperties.class);

appContext.getBeanAsync(
DataSource.class,
dataSource -> {
FlowableProperties flowableProperties = appContext.getBean(FlowableProperties.class);

ProcessEngineConfigurationImpl engineConfiguration =
(ProcessEngineConfigurationImpl)
ProcessEngineConfiguration.createStandaloneInMemProcessEngineConfiguration();

engineConfiguration.setDataSource(dataSource);
engineConfiguration.setDatabaseSchemaUpdate("false");
// 设置流程历史级别
engineConfiguration.setHistoryLevel(
HistoryLevel.getHistoryLevelForKey(flowableProperties.getHistoryLevel()));

// 设置表达式管理器
ExpressionManager ficusExpressionManager = new SolonExpressionManager(appContext, null);
engineConfiguration.setExpressionManager(ficusExpressionManager);

// 定时任务开关
engineConfiguration.setAsyncExecutorActivate(
flowableProperties.getAsyncExecutorActivate());
appContext.wrapAndPut(ProcessEngineConfiguration.class, engineConfiguration);

ProcessEngine processEngine = engineConfiguration.buildProcessEngine();
appContext.wrapAndPut(ProcessEngine.class, processEngine);

RuntimeService runtimeService = processEngine.getRuntimeService();
appContext.wrapAndPut(RuntimeService.class, runtimeService);

RepositoryService repositoryService = processEngine.getRepositoryService();
appContext.wrapAndPut(RepositoryService.class, repositoryService);

IdentityService identityService = processEngine.getIdentityService();
appContext.wrapAndPut(IdentityService.class, identityService);

TaskService taskService = processEngine.getTaskService();
appContext.wrapAndPut(TaskService.class, taskService);

HistoryService historyService = processEngine.getHistoryService();
appContext.wrapAndPut(HistoryService.class, historyService);

ManagementService managementService = processEngine.getManagementService();
appContext.wrapAndPut(ManagementService.class, managementService);

FormService formService = processEngine.getFormService();
appContext.wrapAndPut(FormService.class, formService);

DynamicBpmnService dynamicBpmnService = processEngine.getDynamicBpmnService();
appContext.wrapAndPut(DynamicBpmnService.class, dynamicBpmnService);
});
}

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

表达管理器 (ProcessExpressionManager)

重要,如果没有添加自己的表达式管理,一些流程在执行的时候可能出错。

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
/**
* @author airhead
*/
public class SolonExpressionManager extends ProcessExpressionManager {
protected AppContext appContext;

public SolonExpressionManager(AppContext appContext, Map<Object, Object> beans) {
super(beans);
this.appContext = appContext;
}

@Override
protected ELResolver createElResolver(VariableContainer variableContainer) {
List<ELResolver> elResolvers = new ArrayList<>();
elResolvers.add(createVariableElResolver(variableContainer));
elResolvers.add(new AppContextElResolver(this.appContext));
if (beans != null) {
elResolvers.add(new ReadOnlyMapELResolver(beans));
}
elResolvers.add(new ArrayELResolver());
elResolvers.add(new ListELResolver());
elResolvers.add(new MapELResolver());
elResolvers.add(new JsonNodeELResolver());
ELResolver beanElResolver = createBeanElResolver();
if (beanElResolver != null) {
elResolvers.add(beanElResolver);
}

configureResolvers(elResolvers);

CompositeELResolver compositeElResolver = new CompositeELResolver();
for (ELResolver elResolver : elResolvers) {
compositeElResolver.add(elResolver);
}
compositeElResolver.add(new CouldNotResolvePropertyELResolver());
return compositeElResolver;
}
}

ELResolver

EL 表达式执行器,用于处理 Solon 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
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
/**
* @author airhead
*/
@Slf4j
public class AppContextElResolver extends ELResolver {
protected AppContext appContext;

public AppContextElResolver(AppContext appContext) {
this.appContext = appContext;
}

@Override
public Class<?> getCommonPropertyType(ELContext context, Object arg) {
return Object.class;
}

@Override
public Iterator<FeatureDescriptor> getFeatureDescriptors(ELContext context, Object arg) {
return null;
}

@Override
public Class<?> getType(ELContext context, Object arg1, Object arg2) {
return Object.class;
}

@Override
public Object getValue(ELContext context, Object base, Object property) {
if (base == null) {
String key = property.toString();
Object bean = appContext.getBean(key);
if (bean != null) {
context.setPropertyResolved(true);
return bean;
}
}
return null;
}

@Override
public boolean isReadOnly(ELContext context, Object base, Object property) {
return true;
}

@Override
public void setValue(ELContext context, Object base, Object property, Object value) {
if (base == null) {
String key = (String) property;
this.appContext.getBeanAsync(
key,
(bean) -> {
throw new FlowableException(
"Cannot set value of '"
+ property
+ "', it resolves to a bean defined in the Solon application-context.");
});
}
}
}

问题

  1. 提示模型注册失败,并提示 java.time.LocalDateTime cannot be cast to java.lang.String

img

img

主要的问题,集成的是 6.5.0的 Flowable,不支持高版本的 MySQL 驱动。检查发现Spring 版本使用的是 8.0.20的MySQL 驱动,而 Solon 使用的是 8.0.30 版本的驱动。把MySQL 驱动版本恢复为8.0.20,服务启动成功。

  1. 提示找不到对象 TaskListener 相关的表达式对象,一些流程条件的时候提示如下的错误。

img

原因是虽然通过 Component 注册了 Bean 对象,但 Flowable 无法获取,需有重写 ExpressionManager,增加Solon bean 对象的获取。

这里需要注意,注册 ExpressionManager 时,需要在 ProcessEngine processEngine = engineConfiguration.buildProcessEngine()之前否则可能构建了两个ExpressionManager,在执行获取 TaskListener 的对象时仍然可能报错。

1
2
3
// 设置表达式管理器
ExpressionManager ficusExpressionManager = new SolonExpressionManager(appContext, null);
engineConfiguration.setExpressionManager(ficusExpressionManager);

增加 SolonExpressionManager,继承自 ProcessExpressionManager。重写 EL 表达式执行器,多增加AppContextElResolver。

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
@Override
protected ELResolver createElResolver(VariableContainer variableContainer) {
List<ELResolver> elResolvers = new ArrayList<>();
elResolvers.add(createVariableElResolver(variableContainer));
elResolvers.add(new AppContextElResolver(this.appContext));
if (beans != null) {
elResolvers.add(new ReadOnlyMapELResolver(beans));
}
elResolvers.add(new ArrayELResolver());
elResolvers.add(new ListELResolver());
elResolvers.add(new MapELResolver());
elResolvers.add(new JsonNodeELResolver());
ELResolver beanElResolver = createBeanElResolver();
if (beanElResolver != null) {
elResolvers.add(beanElResolver);
}

configureResolvers(elResolvers);

CompositeELResolver compositeElResolver = new CompositeELResolver();
for (ELResolver elResolver : elResolvers) {
compositeElResolver.add(elResolver);
}
compositeElResolver.add(new CouldNotResolvePropertyELResolver());
return compositeElResolver;
}

增加 AppContextElResolver,继承自 ELResolver,获取 Bean 对象的代码,这里需要注意如果对象创建了,需要设置context.setPropertyResolved(true)

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public Object getValue(ELContext context, Object base, Object property) {
if (base == null) {
String key = property.toString();
Object bean = appContext.getBean(key);
if (bean != null) {
context.setPropertyResolved(true);
return bean;
}
}
return null;
}

说明

在「使用 Solon Cloud Gateway 替换Spring Cloud Gateway 」的文章中,有评论说不知道响应式。当时看的是

Solon Cloud Gateway 使用响应式接口,由 Solon-Rx 来实现,是基于 reactive-streams 封装的 RxJava 极简版。目前仅一个接口 Completable,所以当时以为实现了 ExFilter 返回 Completable 就是响应式了。后续 Solon Cloud Gateway 继续更新文档之后才发现,虽然网关是响应式了,但如果逻辑涉及 IO 或者比较慢的操作,需要在 ExFilter 中开启异步接口,这样才是真正的异步,从而避免对网关的事件循环器造成影响,减少对响应式的性能的伤害。

为了对接同步 IO 接口,Solon 3.2.1 提供了 CloudGatewayFilterSync,用于简化异步调用的编写。

基础版

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
//同步过滤器(会自动转异步)
@FunctionalInterfacepublic interface ExFilterSync extends ExFilter {
@Overridedefault Completable doFilter(ExContext ctx, ExFilterChain chain) {
return Completable.create(emitter -> {
//暂停接收流
ctx.pause();

//开始异步
RunUtil.async(() -> {
try {
//开始同步处理boolean isContinue = doFilterSync(ctx);

if (isContinue) {
//继续
chain.doFilter(ctx).subscribe(emitter);
} else {
//结束
emitter.onComplete();
}
} catch (Throwable ex) {
emitter.onError(ex);
}
});
});
}

/**
* 执行过滤同步处理(一般用于同步 io)
*
* @param ctx 上下文
* @return 是否继续
*/boolean doFilterSync(ExContext ctx) throws Throwable;
}

这个 doFilter 的过程中需要自己先处理 ctx.pause,然后开启异步,并在开启的过程中判断是否异常的提交等等,需要写好这个 ExFitler 的扩展还是有点不容易的。

于是作者又贴心的提供了CloudGatewayFilterSync

简化版

1
2
3
4
5
6
7
8
9
10
11
@Component(index = -9)
public class AuthFilterSync implements CloudGatewayFilterSync {
@Inject
AuthJdbcService authService;

@Overridepublic boolean doFilterSync(ExContext ctx) throws Throwable {
String userId = ctx.rawQueryParam("userId");
//检测路径权限
return authService.check(userId, ctx.rawPath());
}
}

在简化的版本中,只需要在 doFilterSync 中编写具体的逻辑,而不要关心流的暂停和开启异步等。如果已经处理完毕,不需要继续执行返回 ture,否则返回 false。

参考

https://solon.noear.org/article/813

https://solon.noear.org/article/1005

说明

在「 操作 SQL 」中说到,个人比较喜欢 ActiveRecord,后续也会补充对 easy-query 的扩展,比如集成 ActiveRecord 的 SQL 模版管理,基于 Map 的 Model Bean。

可能自己最早是开始从 SQL 开始也业务的,不是一开始就接触 ORM,所以个人习惯使用 SQL 进行代码的编写,特别是有连接的时候,虽然 easy-query 对 join 处理已经很好了,但我还是觉得书写比较麻烦。好在 easy-query提供了原生的 SQL 查询的支持,因此只需要增加 SQL 模版的管理功能,就能使用安心的使用 SQL 写查询了。

在使用 Mybatis-Plus 的过程中发现,这个 ORM 框架在多租户是最无感的。因为引入了 jsqlparser,不论是普通的 orm 的 dsl 写法,还是 SQL 写法都不需要关心租户字段。其他的一些 ORM 框架只是关注 dsl 的写法是能租户字段无感的,在写 SQL 时就需要自己处理租户字段,easy-query 也不例外,jfinal 也是如此。

基于 Map 的 Model Bean,或者 ActiveRecord 的一个好处就是对查询结果的动态扩展或者裁剪,当然这都是要配合 Jfinal 自己的 JSON 序列化,否则动态增加的字段是不会被处理的,另外就是 Jfinal 还提供了 keep 和 remove 等方法可以对数据进行裁剪。这样的好处是什么呢?在代码中可以统一使用一套 Modle 进行传递,而不是各种 Vo 和 Dto 的转换。

自己使用 easy-query 的也不是很熟练,所以这次的调整不一定很完整,这里相当于是提供了一个思路供大家。阅读本文的同学根据自己的情况谨慎使用和自行修改。因为本次的代码的集成修改在另一个项目中(依赖有其他项目的依赖,增加了easyquery和easy-query-web 插件),还没有整理到 porpoise-demo 项目中,所以暂时看不了源码。后续剥离项目的依赖后会陆续上传到 porpoise-demo 中。

Enjoy

因为 AtiveRecord 使用了 enjoy ,做了模版管理的基础,因此可以使用引入依赖包的方式引入,然而原生的 enjoy 对 solon 的容器支持问题(https://gitee.com/jfinal/jfinal/pulls/119),solon 对 enjoy 进行了重新编译。在使用的过程中,发现其实单独版本的 enjoy 和 activerecord 还是有些裁剪的。因此自己整理重新 fork 了一个enjoy 和 activerecord 的版本,基于 5.2.2 版本做了些修改,代码托管在 coding。

地址:

https://e.coding.net/goldsyear/allblue/porpoise-jfinal.git

坐标:

implementation(“com.goldsyear:enjoy:1.0.0”)

ActiveRecord

增加 acitverecord 代码

首先 easy-query 提供了 solon 的插件,将 easy-query 的 solon 插件克隆出来。jfinal 的 ActiveRecord 主要 plugin/activerecord 目录。因此先把两个目录整合在一起,修复其中的编译错误,一些缺失不会处理的可以先屏蔽,确保已经被编辑过来。

img

初始化 SqlKit

Jfinal 通过 SqlKit 管理 SQL 模版的加载。因此在构建的 easy-qeury 的 bean 对象的时候,一起初始化 SqlKit,加载对应的SQL 模版。

EasyQueryHolder

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.easy.query.solon.integration.holder;

import com.easy.query.core.api.client.EasyQueryClient;
import com.easy.query.solon.activerecord.sql.SqlKit;
import org.noear.solon.core.VarHolder;

/**
* create time 2023/7/24 22:19 文件说明
*
* @author xuejiaming
*/
public interface EasyQueryHolder {
String MAIN_CONFIG_NAME = "main";

/**
* 获取sqlKet
*
* @return
*/
SqlKit sqlKit();

// 以下代码省略,方便说明修改 ...
}

DbManger

在构建增加 SqlKit 的初始化,这个通过读取配置,获取 SQL 的文件位置并读取.sql的文件进行装在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/** 构建 */
private static EasyQueryHolder build(BeanWrap bw) {
// 以上代码省略,方便说明修改 ...

// 引入对应的sql模版
SqlKit sqlKit = new SqlKit(configName, solonEasyQueryProperties.getSqlDev());
String sqlPath = solonEasyQueryProperties.getSqlPath();
ScanUtil.scan(sqlPath, n -> n.endsWith(".sql")).forEach(sqlKit::addSqlTemplate);
sqlKit.parseSqlTemplate();
SqlManager.add(sqlKit);
EventBus.publish(sqlKit);

return injectHolderFactory.get().getHolder(configName, easyQueryClient, sqlKit);
}

使用

通过SqlManger(对应DbManger),获取SQL 语法,直接交给 entityQueryable 进行原生的 SQL 查询,而且可以进行类型转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 通过sql模板,查询数据
*
* @param template
* @param data
* @param vClass
* @return
* @param <V>
*/
public <V> List<V> listAs(String template, Map<?, ?> data, Class<V> vClass) {
SqlPara sqlPara = SqlManager.getSqlPara(template, data);
return entityQueryable(sqlPara.getSql(), Arrays.asList(sqlPara.getPara()))
.select(vClass)
.toList();
}

SQL 租户

Easy-query 的 DefaultEntityExpressionExecutor 支持在 querySQL 中对原生的SQL 进行处理,可以在这里增加对租户的逻辑的处理。

依赖

1
api("com.github.jsqlparser:jsqlparser:5.1")

DefaultEntityExpressionExecutorEx

增加租户逻辑的处理,如果存在 TenantLineHandler 的bean 对象,且没有设置忽略租户的时候就进行解析SQL语法,并进行租户的过滤。

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.easy.query.solon.integration.excutor;

import com.easy.query.core.basic.jdbc.executor.DefaultEntityExpressionExecutor;
import com.easy.query.core.basic.jdbc.executor.ExecutorContext;
import com.easy.query.core.basic.jdbc.executor.ResultMetadata;
import com.easy.query.core.basic.jdbc.parameter.SQLParameter;
import com.easy.query.core.expression.executor.parser.EasyPrepareParser;
import com.easy.query.core.expression.executor.query.ExecutionContextFactory;
import com.easy.query.core.inject.ServiceProvider;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.core.AppContext;

/**
* @author airhead
*/
@Slf4j
public class DefaultEntityExpressionExecutorEx extends DefaultEntityExpressionExecutor {
private final ServiceProvider serviceProvider;

public DefaultEntityExpressionExecutorEx(
EasyPrepareParser easyPrepareParser,
ExecutionContextFactory executionContextFactory,
ServiceProvider serviceProvider) {
super(easyPrepareParser, executionContextFactory);
this.serviceProvider = serviceProvider;
}

@Override
public <TR> List<TR> querySQL(
ExecutorContext executorContext,
ResultMetadata<TR> resultMetadata,
String sql,
List<SQLParameter> sqlParameters) {
AppContext appContext = serviceProvider.getService(AppContext.class);
TenantLineHandler tenantLineHandler = appContext.getBean(TenantLineHandler.class);
if (tenantLineHandler == null || tenantLineHandler.ignoreTenant()) {
return super.querySQL(executorContext, resultMetadata, sql, sqlParameters);
}

sql = tenantLineHandler.parseSql(sql, null);
return super.querySQL(executorContext, resultMetadata, sql, sqlParameters);
}
}

TenantLineHandler

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
package com.easy.query.solon.integration.excutor;

import com.easy.query.solon.integration.parser.SqlParser;
import com.easy.query.solon.integration.parser.TenantLineSqlParser;
import net.sf.jsqlparser.expression.*;

/**
* @author airhead
*/
public interface TenantLineHandler {

/**
* 获取租户id参数
*
* @return
*/
Expression getTenantId();

/**
* 获取租户id字段
*
* @return
*/
default String getTenantIdColumn() {
return "tenant_id";
}

/**
* 是否忽略租户
*
* @return
*/
default boolean ignoreTenant() {
return false;
}

/**
* 是否忽略表格
*
* @param tableName
* @return
*/
default boolean ignoreTable(String tableName) {
return false;
}

/**
* 获取SQL 解析器
*
* @return
*/
default SqlParser getSqlParser() {
return new TenantLineSqlParser(this);
}

/**
* 解析SQL
*
* @param sql
* @param obj
* @return
*/
default String parseSql(String sql, Object obj) {
return getSqlParser().parserSingle(sql, obj);
}
}

SqlParser

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
package com.easy.query.solon.integration.parser;

import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.Statements;
import net.sf.jsqlparser.statement.delete.Delete;
import net.sf.jsqlparser.statement.insert.Insert;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.update.Update;

/**
* @author airhead
*/
public interface SqlParser {

/**
* 解析单条 SQL
*
* @param sql
* @param obj
* @return
*/
default String parserSingle(String sql, Object obj) {
try {
Statement statement = CCJSqlParserUtil.parse(sql);
return this.processParser(statement, 0, sql, obj);
} catch (JSQLParserException e) {
throw new RuntimeException(
String.format("Failed to process, Error SQL: %s", sql), e.getCause());
}
}

/**
* 解析多条 SQL
*
* @param sql
* @param obj
* @return
*/
@SuppressWarnings("deprecation")
default String parserMulti(String sql, Object obj) {
try {
StringBuilder sb = new StringBuilder();
Statements statements = CCJSqlParserUtil.parseStatements(sql);
int i = 0;

for (Statement statement : statements.getStatements()) {
if (i > 0) {
sb.append(";");
}

sb.append(this.processParser(statement, i, sql, obj));
++i;
}

return sb.toString();
} catch (JSQLParserException e) {
throw new RuntimeException(
String.format("Failed to process, Error SQL: %s", sql), e.getCause());
}
}

default String processParser(Statement statement, int index, String sql, Object obj) {
if (statement instanceof Insert) {
this.processInsert((Insert) statement, index, sql, obj);
} else if (statement instanceof Select) {
this.processSelect((Select) statement, index, sql, obj);
} else if (statement instanceof Update) {
this.processUpdate((Update) statement, index, sql, obj);
} else if (statement instanceof Delete) {
this.processDelete((Delete) statement, index, sql, obj);
}

sql = statement.toString();

return sql;
}

default void processInsert(Insert insert, int index, String sql, Object obj) {
throw new UnsupportedOperationException();
}

default void processDelete(Delete delete, int index, String sql, Object obj) {
throw new UnsupportedOperationException();
}

default void processUpdate(Update update, int index, String sql, Object obj) {
throw new UnsupportedOperationException();
}

default void processSelect(Select select, int index, String sql, Object obj) {
throw new UnsupportedOperationException();
}
}

TenantLineSqlParser

重点处理 select,如果是插入同时是对单表的操作,使用 ORM 的 dsl 进行操作就可以了。

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
package com.easy.query.solon.integration.parser;

import com.easy.query.solon.integration.excutor.TenantLineHandler;
import java.util.*;
import java.util.stream.Collectors;
import net.sf.jsqlparser.expression.*;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
import net.sf.jsqlparser.expression.operators.relational.*;
import net.sf.jsqlparser.schema.Column;
import net.sf.jsqlparser.schema.Table;
import net.sf.jsqlparser.statement.select.*;
import org.dromara.hutool.core.collection.CollUtil;

/**
* @author airhead
*/
public class TenantLineSqlParser implements SqlParser {
private final TenantLineHandler tenantLineHandler;

public TenantLineSqlParser(TenantLineHandler tenantLineHandler) {
this.tenantLineHandler = tenantLineHandler;
}

@Override
public void processSelect(Select select, int index, String sql, Object obj) {
String whereSegment = (String) obj;
this.processSelectBody(select.getPlainSelect(), whereSegment);
List<WithItem<?>> withItemsList = select.getWithItemsList();
if (CollUtil.isNotEmpty(withItemsList)) {
withItemsList.forEach(
(withItem) -> this.processSelectBody(withItem.getSelect(), whereSegment));
}
}

protected void processSelectBody(Select selectBody, final String whereSegment) {
if (selectBody == null) {
return;
}

if (selectBody instanceof PlainSelect) {
processPlainSelect((PlainSelect) selectBody, whereSegment);
} else if (selectBody instanceof ParenthesedSelect parenthesedSelect) {
processSelectBody(parenthesedSelect.getSelect(), whereSegment);
} else if (selectBody instanceof SetOperationList operationList) {
List<Select> selectBodyList = operationList.getSelects();
if (CollUtil.isNotEmpty(selectBodyList)) {
selectBodyList.forEach(body -> processSelectBody(body, whereSegment));
}
}
}

protected void processPlainSelect(final PlainSelect plainSelect, final String whereSegment) {
List<SelectItem<?>> selectItems = plainSelect.getSelectItems();
if (CollUtil.isNotEmpty(selectItems)) {
selectItems.forEach((selectItem) -> this.processSelectItem(selectItem, whereSegment));
}

Expression where = plainSelect.getWhere();
this.processWhereSubSelect(where, whereSegment);
FromItem fromItem = plainSelect.getFromItem();
List<Table> list = this.processFromItem(fromItem, whereSegment);
List<Table> mainTables = new ArrayList<>(list);
List<Join> joins = plainSelect.getJoins();
if (CollUtil.isNotEmpty(joins)) {
mainTables = this.processJoins(mainTables, joins, whereSegment);
}

if (CollUtil.isNotEmpty(mainTables)) {
plainSelect.setWhere(this.builderExpression(where, mainTables, whereSegment));
}
}

@SuppressWarnings({"rawtypes", "unchecked"})
protected void processWhereSubSelect(Expression where, final String whereSegment) {
if (where == null) {
return;
}
if (where instanceof FromItem) {
processOtherFromItem((FromItem) where, whereSegment);
return;
}
if (where.toString().indexOf("SELECT") > 0) {
// 有子查询
if (where instanceof BinaryExpression expression) {
// 比较符号 , and , or , 等等
processWhereSubSelect(expression.getLeftExpression(), whereSegment);
processWhereSubSelect(expression.getRightExpression(), whereSegment);
} else if (where instanceof InExpression expression) {
// in
Expression inExpression = expression.getRightExpression();
if (inExpression instanceof Select) {
processSelectBody(((Select) inExpression), whereSegment);
}
} else if (where instanceof ExistsExpression expression) {
// exists
processWhereSubSelect(expression.getRightExpression(), whereSegment);
} else if (where instanceof NotExpression expression) {
// not exists
processWhereSubSelect(expression.getExpression(), whereSegment);
} else if (where instanceof ParenthesedExpressionList) {
ParenthesedExpressionList<Expression> expression = (ParenthesedExpressionList) where;
processWhereSubSelect(expression.get(0), whereSegment);
}
}
}

@SuppressWarnings("rawtypes")
protected void processSelectItem(SelectItem selectItem, final String whereSegment) {
Expression expression = selectItem.getExpression();
if (expression instanceof Select) {
processSelectBody(((Select) expression), whereSegment);
} else if (expression instanceof Function) {
processFunction((Function) expression, whereSegment);
} else if (expression instanceof ExistsExpression existsExpression) {
processSelectBody((Select) existsExpression.getRightExpression(), whereSegment);
}
}

/**
* 处理函数
*
* <p>支持: 1. select fun(args..) 2. select fun1(fun2(args..),args..)
*
* <p>
*
* <p>fixed gitee pulls/141
*
* @param function
*/
protected void processFunction(Function function, final String whereSegment) {
ExpressionList<?> parameters = function.getParameters();
if (parameters != null) {
parameters.forEach(
expression -> {
if (expression instanceof Select) {
processSelectBody(((Select) expression), whereSegment);
} else if (expression instanceof Function) {
processFunction((Function) expression, whereSegment);
} else if (expression instanceof EqualsTo) {
if (((EqualsTo) expression).getLeftExpression() instanceof Select) {
processSelectBody(
((Select) ((EqualsTo) expression).getLeftExpression()), whereSegment);
}
if (((EqualsTo) expression).getRightExpression() instanceof Select) {
processSelectBody(
((Select) ((EqualsTo) expression).getRightExpression()), whereSegment);
}
}
});
}
}

protected void processOtherFromItem(FromItem fromItem, final String whereSegment) {
// 去除括号
while (fromItem instanceof ParenthesedFromItem) {
fromItem = ((ParenthesedFromItem) fromItem).getFromItem();
}

if (fromItem instanceof ParenthesedSelect) {
Select subSelect = (Select) fromItem;
processSelectBody(subSelect, whereSegment);
}
}

/** 处理条件 */
protected Expression builderExpression(
Expression currentExpression, List<Table> tables, final String whereSegment) {
// 没有表需要处理直接返回
if (CollUtil.isEmpty(tables)) {
return currentExpression;
}
// 构造每张表的条件
List<Expression> expressions =
tables.stream()
.map(item -> buildTableExpression(item, currentExpression, whereSegment))
.filter(Objects::nonNull)
.collect(Collectors.toList());

// 没有表需要处理直接返回
if (CollUtil.isEmpty(expressions)) {
return currentExpression;
}

// 注入的表达式
Expression injectExpression = expressions.get(0);
// 如果有多表,则用 and 连接
if (expressions.size() > 1) {
for (int i = 1; i < expressions.size(); i++) {
injectExpression = new AndExpression(injectExpression, expressions.get(i));
}
}

if (currentExpression == null) {
return injectExpression;
}
if (currentExpression instanceof OrExpression) {
return new AndExpression(
new ParenthesedExpressionList<>(currentExpression), injectExpression);
} else {
return new AndExpression(currentExpression, injectExpression);
}
}

/**
* 租户字段别名设置
*
* <p>tenantId 或 tableAlias.tenantId
*
* @param table 表对象
* @return 字段
*/
protected Column getAliasColumn(Table table) {
StringBuilder column = new StringBuilder();
// todo 该起别名就要起别名,禁止修改此处逻辑
if (table.getAlias() != null) {
column.append(table.getAlias().getName()).append(".");
}
column.append(tenantLineHandler.getTenantIdColumn());
return new Column(column.toString());
}

/**
* 构建租户条件表达式
*
* @param table 表对象
* @param where 当前where条件
* @param whereSegment 所属Mapper对象全路径(在原租户拦截器功能中,这个参数并不需要参与相关判断)
* @return 租户条件表达式
*/
protected Expression buildTableExpression(
final Table table, final Expression where, final String whereSegment) {
if (tenantLineHandler.ignoreTable(table.getName())) {
return null;
}

return new EqualsTo(getAliasColumn(table), tenantLineHandler.getTenantId());
}

protected List<Table> processFromItem(FromItem fromItem, final String whereSegment) {
// 处理括号括起来的表达式
// while (fromItem instanceof ParenthesedFromItem) {
// fromItem = ((ParenthesedFromItem) fromItem).getFromItem();
// }

List<Table> mainTables = new ArrayList<>();
// 无 join 时的处理逻辑
if (fromItem instanceof Table) {
Table fromTable = (Table) fromItem;
mainTables.add(fromTable);
} else if (fromItem instanceof ParenthesedFromItem) {
// SubJoin 类型则还需要添加上 where 条件
List<Table> tables = processSubJoin((ParenthesedFromItem) fromItem, whereSegment);
mainTables.addAll(tables);
} else {
// 处理下 fromItem
processOtherFromItem(fromItem, whereSegment);
}
return mainTables;
}

/**
* 处理 sub join
*
* @param subJoin subJoin
* @return Table subJoin 中的主表
*/
protected List<Table> processSubJoin(ParenthesedFromItem subJoin, final String whereSegment) {
while (subJoin.getJoins() == null && subJoin.getFromItem() instanceof ParenthesedFromItem) {
subJoin = (ParenthesedFromItem) subJoin.getFromItem();
}
List<Table> tableList = processFromItem(subJoin.getFromItem(), whereSegment);
List<Table> mainTables = new ArrayList<>(tableList);
if (subJoin.getJoins() != null) {
processJoins(mainTables, subJoin.getJoins(), whereSegment);
}
return mainTables;
}

/**
* 处理 joins
*
* @param mainTables 可以为 null
* @param joins join 集合
* @return List<Table> 右连接查询的 Table 列表
*/
protected List<Table> processJoins(
List<Table> mainTables, List<Join> joins, final String whereSegment) {
// join 表达式中最终的主表
Table mainTable = null;
// 当前 join 的左表
Table leftTable = null;

if (mainTables.size() == 1) {
mainTable = mainTables.get(0);
leftTable = mainTable;
}

// 对于 on 表达式写在最后的 join,需要记录下前面多个 on 的表名
Deque<List<Table>> onTableDeque = new LinkedList<>();
for (Join join : joins) {
// 处理 on 表达式
FromItem joinItem = join.getRightItem();

// 获取当前 join 的表,subJoint 可以看作是一张表
List<Table> joinTables = null;
if (joinItem instanceof Table) {
joinTables = new ArrayList<>();
joinTables.add((Table) joinItem);
} else if (joinItem instanceof ParenthesedFromItem) {
joinTables = processSubJoin((ParenthesedFromItem) joinItem, whereSegment);
}

if (joinTables != null && !joinTables.isEmpty()) {

// 如果是隐式内连接
if (join.isSimple()) {
mainTables.addAll(joinTables);
continue;
}

// 当前表是否忽略
Table joinTable = joinTables.get(0);

List<Table> onTables = null;
// 如果不要忽略,且是右连接,则记录下当前表
if (join.isRight()) {
mainTable = joinTable;
mainTables.clear();
if (leftTable != null) {
onTables = Collections.singletonList(leftTable);
}
} else if (join.isInner()) {
if (mainTable == null) {
onTables = Collections.singletonList(joinTable);
} else {
onTables = Arrays.asList(mainTable, joinTable);
}
mainTable = null;
mainTables.clear();
} else {
onTables = Collections.singletonList(joinTable);
}

if (mainTable != null && !mainTables.contains(mainTable)) {
mainTables.add(mainTable);
}

// 获取 join 尾缀的 on 表达式列表
Collection<Expression> originOnExpressions = join.getOnExpressions();
// 正常 join on 表达式只有一个,立刻处理
if (originOnExpressions.size() == 1 && onTables != null) {
List<Expression> onExpressions = new LinkedList<>();
onExpressions.add(
builderExpression(originOnExpressions.iterator().next(), onTables, whereSegment));
join.setOnExpressions(onExpressions);
leftTable = mainTable == null ? joinTable : mainTable;
continue;
}
// 表名压栈,忽略的表压入 null,以便后续不处理
onTableDeque.push(onTables);
// 尾缀多个 on 表达式的时候统一处理
if (originOnExpressions.size() > 1) {
Collection<Expression> onExpressions = new LinkedList<>();
for (Expression originOnExpression : originOnExpressions) {
List<Table> currentTableList = onTableDeque.poll();
if (CollUtil.isEmpty(currentTableList)) {
onExpressions.add(originOnExpression);
} else {
onExpressions.add(
builderExpression(originOnExpression, currentTableList, whereSegment));
}
}
join.setOnExpressions(onExpressions);
}
leftTable = joinTable;
} else {
processOtherFromItem(joinItem, whereSegment);
leftTable = null;
}
}

return mainTables;
}
}

DbManager

在DbManager 中替换默认的 EntityExpressionExecutor。

1
2
3
4
5
easyQueryBuilderConfiguration
.replaceService(DataSourceUnitFactory.class, SolonDataSourceUnitFactory.class)
.replaceService(ConnectionManager.class, SolonConnectionManager.class)
.replaceService(appContext)
.replaceService(EntityExpressionExecutor.class, DefaultEntityExpressionExecutorEx.class);

使用租户

使用时只需要继承 TenantLineHandler,设置对应的租户字段的处理,构建为Bean 对象就可以了。

结构裁剪

虽然 solon 可以设置数据为 null 时不返回属性,但这个和返回结果的裁剪还是有不同。如果通过 easy-query 来实现的话,就需要增加对的 Vo,或者 Dto 来承接数据。这里提供对 Model 进行封装的方式实现对返回结果结构的裁剪。

BaseModel

通过一个baseModel 看和普通model的不同。

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
package com.goldsyear.porpoise.module.dev.code.model.base;

import com.easy.query.core.annotation.Column;
import com.easy.query.solon.activerecord.IBean;
import com.easy.query.solon.activerecord.Model;
import java.time.LocalDateTime;

/**
* 1. private 变量在 Getter 和 Setter 中没有被用到。之所以定义它们,是因为很多序列化框架的需要 private 变量和 Setter 必须同时存在才能成功。 2.
* 方便设置 easy-query 的注解
*
* <p>Generated by Porpoise, please do not modify this file.
*
* @author airhead
*/
@SuppressWarnings({"unused"})
public abstract class BaseCodeDb<M extends BaseCodeDb<M>> extends Model<M> implements IBean {

/** 主键编号 */
@Column(primaryKey = true, generatedKey = true)
private Integer id;

/** 名称 */
private String name;

/** 连接 */
private String url;

/** 用户名 */
private String username;

/** 密码 */
private String password;

/** 创建者 */
private String creator;

/** 创建时间 */
private LocalDateTime createTime;

/** 更新者 */
private String updater;

/** 更新时间 */
private LocalDateTime updateTime;

/** 主键编号 */
public Integer getId() {
return getInt("id");
}

/** 主键编号 */
public void setId(Integer id) {
set("id", id);
}

/** 名称 */
public String getName() {
return getStr("name");
}

/** 名称 */
public void setName(String name) {
set("name", name);
}

/** 连接 */
public String getUrl() {
return getStr("url");
}

/** 连接 */
public void setUrl(String url) {
set("url", url);
}

/** 用户名 */
public String getUsername() {
return getStr("username");
}

/** 用户名 */
public void setUsername(String username) {
set("username", username);
}

/** 密码 */
public String getPassword() {
return getStr("password");
}

/** 密码 */
public void setPassword(String password) {
set("password", password);
}

/** 创建者 */
public String getCreator() {
return getStr("creator");
}

/** 创建者 */
public void setCreator(String creator) {
set("creator", creator);
}

/** 创建时间 */
public LocalDateTime getCreateTime() {
return getLocalDateTime("create_time");
}

/** 创建时间 */
public void setCreateTime(LocalDateTime createTime) {
set("create_time", createTime);
}

/** 更新者 */
public String getUpdater() {
return getStr("updater");
}

/** 更新者 */
public void setUpdater(String updater) {
set("updater", updater);
}

/** 更新时间 */
public LocalDateTime getUpdateTime() {
return getLocalDateTime("update_time");
}

/** 更新时间 */
public void setUpdateTime(LocalDateTime updateTime) {
set("update_time", updateTime);
}
}

ModelUtils

通过 ModelUtils,可以对查询结果进行裁剪。

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
package com.goldsyear.solon.web.util;

import com.easy.query.solon.activerecord.Model;
import com.goldsyear.solon.web.model.PageResult;
import java.util.List;
import org.dromara.hutool.core.collection.CollUtil;

/**
* @author airhead
*/
public class ModelUtils {
public static <M extends Model<M>> M remove(M model, String attr) {
if (model == null) {
return null;
}

model.remove(attr);
return model;
}

public static <M extends Model<M>> List<M> remove(List<M> modelList, String attr) {
if (CollUtil.isEmpty(modelList)) {
return modelList;
}

for (Model<M> model : modelList) {
model.remove(attr);
}

return modelList;
}

public static <M extends Model<M>> PageResult<M> remove(PageResult<M> pageResult, String attr) {
if (pageResult == null) {
return pageResult;
}

remove(pageResult.getRecords(), attr);
return pageResult;
}

public static <M extends Model<M>> M remove(M model, String... attrs) {
if (model == null) {
return model;
}

model.remove(attrs);
return model;
}

public static <M extends Model<M>> List<M> remove(List<M> modelList, String... attrs) {
if (CollUtil.isEmpty(modelList)) {
return modelList;
}

for (Model<M> model : modelList) {
model.remove(attrs);
}

return modelList;
}

public static <M extends Model<M>> PageResult<M> remove(
PageResult<M> pageResult, String... attrs) {
if (pageResult == null) {
return pageResult;
}

remove(pageResult.getRecords(), attrs);

return pageResult;
}

public static <M extends Model<M>> M keep(M model, String attr) {
if (model == null) {
return model;
}

model.keep(attr);
return model;
}

public static <M extends Model<M>> List<M> keep(List<M> modelList, String attr) {
if (CollUtil.isEmpty(modelList)) {
return modelList;
}

for (Model<M> model : modelList) {
model.keep(attr);
}

return modelList;
}

public static <M extends Model<M>> PageResult<M> keep(PageResult<M> pageResult, String attr) {
if (pageResult == null) {
return pageResult;
}

keep(pageResult.getRecords(), attr);

return pageResult;
}

public static <M extends Model<M>> M keep(M model, String... attrs) {
if (model == null) {
return model;
}

model.keep(attrs);

return model;
}

public static <M extends Model<M>> List<M> keep(List<M> modelList, String... attrs) {
if (CollUtil.isEmpty(modelList)) {
return modelList;
}

for (Model<M> model : modelList) {
model.keep(attrs);
}

return modelList;
}

public static <M extends Model<M>> PageResult<M> keep(PageResult<M> pageResult, String... attrs) {
if (pageResult == null) {
return pageResult;
}

keep(pageResult.getRecords(), attrs);

return pageResult;
}
}

AOP

Deform

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.goldsyear.solon.web.annotation;

import java.lang.annotation.*;

/**
* @author airhead
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Deform {
/**
* 保留的属性
*
* @return String[]
*/
String[] keep() default {};

/**
* 移除的属性
*
* @return String[]
*/
String[] remove() default {};
}

DeformInterceptor

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
package com.goldsyear.solon.web.interceptor;

import com.easy.query.solon.activerecord.Model;
import com.goldsyear.solon.web.annotation.Deform;
import com.goldsyear.solon.web.model.PageResult;
import com.goldsyear.solon.web.util.ModelUtils;
import java.util.Arrays;
import java.util.List;
import org.dromara.hutool.core.collection.CollUtil;
import org.noear.solon.core.aspect.Interceptor;
import org.noear.solon.core.aspect.Invocation;

/**
* @author airhead
*/
public class DeformInterceptor implements Interceptor {
@SuppressWarnings({"rawtypes", "unchecked"})
@Override
public Object doIntercept(Invocation inv) throws Throwable {
Object ret = inv.invoke();

Deform deform = inv.getMethodAnnotation(Deform.class);
// 不需要转换
if (deform == null) {
return ret;
}

if (ret instanceof Model) {
if (CollUtil.isNotEmpty(Arrays.asList(deform.keep()))) {
Model model = (Model) ret;
return ModelUtils.keep(model, deform.keep());
}

if (CollUtil.isNotEmpty(Arrays.asList(deform.remove()))) {
Model model = (Model) ret;
return ModelUtils.keep(model, deform.keep());
}
}

if (ret instanceof PageResult) {
if (CollUtil.isNotEmpty(Arrays.asList(deform.keep()))) {
PageResult pageResult = (PageResult) ret;
return ModelUtils.keep(pageResult.getRecords(), deform.keep());
}

if (CollUtil.isNotEmpty(Arrays.asList(deform.remove()))) {
PageResult pageResult = (PageResult) ret;
return ModelUtils.remove(pageResult.getRecords(), deform.keep());
}
}

if (ret instanceof List) {
if (CollUtil.isNotEmpty(Arrays.asList(deform.keep()))) {
List list = (List) ret;
return ModelUtils.keep(list, deform.keep());
}

if (CollUtil.isNotEmpty(Arrays.asList(deform.remove()))) {
List list = (List) ret;
return ModelUtils.remove(list, deform.keep());
}
}

return ret;
}
}

注册 DeformInterceptor

1
2
// 
context.beanInterceptorAdd(Deform.class, new DeformInterceptor());

使用

注意这里的是字段名,而不是类的属性名。

1
2
3
4
5
6
  @Deform(keep = {"id", "name"})
public List<CodeColumn> simpleListCodeColumn(CodeColumn codeColumn) {
List<CodeColumn> list = list(codeColumn);
// ModelUtils.keep(list, "id", "name");
return list;
}

序列化相关

因为 Model 和 Activerecord,渲染的时候并不是直接渲染成员,而是对应的 attr 成员下的内容,因此需要特殊处理。这里使用了 snack3, 如果使用了其他的json 框架,需要根据实际情况进行处理。

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
package com.goldsyear.solon.web;

import com.easy.query.solon.activerecord.Model;
import com.easy.query.solon.activerecord.Record;
import com.goldsyear.solon.web.annotation.Deform;
import com.goldsyear.solon.web.interceptor.DeformInterceptor;
import com.goldsyear.solon.web.model.ModelEncoder;
import com.goldsyear.solon.web.model.ModelFieldGetter;
import com.goldsyear.solon.web.model.RecordEncoder;
import com.goldsyear.solon.web.model.RecordFieldGetter;
import com.jfinal.kit.PathKit;
import com.jfinal.template.Engine;
import java.time.LocalDateTime;
import org.dromara.hutool.core.date.TimeUtil;
import org.noear.snack.core.Feature;
import org.noear.snack.core.Options;
import org.noear.solon.core.AppContext;
import org.noear.solon.core.Plugin;
import org.noear.solon.serialization.snack3.SnackActionExecutor;
import org.noear.solon.serialization.snack3.SnackRenderFactory;
import org.noear.solon.web.staticfiles.StaticMappings;
import org.noear.solon.web.staticfiles.repository.FileStaticRepository;

/**
* @author airhead
*/
public class EasyQueryWebPluginImp implements Plugin {
static boolean started = false;

@Override
public void start(AppContext context) {
if (started) {
return;
}

StaticMappings.add("/", new FileStaticRepository(PathKit.getWebRootPath()));

Engine.addFieldGetterToLast(new ModelFieldGetter());
Engine.addFieldGetterToLast(new RecordFieldGetter());

// 处理序列化
context.getBeanAsync(
SnackRenderFactory.class,
factory -> {
factory.addConvertor(LocalDateTime.class, TimeUtil::formatNormal);
factory.addEncoder(Model.class, new ModelEncoder<>());
factory.addEncoder(Record.class, new RecordEncoder<>());
});

// 处理反序列化,通过setter来设值。
context.getBeanAsync(
SnackActionExecutor.class,
snackActionExecutor -> {
Options config = snackActionExecutor.config();
config.add(Feature.UseSetter, Feature.DisableClassNameRead);
});

// 注册 DeformInterceptor
context.beanInterceptorAdd(Deform.class, new DeformInterceptor());

started = true;
}
}

Base Encoder

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.goldsyear.solon.web.model;

import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import org.dromara.hutool.core.collection.CollUtil;
import org.dromara.hutool.core.text.StrUtil;
import org.noear.snack.ONode;
import org.noear.solon.Solon;
import org.noear.solon.core.Props;

/**
* @author airhead
*/
public class BaseEncoder {
/** 对 Model 和 Record 的字段名进行转换的函数。例如转成驼峰形式对 oracle 支持更友好 */
protected static Function<String, String> fieldNameConverter = StrUtil::toCamelCase;

protected Props props = Solon.cfg().getProp("solon.serialization.json");

protected void encode(ONode node, Map<String, Object> map) {
if (CollUtil.isEmpty(map)) {
return;
}

Map<String, Object> newMap = new HashMap<>(map.size());
for (Map.Entry<String, Object> entry : map.entrySet()) {
String fieldName = entry.getKey();
Object value = entry.getValue();
String attrName = fieldName;
if (fieldNameConverter != null) {
attrName = fieldNameConverter.apply(fieldName);
}
newMap.put(attrName, value);
}

node.fill(newMap);
}
}

ModelEncoder

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

import com.easy.query.solon.activerecord.CPI;
import com.easy.query.solon.activerecord.Model;
import org.noear.snack.ONode;
import org.noear.snack.core.NodeEncoder;

/**
* @author airhead
*/
public class ModelEncoder<T extends Model<?>> extends BaseEncoder implements NodeEncoder<T> {
@Override
public void encode(T data, ONode node) {
if (data == null) {
return;
}

encode(node, CPI.getAttrs(data));
}
}

RecordEncorder

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

import com.easy.query.solon.activerecord.Record;
import org.noear.snack.ONode;
import org.noear.snack.core.NodeEncoder;

/**
* @author airhead
*/
public class RecordEncoder<T extends Record> extends BaseEncoder implements NodeEncoder<T> {

@Override
public void encode(T data, ONode node) {
if (data == null) {
return;
}

encode(node, data.getColumns());
}
}

获取 Model

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
protected <T> T getBean(Class<T> type) {
T t = this.context().paramAsBean(type);
if (Model.class.isAssignableFrom(type)) {
if (t != null) {
Model<?> model = (Model<?>) t;
for (KeyValues<String> values : context().paramMap()) {
// 此处通过Map的方式设置
model.put(NamingCase.toUnderlineCase(values.getKey()), values.getFirstValue());
}

return t;
}
}

return t;
}

protected <T> T getModel(Class<T> type) {
return getModel(type, StrKit.firstCharToLowerCase(type.getSimpleName()));
}

protected <T> T getModel(Class<T> type, String name) {
MultiMap<String> paramMap = context().paramMap();
return PropsConverter.global().convert(new Props().addAll(paramMap).getProp(name), type);
}

小结

通过以上的扩展,easy-query 已经可以使用 ActiveRecord Model 的功能,可以用 SQL 模版的方式管理 SQL,并增强了ActiveRecord 的 SQL 能力,支持租户字段的过滤。同时借助 Solon 的 AOP 的能力,通过一个注解实现对结果结构的裁剪,减少转换类。

说明

在「Mac 上编译 Ragflow」的文章中只是简单的说到可以通过 Remote Development 来进入容器,可以通过修改Dockerfile的方式,增加映射卷的方式来开发。为什么要映射卷呢?这样可以保持 ragflow 原有的目录结构,同时保持 git 的代码管理,文件在卷中也不用担心因容器销毁等导致修改代码丢失的情况。

我今天完整的走通了开发的过程,重新整理开发部分的文档。为了保持完整,保留了 Remote Development 的部分说明。注意,前提是已经在 Mac 上编译完 ragflow 的 Dockerfile,并且可以正常运行。

开发

Remote Development

VS Code 安装 Remote Development 插件。它包含多个插件,其中 Dev Containters 可以连接到 Docker 容器作为开发环境,这样的好处是开发环境与部署环境一致。

img

当安装好 Remote Development,就可以通过 Remote Explorer 查看已经安装的容器,并通过点击对应容器的箭头(注意下图红色框的部分)。

img

进入到容器内部,选对应的目录,就可以打开 /ragflow,这样就能看到实际的代码了。

img

修改Dockerfile

主要是屏蔽编译过程和 probuction 步骤,增加 /raflow 卷的映射。

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
# base stage
FROM docker.m.daocloud.io/ubuntu:22.04 AS base
USER root
SHELL ["/bin/bash", "-c"]

ARG NEED_MIRROR=0
ARG LIGHTEN=0
ENV LIGHTEN=${LIGHTEN}

WORKDIR /ragflow

# Copy models downloaded via download_deps.py
RUN mkdir -p /ragflow/rag/res/deepdoc /root/.ragflow
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/huggingface.co,target=/huggingface.co \
cp /huggingface.co/InfiniFlow/huqie/huqie.txt.trie /ragflow/rag/res/ && \
tar --exclude='.*' -cf - \
/huggingface.co/InfiniFlow/text_concat_xgb_v1.0 \
/huggingface.co/InfiniFlow/deepdoc \
| tar -xf - --strip-components=3 -C /ragflow/rag/res/deepdoc
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/huggingface.co,target=/huggingface.co \
if [ "$LIGHTEN" != "1" ]; then \
(tar -cf - \
/huggingface.co/BAAI/bge-large-zh-v1.5 \
/huggingface.co/BAAI/bge-reranker-v2-m3 \
/huggingface.co/maidalun1020/bce-embedding-base_v1 \
/huggingface.co/maidalun1020/bce-reranker-base_v1 \
| tar -xf - --strip-components=2 -C /root/.ragflow) \
fi

# https://github.com/chrismattmann/tika-python
# This is the only way to run python-tika without internet access. Without this set, the default is to check the tika version and pull latest every time from Apache.
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/,target=/deps \
cp -r /deps/nltk_data /root/ && \
cp /deps/tika-server-standard-3.0.0.jar /deps/tika-server-standard-3.0.0.jar.md5 /ragflow/ && \
cp /deps/cl100k_base.tiktoken /ragflow/9b5ad71b2ce5302211f9c61530b329a4922fc6a4

ENV TIKA_SERVER_JAR="file:///ragflow/tika-server-standard-3.0.0.jar"
ENV DEBIAN_FRONTEND=noninteractive

# Setup apt
# Python package and implicit dependencies:
# opencv-python: libglib2.0-0 libglx-mesa0 libgl1
# aspose-slides: pkg-config libicu-dev libgdiplus libssl1.1_1.1.1f-1ubuntu2_amd64.deb
# python-pptx: default-jdk tika-server-standard-3.0.0.jar
# selenium: libatk-bridge2.0-0 chrome-linux64-121-0-6167-85
# Building C extensions: libpython3-dev libgtk-4-1 libnss3 xdg-utils libgbm-dev
RUN --mount=type=cache,id=ragflow_apt,target=/var/cache/apt,sharing=locked \
if [ "$NEED_MIRROR" == "1" ]; then \
sed -i 's|http://archive.ubuntu.com|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list; \
fi; \
rm -f /etc/apt/apt.conf.d/docker-clean && \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache && \
chmod 1777 /tmp && \
apt update && \
apt --no-install-recommends install -y ca-certificates && \
apt update && \
apt install -y libglib2.0-0 libglx-mesa0 libgl1 && \
apt install -y pkg-config libicu-dev libgdiplus && \
apt install -y default-jdk && \
apt install -y libatk-bridge2.0-0 && \
apt install -y libpython3-dev libgtk-4-1 libnss3 xdg-utils libgbm-dev && \
apt install -y libjemalloc-dev && \
apt install -y python3-pip pipx nginx unzip curl wget git vim less

RUN if [ "$NEED_MIRROR" == "1" ]; then \
pip3 config set global.index-url https://mirrors.aliyun.com/pypi/simple && \
pip3 config set global.trusted-host mirrors.aliyun.com; \
mkdir -p /etc/uv && \
echo "[[index]]" > /etc/uv/uv.toml && \
echo 'url = "https://mirrors.aliyun.com/pypi/simple"' >> /etc/uv/uv.toml && \
echo "default = true" >> /etc/uv/uv.toml; \
fi; \
pipx install uv

ENV PYTHONDONTWRITEBYTECODE=1 DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
ENV PATH=/root/.local/bin:$PATH

# nodejs 12.22 on Ubuntu 22.04 is too old
RUN --mount=type=cache,id=ragflow_apt,target=/var/cache/apt,sharing=locked \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt purge -y nodejs npm cargo && \
apt autoremove -y && \
apt update && \
apt install -y nodejs

# A modern version of cargo is needed for the latest version of the Rust compiler.
RUN apt update && apt install -y curl build-essential \
&& if [ "$NEED_MIRROR" == "1" ]; then \
# Use TUNA mirrors for rustup/rust dist files
export RUSTUP_DIST_SERVER="https://mirrors.tuna.tsinghua.edu.cn/rustup"; \
export RUSTUP_UPDATE_ROOT="https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup"; \
echo "Using TUNA mirrors for Rustup."; \
fi; \
# Force curl to use HTTP/1.1
curl --proto '=https' --tlsv1.2 --http1.1 -sSf https://sh.rustup.rs | bash -s -- -y --profile minimal \
&& echo 'export PATH="/root/.cargo/bin:${PATH}"' >> /root/.bashrc

ENV PATH="/root/.cargo/bin:${PATH}"

RUN cargo --version && rustc --version

# Add msssql ODBC driver
# macOS ARM64 environment, install msodbcsql18.
# general x86_64 environment, install msodbcsql17.
RUN --mount=type=cache,id=ragflow_apt,target=/var/cache/apt,sharing=locked \
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - && \
curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list > /etc/apt/sources.list.d/mssql-release.list && \
apt update && \
arch="$(uname -m)"; \
if [ "$arch" = "arm64" ] || [ "$arch" = "aarch64" ]; then \
# ARM64 (macOS/Apple Silicon or Linux aarch64)
ACCEPT_EULA=Y apt install -y unixodbc-dev msodbcsql18; \
else \
# x86_64 or others
ACCEPT_EULA=Y apt install -y unixodbc-dev msodbcsql17; \
fi || \
{ echo "Failed to install ODBC driver"; exit 1; }

# Add dependencies of selenium
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/chrome-linux64-121-0-6167-85,target=/chrome-linux64.zip \
unzip /chrome-linux64.zip && \
mv chrome-linux64 /opt/chrome && \
ln -s /opt/chrome/chrome /usr/local/bin/
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/chromedriver-linux64-121-0-6167-85,target=/chromedriver-linux64.zip \
unzip -j /chromedriver-linux64.zip chromedriver-linux64/chromedriver && \
mv chromedriver /usr/local/bin/ && \
rm -f /usr/bin/google-chrome

# https://forum.aspose.com/t/aspose-slides-for-net-no-usable-version-of-libssl-found-with-linux-server/271344/13
# aspose-slides on linux/arm64 is unavailable
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/,target=/deps \
if [ "$(uname -m)" = "x86_64" ]; then \
dpkg -i /deps/libssl1.1_1.1.1f-1ubuntu2_amd64.deb; \
elif [ "$(uname -m)" = "aarch64" ]; then \
dpkg -i /deps/libssl1.1_1.1.1f-1ubuntu2_arm64.deb; \
fi

# builder stage
FROM base AS builder
USER root

WORKDIR /ragflow

# install dependencies from uv.lock file
# COPY pyproject.toml uv.lock ./

# https://github.com/astral-sh/uv/issues/10462
# uv records index url into uv.lock but doesn't failover among multiple indexes
# RUN --mount=type=cache,id=ragflow_uv,target=/root/.cache/uv,sharing=locked \
# if [ "$NEED_MIRROR" == "1" ]; then \
# sed -i 's|pypi.org|mirrors.aliyun.com/pypi|g' uv.lock; \
# else \
# sed -i 's|mirrors.aliyun.com/pypi|pypi.org|g' uv.lock; \
# fi; \
# if [ "$LIGHTEN" == "1" ]; then \
# uv sync --python 3.10 --frozen; \
# else \
# uv sync --python 3.10 --frozen --all-extras; \
# fi

# COPY web web
# COPY docs docs
# RUN --mount=type=cache,id=ragflow_npm,target=/root/.npm,sharing=locked \
# cd web && npm install && npm run build

COPY .git /ragflow/.git

RUN version_info=$(git describe --tags --match=v* --first-parent --always); \
if [ "$LIGHTEN" == "1" ]; then \
version_info="$version_info slim"; \
else \
version_info="$version_info full"; \
fi; \
echo "RAGFlow version: $version_info"; \
echo $version_info > /ragflow/VERSION

# production stage
# FROM base AS production
# USER root

# WORKDIR /ragflow

# Copy Python environment and packages
ENV VIRTUAL_ENV=/ragflow/.venv
# COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"

ENV PYTHONPATH=/ragflow/

# COPY web web
# COPY api api
# COPY conf conf
# COPY deepdoc deepdoc
# COPY rag rag
# COPY agent agent
# COPY graphrag graphrag
# COPY agentic_reasoning agentic_reasoning
# COPY pyproject.toml uv.lock ./

# COPY docker/service_conf.yaml.template ./conf/service_conf.yaml.template
# COPY docker/entrypoint.sh docker/entrypoint-parser.sh ./
# RUN chmod +x ./entrypoint*.sh

# Copy compiled web pages
# COPY --from=builder /ragflow/web/dist /ragflow/web/dist

# COPY --from=builder /ragflow/VERSION /ragflow/VERSION

VOLUME /ragflow

ENTRYPOINT ["./entrypoint.sh"]

重新编译镜像

1
docker build --build-arg LIGHTEN=1 --build-arg NEED_MIRROR=1 -f Dockerfile -t infiniflow/ragflow:nightly-slim .

修改docker-compose-macos.yml

这里设置本地的 ragflow 为镜像的/ragflow卷的,映射目录。这的好处就是还可以保留 git 的代码管理。

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
include:
- ./docker-compose-base.yml

services:
ragflow:
depends_on:
mysql:
condition: service_healthy
build:
context: ../
dockerfile: Dockerfile
container_name: ragflow-server
ports:
- ${SVR_HTTP_PORT}:9380
- 80:80
- 443:443
volumes:
- ./ragflow-logs:/ragflow/logs
- ./nginx/ragflow.conf:/etc/nginx/conf.d/ragflow.conf
- ./nginx/proxy.conf:/etc/nginx/proxy.conf
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ../:/ragflow
env_file: .env
environment:
- TZ=${TIMEZONE}
- HF_ENDPOINT=${HF_ENDPOINT}
- MACOS=${MACOS:-1}
- LIGHTEN=${LIGHTEN:-1}
networks:
- ragflow
restart: on-failure
# https://docs.docker.com/engine/daemon/prometheus/#create-a-prometheus-configuration
# If you're using Docker Desktop, the --add-host flag is optional. This flag makes sure that the host's internal IP gets exposed to the Prometheus container.
extra_hosts:
- "host.docker.internal:host-gateway"

因为增加了 ragflow 的卷映射,这里注意拷贝 docker/service_conf.yaml.template 到 ragflow/conf 目录下,docker/entrypoint.sh docker/entrypoint-parser.sh 拷贝到 raflow 目录下,避免程序无法启动。

1
2
cd docker
$ docker compose -f docker-compose-macos.yml up -d

修改entrypoint.sh

增加 --debug 支持调试信息。

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
#!/bin/bash

# replace env variables in the service_conf.yaml file
rm -rf /ragflow/conf/service_conf.yaml
while IFS= read -r line || [[ -n "$line" ]]; do
# Use eval to interpret the variable with default values
eval "echo \"$line\"" >> /ragflow/conf/service_conf.yaml
done < /ragflow/conf/service_conf.yaml.template

/usr/sbin/nginx

export LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/

PY=python3
if [[ -z "$WS" || $WS -lt 1 ]]; then
WS=1
fi

function task_exe(){
JEMALLOC_PATH=$(pkg-config --variable=libdir jemalloc)/libjemalloc.so
while [ 1 -eq 1 ];do
LD_PRELOAD=$JEMALLOC_PATH $PY rag/svr/task_executor.py $1;
done
}

for ((i=0;i<WS;i++))
do
task_exe $i &
done

while [ 1 -eq 1 ];do
$PY api/ragflow_server.py --debug
done

wait;

编译

正常情况下,这个时候服务是无法访问的,但容器已经启动,可以通过 dev containers 来访问容器。进入容器后编译前端和重新安装 ragflow 的 python 依赖。

前端

1
2
3
cd web
npm install
npm run build

后端

重新获取依赖

1
uv sync --python 3.10 --frozen;

验证

目录结构一致

img

修改下代码,比如api/apps/user_app.py。

img

我们可以看到修改的代码已经被执行。

img

小结

编程很多时候的问题是部署开发环境的问题,现在有了 docker 已经可以开发部署简单多了。但因为众所周知的原因,你还需要掌握科学上网的魔法。

这次部署 ragflow 环境下来,发现硬盘太小是个问题,模型或者镜像都要确定哪些没用了及时删除。

说明

一开始尝试按源码启动的方式(https://ragflow.io/docs/dev/launch_ragflow_from_source),直接运行 Ragflow,但是在安装 Python 依赖的时候就报错了。于是尝试使用 Docker 的方式运行(https://ragflow.io/docs/dev/build_docker_image)。为什么呢?因为目前官方提供的所有 Docker 镜像均基于 x86 架构构建,并不提供基于 ARM64 的 Docker 镜像。

我这里使用 OrbStack 工具来管理容器。

编译

步骤

  1. 安装uv
1
2
3
4
# On macOS and Linux.
curl -LsSf https://astral.sh/uv/install.sh | sh

# 根据提示,执行 source 命令,或者重开 termial
  1. 获取源码编译镜像
1
2
3
4
5
git clone https://github.com/infiniflow/ragflow.git
cd ragflow/
uv run download_deps.py
docker build -f Dockerfile.deps -t infiniflow/ragflow_deps .
docker build --build-arg LIGHTEN=1 -f Dockerfile -t infiniflow/ragflow:nightly-slim .

其中 download_deps.py会下载一些基础依赖和模型,可以根据自己的实际情况调整模型的下载,比如是否下载模型,模型的下载地址等(未经完全验证是否可以屏蔽模型)。

以下模型下载的关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 镜像列表
repos = [
"InfiniFlow/text_concat_xgb_v1.0",
"InfiniFlow/deepdoc",
"InfiniFlow/huqie",
"BAAI/bge-large-zh-v1.5",
"BAAI/bge-reranker-v2-m3",
"maidalun1020/bce-embedding-base_v1",
"maidalun1020/bce-reranker-base_v1",
]

# 下载镜像
for repo_id in repos:
print(f"Downloading huggingface repo {repo_id}...")
download_model(repo_id)

注意事项

特别重要的是,编译镜像的过程,注意开启全局的科学上网。主要可能碰到的问题,比如组件下载不成功,组件下载不完整等等。

如果组件下载不成功的话,重新执行一遍就好了。

我就在最后一步 docker build 的时候出现问题,看提示就是需要安装的包出错了,在 ragflow 目录下找到对应下载的文件删掉,然后重新执行 uv run download_deps.py

编译的过程时间可能比较长,需要耐心等待。

运行

需要远行 Ragflow 还需要做些配置。打开 docker/.env,找到 RAGFLOW_IMAGE的配置,把镜像地址修改成 infiniflow/ragflow:nightly-slim

接下来通过docker compose 启动服务。

1
2
cd docker
$ docker compose -f docker-compose-macos.yml up -d

耐心等待相关的服务启动,然后使用http://127.0.0.1 访问。

默认没有用户,直接注册一个就可以,第一个用户会成为管理员。

img

问题

自己碰的的问题,是添加模型的时候出错。看了ragflow 群里的问题,这个问题也蛮多人问的。

ragflow 提供的 docker compose 使用的是 ragflow 的对应的网桥就是 docker_ragflow,也可以通过如下命令查看。

1
2
3
4
5
6
7
docker network ls

# 结果
881b4fc04bf4 bridge bridge local
67e9e0de36bc docker_ragflow bridge local
4e76407faa32 host host local
d611379d4316 none null local

找到了网桥,我们可以通过 docker inspect 查看具体的ip,找到 Gateway 的部分就是宿主机的 ip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
docker inspect docker_ragflow

# Gateway
[
{
"Name": "docker_ragflow",
"Id": "67e9e0de36bc646402f69919aaafa30d663f27eb66a6e63bfaf8f75fc59c8a01",
"Created": "2025-03-14T14:19:59.789673667+08:00",
"Scope": "local",
"Driver": "bridge",
"EnableIPv6": false,
"IPAM": {
"Driver": "default",
"Options": null,
"Config": [
{
"Subnet": "192.168.97.0/24",
"Gateway": "192.168.97.1"
}
]

找到了正确的地址,但还是不能通过宿主机地址访问,那么检查 ollama 的配置,是否导出对应的变量。

1
2
3
4
5
export OLLAMA_ORIGINS="*"
export OLLAMA_HOST=0.0.0.0:11434

# 如果没有添加,需要补上
# source .zshrc

如果没有添加,重新添加后重启 ollama。然后通过本地 IP,而不是 127.0.0.1 或者 localhost 访问,是否正常。

img

如果此时提示网页无法访问,这时应该运行 ollama serve,开启 ollama 的服务。

1
2
3
4
ollama serve

# 可以看到类似的提示信息。
2025/03/14 14:48:19 routes.go:1225: INFO server config env="map[HTTPS_PROXY: HTTP_PROXY: NO_PROXY: OLLAMA_CONTEXT_LENGTH:2048 OLLAMA_DEBUG:false OLLAMA_FLASH_ATTENTION:false OLLAMA_GPU_OVERHEAD:0 OLLAMA_HOST:http://0.0.0.0:11434 OLLAMA_KEEP_ALIVE:5m0s OLLAMA_KV_CACHE_TYPE: OLLAMA_LLM_LIBRARY: OLLAMA_LOAD_TIMEOUT:5m0s OLLAMA_MAX_LOADED_MODELS:0 OLLAMA_MAX_QUEUE:512 OLLAMA_MODELS:/Users/airhead/.ollama/models OLLAMA_MULTIUSER_CACHE:false OLLAMA_NEW_ENGINE:false OLLAMA_NOHISTORY:false OLLAMA_NOPRUNE:false OLLAMA_NUM_PARALLEL:0 OLLAMA_ORIGINS:[* http://localhost https://localhost http://localhost:* https://localhost:* http://127.0.0.1 https://127.0.0.1 http://127.0.0.1:* https://127.0.0.1:* http://0.0.0.0 https://0.0.0.0 http://0.0.0.0:* https://0.0.0.0:* app://* file://* tauri://* vscode-webview://* vscode-file://*] OLLAMA_SCHED_SPREAD:false http_proxy: https_proxy: no_proxy:]"

填写网桥的 ip 之后还是无法访问,那需要确定实际的网桥 ip 是否真的存在。

1
ipconfig grep | 192.168.7.1

如果没有结果就说明 ip 没有真正起来,导致 ip 无法访问。接着通过 ifconfig,找到可访问的 ip,比如我这里实际可访问的网桥是 bridge102(这里暂时不确定为什么。)

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
ipconfig

bridge102: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
options=63<RXCSUM,TXCSUM,TSO4,TSO6>
ether fa:4d:89:d7:be:66
inet 198.19.249.3 netmask 0xfffffe00 broadcast 198.19.249.255
inet6 fe80::f84d:89ff:fed7:be66%bridge102 prefixlen 64 scopeid 0x1e
inet6 fd07:b51a:cc66:0:a617:db5e:ab7:e9f1 prefixlen 64
Configuration:
id 0:0:0:0:0:0 priority 0 hellotime 0 fwddelay 0
maxage 0 holdcnt 0 proto stp maxaddr 100 timeout 1200
root id 0:0:0:0:0:0 priority 0 ifcost 0 port 0
ipfilter disabled flags 0x0
member: vmenet3 flags=10003<LEARNING,DISCOVER,CSUM>
ifmaxaddr 0 port 29 priority 0 path cost 0
nd6 options=201<PERFORMNUD,DAD>
media: autoselect
status: active

bridge100: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
options=63<RXCSUM,TXCSUM,TSO4,TSO6>
ether fa:4d:89:d7:be:64
inet 192.168.97.0 netmask 0xffffff00 broadcast 192.168.97.255
inet6 fe80::f84d:89ff:fed7:be64%bridge100 prefixlen 64 scopeid 0x18
Configuration:
id 0:0:0:0:0:0 priority 0 hellotime 0 fwddelay 0
maxage 0 holdcnt 0 proto stp maxaddr 100 timeout 1200
root id 0:0:0:0:0:0 priority 0 ifcost 0 port 0
ipfilter disabled flags 0x0
member: vmenet0 flags=10003<LEARNING,DISCOVER,CSUM>
ifmaxaddr 0 port 23 priority 0 path cost 0
nd6 options=201<PERFORMNUD,DAD>
media: autoselect
status: active

ifconfig |grep 198.19.249.3
inet 198.19.249.3 netmask 0xfffffe00 broadcast 198.19.249.255

之后使用了 198.19.249.3 这个ip 可以正常访问,配置 llm 也正常。

img

为什么不用本机 ip 而用 docker 网桥的 ip 呢?因为是笔记本,通过 Wi-Fi 获取的 ip 可能变化,而网桥 ip 是稳定的。

开发

VS Code 安装 Remote Development 插件。它包含多个插件,其中 Dev Containters 可以连接到 Docker 容器作为开发环境,这样的好处是开发环境与部署环境一致。

img

当安装好 Remote Development,就可以通过 Remote Explorer 查看已经安装的容器,并通过点击对应容器的箭头(注意下图红色框的部分)。

img

进入到容器内部,选对应的目录,就可以打开 /ragflow,这样就能看到实际的代码了。

img

其他

  1. 还需要调整 Dockerfile 方便挂载本地的源码位置,这样方便通过外部的 Git 管理开发代码。
  2. 在配置 LLM 模型的使用有个支持的最大 Token 数,这个值应该怎么填呢?通过 ollama show查看对应模型的信息,其中 context length 就是模型的最大 Token 数了。

img

1
2
3
4
5
6
7
8
9
ollama show deepseek-r1:7b

#
Model
architecture qwen2
parameters 7.6B
context length 131072
embedding length 3584
quantization Q4_K_M
  1. 因为 ollama 暂时不支持重排,需要安装 xinterface。目前通过容器安装 xinterface,可能镜像或者配置选错了,导致 xinterface 的镜像无法启动。
  2. 因为 OrbStack 也可以运行虚拟机,理论上来说,可以通过安装 Ubuntu 虚拟机的方式,照着 Dockerfile 文件安装相关的依赖。

说明

公司里面原来的项目都是 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 进行编排。通过编排,我们可以整合普通的业务逻辑和大模型的生成式功能实现更复杂、更人性化的业务逻辑,当然要实现真实可用的业务逻辑,需要自己进一步的摸索。