2023/09/15回顾点评--一次很有意思的煞笔经历,算是让我对理论转换成实践增加了理解,写得还不错,现在回看这写的真挺详细的
2023/11/15--追加String新版本优化解析
前言
本文记录我一次煞笔操作,为我不开阔的思路进行一次野蛮冲撞,并且留待以后再看到本文时,反复捶打自己,以保证留下深刻的印象。事情起源于一个奇怪的需求,以及对能力的"质疑",还有我的上头。我会记录下我的完整思考内容,以及错误的路线供大家点评嘲讽。其实解决问题很多时候就差一层窗户纸,关键是就差这点就很难受了,这次的经历非常惨痛,所以必须记住。
需求拆解
描述上要绕一点,说的反正很简单,要在不修改单点登录组件的前提下,做到在单点登录组件前面再加一层URL拦截校验。需求很简单,典型一句话问题,这里我来拆解一下。
首先要分析单点登录组件的构成,因为我是组件的唯一作者,所以我对组件的构成非常了解,简单来说就是一个拦截器Interceptor。那么问题就变成了如何在不修改原有拦截器的情况下,去做扩展。思路是很直接的,我当时是立刻产生了,移除原有的拦截器,增加现有的拦截器这样的思路。关键是如何移除,如何增加,同时如何把握新老拦截器的关系,接下来会先介绍一些基础知识,来预热我的煞笔操作。
相关知识
拦截器和过滤器
拦截器
我这里说的拦截器是基于Spring MVC框架提供的拦截器Interceptor进行讲解,其他框架的类似实现就不一一列举了。首先说明下网上很多文章说的action是啥,就是我们常说的控制器(@Controller注解类)下定义的请求处理方法(@RequestMapping注解方法),每一个方法就是一个action。拦截器是动态拦截action调用的对象,然后提供了可以在action执行前后增加一些操作,也可以在action执行前停止操作,Spring MVC中的拦截器(Interceptor)类似于ServLet中的过滤器(Filter),但是标准和实现方式不同,后面会说。
拦截器体现了面向切面编程(AOP)的思想,基于Java的反射机制(动态代理)实现的,并且一个请求可以链式触发多个拦截器。多个拦截器的处理顺序类似于同心圆,preA->preB->Controller->afterB->afterA,过滤器和拦截器叠加时同理。因为我现在使用Spring Boot2.1.x进行开发,所以我平常建一个拦截器喜欢继承抽象类HandlerInterceptorAdapter,相对于实现接口HandlerInterceptor的好处在于,可以只重写想处理的方法(但是JDK8引入接口default实现后能达到相同的效果,并且HandlerInterceptorAdapter在Spring高版本标记为过时,所以还是直接实现HandlerInterceptor吧)。在HandlerInterceptorAdapter中主要提供了以下的方法:
- preHandle:在方法被调用前执行。在该方法中可以做类似校验的功能。如果返回true,则继续调用下一个拦截器。如果返回false,则中断执行,也就是说我们想调用的方法 不会被执行,但是你可以修改response为你想要的响应。
- postHandle:在方法执行后调用。
- afterCompletion:在整个请求处理完毕后进行回调。如果在InterceptorA执行通过后,InterceptorB.preHandle中报错或返回false ,那么InterceptorB的postHandle和afterCompletion不会走,但注意被执行过的拦截器InterceptorA的afterCompletion仍然会执行。
注册拦截器也很简单,增加一个配置类TestHandlerInterceptorConfiguration如下
public class TestHandlerInterceptorConfiguration implements WebMvcConfigurer {
@Autowired
private TestInterceptor testInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
InterceptorRegistration ir = registry.addInterceptor(testInterceptor);//注册拦截器
ir.addPathPatterns("/common/**");//放行
ir.excludePathPatterns("/health");//不放
ir.order(Ordered.SSO_ORDER);//设置拦截器顺序
}
}
过滤器
过滤器Filter能对web服务器管理的所有web资源,例如Jsp, Servlet, 静态图片文件或静态 html 文件等进行拦截。比拦截器Interceptor拦截的东西更多,生成一个过滤器也很简单,实现Filter接口即可。执行顺序和拦截器一样,也是链式调用。
@WebFilter(urlPatterns = "/user/*", filterName = "triceFilter")
public class SimpleOpenTractingFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) {
log.info("开始初始化={}", filterConfig.getFilterName());
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
log.info("过滤器前处理");
filterChain.doFilter(servletRequest, servletResponse);
log.info("过滤器后处理");
}
@Override
public void destroy() {
log.info("结束销毁");
}
}
- init:web应用程序启动时,web服务器将创建Filter的实例对象,并调用其init方法,完成初始化。Filter对象只创建一次,因此初始化方法仅调用一次,这一点和拦截器不同。因为该接口同样被default修饰,所以实现接口后也不需要重写.
- doFilter:当客户请求访问与过滤器关联的URL的时候,Servlet过滤器将先执行doFilter方法。FilterChain参数用于访问后续过滤器。这里就是为什么说Filter的实现原理是基于函数回调的原因,详情见源码方法org.apache.catalina.core.ApplicationFilterChain#internalDoFilter。该方法类似于拦截器三个方法的浓缩版,可以这么简单理解。
- destroy:销毁时调用,生命周期和web应用程序一致,可以理解init的销毁版。
拦截器和过滤器区别
上面这篇文章就是网上比较好的一篇文章,我这就不赘述了,详情看大佬文章,我这仅作一个总结并且说一下我自己的看法。用Spring Boot进行开发的时候,可以引入很多基于Aop思想实现的框架或者工具,比如Filter、Interceptor、Spring Aop、AspectJ、JavaAgent。在各类组件源码中比较常见的可能是Filter、Interceptor,开发中比较常用的是Spring Aop、AspectJ,也就是我们常说的切面,最后不太常见的是JavaAgent,可能经常玩中间件的可能比较熟悉,打着无侵入名号的由Java开发的中间件大多以此方式加载,比如Skywalking。
- 底层实现方式大不相同。过滤器是基于函数回调的,拦截器则是基于Java的反射机制(动态代理)实现的。
- 使用范围不同。过滤器在sevlet规范中定义,依赖于Tomcat等容器在web程序中应用。拦截器由Spring容器管理,可以单独使用。
- 触发时机不同。过滤器是在请求进入容器后,进入sevlet之前进行处理,过滤器是在sevlet处理后,在controller之前进行处理。
- 拦截的请求范围不同。过滤器几乎可以对所有进入容器的请求起作用,而拦截器只会对Controller中请求或访问static目录下的资源请求起作用。
- 注入Bean情况不同。拦截器和过滤器在引入外部service的时候,本身要注解@Component被Spring管理,但是过滤器还是要注册一下。
- 控制执行顺序的方式不同。过滤器使用@Order注解即可,拦截器需要在注册的时候指定order参数,两者都是值越小优先级越高。
静态代理和动态代理
静态代理其实就是扩展(比如实现)原有类,进行功能的增强,这种模式会强依赖原类。相比而言动态代理会更加灵活,下面直接上代码。
//测试类,有接口,方便JDK代理测试
public interface DictService{
void testProxy();
}
public class DictServiceImpl implements DictService{
@Override
public void testProxy() {
System.out.println("正在测试中");
}
}
//静态代理,直接实现接口或者继承类即可
//要点在于写一个有参构造方法,将被代理对象放进来,然后重写方法即可
public class DictProxy implements DictService {
private DictService target;
public DictProxy(DictService target) {
this.target = target;
}
@Override
public void testProxy() {
System.out.println("静态代理运行前");
target.testProxy();
System.out.println("静态代理运行后");
}
}
JDK动态代理是通过反射机制生成一个和被代理类实现相同接口且继承Proxy类的代理类,并实现接口的方法,保持和被代理类相同的接口,并在调用方法时调用父类持有的Invocationhandler来处理,只能对实现了接口的类生成代理
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
/**
* @Classname JdkProxyFactory
* @Date 2020/12/3 20:01
* @Author WangZY
* @Description JDK 动态代理类 注意导入包为java.lang.reflect
*/
public class JdkProxyFactory implements InvocationHandler {
/**
* 代理类中的真实对象
*/
private final Object target;
public JdkProxyFactory(Object target) {
this.target = target;
}
/**
* @param proxy 增强对象
* @param method 拦截方法
* @param args 参数列表
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException,
IllegalAccessException {
//调用方法之前,我们可以添加自己的操作
System.out.println("JDK动态代理" + method.getName() + "运行前");
Object result = method.invoke(target, args);
//调用方法之后,我们同样可以添加自己的操作
System.out.println("JDK动态代理 " + method.getName() + "运行前");
return result;
}
public Object getProxy() {
return Proxy.newProxyInstance(
// 目标类的类加载
target.getClass().getClassLoader(),
// 代理需要实现的接口,可指定多个,得通过接口代理
target.getClass().getInterfaces(),
// 代理对象对应的自定义 InvocationHandler
this);
}
}
CGLIB动态代理则是通过使用ASM开源包,加载被代理对象的class文件,并修改其字节码文件生成一个继承被代理对象的子类来作为代理对象,并重写代理方法使其调用自定义的方法拦截器去执行,因为基于继承所以被代理类和方法不能被final关键字修饰,保证可以被继承和重写
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
/**
* @author WangZY
* @classname CglibProxy
* @date 2022/11/11 10:58
* @description Cglib代理
*/
public class CglibProxyFactory implements MethodInterceptor {
/**
* CGLIB 增强类对象,代理类对象是由 Enhancer 类创建的,
* Enhancer 是 CGLIB 的字节码增强器,可以很方便的对类进行拓展
*/
private Enhancer enhancer = new Enhancer();
/**
* 创建代理对象
*/
public Object getProxy(Class<?> clazz) {
// 设置类加载
// enhancer.setClassLoader(clazz.getClassLoader());
// 设置被代理类
enhancer.setSuperclass(clazz);
// 回调方法intercept
enhancer.setCallback(this);
// 创建代理对象
return enhancer.create();
}
/**
* @param proxy 增强对象
* @param method 拦截方法
* @param args 参数列表
* @param methodProxy 方法代理
*/
@Override
public Object intercept(Object proxy, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
System.out.println("CGLIB代理开始");
Object result = methodProxy.invokeSuper(proxy, args);
System.out.println("CGLIB代理结束");
return result;
}
}
简单易懂的食用指南
//静态代理
DictProxy userProxy = new DictProxy(new DictServiceImpl());
userProxy.testProxy();
//JDK动态代理
JdkProxyFactory jdkProxyFactory = new JdkProxyFactory(new DictServiceImpl());
DictService jdkProxy = (DictService) jdkProxyFactory.getProxy();
jdkProxy.testProxy();
//CGLib动态代理
CglibProxyFactory cglibProxyFactory = new CglibProxyFactory();
DictServiceImpl cglibProxy = (DictServiceImpl) cglibProxyFactory.getProxy(DictServiceImpl.class);
cglibProxy.testProxy();
JDK动态代理和CGLib动态代理区别
- JDK动态代理:要求⽬标对象实现⼀个接⼝,但是有时候⽬标对象只是⼀个单独的对象,并没 有实现任何的接⼝,这个时候就可以⽤CGLib动态代理
- CGLib动态代理,它是在内存中构建⼀个⼦类对象从⽽实现对⽬标对象功能的扩展
- JDK动态代理是⾃带的,CGlib需要引⼊第三⽅包
- CGLib动态代理基于继承来实现代理,所以⽆法对final类、private⽅法和static⽅法实现代理
Spring AOP中的代理使用的默认策略:
- 如果⽬标对象实现了接⼝,则默认采⽤JDK动态代理
- 如果⽬标对象没有实现接⼝,则采⽤CgLib进⾏动态代理
- 如果⽬标对象实现了接⼝,程序⾥⾯依旧可以指定使⽤CGlib动态代理
逆天操作
需求说明和拆解完毕后,我是理所应当的建了一个Spring Boot Starter组件,然后写了一个拦截器,写好注册代码。之前我弄了个模板所以这一步做的很快,然后书接上回(需求拆解),我开始思考如何移除拦截器,因为领导马上就要,所以临近六点下班的我选择了极为粗暴的直接移除bean(后来证明我的思路一开始就闭塞了,执着于快速暴力的移除)。
常规组件中移除Bean,我的常规操作是利用BeanFactory的后处理器去操作Spring容器
@Component
public class TestBeanConfig implements BeanDefinitionRegistryPostProcessor {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
String[] beanDefinitionNames = registry.getBeanDefinitionNames();
System.out.println(Arrays.toString(beanDefinitionNames));//打印所有BeanName,方便寻找要删除的Bean
if (registry.containsBeanDefinition("com.ruijie.framework.sso.zero.config.RJSsoConfigurerAdapter")) {
registry.removeBeanDefinition("com.ruijie.framework.sso.zero.config.RJSsoConfigurerAdapter");
}
}
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
}
}
实现BeanDefinitionRegistryPostProcessor接口,重写postProcessBeanDefinitionRegistry即可快速实现Bean的创建和删除,这个是比较常见的手法,启动的时候去删除框架中的某些Bean,在不能动框架的情况下进行二次开发是个比较好的手段。但是就我的需求而言是不合格的,因为他要实现在程序运行时的Bean动态创建和销毁。于是我直肠子的选择了最骚的操作,使用ApplicationContext动态创建和销毁Bean
当然结果很明显,Debug发现Bean完好无损,没有一点毛病。换言之,我的操作毫无效果,我不知道是不是我的操作不对,因为这也是我第一次这么用ApplicationContext,之前我都是用来getBean破除循环依赖的,这么玩还是第一次。网上搜索无果后(希望看的本文的大佬能教教我怎么做,拜托啦,这对我真的很重要),到了八点左右,接近两小时的操作无效,领导让我下班明天再弄,正好最近项目紧张到爆炸,我只想休息,于是下班!!!(结果是回家还是忍不住百度了一下,无果后开始双十一抢购,没错这是11月10日晚的事,差点耽误我今晚参与上亿的大项目了)
第二天早上的时候,还是做项目开发,但是没做到一小时,领导问我昨天那个问题如何了。我当时麻了,又要催我做新项目,又要解决老项目的BUG,还要来负责领导的奇思妙想的需求,我也不能影分身啊。没办法,领导为大,又没人来做,只能我上了。接着昨晚的思路,首先毙掉了ApplicationContext,快速放弃不纠结,多通道展开是我在组里火箭式开发(一周一个项目,(喇叭)快,就是要快)下养成的习惯。相关知识里提到的拦截注册问题,于是我想到一个骚操作,引入注册器InterceptorRegistry,调用删除拦截器的API。
好嘛,出师未捷身先死,拦截器注册的原理就是往registrations这个集合里面添加值,但是类里没有提供删除的方法,也就是说常规路子走不通。那有没有野路子呢,有的,反射暴力修改,拿经典的String是否真的不可变来举例,代码如下:
String str = "aaa";
char[] chars = new char[]{'b', 'b', 'b'};
//获取String类中的属性value,私有属性使用getDeclaredField方法
Field field = String.class.getDeclaredField("value");
//两个作用:1.禁用Java安全检查,允许修改私有变量。2.提高反射速度
field.setAccessible(true);
//set强行赋值,第一个参数是被修改的对象实例,第二个参数是修改后的值
field.set(str, chars);
System.out.println(str);
但很快我又放弃了这个思路,感觉更麻烦了,我需要在拦截器preHandle取出所有拦截器,然后删掉某一个,然后在postHandle里面再把删掉的那个补回去,不论怎么想都是一股子作死的感觉。这会儿微信技术交流群的大佬们提供了代理、装饰器等思路,然后我就想着来玩玩代理。
因为拦截器不是接口,所以这里代理只能使用Cglib来动态代理或者静态代理。于是离谱的操作来了,不知道为啥,当我写下SsoProcessHandler instance = (SsoProcessHandler) new CglibProxy().getInstance(SsoProcessHandler.class);,这一行代码后,我大脑死机了。开始偏执的认为拦截器的preHandle方法是由Spring管理被动调用的,而不是我们常见的service.xxx()这种主动调用,然后我傻逼了,在问烦了大佬后,递给大佬一杯奶茶钱,让大佬给我写了Demo。
看了Demo之后,我发现我是个大傻逼,用了将近一天的时间,感觉自己像个小丑。大佬的例子就很简单,静态代理,思路就是这样简单,代码也没几行,一切都显得如此朴实无华。下面是代理的代码,当然也可以用动态代理,看注掉的代码即可.
@Slf4j
public class TestInterceptor extends SsoProcessHandler {
public TestInterceptor() {
}
public TestInterceptor(SSOProperties ssoProperties, BaseEnvironmentConfigration baseEnv) {
super(ssoProperties, baseEnv);
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
log.info("业务逻辑校验开始......");
if (new Random().nextInt() % 2 == 0) {
// SsoProcessHandler instance = (SsoProcessHandler) new CglibProxy().getInstance(SsoProcessHandler.class);
// instance.preHandle(request, response, handler);
log.info("业务逻辑校验通过,执行SSO拦截器");
return super.preHandle(request, response, handler);
}
log.info("业务逻辑校验不通过,不走SSO拦截器了");
return true;
}
}
(⊙﹏⊙),就是这么简单,我已经无语了。当然到这还没有结束,这里解决了如何动态装载老的拦截器的问题,但是没有解决历史注册的问题,我还需要把老的注册器给干掉。我的思路还是很直接,因为胜利在望了,所以选择了上面提到的实现BeanDefinitionRegistryPostProcessor接口来删除老的注册器,然后就出问题了,报找不到老注册器的Bean。
Message: Error creating bean with name 'servletEndpointRegistrar' defined in class path resource [org/springframework/boot/actuate/autoconfigure/endpoint/web/ServletEndpointManagementContextConfiguration$WebMvcServletEndpointManagementContextConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.boot.actuate.endpoint.web.ServletEndpointRegistrar]: Factory method 'servletEndpointRegistrar' threw exception; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No bean named 'com.ruijie.framework.sso.zero.config.RJSsoConfigurerAdapter' available
这里留一个问题啊,希望大佬们能帮忙解答下,配置类为什么不能从这里删除?我个人从排除配置类的方法来推,是猜测和Spring加载配置类和加载BeanFactory的顺序有关。
接下来介绍下如何排除配置类,主要还是围绕@EnableAutoConfiguration的exclude配置来说,有三种方式可以达到这一效果,因为底层逻辑是一致的。
//启动类注解
@SpringBootApplication(exclude = RJSsoConfigurerAdapter.class)
//建一个配置类,使用注解排除
@Configuration
@EnableAutoConfiguration(exclude = RJSsoConfigurerAdapter.class)
public class ExcludeConfig {
}
//配置文件
spring.autoconfigure.exclude=com.ruijie.framework.sso.zero.config.RJSsoConfigurerAdapter
至此整个需求做完,回头来看,我的思路是对的,但是不知道为啥卡住了,我想,可能还是没有把理论活用在实际中吧。好记性不如烂笔头,这次的经历完完整整的记录下来了,没有美化全是写实记录,希望给予自己以警醒。不过距离事件发生已经有十六天了,断断续续凭借印象、代码记录、聊天记录最后复原了整个过程,真是败给了自己的拖延症,话说兄弟们见过一周一个项目的进度吗?
追加赠品
把需求做成Demo给同事后让他自己去补充业务细节玩了几天,来找我说为啥报错了,启动就报错是为啥?我一看就乐了,这不循环依赖吗?轻松加上@Lazy,解决!
如何解除循环依赖
- 手动获取该类,阻止循环依赖。用老朋友applicationContext在Bean容器中直接拿出来直接用就行,这样就不会在程序启动时被检查到。
XXX xxx = applicationContext.getBean(XXX.class);
- 延时加载。和法一是一个思路,不在程序启动的时候去加载,用的时候再加载,这里用Spring提供的注解即可。
@Autowired
@Lazy
private XXX xxx;
String新版本变化
在JDK9以后,String的实现内部改为使用byte数组(byte[])。这样做的主要原因是为了节省内存空间,因为对于大量的拉丁文系列字符(如英文、数字、常见的标点符号等),使用byte数组存储比使用char数组可以节省一半的空间。
同时,String类的内部还引入了一个名为coder的byte类型的字段。这个字段是用来标识存储在byte数组中的数据是何种字符编码的。在新的String类的实现中,存在两种可能的字符编码:ISO-8859-1(一个字符占用一个字节)和UTF-16(一个字符占用两个字节)。对于ISO-8859-1编码的字符串,coder的值为0,而对于UTF-16编码的字符串,coder的值为1。这样,通过检查coder字段的值,就可以知道存储在byte数组中的数据应该使用什么样的编码方式进行处理,从而避免了因为字符编码不同而导致的处理错误。
写在最后
兑现承诺,半个月一更保证原创,其实也就是记录下有意思的事情。这次的算是傻逼操作了,菜鸡浓度拉满了,哎,大佬光环不在了,哈哈。说一说我的近况,最近项目太紧了,每天加班,抗不住了,想记录点东西总是没太多时间。本来这一篇也是临时起意,挖掘项目难点的文章依旧难产中,我不知道该用什么样的方式来举例或者展示给大家,而且本身例子就很难产,难,太难了。同事给我提供了一个素材,让我来写写设计模式怎么应用到项目中,蛮有意思的,之前忘了这东西了。但其实这个在开发中还算是常见的,比如单例代理啥的不自觉就用到了。还有个素材是我最近遇到的communications link failure问题,不过这个还没有完美的解决,我也没太搞明白,暂时不会写。所以下篇会是设计模式或者项目难点中二选一,如果我有空学习新技术的话,可能会更新新技术的学习过程。
犹豫良久,还是想聊聊,关于项目开发的一点问题。我最近在做看板项目嘛,基本是要求开发时间是压缩在三天,联调测试各一天,一共五天。项目可能是比较小,资源配置一般是两后端一前端这样的,目前大致是做了十个左右,我作为后端参与了八个,算是比较清楚整个看板项目的情况。和其他同事配合的时候我主要是做数据运算和一些逻辑上的处理,页面接口我会交给另一个后端同事,我觉得这样的开发模式会比各自负责一个模块效率更高一些,后来证明我的这种模式是正确的,因为另外有两个后端同事过来支援的时候各自负责一半模块,后来整个项目的平均耗时在我们的两倍左右。
八个看板由于时间的紧迫几乎破除了一切正常的开发步骤,上午开完需求会,下午就开始开发了,这个时候没有数据库设计、功能设计、详细设计啥都没有,只有一知半解的需求,因此每个项目都会延期,并且往往做完三四个看板之后,需要回头来解决之前看板被测试、PM、用户提出的问题。我和开发同事们几乎每天加班,有时候包括周末,但依旧感觉疲于奔命,开发的代码量就在那,并且不断的出现性能问题,详见之前的文章。
我有时候都不知道该怎么提升效率了,哪怕我封装了那么多组件,有了那么多自己的代码套路粘来即用。我想问下读者大大们,到底如何在这种情况下来提升代码效率,我个人也是受限于自己的眼界,暂时也想不到什么好的方法。我有时候和开发同事聊天,说咱这开发是为了快而快,并且我也会刻意地去引导开发同事不要想太多,快就完了。不要思考,思考手上的速度就会慢下来。但是这样真的好吗?肯定是不对的,数据库为了加速,我都是极简设计,功能则是尽量简单,以套上套路为第一优先级。八个看板做下来,只感觉到了疲惫,为了快牺牲了质量,挨了骂,整个项目组都十分低气压。所以我就在想,究竟还能咋样再提升下效率呢,或者能让大家的开发更加快乐一些,希望读者大大们能够给予我一些建议,如果没有就当看个乐子,听我吐吐槽了,哈哈。说出来感觉舒服多了,这里是周末仍在加班的Java菜恐龙,下一篇再见!