[译] Let's Encrypt 的新根证书和中间证书

原文

在 2020 年 9 月 30 日这天,Let’s Encrypt 颁发了六个新证书:一个根证书,四个中间证书和一个交叉证书。这些证书属于改善网络隐私的更大计划的一部分,让 ECDSA 终端证书被更广泛的采纳,和更小的证书体积。

鉴于我们每天要颁发 150 万张证书,这些证书有什么特别?为什么颁发它们?怎么颁发它们?让我们通过解释 CA 是如何思考和工作的来回答这些问题。

背景

每一个被公众信任的 CA (例如 Let’s Encrypt) 都至少有一个根证书被众多的浏览器和操作系统 (例如 Mozilla 和 Google) 的信任跟存储所包含。通过这种方式,用户可以确认他们从网站收到的证书是一个被浏览器信任的机构所颁发的。但是根证书由于它们的广泛传播以及较长的信赖周期,它的秘钥必须被妥善的保护并离线保管,以防止被用来不停地签发。因此 CA 会有若干个中间证书来替代根证书以确保证日常安全。

最近 5 年里, Let’s Encrypt 只有一个根证书: ISRG Rott X1,它拥有一个 4096 位的 RSA 秘钥并且有效期直到 2035 年。

也是在这段时间里,我们有了四个中间证书,分别是 Let’s Encrypt Authorities X1, X2, X3 和 X4。 前两个是 Let’s Encrypt 刚开始运营的 2015 年颁发的,有效期为 5 年;后两个是一年后,也就是 2016 年颁发的,有效期 5 年,并将在明年的这个时候过期。所有的这些中间证书都使用 2048 位的秘钥。此外,这些中间证书都由 IdenTrust 公司的 DST Root CA X3 根证书交叉签署,这个证书由另一家广泛被信任的 CA 所管理。

Let’s Encrypt 到 2020 年 8 月的结构图

8月

新证书

首先,我们颁发了两个 2048 位秘钥的 RSA 中间证书:R3 和 R4. 这两个证书都由 ISRG Root X1 签署,拥有 5 年的有效期。同样交由 IdentTrust 交叉签署。它们实际上是 X3 和 X4 的直接替代,考虑到它们一年后即将到期。我们预计会在今年底把主要的证书颁发流水线切换到使用 R3 证书,不会对证书的颁发和续签的造成实际的影响。

另一个新证书更有意思一点。首先,我们有了一个新的 ISRG Root X2,将会使用 ECDSA P-384 替代 RSA,有效期到 2040 年。由它颁发了两个新的中间证书:E1 和 E2,签名算法也是 ECDSA 并且有效期为 5 年。

值得注意的是,这些新中间证书并没交由 IdentTrust 的 DST Root CA X3 交叉签署, ISRG Root X2 本身由 ISRG Root X1 交叉签署。敏锐的观察者可能也会注意到我们没有通过 ISRG Root X2 颁发有 OCSP 签名的证书。

Let’s Encrypt 到 2020 年 9 月的结构图

9月

既然已经讨论到了技术细节,不妨再深入了解下这个结构的由来。

为什么颁发 ECDSA 的根证书和中间证书

已经有好多文章讨论过 ECDSA 的好处 (相同加密程度下更小的秘钥,更快的加密,解密,签名,验证等操作)。不过对我们来说,更大的好处来自于证书体积的缩小。

每一个通过 https:// 到远程主机的链接都需要一次 TLS 握手。每一个 TLS 握手都需要服务器提供它的证书。校验证书的过程还包括检查证书链(包括直到可信根证书的所有中间证书),这通常也是由有服务器提供的。这就意味这每个链接,一个覆盖各种广告和跟踪像素的页面会包含几十甚至上百个链接,这会需要传输大量的证书数据。并且每个证书都包含自己的公钥和签发者的签名。

一个 2048 位的 RSA 公钥大概 256 字节,而一个 ECDSA P-284 的公钥只有 48 字节。类似的, RSA 的签名会需要额外的 256 字节,而 ECDSA 只需要 96 字节。再考虑到其他一些开销,每个证书能节约大约 400 字节。用这个数乘以你的证书链长度以及每天的链接数,带宽的节省就很可观了。

这些省下的流量不仅会使我们的证书使用者每月节省大量的带宽费用,也惠及那些有限的、受限的终端用户。提高整个互联网的隐私性不仅是采用证书技术,也包括这让这些技术更经济。

此外由于我们很关心证书的大小,我们还采取了一些其它手段来让证书变得更小。我们把证书的主体名字由 “Let’s Encrypt Authority X3” 缩减到 “R3”,这是给予机构名称里已经提供了冗余的 “Let’s Encrypt”。同时我们还缩短了 Authority Information Access Issuer 和 CRL Distribution Point 的 URL 长度,我们还整个砍掉了 CPS 和 OCSP 的 URL。通过这些手段我们能够在不丢失实质性信息的情况下让证书再缩小 120 字节。

