SDK目标是优先APIv3版,当然也需要考虑当下,APIv2还在并行运行。两者之间有共性也有特性,把共性部分抽象出来,当属格式化参数部分。随机字符串首当其冲,那就从这个函数实现开始吧。

Formatter::nonce 随机字符串产生器

本博发布之日,这个函数已经迭代了一个版本,并且加入了测试用例覆盖,最终形态如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?php
/**
 * Generate a random ASCII string aka `nonce`, similar as `random_bytes`.
 *
 * @param int $size - Nonce string length, default is 32.
 *
 * @return string - base62 random string.
 */
public static function nonce(int $size = 32): string
{
    return array_reduce(range(1, $size), static function(string $char) {
        return $char .= 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'[mt_rand(0, 61)];
    }, '');
}

本来是可以一行显示的,为了阅读起来方便,特意做了格式化,这个函数使用了PHP内置的三个函数array_reduce, rangemt_rand,并且使用了 Closure Static Anonymous Functions。 此函数的设计思路是:根据入参$size,先构建一个堆栈,然后从 Base62 字符串内随机取个数,填充堆栈并合并返回。

上一版是这么实现的:

1
2
3
4
5
6
7
8
9
<?php
const BASE62_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

public static function nonce(int $size = 32): string
{
    return preg_replace_callback('#0#', static function() {
        return BASE62_CHARS[rand(0, 61)];
    }, str_repeat('0', $size));
}

使用了 preg_replace_callback randstr_repeat 三个内置函数,同样的设计思路,不过有个缺陷,即 $size 型参必须大于0,这个是受限 str_repeat 型参要求。

两个函数其实都备注有与PHP内置的 random_bytes 函数相似,而 random_bytes 也存在型参 $size 必须大于0的要求,其返回值是 Base36 的。

最终选择 array_reduce+range+mt_rand 的组合,这里是做了性能测试的,测试代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// file: cli.php, run: php -f cli.php
<?php
const BASE62_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

$start = microtime(true);
$a = '';
for ($i = 0; $i < 50; $i++) {
    $a .= BASE62_CHARS[mt_rand(0, 61)];
}
printf(
    '[%30s] Time: %.7f s  %-32.32s %s', 'for loop',
    microtime(true) - $start, $a, PHP_EOL
);

$start = microtime(true);
$a = preg_replace_callback('#0#', static function() {
    return BASE62_CHARS[rand(0, 61)];
}, str_repeat('0', 50));
printf(
    '[%30s] Time: %.7f s  %-32.32s %s', 'preg_replace_callback',
    microtime(true) - $start, $a, PHP_EOL
);

$start = microtime(true);
$a = array_reduce(range(1, 50), static function(string $c) {
    return $c .= '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'[random_int(0, 61)];
}, '');
printf(
    '[%30s] Time: %.7f s  %-32.32s %s', 'array_reduce/random_int',
    microtime(true) - $start, $a, PHP_EOL
);

$start = microtime(true);
$a = array_reduce(range(1, 50), static function(string $c) {
    return $c .= '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'[mt_rand(0, 61)];
}, '');
printf(
    '[%30s] Time: %.7f s  %-32.32s %s', 'array_reduce/mt_rand',
    microtime(true) - $start, $a, PHP_EOL
);

$start = microtime(true);
$a = substr(bin2hex(random_bytes(50)), 0, 50);
printf(
    '[%30s] Time: %.7f s  %-32.32s %s', 'random_bytes',
    microtime(true) - $start, $a, PHP_EOL
);

跑上四圈的结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[                      for loop] Time: 0.0004230 s  twqdlCDCyMknrFMJEjPfl94SdFK2a2Km
[         preg_replace_callback] Time: 0.0035770 s  aTb7yrpFVitQju7sKAgofTuiSsiw1Top
[       array_reduce/random_int] Time: 0.0001869 s  9IkLR7HEryOy8zlzuxpVIBpttpprlNib
[          array_reduce/mt_rand] Time: 0.0000098 s  7j2F2stRXiHvAxQ4j2IhplHXCHMGtl9j
[   substr/bin2hex/random_bytes] Time: 0.0000100 s  e6e33fd212b3083bfc4ebb29a13710b1


