SegmentFault 蚂蚁SOFA最新的文章
2019-09-09T10:36:33+08:00
https://segmentfault.com/feeds/blogs
https://creativecommons.org/licenses/by-nc-nd/4.0/
蚂蚁SOFA系列(2) - SOFABoot的Readiness健康检查机制
https://segmentfault.com/a/1190000020330022
2019-09-09T10:36:33+08:00
2019-09-09T10:36:33+08:00
404P
https://segmentfault.com/u/404p
5
<blockquote>作者:404,公众号404P,转载请注明出处。</blockquote>
<h2>前言</h2>
<p>SOFABoot是蚂蚁金服的开源框架,在原有Spring Boot的基础上增强了不少能力,例如Readiness Check,类隔离,日志空间隔离等能力。除此之外,SOFABoot还可以方便的整合SOFA技术栈所包含的各类中间件。如果想要对SOFABoot有体感,可以参考这里<a href="https://link.segmentfault.com/?enc=yToUZSp32QYCDP2sPJgubg%3D%3D.br2MNtFtzyphiT%2B499Io0E8rKj2bk28LwzkGjBktWHZcIOPXUD8FBB79QedT9ptZdgRGbI1p0NZQfZW%2FO6%2BQUQ%3D%3D" rel="nofollow">快速构建一个SOFABoot的应用</a>。</p>
<p>本文来聊聊SOFABoot新增的Readiness健康检查机制。主要内容有以下几点:</p>
<ul>
<li>liveness 和 readiness 的含义和区别</li>
<li>SOFABoot项目如何使用readiness的能力</li>
<li>SOFABoot是如何实现readiness的</li>
</ul>
<h2>一 Liveness 和Readiness</h2>
<p>服务的健康检查,是微服务的基础能力,在微服务的运行时期定时地检查服务健康状态,为熔断降级等提供决策依据。那么说到健康检查,这里提出两个概念:liveness和readiness。这两个概念什么意思呢?有何区别呢?我们先看看在容器编排领域,k8s官网是在什么场景下提到这两个词的。</p>
<blockquote>The kubelet uses liveness probes to know when to restart a Container. For example, liveness probes could catch a deadlock, where an application is running, but unable to make progress. Restarting a Container in such a state can help to make the application more available despite bugs.<br><p>The kubelet uses readiness probes to know when a Container is ready to start accepting traffic. A Pod is considered ready when all of its Containers are ready. One use of this signal is to control which Pods are used as backends for Services. When a Pod is not ready, it is removed from Service load balancers.</p>
</blockquote>
<p>kubelet用liveness探针来检测应用在运行的过程中何时该重启一个容器。例如,liveness探针检测到一个应用在运行中陷入死锁状态,毫无进展,那么这个时候会重启容器暂时避免这种无解的运行状态,保持应用的正常运行。</p>
<p>kuelet用readiness探针来检测何时一个容器可以接受业务流量。当一个Pod中的所有容器都准备就绪了,这个Pod才被认为是准备就绪的,这个时候才会将容器放入到Service的负载均衡池中,对外提供服务。</p>
<p>所以,liveness的职责是在<strong>服务运行期</strong>,已经在跑业务时,定时检查服务是否正常;而readiness的职责则是在<strong>应用服务运行之前</strong>,判断该服务是否准备就绪,如果服务就绪了,负载均衡就可以将业务流量引入到该服务了。服务就绪往往有很多需要判断的,例如:各项配置是否加载完毕。如果这些提供服务前的准备工作未就绪,这个时候把流量放进来,就会有大量报错。</p>
<h2>二 SOFABoot项目中使用Readiness Check</h2>
<p>Readiness Check 在 SOFABoot中是个可选能力,通过starter的方式提供,如果需要使用,引入下方依赖即可:</p>
<pre><code class="xml"><dependency>
<groupId>com.alipay.sofa</groupId>
<artifactId>healthcheck-sofa-boot-starter</artifactId>
</dependency></code></pre>
<p>该starter包含了SpringBoot的健康检查spring-boot-starter-actuator。</p>
<p>在应用启动时,即可启动Readiness检查。</p>
<h2>三 SOFABoot如何实现Readiness Check</h2>
<p>我们到healthcheck-sofa-boot-starter对应的spring.factories文件看看有哪些自定义bean,其配置如下:</p>
<pre><code class="xml">org.springframework.context.ApplicationContextInitializer=\
com.alipay.sofa.healthcheck.initializer.SofaBootHealthCheckInitializer
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.alipay.sofa.healthcheck.configuration.SofaBootHealthCheckAutoConfiguration</code></pre>
<p>配置了两个SOFABoot的实现类,一个是用应用初始化组件,一个是SOFABoot健康检查需要的配置类。</p>
<h3>1 应用初始化</h3>
<blockquote>SofaBootHealthCheckInitializer</blockquote>
<p>这个是SOFABoot对于ApplicationContextInitializer的实现,这个接口的主要职责是:在springcontext 刷新(refresh)之前,调用该接口的initialize做前置的初始化操作,我们看看SOFABoot初始化做了什么事情:</p>
<pre><code class="java">public class SofaBootHealthCheckInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
Environment environment = applicationContext.getEnvironment();
if (SOFABootEnvUtils.isSpringCloudBootstrapEnvironment(environment)) {
return;
}
LOGGER.info("SOFABoot HealthCheck Starting!");
}
}</code></pre>
<p>初始化的时候,判断了当前是否为SpringCloud的引导上下文,如果是的话,则返回,不打印日志,如果不是的话,则打印日志。</p>
<p>这是什么逻辑? 首先,初始化逻辑里只做了一件事情:打印日志,并且是在非SpringCloud环境下才打印日志?其实这是一个兼容逻辑。在SpringCloud环境下,自定义的initializer会被调用两次initialize方法(参考 <a href="https://link.segmentfault.com/?enc=4l3NSEQkqThuKmWSF5nvNQ%3D%3D.kPiIXmni6%2Bd6J6eOZU%2BKApJFnZdtStx42N5KNg0QCRhnNfx2I%2F9eFF1eqNtFsy%2BiRu4SE4q30p8NIu90BA240A%3D%3D" rel="nofollow"># issue1151</a> & <a href="https://link.segmentfault.com/?enc=hjDaF4RtiRFiDAIG2Z8zfA%3D%3D.Yv9x7RFD%2FSkP8STQ7PgGVGg9LaKKggBO%2F3XEJUywgBbvmmz0VC8XWO2OoXSgwkKokL43M0UoxdhbU3I1gcJh1A%3D%3D" rel="nofollow"># issue 232</a>),SpringCloud会加载一个引导上下文(bootstrap context)进来,我们自己的应用程序会加载应用上下文(application context)进来,这两个同时存在,intialzie会调用两次。</p>
<p><strong>isSpringCloudBootstrapEnvironment方法</strong>就是为了区分是否为SpringCloud加载进来的引导上下文,从而屏蔽掉这次initialize执行,确保日志只会在应用上下文时输出,该方法主要是通过是否存在SpringCloud中的特定类来识别是否引入SpringCloud,这里就不赘述了,读者可自行查看。</p>
<h3>2 SOFABoot实现Readiness的核心逻辑</h3>
<h4>2.1 核心Bean介绍</h4>
<blockquote>SofaBootHealthCheckAutoConfiguration</blockquote>
<p>要搞清楚实现Readiness的核心实现,我们先看下SOFABoot到底装配了哪些bean,下面列了一些核心的bean。</p>
<pre><code class="java">@Configuration
public class SofaBootHealthCheckAutoConfiguration {
@Bean
public ReadinessCheckListener readinessCheckListener() {
return new ReadinessCheckListener();
}
@Bean
public HealthCheckerProcessor healthCheckerProcessor() {
return new HealthCheckerProcessor();
}
@Bean
public HealthIndicatorProcessor healthIndicatorProcessor() {
return new HealthIndicatorProcessor();
}
@Bean
public AfterReadinessCheckCallbackProcessor afterReadinessCheckCallbackProcessor() {
return new AfterReadinessCheckCallbackProcessor();
}
}</code></pre>
<p>上面一共罗列了4个bean,一个是应用监听器ReadinessCheckListener,这个是入口逻辑,下文核心逻辑讲解将从这个类开始。</p>
<p>一个是Readiness检查完毕的后置处理器AfterReadinessCheckCallbackProcessor,这个职责也比较容易理解,当Readiness完成之后,就会执行去处理逻辑。</p>
<p>另外两个处理器则是健康检查的处理器,HealthCheckerProcessor是针对SOFABoot提供的HealthChecker类型的bean进行处理,HealthIndicatorProcesso是针对SpringBoot提供的HealthIndicator类型的bean进行处理。</p>
<h4>2.2 核心逻辑解析</h4>
<blockquote>ReadinessCheckListener</blockquote>
<p>这个监听器实现了ApplicationListener,并监听ContextRefreshedEvent事件,当应用上下文刷新完成后,触发监听器收到该事件,执行下面的逻辑。</p>
<pre><code class="java">// ReadinessCheckListener 接收到刷新事件后的执行逻辑
public void onApplicationEvent(ContextRefreshedEvent event) {
if (applicationContext.equals(event.getApplicationContext())) {
healthCheckerProcessor.init();
healthIndicatorProcessor.init();
afterReadinessCheckCallbackProcessor.init();
readinessHealthCheck();
readinessCheckFinish = true;
}
}</code></pre>
<p>接收到上下文的刷新事件后,主要做了四件事情,前面三件是为最后一件事情做准备的:</p>
<ul>
<li>健康检查处理器初始化,将上下文中所有HealthChecker类型的bean都放在map中,等待Readiness检查。</li>
<li>健康指标处理器初始化,将上下文中所有ReactiveHealthIndicator类型的bean都放在map中,等待Readiness检查。</li>
<li>Readiness检查后置处理器初始化,将上下文中所有的ReadinessCheckCallback类型的bean都放在map中,等待Readiness检查完毕后调用。</li>
<li>Readiness健康检查,前面三步已经准备好了HealthChecker、ReactiveHealthIndicator和ReadinessCheckCallback的所有bean,这一步是真正开始Readiness健康检查。Readiness检查核心逻辑如下:</li>
</ul>
<pre><code class="java">public void readinessHealthCheck() {
if (skipAllCheck()) {
logger.warn("Skip all readiness health check.");
} else {
if (skipComponent()) {
logger.warn("Skip HealthChecker health check.");
} else {
healthCheckerStatus = healthCheckerProcessor
.readinessHealthCheck(healthCheckerDetails);
}
if (skipIndicator()) {
logger.warn("Skip HealthIndicator health check.");
} else {
healthIndicatorStatus = healthIndicatorProcessor
.readinessHealthCheck(healthIndicatorDetails);
}
}
healthCallbackStatus = afterReadinessCheckCallbackProcessor
.afterReadinessCheckCallback(healthCallbackDetails);
if (healthCheckerStatus && healthIndicatorStatus && healthCallbackStatus) {
logger.info("Readiness check result: success");
} else {
logger.error("Readiness check result: fail");
}
} </code></pre>
<p>从上面的逻辑,我们可以看到HealthChecker和HealthIndicator的处理都是可以基于配置跳过的,不是必须执行的。当HealthChecker、HealthIndicator、ReadinessCheckCallback对应的处理器都执行成功之后,打印相应的结果信息。</p>
<ul>
<li>healthCheckerProcessor的readinessHealthCheck主要是去收集每一个HealthChecker的检查结果,当所有HealthChecker的检查结果都为true时,返回true。这个过程持续时间比较长,如果一个HealtchChecker返回的结果是false,processor会定时重试再去获取其结果,直到其返回true或者重试到最大次数。</li>
<li>healthIndicatorProcessor的readinessHealthCheck逻辑和healthCheckerProcessor的类似,去收集每一个HealthIndicator的指标的具体信息,但持续过程比较短,无需重试,执行完成则返回true。</li>
<li>afterReadinessCheckCallbackProcessor在Readiness检查完毕之后,逐一去调用所有ReadinessCheckCallback类型的bean,执行readiness的后置处理。</li>
</ul>
<p>核心逻辑,其实就是三个不同类型Bean的处理器,去遍历执行各自的bean集合,收集执行结果。</p>
<ul>
<li>HealthChecker类型:这个是SOFABoot提供的,其处理器是SOFABoot提供的,处理器去遍历执行时需要重试获取检查结果。</li>
<li>ReactiveHealthIndicator类型:这个是SpringBoot本身提供的,其处理器是SOFABoot提供的,用于处理SpringBoot本身的健康检查,处理器遍历执行时无需重试获取检查结果。</li>
<li>ReadinessCheckCallback类型:这个是SOFABoot提供的,其处理器是SOFABoot提供的,Readiness检查后的后置处理bean。</li>
</ul>
<p>可以看出,SOFABoot提供的HealthChecker和SpringBoot提供的HealthIndicator职责有点类似,但是存在差异性,HealthChecker中是适合于Readiness的,其实现类指明重试次数retryCount和重试间隔retryTimeInterval,在应用刚启动时候,做Readiness检查不一定能一次性成功,那么就需要这种最大重试机制,所以HealthChecker的处理器在readinessHealthCheck过程中持续的时间会更长。</p>
<pre><code class="java">public interface HealthChecker {
// some method..
default int getRetryCount() {
return 0;
}
default long getRetryTimeInterval(){
return 0;
}
// some method..
}</code></pre>
<p>而SpringBoot本身提供的是Liveness机制,所以HealthIndicator在运行期间,本身就是一直定时去获取的,没有最大重试次数,只要一直在运行,就要定时去检查。</p>
<pre><code class="java">public interface HealthIndicator {
// Return an indication of health.
Health health();
}</code></pre>
<p>当然,SOFABoot在重试完成了HealthCheck的健康检查之后,再完成了一遍HealthIndicator的健康检查,且执行了一遍后置逻辑,都成功之后,检查结果才是健康的,才可以正式对外提供服务。</p>
<p>显然,要扩展自己的检查指标也是很容易的,如果是要Readiness Check的,则实现一个HealthCheck类,如果是需要Liveness Check的,则实现一个HealthIndicator即可。</p>
<h2>四 结语</h2>
<p>本文开篇介绍了SOFABoot和SpringBoot的关系,在SpringBoot的健康检查中,提供了Liveness Check能力,SOFABoot在此之上新增了Readiness Check能力。通过starter的配置一步一步找到其入口逻辑,并对应用监听器、HealthCheck、HealthIndicator和ReadinessCheckCallback对应的三个处理的逻辑进行了核心解读,并说明了HealthCheck和HealthIndicator的区别。</p>
<hr>
<p><img src="https://image-static.segmentfault.com/376/236/3762361204-5d6f1a32c1a2c_articlex" alt="file" title="file"></p>
<p>近期文章:</p>
<p><a href="https://link.segmentfault.com/?enc=iXMHxnjuLvS69IPdyS8J9g%3D%3D.xW%2FgnQ%2Bq2vrZWlT6SW8P61TDGoV9cbtdw7Xfy9BdZQuPbDkDfPGePc5110vRTbUU3rCRsnE9tO5qPt5njz6EZQ%3D%3D" rel="nofollow">蚂蚁SOFA系列(1) - 聊聊SOFA的模块化</a></p>
蚂蚁SOFA系列(1) - 聊聊SOFA的模块化
https://segmentfault.com/a/1190000020280771
2019-09-04T09:58:12+08:00
2019-09-04T09:58:12+08:00
404P
https://segmentfault.com/u/404p
4
<blockquote>作者:404,公众号404P,转载请注明出处。</blockquote>
<p>SOFA是蚂蚁自研的一套金融级分布式中间件,目前正在逐步向业界开源。SOFA的全称有两个,最早是Service Oriented Fabric Architecture,即面向服务的架构。随着2018年的开源,其全称改为Scalable Open Financial Architecture,即可扩展的开源金融架构。</p>
<p>SOFA技术栈包含了微服务架构体系的各类组件,主要包括RPC框架,服务注册中心,分布式链路追踪,Metrics监控度量等。</p>
<p>本文我们来聊聊SOFA的模块化。</p>
<h2>一 什么是模块化</h2>
<p>模块化在计算机领域是经常讨论的话题,在学校学编程语言的时候,教科书上说程序设计要遵循模块化原则。</p>
<blockquote>模块化程序设计是指在进行程序设计时将一个大程序按照功能划分为若干小程序模块,每个小程序模块完成一个确定的功能,并在这些模块之间建立必要的联系,通过模块的互相协作完成整个功能的程序设计方法。</blockquote>
<p>上面这段话引自百度百科,其实精炼下就是:<strong>高内聚和低耦合</strong>。</p>
<p><img src="/img/remote/1460000020280775?w=580&h=246" alt="file" title="file"></p>
<h2>二 模块化思想演变</h2>
<h3>1 代码设计模块化</h3>
<p>最早学编程的时候,实现一个功能,所有逻辑放到一个main函数里去,后来发现理不清了,就把main里面的逻辑抽成几个函数。这是模块化吗?是。这个模块化是最基本的代码设计能力,增强代码的可读性、可维护性和可扩展性。如下图,计算成绩的时候,没有把所有逻辑放在A中,而是分在B1和B2中,单独计算,最后A调用B1、B2来实现。</p>
<p><img src="https://image-static.segmentfault.com/281/165/2811659102-5d6f1a2d7e94e_articlex" alt="file" title="file"></p>
<p>在这种简单的程序设计中,往往更注重逻辑的内聚性,只要内聚做好了,往往就是低耦合的。</p>
<h3>2 业务领域模块化</h3>
<p>在真实做一些项目的时候,业务系统比较复杂,要实现的功能很多。这个时候出现了横向和纵向的模块化设计。横向的就是分层设计,纵向的就是按照不通的业务领域来设计。</p>
<p>一个业务系统,横向的模块化切分主要分为三大层:Web层,Service层,DAL层。</p>
<p>在业务初期,功能往往都是写在一个业务系统的,比如订单模块Order、库存模块Stock。在maven中这些模就是不同的module,但运行都是在同一个JVM,同一个web容器中的。</p>
<p><img src="/img/remote/1460000020280777?w=560&h=564" alt="file" title="file"></p>
<p>这在种情况下,订单服务依赖于库存服务怎么办?订单模块的pom中引入库存模块的依赖。然后注入bean。</p>
<pre><code class="java">public class OrderService {
@Autowired
private StockService stockService;
}</code></pre>
<p>这种横纵模块化思想随着业务的复杂开始进化了。订单、库存模块虽然在一个Service层,但属于明显不同的领域,已经被分成不同的模块了,具有很好的内聚性,而且要引用其它模块,必须引入pom依赖之后才能访问,具有不错的隔离型。</p>
<h3>3 业务系统模块化的弊端</h3>
<p><strong>但是,这样设计的耦合性还是不够低,隔离性不够强。</strong></p>
<blockquote>程序员小胖:还不够强吗?订单模块和库存模块都在不同的module了,不费任何力气,就可以直接把源代码分成两个项目,由不同的团队来写了。<p>404P: 不够,现在的隔离性最多在开发层,能够相互隔离开发。运行时呢?</p>
</blockquote>
<p>所有module的bean都在同一个spring context中,A模块可以任意引用B模块的bean,开发同学引入另一个module之后,不太清楚该module中哪些是对外提供的的接口,哪些bean是可以直接注入调用的,哪些Bean是内部Bean,不适合直接去注入的。</p>
<p>长期如此,一个模块的bean被不断地注入到另外一个模块被调用,那么其运行时的隔离性就差了。运行时没有做好隔离,是服务拆分的一大痛点。大概就是下图这个样子。</p>
<p><img src="/img/remote/1460000020280778?w=686&h=366" alt="file" title="file"></p>
<blockquote>程序员小胖:请继续你的表演。<p>404P:随着业务的增长,必然会把Order和Stock拆分,成为不同的子业务系统。代码在不同的module,拆分开来很简单,但是拆分后两个业务系统是运行在不同的SpringContext中的。而bean的注入只在一个SpringContext有效,所以之前通过bean注入来实模块交互的地方需要梳理出来,变成系统之间的接口交互才能实现服务拆分。</p>
<p>程序员小胖:听你这么一说,有道理。模块化思想都是跟着架构思想走的啊。如果要考虑未来模块拆分成服务,就需要考虑好运行时隔离,也就是运行时的低耦合交互。</p>
<p>404P: 是的。看看SOFA怎么做的。</p>
</blockquote>
<h2>三 SOFA模块化</h2>
<p>为了防止这种模块之间滥用bean注入来交互。SOFA启动后,会为每个moudule创建一个SpringContext,每个module运行在各自的SpringContext中。不同模块之间的bean无法直接引用,具备了较好的运行时隔离能力。</p>
<p><img src="https://image-static.segmentfault.com/363/769/3637694750-5d6f1a301f4e8_articlex" alt="file" title="file"></p>
<p>那么当Order模块想引用Stock模块的Bean,怎么办呢?</p>
<p>首先,需要Stock模块有发布对外的公共bean,通过如下声明式发布(也可以通过注解方式):</p>
<pre><code class="java"><sofa:service ref="stockBeanA" interface="com.alipay.sofa.StockBeanA"/></code></pre>
<p>那Order模块怎么引用Stock模块中的公开bean呢?通过如下声明方式引用:</p>
<pre><code class="java"><sofa:reference id="stockBeanA" interface="com.alipay.sofa.StockBeanA"/></code></pre>
<p>Stock模块的stockBeanA已经公开发布,并且Order模块已经引用,那么Order模块在编码的时候,就可以直接注入bean stockBeanA了。</p>
<p>这种方式,我们可以很清晰地看到一个模块公开了哪些服务,引用了哪些服务。模块之间的交互变得非常清晰。</p>
<h2>四 SOFA模块拆分成微服务</h2>
<p>当一个SOFA应用开始变得复杂,开发团队成员开始增多时,就需要进行服务化了。比如,Order模块和Stock模块,不再是运行在一个系统了,而是要变成Order系统和Stock系统了。这意味着这两个领域之间的交互从模块级别的交互上升到应用系统级别的交互了。</p>
<p><img src="/img/remote/1460000020280780?w=800&h=237" alt="file" title="file"></p>
<p>这种服务化拆分需要考虑两点:</p>
<p>(1)模块化的交互是在同一JVM内存中不同SpringContext之间的交互,拆分成两个应用系统后,应用系统之间交互必然是通过网络请求来交互,必然要考虑远程通信的问题。</p>
<p>(2)模块之间的服务发布和引用与应用系统之间的服务发布和引用是否有差异,需要改造?</p>
<p>SOFA考虑了以上两点,要将一个SOFA模块拆成微服务是非常便捷的。</p>
<p>Stock应用发布的时候,如下:</p>
<pre><code class="java"><sofa:service ref="stockBeanA" interface="com.alipay.sofa.StockBeanA">
<sofa:binding.bolt/>
</sofa:service></code></pre>
<p>Order应用引用Stock的服务时,如下</p>
<pre><code class="java"><sofa:reference id="stockBeanA" interface="com.alipay.sofa.StockBeanA">
<sofa:binding.bolt/>
</sofa:reference></code></pre>
<p>可以看出,就是添加了个属性 ,</p>
<pre><code class="java"><sofa:binding.bolt/></code></pre>
<p>表示服务之间的交互是通过sofa bolt远程调用框架来完成,发布和引用方式几乎没有变化。这种简易的服务化拆分,为蚂蚁架构在服务化演进的过程中带来了很大的便利。</p>
<h2>五 结语</h2>
<p>随着问题域的复杂性越来越高,模块之间的隔离边界也有更高的要求,本文从简单的例子,逐渐演变到服务拆分,从而引出SOFA的模块化。SOFA基于SpringContext作为模块隔离边界,充分降低了模块交互的耦合性,同时也为后续服务拆分提供了便利。</p>
<p>关于SOFA模块化的实现原理,将另起一文,欢迎关注下方公众号,更多思考,与你分享。</p>
<hr>
<p><img src="https://image-static.segmentfault.com/376/236/3762361204-5d6f1a32c1a2c_articlex" alt="file" title="file"></p>
<h5>近期文章:</h5>
<p><a href="https://link.segmentfault.com/?enc=8us8E28EUAm9tooasU4pAw%3D%3D.llN2CK%2FEfiKUU3sYKn5Dzn2ebBHtytgtrL4YXr29ugnXRoU%2FAY2DFPznCAIWZipF%2Fl6Tu%2B6OaUVU8shxHP%2FDzA%3D%3D" rel="nofollow">分布式幂等问题解决方案三部曲</a></p>
<p><a href="https://link.segmentfault.com/?enc=fXWhCLjf1t32dWoJgGRjQA%3D%3D.%2BNqtPa2vSacksNBECiEXtqxWM1QR1STE9Z6ZvXjrmMfVbhDAWHGIlcQFz3SltINkmDEZ8Y66aqwrvu8LcpCrQg%3D%3D" rel="nofollow">Java跨平台?慎用这些有平台差异性的方法!</a></p>