【SpringBoot】还在用 OpenFeign?来试试 SpringBoot3 中的这个新玩意!

InterviewCoder

# 【SpringBoot】还在用 OpenFeign?来试试 SpringBoot3 中的这个新玩意!

原文链接,侵删:【https://developer.aliyun.com/article/1233446】

# 1. 由来

Spring Boot3 去年底就已经正式发布,我也尝了一把鲜,最近有空会和小伙伴们慢慢聊聊 Spring Boot3 都给我们带来了哪些新东西。

今天我们就先来看看声明式 HTTP 接口。

用过 Spring Cloud 的小伙伴都知道,在 Spring Cloud 家族中,负责进程间通信的,我们可以使用 RestTemplate 或者 OpenFeign(当然也有其他方式如基于消息中间件的消息驱动的微服务或者基于 gRPC 的调用等)。

RestTemplate 我们可以将之当作一个普普通通的 HTTP 调用工具来对待,区别于其他的 HTTP 客户端,RestTemplate 用来调用 RESTful 风格的接口特别方便。

不过,比 RestTemplate 更加方便的是 OpenFeign,通过接口声明就可以实现远程调用,这些的具体用法松哥在之前的视频中讲过,这里就不再赘述了。

以前我们想要用声明式 HTTP 调用,需要通过 OpenFeign 来实现,这个需要第三方的依赖,从 Spring6 开始(Spring Boot3),Spring 自己提供了类似的功能通过 @HttpExchange 注解也能方便的实现 声明式 HTTP 调用。以后跨服务调用又多了一个选择。

# 2. 使用

接下来松哥通过一个案例来和小伙伴们演示一下 @HttpExchange 注解的具体玩法。

首先我们先创建一个普通的名为 server 的 Spring Boot 项目,这个普通的 Spring Boot 项目中只需要提供一个简单的测试接口即可,如下:

1
2
3
4
5
6
7
8
9
@RestController
public class HelloController {

@GetMapping("/server/hello")
public String hello(String name) {
return "hello " + name;
}

}

这个对大家来说应该是没什么难度的,我就不多说了。

现在假设我有另外一个服务名为 client,我想在 client 中调用 server 中提供的这个接口。

首先我们来创建 client 这个项目,大家注意,创建的时候我们不仅需要添加 Web 依赖,还需要 Reactive Web,因为这个 @HttpExchange 底层基于 WebClient,而 WebClient 则是 Reactive Web 提供的:

babcb7f6ba0129c08abf25ff933f0780.png

创建完成后,接下来我们就可以声明 Http 接口了:

1
2
3
4
5
@HttpExchange("/server")
public interface ToDoService {
@GetExchange("/hello")
String hello(@RequestParam String name);
}

这些用法跟我们在 SpringMVC 中常用的 @RequestMapping 和 @GetMapping 等特别类似:

@HttpExchange 类似于 @RequestMapping,可以将之放在类上,起到一个请求窄化的作用,也可以放在方法上,放在方法上我们可以通过 method 属性来指定具体的请求方法,这个也跟 @RequestMapping 类似: @HttpExchange (value = “/server”,method = “GET”)。

@GetExchange 类似于 @GetMapping,这个就不再赘述了,其他类似的注解还有 @DeleteExchange、@PatchExchange、@PostExchange、@PutExchange 等。

另外需要注意的是请求方法的参数需要加上 @RequestParam 注解,这一点和 OpenFeign 比较类似。

接口声明好之后还没完,我们还需要配置一下才能使用。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class WebConfig {
@Bean
WebClient webClient() {
return WebClient.builder()
.baseUrl("http://localhost:8080")
.build();
}
@Bean
ToDoService toDoService() {
HttpServiceProxyFactory httpServiceProxyFactory =
HttpServiceProxyFactory.builder(WebClientAdapter.forClient(webClient()))
.build();
return httpServiceProxyFactory.createClient(ToDoService.class);
}
}

这个配置主要是两方面:

@HttpExchange 是基于 WebClient 的,所以我们首先需要配置 WebClient,配置 WebClient 的时候,也顺便配置了请求的具体地址(因为在 @HttpExchange 注解中并未指定请求的具体域名端口啥的);同时,对于 HTTP 请求头等如果需要定制,也是通过配置 WebClient 来实现的。

由于我们前面提供的 ToDoService 是一个接口,所以我们还需要提供一个该接口的实现类,当然这个配置完全是套路化模版化的,这块就没啥好说了。

全部配置完成后,接下来我们就可以在任何需要的地方,直接注入 ToDoService 的实例去使用了,举一个简单的例子小伙伴们参考下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@SpringBootTest
class ClientApplicationTests {

@Autowired
ToDoService toDoService;

@Test
void contextLoads() {
String hello = toDoService.hello("javaboy");
System.out.println("hello = " + hello);
}

}

好啦,一个简单的例子,小伙伴们不妨体验下。

以后,不用 OpenFeign 也能实现声明式服务调用啦~

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【SpringBoot】SpringBoot项目使用@@pom文件配置

InterviewCoder

# 【SpringBoot】SpringBoot 项目使用 @@pom 文件配置

在引用 pom 文件配置时需要进行配置才可以使用 @@

1
2
3
4
5
6
7
8
9
<build>
<!-- 使用 @@ application.yml 获取pom文件中的配置 -->
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>
1
2
3
spring:
profiles:
active: @profile.active@

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【SpringBoot】SpringBoot下@EventListener注解使用及源码解析

InterviewCoder

# 【SpringBoot】SpringBoot 下 @EventListener 注解使用及源码解析

原文链接,侵删:【https://blog.csdn.net/baidu_19473529/article/details/97646739】

# 一、简介
  • @EventListener 是一种事件驱动编程在 spring4.2 的时候开始有的,早期可以实现 ApplicationListener 接口,想了解下 ApplicationListener 的可以参考下这篇文章 https://blog.csdn.net/baidu_19473529/article/details/86683365Spring 为我们提供的一个事件监听、订阅的实现,内部实现原理是观察者设计模式;为的就是业务系统逻辑的解耦,提高可扩展性以及可维护性。事件发布者并不需要考虑谁去监听,监听具体的实现内容是什么,发布者的工作只是为了发布事件而已。
  • 比如我们做一个电商系统,用户下单支付成功后,我们一般要发短信或者邮箱给用户提示什么的,这时候就可以把这个通知业务做成一个单独事件监听,等待通知就可以了;把它解耦处理。
# 二、使用 @EventListener 注解
  • 建立事件对象,当调用 publishEvent 方法是会通过这个 bean 对象找对应事件的监听。AddDataEvent.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
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
package com.rw.article.pay.event.bean;

import org.springframework.context.ApplicationEvent;

/**
* @author Zhou Zhong Qing
* @Title: ${file_name}
* @Package ${package_name}
* @Description: 新增mongodb数据事件
* @date 2018/10/18 16:26
*/
public class AddDataEvent extends ApplicationEvent {

public AddDataEvent(Object source) {
super(source);
}
public AddDataEvent(Object source, Class clz, Object data) {
super(source);
this.clz = clz;
this.data = data;
}

public AddDataEvent(Object source, Class clz, Object data, String modelName, String userAgent) {
super(source);
this.clz = clz;
this.data = data;
this.modelName = modelName;
this.userAgent = userAgent;
}



/** 要更新的表对象 **/
private Class clz;

/** 操作的数据**/
private Object data;


/** 模块名称**/
private String modelName;

/** 浏览器标识 **/
private String userAgent;


public Class getClz() {
return clz;
}

public void setClz(Class clz) {
this.clz = clz;
}

public Object getData() {
return data;
}

public void setData(Object data) {
this.data = data;
}

public String getModelName() {
return modelName;
}

public void setModelName(String modelName) {
this.modelName = modelName;
}

public String getUserAgent() {
return userAgent;
}

public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
}
  • 对应的监听 AddDataEventListener .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
34
35
36
37
38
package com.rw.article.pay.event.listener;
import com.alibaba.fastjson.JSON;
import com.rw.article.pay.event.bean.AddDataEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

/**
* @author Zhou Zhong Qing
* @Title: ${file_name}
* @Package ${package_name}
* @Description: 新增数据的事件监听
* @date 2018/10/18 16:29
*/
@Component
public class AddDataEventListener {
private static Logger log = LoggerFactory.getLogger(AddDataEventListener.class);

/*
* 在AnnotationConfigUtils#registerAnnotationConfigProcessors注册了BeanDefinition 对应的是EventListenerMethodProcessor对象 , AnnotationConfigUtils在AnnotationConfigServletWebServerApplicationContext构造方法里被加载
* */

/**
* DefaultListableBeanFactory#中preInstantiateSingletons -> (beanName为org.springframework.context.event.internalEventListenerProcessor时得到EventListenerMethodProcessor)EventListenerMethodProcessor#afterSingletonsInstantiated this.processBean(factories, beanName, type)
* 然后把要执行的方法封装为ApplicationListenerMethodAdapter -> 添加到listener中 AbstractApplicationEventMulticaster#addApplicationListener
* */
// 该方法在 ApplicationListenerMethodAdapter 利用反射执行
/**
* 处理新增数据的事件
**/
@EventListener
public void handleAddEvent(AddDataEvent event) {
log.info("发布的data为:{} ", JSON.toJSONString(event));

}
}
  • 建立测试类
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.rw.article.pay.action;

import com.rw.article.pay.event.bean.AddDataEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.annotation.Resource;

/**
* @author Zhou Zhong Qing
* @Title: ${file_name}
* @Package ${package_name}
* @Description: 测试的controller
* @date 2019/7/24 17:13
*/
@Controller
@RequestMapping("/test")
public class TestController {


@Resource
private ApplicationContext applicationContext;


@ResponseBody
@RequestMapping("/testListener")
public String testListener(){
applicationContext.publishEvent(new AddDataEvent(this,TestController.class,"test"));
return "success";
}
}
  • 结果是能够监听到的
    在这里插入图片描述
  • 如果要使用异步加上 @EnableAsync 注解,方法上加 @Async 注解,如下 spring boot 项目配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@SpringBootApplication
@EnableAsync
public class XApplication{
public static void main(String[] args) {
ConfigurableApplicationContext run = new SpringApplicationBuilder(XApplication.class).web(true).run(args);
run.publishEvent("test");
}
}

@Async
@EventListener
public void test(String wrapped){
System.out.println("当前线程 "+Thread.currentThread().getName());
System.out.println(wrapped);
}

  • 还可以配置线程池 taskExecutor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
public class GenericConfiguration {

@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
//核心线程数:线程池创建时候初始化的线程数
//最大线程数:线程池最大的线程数,只有在缓冲队列满了之后才会申请超过核心线程数的线程
//缓冲队列:用来缓冲执行任务的队列
//允许线程的空闲时间60秒:当超过了核心线程出之外的线程在空闲时间到达之后会被销毁
//线程池名的前缀:设置好了之后可以方便我们定位处理任务所在的线程池
//线程池对拒绝任务的处理策略:这里采用了CallerRunsPolicy策略,当线程池没有处理能力的时候,该策略会直接在 execute 方法的调用线程中运行被拒绝的任务;如果执行程序已关闭,则会丢弃该任务
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(20);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("taskExecutor-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
}
# 三、源码解析
  • 原理还得从 org.springframework.context.event.internalEventListenerProcessor
    说起。
  • 在 AnnotationConfigUtils#registerAnnotationConfigProcessors 注册了 BeanDefinition 对应的是 EventListenerMethodProcessor 对象 , 而 AnnotationConfigUtils 是在 AnnotationConfigServletWebServerApplicationContext 构造方法里被加载。这里要提一下 AnnotationConfigServletWebServerApplicationContext,他是 spring boot 启动入口的重要类 (我这里用的是 spring boot 所以是这个类), 可以相当于以前用 xml 的 ClassPathXmlApplicationContext。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 public static final String EVENT_LISTENER_PROCESSOR_BEAN_NAME =
"org.springframework.context.event.internalEventListenerProcessor";

public static Set<BeanDefinitionHolder> registerAnnotationConfigProcessors(
BeanDefinitionRegistry registry, @Nullable Object source) {

...................

// 注册EventListenerMethodProcessor对象
if (!registry.containsBeanDefinition(EVENT_LISTENER_PROCESSOR_BEAN_NAME)) {
RootBeanDefinition def = new RootBeanDefinition(EventListenerMethodProcessor.class);
def.setSource(source);
beanDefs.add(registerPostProcessor(registry, def, EVENT_LISTENER_PROCESSOR_BEAN_NAME));
}
...........................

return beanDefs;
}
  • 注册的 EventListenerMethodProcessor 对象会在初始化非懒加载对象的时候运行它的 afterSingletonsInstantiated 方法。
    AbstractApplicationContext#finishBeanFactoryInitialization
1
2
3
4
5
6
 protected void finishBeanFactoryInitialization(ConfigurableListableBeanFactory beanFactory) {

.............
// 初始化非懒加载对象
beanFactory.preInstantiateSingletons();
}
  • DefaultListableBeanFactory#preInstantiateSingletons
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 @Override
public void preInstantiateSingletons() throws BeansException {
..................

// 触发所有适用bean的初始化后回调 主要是afterSingletonsInstantiated方法
for (String beanName : beanNames) {
//如果beanName传入org.springframework.context.event.internalEventListenerProcessor 因为已经上面代码已经初始化,将从缓存中得到一个EventListenerMethodProcessor对象
Object singletonInstance = getSingleton(beanName);
if (singletonInstance instanceof SmartInitializingSingleton) {
final SmartInitializingSingleton smartSingleton = (SmartInitializingSingleton) singletonInstance;
if (System.getSecurityManager() != null) {
AccessController.doPrivileged((PrivilegedAction<Object>) () -> {
smartSingleton.afterSingletonsInstantiated();
return null;
}, getAccessControlContext());
}
else {
// 调用其afterSingletonsInstantiated方法
smartSingleton.afterSingletonsInstantiated();
}
}
}
}

  • EventListenerMethodProcessor#afterSingletonsInstantiated
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
 @Override
public void afterSingletonsInstantiated() {
List<EventListenerFactory> factories = getEventListenerFactories();
ConfigurableApplicationContext context = getApplicationContext();
String[] beanNames = context.getBeanNamesForType(Object.class);
for (String beanName : beanNames) {
if (!ScopedProxyUtils.isScopedTarget(beanName)) {
Class<?> type = null;
try {
type = AutoProxyUtils.determineTargetClass(context.getBeanFactory(), beanName);
}
catch (Throwable ex) {
// An unresolvable bean type, probably from a lazy bean - let's ignore it.
if (logger.isDebugEnabled()) {
logger.debug("Could not resolve target class for bean with name '" + beanName + "'", ex);
}
}
if (type != null) {
if (ScopedObject.class.isAssignableFrom(type)) {
try {
Class<?> targetClass = AutoProxyUtils.determineTargetClass(
context.getBeanFactory(), ScopedProxyUtils.getTargetBeanName(beanName));
if (targetClass != null) {
type = targetClass;
}
}
catch (Throwable ex) {
// An invalid scoped proxy arrangement - let's ignore it.
if (logger.isDebugEnabled()) {
logger.debug("Could not resolve target bean for scoped proxy '" + beanName + "'", ex);
}
}
}
try {
// 重点是这个方法 处理bean
processBean(factories, beanName, type);
}
catch (Throwable ex) {
throw new BeanInitializationException("Failed to process @EventListener " +
"annotation on bean with name '" + beanName + "'", ex);
}
}
}
}
}

