【翻译】关于 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 即可。

c#中如何正确处理 utf8-with-bom 的读写问题

C# 中如何正确处理 UTF-8 with BOM 的读写问题

昨天把项目中的打包工具做了一些调整,原本正常工作的代码因为新增的部分代码执行流程出现了问题,而且问题比较隐晦。最终通过调试跟踪,发现问题出在解析一个 JSON 文件的时候,该文件中只是存放了一个 JSON 数组而已。之前的版本中解析的文件是直接从服务器下载下来的,而服务器上的文件是在另一个工程中生成的,调整后的项目中,该 JSON 文件是自己生成的。

这个问题的关键在于我们通过以下的代码读取出来的字符串中有特殊字符:

string filePathUTF8 = "/Users/helihua/Temp/poerty_utf8.txt";
byte[] bytesUTF8 = File.ReadAllBytes(filePathUTF8);
string decodedPoetryUTF8 = System.Text.Encoding.UTF8.GetString(bytesUTF8);

直接使用 JSON 解析库解析读取出来的文本会出错,因为这段文本的第一个字符实际上是一个特殊不可见字符(表达零宽度非换行空格的意义,是不是很牛逼,调试的时候输出日志是发现不了的,调试的时候直接查看整个字符串的内容也是查看不了的,只能通过判断字符串长度和字符串第一个 char 才可以分辨噢),是 UTF-8 的 BOM,也就是 U+FEFF 这货,在 UTF-8 编码的文件中表现为前三个字节为: 239 187 191。

仔细分析后,我发现问题出在新调整的代码中,生成新的 JSON 文件使用了 File.WriteAllText(string path, string contents,Encoding encoding) 方法,传入的 encoding 为 System.Text.Encoding.UTF8。而这货默认是开启 BOM 的,那么这就意味着我们新创建的 JSON 文件是 UTF-8 with BOM 编码格式的(其实就是在写入文本的字节数据之前,添加了一个 BOM 块,也就是文件头部多了 3 个字节)。

然而后续读取该 JSON 文件的时候我并没有使用 File.ReadAllText 方法来进行文本内容的读取,而是先通过 File.ReadAllBytes 方法将 JSON 文本文件的所有数据读取为字节数组,后续通过了 UTF8Encoding.GetString 方法将字节数组转化为字符串。而 Encoding.GetString 方法是不会自己去过滤我们获得到的文件字节数组中的 BOM 头对应的 3 个字节的,所以就将其解析成了一个 零宽度非换行空格 了,最终导致解析 JSON 失败,整个程序流程出错了。

