2018年11月23日星期五

把我的个人网站推倒重来(Part 6 - hreflang)

因为我在上一篇文章讲 Open Graph 元数据时提到 hreflang,我可以用这篇文章简单讲一讲如何支持 herflang。使用 hreflang 好处的是让 Google 知道多个页面其实是同一内容的不同语言版本,这样在用户搜索时 Google 就可以尽量提供正确的语言版本。

Google 官方对 hreflang 提供了详尽的解释。要让网站支持 hreflang 有三种做法:HTML 标签、HTTP header 以及 Sitemap。我选择了 HTML 标签,因为在我添加 hreflang 的时候 Google 还没提供另外两种做法。如果让我现在重新选择的话,我很有可能选择使用 Sitemap 从而减少页面中对用户没有价值的字节。

我在之前的篇文章解释了我的网站是如何支持多语言的,我的 hreflang 实现同样依赖于我做的这个基于 Harp 的多语言方案。对于每一个页面 /page,我都有 /zh/page/en/page 对应其中文版和英文版。这时候根据 Google 的要求,hreflang="x-default" 应该指向 /page,然后 hreflang="zh" 应该指向 /zh/page,英文版同理。举个例子,首页的 hreflang 标签是这样子的:

<link rel="alternate" hreflang="x-default" href="https://catchen.me/">
<link rel="alternate" hreflang="en" href="https://catchen.me/en/">
<link rel="alternate" hreflang="zh" href="https://catchen.me/zh/">

为了让 Harp 生成这组标签,我在模板中先取出当前页面语言无关的名称(也就是 /zh/page 中的 page),然后以此生成 x-default 的标签。接着我再遍历网站支持的语言,逐一生成对应语言的 hreflang 标签。具体代码可以在 GitHub 上看到。因为我让 Harp 遍历网站支持的所有语言,将来如果我添加了新的语言,只要让 Harp 重新便宜网站新的语言便会出现,不需要我做任何的手工修改。

hreflang 就这么简单的搞掂了。接下来让我们对网站加上 Google Analytics 和 Facebook Pixel,好让我们统计网站的访问来源。如果你想要继续关注这个系列的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年11月17日星期六

把我的个人网站推倒重来(Part 5 - Open Graph 元数据)

网站发布之后我开始做各种细小的优化,其中一项是为网站加上 Open Graph 元数据( metadata),使得网站在被 Facebook 抓取时能够显示正确的预览信息。(Google 在抓取时也会参考 Open Graph 元数据,虽然 Open Graph 是 Facebook 提出的标准。)

Open Graph 标准

Open Graph 标准本身并不复杂,看着官方的标准信息把每一项相关的属性都加上。以下是我个人网站上使用的 Open Graph 信息:

<meta property="og:type" content="profile">
<meta property="og:title" content="Cat Chen">
<meta property="og:description" content="Cat Chen's personal website.">
<meta property="og:url" content="https://catchen.me/">
<meta property="og:image" content="https://catchen.me/images/profile_picture_360_360_1x.jpg">

Facebook 专门提供了一个工具来验证网页上的 Open Graph 元数据。在把元数据添加好之后,我用这个工具测试了一下 Facebook 抓取到的数据。

Screenshot 2018-11-10 16.35.56

Open Graph 文本属性其实随便怎么填都可以,URL 属性需要保证是正确的地址。以下是两个常用的 URL 属性:

og:image 属性

这个属性用来设置 Facebook 预览时显示的图片。我选择了用来显示我的头像,因为这也是我显示在网站首页左侧的图片。

og:url 属性

这个属性用来指定所谓的「Canonical URL」,我为这个属性折腾了很久。这是因为 Facebook 把 Canonical URL 看作重定向,如果一个 URL1 返回的网页使用 og:url 属性指向另一个 URL2,在 Facebook 看来这根 URL1 返回 302 重定向到 URL2 一样。这导致 Facebook 在抓取我的页面时陷入了无限重定向循环。

