Web 打印开发总结

对于经常浏览网页的你来说,打印网页可能并不陌生。我们平时所使用的浏览器在设置菜单都提供了这个功能选项,也可以通过快捷键来(Mac 上是 command + p)触发打印。下面将简单介绍下,在做打印这个不是特别刚性的需求中遇到的一些问题及注意的要点。

怎么实现打印

除了浏览器提供的内置功能选项,在 JavaScript 中可以通过调用 window.print() 方法来实现,调用之后会出现打印预览的对话框。这样做的好处就是我们可以在用户打印之前从服务端获取一些数据然后动态地生成一些内容插入到文档中,这在某些特定的场景中是非常有用的。

@media print

当你打印网页时,你会发现打印预览时的效果往往跟你的预期不是很相符,里面或多或少包含了一些不必要的内容。同样为了给网站提供更好的用户体验,专门为打印的内容应用样式就显得格外重要。

CSS 提供了一种叫做媒体查询的功能,允许我们在不同的媒介应用不同的样式。这个功能在移动端网页较为常见,常用来兼容不同屏幕的样式。为此我们可以通过媒体查询来引入打印所需要的样式,以下是两种引入方式:

<!-- link 标签引入 -->
<link rel="stylesheet" href="print.css" media="print" />
/* index.css */
@media print {
  .noprint {
    display: none;
  }

  .printable {
    display: block;
  }
}

通过在 link 元素上添加 media="print" 属性就可以为打印应用样式,这种方式的好处就是并不会阻塞页面的首次渲染。注意你选择这种方式的话就不需要在 print.css 中在添加 @media pirnt{} 了。

另外一种方式就是把打印的样式放到页面首次加载的主 CSS 文件中,用 @media print {} 包起来就行了。如果你的打印样式比较少的话可以选择这种方式,也就不必要新增一个文件了。

以上的打印样式都只会在打印预览时产生效果,原理其实也挺简单的,就是在打印预览时隐藏不需要打印的元素就行了。

单位 pt

pt 是自然界的标准长度单位,类似于 cm,mm 等,通常用于打印媒体。实际上 1pt 的大小为 1/72 英寸。而我们平时用的 px(像素)通常用于屏幕媒体,虽然也是固定单位,但是根据屏幕的分辨率不同而可大可小。

当然选择哪个单位并没有绝对的。在写这边文章之前,我做打印的需求时都用的是 px,查阅资料后发现对于打印单位都推荐使用 pt,经过一番实践对比后,在不同的设备上(iPad 和 Mac)用 px 打印出来的字体的大小在 iPad 上显得更加纤细,而使用了 pt 的话,差距就不是很明显。还有如果你对 sketch 比较了解的话,你可以对你的 Artboard(画板)类型选为 Paper Sizes(如下图):A4、A5、A6、Letter,纸张类型(比如 A4)后面的大小为 595 x 842,但是是没有单位的。如果你稍微换算一下就知道,A4 的纸张大小为 210mm x 297mm,如果以 pt 为单位的话,换算过来就是 595pt x 842pt。

屏幕快照_2018-01-21_14.19.12

布局

布局其实跟平时写网页没区别,最省心的方式当然是用 flexbox 来搞定了。但是不建议用浮动,在某些情况下,在电脑端正常但是在 pad 端样式就会错乱,所以尽量避免。如果打印的内容里含有图片,最好是给图片设置一个最大宽度或者最大高度(max-width: 100% or max-height: 100%),这样做是为了图片尺寸太大超出纸张大小影响布局。

通常打印的页面往往都会有固定的 header 或 footer,而中间的 body 是根据内容伸缩的,很常见的布局,如下图:

屏幕快照_2017-09-27_00.37.58

为了显示良好,我们可以用 flexbox 来轻松搞定,但为了兼容性的话也可以用 table 来完成,最终的效果都是相同的,以下为这种布局的伪代码供参考:

// flexbox

.page-wrapper {
    display: flex;
    flex-direction: column;
    height: 100%;
}

.page-body {
    flex: 1;
}

// table

.page-wrapper {
    display: table;
    height: 100%;
}

.page-header,
.page-footer {
    display: table-row;
    height: 1px;
}

.page-body {
    display: table;
    height: 100%;
}

@page

@page 这个规则很强,可以让我们在 CSS 中设置打印页面的一些属性如:纸张尺寸、边距等。比如以下代码:

@page {
  size: A4 landscape;
  margin: 2cm;
}

应用上面的 CSS,打印出来的纸张就是 A4 大小,并且纸张的上下左右边距都为 2cm。这个可以确保你输出内容符合预期,用户用着也省心,不用费劲探究各种配置项了。这个规则里面还是有蛮多东西可以设置的,详情请参见 CSS Paged Media Module Level 3

-webkit-print-color-adjust: exact

在打印的对话框中我们可以设置是否打印背景图形,通过这个属性我们可以强制打印的页面有背景图形,即便你在打印对话框中没有勾选背景图形,做这个的好处就是不要用户再费心探究了。当然这是一个非标准属性,以后可能会更改,目前在 Chrome、Safari 是有效的,Firefox 上不生效。

分页

对于分页问题,CSS 早在 2.x 版本中就支持了一些属性用来控制元素的分页行为。如 page-break-before,page-break-after,page-break-inside,在元素上应用这些属性就可以控制在该元素之前或之后,或者元素内部是否分页。这几个属性的取值这里就不一一列出了,请自行探究。

在实际的场景往往不是很简单,页面上超出的内容可能需要以某种样式整齐的放在下一页,这些属性并不能带来想要的效果,因此还要用
JavaScript 来操作 DOM 来处理分页问题。思路也比较简单,就是如果内容超出了纸张大小就去截取超出内容并放到下一页。

总结

以上是做打印需求的一些简单总结及实践,并不是很全面深入,希望能让各位前端同学们做同样的需求时,少走一些弯路。

参考

CSS print 样式

Print —— 被埋没的Media Type

CSS:EM, PX, PT, CM, IN…

CSS 打印

0

JVM 揭秘:一个 class 文件的前世今生

引子:我们都知道,要运行一个包含 main 方法的 java 文件,首先要将其编译成 class 文件,然后加载 JVM 中,就可以运行了,但是这里存在一些疑问,比如编译之后的 class 文件中到底是什么东西呢?JVM 是如何执行 class 文件的呢?下面我们就以一个很简单的例子来看一下 JVM 到底是如何运行的。

1. 准备

后面所介绍的内容都以下面的 java 文件和 class 文件为例子:

java 文件:

HelloWorld-Java-File

class 文件:

HelloWorld-Class-File

2. class 文件的结构

从上面可以看到,class 文件确实和它的另一个名字字节码文件一样是由一个个的字节码组成的。这里要注意的是因为 class 文件是由一个个字节组成的,所以如果当一个数据大于一个字节的时候,是使用无符号大端模式进行存储的,大小端模式的区别可以参考这里。那么这些字节表示什么意思呢?JVM 是如何解析这些字节数据的呢?我们到 oracle 的官方文档上看一下他们是如何定义 class 文件的结构的:

Class-File-Structure

从上面可以看到,一个 Class 文件中的每一个字节都有指定的意义,比如一开始的 4 个字节代表的是 magic number,这个值对所有的 Class 文件都一样,就是 CAFEBABE,接下来的 2 个字节是次版本号。再比如 cp_info,这是一个非常重要的字段,就是后面要着重介绍的常量池。

如果需要看每个字段的代表的意思可以看一下Java Language and Virtual Machine Specifications

上面的结构看起来可以比较抽象,那么可以看一下下面这张示意图:

Class-File-Format

现在大家应该可以想到了,实际上 class 文件中的所有的字节都代表了固定的信息,所以 JVM 只要根据 class 文件的格式就可以知道这个 class 文件中的存放了什么内容了,比如说方法的信息,字段信息等。

3. class文件的重要组成

现在我们已经知道 class 文件的结构,现在来介绍一下 class 文件中一些重要组成部分。

3.1 常量池

常量池就是前面看到的 ClassFile 里的 cp_info 字段。我们先来直观的看一下常量池到底长什么样子:

Constant-Pool-Structure

上面就是 HelloWorld.class 的常量池。常量池的头两个字节表明了常量池中常量项的个数,因为只有两个字节所以常量项是有数量限制的。具体多少个可以自行计算。常量项个数后面紧跟的就是各个常量项了。每个常量项都有一个 1 个字节的 tag 标志位,用于表示这个常量项具体代表的内容,从图中可以看到如果 tag 是 0A 的话就表示这是一个 MethodRef 的常量项,从名字就可以看出来这是一个表示 Method 信息的常量项。

用专业一点的术语描述的话常量池中保存的内容就是字面量和符号引用。字面量就像类似于文本字符串,或者声明为 final 的常量值。符号引用包括 3 类常量类和接口的全限定名,字段名称和描述符,方法名称和描述符。

特别要注意的一点是常量池中的常量项的索引是从 1 开始的,这样做的目的是满足后面其他结构中需要表明不引用任何一个常量项的含义,这个时候就将索引值置为 0。

从前面的描述可以总结出来,所有的常量池项都具有如下通用格式:

cp_info {
   u1 tag;
   u1 info[];
}

常量池中,每个 cp_info 项(也就是常量项)的格式必须相同,它们都以一个表示 cp_info 类型的单字节 tag 项开头。后面 info[] 项的内容由tag的类型所决定。

tag 的类型有如下几种:

Constant-Pool-Tag

一些常见的常量项:

Class Info:

CONSTANT_Class_Info {
    u1 tag;
    u2 name_index;
}
  • tag 的值为 7
  • name_index 指向了常量池中索引为 name_index 的常量项

UTF8 Info:

CONSTANT_UTF8_Info {
    u1 tag;
    u2 length;
    u1 bytes[length];
}
  • tag 的值为 1
  • length 表示这个 UTF8 编码的字符串的字节数
  • bytes[length] 表示 length 长度的具体的字符串数据

注意:因为 class 文件中的方法名,字段名等都是要引用 UTF8 Info 的,但是 UTF8 Info 的数据长度就是2个字节,所以方法名,字段名的长度最大就是65535。

String Info:

CONSTANT_String_INFO {
    u1 tag;
    u2 string_index;
}
  • tag 的值为 8
  • string_index 指向了常量池中索引为送 string_index 的常量项

Field_Ref Info:

CONSTANT_Fieldref_Info {
    u1 tag;
    u2 class_index;
    u2 name_and_type_index;
}
  • tag 的值为 9
  • class_index 指向了常量池中索引为 class_index 的常量项,且这个常量项必须为 Class Info 类型
  • name_and_type_index 指向了常量池中索引为 name_and_type_index 的常量项,且这个常量项必须为 Name And Type Info 类型

Method_Ref Info:

CONSTANT_Methodref_Info {
    u1 tag;
    u2 class_index;
    u2 name_and_type_index;
}
  • tag 的值为 10
  • class_index 指向了常量池中索引为 class_index 的常量项,且这个常量项必须为 Class Info 类型
  • name_and_type_index 指向了常量池中索引为 name_and_type_index 的常量项,且这个常量项必须为 Name And Type Info 类型

NameAndType Info:

CONSTANT_NameAndType_Info {
    u1 tag;
    u2 name_index;
    u2 descriptor_index;
}
  • tag 的值为 12
  • name_index 指向了常量池中索引为 name_index 的常量项
  • descriptor_index 指向了常量池中索引为 descriptor_index 的常量项

3.2 字段

和之前的常量池一样,因为每个 class 中字段的数量是不确定的,所以字段部分的开头两个字节用于表示当前 class 文件中的字段的个数,紧跟着的才是具体的字段。

先来看一下字段的结构

    Field_Info {
        u2 access_flag;
        u2 name_index;
        u2 descriptor_index;
        u2 attribute_count;
        attribute_info attributes[attribute_count];
    }
  • access_flag 表示该字段的访问修饰符,字段的访问修饰符和类的表示方式相似,但是具体的内容不一样

    字段的访问标识

    FIELD-ACCESS-FLAG

  • name_index 指向常量池中的 name_index 索引的常量项

  • descriptor_index 指向常量池中的 descriptor_index 索引的常量项
  • attribute_count 表示该字段的属性个数
  • attributes[attribute_count] 表示该字段的具体的属性

注意:这里字段的 descriptor 代表的字段的类型,但是类型不是写代码的时候 int,String 这样整个单词的,它是一些字符的简写,如下:

DESCRIPTOR

所以,举个例子如果字段是 String 类型,那么它的 descriptor 就是Ljava/lang/Object; 如果字段是 int[][],那么它的 descriptor 就是[[I

字段的属性和下面介绍的方法的属性是一样的,下文统一介绍。

3.3 方法

方法和字段一样,也需要有一个表示方法个数的字段,同时这个字段后面紧跟的就是具体的方法

同样,来看一下方法的结构:

    Method_Info {
        u2 access_flag;
        u2 name_index;
        u2 descriptor_index;
        u2 attribute_count;
        attribute_info attributes[attribute_count]
    }
  • access_flag 的意义和之前field一样,只不过取值不同,method 的access flag 可以取的值如下:

    method-access-flag

  • name_index 的意义和 field 的也一样,表示了方法的名称

  • descriptor_index 的意义和 field 也一样,只不过其表示方法不同,让我们来看一下它是如何表示的:

    method 的 descriptor 由两部分组成,一部分是参数的 descriptor,一部分是返回值的 descriptor,所以 method 的 descriptor 的形式如下:

    ( ParameterDescriptor* ) ReturnDescriptor
    

    而参数的 descriptor 就是 field 的 descriptor,返回值的descriptor 也是 field 的 descriptor 但是多了一个类型就是 void 类型,其的 descriptor 如下:

        VoidDescriptor:V
    

    所以举个例子,如果一个方法的签名是

    Object m(int i, double d, Thread t) {..}
    

    那么它的 descriptor 就是

    (IDLjava/lang/Thread;)Ljava/lang/Object;
    
  • attribute_count 的意义和 field 一样表示属性的个数
  • attributes[attribute_count] 和 field 也一样表示具体的属性,属性的个数由 attribute_count 决定

3.4 属性

3.4.1 属性结构

属性这个数据结构可以出现在 class 文件,字段表,方法表中。有些属性是特有的,有些属性是三个共有的。

属性的描述如下:

Attribute

这里我们就不详细解释每一个属性了,我们来看一个方法表中最重要的属性,即 Code Attribute。为什么说它重要,因为我们的函数的代码就是在 Code Attribute 中(实际上存储的是指令)。其他属性的一些解释可以参考 Oracle 的 JVM 规范中的描述

3.4.2 Code Attribute

首先来看一下 Code Attribute 的结构

Code_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 max_stack;
    u2 max_locals;
    u4 code_length;
    u1 code[code_length];
    u2 exception_table_length;
    {   u2 start_pc;
        u2 end_pc;
        u2 handler_pc;
        u2 catch_type;
    } exception_table[exception_table_length];
    u2 attributes_count;
    attribute_info attributes[attributes_count];
}

可以看到 Code Attribute 属性是非常复杂的,下面我们简单解释一下每个成员的含义:

  • attribute_name_index 指向的常量池中常量项的索引,而且这个常量项的类型必须是 UTF8 Info,值必须是 "Code"
  • attribute_length 表示这个属性的长度,但是不包括开始的 6 个字节
  • max_stack 表示 Code 属性所在的方法在运行时形成的函数栈帧中的操作数栈的最大深度
  • max_locals 表示最大局部变量表的长度
  • code_length表示 Code 属性所在的方法的长度(这个长度是方法代码编译成字节后字节的长度)
  • code[length]表示的就是具体的代码,所以说 java 函数的代码长度是有限制的,编译出来的字节指令的长度只能是 4 个字节所能代表的最大值。所以一个函数的代码不能太长,否者是不能编译的
  • exception_table_length 表示方法会抛出的异常数量
  • exception_table[exception_table_length] 表示具体的异常
  • attributes_count 表示 Code 属性中子属性的长度,之所以说属性复杂就是因为属性中还可以嵌套属性
  • attributes[attributes_count] 代表具体的属性

现在来直观的看一下 Code Attribute 的组成,下面就是 HelloWorld.class 中的 Code Attribute 属性:

Code-Attribute

3.4.3 Code Attribute的两个子属性

这里额外提一个 Code Attribute 中的两个子属性。不知道大家有没有想过为什么我们用 IDE 运行程序出错时,IDE 可以准确的定位到是哪一行代码出错了? 为什么我们在 IDE 中使用一个方法的时候可以看到这个方法的参数名,并且调试的时候可以根据参数名获取变量值?很关键的原因就在于 Code 属性的这两个子属性。

LineNumberTable

LineNumberTable的结构

LineNumberTable_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 line_number_table_length;
    {   u2 start_pc;
        u2 line_number;
    } line_number_table[line_number_table_length];
}

我们着重要看的是 line_number_table 这个成员,可以看到这个成员表示的就是字节码指令和源码的对应关系,其中 start_pc 是 Code Attribute 中的 code[] 数组的索引值,line_number 是源文件的行号

LocalVariableTable

LocalVariableTable 的结构

LocalVariableTable_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 local_variable_table_length;
    {   u2 start_pc;
        u2 length;
        u2 name_index;
        u2 descriptor_index;
        u2 index;
    } local_variable_table[local_variable_table_length];
}

其中最关键的成员大家也可以想到,肯定是 local_variable_table[local_variable_table_length],它里面属性的意义如下:

  • start_pc 和 length 表示局部变量的索引范围([start_pc, start_pc + length))
  • name_index 表示变量名在常量池中的索引
  • descriptor_index 表示变量描述符在常量池中的索引
  • index 表示此局部变量在局部变量表中的索引

LocalVariableTable 属性实际上是用于描述栈帧中局部变量表中的变量与 Java 源码中定义的变量之间的关系,所以根据这个属性,其他人引用这个方法时就可以知道这个方法的属性名,并且可以在调试的时候根据参数名称从上下文中获得参数值。

4. 执行引擎

前面讲的是 class 文件的静态结构,当 JVM 解析完 class 文件之后就会将其转成运行时结构,并将其存放在方法区中(也就是常说的永久代),然后会创建类对象(也就是 Class 对象)提供访问类数据的接口。

执行的时候 JVM 总是会先从 main 方法开始执行,其实就是从 Class 的所有方法中找到 main 方法,然后从 main 方法的 Code Attribute 中找到方法体的字节码然后调用执行引擎执行。所以要知道 JVM 是如何执行代码的就要了解一些字节码的内容。

4.1 运行时栈帧结构

先来看一看JVM的运行时结构

RUNTIME_STRUCT

因为JVM是一个基于栈的虚拟机,所以基本上所有的操作都是需要通过对栈的操作完成的。执行的过程就是从 main 函数开始(一开始就会为 main 函数创建一个函数栈帧),执行 main 函数的指令(在 Code Attribute 中),如果要调用方法就创建一个新的函数栈帧,如果函数执行完成就弹出第一个函数栈帧。

4.2 JVM的指令

不管你在 java 源文件中写了什么函数,用了什么高深的算法,经过编译器的编译,到了 class 文件中都是一个个的字节,而 Code Attribute 中的code[] 字段中的字节就是函数翻译过来的字节码指令。

JVM 支持的指令大致上可以分成 3 种:没有操作数的、一个操作数的和;两个操作数的。因为 JVM 用一个字节来表示指令,所以指令的最多只有 256 个。

JVM指令通用形式如下:

INSTRUCTION

4.3 几个常用的指令解析

因为 JVM 的指令太多了,在这里不可能全部都解析一遍,所以就选择了几个指令进行解析。

4.3.1 invokespecial

INVOKESPECIAL

说明:invokespecial 用于调用实例方法,专门用来处理调用超类方法、私有方法和实例初始化方法。

indexByte1 和indexByte2 用于组成常量池中的索引((indexbyte1 << 8)|indexbyte2)。所指向的常量项必须是 MethodRef Info 类型。同时该条指令还会创建一个函数栈帧,然后从当前的操作数栈中出栈被调用的方法的参数,并且将其放到被调用方法的函数栈帧的本地变量表中。

4.3.2 aload_n

ALOAD_N

说明:aload_n 从局部变量表加载一个 reference 类型值到操作数栈中,至于从当前函数栈帧的本地变量表中加载哪个变量是有N的值决定的。

4.3.3 astore_n

ASTORE_N

说明:将一个 reference 类型数据保存到局部变量表中,至于保存在局部变量表的哪个位置就由 N 的值决定。

好了,指令就介绍到这里,要看所有指令的说明可以看 Oracle 的 JVM 指令集,里面有对每一个指令的详细说明。

所以执行引擎要做的工作就是根据每一个指令要执行的功能进行对应的实现。

5. 总结

因为 JVM 的内容太过于丰富,这里只分析了 JVM 执行的主要的流程,还有些内容比如:类加载,类的链接(验证,准备,解析),初始化等过程没有说明。不是说这些内容不重要而是我们平时写代码的时候可以更加关注上面所介绍的一些内容。这里我也针对上面的内容写了一个可以运行的例子, 可以在这里找到。

6. 参考

  1. The Java® Virtual Machine Specification

  2. 深入理解Java虚拟机:JVM 高级特性与最佳实践(第2版)

  3. 深入java虚拟机第二版

0

Android 系统图片编辑的原理与实现——涂鸦与马赛克

图片编辑:涂鸦与马赛克

相信大家都用过微信的图片编辑功能,非常有用,例如发送图片前可以画上一些标记,或者把隐私信息涂上马赛克。最近在杏仁医生 APP 上,我们也添加了类似功能。今天就来讲讲其中的涂鸦和马赛克的原理与实现。下图就是我们最终的实现效果。

涂鸦&马赛克

基本概念

在讲具体的实现之前,先来看一下图片编辑功能中用到的一些基本概念。了解这些对后续一些复杂计算的理解有一定的帮助。

坐标系

  1. 触屏坐标系
    这里一般会使用 MotionEvent 中的 getX()getY(),这里的坐标值就是相对于当前控件显示区域的相对坐标,不论当前控件如何显示,如
    scrollXscrollY 的值发生变化后,也不会影响触屏坐标。不过有一点就是控件如果旋转(setRotation)或者平移(setTranslation)后,坐标原点位置也是会跟着变化的。

  2. 画布坐标系
    画布坐标可以简单计算得到,当前的坐标原点就是 (-scrollX, -scrollY),这样可以通过 View 的 scrollToscrollBy 方便地实现平移效果。

图片位置

图片的位置是基于画布坐标系的,并使用一个 RectF 对象表示(下面使用 mFrame 表示),它可以表示出图片的左上角和右下角坐标,因此 mFrame
不仅表示了图片的坐标位置,还表示的图片的缩放程度。关于图片展示,缩放以及平移等操作,可以参考我写过的一个大图预览的库:IntensifyImageView

// 当前图片矩形
RectF mFrame; 
// 原始图片矩形
RectF mOriginalFrame; 
// 当前图片的缩放值
float scale = mFrame.getWidth() / mOriginalFrame.getWidth();

绘制的时候只需要将Bitmap对象绘制到mFrame矩形中即可,对图像的缩放及平移操作全部转化到了对mFrame矩形的操作。

// 绘制图像
canvas.drawBitmap(bitmap, null, mFrame, null);

图像的缩放

图片缩放会涉及到两个坐标系,手势触摸得到缩放值及缩放中心,如 (focusX, focusY, factor),然后转换成画布坐标 (focusX + scrollX, focusY + scrollY, factor),再根据这个坐标及缩放值计算 mFrame,如下:

Matrix m = new Matrix();
m.setScale(factor, factor, newFocusX, newFocusY);
m.mapRect(mFrame);

如上可以看出,缩放会影响到图像的画布坐标。

图像的平移

平移可以使用两个接口:

scrollTo(x, y);

// 还是借助scrollTo实现的
scrollBy(dx, dy);

图像的平移不会影响图片的画布位置,当前控件的视图窗口会发生变化,也就是 scrollXscrollY 的值发生变化。

涂鸦与马赛克

实现要点

  • 涂鸦
    • 画笔颜色可变(几种常用颜色)
    • 画笔粗细始终保持一致
    • 路径过渡平滑及两端圆角
  • 马赛克
    • 画笔粗细在当前缩放状态下保持一致
    • 路径过渡平滑及两端圆角

原理分析

这两个功能点其实是有很大的重合部分的,即都是绘制出一个路径,涂鸦绘制纯色路径,而马赛克绘制处理后的图片路径。

手势路径这个很简单,Java 中的 Path 想必大家都很了解,大致绘制记录代码如下:

switch(event.getActionMasked()) {
    case MotionEvent.ACTION_DOWN:
        path.reset();
        path.moveTo(event.getX(), event.getY());
        break;
    case MotionEvent.ACTION_MOVE:
        path.lineTo(event.getX(), event.getY());
        break;
    case MotionEvent.ACTION_UP:
        // 添加到Path列表中
        break;  
}

但是涂鸦和马赛克的实现原理略有不同,下面我们来详细看下。

涂鸦实现

上面的分析看似很是简单,但是必须还要解决好以下问题:

  • 当前绘制的路径要以正确的尺寸及位置绘制到界面上(手势坐标系)
  • 已绘制的路径要随着图层滚动缩放等正确变化(画布坐标系)

那么为什么会有这两个问题呢?因为图片编辑从一开始的设计思路几乎是紧贴微信的,背景图层是可以随时缩放移动的(和其他图片编辑有些区别,很多是不可以在编辑时缩放移动的),因此绘制计算难度大增。

先看下 Path 和图层间的关系(Path 使用的是控件坐标,因此如果不经变化直接绘制就会出现如下情况):

涂鸦

当前 Path 绘制到屏幕上需要缩放当前画笔粗细度,反向旋转当前画布(旋转角度为负的当前旋转度),再平移到当前的滚动偏移值:

mDoodlePaint.setColor(mPen.getColor());
mDoodlePaint.setStrokeWidth(IMGPath.BASE_DOODLE_WIDTH * mImage.getScale());

canvas.save();
RectF frame = mImage.getClipFrame();
canvas.rotate(-mImage.getRotate(), frame.centerX(), frame.centerY());
canvas.translate(mView.getScrollX(), mView.getScrollY());
canvas.drawPath(mPen.getPath(), mDoodlePaint);
canvas.restore();

这里是唯一不需要的就是缩放,因为当前情况我需要绘制的就是这么大小的尺寸。

那么已经绘制的路径为何处理方式不同呢?

应为绘制完成后我可以对图层进行缩放操作,而已经绘制过的路径是需要与绘制时的图层保持相对位置大小不变的,也就是跟着缩放。

对路径缩放的方法有如下:

Matrix matrix = new Matrix();
matrix.setScale(sx, sy, px, py);
path.transform(matrix);

这样可以,但是效率受到了极大的影响,是因为我每次手势缩放过程中的几十次连续的缩放值的变化都要对已经加入的路径进行如上缩放操作,而且每个路径的实际缩放值还是不同的。

解决办法是将已完成绘制的路径缩放平移旋转到原始坐标中,这样每次绘制时都是统一缩放平移旋转一次画布,如此一来所需要的代价仅仅是绘制一个路径。

具体效果图如下:

涂鸦

public void addPath(IMGPath path, float sx, float sy) {
    if (path == null) return;

    float scale = 1f / getScale();

    M.setTranslate(sx, sy);
    M.postRotate(-getRotate(), mClipFrame.centerX(), mClipFrame.centerY());
    M.postTranslate(-mFrame.left, -mFrame.top);
    M.postScale(scale, scale);
    path.transform(M);

    switch (path.getMode()) {
        case DOODLE:
            mDoodles.add(path);
            break;
        case MOSAIC:
            path.setWidth(path.getWidth() * scale);
            mMosaics.add(path);
            break;
    }
}

当需要绘制这一系列路径时如下(旋转操作在绘制最初已设置):

public void onDrawDoodles(Canvas canvas) {
    if (!isDoodleEmpty()) {
        canvas.save();
        float scale = getScale();
        canvas.translate(mFrame.left, mFrame.top);
        canvas.scale(scale, scale);
        for (IMGPath path : mDoodles) {
            path.onDrawDoodle(canvas, mPaint);
        }
        canvas.restore();
    }
}

马赛克也是类似的路径绘制问题,下面再具体分析并实现。

马赛克实现

马赛克路径的确定和涂鸦基本一致,不同的在于马赛克的路径宽度是不同的,当前绘制时的马赛克宽度永远是一个值,因此缩放后再绘制所形成的马赛克路径粗细各不相同,所以每个马赛克路径需要额外记录一下路径宽度(涂鸦需要记录颜色值)。

那么每个路径画出的马赛克是如和形成的呢?其实很简单,马赛克就是将整个区域的颜色变成一个颜色值,如将 10x10 区域内的颜色变成其中的一个颜色值,所以我们将一张图片缩放到一个较小的尺寸,然后再放大到原始尺寸去显示,这个图片就很模糊了,然后关闭 Paint 的滤波功能
paint.setFilterBitmap(false),这样就得到了一个图片的马赛克效果,如下:

马赛克

所以为什么要将一整张图变成马赛克呢?可以用一张图简单表示如下:

马赛克

将马赛克路径图层与马赛克图层合并显示即可。也就是 Paint 的如下功能:
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));

关键代码如下:

private void makeMosaicBitmap() {
    if (mMosaicImage != null || mImage == null) {
        return;
    }

    if (mMode == IMGMode.MOSAIC) {

        int w = Math.round(mImage.getWidth() / 64f);
        int h = Math.round(mImage.getHeight() / 64f);

        w = Math.max(w, 8);
        h = Math.max(h, 8);

        // 马赛克画刷
        if (mMosaicPaint == null) {
            mMosaicPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
            mMosaicPaint.setFilterBitmap(false);
            mMosaicPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
        }

        mMosaicImage = Bitmap.createScaledBitmap(mImage, w, h, false);
    }
}

从原图创建一个马赛克位图,绘制时分两段,分别是绘制马赛克路径图层和绘制马赛克图层(当前马赛克路径和涂鸦的当前路径绘制逻辑基本一致,这里就不放出了):

public int onDrawMosaicsPath(Canvas canvas) {
    int layerCount = canvas.saveLayer(mFrame, null, Canvas.ALL_SAVE_FLAG);

    if (!isMosaicEmpty()) {
        canvas.save();
        float scale = getScale();
        canvas.translate(mFrame.left, mFrame.top);
        canvas.scale(scale, scale);
        for (IMGPath path : mMosaics) {
            path.onDrawMosaic(canvas, mPaint);
        }
        canvas.restore();
    }

    return layerCount;
}

public void onDrawMosaic(Canvas canvas, int layerCount) {
    canvas.drawBitmap(mMosaicImage, null, mFrame, mMosaicPaint);
    canvas.restoreToCount(layerCount);
}

马赛克路径的加入和涂鸦也是一致的,都统一在上面提到的 addPath 方法中完成了,也是需要缩放旋转平移到原始坐标中去的。

至此涂鸦和马赛克核心编码思想已经全部出现出,部分细节在与贴片和裁剪等功能结合时可能略微调整。

结语

我已经把本文提到的图片编辑功能抽取成独立的类库并开源,大家如果有什么建议,欢迎和我讨论,一起来优化它。Github 的地址是:https://github.com/kareluo/Imaging

0

微服务环境下的集成测试探索(二)—— 契约式测试

微服务的集成

前一篇已经提到,传统方式下,微服务的集成以及测试都是一件很头痛的事情。其实在微服务概念还没有出现之前,在 SOA 流行的时候,就有人提出了消费者驱动契约(Consumer Driven Contract,CDC)的概念。微服务流行后,服务的集成和集成测试成了不得不解决问题,于是出现了基于消费者驱动契约的测试工具,最流行的应该就是 Pact,还有就是今天我们要说的 Spring Cloud Contract。

消费者驱动契约

熟悉敏捷开发的同学应该知道,敏捷开发提倡测试先行,相应的提出了不少方法和流程,例如测试驱动开发(Test Driven Design,TDD)、验收测试驱动开发(Acceptance Test Driven Development,ATDD)、行为驱动设计(Behavior Driven Design,BDD )、实例化需求(Specification By Example)等等。它们的共同特点在开发前就约定好了各种形式的契约。如果是单元测试作为契约,就是 TDD;如果是验收测试作为契约,就是 ATDD;如果是形式化语言甚至图表定义的业务规则,那就是 BDD 或者实例化需求。

对于基于 HTTP 的微服务来说,它的契约就是指 API 的请求和响应的规则。对于请求,包括请求 URL 及参数,请求头,请求内容等;对于响应,包括状态码,响应头,响应内容等。

在 Spring Cloud Contract 里,契约是用一种基于 Groovy 的 DSL 定义的。例如下面是一个短信接口的契约(省略了部分内容,例如 Content-Type 头等)。

org.springframework.cloud.contract.spec.Contract.make {
    request {                                 // 如果消费方发送了一个请求
        method 'POST'                         // 请求方法是 POST
        url '/sendsms'                        // 请求 URL 是 `/sendsms`
        body([                                // 请求内容是 Json 文本,包括电话号码和要发送的文本
               phone: $(regex('[0-9]{13}')),  // 电话号码必须是13个数字组成
               content: "您好"                 // 发送文本必须为"您好"
        ])
    }
    response {
        status 200                            // 那么服务方应该返回状态码 200
        body([                                // 响应内容是 Json 文本,内容为 { "success": true }
               success: true
        ])
    }
}

使用 CDC 开发服务的大致过程是这样的。
1. 业务方和服务方相关人员一起讨论。业务方告知服务方接口使用的场景、期望的返回是什么,服务方考虑接口方案和实现,双方一起定下一个或多个契约。
2. 确定了契约之后,Spring Cloud Contract 会给服务方自动生成验收测试,用于验证接口是否符合契约。服务方要确保开发完成后,这些验收测试都能够通过。
3. 业务方也可以基于这个契约开始开发功能。Spring Cloud Contract 会基于契约生成 Stub 服务,这样业务方就不必等接口开发完成,可以通过 Stub 服务进行集成测试。

契约测试

所以 CDC 和行为驱动设计(BDD)很类似,都是从使用者的需求出发,双方订立契约,测试先行的开发方法。不过一个是针对系统的验收,一个是针对服务的集成。CDC 的好处有以下几点:
- 让服务方和调用方有充分的沟通,确保服务方提供接口都是以调用方的需求出发,并且服务方的开发者也可以充分理解调用方的使用场景。
- 解耦和服务方和调用方的开发过程,一旦契约订立,双方都可以并行开发,通过 Mock 和自动化集成测试确保双方都遵守契约,最终集成也会更简单。
- 通过 Mock 和自动化测试,可以确保双方在演进过程中,也不会破坏已有的契约。

但是要注意一点是,契约不包括业务逻辑,业务逻辑还是需要服务方和调用方通过单元测试、其他集成测试来确保。例如上面的短信服务,可能服务方会有一个逻辑是每天一个号码最多发送一条短信,但这个逻辑并不会包含在契约里,可能契约只有包含成功和错误两种情况。

Spring Cloud Contract 使用方法

服务方

Spring Cloud Contract 支持 Gradle 和 Maven,详细的配置文档就不细述了,请参考文档。对于服务方,Spring Cloud Contract 提供了一个叫 Contarct Verifier 的东西,用于解析契约文件生成测试。

如果使用 Gradle 的话,通过以下命令生成测试。

./gradlew generateContractTests

上面发送短信的契约,生成的测试代码是这样的。

public class SmsTest extends ContractBase {
    @Test
    public void validate_sendsms() throws Exception {
        // given:
            MockMvcRequestSpecification request = given()
                    .body("{\"phone\":\"2066260255168\",\"content\":\"\u60A8\u597D\"}");
        // when:
            ResponseOptions response = given().spec(request)
                    .post("/sendsms");
        // then:
            assertThat(response.statusCode()).isEqualTo(200);
        // and:
            DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
            assertThatJson(parsedJson).field("['success']").isEqualTo(true);
    }
}

可以看到是一个很标准的 JUnit 测试,使用了 RestAssured 来测试 API 接口。其中的 ContractBase 是设置的测试基类,里面可以做一些配置以及 Setup 和 Teardown 操作。例如这里,我们需要用 RestAssured 来启动 Spring 的 webApplicationContext,当然我也可以用 standaloneSetup 设置启动单个 Controller。

    @Before
    public void setup() {
        RestAssuredMockMvc.webAppContextSetup(webApplicationContext);
    }

调用方

首先我们需要在服务方通过以下命令生成 Stub 服务的 Jar 包。

./gradlew verifierStubsJar

这个 Jar 包里面包含了契约文件以及生成的 WireMock 映射文件。我们可以把它发布到 Maven 私库里去,这样调用方可以直接从私库下载 Stub 的 Jar 包。

对于调用方,Spring Cloud Contract 提供了 Stub Runner 来简化 Stub 的使用。

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(webEnvironment= SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(repositoryRoot="http://<nexus_root>",
        ids = {"com.xingren.service:sms-client-stubs:1.5.0-SNAPSHOT:stubs:6565"})
public class ContractTest {
    @Test
    public void testSendSms() {
        ResponseEntity<SmsServiceResponse> response =
        restTemplate.exchange("http://localhost:6565/sendsms", HttpMethod.POST,
                new HttpEntity<>(request), SmsServiceResponse.class);
        // do some verification
    }
}

注意注解 AutoConfigureStubRunner,里面设置了下载 Stub Jar 包的私库地址以及包的完整 ID,注意最后的 6565 就是指定 Stub 运行的本地端口。测试的时候访问 Stub 端口,就会根据契约返回内容。

前端开发

另外一个使用 Mock 的场景就是对于前端开发。以前,前端工程师一般需要自己创建 Mock 数据进行开发,但 Mock 数据很容易和后台最终提供的数据有不一致的地方。CDC 和 Spring Cloud Contract 也可以帮上忙。

Spring Cloud Contract 生成的 Stub 其实是 WireMock 的映射文件,因此直接使用 WireMock 也是可以的。不过,它还提供了使用 Spring Cloud Cli 运行 Stub 的方式。

首先需要安装 SpringBoot Cli 和 Spring Cloud Cli,Mac 下可以使用 Homebrew

$ brew tap pivotal/tap
$ brew install springboot
$ spring install org.springframework.cloud:spring-cloud-cli:1.4.0.RELEASE

然后在当前目录创建一个 stubrunner.yml 配置文件,里面的配置参数和前面的 AutoConfigureStubRunner 的配置其实是一样的:

stubrunner:
  workOffline: false
  repositoryRoot: http://<nexus_root>
  ids:
    - com.xingren.service:sms-client-stubs:1.5.0-SNAPSHOT:stubs:6565

最后运行 spring cloud stubrunner,即可启动 Stub 服务。前端同学就可以愉快的使用 Stub 来进行前端开发了。

DSL

Spring Cloud Contract 的契约 DSL,既可以用于生成服务方的测试,也可以用于生成供调用方使用的 Stub,但是这两种方式对数据的验证方法有一些不同。对于服务方测试,DSL 需要提供请求内容,验证响应;而对于 Stub,DSL 需要匹配请求,提供响应内容。Spring Cloud Contract 提供了几种方式来处理。

一种方式是通过 $(consumer(...), producer(...)) 的语法(或者 $(stub(...), test(...))$(client(...), server(...))$(c(...), p(...)),都是一样的)。例如。

org.springframework.cloud.contract.spec.Contract.make {
    request {
        method('GET')
        url $(consumer(~/\/[0-9]{2}/), producer('/12'))
    }
    response {
        status 200
        body(
                name: $(consumer('Kowalsky'), producer(regex('[a-zA-Z]+')))
        )
    }
}

上面就是指对于调用方,url 需要匹配 ~/\/[0-9]{2}/ 这个正则表达式,Stub 就会返回响应,其中 name 则为 Kowalsky。而对于服务方,生产的测试用例的请求 url 为 /12,它会验证响应中的 name 符合正则 '[a-zA-Z]+'。另外,Spring Cloud Contract 还提供了 stubMatcherstestMatchers 来支持更复杂的请求匹配和测试验证。

Spring Cloud Contract 现在还在快速发展中,目前对于生成测试用例的规则,还是有不够灵活的地方。例如,对于某些 Stub 应该返回,但生成的测试里不需要验证的字段,支持不太完善。还有对于 form-urlencoded 的请求,处理起来不如 Json 的请求那么方便。相信后继版本会改善。

总结

通过上面简单介绍,我们可以看到基于 Spring Cloud Contract 以及契约测试的方法,可以让微服务之间以及前后端之间的集成更顺畅。

另外前面还提到 Pact,它的优势是支持多种语言,但我们的环境都是基于 JVM 的,而 Spring Cloud Contract 和 SpringBoot 以及 Junit 的集成更简单方便。而且 Spring Cloud Contract 的另一个优势是它可以自动生成服务方的自动化测试。

2+

微服务环境下的集成测试探索(一) —— 服务 Stub & Mock

引子

现在微服务很流行,企业架构微服务化的确能解决不少问题,但是在微服务环境下,服务之间的依赖以及由此造成的开发、测试和集成的问题,一直都是微服务最大的痛点。

传统的解决方案是,除了测试、预发布和生产环境,还会部署多套用于开发和集成的环境。这样存在的问题是,只要有一组服务出现问题,就会影响其他使用该环境的团队的日常开发和测试。而且常常出现问题后,需要耗费很多时间定位,结果还常常是因为某个服务的版本没有同步。并且多套环境维护起来也是一个麻烦重重,即使有了容器。

这次我们一起来探索一下 API 模拟工具以及基于契约的测试,也许会是解决这个问题的一个方案。

WireMock

我们开发应用也好、服务也好,常常需要依赖后端或者服务的接口。例如开发移动应用 App,可能后端接口还在开发中,这时 App 的开发因为无法调用后端,很不方便。又或者程序会依赖第三方的接口,例如微信支付,在本地开发时不能直接调用。

这时我们就会需要一个工具来模拟这些服务,WireMock 就是这样的一个工具,主要针对的是最常见的 HTTP 服务。

WireMock 用于开发调试

WireMock 首先自身就是一个可以独立运行的服务。下载 Standalone Jar 文件后,即可可以直接运行。

java -jar wiremock-standalone-2.11.0.jar

此时可以通过 Json 映射文件来定义 Stub 服务。例如下面是一个映射文件,request 部分设置匹配的 Url 路径、请求方法及参数,如果匹配到了,则会返回 response 部分设置的内容。把该文件放到 WireMock 同路径下的 mappings 目录下即可。

{
  "request" : {
    "urlPath" : "/api/order/find",
    "method" : "GET",
    "queryParameters" : {
      "orderId" : {
        "matches" : "^[0-9]{16}$"
      }
    }
  },
  "response" : {
    "status" : 200,
    "bodyFileName" : "body-order-find-1.json",
    "headers" : {
      "Content-Type" : "application/json;charset=UTF-8"
    }
  }
}

Response 的内容可以直接在映射文件里设置,也可以引用了另一个文件。这里是引用了一个名为 body-order-find-1.json 的文件,该文件放置在 WireMock 同路径下的 __files 目录下。

{
    "success": true,
    "data": {
        "id": 781202,
        "buyerId": -2,
        "status": 0,
        // 略...
    }
}

下面我们用 curl 测试一下。第一次我们请求的参数 orderId 无法匹配指定的正则,WireMock 会返回 Request was not matched,而且还会很贴心的告诉你最接近的匹配是什么。

$ curl http://localhost:8080/api/order/find?orderId=abcdefghijklmnop
                                       Request was not matched
                                       =======================

----------------------------------------------------------------------------------------------------------
| Closest stub                                         | Request 
----------------------------------------------------------------------------------------------------------
GET                                                    | GET
/api/order/find                                        | /api/order/find
----------------------------------------------------------------------------------------------------------

第二次我们参数 orderId 匹配的话,WireMock 会直接返回设置的结果。

$ curl http://localhost:8080/api/order/find?orderId=9999999999999999
{
    "success": true,
    "data": {
        "id": 781202,
        "buyerId": -2,
        "status": 0
    }
}

上面的例子是 WireMock 最基本的用法,除了请求匹配响应,WireMock 也能支持:
- 通过 RESTFul 的接口提交和管理请求映射和相应。
- 支持响应模板,返回内容时会将变量填充到响应模板中。当然,这里的模板功能是比较简单的,但对于大部分 Stub 的场景应该是足够了。
- 支持模拟异常返回,例如设置有一定比例的超时返回等等,这个功能用于测试非常方便。

为了方便编写请求映射文件,WireMock 还可以运行在代理模式,只需要运行时添加 --enable-browser-proxying 参数即可。此时 WireMock 匹配到请求后,不是返回指定的内容,而是把请求 Forword 到指定的 URL,获得 Response 后再返回给调用方。同时,WireMock 会记录请求和返回的内容,生成 Json 映射文件。使用时只要根据需求对这些映射文件做一定修改,既可以用来模拟目标服务。

WireMock 用于集成测试

除了独立运行,WireMock 也可以直接嵌入到代码中。最方便的就是在 JUnit 中使用,WireMock 提供了 WireMockRule, 可以很方便的在测试时嵌入一个 Stub 服务。

下面是一个支付相关的集成测试,被测试方法会调用微信的支付服务。stubForUnifiedOrderSuccess 设置了一个很简单的 Stub,一旦匹配到请求的 URL 为 /pay/unifiedorder,那就返回指定的 XML 内容。这样我就可以在集成测试里测试整个支付流程,而不必依赖真正的微信支付。当然,测试时微信支付接口的 Host 也要改成 WireMockRule 配置的本地端口。并且,通过这种方式也很容易测试一些异常情况,根据需要修改 Stub 返回的内容即可。

public class OrderTest {
    @Rule
    public WireMockRule wireMockRule = new WireMockRule(9090);

    /**
     * 统一下单 Stub
     * 参考 https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_1
     *
     * @param tradeType 交易类型, 可以是JSAPI、NATIVE或APP
     */
    public void stubForUnifiedOrderSuccess(String tradeType) {
        String unifiedOrderResp = "<xml>\n" +
                "    <return_code><![CDATA[SUCCESS]]></return_code>\n" +
                "    <return_msg><![CDATA[OK]]></return_msg>\n" +
                "    <appid><![CDATA[wxxxxxxxxxxxxxxxxx]]></appid>\n" +
                "    <mch_id><![CDATA[9999999999]]></mch_id>\n" +
                "    ...... \n" +
                "    <trade_type><![CDATA[" + tradeType + "]]></trade_type>\n" +
                "</xml>";
        stubFor(post(urlEqualTo("/pay/unifiedorder"))
                .withHeader("Content-Type", equalTo("text/xml;charset=UTF-8"))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withHeader("Content-Type", "text/plain")
                        .withBody(unifiedOrderResp)));
    }

    @Test
    public void test001_doPay() {
       stubForUnifiedOrderSuccess("JSAPI");
       payServices.pay();
        // 测试代码...
    }
}

有时候在集成测试里,我们还需要验证系统的行为,例如是否调用了某个 API,调用了几次,调用的参数和内容是否符合要求等。区别于前面说的 Stub,其实这就是常说的 Mock 功能。WireMock 对此也有很强大的支持。

