CrazyAirhead

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

0%

说明

Solon 的核心概念有 IoC、AOP 和本地事件总线。有人常常有误解以为 IoC 和 AOP 是 Spring 提出的,其实这两种思想在Spring 之前就已经有了,但 Spring 把这两个思想在技术上落地和推广做得很好,让 Ioc 和 AOP 广为人知。

核心概念

IoC

Ioc 的全称是 Inversion of Control,是控制反转或者反转控制的意思。它是一种思想,主要解决的是对象创建和管理的问题,用于解耦依赖。Ioc有时也被称为 DI (Dependency Injection),依赖注入。在使用 IoC 的过程中,我们是通过容器来创建和管理对象的,我们也是从容器中获取对象,所以,有时我们会听到 IoC 就是容器的说法,可能只是简化的一种说法。

AOP

AOP, 全称 Aspect Oriented Programming,是面向切面编程。AOP 的目的是将横切关注点(如日志记录、事务管理、权限控制、接口限流、接口幂等等)从核心业务逻辑中分离出来,通过动态代理、字节码操作等技术,实现代码的复用和解耦,提高代码的可维护性和可扩展性。其中涉及的核心有代理,切点,切面。

本地事件总线

本地事件总线是 Solon 在应用生命周期提供的扩展机制,应用可以根据需要的事件时机点,订阅响应的事件。本地事件总线支持自定义的事件。

注意:

  1. 本地事件总线是基于应用生命周期的(应用的启停,插件的启停,bean 创建等),而不是基于业务,如果是涉及业务,可以使用使用作者的另一个作品 DamiBus。
  2. 要在事件发生前订阅事件,否则会错过时机,无法接收到事件消息。

应用生命周期

应用生命周期,是应用程序从 启动到最后停止的整个过程。应用生命周期当中存在一些关键点,可称为时机点。

SolonApp 的应用生命周期如下图所示,其中时机点包括有:一个初始化函数时机点 + 六个应用事件时机点 + 三个插件生命时机点 + 两个容器生命时机点。

作者的这张图画的很细致,一步步的把这个图走完,能加深对 Solon 的理解。

img

一个初始化函数时机点

应用开发时可扩展的时机。

1
2
3
4
5
6
7
8
@SolonMain
public class App {
public static void main(String[] args) {
Solon.start(App.class, args, (app) -> {
//应用初始化时机点
});
}
}

六个应用事件时机点

应用开发时可扩展的时机。

事件 说明 备注
6.AppInitEndEvent 应用初始化完成事件 只支持手动订阅
8.AppPluginLoadEndEvent 应用插件加载完成事件 只支持手动订阅
b.AppBeanLoadEndEvent 应用Bean加载完成事件(即扫描完成)
e.AppLoadEndEvent 应用加载完成事件(即启动完成)
::运行
g.AppPrestopEndEvent 应用预停止事件
j.AppStopEndEvent 应用停止事件

三个插件生命时机点

插件开发时可扩展的时机。

接口 执行时机 说明
7.start 在应用初始化完成后执行 启动
f.prestop 在 ::stop 前执行 预停止
h.stop 在 Solon::stop 时执行 停止(启用安全停止时,prestop 后等几秒再执行 stop)

两个容器生命时机点

Solon 内部的时机点,应用开发不可扩展。

接口 执行时机 说明
d.start 在扫描完成之后执行 启动
i.stop 在 Solon::stop 时执行,在插件(h.stop)后执行 停止

Bean 生命周期

img

被容器托管的 Bean,它的生命周期只限定在容器内部。

::new()

就是调用构造函数,是在 Bean 被扫描时,且符合条件才会执行。

注意,这个时候,Bean 还注册到容器中,还不能使用注入的字段(还未注入)。如果要初始化,推荐使用@Init 函数。

@Inject

开始执行注入。之后就会注册到容器,并通知订阅者。

start() 或 @Init

执行初始化动作,在 AppContext::start() 开始处被执行。如果使用 start() 需要实现 LifecycleBean 接口。此时 Bean 扫描已完成,理论上所有的 Bean 都已进入容器(某些特殊的 Bean 是在 AppContext.start() 时才生产的),并且所有 Bean 的字段都已完成注入。

postStart()

开始之后,AppContext::start() 结束处被执行,一般用于启动一些任务。如果使用 postStart() 需要实现 LifecycleBean 接口。

preStop()

预停止,AppContext::preStop() 时被执行。一般用来做分布式服务注销之类。如果使用 preStop() 需要实现 LifecycleBean 接口。

stop() 或 @Destroy

停止,AppContext::stop() 时被执行。一般用来做安全停止。如果使用 stop() 需要实现 LifecycleBean 接口。

容器应用

在 Solon 中,我们可以自由的选择用注解的方式,还是手动的方式来实现想要的功能。但在这里我们主要讲解注解的使用方式。

扫描

扫描一般是深度遍历指定“包名”下的 .class 文件获取类名,再通过类名从类加载器里获取元信息。

默认情况下,主类所在包名新的类都会扫描。如果需要修改导入的范围可以使用 @Import 注解,增加扫描的包名,或者导入需要的类名。

构建 / 注入

在 Solon 中,我们通过Singleton,Configuration,Bean,Component,Controller,Remoting等方式产生Bean,在Bean 对象中,我们可以 @Inject 注解注入字段。在构建和注入中需要注意的可能是条件构建和依赖注入的部分了。

注解 说明
@Inject * 注入托管对象(by type)
@Inject(“name”) 注入托管对象(by name)
@Inject(“${name}”) 注入配置(可由基础类型或结构体接收)
@Singleton 单例申明(Solon 默认是单例)
@Singleton(false) 非单例
@Configuration 托管配置组件类(与 @Inject, @Bean 共同完成初始化配置、构建托管对象等)
@Bean 配置托管对象(作用在 @Configuration 类的函数上,才有效)
@Component 托管组件(支持自动代理,v2.5.2 开始支持自动代理)
@Controller 控制器组件类(支持函数拦截)
@Remoting 远程控制器类(有类代理;即RPC服务端)

条件构建

条件构建的意思是满足了指定条件才会构建。通过使用 @Condition 来实现,它包含如下的属性:

属性 说明
onClass 有类(只能一个;其实没必要多个)
onClassName 有类名
onProperty 有属性
onMissingBean 没有 Bean
onMissingBeanName 没有 Bean Name
onBean 有 Bean
onBeanName 有 Bean Name

更多细节,查看官网 https://solon.noear.org/article/434

依赖注入

依赖注入的意思是,在构建时需要使用到其他 Bean 对象。依赖注入通常使用@Configuration,配合@Bean 来实现,其中 @Bean 的函数通过参数注入的方式来获取需要的 Bean 对象。另外就是使用手动模式通过异步订阅的方式获取依赖的 Bean 对象。

以下注入的例子来自官网,更多细节,查看官网 https://solon.noear.org/article/587

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class DemoConfig {
@Bean(name="db1", typed=true)
public DataSource db1(@Inject("${demo.db1}") DataSource ds){
return ds;
}

@Bean
public void db1Test(@Db("db1") UserMapper mapper) { //这个注入,依赖“db1”的数据源
return mapper.initUsers();
}
}

说明

Spring Boot 流行起来的一个原因是简化了配置,其中约定优于配置减少了开发者的配置负担,无需配置或者少量的配置就可以启动项目;自动化配置根据项目依赖自动配置应用程序,简化了开发流程。Solon 作为后起之秀也是采用了约定优于配置和自动配置的方式来简化配置。

约定

配置分类

启动参数

启动参数,在应用启动后会被静态化,也就是不能再修改。在Java 启动时指定,通过--key=value的形式指定。

系统属性

系统属性,在应用启动后会被静态化,也就是不能再修改。在Java 启动时指定,通过-Dkey=value的形式指定。

环境变量

操作系统的环境变量。”solon” 开头的环境变量,会被框架同步到系统属性(System::getProperties)与应用属性(Solon::cfg)。

应用配置

应用通过配置文件加载进来的配置。

主配置文件

应用配置文件为 resources/app.yml 或者 app.properties,文件名不能修改也不能配置。

配置加载规则

应用配置的加载主要分了六个层级,其加载规则为:

  • 越静态的越前面
  • 越动态的越后面

因为配置是以“键”为单位,且后面加载的会盖掉前面加载的,所以最终效果就是配置以最后加载为准。

加载顺序为:

  1. 主配置文件,先是主配置文件(app.yml),之后是带环境的主配置(app-{env}.yml),这里不特别区分(yml或者properties)。
  2. 内部配置文件,通过 solon.config.load加载的 classpath 目录下的配置文件。
  3. 外部配置文件,通过 solon.config.add 加载的配置文件。
  4. 动态配置,启动应用时的配置,先是启动参数(–key=value),之后是系统属性(-Dkey=value),最后是环境变量(系统环境设置,或者docker -e 等方式指定)
  5. 启动时加载,通过 app.cfg().loadAdd 或者 app.cfg().loadEnv加载。
  6. 云端配置,通过分布式配置获取,如nacos。

配置引用规则

配置文件(或配置块)解析时,Solon.cfg() 已经存在的变量(或者配置块内的变量),可以被引用。属性之间的引用,使用 ${...}

1
2
3
4
test.demo1: "${db1.url}"                          #引用应用属性
test.demo2: "jdbc:mysql:${db1.server}" #引用应用属性并组合
test.demo3: "jdbc:mysql:${db1.server}/${db1.db}" #引用多个应用属性并组合
test.demo4: "${JAVA_HOME}" #引用环境变量test.demo5: "${.demo4}" #引用本级其它变量(v2.9.0 后支持)

常用配置

配置可能变更,所以直接看官网 https://solon.noear.org/article/174

注入配置

配置注入使用 ${...}的表达式,支持如下具体的配置:

  • ${xxx} 注入字段。
  • ${xxx:def} 注入字段,如果没有则提供 def 默认值,此时只支持单值接收,不支持集合或实体。
  • ${classpath:xxx.yml} 注入资源目录下的配置文件 xxx.xml

注入字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class DemoService{
//注入值(带默认值:demoApi),并开启自动更新(注意:如果不是单例,请不要开启自动刷新)
@Inject(value="${track.name:demoApi}", autoRefreshed=true)
static String trackName; //v3.0 后支持静态字段注入//注入值(没有时,不覆盖字段初始值)

@Inject("${track.url}")
String trackUrl = "http://x.x.x/track";

//注入配置集合
@Inject("${track.db1}")
Properties trackDbCfg;

//注入Bean(根据对应的配置集合自动生成并注入)
@Inject("${track.db1}")
HikariDataSource trackDs;
}

注入类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Inject("${user.config}")  
@Configuration
public class UserProperties{
public String name;
public List<String> tags;
...
}

@BindProps(prefix="user.config") //或者用绑定属性注解。v3.0.7 后支持
@Configuration
public class UserProperties{
public String name;
public List<String> tags;
...
}

@Configuration
public class DemoConfig {
@BindProps(prefix="user.config") //或者用绑定属性注解。v3.0.7 后支持
@Bean
public UserProperties userProperties(){
return new UserProperties();
}
}

注入参数

当注入到参数的时候,不支持自动刷新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configurationpublic class DemoConfig{
//提示:@Bean 只能与 @Configuration 配合@Bean
public DataSource db1(@Inject("${track.db1}") HikariDataSource ds) {
return ds;
}

@Beanpublic DataSourceWrap db1w(@Inject DataSource ds, @Inject("${wrap}") WrapConfig wc) {
return new DataSourceWrap(ds, wc);
}

//也可以带条件处理@Bean@Condition(onProperty="${cache.enable} = true") //有 "cache.enable" 属性值,且等于truepublic CacheService cache(@Inject("${cache.config}") CacheServiceSupplier supper){
return supper.get();
}
}

自动更新

自动刷新只适合于字段注入,以及单例的类。注意:如果不是单例,请不要开启自动刷新

1
2
3
4
5
6
7
8
9
10
@Componentpublic class DemoService{
//注入值(带默认值:demoApi),并开启自动更新
@Inject(value="${track.name:demoApi}", autoRefreshed=true)
String trackName;

//通过函数时时获取最新的(静态或动态,按需设定)
public static String trackName2(){
return Solon.cfg().get("track.name:demoApi");
}
}

手动配置

通过 Solon.cfg() 获取 SolonProps 后进行操作。这里不做过多的描述,如果需要可以参看官网文档。

示例

配置本身相关简单,记住对应的配置加载规则和注入的方法,基本不会有什么太多的问题。这里不对示例做过多说明,更多需要参看,参看具体的代码 demo-solon01 。

小结

本文主要主要介绍了 Solon 的配置配置规则和自动化配置的方法。在理解了 Solon 的配置后,就可以更动态的做部署的配置。

说明

随着大语言模型的流行,因为使用了 SSE 或者 Websocket的技术进行流式的交换,使得 SSE 和 Websocket也火了起来,今天我们来讲解,如何在 Solon 中实现 Websocket 服务端和 SSE 服务端。

Websocket

WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。它使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

我们这里通过一个简易的 im 的服务做为例子。

依赖

如果使用了solon-web,默认使用的是smart-http,已经集成了 websocket,不需要添加其他的依赖。

插件 适配框架 包大小 信号协议支持 端口
solon-boot-smarthttp smart-http (aio) 0.4Mb http, ws 相同端口
solon-boot-jetty + solon-boot-jetty-add-websocket jetty (nio) 1.9Mb http, ws 相同端口
solon-boot-undertow undertow (nio) 4.3Mb http, ws 相同端口
solon-boot-websocket websocket (nio) 0.4Mb ws 独立端口
solon-boot-websocket-netty netty (nio) ws 独立端口

独立的 WebSocket 插件,会使用独立的端口,且默认为:主端口 + 10000,但都可以通过配置进行修改。

启用

在主类开启 Websocket。

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

import org.noear.solon.Solon;
import org.noear.solon.annotation.SolonMain;

/**
* @author airhead
*/
@SolonMain
public class DemoWeb05App {
public static void main(String[] args) {
Solon.start(
DemoWeb05App.class,
args,
app -> {
app.enableWebSocket(true);
});
}
}

开放端点

这里集成了 SimpleWebSocketListener,已经提供了基本的实现,可以根据实际的需要实现需要的接口。

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.web.im.endpoint;

import com.example.demo.web.im.service.ImService;
import java.io.IOException;
import org.noear.solon.annotation.Inject;
import org.noear.solon.net.annotation.ServerEndpoint;
import org.noear.solon.net.websocket.WebSocket;
import org.noear.solon.net.websocket.listener.SimpleWebSocketListener;

/**
* @author airhead
*/
@ServerEndpoint("/im.ws")
public class ImWebsocket extends SimpleWebSocketListener {
@Inject private ImService service;

@Override
public void onOpen(WebSocket socket) {
service.onOpen(socket);
}

@Override
public void onMessage(WebSocket socket, String text) throws IOException {
service.onMessage(socket, text);
}
}

实现逻辑

此处只是做简单的鉴权,和消息的回复,如果要实现完整的 IM 服务,还需要做会话的管理的逻辑。

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

import java.io.IOException;
import org.dromara.hutool.core.text.StrUtil;
import org.noear.solon.annotation.Component;
import org.noear.solon.net.websocket.WebSocket;

/**
* @author airhead
*/
@Component
public class ImService {
public void onOpen(WebSocket socket) {
String token = socket.param("token");
if (StrUtil.isBlank(token)) {
socket.close();
}

// 省略了管理 socket 的管理
}

public void onMessage(WebSocket socket, String text) throws IOException {
socket.send("> " + text + "\r\n" + "消息已阅");
}
}

验证

img

SSE

SSE 全称是 Server-Sent Event,网页自动获取来自服务器的更新,SSE 是单向消息传递。

我们这里通过一个简易的 LLM 的服务做为例子。

依赖

要实现 SSE 需要引入 solon-web-sse。

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

implementation("org.noear:solon-web")
implementation("org.noear:solon-web-sse")

}

开放端点

客户端先要调用 open 方法,初始化 SseEmitter 连接,之后就可以通过 SseEmitter 给客户端发送消息了,为了方便测试这里还提供了 send 方法。

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.web.chat.controller;

import com.example.demo.web.chat.service.ChatService;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Inject;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.web.sse.SseEmitter;

/**
* @author airhead
*/
@Controller
@Mapping("/chat")
public class ChatController {
@Inject private ChatService service;

@Mapping(value = "/open/{id}")
public SseEmitter open(String id) {
return service.open(id);
}

@Mapping("/send/{id}")
public String send(String id) {
return service.send(id);
}

@Mapping("/close/{id}")
public String close(String id) {
return service.close(id);
}
}

实现逻辑

与 Websocket 不同的是,SSE 是单向连接,请求的过程是不带会话连接的,所以需要自己管理好会话。

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
package com.example.demo.web.chat.service;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.noear.snack.ONode;
import org.noear.solon.Utils;
import org.noear.solon.annotation.Component;
import org.noear.solon.core.handle.Result;
import org.noear.solon.web.sse.SseEmitter;
import org.noear.solon.web.sse.SseEvent;

/**
* @author airhead
*/
@Component
@Slf4j
public class ChatService {
static Map<String, SseEmitter> emitterMap = new HashMap<>();

public SseEmitter open(String id) {
SseEmitter sseEmitter =
new SseEmitter(60 * 1000L)
.onCompletion(() -> emitterMap.remove(id))
.onError(e -> log.error("初始化 sse 错误", e))
.onInited(s -> emitterMap.put(id, s));

try {
sseEmitter.send("Ok");
} catch (IOException e) {
log.error("发送 sse 错误", e);
throw new RuntimeException(e);
}

// 初始化后,才能使用
return sseEmitter;
}

public String send(String id) {
SseEmitter emitter = emitterMap.get(id);
if (emitter == null) {
return "No user: " + id;
}

String msg = "test msg -> " + System.currentTimeMillis();
System.out.println(msg);
try {
emitter.send(msg);
// reconnectTime 用于提示前端重连时间
emitter.send(new SseEvent().id(Utils.guid()).data(msg).reconnectTime(1000L));
emitter.send(ONode.stringify(Result.succeed(msg)));
} catch (IOException e) {
log.error("发送 sse 错误", e);
throw new RuntimeException(e);
}

return "Ok";
}

public String close(String id) {
SseEmitter emitter = emitterMap.get(id);
if (emitter != null) {
emitter.complete();
}

return "Ok";
}
}

验证

ApiFox 支持测试 SSE,连接成功之后就可以调用 send 方法。

img

也可以直接使用 curl 进行测试

img

说明

这里说本地网关,主要是为了区分分布式网关。

在 Web 进阶中,我们讲解了如何定制过滤器和拦截器,提到 Action 本质上是 Handler 和 Class Method 的结合通过,今天我们要讲解一种特殊的Handler —— Gateway(本地网关),通过本地网关更统一的定制系统功能,比如二级路由、拦截、过滤、熔断、异常处理等,虽然直接使用过滤器,也可以做统一的鉴权和异常处理,但网关还可以把一批接口编排进多个网关中,从而实现不同的协议。

示例

在 IDEA 中通过 File->New->Modules... 可以创建新的模块 demo-web04,基础代码和配置可以从demo-web02中拷贝过来。