  • EventListenerMethodProcessor#processBean; 这里有一个重要的类就是 ApplicationListenerMethodAdapter,spring 把加入了 @EventListener 注解的方法封装进 ApplicationListenerMethodAdapter 对象里,然后我们 publishEvent 方法是,其实是调用的对应的 ApplicationListenerMethodAdapter, 然后里面是执行这个方法,这里可以看下 ApplicationListenerMethodAdapter 类的属性。
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
public class ApplicationListenerMethodAdapter implements GenericApplicationListener {

protected final Log logger = LogFactory.getLog(getClass());

private final String beanName;

private final Method method;

private final Method targetMethod;

private final AnnotatedElementKey methodKey;

private final List<ResolvableType> declaredEventTypes;

@Nullable
private final String condition;

private final int order;

@Nullable
private ApplicationContext applicationContext;

@Nullable
private EventExpressionEvaluator evaluator;
..................................
}

protected void processBean(
final List<EventListenerFactory> factories, final String beanName, final Class<?> targetType) {

if (!this.nonAnnotatedClasses.contains(targetType)) {
Map<Method, EventListener> annotatedMethods = null;
try {
// 拿到使用了@EventListener注解的方法
annotatedMethods = MethodIntrospector.selectMethods(targetType,
(MethodIntrospector.MetadataLookup<EventListener>) method ->
AnnotatedElementUtils.findMergedAnnotation(method, EventListener.class));
}
catch (Throwable ex) {
// An unresolvable type in a method signature, probably from a lazy bean - let's ignore it.
if (logger.isDebugEnabled()) {
logger.debug("Could not resolve methods for bean with name '" + beanName + "'", ex);
}
}
if (CollectionUtils.isEmpty(annotatedMethods)) {
this.nonAnnotatedClasses.add(targetType);
if (logger.isTraceEnabled()) {
logger.trace("No @EventListener annotations found on bean class: " + targetType.getName());
}
}
else {
// Non-empty set of methods
ConfigurableApplicationContext context = getApplicationContext();
for (Method method : annotatedMethods.keySet()) {
for (EventListenerFactory factory : factories) {
// 判断是否支持该方法 这里用的DefaultEventListenerFactory spring5.0.8 写死的返回true
if (factory.supportsMethod(method)) {
//选择方法 beanName 这里是AddDataEventListener的beanName 默认是addDataEventListener
Method methodToUse = AopUtils.selectInvocableMethod(method, context.getType(beanName));
// 这里是创建一个ApplicationListenerMethodAdapter对象
ApplicationListener<?> applicationListener =
factory.createApplicationListener(beanName, targetType, methodToUse);
if (applicationListener instanceof ApplicationListenerMethodAdapter) {
// 如果是ApplicationListenerMethodAdapter对象 就把context和evaluator传进去
((ApplicationListenerMethodAdapter) applicationListener).init(context, this.evaluator);
}

// 添加到ApplicationListener事件Set集合中去
context.addApplicationListener(applicationListener);
break;
}
}
}
if (logger.isDebugEnabled()) {
logger.debug(annotatedMethods.size() + " @EventListener methods processed on bean '" +
beanName + "': " + annotatedMethods);
}
}
}
}
  • 后面就是触发事件监听了 AbstractApplicationContext#publishEvent
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
 @Override
public void publishEvent(ApplicationEvent event) {
publishEvent(event, null);
}


protected void publishEvent(Object event, @Nullable ResolvableType eventType) {

..............................
// Multicast right now if possible - or lazily once the multicaster is initialized
if (this.earlyApplicationEvents != null) {
this.earlyApplicationEvents.add(applicationEvent);
}
else {
// 进入multicastEvent
getApplicationEventMulticaster().multicastEvent(applicationEvent, eventType);
}

// Publish event via parent context as well...
if (this.parent != null) {
if (this.parent instanceof AbstractApplicationContext) {
((AbstractApplicationContext) this.parent).publishEvent(event, eventType);
}
else {
this.parent.publishEvent(event);
}
}
}
  • SimpleApplicationEventMulticaster#multicastEvent->invokeListener->doInvokeListener
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void doInvokeListener(ApplicationListener listener, ApplicationEvent event) {
try {
listener.onApplicationEvent(event);
}
catch (ClassCastException ex) {
String msg = ex.getMessage();
if (msg == null || matchesClassCastMessage(msg, event.getClass().getName())) {
// Possibly a lambda-defined listener which we could not resolve the generic event type for
// -> let's suppress the exception and just log a debug message.
Log logger = LogFactory.getLog(getClass());
if (logger.isDebugEnabled()) {
logger.debug("Non-matching event type for listener: " + listener, ex);
}
}
else {
throw ex;
}
}
}
  • ApplicationListenerMethodAdapter#onApplicationEvent
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public void onApplicationEvent(ApplicationEvent event) {
processEvent(event);
}
ApplicationListenerMethodAdapter#processEvent
public void processEvent(ApplicationEvent event) {
Object[] args = resolveArguments(event);
if (shouldHandle(event, args)) {
// 执行真正的方法
Object result = doInvoke(args);
if (result != null) {
handleResult(result);
}
else {
logger.trace("No result object given - no result to handle");
}
}
}
  • ApplicationListenerMethodAdapter#doInvoke
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 protected Object doInvoke(Object... args) {
Object bean = getTargetBean();
ReflectionUtils.makeAccessible(this.method);
try {
return this.method.invoke(bean, args);
}
catch (IllegalArgumentException ex) {
assertTargetBean(this.method, bean, args);
throw new IllegalStateException(getInvocationErrorMessage(bean, ex.getMessage(), args), ex);
}
catch (IllegalAccessException ex) {
throw new IllegalStateException(getInvocationErrorMessage(bean, ex.getMessage(), args), ex);
}
catch (InvocationTargetException ex) {
// Throw underlying exception
Throwable targetException = ex.getTargetException();
if (targetException instanceof RuntimeException) {
throw (RuntimeException) targetException;
}
else {
String msg = getInvocationErrorMessage(bean, "Failed to invoke event listener method", args);
throw new UndeclaredThrowableException(targetException, msg);
}
}
}
  • ApplicationListenerMethodAdapter#getTargetBean
1
2
3
4
 protected Object getTargetBean() {
Assert.notNull(this.applicationContext, "ApplicationContext must no be null");
return this.applicationContext.getBean(this.beanName);
}
  • 至此执行这个事件监听的方法执行完毕。如果文字有误的地方,希望批评指正,感谢您的观看。

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【IDEA】解决IDEA中的CTRL+ 快捷注释键不自动缩进

InterviewCoder

# 【IDEA】解决 IDEA 中的 CTRL+ 快捷注释键不自动缩进

原文链接,侵删:【https://blog.csdn.net/qq_41157588/article/details/106823214】

# 1、情况概述

如下图所示情况
今天在安装了 idea2020.1 后,在 xml 中使用了 ctrl+/ 快捷键注释后,看到如下图情景 很让人难受,在写代码过程中每次还得在手动缩进一下,或者进行删减空格,大大的影响开发的效率。

java 注释代码块的问题 如下图
在这里插入图片描述
xml 注释代码块的问题 如下图
在这里插入图片描述

# 2、解决方法

# .java 快捷注释不缩进解决方法

idea 中 点击 file–>settings --> Editor --> CodeStyle–> Java
选择 Code Generation 进行如下设置
在这里插入图片描述
在这里插入图片描述

# .xml 快捷注释不缩进解决方法

idea 中 点击 file–>settings --> Editor --> CodeStyle–> xml
选择 Code Generation 进行如下设置
在这里插入图片描述
在这里插入图片描述
配置完毕后 再次 xml 中使用快捷键后 正常注释显示

# .html 快捷注释不缩进解决方法

情况如下,每次 ctrl+/ 的注释都靠在最左侧
在这里插入图片描述
idea 中 点击 file–>settings --> Editor --> CodeStyle–> html
选择 Code Generation 进行如下设置

在这里插入图片描述

设置好之后,快捷注释不缩进解决,如有帮助到您,还请点个赞,谢谢。

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【Java】Win系统下, 命令提示符输出Java出现乱码

InterviewCoder

# 【Java】Win 系统下,命令提示符输出 Java 出现乱码

如何所示:

img

出现英文正常, 中文乱码的这种情况,一般是时区不对或者是编码不对。那我们看看怎么解决吧。

# 一、编码

如果是编码的话,就比较好解决了。

使用 -Dfile.encoding=UTF-8 这个代码只能解决指定文件的编码问题,从截图上可以看出,这不只是单个 java 文件的编码问题了;

解决方法如下:

1、添加新的环境变量,让每个 java 文件都去执行同一个编码。

鼠标右键此电脑 -> 属性 -> 高级系统设置。

img

以 Windows 10 21H2 版本为例,在关于的右侧就可以看到高级系统设置 (不同系统有细微差别,不过名字都是叫高级系统设置)。

2、打开高级系统设置 -> 高级 -> 环境变量,步骤如图:

img

3、 打开环境变量我们可以看到有两个框,一个用户变量,一个是系统变量。我们这边添加一个新的系统变量,在系统变量下点击新建,如图所示:

img

4、 添加的环境变量为:
变量名: JAVA_TOOL_OPTIONS
变量值: -Dfile.encoding=UTF-8

如图所示:

img

5、添加完成后,点击确定,在点击环境变量对话框的确定按钮。这时我们再去 cmd 里面查看一下是否有用 (PS: 需要重新打开 cmd 噢)。如图:

img

好了,我们成功了。PS: 有些电脑设置的环境变量是需要重启电脑才能生效的,如果没用的小伙伴可以重启电脑试试。

# 二、时区

有些小伙伴可以自己在使用电脑时,因为某些问题改过时区,这样也可以会导致出现乱码的问题,我们只需要改回来即可。

1、点开设置 (Windows 设置) -> 时间和语言

img

2、点击语言 -> 管理语言设置

img

3、在弹出的区域对话框中点击管理 -> 更改系统区域设置

img

4、在这里我们选择中文 (简体,中国),并且 Beta 版的框取消掉 (也就是没有打钩)。

img

5、点击确定,提示我们是否重启计算机,我们重启计算机。

等重启好后,再次去 cmd 中测试,这时的编码就正常了。

img

以上两种方法都可以实行。谢谢大家的查阅,希望对你有所帮助。

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

Github自定义个人首页

InterviewCoder

# Github 自定义个人首页

原文地址,侵删【https://blog.csdn.net/a2360051431/article/details/130945944】

# 前言

GitHub 个人主页,官方称呼是 profile,是一个以 Markdown 脚本语言编写的个人 GitHub 展示主页面。Guthub 个人主页可以展示很多有用的信息,例如添加一个首页被访问次数的计数器,一个 Github 被 Star 与 Commit 的概览信息,以及各种技能标签,设备标签等,还可以利用 wakatime 显示你最近编码各类语言的使用时长,以及你最近 Steam 游戏游玩排行榜。本文主要介绍了如何创建 Github 个人主页以及美化个人主页,通过使用一些功能组件,提升主页功能性和视觉效果。

由于无法解决国内 GitHub API 访问困难的问题,文中的图片和动图均为静态,能够科学上网的同学可以尝试访问代码中的链接查看效果

# 创建 Github 主页

首先登陆 Github,创建一个新的公开仓库,仓库名和自己 Github 用户名相同,并且添加一个 README.md 自述文件

请添加图片描述
当创建的仓库名称与 Github 账户名称相同的时候,下面会弹出对话框,内容如下:

xxxx/xxxx is a ✨special ✨ repository that you can use to add a README.md to your GitHub profile. Make sure it’s public and initialize it with a README to get started.

同名仓库创建成功以后,点击 Github 右上角的头像,然后选择 “Your profile” 即可看到刚刚初始化的 README, 点击 README.md 右上角的编辑图标即可进入编辑模式。在编辑程中想要实时预览,点击左上角的 preview 按钮即可。

Github 提供了这样一个特殊的 Markdown 文件以供我们 DIY 主页,扩展性很高,如果想要定制化更高的排版形式,还可以使用 HTML,CSS 语法。如果不熟悉 Markdown 语法,可以参考这篇文章一篇文章搞懂 Markdown,或者直接跳转到本文 Profile 编辑器部分,该部分介绍了一个可视化的编辑工具,省去了 Markdown 脚本编写,只需要勾选相应的选项即可自动生成 profile。

# GitHub Stats Card(GitHub 统计卡片)

在 README 文件中添加下面的代码,可以得到关于仓库 Star 、提交、贡献等统计信息:

1
<div align="center"> <img src="https://github-readme-stats.vercel.app/api?username=yang-tian-hub&show_icons=true&theme=tokyonight" /> </div>

请添加图片描述
url 中 username 为你的 Github 用户名,详情可查看 Github readme stats 官方文档,其实 Github readme stats 这个开源项目是用来用于在个人 Github 页面上的 Readme 介绍中动态展示自己的 Github 活跃状态的,不过因为它本质上只是提供一个接口可以返回对应的图片,所以可以用到其他很多地方。

# Most used languages(GitHub 使用语言统计)

在 README 文件中添加下面的代码,可以得到使用编程语言对比统计图:

1
<div align="center"> <img src="https://github-readme-stats.vercel.app/api/top-langs/?username=yang-tian-hub" /> </div>

请添加图片描述
同样,具体的风格可查看上面的官方文档。

# GitHub streak(GitHub 连续打卡)

在 README 中展示您连续提交代码的次数,官方文档

1
<div align="center"> <img src="https://github-readme-streak-stats.herokuapp.com/?user=yang-tian-hub" /> </div>

请添加图片描述

# GitHub Readme Activity Graph(GitHub 活动统计图)

动态生成的活动图,用于显示您过去 31 天的 GitHub 活动,官方文档

1
<div align="center"> <img src="https://github-readme-activity-graph.vercel.app/graph?username=yang-tian-hub&theme=xcode" /> </div>

在这里插入图片描述

# 添加徽标

相比于纯文字,徽标显得更显眼,并且有助于提炼关键词,避免写一堆口水话。徽标的生成也很简单,在 shields.io 网站上填想要生成的内容就行了。

比如这个代码:

1
https://img.shields.io/badge/python-3.9-orange

可以生成下面的徽标:

img

也可以通过接口修改外观:

1
https://img.shields.io/badge/python-3.9-orange?style=for-the-badge&logo=python&logoColor=orange

img

# 首页计数器

这个很容易实现,只需要一个统计资源请求的后台服务即可,有很多第三方的服务可以使用,例如

1
<div align="center"> <img src="https://visitor-badge.glitch.me/badge?page_id=yang-tian-hub" /> </div>

img

page_id 后面替换为自己的 Github 用户名

1
<div align="center"> <img src="https://profile-counter.glitch.me/yang-tian-hub/count.svg" /> </div>

img

将 yang-tian-hub 替换为个人 Github 用户名

这些徽标会实时改变,记录 Github 个人主页被访问的次数

# 打字特效

Readme Typing SVG,可以生成循环打字的动图,如下:

1
<div align="center"> <img src="https://readme-typing-svg.herokuapp.com/?lines=今日事,今日毕!;活着就是一个个无可替代的;日子的累积;小杨同学祝您今天愉快!&center=true&font=Roboto&size=27" /></div>

在这里插入图片描述

1
<div align="center"> <a href="https://blog.ytadx.cn/"> <img src="https://readme-typing-svg.herokuapp.com/?lines=今日事,今日毕!;活着就是一个个无可替代的;日子的累积;小杨同学祝您今天愉快!&center=true&size=27"> </a> </div>

在这里插入图片描述

# 全面支持 emoji

下面内容可以直接复制来用,emoji 不是图片,所以可以任意字号展示,这里只是一部分,并不是全部:

😀😃😄😁😆😅🤣😂🙂🙃😉😊😇🥰😍🤩😘😚😙😋😛😜🤪😝🤑🤗🤭🤫🤔🤐🤨😐😑😶😏😒🙄😬🤥😌😔😪🤤😴😷🤒🤕🤢🤮🤧🥵🥶🥴😵🤯🤠🥳😎🤓🧐😕😟🙁☹️😮😯😲😳🥺😦😧😨😰😥😢😭😱😖😣😞😓😩😫🥱😤😡😠🤬

👶🧒👦👧🧑👱👨🧔👨‍🦰👨‍🦱👨‍🦳👨‍🦲👩👩‍🦰🧑👩‍🦱🧑👩‍🦳🧑👩‍🦲🧑👱‍♀️👱‍♂️🧓👴👵🙍🙍‍♂️🙍‍♀️🙎🙎‍♂️🙎‍♀️🙅🙅‍♂️🙅‍♀️🙆🙆‍♂️🙆‍♀️💁💁‍♂️💁‍♀️🙋🙋‍♂️🙋‍♀️🧏🧏‍♂️🧏‍♀️🙇🙇‍♂️🙇‍♀️🤦‍♂️🤦‍♀️🤷‍♀️👨‍⚕️👩‍⚕️👨‍🎓👩‍🎓🧑‍🏫

👋🤚🖐️✋🖖👌🤏✌️🤞🤟🤘🤙👈👉👆🖕👇☝️👍👎✊👊🤛🤜👏🙌👐🤲🤝🙏✍️💅🤳💪

👣👀👁️👄💋👂🦻👃👅🧠🦷🦴💪🦾🦿🦵🦶👓🕶️🥽🥼🦺👔👕👖🧣🧤🧥🧦👗👘🥻🩱🩲🩳👙👚👛👜👝🎒👞👟🥾🥿👠👡🩰👢👑👒🎩🎓🧢⛑️💄💅💍💼🌂☂️💈🛀🛌💥💫💦💨

⬆️➡️⬇️⬅️↩️↪️⤴️⤵️🔃🔄🔙🔚🔛🔜🔝🛐⚛️🕉️✡️️☯️✝️☦️☪️☮️🕎🔯♈♉♊♋♌♍♎♏♐♑♒♓⛎🔀🔁🔂▶️⏩⏭️⏯️◀️⏪⏮️🔼⏫🔽⏬⏸️⏹️⏺️⏏️🎦✖️➕➖➗♾️⁉️❓❔❕❗💱💲⚕️♻️️🔱📛🔰⭕✅☑️✔️❌❎➰➿✳️✴️❇️#️⃣*️⃣0️⃣1️⃣2️⃣3️⃣4️⃣5️⃣6️⃣7️⃣8️⃣9️⃣🔟🅰️🆎🅱️🆑🉐🈚🈲🉑🈸🈴🈳㊗️㊙️🈺🈵🔴🟠🟡🟢🔵🟣🟤⚫⚪🟥🟧🟨🟩🟦🟪🟫⬛⬜◼️◻️◾◽▪️▫️🔶🔷🔸🔹🔺🔻💠🔘🔳🔲🏁🚩🎌🏴🏳️🏳️‍🌈🏳️‍⚧️🏴‍☠️

🙈🙉🙊💥💫💦💨🐵🐒🦍🦧🐶🐕🦮🐕‍🦺🐩🐺🦊🦝🐱🐈🐈‍⬛🦁🐯🐅🐆🐴🐎🦄🦓🦌🐮🐂🐃🐄🐷🐖🐗🐽🐏🐑🐐🐪🐫🦙🦒🐘🦏🦛🐭🐁🐀🐹🐰🐇🐿️🦔🦇🐻🐻‍❄️🐨🐼🦥🦦🦨🦘🦡🐾🦃🐔🐓🐣🐤🐥🐦🐧🕊️🦅🦆🦢🦉🦩🦚🦜🐸🐊🐢🦎🐍🐲🐉🦕🦖🐳🐋🐬🐟🐠🐡🦈🐙🐚🐌🦋🐛🐜🐝🐞🦗🕷️🕸️🦂🦟🦠🦀🦞🦐🦑

💐🌸💮🏵️🌹🥀🌺🌻🌼🌷🌱🌲🌳🌴🌵🌾🌿☘️🍀🍁🍂🍃

🌍🌎🌏🌐🌑🌒🌓🌔🌕🌖🌗🌘🌙🌚🌛🌜☀️🌝🌞⭐🌟🌠☁️⛅⛈️🌤️🌥️🌦️🌧️🌨️🌩️🌪️🌫️🌬️🌈☂️☔⚡❄️☃️⛄☄️🔥💧🌊

🍇🍈🍉🍊🍋🍌🍍🥭🍎🍏🍐🍑🍒🍓🥝🍅🥥🥑🍆🥔🥕🌽🌶️🥒🥬🥦🧄🧅🍄🥜🌰🍞🥐🥖🥨🥯🥞🧇🧀🍖🍗🥩🥓🍔🍟🍕🌭🥪🌮🌯🥙🧆🥚🍳🥘🍲🥣🥗🍿🧈🧂🥫🍱🍘🍙🍚🍛🍜🍝🍠🍢🍣🍤🍥🥮🍡🥟🥠🥡🦪🍦🍧🍨🍩🍪🎂🍰🧁🥧🍫🍬🍭🍮🍯🍼🥛☕🍵🍶🍾🍷🍸🍹🍺🍻🥂🥃🥤🧃🧉🧊🥢🍽️🍴🥄

🧗‍♀️🤺🏇⛷️🏂🏌️🏌️‍♂️🏌️‍♀️🏄🏄‍♂️🏄‍♀️🚣‍♀️🏊‍♀️⛹️⛹️‍♂️⛹️‍♀️🏋️🏋️‍♂️🚴🚵‍♀️🤸🤼‍♀️🤽🤾‍♀️🤹🧘‍♀️🎪🛹🛼🛶🎗️🎟️🎫🎖️🏆🏅🥇🥈🥉⚽⚾🥎🏀🏐🏈🏉🎾🥏🎳🏏🏑🏒🥍🏓🏸🥊🥋🥅⛳⛸️🎣🎽🎿🛷🥌🎯🎱🎮🎰🎲🧩♟️🎭🎨🧵🧶🎼🎤🎧🎷🎸🎹🎺🎻🥁🎬🏹

😈👿👹👺💀☠👻👽👾💣

👣🎠🎡🎢🚣🏔️⛰️🌋🗻🏕️🏖️🏜️🏝️🏞️🏟️🏛️🏗️🏘️🏚️🏠🏡🏢🏣🏤🏥🏦🏨🏩🏪🏫🏬🏭🏯🏰💒🗼🗽⛪🕌🛕🕍⛩🕋⛲⛺🌁🌃🏙️🌄🌅🌆🌇🌉🎠🎡🎢🚂🚃🚄🚅🚆🚇🚈🚉🚊🚝🚞🚋🚌🚍🚎🚐🚑🚒🚓🚔🚕🚖🚗🚘🚙🚚🚛🚜🏎️🏍️🛵🛺🚲🛴🚏🛣️🛤️⛽🚨🚥🚦🚧⚓⛵🚤🛳️⛴️🛥️🚢✈️🛩️🛫🛬🪂💺🚁🚟🚠🚡🛰️🚀🛸🪐🌠🌌⛱️🎆🎇🎑💴💵💶💷🗿🛂🛃🛄🛅🧭

💌💎🔪💈🚪🚽🚿🛁⌛⏳⌚⏰🎈🎉🎊🎎🎏🎐🎀🎁📯📻📱📲☎📞📟📠🔋🔌💻💽💾💿📀🎥📺📷📹📼🔍🔎🔬🔭📡💡🔦🏮📔📕📖📗📘📙📚📓📃📜📄📰📑🔖💰💴💵💶💷💸💳✉📧📨📩📤📥📦📫📪📬📭📮📝📁📂📅📆📇📈📉📊📋📌📍📎📏📐✂🔒🔓🔏🔐🔑🔨🔫🔧🔩🔗💉💊🚬🔮🚩🎌💦💨

💘❤💓💔💕💖💗💙💚💛💜💝💞💟

# 动态更新

GitHub Actions 是 GitHub 官方推出的持续集成 / 部署模块服务(CI/CD)。GitHub Actions 自带云环境运行,包括私有仓库也可以享用,只需一个配置文件即可自动开启此服务。

说白了就相当于你白嫖了一个简易的服务器,他提供了一个配置文件,你在配置文件里书写脚本就可以定时的执行某项任务了。

# 1. 为 GitHub 首页添加贪吃蛇动画

首先在仓库页面点击 Actions 按钮,并新建一个 workflows 工作流,Github 会默认为 Actions 添加配置文件 blank.yml,我们只需要修改这个文件的名字并书写我们自己的脚本即可。

配置如下代码:

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
#Github Action for generating a contribution graph with a snake eating your contributions.

name: Generate Snake

on:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v2.3.4

- name: Generate Snake
uses: Platane/snk@master
id: snake-gif
with:
github_user_name: ${{ github.repository_owner }}
gif_out_path: ./assets/github-contribution-grid-snake.gif
svg_out_path: ./assets/github-contribution-grid-snake.svg

- name: Push to GitHub
uses: EndBug/add-and-commit@v7.2.1
with:
branch: main
message: 'Generate Contribution Snake'

复制上面的脚本代码并替换 yml 文件默认内容后,commit

上面的 Github Actions 执行完毕后,会在当前的仓库中添加一个 assets 文件夹,文件夹中有 github-contribution-grid-snake.gif 和 github-contribution-grid-snake.svg 两个文件。我们把 svg 文件引入到我们的 readme.md 文件中即可。(用 MarkDown 和 HTML 语法都行)

1
2
<div align="center"><img src="https://cdn.jsdelivr.net/gh/这里更换为你的 GitHub ID/这里更换为你的 GitHub ID/assets/github-contribution-grid-snake.svg" /></div>
1

现在整个流程大致算是结束了,GitHub 会在每天的零点(UTC 时区,和北京时间 (UTC+8) 差了八个小时)执行我们的 Actions,并生成新的贪吃蛇动画图片,如果你熟悉 Github Actions 的流程的话,你可以修改 yml 文件来设置更新频率、文件位置、文件名称、commit 信息等等。

计划任务语法有 5 个字段,中间用空格分隔,每个字段代表一个时间单位。

1
2
3
4
5
6
7
┌───────────── 分钟 (0 - 59)
│ ┌───────────── 小时 (0 - 23)
│ │ ┌───────────── 日 (1 - 31)
│ │ │ ┌───────────── 月 (1 - 12 或 JAN-DEC)
│ │ │ │ ┌───────────── 星期 (0 - 6 或 SUN-SAT)
│ │ │ │ │
* * * * *
符号 描述 案例
* 任意值 * * * * * 每天每小时每分钟
, 值分隔符 1,3,4,7 * * * * 每小时的 1 3 4 7 分钟
- 范围 1-6 * * * * 每小时的 1-6 分钟
/ */15 * * * * 每隔 15 分钟

:由于 GitHub Actions 的限制,如果设置为 * * * * * ,实际的执行频率为每 5 分执行一次。

# 2. 编程 & 游戏时长统计

主要利用了 Github Action 的机制,触发定时任务去 Wakatime 平台拉取数据进行统计,而 Wakatime 平台提供了 JetBrains 全家桶、VsCode、Chrome 的插件,用于统计用户的编程时长数据。

  1. 创建一个 Github Gist https://gist.github.com/
  2. 新建一个拥有 gist 空间权限的 Token,保存它 https://github.com/settings/tokens/new
  3. 创建一个 WakaTime 的账号 https://wakatime.com/signup
  4. 在你的 WakaTime 账号设置中选择公开你的编码活动 https://wakatime.com/settings/profile

请添加图片描述

