分类目录归档:Programming

【翻译】关于 Unicode 和字符集,每个程序员都必须掌握的基本内容(别找借口!)

关于 Unicode 和字符集,每个程序员都必须掌握的基本内容(别找借口!)

原文链接在这里:The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)

你是否曾好奇过 ”Content-Type“ 这个 tag 究竟是用来干嘛的?就是那个你在编写 HTML 的时候必须设置的 tag,你是不是一直压根都不知道这货是用来干嘛的呢?

你是否也曾收到过你那儿来自保加利亚的朋友发给你的邮件,但是杯具的是邮件标题显示成 “???? ?????? ??? ???” 这样的乱码了呢?

令我失望的远不止于此,我发现非常多的软件开发人员(程序员)对于字符集、编码和 Unicode 这一类的东西毫无了解。几年前,一个 FogBUGZ 的 beta 测试用户曾经问过我们 FogBUGZ 是否可以支持接收日文的邮件。啥,日文?你们还用日文写邮件啊?好意外啊,我怎么知道能不能支持。然后当我开始认真地去研究我们用来解析邮件消息的 MIME 的商业版 ActiveX 控件时,我们发现这货在处理字符集的这件事上彻头彻尾的错了,所以我们必须自己编写代码来避免它做出错误的转换,并且确保解析正确。然后,我又研究了一下另一个商业的类库,好吧,这货的字符处理模块也彻头彻尾的错了。我联系了这个类库的开发者,并跟他沟通了一下这个问题,你猜人家肿么说的,“对此我们无能为力”。就像大部分程序员一般,估计他希望这个问题能随风远去吧。

然而并不会,有木有。当我发现世界上最好的编程语言 PHP 竟然无视字符集的存在,非常牛逼地直接使用 1 个字节(8 位)来保存字符,这使得 PHP 几乎不可能直接用来开发良好的国际化 Web 应用程序(这不是我说的,是 Joel 说的哦)。好吧,也许够用就好了。

所以,在此我要声明一下:如果你是一个 2003 年的程序员,然后你告诉我你对于字符、字符集、编码和 Unicode,那么别让我逮着你,否则我会把你关到核潜艇上去剥 6 个月的洋葱的(尼玛捡肥皂是不是更好)。相信我,我是认真的。

另外,我想说的是:

这并不难。

在这篇文章里,我会尽量将每个程序员在实际工作中都需要掌握的基本内容讲清楚。尼玛那些 “plain text = ascii = characters are 8 bits” 这种鬼画符的东西不止是错了,简直错到姥姥家去了,如果你继续这么写代码的话,那你就跟一个不相信细菌存在的医生差不多了。请你在读完这篇文章之前,别再写任何代码了。

在我开始之前,我需要先说明一点,如果你对于国际化已经很熟悉了话,你会发现整篇文章讨论的内容相对来说会有点过于简单了。我确实只是尝试着设定一个最低标准来进行讨论,以便每个人都能理解到底发生了什么,并且能学会如何编写可以支持除英语之外的语言显示的代码。另外,我需要说明的一点是,字符处理只是软件国际化工作中的很小的一部分,不过今天在这篇文章里头我一次也就只能讲清楚这一个问题。

历史回顾

理解这些东西最简单直接的方法就是按时间顺序来回顾一遍。

你也许觉得我要开始讲一些类似于 EBCDIC 之类的远古时代的字符集了。然而,我就不。EBCDIC 跟你已经没有半毛钱关系了。我们没必要再回到那么远古的时代了。

那么我们回到中古时代吧,当 Unix 刚刚发明出来时,K&R(Brian Kernighan 和 Dennis Ritchie)还在写《The C Programming Language》这本书的时候,一切都是那么的简单。EBCDIC 就是那个时候出现的。在 EBCDIC 中只考虑到了那些不带音调的英文字母,我们称其为 ASCII,并且使用 32 到 127 之间的整数来代表所有的字符。空格是 32,字母 “A” 是 65,以此类推。这些字母使用 7 位就可以很容易地保存了。不过那个时代的大部分电脑都使用的是 8 位架构,所以不止可以很容易地保存所有的 ASCII 字符,我们还有 1 位富余出来可以用来存别的东西,如果你比较邪恶的话,你完全可以拿这 1 位来偷偷地干坏事:WordStar 这款软件就曾经使用 8 位中的最高位来标记字母是否是一个单词中的最后一个,当然 WordStar 也就只能支持英文文本了。小于 32 的字符被称为不可打印字符,主要用于符咒(开个玩笑罢了)。这些字符是用作控制字符,例如 7 对应的这个字符就是让你的电脑哔哔哔地响,12 这个字符就是让打印机吐出当前正在打印的纸张,接着吸入下一张新的纸张。

ASCII

这一切看上去都还是挺美好的,如果你是一个只说英语的人。

由于这一个字节共 8 位,可以表达的数字是 0 ~ 255,那么就还有可利用的空间,很多人就开始想“对啊,我们可以利用 128 ~ 255 来达成我们自己的一些想法”。可问题的关键是,想到这个点子的人实在是太多了,而且就这 128 ~ 255 之间的数字,他们的想法还各不相同。IBM-PC 的开发人员把这部分设计成了一个提供了部分带有音调适合欧洲语言的字符和一组划线符号的 OEM 字符集,这组划线符号有横杆、竖杠、右边带有虚点的横杠等等,有了这些划线符号,你可以在屏幕上画出非常整齐的方形图案,你现在依然可以在你家的干洗机自带的 8088 微机上看到这些图案呢。事实上一旦当美国之外的人们开始购买 PC,那么各种不同语言的 OEM 字符集纷纷涌现出来,这些字符集们都各自使用这 128 个高位字符来达成自己的设计目的。例如,有些 PC 机上字符编码为 130 的字符是 é,但是在那些以色列出售的机器上,它是希伯来语中的第三个字符 (ג),所以当美国人给以色列人发送 résumés 时,在以色列人的电脑上收到之后会显示为 rגsumגs。还有很多类似的例子,例如俄罗斯人,他们对于如何使用这 128 个高位字符编码有着太多太多不同的想法了,所以你压根就没法可靠地通过某种既定的规则来转换俄语的文档。

