<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>王子亭</name>
    <email>jysperm@gmail.com</email>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <icon>https://www.gravatar.com/avatar/c67a26a7cbd8b806b85330503115871a</icon>
  <id>https://jysperm.me/</id>
  <link href="https://jysperm.me/" rel="alternate"/>
  <link href="https://jysperm.me/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, 王子亭</rights>
  <title>王子亭的博客</title>
  <updated>2026-05-01T15:13:06.179Z</updated>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="年度小结" scheme="https://jysperm.me/tag/summary-of-year/"/>
    <category term="蛋黄" scheme="https://jysperm.me/tag/bud/"/>
    <content>
      <![CDATA[<p>今年 3 月我和蛋黄一起去了泰国旅行，算是我们两个第一次在国外「自由行」，我们先去拜访了之前一起在番茄土豆工作的朋友们，他们热情地招待了我们，带我们在清迈玩了三天，参观了他们的办公室，还体验了泰式按摩、一起玩狼人杀、爬山。之后我们又去了普吉岛，感受沙滩和大海，在岛上骑摩托车。</p><p>在经过几年稳定的工作和疫情封控之后，这是我第一次去体会不一样的生活方式 —— 也许除了在国内一线城市扎根，或者移民发达国家，还可以适当地放弃一些执念，来获得一个更简单的生活。经过这么多年，我也意识到了自己并不是一个喜欢竞争的人，更想要也更适合一个「低竞争」的环境。这时我感觉自己仿佛站在人生的分叉口，要不要考虑去到中国的二线城市或者甚至泰国生活呢？但蛋黄还是很简单地把我劝了回来 —— 毕竟去了加拿大以后还可以后悔回来，但现在躺平的话后悔可就去不了加拿大了。</p><p>9 月时我们还去了日本旅行，因为一直以来都在通过各种影视和游戏作品了解日本的生活习惯和文化，所以日本可能是我们最熟悉的国家了，到日本一下飞机果然是这种感觉 —— 真就和我们印象中的日本一模一样。我们在日本去了环球影城的任天堂园区、任天堂商店、藤子·F·不二雄博物馆、秋叶原，哆啦 A 梦和马里奥这些我们所熟知的形象在日本真的是无处不在。日本由新干线、电车和地铁构成的交通网络非常发达，没有安检、换乘方便、区分快慢车，很多商圈也是以一个电车站为中心，但价格确实不便宜，我们在日本总共竟然花了 1200 人民币来乘坐地铁和电车</p><p>在日本我们也拜访了一位年初才移居日本的前同事，他们距离东京市中心乘电车大概一个半小时，带我们逛了他们所在的居民区和超时。这里和新宿这样的商业区完全不同，密度比较低，街道非常干净整洁，就像是很多影视作品里的日本小镇。</p><p>在经历了 <a href="/2022/12/summary-of-2022">去年的疫情封控</a> 之后，我便下定决心要将我们之前移居加拿大的计划提上日程，并以最高的优先级来执行。在经过了 2022 下半年的调研和准备后，我们决定由蛋黄申请留学签证，我作为配偶申请开放工签的方式移居加拿大。在我们准备签证的过程中，因为公司的一些变化，工作给我带来的压力和不适越来越大，所以在提交了我的签证申请之后，我马上就 <a href="/2023/08/leaving-leancloud/">离开了 TapTap 和 LeanCloud</a>，准备休息一段时间，然后准备搬家。</p><p>在离职之后的这段时间，我开发并发布了 <a href="https://jysperm.me/2023/11/introducing-hostedbeans/">HostedBeans</a>。这是我作为独立开发者的第一个尝试，我希望能利用上之前那么多年的工程经验，设计和开发一些体量较小，但能带来长期价值的产品，也能从中获得收入。</p><p>本来我们都已经买了 11 月 11 日的机票，准备带着皮蛋豆腐和所有东西一起搬到加拿大。然而到 9 月时我们发现，我的签证进入到了一个通常耗时半年以上的一个安全审查（Security Screening）的流程中，而蛋黄的开学时间也没法再推迟了。</p><p>其实在一开始我们就确定了两个人一定要一起出发，不能分开。但现在我们几乎已经走到了最后一步，安全审查除了等待时间比较长，几乎不可能拒签，难道要在这个时候放弃么？我很清楚这段时长并不确定的异地并不是我想要的，会给我们的关系带来很大的考验。但所有的朋友都觉得不能错过这个机会，理性上当然应该这样做，所以蛋黄在 12 月 25 日一个人出发去了加拿大，在 2023 的年底，我们暂时分开。</p><p><img src="/pictures/2023/2023-world-map.png"></p>]]>
    </content>
    <id>https://jysperm.me/2023/12/summary-of-2023/</id>
    <link href="https://jysperm.me/2023/12/summary-of-2023/"/>
    <published>2023-12-30T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>今年 3 月我和蛋黄一起去了泰国旅行，算是我们两个第一次在国外「自由行」，我们先去拜访了之前一起在番茄土豆工作的朋友们，他们热情地招待了我们，带我们在清迈玩了三天，参观了他们的办公室，还体验了泰式按摩、一起玩狼人杀、爬山。之后我们又去了普吉岛，感受沙滩和大海，在岛上骑摩托车]]>
    </summary>
    <title>2023 年度小结</title>
    <updated>2026-05-01T15:13:06.179Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <content>
      <![CDATA[<p>我从 2019 年开始使用 <a href="https://github.com/beancount/beancount">Beancount</a> 记账，作为一个开发者，我非常喜欢这样的纯文本工具 —— 纯文本意味着我可以使用我最熟悉的编辑器来编辑它、可以使用脚本进行批处理，还可以使用 Git 来进行版本控制，最大程度地利用我熟悉的工具。</p><p>在和蛋黄在一起之后，我们的资金和帐目不可避免地混在了一起，如果不把她产生的帐目一起记录进来，那么记账这个事情就显得没有什么意义了。但 Beancount 对于非开发者来说使用门槛还是太高了，需要在本地配置 Python、Git 等环境，前面提到的那样纯文本的优势对她来说其实是一种负担。所以她之前一直很难参与进来，即使只是查看 Fava 的图表，也需要我先在我的电脑上启动 Fava，然后把地址发给她。</p><p>为了让蛋黄至少能够随时查看图表，我调研了几个现有的 Beancount 托管服务，但我觉得它们都缺少了一些关键的能力：</p><ul><li>Git 访问：纯文本是 Beancount 最大的特点，即使使用托管服务也不应该影响使用 Git 和本地编辑的工作流。</li><li>多人协作：可以让多人共同访问一个账本，在 Web UI 对账本的修改也应该反应到 Git 中。</li></ul><p>于是我开发了 HostedBeans 这个项目 —— 基于 Beancount 的协作托管服务，同时支持 Git 和 Fava 访问，可以为账本添加协作者并管理他们的权限，在 Web UI 上的改动也会以协作者的名字被自动同步进 Git。</p><p>使用 HostedBeans 你可以保持之前的工作流不变，在本地使用你熟悉的编辑器、使用 Git 进行版本控制，但同时解锁了随时随地打开 Fava 的能力，而且可以和你的家人共享这种能力，让他们参与到你的记账中来。</p><p>目前 HostedBeans 的功能已经全部可用，我还在继续进行一些细节调整来改善体验。如前面所说，HostedBeans 的目标是 Beancount 和 Fava 的托管服务，我今后我专注在「托管」这个方向上，不会添加「私有」的功能来锁定用户，确保用户可以随时迁移出来。比如 HostedBeans 不会自己实现任何实际的「功能」，而是会接入 Beancount 和 Fava 社区既有的插件，如有必要会直接在上游的开源项目中来开发功能。</p><p>欢迎访问 <a href="https://www.hostedbeans.io/">www.hostedbeans.io</a> 注册帐号试用，我们的免费版本可以让你创建一个私有的账本并和其他一名协作者一起记账。如有疑问或者建议欢迎通过邮件、Telegram 或者 Twitter 联系我，联系我还可以免费获赠 Pro 帐号。</p><blockquote><p>本文原载于 <a href="https://www.hostedbeans.io/docs/introducing">HostedBeans 的文档页面</a></p></blockquote>]]>
    </content>
    <id>https://jysperm.me/2023/11/introducing-hostedbeans/</id>
    <link href="https://jysperm.me/2023/11/introducing-hostedbeans/"/>
    <published>2023-10-31T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>我从 2019 年开始使用 <a href="https://github.com/beancount/beancount">Beancount</a> 记账，作为一个开发者，我非常喜欢这样的纯文本工具 —— 纯文本意味着我可以使用我最熟悉的编辑器来编辑它、可以使用脚本进行]]>
    </summary>
    <title>HostedBeans: 基于 Beancount 的协作托管服务</title>
    <updated>2023-12-08T13:14:20.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <content>
      <![CDATA[<p>从我 2015 年 <a href="https://jysperm.me/2015/11/join-leancloud">加入 LeanCloud</a> 已经 7 年多了，这是我人生中非常重要的一段时间，第一次独居、和蛋黄恋爱结婚、辗转三个城市、有了自己的积蓄、新冠疫情和封控，都发生在这期间。</p><p>我在 LeanCloud 一直在做 <a href="https://www.leancloud.cn/engine/">云引擎</a>，加入公司两年后我就成为了这个产品的负责人。实际上在之前我就对后端托管很感兴趣，在 LeanCloud 这些年也是 Serverless 飞速发展的一段时间，我深度地参与到了云引擎的每一个方面 —— 无论是产品的设计、项目的管理，还是后端开发和运维这样的技术工作，尽我所能实现了这样一个适合于 LeanCloud 的产品形态，我也很高兴公司能在这个产品上给予我非常大的「自由发挥」的空间。</p><p>在这样一个技术主导、时间跨度接近十年的项目中，我得到了一个非常好的舞台去实践我的软件工程知识 —— 在开发功能的同时，我不断地在用我最新学到的知识来进行重构，为一些脆弱的实现寻找彻底的解决方案、降低代码的复杂度，用各种不同的方式来编写测试、文档、提升可观测性。同事们也都认可我在这些方面的努力，与大家的讨论也总是会给我带来新的想法。</p><p>江宏将 Google 的「工程师文化」带到了 LeanCloud，构建了一个对内透明、能够进行坦诚沟通的工作环境，并坚持执行了这么多年，实属不易。这样独特的文化也聚集了一群聪明、专业的同事。因为我的上一家公司番茄土豆同样非常独特，所以其实我是很晚才意识到 LeanCloud 的特别之处，意识到能和这样的同事一起工作是多么难得的事情。</p><p>在被收购后我在很努力地了解大公司工作的方式，但大公司终究难以兼容之前的团队文化，并有着它自身的「大公司问题」，随着业务重心和管理风格的变化、很多一起工作多年的同事的离开，这份工作逐渐变得不那么特别了。</p><p>同时，这么长时间地在一份工作上也让我在业余时间少了很多创作的灵感。因为工作本身就会占用大量精力，加上工作中的开发有着更强、更确定的正向反馈（其中最大的当然是按时间计算的工资），导致相比之下更难在业余项目上集中注意力，有点类似于 <a href="https://en.wikipedia.org/wiki/Overjustification_effect">Overjustification effect</a> 中所描述的，因为外部激励过强影响到了人内在的兴趣。</p><p>所以我觉得是时候离开寻求一些变化了，接下来我会休息一段时间，然后和蛋黄一起去加拿大学习、工作，之后的一段时间对我们来说应该会充满未知与挑战。</p>]]>
    </content>
    <id>https://jysperm.me/2023/08/leaving-leancloud/</id>
    <link href="https://jysperm.me/2023/08/leaving-leancloud/"/>
    <published>2023-08-11T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>从我 2015 年 <a href="https://jysperm.me/2015/11/join-leancloud">加入 LeanCloud</a> 已经 7 年多了，这是我人生中非常重要的一段时间，第一次独居、和蛋黄恋爱结婚、辗转三个城市、有了自己的积蓄、新冠疫情]]>
    </summary>
    <title>离开 LeanCloud</title>
    <updated>2023-12-20T09:06:11.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <content>
      <![CDATA[<p>在最近几年，我发现自己渐渐买齐了差不多所有的 Apple 设备，我使用 MacBook Pro 在写这篇文章、工作时使用 Mac mini、手机是 iPhone、手上戴着 Apple Watch、使用 AirPods 听播客、在床上用 iPad mini 看视频，蛋黄同样也有自己的 iPhone、MacBook Air、iPad Pro 和 AirPods，我们家里还有一个 HomePod mini。</p><h2 id="数据安全与隐私"><a href="#数据安全与隐私" class="headerlink" title="数据安全与隐私"></a>数据安全与隐私</h2><p>我选择 Apple 的第一个理由是数据安全，iPhone 和 Mac 都是在业界率先普及全盘加密，并在近些年开始使用硬件安全隔区（Secure Enclave）强制加密的。就像 Apple 的很多功能一样，因为其对软硬件的垂直整合能力，Apple 可以在其所有品类的设备上，提供水平相当的数据安全保证。Apple 也在其在线服务中尽可能使用端到端加密，iMessage、健康、家庭等功能此前已经是端到端加密，2023 年起包括备份、文件、照片、备忘录、iMessage 聊天记录在内的几乎所有 iCloud 服务也都支持了端到端加密（Advanced Data Protection）。</p><p>在隐私保护方面，Apple 也是最特别的一家厂商 —— Apple 八成以上的收入来自于消费级硬件的销售，这也是 Apple 唯一的主营业务。这意味着 Apple 的商业模式决定了，相比于主要收入来自于广告和增值服务的互联网公司、相比于消费者业务仅占收入一小部分的软件公司，Apple 更有可能与消费者站在一起。</p><p>因此 Apple 的设备上你几乎见不到广告，Apple 也不会帮助第三方收集行为数据和投放广告，在互联网公司千方百计地希望你将数据上传到云端时，Apple 则是唯一坚持设备端机器学习的厂商 —— 将（明文的）数据留在设备端，使用设备端的机器学习芯片来提供 AI 能力。</p><p>我知道在一些人看来 Apple 不配于谈隐私，如果注重隐私的话应该使用能够自己掌控所有数据的开源方案。但我仍希望选择一家商业公司来获得更多便利和保证，Apple 则是其中最好的一个选择。</p><h2 id="软件生态"><a href="#软件生态" class="headerlink" title="软件生态"></a>软件生态</h2><p>对于大部分普通用户来说，Apple 最吸引人的是它的第一方软件生态，这些软件和系统、硬件有着深度的整合，有着非常不错的跨设备使用体验，也贯彻了前面提到的对数据安全和隐私的保护，以及 Apple 的对美学、工具和创造、社会责任和家庭等方面的追求。</p><p>照片可能是普通人在工作之外所产生的最多的「数据」了，Apple 在自己的生态内提供了一套无缝的照片管理体验。包括对 Raw 和 HDR 的支持、够用的照片编辑功能、对照片进行分类和搜索、用 AirDrop 或共享相册来分享照片等等。对照片的改动可以通过 iCloud 同步到所有设备上（包括手机、平板和电脑）；但你也可以选择不使用 iCloud，因为所有的功能（包括机器学习驱动的功能）都是可以本地使用的。</p><p>这种贯穿所有设备的一致体验在所有第一方应用上都有体现，比如我平时每天都会用到的 Safari、备忘录、邮件、地图、播客等应用，虽然相比于更专业的第三方应用，这些系统内建应用的功能都比较有限，但它们共同保证了 Apple 设备的体验下限。曾经摄像头对我来说也是「扫个二维码而已」，但正是因为 Apple 提供了这样的照片拍摄、整理、处理的体验，才让我在最近几年慢慢喜欢上了摄影。</p><p>Apple 对于第三方应用有着非常强的控制，或者换一个角度来说是 Apple 非常善于搭建一个生态让第三方应用能够接入其中，权衡哪些部分应该由系统控制，哪些应该由应用发挥。例如 CloudKit、MapKit、HomeKit、HealthKit 这些框架为第三方应用的开发提供了便利，让第三方应用之间能够互相配合工作，降低用户的学习和迁移成本。出现新的软件或硬件功能时，也可以让第三方软件自动或简单地接入。所以我觉得不能简单地把 Apple 的生态描述为「封闭」，设计一个受控制但又能发展出繁荣生态的系统是非常困难的。</p><p>对于开发者来说，macOS 上有着社区驱动的 Homebrew，我几乎所有的软件都是用 Homebrew 安装的，可以让所有软件无论是 CLI 工具、各类 SDK 和 Runime 还是 GUI 应用都处于管理之下，可以一键安装、升级或卸载。因为 Homebrew 主要被使用在开发环境而不是生产环境，所以它会类似一些滚动更新的 Linux 发行版，通常只对最新版本的软件提供支持，刚好符合我的需要。</p><p>对于独立开发者来说，Apple 生态有着简单的学习路径、清晰的盈利模式，也正是因为如此，Apple 平台上有着大量高质量的独立软件，充分发挥比如 iPad、Apple Watch、Apple TV 这样的设备形态的独特优势。</p><h2 id="硬件"><a href="#硬件" class="headerlink" title="硬件"></a>硬件</h2><p>Apple 在每个产品线上仅提供少量的 SKU，无论是不同产品线还是前后的两代产品，其设计都有着非常强的延续性。就我个人来说我更偏好这样有着清晰产品路线和规划的厂商，由厂商来对各种设计进行权衡，消费者只需做很少的选择。</p><p>因为 Apple 单个产品极大的出货量，Apple 是目前市场上为数不多能真正地按照自己的想法来决定产品的技术方向的公司 —— Apple 可以投入更多的资源去研发新技术，供应链的上下游也愿意配合 Apple 去将新技术落地。对于消费者来说，极大的出货量带来的好处则是更长的硬件支持时间、在遇到问题时更多的资料和配件，以及保值的二手价格。</p><p>实际上 Apple 也确实是在按照自己的标准在做硬件。你很难用其他厂商们都在竞争的屏幕刷新率、电池容量、相机像素数、内存容量去衡量 Apple 的产品，在很多配置参数上 Apple 确实并不占优，因为 Apple 的卖点始终都是包括软件功能在内的综合体验，硬件是为体验服务的，而不是为了与其他厂商竞争的。</p><p>不过作为一家公司，Apple 必须要让愿意花更多钱的客户能够有理由花更多的钱 —— 在 SKU 比较少的情况下，Apple 选择了用扩展内存和存储空间达到这一目的，这也是被很多人诟病的一点。</p><h2 id="垂直整合"><a href="#垂直整合" class="headerlink" title="垂直整合"></a>垂直整合</h2><p>前面聊了几个具体的方面，但如果说有一个最本质的原因导致了这所有的结果的话，我觉得就是「垂直整合」。所谓垂直整合就是指 Apple 一家公司同时完成硬件的设计和制造、操作系统和第一方应用的开发维护、提供 iCloud 这样的软件配套服务，最后还通过 App Store 控制着第三方软件生态。</p><p>这种垂直整合使得 Apple 能够对其产品在消费者手中的体验有着完全的控制，贯彻一致的产品设计逻辑，同时也对所有的体验负责。因此 Apple 并不急于使用新的技术去与其他厂商竞争，而是可以以自己的节奏进行打磨，推出系统性的解决方案（例如 Mac 到 ARM 的转型、未来的 visionOS）。</p><p>所以说 iPhone 的对手并不是 Android，而是每一个 Android 设备厂商；Mac 的对手也不是 Windows，而是每一个 Windows 设备厂商，而整个 Apple 在个人计算设备领域则完全没有相同体量的对手。</p>]]>
    </content>
    <id>https://jysperm.me/2023/08/why-i-use-apple-devices/</id>
    <link href="https://jysperm.me/2023/08/why-i-use-apple-devices/"/>
    <published>2023-08-09T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>在最近几年，我发现自己渐渐买齐了差不多所有的 Apple 设备，我使用 MacBook Pro 在写这篇文章、工作时使用 Mac mini、手机是 iPhone、手上戴着 Apple Watch、使用 AirPods 听播客、在床上用 iPad mini 看视频，蛋黄同样也]]>
    </summary>
    <title>为什么我选择 Apple 设备</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="年度小结" scheme="https://jysperm.me/tag/summary-of-year/"/>
    <content>
      <![CDATA[<p>2022 年疫情防控愈演愈烈，我们从三月开始经历了两个多月足不出户的严格封控。上海的封城可以说是非常严格，在一开始的半个月中物资供应受到了不小的影响，无法买到自己想要的食物，而除此之外的非生活必需品，更是直到六月解封才恢复正常。就在这样荒诞、焦虑和对未来的不确定的情绪中，我们浑浑噩噩地度过了这两个月。</p><p><img src="/pictures/2022/2022-pcr.png"></p><p>但好在封城的这段时间我下定了决心要把英语学好，相比于之前几年断断续续、晒网多过打渔的学习，今年虽算不上高强度，但至少是持续性地在学习。我大量地增加了英文内容的输入：全部改听英文播客、对于英文视频直接看原视频而不是翻译、阅读英文的技术书籍和博客。为了弥补我词汇量的短板开始改用英英字典，除了去查见到的每一个单词之外，也专门背了一些单词。我甚至在封城期间甚至报名了一个线上的英文课程，但随着封城的结束生活回到正规，也因为工作方面的变化，这个课程并没有上完。</p><p>封城结束后，上海则开始要求 72 小时的核酸阴性证明，我们也开始了差不多两天一次核酸检测的生活。在此之前我只在去年和公司一起去桂林时做过 2 次核酸检测，而在今年一年我们就做了超过 100 次的核酸检测。但即使是这样，因为奥密克戎极强的传染性，在接近年底时，「动态清零」变得越来越难以持续下去，终于在 12 月 7 日随着「新十条」的发布，接近三年的疫情防控措施划上了一个句号，大家很快地就回到了正常的生活轨迹。</p><p>在「新十条」发布后，我和蛋黄马上就计划了去沈阳的行程，在沈阳领了结婚证。因为我们彼此早已确认我们会一直生活在一起；因为无论是客观原因，还是我们自己都不希望婚姻是很多人说的「两个家庭的事情」 ，而就是我们两个人的事情；也因为我们都很清楚接下来我们会有更大的挑战要面对，所以结婚这件事更像是我们旅途上的一个小站点 —— 然后就在我还没来得及回味更多时，在沈阳的最后一天我开始发烧，第二天顶着高烧回到了家里，之后通过抗原检测 <a href="https://twitter.com/jysperm/status/1603908908523065344">确认了是新冠感染</a>，这趟行程是如此地匆忙，等回过神来已经到了 2023 年。</p><p>三年的疫情重塑了我们每个人对于「风险」的认知，有人会冒着隔离和封城的风险继续旅行，也有的人会选择留在熟悉的城市或稳定的工作。随着阅历的增加，我们能越来越全面地预料到一件事情的风险，或者说其实去做每件事情都是有风险的。总的来说我觉得我自己在这三年中表现得过于谨慎，而我本来不应该是这样一个畏首畏尾的人。</p><p>这一年工作上面也发生了很多变化，很多一起工作多年的同事离开了我们。在外部环境好的时候大家当然都很开心，但当大公司面临压力时，这种压力自上而下传导时就会走样，好不容易建立的文化也会很快地被破坏掉。总的来说这一年的工作让我觉得不是很有成就感，花费了很多时间在难以对自己、公司或社会产生价值的事情上。在接下来的一年中，我希望自己能将之前仍有维护价值的 side project 捡起来，同时在开始新的项目时能够快速试错，用上我这么多年积累的工程经验，看看能否在几年内实现我理想中的自由职业。</p><p>今年我购买了我的第一台可更换镜头的相机 EOS M6 Mark II 和几支镜头，也拍摄了很多照片。此前我的照片一直是放在 iCloud 上的，编辑、整理、同步的体验非常好，但随着照片接近 200G 的容量上限，我花了一些时间去 <a href="https://twitter.com/jysperm/status/1550888855305216003">寻找基于 NAS 的管理方案</a>。但这些方案和 Photos 的差距实在是太大，再加上 iCloud 发布了 Shared Photo Library 和 Advanced Data Protection，最后我还是选择了升到 2T 继续用，NAS 仅作为备份使用。在近几年我开始越来越喜欢拍照，除了每张照片都是一个创作的过程中之外，整个照片库也是一个有关数字化的回忆的作品，所以我一直很重视照片库的管理，也是为什么只有 Photos 能够达到我的需求。</p><p>此外今年还购买了我的第一个全面屏手机 <a href="https://twitter.com/jysperm/status/1574014265266122752">iPhone 13 mini</a> 和 Steam Deck，也遇到了 Persona 5 这样相见恨晚的游戏，算是这沉重的一年中的一点色彩。</p>]]>
    </content>
    <id>https://jysperm.me/2022/12/summary-of-2022/</id>
    <link href="https://jysperm.me/2022/12/summary-of-2022/"/>
    <published>2022-12-30T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>2022 年疫情防控愈演愈烈，我们从三月开始经历了两个多月足不出户的严格封控。上海的封城可以说是非常严格，在一开始的半个月中物资供应受到了不小的影响，无法买到自己想要的食物，而除此之外的非生活必需品，更是直到六月解封才恢复正常。就在这样荒诞、焦虑和对未来的不确定的情绪中，我]]>
    </summary>
    <title>2022 年度小结</title>
    <updated>2026-05-01T15:13:06.178Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <content>
      <![CDATA[<p>我参加过几十场技术面试，其中作为面试官的次数要远多于候选人。</p><p>说起来在我第一次做面试官之前，并没有人教过我应该怎么做，我则一直将面试视作通过一小时左右的沟通，对候选人形成一个整体的印象，最后给出一个主观的评价的过程。在这么多次的面试中，我也总结出了一些经验可以和大家分享。</p><p><strong>验证简历真实性</strong></p><p>首先花一些时间聊一聊简历上提到的项目，请对方进一步介绍这个项目的业务、自己在其中承担的职责和遇到的问题。然后针对其中自己了解的部分提几个问题，如「据我所知这类项目的难点是某某方面，请问你是否有遇到、是如何解决的」，确认对方的项目经验是否真实、是否比较深度地参与了项目。</p><p><strong>围绕简历提问</strong></p><p>尽量提问对方了解和擅长的话题，让对方有足够的表达机会，发挥出正常水平，考察候选人擅长的部分要比不擅长的部分更有价值。</p><p><strong>开放性问题</strong></p><p>避免问有标准答案的问题，而是可以问「遇到某种情况时可能是什么原因、你会怎么做」或「请解释一下某个事物是如何工作的」这样开放性的问题，给对方足够的发挥空间、主动提到一些自己的知识和经验。</p><p>也许有些候选人不喜欢这样的问题，认为缺少安全感、不知道该说什么，但我觉得开放性问题才有足够的区分度，才能在短时间内对候选人形成立体的印象。我们可以在候选人实在不知道从何说起时，对问题再做进一步的解释和提示。</p><p><strong>围绕话题由浅入深</strong></p><p>应该针对同一话题准备多个难度逐步增加的问题，让对方回答时思路能够相对流畅，更容易考察对方在这一话题上的深度；反过来应该避免大量零散、无联系、无难度区分的小问题。</p><p><strong>没必要执着于答案</strong></p><p>有时在反复提示下对方的答案仍差那么一点，在面试时间大多比较紧张的情况下，我们没必要花更多的时间去让对方答出答案。在候选人表示无法准确回答问题时，也可以引导他做出一些合理的猜测（这时的重点在于猜测是否合理而不是是否正确）。只要候选人在这一问题上说足够多的话，我们就可以考察到其水平了，不一定要等他说出最后的答案。</p><p><strong>自己不懂也没关系</strong></p><p>在一开始，我会担心问出的问题我自己也不了解，对方回答之后会把自己问倒或者无法考察对方水平。但后来发现完全没有必要，因为面试官没有义务即时地对候选人的回答做出评价（告诉对方对还是错），即使对于不了解的话题，自己也可以从对方的自信程度、逻辑层面，对回答有一个大体的判断的。</p><p><strong>现场编码可以很简单</strong></p><p>候选人在现场会非常紧张，面试时间也有限，在现场编码环节没必要出太难的题，重点在于考察候选人是否有最基本编程思维和编码能力。例如我觉得一些难度不高，但适合用递归解决的题目会比较合适，可以考虑在候选人编码的过程中一直保持沟通，注重过程而不是结果。</p><p>同时我反对为候选人在面试前或面试后布置编码的作业，这会让候选人付出不对等的时间去准备面试，也很容易从「考察候选人的能力」变成「考察候选人的诚意」。</p><p><strong>重复使用一套题库没什么问题</strong></p><p>可以自己建立自己的面试题库，在一次次面试中对其进行反复地打磨，让问题更准确、考察更全面。有些话题可能是永远都不过时的，例如对于后端工程师可能是并发模型及与之相关的线程、内存等话题。</p><p><strong>给对方提问的机会</strong></p><p>一般面试的最后一个步骤是让候选人提问，不要把这个过程敷衍掉，可以引导对方提问，告诉对方都可以问哪些问题。如可以补充一下没有提及的技术话题、可以问我司的技术架构或选型、可以问所面试的职位和如果入职之后所参与的项目、可以问日常工作环境和时间等。这样可以让对方有一个主动打开话题的机会，互相判断需求是否匹配，即使没有通过面试也让对方对公司有一个比较好的印象。</p><p><strong>有任何顾虑都可以 pass 掉</strong></p><p>我们前面做了这么多都是为了让候选人有更好的面试体验、发挥出自己最好的水平。这种情况下如果仍对候选人某一方面的表现有顾虑的话，应该果断地拒绝掉，招进来一个不合适的人损失远比错过一个合适的人大。</p><p><strong>沟通是否顺畅很重要</strong></p><p>沟通是后续开展一切工作的基础，如果觉得与候选人的沟通不顺畅，对方总是不能理解自己的意思，那么即使候选人的技术水平满足要求，也应该慎重考虑。</p><p><strong>留下面试的记录</strong></p><p>我每次面试结束后都会尽快开始写对候选人的评价，大多在一两百字，先列出面试聊到的内容、觉得好和不好的地方、了解到的对方的技术栈，然后总结一下对候选人技术水平和沟通情况的评价。这样可以帮助自己整理思路，在候选人比较多的时候也不会记混，最后给出通过与否的评价，同时还能给下一轮的面试官提供参考。</p>]]>
    </content>
    <id>https://jysperm.me/2022/05/technical-interview-tips/</id>
    <link href="https://jysperm.me/2022/05/technical-interview-tips/"/>
    <published>2022-05-01T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>我参加过几十场技术面试，其中作为面试官的次数要远多于候选人。</p>
