转载 spring单例bug

https://www.cnblogs.com/fengzheng/p/14171443.html
这个 bug 让我更加理解 Spring 单例了
我是风筝,公众号「古时的风筝」,一个兼具深度与广度的程序员鼓励师,一个本打算写诗却写起了代码的田园码农!
文章会收录在 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 IoC 实现过程

从 Spring Boot 出发,分析 Spring IoC 过程

这位英俊潇洒的少年,如果觉得还不错的话,给个推荐可好!

公众号「古时的风筝」,Java 开发者,全栈工程师,bug 杀手,擅长解决问题。
一个兼具深度与广度的程序员鼓励师,本打算写诗却写起了代码的田园码农!坚持原创干货输出,你可选择现在就关注我,或者看看历史文章再关注也不迟。长按二维码关注,跟我一起变优秀!

人生没有回头路,珍惜当下。
分类: java
标签: Spring单例模式, Spring
好文要顶 关注我 收藏该文
风的姿态
关注 - 0
粉丝 - 543
+加关注
9
« 上一篇: 『CDN』让你的网站访问起来更加柔顺丝滑
» 下一篇: 10年前,我就用 SQL注入漏洞黑了学校网站
posted @ 2020-12-22 09:46 风的姿态 阅读(1554) 评论(13) 编辑 收藏

评论列表
回复 引用#1楼 2020-12-22 10:15 fen儿
这个还算实用哈哈

支持(0) 反对(0)
回复 引用#2楼 2020-12-22 10:16 ydc
…zhe 这…
callback 和 变化后的 值用不同变量不就好了…没太仔细看
你这不好把 callback = callback 操作后的值 之后 还想用之前的callback

支持(0) 反对(0)
回复 引用#3楼 2020-12-22 10:24 多啦A梦的弟弟
我不觉得这是单例造成的问题,应该是作用域没有处理好,我认为有三种方案:
1、callback放到一个独立的类中管理,用完就释放。
2、callback是类的一个属性,我们把它当成一个模板来用,每次都复制一个新的变量。
3、作为函数的一个局部变量,用完就释放。
我个人更推崇1或者3,因为在2中callback作为一个类的属性歧义性太大了,很难只根据接口上下文来推定他的逻辑。

支持(2) 反对(0)
回复 引用#4楼 2020-12-22 10:29 多啦A梦的弟弟
再多说一点[手动滑稽],我认为编程的目的是完成功能,但是这个过程要以人的思维为主题,上面3个方案都可以解决这个问题但是再后期的维护上面则区别很大,我觉得最好的注释就是代码,我们在逻辑上表现清晰以后维护也会很好处理。

支持(1) 反对(0)
回复 引用#5楼 2020-12-22 11:15 来自非洲大草原的食人虎
晕了,这是什么逻辑。跟spring多示例有什么关系,是变量的作用范围没有搞清楚吧
private String callback = “https://ip.com/token=%s”;

callback = callback.replace("", String.valueOf(number));
return callback;

====
上面的那个变量放到方法里面作为栈变量,你这样的直接定义就作为类的实例变量。
直接 return callback.replace("", String.valueOf(number)); 完事了,还扯spring prototype 作啥

支持(7) 反对(0)
回复 引用#6楼 2020-12-22 12:31 calm2020
不仅仅是单例问题 也有string 引用传递所引起的问题

支持(0) 反对(0)
回复 引用#7楼 2020-12-22 16:50 cgyqu
怎么感觉强行扯一块。。。虽说每次实例化能解决,但也会造成其他问题。但这个明显是字符串拼接姿势不对的

private String callback = “https://ip.com/token=%s”;
String url = String.format(callback, String.valueOf(number));
这样打死也不会出问题,replace方式少用。。

支持(2) 反对(0)
回复 引用#8楼 2020-12-22 17:18 JeffWong
少用全局变量 多用局部变量 咩哈哈

支持(0) 反对(0)
回复 引用#9楼 [楼主] 2020-12-22 17:26 风的姿态
@cgyqu
😂

支持(0) 反对(0)
回复 引用#10楼 [楼主] 2020-12-22 17:26 风的姿态
@来自非洲大草原的食人虎
😂

支持(0) 反对(0)
回复 引用#11楼 2020-12-22 21:24 mrfangzheng
因为callback字符串中的第一次被数字替换之后,字符串中就没有了这个占位符了,以后再怎么替换都替换不了。

支持(1) 反对(0)
回复 引用#12楼 2020-12-23 09:01 黑豆爱吃苹果
估计也只有新手会犯这样的错误,不是单例的问题,而是替换以后就没有token这个字段了啊,逻辑思维有问题。

支持(0) 反对(0)
回复 引用#13楼 2020-12-23 09:11 9999号打工仔
用string.format就解决了,字符串定义为static或者const,final,然后永远不变

支持(1) 反对(0)
刷新评论刷新页面返回顶部
发表评论
编辑
预览
支持 Markdown

自动补全
退出 订阅评论

[Ctrl+Enter快捷键提交]

AWS免费产品:
· 如何在AWS上免费构建网站
· AWS免费云存储解决方案
· 在AWS上免费构建数据库
· AWS上的免费机器学习
最新新闻:
· 吉利急了:李书福万字动员书背后的激流
· 国家邮政局:进京快递全面实行“二次安检”
· 波音777发动机爆炸 美日韩停飞 民航局发声关注
· 曝上汽集团将进军汽车芯片产业!已与地平线敲定合作协议
· 3万台苹果电脑遭恶意软件入侵,包括最新的M1系列!快检查一下自己的电脑
» 更多新闻…
公告
扫一下,关注我的微信公众号

公众号点击“加群”菜单,加入 Java 技术微信群
昵称: 风的姿态
园龄: 10年
粉丝: 543
关注: 0
+加关注
积分与排名
积分 - 280525
排名 - 2091
随笔分类 (118)
Android(2)
asp.net(2)
C++(3)
Cocos2d-x(1)
Cordova(1)
Docker(5)
java(63)
java版微信公众账号开发(3)
Js&Jquery(4)
PHP(1)
Python(12)
SharePoint(1)
Sql(5)
其他(7)
网络(1)
更多
阅读排行榜

  1. Spring Cloud Config 实现配置中心,看这一篇就够了(92513)
  2. 网页数据抓取工具,webscraper 最简单的数据抓取教程,人人都用得上(55177)
  3. Spring Cloud OAuth2 实现用户认证及单点登录(51130)
  4. 我所理解的SOA和微服务(29975)
  5. 用java开发微信公众号:公众号接入和access_token管理(二)(28210)
    评论排行榜
  6. ASP.NET是如何在IIS下工作的(68)
  7. 程序员敲代码时耳机里听的到底是什么?(50)
  8. 为什么你在群里提的技术问题没人回答?(38)
  9. 公司短信平台上的2万块钱,瞬间就被黑光了(28)
  10. 我真的不想再用 JPA 了(25)
    推荐排行榜
  11. ASP.NET是如何在IIS下工作的(193)
  12. 面试官你好,我已经掌握了MySQL主从配置和读写分离,你看我还有机会吗?(66)
  13. 一文讲清楚MySQL事务隔离级别和实现原理,开发人员必备知识点(63)
  14. Spring Cloud Config 实现配置中心,看这一篇就够了(48)
  15. 『JWT』,你必须了解的认证登录方案(44)
    Copyright © 2021 风的姿态
    Powered by .NET 5.0 on Kubernetes
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值