那么后续我们应该如何来规避类似的问题,正确地处理 UTF-8 with BOM 的读写问题呢?我的建议是:

  1. 尽可能让写文件和读文件采用对应的方法,例如写入文本文件的时候,使用 File.WriteAllText 方法,那么在读取文本文件的时候,就应该使用 File.ReadAllText 方法(C# 默认的实现非常鸡贼,在写入文件的时候会按照你传入的 Encoding 中声明是否需要写入 BOM 来写入文件,但是在读取文本的时候,不论传入的 Encoding 中是否声明带有 BOM,它都会检测 BOM,并且会把 BOM 从读取出来的字符串中移除掉,也就是说我们通过 File.ReadAllText 读取出来的文本字符串肯定是干净的,不会出现 BOM 这种奇怪的捣乱分子的);
  2. 在使用上面的方法的同时,使用相同的编码方式,并且建议使用不带 BOM 的编码方式(BOM 更多是为了给文本编辑器检测文件编码用的,对于 BOM 的各种争端,就如同编辑器 VIM 和 Emacs 之间的圣战般激烈,在此我表个态,我认为尽可能不用 BOM),如此一来所有的 UTF-8 都是不带 BOM 的,也就不存在在某些场景下读取文件的人没意识到文件头部可能会有 BOM ,编码过程中完全没有考虑到该问题,最终读取出来的文本中含有特殊字符与预期结果不一致的可能性;
  3. 如果在实际开发中,无法确定文件是什么类型,或者写文件和读文件的代码模块不由同一个人来开发维护,那么就需要非常明确地通过文档规范来声明读写文件应该采用什么方式,通用的方式当然就是通过 StreamWriter 来写入字节,通过 StreamReader 来读取字节,至于需要如何应用这些字节,可以交给上层的应用自行来处理。

在 Unity Editor 中同步执行外部脚本

在 Unity Editor 中同步执行外部脚本

在 Unity 开发的过程中,我们都难免会碰到需要使用某些外部脚本来完成某些特定的任务,例如调用外部的 Python 脚本导入 Excel 配置表为 Json 文件等等。

C# 提供的 System.Diagnositics.Process 类可以很好的帮助我们达成这个目的,下面我们就来看看如果使用 Process 类来调用外部的脚本。

由于 C# 调用外部脚本执行的程序会新建一个进程来执行该指定的外部脚本,跟 Unity Editor 并不会在同一个进程,所以如果我们直接调用 Process.Start() 方法启动外部程序的话,该外部程序的执行与当前 Unity Editor 进程空间中执行的 C# 代码之间是无法交互的,从表现上来看,外部程序完全是独立于 Unity Editor 进程在执行。如此一来,在某些应用场景下就可能出现无法满足我们的需求,例如在每次调用 BuildPipeline.BuildPlayer 方法打包 APK 之前,需要导入最新的配置表文件,那么我们怎么确保 BuildPipeline.BuildPlayer 是在执行外部 Python 脚本导入配置表之后再执行的呢?我们来看代码:

// 同步调用外部脚本,并将其输出使用 Unity Editor 的 Log 进行输出
Process importProcess = new Process();
importProcess.StartInfo.FileName = batName;

// 如果需要在 Unity Editor 中使用 Log 输出外部程序执行时的输出进行查看的话,
// 就必须将 UseShellExecute 设置为 false
importProcess.StartInfo.UseShellExecute = false;

// 将标准输出重定向,所有输出会通过事件回调的方式在回调参数中返回
importProcess.StartInfo.RedirectStandardOutput = true;
importProcess.OutputDataReceived += new System.Diagnostics.DataReceivedEventHandler(ImportAllOutputDataReceived);

// 将错误输出重定向,所有输出会通过事件回调的方式在回调参数中返回
importProcess.StartInfo.RedirectStandardError = true;
importProcess.ErrorDataReceived += new System.Diagnostics.DataReceivedEventHandler(ImportAllErrorDataReceived);
importProcess.Start();
sImportAllOutput = new StringBuilder();
sImportAllError = new StringBuilder();
importProcess.BeginOutputReadLine();
importProcess.BeginErrorReadLine();

// 这一句很关键哦,就是因为调用了这句话,我们才能让外部程序与 Unity Editor 中的脚本同步执行
importProcess.WaitForExit();

if (!string.IsNullOrEmpty(sImportAllOutput.ToString()))
{
    UnityEngine.Debug.Log(string.Format("Import all json log:\n {0}", sImportAllOutput.ToString()));
}
if (!string.IsNullOrEmpty(sImportAllError.ToString()))
{
    UnityEngine.Debug.LogError(string.Format("Import all json error:\n {0}", sImportAllError.ToString()));
}
UnityEngine.Debug.Log(string.Format("Import all json process exited at: {0} with code: {1}, start to imprted json files to protypes",
                                 importProcess.ExitTime, importProcess.ExitCode));

// 完成导入之后,调用一下刷新方法,确保 Unity Editor 加载到的配置文件都是最新的
AssetDatabase.Refresh();   

// 执行打包操作
BuildPipeline.BuildPlayer(SceneInfoManager.Levels, androidOutputPath, BuildTarget.Android, buildOptions);

👆 上面这段代码的正确执行除了需要补全一些变量之外,还需要实现两个事件回调方法:

private static void ImportAllOutputDataReceived(object sender, System.Diagnostics.DataReceivedEventArgs args)
{
   if (!string.IsNullOrEmpty(args.Data))
   {
       sImportAllOutput.AppendLine(args.Data);
   }
}

private static void ImportAllErrorDataReceived(object sender, System.Diagnostics.DataReceivedEventArgs args)
{
   if (!string.IsNullOrEmpty(args.Data))
   {
       sImportAllError.AppendLine(args.Data);
   }
}

如此一来,调用外部脚本就成为了整个工具脚本执行流程中的一个小环节了,而且执行成功或者出错,也能通过 Unity Editor 的 Log 进行查看,结合 UnityEngine.Debug.LogError 也能很好地作出相应地错误提示,是不是感觉棒棒哒。

👆 上面的这段代码中是直接调用了操作系统平台的可执行脚本文件,例如在 macOS 上就是 shell 脚本,在 Windows 上就是 bat 脚本了。这样其实还是有点蛋疼对吧,我得每个平台都写一个脚本,那么为了不这么蛋疼,我们可以这么做。

// 我们最终是调用 python 来执行这个 python 脚本
string convertJsonToExcelPythonScriptFielPath = Path.Combine(Path.Combine(Directory.GetParent(Application.dataPath).FullName, "Tools"), "convert_json_to_excel.py");

// 构建完成的参数列表
string arguments = string.Format("{0} -j {1} -e {2} -s {3}", convertJsonToExcelPythonScriptFielPath, jsonFilePath, excelFilePath, sheetName);

System.Diagnostics.Process convertJsonToExcelProcess = new System.Diagnostics.Process ();
// 针对不同的平台,找到不同的 python 执行程序
if (Application.platform == RuntimePlatform.OSXEditor) 
{
    System.Diagnostics.ProcessStartInfo startInfo = new System.Diagnostics.ProcessStartInfo ("/usr/bin/python", arguments);
    convertJsonToExcelProcess.StartInfo = startInfo;
} 
else if (Application.platform == RuntimePlatform.WindowsEditor) 
{
    System.Diagnostics.ProcessStartInfo startInfo = new System.Diagnostics.ProcessStartInfo (@"C:\Python27\python.exe", arguments);
    convertJsonToExcelProcess.StartInfo = startInfo;
}
convertJsonToExcelProcess.StartInfo.UseShellExecute = false;
convertJsonToExcelProcess.StartInfo.RedirectStandardOutput = true;
convertJsonToExcelProcess.OutputDataReceived += new System.Diagnostics.DataReceivedEventHandler(ConvertJsonToExcelOutputDataReceived);
convertJsonToExcelProcess.StartInfo.RedirectStandardError = true;
convertJsonToExcelProcess.ErrorDataReceived += new System.Diagnostics.DataReceivedEventHandler(ConvertJsonToExcelErrorDataReceived);
sConvertJsonToExcelOutput = new StringBuilder();
sConvertJsonToExcelError = new StringBuilder();
convertJsonToExcelProcess.Start ();
convertJsonToExcelProcess.BeginOutputReadLine();
convertJsonToExcelProcess.BeginErrorReadLine();
convertJsonToExcelProcess.WaitForExit();

好了,基于以上的两段示范代码,实际上我们已经能很好地完成在 Unity Editor 中同步执行外部程序,配合 Unity Editor 工具脚本完成各种任务了。

深入理解 iOS App 开发过程中的数字证书和授权文件以及签名过程

在做 iOS 开发的过程中,我们不免都碰到过跟证书相关的种种问题,而这些问题我相信很多人都是比较懵的,即便我们很多时候知道了具体怎么去做可以让我们能成功的编译某个工程并且打包发布到 AppStore 上去,但是我想其实我们还是有很多的东西需要去挖一挖的。

为什么要数字证书?

做个 App 又不是念大学或者考职称对吧,要啥证书?你咋不要自行车呢?不过我们回过头来看看,证书这个东西实际上并非什么新鲜的东西。我想大家在 Windows 上都安装过很多应用程序对吧,大概是从 Windows 7 开始,有些安装程序在开始安装之前,我们会看到系统会弹出一个提示框,提示这个应用程序是哪家公司开发的,我们是否确定要安装,点击确定之后才会开始真正的程序安装,对吧。

那么 Windows 怎么知道这个应用是谁开发的呢?难道我们自己在安装程序里头直接写明是谁开发的就好了吗?是这么回事,也不是这么回事。如果大家随便说这个程序是谁谁谁开发的,那么做恶意软件的人就可以随意将自己的木马程序伪装成任何一家有公信力的大公司了,例如微软自己等等。这显然是不可接受的,为了让这个机制正常运作起来,就有了一个公开的可信的签名和验证机制,大家按照这个机制的规则来,就可以避免出现伪装的这种情况了。

这个数字签名的机制保证了 Windows 可以在完全不知晓你要安装的具体是什么软件,即便这个软件是在我们使用的 Windows 系统发布好多年之后才发布的一款软件,只要这款软件的安装程序按照这个签名的规则对其自身的二进制安装文件进行签名,那么 Windows 就可以根据规则将其签名信息读出来,然后展示给最终用户也就是我们,然后再将判断的主动权交给我们,让我们自行判断你要安装的这款软件是否是出自这家公司的,如果是当然最好了,果断确认安装即可,如果不是,那么就要小心这个是不是恶意软件开发商搞的鬼了。搞得这么麻烦,究竟是为了神马捏?这个成本显然还是蛮高的嘛。

一切都是为了信息安全

在信息社会里头,数字安全一直都是一个很严肃但又时常被轻视的话题,由于数字信息传播的高速性,所以一个小小的安全问题往往可以被放大很多很多倍。为了安全,就会有各种加密手段来帮助我们达成信息安全的这个目的。在这里我们用人与人之间通信的这个过程来作为例子来简单地看看我们是如何通过加密来保证我们之间传送的信息是安全的。可以先从最简单最原始的人与人之间的通信开始,例如小明给小黄发了一封信,内容是「xxxxxx」,小黄收到的信内容也就是「xxxxxx」,这就是最原始的明文通信了。任何接触到这封信的人都是可以偷偷地阅读这封信的内容的,如下图:

原始的通信方式

原始的通信方式

那么为了防止别人随意偷看信件的内容,或者即便被人偷看了,也让人压根看不懂,就有了加密这种手段啊来确保只有通信双方能看明白信件的内容,其他的人即便看到了加密之后的信件内容也不会造成信息泄漏,最简单的加密情况是这样的,也称之为对称加密(相对于后面我们后头会讲到的非对称加密来说)。

对称加密的通信方式

对称加密的通信方式

在这种通信方式中的通信双方协商好一种加密和解密方法,并且必须持有同一个密钥才可以完成整个加解密的流程,而现实中这种方式会带来各种不便和其他的安全隐患问题,例如密码又该如何先给到通信的目标方,密码传送的过程中又如何保证其安全性,通信双方都持有同一个密码后无法区分双方的身份等等。所以就又了更为高级和方便的加密方法,就是非对称加密。

非对称加密的通信方式

非对称加密的通信方式

在这个通信方式里头的非对称加密方法已经可以做到让加密和解密的密钥是分离的,这样一来在发送方保存好自己的私钥不被泄漏的情况下,可以把公钥给到所有需要与他通信的目标方手里,就不会出现对称加密中收信方可以直接拿着与发信方共享的密钥去伪装成发信方的情况了。但是这个方案中还存在其他的问题,例如如何获取发信方的公钥以及如何确认获取到的发信方的公钥是可信的。这个时候数字证书就应运而生了。

关于密码学相关的内容,建议大家参考一下阮一峰写的几篇博客:

密码学笔记

RSA 算法原理(一)

RSA 算法原理(二)

图解 SSL/TLS 协议

数字签名是什么?

数字证书和数字签名是怎么运作的?

那么这个数字签名机制究竟是怎么运作的呢?下图就是一个证书用户如何创建并应用数字证书的流程:

数字证书的应用流程

数字证书的应用流程

图中的数字证书的申请和应用流程大体上就是:数字证书用户(开发者)创建密钥对 => 生成 CSR 文件 => 向 CA 申请数字证书文件 => CA 审核证书申请者提供的各项信息合法性 => CA 使用自身的私钥生成一个加密的数字证书文件 => 将数字证书发送给证书申请者(开发者)=> 证书申请者(开发者)收到证书之后,使用证书对需要分发的文件进行签名 => 将签名后的文件分发给最终用户。

我们还可以看看一个数字证书里头都包含着哪些信息和数据:

数字证书内容结构图

数字证书内容结构图

从上图中我们看到证书文件中包含了证书拥有者的公钥信息和 CA 机构的签名信息,以及相关的身份标识信息,通过这些信息我们就可以达成通过数字证书来验证被签名的内容是否可信了。

那么证书申请者在获得到证书之后又怎么做数字签名呢?下图就是一个对数字内容进行数字签名的流程:

生成数字签名的流程图

生成数字签名的流程图

那么数字签名生成之后,附加到了某个文件上分发给了最终的接收者,收到这个文件的人又是如何验证这个数字签名的合法性呢?下图就是一个数字签名验证的流程:

验证数字签名的流程图

验证数字签名的流程图

在了解完数字证书相关的基础知识之后,我们终于可以去揭开苹果开发中这个繁复的证书创建和应用的流程的面纱了,看看这货究竟要怎么搞才是对的,以及为什么需要这么搞才是对的。

iOS 应用的整个签名和验证的流程

既然我们已经铺垫了这么多,那么 iOS 应用开发涉及到的应用的签名,以及 iOS 设备又是如何验证设备上安装的应用的签名是否可信,然后允许安装这些 iOS 应用的呢?通过下面这张流程图,我们来看看 Apple 是如何设计其应用签名流程的。

iOS 应用签名的流程示意图

iOS 应用签名的流程示意图

在整个开发 APP 签名的流程中,我们只需要抓住三个文件,就可以完全 Hold 住整个签名打包的过程了:

  1. 创建 CSR 文件,在 macOS 上通过 Keychain Access 创建 CSR 文件的同时,实际上这货会同时帮你创建好对应该 CSR 的 RSA Key Pair 文件(这些密钥文件 Keychain Access 会自动帮你保存,后续编译打包的 Xcode 也都是直接通过 Keychain Access 来获取相关的密钥信息的);
  2. 上传 CSR 文件,创建 Certificate 文件,下载 Certificate 文件并导入到 Keychain Access 中,针对指定的 Bundle Identifier 和 Certificate 文件创建 Provisioning Profile 文件,下载并导入;
  3. 使用 Certificate 文件对应用进行签名,将 Provisioning Profile 文件打包到应用中。

然后我们针对以上这三个文件来看看它们是如何与上面我们叨逼叨了一大堆的数字签名流程对应的,这样我们在了解了数字签名的通用流程之后,再来看看 Apple 是如何在 iOS 应用的开发流程中应用这一通用的应用签名和验证技术的,如此印证一番,更能加深我们对数字签名远离的理解。

  • 创建 RSA Key Pair 和 CSR 文件(Keychain Access 自动完成了创建 RSA Key Pair 文件的过程);
  • 通过 CSR 文件请求 CA 颁发 Certificate 文件(由于 Apple 的整个生态系统相对封闭,整个申请证书的流程相对就简单并高效,只需要通过 Apple 提供的一系列工具和 Developer 后台即可完成,这个对于安全性的掌控非常的重要,对 Apple、开发者和用户三方来说,都尤为重要。正因为有了这一个封闭而又完整的系统,开发者可以将精力放在研发优质内容上毋需为了内容被盗版多费心力,用户也大可放心的安装 App Store 中的应用毋需过于担心被恶意软件或病毒骚扰,Apple 对于开发者和 APP 拥有绝对的生杀大权使得开发者不敢轻易挑战其底线。当然这个话题有点大,不展开了,况且咱对安全也不太专业,这仅仅是 Apple 对 iOS 系统安全控制所做的努力中极为基础和微小的一部分,咱别夸大了,😄);
  • 成功编译 APP 的工程后,使用 Certificate 文件对 APP 的文件进行数字签名;

    在这个过程中,Xcode 会在 APP 最终的目录下创建一个名为 _CodeSignature 的目录,在该目录下会有一个名为 CodeResources 的 plist 文件:

    iOS APP 数字签名文件目录

    iOS APP 数字签名文件目录

    这个 CodeResources 文件的内容如下:

    这个 plist 文件中会将整个 APP 中使用到的可执行文件以及资源文件的签名 Hash 值都罗列出来(当然有些文件是可以配置为忽略签名验证的,例如 CodeResources 文件,防止出现鸡生蛋蛋生鸡死循环的验证链死锁问题),文件中的 Hash 值就是使用我们在创建 CSR 文件时自动创建的 RSA Key Pair 中的私钥,通过某个 Hash 算法计算出来的。iOS 设备在安装一个 APP 的时候,会自动读取 APP 中 _CodeSignature 目录下 CodeResources 文件中的所有内容,然后使用随 APP 一并打包到 APP 内的 Provisioning Profile 文件中的 Certificate 通过同一个指定的 Hash 算法对 APP 包内所有文件进行 Hash 计算,然后与 CodeResources 文件中的 Hash 值进行比对,如果出现 Hash 值不一致的,签名验证失败,该 APP 肯定是无法正确安装运行的。

  • 基于已经创建的 Certificate 文件生成 Provisioning Profile 文件,这是 iOS 开发中特有的一个文件,该文件针对 iOS 应用的分发方式不同而有所区别,一般有三种类型:
    1. Development,用于开发者将正在开发中 APP 安装到在 Apple Developer 中注册的 iOS 测试设备上;
    2. Ad Hoc,这个实际上跟 Development 没有太大的区别,也是用于将 APP 部署到已注册的 iOS 测试设备上,不过使用这个 Provisioning Profile 打包的 ipa 可以通过 OTA 方式直接安装,不需要从物理层面上拿着测试设备找开发者来安装 APP(实际上使用 Development 授权文件打包的 ipa 也可以安装到 iOS 设备上,不过得先拿到 ipa 文件);
    3. App Store,这就是最终提交 APP 到 App Store 中时打包必须使用的 Provisioning Profile 了,在我们上传 APP 到 App Store 的过程中,Apple 会自动验证当前上传的 APP 是否使用了正确的 Provisioning Profile 打包的。

      Apple 通过这一额外的 Provisioning Profile(授权文件)机制很好地实现了在不同场景下的应用分发控制,由于 Development 和 Ad Hoc 都有明显的安装设备数限制(100 台设备),而且需要事先将目标安装设备的 Device Identifier 注册到 Apple Developer,这就意味着开发者如果想让自己的 APP 安装到大量用户的 iOS 设备上,只能选择使用 App Store Provisioning Profile 打包自己的 APP 然后上传到 App Store(当然实际上绝大部分用户也都是通过 App Store 来寻找并安装 APP,这两者并没有什么直接的因果关系,只是 Apple 的设计使然)。

      所有的授权文件都包含了以下的信息:

      1. 用于签名的证书,授权文件中包含有用来给该 APP 签名的数字证书的内容,如下图:

      iOS Provisioning Profile 文件中包含了数字证书的内容

      iOS Provisioning Profile 文件中包含了数字证书的内容

      2. APP 的 Bundle Identifier(该授权文件只能随指定包名的 APP 一起打包进行分发);

      3. 分发方式:Development、Ad Hoc、App Store、Enterprise 中的某一个(指明该 APP 的分发方式,iOS 设备在安装和启动时会根据授权文件声明的不同分发方式,进行不同的处理,例如使用 Enterprise 方式分发的应用,Apple 可以随时吊销该应用的授权文件,授权文件被吊销之后就无法再正常启动了,正常情况下,这类 APP 启动之前,iOS 会弹出一个非常明确的提示框告知用户当前正在使用一个由开发者 XX 开发并使用企业授权文件分发的 APP,用户可以选择信任该开发者或者不信任,用户确认信任该开发者的授权文件后,APP 才能正常启动,如果是 Development 和 Ad Hoc 的授权文件,那么在安装的时候 iOS 系统就会先确认该 APP 中的授权文件中是否包含当前安装的目标设备的 Identifier,如果不在其列,该 APP 是无法成功安装到目标设备上的);

      4. 应用的能力(Entitlements),声明该 APP 能否使用 Apple Push Service 等 Apple 提供的公共服务,以及该 APP 与同一开发者发布的 APP 直接协作的权限等等;

      5. 应用安装的有效日期,声明使用该授权文件打包的 APP 的过期时间,过期后该 APP 无法再被安装到 iOS 设备上。

iOS 开发中的数字证书配置流程和使用方法

iOS 开发过程中大家都绕不过去的就是这个证书和授权文件的配置,生成和使用问题,我们希望能在这片文章里头把这件事情给搞清楚了。

通常我们会怎们做?

相信接触过 iOS 开发的童鞋们都已经很了解以下的这些步骤了,不过我们既然要把这个问题搞透彻一些,那么我们就还是假装我们自己神马都不会吧,从零开始,我们一步步地操作一下,看看究竟如何才能编译一个应用并且能把这应用装到我们的设备上呢。

创建一个 App 工程

关于如何创建一个工程,这个不是我们这篇文章所要聊的,所以这里不展开聊如何使用 Xcode 进行 App 的开发了,这也不是我所擅长的更不能瞎说。

好了,言归正传,我们先看看下面这两个截图。
这是未设置开发者账号的 Xcode 工程的基本设置信息页面:

未设置开发者账号的 Xcode 工程基本设置页面

未设置开发者账号的 Xcode 工程基本设置页面

这是设置为我个人账号的 Xcode 工程基本设置信息页面:

设置为不可用的个人账号的 Xcode 工程基本设置信息页面

设置为不可用的个人账号的 Xcode 工程基本设置信息页面

这是设置为可用的企业开发者账号的 Xcode 工程基本设置信息页面:

正确设置了开发者账号的 Xcode 工程基本设置页面

正确设置了开发者账号的 Xcode 工程基本设置页面

大家可以看到第二个截图中,我使用的 Team 账号是我个人的帐号,而第三个截图中我使用的是公司的开发者帐号,这有什么区别呢?

  1. 帐号的主体不一样,申请的流程也不一样,如果是个人帐号的话,通常我们只需要有一个 Apple Id,然后登录 http://developer.apple.com 网站注册开发者服务即可,然后选择 iOS Developer Program 进行付费购买即可,而企业帐号就必须有一个企业组织的邓白氏码(这货是有一个专门的第三方机构在做,目前国内已经有官方代理商,通常办理都是免费的,只需要提交信息就可以获得一个邓白氏码,一般是申请成功后,15 个工作日后方可在苹果的 Developer 网站使用,不过这个数值不太准确,这个应该是最保守的估计。),有了可用的邓白氏码之后就可以直接付费开通 iOS Developer Program 了,开通服务器以后,实际上在 Developer 站点上个人帐号跟企业帐号之间应该是没有什么太大区别的,主要都是用来管理 App 的 Bunder Indentifier,开发者的证书文件和开发设备列表,以及各种授权文件了,当然还有用户管理啦,毕竟咱们现在开发任何一个 App 通常都是需要多人协作来完成的,所以在这个里头也可以完成开发者的邀请,开发者权限的管理等等操作。
  2. 实际上在完成了开发阶段之后,我们还需要使用这个开发者帐号登录到一个叫 iTunes Connect 的站点中,进行 App 的发布前的提交工作,提交 App 的名字、描述、截图、售价以及内购支付相关的诸多选项,所有提交需要的信息都准备完毕之后,我们就可以着手在 Xcode 中将打包好的 App 提交到 iTunes Connect 上了,提交完毕之后就等审核了,审核通过之后就可以在 App Store 中正式发布我们的 App 了,这样用户就能下载到我们的 App 了。那么在支付相关的选项上,个人帐号和企业帐号之间应该是有区别的,个人帐号我们只需要提供一个个人的银行卡即可,而企业可能需要提供的信息就更多了,包括银行开户信息,企业工商和税务的信息可能都需要酌情提交,由于这个事情之前处理的事情有点久远了,已经忘得差不多了,有机会再补充吧。本质上结算应该也不会有什么区别,最终都是苹果按照一定账期定期会结算并汇款到指定的银行账户,记得应该是两个月的账期。

好了,不扯远了,既然第一个 App 没有正确地设置授权文件,那么我们尝试着点一下那个 Fix Issue 按钮看看行不行呗,遗憾的是不好使,因为我这个个人帐号是没有购买 iOS Developer Program 服务的,也就是说我只能开发 App,但是不能发布 App,如果需要发布的话,还是必须购买 iOS Developer Program 的,之前有看到报道说苹果开放了 Developer 的资源,貌似现在不需要购买 iOS Developer Program 也能开发 App 并且可以安装到自己的设备上,但是实际测试的结果并非如此,从👆 上面第二张截图中来看,这个方法貌似并不可行。

请注意: 以上三张截图中,我们都可以清楚地看到勾选了一个 Automatically manage signing 选项,勾选这个选项之后,我们就可以完全将创建 Bundle Identifier,生成 Certificate 文件,创建 Provisioning Profile 文件这些无聊繁琐而又很容易把人搞懵逼的事情委托给 Xcode 了,Xcode 会在 Build 之前帮我们把所有需要做好的事情都做好。在 Xcode 8 某个版本之前(原谅我无法记住这些玩意儿),Automatically manage signing 还不存在的时候,实际上是有一个叫 Fix Issue 的按钮的,那个时候如果为 Xcode 工程设置好了某个 Development Team 账号之后,Xcode 无法在本机找到可用的对应当前 Xcode 工程的 Bundle Identifier 的相关的数字证书(Certificate)和授权文件(Provisioning Profile)的话,会提供一个快捷修复的按钮给开发者的。咱们来看个之前版本的项目设置页面的截图吧:

Xcode未提供自动托管APP签名之前的设置页面

Xcode 未提供自动托管 APP 签名之前的设置页面

虽然现在不再有 Fix Issue 按钮了,但是我们也可以了解一下,这个 Fix Issue 按钮究竟会干些什么。

按照直觉来讲,Xcode 是不是应该先在 Developer 中新建一个 App Bundle Indentifier,正如我们截图中的这个 App 的 Bundle Indentifier —— “com.laputa.playcard”,然后再在 Developer 站点上创建数字证书(如果需要的话),生成授权文件呢?嗯,我们的直觉非常的准确。作为一个好程序员,我一直认为这种直觉来源于不断地进行逻辑和程序化的思维训练,这种逻辑和程序化的思维是很容易训练和形成的,而且非常必要,思考问题时我们要先站到设计者或者实现者的角度上去看,看看如果是我们自己来设计或者实现的时候会按照什么样的套路来做,最后再跟对方的设计和实现验证一番,慢慢地我们会发现实际上大部分的设计和实现真的跟我们之前的直觉是一致的,或者说大体上会保持一致,这是一种很好的思维训练,而且会很利于我们更深入地去理解对方的设计和实现的意图和思路。

实际上点击 Fix Issue 按钮之后,Xcode 会默默地帮我们做以下的一些事情:

Xcode Fix Issue 流程示意图

Xcode Fix Issue 流程示意图

不过这个 Fix Issue 按钮已经不见了,所以现在我们没法到 Apple Developer 后台一一再次验证一下是否会创建对应的文件了,有点遗憾,所以我们可以暂时先把它给忘了。在最新的 Xcode 8 中,Xcode 虽说会自动帮我们管理签名相关的东西,但实际上原理应该是一致的,只是 Xcode 与 Apple Developer 后台服务交互的所有过程,以及整个过程中创建的所有文件都给隐藏起来了(我想这可能是防止有人在 Developer 后台误操作导致出现一些不必要的问题而设计的),Xcode 依然会通过上面截图中的流程在后台默默地帮我们把这些事情给做了。

那我们到 Apple Developer 后台去看一看吧。
Developer 中关于 App IDs 设置的页面:

Developer 中关于 App IDs 设置的页面

Developer 中关于 App IDs 设置的页面

Developer 中关于证书的设置页面:

Developer 中关于证书的设置页面

Developer 中关于证书的设置页面

Developer 中关于授权文件的设置页面:

Developer 中关于授权文件的设置页面

Developer 中关于授权文件的设置页面

从这三个截图中来看,实际上我们并没有看到任何跟 “com.laputa.playcard” 相关的东西,Xcode 8 的 Automatically manage signing 真的是完全不留痕迹,有木有?

有了 Xcode 8 的 Automatically manage signing 功能选项,实际上这篇文章看起来就有点多余了。你说你讲这么多对我有个毛的用捏,是伐?伦家 Xcode 已经帮你搞得妥妥的了,干嘛要给自己找不开森呢?你死磕这玩意儿有意思吗?

有,太有了。如果我们只需要使用 Xcode 进行简单的编码和编译打包的话,那么止步于此并没有啥问题,但是我们是程序猿啊,有木有?我们是一群懒人啊,有木有?我们肯定会利用持续集成服务来帮我们省去每次需要自己手动编译打包这种无聊重复工作浪费的时间啊,有木有?而且还有 Unity 3D 这个奇怪的,每次打包 iOS 包都需要借助 Xcode 中间工程的鬼噢,难道说每次等待 Unity Editor 编译出 iOS 工程,再手动打包 APP 不蛋疼吗?

好吧,那么我们需要持续集成来帮我们缓解这个蛋疼,有木有?而 iOS 工程的持续集成就需要咱们尽可能多地去了解 iOS APP 整个的签名和打包的机制了,掌握了整个 iOS APP 签名打包的机制和流程之后,在配置 iOS 工程持续集成时就能做到游刃有余。

下一次,我们再来好好分享一下,如果使用 Jenkins 持续集成 Unity3D 工程编译打包 Android 和 iOS 平台的安装包。

参考资料

密码学笔记

RSA 算法原理(一)

RSA 算法原理(二)

图解 SSL/TLS 协议

数字签名是什么?

Migrating Code Signing Configurations to Xcode 8