在这里,我们将继续调整demo-web02,通过 Gateway 来统一的控制逻辑。

  • 增加路由分组,/admin-api/** 对应管理端接口,/app-api/**对应应用端接口
  • 不同的路由分组使用不同的过滤器,从而实现不同的协议等。

通用

BaseGateway

通过 Component 的标签来添加过滤器的通用逻辑。

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
package com.example.demo.web.common.gateway;

import java.util.function.Predicate;
import org.noear.solon.Solon;
import org.noear.solon.core.BeanWrap;
import org.noear.solon.core.handle.Filter;
import org.noear.solon.core.handle.Gateway;

/**
* @author airhead
*/
public abstract class BaseGateway extends Gateway {
public void addFilters(Predicate<BeanWrap> where) {
Solon.context()
.lifecycle(
() ->
Solon.context()
.beanForeach(
(bw) -> {
if (where.test(bw)) {
if (bw.raw() instanceof Filter) {
filter(bw.index(), bw.raw());
}
}
}));
}
}

SwaggerConfig

对 /admin-api 和 /app-api 的文档进行分组。

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
package com.example.demo.web.common.config;

import com.github.xiaoymin.knife4j.solon.extension.OpenApiExtensionResolver;
import io.swagger.models.Scheme;
import io.swagger.models.auth.ApiKeyAuthDefinition;
import io.swagger.models.auth.In;
import java.util.function.Predicate;
import org.dromara.hutool.core.text.StrUtil;
import org.noear.solon.Solon;
import org.noear.solon.annotation.Bean;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.annotation.Inject;
import org.noear.solon.core.BeanWrap;
import org.noear.solon.docs.DocDocket;
import org.noear.solon.docs.models.ApiContact;
import org.noear.solon.docs.models.ApiInfo;

/**
* @author airhead
*/
@Configuration
public class SwaggerConfig {
@Inject private OpenApiExtensionResolver openApiExtensionResolver;

@Bean("adminApi")
public DocDocket adminApi() {
DocDocket docDocket =
new DocDocket()
.groupName("Admin 端接口")
.info(
new ApiInfo()
.title("porpoise-demo")
.description("在线API文档")
.contact(new ApiContact().name("CrazyAirhead").email("l4qiang@hotmail.com"))
.version("1.0"))
.schemes(Scheme.HTTP.toValue())
.globalResponseInData(true)
.basePath(getBasePath())
.vendorExtensions(openApiExtensionResolver.buildExtensions())
.securityExtensions("token", new ApiKeyAuthDefinition().name("token").in(In.HEADER));

addApis(docDocket, bw -> "adminApi".equals(bw.tag()));

return docDocket;
}

@Bean("appApi")
public DocDocket appApi() {
DocDocket docDocket =
new DocDocket()
.groupName("app 端接口")
.info(
new ApiInfo()
.title("Ficus")
.description("在线App API文档")
.termsOfService("https://jishunetwork.com")
.contact(
new ApiContact()
.name("Ficus")
.url("https://jishunetwork.com")
.email("l4qiang@gmail.com"))
.version("1.0"))
.schemes(Scheme.HTTP.toValue())
.globalResponseInData(true)
.basePath(getBasePath());

addApis(docDocket, bw -> "appApi".equals(bw.tag()));

return docDocket;
}

private String getBasePath() {
String basePath = Solon.cfg().serverContextPath();
if (StrUtil.isBlank(basePath)) {
return "/";
}

if (basePath.endsWith("/")) {
basePath = basePath.substring(0, basePath.length() - 1);
}

return basePath;
}

private void addApis(DocDocket docDocket, Predicate<BeanWrap> where) {
Solon.context()
.lifecycle(
() ->
Solon.context()
.beanForeach(
bw -> {
if (where.test(bw)) {
String name = bw.clz().getName();
docDocket.apis(name);
}
}));
}
}

管理端

AdminGateway

路由注册,添加 adminApi 标签的过滤器,添加 adminApi 标签的请求方法。除了按批添加接口,还可以单独添加,这里未单独列出。

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

import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Mapping;

/**
* @author airhead
*/
@Component
@Mapping("/admin-api/**")
public class AdminGateway extends BaseGateway {
@Override
protected void register() {
// 添加过滤器
addFilters(beanWrap -> "adminApi".equals(beanWrap.tag()));

// 添加接口
addBeans(beanWrap -> "adminApi".equals(beanWrap.tag()));
}
}

AdminExceptionFilter

增加 adminApi 的标签,delivered设置为false,这样不会被加载为全局的路由,index设置为-1,提供有限级。为了区分与 App 端的不同,这里的的错误会返回异常的具体信息。

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

import com.example.demo.web.common.model.Ret;
import java.io.IOException;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.Filter;
import org.noear.solon.core.handle.FilterChain;
import org.noear.solon.validation.ValidatorException;

/**
* @author airhead
*/
@Slf4j
@Component(tag = "adminApi", delivered = false, index = -1)
public class AdminExceptionFilter implements Filter {
@Override
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
try {
chain.doFilter(ctx);
} catch (ValidatorException e) {
log.error("validator exception", e);

ctx.status(e.getCode());
ctx.outputAsJson(Ret.fail(e.getMessage()).toJson());
} catch (Throwable e) {
log.error("other throwable", e);

ctx.status(500);
ctx.outputAsJson(Ret.fail(e.getMessage()).toJson());
} finally {
// 文件清理放此处也算合理,异常时需要清理和尝试恢复
if (ctx.isMultipartFormData()) {
try {
ctx.filesDelete();
} catch (IOException e) {
log.error("clean temp file exception", e);
}
}
}
}
}

AdminTokenFilter

管理端需要 token 校验,这里也是为了演示通过不同的 Gateway 可以有不同的过滤器的配置。

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

import lombok.extern.slf4j.Slf4j;
import org.dromara.hutool.core.text.StrUtil;
import org.noear.solon.annotation.Component;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.Filter;
import org.noear.solon.core.handle.FilterChain;

/**
* @author airhead
*/
@Slf4j
@Component(tag = "adminApi", delivered = false, index = 0)
public class AdminTokenFilter implements Filter {
@Override
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
String token = ctx.header("token");
if (StrUtil.isBlank(token)) {
throw new RuntimeException("token 不能为空");
}

chain.doFilter(ctx);
}
}

DeptController

这里的 Controller 去除了 @Controller 的注解和 @Mapping 的注解,使用的是 @Component 的注解,并设置了adminApi的标签,以便 AdminGateway 能进行批量的添加。

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
package com.example.demo.web.manage.controller.admin;

import com.example.demo.web.common.annotation.OperateLog;
import com.example.demo.web.common.model.Ret;
import com.example.demo.web.manage.dto.DeptDto;
import com.example.demo.web.manage.service.DeptService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.util.List;
import org.noear.solon.annotation.*;

/**
* @author airhead
*/
@Component(tag = "adminApi")
@Api("管理端 部门管理")
public class DeptController {
@Inject private DeptService service;

@Mapping(value = "listWithException")
@Get
@ApiOperation("获取列表但返回异常")
public Ret<List<DeptDto>> listWithException() {
return Ret.ok(service.listWithException());
}

@Mapping(value = "list")
@Get
@ApiOperation("获取列表")
@OperateLog
public Ret<List<DeptDto>> list() {
return Ret.ok(service.list());
}
}

应用端

AppGateway

路由注册,添加 appApi 标签的过滤器,添加 appApi 标签的请求方法。

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

import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Mapping;

/**
* @author airhead
*/
@Component
@Mapping("/app-api/**")
public class AppGateway extends BaseGateway {
@Override
protected void register() {
// 添加过滤器
addFilters(beanWrap -> "appApi".equals(beanWrap.tag()));

// 添加接口
addBeans(beanWrap -> "appApi".equals(beanWrap.tag()));
}
}

AppExceptionFilter

Component增加 appApi 的标签,delivered设置为false,这样不会被加载为全局的路由,index设置为-1,提高优先级。为了区分与 Admin 端的不同,这里的的错误只返回「服务器错误,请重试」这样的错误信息。

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
package com.example.demo.web.common.filter;

import com.example.demo.web.common.model.Ret;
import java.io.IOException;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.Filter;
import org.noear.solon.core.handle.FilterChain;
import org.noear.solon.validation.ValidatorException;

/**
* App
*
* @author airhead
*/
@Slf4j
@Component(tag = "appApi", delivered = false, index = -1)
public class AppExceptionFilter implements Filter {
@Override
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
try {
chain.doFilter(ctx);
} catch (ValidatorException e) {
log.error("validator exception", e);

ctx.status(e.getCode());
ctx.outputAsJson(Ret.fail("服务器错误,请重试").toJson());
} catch (Throwable e) {
log.error("other throwable", e);

ctx.status(500);
ctx.outputAsJson(Ret.fail("服务器错误,请重试").toJson());
} finally {
// 文件清理放此处也算合理,异常时需要清理和尝试恢复
if (ctx.isMultipartFormData()) {
try {
ctx.filesDelete();
} catch (IOException e) {
log.error("clean temp file exception", e);
}
}
}
}
}

AppDeptController

这里的 Controller 去除了 @Controller 的注解和 @Mapping 的注解,使用的是 @Component 的注解,并设置了appApi的标签,以便 AdminGateway 能进行批量的添加。

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
package com.example.demo.web.manage.controller.app;

import com.example.demo.web.manage.dto.DeptDto;
import com.example.demo.web.manage.service.DeptService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.util.List;
import org.noear.solon.annotation.*;
import org.noear.solon.core.handle.ModelAndView;

/**
* @author airhead
*/
@Component(tag = "appApi")
@Api("App 端部门服务")
public class AppDeptController {
@Inject private DeptService service;

@Mapping(value = "listWithException")
@Get
@ApiOperation("获取列表但返回异常")
public List<DeptDto> listWithException() {
return service.listWithException();
}

@Mapping(value = "list")
@ApiOperation("获取列表")
public List<DeptDto> list() {
return service.list();
}

@Mapping("/index")
@Get
@ApiOperation("浏览部门")
public ModelAndView index() {
ModelAndView mv = new ModelAndView();
mv.put("depts", service.list());
mv.view("dept.shtm");

return mv;
}
}

