MapReduce框架原理--Shuffle机制
Shuffle机制
Mapreduce确保每个reducer的输入都是按键排序的。系统执行排序的过程(Map方法之后,Reduce方法之前的数据处理过程)称之为Shuffle。

partition分区
Partition分区流程处于Mapper数据属于初到环形缓冲区时进行,此时会将通过Partition分区获取到的每一行key-value对应的分区值计入环形缓冲流的左。
问题引出
要求将统计结果按照条件输出到不同文件中(分区)。比如:将统计结果按照手机归属地不同省份输出到不同文件中(分区)
分区可以实现将Map阶段处理的数据在向环形缓冲区写入的时候是以"分类"的方式写的。"一般情况下",MR程序分区数有多少,ReduceTask的数量就应该有多少,可以实现一个分区的数据一个ReduceTask去处理。ReduceTask处理完成之后都会去生成一个结果文件
以WordCount为例,设置ReduceTask的数量
job.setNumReduceTasks(2);

MapReduce底层默认分区机制
默认使用的是HashPartitioner分区机制
@Public
@Stable
public class HashPartitioner<K, V> extends Partitioner<K, V> {
public HashPartitioner() {
} public int getPartition(K key, V value, int numReduceTasks) {
return (key.hashCode() & 2147483647) % numReduceTasks; // 2147483647是int类型的最大值,numReduceTask即是上面设置的ReduceTask的值
}
}
这种分区机制是不可控的,因为它是根据Map阶段获取的key的hashCode值和numReduceTask取余得来的,但是key的hashCode值不确定,所以把key-value数据分到哪一个区我们是不确定的。比如会出现不同的key的HashCode值一致,导致结果输出的不可控制。因此我们在去定义分区的时候我们最常用的方法就是:自定义分区机制
自定义Partition(案例)
自定义Partition步骤:
- 继承Partitioner类:在继承Partitioner类时,应该传递一个<key, value>的泛型,它代表的是需要进行分区的数据,所以需要传递的是map阶段输出的key-value类型,因为分区是在Map阶段执行结束之后往ReduceTask阶段在输出数据时执行的
- 重写里面的getPartition方法,返回值是一个int类型,返回值就是我们的分区。执行逻辑返回一个从0开始的一个连续型数字。比如getPartition返回的取值有0 1 2 3 4时就代表有5个分区,0是1号分区,对应的文件是part-r-00000文件,以此类推。
要求:
a) 返回的分区数字最好是连续的,比如返回了 0 2 3 4 ,数字不连续,不可行
b) 一般情况下,有几个分区,就在Driver中指定numReduceTasks就有几个,不能多写也不能少写
public int getPartition(Text key, Text value, int numReduceTask)方法有三个参数,返回一个int类型的值,它们代表的含义分别是:
@param key:Map阶段输出的key值
@param value:Map阶段输出的value值
@param numReduceTask:定义的ReduceTask的任务数,默认是1
@return 数字,代表的是我要将这条key-value数据输送到哪个分区
3. 在Driver类中指定分区所在的类与分区数量
// 定义不使用默认的HashPartitioner分区,而是使用自定义的分区
job.setPartitionerClass(PhoneDataPartition.class);
// 指定你的ReduceTask必须是5
job.setNumReduceTasks(5);
这里要求ReduceTask的数量必须和自定义的Partition类中设置的分区数量保持一致,原因就是在MR程序中一般默认情况下是一个分区要有一个ReduceTask专门去处理。但是在有些情况下,ReduceTask可能少写或者多写,这样会出一些奇怪的问题。
- 假设Mapper阶段输出的分区是5个,但是设置了1个ReduceTask任务去执行,程序可以运行成功,此时在HDFS中生成1个结果文件。虽然有多个分区,但是一个ReduceTask可以处理这多个分区。
- 但是假设Mapper阶段输出的分区是5个,ReduceTask的数量少于5个,这时程序不能运行,且程序报IO异常的错误。(不患寡而患不均)
java.lang.Exception: java.io.IOException: Illegal partition for 138 (2)
(这里可以利用打工人来举例,假设有五个人一起去打工,但是此时岗位只有一个,那么这五个人可以一起做着一份工作。但是如果设置了两个岗位,分配时将会出现矛盾状况,比如两个工人都想做一份工作,或者两个工人都不想做这一个工作,就会产生冲突)
3. 假设分区有5个,ReduceTask的数量也有5个,那么百分之百可以正常运行,这也是最佳状态/最理想状态。(因为每个人都分配到了自己想做的工作)
4. 假设分区有5个,ReduceTask的数量多于5个,程序也是百分之百可以正常运行,但是会多出一个空白结果文件
【注意】以后在工作中写的ReduceTask的数量最好和分区的数量保持一致,这样的话才能保证处理出的MR程序处于最佳状态
案例一:电话归属地
需求:将统计结果按照手机归属地不同省份输出到不同文件中(分区)
分区:
136----分区1
137----分区2
138----分区3
139----分区4
其他---分区5
默认分区机制:5个分区,需要设置5个ReduceTask,同时默认分区机制是按照key的HashCode值分配的
- PhoneDataMapper.java
public class PhoneDataMapper extends Mapper<LongWritable, Text, Text, Text> {
@Override
protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
String line = value.toString();
String[] fields = line.split("\t");
String phone = fields[1];
// 拿到手机号的前三位
String phoneThree = phone.substring(0, 3);
context.write(new Text(phoneThree), value);
}
}
- PhoneDatePartition.java
public class PhoneDataPartition extends Partitioner<Text, Text> {
@Override
public int getPartition(Text key, Text value, int numReduceTask) {
String s = key.toString();
switch (s) {
case "136":
return 0;
case "137":
return 1;
case "138":
return 2;
case "139":
return 3;
default:
return 4;
}
}
} - PhoneDataReducer.java
public class PhoneDataReducer extends Reducer<Text, Text, NullWritable, Text> {
@Override
protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException {
for (Text value : values) {
context.write(NullWritable.get(), new Text(value));
}
}
} - PhoneDateDriver.java
public class PhoneDataReducer extends Reducer<Text, Text, NullWritable, Text> {
@Override
protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException {
for (Text value : values) {
context.write(NullWritable.get(), new Text(value));
}
}
}
运行截图

