每次看到iOS的远程消息推送,总是感觉很头大,即便后来项目都做完了,还是觉得摸不着远程推送的脉门,网上介绍的资料虽多,但不是写的太简单了,就是写的太详细了,不能一下抓住要点,今天终于能够抽出点时间,来扒一扒这其中究竟有怎样的奥秘。
根据苹果掌控一切的习惯,消息推送也当然不能例外,不论你在哪里推送,也不论你用什么方式推送,都必须首先把消息发给苹果的消息推送服务器APNs(Apple Push Notification Service),然后再由APNs发给指定的设备,也就是说消息推送的控制权完全掌握在苹果手中。
有张很经典的图来描述整个消息推送的过程,我们也借鉴一下
由此图可以看出,要想进行消息推送,首先必须获取deviceToken,由要接收消息的iOS设备向APNs发送请求,APNs根据设备的请求信息来验证其是否合法,验证通过之后,会给设备发来一个deviceToken,这个东东就能够唯一标识这台设备(貌似deviceToken是设备的identifier经过Apple私有算法而生成的,总之,知道deviceToken可以唯一标识一个设备就可以了),然后再使用deviceToken和其他资料就可以进行消息推送了。
根据我经常提到的最小系统原则(用最少的代码实现需要的功能),远程消息推送实际上可以分为两步进行:
1.获取deviceToken,只有获取到deviceToken,才拥有向这台设备推送消息的资格;
2.使用deviceToken作为门票向设备推送消息(当然还有其他的东东)。
所以我们可以把任务细分,第一步就是要获取设备的deviceToken,只要能成功获取到deviceToken,我们便成功了一半。
第一步 :获取 deviceToken
说起来很简单,但实际上还是需要花些功夫,这主要归功于苹果层层的身份验证。
要想推送消息,必须首先申请推送证书,跟开发证书和发布证书一样,推送证书也分为开发版和发布版两种,当然,要获取推送证书,也必须像开发证书和发布证书那样向苹果进贡,即申请付费版的苹果开发者账号,关于苹果开发者账号的相关问题可以参看我前面写的文章,这里不做介绍。与开发证书和发布证书不同的是,推送证书与具体的App相互绑定,也就是说,开发版和发布版证书,都可以同时服务于多个App,但是推送证书只能服务于一个App,在创建的时候就必须指定App ID,且无法修改。当然,因为这个,推送证书并不像开发证书和发布证书那样最多都只能申请2个,推送证书并没有个数限制。
下面正式开始我们的获取deviceToken之旅,我假定你已经拥有苹果开发者账号并能够熟练使用。
1.申请App ID
创建一个新的应用需要一个新的 App ID,申请App ID记得开启消息推送功能
App ID申请成功后会有如下页面
从这里也可以看出推送证书分为开发版推送证书和发布版推送证书,黄色的 Configurable 表示还没有创建该App ID 对应的推送证书
2.创建开发版推送证书
注意区分开发版推送证书和发布版推送证书
因为推送证书是与具体App绑定的,所以要选择 App ID,此处选择我们刚刚创建的 App ID
选择证书请求文件
关于如何生成请求文件我会在后续文章中介绍
开发版推送证书申请成功,点击 “Download” 按钮 下载安装
安装成功后会自动跳转到钥匙串工具
这就是我们刚刚申请的开发版推送证书,与开发证书和发布证书不同的是,推送证书本身就带有 App ID,创建的时候就与某一具体的App绑定,只能服务于一个App,前面的 “Apple Development” 表示这是一个开发版的推送证书。
此时我们再回头去看App ID
此时 Push Notifications 的开发版选项变成绿色的 Enabled,而发布版选项依然是黄色的 Configurable,这说明我们生成了开发版的推送证书,没有生成发布版的推送证书。
3.创建Profile文件
因为我们创建了一个新的 App ID,要想在真机上调试该应用,就必须创建相应的Profile文件,因为开发证书是通用的,所以此处可以不创建新的开发证书,但Profile文件也是与具体 App 绑定的,所以必须新建一个
Profile文件也分为开发版和发布版两种
选择我们刚刚创建的App ID
选择开发证书(注意是开发证书,不是刚刚创建的推送证书),此处我使用以前创建的,如果没有则需要创建一个新的开发证书。
选择移动设备
给新建的Profile文件起个拉风名字,以便编译前设置选择
Profile文件创建成功,下载安装
证书的问题解决了,下面就开始创建App了
4.创建 App
打开Xcode,创建一个Single View工程
工程创建成功之后,在AppDelegate.m的 didFinishLaunchingWithOptions 函数中输入注册远程通知的代码
if ([[[UIDevice currentDevice] systemVersion] floatValue] >= 8.0) { //iOS8.0以后注册方法与之前有所不同,需要要分别对待
UIUserNotificationSettings *settings = [UIUserNotificationSettings settingsForTypes: (UIUserNotificationTypeAlert | UIUserNotificationTypeSound | UIUserNotificationTypeBadge) categories: nil];
[application registerUserNotificationSettings: settings];
[application registerForRemoteNotifications];
}else{ //iOS8.0以前版本的注册方法
[application registerForRemoteNotificationTypes: (UIRemoteNotificationTypeAlert | UIRemoteNotificationTypeSound | UIRemoteNotificationTypeBadge)];
}
成功接收到deviceToken后会回调下面的方法
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken{
NSLog(@"DeviceToken: %@", deviceToken);
}
接收失败调用的方法
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error{
NSLog(@"RegisterError: %@", error.localizedDescription);
}
5. 运行 App
首先将 App 的 Bundle id 设置为我们刚刚生成的App ID
其次需要设置刚刚生成的profile文件和使用的开发版证书
然后便可以运行App了,真机上将会出现以下界面(一定要在真机上运行,模拟器无法申请到deviceToken)
该通知只会出现一次(当然把程序删除,重新安装又会重新出现),点击“好”将会接收远程消息
如果在Xcode输出栏看到下面信息
那么恭喜你,成功获取到了deviceToken,尖括号内的内容就是该设备的deviceToken。
获取deviceToken的工作完成了。
第二步:消息推送
消息推送,严格来说,这已经是服务器的工作了,现在市面上有很多的第三方消息推送平台可以用来帮助我们完成此项工作,比较有名的有 极光推送、个推、小米、友盟、华为、腾讯信鸽、百度云推送、Parse推送等等。
但是为了搞清楚推送的原理,我们还是选择自己来实现。
不借助这些第三方平台,我们自己也能完成简单的推送功能,苹果的APNs对大家来说都是一样的,第三方无非是多了一些其他的功能。
1. 准备原材料
首先下载刚刚生成的开发版推送证书 aps_development-15.cer
其次将编译时使用的开发证书的秘钥导出来生成p12文件 APNsDevTest001.p12 ,千万不要搞反了。
2.生成pem文件
由于推送服务对于Java语言,需要要用到p12格式,而 PHP语言,则要用到 pem 格式,因为我们使用的语言是PHP,所以要转换成pem格式,使用openssl来完成转换工作
首先将开发版推送证书文件 aps_development-15.cer 转换成pem文件 APNsDevTestCert.pem
其次将开发证书导出的私钥p12文件 APNsDevTest001.p12 转换成pem文件
转换过程需要输入3次密码,因为p12文件是有密码的,所以首先验证p12文件的密码,正确了才能往下进行,然后输入生成的pem文件的密码,这个密码要输两便,后面会用到。
最后将生成的两个pem文件合并成一个pem文件,这个文件在后面服务器推送时会用到。
好了,材料已经准备好了,开始下一步工作。
3.连接苹果推送服务器APNs,验证证书是否可用
苹果推送服务器也分为开发服务器和发布服务器两个,一定要分清楚
我们现在用的是开发版消息推送服务器:gateway.sandbox.push.apple.com:2195
首先使用telnet工具测试是否可以连接苹果APNs
如果看到以上信息,说明你的电脑可以连接苹果APNs,按Ctrl + C 取消,否则就要去检查是不是你的防火墙未开放2195端口
其次验证证书是否正确,此处用到合并之前的两个pem文件
输入命令:
openssl s_client -connect gateway.sandbox.push.apple.com:2195 -cert APNsDevTestCert.pem -key APNsDevTestKey.pem
如果看到后面的信息,说明生成的证书是没有问题的,可以进行消息推送。
4.消息推送
一切具备,开始消息推送
此处我们使用PHP语言作为推送语言
代码不多,需要将以下几个信息替换掉即可
a. 获取到的deviceToken,当然需要去掉尖括号,去掉中间的空格
b. p12文件转换成pem文件时输入的密码(后面输入两次的密码)
c. 合并之后生成的pem文件,必须放在php文件相同的目录下
d. 苹果APNs地址,一定要区分清开发版APNs和发布版APNs的地址
当然,你还可以修改显示消息的内容
下面附上PHP文件源码
simplepush.php
<?php
// Put your device token here (without spaces):
$deviceToken = 'b3e6888636a6ae3e29492a293125c01d448e1bec35b0b76a3894a19ba351dcf6';
// Put your private key's passphrase here:
$passphrase = '12345';
// Put your alert message here:
$message = '我的推送消息!';
$ctx = stream_context_create();
stream_context_set_option($ctx, 'ssl', 'local_cert', 'APNsDevTestCK.pem');
stream_context_set_option($ctx, 'ssl', 'passphrase', $passphrase);
// Open a connection to the APNS server
$fp = stream_socket_client(
'ssl://gateway.sandbox.push.apple.com:2195', $err,
$errstr, 60, STREAM_CLIENT_CONNECT|STREAM_CLIENT_PERSISTENT, $ctx);
if (!$fp)
exit("Failed to connect: $err $errstr" . PHP_EOL);
echo 'Connected to APNS' . PHP_EOL;
// Create the payload body
$body['aps'] = array(
'alert' => $message,
'sound' => 'default',
'badge' => 99,
);
// Encode the payload as JSON
$payload = json_encode($body);
// Build the binary notification
$msg = chr(0) . pack('n', 32) . pack('H*', $deviceToken) . pack('n', strlen($payload)) . $payload;
// Send it to the server
$result = fwrite($fp, $msg, strlen($msg));
if (!$result)
echo 'Message not delivered' . PHP_EOL;
else
echo 'Message successfully delivered' . PHP_EOL;
// Close the connection to the server
fclose($fp);
?>
确认信息无误之后执行PHP脚本
看到上面的信息说明消息推送成功
手机端显示(需要将应用切回后台,应用在前台默认是不会弹出通知消息的)
有没有点小激动呢,伟大的工程终于完工了。
开发版的消息推送掌握了,发布版的消息推送应该也能搞定了吧,这里就不做介绍了。
通过自己完成整个推送过程至少可以搞明白一件事,如果你无法收到远程消息,那么首先就要去看是否能够获取到deviceToken,如果获取不到deviceToken,说明你的推送证书有问题或者是App ID 不匹配等等,如果能获取到deviceToken,那要么你没有把deviceToken上传给消息推送服务器,要么是传给消息推送服务器的推送证书有问题。
下面来做个总结
1.苹果消息推送服务器APNs地址
开发版:gateway.sandbox.push.apple.com 2195
发布版:gateway.push.apple.com 2195
2.证书的时效
开发版:有效期大概四个月左右
发布版:有效期是一年
3.deviceToken的唯一性
在iOS7之前,苹果对于一个设备上的多个App,生成相同的deviceToken,iOS7之后(包括iOS7),苹果对于一个设备上的多个App,生成不同的deviceToken,经测试,同一个App,删除之后重新编译安装,获取到的deviceToken也不相同。也就是说一个deviceToken只能对于一个iOS设备,但一个iOS设备可以同时拥有多个deviceToken。
这种新改变导致APNs上创建了一张新老token的映射表,如果你一直用老的token,那没问题,但是,一旦服务器使用新的deviceToken,映射表中的记录就会被删除,这意味着,老的deviceToken就不能用了,必然发送失败。
4. 消息时效
如果推送的时候deviceToken对应的机器在APNs服务器上是离线状态,苹果会保存推送信息“一段时间”,当机器恢复在线状态时,推送信息到该机器。如果机器长时间不在线,苹果会抛弃掉这条消息。这个“一段时间”没有明文说多久,而且不知道苹果在不同情况下对这个时间有没有动态调整,所以无法推测这个时间对于信息丢失情况的影响。
对于连续推送的情况,针对离线设备,苹果永远只存储最新的一条,上一条信息会被抛弃。
有多条推送任务时,苹果推荐使用单个连接持续发送,而不是重复的开关连接,否则会被苹果认为D-O-S攻击给拒绝掉。如果有多台服务器,可以并发连接到APNS,分摊推送任务,可以更高效的执行任务。
发送多条推送任务时,如果其中有一条推送使用了错误的deviceToken,那么连接就会被断掉,导致后面的推送任务停止执行。苹果通过一个“The Feedback Service”的服务来定期告知provider无效的deviceToken列表,如何使用这个服务参见苹果官方文档中的详细说明。
5.推送原理
我们知道,iOS设备对于应用程序在后台运行有诸多限制,考虑到手机电池电量,系统不允许应用在后台进行过多的操作。因此,当用户切换到其他程序后,原先的程序将无法保持运行状态。对于那些需要保持持续连接状态的应用程序(比如社区网络应用),将不能收到实时的信息。为解决这一限制,苹果推出了APNs(苹果推送通知服务 Apple Push Notification services),APNs是解决轮询所造成的流量消耗和电量消耗的一个比较好的解决方案。
APNs 允许iOS设备与苹果的推送通知服务器保持常连接状态,当你想发送一个推送通知给某个用户的iOS设备上的应用程序时,你可以使用 APNs 发送一个推送消息给目标设备上已安装的某个应用程序。苹果的推送服务APNs基本原理简单来说就是苹果利用自己专门的推送服务器(APNs)接收来自我们自己的应用服务器发过来的需要被推送的信息,然后推送到指定的iOS设备上,再由iOS设备通知到我们的应用程序,设备以通知或者声音的形式通知用户有新的消息。
推送的前提是装有我们应用的设备需要向APNs服务器注册,注册成功后APNs服务器会将我们的设备加入到Push服务的设备列表中,同时返给我们一个 deviceToken,拿到这个 deviceToken 后我们将这个 deviceToken 发给我们自己的应用服务器,当有需要被推送的消息时,我们自己的应用服务器会将消息按指定的格式打包,然后结合设备的 deviceToken 一并发给APNs服务器,APNs会在自己维护的Push设备列表中查找,找到匹配的设备后,将消息发送到这些设备上,实际上,此时的 deviceToken 就相当于一个设备的地址。由于我们的应用和APNs维持一个基于SSL协议的TCP流通讯长连接,APNs能够将新消息推送到我们设备上,然后在屏幕上显示出新消息来。
需要注意的是 App 需要每次启动的时候都去注册远程通知,但是这并不会带来额外的负担,因为iOS系统在第一次获得了有效的 deviceToken 之后,会本地缓存起来,以后 App 再调用 registerForRemoteNotifications: 的时候会立刻返回,并不会再进行网络请求。这个工作由iOS系统来做,而我们的 App 层面不应该对 deviceToken 进行缓存,因为 deviceToken 也有可能变化—— 比如重装系统或重装应用都会造成deviceToken的改变,又或者是,用户restore了原来的backup到新的设备上,那么原来的 deviceToken 也会失效。
6.推送过程
Provider: 为指定iOS设备应用程序提供消息推送的服务器,即我们自己的用来推送消息给APNs的服务器
APNS: 苹果消息推送服务器Apple Push Notification Service;
iPhone: 用来接收APNs下发下来的消息的iOS设备;
Client App:iOS设备上的应用程序,用来接收APNs下发消息的客户端 App(消息的最终响应者)
推送过程可以归纳为以下几步:
1.用户iOS设备上的某个App使用设备id向APNs注册远程消息推送服务
2.APNs验证请求的合法性,验证成功后生成该设备的deviceToken,
在自己维护的Push设备列表中加入该设备,并将deviceToken返回给请求者
3.用户App成功获取到deviceToken之后,将deviceToken提交给自己的消息推送服务器Provider
4.有消息需要推送时,Provider会将要推送的消息和目标设备的唯一标识按指定格式打包,发给APNs
5.APNs在自己维护的Push设备列表中查找相应标识的iOS设备,找到后将消息发送给该设备
6.iOS设备接收到APNs下发的消息后,根据标识传递给对应的App,并按照设定格式弹出Push通知。
注意:
Provider只是把消息发送给了APNs,由APNs完成真正的推送工作,我们自己的应用服务器只是将需要推送的消息告诉苹果服务器,至于如何维护消息队列或如何保证消息能被推送到指定的设备上,这些工作都由APNs帮助我们完成。
deviceToken用来唯一标识一个设备,App ID用来唯一标识一个用于,二者结合就能找到指定设备上的指定应用。
7.APNs推送接口
Apple 为应用开发者提供了一个 APNs 推送接口,称为 binary interface
最初版本的 binary interface 协议如下图,这里我们称之为 v1。
Binary Interface v1
v1协议包括五个部分:第一个部分是命令标示符,第二个部分是我们的deviceToken的长度,第三部分是deviceToken 的内容,第四部分是推送消息体(payload)的长度,最后一部分也就是真正的消息内容了,里面包含了推送消息的基本信息,比如消息内容,应用icon右上角显示多少数字以及推送消息到达时所播放的声音等。
v1 协议有几个问题:
消息是否发送成功没有明确的反馈;
如果一个消息发送失败,比如因为 deviceToken 不合法,APNs 会在大约 500ms 后断掉链接,在断链前发送的消息也会发送失败;
经我们验证,feedback service 只会报告应用被卸载后,造成 deviceToken 失效的错误。而不会报告 deviceToken 不合法这种类型的推送错误。也就是说如果我们给一批用户发消息,只要有一个 deviceToken 不合法,将会有可能造成若干个用户收不到消息。并且没办法确认哪些 deviceToken 不合法,哪些 deviceToken 需要被重发。这应该是 APNs 丢消息的一个重要的原因。
经过开发者不断的向 Apple 反馈这个问题,Apple 终于推出了一个新版本的 binary interface,称为 enhanced binary interface,我们称这为 v2。
Binary Interface V2
我们发现,在 v1 的基础上增加了两个字段:
Identifier:一个任意的值,用于一条消息的识别。如果发送出现问题,错误应答里会把 Identifier 带回来。
Expiry: 离线消息超时的时间,如果为0或者小于0,APNs 不会保存这条消息。
和 v1 一样,如果消息发送没有问题,APNs 不会有任何返回。和 v1 不同,并且很重要的改进是,如果发送出现错误,v2 会在断链之前返回一个错误应答,带上发消息时的 Identifier 和一个错误码。
error-response packet
根据这个错误应答,我们有机会找到是哪条消息发送出错,并确定哪些消息需要被重发。
有一种情况,当我们将应用从设备卸载后,推送的消息改如何处理呢?我们知道,当我们将应用从设备卸载后,我们是收不到Provider给我们推送的消息的,但是,如何让APNs和Provider都知道不去向这台卸载了应用的设备推送消息呢?
针对这个问题,苹果也已经帮我们解决了,那就是Feedback service。他是APNs的一部分。APNs会持续的更新Feedback service的列表,当我们的Provider将信息发给APNs推送到我们的设备时,如果这时设备无法将消息推送到指定的应用,就会向APNs服务器报告一个反馈信息,而这个信息就记录在Feedback service中。按照这种方式,Provider应该定时的去检测Feedback service的列表,然后删除在自己数据库中记录的存在于反馈列表中的 deviceToken,从而不再向这些设备发送推送信息。
连接Feedback service的过程同样使用Socket的方式,连接上后,直接接收由APNs传输给我们的反馈列表,传输完成后断开连接,然后我们根据这个最新的反馈列表再更新我们自己的数据库,删除那些不再需要推送信息的设备的deviceToken。
从Feedback service读取的数据结构如下:
结构中包含三个部分:
第一部分是一个时间戳,记录设备失效后的时间信息;
第二个部分是deviceToken的长度
第三部分就是失效的deviceToken
我们所要获取的就是第三部分,跟我们的数据库进行对比后,删除对应的deviceToken,下次不再向这些设备发送推送信息。
8.消息大批量发送问题
由于APNs本身机制的原因,目前easy APNs的消息发送机制为:
对每一条发送的消息,为所有需要推送的设备都在数据库中创建一条消息,然后通过轮训数据库表来一条一条向苹果消息推送服务器ANPs发送消息,也就是,如果有10W台设备,就需要发送10W次。这种方式必然会导致在需要推送的设备较多的情况下,由于存在大量的网络链接,从而产生较长时间的延迟。
也就是说,APNs消息推送不支持群发,只能一个一个发.如果你的App有100万个用户,就只能老老实实发100万次。
9.消息提醒表现形式
4种:徽章、提示框、声音和横幅
10.消息推送机制的优点
财大气粗的苹果提供了一堆服务器来保障每个iOS设备和这些服务器保持了一个长连接,iOS版本更新提示,手机时钟校准什么的都是通过这个连接。
苹果把这个长连接开放出来给大家推送消息用,很积德,因为这是个全球服务,几十亿台iOS设备,服务器少说也需要上万台,还没有钱可以赚。Android爸爸就不做这个,于是各个App为了发消息,只能直接拼命赖在后台维持一个长连接,电就是这样被耗光的。
苹果提供的APNs,只是长连接机器的一部分,你要向你的用户发消息,必须通过APNs中转,由Provider发给APNs,APNs转发给你的手机,你的推送程序和用户手机没有直接联系。
我们知道,iOS系统不允许应用在后台活动,这样做的好处我想用过iOS设备的人都知道,省电是一方面,最明显的就是无论你安装了多少应用,无论你打开了多少App,只要存储空间还够,就不用去删除应用,不会因为程序安装或打开太多而卡顿。
但是有了这个限制,对于终端设备,有些应用又是有必要“通知”到户的,需要随时与用户主动沟通起来的,比较典型的如QQ、微信等即时通信软件。于是,APNs应运而生。
APNs 的做法是:iOS系统自己做了个长连接,依托一个或几个系统常驻内存的进程运作,保持与APNs之间的通讯,接管所有应用的消息推送,独立于应用之外,而且这种长连接一直保持,即使在手机休眠的时候。对于大部分人来说,最不理解的就是,休眠时候都保持在那里的 TCP 长连接,不会耗电很厉害么?
答案是:不会。这是手机的设计来做到的。TCP长连接有个心跳的时间,在国外可以很长,比如30分钟,在国内则因为网络环境复杂一般10分钟。客户端发起的心跳,会短暂地消耗手机电能,但在这个心跳间隔期间,则消耗电能是很少的。当在心跳期间服务器端有推送信息过来时,客户端可以收到并做处理。
APNs存在的好处是:
1.安全:只有身份验证成功者才可以通过APNs推送消息
2.快速,稳定,可靠:这一点由APNs和iOS系统来保障
3.更省电:有了APNs就可以实现App无需后台运行
4.让整个系统的体验更统一和简单:不用大量 App和App的服务为了推送挂后台,
也不会出现 App 被杀就收不到推送这种脑残事
5.开发容易:当然,开发者还是要做些事情,比如维护个服务器什么的,但是复杂度无疑降低了很多
当然苹果还是要为此付出一些代价的:苹果需要维护一个代价不小的服务器集群,而且要为服务器的宕机负责。
苹果承担责任,尽可能的减少了不可控的意外,保证了用户体验。这,只能说是公司决策者的功劳。
其实,现在手机主流平台都有自家提供Push的功能,让应用开发者能够很方便地把Push能力集成到应用中。
Android: GCM (Google Cloud Messaging)
iOS: APNs(Apple Push Notification service)
Windows Phone: MPNs(Microsoft Push Notification service)。
由于Windows Phone的市场占比不高,所以一般也就没有人会专门做WP系统的推送。
至于GCM在国内基本上是不可用的。原因主要有以下两点:
1.国内大部分Android手机都不带Google服务,也就用不了GCM,这是主要的问题。2.在国内Google的服务一般都不太稳定,原因你懂的。
所以现在正常在用也只有iOS平台的APNs服务。
除了官方推送服务之外,还有很多第三方的推送服务可用,如极光推送、百度云推送等等,但他们使用的基本技术都是长连接和心跳包。
啰啰嗦嗦唠叨了这么多,最后借用一张很有意思的图片来结束这片文章吧。
参考文章:
http://www.cocoachina.com/industry/20140528/8582.htmlhttp://www.360doc.com/content/15/0118/17/1073512_441822850.shtml
http://www.360doc.com/content/15/0414/19/1073512_463201381.shtml
http://blog.sina.com.cn/s/blog_6f9a9718010128hi.html
http://www.cocoachina.com/industry/20130321/5862.html
http://www.cnblogs.com/iphone520/archive/2013/04/16/3023687.html
http://my.oschina.net/w11h22j33/blog/208744
http://www.zhihu.com/question/20667886