精子最近在 第一届烧火节 中体验了钻木取火,并制作了第一个 Vlog。

入手 MacBook Air (Apple Silicon)

最近两年几乎买齐了苹果的全线产品,越来越看好苹果,甚至买了一些苹果的股票,当然为什么选择苹果生态这个话题可以放在单独的文章里来说。

我在 Small talk 的第二期「聊聊用 M1 芯片的新 Mac」中也聊到了 Apple Silicon 的话题,欢迎大家收听,但这期播客录制时间较早,如有冲突还是以本文为准。

第一体验

在 11 月 17 日的发布会后我又观望了一周才下单,最后在 12 月 4 日拿到了搭载 M1 处理器 的 MacBook Air,我将内存升级到了 16G ,存储则还是低配的 256G。

2021/macbook-order.png

选择这一款是因为从测评来看 Air 和 Pro 的性能差别并不显著,也不想为了 Touch Bar 和屏幕亮度支付额外 2000 元的价格,不如把这个钱加到内存上。

说到内存,新的 Mac 使用了「统一内存架构(UMA)」,可以消除 CPU 和显卡等专用计算单元之间的内存拷贝,既提高了速度,又减少了内存使用。一些朋友表示 8G 的内存对于不开虚拟机的中度使用也非常够用,但相信硬件的提升很快就会被软件消化,如果你希望新的 Mac 有一个比较长的使用周期,还是建议升到 16G 内存。对于新的 Mac 来说最高也只能选配 16G 内存,据说是因为总线 IO 的瓶颈,只有 2 个雷电接口也是这个原因。

至于存储空间,我属于最低的存储空间都够用的那一类人 —— 我希望设备上有最高性能的本地存储,但我并不会用这么昂贵的空间去存储冷数据,毕竟我才刚刚花了大功夫 自己搭了一个 NAS

拿到 MacBook Air 开始,最亮眼的还是发热和续航的表现,我偶尔会把 MacBook 放在腿上使用,之前的 Intel MacBook 十几分钟就会觉得烫,而 M1 Mac 则在日常使用时几乎感觉不到温度,在 CPU 跑满的情况下温热,只有 CPU 和 GPU 同时跑满才会有烫的感觉。相应地,M1 的续航表现也非常亮眼,后面的性能测试中会有详细的说明。

然后把它和我们家其他的 Mac 对比一下跑分,果然是用最低的价格提供了最高的分数:

Mac Air (M1) Pro (2020) Pro (2017) mini (2018)
CPU M1 I5-1038NG7 I5-7360U I7-8700B
GPU 7-Core Iris Plus Iris Plus 640 Intel UHD 630
Memory 16G 16G 8G 16G
Geekbench SC 1678 1136 852 1117
Geekbench MC 7225 4237 2020 5621
Geekbench Metal 19138 8498 4930 3776
Price ¥9499 ¥14499 ¥11888 ¥11909

数据来自 everymac.com 和 geekbench.com

其中 MacBook Pro 2020 是我 2020 年初时购买的最后一代 Intel MacBook,使用第十代 i5,倒是没什么问题,只是目前来看就买得实在太亏了;MacBook Pro 2017 是蛋黄一直在用,最近她开始学习 Swift 就一直在吐槽电脑实在太慢了,同时电池也进入了待维护状态;Mac mini 2018 是我目前工作用的电脑,当时虽然选了最高配的 i7 CPU,但没考虑到 Intel UHD 630 的性能实在太差了,即使我只是接了一块 4k 屏,系统的界面响应就已经非常卡顿了,现在 GPU 成为了整台电脑的瓶颈。

ARM 生态

应该说这次从 x86 到 ARM 的切换比我想象中的要顺利,苹果的第一方应用和 masOS 独占的应用都第一时间进行了适配,其他没有适配的应用则可以用 Rosetta 2 来运行。Rosetta 2 用起来是完全无感的,系统会自动将 x86 的应用以转译的方式来运行,无论是图形界面应用还是命令行的 binary 文件。性能上的差别对于大部分应用来说也并不明显,很多时候感觉不到自己是否使用了 Rosetta 2。

M1 芯片之前对我来说最大的变数在于对 Docker 的支持,但就在前几天 Docker for Mac 也发布了 针对 M1 芯片的测试版本。测试版中默认会运行一个 ARM 架构的 Linux 虚拟机,默认运行 linux/arm64 架构的镜像(说起来在 M1 之前 linux/arm64 大概主要是被用在树莓派上吧);对于没有提供 linux/arm64 架构的镜像则会自动使用 QEMU 来运行 x64_64 的镜像,性能就比较差了。

macOS 吸引我的一大理由就是 Homebrew —— 可能是桌面开发环境中最好用的包管理器。在 M1 上 Homebrew 目前 推荐大家使用 Rosetta 2 来运行,所安装的包也都是需要 Rosetta 2 转译运行的 x86 版本,即使这个包已经提供了 ARM 版本。

这是因为 Rosetta 2 虽然可以完美运行 x86 的 binary,但当一个脚本中会以字符串的方式传递架构名、会调用多种不同的架构的程序,且这些程序同样关心当前的架构时就出问题了,不同的程序无法对这台机器的架构达成一致 —— 这往往发生在编译脚本里,也就是 Homebrew 的主要工作。解决这个问题目前只能是让整个脚本都运行在 x86(即 Rosetta 2)下,Homebrew 目前也是这样做的。

当然你可以选择在另外一个路径 安装 ARM 版的 Homebrew 来安装 ARM 版的包,但目前这种方式缺少官方指引、需要自己尝试一个包的 ARM 版是否可以工作、需要从源码编译。目前大多数无法工作的包是受限于上游依赖的发布周期(如支持 darwin/arm64 的 Go 1.16 要等到 2021 年二月才会发布),对于不涉及特定架构、或已经在其他平台提供有 ARM 版本的包,届时只需重新编译就可以提供 ARM 版本。

