Svg Filter 相关的几个问题

在用 Leaflet 处理 GeoJSON 格式的地理信息时,解析结果被以内联的 SVG 格式嵌入网页中。为了使地图更有质感,希望通过添加阴影获得视觉上的高低效果,尝试过程记录如下。

css

首先,<svg> 内的元素是支持 css 样式的,用法也和普通的 css 一样,可以通过 id 或 class 来套用样式,但是 svg 元素具体支持的规则和普通 DOM 元素相差很大

box-shadow

box-shadow 不支持 svg 内部的元素

filter

filterdrop-shadow 也可以制造阴影效果,但是目前只有 Edge 支持对 svg 内部元素使用

元素

1
2
3
4
5
6
<defs>
<filter id="shadow-water">
<feGaussianBlur out="blurOut" in="SourceGraphic" stdDeviation="5"></feGaussianBlur>
<feBlend in="SourceGraphic" in2="blurOut" mode="normal"></feBlend>
</filter>
</defs>

<filter> 本身就是 svg 标准的一部分,也是目前兼容性最好的,为 svg 元素添加阴影的方法,但是其本身并没有提供一个直接的添加阴影的方法,上面的例子里是通过将模糊后的图像和原始图像叠加实现的

<defs> 需要放置在 <svg> 元素内