[                      for loop] Time: 0.0000420 s  UppY2CoJOVSmHE0EkynhyOeqaWdvSGJ5
[         preg_replace_callback] Time: 0.0002630 s  s64pxnpKALGq16HxnbCbKh0NGgRq8Ou2
[       array_reduce/random_int] Time: 0.0001841 s  7yYaGsWoBudf5PKlz2OkdodGrN05OkQo
[          array_reduce/mt_rand] Time: 0.0000188 s  0ruErA844vf9CifmfhEiyjoUVcbgv3yy
[   substr/bin2hex/random_bytes] Time: 0.0000091 s  db57e8c90751d00b76da5d553f54c950


[                      for loop] Time: 0.0000441 s  0hZgmb4EeYuL14FDd0UIkbjiNyjGjZxy
[         preg_replace_callback] Time: 0.0002651 s  BMoSsbN2N7ObEDmqpgtTKKtGdMiUuV4U
[       array_reduce/random_int] Time: 0.0001869 s  7cGZKBUGiZL6v594o5k6wtQTmm5I7IYq
[          array_reduce/mt_rand] Time: 0.0000200 s  leXlVr5aJ8zmhbn9kk27D3bpy4FJ6Mhy
[   substr/bin2hex/random_bytes] Time: 0.0000100 s  7c9de4e1ee533aa3c9ddecf78667afaf


[                      for loop] Time: 0.0000479 s  OpqudO593AKmRROllDX8h0tjuBzLXPQZ
[         preg_replace_callback] Time: 0.0002871 s  oXoJEBRt0Qpy5jXUNDnAapblbXhpBFI3
[       array_reduce/random_int] Time: 0.0002170 s  SPdIjbbiI3wKLrMCnAiurpBdnxeJsip6
[          array_reduce/mt_rand] Time: 0.0000169 s  bKygHiTtIj6LsnmWxRKXTtSpr1oIdkBJ
[   substr/bin2hex/random_bytes] Time: 0.0000169 s  f8c4105678ba0e97f41fbb9197332f28

其中 preg_replace_callback 组合是最慢的, array_reduce/mt_rand 组合与最快的 random_bytes 很接近。

array_reduce/mt_rand 接受的入参可以是负数,也可以是正数,从严谨性上来取舍,遂选择了这个组合。 测试用例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php
public function nonceRulesProvider(): array
{
    return [
        'default $size=32'       => [32,  '/[a-zA-Z0-9]{32}/'],
        'half-default $size=16'  => [16,  '/[a-zA-Z0-9]{16}/'],
        'hundred $size=100'      => [100, '/[a-zA-Z0-9]{100}/'],
        'one $size=1'            => [1,   '/[a-zA-Z0-9]{1}/'],
        'zero $size=0'           => [0,   '/[a-zA-Z0-9]{2}/'],
        'negative $size=-1'      => [-1,  '/[a-zA-Z0-9]{3}/'],
        'negative $size=-16'     => [-16, '/[a-zA-Z0-9]{18}/'],
        'negative $size=-32'     => [-32, '/[a-zA-Z0-9]{34}/'],
    ];
}
/**
 * @dataProvider nonceRulesProvider
 */
public function testNonce(int $size, string $pattern): void
{
    $nonce = Formatter::nonce($size);

    self::assertIsString($nonce);

    self::assertTrue(strlen($nonce) === ($size > 0 ? $size : abs($size - 2)));

    if (method_exists($this, 'assertMatchesRegularExpression')) {
        self::assertMatchesRegularExpression($pattern, $nonce);
    } else {
        self::assertRegExp($pattern, $nonce);
    }
}

适应性上堪称完美。

一个重要提示: 按照PHP手册上提示,mt_rand是密码学不安全的函数,这里也做一并提示 Formatter::nonce() 也是密码学不安全实现。本类库在使用时,仅当salt(盐)使用,扩展使用时,请注意使用场景。

Formatter::timestamp 时间戳

这个函数是对time()的一个及其简单的一个封装。之所以要封装,其实是有一点点说法的。按照微信支付官方开发文档说明,时间戳是自1970年1月1日起的Unix timesamp,即 Epoch timesamp。PHP内置time()函数就是这个值。其他平台,有见到把timesamp翻译成yyyy-MM-dd HH:mm:ss格式的字符串,做这么个封装的原因:

  1. 对函数返回值做类型签名,严格区分其他平台的翻译;
  2. PHP的命名空间namespace,存在FQN引用,自PHP7开始,在命名空间下可以use function,为让代码通俗易懂,在一个地方引用内置函数,比多次引用要直观;

代码块如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php
/**
 * Retrieve the current `Unix` timestamp.
 *
 * @return int - Epoch timestamp.
 */
