聚沙成塔

一个平面设计的作业,形状勉强像塔

今天初步把以前的博客从时光机搬了回来。勉强能用RSS文件导回一些,更多是靠手动搬运,部分文章的排版仍是混乱的,有空再慢慢整理吧。

没有这些博客,我都忘记自己是怎么过来的了,早期的文章记录的都是些细碎的东西,解决的都是一个个非常具体的问题。虽然,好多解决方法现在看来都不完善,同样的问题其实也可能是其他原因导致的,那时却根本无法看到,只知道某个方法奏效了,那就记录下来。

因为简单,产量就很高,最多的时候半个月能写5篇,简直难以想象。光光解决各种乱码问题就能写上3篇,毕竟,写第一篇的时候想不到还会需要另两种方法来解决一个看似相同的问题。

但正是这些细小的沙子慢慢往上垒,我今天才有机会从更高的视角来俯视曾经的稚嫩吧。

时光机和回忆之旅

时光机和回忆之旅

今天又用时光机找回以前的博客看了看,终于下定决心要将这些逝去的记忆碎片拼凑回来。
这个随着阿荡的服务器到期丢失的,我的最初的博客。
第一次通过邮件和老外交流;第一次翻译英文文档;第一次使用安卓手机;让电脑通过手机的无线流量套餐上网。

现在看看,还真有一种奇妙的感觉。

归来的技术栈——无状态的服务端

本系列文章都是以方便开发和维护为出发点,在可能的时候,尽量兼顾性能方面的需求

本文是 归来的技术栈 的第三篇,必要时请阅读链接中的内容了解背景。

如果拿 Node.js 和 PHP 比较,我向来是喜欢 Node 多一点。但有一点是 PHP 天然具备, 而 Node 需要付出一些努力才能实现的。

默认情况下,PHP 针对每个请求会使用独立的进程来处理,完成后即销毁。虽然从性能上来说,每次执行都要重复加载文件和数据会带来负面影响,但在另一些方面,却有着先天优势。

比如某个请求发生了错误,不会对另一个请求造成影响;内存泄漏的负面效果无法累积起来影响系统的长期稳定运行;在一定程度上只要堆服务器就可以横向扩展来提高并发能力;不需要针对服务器启停编写额外的代码来初始化和扫尾;服务器意外关闭一般也不会造成什么影响(虽然也不总是如此,比如用了APC之类)。

这些都是无状态带来的好处,可以降低开发和维护的成本,并通过堆硬件提高性能。抛弃一些东西,在其他方面就可以获得更多。

完全无状态的服务端类似一个巨大的纯函数,每个参数相同的请求都会获得相同的结果,也不对外部环境产生影响。但是如果真做到这个程度,服务端就变成静态了,失去其应有的作用。

因此我们还是需要一种手段,在外部保存数据,最好是基于文件的数据库。数据库用内存加速可以接受,但是完全依赖内存就会失去对意外宕机的抵抗力。其他临时性的数据,能省则省。包括 session,虽然 session 也可以用文件或数据库保存,但是我们可以通过使用 json web token ,把它也省了。服务器上的状态越少、维护越容易。

当然,在实际工作中,我也使用缓存,但仅限于特别关键的地方。缓存必须独立于自己开发的软件存在,并且往往是以单向的、即时计算的形式来生成。独立可以确保能在不同进程间共享;单向是指只有一种方法来计算缓存,如果要更新缓存,只能用相同的方法重新计算一次,而不允许对当前的缓存进行加减等操作,这样就算出现意外的并发,也不会导致缓存中的内容出现错误;即时计算是指在第一次需要某个值时,才对其进行计算,并将结果存入缓存,直到过期。如此一来,缓存也成了“无状态”的,缓存的内容不受计算次数的影响,宕机重启也不会影响软件的整体运行,缓存会在需要时各自重建。虽然这种做法没有将缓存的价值发挥到最大,但由于开发和维护成本极低,有很高的性价比。

Node.js 毕竟不是 PHP,要在 Node.js 和 Express 环境中做到完全的无状态有点费力不讨好,但是基本的思想可以借鉴,尽量减少服务端要维护的状态,并在模块的级别实现这一目标。