为什么交叉签署 ECDSA 根证书

交叉签署是新根证书从被签发到被主流信任的过渡阶段很重要的步骤。我们知道 ISRG Root X2 可能需要 5 年甚至更长时间才能被广泛接受,所以 E1 中间证书签发的证书如果希望被信任,一定需要证书链中某处的交叉签署。

我们基本上有两种方法:用现有的 ISRG Root X1 交叉签署 ISRG Root X2,或者用 ISRG Root X1 直接交叉签署 E1 和 E2。接下来我们就来分析下这两种做法分别有什么优缺点。

交叉签署 ISRG Root X2 意味着如果一个用户在信任库里有 ISRG Root X2,那么该证书链就是 100% ECDSA ,可以利用前文所述的快速校验。并且在接下来的几年里随着 ISRG Root X2 被加到越来越多的信任库里,ECDSA 终端证书的验证会越来越快而不需要用户或者网站做什么。这么做的代价是,只要 X2 还不在信任库里,用户的客户端就需要验证两个中间证书包括 E1 和 X2 直到 X1 根证书。这显然增加了证书验证的时间。

直接交叉新签署中间证书也有问题。一方面所有证书链的长度是相同的,在证书使用者和被广泛信任的 ISRG Root X1 之间只有一个中间证书。但是另一方面,随着 ISRG Root X2 获得越来越广泛的的信任,我们需要通过切换到另外一个链来
保证所有都能享受全链 ECDSA 的好处。

最终我们认为全链 ECDSA 更重要,所以我们选择了第一个方案,交叉签署 ISRG Root X2 证书。

为什么我们不提供 OCSP Responder 了

OCSP 协议是用户客户端用来发现并实时检查证书是否被吊销的一种方式。无论何时,一个浏览器如果想要知道证书是否有效,它可以通过访问证书里的一个 URL 就能得到是或否的答案,这个结果是由另一个可以被用相同方式检查的证书签名。这对终端用户的证书来说是非常棒的,应为请求响应体积很小并且速度很快。根据访问的站点不同,任何一个用户可能会关心(因此必须下载)海量证书集合的有效性。

但是中间证书只是海量证书的一个小小的子集,并且通常是广为人知的,也很少被吊销。因此,提供一个所有常用中间证书的吊销列表可能会更有效。我们的中间证书都包括一个 URL,通过这个 URL 浏览器可以下载证书的 CRl。实际上有些浏览器甚至会在例行更新里带上 CRL 列表集合,这使得在检查中间证书有效性的时候不需要再进行一次额外的访问开销,从而为大家创造更好的体验。

实际上用来指导 CA 的 Baseline Requirements 最近一次更新表明,中间证书已经不再强制要求包含一个 OCSP URL,而是可以只通过 CRL 来发布自己的吊销信息。鉴于此,我们从中间证书里移除了 OCSP URL,即我们不再需要为所有 ISRG Root X2 颁发的中间证书提供 OCSP Responder。

总结

至此我们已经介绍了新证书,最后再提一点:我们是怎么颁发这些证书的。

创建新根证书和中间证书是一个大事件,因为它们是被监管的的并且需要极其小心地看管好秘钥。这个事件是如此重要以至于颁发新证书被称作是“仪式”。在 Let’s Encrypt 我们非常推崇机器自动化,所以我们想要这个仪式的人为干预越少越好。

在过去的几个月里我们为这个仪式建立了一个工具,如果输入正确的配置,就能够生成所需的秘钥、证书和交叉签名请求等。我们还建立了一个仪式的 Demo 来说明这个配置文件可以并且允许所有人来运行和检查结果。我们的 SRE 搭建了一个相同并配有硬件安全模块的网络,自己执行了若干次仪式以确保整个流程完美无暇。我们与技术委员会、社区和若干邮件列表分享了这个 Demo,在这个过程中获得了很多有价值的反馈,其中一些甚至影响了上面我们提到的一些决定。最终、在 2020 年 9 月 3 号,我们的执行董事和 SRE 在一个安全的数据中心碰面并执行了整个仪式,并且有录像以供审计用。

现在仪式已经完成。我们已经更新了证书页面上关于新证书的细节,并且开始着手于申请将我们的新根证书加入到若干个信任库中。我们会在未来几周里开始用新中间证书来签发证书,并在社区论坛里发布进一步的公告。

希望我们关于新证书结构的导览是有趣的和干货的。我们期待继续通过一张张的证书来改进互联网隐私。我们由衷感谢 IdenTrust 在早期和后来不间断的支持我们让互联网更安全的愿景。

我们依靠社区和支持者来提供我们的服务。如果你的公司或者机构希望可以赞助 Let’s Encrypt 可以发邮件到 sponsor#letsencrypt.org 。我们需要您力所能及的帮助、

JIRA 内置用户目录与 LDAP 的关系