public static function timestamp(): int
{
    return time();
}

测试用例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php
public function testTimestamp(): void
{
    $timestamp = Formatter::timestamp();
    $pattern = '/^1[0-9]{9}/';

    self::assertIsInt($timestamp);

    $timestamp = strval($timestamp);

    self::assertTrue(strlen($timestamp) === 10);

    if (method_exists($this, 'assertMatchesRegularExpression')) {
        self::assertMatchesRegularExpression($pattern, $timestamp);
    } else {
        self::assertRegExp($pattern, $timestamp);
    }
}

1开头的10位纯数字,记住了,这就是 Unix timestamp

Formatter::authorization 认证值

这个函数,官方文档说的很详细了,但是内涵相当多的知识点,没有明说(哲学:我认为你应该知道所以我不讲了),这个函数就是其中之一。翻MDN,引申有说明,RFC 7235, section 4.2: Authorization:只要符合规范,厂商可自主实现<type> <credentials>

微信支付 APIv3 即以 WECHATPAY2-SHA256-RSA2048 声明 type 变量,mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s" 组合实现 credentials 声明。

代码块如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<?php
/**
 * Formatting for the heading `Authorization` value.
 *
 * @param string $mchid - The merchant ID.
 * @param string $nonce - The Nonce string.
 * @param string $signature - The base64-encoded `Rsa::sign` ciphertext.
 * @param string $timestamp - The `Unix` timestamp.
 * @param string $serial - The serial number of the merchant public certification.
 *
 * @return string - The APIv3 Authorization `header` value
 */
public static function authorization(string $mchid, string $nonce, string $signature, string $timestamp, string $serial): string
{
    return sprintf(
        'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"',
        $mchid, $nonce, $signature, $timestamp, $serial
    );
}

那些官方没有明说的知识点就来了:

  1. 商户号 mchid 是1至32字符的base62字符串,当前绝大部分商户号是纯数字;
  2. 请求随机串 nonce_str 只要能通过HTTP上送的字符均可,建议是至少16字符的base62字符串;
  3. 签名值 signaturebase64字符串,base64末尾有可能有0个、1个或者2个=号,这都是正常的base64字符串;
  4. 时间戳 timestampunix timestamp,如 Formatter::timestamp() 封装所述;
  5. 商户API证书 serial_no 是8至40字符的【0-9A-Z]全大写字符串;
  6. 认证值有字典要求,即 mchid,nonce,signature,timestamp,serial 即这5个必须出现,排列组合顺序任意,值的组合用半角逗号(,)分隔,严格没有空格;

我们用测试用例覆盖来校验如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
public function testAuthorization(): void
{
    $value = Formatter::authorization('1001', Formatter::nonce(), 'mock', (string) Formatter::timestamp(), 'mockmockmock');

    self::assertIsString($value);

    self::assertStringStartsWith('WECHATPAY2-SHA256-RSA2048 ', $value);
    self::assertStringEndsWith('"', $value);

    $pattern = '/^WECHATPAY2-SHA256-RSA2048 '
        . 'mchid="[0-9A-Za-z]{1,32}",'
        . 'nonce_str="[0-9A-Za-z]{16,}",'
        . 'signature="[0-9A-Za-z\+\/]+={0,2}",'
        . 'timestamp="1[0-9]{9}",'
        . 'serial_no="[0-9A-Z]{8,40}"$/';

    if (method_exists($this, 'assertMatchesRegularExpression')) {
        self::assertMatchesRegularExpression($pattern, $value);
    } else {
        self::assertRegExp($pattern, $value);
    }
}

PS: 官方文档上,特意说了 认证类型 <type>,目前为 WECHATPAY2-SHA256-RSA2048,畅想应该还会有其他值。所以在APIv3开发的时候,建议还是要多看看官方文档/公告说明,以免 我的代码没动过啊,为什么现在不行了 这类问题产生。

Formatter::request 请求字符串

顾名思义,这个函数就是用来格式化请求参数的,按照官方开发文档介绍,输入参数是需要按照”\n”(char0x0A)做排列合并,而0x0A是个非打印字符,文本描述起来比较困难,另外对于空请求串情况,占位行不能省略,顾做一个纵向封装,以程序语言(函数型参)来描述请求串,代码块如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php
/**
 * Formatting this `HTTP::request` for `Rsa::sign` input.
 *
 * @param string $method - The HTTP verb, must be the uppercase sting.
 * @param string $uri - Combined string with `URL::pathname` and `URL::search`.
 * @param string $timestamp - The `Unix` timestamp, should be the one used in `authorization`.
 * @param string $nonce - The `Nonce` string, should be the one used in `authorization`.
 * @param string $body - The playload string, HTTP `GET` should be an empty string.
 *
 * @return string - The content for `Rsa::sign`
 */
