

这个 bug 让我更加理解 Spring 单例了
source link: https://www.cnblogs.com/fengzheng/p/14171443.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

我是风筝,公众号「古时的风筝」,一个兼具深度与广度的程序员鼓励师,一个本打算写诗却写起了代码的田园码农!
文章会收录在 JavaNewBee 中,更有 Java 后端知识图谱,从小白到大牛要走的路都在里面。
谁还没在 Spring 里栽过跟头呢,从哪儿跌倒,就从哪儿睡一会儿,然后再爬起来。
讲点儿武德
这是由一个真实的 bug 引起的,bug 产生的原因就是忽略了 Spring Bean 的单例模式。来,先看一段简单的代码。
public class TestService {
private String callback = "https://ip.com/token={token}";
public String getCallback() {
Random random = new Random();
int number = random.nextInt(100);
System.out.println("本次随机数为:" + number);
callback = callback.replace("{token}", String.valueOf(number));
return callback;
}
public static void main(String[] args) {
TestService testService = new TestService();
while (true) {
Scanner reader = new Scanner(System.in);
int number = reader.nextInt();
if (number > 0) {
String url = testService.getCallback();
System.out.println(url);
}
}
}
}
callback
是一个带有一个回调地址,参数 token
是不确定的。
getCallback
方法每次调用,会随机生成一个100以内的数字,然后将 callback
中的{token}
替换为这个随机数字,最后的格式就像这样的:
https://ip.com/token=88
然后在 main
方法中接收控制台输入,每次输入的数字大于0,调用 getCallback
方法,然后输出 url。
相信各位都能轻易的看出这段程序的输出。

执行程序之后,不管你输入多少次数字,最后输出的 callback
都是第一次的那个。

虽然每次生成的随机数都变了,但是 callback
没变。
其实就是单例
有同学说,你过分了啊,这我能不知道为啥吗?
main
方法只创建了一个TestService
实例,在第一次调用 getCallback
方法的时候,callback
这个字符串就被修改成 https://ip.com/token=89
了,所以,之后不管你再调用多少次,都不会执行 replace
动作了,因为 callback
中已经没有 {token}
这一段了。
TestService
在整个程序执行过程中就是一个单例,所以,在 callback
第一次被修改后,后面再执行
callback.replace("{token}", String.valueOf(number));
的动作,拿到的 callback
中就已经没有 {token}
了,所以说,不会有替换的动作。
当然,这只是用最简单的程序说明单例中的这个问题,真正的项目中想用单例的话,还要借助于单例设计模式实现。
回到那个 bug
有个弟弟在做微信服务号的开发,微信服务号或者订阅号中有个 access_token
的概念,这是所有请求的凭证,有效期 2 个小时,到期之前要进行刷新。
他是这样设计的,在项目启动的时候立即调用微信接口获取 access_token
,然后写了一个定时任务每1个小时刷新一次,获取来的 access_token
放到 redis 和 数据库中,当调用微信服务号其他接口的时候,在 redis 中获取 access_token
并拼接到接口地址中。
开发调试的时候一起顺利,看上去非常完美。
问题出现了
当项目部署到测试环境测试的时候,问题出现了。项目刚发版的时候,测试都正常,但是过一段时间,就会出现错误,查看日志的时候,发现是微信服务号的接口返回了错误码,意思就是 access_token
已过期,需要重新获取。
弟弟第一时间怀疑是定时任务出现了问题,但是通过日志和数据库中的更新时间,发现定时任务是完全没有问题的,刷新 access_token
的时间和定时任务是完全吻合的,说明已经及时刷新了。
我让他用 redis 或数据库中的access_token
去调一下服务号接口,看看是不是也有同样的过期问题。
结果一试,redis 中存的是没问题的,可以正常使用。
那彻底排除是定时任务的问题了,问题的症结应该就出在两个地方:
1、在获取 redis 中的access_token
的过程;
2、将获取到的 access_token
拼接到请求接口 URL 上发生了错误;
到这里就很好判断了,他把从 redis 拿到的access_token
和最后拼接好的 URL 都输出到日志中一看,果然,两个是不一致的。
从 redis 取出的确实是最新可用的 access_token
,但是拼接到接口 URL 上之后,发现是另外一个。那就确定是拿到的 access_token
是没问题的,但是最后拼接到 URL 却有问题。这时,弟弟仔细检查了代码,然后彻底蒙了。

