TimothyQiu's Blog

keep it simple stupid

std::error_code 和它的朋友们

No Comments

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

阅读剩余部分...

从 feedly 投向 inoreader

No Comments

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

阅读剩余部分...

自定义 Xcode 文件模板

No Comments

几年前初次接触 Xcode,就觉得它的默认的代码模板很不好:

当时毕竟还是 Too Young,刚接触 OS X,照着奇怪的教程跑去 /Applications/Xcode.app 里乱改一气,虽然达到了目的,但总觉得有些怪味道,就不了了之了。

最近终于有机会重新折腾一下 Xcode,就又查了一下相关的内容。不得不说,Xcode 这方面文档真是太不友好了 =,=

自定义文件模板

/Applications/Xcode.app 里是应用自己的原生内容,不应该擅自修改。要修改文件模板,应该把对应的位于 /Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates.xctemplate 模板文件夹复制到 ~/Library/Developer/Xcode/Templates 改个名字后,再加以修改。

# 创建文件夹结构(默认没有 Templates 及往后的文件夹)
mkdir -p ~/Library/Developer/Xcode/Templates/File\ Templates/Source

# 复制需要修改的 .xctemplate 文件夹
# 这里是在 Source 分类下,将 Swift File 模板复制为 Empty Swift File 模板
cp -R /Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File\ Templates/Source/Swift\ File.xctemplate/ ~/Library/Developer/Xcode/Templates/Source/Empty\ Swift\ File.xctemplate/

这样,修改 Empty Swift File.xctemplate/___FILEBASENAME___.swift 就可以把讨厌的注释去掉了。

这里没法修改原有模板,也没法覆盖。即便使用与 Xcode 自带模板相同的名称,也只会在新建文件的模板选择对话框里出现两个同名的模板,非常蛋疼。

细枝末节

由于相关文档不足,我又有点强迫症,于是尝试了一些东西,比如上面说的「使用与 Xcode 默认模板相同的名字没法覆盖默认模板」。

上面路径中的 ~/Library/Developer/Xcode/Templates 为用户自定义模板的根目录,这个目录下的 .xctemplate 文件夹会被 Xcode 加载。Xcode 模板中的「分类」与此处 .xctemplate 的父目录有关。

虽然最后两种在效果上没什么区别,但还是推荐使用最后一种,与 Xcode.app 中的目录结构对应起来,会比较方便管理。

另外,使用向导填写模板参数的高级用法需要查找官方 Reference,不过粗略找了一下也没有找到官方说明,很是沮丧 :( 不过反正目前用不到 :)

Python 3 的 surrogateescape

1 Comment

新项目中第一次开始作死使用 Python 3,在测试中遇到一件奇怪的事情。

系统中有一个邮件发送模块,直接在命令行中手动跑 Worker 的话邮件可以成功发送,而一旦用 supervisor 运行则无法发送邮件。日志中显示在邮件发送库中抛出了如下异常:

UnicodeEncodeError: 'utf-8' codec can't encode character '\udce6' in position 0: surrogates not allowed

Surrogate

所以,日志里这个 surrogate 是个啥?

Surrogate 是 Unicode 中位于 BMP 外的一组不会有对应字符的码位,Python 3 中使用这些码位来「代表」无法编码的字节。

如果你明白「锟斤拷」是怎么来的,那么就一定能理解 Python 的 replace 编解码错误处理机制:无法解码的字节会被替换成 U+FFFD,无法编码的码位会被替换成 ?。这样做有一个明显的缺点就是不可逆。Python 3 中新增的 surrogateescape 则是一种可逆的错误处理机制,利用 Surrogate 码位保存无法解码的字节,编码时则将其还原为对应的原始字节。

例子中的报错正是因为 0xE6 无法使用 ascii 解码,而被解为了 U+DCE6

'\udce6'.encode('ascii', 'surrogateescape')
>>> b'\xe6'
b'\xe6'.decode('ascii', 'surrogateescape')
>>> '\udce6'

知道了原因,用这种方法解码异常信息中的完整字符串就找到了罪魁祸首:邮件发送者的配置。

Python 3 的环境变量与编码

邮件发送者是使用 显示名称 <邮箱地址> 的格式在环境变量中设置的,显示名称部分包含了汉字。

Python 3 中,使用 os.getenv 或者 os.environ 获得到的环境变量的值都是 str 类型。在类 Unix 系统中,解码过程是使用 sys.getfilesystemencoding()'surrogateescape' 错误处理来实现的。而 sys.getfilesystemencoding() 的取值与 nl_langinfo(COODESET) 相关,即 LC_CTYPE 环境变量。

所以,导致这部分环境变量包含 Surrogate 的原因就是 LC_TYPE 指定的编码不正确。