要做到这一点,最基本的一个要求,就是别让变量活过一个请求,Express 的每个请求最终都会对应到一个回调函数,这个函数里不能出现其他来自外部的变量(必要的第三方库和数据库连接池会出现在其中,但从整个模块角度看,它们应该算是不可变量)。如果你在整个模块中,都能只使用 const,不用 var 来完成所有功能,并且没有作弊(把 const 定义的对象下的属性当变量用),那么就基本达成无状态的目标了。

当你的模块实现了无状态。在性能方面,就可放心的使用 Cluster 来启动子进程扩展并发能力,请求无论落入哪个子进程,都不会影响返回的结果,如果在前面加上一道负载均衡,还可以进一步扩展到多台服务器;在开发和维护方面,你可以放心的使用热加载,通过监测文件变化,在不停止服务的情况下更新软件版本。粒度比使用 nodemon 或者 supervior 等工具更细。

归来的技术栈——正确模块化,express 的 app.use

在 express 4 中,app.use 有如下用法:

1
2
3
4
5
6
7
const express = require('express');
const app = express();
const subapp = express();

app.use('/subpath', subapp);

app.listen(3000);

subapp 做为 express 的一个实例,本身也是 middleware ,可以被 app.use “挂载”到指定路径。

这种用法给我们项目中功能模块的可移植性进一步增加了保证,以之前用到的目录结构为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
├── app.js
└── src
├── admin
│ ├── stylesheets
│ ├── javascripts
│ ├── index.html
│ ├── client.js
│ ├── riot-tags
│ └── server.js
└── app
├── stylesheets
├── javascripts
├── index.html
├── client.js
├── riot-tags
└── server.js

两个 server.js 文件分别是 admin 和 app 的入口文件。这时我们可以这样做:

/app.js

1
2
3
4
5
6
7
8
const express = require('express');
const app = express();

const admin = require('./src/admin/server.js');

app->use('/admin', admin);

app->listen(3000);

/src/admin/server.js

1
2
3
4
5
6
7
8
const express = require('express');
const app = express();

app->get('/login', ...)
app->post('/api/login', ...)
...

module.exports = app;

这样,我们可以通过GET /admin/loginPOST /admin/api/login 来访问 admin 模块中的方法了。 在这种设计下,admin 目录中的内容自成一体,不依赖 /app.js 向它传入参数。可移植性就更好了。

由于 /src/admin/server.js 中的 app 是一个完整的 express 实例,因此它本身也可以通过 use 方法来使用 middleware。并且这个 middleware 的有效范围也会被控制在 admin 中,而不会干扰其他模块。所以这也是一种控制 middleware 有效范围的简便方法。

这个方法将模块和主程序完全解偶,如果你再进一步,将 admin 模块做成无状态的,那么就可以安全的实现热更新了,这将大大提高开发效率。

归来的技术栈

所有的设计都是从开发者面对的问题出发。

我要用有限的人手(就当是我一个人吧)进行全栈开发,中小型项目。从开发角度讲,要同时保证开发效率和业务模块的可移植性;从运行的角度讲,需要较高的可靠性但对性能要求不高。

向来是个全栈程序员。自从上次换了工作,到现在三年多。后端一直在用 PHP 和 Slim 框架,Composer 进行包管理,MySQL 做数据库,开发 restful 风格的接口,四平八稳;前端页面做成 SPA 形式,因缘际会用了 riot.js,理念和现在当红的 react 和 vue 相似,功能稍弱,但学习成本也低。

这个理念,正是这次想要更换技术栈的主要原因,上个月和朋友聊天时,说起了以前为什么会选择 riot.js,为了 riot.js 官网里的两句话:

We should focus on reusable components instead of templates
Templates separate technologies, not concerns

这个观点其实是 react 的开发者提出来的,我们要关注的是可重用的组件而不是模版,模版的概念是从技术角度出发的,而真正需要我们关心的是业务。第一次看到这个观点时,我是震撼的,因为那时我正在用 Angluar 1,老是要在文件(模型、视图、控制器)之间来回切换和定位,总觉得现状有问题,却又不知道如何改变。