<p>说起来在我第一次做面试官之前，并没有人教过我应该怎么做，我则一直将面试视作通过一小时左右的沟通，对候选人形成一个整体的印象，最后给出一个主观的评价的过程。在这么多次的面试中，我也总结出了一些经验可以]]>
    </summary>
    <title>如何进行技术面试（面试官视角）</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="Docker" scheme="https://jysperm.me/tag/docker/"/>
    <content>
      <![CDATA[<p>最近公司的很多同事都换上了搭载 M1 Pro 或 M1 Max 的新款 MacBook Pro，虽然日常使用的软件如 Chrome、Visual Studio Code 和 Slack 都已经适配得很好了，但面对 Docker 却犯了难。</p><p>众所周知，Docker 用到了 Linux 的两项特性：namespaces 和 cgroups 来提供隔离与资源限制，因此无论如何在 macOS 上我们都必须通过一个虚拟机来使用 Docker。</p><p>在 2021 年 4 月时，Docker for Mac（Docker Desktop）<a href="https://www.docker.com/blog/released-docker-desktop-for-mac-apple-silicon/">发布了</a> 对 Apple Silicon 的实验性支持，它会使用 QEMU 运行一个 ARM 架构的 Linux 虚拟机，默认运行 ARM 架构的镜像，但也支持运行 x86 的镜像。</p><p><img src="/pictures/2022/docker-for-mac.png"></p><p><a href="https://www.qemu.org/docs/master/about/index.html">QEMU</a> 是一个开源的虚拟机（Virtualizer）和仿真器（Emulator），所谓仿真器是说 QEMU 可以在没有来自硬件或操作系统的虚拟化支持的情况下，去模拟运行一台计算机，包括模拟与宿主机不同的 CPU 架构，例如在 Apple Silicon 上模拟 x86 架构的计算机。而在有硬件虚拟化支持的情况下，QEMU 也可以使用宿主机的 CPU 来直接运行，减少模拟运行的性能开销，例如使用 macOS 提供的 <code>Hypervisor.Framework</code>。</p><p>Docker for Mac 其实就是分别用到了 QEMU 的这两种能力来在 ARM 虚拟机上运行 x86 镜像，和在 Mac 上运行 ARM 虚拟机。</p><p>Docker for Mac 确实很好，除了解决新架构带来的问题之外它还对文件系统和网络进行了映射，容器可以像运行在本机上一样访问文件系统或暴露网络端口到本机，几乎感觉不到虚拟机的存在。但 LeanCloud 加入 TapTap 之后已经不是小公司了，按照 Docker Desktop 在 2021 年 8 月推出的 <a href="https://www.docker.com/blog/updating-product-subscriptions/">新版价格方案</a>，我们每个人需要支付至少 $5 每月的订阅费用。倒不是我们不愿意付这个钱，只是我想要找一找开源的方案。</p><p>之前在 Intel Mac 上，我们会用 Vagrant 或 minikube 来创建虚拟机，它们底层会使用 VirtualBox 或 HyperKit 来完成实际的虚拟化。但 VirtualBox 和 HyperKit 都没有支持 Apple Silicon 的计划。实际上目前开源的虚拟化方案中只有 QEMU 对 Apple Silicon 有比较好的支持，QEMU 本身只提供命令行的接口，例如 Docker for Mac 调用 QEMU 时的命令行参数是这样：</p><pre><code>/Applications/Docker.app/Contents/MacOS/qemu-system-aarch64 -accel hvf \-cpu host -machine virt,highmem=off -m 2048 -smp 5 \-kernel /Applications/Docker.app/Contents/Resources/linuxkit/kernel \-append linuxkit.unified_cgroup_hierarchy=1 page_poison=1 vsyscall=emulate \panic=1 nospec_store_bypass_disable noibrs noibpb no_stf_barrier mitigations=off \vpnkit.connect=tcp+bootstrap+client://192.168.65.2:61473/f1c4db329a4a520d73a79eaa1360de7be7d09948a1ac348b04c8e01f6f6eb2c9 \console=ttyAMA0 -initrd /Applications/Docker.app/Contents/Resources/linuxkit/initrd.img \-serial pipe:/var/folders/12/_bbrd4692hv8r9bx_ggw5kp80000gn/T/qemu-console1367481183/fifo \-drive if=none,file=/Users/ziting/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw,format=raw,id=hd0 \-device virtio-blk-pci,drive=hd0,serial=dummyserial -netdev socket,id=net1,fd=3 -device virtio-net-device,netdev=net1,mac=02:50:00:00:00:01 \-vga none -nographic -monitor none</code></pre><p>为了实际使用 QEMU 进行开发，我们需要一个使用上更友好的封装，能够自动配置好 Docker 和 Kubernetes（或者至少方便编写像 Vagrantfile 一样的脚本），提供类似 Docker for Mac 的网络映射和文件映射，于是我找到了 Lima。</p><p><a href="https://github.com/lima-vm/lima">Lima</a> 自称是 macOS 上的 Linux 子系统（macOS subsystem for Linux），它使用 QEMU 运行了一个 Linux 虚拟机，其中安装有 rootless 模式的 containerd，还通过 SSH 提供了文件映射和自动的端口转发。</p><p>但为什么是 containerd 而不是 Docker 呢？随着容器编排平台 Kubernetes 如日中天，社区希望将运行容器这个关键环节进行标准化，让引入 Docker 之外的其他容器运行时更加容易，于是 <a href="https://kubernetes.io/blog/2016/12/container-runtime-interface-cri-in-kubernetes/">推出了 Container Runtime Interface</a> (CRI)。containerd 就是从 Docker 中拆分出的一个 CRI 的实现，相比于 Docker 本体更加精简，现在也交由社区维护。</p><p>因此如 Lima 这样新的的开源软件会更偏好选择 containerd 来运行容器，因为组件更加精简会有更好的性能，也不容易受到 Docker 产品层面变化的影响。nerdctl 是与 containerd 配套的命令行客户端（<code>nerd</code> 是 <code>containerd</code> 的末尾 4 个字母），用法与 docker 或 docker-compose 相似（但并不完全兼容）。</p><p>所谓 <a href="https://rootlesscontaine.rs/">rootless</a> 则是指通过替换一些组件，让容器运行时（containerd）和容器都运行在非 root 用户下，每个用户都有自己的 containerd，这样绝大部分操作都不需要切换到 root 来进行，也可以减少安全漏洞的攻击面。</p><p>但我们希望能在本地运行完整的 rootful 模式的 dockerd 和 Kubernetes 来尽可能地模拟真实的线上环境，好在 Lima 提供了丰富的 <a href="https://github.com/lima-vm/lima/blob/master/pkg/limayaml/default.yaml">自定义能力</a>，我基于社区中的一些脚本（<a href="https://github.com/lima-vm/lima/blob/master/examples/docker.yaml">docker.yaml</a> 和 <a href="https://github.com/afbjorklund/lima/blob/minikube/examples/minikube.yaml">minikube.yaml</a>）实现了我们的需求，而且这些自定义的逻辑都被以脚本的形式写到了 yaml 描述文件中，只需一条命令就可以创建出相同的虚拟机。</p><pre><code>~ ❯ limactl start docker.yaml? Creating an instance &quot;docker&quot; Proceed with the default configurationINFO[0005] Attempting to download the image from &quot;https://cloud-images.ubuntu.com/focal/current/focal-server-cloudimg-arm64.img&quot;INFO[0005] Using cache &quot;/Users/ziting/Library/Caches/lima/download/by-url-sha256/ae20df823d41d1dd300f8866889804ab25fb8689c1a68da6b13dd60a8c5c9e35/data&quot;INFO[0006] [hostagent] Starting QEMU (hint: to watch the boot progress, see &quot;/Users/ziting/.lima/docker/serial.log&quot;)INFO[0006] SSH Local Port: 55942INFO[0006] [hostagent] Waiting for the essential requirement 1 of 5: &quot;ssh&quot;INFO[0039] [hostagent] Waiting for the essential requirement 2 of 5: &quot;user session is ready for ssh&quot;INFO[0039] [hostagent] Waiting for the essential requirement 3 of 5: &quot;sshfs binary to be installed&quot;INFO[0048] [hostagent] Waiting for the essential requirement 4 of 5: &quot;/etc/fuse.conf to contain \&quot;user_allow_other\&quot;&quot;INFO[0051] [hostagent] Waiting for the essential requirement 5 of 5: &quot;the guest agent to be running&quot;INFO[0051] [hostagent] Mounting &quot;/Users/ziting&quot;INFO[0051] [hostagent] Mounting &quot;/tmp/lima&quot;INFO[0052] [hostagent] Forwarding &quot;/run/lima-guestagent.sock&quot; (guest) to &quot;/Users/ziting/.lima/docker/ga.sock&quot; (host)INFO[0092] [hostagent] Waiting for the optional requirement 1 of 1: &quot;user probe 1/1&quot;INFO[0154] [hostagent] Forwarding TCP from [::]:2376 to 127.0.0.1:2376INFO[0304] [hostagent] Forwarding TCP from [::]:8443 to 127.0.0.1:8443INFO[0332] [hostagent] Waiting for the final requirement 1 of 1: &quot;boot scripts must have finished&quot;INFO[0351] READY. Run `limactl shell docker` to open the shell.INFO[0351] To run `docker` on the host (assumes docker-cli is installed):INFO[0351] $ export DOCKER_HOST=tcp://127.0.0.1:2376INFO[0351] To run `kubectl` on the host (assumes kubernetes-cli is installed):INFO[0351] $ mkdir -p .kube &amp;&amp; limactl cp minikube:.kube/config .kube/config</code></pre><p>我还发现了另外一个基于 Lima 的封装 —— <a href="https://github.com/abiosoft/colima">Colima</a>，默认提供 rootful 的 dockerd 和 Kubernetes，但 Colima 并没有对外暴露 Lima 强大的自定义能力，因此我们没有使用，但对于没那么多要求的开发者来说，也是一个更易用的选择。</p><p>在默认的情况下，Lima 中的 Docker 在 Apple Silicon 上只能运行 ARM 架构的镜像，但就像前面提到的那样，我们可以使用 QEMU 的模拟运行的能力来运行其他架构（如 x86）的容器。<code>qemu-user-static</code> 是一个进程级别的模拟器，可以像一个解释器一样运行其他架构的可执行文件，我们可以利用 Linux 的一项 <a href="https://en.wikipedia.org/wiki/Binfmt_misc">Binfmt_misc</a>（<a href="https://zh.wikipedia.org/wiki/Binfmt_misc">中文版</a>）的特性让 Linux 遇到特定架构的可执行文件时自动调用 <code>qemu-user-static</code>，这种能力同样适用于容器中的可执行文件。</p><p>社区中也有 <a href="https://dbhi.github.io/qus/">qus</a> 这样的项目，对这些能力进行了封装，只需执行一行 <code>docker run --rm --privileged aptman/qus -s -- -p x86_64</code> 就可以让你的 ARM 虚拟机魔法般地支持运行 x86 的镜像。</p><pre><code>/usr/bin/containerd-shim-runc-v2\_ /qus/bin/qemu-x86_64-static /usr/sbin/nginx -g daemon off;    \_ /qus/bin/qemu-x86_64-static /usr/sbin/nginx -g daemon off;    \_ /qus/bin/qemu-x86_64-static /usr/sbin/nginx -g daemon off;    \_ /qus/bin/qemu-x86_64-static /usr/sbin/nginx -g daemon off;    \_ /qus/bin/qemu-x86_64-static /usr/sbin/nginx -g daemon off;</code></pre><blockquote><p>使用 qus 运行 x86 镜像的进程树如上，所有进程（包括创建出的子进程）都自动通过 QEMU 模拟运行。</p></blockquote><p>回到题目中的问题，因为 Docker 依赖于 Linux 内核的特性，所以在 Mac 上必须通过虚拟机来运行；Apple Silicon 作为新的架构，虚拟机的选择比较受限，因为有些镜像并不提供 ARM 架构的镜像，所以有时还有模拟运行 x86 镜像的需求；Docker Desktop 作为商业产品，有足够的精力来去解决这些「脏活累活」，但它在这个时间点选择不再允许所有人免费使用；开源社区中新的项目都希望去 Docker 化，用 containerd 取代 dockerd，但这又带来了使用习惯的变化并且可能与线上环境不一致。因为这些原因，目前在 Apple Silicon 上安装 Docker 还是需要花一些时间去了解背景知识的，但好在依然有这些优秀的开源项目可供选择。</p><p>虽然 <a href="https://developer.taptap.com/product-intro/cloud-engine">云引擎</a> 也是基于 Docker 等容器技术构建的，但云引擎力图为用户提供开箱即用的使用体验而不必自己配置容器环境、编写构建脚本、收集日志和统计数据。如果想得到容器化带来的平滑部署、快速回滚、自动扩容等好处但又不想花时间配置，不如来试试云引擎。</p><p>其他参考资料：</p><ul><li><a href="https://www.tutorialworks.com/difference-docker-containerd-runc-crio-oci/">https://www.tutorialworks.com/difference-docker-containerd-runc-crio-oci/</a></li></ul>]]>
    </content>
    <id>https://jysperm.me/2022/02/install-docker-on-apple-silicon/</id>
    <link href="https://jysperm.me/2022/02/install-docker-on-apple-silicon/"/>
    <published>2022-02-17T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>最近公司的很多同事都换上了搭载 M1 Pro 或 M1 Max 的新款 MacBook Pro，虽然日常使用的软件如 Chrome、Visual Studio Code 和 Slack 都已经适配得很好了，但面对 Docker 却犯了难。</p>
<p>众所周知，Docke]]>
    </summary>
    <title>为什么在 Apple Silicon 上装 Docker 这么难</title>
    <updated>2026-05-01T15:13:06.178Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="年度小结" scheme="https://jysperm.me/tag/summary-of-year/"/>
    <content>
      <![CDATA[<p>2021 年新冠疫情仍然没有结束，我们甚至已经习惯了它的存在。</p><p>和蛋黄在昆山住了三年之后，今年最大的变化是我们搬到了上海。相比于之前在北京的一年，因为收入更高了也有条件在上海租更好的房子，离公司和地铁站都非常近，面积也并 <a href="https://twitter.com/jysperm/status/1405473824515051521">不比昆山的房子小太多</a>。</p><p>因为在走之前我有些舍不得我投入了这么多精力改造的家，直到搬走前三天我们才开始高强度地打包收拾。这次我们的物品达到了 <a href="https://twitter.com/jysperm/status/1402669620163584001">惊人的 1000 千克、7.9 立方米</a>，到了上海后又断断续续花了一个月收拾，买了沙发、桌子、电视、洗衣机、新的净水器，再次自己安装了窗帘杆，为了将洗碗机和洗衣机放到理想的位置做了不少改造，对于选择哪个房间做卧室（同时也决定了我们的桌子如何摆放）也纠结了很久。在又投入了这么多精力之后，我发现我也不会去想到昆山了，毕竟当下才是自己最理想的家。</p><p><img src="/pictures/2021/2021-shanghai-1.png"></p><p><img src="/pictures/2021/2021-shanghai-2.png"></p><p>上海的确有更多的地方可以逛、可以玩，但因为我周末总是下午才起床，所以我们不经常去比较远的地方。而是就像 2020 年一样，在夜里骑着电动车说走就走，走遍了家附近方圆七、八公里的区域。</p><p><img src="/pictures/2021/2021-shanghai-3.png"></p><p>新的环境也确实对我的情绪有一些改善。想到和蛋黄刚在一起的时候就说「重要的是我们在一起并不需要做出什么改变就可以很开心，而不一定要按照社会的期待去改变自己」，我得承认在一些事情上我没有做到，有时候会因为觉得大家都可以这样做而去要求蛋黄。但她却一直是这样做的，会包容我所有和其他人不一样的地方，从未要求我改变什么。</p><p>在工作方面最大的变化则是 LeanCloud 被心动收购，团队整体加入了 TapTap，办公地点也搬到了上海。公司被收购是一种非常独特的体验，整个过程充满了不确定性，自己和同事们也都在考虑各自后续的变化 —— 其实说起来那两个月都没什么心思工作。最后尘埃落定，加入一个新的公司时，你既是老员工，也是新员工，也有了更多的时间去审视自己与公司的关系。</p><p>我一直对于大公司非常抵触，我也绝对不会加入像阿里巴巴、拼多多、华为这样的公司。当公司规模大到老板无法知道每一个人在做什么的时候，就会开始引入组织架构。这种变化一方面带来了效率的降低 —— 部门间的利益和公司的利益不一定是一致的，需要大量的管理来「对齐」；另一方面也带来了一种系统性的压迫 —— 公司与员工的力量是如此地悬殊，制定和执行规则的人也与员工离得更远，他们会充分利用合同和协议将风险全部转移到员工身上，在这里要感谢我之前的两家公司从未让我有过这样的感觉。</p><p>心动的 CEO <a href="https://twitter.com/DashHuang">黄一孟</a> 在社交媒体上很活跃，也曾分享过心动使用 Slack 和 Confluence 实现内部透明，TapTap 的「离职致意金」和无限假期等政策，尤其后两者以我的理解是在倒逼中层管理人员来提高管理水平，及时辞退不合适的员工。且不论结果如何，对于这种解决「大公司问题」的尝试我是认可的，也是心动不同于同等规模公司的地方。</p><p>今年下半年受邀参与了几个智能合约项目的代码审计，虽然实际完成的工作很少，但也算是补习了近几年智能合约和 DeFi 的发展。可以看到现在已经很少有人再去质疑 Bitcoin 或者 Ethereum 的意义了，而是将焦点放在了 DeFi 和 NFT 上，这说明了整个密码货币产业还在不断在向前发展，我几年前曾写过 <a href="https://jysperm.me/2017/06/why-i-trade-bitcoin/">一篇文章</a> 说比特币是一场实验，那么我觉得在今天这个时间点可以说这场实验已经成功了。</p><p><img src="/pictures/2021/2021-cryptocurrency.png"></p><blockquote><p>图为我持有的所有密码货币在 2021 年的波动</p></blockquote><p>继软路由和 NAS 之后，今年投入了一些时间搭建 <a href="https://twitter.com/jysperm/status/1450448737545834499">基于 Home Assistant 的智能家居</a>，花的精力不算太多，主要是将部分受支持的米家设备接入了操作体验更好的 HomeKit。</p><p>今年 <a href="https://twitter.com/jysperm/status/1477688573470015489">和蛋黄一起玩了双人成行</a>、底特律：成为人类 和 DYSMANTLE，不同于之前引导蛋黄玩游戏的尝试，这三部作品都是素质过硬且适合双人游玩的游戏，我们也都通关了这三部作品。</p>]]>
    </content>
    <id>https://jysperm.me/2021/12/summary-of-2021/</id>
    <link href="https://jysperm.me/2021/12/summary-of-2021/"/>
    <published>2021-12-30T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>2021 年新冠疫情仍然没有结束，我们甚至已经习惯了它的存在。</p>