作为运维开发怎么能不折腾一下 JIRA 呢。JIRA 支持从多个来源获取用户信息,默认的是内置用户目录,一个比较常见需求是改用 LDAP 作为用户目录,特别适合公司里有多个账号体系的情况。

如果想了解其中的细节最好方式还是搭个独立的测试环境,主要是 Jira, OpenLDAP 和 MySQL,可以用一个 docker-compose 搞定。安装和配置部分略去,以下直接给出分析过程:

相关的表

表名 功能
cwd_directory 用户目录
cwd_group 用户组
cwd_user 用户
cwd_membership 用户组 -> 用户

cwd_directory

ID directory_name impl_class directory_type
1 JIRA Internal Directory com.atlassian.crowd.directory.InternalDirectory INTERNAL
10000 My OpenLDAP com.atlassian.crowd.directory.OpenLDAP CONNECTOR

之前看到网上有一条语句改库完成 LDAP 迁移的神操作,这个操作会假设 OpenLDAP 的库 ID 是 10000,果然是艺高人胆大。

cwd_group

ID directory_id group_name active local group_type
10000 1 jira-administrators 1 0 GROUP
10010 1 jira-software-users 1 0 GROUP
10110 1 jira-software-users 1 1 GROUP

cwd_user

ID directory_id username active email_address CREDENTIAL
10000 1 root 1 root@example.com {PKCS5S2}********
10102 10000 foo 1 foo@example.com nopass
10100 1 foo 1 foo@example.com {PKCS5S2}********

迁移之前最大的顾虑就是迁移前的数据能够保存了,这块主要分两部分

  • 各种任务,评论等
  • 在项目里的角色
  • 用户组信息

通过实验发现只要保证 internal 和 ldap 里的 username 一致 1 和 2 就可以得到保留,同样,只要保证 group_name 一致 3 就可以得到保留。

结论

结合表里的数据可得到以下结论:

  • username 和 group_name 在各自的库里都是唯一的
  • 从全局看 username 对应的用户只能有一个,取决于目录的优先级顺序
  • 从全局看 group_name 是不同目录下的用户的集合

Pycharm 里为 Vagrantfile 如何设置语法高亮

最近又开始捣鼓 Vagrant 了,但是在 Pycharm (2020.1.2) 里一直没有语法高亮,明明装了 Vagrant 插件的。

还好网上搜到了这个方法,新建 Vagrantfile.xml 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<filetype binary="false" description="Vagrant Configuration File" name="Vagrantfile">
<highlighting>
<options>
<option name="LINE_COMMENT" value="#" />
<option name="COMMENT_START" value="=begin" />
<option name="COMMENT_END" value="=end" />
<option name="HEX_PREFIX" value="" />
<option name="NUM_POSTFIXES" value="" />
<option name="HAS_BRACES" value="true" />
<option name="HAS_BRACKETS" value="true" />
<option name="HAS_PARENS" value="true" />
<option name="HAS_STRING_ESCAPES" value="true" />
</options>
<keywords keywords="BEGIN;END;begin;break;case;do;else;elsif;end;ensure;for;if;in;next;rescue;retry;then;until;when;while" ignore_case="false" />
<keywords2 keywords="__ENCODING__;__END__;__FILE__;__LINE__" />
<keywords3 keywords="and;false;nil;not;or;true" />
<keywords4 keywords="class;def;module;return;self;super;undef;yield" />
</highlighting>
<extensionMap>
<mapping pattern="Vagrantfile" />
</extensionMap>
</filetype>

貌似得放到某个文件夹下,懒得找了,直接在 IDE 里设置吧:

进入 File > Settings > Editor > File Types 对话框,在 Recognized file types: 里点击 + 号,
添加 Vagrantfile 类型即可,其它的配置参照上面 Vagrantfile.xml 里,

唯一要注意的是 Keywords 部分不是以 ; 分割而是需要一行一个 Keyword

错误

1
__ENCODING__;__END__;__FILE__;__LINE__

正确

1
2
3
4
__ENCODING__
__END__
__FILE__
__LINE__

用这种方法还可为任何类型的文件增加语法高亮,是不是很赞!

在 Docker 容器里使用 Crontab

最近公司有一个需求是在容器里运行 Cron 服务

Dockerfile

CentOS 上安装 Cron 很简单,一条命令就可以搞定

1
yum install cronie

目前的 cronie 版本是 1.4.11

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Name        : cronie
Arch : x86_64
Version : 1.4.11
Release : 23.el7
Size : 215 k
Repo : installed
From repo : base
Summary : Cron daemon for executing programs at set times
URL : https://github.com/cronie-crond/cronie
License : MIT and BSD and ISC and GPLv2+
Description : Cronie contains the standard UNIX daemon crond that runs specified programs at
: scheduled times and related tools. It is a fork of the original vixie-cron and
: has security and configuration enhancements like the ability to use pam and
: SELinux.

