开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

盘点认证框架 Pac4j 认证工具

发表于 2021-04-14

总文档 :文章目录

Github : github.com/black-ant

一 . 前言

这一篇来说说 Pac4j , 为什么说他是认证工具呢 ,因为它真的提供了满满的封装类 ,可以让大部分应用快速的集成完成 ,使用者不需要关系认证协议的流程 , 只需要请求和获取用户即可

需要注意的是 , Pac4j 中多个不同的版本其实现差距较大 ,我的源码以 3.8.0 为主 ,分析其思想 , 然后再单独对比一下后续版本的优化 , 就不过多的深入源码细节了

二 . 基础使用

Pac4j 的一大特点就是为不同供应商提供了很完善的 Client , 基本上无需定制就可以实现认证的处理 , 但是这里我们尽量定制一个自己的流程 , 来看看 Pac4j 的一个定制流程是怎样的

以OAuth 为例 :

2.1 构建 Authoriza 请求

我们先构建一个 Client ,用来发起请求 :

OAuth20Configuration : 最原生的 OAuth 配置类 , 可以自行定制符合特定规范的配置类
OAuth20Client : 最原生的客户端调用类 , 后面可以看到 pac4j 有很多定制的client 类

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
java复制代码public class OAuthService extends BasePac4jService {

private Logger logger = LoggerFactory.getLogger(this.getClass());

private final static String CLIENT_ID = "b45e4-41c0-demo";
private final static String CLIENT_SECRET = "0407581-ef15-f773-demo";
private final static String CALLBACK_URL = "http://127.0.0.1:8088/oauth/callback";

/**
* 执行 Authorization 请求
*
* @return
*/
public void doOAuthRequest(HttpServletRequest request, HttpServletResponse response) {

// Step 1 :构建请求 Client
OAuth20Configuration config = new OAuth20Configuration();
config.setApi(new DefaultOAuthAPI());
config.setProfileDefinition(new DefaultOAuthDefinition());
config.setScope("user");
config.setKey(CLIENT_ID);
config.setSecret(CLIENT_SECRET);

// Step 2 : 构建一个 Client
OAuth20Client client = new OAuth20Client();

// 补充完善属性
client.setConfiguration(config);
client.setCallbackUrl(CALLBACK_URL);

// Step 3 : 构建请求 , 这里通过 302 重定向
J2EContext context = new J2EContext(request, response);
client.redirect(context);

// Step 4 : 缓存数据
request.getSession().setAttribute("client", client);

}
}

注意 , 这里有个 DefaultOAuthAPI 和 DefaultOAuthDefinition , 定义的是 SSO 路径和 Profile 声明

DefaultOAuthAPI

DefaultOAuthAPI 中主要包含了请求的地址 , DefaultApi20 有2个抽象接口 , 我额外添加了一个自己的接口

DefaultOAuthAPI 不做任何限制 , 可以把任何需要的接口都放进去 , 用于后续取用.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码public class DefaultOAuthAPI extends DefaultApi20 {

public String getRootEndpoint() {
return "http://127.0.0.1/sso/oauth2.0/";
}

@Override
public String getAccessTokenEndpoint() {
return getRootEndpoint() + "accessToken";
}

@Override
protected String getAuthorizationBaseUrl() {
return getRootEndpoint() + "authorize";
}
}

DefaultOAuthDefinition

该声明相当于一个字典 , 用于翻译 profile 返回的数据

整个类中做了下面这些事 :

  • 定义了 user profile 会返回的属性
  • 定义了各种转换类和映射
  • 定义了 profile 请求的地址
  • 定义了 转换数据的实际实现
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
java复制代码
public class DefaultOAuthDefinition extends OAuth20ProfileDefinition<DefaultOAuhtProfile, OAuth20Configuration> {

public static final String IS_FROM_NEW_LOGIN = "isFromNewLogin";
public static final String AUTHENTICATION_DATE = "authenticationDate";
public static final String AUTHENTICATION_METHOD = "authenticationMethod";
public static final String SUCCESSFUL_AUTHENTICATION_HANDLERS = "successfulAuthenticationHandlers";
public static final String LONG_TERM_AUTHENTICATION_REQUEST_TOKEN_USED = "longTermAuthenticationRequestTokenUsed";

public DefaultOAuthDefinition() {
super(x -> new DefaultOAuhtProfile());
primary(IS_FROM_NEW_LOGIN, Converters.BOOLEAN);
primary(AUTHENTICATION_DATE, new DefaultDateConverter());
primary(AUTHENTICATION_METHOD, Converters.STRING);
primary(SUCCESSFUL_AUTHENTICATION_HANDLERS, Converters.STRING);
primary(LONG_TERM_AUTHENTICATION_REQUEST_TOKEN_USED, Converters.BOOLEAN);
}

@Override
public String getProfileUrl(final OAuth2AccessToken accessToken, final OAuth20Configuration configuration) {
return ((DefaultOAuthAPI) configuration.getApi()).getRootEndpoint() + "/profile";
}

@Override
public DefaultOAuhtProfile extractUserProfile(final String body) {
final DefaultOAuhtProfile profile = newProfile();

// 参数从 attributes 中获取
final String attributesNode = "attributes 中获取";
JsonNode json = JsonHelper.getFirstNode(body);
if (json != null) {
profile.setId(ProfileHelper.sanitizeIdentifier(profile, JsonHelper.getElement(json, "id")));
json = json.get(attributesNode);
if (json != null) {

// 这里以 CAS 的返回做了不同的处理
if (json instanceof ArrayNode) {
final Iterator<JsonNode> nodes = json.iterator();
while (nodes.hasNext()) {
json = nodes.next();
final String attribute = json.fieldNames().next();
convertAndAdd(profile, PROFILE_ATTRIBUTE, attribute, JsonHelper.getElement(json, attribute));
}

} else if (json instanceof ObjectNode) {
final Iterator<String> keys = json.fieldNames();
while (keys.hasNext()) {
final String key = keys.next();
convertAndAdd(profile, PROFILE_ATTRIBUTE, key, JsonHelper.getElement(json, key));
}
}
} else {
raiseProfileExtractionJsonError(body, attributesNode);
}
} else {
raiseProfileExtractionJsonError(body);
}
return profile;
}
}

DefaultDateConverter

该对象用于解析数据 , 例如此处解析时间类型

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
java复制代码
public class DefaultDateConverter extends DateConverter {

public DefaultDateConverter() {
super("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
}

@Override
public Date convert(final Object attribute) {
Object a = attribute;
if (a instanceof String) {
String s = (String) a;
int pos = s.lastIndexOf("[");
if (pos > 0) {
s = s.substring(0, pos);
pos = s.lastIndexOf(":");
if (pos > 0) {
s = s.substring(0, pos) + s.substring(pos + 1, s.length());
}
a = s;
}
}
return super.convert(a);
}
}

DefaultOAuhtProfile

可以理解为一个 TO , 用于接收数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码public class DefaultOAuhtProfile extends OAuth20Profile {

private static final long serialVersionUID = 1347249873352825528L;

public Boolean isFromNewLogin() {
return (Boolean) getAttribute(DefaultOAuthDefinition.IS_FROM_NEW_LOGIN);
}

public Date getAuthenticationDate() {
return (Date) getAttribute(DefaultOAuthDefinition.AUTHENTICATION_DATE);
}

public String getAuthenticationMethod() {
return (String) getAttribute(DefaultOAuthDefinition.AUTHENTICATION_METHOD);
}

public String getSuccessfulAuthenticationHandlers() {
return (String) getAttribute(DefaultOAuthDefinition.SUCCESSFUL_AUTHENTICATION_HANDLERS);
}

public Boolean isLongTermAuthenticationRequestTokenUsed() {
return (Boolean) getAttribute(DefaultOAuthDefinition.LONG_TERM_AUTHENTICATION_REQUEST_TOKEN_USED);
}
}

2.2 构建一个接收对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码    @GetMapping("callback")
public void callBack(final HttpServletRequest request, final HttpServletResponse response) throws IOException {

logger.info("------> [SSO 回调 pac4j OAuth 逻辑] <-------");

// 从 Session 中获取缓存的对象
OAuth20Client client = (OAuth20Client) request.getSession().getAttribute("client");
J2EContext context = new J2EContext(request, response);

// 获取 AccessToken 对应的Credentials
final Credentials credentials = client.getCredentials(context);

// 通过 Profile 获取 Profile
final CommonProfile profile = client.getUserProfile(credentials, context);

// Web 返回数据信息
logger.info("------> Pac4j Demo 获取用户信息 :[{}] <-------", profile.toString());
response.getWriter().println(profile.toString());
}

总结一下就是 :