<p>和蛋黄在昆山住了三年之后，今年最大的变化是我们搬到了上海。相比于之前在北京的一年，因为收入更高了也有条件在上海租更好的房子，离公司和地铁站都非常近，面积也并 <a href="https://tw]]>
    </summary>
    <title>2021 年度小结</title>
    <updated>2026-05-01T15:13:06.184Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="Node.js" scheme="https://jysperm.me/tag/nodejs/"/>
    <content>
      <![CDATA[<p><a href="https://deno.land/">Deno</a> 一出生便带着光环 —— 它发布于 Node.js 创始人 Ryan Dahl 的演讲「<a href="https://www.youtube.com/watch?v=M3BM9TB-8yA">Design Mistakes in Node</a>（<a href="https://tinyclouds.org/jsconf2018.pdf">幻灯片</a>）」，当时有些人说 Node.js 要凉了，但我不这么认为。</p><h2 id="原生-TypeScript"><a href="#原生-TypeScript" class="headerlink" title="原生 TypeScript"></a>原生 TypeScript</h2><p>其实目前我们在引擎的「用户态」去使用 TypeScript 并没有引入任何问题，而且给用户带来了很大的灵活性。考虑到 TypeScript 不可能离开 JavaScript 的生态 —— 毕竟引擎总是要支持 JavaScript 的；再加上 TypeScript 有不同的版本、不同的编译开关，在用户态使用 TypeScript 可以说是最好的方案了。TypeScirpt 迟早会成为 Deno 的历史包袱。</p><p>从性能的角度，在 TypeScript 没出现之前，V8 已经在 JavaScript 上进行大量 <a href="https://zhuanlan.zhihu.com/p/29638866">魔法优化</a> 了，可以说 JIT 出来的代码并不比其他静态类型的语言差太多，是没法简单地通过 TypeScript 来提升性能的。再加上前面说了引擎总还是要支持 JavaScript、TypeScript 的运行时语义依然是 JavaScript（TypeScript 并不能保证对象的实际类型在运行时不被修改），所以引擎也不可能从对 JavaScript 的魔法优化切换到基于 TypeScript 的类型来做优化。</p><h2 id="包管理器"><a href="#包管理器" class="headerlink" title="包管理器"></a>包管理器</h2><p>我一直认为 NPM 是最好用的包管理器之一，这包括将依赖保存在项目目录中 —— 在调整一个项目的依赖时不必担心对其他项目产生影响；每个包都可以指定自己的依赖版本，允许多版本并存 —— 在升级一个包的依赖时不会影响到其他包，每个包都可以使用新的版本或继续使用旧的版本；NPM 负责查找和安装包，而 Node.js 则用相对简单的协议去使用这些包，它们可以彼此独立地升级演进。</p><p>可以看到 NPM 最终极大地减轻了开发者的心智负担，只要你按照正确的方式去使用它，极少会遇到其他语言中有关依赖管理的问题。而 Deno 则反其道行之。虽然 Deno 也提供了一些相关的功能（<a href="https://deno.land/manual@master/linking_to_external_code/reloading_modules">deno cache</a>），但你会发现 Deno 的本意仍然是不希望进行「依赖管理」。</p><p>在代码中包含 URL 是一个非常糟糕的做法（Golang 也是如此），Deno 称之为去中心化，但其实它只是重新将使用包的代码与包的来源耦合在了一起（现在 Deno 提供了一个 <a href="https://deno.land/x">官方的代理</a>，但这样和 NPM 的中心仓库又有什么区别呢）。缓存机制也带来了相当大的不确定性：<code>package-lock.json</code> 可以保证每次安装的依赖是完全一致的，而 Deno 的 <a href="https://deno.land/manual@v1.10.2/linking_to_external_code/integrity_checking">lock.json</a> 只能检查依赖是否有变化（如果有的话就拒绝运行）。这使得开发者很难控制依赖更新的时机，<a href="https://deno.land/manual/linking_to_external_code#but-what-if-the-host-of-the-url-goes-down-the-source-won#39t-be-available">Deno 则建议将依赖缓存放入 Git</a>。</p><h2 id="内建权限系统"><a href="#内建权限系统" class="headerlink" title="内建权限系统"></a>内建权限系统</h2><p>一直以来通用编程语言都不曾在语言层面引入权限控制，但确实开源社区也曾报出过多次恶意代码的事件，但 Deno 的权限机制相当粗糙 —— 只能在进程级别进行权限控制，我可以大胆地预言，在几乎所有的场景里我们都需要 <code>--allow-all</code>，并不能对安全起到太多作用。</p><p>我们需要考虑 Deno 的用户到底是开发者还是使用者：对于 Deno 脚本的使用者来说关注的当然是进程级别的权限；而对于开发者我认为更关注的是第三方包的权限，权限系统应该以包为单位（然而 Deno 里并没有包的概念了），Node 里本来也有 vm 模块可以一定程度上实现沙盒（但确实非常难以控制）。</p><p>而且说起来我们现在已经有了 Docker（或者更广泛的容器的概念）这种彻底的隔离和权限控制机制，业界对编程语言引入一套权限控制已经没有太大的需求了。</p><h2 id="孤立的生态"><a href="#孤立的生态" class="headerlink" title="孤立的生态"></a>孤立的生态</h2><p>可以说 JavaScript 的生态来自于用户态类库的充分竞争，Deno 则在 Runtime API 之外提供了 Standard Library（类似 <code>golang.org/x</code>）、提供了全套的开发工具链（fmt、test、doc、lint、bundle），在试图提供开箱即用的使用体验的同时，也削弱了第三方生态。</p><p>在 Node.js 和 NPM 已然成为 JavaScript 事实标准的一部分的情况下，Deno 本来可以通过兼容 Node.js 或 NPM 有一个非常好的开场。但 Deno 却选择了和 Node.js 划清界限，而是兼容了一些浏览器环境的 API（如 prompt 或 onload）。</p><p>Deno 自己的说法是为了遵循已有的 Web 标准避免发明新东西，但实际上这些 Web 标准在设计时并未充分考虑浏览器之外的 Runtime，况且 Deno 其实也没能避免发明新东西（这些新东西被放在了 Deno 这个命名空间中）。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>Deno 就是这样一个有着非常鲜明个人偏好的 JavaScript Runtime，它试图去纠正 Node.js 的一些「设计失误」、希望给出一种「JavaScript 最佳实践」，希望提供高质量且开箱即用的标准库和工具链。这些偏好的选择总会有人喜欢或不喜欢，但除此之外 Deno 实在是缺少一个 killer feature（杀手级特性）让一个「理性」的 Node.js 开发者（如一个公司）切换到 Deno。</p><p>通过单一文件发行、进程级别的权限控制使 Deno 会更适合命令行工具的开发，但能否与已经广泛用于命令行工具的 Golang 竞争尚且存疑。</p><p>作为一个 Node.js 开发者，我并不觉得 Deno 可以在未来替代 Node 成为我的主力开发工具，Deno 更像是 Golang 的设计哲学对 JavaScript 的一次入侵。</p>]]>
    </content>
    <id>https://jysperm.me/2021/05/we-dont-need-deno/</id>
    <link href="https://jysperm.me/2021/05/we-dont-need-deno/"/>
    <published>2021-05-26T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p><a href="https://deno.land/">Deno</a> 一出生便带着光环 —— 它发布于 Node.js 创始人 Ryan Dahl 的演讲「<a href="https://www.youtube.com/watch?v=M3BM9TB-8yA">De]]>
    </summary>
    <title>我们并不需要 Deno</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="购物" scheme="https://jysperm.me/tag/shopping/"/>
    <content>
      <![CDATA[<p>最近两年几乎买齐了苹果的全线产品，越来越看好苹果，甚至买了一些苹果的股票，当然为什么选择苹果生态这个话题可以放在单独的文章里来说。</p><blockquote><p>我在 Small talk 的第二期「<a href="https://1byte.io/small-talk-e2-apple-silicon/">聊聊用 M1 芯片的新 Mac</a>」中也聊到了 Apple Silicon 的话题，欢迎大家收听，但这期播客录制时间较早，如有冲突还是以本文为准。</p></blockquote><h2 id="第一体验"><a href="#第一体验" class="headerlink" title="第一体验"></a>第一体验</h2><p>在 11 月 17 日的发布会后我又观望了一周才下单，最后在 12 月 4 日拿到了搭载 <a href="https://www.apple.com.cn/newsroom/2020/11/apple-unleashes-m1/">M1 处理器</a> 的 MacBook Air，我将内存升级到了 16G ，存储则还是低配的 256G。</p><p><img src="/pictures/2021/macbook-order.png"></p><p>选择这一款是因为从测评来看 Air 和 Pro 的性能差别并不显著，也不想为了 Touch Bar 和屏幕亮度支付额外 2000 元的价格，不如把这个钱加到内存上。</p><p>说到内存，新的 Mac 使用了「统一内存架构（UMA）」，可以消除 CPU 和显卡等专用计算单元之间的内存拷贝，既提高了速度，又减少了内存使用。一些朋友表示 8G 的内存对于不开虚拟机的中度使用也非常够用，但相信硬件的提升很快就会被软件消化，如果你希望新的 Mac 有一个比较长的使用周期，还是建议升到 16G 内存。对于新的 Mac 来说最高也只能选配 16G 内存，据说是因为总线 IO 的瓶颈，只有 2 个雷电接口也是这个原因。</p><p>至于存储空间，我属于最低的存储空间都够用的那一类人 —— 我希望设备上有最高性能的本地存储，但我并不会用这么昂贵的空间去存储冷数据，毕竟我才刚刚花了大功夫 <a href="https://zhuanlan.zhihu.com/p/273394399">自己搭了一个 NAS</a>。</p><p>拿到 MacBook Air 开始，最亮眼的还是发热和续航的表现，我偶尔会把 MacBook 放在腿上使用，之前的 Intel MacBook 十几分钟就会觉得烫，而 M1 Mac 则在日常使用时几乎感觉不到温度，在 CPU 跑满的情况下温热，只有 CPU 和 GPU 同时跑满才会有烫的感觉。相应地，M1 的续航表现也非常亮眼，后面的性能测试中会有详细的说明。</p><p>然后把它和我们家其他的 Mac 对比一下跑分，果然是用最低的价格提供了最高的分数：</p><table><thead><tr><th>Mac</th><th>Air (M1)</th><th>Pro (2020)</th><th>Pro (2017)</th><th>mini (2018)</th></tr></thead><tbody><tr><td>CPU</td><td>M1</td><td>I5-1038NG7</td><td>I5-7360U</td><td>I7-8700B</td></tr><tr><td>GPU</td><td>7-Core</td><td>Iris Plus</td><td>Iris Plus 640</td><td>Intel UHD 630</td></tr><tr><td>Memory</td><td>16G</td><td>16G</td><td>8G</td><td>16G</td></tr><tr><td>Geekbench SC</td><td>1678</td><td>1136</td><td>852</td><td>1117</td></tr><tr><td>Geekbench MC</td><td>7225</td><td>4237</td><td>2020</td><td>5621</td></tr><tr><td>Geekbench Metal</td><td>19138</td><td>8498</td><td>4930</td><td>3776</td></tr><tr><td>Price</td><td>¥9499</td><td>¥14499</td><td>¥11888</td><td>¥11909</td></tr></tbody></table><blockquote><p>数据来自 everymac.com 和 geekbench.com</p></blockquote><p>其中 MacBook Pro 2020 是我 2020 年初时购买的最后一代 Intel MacBook，使用第十代 i5，倒是没什么问题，只是目前来看就买得实在太亏了；MacBook Pro 2017 是蛋黄一直在用，最近她开始学习 Swift 就一直在吐槽电脑实在太慢了，同时电池也进入了待维护状态；Mac mini 2018 是我目前工作用的电脑，当时虽然选了最高配的 i7 CPU，但没考虑到 Intel UHD 630 的性能实在太差了，即使我只是接了一块 4k 屏，系统的界面响应就已经非常卡顿了，现在 GPU 成为了整台电脑的瓶颈。</p><h2 id="ARM-生态"><a href="#ARM-生态" class="headerlink" title="ARM 生态"></a>ARM 生态</h2><p>应该说这次从 x86 到 ARM 的切换比我想象中的要顺利，苹果的第一方应用和 masOS 独占的应用都第一时间进行了适配，其他没有适配的应用则可以用 Rosetta 2 来运行。Rosetta 2 用起来是完全无感的，系统会自动将 x86 的应用以转译的方式来运行，无论是图形界面应用还是命令行的 binary 文件。性能上的差别对于大部分应用来说也并不明显，很多时候感觉不到自己是否使用了 Rosetta 2。</p><p>M1 芯片之前对我来说最大的变数在于对 Docker 的支持，但就在前几天 Docker for Mac 也发布了 <a href="https://docs.docker.com/docker-for-mac/apple-m1/">针对 M1 芯片的测试版本</a>。测试版中默认会运行一个 ARM 架构的 Linux 虚拟机，默认运行 linux&#x2F;arm64 架构的镜像（说起来在 M1 之前 linux&#x2F;arm64 大概主要是被用在树莓派上吧）；对于没有提供 linux&#x2F;arm64 架构的镜像则会自动使用 QEMU 来运行 x64_64 的镜像，性能就比较差了。</p><p>macOS 吸引我的一大理由就是 Homebrew —— 可能是桌面开发环境中最好用的包管理器。在 M1 上 Homebrew 目前 <a href="https://brew.sh/2020/12/01/homebrew-2.6.0/">推荐大家使用 Rosetta 2 来运行</a>，所安装的包也都是需要 Rosetta 2 转译运行的 x86 版本，即使这个包已经提供了 ARM 版本。</p><p>这是因为 Rosetta 2 虽然可以完美运行 x86 的 binary，但当一个脚本中会以字符串的方式传递架构名、会调用多种不同的架构的程序，且这些程序同样关心当前的架构时就出问题了，不同的程序无法对这台机器的架构达成一致 —— 这往往发生在编译脚本里，也就是 Homebrew 的主要工作。解决这个问题目前只能是让整个脚本都运行在 x86（即 Rosetta 2）下，Homebrew 目前也是这样做的。</p><p>当然你可以选择在另外一个路径 <a href="https://github.com/mikelxc/Workarounds-for-ARM-mac">安装 ARM 版的 Homebrew</a> 来安装 ARM 版的包，但目前这种方式缺少官方指引、需要自己尝试一个包的 ARM 版是否可以工作、需要从源码编译。目前大多数无法工作的包是受限于上游依赖的发布周期（如支持 darwin&#x2F;arm64 的 Go 1.16 要等到 2021 年二月才会发布），对于不涉及特定架构、或已经在其他平台提供有 ARM 版本的包，届时只需重新编译就可以提供 ARM 版本。</p><p>M1 的 Mac 可以直接安装 iOS 应用这一点我倒不是很在意，一方面是很多国内的毒瘤应用第一时间就从 Mac 商店下架，不允许安装。另一方面 iOS 基于触屏的交互逻辑本来就不适合 Mac，我也不觉得 Mac 之后会加入触屏的支持。</p><h2 id="性能测试"><a href="#性能测试" class="headerlink" title="性能测试"></a>性能测试</h2><p>以极低的功耗实现高于之前 MacBook 的性能是这次 M1 Mac 的亮点，在我购买之前实际上就已经看了很多视频自媒体的测评，在他们的测试中 M1 Mac 在使用 Final Cut Pro X 进行视频剪辑和导出有着碾压级的性能表现。</p><p>但显然这并不能代表 M1 在所有工作负载下的表现，因此我根据我日常的工作负载设计了 7 组共 15 项测试，主要将搭载 M1 的 MacBook Air 和我目前在使用的最后一代 Intel MacBook Pro (2020, i5 10th) 进行对比，以下数据均以后者为基准。</p><table><thead><tr><th>Name</th><th>MacBook Pro (i5 10th)</th><th>MacBook Air (M1, x86)</th><th>MacBook Air (M1, ARM)</th></tr></thead><tbody><tr><td>Node.js npm install</td><td>2m 41s</td><td>1m 38s (+39%)</td><td>1m 2s (+61%)</td></tr><tr><td>Node.js webpack build</td><td>54s</td><td>38s (+30%)</td><td>27s (+50%)</td></tr><tr><td>Xcode build Swift SDK</td><td>11m 30s</td><td>N&#x2F;A</td><td>6m 47s (+41%)</td></tr><tr><td>Xcode start iOS Simulator</td><td>49s</td><td>N&#x2F;A</td><td>16s (+67%)</td></tr><tr><td>Docker Redis benchmark</td><td>128k QPS</td><td>133k QPS (+4%)</td><td>261k QPS (+96%)</td></tr><tr><td>Docker build Node.js app</td><td>2m 56s</td><td>4m 43s (-61%)</td><td>3m 17s (-12%)</td></tr><tr><td>Visual Studio Code startup</td><td>7s</td><td>17s (-142%)</td><td>3s (+57%)</td></tr><tr><td>Visual Studio Code open and close tabs</td><td>36s</td><td>37s (-3%)</td><td>40s (-11%)</td></tr><tr><td>Chrome Speedometer 2.0</td><td>88 times&#x2F;m</td><td>121 times&#x2F;m (+38%)</td><td>214 times&#x2F;m (+143%)</td></tr><tr><td>Safari Speedometer 2.0</td><td>111 times&#x2F;m</td><td>N&#x2F;A</td><td>227 times&#x2F;m (+105%)</td></tr><tr><td>Safari 10% battery for Bilibili</td><td>32m</td><td>N&#x2F;A</td><td>1h 50m (+244%)</td></tr><tr><td>Final Cut Pro X background rendering</td><td>9m</td><td>N&#x2F;A</td><td>6m 20s (+30%)</td></tr><tr><td>Final Cut Pro X export H.264</td><td>8m 25s</td><td>N&#x2F;A</td><td>7m 8s (+15%)</td></tr><tr><td>Steam Oxygen Not Included</td><td>25 ~ 40 fps</td><td>45 ~ 50 fps (+25 ~ 80%)</td><td>Not support</td></tr><tr><td>Steam Sid Meier’s Civilization VI</td><td>p99 22 fps</td><td>p99 51 fps (+132%)</td><td>Not support</td></tr></tbody></table><p>对于 Node.js 依赖安装、前端项目构建、Swift 代码编译这些 CPU 密集且内存访问频繁、其中一些步骤依赖单核性能的场景，M1 有着非常明显的提升，即使使用 Rosetta 2 转译也要显著好于 i5。</p><p>最值得一提的是得益于 M1 的统一内存架构的高带宽和低延迟，Redis 跑出了 26 万 QPS 的成绩（无论是否在 Docker 中这个数据都差不多），而 i5 仅有 6 万。在调整 redis-benchmark 的数据长度参数时，M1 的结果几乎没有什么变化，而 i5 则随着数据长度的增加 QPS 逐步下降。说不定未来搭载 M1 的 Mac mini 会成为运行遇到 CPU 瓶颈的 Redis 的最佳硬件。</p><p>而使用 Docker for Mac 构建镜像则没有提升，这可能是因为构建的过程有很多零散的 IO，CPU 会有比较多的时间休息。而如果使用 Docker 去构建 x86_64 架构的镜像的话，性能损失就非常严重了（-61%）。</p><p>我编写了一个反复开关标签页的脚本来测试 VSCode 的性能，结果表明对于这类负载并不重的 GUI 程序，Rosetta 2 转译并不会影响性能，同样编译到 ARM 也不会对性能有多少提升，Rosetta 2 主要是会比较明显地增加启动速度。在 VSCode 的测试数据中出现了比较奇怪的现象 —— Rosetta 2 转译的版本竟然比 ARM 还快，我目前倾向于这是实验的误差，两者的速度实际上是几乎相同的。</p><p>在浏览器的测试中我们选择了 Speedometer，它会运行上百个由主流 Web 框架编写的 Todolist。结果显示无论是 Chrome 还是 Safari，其 ARM 版本都有一倍以上的性能提升，同样即使经过 Rosetta 2 转译也仍然比 i5 要快。浏览器的场景其实和前面 Node.js、Swfit 和 Redis 很像，都是 CPU 密集且内存访问频繁、其中一些步骤依赖单核性能，这也是 Intel CPU 之前的痛点。</p><p>我还基于浏览器进行了续航测试，我在中等亮度下播放 Bilibili 上 4K 120 帧的视频，开启弹幕的情况下 M1 使用前 10% 的电池播放了惊人的 1 小时 50 分钟，在这段时间的日常使用体验也是如此，我毫不怀疑官网给出的 18 小时视频播放时间。</p><p>在 Final Cut Pro X 的视频渲染和导出上，虽然 M1 确实有提升，但远不如之前一些媒体宣传的那么夸张，目前我还不清楚原因。</p><p>游戏方面我测试了我经常玩的 Oxygen Not Included（缺氧）和 Sid Meier’s Civilization VI（文明 6），我使用的都是中后期的存档、默认画面预设，在大多数时间都有 50 帧以上，是完全可以流畅游玩的。</p><p>可以看到 M1 的 Mac 在之前低配的价位上实现了中配甚至高配的计算性能，得益于专用的加速芯片，在苹果第一方和 macOS 独占的应用上有非常惊人的表现，而对于必须经过 Rosetta 2 转译的应用，仍有可以接受的性能表现，也是远高于之前同一价位的 Mac 的。</p><h2 id="多用户模式"><a href="#多用户模式" class="headerlink" title="多用户模式"></a>多用户模式</h2><p>因为蛋黄和我对新的 MacBook 都很有兴趣，因此我们各建了一个账户，这段时间是在轮流使用这台 MacBook，这也是我第一次使用 macOS 的多用户模式。整体体验还是很不错的，macOS 允许两个用户同时登录，在不退出程序的情况下在两个用户间切换，这使得我和蛋黄同时使用一台电脑的体验非常流畅。</p><p>16G 的内存也非常够用，即使另外一个用户运行了 XCode、Final Cut Pro X 或大量标签页的 Chrome，也不会有任何感觉。倒是 256G 的存储空间对于两个用户同时使用有些不够，不过这样的状态应该不会持续太久，后面我也会入手一台 M1 的 Mac。</p><h2 id="对-Mac-的展望"><a href="#对-Mac-的展望" class="headerlink" title="对 Mac 的展望"></a>对 Mac 的展望</h2><p>Rosetta 2 为什么会有这么好的性能呢？之前 Surface 等 x86 模拟器性能不佳的一个原因是 x86 与 ARM 在一个有关内存顺序的机制上有着不同的行为，在 ARM 上模拟这一行为会导致很大的性能损失。而苹果选择直接 <a href="https://twitter.com/ErrataRob/status/1331735383193903104">在 M1 芯片中实现了一套 x86 的内存机制</a>，大大加速了 Rosetta 2 的性能。据说苹果同样在芯片层面对 JavaScript 和 Swfit 中一些特定场景进行了优化，还有大量的专用计算芯片来加速编视频编解码、密码学计算等特定的任务。</p><p>这是一个非常有趣的方向，过去很长一段时间都是应用来适配芯片，但只要对硬件和操作系统的控制力足够强，芯片也可以反过来去对最常用、性能问题最突出的应用进行芯片层面的优化或加入专用的计算芯片，和应用程序一起进行迭代更新。M1 中有的是对 Rosetta 2 的优化，而下一代的 M2 芯片则可能不再需要 Rosetta 2，而是可以根据需要去优化当时的热门场景。</p><p>对于苹果来说切换到 ARM 最重要的是提升了其垂直整合的能力、自主控制 Mac 产品线的更新周期。因为苹果对于操作系统的控制力和对应用生态的号召力，可以最大限度地发挥出自主设计的 ARM 芯片的效果。Windows 阵营当然可以切换到 ARM，会享受到前面提到的一些好处，毕竟苹果已经证明了这条路是可行的。但因为软硬件不是同一家公司控制、Windows 对应用生态的号召力弱，微软又不敢破釜沉舟地投入到 ARM 上，因此短期内可能 Windows 阵营还很难实现。</p>]]>
    </content>
    <id>https://jysperm.me/2021/01/macbook-air-apple-silicon/</id>
    <link href="https://jysperm.me/2021/01/macbook-air-apple-silicon/"/>
    <published>2021-01-09T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>最近两年几乎买齐了苹果的全线产品，越来越看好苹果，甚至买了一些苹果的股票，当然为什么选择苹果生态这个话题可以放在单独的文章里来说。</p>
<blockquote>
<p>我在 Small talk 的第二期「<a href="https://1byte.io/small-]]>
    </summary>
    <title>入手 MacBook Air (Apple Silicon)</title>
    <updated>2026-05-01T15:13:06.183Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="年度小结" scheme="https://jysperm.me/tag/summary-of-year/"/>
    <category term="蛋黄" scheme="https://jysperm.me/tag/bud/"/>
    <content>
      <![CDATA[<p>2020 可能对于每一个人都是非常特殊的一年，中间发生的几乎每一件事都能和疫情联系起来。本来盼望了很久能在春节去蛋黄的老家放烟花，然而随着新冠疫情愈演愈烈，我们最后在 1 月 17 日决定取消了行程。</p><p>在疫情的初期也就是春节期间，事件的发展有着很大的不确定性，这种不确定性和媒体中对立的观点对我的情绪影响也很大，从这里开始，应该说这一整年我的情绪都很差。这次疫情也打破了我之前对于科技和未来的一些想象 —— 原来在今天，病毒会对人类社会造成这么大的影响、给我们的生活带来这么大的变化，而且这种变化很有可能常态化地持续下去。</p><p>年初蛋黄开始在我的指导下比较系统地开始学习 JavaScript 和 Web，中间短暂地工作过几个月，后面又开始学 Swift。一开始我非常着急希望蛋黄能学会一些技能然后开始工作，为此经常吵架。但后来蛋黄工作后我的情绪也没有变好，变得越来越难调节自己的情绪、总是在患得患失，也发生了很多不开心的事情。在这种情况下也很难去规划未来，只能先调整情绪，这个过程中全靠蛋黄哄着我。这对我的内容创作影响也很大，今年的内容输出非常少。希望我们能早些回到刚搬到昆山时的状态。</p><p>在年初我们参加了一次 <a href="https://twitter.com/QQSun/status/1215148321821446147">烧火节</a> 的线下活动，以此为契机拍摄制作了我的第一个 Vlog「<a href="https://www.bilibili.com/video/BV1s7411V73p">第一届烧火节 Vlog：钻木取火、烧烤露营</a>」。在之后我陆续购买了新的相机和稳定器等设备、拍摄了大量的素材，也制作了其他几个视频，但回过头来看烧火节的 Vlog 仍然是我最满意的一个视频。在疫情有了一定缓解之后我们还去了迪士尼和横店，也拍了很多视频，不过一直没有来得及剪辑成片。</p><p><img src="/pictures/2020/2020-fire-festival.png"></p><p>今年我业余时间完成的最大的一个项目是 NAS，我花了半年的时间，先在树莓派上做实验，然后购买了 HPE MicroServer Gen 10 作为正式环境，希望能解决未来十年的存储需求。我还将整个过程写成了一篇文章「<a href="https://jysperm.me/2020/11/my-opensource-nas-build/">我的 NAS 选型与搭建过程</a>」。它的主机名叫 <code>infinity</code>，实际投入使用后确实给了我很大的安全感 —— 这是一个由我所熟悉的开源软件构建的、有着先进的备份和纠错机制的存储系统，数据一旦被导入其中就几乎不可能再丢失。</p><p><img src="/pictures/2020/2020-nas.png"></p><p>所幸在疫情期间我也找到了一些投资机会，在全面的货币增发的情况下，今年收入和个人资产都有不小的增长，尤其在年底，密码货币差不多已经涨回了 2017 年的高点。从我工作以来这种收入和资产的不断增长，让我一直对未来保持着一种乐观的态度。</p><p>在工作方面，今年最大的变化是我需要花更多的时间去协调其他同事的工作，拆分工作、制定计划、参与面试，将自己的知识和经验分享给新加入的同事。随着工作年限的增加，工作本身会要求你承担更重要、更复杂（但可能没那么有趣）的工作，如果跟不上这种变化可能就需要给别人让位了，我想这应该就是所谓的「35 岁危机」。</p><p>一转眼从北京回到昆山也两年多了，开始觉得昆山实在是待够了 —— 现在我们几乎把昆山能逛的地方都逛了遍。我们接下来大概率会搬到上海，因为相比于其他选择，这可能是最容易做出的一个改变。其实这有点像我三年前搬去北京的理由，但我觉得上海无论是环境还是生活节奏应该都会比北京好很多。</p><p><img src="/pictures/2020/2020-fog-of-world.png"></p><p>在有了搬去上海这个想法之后，我发现我越来越害怕失去，之前每次搬家的过程中会扔掉很多东西，但现在就想把所有东西都留着；我会舍不得昆山的办公室；舍不得我们在昆山的房子 —— 我们在这里住了三年，留下了很多回忆，同时在不断地改造这个房子，让人觉得很难有比这里更舒服的地方了。我也会担心皮蛋豆腐 —— 它们今年经常会吐，中间豆腐还有那么半个月食欲不佳，虽然最后去医院检查时一切正常。</p>]]>
    </content>
    <id>https://jysperm.me/2020/12/summary-of-2020/</id>
    <link href="https://jysperm.me/2020/12/summary-of-2020/"/>
    <published>2020-12-30T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>2020 可能对于每一个人都是非常特殊的一年，中间发生的几乎每一件事都能和疫情联系起来。本来盼望了很久能在春节去蛋黄的老家放烟花，然而随着新冠疫情愈演愈烈，我们最后在 1 月 17 日决定取消了行程。</p>
<p>在疫情的初期也就是春节期间，事件的发展有着很大的不确定性，]]>
    </summary>
    <title>2020 年度小结</title>
    <updated>2026-05-01T15:13:06.181Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="Linux" scheme="https://jysperm.me/tag/linux/"/>
    <category term="NAS" scheme="https://jysperm.me/tag/nas/"/>
    <content>
      <![CDATA[<p>在 2016 年我拥有了第一台 NAS —— 群晖的 DS215J，其实在之后的很长一段时间其实并没有派上多大用场，因为我的数据并不多，大都存储在云端，更多的是体验一下 NAS 的功能和工作流。</p><p>直到最近我才开始真正地将 NAS 利用上，于是准备升级一下，但考虑到群晖的性价比实在太低，再加上去年配置 Linux 软路由让我对基于「原生 Linux」的开源解决方案信心和兴趣大增，于是准备自己 DIY 一台 NAS，计划解决未来十年的存储需求。</p><p>我依然选择了我最熟悉的 Ubuntu 作为操作系统、Ansible 作为配置管理工具，因此这个 NAS 的大部分配置都可以在我的 <a href="https://github.com/jysperm/playbooks/tree/master/roles">GitHub</a> 上找到。</p><blockquote><p>注意这个仓库中的 Ansible 配置仅供参考，不建议直接运行，因为我在编写这些配置时并未充分考虑兼容性和可移植性。</p></blockquote><h2 id="文件系统"><a href="#文件系统" class="headerlink" title="文件系统"></a>文件系统</h2><p>对于一台 NAS 来说最重要的当然是文件系统，不需要太多调研就可以找到 <a href="https://openzfs.github.io/openzfs-docs/index.html">ZFS</a> —— 可能是目前在数据可靠性上下功夫最多的单机文件系统了，于是我的整个选型就围绕 ZFS 展开了。</p><p>ZFS 既是文件系统，同时又是阵列（RAID）管理器，这为它带来了一些其他文件系统难以提供的能力：</p><ul><li>ZFS 为每个块都存储了校验和，同时会定期扫描整个硬盘，从 RAID 中的其他硬盘修复意外损坏的数据（如宇宙射线导致的比特翻转）。</li><li>在 RAID 的基础上可以 <a href="https://docs.oracle.com/cd/E19253-01/819-5461/gevpg/index.html">指定某些目录以更多的份数冗余存储</a>，对于重要的数据即使损坏的硬盘超过了 RAID 方案的限制，依然有可能找回。</li></ul><p>ZFS 还支持数据加密、压缩和去重，这三项功能以一种巧妙的顺序工作，并不会互相冲突，同时这些所有选项都可以设置在目录（dataset）级别、可以随时更改（只对新数据生效）。</p><p>ZFS 当然也支持快照，快照可以被导出为二进制流，被存储到任何地方。这个功能可以让你在不丢失任何元信息的情况下对 ZFS 的文件系统进行备份、传输和恢复。</p><h2 id="硬件"><a href="#硬件" class="headerlink" title="硬件"></a>硬件</h2><p>我并不擅长淘硬件，于是就选择了 HPE 的 MicroServer Gen10，一个四盘位的成品微型服务器，CPU 是 AMD X3421 ，8G ECC 内存，也是标准的 x86 通用硬件，应该不太容易遇到坑。</p><p><img src="/pictures/2020/nas-gen-10.png"></p><p>我用转接卡在 PCI-E 插槽上装了一块 NVME SSD，用作系统盘和 ZFS 的读缓存（L2ARC，不过从后面的统计来看效果并不明显），数据盘则暂时用的是旧的硬盘，最终会升级到四块 4T 的硬盘。这里需要注意的是因为 ZFS 不支持更改 RAID 的结构，所以必须在一开始就配置足够的硬盘来占位，后续再升级容量，我甚至用 USB 接了一块移动硬盘来凑数。</p><h2 id="ZFS"><a href="#ZFS" class="headerlink" title="ZFS"></a>ZFS</h2><p>因为是四盘位，所以我采用了 raidz1（RAID5），冗余一块盘作为校验，如果最终所有的盘都升级到 4T，一共是 12T 的实际可用容量。</p><figure class="highlight txt"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">root@infinity:~# zpool status</span><br><span class="line">  pool: storage</span><br><span class="line"> state: ONLINE</span><br><span class="line">config:</span><br><span class="line">    NAME                                 STATE     READ WRITE CKSUM</span><br><span class="line">    storage                              ONLINE       0     0     0</span><br><span class="line">      raidz1-0                           ONLINE       0     0     0</span><br><span class="line">        sda                              ONLINE       0     0     0</span><br><span class="line">        sdb                              ONLINE       0     0     0</span><br><span class="line">        sdc                              ONLINE       0     0     0</span><br><span class="line">        sdd                              ONLINE       0     0     0</span><br><span class="line">    cache</span><br><span class="line">      nvme0n1p4                          ONLINE       0     0     0</span><br><span class="line"></span><br><span class="line">root@infinity:~# zpool list</span><br><span class="line">NAME      SIZE  ALLOC   FREE  CKPOINT   FRAG    CAP  DEDUP    HEALTH</span><br><span class="line">storage  7.27T  3.52T  3.75T        -    10%    48%  1.00x    ONLINE</span><br></pre></td></tr></table></figure><blockquote><p>通常认为 RAID5 在出现硬盘故障的恢复过程中存在着较高的风险发生第二块盘故障、最终丢失数据的的情况；或者硬盘上的数据随着时间推移发生比特翻转导致数据损坏。但考虑到 ZFS 会定期做数据校验来保证数据的正确性，再综合考虑盘位数量和容量，我认为这个风险还是可以接受的，后面也会提到还有异地备份作为兜底措施。</p></blockquote><p>我开启了 ZFS 的加密功能，但这带来了一个问题：我不能把密钥以明文的方式存储在 NAS 的系统盘 —— 否则密钥和密文放在一起的话，这个加密就失去意义了。所以每次 NAS 重启后，都需要我亲自输入密码、挂载 ZFS 的 dataset，然后再启动其他依赖存储池的服务。</p><p>我还开启了 ZFS 的数据压缩，默认的 lz4 只会占用少量的 CPU 却可以在一些情况下提高 IO 性能 —— 因为需要读取的数据量变少了。因为去重对资源的需求较高，相当于需要为整个硬盘建立一个索引来找到重复的块，我并没有开启去重功能。</p><blockquote><p>一些评论认为 ZFS 对内存的需求高、必须使用 ECC 内存。这其实是一种误解：更多的内存可以提升 ZFS 的性能，ECC 则可以避免系统中所有应用遇到内存错误，但这些并不是必须的，即使没有更多的内存或 ECC，ZFS 依然有着不输其他文件系统的性能和数据完整性保证。</p></blockquote><h2 id="存储服务"><a href="#存储服务" class="headerlink" title="存储服务"></a>存储服务</h2><blockquote><p>小知识：SMB 是目前应用得最广泛的局域网文件共享协议，在主流的操作系统中都有内建的支持。CIFS 是微软（Windows）对 SMB 的一个实现，而我们会用到的 Samba 是另一个实现了 SMB 协议的自由软件。</p></blockquote><p><img src="/pictures/2020/nas-samba.png"></p><p>作为 NAS 最核心的功能就是通过 SMB 协议向外提供存储服务，所有的成品 NAS 都有丰富的选项来配置 SMB 的功能，但我们就只能直接去编辑 Samba 的配置文件了，Samba 直接采用了 Linux 的用户和文件权限机制，配置起来也不算太麻烦：</p><figure class="highlight txt"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br></pre></td><td class="code"><pre><span class="line"># 可以在 path 中使用占位符来为每个用户提供单独的 Home 目录</span><br><span class="line"># 可以在 valid users 中使用用户组来控制可访问的用户</span><br><span class="line">[Home]</span><br><span class="line">path = /storage/private/homes/%U</span><br><span class="line">writeable = yes</span><br><span class="line">valid users = @staff</span><br><span class="line"></span><br><span class="line"># Samba 默认以登录用户创建文件，但 NextCloud 以 www-data 运行，可以用 force user 覆盖为特定的用户</span><br><span class="line">[NextCloud]</span><br><span class="line">path = /storage/nextcloud/data/%U/files</span><br><span class="line">writeable = yes</span><br><span class="line">valid users = @staff</span><br><span class="line">force user = www-data</span><br><span class="line"></span><br><span class="line"># 通过这些设置可以让 macOS 的 TimeMachine 也通过 SMB 进行备份</span><br><span class="line"># 详见 https://www.reddit.com/r/homelab/comments/83vkaz/howto_make_time_machine_backups_on_a_samba/</span><br><span class="line">[TimeMachine]</span><br><span class="line">path = /storage/backups/timemachines/%U</span><br><span class="line">writable = yes</span><br><span class="line">valid users = @staff</span><br><span class="line">durable handles = yes</span><br><span class="line">kernel oplocks = no</span><br><span class="line">kernel share modes = no</span><br><span class="line">posix locking = no</span><br><span class="line">vfs objects = catia fruit streams_xattr</span><br><span class="line">ea support = yes</span><br><span class="line">inherit acls = yes</span><br><span class="line">fruit:time machine = yes</span><br><span class="line"></span><br><span class="line"># 对于共享的目录可以用 force group 覆盖文件的所属组、用 create mask 覆盖文件的权限位</span><br><span class="line">[VideoWorks]</span><br><span class="line">path = /storage/shares/VideoWorks</span><br><span class="line">writeable = yes</span><br><span class="line">valid users = @staff</span><br><span class="line">force group = staff</span><br><span class="line">create mask = 0775</span><br><span class="line"></span><br><span class="line"># 还可以设置游客可读、指定用户组可写的公开目录</span><br><span class="line">[Resources]</span><br><span class="line">path = /storage/public/Resources</span><br><span class="line">guest ok = yes</span><br><span class="line">write list = @staff</span><br><span class="line">force group = +staff</span><br><span class="line">create mask = 0775</span><br></pre></td></tr></table></figure><p>从上面的配置中也可以看到这些共享目录分散在几个不同的路径，为了匹配不同的数据类型、方便在目录级别进行单独设置，我划分了几个 dataset:</p><ul><li><code>db</code> 存放应用的数据库文件，将 recordsize 设置为了 8k（默认 128k）。</li><li><code>nextcloud</code> NextCloud 的数据目录，也可被 SMB 访问。</li><li><code>private</code> 每个用户的个人文件。</li><li><code>shares</code> 家庭内部共享的文件（如拍摄的视频）。</li><li><code>public</code> 可以从互联网上下载到的文件，不参与异地备份。</li><li><code>backups</code> 备份（Time Machine 等），不参与异地备份。</li></ul><figure class="highlight txt"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">root@infinity:~# zfs list</span><br><span class="line">NAME                USED  AVAIL     REFER  MOUNTPOINT</span><br><span class="line">storage            2.27T   286G      169K  /storage</span><br><span class="line">storage/backups     793G   286G      766G  /storage/backups</span><br><span class="line">storage/db          741M   286G      339M  /storage/db</span><br><span class="line">storage/nextcloud   207G   286G      207G  /storage/nextcloud</span><br><span class="line">storage/private    62.2G   286G     62.2G  /storage/private</span><br><span class="line">storage/public      648G   286G      613G  /storage/public</span><br><span class="line">storage/shares      615G   286G      609G  /storage/shares</span><br></pre></td></tr></table></figure><h2 id="应用"><a href="#应用" class="headerlink" title="应用"></a>应用</h2><p>首先我安装了 <a href="https://github.com/netdata/netdata">Netdata</a>，这是一个开箱即用的监控工具，在仅占用少量资源的情况下提供秒级精度的大量统计指标，非常适合用于监控单台服务器的性能瓶颈。</p><p><img src="/pictures/2020/nas-netdata.jpg"></p><p>其余的应用都被我运行在了 Docker 中（使用 docker-compose 来管理），这样可以隔离应用的运行环境，提升宿主机的稳定性，安装、升级、卸载应用也会更方便。</p><p>其中最重要的一个应用是 <a href="https://nextcloud.com/">NextCloud</a>，这是一个开源的同步盘，我主要看中它的 iOS 应用和 iOS 有不错的整合，可以正确地同步 Live Photo，也可以在 iOS 的文件应用中被调用。</p><p><img src="/pictures/2020/nas-nextcloud.jpg"></p><p>NextCloud 服务端会直接读写文件系统中的文件，而不是将文件存储在数据库里，这意味着 NextCloud 的数据目录同时也可以通过 Samba 来访问，这一点非常方便（不过需要一个定时任务来刷新 NextCloud 数据库中的元信息）。</p><p>我还在 Docker 中运行了这些服务，它们都是开源的：</p><ul><li><a href="https://miniflux.app/">Miniflux</a>，一个 RSS 服务端，通过 Fever API 支持绝大部分的 RSS 客户端。</li><li><a href="https://github.com/dani-garcia/bitwarden_rs">Bitwarden</a>（非官方实现），一个密码管理器，提供有各平台的客户端和浏览器插件。</li><li><a href="https://transmissionbt.com/">Transmission</a>，一个 BitTorrent 客户端，提供基于 Web 的管理界面。</li></ul><h2 id="外部访问"><a href="#外部访问" class="headerlink" title="外部访问"></a>外部访问</h2><p>如果要真正地用 NAS 来替代网盘的话，还是需要保证不在家里的内网的时候也可以访问到文件的。</p><p>通常的做法是使用 DDNS（动态 DNS）将一个域名解析至家庭宽带的 IP，这要求家庭宽带有公网 IP，而且运营商允许在 80 或 443 端口提供 Web 服务。我不想依赖这一点，所以想到了用 <a href="https://github.com/fatedier/frp">frp</a> 来进行「反向代理」，如果你确实有公网 IP 的话，也可以使用 DDNS 的方案，这样会省去一个中转服务器，也可以有更好的速度。</p><p>为了让 NextCloud 能有一个固定的地址（如 <code>https://nextcloud.example.com</code>）我将域名在内外网分别进行了解析，在家时解析到内网地址，在外解析到中转服务器。无论是内外网，数据流都会经过 Let’s Encrypt 的 SSL 加密，这样就不需要中转服务器有较高的安全保证。</p><p>虽然不需要先拨一个 VPN 确实很方便，但将 NextCloud 开放在公网上 <a href="https://www.cvedetails.com/vulnerability-list/vendor_id-15913/Nextcloud.html">并不安全</a>，在社区中已有用户 <a href="https://github.com/nextcloud/ios/issues/847">要求 NextCloud 客户端支持双向 SSL 认证</a>，我也非常期待这个功能，可以在公网访问上提供更好的安全性。</p><p>我还在 NAS 上安装了 <a href="https://www.wireguard.com/">WireGuard</a>，这是一个内建在 Linux 内核中的 VPN 模块，同样通过 frp 暴露在外网，除了 NextCloud 之外的服务，如 SMB、SSH 和 Nextdata 都可以通过 WireGuard 来访问。</p><p>如果你不执着于开源方案的话，也可以试试 <a href="https://www.zerotier.com/">ZeroTier</a>，它提供了 NAT 穿透的能力，让你的设备和 NAS 之间可以不借助中转服务器直接传输，改善连接速度。</p><h2 id="备份和数据完整性"><a href="#备份和数据完整性" class="headerlink" title="备份和数据完整性"></a>备份和数据完整性</h2><p>在 raidz1 的基础上，我设置了定时任务让 ZFS 每天生成一个快照，还写了一个脚本来按照类似 Time Machine 的规则来清理备份：保留最近一周的每天快照、最近一个月的每周快照、最近一年的每月快照、以及每年的快照。</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">root@infinity:~# zfs list storage/nextcloud -t snapshot</span><br><span class="line">NAME                           USED  AVAIL     REFER  MOUNTPOINT</span><br><span class="line">storage/nextcloud@2020-09-05  83.9M      -      182G  -</span><br><span class="line">storage/nextcloud@2020-09-15  35.2M      -      207G  -</span><br><span class="line">storage/nextcloud@2020-09-21  30.2M      -      207G  -</span><br><span class="line">storage/nextcloud@2020-09-23  29.7M      -      207G  -</span><br><span class="line">storage/nextcloud@2020-09-26  29.3M      -      207G  -</span><br><span class="line">storage/nextcloud@2020-09-27  28.2M      -      207G  -</span><br><span class="line">storage/nextcloud@2020-09-28  28.2M      -      207G  -</span><br><span class="line">storage/nextcloud@2020-09-29  29.1M      -      207G  -</span><br><span class="line">storage/nextcloud@2020-09-30  33.5M      -      207G  -</span><br></pre></td></tr></table></figure><p>快照主要是为了防止人工的误操作，除了单纯的、当场就能发现的手滑之外，有时你会误以为你不会用到这个文件而将它删除，直到很久之后才发现并非如此。</p><p>同时每周会有定时任务使用 <a href="https://restic.net/">restic</a> 备份一个快照到 <a href="https://www.backblaze.com/b2/cloud-storage.html">Backblaze B2</a> 作为异地备份，这是一个价格较低的对象存储，非常适合备份。restic 支持增量的快照备份，也支持加密。出于成本考虑，异地备份仅包括由我产生的数据，并不包括 public 和 backups 目录。</p><p>我曾考虑过直接在远端运行一个 ZFS 来进行备份，zfs send &#x2F; recv 支持以二进制流的形式传输一个快照 —— 不需要远端安装其他任何的工具，只需要用 shell 的管道操作符将 zfs send 的字节流重定向到 ssh 命令即可。这个方案非常具有技术美感，但考虑到块存储的价格是对象存储的十倍以上，最后还是放弃了这个方案。</p><h2 id="成本核算"><a href="#成本核算" class="headerlink" title="成本核算"></a>成本核算</h2><p>硬件上其实我预算并不紧张，留的余量也比较大，如果换一些性价比更高的硬件的话，价格还可以下降很多。</p><ul><li>主机（主板、CPU、内存、系统盘） 3500 元</li><li>硬盘（4 * 4T） 2200 元（其实目前只买了一块，其他三块是旧的）</li></ul><p>考虑到我之前的群辉用了五年，新的 NAS 设计使用寿命定在十年：</p><ul><li>硬件成本折合每年 570 元</li><li>电费（35W）每年 110 元</li><li>远程访问每年 100 元（国内年付促销服务器，如有公网 IP 使用 DDNS 则无需此项）</li><li>异地备份每年 415 元（按量付费，这里按 1T 需要异地备份的数据计算）</li></ul><p>总共 12T 的容量每年 1195 元，折合 1T 每月 8 元，如果去掉远程访问和异地备份的话则是 1T 每月 5 元。</p><h2 id="为什么要用自部署方案"><a href="#为什么要用自部署方案" class="headerlink" title="为什么要用自部署方案"></a>为什么要用自部署方案</h2><p>相比于使用云服务，第一个理由自然是对数据的「掌控感」，虽然没有什么确凿的理由说云服务就一定不安全，但有些人就是喜欢这种对个人数据的掌控感。</p><p>还有一个技术原因是部署在家中内网的 NAS 可以通过 SMB 简单地支持一些「在线编辑」，如直接加载 NAS 上的素材进行视频剪辑、甚至将整个工程文件都直接放在 NAS 上。使用云服务的话一方面是没有 SMB 协议的支持，即使支持延迟对于在线编辑来说也是无法接受的。</p><p>另外一个不能忽略的话题就是成本，在这里我们只考虑以容量为计价方案的网盘服务，iCloud、Google Drive、Dropbox 的价格方案都非常接近，在超过 200G（大概 $3）这一档之后就直接跳到了 2T（大概 $10），这时云服务按量付费的优势其实就没有了，是一个切换到自部署方案的一个不错的时间点，一次性投入之后只需 2 - 3 年即可回本。</p><p>当然最重要的一点是兴趣，在这个折腾的过程中你需要做很多决定、遇到很多困难，最后搭建出来一个几乎是独一无二的自部署方案。如果你能在这个过程中找到乐趣的话，那当然是非常值得的；反过来如果你没有兴趣，算上投入的时间成本，自部署方案的性价比将会非常低。</p><p>任何自部署的方案都需要长期的维护才能保持工作，对后端运维完全没有兴趣怎么办，不如了解一下 <a href="https://www.leancloud.cn/">LeanCloud</a>，领先的 BaaS 提供商，为移动开发提供强有力的后端支持。</p>]]>
    </content>
    <id>https://jysperm.me/2020/11/my-opensource-nas-build/</id>
    <link href="https://jysperm.me/2020/11/my-opensource-nas-build/"/>
    <published>2020-11-03T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>在 2016 年我拥有了第一台 NAS —— 群晖的 DS215J，其实在之后的很长一段时间其实并没有派上多大用场，因为我的数据并不多，大都存储在云端，更多的是体验一下 NAS 的功能和工作流。</p>
<p>直到最近我才开始真正地将 NAS 利用上，于是准备升级一下，但考]]>
    </summary>
    <title>我的 NAS 选型与搭建过程（基于开源方案）</title>
    <updated>2026-05-01T15:13:06.184Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="JavaScript" scheme="https://jysperm.me/tag/javascript/"/>
    <content>
      <![CDATA[<p>作为一个 Node.js 开发者，我很早便了解到了 TypeScript，但又因为我对 CoffeeScript 的喜爱，直到 2016 年才试用了一下 TypeScript，但当时对它的学习并不深入，直到最近又在工作中用 TypeScript 开发了两个后端项目，对 TypeScript 有了一些新的理解。</p><h2 id="为-JavaScript-添加类型"><a href="#为-JavaScript-添加类型" class="headerlink" title="为 JavaScript 添加类型"></a>为 JavaScript 添加类型</h2><p>大家总会把 TypeScript 和其他语言去做对比，说它是在模仿 Java 或 C#，我也曾一度相信了这种说法。但其实并非如此，<strong>TypeScript 的类型系统和工作机制是如此的独特，无法简单地描述成是在模仿哪一个语言，更像是在 JavaScript 的基础上重新发明了 JavaScript</strong>。</p><p>究其根本，TypeScript 并不是一个全新的语言，它是在一个已有的语言 —— 还是一个非常灵活的动态类型语言上添加静态约束。在官方 Wiki 上的 <a href="https://github.com/microsoft/TypeScript/wiki/TypeScript-Design-Goals">TypeScript Design Goals</a> 中有提到，TypeScript 并不是要从 JavaScript 中抽取出一个具有静态化语义的子集，而是要尽可能去支持之前社区中已有的编程范式，避免与常见的用法产生不兼容。</p><p>这意味着 TypeScript 试图为 JavaScript 已有的大量十分「动态」的特性去提供静态语义。一般认为「静态类型」的标志是在编译时为变量确定类型，但 TypeScript 很特殊，因为 JavaScript 本身的动态性，TypeScript 中的类型更像是一种「约束」，它尊重已有的 JavaScript 设计范式，同时尽可能添加一点静态约束 —— 这种约束不会影响到代码的表达能力。或者说，TypeScript 会以 JavaScript 的表达能力为先、以 JavaScript 的运行时行为为先，而静态约束则次之。</p><p>这样听起来 TypeScript 是不是很无聊呢，毕竟 Python 也有 Type Checking，JavaScript 之前也有 Flow。的确如此，但 <strong>TypeScript 的类型系统的表达能力和工具链的支持实在太强了，并不像其他一些静态类型标注仅能覆盖一些简单的情况，而是能够深刻地参与到整个开发过程中，提高开发效率</strong>。</p><p>前面提到 TypeScript 并不想发明新的范式，而是要尽可能支持 JavaScript 已有的用法。因此虽然 TypeScript 有着强大的类型系统、大量的特性，但对于 JavaScript 开发者开说学习成本并不高，因为几乎每个特性都可以对应 JavaScript 社区中一种常见的范式。</p><h2 id="基于属性的类型系统"><a href="#基于属性的类型系统" class="headerlink" title="基于属性的类型系统"></a>基于属性的类型系统</h2><p>在 JavaScript 中，对象（Object）是最常用的类型之一，我们会使用大量的对象字面量来组织数据，我们经常将很多不同的参数塞进一个对象，或者从一个函数中返回一个对象，对象中还可以再嵌套对象。可以说对象是 JavaScript 中最常用的数据容器，但并没有类型去约束它。</p><p>例如 request 这个库会要求使用者将发起请求的所有参数一股脑地以一个对象的形式作为参数传入。这就是非常典型的 JavaScript 风格。再比如 JavaScript 中一个 Promise 对象只需有 then 和 catch 这两个实例方法就可以，而并不真的需要真的来自标准库中的 Promise 构造器，实际上也有很多第三方的 Promise 的实现，或一些返回类 Promise 对象的库（例如一些 ORM）。</p><p>在 JavaScript 中我们通常只关注一个对象是否有我们需要的属性和方法，这种范式被称为「<a href="https://zh.wikipedia.org/wiki/%E9%B8%AD%E5%AD%90%E7%B1%BB%E5%9E%8B">鸭子类型</a>（Duck typing）」，就是说「<strong>当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子，那么这只鸟就可以被称为鸭子</strong>」。</p><p>所以 TypeScript 选择了一种基于属性的类型系统（Structural type system），这种类型系统不再关注一个变量被标称的类型（由哪一个构造器构造），而是 <strong>在进行类型检查时，将对象拆开，抽丝剥茧，逐个去比较组成这个对象的每一个不可细分的成员。如果一个对象有着一个类型所要求的所有属性或方法，那么就可以当作这个类型来使用</strong>。</p><p>这就是 TypeScript 类型系统的核心 —— Interface（接口）：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">interface</span> <span class="title class_">LabeledValue</span> &#123;</span><br><span class="line">  <span class="attr">label</span>: <span class="built_in">string</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>TypeScript 并不关心 Interface 本身的名字，与其说是「类型」，它更像是一种约束。一个对象只要有一个字符串类型的 label 属性，就可以说它满足了 LabeledValue 的约束。它可以是一个其他类的实例、可以是字面量、可以有额外的属性；只要它满足 LabeledValue 所要求的属性，就可以被赋值给这个类型的变量、传递给这个类型的参数。</p><p>前面提到 Interface 实际上是一组属性或一组约束的集合，说到集合，当然就可以进行交集、并集之类的运算。例如 <code>type C = A &amp; B</code> 表示 C 需要同时满足类型 A 和类型 B 的约束，可以简单地实现类型的组合；而 <code>type C = A | B</code> 则表示 C 只需满足 A 和 B 任一类型的约束，可以实现联合类型（Union Type）。</p><p>接下来我会挑选一些 TypeScript 具有代表性的一些特性进行介绍，它们之间环环相扣，十分精妙。</p><h3 id="字符串魔法：字面量"><a href="#字符串魔法：字面量" class="headerlink" title="字符串魔法：字面量"></a>字符串魔法：字面量</h3><p>在 TypeScript 中，字面量也是一种类型：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> <span class="title class_">Name</span> = <span class="string">&#x27;ziting&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="attr">myName</span>: <span class="title class_">Name</span> = <span class="string">&#x27;ziting&#x27;</span></span><br></pre></td></tr></table></figure><p>在上面的代码中，Name 类型唯一合法的值就是 ziting 这个字符串 —— 这看起来毫无意义，但如果我们引入前面提到的集合运算（联合类型）呢？</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> <span class="title class_">Method</span> = <span class="string">&#x27;GET&#x27;</span> | <span class="string">&#x27;PUT&#x27;</span> | <span class="string">&#x27;DELETE&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">interface</span> <span class="title class_">Request</span> &#123;</span><br><span class="line">  <span class="attr">method</span>: <span class="title class_">Method</span></span><br><span class="line">  <span class="attr">url</span>: <span class="built_in">string</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>上面的代码中我们约束了 Request 的 method 只能是 GET、PUT 和 DELETE 之一，这比单纯地约束它是一个字符串类型要更加准确。这是 JavaScript 开发者经常使用的一种模式 —— 用字符串来表示枚举类型，字符串更灵活也更具有可读性。</p><p>在 lodash 之类的库中，JavaScript 开发者还非常喜欢使用字符串来传递属性名，在 JavaScript 中这很容易出错。而 TypeScript 则提供了专门的语法和内建的工具类型来实现对这些字符串字面量的计算，提供静态的类型检查：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">interface</span> <span class="title class_">Todo</span> &#123;</span><br><span class="line">  <span class="attr">title</span>: <span class="built_in">string</span></span><br><span class="line">  <span class="attr">description</span>: <span class="built_in">string</span></span><br><span class="line">  <span class="attr">completed</span>: <span class="built_in">boolean</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// keyof 将 interface 的所有属性名提取成一个新的联合类型</span></span><br><span class="line"><span class="keyword">type</span> <span class="title class_">KeyOfTodo</span> = keyof <span class="title class_">Todo</span> <span class="comment">// &#x27;title&#x27; | &#x27;description&#x27; | &#x27;completed&#x27;</span></span><br><span class="line"><span class="comment">// Pick 可以从一个 interface 中提取一组属性，生成新的类型</span></span><br><span class="line"><span class="keyword">type</span> <span class="title class_">TodoPreview</span> = <span class="title class_">Pick</span>&lt;<span class="title class_">Todo</span>, <span class="string">&#x27;title&#x27;</span> | <span class="string">&#x27;completed&#x27;</span>&gt; <span class="comment">// &#123;title: string, completed: boolean&#125;</span></span><br><span class="line"><span class="comment">// Extract 可以找到两个并集类型的交集，生成新的类型</span></span><br><span class="line"><span class="keyword">type</span> <span class="title class_">Inter</span> = <span class="title class_">Extract</span>&lt;keyof <span class="title class_">Todo</span>, <span class="string">&quot;title&quot;</span> | <span class="string">&quot;author&quot;</span>&gt; <span class="comment">// &quot;title&quot;</span></span><br></pre></td></tr></table></figure><p>借助这些语法和后面提到的泛型能力，JavaScript 中各种以字符串的形式传递属性名、魔法般的对象处理，也都可以得到准确的类型检查。</p><h3 id="类型元编程：泛型"><a href="#类型元编程：泛型" class="headerlink" title="类型元编程：泛型"></a>类型元编程：泛型</h3><p>泛型提供了一种将类型参数化的能力，在其他语言中最基本的用途是定义容器类型，使得工具函数可以不必知道被操作的变量的具体类型。JavaScript 中的数组或 Promise 在 TypeScript 中都会被表述为这样的泛型类型，例如 Promise.all 的类型定义可以写成：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> all&lt;T&gt;(<span class="attr">values</span>: <span class="title class_">Array</span>&lt;T | <span class="title class_">Promise</span>&lt;T&gt;&gt;): <span class="title class_">Promise</span>&lt;<span class="title class_">Array</span>&lt;T&gt;&gt;</span><br></pre></td></tr></table></figure><p>可以看到类型参数可以被用来构造更复杂的类型，进行集合运算或嵌套。</p><p>默认情况下，因为类型参数可以是任意的类型，所以不能假定它有某些属性或方法，也就不能访问它的任何属性，只有添加了约束才能遵循这个约束去使用它，同时 TypeScript 会依照这个约束限制传入的类型：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">interface</span> <span class="title class_">Lengthwise</span> &#123;</span><br><span class="line">  <span class="attr">length</span>: <span class="built_in">number</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> logLength&lt;T <span class="keyword">extends</span> <span class="title class_">Lengthwise</span>&gt;(<span class="attr">arg</span>: T) &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(arg.<span class="property">length</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>约束中也可以用到其他的类型参数或使用多个类型参数，在下面的代码中我们限制类型参数 K 必须是 obj 的一个属性名：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> getProperty&lt;T, K <span class="keyword">extends</span> keyof T&gt;(<span class="attr">obj</span>: T, <span class="attr">key</span>: K) &#123;</span><br><span class="line">  <span class="keyword">return</span> obj[key];</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>除了在函数上使用泛型之外，我们还可以定义泛型类型：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> <span class="title class_">Partial</span>&lt;T&gt; = &#123;</span><br><span class="line">  [P <span class="keyword">in</span> keyof T]?: T[P];</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>当定义泛型类型时我们实际上是在定义一种处理类型的「函数」，使用泛型参数去生成新的类型，这也被称作「元编程」。例如 Partial 会遍历传入类型 T 的每一个属性，返回一个所有属性都可空的新类型：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">interface</span> <span class="title class_">Person</span> &#123;</span><br><span class="line">  <span class="attr">name</span>: <span class="built_in">string</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> <span class="attr">a</span>: <span class="title class_">Person</span> = &#123;&#125; <span class="comment">// 报错 Property &#x27;name&#x27; is missing in type &#x27;&#123;&#125;&#x27; but required in type &#x27;Person&#x27;.</span></span><br><span class="line"><span class="keyword">const</span> <span class="attr">b</span>: <span class="title class_">Partial</span>&lt;<span class="title class_">Person</span>&gt; = &#123;&#125;</span><br></pre></td></tr></table></figure><p>前面我们提到的 Pick 和 Extract 都是这样的泛型类型。</p><p>在此之外 TypeScript 甚至可以在定义泛型类型时进行条件判断和递归，这使得 TypeScript 的类型系统变成了 <a href="https://github.com/microsoft/TypeScript/issues/14833">图灵完备的</a>，可以在编译阶段进行任何计算。</p><p>你可能会怀疑这样复杂的类型真的有用么？其实这些特性更多地是提供给库开发者使用的，对于 JavaScript 社区中的 ORM、数据结构，或者是 lodash 这样的库来说，如此强大的类型系统是非常必要的，lodash 的 <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/lodash">类型定义</a> 行数甚至是它本身代码的几十倍。</p><h3 id="类型方程式：自动推导"><a href="#类型方程式：自动推导" class="headerlink" title="类型方程式：自动推导"></a>类型方程式：自动推导</h3><p>但其实我们并不一定要掌握这么复杂的类型系统，实际上前面介绍的高级特性在业务代码中都极少被用到。TypeScript 并不希望标注类型给开发者造成太大的负担，因此 TypeScript 会尽可能地进行类型推导，让开发者在大多数情况下不必手动标注类型。</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> bool = <span class="literal">true</span> <span class="comment">// bool 是字面量类型 true</span></span><br><span class="line"><span class="keyword">let</span> num = <span class="number">1</span> <span class="comment">// num 是 number</span></span><br><span class="line"><span class="keyword">let</span> arr = [<span class="number">0</span>, <span class="number">1</span>, <span class="string">&#x27;str&#x27;</span>] <span class="comment">// arr 是 (number | string)[]</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">let</span> body = <span class="keyword">await</span> fs.<span class="title function_">readFile</span>() <span class="comment">// body 是 Buffer</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// cpuModels 是 string[]</span></span><br><span class="line"><span class="keyword">let</span> cpuModels = os.<span class="title function_">cpus</span>().<span class="title function_">map</span>( <span class="function"><span class="params">cpu</span> =&gt;</span> &#123;</span><br><span class="line">  <span class="comment">// cpu 是 os.CpuInfo</span></span><br><span class="line">  <span class="keyword">return</span> cpu.<span class="property">model</span></span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>类型推导同样可以用在泛型中，例如前面提到的 Promise.all 和 getProperty，我们在使用时都不必去管泛型参数：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 调用 Promise.all&lt;Buffer&gt;，files 的类型是 Promise&lt;Buffer[]&gt;</span></span><br><span class="line"><span class="keyword">const</span> files = <span class="title class_">Promise</span>.<span class="title function_">all</span>(paths.<span class="title function_">map</span>( <span class="function"><span class="params">path</span> =&gt;</span> fs.<span class="title function_">readFile</span>(path)))</span><br><span class="line"><span class="comment">// 调用 Promise.all&lt;number[]&gt;，numbers 的类型是 Promise&lt;number[]&gt;</span></span><br><span class="line"><span class="keyword">const</span> numbers = <span class="title class_">Promise</span>.<span class="title function_">all</span>([<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>, <span class="number">4</span>])</span><br><span class="line"></span><br><span class="line"><span class="comment">// 调用 getProperty&lt;&#123;a: number&#125;, &#x27;a&#x27;&gt;，a 的类型是 number</span></span><br><span class="line"><span class="keyword">const</span> a = <span class="title function_">getProperty</span>(&#123;<span class="attr">a</span>: <span class="number">2</span>&#125;, <span class="string">&#x27;a&#x27;</span>)</span><br></pre></td></tr></table></figure><p>前面提到泛型是在将类型参数化，引入一个未知数来代替实际的类型，所以说泛型对于 TypeScript 就像是一个方程式一样，只要你提供了能够解开这个方程的其他未知数，TypeScript 就可以推导出剩余的泛型类型。</p><h3 id="价值十亿美金的错误"><a href="#价值十亿美金的错误" class="headerlink" title="价值十亿美金的错误"></a>价值十亿美金的错误</h3><p>在很多语言中访问空指针都会报出异常（在 JavaScript 中是从 null 或 undefined 上读取属性时），空指针异常被称为「<a href="https://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare/">价值十亿美元的错误</a>」。TypeScript 则为空值检查也提供了支持（需开启 strictNullChecks），虽然这依赖于类型定义的正确性，并没有运行时的保证，但依然可以提前在编译期发现大部分的错误，提高开发效率。</p><p>TypeScript 中的类型是不可为空（undefined 或 null）的，对于可空的类型必须表示成和 undefined 或 null 的并集类型，这样当你试图从一个可能为 undefined 的变量上读取属性时，TypeScript 就会报错了。</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">logDateValue1</span>(<span class="params">date: <span class="built_in">Date</span></span>) &#123; <span class="comment">// 不可空</span></span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(date.<span class="title function_">valueOf</span>())</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="title function_">logDateValue1</span>(<span class="keyword">new</span> <span class="title class_">Date</span>)</span><br><span class="line"><span class="title function_">logDateValue1</span>() <span class="comment">// 报错 An argument for &#x27;date&#x27; was not provided.</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">logDateValue2</span>(<span class="params">date: <span class="built_in">Date</span> | <span class="literal">undefined</span></span>) &#123; <span class="comment">// 可空</span></span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(date.<span class="title function_">valueOf</span>()) <span class="comment">// 报错 Object is possibly &#x27;undefined&#x27;.</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="title function_">logDateValue2</span>(<span class="keyword">new</span> <span class="title class_">Date</span>)</span><br><span class="line"><span class="title function_">logDateValue2</span>()</span><br></pre></td></tr></table></figure><p>在这种情况下 TypeScript 会要求你先对这个值进行判断，排除其为 undefined 可能性。这就要说到 TypeScript 的另外一项特性 —— 其基于控制流的类型分析。例如在你使用 if 对变量进行非空判断后，在 if 之后的花括号中这个变量就会变成非空类型：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">print</span>(<span class="params">str: <span class="built_in">string</span> | <span class="literal">null</span></span>) &#123;</span><br><span class="line">  <span class="comment">// str 在这里的类型是 string | null</span></span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(str.<span class="title function_">trim</span>()) <span class="comment">// 报错 Object is possibly &#x27;null&#x27;.</span></span><br><span class="line">  <span class="keyword">if</span> (str !== <span class="literal">null</span>) &#123;</span><br><span class="line">    <span class="comment">// str 在这里的类型是 string</span></span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(str.<span class="title function_">trim</span>())</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>同样的类型分析也发生在使用 if、switch 等语句对并集类型进行判断时：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">interface</span> <span class="title class_">Rectangle</span> &#123;</span><br><span class="line">  <span class="attr">kind</span>: <span class="string">&#x27;rectangle&#x27;</span></span><br><span class="line">  <span class="attr">width</span>: <span class="built_in">number</span></span><br><span class="line">  <span class="attr">height</span>: <span class="built_in">number</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">interface</span> <span class="title class_">Circle</span> &#123;</span><br><span class="line">  <span class="attr">kind</span>: <span class="string">&#x27;circle&#x27;</span></span><br><span class="line">  <span class="attr">radius</span>: <span class="built_in">number</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">area</span>(<span class="params">s: Rectangle | Circle</span>) &#123;</span><br><span class="line">  <span class="comment">// s 在这里的类型是 Rectangle | Circle</span></span><br><span class="line">  <span class="keyword">switch</span> (s.<span class="property">kind</span>) &#123;</span><br><span class="line">    <span class="keyword">case</span> <span class="string">&quot;rectangle&quot;</span>:</span><br><span class="line">      <span class="comment">// s 在这里的类型是 Rectangle</span></span><br><span class="line">      <span class="keyword">return</span> s.<span class="property">height</span> * s.<span class="property">width</span></span><br><span class="line">    <span class="keyword">case</span> <span class="string">&quot;circle&quot;</span>:</span><br><span class="line">      <span class="comment">// s 在这里的类型是 Circle</span></span><br><span class="line">      <span class="keyword">return</span> <span class="title class_">Math</span>.<span class="property">PI</span> * s.<span class="property">radius</span> ** <span class="number">2</span>;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="仅仅工作在编译阶段"><a href="#仅仅工作在编译阶段" class="headerlink" title="仅仅工作在编译阶段"></a>仅仅工作在编译阶段</h2><p>TypeScript 最终仍然会编译到 JavaScript，再被 JavaScript 引擎（如 V8）执行，在生成出的代码中不会包含任何类型信息，TypeScript 也不会添加任何与运行时行为有关的功能。</p><p>TypeScript 仅仅提供了类型检查，但它并没有去保证通过检查的代码一定是可以正确运行的。可能一个变量在 TypeScript 的类型声明中是一个数字，但并不能阻止它在运行时变成一个字符串 —— 可能是使用了强制类型转换或使用了其他非 TypeScript 的库且类型定义文件有误。</p><p>在 TypeScript 中你可以将类型设置为 any 来绕过几乎所有检查，或者用 as 来强制「转换」类型，当然就像前面提到的那样，这里转换的仅仅是 TypeScript 在编译阶段的类型标注，并不会改变运行时的类型。虽然 TypeScript 设计上要去支持 JavaScript 的所有范式，但难免有一些极端的用例无法覆盖到，这时如何使用 any 就非常考验开发者的经验了。</p><p>编程语言的类型系统总是需要在灵活和复杂、简单和死板之间做出权衡，TypeScript 则给出了一个完全不同的答案 —— 将编译期的检查和运行时的行为分别看待。这是 TypeScript 饱受争议的一点，有人认为这样非常没有安全感，即使通过了编译期检查在运行时依然有可能得到错误的类型，也有人认为 <strong>这是一个非常切合工程实际的选择 —— 你可以用 any  来跳过类型检查，添加一些过于复杂或无法实现的代码，虽然这破坏了类型安全，但确实又解决了问题</strong>。</p><p>那么这种仅仅工作在编译阶段类型检查有意义么？我认为当然是有的，毕竟 JavaScript 已经提供了足够使用的运行时行为，而且要保持与 JavaScript 的互操作性。大家需要的只是 TypeScript 的类型检查来提高开发效率，除了编译阶段的检查来尽早发现错误以外，TypeScript 的类型信息也可以给编辑器（IDE）非常准确的补全建议。</p><h2 id="与-JavaScript-代码一起工作"><a href="#与-JavaScript-代码一起工作" class="headerlink" title="与 JavaScript 代码一起工作"></a>与 JavaScript 代码一起工作</h2><p><strong>任何基于 JavaScript 的技术都要去解决和标准 JavaScript 代码的互操作性</strong> —— TypeScript 不可能创造出一个平行与 JavaScript 的世界，它必须依赖社区中已有的数十万的 JavaScript 包。</p><p>因此 TypeScript 引入了一种类型描述文件，允许社区为 JavaScript 编写类型描述文件，来让用到它们的代码可以得到 TypeScript 的类型检查。</p><p>描述文件的确是 TypeScript 开发中最大的痛点，毕竟只有当找全了定义文件之后，才会有流畅的开发体验。在开发的过程中不可避免地会用到一些特定领域的、小众的库，这时就必须要去考虑这个库是否有定义文件、定义文件的质量如何、是否需要自己为其编写定义文件。对于不涉及复杂泛型的库来说，写定义文件并不会花太多时间，你也只需要给自己用到的接口写定义，但终究是一个分心的点。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>TypeScript 有着先进的类型系统，而且这个先进并不是「学术」意义上的先进，而是「工程」意义上的先进，能够切实地提高开发效率，减轻动态类型的心理负担，提前发现错误。所以在此建议所有的 JavaScript 开发者都了解和尝试一下 TypeScript，对于 JavaScript 的开发者来说，TypeScript 的入门成本非常低。</p><p>在 LeanCloud，控制台在最近的一次的重构中切换到了 TypeScript，提高了前端项目的工程化水平，让代码可以被长时间地维护下去。同时我们一部分既有的基于 Node.js 的后端项目也在切换到 TypeScript。</p><p>LeanCloud 的一些内部工具和边缘服务也会优先考虑 TypeScript，较低的学习成本（谁没写过几行 JavaScript 呀！）、静态类型检查和优秀的 IDE 支持，极大地降低了新同事参与不熟悉或长时间无人维护的项目的门槛，提高大家改进内部工具的积极性。</p><p>LeanCloud 的 JavaScript SDK、Node SDK 和 Play SDK 都添加了 TypeScript 的定义文件（并且打算在之后的版本中使用 TypeScript 改写），让使用 LeanCloud 的开发者可以在 TypeScript 中使用 SDK，即使不用 TypeScript，定义文件也可以帮助编辑器来改进代码补全和类型提示。</p><p>如果你也希望一起来完善这些项目，可以了解一下在 LeanCloud 的 <a href="https://www.leancloud.cn/jobs/">工作机会</a>。</p><p>参考资料：</p><ul><li><a href="https://mariusschulz.com/blog/series/typescript-evolution">TypeScript Evolution</a></li><li><a href="https://basarat.gitbook.io/typescript/">TypeScript Deep Dive</a>（<a href="https://jkchao.github.io/typescript-book-chinese/">中文版</a>）</li><li><a href="https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals">TypeScript Design Goals</a></li><li><a href="https://www.typescriptlang.org/docs/handbook/">The TypeScript Handbook</a></li><li><a href="https://zhuanlan.zhihu.com/p/64446259">浅谈 TypeScript 类型系统</a></li><li><a href="https://zhuanlan.zhihu.com/p/85655537">TypeScript类型元编程：实现8位数的算术运算</a></li><li><a href="https://www.yinwang.org/blog-cn/2015/11/21/programming-philosophy">编程的智慧</a>（正确处理 null 指针）</li><li><a href="https://www.lucidchart.com/techblog/2015/08/31/the-worst-mistake-of-computer-science/">The worst mistake of computer science</a>（<a href="https://www.open-open.com/news/view/16166e1">中文版</a>）</li></ul>]]>
    </content>
    <id>https://jysperm.me/2020/06/typescript-reinvent-javascript/</id>
    <link href="https://jysperm.me/2020/06/typescript-reinvent-javascript/"/>
    <published>2020-06-09T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>作为一个 Node.js 开发者，我很早便了解到了 TypeScript，但又因为我对 CoffeeScript 的喜爱，直到 2016 年才试用了一下 TypeScript，但当时对它的学习并不深入，直到最近又在工作中用 TypeScript 开发了两个后端项目，对 Ty]]>
    </summary>
    <title>TypeScript：重新发明一次 JavaScript</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="购物" scheme="https://jysperm.me/tag/shopping/"/>
    <content>
      <![CDATA[<p>我从小一直生活在湿度很低的北方，常年鼻子和咽喉不舒服，经常流鼻血，但因为从小如此，也没觉得有什么不对劲。而来到苏州之后，我就觉得苏州湿润的空气真是好呀，鼻炎和咽炎的症状几乎没有了。但等到冬天开空调的时候就觉得无法再回到那么干燥的环境了，于是开始疯狂地买加湿器，这么多年下来，应该买过差不多十个加湿器了。</p><h2 id="加湿原理"><a href="#加湿原理" class="headerlink" title="加湿原理"></a>加湿原理</h2><p><strong>超声波</strong> 加湿器使用超声波将水打散成为非常小的小液滴，然后用风扇吹出，小水珠通常会在落地之前蒸发为水蒸汽，溶解至空气中。超声波加湿器是使用最普遍、价格最低、同价位加湿量最大的加湿器类型。其主要缺点是超声波会将水中的固体颗粒物和溶解物一起吹出，且容易有颗粒较大的液滴未能在落地之前完全溶解到空气中。虽说超声波加湿器被认为并没有耗材，但实际上其核心组件雾化片的寿命只有两年左右，且难以更换。</p><p><strong>冷蒸发</strong> 加湿器使用风扇将空气吹过吸水的多孔材料，加速水的蒸发。冷蒸发是近一段时间中被高端的加湿器所采用的技术，不会有固体颗粒物进入空气，加湿量随湿度变化有一定自我调节的能力。其主要缺点是单位价格加湿量较低（且加湿量和噪音成正比），用于吸水的多孔材料作为耗材需要定时更换。</p><p><strong>热蒸发</strong> 加湿器简单地通过加热水来产生水蒸汽。热蒸发加湿量较大（和功率成正比），同样没有固体颗粒物进入空气。其主要缺点是耗电较多，会增加室内温度（冬天倒是不要紧），且高温蒸汽遇到低温物体会产生水珠凝结。</p><table><thead><tr><th>类型</th><th>价格</th><th>水珠凝结</th><th>耗电</th><th>耗材和使用寿命</th><th>固体颗粒物</th><th>加湿量(同价位)</th></tr></thead><tbody><tr><td>超声波</td><td>低</td><td>有</td><td>低</td><td>无耗材 寿命短</td><td>有</td><td>高</td></tr><tr><td>冷蒸发</td><td>高</td><td>无</td><td>低</td><td>有耗材</td><td>无</td><td>低</td></tr><tr><td>热蒸发</td><td>中等</td><td>有</td><td>高</td><td>无耗材</td><td>无</td><td>中等</td></tr></tbody></table><p>在加湿原理上面我觉得很难一概而论，需要具体情况具体分析。但如果你不知道怎么选的话，可以先买个性价比最高的超声波加湿器试试看。</p><h2 id="湿度"><a href="#湿度" class="headerlink" title="湿度"></a>湿度</h2><p>我们通常说的湿度其实是指相对湿度，即相对于当前空气中能溶解水蒸汽的最大量来说的湿度。人在不同的温度下对于同一相对湿度的感受是完全不同的，在 28 度下 40% 的相对湿度会比较舒适，而在 22 度下则需要 60% 的相对湿度才能达到相同的感受。</p><p>于是我找到了一个单一指标来衡量体感湿度 —— <a href="https://zh.wikipedia.org/zh-cn/%E9%9C%B2%E7%82%B9">露点</a>，露点描述的是在当前温度和相对湿度的条件下，空气中的水蒸汽遇到多冷的物体时会凝结成小水珠，综合了温度、相对湿度和大气压力的影响。</p><p>对于我而言我觉得理想的露点是在 13 度左右，也就是我前面说的 28 度下 40%、22 度下 60%。如果你不像我这么喜欢湿润的空气，可以调低一点，例如 10 度左右。</p><h2 id="加湿量"><a href="#加湿量" class="headerlink" title="加湿量"></a>加湿量</h2><p>首先我们根据房间容积（这里以我家客厅 90 立方米计算）、目标温度和目标湿度来计算所需要的加湿量。我这里的计算并没有扣除家具所占用的空间，主要是考虑到家具本身（尤其是织物）也是会吸水的，所以综合考量下来不加也不减。</p><p>先计算不开空调和加湿器时的基准值，按室外温度 10 度，相对湿度 60% 计算，查表可得每立方米有 5.63g 的水蒸汽，总共就是 <code>90m^2 * 5.63g = 506.7g</code>，相当于我们房间里本来就有这么多水。</p><p>然后按照目标温度 24 度，目标露点 13 度，算出目标相对湿度是 50%，查表可得每立方米有 10.85g 的水蒸汽，总共就是 <code>90m^2 * 10.85g = 976.5g</code>，相当于我们的目标是房间里有这么多水。</p><p>这意味着我们需要补充差不多 500g 的水到空气中，但需要注意的是房间并不是密闭的，房间里的空气一直在和外界进行着交换，水蒸汽会流失到外界，也会在低温物体（如玻璃）上凝结为小水珠。以我个人的经验，如果你需要补充 500g 的水到空气中的话，那差不多你就需要一个加湿量 500ml&#x2F;h 的加湿器 —— 相当于每小时房间里的水分都会流失掉一遍。如果加湿器的加湿量不够的话，就会出现即使一直开到最大，湿度也升不上去的情况。</p><p>500ml&#x2F;h 已经算是加湿量比较大量的加湿器了，你需要考虑是使用多个加湿器还是单个加湿器。多个加湿器的话维护成本会比较高，你需要单独为每个加湿器加水、进行清洗和维护；单个加湿器的话则要考虑如何增加空气的对流，让整个房间保持相对均匀的湿度，例如冬天开空调的话，可以把加湿器放在空调可以直接吹到的地方。</p><h2 id="功能"><a href="#功能" class="headerlink" title="功能"></a>功能</h2><p>在购买加湿器的时候需要留意这些功能：</p><ul><li><strong>上加水</strong> 极大地提升了加水的便利性，直接关系到使用体验。</li><li><strong>水箱</strong> 注意确认容量是否足够、水箱的形状是否容易清洗。</li><li><strong>通电自动启动</strong> 有些加湿器在接通电源后还需要触摸按键才能启动，这样便无法与智能插座配合使用。</li><li><strong>出雾高度和方向</strong> 出雾方向是否可调，这会影响加湿器放置的灵活性。</li><li><strong>紫外线除菌</strong> 见后面「固体颗粒物」小节。</li></ul><p>可有可无的功能：</p><ul><li><strong>红外线遥控</strong> 可以和智能家居组件配合使用。</li><li><strong>指示灯</strong> 如果是放在卧室的话，需要确认指示灯的亮度以及是否可以关闭，否则可能关灯后会显得太亮。</li><li><strong>智能控制</strong> 大多需要专门的应用，难以和既有的智能家居一同工作，不如用智能插座或红外遥控来解决。</li><li><strong>恒湿</strong> 位于机器本体上的湿度传感器非常不准确，效果有限（尤其对于超声波加湿器）。</li></ul><p>不太需要关注的功能：</p><ul><li><strong>静音</strong> 大多数超声波加湿器都没多大差别。</li><li><strong>缺水保护</strong> 所有加湿器都有的功能。</li></ul><p>没什么用的功能：</p><ul><li><strong>银离子</strong> 效果要比紫外线差很多，而且需要耗材。</li><li><strong>净水滤芯</strong> 本来加的都是干净的水，没有必要过滤，而且需要耗材。</li><li><strong>负离子</strong> 并没有可靠的资料表明这个功能有什么作用。</li></ul><p>关于大雾量加湿器：</p><p>单个超声波雾化片可以提供 300ml&#x2F;h - 400ml&#x2F;h 的加湿量，再大的加湿量就需要考虑多个雾化片的加湿器了。</p><h2 id="固体颗粒物"><a href="#固体颗粒物" class="headerlink" title="固体颗粒物"></a>固体颗粒物</h2><p>超声波加湿器会将水打成小液滴，这会导致家用的固体颗粒物传感器瞬间爆表，但实际上家用的传感器是无法区分小液滴和真正的固体颗粒物的，因此这个传感器的数值并不能说明有多少固体颗粒物进入到了空气中。</p><p>但我们仍有必要考虑微生物在加湿器内滋生并随着小液滴进入空气的情况，市场上目前有紫外线和银离子两种方案来解决这一问题。</p><p>经过我的调查，紫外线的除菌效果要远优于银离子，且不需要耗材：</p><ul><li>紫外线被标注为「除菌」，而银离子被标注为「抑菌」</li><li>网络上一些细菌培养实验显示紫外线的除菌效果更好</li><li>高端的加湿器通常采用紫外线除菌</li></ul><p>下一个问题就是加湿器中到底应该加什么水呢？</p><p>一派观点认为自来水中的余氯有助于减少加湿器中的细菌；另一派观点认为有条件加纯水当然应该加纯水，可以减少被超声波打入空气中的溶解物数量（尤其一些地区自来水的 TDS 非常高），也可以延长超声波雾化片、冷蒸发的多孔材料、热蒸发的电热管的使用寿命。</p><p>所以综合考虑，建议大家选择有紫外线除菌功能的加湿器，然后向加湿器中添加净水器产生的纯水来避免自来水中的溶解物进入空气。</p><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li><a href="https://zh.wikipedia.org/zh-cn/%E9%9C%B2%E7%82%B9">露点</a></li><li><a href="https://www.mrfixitbali.com/images/articleimages/dew-point-chart-full.pdf">相对湿度与露点对照表</a></li><li><a href="http://rolfb.ch/tools/thtable.php?tmin=0&tmax=46&tstep=1&hmin=5&hmax=100&hstep=5&acc=2&calculate=calculate">相对湿度与绝对湿度对照表</a></li><li><a href="https://www.qwyw.org/archives/2136">器物于我：加湿器：超声波、蒸发和电热</a></li><li><a href="https://www.ifanr.com/1164823">爱否科技：加湿器选购全指南</a></li><li><a href="https://post.smzdm.com/p/apzeo089/">老爸测评：从 100 到 4000的加湿器，谁才是性价比之王？ </a></li></ul>]]>
    </content>
    <id>https://jysperm.me/2020/02/buying-humidifier/</id>
    <link href="https://jysperm.me/2020/02/buying-humidifier/"/>
    <published>2020-02-06T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>我从小一直生活在湿度很低的北方，常年鼻子和咽喉不舒服，经常流鼻血，但因为从小如此，也没觉得有什么不对劲。而来到苏州之后，我就觉得苏州湿润的空气真是好呀，鼻炎和咽炎的症状几乎没有了。但等到冬天开空调的时候就觉得无法再回到那么干燥的环境了，于是开始疯狂地买加湿器，这么多年下来，]]>
    </summary>
    <title>加湿器选购指南</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="Golang" scheme="https://jysperm.me/tag/golang/"/>
    <category term="年度技术小结" scheme="https://jysperm.me/tag/programming-of-year/"/>
    <category term="JavaScript" scheme="https://jysperm.me/tag/javascript/"/>
    <category term="容器" scheme="https://jysperm.me/tag/container/"/>
    <category term="Redis" scheme="https://jysperm.me/tag/redis/"/>
    <content>
      <![CDATA[<h3 id="Redis-的工程价值"><a href="#Redis-的工程价值" class="headerlink" title="Redis 的工程价值"></a>Redis 的工程价值</h3><p>今年一开始先是完善了去年的 <a href="https://leancloud.cn/docs/leanengine_cloudqueue.html">任务队列</a>，为了让它真正地被用起来，我将之前由另外一个服务实现的定时任务系统也合并进了这个任务队列，让它有了一些固定的流量，以便我来发现性能上的问题，进一步完善它。</p><p>这项工作最后取得了还不错的效果，开始有了用户使用任务队列的功能。我也写了一篇文章来介绍 <a href="https://jysperm.me/2019/08/redis-cronjob-system/">用 Redis 来实现定时任务</a>，当然这篇文章中介绍的是一种经过极度简化的范式，实际上实际的代码要复杂的多，例如序列化、错误处理、结果查询、限流、统计等额外的特性。</p><p>这个项目让我再度增加了对 Redis 的好感，这次我用到了比较大量的 Lua Script 来保证分布式架构下的一致性（Redis 的Lua Script 会被独占地执行）。在将一致性需求限制到单个 Redis 实例可以容纳的范围（Redis 只使用一个线程）并且 Redis 运行相对稳定的情况（故障切换会丢失数据），Redis 为业务层提供了一个非常「够用」的高性能的、有一致性保证的数据同步方案。这并不是说 Redis 提供的方案有多么完美，而是在性能、功能、一致性、可靠性上提供了一个非常好的这种，更具有工程价值。</p><h3 id="Golang-的表达能力"><a href="#Golang-的表达能力" class="headerlink" title="Golang 的表达能力"></a>Golang 的表达能力</h3><p>我还尝试了为 LeanCloud 写一个 Golang 的 SDK（后来因时间安排的关系暂停掉了，目前还未发布），就像之前我为 <a href="https://github.com/jysperm/deploybeta">DeployBeta</a> 写 ORM 一样，同样遇到了 Golang表达能力不足的问题。问题主要在于 Golang 中并没有能让 Struct 继承方法的机制（数据字段则可以通过内嵌匿名 Struct 的方式来继承）。</p><p>所以当用户定义一个「继承」自 ORM 基类的 Struct 时，我们无法向用户定义的 Struct 上添加例如 Save、Set 之类的方法，无法有效地追踪用户对于数据对象的改动。</p><p>经过几个版本的改动，我最后选择了一种将所有基本类型（string、int）包装为 Struct 的方案：</p><pre><code>type Todo struct {  orm.ObjectMeta  Name     orm.String `orm:&quot;name&quot;`  Priority orm.Number `orm:&quot;priority&quot;`}todo := Todo{  Name:     orm.NewString(&quot;test&quot;),  Priority: orm.NewNumber(1),}err = orm.Save(&amp;todo)todo.Name.Set(&quot;test&quot;)todo.Priority.Incr(1)err = orm.Save(&amp;todo)fmt.Println(todo.Name.Get(), todo.Priority.Get())</code></pre><p>这个方案可以做到不以字符串的形式传递字段名（可以得到编译期的类型检查），可以追踪对每个字段进行的修改（包括 Incr 等运算）。我将 Set 添加到了基本类型的封装类型上，将 Save 作为了一个全局方法，避开了 Golang 对于继承的限制。带来的问题则是用户需要通过我们的封装方法（Get）来访问字段的值；同时今后设计嵌套对象时也需要更大的工作量。</p><p>所以并不是如 Golang 的支持者说的那样，更少的特性意味着更简单的设计。当业务逻辑确实复杂，语言表达能力又非常匮乏的情况下，会逼着开发者做出一些不优雅的、不易理解的、反常规的设计，这些代码往往非常容易出错（例如反射、代码生成、强制类型转换等），而本来这些需求（如继承）在其他语言里是可以非常轻易地解决的。</p><h3 id="TypeScript-的胜利"><a href="#TypeScript-的胜利" class="headerlink" title="TypeScript 的胜利"></a>TypeScript 的胜利</h3><p>之前因为对 CoffeeScript 的喜爱，我的 TypeScript 使用经验非常少，终于今年我也不得不去接受 TypeScript 了。今年我用 TypeScript 开发了两个新的后端项目，也更深入地学习了 TypeScript，经过进一步的了解，我逐渐地发现了 TypeScript 的闪光点，之后我会单独写一篇文章来介绍 TypeScript。</p><p>TypeScript 有着一个先进的类型系统，这种先进并非是学术意义上的先进，而是工程意义上的先进。它几乎可以为所有 JavaScript 中常见的范式添加静态约束，得益于强大的类型推导，在大部分情况下并不需要自己添加类型标注，但却能在编译期提前发现错误、配合 Language Server 得到准确的代码补全和类型提示信息，完全没有前面提到的 Golang 中的那种束缚感。</p><p>因为 TypeScript 并不打算创造新的范式，而是尽可能将 JavaScript 社区中已有的范式用静态类型的语义描述起来。这样最大程度上地降低了 JavaScript 开发者学习的成本，提高了与标准 JavaScript 代码的互操作性，我认为这也是 TypeScript 能够取得成功的关键。</p><p>同时我也不得不接受 Atom 的市场已经几乎完全被 VS Code 取代的现实，切换到了对 TypeScript 支持更好的 VS Code。现在想想 Atom 失败的原因一方面是在 CoffeeScript 已经表现出没落的时候选择了 CoffeeScript；另一方面是希望依靠社区的力量，但又缺乏对社区的引导。例如对于插件的 GUI 改动引导不够导致界面卡顿，对于代码补全、调试等常见需求没能建立统一的标准等等。</p><h3 id="Kubernetes-的阴谋"><a href="#Kubernetes-的阴谋" class="headerlink" title="Kubernetes 的阴谋"></a>Kubernetes 的阴谋</h3><p>今年其中一个新项目是开发一个数据库调度平台，在 Kubernetes 上运行数据库容器，这和我之前在 DeployBeta 实现的原型非常相似，只不过这次是真的要上线的项目。</p><p>在去年和今年对 Kubernetes 的了解过程中我逐渐对 Kubernetes 由粉转黑。我现在认为 Kubernetes 是以 Google 为首的三大云计算巨头的垄断工具，他们开发出了一个如此复杂的系统，并引导其作为行业标准。虽然 Kubernetes 是开源并由社区维护的，但真正能够独立搭建好 Kubernetes 及其插件的公司是极少数，甚至可以说除了三大巨头之外，其他的云计算公司都不能提供稳定可靠的 Kubernetes 集群。最后大家在尝试过自己搭建之后，还是不得不购买三大巨头的 Kubernetes 云，毕竟这是行业标准嘛。</p><p>今年看过觉得最好的书是「<a href="https://union-click.jd.com/jdc?e=&p=AyIGZRprFQEXBVMcWBAyVlgNRQQlW1dCFFlQCxxKQgFHREkdSVJKSQVJHFRXFk9FUlpGQUpLCVBaTFhbXQtWVmpSWRtYEAAUAFYea2tgEW8XWyNhYWFbM1kmdmAbZShLCGUOHjdUK1sUAxICVBteHAUiN1Uca0NsEgZUGloUBhYCXStaJQIWDlAeXBMEEgFQH18lBRIOZUATV0NGRAl1ByUyIgRlK2sVMhE3F3UJQVcaUwAeWhZRGwNQS1IWUhoPAE9eEQMQUlQaWxwAETdXGloRCw==">数据密集型应用系统设计（DDIA）</a>」，它给我的数据库调度平台带来了很多启发。书中介绍了分布式架构对于数据库的挑战，包括数据模型、复制、分区、事务、分布式共识等等，以及各个数据库在面对这些挑战时采取的解决方案，只有理清这些思路，才能在面对复杂的业务的时候采用一种或几种合适的数据库。</p><p>我的理解是当数据存在于两台或更多的计算机之上时（原因可能是容量或可用性要求），就可以称作「大数据」了。因为从一台到两台是一个质的变化，而从两台到更多只不过是量的变化。就如书中所说，在单机条件下，所有的称作都是确定的，一个操作要么成功要么失败（可能伴随着程序或系统的崩溃）；但在分布式条件下，对于经过网络的操作会引入成功和失败之外的第三种情况 —— 网络延迟，你无法预测一个操作会在下一秒完成还是永远都不会完成。所以分布式系统需要被设计成可以在容忍一定的错误（部分失效）的情况下继续运行。无论是一个分布式数据库还是一个分布式的容器平台，其实都在与这种不确定性的超时进行对抗。</p><h3 id="写不下去的业余项目"><a href="#写不下去的业余项目" class="headerlink" title="写不下去的业余项目"></a>写不下去的业余项目</h3><p>现在我愈发认识到软件开发不是一个人的单打独斗，之前在做一些业余项目的时候还会有一些幻想，幻想自己能长期维护下去、能吸引到其他的贡献者、能建立起一个社区。但现在想想还是以内容作为主要的输出更有可行性。同时因为我对现在的工作非常满意，在工作中基本完全满足了我对于写代码和团队协作的欲望，所以我可以将业余时间放在其他的输出形式上，在接下来一年中输出更多的文章或视频，这样我的经验和知识会给读者带来更大的价值。</p>]]>
    </content>
    <id>https://jysperm.me/2020/01/programming-of-2019/</id>
    <link href="https://jysperm.me/2020/01/programming-of-2019/"/>
    <published>2020-01-30T17:00:00.000Z</published>
    <summary>
      <![CDATA[<h3 id="Redis-的工程价值"><a href="#Redis-的工程价值" class="headerlink" title="Redis 的工程价值"></a>Redis 的工程价值</h3><p>今年一开始先是完善了去年的 <a href="https://lea]]>
    </summary>
    <title>2019 年度小结（技术方面）</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="年度小结" scheme="https://jysperm.me/tag/summary-of-year/"/>
    <category term="蛋黄" scheme="https://jysperm.me/tag/bud/"/>
    <content>
      <![CDATA[<p>转眼又一年过去了，今年我将更多的时间放在了除了编程之外其他的业余爱好上面，例如太空探索、开车、乐高、视频拍摄和剪辑、无人机、游戏、记账等等。</p><p>在今年年初和蛋黄一起去 <a href="https://jysperm.me/2019/01/cruise-line/">体验了一次游轮</a> —— 一种不需要做计划、自助吃喝、走几步路就可以回到室内、再多走几步就能回到房间的「佛系旅行项目」，不喜欢出门的蛋黄对此表示非常满意。</p><p>在 2018 年末我和蛋黄一起报名了驾校，在 6 月拿到驾照的当天，我们就租了一辆共享汽车上路了，然后第二天就开去了上海，第三天就出了一次不大不小的事故 —— 蛋黄开着车误上了上海的高速路，在下闸道时减速不及撞到了护栏。</p><p><img src="/pictures/2019/2019-accident.png"></p><p>在驾驶生涯的初期遇到这样一个不大不小的事故其实也算是一种幸运，可以让我们更直接地认识到驾驶本身的危险性、提高安全意识，避免之后更严重的事故。</p><p>在之后的半年里我们也经常开着共享汽车出去玩，加起来可能开了两、三千公里的样子，不过大多还是短途，最远也就是苏州和上海。目前我的驾驶经验绝大部分是在电动汽车上，想想我可能算是第一代直接接触电动车的驾驶者了。我们今年还去了南京、大连和沈阳，我们都分别在目的地租了车，以一种不同于以往的方式在其他城市旅行。</p><p>确实掌握了开车这项技能之后就像打开了新世界大门，占据了城市大量面积的公共道路其实是为汽车而设计的，坐在驾驶位去看到道路的设计、道路上的标识标线，有一种完全不同的感受。也认识到了汽车确实是最伟大的发明之一，极大地拓展了人类的活动空间、活动的自由性。</p><p><img src="/pictures/2019/2019-driving.png"></p><p>今年比之前投入了更多的时间在游戏上面，首先是年初突然发现我曾在 Steam 中买过一个叫坎巴拉太空计划的游戏，恰逢当时对 <a href="https://jysperm.me/2019/07/space-exploration/">太空探索</a> 重拾兴趣，于是便一发不可收拾，目前已成为我 Steam 上游戏时间第二的游戏。</p><p><img src="/pictures/2019/2019-kerbal.jpg"></p><p>在 2017 年我曾从朋友那里借过一台 Switch，但因为他并非任天堂粉丝，机器里也仅有一款任天堂的游戏，所以当时并没有对 Switch 产生兴趣。今年我开始在一些游戏相关的自媒体了解到业界对于任天堂极高的评价，于是在 8 月时入手了一台 Switch，接触到了 <a href="https://jysperm.me/2019/10/zelda-breath-of-the-wild/">赛尔达传说：旷野之息</a>、马里欧：奥德赛、喷射战士 2、健身环大冒险这样的任天堂第一方游戏，这些游戏的设计和制作水平让我完全成为了任天堂的粉丝，不由得有一种过去十几年的游戏都白玩了的感觉。</p><p>在今年一月时我决定用复式记账的方式记一年的帐，来和 <a href="https://jysperm.me/2017/01/expenses-analysis-2016/">2016 年的年度支出分析</a> 做一个比较。复式记账用账户之间款项的转移代替了单纯的支出流水帐，可以更好地体现投资、货币兑换，也可以更好地进行回溯和纠错。</p><p>但因为现在我和蛋黄的支出都是混在一起的，所以需要连着她的帐目一起记，否则这个账就没什么意义了。因此每次记账我都需要去翻她的交易记录，确认一些款项，导致记账这件事情经常拖延很久，到现在也只把帐目清理到了今年 7 月。后面我会尽力把这一整年的帐目整理完，来写一篇日志，但之后可能就不会再这样记完整的账目了，可能会考虑退回之前每个月记录一次个账户快照的方式。</p><p><img src="/pictures/2019/2019-accounting.png"></p><p>在今年中，偶尔几次会有一种通过视频来记录和展示生活中有趣的瞬间的冲动。我用去年买的小蚁微单和今年买的无人机拍摄了不少素材，也录制了一些游戏实况，做了一些尝试，但实际的产出的视频很少。</p><ul><li><a href="https://space.bilibili.com/2983829/">我的 Bilibili 帐号</a></li><li><a href="https://www.youtube.com/channel/UClss8vCFD6Rrt8V3meGUW8A">我的 Youtube 帐号</a></li></ul><p>和文字内容不同，视频的拍摄是不能轻易重来的，需要后期将素材剪辑成作品。而且和之前我录制的播客相比，视频的素材要更加非线性，剪播客更多的是将「不想要的部分剪掉」，而剪视频则是「将想要的部分挑出来」，后者的难度要高出不少。</p><p>同时视频的制作对设备的要求也更高，不过我想着还是不要一下子买太多设备，不如每产出几个视频就升级一样设备，这样可能会让我的视频播主之路变得更有趣，有一种逐渐升级的感觉。</p><p><img src="/pictures/2019/2019-pidan-doufu.png"></p><blockquote><p>皮蛋豆腐还是一如既往。</p></blockquote>]]>
    </content>
    <id>https://jysperm.me/2019/12/summary-of-2019/</id>
    <link href="https://jysperm.me/2019/12/summary-of-2019/"/>
    <published>2019-12-30T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>转眼又一年过去了，今年我将更多的时间放在了除了编程之外其他的业余爱好上面，例如太空探索、开车、乐高、视频拍摄和剪辑、无人机、游戏、记账等等。</p>
<p>在今年年初和蛋黄一起去 <a href="https://jysperm.me/2019/01/cruise-line]]>
    </summary>
    <title>2019 年度小结</title>
    <updated>2026-05-01T15:13:06.184Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="年度小结" scheme="https://jysperm.me/tag/summary-of-year/"/>
    <content>
      <![CDATA[<p>又一年过去了，已经 24 岁了，最近两年我已经有些感觉到了时间的流逝。大概人对时间流逝的感觉是基于已经度过的时间吧 —— 同样的一年在 16 岁时是经历过人生的 1&#x2F;16，而现在的一年则只是 1&#x2F;23。这就开始让我在很多事情上有一种「会不会太晚了」的焦虑，尤其今年移民的进程并不顺利，之前觉得自己年龄小的优势可能会越来越小。</p><p>最近两年学习和输出的节奏也慢了一些，休息时间更多地选择看视频来放松，表达欲望也比之前下降了不少。偶尔会有一些灵感，但大多没能写完，都积压在了草稿箱中，博客快要变成年更了。这当然不是一个好现象，我还是希望能花更多的时间做一些输出。今年我决定减少在业余时间对于写代码的投入，不去造一些很可能没有什么后续的轮子了。而是花更多的时间在写文章，包括技术文章上，这样能帮助到更多人，我也会得到更直接的反馈。</p><p>虽然去年和蛋黄的那次比较大的矛盾在年初就结束了，但对我一整年的心态的影响还是很大，有些患得患失，对我们的未来有点迷茫，或者说觉得未来是很遥远的事情，就想维持现有的状态。就像人们觉得 35 岁之后诞生的科技都是违反自然规律的，现在想想我们互相对于在一起之前或刚在一起时就已经暴露出来的差异，就更容易互相接受；但对于之后才暴露出来的差异则非常难以互相理解。</p><p>蛋黄在年初就说要学习编程并希望直接去找编程相关的工作，一年里她也断断续续、三天打渔两头晒网地学，但进度实在太慢了，也没有什么阶段性的进展，还不愿意听我的建议。所以我还是对她的工作和学习能力有一些担忧、担心她能够找到合适的工作，也担心之前完全交给她的移民的事情是否做出了正确的决策。如果没有的话，其实包括学习编程和移民，都是在坚持一个结果非常渺茫的事情，这种感觉让我非常焦虑。</p>]]>
    </content>
    <id>https://jysperm.me/2019/11/thinking-about-my-23/</id>
    <link href="https://jysperm.me/2019/11/thinking-about-my-23/"/>
    <published>2019-11-24T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>又一年过去了，已经 24 岁了，最近两年我已经有些感觉到了时间的流逝。大概人对时间流逝的感觉是基于已经度过的时间吧 —— 同样的一年在 16 岁时是经历过人生的 1&#x2F;16，而现在的一年则只是 1&#x2F;23。这就开始让我在很多事情上有一种「会不会太晚了」的焦虑]]>
    </summary>
    <title>23 岁的我在想些什么</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="游戏" scheme="https://jysperm.me/tag/gaming/"/>
    <content>
      <![CDATA[<p>开门见山地说，「<a href="https://www.nintendo.com/games/detail/the-legend-of-zelda-breath-of-the-wild-switch/">塞尔达传说：旷野之息</a>」是我玩过最好的 RPG（角色扮演）游戏，可能也是目前最好玩的开放世界、动作解密类的 RPG 游戏。我在八月初买了 Switch 和旷野之息之后，几乎每天晚上、每个周末都在玩，上班也要带着午休的时候玩，出门也要带着边走边玩。目前的游戏时间超过 160 小时，我觉得就算买来 Switch 只用来玩旷野之息，也值了。</p><p>对一个事物最好的评价应该是「恰到好处」，旷野之息就是这样一个恰到好处的游戏，很难说它有什么地方是最好的，但就是各种恰到好处的取舍，让它营造出了一个有着充分的代入感的世界，成为了最好玩的游戏。</p><p><img src="/pictures/2019/zelda-rendering.jpg"></p><p>旷野之息选择了一种类似赛璐珞的风格来渲染 3D 模型，带来了一种非常特殊的、类似水彩画的视觉风格。旷野之息在掌机模式下是 720p 30 帧，TV 模式下是 960p 30 帧，只能说是一个中规中矩的数据，不如同时期游戏在我的 Windows PC 上的效果。实际游玩过程中在极少数光影比较复杂的雨林场景会有明显的掉帧，其他的时候都非常流畅。</p><h2 id="探索开放世界"><a href="#探索开放世界" class="headerlink" title="探索开放世界"></a>探索开放世界</h2><p>旷野之息首先给我带来的就是「探索」的乐趣，我在开始玩旷野之息之前并没有看太多的测评，在打开游戏时其实我对这个「世界」一无所知（尤其塞尔达传说是一个系列作品），但好在本作的主角也失去了记忆，同样对这个世界一无所知，我随着主角的视角探索着这个世界，了解之前发生过的事情，经历着很多个「第一次」，体验着很久都没有过的、有关发现的乐趣。</p><p><strong>如果你准备在近期游玩旷野之息，并且想充分体验这种「探索」的乐趣，那么建议你关掉这篇文章，在完成主线剧情之前不要看任何的测评、教程和攻略。</strong> 如果你不打算在近期游玩的话，还是可以继续看的，毕竟在一段时间后你的记忆就会模糊掉，不至于太严重地影响探索的乐趣。</p><p>有一个说法是，「开放世界」的重点不在于「开放」而在于「世界」。只有构建了一个能够让玩家产生代入感的世界，玩家才会感觉到开放，感觉自己可以在这个世界中获得自由。</p><p>旷野之息便做到了这一点，它有着一个非常大的无缝地图，据网友测算面积大概在 40 - 80 平方公里左右，其中有平原、湿地、火山、雪山、沙漠、峡谷等地形，因为面积足够大，这些地形之间的过渡也非常自然。更关键的是，这几十平方公里的地图是经过精心设计的，而不是随意摆放或随机生成的，地图的每一个角落都恰到好处地安排了可以互动的元素，可以是一个有趣的景观，一个驿站、一个村落、一个怪物营地、一个解谜任务，或是一个可收集要素。</p><p>旷野之息赋予了主角攀爬、滑翔、游泳的能力，因此这个巨大的地图对于玩家来说并不是平面的，而是立体的，玩家可以先攀爬到高处，再通过滑翔越过低处的障碍等等。地形会阻挡或引导玩家的视线，从地图上的一个点到另外一个点不再是一条直线，每一条路线上都会有不同的体验。</p><p><img src="/pictures/2019/zelda-lookout.jpg"></p><blockquote><p>图为从山上远眺海拉鲁平原</p></blockquote><p>当你想要探索一个地区的时候，也会非常自然地先登上高处 —— 这是我第一次在游戏中产生这么直观的念头，也是我第一次在一个游戏里认真地俯瞰远处的事物，因为我知道视线所及的一些都是可探索的，我早晚有一天会去到那里，而不仅仅是一张贴图。前面提到的攀爬、滑翔和游泳都会用到一个「精力值」，用尽的话就会从山上跌落，所以还是要谨慎地在登山过程中寻找落脚点来休息。</p><p>游戏中的主线和支线任务会引导你去探索地图，所有的故事也围绕着这个地图展开。同时在游玩的过程中你也会有一种内在的动力，想要去走遍地图的每个区域。只有这样一个足够庞大和丰富的世界，才会让玩家感受到这是一个完整的世界，而不是自己被局限在某个小区域。再加上天气和昼夜的变化，在不同的时间和天气，来到同一个地方，观感是完全不同的，这在扩充了游戏内容的同时也进一步提升了代入感。</p><p><img src="/pictures/2019/zelda-gradon.jpg"></p><blockquote><p>图为第一次遇到的龙，非常震撼</p></blockquote><p>我们不提人物、剧情、战斗，仅仅这个地图就值得说这么多，实际上我觉得旷野之息的要素完全是基于这张地图展开的。旷野之息并不是要复刻一个「真实」的世界（例如 GTA），而是要在其中去填充有趣的细节，给玩家带来一种经过了精心设计的探索体验。</p><h2 id="非线性流程"><a href="#非线性流程" class="headerlink" title="非线性流程"></a>非线性流程</h2><p>因为开放世界的缘故，旷野之息的剧情是非线性的，玩家可以自由选择完成主线和支线任务的顺序。当然游戏并不会把这些任务摆出来给你选，所以其实是很随机地，你走到了哪里就遇到了什么样的任务。这样的设定造成每个玩家的体验是完全不同的，每个玩家完成每个任务、第一次遇到某个事物的顺序都是不同的，每个玩家觉得有趣和困难的点也不同。</p><p>游戏一开始就告诉你了反派盖农就在城堡里，你需要去城堡里救公主。而其余大部分的剧情都是非常直接地在为这个目标服务，例如有若干段回忆（CG）告诉你一百年前发生的事情，另外一部分有关四大神兽的主线任务则是为击败盖农所做的准备。这样的设计让玩家在探索世界的过程中不至于失去目标，因为你知道你做的一切都是在积蓄力量，等待着有朝一日去城堡战胜盖农，救出公主，拯救世界。城堡位于地图的中央，在整个游戏的过程中你一直都能看得到城堡，记得自己的目标。</p><p><img src="/pictures/2019/zelda-castle.jpg"></p><blockquote><p>图为远处的海拉鲁城堡</p></blockquote><p>不同于其他一些 RPG 游戏，旷野之息中并没有经验、等级和技能树这样的机制来大幅提升主角的能力；防具系统也较为简单，只能增加防御和提供一些特殊效果；完成的神庙数量会增加血量和精力槽（限制攀爬、游泳和滑翔续航）。</p><p>在此之外，游戏并没有限制你在什么样的阶段可以去挑战什么样的敌人。你在游戏的过程中可以感受到更大程度上是因为你的技术的提升（或者说对游戏机制的了解），你才能够逐步地去挑战更强大的敌人。除了一开始的初始台地之外，游戏也没有限制你在什么样的阶段可以去到地图的哪个区域，在非常早的阶段，你就获得了去到地图上任意一点、甚至跳过剧情直接挑战最终 Boss 的能力。</p><p><img src="/pictures/2019/zelda-inside-castle.jpg"></p><blockquote><p>图为进入城堡挑战盖农之前</p></blockquote><p><img src="/pictures/2019/zelda.jpg"></p><blockquote><p>图为赛尔达公主封印盖农</p></blockquote><p>相比于其他一些游戏中手把手地、较为出戏的「新手教程」，旷野之息将所有的引导都放在了游戏流程中。一部分内容会通过不那么显眼的 NPC 对话来交待，另外一部分则可能完全没有介绍，而是将你放到一个较为封闭的环境中，然后等待你自己去摸索发现新机制的用法，然后再逐渐地增加难度、与其他机制进行组合。</p><h2 id="1-1-2-的互动机制"><a href="#1-1-2-的互动机制" class="headerlink" title="1 + 1 &gt; 2 的互动机制"></a>1 + 1 &gt; 2 的互动机制</h2><p>旷野之息的互动机制，包括和怪物的互动（战斗）、和环境的互动（探索）、和谜题机关的互动（解谜）都显得精简而完备，更注重将不同的元素加以组合，用少量的机制来支持多样的玩法，而不是简单地堆砌。</p><p>游戏中没有复杂的战斗技能，魔法效果也仅有三种：冰、火、电。但无论是武器、怪物还是环境都会和这三种基本属性发生互动。</p><ul><li>冰可以可以被火融化、拿着冰属性的武器可以抵御炎热、冰属性的武器可以冻结敌人。</li><li>火会产生上升气流吹起滑翔伞、火可以抵御寒冷、火可以点燃草、篝火或者敌人、烘烤食物。</li><li>电可以在水中传导更远、金属物体可以导电、雷属性的武器可以麻痹目标令其手持的武器掉落。</li></ul><p><img src="/pictures/2019/zelda-ice-and-fire.jpg"></p><blockquote><p>图为使用篝火来融化冰块</p></blockquote><p>游戏中还有温度、天气和日夜变换，这些效果不仅仅在影响着视觉效果，也在非常积极地参与游戏机制：</p><ul><li>玩家需要穿戴对应抗性的防具来抵御过高或者过低的温度，过高的温度会烤熟食物、点燃木质武器；过低的温度则会冷冻食物。</li><li>雨会浇灭火、会让岩壁变得湿滑难以攀爬；金属物体在雷雨天会被雷劈。</li><li>风会影响草燃烧的方向、可以吹走物品、滑翔伞会沿着风的方向滑行。</li><li>夜晚会出现骷髅构成的敌人、萤火虫等生物；NPC 和怪物在夜晚也会睡觉；晚上会有陨石（流星）掉落；昼夜变换也会影响沙漠地区的温度。</li></ul><p>总之这些机制以一种「合乎逻辑」的方式在产生着互相作用，也在影响你游戏时的行为，你会做出一些非常符合逻辑的行为，进一步提升代入感。例如在下雨时因为难以攀爬、能见度不高，你会想要找个地方避雨，点燃篝火来消磨时间；在身上着火的时候会想要跳入水中灭火等等。</p><p>这样的组合机制会让玩家非常积极地去尝试新的互动类型，同时旷野之息的死亡惩罚也非常地低，游戏允许在任何时刻暂停来更换武器和防具、服用料理和药物，在除 Boss 战的任何时候传送和存档，死亡不会掉落任何物品或携带负面效果，非战斗死亡（摔死、溺水）自动在最近的安全地点复活。</p><h2 id="战斗、解谜和其他"><a href="#战斗、解谜和其他" class="headerlink" title="战斗、解谜和其他"></a>战斗、解谜和其他</h2><p>这个世界是如此地吸引我，以至于同样非常吸引人的战斗和解谜被我放在了最后来介绍。</p><p>游戏的战斗和解密共用了一套物理引擎，它支持了前面提到的冰、火、电、风等互动效果，也支持了炸弹、磁铁、冰块、时间暂停这几个有限的解谜技能。这意味着游戏中并不存在明确的「战斗技能」和「解谜技能」的区分，你可以用磁铁吸起铁块来砸死怪物，也可以用电属性的武器来点亮解密关卡中的电路。</p><p>虽然前面提到游戏的要素十分精简，怪物仅有十几种模型，但战斗的流畅性和打击感却意外地好，可以锁定视角、可以潜行暗杀、可以蓄力和跳劈、可以投掷、可以弹反、可以骑马战斗、在飞行中拉弓可以触发「林克时间」—— 放慢时间来提供更多瞄准的时间。同时旷野之息中有弓箭、单手剑、双手剑、枪等不同类型的武器，种类多但不重复。这些机制的组合让旷野之息即使作为一个刷怪的游戏也相当有可玩性。</p><p><img src="/pictures/2019/zelda-guardian.jpg"></p><blockquote><p>图为使用弓箭射击守护者</p></blockquote><p>旷野之息有一个很有趣的设定，武器是有耐久度的，且耗尽会直接消失，无法修复。这意味着无论多厉害的武器，大概在砍了几十下之后便会消失。虽然有些朋友表示非常不舒服，但我觉得一旦接受了这种设定就会感觉到这是一个很好的设计：鼓励玩家尝试不同的武器，而不是一直用着「最好的武器」；同时后期打小怪时也不会那么无聊 —— 我通常是用比较差一点的武器打小怪。</p><p>因为旷野之息的地图如此之大，虽然可以随时传送，但仍有时候需要借助载具来赶路，不算 DLC 中的摩托车的话，唯一可用的载具就是马。而马这个系统的设计也非常地下功夫，和其他的游戏内容很好地结合在了一起，例如游戏中有 15 个以寄存马匹为核心服务的驿站，顺便也承担了其他一些补给功能。</p><p><img src="/pictures/2019/zelda-horse.jpg"></p><p>马匹需要你自己去捕捉，在捕捉到马匹之后还要经过一段时间的磨合来达到最好的操控效果。在地图中有比较明显的「道路」你在骑马的时候会有意识地沿着道路来走 —— 否则马跨越障碍的能力是很差的，游戏中刻意模拟了一种「操纵另外一个生物」的感觉，让你感觉到马并不是万能的，但在有条件的时候你一定会想到它。</p><h2 id="DLC"><a href="#DLC" class="headerlink" title="DLC"></a>DLC</h2><p>在完成主线任务（讨伐盖农）之前我就买了 DLC，作为一个统计爱好者，主要还是看是看中了可以在大地图上显示轨迹的功能：</p><p><img src="/pictures/2019/zelda-map.jpg"></p><p>DLC 中的剑之试炼和英杰的诗篇是在完成主线后开始打的，总体上来说我对这两个 DLC 并不感兴趣，原因是这两个 DLC 都以解密和限制性的战斗关卡为主，相比于游戏本体难度也有提升，但并没有给我带来探索的乐趣，截至本文发布我还未完成剑之试炼。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>旷野之息就是这样一个可以称之为艺术品的游戏，它恰到好处地营造了一个让你有着充分的代入感的世界，也提供了充实的游戏内容，可以轻松支持 200 小时左右的游戏时间。本文也仅仅是从我的个人的角度介绍了最吸引我的部分，但仍有大量的游戏机制和内容并没有提到，在此推荐所有读者尝试一下这款游戏。</p>]]>
    </content>
    <id>https://jysperm.me/2019/10/zelda-breath-of-the-wild/</id>
    <link href="https://jysperm.me/2019/10/zelda-breath-of-the-wild/"/>
    <published>2019-10-26T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>开门见山地说，「<a href="https://www.nintendo.com/games/detail/the-legend-of-zelda-breath-of-the-wild-switch/">塞尔达传说：旷野之息</a>」是我玩过最好的 RPG（角色扮演）游戏]]>
    </summary>
    <title>塞尔达传说：旷野之息（The Legend of Zelda: Breath of the Wild）</title>
    <updated>2026-05-01T15:13:06.182Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="Node.js" scheme="https://jysperm.me/tag/nodejs/"/>
    <category term="Redis" scheme="https://jysperm.me/tag/redis/"/>
    <content>
      <![CDATA[<p>这篇文章会带领大家实现一个基于 Redis 的分布式高可用定时任务系统，其中的 worker 以 Node.js 为例，但其实可以使用任何语言来实现。这篇文章不会给出完整的代码，更侧重于探索的过程。</p><h2 id="高可用设计"><a href="#高可用设计" class="headerlink" title="高可用设计"></a>高可用设计</h2><p>首先我们希望让 worker 是无状态的，这样会大幅减少对于 worker 的高可用需求，也不涉及到 worker 之间的数据同步或者选举。我们将所有的状态集中到 Redis 上，Redis 可以用 Master-Slave + Sentinel 的方式达到一个相对较高的可用性。</p><p><strong>为什么不使用选举 Master 的方式？</strong></p><p>有一种很常见的做法是在单个实例上完成所有的工作，这样甚至连 Redis 都不需要了。但为了保证高可用，往往会同时启动多个实例，然后引入一个「选举」的过程来决定谁是那个完成所有工作的人（称为 master），在 master 失效后，则需要重新进行选举，选出另外一个 master。</p><p>简单来说我觉得这种做法不够「分布式」，只有一个 master 在工作，其他实例只是热备而已，同时选举和对 master 失效的监测也是一个非常麻烦的事情。而在我们的方案中所有的 worker 都是平等的，都在执行任务，可以在任意时候创建或移除 worker。</p><h2 id="核心循环"><a href="#核心循环" class="headerlink" title="核心循环"></a>核心循环</h2><p>对于一个定时任务系统来说，核心的工作是给定时任务们按时间排序，然后找到并等待下一个需要触发的任务。Redis 刚好为我们提供了 <a href="https://redis.io/topics/data-types#sorted-sets">ZSET</a> 这种支持排序的集合类型，ZSET 中存储着若干个互不相同的 member（字符串或二进制数据），每个 member 有一个相关联的 score（数字），整个 ZSET 会按照 score 排序，基于这样的数据结构提供了若干操作。</p><p>我们将定时任务的 ID 作为 member 放在一个叫 <code>cronjobs</code> 的 ZSET 里，使用下次触发时间作为 score 来排序，这样便得到了一个以下次触发时间排序的列表。</p><p>我们可以这样向系统中添加任务：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">redisClient.<span class="title function_">zadd</span>(<span class="string">&#x27;cronjobs&#x27;</span>, <span class="title function_">nextTriggerAt</span>(cronjobId), cronjobId)</span><br></pre></td></tr></table></figure><p>用 <code>ZRANGE cronjobs 0 -1 WITHSCORES</code> 可以看到已添加的任务：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">127.0.0.1:6379&gt; ZRANGE cronjobs 0 -1 WITHSCORES</span><br><span class="line">1) &quot;2&quot;</span><br><span class="line">2) &quot;1565453967832&quot;</span><br><span class="line">3) &quot;1&quot;</span><br><span class="line">4) &quot;1565453998742&quot;</span><br></pre></td></tr></table></figure><p>然后我们可以在 worker 中写一个无限循环，不断地检查这个 ZSET 中 score 最小（触发时间最早）的任务是否已经超过了当前时间，如果是的话就执行这个任务，并修改 ZSET 中的 score 为下次触发时间，Node.js 的代码如下：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">while</span> (<span class="literal">true</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> [cronjobId, triggerAt] = <span class="keyword">await</span> redisClient.<span class="title function_">zrange</span>(<span class="string">&#x27;cronjobs&#x27;</span>, <span class="number">0</span>, <span class="number">0</span>, <span class="string">&#x27;WITHSCORES&#x27;</span>)</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (<span class="built_in">parseInt</span>(triggerAt) &lt; <span class="title class_">Date</span>.<span class="title function_">now</span>()) &#123;</span><br><span class="line">    <span class="comment">// ZADD CH 会返回被修改 member 数量，只有当成功修改了 score 我们才会继续执行，否则说明这个任务已经被其他的 worker 执行了</span></span><br><span class="line">    <span class="keyword">if</span> (<span class="keyword">await</span> redisClient.<span class="title function_">zadd</span>(<span class="string">&#x27;cronjobs&#x27;</span>, <span class="string">&#x27;CH&#x27;</span>, <span class="title function_">nextTriggerAt</span>(cronjobId), cronjobId)) &#123;</span><br><span class="line">      <span class="comment">// 异步地运行任务，避免「阻塞」核心循环</span></span><br><span class="line">      <span class="title function_">runJob</span>(cronjobId)</span><br><span class="line">    &#125;</span><br><span class="line">  &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">    <span class="comment">// 等待下一个任务触发，如果距离下一个任务的触发少于 10 秒，则等待下一个任务执行，否则等待 10 秒后重试。</span></span><br><span class="line">    <span class="keyword">await</span> bluebird.<span class="title function_">delay</span>(triggerAt ? <span class="title class_">Math</span>.<span class="title function_">min</span>(<span class="built_in">parseInt</span>(triggerAt) - <span class="title class_">Date</span>.<span class="title function_">now</span>(), <span class="number">10000</span>) : <span class="number">10000</span>)</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>上面的循环构成了这个定时任务系统最核心的部分，后面我们会逐渐地完善他。</p><p><strong>为什么不用 Keyspace Notifications?</strong></p><p>在社区中有很多文章推荐简单地使用 Keyspace Notifications 来实现「定时」，即为一个 key 设置一个过期时间，然后订阅这个 key 过期的事件。但这种方式主要的问题是 Redis 的 Pub&#x2F;Sub 并不保证送达，如果刚好在这个 key 过期时 worker 不在线，那么这一次触发就不会生效；如果刚好有多个 worker 在线，那么这一次触发的任务也可能被执行多次。</p><p>而我们选择的基于 ZSET 的方式，需要 worker 主动修改 ZSET 中的下次触发时间，即使 worker 暂时不可用，在恢复时也会继续执行之前剩余的任务。</p><p><strong>这样 Redis 就变成了系统的单点？</strong></p><p>是这样的，这个系统中几乎全部的状态都存储于 Redis 上，可以说是系统中的单点。但相比于 worker，Redis（或其他的数据库）是一个更稳定、更标准化的组件。你可以用官方的 Master-Slave + Sentinel 方案来达到一个相对较高的可用性，你也可以使用由云服务厂商提供的托管 Redis 产品，避免自己来维护它。</p><h2 id="继续完善"><a href="#继续完善" class="headerlink" title="继续完善"></a>继续完善</h2><h3 id="CRON-表达式"><a href="#CRON-表达式" class="headerlink" title="CRON 表达式"></a>CRON 表达式</h3><p>前面的代码中我们并没有实现 nextTriggerAt，你可以用 cron-parser 这样的库去解析 CRON 表达式，计算下次触发时间：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> cronParser = <span class="built_in">require</span>(<span class="string">&#x27;cron-parser&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">nextTriggerAt</span>(<span class="params">cronjobId</span>) &#123;</span><br><span class="line">  <span class="comment">// 从 Redis 或其他数据库中根据 cronjobId 拉取定时任务的详情</span></span><br><span class="line">  <span class="keyword">const</span> cronjobInfo = <span class="keyword">await</span> <span class="title function_">getCronjobInfo</span>(cronjobId)</span><br><span class="line">  <span class="keyword">return</span> cronParser.<span class="title function_">parseExpression</span>(cronjobInfo.<span class="property">cron</span>).<span class="title function_">next</span>().<span class="title function_">getTime</span>()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="中断任务处理"><a href="#中断任务处理" class="headerlink" title="中断任务处理"></a>中断任务处理</h3><p>如果一个 Worker 意外退出，那么当时正在被它处理的所有任务都会永久性地丢失。为了避免这种情况，我们将正在执行的任务也存储到 Redis 中（一个叫 <code>running</code> 的 ZSET）：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (<span class="keyword">await</span> redisClient.<span class="title function_">zadd</span>(<span class="string">&#x27;cronjobs&#x27;</span>, <span class="string">&#x27;CH&#x27;</span>, <span class="title function_">nextTriggerAt</span>(cronjobId), cronjobId)) &#123;</span><br><span class="line">    <span class="comment">// 为每个任务生成一个随机的 uuid 以便能单独地追踪每个任务，例如打印到日志中</span></span><br><span class="line">  <span class="keyword">await</span> redisClient.<span class="title function_">zadd</span>(<span class="string">&#x27;running&#x27;</span>, <span class="title class_">Date</span>.<span class="title function_">now</span>() + <span class="number">60000</span>, <span class="string">`<span class="subst">$&#123;cronjobId&#125;</span>:<span class="subst">$&#123;uuid.v4()&#125;</span>`</span>)</span><br><span class="line">  <span class="title function_">runJob</span>(cronjobId).<span class="title function_">finally</span>( <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="comment">// 在一个任务被完成时，我们还需要将它从 running 集合中取出</span></span><br><span class="line">    redisClient.<span class="title function_">zrem</span>(<span class="string">&#x27;running&#x27;</span>, uniqueId)</span><br><span class="line">  &#125;)</span><br><span class="line"> &#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>如果你有一些为多实例应用编写代码的经验，那么可能会注意到这里存在一个竞态条件：对 cronjobs 和 running 的操作并不是原子的，可能会出现对 cronjobs 的操作成功了，随即 worker 意外退出，没有来得及写入 running 的情况。</p><p>因为这里我们需要对 ZADD 的返回值做判断，所以不能简单地使用 Redis 的 Pipeline 功能，而是要用到 Lua Script：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line">redisClient.<span class="title function_">defineCommand</span>(<span class="string">&#x27;startJob&#x27;</span>, &#123;</span><br><span class="line">  <span class="attr">lua</span>: <span class="string">`</span></span><br><span class="line"><span class="string">    local cronjobId = ARGV[1]</span></span><br><span class="line"><span class="string">    local jobName = ARGV[2]</span></span><br><span class="line"><span class="string">    local nextTriggerAt = tonumber(ARGV[3])</span></span><br><span class="line"><span class="string">    local timeoutAt = tonumber(ARGV[4])</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">    local changed = redis.call(&#x27;ZADD&#x27;, &#x27;cronjobs&#x27;, &#x27;CH&#x27;, nextTriggerAt, cronjobId)</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">    if changed ~= 0 then</span></span><br><span class="line"><span class="string">      redis.call(&#x27;ZADD&#x27;, &#x27;running&#x27;, timeoutAt, jobName)</span></span><br><span class="line"><span class="string">    end</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">    return changed</span></span><br><span class="line"><span class="string">  `</span></span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>经过修改后的核心循环：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> jobName = <span class="string">`<span class="subst">$&#123;cronjobId&#125;</span>:<span class="subst">$&#123;uuid.v4()&#125;</span>`</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> (<span class="keyword">await</span> redisClient.<span class="title function_">startJob</span>(cronjobId, jobName, <span class="title function_">nextTriggerAt</span>(cronjobId), <span class="title class_">Date</span>.<span class="title function_">now</span>() + <span class="number">60000</span>)) &#123;</span><br><span class="line">  <span class="title function_">runJob</span>(cronjobId).<span class="title function_">finally</span>( <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    redisClient.<span class="title function_">zrem</span>(<span class="string">&#x27;running&#x27;</span>, uniqueId)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>然后我们便可以添加另外一个循环，从 running 中拉取已经超时的任务进行重试或其他处理，这里不再给出具体的代码。</p><p><strong>Lua Script</strong></p><p>Lua Script 是 Redis 提供的一种类似事务能力，Redis 保证每个 Lua Script 都是串行执行的，中途不会有其他指令被执行，这提供了一种非常强的一致性保证。在实际的开发中，我们可以将需要一致性保证的逻辑写成 Lua Script。</p><h3 id="平滑关闭"><a href="#平滑关闭" class="headerlink" title="平滑关闭"></a>平滑关闭</h3><p>我们不可避免地会对 worker 进程进行新版本的部署或其他维护，因此我们需要一种平滑的方式来关闭 worker 进程，让它继续执行已经收到的任务，但不去接受新的任务，在执行完当前的任务之后，主动退出。</p><p>在 Unix 中最正统的方式是实现自定义的 SIGINT 处理器来实现这个功能，即由终端模拟器、进程管理器或容器平台向程序发送 SIGINT 信号，程序即开始进行退出前的清理工作，然后待清理工作结束后，程序主动退出。当然进程管理器也有可能等不及，再发送一个强制结束的 SIGKILL。</p><p>所以我们需要将所有正在执行的任务注册到一个全局的 Promise 数组中，然后在受到 SIGINT 时停止接受新任务，并等待所有正在执行的任务完成后主动退出：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> runningJobs = []</span><br><span class="line"><span class="keyword">let</span> shuttingDown = <span class="literal">false</span></span><br><span class="line"></span><br><span class="line">process.<span class="title function_">on</span>(<span class="string">&#x27;SIGTERM&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span><br><span class="line">  shuttingDown = <span class="literal">true</span></span><br><span class="line"></span><br><span class="line">  <span class="comment">// 等待 runningJobs 中所有的任务完成，无论成功还是失败</span></span><br><span class="line">  <span class="title class_">Promise</span>.<span class="title function_">all</span>(runningJobs.<span class="title function_">map</span>( <span class="function"><span class="params">p</span> =&gt;</span> p.<span class="title function_">catch</span>(<span class="function">() =&gt;</span> &#123;&#125;) )).<span class="title function_">then</span>( <span class="function">() =&gt;</span> &#123;</span><br><span class="line">    process.<span class="title function_">exit</span>(<span class="number">0</span>)</span><br><span class="line">  &#125;)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>修改核心循环，在开始任务时将 runJob 返回的 Promise 存入 runningJobs，然后在任务执行完时取出：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">while</span> (<span class="literal">true</span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (shuttingDown) &#123;</span><br><span class="line">    <span class="keyword">break</span></span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// ...</span></span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (<span class="keyword">await</span> redisClient.<span class="title function_">zadd</span>(<span class="string">&#x27;cronjobs&#x27;</span>, <span class="string">&#x27;CH&#x27;</span>, <span class="title function_">nextTriggerAt</span>(cronjobId), cronjobId)) &#123;</span><br><span class="line">    <span class="comment">// 异步地运行任务，避免「阻塞」核心循环</span></span><br><span class="line">    <span class="keyword">const</span> jobPromise = <span class="title function_">runJob</span>(cronjobId)</span><br><span class="line"></span><br><span class="line">    jobPromise.<span class="title function_">finally</span>( <span class="function">() =&gt;</span> &#123;</span><br><span class="line">      _.<span class="title function_">pull</span>(runningJobs, jobPromise)</span><br><span class="line">    ))</span><br><span class="line"></span><br><span class="line">    runningJobs.<span class="title function_">push</span>(jobPromise)</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// ...</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="容量的横向拓展"><a href="#容量的横向拓展" class="headerlink" title="容量的横向拓展"></a>容量的横向拓展</h3><p>目前这个定时任务系统中的 worker 是可以无限拓展的，但 Redis 却是整个系统中的瓶颈，每个 worker 都需要从 Redis 获取任务来执行。按照我们对于 Redis 通常  70k QPS 的估计，按每个任务需要执行 5 个命令计算，整个系统可以支持每秒 14k 次任务触发，对于绝大部分的场景其实完全够用了。</p><p>如果要继续拓展的话，我的建议是根据业务上的一些区分（例如用户、任务类型）将队列分散到不同的 Key 上面（例如 <code>userA:cronjobs</code> 和 <code>userB:cornjobs</code>），这样便可以利用 Redis Cluster 的分片功能来进行扩展了。</p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>我们用 ZSET 将定时任务按照触发时间排序，然后使用一个无限循环来拉取需要触发的任务，实现了一个分布式定时任务系统的核心部分，读者可以在此基础上根据自己的需要做进一步扩展。</p><p>本文甚至没有给出完整的代码，因此并不能直接地复制到你的项目中使用，更多地在于提出和讨论一种解决方案。社区中也有一些类似的开源组件可供选用，例如 <a href="https://github.com/OptimalBits/bull">Bull</a> 是一个功能完整的任务队列，其中包括了定时任务功能，Bull 使用了和本文类似的 ZSET + Scripting 技术，使用 Redis 作为后端。</p>]]>
    </content>
    <id>https://jysperm.me/2019/08/redis-cronjob-system/</id>
    <link href="https://jysperm.me/2019/08/redis-cronjob-system/"/>
    <published>2019-08-18T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>这篇文章会带领大家实现一个基于 Redis 的分布式高可用定时任务系统，其中的 worker 以 Node.js 为例，但其实可以使用任何语言来实现。这篇文章不会给出完整的代码，更侧重于探索的过程。</p>