  1. 在你 WakaTime 账户设置中创建 Api-Key ,并且复制它。 https://wakatime.com/settings/api-key
  2. Fork 这个仓库 https://github.com/matchai/waka-box
  3. 编辑 .github/workflows/schedule.yml 文件,将其中的 GIST_ID 设置为你创建的 gist 页面的 url
  4. 在你仓库的安全设置中添加下面两个环境变量 Settings > Secrets GH_TOKEN: 第 2 步的 gist token,WAKATIME_API_KEY: 第 5 步中的 wakatime Api-key

schedule.yml 文件代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
name: Update gist with WakaTime stats
on:
schedule:
- cron: "0 0 * * *"
jobs:
update-gist:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Update gist
uses: matchai/waka-box@master
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GIST_ID: 你创建的gist页面的url
WAKATIME_API_KEY: ${{ secrets.WAKATIME_API_KEY }}

你可以将这个 gist 的 script 文件添加到你的 Github Profile 中,用来在首页显示你的编码时间。

统计 Steam 游戏时间也是类似,不过没有 Wakatime 这样的平台去让我们拉取数据,但是我们可以直接去 Steam 拉取,可以参考这个仓库,本质都是类似于爬虫,利用 Github Action 来达到触发的效果。

# Profile 编辑器

这个项目是一个是一个可视化 profile 生成工具,使用者无需学习 Markdown 语法,仅需要在对应窗口中输入或者选择相应的内容,工具会自动生成 Markdown 脚本。脚本编辑完成以后,直接复制粘贴到自己的 Github 即可。

请添加图片描述
项目左侧是编辑窗口,在输入框中填入对应的信息,右侧展示框实时显示生成的效果,在编辑完成以后,点击右上角的按钮即可生成 Markdown 脚本文件。

我比较推荐它的 logo 展示功能,将自己技术栈以 logo 的方式展现出来,更加生动形象。

请添加图片描述

# 总结

以上就是 Github 自定义首页的基本玩法了。如果想搞些更花哨的(比如下象棋),那么可以参考 awesome-github-profile-readme推荐模板仓库 1推荐模板仓库 2。这么多有创意的首页都是开源的,你可以把喜欢的内容直接 “借鉴” 过来。后期有能力的话,也可以自己去编写 actions,然后交给 GitHub 去定时触发,比方说写一个爬虫程序,然后将爬取的数据展现到 GitHub 首页等等。

毛遂自荐:我的 Github 首页,仰天爱大笑

本笔记参照文章

Github 个人首页美化指北
Github 首页美化教程(一):打造个性化的 GitHub 首页
花式自定义 Github 首页:可能是程序员最棒的自我介绍了

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【公众号开发】公众号技术分享第三期-公众号事件处理-设计模式实战分享

InterviewCoder

# 【公众号开发】公众号技术分享第三期 - 公众号事件处理 - 设计模式实战分享

# 由于内容过于干燥。brath 趁机打一波广告…

​ 由 Brath 全栈开发的程序员刷题应用 - 面试记 现已经全面开放测试。

​ 在面试记,你可以:获取与分享编程知识与面经,数十万道题目供你选择,通过喜爱标签自由组卷,模拟面试,最新支持 GPT3.5 免费使用…

​ 由于时间原因,更多功能还在慢慢开发中,如果你对面试记感兴趣,想要共同开发的话,请联系我的微信:【Brath_code】

# 面试记 APP

Github:https://github.com/Guoqing815/interview

安卓 APP 下载:https://www.pgyer.com/interview_app_release

Brath 的个人博客:https://brath.top

面试记官方公众号,定期分享有趣的编程知识:https://mp.weixin.qq.com/s/jWs6lLHl5L-atXJhHc4YvA

image-20230517151019335

# 前言

​ 上期我们介绍了用非常优雅的方式来实现发送模板消息,本期将结合工厂模式 + 策略模式来同样优雅的实现公众号事件处理。

#

# 首先来回顾下工厂和策略模式💻

# 工厂模式:

​ 工厂模式是一种创建型设计模式,它提供了一种创建对象的最佳方式,而不必暴露对象创建的逻辑。在工厂模式中,我们定义一个工厂接口,该接口有一个或多个方法用于创建对象。然后,我们实现这个接口来创建具体的对象。这样,我们就可以在不暴露对象创建逻辑的情况下创建对象。

# 策略模式:

​ 策略模式是一种行为型设计模式,它允许在运行时动态地改变对象的行为。在策略模式中,我们定义一系列算法,将每个算法封装到一个独立的类中,并让它们可以相互替换。这样可以使得算法的变化独立于使用它的客户端。

# 正文开始:

# 一、设计思想

# 1. 事件策略:将许多类型的事件分为不同的策略,例如文本消息、图片消息、事件消息等等。
# 2. 事件工厂:储存所有事件信息,对外提供获取事件处理器的方法。
# 3. 事件解析:存储事件工厂,对外提供解析方法,返回解析后的实体对象 WechatMessage
# 4. 事件处理:通过工厂提供的获取事件处理器的方法,配合传来的事件类型获取具体处理器在进行消息处理。

# 二、结构预览

image-20230612142541419

# eventProcessing 包,定义事件处理 POJO 对象、处理器、处理器工厂、解析器。

image-20230612135531430

image-20230612135502183

# handlers 包,定义多种事件消息策略处理器。

image-20230612135404358

预览了结构之后,可以更好的理解下面的代码实现

# 三、策略消息以及实现类型

现在开始实现代码的部分,首先我们来定义事件对象、事件消息接口、基础实现

# 事件对象:
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
/**
* @author: Brath
* @date: 2023-06-12 08:49
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: WechatMessage 微信消息聚合对象
*/
@Data
@Accessors(chain = true)
public class WechatMessage {
// 接收方帐号(收到的OpenID)
private String toUserName;
// 开发者微信号
private String fromUserName;
// 消息创建时间 (整型)
private long createTime;
// 消息类型,文本为text
private String msgType;
// 文本消息内容
private String content;
// 消息id,64位整型
private String msgId;
// 事件类型,subscribe(订阅)、unsubscribe(取消订阅)
private String event;
// 事件KEY值,qrscene_为前缀,后面为二维码的参数值
private String eventKey;
// 二维码的ticket,可用来换取二维码图片
private String ticket;
// 地理位置纬度
private String latitude;
// 地理位置经度
private String longitude;
// 地理位置精度
private String precision;
// 图片链接(由系统生成)
private String picUrl;
// 图片消息媒体id,可以调用多媒体文件下载接口拉取数据。
private String mediaId;
// 语音格式,如amr,speex等
private String format;
// 视频消息缩略图的媒体id,可以调用多媒体文件下载接口拉取数据。
private String thumbMediaId;
// 地理位置纬度
private String location_X;
// 地理位置经度
private String location_Y;
// 地图缩放大小
private String scale;
// 地理位置信息
private String label;
// 消息标题
private String title;
// 消息描述
private String description;
// 消息链接
private String url;
// 音乐链接
private String musicUrl;
// 高质量音乐链接,WIFI环境优先使用该链接播放音乐
private String hqMusicUrl;
// 语音识别结果,UTF8编码
private String recognition;
// 加密信息,仅在使用安全模式下需要
private String encrypt;
}
# 事件消息接口:
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: Brath
* @date: 2023-06-12 08:49
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: WechatMessageHandler 微信消息处理接口规范
*/
public interface WechatMessageHandler {

/**
* 处理器是否支持 ${messageType} 类型
*
* @param messageType
* @return
*/
boolean supports(String messageType);

/**
* 通过XML数据解析消息
*
* @param wechatMessage
* @param xmlElement
*/
void parseMessage(WechatMessage wechatMessage, Element xmlElement);

/**
* 处理微信消息
*
* @param wechatMessage
*/
String handleMessage(WechatMessage wechatMessage, HttpServletRequest request);

/**
* 获取处理器名称
*
* @return
*/
String getHandleName();
}
# 实现类:
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
@Data
@Component(EventConsts.TEXT_MESSAGE_HANDLER)
@ApiModel(value = "文本消息处理器")
@Accessors(chain = true)
public class TextMessageHandler implements WechatMessageHandler {

private Logger logger = LoggerFactory.getLogger(TextMessageHandler.class);

/**
* 文本消息回复服务
*/
@Resource
private TextReplyService textReplyService;

@Override
public String getHandleName() {
return EventConsts.TEXT_MESSAGE_HANDLER;
}

@Override
public boolean supports(String messageType) {
return "text".equals(messageType);
}

@Override
public void parseMessage(WechatMessage wechatMessage, Element xmlElement) {
// 解析文本消息
String content = xmlElement.elementText("Content");
wechatMessage.setContent(content);
}

@Override
public String handleMessage(WechatMessage wechatMessage, HttpServletRequest request) {
logger.info("收到文本消息 wechatMessage: {}", wechatMessage);
//TODO...
}
}

注意:EventConsts 为常量,可以自己定义:

1
public static final String TEXT_MESSAGE_HANDLER = "textMessageHandler";
# 这两段代码实现了一个事件消息处理规范接口,以及具体的处理器实现

# 四、事件工厂,事件处理器实现

# 事件工厂:
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: Brath
* @date: 2023-06-12 08:49
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: WechatMessageHandlerFactory 微信消息处理器工厂
*/
@Component
public class WechatMessageHandlerFactory {

/**
* 消息事件处理器组
*/
private List<WechatMessageHandler> messageHandlers;

/**
* 初始化处理器组
*
* @param properties
*/
@Resource
private void initService(WechatMessageHandler[] properties) {
messageHandlers = Arrays.asList(properties);
}

/**
* 根据消息类型获取处理器
*
* @param messageType
* @return
*/
public WechatMessageHandler getMessageHandler(String messageType) {
return messageHandlers.stream()
.filter(handler -> handler.supports(messageType))
.findFirst()
.orElse(null);
}
}

# 事件处理器:
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
@Component
public class WechatMessageParser {

/**
* 处理器工厂
*/
private static WechatMessageHandlerFactory wechatMessageHandlerFactory;

@Resource
private void setWechatMessageHandlerFactory(WechatMessageHandlerFactory wechatMessageHandlerFactory) {
WechatMessageParser.wechatMessageHandlerFactory = wechatMessageHandlerFactory;
}

/**
* 解析XML
*
* @param xml
* @return
*/
public static WechatMessage parse(String xml) {
try {
Document document = DocumentHelper.parseText(xml);
Element rootElement = document.getRootElement();

//获取消息类型
String messageType = rootElement.elementText("MsgType");

//对象转换
WechatMessage wechatMessage = new WechatMessage()
.setToUserName(rootElement.elementText("ToUserName"))
.setFromUserName(rootElement.elementText("FromUserName"))
.setCreateTime(Long.parseLong(rootElement.elementText("CreateTime")))
.setMsgType(messageType);

//通过工厂获取处理器,并使用处理器parseMessage方法解析当前消息以及element对象
Optional<WechatMessageHandler> optionalMessageHandler = Optional.ofNullable(wechatMessageHandlerFactory.getMessageHandler(messageType));
optionalMessageHandler.ifPresent(handler -> handler.parseMessage(wechatMessage, rootElement));

return wechatMessage;
} catch (DocumentException e) {
throw new RuntimeException(e);
}
}
}
# 这两段代码我们实现了事件工厂以及事件解析器的实现。

# 到此为止,整条链路打通,可以来单元测试一下:

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
/**
* @author: Brath
* @date: 2023-06-12 08:49
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: SpringRunnerTest
*/
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class SpringRunnerTest {

private Logger logger = LoggerFactory.getLogger(SpringRunnerTest.class);

@Resource
private WechatMessageHandlerFactory wechatMessageHandlerFactory;

@Test
public void testHandler() {
//测试text策略处理器
WechatMessageHandler textHandler = wechatMessageHandlerFactory.getMessageHandler("text");
System.out.println(textHandler.getHandleName());

//测试位置策略处理器
WechatMessageHandler location = wechatMessageHandlerFactory.getMessageHandler("location");
System.out.println(location.getHandleName());

//测试解析text的xml数据
WechatMessage wechatMessage = WechatMessageParser.parse("<xml><ToUserName><! [CDATA[gh_6ecd244c13d6]]></ToUserName>\n" +
"<FromUserName><![CDATA[ow3gF5zkvJc097jaYvLj5uWKZZTk]]></FromUserName>\n" +
"<CreateTime>1686546944</CreateTime>\n" +
"<MsgType><![CDATA[text]]></MsgType>\n" +
"<Content><![CDATA[测试]]></Content>\n" +
"<MsgId>24145550072327732</MsgId>\n" +
"</xml>\n");
System.out.println(wechatMessage);

//测试使用text策略处理器处理wechatMessage对象
MockHttpServletRequest request = new MockHttpServletRequest();
String content = textHandler.handleMessage(wechatMessage, request);
System.out.println("content: " + content);
}
}

输出:

image-20230612141441920

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//测试text策略处理器
textMessageHandler

//测试位置策略处理器
locationMessageHandler

//测试解析text的xml数据
WechatMessage(toUserName=gh_6ecd244c13d6, fromUserName=ow3gF5zkvJc097jaYvLj5uWKZZTk, createTime=1686546944, msgType=text, content=测试, msgId=null, event=null, eventKey=null, ticket=null, latitude=null, longitude=null, precision=null, picUrl=null, mediaId=null, format=null, thumbMediaId=null, location_X=null, location_Y=null, scale=null, label=null, title=null, description=null, url=null, musicUrl=null, hqMusicUrl=null, recognition=null, encrypt=null)

//测试使用text策略处理器处理wechatMessage对象
content: <xml>
<ToUserName><![CDATA[ow3gF5zkvJc097jaYvLj5uWKZZTk]]></ToUserName>
<FromUserName><![CDATA[gh_6ecd244c13d6]]></FromUserName>
<CreateTime><![CDATA[1686550406094]]></CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<FuncFlag><![CDATA[0]]></FuncFlag>
<Content><![CDATA[测试成功]]></Content>
</xml>
# 测试成功~
# tips:如果你觉得 Brath 分享的代码还可以的话,请将我分享给更多需要帮助的人~
# 到此为止,公众号事件处理 - 设计模式的知识分享就结束啦,还请同学们多多关注 InterviewCoder,做一个激进的开发者,为了更好的你,也为了更好的世界!

# 完结撒花❀

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【公众号开发】公众号技术分享第二期-公众号模板消息-模板模式实战分享

InterviewCoder

# 【公众号开发】公众号技术分享第二期 - 公众号模板消息 - 模板模式实战分享

# 打一波广告…

# 面试记 APP

Github:https://github.com/Guoqing815/interview

安卓 APP 下载:https://www.pgyer.com/interview_app_release

Brath 的个人博客:https://brath.top

面试记官方公众号,定期分享有趣的编程知识:https://mp.weixin.qq.com/s/jWs6lLHl5L-atXJhHc4YvA

# 前言

​ 大家好啊,我是 Brath,一名激进的全栈开发者,从这期专栏开始,我会逐渐分享面试记项目中的部分优质源码给到大家。还希望大家多多关注!

​ 上期我们介绍了微信模板消息的配置方式,以及 Java 代码示例,接下来我们要用更优雅的方式来实现发送模板消息!

# 注意:这是实战代码,不是 Demo!

# 模板模式介绍:

​ 模板模式是一种行为型设计模式,它定义了一个操作中的算法骨架,而将一些步骤延迟到子类中实现。模板方法使得子类可以不改变一个算法的结构即可重新定义该算法的某些特定步骤。

​ 在模板模式中,我们定义一个抽象类,其中包含一个模板方法,该方法定义了算法的骨架,以及一些抽象的方法,这些方法需要子类去实现。子类可以继承该抽象类,并实现其中的抽象方法,从而完成具体的业务逻辑。

# 如何使用模板设计模式优雅的实现发送微信公众号模板消息?

# 一、设计模板架构

​ 我们的业务就是发送模板消息,最开始我使用了 switch 分支来判断不同的模板消息,起初它是好用的,但是随着消息类型的增加,慢慢的代码开始冗余了起来,所以我想到了利用模板模式来彻底优化这个业务。

​ 这次设计的模板架构分为五个模块:

# 1. 模板:定义每个模板的规范,抽取共性方法,凝练重要功能
# 2. 消息类型:分类管理,为不同的消息类型赋能
# 3. 类型配置:便携管理消息类型,摒弃了 if 与 switch 判断
# 4. 数据支撑:为模板消息提供基础数据支撑,例如公众号消息推送业务
# 5. 统一校验:在抽象的层面去校验模板合法性,校验通过发送消息
# 6. 消息注入:接口层面通过反射实例化消息,并将参数集合注入消息体

# 二、结构预览

# messageData 包,定义消息体,规范消息格式

1.IMessage 为模板通用定义,实现了两个方法,getMessageName 用于获取模板自己的名称,getWxMaTemplateData 用于获取模板的参数配置

2.LoginSuccessTemplateImpl 为登录成功模板消息的实例定义,实现了 IMessage 接口,并且重写 getMessageName 和 getWxMaTemplateData 方法,即能提供自己的名称和参数列表到接口定义中。

image-20230606141325324

# messageModule 包,定义模板配置,发送流程

1.WechatMessageConfig 为模板配置,用于获取所有模板映射的集合,方便我们通过名称获取模板配置实例,摒弃了 ifelse 的判断模式。

2.WechatMessageSupport 为基础数据支撑,用于提供基础的微信模板发送消息的方法,以及模板消息注入方法的实现。

3.IWechatMessageExec 为发送模板的接口,用于实现发送模板消息的规范。

4.AbstractWechatMessageBase 为抽象的微信模板消息,用于抽象化管理所有模板消息,在这里做参数的校验,以及 unionId 的获取。

5.IWechatMessageExecImpl 为发送消息的实现,在抽象层面获取到 fromOpenId 后即可通过抽象层继承的 Support 层的微信模板发送消息的方法来发送微信模板消息。

image-20230606141135188

预览了结构之后,可以更好的理解下面的代码实现

# 三、模板消息以及实现类型

现在开始实现代码的部分,首先我们来定义一个消息体的抽象接口,用来表示统一的模板

# 抽象接口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @author: Brath
* @date: 2023-06-5 18:10
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: 消息体的抽象接口
*/
public interface IMessage {

/**
* 获取消息名称
*
* @return
*/
String getMessageName();

/**
* 获取模板消息集合
*
* @return
*/
List<WxMaTemplateData> getWxMaTemplateData();
}
# 实现类:
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
/**
* @author: Brath
* @date: 2023-06-5 18:13
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: 登录成功模板消息
*/
@Component("loginSuccessTemplateMessage")
@ApiModel(value = "登录成功模板消息")
@Data
@Accessors(chain = true)
public class LoginSuccessTemplateImpl implements IMessage {

@NotBlank(message = "登录用户不能为空")
private String loginUser;

@NotBlank(message = "登录地址不能为空")
private String loginAddr;

@NotBlank(message = "登录IP不能为空")
private String loginIp;

@Override
public String getMessageName() {
return NotifyType.LOGIN_SUCCESS_TEMPLATE_MESSAGE.getType();
}

@Override
public List<WxMaTemplateData> getWxMaTemplateData() {
List<WxMaTemplateData> params = new ArrayList<>();
params.add(new WxMaTemplateData("keyword1", loginUser + ",您的账号刚刚在" + loginAddr + "登录。请关注本次登录时间和地点,若是您本人登录,请忽略本提醒,若非本人登录,请点击修改密码,并检查账号最近登录和操作行为是否有问题。", "#173177"));
params.add(new WxMaTemplateData("keyword2", new SimpleDateFormat("yyyy-MM-dd HH:mm").format(new Date()), "#173177"));
params.add(new WxMaTemplateData("keyword3", loginIp, "#173177"));
return params;
}
}

注意:NotifyType 表示消息的类型,你可以自己定义常量来代替这段代码

# 这两段代码实现了一个抽象的消息模板接口,并创建了登录成功模板消息类型,实现了对应的方法

# 四、数据支撑以及数据配置

# 数据支撑:
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
/**
* @author: Brath
* @date: 2023-06-5 19:11
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: WechatMessageSupport
*/
@Service
public class WechatMessageSupport extends WechatMessageConfig {

private static final Logger logger = LoggerFactory.getLogger(WechatMessageSupport.class);

/**
* Information service interface
*/
protected static NotifyService notifyService;

/**
* 注入消息服务接口
*
* @param notifyService
*/
@Resource
public void setNotifyService(NotifyService notifyService) {
WechatMessageSupport.notifyService = notifyService;
}

/**
* 注入消息体
*
* @param paramMap
* @param iMessage
*/
public static IMessage injectMessage(HashMap<String, Object> paramMap, IMessage iMessage) {
// 获取class模板
Class<?> clazz = iMessage.getClass();

// 缓存setter方法
Map<String, Method> setterMap = new HashMap<>(16);
for (Method method : clazz.getMethods()) {
if (method.getName().startsWith(Consts.SET)) setterMap.put(method.getName(), method);
}

// 注入参数到字段中
for (Map.Entry<String, Object> entry : paramMap.entrySet()) {
String paramName = entry.getKey();
Object paramValue = entry.getValue();
String setterName = generateSetterName(paramName);
Method method = setterMap.get(setterName);
if (method != null) {
// 检查参数类型是否匹配
Class<?>[] parameterTypes = method.getParameterTypes();
if (parameterTypes.length != 1 || !parameterTypes[0].isInstance(paramValue)) {
logger.warn("无法为消息对象设置参数 {} = {}, 参数类型不匹配", paramName, paramValue);
continue;
}

// 检查方法是否可访问
if (!method.isAccessible()) {
method.setAccessible(true);
}

try {
method.invoke(iMessage, paramValue);
} catch (IllegalAccessException | InvocationTargetException e) {
logger.error("无法为消息对象设置参数 {} = {}", paramName, paramValue, e);
}
} else {
logger.warn("无法为消息对象设置参数 {} = {}", paramName, paramValue);
}
}

return iMessage;
}

/**
* 生成setter方法名称
*
* @param propertyName
* @return
*/
private static String generateSetterName(String propertyName) {
return Optional.ofNullable(propertyName).filter(name -> !name.isEmpty()).map(name -> "set" + Character.toUpperCase(name.charAt(0)) + name.substring(1)).orElse(null);
}
}
# 数据配置:
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
/**
* @author: Brath
* @date: 2023-06-5 19:39
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: WechatMessageConfig
*/
@Service
public class WechatMessageConfig {

/**
* 消息策略组映射表
* 疑:为什么初始值会设置成8?而不是6呢?
* 答:因为 HashMap 的实现可能会自动将 initcp 调整为接近 2 的幂次方的值,以便更好地处理哈希冲突
*/
public static Map<String, IMessage> messageStrategyGroup = new ConcurrentHashMap<>(8);

/**
* 初始化消息组
*
* @param properties
*/
@Resource
private void initService(IMessage[] properties) {
Arrays.stream(properties).forEach(
property -> messageStrategyGroup.put(property.getMessageName(), property)
);
}
}

这两段代码我们实现了数据的基础支撑,以及类型模式配置。

# 五、注入抽象消息到模板,并且实现发送流程

# 抽象消息接口:定义发送模板消息的接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author: Brath
* @date: 2023-06-5 20:23
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: IWechatMessageExec
*/
public interface IWechatMessageExec {

/**
* 发送模板消息
*/
Object sendMessage(IMessage message, String unionId);

}
# 抽象消息实现:在抽象消息层面去判断消息体的参数合法性,并且将 unionId 转换为 fromOpenId
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
/**
* @author: Brath
* @date: 2023-06-6 07:12
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: AbstractWechatMessage
*/
public abstract class AbstractWechatMessageBase extends WechatMessageSupport implements IWechatMessageExec {

private static final Logger logger = LoggerFactory.getLogger(AbstractWechatMessageBase.class);

/**
* 发送抽象消息,准备消息体,转换FromOpenID
*/
@Override
public Object sendMessage(IMessage message, String unionId) {
ValidatorUtils.validateEntity(message);

if (AssertUtil.isEmpty(unionId)) {
logger.error(DEFALUT_FAIL, ResponseCode.DATA_DOES_NOT_EXIST.desc());
return ResponseUtil.fail(ResponseCode.DATA_DOES_NOT_EXIST.desc());
}
//这段代码原本在WechatMessageSupport层实现,被我剔除掉了,这个需要大家去自己实现收集公众号关注人的fromOpenId,不是微信的openId,是关注了公众号后,产生的openId。
String fromOpenId = wxservFollowService.getFromOpenIdByUnionId(unionId);
if (AssertUtil.isEmpty(fromOpenId)) {
logger.error(DEFALUT_FAIL, ResponseCode.DATA_DOES_NOT_EXIST.desc());
return ResponseUtil.fail(ResponseCode.DATA_DOES_NOT_EXIST.desc());
}
//总之fromOpenId就是关注了公众号后产生的唯一ID
return this.sendMessage(message, fromOpenId, message.getMessageName());
}

/**
* 发送抽象消息
*
* @param message
* @param openId
* @param messageType
* @return
*/
protected abstract Object sendMessage(IMessage message, String openId, String messageType);
}
# 具体消息实现:在抽象层确保消息体完整性后,调用抽象层面的具体实现类,调用数据支撑层的 notifyService 服务完成发送模板消息
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
/**
* @author: Brath
* @date: 2023-06-6 07:40
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: 模板消息推送实现
*/
@Component("wechatMessageExecImpl")
public class IWechatMessageExecImpl extends AbstractWechatMessageBase {

private static final Logger logger = LoggerFactory.getLogger(IWechatMessageExecImpl.class);

@Override
protected Object sendMessage(IMessage message, String openId, String messageType) {
logger.info("【公众号模板消息推送】:开始 -- messageType:{},openId:{}", messageType, openId);
try {
notifyService.notifyWxTemplate(
openId,
NotifyType.of(messageType),
message.getWxMaTemplateData()
);
logger.info("【公众号模板消息推送】:结束 -- messageType:{},openId:{}", messageType, openId);
} catch (Exception e) {
e.printStackTrace();
logger.error("【公众号模板消息推送】:异常 -- {}", e.getMessage());
return ResponseUtil.fail(e.getMessage());

}
return ResponseUtil.ok();
}
}

# SpringBoot 配置文件、消息类型、NotifyService 实现:

# pom 文件配置依赖:这里我们使用大神 binarywang 的 miniapp 库来实现微信支持
1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-miniapp</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-pay</artifactId>
<version>3.3.0</version>
</dependency>
# yaml 配置:实现自定义配置类,配置模板集合,以及是否开启
1
2
3
4
5
6
7
system:
notify:
wx:
enable: true #启动模板消息
template:
- name: loginSuccessTemplateMessage #登录成功模板消息
templateId: qDHo-UXXXXXXXXXXXXXXXXXXXXX #公众平台模板ID
# 配置信息类:使用静态内部类实现子配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @author: Brath
* @date: 2023-06-6 07:40
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: NotifyProperties
*/
@Data
@ConfigurationProperties(prefix = "system.notify")
public class NotifyProperties {

private Wx wx;

@Data
public static class Wx {
private boolean enable;
private List<Map<String, String>> template = new ArrayList<>();
}
}
# 自动配置类:使用 EnableConfigurationProperties 注解注入 NotifyProperties 配置并实现 notifyService 配置注入
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
/**
* @author: Brath
* @date: 2023-06-6 07:40
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: 挂载NotifyProperties类,获取配置信息并自动加载
*/
@Configuration
@EnableConfigurationProperties(NotifyProperties.class)
public class NotifyAutoConfiguration {

private final NotifyProperties properties;

public NotifyAutoConfiguration(NotifyProperties properties) {
this.properties = properties;
}

@Bean(name = "NotifyService")
public NotifyService notifyService() {
NotifyService notifyService = new NotifyService();

NotifyProperties.Wx wxConfig = properties.getWx();
if (wxConfig.isEnable()) {
notifyService.setWxTemplateSender(wxTemplateSender());
NotifyService.wxTemplate = wxConfig.getTemplate();
}
return notifyService;
}

@Bean
public WxTemplateSender wxTemplateSender() {
WxTemplateSender wxTemplateSender = new WxTemplateSender();
return wxTemplateSender;
}
}
# 消息类型:配置模板消息类型
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
/**
* @author: Brath
* @date: 2023-05-25 08:49
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: NotifyType
*/
public enum NotifyType {

//登录成功模板消息
LOGIN_SUCCESS_TEMPLATE_MESSAGE("loginSuccessTemplateMessage");


private static final EnumMap<NotifyType, String> typeMap = new EnumMap<>(NotifyType.class);

static {
for (NotifyType notifyType : NotifyType.values()) {
typeMap.put(notifyType, notifyType.type);
}
}

private String type;

NotifyType(String type) {
this.type = type;
}

public static NotifyType of(String messageType) {
for (NotifyType notifyType : typeMap.keySet()) {
if (typeMap.get(notifyType).equals(messageType)) {
return notifyType;
}
}
throw new IllegalArgumentException("No such enum object for the given messageType");
}

public String getType() {
return this.type;
}
}
# NotifyService 服务实现:实现发送消息的服务类
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
/**
* @author: Brath
* @date: 2023-06-6 07:40
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: 公众号模板消息服务
*/
@Data
@NoArgsConstructor
public class NotifyService {
private MailSender mailSender;
private String sendFrom;
private String sendTo;

private WxTemplateSender wxTemplateSender;
public static List<Map<String, String>> wxTemplate = new ArrayList<>();

public boolean isWxEnable() {
return wxTemplateSender != null;
}


/**
* 微信模版消息通知,不跳转
* <p>
* 该方法会尝试从数据库获取缓存的FormId去发送消息
*
* @param touser 接收者openId
* @param notifyType 通知类别,通过该枚举值在配置文件中获取相应的模版ID
* @param params 通知模版内容里的参数,类似"您的验证码为{1}"中{1}的值
*/
@Async
public void notifyWxTemplate(String touser, NotifyType notifyType, List<WxMaTemplateData> params) {
if (wxTemplateSender == null)
return;

String templateId = getTemplateId(notifyType, wxTemplate);
wxTemplateSender.sendWechatMsg(touser, templateId, params);
}

/**
* 微信模版消息通知,带跳转
* <p>
* 该方法会尝试从数据库获取缓存的FormId去发送消息
*
* @param touser 接收者openId
* @param notifyType 通知类别,通过该枚举值在配置文件中获取相应的模版ID
* @param params 通知模版内容里的参数,类似"您的验证码为{1}"中{1}的值
* @param page 点击消息跳转的页面
*/
@Async
public void notifyWxTemplate(String touser, NotifyType notifyType, List<WxMaTemplateData> params, String page) {
if (wxTemplateSender == null)
return;

String templateId = getTemplateId(notifyType, wxTemplate);
wxTemplateSender.sendWechatMsg(touser, templateId, params, page);
}

/**
* 获取模板ID
*
* @param notifyType
* @param values
* @return
*/
private String getTemplateId(NotifyType notifyType, List<Map<String, String>> values) {
for (Map<String, String> item : values) {
String notifyTypeStr = notifyType.getType();
if (item.get("name").equals(notifyTypeStr))
return item.get("templateId");
}
return null;
}

}
# WxTemplateSender 发送者实现:实现发送消息的基础类,使用 WxMaService 库发送消息
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
/**
* @author: Brath
* @date: 2023-06-6 07:40
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: 微信模版消息通知
*/
public class WxTemplateSender {
private final Log logger = LogFactory.getLog(WxTemplateSender.class);

@Autowired
private WxMaService wxMaService;

/**
* 发送微信消息(模板消息),不带跳转
*
* @param touser
* 用户 OpenID
* @param templatId
* 模板消息ID
* @param parms
* 详细内容
*/
public void sendWechatMsg(String touser, String templatId, String[] parms) {
sendMsg(touser, templatId, parms, "", "", "");
}

/**
* 发送微信消息(模板消息),不带跳转
*
* @param touser
* 用户 OpenID
* @param templatId
* 模板消息ID
* @param parms
* 详细内容
*/
public void sendWechatMsg(String touser, String templatId, List<WxMaTemplateData> parms) {
sendMsg(touser, templatId, parms, "", "", "");
}

/**
* 发送微信消息(模板消息),带跳转
*
* @param touser
* 用户 OpenID
* @param templatId
* 模板消息ID
* @param parms
* 详细内容
* @param page
* 跳转页面
*/
public void sendWechatMsg(String touser, String templatId, List<WxMaTemplateData> parms, String page) {
sendMsg(touser, templatId, parms, page, "", "");
}

/**
* 发送消息基类
*
* @param touser
* @param templatId
* @param parms
* @param page
* @param color
* @param emphasisKeyword
*/
private void sendMsg(String touser, String templatId, String[] parms, String page, String color,
String emphasisKeyword) {
if (touser == null)
return;

WxMaTemplateMessage msg = new WxMaTemplateMessage();
msg.setTemplateId(templatId);
msg.setToUser(touser);
msg.setFormId(touser);
msg.setPage(page);
msg.setColor(color);
msg.setEmphasisKeyword(emphasisKeyword);
msg.setData(createMsgData(parms));

try {
wxMaService.getMsgService().sendTemplateMsg(msg);
} catch (Exception e) {
e.printStackTrace();
}
}

private static final JsonParser JSON_PARSER = new JsonParser();
private static final String SEND_MSG_API = "https://api.weixin.qq.com/cgi-bin/message/template/send";

private void sendMsg(String touser, String templatId, List<WxMaTemplateData> parms, String page, String color,
String emphasisKeyword) {
if (touser == null)
return;

WxMaTemplateMessage msg = new WxMaTemplateMessage();
msg.setTemplateId(templatId);
msg.setToUser(touser);
msg.setFormId(touser);
msg.setPage(page);
msg.setColor(color);
msg.setEmphasisKeyword(emphasisKeyword);
msg.setData(parms);

try {
String responseContent = this.wxMaService.post(SEND_MSG_API, msg.toJson());
JsonObject jsonObject = JSON_PARSER.parse(responseContent).getAsJsonObject();
if (jsonObject.get(WxMaConstants.ERRCODE).getAsInt() != 0) {
throw new WxErrorException(WxError.fromJson(responseContent));
}
} catch (WxErrorException e) {
logger.error("【微信消息模板】:服务端异常,原因可能是:{}", e);
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();

}
}

private List<WxMaTemplateData> createMsgData(String[] parms) {
List<WxMaTemplateData> dataList = new ArrayList<WxMaTemplateData>();
for (int i = 1; i <= parms.length; i++) {
dataList.add(new WxMaTemplateData("keyword" + i, parms[i - 1]));
}

return dataList;
}
}
# 在配置完如上代码后,我们的代码就完成了,接下来即可通过单元测试来测试模板消息!

# 六、使用抽象模板发送消息

单元测试发送消息:

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
/**
* @author: Brath
* @date: 2023-06-6 08:21
* @github: https://github.com/Guoqing815
* @Copyright: 公众号:InterviewCoder | 博客:https://brath.top - 为了更好的你,也为了更好的世界。
* @description: 单测
*/
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class SpringRunnerTest {

private Logger logger = LoggerFactory.getLogger(SpringRunnerTest.class);

@Resource
private IWechatMessageExec messageExec;

@Test
public void test_sendMessage() {
//产品提测消息
ProductTestTemplateImpl productTestTemplate = new ProductTestTemplateImpl()
.setCompanyName("测试公司")
.setUserName("Brath")
.setVersion("2.0.6");
//注册成功消息
RegistTemplateImpl registTemplate = new RegistTemplateImpl();

//登录成功消息
LoginSuccessTemplateImpl loginSuccessTemplate = new LoginSuccessTemplateImpl()
.setLoginUser("Brath")
.setLoginAddr("JVM")
.setLoginIp("0.0.0.0");
messageExec.sendMessage(loginSuccessTemplate, "o-KKr6Qwsxxxxxxxxxxxxxxx");
}
}
# 测试成功~

image-20230606143429408

# tips:如果你觉得 Brath 分享的代码还可以的话,请将我分享给更多需要帮助的人~
# 到此为止,公众号发送模板消息的知识分享就结束啦,还请同学们多多关注 InterviewCoder,做一个激进的开发者,为了更好的你,也为了更好的世界!

# 完结撒花❀

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【公众号开发】公众号技术分享第一期-如何注册微信公众平台并使用公众号模板消息

InterviewCoder

# 【公众号开发】公众号技术分享第一期 - 如何注册微信公众平台并使用公众号模板消息

# 打一波广告…

# 面试记 APP

Github:https://github.com/Guoqing815/interview

安卓 APP 下载:https://www.pgyer.com/interview_app_release

Brath 的个人博客:https://brath.top

面试记官方公众号,定期分享有趣的编程知识:https://mp.weixin.qq.com/s/jWs6lLHl5L-atXJhHc4YvA

# 前言

​ 大家好啊,我是 Brath,一名激进的全栈开发者,从这期专栏开始,我会逐渐分享面试记项目中的部分优质源码给到大家。还希望大家多多关注!

# 使用公众号模板消息有几个前提:
# 1. 需要拥有一个已经认证过的公众号,认证费用 300 元。
# 2. 至少有基础的 Java 开发经验。
# 3. 了解 XML 和 JSON 格式的数据
# 4. 微信公众平台:https://mp.weixin.qq.com/

# 一、开通模板消息

​ 登录微信公众号 Web 端,开通模板消息功能,选择使用的模板消息或者申请新的模板消息,只有通过企业认证才能使用模板消息

image-20230606094513227

image-20230606094408873

# 二、开启接口权限

接口权限 => 网页服务 => 网页授权 => 申请 / 修改

image-20230606094447163

# 三、配置域名

​ 三项都要配置。注意前面不要加 http 或 https,后面不可带端口号。还需要下载一个校验文件,把下载下来的校验文件放到所配置域名解析的服务器上,需要域名 + 检验文件名可以访问到这个文件才算校验通过,后面不能有端口(例如:wx.qq.com/MP_verify_FF1peUkHP0MrdJqN.txt;)。

ps:放校验文件的时候需要注意,http 默认端口是 80,https 默认端口是 443

image-20230606094747344

image-20230606094814359

# 四、配置 IP 白名单。

​ 后续获取 access_token 访问接口时,需要设置访问来源 IP 为白名单,如不配置就拿不到 token,每台机器上请求返回的 ip 都会不一样,把开发机器和服务器拿到的 ip 都配置一样就好,配置多个 ip 时每个 ip 用回车隔开就行。怎么获取来源 ip:如果没有配置 ip 白名单,请求获取 access_token 时会返回一个 ip,把这个 ip 配上去就行了。另外顺便保存一下 appId 和 AppSecret,方便后续使用。

image-20230606105545467

# 五、发送模板消息流程

1. 获取 code:需要更换的参数是 appid、redirect_uri,其他参数不变,其中 scope 有两个参数,以 snsapi_base 为 scope 发起的网页授权,是用来获取进入页面的用户的 openid 的,并且是静默授权并自动跳转到回调页的。用户感知的就是直接进入了回调页(往往是业务页面);
以 snsapi_userinfo 为 scope 发起的网页授权,是用来获取用户的基本信息的。但这种授权需要用户手动同意,并且由于用户同意过,所以无须关注,就可在授权后获取该用户的基本信息。我这里用的是静默授权然后跳转到相关页面。另外还要注意的是这个地址只能在微信客户端请求才有用,可以用微信开发者工具切换成公众号调试模式测试,请求后会自动重定向,在跳转的地址后面拼上一个 code,这个就是我们需要的参数。
https://open.weixin.qq.com/connect/oauth2/authorize?appid = 你的 appid&redirect_uri = 要跳转的地址 / 页面 & response_type=code&scope=snsapi_base&state=STATE#wechat_redirect

2. 通过 code 换取网页授权 access_token,拿到 openId

注意上面拿到的 code 只能使用一次,并且有效时长为 5 分钟,失效必须重新获取

https://api.weixin.qq.com/sns/oauth2/access_token?appid = 你的 appid&secret = 你的 secret&code = 上面地址栏拿到的 code&grant_type=authorization_code

3. 获取 token (此 token 与上面的 access_token 不一样),此 token 用于发送模板消息用以及其他 api 接口的调用
https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid = 你的 appid&secret = 你的 secret

4. 发送模板消息
https://api.weixin.qq.com/cgi-bin/message/template/send?access_token = 上面获取到的 token

# JSON 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"touser":"OPENID",
"template_id":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"url":"http://weixin.qq.com/download",
"miniprogram":{
"appid":"xxxxxxxxxxxxxxx",
"pagepath":"xxxxxx"
},
"client_msg_id":"MSG_000001",
"data":{
"keyword1":{
"value":"test",
"color":"#173177"
},
"keyword2":{
"value":"test",
"color":"#173177"
}
}
}