案例二:单词计数
需求:按照单词首字母的ASCII码进行奇偶分区(Partitioner)

- WordCountPartition.java
public class WordCountPartition extends Partitioner<Text, LongWritable> {
@Override
public int getPartition(Text text, LongWritable longWritable, int i) {
String word = text.toString();
char first = word.charAt(0);
if (first % 2 == 0 ) {
return 0;
} else {
return 1;
}
}
}

举一反三
单词计数案例:要求根据单词首字母的大小写分区,如果单词首字母是大写,那么单词输出在一个文件中,如果单词的首字母是小写,那么单词输出在另一个分区中
源代码:
public class OtherPartition extends Partitioner<Text, LongWritable> {
@Override
public int getPartition(Text text, LongWritable longWritable, int i) {
String word = text.toString();
char first = word.charAt(0);
if (Character.isUpperCase(first)) {
return 1;
} else {
return 0;
}
}
}
运行结果:

WritableComparable排序
排序概述
- 排序是MapReduce框架中最重要的操作之一。
- MapTask和ReduceTask均会对数据按照key进行排序。该操作属于Hadoop的默认行为。任何应用程序中的数据均会被排序,而不管逻辑上是否被需要。
- 默认排序是按照字典顺序排序,且实现该排序的方法是快速排序。
- 对于MapTask,他会将处理的结果暂时放到环形缓冲区中,当环形缓冲区使用率达到一定阈值后,再对缓冲区中的数据进行一次快速排序,并将这些有序数据溢出到磁盘上,而当这些数据处理完毕后,他会对磁盘上的所有文件进行归并排序。
- 对于ReduceTask,他从每个MapTask上远程拷贝相应的数据文件,如果文件大小超过阈值,则溢写到磁盘上,否则存储在内存中。如果磁盘上文件数目达到一定阈值,则进行一次归并排序以生成一个更大的文件、如果内存最终文件大小或者数目超过一定阈值,则进行一次合并后将数据溢写到磁盘上。当所有数据拷贝完成后,ReduceTask统一对内存和磁盘上的所有数据进行一次归并排序
【问题】在一次MR程序中需要进行几次排序?在什么时候进行排序?
【解答】会进行三次排序。第一次是在环形缓冲区使用率达到一定阈值(80%)时,会对缓冲区中的数据进行一次快速排序;第二次是当数据处理完毕时,会对磁盘上的所有文件进行归并排序;第三次是ReduceTask统一对内存和磁盘上的所有数据进行一次归并排序
排序的分类

部分排序与全排序的区别:全排序是一个结果文件,部分排序是多个结果文件。在实际编程中也就是添加了自定义排序和设置NumReduceTask()
要想实现使自己的数据充当key值进行排序,必须实现WritableComparable接口的方法。
全排序案例
- 需求:
将单词计数后的结果按照数量升序排序
- 源代码:
- WordCountBean.java
public class WordCountBean implements WritableComparable<WordCountBean> {
private String word;
private int count; public String getWord() {
return word;
} public void setWord(String word) {
this.word = word;
} public int getCount() {
return count;
} public void setCount(int count) {
this.count = count;
} @Override
public String toString() {
return word + " " + count;
} /**
* 比较器默认大于或者小于时返回一个非零的数,如果等于则返回0,代表两个对象一模一样
* MR程序比较大小的时候,千万不能返回0.如果返回0,代表两个对象一样,那么相当于认为key是一样的,此时其余的相等数据将显示不出来
* @param o
* @return
*/
@Override
public int compareTo(WordCountBean o) {
if (this.count > o.getCount()) {
return 1;
} else {
return -1;
}
} @Override
public void write(DataOutput dataOutput) throws IOException {
dataOutput.writeUTF(this.word);
dataOutput.writeInt(this.count);
} @Override
public void readFields(DataInput dataInput) throws IOException {
this.word = dataInput.readUTF();
this.count = dataInput.readInt();
}
}
- WordCountBean.java
- 运行截图

部分排序案例
部分排序只是相对于全排序多了自定义分区以及设置ReduceTask的数量
- 需求
根据手机号码前三位进行分区,前三位为"135",则位于第一分区;前三位为"136",则位于第二分区;前三位为"137",则位于第三分区,其余数据在第四分区,并且按照总流量数升序排列
- 源代码
- FlowBean.java
public class FlowBean implements WritableComparable<FlowBean> {
private String phone;
private int upFlow;
private int downFlow;
private int sumFlow; public String getPhone() {
return phone;
} public void setPhone(String phone) {
this.phone = phone;
} public int getUpFlow() {
return upFlow;
} public void setUpFlow(int upFlow) {
this.upFlow = upFlow;
} public int getDownFlow() {
return downFlow;
} public void setDownFlow(int downFlow) {
this.downFlow = downFlow;
} public int getSumFlow() {
return sumFlow;
} public void setSumFlow(int sumFlow) {
this.sumFlow = sumFlow;
} @Override
public String toString() {
return phone + " " + upFlow + " " + downFlow + " " + sumFlow;
} /**
* 序列化方法:将Java对象的属性值怎么序列化写出
* @param dataOutput
* @throws IOException
*/
@Override
public void write(DataOutput dataOutput) throws IOException {
// 将一个String类型的属性序列化写出成二进制数据
dataOutput.writeUTF(this.phone);
// 将一个String类型的属性序列化写出成二进制数据
dataOutput.writeInt(upFlow);
dataOutput.writeInt(downFlow);
dataOutput.writeInt(sumFlow);
} /**
* 反序列化方法:怎么将二进制代码转成JavaBean对象属性的值
* 反序列化的时候,读取二进制数据时,不能随便读
* 序列化写出时先写出哪个属性的值,就先读哪个属性值
* @param dataInput
* @throws IOException
*/
@Override
public void readFields(DataInput dataInput) throws IOException {
this.phone = dataInput.readUTF();
this.upFlow = dataInput.readInt();
this.downFlow = dataInput.readInt();
this.sumFlow = dataInput.readInt();
} /**
* 传进来一个对象,我们需要在这个方法中定义我和传进来的这个对象谁大谁小的逻辑
* 一般情况下,JavaBean对象去比较大小都是比较属性值的大小
* 如果返回的是1,那么代表大于传进来的对象,升序排序
* 如果返回的是0.那么代表相等
* 返回的是-1,代表小于传进来的对象,降序排序
*
* @param o
* @return
*/
@Override
public int compareTo(FlowBean o) {
return Integer.compare(this.sumFlow, o.getSumFlow());
}
} - FlowPartition.java
public class FlowPartition extends Partitioner<FlowBean, NullWritable> {
@Override
public int getPartition(FlowBean flowBean, NullWritable nullWritable, int i) {
String phone = flowBean.getPhone();
String three = phone.substring(0, 3);
switch (three) {
case "135":
return 0;
case "136":
return 1;
case "137":
return 2;
default:
return 3;
}
}
}
- FlowBean.java
3. 运行截图

二次排序案例
- 需求
学生成绩排序:先根据语文成绩降序排序,如果语文成绩相同,再根据英语成绩降序排序
- 源代码
RecordBean.java
public class RecordBean implements WritableComparable<RecordBean> {
private String name;
private int china;
private int maths;
private int english; public String getName() {
return name;
} public void setName(String name) {
this.name = name;
} public int getChina() {
return china;
} public void setChina(int china) {
this.china = china;
} public int getMaths() {
return maths;
} public void setMaths(int maths) {
this.maths = maths;
} public int getEnglish() {
return english;
} public void setEnglish(int english) {
this.english = english;
} @Override
public String toString() {
return "name='" + name + '\'' +
", china=" + china +
", maths=" + maths +
", english=" + english;
} @Override
public void write(DataOutput dataOutput) throws IOException {
dataOutput.writeUTF(this.name);
dataOutput.writeInt(china);
dataOutput.writeInt(maths);
dataOutput.writeInt(english);
} @Override
public void readFields(DataInput dataInput) throws IOException {
this.name = dataInput.readUTF();
this.china = dataInput.readInt();
this.maths = dataInput.readInt();
this.english = dataInput.readInt();
} @Override
public int compareTo(RecordBean o) {
if (this.china > o.getChina()) {
return -1;
}else if(this.china == o.getChina()) {
if (this.english > o.getEnglish()) {
return -1;
} else {
return 1;
}
} else {
return 1;
}
}
} - 运行截图

分组辅助排序
Combiner合并
Combiner基本概念