<h2 id="高可用设计"><a href="#高可用设计" c]]>
    </summary>
    <title>设计基于 Redis 的定时任务系统（ZSET + Scripting）</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="蛋黄" scheme="https://jysperm.me/tag/bud/"/>
    <category term="太空探索" scheme="https://jysperm.me/tag/space/"/>
    <content>
      <![CDATA[<p>从小我就对太空以及人类对太空的探索特别感兴趣，最近一段时间因猎鹰重型火箭首次商业发射和回收成功，又激起了我对于太空探索的兴趣，今天我想讲一讲我和太空探索的故事以及这件事为何如此让我着迷。</p><p>在 2002 年前后（7 岁），我爸送了我两本书 <a href="https://book.douban.com/subject/1062137/">最新21世纪少年儿童百科</a> 和「十万个为什么」（这个版本全是文字、每本几百页左右、共 12 册，我并没有在网络上找到，现在想起来这本书的目标用户也许是家长才对）。我对太空探索以及其他科学知识的启蒙都来自于这两本书，小时候可以阅读的内容并不多，在之后的近十年中，这两本我起码看了十几遍以上，不过现在其实并不能想起来着两本书上具体讲了什么，只有一些模糊的片段。</p><p>后来 2005 年（10 岁）的暑假，发现号航天飞机在哥伦比亚号的事故后首次 <a href="https://zh.wikipedia.org/wiki/STS-114">执行任务</a>，我记得那半个月我每天都从新闻和报纸关注事件的动态，还用积木（其实是麻将牌）去模拟我所理解的航天飞机发射过程。那时候每天晚上会有一段固定的时间，爷爷会给我解答一些我感兴趣的问题，我当时并不理解太空是怎样的，也不了解飞船是如何工作的。我想搞清楚这些，但受限于当时的知识，我只能以我所理解的方式去提问，所以很多时候并没有得到想要的答案。</p><p>值得一提的时候我高中有那么一年多在玩 <a href="https://zh.wikipedia.org/zh-cn/%E6%98%9F%E6%88%98%E5%89%8D%E5%A4%9C">EVE</a> 这个游戏，其实说起来 EVE 在飞船的操纵上已经十分简化了，游戏背景中的先进科技也绕开了现实中的燃料和光速等限制。但在 EVE 中驾驶飞船给了我对于宇宙的一种直观的认识 —— 星球是如此地大、星球之间的距离是如此地远，其余的空间都是一片虚无，如果没有一个坐标的话，你几乎不可能和别人相遇。</p><p>对我影响比较大的作品还有 <a href="https://book.douban.com/subject/2567698/">三体</a> 和 <a href="https://movie.douban.com/subject/1889243/">星际穿越</a>。三体是我在高中的时候看的，因为我小说其实看得并不多，所以这是第一本让我印象深刻的科幻小说；星际穿越则是我刚刚离开学校（18 岁）时看的，也是我非常喜欢的太空背景电影。这两部作品构建了我对于太空的形象化的认识，无论是三体中的黑暗森林设定还是星际穿越中黑洞的视觉呈现，都很好地把握了宇宙的尺度。当时看完星际穿越我发了一条 <a href="https://twitter.com/jysperm/status/532929099954520064">推文</a>：「看了 Interstellar, 虽然不喜欢结局，但确实是一部非常之优秀的科幻作品。觉得在宇宙面前人类的力量实在太渺小，或许其他的技术难关都可以克服，但时间的限制是永远突破不了的。大概人类永远也无法离开地球，因为一旦到宇宙中去，对于人类，时间或是过于短暂，或是无比漫长。」</p><p>蛋黄同样对太空探索很感兴趣，近两年我们也看了不少相关的纪录片，我们经常互相拉着对方讲新了解到的关于太空探索有趣的事情：蛋黄讲得比较多的是历史，而我会更关注技术。我们关注最多的具体项目还是阿波罗计划了，毕竟是人类首次登上另外一颗星球，直到目前依然保持着人类所到达的最远的地方的记录。在做了更多了解之后，我发现其实有非常多的美国公司（尤其是飞机制造商）都参与了阿波罗计划，负责具体的火箭和飞船制造，而 NASA 主要负责的是设计和组织，整个项目也可以算是人类历史上最宏大的工程之一了。</p><p>提到太空探索的爱好者，就必须要提 <a href="https://store.steampowered.com/app/220200/Kerbal_Space_Program/">Kerbal Space Program</a>（坎巴拉太空计划）这款游戏，基本上在知乎这样的社区只要涉及航天或火箭相关的话题，评论区就一定有人刷坎巴拉的梗。在我入手这个游戏后便很快沉迷无法自发，在游戏中我可以亲手实现之前的文字或视频中见到过无数次的技术：发射入轨、霍曼转移、交会对接、反推着陆、引力弹弓。也对比冲、Δv 之类的数值有了更深刻的认识，之后再看纪录片的时候就能脑补出其中提到的各种操作了。后面我应该会专门写一篇文章来介绍坎巴拉太空计划这个游戏。</p><p>那么为什么我对于太空探索如此着迷呢？首先我还是视它为一种兴趣，是因为去了解这些知识会让我感到高兴和满足，而不是我要用这些知识做一件什么事情。相比于其他的学科，例如数学、物理甚至是计算机，最新的研究成果几乎都是我无法理解的，并不适合作为消遣和兴趣；而美国人登陆了月球、火星发现了液态水、木卫四可能存在生命这些就要好理解得多。这大概是因为作为太空探索的主力 —— NASA 的大部分资金都来自于财政拨款，这要求它必须以一种普通人能够理解的方式，向美国的纳税人解释他们的工作，以得到更多人的支持、得到更多的拨款。</p><p>接下来的几年也非常值得期待，在太空竞赛、人类登月之后，经过几十年的修整，接下来十几年人类很有可能会在航天领域取得新突破。比如 SpaceX 的 BFR 和 Starlink，人类重返月球和登上火星以及更多的深空探测计划。</p>]]>
    </content>
    <id>https://jysperm.me/2019/07/space-exploration/</id>
    <link href="https://jysperm.me/2019/07/space-exploration/"/>
    <published>2019-07-01T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>从小我就对太空以及人类对太空的探索特别感兴趣，最近一段时间因猎鹰重型火箭首次商业发射和回收成功，又激起了我对于太空探索的兴趣，今天我想讲一讲我和太空探索的故事以及这件事为何如此让我着迷。</p>