# 参数说明:

image-20230606095123398

#

# 简单的 Java 代码:

1. 获取用户 openid,参数为 code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 微信公众号获取获取用户openid
* @param code
* @return
*/
@GetMapping("/getOpenId")
@ApiOperation(value = "微信公众号获取用户openid")
public String getOpenId(@RequestParam String code){
String url= "https://api.weixin.qq.com/sns/oauth2/access_token" + "?appid=" + appId + "&secret=" + appSecret + "&code="+ code+"&grant_type=authorization_code";
RestTemplate restTemplate=new RestTemplate();
String response=restTemplate.getForObject(url,String.class);
JSONObject jsonObj = new JSONObject(response);
String openid = jsonObj.get("openid").toString();
return openid;
}

2. 获取 token

1
2
3
4
5
6
7
8
9
10
11
/**
* 微信公众号获取获取token
* @return
*/
public String getWeiXinToken(){
String url="https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid="+ appId + "&secret=" + appSecret;
RestTemplate restTemplate = new RestTemplate();
String forObject = restTemplate.getForObject(url, String.class);
JSONObject jsonObj = new JSONObject(forObject);
return jsonObj.get("access_token").toString();
}

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
31
32
33
34
@Data
public class WxTemplateMsg {
/**
* 接收者openId
*/
private String touser;
/**
* 模板ID
*/
private String template_id;
/**
* 模板跳转链接
*/
private String url;

/**
* data数据
*/
private TreeMap<String, TreeMap<String, String>> data;

/**
* 参数
*
* @param value 值
* @param color 颜色 可不填
* @return params
*/
public static TreeMap<String, String> item(String value, String color) {
TreeMap<String, String> params = new TreeMap<String, String>();
params.put("value", value);
params.put("color", color);
return params;
}
}

4. 模板消息封装,消息参数根据自己选择的模板消息来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 模板消息封装
* @param
* @return
*/
public String noticeTemplate(String openIdd,String siteName,String airIndex,String value1,String value2,String airIndexTime) {
String templateId="你的模板消息id";
TreeMap<String, TreeMap<String, String>> params = new TreeMap<>();
params.put("keyword1", WxTemplateMsg.item("test1", "#000000"));
params.put("keyword2", WxTemplateMsg.item("test2", "#000000"));

WxTemplateMsg wxTemplateMsg = new WxTemplateMsg();
wxTemplateMsg.setTemplate_id(templateId);
wxTemplateMsg.setTouser(openIdd);
wxTemplateMsg.setData(params);

return JSONUtil.toJsonStr(wxTemplateMsg);
}

5. 发送模板消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 发送模板消息
*/
public void sendTemplateMsg(String openId, String siteName, String airIndex, String value1, String value2, String airIndexTime) {
String weiXinToken = getWeiXinToken();
String data = noticeTemplate(openId, siteName, airIndex, value1, value2, airIndexTime);
okhttp3.RequestBody requestBody = okhttp3.RequestBody.create(MediaType.parse("application/json"), data);
Request request = new Request.Builder().url("https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=" + weiXinToken).post(requestBody).build();
OkHttpClient okHttpClient = new OkHttpClient();
Response execute = null;
try {
execute = okHttpClient.newCall(request).execute();
String body = execute.body().string();
if (execute.code() == 200) {
System.out.println("模板消息发送成功==========" + body);
} else {
System.out.println("模板消息发送失败==========" + body);
}
} catch (IOException e) {
e.printStackTrace();
}
}

# 完结撒花❀

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【Sentinel】Docker搭建Sentinel

InterviewCoder

# 【Sentinel】Docker 搭建 Sentinel

# 1、概述

Sentinel 提供一个轻量级的开源控制台,它提供机器发现以及健康情况管理、监控(单机和集群),规则管理和推送的功能。

Sentinel 控制台包含如下功能:

查看机器列表以及健康情况: 收集 Sentinel 客户端发送的心跳包,用于判断机器是否在线。
监控 (单机和集群聚合): 通过 Sentinel 客户端暴露的监控 API,定期拉取并且聚合应用监控信息,最终可以实现秒级的实时监控。
规则管理和推送: 统一管理推送规则。
鉴权: 生产环境中鉴权非常重要。这里每个开发者需要根据自己的实际情况进行定制。

# 2、制作镜像

sentinel-dashboard 就是一个 SpringBoot 项目,直接使用命令启动即可,所有自定义配置 docker 启动。

如果没有特殊需要可以直接下载 jar,需要修改源码则下载源码包即可,下载地址:https://github.com/alibaba/Sentinel/releases,下载相应版本的 jar 包,比如 sentinel-dashboard-1.8.1.jar

image-20230606131018352

1、创建工作目录:

1
mkdir /home/soft/sentinel -p

2、拷贝文件:

将从官网下载的或者是自定义编译好的 jar 包,拷贝到 /home/soft/sentinel 目录下

3、Dockerfile:

1
vim /home/soft/sentinel/Dockerfile

内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
#java 版本
FROM openkbs/jdk11-mvn-py3
#root用户
USER root
#设置时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
#设置工作目录集
WORKDIR /root/sentinel
#复制jars和命令
ADD *.jar /root/sentinel/
EXPOSE 8858

4、制作镜像:

保证 jarDockerfile 在同一个目录下,执行命令:

1
docker build -t sentinel-server .

启动容器:

1
docker run -dit -p 8858:8858 --privileged=true -P --name sentinel sentinel /bin/bash -c 'tail -f /dev/null' -g 'daemon off;'

进入容器启动:

1
java -Dserver.port=8858 -Dsentinel.dashboard.auth.username=sentinel -Dsentinel.dashboard.auth.password=sentinel -jar  sentinel-dashboard-1.8.0.jar

3、启动测试

http://127.0.0.1:8858/ ;默认账户密码:sentinel/sentinel

鉴权:
从 Sentinel 1.6.0 起,Sentinel 控制台引入基本的登录功能,默认用户名和密码都是 sentinel。该鉴权能力非常基础,生产环境使用建议根据安全需要自行改造。

可以在 Dockerfile 文件中,通过如下 JVM 参数进行配置:

-Dsentinel.dashboard.auth.username=sentinel 用于指定控制台的登录用户名为 sentinel;
–Dsentinel.dashboard.auth.password=123456 用于指定控制台的登录密码为 123456;如果省 略这两个参数,默认用户和密码均为 sentinel;
-Dserver.servlet.session.timeout=7200 用于指定 Spring Boot 服务端 session 的过期时间,如 7200 表示 7200 秒;60m 表示 60 分钟,默认为 30 分钟;
除了修改 JVM 启动参数的形式,还是源码中通过 application.properties 文件进行配置.

# 4、配置项目说明

控制台的一些特性可以通过配置项来进行配置

image-20230606112448778