直接基于 CentOS7 编写 Dockerfile

1
2
3
4
5
FROM centos:7
RUN groupadd --gid 1000 foo &&\
useradd --uid 1000 --gid foo foo
RUN yum install cronie -y
CMD ["crond","-n","-x","misc,load","-i"]

启动命令

Cron 的入口当然是 crond 啦,不过 crond 需要前台运行

1
crond -n -i -x misc,load

其中:

-n 进程挂前台

-i 关闭 inotify,因为容器里的 inotify 不生效因此检测不到挂载文件的变更

-x 开启调试信息: misc 可以打印命令的输出,load 可以显示读取了哪些配置,其它配置项见文末参考

配置文件

cron 的配置文件可以在构建镜像的时候打进去,也可以按照需要挂在

挂载为用户服务

1
docker run -it --rm -v ./foo:/var/spool/cron/foo cron crond -n -x misc,load -i

foo 文件

1
2
3
4
5
SHELL=/bin/bash
PATH=/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=root

* * * * * id

挂载为系统服务

1
docker run -it --rm -v ./root:/etc/crontab cron crond -n -x misc,load -i

crontab 文件

1
2
3
4
5
SHELL=/bin/bash
PATH=/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=root

* * * * * root id

注意 /etc/crontab 和 /var/spool/cron/foo 不一样还有第 6 个参数,即指定运行的用户

参考

参数 备注
ext 通常和其它几个变量组合使用
sch scheduler
proc 进程控制
pars 语法解析
load 读取
misc 杂项
test 测试模式,命令实际不会执行
bit ??

也谈 HashiCorp 禁止中国公司使用

昨天最大的瓜当属 HashCorp 禁止中国公司使用。

作为一名 Devops 工程师,HashCorp 的产品或多或少都接触过。例如之前我们就用他家的 Vagrant 搭建 Ansible 脚本的 CI 环境, 用 Consul 做服务发现,整体给人的感觉还是不错的。

今天这个瓜的震惊之处在于 Hashicorp 赤裸裸的在协议里写了禁止在中国使用,虽然实际上 出口管制法 并不是第一天存在了. 中国的很多外企分公司实际上都在遵守这一法律。比如微软、英特尔这样的公司很多核心技术都不会出口到中国来,甚至中国国籍的员工参与这些项目的研发也会被法律所限制。

HashiCorp 错就错在中美斗争的这个关键节点上,把这个一直存在的问题又强调了一遍,HashiCorp 本可以只写 出口限制国家 的(这个列表里还有我们的朝鲜兄弟、伊朗兄弟等),但是非得写 PEOPLE'S REPUBLIC OF CHINA,瞬间在技术圈引爆了一个大瓜。

另一家公司 Github 其实也不让人省心,因为之前就爆出过屏蔽伊朗开发者的新闻。类似的事件或多或少都会影响中国的开发者参与国际开源软件的热情。还好疫情期间我自己在阿里云上搭了一个 Git 服务器,CI 环境使用的也是自己的,不再担心 Travis 总是很慢的问题。

总之这次事件再次给我们提了个醒,自主知识产权是多么重要,国产软件加油,国产基础设施加油!

Snap 设置代理

Snap 从 2.28 版本开始支持代理设置了,安装开发用的工具就方便很多。

1
2
sudo snap set system proxy.http="http://<proxy_addr>:<proxy_port>"
sudo snap set system proxy.https="http://<proxy_addr>:<proxy_port>"

分享几个我常用的

Redis Desktop Manager

Redis 的一个图形界面客户端

1
sudo snap install redis-desktop-manager

Slack

Slack 客户端

1
sudo snap install slack

[译] 导致 SourceMap 无效常见的 4 个原因

原文

Souce map 非常好用。换句话说,它们被用来在调试阶段显示源代码,这比线上压缩后的代码好懂多了。从某种意义上讲,source map 可以说是秘密代码(压缩后的代码)的解码器。

但是要让 source map 正常工作可能很棘手。如果你遇到了麻烦,接下来的一些提示或许能帮助你更好的工作。

如果你第一次接触 source map,请在继续阅读前看看这篇早期的博客 Debugging Minified JavaScript with Source Maps.

丢失或错误的 source map 注释

我们假设你已经通过 UglifyJS 或者 Webpack 生成了一个 source map。但如果只是生成,而浏览器实际上找不到它,那就很划不来了。要做到这一点,浏览器会假设打包好的 JavaScript 文件里有一行含 sourceMappingURL 的注释或者返回一个叫 SourceMap 的 HTTP 响应头,这个响应头指向 source map 文件的位置。

为了验证 source map 注释能够正常工作,你需要:

找到文件最后,自成一行的 sourceMappingURL 注释

1
//# sourceMappingURK=script.min.js.map