需要使用此效果的元素,需要设置属性 filter=”url(#shadow-water)” 来应用

svg 元素的操作

在我的环境里,svg 是由 leaflet 生成的,因此自定义的 <defs> 只能通过代码动态的插入到已有的 <svg> 标签内,但是这里不能使用 jQuery 来操作,类似

1
$('svg').prepend('<defs> <filter id="shadow-water"> <feGaussianBlur out="blurOut" in="SourceGraphic" stdDeviation="5"></feGaussianBlur> <feBlend in="SourceGraphic" in2="blurOut" mode="normal"></feBlend> </filter> </defs>');

是行不通的,因为 <svg> 内的元素类型需要是 SVGElement 才能正常工作,而 jQuery 会将 defs 等元素当作普通 DOM 元素加入到 svg 内部,因此会出现从调试工具里看到代码和手动编写的 svg 文件一致,但实际上无法发挥作用的情况。

这里有一篇文章描述了这个问题,并提供了一个动态创建 svg 元素的方法

两个 svg 标签

但是上面提到的方法写起来比较麻烦,尤其是需要动态插入的标签数量比较多,并且有嵌套的情况。

最后我尝试了将 defs 写在另一 svg 里来解决这个问题。一个 svg 里的元素是可以引用另一个 svg 里的 filter 的,我预先创建了一个只包含 defs 的 svg 标签,内部只包含了几个需要用到的 filter 效果,而没实质性的元素。这个 svg 默认也会占据一定的屏幕空间,150x300,因此需要加上样式将其隐藏。

隐藏提供特效的 svg 标签

首先尝试 style=”display: none”,svg 标签被隐藏了,但是特效也一起消失;
然后尝试 style=”width: 0; height: 0”,特效得以保留,但仍占据了屏幕大约一行文字的高度;
最终改成 style=”position: absolute; width: 0; height: 0”,问题解决

Atom 编辑器 PHP 调试配置

环境

  • macOS 10.12
  • php 7.1
  • atom 1.16
  • brew 1.2

安装和配置

xdebug

打开命令行终端,输入以下命令

1
brew install php71 php71-xdebug

brew 是 macOS 下的包管理器,php71 是 php7.1 的包名,如果你使用其他系统,请自行替换成相应的命令

配置

xdebug 的配置在 /usr/local/etc/php/7.1/conf.d/ext-xdebug.ini 中[注1],增加下列配置

1
2
3
4
5
6
7
xdebug.remote_enable=1
xdebug.remote_host=127.0.0.1
xdebug.remote_connect_back=1 # Not safe for production servers
xdebug.remote_port=9332
xdebug.remote_handler=dbgp
xdebug.remote_mode=req
xdebug.remote_autostart=false

这里将 remote_port 设置成 9332 是为了避免和 php-fpm 的默认端口 9000 冲突,你也可以自行选择一个端口,但是必须和下面的 php-debug 插件中指定的端口一致

remote_autostart 设置成 false 可以配合浏览器插件,按需开启调试功能。

[注1]

如果你的配置文件不在这个位置,可以在命令行终端中输入

1
php -i | grep with-config-file-scan-dir

找到配置文件所在目录

php-debug

打开插件安装界面,搜索 php-debug

php-debug

点击 install 安装

配置

安装完毕,点击 Settings,打开 php-debug 的配置界面
找到 Server Port 选项,默认是 9000,改成 9332,和 xdebug 的 remote_port 一致。
server port

php-debug 安装完毕后,atom 的左下角会有一个带虫子图表的 PHP Debug 按钮,点击展开,在五个调试按钮的右边可以看到 Listening on port 9332…,如果你看到的还是 9000,说明 Server Port 的修改尚未生效,可以尝试重启 atom 编辑器。

xdebug-helper

xdebug helper 是一个 chrome 插件,让我们可以选择性的开启 xdebug 的代码调试和性能调优工具,可以在 php-debug 的配置页面找到它的链接,也可以直接在 chrome 商城查找。

配置

安装完毕后,chrome 地址栏右侧会多出一个虫子图标,右键点击,进入选项,在 IDE key 选择 other,右侧输入框填写你的 key,然后点击 save 保存。

输入图片说明

key 是怎么来的呢?在命令行终端输入:

1
php -i | grep IDE

会得到类似:

1
IDE Key => xiongliding

右边的值就是你的 key 了。

php -i 可以理解为命令行版的 phpinfo 页面。

对于 macOS 和 brew 用户,安装 xdebug 时会自动以你的用户名为 key。

测试

新建一个目录,创建一个简单的 hello.php 文件:

1
2
3
<?php
echo "Hello, World";
`

将命令行终端切换到该目录下,输入

1
php -S 0.0.0.0:8888

这时用浏览器访问 http://127.0.0.1:8888/hello.php 可以看到 Hello, World

现在,点击编辑器行号右边的位置设置断点,可以看到一个小圆点,行号变绿。

输入图片说明

切换到浏览器,左键点击 xdebug helper 的虫子图标,在弹出菜单选择 debug 。

输入图片说明

刷新浏览器,浏览器会保持在加载状态,回到编辑器,可以看到断点所在行已经高亮显示,下方 PHP Debug 的五个按钮也都变成可用状态,原来显示 Listening on port 9332… 的位置变为 Connected

输入图片说明

现在,你可以进入自己的项目目录,通过 php -S 建立临时的 php 服务来调试自己的代码了。如果原来使用的是 php-fpm ,那么重启 php-fpm 服务,也可以使调试在自己的项目中生效[注2]。

[注2]

默认前提是你的 php-fpm 和 php-xdebug 是用相同的包管理器安装的对应版本。

大规模数据上传的问题和解决

过完年第一天上班,就接到一个项目连夜出差去了。客户手头有大约 20 万条住房信息需要上报到一个指定的系统。这 20 万条记录由 200 多个员工花了超过一个月的时间到该县辖区内的各自然村挨家挨户上门采集,然后以乡镇-行政村-自然村的结构存放在 1000 多个 excel 文件中。excel 中的每条记录对应一套住房,同目录下,还有房屋的照片。

这些信息和图片都要上传到一个指定的网站,当然,没有接口。

所以,问题还是很清晰的,就是实现一个爬虫模拟请求,先登录网站,然后从 excel 中依次读取记录,将相应的信息和图片进行上传,重复这个过程。

常规问题

技术选择

其实没什么好选,我最熟悉的语言是 PHP 和 JavaScript,最终选了 Node.js 。平常处理网络请求已经用的很多,处理数据的话有 underscore ,模拟请求有 superagent ,处理 dom 内容有 cheerio ,想要控制队列和并发有 async ,处理 excel 有 xlsx 。性能和开发速度也有个很好的平衡。

验证码和保持会话

只要网站是需要登录的,做爬虫就会遇到这两个问题。验证码简单可以用算法识别,验证码复杂就人工介入,遇到验证码后下载到本地,然后弹出对话框等待人工识别后回填。会话保持的话 superagent 本身就有 cookie 机制,不是问题。

表单分析

每个请求需要发送什么内容,有哪些参数,如何和原始数据对应,这个工作琐碎而费神,好在有同事帮忙处理。

大局观

之前提到的问题都是纯粹的技术问题,以往的经验基本都已经覆盖到了,下面要讲的则是我在这个项目中最深刻的感悟。这些概念其实很早就接触过,或隐约有体会,但这次是深深的体验了一把。

每次只做一件事

顺利的情况下,上传一条记录包括下列步骤:读取一条数据、预处理一条数据、上传一张图片、拼接表单、发送请求、记录日志。

理想情况下,我可以先实现一个处理一条记录的方法,然后在外面套一层循环,问题就解决了。但事实上,每个步骤本身都很复杂,想要一步不错的走完这个流程还是有难度的,预处理时会发现数据不合要求、上传图片可能由于网络原因失败,代码质量的问题也可能导致程序意外终止。

更合理的做法是把整个流程用几个相对独立的小程序来实现,一个程序用来压缩图片,一个程序用来上传图片,一个程序用来检查数据质量,一个程序用来上报数据。就像 Unix 的管道一样,上一个工具的输出,可以作为下一个工具的输入。

比较值得一提的一点是,这几个独立程序的输出,必须以文件的形式保存下来。

这样做最大的好处就是让问题显而易见,每经过一个步骤,你都可以对产物进行检查,有没有出错一目了然。压缩失败,目录里的文件会缺,上传失败,上传结果汇总表的数据会比文件数量少,这比看程序运行时内存里的数据容易多了。

这些产物本身也是日志,比如上传时部分成功,部分失败,下一次上传的时候,可以根据上一次上传的结果,跳过已经成功的部分,只补传上一次失败的,简单的续传就实现了。这一点在数据量大的时候尤其重要,节约的时间非常可观。

不要相信用户

做网站的时候,出于安全的考虑,有一个原则叫做不要相信用户,因为总有人想要搞破坏,绕过你的防线。

我发现处理大量数据的时候也存在这个问题,你不能相信用户给的数据是符合要求的。这里,用户也想把数据质量做好,但确实无法做到。首先数据来源不一,要求 200 个人采集来的数据格式完全相同几乎是不可能的,即使经过培训,有统一模版,还有专门的宏来解决一些重复操作,也不可能。

一开始用户说我们负责程序,他们负责保证数据正确,我信了,结果我在加班。增加了一个功能,专门检查数据的正确性。

垃圾进,垃圾出 Garbage In Garbage Out GIGO

之前说到我们负责程序,他们负责数据正确性,看上去责任很明确。为什么我还是要做检查数据正确性的工作呢。一方面是为了互相配合更好的完成工作,另一方面是因为数据量大了以后,错误很难定位。

具体到这个项目中,在上传过程中,有一个地方需要将 excel 中的多条记录合并上传,比如根据身份证判断为同一个人,他名下的房屋要合并成一条记录后再上传,但实际上数据里存在两个人填了同一个身份证或者里面填写的房屋数量和实际条数不符的情况。

出现上述问题的情况下,按照正确数据格式设计的合并功能将无法正确工作,最终导致上报到系统中的条数和原始数据条数不一致。

那么问题来了,我如何证明程序是对的,错是在数据呢。从几万条数据里找到那条出错的数据,然后告诉客户“你给的这条数据有问题”吗?

世界上有很多东西,要证明正确是很难的,要证明错误却容易的多,程序就是其中之一。

最后一点,考虑的全面一点

这个太难了,经验会让你考虑到更多问题,却总是不能全面。这也是我写这篇文章的目的之一,对遇到的问题进行总结,下次多少能思考的更全面。

关于这个项目,最有意思的是,我没有预先考虑到要做删除数据的功能。程序唰唰唰一跑,几千几万条数据就上去了,如果出错了,根本定位不到哪些条目是错的,尤其是测试的时候,刷了一片数据上去。手动删可真要删到翻白眼了。

做一个批量删除吧。

针对微信的前端文件版本管理原则

之前的文章里,我们讲到了微信的一个问题:缓存它认为是静态内容的文件,比如 javascript、css、html 还有图片。这给使用 Android 系统的开发人员带来了不便,因为 Android 中的微信没有刷新按钮,改完代码后想再测试很不方便。

但这是个契机,放大了一个被很多开发人员忽略的问题:前端文件是会被缓存的。不少开发人员甚至从未意识到这一点,因为在日常开发过程中,每次改完代码都会主动刷新一下再测试效果,如果不行就强制刷新一下,从来没有考虑普通用户会遇到什么问题。普通用户并不会在你的新代码上线后就刷新一下浏览器,知道强制刷新的就更少了,导致部分用户仍在用旧版的前端代码和你新版的后端交互,这经常会导致一些奇怪的 bug,而且是你永远无法查出来的 bug 。

因此,无论是不是针对微信,前端文件的版本管理都是十分重要的。

避免过时的缓存

浏览器缓存是一个非常有用的机制,可以明显减少浏览器和服务器间的数据传输,加快网页的加载速度,也能大大减少服务器的压力。但是缓存一旦过期,就会起到反作用。

有人会使用简单暴力的方法,通过在服务器配置文件头,告诉浏览器不要缓存某些文件,但这样做会牺牲缓存可以带来的好处,而且这一套在微信浏览器里完全行不通,它根据文件后缀来决定是不是要缓存,不管你的服务器是怎么跟它说的。

因此我们只能在迁就微信的情况下,合理的使用缓存,充分利用缓存的优点,同时避免缓存过期的问题。

两个原则
一、绝对不要缓存入口文件

除非你有信心,永远不需要修改这个页面以及所有被它使用的资源,否则绝对不要缓存入口文件。即使你对自己的代码有信心,你也很难保证需求不变化,呵呵。所以,绝对不要缓存入口文件

入口文件和其他文件是不同的,不能随意地变更地址,入口地址放在微信菜单里,会有最长 24 小时的生效时间,如果是分享类的活动,中途想换地址就更不可能。

实际操作中的规则,就是永远不要把入口文件命名成 .html 结尾。微信会无条件缓存 html 文件,导致你后续的修改无法传达到之前访问过的用户手中,他们会在很长一段时间里,只能看到旧版。

如果你的入口文件是用 php 之类的动态生成的,那么不要把地址重写成 .html 就好。

如果你的入口文件真的是静态的 html 文件,那么你可以考虑用你服务器端的编程语言加载并原样输出这个文件(以 php 为例):

1
2
<?php  
echo file_get_contents('index.html');

针对支持 php 的服务器,更简单的方法是直接将文件后缀改成 .php,asp 和 jsp 也类似。

如果你对性能有更高的要求,更好的方法是将文件后缀改掉,然后添加一个相应的 Mime 类型。例如将文件改成 index.nocache ,然后在 mime.conf (以 nginx 为例)里为 text/html 类别添加一个新后缀。

1
2
3
4
types {  
text/html html htm shtml nocache;
....
}

这样服务器就不必调用第三方的程序(如 php-fpm)尝试解析了,访问量大的时候也可以节约一些性能。

二、用版本管理其他资源

除了入口文件,所有其他资源都应该使用版本进行管理,主要包括 javascript、css、web 字体、还有图片。版本号可以包含在文件名里(如 app-0001.js,或者查询参数中(app.js?0001),两者各有好处,我个人更喜欢后者。

对版本号有两个要求:内容不同的文件,版本号一定是不同的;版本号不同的文件,内容一定是不同的

第一个要求用来避免用户使用过期的缓存。如果你改了内容,却不改版本号,用户就会继续使用缓存中的旧版本。

第二个要求用来避免缓存的浪费。因为你每次修改版本号,用户就需要重新加载一次那个文件,如果文件内容明明没有改变,缓存里的已经够用,你却更新了版本号,会让用户白白多下载一次。

因此,使用文件的哈希值来作为版本号是非常合适的,哈希值以文件内容作为计算依据,并且在有限的样本空间里,可以认为内容和哈希值是一一对应的,满足上面所说的两个条件。用哈希值还可以很方便的自动化,避免人工命名,我们都清楚,人迟早是会犯错的。

示例

结合上述两个原则,我的入口文件会被命名为 index.php ,简化后的内容如下:

1
2
3
4
5
6
7
8
9
10
<!doctype html>  
<head>
<title>缓存管理</title>
<script src="/app.js?4f05b2a7"></script>
...
</head>
<body>
...
</body>
</html>

下周,我们继续聊聊如何将今天提到的原则应用到实际的项目中,实现版本号的自动化管理,以及如何解决版本号带来的测试方面的问题。

用加载画面改善用户体验

毋庸置疑,单页面应用(Single Page Application,以下简称 SPA)正在变得越来越流行。SPA 具有前后端分离、接口重用度高、操作响应快速等优点,在用户体验上相比传统的网站也更接近原生应用。但与此同时,SPA 的体积却在不断增大,导致加载所需的时间越来越长,而这个问题在微信里面特别突出。

微信中的加载问题

首先,微信通常安装在移动设备上,网络的速度并不像固定宽带那么有保障,虽然 4G 已经普及,但总有些地方或某些时候网络没那么理想。在网络状况不佳时,加载较大的 SPA 耗时较长,屏幕表现为长时间白屏。

然后,微信对网页的缓存机制跟一般的浏览器不同。即使你没在服务器上进行特别的配置,它默认就会缓存 html、css、javascript 等它认为是“静态”的内容,而且在缓存有效期间“拒绝”去服务器上看看是否有更新,给不少新接触微信开发的程序员造成困扰。反过来,当你希望它将某个内容长期缓存,并通过响应头部的信息明确告知时,它也不能很好的遵守。说实话,你没法知道缓存什么时候会过期。不过也不能全怪它,微信毕竟不是浏览器。

结果,我们必须面对的事实就是经常会遇到这样的情况:用户打开你的公众号,点击某个按钮进入了你开发的 SPA,要加载的东西不少、网络又不太好、而缓存又都过期了,用户等待了 5 秒钟,页面还是空白的,用户关闭了页面并觉得你的网站很糟糕。

在对比 SPA 和原生应用时,常常会把“不需要下载和安装”作为一项优势,但我们都清楚,其实每次使用前都要“下载”一次。

加载画面

加载画面本身不能加快网站的加载速度,甚至会稍微拖慢一点,但它是一个信号,告诉用户这个网站并没有挂掉,他已经打开了这个网站,只是还要等待一会才能使用。这会让用户更有耐心继续等下去,尤其是在你能提供一个进度条的时候。

既然决定要在应用里加入加载画面了,那么一定要第一时间让它出现,并且随着网页内容的加载随时汇报加载进度。

显示加载画面

先来看一个没有加载画面的网页简化之后的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!doctype html>  
<html>
<head>
<meta charset="utf-8">
<title>loading demo</title>
<link href="/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<app></app>

<script src="/javascript/require.js"></script>
<script>
// 通过 require.js 来加载其他 javascript 文件
</script>
</body>
</html>

在上面这个例子中,样式文件放在 head 中,脚本文件放在 body 的尾部,是一种推荐写法,能让网页在第一时间进行显示,然后再加载 javascript 来对网页进行渐进式的增强。

但是这个做法对于 SPA 的帮助不是很大,现在大多数 SPA 都是基于 Angular、React、Vue 等前端框架,其内容都是通过 javascript 动态生成后再显示出来的。

这里我们假设网站已经使用 require.js 实现了按需加载和多线并行加载,但用户对加载速度仍不满意(这就是我遇到的问题和写这篇文章的原因)。

为了让加载画面能第一时间显示,我必须放弃利用其他框架,写成一个零依赖的库。它的位置应该同样位于 body 的尾部,但在所有其他 javascript 文件之前。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!doctype html>  
<html>
<head>
<meta charset="utf-8">
<title>loading demo</title>
<link href="/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<app></app>

<script>
var splash = document.createElement('div');
splash.setAttribute('style', 'display: flex; justify-content: center; text-align: center; flex-direction: column; position: fixed; top: 0; left: 0; width: 100%; height: 100%; color: #fff; background: #288198');
var title = document.createElement('div');
title.textContent = 'WebCraft';
var progress = document.createElement('div');
progress.textContent = '开始加载';
splash.appendChild(title);
splash.appendChild(progress);
document.body.appendChild(splash);
</script>
<script src="/javascript/require.js"></script>
<script>
// 通过 require.js 来加载其他 javascript 文件
</script>
</body>
</html>

写完后,通过浏览器进行观察,方法是调出 Chrome 的开发者工具,打开 Network 标签,勾选 Disable Cache 禁用缓存,在 Throttling 处选择 GPRS,这样浏览器就会模拟 GRPS 网络的速度来加载文件。

可以观察到,加载画面在加载 require.js 之前就显示了,但是暴露出另一个问题,在样式文件加载完之前,网页一直是空白的,这是因为浏览器在加载完样式文件之前,是不会进行渲染的。

这让我想起了早期的 Opera 浏览器(换核之前),加载样式文件是不会阻止渲染的,它会直接显示出一个没有样式的网页,在加载完样式文件后再渲染成有样式的样子。

在新的标准中,link 标签将会有一个可选的 preload 属性,允许浏览器在不加载完这个样式文件的时候就进行渲染,但目前很多浏览器尚未支持,微信在 Android 中使用的 X5 内核,以及 iOS 的 Safari 也都不支持。

我目前开发的大多数的项目都需要加载 bootstrap、font-awesome 这两个体积稍大的样式库,还有一些根据项目需要另外引入的零碎的库。加在一起要加载的量也是不小的。

为了让加载画面出现的更快,样式文件必须下移到显示加载画面的 script 标签之后。但是很不幸,微信内嵌浏览器使用的内核,无论是自己的 X5 还是 iOS 的 Safari 内核,还有一个特性,就是只要遇到加载样式文件的 link 标签,就会停止渲染,直到样式文件加载完毕。

也就是说,即使把 link 放在 script 之后,也无法先显示加载画面。

现在就剩下一条路了,动态加载样式文件,听起来高大上,实现起来很简单,用 javascript 创建一个 link 标签,指定好要加载的样式文件,再挂到 DOM 树中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!doctype html>  
<html>
<head>
<meta charset="utf-8">
<title>loading demo</title>
<link href="/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<app></app>

<script>
var splash = document.createElement('div');
splash.setAttribute('style', 'display: flex; justify-content: center; text-align: center; flex-direction: column; position: fixed; top: 0; left: 0; width: 100%; height: 100%; color: #fff; background: #288198');
var title = document.createElement('div');
title.textContent = 'WebCraft';
var progress = document.createElement('div');
progress.textContent = '开始加载';
splash.appendChild(title);
splash.appendChild(progress);
document.body.appendChild(splash);

var link = c('link');
link.setAttribute('href', '/css/bootstrap.css');
link.setAttribute('rel', 'stylesheet');
document.body.appendChild(link);
</script>
<script src="/javascript/require.js"></script>
<script>
// 通过 require.js 来加载其他 javascript 文件
</script>
</body>
</html>

经过这一次的修改,加载画面就可以在只加载了 html 文件的情况下显示啦。接下来我们要实现显示加载进度的功能。

显示加载进度

首先,我们要明确在第一个主要内容页面显示之前,总共要加载多少东西,或者说有几个节点,这样我们就有了一个计数的目标;然后创建一个空的计数器,每完成一个节点,计数器加一,并更新进度;最终,当计数器到达计数目标的时候,加载完成,隐藏掉加载画面,显示出内容。

1
2
3
4
5
6
7
8
9
10
var total = 节点数;  
var count = 0;
var loaded = function () {
count++;
if (count < total) {
// 更新加载进度
} else {
// 隐藏加载画面
}
}

total 是计数目标,count 是计数器,每次完成一个节点,调用 loaded 函数就可以更新计数。

现在,我们暂不考虑应用本身的逻辑,只把文件的加载纳入进度显示中,那么在上面的例子中有两个需要加载的文件,一个是 bootstrap.css ,另一个是 require.js 。对于 JavaScript 文件,由于是顺序加载和执行的,实现起来非常简单:

1
2
3
4
<script src="/javascript/require.js"></script>  
<script>
loaded();
</script>

如上所示,require.js 加载和执行完毕后,就会执行 loaded() 使计数器加一。

对于样式文件,我们通过监听 load 事件来实现:

1
2
3
4
5
var link = c('link');  
link.setAttribute('href', '/css/bootstrap.css');
link.setAttribute('rel', 'stylesheet');
link.addEventListener('load', loaded);
document.body.appendChild(link);

回到现实的情况中,通过 require.js 加载的、运行 SPA 必备的库也可以作为节点,通过在 require 函数的回调中加入 loaded 来监控进度。

1
2
3
4
require(['jquery'], function () {  
loaded();
// 继续处理
});

如果需要,还可以进一步将进度监控到获取渲染第一个页面所需的数据的步骤,但是别忘了将计数目标做出相应改变。

1
2
3
4
$.get('/api/some-data', function (data) {
loaded();
// 渲染第一个主要内容页面
});

完整的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<!doctype html>  
<html>
<head>
<meta charset="utf-8">
<title>loading demo</title>
<link href="/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<app></app>

<script>
var splash = document.createElement('div');
splash.setAttribute('style', 'display: flex; justify-content: center; text-align: center; flex-direction: column; position: fixed; top: 0; left: 0; width: 100%; height: 100%; color: #fff; background: #288198');
var title = document.createElement('div');
title.textContent = 'WebCraft';
var progress = document.createElement('div');
progress.textContent = '开始加载';
splash.appendChild(title);
splash.appendChild(progress);
document.body.appendChild(splash);

var total = 4;
var count = 0;
var loaded = function () {
count++;
if (count < total) {
progress.textContent = count + '/' + total;
} else {
splash.remove();
}
}
var link = c('link');
link.setAttribute('href', '/css/bootstrap.css');
link.setAttribute('rel', 'stylesheet');
link.addEventListener('load', loaded);
document.body.appendChild(link);
</script>
<script src="/javascript/require.js"></script>
<script>
loaded();
require(['/javascript/jquery.js'], function ($) {
loaded();
$.get('/api/some-data', function (data) {
loaded();
$('app').append(data);
});
});
</script>
</body>
</html>

至此,一个简单的,可显示进度的加载画面的原型已经完成。本文的主要内容也就到此为止了。

尾声

如果要应用到生产环境中,还有几个问题必须解决:动态添加样式的几个语句应该定义成一个函数以使加载多个样式文件更方便;暴露在全局中的变量太多,不利于维护;样式固定,无法适应不同风格的应用。

持续更新的演示地址:
https://bnlt.org/demo/loading/index.html

在 CentOS 6 和 Nginx 中部署 Let's Encrypt 的 SSL 证书

上周写了一篇文章讲了讲为什么应该使用 HTTPS 加密你的网站,并笼统的介绍了如何申请和使用 Let’s Encrypt 提供的 SSL 证书。这次我们说说如何在 CentOS 6 和 Nginx 中部署 Let’s Encrypt 的 SSL 证书,过程中可能遇到的问题,以及如何解决。

整体上分成申请证书和使用证书两个部分。

申请证书

准备工作

在申请证书之前,你必须有一个域名,并配置好 DNS 解析服务,使其指向你将要使用的服务器。说白了,就是你要保证所有人都可以通过在浏览器中输入域名打开你的网站。

此时 Nginx 的配置文件如下(为了便于说明只包含了最基本的信息):

1
2
3
4
5
6
7
8
9
[ngxin]
server {
listen 80;
server_name www.bnlt.org;

location / {
root /usr/share/nginx/html;
}
}

其中 80 是 http 协议默认端口,www.bnlt.org 是我的域名,/usr/share/nginx/html 是网站根目录。

安装 Certbot

Certbot 是 Let’s Encrypt 官方推出的,用来获取 SSL 证书的工具。Certbot 的官网为各大主流操作系统和 Web 服务器提供了安装和使用的指南。下面首先介绍官方提供的方法。

下列操作均在 root 用户权限下执行。

官方推荐

针对 CentOS 6 系统,官方推荐的是使用 certbot-auto 脚本。安装方法如下:

1
2
wget https://dl.eff.org/certbot-auto  
chmod a+x certbot-auto

第一个命令将 certbot-auto 下载到当前目录下,第二个命令给予执行权限。

这是一个 shell 脚本,可以通过在当前目录下执行 ./certbot-auto 来调用。

它首先检查你是否已经安装了最新版本的 certbot,如果尚未安装,则调用 yum 检查和安装相关的依赖,其中包括了 python、python-dev 和 python-pip,然后创建一个虚拟环境(相对独立的、运行 python 的虚拟环境,在里面安装的依赖和库独立于操作系统使用的 python 环境,可以避免影响主系统的运行),在这个虚拟环境里安装一个最新版本的 certbot。用户交给 certbot-auto 的参数都被委派给这个安装在虚拟环境中的 certbot 来执行。

由于这个 certbot 被安装在虚拟环境里,你不能直接在命令行里使用 certbot 命令,只能通过 certbot-auto 脚本间接使用它。

不过使用 certbot-auto 脚本有时会遇到问题,表现为虚拟环境创建完毕后,长时间卡在安装 Python 包的步骤:

1
2
Creating virtual environment...  
Installing Python packages...

这通常被认为是 pip 的源在国内访问受限导致。

直接使用 pip 安装 certbot

上面讲到了,certbot-auto 是先安装 python-pip,再通过 python-pip 来安装 certbot 的,所以我们也可以自己手动来完成这个过程。通过这个方法安装的 certbot 会成为一个全局的命令,具体安装在 /usr/bin/certbot 。

如果你想自己安装 certbot,我仍然建议你先执行一下 certbot-auto 命令,让它先帮你安装好相关的依赖。

默认情况下 certbot-auto 也会为你安装 python 和 python-pip,但是版本教旧的 python2.6 和 pip7.1,,虽然用来安装和使用 certbot 是完全没有问题的,但每次执行时都会提示你当前使用的版本较老,官方已不再支持,有安全隐患。

我使用的是 python2.7 以及 pip9.0,可以通过添加 IUS 的源来安装。

添加 IUS 源

1
yum install https://centos6.iuscommunity.org/ius-release.rpm

安装 python2.7 和 pip9.0+

1
yum install python27 python27-devel python-pip

其中 python27-devel 是运行 certbot 必需的。

全局安装 certbot

1
pip install certbot

如果 pip install 速度很慢或卡进度,可以尝试设置 pip 的源为阿里云的镜像,再尝试上面的命令。

在当前用户目录下建立 ~/.pip/pip.conf 文件。内容如下:

1
2
3
4
5
[global]
index-url = http://mirrors.aliyun.com/pypi/simple/

[install]
trusted-host=mirrors.aliyun.com

安装完毕后,你会得到一个新命令 certbot,用 which 命令可以查到其安装的位置。

1
2
which certbot  
/usr/bin/certbot

获取证书

现在可以使用 certbot 获取 SSL 证书了。

如果你使用官方脚本,将下列命令中的所有 certbot 替换为 ./certbot-auto 即可。

这里我们使用 certbot certonly --webroot 方式来获取证书,此命令借助已有的 Web 服务实现认证,并生成证书,命令执行过程中不会对网站的正常运行造成影响,以后给证书续期也更平滑。

完整的命令如下:

1
certbot certonly --webroot -w /usr/share/nginx/html -d www.bnlt.org

-w 参数指定了网站的根目录,-d 参数指定了网站的域名。

背后的原理大致如下:执行命令的计算机会和 Let’s Encrypt 的服务器进行通信,商定一个字符串,这个字符串以文件的形式保存在-w 参数指定的路径下的 .well-known/acme-challenge/ 目录中,服务器再访问 -d 指定的网站目录下的相应文件来进行核实。核对成功就可以基本证明这个域名确实归你所有,Let’s Encrypt 就会为你颁发相应的证书了。

实际上只能证明当前是你控制了这个域名的解析,如果域名解析被他人恶意控制或污染,他也能生成此域名的证书,因此 Let’s Encrypt 提供的证书仅能用于加密,保证信息在传输过程中不被篡改,但不能用来证明域名的所有权。从这个角度来看,只要攻击者能控制或污染域名解析,即使你访问的某个网站显示了绿锁,仍有可能是个钓鱼网站。

执行此命令后,会弹出一个界面问你是否接受相关的协议,选择 OK 并确认即可。你也可以在执行上述命令时加上 –agree-tos 自动接受协议以跳过此步骤。

如果服务器配置正确,命令行参数也无误,那么就能成功完成,提示如下:

1
2
3
4
5
6
- Congratulations! Your certificate and chain have been saved at
/etc/letsencrypt/live/www.bnlt.org/fullchain.pem. Your cert will
expire on 2017-01-30\. To obtain a new or tweaked version of this
certificate in the future, simply run certbot again. To
non-interactively renew *all* of your certificates, run
"certbot renew"

提示信息告诉你证书存放在 /etc/letsencrypt/live/www.bnlt.org 目录下,过期时间是 2017-01-30,最后还告诉你续期的方法是执行 certbot renew

而最常见的错误提示如下:

1
2
An unexpected error occurred:  
The request message was malformed :: No such challenge

遇到这样的情况是因为 Let’s Encrypt 的服务器无法在你的网站上找到验证用的文件。你首先要检查 -w-d 参数是否有误,是否把验证文件放在了别的目录下或者填错了域名,另外还要确保 Let’s Encrypt 的服务器能访问到你的网站,比如你是刚做的域名解析,可能对它所处的网络而言域名解析尚未生效。

使用证书

要在 Nginx 中用刚才得到的证书来启用 https ,需要将配置文件改为:

1
2
3
4
5
6
7
8
9
10
11
server {  
listen 443 ssl;
server_name www.bnlt.org;

ssl_certificate /etc/letsencrypt/live/www.bnlt.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.bnlt.org/privkey.pem;

location / {
root /usr/share/nginx/html;
}
}

和原来的配置文件相比主要有两处变化,一是将 listen 端口改为 https 协议的默认端口 443,并加上 ssl 说明是加密连接。

还有就是增加了 ssl_certificatessl_certificate_key,前者指定公钥,后者指定私钥。

保存配置文件,执行 /etc/init.d/nginx reload 使新配置生效

http 自动跳转 https

如果你想更进一步,对所有用户强制使用 https,可以在配置文件里增加如下内容:

1
2
3
4
5
server {  
listen 80;
server_name www.bnlt.org;
return 301 https://$server_name$request_uri;
}

此配置会将所有通过 80 端口的 http 协议访问的用户,安全的跳转到对应的 https 版本页面。

同样,执行 /etc/init.d/nginx reload 使新配置生效。

把你的网站升级到 HTTPS

把你的网站升级到 HTTPS

作为一个普通用户,在使用手机浏览网页,或是阅读微信中别人分享的内容时,经常会看到屏幕下方有个弹出广告,什么点击领红包啦,免费送流量啦,或是移动运营商的广告。通常这些广告跟你浏览的网站本身风格完全不搭、画质又非常糟糕、而且在几个毫不相干的网站中都出现了。遇到这样的情况,基本可以肯定是有中间人劫持了你的流量,并篡改了网页内容,在其中嵌入了广告。纯粹做做广告已经算是客气,更恶劣的可以替换你浏览的网页内容,把你引向钓鱼网站,甚至直接窃取你的密码。

作为一个开发者,你的境遇更糟糕,因为这一切时刻都发生在你自己的网站或 APP 上。也许是你花费不少心思才做出来的精美页面被一个很粗糙的广告拉低了好几个档次;也许是一个重要活动的报名按钮被广告遮挡,你的用户不得不先小心翼翼关了广告才能继续;也许是为一个大客户制作的严肃的内容下面出现了一段夸张又搞笑的广告语。

但最最糟糕的是,你的大多数客户和用户都不清楚这是运营商或广告商劫持了他们的流量,而是以为你这个家伙为了一点微薄的广告收入在里面嵌了品味低下的广告,毕竟这网站是你的,这应用是你开发的,不是你干的还能是谁呢。你是希望当这一切发生的时候向他们解释这不是你的错,还是从根本上杜绝这样的事情发生呢?更何况你不是一点错都没有,是你的安全工作没做到位,给了无良广告商机会,钱他们赚,黑锅你背。

换了过去,你还有借口,对网站进行 HTTPS(SSL/TLS) 安全加密的证书是要花钱买的,每个独立子域名就要几百元一年,高级的证书则需要几千上万。

不过现在有了 Let’s Encrypt。一个致力于推行互联网安全的非营利性组织,免费为你的网站进行认证并颁发证书,其颁发的证书已被各大浏览器广泛接受。

那么接下来就只剩一个问题了,到底怎么才能把自己的网站从 http 升级到 https 呢?

Let’s Encrypt 和 Certbot

Let’s Encrypt 颁发证书是完全自助的,如果你的网站已经上线运行,那么你只需要用它们提供的工具执行一个命令就能为其申请一个证书。

这个工具是 Certbot。

安装 Certbot
  • 打开 Certbot 的官方网站 https://certbot.eff.org/
  • 在 I’m Using 处选择自己使用的 Web 服务器和操作系统
  • 下方的内容会根据你的选择进行相应的变化
  • 根据网站给出的提示逐步操作

好吧,这个网站是英文的。

不过我简单总结了一下,主要是这两种方式:

首先尝试用操作系统对应的包管理器来安装,比如 ubuntu 用 apt-get install certbot, arch 用 pacman -S certbot,macOS 用 brew install certbot, centos 用 yum install certbot

如果提示包不存在,比如我用的 CentOS 6,那么可以尝试下载一个官方提供的叫做 certbot-auto 的脚本

1
2
wget https://dl.eff.org/certbot-auto  
chmod a+x certbot-auto

如果使用 certbot-auto ,后续的命令中用到 certbot 的地方都换成 ./certbot-auto 即可。

获取证书
1
certbot certonly --webroot -w 网站根目录 -d 你的域名

将网站根目录和域名换成你实际使用的目录和域名即可。

certbot 会自动完成一系列的验证工作,把证书放在你的服务器上,然后弹出一个恭喜你成功获取证书的提示,证书文件的名称也包含在该提示中。通常会是 /etc/letsecrypt/live/你的域名/fullchain.pem

为你的网站启用 HTTPS

接下来就要修改你 Web 服务器的配置文件了,比如 Nginx 默认是修改 /etc/nginx/conf.d/default.conf

将配文件中的

1
listen 80;

改成

1
listen 443 ssl;

然后添加两行配置说明使用哪个证书文件

1
2
ssl_certificate /etc/letsencrypt/live/你的域名/fullchain.pem;  
ssl_certificate_key /etc/letsencrypt/live/你的域名/privkey.pem;

重启 Web 服务器使证书生效。

证书续期

Let’s Encrypt 颁发的证书有效期为 90 天,你可以在证书快到期的时候进行续期操作,续期命令更加简单:

1
certbot renew

该命令会逐一检查你所有的证书文件,并对其中快到期的证书进行续期。

为了使续期后的新证书生效,重启一下 Web 服务器。

完整的例子

本来只是想写一篇如何在 CentOS 6 和 Nginx 下使用 Let’s Encrypt 为你的网站增加 HTTPS ,一不小心把题目写大了收不住。这个具体的例子,会涉及一些官方操作未提及的细节和问题,只好再另开一篇进行详细描述了。

ImageMagick 将 PDF 转换成多张图片

ImageMagick 将 PDF 转换成多张图片

今天遇到一个需求,客户发来几份PDF,想放到他们的微网站(专门给微信用的网站)上,由于是给手机看的,加上文件页数不多,就想到转换成图片再放上去。

第一时间想到用 ImageMagick 的 convert 命令,网上一搜,命令如下1

1
convert -density 600 foo.pdf foo-%02d.jpg

-density 设置了生成的图片的精度,数值越大,图片越清晰(分辨率高),转换也越慢。如果给手机用,普通 A4 大小的 PDF 设置在 200 左右比较合适。

foo-%02d.jpg 是希望生成的文件名,%02d 部分会替换成页码(从 0 开始),用过 printf 函数的应该对这个规则会比较熟悉。

然而在实际使用时遇到了一个意外情况:

1
convert: no images defined `foo-%02d.jpg' @ error/convert.c/ConvertImageCommand/3258\.

继续求助搜索引擎,找到解决方案,缺少 gs 2。gs 即 GhostScript,ImageMagick 用它来解析 PDF 文件。

1
brew install gs

安装完 gs ,再执行一次 convert 命令,问题解决。

Hash, PushState 和微信 JSSDK 授权

最近将 riot.js 升级到了 3.0,并用上了新版本的 riot-route,原先用了一年多的 2.2.4 版本内置的 riot.route 只支持 hash 形式的 SPA 单页面应用,riot-route 则支持 pushState。

Hash 方式有个缺点,就是服务器不知道地址栏中 # 之后的内容,放在微信里,就导致了未授权用户授权后想返回原界面需要借助 JS 来实现,导致历史记录多了一条,用户按返回键无法退出(返回上一页面后又被 JS “前进”了)。

PushState 方式就没有这个问题,可以直接用 HTTP 302 重定向过来,不影响历史记录和返回按钮功能。

于是在新项目中开始采用 pushState 方式。

不过等到应用到了微信环境中,又冒出来一个问题,通过 pushState 改变地址在 iOS 中会导致 JSSDK 的授权失败。

在 config 中开启 debug,可见 invalid signature 。原因是 location.href 中的地址变化了,但是微信客户端认为地址还是打开页面时的地址,微信“复制链接”得到的地址可以作为旁证。

解决方法

先按标准方法用当前地址计算 signature,如果失败,再用打开浏览器时的地址计算 signature。

由于之前已经把加载微信 JSSDK 和相关 Api 的功能独立成函数,因此这次解决起来也比较简单。

修改前的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function runWx(api, fn, loadError) {  
if (typeof api === 'string') {
api = [api];
}

// 用 require.js 加载 JSSDK 文件,如果你已经用 <script> 方式加载,可以省掉这一步
require(['jweixin'], function (wx) {
var url = location.href.split('#')[0];

// 将 url 发往服务器计算相应的 signature
$.get('/signature', { url: url }, function (ans) {
// wx.config 执行成功时调用
wx.ready(function () {
wx.checkJsApi({
jsApiList: _.clone(api), // 坑,微信会改变此参数的内容
success: function success(res) {
if (!res || !res.checkResult || _.any(api, function (v) {
return !res.checkResult[v];
})) {
alert('您的微信版本过低,无法使用此功能,请升级微信');
return;
}
fn(wx);
}
});
});

// wx.config 执行失败时调用
wx.error(function () {
alert('授权失败,您可能无法使用部分功能');
});

// 校验用的参数来自服务器
var config = {
// debug: true,
appId: ans.data.appId,
timestamp: ans.data.timestamp,
nonceStr: ans.data.nonceStr,
signature: ans.data.signature,
jsApiList: _.union(['checkJsApi'], api)
};

// 进行校验
wx.config(config);
});
}, loadError);
}

先简单展示一下这个 runWx 函数的用法:

1
2
3
4
5
6
7
8
// 当需要用到特定的微信接口时,运行 runWx
runWx(['uploadImage', 'chooseImage'], function (wx) {
// 可以在这里使用 wx.uploadImage 和 wx.chooseImage 功能
wx.uploadImage(...);
wx.chooseImage(...);
}, function () {
// 加载失败时要做的事
});

runWx 做了这么几件事:

  • 用 require.js 加载 weixin sdk 的 js 文件
  • 获取当前页面地址 location.href.split(‘#’)[0]
  • 将 url 作为参数发送到服务器计算出 signature
  • 调用 wx.config
  • 成功时调用 wx.ready 并最终调用回调函数 fn
  • 失败时调用 wx.error 提示错误
修改后的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 解决部分机型 pushState 不能正确改变地址导致授权失败

// 在第一次打开页面时加载此文件,记录当时的地址作为原始地址
var originUrl = location.href.split('#')[0];

// 增加 tryOrigin 参数
function runWx(api, fn, loadError, tryOrigin) {
if (typeof api === 'string') {
api = [api];
}
require(['jweixin'], function (wx) {
// tryOrigin 为真时使用原始地址
var url = tryOrigin ? originUrl : location.href.split('#')[0];

$.get('/signature', { url: url }, function (ans) {
wx.ready(function () {
wx.checkJsApi({
jsApiList: _.clone(api), // 坑,微信会改变此参数的内容
success: function success(res) {
if (!res || !res.checkResult || _.any(api, function (v) {
return !res.checkResult[v];
})) {
alert('您的微信版本过低,无法使用此功能,请升级微信');
return;
}
fn(wx);
}
});
});

wx.error(function () {
// 已经试了原始地址
if (originUrl === url) {
alert('授权失败,您可能无法使用部分功能');
return;
}
// 没有使用原始地址且 signature 不匹配,尝试用原始地址计算 signature
runWx(api, fn, loadError, true);
});

var config = {
debug: true,
appId: ans.data.appId,
timestamp: ans.data.timestamp,
nonceStr: ans.data.nonceStr,
signature: ans.data.signature,
jsApiList: _.union(['checkJsApi'], api)
};
wx.config(config);
});
}, loadError);
}

和原来相比,主要变化有:

  • 记录了打开浏览器时的原始地址
  • 先尝试用标准的 location.href.split(‘#’)[0] 计算 signature
  • 失败时用 originUrl 再试一次

这个改动的主要优点是原来用到 runWx 的地方,代码完全不需要进行变动,由 runWx 自己去尝试解决问题。