OEM

最终这场 OEM 字符集的混战由 ANSI 标准终结了。在 ANSI 标准中,低于 128 编码的字符得到了统一,跟 ASCII 码完全一致,但是对于高于 128 编码的的字符,就有很多种不同的方法来处理了,这主要取决于你住在哪儿。这些不同的高位字符处理系统被称为码点页。例如,以色列 DOS 系统使用了一个名为 862 的码点页,希腊人用的是 737 码点页。这两个系统中,字符编码低于 128 的字符都是一样的,但是高于 128 编码的字符就不同了,所有其他有趣的字母就都放在这儿了。全国的 MS-DOS 版本得有几十个这样的码点页,用来处理从英语到冰岛语,甚至还有部分多语言的码点页用来处理世界语和加利西亚语在同一台电脑上的显示。哇!但是希伯来语和希腊语还是无法在一台电脑上同时显示,除非你自己编写一个程序将所有的字符都按照位图来显示,因为处理希伯来语和希腊语的高位编码的字符所需要的码点页完全不同。

同时在亚洲,这件事情显得尤为严重,因为亚洲语言文字有成千上万的单字,8 位根本无法表达这么多的单字。这个问题通过一个叫做 DBCS(double byte character set)的系统解决了,在这个系统里头有些单字使用 1 个字节来表示和存储,有些单字使用 2 个字节。这个系统有个问题就是,它可以很容易地实现字符串中往前查找单字,但是几乎没法往后查找。程序员们不应该 s++ 和 s– 这样的操作来往后和往前查找单字,而应该调用类似于 Windows 系统中提供的 AnsiNext 和 AnsiPrev 函数来完成相应的操作,这些函数会自行搞定那些乱七八糟的事情。

但是就是还有那么多的人,他们依然假装 1 个字节就是一个字符,一个字符就是 8 位,而且你也从来不会把一个字符串从这台电脑发送到另一台电脑上,也只会一种语言,这样的话,貌似是没什么问题的。当然,事实上是互联网出现了,字符串在不同电脑之间的传输变得既平常又频繁,这下这些乱七八糟的字符处理系统终于扛不住了。幸运的是我们有了 Unicode。

Unicode

Unicode 很成功地将目前地球上所有的文字书写方式都整合到了一个字符集里头,甚至一些虚构的文字都可以囊括进来,例如克林贡语等等。有些人可能简单地误以为 Unicode 就是一个简单的 16 位的编码,每个字符都是用 16 位来进行存储和表示,那么它就最多能表示 65536 个字符了。实际上这样理解是不对的。这是对于 Unicode 最常见的一种误解,你这么想并不孤独,别在意就好了。

实际上 Unicode 对于字符的管理有一套完全不同的思维方式,你必须理解 Unicode 的思维方式才行。

现在,让我们假设需要将一个字母转换为二进制数据存储到磁盘或内存中:

A -> 0100 0001

在 Unicode 中,一个字母映射一个码点,这个码点是一个理论上的常量值。至于这个码点如何存储到内存或磁盘就完全是另外一回事了。

在 Unicode 中,字母 A 是一个纯抽象的概念,它并不指向任何特定的字符表示形式。抽象的 A 和 B 不是同一个字符,A 和 a 也不是同一个字符,但是 A 和 A 还有 A 指的却是同一个字符。也就是说在 Times New Roman 字体中的 A 和 在 Helvetica 字体中的 A 是同一个字符,但是与同一个字体中的小写的 “a” 却是不同的字符,看上去好像也没啥毛病对吧,但是在有些语言里头这就行不通。例如德语中的 ß 字母究竟是一个独立的字母呢,还是 “ss” 的一个花俏的写法?如果一个字母出现在单词的末尾,它的形状就要发生变形,那么它们要算是不同的字符吗?希伯来语就是这样的,但是阿拉伯语又不是这样的。不过不管怎样,Unicode 组织中那些牛逼的大大们在过去的十年里已经帮我们把这些问题都解决了,天知道他们都经历过了什么样的争辩与妥协(这已经是政治问题了,亲),所以你不需要再去担心这些问题了。他们已经把这些问题都搞定了。

每一个字母表中的抽象的字母都会被 Unicode 组织分配到一个长这样的魔数:U+0639。这个魔数被称为码点。“U+” 代表这个字符采用的是 Unicode 编码并且采用十六进制来表示编码的值。U+0639 对应的是阿拉伯字符中的 ع 。而英文字母的 A 对应的 Unicode 码点是 U+0041。你可以通过 Windows 2000/XP 中自带的 charmap 工具或者访问 Unicode 的官网 来查找不同字符的码点或者通过码点进行反向查找。

Unicode 编码所能定义的字符数量是不存在上限的,实际上 Unicode 字符集中定义的字符数量早已经超出 65536 了,所以并非所有的 Unicode 字符能被压缩为两个字节进行存储,但是这听上去有点诡异对吧。

好吧,那么我们来看个字符串:

Hello

在 Unicode 编码中,对应该字符串的 5 个字符的码点值为:

U+0048 U+0065 U+006C U+006C U+006F

至此我们看到的都只是一串码点值,看上去实际上都只是数字而已。我们还没有讨论过如何将这些字符如何存储到内存或者在 Email 中如何显示它们。

编码

想解释上面的两个问题,就需要涉及到编码了。

最早的 Unicode 编码试图将所有的字符都编码为两个字节来存储,那么好的,我们只需要将这些数字编码为两个字节,然后按顺序排好。那么 Hello 就成这样了:

00 48 00 65 00 6C 00 6C 00 6F

就这样吗?等等,我们看看这样行吗?

48 00 65 00 6C 00 6C 00 6F 00