M1 的 Mac 可以直接安装 iOS 应用这一点我倒不是很在意,一方面是很多国内的毒瘤应用第一时间就从 Mac 商店下架,不允许安装。另一方面 iOS 基于触屏的交互逻辑本来就不适合 Mac,我也不觉得 Mac 之后会加入触屏的支持。

性能测试

以极低的功耗实现高于之前 MacBook 的性能是这次 M1 Mac 的亮点,在我购买之前实际上就已经看了很多视频自媒体的测评,在他们的测试中 M1 Mac 在使用 Final Cut Pro X 进行视频剪辑和导出有着碾压级的性能表现。

但显然这并不能代表 M1 在所有工作负载下的表现,因此我根据我日常的工作负载设计了 7 组共 15 项测试,主要将搭载 M1 的 MacBook Air 和我目前在使用的最后一代 Intel MacBook Pro (2020, i5 10th) 进行对比,以下数据均以后者为基准。

Name MacBook Pro (i5 10th) MacBook Air (M1, x86) MacBook Air (M1, ARM)
Node.js npm install 2m 41s 1m 38s (+39%) 1m 2s (+61%)
Node.js webpack build 54s 38s (+30%) 27s (+50%)
Xcode build Swift SDK 11m 30s N/A 6m 47s (+41%)
Xcode start iOS Simulator 49s N/A 16s (+67%)
Docker Redis benchmark 128k QPS 133k QPS (+4%) 261k QPS (+96%)
Docker build Node.js app 2m 56s 4m 43s (-61%) 3m 17s (-12%)
Visual Studio Code startup 7s 17s (-142%) 3s (+57%)
Visual Studio Code open and close tabs 36s 37s (-3%) 40s (-11%)
Chrome Speedometer 2.0 88 times/m 121 times/m (+38%) 214 times/m (+143%)
Safari Speedometer 2.0 111 times/m N/A 227 times/m (+105%)
Safari 10% battery for Bilibili 32m N/A 1h 50m (+244%)
Final Cut Pro X background rendering 9m N/A 6m 20s (+30%)
Final Cut Pro X export H.264 8m 25s N/A 7m 8s (+15%)
Steam Oxygen Not Included 25 ~ 40 fps 45 ~ 50 fps (+25 ~ 80%) Not support
Steam Sid Meier’s Civilization VI p99 22 fps p99 51 fps (+132%) Not support

对于 Node.js 依赖安装、前端项目构建、Swift 代码编译这些 CPU 密集且内存访问频繁、其中一些步骤依赖单核性能的场景,M1 有着非常明显的提升,即使使用 Rosetta 2 转译也要显著好于 i5。

最值得一提的是得益于 M1 的统一内存架构的高带宽和低延迟,Redis 跑出了 26 万 QPS 的成绩(无论是否在 Docker 中这个数据都差不多),而 i5 仅有 6 万。在调整 redis-benchmark 的数据长度参数时,M1 的结果几乎没有什么变化,而 i5 则随着数据长度的增加 QPS 逐步下降。说不定未来搭载 M1 的 Mac mini 会成为运行遇到 CPU 瓶颈的 Redis 的最佳硬件。

而使用 Docker for Mac 构建镜像则没有提升,这可能是因为构建的过程有很多零散的 IO,CPU 会有比较多的时间休息。而如果使用 Docker 去构建 x86_64 架构的镜像的话,性能损失就非常严重了(-61%)。

我编写了一个反复开关标签页的脚本来测试 VSCode 的性能,结果表明对于这类负载并不重的 GUI 程序,Rosetta 2 转译并不会影响性能,同样编译到 ARM 也不会对性能有多少提升,Rosetta 2 主要是会比较明显地增加启动速度。在 VSCode 的测试数据中出现了比较奇怪的现象 —— Rosetta 2 转译的版本竟然比 ARM 还快,我目前倾向于这是实验的误差,两者的速度实际上是几乎相同的。

在浏览器的测试中我们选择了 Speedometer,它会运行上百个由主流 Web 框架编写的 Todolist。结果显示无论是 Chrome 还是 Safari,其 ARM 版本都有一倍以上的性能提升,同样即使经过 Rosetta 2 转译也仍然比 i5 要快。浏览器的场景其实和前面 Node.js、Swfit 和 Redis 很像,都是 CPU 密集且内存访问频繁、其中一些步骤依赖单核性能,这也是 Intel CPU 之前的痛点。

我还基于浏览器进行了续航测试,我在中等亮度下播放 Bilibili 上 4K 120 帧的视频,开启弹幕的情况下 M1 使用前 10% 的电池播放了惊人的 1 小时 50 分钟,在这段时间的日常使用体验也是如此,我毫不怀疑官网给出的 18 小时视频播放时间。

在 Final Cut Pro X 的视频渲染和导出上,虽然 M1 确实有提升,但远不如之前一些媒体宣传的那么夸张,目前我还不清楚原因。

游戏方面我测试了我经常玩的 Oxygen Not Included(缺氧)和 Sid Meier’s Civilization VI(文明 6),我使用的都是中后期的存档、默认画面预设,在大多数时间都有 50 帧以上,是完全可以流畅游玩的。

可以看到 M1 的 Mac 在之前低配的价位上实现了中配甚至高配的计算性能,得益于专用的加速芯片,在苹果第一方和 macOS 独占的应用上有非常惊人的表现,而对于必须经过 Rosetta 2 转译的应用,仍有可以接受的性能表现,也是远高于之前同一价位的 Mac 的。

多用户模式