通过 JVM 方式为:在 配置DockerfielENTRYPOINT 中加入相应配置就行。
比如: ENTRYPOINT [ "java" ,"-jar","-Dsentinel.dashboard.app.hideAppNoMachineMillis=60000"

# 5、镜像保存与加载

1
2
3
4
5
6
7
8
9
# 保存iamge到home目录下
docker save -o /home/sentinel-server.tar iamgeName

# 仓home目录下导入image
docker load --input /home/sentinel-server.tar

# 测试:
# 1、save成功以后,删除原有images中的sentinel-server
# 2、导入成功后,重新启动容器,并且成功访问

# 6、控制台介绍

查看机器列表以及健康情况:

在机器列表中看到的连接到 sentinel 的机器,并且展示监控状况

image-20230606131048872

簇点链路:
簇点链路(单机调用链路)页面实时的去拉取指定客户端资源的运行情况。它一共提供两种展示模式:一种用树状结构展示资源的调用链路,另外一种则不区分调用链路展示资源的实时情况。

注意: 簇点链路监控是内存态的信息,它仅展示启动后调用过的资源。

image-20230606131057100

实时监控:
同一个服务下的所有机器的簇点信息会被汇总,并且秒级地展示在 "实时监控" 下。需要确保 Sentinel 控制台所在的机器时间与自己应用的机器时间保持一致,否则会导致拉不到实时的监控数据。

注意:实时监控仅存储 5 分钟以内的数据,如果需要持久化,需要通过调用实时监控接口来定制。

image-20230606131105294

规则管理:
Sentinel 规则分为:流控、降级、热点、 系统、授权,通过控制台可以对各个 资源 配置相应的规则

image-20230606131111772

7、整合 SpringCloud 使用

1
2
3
4
5
6
7
8
spring:
cloud:
sentinel:
transport:
port: 9999 #跟控制台交流的端口,随意指定一个未使用的端口即可
dashboard: 127.0.0.1:8858 # 指定控制台服务的地址
log:
dir: logs/sentinel #日志输出地址

验证:
Sentinel 会在客户端首次调用的时候进行初始化,开始向控制台发送心跳包,我们启动服务后,请求任意一个接口后,即可成功注册。

image-20230606131122702

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【Git】Github提交代码遇到错误:ERROR You‘re using an RSA key with SHA-1, which is no longer allowed

InterviewCoder

# 【Git】Github 提交代码遇到错误:ERROR You‘re using an RSA key with SHA-1, which is no longer allowed

ERROR: You’re using an RSA key with SHA-1, which is no longer allowed.
在这里插入图片描述
通过 baidu,从错误下面给出的官方文档 https://github.blog/2021-09-01-improving-git-protocol-security-github/ 可以看到,github 对 SSH 密钥做了升级,原来的 SHA-1,rsa 等一些已经不支持了,由于我使用的是 rsa,可能和大部分用户一样,所以今天在 push 代码时候遇到了这个问题,记录以下。
生成新的 Ed25519 密钥对:
ssh-keygen -t ed25519 -C "your-email"
一路回车。
会在.ssh 目录下生成两个文件

id_ed25519
id_ed25519.pub

将 id_ed25519.pub 文件中的内容 copy,拿出来到 github 上
这里:

image-20230605155058169
添加一个新的 ssh keys 即可
验证 key 是否可用:使用 ssh -T git@github.com 对 ssh key 进行验证
img
之后就可以正常使用 push 命令上传了。

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【Linux】阿里云linux 挂载数据盘并docker工作目录迁移到数据盘

InterviewCoder

# 【Linux】阿里云 linux 挂载数据盘并 docker 工作目录迁移到数据盘

一、挂载数据盘,分区 - 格式化 - 挂载

1、fdisk -l 查看磁盘情况

执行命令 fdisk -l 发现 2 个磁盘,但是磁盘 nvme1n1 没有分区,nvme0n1 是有 2 个分区了。

img

2、fdisk /dev/nvme1n1 执行创建分区

执行命令 fdisk /dev/nvme1n1

输入 n 再回车创建新分区:

img

输入 p 再回车创建主分区:

img

输入 1 设置分区号:

img

起始扇区设置,直接回车就可以:

img

扇区结束位置,直接回车就可以:

img

最后输入 w 再回车保存设置:

img

3、格式化分区

运行 fdisk -lu /dev/nvme1n1 查看分区情况

img

格式化分区,并建立文件系统 ext4。 执行命令:sudo mkfs.ext4 /dev/nvme1n1p1

注意:也有人说硬盘应该是买来就已经格式化过了的,说的好像也有那么些道理。但是我现在是 nvme1n1 这个硬盘根本看不到分区的情况下创建了一个新的分区 nvme1n1p1 ,格式化一下这个分区我个人认为也没错。

img

4、挂载分区

执行命令:sudo mount /dev/nvme1n1p1 /mnt 将分区 /dev/nvme1n1p1 挂载到 mnt 目录下。

然后执行 df 查看是否挂载成功 ,执行 df -Th 查看新挂载的磁盘文件系统和其他磁盘是否一致。

img

img

5、设置开机自动挂载

sudo vim /etc/fstab 使用这个命令进入 配置文件,然后按 i 键进入插入编辑模式

在文件末尾增加下面的一行

/dev/nvme1n1p1 /mnt ext4 defaults 0 0

按 esc 退出编辑,输入 :wq 保存退出。

二、docker 目录从系统盘迁移到数据盘

1、确认已经将 /dev/nvme1n1 这个数据硬盘的 /dev/nvme1n1p1 分区挂载到了 mnt 目录

img

2、执行 docker info 命令,得到 docker 基本信息,其中可以看到 Docker Root Dir: /var/lib/docker 和 Storage Driver: overlay2 这两个信息,说明了 docker 程序文件安装在 /var/lib/docker,其中 overlay2 为数据存储位置。

img

3、先停止 Docker ,保证移动的时候数据完整,执行 service docker stop 命令停止 Docker daemon。 命令:systemctl stop docker

可以用 ps 命令进一步检查:ps faux | grep -i docker

img

4、将 Docker 默认数据目录下的数据移动到一个备份的目录,例如 /mnt/Docker_data,执行命令:

mv /var/lib/docker /mnt/Docker_data

img

5、把 Docker 的工作目录切换到 /mnt/Docker_data/docker

(1)更改 Docker 服务的 service 文件: vi /lib/systemd/system/docker.service

(2)使用输入 /ExecStart 搜索关键字 , 找到这一行:ExecStart=/usr/bin/dockerd -H fd://--containerd=/run/containerd/containerd.sock

改为:ExecStart=/usr/bin/dockerd -g /mnt/Docker_data/docker -H fd:// --containerd=/run/containerd/containerd.sock

img

(3) 刷新 docker 服务 :sudo systemctl daemon-reload

重启 docker 服务:systemctl start docker

img

(4) 查看 docker 状态:systemctl status docker

img

注意 :如果查看状态出现了失败的红色的提示,那么就要返回第 3 步停止服务开始,再走一遍步骤,重新修改 Docker 服务的 service 文件,比如我开始的时候 - H 前面少了一个空格,就报红色错误了。

img

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【Seata】来自阿里开源分布式事务框架-Seata的原理

InterviewCoder

# 【Seata】来自阿里开源分布式事务框架 - Seata 的原理

首先 Seata 客户端启动一般分为以下几个流程:

  1. 自动加载 Bean 属性和配置信息
  2. 初始化 TM
  3. 初始化 RM
  4. 初始化分布式事务客户端完成,完成代理数据库配置
  5. 连接 TC (Seata 服务端),注册 RM 和 TM
  6. 开启全局事务

在这篇源码的讲解中,我们主要以 AT 模式为主导,官网也是主推 AT 模式,我们在上篇的文章中也讲解过,感兴趣的小伙伴可以去看一看分布式事务 (Seata) 四大模式详解,在官网中也提供了对应的流程地址:https://seata.io/zh-cn/docs/dev/mode/at-mode.html ,在这里我们只是做一些简单的介绍,AT 模式主要分为两个阶段:

一阶段:

  • 解析 SQL,获取 SQL 类型(CRUD)、表信息、条件 (where) 等相关信息
  • 查询前镜像 (改变之前的数据),根据解析得到的条件信息,生成查询语句,定位数据
  • 执行业务 SQL,更新数据
  • 查询后镜像(改变后的数据),根据前镜像的结果,通过主键都给你为数据
  • 插入回滚日志,将前后镜像数据以及业务 SQL 等信息,组织成一条回滚日志记录,插入到 undo Log 表中
  • 提交前,向 TC 注册分支,申请全局锁
  • 本地事务提交,业务数据的更细腻和生成的 undoLog 一起提交
  • 将本地事务提交的结果通知给 TC

二阶段:

如果 TC 收到的是回滚请求

  • 开启本地事务,通过 XID 和 BranchID 查找到对应的 undo Log 记录
  • 根据 undoLog 中的前镜像和业务 SQL 的相关信息生成并执行回滚语句
  • 提交本地事务,将本地事务的执行结果(分支事务回滚的信息)通知给 TC

如果没问题,执行提交操作

  • 收到 TC 分支提交请求,将请求放入到一个异步任务的队列中,马上返回提交成功的结果给 TC
  • 异步任务阶段的分支提交请求删除 undoLog 中记录

img

# 源码入口

接下来,我们就需要从官网中去下载源码,下载地址:https://seata.io/zh-cn/blog/download.html,选择 source 即可,下载完成之后,通过 IDEA 打开项目。

img

源码下载下来之后,我们应该如何去找入口呢?首先我们需要找到对应引入的 Seataspring-alibaba-seata ,我们在回想一下,我们开启事务的时候,是不是添加过一个 @GlobalTransactional 的注解,这个注解就是我们入手的一个点,我们在 spring.factories 中看到有一个 GlobalTransactionAutoConfiguration ,这个就是我们需要关注的点,也就是我们源码的入口

img

GlobalTransactionAutoConfiguration 中我们找到一个用 Bean 注入的方法 globalTransactionScanner ,这个就是全局事务扫描器,这个类型主要负责加载配置,注入相关的 Bean

img

这里给大家展示了当前 GlobalTransactionScanner 的类关系图,其中我们现在继承了 Aop 的 AbstractAutoProxyCreator 类型,在这其中有一个重点方法,这个方法就是判断 Bean 对象是否需要代理,是否需要增强。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
@EnableConfigurationProperties(SeataProperties.class)
public class GlobalTransactionAutoConfiguration {

//全局事务扫描器
@Bean
public GlobalTransactionScanner globalTransactionScanner() {

String applicationName = applicationContext.getEnvironment()
.getProperty("spring.application.name");

String txServiceGroup = seataProperties.getTxServiceGroup();

if (StringUtils.isEmpty(txServiceGroup)) {
txServiceGroup = applicationName + "-fescar-service-group";
seataProperties.setTxServiceGroup(txServiceGroup);
}
// 构建全局扫描器,传入参数:应用名、事务分组名,失败处理器
return new GlobalTransactionScanner(applicationName, txServiceGroup);
}

}

在这其中我们要关心的是 GlobalTransactionScanner 这个类型,这个类型扫描 @GlobalTransactional 注解,并对代理方法进行拦截增强事务的功能。我们就从源码中搜索这个 GlobalTransactionScanner 类,看看里面具体是做了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
/**
* The type Global transaction scanner.
* 全局事务扫描器
* @author slievrly
*/
public class GlobalTransactionScanner
//AbstractAutoProxyCreator AOP动态代理 增强Bean
extends AbstractAutoProxyCreator
/**
* ConfigurationChangeListener: 监听器基准接口
* InitializingBean: Bean初始化
* ApplicationContextAware: Spring容器
* DisposableBean: Spring 容器销毁
*/
implements ConfigurationChangeListener, InitializingBean, ApplicationContextAware, DisposableBean {

private final String applicationId;//服务名
private final String txServiceGroup;//事务分组

private void initClient() {
//启动日志
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Initializing Global Transaction Clients ... ");
}
//检查应用名以及事务分组名,为空抛出异常IllegalArgumentException
if (DEFAULT_TX_GROUP_OLD.equals(txServiceGroup)) {
LOGGER.warn("the default value of seata.tx-service-group: {} has already changed to {} since Seata 1.5, " +
"please change your default configuration as soon as possible " +
"and we don't recommend you to use default tx-service-group's value provided by seata",
DEFAULT_TX_GROUP_OLD, DEFAULT_TX_GROUP);
}
if (StringUtils.isNullOrEmpty(applicationId) || StringUtils.isNullOrEmpty(txServiceGroup)) {
throw new IllegalArgumentException(String.format("applicationId: %s, txServiceGroup: %s", applicationId, txServiceGroup));
}
//init TM
//初始化TM
TMClient.init(applicationId, txServiceGroup, accessKey, secretKey);
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Transaction Manager Client is initialized. applicationId[{}] txServiceGroup[{}]", applicationId, txServiceGroup);
}
//init RM
//初始化RM
RMClient.init(applicationId, txServiceGroup);
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Resource Manager is initialized. applicationId[{}] txServiceGroup[{}]", applicationId, txServiceGroup);
}

if (LOGGER.isInfoEnabled()) {
LOGGER.info("Global Transaction Clients are initialized. ");
}
registerSpringShutdownHook();

}

@Override
public void afterPropertiesSet() {
if (disableGlobalTransaction) {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Global transaction is disabled.");
}
ConfigurationCache.addConfigListener(ConfigurationKeys.DISABLE_GLOBAL_TRANSACTION,
(ConfigurationChangeListener)this);
return;
}
if (initialized.compareAndSet(false, true)) {
initClient();
}
}

private void initClient() {
//启动日志
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Initializing Global Transaction Clients ... ");
}
//检查应用名以及事务分组名,为空抛出异常IllegalArgumentException
if (DEFAULT_TX_GROUP_OLD.equals(txServiceGroup)) {
LOGGER.warn("the default value of seata.tx-service-group: {} has already changed to {} since Seata 1.5, " +
"please change your default configuration as soon as possible " +
"and we don't recommend you to use default tx-service-group's value provided by seata",
DEFAULT_TX_GROUP_OLD, DEFAULT_TX_GROUP);
}

//检查应用名以及事务分组名,为空抛出异常IllegalArgumentException
if (StringUtils.isNullOrEmpty(applicationId) || StringUtils.isNullOrEmpty(txServiceGroup)) {
throw new IllegalArgumentException(String.format("applicationId: %s, txServiceGroup: %s", applicationId, txServiceGroup));
}
//init TM
//初始化TM
TMClient.init(applicationId, txServiceGroup, accessKey, secretKey);
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Transaction Manager Client is initialized. applicationId[{}] txServiceGroup[{}]", applicationId, txServiceGroup);
}
//init RM
//初始化RM
RMClient.init(applicationId, txServiceGroup);
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Resource Manager is initialized. applicationId[{}] txServiceGroup[{}]", applicationId, txServiceGroup);
}

if (LOGGER.isInfoEnabled()) {
LOGGER.info("Global Transaction Clients are initialized. ");
}
registerSpringShutdownHook();

}

//代理增强,Spring 所有的Bean都会经过这个方法
@Override
protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
// do checkers
//检查bean和beanName
if (!doCheckers(bean, beanName)) {
return bean;
}

try {
//加锁防止并发
synchronized (PROXYED_SET) {
if (PROXYED_SET.contains(beanName)) {
return bean;
}
interceptor = null;
//check TCC proxy
//检查是否为TCC模式
if (TCCBeanParserUtils.isTccAutoProxy(bean, beanName, applicationContext)) {
// init tcc fence clean task if enable useTccFence
//如果启用useTccFence 失败 ,则初始化TCC清理任务
TCCBeanParserUtils.initTccFenceCleanTask(TCCBeanParserUtils.getRemotingDesc(beanName), applicationContext);
//TCC interceptor, proxy bean of sofa:reference/dubbo:reference, and LocalTCC
//如果是,添加TCC拦截器
interceptor = new TccActionInterceptor(TCCBeanParserUtils.getRemotingDesc(beanName));
ConfigurationCache.addConfigListener(ConfigurationKeys.DISABLE_GLOBAL_TRANSACTION,
(ConfigurationChangeListener)interceptor);
} else {
//不是TCC
Class<?> serviceInterface = SpringProxyUtils.findTargetClass(bean);
Class<?>[] interfacesIfJdk = SpringProxyUtils.findInterfaces(bean);

//判断是否有相关事务注解,如果没有不进行代理
if (!existsAnnotation(new Class[]{serviceInterface})
&& !existsAnnotation(interfacesIfJdk)) {
return bean;
}

//发现存在全局事务注解标注的Bean对象,添加拦截器
if (globalTransactionalInterceptor == null) {
//添加拦截器
globalTransactionalInterceptor = new GlobalTransactionalInterceptor(failureHandlerHook);
ConfigurationCache.addConfigListener(
ConfigurationKeys.DISABLE_GLOBAL_TRANSACTION,
(ConfigurationChangeListener)globalTransactionalInterceptor);
}
interceptor = globalTransactionalInterceptor;
}

LOGGER.info("Bean[{}] with name [{}] would use interceptor [{}]", bean.getClass().getName(), beanName, interceptor.getClass().getName());
//检查是否为代理对象
if (!AopUtils.isAopProxy(bean)) {
//不是代理对象,调用父级
bean = super.wrapIfNecessary(bean, beanName, cacheKey);
} else {
//是代理对象,反射获取代理类中已经存在的拦截器组合,然后添加到这个集合中
AdvisedSupport advised = SpringProxyUtils.getAdvisedSupport(bean);
Advisor[] advisor = buildAdvisors(beanName, getAdvicesAndAdvisorsForBean(null, null, null));
int pos;
for (Advisor avr : advisor) {
// Find the position based on the advisor's order, and add to advisors by pos
pos = findAddSeataAdvisorPosition(advised, avr);
advised.addAdvisor(pos, avr);
}
}
PROXYED_SET.add(beanName);
return bean;
}
} catch (Exception exx) {
throw new RuntimeException(exx);
}
}


}

InitializingBean`:中实现了一个 `afterPropertiesSet()`方法,在这个方法中,调用了`initClient()

AbstractAutoProxyCreator :APO 动态代理,在之前的的 Nacos 和 Sentiel 中都有这个代理类,AOP 在我们越往深入学习,在学习源码的会见到的越来越多,越来越重要,很多相关代理,都是通过 AOP 进行增强,在这个类中,我们需要关注有一个 wrapIfNecessary() 方法, 这个方法主要是判断被代理的 bean 或者类是否需要代理增强,在这个方法中会调用 GlobalTransactionalInterceptor.invoke() 进行带来增强。

具体代码如下:

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
public class GlobalTransactionalInterceptor implements ConfigurationChangeListener, MethodInterceptor, SeataInterceptor {

public GlobalTransactionalInterceptor(FailureHandler failureHandler) {
this.failureHandler = failureHandler == null ? DEFAULT_FAIL_HANDLER : failureHandler;
this.disable = ConfigurationFactory.getInstance().getBoolean(ConfigurationKeys.DISABLE_GLOBAL_TRANSACTION,
DEFAULT_DISABLE_GLOBAL_TRANSACTION);
this.order =
ConfigurationFactory.getInstance().getInt(ConfigurationKeys.TM_INTERCEPTOR_ORDER, TM_INTERCEPTOR_ORDER);
degradeCheck = ConfigurationFactory.getInstance().getBoolean(ConfigurationKeys.CLIENT_DEGRADE_CHECK,
DEFAULT_TM_DEGRADE_CHECK);
if (degradeCheck) {
ConfigurationCache.addConfigListener(ConfigurationKeys.CLIENT_DEGRADE_CHECK, this);
degradeCheckPeriod = ConfigurationFactory.getInstance()
.getInt(ConfigurationKeys.CLIENT_DEGRADE_CHECK_PERIOD, DEFAULT_TM_DEGRADE_CHECK_PERIOD);
degradeCheckAllowTimes = ConfigurationFactory.getInstance()
.getInt(ConfigurationKeys.CLIENT_DEGRADE_CHECK_ALLOW_TIMES, DEFAULT_TM_DEGRADE_CHECK_ALLOW_TIMES);
EVENT_BUS.register(this);
if (degradeCheckPeriod > 0 && degradeCheckAllowTimes > 0) {
startDegradeCheck();
}
}
this.initDefaultGlobalTransactionTimeout();
}

@Override
public Object invoke(final MethodInvocation methodInvocation) throws Throwable {
//获取执行的方法
Class<?> targetClass =
methodInvocation.getThis() != null ? AopUtils.getTargetClass(methodInvocation.getThis()) : null;
Method specificMethod = ClassUtils.getMostSpecificMethod(methodInvocation.getMethod(), targetClass);
if (specificMethod != null && !specificMethod.getDeclaringClass().equals(Object.class)) {
final Method method = BridgeMethodResolver.findBridgedMethod(specificMethod);
//获取GlobalTransactional(全局事务)、GlobalLock(全局锁)元数据
final GlobalTransactional globalTransactionalAnnotation =
getAnnotation(method, targetClass, GlobalTransactional.class);
//GlobalLock会将本地事务的执行纳入Seata分布式事务的管理,共同竞争全局锁
//保证全局事务在执行的时候,本地事务不可以操作全局事务的记录
final GlobalLock globalLockAnnotation = getAnnotation(method, targetClass, GlobalLock.class);//获取全局锁
boolean localDisable = disable || (degradeCheck && degradeNum >= degradeCheckAllowTimes);
if (!localDisable) {
if (globalTransactionalAnnotation != null || this.aspectTransactional != null) {
AspectTransactional transactional;
if (globalTransactionalAnnotation != null) {
transactional = new AspectTransactional(globalTransactionalAnnotation.timeoutMills(),
globalTransactionalAnnotation.name(), globalTransactionalAnnotation.rollbackFor(),
globalTransactionalAnnotation.noRollbackForClassName(),
globalTransactionalAnnotation.noRollbackFor(),
globalTransactionalAnnotation.noRollbackForClassName(),
globalTransactionalAnnotation.propagation(),
globalTransactionalAnnotation.lockRetryInterval(),
globalTransactionalAnnotation.lockRetryTimes());
} else {
transactional = this.aspectTransactional;
}
//执行全局事务
return handleGlobalTransaction(methodInvocation, transactional);
} else if (globalLockAnnotation != null) {
//执行全局锁
return handleGlobalLock(methodInvocation, globalLockAnnotation);
}
}
}
return methodInvocation.proceed();
}

}

具体流程图如下所示:

img

# 核心源码

在上面我们讲解到 GlobalTransactionalInterceptor 作为全局事务拦截器,一旦执行拦截,就会进入 invoke 方法,其中,我们会做 @GlobalTransactional 注解的判断,如果有这个注解的存在,会执行全局事务和全局锁,再执行全局事务的时候会调用 handleGlobalTransaction 全局事务处理器,获取事务信息,那我们接下来就来看一下 GlobalTransactionalInterceptor.handleGlobalTransaction 到底是如何执行全局事务的

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
Object handleGlobalTransaction(final MethodInvocation methodInvocation,
final AspectTransactional aspectTransactional) throws Throwable {
boolean succeed = true;
try {
return transactionalTemplate.execute(new TransactionalExecutor() {
@Override
public Object execute() throws Throwable {
return methodInvocation.proceed();
}

//获取事务名称,默认获取方法名
public String name() {
String name = aspectTransactional.getName();
if (!StringUtils.isNullOrEmpty(name)) {
return name;
}
return formatMethod(methodInvocation.getMethod());
}

/**
* 解析GlobalTransation注解属性,封装对对象
* @return
*/
@Override
public TransactionInfo getTransactionInfo() {
// reset the value of timeout
//获取超时时间,默认60秒
int timeout = aspectTransactional.getTimeoutMills();
if (timeout <= 0 || timeout == DEFAULT_GLOBAL_TRANSACTION_TIMEOUT) {
timeout = defaultGlobalTransactionTimeout;
}

//构建事务信息对象
TransactionInfo transactionInfo = new TransactionInfo();
transactionInfo.setTimeOut(timeout);//超时时间
transactionInfo.setName(name());//事务名称
transactionInfo.setPropagation(aspectTransactional.getPropagation());//事务传播
transactionInfo.setLockRetryInterval(aspectTransactional.getLockRetryInterval());//校验或占用全局锁重试间隔
transactionInfo.setLockRetryTimes(aspectTransactional.getLockRetryTimes());//校验或占用全局锁重试次数
Set<RollbackRule> rollbackRules = new LinkedHashSet<>();
//其他构建信息
for (Class<?> rbRule : aspectTransactional.getRollbackFor()) {
rollbackRules.add(new RollbackRule(rbRule));
}
for (String rbRule : aspectTransactional.getRollbackForClassName()) {
rollbackRules.add(new RollbackRule(rbRule));
}
for (Class<?> rbRule : aspectTransactional.getNoRollbackFor()) {
rollbackRules.add(new NoRollbackRule(rbRule));
}
for (String rbRule : aspectTransactional.getNoRollbackForClassName()) {
rollbackRules.add(new NoRollbackRule(rbRule));
}
transactionInfo.setRollbackRules(rollbackRules);
return transactionInfo;
}
});
} catch (TransactionalExecutor.ExecutionException e) {
//执行异常
TransactionalExecutor.Code code = e.getCode();
switch (code) {
case RollbackDone:
throw e.getOriginalException();
case BeginFailure:
succeed = false;
failureHandler.onBeginFailure(e.getTransaction(), e.getCause());
throw e.getCause();
case CommitFailure:
succeed = false;
failureHandler.onCommitFailure(e.getTransaction(), e.getCause());
throw e.getCause();
case RollbackFailure:
failureHandler.onRollbackFailure(e.getTransaction(), e.getOriginalException());
throw e.getOriginalException();
case RollbackRetrying:
failureHandler.onRollbackRetrying(e.getTransaction(), e.getOriginalException());
throw e.getOriginalException();
default:
throw new ShouldNeverHappenException(String.format("Unknown TransactionalExecutor.Code: %s", code));
}
} finally {
if (degradeCheck) {
EVENT_BUS.post(new DegradeCheckEvent(succeed));
}
}
}

在这里我们,主要关注一个重点方法 execute() ,这个方法主要用来执行事务的具体流程:

  • 获取事务信息
  • 执行全局事务
  • 发生异常全局回滚,各个数据通过 UndoLog 进行事务补偿
  • 全局事务提交
  • 清除所有资源