从技术上来说,这两种编码方式都是可以的,事实上早期的 Unicode 编码实现者们就希望能以大端字节序或者小端字节序的方式来存储他们的 Unicode 码点,无论他们的电脑的 CPU 是快还是慢,现在是白天还是晚上,反正现在就是有了两种存储 Unicode 码点的方式了。所以人们就必须在处理 Unicode 字符串之前,遵守一个奇怪的约定,那就是在每一个 Unicode 字符串的最前面加上 FE FF 这两个字节(这被称为 Unicode 字节顺序标记,也被戏称为 BOM,炸弹的谐音)用来标记这个 Unicode 字符串的编码字节序是大端字节序,如果使用小端字节序进行编码存储的话,就在 Unicode 字符串编码的最前面加上 FF FE 两个字节,这样一来解析该 Unicode 字符串的人,在读取整个字符串编码的最前面两个字节就能判断当前的 Unicode 字符串究竟是采用大端字节序进行编码存储还是使用的小端字节序。但是,你懂的,真实的情况是,并非所有的 Unicode 字符串都会在其最头部加入所谓的 BOM 噢。

这看上去都挺好的,可是过了没多久,就有一帮程序猿开始有意见了。他们说:“靠,你们看看这些 0 们,你们有什么想法没有!”。因为他们是美国人,而且他们看到的也大都是英文,所以他们几乎从来都不回用到高于 U+00FF 的码点。另外他们还是加州的自由主义嬉皮士,他们很想节约一点存储空间(呵呵)。如果他们是德州人,他们就压根不会在意这多出来一倍字节数。但是加州的这帮弱鸡们就是无法忍受要把存储字符串的的空间給增大一倍,并且他们已经有了那么多的该死的文档已经是使用 ANSI 和 DBCS 字符集进行编码和存储的,尼玛谁愿意再一一地去給这些文档做格式转换啊?光是出于这个原因,很多人在很长的一段时间里头都选择无视 Unicode 的存在,与此同时,事情变得越来越糟了。

于是机智的 UTF-8 登场了。UTF-8 是另一个用来编码存储 Unicode 字符码点的编码方式,使用 8 个比特来存储码点魔数种的每一个数字。在 UTF-8 中,码点从 0 到 127 的字符都是使用一个字节来进行存储。只有码点值为 128 以及更高的字符才会使用 2 个或者 3 个字节进行存储,最多的需要使用 6 个字节。

UTF-8

这个方案有个巧妙的附带效果,那就是对于英文来说,在 UTF-8 和 ASCII 中的编码几乎是一模一样的,所以美国人根本都意识不到有任何问题。只有世界上其他地区的人才需要去跳过这些坑。还以这个 Hello 为例,它的 Unicode 码点是 U+0048 U+0065 U+006C U+006C U+006F,在 UTF-8 编码中将会以 48 65 6C 6C 6F 的形式来存储。看,这就跟使用 ASCII 和 ANSI 编码进行存储一毛一样了对伐。好了,现在如果你执意要使用带有音调的字母或者是希腊字母和克林贡字母的话,那么你就需要使用多个字节来保存一个字母的码点了,但是美国人压根儿都意识不到。(UTF-8 还有一个优良的特性,就是那些无知的使用一个值为 0 的字节来作为字符串终止符的老旧字符串处理代码不会错误地把字符串給截断了)

目前为止,我已经告诉了你有三种 Unicode 字符的编码方式。最传统的做法是将所有的字符编码保存到两个字节中,这种编码方法被称为 UCS-2(因为存储在两个字节里头)或者 UTF-16(因为使用了 16 个比特),而且你需要根据字符串的 BOM 来判断其存储采用的是大端字节序还是小端字节序。更为普遍被采用的就是新的 UTF-8 编码标准了,使用 UTF-8 编码有个好处,就是对于那种无脑的只能处理 ASCII 字符陈年老程序或者碰巧你只需要处理英文文本的话,你几乎不用做任何调整就可以正常使用了。

当然实际上还有很多种不同的 Unicode 字符编码方式。例如 UTF-7 就是一种跟 UTF-8 非常相似的编码方式,但是它会确保一个字节中的 8 个比特中最高位的值永远为 0,所以如果你必须通过某种认为只需要使用 7 个比特来进行字符编码就够了的政府-警察的电子邮件系统来发送 Unicode 字符的话,UTF-7 就能将 Unicode 字符压缩到 7 位并进行编码和存储,而不至于丢失字符串中的任何内容。还有 UCS-4,它将每个 Unicode 字符的码点编码为 4 个字节进行存储,它的优势是所有的字符都是使用相同的字节数进行存储,不过,这样的话,即便是德州人估计都不太乐意浪费这么多的内存空间了。

现在你在想的应该是,理论上来讲,所有这些通过 Unicode 码点来表示的字符,应该也能通过原来的老的编码方式来进行编码才对啊。例如,你可以讲 Hello(U+0048 U+0065 U+006C U+006C U+006F)的 Unicode 字符串通过 ASCII 编码的方式进行编码,也可以通过老的 OEM Greek,甚至是 Hebrew ANSI 编码方式,乃至前面我们提到的几百种编码方式。但是使用这些老的编码方式对 Unicode 字符进行编码都会有同一个问题:那就是有些字符是无法正常显示的。如果在这个编码方式中找不到一个跟 Unicode 字符的码点对应的字符来显示,你通常都只能看到一个问号 ?,或者更好一些的就是一个带着黑色的块中有个问号 �。

有好几百种这种过时的编码系统,只能正确地存储一小部分的 Unicode 字符码点,然后将其他的字符都存储的存储为问号 ?了。例如一些非常常用的英文文本编码方式 Windows-1252(Windows 9x 中内置的西欧语言的标准编码方式)和 ISO-8859-1 也叫做 Latin-1(也适用于所有西欧语言),都没法正确地存储俄文字符或者希伯来语字符,而只会把这些字符变成一串串的问号 ?。而 UTF 7,8,16 和 32 就都能很好地将任何码点的字符进行正确的编码和存储。

关于编码的一个最重要的事实

如果你已经完全忘掉了我刚刚吧啦吧啦讲了的一大堆东西,那么就记住一个非常重要的事情,那就是 “如果不知道一个字符串使用的是什么编码,那么你就完全不知道它会是个什么鬼”。你再也不能像鸵鸟一般把头埋到沙子里头,然后假装所有的纯文本都是使用 ASCII 编码的。

根本就没有什么纯文本这样的东西。