因为蛋黄和我对新的 MacBook 都很有兴趣,因此我们各建了一个账户,这段时间是在轮流使用这台 MacBook,这也是我第一次使用 macOS 的多用户模式。整体体验还是很不错的,macOS 允许两个用户同时登录,在不退出程序的情况下在两个用户间切换,这使得我和蛋黄同时使用一台电脑的体验非常流畅。

16G 的内存也非常够用,即使另外一个用户运行了 XCode、Final Cut Pro X 或大量标签页的 Chrome,也不会有任何感觉。倒是 256G 的存储空间对于两个用户同时使用有些不够,不过这样的状态应该不会持续太久,后面我也会入手一台 M1 的 Mac。

对 Mac 的展望

Rosetta 2 为什么会有这么好的性能呢?之前 Surface 等 x86 模拟器性能不佳的一个原因是 x86 与 ARM 在一个有关内存顺序的机制上有着不同的行为,在 ARM 上模拟这一行为会导致很大的性能损失。而苹果选择直接 在 M1 芯片中实现了一套 x86 的内存机制,大大加速了 Rosetta 2 的性能。据说苹果同样在芯片层面对 JavaScript 和 Swfit 中一些特定场景进行了优化,还有大量的专用计算芯片来加速编视频编解码、密码学计算等特定的任务。

这是一个非常有趣的方向,过去很长一段时间都是应用来适配芯片,但只要对硬件和操作系统的控制力足够强,芯片也可以反过来去对最常用、性能问题最突出的应用进行芯片层面的优化或加入专用的计算芯片,和应用程序一起进行迭代更新。M1 中有的是对 Rosetta 2 的优化,而下一代的 M2 芯片则可能不再需要 Rosetta 2,而是可以根据需要去优化当时的热门场景。

对于苹果来说切换到 ARM 最重要的是提升了其垂直整合的能力、自主控制 Mac 产品线的更新周期。因为苹果对于操作系统的控制力和对应用生态的号召力,可以最大限度地发挥出自主设计的 ARM 芯片的效果。Windows 阵营当然可以切换到 ARM,会享受到前面提到的一些好处,毕竟苹果已经证明了这条路是可行的。但因为软硬件不是同一家公司控制、Windows 对应用生态的号召力弱,微软又不敢破釜沉舟地投入到 ARM 上,因此短期内可能 Windows 阵营还很难实现。

我的 NAS 选型与搭建过程(基于开源方案)

在 2016 年我拥有了第一台 NAS —— 群晖的 DS215J,其实在之后的很长一段时间其实并没有派上多大用场,因为我的数据并不多,大都存储在云端,更多的是体验一下 NAS 的功能和工作流。

直到最近我才开始真正地将 NAS 利用上,于是准备升级一下,但考虑到群晖的性价比实在太低,再加上去年配置 Linux 软路由让我对基于「原生 Linux」的开源解决方案信心和兴趣大增,于是准备自己 DIY 一台 NAS,计划解决未来十年的存储需求。

我依然选择了我最熟悉的 Ubuntu 作为操作系统、Ansible 作为配置管理工具,因此这个 NAS 的大部分配置都可以在我的 GitHub 上找到。

注意这个仓库中的 Ansible 配置仅供参考,不建议直接运行,因为我在编写这些配置时并未充分考虑兼容性和可移植性。

文件系统

对于一台 NAS 来说最重要的当然是文件系统,不需要太多调研就可以找到 ZFS —— 可能是目前在数据可靠性上下功夫最多的单机文件系统了,于是我的整个选型就围绕 ZFS 展开了。

ZFS 既是文件系统,同时又是阵列(RAID)管理器,这为它带来了一些其他文件系统难以提供的能力:

  • ZFS 为每个块都存储了校验和,同时会定期扫描整个硬盘,从 RAID 中的其他硬盘修复意外损坏的数据(如宇宙射线导致的比特翻转)。
  • 在 RAID 的基础上可以 指定某些目录以更多的份数冗余存储,对于重要的数据即使损坏的硬盘超过了 RAID 方案的限制,依然有可能找回。

ZFS 还支持数据加密、压缩和去重,这三项功能以一种巧妙的顺序工作,并不会互相冲突,同时这些所有选项都可以设置在目录(dataset)级别、可以随时更改(只对新数据生效)。

ZFS 当然也支持快照,快照可以被导出为二进制流,被存储到任何地方。这个功能可以让你在不丢失任何元信息的情况下对 ZFS 的文件系统进行备份、传输和恢复。

硬件

我并不擅长淘硬件,于是就选择了 HPE 的 MicroServer Gen10,一个四盘位的成品微型服务器,CPU 是 AMD X3421 ,8G ECC 内存,也是标准的 x86 通用硬件,应该不太容易遇到坑。

2020/nas-gen-10.png

我用转接卡在 PCI-E 插槽上装了一块 NVME SSD,用作系统盘和 ZFS 的读缓存(L2ARC,不过从后面的统计来看效果并不明显),数据盘则暂时用的是旧的硬盘,最终会升级到四块 4T 的硬盘。这里需要注意的是因为 ZFS 不支持更改 RAID 的结构,所以必须在一开始就配置足够的硬盘来占位,后续再升级容量,我甚至用 USB 接了一块移动硬盘来凑数。

ZFS

因为是四盘位,所以我采用了 raidz1(RAID5),冗余一块盘作为校验,如果最终所有的盘都升级到 4T,一共是 12T 的实际可用容量。

root@infinity:~# zpool status
  pool: storage
 state: ONLINE
config:
    NAME                                 STATE     READ WRITE CKSUM
    storage                              ONLINE       0     0     0
      raidz1-0                           ONLINE       0     0     0
        sda                              ONLINE       0     0     0
        sdb                              ONLINE       0     0     0
        sdc                              ONLINE       0     0     0
        sdd                              ONLINE       0     0     0
    cache
      nvme0n1p4                          ONLINE       0     0     0