要解释这件事情,首先要解释我在 Netlify 上做了什么重定向配置,这是我在上一篇文章中没有提到的。我在之前一篇文章中说了,我的网站支持多语言(暂时只有英文和中文),不同语言使用不同的目录,例如说中文版在 /zh/* 英文版在 /en/*。这造成了一个问题,网站首页 / 应该显示中文版还是英文版?为此我让 Netlify 根据用户浏览器提供的 accept-language header 来选择正确的语言。(如果这个 header 不存在则默认为英文。)这是当时的 netlify.toml 配置文件。

如果只有这一种重定向,无限循环并不出现。无限循环之所以出现,是因为我把 /zh//en/ 的 Canonical URL 都指向了 /。为什么要指向 / 呢?因为我认为中文版和英文版都只是特定语言的版本而已,只有未指定语言的 / 才有资格叫做 Canonical URL。

最终我撤销了第一种重定向(302),保留了第二种重定向(Canonical URL)。撤销第一种重定向后,我把 / 改了了内容代理,也就是说如果浏览器的 accept-language header 选择了中文,那 / 显示的就是中文版,内容跟 /zh/ 完全一样但不改变 URL。这使得我的网站被分享到 Facebook 时指向的都是未指定语言的 URL,每一个用户实际打开时看到的语言都由他浏览器的设置来决定。我觉得这是我能做到的对用户最便利的选择。

这是我修改后的 netlify.toml。在 Netlify 的配置中使用 200 重定向意思就是代理目标页面内容——用户看不到重定向的发生,但实际返回的内容时目标页面的内容。做了这一件事情后,接下来要支持 hreflang 就很方便了,我在下一篇文章里就会讲述 hreflang 的设置。如果你对此感兴趣的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年11月7日星期三

NPM 打包时该忽略哪些文件?

最近在写一个新的 JavaScript 库,叫做 dice-chance,用来分析掷骰子的概率。计划是库写完了就用 PWA 封装一下发布给大家用。因为在写的时候用到了 Flow 做类型声明,所以源代码文件不能不经处理直接被调用,必须经过 flow-remove-types 处理一下删除 Flow 类型声明。

为了保证在包发布时 Flow 类型会被删除掉,我在 package.json 中定义了 build 脚本,然后设置了 prepublish 事件触发 build 脚本:

"scripts": {
  "build": "flow-remove-types src/ -d lib/",
  "prepublish": "yarn run build"
},

奇怪的是,在执行 npm publish 时我明明看到了 build 脚本被触发了但打包时却没有引入 lib 目录。这样打包出来的库不能用,因为 index.js 里面引用的文件都来自于 lib 目录而非 src 目录。打包时的输出时这样子的:

$ npm publish --dry-run

> dice-chance@2.0.1 prepublish .
> npm run build

> dice-chance@2.0.1 build .
> flow-remove-types src/ -d lib/

src/Analyzer.js
 ↳ lib/Analyzer.js
src/DiceChance.js
 ↳ lib/DiceChance.js
src/Parser.js
 ↳ lib/Parser.js
src/Tokens.js
 ↳ lib/Tokens.js
src/__tests__/Analyzer-test.js
 ↳ lib/__tests__/Analyzer-test.js
src/__tests__/DiceChance-test.js
 ↳ lib/__tests__/DiceChance-test.js
src/__tests__/Parser-test.js
 ↳ lib/__tests__/Parser-test.js
npm notice
npm notice 📦  dice-chance@2.0.1
npm notice === Tarball Contents ===
npm notice 1.1kB   package.json
npm notice 48B     .babelrc
npm notice 58B     .flowconfig
npm notice 232B    .travis.yml
npm notice 48B     index.js
npm notice 1.1kB   LICENSE
npm notice 1.5kB   README.md
npm notice 112.4kB yarn.lock
npm notice 6.1kB   src/__tests__/Analyzer-test.js
npm notice 2.8kB   src/__tests__/DiceChance-test.js
npm notice 2.6kB   src/__tests__/Parser-test.js
npm notice 2.0kB   src/Analyzer.js
npm notice 1.4kB   src/DiceChance.js
npm notice 1.3kB   src/Parser.js
npm notice 949B    src/Tokens.js
npm notice === Tarball Details ===
npm notice name:          dice-chance
npm notice version:       2.0.1
npm notice package size:  37.7 kB
npm notice unpacked size: 133.7 kB
npm notice shasum:        5d7c0aca59b63aef43e32885fd7d254676a6db8f
npm notice integrity:     sha512-4kzv5srVKgrYJ[...]ynoZKx48wAKfw==
npm notice total files:   15
npm notice
+ dice-chance@2.0.1

这到底是为什么呢?一番搜索后我才发现,NPM 默认会用 .gitignore 来决定打包时忽略哪些文件。因为 lib 是构建的产物,不应该属于源代码的一部分,所以我用 .gitignore 文件把 lib 目录忽略掉了。NPM 因此在打包时也把 lib 忽略掉了,但其实 lib 必须被打包,而 src 反而可以被忽略掉(因为 src 中的源文件不会被 index.js 引用。

为了解决这个问题,我需要引入 .npmignore 文件。这个文件的格式跟 .gitignore 的格式一致,只要这个文件存在 NPM 打包时就用它来决定忽略什么,不再理会 .gitignore。我把 .gitignore 先复制为 .npmignore,再把里面的 lib 替换成 src,然后打包就再也没有问题了。以下是正确的打包输出:

$ npm publish --dry-run

> dice-chance@2.0.1 prepublish .
> yarn run build

yarn run v1.12.1
$ flow-remove-types src/ -d lib/
src/Analyzer.js
 ↳ lib/Analyzer.js
src/DiceChance.js
 ↳ lib/DiceChance.js
src/Parser.js
 ↳ lib/Parser.js
src/Tokens.js
 ↳ lib/Tokens.js
src/__tests__/Analyzer-test.js
 ↳ lib/__tests__/Analyzer-test.js
src/__tests__/DiceChance-test.js
 ↳ lib/__tests__/DiceChance-test.js
src/__tests__/Parser-test.js
 ↳ lib/__tests__/Parser-test.js
✨  Done in 0.26s.
npm notice
npm notice 📦  dice-chance@2.0.1
npm notice === Tarball Contents ===
npm notice 1.1kB   package.json
npm notice 48B     .babelrc
npm notice 58B     .flowconfig
npm notice 232B    .travis.yml
npm notice 48B     index.js
npm notice 1.1kB   LICENSE
npm notice 1.5kB   README.md
npm notice 112.4kB yarn.lock
npm notice 6.1kB   lib/__tests__/Analyzer-test.js
npm notice 2.8kB   lib/__tests__/DiceChance-test.js
npm notice 2.6kB   lib/__tests__/Parser-test.js
npm notice 2.0kB   lib/Analyzer.js
npm notice 1.4kB   lib/DiceChance.js
npm notice 1.3kB   lib/Parser.js
npm notice 949B    lib/Tokens.js
npm notice === Tarball Details ===
npm notice name:          dice-chance
npm notice version:       2.0.1
npm notice package size:  37.6 kB
npm notice unpacked size: 133.7 kB
npm notice shasum:        218703ab30ffad27bc76c1c9a6a2852838e0fb58
npm notice integrity:     sha512-gammoNvPgDcyd[...]BpngI1zA2X7dg==
npm notice total files:   15
npm notice
+ dice-chance@2.0.1

2018年10月7日星期日

把我的个人网站推倒重来(Part 4 - Responsive Image)

网站整体完成后,我就可以开始做各种小优化了。其中一个优化是使用 responsive image 来适应不同分辨率和不同像素密度的屏幕,用到的是 <img /> 新增的 srcsetsizes 属性以及新增的 <picture /> 元素。因为现在有多套新旧并存的 responsive image 方案,而且它们使用的属性存在重叠,所以要搞清楚到底这些属性如何运作,还是要动手实验。

sizes 属性

<img srcset="elva-fairy-320w.jpg 320w,
             elva-fairy-480w.jpg 480w,
             elva-fairy-800w.jpg 800w"
     sizes="(max-width: 320px) 280px,
            (max-width: 480px) 440px,
            800px"
     src="elva-fairy-800w.jpg" alt="Elva dressed as a fairy">

这是一段来自 MDN 的代码。虽然大家都是先写 srcset 再写 sizes,但其实更符合直觉的阅读顺序是先读 sizessizes 的值是一组类似 media query 的命令,它们描述了在什么情况下这个 <img /> 应该有多宽。拿上面这段代码举例:如果屏幕宽度是 320px 或以下,图片宽度为 280px;如果屏幕宽度是 480px 或以下(但 320px 以上),图片宽度为 440px;其它屏幕宽度,图片宽度默认为 800px。

这一切看起来都很简单,但这是因为我们只在讨论分辨率没在讨论像素密度。如果是 2 倍像素密度的 Retina Display,上述图片宽度计算是否保持不变?答案是跟 media query 一样保持不变。无论像素密度是多少,sizes 关注的都是 CSS 像素而不是物理像素。我觉得这个设计是合理的,因为在描述 <img /> 宽度时,我们的思维模式跟在写 CSS 时一样,所以应该使用 CSS 像素。

尽管在 MDN 的例子中 sizes 属性的取值都是固定值,但其实这里可以使用 calc() 表达式进行复杂的计算,如我的代码中就用到了 calc(100vw - 30px),意思是 100% 的 viewport 宽度减去屏幕两侧各 15pxmargin

srcset 属性

看完 sizes 接着看 srcset。在上面这段代码里,我们看到了一个神奇的单位叫做 w,这是指代图片文件的像素宽度。文件图片的像素宽度跟 <img /> 的 CSS 像素宽度不是 1:1 对应的吗?这需要看像素密度。如果 <img /> 的宽度是 100px,像素密度是 1 时最佳图片文件宽度是 100w;像素密度是 2 时最佳图片文件宽度是 200w;像素密度是 3 时最佳文件宽度是 300w;如此类推。

在上面这段代码中,srcset 描述了 3 个图片文件地址,它们的文件图片像素宽度分别是 320w480w800w。这也就是说,如果在一个 1000px 宽 1 像素密度的屏幕上,根据 sizes 这个 <img /> 的宽度应该是 800px,因此应该选择 800w 的图片文件地址;如果在一个 480px 宽 2 像素密度的屏幕上,根据 sizes 这个 <img /> 的宽度应该是 440px,但因为像素密度为 2 所以最佳图片文件宽度是 880w,由于找不到 880w 的图片文件地址所以选用略差一档的 800w 图片文件地址。

为什么 sizessrcset 这两个属性要如此设计呢?因为在之前的标准(如 CSS media query)里,我们需要在代码中描述如何根据两个变量(屏幕宽度和像素密度)来选择正确的图片文件地址,这个过程超级复杂,看这篇文章就能理解为什么这样的标准不好用。为了解决这个问题,新的标准让我们把这两件事情分离开来:sizes 决定图片在 CSS 布局中的大小,但跟像素密度无关;srcset 提供文件图片像素宽度,浏览器会自行根据 sizes 的结果和像素密度作出最佳选择,我们根本不需要知道像素密度。

(如果 <img /> 在布局中的大小永恒不变,可以不设置 sizes 属性,然后在 srcset 中使用 x 单位描述像素密度而不使用 w 单位。这时候 2x3x 可以对应不同的图片文件地址,浏览器会作出正确的选择。之所以我不选择这样做,是因为我的 <img /> 大小本身需要 responsive,所以必须用 sizes。因为 wx 这两种单位不能在同一个 <img /> 上混合使用,所以我用了 w。)

src 属性

看完 sizessrcset 这两个属性后,最后我们看 src 属性。这是给那些看不懂前两个属性的老浏览器看的,也就是默认的图片文件地址。

<picture /> 元素

上述 <img /> 元素属性能够实现同一张图片适应不同的屏幕尺寸和像素密度,但做不到根据屏幕尺寸现实不同的图片。我的网站首页布局本身是 responsive 的:如果屏幕宽度至少有 768px,使用左右两栏布局;否则使用一栏布局。

Screenshot 2018-09-30 15.21.45

左侧栏只显示我的个人照片,所以在能够使用两栏布局时我希望显示正方形(1:1)的剪裁。同样的剪裁显示在只能显示一栏的手机屏幕上就会显得很占地方,因此我需要换个剪裁方式(3:2)减少它占用的垂直高度,把更多的首屏垂直高度留给文本信息。

Screenshot 2018-09-30 15.22.05

为了实现根据屏幕尺寸使用不同的剪裁,我必须引入 <picture /> 元素然后在里面放入 <source /><img />

<picture>
  <source media="(max-width: 799px)" srcset="elva-480w-close-portrait.jpg">
  <source media="(min-width: 800px)" srcset="elva-800w.jpg">
  <img src="elva-800w.jpg" alt="Chris standing up holding his daughter Elva">
</picture>

这也是一段来自 MDN 的代码。浏览器在碰到 <picture /> 后,就开始按顺序看里面的 <source />。每个 <source /> 元素都有 media 属性,浏览器就如同执行 media query 一样来判断这个 media 属性的值是否通过,通过了就使用这个 <source /> 来显示图片,后面的子元素都会被忽略掉。如果所有 <source /> 都无法通过 media query 检测,最后那个 <img /> 就会用来显示。(不兼容 <picture /><source /> 的老浏览器只会显示 <img />。)

由于 <source /> 支持上述 <img /> 特有的 sizessrcset 属性,所以就算是放在 <source /> 中的图片也可以用上述方式支持不同的像素密度。考虑到大多数移动浏览器都会获得及时更新,能够支持 <picture /><source />,所以我选择了把正方形的剪裁当作默认剪裁放在 <img /> 里面,而针对小屏幕的剪裁放在 <source /> 里面。老旧的桌面浏览器如果打开我的网站首页,就算什么新元素和属性都不认识,至少能够根据 img.src 显示默认图片(像素密度为 1 的正方形剪裁)。

总结一下,如果需要针对不同的屏幕尺寸显示不同的图片(尤其是剪裁不一样的),必须使用 <picture /> 配合 <source /> 来选择正确的图片。一旦选定图片后,根据屏幕尺寸和像素密度设定图片尺寸只需要用到 sizessrcset 属性。

我的图片优化不仅仅用到了首页的个人照片上,也用到了项目页面的截图上。在图片之后,我们还有很多其它东西可以优化的,欢迎通过邮件RSS/Atom 订阅我的博客,继续关注后继文章。

2018年9月23日星期日

把我的个人网站推倒重来(Part 3 - 用 Netlify 做静态网站发布)

之前两篇文章讲述了我用 Harp 和 Bootstrap 搭建新版个人网站的过程,执行 harp compile 进行构建,输出的 www 目录就是我们想要的静态网站。我可以找个传统的静态网站 host,然后通过 FTP 这种古老的方式把文件上传上去。然而这真是我在第一篇文章中说到的一个痛点,我不希望通过 FTP 部署,最好是好像我熟悉的 Heroku 那样通过 GitHub 触发部署,我每次本地更新后执行一下 git push 就行。

Heroku

因为我已经熟悉 Heroku,所以我想到的第一个服务自然是 Heroku。Heroku 官方提供一个静态网站 buildpack,把 www 目录扔进去就可以了。当然这样还是不够简单,最好是把源代码扔进去,然后 buildpack 自动帮我调用 harp compile。曾经有人做过这样一个 Harp 专用 buildpack,可惜现在已经不维护了。基于 Heroku 官方的静态网站 buildpack 自己写一个 Harp 专用 buildpack 也不是很难,而且还能继承静态网站 buildpack 那一堆 header、redirect 等配置功能。

然而 Heroku 的免费版有两个问题:

  1. 应用会自动休眠——一段时间没有请求后它就会让你的服务进入睡眠状态,直到有新请求时再唤醒。要避免这个问题,需要每个月花 $7 来升级到业余爱好者版。有一个方法能够绕过这个问题,那就是在 Heroku 上加载某些用来监控服务可靠性的 add-on,因为这种 add-on 会定期访问你的网站检查可靠性,相当于帮你不停地阻止休眠。
  2. 不支持自定义域名 HTTP——同样是必须每个月花 $7 升级才能解决。绕过的办法也不是没有,那就是用 Cloudflare 做负载均衡器,然后用它免费提供的 HTTPS 服务。(Hue Explorer 就是用这个方法做 HTTPS 的。)引入 Cloudflare 的代价是,我必须把 DNS nameserver 迁到 Cloudflare,不能继续用 Google Domains 自带的 nameserver。

尽管每个月 $7 也不是很贵,但因为我知道我的个人网站很有可能部署后就几年都不碰一下,还要惦记着在 Heroku 上保持有效的付款方式好像有点麻烦,而一旦忘记了就可能导致网站下线。因此 Heroku 不是一个很好的解决方案。

因为我最近读完了《Working with Static Sites: Bringing the Power of Simplicity to Modern Sites》,所以就翻开了看看书中还推荐哪些静态网站的 host,然后就找到了以下两个服务。

Surge.sh

Surge.sh 超级简单,它甚至没有一个基于 web 的应用管理界面,它只提供一个命令行工具把你指定的目录发布到 CDN 上。没错,Surge.sh 提供的是非常直白的 CDN 服务,你提供一个要发布的目录,它就保证所有文件都能通过一个特定的域名在 CDN 上访问到。没有 Heroku 那么复杂的功能,连动态服务器都没有,一切都是静态的。

我安装了 Surge.sh 的命令行工具,把我老版本的个人网站部署上去了,然后发现它存在两个问题:

  1. 不支持自定义域名 HTTP——这跟 Heroku 一样,只是付费的专业版更贵,每个月要 $30。此外 Surge.sh 不像 Heroku 那样交钱了它就自动调用 LetsEncrypt 帮你认证域名然后申请证书,它需要你手动申请证书。我希望部署后永远不需要维护的,这跟我的理想有点差距。
  2. 每次都要重复上传所有文件——习惯了 git 只上传 delta 的速度,体验了几次 Surge.sh 整个目录重新上传的速度,然后就不想再用了。我的理想还是执行 git push 就能完成发布。(git 利用 rsync 实现 delta 上传,很早就有人提议 Surge.sh 用 rsync 但至今尚未实现。)

Surge.sh 有意思的地方是,所有的配置都在你上传的文件里面了。需要指定域名?写一个 CNAME 文件。需要配置重定向?写一个 ROUTE 文件。

Netlify

Netlify 给我的第一印象就是 Heroku 和 Surge.sh 中间的平衡点。它的服务性质跟 Surge.sh 一样,为静态网站提供 host,而不像 Heroku 那样可以用来 host 动态网站。然而它的管理界面更像是 Heroku,配置能够在 web 上面完成,既能拖放上传整个目录,也能关联 GitHub 在 git push 时触发部署。

git push 部署

在设置好 GitHub 关联后,我尝试用 Netlify 自动部署我现有的代码。Heroku 有 buildpack 的概念,可以在 buildpack 中执行很复杂的事情,例如说指定依赖于 Node 平台上的 Harp 然后执行 harp compile。因为 Netlify 没有 buildpack 的概念,只有一个简单的「build command」文本框,我一开始还以为它做不了很复杂的事情。在研究了一番文档和别人的开源项目后,我发现只需要在「build command」填入 npm install -g harp && harp compile 就可以了。因为 Netlify 的构建系统内置了常见的脚本语言平台,所以不需要 buildpack 这样复杂的概念,直接就有现成的 npm 可以调用。接着「publish directory」填入 www,输出的静态文件目录就会成为服务的根目录。

在 Netilfy 上成功部署一次 GitHub 代码后,接下来的就很简单了。它跟 Heroku 一样,每次我本地修改完源代码只需要 git push 就会触发它部署。因为它的部署过程比 Heroku 的 buildpack 简单,所以部署速度非常快。如果我留意到部署没有成功,我可以打开 Netlify 看日志,看看什么导致部署失败了。很多时候是我写的 Harp 页面有问题,导致 harp compile 执行出错,后来我养成了 git commit 前先调用 harp compile 验证的习惯,也就是没问题了。

HTTPS 证书

Netlify 可以配置自定义域名,而且免费支持 HTTPS。(当然也是调用 LetsEncrypt 颁发证书。)可能因为我在 Netlify 添加域名时还在用 Cloudflare,没有正规地把域名指向 Netlify 的地址,只是把流量倒过去了,所以导致证书申请卡住了。后来我把 DNS 指向 Netlify 地址了,然后找他们客服帮忙,很快就搞掂了证书的问题。

这时候我发现 Cloudflare 已经没有存在的意义了,就考虑把 Cloudflare 撤销掉,但在撤销之前还是做了几个比较。第一个比较是证书本身,Cloudflare 免费版让多个网站共用一张 LetsEncrypt 证书,然后通过 SNI 支持多域名。Netlify 专门为我的 catchen.me 申请一张独立的 LetsEncrypte 证书,然后用 SNI 在这张证书上添加子域名(其实只有 www.catchen.me 一个子域名)。尽管大多数用户并不会去留意证书的细节,但我自己一张证书这感觉就很爽。

CSS/JS 打包

在上一篇文章里我说到,我把 Bootstrap 和 jQuery 的 CSS/JS 文件都放到了我自己的部署目录当中去,这样浏览器就不需要连接不同的域名和 IP 来下载这些文件。Netlify 提供一组优化选项,帮我打包和压缩这些文件。因为 harp compile 并不对 CSS/JS 进行打包和压缩,所以我就选择了让 Netlify 进行优化。

这是 Netlify 比 Heroku 和 Surge.sh 要好的地方,照顾到了 Harp 这种不自带 Webpack 且不进行优化的构建流程。使用 Cloudflare 的话,它可以帮忙进行压缩,但没有打包的选项。同样是免费的 HTTPS,这方面 Netlify 比 Cloudflare 有优势。

CDN

尽管这些 CSS/JS 文件在部署时都存在于我网站的 /css//js/ 目录当中,但一旦启用 Netlify 打包功能这些文件就不会从我的域名进行加载,打包后的版本只存在于 Cloudfront 上面,也就是 AWS 的 CDN。我尝试拿它跟 Cloudflare 的 CDN 进行对比,发现 AWS 的节点数量略微比 Cloudflare 的多一些但也没有绝对的优势。

基于上述对比,我最终选择了使用 Netlify 来发布我新版的个人网站,并且不使用 Cloudflare 来做优化,全盘依赖于 Netlify 自身的优化。这一步做完之后,新版网站就重新上线了。接下来还有很多细节可以优化,如果你想要了解更多的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年9月3日星期一

把我的个人网站推倒重来(Part 2 - 用 Bootstrap 做移动网页)

配置好 Harp 做静态网站构建后,就可以开始做网页了。上一个版本的个人网站样式是我自己设计的,当年用的还是 Macromedia/Adobe Fireworks,做出来一个 PNG 文件然后导出为不同的小图片。这次我也有考虑过要不要自己重新设计一个新的样式,但考虑到新设计不如解决其他几大问题重要,于是决定推迟样式设计。现在的计划是,先用 Bootstrap 解决绝大部分的问题,将来有时间重新设计样式了再做成 Bootstrap 主题。

导航栏

我的个人网站对组件的需求很简单:

  1. 要有一个导航栏,显示「首页」、「简历」、「项目」、「文章」。过去后面两项是「作品」和「更多」,我觉得改成新的名字会更贴近我现在能展示的内容。
  2. 要有一个语言切换菜单,能在英文和中文之间切换。过去我还区分繁体中文和简体中文,现在决定框架上要做到支持任意多的语言,但首要目标是支持英文和中文。

这两项需求对 Bootstrap 来说不是什么难事,直接扔一个 navbar 上去就解决问题了。首先把 4 大页面放在 navbar 上,然后再加一个选择语言的下拉菜单显示 2 种语言。因为 Bootstrap 内置了移动网页的适配,navbar 移动浏览器上会自动变成一个可缩放的菜单,完全不需要我做任何优化。

内容分栏

不同的页面有不同的分栏需求,首页左侧只显示大头照,简历页的每一项左侧时间右侧详情。Bootstrap 的 grid system 能够很好地解决我的问题。为了进一步优化不同尺寸屏幕上的体验,我又针对不同屏幕尺寸做了不同的布局,借助 Bootstrap 的 responsive breakpoint 这超级容易实现。

首先,分栏仅对宽度至少有 768px 的屏幕生效,这对应 Bootstrap 中 md (medium) 的定义。然后所有分栏的 CSS 类都会加上 md 限定条件,例如 col-md-4col-md-8。尺寸小于 md 定义的屏幕,统统只会看到一栏布局,首页就会把我的头像放上面,简历页也会把时间放在详情上面。

首页里面的「联系方式」用到了分栏内嵌分栏,这个分栏无论屏幕大小都会存在。我想要尽力保证美观的前提下不产生换行,然而每一栏都很窄,为此我要针对多种不同的屏幕尺寸按不同的比例来分栏了:col-lg-3 col-sm-4 col-5。这一行代码的意思是:这一栏在大屏幕时宽度为 3/12、中屏幕时为 4/12、默认(小屏幕)时为 5/12。(Bootstrap grid system 把屏幕分成 12 栏,所以分母永远是 12。)

字体大小

Bootstrap 默认的 h1h6 字体都挺大的。因为我用 h2h3 做小节标题,但又不希望出现巨型的文字,所以选择了把 h2h3 的尺寸调小。在 md 或以上屏幕上,因为存在分栏布局,内容条例已经很清晰,h2h3 大小可以很接近正文大小。在更小的屏幕上,我还是需要依赖它们来划分小节的,大小跟正文太过接近反而会把人弄晕。为此我特意写了个很小的 CSS 文件来调整字体大小:

h2 {
  font-size: 2em;
}
h3 {
  font-size: 1.5em;
}

@media (min-width: 768px) {
  h2 {
    font-size: 1.5em;
  }
  h3 {
    font-size: 1em;
  }
}

理想的做法是写 Sass 而非 CSS,然后用 @include media-breakpoint-up(md) 而非 @media (min-width: 768px),这样可以保证我的代码跟 Bootstrap 的代码同步。Harp 是支持编译 Sass 文件的,但它不支持 Bootstrap 所依赖的 Autoprefixer,不做这一步就意味着 -webkit- 这类 prefix 不会被自动加上,影响浏览器兼容性。我现在的选择是,用编译好的 Bootstrap CSS,然后我自己额外写的也是 CSS,只要我不手动更新 Bootstrap 版本就没有不同步的风险。

CSS/JS 文件来源

Bootstrap 所依赖于的 CSS/JS 文件可以不下载直接使用,这是 Bootstrap 官网提供的 CDN 地址

https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css
https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js
https://code.jquery.com/jquery-3.3.1.slim.min.js
https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js

观察一下这有什么问题?它们分别指向 3 个不同的域名啊,如果解释出来的 IP 不一样这可是 3 个独立的 HTTPS 连接啊,多浪费时间!我用 dig 命令查了一下,发现这 3 个域名确实指向不同的 IP,尽管非 Cloudflare 的那两个都指向了 hwcdn.net,但是是不同 IP。(我还特意查了一下这 hwcdn.net 是哪家 CDN 的,原来是一家叫做 Highwinds 的公司,现在已经被整合到 StackPath。这两个名字我之前都没听说过。)

为了解决这个问题,我把 Bootstrap 4.1.3 和 jQuery 3.3.1 下载下来了,然后放到我的本地目录中来使用。这样子所有 CSS/JS 文件都能够在同一个域名中获取,无需建立多个 HTTPS 连接浪费多重 RTT。

CSS/JS 文件体积

使用 Bootstrap 的话,默认需要下载的 CSS/JS 体积太大了,有些功能完全用不到但也需要下载。

最容易去掉的是 Popper.js,它存在于一个独立的 JS 文件中,直接不引用就行。根据 Bootstrap 的官方文档,只要用了 dropdown 就需要使用 Popper.js。但仔细看 Boostrap 源代码就会发现,在 navbar 中使用 dropdown 是不需要依赖于 Popper.js 的。(可能因为 navbar 的 dropdown 永远只能朝下展开,没有任何选项可言。)干掉 Popper.js 能省 6.9kb (Brotli 压缩后体积)。

最应该去掉的是 Bootstrap CSS 内众多我没用到的模块,但这又回到了我们之前的那个问题——如果我让 Harp 来编译 Bootstrap,我就失去了 Autoprefixer。我需要解决对 Autoprefixer 的依赖,然后才能让 Harp 编译 Bootstrap,接着我就可以把我没有用到的 Bootstrap 模块全部干掉了。这项优化留待我将来做吧。

折腾完 Bootstrap,我的网站模板也就没问题了,内容可以慢慢填充。接下来我需要解决静态网站发布的问题了,因为网站做好了总需要找个地方放啊。如果你想要继续跟着我一起折腾的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年8月31日星期五

把我的个人网站推倒重来(Part 1 - 用 Harp 做模板引擎)

根据模板和数据生成静态网站的框架有很多,例如说 HarpJekyllHugo 等等。我对比了一下,最终选择了用 Harp,因为它是用 JavaScript 写的,如果我真的需要做什么改动我可以轻易地去改它的源代码。用 Harp 的坏处也很明显,这个项目在 GitHub 的源代码上已经很久没更新,搞不好将来不再有人维护。

安装 Harp 和用 Harp 编译生成静态页面很容易。因为 Harp 是「convention over configuration」的框架,所以每个页面在使用数据时会优先在里自己目录里的 _data.json 文件,然后看项目根目录里的 harp.json。然而有一个问题是 Harp 没解决的:本地化(多国语言文字)。

本地化

我用每种语言一个目录的方式解决本地化问题:

public/
  ├ _data.json
  ├ index.jade
  ├ _partials/
  │   ├ index.jade
  │   └ resume.jade
  ├ en/
  │   ├ _data.json
  │   ├ index.jade
  │   └ resume.jade
  └ zh/
      ├ _data.json
      ├ index.jade
      └ resume.jade

无论是哪个语言的 index.jade,调用的代码都是 public/_partials/index.jade,只不过把自己当前所拥有的 _data 传过去。无论是哪个语言的哪个文件,都调用 public/_partials/ 下的同名文件,因此所有这些特定语言特定文件都只有一行相同的代码

至于背后实际干活的那个 public/_partials/ 下的文件,它需要同时看 public/_data.jsonpublic/{locale}/_data.json 来进行渲染:前者为它提供语言无关的数据,后者为它提供语言相关的数据。

一开始的时候我严格执行 public/_data.json 只放语言无关数据,后来我发现这样编辑起来很麻烦,因为一个模板往往同时涉及两种数据,我需要修改两个 _data.json 文件。于是我把英文数据和语言无关数据都放到了 public/_data.json 里面,写文档时专注于写英文版。英文版完成后再翻译为其他语言,这时候 public/{locale}/_data.json 里的翻译就会覆盖 public/_data.json 中的同名数据,语言无关的数据自然不会被覆盖。

为了实现「覆盖」这一项功能,我还专门实现了一个 deepCopy 函数用来深复制 JSON 数据。我希望 Harp 能够内置这个功能:_data.json 从文件所在目录开始层层往上覆盖,一直覆盖到 harp.json 为止。在 Harp 支持这个功能之前,我只能自己先实现一个版本来方便我做本地化。

调试技巧

有时候我们实在想看一下 Harp 可访问的数据结构。我们知道 public 对象是任何页面都能访问的,public/ 目录下每一个子目录都会产生一个同名子对象,每一个文件的文件名会以字符串形式出现在对应子目录对象的 _content 中,每一个 _data.json 文件的内容都会展开在对应子目录对象的 _data 中。说这么多,还不如直接把整个 public 打印出来看看!

为此我专门做了一个 debug.jade 的 partial,需要打印 Harp 运行时变量的话就调用一下 != partial('debug', { data: public }),变量立即从 JavaScript 中打印出来。(其中 public 可以被替换为任何我想要查看的变量。)

关于 Harp 的部分到此结束,虽然我还没有把所有改重写的页面都写完,但我觉得 Harp 应用上的问题都已经解决完了。在下一篇文章里,我开始要解决页面布局问题了,如果你喜欢跟着我一起折腾的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年8月24日星期五

把我的个人网站推倒重来(Part 0 - 历史背景)

我的个人网站最初是架设在 catchen.biz 上面的,当时还在大学里,只是想做个网站存放简历和作品,方便找工作。那个时候我还自己设计了这个网站的模板,然后用上了时下最流行的 XHTML + CSS + JavaScript 来实现这个模板。每个页面顶部都有一行在 HTML5 时代早已不需要的声明:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

这么多年过去了,我只更新过上面的简历信息,模板没有重新设计过,实现也没有更新。这造成了很多问题,也给我这次的推倒重来留下了很多可以做的事情:

  1. 不支持 responsive design,在移动浏览器上难以使用。Google 直接发邮件来通知说,不为移动浏览器优化的页面会在搜索结果里面降权。如果支持 AMP 的话,可能能在 Google 搜索结果和移动浏览器中获得更好的体验。
  2. 不支持 HTTPS。当年搞个 HTTPS 证书很麻烦,而且 SNI 尚未流行所以还需要跟服务运营商要求独立 IP,现在这一切都变得很简单。
  3. 缺乏页面模板,每个页面的源代码都需要手工维护。现在静态网站引擎那么多,这个问题可以轻易解决掉。
  4. FTP 上传麻烦,必须覆盖所有文件。这样的部署方式太不方便,我还是需要为此而保留一个 FTP 客户端。因为不知道哪些文件更新了,所以每次都必须要上传并覆盖所有文件。

为了解决这些问题,我选择了把整个网站推倒重来。在这个过程中,我顺便把网站迁移到 catchen.me。这样子我就可以在 catchen.me 上慢慢折腾我的半成品,同时保持 catchen.biz 正常运作。完工后,我只需要在 catchen.biz 上用 301 转跳指向 catchen.me 即可。

在下一篇文章里,我就要开始讲述我如何折腾我的个人网站了,如果你喜欢跟着我一起折腾的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年8月21日星期二

《The Dictator's Handbook》摘要

这次试试新玩法:在读数过程中把想到的直接发往 Twitter,然后再把所有的 tweets 加到一个 moment 里面,最后把 moment 嵌入到博客当中。这次读的书是《The Dictator’s Handbook: Why Bad Behavior is Almost Always Good Politics》,摘要请看下面嵌入的 tweets。

P.S. 嵌入 tweets 的坏处是对 SEO 不利,因为所有 tweet 的内容都没有出现在页面的 HTML 里面。此外这样做也可能影响 Feed 和邮件阅读的体验,不过我总要试一次才知道体验如何嘛。

2018年7月21日星期六

Sonos One:满足但折中你的每一个愿望

Sonos One

我家里在饭厅和厨房之间放了一个老一代的 Echo,然后在我床头柜上放了一个 Echo Show。因为老一代的 Echo 感觉「听力下降」,整天喊它开电视关灯之类的都听不到,所以就想把它替换掉。正好前几天 Amazon Prime Day,Sonos One 打折到 $150 ,买两个都还是比 Apple HomePod 便宜,所以就赶紧买了两个。我的计划是,把 Sonos One 放到睡房里替换掉 Echo Show,这样我在睡房里播音乐打游戏就有 AirPlay 2 立体声了;然后用 Echo Show 把厨房的 Echo 替换掉解决它听力不足的问题。

第一印象

两个 Sonos One 到了之后,从包装上来看就感觉比 Echo 高级很多。Amazon 硬件包装简单优雅,Sonos 则要豪华和结实。Sonos One 包在一个布袋里面,然后放在厚厚的缓冲纸托盘上,底下还专门有一层纸托盘放电源线和网线。Sonos One 的 Wi-Fi 设置流程比我见过的其它硬件都要简单,因为它调用了 iOS 的外置硬件 Wi-Fi 设置流程,iOS 会自动帮你把 Sonos One 连上家里 Wi-Fi,完全不需要手动输入密码。在设置好两个 Sonos One 后,Sonos 会问你是不是把它们设置为一对立体声音箱,然后用 Trueplay Tuning 来校准它们。

Sonos 跟 Echo 对比

在把 Sonos One 设置好之后就可以把它们加入到 Alexa 设备当中了。Sonos One 说了自己不支持所有 Echo 支持的功能,是时候看看到底什么功能不支持了。

我之前设置了床头柜上的 Echo Show 每天早上播报新闻的,但在换成 Sonos One 之后发现 Alexa Routine 并不能在非 Echo 上执行。Alexa Multi-Room Music 不支持 Sonos One,所以不能让 Echo 和 Sonos 一起播放音乐。由此看来,Sonos One 只能对 Alexa 作出主动请求,而 Echo 能够被动地在 Alexa 的远程操纵下做事情。此外,Sonos One 对于 Alexa 唤醒的响应也没有 Echo 那么自然,喊完「Alexa」后它会响一下表示听到了,这时候我已经在说后面的指令了,这一下响声反而会把我打断。

关于 Echo,还有一件很奇怪的问题:我把老的 Echo 断电后,发现就再也不能通过 Echo 控制家里的 Hue 灯泡了。然而只要我把原来的 Echo 接上去,Hue 就又能用了。(不仅仅那个 Echo 能用,Echo Show 和 Sonos One 都能控制 Hue。)这让我怀疑那个 Echo 是否充当了什么神奇的角色,暂时的解决方案是让它继续保持在线。

Sonos 跟 HomeKit 连接

在配置好 Sonos One 之后,它会提醒你配置 Alexa,但不会提醒你配置 HomeKit。我一开始看到 AirPlay 2 能用了,还忘记要配置 HomeKit 了,想起来之后还查了一下才知道如何配置。(其实很简单,打开 Home 添加一下就可以了。)配置好 HomeKit 之后可以用 Siri 叫 HomeKit 操作 Sonos One,但因为我没有 Apple Music 也不怎么用 Siri,所以对我来说其实没用。

有一件很神奇的事情,那就是我在家里就算连接的不是这些智能硬件所在的 Wi-Fi 网络,我也能在 AirPlay 看到我的 Apple TV,然而却看不到 Sonos One。理论上最新版的 AirPlay 不要求设备都在同一个 Wi-Fi 网络,因为它们可以私下建立一个点对点网络,至少 Apple 的设备是这样做的。显然 Sonos 不支持这一项功能,不知道是硬件上的限制还是软件没有实现。这应该是一个 HomePod 能做好但 Sonos One 没做好的事情。(我没有 HomePod 所以不能确认,但我觉得 Apple TV 能做的事情 HomePod 应该也能做。)

总结

Sonos One 尝试把 Echo 和 HomePod 能做的事情给做了,确实也做到了一部分,但又明显有做得不完美的地方。这就是我标题所说的,满足你的每一个愿望,但每一个都要折中。

如果你喜欢类似的硬件测评文章的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年7月1日星期日

Heroku + Node.js + HTTPS

昨天把 biz-to-me 升级到支持 HTTPS 了,为此研究了一下如何让 Heroku 上跑的 Node.js 应用支持 HTTPS。我发现并没有任何文章描述这个具体的流程,只有零碎的信息,所以在此记录一下。

首先,Heroku 应用要支持 HTTPS 必须要是付费的等级,最便宜的是每月 $7 的 Hobby 级别。把应用升级到 Hobby 级别后,我们在应用设置里面添加的域名就能自动获得 SSL 证书,这个过程是全自动的,无需手动操作。(全自动不意味着实时,每次添加新域名都需要等一段时间才会看到「ACM Status」这一栏变成「Ok」的状态。背后实际使用的证书签发机构其实是 Let’s Encrypt。)

Banners_and_Alerts_and_biz-to-me_·_Settings___Heroku

一般 Heroku 应用的「DNS Target」是 app-name.herokuapp.com,需要留意的是一旦升级到支持 SSL 后这一栏的内容会改变,变成 app.example.com.herokudns.com,也就是在 herokudns.com 之前加上应用的整个域名。如果之前 DNS 的 CNAME 指向还是 herokuapp.com,那必须记得更新为 herokudns.com,否则 herokuapp.com 不会使用正确的 SSL 证书提供服务。(herokuapp.com 永远只会用 *.herokuapp.com 的证书提供服务,而 herokudns.com 会根据前缀选择正确的证书。)

在大多数情况下,Heroku 应用升级到支持 HTTPS 后应用代码不需要更新,这是因为 SSL 连接在 Heroku 的负载均衡器那里就种终止了,负载均衡器用明文 HTTP 跟应用连接所以应用本身不需要具备处理 HTTPS 的能力。(Node.js 有 https 模块,但调用时需要访问证书私钥,所以还是用 http 模块比较方便。)如果需要在 Node.js 中判断上游请求是不是 HTTPS,可以通过读取 X-Forwarded-Proto header 来实现,这个 header 的取值可以是 "http""https",例如说在 biz-to-me 中我就通过这一行代码来进行判断。

最后简述一下如何验证配置是否成功。最简单的方法自然是测试一下 HTTPS 服务是否正常工作。biz-to-me 的服务很简单,如果我打开一个 http(s)://*.catchen.biz/* 的 URL,它返回 301 把我重定向到 http(s)://*.catchen.me/*。这篇文章的 URL 是 https://chinese.catchen.me/2018/07/heroku-nodejs-https.html,如果我 curl -i 对应的 catchen.biz URL 能得到正确的 301 重定向那就是成功了。

$ curl -i https://chinese.catchen.biz/2018/07/heroku-nodejs-https.html
HTTP/1.1 301 Moved Permanently
Server: Cowboy
Connection: keep-alive
Location: https://chinese.catchen.me/2018/07/heroku-nodejs-https.html
Date: Sun, 01 Jul 2018 21:29:36 GMT
Transfer-Encoding: chunked
Via: 1.1 vegur

Permanently moved to <a href="https://chinese.catchen.me/2018/07/heroku-nodejs-https.html">https://chinese.catchen.me/2018/07/heroku-nodejs-https.html</a>.

留意 Location header 的值是 HTTPS 开头的而非 HTTP,因为在 Node.js 代码更新到支持 HTTPS 之前我们也能得到类似的 301 响应,只不过 Location 永远都只是 HTTP 的。

如果 curl -i 因为证书不正确而出错的话,可以用 curl --insecure 来忽略证书验证 Node.js 代码。然后再用 curl -v 来查看证书,看看到底为什么证书错误。curl -v 的结果关键看这一段:

* Server certificate:
*  subject: CN=cantonese.catchen.biz
*  start date: Jul  1 04:44:37 2018 GMT
*  expire date: Sep 29 04:44:37 2018 GMT
*  subjectAltName: host "chinese.catchen.biz" matched cert's "chinese.catchen.biz"
*  issuer: C=US; O=Let's Encrypt; CN=Let's Encrypt Authority X3
*  SSL certificate verify ok.

如果 Heroku 应用只添加了一个域名,subject 那一行的域名必须是这个唯一域名。如果看到 subject 那一行显示的是 *.herokuapp.com,那意味着我们需要把域名 CNAME 改为 herokudns.com,或者是域名变更还没生效。(变更还没在本地 DNS 生效的话,可以用 curl --resolve 在本地强行覆盖。)如果 Heroku 应用有多个域名,subject 会是其中一个,但 subjectAltName 会有多个域名,至少应该找到有一个对的。

最后,如果你喜欢我的文章的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年6月15日星期五

iOS Workflow 分享 - Create QR Code

Workflow: Create QR Code

上次我分享了一个 Scan QR Code 的 Workflow,这次我分享一个正好相反的。如果我要分享一个 URL(或者是一段非常短的文本)给别人,我就可以用这个 Workflow 来生成 QR Code 图片然后发送给别人。

如果你还没有安装 Workflow,你可以先去免费下载安装上。然后打开 Create QR Code 并点击「GET WORKFLOW」,这个 Workflow 就会被自动导入到你的 Workflow 中去,之后你在分享菜单中就可以调用 Workflow 生成 QR Code 图片了。

原理

这是一个超级简单的 Workflow,我们只需要调用「Generate QR Code」这个 action 就能把字符串转为 QR Code 图片。我在分享 QR Code 图片前还加了一个「Quick Look」的 action,让我检查一下这个 QR Code。接着我们就可以把图片分享出去了。

Workflow: Create QR Code

我觉得这个 Workflow 虽然做了它该做的事情,但其实还有很大的优化空间。通常我们需要的不是简单地分享一个 QR Code 图片,而是把 QR Code 嵌入到一张更复杂的图片里去。Workflow 有一定的图片处理功能,需要的话肯定是可以把 QR Code 嵌入到我们挑选的另外一张(或一组)图片里去。

订阅

我之后还会分享更多 Workflow,不想错过的话欢迎通过邮件RSS/Atom 订阅我的博客。

2018年6月11日星期一

猜想:为什么 QR Code 在中日韩如此流行

为什么 QR Code 在中日韩的流行程度比在欧美地区高得多?我觉得要理解这个问题必须先理解 QR Code 本身解决的是什么问题。

难题 1:输入法

我觉得 QR Code 解决的第一个大问题源自中日韩对输入法的依赖。假设你刚刚认识了新的朋友,要加对方微信或 Facebook,这时候你如何找到对方呢?你可以问「你叫什么啊」,然后搜索对应的名字或 ID。(有些平台的用户更多选择使用真名,而另外一些则主要使用 ID,不同人的选择也不一样。)如果是中文名的话,你很可能就需要问对方具体是哪几个字,因为同音字太常见了,接着你需要正确地使用输入法输入这几个字,然后才能找到你要加的人。用英文的话,常见名字如果不是变体的话美国人一般都能拼出来,不需要问怎么拼。英文键盘直接输入就能进行搜索,不需要使用输入法。

中日韩输入成本之高,使得使用 QR Code 有明显优势。打开摄像头然后扫描对方屏幕其实并不快,如果好像英文那样听完自然能拼写正确,QR Code 并没有优势。但是需要区分同音字和使用输入法这两件事情使得 QR Code 的使用成本比输入中日韩要低。

当然,所有这些输入方式都在不停地得到改进,所以这并不一定是永恒不变的。中文输入法变得越来越智能,整词整句识别和模糊拼音提高了输入效率。中文搜索也在变得越来越智能,即使有个别错别字也能找到正确的结果。类似的改进在英文中同样出现了,而且不会比中文差。QR Code 扫描的性能也在得到提升,摄像头启动的速度和识别 QR Code 的速度都在变得越来越快。最终是否会出现一种输入方式能够超过 QR Code,这很难说。(我觉得语音有可能成为超越 QR Code 的输入方式。假设我对着你的手机说出我的名字就能让你搜索到我,这个过程比扫描 QR Code 还要快。)

难题 2:连接实体物品

QR Code 解决的第二大问题是如何连接那些没有具体名字的实体物品。一台打印机,甚至是一瓶水,在系统上都可以分配一个唯一 ID 给它,但让人输入这个唯一 ID 就不那么方便了。

人们想过用不同的方法来解决这个问题:

  • 蓝牙:这需要双方都有蓝牙芯片,要求每一瓶水上都有蓝牙芯片是不可能的,因为成本太高了。而且蓝牙的搜索范围太广,很容易搜索到周边的物品。
  • NFC:这解决了蓝牙搜索方位太广的问题,成本也稍微低一些,而且其中一方不需要主动供电,等待扫描就可以了。这其实能够解决一部分问题,但因为 Apple 一直拒绝在 iOS 设备上加入 NFC API 而难以得到推广。
  • QR Code:这是成本最低的方案,任何人都可以拿一张纸打印个 QR Code,或者在设备屏幕上显示 QR Code。因为现在的设备都用摄像头,并且提供 API 允许任何人调用,不会出现好像 Apple 拒绝开放 NFC API 这样的障碍。

我们可以看到 QR Code 虽然是制作成本最低的方案,但不是使用起来最便捷的。NFC 的便利性可以比 QR Code 更好,但是因为 iOS 上的限制导致无法进一步普及,大家被迫使用 QR Code。如果有一天 Apple 开放 NFC API,NFC 有可能能够迅速普及开来。

跟输入法一样,解决这个问题的技术手段也在不停地进步。如果有一天设备的视觉识别能力足够好,QR Code 就不需要存在了,应用直接识别摄像头看到的实体物品就可以了。就算退一步来看,如果存在一种人手写写画画就能实现的 QR Code,例如说用我的笔迹写我的名字就能识别到是我,那也有可能能取代 QR Code。

总结

QR Code 解决的是标识输入(identification input)的问题。当我在应用内需要正确输入一个人(添加好友)、一家商店(支付)或一张优惠券(打折)的标识时,QR Code 是其中一种解决方案。其他替代解决方案的成本都更高,在中日韩尤其高,所以 QR Code 在中日韩迅速流行起来。

这意味着什么?

  1. 如果出现新的技术,能够使得标识输入的成本进一步降低,明显低于现在 QR Code 的成本,新技术可以逐步取代 QR Code。(Apple 开放 NFC API 也属于这种情况。)
  2. 如果新技术一直不出现,QR Code 是会逐渐在欧美和其他地区流行起来的,因为 QR Code 在某些应用场景还是比其他方案有优势,只是没有在中日韩那么明显而已。

最后,如果你喜欢我的文章的话,欢迎通过邮件RSS/Atom 订阅我的博客。

2018年6月10日星期日

iOS Workflow 分享 - Scan QR Code

Workflow: Scan Code

很多时候我们无意识地用微信扫描一个 QR Code,然后无论打开的是什么我们用就是了。我经常会好奇到底 QR Code 编码的是什么信息,到底是一个 ID 呢,还是一个 URL(可能是 deeplink)呢,还是一个 JSON 呢。所以我做了这个简单的 Workflow,先把 QR Code 的纯文本内容显示出来,然后再让我选择使用哪个 app 来接收这个字符串。如果这个字符串是 URL,Workflow 会提示我用浏览器打开,我也可以用 Opener 来打开 deeplink。

如果你还没有安装 Workflow,你可以先去免费下载安装上。然后打开 Scan QR Code 并点击「GET WORKFLOW」,这个 Workflow 就会被自动导入到你的 Workflow 中去,然后你就可以调用它来扫描 QR Code 了。

此外顺便推荐一下上面提到那个叫做 Opener 的 app。它做的事情很简单,你给它一个网站的 URL,如果那个网站有对应的 app 的话,它会尝试通过 deeplink 在 app 里面打开这个 URL 对应的内容。举个例子,我在 Mobile Safari 上打开了一个知乎的问题,然后我可以把这个网页的 URL 发送给 Opener 让它帮我打开知乎 app 且在知乎 app 内打开同一个问题。

原理

这个 Workflow 最重要的 action 是第一步,利用「Scan QR/Bar Code」把 QR Code 扫描出来。这一个 action 会返回 QR Code 本后编码的字符串,然后我们把它存到 Content 变量中去。

Workflow: Scan Code

接下来「Get URLs from Input」这个 action 可以把 Content 中看似 URL 的内容提取出来,因此如果 Content 是 URL 我们就会得到 URL 否则就没有内容。为了验证 URL 提取成功,检查提出出来的内容包含 ://,如果有的话那就一定是 URL 了,否则就是非 URL 的文本。

对于 URL,我提供 5 个操作的选项;对于非 URL,只有其中的 2 个。然后下面是根据用户作出的选择,执行 5 个操作之一:

  1. 打开(仅限 URL):使用 iOS 系统方式打开这个 URL。如果这是个 deeplink 或者 Universal Link,那就会在对应的 app 中打开。
  2. 在 Chrome 中打开(仅限 URL):因为我用 Chrome for iOS 而非 Mobile Safari,所以我会对普通网页使用这个选项。
  3. 在 Opener 中打开(仅限 URL):如果我想尝试用这个网页对应的 app 打开的话。我在 Opener 中设置了如果找不到对应的 app 就默认提示我用 Chrome 打开。
  4. 复制:复制字符串。
  5. 分享:调用系统的分享功能。

订阅

我之后还会分享更多 Workflow,不想错过的话欢迎通过邮件RSS/Atom 订阅我的博客。

邀请链接(referral links)

中场休息,广告时间。以下是一些我的 referral links,如果你使用它们注册对应服务的话,那么你和我都能获得到一些好处。我会为每项服务做简单介绍,是否合适你还是要靠你自己去搜索别人的测评。

Wealthfront

Wealthfront 是一个 robo adviser,跟 Betterment 等服务相似。你把钱放进去,它帮你管理 portfolio,买进股票、债券等 ETF (Exchange-Traded Fund)。具体回报率跟你选择的风险等级相关,风险系数越高股票占 portfolio 的比例越大。

通过 Wealthfront 管理的资产,头 $10k 是免管理费的,之后是 0.25%。也就是说,如果有 $20k 资产在里面,头 $10k 免费然后之后的 $10k 每年收 $25($10k * 0.25%),然后分摊到 12 个月来收,每个月 $2 多。

如果你使用我的邀请链接的话:

  • 你得到的好处:免管理费资产额度增加 $5k,也就是说注册帐号就有 $15k 免管理费资产额度。
  • 我得到的好处:免管理费资产额度增加 $5k。

邀请链接:http://j.mp/catchen-wealthfront-invite

Acorns

如果你无法一次拿出 $500 存进 Wealthfront 进行投资,你可以选择通过 Acorns 慢慢一点一点存钱。Acorns 也是一个 robo adviser,但跟 Wealthfront 不一样的地方在于 Acorns 使用一些小技巧让你把零钱存下来。

在允许 Acorns 连接你的银行后,Acorns 会对你消费的「找零」进行统计,然后用作投资。例如说,你刷卡消费了 $12.45,Acorns 就会把这当作消费 $13 找零 $0.55,从你的银行转 $0.55 到你的投资账户。除此之外,Acorns 还跟一些商家有 cash back 的合作,通过 Acorns 在这些商家消费的话 cash back 也会进入投资账户。

如果你使用我的邀请链接的话:

  • 你得到的好处:获得 $5。
  • 我得到的好处:获得 $5。

邀请链接:https://chen.cat/acorns-invite

Stitch Fix

Stitch Fix 是一个帮你搭配衣服的服务。注册时它会让你填写众多衣服尺码和喜好的信息,多到简直让人想要放弃注册。然后它会根据这些信息帮你挑选 5 件单品并邮寄给你。(在它帮你挑选前,你可以留言说明一下有没有什么你特别想要的。)

在收到包裹后,你可以试穿然后决定 5 件中哪些你想要留下哪些你想要退回去。如果你 5 件都要了,全单 7.5 折;如果你 5 件都不要,你需要给 $20 的最低消费;否则你只需要对你要的那几件买单。我至今只下单过 3 个包裹,头两个我都只要了 1 件,第三个我想要 3 件,但发现全要的话打折之后更便宜,于是全要了。

如果你使用我的邀请链接的话:

  • 你得到的好处:$25,用于首次消费。
  • 我得到的好处:$25,用于下次消费。

邀请链接:http://chen.cat/catchen-stitchfix-invite

Chase Sapphire Reserve

Chase 最高端的旅行及饮食类信用卡:

  • 旅游和餐饮消费 3 倍积分。
  • 每年 $500 的旅游报销(自动 statement credit),可用于 Uber 甚至是 Uber Eats。
  • 免费附送 Priority Pass Select,可以免费使用全球上千个(美国 26 个)机场的贵宾室,持卡人可免费带两名同行旅客。(不能免费带同行旅客的 Priority Pass Prestige 价格为 $400 一年。)
  • 报销 Global Entry 或 TSA Precheck 费用。前者服务包含后者,费用分别是每 5 年 $100/$85。
  • 年费 $450。

如果你使用我的邀请链接的话:

  • 你得到的好处:没有。跟你自己申请开卡一样,头三个月消费满 $4k 就能获取 50k 点的开卡奖励。
  • 我得到的好处:获得 10k 点奖励。

https://chen.cat/chase-sapphire-reserve-invite–2018–06

2018年6月2日星期六

iOS Workflow 分享 - Debug Action

Workflow: Debug Action

有时候我们想要知道别人的 app 在调用 Share Extension 时提供了什么类型的数据以及具体数据是什么,我们可以自己在 Xcode 里面写个 app 去接收别人 app 的数据,但我们也可以用 Workflow 内置的「View Content Graph」来展示数据。相对于自己写个 app 而言,显然是免费的 Workflow 要简单得多。

如果你还没有安装 Workflow,你可以先去免费下载安装上。然后打开 Debug Action 并点击「GET WORKFLOW」,这个 Workflow 就会被自动导入到你的 Workflow 中去,然后你就可以在其它 app 里分享数据给这个 Workflow 然后让它显示给你看了。

演示

我用 Foursquare 的 app 打开三藩 Union Square 这个地标,然后选择分享,接着就能在这个 Workflow 里看到 Foursqare 的分享功能都传递了什么数据:图片、地址、名称和 URL。

原理

这个 Workflow 做的事情超级简单:它只是把输入的数据传给「View Content Graph」这个步骤,让它显示调试信息,接着它会重新调用 Share Extension 让分享流程跑下去。之所以重新调用 Share Extension,是因为有时候我们需要先看看分享的是什么信息,再决定分享到哪个 Share Extension,这时候把这个 Workflow 插入在中间就很方便了。这个 Workflow 能够显示调试信息,之后又回到正常的分享流程。

Workflow: Debug Action

订阅

我之后还会分享更多 Workflow,不想错过的话欢迎通过邮件RSS/Atom 订阅我的博客。

2018年5月14日星期一

飞利浦 Hue 智能照明系统开发(Part 1 - API 入门)

Hue Bridge

我家里有超过 10 个的飞利浦 Hue 智能灯泡,我通常使用 Amazon Echo 和 iOS HomeKit 控制它们,例如说睡觉时对着 Echo 喊「Alexa, turn off bedroom.」,或者在 iPhone 上通过 Control Center 迅速开关一盏灯。在我买了一个 Raspberry Pi 架设加密 DNS 后,我就开始思考是否能在 Raspberry Pi 上跑一个程序控制 Hue 做更复杂的事情。在此之前,我先要自己搞明白 Hue 的 API 是如何工作的以及能做什么,于是我开始阅读 Hue API 文档

Hue API 是很典型的 REST API,我们可以通过 bridge 上面的 HTTP 服务器调用 Hue API 操作这个 bridge 连接的所有灯泡。Hue API 可以操作的对象包括灯泡、房间、传感器、规则等等,不过在我们能够调用这些 API 之前,首先我们必须找到 bridge 的 IP,然后才能连上它的 HTTP 服务器。

寻找 Hue Bridge

大多数人家里的网络都是用 DHCP 动态分配 IP,因此我们不知道 Hue bridge 的 IP 是什么,就算知道了将来还可能变。我们要如何连接 bridge 呢?最简单的方法是发一个请求到这个地址:

https://www.meethue.com/api/nupnp

这是飞利浦自己的服务器,不是我们家里 bridge 的服务器。它会根据我们的公网 IP 来查询我们家是否有 bridge 通过同一个公网 IP 连接着飞利浦的服务器,如果有的话它就会返回这个(或这些)bridge 的信息,包括内网 IP。例如说:

[
  {
    "id": "001788fffe000999",
    "internalipaddress": "10.0.0.9"
  }
]

这个 JSON 里面只有一个 bridge,这也是最常见的状况。(很少人会需要在家里装超过一个 bridge。)这一个 bridge 的 IP 是 10.0.0.9,也就是 internalipaddress 属性的值。那 id 属性的值是什么呢?这是这个 bridge 的序列号,如果这个 bridge 的 IP 将来发生了变化,我们可以通过这个序列号来确认这是同一个 bridge。

在找到 bridge 的 IP 后,我们就可以连接上去了。我这里提到的只是最容易上手的 bridge 寻找办法,更复杂的方法可以看官方的 Hue Bridge Discovery Guide

Hue Bridge 本地授权

能够连接到 bridge 的 IP 并不意味着能够操作 bridge,否则 bridge 就没有任何安全性可言了。任何新的客户端连接 bridge 之前,都需要有人去手动按一下 bridge 上面的物理按钮,然后新的客户端才能获取到授权。

首先,我们要按下 bridge 上的物理按钮,然后发送一个 POST 请求到这个地址:

http://<bridge_ip>/api

这个 POST 请求需要带上一个简单的 JSON:

{
  "devicetype": "<app_name>#<device_name>"
}

这个 JSON 只有一个叫做 devicetype 的属性,用来命名这个新增的客户端,格式为应用名称加上 # 再加上设备名称。(一个应用可以安装在多台设备上,这样的命名格式要求可以帮助区分。)官方文档说这个字符串长度不能超过 40 个字符,其中应用名称不超过 19 个字符,设备名称不超过 20 个字符。实际上当前的 API 版本(1.24.0)只检查字符串长度是否超过 40,不检查应用名称和设备名称是否超过允许长度。

这个请求成功的话,返回的 JSON 会是这样子的:

[
  {
    "success": {
      "username": "83b7780291a6ceffbe0bd049104df"
    }
  }
]

我们获取到了一个叫做 username 的东西,这实际上是个密钥一样的东西,因为只要掌握了它就能操作 bridge。我们需要把这个密钥保存下来,然后我们就可以进行其他任意操作了。

测试请求

拿到 username 后,我们当然要测试一下能不能用。Hue API 传输 username 这个密钥的方式很有趣,我们需要把它放在 URL 里面,例如这样子:

http://<bridge_ip>/api/<username>/config

发一个 GET 请求到这个地址,我们就能获得这个 bridge 的所有配置信息。成功获取信息意味着 username 是有效的密钥,之后我们就能用它来获取其他信息了。以下是一些可以尝试 GET 请求获取的地址:

http://<bridge_ip>/api/<username>/lights
http://<bridge_ip>/api/<username>/groups
http://<bridge_ip>/api/<username>/schedules
http://<bridge_ip>/api/<username>/scenes
http://<bridge_ip>/api/<username>/sensors
http://<bridge_ip>/api/<username>/rules
http://<bridge_ip>/api/<username>/resourcelinks
http://<bridge_ip>/api/<username>/capabilities

总结

拥有上述知识后,我们就可以进一步探索 Hue API 了。为了方便方便我自己,我写了一个叫做 Hue Explorer 的开源项目用于连接我自己的 bridge 并查看上面的信息,如果你想要看源代码的话你可以到 GitHub 上查看。我暂时只做了一部分的 JSON 可视化,例如说灯可以可视化为这样子:

Hue Explorer v.0.2.0

如果你想要用第三方工具发送 REST 请求,可以试一下免费的 Postman。如果你懒得下载,可以直接访问 http://<bridge_ip>/debug/clip.html,这是一个每一个 bridge 都自带的调试工具,可以用来手工编写 REST 请求。

在下一篇文章里,我会开始讲述每一个具体的 Hue API 能做什么,不想错过的话欢迎通过邮件RSS/Atom 订阅我的博客。

2018年4月28日星期六

用 Raspberry Pi 架设加密 DNS 客户端

dig through DNS-over-HTTPS

最近 Cloudflare 宣布使用 1.1.1.1 作为 DNS,并且强调隐私保护。由于 Cloudflare DNS 支持 DNS-over-TLS 和 DNS-over-HTTPS,这使得加密 DNS 成为了热门话题

因为操作系统往往不支持加密 DNS,所以要使用加密 DNS 必须使用一个加密 DNS 的客户端,然后这个客户端同时作为一个明文 DNS 服务器向操作系统提供正常的 DNS 服务。我可以选择在每一台我使用的设备上安装一个加密 DNS 客户端(对于 iOS 来说则是 NetworkExtension),我也可以选择在家里假设一个加密 DNS 客户端然后把路由器 DNS 指向过去,之后家里所有设备的 DNS 都会跟着变。我选择了后者,因为这样做比较方便,也为我提供了一个折腾 Raspberry Pi 的借口——我需要把加密 DNS 客户端部署到 Raspberry Pi 上让它长期为家里的局域网提供 DNS 服务。

(为什么不用 OpenWRT 呢?因为我家里已经在用 Eero 来做路由器了,它可以通过 mesh Wi-Fi 来提供更好的覆盖。如果我要多买一个 OpenWRT 路由放在 Eero 前面,那我还不如买个 Raspberry Pi 来玩玩呢。)

Raspberry Pi

我买了这个 Raspberry Pi 套装,因为它自带盒子和电源。电源不重要,我家已经有很多 USB 电源,但是我总不能一块电路板随便一放吧,所以必须买个盒子。然后我还买了张 64GB 的 microSD。因为我所有 microSD 都是 64GB 的,所以我只买 64GB 的方便有需要时随意替换。

收到 Raspberry Pi 之后,我就按照官方 NOOBS 的指引下载和准备安装。然而 NOOBS 复制到 SD 卡后无论如何 Raspberry Pi 都无法正常启动,只亮红灯没有视频输出。搜索之后发现绿灯不亮就是没有读取 SD 卡进行启动。我开头怀疑是我下载的 NOOBS 有问题,于是换成 NOOBS Lite 和 Raspbian,但都是不行。我也怀疑过是不是下载的 zip 数据有问题,但 sha256 checksum 正确。

实在找不到问题了,我就开始搜索到底 Raspberry Pi 是如何进行引导的,发现它必须从 FAT 分区进行引导。Raspberry Pi 自己的官方文档教大家使用一个叫做 SD Association’s Formatting Tool 的软件来格式化 SD 卡,但这个软件在面对超过 32GB 的卡时就会傻傻地使用 exFAT 来进行格式化。其实使用 Mac 内置的 Disk Utility 不就好咯,就算是超过 32GB 的 SD 卡也可以选择格式化为 FAT。

把 SD 格为 FAT 后,所有问题都解决了。NOOBS 能够正常启动,接着 Raspbian 也能够顺利装上。Raspberry Pi 安装好之后我尝试启用 VNC 以便我用 Mac 远程控制,结果那上面装的 VNC 和 Mac 自带的 Screen Sharing 客户端不兼容,我只好降级到用 SSH,不过也能完成绝大多数操作了。

启用 SSH 后 Raspbian 会提醒你改默认密码,没有改的话记得改掉,否则太不安全了。因为 Raspbian 连 dig 这么基本的命令都没有,需要通过 apt-get 来安装,所以我们需要先更新一下然后把 dig 装上:

sudo apt-get update
sudo apt-get install dnsutils

DNS-over-HTTPS

我基本上就是按照 Cloudflare 的 DNS-over-HTTPS 指引 来做的。一开始我觉得 Raspbian 既然是 Debian 系的就下载了 Debian 的安装包,结果发现安装不上去。接着尝试用 Linuxbrew 来装 homebrew 的版本,结果装上后发现不能执行。看到「exec format error」并且搜索后才突然明白到,Raspberry Pi 不是基于 x86/x64 架构的,而是基于 ARM 架构的。那到底 Raspberry Pi 是 32 位还是 64 位的呢?理论上 Raspberry Pi 3 B+ 是 64 位的 CPU,但在 Raspbian 上执行 uname -a 的话会显示:

Linux raspberrypi 4.9.80-v7+ #1098 SMP Fri Mar 9 19:11:42 GMT 2018 armv7l GNU/Linux

所以其实不是 64 位的,如果要选正确的版本那必须选 32 位的 ARM。只要选择正确的版本,Cloudflared 和 Dnscrypt-Proxy 都是可以用的。我两个都装了,都能在 localhost:53 上跑起来,最后选择了 Dnscrypt-Proxy 是因为配置方便。(Dnscrypt-Proxy 有配置文件模板,改改就可以用了,不需要对着文档写一个新的。)

Dnscrypt-Proxy 的安装跟着官方指引做就可以了,选择 Linux 版本 来下载。记得下载 Linux ARM 的版本,不要用 Android 或者 ARM64 的版本。(尽管 Dnscrypt-Proxy 是可以安装在 Pi-Hole 上面的,但我不想安装 Pi-Hole 来过滤广告所以选择了非 Pi-Hole 的版本。)尽管官方指引叫你检查一下是否有别的 DNS 服务正在使用 53 端口,但新装的 Raspbian 应该是不会有任何服务占用 53 端口的所以这一步可以略过。

Dnscrypt-Proxy 下载和解压好之后就可以开始配置了。假设我们已经在 Dnscrypt-Proxy 解压好的目录里:

cp example-dnscrypt-proxy.toml dnscrypt-proxy.toml
sudo ./dnscrypt-proxy

这时候 Dnscrypt-Proxy 应该能够跑起来,在 Raspberry Pi 上用 dig 验证一下就知道了:

dig +short @127.0.0.1 cloudflare.com AAAA

这个验证必须在 Raspberry Pi 上做,因为 Dnscrypt-Proxy 的默认配置只监听 localhost:53 端口,从另外一台机器连上来 53 端口是不行的。如果 Dnscrypt-Proxy 正常工作了,我们就可以开始改配置了。打开 dnscrypt-proxy.toml,然后把 server_nameslisten_addresses 改掉。(在 SSH 上面,用 nanovi 都可以编辑 dnscrypt-proxy.toml。)

首先找到 server_names,把前面注释这一行的 # 删掉,然后把后面的内容改为你想要的服务。因为 Cloudflare 和 Google 都支持 DNS-over-HTTPS,而且都是可靠的大公司,所以我在这两家之间选。因为 Google 不强调隐私,有可能记录数据,所以我只用 Cloudflare 的,按照 Cloudflare 的文档把这一行改为这样子:

server_names = ['cloudflare', 'cloudflare-ipv6']

接着找到 listen_addresses,你会发现它只监听 IPv4 和 IPv6 的 localhost,所以其他机器不能用 Raspberry Pi 来做 DNS。这时候你要想办法把 Raspberry Pi 的 IP 绑上去。我的做法是这样子的:因为我家里路由器的 IP 是 192.168.0.1,然后 DHCP 范围是 192.168.0.10–192.168.0.199,所以 192.168.0.2–192.168.0.9 是不会被动态分配出去的。我把 Raspberry Pi 的有线网 IP 写死为 192.168.0.2,然后把它加到监听地址端口列表上:

listen_addresses = ['127.0.0.1:53', '[::1]:53', '192.168.0.2:53']

搞掂之后,可以再启动一下 Dnscrypt-Proxy:

sudo ./dnscrypt-proxy

然后从另外一台机器使用 dig 测试一下:

dig +short @192.168.0.2 cloudflare.com AAAA

如果没有问题的话,就可以把 Dnscrypt-Proxy 当装系统服务启动了:

sudo ./dnscrypt-proxy -service install
sudo ./dnscrypt-proxy -service start
sudo systemctl enable dnscrypt-proxy

之后登录到路由器,把路由器的 DNS 改为 192.168.0.2 就可以了,家里所有设备的 DNS 都会经过 Raspberry Pi 上的 Dnscrypt-Proxy 走 DNS-over-HTTPS 连接 Cloudflare 服务器。尽管 Dnscrypt-Proxy 的官方指引还说要把 Linux 上的 DNS 客户端指向 localhost,但因为我暂时不在 Raspberry Pi 上做别的事情所以不在意 Raspberry Pi 本身发出的 DNS 请求是否加密。只要它作为 DNS 服务器服务好我家里的其他设备就行。

已知问题

上述做法是有一些已知问题的。首先,如果我们请求使用 SNI 的 HTTPS 服务的话,我们还是会明文传输域名的,就算 DNS 加密了还是会存在域名泄漏的情况。如果多个不同证书的 HTTPS 域名要在一个 IP 上共处,那必须使用 SNI 否则 SSL 握手时无法决定用哪个证书的密钥。因此 SNI 常见于跑在云平台上的服务,因为云平台往往在多个服务之间共享 IP,但每一个服务来自不同的客户有不同的证书。对于大型网站来说这不常见,因为无论一个大型网站旗下有多少域名,它都可以选择把所有域名放在同一个证书里面。

其次,我没有做 IPv6 的配置,只让 Dnscrypt-Proxy 绑定了一个 IPv4 地址。这时候如果 IPv6 分配了不一样的 DNS,那使用 IPv6 DNS 查询时还是会走明文的。如果你所处的网络完全不使用 IPv6,那是没问题的。我知道 Comcast 是会分配 IPv6 地址和 IPv6 DNS 的,所以如果不在路由器上设置 IPv6 DNS(或者是不能设置)的话,那 IPv6 DNS 就有可能是 Comcast 分配下来的,也就是明文 DNS。(其他 ISP 也一样。)

最后,如果你喜欢我的文章,欢迎通过邮件订阅我的博客。

2018年4月27日星期五

网上吵架原则

在网上跟别人吵架辩论时,我有两条原则:

  1. 不能在三个回合内结束的架不吵。如果一件事情不能在三个回合内说清楚,那就是自己没搞清楚,不要浪费时间去跟别人吵。
  2. 拥有不能公开的信息时不跟别人吵架。如果一件事情自己觉得能够说清楚,但有部份信息不能够公开,那肯定说服不了别人,说了还不如不说,因为说了之后别人会觉得你缺乏证据强词夺理。

2018年4月22日星期日

如何把 Blogger 文章导入到 Facebook Instant Article

Facebook Instant Article in Pages Manager

如果你跟我一样还在用 Blogger 这么远古的工具来写博客,同时又想追赶一下 Facebook Instant Article 的潮流,那你可以跟着我这篇文章做一遍来把 Blogger 的文章导入到 Instant Article。

启用 Instant Article

假设你跟我一样已经有一个在用的 Blogger 博客了,你不需要在 Blogger 上做什么改动,直接开始注册 Instant Article 就可以了。打开 Facebook Instant Article 的入口页面,然后点击注册。Facebook 会问你要为哪个 Page 启用 Instant Article,你选一个就是了。我用 Cat Chen Posts 这个页面来导出我所有的 Blogger 文章,所以我就选择了这个 Page。如果你还没有 Page 的话,可以先创建一个,因为没有 Page 是不能创建 Instant Article 的。但只要在 Page 上把一篇文章转化为 Instant Article 了,之后在个人帐号分享这篇文章的链接也会显示为 Instant Article。

关联域名

接下来你需要跟随 Facebook Instant Article 的配置工具来一步一步完成配置。首先,你要把自己的 BlogSpot 网站关联到你的 Page 下面来。Facebook 会让你把这样一行加到你的 BlogSpot 模板上:

<meta property="fb:pages" content="{page-id}" />

每一个具体 Page 的 {page-id} 都不一样,你复制粘贴 Facebook 配置工具上显示的那一行就可以了。把这一行复制下来后,你可以去 Blogger 里配置 Theme,选择修改 HTML,然后把这一行贴到 <head>...</head> 里面。之后把你 BlogSpot 的域名填到 Facebook 配置工具里去,Facebook 就会抓取你 BlogSpot 的首页并且进行验证。(如果你之前在 Google Webmasters 之类的服务做过类似的域名认证操作,这一步应该很容易。)

导入 RSS

接下来要把 Blogger 的 RSS 导入到 Facebook。如果你的 RSS 没有搞过什么花样,这是非常简单的事情。但如果你像我一样在 RSS 上做过各种优化,那这一步可以很复杂。

FeedBurner

如果你像我一样启用了 FeedBurner,你会发现 FeedBurner 导出的 RSS 可能是 Facebook 不愿意接受的格式,因为 FeedBurner 的优化加了太多东西进去。(但如果不加任何优化的话,Facebook 是可以接受的。)你可以使用 BlogSpot 自带的 feed,但既然你用了 FeedBurner 你很可能跟我一样让 BlogSpot 把自带的 feed 重定向到 FeedBurner,这时候如何才能获取到 BlogSpot 自带的 feed 呢?关键在于 querystring。以我自己的 feed 为例:

http://chinese.catchen.me/feeds/posts/default?alt=rss&redirect=false

加上 ?alt=rss 会强制输出符合 Facebook 期望的 RSS 格式。加上 ?redirect=false 或禁用 FeedBurner 重定向。两者都用上,就能让 Facebook 得到一个它能够解析和接受的 RSS。

合并多个 RSS

我用有多个 Blogger,因此我有多个 RSS 但 Facebook 只接受一个,这怎么办呢?这个问题可以用 RSS Mix 解决。把多个 RSS 的地址输入进去,它会生成一个 RSS,然后把这个合并后的 RSS 给 Facebook 就可以了。

提交审核

搞掂 RSS 后,就可以把 RSS 地址交给 Facebook 了。直接填进去 Production RSS Feed 是没问题的,将来要测试新版本的 RSS 可以用 Development RSS Feed。如果 RSS 里面已经有至少 10 篇文章,那就可以提交审核了,否则还需要写满 10 篇文章才能提交审核。

我把博客提交审核时没有遇到任何问题,但因为审核这个事情因人而异所以很难说你会不会遇到什么问题。如果遇到问题的话可以根据 Facebook 的提示进行修改。审核一旦通过了,你就可以把 Production RSS Feed 里面的内容发布为 Instant Article 了。

发布 Instant Article

尽管 Facebook 从 RSS 中读取了文章,但并不会自动把 Instant Article 发出去。你需要去 Production Articles 里面查看 RSS 导入了的文章,然后把你想要发布的发布出去,不发布的话它们会当作草稿一直存着。

在发布之前,你可能会看到某些文章标题旁边有个感叹号,那意味着 Facebook 在解析这篇文章时遇到了问题,不解决这些问题这篇文章就无法被发布出去。这时候你需要做的事情就是编辑文章 HTML,然后把问题都解决掉。具体哪些问题会出现,要看你在 Blogger 中使用的 HTML 有多复杂。尽管 Facebook 使用的 Instant Article 格式也是 HTML,但其实只是一个 HTML 子集,如果你使用的 HTML 超出了这个子集 Facebook 就会尝试进行调整,如果调整后还是有问题你就会看到那个感叹号。

我最常遇到的问题是图片嵌入在段落内。Facebook Instant Article 规定图片必须放在 <figure>...</figure> 里面,如果你在写作时只是用了 <img />,那 Facebook 就会尝试智能地在外面包一层 <figure>...</figure>。但如果你原本的 <img /> 是嵌套在 <p>...</p> 里面的话,那 Facebook 处理后就会变成了 <p><figure><img /></figure></p>。由于 Instant Article 中的 <p>...</p><figure>...</figure> 是互斥的,只能是平级关系,不能互相嵌套,所以 Facebook 就会报错。(错误信息还很奇怪,Facebook 会告诉你元素内没有文本,但其实意思是 <p>...</p> 内不能嵌套 <figure>...</figure>。)解决的办法很简单,把外面那层 <p>...</p> 去掉就可以了。

除了上述问题外,你还可能遇到其他跟 Instant Article HTML 子集不兼容的问题。Facebook 提供的错误信息不一定容易理解,但自行搜索一下总能找到答案。只要把问题都解决了,文章就能够当作 Instant Article 发布了。

测试 Instant Article 效果

最简单的测试方式是用 Facebook Pages Manager (iOS | Android)。在里面打开自己的 Page,如果看到文章下面有个 Instant Article 的闪电符号那意味着文章成功发布为 Instant Article 了。点击进去就能看到文章以 Instant Article 渲染的样子,如果跟自己想要的样子不一样可以回去继续修改 HTML。这篇文章开头的截图就是来自 Facebook Pages Manager,里面显示的是我之前一篇文章的 Instant Article 版本。

2018年4月15日星期日

移动网页的 iPhone X 适配

Instagram web without iPhone X fix (portrait)

一个月前我在 iPhone X 的 Mobile Safari 中打开 Instagram web,发现页面底下的导航栏跟 iPhone 的 home indicator 重叠在一起不方便使用。我想既然 Apple 为 iPhone X 专门更新的 Human Interface Guidelines 并为 native app 引入了 safe area 和 inset 等概念,那 Mobile Safari 应该有对应的 web 概念吧。搜索了一下,发现 Apple 确实对 Mobile Safari 增加了对应的功能。既然 Instagram 是我们公司的产品,那就动手去改吧。

改造的第一步是对页面加上这一句:

<meta name='viewport' content='initial-scale=1, viewport-fit=cover'>

因为大多数移动页面都已经有类似的声明,所以只要加上 viewport-fit=cover 就行了。不加的话,下面所有的 CSS inset 声明都不会生效。

第二部是把竖屏(portrait mode)时的页面底部导航栏往上挪。这时候我们可以把导航栏到屏幕底部的距离设置为 env(safe-area-inset-bottom),然后浏览器自动会使用正确的数值来进行布局。(在 Safari 显示自己的工具栏时,这个值会神器地变为 0,使得页面底部导航栏紧贴 Safari 工具栏。)假设我们使用 padding-bottom 把导航栏往上挪,那么我们可以写 padding-bottom: env(safe-area-inset-bottom)。(当然 Instagram web 的实际情况比这个复杂,如果你想研究的话可以用 Safari remote debugger + iPhone X Simulator 来看。)这样竖屏的问题就修复了。

Instagram web with iPhone X fix (portrait)

如果这是个 native app 的话,问题可能到此就结束了,因为 native app 可以选择不支持横屏(landscape mode)。然后网页必须支持横屏,因为浏览器本身可以横屏。(当然你也可以很霸道地在浏览器横屏时只显示一句提示让用户把屏幕直过来,这样就可以不支持横屏了。)因为 iPhone X 屏幕顶上的那个缺口(notch),Mobile Safari 在横屏时默认会在页面两侧加白边,确保任何没对 iPhone X 做修改的页面能够正常显示。

Instagram web without iPhone X fix (landscape)

这两侧的白边很不好看,因为会让原本应该贯穿全屏的横线终止在屏幕内。在加上 viewport-fit=cover 后,两侧的白边会消失掉,因为 Mobile Safari 把这看作开发者愿意对 iPhone X 布局负责,之后如何处理横屏一侧缺口就是开发者的责任了。之前对 Instagram web 的竖屏调整一旦放到横屏就会发现新问题。

Instagram web with some iPhone X fix (landscape)

页面顶部标题栏两侧的按钮太靠近屏幕边缘了。因为 iPhone X 屏幕边缘有圆角,所以按钮放在那里并不好按。此外那也在 Apple 定义的 safe area 之外,本来就不应该放可点击元素。为此我们必须使用 env(safe-area-inset-left)env(safe-area-inset-right) 把这两个按钮往页面中间挪。假设我们使用 margin-leftmargin-right 来控制布局的话,我们可以这样写:

.leftButton {
  margin-left: env(safe-area-inset-left);
}
.rightButton {
  margin-right: env(safe-area-inset-right);
}

这样子横屏是修复了,但又会给竖屏引入新的问题。在原本的竖屏设计中,按钮离两侧屏幕边缘 16px。在我们把 16px 替换成 env(safe-area-inset-left)env(safe-area-inset-right) 之后,竖屏时这两个按钮就贴着屏幕边缘了。为此我们要引入 max() 来保证按钮离屏幕边缘至少有 16px

.leftButton {
  margin-left: max(16px, env(safe-area-inset-left));
}
.rightButton {
  margin-right: max(16px, env(safe-area-inset-right));
}

这时候竖屏横屏都没问题了,唯一问题是 Safari 以外的浏览器都被弄晕了,这 maxenv 都是什么呀?我们还没支持呢,而且是否会被标准化也很难说。幸好大多数浏览器都支持 @support,我们可以用它来进行筛选,把专门写给 Safari 看的 CSS 留给 Safari 看。

.leftButton {
  margin-left: 16px;
}
.rightButton {
  margin-right: 16px;
}

@supports (margin: max(16px)) {
  .leftButton {
    margin-left: max(16px, env(safe-area-inset-left));
  }
  .rightButton {
    margin-right: max(16px, env(safe-area-inset-right));
  }
}

到此所有的问题都解决了,Instagram web 也能在横屏中正常显示了。王子和公主从此幸福地生活在一起。

Instagram web with all iPhone X fix (landscape)

故事当然不会到这里就结束了。首先,Instagram web 可不止这一个页面。这些页面的竖屏都不会有问题,但横屏就很难说了,有可能某些元素在使用 viewport-fit=cover 之后被布局到了 safe area 之外,需要把它们挪回来。这些问题我见到一个修一个,但永远也不知道是否有遗留的。当然这个问题在 native app 里面也存在,除非从零开始设计一个新的 app 并在设计原则和布局框架上对 safe area 作出考虑,否则一个 app 无论怎么改都无法证明改全了,而且开发新功能时一不小心没测 iPhone X 就可能出现不兼容的问题。

其次,Mobile Safari 在横屏模式时如果显示地址栏就会导致页面底部导航栏处于半隐藏状态,而非原来的全隐藏状态。

iPhone X Safari Apple bug (landscape)

为什么会发生这样的事情呢?因为在显示地址栏时 Safari 会把整个 viewport 往屏幕下方挪动地址栏的高度。这时候 viewport 高度是不会改变的,因此 viewport 的一部分就跑到屏幕外去了。(但 viewport 的定义不就是屏幕内可见区域么?Apple 你自己发明了这个概念,现在说改就改。)Apple 对此的解释是,显示地址栏的 animation 必须保持 60 FPS,但 viewport 高度变化过程受页面布局速度影响而无法做到 60 FPS,所以这是 feature 不是 bug。(Chrome for iOS 在显示地址栏时会调整 viewport 高度,但因为不是 60 FPS animation 所以会看到页面闪烁。)

我觉得 Apple 要把 viewport 偷偷隐藏掉一部分也不是问题,但在隐藏的时候至少应该把 env(safe-area-inset-bottom) 自动变会 0 吧?这样子底部导航栏至少可以完全隐藏掉。这个问题已经有其他人写过,并且那篇文章的作者已经给 Apple 开 bug。

最后一个问题,为什么 env(safe-area-inset-top) 没有被用到?因为 Mobile Safari 总会在屏幕顶部显示状态栏,所以网页永远都不需要自己想办法避让屏幕顶部的缺口。(那使用 <meta name="apple-mobile-web-app-capable" content="yes"> 强行进入全屏模式呢?iPhone X 会很恶心地在屏幕上方留下一个黑色区域。)估计唯一的例外是你自己写一个 app 并在里面放一个全屏的 WebView,这时候 WebView 内的网页就需要使用 env(safe-area-inset-top) 了。我没有试过做这样的事情,但可以参考别人的文章

总的来说,iPhone X 适配不是一个很难的技术问题,尤其是只做竖屏模式的话。

2018年3月9日星期五

Buy Me a Coffee

最近开了一个 Buy Me a Coffee 的页面,让大家帮我买咖啡。如果你想买一杯咖啡给我,你可以打开 chen.cat/buy-coffee 然后付 $5 给我,这正好够我去 Saint Frank Coffee 买一杯 Nitro Float。(如果你想买多几杯的话也可以哦!)

Nitro Float from Saint Frank Coffee

在刚刚创建这个页面时,我设置了一个目标:如果收到 5 杯咖啡(总共 $25)我就重新开始写博客。然后这个目标一天就超额完成了(总共 $40)。给我买咖啡的主要是我在 Facebook 的同事,其中 Shawn 最多,一下子就给我 5 杯的钱。

之后为了测试一下我知乎的转化率,我又把这个链接发到了我的知乎专栏,同时设定了一个新目标:如果收到 10 杯咖啡(总共 $50)我就在三月每周更新一篇博客。最终我从知乎收到了 12 杯咖啡(总共 $60),按照我知乎 24 万关注者来算这转化率为 0.005%。(因为 BMC 暂时还不提供任何手段区分用户来源,所以我并不能确保那 12 杯咖啡都是来自知乎的,只能说是在知乎专栏宣传后产生的。)

既然目标设定了,我就必须要写。为了监督我写作,大家可以订阅我的博客,或者关注我的 TwitterFacebook

手机无线充电真的更方便吗?

iPhone X with Anker wireless charger

之前一直在纠结要不要买个手机无线充电器来给 iPhone X 充电,因为 $60 买一个第一代产品总觉得有点浪费,而且还不是 Apple 自己的产品。(Apple 自己的 AirPower 至今连个日期和价格都还没有。)后来发现原来普通的 Qi 兼容无线充电器只需要 $20,唯一问题是不兼容 iOS 11.2 升级后新增的 7.5W 快充,只支持 5W 充电。+200% 的价格换 +50% 的充电速度不划算,所以我最终买了 Anker 的 5W 无线充电器。(Anker 还有一个 10W 的版本,但充 iPhone X 还是 5W,无法做到 7.5W。)

在买的时候我就想过了不同使用场景的利弊:

  1. 放在公司电脑桌上:我在座位时可以随时把手机放上去充电,需要去开会时随手可以拿走。好处是比插拔充电线稍微方便一点,但因为我桌面长期有闲置的充电线所以好处也不是非常明显。坏处是手机拿起来用时就不充电了。
  2. 放在家里床头柜上:晚上睡觉时充电。好处坏处跟前面的相似,外加一点坏处是充电灯睡觉时亮着会影响睡眠。
  3. 放在其他地方:除了上述两个地方以外,没有哪里我是会经常把手机放下来的,因此放在其他地方的坏处是使用频率会很低。

最终我选择了放在公司电脑桌上,然后发现这是个正确的选择。其实无线充电最大的便利就是不用插拔线,所以最值得把有线改为无线的地方应该具备两个特性:

  1. 这是一个固定的长期充电点。随身带一个无线充电器没有意义,无线充电器必须固定下来,所以必须固定在一个经常会把手机放下来充电的地方。
  2. 手机需要经常在充电和拿走之间切换。切换得约频发,无线充电消除的充电线插拔次数越多,价值越大。

我以前最头痛的问题是,iPhone 用了一年后电池容量衰减了,经常到下班的时候电量就少于 50%,晚上出去时要用手机很不方便,因此我白天需要注意剩余电量和及时充电。尽管我桌面有闲置的充电线,随时接上去就能充电,但接一下这个操作是有成本的,所以如果电量不是很低我就会懒得去充。换成无线充电最大的好处是把这个成本消除了,我什么时候回到座位都可以把手机放上去充电,无论我在座位停留多短暂都不觉得充电存在成本。

用这个方式去思考的话,很容易会发现在汽车导航时使用无线充电器也是合适的。这个充电点很固定,而且如果经常上下班开短途的话也算是频繁插拔。不过考虑到我还在用 iPhone 7+ 做导航(因为屏幕比 iPhone X 大),所以我暂时就不买整合无线充电的 car mount 了。

2018年2月9日星期五

活跃粉丝数

在知乎我大概能感觉到粉丝总数不如月活粉(monthly active follower)、日活粉(daily active follower)重要。在我不怎么用知乎的时候,无论我有多少粉回答后都不能得到多少赞和评论,必须要我频繁回答问题一两个月后赞和评论才能跟上来。我觉得这是因为我的粉丝中的大部分都已经不活跃,所以基数大也没有用。只有不停地吸引新粉丝,才能把月活粉、日活粉质量提上去,然后才能看到赞和评论的明显改善。

因此我觉得各大网站显示一个粉丝数其实挺没有意思的,基数大可能看起来很有面子,但其实无法转化为任何东西因为不活跃的粉丝跟僵死粉本质上毫无区别。不过要计算月活粉、日活粉需要增加网站计算负担,估计大家都不会做。

做直播估计是间接测算月活粉的最好办法,如果观众出现在直播上那一定是活跃的,就算不是直播而是帖子这些粉丝应该也会乐意交互。当然好像我这么懒的,直播能不搞就不搞,所以还要再想个办法测算月活粉。