如果你有一个字符串,不论它是在内存里,在文件中,还是在一封电子邮件里头,你都需要知道它的编码方式,否则你压根就没法正确地解析它,然后再正确地显示给你的用户。

几乎所有类似“我的网站看起来尼玛彻底乱码了啊”以及“她没法查看我发的带有音调字母的电子邮件”这种傻逼问题,通常都是因为某个无知(naive)的程序猿压根儿都没理解一个很简单的道理,那就是如果你不告诉我你的字符串使用的是 UTF-8 或者 ASCII 还是 ISO-8859-1(Latin 1),又或是 Windows 1252(西欧语言)编码,你压根儿就没法正常地显示这些字符,甚至都没法判断字符串在那儿结束的。高于 127 的码点字符的编码方式有好几百种,这就彻底歇菜了。

那么我们如何将字符串使用的编码信息保存起来呢?当然,这也都有标准的方法来实现。例如在电子邮件中,你就需要在邮件内容的头部加入一段这种格式的文本来说明这封电子邮件正文使用的编码方式:

Content-Type: text/plain; charset=”UTF-8″

对于网页来讲,最开始的想法是这样的,Web 服务器在每次返回网页内容的同时会返回一个类似的 Content-Type 的 HTTP 的头信息,注意不是在网页 HTML 文件中,而是在服务器返回 HTML 文件之前通过一个 HTTP 的响应头返回的。

不过这样就带了一些问题。假设你有一个庞大的 Web 服务器,这个服务器上部署了很多的网站,而且这些网站的网页是由很多使用不同语言的人创建的,而他们都直接使用微软的 FrontPage 编写网页,然后直接将 FrontPage 生成的网页文件拷贝到服务器上。服务器没法真正地确定每个网页文件实际上使用的编码方式的,所以它就没法正确地发送 Content-Type 的 HTTP 头了。

如果你能使用某种特殊的 tag 将 HTML 文件的 Content-Type 在网页文件内进行正确地声明,那么也是挺方便的。但是这就让那些纯粹主义者们就疯了,在你知道一个 HTML 文件的编码之前,你要怎么读取这个 HTML 文件来解析它内部的内容呢?(听起来有点鸡生蛋,蛋生鸡的意思噢)不过幸运的是,几乎所有常用的编码对于码点值 32 到 127 之间的字符处理都是一样的,所以你总是可以直接解析 HTML 文件中的关于编码的 tag 的内容,而不需要关注可能出现的乱码的字符,通常 HTML 文件中关于编码的 tag 的内容如下:

我们需要注意的是这个 meta tag 必须是 <head> 标签中的第一个子节点,因为浏览器在读取到这个 meta tag 之后就会马上停止继续解析当前的网页内容了,而是使用 meta tag 中声明的编码重新开始解析当前网页的内容。

那么如果浏览器没有从 HTML 文件和 Web 服务器返回的 HTTP 头中读取到任何指定当前网页的 Content-Type 的信息,浏览器会怎么解析当前网页呢? IE 浏览器实际上会做一些很有意思的事情:它会基于不同的字节在不同语言中的常用编码和出现频率,尝试着去猜测当前网页使用的语言和编码方式。由于不同的老的 8 位码点页编码系统总是尝试着将其国家或地区语言中特殊字符映射到 128 到 255 之间的不同点位,而每种语言中的字符实际上是有其统计上的特征的,所以 IE 浏览器的这个自动判断当前网页使用的语言和编码方式偶尔还是能奏效的。乍看上去非常诡异,但是由于总是有些无知的网页开发者压根儿就不知道需要在他们编写的网页的头部声明网页的 Content-Type 以便浏览器能正确地展示他编写的网页。直到有一天,他们编写了一个不符合他们母语字符分布规律的网页,而 IE 浏览器最终会把这个网页当成韩文来进行渲染展示,坦率地讲我认为波斯特尔法则(Postel’s Law)—— “严于律己,宽于待人”(哈哈,这个翻译有点诡异,实际上的意思是,对于自己创建和发送的内容要尽量严格要求,减少出错的可能,而对于我们接收的内容要尽可能的宽容,并考虑容错)并不是一个好的工程准则。不管怎么说,这个网站可怜的读者们面对这原本应该显示为保加利亚语的网页被显示为韩语(还不确定是北朝鲜语或者是南朝鲜语),他又能做什么呢?他可以使用 IE 浏览器中菜单栏里头的视图(View)|编码(Encoding)选项来尝试使用不同的编码方式(大概有个几十种东欧的语言吧)来显示当前网页,直到他最终试出了当前网页使用的语言和编码。当然前提是他得知道这么操作才行,然而实际上绝大部分人根本就知道怎么调整浏览器渲染和显示网页时使用的编码。

在我们公司发布的最新版本的网站管理软件 CityDesk 中,我们决定在软件内部实现中都采用 UCS-2 编码,这也是 Visual Basic、COM 和 Windows NT/2000/XP 底层使用的字符串编码方式。在 C++ 中,我们将所有的字符串都声明为 wchar_t(宽字符)类型,而不再使用 char 类型,使用 wcs 方法,而不再使用 str 方法(同理我们会使用 wcscat 和 wcslen 替换 strcat 和 strlen 方法)。在 C 语言中,你只需要在字符串的前面加上一个大写的 L,例如 L“Hello” 就可以创建一个字面上的 UCS-2 字符串了。

在 CityDesk 发布网页时,它会将所有的网页都转化为 UTF-8 编码,多年以前各大浏览器对 UTF-8 编码的支持就已经非常好了。这也是为什么 Joel on Software 这个网站拥有 29 种语言的版本,但是我从来没有听说过任何一位读者反馈说他没法正常地浏览这个网站上的内容。

这边文章写得实在是太长了,但是我依然无法在这一篇文章里涵盖字符编码和 Unicode 的所有内容,但是我依然希望当你读完这篇文章,再回去编程的时候,能不再盲信什么水蛭或咒语可以治病,而是学会如何正确地使用抗生素,这是是我想留给你的一个小任务。

使用 Supervisor 自制开机自启动后台服务

使用 Supervisor 自制开机自启动后台服务

写在前面的废话

