TimothyQiu's Blog

keep it simple stupid

经常追番的童鞋应该对日语的「喫茶店きっさてん」一词不陌生,它就是咖啡馆、茶店的意思。因为「吸烟」是「喫煙きつえん」,以及小学语文老师「秀才不识字,念半边」的教诲,我一直把「喫」字读作 qì。最近偶尔发现「喫」字在普通话中应该念 chī,感觉打开了新世界的大门。

了解了这一点,感觉很多原本奇怪的设定,居然就说得通了。

最浅显的,原本日语里看上去感觉阳春白雪的「喫茶店」「喫煙」就变成了非常接地气的「吃茶店」「吃香烟」,无锡话里也还在用这样的词汇,感觉很生活。

更延伸一些,表示「支持、负担」的「吃不消」就可以写成「喫不消」。这样一来「契合」这样的词语,也就变得好懂了。

而由于「哑」表示无法说话,「哑巴」表示无法说话的人;那么既然「」表示说话不流畅,「巴」就应该表示说话不流畅的人。于是似乎「结巴」一词的来源就可以猜到了,至少,在无锡话里,这两个词是同音的。

以上。

使用支付宝「移动支付」同步通知时遇到的问题与吐槽

移动支付」是支付宝推出的针对手机移动支付的服务。虽然支付宝现在建议新商户转用「App 支付」接口了,但是一些较早接入的 App 仍旧在使用这个服务,比如鄙厂的智能证件照 ;-)

各大支付服务的套路其实都是一样的,无论网页还是 App:

  1. 服务器为支付所需数据签名
  2. 客户端使用签名后的数据调起支付服务
  3. 用户支付成功后,客户端获得支付结果同步通知,服务器获得异步通知回调

显然支付状态应以服务器获得的异步通知为准,不过某些情况下,客户端可能会有在先检查一下本地的支付结果有效性的需求。

前些天在 Sentry 里收到了错误报告,原因是校验客户端获得的同步通知结果时,发生了「被校内容格式异常」的错误。初以为是有人故意修改了客户端在捣乱,检查请求内容才发现,原来是支付宝(不知为何)本次发来的同步通知内容使用了不同以往的格式:

partner="2088101568358171"&seller_id="xxx@alipay.com"&out_trade_no="0819145412-6177"&subject="测试"&body="测试测试"&total_fee="0.01"&notify_url="http://notify.msp.hk/notify.htm"&service="mobile.securitypay.pay"&payment_type="1"&_input_charset="utf-8"&it_b_pay="30m"&success="true"&sign_type="RSA"&sign="hkFZr+zE9499nuqDNLZEF7W75RFFPsly876QuRSeN8WMaUgcdR00IKy5ZyBJ4eldhoJ/2zghqrD4E2G2mNjs3aE+HCLiBXrPDNdLKCZgSOIqmv46TfPTEqopYfhs+o5fZzXxt34fwdrzN4mX6S13cr3UwmEV4L3Ffir/02RBVtU=";extendInfo="doNotExit":true,"isDisplayResult":true

末尾前无古人后无来者地加了个分号,然后以奇怪的格式引入了 extendInfoisDisplayResult 两个字段。这是文档中从来没有提过会发生的事情。

原本通过文档中字段说明及例子,我们可能还觉得返回的支付结果就是用的 HTTP Query String 格式,而且支付结果去除涉及签名的 signsign_type 相关键值对后可以直接用来验证签名。收到这回这么一条回调,就都不成立了。

所幸原先为了偷懒没有以 HTTP Query String 的格式解析支付结果,而是直接用正则表达式 ^(.+)&sign_type="RSA"&sign="(.+)"$ 从中提取待签名字符串及签名本身。(偷懒之处在于:因为如果解析以后,按照支付宝的签名规则,我还得再把它们重新排序;而解析前的键值对本身已经是排过序的了。)

但不幸的是,这个正则表达式认为 sign="xxxx" 后不应该有任何多余数据。于是便有了「被校内容格式异常」。

反思及吐槽

