前史数据搬迁
- 项目地址:
https://gitee.com/xl-echo/dataMigration
前史搬迁处理方案。微服务的架构为基础,运用多种规划形式,如:单利、桥接、工厂、模板、战略等。其间触及的中心技能有,多线程、过滤器等。致力于处理mysql大表搬迁的问题。供给多种搬迁形式,如:库到库、库到文件再到库等! Historical migration solution. Based on the architecture of microservices, multiple design patterns are used, such as simple interest, bridge, factory, template, strategy, etc. The core technologies involved include multithreading, filters, etc. It is committed to solving the problem of MySQL large table migration. Provide multiple migration modes, such as library to library, library to file, and then to library
开发环境
工具 | 版别 | 描绘 |
---|---|---|
IDEA | 2019.2 | |
JDK | 1.8 | |
MySQL | 5.7 | 5.7 + |
技能结构
技能 | 描绘 | 用处 | 补白 |
---|---|---|---|
SpringBoot | 基础结构 | 构建体系架构 | |
MyBatis | ORM结构 | 构建数据层 | |
JDBC | 构建外部数据源 | 用于装备搬迁 | |
Maven | 项目办理 | 项目办理 | |
ExecutorService | 线程池 | 分批履行使命 |
需求分析和结构整理
前史数据搬迁
,望文生义便是将某个当地的前史数据转移到别的一个当地。需求明了,就一句话能够概括完整。其间要触及的技能,其实也不是很难。无非是规划一个仿制数据的流程,规划一个刺进数据的流程和一个删去数据的流程。有三个流程去完结这件工作,节本就能搞定。当然,咱们不能只是考虑把流程规划的能履行就好,还需求保证流程的健全性。虽然三个流程,就有了可行性,并且在实际运用中根本这三个流程是不行缺少的。可是咱们有必要确保数据安全,流程安全,并且能够直接,拓宽,这样更有利于咱们的项意图接入,运用。要害是处理了数据丢掉,数据仿制不完整的问题。
付出职业
,付出职业中对搬迁的运用,都是有必要保证数据安全的,宁可数据搬迁不成功,也有必要保证数据不由于搬迁二缺少,所以以上这三个流程并不完善。
查看流程的加入
,处理以上说的三个流程,本次需求规划了别的两个查看流程,用来查看查询出来的数据和刺进的数据是否完整,这样保证了源库数据和搬迁到方针库的数据共同
业务规划
,搬迁过程中或许呈现的各种体系反常会导致数据仿制问题,假如不加入业务的考虑,会导致数据丢掉,这也是需求处理的问题。
内存办理
,在搬迁过成功,假如一次性加载所以需求搬迁的数据到项目或许内存中,根本都会导致OOM之类的问题,这也是咱们要仔细考虑到的问题。
代码流程规划如下
数据搬迁的办法有许多种,可是其意图都是共同的,都是将数据从源方位,转移到方针方位。项目中规划了一个通用流程来专门办理多种搬迁办法,并且用到了多种规划形式来让代码愈加简练和更便于办理搬迁。让代码灵敏,并且方便二开或许多开。通用流程履行示意图如下,也是该项目首要流程
简练的代码和流程,其实得益于本来的流程规划和规划形式的运用。
搬迁物理架构图解
数据库模型规划
数据库模型的规划,是咱们整个流程的中心,假如没有这个数据库的模型,咱们的代码将会乱成一团。也正是数据库先一步做出了规定,让咱们的代码能够愈加的灵敏。
表名详解
- transfer_data_task: 搬迁使命。在该表中装备对应的搬迁使命。该表仅做使命办理,并且指定搬迁的履行形式
- transfer_database_config: 数据源装备。一个搬迁使命对应两个数据源装备,一个源数据库装备,一个方针数据库装备,由
database_direction
字段控制是需求源库仍是方针,字段有两个值source,target
- transfer_select_config: 源库表装备。搬迁使命经过task_id相关到源库查询装备,其间首要装备信息便是咱们需求查询的表,表字段有哪些,查询条件是什么,一次性查询多少条。终究会在代码中拼接成为查询源库的SQL语句。 拼接规矩:“`select 装备的字段 from 装备的表名 where 装备的条件 limit 装备的约束条数““,现在一个定时使命仅支撑装备一个表的搬迁。
- transfer_insert_config: 方针库表装备。搬迁使命经过task_id相关到方针库装备,其间首要装备信息便是咱们需求刺进的表,需求刺进的字段有哪些,查询的条件是什么,一次性刺进多少条。终究会在代码中拼接成为刺进方针库的SQL语句。
拼接规矩:
insert into 装备的表名 (装备的字段) values (...),(),()...装备的刺进条数据控制value后边有多少个值
- transfer_log: 日志记录表。在整个搬迁过程中,log是不行或缺的,这儿规划了使命搬迁的日志盯梢。
日志的选型,日志规划(链路追寻)
- 日志结构用的是:Log4j
这是一个由Java编写可靠、灵敏的日志结构,是Apache旗下的一个开源项目。最开端没有做过多的考虑,选用的准则就一个,市面上实际运用多的,并且开源的结构就行。不过日志的输出的确精心规划的。
在咱们许多的项目傍边,一个接口的日志,早年到后或许会有许多。在排查的过程中,咱们也根本是跟着日志从代码最前面往代码最后边推。这个流程在实际运用傍边会略微有一些问题,特别是有新线程,或许并发、大流量的状况根本就很难能一句一句排查了。由于你看到的上一句和下一句,并不一定是一个线程写出来的。所以这个时分咱们需求去精心规划整个日志输出,让他能够在各种环境中一目了然。
TLog
为啥要去自己规划,不直接运用TLog这样的链路终究日志结构呢?这儿有一个问题,TLog链路追寻会在咱们的项目中为每一次履行创立一个仅有键,不论传递多少个服务,只需都整合了TLog就能按照仅有键一路追寻下去。这儿没有运用的原因只要一个,那便是为了方便办理,并且能够愈加快捷的拜访到日志。咱们对日志的输出规划不只是做了链路追寻的流程,还讲仅有键直接存入了库里边,这样咱们愈加的快捷去追寻咱们的搬迁使命。 自己规划的日志追寻流程,的确会愈加的灵活,并且由于放入了库中与log表相关起来,更便于咱们查询。当然他也会有缺陷,关于日志这一块咱们假如忘记这条规矩,会导致咱们链路追寻在链路中开裂 按照链路追寻的形式咱们规划了日志,其间最要害的环节就两个 - 仅有键的规划(雪花算法、UUID) 雪花算法和UUID的选型,刚开端自己完成了一个雪花算法
package com.echo.one.utils.uuid;
/**
* 雪花算法
*
* @author echo
* @date 2022/11/16 11:09
*/
public class SnowflakeIdWorker {
/**
* 开端时刻截 (2015-01-01)
*/
private final long twepoch = 1420041600000L;
/**
* 机器id所占的位数
*/
private final long workerIdBits = 5L;
/**
* 数据标识id所占的位数
*/
private final long datacenterIdBits = 5L;
/**
* 支撑的最大机器id,结果是31 (这个移位算法能够很快的计算出几位二进制数所能表明的最大十进制数)
*/
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
/**
* 支撑的最大数据标识id,结果是31
*/
private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
/**
* 序列在id中占的位数
*/
private final long sequenceBits = 12L;
/**
* 机器ID向左移12位
*/
private final long workerIdShift = sequenceBits;
/**
* 数据标识id向左移17位(12+5)
*/
private final long datacenterIdShift = sequenceBits + workerIdBits;
/**
* 时刻截向左移22位(5+5+12)
*/
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
/**
* 生成序列的掩码,这儿为4095 (0b111111111111=0xfff=4095)
*/
private final long sequenceMask = -1L ^ (-1L << sequenceBits);
/**
* 工作机器ID(0~31)
*/
private long workerId;
/**
* 数据中心ID(0~31)
*/
private long datacenterId;
/**
* 毫秒内序列(0~4095)
*/
private long sequence = 0L;
/**
* 前次生成ID的时刻截
*/
private long lastTimestamp = -1L;
/**
* 结构函数
*
* @param workerId 工作ID (0~31)
* @param datacenterId 数据中心ID (0~31)
*/
public SnowflakeIdWorker(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
/**
* 取得下一个ID (该办法是线程安全的)
*
* @return SnowflakeId
*/
public synchronized long nextId() {
long timestamp = timeGen();
// 假如当时时刻小于上一次ID生成的时刻戳,阐明体系时钟回退过这个时分应当抛出反常
if (timestamp < lastTimestamp) {
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
// 假如是同一时刻生成的,则进行毫秒内序列
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
// 毫秒内序列溢出
if (sequence == 0) {
//阻塞到下一个毫秒,取得新的时刻戳
timestamp = tilNextMillis(lastTimestamp);
}
}
// 时刻戳改动,毫秒内序列重置
else {
sequence = 0L;
}
// 前次生成ID的时刻截
lastTimestamp = timestamp;
// 移位并经过或运算拼到一起组成64位的ID
return ((timestamp - twepoch) << timestampLeftShift) | (datacenterId << datacenterIdShift) | (workerId << workerIdShift) | sequence;
}
/**
* 阻塞到下一个毫秒,直到取得新的时刻戳
*
* @param lastTimestamp 前次生成ID的时刻截
* @return 当时时刻戳
*/
protected long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
/**
* 返回以毫秒为单位的当时时刻
*
* @return 当时时刻(毫秒)
*/
protected long timeGen() {
return System.currentTimeMillis();
}
}
这个算法其实仍是很优秀的,其间的分布式id保证仅有规划也的确能够很有用的帮组咱们获取链路追寻的仅有键。不过在项目中也暴露出来了两个问题: 1、ID重复(雪花算法便是获取仅有id的,为啥会呈现重复的呢?后边解说) 2、功用瓶颈
在运用过程中雪花算法呈现重复,导致仅有键报错。 雪花算法呈现重复的或许就几种: 1)、由于雪花算法强依赖机器时钟,所以难以避免遭到时钟回拨的影响,有或许产生ID重复。 2)、同一台机器同一毫秒需求生成多个Id,并且对long workerId, long datacenterId控制不完善导致 第一种状况这根本无法避免,不过时钟回拨好久才发生一次。所以问题仍是呈现在第二种看状况,首要仍是对代码的不完善导致。后边也正是由于代码的不完善直接改用UUID。UUID就一行代码就能够处理的工作,这儿还要不断的去完善规划的代码。
UUID的确简练,选型UUID看中的也是这一点,同时也还有其他因素。在整个项目中由于代码的灵敏性和整个代码的开放性,在履行的过程中或许会呈现多个线程,并行履行,并且为了资源能够更高效的履行,那么对锁的运用是要慎重考虑的。雪花算法中,运用了sync同步锁,当咱们运用多线程去履行代码的时分,同步锁会成为重量级锁,并且成为咱们项目履行的功用瓶颈。这样是终究挑选UUID的重要因素
- 链路的规划
链路的规划现在仍是极为简单的手动将需求加入到整个链路中的日志加上仅有键。 链路的规划比较简单,并且整个流程链路中,只需求在相应流程形式中进行链路的完善即可,比如:在多线程的环境下,怎样去确保当时线程是我本来的前一使命的链道路;履行形式中循环履行回来的时分,怎样确保新的流程能够用本来的仅有件
搬迁形式
搬迁形式一共三种,分别对应transfer_data_task中transfer_mode的0、1、2,引荐运用形式1
- 0、库 -> 文件 -> 库(不引荐运用)
形式流程图
该形式,能够有用的安全保证数据在搬迁过程中,不会呈现数据的错漏,文件相当于备份操作,后期能够直接追溯搬迁过程。仅有的缺陷便是大表搬迁中,假如运用这种形式需求两个东西,一个是具备local\temp
读写权限的账号,一个是足够的磁盘。大表数据写入磁盘很暂用空间,在测试中间,尝试了100w数据,写入文件结束后,发现有10G(大表,150字段左右)
-
1、库 -> 库(引荐运用)
-
2、source.dump copy target.dump(有风险)
模块规划
dataMigration
-----bin
---------startup.sh
-----doc
---------blog.sql
---------blogbak.sql
---------community.sql
-----src
---------main
-------------java
-----------------com.echo.one
---------------------common
-------------------------base
-----------------------------TransferContext.java
-----------------------------TransferDataContext.java
-----------------------------BeanUtilsConfig.java
-------------------------constant
-----------------------------DataMigrationConstant.java
-------------------------enums
-----------------------------DatabaseColumnType.java
-----------------------------DatabaseDirection.java
-----------------------------FormatPattern.java
-----------------------------StatusCode.java
-----------------------------TaskStatus.java
-------------------------exception
-----------------------------DataMigrationException.java
-------------------------factory
-----------------------------DataMigrationBeanFactory.java
-------------------------framework
-----------------------------config
---------------------------------RemoveDruidAdConfig.java
-----------------------------filter
---------------------------------WebMvcConfig.java
-----------------------------handler
---------------------------------GlobalExceptionHandler.java
-----------------------------result
---------------------------------Result.java
---------------------controller
-------------------------TransferDatabaseConfigController.java
-------------------------TransferDataTaskController.java
-------------------------TransferHealthy.java
-------------------------TransferInsertConfigController.java
-------------------------TransferLogController.java
-------------------------TransferSelectConfigController.java
---------------------dao
-------------------------TransferDatabaseConfigMapper.java
-------------------------TransferDataTaskMapper.java
-------------------------TransferInsertConfigMapper.java
-------------------------TransferLogMapper.java
-------------------------TransferSelectConfigMapper.java
---------------------DataMigrationApplication.java
---------------------job
-------------------------DataTaskThread.java
-------------------------TransferDataTaskJob.java
-------------------------TransferLogJob.java
---------------------po
-------------------------TransferDatabaseConfig.java
-------------------------TransferDataTask.java
-------------------------TransferInsertConfig.java
-------------------------TransferLog.java
-------------------------TransferSelectConfig.java
---------------------processer
-------------------------CheckInsertDataProcessModeOne.java
-------------------------CheckInsertDataProcessModeTwo.java
-------------------------CheckInsertDataProcessModeZero.java
-------------------------CheckInsertDataTransactionalProcessModeThree.java
-------------------------CheckSelectDataProcessModeOne.java
-------------------------CheckSelectDataProcessModeTwo.java
-------------------------CheckSelectDataProcessModeZero.java
-------------------------CheckSelectDataTransactionalProcessModeThree.java
-------------------------DeleteDataProcessModeOne.java
-------------------------DeleteDataProcessModeTwo.java
-------------------------DeleteDataProcessModeZero.java
-------------------------DeleteDataTransactionalProcessModeThree.java
-------------------------InsertDataProcessModeOne.java
-------------------------InsertDataProcessModeTwo.java
-------------------------InsertDataProcessModeZero.java
-------------------------InsertDataProcessOneThread.java
-------------------------InsertDataTransactionalProcessModeThree.java
-------------------------ProcessMode.java
-------------------------SelectDataProcessModeOne.java
-------------------------SelectDataProcessModeTwo.java
-------------------------SelectDataProcessModeZero.java
-------------------------SelectDataTransactionalProcessModeThree.java
---------------------service
-------------------------imp
-----------------------------TransferDatabaseConfigServiceImpl.java
-----------------------------TransferDataTaskServiceImpl.java
-----------------------------TransferHealthyServiceImpl.java
-----------------------------TransferInsertConfigServiceImpl.java
-----------------------------TransferLogServiceImpl.java
-----------------------------TransferSelectConfigServiceImpl.java
-------------------------TransferDatabaseConfigService.java
-------------------------TransferDataTaskService.java
-------------------------TransferHealthyService.java
-------------------------TransferInsertConfigService.java
-------------------------TransferLogService.java
-------------------------TransferSelectConfigService.java
---------------------utils
-------------------------DateUtils.java
-------------------------jdbc
-----------------------------JdbcUtils.java
-------------------------SpringContextUtils.java
-------------------------thread
-----------------------------CommunityThreadFactory.java
-------------------------thread
-----------------------------DataMigrationRejectedExecutionHandler.java
-------------------------thread
-----------------------------ExecutorServiceUtil.java
-------------------------uuid
-----------------------------SnowflakeIdWorker.java
---------main
-------------resources
-----------------application.yml
-----------------banner.txt
-----------------logback-spring.xml
-----------------mapper
---------------------TransferDatabaseConfigMapper.xml
---------------------TransferDataTaskMapper.xml
---------------------TransferInsertConfigMapper.xml
---------------------TransferLogMapper.xml
---------------------TransferSelectConfigMapper.xml
-----LICENSE
-----pom.xml
-----README.md
-----dataMigration.iml
规划形式
假如不考虑规划形式,直接硬编码在咱们项目傍边会有许多的藕合,也会导致整个代码的可读性很差。如下面的代码: 首要来看项目类的代码履行的过程: 1、查询源库数据 2、查看查询出的源库数据 3、刺进方针数据库 4、查看刺进 5、删去源库数据
if(mode = 1) ...
selectData();
checkSelectData();
insertData();
checkInsertData();
deleteData();
if(mode = 2) ...
selectData();
checkSelectData();
insertData();
checkInsertData();
deleteData();
if(mode = 3) ...
selectData();
checkSelectData();
insertData();
checkInsertData();
deleteData();
...
每次新增一个mode,咱们就需求手动去修正代码,增加一个if,极度不易于拓宽,并且if的多少影响阅读和美观。当咱们加入规划形式之后,这样的if能够直接被撤销。
- 桥接形式的运用 什么是桥接形式?桥接(Bridge)是用于把笼统化与完成化解耦,使得二者能够独立变化。这种类型的规划形式归于结构型形式,它经过供给笼统化和完成化之间的桥接结构,来完成二者的解耦。这种形式触及到一个作为桥接的接口,使得实体类的功用独立于接口完成类。这两种类型的类可被结构化改动而互不影响。
能够在上面的代码中看到,假若咱们能消除if else
许多的代码就不会重复,比如其间的固定履行流程。然后每一个流程代码,咱们规划成为一个固定的模板,每个模板都有一个固定的办法,这个时分,关于详细的完成独立出来就完美的处理了这样的问题。
首要处理第一个问题:将过程代码笼统出来,不论哪个形式,都要走界说好的过程办法。可是详细完成每个形式互不搅扰
public abstract class ProcessMode {
public final void invoke(TransferContext transferContext) {
try {
handler(transferContext);
} catch (DataMigrationException e) {
throw e;
} catch (Exception e) {
throw new DataMigrationException("system error, msg: " + e.getMessage());
}
}
protected abstract void handler(TransferContext transferContext);
}
这儿直接笼统出来一个类,保证每个流程过程都归于他的完成类,这样处理了流程过程的多完成互不搅扰。
然后处理if else
:有了笼统类了,咱们这儿直接运用mode去定位对应的详细完成。当然,这儿要配合一个细巧的类的注入名的修正。本来靠if else
检索方位,现在直接选用类型来定位,这样就不需求运用判断来做了。类加载到容器中都有自己固定的名字,这儿咱们将每一个mode对应的完成类规划成为带有mode的类,规划规矩:process + 功用 + mode号
, 然后咱们按照类的完成主动加载运用对应的完成类
ProcessMode sourceBean = SpringContextUtils.getBean("process.selectData." + transferContext.getTransferDataTask().getTransferMode(), ProcessMode.class);
在桥接形式中,咱们能够看到需求一个桥(类)来连接对应的完成 在咱们的代码中其实也正是运用了这种思维,来让咱们的代码每个mode方法之间互不搅扰,但又能和代码紧密相连接。
ProcessMode
完美的衔接了咱们代码,充当了咱们的桥
- 单例形式的运用 单利形式很好被理解,单例(Singleton)形式的界说:指一个类只要一个实例,且该类能自行创立这个实例的一种形式。单例形式有 三个特点:1、类只要一个实例目标; 2、该单例目标有必要由单例类自行创立; 3、类对外供给一个拜访该单例的全局拜访点;
为什么用单利? 单利最常见的运用场景有 1、Windows的Task Manager(使命办理器)便是很典型的单例形式 2、项目中,读取装备文件的类,一般也只要一个目标。没有必要每次运用装备文件数据,每次new一个目标去读取。 3、数据库连接池的规划一般也是选用单例形式,由于数据库连接是一种数据库资源。 4、在Spring中,每个Bean默认便是单例的,这样做的长处是Spring容器能够办理 。 5、在servlet编程中/spring MVC结构,每个Servlet也是单例 /控制器目标也是单例. 在咱们项目中,到哪里的场景很显然便是第一个,他便是一个使命办理器。作为一个开源的搬迁数据项目,咱们需求考虑不单单是每次仅履行一个使命,而是每次履行多个,并且每次或许会并发履行许多的使命,当然随之而来的便是数据安全为题,和使命重复的问题。这个咱们后边在做解说
单利形式又分为:懒汉式、饿汉式。为了确保数据的安全,咱们这儿直接选用了饿汉式
详细完成如下:
public class ExecutorServiceUtil {
private static final Logger logger = LoggerFactory.getLogger(ExecutorServiceUtil.class);
private static ThreadPoolExecutor executorService;
private static final int INT_CORE_POOL_SIZE = 50;
private static final int MAXIMUM_POOL_SIZE = 100;
private static final int KEEP_ALIVE_TIME = 60;
private static final int WORK_QUEUE_SIZE = 2000;
static {
executorService = new ThreadPoolExecutor(
INT_CORE_POOL_SIZE,
MAXIMUM_POOL_SIZE,
KEEP_ALIVE_TIME,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(WORK_QUEUE_SIZE),
new CommunityThreadFactory(),
new DataMigrationRejectedExecutionHandler());
}
private ExecutorServiceUtil() {
if(executorService != null){
throw new DataMigrationException("Reflection call constructor terminates execution!!!");
}
}
public static ExecutorService getInstance() {
logger.info("queue size: {}", executorService.getQueue().size());
logger.info("Number of active threads: {}", executorService.getActiveCount());
logger.info("Number of execution completion threads: {}", executorService.getCompletedTaskCount());
return executorService;
}
}
将咱们的线程池进行单利化,为的便是处理使命多发或许高发的问题,有序的控制使命内存和履行。既然提到了多发或许高发,必定需求处理的便是线程安全问题。这儿咱们不仅运用了单例,还对部分数据操作的类做了数据安全处理
类关系图
对要害类都去界说了一个:@Scope(value = "prototype")
,运用原型效果域,每个使命过来都会生成一个独有的类,这样有用的避免数据共享。
业务的办理
在数据搬迁过程中,业务的履行是很重要的,如不能确保数据共同,或许在搬迁过程中直接导致数据丢掉。
项目中也有简单的对业务的运用。由于规划形式的问题,拓宽才能很强,代码灵敏度也很高。不过接踵而来的便是部分安全代码的问题。比如:业务。当咱们独立了刺进和删去的时分,咱们的业务就被拆分了。那咱们需求运用分布式业务,或许业务传播吗?
项目中并没有!
本来规划形式之前,直接包含在一个业务内即可,这儿咱们运用了规划形式之后,行不通了。
现有的业务逻辑
不难发现,假如insertData呈现问题,insertData的业务会回滚对应的刺进数据,当时deleteData持续履行,这个时分必定会来带数据共同性问题。不过这儿选用了流程上的控制来处理这个问题。
假若:一个搬迁使命只履行一次insertData,deleteData,为了确保数据共同性,咱们将这两个流程规划了固定的履行先后顺序,并且,前面一个报错,后边不在持续履行。
报错假如呈现在删去,那有什么影响,就一个:那便是源库的数据有一部分并未被删去掉,可是必定是已经搬迁到了方针库,这个时分严厉意义上来讲,数据的搬迁并不影响,只是只是多出来一份数据,没有数据安全问题。
总结
每个规划方案都不是完美的,都是奇妙的去规划,灵敏的去习惯。 假若不断迭代,问题必定会不断呈现,新的方案必定也会不断的替代老方案。
拓宽
当时项目真正运用应该怎样入手?怎样商用?