这几天在了解了一些关于 kcptun 相关的信息(耀华同学科普的),想着近期自己的 VPS 科学上网方案时不时的抽风,就上手折腾了一番。但是作为一个仅仅会照着官方文档执行一些命令的我,对于很多 Linux 服务器运维相关的知识知之甚少,原来因为身边总是有耀华同学,搞不定直接把问题丢给他就好了,可是现在更多的时候得靠自己了。

然后找到耀华同学了解了一番如何在 Linux 服务器上自制一个开机自启动服务,最终选择了耀华同学推荐的 Supervisor。废话到这,下面开始折腾。

安装 Supervisor 服务

在做一件自己以前从未做过的事情之前,建议大家还是自备一个不会影响到其他人和自己的虚拟机进行学习和测试,相信我,你总有可能做出一些自己难以预测而破坏性又大的事情的。记住,不要因为自己的无知,影响到他人和自己,否则你会因为这个后果自己承担不起或者恼怒不已,最终你会把锅扣在你正要开始学习的事物上,这对于我们开始一项新的学习无疑毫无益处。

我们歌王同学刚好今天在公司内网新建了一台全新的 CentOS 的虚拟机,我刚走过去想问问能否给我一台仅供我自己折腾使用的服务器,歌王就把用户名和密码给我了,简直贴心到爆啊,有木有,有这样的同事,也是我修来的福分好伐。

其实我想要的最好是一台 Ubuntu 服务器啦,毕竟讲真,对于我这样的菜鸟服务器管理员,Ubuntu 总感觉可用度更高些(各种问题 Google 一下,马上出来),大部分流行的工具在官方的软件库里头都能找到,确实能省不少的事情,降低了维护的难度和成本。不过看了一眼 Supervisor 官方文档,感觉这货在各大 Linux 的发行版上都如鱼得水呢,毕竟它是用 Python2 实现的嘛,那么 CentOS 就 CentOS 吧,我总不能再要求我们歌王同学再给我去搞一个 Ubuntu 的虚拟机吧(我们公司后端使用的服务器均是 CentOS)。

通过 easy_install 直接安装 Supervisor,执行以下命令:

easy_install supervisor

即可安装 Supervisor,安装成功之后,可以执行以下 supervisord,测试一下是否安装成功,确认安装成功之后,我们就可以继续下一步了,编写开机自启动后台服务的配置。

编写开机自启动后台服务的 Supervisor 配置

编辑 /etc/supervisord.conf 文件(如果没有的话,执行 echo_supervisord_conf > /etc/supervisord.conf 创建该配置文件),在文件末尾新增以下配置:

[program:ss-proxy-kcptun-sg]
command=/root/kcptun/client_linux_amd64 -c /root/kcptun/ss-kcptun-config-sg.json

就是这么简单,Supervisor 默认的选项已经完全够用了,重新执行命令 supervisord -c /etc/supervisord.conf,然后我们就可以通过命令 ps aux | grep kcptun 来确认 kcptun 的 client 是否已经随着 supervisord 的启动自动启动了。

配置 Supervisor 开机自启动

通过上面的步骤,我们已经可以让 kcptun 的 client 随着 supervisord 的启动自动启动了,但是 supervisord 的开机自启动还得做一下设置(由于我这里是通过 easy_install 来安装的,貌似这货不会自启动),也许使用 yum 直接安装 Supervisor 会自动启动噢。我在 Ubuntu 上使用 apt 安装的 Supervisor 确实是会自动启动的(而且其默认的配置文件在 /etc/supervisor/supervisord.conf 这里噢)。

至于如何在不同的系统下设置 Supervisor 的开机自启动,在 Supervisor 的 官方文档 上有这么一句噢:

If you are using a distribution-packaged version of Supervisor, it should already be integrated into the service management infrastructure of your distribution.

There are user-contributed scripts for various operating systems at: https://github.com/Supervisor/initscripts

There are some answers at Serverfault in case you get stuck: How to automatically start supervisord on Linux (Ubuntu)

我现在的操作系统是 CentOS,所以我就从这个 Supervisor 的官方 Github 账号下的一个用户们分享的启动脚本的仓库中,下载了适用于 CentOS 的启动脚本,从文件的命名以及脚本内容的风格来看,这货是一个用于 systemd 启动的配置呢。

所以我就 Google 了一番,关于如何在 CentOS 上创建 systemd 的启动脚本, 这里 有完整的在 CentOS(实际上是 Redhat 啦,不过我们都晓得 CentOS 师出 Redhat 门下,同根同源,不会错的)上创建一个自定义的 systemd 的启动脚本的流程。以下是我执行的所有脚本的流程:

curl https://raw.githubusercontent.com/Supervisor/initscripts/master/centos-systemd-etcs > supervisord.service
mv supervisord.service /etc/systemd/system
chmod 644 /etc/systemd/system/supervisord.service
systemctl daemon-reload
systemctl start supervisord.service

此后如果修改了 Supervisor 使用到的相关配置,需要重启 Supervisor 服务的话,执行 systemctl restart supervisord.service 即可。

Linode 上 VPS 重启后 MySQL 服务器挂了的恢复方法

做个备忘,省得每次遇见都得重新 Google 了。

这次 Linode 整体更新 XEN 的安全漏洞补丁之后,我的 VPS 重启之后,自己也没有注意,今天打开一下自己的博客站点,发现 WordPress 报了了一个「连接数据库出错」的问题,首页无法正常打开了。

之前也碰到过类似的问题,但是之前每次都是直接丢给我们可爱的耀华同学来搞定的,现在耀华同学不在身边,有的时候用起来就没有那么顺手了,所以捏,咱们还是得学会自己动手,丰衣足食啊。在询问了耀华同学咱们 MySQL 的错误日志在哪里无果之后,本想尝试直接使用耀华同学提供的通过 find 命令暴力查找了(事实证明这样可能可以找到,但是会比较麻烦),刚好看到网上有个默认地址提供了,直接 cd 到那个目录之后,看到一个长得很像的 error log 文件,打开一看,果了个然啊。打开看看错误日志里头的错误是啥,原来是 mysql 依赖的临时文件创建不成功,文件权限有问题,那么修改一下文件权限吧,修改好了文件权限之后,重启 mysql 服务,一切 OK。

