oldj's blog https://oldj.net 老杰的博客 Wed, 18 Mar 2026 21:56:38 +0800 https://validator.w3.org/feed/docs/rss2.html zh-CN 使用 uv 管理 Python 依赖 https://oldj.net/article/2025/11/29/python-with-uv https://oldj.net/article/2025/11/29/python-with-uv Sat, 29 Nov 2025 15:26:00 +0800 <p>我有一个运行了好几年的 Django 项目,之前一直在使用默认的 pip 管理和安装依赖,最近切换到了 <a href="https://github.com/astral-sh/uv">uv</a>,感觉还不错,在这儿记录一下。</p> <h2 id="uv">什么是 uv</h2> <p>根据官网的介绍,uv 是一个 Python 包以及项目管理器,非常快,使用 Rust 开发。</p> <p>安装和管理依赖只是它的功能之一,除此之外,它还可以创建虚拟环境,即可以取代 pip + virtualenv 的功能。</p> <p>我测试了一下,uv 确实比 pip 快了很多。在使用相同的镜像源,且都是纯净的 docker 环境下,使用 pip 安装项目的依赖花了约 88 秒,但使用 uv 只用了 13 秒。</p> <p>不过,安装依赖并不是一个高频操作,多花一点时间一般不是什么痛点,uv 更吸引人的是它简化了很多工作,比如内置了 Python 多版本安装以及虚拟环境管理,且能保证环境的可复现性,这就让 Python 项目的开发和发布工作简单了很多。</p> <h2 id="uv_1">uv 的安装和初始化</h2> <p>在 macOS 或 Linux 上,可以使用以下命令安装 uv:</p> <pre><code class="language-bash">curl -LsSf https://astral.sh/uv/install.sh | sh </code></pre> <p>Windows 上的命令如下:</p> <pre><code class="language-bash"># On Windows. powershell -ExecutionPolicy ByPass -c &quot;irm https://astral.sh/uv/install.ps1 | iex&quot; </code></pre> <p>安装完成之后,可以使用以下命令初始化一个新项目:</p> <pre><code class="language-bash">uv init my-project </code></pre> <p>如果你的项目已经存在,也可以直接进入项目根目录,执行以下命令:</p> <pre><code class="language-bash">uv init </code></pre> <p>如果项目中已经有 <code>requirements.txt</code>,想改用 uv 进行管理,可以在项目根目录执行以下命令:</p> <pre><code class="language-bash">uv pip install -r requirements.txt </code></pre> <p>也可以直接执行:</p> <pre><code class="language-bash">uv sync </code></pre> <p>初始化后,uv 会在项目根目录下生成一个 <code>pyproject.toml</code> 文件,其中包含了项目的基本信息以及依赖项。</p> <p>可以运行以下命令,锁定依赖项:</p> <pre><code class="language-bash">uv lock </code></pre> <p>这个命令将会在项目根目录下生成 <code>uv.lock</code> 文件,类似 Node.js 的 <code>package-lock.json</code>,其中包含了项目依赖的各个第三方库以及版本号,确保下次安装时安装的包相同。</p> <h2 id="_1">虚拟环境</h2> <p>Python 默认是安装在系统中的,使用 pip 安装依赖时,默认也会全局安装,在很多情况下,尤其是需要维护多个项目时,这显然不是我们期望的,此时可以使用虚拟环境。</p> <p>在 uv 中使用虚拟环境很简单。</p> <p>首先,可以使用 uv 安装多个不同版本的 Python:</p> <pre><code class="language-bash">uv python install 3.10 3.11 3.12 </code></pre> <p>然后,可以通过类似下面的命令安装虚拟环境:</p> <pre><code class="language-bash">uv venv --python 3.12.0 </code></pre> <p>可以直接在项目根目录下执行这个命令,执行成功之后,项目根目录下会生成一个 <code>.venv</code> 文件夹,包含这个环境的所有信息,之后安装的包也会保存在这个文件夹下,记得将这个文件夹添加到 <code>.gitignore</code> 中。</p> <p>如果你熟悉 Node.js,会发现这个 <code>.venv</code> 文件夹和 Node.js 的 <code>node_modules</code> 文件夹功能类似,且它更进一步,不仅包含依赖,还能包含当前项目所需的 Python 本身。</p> <p>使用以下命令可以用虚拟环境中的 Python 执行指定脚本:</p> <pre><code class="language-bash">uv run example.py </code></pre> <p>如果你在终端中访问项目,可以使用以下命令激活当前 Python 虚拟环境:</p> <pre><code class="language-bash">source .venv/bin/activate </code></pre> <p>激活虚拟环境之后,可以直接用类似 <code>python example.py</code> 的方式来运行项目中的脚本。</p> <p>使用现代 IDE(比如 PyCharm、VSCode 等)打开这个项目时,IDE 一般都能自动识别项目中的 <code>.venv</code> 虚拟环境。</p> <h2 id="_2">安装依赖</h2> <p>配置好环境后,就可以使用类似下面的命令安装依赖了:</p> <pre><code>uv add django </code></pre> <p>这信命令会下载对应的包并安装在 <code>.venv</code>中,安装成功之后,会修改 <code>pyproject.toml</code> 和 <code>uv.lock</code> 文件。</p> <p>如果你刚将代码从仓库中拉到本地,项目中已经有了 <code>pyproject.toml</code> 和 <code>uv.lock</code>,那么只需执行以下命令即可安装所有依赖:</p> <pre><code class="language-bash">uv sync </code></pre> <p>注意,这个命令会根据 <code>pyproject.toml</code> 解析和下载依赖,有可能会改进 <code>uv.lock</code>。如果是在生产环境,你希望<strong>严格</strong>按照 <code>uv.lock</code> 中的版本安装依赖,可以使用以下命令:</p> <pre><code class="language-bash">uv sync --frozen </code></pre> <h2 id="docker-uv">在 docker 中使用 uv</h2> <p>如果你的项目需要使用 docker 发布,还有一些额外需要注意的事项。</p> <p>如果你的服务器在国内,那么可能需要使用国内 pypi 镜像,uv 中要指定镜像很简单,设置相应的环境变量即可,例如下面设置使用了阿里云的镜像:</p> <pre><code class="language-bash">ENV UV_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/ ENV UV_TRUSTED_HOST=mirrors.aliyun.com </code></pre> <p>在 docker 中安装依赖时,大体上有两种方式,一种是使用 <code>uv sync</code> 命令,如下所示:</p> <pre><code class="language-bash">WORKDIR /code/ COPY pyproject.toml uv.lock /code/ # 安装依赖 RUN uv sync --frozen --no-dev ENV PATH=&quot;/code/.venv/bin:$PATH&quot; </code></pre> <p>这种方式会在当前目录下创建虚拟环境,所有依赖都将安装到 <code>.venv</code> 目录下,因此需要将对应的目录加入 <code>PATH</code>。这种方式安装的依赖将严格遵守 <code>uv.lock</code> 中的版本限制,最为可靠。</p> <p>或者,也可以选择将依赖直接安装到系统环境中:</p> <pre><code class="language-bash">RUN uv pip install --no-cache-dir --system . </code></pre> <p>注意那个 <code>--system</code> 参数,这种方式会将各依赖包安装到全局目录,如果你的 docker 中只有这一个 Python 项目,且不想使用虚拟环境,也可以使用这种方式安装。不过,这种方式安装时虽然也会参考 <code>uv.lock</code>,但并不保证各依赖的版本和 <code>uv.lock</code> 中严格相同。</p> <h2 id="_3">小结</h2> <p>Python 发布迄今已有三十余年,一开始并没有第三方包的安装和管理工具,这和 Node.js 一发布就自带 npm 不同。如果你使用 Python 的时间较早,可能还会记得曾经有一个叫 easy_install 的工具用于安装 Python 的第三方包。</p> <p>约 2008 年,pip 发布,随后在 2014 年被 Python 官方集成到 3.4 版中(以及 2.7.9 中),Python 这才有了一个官方的依赖管理工具。不过 pip 并不完美,主要是依赖解析能力较弱,无法保证每次安装后的环境完全相同,同时安装速度也有一些慢,因此后续又出现了一些新的依赖管理工具,比如 poetry、uv 等。</p> <p>目前,开发 Python 项目的最佳实践是为每个项目创建独立的虚拟环境,将项目所需的依赖安装在该环境中,并通过依赖管理文件记录依赖,以确保隔离、可复现和可移植性。这些工作都可以使用 uv 完成,如果你正在开发或维护一个 Python 项目,不妨试试 uv。</p> 富文本框架体验 https://oldj.net/article/2025/06/21/rich-text-editor-experience https://oldj.net/article/2025/06/21/rich-text-editor-experience Sat, 21 Jun 2025 17:31:00 +0800 <p>由于项目中要使用富文本编辑器,过去一段时间我深入研究了一下几个知名的富文本框架,在这儿写一下体验。</p> <p>这不是一个全面的分析,仅代表个人观点。</p> <h2 id="quill">Quill</h2> <p>我最开始尝试的是 <a href="https://quilljs.com/">Quill</a> 项目,因为它看起来比较简单同时又足够强大,更重要的是它还有一个 <a href="https://pub.dev/packages/flutter_quill">Flutter 版本</a>,可用于移动端应用的富文本开发。</p> <p>实际体验上,Quill 确实比较容易上手,根据文档很容易就能写出一个基本可用的富文本编辑器来。当然,如果要添加更多复杂的或自定义功能,就要继续深入研究了。</p> <p>Quill 的数据结构比较特别,它自定义了一个名为 <a href="https://quilljs.com/docs/delta">Delta</a> 的 JSON 格式用于描述文档,这种格式概念很简单,比如只有 <code>insert</code>、<code>delete</code>、<code>retain</code> 三种操作,但理论上足以描述任意复杂的文档格式以及修改。</p> <p>在 Delta 的基础上,Quill 的文档可以被认为是一个“流”,只需一个数字就可以表示文档的任意位置,这让使用代码来查找、修改文档的操作变得非常简单。</p> <p>一切看起来都很美好,不过当我继续深入,想实现一些更复杂的功能之时,逐渐发现 Quill 存在一些较为严重的不足。</p> <p>第一个也是最大的不足,是 Quill 缺少“装饰器”设计。</p> <p>所谓“装饰器”,是给文档中指定内容临时添加一些样式,但又不会影响文档数据的功能。</p> <p>举例来说,实现关键词搜索时,我们需要把文档中所有匹配的关键词全部高亮显示(比如背景显示为黄色),但这个高亮样式只是临时性的,需要与富文本中原本就有的样式区分开来,添加这种装饰性的样式时,不需要触发文档的 <code>onChange</code> 事件,此时如果获取文档的内容,得到的数据中也不应该包含装饰性的样式。</p> <p>缺少了装饰器功能,如果想高亮匹配的搜索关键词,就只能直接修改文档数据,在文档的原始数据中为这些关键词添加样式,随后在搜索结束时再去除这些样式。这便需要开发者自行添加和维护这些临时样式的状态,并区分哪些修改是真正的修改可以保存,哪些则只是装饰性修改不需要保存,显然,这会让代码逻辑变得复杂,且由于不是框架底层原生支持,性能上也会差很多。</p> <p>第二个问题则和 Delta 格式有关。</p> <p>使用了一段时间之后,我发现 Delta 这种格式用于描述纯文本或简单富文本的修改确实很方便,生成的 JSON 也很容易阅读,但如果要描述带有复杂嵌套结构的 HTML,Delta 反而会让问题复杂化。</p> <p>同时,应该是实现上还存在 bug,当文档中有一些格式存在重叠、嵌套时,如果对它们再次格式化,可能会让内容变得混乱。对此我在 Quill 的 GitHub 仓库中提交了一个 <a href="https://github.com/slab/quill/issues/4574">issue</a> ,不过暂时没有得到回应。</p> <h2 id="slate">Slate</h2> <p>随后,我又尝试了 <a href="http://slatejs.org/">Slate</a> ,一个基于 React 的富文本框架。</p> <p>Slate 非常强大,且相较 Quill 自由度更高,基本上可以用它实现任何想要的功能。它自带装饰器(Decorations),因此,诸如高亮搜索关键词等功能的实现都变得很容易。对应地,它的概念也比 Quill 多了一些,学习成本稍高。</p> <p>我用 Slate 基本实现了整个需求,原本已经基本准备正式使用它了,不过也许是我对它的理解还不够,遇到了一些始终没能处理好的小问题,虽然不影响主要功能,但却让编辑器在体验上总是差了那么一点。</p> <p>比如,有时候选中了文档内容,鼠标点在空白处时不会取消选中,要再点一次才行。还有一些中文输入法似乎与它存在兼容问题,一些操作会让整个编辑器崩溃,我研究了很久也找不到修复或者处理方法,只能在外层容器捕获错误,提示用户刷新页面。</p> <h2 id="prosemirror">ProseMirror</h2> <p>最后,我决定转向 <a href="https://prosemirror.net/">ProseMirror</a> 。</p> <p>ProseMirror 是一个老牌富文本框架,它的作者还写过知名的代码编辑器 CodeMirror。ProseMirror 非常强大,但因为学习曲线相对陡峭,因此劝退了很多人,包括之前的我也一度先尝试 Quill、Slate 等方案。</p> <p>也有一些基于 ProseMirror 的富文本框架,比如 <a href="https://tiptap.dev/">TipTap</a> ,这也从另一个角度展示了 ProseMirror 的强大。</p> <p>ProseMirror 的文档对新手不太友好,且它有一些自已的概念,比如文档的结构由 Schema 定义和描述,因此需要花一些时间才能上手,不过,上手之后会发现它的文档其实非常详细,基本可以从中找到需要的一切信息。</p> <p>在使用 ProseMirror 将需求重新实现之后,我发现在使用 Slate 遇到的那些恼人的小问题要么没有了,要么有解决方案,而且在处理有较多装饰器的长文档时,ProseMirror 的性能似乎更好一些。</p> <p>当然,ProseMirror 也不能说十全十美,不过,在深度使用了几个流行的富文本编辑器框架之后,个人觉得 ProseMirror 是综合来说最强大也最值得学习的。</p> <h2 id="_1">小结</h2> <p>富文本编辑器是前端开发中最复杂也最难的主题之一,幸运的是多数情况下我们不需要完全从头开始开发,而是可以选择一个基础框架,基于它进行二次开发。</p> <p>网上有很多富文本编辑器框架的评测,你可以根据自己的需求以及实际情况进行选择。</p> <p>如果你希望尽快完成项目,且你的文档结构不会很复杂,也没有装饰器等需求,可以考虑 Quill。</p> <p>如果你的需求比较复杂,且在使用 React,可以考虑 Slate。</p> <p>最后,如果你的需求很复杂,且你的时间不那么紧张,推荐选择 ProseMirror。</p> 使用函数计算运行定时任务 https://oldj.net/article/2025/05/02/cron-job-with-fc https://oldj.net/article/2025/05/02/cron-job-with-fc Fri, 02 May 2025 15:42:00 +0800 <p>上一篇博客提到我在<a href="/article/2025/04/20/deploy-website-with-dokploy">使用 Dokploy 部署网站服务</a>,但 Dokploy 不支持定时任务,于是只能创建普通服务,并在内部使用脚本定时执行命令。最近发现,将这些定时任务放在函数计算中执行可能是更好的选择。</p> <p>我的服务都跑在阿里云上,下面介绍的也是阿里云函数计算。</p> <h2 id="_1">创建函数</h2> <p>要创建一个函数,在阿里云函数界面后台,点击创建函数按钮即可,如下图所示:</p> <p><center> <img src="https://s.oldj.net/upload/2025/05/01/01.png" width="560" alt="" /> </center></p> <p>在随后的界面中,选择“任务函数”类型。</p> <p><center> <img src="https://s.oldj.net/upload/2025/05/01/02.png" width="315" alt="" /> </center></p> <p>然后,函数代码部分可根据需要选择类型,比如可以使用 ACR 中的 Docker 镜像。</p> <p><center> <img src="https://s.oldj.net/upload/2025/05/01/03.png" width="648" alt="" /> </center></p> <p>需要注意的是,无论是上传代码还是使用 Docker 镜像,都要确保对应的代码能提供一个 HTTP 服务,因为定时任务执行的入口即是这个 HTTP 服务。</p> <p>其他还有环境变量等配置,根据你的实际情况填写即可。</p> <h2 id="_2">函数入口</h2> <p>使用函数计算的定时任务,需要你的代码提供一个 HTTP 服务,定时任务执行时,会以 POST 的方式请求 <code>/invoke</code> 路径,即类似下面这样的请求:</p> <pre><code class="language-bash">curl -X &quot;POST&quot; &quot;http://localhost:8050/invoke&quot; \ -H 'Content-Type: application/json' \ -d $'{ &quot;payload&quot;: &quot;YOUR_PAYLOAD&quot;, &quot;triggerName&quot;: &quot;trigger-name&quot;, &quot;triggerTime&quot;: &quot;2025-04-27T03:12:45Z&quot; }' </code></pre> <p>当然,真实的请求还有很多 HTTP 头信息。</p> <p>你需要在代码中实现 <code>/invoke</code> 接口,并在其中执行定时任务。在函数计算后台,可以设置超时时间等属性。</p> <p>注意其中的 <code>payload</code> 字段,后面在设置定时触发器时,可以自定义传入的 <code>palyload</code> 信息。</p> <h2 id="_3">设置定时触发器</h2> <p>添加函数之后,即可在配置界面设置触发器。</p> <p><center> <img src="https://s.oldj.net/upload/2025/05/01/04.png" width="553" alt="" /> </center></p> <p>函数计算支持多种触发器,在这儿,我们选择定时触发器即可。</p> <p><center> <img src="https://s.oldj.net/upload/2025/05/01/05.png" width="790" alt="" /> </center></p> <p>其中最后一个字段“触发消息”,其中填写的内容即是上面 <code>payload</code> 参数的值。注意这儿传递的是普通字符串,而不是 JSON,收到之后可根据需要做一个解析。</p> <p>如果你在同一个函数中有多个用途不同的触发器,可以通过 <code>payload</code> 参数进行区分。</p> <p>设置好之后,在函数详情界面可以看到类似下面的图示。</p> <p><center> <img src="https://s.oldj.net/upload/2025/05/01/06.png" width="974" alt="" /> </center></p> <p>如果一切顺利,定时任务就添加成功了,稍后可以在日志页面看到执行记录。</p> <p><center> <img src="https://s.oldj.net/upload/2025/05/01/07.png" width="1600" alt="" /> </center></p> <h2 id="_4">更新函数</h2> <p>如果你的函数使用的是 ACR 中的 Docker 镜像,当推送了新的镜像时,函数计算的版本不会自动更新,需要你登录网站后台手动修改,或者调用函数计算的 API 进行设置。</p> <p>每次手动修改是一件很麻烦的事,建议使用 API,以便和现有的发布流程结合起来。你可以先安装 <a href="https://help.aliyun.com/zh/cli/">aliyun-cli</a> 命令行工具,然后执行类似下面的命令:</p> <pre><code class="language-bash">aliyun fc PUT /2023-03-30/functions/YOUR_FC --region cn-shanghai --header &quot;Content-Type=application/json;&quot; --body &quot;{\&quot;tracingConfig\&quot;:{},\&quot;customContainerConfig\&quot;:{\&quot;image\&quot;:\&quot;registry-vpc.cn-shanghai.aliyuncs.com/XXX/YYY:1.2.3\&quot;}}&quot; </code></pre> <p>请注意将其中的参数值替换为你的项目中的值。</p> <h2 id="_5">小结</h2> <p>有一些定时任务(比如清理老数据、备份用户数据等)比较耗费资源,将它们迁移到函数计算中可以减少主服务器的负担,是一个不错的实践。</p> <p>函数计算是按量收费的,多数情况下,定时任务使用函数计算应该比专门买一台服务器划算,不过也不要大意,请做好优化,同时注意关注每日的用量。</p> 使用 Dokploy 部署网站服务 https://oldj.net/article/2025/04/20/deploy-website-with-dokploy https://oldj.net/article/2025/04/20/deploy-website-with-dokploy Sun, 20 Apr 2025 21:41:00 +0800 <p>之前的几年我一直在使用 <a href="https://oldj.net/article/2022/04/17/install-k3s-and-rancher/">K3s + Rancher</a> 的组合来管理网站服务,不过前段时间迁移到了 <a href="https://dokploy.com/zh-Hans">Dokploy</a>,在这儿记录一下要点。</p> <h2 id="_1">为什么迁移?</h2> <p>K3s + Rancher 的组合挺好,几年来一直运行稳定,不过对像我这样的非专业运维来说还是有点太复杂了,事实上几年来,我一直只在使用这个组合的一些最基础的功能。</p> <p>去年看到有人介绍 Dokploy,了解了一下之后,发现它非常适合我的使用场景,同时又足够简单,于是花了一点时间做了研究,并最终决定迁移到 Dokploy。</p> <p>除了 Dokploy 之外,还有 <a href="https://www.coolify.io/">Coolify</a> 等产品也不错,而且功能更多一些,读者朋友如果有需要也可以试一试。</p> <h2 id="_2">云服务还是自托管?</h2> <p>Dokploy 提供了云服务,订阅之后可通过他们的云服务管理自己的服务器。</p> <p>云服务听起来是个不错的选择,可以减少自己运维的时间成本,我也花了 $4.5 订阅了一个月体验了一番。不过 Dokploy 的云服务在海外,我的服务器在国内,两者之间通讯不畅,因此体验并不是很好。</p> <p>最后,我选择了自托管服务,将 Dokploy 和网站服务安装在同一个网络中。</p> <h2 id="dokploy">安装 Dokploy</h2> <p>Dokploy 的安装很简单,在一台干净的服务器上运行以下命令即可:</p> <pre><code class="language-bash">curl -sSL https://dokploy.com/install.sh | sh </code></pre> <p>为了确保 Dokploy 能顺利运行,这台服务器建议至少要 2 CPU + 2 G 内存。</p> <p>如果你的服务器在国内,安装时可能耗时较长,可以添加国内的 docker 镜像,比如修改 <code>/etc/docker/daemon.json</code> 文件,添加以下内容:</p> <pre><code class="language-json">{ &quot;registry-mirrors&quot;: [ &quot;https://docker.1ms.run&quot; ] } </code></pre> <p>安装完成之后,即可通过 <code>http://{服务器 IP}:3000</code> 的形式访问 Dokploy 后台。</p> <h2 id="_3">添加服务器</h2> <p>Dokploy 成功安装后,马上就可以开始创建应用。不过,这时创建的应用会和 Dokploy 安装在同一台服务器上,你也可以在 Dokploy 后台添加新的服务器,并将应用添加到新服务器上。</p> <p>个人建议用一台服务器专门运行 Dokploy,然后在 Remote Servers 面板中添加其他服务器。</p> <p>添加服务器之后,还需要在 Actions 菜单中点击 Setup Server,并根据提示进行设置。</p> <p><center> <img src="https://s.oldj.net/upload/2025/04/20/setup_server.png" width="420" alt="" /> </center></p> <p>其中 Deployments 那个步骤可能耗时会很长,可以考虑点击 Modify Script,将脚本复制到对应的服务器上手动执行。</p> <p><center> <img src="https://s.oldj.net/upload/2025/04/20/setup_server_deployments.png" width="886" alt="" /> </center></p> <h2 id="_4">添加服务</h2> <p>添加完服务器之后,就可以添加项目,随后在项目中添加服务了。</p> <p>添加服务这儿,最重要的一个设置是 Provider,即设置代码的来源。</p> <p>Dokploy 支持多种常见的源,比如 Github,配置好之后只需向指定仓库和分支推送代码,Dokploy 就会自动拉取并构建代码,就像 Vercel 一样。</p> <p>对小项目来说,这样的方式自然是很方便的,不过也可以用 Docker 作为 Provider,并使用第三方镜像服务。这样主要有两个好处:</p> <ol> <li>镜像的构建工作在第三方执行,不会占用线上服务器资源;</li> <li>第三方构建镜像时可以打上版本号 tag,后续回滚操作将会很方便。</li> </ol> <p>我使用的是阿里云的容器镜像服务,填写方式类似下图:</p> <p><center> <img src="https://s.oldj.net/upload/2025/04/20/provider_docker.png" width="865" alt="" /> </center></p> <h2 id="_5">更新服务</h2> <p>Dokploy 提供了丰富的 API,几乎所有操作都可以通过 API 完成。当某个服务需要更新时,可以登录网站手动修改相关值,也可以使用 API 更新。</p> <p>比如,如果一个服务的 Provider 是 Docker,可以用类似下面的请求进行修改:</p> <pre><code class="language-shell">curl -X &quot;POST&quot; &quot;https://your-dokploy/api/application.saveDockerProvider&quot; \ -H 'x-api-key: $YOUR_TOKEN' \ -H 'Content-Type: application/json' \ -d $'{ &quot;applicationId&quot;: &quot;$APP_ID&quot;, &quot;dockerImage&quot;: &quot;$DOCKER_URL&quot; }' </code></pre> <p>有几个注意点:</p> <ol> <li>授权头信息是 <code>x-api-key: xxx...</code>,而不是常见的 <code>Authorization: Bearer xxx...</code> 。</li> <li><code>applicationId</code> 的值在 URL 中,在界面上暂时没有显示。</li> </ol> <p>比如某个服务的地址是 <code>https://your-dokploy/dashboard/project/aaa/services/application/bbb</code>,地址最后的 <code>bbb</code> 就是 <code>applicationId</code>。</p> <p>通过 API 的方式,可以很方便地将服务的发布、回滚等操作集中到一处管理,或者与你现有的服务集成。</p> <h2 id="dokploy_1">升级 Dokploy</h2> <p>Dokploy 本身也在不断迭代更新,一段时间之后,你可能需要升级 Dokploy。</p> <p>Dokploy 后台提供了自助升级服务,不过由于网络原因,在国内服务器上这个升级可能会失败,也可以登录到服务器后使用以下命令升级:</p> <pre><code class="language-bash">curl -sSL https://dokploy.com/install.sh | sh -s update </code></pre> <h2 id="_6">使用小结</h2> <p>使用 Dokploy 已经有一段时间了,整体而言还是很满意的,相对其他方案它很容易上手,且足够稳定,可用于生产环境。</p> <p>不足是暂时还不支持定时任务,不过可以通过启动一个普通服务并在其中运行定时脚本的方式解决。</p> <p>如果你有类似的需求,不妨也试一试 Dokploy。</p> Electron 中的 Kiosk 窗口 https://oldj.net/article/2024/11/28/electron-kiosk-mode https://oldj.net/article/2024/11/28/electron-kiosk-mode Thu, 28 Nov 2024 21:38:00 +0800 <p>最近在产品中用到了 Electron 中的 Kiosk 模式,记录一下要点。</p> <h2>什么是 Kiosk 模式?</h2> <p>Kiosk 模式是一种专门为限制用户操作而设计的应用运行模式,通常用于构建锁定的全屏应用程序,禁止用户访问系统其他功能或退出应用。在这种模式下,应用程序占据整个屏幕,并且用户无法通过常见的方式(如键盘快捷键、窗口控制按钮等)退出或切换到其他应用。</p> <p>Kiosk 模式的主要用途是为用户提供一个专注且受限的操作环境,避免对系统的其他部分产生干扰。</p> <h2>哪些场景下需要使用 Kiosk 模式?</h2> <p>Kiosk 模式被广泛应用于以下场景:</p> <ul> <li>公共信息亭:自助服务终端,如银行 ATM、自助点餐机、自助售票机。</li> <li>展览展示:在博物馆、展览会、零售店中,用于展示信息或广告内容的屏幕。</li> <li>教育场景:限制学生只能使用特定的教学应用,避免访问其他不必要的内容。</li> <li>会议或演讲:锁定演示内容,避免误操作或退出。</li> <li>数字标牌:作为广告屏幕或公告牌,循环播放内容。</li> </ul> <p>当然,我在开发的是日常效率软件,并不属于以上场景。我用到 Kiosk 模式的场景主要如下。</p> <h3>图几截图软件</h3> <p>我开发并维护着一个截图软件<a href="https://tuji.app">图几</a>,它有三种截图模式:全屏截图、窗口截图、区域截图。</p> <p>其中区域截图的交互方式是:用户点击截图按钮(或按下截图快捷键),先生成当前屏幕的截图,随后显示一个全屏无边框窗口,在窗口中显示将刚刚生成的屏幕截图,同时允许用户在窗口上进行框选等操作。</p> <p>这个无边框窗口就需要使用 Kiosk 模式,以免用户无意中切换窗口。当然,等用户完成或取消截图时,需要再退出或关闭对应的 Kiosk 窗口。</p> <h3>WonderPen 写作软件</h3> <p><a href="https://wonderpen.app">WonderPen 写作软件</a>最近添加了<em>小黑屋</em>模式,进入这种模式后,软件将全屏显示,屏蔽一切干扰,在完成预设的写作目标之前,将无法退出或切换到其他软件。</p> <p>这个禁止退出的小黑屋,自然也使用了 Kiosk 模式。</p> <h2>Electron 中的 Kiosk 模式</h2> <p>在 Electron 中,将一个窗口设为 Kiosk 模式非常简单,在创建窗口时设置 <code>kisok</code> 属性为 <code>true</code> 即可。</p> <p>有时,我们的窗口在创建时需要以普通模式显示,然后再在一定条件下切换为 Kiosk 模式,只需用类似下面的代码切换即可:</p> <pre><code class="language-js">win.setKiosk(flag) </code></pre> <p>其中 <code>flag</code> 是一个布尔值。</p> <p>你还可以使用 <code>win.isKiosk()</code> 方法判断当前窗口是否为 Kiosk 模式。</p> <p>在实践过程中,我发现很多时候只设置 Kiosk 属性还不太够,还需要设置 <code>frame</code> 等属性。以下是一个示例:</p> <pre><code class="language-js">const win = new BrowserWindow({ // 其他属性... closable: false, maximizable: false, minimizable: false, resizable: false, fullscreen: false, fullscreenable: false, frame: false, skipTaskbar: true, alwaysOnTop: true, useContentSize: true. autoHideMenuBar: true. movable: false. thickFrame: false. titleBarStyle: 'default', paintWhenInitiallyHidden: false, roundedCorners: false, enableLargerThanScreen: true, acceptFirstMouse: true, kiosk: true, // 其他属性... }) </code></pre> <p>即使这样设置之后,在 macOS 上有时仍会出现 Docker 栏和顶部系统菜单栏出现在 Kiosk 窗口上方的情况,因此还需要进一步设置 alwaysOnTop 的属性为 <code>screen-saver</code>,代码如下:</p> <pre><code class="language-js">win.setAlwaysOnTop(true, 'screen-saver', 1) </code></pre> <p>在 Windows 和 macOS 中,alwaysOnTop 的窗口有多种极别,按层级由低到高分别是:</p> <ul> <li>normal</li> <li>floating</li> <li>torn-off-menu</li> <li>modal-panel</li> <li>main-menu</li> <li>status</li> <li>pop-up-menu</li> <li>screen-saver</li> </ul> <p>如果只是简单地 <code>win.setAlwaysOnTop(true)</code> ,则窗口的级别只是 <code>floating</code>,仍有可能被其他系统组件遮挡。</p> <p>另外需要注意,在 macOS 下,太高的级别会挡住系统自带输入法的候选字窗口,如果你的 Kiosk 窗口需要用户输入,并且可能使用系统自带输入法的话,这个级别不能高于 <code>modal-panel</code>。</p> <h2>一些其他注意点</h2> <p>Kiosk 模式只对当前窗口有效,一个窗口只能覆盖一个屏幕,若用户有多个显示器,则需先检测显示器数量,然后创建多个 Kiosk 窗口分别覆盖。</p> <p>设置 Kiosk 模式后,用户仍可以使用 <code>Cmd+Q</code> 这样的快捷键退出应用,因此需要在代码中监听窗口的 <code>close</code> 事件,并检查是否处在 Kiosk 状态,如是则阻止退出。代码类似下面这样:</p> <pre><code class="language-typescript">win.on('close', async (e: Electron.Event) =&gt; { if (win.isKiosk()) { e.preventDefault() return } // 其他逻辑 } </code></pre> <p>Windows 下退出 Kiosk 模式后,窗口的大小可能会变成全屏大小,如希望退出时恢复原大小,可以在进入 Kiosk 模式之前先记住窗口大小,退出后再设置为原大小。</p> <p>Kiosk 模式并不能阻止用户重启计算机。如果希望重启计算机后能自动恢复 Kiosk 状态,可以将软件设置为随系统启动,并且启动时自动进入 Kiosk 模式。</p> 使用 acme.sh 申请 SSL 证书 https://oldj.net/article/2024/09/23/ssl-cert-with-acme-sh https://oldj.net/article/2024/09/23/ssl-cert-with-acme-sh Mon, 23 Sep 2024 10:57:00 +0800 <p>之前很长一段时间,这个博客一直在用云服务商提供的免费 SSL 证书,那个证书有一年有效期,也即一年只需要申请部署一次,因此全手动操作也不算麻烦,但现在免费 SSL 证书的有效期统一缩短为 3 个月了,意味着每 3 个月就要操作一次,这就让手动申请和部署变得麻烦起来了。</p> <p>最近,我尝试了一下使用 <a href="https://acme.sh">acme.sh</a> 申请 SSL 证书的方法,确实方便了不少,在这里记录一下。</p> <h2>安装 acme.sh</h2> <p>acme.sh 是一个实现 ACME 协议的脚本,主要用途是申请或更新免费 SSL 证书。运行以下命令即可安装:</p> <pre><code class="language-bash">curl https://get.acme.sh | sh -s [email protected] </code></pre> <p>更多安装方式可见官方文档:<a href="https://github.com/acmesh-official/acme.sh">https://github.com/acmesh-official/acme.sh</a>。</p> <p>acme.sh 会被安装在 <code>~/.acme.sh</code> 目录下。</p> <h2>手动申请证书</h2> <p>安装好 acme.sh 后,可以用以下命令申请证书:</p> <pre><code class="language-bash">acme.sh --issue --dns -d mydomain.com -d &quot;*.mydomain.com&quot; --yes-I-know-dns-manual-mode-enough-go-ahead-please </code></pre> <p>记得把其中的 <code>mydomain.com</code> 换成你自己的域名。</p> <p>上面的代码中,我申请了泛域名证书,所以同时添加了 <code>mydomain.com</code> 和 <code>*.mydomain.com</code> 域名。需要注意的是,<code>*.mydomain.com</code> 不包含 <code>mydomain.com</code>,如果你希望证书除了包含 <code>www.mydomain.com</code> 这样的二级域名,也包含 <code>mydomain.com</code> 的话,记得把 <code>mydomain.com</code> 也加上。</p> <p>另外,<code>*.mydomain.com</code> 也不包含更深的层级,比如它包含 <code>home.mydomain.com</code>,但不包含 <code>app.home.mydomain.com</code> 。如果你需要更深层级的泛域名,需要把对应的域名也填上。</p> <p>还需要注意的是最后一个参数 <code>--yes-I-know-dns-manual-mode-enough-go-ahead-please</code> 。acme.sh 更希望用户使用自动申请证书的方式(见下一小节),如果你确实需要手动申请,需加上这个参数,否则命令不会正常执行。</p> <p>如果一切顺利,acme.sh 命令会输出两段 TXT 信息,需要你<strong>手动</strong>添加到对应域名的 DNS 解析中,以验证你确实对这个域名拥有权限。在证书申请完成之后,可以删除对应的 TXT 记录。</p> <p>登录域名服务商(比如阿里云)后台,在域名解析中添加上对应的 TXT 记录,然后再运行以下命令,即可生成证书:</p> <pre><code class="language-bash">acme.sh --renew -d mydomain.com -d &quot;*.mydomain.com&quot; --yes-I-know-dns-manual-mode-enough-go-ahead-please </code></pre> <p>证书会被保存在 <code>~/.acme.sh/</code> 目录下,包含以下四个文件:</p> <ul> <li><code>mydomain.com.cer</code> 证书</li> <li><code>mydomain.com.key</code> 密钥</li> <li><code>ca.cer</code></li> <li><code>fullchain.cer</code> 全链路证书</li> </ul> <p>其中在网站场景主要使用 <code>fullchain.cer</code> 文件和 <code>mydomain.com.key</code> 文件。</p> <h2>自动申请证书</h2> <p>可以看到,上面手动申请的步骤,主要的手动操作就是要为域名添加 TXT 记录以验证域名权限,acme.sh 支持让这个步骤自动化,即自动添加 TXT 记录,并在验证完成之后自动删除对应的记录。</p> <p>以阿里云为例(如果你的域名是在阿里云注册并解析的),首先需要去阿里云控制台获取一个 AccessKey,建议专门设置一个 RAM 用户,只开通 DNS 权限。</p> <p>得到 AccessKey 之后,在命令行中执行以下命令:</p> <pre><code class="language-bash">export Ali_Key=&quot;key&quot; export Ali_Secret=&quot;secret&quot; </code></pre> <p>随后再执行以下命令,即可自动申请或更新证书了:</p> <pre><code class="language-bash">acme.sh --issue --dns dns_ali -d mydomain.com -d &quot;*.mydomain.com&quot; </code></pre> <p>注意 <code>--dns</code> 参数后面的值为 <code>dns_ali</code>。</p> <p>一切顺利的话,证书申请会自动完成,并被保存在 <code>~/.acme.sh/</code> 目录下。</p> <p>其他各大域名服务商的自动申请方式类似,具体可参见官方文档。</p> <h2>一些注意点</h2> <p>如果你使用了自动申请,AccessKey 会被明文保存在 <code>~/.acme.sh/account.conf</code> 文件内,如果介意,可在申请完之后修改这个文件并删除对应的 AccessKey。</p> <p>另外,使用自动申请后,acme.sh 会添加一条定时任务,每天自动检查证书是否需要更新。可运行以下命令查看当前系统的定时任务列表:</p> <pre><code class="language-bash">crontab -l </code></pre> <p>现在 acme.sh 默认使用的证书颁发机构是 ZeroSSL,还有一些其他可选机构,比如 Let's Encrypt。可以用 <code>--set-default-ca</code> 修改默认证书颁发机构,比如:</p> <pre><code class="language-bash">acme.sh --set-default-ca --server letsencrypt </code></pre> <p>我没有修改 CA,在使用默认的 ZeroSSL 的证书,目前来看暂时没有遇到什么问题。</p> <p>除了自动申请证书外,大部分网络服务商也支持自动上传 SSL 证书,不过这部分我还没有研究,后续如果觉得值得记录,会另外写文分享。</p> 处理苹果平台的 CONSUMPTION_REQUEST 消息 https://oldj.net/article/2024/08/09/consumption-request-apple https://oldj.net/article/2024/08/09/consumption-request-apple Fri, 09 Aug 2024 16:31:00 +0800 <p>最近完善了一下产品的购买流程,其中的一项工作是处理来自苹果 App Store 平台的 CONSUMPTION_REQUEST 消息,在这儿记录一下要点。</p> <h2>消息说明</h2> <p>App 如果使用了苹果的内购(IAP),每当发生用户购买、续费、退款等操作时,苹果服务器都会向开发者指定的地址发送一条消息,不同的消息有不同的 <code>notificationType</code> 值,其中 CONSUMPTION_REQUEST 消息的意思是用户为应用内购买发起了退款请求,App Store 请求开发者服务器提供用户的消费数据,用于协助 App Store 决定是否给用户退款。</p> <p>开发者可以忽略 CONSUMPTION_REQUEST 消息,也可以根据需要,在 12 小时内回应 App Store。</p> <h2>回应消息</h2> <p>要回应 CONSUMPTION_REQUEST 消息,只需向指定的地址发一个 PUT 请求即可。具体细节可见<a href="https://developer.apple.com/documentation/appstoreserverapi/send_consumption_information">官网文档</a>。</p> <p>这个 PUT 消息的要点主要有两个:</p> <ol> <li>在 Header 中添加认证 token 信息;</li> <li>在 Body 中发送一个 JSON 格式的对象,向 App Store 提交对应的信息。</li> </ol> <h2>数据内容</h2> <p>我们先看 Body 中的数据内容。</p> <p>根据<a href="https://developer.apple.com/documentation/appstoreserverapi/consumptionrequest">文档</a>,数据字段以及含义大致如下:</p> <pre><code class="language-js">{ &quot;accountTenure&quot;: 0, // 用户年龄段,0 表示未知 &quot;appAccountToken&quot;: &quot;&quot;, // 用户 uuid,由于之前没有设置,此处留空 &quot;consumptionStatus&quot;: 0, // 消费状态,0:未知,1:未消费,2:部分消费,3:全部消费 &quot;customerConsented&quot;: True, // 用户是否同意提供消费数据 &quot;deliveryStatus&quot;: 0, // 交付状态,0:已成功交付 &quot;lifetimeDollarsPurchased&quot;: 0, // 用户在应用内购买的总金额,0 表示未知 &quot;lifetimeDollarsRefunded&quot;: 0, // 用户在应用内退款的总金额,0 表示未知 &quot;platform&quot;: 1, // 平台,0:未知,1:苹果平台,2:其他平台 &quot;playTime&quot;: 0, // 用户在应用内的总时间,0 表示未知 &quot;refundPreference&quot;: 1, // 商家对退款的意见,0:未知,1:支持,2:不支持,3:不确定 &quot;sampleContentProvided&quot;: True, // 是否已经提供了示例内容 &quot;userStatus&quot;: 1, // 用户账号状态,0:未知,1:活跃,2:暂停,3:关闭,4:受限 } </code></pre> <p>你可以根据需要,修改对应字段的值。</p> <h2>请求 Header</h2> <p>请求 Header 中有两个必填的自定义字段,分别是:</p> <ul> <li><strong>Content-Type</strong> 值固定是 <code>application/json</code></li> <li><strong>Authorization</strong> 值为 <code>Bearer $jwt_token</code></li> </ul> <p>其中 <code>jwt_token</code> 必须要正确填写,否则请求会返回 401 错误。</p> <p><code>jwt_token</code> 的具体生成说明可见官方<a href="https://developer.apple.com/documentation/appstoreserverapi/generating_json_web_tokens_for_api_requests">文档</a>,大致格式类似下面这样:</p> <p>Header:</p> <pre><code class="language-json">{ &quot;kid&quot;: &quot;ZA12345678&quot;, &quot;alg&quot;: &quot;ES256&quot;, &quot;typ&quot;: &quot;JWT&quot; } </code></pre> <p>Payload:</p> <pre><code>{ &quot;iss&quot;: &quot;your_uuid&quot;, &quot;iat&quot;: 1723173620, &quot;exp&quot;: 1723183620, &quot;aud&quot;: &quot;appstoreconnect-v1&quot;, &quot;bid&quot;: &quot;your_bundle_id&quot; } </code></pre> <p>其中 <code>kid</code>、<code>iss</code>,以及生成 JWT 时所需的私钥等几项,需要去 App Store Connect 后台生成。</p> <h2>JWT 私钥</h2> <p>如果你之前还没有生成过对应的私钥,可以前往 App Store Connect 后台的“用户和访问” → “集成” → “App 内购买项目”页面生成,如下图所示:</p> <figure> <img src="https://s.oldj.net/upload/2024/08/09/app_store.png" width="1080" alt=""/> <figcaption></figcaption> </figure> <p>生成之后,可以在这个页面下载 .p8 格式的私钥。注意这个私钥只能下载一次,下载之后请妥善保存,如果不慎遗失,只能删除再重新生成一个。</p> <p>上面生成 JWT 所需的 <code>kid</code> 对应上图中的“密钥 ID”,<code>iss</code> 对应“Issuer ID”,私钥即上面下载的 .p8 文件中的内容。</p> <p>然后就可以用类似下面的方法生成 JWT 了:</p> <pre><code class="language-python">import jwt jwt_token = jwt.encode( payload, private_key, algorithm=&quot;ES256&quot;, headers=headers, ) </code></pre> <p>最后,将得到的 <code>jwt_token</code> 以 <code>Bearer $jwt_token</code> 的形式包含在请求头的 <code>Authorization</code> 中,发起 PUT 请求即可。</p> <p>如果请求返回 202 状态码,表示请求成功了。如果是其他值,可根据错误状态再仔细检查处理。</p> 在 Flutter 中适配 1Password 登录 https://oldj.net/article/2024/06/27/flutter-and-1password https://oldj.net/article/2024/06/27/flutter-and-1password Thu, 27 Jun 2024 14:34:00 +0800 <p>最近在开发 Flutter 项目,其中 iOS 版 App 账号登录时,需要适配 1Password 等密码管理器,即需要告诉 1Password 等密码管理器当前 App 的登录特征信息(域名),以及应该填写界面上的哪些表单项。在这儿记录一下要点。</p> <h2>基本设置</h2> <p>我们首先要处理的,是让 App 和某个域名(通常是官网域名)关联,这样在 App 中唤起 1Password 填写密码时,1Password 才知道应该显示哪些账号。</p> <p>这儿主要有三个步骤。</p> <h3>Apple 开发者后台设置</h3> <p>在 Apple 开发者后台的 Certificates, Identifiers &amp; Profiles 页面,记得要选中 Associated Domains 选项,如下图所示:</p> <figure> <img src="https://s.oldj.net/upload/2024/06/27/tucdda7Ab6.png" width="1257" alt=""/> <figcaption></figcaption> </figure> <h3>Xcode 中的设置</h3> <p>接下来,要在 Xcode 中为你的 App 添加关联域名,如下图所示:</p> <figure> <img src="https://s.oldj.net/upload/2024/06/27/QiCYoDUIUn.png" width="869" alt=""/> <figcaption></figcaption> </figure> <p>在 Domains 那一栏,添加 <code>webcredentials:你的域名</code> 即可,比如你的域名是 test.com,那么添加 <code>webcredentials:test.com</code> 就行。</p> <h3>网站设置</h3> <p>最后,还需要在你的域名对应的网站上添加一个认证文件,证明指定 App 确实和当前域名相关。这个文件的文件名固定为 <code>apple-app-site-association</code>,可以放在网站的根目录,或者 <code>.well-known</code> 目录下,确保可以通过网络访问到。</p> <p>这个文件的用途很多,可能还会包含一些其他字段,和密码管理器相关的主要是以下内容:</p> <pre><code>{ &quot;webcredentials&quot;: { &quot;apps&quot;: [ &quot;TeamId.BundleId&quot; ] } } </code></pre> <p>确保你的 <code>apple-app-site-association</code> 文件包含 <code>webcredentials</code> 字段,并将其中 apps 中的 TeamId、BundleId 换成你的真实 ID。</p> <h2>Flutter 中的设置</h2> <p>为了得到更好的登录体验,Flutter 中也要做一些设置,主要是告诉 1Password 等密码管理器需要填写哪些字段,以及各个字段分别对应什么内容。</p> <p>关键代码如下:</p> <pre><code class="language-dart">@override Widget build(BuildContext context) { // ... return Container( body: Center( child: AutofillGroup( child: Column( children: [ TextField( autofillHints: const [AutofillHints.email], decoration: InputDecoration( labelText: 'Email', ), ), TextField( autofillHints: const [AutofillHints.password], decoration: InputDecoration( labelText: 'Password', ), ), ElevatedButton( onPressed: () { // Submit the form }, child: Text('Submit'), ), ], ), ), ), ); } </code></pre> <p>其中最关键的有两处,一是需要自动填写的表单部分,需要用 <code>AutofillGroup</code> 组件包起来,这样 1Password 就知道哪些字段是需要自动填写的。二是 Email、用户名、密码等需要填写的字段,需要添加 <code>autofillHints</code> 属性,比如 <code>autofillHints: const [AutofillHints.email]</code>,这样 1Password 才知道当前字段应该填什么内容。</p> <p>完成这些设置之后,App 登录时就应该能正常适配 1Password 了。</p> 从打牌想到的 https://oldj.net/article/2024/04/21/lessons-from-poker https://oldj.net/article/2024/04/21/lessons-from-poker Sun, 21 Apr 2024 14:17:00 +0800 <p>最近几个月打了很多次牌,有时是线下聚会时和朋友玩,有时则是在手机上玩。玩得久了,逐渐发现扑克牌游戏和现实生活中的规则有一些类似之处。以下是一些感想。</p> <h2>手中的牌</h2> <p>打牌时,手中拿到什么牌非常重要,这一点很容易理解,无论你是高手还是菜鸟,如果起手就拿到一手好牌,那么只要不乱打,并且运气不是差到极点,你基本上都能赢。同样的,就算你是绝顶高手,如果拿到一把烂牌,要赢恐怕也非常难。</p> <p>当然,拿到极品好牌和极品烂牌的概率都不大,很多时候,我们以及我们的对手拿到的都是中等牌,这种时候,如何组合手中的牌打出最好的效果,就看各自的技术了。</p> <p>我们的人生也类似,总有一些人一开始就拿到一手好牌,比如家境良好,父母见识不凡,自身也健康聪明,因而只要自己不走错路,人生总体上会非常顺利。还有一些不那么幸运的人,出生在落后的地方贫穷的家庭,几乎没有什么可利用的资源或者助力,要获得成功可就不容易了。</p> <p>拿到好牌时,不要得意忘形,因为你的成功很大程度源于运气。拿到烂牌时,也不要破罐子破摔,认真思考,尽最大努力打好手中的牌,因为只有这样你才能多一点获胜的机会。</p> <h2>合适的才是最好的</h2> <p>摸牌的时候,一般来说摸到大牌比摸到小牌更好一些,不过稍有经验就会发现,和单独的大牌相比,那些能让现有的牌组合起来的牌可能更好。比如,有一些时候,摸到一张最小的 2 可能会让你的几张散牌组成同花顺,这时对你而言 2 就比大王更好。</p> <p>生活和工作中也是类似,有一些团队,单独来看每位成员可能都相对普通,但由于配合出色,于是团队整体的战斗力非常强悍。</p> <p>团队招人的时候,也不是招越牛的人越好,而是要看新来的人能否让团队的整体能力得到提升。有时候,也许加入一位履历一般但却能搞定一些其他人不擅长处理的小事的成员,会让团队整体焕发新生。同样的,一位看似不重要的成员离开,也有可能打断团队内部的某种连接,让团队效率大受影响。</p> <p>寻找人生伴侣也是如此,那些光彩夺目的潜在选项当然也不错,但一位能与你互补,让你成为更好的自己的伴侣,也许是更好的选择。</p> <h2>本钱</h2> <p>拿到什么牌很大程度上能决定你单局的胜负,有多少本钱则决定你能在牌桌上待多久。</p> <p>以腾讯欢乐斗地主的掼蛋游戏为例,新手场单场输赢封顶是 5 万欢乐豆。即如果你手中的欢乐豆不足 5 万,那么你赢的时候手中的欢乐豆翻倍,输的时候会赔得精光。当然,实际上输赢的数额还取决于对手的豆子够不够,不过为了简化讨论,此处我们假设对手总是有足够的豆子。</p> <p>这个规则很容易理解,而且看起来似乎也很公平。但在玩了很多场之后,我发现这其实是一个对水平普通且本钱较少的玩家非常不友好的规则。</p> <p>假设你是一位普通水平的玩家,即你的水平处在中游,每局有 50% 的概率赢,也有 50% 的概率输,并且初期你只有 1000 欢乐豆,你会面临什么情况呢?</p> <p>当你的豆子数量在 5 万以下时,无论你之前赢了多少次,只要输一次,你的豆子就被清空了。也就是说,想要让自己的豆子超过 5 万,你需要连赢 \(6\) 次,才能让自己的豆子从 1000 增长到超过 5 万的数量(\(1000 \times 2^6 = 64000\))。但是,由于你的赢率只有 50%,因此连赢 6 次的概率也只有 \(\frac{1}{2^6}\),即 \(\frac{1}{64}\) 或者 1.56% 。</p> <p>但即使你有了 6.4 万豆子,你仍然不安全,只要连输两次(发生的概率为 \(\frac{1}{4}\) 或 25%,这并不是一个小概率),你就又会回到一无所有的状态。要让自己更安全一些,你可能需要至少 20 万豆子,这样只有连输四次(发生的概率为 \(\frac{1}{16}\) 或 6.25%),你才会输光所有豆子。而要从 1000 豆子变成 20 万豆子,你需要先连赢 6 次,然后在接下来保住本金的情况下再净赢至少 3 次。</p> <p>粗略计算一下就可以知道,作为一名赢率只有 50% 的中等水平玩家,取得这个成就的概率不足 1%。</p> <p>另一方面,如果你仍然只是一名中等水平的玩家,但你已经有 20 万甚至更多的豆子了,会怎么样呢?</p> <p>由于你的输赢概率各一半,并且每次输赢都只是增减 5 万欢乐豆而不是一下子输光所有,因此你的账户余额从概率上来讲将会一直持平。期间可能会因为随机因素出现一些波动,甚至有非常小的概率你在某次大波动中余额被击穿从而破产,但更大的概率是你的资产经过波动之后又回归到初始值附近。</p> <p>换一句话来说,对一名中等水平玩家来说,从底层逆袭的成功概率极小,因为在初期他需要连赢很多次才行,同时只要输一次就会完全失败。但同样水平的玩家,如果一开始就拥有大量本钱,他却有很大的概率能守住这些财富,因为他不怕输,即使输了也仍然有机会赢回来。也即本钱越少,越不容易出头,财富越多,则越容易守住财富。</p> <p>这大概也是阶级固化的一种解释吧。</p> 读《星震》 https://oldj.net/article/2024/03/08/notes-of-starquake https://oldj.net/article/2024/03/08/notes-of-starquake Fri, 08 Mar 2024 16:50:00 +0800 <figure> <img src="https://s.oldj.net/upload/2024/03/08/%E6%98%9F%E9%9C%87-min.jpg" width="540" alt=""/> <figcaption></figcaption> </figure> <p>科幻小说《星震》是<a href="https://oldj.net/article/2022/05/07/notes-of-dragons-egg/">《龙蛋》</a>的续集,讲的是人类造访一颗名为龙蛋(也叫蛋星)的中子星,行程结束准备离开时,蛋星发生了一场星球级别的大地震(星震),星球上的奇拉文明几乎毁灭,随后奇拉与人类相互帮助、相互拯救的故事。</p> <p>读这部小说之前,最好先读过前作《龙蛋》,否则对故事的背景可能会难以理解。在继续写这篇读后感之前,也让我们先回顾一下《龙蛋》的前文提要以及设定。</p> <p>蛋星是一颗由比太阳还要大的红巨星坍塌而成的中子星,直径只有二十公里,因此星球上的物质密度极高,引力也极为强大,根据书中的设定,蛋星表面的引力强度是地球上的 670 亿倍!</p> <p>在这种环境下,蛋星上物质的形态与地球上常见物质的形态自然大不相同,那儿的各种活动不再依赖分子级别的化学反应,而是速度更快的核子级别的物理反应,于是对那颗星球上的智慧生命奇拉而言,它们的时间比我们快 100 万倍。所以,在前作《龙蛋》中,人类刚与奇拉们相遇时,奇拉们还处在石器时代,但一星期后,奇拉们的科技就已经远超人类。</p> <p>然而,就在人类基本完成考察准备离开时,一场灾难突如其来,星震发生了,蛋星上的奇拉文明遭受了毁灭性的打击,无数奇拉死去,大量建筑和设备报废,只有少数幸运儿活了下来,但他们几乎不会操作和修复那些先进的设备。</p> <p>不幸中的万幸是当时蛋星上空还有一些正在太空中执行任务的奇拉,这些太空奇拉受过良好的教育,依然掌握着部分先进技术,它们是奇拉恢复文明的希望。但问题是,地表的降落场地都已经在星震中被毁坏了,他们无法降落,只能先设法联系上地表的奇拉,并远程指导他们修复降落场。</p> <p>一开始,一切都很顺利,但不久之后,地表上一些原本处在社会边缘的奇拉有了其他心思,他们觉得现在这种没有法律可以随心所欲的灾后世界似乎也挺好,如果让太空奇拉降落,他们是不是又要回到从前那种处处受限的状态?</p> <p>一番争斗之后,这些“坏”奇拉赢了,他们建立了新的秩序,蛋星的地表文明退回了蛮荒时代。太空奇拉无法降落,只能另想办法,而唯一可行的办法会让正准备离开的地球人陷入危机……</p> <p>最后,在太空奇拉、地表“好”奇拉以及人类的共同努力以及牺牲之下,蛋星终于重建了文明,而且在 100 万倍的时间流速下,很快就达到了比之前更高的高度。</p> <p>整个故事对人类而言都是在一天之内发生的,但对奇拉而言却已经过去了很多代人。</p> <p>比较有意思的是故事中没有一个贯穿始终的超级英雄个体,很多奇拉在为恢复文明而努力,某位奇拉在某个阶段出场很多,正当你以为 Ta 是主角时,Ta 却可能突然因为意外或战争“流逝”(故事中奇拉的死亡被称为“流逝”)了。这种安排可能会让期待超级英雄的读者读起来不那么“爽”,但掩卷细思却也觉得非常合理,因为他们面临的是星球级别的灾难,在这种灾难面前个体的力量是渺小的,只有所有人合力,一起向着一个方向前赴后继地努力才有可能取得最后的成功。</p> <p>故事中也探讨了一些科幻作品中常见的问题。比如,科技文明的诞生是必然吗?作者认为并不是,故事中作者借一位奇拉的思考表达了这样的观点:蛋星上的奇拉们虽然很早就有了智慧,进入了原始文明,但如果不是人类飞船的造访和影响,也许奇拉会继续在低水平的文明中徘徊漫长时光。而如果在星震发生的时候,他们仍然没能发展到可以进入太空,那么迎接他们的将会是一场更彻底的倒退甚至毁灭。</p> <p>另一个问题是,奇拉文明的科技水平已经远远超过了地球文明,为什么他们没有入侵或者消灭地球文明?故事中也给出了解释,因为奇拉文明是中子星文明,由普通物质组成的地球世界在他们眼中几乎是空的,太阳系的资源对他们来说也几乎毫无用途,换句话说就是他们和地球文明处在完全不同的生态位上,没有竞争关系。做一个不太恰当的类比的话,奇拉文明和地球文明就好像狮子和野草一样,两者所需的资源差异极大,又对彼此都没有威胁,因此完全可以和平共处。如果奇拉文明遇到另一个中子星文明,或许就不会如此和谐了。</p> <p>还有一个经常出现在科幻作品中的问题是,为了活下去,我们可以吃同类的尸体吗?故事中奇拉文明和人类文明有着完全不同的道德观和选择,对奇拉文明而言这根本不是一个问题,因为在他们的文化中吃死去同类的尸体是很正常的事,即使在不缺食物的情况下,同类的尸体仍然是他们喜爱的美食,甚至宴会中最好的菜可能就是一份烤奇拉。但对地球文明来说,这却是让人难以接受的事,故事中地球人类遇到这个困境时,果断拒绝了这个选项,宁可饿死。</p> <p>在本书的最后,作者还描写了奇拉文明中的一些细节,有一个细节我觉得挺有趣:奇拉文明中的建筑都是没有房顶的。</p> <p>为什么会这样?作者解释说因为蛋星的引力太大了,加上大气稀薄(蛋星上的大气由电子、铁离子或其他典型星壳核子组成),因此自然界中从来没有进化出会飞的生物,大概也没啥能跳得很高的生物,于是他们的世界基本上是二维的,几乎从来不会有麻烦来自上方,所以他们只需要建四面墙把自己围起来就安全了,不需要屋顶。</p> <p>总的来说,这是一部有趣的硬核科幻小说。</p>