验证

管理端接口请求

img

应用端接口请求

img

小结

本章通过本地网关对路由分组注册的方式演示了统一的异常处理和鉴权的逻辑,其实 Gateway 还能进一步的定制,可以参考官网的文档 https://solon.noear.org/article/214

说明

在数据操作的章节中,我们只是讲解了对关系数据库和非关系数据库的简单的数据操作,在实际的业务当中,操作会更加复杂,因此不可避免的会涉及到数据库的事务和数据的缓存。

Solon 通过 Solon data 提供事务的管理和基础的缓存框架,具体的缓存实现还是通过插件的方式来实现的。

事务

Solon 的事务是通过 AOP 的方式实现的,因此在同一个类中的两个方法的调用,事务传播机制是不会生效的。

Solon 通过 Tran 注解来标记事务,主要有 policy (事务传导策略)和 isolation(事务隔离级别)

事务传导策略

传番策略 说明
TranPolicy.required 支持当前事务,如果没有则创建一个新的。这是最常见的选择。也是默认。
TranPolicy.requires_new 新建事务,如果当前存在事务,把当前事务挂起。
TranPolicy.nested 如果当前有事务,则在当前事务内部嵌套一个事务;否则新建事务。
TranPolicy.mandatory 支持当前事务,如果没有事务则报错。
TranPolicy.supports 支持当前事务,如果没有则不使用事务。
TranPolicy.not_supported 以无事务的方式执行,如果当前有事务则将其挂起。
TranPolicy.never 以无事务的方式执行,如果当前有事务则报错。

事务隔离级别

属性 说明
TranIsolation.unspecified 默认(JDBC默认)
TranIsolation.read_uncommitted 脏读:其它事务,可读取未提交数据
TranIsolation.read_committed 只读取提交数据:其它事务,只能读取已提交数据
TranIsolation.repeatable_read 可重复读:保证在同一个事务中多次读取同样数据的结果是一样的
TranIsolation.serializable 可串行化读:要求事务串行化执行,事务只能一个接着一个执行,不能并发执行

示例

虽然 Solon 支持手动的方式提供对事务的操作,但用注解的方式已经足够用,所以这里就不讲解手动使用的方式。如果需要可以查看官网文档 https://solon.noear.org/article/299 。官网也提供了事务的监听器,可能针对性的对事务的事件做些附加的处理。

基于 Web02 增加用户表(主表)和用户部门表(子表)来演示事务的三个常见:

  1. 回滚,无论添加主表方法异常,还是添加子表方法异常。在这个例子业务数据正常。
  2. 只回滚父事务,但不回滚子事务。也就是主表不会添加数据,子表会添加数据。在这个例子中表现为业务数据异常。
  3. 只回滚子事务,但不回滚父事务。也就是子表不会添加数据,父表会添加数据。在这个例子中表现为业务数据异常。

为了方便说明和查看方便,不同的回滚场景提供的代码会做删减,请注意到仓库获取完整代码。