<p>在 2002 年前后（7 岁），我爸送了我两本书 <a href="ht]]>
    </summary>
    <title>我和太空探索的故事</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="读书" scheme="https://jysperm.me/tag/reading/"/>
    <category term="电影" scheme="https://jysperm.me/tag/movie/"/>
    <category term="太空探索" scheme="https://jysperm.me/tag/space/"/>
    <content>
      <![CDATA[<p>除了是哆啦 A 梦的铁杆粉丝之外，我也算是刘慈欣的粉丝 —— 三体自然不用说，其他所有的短篇小说我都看过两到三遍，这些短篇小说一点都不比三体逊色，流浪地球就是其中之一。</p><p>刘慈欣的小说向来重设定而轻情节，小说关注的是全人类的命运，主角只是叙述的视角而非故事的核心。在阅读小说的过程中，你体验到的不是沉浸感，而是一种站在上帝视角观察人类的新奇感，其中的人物只是当时社会中人类的典型代表。他会在小说中使用类似「刹车时代」、「威慑纪元」这样的标题来强化这种上帝视角，让书中短短几句的描述比电影的特效画面更加让人感到震撼。</p><p>最近我又重温了刘慈欣的几个短篇小说，包括：</p><ul><li>流浪地球：给地球装上发动机来驶离太阳系</li><li>镜子：一台能够根据宇宙的初始状态模拟整个宇宙的计算机</li><li>吞食者：外星人通过环状太空船套住地球并掠夺地球的资源</li><li>山：一个诞生于行星地心的机械文明探索世界的故事</li><li>地火：通过点燃地下的煤层的方式来开采水煤气</li><li>地球大炮：挖一条贯通地球地心的隧道</li><li>赡养人类：在贫富差距达到极致之后，99% 的财富都集中到了一个人</li></ul><p>回到流浪地球这个电影，视觉效果我还是很满意的，也确实是头一次看到中国的地标能够出现在科幻片或者灾难片中。情节上前半部分对于世界观的介绍还可以，但后半部分就非常俗套了，就是一个很普通的、几个英雄拯救地球的故事。其实我更宁可电影能从中间结束 —— 让地球撞木星也不是不可以。</p><p>前面提到过，流浪地球的小说关注的是全人类的命运、全人类在面对灾难时的反应，给人一种宏大和震撼的感觉，而电影则完全没有继承小说的精神内核，显得格局太小，更偏向于主角在整个大的世界观中的个人体验，而不是整个流浪地球计划。</p><p>我认为小说中有两个关键的点电影没有表达出来，一是人们对于「太阳」的情感的变化，以前太阳象征着温暖、壮美，但当人们知道这个太阳随时可能会爆炸之后，太阳便变成了恐惧的象征。在变轨的过程中地球一次次地接近和远离太阳，每当地球到达近日点的时候，这种恐惧就会到达顶峰。这种恐惧在几代人的时间里渗透到了社会文化和每个人的心中。而当地球逃离了太阳系、从对太阳的恐惧中解脱之后，人们又开始怀念太阳的温暖，开始怀疑整个计划是否正确。</p><p>另外一点是人们面对灾难时的冷静和理性，小说中有一个情节是在地下城遇到岩浆涌入需要撤离时，自动按照年龄排成一队，因为通常来说越年轻的人对于社会的价值会越大。因为人类知道了自己的命运，但又不知道自己是否可以逃离，太阳又永远悬在天上，这种恐惧深刻地影响了人们对于生活的态度，这也可以解释为什么人类能够组成联合政府、能够在全世界范围内协调和完成如此大规模的地球发动机。</p><p>当然这些评价是我从原著读者的角度来说的，如果作为一个普通的的科幻电影，或普通的国产电影，它应该是合格的。结尾的地方有一个很短的镜头是北京的地下城里有一队人在游行，举着一个「我们要太阳」的牌子，这个才是小说本来的结尾，算一个彩蛋吧。</p>]]>
    </content>
    <id>https://jysperm.me/2019/03/the-wandering-earth/</id>
    <link href="https://jysperm.me/2019/03/the-wandering-earth/"/>
    <published>2019-03-12T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>除了是哆啦 A 梦的铁杆粉丝之外，我也算是刘慈欣的粉丝 —— 三体自然不用说，其他所有的短篇小说我都看过两到三遍，这些短篇小说一点都不比三体逊色，流浪地球就是其中之一。</p>