public static function request(string $method, string $uri, string $timestamp, string $nonce, string $body = ''): string
{
    return static::joinedByLineFeed($method, $uri, $timestamp, $nonce, $body);
}

函数入参接受5部分,其中$body可为空,内置驱动 joinedByLineFeed 做参数合并并返回字符串。这里有个点就是对入参 $timestamp 的类型定义,这里用了字符串定义,原因是:合并后的输出是个字符串,输入端就做了*妥协*,当然在非严格限制模式行,用纯数字输入也是可以的。

测试用例覆盖如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php
const LINE_FEED = "\n";

public function requestPhrasesProvider(): array
{
    return [
        'DELETE root(/)' => ['DELETE', '/', ''],
        'DELETE root(/) with query' => ['DELETE', '/?hello=wechatpay', ''],
        'GET root(/)' => ['GET', '/', ''],
        'GET root(/) with query' => ['GET', '/?hello=wechatpay', ''],
        'POST root(/) with body' => ['POST', '/', '{}'],
        'POST root(/) with body and query' => ['POST', '/?hello=wechatpay', '{}'],
        'PUT root(/) with body' => ['PUT', '/', '{}'],
        'PUT root(/) with body and query' => ['PUT', '/?hello=wechatpay', '{}'],
        'PATCH root(/) with body' => ['PATCH', '/', '{}'],
        'PATCH root(/) with body and query' => ['PATCH', '/?hello=wechatpay', '{}'],
    ];
}

/**
 * @dataProvider requestPhrasesProvider
 */
public function testRequest(string $method, string $uri, string $body): void
{
    $value = Formatter::request($method, $uri, (string) Formatter::timestamp(), Formatter::nonce(), $body);

    self::assertIsString($value);

    self::assertStringStartsWith($method, $value);
    self::assertStringEndsWith(LINE_FEED, $value);
    self::assertLessThanOrEqual(substr_count($value, LINE_FEED), 5);

    $pattern = '#^' . $method . LINE_FEED
        .  preg_quote($uri) . LINE_FEED
        . '1[0-9]{9}' . LINE_FEED
        . '[0-9A-Za-z]{32}' . LINE_FEED
        . preg_quote($body) . LINE_FEED
        . '$#';

    if (method_exists($this, 'assertMatchesRegularExpression')) {
        self::assertMatchesRegularExpression($pattern, $value);
    } else {
        self::assertRegExp($pattern, $value);
    }
}

测试用例说明一下,共计十种情形,函数返回值,必须以所请求的 $method 开头,并且至少含有5个LINE_FEED常量,其中一个LINE_FEED在末尾。 用例的数据供给,含了已知的APIv3 HTTP verbs,即:DELETE, GET, POST, PUTPATCH。 按照 rfc3986 规范,DELETEGET是不带请求$body的。

Formatter::response 响应字符串

这个函数是APIv3开发文档上验签逻辑的一个封装,直白代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?php
/**
 * Formatting this `HTTP::response` for `Rsa::verify` input.
 *
 * @param string $timestamp - The `Unix` timestamp, should be the one from `response::headers[Wechatpay-Timestamp]`.
 * @param string $nonce - The `Nonce` string, should be the one from `response::headers[Wechatpay-Nonce]`.
 * @param string $body - The response payload string, HTTP status(`201`, `204`) should be an empty string.
 *
 * @return string - The content for `Rsa::verify`
 */
public static function response(string $timestamp, string $nonce, string $body = ''): string
{
    return static::joinedByLineFeed($timestamp, $nonce, $body);
}