既然问题出在哪儿已经确定了,那就分析那段代码就好了。
项目整体采用的是 Spring Boot,代码很简单,就是在一个 Controller 中调用 Service 中的一个方法。大致 demo 是这样的。
@RestController
@RequestMapping(value = "test")
public class TestController {
@Autowired
private TestService testService;
@GetMapping(value = "call")
public Object getCallback() {
return testService.getCallback();
}
}
@Service
public class TestService {
private String callback = "https://ip.com/token={token}";
public String getCallback() {
Random random = new Random();
int number = random.nextInt(100);
System.out.println("本次随机数为:" + number);
callback = callback.replace("{token}", String.valueOf(number));
return callback;
}
}
看到这里,各位肯定已经发现问题原因了。虽然有多次请求,但因为 Spring Bean 默认是单例模式,所以实际上和前面演示的那个控制台程序是类似的,从头到尾都只有一个 TestService 实例,所以只有第一次能将{token}
替换成真正的access_token
。
对应到实际的服务号场景中,在第一次调用这个接口时,从 redis 拿到 access_token
拼接到具体的 URL中是没问题的,但是一旦这个access_token
过期(1小时后),再次请求这个接口就会出现 access_token
过期的问题。
这里违反了 Spring 单例模式的一个点,那就是 Spring 单例模式,不适合存储有状态的值,比如这里的 callback
就是个有状态的值,它应该随着定时任务的进行,获取到不同的值。
关于 Spring 或 Spring Boot 工作流程的介绍可以阅读文末的两篇文章,其中包括 Bean 实例化过程。
如何解决这个问题呢?
其实很简单,不让callback
每次调用发生变化就可以了,每次拼接 URL 的时候,先将 callback
赋给一个局部变量,然后在这个变量上操作就好了。
public String getCallback() {
Random random = new Random();
int number = random.nextInt(100);
System.out.println("本次随机数为:" + number);
String tempCallback = callback;
tempCallback = tempCallback.replace("{token}", String.valueOf(number));
return tempCallback;
}
另外,说到 Spring 单例模式,Spring 本身还支持其他几种模式,与单例模式对应的就是 prototype
模式,这种模式是每个请求都重新生成实例。所以,如果你确定这个 Controller 和 Service 可以不用单例模式,可以加上 @Scope(value = "prototype")
注解。
@RestController
@RequestMapping(value = "test")
@Scope(value = "prototype")
public class TestController {
@Autowired
private TestService testService;
@GetMapping(value = "call")
public Object getCallback() {
return testService.getCallback();
}
}
@Service
@Scope(value = "prototype")
public class TestService {
private String callback = "https://ip.com/token={token}";
public String getCallback() {
Random random = new Random();
int number = random.nextInt(100);
System.out.println("本次随机数为:" + number);
callback = callback.replace("{token}", String.valueOf(number));
return callback;
}
}
这样一来,每次都是新的实例,自然就不存在那个问题了。
从 Spring Boot 出发,分析 Spring IoC 过程
这位英俊潇洒的少年,如果觉得还不错的话,给个推荐可好!
公众号「古时的风筝」,Java 开发者,全栈工程师,bug 杀手,擅长解决问题。
一个兼具深度与广度的程序员鼓励师,本打算写诗却写起了代码的田园码农!坚持原创干货输出,你可选择现在就关注我,或者看看历史文章再关注也不迟。长按二维码关注,跟我一起变优秀!
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK