文章目录

  • 1 原始API
  • 1.1 卷积层
  • 1.1.1 命名空间与变量名
  • 1.1.2 权重变量的定义
  • 1.1.3 偏置项变量的定义
  • 1.1.4 卷积操作的定义
  • 1.1.5 加偏置操作的定义
  • 1.1.6 激活操作的定义
  • 1.2 池化层
  • 1.3 完整样例
  • 1.3.1 完整样例1
  • 1.3.2 完整样例2
  • 2 TensorFlow-Slim API(推荐使用)
  • 2.1 slim.conv2d()
  • 2.2 slim.max_pool2d()
  • 2.3 slim.fully_connected()
  • 2.4 slim.arg_scope()
  • 2.5 slim.repeat()
  • 2.6 slim.stack()


1 原始API

1.1 卷积层

搭建一个卷积层的完整结构为:

with tf.variable_scope('layer1_conv1'):
	# 定义卷积层参数
    conv1_weights = tf.get_variable("weights", [5, 5, 3, 32], initializer=tf.truncated_normal_initializer(stddev=0.1))
    conv1_biases = tf.get_variable("bias", [32],  initializer=tf.constant_initializer(0.0))

    # 卷积层前向传播
    # input_tensor 是当前卷积层的输入张量
    conv1 = tf.nn.conv2d(input_tensor, conv1_weights, strides=[1, 2, 2, 1], padding='SAME')
    relu1 = tf.nn.relu(tf.nn.bias_add(conv1, conv1_biases))

1.1.1 命名空间与变量名

  • tf.variable_scope()用来提供变量的命名空间
  • tf.get_variable()用来创建卷积核的权重变量和偏置项变量

这两个函数搭配使用,有一个很重要的好处:通过使用不同的命名空间来隔离不同层的变量,这可以让每一层中的变量命名只需要考虑在当前层的作用,而不需要担心重名的问题。

直接看例子:

import tensorflow as tf
with tf.variable_scope("layer1"):
    layer1_weights = tf.get_variable("weights", [2, 3], initializer=tf.truncated_normal_initializer(stddev=0.1))
with tf.variable_scope("layer2"):
    layer2_weights = tf.get_variable("weights", [2, 3], initializer=tf.truncated_normal_initializer(stddev=0.1))

print(layer1_weights.name)
print(layer2_weights.name)

输出结果:

layer1/weights:0
layer2/weights:0

分析:虽然在定义layer1_weights和layer2_weights时,给两者都赋予名字"weights",但它们属于不同的命名空间,所以最终的名字是:“命名空间/weights:0”。

1.1.2 权重变量的定义

权重变量的定义代码如下:

conv1_weights = tf.get_variable("weights", [5, 5, 3, 32], initializer=tf.truncated_normal_initializer(stddev=0.1))

第一个参数是名字,第三个参数是权重变量的初始化方法,最重要的是第二个参数,即权重变量的shape。

卷积层的权重变量是一个四维张量:

  • 前两个维度是卷积核的尺寸(上述代码的卷积核尺寸为5*5)
  • 第三个维度是当前层的深度,这个维度取决于当前卷积层输入张量的深度(上述代码的输入张量深度为3)
  • 第四个维度是卷积核的深度,这个维度决定了当前卷积层输出张量的深度(上述代码的输出张量深度为32),一般来说就是下一层输入张量的深度。

1.1.3 偏置项变量的定义

偏置项变量的定义代码如下:

conv1_biases = tf.get_variable("bias", [32],  initializer=tf.constant_initializer(0.0))

第一个参数是名字,第三个参数是 偏置项变量的初始化方法,最重要的是第二个参数,即 偏置项变量的shape。

卷积层的偏置项变量是一个一维张量,该维度表示卷积核的深度(与权重变量的第四个维度相同,因为卷积层不同位置的偏置项是共享的)

1.1.4 卷积操作的定义

卷积操作的定义代码如下:

conv1 = tf.nn.conv2d(input_tensor, conv1_weights, strides=[1, 2, 2, 1], padding='SAME')
  • input_tensor 是当前卷积层的输入张量,它是一个四维张量,第一个维度对应一个输入batch,其它三个维度对应一个节点矩阵。比如在输入层,input_tensor [0, :, :, :]表示第一张图片;input_tensor [1, :, :, :]表示第二张图片。
  • conv1_weights 是当前卷积层的权重变量
  • strides 在不同维度上的卷积步长,虽然该参数提供的是一个四维数组,但是第一个维度和第四个维度要求一定是1。这是因为卷积层的步长只对矩阵的长和宽有效(只对input_tensor的第二个维度和第三个维度有效)
  • padding 填充方法。'SAME’为全0填充,'VALID’表示不填充。

1.1.5 加偏置操作的定义

加偏置操作的定义代码如下:

tf.nn.bias_add(conv1, conv1_biases)

注意:卷积层的加偏置操作与全连接层的加偏置操作是不同的。

  • 全连接层的偏置项数目与当前层的节点数是相同的,所以可以直接相加(+)。
  • 卷积层的矩阵是多维的,矩阵上不同位置上的节点都需要加上同样的偏置,而偏置项是一维的,所以不能直接相加,只能使用函数tf.nn.bias_add()

1.1.6 激活操作的定义

使用ReLU激活函数进行激活操作的定义代码如下:

relu1 = tf.nn.relu()

1.2 池化层

搭建一个最大池化层的完整结构为:

with tf.name_scope('layer2_pool1'):
        pool1 = tf.nn.max_pool(relu1, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding='SAME')

由于池化层没有用于训练的参数变量(只有超参数,如池化核尺寸、池化步长、是否全0填充)
tf.nn.max_pool()的第一个参数是池化层输入张量,其格式与tf.nn.conv2d()的第一个参数相同。

  • ksize 是池化核尺寸,第一维和第四维要求一定为1,第二维和第三维才是池化核真正的尺寸。
  • strides 是池化步长,第一维和第四维要求一定为1,第二维和第三维是池化核真正的步长。

1.3 完整样例

一般的网络结构由若干个卷积-池化层后接若干个全连接层组成,所以典型的网络结构完整样例为:

1.3.1 完整样例1

import tensorflow as tf


def inference(input_tensor, train, regularizer):
    """
    定义神经网络参数、结构和前向传播过程
    """
    with tf.variable_scope('layer1_conv1'):
        conv1_weights = tf.get_variable("weights", [5, 5, 1, 32], initializer=tf.truncated_normal_initializer(stddev=0.1))
        conv1_biases = tf.get_variable("bias", [32], initializer=tf.constant_initializer(0.0))

        # 使用边长为5,深度为32的过滤器,移动步长为1,使用全0填充
        conv1 = tf.nn.conv2d(input_tensor, conv1_weights, strides=[1, 1, 1, 1], padding='SAME')
        relu1 = tf.nn.relu(tf.nn.bias_add(conv1, conv1_biases))

    with tf.name_scope('layer2_pool1'):
        # 使用2*2过滤器,步长为2
        pool1 = tf.nn.max_pool(relu1, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding='SAME')

    with tf.variable_scope('layer3_conv2'):
        conv2_weights = tf.get_variable(
            "weights", [5, 5, 32, 64],
            initializer=tf.truncated_normal_initializer(stddev=0.1))
        conv2_biases = tf.get_variable("bias", [64], initializer=tf.constant_initializer(0.0))
        # 使用边长为5,深度为64的过滤器,移动步长为1,使用全0填充
        conv2 = tf.nn.conv2d(pool1, conv2_weights, strides=[1, 1, 1, 1], padding='SAME')
        relu2 = tf.nn.relu(tf.nn.bias_add(conv2, conv2_biases))

    with tf.name_scope('layer4_pool2'):
        # 使用2*2过滤器,步长为2
        pool2 = tf.nn.max_pool(relu2, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding='SAME')

    # 数据维度转换
    pool_shape = pool2.get_shape().as_list()
    nodes = pool_shape[1] * pool_shape[2] * pool_shape[3]
    reshaped = tf.reshape(pool2, [pool_shape[0], nodes])

    with tf.variable_scope('layer5_fc1'):
        fc1_weights = tf.get_variable("weights", [nodes, 512],
                                      initializer=tf.truncated_normal_initializer(stddev=0.1))
        if regularizer:
            tf.add_to_collection('losses', regularizer(fc1_weights))
        fc1_biases = tf.get_variable("bias", [512], initializer=tf.constant_initializer(0.0))
        fc1 = tf.nn.relu(tf.matmul(reshaped, fc1_weights)+fc1_biases)
        if train:
            fc1 = tf.nn.dropout(fc1, 0.5)

    with tf.variable_scope('layer6_fc2'):
        fc2_weights = tf.get_variable("weights", [512, 10],
                                      initializer=tf.truncated_normal_initializer(stddev=0.1))
        if regularizer:
            tf.add_to_collection('losses', regularizer(fc2_weights))
        fc2_biases = tf.get_variable("bias", [10], initializer=tf.constant_initializer(0.0))
        logit = tf.matmul(fc1, fc2_weights)+fc2_biases

    return logit