  • DefaultOAuthAPI : 作为 metadata , 来标识请求的路径
  • DefaultOAuthDefinition : 解释器用于解释返回的含义
  • DefaultDateConverter : 用于转换数据
  • DefaultOAuhtProfile : to 对象用于承载数据

很简单的一个定制 , 可以适配多种不同的 OAuth 供应商

三 . 源码一览

3.1 OAuth 请求篇

3.1.1 Authoriza 流程

Authoriza 中核心类为 IndirectClient , 我们来简单看一下 IndirectClient的逻辑

发起 Authoriza 认证

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
java复制代码C01- IndirectClient
M101- redirect(WebContext context)
?- 之前可以看到 , 我们调用 redirect 发起了请求

M102- getRedirectAction
- 如果请求类型是 ajaxRequest , 则由 ajaxRequestResolver 进行额外处理
-


// M101 伪代码
public final HttpAction redirect(WebContext context) {
RedirectAction action = this.getRedirectAction(context);
return action.perform(context);
}

// M102 伪代码
public RedirectAction getRedirectAction(WebContext context) {
this.init();
if (this.ajaxRequestResolver.isAjax(context)) {
RedirectAction action = this.redirectActionBuilder.redirect(context);
this.cleanRequestedUrl(context);
return this.ajaxRequestResolver.buildAjaxResponse(action.getLocation(), context);
} else {
String attemptedAuth = (String)context.getSessionStore().get(context, this.getName() + "$attemptedAuthentication");
if (CommonHelper.isNotBlank(attemptedAuth)) {
this.cleanAttemptedAuthentication(context);
this.cleanRequestedUrl(context);
throw HttpAction.unauthorized(context);
} else {
return this.redirectActionBuilder.redirect(context);
}
}
}

C02- RedirectActionBuilder (OAuth20RedirectActionBuilder)
M201- redirect
- 生成 state 且放入 session
2- this.configuration.buildService : 构建一个 OAuth20Service
3- 通过 param 属性获取一个 authorizationUrl
- RedirectAction.redirect(authorizationUrl) : 发起认证


// M201 伪代码
public RedirectAction redirect(WebContext context) {

//伪代码 , 通过 generateState 生成 state ,且会放入 session
String state=this.configuration.isWithState()?generateState : null;

// M201-2 : OAuth20Service 是 OAuth 的业务类
OAuth20Service service = (OAuth20Service)this.configuration.buildService(context, this.client, state);
// M201-3 : 设置了认证的地址
String authorizationUrl = service.getAuthorizationUrl(this.configuration.getCustomParams());
return RedirectAction.redirect(authorizationUrl);

}


// 到这里还没有看到实际请求的情况 ,我们再往底层看看

我们回到 M101 方法的 perform 中
C- RedirectAction
M- perform(WebContext context)
- this.type == RedirectAction.RedirectType.REDIRECT ? HttpAction.redirect(context, this.location) : HttpAction.ok(context, this.content);

// 再深入一层 , 真相大白了
public static HttpAction redirect(WebContext context, String url) {
context.setResponseHeader("Location", url);
context.setResponseStatus(302);
return new HttpAction(302);
}

他使用的是302 重定向的状态码 , 由浏览器完成重定向 , 这里的充电关系地址是
http://127.0.0.1/sso/oauth2.0/authorize?response_type=code&client_id=b7a8cc2a-5dec-4a78&redirect_uri=http%3A%2F%2F127.0.0.1%3A9081%2Fmfa-client%2Foauth%2Fcallback%3Fclient_name%3DCasOAuthWrapperClient

补充一 : OAuth20Service

OAuth20Service 是一个 OAuth 业务类 , 其中包含常用的 OAuth 操作

3.1.2 AccessToken 流程

在上文中 ,我们为 OAuth 请求构建了一个 CallBack 方法 , SSO 认证完成后会回调该方法 , 我们来看看其中的一些有趣的点 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码    public void oauthCallBack(final HttpServletRequest request, final HttpServletResponse response) throws IOException {

// 这里可以和之前构建 Context 进行对比
// 当时将多个属性放在了session 中 , 这里就形成了一个循环 , 将状态发了回来
CasOAuthWrapperClient client = (CasOAuthWrapperClient) request.getSession().getAttribute("oauthClient");

// 第二步 : 获取 AccessToken
J2EContext context = new J2EContext(request, response);
final OAuth20Credentials credentials = client.getCredentials(context);

final CommonProfile profile = client.getUserProfile(credentials, context);

response.getWriter().println(profile.toString());


}

来看一看 getCredentials 方法干了什么

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
java复制代码C01- IndirectClient
M103- getCredentials(WebContext context)
- this.init() : OAuth 这一块主要是断言
- CommonHelper.assertNotBlank("key", this.key);
- CommonHelper.assertNotBlank("secret", this.secret);
- CommonHelper.assertNotNull("api", this.api);
- CommonHelper.assertNotNull("hasBeenCancelledFactory", this.hasBeenCancelledFactory);
- CommonHelper.assertNotNull("profileDefinition", this.profileDefinition);




public final C getCredentials(WebContext context) {
this.init();
C credentials = this.retrieveCredentials(context);
if (credentials == null) {
context.getSessionStore().set(context, this.getName() + "$attemptedAuthentication", "true");
} else {
this.cleanAttemptedAuthentication(context);
}

return credentials;
}

// 继续索引 , 可以看到更复杂得
C03- BaseClient
M301- retrieveCredentials
- this.credentialsExtractor.extract(context) : 获取一个 credentials 对象
?- 这个对象是之前 Authoriza 完成后返回的 Code 对象 :PS001
- this.authenticator.validate(credentials, context) : 发起校验
?- 这里是 OAuth20Authenticator , 详见



// 补充 PS001
#OAuth20Credentials# | code: OC-1-wVu2cc3p33ChsQshKd1rUabk6lggPB1QhWh | accessToken: null |

C04- OAuth20Authenticator
M401- retrieveAccessToken
- 从 OAuth20Credentials 获得 code
- 通过 OAuth20Configuration 构建 OAuth20Service , 调用 getAccessToken

// M401 伪代码 : 这里就很清楚了
protected void retrieveAccessToken(WebContext context, OAuthCredentials credentials) {
OAuth20Credentials oAuth20Credentials = (OAuth20Credentials)credentials;
String code = oAuth20Credentials.getCode();
this.logger.debug("code: {}", code);

OAuth2AccessToken accessToken;
try {
accessToken = ((OAuth20Service)((OAuth20Configuration)this.configuration).buildService(context, this.client, (String)null)).getAccessToken(code);
} catch (InterruptedException | ExecutionException | IOException var7) {
throw new HttpCommunicationException("Error getting token:" + var7.getMessage());
}

this.logger.debug("accessToken: {}", accessToken);
oAuth20Credentials.setAccessToken(accessToken);
}


C05- OAuth20Service
M501- getAccessToken
- OAuthRequest request = this.createAccessTokenRequest(code, pkceCodeVerifier);
- this.sendAccessTokenRequestSync(request);
M502- sendAccessTokenRequestSync
- (OAuth2AccessToken)this.api.getAccessTokenExtractor().extract(this.execute(request));
?- 点进去可以发现 ,其底层实际上是一个 HttpClient 调用
- httpClient.execute(userAgent, request.getHeaders(), request.getVerb(), request.getCompleteUrl(),request.getByteArrayPayload());
?- PS002



// PS002 补充 : 参数详见下图
http://127.0.0.1/sso/oauth2.0/accessToken?

PS002

image.png

3.1.3 UserInfo

看了 AccessToken 的获取 , 再看看怎么换取 Userinfo

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
java复制代码
// Step 1 :起点方法 
final CommonProfile profile = client.getUserProfile(credentials, context);

C03- BaseClient
M302- getUserProfile
- U profile = retrieveUserProfile(credentials, context);
M303- retrieveUserProfile
- this.profileCreator.create(credentials, context);
?- 这里是 OAuth20ProfileCreator : M601

C06- OAuthProfileCreator
M601- create
- T token = this.getAccessToken(credentials) : 获取了 Token
- return this.retrieveUserProfileFromToken(context, token);
M602- retrieveUserProfileFromToken
- final OAuthProfileDefinition<U, T, O> profileDefinition = configuration.getProfileDefinition();
?- OAuthProfileDefinition 用于构建请求 , 包括发送的类型等
- final String profileUrl = profileDefinition.getProfileUrl(accessToken, configuration);
?- profile 地址
- final S service = this.configuration.buildService(context, client, null);
?- 构建一个 Service
- final String body = sendRequestForData(service, accessToken, profileUrl, profileDefinition.getProfileVerb());
?- 请求 Profile , 这里实际上就已经调用拿到数据了
- final U profile = (U) configuration.getProfileDefinition().extractUserProfile(body);
?- 解析成 Profile 对象
- addAccessTokenToProfile(profile, accessToken);
?- 构建最后的对象

image.png

3.2 SAML 篇

3.2.1 发起请求

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码// Step 1 : 发起请求
- 构建一个 Configuration
- 构建一个 Client
- 因为 saml 的 API 都在 metadata 中 , 所以这里没有注入 API 的需求

--> 发起调用
RedirectAction action = client.getRedirectAction(context);
action.perform(context);

- return redirectActionBuilder.redirect(context);
?- 一样的套路 , 这里的 builder 是 SAML2RedirectActionBuilder

// 最后还是一样构建了一个 SAML 的 302 请求

看一下请求的结果

image-20210414115606499.png

3.2.2 接收数据

后面仍然是一模一样的 , 只不过 Authenticator 变成了 SAML2Authenticator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码
final SAML2Client client = (SAML2Client) request.getSession().getAttribute("samlclient");

// 获取 J2EContext 对象
J2EContext context=new J2EContext(request,response);
final SAML2Credentials credentials = client.getCredentials(context);

// 获取 profile 数据
final CommonProfile profile = client.getUserProfile(credentials, context);
response.getWriter().println(profile.toString());

C- SAML2Authenticator
M- validate
- final SAML2Profile profile = getProfileDefinition().newProfile();
?- 获取返回的 Profile
- profile.addAuthenticationAttribute(SESSION_INDEX, credentials.getSessionIndex());
- profile.addAuthenticationAttribute(SAML_NAME_ID_FORMAT, nameId.getFormat());
- profile.addAuthenticationAttribute(SAML_NAME_ID_NAME_QUALIFIER, nameId.getNameQualifier());
- profile.addAuthenticationAttribute(SAML_NAME_ID_SP_NAME_QUALIFIER, nameId.getSpNameQualifier());
- profile.addAuthenticationAttribute(SAML_NAME_ID_SP_PROVIDED_ID, nameId.getSpProviderId());
?- 配置相关属性


// final CommonProfile profile = client.getUserProfile(credentials, context);

saml0011.jpg

四 . 深入分析

Pac4j 是一个很好的开源项目 , 从流程上讲 , 他拥有非常好的扩展性 (PS: 个人写产品很喜欢扩展性 , 什么都可配) , 这在开源里面是一个很大的优势 , 它的整体流程基本上可以看成以下几个部分

Configuration 体系

saml0012.jpg

Client 体系

IndirectClient.png

Credentials 体系

Credentials.png

Profile 体系

UserProfile.png

在这么多体系的情况下 ,通过 Context 完成整体容器的协调 , 在通过 RedirectAction 做统一的 请求重定向 .

为什么专门提 RedirectAction 呢 ?

因为我认为 pac4j 把所有的请求都抽象成了2个部分 , 一个是发起认证 , 一个的 callback 返回 ,
以这2个直观的操作为边界 , 再在其中加入认证信息获取等操作 , 用户基本上对请求的调用是不可见的.

DemoSSO配置 Configuration构建 Demo client发起 OAuth 认证返回认证信息Definition 解析返回 , 拿到一个 AccessToken Credentials通过 token 发起信息获取返回用户信息解析为 ProfileDemoSSO
五 . 开源分析


那么 , 从 pac4j 里面 , 能学到哪些优点呢?

首先 , pac4j 的定位是什么?

pac4j 是一个认证工具 , 或者说 SDK , 他解决了认证过程的复杂性 , 使用者进行简单的调用就可以直接拿到用户信息.

而他的第一个优点 , 就是兼容性和可配置性 , 我提供了了这么多 client , 你可以省事直接调 , 也可以自己定制 ,都没关系.


从代码结构上说 , pac4j 的第二个优点就是结构清晰 .

我们从上面的分析中 , 就能感觉到 , 能做什么 , 怎么做 , 怎么命名 ,其实都规约好了 , 进行简单的实现就可以满足要求.


而我认为第三个优点 , 就是耦合性低.

pac4j 采用的使 maven 聚合方式 , 想实现什么协议 , 就只引用相关包即可 . 代码与代码间的依赖度也低 , 这同样对定制有很大的好处, 值得学习.

六. 总结

pac4j 这工具 , 如果为了快速集成上线 , 确实是一个很理想的工具 ,

个人在写demo 的时候 , 也经常用他做基础测试 , 别说 , 真挺好用

代码已经发在 git 上面 , case 4.6.2 , 可以直接看.

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

jar包构建成镜像运行在docker容器中

发表于 2021-04-14

演示环境

CentOS 7.2

Docker 1.13.1

Spring Boot 2.1.1.RELEASE

准备一个Spring Boot项目

构建一个 Spring Boot项目的方式有很多,也不是我们讨论的重点,这里就不多做描述了。我们在项目中准备一个简单的HelloController,其中有一个sayHello()方法映射到我们访问项目的跟路径,访问时返回:Hello Jerome, This is docker image !。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码package top.nikk.demo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

@GetMapping("")
public String sayHello() {
return "Hello Felix, This is docker image !";
}
}

对项目打包,生成可执行jar

我们到项目的根目录,运行mvn clean package对项目进行打包,运行之后会在target目录下生成一个xxx.jar的可执行jar文件。

编写Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
text复制代码# Docker image for springboot file run
# VERSION 1.0.0
# Author: Felix
# 基础镜像使用java
FROM java:8
# 作者
MAINTAINER Felix<Felix5200@163.com>
# VOLUME 指定了临时文件目录为/tmp。
# 其效果是在主机 /var/lib/docker 目录下创建了一个临时文件,并链接到容器的/tmp
VOLUME /tmp
# 将jar包添加到容器中并更名为app.jar
ADD felix-admin.jar app.jar
# 运行jar包
RUN bash -c 'touch /app.jar'
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]

上面命令的相关解释:

从docker仓库获取openjdk作为我们项目的容器
VOLUME指向了一个/tmp的目录,由于Spring Boot使用内置的Tomcat容器,Tomcat默认使用/tmp作为工作目录。效果就是在主机的/usr/local/apps目录下创建了一个临时文件,并连接到容器的/tmp。
项目的docker-demo-0.0.1.jar作为app.jar添加到容器。
ENTRYPOINT 执行项目 app.jar。为了缩短 Tomcat 启动时间,添加一个系统属性指向/dev/urandom 作为 Entropy Source
from java:8 拉取一个jdk为1.8的docker image

  1. maintainer 作者是Felix
  2. felix-admin.jar 就是你上传的jar包,修改名称为app.jar包
  3. app.jar 是你将该jar包重新命名的名称,在容器中运行
  4. expose 该容器暴露的端口是多少,就是jar在容器中以多少端口运行
  5. entrypoint 容器启动之后执行的命令,java -jar demo.jar 即启动jar

构建Docker镜像

我们进入到/usr/local/apps目录下,执行Docker的命令来构建镜像

1
linux复制代码docker build -t top.nikk/docker-demo:1.0.0 .

注意此处最后有一个 . 的命令

参数说明:

1
2
3
diff复制代码-t :指定要创建的目标镜像名

. :Dockerfile 文件所在目录,可以指定Dockerfile 的绝对路径

这个命令就是使用Docker的build命令来构建镜像,并给镜像起了一个名字top.nikk/docker-demo其TAG为1.0.0在当前文件夹下。

我们使用docker images来查看所有的镜像。

之后执行

1
linux复制代码docker run -id -p 8080:8080 --name demo  top.nikk/docker-demo:1.0.0

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Nodejs结合wechaty实现个人微信机器人助手

发表于 2021-04-14

需求

自己一直想做一个个人的微信机器人,曾经目睹一位大佬用自己个人微信实现语音控制机器人搭建后台系统,羡慕不已的同时,自己也暗自下定决心做一个自己的微信机器人,发现wechaty的时候,似乎看到了希望,并不是超越谁而是实现目前所可以做到的。

搭建自己的微信公众号,jd转链接,图文翻译,智能对话,个人收藏夹目前就是这几个,可是我发现对于小程序是不可以发送到自己的订阅号的,还有就是jd链接我每次都会重复的打开微信公众号,找到自己订阅号后发送似乎繁琐,所以我决定自己搞一个个人机器人解决以上的痛点。

惊喜!!!放在前面

在我发布文章这一天,下午打开wechaty博客的时候发现

重磅:绕过登录限制,wechaty免费版web协议重新荣光
这篇文章,可以完美实现无需token即可实现微信登录

效果及代码
你的微信会在桌面微信登录,完美绕过不可以web登录微信的痛点,也可以正常使用你的其他功能

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
javascript复制代码const { Wechaty } = require("wechaty");
const Qrterminal = require("qrcode-terminal");

const name = "wechat-puppet-wechat";
let bot = "";
bot = new Wechaty({
name, // generate xxxx.memory-card.json and save login data for the next login
});

// 二维码生成
function onScan(qrcode, status) {
Qrterminal.generate(qrcode); // 在console端显示二维码
const qrcodeImageUrl = [
"https://wechaty.js.org/qrcode/",
encodeURIComponent(qrcode),
].join("");
console.log(qrcodeImageUrl);
}

// 登录
async function onLogin(user) {
console.log(`贴心小助理${user}登录了`);
if (config.AUTOREPLY) {
console.log(`已开启机器人自动聊天模式`);
}
}

//登出
function onLogout(user) {
console.log(`小助手${user} 已经登出`);
}

bot.on("scan", onScan);
bot.on("login", onLogin);
bot.on("logout", onLogout);
bot.on("message", function(message){
console.log(message);
}) // 消息监听
bot
.start()
.then(() => console.log("开始登陆微信"))
.catch((e) => console.error(e));

微信图片_20210414142032.png

介绍

Wechaty

Wechaty是一款适用于Chatbot Makers的现代会话 RPA SDK,可以用几行代码创建一个bot。

Wechaty提供了开箱即用的支持,可以将您的IM帐户转变为聊天机器人,从而为您提供期望的通用功能,开发人员可以轻松地对其进行自定义和扩展,以创建满足其确切需求的聊天机器人。

世界上最短的聊天机器人#

我们可以使用Wechaty用最少6行代码构建一个聊天机器人

1
2
3
4
5
6
7
8
9
10
11
12
13
javascript复制代码const { Wechaty } = require('wechaty')

async function main () {
conswt bot = new Wechaty()
bot
.on('scan', (qrcode, status) => console.log(`Scan QR Code to login: ${status}\nhttps://wechaty.js.org/qrcode/${encodeURIComponent(qrcode)}`))
.on('login', user => console.log(`User ${user} logged in`))
.on('message', message => console.log(`Message: ${message}`))
await bot.start()
}

main()
.catch(console.error)

运行代码之前

准备好可运行Wechaty的微信号,已经申请前缀为puppet_padlocal的token

  • wechaty-puppet-padlocal:wechaty的ipad协议实现

(每个人申请的token是不同的所以使用的协议也不同,需要在入口做处理)