root@infinity:~# zpool list
NAME      SIZE  ALLOC   FREE  CKPOINT   FRAG    CAP  DEDUP    HEALTH
storage  7.27T  3.52T  3.75T        -    10%    48%  1.00x    ONLINE

通常认为 RAID5 在出现硬盘故障的恢复过程中存在着较高的风险发生第二块盘故障、最终丢失数据的的情况;或者硬盘上的数据随着时间推移发生比特翻转导致数据损坏。但考虑到 ZFS 会定期做数据校验来保证数据的正确性,再综合考虑盘位数量和容量,我认为这个风险还是可以接受的,后面也会提到还有异地备份作为兜底措施。

我开启了 ZFS 的加密功能,但这带来了一个问题:我不能把密钥以明文的方式存储在 NAS 的系统盘 —— 否则密钥和密文放在一起的话,这个加密就失去意义了。所以每次 NAS 重启后,都需要我亲自输入密码、挂载 ZFS 的 dataset,然后再启动其他依赖存储池的服务。

我还开启了 ZFS 的数据压缩,默认的 lz4 只会占用少量的 CPU 却可以在一些情况下提高 IO 性能 —— 因为需要读取的数据量变少了。因为去重对资源的需求较高,相当于需要为整个硬盘建立一个索引来找到重复的块,我并没有开启去重功能。

一些评论认为 ZFS 对内存的需求高、必须使用 ECC 内存。这其实是一种误解:更多的内存可以提升 ZFS 的性能,ECC 则可以避免系统中所有应用遇到内存错误,但这些并不是必须的,即使没有更多的内存或 ECC,ZFS 依然有着不输其他文件系统的性能和数据完整性保证。

存储服务

小知识:SMB 是目前应用得最广泛的局域网文件共享协议,在主流的操作系统中都有内建的支持。CIFS 是微软(Windows)对 SMB 的一个实现,而我们会用到的 Samba 是另一个实现了 SMB 协议的自由软件。

2020/nas-samba.png

作为 NAS 最核心的功能就是通过 SMB 协议向外提供存储服务,所有的成品 NAS 都有丰富的选项来配置 SMB 的功能,但我们就只能直接去编辑 Samba 的配置文件了,Samba 直接采用了 Linux 的用户和文件权限机制,配置起来也不算太麻烦:

# 可以在 path 中使用占位符来为每个用户提供单独的 Home 目录
# 可以在 valid users 中使用用户组来控制可访问的用户
[Home]
path = /storage/private/homes/%U
writeable = yes
valid users = @staff

# Samba 默认以登录用户创建文件,但 NextCloud 以 www-data 运行,可以用 force user 覆盖为特定的用户
[NextCloud]
path = /storage/nextcloud/data/%U/files
writeable = yes
valid users = @staff
force user = www-data

# 通过这些设置可以让 macOS 的 TimeMachine 也通过 SMB 进行备份
# 详见 https://www.reddit.com/r/homelab/comments/83vkaz/howto_make_time_machine_backups_on_a_samba/
[TimeMachine]
path = /storage/backups/timemachines/%U
writable = yes
valid users = @staff
durable handles = yes
kernel oplocks = no
kernel share modes = no
posix locking = no
vfs objects = catia fruit streams_xattr
ea support = yes
inherit acls = yes
fruit:time machine = yes

# 对于共享的目录可以用 force group 覆盖文件的所属组、用 create mask 覆盖文件的权限位
[VideoWorks]
path = /storage/shares/VideoWorks
writeable = yes
valid users = @staff
force group = staff
create mask = 0775

# 还可以设置游客可读、指定用户组可写的公开目录
[Resources]
path = /storage/public/Resources
guest ok = yes
write list = @staff
force group = +staff
create mask = 0775

从上面的配置中也可以看到这些共享目录分散在几个不同的路径,为了匹配不同的数据类型、方便在目录级别进行单独设置,我划分了几个 dataset:

  • db 存放应用的数据库文件,将 recordsize 设置为了 8k(默认 128k)。
  • nextcloud NextCloud 的数据目录,也可被 SMB 访问。
  • private 每个用户的个人文件。
  • shares 家庭内部共享的文件(如拍摄的视频)。
  • public 可以从互联网上下载到的文件,不参与异地备份。
  • backups 备份(Time Machine 等),不参与异地备份。
root@infinity:~# zfs list
NAME                USED  AVAIL     REFER  MOUNTPOINT
storage            2.27T   286G      169K  /storage
storage/backups     793G   286G      766G  /storage/backups
storage/db          741M   286G      339M  /storage/db
storage/nextcloud   207G   286G      207G  /storage/nextcloud
storage/private    62.2G   286G     62.2G  /storage/private
storage/public      648G   286G      613G  /storage/public
storage/shares      615G   286G      609G  /storage/shares

应用

首先我安装了 Netdata,这是一个开箱即用的监控工具,在仅占用少量资源的情况下提供秒级精度的大量统计指标,非常适合用于监控单台服务器的性能瓶颈。

2020/nas-netdata.jpg

其余的应用都被我运行在了 Docker 中(使用 docker-compose 来管理),这样可以隔离应用的运行环境,提升宿主机的稳定性,安装、升级、卸载应用也会更方便。

其中最重要的一个应用是 NextCloud,这是一个开源的同步盘,我主要看中它的 iOS 应用和 iOS 有不错的整合,可以正确地同步 Live Photo,也可以在 iOS 的文件应用中被调用。

2020/nas-nextcloud.jpg

NextCloud 服务端会直接读写文件系统中的文件,而不是将文件存储在数据库里,这意味着 NextCloud 的数据目录同时也可以通过 Samba 来访问,这一点非常方便(不过需要一个定时任务来刷新 NextCloud 数据库中的元信息)。

