分布式任務(wù)調(diào)度有那么難嗎?來,10分鐘帶你實(shí)戰(zhàn)
一、概述
1.1、什么是任務(wù)調(diào)度
我們可以思考一下下面業(yè)務(wù)場景的解決方案:
某電商平臺(tái)需要每天上午10點(diǎn),下午3點(diǎn),晚上8點(diǎn)發(fā)放一批優(yōu)惠券
某銀行系統(tǒng)需要在信用卡到期還款日的前三天進(jìn)行短信提醒
某財(cái)務(wù)系統(tǒng)需要在每天凌晨0:10分結(jié)算前一天的財(cái)務(wù)數(shù)據(jù),統(tǒng)計(jì)匯總
以上場景就是任務(wù)調(diào)度所需要解決的問題,任務(wù)調(diào)度是為了自動(dòng)完成特定任務(wù),在約定的特定時(shí)刻去執(zhí)行任務(wù)的過程。
在Spring中也提供了定時(shí)任務(wù)注解@Scheduled。我們只需要在業(yè)務(wù)中貼上注解然后在啟動(dòng)類上貼上@EnableScheduling注解即可完成任務(wù)調(diào)度功能。
@Scheduled(cron = "0/20 * * * * ? ") // 每隔20秒執(zhí)行一次
public void doWork(){
//doSomething
}
復(fù)制代碼1.2、分布式調(diào)度出現(xiàn)
感覺Spring給我們提供的這個(gè)注解可以完成任務(wù)調(diào)度的功能,好像已經(jīng)完美解決問題了,為什么還需要分布式呢?主要的原因有以下幾點(diǎn):
機(jī)處理極限:原本1分鐘內(nèi)需要處理1萬個(gè)訂單,但是現(xiàn)在需要1分鐘內(nèi)處理10萬個(gè)訂單;原來一個(gè)統(tǒng)計(jì)需要1小時(shí),現(xiàn)在業(yè)務(wù)方需要10分鐘就統(tǒng)計(jì)出來。你也許會(huì)說,你也可以多線程、單機(jī)多進(jìn)程處理。的確,多線程并行處理可以提高單位時(shí)間的處理效率,但是單機(jī)能力畢竟有限(主要是CPU、內(nèi)存和磁盤),始終會(huì)有單機(jī)處理不過來的情況。
高可用:單機(jī)版的定式任務(wù)調(diào)度只能在一臺(tái)機(jī)器上運(yùn)行,如果程序或者系統(tǒng)出現(xiàn)異常就會(huì)導(dǎo)致功能不可用。雖然可以在單機(jī)程序?qū)崿F(xiàn)的足夠穩(wěn)定,但始終有機(jī)會(huì)遇到非程序引起的故障,而這個(gè)對于一個(gè)系統(tǒng)的核心功能來說是不可接受的。
防止重復(fù)執(zhí)行: 在單機(jī)模式下,定時(shí)任務(wù)是沒什么問題的。但當(dāng)我們部署了多臺(tái)服務(wù),同時(shí)又每臺(tái)服務(wù)又有定時(shí)任務(wù)時(shí),若不進(jìn)行合理的控制在同一時(shí)間,只有一個(gè)定時(shí)任務(wù)啟動(dòng)執(zhí)行,這時(shí),定時(shí)執(zhí)行的結(jié)果就可能存在混亂和錯(cuò)誤了。
1.3、Elastic-Job
Elastic-Job是一個(gè)分布式調(diào)度的解決方案,由當(dāng)當(dāng)網(wǎng)開源,它由兩個(gè)相互獨(dú)立的子項(xiàng)目Elastic-job-Lite和Elastic-Job-Cloud組成,使用Elastic-Job可以快速實(shí)現(xiàn)分布式任務(wù)調(diào)度。Elastic-Job的github地址。他的功能主要是:
分布式調(diào)度協(xié)調(diào)
在分布式環(huán)境中,任務(wù)能夠按照指定的調(diào)度策略執(zhí)行,并且能夠避免同一任務(wù)多實(shí)例重復(fù)執(zhí)行。
豐富的調(diào)度策略:
基于成熟的定時(shí)任務(wù)作業(yè)框架Quartz cron表達(dá)式執(zhí)行定時(shí)任務(wù)。
彈性拓容縮容
當(dāng)集群中增加一個(gè)實(shí)例,它應(yīng)當(dāng)能夠被選舉被執(zhí)行任務(wù);當(dāng)集群減少一個(gè)實(shí)例時(shí),他所執(zhí)行的任務(wù)能被轉(zhuǎn)移到別的示例中執(zhí)行。
失效轉(zhuǎn)移
某示例在任務(wù)執(zhí)行失敗后,會(huì)被轉(zhuǎn)移到其他實(shí)例執(zhí)行。
錯(cuò)過執(zhí)行任務(wù)重觸發(fā)
若因某種原因?qū)е伦鳂I(yè)錯(cuò)過執(zhí)行,自動(dòng)記錄錯(cuò)誤執(zhí)行的作業(yè),并在下次次作業(yè)完成后自動(dòng)觸發(fā)。
支持并行調(diào)度
支持任務(wù)分片,任務(wù)分片是指將一個(gè)任務(wù)分成多個(gè)小任務(wù)在多個(gè)實(shí)例同時(shí)執(zhí)行。
作業(yè)分片一致性
當(dāng)任務(wù)被分片后,保證同一分片在分布式環(huán)境中僅一個(gè)執(zhí)行實(shí)例。
支持作業(yè)生命周期操作
可以動(dòng)態(tài)對任務(wù)進(jìn)行開啟及停止操作。
豐富的作業(yè)類型
支持Simple、DataFlow、Script三種作業(yè)類型