目前实现功能

  • 自动通过好友验证
    • 当有人添加机器人时,判断验证消息关键字后通过或直接通过
    • 通过验证后自动回复并介绍机器人功能
  • 私聊关键字回复
    • 例如回复 加群 推送群聊邀请
    • 例如回复 群聊名称 自动拉群
  • 自动聊天
    • 群聊中配置和自己的机器人聊天
    • 私聊发送消息即可聊天
  • 解析小程序信息
  • 加入群聊自动欢迎
    • 当新的小伙伴加入群聊后自动 @[新的小伙伴] 发一个文字欢迎
    • 关键字触发,发送个人卡片链接
    • 群内发送小程序可获取小程序相关信息
    • 群内发送英文开启翻译功能,最多不可超多2000字

api接口

有道翻译
百度翻译

天行api

核心代码

入口文件

bot.js

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
javascript复制代码const { Wechaty } = require("wechaty");
const { PuppetPadlocal } = require("wechaty-puppet-padlocal"); // 引入协议包
const QrcodeTerminal = require("qrcode-terminal"); // 控制台二维码
const onMessage = require("./onMessage"); // 消息监听回调
const config = require("./config"); // 配置文件


// 实例化机器人
const bot = new Wechaty({
puppet: new PuppetPadlocal({
token: config.token,
}),
});

// 添加监听事件
bot
// 扫码登录
.on("scan", (qrcode, status) => {
console.log(
`Scan QR Code to login: ${status}\nhttps://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(
qrcode
)}`
);
QrcodeTerminal.generate(qrcode);
})
// 登录监听
.on("login", (user) => {
console.log(user, "logined");
})
// 退出监听
.on("logout", (user) => {
console.log(user, "logout");
})
// 消息监听
.on("message", onMessage(bot))
.start();

onMessage.js

监听消息事件触发,这里只判断了接收的主要类型,文字type7,小程序type9,卡片链接type14,使用群聊@的时候你可能会遇到发送不成功,你可以使用另外的一种方法如果你也遇到room.say失效,然后我在获取文本信息处理的时候加了一层判断,因为消息监听默认也会监听到你所有群聊的消息,我测试的时候就遇到机器人主动和群里的人聊天,很尴尬所以拉了几个好友组了一个测试群方便测试

1
2
3
4
5
ini复制代码 // 获取@你的群友
let member = msg.talker();
// 群聊@群友回复
let response = ‘回复给群友的内容’;
msg.room().say(response,member);
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
javascript复制代码const { UrlLink } = require("wechaty");
const request = require("request");
const urlencode = require("urlencode");
const config = require("./config");
const miniProrReply = require("../utils/miniProReply"); // 小程序参数
const translate = require("../utils/translate"); // 百度翻译
const { rainbowFart, circle ,drugInstruction} = require("../utils/txAPI"); // 天行api
const name = config.name;
const roomList = config.room.roomList;

// 消息监听回调
module.exports = (bot) => {
return async function onMessage(msg) {
// 判断消息来自自己,直接return
if (msg.self()) return;
// 消息类型判断
switch (msg.type()) {
case 7:
var reg = /^[\u4e00-\u9fa5]+$/; // 文字正则
console.log("获取到文本");
// 回复信息是关键字 “加群” 测试成功
if (await isAddRoom(msg)) return;
// 回复信息是所管理的群聊名 测试成功
if (await isRoomName(bot, msg)) return;
// 开启机器人
if (msg.payload.roomId) {
// 添加判断 不是指定群聊的信息不触发 [不加判断机器人,机器人会回复任意所在群聊内容。。。]
console.log("获取到群聊消息");
if (msg.payload.roomId === "20856899751@chatroom") {
// 判断群内回复内容不为文字
if(!reg.test(msg.text())){
translate(msg);
return;
} else{
roomMessageReply(msg);
return;
}
}
}
break;
case 9:
console.log("获取到小程序"); // 测试成功
miniProrReply(msg);
break;
case 14:
console.log("获取到卡片链接"); // 测试成功
break;
default:
console.log("暂时不支持该类型的接收!");
break;
}
};
};

/**
* @description 回复群聊信息 处理函数
* @param {Object} msg 消息对象
* @return {Promise} true-是 false-不是
*/
async function roomMessageReply(msg) {
const room = msg.room();
const text = msg.text();
if (await msg.mentionSelf()) {
// 获取消息内容,拿到整个消息文本,去掉 @名字
const sendText = msg.text().replace("@蔚蓝", "");
// 请求机器人接口回复
let res = await requestRobot(sendText);
// 返回消息,并@来自人 只可以padplus调用
// room.say(res, msg.fromId());
// 此处替换为群内回话
// padlocal使用 msg函数@指定人
// 获取@你的群友
// let member = msg.talker();
// 群聊@群友回复
// msg.room().say(response,member);
room.say(res);
} else {
let content = await requestRobot(msg.text());
room.say(content);
return
}

// 指定关键字触发
if (/互动/.test(text)) {
room.say("互动测试");
return
} else if(/彩虹屁/.test(text)){
let reply = await rainbowFart(params);
room.say(reply);
return
} else if(/文案/.test(text)){
let reply = await circle(params);
room.say(reply);
return
}else if (/蔚蓝工作室/.test(text)) {
room.say(new UrlLink(config.personal.introUrl));
return
} else if(/药品/.test(text)){
params.word = text.substring(2);
console.log(params);
let reply = await drugInstruction(params);
room.say(reply);
return
}
}

/**
* @description 回复信息是关键字 “加群” 处理函数
* @param {Object} msg 消息对象
* @return {Promise} true-是 false-不是
*/
async function isAddRoom(msg) {
// 关键字 加群 处理
if (msg.text() == "加群") {
let roomListName = Object.keys(roomList);
let info = `${name}当前管理群聊有${roomListName.length}个,回复群聊名即可加入哦\n\n`;
roomListName.map((v) => {
info += "【" + v + "】" + "\n";
});
msg.say(info);
return true;
}
return false;
}

/**
* @description 回复信息是所管理的群聊名 处理函数
* @param {Object} bot 实例对象
* @param {Object} msg 消息对象
* @return {Promise} true-是群聊 false-不是群聊
*/
async function isRoomName(bot, msg) {
// 回复信息为管理的群聊名
if (Object.keys(roomList).some((v) => v == msg.text())) {
// 通过群聊id获取到该群聊实例
const room = await bot.Room.find({ id: roomList[msg.text()] });
// 获取当前room信息
console.log(room);
// 判断是否在房间中 在-提示并结束
if (await room.has(msg.from())) {
await msg.say("您已经在房间中了");
return true;
}

// 发送群邀请
await room.add(msg.from());
await msg.say("已发送群邀请");
return true;
}
return false;
}

/**
* @description 机器人请求接口 处理函数
* @param {String} info 发送文字
* @return {Promise} 相应内容
*/
function requestRobot(info) {
return new Promise((resolve, reject) => {
let url = `https://open.drea.cc/bbsapi/chat/get?keyWord=${urlencode(info)}`;
request(url, (error, response, body) => {
if (!error && response.statusCode == 200) {
let res = JSON.parse(body);
if (res.isSuccess) {
let send = res.data.reply;
// 免费的接口,所以需要把机器人名字替换成为自己设置的机器人名字
send = send.replace(/Smile/g, name);
resolve(send);
} else {
if (res.code == 1010) {
resolve("没事别老艾特我,我还以为爱情来了");
} else {
resolve("你在说什么,我听不懂");
}
}
} else {
resolve("你在说什么,我脑子有点短路诶!");
}
});
});
}

utils文件夹

主要存放的是一些onmessage的方法,api请求以及小程序通过xml-js提取参数类的,这部分相对简单一些,此处不再赘述

百度翻译 使用请到utils-translate文件替换appid以及key

天行api 使用请到src-config文件更换key保证正常使用

效果

4de6c65e83c4624b6ea90fe791cd3b1.jpg

ad0cf4c464a0ea0e2cbe74c7d6fb1fe.jpg

8e14eaa78b5a28570231048aee0060a.jpg

274fb61aa0bf250fdac31b81acc6922.jpg

常见问题

github wechaty-puppet-padplus升级说明如下:
卸载wechaty-puppet-padplus,安装wechaty-puppet-hostie
代码中启动wechaty的wechaty-puppet-padplus更换成【wechaty-puppet-hostie】

npm官网 Wechaty-Puppet-Hostie模块已重命名为wechaty-puppet-service,请改用【wechaty-puppet-service】
使用wechaty-puppet-service代替Wechaty-Puppet-Hostie

协议使用服务兼容性

免费token

padlocal 7天免费(推荐)

最后

探索token和对应协议的过程有点痛苦,不过我已经搭建好了,我会给你提供最简单的搭建个人机器人的方式,一起交流学习

项目github

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

推荐一个开源文件管理系统,简单好用

发表于 2021-04-14

  大家好,我是为广大程序员兄弟操碎了心的小编,每天推荐一个小工具/源码,装满你的收藏夹,每天分享一个小技巧,让你轻松节省开发效率,实现不加班不熬夜不掉头发,是我的目标!

  今天小编推荐一个开源文件管理系统:基于 SpringBoot2.x + MyBatisPlus + MySQL + Shiro+ Layui 等搭配七牛云,阿里云OSS实现的云存储管理系统。包含文件上传、删除、预览、云资源列表查询、下载、文件移动、重命名、目录管理、登录、注册、以及权限控制等功能。

开源协议

  使用 Apache-2.0 开源许可协议

链接地址

  公众号【Github导航站】回复关键词【ffs】获取git地址

技术栈

后端:

  • Spring Boot 2.4.3
  • orm: MyBatis Plus
  • 数据库:MySQL 5.7
  • 权限安全控制:shiro
  • 本地缓存:ehcache
  • 文件上传:七牛云 阿里云OSS

前端:

  • Thymeleaf
  • Layui v2.5.5
  • Jquery
  • Ajax

演示截图

登录页

注册页

主页


资源操作

目录管理

重命名

添加文件夹

移动文件或目录

删除文件

结尾

  本期就分享到这里,我是小编南风吹,专注分享好玩有趣、新奇、实用的开源项目及开发者工具、学习资源!希望能与大家共同学习交流,欢迎关注我的公众号**【Github导航站】**。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

基于SpringBoot实现让日志像诗一样有韵律(日志追踪)

发表于 2021-04-14

前言

在传统系统中,如果能够提供日志输出,基本上已经能够满足需求的。但一旦将系统拆分成两套及以上的系统,再加上负载均衡等,调用链路就变得复杂起来。

特别是进一步向微服务方向演化,如果没有日志的合理规划、链路追踪,那么排查日志将变得异常困难。

比如系统A、B、C,调用链路为A -> B -> C,如果每套服务都是双活,则调用路径有2的三次方种可能性。如果系统更多,服务更多,调用链路则会成指数增长。

因此,无论是几个简单的内部服务调用,还是复杂的微服务系统,都需要通过一个机制来实现日志的链路追踪。让你系统的日志输出,像诗一样有形式美,又有和谐的韵律。

日志追踪其实已经有很多现成的框架了,比如Sleuth、Zipkin等组件。但这不是我们要讲的重点,本文重点基于Spring Boot、LogBack来手写实现一个简单的日志调用链路追踪功能。基于此实现模式,大家可以更细粒度的去实现。

Spring Boot中集成Logback

Spring Boot本身就内置了日志功能,这里使用logback日志框架,并对输出结果进行格式化。先来看一下SpringBoot对Logback的内置集成,依赖关系如下。当项目中引入了:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

spring-boot-starter-web中间接引入了:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

spring-boot-starter又引入了logging的starter:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>

在logging中真正引入了所需的logback包:

1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-to-slf4j</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId>
</dependency>

因此,我们使用时,只需将logback-spring.xml配置文件放在resources目录下即可。理论上配置文件命名为logback.xml也是支持的,但Spring Boot官网推荐使用的名称为:logback-spring.xml。

然后,在logback-spring.xml中进行日志输出的配置即可。这里不贴全部代码了,只贴出来相关日志输出格式部分,以控制台输出为例:

1
<property name="CONSOLE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %contextName [%thread] %-5level [%X{requestId}] %logger{36} - %msg%n"/>

在value属性的表达式中,我们新增了自定义的变量值requestId,通过“[%X{requestId}]”的形式来展示。

这个requestId便是我们用来追踪日志的唯一标识。如果一个请求,从头到尾都使用了同一个requestId便可以把整个请求链路串联起来。如果系统还基于EKL等日志搜集工具进行统一收集,就可以更方便的查看整个日志的调用链路了。

那么,这个requestId变量是如何来的,又存储在何处呢?要了解这个,我们要先来了解一下日志框架提供的MDC功能。

什么是MDC?

MDC(Mapped Diagnostic Contexts) 是一个线程安全的存放诊断日志的容器。MDC是slf4j提供的适配其他具体日志实现包的工具类,目前只有logback和log4j支持此功能。

MDC是线程独立、线程安全的,通常无论是HTTP还是RPC请求,都是在各自独立的线程中完成的,这与MDC的机制可以很好地契合。

在使用MDC功能时,我们主要使用是put方法,该方法间接的调用了MDCAdapter接口的put方法。

看一下接口MDCAdapter其中一个实现类BasicMDCAdapter中的代码来:

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
public class BasicMDCAdapter implements MDCAdapter {

private InheritableThreadLocal<Map<String, String>> inheritableThreadLocal = new InheritableThreadLocal<Map<String, String>>() {
@Override
protected Map<String, String> childValue(Map<String, String> parentValue) {
if (parentValue == null) {
return null;
}
return new HashMap<String, String>(parentValue);
}
};

public void put(String key, String val) {
if (key == null) {
throw new IllegalArgumentException("key cannot be null");
}
Map<String, String> map = inheritableThreadLocal.get();
if (map == null) {
map = new HashMap<String, String>();
inheritableThreadLocal.set(map);
}
map.put(key, val);
}
// ...
}

通过源码可以看出内部持有一个InheritableThreadLocal的实例,该实例中通过HashMap来保存context数据。

此外,MDC提供了put/get/clear等几个核心接口,用于操作ThreadLocal中存储的数据。而在logback.xml中,可在layout中通过声明“%X{requestId}”这种形式来获得MDC中存储的数据,并进行打印此信息。

基于MDC的这些特性,因此它经常被用来做日志链路跟踪、动态配置用户自定义信息(比如requestId、sessionId等)等场景。

