<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
    <channel>
        <title>oldj's blog</title>
        <link>https://oldj.net</link>
        <description></description>
        <lastBuildDate>Wed, 25 Mar 2026 11:20:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>zh</language>
        <copyright>All rights reserved 2026</copyright>
        <item>
            <title><![CDATA[AB 工作法]]></title>
            <link>https://oldj.net/posts/the-ab-method</link>
            <guid isPermaLink="false">https://oldj.net/posts/the-ab-method</guid>
            <pubDate>Wed, 25 Mar 2026 11:20:00 GMT</pubDate>
            <description><![CDATA[<h2>什么是 AB 工作法</h2>
<p>想象一下，你有两个工作台：</p>
<p><strong>A 工作台</strong>上摆着你最重要的那个大项目——可能是一个复杂的架构设计，可能是一本书的核心章节，或者是一个需要深度思考的研究课题。这个工作需要你全神贯注，不被打扰。</p>
<p><strong>B 工作台</strong>上则堆着其他各种各样的事情——紧急但不复杂的需求、日常维护工作、突发的小任务、需要回复的邮件等等。这些事情重要但不需要长时间的深度投入。</p>
<p>AB 工作法的核心思路是：将各项工作放合适的工作台上，然后你的注意力在这两个工作台之间定期切换。比如上午在 A 工作台专注做深度工作，下午切换到 B 工作台处理各种杂事，或者今天做 A，明天做 B，关键是有节奏地轮换，而不是随机跳跃。</p>
<p>这个方法特别适合那些每天要处理大量不同类型事务的人，——你需要推进重要的长期项目，同时又不能让其他事情堆积成山。如果你的工作是流水线式的，按先来后到依次处理就好，可能用不上这个方法。</p>
<h2>它要解决什么问题</h2>
<p>我们先来看一个常见的困境。</p>
<p>假设你是一位程序员，你的待处理工作列表很长，每项工作的重要性、紧急程度、耗时都不同。你决定按串行方式处理，每次挑一项最重要的处理，完成之后再挑选下一项。这个策略通常运行良好，但偶尔也会遇到麻烦：某项工作特别耗时，你在上面花了一两周，其他耗时短的小任务就都卡住了，有些原本不紧急的需求因此变得紧急起来。</p>
<p>这背后是两个相互冲突的需求：</p>
<p>一方面，<strong>我们需要专注</strong>。频繁切换任务的成本很高，因为大脑需要时间重新加载上下文，频繁切换不仅降低效率，还会消耗大量精力，导致精神疲劳。</p>
<p>另一方面，<strong>我们又不能长时间陷入一件事</strong>。如果完全沉浸在一个大项目中，其他重要但耗时短的任务会被无限期推迟，最终可能演变成紧急问题，甚至引发更大的麻烦。</p>
<p>AB 工作法试图在这两者之间找到平衡。</p>
<h2>核心机制</h2>
<p>回到“两个工作台”的比喻。AB 工作法的运作方式是：</p>
<p><strong>建立两个工作台</strong></p>
<p>你需要把所有工作分类放到两个工作台上：</p>
<ul>
<li>
<p><strong>A 工作台</strong>：存放那些需要深度工作的任务——最重要、最复杂、预计耗时较长的工作。这些任务摆在 A 工作台上，等待你在专注时段处理。</p>
</li>
<li>
<p><strong>B 工作台</strong>：存放其他所有事务——重要但不太复杂的任务、紧急但耗时短的需求、日常维护工作、突发事项等等。这些任务堆在 B 工作台上，可以在较短时间内逐个处理。</p>
</li>
</ul>
<p><strong>定期轮换</strong></p>
<p>你需要设定一个固定的轮换周期。在 A 时段，你只在 A 工作台工作，专注处理深度任务；在 B 时段，你切换到 B 工作台，灵活处理各种事务。这种有节奏的切换，既保证了主要任务的持续推进，又确保了其他事务不会被长期搁置。</p>
<p><strong>保持纪律</strong></p>
<p>关键是严格遵守“在哪个工作台就做哪个工作台的事”。在 A 工作台时，即使 B 工作台上有看起来很紧急的事，也要记下来留到 B 时段处理。在 B 工作台时，即使突然对 A 任务有了灵感，也要克制住冲动，留到 A 时段再说。</p>
<h2>具体怎么做</h2>
<h3>第一步：给两个工作台分配任务</h3>
<p>拿出你的工作列表，开始分类：</p>
<p><strong>哪些任务该放到 A 工作台？</strong></p>
<p>答案是那些“如果不专注投入就很难推进”的工作。对程序员来说，可能是复杂的架构重构；对作家来说，可能是书的核心章节；对研究者来说，可能是关键实验的设计与实施。判断标准是：这个任务需要长时间的连续思考吗？需要进入“心流”状态才能做好吗？如果答案是肯定的，就放到 A 工作台。</p>
<p><strong>哪些任务该放到 B 工作台？</strong></p>
<p>答案是其他所有工作——包括那些重要但不太复杂的任务、紧急但耗时短的需求、日常维护工作、突发事项等等。不用担心 B 工作台上堆得太满太杂，这个工作台本来就是为处理多样化任务设计的。</p>
<h3>第二步：设定轮换节奏</h3>
<p>根据你的工作性质和个人习惯，选择一个合适的轮换周期：</p>
<p><strong>每半天轮换</strong>：上午在 A 工作台，下午在 B 工作台，或者上午在 B 工作台，下午在 A 工作台。这是最常见的节奏，适合工作环境相对安静、任务复杂度高的情况。</p>
<p><strong>每天轮换</strong>：今天在 A 工作台，明天在 B 工作台。如果你的深度任务需要更长的连续时间来建立思考状态，这种方式可能更合适。</p>
<p><strong>每个时段轮换</strong>：每个工作时段（2-4 小时）切换一次工作台。适合工作环境多变、突发事件较多的情况。</p>
<p><strong>每 2 小时轮换</strong>：节奏更快，但不建议更短——太频繁的切换会让上下文切换成本抵消掉收益。</p>
<p><strong>根据效率曲线设置</strong>：选择你每天最高效的时间段使用 A 工作台，其他时段使用 B 工作台。比如，如果你在上午 10 点到下午 3 点精力最充沛、思维最清晰，就把这个时段固定为 A 时段，其余时间处理 B 任务。这种方式能最大化利用你的黄金工作时段。</p>
<p><strong>关于时间分配比例</strong></p>
<p>A、B 的时间分配不一定是 1:1。如果你的日常事务相对较少，而深度任务需要大量投入，时间分配可以是 2:1、3:1，甚至可以一周的五个工作日，四天都在 A 工作台，剩下一天在 B 工作台。关键是根据实际工作量来设定合理的比例。</p>
<p>但有一点很重要：<strong>一旦定好节奏，就不要轻易改动</strong>。频繁调整计划本身就是一种低效的行为，会削弱这个方法的效果。</p>
<h3>第三步：严格遵守工作台纪律</h3>
<p>这是 AB 工作法成败的关键。</p>
<p><strong>在 A 工作台时</strong>：坚决拒绝处理 B 工作台上的事项，即使它们看起来很紧急。你可以把它们记在便签上，但要留到 B 时段再处理。这不是不负责任，而是在保护你的深度工作状态。</p>
<p><strong>在 B 工作台时</strong>：不要被 A 工作台上的任务吸引回去，即使你突然有了灵感。把灵感记下来，留到 A 时段再展开。这同样是在保护你的工作模式——B 时段的价值在于灵活机动，如果被大任务拖住，就失去了意义。</p>
<p>这种纪律看似僵化，实际上是在保护两种工作模式的完整性。你可以把自己想象成两个不同的人：A 工作台上的你是专注的深度思考者，B 工作台上的你是灵活的问题解决者。</p>
<p><strong>真正的紧急情况例外</strong></p>
<p>当然，如果遇到真正重要且紧急的突发事件——比如生产系统崩溃、客户的关键问题、或者团队成员急需你的帮助——无论你当前在哪个工作台，都可以立刻停下来去处理。</p>
<p>但需要记住：<strong>这种打断应该是罕见的例外，而不是常态</strong>。如果你发现自己经常因为“紧急情况”打断工作，可能需要重新审视：这些事情真的都是既重要又紧急吗？还是因为缺乏边界，让所有事情都变成了紧急？</p>
<p>一个简单的判断标准是：如果这件事推迟 2 小时（或推迟到下一个工作台时段）会造成严重后果，那就是真正的紧急情况。如果只是“看起来紧急”但推迟几小时也无妨，那就记下来，留到合适的时段处理。</p>
<h3>第四步：动态调整两个工作台</h3>
<p>AB 工作法不是一成不变的，需要根据实际情况动态调整：</p>
<p><strong>当 A 工作台清空时</strong>：可以从 B 工作台选出最重要、最复杂的任务，把它移到 A 工作台。这样确保你始终有深度任务在推进。</p>
<p><strong>当 B 任务需要升级时</strong>：在执行过程中，如果发现某个 B 任务的重要性和复杂度超出了最初的预期，可以把它从 B 工作台移到 A 工作台，作为深度任务来处理。</p>
<p><strong>当 A 任务需要降级时</strong>：有时候你可能会发现某个 A 任务其实没那么复杂，或者可以拆分成小块处理，这时也可以把它或者它拆分后的一部分移到 B 工作台。</p>
<p>关键是保持两个工作台的动态平衡，确保 A 工作台上始终有需要深度工作的任务，B 工作台上有足够的机动任务。</p>
<h3>特殊情况：B 工作台清空了怎么办？</h3>
<p>这是一个有意思的问题。如果到了 B 时段，却发现 B 工作台上的事情都处理完了，你有两个合理的选择：</p>
<p><strong>选择一：转到 A 工作台</strong></p>
<p>既然没有其他事务需要处理，将这段时间用于推进 A 工作台上的任务是很自然的想法，这样可以加快深度任务的进度。</p>
<p>不过需要注意的是，如果你经常这样做，要警惕 AB 工作法是否正在退化成单线程工作模式。偶尔为之没问题，但如果成为常态，可能需要重新审视你的时间分配比例。</p>
<p><strong>选择二：做“元工作”或休息</strong></p>
<p>“元工作”是指那些不属于具体任务，但对工作系统本身有益的活动：整理工作笔记、回顾近期进展、学习新技能、优化工作流程、清理工作环境、或者进行一些前瞻性思考。这些活动往往在忙碌时被忽视，但长期来看对提升工作质量和效率很有帮助。</p>
<p>如果找不到合适的“元工作”，那就干脆休息一下，——散散步、冥想、或者做一些轻松的事情，为下一个 A 时段储备精力。</p>
<p>最重要的是，不要因为 B 工作台暂时清空就感到焦虑或内疚。这恰恰说明你的工作节奏是健康的，——你既在推进重要的长期项目，又及时处理了其他事务。这正是 AB 工作法追求的理想状态。</p>
<h2>为什么 AB 工作法有效</h2>
<p>AB 工作法的有效性来自于它对人类认知特性的尊重和对工作现实的妥协：</p>
<p><strong>它承认专注的价值</strong></p>
<p>通过为深度任务分配专门的时间块，我们能够进入心流状态。在 A 工作台上，你不需要担心其他事情，可以全身心投入到复杂的思考中。这种不被打扰的专注，是完成复杂工作的必要条件。</p>
<p><strong>它承认灵活的必要</strong></p>
<p>通过 B 工作台的存在，我们不会因为过度专注而忽视其他重要事项。那些看似琐碎但不可或缺的工作——回复邮件、处理临时需求、日常维护——也能得到及时处理，不会堆积成山。</p>
<p><strong>它提供了心理缓冲</strong></p>
<p>知道“我只需要在这个时段专注于 A 工作台，其他事情稍后会有专门时间处理”，这种确定性能够显著降低焦虑。你不会因为“还有很多其他事没做”而分心，也不会因为“一直在做杂事”而焦虑。</p>
<p><strong>它创造了自然的休息点</strong></p>
<p>工作台切换本身就是一种认知上的休息。从深度思考切换到处理多个小任务，或者反过来，都能让大脑的不同区域得到交替使用和恢复。这比连续 8 小时做同类工作要健康得多。</p>
<h2>适合谁用</h2>
<p>AB 工作法特别适合以下人群：</p>
<ul>
<li>
<p>需要同时推进长期项目和处理日常事务的知识工作者</p>
</li>
<li>
<p>工作内容多样、优先级复杂的管理者、创业者或独立开发者</p>
</li>
<li>
<p>需要在创造性工作和事务性工作之间切换的专业人士</p>
</li>
<li>
<p>容易陷入“只做紧急事，忽视重要事”或“只做重要事，忽视紧急事”两个极端的人</p>
</li>
</ul>
<h2>局限性</h2>
<p>当然，AB 工作法也有它的局限性：</p>
<p>如果你的工作本质上是单线程的（比如流水线工人、客服人员），这个方法可能过于复杂。如果你的工作环境完全不可控、频繁被打断，可能需要先改善工作环境，再尝试这个方法。如果你正处于某个项目的冲刺期，需要全力以赴，暂时放弃 AB 结构、全力投入也是合理的选择。</p>
<h2>一些实践建议</h2>
<p><strong>从简单的节奏开始</strong></p>
<p>如果你是第一次尝试 AB 工作法，建议从每天轮换开始，而不是每小时。今天在 A 工作台，明天在 B 工作台。这样更容易建立习惯，也更容易感受到效果。等习惯养成后，再尝试更短的轮换周期。</p>
<p><strong>用工具标记当前工作台</strong></p>
<p>可以使用日历、番茄钟或专门的时间管理工具来标记你当前在哪个工作台。视觉提示能够帮助你更好地保持纪律。有些人会在桌面上放两个不同颜色的便签纸，A 时段翻开绿色，B 时段翻开黄色，这种物理提示也很有效。</p>
<p><strong>每周回顾与调整</strong></p>
<p>每周花 15 分钟回顾一下：A 工作台上的任务推进得如何？B 工作台是否有积压？两个工作台的任务划分是否合理？轮换节奏是否合适？根据实际情况调整策略。这种定期回顾能帮你不断优化自己的工作节奏。</p>
<p><strong>让同事知道你的节奏</strong></p>
<p>如果你在团队中工作，让同事知道你的工作方式。比如在日历上标注“深度工作时段（A 工作台）”，让他们知道这段时间最好不要打扰你。大多数同事会理解并尊重这种安排，因为他们自己可能也有类似的需求。</p>
<p><strong>保持弹性</strong></p>
<p>AB 工作法是一个框架，不是枷锁。真正的紧急情况出现时，当然可以打破规则。关键是这种打破应该是例外，而不是常态。如果你发现自己经常打破规则，可能需要重新审视你的时间分配或任务分类。</p>
<h2>扩展：可以有更多工作台吗？</h2>
<p>AB 工作法的核心是“两个工作台”，但这不是唯一的可能。如果你的工作情况更复杂，完全可以扩展为三个、四个甚至更多工作台。</p>
<p><strong>什么时候需要更多工作台？</strong></p>
<p>如果你的工作内容跨越多个几乎不相关的领域，每个领域都有自己的深度任务和日常事务，那么多个工作台可能更合适。</p>
<p>比如，假设你同时负责产品开发和市场推广两个领域，你可以采用 ABC 工作法：</p>
<ul>
<li>
<p><strong>A 工作台</strong>：产品开发方面的深度工作——架构设计、核心功能开发、技术难题攻关</p>
</li>
<li>
<p><strong>B 工作台</strong>：市场推广方面的深度工作——营销策略规划、重要内容创作、关键活动策划</p>
</li>
<li>
<p><strong>C 工作台</strong>：所有其他日常事务——邮件回复、会议、临时需求、日常维护</p>
</li>
</ul>
<p>然后制定一个轮换策略，比如：周一周二在 A 工作台，周三周四在 B 工作台，周五在 C 工作台。或者每天上午在 A 工作台，下午在 B 工作台，晚上处理 C 工作台的事情。</p>
<p><strong>需要注意的是</strong></p>
<p>工作台越多，轮换的复杂度就越高，上下文切换的成本也会增加。大多数情况下，两个工作台就足够了。只有当你确实需要在多个完全不同的领域之间切换，并且每个领域都有足够的工作量时，才考虑增加工作台数量。</p>
<p>一个简单的判断标准：如果你发现自己在某个“深度工作台”上经常没事可做，或者某两个工作台上的任务其实可以合并处理，那就说明工作台分得太细了，不如简化回两个工作台的结构。</p>
<h2>写在最后</h2>
<p>AB 工作法的本质，是在专注与灵活、深度与广度、长期与短期之间寻找平衡。它不是什么神奇的生产力秘诀，而是一种务实的工作组织方式。</p>
<p>想象你面前真的有两个工作台。A 工作台干净整洁，只摆着少量需要全神贯注的大项目；B 工作台上堆着各种各样的事情，等待你灵活处理。你在这两个工作台之间有节奏地切换，既不会因为过度专注而忽视其他事务，也不会因为琐事缠身而无法推进重要项目。</p>
<p>在这个信息过载、任务繁杂的时代，我们既需要能够深入思考的专注时光，也需要能够快速响应的灵活机制。AB 工作法提供了一个简单但有效的框架，让这两种看似矛盾的需求能够在同一个人的工作节奏中和谐共存。</p>
<p>如果你也面临着“重要的大项目总是被琐事打断”或“专注于大项目时其他事情都荒废了”的困扰，不妨试试 AB 工作法。给自己两周时间实验，你可能会发现一种全新的工作节奏。</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[使用 uv 管理 Python 依赖]]></title>
            <link>https://oldj.net/posts/python-with-uv</link>
            <guid isPermaLink="false">https://oldj.net/posts/python-with-uv</guid>
            <pubDate>Sat, 29 Nov 2025 07:26:00 GMT</pubDate>
            <description><![CDATA[<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>]]></description>
        </item>
        <item>
            <title><![CDATA[富文本框架体验]]></title>
            <link>https://oldj.net/posts/rich-text-editor-experience</link>
            <guid isPermaLink="false">https://oldj.net/posts/rich-text-editor-experience</guid>
            <pubDate>Sat, 21 Jun 2025 09:31:00 GMT</pubDate>
            <description><![CDATA[<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>]]></description>
        </item>
        <item>
            <title><![CDATA[使用函数计算运行定时任务]]></title>
            <link>https://oldj.net/posts/cron-job-with-fc</link>
            <guid isPermaLink="false">https://oldj.net/posts/cron-job-with-fc</guid>
            <pubDate>Fri, 02 May 2025 07:42:00 GMT</pubDate>
            <description><![CDATA[<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>]]></description>
        </item>
        <item>
            <title><![CDATA[使用 Dokploy 部署网站服务]]></title>
            <link>https://oldj.net/posts/deploy-website-with-dokploy</link>
            <guid isPermaLink="false">https://oldj.net/posts/deploy-website-with-dokploy</guid>
            <pubDate>Sun, 20 Apr 2025 13:41:00 GMT</pubDate>
            <description><![CDATA[<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>]]></description>
        </item>
        <item>
            <title><![CDATA[Electron 中的 Kiosk 窗口]]></title>
            <link>https://oldj.net/posts/electron-kiosk-mode</link>
            <guid isPermaLink="false">https://oldj.net/posts/electron-kiosk-mode</guid>
            <pubDate>Thu, 28 Nov 2024 13:38:00 GMT</pubDate>
            <description><![CDATA[<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>
]]></description>
        </item>
        <item>
            <title><![CDATA[使用 acme.sh 申请 SSL 证书]]></title>
            <link>https://oldj.net/posts/ssl-cert-with-acme-sh</link>
            <guid isPermaLink="false">https://oldj.net/posts/ssl-cert-with-acme-sh</guid>
            <pubDate>Mon, 23 Sep 2024 02:57:00 GMT</pubDate>
            <description><![CDATA[<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=my@example.com
</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>
]]></description>
        </item>
        <item>
            <title><![CDATA[处理苹果平台的 CONSUMPTION_REQUEST 消息]]></title>
            <link>https://oldj.net/posts/consumption-request-apple</link>
            <guid isPermaLink="false">https://oldj.net/posts/consumption-request-apple</guid>
            <pubDate>Fri, 09 Aug 2024 08:31:00 GMT</pubDate>
            <description><![CDATA[<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>
]]></description>
        </item>
        <item>
            <title><![CDATA[在 Flutter 中适配 1Password 登录]]></title>
            <link>https://oldj.net/posts/flutter-and-1password</link>
            <guid isPermaLink="false">https://oldj.net/posts/flutter-and-1password</guid>
            <pubDate>Thu, 27 Jun 2024 06:34:00 GMT</pubDate>
            <description><![CDATA[<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>
]]></description>
        </item>
        <item>
            <title><![CDATA[从打牌想到的]]></title>
            <link>https://oldj.net/posts/lessons-from-poker</link>
            <guid isPermaLink="false">https://oldj.net/posts/lessons-from-poker</guid>
            <pubDate>Sun, 21 Apr 2024 06:17:00 GMT</pubDate>
            <description><![CDATA[<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>
]]></description>
        </item>
    </channel>
</rss>