1.3.2 完整样例2

完整样例1存在一个问题:当卷积层、池化层和全连接层较多时,代码会显得非常冗余。解决方法是定义卷积层函数、池化层函数、全连接层函数,然后调用这些函数来搭建神经网络,这样就得到了完整样例2。

import tensorflow as tf

def create_conv(input_tensor, conv_size, conv_stride, padding, layer_name):
    """
    搭建卷积层
    :param input_tensor: 输入张量,shape = [batch_size, height, width, channels]
    :param conv_size: 卷积核尺寸和深度,eg: conv_size = [3, 3,64]
    :param conv_stride: 卷积步长,eg: conv_stride = [2, 2]
    :param padding: 填充方式,只有两种填充方式: padding = 'SAME' or padding = 'VALID'
    :param layer_name: 卷积层命名空间的名字,str
    :return: layer_output: 卷积层输出张量
    """
    with tf.variable_scope(layer_name):
        input_shape = input_tensor.get_shape().as_list()
        weights = tf.get_variable("weights", [conv_size[0], conv_size[1], input_shape[3], conv_size[2]],
                                  initializer=tf.truncated_normal_initializer(stddev=0.1))
        biases = tf.get_variable("bias", [conv_size[2]], initializer=tf.constant_initializer(0.0))

        conv = tf.nn.conv2d(input_tensor, weights, strides=[1, conv_stride[0], conv_stride[1], 1], padding=padding)
        layer_output = tf.nn.relu(tf.nn.bias_add(conv, biases))
    return layer_output


def create_pool(input_tensor, pool_size, pool_stride, pool_type, padding, layer_name):
    """
    搭建池化层
    :param input_tensor: 输入张量,shape = [batch_size, height, width, channels]
    :param pool_size: 池化核尺寸,eg: pool_size = [2, 2]
    :param pool_stride: 池化步长,eg: pool_stride = [2, 2]
    :param pool_stride: 池化步长,eg: pool_stride = [2, 2]
    :param pool_type: 池化类型,只有两种池化方式: padding = 'MAX' or padding = 'VALID'
    :param padding: 填充方式,只有两种填充方式: padding = 'SAME' or padding = 'VALID'
    :param layer_name: 池化层命名空间的名字,str
    :return: layer_output: 池化层输出张量
    """
    with tf.name_scope(layer_name):
        if pool_type == 'MAX':
            layer_output = tf.nn.max_pool(input_tensor, ksize=[1, pool_size[0], pool_size[1], 1],
                                          strides=[1, pool_stride[0], pool_stride[1], 1], padding=padding)
        else:
            layer_output = tf.nn.avg_pool(input_tensor, ksize=[1, pool_size[0], pool_size[1], 1],
                                          strides=[1, pool_stride[0], pool_stride[1], 1], padding=padding)

    return layer_output


def create_fc(input_tensor, num_output, train, activate_function, regularizer, layer_name):
    """
    搭建全连接层
    :param input_tensor: 输入张量,shape = [batch_size, num_input]
    :param num_output: 当前全连接层的节点数
    :param train: 是否用于训练,bool
    :param activate_function: 激活函数
    :param regularizer: 正则化
    :param layer_name: 池化层命名空间的名字,str
    :return: layer_output: 池化层输出张量
    """
    with tf.variable_scope(layer_name):
        input_shape = input_tensor.get_shape().as_list()
        weights = tf.get_variable("weights", [input_shape[1], num_output],
                                  initializer=tf.truncated_normal_initializer(stddev=0.1))
        if regularizer:
            tf.add_to_collection('losses', regularizer(weights))
        biases = tf.get_variable("bias", [num_output], initializer=tf.constant_initializer(0.0))
        if activate_function:
            fc = tf.nn.relu(tf.matmul(input_tensor, weights)+biases)
        else:
            fc = tf.matmul(input_tensor, weights) + biases
        if train:
            fc = tf.nn.dropout(fc, 0.5)
        layer_output = fc
    return layer_output