这个值必须是一个有效的 URI。如果是相对路径,那么它是相对于打包出来的 JavaScript 文件(例如 script.min.js)的路径。大多数 source map 生成工具会自动生成这个值,而且提供了选项用于覆盖它。

如果用的是 UglifyJS,可以通过指定 source map 参数 url=script.min.js.map 来生成这个注释:

1
2
# Using UglifyJS 3.3
$ uglifyjs --source-map url=script.min.js.map,includeSources --output script.min.js script.js

如果用的是 Webpack ,通过指定 devtool: "source-map" 能够开启 source map,Webpack 会在最终生成的文件最后输出 sourceMappingURL。你可以通过 sourceMapFilename 自定义该文件的名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// webpack.config.js
module.exports = {
// ...
entry: {
app: "src/app.js"
},
output: {
path: path.join(__dirname, 'dist'),
filename: "[name].js",
sourceMapFilename: "[name].js.map"
},
devtool: "source-map"
// ...
}

需要注意的是即使你正确生成了 sourceMappingURL,也有可能它没有在最终上线的版本里出现。例如,前端构建工具链里其它的工具可能会移除所有的注释,结果就是把 //# sourceMappingURL 也一并删掉。

还有一种情况是你的 CDN 可能会相当智能地把不认识的注释统统删掉;Cloudflare 的自动压缩功能以前就会这么干。所以记住上线后一定要再次确认!

另外一种做法是:确保服务器返回有效的 SourceMap HTTP 响应头

除了这个神奇的 sourceMappingURL 注释,你还可以通过返回一个 SourceMap HTTP 响应头来指定 source map 的地址。

1
SourceMap: /path/to/script.min.js.map

sourceMappingURL 一样,如果这个值是相对路径则相对于打包出来的 JavaScript 文件。浏览器解析 SourceMap HTTP 响应头和 sourceMappingURL 的规则是一样的。

注意你需要配置你的 web 服务器或者 CDN 来返回这个响应头。但是很多 JavaScript 开发者并不能够随意的修改线上资源的头,所以对大多数人来说,生成 sourceMappingURL 要更简单一些。

缺少源代码文件

我们假设你已经正确配置好 source map,你的 sourceMappingURL(或者 SourMap 响应头)存在且生效。到源代码的转换前面部分已经能够正常工作;例如,错误堆栈现在指向源文件的文件名,并且行号和列号也有意义了。尽管这已经算有所提升,但还是缺少一部分,你还是不能通过浏览器的调试工具查看到源代码。

这很有可能是由于你的 source map 文件没有包含或是指向你的源文件导致的。如果没有源文件,你在调试压缩后的代码时还是会卡住。哦天哪。

有几种解决方案可以让源代码文件能够正常工作:

通过 sourcesContent 把源代码嵌到 source map 文件里

实际上把源代码放到 source map 里是有可能的。在 source map 里,这个字段是 sourcesContent。虽然这会导致 source map 的体积增迅速增长(数以兆计),但是能够非常简单地让浏览器定位并关联你的源文件。如果你为了让浏览器显示源文件而焦头烂额,我们推荐你这么做。

如果你用 UglifyJS,你可以用过 includeSources 命令行参数把源代码包含到 source map 的 sourcesContent 属性里:

1
uglifyjs --source-map url=script.min.js.map,includeSources --output script.min.js script.js

如果你用 Webpack,不需要做什么 - Webpack 会默认把源代码包含进 source map (前提是已经打开了 devtool:"source-map" 配置)。

把源代码放到开放服务器上

除了在 source map 里包含源代码,你也可以把它们放到服务器上供浏览器下载。如果你对安全性有担忧,毕竟是你的原始代码,你可以放到 localhost 服务器或者确保它们通过 VPN 才能访问(即这些文件只能通过公司内部网络访问)

Sentry 用户可以上传源文件

如果你是一个 Sentry 用户并且你的首要目的是确保 source map 文件能够被用来还原堆栈信息以及前后的源代码,你可以试一下第三种方法:使用 sentry-cli 或者直接调用 API 上传源文件

当然,如果你用的是前两种方法 - 不管是在 source map 了包含源代码还是放到对外开放的服务器上 - Sentry 都能够找到。这完全取决于你。

多次转换导致 source map 失效

如果你用到了两个或以上的 JavaScript 编译器(例如 Babel 和 UglifyJS)独立调用,有可能生成的 source map 文件指向的是一个处于中间转换状态的代码,而不是源代码。这意味着你在浏览器里调试的时候,步进的是未压缩的代码(这已经有所改善)而不是和你的源代码一一对应。

举个例子,你用 Babel 把 ES2018 的代码转换成 ES2015,然后用 UglifyJS 进行压缩

1
2
3
4
# Using Babel7.1 and UglifyJS 3.3
$ babel-cli script.js --presets=@babel/env | uglifyjs -o script script.min.js --source -map "filename=app.min.js.map"
$ ls script*
script.js script.min.js script.min.js.map