实战使用

上面了解了一些基础的原理知识,下面我们就来看看如何基于日志框架的MDC功能实现日志的追踪。

工具类准备

首先定义一些工具类,这个强烈建议大家将一些操作通过工具类的形式进行实现,这是写出优雅代码的一部分,也避免后期修改时每个地方都需要改。

TraceID(我们定义参数名为requestId)的生成类,这里采用UUID进行生成,当然可根据你的场景和需要,通过其他方式进行生成。

1
2
3
4
5
6
7
8
9
10
11
public class TraceIdUtils {

/**
* 生成traceId
*
* @return TraceId 基于UUID
*/
public static String getTraceId() {
return UUID.randomUUID().toString().replace("-", "");
}
}

对Context内容的操作工具类TraceIdContext:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class TraceIdContext {

public static final String TRACE_ID_KEY = "requestId";

public static void setTraceId(String traceId) {
if (StringLocalUtil.isNotEmpty(traceId)) {
MDC.put(TRACE_ID_KEY, traceId);
}
}

public static String getTraceId() {
String traceId = MDC.get(TRACE_ID_KEY);
return traceId == null ? "" : traceId;
}

public static void removeTraceId() {
MDC.remove(TRACE_ID_KEY);
}

public static void clearTraceId() {
MDC.clear();
}

}

通过工具类,方便所有服务统一使用,比如requestId可以统一定义,避免每处都不一样。这里不仅提供了set方法,还提供了移除和清理的方法。

需要注意的是,MDC.clear()方法的使用。如果所有的线程都是通过new Thread方法建立的,线程消亡之后,存储的数据也随之消亡,这倒没什么。但如果采用的是线程池的情况时,线程是可以被重复利用的,如果之前线程的MDC内容没有清除掉,再次从线程池中获取这个线程,会取出之前的数据(脏数据),会导致一些不可预期的错误,所以当前线程结束后一定要清掉。

Filter拦截

既然我们要实现日志链路的追踪,最直观的思路就是在访问的源头生成一个请求ID,然后一路传下去,直到这个请求完成。这里以Http为例,通过Filter来拦截请求,并将数据通过Http的Header来存储和传递数据。涉及到系统之间调用时,调用方设置requestId到Header中,被调用方从Header中取即可。

Filter的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class TraceIdRequestLoggingFilter extends AbstractRequestLoggingFilter {

@Override
protected void beforeRequest(HttpServletRequest request, String message) {
String requestId = request.getHeader(TraceIdContext.TRACE_ID_KEY);
if (StringLocalUtil.isNotEmpty(requestId)) {
TraceIdContext.setTraceId(requestId);
} else {
TraceIdContext.setTraceId(TraceIdUtils.getTraceId());
}
}

@Override
protected void afterRequest(HttpServletRequest request, String message) {
TraceIdContext.removeTraceId();
}
}

在beforeRequest方法中,从Header中获取requestId,如果获取不到则视为“源头”,生成一个requestId,设置到MDC当中。当这个请求完成时,将设置的requestId移除,防止上面说到的线程池问题。系统中每个服务都可以通过上述方式实现,整个请求链路就串起来了。

当然,上面定义的Filter是需要进行初始化的,在Spring Boot中实例化方法如下:

1
2
3
4
5
6
7
8
@Configuration
public class TraceIdConfig {

@Bean
public TraceIdRequestLoggingFilter traceIdRequestLoggingFilter() {
return new TraceIdRequestLoggingFilter();
}
}

针对普通的系统调用,上述方式基本上已经能满足了,实践中可根据自己的需要在此基础上进行扩展。这里使用的是Filter,也可以通过拦截器、Spring的AOP等方式进行实现。

微服务中的Feign

如果你的系统是基于Spring Cloud中的Feign组件进行调用,则可通过实现RequestInterceptor拦截器来达到添加requestId效果。具体实现如下:

1
2
3
4
5
6
7
8
9
@Configuration
public class FeignConfig implements RequestInterceptor {

@Override
public void apply(RequestTemplate requestTemplate) {
requestTemplate.header(TraceIdContext.TRACE_ID_KEY, TraceIdContext.getTraceId());
}

}

结果验证

当完成上述操作之后,对一个Controller进行请求,会打印如下的日志:

1
2
2021-04-13 10:58:31.092 cloud-sevice-consumer-demo [http-nio-7199-exec-1] INFO  [ef76526ca96242bc8e646cdef3ab31e6] c.b.demo.controller.CityController - getCity
2021-04-13 10:58:31.185 cloud-sevice-consumer-demo [http-nio-7199-exec-1] WARN [ef76526ca96242bc8e646cdef3ab31e6] o.s.c.o.l.FeignBlockingLoadBalancerClient -

可以看到requestID已经被成功添加。当我们排查日志时,只需找到请求的关键信息,然后根据关键信息日志中的requestId值就可以把整个日志串联起来。

小结

最后,我们来回顾一下日志追踪的整个过程:当请求到达第一个服务器,服务检查requestId是否存在,如果不存在,则创建一个,放入MDC当中;服务调用其他服务时,再通过Header将requestId进行传递;而每个服务的logback配置requestId的输出。从而达到从头到尾将日志串联的效果。

在学习本文,如果你只学到了日志追踪,那是一种损失,因为文中还涉及到了SpringBoot对logback的集成、MDC的底层实现及坑、过滤器的使用、Feign的请求拦截器等。如果感兴趣,每个都可以发散一下,学习到更多的知识点。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

万字长文 - Kotlin 协程进阶 前言 Kotlin C

发表于 2021-04-13

前言

本篇文章是作为我的一个学习记录,写成文章也是为了更好的加深记忆和理解,也是为了分享知识。本文的定位是协程的稍微深入的全面知识,也会示例一些简单的使用,这里不对suspend讲解,因为有更好的博文,下文中给出了链接,也不对协程的高级用法做阐述(热数据通道Channel、冷数据流Flow...),本文主要讲协程稍微深入的全面知识。

Kotlin Coroutine 简介

Kotlin 中的协程提供了一种全新处理并发的方式,您可以在 Android 平台上使用它来简化异步执行的代码。协程是从 Kotlin 1.3 版本开始引入,但这一概念在编程世界诞生的黎明之际就有了,最早使用协程的编程语言可以追溯到 1967 年的 Simula 语言。

  在过去几年间,协程这个概念发展势头迅猛,现已经被诸多主流编程语言采用,比如 Javascript、C#、Python、Ruby 以及 Go 等。Kotlin 的协程是基于来自其他语言的既定概念。

  在 Android 平台上,协程主要用来解决两个问题:

  • 处理耗时任务 (Long running tasks),这种任务常常会阻塞住主线程;
  • 保证主线程安全 (Main-safety) ,即确保安全地从主线程调用任何 suspend 函数。

Kotlin Coroutine Version

Kotlin Version: 1.4.32

Coroutine Version: 1.4.3

Kotlin Coroutine 生态

协程生态.png

kotlin的协程实现分为了两个层次:

  • 基础设施层:

标准库的协程API,主要对协程提供了概念和语义上最基本的支持

  • 业务框架层 kotlin.coroutines:

协程的上层框架支持,也是我们日常开发使用的库

接入Coroutine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
groovy复制代码dependencies {
// Kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.4.32"

// 协程核心库
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3"
// 协程Android支持库
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3"
// 协程Java8支持库
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.4.3"

// lifecycle对于协程的扩展封装
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
}

Coroutine 基本使用

suspend

关于suspend挂起函数,这里不展开去讲,因为有更好的博文,那当然是扔物线凯哥的博文,最初我也是跟随凯哥的视频去学习的写成,大家可以去扔物线的网站去学习下协程的 suspend 或其他关于协程的知识,下面放上链接:

扔物线 - Kotlin 协程的挂起好神奇好难懂?今天我把它的皮给扒了

创建协程

创建协程的方式有很多种,这里不延伸协程的高级用法(热数据通道Channel、冷数据流Flow...),也许以后会在文章里补充或者新写文章来专门讲解,创建协程这里介绍常用的两种方式:

  • CoroutineScope.launch()
  • CoroutineScope.async()

  这是常用的协程创建方式,launch 构建器适合执行 “一劳永逸” 的工作,意思就是说它可以启动新协程而不将结果返回给调用方;async 构建器可启动新协程并允许您使用一个名为 await 的挂起函数返回 result。 launch 和 async 之间的很大差异是它们对异常的处理方式不同。如果使用 async 作为最外层协程的开启方式,它期望最终是通过调用 await 来获取结果 (或者异常),所以默认情况下它不会抛出异常。这意味着如果使用 async 启动新的最外层协程,而不使用await,它会静默地将异常丢弃。

CoroutineScope.launch()

直接上代码:

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
kotlin复制代码import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import kotlinx.coroutines.*

class MainActivity : AppCompatActivity() {

/**
* 使用官方库的 MainScope()获取一个协程作用域用于创建协程
*/
private val mScope = MainScope()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

// 创建一个默认参数的协程,其默认的调度模式为Main 也就是说该协程的线程环境是Main线程
val job1 = mScope.launch {
// 这里就是协程体

// 延迟1000毫秒 delay是一个挂起函数
// 在这1000毫秒内该协程所处的线程不会阻塞
// 协程将线程的执行权交出去,该线程该干嘛干嘛,到时间后会恢复至此继续向下执行
delay(1000)
}

// 创建一个指定了调度模式的协程,该协程的运行线程为IO线程
val job2 = mScope.launch(Dispatchers.IO) {

// 此处是IO线程模式

// 切线程 将协程所处的线程环境切至指定的调度模式Main
withContext(Dispatchers.Main) {
// 现在这里就是Main线程了 可以在此进行UI操作了
}
}

// 下面直接看一个例子: 从网络中获取数据 并更新UI
// 该例子不会阻塞主线程
mScope.launch(Dispatchers.IO) {
// 执行getUserInfo方法时会将线程切至IO去执行
val userInfo = getUserInfo()
// 获取完数据后 切至Main线程进行更新UI
withContext(Dispatchers.Main) {
// 更新UI
}
}
}

/**
* 获取用户信息 该函数模拟IO获取数据
* @return String
*/
private suspend fun getUserInfo(): String {
return withContext(Dispatchers.IO) {
delay(2000)
"Kotlin"
}
}

override fun onDestroy() {
super.onDestroy()
// 取消协程 防止协程泄漏 如果使用lifecycleScope则不需要手动取消
mScope.cancel()
}
}

上面的代码中,给出了一些代码示例,其实协程的简单使用非常简单,你甚至完全不需要担心其他的东西,你只需要记得及时取消协程就ok,如果你使用lifecycleScope或者viewModelScope你连取消都不用自己管,界面或ViewModel被销毁时,会自动帮你把协程取消掉。使用协程只需要会创建、会切线程、懂四种调度模式,基本就ok了,基本开发已满足。

CoroutineScope.async()

async主要用于获取返回值和并发,直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码fun asyncTest() {
mScope.launch {
// 开启一个IO模式的线程 并返回一个Deferred,Deferred可以用来获取返回值
// 代码执行到此处时会新开一个协程 然后去执行协程体 父协程的代码会接着往下走
val deferred = async(Dispatchers.IO) {
// 模拟耗时
delay(2000)
// 返回一个值
"Quyunshuo"
}
// 等待async执行完成获取返回值 此处并不会阻塞线程 而是挂起 将线程的执行权交出去
// 等到async的协程体执行完毕后 会恢复协程继续往下执行
val date = deferred.await()
}
}

上面的代码主要展示async的返回值功能,需要与await()挂起函数结合使用

下面展示async的并发能力:

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
kotlin复制代码fun asyncTest2() {
mScope.launch {
// 此处有一个需求 同时请求5个接口 并且将返回值拼接起来

val job1 = async {
// 请求1
delay(5000)
"1"
}
val job2 = async {
// 请求2
delay(5000)
"2"
}
val job3 = async {
// 请求3
delay(5000)
"3"
}
val job4 = async {
// 请求4
delay(5000)
"4"
}
val job5 = async {
// 请求5
delay(5000)
"5"
}

// 代码执行到此处时 5个请求已经同时在执行了
// 等待各job执行完 将结果合并
Log.d(
"TAG",
"asyncTest2: ${job1.await()} ${job2.await()} ${job3.await()} ${job4.await()} ${job5.await()}"
)

// 因为我们设置的模拟时间都是5000毫秒 所以当job1执行完时 其他job也均执行完成
}
}

上面的代码就是一个简单的并发示例,是不是感觉十分的简单,协程的优势立马凸显出来了。

这就是最基本的协程使用,关于作用域,更推荐的是在UI组件中使用LifecycleOwner.lifecycleScope,在ViewModel中使用ViewModel.viewModelScope

Coroutine的深入

其实简单的使用,就已经满足大部分日常开发需求,但是我们有必要全面了解一下Coroutine,以便能够排查问题及自定义场景,下面我们从一个最基本的函数来切入,这个函数就是launch{}:

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext, // 协程上下文
start: CoroutineStart = CoroutineStart.DEFAULT, // 协程启动模式
block: suspend CoroutineScope.() -> Unit // 运行在协程的逻辑
): Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}

上面是launch函数的定义,它以CoroutineScope的扩展函数的形成出现,函数参数分别是:协程上下文CoroutineContext、协程启动模式CoroutineStart、协程体,返回值是协程实例Job,其中CoroutineContext又包括了Job、CoroutineDispatcher、CoroutineName。下面我们就一一介绍这些内容:CoroutineContext、Job、CoroutineDispatcher、CoroutineStart、CoroutineScope。

CoroutineContext - 协程上下文

CoroutineContext即协程的上下文,是 Kotlin 协程的一个基本结构单元。巧妙的运用协程上下文是至关重要的,以此来实现正确的线程行为、生命周期、异常以及调试。它包含用户定义的一些数据集合,这些数据与协程密切相关。它是一个有索引的 Element 实例集合。这个有索引的集合类似于一个介于 set 和 map之间的数据结构。每个 element 在这个集合有一个唯一的 Key 。当多个 element 的 key 的引用相同,则代表属于集合里同一个 element。它由如下几项构成:

  • Job: 控制协程的生命周期;
  • CoroutineDispatcher: 向合适的线程分发任务;
  • CoroutineName: 协程的名称,调试的时候很有用;
  • CoroutineExceptionHandler: 处理未被捕捉的异常。

  CoroutineContext 有两个非常重要的元素 — Job 和 Dispatcher,Job 是当前的 Coroutine 实例而 Dispatcher 决定了当前 Coroutine 执行的线程,还可以添加CoroutineName,用于调试,添加 CoroutineExceptionHandler 用于捕获异常,它们都实现了Element接口。看一个例子:

1
2
3
4
5
6
kotlin复制代码fun main() {
val coroutineContext = Job() + Dispatchers.Default + CoroutineName("myContext")
println("$coroutineContext,${coroutineContext[CoroutineName]}")
val newCoroutineContext = coroutineContext.minusKey(CoroutineName)
println("$newCoroutineContext")
}

输出结果如下:

1
2
scss复制代码[JobImpl{Active}@7eda2dbb, CoroutineName(myContext), Dispatchers.Default],CoroutineName(myContext)
[JobImpl{Active}@7eda2dbb, Dispatchers.Default]

CoroutineContext接口的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码public interface CoroutineContext {

public operator fun <E : Element> get(key: Key<E>): E?

public fun <R> fold(initial: R, operation: (R, Element) -> R): R

public operator fun plus(context: CoroutineContext): CoroutineContext{...}

public fun minusKey(key: Key<*>): CoroutineContext

public interface Key<E : Element>

public interface Element : CoroutineContext {...}
}

CoroutineContext 定义了四个核心的操作:

  • 操作符get

可以通过 key 来获取这个 Element。由于这是一个 get 操作符,所以可以像访问 map 中的元素一样使用 context[key] 这种中括号的形式来访问。

  • 操作符 plus

和 Set.plus 扩展函数类似,返回一个新的 context 对象,新的对象里面包含了两个里面的所有 Element,如果遇到重复的(Key 一样的),那么用+号右边的 Element 替代左边的。+ 运算符可以很容易的用于结合上下文,但是有一个很重要的事情需要小心 —— 要注意它们结合的次序,因为这个 + 运算符是不对称的。

  • fun fold(initial: R, operation: (R, Element) -> R): R

和 Collection.fold 扩展函数类似,提供遍历当前 context 中所有 Element 的能力。

  • fun minusKey(key: Key<*>): CoroutineContext

返回一个上下文,其中包含该上下文中的元素,但不包含具有指定key的元素。

某些情况需要一个上下文不持有任何元素,此时就可以使用 EmptyCoroutineContext 对象。可以预见,添加这个对象到另一个上下文不会对其有任何影响。

在任务层级中,每个协程都会有一个父级对象,要么是 CoroutineScope 或者另外一个 coroutine。然而,实际上协程的父级 CoroutineContext 和父级协程的 CoroutineContext 是不一样的,因为有如下的公式:

父级上下文 = 默认值 + 继承的 CoroutineContext + 参数

其中:

  • 一些元素包含默认值: Dispatchers.Default 是默认的 CoroutineDispatcher,以及 “coroutine” 作为默认的 CoroutineName;
  • 继承的 CoroutineContext 是 CoroutineScope 或者其父协程的 CoroutineContext;
  • 传入协程 builder 的参数的优先级高于继承的上下文参数,因此会覆盖对应的参数值。

请注意: CoroutineContext 可以使用 “ + “ 运算符进行合并。由于 CoroutineContext 是由一组元素组成的,所以加号右侧的元素会覆盖加号左侧的元素,进而组成新创建的 CoroutineContext。比如,(Dispatchers.Main, "name") + (Dispatchers.IO) = (Dispatchers.IO, "name")。

Job & Deferred - 任务

Job 用于处理协程。对于每一个所创建的协程 (通过 launch 或者 async),它会返回一个 Job实例,该实例是协程的唯一标识,并且负责管理协程的生命周期

  CoroutineScope.launch 函数返回的是一个 Job 对象,代表一个异步的任务。Job 具有生命周期并且可以取消。 Job 还可以有层级关系,一个Job可以包含多个子Job,当父Job被取消后,所有的子Job也会被自动取消;当子Job被取消或者出现异常后父Job也会被取消。

  除了通过 CoroutineScope.launch 来创建Job对象之外,还可以通过 Job() 工厂方法来创建该对象。默认情况下,子Job的失败将会导致父Job被取消,这种默认的行为可以通过 SupervisorJob 来修改。

  具有多个子 Job 的父Job 会等待所有子Job完成(或者取消)后,自己才会执行完成

Job 的状态

一个任务可以包含一系列状态: 新创建 (New)、活跃 (Active)、完成中 (Completing)、已完成 (Completed)、取消中 (Cancelling) 和已取消 (Cancelled)。虽然我们无法直接访问这些状态,但是我们可以访问 Job 的属性: isActive、isCancelled 和 isCompleted。

  如果协程处于活跃状态,协程运行出错或者调用 job.cancel() 都会将当前任务置为取消中 (Cancelling) 状态 (isActive = false, isCancelled = true)。当所有的子协程都完成后,协程会进入已取消 (Cancelled) 状态,此时 isCompleted = true。

State [isActive] [isCompleted] [isCancelled]
New (optional initial state) false false false
Active (default initial state) true false false
Completing (transient state) true false false
Cancelling (transient state) false false true
Cancelled (final state) false true true
Completed (final state) false true false
1
2
3
4
5
6
7
8
9
10
11
sql复制代码                                      wait children
+-----+ start +--------+ complete +-------------+ finish +-----------+
| New | -----> | Active | ---------> | Completing | -------> | Completed |
+-----+ +--------+ +-------------+ +-----------+
| cancel / fail |
| +----------------+
| |
V V
+------------+ finish +-----------+
| Cancelling | --------------------------------> | Cancelled |
+------------+ +-----------+

Job 的常用函数

这些函数都是线程安全的,所以可以直接在其他 Coroutine 中调用。

  • fun start(): Boolean

调用该函数来启动这个 Coroutine,如果当前 Coroutine 还没有执行调用该函数返回 true,如果当前 Coroutine 已经执行或者已经执行完毕,则调用该函数返回 false

  • fun cancel(cause: CancellationException? = null)

通过可选的取消原因取消此作业。 原因可以用于指定错误消息或提供有关取消原因的其他详细信息,以进行调试。

  • fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle

通过这个函数可以给 Job 设置一个完成通知,当 Job 执行完成的时候会同步执行这个通知函数。 回调的通知对象类型为:typealias CompletionHandler = (cause: Throwable?) -> Unit. CompletionHandler 参数代表了 Job 是如何执行完成的。 cause 有下面三种情况:

+ 如果 `Job` 是正常执行完成的,则 `cause` 参数为 `null`
+ 如果 `Job` 是正常取消的,则 `cause` 参数为 `CancellationException` 对象。这种情况不应该当做错误处理,这是任务正常取消的情形。所以一般不需要在错误日志中记录这种情况。
+ 其他情况表示 `Job` 执行失败了。这个函数的返回值为 `DisposableHandle` 对象,如果不再需要监控 `Job` 的完成情况了, 则可以调用 `DisposableHandle.dispose` 函数来取消监听。如果 `Job` 已经执行完了, 则无需调用 `dispose` 函数了,会自动取消监听。
  • suspend fun join()

join 函数和前面三个函数不同,这是一个 suspend 函数。所以只能在 Coroutine 内调用。

这个函数会暂停当前所处的 Coroutine直到该Coroutine执行完成。所以 join 函数一般用来在另外一个 Coroutine 中等待 job 执行完成后继续执行。当 Job 执行完成后, job.join 函数恢复,这个时候 job 这个任务已经处于完成状态了,而调用 job.join 的 Coroutine 还继续处于 activie 状态。

请注意,只有在其所有子级都完成后,作业才能完成

该函数的挂起是可以被取消的,并且始终检查调用的Coroutine的Job是否取消。如果在调用此挂起函数或将其挂起时,调用Coroutine的Job被取消或完成,则此函数将引发 CancellationException。

Deferred

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码public interface Deferred<out T> : Job {

public val onAwait: SelectClause1<T>

public suspend fun await(): T

@ExperimentalCoroutinesApi
public fun getCompleted(): T

@ExperimentalCoroutinesApi
public fun getCompletionExceptionOrNull(): Throwable?
}

通过使用async创建协程可以得到一个有返回值Deferred,Deferred 接口继承自 Job 接口,额外提供了获取 Coroutine 返回结果的方法。由于 Deferred 继承自 Job 接口,所以 Job 相关的内容在 Deferred 上也是适用的。 Deferred 提供了额外三个函数来处理和Coroutine执行结果相关的操作。

  • suspend fun await(): T

用来等待这个Coroutine执行完毕并返回结果。

  • fun getCompleted(): T

用来获取Coroutine执行的结果。如果Coroutine还没有执行完成则会抛出 IllegalStateException ,如果任务被取消了也会抛出对应的异常。所以在执行这个函数之前,可以通过 isCompleted 来判断一下当前任务是否执行完毕了。

  • fun getCompletionExceptionOrNull(): Throwable?

获取已完成状态的Coroutine异常信息,如果任务正常执行完成了,则不存在异常信息,返回null。如果还没有处于已完成状态,则调用该函数同样会抛出 IllegalStateException,可以通过 isCompleted 来判断一下当前任务是否执行完毕了。

SupervisorJob

SupervisorJob 是一个顶层函数,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kotlin复制代码/**
* Creates a _supervisor_ job object in an active state.
* Children of a supervisor job can fail independently of each other.
*
* A failure or cancellation of a child does not cause the supervisor job to fail and does not affect its other children,
* so a supervisor can implement a custom policy for handling failures of its children:
*
* * A failure of a child job that was created using [launch][CoroutineScope.launch] can be handled via [CoroutineExceptionHandler] in the context.
* * A failure of a child job that was created using [async][CoroutineScope.async] can be handled via [Deferred.await] on the resulting deferred value.
*
* If [parent] job is specified, then this supervisor job becomes a child job of its parent and is cancelled when its
* parent fails or is cancelled. All this supervisor's children are cancelled in this case, too. The invocation of
* [cancel][Job.cancel] with exception (other than [CancellationException]) on this supervisor job also cancels parent.
*
* @param parent an optional parent job.
*/
@Suppress("FunctionName")
public fun SupervisorJob(parent: Job? = null) : CompletableJob = SupervisorJobImpl(parent)

该函数创建了一个处于 active 状态的supervisor job。如前所述, Job 是有父子关系的,如果子Job 失败了父Job会自动失败,这种默认的行为可能不是我们期望的。比如在 Activity 中有两个子Job分别获取一篇文章的评论内容和作者信息。如果其中一个失败了,我们并不希望父Job自动取消,这样会导致另外一个子Job也被取消。而SupervisorJob就是这么一个特殊的 Job,里面的子Job不相互影响,一个子Job失败了,不影响其他子Job的执行。SupervisorJob(parent:Job?) 具有一个parent参数,如果指定了这个参数,则所返回的 Job 就是参数 parent 的子Job。如果 Parent Job 失败了或者取消了,则这个 Supervisor Job 也会被取消。当 Supervisor Job 被取消后,所有 Supervisor Job 的子Job也会被取消。

MainScope() 的实现就使用了 SupervisorJob 和一个 Main Dispatcher:

1
2
3
4
5
kotlin复制代码/**
* Creates the main [CoroutineScope] for UI components.
*
* Example of use:
*
  • class MyAndroidActivity {
  • private val scope = MainScope()
  • override fun onDestroy() {
  • super.onDestroy()
  • scope.cancel()
  • }
  • }
  • 1
    2
    3
    4
    5
    6
    7
     *
    * The resulting scope has [SupervisorJob] and [Dispatchers.Main] context elements.
    * If you want to append additional elements to the main scope, use [CoroutineScope.plus] operator:
    * `val scope = MainScope() + CoroutineName("MyActivity")`.
    */
    @Suppress("FunctionName")
    public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

但是SupervisorJob是很容易被误解的,它和协程异常处理、子协程所属Job类型还有域有很多让人混淆的地方,具体异常处理可以看Google的这一篇文章:协程中的取消和异常 | 异常处理详解

CoroutineDispatcher - 调度器

CoroutineDispatcher 定义了 Coroutine 执行的线程。CoroutineDispatcher 可以限定 Coroutine 在某一个线程执行、也可以分配到一个线程池来执行、也可以不限制其执行的线程。

  CoroutineDispatcher 是一个抽象类,所有 dispatcher 都应该继承这个类来实现对应的功能。Dispatchers 是一个标准库中帮我们封装了切换线程的帮助类,可以简单理解为一个线程池。它的实现如下:

dispatchers.webp

  • Dispatchers.Default

默认的调度器,适合处理后台计算,是一个CPU密集型任务调度器。如果创建 Coroutine 的时候没有指定 dispatcher,则一般默认使用这个作为默认值。Default dispatcher 使用一个共享的后台线程池来运行里面的任务。注意它和IO共享线程池,只不过限制了最大并发数不同。

  • Dispatchers.IO

顾名思义这是用来执行阻塞 IO 操作的,是和Default共用一个共享的线程池来执行里面的任务。根据同时运行的任务数量,在需要的时候会创建额外的线程,当任务执行完毕后会释放不需要的线程。

  • Dispatchers.Unconfined

由于Dispatchers.Unconfined未定义线程池,所以执行的时候默认在启动线程。遇到第一个挂起点,之后由调用resume的线程决定恢复协程的线程。

  • Dispatchers.Main:

指定执行的线程是主线程,在Android上就是UI线程·

由于子Coroutine 会继承父Coroutine 的 context,所以为了方便使用,我们一般会在 父Coroutine 上设定一个 Dispatcher,然后所有 子Coroutine 自动使用这个 Dispatcher。

CoroutineStart - 协程启动模式

  • CoroutineStart.DEFAULT:

协程创建后立即开始调度,在调度前如果协程被取消,其将直接进入取消响应的状态

虽然是立即调度,但也有可能在执行前被取消

  • CoroutineStart.ATOMIC:

协程创建后立即开始调度,协程执行到第一个挂起点之前不响应取消