那么我们来回顾一下整个流程是怎么样的。

  1. 发现 WordPress 报告 MySQL 数据库连接出错了,那么 ssh 到 VPS 上,执行一下 ps aux | grep mysql 命令,发现并没有 mysql 服务进程,可以肯定 mysql 服务没有成功启动;
  2. 那么接下来我们就要看看 mysql 服务为啥没有在此次 XEN 补丁升级后自动启动成功呢,找 error 日志来看吧,最终我们发现 rpm 默认安装的 mysql 错误日志路径在 /var/lib/mysql 目录下,跟 mysql 的核心数据库文件就在同一个目录,建议不要这么干,还是在 my.cnf 中好好地配置一下 mysql 的日志文件存放路径吧(由于我并非一个合格的服务器开发人员或维护人员,原谅我使用了默认配置);
  3. 找到这个名称为 li402-16.err(文件名格式为:[host-name].err)的错误日志文件,滚到最下面一行,看看错误是啥吧,/usr/libexec/mysqld: Can’t create/write to file ‘/dev/shm/ibinQuSV’ (Errcode: 13);
  4. 这下可以确认是因为不能往 /dev/shm 这个目录里头写入文件了,如果不是磁盘满了那么肯定就是权限不足,所以无法写入文件,但是这个目录其实是一个内存里头的临时文件目录,所以不会出现满了的情况,那么肯定的权限的问题;
  5. 接下来我们就应该给 mysql 用户分配 /dev/shm 目录的写权限就好了,由于我比较懒(实际上是不太会,这个会有安全隐患的),我直接把 /dev/shm 的权限修改为 777 了,建议还是自行严格地将写入权限分配给 mysql 用户吧;
  6. 然后通过 /etc/init.d/mysqld start 启动 mysql 服务;
  7. 再次打开博客站点首页,测试通过,OK 搞定了。

此事的原因很有可能是因为 VPS 重启之后,对于某些敏感文件目录,XEN 容器管理的原则就是将其恢复到默认的文件权限吧,不是很了解,但是直觉会是这样,也就是说以后碰到重启这样的情况再次出现 MySQL 启动错误,都应该先检查一下是否为临时文件写入权限的问题导致的。


喵的,这次 Linode VPS 硬件问题之后,机器再次重启了,泥煤的啊,突然发现,在这里的备忘压根达不到备忘的效果啊,VPS 挂了,博客打不开啊,你上哪儿看去呢?好像是个问题呢。

用 Python 写一个简单的 Alfred Workflow 用于颜色值的转换

由于自己是程序员,而且比较痴迷于使用各种工具,虽然可能这些工具并没有给我的效率带来太大的提高,但是我就是喜欢折腾这些软件,因为能给我带来快乐和满足感,你说谁不想爽呢。

Alfred 是我使用 Mac 之后安装的第二个工具软件(正常工作需要的开发软件和浏览器之类的通用软件不算),第一个是 Dash,第二个是 Alfred。Dash 是在试用了 5 分钟不到,查看 Android 帮助文档如飞的响应速度,让我直接购买了正版授权。Alfred 是在当做 Launch 软件使用了快 1 年之后,看了很多人提到的关于 Workflow 的增强功能之后,在某个非常想购物的日子里头买了一个 PowerPack,从此用上了 Workflow 的功能,当然在这一年里头实际上我也就只用了有道词典这么一个 Workflow,其他的几乎不怎么使用,主要还是习惯问题。

这几天手痒,总觉得自己不像个能折腾的程序员,刚好看到一个用于转化数字的 Workflow 感觉还不错,试用了一下之后,我发现其实我自己也有一个比较强的需求可以通过这个 Workflow 来完成。在做 Unity3D 的开发过程中,我们时常会需要使用到调色板来给界面上的文字进行着色,就是设置颜色值。但是美术一般提供过来的就是效果图,或者是 RGB 值,通常格式是这样的 (234,120,54),但是在实际的开发中我们并不是每次都是针对一个 UILabel(我们的 UI 使用 NGUI 开发)进行字体颜色设置,因为有的时候我们只是想突出显示一段文字中的某一部分,这个时候依赖于 NGUI UILabel 的 BBCode 机制,我们可以通过 [ffbbcc] 需要指定颜色显示的文字内容 [-] 这样的方式来实现同一个 UILabel 下的文字使用不同的颜色进行显示。

那么这个时候有一个能快速转换(234,120,54)为#ea7836 就有一些必要了,参考那个 Number Convertor 我就自己开始造轮子了。

Alfred Workflow设置页面

找到 Number Convertor 这个 Workflow,选中任意一个 Script Filter 右键,弹出菜单之后点击 Configure,或者直接双击也行,然后弹出一个配置页面,配置页面的右下方有一个 Open workflow folder 的按钮,点击之后会打开当前 Workflow 对应的所在目录,然后我们会发现这个目录下面有以下的内容:

Alfred Workflow对应目录

仔细查看一下,我们会发现所有的转化逻辑都是通过 number_convertor.py 文件来完成的,但是这个脚本依赖于一个叫 Workflow 的 Python 库,这个目录里头的 workflow 就是干这个用的。这个 Python 的库可以在 这里 找到。

以下是的脚本内容:

然后我们就可以通过 Workflow -> Add Templates -> Essentials -> Script Filter to Script 创建一个 Workflow,然后通过上面提到的方法进入设置页面打开 Workflow 所在目录,按照 alfred-workflow 的 Python 库的安装方式安装设置好 alfred-workflow,然后将写好的 Python 脚本放到相应的位置,最终目录里头的内容如下:

目录结构

然后再回到 Workflow 设置页面设置一下 keyword 和调用 Python 脚本的方法和参数就 OK 了。

QQ20150511-7@2x

同理可以再设置一个 HexToRGB 的行为:

QQ20150511-8@2x

转换成功到了之后,我们把结果保存到剪贴板中,并且通过鼠标拖拽 Script Filter 节点上的小圆点到 Copy to Clipboard 节点上,这样就能把 Script Filter 中获得的结果保存到系统的剪贴板中了。