如果你直接用这个命令生成的 source map 文件,你就会发现它并不准确。这是因为这个 source map 只能把压缩后的代码转换成 Bebel 生成的代码。它并不会指向你的源代码。

注意这个问题在用 Gulp 或者 Grunt 这类任务管理器的时候也很常见。

要解决这个问题,有两种方案:

用类似 Webpack 的打包工具管理所有的转换

不再把 Babel 和 UglifyJS 分开调用,而是用它们的 Webpack 插件形式(例如 babel-loaderuglifyjs-webpack-plugin)。Webpack 能够生成单一的 source map 文件来把最终结果转换回源代码,虽然实际上背后依然有多个转换步骤。

用一个库把不同转换步骤的 source map 串联起来

如果你决意要分开使用编译器,你可以用 source-map-merger,或者 Webpack 的 source-map-loader 插件,来把上一步的 source map 吐给下一步的转换。

如果你有的选,还是推荐你用第一步,直接用 Webpack 省得后来哀怨。

文件版本不对或缺少版本管理

我们假设你遵循了上面所有的步骤。你的 sourceMappingURL(或 SourceMap HTTP 响应头)存在并且被正确的声明。你的 source map 包括了你的源代码(或放到公网上)。并且你用了 Webpack 做转换端到端的管理。你的 source map 还是会时不时地映射错误。

还剩下这样的可能:source map 和生成的代码不匹配。

这个问题会在这种情况下会发生:首先、浏览器或者工具下载了一个生成的代码(例如 script.min.js),然后试着去下载对应的 source map 文件(script.min.js.map),但是下载到的是 “更新” 后的 source map 文件,和之前的生成代码已经不匹配了。

这种情况并不会很常见,但是当你在调试的同时进行部署的时候会发生,或者你调试的是即将过期的、被浏览器缓存的资源时会发生。

要解决这个问题,你需要管理好文件和 source map 的版本,有下面几种方式:

  • 给每个文件添加版本号,例如:script.abc123.min.js
  • 在 URL 里添加版本号字符,例如 script.min.js?abc123
  • 为父级目录添加版本号,例如 abc123/script.min.js

选择哪种策略并不要紧,关键是对所有的 JavaScript 资源要使用一致的策略。最好每一个生成的文件和 source map 都有相同的版本号和命名规则,就像下面这样:

1
2
3
// script.abc123.min.js
for(var a=[i=0];++i<20;a[i]=i);
//# sourceMappingURL=script.abc123.min.js.map

用这种方法管理版本能够保证浏览器下载到生成代码和 source map 文件对应上,避免不必要的版本不一致问题。

[译] 书写灵活、可维护,可扩展的 Ansible Playbook

原文

自从 2013 年开始使用 Ansible,我已经用 Ansible 自动化完成很多事情:SaaS 服务,树莓派集群,家庭自动化系统,甚至我自己的电脑。

从那以后,我学会了很多能够降低维护负担的技巧。对我来说项目的可维护性异常重要,因为我的很多项目,例如一个 Apache Solr 项目已经存在超过 10 年了!如果项目难维护或者架构上难以做出大的改变,我会把项目输给其它更敏捷(nimble)的对手,进而丢掉金钱,更重要的是我可能会疯掉。

今年我会在奥斯汀举办的 AnsibleFest 上做一个同名分享,本文即总结这次分享的主题。

保持井井有条

我喜欢摄影和自动化,所以我花了很多时间在涉及树莓派和相机的电子项目上。如果没有图中的组织系统,想要把部件放到正确的位置会是一件让人沮丧的事情。

组织系统

同样的,在 Ansible 中,我喜欢把我常用的 task 组织起来,这样才能更轻松编写和测试它们,并且不需要太多的精力就可以管理好它们。

开始的时候我会把所有的 task 写到一个 playbook 文件里。当文件到达 100 行左右,我会把相关的任务拆分到不同的文件里,并在 playbook 中使用 include_tasks 引入它们。

随着 playbook 越来越复杂,我经常注意到有一些相关性很高的的 task 可以被独立开,例如安装一个软件、拷贝配置文件、启动(重启)一个守护进程。这种情况下我会用 ansible-galaxy init ROLE_NAME 命令新建一个 role,并且把那些 tasks 放进这个 role 里。

如果这个 role 够通用,我会把 role 放到 Github 并且提交到 Ansible Galaxy 里,又或者放到一个单独的私有 Git 仓库里。现在我可以通过 Molecule 或者其它测试工具为 role 添加一系列的通用测试,哪怕这些 role 被隶属不同的团队的不同项目所使用。

之后我会通过 requirements.txt 文件把外部的 role 引入到项目里。对于某些稳定性至关重要的项目,我会通过 git ref 或者 tag 指定 role 的版本。对于其它项目我则会牺牲一点稳定性以换取更好的可维护性(例如测试 playbook 或者一次性的服务器配置),我直接使用 role 的名字(如果不在 Ansible Galaxy 上就指定仓库的详情)。