1.4、啟動(dòng)zookeeper
解壓包
到conf目錄中把oo_sample.cfg 拷貝一份 , 修改名字為zoo.cfg。
到bin目錄中啟動(dòng)startup.cmd文件(用1命令行啟動(dòng))。
1.4、啟動(dòng)zookeeper圖形化界面
解壓。
到1build目錄中,找到j(luò)ar包。
使用命令:java -jar jar包的名字
二、Elastic-Job快速入門
Elastic-Job的1環(huán)境要求:
JDK 要求1.7以上保本
Maven 要求3.0.4及以上版本
Zookeeper 要求采取3.4.6以上版本
2.1、環(huán)境搭建
安裝運(yùn)行zookeeper
創(chuàng)建一個(gè)maven項(xiàng)目并導(dǎo)入依賴
<dependency>
<groupId>com.dangdang</groupId>
<artifactId>elastic-job-lite-core</artifactId>
<version>2.1.5</version>
</dependency>
復(fù)制代碼寫任務(wù)類
public class XiaoLinJob implements SimpleJob {
// 寫任務(wù)類
@Override
public void execute(ShardingContext shardingContext) {
System.out.println("定時(shí)任務(wù)開始");
}
}
復(fù)制代碼編寫配置類
public class JobDemo {
public static void main(String[] args) {
new JobScheduler(createRegistryCenter(), createJobConfiguration()).init();
}
private static CoordinatorRegistryCenter createRegistryCenter() {
//配置zk地址,調(diào)度任務(wù)的組名
ZookeeperConfiguration zookeeperConfiguration = new ZookeeperConfiguration("127.0.0.1:2181", "elastic-job-demo");
zookeeperConfiguration.setSessionTimeoutMilliseconds(1000);
CoordinatorRegistryCenter regCenter = new ZookeeperRegistryCenter(zookeeperConfiguration);
regCenter.init();
return regCenter;
}
private static LiteJobConfiguration createJobConfiguration() {
// 定義作業(yè)核心配置
JobCoreConfiguration simpleCoreConfig = JobCoreConfiguration.newBuilder("demoSimpleJob","0/1 * * * * ?",1).build();
// 定義SIMPLE類型配置
SimpleJobConfiguration simpleJobConfig = new SimpleJobConfiguration(simpleCoreConfig, XiaoLinJob.class.getCanonicalName());
// 定義Lite作業(yè)根配置
LiteJobConfiguration simpleJobRootConfig = LiteJobConfiguration.newBuilder(simpleJobConfig).build();
return simpleJobRootConfig;
}
}
復(fù)制代碼2.2、測試
當(dāng)只有一臺(tái)啟動(dòng)的時(shí)候,按照corn表達(dá)式進(jìn)行任務(wù)調(diào)度。
開啟兩臺(tái)機(jī)器的1時(shí)候,新開的一臺(tái)會(huì)繼續(xù)執(zhí)行定時(shí)任務(wù),舊的1那一臺(tái)會(huì)停止。
三、SpringBoot集成Elastic-Job
3.1、引入依賴
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.dangdang</groupId>
<artifactId>elastic-job-lite-spring</artifactId>
<version>2.1.5</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
復(fù)制代碼3.2、編寫配置文件
application.yaml
因?yàn)榕渲弥行牡牡刂凡皇枪潭ǖ?,所以我們不能寫死在代碼中,需要把他寫在配置文件中。新建一個(gè)配置文件:
elasticjob:
zookeeper-url: localhost:2181
group-name: elastic-job-group
復(fù)制代碼zookeeper注冊中心配置類
// 注冊中心的配置類
@Configuration
public class RegistryCenterConfig {
@Bean(initMethod = "init")
// 從配置文件中獲取注冊中心的的url和命名空間
public CoordinatorRegistryCenter coordinatorRegistryCenter(
@Value("${elasticjob.zookeeper-url}") String zookeeperUrl,
@Value("${elasticjob.group-name}") String namespace){
// zk的配置
ZookeeperConfiguration zookeeperConfiguration =
new ZookeeperConfiguration(zookeeperUrl,namespace);
// 設(shè)置超時(shí)時(shí)間
zookeeperConfiguration.setMaxSleepTimeMilliseconds(10000000);
// 創(chuàng)建注冊中心
ZookeeperRegistryCenter zookeeperRegistryCenter = new ZookeeperRegistryCenter(zookeeperConfiguration);
return zookeeperRegistryCenter;
}
}
復(fù)制代碼任務(wù)調(diào)度的配置類
@Configuration
public class JobConfig {
@Autowired
XiaoLinJob xiaoLinJob;
@Autowired
private CoordinatorRegistryCenter registryCenter;
private static LiteJobConfiguration createJobConfiguration(
final Class<? extends SimpleJob> jobClass, // 任務(wù)的名字
final String cron, // cron表達(dá)式
final int shardingTotalCount, // 分片的數(shù)量
final String shardingItemParameters // 分片類信奉的參數(shù)
){
JobCoreConfiguration.Builder jobCoreConfigurationBuilder = JobCoreConfiguration.newBuilder(jobClass.getSimpleName(),cron,shardingTotalCount);
if(!StringUtils.isEmpty(shardingItemParameters)){
jobCoreConfigurationBuilder.shardingItemParameters(shardingItemParameters);
}
SimpleJobConfiguration simpleJobConfig = new SimpleJobConfiguration(jobCoreConfigurationBuilder.build(), XiaoLinJob.class.getCanonicalName());
LiteJobConfiguration simpleJobRootConfig = LiteJobConfiguration.newBuilder(simpleJobConfig).overwrite(true).build();
return simpleJobRootConfig;
}
@Bean(initMethod = "init")
public SpringJobScheduler initSimpleElasticJob(){
SpringJobScheduler springJobScheduler = new SpringJobScheduler(xiaoLinJob,registryCenter,createJobConfiguration(XiaoLinJob.class,"0/3 * * * * ?",1,null));
return springJobScheduler;
}
}
復(fù)制代碼自定義任務(wù)類
@Component
public class XiaoLinJob implements SimpleJob {
@Override
public void execute(ShardingContext shardingContext) {
System.out.println("============");
}
}
復(fù)制代碼3.3、測試
四、小案例
4.1、單機(jī)版本
4.1.1、需求描述
數(shù)據(jù)庫中有一些列的數(shù)據(jù),需要對這些數(shù)據(jù)進(jìn)行備份操作,備份完之后,修改數(shù)據(jù)的狀態(tài),標(biāo)記已經(jīng)備份了。
4.1.2、創(chuàng)建數(shù)據(jù)庫
SET FOREIGN_KEY_CHECKS=0;
-- ----------------------------
-- Table structure for t_file_custom
-- ----------------------------
DROP TABLE IF EXISTS `t_file_custom`;
CREATE TABLE `t_file_custom` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`content` varchar(255) DEFAULT NULL,
`type` varchar(255) DEFAULT NULL,
`backedUp` tinyint(4) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8;
-- ----------------------------
-- Records of t_file_custom
-- ----------------------------
INSERT INTO `t_file_custom` VALUES ('1', '文件1', '內(nèi)容1', 'text', '1');
INSERT INTO `t_file_custom` VALUES ('2', '文件2', '內(nèi)容2', 'text', '1');
INSERT INTO `t_file_custom` VALUES ('3', '文件3', '內(nèi)容3', 'text', '1');
INSERT INTO `t_file_custom` VALUES ('4', '文件4', '內(nèi)容4', 'image', '1');
INSERT INTO `t_file_custom` VALUES ('5', '文件5', '內(nèi)容5', 'image', '1');
INSERT INTO `t_file_custom` VALUES ('6', '文件6', '內(nèi)容6', 'text', '1');
INSERT INTO `t_file_custom` VALUES ('7', '文件6', '內(nèi)容7', 'radio', '1');
INSERT INTO `t_file_custom` VALUES ('8', '文件8', '內(nèi)容8', 'radio', '1');
INSERT INTO `t_file_custom` VALUES ('9', '文件9', '內(nèi)容9', 'vedio', '1');
INSERT INTO `t_file_custom` VALUES ('10', '文件10', '內(nèi)容10', 'vedio', '1');
INSERT INTO `t_file_custom` VALUES ('11', '文件11', '內(nèi)容11', 'vedio', '1');
INSERT INTO `t_file_custom` VALUES ('12', '文件12', '內(nèi)容12', 'vedio', '1');
INSERT INTO `t_file_custom` VALUES ('13', '文件13', '內(nèi)容13', 'image', '1');
INSERT INTO `t_file_custom` VALUES ('14', '文件14', '內(nèi)容14', 'text', '1');
INSERT INTO `t_file_custom` VALUES ('15', '文件15', '內(nèi)容15', 'image', '1');
INSERT INTO `t_file_custom` VALUES ('16', '文件16', '內(nèi)容16', 'text', '1');
INSERT INTO `t_file_custom` VALUES ('17', '文件17', '內(nèi)容17', 'radio', '1');
INSERT INTO `t_file_custom` VALUES ('18', '文件18', '內(nèi)容18', 'image', '1');
INSERT INTO `t_file_custom` VALUES ('19', '文件19', '內(nèi)容19', 'radio', '1');
INSERT INTO `t_file_custom` VALUES ('20', '文件20', '內(nèi)容20', 'vedio', '1');
復(fù)制代碼4.1.3、Druid&MyBatis
4.1.3.1、添加依賴
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.10</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.2.0</version>
</dependency>
<!--mysql驅(qū)動(dòng)-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
復(fù)制代碼4.1.3.2、集成數(shù)據(jù)庫
spring:
datasource:
url: jdbc:mysql://localhost:3306/elastic-job-demo?serverTimezone=GMT%2B8
driverClassName: com.mysql.jdbc.Driver
type: com.alibaba.druid.pool.DruidDataSource
username: root
password: admin
復(fù)制代碼4.1.4、添加實(shí)體類
@Data
public class FileCustom {
//唯一標(biāo)識
private Long id;
//文件名
private String name;
//文件類型
private String type;
//文件內(nèi)容
private String content;
//是否已備份
private Boolean backedUp = false;
public FileCustom(){}
public FileCustom(Long id, String name, String type, String content){
this.id = id;
this.name = name;
this.type = type;
this.content = content;
}
}
復(fù)制代碼4.1.5、添加任務(wù)類
@Slf4j
@Component
public class FileCustomElasticJob implements SimpleJob {
@Autowired
FileCopyMapper fileCopyMapper;
@Override
public void execute(ShardingContext shardingContext) {
doWork();
}
private void doWork() {
List<FileCustom> fileCustoms = fileCopyMapper.selectAll();
if (fileCustoms.size() == 0){
log.info("備份完成");
return;
}
log.info("需要備份的文件個(gè)數(shù)為:"+fileCustoms.size());
for (FileCustom fileCustom : fileCustoms) {
backUpFile(fileCustom);
}
}
private void backUpFile(FileCustom fileCustom) {
try {
Thread.sleep(1000);
log.info("執(zhí)行備份文件:"+fileCustom);
fileCopyMapper.backUpFile(fileCustom.getId());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
復(fù)制代碼4.1.6、添加Mapper
@Mapper
public interface FileCopyMapper {
@Select("select * from t_file_custom where backedUp = 0")
List<FileCustom> selectAll();
@Update("update t_file_custom set backedUp = 1 where id = #{id}")
void backUpFile(Long id);
}
復(fù)制代碼4.1.7、添加任務(wù)調(diào)度配置
@Bean(initMethod = "init")
public SpringJobScheduler initFileCustomElasticJob(FileCustomElasticJob fileCustomElasticJob){
SpringJobScheduler springJobScheduler = new SpringJobScheduler(fileCustomElasticJob,registryCenter,createJobConfiguration(XiaoLinJob.class,"0/3 * * * * ?",1,null));
return springJobScheduler;
}
復(fù)制代碼4.1.8、存在的問題
為了高可用,我們會(huì)對這個(gè)項(xiàng)目做集群的操作,可以保證其中一臺(tái)掛了,另外一臺(tái)可以繼續(xù)工作.但是在集群的情況下,調(diào)度任務(wù)只在一臺(tái)機(jī)器上運(yùn)行,如果單個(gè)任務(wù)調(diào)度比較耗時(shí),耗資源的情況下,對這臺(tái)機(jī)器的消耗還是比較大的。
但是這個(gè)時(shí)候,其他機(jī)器卻是空閑著的,如何合理的利用集群的其他機(jī)器且如何讓任務(wù)執(zhí)行得更快些呢?這時(shí)候Elastic-Job提供了任務(wù)調(diào)度分片的功能。
4.2、集群版本
4.2.1、分片概念
作業(yè)分片是指任務(wù)的分布式執(zhí)行,需要將一個(gè)任務(wù)拆分為多個(gè)獨(dú)立的任務(wù)項(xiàng),然后由分布式的應(yīng)用實(shí)例分別執(zhí)行某一個(gè)或者幾個(gè)分布項(xiàng)。
例如在單機(jī)版本的備份數(shù)據(jù)的案例,如果有兩臺(tái)服務(wù)器,每臺(tái)服務(wù)器分別跑一個(gè)應(yīng)用實(shí)例。為了快速執(zhí)行作業(yè),那么可以講任務(wù)分成4片,每個(gè)應(yīng)用實(shí)例都執(zhí)行兩片。作業(yè)遍歷數(shù)據(jù)邏輯應(yīng)為:實(shí)例1查找text和image類型文件執(zhí)行備份,實(shí)例2查找radio和vedio類型文件執(zhí)行備份。
如果由于服務(wù)器拓容應(yīng)用實(shí)例數(shù)量增加為4,則作業(yè)遍歷數(shù)據(jù)的邏輯應(yīng)為: 4個(gè)實(shí)例分別處理text、image、radio、video類型的文件。
通過對任務(wù)的合理分片化,從而達(dá)到任務(wù)并行處理的效果,他的好處是:
分片項(xiàng)與業(yè)務(wù)處理解耦:Elastic-Job并不直接提供數(shù)據(jù)處理的功能,框架只會(huì)將分片項(xiàng)分配至各個(gè)運(yùn)行中的作業(yè)服務(wù)器,開發(fā)者需要自行處理分片項(xiàng)與真實(shí)數(shù)據(jù)的對應(yīng)關(guān)系。
最大限度利用資源:將分片項(xiàng)設(shè)置大于服務(wù)器的數(shù)據(jù),最好是大于服務(wù)器倍數(shù)的數(shù)量,作業(yè)將會(huì)合理利用分布式資源,動(dòng)態(tài)的分配分片項(xiàng)。例如:3臺(tái)服務(wù)器,分成10片,則分片項(xiàng)結(jié)果為服務(wù)器A=0、1、2。服務(wù)器B=3、4、5。服務(wù)器C=6、7、8、9。如果 服務(wù)器C奔潰,則分片項(xiàng)分配結(jié)果為服務(wù)器A=0、1、2、3、4。服務(wù)器B=5、6、7、8、9。在不丟失分片項(xiàng)的情況下,最大限度利用現(xiàn)有的資源提高吞吐量。
4.2.2、配置類修改
如果想將單機(jī)版本改為集群版本,我們首先需要在任務(wù)配置類中增加分片個(gè)數(shù)以及分片參數(shù)。
@Bean(initMethod = "init")
public SpringJobScheduler initFileCustomElasticJob(FileCustomElasticJob fileCustomElasticJob){
SpringJobScheduler springJobScheduler = new
//第一個(gè)參數(shù)表示自定義任務(wù)類,第二個(gè)參數(shù)是corn表達(dá)式,第三個(gè)參數(shù)是分片個(gè)數(shù),第四個(gè)參數(shù)是分片的名稱,第一個(gè)分片作用是查詢類型為test的,以此類推
SpringJobScheduler(fileCustomElasticJob,registryCenter,createJobConfiguration(XiaoLinJob.class,"0/3 * * * * ?",4,"0=text,1=image,2=radio,3=vedio"));
return springJobScheduler;
}
復(fù)制代碼4.2.3、 新增作業(yè)分片邏輯
@Slf4j
@Component
public class FileCustomElasticJob implements SimpleJob {
@Autowired
FileCopyMapper fileCopyMapper;
@Override
public void execute(ShardingContext shardingContext) {
// 獲取到指定分片的類型
doWork(shardingContext.getShardingParameter());
}
private void doWork(String fileType) {
List<FileCustom> fileCustoms = fileCopyMapper.selectByType(fileType);
if (fileCustoms.size() == 0){
log.info("備份完成");
return;
}
log.info("需要備份的文件類型是:"+fileType+"文件個(gè)數(shù)為:"+fileCustoms.size());
for (FileCustom fileCustom : fileCustoms) {
backUpFile(fileCustom);
}
}
private void backUpFile(FileCustom fileCustom) {
try {
Thread.sleep(2000);
log.info("執(zhí)行備份文件:"+fileCustom);
fileCopyMapper.backUpFile(fileCustom.getId());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
復(fù)制代碼4.2.4、Mapper類修改
@Mapper
public interface FileCopyMapper {
@Select("select * from t_file_custom where backedUp = 0")
List<FileCustom> selectAll();
@Update("update t_file_custom set backedUp = 1 where id = #{id}")
void backUpFile(Long id);
@Select("select * from t_file_custom where backedUp = 0 and type = #{fileType}")
List<FileCustom> selectByType(String fileType);
}
復(fù)制代碼4.2.5、測試
4.2.5.1、一臺(tái)機(jī)器
一臺(tái)機(jī)器啟動(dòng)四個(gè)線程直接跑完。
4.2.5.2、四臺(tái)機(jī)器
當(dāng)四臺(tái)機(jī)器啟動(dòng)的時(shí)候,每臺(tái)機(jī)器分得一個(gè)線程,查詢并備份一種類型的數(shù)據(jù)。

----------------------------------------------------------我是分割線----------------------------------------------------------

----------------------------------------------------------我是分割線----------------------------------------------------------

----------------------------------------------------------我是分割線----------------------------------------------------------

作者:XiaoLin_Java
鏈接:https://juejin.cn/post/7007058861379190821
來源:掘金
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請注明出處。