Screen Shot 2015-05-11 at 11.35.16 PM

最后来个实际使用的截图吧:

Screen Shot 2015-05-11 at 11.38.12 PM

折腾完毕。

C# Socket 如何判断连接已断开

相信很多 Unity3D 的程序员都会遇到网络编程的情况,虽然目前可能大部分的手游都会优先选择使用非长连接技术来实现,也就是我们常说的直接使用 Http 来完成跟服务器通信的部分,但是还是难免会有遇到需要使用实时网络连接的时候。那么这时候,我们通常还是需要回到网络编程的根源,那就是直接使用 Socket 进行编程,.NET Framework 中的 Socket 已经封装得非常的完备了,真的很好使。

但是在使用的过程中难免还是会碰到一丢丢问题的,对吧。今天我要记录的就是在处理连接已断开的这种情况。

在手游的使用场景中,以下情况可能会出现客户端与服务器出现无法连接的情况:

  1. 客户端无法连接到网络,例如:客户端设备当前接入的 Wi-Fi 热点无法连接到互联网,客户端设备进入无线网络信号很差的地方(例如某些大厦的电梯,某些地铁线路等等),这个时候其实客户端和服务器都没有主动关闭过这个 TCP 连接,但是网络已经无法正常连接了;
  2. 服务器主动关闭了这条 TCP 连接,服务器为什么要主动关闭掉我的这条 TCP 连接呢?设想一下,客户端跟服务器建立 TCP 连接之后,客户端在 10 分钟内都没有向服务器发送过任何数据,那么服务器还有必要保留这条连接吗?如果当前在线的玩家较多,服务器可用的连接数已经达到一个容限值,那么是否需要及时将某些已经不活跃(例如 10 分钟以上没有发送任何消息)的连接主动关闭,回收这些连接资源,以备其他新登录游戏的玩家设备连接使用呢?所以,当服务器主动关闭了 TCP 连接的时候,10 分钟我上完厕所回来了,我重新拿起我的手机,想继续玩我的游戏,那么就需要处理客户端如何重新连接到服务器的问题了;
  3. 服务器网络出现问题,例如服务器所在机房链路出现问题,通过某些网络就是无法访问服务器,例如服务器网线不小心被挖断了或者网线被网管拔了,服务器电源烧了,主板坏了,等等诸如此类的物理设备出现故障的情况下,客户端是无法连接到服务器的,服务器也不可能通过 TCP 协议给客户端发送什么 FIN,ACK,SYN 等等状态码啦,这个时候客户端也需要有正常的逻辑来处理这个情况。

其实说了这么多就是想表达一下,我们的客户端可能在任何情况下与服务器失去连接,而这个时候客户端需要合理地来处理这种连接已断开的情况。

我们可以查看一下.NET Framework 的文档,看看 Socket 中的一些相关的属性和方法:

Socket.Connected Property

Gets a value that indicates whether a Socket is connected to a remote host as of the last Send or Receive operation.

The Connected property gets the connection state of the Socket as of the last I/O operation. When it returns false, the Socket was either never connected, or is no longer connected.

The value of the Connected property reflects the state of the connection as of the most recent operation. If you need to determine the current state of the connection, make a nonblocking, zero-byte Send call. If the call returns successfully or throws a WAEWOULDBLOCK error code (10035), then the socket is still connected; otherwise, the socket is no longer connected.

If you call Connect on a User Datagram Protocol (UDP) socket, the Connected property always returns true; however, this action does not change the inherent connectionless nature of UDP.

这个是 Connected 属性的相关描述,大体的意思就是这个 Connected 属性会根据当前这个 Socket 最后一次的 I/O 操作来进行设定,也就是说这个属性为 True 还是为 False 会在 Socket 最后一次 I/O 操作完毕之后进行设定,应该也是根据错误返回码 SocketError 来进行设定的。如果我们使用的是阻塞的 Socket 的话,那么这个状态会在最后一次 I/O 操作出错后将这个属性设置为 False,如果使用的是非阻塞的 Socket 就需要我们自己通过发送一个零字节的数据包,然后根据错误返回码来判断 Socket 连接是否断开。


 

Socket.Receive Method (Byte[], Int32, Int32, SocketFlags, SocketError)

Receives data from a bound Socket into a receive buffer, using the specified SocketFlags.

在我们的项目中我们使用了这个方法来读取 Socket 中的数据 ,这个方法中的最后一个参数是一个 out 参数,也就是如果我们在读取数据的时候出现了一些异常,错误码会通过这个参数返回。


Socket.Send Method (Byte[], Int32, Int32, SocketFlags, SocketError)

Sends the specified number of bytes of data to a connected Socket, starting at the specified offset, and using the specified SocketFlags

我们使用这个 Send 方法来进行数据的发送,同样也是通过最后一个 out 参数 SocketError 来进行错误码的返回。

这个 SocketError 类型中呢有很多很多预定义的错误码,这个需要我们仔细查阅看看有哪些是我们需要使用到的,这个因项目类型和需求不同,这里不一一阐述了。

那么在我们实际的开发过程中呢,我们肯定可以通过判断 SocketError 错误码来进行一些异常情况的处理,不过有些时候我们其实并不想在出现异常了之后才处理,例如我有一个发送消息的队列,有一条消息发送出错了,我还得把这条消息再放回队列,可是如果直接入队到消息队列的队尾可能会有消息先后顺序的问题等等。所以有些时候我们就需要在异常出现之前就想好解决方法,也就是在我们读取或者发送数据之前就先检查一下连接是否正常消息是否能够成功发送出去,那么这又要肿么搞呢?

先看看 Poll 方法的描述吧:

Socket.Poll Method

Determines the status of the Socket.

Remarks

The Poll method will check the state of the Socket. Specify SelectMode.SelectRead for the selectMode parameter to determine if the Socket is readable. Specify SelectMode.SelectWrite to determine if the Socket is writable. Use SelectMode.SelectError to detect an error condition. Poll will block execution until the specified time period, measured in microseconds, elapses. Set the microSeconds parameter to a negative integer if you would like to wait indefinitely for a response. If you want to check the status of multiple sockets, you might prefer to use the Select method.

 看上去这个方法貌似就是我们想要的呢,对吧?如果你这么想那就太 Naive 了,在实际使用的过程中,我就犯了一个不仔细看文档,导致栽了一个大跟头的错误。我写了一段代码用来判断当前 Socket 是否已经跟服务器断开连接。