对于大部分项目我都不会把外部 role 提交到代码仓库里,因为在 CI 系统里每次从头运行的时候都会去安装。但是在有一些情况下,最好把所有的 role 都提交到仓库里。比如有一些开发者日常会用到我写的 Drupal 虚拟机的 playbook,这些开发者通常住在离 Ansible Galaxy 服务器很远的地方,所以他们在安装大量必要依赖的时候会遇到麻烦。因此我把所有 role 都提交到仓库里了,这样他们在构建一个新的 Drupal 虚拟机实例的时候就不用等着所有 role 安装完成了。

如果你真的把 role 都提交到仓库里了,你需要在每次更新 role 的时候有一个彻底(thorough)的流程,确保你的 requirements.yml 文件和已经安装的 role 同步!我通常通过 ansible-galaxy install -r requirements.yml --force 命令来强制替换仓库里的 role,并且保持诚实(踏实?)!

简化和优化

YAML 不是一门编程语言
- Jeff Geerling

大家喜欢用 Ansible 的一个原因是它基于 YAML 并且拥有一套声明式的语法。如果你要安装一个模块就在 task 里这样写:package: name=httpd state=present。如果你要确保一个服务运行就这样写 service: name=httpd state=started

然而在很多情况下,你会需要让一切更智能化。举个例子,如果你用相同的 role 构建虚拟机和容器,但是你并不想在容器里启动服务,你需要增加一个只在某些条件下执行(when condition)的限制:

1
2
3
4
5
- name: Ensure Apache is started
service:
name: httpd
state: started
when: 'server_type != "container" '

此类逻辑是简单的,并且在别人阅读 task 以搞清楚它的目的时候很有用。但是有的人会在 when condition 里写上一大堆花里胡哨的判断,甚至是 Ansible 暴露出的 Jinja2 和 Python 的接口,这种情况下容易失控(get off rails)。

根据经验(as a rule of thumb),如果你在 playbook 的 when condition 里为了正确的转义引号上花费了 10 分钟以上,你这时候就应该考虑写一个单独的模块来完成 task 用到的逻辑。Python 脚本通常应该位于独立的模块里,而不是和其它的 YAML 写到行内。当然也有例外(比如比较复杂的字典和字符串时),但我会努力避免在 Ansible playbook 里写任何复杂的代码。

除了避免复杂逻辑,还有一个很有效的方法是让 playbook 运行更快。我经常 profile 一个 playbook (通过设置 callback_whitelist = profile_roles, profile_tasks, timer 默认参数),发现一两个 task 或者 role 和 playbook 其它的相比花了很长的时间。

举个例子,有一个 playbook 里用了 copy 模块来复制一个有几十个文件的大目录。由于 Ansible 拷贝文件模块的内部实现,复制每个文件都意味着一直在 SSH 链接上等待着传输完成。

把这个 task 改成基于 synchronize 的可以在每次运行的时候节省好长时间。针对单次运行这看起来没什么,但是当 playbook 需要定期运行的时候(例如确保一台服务器的配置),或者作为 CI 流程的一部分的时候保持它的高效就很重要了。否则它会让 CPU 周期耗费在一些无用代码上,开发者通常会很讨厌等待 CI 测试通过,他们只想知道代码会不会导致问题。

请关注我在 AnsibleFest Austin 2018 的演讲 Make your Ansible Playbooks flexible, maintainable, and scalable。如果这还不够,我在我的书 《Ansible for DevOsp》 里有很多关于编写和维护 Ansible Playbook 的文章(你可以从 Red Hat 官网下载到摘录)。

基于 Webpack 的跨平台开发

小程序、快应用的开发最近相当热门,公司也在开发对应的 SDK 。既然小程序、快应用都是选用的 JavaScript 做为开发语言,那么有没有
可能,让小程序和快应用都能公用基于 H5 的 SDK 核心。

答案是肯定可以!如果用一个词来概括软件工程最想解决的问题,那么 问题拆分 也许是最合适的,我们把问题拆解:

  • JavaScript 核心
  • 对不同平台接口的抽象

JavaScript 核心 不用说,和具体的业务逻辑有关,设计之初就要清晰的知道系统的边界在哪里,核心和接口之间如何通信。

主要的问题在如何降低接口抽象的复杂度。

环境变量 - 代码如何知道当前运行的环境

现代软件开发越来越重视过程,例如很多软件都很明显的区分为 构建运行 两个部分。通过参数的不同取值指定运行环境
构建 过程一个比较常用的步骤,这样做的好处是能够为不同平台构建出不同的工件,有利于减小工件的体积。

1
2
# 为 H5 打包
PLATFORM=H5 build
1
2
# 为快应用打包
PLATFORM=quickapp build

这样就通过环境变量把运行时的环境传给了负责构件的命令 build (这里假设构件的命令是 build)。