这个位置也是一个非常核心的一个位置,因为我们所有的业务进来以后都会去走这个位置,具体源码如下所示:

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
public Object execute(TransactionalExecutor business) throws Throwable {
// 1. Get transactionInfo
//获取事务信息
TransactionInfo txInfo = business.getTransactionInfo();
if (txInfo == null) {
throw new ShouldNeverHappenException("transactionInfo does not exist");
}
// 1.1 Get current transaction, if not null, the tx role is 'GlobalTransactionRole.Participant'.
//获取当前事务,主要获取XID
GlobalTransaction tx = GlobalTransactionContext.getCurrent();

// 1.2 Handle the transaction propagation.
//根据配置的不同事务传播行为,执行不同的逻辑
Propagation propagation = txInfo.getPropagation();
SuspendedResourcesHolder suspendedResourcesHolder = null;
try {
switch (propagation) {
case NOT_SUPPORTED:
// If transaction is existing, suspend it.
if (existingTransaction(tx)) {
suspendedResourcesHolder = tx.suspend();
}
// Execute without transaction and return.
return business.execute();
case REQUIRES_NEW:
// If transaction is existing, suspend it, and then begin new transaction.
if (existingTransaction(tx)) {
suspendedResourcesHolder = tx.suspend();
tx = GlobalTransactionContext.createNew();
}
// Continue and execute with new transaction
break;
case SUPPORTS:
// If transaction is not existing, execute without transaction.
if (notExistingTransaction(tx)) {
return business.execute();
}
// Continue and execute with new transaction
break;
case REQUIRED:
// If current transaction is existing, execute with current transaction,
// else continue and execute with new transaction.
break;
case NEVER:
// If transaction is existing, throw exception.
if (existingTransaction(tx)) {
throw new TransactionException(
String.format("Existing transaction found for transaction marked with propagation 'never', xid = %s"
, tx.getXid()));
} else {
// Execute without transaction and return.
return business.execute();
}
case MANDATORY:
// If transaction is not existing, throw exception.
if (notExistingTransaction(tx)) {
throw new TransactionException("No existing transaction found for transaction marked with propagation 'mandatory'");
}
// Continue and execute with current transaction.
break;
default:
throw new TransactionException("Not Supported Propagation:" + propagation);
}

// 1.3 If null, create new transaction with role 'GlobalTransactionRole.Launcher'.
//如果当前事务为空,创建一个新的事务
if (tx == null) {
tx = GlobalTransactionContext.createNew();
}

// set current tx config to holder
GlobalLockConfig previousConfig = replaceGlobalLockConfig(txInfo);

try {
// 2. If the tx role is 'GlobalTransactionRole.Launcher', send the request of beginTransaction to TC,
// else do nothing. Of course, the hooks will still be triggered.
//开始执行全局事务
beginTransaction(txInfo, tx);

Object rs;
try {
// Do Your Business
// 执行当前业务逻辑
//1、在TC注册当前分支事务,TC会在branch_table中插入一条分支事务数据
//2、执行本地update语句,并在执行前后查询数据状态,并把数据前后镜像存入到undo_log中
//3、远程调用其他应用,远程应用接收到XID,也会注册分支事务,写入branch_table以及本地undo_log表
//4、会在lock_table表中插入全局锁数据(一个分支一条)
rs = business.execute();
} catch (Throwable ex) {
// 3. The needed business exception to rollback.
//发生异常全局回滚,每个事务通过undo_log表进行事务补偿
completeTransactionAfterThrowing(txInfo, tx, ex);
throw ex;
}

// 4. everything is fine, commit.
//全局提交
commitTransaction(tx);

return rs;
} finally {
//5. clear
//清理所有资源
resumeGlobalLockConfig(previousConfig);
triggerAfterCompletion();
cleanUp();
}
} finally {
// If the transaction is suspended, resume it.
if (suspendedResourcesHolder != null) {
tx.resume(suspendedResourcesHolder);
}
}
}

这其中的第三步和第四步其实在向 TC(Seata-Server)发起全局事务的提交或者回滚,在这里我们首先关注执行全局事务的 beginTransaction() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
// 向TC发起请求,采用模板模式
private void beginTransaction(TransactionInfo txInfo, GlobalTransaction tx) throws TransactionalExecutor.ExecutionException {
try {
triggerBeforeBegin();
//对TC发起请求
tx.begin(txInfo.getTimeOut(), txInfo.getName());
triggerAfterBegin();
} catch (TransactionException txe) {
throw new TransactionalExecutor.ExecutionException(tx, txe,
TransactionalExecutor.Code.BeginFailure);

}
}

在来关注其中,向 TC 发起请求的 tx.begin() 方法,而调用 begin() 方法的类为: DefaultGlobalTransaction

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
@Override
public void begin(int timeout, String name) throws TransactionException {
//判断调用者是否为TM
if (role != GlobalTransactionRole.Launcher) {
assertXIDNotNull();
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Ignore Begin(): just involved in global transaction [{}]", xid);
}
return;
}
assertXIDNull();
String currentXid = RootContext.getXID();
if (currentXid != null) {
throw new IllegalStateException("Global transaction already exists," +
" can't begin a new global transaction, currentXid = " + currentXid);
}
//获取XID
xid = transactionManager.begin(null, null, name, timeout);
status = GlobalStatus.Begin;
//绑定XID
RootContext.bind(xid);
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Begin new global transaction [{}]", xid);
}
}

再来看一下 transactionManager.begin() 方法,这个时候使用的是 DefaultTransactionManager.begin 默认的事务管理者,来获取 XID,传入事务相关的信息 ,最好 TC 返回对应的全局事务 XID,它调用的是 DefaultTransactionManager.begin() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public String begin(String applicationId, String transactionServiceGroup, String name, int timeout)
throws TransactionException {
GlobalBeginRequest request = new GlobalBeginRequest();
request.setTransactionName(name);
request.setTimeout(timeout);
//发送请求得到响应
GlobalBeginResponse response = (GlobalBeginResponse) syncCall(request);
if (response.getResultCode() == ResultCode.Failed) {
throw new TmTransactionException(TransactionExceptionCode.BeginFailed, response.getMsg());
}
//返回XID
return response.getXid();
}

在这里我们需要关注一个 syncCall ,在这里采用的是 Netty 通讯方式

1
2
3
4
5
6
7
8
private AbstractTransactionResponse syncCall(AbstractTransactionRequest request) throws TransactionException {
try {
// 通过Netty发送请求
return (AbstractTransactionResponse) TmNettyRemotingClient.getInstance().sendSyncRequest(request);
} catch (TimeoutException toe) {
throw new TmTransactionException(TransactionExceptionCode.IO, "RPC timeout", toe);
}
}

具体图解如下:

img

在这里我们需要重点了解 GlobalTransactionScanner 这个类型,在这个类型中继承了一些接口和抽象类,这个类主要作用就是扫描有注解的 Bean,并做 AOP 增强。

  • ApplicationContextAware :继承这个类型以后,需要实现其方法 setApplicationContext() ,当 Spring 启动完成以后,会自动调用这个类型,将 ApplicationContextbean ,也就是说, GlobalTransactionScanner 能够很自然的使用 Spring 环境
  • InitializingBean : 继承这个接口,需要实现 afterPropertiesSet() ,但凡是继承这个接口的类,在初始化的时候,当所有的 properties 设置完成以后,会执行这个方法
  • DisposableBean : 这个类,实现了一个 destroy() 这个方法是在销毁的时候去调用
  • AbstractAutoProxyCreator : 这个类是 Spring 实现 AOP 的一种方式,本质上是一个 BeanPostProcessor ,在 Bean 初始化至去年,调用内部 createProxy() ,创建一个 Bean 的 AOP 代理 Bean 并返回,对 Bean 进行增强。

# Seata 数据源代理

在上面的环节中,我们讲解了 Seata AT 模式 2PC 的执行流程,那么现在我们就来带大家了解一下关于 AT 数据源代理的信息,这也是 AT 模式中非常关键的一个重要知识点,大家可以拿起小本子,记下来。

首先 AT 模式的核心主要分为一下两个

  • 开启全局事务,获取全局锁。
  • 解析 SQL 并写入 undoLog 中。

关于第一点我们已经分析清楚了,第二点就是关于 AT 模式如何解析 SQL 并写入 undoLog 中,但是在这之前,我们需要知道 Seata 是如何选择数据源,并进行数据源代理的。虽然全局事务拦截成功后最终还是执行了业务方法进行 SQL 提交和操作,但是由于 Seata 对数据源进行了代理,所以 SQL 的解析和 undoLog 的操作,是在数据源代理中进行完成的。

数据源代理是 Seata 中一个非常重要的知识点,在分布式事务运行过程中,undoLog 的记录、资源的锁定,用户都是无感知的,因为这些操作都是数据源的代理中完成了,恰恰是这样,我们才要去了解,这样不仅有利于我们了解 Seata 的核心操作,还能对以后源码阅读有所帮助,因为其实很多底层代码都会去使用这样用户无感知的方式 (代理) 去实现。

同样,我们在之前的寻找源码入口的时候,通过我们项目中引入的 jar 找到一个 SeataAutoConfiguration 类,我们在里面找到一个 SeataDataSourceBeanPostProcessor() ,这个就是我们数据源代理的入口方法

img

我们进入 SeataDataSourceBeanPostProcessor 类里面,发现继承了一个 BeanPostProcessor , 这个接口我们应该很熟悉,这个是 Sprng 的拓展接口,所有的 Bean 对象,都有进入两个方法 postProcessAfterInitialization()postProcessBeforeInitialization() 这两个方法都是由 BeanPostProcessor 提供的,这两个方法,一个是初始化之前执行 Before 。一个是在初始化之后执行 After ,主要用来对比我们的的 Bean 是否为数据源代理对象。

在这里我们需要关注到一个 postProcessAfterInitialization.proxyDataSource() 方法,这个里面

1
2
3
4
5
6
7
8
9
private Object proxyDataSource(Object originBean) {
DataSourceProxy dataSourceProxy = DataSourceProxyHolder.get().putDataSource((DataSource) originBean);
if (this.useJdkProxy) {
return Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), SpringProxyUtils.getAllInterfaces(originBean), (proxy, method, args) -> handleMethodProxy(dataSourceProxy, method, args, originBean));
} else {
return Enhancer.create(originBean.getClass(), (MethodInterceptor) (proxy, method, args, methodProxy) -> handleMethodProxy(dataSourceProxy, method, args, originBean));
}

}

这里有一个 DataSourceProxy 代理对象,我们需要看的就是这个类,这个就是我们数据库代理的对象,我们从我们下载的源码项目中,搜索这个代理对象,当我们打开这个类的目录时发现,除了这个,还有 ConnectionProxy 连接对象、 StatementProxyPreparedStatementProxy SQL 执行对象,这些都被 Seata 进行了代理,为什么要对这些都进行代理,代理的目的其实为了执行 Seata 的业务逻辑,生成 undoLog,全局事务的开启,事务的提交回滚等操作

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
DataSourceProxy` 具体做了什么,主要功能有哪些,我们来看一下。他在源码中是如何体现的,我们需要关注的是`init()
public class DataSourceProxy extends AbstractDataSourceProxy implements Resource {

private String resourceGroupId;

private void init(DataSource dataSource, String resourceGroupId) {
//资源组ID,默认是“default”这个默认值
this.resourceGroupId = resourceGroupId;
try (Connection connection = dataSource.getConnection()) {
//根据原始数据源得到JDBC连接和数据库类型
jdbcUrl = connection.getMetaData().getURL();
dbType = JdbcUtils.getDbType(jdbcUrl);
if (JdbcConstants.ORACLE.equals(dbType)) {
userName = connection.getMetaData().getUserName();
} else if (JdbcConstants.MARIADB.equals(dbType)) {
dbType = JdbcConstants.MYSQL;
}
} catch (SQLException e) {
throw new IllegalStateException("can not init dataSource", e);
}
initResourceId();
DefaultResourceManager.get().registerResource(this);
if (ENABLE_TABLE_META_CHECKER_ENABLE) {
//如果配置开关打开,会定时在线程池不断更新表的元数据缓存信息
tableMetaExecutor.scheduleAtFixedRate(() -> {
try (Connection connection = dataSource.getConnection()) {
TableMetaCacheFactory.getTableMetaCache(DataSourceProxy.this.getDbType())
.refresh(connection, DataSourceProxy.this.getResourceId());
} catch (Exception ignore) {
}
}, 0, TABLE_META_CHECKER_INTERVAL, TimeUnit.MILLISECONDS);
}

//Set the default branch type to 'AT' in the RootContext.
RootContext.setDefaultBranchType(this.getBranchType());
}
}

从上面我们可以看出,他主要做了以下几点的增强:

  1. 给每个数据源标识资源组 ID
  2. 如果打开配置,会有一个定时线程池定时更新表的元数据信息并缓存到本地
  3. 生成代理连接 ConnectionProxy 对象

在这三个增强功能里面,第三个是最重要的,在 AT 模式里面,会自动记录 undoLog,资源锁定,都是通过 ConnectionProxy 完成的,除此之外 DataSrouceProxy 重写了一个方法 getConnection ,因为这里返回的是一个 ConnectionProxy ,而不是原生的 Connection

1
2
3
4
5
6
7
8
9
10
11
@Override
public ConnectionProxy getConnection() throws SQLException {
Connection targetConnection = targetDataSource.getConnection();
return new ConnectionProxy(this, targetConnection);
}

@Override
public ConnectionProxy getConnection(String username, String password) throws SQLException {
Connection targetConnection = targetDataSource.getConnection(username, password);
return new ConnectionProxy(this, targetConnection);
}

# ConnectionProxy

1
ConnectionProxy` 继承 `AbstractConnectionProxy` ,在这个父类中有很多公用的方法,在这个父类有 `PreparedStatementProxy` 、`StatementProxy` 、`DataSourceProxy

img

所以我们需要先来看一下 AbstractConnectionProxy ,因为这里封装了需要我们用到的通用方法和逻辑,在其中我们需要关注的主要在于 PreparedStatementProxyStatementProxy ,在这里的逻辑主要是数据源连接的步骤,连接获取,创建执行对象等等

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
@Override
public Statement createStatement() throws SQLException {
//调用真实连接对象获取Statement对象
Statement targetStatement = getTargetConnection().createStatement();
//创建Statement的代理
return new StatementProxy(this, targetStatement);
}

@Override
public PreparedStatement prepareStatement(String sql) throws SQLException {
//获取数据库类型 mysql/oracle
String dbType = getDbType();
// support oracle 10.2+
PreparedStatement targetPreparedStatement = null;
//如果是AT模式且开启全局事务
if (BranchType.AT == RootContext.getBranchType()) {
List<SQLRecognizer> sqlRecognizers = SQLVisitorFactory.get(sql, dbType);
if (sqlRecognizers != null && sqlRecognizers.size() == 1) {
SQLRecognizer sqlRecognizer = sqlRecognizers.get(0);
if (sqlRecognizer != null && sqlRecognizer.getSQLType() == SQLType.INSERT) {
//获取表的元数据
TableMeta tableMeta = TableMetaCacheFactory.getTableMetaCache(dbType).getTableMeta(getTargetConnection(),
sqlRecognizer.getTableName(), getDataSourceProxy().getResourceId());
//得到表的主键列名
String[] pkNameArray = new String[tableMeta.getPrimaryKeyOnlyName().size()];
tableMeta.getPrimaryKeyOnlyName().toArray(pkNameArray);
targetPreparedStatement = getTargetConnection().prepareStatement(sql,pkNameArray);
}
}
}
if (targetPreparedStatement == null) {
targetPreparedStatement = getTargetConnection().prepareStatement(sql);
}
//创建PreparedStatementProxy代理
return new PreparedStatementProxy(this, targetPreparedStatement, sql);
}

在这两个代理对象中,都用到了以下几个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public ResultSet executeQuery(String sql) throws SQLException {
this.targetSQL = sql;
return ExecuteTemplate.execute(this, (statement, args) -> statement.executeQuery((String) args[0]), sql);
}

@Override
public int executeUpdate(String sql) throws SQLException {
this.targetSQL = sql;
return ExecuteTemplate.execute(this, (statement, args) -> statement.executeUpdate((String) args[0]), sql);
}

@Override
public boolean execute(String sql) throws SQLException {
this.targetSQL = sql;
return ExecuteTemplate.execute(this, (statement, args) -> statement.execute((String) args[0]), sql);
}

在这些方法中都调用了 ExecuteTemplate.execute() ,所以我们就看一下在 ExecuteTemplate 类中具体是做了什么操作:

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
public class ExecuteTemplate {

public static <T, S extends Statement> T execute(List<SQLRecognizer> sqlRecognizers,
StatementProxy<S> statementProxy,
StatementCallback<T, S> statementCallback,
Object... args) throws SQLException {
//如果没有全局锁,并且不是AT模式,直接执行SQL
if (!RootContext.requireGlobalLock() && BranchType.AT != RootContext.getBranchType()) {
// Just work as original statement
return statementCallback.execute(statementProxy.getTargetStatement(), args);
}

//得到数据库类型- mysql/oracle
String dbType = statementProxy.getConnectionProxy().getDbType();
if (CollectionUtils.isEmpty(sqlRecognizers)) {
//sqlRecognizers 为SQL语句的解析器,获取执行的SQL,通过它可以获得SQL语句表名、相关的列名、类型等信息,最后解析出对应的SQL表达式
sqlRecognizers = SQLVisitorFactory.get(
statementProxy.getTargetSQL(),
dbType);
}
Executor<T> executor;
if (CollectionUtils.isEmpty(sqlRecognizers)) {
//如果seata没有找到合适的SQL语句解析器,那么便创建简单执行器PlainExecutor
//PlainExecutor直接使用原生的Statment对象执行SQL
executor = new PlainExecutor<>(statementProxy, statementCallback);
} else {
if (sqlRecognizers.size() == 1) {
SQLRecognizer sqlRecognizer = sqlRecognizers.get(0);
switch (sqlRecognizer.getSQLType()) {
//新增
case INSERT:
executor = EnhancedServiceLoader.load(InsertExecutor.class, dbType,
new Class[]{StatementProxy.class, StatementCallback.class, SQLRecognizer.class},
new Object[]{statementProxy, statementCallback, sqlRecognizer});
break;
//修改
case UPDATE:
executor = new UpdateExecutor<>(statementProxy, statementCallback, sqlRecognizer);
break;
//删除
case DELETE:
executor = new DeleteExecutor<>(statementProxy, statementCallback, sqlRecognizer);
break;
//加锁
case SELECT_FOR_UPDATE:
executor = new SelectForUpdateExecutor<>(statementProxy, statementCallback, sqlRecognizer);
break;
//插入加锁
case INSERT_ON_DUPLICATE_UPDATE:
switch (dbType) {
case JdbcConstants.MYSQL:
case JdbcConstants.MARIADB:
executor =
new MySQLInsertOrUpdateExecutor(statementProxy, statementCallback, sqlRecognizer);
break;
default:
throw new NotSupportYetException(dbType + " not support to INSERT_ON_DUPLICATE_UPDATE");
}
break;
//原生
default:
executor = new PlainExecutor<>(statementProxy, statementCallback);
break;
}
} else {
//批量处理SQL语句
executor = new MultiExecutor<>(statementProxy, statementCallback, sqlRecognizers);
}
}
T rs;
try {
//执行
rs = executor.execute(args);
} catch (Throwable ex) {
if (!(ex instanceof SQLException)) {
// Turn other exception into SQLException
ex = new SQLException(ex);
}
throw (SQLException) ex;
}
return rs;
}

}

ExecuteTemplate 就一个 execute() ,Seata 将 SQL 执行委托给不同的执行器 (模板),Seata 提供了 6 种执行器也就是我们代码 case 中( INSERTUPDATEDELETESELECT_FOR_UPDATE , INSERT_ON_DUPLICATE_UPDATE ),这些执行器的父类都是 AbstractDMLBaseExecutor

  • UpdateExecutor : 执行 update 语句
  • InsertExecutor : 执行 insert 语句
  • DeleteExecutor : 执行 delete 语句
  • SelectForUpdateExecutor : 执行 select for update 语句
  • PlainExecutor : 执行普通查询语句
  • MultiExecutor : 复合执行器,在一条 SQL 语句中执行多条语句

关系图如下:

img

然后我们找到 rs = executor.execute(args); 最终执行的方法,找到最顶级的父类 BaseTransactionalExecutor.execute()

1
2
3
4
5
6
7
8
9
10
11
@Override
public T execute(Object... args) throws Throwable {
String xid = RootContext.getXID();
if (xid != null) {
//获取XID
statementProxy.getConnectionProxy().bind(xid);
}
//设置全局锁
statementProxy.getConnectionProxy().setGlobalLockRequire(RootContext.requireGlobalLock());
return doExecute(args);
}

在根据 doExecute(args); 找到其中的重写方法 AbstractDMLBaseExecutor.doExecute()

1
2
3
4
5
6
7
8
9
10
@Override
public T doExecute(Object... args) throws Throwable {
AbstractConnectionProxy connectionProxy = statementProxy.getConnectionProxy();
//是否自动提交
if (connectionProxy.getAutoCommit()) {
return executeAutoCommitTrue(args);
} else {
return executeAutoCommitFalse(args);
}
}

对于数据库而言,本身都是自动提交的,所以我们进入 executeAutoCommitTrue()

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
    protected T executeAutoCommitTrue(Object[] args) throws Throwable {
ConnectionProxy connectionProxy = statementProxy.getConnectionProxy();
try {
//设置为手动提交
connectionProxy.changeAutoCommit();
return new LockRetryPolicy(connectionProxy).execute(() -> {
//调用手动提交方法,得到分支执行的最终结果
T result = executeAutoCommitFalse(args);
//执行提交
connectionProxy.commit();
return result;
});
} catch (Exception e) {
// when exception occur in finally,this exception will lost, so just print it here
LOGGER.error("execute executeAutoCommitTrue error:{}", e.getMessage(), e);
if (!LockRetryPolicy.isLockRetryPolicyBranchRollbackOnConflict()) {
connectionProxy.getTargetConnection().rollback();
}
throw e;
} finally {
connectionProxy.getContext().reset();
connectionProxy.setAutoCommit(true);
}
}

connectionProxy.changeAutoCommit()`方法,修改为手动提交后,我们看来最关键的代码`executeAutoCommitFalse()
protected T executeAutoCommitFalse(Object[] args) throws Exception {
if (!JdbcConstants.MYSQL.equalsIgnoreCase(getDbType()) && isMultiPk()) {
throw new NotSupportYetException("multi pk only support mysql!");
}
//获取前镜像
TableRecords beforeImage = beforeImage();
//执行具体业务
T result = statementCallback.execute(statementProxy.getTargetStatement(), args);
//获取执行数量
int updateCount = statementProxy.getUpdateCount();
//判断如果执行数量大于0
if (updateCount > 0) {
//获取后镜像
TableRecords afterImage = afterImage(beforeImage);
//暂存到undolog中,在Commit的时候保存到数据库
prepareUndoLog(beforeImage, afterImage);
}
return result;
}

我们再回到 executeAutoCommitTrue 中,去看看提交做了哪些操作 connectionProxy.commit();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public void commit() throws SQLException {
try {
lockRetryPolicy.execute(() -> {
//具体执行
doCommit();
return null;
});
} catch (SQLException e) {
if (targetConnection != null && !getAutoCommit() && !getContext().isAutoCommitChanged()) {
rollback();
}
throw e;
} catch (Exception e) {
throw new SQLException(e);
}
}

进入到 doCommit()

1
2
3
4
5
6
7
8
9
10
private void doCommit() throws SQLException {
//判断是否存在全局事务
if (context.inGlobalTransaction()) {
processGlobalTransactionCommit();
} else if (context.isGlobalLockRequire()) {
processLocalCommitWithGlobalLocks();
} else {
targetConnection.commit();
}
}

作为分布式事务,一定是存在全局事务的,所以我们进入 processGlobalTransactionCommit()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void processGlobalTransactionCommit() throws SQLException {
try {
//注册分支事务
register();
} catch (TransactionException e) {
recognizeLockKeyConflictException(e, context.buildLockKeys());
}
try {
//写入数据库undolog
UndoLogManagerFactory.getUndoLogManager(this.getDbType()).flushUndoLogs(this);
//执行原生提交 一阶段提交
targetConnection.commit();
} catch (Throwable ex) {
LOGGER.error("process connectionProxy commit error: {}", ex.getMessage(), ex);
report(false);
throw new SQLException(ex);
}
if (IS_REPORT_SUCCESS_ENABLE) {
report(true);
}
context.reset();
}

其中 register() 方法就是注册分支事务的方法,同时还会将 undoLog 写入数据库和执行提交等操作

1
2
3
4
5
6
7
8
9
10
//注册分支事务,生成分支事务ID
private void register() throws TransactionException {
if (!context.hasUndoLog() || !context.hasLockKey()) {
return;
}
//注册分支事务
Long branchId = DefaultResourceManager.get().branchRegister(BranchType.AT, getDataSourceProxy().getResourceId(),
null, context.getXid(), context.getApplicationData(), context.buildLockKeys());
context.setBranchId(branchId);
}