虽然是立即调度,但其将调度和执行两个步骤合二为一了,就像它的名字一样,其保证调度和执行是原子操作,因此协程也一定会执行

  • CoroutineStart.LAZY:

只要协程被需要时,包括主动调用该协程的start、join或者await等函数时才会开始调度,如果调度前就被取消,协程将直接进入异常结束状态

  • CoroutineStart.UNDISPATCHED:

协程创建后立即在当前函数调用栈中执行,直到遇到第一个真正挂起的点

是立即执行,因此协程一定会执行

这些启动模式的设计主要是为了应对某些特殊的场景。业务开发实践中通常使用DEFAULT和LAZY这两个启动模式就够了

CoroutineScope - 协程作用域

定义协程必须指定其 CoroutineScope 。CoroutineScope 可以对协程进行追踪,即使协程被挂起也是如此。同调度程序 (Dispatcher) 不同,CoroutineScope 并不运行协程,它只是确保您不会失去对协程的追踪。为了确保所有的协程都会被追踪,Kotlin 不允许在没有使用 CoroutineScope 的情况下启动新的协程。CoroutineScope 可被看作是一个具有超能力的 ExecutorService 的轻量级版本。CoroutineScope 会跟踪所有协程,同样它还可以取消由它所启动的所有协程。这在 Android 开发中非常有用,比如它能够在用户离开界面时停止执行协程。

  Coroutine 是轻量级的线程,并不意味着就不消耗系统资源。 当异步操作比较耗时的时候,或者当异步操作出现错误的时候,需要把这个 Coroutine 取消掉来释放系统资源。在 Android 环境中,通常每个界面(Activity、Fragment 等)启动的 Coroutine 只在该界面有意义,如果用户在等待 Coroutine 执行的时候退出了这个界面,则再继续执行这个 Coroutine 可能是没必要的。另外 Coroutine 也需要在适当的 context 中执行,否则会出现错误,比如在非 UI 线程去访问 View。 所以 Coroutine 在设计的时候,要求在一个范围(Scope)内执行,这样当这个 Scope 取消的时候,里面所有的子 Coroutine 也自动取消。所以要使用 Coroutine 必须要先创建一个对应的 CoroutineScope。

CoroutineScope 接口

1
2
3
kotlin复制代码public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}

CoroutineScope 只是定义了一个新 Coroutine 的执行 Scope。每个 coroutine builder 都是 CoroutineScope 的扩展函数,并且自动的继承了当前 Scope 的 coroutineContext 。

分类及行为规则

官方框架在实现复合协程的过程中也提供了作用域,主要用以明确写成之间的父子关系,以及对于取消或者异常处理等方面的传播行为。该作用域包括以下三种:

  • 顶级作用域

没有父协程的协程所在的作用域为顶级作用域。

  • 协同作用域

协程中启动新的协程,新协程为所在协程的子协程,这种情况下,子协程所在的作用域默认为协同作用域。此时子协程抛出的未捕获异常,都将传递给父协程处理,父协程同时也会被取消。

  • 主从作用域

与协同作用域在协程的父子关系上一致,区别在于,处于该作用域下的协程出现未捕获的异常时,不会将异常向上传递给父协程。

除了三种作用域中提到的行为以外,父子协程之间还存在以下规则:

  • 父协程被取消,则所有子协程均被取消。由于协同作用域和主从作用域中都存在父子协程关系,因此此条规则都适用。
  • 父协程需要等待子协程执行完毕之后才会最终进入完成状态,不管父协程自身的协程体是否已经执行完。
  • 子协程会继承父协程的协程上下文中的元素,如果自身有相同key的成员,则覆盖对应的key,覆盖的效果仅限自身范围内有效。

常用作用域

官方库给我们提供了一些作用域可以直接来使用,并且 Android 的Lifecycle Ktx库也封装了更好用的作用域,下面看一下各种作用域

GlobalScope - 不推荐使用

1
2
3
4
5
6
7
kotlin复制代码public object GlobalScope : CoroutineScope {
/**
* Returns [EmptyCoroutineContext].
*/
override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}

GlobalScope是一个单例实现,源码十分简单,上下文是EmptyCoroutineContext,是一个空的上下文,切不包含任何Job,该作用域常被拿来做示例代码,由于 GlobalScope 对象没有和应用生命周期组件相关联,需要自己管理 GlobalScope 所创建的 Coroutine,且GlobalScope的生命周期是 process 级别的,所以一般而言我们不推荐使用 GlobalScope 来创建 Coroutine。

runBlocking{} - 主要用于测试

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
kotlin复制代码/**
* Runs a new coroutine and **blocks** the current thread _interruptibly_ until its completion.
* This function should not be used from a coroutine. It is designed to bridge regular blocking code
* to libraries that are written in suspending style, to be used in `main` functions and in tests.
*
* The default [CoroutineDispatcher] for this builder is an internal implementation of event loop that processes continuations
* in this blocked thread until the completion of this coroutine.
* See [CoroutineDispatcher] for the other implementations that are provided by `kotlinx.coroutines`.
*
* When [CoroutineDispatcher] is explicitly specified in the [context], then the new coroutine runs in the context of
* the specified dispatcher while the current thread is blocked. If the specified dispatcher is an event loop of another `runBlocking`,
* then this invocation uses the outer event loop.
*
* If this blocked thread is interrupted (see [Thread.interrupt]), then the coroutine job is cancelled and
* this `runBlocking` invocation throws [InterruptedException].
*
* See [newCoroutineContext][CoroutineScope.newCoroutineContext] for a description of debugging facilities that are available
* for a newly created coroutine.
*
* @param context the context of the coroutine. The default value is an event loop on the current thread.
* @param block the coroutine code.
*/
@Throws(InterruptedException::class)
public fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
val currentThread = Thread.currentThread()
val contextInterceptor = context[ContinuationInterceptor]
val eventLoop: EventLoop?
val newContext: CoroutineContext
if (contextInterceptor == null) {
// create or use private event loop if no dispatcher is specified
eventLoop = ThreadLocalEventLoop.eventLoop
newContext = GlobalScope.newCoroutineContext(context + eventLoop)
} else {
// See if context's interceptor is an event loop that we shall use (to support TestContext)
// or take an existing thread-local event loop if present to avoid blocking it (but don't create one)
eventLoop = (contextInterceptor as? EventLoop)?.takeIf { it.shouldBeProcessedFromContext() }
?: ThreadLocalEventLoop.currentOrNull()
newContext = GlobalScope.newCoroutineContext(context)
}
val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop)
coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
return coroutine.joinBlocking()
}

这是一个顶层函数,从源码的注释中我们可以得到一些信息,运行一个新的协程并且阻塞当前可中断的线程直至协程执行完成,该函数不应从一个协程中使用,该函数被设计用于桥接普通阻塞代码到以挂起风格(suspending style)编写的库,以用于主函数与测试。该函数主要用于测试,不适用于日常开发,该协程会阻塞当前线程直到协程体执行完成。

MainScope() - 可用于开发

1
2
3
4
5
kotlin复制代码/**
* Creates the main [CoroutineScope] for UI components.
*
* Example of use:
*
  • class MyAndroidActivity {
  • private val scope = MainScope()
  • override fun onDestroy() {
  • super.onDestroy()
  • scope.cancel()
  • }
  • }
  • 1
    2
    3
    4
    5
    6
    7
     *
    * The resulting scope has [SupervisorJob] and [Dispatchers.Main] context elements.
    * If you want to append additional elements to the main scope, use [CoroutineScope.plus] operator:
    * `val scope = MainScope() + CoroutineName("MyActivity")`.
    */
    @Suppress("FunctionName")
    public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

该函数是一个顶层函数,用于返回一个上下文是SupervisorJob() + Dispatchers.Main的作用域,该作用域常被使用在Activity/Fragment,并且在界面销毁时要调用fun CoroutineScope.cancel(cause: CancellationException? = null)对协程进行取消,这是官方库中可以在开发中使用的一个用于获取作用域的顶层函数,使用示例在官方库的代码注释中已经给出,上面的源码中也有,使用起来也是十分的方便。

LifecycleOwner.lifecycleScope - 推荐使用

1
2
3
4
5
6
7
8
9
10
kotlin复制代码/**
* [CoroutineScope] tied to this [LifecycleOwner]'s [Lifecycle].
*
* This scope will be cancelled when the [Lifecycle] is destroyed.
*
* This scope is bound to
* [Dispatchers.Main.immediate][kotlinx.coroutines.MainCoroutineDispatcher.immediate].
*/
val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
get() = lifecycle.coroutineScope

该扩展属性是 Android 的Lifecycle Ktx库提供的具有生命周期感知的协程作用域,它与LifecycleOwner的Lifecycle绑定,Lifecycle被销毁时,此作用域将被取消。这是在Activity/Fragment中推荐使用的作用域,因为它会与当前的UI组件绑定生命周期,界面销毁时该协程作用域将被取消,不会造成协程泄漏,相同作用的还有下文提到的ViewModel.viewModelScope。

ViewModel.viewModelScope - 推荐使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码/**
* [CoroutineScope] tied to this [ViewModel].
* This scope will be canceled when ViewModel will be cleared, i.e [ViewModel.onCleared] is called
*
* This scope is bound to
* [Dispatchers.Main.immediate][kotlinx.coroutines.MainCoroutineDispatcher.immediate]
*/
val ViewModel.viewModelScope: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
}

该扩展属性和上文中提到的LifecycleOwner.lifecycleScope基本一致,它是ViewModel的扩展属性,也是来自Android 的Lifecycle Ktx库,它能够在此ViewModel销毁时自动取消,同样不会造成协程泄漏。该扩展属性返回的作用域的上下文同样是SupervisorJob() + Dispatchers.Main.immediate

coroutineScope & supervisorScope

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
kotlin复制代码/**
* Creates a [CoroutineScope] with [SupervisorJob] and calls the specified suspend block with this scope.
* The provided scope inherits its [coroutineContext][CoroutineScope.coroutineContext] from the outer scope, but overrides
* context's [Job] with [SupervisorJob].
*
* A failure of a child does not cause this scope to fail and does not affect its other children,
* so a custom policy for handling failures of its children can be implemented. See [SupervisorJob] for details.
* A failure of the scope itself (exception thrown in the [block] or cancellation) fails the scope with all its children,
* but does not cancel parent job.
*/
public suspend fun <R> supervisorScope(block: suspend CoroutineScope.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return suspendCoroutineUninterceptedOrReturn { uCont ->
val coroutine = SupervisorCoroutine(uCont.context, uCont)
coroutine.startUndispatchedOrReturn(coroutine, block)
}
}



/**
* Creates a [CoroutineScope] and calls the specified suspend block with this scope.
* The provided scope inherits its [coroutineContext][CoroutineScope.coroutineContext] from the outer scope, but overrides
* the context's [Job].
*
* This function is designed for _parallel decomposition_ of work. When any child coroutine in this scope fails,
* this scope fails and all the rest of the children are cancelled (for a different behavior see [supervisorScope]).
* This function returns as soon as the given block and all its children coroutines are completed.
* A usage example of a scope looks like this:
*
*
  • suspend fun showSomeData() = coroutineScope {
  • val data = async(Dispatchers.IO) { // <- extension on current scope
  • … load some UI data for the Main thread …
  • }
  • withContext(Dispatchers.Main) {
  • doSomeWork()
  • val result = data.await()
  • display(result)
  • }
  • }
  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
     *
    * The scope in this example has the following semantics:
    * 1) `showSomeData` returns as soon as the data is loaded and displayed in the UI.
    * 2) If `doSomeWork` throws an exception, then the `async` task is cancelled and `showSomeData` rethrows that exception.
    * 3) If the outer scope of `showSomeData` is cancelled, both started `async` and `withContext` blocks are cancelled.
    * 4) If the `async` block fails, `withContext` will be cancelled.
    *
    * The method may throw a [CancellationException] if the current job was cancelled externally
    * or may throw a corresponding unhandled [Throwable] if there is any unhandled exception in this scope
    * (for example, from a crashed coroutine that was started with [launch][CoroutineScope.launch] in this scope).
    */
    public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R {
    contract {
    callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return suspendCoroutineUninterceptedOrReturn { uCont ->
    val coroutine = ScopeCoroutine(uCont.context, uCont)
    coroutine.startUndispatchedOrReturn(coroutine, block)
    }
    }

首先这两个函数都是挂起函数,需要运行在协程内或挂起函数内。supervisorScope属于主从作用域,会继承父协程的上下文,它的特点就是子协程的异常不会影响父协程,它的设计应用场景多用于子协程为独立对等的任务实体的时候,比如一个下载器,每一个子协程都是一个下载任务,当一个下载任务异常时,它不应该影响其他的下载任务。coroutineScope和supervisorScope都会返回一个作用域,它俩的差别就是异常传播:coroutineScope 内部的异常会向上传播,子协程未捕获的异常会向上传递给父协程,任何一个子协程异常退出,会导致整体的退出;supervisorScope 内部的异常不会向上传播,一个子协程异常退出,不会影响父协程和兄弟协程的运行。

协程的取消和异常

普通协程如果产生未处理异常会将此异常传播至它的父协程,然后父协程会取消所有的子协程、取消自己、将异常继续向上传递。下面拿一个官方的图来示例这个过程:

coroutine_e.gif

这种情况有的时候并不是我们想要的,我们更希望一个协程在产生异常时,不影响其他协程的执行,在上文中我们也提到了一些解决方案,下面我们就在实践一下。

使用SupervisorJob**

在上文中我们也对这个顶层函数做了讲解,那如何使用呢?直接上代码:

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
kotlin复制代码import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import kotlinx.coroutines.*

class MainActivity : AppCompatActivity() {

/**
* 使用官方库的 MainScope()获取一个协程作用域用于创建协程
*/
private val mScope = MainScope()

companion object {
const val TAG = "Kotlin Coroutine"
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

mScope.launch(Dispatchers.Default) {
delay(500)
Log.e(TAG, "Child 1")
}

mScope.launch(Dispatchers.Default) {
delay(1000)
Log.e(TAG, "Child 2")
throw RuntimeException("--> RuntimeException <--")
}

mScope.launch(Dispatchers.Default) {
delay(1500)
Log.e(TAG, "Child 3")
}
}
}


打印结果:
E/Kotlin Coroutine: Child 1
E/Kotlin Coroutine: Child 2
E/AndroidRuntime: FATAL EXCEPTION: DefaultDispatcher-worker-3
Process: com.quyunshuo.kotlincoroutine, PID: 24240
java.lang.RuntimeException: --> RuntimeException <--
at com.quyunshuo.kotlincoroutine.MainActivity$onCreate$2.invokeSuspend(MainActivity.kt:31)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
E/Kotlin Coroutine: Child 3

MainScope()我们之前提到过了,它的实现就是用了SupervisorJob。执行结果就是Child 2抛出异常后,Child 3正常执行了,但是程序崩了,因为我们没有处理这个异常,下面完善一下代码

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
kotlin复制代码override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

mScope.launch(Dispatchers.Default) {
delay(500)
Log.e(TAG, "Child 1")
}

// 在Child 2的上下文添加了异常处理
mScope.launch(Dispatchers.Default + CoroutineExceptionHandler { coroutineContext, throwable ->
Log.e(TAG, "CoroutineExceptionHandler: $throwable")
}) {
delay(1000)
Log.e(TAG, "Child 2")
throw RuntimeException("--> RuntimeException <--")
}

mScope.launch(Dispatchers.Default) {
delay(1500)
Log.e(TAG, "Child 3")
}
}