下面的代码就能够打出不同的包,并在不同平台上会打印出不同的内容:

1
2
// app.js
console.log(`我在 ${process.env.PLATFORM} 平台下`);

不同平台如何访问环境变量

process 其实是 Node.js 全局定义的一个属性,那么为什么在非 Node.js 平台下也能访问到呢?这就要借助 前端 JavaScript 打包
工具 webpack 了, 关于 process 的处理有两种情况:

  • 在全局会有一个 mock 的 process 对象,确保相关代码能够访问到 process 对象而不会报错
  • 通过 DefinePlugin 替换代码里的 process.env.PLATFORM

模块隔离 - 根据平台执行不同的逻辑

知道了当前的运行环境,就能够根据平台执行不同的逻辑了

一般做法

其实就是 if else ,代码里一定多多少少有一些判断当前平台的代码。

1
2
3
4
// app.js
if(process.env.PLATFORM === 'H5'){
// 如果是 H5 平台就执行这里的代码
}

通过 if else 虽然能够解决问题,但是当代码比较复杂的时候还是会导致难以维护,比如下面这种情况:

1
2
3
4
5
6
7
8
9
// app.js
if(process.env.PLATFORM === 'H5'){
// H5 相关逻辑
}else if(process.env.PLATFORM === 'quickapp'){
// 快应用下要加载一个模块
const dep = require('some-dependency');
}else{
// 其它
}

因此这种做法只适合代码比较简单的情况,例如传递一些 flag,否则可以用下面这种方法。

更优方案

假设 H5、快应用、小程序都需要用到一个模块叫做 foo,引入的源代码如下:

1
2
// app.js
require('./foo')

目录结构如下,foo 模块对应不同平台有不同实现,但是暴露的方法签名以及返回类型是一致的:

1
2
3
4
.
foo.js
foo.quickapp.js
foo.miniprogram.js

通过 webpackconfig 参数可以指定不同平台的配置文件。

平台 webpack 配置文件
h5 webpack.config.js
miniprogram webpack.config.miniprogram.js
quickapp webpack.config.quickapp.js
1
2
3
4
5
6
7
// webpack.config.js
module.exports = {
//...
resolve: {
extensions: ['.js', '.json']
}
};

如果是快应用的话就修改成这样

1
2
3
4
5
6
7
// webpack.config.quickapp.js
module.exports = {
//...
resolve: {
extensions: ['.quickapp.js', '.js', '.json']
}
};

可以看到 resolve.extensions 其实是一个数组,当 webpack 遇到 require 一个文件依赖的时候会按照这个顺序进行匹配。

命名空间 - 解决第三方模块依赖于浏览器的问题

这其实是开发过程过程中一个非常具体的问题,这个模块是 jsencrypt

H5 平台下引入了这个模块,打包后运行没有问题,但是当尝试在快应用上运行的时候一直报这个错:

1
"window is undefined"

显然这个插件是为了 H5 开发的,没有考虑其它平台的兼容性。

一般做法

直接把源码下下来丢到 vendors 文件夹下,然后把 windownavigator 相关的兼容性一个一个问题修复。这种做法其实放弃了使用 npm 管理依赖的优势,
并且在项目中留下了一个技术债。以后如果 jsencrypt 发布了一个新的不得不使用的版本(例如修复某个安全漏洞),需要手动更新依赖并且打补丁。

更优方案

后来突然想起来网上有人遇到过类似的情况:社区有很多的 jQuery 插件,这些插件在编写的时候会假设全局有一个 $ 对象,但是使用了 webpack 以后,
由于用到了闭包,全局环境下其实是没有 $ 这个对象的的,而解决这个问题一个比较通用的做法是使用 webpackProvidePlugin

1
2
3
4
5
6
7
8
9
10
// webpack.config.js
module.exports = {
//...
plugins: [
//...,
new webpack.ProvidePlugin({
$: 'jquery'
})
]
};

顺着这个思路,修改了一下 webpack 配置文件:

1
2
3
4
5
6
7
8
9
10
11
// webpack.config.quickapp.js
module.exports = {
//...
plugins: [
//...,
new webpack.ProvidePlugin({
window: path.join(__dirname, 'noop.js'),
navigator: path.join(__dirname, 'noop.js')
})
]
};

noop 的代码也非常简单,就是返回一个空对象

1
2
// noop.js
module.exports = {};

这样至少可以保证所有第三方模块都不会报 window 或者 navigator 找不到的错误。

总结

通过上面几个由浅入深的问题,我们都够了解到 webpack 不仅仅是前端的打包工具,也可以用于跨平台的开发。

解决问题 Webpack 配置 试用场景
环境变量 DefinePlugin 判断平台,传递 Flag 等较为简单的逻辑
模块隔离 resolve.extensions 引入同一个模块在不同平台下的实现
命名空间 ProvidePlugin 修复兼容性问题,提供跨平台的全局空间