然后我们在回到 processGlobalTransactionCommit 中,看看写入数据库中的 flushUndoLogs()

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
@Override
public void flushUndoLogs(ConnectionProxy cp) throws SQLException {
ConnectionContext connectionContext = cp.getContext();
if (!connectionContext.hasUndoLog()) {
return;
}
//获取XID
String xid = connectionContext.getXid();
//获取分支ID
long branchId = connectionContext.getBranchId();

BranchUndoLog branchUndoLog = new BranchUndoLog();
branchUndoLog.setXid(xid);
branchUndoLog.setBranchId(branchId);
branchUndoLog.setSqlUndoLogs(connectionContext.getUndoItems());

UndoLogParser parser = UndoLogParserFactory.getInstance();
byte[] undoLogContent = parser.encode(branchUndoLog);

if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Flushing UNDO LOG: {}", new String(undoLogContent, Constants.DEFAULT_CHARSET));
}

CompressorType compressorType = CompressorType.NONE;
if (needCompress(undoLogContent)) {
compressorType = ROLLBACK_INFO_COMPRESS_TYPE;
undoLogContent = CompressorFactory.getCompressor(compressorType.getCode()).compress(undoLogContent);
}
//写入数据库具体位置
insertUndoLogWithNormal(xid, branchId, buildContext(parser.getName(), compressorType), undoLogContent, cp.getTargetConnection());
}

具体写入方法,此时我们使用的是 MySql,所以执行的是 MySql 实现类 MySQLUndoLogManager.insertUndoLogWithNormal()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
protected void insertUndoLogWithNormal(String xid, long branchId, String rollbackCtx, byte[] undoLogContent,
Connection conn) throws SQLException {
insertUndoLog(xid, branchId, rollbackCtx, undoLogContent, State.Normal, conn);
}

//具体写入操作
private void insertUndoLog(String xid, long branchId, String rollbackCtx, byte[] undoLogContent,
State state, Connection conn) throws SQLException {
try (PreparedStatement pst = conn.prepareStatement(INSERT_UNDO_LOG_SQL)) {
pst.setLong(1, branchId);
pst.setString(2, xid);
pst.setString(3, rollbackCtx);
pst.setBytes(4, undoLogContent);
pst.setInt(5, state.getValue());
pst.executeUpdate();
} catch (Exception e) {
if (!(e instanceof SQLException)) {
e = new SQLException(e);
}
throw (SQLException) e;
}
}

具体流程如下所示:

img

# Seata 服务端

我们找到 Server.java 这里就是启动入口,在这个入口中找到协调者,因为 TC 整体的操作就是协调整体的全局事务

1
2
//默认协调者
DefaultCoordinator coordinator = DefaultCoordinator.getInstance(nettyRemotingServer);

DefaultCoordinator 类中我们找到 一个 doGlobalBegin 这个就是处理全局事务开始的方法,以及全局提交 doGlobalCommit 和全局回滚 doGlobalRollback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    //处理全局事务
@Override
protected void doGlobalBegin(GlobalBeginRequest request, GlobalBeginResponse response, RpcContext rpcContext)throws TransactionException {
//响应客户端xid
response.setXid(core.begin(rpcContext.getApplicationId(), rpcContext.getTransactionServiceGroup(),
request.getTransactionName(), request.getTimeout()));
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Begin new global transaction applicationId: {},transactionServiceGroup: {}, transactionName: {},timeout:{},xid:{}",
rpcContext.getApplicationId(), rpcContext.getTransactionServiceGroup(), request.getTransactionName(), request.getTimeout(), response.getXid());
}
}

//处理全局提交
@Override
protected void doGlobalCommit(GlobalCommitRequest request, GlobalCommitResponse response, RpcContext rpcContext)throws TransactionException {
MDC.put(RootContext.MDC_KEY_XID, request.getXid());
response.setGlobalStatus(core.commit(request.getXid()));
}

//处理全局回滚
@Override
protected void doGlobalRollback(GlobalRollbackRequest request, GlobalRollbackResponse response,
RpcContext rpcContext) throws TransactionException {
MDC.put(RootContext.MDC_KEY_XID, request.getXid());
response.setGlobalStatus(core.rollback(request.getXid()));
}

在这里我们首先关注 doGlobalBegincore.begin()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
public String begin(String applicationId, String transactionServiceGroup, String name, int timeout)
throws TransactionException {
//创建全局事务Session
GlobalSession session = GlobalSession.createGlobalSession(applicationId, transactionServiceGroup, name,
timeout);
MDC.put(RootContext.MDC_KEY_XID, session.getXid());

//为Session重添加回调监听,SessionHolder.getRootSessionManager() 获取一个全局Session管理器DataBaseSessionManager
//观察者设计模式,创建DataBaseSessionManager
session.addSessionLifecycleListener(SessionHolder.getRootSessionManager());

//全局事务开始
session.begin();

// transaction start event
MetricsPublisher.postSessionDoingEvent(session, false);

return session.getXid();
}

然后我们在来看一下 SessionHolder.getRootSessionManager()

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
/**
* Gets root session manager.
* 获取一个全局Session管理器
* @return the root session manager
*/
public static SessionManager getRootSessionManager() {
if (ROOT_SESSION_MANAGER == null) {
throw new ShouldNeverHappenException("SessionManager is NOT init!");
}
return ROOT_SESSION_MANAGER;
}

public static void init(String mode) {
if (StringUtils.isBlank(mode)) {
mode = CONFIG.getConfig(ConfigurationKeys.STORE_SESSION_MODE,
CONFIG.getConfig(ConfigurationKeys.STORE_MODE, SERVER_DEFAULT_STORE_MODE));
}
StoreMode storeMode = StoreMode.get(mode);
//判断Seata模式,当前为DB
if (StoreMode.DB.equals(storeMode)) {
//通过SPI机制读取SessionManager接口实现类,读取的META-INF.services目录,在通过反射机制创建对象DataBaseSessionManager
ROOT_SESSION_MANAGER = EnhancedServiceLoader.load(SessionManager.class, StoreMode.DB.getName());
........
}
}

在这里他其实读取的是 DB 模式下 io.seata.server.session.SessionManager 文件的内容

img

我们在回到 begin 方法中,去查看 session.begin()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void begin() throws TransactionException {
//声明全局事务开始
this.status = GlobalStatus.Begin;
//开始时间
this.beginTime = System.currentTimeMillis();
//激活全局事务
this.active = true;
//将SessionManager放入到集合中,调用onBegin方法
for (SessionLifecycleListener lifecycleListener : lifecycleListeners) {
//调用父级抽象类的方法
lifecycleListener.onBegin(this);
}
}

这里我们来看一下 onBegin() 方法,调用的是父级的方法,在这其中我们要关注 addGlobalSession() 方法,但是要注意,这里我们用的是 db 模式所以调用的是 db 模式的 DateBaseSessionManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public void onBegin(GlobalSession globalSession) throws TransactionException {
//这里调用的是DateBaseSessionManager
addGlobalSession(globalSession);
}

@Override
public void addGlobalSession(GlobalSession session) throws TransactionException {
if (StringUtils.isBlank(taskName)) {
//写入session
boolean ret = transactionStoreManager.writeSession(LogOperation.GLOBAL_ADD, session);
if (!ret) {
throw new StoreException("addGlobalSession failed.");
}
} else {
boolean ret = transactionStoreManager.writeSession(LogOperation.GLOBAL_UPDATE, session);
if (!ret) {
throw new StoreException("addGlobalSession failed.");
}
}
}

然后在看查询其中关键的方法 DataBaseTransactionStoreManager.writeSession()

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
@Override
public boolean writeSession(LogOperation logOperation, SessionStorable session) {
//第一次进入是写入 会进入当前方法
//全局添加
if (LogOperation.GLOBAL_ADD.equals(logOperation)) {
return logStore.insertGlobalTransactionDO(SessionConverter.convertGlobalTransactionDO(session));
//全局修改
} else if (LogOperation.GLOBAL_UPDATE.equals(logOperation)) {
return logStore.updateGlobalTransactionDO(SessionConverter.convertGlobalTransactionDO(session));
//全局删除
} else if (LogOperation.GLOBAL_REMOVE.equals(logOperation)) {
return logStore.deleteGlobalTransactionDO(SessionConverter.convertGlobalTransactionDO(session));
//分支添加
} else if (LogOperation.BRANCH_ADD.equals(logOperation)) {
return logStore.insertBranchTransactionDO(SessionConverter.convertBranchTransactionDO(session));
//分支更新
} else if (LogOperation.BRANCH_UPDATE.equals(logOperation)) {
return logStore.updateBranchTransactionDO(SessionConverter.convertBranchTransactionDO(session));
//分支移除
} else if (LogOperation.BRANCH_REMOVE.equals(logOperation)) {
return logStore.deleteBranchTransactionDO(SessionConverter.convertBranchTransactionDO(session));
} else {
throw new StoreException("Unknown LogOperation:" + logOperation.name());
}
}

我们就看第一次进去的方法 logStore.insertGlobalTransactionDO(SessionConverter.convertGlobalTransactionDO(session));

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
@Override
public boolean insertGlobalTransactionDO(GlobalTransactionDO globalTransactionDO) {
String sql = LogStoreSqlsFactory.getLogStoreSqls(dbType).getInsertGlobalTransactionSQL(globalTable);
Connection conn = null;
PreparedStatement ps = null;
try {
int index = 1;
conn = logStoreDataSource.getConnection();
conn.setAutoCommit(true);
ps = conn.prepareStatement(sql);
ps.setString(index++, globalTransactionDO.getXid());
ps.setLong(index++, globalTransactionDO.getTransactionId());
ps.setInt(index++, globalTransactionDO.getStatus());
ps.setString(index++, globalTransactionDO.getApplicationId());
ps.setString(index++, globalTransactionDO.getTransactionServiceGroup());
String transactionName = globalTransactionDO.getTransactionName();
transactionName = transactionName.length() > transactionNameColumnSize ?
transactionName.substring(0, transactionNameColumnSize) :
transactionName;
ps.setString(index++, transactionName);
ps.setInt(index++, globalTransactionDO.getTimeout());
ps.setLong(index++, globalTransactionDO.getBeginTime());
ps.setString(index++, globalTransactionDO.getApplicationData());
return ps.executeUpdate() > 0;
} catch (SQLException e) {
throw new StoreException(e);
} finally {
IOUtil.close(ps, conn);
}
}

在这里有一个 GlobalTransactionDO 对象,里面有 xid、transactionId 等等,到这里是不是就很熟悉了、

img

还记得我们第一次使用 Seata 的时候会创建三张表

  1. branch_table 分支事务表
  2. global_table 全局事务表
  3. lock_table 全局锁表

而这里就是对应我们的 global_table 表,其他两个也是差不多,都是一样的操作

img
流程图如下:
img

# 总结

完整流程图:

img

对于 Seata 源码来说主要是了解从哪里入口以及核心点在哪里,遇到有疑问的,可以 Debug,对于 Seata AT 模式,我们主要掌握的核心点是

  • 如何获取全局锁、开启全局事务
  • 解析 SQL 并写入 undolog

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【Java】记一次线上故障:服务接口无响应

InterviewCoder

# 事故起因

# 中午公众号突然收到消息,很多用户说 chatGPT 不回复,询问我是不是程序坏掉了

# 排查流程

# 1. 进入 docker 查看服务日志,发现日志都正常输出,没有报错

# 2. 在本地启动程序排查,发现无异常

# 3. 使用接口工具调用其他接口和网关心跳接口尝试,无响应

# 4. 查看线上 CPU 和内存使用率,结果正常

# 5. 查看线上磁盘:占用 100%

# 结果

​ 清理掉 Nacos 的大量日志后,服务恢复正常。

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!

【Java】HashMap核心原理解读

InterviewCoder

# 【Java】HashMap 核心原理解读

# 一、前言

得益于 Doug Lea 老爷子的操刀,让 HashMap 成为使用和面试最频繁的 API,没办法设计的太优秀了!

HashMap 最早出现在 JDK 1.2 中,底层基于散列算法实现。HashMap 允许 null 键和 null 值,在计算哈键的哈希值时,null 键哈希值为 0。HashMap 并不保证键值对的顺序,这意味着在进行某些操作后,键值对的顺序可能会发生变化。另外,需要注意的是,HashMap 是非线程安全类,在多线程环境下可能会存在问题。

# 二、源码分析

# 1. 写一个最简单的 HashMap

学习 HashMap 前,最好的方式是先了解这是一种怎么样的数据结构来存放数据。而 HashMap 经过多个版本的迭代后,乍一看代码还是很复杂的。就像你原来只穿个裤衩,现在还有秋裤和风衣。所以我们先来看看最根本的 HashMap 是什么样,也就是只穿裤衩是什么效果,之后再去分析它的源码。

问题: 假设我们有一组 7 个字符串,需要存放到数组中,但要求在获取每个元素的时候时间复杂度是 O (1)。也就是说你不能通过循环遍历的方式进行获取,而是要定位到数组 ID 直接获取相应的元素。

方案: 如果说我们需要通过 ID 从数组中获取元素,那么就需要把每个字符串都计算出一个在数组中的位置 ID。字符串获取 ID 你能想到什么方式? 一个字符串最直接的获取跟数字相关的信息就是 HashCode,可 HashCode 的取值范围太大了 [-2147483648, 2147483647] ,不可能直接使用。那么就需要使用 HashCode 与数组长度做与运算,得到一个可以在数组中出现的位置。如果说有两个元素得到同样的 ID,那么这个数组 ID 下就存放两个字符串。

以上呢其实就是我们要把字符串散列到数组中的一个基本思路,接下来我们就把这个思路用代码实现出来。

# 1.1 代码实现

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
// 初始化一组字符串
List<String> list = new ArrayList<>();
list.add("jlkk");
list.add("lopi");
list.add("小傅哥");
list.add("e4we");
list.add("alpo");
list.add("yhjk");
list.add("plop");

// 定义要存放的数组
String[] tab = new String[8];

// 循环存放
for (String key : list) {
int idx = key.hashCode() & (tab.length - 1); // 计算索引位置
System.out.println(String.format("key值=%s Idx=%d", key, idx));
if (null == tab[idx]) {
tab[idx] = key;
continue;
}
tab[idx] = tab[idx] + "->" + key;
}
// 输出测试结果
System.out.println(JSON.toJSONString(tab));

这段代码整体看起来也是非常简单,并没有什么复杂度,主要包括以下内容;

  1. 初始化一组字符串集合,这里初始化了 7 个。
  2. 定义一个数组用于存放字符串,注意这里的长度是 8,也就是 2 的 3 次幂。这样的数组长度才会出现一个 0111 除高位以外都是 1 的特征,也是为了散列。
  3. 接下来就是循环存放数据,计算出每个字符串在数组中的位置。 key.hashCode() & (tab.length - 1)
  4. 在字符串存放到数组的过程,如果遇到相同的元素,进行连接操作 模拟链表的过程
  5. 最后输出存放结果。

测试结果

1
2
3
4
5
6
7
8
key值=jlkk Idx=2
key值=lopi Idx=4
key值=小傅哥 Idx=7
key值=e4we Idx=5
key值=alpo Idx=2
key值=yhjk Idx=0
key值=plop Idx=5
测试结果:["yhjk",null,"jlkk->alpo",null,"lopi","e4we->plop",null,"小傅哥"]
  • 在测试结果首先是计算出每个元素在数组的 Idx,也有出现重复的位置。
  • 最后是测试结果的输出,1、3、6,位置是空的,2、5,位置有两个元素被链接起来 e4we->plop
  • 这就达到了我们一个最基本的要求,将串元素散列存放到数组中,最后通过字符串元素的索引 ID 进行获取对应字符串。这样是 HashMap 的一个最基本原理,有了这个基础后面就会更容易理解 HashMap 的源码实现。

# 1.2 Hash 散列示意图

如果上面的测试结果不能在你的头脑中很好的建立出一个数据结构,那么可以看以下这张散列示意图,方便理解;

bugstack.cn Hash散列示意图

  • 这张图就是上面代码实现的全过程,将每一个字符串元素通过 Hash 计算索引位置,存放到数组中。
  • 黄色的索引 ID 是没有元素存放、绿色的索引 ID 存放了一个元素、红色的索引 ID 存放了两个元素。

# [#](https://bugstack.cn/md/java/interview/2020-08-07 - 面经手册・第 3 篇《HashMap 核心知识,扰动函数、负载因子、扩容链表拆分,深度学习》.html#_1-3 - 这个简单的 hashmap 有哪些问题) 1.3 这个简单的 HashMap 有哪些问题

以上我们实现了一个简单的 HashMap,或者说还算不上 HashMap,只能算做一个散列数据存放的雏形。但这样的一个数据结构放在实际使用中,会有哪些问题呢?

  1. 这里所有的元素存放都需要获取一个索引位置,而如果元素的位置不够散列碰撞严重,那么就失去了散列表存放的意义,没有达到预期的性能。
  2. 在获取索引 ID 的计算公式中,需要数组长度是 2 的幂次方,那么怎么进行初始化这个数组大小。
  3. 数组越小碰撞的越大,数组越大碰撞的越小,时间与空间如何取舍。
  4. 目前存放 7 个元素,已经有两个位置都存放了 2 个字符串,那么链表越来越长怎么优化。
  5. 随着元素的不断添加,数组长度不足扩容时,怎么把原有的元素,拆分到新的位置上去。

以上这些问题可以归纳为; 扰动函数初始化容量负载因子扩容方法 以及 链表和红黑树 转换的使用等。接下来我们会逐个问题进行分析。

# 2. 扰动函数

在 HashMap 存放元素时候有这样一段代码来处理哈希值,这是 java 8 的散列值扰动函数,用于优化散列效果;

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

# 2.1 为什么使用扰动函数

理论上来说字符串的 hashCode 是一个 int 类型值,那可以直接作为数组下标了,且不会出现碰撞。但是这个 hashCode 的取值范围是 [-2147483648, 2147483647],有将近 40 亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。

我们默认初始化的 Map 大小是 16 个长度 DEFAULT_INITIAL_CAPACITY = 1 << 4 ,所以获取的 Hash 值并不能直接作为下标使用,需要与数组长度进行取模运算得到一个下标值,也就是我们上面做的散列列子。

那么,hashMap 源码这里不只是直接获取哈希值,还进行了一次扰动计算, (h = key.hashCode()) ^ (h >>> 16) 。把哈希值右移 16 位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性。计算方式如下图;

bugstack.cn 扰动函数

  • 说白了,使用扰动函数就是为了增加随机性,让数据元素更加均衡的散列,减少碰撞。

# 2.2 实验验证扰动函数

从上面的分析可以看出,扰动函数使用了哈希值的高半区和低半区做异或,混合原始哈希码的高位和低位,以此来加大低位区的随机性。

但看不到实验数据的话,这终究是一段理论,具体这段哈希值真的被增加了随机性没有,并不知道。所以这里我们要做一个实验,这个实验是这样做;

  1. 选取 10 万个单词词库
  2. 定义 128 位长度的数组格子
  3. 分别计算在扰动和不扰动下,10 万单词的下标分配到 128 个格子的数量
  4. 统计各个格子数量,生成波动曲线。如果扰动函数下的波动曲线相对更平稳,那么证明扰动函数有效果。
# 2.2.1 扰动代码测试

扰动函数对比方法

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

public static int disturbHashIdx(String key, int size) {
return (size - 1) & (key.hashCode() ^ (key.hashCode() >>> 16));
}

public static int hashIdx(String key, int size) {
return (size - 1) & key.hashCode();
}

}
  • disturbHashIdx 扰动函数下,下标值计算
  • hashIdx 非扰动函数下,下标值计算

单元测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 10万单词已经初始化到words中
@Test
public void test_disturb() {
Map<Integer, Integer> map = new HashMap<>(16);
for (String word : words) {
// 使用扰动函数
int idx = Disturb.disturbHashIdx(word, 128);
// 不使用扰动函数
// int idx = Disturb.hashIdx(word, 128);
if (map.containsKey(idx)) {
Integer integer = map.get(idx);
map.put(idx, ++integer);
} else {
map.put(idx, 1);
}
}
System.out.println(map.values());
}

以上分别统计两种函数下的下标值分配,最终将统计结果放到 excel 中生成图表。

# [#](https://bugstack.cn/md/java/interview/2020-08-07 - 面经手册・第 3 篇《HashMap 核心知识,扰动函数、负载因子、扩容链表拆分,深度学习》.html#_2-2-2 - 扰动函数散列图表) 2.2.2 扰动函数散列图表

以上的两张图,分别是没有使用扰动函数和使用扰动函数的,下标分配。实验数据;

  1. 10 万个不重复的单词
  2. 128 个格子,相当于 128 长度的数组

未使用扰动函数

bugstack.cn 未使用扰动函数

使用扰动函数

bugstack.cn 使用扰动函数

  • 从这两种的对比图可以看出来,在使用了扰动函数后,数据分配的更加均匀了。
  • 数据分配均匀,也就是散列的效果更好,减少了 hash 的碰撞,让数据存放和获取的效率更佳。

# 3. 初始化容量和负载因子

接下来我们讨论下一个问题,从我们模仿 HashMap 的例子中以及 HashMap 默认的初始化大小里,都可以知道,散列数组需要一个 2 的幂次方的长度,因为只有 2 的幂次方在减 1 的时候,才会出现 01111 这样的值。

那么这里就有一个问题,我们在初始化 HashMap 的时候,如果传一个 17 个的值 new HashMap<>(17); ,它会怎么处理呢?

# 3.1 寻找 2 的幂次方最小值

在 HashMap 的初始化中,有这样一段方法;