前两天刚在知乎大言不惭地在回答里说很多程序员不肯看文档、凭直觉掩耳盗铃地写代码,结果这就立马变成自我吐槽了 ;-(

其实仔细看支付宝的文档的话,关于同步通知中的支付结果和签名,是这样说的:

result:本次操作返回的结果数据。其中:&success="true"&sign_type="RSA"&sign="xxx"之前的部分为商户的原始数据。success用来标识本次支付结果。sign="xxx"为支付宝对本次支付结果的签名(加签内容为:案例中原始数据&支付结果,……)

首先,你看文档里确实并没有保证支付结果数据始终以 sign="xxx" 结尾。我在正则表达式里作出那样的假设,是基于例子推出的假设,但这其实是个高中数学「充要条件」的问题。

其次,我那偷懒利用结果数据中「已排序」特性的操作,虽然目前并没有什么问题,但其实也是有风险的。因为文档里并没有保证它的有序性。

所以嘛,我错了,我应该好好看文档的……

以上,真是有意义的一天呐~

自旋锁、互斥器、条件变量及读写锁

自旋锁(spinlock)很好理解。对自旋锁加锁的操作,你可以认为是类似这样的:

while (抢锁(lock) == 没抢到) {
}

只要没有锁上,就不断重试。显然,如果别的线程长期持有该锁,那么你这个线程就一直在 while while while 地检查是否能够加锁,浪费 CPU 做无用功。

仔细想想,其实没有必要一直去尝试加锁,因为只要锁的持有状态没有改变,加锁操作就肯定是失败的。所以,抢锁失败后只要锁的持有状态一直没有改变,那就让出 CPU 给别的线程先执行好了。这就是互斥器(mutex)也就是题目里的互斥锁(不过个人觉得既然英语里本来就不带 lock,就不要称作锁了吧)。对互斥器加锁的操作你可以认为是类似这样的:

while (抢锁(lock) == 没抢到) {
    本线程先去睡了请在这把锁的状态发生改变时再唤醒(lock);
}

操作系统负责线程调度,为了实现「锁的状态发生改变时再唤醒」就需要把锁也交给操作系统管理。所以互斥器的加锁操作通常都需要涉及到上下文切换,操作花销也就会比自旋锁要大。

以上两者的作用是加锁互斥,保证能够排它地访问被锁保护的资源。

不过并不是所有场景下我们都希望能够独占某个资源,很快你可能就会不得不写出这样的代码:

// 这是「生产者消费者问题」中的消费者的部分逻辑
// 等待队列非空,再从队列中取走元素进行处理

加锁(lock);  // lock 保护对 queue 的操作
while (queue.isEmpty()) {  // 队列为空时等待
    解锁(lock);
    // 这里让出锁,让生产者有机会往 queue 里安放数据
    加锁(lock);
}
data = queue.pop();  // 至此肯定非空,所以能对资源进行操作
解锁(lock);
消费(data);  // 在临界区外做其它处理

你看那个 while,这不就是自己又搞了一个自旋锁么?区别在于这次你不是在 while 一个抽象资源是否可用,而是在 while 某个被锁保护的具体的条件是否达成。

有了前面自旋锁、互斥器的经验就不难想到:「只要条件没有发生改变,while 里就没有必要再去加锁、判断、条件不成立、解锁,完全可以让出 CPU 给别的线程」。不过由于「条件是否达成」属于业务逻辑,操作系统没法管理,需要让能够作出这一改变的代码来手动「通知」,比如上面的例子里就需要在生产者往 queuepush_back 后「通知」!queue.isEmpty() 成立。

也就是说,我们希望把上面例子中的 while 循环变成这样:

while (queue.isEmpty()) {
    解锁后等待通知唤醒再加锁(用来收发通知的东西, lock);
}

生产者只需在往 queuepush_back 数据后这样,就可以完成协作:

触发通知(用来收发通知的东西);
// 一般有两种方式:
//   通知所有在等待的(notifyAll / broadcast)
//   通知一个在等待的(notifyOne / signal)

这就是条件变量(condition variable),也就是问题里的条件锁。它解决的问题不是「互斥」,而是「等待」。

至于读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。读写锁不需要特殊支持就可以直接用之前提到的几个东西实现,比如可以直接用两个 spinlock 或者两个 mutex 实现:

void 以读者身份加锁(rwlock) {
    加锁(rwlock.保护当前读者数量的锁);
    rwlock.当前读者数量 += 1;
    if (rwlock.当前读者数量 == 1) {
        加锁(rwlock.保护写操作的锁);
    }
    解锁(rwlock.保护当前读者数量的锁);
}

void 以读者身份解锁(rwlock) {
    加锁(rwlock.保护当前读者数量的锁);
    rwlock.当前读者数量 -= 1;
    if (rwlock.当前读者数量 == 0) {
        解锁(rwlock.保护写操作的锁);
    }
    解锁(rwlock.保护当前读者数量的锁);
}

void 以写者身份加锁(rwlock) {
    加锁(rwlock.保护写操作的锁);
}

void 以写者身份解锁(rwlock) {
    解锁(rwlock.保护写操作的锁);
}

如果整个场景中只有一个读者、一个写者,那么其实可以等价于直接使用互斥器。不过由于读写锁内部是至少需要用一把锁来保护当前读者数的,所以,如果你的临界区很小,读写锁相比一般的锁并不能带来很大的优势,甚至可能性能更低。

另一方面,读写锁要真正发挥效能,条件也比较麻烦。比如实际的读写锁通常不用例子里两把锁的实现,而是用一把锁、一个条件变量来实现,好处是可以缓解写者饥饿的情况(一旦有写者在等锁,后续读者都需要等写者离开后才能继续),但这样一来,如果读者的临界区没有明显小于写者的临界区,阻塞情况可能会变得比较不理想……

所以你可以认为读写锁是针对某种特定情景的「优化」。不是说不要用读写锁,而是读写锁往往没有看上去那么理想。个人建议是可以优先用 mutex,如果遇到瓶颈后可以选择替换为读写锁,看看能否带来性能提升。

以上。

std::error_code 和它的朋友们

前几天看 API 文档时候遇到了 std::error_code 这个东西,当时以为是 errno 的 Alias,后续查阅文档才发现并没有那么简单。

system_error

std::error_code 包含在 <system_error> 头文件中,相关的共有四个组件:

std::system_error 是一个继承自 std::runtime_error 的异常类,用以表示与 OS 交互时得到的以错误码形式返回的错误。因此除了提供标准的 what() 函数外,它还额外暴露了一个返回 std::error_codecode() 函数。同时,因为必须提供错误码,它既不提供空构造、也不提供接受字符串的构造函数。

从名字上就可以看出来(同时也是当时的提案 N2241 中指出的),整个 <system_error> 头文件都是为实现这个异常而生的。STL 有意将 std::error_code 作为 std::system_error 的附属品,而非单独的错误处理机制。

std::error_code

STL 中早期类似的错误码实现需求来自(C++17 才正式进入标准的)文件系统 API,当时的实现认为错误分类应在 errno 和操作系统原生错误码上二选一。但由于 STL 中网络、各种奇形怪状的 Boost 库的陆续加入,大家急需一个可扩展的错误码表示方案,才演变成现在的 std::error_code

std::error_code 顾名思义表示错误码,是由一个 int 型的 value 和一个 std::error_category * 型的 category 组成的值类型类。

之所以在 value 外还需要保存一个 category,是因为即使是同样的错误码,在不同的库或者场景下表示的意义可能不同。同样是 42,在一个库的错误码中可能表示「文件不存在」,在另一个库中可能就表示「DNS 解析失败」。如果你熟悉 Cocoa 的那一套,那么这就类似 NSErrordomain 属性。

此外,虽然没有明说 value 在等于 0 时表示无错误,但整个系统就是建立在这样的假设上的,例如:

构造 std::error_code

std::error_code 的构造函数共有三种重载:

前两种没啥好说的,第三种却值得推敲。

template<class ErrorCodeEnum>
error_code(ErrorCodeEnum e) noexcept;

此处的 ErrorCodeEnum 只是名字上说是枚举,但其实只要是用户定义类型就行(比如 enum class / enum / class),所以理论上可以从异常直接构造 std::error_code

通常,如果构造函数只接受一个参数,那么我们推荐将它标记为 explicit 以免发生不必要的隐式转换。但这个函数不然,它要的就是让原始的 ErrorCodeEnum 可以隐式转换为 std::error_code,从而实现这两者的直接比较。

catch (std::system_error const& e) {
    if (e.code() == std::errc::invalid_argument) {
        // blah blah blah...
    }
}

当然,为了避免从任意类型的值都能搞个 std::error_code 出来,此重载只有当 std::is_error_code_enum<ErrorCodeEnum>::valuetrue 时才会有效。所以你自定义错误码枚举时,需要在 std 命名空间中特化此模版。

#include <iostream>
#include <system_error>

enum class YourErrorCode {
    kSuccess = 0,  // 别忘了 0 应该表示无错误
    kNetworkError,
    kBadRequest,
    kServerError,
};

// 特化模版,启用对应的重载
namespace std {
template<>
struct is_error_code_enum<YourErrorCode>: true_type {};
}

// 提供工厂函数
// 工厂函数不必要写在 std 中
std::error_code make_error_code(YourErrorCode code)
{
    return {
        static_cast<int>(code),
        std::generic_category(),  // 这里暂时用自带的 category
    };
}

int main()
{
    std::error_code e = YourErrorCode::kBadRequest;
    std::cout << e << '\n';  // 自带一个输出流的重载
}

当然,如果后续 C++ 中终于引入 Concept 的话,这个重载应该就没有这么复杂了吧。

std::error_category

前面说到 std::error_code 里保存的其实是指向 std::error_category 的指针,而非对象。这是因为这个类就是应该被当作单例来用的,实际也只能这么用才对,因为它的 operator=() 是通过直接比较 this 指针来实现的。

此外,std::error_category 还是个纯虚类,你必须实现的纯虚函数有:

STL 自带 std::error_category 的几个子类,很好地示范了「如何正确使用纯虚类」:不暴露具体的子类,而是暴露工厂函数,只通过接口(纯虚类)访问子类。

// 得到的都是 std::error_category const&
auto const& gec = std::generic_category();
auto const& sec = std::system_category();

std::error_condition

2007 年,<system_error> 正式进入标准前夕,委员会对 N2241 提案给出了一些意见,其中包括一条

Obscure the distinction between system-specific errors and the general and portable notion of an error condition.

于是,为了区分「系统相关的错误」和「平台无关的错误」,std::error_condition 诞生了。

因此你可以看到,std::error_condition 是一个与 std::error_code 除了语义几乎没有差别的东西。从库作者的角度,你可以理解为封装底层细节时用 std::error_code,而对外暴露接口时推荐使用 std::error_condition

一起玩

std::error_conditionstd::error_code 虽然是两个独立的类,但它们可以通过 std::error_category 连接在一起。

这两个类的对象互相比较时,STL 提供了对应的 operator=() 等函数的重载,通过调用双方的 category().equivalent(other) 来比较。只要任何一方的 category 认为对方与自己等价,两者就会被判为相等。

// 卖个萌 :-P
enum class MyErrorCondition {
    kChenggong,
    kWangluoCuowu,
    kQingqiuCuowu,
    kFuwuqiCuowu,
};

class MyErrorCategory: public std::error_category
{
public:
    // 还记得 std::error_cateory 是个单例么?
    static MyErrorCategory const& instance() {
        static MyErrorCategory instance;
        return instance;
    }

    char const *name() const noexcept override {
        return "MyErrorCategory";
    }

    std::string message(int code) const override {
        return "Message";  // 偷个懒
    }

    bool equivalent(std::error_code const& code, int condition) const noexcept override {
        // 理论上你用不着在这里处理 code.category() == this->instance 的情况

        // 因为是个单例,所以某些情况下不得不用这么绕的办法来拿
        auto const& yourErrorCodeCategory = std::error_code(YourErrorCode{}).category();

        if (code.category() == yourErrorCodeCategory) {
            switch (static_cast<MyErrorCondition>(condition)) {
            case MyErrorCondition::kChenggong:
                return code == YourErrorCode::kSuccess;
            case MyErrorCondition::kWangluoCuowu:
                return code == YourErrorCode::kNetworkError;
            case MyErrorCondition::kQingqiuCuowu:
                return code == YourErrorCode::kBadRequest;
            case MyErrorCondition::kFuwuqiCuowu:
                return code == YourErrorCode::kServerError;
            }
        }
        return false;
    }
};

// error_condition 同样需要特化模版启动重载
namespace std {
    template<>
    struct is_error_condition_enum<MyErrorCondition>: true_type {};
}

// error_condition 同样可以通过工厂函数构造
std::error_condition make_error_condition(MyErrorCondition code)
{
    return {static_cast<int>(code), MyErrorCategory::instance()};
}


int main()
{
    std::error_code code = YourErrorCode::kNetworkError;
    std::error_condition condition = MyErrorCondition::kWangluoCuowu;
    std::cout << (code == condition) << '\n';
}

看上去有点绕,但由于通常 std::error_code 是底层接口定死的,而你所做的只能是用 std::error_condition 来适配的情况来说,还是很合理的。

还是 STL 轻松,直接抛 std::system_error 给你。std::error_code 甩脸,要抽象你自己去用 std::error_condition 搞,哈哈。

顺带一提

上面已经提到此处的 std::system_error 异常与 std::error_code 的关系。事实上,至少在提出引入 std::error_code 提案的作者看来,STL 乃至 C++ 中推荐的错误汇报方法还是异常。由于存在与操作系统等底层 API 的交互,才引入了「从 API 返回的错误码构建异常」以及「将异常转换为错误码传给 API」这样的需求。

当然,为了方便统一范式,便于不支持异常的系统使用文件系统 API。C++17 才正式进入标准的文件系统 API 也添加了「默认抛异常,但额外传一个 std::error_code 就不抛异常」的机制。

以上。

从 feedly 投向 inoreader

当年 Google Reader 拜拜后,我也陆续尝试了不少国内国外的替代品。当时选择了相对不错的 feedly,但因为不像 GR 一样存在感强烈,常常忘掉它的存在。最近整理阅读渠道,又把 feedly 拾起来,就感觉到了 feedly 的不足。

首先便是基本功能收费,无法理解为啥作为一个 RSS 阅读器,feedly 连在自己的所有 feed 中搜索都是需要高级会员才能解锁的功能。

其次是 feedly 近几个月来隐藏已读文章的功能都是失效的。无法看到清爽的文章列表界面,这对于我这种强迫症来说简直要命。

另外,feedly 取消了几乎是 RSS 阅读器必备的星标功能,转而加入了「稍后阅读」按钮。虽然功能都只是一个旗标,可以将已标记的文章单独列出,但「星标」的语义可以由用户自己赋予;而「稍后阅读」却已经框死了语义,将其另作他用总让人有一种负罪感。于是我感觉很不舒服。

当然最令人发指的是 Android 客户端自带的「发送到 Pocket」功能,居然是需要你提供 Pocket 的用户名和密码的。既然网页版都实现了基于 OAuth 的 Pocket 授权,为啥客户端版本的反而要用这么「不安全」的方法,无法理解……曲线救国又需要「菜单 / 分享 / Add to Pocket」 这么啰嗦的步骤,所以干脆再见吧。

于是最近又尝试了一圈市面上仅存(诶)的几款 RSS 阅读器,客户端能够支持隐藏已读文章、支持星标(废话)、抓取速度好、又有比较好的 Pocket 支持的,大概就是 Inoreader 了。功能上,Inoreader 几乎是 Google Reader 传承而来的,也有丰富的快捷键支持。缺点是界面相比 feedly 感觉要不那么「现代」一点 :)

目前只用了 Inoreader 一小段时间,暂时没有特别的惊喜和苦恼。有一个有趣的用法是它可以将 Twitter / Google+ 当作 RSS 订阅,如果有合适的目标账号,应该还是很有用的。

以上。