Combiner合并其实是MapReduce程序中的一个调优策略。
如果多个 MapTask对应一个ReduceTask任务时,ReduceTask的压力会很大,这时可以将ReduceTask的数量增加,这样每个ReduceTask只需要处理一个MapTask,压力会小很多。
还有一种方式就是在Mapper阶段设置一个Combiner合并操作,每一个Combiner对应一个MapTask任务,它会将MapTask任务先进行一次合并,之后再交由ReduceTask执行。
Combiner是可选操作,他只是MR程序中的一个调优策略。但是分区和排序是必选的
Combiner处于Map阶段执行之后,Reduce阶段执行之前,其中Combiner的输入是Mapper阶段的输出Key-value,输出时Reduce阶段输入的key-value
Combiner代码实现
- 自定义一个combiner继承Reducer,重写reduce方法
public class WordCountCombiner extends Reducer<Text, LongWritable, Text, LongWritable> {
@Override
protected void reduce(Text key, Iterable<LongWritable> values, Context context) throws IOException, InterruptedException {
Iterator<LongWritable> iterator = values.iterator();
long num = 0L;
while (iterator.hasNext()) {
LongWritable next = iterator.next();
long l = next.get();
num += l;
}
context.write(key, new LongWritable(num));
}
}
在job驱动类中设置:
job.setCombinerClass(WordCountCombiner.class);
通过上述代码可以发现,combiner类写的逻辑方法与Reduce阶段的相同(一般情况下两个的处理逻辑都一样),所以可以直接在Driver中设置Combiner类为Reduce类。注意:虽然二者代码一样,但是执行的逻辑不一样。
在job驱动类中设置
job.setCombinerClass(WordCountReduce.class);
Combiner总结
- Combiner执行时机在Mapper结束之后,Reducer开始之前
- Combiner操作可选择,可以添加也可以不添加,不添加的话Map阶段的数据会直接传给Reducer。如果加上这个操作,那么Map阶段的数据先给Combiner,再从Combiner给Reducer。同时注意,map阶段的输出就是Combiner的输入,Combiner的输出就是Reducer的输入
- Combiner和Reducer的逻辑非常像,都是根据key值,将相同的key值的value值集中起来走一次Reducer。不同点在于Combiner对每一个MapTask都会去执行一次,而Reducer是将每一个Maptask的数据全部拉过来再去执行,这样效率比较低
GroupingComparator分组(辅助排序)
分组排序简介
这个分组排序又叫辅助排序,也是可选的。但是在有些业务条件下,分组排序必须存在。
主要作用:如果我们定义的key是JavaBean对象,我们可以在分组中将部分字段相同的bean对象当做同一个key处理。
分组排序的时机
map阶段数据分区排序合并结束,reduce已经将map阶段的输出数据读取到内存中,即将执行Reducer方法之前
分组排序的作用
- 将数据进行排序(一般情况下都用WritableComparable)
- 判断满足什么条件key值就相等了。
- Text类型判断的就是值,就是相等的key;
- LongWritable类型也是判断值相等,就是相等的key;
- 对于自定义的JavaBean对象,如何判断相等呢?是判断地址相等吗?
【补充】
例如:Student s1 = new Student("zs", "8374772648327498");
Student s2 = new Student("ls", "8374772648327498");
这两个对象相等吗?
从代码角度而言,这两个对象不相等,因为他们的地址不相等
但是Java是面向对象编程的语言,面向对象就是拿现实的逻辑去理解代码,所以从现实的角度来看,这两个对象是同一个对象,因为他们的身份证号一样
所以需要主要一个问题,在Java中,比较基本数据类型是否相等,应该用 == 去判断;但是如果需要比较两个引用数据是否相等,需要通过两个对象的属性值是否相等来判断两个对象是否为同一个对象,一般使用equals方法和compare方法
有了以上基础,再看上面的问题。对于JavaBean对象是否相等的判断,不应该用地址判断,我们需要判断对象中的某些属性是否相等。所以在MR程序中,如果你想根据我们自己的逻辑将不同的JavaBean对象当做同一个key处理,我们可以通过分组辅助排序实现。当然我们的WritableComparable也可以去实现,但是在业务逻辑上这个接口主要是做排序使用的。如果想在Reducer阶段让不同的Java对象成为一组相同的key,那么你就需要定义一个分组排序规则,在里面定义一下哪个对象是相同的key
分组排序的实现思路
案例:订单排序
有如下订单数据
|
订单id |
商品id |
成交金额 |
|
0000001 |
Pdt_01 |
222.8 |
|
0000001 |
Pdt_05 |
25.8 |
|
0000002 |
Pdt_03 |
522.8 |
|
0000002 |
Pdt_04 |
122.4 |
|
0000002 |
Pdt_05 |
722.4 |
|
0000003 |
Pdt_01 |
222.8 |
|
0000003 |
Pdt_02 |
33.8 |
现在需要求出每一个订单中最贵的商品。
- 继承extendsWritableComparator
- 定义一个构造器,指定判断相等的是那个JavaBean对象,并且传一个true(如果不定义,会报空指针异常的错误,因为自定义的比较器默认传入的值都是null值)
- 重写compare(WritableComparable a, WritableComparable b) 方法,在里面写我们比较的逻辑
【注意】这里的参数都是WritableComparable类型,实际上是Mapper阶段输出的key的类型。这里可以使用多态的方式对参数进行强转
- 在Driver类中对分组排序类进行配置
job.setGroupingComparatorClass(OrderGroup.class);
public class OrderGroupComparable extends WritableComparator {
public OrderGroupComparable() {
super(Order.class, true);
}
@Override
public int compare(WritableComparable a, WritableComparable b) {
Order order1 = (Order) a;
Order order2 = (Order) b;
if (order1.getId() > order2.getId()) {
return -1;
} else if (order1.getId() == order2.getId()) {
return 0;
} else {
return -1;
}
}
}
运行截图