<p>刘慈欣的小说向来重设定而轻情节，小说关注的是全人类的命运，主角只是叙述的视角而非故事的核心]]>
    </summary>
    <title>流浪地球和大刘的短篇小说</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="年度技术小结" scheme="https://jysperm.me/tag/programming-of-year/"/>
    <category term="游戏开发" scheme="https://jysperm.me/tag/gaming-development/"/>
    <category term="容器" scheme="https://jysperm.me/tag/container/"/>
    <category term="Redis" scheme="https://jysperm.me/tag/redis/"/>
    <content>
      <![CDATA[<p>今年我完成的业余项目确实非常少，勉强算下来也就只有 <a href="https://github.com/jysperm/deploybeta">DeployBeta</a> 的 0.4 版本和 <a href="https://github.com/jysperm/elecpass">Elecpass</a> 的 v3 版本。</p><p>所以我今年得到的一个重要的教训就是 <strong>一次要专注于一个项目</strong>，同时尽快完成一个阶段性的可用版本，尽快发布。尤其是对于我来说，还有全职工作，业余时间并不多，如果不能尽快发布，获得一些反馈，就失去了继续完善的动力，而且会摊子越铺越大，一直无法发布。</p><p>在这一点上，DeployBeta 是一个反面的例子，持续了两年的时间，但也没到可以对外发布的标准。而 Elecpass 是一个正面的例子，在 2017 年 10 月，我花了半个月的时间就发布了两个版本，之后一年多的时间我自己一直在使用，然后在今年十一月集中花了一周的时间发布了 v3 版本（筛选框、布局优化、强化编辑功能、编译 Windows 版），这个项目大部分的时候都是「已发布」状态，而不是还有功能做到一半。</p><hr><p>在 DeployBeta v4 版本的前夕我将 DeployBeta 开源了，在 v4 和后续的未发布版本中，我实现了 MySQL、Redis、MongoDB 三种数据库的支持、重写了基于 Etcd 的 ORM。所以今年我也写了一些 Golang，今年我主要的怨念在于 Golang 中缺乏对于接口数组（或者说泛型数组）的支持，只能使用 <code>interface{}</code> 和反射来实现 ORM 中「获取结果数组」的功能。</p><p>这个 ORM 其实就是将 JSON 数据存储在 Etcd 中，同时提供关系和事务的简单封装。这其实和 Kubernetes 中的 api-server 做的事情差不多，但因为没有找到比较好的关于 Kubernetes 使用 Etcd 的文档，所以我没有太多地参考它。</p><hr><p>为什么容器平台能够简化容器的管理工作呢？我认为一个重要的原因是容器平台提供了一种纯描述式的定义文件，让开发者去描述所期望的最终状态。这一点在 Kubernetes 中实现得最为彻底，我相信这也是 Kubernetes 成功的原因之一。</p><p>今年因为工作的原因，我非常深度地接触了 Kubernetes，在其基础上进行封装，来提供容器服务。Kubernetes 不仅仅是一个工具，同时也是一个平台，它以 RESTFul 风格的 API 将所有功能抽象为资源，然后由每种资源的 Controller 去将对象的实际状态同步到预期的状态。这意味着在 Kubernetes 的基础上你可以去添加自定义的资源和相应的 Controller 去拓展它的功能。</p><p>在对 Dockerfile 的抽象能力忍无可忍之后，今年我用 Node.js 为 Dockerfile 实现了一个简易的 DSL，主要是将 Dockerfile 分为多个段落，然后在每个段落中结构化地保存指令数据，以便在对 Dockerfile 的整个处理过程中随时向任何段落添加或修改指令，最后等到完成所有的处理之后再将结构化的指令数据生成真正的 Dockerfile。经过这样的过程生成的 Dockerfile 有着更规范的格式，更有利于跨应用甚至跨语言之间的缓存。</p><p>其实我们在生产环境使用容器技术已经很多年了，但很多时候只是将已有的程序跑在容器里而已，而没能做到 Container Native。例如我们实际上还有很多容器在依赖本地存储、没有有效的健康检查、不能正确地处理信号来实现平滑关闭。</p><hr><p>我司今年发布了一个 <a href="https://blog.leancloud.cn/6266/">游戏后端解决方案</a>，它本质上是一个「消息转发服务」，帮助游戏的客户端之间来转发消息、同步状态。</p><p>但出于反作弊的需要，我们还需要提供一种在服务器端运行游戏逻辑的能力。对于暴露这种能力的方式，一开始我们内部有两种方案。我认为比较好的方案是将这种在服务器端运行的游戏逻辑也作为一个客户端去加入到消息服务中，以消息服务为中心与其他客户端进行交互。这样做的好处是：</p><ul><li>在服务器和客户端之间复用大部分的游戏逻辑</li><li>单机游戏 &#x3D;&gt; 动作同步 &#x3D;&gt; 状态同步 的迁移过程非常平滑</li><li>服务器端的游戏逻辑和消息转发服务解耦</li></ul><p>为了验证这个方案的可行性，我花了一些时间制作了一个 Demo，实现了一个 <a href="https://jysperm.me/2018/11/play-cards-realtime-game/">简单的回合制卡牌游戏</a>，这种模式后来也被我司发布为了正式的产品：<a href="https://leancloud.cn/docs/client-engine.html">Client Engine</a>。</p><p>在这个过程中我其实是第一次接触游戏后端的开发，其实我并没有去了解既有的游戏框架，但在不知不觉中也重新发明了一些轮子，例如「动作」和「状态」的概念，感觉去探索游戏的开发过程还是挺有意思的一键事情。</p><hr><p>今年年初的时候我尝试为云引擎加上了 <a href="https://forum.leancloud.cn/t/topic/18026">任务队列</a> 的功能，因为云引擎本来已经有了基于 HTTP 的云函数功能，所以我想这个任务队列只提供一种调度的能力，而不提供计算资源，依然通过 HTTP 来调用原本的云函数。我认为这样的形式可以减少引入的新概念，降低介入的成本。</p><p>但低调公布之后的效果并不是很好，我觉得其中一个原因是是我自己就比较少用任务队列，所以比较难站在用户的角度去考虑他们需要任务队列有怎样的功能、希望超时和并发的控制是怎样的。在新的一年里我还会继续改进这个功能，去参考其他类似的云服务如何设计任何队列的功能。</p><p>大概是因为我的服务器端编程经验都在 Node.js 上，Node.js 中异步任务的成本很低，所以不需要出于减少线程开销的考虑去使用任务队列；同时我会通过 Redis 来维护一些关键状态，消除单点、保证因应用重启而中断的任务可以恢复，所以也几乎不需要任务队列去保证任务执行的连续性。</p><p>在这个功能的实现上我重度地使用了 Redis：使用 Redis 存储所有的状态、提供一致性保证，用 Node.js 去实现 Worker，调用 Lua Script 去实现原子操作。说起来 Redis 是我用过最好的服务器端软件之一，我认为 Redis 找到了一个非常好的切入点、找准了自己的定位，才使得它的设计看起来那么简单。</p><p>应该说任务队列的需求是非常多样化的，每种业务可能都会对任务队列有不同的需求，再加上很大程度上又是语言相关的，所以我觉得在这个方面如果能做一些开源项目也会有比较大的空间，例如我就觉得 Redis 5 中的 Stream 类型就是为任务队列设计的，想去写一个充分利用 Stream 特性的任务队列。</p>]]>
    </content>
    <id>https://jysperm.me/2019/01/programming-of-2018/</id>
    <link href="https://jysperm.me/2019/01/programming-of-2018/"/>
    <published>2019-01-30T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>今年我完成的业余项目确实非常少，勉强算下来也就只有 <a href="https://github.com/jysperm/deploybeta">DeployBeta</a> 的 0.4 版本和 <a href="https://github.com/jysperm/el]]>
    </summary>
    <title>2018 年度小结（技术方面）</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="蛋黄" scheme="https://jysperm.me/tag/bud/"/>
    <category term="生活记录" scheme="https://jysperm.me/tag/live/"/>
    <category term="旅行" scheme="https://jysperm.me/tag/travel/"/>
    <content>
      <![CDATA[<p>就如 <a href="/2018/12/summary-of-2018/">2018 年度小结</a> 中说的那样，蛋黄并不喜欢出门旅行，就喜欢宅在家里，那怎么办呢，于是我们想到了一种合适的「佛系旅行项目」—— 游轮。在游轮上不需要做计划、想去哪就去哪，绝大部分项目不需要额外付费，在船上只需要走几步路就可以回到室内、找到卫生间，再多走几步就可以回到房间或者找到餐厅（也不需要额外付费）。</p><p>我们去的是「喜悦号」的游轮，往返上海和福冈（船每次出海停靠日本不同的港口）。5 天 4 夜，其实就是四个整天（两天在海上、一天停靠在日本、登船和下船加起来一天）。总体上我是比较满意的，船上设施和房间内都非常新、非常干净（蛋黄非常看重这一点），这艘船只启用了一年半，三月末就会离开中国，所以还是要把握机会的。</p><p><img src="/pictures/2019/cruise-ship-bow.png"></p><p>整个行程人均 2900 元（2200 房费 + 400 固定支出的「小费」+ 300 额外付费项目），这个性价比我觉得也非常可以了，这个价格是淡季的价格，赶上假期就会贵很多。我们住的是阳台房，基本就是比快捷酒店小一些的房间，卫生间很小但设施设计得很好，所以并不难用，有一个露天阳台可以看海。</p><p><img src="/pictures/2019/cruise-room.png"></p><p>关于船上设施网上的介绍很全了，我就只谈个人感受。</p><p>我们全部吃的是免费餐厅（因为网上测评说收费餐厅并没有比免费的强多少，然后价格很贵），吃得还是很好的，有两种免费餐厅，一种是自助餐，面积很大，饮料、水果和糕点很全、主菜每天会变化但品种不多、没什么海鲜。</p><p><img src="/pictures/2019/cruise-buffet.png"></p><p>另一种是正餐厅，每天有一套不同的菜单（五、六个菜），中西餐的菜混合，每天都有一道虾（很大），还有牛排和三文鱼，这些食材的品质我觉得都是非常好的。然而中餐做得比较「西式」，不看菜单根本猜不出来是什么菜，经蛋黄点拨发现原来是他们不会炒菜，感觉都是炖出来的 … 西餐的话我还是非常满意的。</p><p><img src="/pictures/2019/cruise-dinner.png"></p><p>刚进黄海的时候风景不怎么样，水很浑浊，到靠近日本的地方海才变得非常蓝、非常绿，天也是蓝的，很美。</p><p><img src="/pictures/2019/cruise-sea.png"></p><p>然后晚上可以看到满天的星星，已经很多年没见过了（当然这一点比较依赖于天气，只有一天晚上没有云，才能看到星星）。船很稳，前两天根本感觉，后面两天开始有一些浪，但还是非常稳的，稳到蛋黄觉得船震没有什么特别的感觉。</p><p>上海到日本这片海域，基本全程都能看到其他的船，甲板上有两个望远镜可以用，但蛋黄觉得自己带一个望远镜会更好，可以看海岸、船和海鸥。</p><p><img src="/pictures/2019/cruise-seagull.png"></p><p>室外的气温和昆山差不多，但顶层的风非常大；室内则是恒温的，空调非常足。</p><p><img src="/pictures/2019/cruise-wind.png"></p><p>甲板上有露天的温水游泳池，还看到有人在里面游。</p><p><img src="/pictures/2019/cruise-pool.png"></p><p>船上有很多演出和活动（还有广场舞），都是免费的，我们看了两场歌舞剧和一场杂耍。歌舞剧看不太懂，但舞台效果还是很不错的。船上有几个露天的泳池，里面是温水，看到有几个人在游。还有一些乒乓球台，和蛋黄打了很久的乒乓球。</p><p><img src="/pictures/2019/cruise-deck.png"></p><p>付费娱乐项目我最感兴趣的还是卡丁车，$10 一次五分钟，和之前在昆山玩的比起来价格差不多，但不够刺激：车不够快、不能漂移、赛道窄不容易超车，碰到不会开的人就会一直堵着路，不过既然是在船上也不能要求太高了，我全程玩了三场还是挺开心的。因为船上以大爷大妈为主，所以这些娱乐项目的人很少，不需要排队。</p><p><img src="/pictures/2019/cruise-gokart.png"></p><p>到日本之后的岸上观光项目很坑，基本相当于是被卖给了旅行社（以每个人 500 的价格，不想跟团的话要交 500），带你去一些一点都不好玩的景点、一点都不好吃的「美食广场」、一点都不便宜的免税店，行程还特别赶。</p><p><img src="/pictures/2019/cruise-japan.png"></p><p>关于这个船三月末离开中国，主要是觉得中国人额外的消费太少，工作人员说在欧美航线一周可以卖 50000 瓶啤酒，然而船上基本都是中老年人，酒吧根本没有人。我和蛋黄觉得很大原因是这个公司（这个公司只有这一艘游轮在中国）不了解中国的情况，没有开发出适合中国大爷大妈的额外消费项目，比如搞搞中医按摩针灸、卖点保健品，实在不行搞个水池让大家扔硬币也行呀。有钱的大爷大妈还是很多的，有一天吃饭和做旁边的一对上海大爷大妈聊天，家里在上海几套房，经常坐游轮（估计这是他们坐的最低端的游轮了），说今天买包买衣服就花了两万多 …. 还觉得特别值。</p><p>据说这艘船是为中国人设计的（比如热水和热水壶），船上大部分的服务人员都是中国人，餐厅和客房的服务员则主要是东南亚人。不过总之绝大部分地方都只需要用到中文，对大爷大妈还是非常友好的。东南亚的服务人员的态度非常好，总会笑着打招呼，虽然不会中文也会积极地沟通；然而部分中国的服务人员态度不是很好。</p><p>船上网络是额外付费的，大概 500 元全程，我没有买，因为我已经做好准备把「没有网络」作为这次游轮体验的一部分了。实际上因为每天的活动内容都很丰富，没有网络也不会无聊，就看着船下的海浪都可以看很久。我带了电脑、几本书、iPad 和相机，但实际上并没有用到，就是因为船上的活动太丰富；相机的话，因为我换了 iPhone 8，感觉和相机的拍照效果已经差不多了，所以也没怎么用上。</p><p>我们在船上看了泰坦尼克号作为安全教育片，我事前事后也了解了不少游轮的背景知识以及泰坦尼克号的故事，经过那之后，现在海上的安全标准应该已经非常可靠了，但蛋黄还是表示想坐一坐救生船（忘记拍救生船的照片了）。</p>]]>
    </content>
    <id>https://jysperm.me/2019/01/cruise-line/</id>
    <link href="https://jysperm.me/2019/01/cruise-line/"/>
    <published>2019-01-09T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>就如 <a href="/2018/12/summary-of-2018/">2018 年度小结</a> 中说的那样，蛋黄并不喜欢出门旅行，就喜欢宅在家里，那怎么办呢，于是我们想到了一种合适的「佛系旅行项目」—— 游轮。在游轮上不需要做计划、想去哪就去哪，绝大部分项目不需要]]>
    </summary>
    <title>游轮之行</title>
    <updated>2026-05-01T15:13:06.182Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="年度小结" scheme="https://jysperm.me/tag/summary-of-year/"/>
    <category term="蛋黄" scheme="https://jysperm.me/tag/bud/"/>
    <category term="生活记录" scheme="https://jysperm.me/tag/live/"/>
    <content>
      <![CDATA[<p>转眼又一年过去了，今年我们又回到了昆山，节奏放慢了很多，更多的时间花在了「生活」本身上面。</p><p>就如去年的小结中计划的那样，今年四月我们从北京搬回了昆山，接着搬家的机会添置了很多新物件，比如戴森的吸尘器、洗碗机、全自动咖啡机、米皮新风、扫地机器人、恒温龙头、<a href="https://jysperm.me/2018/09/tenda-nova-mw6/">Mesh 路由器</a>、冰箱、桌子椅子、1.8 米的床和床垫等。昆山的厨房也更大了一点，买了更多的锅具和碗筷，感觉生活质量提高了一大截。</p><p><img src="/pictures/2018/2018-kunshan-1.png"></p><p><img src="/pictures/2018/2018-kunshan-2.png"></p><p><img src="/pictures/2018/2018-kunshan-3.png"></p><p>在昆山安顿下来之后，因为家里有了更多的地方，所以又买了一些玩具，包括 <a href="https://www.bilibili.com/video/av25994969">乐高的跑车</a> 和 <a href="https://twitter.com/jysperm/status/1026117613808537601">乐高的小火车</a>，甚至现在有一个大桌子专门摆我的玩具。</p><p><img src="/pictures/2018/2018-toys.png"></p><p>今年我也花了不少时间在「建造类」游戏上，主要包括 <a href="https://jysperm.me/2018/06/cities-skylines/">天际线（Cities: Skylines）</a>、缺氧（Oxygen Not Included）和异形工厂（Factorio）。异星工厂是我今年新发现的游戏，主要内容是建造自动化的流水线，非常对我的胃口，记得当时连着玩了几天，花了 50 多个小时第一次通关。</p><p>皮蛋豆腐今年也三岁了，和之前比起来它们更加「成熟稳重」了。从搬到昆山开始，豆腐总在猫砂盆旁边的地面上撒尿，经过一番摸索后发现原来是它对每天晚上被关在阳台表示不满。于是从那之后就不再把皮蛋豆腐关在阳台上了，即使我们出门也会把它们留在客厅，在我们睡觉的时候也会让它们进入我们的房间，它们有时候睡在衣柜里、有时候睡在地上、有时候睡在椅子上、还有的时候睡在蛋黄的被子上，但它们要是睡在我的被子上就会被我踢下去。</p><p><img src="/pictures/2018/2018-pidan-doufu.png"></p><p>今年也全面启动移民的进程，我们的主要目标是加拿大，毕竟加拿大是英语国家、是高福利的发达国家，同时也是一个传统的移民国家。对于我来说主要是自考学历和英语这两大块，今年一年都在背单词，但其实三天打渔两天晒网，效果并不是很显著。</p><p>关于英语学习，我觉得 <a href="https://1byte.io/how-to-learn-english/">我们老板的文章</a> 就挺有道理的，要在实际的日常场景中学习英语，将之前消费的一部分中文内容替换成英文。于是我将手机和电脑的系统语言改为英语、开始在洗澡时改听英文的播客、改去 Youtube 直接看一些英文播主的视频、去读技术类的英文书。在产出的方面则新的开源项目都使用英文来写文档和注释，后续还打算开始在 Medium 上开始写英文的博客。</p><p>在九月份的时候，我放弃了之前等自动驾驶汽车的想法，决定考一个驾照，主要是考虑可以创造一种租车前往公共交通不便利的地方的可能，尤其如果以后准备去加拿大的话。于是我和蛋黄一块报名，顺利通过了科目一和科目二，剩下的部分则有待明年继续。</p><p>在今年的最后两个月，我和蛋黄陷入了一场长达两个月的争吵，主要是关于原生家庭的观念冲突。虽然我们都从一开始就认为我们作为一个小家庭应该和双方的原生家庭保持独立，但因为成长环境、和家人的相处方式的不同，在具体的做法上面还是有非常大的分歧。我不想在这里描述细节，但这件事对我们的负面影响都很大，并且一直持续到了 2019 年。</p><p>今年和蛋黄一起去了沈阳、天津和无锡旅行，不过蛋黄其实并不喜欢出门旅行，在外面一副特别「丧」的样子，有时候还发脾气。</p><p><img src="/pictures/2018/2018-yellow-bud.png"></p><p>除此之外，我还和公司一起去了菲律宾、自己去了海南。在菲律宾的长滩岛的体验其实并不怎么好 —— 主要是没什么吃的，虽然是一个小岛，但并没有吃到什么海鲜。</p><p>在 iPhone SE 服役两年半之后，在双十一我入手了 iPhone 8，相比于全面屏和人脸识别，我还是更喜欢指纹。无线充电也非常方便，尤其后来我在家里的桌面上打了一个孔，安装了宜家的嵌入式充电器。</p><p>今年币圈和整个投资市场、全球经济都非常不好，大家都说今年只要不亏就算赚了。在股票上面我今年勉强没有亏，但在币圈可是亏惨了，相比于年初几乎跌掉了八成。</p>]]>
    </content>
    <id>https://jysperm.me/2018/12/summary-of-2018/</id>
    <link href="https://jysperm.me/2018/12/summary-of-2018/"/>
    <published>2018-12-30T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>转眼又一年过去了，今年我们又回到了昆山，节奏放慢了很多，更多的时间花在了「生活」本身上面。</p>