1
2
3
4
5
public HashMap(int initialCapacity, float loadFactor) {
...
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
  • 阈值 threshold ,通过方法 tableSizeFor 进行计算,是根据初始化来计算的。
  • 这个方法也就是要寻找比初始值大的,最小的那个 2 进制数值。比如传了 17,我应该找到的是 32(2 的 4 次幂是 16<17, 所以找到 2 的 5 次幂 32)。

计算阈值大小的方法;

1
2
3
4
5
6
7
8
9
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
  • MAXIMUM_CAPACITY = 1 << 30,这个是临界范围,也就是最大的 Map 集合。
  • 乍一看可能有点晕😵怎么都在向右移位 1、2、4、8、16,这主要是为了把二进制的各个位置都填上 1,当二进制的各个位置都是 1 以后,就是一个标准的 2 的幂次方减 1 了,最后把结果加 1 再返回即可。

那这里我们把 17 这样一个初始化计算阈值的过程,用图展示出来,方便理解;

bugstack.cn 计算阈值

# 3.2 负载因子

1
static final float DEFAULT_LOAD_FACTOR = 0.75f;  

负载因子是做什么的?

负载因子,可以理解成一辆车可承重重量超过某个阈值时,把货放到新的车上。

那么在 HashMap 中,负载因子决定了数据量多少了以后进行扩容。这里要提到上面做的 HashMap 例子,我们准备了 7 个元素,但是最后还有 3 个位置空余,2 个位置存放了 2 个元素。 所以可能即使你数据比数组容量大时也是不一定能正正好好的把数组占满的,而是在某些小标位置出现了大量的碰撞,只能在同一个位置用链表存放,那么这样就失去了 Map 数组的性能。

所以,要选择一个合理的大小下进行扩容,默认值 0.75 就是说当阈值容量占了 3/4 时赶紧扩容,减少 Hash 碰撞。

同时 0.75 是一个默认构造值,在创建 HashMap 也可以调整,比如你希望用更多的空间换取时间,可以把负载因子调的更小一些,减少碰撞。

# 4. 扩容元素拆分

为什么扩容,因为数组长度不足了。那扩容最直接的问题,就是需要把元素拆分到新的数组中。拆分元素的过程中,原 jdk1.7 中会需要重新计算哈希值,但是到 jdk1.8 中已经进行优化,不再需要重新计算,提升了拆分的性能,设计的还是非常巧妙的。

# 4.1 测试数据

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
@Test
public void test_hashMap() {
List<String> list = new ArrayList<>();
list.add("jlkk");
list.add("lopi");
list.add("jmdw");
list.add("e4we");
list.add("io98");
list.add("nmhg");
list.add("vfg6");
list.add("gfrt");
list.add("alpo");
list.add("vfbh");
list.add("bnhj");
list.add("zuio");
list.add("iu8e");
list.add("yhjk");
list.add("plop");
list.add("dd0p");
for (String key : list) {
int hash = key.hashCode() ^ (key.hashCode() >>> 16);
System.out.println("字符串:" + key + " \tIdx(16):" + ((16 - 1) & hash) + " \tBit值:" + Integer.toBinaryString(hash) + " - " + Integer.toBinaryString(hash & 16) + " \t\tIdx(32):" + ((
System.out.println(Integer.toBinaryString(key.hashCode()) +" "+ Integer.toBinaryString(hash) + " " + Integer.toBinaryString((32 - 1) & hash));
}
}

测试结果

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
字符串:jlkk 	Idx(16)3 	Bit值:1100011101001000010011 - 10000 		Idx(32):19
1100011101001000100010 1100011101001000010011 10011
字符串:lopi Idx(16)14 Bit值:1100101100011010001110 - 0 Idx(32):14
1100101100011010111100 1100101100011010001110 1110
字符串:jmdw Idx(16)7 Bit值:1100011101010100100111 - 0 Idx(32):7
1100011101010100010110 1100011101010100100111 111
字符串:e4we Idx(16)3 Bit值:1011101011101101010011 - 10000 Idx(32):19
1011101011101101111101 1011101011101101010011 10011
字符串:io98 Idx(16)4 Bit值:1100010110001011110100 - 10000 Idx(32):20
1100010110001011000101 1100010110001011110100 10100
字符串:nmhg Idx(16)13 Bit值:1100111010011011001101 - 0 Idx(32):13
1100111010011011111110 1100111010011011001101 1101
字符串:vfg6 Idx(16)8 Bit值:1101110010111101101000 - 0 Idx(32):8
1101110010111101011111 1101110010111101101000 1000
字符串:gfrt Idx(16)1 Bit值:1100000101111101010001 - 10000 Idx(32):17
1100000101111101100001 1100000101111101010001 10001
字符串:alpo Idx(16)7 Bit值:1011011011101101000111 - 0 Idx(32):7
1011011011101101101010 1011011011101101000111 111
字符串:vfbh Idx(16)1 Bit值:1101110010111011000001 - 0 Idx(32):1
1101110010111011110110 1101110010111011000001 1
字符串:bnhj Idx(16)0 Bit值:1011100011011001100000 - 0 Idx(32):0
1011100011011001001110 1011100011011001100000 0
字符串:zuio Idx(16)8 Bit值:1110010011100110011000 - 10000 Idx(32):24
1110010011100110100001 1110010011100110011000 11000
字符串:iu8e Idx(16)8 Bit值:1100010111100101101000 - 0 Idx(32):8
1100010111100101011001 1100010111100101101000 1000
字符串:yhjk Idx(16)8 Bit值:1110001001010010101000 - 0 Idx(32):8
1110001001010010010000 1110001001010010101000 1000
字符串:plop Idx(16)9 Bit值:1101001000110011101001 - 0 Idx(32):9
1101001000110011011101 1101001000110011101001 1001
字符串:dd0p Idx(16)14 Bit值:1011101111001011101110 - 0 Idx(32):14
1011101111001011000000 1011101111001011101110 1110
  • 这里我们随机使用一些字符串计算他们分别在 16 位长度和 32 位长度数组下的索引分配情况,看哪些数据被重新路由到了新的地址。
  • 同时,这里还可以观察🕵出一个非常重要的信息,原哈希值与扩容新增出来的长度 16,进行 & 运算,如果值等于 0,则下标位置不变。如果不为 0,那么新的位置则是原来位置上加 16。{这个地方需要好好理解下,并看实验数据}
  • 这样一来,就不需要在重新计算每一个数组中元素的哈希值了。

4.2 数据迁移

bugstack.cn 数据迁移

  • 这张图就是原 16 位长度数组元素,向 32 位扩容后数组转移的过程。
  • 对 31 取模保留低 5 位,对 15 取模保留低 4 位,两者的差异就在于第 5 位是否为 1,是的话则需要加上增量,为 0 的话则不需要改变
  • 其中黄色区域元素 zuio 因计算结果 hash & oldCap 低位第 5 位为 1,则被迁移到下标位置 24。
  • 同时还是用重新计算哈希值的方式验证了,确实分配到 24 的位置,因为这是在二进制计算中补 1 的过程,所以可以通过上面简化的方式确定哈希值的位置。

那么为什么 e.hash & oldCap == 0 为什么可以判断当前节点是否需要移位,而不是再次计算 hash;

仍然是原始长度为 16 举例:

1
2
3
4
5
6
7
8
9
old:
10: 0000 1010
15: 0000 1111
&: 0000 1010

new:
10: 0000 1010
31: 0001 1111
&: 0000 1010

从上面的示例可以很轻易的看出,两次 indexFor () 的差别只是第二次参与位于比第一次左边有一位从 0 变为 1, 而这个变化的 1 刚好是 oldCap, 那么只需要判断原 key 的 hash 这个位上是否为 1: 若是 1, 则需要移动至 oldCap + i 的槽位,若为 0, 则不需要移动;

这也是 HashMap 的长度必须保证是 2 的幂次方的原因,正因为这种环环相扣的设计,HashMap.loadFactor 的选值是 3/4 就能理解了,table.length * 3/4 可以被优化为 ((table.length>> 2) << 2) - (table.length >> 2) == table.length - (table.length >> 2), JAVA 的位运算比乘除的效率更高,所以取 3/4 在保证 hash 冲突小的情况下兼顾了效率;

# 面试题总结

# Question

# 1. 为什么 HashMap 哈希表中数组长度总是取 2 的幂次方?

# 散列数组需要一个 2 的幂次方的长度,因为只有 2 的幂次方在减 1 的时候,才会出现 01111 这样的值。
1
2
//通过获取存储内容的hashcode,并且对长度是 2 的幂次方的数组长度-1进行与运算得到一个可以在数组中出现的位置
int idx = key.hashCode() & (tab.length - 1);
1
2
3
4
5
6
7
哈希表中数组长度总是取 2 的幂次方,这是因为对于大多数的质数来说,它们的二进制表示中只有少数位是为 1。如果使用一个不是 2 的幂次方的数组长度,那么当元素的哈希映射到索引时,就会出现某些索引永远无法被访问到的情况,这就浪费了哈希表的空间。

举个例子,比如我们要在一个大小为 5 的数组中插入 10 个元素 {a,b,c,d,e,f,g,h,i,j},而哈希函数将他们依次映射到下标位置:[0,3,4,2,4,1,3,2,3,4],可以看到,有三个下标(567)始终没有被映射到,完全浪费了这几个下标的存储空间。但如果数组长度为 8,那么上述元素的哈希值按照上面的映射规则可以得到的下标分别是 [0, 3, 4, 2, 4, 1, 3, 2, 3, 4] % 8 = [0, 3, 4, 2, 4, 1, 3, 2, 3, 4],不会出现任何下标被浪费的情况。

这种情况下,如果将数组长度改为 7,那么上述元素的哈希值按照上面的映射规则可以得到的下标分别是 [0, 3, 4, 2, 4, 1, 3, 2, 3, 4] % 7 = [0, 3, 4, 2, 4, 1, 3, 2, 3, 1],可以看到,索引为 56 的空间仍然被浪费了。

因此,为了避免在哈希表中浪费存储空间,我们一般都会选择使用 2 的幂次方作为哈希表的大小。这样,在计算元素的位置时,只需要对哈希码进行位运算即可,不需要进行除法等复杂的计算。同时,由于 2 的幂次方满足 2^n2*n*,所以用位运算来代替除法或取模操作也能够提高运行效率。

# 2.HashMap 怎么保证哈希表长度是 2 的幂次方?

1
2
3
4
5
6
7
在 Java 中,哈希表的长度总是 2 的幂次方。这是通过 HashMap 类中的一个名为 `tableSizeFor(int cap)` 的静态方法来实现的。

这个方法会首先将传入的参数减去 1,然后将结果右移一位,在这个值上加 1,最终得到的结果就是大于等于原始参数且最接近原始参数的 2 的幂次方。例如,如果传入的参数是 7,则按照上述算法可以得到 8:(((7 - 1) >>> 1) + 1) = ((0110)_2 >>> 1 + 1) = (0011)_2 + (0001)_2 = (0100)_2 = 8(((71)>>>1)+1)=((0110)2>>>1+1)=(0011)2+(0001)2=(0100)2=8

因此,当我们创建一个新的哈希表时,只需要将初始容量设置为需要存储的元素数目除以负载因子(load factor),然后调用 `tableSizeFor` 方法进行处理即可。这样就能够保证哈希表的长度始终是 2 的幂次方,并达到较好的性能和空间利用率。

需要注意的是,虽然这种方式确实可以保证哈希表的长度是 2 的幂次方,但有时候也可能会出现哈希冲突的情况。因此,在实际应用中,还需要根据具体情况选择合适的哈希函数、负载因子等参数来避免哈希冲突,并提高哈希表的性能。

# 3. 在 HashMap 存放元素时候有这样一段代码来处理哈希值,这是 java 8 的散列值扰动函数,用于优化散列效果

1
2
3
4
static final int hash(Object key){
int h;
return (key == null) ? 0 : (h = key.hashcode()) ^ (h >>> 16);
}

# 4.Hash 为什么用 31 计算?

1
2
3
4
5
6
7
	使用 31 这个系数的原因是因为它既是一个质数,又可以通过移位和减法等简单的运算来实现乘法。同时,由于 31 在二进制中只有一个非零的比特位,因此用 31 进行乘法相当于进行了位运算和加法的组合,这样不仅能够保证高效运算,还能够避免哈希冲突。

31具有两个优点:
1.小质数:31 是一个较小的质数,在哈希表中被用来乘以键的哈希码,使得结果不会太大,从而提高性能。相对于其他的质数,31 更易于被 CPU 缓存,这也有助于提升哈希表的查询速度。
2.可逆性:因为 31 是质数且比较小,所以只要哈希表的大小足够大,在乘法过程中产生的数据溢出是不会影响哈希结果的。而且,31 的逆元 0x9e3779b1 是一个固定的常量,也很容易计算,这有助于在需要恢复哈希码时进行逆运算。

在实现哈希函数时,使用系数 31 可以保证高效性、可逆性和低冲突率,因此在 Java 中被广泛使用。

# 5.HashMap 扰动函数的作用是什么,以及它可以被应用在哪些地方?

1
2
3
4
5
6
7
8
9
10
11
12
13
扰动函数是一种用于增强哈希函数的技术。它通常被用来对原始哈希值进行混淆和扰动,从而减小哈希冲突的发生率。

具体来说,扰动函数会将原始哈希值与另一个数值进行混合,并再次运用哈希函数产生最终的哈希值。这个额外的数值可以是任意的固定值或随机数,目的是使得不同的哈希值在进行混淆之后,仍然能够保持一定的分散性,从而减小哈希冲突的概率。

扰动函数可以被应用在许多地方,例如:

1.哈希表:在哈希表中,扰动函数可以用来增加键的随机性,减少哈希冲突的发生率,从而提高哈希表的性能。Java 中的 HashMap 就使用了扰动函数对键的哈希码进行混淆。

2.加密算法:在一些加密算法中,也会使用扰动函数来增加密码熵,并提高加密强度。例如,MD5 算法就采用了一系列扰动函数对数据进行混淆。

3.图像处理:在图像处理中,扰动函数可以用来对像素值随机化,增加图像的噪声,从而实现一些特殊效果,如毛玻璃效果、雨滴效果等。

总之,扰动函数是一种通用的技术,可以在许多场景下使用。通过引入随机数和混淆操作,扰动函数可以增强数据的随机性和复杂性,从而提高系统的安全性和性能。

# 6.HashMap 负载因子?

1
2
3
4
5
6
7
负载因子,可以理解成一辆车可承重重量超过某个阈值时,把货放到新的车上。

那么在HashMap中,负载因子决定了数据量多少了以后进行扩容。这里要提到上面做的HashMap例子,我们准备了7个元素,但是最后还有3个位置空余,2个位置存放了2个元素。 所以可能即使你数据比数组容量大时也是不一定能正正好好的把数组占满的,而是在某些小标位置出现了大量的碰撞,只能在同一个位置用链表存放,那么这样就失去了Map数组的性能。

所以,要选择一个合理的大小下进行扩容,默认值0.75就是说当阈值容量占了3/4时赶紧扩容,减少Hash碰撞。

同时0.75是一个默认构造值,在创建HashMap也可以调整,比如你希望用更多的空间换取时间,可以把负载因子调的更小一些,减少碰撞。

# 7.HashMap 是开放寻址还是拉链寻址?

1
2
3
4
5
6
7
8
9
HashMap 是一种拉链式哈希表,也就是说,它使用了链表来解决哈希冲突。

具体来说,当多个不同的键映射到同一个桶时,HashMap 会将它们存储在同一个桶中,并通过链表或红黑树等数据结构组织起来。这样,在进行查找、插入、删除等操作时,就可以遍历对应桶中的链表,找到所需的元素。

相对于开放寻址法,拉链法能够更好地处理哈希冲突,并且支持动态扩容。因为在拉链法中,哈希表的每个桶都可以存储多个元素,而在开放寻址法中,每个位置只能存储一个元素,这使得动态扩容变得更加困难和低效。

但是,由于链表的空间和时间开销,当哈希冲突较为频繁时,拉链法可能会导致性能下降。为了解决这个问题,Java8 引入了基于红黑树的优化机制,即当链表长度超过一定阈值时,将链表转换为红黑树,以提高查询效率。

总之,HashMap 是一种基于拉链法实现的哈希表,能够高效处理哈希冲突,并且支持动态扩容和基于红黑树的优化。

# 8.HashMap 链表什么时候树化以及迁移数据算法是什么?

1
2
3
4
5
6
7
8
9
在JDK1.8中,HashMap是以数组+链表+红黑树构成的。

具体来说,当某个桶中的链表长度超过了阈值时,HashMap 会将这个链表转换成一棵红黑树,以便支持更快的查找、插入和删除等操作。这个过程中,需要进行以下步骤:

1.创建一棵空的红黑树,并将原始链表中的所有元素插入到这棵树中。
2.删除原始链表中的所有元素,并设置哈希表的结构类型为 TREEBIN。
3.在进行插入、删除等操作时,先判断当前桶的数据结构类型,如果是链表,则采用链表的方法进行操作;否则,采用红黑树的方法进行操作。

总之,在 JDK8 及以后版本中,HashMap 在树化和迁移数据方面做了一些优化,使得它能够更好地支持大规模并发和高效内存使用。

# 9.HashMap 中的 key 若为 Object 类型, 则需实现哪些方法?

HashMap 在 JDK1.7 和 JDK1.8 中有哪些区别?_数组_07

1
2
3
4
5
6
7
如果 HashMap 中的 key 是 Object 类型,则该对象需要正确地实现 equals() 和 hashCode() 方法。这两个方法用于确定两个对象是否相等,并计算对象的哈希码。

具体来说,equals() 方法用于比较两个对象是否相等。在 HashMap 中,当两个 key 的哈希值相同时,会调用它们的 equals() 方法进行比较,以确定它们是否真正相等。因此,正确实现 equals() 方法可以防止哈希冲突和键值对重复的问题。

hashCode() 方法用于计算对象的哈希码,它将一个对象映射到一个整数值,用于确定该对象在哈希表中的位置。在 HashMap 中,当插入或查找键值对时,会首先计算 key 的哈希码,并根据哈希码查找对应的桶。因此,正确实现 hashCode() 方法可以提高哈希表的性能和效率。

需要注意的是,如果在 HashMap 中使用自定义的对象作为 key,默认情况下,它们的 equals() 方法和 hashCode() 方法是通过继承 Object 类而来的,这可能导致 key 的比较和哈希码计算不准确。因此,我们通常需要自己重写 equals() 和 hashCode() 方法,以满足我们的具体需求。同时,还需要遵循一些规则,例如:如果两个对象相等,那么它们的哈希码必须相等;反之亦然。

# 10.HashMap 中的扰动函数是如何计算的?

1
2
3
4
5
6
/**
* 使用扰动函数就是为了增加随机性,让数据元素更加均衡的散列,减少碰撞
* 把哈希值右移16位,也就正好是自己长度的一半,之后与原哈希值做异或运算
* 这样就混合了原哈希值中的高位和低位,增大了随机性
*/
int hashIndex = (size - 1) & (key.hashCode() ^ (key.hashCode() >>> 16));

# 11. 为什么 hashMap 中要使用 (1 << 30) 来作为最大限制

1
2
3
4
5
在 HashMap 中,使用 1 << 30 来作为容量的最大限制是因为 Java 中的数组长度不能超过 Integer.MAX_VALUE(即 2^31-1)。而 HashMap 内部的实现需要使用一个数组来保存数据,因此其容量也必须受到这个限制。

为了保证 HashMap 的最大容量不超过 Integer.MAX_VALUE,同时又要尽可能地提高 HashMap 的容量,Java 开发团队选择了 1 << 30 作为 HashMap 的最大容量。这个值是一个 2 的幂次方,可以充分利用位运算的优势,同时又不会超过数组长度的最大限制。

需要注意的是,HashMap 实际上并不会直接使用 1 << 30 作为容量的最大限制。在初始化 HashMap 时,如果传入的初始容量大于等于 1 << 30,则会将容量设置为 Integer.MAX_VALUE;如果传入的初始容量小于 1 << 30,则会将容量调整为大于等于传入值且最接近 2 的幂次方的数。这样可以确保 HashMap 容量的合理性,同时避免出现数组长度超过 Integer.MAX_VALUE 的情况。

# 12.HashMap 如何寻找 2 的幂次方最小值?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//JDK1.8中:把二进制的各个位置都填上1,当二进制的各个位置都是1以后,就是一个标准的2的幂次方减1了,最后把结果加1再返回即可。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
//JDK1.8之后:(二分法)通过多次右移和减小位宽的方式来逐步缩小搜索范围,最终得到最高位 0 的个数
static final int tableSizeFor(int cap) {
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

# 12. 为什么重写 equals 一定要重写 hashcode

1
2
3
4
5
6
7
8
9
10
11
在 Java 中,每个类都继承了 Object 类,Object 类提供了两个有关哈希值的方法,一个是 equals() 方法,另一个是 hashCode() 方法。其中,equals() 方法用于判断两个对象是否相等,而 hashCode() 方法则返回该对象的哈希码。

如果在一个类中重写了 equals() 方法,但没有重写 hashCode() 方法,则可能会导致出现以下情况:

在使用 HashMap、HashSet 等哈希数据结构时,由于不同的对象可以返回相同的哈希值,因此可能会将这些对象误认为是同一个对象,从而引发程序错误。
在使用自定义对象作为键来进行 Map 操作时,由于不同的键可以返回相同的哈希值,因此可能无法正确地定位到对应的键值对,从而导致数据丢失或查找失败。
因此,如果要重写 equals() 方法,则必须同时重写 hashCode() 方法,以确保它们的行为一致并满足一些约定:

如果两个对象使用 equals() 方法比较返回相等,则它们的 hashCode() 值必须相等。
如果两个对象的 hashCode() 值相等,则它们不一定相等(即可能存在哈希冲突),因此需要再次使用 equals() 方法进行比较。
通过遵循这些约定,可以保证在使用哈希数据结构或自定义对象作为键值对时不会出现问题。

# 13. 为什么在 Java 的 HashMap 实现中,数组的大小(即容量)必须始终保持为 2 的幂次方?

1
2
3
4
5
6
7
8
HashMap的长度必须保证是2的幂次方,是为了避免出现HashMap为空或者HashMap中存储的元素个数不足2的幂次方的情况。

如果HashMap的长度不是2的幂次方,可能会导致以下问题:

如果HashMap的长度为1,那么它的哈希冲突解决方式只能是链地址法(即将冲突的键值对插入到链表中),这种方式不能处理哈希冲突。
如果HashMap的长度为0,那么它的哈希冲突解决方式只能是开放地址法(即将冲突的键值对直接放在HashMap中),这种方式不能处理哈希冲突。

因此,为了保证HashMap的正确性和性能,我们需要确保它的长度是2的幂次方,或者为一个固定的最大值。如果需要动态地调整HashMap的长度,可以使用链表法或者开放地址法。

# 14. 为什么 HashMap.loadFactor 的选值是 3/4,而不是 2/4?

1
2
3
首先,当loadFactor的值比较小的时候,虽然能够减少空间的浪费,但是会导致哈希表更加频繁地进行扩容操作,这会影响到HashMap的性能。
其次,当loadFactor的值比较大的时候,虽然能够减少扩容操作的次数,但是会导致哈希链表长度过长,查找效率会变得较低。
因此,为了平衡空间利用率和时间效率,选择一个适当的loadFactor值非常重要。经过实验和分析,发现loadFactor取0.75时,可以在保证哈希表查找效率的同时,稍微减少空间的浪费。另外,这个值也是比较常见的一个取值,很多编程语言中也采用了类似的值。

# 15.HashMap 什么时候会触发扩容机制?如何扩容?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
HashMap在存储键值对时,会将键通过哈希函数映射到桶里面,每个桶是一个链表或红黑树。当HashMap中的元素数量增加到超过了负载因子(默认为0.75),就会触发扩容机制。

具体地说,在添加元素时,如果当前的元素数量达到了阈值(即容量乘以负载因子),就会启动扩容机制。这个阈值通过如下公式计算:

threshold = capacity * loadFactor

其中capacity是当前HashMap的容量,loadFactor是负载因子,默认值为0.75。当HashMap中元素个数达到了threshold值时,就会触发扩容机制。

扩容操作包括以下几个步骤:

1.创建一个新的Entry数组,长度为原数组的两倍。
2.将原来数组中所有的元素重新分配到新的数组中,这一步需要重新计算每个元素的hash值,并且根据新的数组长度求出它们在新数组中的位置。
3.在重新分配元素的过程中,如果某个位置上有多个元素,则会形成一个链表或红黑树,这取决于链表长度是否大于等于8,并且桶容量大于64。如果链表长度大于等于8并且桶容量大于64的话,则会将其转换为红黑树,否则仍然使用链表。这一步是为了提高查询效率,因为红黑树的查询时间复杂度是O(log n),而链表的查询时间复杂度是O(n)。
4.更新HashMap的容量和阈值。

扩容操作会比较耗时,因为需要重新计算hash值、重新分配元素等,所以应该尽可能避免频繁触发扩容。可以通过调整负载因子的大小来控制HashMap的性能和空间占用

# 关于我

Brath 是一个热爱技术的 Java 程序猿,公众号「InterviewCoder」定期分享有趣有料的精品原创文章!

InterviewCoder

非常感谢各位人才能看到这里,原创不易,文章如果有帮助可以关注、点赞、分享或评论,这都是对我的莫大支持!