输出结果:
E/Kotlin Coroutine: Child 1
E/Kotlin Coroutine: Child 2
E/Kotlin Coroutine: CoroutineExceptionHandler: java.lang.RuntimeException: --> RuntimeException <--
E/Kotlin Coroutine: Child 3

这一次,程序没有崩溃,并且异常处理的打印也输出了,这就达到了我们想要的效果。但是要注意一个事情,这几个子协程的父级是SupervisorJob,但是他们再有子协程的话,他们的子协程的父级就不是SupervisorJob了,所以当它们产生异常时,就不是我们演示的效果了。我们使用一个官方的图来解释这个关系:

child_job.png

这个图可以说是非常直观了,还是官方🐂。新的协程被创建时,会生成新的 Job 实例替代 SupervisorJob。

使用supervisorScope

这个作用域我们上文中也有提到,使用supervisorScope也可以达到我们想要的效果,上代码:

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
kotlin复制代码import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import kotlinx.coroutines.*

class MainActivity : AppCompatActivity() {

companion object {
const val TAG = "Kotlin Coroutine"
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

val scope = CoroutineScope(Job() + Dispatchers.Default)

scope.launch(CoroutineExceptionHandler { coroutineContext, throwable ->
Log.e(TAG, "CoroutineExceptionHandler: $throwable")
}) {
supervisorScope {
launch {
delay(500)
Log.e(TAG, "Child 1 ")
}
launch {
delay(1000)
Log.e(TAG, "Child 2 ")
throw RuntimeException("--> RuntimeException <--")
}
launch {
delay(1500)
Log.e(TAG, "Child 3 ")
}
}
}
}
}

输出结果:
E/Kotlin Coroutine: Child 1
E/Kotlin Coroutine: Child 2
E/Kotlin Coroutine: CoroutineExceptionHandler: java.lang.RuntimeException: --> RuntimeException <--
E/Kotlin Coroutine: Child 3

可以看到已经达到了我们想要的效果,但是如果将supervisorScope换成coroutineScope,结果就不是这样了。最终还是拿官方的图来展示:

ok.png

结语

至此文章就已经结束,本文主要是我学习协程的一些记录,分享出来供大家翻阅一下,大家好才是真的好。里面有很多的描述都是摘录自别的文章的,下面也给出了链接,其实官方的文章已经将协程的使用讲的非常全面了,大家可以翻阅一下官方的文章进行学习,虽然可能描述的不是很详细,但是该有的细节都提到了。

  后续有时间可能会出一些协程的高级用法的文章,比如协程的冷数据流Flow,这个在我们的项目里也已经用上了,没错,是我引入的😁。总体来说,协程简单使用非常简单,但是想用好,还是需要下一定的功夫去研究的,但是还是逃不过真香定律,大家赶紧学习用起来吧。

我的其他文章:

  • 一个 Android MVVM 组件化架构框架
  • 解锁管理EventBus注册新姿势——自定义注解+反射
  • 万字长文 - 史上最全ConstraintLayout(约束布局)使用详解

参考及摘录

掌握Kotlin Coroutine之 Job&Deferred

霍丙乾 - 《深入理解Kotlin协程》

谷歌开发者 - 在 Android 开发中使用协程 | 背景介绍

谷歌开发者 - 协程中的取消和异常 | 核心概念介绍

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

MySQL底层原理剖析 什么是MySQL? 磁盘数据是怎么读

发表于 2021-04-13

本文仅限于innodb存储引擎
Created: Apr 13, 2021 7:12 PM

Created By: crazy

Last Edited By: crazy

Last Edited Time: Apr 13, 2021 7:47 PM

本文分3个部分:
1,MySQL的介绍和优化
2,Golang连接MySQL的连接池源码剖析
3,Innodb的索引介绍

什么是MySQL?

MySQL是一种关系型数据库管理系统

磁盘数据是怎么读取的?

在介绍MySQL之前,先简单说说磁盘是怎么读取数据的

Untitled.png

MySQL架构

一共分为4层

1,连接层

2,核心服务层:SQL解析,SQL优化等

3,存储引擎层:innodb

4,数据存储层:磁盘

Untitled 1.png

SQL查询过程

Untitled 2.png

只有了解了MySQL的实现原理,才能知道如何着手进行优化

优化阶段

MySQL一共可以从以下3个方向进行优化

1,SQL语句

2,MySQL配置

3,磁盘性能

SQL

SQL自身

例如:like 、 select *、 limit 、join 等

索引

1,单独加索引

2,explain sql 定位

建表:

主键设立:单机用自增id,分布式用专门的发号器或者雪花算法获取分布式id,为什么不用uuid做主键,原因跟数据的落盘,以及磁盘的读取方式有关;

磁盘的数据是怎么读取的呢? 磁头会在磁盘页上顺序的读取,而,innodb是顺序存储的,但是由于uuid是分布,所以在磁盘上为零散的,并不是顺序落盘,这就导致读取效率的降低。

一般遵循3范式,但是如果是clickhouse,那么一般以宽表为主,数仓的建设都是以宽表为主,更利于查询

索引查询

主键查询和普通索引查询也是不一样的

1,主键为聚簇索引,一次查询就能够获取到记录

2,普通索引也为二级索引,非聚簇索引,需要2次:第一次,先查询着id,再根据id查询到对于的数据,相当于二级索引指向主键,该行为称之为回表

3,覆盖索引为特殊的普通索引,不用回表,因为通过组合索引的特性直接获取到对于的数据,explain 的extra中表现为use indexing

Untitled 3.png

MySQL的配置

通过优化mysql自身配置也能实现mysql的优化,具体可以从查询过程中查看,例如:缓存的大小等

磁盘的配置

每次磁盘的读取都是一次机械性的io动作,所以,磁盘读取的快慢直接影响了mysql读取的性能

Golang的MySQL 连接池源码剖析

Golang官方库的介绍

这里明确说明了sql.Open 不会产生新的连接,让我们来看看为什么

image.png

db 是sql包里的一个struct

image.png

真正发生连接是在每次去执行语句的时候,例如,query

当你执行的时候,他会去判断,如果有空闲的连接,那么就优先用这个空闲的连接,如果没有则新建一个

image.png

这里,sql 内部自己维护了一个pool,pool的维护需要考虑几个方面:

  1. pool的容量大小,不可能无限大,也不可能无限小
  2. pool的申请和回收,不回收你这个pool 或许一下就被apply完了

最后,可能有的小伙伴会疑惑,那既然sql.Open不是新建一个conn,为什么要db.Close()呢?
那,我们就来一探究竟
从包里的解释,我们能够大致了解到

  • 关闭db 这个聚柄
  • 连接的处理
    • 防止后续有新的conn 链接
    • 等待所有进行中的
      image.png

让我们一起看看源码的真面目

1.保持原子性

2.关闭 conn(chan)

3.db.stop() ,什么是stop(),他是一个context 的cancal(),在sql.Open()的时候定义的

image.png

sql.Open()的时候,OpenDB()会定义好context,这个也行就是golang的魅力吧
image.png

索引的真面目

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

你有没有被ThreadLocal坑过?

发表于 2021-04-13

上一篇文章发出去后,雄哥说我写得挺好,但是有一些关于ThreadLocal的坑没有指出来。大佬不愧是大佬,一语中的。

因此这篇来看看ThreadLocal存在什么问题,又有怎样的解决方案

ThreadLocal的问题

ThreadLocal是线程变量,每个线程都会有一个ThreadLocal副本。每个Thread都维护着一个ThreadLocalMap,
ThreadLocalMap 中存在一个弱引用Entry。如果一个ThreadLocal没有外部强引用来引用它,那么系统 GC 的时候,这个ThreadLocal势必会被回收。

这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链
Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value 永远无法回收,造成内存泄漏。

虽然在调用get()、set()、remove()的时候会清除所有key为null的value,但是如果调用者不调用也没办法。关于ThreadLocalMap的分析可以参考我写的这篇开放寻址的ThreadLocalMap分析

thread-local.png

使用ThreadLocal,在子线程中我们是获取不到父线程中的ThreadLocal的值的。

test-single-thread.png

输出结果如下

single-thread-test-result.png

如何获取父线程中ThreadLocal的值?

使用InheritableThreadLocal。InheritableThreadLocal重写了getMap与createMap方法,ThreadLocal使用的是Thread.threadLocals,而InheritableThreadLocal使用的是Thread.inheritableThreadLocals。

inherit-thread-local.png

问: InheritableThreadLocal是如何实现在子线程中获取父线程中ThreadLocal值?
答:将父线程中的所有值Copy到子线程

InheritableThreadLocal在创建线程的时候做了一些工作

inherit-create.png

若父线程inheritableThreadLocals不为null,则为当前线程创建inheritableThreadLocals,并且copy父线程inheritableThreadLocals中的内容,createInheritedMap会创建并拷贝。

总结:

  • 创建InheritableThreadLocal对象时,赋值给了Thread.inheritableThreadLocals变量
  • 创建新的子线程会check父线程的inheritableThreadLocals是否为null, 不为null拷贝父线程inheritableThreadLocals中的内容到当前线程
  • InheritableThreadLocal重写了getMap, createMap, 使用的都是Thread.inheritableThreadLocals变量

InheritableThreadLocal的问题

在使用线程池的时候InheritableThreadLocal并不能解决获取父线程值得问题,因为线程池中的线程是复用的,可能在子线程中对值进行了修改,使子线程获取到的值并不正确。

test-inherit-thread.png

输出结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
markdown复制代码[main]: aaa

[pool-1-thread-1]: aaa
[pool-1-thread-1]: bbb

[pool-1-thread-2]: aaa
[pool-1-thread-1]: bbb
[pool-1-thread-4]: aaa
[pool-1-thread-1]: bbb
[pool-1-thread-2]: aaa
[pool-1-thread-3]: aaa
[pool-1-thread-2]: aaa
[pool-1-thread-1]: bbb
[pool-1-thread-4]: aaa
[pool-1-thread-3]: aaa

[main]: aaa

几个典型应用场景

  • 分布式跟踪系统
  • 日志收集记录系统上下文
  • 应用容器或上层框架跨应用代码给下层SDK传递信息

比如我们的这样记日志,再也不背锅 其中的MDC就是使用的InheritableThreadLocal。

解决办法:

  1. 线程使用完成,清空TheadLocalMap
  2. submit提交新任务时,重新拷贝父线程中的所有Entry。重新为当前线程的inheritableThreadLocals赋值。

使用alibab TransmittableThreadLocal

TransmittableThreadLocal就采用了备份的方法来解决这个问题

ttl-thread-local-test.png

输出结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
markdown复制代码[main]: aaa

[pool-1-thread-1]:aaa
[pool-1-thread-1]:bbb

[pool-1-thread-2]:aaa
[pool-1-thread-1]:aaa
[pool-1-thread-3]:aaa
[pool-1-thread-4]:aaa
[pool-1-thread-3]:aaa
[pool-1-thread-1]:aaa
[pool-1-thread-3]:aaa
[pool-1-thread-2]:aaa
[pool-1-thread-1]:aaa
[pool-1-thread-4]:aaa

[main]: aaa

TransmittableThreadLocal原理很容易理解,就是在业务逻辑之前先将ThreadLocal中的内容备份,业务逻辑完成后在将内容还原。

ttl-thread-local.png

具体的可以参考官方这篇文档: github.com/alibaba/tra…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

GoReplay实战 - 是谁占用了带宽|Go主题月 GoR

发表于 2021-04-13

GoReplay是什么

最恐怖的事情莫过于生产环境来了问题,可能是突然流量大了,也可能是接收到了奇怪的参数,在监控、日志不全的情况下,心态肯定崩了。这时候,GoReplay就应该出场了。