我还在 Docker 中运行了这些服务,它们都是开源的:

  • Miniflux,一个 RSS 服务端,通过 Fever API 支持绝大部分的 RSS 客户端。
  • Bitwarden(非官方实现),一个密码管理器,提供有各平台的客户端和浏览器插件。
  • Transmission,一个 BitTorrent 客户端,提供基于 Web 的管理界面。

外部访问

如果要真正地用 NAS 来替代网盘的话,还是需要保证不在家里的内网的时候也可以访问到文件的。

通常的做法是使用 DDNS(动态 DNS)将一个域名解析至家庭宽带的 IP,这要求家庭宽带有公网 IP,而且运营商允许在 80 或 443 端口提供 Web 服务。我不想依赖这一点,所以想到了用 frp 来进行「反向代理」,如果你确实有公网 IP 的话,也可以使用 DDNS 的方案,这样会省去一个中转服务器,也可以有更好的速度。

为了让 NextCloud 能有一个固定的地址(如 https://nextcloud.example.com)我将域名在内外网分别进行了解析,在家时解析到内网地址,在外解析到中转服务器。无论是内外网,数据流都会经过 Let’s Encrypt 的 SSL 加密,这样就不需要中转服务器有较高的安全保证。

虽然不需要先拨一个 VPN 确实很方便,但将 NextCloud 开放在公网上 并不安全,在社区中已有用户 要求 NextCloud 客户端支持双向 SSL 认证,我也非常期待这个功能,可以在公网访问上提供更好的安全性。

我还在 NAS 上安装了 WireGuard,这是一个内建在 Linux 内核中的 VPN 模块,同样通过 frp 暴露在外网,除了 NextCloud 之外的服务,如 SMB、SSH 和 Nextdata 都可以通过 WireGuard 来访问。

如果你不执着于开源方案的话,也可以试试 ZeroTier,它提供了 NAT 穿透的能力,让你的设备和 NAS 之间可以不借助中转服务器直接传输,改善连接速度。

备份和数据完整性

在 raidz1 的基础上,我设置了定时任务让 ZFS 每天生成一个快照,还写了一个脚本来按照类似 Time Machine 的规则来清理备份:保留最近一周的每天快照、最近一个月的每周快照、最近一年的每月快照、以及每年的快照。

root@infinity:~# zfs list storage/nextcloud -t snapshot
NAME                           USED  AVAIL     REFER  MOUNTPOINT
storage/nextcloud@2020-09-05  83.9M      -      182G  -
storage/nextcloud@2020-09-15  35.2M      -      207G  -
storage/nextcloud@2020-09-21  30.2M      -      207G  -
storage/nextcloud@2020-09-23  29.7M      -      207G  -
storage/nextcloud@2020-09-26  29.3M      -      207G  -
storage/nextcloud@2020-09-27  28.2M      -      207G  -
storage/nextcloud@2020-09-28  28.2M      -      207G  -
storage/nextcloud@2020-09-29  29.1M      -      207G  -
storage/nextcloud@2020-09-30  33.5M      -      207G  -

快照主要是为了防止人工的误操作,除了单纯的、当场就能发现的手滑之外,有时你会误以为你不会用到这个文件而将它删除,直到很久之后才发现并非如此。

同时每周会有定时任务使用 restic 备份一个快照到 Backblaze B2 作为异地备份,这是一个价格较低的对象存储,非常适合备份。restic 支持增量的快照备份,也支持加密。出于成本考虑,异地备份仅包括由我产生的数据,并不包括 public 和 backups 目录。

我曾考虑过直接在远端运行一个 ZFS 来进行备份,zfs send / recv 支持以二进制流的形式传输一个快照 —— 不需要远端安装其他任何的工具,只需要用 shell 的管道操作符将 zfs send 的字节流重定向到 ssh 命令即可。这个方案非常具有技术美感,但考虑到块存储的价格是对象存储的十倍以上,最后还是放弃了这个方案。

成本核算

硬件上其实我预算并不紧张,留的余量也比较大,如果换一些性价比更高的硬件的话,价格还可以下降很多。

  • 主机(主板、CPU、内存、系统盘) 3500 元
  • 硬盘(4 * 4T) 2200 元(其实目前只买了一块,其他三块是旧的)

考虑到我之前的群辉用了五年,新的 NAS 设计使用寿命定在十年:

  • 硬件成本折合每年 570 元
  • 电费(35W)每年 110 元
  • 远程访问每年 100 元(国内年付促销服务器,如有公网 IP 使用 DDNS 则无需此项)
  • 异地备份每年 415 元(按量付费,这里按 1T 需要异地备份的数据计算)

总共 12T 的容量每年 1195 元,折合 1T 每月 8 元,如果去掉远程访问和异地备份的话则是 1T 每月 5 元。

为什么要用自部署方案

相比于使用云服务,第一个理由自然是对数据的「掌控感」,虽然没有什么确凿的理由说云服务就一定不安全,但有些人就是喜欢这种对个人数据的掌控感。

还有一个技术原因是部署在家中内网的 NAS 可以通过 SMB 简单地支持一些「在线编辑」,如直接加载 NAS 上的素材进行视频剪辑、甚至将整个工程文件都直接放在 NAS 上。使用云服务的话一方面是没有 SMB 协议的支持,即使支持延迟对于在线编辑来说也是无法接受的。

另外一个不能忽略的话题就是成本,在这里我们只考虑以容量为计价方案的网盘服务,iCloud、Google Drive、Dropbox 的价格方案都非常接近,在超过 200G(大概 $3)这一档之后就直接跳到了 2T(大概 $10),这时云服务按量付费的优势其实就没有了,是一个切换到自部署方案的一个不错的时间点,一次性投入之后只需 2 - 3 年即可回本。

当然最重要的一点是兴趣,在这个折腾的过程中你需要做很多决定、遇到很多困难,最后搭建出来一个几乎是独一无二的自部署方案。如果你能在这个过程中找到乐趣的话,那当然是非常值得的;反过来如果你没有兴趣,算上投入的时间成本,自部署方案的性价比将会非常低。

任何自部署的方案都需要长期的维护才能保持工作,对后端运维完全没有兴趣怎么办,不如了解一下 LeanCloud,领先的 BaaS 提供商,为移动开发提供强有力的后端支持。

TypeScript:重新发明一次 JavaScript

作为一个 Node.js 开发者,我很早便了解到了 TypeScript,但又因为我对 CoffeeScript 的喜爱,直到 2016 年才试用了一下 TypeScript,但当时对它的学习并不深入,直到最近又在工作中用 TypeScript 开发了两个后端项目,对 TypeScript 有了一些新的理解。

为 JavaScript 添加类型

大家总会把 TypeScript 和其他语言去做对比,说它是在模仿 Java 或 C#,我也曾一度相信了这种说法。但其实并非如此,TypeScript 的类型系统和工作机制是如此的独特,无法简单地描述成是在模仿哪一个语言,更像是在 JavaScript 的基础上重新发明了 JavaScript

究其根本,TypeScript 并不是一个全新的语言,它是在一个已有的语言 —— 还是一个非常灵活的动态类型语言上添加静态约束。在官方 Wiki 上的 TypeScript Design Goals 中有提到,TypeScript 并不是要从 JavaScript 中抽取出一个具有静态化语义的子集,而是要尽可能去支持之前社区中已有的编程范式,避免与常见的用法产生不兼容。

这意味着 TypeScript 试图为 JavaScript 已有的大量十分「动态」的特性去提供静态语义。一般认为「静态类型」的标志是在编译时为变量确定类型,但 TypeScript 很特殊,因为 JavaScript 本身的动态性,TypeScript 中的类型更像是一种「约束」,它尊重已有的 JavaScript 设计范式,同时尽可能添加一点静态约束 —— 这种约束不会影响到代码的表达能力。或者说,TypeScript 会以 JavaScript 的表达能力为先、以 JavaScript 的运行时行为为先,而静态约束则次之。

这样听起来 TypeScript 是不是很无聊呢,毕竟 Python 也有 Type Checking,JavaScript 之前也有 Flow。的确如此,但 TypeScript 的类型系统的表达能力和工具链的支持实在太强了,并不像其他一些静态类型标注仅能覆盖一些简单的情况,而是能够深刻地参与到整个开发过程中,提高开发效率

前面提到 TypeScript 并不想发明新的范式,而是要尽可能支持 JavaScript 已有的用法。因此虽然 TypeScript 有着强大的类型系统、大量的特性,但对于 JavaScript 开发者开说学习成本并不高,因为几乎每个特性都可以对应 JavaScript 社区中一种常见的范式。

基于属性的类型系统

在 JavaScript 中,对象(Object)是最常用的类型之一,我们会使用大量的对象字面量来组织数据,我们经常将很多不同的参数塞进一个对象,或者从一个函数中返回一个对象,对象中还可以再嵌套对象。可以说对象是 JavaScript 中最常用的数据容器,但并没有类型去约束它。

例如 request 这个库会要求使用者将发起请求的所有参数一股脑地以一个对象的形式作为参数传入。这就是非常典型的 JavaScript 风格。再比如 JavaScript 中一个 Promise 对象只需有 then 和 catch 这两个实例方法就可以,而并不真的需要真的来自标准库中的 Promise 构造器,实际上也有很多第三方的 Promise 的实现,或一些返回类 Promise 对象的库(例如一些 ORM)。

在 JavaScript 中我们通常只关注一个对象是否有我们需要的属性和方法,这种范式被称为「鸭子类型(Duck typing)」,就是说「当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子」。

所以 TypeScript 选择了一种基于属性的类型系统(Structural type system),这种类型系统不再关注一个变量被标称的类型(由哪一个构造器构造),而是 在进行类型检查时,将对象拆开,抽丝剥茧,逐个去比较组成这个对象的每一个不可细分的成员。如果一个对象有着一个类型所要求的所有属性或方法,那么就可以当作这个类型来使用

这就是 TypeScript 类型系统的核心 —— Interface(接口):

interface LabeledValue {
  label: string
}

TypeScript 并不关心 Interface 本身的名字,与其说是「类型」,它更像是一种约束。一个对象只要有一个字符串类型的 label 属性,就可以说它满足了 LabeledValue 的约束。它可以是一个其他类的实例、可以是字面量、可以有额外的属性;只要它满足 LabeledValue 所要求的属性,就可以被赋值给这个类型的变量、传递给这个类型的参数。

前面提到 Interface 实际上是一组属性或一组约束的集合,说到集合,当然就可以进行交集、并集之类的运算。例如 type C = A & B 表示 C 需要同时满足类型 A 和类型 B 的约束,可以简单地实现类型的组合;而 type C = A | B 则表示 C 只需满足 A 和 B 任一类型的约束,可以实现联合类型(Union Type)。

接下来我会挑选一些 TypeScript 具有代表性的一些特性进行介绍,它们之间环环相扣,十分精妙。

字符串魔法:字面量

在 TypeScript 中,字面量也是一种类型:

type Name = 'ziting'

const myName: Name = 'ziting'

在上面的代码中,Name 类型唯一合法的值就是 ziting 这个字符串 —— 这看起来毫无意义,但如果我们引入前面提到的集合运算(联合类型)呢?

type Method = 'GET' | 'PUT' | 'DELETE'

interface Request {
  method: Method
  url: string
}

上面的代码中我们约束了 Request 的 method 只能是 GET、PUT 和 DELETE 之一,这比单纯地约束它是一个字符串类型要更加准确。这是 JavaScript 开发者经常使用的一种模式 —— 用字符串来表示枚举类型,字符串更灵活也更具有可读性。

在 lodash 之类的库中,JavaScript 开发者还非常喜欢使用字符串来传递属性名,在 JavaScript 中这很容易出错。而 TypeScript 则提供了专门的语法和内建的工具类型来实现对这些字符串字面量的计算,提供静态的类型检查:

interface Todo {
  title: string
  description: string
  completed: boolean
}

// keyof 将 interface 的所有属性名提取成一个新的联合类型
type KeyOfTodo = keyof Todo // 'title' | 'description' | 'completed'
// Pick 可以从一个 interface 中提取一组属性,生成新的类型
type TodoPreview = Pick<Todo, 'title' | 'completed'> // {title: string, completed: boolean}
// Extract 可以找到两个并集类型的交集,生成新的类型
type Inter = Extract<keyof Todo, "title" | "author"> // "title"

借助这些语法和后面提到的泛型能力,JavaScript 中各种以字符串的形式传递属性名、魔法般的对象处理,也都可以得到准确的类型检查。

类型元编程:泛型

泛型提供了一种将类型参数化的能力,在其他语言中最基本的用途是定义容器类型,使得工具函数可以不必知道被操作的变量的具体类型。JavaScript 中的数组或 Promise 在 TypeScript 中都会被表述为这样的泛型类型,例如 Promise.all 的类型定义可以写成:

function all<T>(values: Array<T | Promise<T>>): Promise<Array<T>>

可以看到类型参数可以被用来构造更复杂的类型,进行集合运算或嵌套。

默认情况下,因为类型参数可以是任意的类型,所以不能假定它有某些属性或方法,也就不能访问它的任何属性,只有添加了约束才能遵循这个约束去使用它,同时 TypeScript 会依照这个约束限制传入的类型:

interface Lengthwise {
  length: number
}

function logLength<T extends Lengthwise>(arg: T) {
  console.log(arg.length)
}

约束中也可以用到其他的类型参数或使用多个类型参数,在下面的代码中我们限制类型参数 K 必须是 obj 的一个属性名:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

除了在函数上使用泛型之外,我们还可以定义泛型类型:

type Partial<T> = {
  [P in keyof T]?: T[P];
}

当定义泛型类型时我们实际上是在定义一种处理类型的「函数」,使用泛型参数去生成新的类型,这也被称作「元编程」。例如 Partial 会遍历传入类型 T 的每一个属性,返回一个所有属性都可空的新类型:

interface Person {
  name: string
}

const a: Person = {} // 报错 Property 'name' is missing in type '{}' but required in type 'Person'.
const b: Partial<Person> = {}

前面我们提到的 Pick 和 Extract 都是这样的泛型类型。

在此之外 TypeScript 甚至可以在定义泛型类型时进行条件判断和递归,这使得 TypeScript 的类型系统变成了 图灵完备的,可以在编译阶段进行任何计算。

你可能会怀疑这样复杂的类型真的有用么?其实这些特性更多地是提供给库开发者使用的,对于 JavaScript 社区中的 ORM、数据结构,或者是 lodash 这样的库来说,如此强大的类型系统是非常必要的,lodash 的 类型定义 行数甚至是它本身代码的几十倍。

类型方程式:自动推导

但其实我们并不一定要掌握这么复杂的类型系统,实际上前面介绍的高级特性在业务代码中都极少被用到。TypeScript 并不希望标注类型给开发者造成太大的负担,因此 TypeScript 会尽可能地进行类型推导,让开发者在大多数情况下不必手动标注类型。

const bool = true // bool 是字面量类型 true
let num = 1 // num 是 number
let arr = [0, 1, 'str'] // arr 是 (number | string)[]

let body = await fs.readFile() // body 是 Buffer

// cpuModels 是 string[]
let cpuModels = os.cpus().map( cpu => {
  // cpu 是 os.CpuInfo
  return cpu.model
})

类型推导同样可以用在泛型中,例如前面提到的 Promise.all 和 getProperty,我们在使用时都不必去管泛型参数:

// 调用 Promise.all<Buffer>,files 的类型是 Promise<Buffer[]>
const files = Promise.all(paths.map( path => fs.readFile(path)))
// 调用 Promise.all<number[]>,numbers 的类型是 Promise<number[]>
const numbers = Promise.all([1, 2, 3, 4])

// 调用 getProperty<{a: number}, 'a'>,a 的类型是 number
const a = getProperty({a: 2}, 'a')

前面提到泛型是在将类型参数化,引入一个未知数来代替实际的类型,所以说泛型对于 TypeScript 就像是一个方程式一样,只要你提供了能够解开这个方程的其他未知数,TypeScript 就可以推导出剩余的泛型类型。

价值十亿美金的错误

在很多语言中访问空指针都会报出异常(在 JavaScript 中是从 null 或 undefined 上读取属性时),空指针异常被称为「价值十亿美元的错误」。TypeScript 则为空值检查也提供了支持(需开启 strictNullChecks),虽然这依赖于类型定义的正确性,并没有运行时的保证,但依然可以提前在编译期发现大部分的错误,提高开发效率。

TypeScript 中的类型是不可为空(undefined 或 null)的,对于可空的类型必须表示成和 undefined 或 null 的并集类型,这样当你试图从一个可能为 undefined 的变量上读取属性时,TypeScript 就会报错了。

function logDateValue1(date: Date) { // 不可空
  console.log(date.valueOf())
}

logDateValue1(new Date)
logDateValue1() // 报错 An argument for 'date' was not provided.

function logDateValue2(date: Date | undefined) { // 可空
  console.log(date.valueOf()) // 报错 Object is possibly 'undefined'.
}

logDateValue2(new Date)
logDateValue2()

在这种情况下 TypeScript 会要求你先对这个值进行判断,排除其为 undefined 可能性。这就要说到 TypeScript 的另外一项特性 —— 其基于控制流的类型分析。例如在你使用 if 对变量进行非空判断后,在 if 之后的花括号中这个变量就会变成非空类型:

function print(str: string | null) {
  // str 在这里的类型是 string | null
  console.log(str.trim()) // 报错 Object is possibly 'null'.
  if (str !== null) {
    // str 在这里的类型是 string
    console.log(str.trim())
  }
}

同样的类型分析也发生在使用 if、switch 等语句对并集类型进行判断时:

interface Rectangle {
  kind: 'rectangle'
  width: number
  height: number
}

interface Circle {
  kind: 'circle'
  radius: number
}

function area(s: Rectangle | Circle) {
  // s 在这里的类型是 Rectangle | Circle
  switch (s.kind) {
    case "rectangle":
      // s 在这里的类型是 Rectangle
      return s.height * s.width
    case "circle":
      // s 在这里的类型是 Circle
      return Math.PI * s.radius ** 2;
  }
}

仅仅工作在编译阶段

TypeScript 最终仍然会编译到 JavaScript,再被 JavaScript 引擎(如 V8)执行,在生成出的代码中不会包含任何类型信息,TypeScript 也不会添加任何与运行时行为有关的功能。

TypeScript 仅仅提供了类型检查,但它并没有去保证通过检查的代码一定是可以正确运行的。可能一个变量在 TypeScript 的类型声明中是一个数字,但并不能阻止它在运行时变成一个字符串 —— 可能是使用了强制类型转换或使用了其他非 TypeScript 的库且类型定义文件有误。

在 TypeScript 中你可以将类型设置为 any 来绕过几乎所有检查,或者用 as 来强制「转换」类型,当然就像前面提到的那样,这里转换的仅仅是 TypeScript 在编译阶段的类型标注,并不会改变运行时的类型。虽然 TypeScript 设计上要去支持 JavaScript 的所有范式,但难免有一些极端的用例无法覆盖到,这时如何使用 any 就非常考验开发者的经验了。

编程语言的类型系统总是需要在灵活和复杂、简单和死板之间做出权衡,TypeScript 则给出了一个完全不同的答案 —— 将编译期的检查和运行时的行为分别看待。这是 TypeScript 饱受争议的一点,有人认为这样非常没有安全感,即使通过了编译期检查在运行时依然有可能得到错误的类型,也有人认为 这是一个非常切合工程实际的选择 —— 你可以用 any 来跳过类型检查,添加一些过于复杂或无法实现的代码,虽然这破坏了类型安全,但确实又解决了问题

那么这种仅仅工作在编译阶段类型检查有意义么?我认为当然是有的,毕竟 JavaScript 已经提供了足够使用的运行时行为,而且要保持与 JavaScript 的互操作性。大家需要的只是 TypeScript 的类型检查来提高开发效率,除了编译阶段的检查来尽早发现错误以外,TypeScript 的类型信息也可以给编辑器(IDE)非常准确的补全建议。

与 JavaScript 代码一起工作

任何基于 JavaScript 的技术都要去解决和标准 JavaScript 代码的互操作性 —— TypeScript 不可能创造出一个平行与 JavaScript 的世界,它必须依赖社区中已有的数十万的 JavaScript 包。

因此 TypeScript 引入了一种类型描述文件,允许社区为 JavaScript 编写类型描述文件,来让用到它们的代码可以得到 TypeScript 的类型检查。

描述文件的确是 TypeScript 开发中最大的痛点,毕竟只有当找全了定义文件之后,才会有流畅的开发体验。在开发的过程中不可避免地会用到一些特定领域的、小众的库,这时就必须要去考虑这个库是否有定义文件、定义文件的质量如何、是否需要自己为其编写定义文件。对于不涉及复杂泛型的库来说,写定义文件并不会花太多时间,你也只需要给自己用到的接口写定义,但终究是一个分心的点。

小结

TypeScript 有着先进的类型系统,而且这个先进并不是「学术」意义上的先进,而是「工程」意义上的先进,能够切实地提高开发效率,减轻动态类型的心理负担,提前发现错误。所以在此建议所有的 JavaScript 开发者都了解和尝试一下 TypeScript,对于 JavaScript 的开发者来说,TypeScript 的入门成本非常低。

在 LeanCloud,控制台在最近的一次的重构中切换到了 TypeScript,提高了前端项目的工程化水平,让代码可以被长时间地维护下去。同时我们一部分既有的基于 Node.js 的后端项目也在切换到 TypeScript。

LeanCloud 的一些内部工具和边缘服务也会优先考虑 TypeScript,较低的学习成本(谁没写过几行 JavaScript 呀!)、静态类型检查和优秀的 IDE 支持,极大地降低了新同事参与不熟悉或长时间无人维护的项目的门槛,提高大家改进内部工具的积极性。

LeanCloud 的 JavaScript SDK、Node SDK 和 Play SDK 都添加了 TypeScript 的定义文件(并且打算在之后的版本中使用 TypeScript 改写),让使用 LeanCloud 的开发者可以在 TypeScript 中使用 SDK,即使不用 TypeScript,定义文件也可以帮助编辑器来改进代码补全和类型提示。

如果你也希望一起来完善这些项目,可以了解一下在 LeanCloud 的 工作机会

参考资料:

12381

精子生于 1995 年,英文 ID jysperm.

订阅推送

通过邮件订阅精子的博客日志、产品和项目的最新动态,精子承诺每一封邮件都会认真撰写(历史邮件),有想和精子说的话也可以直接回复邮件。

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-ND  授权发布。最后生成于 2021-01-16.