<p>就如去年的小结中计划的那样，今年四月我们从北京搬回了昆山，接着搬家的机会添置了很多新物件，比如戴森的吸尘器、洗碗机、全自动咖啡机、米皮新风、扫地机器人、恒温龙头、<a]]>
    </summary>
    <title>2018 年度小结</title>
    <updated>2026-05-01T15:13:06.185Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="年度小结" scheme="https://jysperm.me/tag/summary-of-year/"/>
    <category term="生活记录" scheme="https://jysperm.me/tag/live/"/>
    <content>
      <![CDATA[<p>蛋黄总说我过去二十几年过得顺风顺水，想想也是，虽然有小的波折，但并没有什么让我后悔的事情。我觉得这当然有运气的成分，但和我个人的选择也是分不开的。</p><p>在大家抱怨学校生活时，我就想找一个彻底的方式来摆脱学校的限制，于是后来我退学了；在大家抱怨工作不称心的时候，我就想着一定要去一家能让我开心地工作的公司，于是我找到了番茄土豆和 LeanCloud；现在我仍在听着大家抱怨着生活中的种种，于是我也在准备着一个能够彻底摆脱这些问题的计划。</p><p>对于这个选择我的心情非常复杂，既有期待也有不安；既怪自己行动得太晚了，但也明白是遇到了蛋黄才让我有了勇气和决心来准备移民。在我们刚刚在一起的时候其实就已经有了这个想法，也在陆续做着准备，在过去的一年中，整个路线逐渐清晰了起来，我们也越来越期待成功的那一天，这将会是我下一个重要的选择。</p><p>国内就总喜欢黑高福利国家人们不好好工作、人力成本高的要死，黑民主国家办事效率低、办不成大事。但我现在越来越觉得，这也许才是一个稳定的国家的常态，充分保障个人的自由、甚至说保障人们「不求上进」的自由，平衡各方的利益，这样才能带来稳定。</p><p>随着一开始的「矜持」被放下，这一年中我们吵得确实比刚在一起的几个月要多得多。但我和蛋黄已经为未来做了这么多规划了，虽然大大小小的分歧吵闹不断，但我还是相信我们会一直走下去。年初我在知乎上回答了一个 <a href="https://www.zhihu.com/question/23932491/answer/321393020">关于完美女朋友的问题</a>，也为自己找到了「完美女朋友」而高兴 —— 当然我现在也是。但还有一点我比较关心的是，蛋黄还没找到工作，我觉得工作是生活中很重要的一部分，我希望她也能找到一个愿意为之投入，也能获取合理的收入的工作。另外我觉得人在开始工作之后可能对于很多事情的看法会有一个比较大的转变，对这种转变我还是期待更多一点，我觉得那时她会更能够理解我的一些想法。</p><p>在 LeanCloud 工作三年了，期间也有人问我要不要考虑其他的工作机会，我想了一下，继续留在 LeanCloud 大概是因为这里的工作既「稳定」又「充满挑战」。这么说也许看似矛盾，但我说的稳定是指，这里的工作全部围绕着 LeanCloud 这个云平台 —— 更具体来说是围绕着云引擎，它本身已经是一个相对稳定的系统了，有着不小的用户量，需要以严肃的态度去对待每一个改动；充满挑战是指围绕这个大的方向，还是可以做很多有趣的尝试的，工作的方向和内容并没有受到限制，而且我会感觉到我个人的决定，对公司的产品是有着直接的影响的。</p>]]>
    </content>
    <id>https://jysperm.me/2018/11/thinking-about-my-22/</id>
    <link href="https://jysperm.me/2018/11/thinking-about-my-22/"/>
    <published>2018-11-24T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>蛋黄总说我过去二十几年过得顺风顺水，想想也是，虽然有小的波折，但并没有什么让我后悔的事情。我觉得这当然有运气的成分，但和我个人的选择也是分不开的。</p>