1
bash复制代码监控和日志也要做好啊喂(#`O′)

GoReplay是Go语言写的一个网络流量(http)转发的应用,无需侵入代码或者修改现有配置。配置简单,单文件命令行即可部署。

通过监听网卡,直接录制请求,后续可以进行流量回放、压力测试、监控等。

image.png

实现原理

图中Capture netowrk部分是基于pcap实现的,使用BPF设置指定端口的过滤表达式

1
复制代码是不是想到了tcpdump,所以对于比较复杂的流量,更推荐使用tcpcopy来进行流量复制

常见用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bash复制代码# 1. 简单的 HTTP 流量复制:
gor –input-raw :80 –output-http “http://example.com”

# 2.HTTP 流量复制频率控制:
gor –input-tcp :28020 –output-http “http://example.com|10″

# 3.HTTP 流量复制缩小:
gor –input-raw :80 –output-tcp “replay.local:28020|10%”

# 4.HTTP 流量记录到本地文件:
gor –input-raw :80 –output-file requests.gor

# 5.HTTP 流量回放和压测:
gor –input-file “requests.gor|200%” –output-http “example.com”

# 6.HTTP 流量过滤复制:
gor –input-raw :8080 –output-http example.com –output-http-url-regexp ^www.

# 7.HTTP指定接口流量复制:
gor --input-raw :80 --http-allow-url '/api/v1' --output-stdout # --output-stdout表示直接在控制台输出

我在什么场景下使用了

线上某个程序平稳运行了很久,最近阿里云监控一直报警带宽。由于老旧项目没有接入prometheus,并不是很清楚实际运行情况,所以准备用GoReplay录制线上流量,然后对接口进行分析。

首先肯定先将线上流量保存下来,使用root运行命令行

1
2
3
4
bash复制代码./gor --input-raw :7018 \ # 设置监听端口
--input-raw-track-response \ # 同时保存response
--output-file logs/requests-%Y%m%d.gor \ # 按天输出文件
--output-file-append # 默认会分割文件,我们需要合并在一个文件中

分块读取

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
go复制代码// 通过回调函数,在外面解析具体的请求
func load(filename string, iter func(i int, req *http.Request, resp *http.Response) error) error {
file, err := os.Open(filename)
if err != nil {
log.Printf("Cannot open text file: %s, err: [%v]", filename, err)
return err
}
defer file.Close()

// 设置一个读取的buffer,这里自定义了一个bufio.SplitFunc
buf := make([]byte, 0, 1024*1024)
scanner := bufio.NewScanner(file)
scanner.Buffer(buf, 10*1024*1024)

// 你没有看错,是通过emoji分割的
scanner.Split(splitByWords([]byte("🐵🙈🙉")))
var req *http.Request
var resp *http.Response
var i = 1
for scanner.Scan() {
text := scanner.Bytes()
n := bytes.IndexByte(text, '\n')

// 根据第一个字符判断是request还是response
// 这里直接使用了原生方法读取了request
if text[0] == '1' {
req, _ = http.ReadRequest(bufio.NewReader(bytes.NewReader(text[n+1:])))
} else if text[0] == '2' {
resp, _ = http.ReadResponse(bufio.NewReader(bytes.NewReader(text[n+1:])), req)
}
if i%2 == 0 {
if iter(i/2, req, resp) != nil {
return err
}
}
i += 1
}

if err := scanner.Err(); err != nil {
log.Printf("Cannot scanner text file: %s, err: [%v]", filename, err)
return err
}

return nil
}

实现bufio.SplitFunc

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
go复制代码// copy from bufio.ScanWords
func isSpace(r rune) bool {
if r <= '\u00FF' {
// Obvious ASCII ones: \t through \r plus space. Plus two Latin-1 oddballs.
switch r {
case ' ', '\t', '\n', '\v', '\f', '\r':
return true
case '\u0085', '\u00A0':
return true
}
return false
}
// High-valued ones.
if '\u2000' <= r && r <= '\u200a' {
return true
}
switch r {
case '\u1680', '\u2028', '\u2029', '\u202f', '\u205f', '\u3000':
return true
}
return false
}

func splitByWords(words []byte) bufio.SplitFunc {
return func(data []byte, atEOF bool) (advance int, token []byte, err error) {
start := 0
for width := 0; start < len(data); start += width {
var r rune
r, width = utf8.DecodeRune(data[start:])
if !isSpace(r) {
break
}
}
for width, i := 0, start; i < len(data); i += width {
_, width = utf8.DecodeRune(data[i:])
if bytes.HasSuffix(data[start:i], words) {
return i + width, data[start : i-len(words)], nil
}
}
// If we're at EOF, we have a final, non-empty, non-terminated word. Return it.
if atEOF && len(data) > start {
return len(data), data[start:], nil
}
return start, nil, nil
}
}

结语

完整代码放在Gist上了

这次实战的收货

  • 对http的流量有了更深一步的理解。经过统计分析,实际上http header占据了很大一部分流量
  • 对于请求频次的很高的接口,通过合并上报接口、延长轮训返回时间等手段,可以极大的降低流量
  • 调用一些不常用的接口http.ReadRequest、http.ReadResponse
  • 为了优化读取性能,自己实现了bufio.SplitFunc

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

在SpringBoot项目中,自定义注解+拦截器优雅的实现敏

发表于 2021-04-13

在实际生产项目中,经常需要对如身份证信息、手机号、真实姓名等的敏感数据进行加密数据库存储,但在业务代码中对敏感信息进行手动加解密则十分不优雅,甚至会存在错加密、漏加密、业务人员需要知道实际的加密规则等的情况。

本文将介绍使用springboot+mybatis拦截器+自定义注解的形式对敏感数据进行存储前拦截加密的详细过程。

一、什么是Mybatis Plugin

在mybatis官方文档中,对于Mybatis plugin的的介绍是这样的:

MyBatis 允许你在已映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:

1
2
3
4
5
6
7
8
9
10
11
scss复制代码//语句执行拦截
Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)

// 参数获取、设置时进行拦截
ParameterHandler (getParameterObject, setParameters)

// 对返回结果进行拦截
ResultSetHandler (handleResultSets, handleOutputParameters)

//sql语句拦截
StatementHandler (prepare, parameterize, batch, update, query)

简而言之,即在执行sql的整个周期中,我们可以任意切入到某一点对sql的参数、sql执行结果集、sql语句本身等进行切面处理。基于这个特性,我们便可以使用其对我们需要进行加密的数据进行切面统一加密处理了(分页插件 pageHelper 就是这样实现数据库分页查询的)。

二、实现基于注解的敏感信息加解密拦截器

2.1 实现思路

对于数据的加密与解密,应当存在两个拦截器对数据进行拦截操作

参照官方文档,因此此处我们应当使用ParameterHandler拦截器对入参进行加密

使用ResultSetHandler拦截器对出参进行解密操作。

目标需要加密、解密的字段可能需要灵活变更,此时我们定义一个注解,对需要加密的字段进行注解,那么便可以配合拦截器对需要的数据进行加密与解密操作了。

mybatis的interceptor接口有以下方法需要实现。

1
2
3
4
5
6
7
8
9
10
11
12
typescript复制代码public interface Interceptor {

//主要参数拦截方法
Object intercept(Invocation invocation) throws Throwable;

//mybatis插件链
default Object plugin(Object target) {return Plugin.wrap(target, this);}

//自定义插件配置文件方法
default void setProperties(Properties properties) {}

}

2.2 定义需要加密解密的敏感信息注解

定义注解敏感信息类(如实体类POJOPO)的注解

1
2
3
4
5
6
7
8
less复制代码/**
* 注解敏感信息类的注解
*/
@Inherited
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface SensitiveData {
}

定义注解敏感信息类中敏感字段的注解

1
2
3
4
5
6
7
8
less复制代码/**
* 注解敏感信息类中敏感字段的注解
*/
@Inherited
@Target({ ElementType.Field })
@Retention(RetentionPolicy.RUNTIME)
public @interface SensitiveField {
}

2.3 定义加密接口及其实现类

定义加密接口,方便以后拓展加密方法(如AES加密算法拓展支持PBE算法,只需要注入时指定一下便可)

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public interface EncryptUtil {

/**
* 加密
*
* @param declaredFields paramsObject所声明的字段
* @param paramsObject mapper中paramsType的实例
* @return T
* @throws IllegalAccessException 字段不可访问异常
*/
<T> T encrypt(Field[] declaredFields, T paramsObject) throws IllegalAccessException;
}

EncryptUtil 的AES加密实现类,此处AESUtil为自封装的AES加密工具,需要的小伙伴可以自行封装,本文不提供。(搜索公众号Java知音,回复“2021”,送你一份Java面试题宝典)

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
typescript复制代码@Component
public class AESEncrypt implements EncryptUtil {

@Autowired
AESUtil aesUtil;

/**
* 加密
*
* @param declaredFields paramsObject所声明的字段
* @param paramsObject mapper中paramsType的实例
* @return T
* @throws IllegalAccessException 字段不可访问异常
*/
@Override
public <T> T encrypt(Field[] declaredFields, T paramsObject) throws IllegalAccessException {
for (Field field : declaredFields) {
//取出所有被EncryptDecryptField注解的字段
SensitiveField sensitiveField = field.getAnnotation(SensitiveField.class);
if (!Objects.isNull(sensitiveField)) {
field.setAccessible(true);
Object object = field.get(paramsObject);
//暂时只实现String类型的加密
if (object instanceof String) {
String value = (String) object;
//加密 这里我使用自定义的AES加密工具
field.set(paramsObject, aesUtil.encrypt(value));
}
}
}
return paramsObject;
}
}

2.4 实现入参加密拦截器

Myabtis包中的org.apache.ibatis.plugin.Interceptor拦截器接口要求我们实现以下三个方法

1
2
3
4
5
6
7
8
9
10
11
12
typescript复制代码public interface Interceptor {

//核心拦截逻辑
Object intercept(Invocation invocation) throws Throwable;

//拦截器链
default Object plugin(Object target) {return Plugin.wrap(target, this);}

//自定义配置文件操作
default void setProperties(Properties properties) { }

}

因此,参考官方文档的示例,我们自定义一个入参加密拦截器。

@Intercepts 注解开启拦截器,@Signature 注解定义拦截器的实际类型。

@Signature中

type 属性指定当前拦截器使用StatementHandler 、ResultSetHandler、ParameterHandler,Executor的一种
method 属性指定使用以上四种类型的具体方法(可进入class内部查看其方法)。
args 属性指定预编译语句
此处我们使用了 ParameterHandler.setParamters()方法,拦截mapper.xml中paramsType的实例(即在每个含有paramsType属性mapper语句中,都执行该拦截器,对paramsType的实例进行拦截处理)

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
less复制代码/**
* 加密拦截器
* 注意@Component注解一定要加上
*
* @author : tanzj
* @date : 2020/1/19.
*/
@Slf4j
@Component
@Intercepts({
@Signature(type = ParameterHandler.class, method = "setParameters", args = PreparedStatement.class),
})
public class EncryptInterceptor implements Interceptor {

private final EncryptDecryptUtil encryptUtil;

@Autowired
public EncryptInterceptor(EncryptDecryptUtil encryptUtil) {
this.encryptUtil = encryptUtil;
}

@Override

@Override
public Object intercept(Invocation invocation) throws Throwable {
//@Signature 指定了 type= parameterHandler 后,这里的 invocation.getTarget() 便是parameterHandler
//若指定ResultSetHandler ,这里则能强转为ResultSetHandler
ParameterHandler parameterHandler = (ParameterHandler) invocation.getTarget();
// 获取参数对像,即 mapper 中 paramsType 的实例
Field parameterField = parameterHandler.getClass().getDeclaredField("parameterObject");
parameterField.setAccessible(true);
//取出实例
Object parameterObject = parameterField.get(parameterHandler);
if (parameterObject != null) {
Class<?> parameterObjectClass = parameterObject.getClass();
//校验该实例的类是否被@SensitiveData所注解
SensitiveData sensitiveData = AnnotationUtils.findAnnotation(parameterObjectClass, SensitiveData.class);
if (Objects.nonNull(sensitiveData)) {
//取出当前当前类所有字段,传入加密方法
Field[] declaredFields = parameterObjectClass.getDeclaredFields();
encryptUtil.encrypt(declaredFields, parameterObject);
}
}
return invocation.proceed();
}

/**
* 切记配置,否则当前拦截器不会加入拦截器链
*/
@Override
public Object plugin(Object o) {
return Plugin.wrap(o, this);
}

//自定义配置写入,没有自定义配置的可以直接置空此方法
@Override
public void setProperties(Properties properties) {
}
}

至此完成自定义加密拦截加密。

2.5 定义解密接口及其实现类

解密接口,其中result为mapper.xml中resultType的实例。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public interface DecryptUtil {

/**
* 解密
*
* @param result resultType的实例
* @return T
* @throws IllegalAccessException 字段不可访问异常
*/
<T> T decrypt(T result) throws IllegalAccessException;

}

解密接口AES工具解密实现类

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
typescript复制代码public class AESDecrypt implements DecryptUtil {

@Autowired
AESUtil aesUtil;

/**
* 解密
*
* @param result resultType的实例
* @return T
* @throws IllegalAccessException 字段不可访问异常
*/
@Override
public <T> T decrypt(T result) throws IllegalAccessException {
//取出resultType的类
Class<?> resultClass = result.getClass();
Field[] declaredFields = resultClass.getDeclaredFields();
for (Field field : declaredFields) {
//取出所有被EncryptDecryptField注解的字段
SensitiveField sensitiveField = field.getAnnotation(SensitiveField.class);
if (!Objects.isNull(sensitiveField)) {
field.setAccessible(true);
Object object = field.get(result);
//只支持String的解密
if (object instanceof String) {
String value = (String) object;
//对注解的字段进行逐一解密
field.set(result, aesUtil.decrypt(value));
}
}
}
return result;
}
}

2.6 定义出参解密拦截器

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
less复制代码@Slf4j
@Component
@Intercepts({
@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class})
})
public class DecryptInterceptor implements Interceptor {

@Autowired
DecryptUtil aesDecrypt;

@Override
public Object intercept(Invocation invocation) throws Throwable {
//取出查询的结果
Object resultObject = invocation.proceed();
if (Objects.isNull(resultObject)) {
return null;
}
//基于selectList
if (resultObject instanceof ArrayList) {
ArrayList resultList = (ArrayList) resultObject;
if (!CollectionUtils.isEmpty(resultList) && needToDecrypt(resultList.get(0))) {
for (Object result : resultList) {
//逐一解密
aesDecrypt.decrypt(result);
}
}
//基于selectOne
} else {
if (needToDecrypt(resultObject)) {
aesDecrypt.decrypt(resultObject);
}
}
return resultObject;
}

private boolean needToDecrypt(Object object) {
Class<?> objectClass = object.getClass();
SensitiveData sensitiveData = AnnotationUtils.findAnnotation(objectClass, SensitiveData.class);
return Objects.nonNull(sensitiveData);
}


@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}

@Override
public void setProperties(Properties properties) {

}
}

至此完成解密拦截器的配置工作。

3、注解实体类中需要加解密的字段


此时在mapper中,指定paramType=User resultType=User 便可实现脱离业务层,基于mybatis拦截器的加解密操作。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…686687688…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%