创建表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CREATE TABLE `demo_user` (
`id` int NOT NULL AUTO_INCREMENT,
`nick_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '昵称',
`user_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '用户名',
`password` varchar(255) DEFAULT NULL COMMENT '密码',
`create_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`creator` varchar(255) DEFAULT NULL COMMENT '创建人',
`updater` varchar(255) DEFAULT NULL COMMENT '更新人',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';

CREATE TABLE `demo_user_dept` (
`id` int NOT NULL AUTO_INCREMENT COMMENT 'id',
`user_id` int DEFAULT NULL COMMENT '用户id',
`dept_id` int DEFAULT NULL COMMENT '部门id',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

回滚主子表

回滚主子表的时候,可能出现两种情况,一种是子表方法出现错误,一种是主表方法出现问题。

UserController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Controller
@Mapping("/user")
@Api("用户管理")
public class UserController {
@Inject private UserService service;

@Mapping("rollbackAll")
@Post
@ApiOperation("回滚全部")
public Boolean rollbackAll(@Body UserDto userDto) {
service.rollbackAll(userDto);

return true;
}

@Mapping("rollbackAll1")
@Post
@ApiOperation("回滚全部1")
public Boolean rollbackAll1(@Body UserDto userDto) {
return service.rollbackAll1(userDto);
}
}
UserService
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
/**
* @author airhead
*/
@Component
@Slf4j
public class UserService {
@Db("db1")
private EasyEntityQuery entityQuery;

@Inject private UserDeptService userDeptService;

@Tran
public void rollbackAll(UserDto userDto) {
UserEntity user = UserConvert.INSTANCE.convert(userDto);

entityQuery.insertable(user).executeRows(true);

UserDeptDto userDeptDto = new UserDeptDto();
userDeptDto.setUserId(user.getId());
userDeptDto.setDeptId(userDto.getDeptId());
// 子表方法异常
userDeptService.rollbackUserDept(userDeptDto);
}

@Tran
public Boolean rollbackAll1(UserDto userDto) {
UserEntity user = UserConvert.INSTANCE.convert(userDto);

entityQuery.insertable(user).executeRows(true);

UserDeptDto userDeptDto = new UserDeptDto();
userDeptDto.setUserId(user.getId());
userDeptDto.setDeptId(userDto.getDeptId());
userDeptService.addUserDept(userDeptDto);

// 主表方法异常
throw new RuntimeException("不能添加主表");
}
}
UserDeptService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @author airhead
*/
@Component
@Slf4j
public class UserDeptService {
@Db("db1")
private EasyEntityQuery entityQuery;

@Tran
public void addUserDept(UserDeptDto userDeptDto) {
UserDeptEntity userDept = UserDeptConvert.INSTANCE.convert(userDeptDto);
entityQuery.insertable(userDept).executeRows(true);
}

@Tran
public Boolean rollbackUserDept(UserDeptDto userDeptDto) {
UserDeptEntity userDept = UserDeptConvert.INSTANCE.convert(userDeptDto);
entityQuery.insertable(userDept).executeRows(true);

throw new RuntimeException("不能添加子表");
}
}
效果

这里就不截图了,可以从日志和看数据库数据,可以看到 user 表和 user_dept 表都不会增加数据。

只回滚主表,但不回滚子表

UserController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Controller
@Mapping("/user")
@Api("用户管理")
public class UserController {
@Inject private UserService service;

@Mapping("rollbackMaster")
@Post
@ApiOperation("回滚主表")
public Boolean rollbackMaster(@Body UserDto userDto) {
service.rollbackMaster(userDto);
return true;
}
}
UserService
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
/**
* @author airhead
*/
@Component
@Slf4j
public class UserService {
@Db("db1")
private EasyEntityQuery entityQuery;

@Inject private UserDeptService userDeptService;

@Tran
public void rollbackMaster(UserDto userDto) {
UserEntity user = UserConvert.INSTANCE.convert(userDto);

entityQuery.insertable(user).executeRows(true);

UserDeptDto userDeptDto = new UserDeptDto();
userDeptDto.setUserId(user.getId());
userDeptDto.setDeptId(userDto.getDeptId());

userDeptService.addUserDept1(userDeptDto);

throw new RuntimeException("不能添加主表");
}
}
UserDeptService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author airhead
*/
@Component
@Slf4j
public class UserDeptService {
@Db("db1")
private EasyEntityQuery entityQuery;

@Tran(policy = TranPolicy.requires_new)
public void addUserDept1(UserDeptDto userDeptDto) {
UserDeptEntity userDept = UserDeptConvert.INSTANCE.convert(userDeptDto);
entityQuery.insertable(userDept).executeRows(true);
}
}
效果

这里就不截图了,可以从日志和看数据库数据,可以看到 user 表不会增加数据,但 user_dept 表会增加数据。

只回滚子表,但不回滚主表

UserController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
@Mapping("/user")
@Api("用户管理")
public class UserController {
@Inject private UserService service;

@Mapping("rollbackSub")
@Post
@ApiOperation("回滚子表")
public Boolean rollbackSub(@Body UserDto userDto) {
service.rollbackSub(userDto);

return true;
}
}
UserService
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
/**
* @author airhead
*/
@Component
@Slf4j
public class UserService {
@Db("db1")
private EasyEntityQuery entityQuery;

@Inject private UserDeptService userDeptService;

@Tran
public void rollbackSub(UserDto userDto) {
UserEntity user = UserConvert.INSTANCE.convert(userDto);

entityQuery.insertable(user).executeRows(true);

UserDeptDto userDeptDto = new UserDeptDto();
userDeptDto.setUserId(user.getId());
userDeptDto.setDeptId(userDto.getDeptId());

try {
userDeptService.rollbackUserDept1(userDeptDto);
} catch (Exception ignore) {
// 忽略子表的异常
}
}
}
UserDeptService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @author airhead
*/
@Component
@Slf4j
public class UserDeptService {
@Db("db1")
private EasyEntityQuery entityQuery;

@Tran(policy = TranPolicy.nested)
public Boolean rollbackUserDept1(UserDeptDto userDeptDto) {
UserDeptEntity userDept = UserDeptConvert.INSTANCE.convert(userDeptDto);
entityQuery.insertable(userDept).executeRows(true);

throw new RuntimeException("不能添加子表");
}
}
效果

这里就不截图了,可以从日志和看数据库数据,可以看到 user 表会添加数据,但 user_dept 表不会添加数据。

缓存

Solon 通过 solon-data 插件提供缓存的基础框架,然后通过不同的缓存插件实现具体的缓存逻辑,使得应用层可以快速的切换缓存的存储或者实现多级缓存。

Solon 的缓存框架使用 key(唯一标识) 和 tags(标签)两个维度来管理缓存。

  • key :缓存唯一标识,没有指定时会自动生成。无论自己指定还是自动生成都需要注意避免 key 重复。
  • tags:缓存标签,可以用于标签的批量操作,通常为批量删除。

注解说明

@Cache 注解:

属性 说明
service() 缓存服务
seconds() 缓存时间
key() 缓存唯一标识,支持字符串模版
tags() 缓存标签,多个以逗号隔开(为当前缓存块添加标签,用于清除)

@CachePut 注解:

属性 说明
service() 缓存服务
seconds() 缓存时间
key() 缓存唯一标识,支持字符串模版
tags() 缓存标签,多个以逗号隔开(为当前缓存块添加标签,用于清除)

@CacheRemove 注解:

属性 说明
service() 缓存服务
keys() 缓存唯一标识,多个以逗号隔开,支持字符串模版
tags() 缓存标签,多个以逗号隔开(方便清除一批key)

示例

我们继续通过部门管理的例子来演示数据的缓存。

RedisConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @author airhead
*/
@Configuration
public class RedisConfig {
@Bean(typed = true)
public CacheService defaultCache(@Inject RedisClient redisClient) {
RedisCacheService cacheService = new RedisCacheService(redisClient, 30);
cacheService.enableMd5key(false);
return cacheService;
}

@Bean(typed = true)
public RedisClient defaultClient(
@Inject("${demo.redis}") RedisClientSupplier redisClientSupplier) {
return redisClientSupplier.get();
}
}

DeptDto

需要实现 Serializable 这样才能给缓存序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* @author airhead
*/
@ApiModel
@Data
public class DeptDto implements Serializable {
private Long id;

/** 部门名称 */
private String name;

/** 部门编码 */
private String code;
}

DeptService

保存、更新、删除的时候通过tags清理缓存,获取id和list的时候通过key增加缓存,同时增加tags标签。

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
/**
* @author airhead
*/
@Component
@Slf4j
public class DeptService {
@Db("db1")
private EasyEntityQuery entityQuery;

@CachePut(key = "dept:list", tags = "dept")
public List<DeptDto> list() {
log.info("search list from db");

return entityQuery.queryable(DeptEntity.class).select(DeptDto.class).toList();
}

@CacheRemove(tags = "dept")
public Boolean add(DeptDto deptDto) {
DeptEntity dept = DeptConvert.INSTANCE.convert(deptDto);

return entityQuery.insertable(dept).executeRows(true) > 0L;
}

@CacheRemove(tags = "dept")
public Boolean update(DeptDto deptDto) {
DeptEntity dept = DeptConvert.INSTANCE.convert(deptDto);
return entityQuery.updatable(dept).executeRows() > 0L;
}

@CachePut(key = "dept:${id}", tags = "dept")
public DeptDto get(Long id) {
log.info("search from db by id");

return entityQuery
.queryable(DeptEntity.class)
.whereById(id)
.select(DeptDto.class)
.firstOrNull();
}

@CacheRemove(tags = "dept")
public Boolean delete(Long id) {
return entityQuery
.getEasyQueryClient()
.deletable(DeptEntity.class)
.allowDeleteStatement(true)
.whereById(id)
.executeRows()
> 0L;
}
}

效果

调用 list 或 get 接口时缓存,如果存在缓存时不再从数据库中获取数据。

img

小结

要在 Solon 应用中使用事务和缓存是比较容易的,增加对应的注解即可实现相关的功能。有了事务和缓存的支持,数据的可靠性和应用的性能可以进一步得到保障,通过以上内容的学习,基本上可以起飞,做业务开发了。

说明

在基础篇当中,我们讲究了 Solon 对 MVC 的支持和提供的基础的Web 能力。在这个章节我们会讲解 Solon 提供了哪些能力可以供我们进行控制。

首先我们来看看 Solon Web 的请求过程,理解 Solon 的基础概念,这样方便我们后续的定制。

请求过程

img

从图中,我们可以看到在Web 的请求过程中,会经过几个环节:Filter(全局过滤器)->

RouterInterCeptor(路由拦截器)-> Handler(处理器)-> Interceptor(拦截器)。

Context

Solon 采用 Handler + Context 架构,Context 是请求的上下文。 在 Context 中可以获取请求和响应相关的数据,包括请求相关的对象与接口,会话相关的对象与接口,响应相关的对象与接口等。Solon 提供了多种方式获取Context,也为Context 其他了一些常用的接口,更详细的内容可以查看 https://solon.noear.org/article/216

Filter

过滤器,可以对所有请求(无论动态还是静态的请求)进行全局的控制。通常用于全局的 Web 请求异常处理,全局的性能记录,全局的响应状态调整,全局的上下文日志记录,全局的链路跟踪等等,当然也可以作用在局部上。

RouterInterceptor

路由拦截器,可以对动态请求进行全局的控制,功能基本与 Filter 一致。主要的区别是:1. 只能对动态请求(路由器范围)进行拦截;2. 可以修改参数和返回结果。

Interceptor

拦截器,拦截器使用切面(AOP)的方式实现,通过定义注解、设置切点、注册拦截器,Solon 将对方法进行拦截,从而增加拦截器方法的执行。拦截器可以用于实现缓存、事务等等功能。

Action

实际的请求方法,注册到路由器的方法(通常是@Mapping 函数,可以通过 Controller 的方式定义,也可以通过 Handler 的方式定义,等等)。Action 本质上是 Handler 和 Class Method 的结合,可以请求方法进行局部的控制。

示例 demo-web02

在 IDEA 中通过 File->New->Modules... 可以创建新的模块 demo-web02,基础代码和配置可以从demo-web01中拷贝过来。

这里我们继续补充demo-web01 ,增加定制化的处理。

  • 增加简单的鉴权用于演示的Filter。
  • 增加RouterInterceptor,用于演示Filter和RouterInterceptor的区别。
  • 增加日志审计的功能用于演示 Interceptor 拦截器。

全局过滤器

使用全局的Filter做简单的 token 验证。这里需要指定Component的注解,也就是统一有Solon 来管理。如果有多个过滤器是可以指定Component的 index 参数,参数越小,越外层,可以理解为优先级越高。

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
package com.example.demo.web.common.filter;

import com.jfinal.kit.Kv;
import lombok.extern.slf4j.Slf4j;
import org.dromara.hutool.core.text.StrUtil;
import org.noear.snack.ONode;
import org.noear.solon.annotation.Component;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.Filter;
import org.noear.solon.core.handle.FilterChain;

/**
* @author airhead
*/
@Slf4j
@Component
public class GlobalFilter implements Filter {
@Override
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
String path = ctx.path();
log.info("1. GlobalFilter, path: {}", path);
// 简单逻辑,非静态的路径都需要token
if (!path.contains(".")) {
String token = ctx.param("token");
if (StrUtil.isBlank(token)) {
ctx.outputAsJson(ONode.stringify(Kv.of("state", false)));
return;
}
}

chain.doFilter(ctx);
}
}

路由拦截器

这边只是为了对比和Filter的不同,如果是静态资源请求时,不会进入路由拦截器,也就不会打印对应的日志。

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.web.common.interceptor;

import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.Handler;
import org.noear.solon.core.route.RouterInterceptor;
import org.noear.solon.core.route.RouterInterceptorChain;

/**
* @author airhead
*/
@Slf4j
@Component
public class DemoRouteInterceptor implements RouterInterceptor {
@Override
public void doIntercept(Context ctx, Handler mainHandler, RouterInterceptorChain chain)
throws Throwable {
String path = ctx.path();
log.info("2. DemoRouteInterceptor, path: {}", path);

chain.doIntercept(ctx, mainHandler);
}
}

局部过滤器

局部过滤器可以通过 Addition 注解直接使用,可以通过注解继承的方式使用。

WhitelistFilter

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

import lombok.extern.slf4j.Slf4j;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.Filter;
import org.noear.solon.core.handle.FilterChain;

/**
* @author airhead
*/
@Slf4j
public class WhitelistFilter implements Filter {
@Override
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
String path = ctx.path();
log.info("3. WhitelistFilter, path: {}", path);
chain.doFilter(ctx);
}
}

Whitelist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.example.demo.web.common.annotation;

import com.example.demo.web.common.filter.WhitelistFilter;
import java.lang.annotation.*;
import org.noear.solon.annotation.Addition;

/**
* @author airhead
*/
@Addition(WhitelistFilter.class)
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Whitelist {}

拦截器

拦截器可以通过 Around 注解直接使用,可以通过注解继承的方式使用。

LogInterceptor

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

import lombok.extern.slf4j.Slf4j;
import org.noear.solon.core.aspect.Interceptor;
import org.noear.solon.core.aspect.Invocation;
import org.noear.solon.core.handle.Context;

/**
* @author airhead
*/
@Slf4j
public class LogInterceptor implements Interceptor {
@Override
public Object doIntercept(Invocation inv) throws Throwable {
Context context = Context.current();
String path = context.path();
log.info("4. LogInterceptor, path: {}", path);

return inv.invoke();
}
}

OperateLog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.example.demo.web.common.annotation;

import com.example.demo.web.common.interceptor.LogInterceptor;
import java.lang.annotation.*;
import org.noear.solon.annotation.Around;

/**
* @author airhead
*/
@Around(LogInterceptor.class)
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OperateLog {}

Controller的调整

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

import com.example.demo.web.common.annotation.OperateLog;
import com.example.demo.web.common.annotation.Whitelist;
import com.example.demo.web.common.filter.WhitelistFilter;
import com.example.demo.web.common.interceptor.LogInterceptor;
import com.example.demo.web.manage.dto.DeptDto;
import com.example.demo.web.manage.service.DeptService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.util.List;
import org.noear.solon.annotation.*;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.ModelAndView;
import org.noear.solon.core.handle.UploadedFile;

@Controller
@Mapping("/dept")
@Api("部门管理")
public class DeptController {
@Inject private DeptService service;

@Mapping("/index")
@Get
@ApiOperation("浏览部门")
@OperateLog
@Whitelist
public ModelAndView index() {
ModelAndView mv = new ModelAndView();
mv.put("depts", service.list());
mv.view("dept.shtm");

return mv;
}

@Mapping(value = "list")
@Get
@ApiOperation("获取列表")
@Around(LogInterceptor.class)
@Addition(WhitelistFilter.class)
public List<DeptDto> list() {
return service.list();
}
}

验证

  • 请求 /dept/index,不带token得情况,演示全局的过滤器效果

img

  • 请求 /dept/list?tokent=1,带token得情况,验证请求过程,使用的是 Addition 和 Around 的方式添加局部过滤器和拦截器。

img

img

  • 请求 /dept/index?tokent=1,带token得情况,验证请求过程,使用注解继承的方式添加局部过滤器和拦截器。

img

img

  • 请求 /img/wechat.png,验证静态请求不会通过RouterInterceptor。如果静态资源不存在的情况,依然会继续查找动态路由,此时会进入RouterInterceptor。

img

小结

本章节通过示意图的方式简单描述Solon web的请求过程,并对其中的重要概念做了讲解,通过实例的方式正式了对过滤器,路由拦截器和拦截器的定制。

官网对这些内容做了多篇文章的介绍,可以通过官网进一步的了解这些概念和使用方法。

说明

Web 基础主要讲解 Solon 对 Web 开发提供的基础能力,支持 HTML 文档和其他静态资源的获取,提供数据交换的能力。

MVC

Web 开发中最常使用的 MVC 架构,MVC 全称是 “模型 (Model)- 视图 (View)- 控制器 (Controller)”,是一种广泛使用的设计模式,用于将应用程序的逻辑、数据和界面进行分离。

img

在具体对 MVC 的划分中可能存在一些边界的不同,有的人认为 Modle是输入,View 是输出,Controller是处理;有的人认为 Model 是数据,View 是数据的展示与用户的交互,Controller是处理,包含了输入和输出;有的人认为 Model 就是模型,即数据+接口,View 是模型的裁剪,Controller 是数据交互。随着前后端分离的架构的发展,前端也有自己的MVC 的结构,这个时候后端的接口的就成了 Model 层。根据自己对 MVC 的实际理解去用就好了,适合自己就好。

在 Solon 中可以这样理解:solon-data 对应处理 Model 层,提供数据处理、事务、缓存等支持;solon-core/solon-boot 对应处理 Controller 层,提供 Handler + Context 操作接口,提供Http信号接入支持;solon-view 对应处理 View 层(单体结构),提供不同的模板渲染框架,solon-serialization 对应处理 View 层(前后端分离结构),提供JSON、XML、PROTOSTUFF 等不同格式的序列化。

常用注解

@Controller 控制器注解(只有一个注解,会自动通过不同的返回值做不同的处理)
@Param 注入请求参数(包括:QueryString、Form、Path)。起到指定名字、默认值等作用
@Header 注入请求 header
@Cookie 注入请求 cookie
@Path 注入请求 path 变量(因为框架会自动处理,所以这个只是标识下方便文档生成用)
@Body 注入请求体(一般会自动处理。仅在主体的 String, Steam, Map 时才需要)
@Mapping 路由关系映射注解
@Get @Mapping 的辅助注解,便于 RESTful 开发
@Post @Mapping 的辅助注解,便于 RESTful 开发
@Put @Mapping 的辅助注解,便于 RESTful 开发
@Delete @Mapping 的辅助注解,便于 RESTful 开发
@Patch @Mapping 的辅助注解,便于 RESTful 开发
@Produces 输出内容类型申明
@Consumes 输入内容类型申明(当输出内容类型未包函 @Consumes,则响应为 415 状态码)
@Multipart 显式申明支持 Multipart 输入

参数注入

关于 MVC 参数注入,主要尊守以下规则:

  • 参数名与请求数据名一一对应(使用 -parameters)。
  • 当对映不上时,会采用整体数据注入(如果接收的是实体)
  • 参数名与请求数据同名时,又想整体注入(如果接收的是实体),可使用 @Body 注解强制标注。

个人偏好

不同的 MVC 的理解,会有不一样的项目结构,下面是阿里巴巴《Java 开发手册(黄山版)》的截图,可供参考。

img

img

以下属于个人偏好的目录结构,后续的示例会按这个方式处理。

1
2
3
4
5
6
7
domain(业务领域)
- controller(对应Contoller 层,提供用户交互接口)
- admin(管理端接口)
- app(应用端接)
- dto (对应 View 层的数据)
- model(对应 Model 层的数据)
- service(对应 Model 层的接口)

进一步补充,在 controller 中我不会放业务逻辑,只是调用 service进行转发,这样能更大程度的复用Service;在service中通常不采用接口实现的分离方式(虽然这种方式更好,但在编写、查看代码的过程中跳转太多,而且对应业务来说,通常不会有多种实现,有多种实现的逻辑再进行接口实现分离的方式);尽可能少的分层领域模型,避免过多的数据转换操作。

demo-web01

在 IDEA 中通过 File->New->Modules... 可以创建新的模块 demo-web01,基础代码和配置可以从demo-orm中拷贝过来,或者自己手动创建,之后调整包名。

这里我们继续补充demo-orm的部门管理的例子。实现基本的增删改查(CURD)接口,实现数据的导入导出展示文件的上传与下载,实现 index 接口用于展示模版的渲染,同时在模版中访问静态资源。提供 list 展示 json 的序列化,提供 listXml 展示 XML 的序列化。

为了在接口中尽可能多的展示不同的参数注入的使用,接口的定义不会是完全的 RESTful 风格。

修改依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
dependencies {
// enjoy 模版渲染
implementation("org.noear:solon-view-enjoy")

// xml 序列化
implementation("org.noear:solon-serialization-jackson-xml")

// json 序列化,默认就是snack3 可以不配置
implementation("org.noear:solon-serialization-snack3")

// 静态资源访问
implementation("org.noear:solon-web-staticfiles")
}

创建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
package com.example.demo.web.manage.model;

import com.easy.query.core.annotation.Column;
import com.easy.query.core.annotation.EntityProxy;
import com.easy.query.core.annotation.Table;
import com.easy.query.core.proxy.ProxyEntityAvailable;
import com.example.demo.web.manage.model.proxy.DeptEntityProxy;
import java.time.LocalDateTime;
import lombok.Data;

/**
* 部门表 实体类。
*
* @author Airhead
* @since 1.0
*/
@Data
@Table(value = "demo_dept")
@EntityProxy
public class DeptEntity implements ProxyEntityAvailable<DeptEntity, DeptEntityProxy> {

/** id */
@Column(primaryKey = true, value = "id", generatedKey = true)
private Long id;

/** 部门名称 */
private String name;

/** 部门编码 */
private String code;

/** 时间时间 */
private LocalDateTime createAt;

/** 更新时间 */
private LocalDateTime updateAt;
}

创建service

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

import com.easy.query.api.proxy.client.EasyEntityQuery;
import com.easy.query.solon.annotation.Db;
import com.example.demo.web.manage.convert.DeptConvert;
import com.example.demo.web.manage.dto.DeptDto;
import com.example.demo.web.manage.model.DeptEntity;
import java.util.List;
import org.noear.solon.annotation.*;

/**
* @author airhead
*/
@Component
public class DeptService {
@Db("db1")
private EasyEntityQuery entityQuery;

public List<DeptDto> list() {
return entityQuery.queryable(DeptEntity.class).select(DeptDto.class).toList();
}

public Boolean add(DeptDto deptDto) {
DeptEntity dept = DeptConvert.INSTANCE.convert(deptDto);

return entityQuery.insertable(dept).executeRows(true) > 0L;
}

public Boolean update(DeptDto deptDto) {
DeptEntity dept = DeptConvert.INSTANCE.convert(deptDto);
return entityQuery.updatable(dept).executeRows() > 0L;
}

public DeptDto get(Long id) {
return entityQuery
.queryable(DeptEntity.class)
.whereById(id)
.select(DeptDto.class)
.firstOrNull();
}

public Boolean delete(Long id) {
return entityQuery
.getEasyQueryClient()
.deletable(DeptEntity.class)
.allowDeleteStatement(true)
.whereById(id)
.executeRows()
> 0L;
}
}

创建dto

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

import lombok.Data;

/**
* @author airhead
*/
@Data
public class DeptDto {
private Long id;

/** 部门名称 */
private String name;

/** 部门编码 */
private String code;
}

创建controller

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

import com.example.demo.web.manage.dto.DeptDto;
import com.example.demo.web.manage.service.DeptService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.util.List;
import org.noear.solon.annotation.*;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.ModelAndView;
import org.noear.solon.core.handle.UploadedFile;

@Controller
@Mapping("/dept")
@Api("部门管理")
public class DeptController {
@Inject private DeptService service;

@Mapping(value = "list", produces = "application/json")
@Get
@ApiOperation("获取列表json")
public List<DeptDto> list() {
return service.list();
}

@Mapping(value = "listXml", produces = "application/xml")
@Get
@ApiOperation("获取列表Xml")
public List<DeptDto> listXml() {
return service.list();
}

@Mapping
@Post
@ApiOperation("新增")
public Boolean add(@Body DeptDto deptDto) {
return service.add(deptDto);
}

@Mapping
@Put
@ApiOperation("更新")
public Boolean update(@Body DeptDto deptDto) {
return service.update(deptDto);
}

@Mapping("/{id}")
@Get
@ApiOperation("获取")
public DeptDto get(@Path Long id) {
return service.get(id);
}

@Mapping
@Delete
@ApiOperation("删除")
public Boolean delete(@Param Long id) {
return service.delete(id);
}

@Mapping("/importData")
@Post
@ApiOperation("上传")
@Multipart
public Boolean importData(UploadedFile file) {
return service.importData(file);
}

@Mapping("/exportData")
@Get
@ApiOperation("下载")
public void exportData(Context context) {
service.exportData(context);
}

@Mapping("/index")
@Get
@ApiOperation("浏览部门")
public ModelAndView index() {
ModelAndView mv = new ModelAndView();
mv.put("depts", service.list());
mv.view("dept.shtm");

return mv;
}
}

上传下载

Solon 对上传下载的支持也是比较丰富的,这里只是使用了最常用的方式进行展示。更多的使用方法,参看 https://solon.noear.org/article/313

静态资源

引入solon-web-staticfiles可以提供公共的静态文件服务,默认静态文件目录是resources/static/ 。可以通过配置的的方式调整目录。

1
2
3
4
5
solon:
staticfiles:
mappings:
- path: "/img/"
repository: "classpath:img"

还可以通过代码的方式调整目录,更多的配置参看 https://solon.noear.org/article/268

验证

View

微信号的展示和logo,访问的是静态资源,且使用了不同的目录。

img

Json

img

Xml

img

上传

img

上传文件后,可以在日志中看到上传的文件名,可以在代码根目录看到对应上传的文件。

下载

下载的内容 swagger 工具显示不了,这里只展示返回的头信息。可以在浏览器中直接输入http://localhost:8080/dept/exportData 进行下载测试。

img

问题

在 Solon 3.0.7 版本测试的时候发现上述 controller 的路由定义,可能存在 index 接口无法访问,而路由到的 get 接口的情况,但 3.1.0-SNAPSHOT已经修复这个问题,当前代码实用了 Solon 3.1.0-SNAPSHOT,后续 3.1.0 发布时,教程也会统一更新版本号。

img

小结

MVC 虽然广为人知,但是在细节处还是存在理解的不同。Solon 对 MVC 用完整的支持,可以放心食用。不喜欢示例中的项目结构划分的可以参考阿里或者其他的目录结构进行项目结构的组织。

补充说明

春节期间的囤货已经用完了,也是第一次写比较完整的教程,后续教程的更新可能会比较慢。

说明

一般的 Web 应用都会使用到 Redis 做缓存处理,也可能会用 ElasticSearch 做全文检索。所以这里主要介绍对这个两个数据中间件的使用。Monogo方面也有对应的插件,可以自行尝试。

创建模块

在 IDEA 中通过 File->New->Modules... 可以创建新的模块 demo-nosql,基础代码和配置可以从demo01中拷贝过来,或者自己手动创建。

修改依赖

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
plugins {
id 'java'
id "io.freefair.lombok"
}

group = "com.example"
// 统一版本号
version "${demoVersion}"
description = "orm demo"

dependencies {
implementation platform(project(":demo-parent"))

implementation("org.noear:solon-web")
implementation("org.noear:solon-logging-logback")
implementation("org.noear:solon-openapi2-knife4j")
implementation("org.noear:solon-cache-jedis")
implementation("org.noear:esearchx")

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

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


compileJava {
options.encoding = "UTF-8"
options.compilerArgs << "-parameters"
}

jar {
manifest {
attributes "Main-Class": "com.example.demo.orm.DemoOrmApp"
}

dependsOn(configurations.runtimeClasspath)

duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from(configurations.runtimeClasspath.collect {
if (it.isDirectory()) it else zipTree(it)
}) {
exclude 'META-INF/MANIFEST.MF'
exclude 'META-INF/*.SF'
exclude 'META-INF/*.DSA'
exclude 'META-INF/*.RSA'
}

def sourcesMain = sourceSets.main
sourcesMain.allSource.forEach { println("add from sources: ${it.name}") }
from(sourcesMain.output)
}

使用 Redis

增加依赖

build.gradle

1
2
3
dependencies {
implementation("org.noear:solon-cache-jedis")
}

修改配置

app.yml

1
2
3
4
5
6
7
8
9
10
11
12
# 配置数据源
demo:
redis:
driverType: redis #驱动类型
server: 127.0.0.1:6379
db: 0 #默认为 0,可不配置
password: porpoise-demo
keyHeader: porpoise-demo
enableMd5key: false
defSeconds: 600 #默认为 30,可不配置
idleConnectionTimeout: 10000
connectTimeout: 10000

增加配置类

RedisConfig

1
2
3
4
5
6
7
8
9
10
11
/**
* @author airhead
*/
@Configuration
public class RedisConfig {
@Bean(typed = true)
public RedisClient defaultClient(
@Inject("${demo.redis}") RedisClientSupplier redisClientSupplier) {
return redisClientSupplier.get();
}
}

增加 controller

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
/**
* @author airhead
*/
@Controller
@Mapping("/redis")
@Api("redis")
public class RedisController {
@Inject private RedisClient redisClient;

@Mapping
@Get
@ApiOperation("获取key的值")
public String get(String key) {
return redisClient.openAndGet(session -> session.key(key).get());
}

@Mapping
@Post
@ApiOperation("设置key的值")
public Boolean set(String key, String value) {
redisClient.open(
session -> {
session.key(key).set(value);
});

return true;
}
}

验证

验证有很多种方式,这里通过 swagger 文档进行调用。浏览器输入 http://localhost:8080/doc.html

设置

img

获取

img

使用 ElasticSearch

增加依赖

build.gradle

1
2
3
4
dependencies {
// es 没有对应的插件,需要自己引入 esearchx
implementation("org.noear:esearchx")
}

修改配置

app.yml

1
2
3
4
5
6
# 配置数据源
demo:
es:
url: http://127.0.0.1:9200
username: root
password: 123456

增加配置类

EsConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author airhead
*/
@Configuration
public class EsConfig {
/**
* url,username,password
*
* @param properties
* @return
*/
@Bean(name = "esContext", typed = true)
public EsContext esContext(@Inject("${demo.es}") Properties properties) {
return new EsContext(properties);
}
}

增加 entity

增加DemoEntity方便操作数据

1
2
3
4
5
6
7
8
/**
* @author airhead
*/
@Data
public class DemoEntity {
private String name;
private String code;
}

增加 controller

Controller 非必须,只是方便测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* @author airhead
*/
@Controller
@Mapping("/es")
@Api("es")
@Slf4j
public class EsController {
@Inject private EsContext esContext;

@Mapping
@Get
@ApiOperation("获取列表")
public List<DemoEntity> list() {
try {
EsData<DemoEntity> result = null;
result = esContext.indice("demo").selectList(DemoEntity.class);
return result.getList();
} catch (IOException e) {
log.error("add error", e);
throw new RuntimeException(e);
}
}

@Mapping
@Post
@ApiOperation("保存文档")
public Boolean add(@Body DemoEntity entity) {
try {
esContext.indice("demo").insert(entity);
} catch (IOException e) {
log.error("add error", e);
throw new RuntimeException(e);
}

return true;
}
}

验证

验证有很多种方式,这里通过 swagger 文档进行调用。浏览器输入 http://localhost:8080/doc.html

保存文档

img

获取文档列表

img

小结

Solon 通过简单的配置就可以操作 Redis 和 ElasticSearch ,对 NoSql 的支持还是挺完整的,更多的对数据的操作需要看具体的类库提供的接口。

完整代码可以从 https://gitee.com/CrazyAirhead/porpoise-demo 获取。

说明

Solon 不像 Jfinal 提供了基础的数据库能力,是通过集成第三方的 ORM 来实现的,当然 Solon 家也有自己的 ORM(wood)。结合自己使用了多个 ORM 的情况,选择了无 XML 的 easy-query。虽然 Solon 也有 activerecord 的插件,但是 Jfinal 框架内,使用还是不够趁手。

个人确实比较喜欢 activerecord,后续也会补充对 easy-query 的扩展,比如继承 activerecord 的 sql 模版管理,基于 map 的 Model Bean。

这里使用的是 MySQL 数据库,其他数据库是类似的,切换对应的驱动即可。

创建模块

在 IDEA 中通过 File->New->Modules... 可以创建新的模块 demo-orm,基础代码和配置可以从demo01中拷贝过来,或者自己手动创建。

修改依赖

使用初始器创建项目,增加 web 和 easy-query 依赖,同时增加 swagger 依赖,并非必须,只是为了方便测试接口。

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
plugins {
id 'java'
id "io.freefair.lombok"
}

group = "com.example"
// 统一版本号
version "${demoVersion}"
description = "orm demo"

dependencies {
implementation platform(project(":demo-parent"))

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

implementation("com.zaxxer:HikariCP")
implementation("mysql:mysql-connector-java")
implementation("com.easy-query:sql-solon-plugin")

annotationProcessor("org.mapstruct:mapstruct-processor:${mapstructVersion}")
// 生成 entityProxy
annotationProcessor("com.easy-query:sql-processor:${easyQueryVersion}")

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


compileJava {
options.encoding = "UTF-8"
options.compilerArgs << "-parameters"
}

jar {
manifest {
attributes "Main-Class": "com.example.demo.orm.DemoOrmApp"
}

dependsOn(configurations.runtimeClasspath)

duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from(configurations.runtimeClasspath.collect {
if (it.isDirectory()) it else zipTree(it)
}) {
exclude 'META-INF/MANIFEST.MF'
exclude 'META-INF/*.SF'
exclude 'META-INF/*.DSA'
exclude 'META-INF/*.RSA'
}

def sourcesMain = sourceSets.main
sourcesMain.allSource.forEach { println("add from sources: ${it.name}") }
from(sourcesMain.output)
}

创建数据库

1
2
3
4
5
6
7
8
CREATE TABLE `demo_dept` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '部门名称',
`code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '部门编码',
`create_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '时间时间',
`update_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='部门表';

修改配置

app.yml

1
2
3
4
5
6
7
8
# 配置数据源
solon.dataSource:
db1:
jdbcUrl: jdbc:mysql://127.0.0.1:3306/demo?useUnicode=true&characterEncoding=utf8&autoReconnect=true&rewriteBatchedStatements=true
driverClassName: com.mysql.cj.jdbc.Driver
username: demo
password: 123456

配置数据源

1
2
3
4
5
6
7
@Configuration
public class EasyQueryConfig {
@Bean(name = "db1", typed = true)
public DataSource db1(@Inject("${demo.db1}") HikariDataSource ds) {
return ds;
}
}

配置文档

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
/**
* @author airhead
*/
@Configuration
public class SwaggerConfig {
@Inject private OpenApiExtensionResolver openApiExtensionResolver;

@Bean("adminApi")
public DocDocket adminApi() {
DocDocket docDocket =
new DocDocket()
.groupName("Admin 端接口")
.info(
new ApiInfo()
.title("porpoise-demo")
.description("在线API文档")
.contact(new ApiContact().name("CrazyAirhead").email("l4qiang@hotmail.com"))
.version("1.0"))
.schemes(Scheme.HTTP.toValue())
.globalResponseInData(true)
.vendorExtensions(openApiExtensionResolver.buildExtensions())
.apis("com.example.demo");

return docDocket;
}
}

增加 model

easy-query 提供了插件可以生成 model,通过IDEA 的database 连接数据库,选择表,右键选择TableEntityGenerate,填写对应的信息,点击 ok。

img

img

生成代码后需要使用Ctrl+enter,呼出IDEA的代码生成菜单,点击 EntityQueryImplement。

img

如果没有使用插件,自己创建实体类即可。

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
package com.example.demo.orm.model;

import com.easy.query.core.annotation.Column;
import com.easy.query.core.annotation.EntityProxy;
import com.easy.query.core.annotation.Table;
import com.easy.query.core.proxy.ProxyEntityAvailable;
import com.example.demo.orm.model.proxy.DeptEntityProxy;
import lombok.Data;

import java.time.LocalDateTime;

/**
* 部门表 实体类。
*
* @author Airhead
* @since 1.0
*/
@Data
@Table(value = "demo_dept")
@EntityProxy
public class DeptEntity implements ProxyEntityAvailable<DeptEntity, DeptEntityProxy> {

/**
* id
*/
@Column(primaryKey = true, value = "id", generatedKey = true)
private Long id;

/**
* 部门名称
*/
private String name;

/**
* 部门编码
*/
private String code;

/**
* 时间时间
*/
private LocalDateTime createAt;

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


}

增加Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Controller
@Mapping("/dept")
@Api("部门管理")
public class DeptController {
@Db("db1")
private EasyEntityQuery entityQuery;

@Mapping
@Get
@ApiOperation("获取列表")
public List<DeptEntity> list() {
return entityQuery.queryable(DeptEntity.class).toList();
}

@Mapping
@Post
@ApiOperation("新增")
public Boolean add(@Body DeptEntity dept) {
return entityQuery.insertable(dept).executeRows(true) > 0L;
}

@Mapping
@Put
@ApiOperation("更新")
public Boolean update(@Body DeptEntity dept) {
return entityQuery.updatable(dept).executeRows() > 0L;
}

@Mapping("/{id}")
@Get
@ApiOperation("获取")
public DeptEntity get(Long id) {
return entityQuery.queryable(DeptEntity.class).whereById(id).firstOrNull();
}

@Mapping("/{id}")
@Delete
@ApiOperation("删除")
public Boolean delete(Long id) {
return entityQuery
.getEasyQueryClient()
.deletable(DeptEntity.class)
.allowDeleteStatement(true)
.whereById(id)
.executeRows()
> 0L;
}

验证

验证有很多种方式,这里通过 swagger 文档进行调用。浏览器输入 http://localhost:8080/doc.html

创建

img

查询

img

其他的接口可以自行尝试。

小结

通过以上步骤已经,可以实现对 dept 表的基础增删改查了,可以自己继续添加一些功能进行尝试性的修改。Solon 第三方的 orm 插件非常的多,如果不喜欢使用 easy-query,也可以选择其他 orm 框架进行尝试。

完整代码可以从 https://gitee.com/CrazyAirhead/porpoise-demo 获取。

说明

为了简化和统一后续的开发,我们采用多模块的项目结构,引入 BOM 来统一管理依赖。本人并不擅长前端,前端不会过多讲解,主要基于 Sysbean Admin,后续的例子也将以对接 Sysbean Admin的接口作为例子进行讲解。

多模块

多模块的配置,可以参看 https://mp.weixin.qq.com/s/qXaZ7BCL_0-1hkqTGNxFmA。

在 IDEA 中通过 File->New->Modules... 可以创建新的模块。

先创建一个demo01的模块,然后把第一应用的文件移动到 demo01中(第一个应用的玩转例子保留在 start-up 的分支)。

之后再创建demo-parent模块,该模块用于统一管理依赖。

修改settings.gradle

补充说明写在注释中。

1
2
3
4
5
6
// 将demo,修改名称为porpoise-demo
rootProject.name = "porpoise-demo"

// 创建新模版后自动增加的内容可以不用调整
include 'demo01'
include 'demo-parent'

配置 build.gradle

补充说明写在注释中。

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
plugins {
id 'java'
id "io.freefair.lombok" version "8.11"
}

// 统一 jdk 版本号
def jdkVersion = 17
def demoVersion = "1.0.0"
// processor 的依赖需要指定版本号
def mapstructVersion = "1.6.3"
// processor 的依赖需要指定版本号
def easyQueryVersion = "2.4.14"

group = "com.example.demo"
version "${demoVersion}"

allprojects {
repositories {
mavenLocal()
mavenCentral()
// 增加腾讯云 maven 仓库镜像,提速。
maven { url "https://mirrors.cloud.tencent.com/nexus/repository/maven-public/" }
}

// 版本号传递
ext["jdkVersion"] = "${jdkVersion}"
ext["demoVersion"] = "${demoVersion}"
ext["mapstructVersion"] = "${mapstructVersion}"
ext["easyQueryVersion"] = "${easyQueryVersion}"

tasks.withType(JavaCompile).tap {
configureEach {
options.encoding = "UTF-8"
// 统一指定编译时保留参数名
options.compilerArgs << "-parameters" << "-Xlint:unchecked" << "-Xlint:deprecation"
}
}

}

配置 demo-parant

增加dependencies.gradle

用于统一管理版本号。

1
2
3
4
5
6
7
8
ext {
set("solonVersion", "3.0.7")
set("esearchxVersion", "1.0.22")
set("hikaricpVersion", "5.1.0")
set("mysqlVersion", "8.0.28")
set("hutoolVersion", "6.0.0-M18")
set("transmittableThreadLocalVersion", "2.14.2")
}

配置 build.gradle

补充说明写在注释中。

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
plugins {
// 使用 java-platform 可以统一管理 bom
id 'java-platform'
id 'maven-publish'
}

// 引入版本管理文件
apply from: "dependencies.gradle"

group 'com.example.demo'
version "${demoVersion}"

javaPlatform {
allowDependencies()
}

dependencies {
api platform("org.noear:solon-parent:${solonVersion}")

constraints {
api("com.zaxxer:HikariCP:${hikaricpVersion}")
api("mysql:mysql-connector-java:${mysqlVersion}")
api("com.easy-query:sql-solon-plugin:${easyQueryVersion}")
api("org.noear:esearchx:${esearchxVersion}")
api("org.dromara.hutool:hutool-all:${hutoolVersion}")
api("org.mapstruct:mapstruct:${mapstructVersion}")
api("com.alibaba:transmittable-thread-local:${transmittableThreadLocalVersion}")
}
}

publishing {
publications {
//以下maven并非关键字,可自定义
maven(MavenPublication) {
//通过java-platform生成BOM
from components.javaPlatform
}
}

repositories {
// 根据需要发布到不同的仓库,此处只是发布到本地
mavenLocal()
}
}

配置 demo01

配置 build.gradle

补充说明写在注释中。

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
plugins {
id 'java'
id "io.freefair.lombok"
}

group = "com.example"
// 统一版本号
version "${demoVersion}"
description = "Demo project for Solon"

java {
toolchain {
languageVersion = JavaLanguageVersion.of("${jdkVersion}")
}
}

dependencies {
// 使用 demo-parent,依赖调整的最主要的位置
implementation platform(project(":demo-parent"))

implementation("org.noear:solon-web")
implementation("org.noear:solon-view-freemarker")
implementation("org.noear:solon-logging-logback")

// 删除,已经用插件的方式引入了
// compileOnly("org.projectlombok:lombok")
testImplementation("org.noear:solon-test")
}


compileJava {
options.encoding = "UTF-8"
options.compilerArgs << "-parameters"
}

jar {
manifest {
attributes "Main-Class": "com.example.demo.App"
}

dependsOn(configurations.runtimeClasspath)

duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from(configurations.runtimeClasspath.collect {
if (it.isDirectory()) it else zipTree(it)
}) {
exclude 'META-INF/MANIFEST.MF'
exclude 'META-INF/*.SF'
exclude 'META-INF/*.DSA'
exclude 'META-INF/*.RSA'
}

def sourcesMain = sourceSets.main
sourcesMain.allSource.forEach { println("add from sources: ${it.name}") }
from(sourcesMain.output)
}

重新运行demo01

日志中出现如下字样,表示项目结构调整成功,可通过浏览器调用进行测试。

1
App: End loading elapsed=258ms pid=32912 v=3.0.7

img

技术栈

后端

  • JDK 17
  • Gradle 8+
  • Solon 3.0.7
  • Easy-query 2.4.14
  • lombok 8.11
  • Mapstruct 1.6.3
  • Hutool 6.0.0-M18
  • MySQL 8+
  • Redis 6+

前端

具体参看其官方文档,https://docs.soybeanjs.cn/zh/guide/quick-start

  • Node 18.19.0+
  • pnpm 8.7.0+
  • Vue 3
  • Vite 4
  • TypeScript

小结

将项目调整为多模块结构,增加父BOM,并测试 demo01 可以正常运行,为后续项目的开发做好准备。

完整代码可以从 https://gitee.com/CrazyAirhead/porpoise-demo 获取。