MapReduce框架原理--Shuffle机制的更多相关文章
- Hadoop(17)-MapReduce框架原理-MapReduce流程,Shuffle机制,Partition分区
MapReduce工作流程 1.准备待处理文件 2.job提交前生成一个处理规划 3.将切片信息job.split,配置信息job.xml和我们自己写的jar包交给yarn 4.yarn根据切片规划计 ...
- MapReduce(五) mapreduce的shuffle机制 与 Yarn
一.shuffle机制 1.概述 (1)MapReduce 中, map 阶段处理的数据如何传递给 reduce 阶段,是 MapReduce 框架中最关键的一个流程,这个流程就叫 Shuffle:( ...
- Hadoop(14)-MapReduce框架原理-切片机制
1.FileInputFormat切片机制 切片机制 比如一个文件夹下有5个小文件,切片时会切5个片,而不是一个片 案例分析 2.FileInputFormat切片大小的参数配置 源码中计算切片大小的 ...
- 【待完成】[MapReduce_9] MapReduce 的 Shuffle 机制
0. 说明 待补充...
- Hadoop_18_MapRduce 内部的shuffle机制
1.Mapreduce的shuffle机制: Mapreduce中,map阶段处理的数据如何传递给Reduce阶段,是mapreduce框架中最关键的一个流程,这个流程就叫shuffle 将mapta ...
- MapReduce实例2(自定义compare、partition)& shuffle机制
MapReduce实例2(自定义compare.partition)& shuffle机制 实例:统计流量 有一份流量数据,结构是:时间戳.手机号.....上行流量.下行流量,需求是统计每个用 ...
- hadoop MapReduce Yarn运行机制
原 Hadoop MapReduce 框架的问题 原hadoop的MapReduce框架图 从上图中可以清楚的看出原 MapReduce 程序的流程及设计思路: 首先用户程序 (JobClient) ...
- java大数据最全课程学习笔记(6)--MapReduce精通(二)--MapReduce框架原理
目前CSDN,博客园,简书同步发表中,更多精彩欢迎访问我的gitee pages 目录 MapReduce精通(二) MapReduce框架原理 MapReduce工作流程 InputFormat数据 ...
- MapReduce框架原理
MapReduce框架原理 3.1 InputFormat数据输入 3.1.1 切片与MapTask并行度决定机制 1.问题引出 MapTask的并行度决定Map阶段的任务处理并发度,进而影响到整个J ...
随机推荐
- 图解协程调度模型-GMP模型
现在无论是客户端.服务端或web开发都会涉及到多线程的概念.那么大家也知道,线程是操作系统能够进行运算调度的最小单位,同一个进程中的多个线程都共享这个进程的全部系统资源. 线程 三个基本概念 内核线程 ...
- 6、基本数据类型(list)
6.1.列表: 1.li = [1, 12, 9, "age", ["孙子涵", ["19", 10], "张涵予"], ...
- 22.17、heartbeat和drbd整合
1.要确保master-db和slave-db的drbd服务和heartbeat服务都已经停止了: 2.heartbeate设置: 修改master-db和slave-db的'/etc/ha.d/ha ...
- 1.5Java、万维网以及其他
要点提示:Java是一种功能强大和多用途的编程语言,可用于开发运行在移动设备.台式计算机以及服务器端的软件.
- CRM系统选型时的参考哪些方面
企业不论在制定营销策略或是在进行CRM系统选型时,首先都是要了解自身的需求.每一家企业的情况和需求都有很大差异,CRM系统的功能也都各有偏重.有些CRM偏重销售管理.有些注重于营销自动化.有些则侧重于 ...
- 10 年 bloger 告诉你要不要写博客,又该如何优雅地写博客?
关于作者:程序猿石头(ID: tangleithu),现任阿里巴巴技术专家,清华学渣,前大疆后端 Leader.公众号后台回复关键字 "1024" 获取程序员大厂面试指南. 图:D ...
- AcWing 1252. 搭配购买
#include<bits/stdc++.h> #define N 10010 using namespace std; int fa[N],v[N],pr[N]; int vv[N],p ...
- Linux:修改Ubuntu环境变量后bash找不到
1.问题显示 修改环境变量,导致命令全部失效 2.解决办法 #使用备份bin export PATH=/usr/bin:/bin 之后使用命令就可以了. 3.找到修改的环境变量 一般修改的环境变量 ...
- linux 中只显示目录的几种方法
ls 参数 -a 表示显示所有文件,包含隐藏文件-d 表示显示目录自身的属性,而不是目录中的内容-F 选项会在显示目录条目时,在目录后加一个/ ls -l total 8 drwxrwxr-x 2 r ...
- Django 接口自动化测试平台
本项目工程 github 地址:https://github.com/juno3550/InterfaceAutoTestPlatform 0. 引言 1. 登录功能 2. 项目 3. 模块 4. 测 ...