那天聊完之后,我突然意识到,同样的理念可以走的更远一点,不用限定在组件的级别,用在业务模块的级别上也是可行的(尤其是对全栈开发者和中小型项目来说)。我们之前的项目根据前后端来划分目录结构,也是一种从技术角度出发的选择,而这让我遇到了一些问题。

先来看看以前的项目结构(隐藏了一些不必要的细节)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
├── app
│ ├── lib
│ │ ├── admin
│ │ └── app
│ └── router.php
├── lib
├── pages
│ ├── admin.html
│ └── app.html
├── public
│ ├── images
│ ├── javascripts
│ └── stylesheets
└── riot-tags
├── admin
└── app

/app/lib 目录下是后端的逻辑,/pages 目录下是前端入口页面(模版,通过PHP调用展示),/riot-tags 下是前端文件,其他前端需要用到的图片、公共样式和库都在 /public 下面。

假设另一个项目需要用到 admin 模块,我只能分别将 /app/lib/admin/pages/admin.html/riot-tags/admin 拷贝到新项目里去。但是 /public 目录下的内容就比较麻烦了,不同模块用到的东西都混在一起,已经很难分清哪个被谁用了。如果再在这些目录下按模块分目录,又会变得非常繁琐。

这样分配目录还会对开发者的心态产生潜移默化的影响,两个模块的后端文件之间靠的太近,会增加相互调用,写出高耦合度代码的倾向。

再之前的工作是开发一个长期维护的平台,没有这方面的考量,而现在经常遇到将之前开发的系统中的功能挑几个出来,做成一个新系统的需求,因此模块的独立性和可移植性变得非常重要。

新的目录结构类似这种形式(隐藏了一些不必要的细节):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
├── app.js
└── src
├── admin
│ ├── stylesheets
│ ├── javascripts
│ ├── index.html
│ ├── client.js
│ ├── riot-tags
│ └── server.js
└── app
├── stylesheets
├── javascripts
├── index.html
├── client.js
├── riot-tags
└── server.js

除了目录之外,现在的技术栈也存在其他方面的问题,由于同时用了 PHP 和 JavaScript 两种主要的开发语言,在小公司难招人的情况下,招两个都会的人更难;需要用到两个包管理工具,开发环境、运行环境、开发工具配置什么也都要搞两种;虽然已经把服务端搞得很瘦,常用逻辑的代码生成器也做了;但同时要维护两套东西确实不够DRY的。说到底吧,还是人不够。

所以,只能尽可能把技术栈缩小,另外通过把粒度做粗一点、牺牲一定的性能来换取开发时的便利和运行时的稳定。

于是,在国庆假期的时候,时隔三四年,再次拿起 node.js 和 express,是为“归来的技术栈”(不知道为什么突然想起了《归来的奥特曼》,挺应景的,所以取了这么个标题)。

这次把整个结构从头梳理了一遍,保留之前的成功之处,再引入一些新鲜血液。整个过程下来,积攒了不少东西,在此和大家分享。当然,由于各自面对的问题和环境不同,你不一定认同我的某些观点,但我仍希望能带给你一些有意思的东西。

先丢几个我非常认同的观点:

代码是写给人看的,其次才是给机器执行
软件的维护成本非常重要,软件生命周期越长越重要,直到变成最重要的
开发人员的时间比机器的更宝贵
写博客比写代码更花时间

这个系列会包含以下内容:

  • 正确模块化,express 的 app.use
  • 无状态的服务端
  • json web token
  • 一个小坑 - app.use 和 unless 的 useOriginalUrl
  • 无状态的客户端
  • 单页面应用和路由 - navigo
  • 如何保存和校验密码 - bcrypt
  • 打通前后端,简化操作 - pouchdb
  • 打通前后端,简化操作 - restlike
  • 简化开发流程,后端自动重启和热更新
  • 简化开发流程,前端热更新 - webpack

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 中的多条记录合并上传,比如根据身份证判断为同一个人,他名下的房屋要合并成一条记录后再上传,但实际上数据里存在两个人填了同一个身份证或者里面填写的房屋数量和实际条数不符的情况。

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

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

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

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

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

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

做一个批量删除吧。