def inference(input_tensor, train, regularizer):
    """
    定义神经网络参数、结构和前向传播过程
    """
    conv1 = create_conv(input_tensor, [5, 5, 32], [1, 1], 'SAME', 'layer1_conv1')
    pool1 = create_pool(conv1, [2, 2], [2, 2], 'MAX', 'SAME', 'layer2_pool1')
    conv2 = create_conv(pool1, [5, 5, 64], [1, 1], 'SAME', 'layer3_conv2')
    pool2 = create_pool(conv2, [2, 2], [2, 2], 'MAX', 'SAME', 'layer4_pool2')

    # 数据维度转换
    reshaped_pool2 = tf.layers.flatten(pool2)

    fc1 = create_fc(reshaped_pool2, 512, train, "relu", regularizer, 'layer5_fc1')
    logit = create_fc(fc1, 10, train, None, regularizer, 'layer6_fc2')

    return logit

实际上,该封装方式与下面要讲的slim库是非常相似的。

2 TensorFlow-Slim API(推荐使用)

关于TensorFlow-Slim API,有一篇博客写得很详细: TensorFlow-Slim API 官方教程 * * * * *.
我们这里主要讲解使用TensorFlow-Slim来搭建神经网络的结构。
首先给出一个VGG16的TF-Slim实现版本,让大家感受一下使用TF-Slim库来搭建神经网络结构的便捷性:

def vgg16(inputs):
  with slim.arg_scope([slim.conv2d, slim.fully_connected],
                      activation_fn=tf.nn.relu,
                      weights_initializer=tf.truncated_normal_initializer(0.0, 0.01),
                      weights_regularizer=slim.l2_regularizer(0.0005)):
    net = slim.repeat(inputs, 2, slim.conv2d, 64, [3, 3], scope='conv1')
    net = slim.max_pool2d(net, [2, 2], scope='pool1')
    net = slim.repeat(net, 2, slim.conv2d, 128, [3, 3], scope='conv2')
    net = slim.max_pool2d(net, [2, 2], scope='pool2')
    net = slim.repeat(net, 3, slim.conv2d, 256, [3, 3], scope='conv3')
    net = slim.max_pool2d(net, [2, 2], scope='pool3')
    net = slim.repeat(net, 3, slim.conv2d, 512, [3, 3], scope='conv4')
    net = slim.max_pool2d(net, [2, 2], scope='pool4')
    net = slim.repeat(net, 3, slim.conv2d, 512, [3, 3], scope='conv5')
    net = slim.max_pool2d(net, [2, 2], scope='pool5')
    net = slim.fully_connected(net, 4096, scope='fc6')
    net = slim.dropout(net, 0.5, scope='dropout6')
    net = slim.fully_connected(net, 4096, scope='fc7')
    net = slim.dropout(net, 0.5, scope='dropout7')
    net = slim.fully_connected(net, 1000, activation_fn=None, scope='fc8')
  return net

2.1 slim.conv2d()

slim.conv2d()用于搭建卷积层,其函数定义如下:

slim.conv2d(inputs,
            num_outputs,
            kernel_size,
            stride=1,
            padding='SAME',
            data_format=None,
            rate=1,
            activation_fn=nn.relu,
            normalizer_fn=None,
            normalizer_params=None,
            weights_initializer=initializers.xavier_initializer(),
            weights_regularizer=None,
            biases_initializer=init_ops.zeros_initializer(),
            biases_regularizer=None,
            reuse=None,
            variables_collections=None,
            outputs_collections=None,
            trainable=True,
            scope=None)
  • inputs : 指需要做卷积的输入图像
  • num_outputs : 指定卷积核的个数(就是filter的- 个数)
  • kernel_size : 用于指定卷积核的维度(卷积核的宽度,卷积核的高度)
  • stride : 为卷积时在图像每一维的步长
  • padding : 为padding的方式选择,VALID或者SAME
  • data_format : 是用于指定输入输出张量的形状,默认为None,即“NHWC”,即[batch_size, height, width, channels];还有另一种为“NCHW”,即[batch_size, channels, height, width]。
  • rate : 对于使用空洞卷积的膨胀率,rate等于1为普通卷积,rate=n代表卷积核中两两数之间插入了n-1个0
  • activation_fn : 用于激活函数的指定,默认的为ReLU函数
  • normalizer_fn : 用于指定正则化函数
  • normalizer_params : 用于指定正则化函数的参数
  • weights_initializer : 用于指定权重的初始化程序
  • weights_regularizer : 为权重可选的正则化程序
  • biases_initializer : 用于指定biase的初始化程序
  • biases_regularizer : biases可选的正则化程序
  • reuse : 指定是否共享层或者和变量
  • variable_collections 指定所有变量的集合列表或者字典
  • outputs_collections : 指定输出被添加的集合
  • trainable : 卷积层的参数是否可被训练
  • scope : 共享变量所指的variable_scop

2.2 slim.max_pool2d()

slim.max_pool2d()用于搭建最大池化层,其函数定义如下:

slim.max_pool2d(inputs,
                kernel_size,
                stride=2,
                padding='VALID',
                data_format=DATA_FORMAT_NHWC,
                outputs_collections=None,
                scope=None)

slim.max_pool2d()各参数与slim.conv2d()均相同。

2.3 slim.fully_connected()

slim.fully_connected()用于搭建全连接层,其函数定义如下:

slim.fully_connected(inputs,
                     num_outputs,
                     activation_fn=nn.relu,
                     normalizer_fn=None,
                     normalizer_params=None,
                     weights_initializer=initializers.xavier_initializer(),
                     weights_regularizer=None,
                     biases_initializer=init_ops.zeros_initializer(),
                     biases_regularizer=None,
                     reuse=None,
                     variables_collections=None,
                     outputs_collections=None,
                     trainable=True,
                     scope=None):
  • inputs: A tensor of at least rank 2 and static value for the last dimension;
    i.e. [batch_size, depth], [None, None, None, channels].
  • num_outputs: Integer or long, the number of output units in the layer.
  • 其他参数与slim.conv2d()一样。

Note: that if inputs have a rank greater than 2, then inputs is flattened prior to the initial matrix multiply by weights.

所以在第2节最开始的VGG16结构中,pool5的输出直接作为fc6的输入,因为slim.fully_connected()会自动flatten。

2.4 slim.arg_scope()

有了以上3个函数,你完全可以搭建任何你想要的神经网络,只是有一个问题:这3个函数的参数非常之多,并且这3个函数有很多相同的参数,这会造成代码冗余、代码的可读性和维护性差,不信你看:

net = slim.conv2d(inputs, 64, [11, 11], 4, padding='SAME',
                  weights_initializer=tf.truncated_normal_initializer(stddev=0.01),
                  weights_regularizer=slim.l2_regularizer(0.0005), scope='conv1')
net = slim.conv2d(net, 128, [11, 11], padding='VALID',
                  weights_initializer=tf.truncated_normal_initializer(stddev=0.01),
                  weights_regularizer=slim.l2_regularizer(0.0005), scope='conv2')
net = slim.conv2d(net, 256, [11, 11], padding='SAME',
                  weights_initializer=tf.truncated_normal_initializer(stddev=0.01),
                  weights_regularizer=slim.l2_regularizer(0.0005), scope='conv3')

很明显,这三个卷积层共享很多相同的超参数。两个有相同的 padding,三个都有相同的 weights_initializerweight_regularizer。这段代码很难读,并且包含了很多重复的值。一个解决方案是使用变量指定默认值:

padding = 'SAME'
initializer = tf.truncated_normal_initializer(stddev=0.01)
regularizer = slim.l2_regularizer(0.0005)
net = slim.conv2d(inputs, 64, [11, 11], 4,
                  padding=padding,
                  weights_initializer=initializer,
                  weights_regularizer=regularizer,
                  scope='conv1')
net = slim.conv2d(net, 128, [11, 11],
                  padding='VALID',
                  weights_initializer=initializer,
                  weights_regularizer=regularizer,
                  scope='conv2')