测试用例覆盖如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?php
public function responsePhrasesProvider(): array
{
    return [
        'HTTP 200 STATUS with body' => ['{}'],
        'HTTP 200 STATUS with no body' => [''],
        'HTTP 202 STATUS with no body' => [''],
        'HTTP 204 STATUS with no body' => [''],
        'HTTP 301 STATUS with no body' => [''],
        'HTTP 301 STATUS with body' => ['<html></html>'],
        'HTTP 302 STATUS with no body' => [''],
        'HTTP 302 STATUS with body' => ['<html></html>'],
        'HTTP 307 STATUS with no body' => [''],
        'HTTP 307 STATUS with body' => ['<html></html>'],
        'HTTP 400 STATUS with body' => ['{}'],
        'HTTP 401 STATUS with body' => ['{}'],
        'HTTP 403 STATUS with body' => ['<html></html>'],
        'HTTP 404 STATUS with body' => ['<html></html>'],
        'HTTP 500 STATUS with body' => ['{}'],
        'HTTP 502 STATUS with body' => ['<html></html>'],
        'HTTP 503 STATUS with body' => ['<html></html>'],
    ];
}

/**
 * @dataProvider responsePhrasesProvider
 */
public function testResponse(string $body): void
{
    $value = Formatter::response((string) Formatter::timestamp(), Formatter::nonce(), $body);

    self::assertIsString($value);

    self::assertStringEndsWith(LINE_FEED, $value);
    self::assertLessThanOrEqual(substr_count($value, LINE_FEED), 3);

    $pattern = '#^1[0-9]{9}' . LINE_FEED
        . '[0-9A-Za-z]{32}' . LINE_FEED
        . preg_quote($body) . LINE_FEED
        . '$#';

    if (method_exists($this, 'assertMatchesRegularExpression')) {
        self::assertMatchesRegularExpression($pattern, $value);
    } else {
        self::assertRegExp($pattern, $value);
    }
}

知识点如下:

  1. HTTP请求返回内容,均是纯文本格式,即使是头部内容,解析出来也是字符串形式;
  2. 与请求串格式化函数很像,这里接受3个参数,并且使用内置抽象函数joinedByLineFeed做数据合并;
  3. 官方文档上,仅描述了204状态码时的返回内容为空,其实API接口,也有可能产生202状态码返回,内容也是空的;
  4. 301/302/307/403/404/502/503等状态码时,返回的内容有可能不是预期的json字符串,而是html串;
  5. $timestamp/$nonce 是从HTTP HEADES上取,对应的key是Wechatpay-TimestampWechatpay-Nonce,其实还有3个key非常有用,后边再讲;

Formatter::joinedByLineFeed 字符合并

上边两个函数,都提到这个字符串合并函数了,之所以单独拎出来,是因为这个函数不仅仅用在这两个函数之上,微信字符与微信的数据交换二次签名以及官方小程序发券插件数据签名,数据格式均以0x0A做合并签名,并且末行的0x0A是不能少的。

这里不得不对APIv3的规范性做个感性评价,一致性还是相当可以的(虽然有难以言表的接口出现,拔特总体还是很可以的)。

代码块如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<?php
/**
 * Joined this inputs by for `Line Feed`(LF) char.
 *
 * @param string[] ...$pieces - The string(s) joined by line feed.
 *
 * @return string - The joined string.
 */
public static function joinedByLineFeed(...$pieces): string
{
    return implode("\n", array_merge($pieces, ['']));
}

这里用到了PHP7的弹性入参功能Variable-length argument lists,入参是平展展的字符串,赋值给$piecies型参,内部用了内置的implodearray_merge函数,来构建末尾是0xoA的字符串。

测试用例如下(其实request/response都已经覆盖):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?php

public function joinedByLineFeedPhrasesProvider(): array
{
    return [
        'one argument' => [1],
        'two arguments' => [1, '2'],
        'mixed arguments' => [1, 2.0, '3', LINE_FEED, true, false, null '4'],
    ];
}

/**
 * @dataProvider joinedByLineFeedPhrasesProvider
 */
public function testJoinedByLineFeed(...$data): void
{
    $value = Formatter::joinedByLineFeed(...$data);

    self::assertIsString($value);

    self::assertStringEndsWith(LINE_FEED, $value);

    self::assertLessThanOrEqual(substr_count($value, LINE_FEED), count($data));
}

public function testNoneArgumentPassedToJoinedByLineFeed(): void
{
    $value = Formatter::joinedByLineFeed();

    self::assertIsString($value);

    self::assertStringNotContainsString(LINE_FEED, $value);

    self::assertTrue(strlen($value) == 0);
}

小技巧: 在测试用例上,使用了PHP7的变长函数参数特性,透传给了被测试函数(不一定是个好方案),姑且先这样测试,后续再说。

Formatter::ksort 字典序排列数组

这个函数是APIv2用的,字典序排序入参,代码块如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?php
/**
 * Sort an array by key with `SORT_FLAG_CASE | SORT_NATURAL` flag.
 *
 * @param array<string, string|int> $thing - The input array.
 *
 * @return array<string, string|int> - The sorted array.
 */