当环境变量 LC_CTYPELC_ALLLANG 均没有显式定义时,各个操作系统有各自的 LC_CTYPE 的缺省值(比如 C)。这种情况下,通过 sys.getfilesystemencoding() 通常会得到 ascii,环境变量中包含非 ASCII 字符就会得到上面的 Surrogate。

真正的问题

到了这里,问题根源和解决方法基本上已经水落石出了。

前面说手动跑 Worker 可以但是 supervisor 跑 Worker 就不行,这是因为被 supervisor 管理的子进程并不会单独开 Shell 而是继承 supervisord 的 Shell,所以基本的环境变量也有不同。

解决方法便是在 supervisor 配置中修改 environment,加入 LC_CTYPE 或者 LC_ALL 或者 LANG 环境变量的配置。

附,LANGLC_ALLLC_TYPE 什么的之间到底是个啥关系:查找 LC_X 的值的过程是:LC_ALL 有值就用 LC_ALL 的值;否则 LC_X 有值就用 LC_X 的值;否则 LANG 有值就用 LANG 的值;否则就用操作系统的默认值。

以上。

参考文档

SQLAlchemy 的 Identity Map 和 Cache 造成的 add 失败

No Comments

(2017-09-11 更新)文末追加了一个更好的做法。

标题剧透预警,然而已经晚了 :)

事情是这样的:这两天在写某 Flask App 的用户模块,访问邮箱验证邮件里的链接时,SQLAlchemy 在 db.session.add(user) 时有一定几率抛出异常:

AssertionError: A conflicting state is already present in the identity map for key (<class 'project.models.UserModel'>, (UUID('12345678-1234-1234-1234-123456789abc'),))

重现模式也很奇怪,同一个链接,首次访问时几乎都是异常,而刷新一下重新访问,一切就平平稳稳通过了。但是如果开新的浏览器(新的隐身窗口)访问,似乎无论是不是首次访问都没有问题。

问题是什么

断言内容直译是「目前的 Identity Map 中已存在与之冲突的状态」。

SQLAlchemy 是数据库和 Python 的中间层,Identity Map 即数据库对象与 Python 对象的映射表。我们之所以可以做这样的判断:

new_user = User(id=42)
db.session.add(new_user)
user = User.query.get(42)
assert user is new_user

就是 Identity Map 的功劳。

如果试图往 Identity Map 里加入两个不同的 Python 对象,但这两个 Python 对象都映射到同一个数据库对象,自然就不科学了,因为打破了其中的不变量。

foo = Model(id=42)
db.session.add(foo)  # OK
bar = Model(id=42)
db.session.add(bar)  # Fail

问题的成因

系统中有一个 get_user_by_id 的函数用以从数据库查找用户。这个函数额外加了个自己实现的 @cache 装饰器,用 Redis 做了缓存。

发送验证邮件时,因为其它部分的逻辑对用户信息做了修改,导致缓存被清空。于是下一次对 get_user_by_id 的调用一定会走数据库。

点击邮件中的验证链接后,访问验证页面。由于在 app.before_request 中插入了用 Session 中的 user_id 通过 get_user_by_id 获取用户信息的操作,做了数据库访问, Identity Map 中就保存了这一份 UserModel,暂且称作 A。

正式的验证页面逻辑,需要从验证链接中提取到这条链接对应的 user_id,继而通过 get_user_by_id 获得对应的 UserModel,此时的 UserModel 是从 Redis 中反序列化而来的,暂且称作 B。

A 和 B 虽然指向同一个数据库对象,但其实是不同的 Python 对象。通过 db.session.add 把 B 加入 Session,就会与 Identity Map 里原有的 A 发生冲突。

所以第二次访问该链接,由于都是从 Cache 加载的对象,Identity Map 一直是空的,就不会有如何问题;打开新浏览器,由于没有登录,也不会触发 app.before_request 中的数据库操作,所以也不会有问题。

问题的解决

目前的做法是新开一个 get_user_by_id_for_update(user_id) 从数据库加载,与此同时,正好可以在该函数内部给数据库请求加上 with_for_update() 给该记录加锁。

其它的选项还包括:

还是用 merge 更好啦

距离原来这篇博客发出已经一年多了,期间一直在遵循着上面所说的解决办法。但最近因为需要自己为 SQLAlchemy 封装 Write-through Cache 的轮子,又仔细阅读了 SQLAlchemy 的文档,发现在出现此种状况也可以选择使用 session.merge(obj, load=False) 将对象直接放入本 session 的 identity map 中。

session.merge 会首先根据对象的主键在 identity map 中查找对象,如果没有找到,则从数据库中加载一个进来。然后,会将传入对象的状态同步给 identity map 中的对象。

添加 load=False 参数,就会跳过「从数据库中加载一个」的步骤,直接在 identity map 中新建一个对象。由于对对象的状态有严格要求,官方文档 中只推荐从缓存中加载时使用。不过这正是我们所需要的,哈哈。

以上。