<p>在大家抱怨学校生活时，我就想找一个彻底的方式来摆脱学校的限制，于是后来我退学了；在大家抱怨工作不称心的时候，我就]]>
    </summary>
    <title>22 岁的我在想些什么</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="LeanCloud" scheme="https://jysperm.me/tag/leancloud/"/>
    <category term="游戏开发" scheme="https://jysperm.me/tag/gaming-development/"/>
    <content>
      <![CDATA[<p>当我们公司决定推出一个「多人在线游戏后端解决方案」的时候，我其实很疑惑会有一个「通用」的方案可以解决所有在线游戏的后端需求么？</p><p>于是在前一段时间，我尝试开发了一个「多人在线卡牌对战游戏」，支持了「斗地主」游戏规则的一个子集，可以在 <a href="https://play-cards.leanapp.cn/" title="play-cards.leanapp.cn">play-cards.leanapp.cn</a> 访问到，源代码在 <a href="https://github.com/jysperm/play-cards" title="jysperm&#x2F;play-cards">jysperm&#x2F;play-cards</a>。不过请不要指望能够匹配到其他玩家 —— 你需要自己开三个窗口，通过左右手互搏的方式来体验游戏，下面是一个演示视频：</p><p><a href="https://streamable.com/belpq">https://streamable.com/belpq</a></p><h2 id="动作同步和状态同步"><a href="#动作同步和状态同步" class="headerlink" title="动作同步和状态同步"></a>动作同步和状态同步</h2><p>其实这个游戏的关键就是在多个客户端之间来同步数据、实现联机游戏，为此我先了解了一下业界总结出的两种同步模型：「动作同步」和「状态同步」。</p><p>在动作同步（帧同步）中：</p><ul><li>客户端发送操作，服务器只转发客户端的操作</li><li>游戏逻辑主要在客户端运行（通常客户端需要掌握所有数据）</li><li>延迟低，适合 RTS、MOBA、FPS</li><li>可以让所有客户端有一个完全一致的时间轴</li></ul><p>游戏状态的计算必须是确定的，不能有随机数，这样才能保证不同的客户端在应用相同的一系列动作之后能够得到相同的状态（游戏画面）。</p><p>在状态同步（C&#x2F;S 同步）中：</p><ul><li>客户端发送操作，服务器转发计算后的游戏状态</li><li>游戏逻辑主要在服务器运行（可以只向客户端发送部分数据）</li><li>易于反作弊，适合 MMORPG、回合制（卡牌）游戏</li></ul><p>因为游戏逻辑需要运行在服务器，所以服务器程序还是必不可少的。</p><h2 id="选型"><a href="#选型" class="headerlink" title="选型"></a>选型</h2><p>因为这是一个实验性质的项目，因此我想同时实现这两种同步模式来进行对比。在这里我挑选了一个回合制、属于非对称博弈的卡牌游戏作为游戏的内容。</p><p>我首先实现了动作同步，在动作同步中服务器几乎不需要做什么事情，只是作为一个「消息服务」来转发客户端的动作。于是我在这里使用了 <a href="https://leancloud.cn/docs/multiplayer.html">LeanCloud Play</a> 来完成这个消息转发的工作，每局游戏对应 Play 中的一个 Room，Play 允许客户端在房间中向其他玩家广播消息。</p><p>然后我又尝试将这个游戏改为了状态同步 —— 我引入一个运行在服务器上的特殊客户端（称为 MasterClient）。这个客户端中同样连入 Play 的消息服务，其中运行着完整的游戏逻辑，它将其他真正玩家的动作作为输入，然后输出游戏状态给其他真正玩家的客户端来展示，防止客户端作弊。</p><h2 id="动作和状态"><a href="#动作和状态" class="headerlink" title="动作和状态"></a>动作和状态</h2><p>前面我提到业界已经总结出了两种同步模式，即动作同步和状态同步，「动作（Action）」和「状态（State）」这两个概念会贯穿这篇文章。</p><p>动作即玩家对于游戏的「输入」，可以是按键、点击，或者更抽象的动作，例如在这个游戏中我定义了两种动作 —— 出牌和放弃出牌：</p><pre><code>type GameAction = PlayCardsAction | PassActioninterface PlayCardsAction {  action: &#39;playCards&#39;  player: Player  cards: Card[]}interface PassAction {  action: &#39;pass&#39;  player: Player}</code></pre><p>状态即用来表示整个游戏局势所需的所有数据，例如在这个游戏中：</p><pre><code>export interface GameState {  players: Player[]  playersCardsCount: PlayersCardsCount  myCards: Card[]  previousCards: Card[]  previousCardsPlayer?: Player  currentPlayer?: Player  winer?: Player}</code></pre><h2 id="游戏抽象"><a href="#游戏抽象" class="headerlink" title="游戏抽象"></a>游戏抽象</h2><p>有了动作和状态的概念，我们就可以对游戏进行一个抽象了，我设计了一个 Game 类，是对一局游戏整个生命周期的封装，这个类将会同时运行于客户端和服务器：</p><pre><code>// 事件：action（当前玩家的动作）、stateChanged（游戏状态变化）、errorclass Game extends EventEmitter {  constructor(seed: string, players: Player[])  // 获取游戏状态（供 UI 调用）  public getState(player: Player): GameState  // 设置游戏状态（状态同步时），会触发 stateChanged 事件  public setState(player: Player, state: GameState)  // 当前玩家执行动作，会触发 action 事件  public performAction(action: GameAction)  // 应用其他玩家的动作（动作同步时）  public applyAction(action: GameAction)}</code></pre><p>在客户端中，当 UI 捕捉到用户的输入时，执行 <code>Game.performAction</code>，动作（Action）的执行会改变状态（State），触发 stateChanged 事件，UI 收到这个事件后根据新的游戏状态来重绘 UI。</p><p>至于其他游戏逻辑则主要是关于「一组牌能够管得上另一组牌」的判断，在此不再罗列。</p><h2 id="结构"><a href="#结构" class="headerlink" title="结构"></a>结构</h2><p>在这个游戏中，我们将消息转发（由 LeanCloud Play 提供）视作一项服务、视作一个中心。所有玩家的客户端都连接到消息转发服务上，同时每局游戏我们还需加入一个运行在服务器上的 MasterClient 来提供特殊的管理能力。</p><blockquote><p>为了将两种同步模式做成可简单替换的，我其实将服务器程序作为了一个必选组件（<code>master-client</code> 目录），但这个服务器程序在动作同步中只是负责创建房间（可以移到客户端），并不参与游戏逻辑。</p></blockquote><p>我的代码分为 3 个部分：</p><pre><code>common├── game.ts└── types.tsbrowser-client├── app.tsx└── client-sync.tsmaster-client├── server-sync.ts└── server.ts</code></pre><ul><li><code>common</code> 部分会同时运行在服务器和客户端，包含游戏的核心逻辑</li><li><code>browser-client</code> 是运行在浏览器中的客户端，包含 UI</li><li><code>master-client</code> 是运行在服务器端中的 MasterClient</li></ul><h2 id="动作同步"><a href="#动作同步" class="headerlink" title="动作同步"></a>动作同步</h2><p>我首先实现的是动作同步（<code>client-sync.ts</code> 和 <code>server-sync.ts</code> 中的 actionSyncController），这种模式下客户端发送动作（Action），服务器只转发动作，游戏逻辑主要在客户端运行，客户端掌握所有的数据（包括其他玩家的手牌）。</p><p>客户端的工作：</p><ul><li><code>game.on(&#39;action&#39;)</code> 时（表示用户在 UI 上进行了一个动作），通过 Play 将动作广播给其他客户端<code>play.sendEvent(action)</code></li><li><code>play.on(&#39;customEvent&#39;)</code> 时（表示收到其他客户端广播的动作），应用其他玩家的动作 <code>game.applyAction(action)</code></li></ul><p>而服务器端几乎没有什么工作，只是帮助客户端创建一个房间而已。</p><h2 id="状态同步"><a href="#状态同步" class="headerlink" title="状态同步"></a>状态同步</h2><p>在状态同步（<code>client-sync.ts</code> 和 <code>server-sync.ts</code> 中的 statusSyncContorller）中，客户端发送动作（Action），服务器运行游戏逻辑后，转发计算后的游戏状态（State），游戏逻辑主要在服务器运行，客户端只做展现，只知道自己的手牌。</p><p>客户端的工作：</p><ul><li><code>game.on(&#39;action&#39;)</code> 时（表示玩家在 UI 上进行了一个动作），通过 Play 将动作单独发送给 MasterClient <code>play.sendEvent(action)</code>（需加参数 <code>{receiverGroup: ReceiverGroup.MasterClient}</code>）</li><li><code>play.on(&#39;customEvent&#39;)</code> 时（表示收到 MasterClient 广播的最新状态），从服务器覆盖游戏状态 <code>game.setState(state)</code></li></ul><p>MasterClient 在服务器的工作：</p><ul><li>为每局游戏创建一个 Game 对象。</li><li><code>play.on(&#39;customEvent&#39;)</code> 时（表示玩家进行了一个操作），在游戏对象上执行动作 <code>game.performAction(action)</code></li><li><code>game.on(&#39;stateChanged&#39;)</code> 时，给每一个玩家发送最新的游戏状态 <code>play.sendEvent(state)</code></li></ul><h2 id="复用代码"><a href="#复用代码" class="headerlink" title="复用代码"></a>复用代码</h2><p>在完成这个实验性质的项目后，我们需要来思考一下，哪些代码是可以复用的。</p><ul><li>Game 类为一个游戏的过程提供了一个非常基本的抽象（即通过动作去改变状态），也为数据同步提供了基础的支持（action 事件和 stateChanged 事件）。</li><li>MasterClient 中对房间的管理是通用的，并且还有很大的改善空间，比如支持多实例允许并实现负载均衡等。</li></ul><p>我将我使用的这种开发多人在线游戏的方式称之为 MasterClient 模式，它的好处是：</p><ul><li><strong>在服务器和客户端之间复用大部分的游戏逻辑</strong> 如果你像我一样使用 JavaScript 的话，那么可以在服务器和客户端运行完全相同的代码。</li><li><strong>单机游戏 &#x3D;&gt; 动作同步 &#x3D;&gt; 状态同步 的迁移过程非常平滑</strong> 只要一开始能够区分好动作和状态，那么这个迁移的过程中只需改动少数代码。</li><li><strong>服务器端的游戏逻辑和消息转发服务解耦</strong> 消息转发服务可以更加稳定；MasterClient 则可以更快速地迭代。</li><li><strong>符合开发者直觉</strong> 至少我作为一个游戏开发的小白是觉得挺符合直觉的</li></ul><h2 id="Play-Client-Engine"><a href="#Play-Client-Engine" class="headerlink" title="Play &amp; Client Engine"></a>Play &amp; Client Engine</h2><p>在我完成这个项目的过程中，我也不断地与公司的同事保持着沟通，经过几个月的努力，LeanCloud 在 Play 基础上发布了 <a href="https://leancloud.cn/docs/client-engine.html">Client Engine</a> —— 一个用于托管 MasterClient 的容器平台（类似于云引擎），同时我们也提供了一个项目骨架，集成了我前面提到的功能，帮助开发者实现服务器端逻辑：</p><ul><li><strong>游戏抽象</strong> 提供了一个类似的 Game 类来帮助开发者管理房间和玩家、填充游戏逻辑，在 Play 的基础上提供 RxJS 风格的高层次 API 来操作游戏动作和状态。</li><li><strong>负载均衡</strong> 提供了一个 GameManager 类来管理房间的创建，允许 Master Client 以多实例的集群模式运行，以便进行横向扩展，消除容量瓶颈。</li><li><strong>平滑部署</strong> 当你部署新版本的时候，旧实例会等待已有的房间完成游戏再退出，你可以在任何时候部署新的版本而不必担心影响用户的游戏。</li></ul><blockquote><p>我的这个项目早于 Client Engine 成型，是对通用游戏后端的一个实验。如果你希望编写类似的游戏，请阅读 Client Engine 的文档，并基于 Client Engine 的脚手架来进行开发，而不要直接基于本项目修改。</p></blockquote><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>本文探索的是一种「通用游戏后端」的解决方案，在这个小项目中我们用到了两个 LeanCloud 的服务：Play 和 Client Engine。</p><ul><li><a href="https://leancloud.cn/docs/multiplayer.html">LeanCloud Play</a> 扮演的是一个「消息转发服务」，它会维持与所有客户端（包括 Master Client）的长链接，允许客户端之间广播或单发消息。</li><li><a href="https://leancloud.cn/docs/client-engine.html">LeanCloud Client Engine</a> 提供了一个可信的服务器端环境来运行客户端 —— 在本文的例子中是 MasterClient，可以将游戏逻辑运行在服务器端来实现反作弊。</li></ul><p>借助这两个服务，我们将对游戏服务器的开发需求降到了最低，只需编写运行于服务器端的游戏逻辑即可，而不必关心链接的保持、消息的转发和服务器环境和扩容等问题。</p>]]>
    </content>
    <id>https://jysperm.me/2018/11/play-cards-realtime-game/</id>
    <link href="https://jysperm.me/2018/11/play-cards-realtime-game/"/>
    <published>2018-11-21T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>当我们公司决定推出一个「多人在线游戏后端解决方案」的时候，我其实很疑惑会有一个「通用」的方案可以解决所有在线游戏的后端需求么？</p>
<p>于是在前一段时间，我尝试开发了一个「多人在线卡牌对战游戏」，支持了「斗地主」游戏规则的一个子集，可以在 <a href="https]]>
    </summary>
    <title>Play Cards: 探索通用的游戏后端方案</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="开源" scheme="https://jysperm.me/tag/opensource/"/>
    <content>
      <![CDATA[<blockquote><p>本文整理自 <a href="https://jysperm.me/caipai">彩排</a> 中的一期播客。</p></blockquote><h2 id="什么是自由软件"><a href="#什么是自由软件" class="headerlink" title="什么是自由软件"></a>什么是自由软件</h2><p>我总结了三点自由：</p><ul><li>出于任何目的、以任何方式 <strong>使用该软件的自由</strong>（包括商业用途，一些商业软件限制你不得进行破解、不能用于犯罪活动就是在限制你使用的方式或目的）。</li><li><strong>修改软件的自由</strong>，当然修改软件的前提是你要能够得到源代码。</li><li><strong>重新分发的自由</strong>，不仅是分发原始版本，你也可以分发修改后的版本。</li></ul><p>只有符合这三条才可以算自由软件，有一些只开源代码（可能是为了安全审计，或者只限非商业用途）的软件并不能算自由软件。</p><p>我们经常看到的 GPL、Apache、MIT 都属于自由软件许可证，这些许可证会在前面的三项自由之外附加一些限制条款，但这些限制条款与自由软件中的自由并不冲突。</p><h2 id="Copyleft-的-GPL-家族"><a href="#Copyleft-的-GPL-家族" class="headerlink" title="Copyleft 的 GPL 家族"></a>Copyleft 的 GPL 家族</h2><p>在自由软件许可证中，有一类特殊的被称为 Copyleft 的许可证（Copyleft 是一个与 Copyright 相对的词，代表着一种具有「传染性」的自由），其中最知名的就是 GPL 家族了，我们接下来会聊到其中的 GPL、LGPL 和 AGPL。</p><p>Copyleft 的特点在于它有一种理想主义的野心 —— 它希望这个世界上所有的软件都是自由的，因此这些许可证都是具有「传染性」的。它们在自由软件的基础上附加了一个最主要的限制条款：在重新分发 Copyleft 的软件时，必须授予用户同等的自由、也必须附加同等的限制条款。在实践中一般就是说，你修改一个 GPL 的软件后，你再发布的时候也必须是 GPL，其他人修改了你修改后的软件，也必须是 GPL，这就是所谓的传染性。</p><p>自由软件社区中有很多使用 GPL 许可证的软件，例如我们熟知的 Linux、WordPress 等等。说到 WordPress，现在有很多公开出售 WordPress 主题或插件的社区，但按照 GPL 的许可证，主题或插件中的 PHP 代码显然属于衍生作品，必须使用 GPL 许可证来发布（而 CSS 和 JavaScript 可以通过单独以其他协议发布来规避），所以理论上，任何一个人购买了 WordPress 主题，那么他都可以随意地再次出售这个主题（这是 GPL 授予他的权利），在 2013 年我便就此话题写过一篇 <a href="https://jysperm.me/2013/06/1127/">文章</a>。</p><h2 id="GPL-中的衍生作品"><a href="#GPL-中的衍生作品" class="headerlink" title="GPL 中的衍生作品"></a>GPL 中的衍生作品</h2><p>我们再来看 GPL 的限制条款，即「再分发衍生作品时必须授予用户同等的自由」，这里有两个词可以单独拿出来讨论一下，「分发」和「衍生作品」。</p><p>所谓 <strong>分发</strong> 就是说你把这个软件（包括修改后的版本）发送给其他人使用，例如你开发了一个桌面软件，当你给用户提供下载时，这就是一种分发行为；当你提供一个库（无论是源代码还是二进制版本）也是一次分发行为；甚至包括浏览器中的 JavaScript 也算分发行为 —— 因为用户的浏览器需要从你的服务器下载 JavaScript.</p><p>GPL 为了保证用户有修改软件的自由，要求你在进行分发时同时提供源代码。而分发可能是公开的，也可能是小范围的，如果你在网络上公开发布一个软件，那么你也必须公开发布源代码；但如果你只是私下发给一个朋友，或在公司内部分发一个内部软件，那么你只需要向这个朋友，或者在你的公司内部提供源代码。所以说「分发」和「提供源代码」的行为是挂钩的，你向谁分发就向谁提供源代码。</p><p>所谓 <strong>衍生作品</strong>，GPL 认为只要你的项目中用到了 GPL 的代码，都可以算衍生代码，无论你是把 GPL 代码作为一个主要功能，还是用来做一个非常细枝末节的功能。而且 GPL 感染的对象是你的整个软件，也就是说只要你的项目用了 GPL，那么整个项目都被感染成了 GPL，你在进行发布时需要提供整个项目的源代码。</p><p>这样听起来 GPL 的确很流氓，那么具体到条款上，GPL 什么情况下会感染你的项目呢？我总结了一下，除非你采用以下三种隔离方法中的一种，将 GPL 代码与你的项目进行了隔离，否则都会被 GPL 感染：</p><ul><li><strong>内存地址空间隔离</strong>，即如果你的代码和用到的 GPL 代码不共享内存地址空间，例如分别属于两个进程（操作系统帮你做了内存地址空间隔离），或者分别属于内核态和用户态那么就不算衍生作品，例如 Linux 并不会感染运行在 Linux 上的程序。</li><li><strong>代码和数据隔离</strong>，即如果你的代码和 GPL 的代码是分别作为代码和数据被看待的，那么不算衍生作品。例如用 GCC 编译你的程序是不会感染的，因为在编译时，你的程序是作为数据被看待的；同理，用 Emacs 编写代码也不会感染被编写的代码。</li><li><strong>指令计数器隔离</strong>，这个是针对虚拟机的一种例外，像 JVM 这样的运行环境，实际上是虚拟了一个 CPU 去执行其上的 Java 操作码，这个虚拟的 CPU 实际上有着和物理 CPU 不同的指令计数器（即 CPU 用来记录当前执行位置的寄存器），因此 JVM 不会感染运行在其上的 Java 代码。</li></ul><p>注意这里的例外并不包括静态链接和动态链接，因此如果你静态链接一个 GPL 的库也是会感染你的整个项目的；至于动态链接则目前还存在争议，GNU 极力认为动态链接也在感染的范围内。</p><h2 id="LGPL"><a href="#LGPL" class="headerlink" title="LGPL"></a>LGPL</h2><p>除了 GPL 还有一个叫 LGPL 的许可证，它是一个比 GPL 宽松一些的许可证，LGPL 中将衍生作品的范围减少到「同一个编译单元」，允许用户在使用了 LGPL 的静态链接库时，仅将 LGPL 代码所属的编译单元开源，所谓编译单元在 C 这样的语言里就是一个文件，若干个编译单元会在最后被链接到一起，形成一个可执行文件。</p><p>但如果推广到一些并没有明确区分编译和链接环节的基于虚拟机的语言（例如 Java 或 JavaScript）中，则还有一些争议，一般是认为一个包属于一个编译单元。</p><p>LGPL 被广泛地使用在依赖库中，例如 Qt 和 FFmpeg 都使用 LGPL 许可证，但实际上作为 GNU 之父的 Richard 后来并不建议大家使用 LGPL，而是建议大家直接使用原版的 GPL。</p><p>他在一篇题为「<a href="https://www.gnu.org/licenses/why-not-lgpl.html">Why you shouldn’t use LGPL for your next library</a>」的文章中说，非自由软件的开发者往往都有很多金钱上的优势，我们这些自由软件的开发者必须团结起来以便得到某种优势来和他们对抗。如果我们发布的库都使用原版的 GPL，那么我们这些自由软件开发者将获得一种巨大的优势 —— 我们可以使用这些库，但那些非自由软件开发者却不能。</p><p>所以后来 Richard 将 LGPL 的全称从 GNU Library License 改为了 GNU Lesser License，并且不建议大家在新的项目中使用。</p><h2 id="GPL-在法律上的现状"><a href="#GPL-在法律上的现状" class="headerlink" title="GPL 在法律上的现状"></a>GPL 在法律上的现状</h2><p>GPL 在法律上的现状并不乐观，很多时候 GPL 是一种君子协定，很多人经常是基于道德，或者很多公司是基于声誉的考量才去遵守 GPL。虽然 GPL 许可证本质上是一种合同，将代码的使用权有限制地授予愿意遵守这个限制的人。但 GPL 的主要诉求是「开源代码」，而即使赢得诉讼，（至少在中国）法院也不会帮你强制执行「开源代码」这一判决；因为通常法院只能强制执行经济类的判决，如果你要求经济上的赔偿的话，因为 GPL 中并没有写明不遵守许可证的情况下的罚金，因此你可能需要证明被告不开源这一行为给他带来了多大的利益或者给你造成了多大损失，注意并不是他使用你的代码得到的利益，而仅仅是不开源这一行为的利益。</p><p>尤其很多开源项目并没有精力去打官司，一部分开源软件可能只是会在网站上登出一个「耻辱柱」的页面，列出哪些项目或公司使用了他们的代码却没有遵守 GPL 许可证，例如 <a href="http://web.archive.org/web/20101214233906/http://ffmpeg.org/shame.html">FFmpeg 的网站</a>。</p><h2 id="AGPL"><a href="#AGPL" class="headerlink" title="AGPL"></a>AGPL</h2><p>接下来我们来介绍 GPL 家族的另一位成员 AGPL，AGPL 是一个更严格的 GPL 协议，代表性的项目是 MongoDB —— 一个非常知名的非关系型数据库。考虑到现在是互联网时代，很多公司会将 MongoDB 之类的开源软件进行一些修改或封装，然后提供基于 MongoDB 的云服务来盈利。于是就有了 AGPL 许可证，AGPL 将分发的行为扩展到了「通过网络提供服务」，之前的 GPL 对于服务器端应用是没有什么约束的，因为服务器端应用并不会发布给最终用户，但如果你使用了 AGPL 来通过网络提供服务，那么你也需要开源你对其的修改。</p><p>就在发文前，<a href="https://www.mongodb.com/blog/post/mongodb-now-released-under-the-server-side-public-license">MongoDB 宣布新版本将会切换到 SSPL</a>，这个协议是对 AGPL 的进一步细化，规定了若提供主要价值来自于 MongoDB 的云服务，就必须开源能够让用户运行起来这个服务所需的所有组件，包括用户界面、API、自动化、监控、备份等组件。</p><h2 id="非-Copyleft-许可证"><a href="#非-Copyleft-许可证" class="headerlink" title="非 Copyleft 许可证"></a>非 Copyleft 许可证</h2><p>前面花了很多篇幅去介绍 GPL 家族，的确他们是自由软件中最有趣也最优代表性的部分。但现在一些新的开源软件往往会选择一个非 Copyleft 的许可证，例如 Apache、BSD、MIT 等等，不过这些协议的条款相对简单，一句话便可概括。</p><p>其中 MIT 许可证仅仅要求用户进行署名，所谓署名是你必须将用到的自由软件代码的许可证附带在你分发的每份软件中，并且让用户能够看到，例如在很多软件的某个隐蔽的角落，你可以看到一个叫「开源许可证」或「法律信息」的按钮，点开可以看到它用到的所有自由软件的许可证。</p><p>当然你也就可以连署名都不要求的话，你可以选择直接将你的作品释放到共有领域，例如 SQLite（一个嵌入式关系型数据库）就直接被发布到了共有领域，你可以用它的代码做任何事情，连署名都不需要。</p><p>BSD 会稍微严格一些，除了要求署名之外还要求用户不得使用其名字用作宣传的用途，例如你基于一个 BSD 协议的软件发布了一个修改后的版本你必须为它起一个新的名字，和原有项目做出明确的区分，以免误导其他用户。</p><p>而 Apache 许可证在 BSD 的基础上还要求你在发布修改后的版本时明确注明对哪些文件、做了什么修改。</p><p>这里需要注意的一点是这些许可证的授权往往是 <strong>不可撤销</strong> 的，一旦你曾经以某个许可证发布了一个项目，那么任何人都可以按照你当时发布的那个版本的许可证去使用你当时发布的那个版本的代码。当然你可以从某个时间点开始以新的许可证来发布代码，但其他人依然可以选择遵守旧的许可证去使用旧的版本。这一点是写在大部分自由软件许可证中的条款，是出于保护自由软件代码的使用者的考虑，否则如果你可以随时收回你的授权，是没有任何人敢去用你的代码的。</p><h2 id="Creative-Commons"><a href="#Creative-Commons" class="headerlink" title="Creative Commons"></a>Creative Commons</h2><p>前面我们提到的的都是针对于代码的许可证，但有的时候我们也希望授予文本作品的读者一些自由。我接下来介绍的这个「<a href="https://creativecommons.org/">创作共用许可证</a>」就是这样的一种许可证，通常缩写为 CC.</p><p>CC 许可证将文本作品的作者常见的几种诉求总结成了四点：</p><ul><li><strong>BY 署名</strong></li><li><strong>SA 相同方式共享</strong>，所有的衍生作品都必须使用同样的许可证，类似于 GPL 的要求，但因为文本作品的衍生关系相对简单，所以可能并没有太多人关注它的传染性</li><li><strong>NC 禁止商业使用</strong></li><li><strong>ND 禁止演绎</strong></li></ul><p>这四种诉求可以被组合到一起，例如我的博客选用了 CC 署名、非商业使用、相同方式共享发布。也就是说你可以非商业的目的自由地使用和分发这篇文章，需要提供指向 caipai.fm 的链接，如果你修改了文章则必须也允许其他人以相同的方式转载和修改。</p><h2 id="CLA"><a href="#CLA" class="headerlink" title="CLA"></a>CLA</h2><p>我们还会看到一些软件同时是自由软件（比如 Qt 和 MySQL），也提供了商业授权，如果你购买了他们的商业授权，那么你并不需要遵守自由软件的许可证，这是如何做到的呢？</p><p>首先，一个作品的作者是有权决定如何把这个作品的使用权授予其他人的，他可以即发布一个面向所有人的自由软件许可证的版本，当然这个自由软件版本其实也是有限制的；他也可以以单独的、有着完全不同的限制的方式来把使用权授予其他人，商业授权就是这样实现的。</p><p>但很多社区开发的自由软件，例如 Linux，一旦有来自社区的贡献者参与了进来之后，最初的发起者就没有权利去修改许可证或发行商业版本了，因为这时已经没有人拥有这个软件的所有的代码的所有权利了，因为这个软件的不同的部分的代码来自不同的贡献者，他们只是以这个项目的自由软件许可证发布他们的代码，所有人都必须遵守这个自由软件许可证，而显然将一个 GPL 许可证的自由软件改为其他非 Copyleft 许可证是不行的，因为这违反了 GPL 许可证的要求。</p><p>所以在实践上，很多社区开发的软件会要求贡献者在提交代码之前签署一份协议（通常缩写为 CLA），这份协议的主要内容有两点：</p><ul><li>贡献者授予软件的维护者有关此次贡献的代码的永久的、无限制的使用权（而不仅仅是按照自由软件许可证授权）。</li><li>贡献者声明自己拥有所贡献的代码的相关权利（而不是从另外一个不兼容的许可证的项目、或非开源项目中拷贝的）。</li></ul><p>如果一个社区开发的软件一开始就这么做，那么第一条保证了软件的维护者始终拥有对所有代码的没有限制的使用权，这样项目的维护者才有可能修改许可证或者发行商业授权；而第二点我个人认为只是为了在出现著作权纠纷时，规避一些法律上的问题。</p><p>拓展阅读：</p><ul><li><a href="https://www.gnu.org/philosophy/free-sw.html">https://www.gnu.org/philosophy/free-sw.html</a>（GNU 对自由软件的解释）</li><li><a href="https://choosealicense.com/">https://choosealicense.com</a>（GitHub 旗下帮助你选择开源软件协议的网站）</li><li><a href="http://web.archive.org/web/20161026203954/https://cla.github.com/agreement">http://web.archive.org/web/20161026203954/https://cla.github.com/agreement</a>（GitHub 的 CLA）</li></ul>]]>
    </content>
    <id>https://jysperm.me/2018/10/freesoftware-opensource-licenses/</id>
    <link href="https://jysperm.me/2018/10/freesoftware-opensource-licenses/"/>
    <published>2018-10-21T17:00:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p>本文整理自 <a href="https://jysperm.me/caipai">彩排</a> 中的一期播客。</p>
</blockquote>
<h2 id="什么是自由软件"><a href="#什么是自由软件" class="header]]>
    </summary>
    <title>自由软件和开源许可证</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="网络" scheme="https://jysperm.me/tag/networking/"/>
    <category term="硬件" scheme="https://jysperm.me/tag/hardware/"/>
    <content>
      <![CDATA[<p>我之前在用的路由器是 Synology 的 <a href="https://www.synology.com/zh-cn/products/RT1900ac">RT1900ac</a>，当时非常粉 Synology，于是也想试试他们家的路由器。但现在看来这绝对不是一个正确的选择 —— 我既然已经有了 Synology 的 NAS，他们家的路由器上的很多功能对我来说就显得重复了。</p><h2 id="Mesh-Wi-Fi"><a href="#Mesh-Wi-Fi" class="headerlink" title="Mesh Wi-Fi"></a>Mesh Wi-Fi</h2><p>RT1900ac 的信号覆盖也并不理想 —— 我仔细想了一下，信号强度不仅仅在于路由器的发射强度，因为接入设备（例如手机）也是要将信号回传给路由器的，有可能回传才是瓶颈。因此，更好的做法是提高路由器（AP）的密度，缩短和接入设备之间的距离。</p><p>于是，我发现了 Mesh Wi-Fi 这项技术，也称作「分布式路由器」、「网状网络」、「多跳网络」，对应 IEEE 的 802.11s 标准。简单来说就是一个网络由多个节点（例如 3 个路由器）构成，任意两个节点之间都通过无线（Wi-Fi）或有线（所谓「有线回程」功能）连接。这些节点提供相同的 SSID，设备会接入最近的节点，网络流量可能会经过多个节点之间的传递，最后到达外网网关（接宽带猫的节点）。与桥接网络相比，在多个节点之间可以无缝漫游（IEEE 802.11v&#x2F;r）。</p><p><img src="/pictures/2018/nova-mw6-mesh.png"></p><p>这样的技术解决了前面提到的问题，即提高路由器的密度、提高信号覆盖、缩短接入设备和路由器之间的距离。一开始我是想买 UBNT 的 <a href="https://amplifi.com/">AmpliFi</a>，可实在太贵了。于是我最后买了 <a href="http://www.tenda.com.cn/product/nova%20MW6.html">Tenda Nova MW6</a>（3 个节点），大概是 Amplifi 三分之一的价格，算是 Mesh Wi-Fi 解决方案里价格较低的了，其实 Tenda 还有更便宜的 Mesh Wi-Fi，不过是百兆的。</p><h2 id="信号覆盖"><a href="#信号覆盖" class="headerlink" title="信号覆盖"></a>信号覆盖</h2><p>为了量化信号覆盖的改善效果，我在切换路由器前先测量了 RT1900ac 的信号覆盖情况（5G 信号）：</p><p><img src="/pictures/2018/nova-mw6-rt1900ac-snr.png"></p><p>图中的数字是信噪比，越高表示信号越好。可以看到之前的 RT1900ac 因为放在客厅，在卧室中的信号是非常差的。接下来再看一下 Nova MW6 的信号覆盖情况（5G 信号）：</p><p><img src="/pictures/2018/nova-mw6-snr.png"></p><p>我实际上是部署了 3 个节点的，但似乎 <a href="https://www.netspotapp.com/">NetSpot</a> 只识别出了其中 2 个节点。可以看到因为卧室也有一个节点，所以卧室的信噪比从之前最低的 15% 提高了现在的最低 57%。</p><h2 id="带宽测试"><a href="#带宽测试" class="headerlink" title="带宽测试"></a>带宽测试</h2><p>外网表现上，是可以跑满运营商宽带 85Mbps 的下行和 3Mbps 的上行的：</p><p><img src="/pictures/2018/nova-mw6-speedtest.png"></p><p>内网表现上，我分别测试了很多情况，需要注意的是受到我手里设备类型的限制，这些测试并没有充分地控制变量：</p><ul><li>设备 <strong>有线连接</strong> 到不同的节点，节点之间无线连接：300Mbps</li><li>设备 <strong>无线连接</strong>（5G）到 <strong>不同</strong> 的节点，节点之间无线连接：200Mbps</li><li>设备 <strong>无线连接</strong>（5G）到 <strong>相同</strong> 的节点，节点之间无线连接：200Mbps</li></ul><h2 id="关于运营商"><a href="#关于运营商" class="headerlink" title="关于运营商"></a>关于运营商</h2><p>从四月末从北京搬到昆山以来，在昆山办理的联通宽带的质量非常差，下行跑不到标称的 100Mbps 不说，上行在绝大部分时间只有 2Mbps，而且一旦跑满 2Mbps 就会「断网」一段时间。</p><p>之前的 RT1900ac 在这种异常的「断网」之后不会自动重连，每次都需要登录路由器管理界面手动重播 PPPoE，这也是这次换路由器的原因之一，新的路由器在这种情况下会自动恢复，但断网的现象依旧存在。</p><p>如果要是我之前一个人的话，估计会嫌麻烦凑合用下去，但在蛋黄的反复报修、投诉下 —— 问题依然没有解决，只不过了解了更多的内情。联通的宽带安装人员表示我们小区靠南的几栋楼容量不足，本不应该允许办理 100Mbps 的宽带，如今必须扩容才能彻底解决这个容量问题，并表示会帮助我向上级反映。</p><h2 id="使用体验"><a href="#使用体验" class="headerlink" title="使用体验"></a>使用体验</h2><p>Tenda Nova MW6 被设计成只能通过手机应用来进行配置，而且配置界面非常简单，不能设置频段、不能单独开关 2.4G 网络、不能看到路由器的负载情况（CPU）。不过这也意味着没有乱七八糟的广告植入和强迫用户使用的功能，至少目前是这样</p><p>设置界面中有一个「智能 QoS」的功能，按我的理解，这这里设置宽带的上下行带宽之后，路由器应该在带宽快要被用满时限制占用带宽（对于我来说主要是上行）过多的设备。但实际表现上并不理想，依然经常出现上行带宽被占满，导致下行也无速度的情况，不过我并不确定这是路由器 QoS 不起作用，还是运营商在带宽快要被用满时的特殊行为。</p><p>而且在固件升级的过程中遇到了一些问题，一开始我在只启动了一个节点的情况下进行了固件升级。之后连上其他 2 个节点后，就无法对剩余两个节点进行固件升级了，最后我不得不重置了所有的节点，然后单独对剩余的两个节点进行升级。</p><p>关于无缝漫游，在实际使用中确实 Wi-Fi 信号总是满格，不会出现桥接网络时在两个路由器中间时信号较差的情况。我在整个屋子移动的过程中一直开着 <code>ping</code> 发现在切换节点时会出现一秒钟的延迟较高（从 50ms 增加到 200ms）的现象，但因为缺乏使用场景，我并不是很清楚对于更上层的应用（例如游戏），节点会有什么影响，反正如果只是刷刷网页、看看视频，这个效果应该足够了。</p><p>理论上既然是分布式 Wi-Fi，单个节点失效不应该影响其他节点（官方的介绍上也是这么说的），但我实测发现接入外网网关的节点实际上是「主节点」，一旦主节点关机，整个网络都会失效（无法搜索到 SSID），无法进行内网通讯。</p>]]>
    </content>
    <id>https://jysperm.me/2018/09/tenda-nova-mw6/</id>
    <link href="https://jysperm.me/2018/09/tenda-nova-mw6/"/>
    <published>2018-09-15T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>我之前在用的路由器是 Synology 的 <a href="https://www.synology.com/zh-cn/products/RT1900ac">RT1900ac</a>，当时非常粉 Synology，于是也想试试他们家的路由器。但现在看来这绝对不是一个正确]]>
    </summary>
    <title>入手 Tenda Nova MW6（Mesh 路由器）</title>
    <updated>2026-05-01T15:13:06.185Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="游戏" scheme="https://jysperm.me/tag/gaming/"/>
    <content>
      <![CDATA[<blockquote><p>欢迎关注 [我的 Steam 帐号][1]，截至本文发布时的总游戏时间为 186 小时</p></blockquote><p>因为 SimCity 2013 的失利，2015 年发售的城市天际线可以说是目前最好的城市建造类游戏了，三年以来也保持着稳定的更新（虽然每个 DLC 的价格都和游戏本体差不多了），相比于 SimCity 2013, 天际线没有无趣的「城市间交互」，取而代之的是更大的地图。</p><p><img src="/pictures/2018/skylines-overview.png"></p><p>天际线的经营要素并不是很强，只要前期不作死 —— 不规划远超实际需要的基础设施，就不太可能出现破产的情况，从中后期开始主要的重心还是在交通上面。当然不同的人在玩这个游戏时会有不同的目标，我的目标是在不使用 Mod 的情况下、在默认的游戏规则下，建造一个看上去符合「常理」、运转起来也符合「常理」的城市。</p><p><img src="/pictures/2018/skylines-budget.png"></p><p>天际线在一些方面提供了非常细致的模拟，例如有对市民的人生阶段的模拟：先上小学、再上中学、再上大学或开始工作，最后退休在家；也有对于每个市民的住所、学校或工作地点和通勤路线的模拟，市民会在步行、公共交通、私家车之间换乘；对于工业区也会模拟每个工厂进货（进口或来自其他工厂）和出货（出口或去往本地工厂、商店）的交通路径；不同的工作对于市民的教育程度有不同的要求；治安、消防、垃圾等市政服务的覆盖范围是取决于交通状况的。</p><p>在道路建设的方面，天际线提供了很大的自由度，所有的道路都可以提升为桥梁或下沉为隧道，自带的曲线工具和对齐工具可以帮助你建造出完美的道路和立交桥，但游戏也在桥梁的跨度、角度、坡度方面进行了一定的限制，不允许建造不符合常理的道路。游戏提供了三种等级（分别为 2、4、6 条车道）的城市道路，和几种不同样式的高速道路，车辆会更偏好选择高等级的道路来行驶，这样你便可以通过建造不同等级的道路来引导车流。游戏也提供了单行线、人行道（可提升为天桥或下沉为隧道）、非对称数量车道、公交专用道等各种不同类型的道路以供选用。</p><p><img src="/pictures/2018/skylines-overpass.png"></p><p>游戏中后期的主要内容就是解决交通拥堵了，游戏中的车流主要集中在商业区和工业区，而居民区的交通需求大部分可以用公共交通替代。工业区因为本身有污染、重型卡车比较多，在规划上会和其他区域分离，直接接入高速道路。高速道路负责区域间的长距离交通，和城市道路的互通都要靠无信号灯、车流交叉较少的立交来实现，保证高速道路不会出现拥堵；当然也会有一些级别稍低的高速路，出于占地的考量，是会出现和城市道路的路口的。在城市道路中，通过分级的路网来控制车流，在高等级道路上使用较高的路口间距，在低等级道路上不再设置信号灯等等。</p><p>参考链接：[交通规划指南][2]</p><p>在最近的一个交通 DLC 之后，已经可以为道路设置「主干道」属性，也可以单独控制每个路口有无信号灯了。但我仍对默认的交通系统有些怨念，比如不能为路口设置禁止左转 —— 通常一条小路汇入主干道时是禁止左转的；游戏中的信号灯对于直行和左转是同时放行的；车辆的车道变换也显得非常不自然。</p><p><img src="/pictures/2018/skylines-crossing.png"></p><p>在公共交通方面，天际线提供了公交、电车、地铁、轻轨、铁路、轮渡、飞机、飞艇、缆车、出租车，不过在我的实际使用中觉得很多交通工具提供的能力是重复的，城市内交通只要公交和地铁就可以了，城际交通（游客）靠飞机和铁路，货运靠铁路和货运港口。游戏中公交和地铁的线路和站点都是要自己规划的（也可以调整每条线路配置的车辆数量），并不像 SimCity 那样只要放置车站就可以了。这个设计带来了很多可玩性，设计得好的公共交通系统和设计得不好的交通系统将会有非常大的差别，你需要观察已有线路的客流数据（每个站点的客流量和等待人数）去进行调整、规划新的线路。</p><p><img src="/pictures/2018/skylines-mass-traffic.png"></p><p>我对公共交通的怨念主要在于我认为地铁和轻轨其实是完全一样的，为何不直接让地铁可以提升到陆地或高架呢？以及游戏默认没有提供地铁的换乘车站，所以要么站外换乘，要么多个线路共用一个站台（会非常堵）；以及目前游戏中不同公共交通的换乘还比较弱，火车总站和公交总站规模过大，没有中等规模的车站。</p><p>天际线默认提供了 36 平方公里的地图，使用 Mod 可以扩大到 300 平方公里以上，几乎相当于一个真正的城市了。对于这么大的地图，游戏也提供了丰富的管理工具，你可以为每个建筑、道路、公共交通线路、甚至市民命名，也可以划分行政区，在不同的行政区使用不同的政策和税率。</p><p><img src="/pictures/2018/skylines-map-view.png"></p><p>图中的城市是我近期耗时几十个小时建造的一个城市，目前有人口 16 万人（相比于城市规模，天际线的人口数量不是很真实，严重偏少）。</p><p>[1]:<a href="http://steamcommunity.com/id/jysperm">http://steamcommunity.com/id/jysperm</a><br>[2]:<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=439725721">https://steamcommunity.com/sharedfiles/filedetails/?id=439725721</a></p>]]>
    </content>
    <id>https://jysperm.me/2018/06/cities-skylines/</id>
    <link href="https://jysperm.me/2018/06/cities-skylines/"/>
    <published>2018-06-09T17:00:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p>欢迎关注 [我的 Steam 帐号][1]，截至本文发布时的总游戏时间为 186 小时</p>
</blockquote>
<p>因为 SimCity 2013 的失利，2015 年发售的城市天际线可以说是目前最好的城市建造类游戏了，三年以来也保持]]>
    </summary>
    <title>游戏：城市天际线（Cities: Skylines）</title>
    <updated>2026-05-01T15:13:06.185Z</updated>
  </entry>
  <entry>
    <author>
      <name>王子亭</name>
      <email>jysperm@gmail.com</email>
    </author>
    <category term="Golang" scheme="https://jysperm.me/tag/golang/"/>
    <category term="年度技术小结" scheme="https://jysperm.me/tag/programming-of-year/"/>
    <content>
      <![CDATA[<p>从今年年初开始，我就尝试在业余时间和一个朋友开发一个容器平台，更多地是实验一些新的技术，也希望能够通过它将自己的一些小应用管理起来，在基本完成后可能会考虑开源。之所以说是实验是因为我选择了一个我几乎完全不了解的技术栈：主要编程语言是 Golang、<strong>只使用 Etcd</strong> 作为数据库、基于 Docker Swarm 管理容器。</p><p>不得不说 Golang 是一个非常难用的语言，在语言层面，为了所谓的「简单」而没有添加 <strong>异常</strong> 和 <strong>泛型</strong> 这两个对于高级编程非常重要的特性；在生态上仍没有统一出一个包管理器，如果只发布编译好的二进制程序倒是没问题，但如果发布源代码的话，缺少统一的包管理会带来很多麻烦，以至于很多开发者选择将 vendor 直接包含在版本控制中。</p><p>在这个项目中，没有异常和泛型真的给我带来了很大的困扰，几乎一半的代码都在进行繁琐的错误检查，没有泛型则很难实现一些通用的函数，或者不得不进行强制类型转换。这让我觉得 Golang 的使用场景非常受限：因为有 GC，它难以胜任对实时性要求较高的底层的工作；又因为缺少高层次的抽象手段，不适合业务逻辑复杂的应用编程（例如 Web 后端），可以说不上不下，只适合于一些业务逻辑不复杂的中间件，或者一些客户端命令行工具（毕竟在三个平台下都没有运行时依赖）。</p><p>Etcd 是一个我之前没有接触过的数据库类型，它是分布式的键值数据库，可以在大多数节点存活的情况下保证读写的强一致性，也提供了事务、订阅修改、TTL、检索历史快照等功能。我在这个项目中直接使用 Etcd 作为唯一的数据库存储所有数据，也使用 Golang 对 Etcd 的 API 进行了简单的封装，以便更好地使用 JSON 和 Etcd 的事务。</p><p>因为毕竟是业余项目，这个项目一直进展缓慢，在今年的最后我还尝试在 Swarm 上实现高可用的有状态容器，例如 Redis 和 MongoDB。我在容器内用 Shell 编写了一系列的脚本，在启动时从 Etcd 获取集群信息和自己的角色，然后通过长轮询完成配置的切换，再运行一个 Nginx 将从节点的流量转发给主节点，容器的数量则由 Swarm 保证，实现了一个「自维护」的数据库容器。</p><hr><p>在去年 <a href="https://jysperm.me/2016/10/nodejs-error-handling/">Node.js 错误处理实践</a> 的基础上，今年我又在继续探索错误处理和日志的最佳实践。之前的方法存在一个问题，即我特别关注于将错误对象原样地传递出去，但有时看到一个非常底层、非常细节的错误（例如 CONNTIMEOUT），则难以判断究竟发生了什么。虽然从异常的调用栈中可以看出调用路径，但并不能看到一些关键变量的值，例如这个连接错误是在请求哪个地址，主要参数是什么，这是因为在异常传递的过程中，我们并没有记录这个信息。最后只能得到一个非常细节的错误信息，而不知道这个错误发生在更上层的哪个环节。</p><p>于是我开始使用 <a href="https://github.com/joyent/node-verror">verror</a> 这个库，它最主要的功能是帮助你创建一个「异常链」，你可以在每个层级来向异常上补充路径信息（会被反映到 <code>err.message </code> 例如一个来自底层的错误信息可能是 <code>request failed: failed to stat &quot;/junk&quot;: No such file or directory</code> 这样）。这个异常链信息也会和其他元信息一起以结构化的方式存储在错误对象上，这个库也提供了一些工具函数来获取这些结构化信息。我尝试使用 verror 来管理所有的异常，报告带有详细的、每一层级信息的异常。同时我也会向错误对象上附加一些元信息用来指示如何响应客户端、是否需要发到 Sentry、是否可以重试等。</p><p>除了异常，我也开始尝试使用 bunyan 打印结构化的日志，并存储到 Elasticsearch。通过 Kibana 的 Web UI 可以很简单地对日志进行筛选和查询，在排查问题时找到相关的那部分日志。对于一个既有的系统来说，调整异常和日志可以说是一个非常庞杂的工作，在调整的过程中也我也在不断地修正自己的实践，今年一整年我都在做这样的尝试。</p><hr><p>对于一个稍微复杂一点的项目来说，并不是所有的数据都在事务的保护下 —— 其实很多互联网项目也并不会使用事务。这样就难免出现数据不一致的情况，这种不一致可能是数据的关系出现损坏、缓存和数据不一致，也可能是多种数据库甚至外部资源的状态没有同步。</p><p>今年我探索了解决这个问题的一种实践：编写脚本去自动地检查和恢复这种不一致，这种脚本是常态化运行的，例如我的一个项目中现在有 4 个脚本以每 10 分钟左右的频率在进行各种检查和恢复。这样不一致的数据会在很短的时间内被恢复（也会留下可查的记录），对于用户来说就是碰到问题的次数变少了，在一些重大的的故障发生时，这种脚本也可以帮助你快速地恢复服务。</p><p>这样自动地修复不一致也引入了一个问题：就是在核心业务中会不自觉地降低对一致性的追求 —— 反正有脚本来修复，问题不会暴露出来。目前只能是为检查和恢复的情况绘制图表，在不一致的频率超出预期时及时地发现。</p><hr><p>因为云引擎的 <a href="https://zhuanlan.zhihu.com/p/26251587">负载均衡</a> 逻辑比较复杂，之前是在一个开源的 Node.js 反向代理组件上进行了一些二次开发，但在高峰时的性能不是很理想，一直有想法换成 Nginx。于是今年年初我就开始基于 Openresty 用 Lua 重写了负载均衡组件，效果非常理想，只用了 Node.js 十分之一的 CPU 和内存，再也没有出现容量不足的情况。</p><p>原因当然是 Nginx 对内存有着非常细粒度的管理，只在请求开始和结束时申请和释放整块内存，也没有 GC，保持一个长链接几乎不需要消耗多少资源。Openresty 则将 Lua 嵌入到了 Nginx 中，在 Nginx 高性能的请求处理和丰富的 HTTP 功能的基础上，让你可以用 Lua 去实现一些逻辑，对于负载均衡肯定是够用了。</p><hr><p>我之前一直有在使用 <a href="http://www.passwordstore.org/">pass</a> 这个基于 GPG 和 Git 的命令行密码管理器，并将密码仓库托管在 <a href="https://github.com/jysperm/passwords">GitHub</a> 上。之所以用它是因为它基于可靠的开源工具、本身也是开源的，同时它足够简单，简单到我不需要它也可以操作我的密码。</p><p>也一直有想法为它开发一个 UI, 于是今年九月我用 Electron 开发了一个名为 <a href="https://github.com/jysperm/elecpass">Elecpass</a> 的密码管理器，在机制和数据格式上与 pass 完全兼容。之前其实我并没有用过 Electron, 但上手的体验还是相当不错的，没有遇到什么问题。因为 Electron 自带了 commonjs 的模块加载系统，也不再需要像前端开发那样复杂的构建过程。</p><p>目前 Elecpass 一共发布了两个版本，虽然还非常简陋而且有一些 Bug，但已经可以满足基本需求了，我自己也一直在使用，明年我应该会为它添加更多的功能。</p><hr><p>今年年初腾讯发布了微信小程序，我代表公司在「小小程序，大有作为」的线下活动里做了一个主题为「<a href="https://github.com/jysperm/slides/blob/master/We%20App.pdf">在微信小程序中使用 LeanCloud</a>」的分享，在准备期间我也了解了一下微信小程序。</p><p>可以说微信小程序就是腾讯为了在微信中构建一个封闭的「操作系统」的产物，但大家迫于微信本身的平台能力，比如用户信息、推送、支付，不得不使用它。作为一个平台，微信小程序绑定了一个数据绑定框架，也绑定了一套模板语言，同时和前端现有的工具链（编译打包）的整合也非常差，很难利用现有的 JavaScript 生态。作为结果，我相信微信小程序不会有什么技术层面的社区和生态，只能作为最末端的用户界面。</p><hr><p>年初因为发现我司的 <a href="https://status.leancloud.cn/">服务状态页</a> 年久失修，我决心重写一个服务状态页，参考一下 GitHub 等网站。我希望它能同时展示三个节点的状态、能够展示过去一天的历史状态、允许运维同事在服务状态页上快速地发布通知。最后这个状态页也开源了出来，在 <a href="https://github.com/leancloud/leancloud-status">leancloud&#x2F;leancloud-status</a>。</p><p>为了能够让服务状态页本身总是保持可用，我设计了一个比较有趣的架构：后端（检查器）分别运行在我们三个节点的云引擎上，交叉对所有节点进行检查，将结果和展示历史图表所需要的数据写入到 S3（或其他对象存储上）；状态页面作为静态页面托管在 CDN 上，从 S3 分别拉取三个节点的检查结果和历史图表数据，对来自三个节点的数据进行汇总，决定显示为「正常」还是「故障」。</p><p>这样就保证了服务状态页本身的可用性和三个节点隔离，可用性仅依赖于 S3（理论上可以同时写入多个对象存储作为热备），检测程序又运行在我们自己的云引擎上（比单独部署在一台机器上更易于维护），架构又并不复杂。</p><p>为了在前端合并三个节点的时序数据并绘制图表，我其实是费了很大的功夫的，但在实际部署的过程中遇到了很多细节的问题，做了很多妥协。例如我们的美国节点到国内的访问一直不畅等等，最后并没有把我制作的历史图表展示出来。</p><hr><p>之前几个北京的同事写了一个 <a href="https://github.com/leancloud/obama">聊天机器人</a> 放在公司的 IM 上，每天看他们调戏机器人觉得挺幼稚的。但等我搬到北京之后也加入了他们的队伍，我给机器人加了几个有趣的功能，虽然实现上并不复杂，但你可以通过聊天的方式把它展示给别人看，也可以让别人参与人来，还是个非常有意思的事情。</p><p>首先我写了一个 <a href="https://zhuanlan.zhihu.com/p/27183277">帮助大家决定晚上吃什么</a> 的功能，这一写我就来了兴趣，后来又写了 <a href="https://github.com/leancloud/obama/blob/master/scripts/are-you-ready.js">确认大家是否都准备好吃晚饭了</a>、<a href="https://github.com/leancloud/obama/blob/master/scripts/status.js">帮助运维同事简单地更新服务状态页</a>，还 <a href="https://github.com/leancloud/obama/blob/master/scripts/lunch.js">为公司免费午餐的福利随机人选</a>。</p>]]>
    </content>
    <id>https://jysperm.me/2018/01/programming-of-2017/</id>
    <link href="https://jysperm.me/2018/01/programming-of-2017/"/>
    <published>2018-01-15T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>从今年年初开始，我就尝试在业余时间和一个朋友开发一个容器平台，更多地是实验一些新的技术，也希望能够通过它将自己的一些小应用管理起来，在基本完成后可能会考虑开源。之所以说是实验是因为我选择了一个我几乎完全不了解的技术栈：主要编程语言是 Golang、<strong>只使用 E]]>
    </summary>
    <title>2017 年度小结（技术方面）</title>
    <updated>2023-12-06T12:33:18.000Z</updated>
  </entry>
</feed>