public static function ksort(array $thing = []): array
{
    ksort($thing, SORT_FLAG_CASE | SORT_NATURAL);

    return $thing;
}

知识点: PHPSORT_NATURAL 是按自然序排序,让我们用测试用例来感受一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php
public function ksortByFlagNaturePhrasesProvider(): array
{
    return [
        [
            ['a' => '1', 'b' => '3', 'aa' => '2'],
            ['a' => '1', 'aa' => '2', 'b' => '3'],
        ],
        [
            ['rfc1' => '1', 'b' => '4', 'rfc822' => '2', 'rfc2086' => '3'],
            ['b' => '4', 'rfc1' => '1', 'rfc822' => '2', 'rfc2086' => '3'],
        ],
    ];
}

/**
 * @dataProvider ksortByFlagNaturePhrasesProvider
 */
public function testKsort(array $thing, array $excepted): void
{
    self::assertEquals(Formatter::ksort($thing), $excepted);
}

public function nativeKsortPhrasesProvider(): array
{
    return [
        [
            ['a' => '1', 'b' => '3', 'aa' => '2'],
            ['a' => '1', 'aa' => '2', 'b' => '3'],
        ],
        [
            ['rfc1' => '1', 'b' => '4', 'rfc822' => '2', 'rfc2086' => '3'],
            ['b' => '4', 'rfc1' => '1', 'rfc2086' => '3', 'rfc822' => '2'],
        ],
    ];
}

/**
 * @dataProvider nativeKsortPhrasesProvider
 */
public function testNativeKsort(array $thing, array $excepted): void
{
    ksort($thing);
    self::assertEquals($thing, $excepted);
}

差异点就在于,对于字符串+数字序列的键值,自然排序是rfc1, rfc822, rfc2086,默认排序是rfc1, rfc2086, rfc822,理论上,这个和官方的字典序排序是不完全一样的,待后续观察。

Formatter::queryStringLike 数组转字符串

方法名儿已经语义化了,是跟querystring做法类似,但是是有排除,排除规则是:键值是sign的,值是空串或者null的。代码块如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
/**
 * Like `queryString` does but without the `sign` and `empty value` entities.
 *
 * @param array<string, string|int|null> $thing - The input array.
 *
 * @return string - The `key=value` pair string whose joined by `&` char.
 */
public static function queryStringLike(array $thing = []): string
{
    $data = [];

    foreach ($thing as $key => $value) {
        if ($key === 'sign' || is_null($value) || $value === '') {
            continue;
        }
        $data[] = implode('=', [$key, $value]);
    }

    return implode('&', $data);
}

为什么要排除掉null呢??这其实是本SDK一个强制要求,APIv2上,有一个接口是不太标准的,要求入参是NULL字符串,null数据类型和NULL字符串不是一回事儿,不是一回事儿,不是一回事儿,重要的事情说三遍。。。。

测试用例覆盖如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php
/**
 * @dataProvider nativeKsortPhrasesProvider
 */
public function testNativeKsort(array $thing, array $excepted): void
{
    self::assertTrue(ksort($thing));
    self::assertEquals($thing, $excepted);
}

public function queryStringLikePhrasesProvider(): array
{
    return [
        'none specific chars' => [
            ['a' => '1', 'b' => '3', 'aa' => '2'],
            'a=1&b=3&aa=2',
        ],
        'has `sign` key' => [
            ['a' => '1', 'b' => '3', 'sign' => '2'],
            'a=1&b=3',
        ],
        'has `empty` value' => [
            ['a' => '1', 'b' => '3', 'c' => ''],
            'a=1&b=3',
        ],
        'has `null` value' => [
            ['a' => '1', 'b' => null, 'c' => '2'],
            'a=1&c=2',
        ],
        'mixed `sign` key, `empty` and `null` values' => [
            ['bob' => '1', 'alice' => null, 'tom' => '', 'sign' => 'mock'],
            'bob=1',
        ],
    ];
}

/**
 * @dataProvider queryStringLikePhrasesProvider
 */
public function testQueryStringLike(array $thing, string $excepted): void
{
    $value = Formatter::queryStringLike($thing);
    self::assertIsString($value);
    self::assertEquals($value, $excepted);
}

至此,SDK上封装的基础格式化参数函数讲解完了,知识点即“踩坑”点,规集整理出来,分享给大家。