verify(postRequestedFor(urlEqualTo("/pay/unifiedorder"))
        .withHeader("Content-Type", equalTo("text/xml;charset=UTF-8"))
        .withQueryParam("param", equalTo("param1"))
        .withRequestBody(containing("success"));

这样,有了 WireMock,集成测试时处理第三方的依赖就非常方便了。不需要直接调用依赖的服务,也不需要专门创建用于集成测试的 Stub 或 Mock,直接代码中根据需要设置即可。

WireMock 总结

综上所属, WireMock 可以:
- 作为代理运行,此时可以录制请求和返回的脚本,用于后继 Stub 和 Mock 使用。
- 独立运行,作为一个 Stub 服务,根据匹配的请求返回数据。
- 作为 Stub,通过代码嵌入 HTTP 模拟服务,在指定端口监听,并根据匹配的请求返回数据。
- 作为 Mock,在单元测试和集成测试中,验证请求逻辑。例如是否进行了调用、参数是否正确等。

这里再强调下 Stub 和 Mock 的区别,很多人经常搞混。Stub 就是一个纯粹的模拟服务,用于替代真实的服务,收到请求返回指定结果,不会记录任何信息。Mock 则更进一步,还会记录调用行为,可以根据行为来验证系统的正确性。

总之,我们可以用 WireMock 来:
- 在外部服务尚未开发完成时,模拟服务,方便开发。
- 在本地开发时,模拟外部服务避免直接依赖。
- 在单元测试中模拟外部服务,同时验证业务逻辑。

契约式测试

本文主要以 WireMock 为例介绍了 API 模拟工具的使用方法。其实除了 WireMock,还有不少类似的工具,例如最早的 MounteBank,以及 MockServer、Moco 等也都是很强大的工具。

不过,在微服务环境下,光有 API 模拟工具还不够。对于 WireMock,首先必须考虑如何来管理大量的映射文件。一个方法是开发一个专用的 Stub 平台,来管理所有的映射文件,同时作为 Stub 运行。另外一个方法是通过 Git 来管理映射文件,需要的时候同步下来运行 WireMock 即可。

另外,我们上面提到 WireMock 的两大作用,调用方模拟服务以及服务方集成测试,是否可以统一两者呢?也就是说,调用方和服务方约定好接口,生成映射文件,这个文件即可以用于客户端模拟服务,也可以用于服务方集成测试,这样双方开发也好、集成也好都会方便很多。下一篇我们来研究一下 Spring Cloud Contract,它就是基于 WireMock 实现了契约式的测试,上文中双方约定好的接口,其实就是双方的契约。

1+

Objective-C 里的语法糖

语法糖(Syntactic sugar),也译为糖衣语法,是由英国计算机科学家彼得·约翰·兰达(Peter J. Landin)发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。

——维基百科

需要声明的是“语法糖”这个词绝非贬义词,它可以给我带来方便,是一种便捷的写法,编译器会帮我们做转换;而且可以提高开发编码的效率。
通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会,本文在简单的介绍 OC 语法糖的同时也会跟大家分享下我们使用过程中发现的'新'问题。

语法糖的自白

先举个生活中的🌰:
1. 老班:为了传达教育局和学校的教育精神我来讲几句。
2. 老班:下面我要讲啦啊。
3. 老班:我要讲的是教育局和学校刚传达的规定。
4. 老班:最近校长发现迟到学生越来越多。
5. 老班:为了维护教学秩序,学校制定了新校规。
6. 老班:.....
7. 小明:说人话!
8. 老班:从明天起7点钟准时到校上课!

老班巴拉巴拉讲了一堆,其实只是要表达从明天起 7 点钟准时到校上课!虽然最终能表达出效果,但是老班说的累小明们听着也烦。举这个例子可能比较极端,至少我没有遇到这么啰嗦的老师。但在编程语言中的确有不少让程序员感到罗嗦的语法,让人不能忍,这个时候语法糖就派上了用场。

比如OC取数组元素:

id element = [array1 objectAtIndex:0];

OC 语法糖:你看,我是不是写起来很方便?

id element = array1[0];

OC 语法糖:往下看,我还能做更多呢。

OC语法糖

@[]@{}

NSArray

一般数组的初始化和访问数组元素是这样的:

// NSArray 的 alloc 初始化
NSArray *array1 = [[NSArray alloc] initWithObjects:@"a", @"b", @"c", nil];
// NSArray 的便捷构造
NSArray *array2 = [NSArray arrayWithObjects:@"1", @"2", @"3", nil];

获取数组中的元素可以这样的:

// 获取相应索引的元素
id element = [array1 objectAtIndex:0];
NSLog(@"array1_count = %d, array[0] = %@",[array1 count], element);

如果使用语法糖,可以这样写:

// NSArray的定义
NSArray *array = @[@"lu", @"da", @"shi", @YES, @123];
int count = (int)[array count];
for (int i = 0; i < count; i++)
{
   NSLog(@"%@", array[i]);
}
NSDictionary

字典的初始化一般是这样的:

NSDictionary *dictionay = [NSDictionary dictionaryWithObjectsAndKeys:@"value1", @"key1", @"value2", @"key2", nil];
id value = [dictionay objectForKey:@"key1"];
NSLog(@"key1 => %@", value);

我们还可以这样简化:

NSDictionary *dictionary = @{
                             @"key0" : @"value0",
                             @"key1" : @"value1",
                             @"key2" : @"value2"
                             };
NSLog(@"key2 => %@", dictionary[@"key2"]);

事实上 [ ]{ } 在JSON数据格式中最常见了,[ ] 一般封装一个数组,{ } 一般封装一个整体对象。

NSNumber

一般写法是这样的:

NSNumber *intNumber = [NSNumber numberWithInt:123];
NSNumber *floatNumber = [NSNumber numberWithFloat:12.3];
NSNumber *charNumber = [NSNumber numberWithChar:@('a')];

语法糖简化写法:

NSNumber *a = @123;
NSNumber *b = @12.3;
NSNumber *c = @('a');
NSLog(@"a = %@, b = %@, c = %@", a, b, c);

. 点语法

再用数组 NSArray *array = @[@"lu", @"da", @"shi", @YES, @123]; 举例。想要获取数组中有多少个元素,我们平时都是怎么做的?
[array count] 还是 array.count?

老司机们思索片刻后说到:好像都用过,但是...我们知道在 OC 中 []. 分别代表调用方法和属性,看申明明明是属性呀,怎么可以用 . 方法?

从 OC 2.0 开始只要符合系统默认 settergetter 书写格式的方法都可以使用 . 点语法,属性是一对 gettersetter 方法,点语法是属性的另一种调用格式,就是语法糖方法。这么做的目的只有一个,就是增加可读性、兼容常见用法减少代码报错!

OC语法糖带来的'坑'

一般我们认为语法糖带来了方便,特别是对于字典的初始化 直接是key:value的赋值方式比 dictionaryWithObjectsAndKeys 这种反人类的方式友好的多。

但真的没有其他什么问题吗?看下下面两个定义及运行结果:
屏幕快照_2017-11-20_上午10.08.23

屏幕快照_2017-11-20_上午10.08.39

可见 dictionaryWithObjectsAndKeys 如果遇到 value 为 nil 的情况,后续 key-value 不会入库直接当做末尾 nil 结束初始化;
而语法糖的方式就直接崩溃了,对崩溃了。

所以使用语法糖还需要注意数据合法性问题,nil 会造成意外的崩溃哦!

基本原理

语法糖就是语言中的一个构件,当去掉该构件后并不影响语言的功能和表达能力。而使用语法糖语言的处理器,包括编译器,静态分析器等,会在处理之前把语法糖构件转换成加糖之前的构件,这个过程通常被称为:desugaring。说白了,语法糖就是对现有语法的一个封装,编译运行的时候再脱掉这层糖衣变为老的语法实现。

写在最后

  1. 之所以叫「语法」糖,不只是因为加糖后的代码功能与加糖前保持一致,更重要的是糖在不改变其所在位置的语法结构的前提下,实现了运行时等价。可以简单理解为,加糖后的代码编译后跟加糖前一摸一样。
  2. 之所以叫语法「糖」,是因为加糖后的代码写起来很爽,
    包括但不限于:代码更简洁流畅,代码更语义自然,写得爽,看着爽!
  3. 「糖」也可能有毒,使用需谨慎。
0