net = slim.conv2d(net, 256, [11, 11],
                  padding=padding,
                  weights_initializer=initializer,
                  weights_regularizer=regularizer,
                  scope='conv3')

这个解决方案保证了三个卷积层拥有相同的参数值,但代码仍不够清晰。通过使用一个arg_scope,我们能够在保证每一层使用相同参数值的同时,简化代码:

with slim.arg_scope([slim.conv2d], padding='SAME',
                      weights_initializer=tf.truncated_normal_initializer(stddev=0.01)
                      weights_regularizer=slim.l2_regularizer(0.0005)):
    net = slim.conv2d(inputs, 64, [11, 11], scope='conv1')
    net = slim.conv2d(net, 128, [11, 11], padding='VALID', scope='conv2')
    net = slim.conv2d(net, 256, [11, 11], scope='conv3')

如上例所示,使用arg_scope使代码更清晰、简单并且容易去维护。arg_scope允许用户给一个或多个 op 指定一套默认参数,这些默认参数将被传给arg_scope里使用的的每一个 op。
注意,在arg_scope内部指定op的参数值时,指定的参数将取代默认参数。具体来讲,上例中,当 padding 参数的默认值被设置为 ‘SAME’ 时,第二个卷积的 padding 参数被指定为 ‘VALID’。

2.5 slim.repeat()

允许用户重复地进行(perform)相同的操作(operation)。例如,考虑下面的代码段(来自 VGG 网络,它的 layers 在两个 pooling 层之间进行了很多 conv):

net = ...
net = slim.conv2d(net, 256, [3, 3], scope='conv3_1')
net = slim.conv2d(net, 256, [3, 3], scope='conv3_2')
net = slim.conv2d(net, 256, [3, 3], scope='conv3_3')
net = slim.max_pool2d(net, [2, 2], scope='pool2')

一个减少代码重复的方法是使用 for 循环:

net = ...
for i in range(3):
  net = slim.conv2d(net, 256, [3, 3], scope='conv3_%d' % (i+1))
net = slim.max_pool2d(net, [2, 2], scope='pool2')

使用slim.repeat() 可以使上面的代码变得更清晰明了:

net = slim.repeat(net, 3, slim.conv2d, 256, [3, 3], scope='conv3')
net = slim.max_pool2d(net, [2, 2], scope='pool2')

注意:slim.repeat 不仅对 repeated 单元采用相同的参数,而且它对 repeated 单元的 scope 采用更好的命名方式(加下划线,再加迭代序号)。具体来说,上面例子中的 scopes 将会命名为 ‘conv3/conv3_1’,‘conv3/conv3_2’,‘conv3/conv3_3’

2.6 slim.stack()

更进一步,Slim 的slim.stack允许去重复多个操作 with 不同的参数,从而创建一个多层的堆叠结构。slim.stack也为每一个创建的 op 创造了一个新的 tf.variable_scope。例如,创建一个多层感知器(Multi-Layer Perceptron (MLP))的一个简单方式:

# Verbose way: 冗长的方式
x = slim.fully_connected(x, 32, scope='fc/fc_1')
x = slim.fully_connected(x, 64, scope='fc/fc_2')
x = slim.fully_connected(x, 128, scope='fc/fc_3')

# Equivalent, TF-Slim way using slim.stack:
x = slim.stack(x, slim.fully_connected, [32, 64, 128], scope='fc')

在这个例子中,slim.stack调用slim.fully_connected三次,并将函数上一次调用的输出传递给下一次调用。但是,在每个调用中,隐形单元(hidden units)的数量分别为 32,64,128。相似地,我们可以使用 stack 去简化多层卷积的堆叠:

# Verbose way: 冗长的方式
x = slim.conv2d(x, 32, [3, 3], scope='core/core_1')
x = slim.conv2d(x, 32, [1, 1], scope='core/core_2')
x = slim.conv2d(x, 64, [3, 3], scope='core/core_3')
x = slim.conv2d(x, 64, [1, 1], scope='core/core_4')

# Using stack:
x = slim.stack(x, slim.conv2d, [(32, [3, 3]), (32, [1, 1]), (64, [3, 3]), (64, [1, 1])], scope='core')

参考资料:
[1]: TensorFlow实战Google深度学习框架(第2版)
[2]: TensorFlow-Slim API 官方教程 * * * * *.