Socket socket;
// 省去初始化和连接服务器代码若干
if (!socket.Poll(1000, SelectMode.SelectRead)) {
// 做错误提示等相关的操作
}

事实证明这样是瞎搞,因为.NET Framework 的文档关于 SelectMode.SelectRead 这样描述的。

true if Listen has been called and a connection is pending;
-or-
true if data is available for reading;
-or-
true if the connection has been closed, reset, or terminated;
otherwise, returns false.

你说这不是坑爹是啥?这个意思就是说,如果我们是一个服务器端的 Socket,调用了 Listen 方法,并且有一个外部连接在等待我们处理的时候,这个 Poll 方法会返回 true,如果当前 Socket 中有数据可以读取时,这个 Poll 方法也会返回 true,或者当这个 Socket 连接被关闭、重置、强制终止了也会返回 true,否则返回 false。

实际测试的时候,发现这个值只在 Socket 缓冲区中有数据可以读取和连接被服务器关闭了之后才会返回 true,好吧,我承认自己是猪吧。那么回到我们的初衷上来,我们还是希望能在读取数据之前就能知道这个连接是否已经被关闭了(是否与服务器断开连接了)。那么可以通过下面这段代码来进行判断。

// 省去初始化和连接服务器代码若干
if (socket.Available == 0 && socket.Poll(1000, SelectMode.SelectRead)) {
// 因为根据文档的描述,如果连接正常的话,这两个条件不可能同时成立
// 只有在连接已断开的时候,才可能会出现这两个条件同时成立的情况
}

写到这里的时候,突然之间很想看看 Available 这个属性又是怎么一回事,好吧,我们再来看看.NET Framework 的文档吧。

Socket.Available Property

Gets the amount of data that has been received from the network and is available to be read.

Remarks

If you are using a non-blocking Socket, Available is a good way to determine whether data is queued for reading, before calling Receive. The available data is the total amount of data queued in the network buffer for reading. If no data is queued in the network buffer, Available returns 0.

If the remote host shuts down or closes the connection, Available can throw a SocketException. If you receive a SocketException, use the SocketException.ErrorCode property to obtain the specific error code. After you have obtained this code, refer to the Windows Sockets version 2 API error code documentation in the MSDN library for a detailed description of the error.

虽然文档中提到如果我们使用的是非阻塞的 Socket 的话,那么 Available 属性将会是一个用于检测 Socket 缓冲区中是否有数据可以 Receive 的好选择,这样我们如果使用阻塞的 Socket 难道就不能用了吗?当然也不是,肯定还是可以用的,只是说非阻塞的 Socket 如果一直循环调用 Receive 很有可能会做无用功,因为如果当前 Socket 缓冲区中没有数据可以读取的话,Receive 方法会很快速的结束调用并且抛出一个 SocketException,而如果每次 Receive 之前检查一下变量的成本相对于调用一次 Receive 方法可能开销会更小一些吧。而对于阻塞的 Socket 来说,每次 Receive 调用都是阻塞直到有数据返回,或者超时后抛出一个超时异常。所以我们阻塞的 Socket 也是可以用 Available 这个属性来进行提前判断的。但是实际测试中,Unity3D 中使用的 Mono 中的 Socket 实现并非和.NET Framework 的文档描述一致。关于如果 Remote Host 关闭连接之后,再调用这个 Available 属性会抛出异常的行为就没有。

 关于 SelectMode 中另外两个 SelectWrite 和 SelectError 类型,显然我们也不能放过啊。一一测试之后发现,在服务器主动断开连接之后,通过 Socket.Poll(1000, SelectMode.SelectWrite) 返回一直都是 true。即便我们通过 Send 方法发出去的字节数为 0,返回的 SocketError 中的错误码为 Shutdown,依然不为所动啊,所以对于这个 SelectMode.SelectWrite 我已经凌乱了,希望哪天可以找到合理的解释。

但是 Socket.Poll(1000, SelectMode.SelectError) 在服务器断开连接之后会在第一时间内从 false 变成 true,貌似这个还是蛮靠谱的,但是看了一下文档之后我又有点凌乱了。

true if processing a Connect that does not block, and the connection has failed;
-or-
true if OutOfBandInline is not set and out-of-band data is available;
otherwise, returns false.

这个意思是说,如果正在建立一个非阻塞的连接,然后这个连接失败了会返回 true,或者没有设置 OutOfBandInline 但是又收到了 out-of-band 数据的时候会返回 true,否则就返回 false。

综合上面说的那一大堆东西,最终得出的结论就是 Connected 字段可以用,但是有滞后性,如果想通过这个字段来检测连接是否已经断开的话,就要明白这个字段是在我们通过 Receive 或者 Send 方法成功进行一次 I/O 操作之后,系统根据最后一次 I/O 操作的错误返回码来设定 Connected 字段的值的。

而如果想在进行 I/O 操作之前就检测到连接出现断开的情况的话,是可以通过 Poll 来达成目的的,当然这个会比较纠结。首先 Poll 配合 SelectMode.SelectRead 和 SelectMode.SelectWrite 都不能非常直接的反馈连接是否被断开 。但是貌似这个 SelectMode.SelectError 还是蛮靠谱的,所以呢,从目前测试得到的结果中,可以使用这个来提前判断连接是否已经断开。

不过在实际的应用中,我个人倒是更倾向于在使用 SelectMode.SelectError 做判断的同时,一定还要对在 Receive 和 Send 方法中返回的 SocketError 进行检查,这个返回码是直接从 Native 层返回的 Socket 错误码,非常可靠也很能说明问题。例如”HostDown”、”ShutDown“、”NetworkDown“等等状态码还是很能直观地反应当前 Socket 的状态的,所以还是需要好好地看看.NET Framework 中关于 SocketError 的描述文档。