MaxCompute做为阿里云大数据平台的核心计算组件,拥有强大的计算能力,可以调度大量的节点作并行计算,同时对分布式计算中的failover,重试等均有一套行之有效的处理管理机制。 而MaxCompute SQL能在简明的语义上实现各类数据处理逻辑,在集团内外更是广为应用,在其上实现与各类数据源的互通,对于打通整个阿里云的数据生态具备重要意义。基于这一点,最近MaxCompute团队依托MaxCompute2.0系统架构,引入了非结构化数据处理框架:经过外部表,为各类数据在MaxCompute上的计算处理提供了入口。这里以MaxCompute处理存储在OSS上的数据为例,介绍这些新功能。html
现阶段MaxCompute SQL面对的主要是以cfile列格式,存储在内部MaxCompute表格中的结构化数据。而对于MaxCompute表外的各类用户数据(包括文本以及各类非结构化的数据),须要首先经过各类工具导入MaxCompute表,才能在其上面进行计算。这个数据导入的过程,具备较大的局限性。以OSS为例子,想要在MaxCompute里处理OSS上的数据,一般有两种作法:java
经过OSS SDK或者其余工具从OSS下载数据,而后再经过MaxCompute Tunnel将数据导入表里。git
写UDF,在UDF里直接调用OSS SDK访问OSS数据。github
但这两种作法都不够好。#1须要在MaxCompute系统外部作一次中转,若是OSS数据量太大,还须要考虑如何并发来加速,没法充分利用MaxCompute大规模计算的能力;#2一般须要申请UDF网络访问权限,还要开发者本身控制做业并发数和数据如何分片的问题。算法
本文介绍了一种外部表的功能 ,支持旨在提供处理除了MaxCompute现有表格之外的其余数据的能力。在这个框架中,经过一条简单的DDL语句,便可在MaxCompute上建立一张外部表,创建MaxCompute表与外部数据源的关联,提供各类数据的接入和输出能力。建立好的外部表能够像普通的MaxCompute表同样使用(大部分场景),充分利用MaxCompute SQL的强大计算功能。sql
这里的“各类数据”涵盖两个维度:网络
多样的数据存储介质架构
插件式的框架能够对接多种数据存储介质,好比OSS、OTS并发
多样的数据格式:MaxCompute表是结构化的数据,而外部表能够不限于结构化数据框架
a. 彻底无结构数据;好比图像,音频,视频文件,raw binaries等
b. 半结构化数据;好比csv,tsv等隐含必定schema的文本文件
c. 非cfile的结构化数据; 好比orc/parquet文件,甚至hbase/OTS数据
下面经过一个简单例子,来演示如何在MaxCompute上轻松访问OSS上的数据。
使用MaxCompute内置的extractor,能够很是方便的读取按照约定格式存储的OSS数据。咱们只须要建立一个外部表,就能以这张表为源表作查询。假设有一份csv数据存在OSS上,endpoint为oss-cn-hangzhou-zmf.aliyuncs.com
,bucket为oss-odps-test
,数据文件放在/demo/SampleData/CSV/AmbulanceData/vehicle.csv
。
首先须要在RAM中受权MaxCompute访问OSS的权限。登陆RAM控制台,建立角色AliyunODPSDefaultRole
,并将策略内容设置为:
{ "Statement": [ { "Action": "sts:AssumeRole", "Effect": "Allow", "Principal": { "Service": [ "odps.aliyuncs.com" ] } } ], "Version": "1"}
而后编辑该角色的受权策略,将权限AliyunODPSRolePolicy
受权给该角色。
若是以为这些步骤太麻烦,能够点击此处完成一键受权。
执行一条DDL语句,建立外部表:
-- (1)set odps.sql.planner.mode=lot;set odps.sql.ddl.odps2=true;set odps.sql.preparse.odps2=lot;set odps.task.major.version=2dot0_demo_flighting;CREATE EXTERNAL TABLE IF NOT EXISTS ambulance_data_csv_external ( vehicleId int, recordId int, patientId int, calls int, locationLatitute double, locationLongtitue double, recordTime string, direction string)STORED BY 'com.aliyun.odps.CsvStorageHandler' -- (2)LOCATION 'oss://oss-cn-hangzhou-zmf.aliyuncs.com/oss-odps-test/Demo/SampleData/CSV/AmbulanceData/'; -- (3)(4)(5)
注释:
这个功能是MaxCompute2.0的一部分,目前处于试用状态。这里须要提早设置一些开关来临时打开这个功能(这个开关设置前须要申请试用MaxCompute2.0,文章末尾有介绍),本文后面全部的SQL例子语句运行前都须要设置这些开关,为了便于阅读,我只在这里明确写出。
com.aliyun.odps.CsvStorageHandler
是内置的处理csv格式文件的StorageHandler,它定义了如何读写csv文件。咱们只须要指明这个名字,相关逻辑已经由系统实现。
LOCATION必须指定一个OSS目录,默认系统会读取这个目录下全部的文件。
外部表只是在系统中记录了与OSS目录的关联,当DROP这张表时,对应的LOCATION数据不会被删除。
前面咱们已经经过RAM将帐号中的OSS资源访问权限受权给了MaxCompute。在后续的访问OSS过程当中,MaxCompute将经过STS拿到对OSS资源的临时权限。须要注意的是,MaxCompute在获取权限时,是以表的建立者的身份去STS申请的,所以,这里建立表的帐号和OSS必须是同一个云帐号,并且必须是主帐号,不能是子帐号。
外部表建立成功后,咱们能够像对普通表同样使用这个外部表。
假设/demo/SampleData/CSV/AmbulanceData/vehicle.csv
数据为:
1,1,51,1,46.81006,-92.08174,9/14/2014 0:00,S1,2,13,1,46.81006,-92.08174,9/14/2014 0:00,NE1,3,48,1,46.81006,-92.08174,9/14/2014 0:00,NE1,4,30,1,46.81006,-92.08174,9/14/2014 0:00,W1,5,47,1,46.81006,-92.08174,9/14/2014 0:00,S1,6,9,1,46.81006,-92.08174,9/14/2014 0:00,S1,7,53,1,46.81006,-92.08174,9/14/2014 0:00,N1,8,63,1,46.81006,-92.08174,9/14/2014 0:00,SW1,9,4,1,46.81006,-92.08174,9/14/2014 0:00,NE1,10,31,1,46.81006,-92.08174,9/14/2014 0:00,N
执行:
SELECT recordId, patientId, directionFROM ambulance_data_csv_externalWHERE patientId > 25;
这条语句会提交一个做业,调用内置csv extractor,从OSS读取数据进行处理。
结果:
+------------+------------+-----------+ | recordId | patientId | direction | +------------+------------+-----------+ | 1 | 51 | S | | 3 | 48 | NE | | 4 | 30 | W | | 5 | 47 | S | | 7 | 53 | N | | 8 | 63 | SW | | 10 | 31 | N | +------------+------------+-----------+
当OSS中数据格式比较复杂,内置的extractor没法知足需求时,须要自定义extractor来读取OSS文件中的数据。
例若有一个text数据文件,并非csv格式,记录之间的列经过|
分割。/demo/SampleData/CustomTxt/AmbulanceData/vehicle.csv
数据为:
1|1|51|1|46.81006|-92.08174|9/14/2014 0:00|S1|2|13|1|46.81006|-92.08174|9/14/2014 0:00|NE1|3|48|1|46.81006|-92.08174|9/14/2014 0:00|NE1|4|30|1|46.81006|-92.08174|9/14/2014 0:00|W1|5|47|1|46.81006|-92.08174|9/14/2014 0:00|S1|6|9|1|46.81006|-92.08174|9/14/2014 0:00|S1|7|53|1|46.81006|-92.08174|9/14/2014 0:00|N1|8|63|1|46.81006|-92.08174|9/14/2014 0:00|SW1|9|4|1|46.81006|-92.08174|9/14/2014 0:00|NE1|10|31|1|46.81006|-92.08174|9/14/2014 0:00|N
这个时候能够写一个通用的extractor,将分隔符做为参数传进来,能够处理全部相似格式的text文件。
/** * Text extractor that extract schematized records from formatted plain-text(csv, tsv etc.) **/public class TextExtractor extends Extractor { private InputStreamSet inputs; private String columnDelimiter; private DataAttributes attributes; private BufferedReader currentReader; private boolean firstRead = true; public TextExtractor() { // default to ",", this can be overwritten if a specific delimiter is provided (via DataAttributes) this.columnDelimiter = ","; } // no particular usage for execution context in this example @Override public void setup(ExecutionContext ctx, InputStreamSet inputs, DataAttributes attributes) { this.inputs = inputs; // (1) this.attributes = attributes; // check if "delimiter" attribute is supplied via SQL query String columnDelimiter = this.attributes.getValueByKey("delimiter"); // (2) if ( columnDelimiter != null) { this.columnDelimiter = columnDelimiter; } // note: more properties can be inited from attributes if needed } @Override public Record extract() throws IOException { String line = readNextLine(); if (line == null) { return null; // (5) } return textLineToRecord(line); // (3)(4) } @Override public void close(){ // no-op } }
inputs
是一个InputStreamSet
,每次调用next()
返回一个InputStream
,这个InputStream
能够读取一个OSS文件的全部内容。
delimiter
经过DDL语句传参
textLineToRecord
将一行数据按照delimiter
分割为多个列,完整实现能够参考: https://github.com/aliyun/aliyun-odps-java-sdk/blob/master/odps-sdk-impl/odps-udf-example/src/main/java/com/aliyun/odps/udf/example/text/TextExtractor.java
extactor()
调用返回一条Record
,表明外部表中的一条记录
返回null
来表示这个表中已经没有记录可读了
StorageHandler做为external table自定义逻辑的统一入口。
package com.aliyun.odps.udf.example.text; public class TextStorageHandler extends OdpsStorageHandler { @Override public Class<? extends Extractor> getExtractorClass() { return TextExtractor.class; } @Override public Class<? extends Outputer> getOutputerClass() { return TextOutputer.class; } }
将自定义代码编译打包,并上传到MaxCompute。
add jar odps-udf-example.jar;
与使用内置extractor相似,咱们一样须要创建一个外部表,不一样的是此次须要指定外部表访问数据的时候,使用自定义的StorageHandler。
CREATE EXTERNAL TABLE IF NOT EXISTS ambulance_data_txt_external ( vehicleId int, recordId int, patientId int, calls int, locationLatitute double, locationLongtitue double, recordTime string, direction string)STORED BY 'com.aliyun.odps.udf.example.text.TextStorageHandler' -- (1)WITH SERDEPROPERTIES( 'delimiter'='|' --(2)LOCATION 'oss://oss-cn-hangzhou-zmf.aliyuncs.com/oss-odps-test/Demo/SampleData/CustomTxt/AmbulanceData/'USING 'odps-udf-example.jar'; --(3)
STORED BY
指定自定义StorageHandler的类名
SERDEPROPERITES
能够指定参数,这些参数会经过DataAttributes
传递到Extractor代码中
同时须要指定类定义所在的jar包
执行:
SELECT recordId, patientId, directionFROM ambulance_data_txt_externalWHERE patientId > 25;
这条语句会提交一个做业,调用自定义的extractor,从OSS读取数据进行处理。
结果:
+------------+------------+-----------+ | recordId | patientId | direction | +------------+------------+-----------+ | 1 | 51 | S | | 3 | 48 | NE | | 4 | 30 | W | | 5 | 47 | S | | 7 | 53 | N | | 8 | 63 | SW | | 10 | 31 | N | +------------+------------+-----------+
在前面咱们看到了经过内置与自定义的extractor能够轻松处理存储在OSS上的csv等文本数据。接下来咱们以语音数据(wav格式文件)为例,来看看怎样经过自定义的extractor来访问处理OSS上的非文本文件。
这里咱们从最终执行的SQL query开始,介绍以MaxCompute SQL为入口,处理存放在OSS上的语音文件的使用方法:
CREATE EXTERNAL TABLE IF NOT EXISTS speech_sentence_snr_external ( sentence_snr double,id string)STORED BY 'com.aliyun.odps.udf.example.speech.SpeechStorageHandler'WITH SERDEPROPERTIES ( 'mlfFileName'='sm_random_5_utterance.text.label' , 'speechSampleRateInKHz' = '16') LOCATION 'oss://oss-cn-hangzhou-zmf.aliyuncs.com/oss-odps-test/dev/SpeechSentenceTest/'USING 'odps-udf-example.jar,sm_random_5_utterance.text.label';
SELECT sentence_snr, id FROM speech_sentence_snr_externalWHERE sentence_snr > 10.0;
这里咱们依然创建的外部表,而且经过外部表的schema定义了咱们但愿经过外部表从语音文件中抽取出来的信息:
一个语音文件中的语句信噪比(SNR):sentence_snr
对应语音文件的名字:id
建立了外部表后,经过标准的select语句进行查询,则会触发extractor运行计算。
从这咱们能够更直接的感觉到,在读取处理OSS数据时,除了以前介绍过的对文本文件作简单的反序列化处理,还能够经过在自定义的extractor中实现更复杂的数据处理抽取逻辑:在这个例子中,咱们经过自定义的com.aliyun.odps.udf.example.speech. SpeechStorageHandler 中封装的extractor, 实现了对语音文件计算平均有效语句信噪比的功能,并将抽取出来的结构化数据直接进行SQL运算(WHERE sentence_snr > 10), 最终返回全部信噪比大于10的语音文件以及对应的信噪比值。
在OSS地址oss://oss-cn-hangzhou-zmf.aliyuncs.com/oss-odps-test/dev/SpeechSentenceTest/
上,存储了原始的多个wav格式的语音文件,MaxCompute 框架将读取该地址上的全部文件,并在必要的时候进行文件级别的分片,自动将文件分配给多个计算节点处理。每一个计算节点上的extractor则负责处理经过InputStreamSet分配给该节点的文件集。具体的处理逻辑则与用户单机程序相仿,用户不用关心分布计算中的种种细节,按照类单机方式实现其用户算法便可。
这里简单介绍一下定制化的SpeechSentenceSnrExtractor主体逻辑。首先咱们在setup接口中读取参数,进行初始化,而且导入语音处理模型(经过resource引入):
public SpeechSentenceSnrExtractor(){ this.utteranceLabels = new HashMap<String, UtteranceLabel>(); } @Override public void setup(ExecutionContext ctx, InputStreamSet inputs, DataAttributes attributes){ this.inputs = inputs; this.attributes = attributes; this.mlfFileName = this.attributes.getValueByKey(MLF_FILE_ATTRIBUTE_KEY); String sampleRateInKHzStr = this.attributes.getValueByKey(SPEECH_SAMPLE_RATE_KEY); this.sampleRateInKHz = Double.parseDouble(sampleRateInKHzStr); try { // read the speech model file from resource and load the model into memory BufferedInputStream inputStream = ctx.readResourceFileAsStream(mlfFileName); loadMlfLabelsFromResource(inputStream); inputStream.close(); } catch (IOException e) { throw new RuntimeException("reading model from mlf failed with exception " + e.getMessage()); } }
extractor()
接口中,实现了对语音文件的具体读取和处理逻辑,对读取的数据根据语音模型进行信噪比的计算,而且将结果填充成[snr, id]格式的Record。
这个例子中对实现进行了简化,同时也没有包括涉及语音处理的算法逻辑,具体实现可参见:
https://github.com/aliyun/aliyun-odps-java-sdk/blob/master/odps-sdk-impl/odps-udf-example/src/main/java/com/aliyun/odps/udf/example/speech/SpeechSentenceSnrExtractor.java
@Override public Record extract() throws IOException { SourceInputStream inputStream = inputs.next(); if (inputStream == null){ return null; } // process one wav file to extract one output record [snr, id] String fileName = inputStream.getFileName(); fileName = fileName.substring(fileName.lastIndexOf('/') + 1); logger.info("Processing wav file " + fileName); String id = fileName.substring(0, fileName.lastIndexOf('.')); // read speech file into memory buffer long fileSize = inputStream.getFileSize(); byte[] buffer = new byte[(int)fileSize]; int readSize = inputStream.readToEnd(buffer); inputStream.close(); // compute the avg sentence snr double snr = computeSnr(id, buffer, readSize); // construct output record [snr, id] Column[] outputColumns = this.attributes.getRecordColumns(); ArrayRecord record = new ArrayRecord(outputColumns); record.setDouble(0, snr); record.setString(1, id); return record; } private void loadMlfLabelsFromResource(BufferedInputStream fileInputStream) throws IOException { // skipped here } // compute the snr of the speech sentence, assuming the input buffer contains the entire content of a wav file private double computeSnr(String id, byte[] buffer, int validBufferLen){ // computing the snr value for the wav file (supplied as byte buffer array), skipped here }
执行一开始的SQL语句,可得到计算结果:
-------------------------------------------------------------- | sentence_snr | id | --------------------------------------------------------------| 34.4703 | J310209090013_H02_K03_042 | --------------------------------------------------------------| 31.3905 | tsh148_seg_2_3013_3_6_48_80bd359827e24dd7_0 | --------------------------------------------------------------| 35.4774 | tsh148_seg_3013_1_31_11_9d7c87aef9f3e559_0 | --------------------------------------------------------------| 16.0462 | tsh148_seg_3013_2_29_49_f4cb0990a6b4060c_0 | --------------------------------------------------------------| 14.5568 | tsh_148_3013_5_13_47_3d5008d792408f81_0 | --------------------------------------------------------------
能够看到,经过自定义extractor,咱们在SQL语句上便可分布式地处理多个OSS上语音数据文件。一样的,用相似的方法,咱们能够方便的利用MaxCompute的大规模计算能力,完成对图像,视频等各类类型非结构化数据的处理。
在前面的例子中,一个外部表关联的数据经过LOCATION上指定的OSS“目录”来实现,而在处理的时候,MaxCompute对读取“目录”下面的全部数据,**包括子目录中的全部文件**。在数据量比较大,尤为是对于随着时间不断积累的数据目录,这样子的全目录扫描,可能带来没必要要的额外IO以及数据处理时间。 解决这个问题一般有两种作法:
比较直接的减小访问数据量的方法是用户本身负责对数据存放地址作好规划,同时考虑使用多个EXTERNAL TABLE来描述不一样部分的数据,让每一个EXTERNALTABLE的LOCATION指向数据的一个子集。
另外一个方面,EXTERNAL TABLE与内部表同样,**支持分区表的功能**,用户能够利用这个功能来对数据作系统化的管理。这个章节主要介绍一下EXTERNAL TABLE的分区功能。
与MaxCompute内部表不一样,对于存放在外部存储上(如OSS)上面的数据,MaxCompute不拥有数据的管理权,因此用户若是但愿系统的使用分区表功能的话,在OSS上的数据文件的存放路径应该符合必定的格式,具体说来就是路径为以下格式:
partitionKey1=value1\partitionKey2=value2\...
举一个例子,假设用户天天的LOG文件存放在OSS上面,而后但愿能在经过MaxCompute处理的时候,可以按照粒度为“天”来访问一部分数据。简单假设这些LOG文件就是CSV的格式(复杂自定义格式用法也相似),那么可使用以下的**分区外部表**来定义数据:
CREATE EXTERNAL TABLE log_table_external ( click STRING, ip STRING, url STRING, ) PARTITIONED BY ( year STRING, month STRING, day STRING ) STORED BY 'com.aliyun.odps.CsvStorageHandler' LOCATION 'oss://<ak_id>:<ak_key>@oss-cn-hangzhou-zmf.aliyuncs.com/oss-odps-test/log_data/';
能够看到,这里和前面的例子不同的主要就是在定义EXTERNAL TABLE的时候,咱们经过PARTITIONED BY
的语法,来指定该外部表为分区表,这里举了一个三层分区的例子,分区的key分别是 year
, month
和 day
。为了让这样的分区能生效,在OSS上面存储数据的时候须要遵循上面提到的路径格式。这里举一个有效的路径存储layout的例子:
osscmd ls oss://oss-odps-test/log_data/2017-01-14 08:03:35 128MB Standard oss://oss-odps-test/log_data/year=2016/month=06/day=01/logfile2017-01-14 08:04:12 127MB Standard oss://oss-odps-test/log_data/year=2016/month=06/day=01/logfile.12017-01-14 08:05:02 118MB Standard oss://oss-odps-test/log_data/year=2016/month=06/day=02/logfile2017-01-14 08:06:45 123MB Standard oss://oss-odps-test/log_data/year=2016/month=07/day=10/logfile2017-01-14 08:07:11 115MB Standard oss://oss-odps-test/log_data/year=2016/month=08/day=08/logfile...
须要再次强调的,由于用户本身离线准备了数据,即经过osscmd或者其余OSS工具自行上载到OSS存储服务上,因此数据路径格式是由用户决定的,若是想要EXTERNAL TABLE的分区表功能正常工做的话,咱们推荐用户上载数据的时候遵循如上路径格式。在这个前提下,经过ALTER TABLE ADD PARTITION
DDL语句,就能够把这些分区信息引入MaxCompute。在这里对应的DDL语句是:
ALTER TABLE log_table_external ADD PARTITION (year = '2016', month = '06', day = '01')ALTER TABLE log_table_external ADD PARTITION (year = '2016', month = '06', day = '02')ALTER TABLE log_table_external ADD PARTITION (year = '2016', month = '07', day = '10')ALTER TABLE log_table_external ADD PARTITION (year = '2016', month = '08', day = '08') ...
事实上这些操做都和标准的MaxCompute内部表操做同样,对分区表概念不熟悉的能够参考文档。在数据准备好而且PARTITION信息引入系统以后,就能够经过SQL语句来对OSS上的外表数据的分区进行操做了。
好比若是只想分析2016年6月1号当天,有多少不一样的IP出如今LOG里面,能够经过以下语句实现:
SELECT count(distinct(ip)) FROM log_table_external WHERE year = '2016' AND month = '06' AND day = '01';
这种状况下, 对log_table_external这个外表对应的目录,将只访问log_data/year=2016/month=06/day=01
子目录下的文件(logfile
和logfile.1
), 而**不会对整个**log_data/
**目录做全量数据扫描,避免大量无用的IO操做。**
一样若是只但愿对2016年下半年的数据作分析, 则能够经过
SELECT count(distinct(ip)) FROM log_table_external WHERE year = '2016' AND month > '06';
来**只访问OSS上面存储的下半年的LOG数据**.
若是用户已经有事先存在OSS上面的历史数据,可是又不是根据partitionKey1=value1\partitionKey2=value2\...
的路径格式来组织存放的,那也是能够经过MaxCompute的分区方式来进行访问计算的。虽然*一般状况下不这样推荐*,可是在非结构化数据处理这方面,MaxCompute一样提供了经过自定义路径来引入partition的方法。
好比假设用户的数据路径上只有简单的分区值(而无分区key信息),也就是数据的layout为:
osscmd ls oss://oss-odps-test/log_data_customized/2017-01-14 08:03:35 128MB Standard oss://oss-odps-test/log_data_customized/2016/06/01/logfile2017-01-14 08:04:12 127MB Standard oss://oss-odps-test/log_data_customized/2016/06/01/logfile.12017-01-14 08:05:02 118MB Standard oss://oss-odps-test/log_data_customized/2016/06/02/logfile2017-01-14 08:06:45 123MB Standard oss://oss-odps-test/log_data_customized/2016/07/10/logfile2017-01-14 08:07:11 115MB Standard oss://oss-odps-test/log_data_customized/2016/08/08/logfile...
那么要绑定不一样的子目录到不一样的分区上,能够经过相似以下自定义分区路径的DDL语句实现:
ALTER TABLE log_table_external ADD PARTITION (year = '2016', month = '06', day = '01') LOCATION 'oss://<ak_id>:<ak_key>@oss-cn-hangzhou-zmf.aliyuncs.com/oss-odps-test/log_data_customized/2016/06/01/';
在ADD PARTITION的时候增长了LOCATION信息,从而实现自定义分区数据路径后,即便数据存放不符合推荐的partitionKey1=value1\partitionKey2=value2\...
格式,也能正确的实现对子目录数据的分区访问了。
最后在一些特殊状况下,用户可能会有访问某个OSS路径上的任意文件子集的需求,而这个文件子集中的文件在路径格式上*没有明显的规律性*。在这方面MaxCompute非结构化数据处理框架也提供了相应的支持,可是在这里不展开作介绍了。若是对于这些高阶的特殊用法,有详细的具体需求和场景描述的话,能够联系MaxCompute技术团队。
MaxCompute上增添处理非结构化数据的能力,可以有效的利用MaxCompute框架上成熟的大规模计算资源,处理来自各类数据源上的数据,从而真正实现数据计算互通。随着MaxCompute 2.0框架的逐步上线,这些新功能将为集团内外用户提供更多的计算价值。目前对于OSS数据的读取计算功能,在集团内一些急需大规模非结构化数据处理能力的团队中已经使用。MaxCompute团队将进一步完善相关功能,而且提供对更多数据源的支持,例如TableStore(OTS)等。后继咱们也会在ATA上作更多的介绍。
原文连接:http://click.aliyun.com/m/14000/