<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="/feeds/atom-style.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://chenhe.me/zh/</id>
    <title>晨鹤部落格</title>
    <updated>2026-01-31T11:48:28.269Z</updated>
    <generator>Astro-Theme-Retypeset with Feed for Node.js</generator>
    <author>
        <name>Chenhe</name>
        <uri>https://chenhe.me/</uri>
    </author>
    <link rel="alternate" href="https://chenhe.me/zh/"/>
    <link rel="self" href="https://chenhe.me/zh/atom.xml"/>
    <subtitle>Hi, 这里是晨鹤（aka. 梁宸赫）的个人博客。一个赶上了 90 后末班车的跨时代 ISFP 努力守护的一个一亩三分地。</subtitle>
    <rights>Copyright © 2026 Chenhe</rights>
    <entry>
        <title type="html"><![CDATA[埃及: 矛盾的割裂国度]]></title>
        <id>https://chenhe.me/zh/posts/egypt-contradictions-and-divides/</id>
        <link href="https://chenhe.me/zh/posts/egypt-contradictions-and-divides/"/>
        <updated>2025-05-07T22:01:48.000Z</updated>
        <summary type="html"><![CDATA[说到埃及，你的第一想法是什么？神秘的法老还是清澈的红海？低廉的物价还是落后的基建？事实上这是个充满了矛盾的割裂国度，即使在短短的 5 天自驾...]]></summary>
        <content type="html"><![CDATA[<h2>前言</h2>
<p>说到埃及，你的第一想法是什么？神秘的法老还是清澈的红海？低廉的物价还是落后的基建？事实上这是个充满了矛盾的割裂国度，即使在短短的 5 天自驾行程中，我也清晰看到了贫富差距的尖锐，进而导致旅游乱象的丛生；同时也看到了埃及政府为了稳住形象所做的努力，但在贫穷的大背景下各阶级博弈后的妥协。除了内部矛盾，作为第一个前往的一个小费国家，我也为部分国人既要又要，加上另一个跪舔式撒钱这两个极端，综合导致的华人游客形象的降低感到难过。</p>
<h2>开罗: 矛盾的起点</h2>
<h3>诟病已久的交通</h3>
<p>只要你哪怕做了一丁点埃及自驾攻略，就一定看到过大量的开罗劝退帖。到什么程度呢？开罗租车公司甚至不提供全险，给钱也不行（第三方还是有的）。</p>
<p>对于有了心理预期并且从爱尔兰这样一个落后渔村出发的我，从机场提车开始就被开罗立体交通深深震惊到了。诚然他们没有北京上海动辄 10 层的叠叠乐，但作为一个相对贫穷国家，6 车道的高架像大动脉一样穿过城市的每一个热点区域，如果不需要到地面的商铺，你可以以 90 甚至 110 的时速踏遍大开罗地区的每个角落。所以这是一个基建狂魔吗？并不是。高架的两侧林立着密集的、外墙粗制滥造、颜色单调的土黄色公寓，时刻提醒你这是一个人均月收入仅 200 美元的国家。</p>
<p>如果不幸来到市区地面，你将体验到印度同款交通：地面坑洼不平尘土飞扬，行人摩托车三轮车机动车混杂在摊贩与乞丐中，面包车随走随停穿越于奔跑的孩童间，喇叭不绝于耳 - 即使所有人都知道这无济于事。在这种环境下他们习惯于地板油以不到 5cm 的距离蛇皮走位超车，没有人车合一的驾驶技巧与胆魄，我保证会被吓到动弹不得，进而被后面的喇叭催促到大脑宕机四肢僵硬。哦对了，红绿灯几乎是不存在的，超高的减速带是交叉路安全的唯一保障，然而很多司机会因为没意识到它的突然出现而被前车突如其来的减速搞的恼羞成怒，边吠着喇叭边试图在两倍于中国高度的减速带区域超车 - 然后发现自己错了。</p>
<p>等我终于回到高架时，刚才的一切仿佛是梦，只有旁边 120 时速加塞式超车的司机炫耀着他们在地面习得的武艺，才能间接证明地域难度道路的存在。</p>
<h3>金字塔: 尽力了</h3>
<p>从 2025 年 5 月开始，金字塔禁止一切社会车辆进入，曾经的乱象有了极大改善，算是埃及政府做的最突出的举措吧。自驾的同学也不用遗憾，新规下的金字塔提供随上随下的免费接驳大巴，频次非常给力，3-5 分钟一班基本不用等，里面空调也很足。新的西门提供了大面积停车场，£120 不限时间，导航到 <a href="https://maps.app.goo.gl/HjeUWkYcF467wPF69">Pyramids Visitor Centre</a> 就好。</p>
<blockquote>
<p>自驾请注意 Google map 上入口的位置偏南了，那里是工作人员的小门，接着往前开就能开到明显的大门，实在找不到就找人问一下。</p>
<p><img src="https://img.chenhe.cc/i/2025/05/07/681a47d8aa6d5.png" alt="金字塔西门正确路线" /></p>
</blockquote>
<p>禁止社会车辆的同时，里面的骑骆驼也规范了一下，最明显的是接驳车的道路以及第一站官方观景台的圈内，可视为绝对安全区，他们只敢贴着边界拉拢你，不会越过来。不过狗皮膏药一般的死缠烂打还是很烦人，无论回绝还是无视他们都会一直给你推销。此外景区还作了另一个妥协，观景台距离六塔同框的取景地还有一段沙地，算是给骆驼主留了一线生机，也称成了金子塔改革最顽固的毒瘤。东门狮子人面像附近有一个明确的牌子提醒你骑骆驼标准价格是 £500/人/小时（见下图），但不知为何新的西门没见这种提示，而选择骑骆驼最多的地方恰恰是西门进入后的第一站。被坑的金额五花八门，从 £1000 到 $50 (£2500) 都有，甚至光小费就有敢要 $40 的（详情见「小费」章节）。建议保留下图讲价并确认：</p>
<ul>
<li>£500 是往返费用，并且都是骑骆驼（除非你自愿换成马车）。</li>
<li>原则上是 1 小时，虽然路程有限最多 30 分钟，但他不可以拍照时强行催你或加价。</li>
</ul>
<p>注意，官方价格是底线，无论他说什么，务必坚守底线。景区到处都是警察而且实测他们非常怕（有许可证才能在这拉活的），有任何矛盾或遇到强行索要小费的，可以口头警告会报警，无效的话直接叫警察或打电话给旅游警察 126.</p>
<p><img src="https://img.chenhe.cc/i/2025/05/07/681a4c7f0e7ff.png" alt="骑骆驼标准价格" /></p>
<p>按照 £500 的价格建议玩一玩的，一来感受一下，二来那个拍照点的确不错，自己走过去累不说（沙地可不是平路那么好走！）而且搞的一鞋沙也很难受。灰蒙蒙的是那边的日常，遇到晴天就偷着乐吧。</p>
<p><img src="https://img.chenhe.cc/i/2025/05/07/681a4f7e27dbe.jpg" alt="六塔同框，有刺客信条那味了" /></p>
<p>排除掉骆驼管理，尤其是价格管理仍需加强外，我对金字塔最大的不满在于高昂的门票以及停车费（一般撑死 £50），尤其是外国人需要支付高达 11 倍于埃及公民的费用，这还不算进入墓室的额外收费，即使考虑到改革后的运营成本，综合当地消费水平以及景区规模，个人感觉也比较离谱。当然这不是金字塔个例，全埃及所有景区都是双轨制票价，但停车费没有这么过分的。</p>
<h3>大埃及博物馆: 村中城</h3>
<p>大埃及博物馆就在金字塔不远处，虽然文物还没完全迁移过来（据说 2025 年 7 月就全部弄来了）但已经足够震撼。哪怕是走马观花也得 2 小时才能逛完，深入了解怕是一整天都不够用，当然代价就是 £1270 的门票。来到这里，一般都已经逛完了金字塔，对开罗市区也有了宏观的了解。此时对这个城的印象大概已经与脏乱差绑定，甚至还不如我们的新农村。大埃及博物馆就要刷新这个认知了，场馆本身就个设计精美气势恢宏的艺术品，空调比肩新加坡，真实的古迹大部分都直接陈列着没有玻璃隔离，灯效与介绍都非常用心。左侧为各类展厅，右侧是餐厅与商店，整个博物馆是个大型综合体。</p>
<p>如果说之前四通八达的高架穿越拥挤的楼群与下面混乱的小径，第一次给了我这个国家基建上的割裂感，这个博物馆则是让我感受到了埃及的里世界（或者说这才是表世界？一个他们希望展示给游客的世界）。尤其当从博物馆内部眺望金字塔，现代与历史交织，颇有刺客信条梦醒回现代再穿越回托勒密王朝时的恍惚。</p>
<p><img src="https://img.chenhe.cc/i/2025/05/07/681a8426021f0.jpg" alt="博物馆大厅" /></p>
<p><img src="https://img.chenhe.cc/i/2025/05/07/681a882427bb7.jpg" alt="从博物馆眺望金字塔" /></p>
<h2>卢克索阿斯旺: 全员恶人？</h2>
<p>虽然这个结论过于粗暴，但“基本上”描述出了这些小城的整体风貌。发展中国家我去了好几个，至少在远离景区的地方都可以找到正常的物价以及热情的居民。埃及除外。也许因为他们城太小以至于没有“景点之外的地方”，但我更愿意将其归因为本国经济的不稳定（埃及镑多次跳水）以及高消费游客过多从而激发出的人性的贪婪。无论是郊区纯阿拉伯文的餐厅，还是不知名镇子的西瓜摊，对外国人的报价一律两倍起，不到 10 倍都算是铆足了良心。甚至把游客当瞎子，正大光明地忽略明码标价的招牌，问就是打哈哈妄图萌混过关，细究则以物价上涨太快菜单没更新为由搪塞，象征性的给你抹零还表现得一副好兄弟的样子。</p>
<p>与之形成鲜明对比的是警察，无论是高速或城市的交警，还是巡警武警，都十分友好。不小心逆行时只是口头提醒，还指出正确的方向以及预计到达时间。武警执勤 3 人一组，我们实在找不到人前去问路时，外边站岗的士兵听不懂英文就请我们进入了警戒区叫来他们队长，特地下车跟我们走到路边以便指的更清楚。这在小费泛滥的发展中国家，甚至许多自诩文明的发达国家都很罕见。除此之外部分商贩，例如阿斯旺核心区域的一家<a href="https://maps.app.goo.gl/jjeCb14NMWH39RXs9">卷饼店 (Crepes Restaurant) </a>价格十分公道，也没有阴阳菜单，亲眼看到阿拉伯穿搭的人和我们一起点餐。可惜他家没有座位，好在类似肉夹馍拿着吃也不麻烦。卢克索郊区的一个水果摊多次给我们指路决口不提小费，我们拒绝买些水果后依然礼貌道别。还有一些随机的路人喜欢操着蹩脚的「你好」给我们打招呼，其实没有恶意也不是推销，简短寒暄后不忘欢迎我们来到这个国家，让做好了回击准备的我们猝不及防。</p>
<p>所以全员恶人吗？倒也不见得。尽管这里相对更难遇到本分的商家，但我们不能忽略那些依然坚守营商之道的摊贩与热情正义的警察，尤其是后者，如果让我说出埃及比摩洛哥更好的地方，他们必然是最闪耀的一颗。</p>
<p><img src="https://img.chenhe.cc/i/2025/05/08/681bc858984f1.jpg" alt="卢克索帝王谷远景" /></p>
<h2>赫尔格达: 割裂的顶峰</h2>
<p>大埃及博物馆已经向我们充分展示了混乱的开罗城的另一面，但至少，这一面对于本地人，甚至是低收入人群也并不是遥不可及，只需 £100-200 也可参观。而赫尔格达则残酷许多。作为一个小城，显然收入水平远不如首都，红海沙滩几乎被被私人度假酒店占据。一道道大门彻底切分了这个本来就不大的城市，就像那个黑暗时期的中国被一个个租界搞得千疮百孔。里面是悠游自在、歌舞升平的度假区，外面是尘土弥漫噪音回荡的街道。不管是餐厅、便利店，还是出租车、码头，除了个别大型连锁超市，几乎所有商家都虎视眈眈地盯着每一个游客，生怕一块肥肉被别人叼了去。以为他们收入很低吗？但这里干脆放弃了埃及镑，直接以美元定价，完全与国际接轨，就是不知道这些财富在层层分配后，到底有多少能到居民的手中。</p>
<p>这里，游客与商家的矛盾也到达了顶峰。金字塔的坑顶多是破费些钱财，而这里不但要付出美元的报名费，还有可能浪费一整天宝贵的度假时光。沙漠豪华团，越野车排成线，军训般兜一圈；骑骆驼 2 分钟，专业网红照流水线；特地为游客表演的所谓沙漠营地变成了 £480 一杯果汁的现金黑洞。更可怕的是 7 小时的行程大部分时间都在烈日下排队等待，想中途退出连个交通都没有，必须白白耗完一天。哦对了，埃及的沙漠与摩洛哥的撒哈拉不一样，这里没有沙丘，基本是平整的地面。沙子也没那么细腻与纯粹，更像是戈壁，真的不建议去。</p>
<p>接着是红海，服务项目大差不差倒也没有太多槽点，以为终于能好好玩耍了？不，这里是中国游客的正面战场。在小红书的推波助澜下，所有的美与恶都被摆在聚光灯下无限放大。一个商家被推荐，中国人蜂拥而至以至于踏入店门一秒回国；一个店子被避雷，一堆人口诛笔伐让老外切实感受到一生无法逃避的三件事：出生、死亡和辱华。首先来说一下被吐槽最多的“不公平待遇”，也就是潜水时中国人永远被排在最后一个。事实是其他游客支付的是 $55，而我们把价格砍到了 $25，相比之下排到最后一个我觉得也没什么，被排倒数第二个老外可是一分钱没少花。酒店从我的体验来看也没感觉到歧视，服务生对所有人都差不多，无非就是可能更喜欢和老外开玩笑，考虑到中国人给人的一贯印象就是不爱交流以及英文欠缺，也上升不到辱华的高度。</p>
<p>每当此时我都很好奇这究竟是我们太自信了，还是太不自信了？我想是后者。一个自信的民族，不应该每天想着别人的某个动作是不是又歧视我了，也不应过个洋节欢乐一下就成了文化入侵，老外过个春节又马上大肆宣传。咳咳扯远了。总之希望同胞们不卑不亢，不要既要又要。</p>
<p><img src="https://img.chenhe.cc/i/2025/05/07/681a8e25895be.jpg" alt="红海与围墙般的度假村" /></p>
<h2>购票与小费</h2>
<p>如无实体票需求，埃及所有景区均可在文旅部官方网站 https://egymonuments.com 在线购票，电子票实时发到邮箱无需取票。整体体验还算流畅，注意下这个网站上标的营业时间不太准。</p>
<p>小费问题算是突出矛盾了。首先我想明确两个原则</p>
<ul>
<li>所有强行索要小费都是违法的，完全可以坚决不给。</li>
<li>当地确有小费文化，在合适的场合，建议给一点。</li>
</ul>
<p>包括但不限于景区、加油站、餐厅、酒店等任何地方，关于强行索要，或者对给的小费不满意的情况，如果商贩有任何阻止游客离开的行为，务必不要露怯或心软，给小费是我们针对服务质量的自愿行为，立即严声警告再这样就报警。埃及所有景区都有大量警察，并且实测他们对游客（包括中国游客）很热情，小贩也非常怕他们。看不到人可以拨打 126 旅游警察报警（不是恶性事件注意不要拨打 122 普通匪警）。警告后一般都不会纠缠，因为他们都是持证上岗，闹大了会被吊销。</p>
<p>关于第二点，虽然原则上是自愿的，但我觉得还是尊重一下当地的习惯，况且一线工人生活确实艰辛。只要不是恶意索要，给一点也无妨。我的建议是：<strong>对于底层打工人可以适当给一点</strong>。对我们来说甚至钱包里嫌弃累赘的零钱就能帮他们加一顿餐。本地人也一样会给的。一个典型例子是加油时可能会有人帮忙擦玻璃，不用太严厉拒绝，我第一次不了解风情表现出一些攻击性，他都有些愣了擦完了也没给小费，挺后悔的。现在已经回到家了，还不时浮现出他那迷茫甚至有一点惊恐不知所措的眼神，想想也是某个家庭的老父亲，真想找到他弥补一下。这种情况一般给 £10-20 就好了，2 块钱人民币的事情，或者加完油付款的时候向上取整到十位也可以。道路尘土飞扬，擦完确实清爽多了。另一个推荐给的场景是客房保洁，大到星级酒店小到民宿客栈，根据消费不同一天给 £10-50 都可以，见不到人可以留个纸条放桌子上，拿到小费做事也会更尽心，甚至给你整出一些花活，其实是个两全其美的事情。</p>
<p>作为中国人，我当然知道我们没有这种文化，并且认为「我已经付了钱，为什么还要为应得的服务付更多的钱」。但从小费国家文化的角度，你付的钱更多是被资本家拿走了，他们给员工设定工资的时候已经假定他们可以拿到小费。所以事实上小费是工资的一部分，也应该纳入为我们消费预算的一部分。另外我也看到一些说法，说当地给小费的是精英阶层，而我们在中国算不上这个，或者他们的小费收入可能比我们还多之类的。我觉得这里主要得分场景看待，比如餐饮或景区的马车，我就坚决不给。因为已经搞了阴阳菜单，价格远超实际消费水平，而且他们属于自雇性质，应该可以得到全部收入。而像上文提到的加油站或保洁，我倾向于多少给一点。我们虽远不及精英阶级，但因汇率问题在埃及的正常物价下也算是高收入了。</p>
<p>当然，也别走向另一个极端给的太多，尤其是在获得服务之前，那不叫小费，叫行贿。看到有人分享给保洁一天 £500 然后收获了一个精心布置的房间。当然钱怎么花是每个人的自由，但我们还是尽力维护一下市场秩序对吧。如果这方面都要卷的话，后果就是中国人会继续被打上人傻钱多的标签。而且习惯了给得多，慢慢就会变成理所应当，然后对给的少的进行报复，人性如此何必考验呢。</p>
<p>最后，<strong>对于有标记明确禁止小费的地方坚决不要给小费，不要助长歪风邪气</strong>，典型区域是机场安检以及一些卫生间。据我经历，埃及的警察还是比较正直的，所以我们自己可别先歪了。尤其是一些旅行团导游，奉劝你们不要为了自己的一时方便，让全体国民蒙羞，让埃及成为第二个泰国。</p>
<p><img src="https://img.chenhe.cc/i/2025/05/07/681a8130ebbd5.png" alt="禁止小费标识牌" /></p>
<h2>后记</h2>
<p>埃及对我来说不是个适合自驾自由行的国家，尤其是与经济地理环境类似的摩洛哥相比。主要问题在于：</p>
<ul>
<li>城市交通极度混乱，对驾驶技术与心理有较高要求。</li>
<li>核心旅游城相距过远，超过一半时间在奔波。而且一路地貌单调，与其他城市尚有距离，不利于随机探索。</li>
<li>景点过于明确，除开这些著名古迹外未有意外之喜，自驾沦为与旅游团类似的体验。</li>
<li>住宿两极化严重，相同价格下质量明显更差。度假村被垄断不思进取设施陈旧，民宿服务也难以企及。</li>
</ul>
<p>抛开金字塔神庙这些世界级遗产，埃及没有太多值得探索的东西。就像长城，是的，它很壮观很知名，但总不能一直看吧。我更偏向于把红海作为核心，将埃及视作作为潜水度假的好地方（尽管那些酒店并不尽如人意），但对于附近距离巴厘岛比较远的同胞们似乎也没有更性价比的选择。</p>
<p>我知道中国人英语口语相对较差，性格内敛，以及被坑怕了，但还是鼓励各位对于友好的打招呼，尤其是没有利益关系的路人，给予回应，这也算是国际公认的礼节。哪怕遇到拉客的，大不了再回绝就好了，事实上如果真的是赖皮，即使装聋作哑也会一直黏着你。所以大部分时候回应一下不会有什么负面后果，宁错杀一千不放过一个的思想很不利于我们的国际形象。</p>
<p>最后，二十一世纪 20 年代的互联网，信息没有变得更透明，或者说没有更可信。马蜂窝等传统平台没落，认真写攻略的大神隐于江湖。以小红书为代表的新媒体充斥着情绪化的表达、浮夸的修饰，以极不负责任的态度乘着流量的东风误导一批又一批无头苍蝇般的游客。异国他乡，中国人逐渐自成一派与其他游客实现了种族隔离。自由行变成了自助团，而且是那种上百人的超大团。旅行在千篇一律的网红玩法中失去了活力，度假在吹毛求疵的避雷中成了战战兢兢如履薄冰的冒险。什么时候旅游变成了一种任务，在既定的路线中匹配着清晰的期待，一切变数都成了不稳定的因素。谨此警醒自己不要变成了旅行机器，也提醒各位我们的生活已经足够单调，多多踏上未知的旅途吧。</p>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2025-05-07T22:01:48.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[RIME / 雾凇拼音 Android 配置与同步]]></title>
        <id>https://chenhe.me/zh/posts/rime-android/</id>
        <link href="https://chenhe.me/zh/posts/rime-android/"/>
        <updated>2025-04-16T20:48:51.000Z</updated>
        <summary type="html"><![CDATA[RIME/中州韵 是一个输入法引擎，能够通过配置文件打造出自己的输入法。这里所谓的「输入法」更多指的是字符映射（比如全拼中 “ai” 可以映...]]></summary>
        <content type="html"><![CDATA[<h2>简介</h2>
<p><a href="https://rime.im">RIME/中州韵</a> 是一个输入法引擎，能够通过配置文件打造出自己的输入法。这里所谓的「输入法」更多指的是字符映射（比如全拼中 “ai” 可以映射为 ”爱“ 等许多字符）、组词造句、词频词库等等逻辑上的东西，它还需要一个面向用户的客户端才能成为我们熟知的、能看见的输入法。</p>
<p>在 Android 平台比较有名的有两个客户端：</p>
<ul>
<li>同文输入法 / Trime: https://github.com/osfans/trime</li>
<li>小企鹅输入法 / Fcitx5-android + Rime 插件: https://fcitx5-android.github.io</li>
</ul>
<blockquote>
<p><a href="https://chenhe.me/post/oh-my-rime">Rime/小狼豪/鼠须管 输入法配置记</a> 介绍了如何在 mac / PC 上安装 RIME 并初步定制属于自己的输入法。</p>
</blockquote>
<p><a href="https://dvel.me/posts/rime-ice/">雾凇拼音</a> 是一套著名的 RIME 配置，提供全拼/多方案双拼/九宫格等输入模式，并且实现了不输于甚至强于商业输入法的功能，还附带高质量词库。感谢雾凇拼音让我迁移到 RIME 3 年多丝毫不怀念商业输入法。</p>
<p>在 Google 三番五次地折腾 Android 文件模型/权限管理的背景下，常规用户基本无法成功配置好 Rime 的词库同步（<s>甚至不会配置 Rime 本身</s>），本文选用 Fcitx5-android，手把手教你怎么搞定它们并尽力解释一下背后的原理。</p>
<h2>客户端安装</h2>
<p>首先需要安装 fcitx5-android 本身（<a href="https://play.google.com/store/apps/details?id=org.fcitx.fcitx5.android">GooglePlay</a> | <a href="https://f-droid.org/packages/org.fcitx.fcitx5.android/">F-Droid</a> | <a href="https://github.com/fcitx5-android/fcitx5-android/releases">Github</a>)。</p>
<p>然后从 Github 下载安装与本体版本匹配的 RIME 插件：打开<a href="https://github.com/fcitx5-android/fcitx5-android/releases">发布页面</a>找到对应的版本，那里会有一个表格，找到 <code>plugin.rime</code> 那一行，一般来说下载 <code>arm64-v8a</code> 即可，如果无法安装则尝试 <code>armeabi-v7a</code> （<s>你确定不把这个破手机丢到有害垃圾桶吗？</s>）。</p>
<p>完成后打开 Fcitx5，点击 <code>Plugins</code> 应该可以看到已加载的 rime 插件。回到主页点击 <code>Input Methods</code>，点击右下角添加按钮，找到 Rime 并添加。</p>
<p>最后别忘了在系统中启用新的输入法（打开 App 时应该提醒你的来着）。到此为止 RIME 已经可以工作了，但由于没有任何配置文件，大概连中文都打不出来。</p>
<h2>配置雾凇拼音</h2>
<h3>准备配置文件</h3>
<p>打开<a href="https://github.com/iDvel/rime-ice/releases">发布页面</a>找到最新版，展开 <code>Assets</code>，下载 <code>full.zip</code> 解压，将其传到手机指定位置。如果有其他已经配置好 RIME 的设备，建议优先重用这些文件以保留个性化配置。但注意<strong>不要</strong>复制这些（如果有）：</p>
<ul>
<li><code>installation.yaml</code>: 每个设备独特的安装配置。</li>
<li><code>user.yaml</code>: 一些元数据。</li>
<li><code>build</code>: 客户端部署时会重新构建的。</li>
<li><code>plum</code>: RIME 的方案管理器，在 Android 上没啥用。</li>
</ul>
<p>具体来说，有两种传输方案：电脑辅助 / 纯手机操作。</p>
<p><strong>【电脑辅助】</strong></p>
<p>把手机用数据线连接电脑（mac 默认不支持）并允许访问（无需开发者模式 / adb）。打开电脑的文件资源管理器，进入手机，把准备好的文件一股脑复制到 <code>Internal storage\Android\data\org.fcitx.fcitx5.android\files\data\rime</code> 就行了。再次提醒不要覆盖已有的 <code>installation.yaml</code> 文件。完成后文件大概如图所示（随着版本更新有个别文件不一样没关系）：</p>
<p><img src="https://img.chenhe.cc/i/2025/04/16/67fef407c5ece.webp" alt="已复制的雾凇拼音配置文件" /></p>
<p><strong>【纯手机操作】</strong></p>
<blockquote>
<p><strong>科普时间</strong></p>
<p>现代 Android 已不允许任何应用（包括系统的文件管理器）以任何方式直接修改数据文件，即任何 <code>Android/data</code> 下的文件，也无法手动授权。如果一个应用的确希望暴露它的私有文件，则需要通过一个叫 "ContentProvider" 内容提供者的机制。它更像是沙盒，通过一种逻辑目录（而不是物理目录）来允许外界访问内部资源，形如 <code>content://org.fcitx.fcitx5.android/xxxx</code>。</p>
<p>对于其他位置，应用默认无权读写。但可通过 ① 请求「访问所有文件」这一特殊权限；或 ② 使用 FAS 请求用户授权（即打开一个对话框要求你选择允许访问的目录）来获得读写权限。</p>
<p>还有几个默认就是公开读写的特殊位置，比如下载目录(<code>Download</code>) 或文档目录 (<code>Documents</code>)，任何应用无需显式授权就可访问。但只能编辑自己创建的文件，不过这一限制可通过上述两个途径解除。</p>
</blockquote>
<p>如果你手机的文件管理器带有读取 ContentProvider 的功能，那么恭喜，直接把配置文件拷贝到 <code>Fcitx5/data/rime</code> 就行了。</p>
<blockquote>
<p><strong>怎么判断是否支持 ContentProvider?</strong></p>
<p>到处找一找，着重关注顶层的位置选项或文件分类，看是否有带有 Fcitx5 图标且名字就是 Fcitx5（小企鹅输入法）的入口。实在找不到也没关系，下面的变通方案也不是很麻烦。</p>
</blockquote>
<p>如果默认的文件管理器不支持或者找不到入口也不用担心，所有通过了兼容性认证的 Android 系统都有一个内置的隐藏的文件管理器，它的功能精简、UI 丑陋，但支持 ContentProvider！</p>
<ol>
<li>首先把准备好的配置文件放到一个易于寻找的目录（比如 <code>/Download/rime</code>），这样一会使用残废的隐藏文件管理器不至于找不到它们。</li>
<li>打开 Fcitx5，点击 <code>输入法 - Rime ⚙️ 图标 - User data dir - 确定</code>。这时候隐藏的管理器就被唤醒啦，点击左上角的菜单，进入之前准备好的目录，拷贝所有文件，粘贴到 <code>Fcitx5/data/rime</code> 里面吧。</li>
</ol>
<h3>激活配置</h3>
<ol>
<li>随便找个打字的地方唤出 Fcitx5 输入法，点击候选词栏的更多按钮（三个点）。</li>
<li>点击重载配置。</li>
<li>提示完成后点击代码图标的按钮，选择 <code>Deploy</code>。耐心等待完成，首次部署大型方案会比较慢，甚至接近 1 分钟。</li>
<li>重新点击按钮，选择你喜欢的输入方案吧。
<img src="https://img.chenhe.cc/i/2025/04/17/68000f050f6df.jpg" alt="激活配置" /></li>
</ol>
<h2>词库同步</h2>
<h3>自定义同步目录</h3>
<p>这里主要给小伙伴们解释清楚自定义目录的权限问题。至于用什么软件实现同步就看各自的选择啦，我这边用的是 Syncthing。</p>
<p>经过了上文的「科普时间」（没看过的先去看！），我们已经知道其他应用不可能修改 Fcitx5-rime 默认的配置位置下的文件，自然也去没法实现双向同步。所以需要将同步目录配置到一个双方（Fcitx5 与同步工具）都有可能读+写的位置。仅剩的选择就是 Android 预留的几个资源目录，排除音乐等明显不合适的地方， 能选的只有 <code>Download</code> 与 <code>Documents</code>。下载目录里面文件比较乱，所以我这边放到了 <code>Documents</code> 中。</p>
<p>同步目录在 Fcitx5-rime 的 <code>userdata/data/rime/installation.yaml</code> 中配置，忘记怎么打开的回去看看上一节。如果文件不存在大概是你忘记部署了(Deploy)。用一个称手的文本编辑器（手机/电脑均可），不要用 Office/WPS 等富文本软件，它们可能会破坏文件格式。</p>
<blockquote>
<p><code>yaml</code> 是一个纯文本配置文件格式，最简单的语法为 <code>name: value</code>，每行一个。如果配置项的值是文本，强烈建议用英文引号 <code>"</code> 括起来。</p>
</blockquote>
<p>对于已有的内容，最好修改 <code>installation_id</code> 的值为手机型号等，它将作为同步目录中此设备对应的目录名。不建议使用中文。</p>
<p>然后添加一行新配置来指明同步目录：<code>sync_dir: "/storage/emulated/0/Documents/RimeSync"</code>。其中 <code>/storage/emulated/0</code> 是 Android 系统内置存储惯用的路径，在绝大部分设备上都是这个。</p>
<blockquote>
<p><strong>小技巧</strong>
以点 (<code>.</code>) 开头的目录/文件将在资源管理器中默认隐藏，可防止误删。例如可以配置为 <code>/storage/emulated/0/Documents/.RimeSync</code>。</p>
</blockquote>
<p>修改后的文件应该类似（数值可能不同）：</p>
<pre><code>distribution_code_name: "fcitx-rime"
distribution_name: Rime
distribution_version: 5.1.10
install_time: "xxxxxx"
installation_id: "xxxxxxxxx"
rime_version: 1.12.0
sync_dir: "/storage/emulated/0/Documents/RimeSync"
</code></pre>
<p>按照上文「激活配置」中的步骤重载配置并重新部署，然后记得再点一下 <code>Synchronize</code>。用文件管理器打开刚刚设置的目录，如果里面出现了一堆文件就代表成功了。</p>
<h3>配置同步软件</h3>
<p>大家各自选用的同步软件都不一样，所以这里就不再手把手教啦（主要 Syncthing 是自托管的，使用门槛比较高，不推荐非专业同学使用）。既然我们已经把文件存到了公共目录，理论上同步软件可以请求「访问所有文件」或通过 FAS 让我们选择同步目录来授予读写权限。如果你的同步软件遇到了权限问题则很可能是它没有适配 Android 最新的文件模型，建议换用其他应用，比如 FolderSync。</p>
<h3>同步原理</h3>
<p>最后澄清一点，Rime 的同步是全手动的。没错，一点都不自动。具体来说，假如希望在设备 B 上获得设备 A 最新的词库，则需要：</p>
<ol>
<li>在设备 A 上点击 Rime 的同步。</li>
<li>等待文件同步到设备 B。</li>
<li>在设备 B 上点击 Rime 的同步。</li>
</ol>
<p>也许不同平台不同客户端，可以通过某些技巧配合其他工具实现定时同步，但这需要根据自身情况研究，没有通用的方案。好在雾凇拼音本身词库已经足够强大，日常积累的个性化词库想起来同步下就好，不会太影响使用。</p>
<hr />
<p>进入同步文件文件夹，应该可以看到 N 个目录，N 是你在所有平台安装的 RIME 的个数，目录名由各自 <code>installation.yaml</code> 中的 <code>installation_id</code> 决定。里面除了 <code>*.userdb.txt</code> 的词库文件外，RIME 也会把配置文件备份到这里一份，不过这是单向的，而且并不完整，建议不要依赖，配置完成后还是自己打的压缩包备份比较好。</p>
<p>当点击同步按钮时，RIME 会遍历所有的设备目录中的词库，将它们与本地词库合并，然后把合并后的输出到同步目中当前设备的文件夹。换句话说，RIME 永远之后修改属于当前设备的目录中的文件，这样可以一定程度避免冲突，尤其在各个设备不是实时同步的情况下。</p>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2025-04-16T20:48:51.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[留下，目送更多人回家]]></title>
        <id>https://chenhe.me/zh/posts/stay-in-ireland/</id>
        <link href="https://chenhe.me/zh/posts/stay-in-ireland/"/>
        <updated>2024-12-18T00:22:31.000Z</updated>
        <summary type="html"><![CDATA[两年半之前，我做了人生中最唐突，也最漫长的决定（三年考研路，我在终点前退出了）。现在来看，对大多数人而言这是错误的，而对我它是正确的。如果你...]]></summary>
        <content type="html"><![CDATA[<p>两年半之前，我做了人生中最唐突，也最漫长的决定（<a href="https://chenhe.me/post/fxxk-ch-postgraduate">三年考研路，我在终点前退出了</a>）。现在来看，对大多数人而言这是错误的，而对我它是正确的。如果你正面临这种选择，也许这篇虽然狗屁不通但至少比小红书营销号更接地气的文章可以帮助你更好地决策。</p>
<h2>国外，不是外国</h2>
<p>发现大部分国人有很严重的大国病（非贬义），总觉得世界除了中国就是外国，出国是一件多么了不得的事情。然而地球上没有一个国家叫作「外国」，其他国家之间的区别，甚至要超过与中国的差距。当你自然而然地准备从美加澳新中选择开启人生新篇章的时候，估计从来没有意识到，哪怕是这四个整天被捆绑起来提及的地方，具体生活环境的差异也足够给你比第一次踏出国门时更大的震撼。更别说因为经济与政治问题，当爱尔兰这种破岛也变成目的地之一时，会给你对所谓发达国家的期望埋下多大割裂感的隐患。</p>
<p>根据近两年我的一手数据，应届生出国的并不多。或许因为整体的经济下行，又或者的留学光环的褪去，以及疫情给大家带来的心理阴影？总之，新生的年龄竟然出现递增的趋势。2024 年秋季计算机专业新生大部分出生于 1996 年之前，这些人大概是勉强赶上了互联网浪潮的末班车在大厂得到了还说得过去的薪资。但 35 岁危机以及越来越卷的生存环境，的确让人很难看到在一线城市安家落户的希望。至于回乡？除了加入热度已远超考研的考公大军，或者接受 10 倍的薪资落差变成网管从而在婚恋市场上一败涂地，小城市没能给这个行业的人更多选择。索性国外给了——即便经历了数年的大厂裁员，计算机依然是体面地润出去的最佳专业。</p>
<p>到此我们已经有了比较清晰的画像：有一定的积蓄但到不了中产，挥洒完了最青春的 8 年不再想签什么奋斗者协议但又不愿放弃积累的技术经验从另一个行业白手起家。那么无论是生活节奏、投资成本还是政治风险，美国与澳大利亚显然不是可选项。新加坡卷破天际大家也应该有所耳闻当然也是避犹不及。选着选着，爱尔兰这个堪比古代流放之地的穷乡僻壤竟也成了热门目的地。这就引起了主要矛盾，尽管对外有着「欧洲硅谷」之类的宣传口号，以及人均 GDP 高出大气层的豪言壮语，作为发达国家的爱尔兰实际上是一个比中国的农村更农村的偏远岛国。与几乎所有的其他候选国家有着天壤之别，哪怕是有着大农村美称的欧洲大陆，与假欧盟成员国爱尔兰相比起来，城市化水平可以说遥遥领先。</p>
<p>当然，我这一通阴阳怪气不是为了抹黑爱尔兰（但也没有任何夸张），而且希望各位能对本节的小标题有切实感受并充分思考自己离开中国的理由和真正的追求。我见过太多人嘴里说着喜欢田园不要剥削，但其实我们喜欢的是有明日达快递以及削廉价外卖的田园。没错，作为有资本出国的人，保守估计已经超越了 60% 的国民。尽管名义上生活在发展中国家，殊不知那些城市早已超过了发达国家的标准。更直接地说，我们是享受人口红利的上层阶级（虽然也没那么上层）。到了国外，尤其是欧洲这种高福利地区，人人平等并不是想象中让我们变成富豪，相反是让我们降低生活质量去照顾蓝领甚至乞丐。直接从地主生活变成了被打倒的乡绅，不仅失去了佣人的照顾（廉价劳动力与商品）还要承担高昂赋税（综合税率高达50%）。</p>
<blockquote>
<p><strong>人人平等是好事，但如果我们其实是高于平均的那个呢？</strong>
中国 6 亿人月收入仅 1000 元。  ——李克强</p>
</blockquote>
<p>到这里其实有一半（现在可能只有一小半）的同学已经不用考虑出国这个事了，你们的家庭在中国或多或少有社会关系，可以轻松去个体制内养老。房车不愁，外卖自由，获得优先择偶权还没有异地恋的痛。也许现在你们还有颗想闯荡的年少之心，拿出一部分积蓄以旅行体验的心态给自己了个心愿，顺手拿个低时间成本的研究生学历也未尝不可。但终究，中国才是你的归宿。</p>
<h2>转码，落幕的时代</h2>
<p>虽然这不是篇讨论计算机的文章，但作为出国行业第一选择，当然配得上自成一段。</p>
<p>有超多人，到 2024 年了还做着转码上岸的梦。扪心自问，有哪个行业不需要本科学习，短短 1 年硕士加上几个月的培训班，坐在冬暖夏凉的办公室就月入过万？这要是能成为常态，其他专业都可以撤销了，衡水也别卷高考了，转行码农培训算了。当然也有很多人唱衰计算机，说什么 2023 秋招横尸遍野哀嚎一片。个人觉得不用那么极端，计算机不是在衰落，只是回归理性回到正常而已。过了阵痛期长远看其实是好事，真正想从事这个行业的同学不用再面临数十个专业跨考做题家的竞争，企业也不会在盲目扩张和全球裁员之间反复横跳搞得人心惶惶。</p>
<p>可惜对于转码的同学们，大门真的在缓缓关闭。没错，中介或培训班依然可以举出许多成功案例以便掏空你的钱包，不过从百分比来看转码的难度与收益堪比考公。我不否认依然存在转码大厂上岸，甚至现在的同事就是活生生的例子，但我们何必用前五年的积蓄以及未来五年的光阴去赌这样一个渺茫的机会？何况在行业冷静与沉淀下来之后，没有真正的热情与持续投入，即使转码成功也很难走得更远。站在风口上猪也能飞起来，这些无关技术与努力，千万不要用前人的成功麻痹自己，或者过分夸大主观能动性的作用。大厂工作这事，三分靠实力七分靠运气。</p>
<blockquote>
<p><strong>计算机是个围城，转码已不是尚方宝剑。</strong></p>
</blockquote>
<p>如果非要转，以我几年来帮助转码同学（甚至科班同学）的经验看，大部分人没有认识到计算机是一个极其特殊的学科，它包罗万象，没有成熟的培养体系与学习路线，也没有公认的标准来衡量水平。有些同学把不用考证视为利好跨专业的优点，然而这其实是天坑。没有考试做指挥棒意味着没有人能搞清楚该学什么、怎么学、学到哪一步。甚至，计算机就不是一个可以教和学的科目，工作内容也会与平时接触的大相径庭。在这上面取得成就很大程度取决于天赋、热爱与好奇心。直到大四毕业连 99 乘法表都不能顺利打印出来的大有人在，那些算法与八股文，背一背骗得了面试官骗不了自己，靠记忆的知识入职后工作会特别痛苦。反正大部分发达国家蓝领的薪资与社会地位不会太差，真的决心走出去的话机会还是很多，没有必要死磕计算机，至少，没有必要死磕程序员。技术支持、测试之类的相关岗位也是不错的选择。</p>
<h2>家，到底在哪</h2>
<p>来到国外不用太久，大概两三周，就会感受到「家」定义的冲突。当同学们提到「回家」到底是回宿舍？出租屋？还是中国的那个家？这当然不是国外游子们专属的烦恼，但的确更加严重。曾经我们说有妈妈的地方就是家，后来也许是有对象的地方就是家。但在我看来，能让你开心的地方也是家。中国人似乎被奋斗洗了脑，不断地给自己设定目标、互相比较，我们自古就说「先天下之忧而忧，后天下之乐而乐」又或是「居安思危」，就好像快乐是多么奢侈多么罪过的一件事。大学毕业后朋友再见面，话题总离不开买车买房升职结婚，很少有人关注我们过的快乐吗？</p>
<p>我不清楚美国之类的地方是什么场景，但至少在欧洲，躺平与快乐是大家默认的生活哲学。我也有同事们下班后研究技术并组织分享 party，但我觉得这不是卷，而是乐在其中的探索。就好像最早时期的大学，如张宇所说，计算积分是派对上的娱乐活动而已。如果你不喜欢，完全可以不参加，去寻找喜欢的东西。当回到某个地方，你有权利也有心情放下工作，放下一切劳累心烦的东西，不用纠结于明天几点起床，不用思考下一个 DDL 什么时候，可以毫无负担地浪费一整个下午发呆，用一整晚学习面食烹饪，再用整个周末做个甜点，这就称得上是家了。</p>
<p>我离开是因为不喜欢中国，不喜欢它的政治与一些文化。但后来每当回去，还是能感受到放松和亲切，因为我喜欢吗？我想不是，因为此时我清晰地知道如果它让我不开心的话我有能力随时离开，清晰地知道我不在这里工作，这里一切讨厌的东西无法纠缠着我。换句话说，此时的中国可以让我放下烦恼，那么它也是家了。这一点给了很多游子错觉，觉得中国还是比国外好。其实不然，只不过此时此刻在国外面临了一些压力，回国帮你隔离了这些。如果真的回去开始工作，自然又会怀念起留学时光。相反，如果目前在国内已经有环境良好的工作，稳定的社交关系，那么这才是你的家。</p>
<blockquote>
<p><strong>家是港湾，但我们不要自己制造波浪。</strong>
如果你不是乐于奋斗，那么休息下吧。钱没那么重要，也不会让你开心。</p>
</blockquote>
<p>这边的一些朋友，习惯性的把现在的工资与中国美国比较，渴望找出那么几个证据证明自己的选择没错。也许这个过程就错了，每个地区都有自己的习惯与追求，想要什么就去对应的地方。发自内心的开心才应该是判决决策正确性的唯一标准，而不是通过打败谁来获得。家是和谐相处的地方，不是战场。</p>
<p>最后，只是我亲身经历的，2 年中有至少 4 个中国同学因为不适应这里的环境而抑郁，有考试全挂的，也有休学退学回家的。与此同时也有人缓解了原先的抑郁。所以国外没有错，中国也没有错。我们只是需要找到自己想要的地方。为期一两个月的旅行也许是个好办法，不要做攻略也不用追景点，到一个地方，租个房子，认认真真生活一段时间，去感受作为家的可行性。相比一次投资数十万打水漂来说性价比高得多。毕竟当代国人又有多少真的有个温馨的窝呢？</p>
<p>祝大家都能找到自己的家吧。另外别忘了，马上过年走亲访友，别再攀比鞭策，交流一下过得还开心吗，别累着了，人生就那么短，非要奉献给你的房子吗。</p>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2024-12-18T00:22:31.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[MikroTik ROS 开荒:  公网访问与 ZeroTier 组网]]></title>
        <id>https://chenhe.me/zh/posts/mikrotik-ros-setup/</id>
        <link href="https://chenhe.me/zh/posts/mikrotik-ros-setup/"/>
        <updated>2024-10-09T05:58:17.000Z</updated>
        <summary type="html"><![CDATA[我的环境：硬件: Mikrotik RB5009UG+S+IN 系统: ROS 7.16 宽带: 江苏电信千兆家庭宽带，光猫桥接 阅读本文需...]]></summary>
        <content type="html"><![CDATA[<p>我的环境：</p>
<ul>
<li>硬件: Mikrotik RB5009UG+S+IN</li>
<li>系统: ROS 7.16</li>
<li>宽带: 江苏电信千兆家庭宽带，光猫桥接</li>
</ul>
<p>阅读本文需要一定的网络知识，了解 DNS, DDNS, 路由等网络概念。</p>
<h2>前言</h2>
<p>OpenWrt 是个好东西，但终究不是一个专业的路由器。从个人角度，主要问题是 ① 设置杂乱，加上翻译问题，经常不知所云。② 命令行不友好，原生 Linux 远不如 Cisco 等专业系统配置起来方便。另外就是太灵活了，灵活到总是给人不安全感。终于，决定把家里网络换成主路由 + 旁路网关的模式，让路由器回归本质。</p>
<p>诚然，与 OpenWrt 相比，RouterOS 没有那么直观，界面也非常复古，但从专业角度，无论是 WebUI 还是命令行，配置起来其实更加顺手。Quick Set 的加入使得新手（需要具备基础网络知识）也能快速完成 PPPoE 拨号配置，缺省的防火墙规则也足够安全。本文不会关注 ROS 的基本使用，假定你已经或手动或通过 Quick Set 配置，能够正常访网络。此外，本文的目的是尽可能地解释清楚每个配置背后的原理，而不是直接给出命令。所以如果你在寻找一个无脑配置清单，或许其他文章更适合你。</p>
<h2>访问光猫</h2>
<p>光猫改完桥接模式后便无法直接访问到了。解决方法非常简单，在给光猫的那个物理接口手动分配一个与光猫同网段的 ip 即可。例如我的光猫 ip 为 <code>192.168.1.1</code>，那在「IP - Address - Add New」：</p>
<ul>
<li>Address: 自定义一个同网段且不冲突的 IP，例如 <code>192.168.1.2</code>。</li>
<li>Network: 光猫所在的网段，例如 <code>192.168.1.0</code>。</li>
<li>Interface: 连接光猫的<strong>物理</strong>接口，通常应该是 <code>etherX</code>，注意不要选 <code>pppoe-out1</code>。</li>
</ul>
<p>这样应该就已经可以了。部分教程说还要配置防火墙的 NAT 或 Mangle，但我认为不需要，实测也不需要。</p>
<h2>端口映射</h2>
<h3>基本配置</h3>
<p>从外部访问的端口映射本质上是 dnat，点击「IP - Firewall - Add New」添加规则：</p>
<p><strong>General</strong></p>
<ul>
<li>Chain: <code>dstnat</code>。</li>
<li>Dst. Address: 路由器的公网 IP。（我知道这个会变，先填当前的再说）</li>
<li>Protocal: 根据需求填写，多为 <code>tcp</code>。</li>
<li>Dst. Port: 从外网访问的端口号。</li>
</ul>
<p><strong>Action</strong></p>
<ul>
<li>Action: <code>dst-nat</code></li>
<li>To Addresses: 要转发到的内网地址。</li>
<li>To Ports: 要转发到的内网端口。</li>
</ul>
<p>此时从外部应该就可以访问了，但是还有两个问题：</p>
<ul>
<li>无法从内网通过公网 ip 访问其他内网服务。</li>
<li>公网 ip 变更后无法及时更新。</li>
</ul>
<h3>回流</h3>
<p>目前内部主机无法通过公网 IP 访问映射的其他内网服务。在解决之前先来分析下原因，假设我们内网主机 A <code>192.168.50.2</code> 试图通过公网地址 <code>20.1.1.1</code>（已配置好端口转发）访问内网服务器 S <code>192.168.50.10</code>，数据流如下：</p>
<ol>
<li>A 的数据发送到公网地址 <code>20.1.1.1</code>，最终到达路由器。</li>
<li>因为已经配置了端口转发（DNAT），路由器会重写目的地址，然后把数据包发给 S。</li>
<li>S 向源地址发送响应。注意，DNAT 不会修改源地址，因此这里是 <code>192.168.50.2</code>，即主机 A 的内网地址。</li>
<li>因为源地址与 S 在同一网段，因此响应数据通过二层转发送达，那么主机 A 收到的响应的源地址是 <code>192.168.50.10</code>，即服务器 S 的内网地址，而不是一开始的公网地址。<strong>此时问题出现了，A 不会认可这个响应，无法真正建立连接。</strong></li>
</ol>
<p>所以我们要让服务器 S 响应给路由器，而不是 A 的内网地址，因此路由器不仅要重写目的地址，也需要重写源地址。具体来说需要添加一个 SNAT。</p>
<p>点击「IP - Firewall - NAT - Add New」：</p>
<p><strong>General</strong></p>
<ul>
<li>Chain: <code>srcnat</code></li>
<li>Src. Address: 路由器网段，例如 <code>192.168.50.0/24</code></li>
<li>Out. Interface List: LAN</li>
</ul>
<p><strong>Action</strong></p>
<p>Action: <code>masquerade</code></p>
<h3>IP 更新</h3>
<p>家庭宽带得到的公网 IP 都是随时变动的，需要实时将新的地址更新到 DNAT 中。为此我们得编写一些脚本。点击 「System - Scripts - Add New」：</p>
<ul>
<li>Name: 随意填写，用于引用脚本，例如 <code>dynamic_nat</code>。</li>
</ul>
<p>Source 如下，<strong>注意修改前两个变量 <code>pppoe</code> 与 <code>natComment</code></strong>，分别是 pppoe 的逻辑接口名与 DNAT 规则的注释。因为防火墙规则没有 <code>name</code> 属性，所以只能用注释来识别。</p>
<pre><code># name of pppoe interface
:local pppoe "pppoe-out1"
# comment of dnat rule
:local natComment "port_mapping"

:if ([/interface get [/interface find name=$pppoe] running]=true) do={
   :local ipaddr [/ip address get [/ip address find dynamic=yes interface=$pppoe] address]
   :set ipaddr [:pick $ipaddr 0 ([len $ipaddr] -3)]
   :local oldip [/ip firewall nat get [/ip firewall nat find comment=$natComment] dst-address]
   :if ($ipaddr != $oldip) do={
       /ip firewall nat set [/ip firewall nat find comment=$natComment] dst-address=$ipaddr
       :log info ("changed dst-address of " . $natComment . ": " . $oldip . " -&gt; " . $ipaddr)
   } else={
       :log info ($pppoe . "'s ip not changed: " . $oldip)
   }
} else={
   :log info ($pppoe . "not running, stop updating ip address")
}
</code></pre>
<p>有了脚本，接下来要设置触发方式，理想情况下我们 IP 变动后即时修改，通常唯一可能发生 IP 改变的时机就是 pppoe 重新连接。所以这里使用 profile 特性自动执行我们的更新脚本。</p>
<p>进入「PPP - Profiles - default」，如果没有就新创建一个。在 Scripts 节可以看到 <code>On Up</code> 输入框，这里面的脚本就是在连接成功时自动执行的，新增下面的指令：</p>
<pre><code>delay 3s
:execute "dynamic_nat"
</code></pre>
<p>注意 <code>:execute</code> 后面的名字要和刚才创建的脚本名匹配。然后进入「Interfaces - pppoe - Dial Out」确认 <code>Profile</code> 是刚才设置的那个。</p>
<p>配置好后可以尝试禁用再重新启用 pppoe 接口，应该可以看到类似下面的日志（<code>/log print</code>）：</p>
<pre><code>18:17:34 script,info changed dst-address of port_mapping: 49.68.119.48 -&gt; 49.68.118.71
</code></pre>
<p><strong>但为了以防万一，最好再设置一个定时任务来兜底。</strong></p>
<p>点击「System - Scheduler - Add New」：</p>
<ul>
<li>
<p>Name: 任意填写，比如 <code>update_dnat_ip</code>。</p>
</li>
<li>
<p>Start Time: startup</p>
</li>
<li>
<p>Interval: 任意设置，比如 5 分钟 00:05:00。</p>
</li>
<li>
<p>On Event: 注意更改为刚才创建的脚本名称</p>
<pre><code>:execute "dynamic_nat"
</code></pre>
</li>
</ul>
<h3>多规则更新</h3>
<p>现在还有一个问题，如果设置多个端口映射，每个规则需要分别更新 IP 非常麻烦。我们创建一个新的 NAT 规则：</p>
<p><strong>General</strong></p>
<ul>
<li>Comment: 随便填，但这个需要在上文更新 IP 脚本中的引用，也就是 <code>natComment</code> 变量的值。</li>
<li>Chain: dstnat。</li>
<li>Dst. Address: 路由器公网 IP，我们的脚本应该会更新它。</li>
</ul>
<p><strong>Action</strong></p>
<ul>
<li>Action: Jump</li>
<li>Jump Target: 新建一个，比如 <code>port-mapping</code>。</li>
</ul>
<p>然后编辑所有端口映射 DNAT 规则：</p>
<ul>
<li>Chain: 改为刚才的 Jump Target，也就是 <code>port-mapping</code>。</li>
<li>Dst. Address: 清空，不再设置</li>
</ul>
<p>其他保持不变。这样一来只需要用一个脚本更新一个 Action 为 Jump 的规则就行了。</p>
<h2>DDNS</h2>
<h3>设置脚本</h3>
<p>ROS 自带 DDNS 服务称为 Cloud，但在大陆水土不服，似乎是更新解析的 API 被墙了。要使用第三方 DDNS 服务需要自己编写脚本，其核心是通过 <code>/tool fetch</code> 命令发送 http 请求。所以理论上可以接入任何提供 HTTP API 的 DNS 厂商。很多小伙伴喜欢使用阿里云或腾讯云的 DNS 服务，但免费的套餐 TTL 较长，不适合 IP 经常变动的场景。所以这里以 dynv6.com 为例，这是一个免费的同时支持 IPv4 与 IPv6 的 DDNS 服务，并且针对 IPv6 提供前后缀组合。</p>
<blockquote>
<p><strong>TTL 科普</strong>
TTL 即 Time To Live，可以简单地理解为 DNS 解析记录可以被缓存的时间，单位固定为秒。云计算大厂免费 DNS 通常最低设置为 600，那么在最差情况下 IP 变更需要等待 10 分钟才能生效。</p>
</blockquote>
<p>点击「System - Scripts - Add New」创建新脚本，名称随意，例如 <code>ddns_update</code>，输入下面的内容，记得补全所有的参数 (Args)，若想忽略 IPv4（比如你没有公网 IPv4）请把对应的参数设置为 <code>false</code>：</p>
<pre><code>#!rsc by RouterOS
#
# Used to update dynv6 DNS records.
# Disabled IP versions will be considered as no available address.
#
# Args:
#   ddnsnic: the name of pppoe interface
#   token: dynv6 account token
#   zone: zone registered on dynv6
#   enableIPv4: enable ipv4 record update
#   enableIPv6: enable ipv6 record update

# Args:
:local ddnsnic "pppoe-out1"
:local token "your_dynv6_token_here"
:local zone "xxx.dns.navy"
:local enableIPv4 true
:local enableIPv6 true

# use to cache operation
:global ddnsCurIPv4
:global ddnsCurIPv6

:local logPrefix "ddns"
:local ipv4
:local ipv6

# acquire current IP addresses
:if ($enableIPv4) do={
    :set ipv4 [/ip address get [/ip address find dynamic=yes interface=$ddnsnic] address]
    # "49.68.118.208/32" -&gt; "49.68.118.208"
    :set ipv4 [:pick $ipv4 0 [:find $ipv4 "/"]]
}
:if ($enableIPv6) do={
    :set ipv6 [/ipv6 dhcp-client get [/ipv6/dhcp-client find interface=$ddnsnic] prefix]
    # "240e:aaaa:bbbb:cccc::/60, 2d23h5m11s" -&gt; "240e:aaaa:bbbb:cccc::/60"
    :set ipv6 [:pick $ipv6 0 [:find $ipv6 ","]];
}

:if ($ipv4=$ddnsCurIPv4 &amp;&amp; $ipv6=$ddnsCurIPv6) do={
    :log info "$logPrefix: IP address (4&amp;6) on $ddnsnic has not changed: [$ipv4] [$ipv6]"
} else={
    :if ( [:typeof $ipv4]="nothing" &amp;&amp; [:typeof $ipv6]="nothing" ) do={
        :if ( -$enableIPv4 &amp;&amp; -$enableIPv6 ) do={
            :log info "$logPrefix: both ipv4&amp;6 are disabled, clear records..."
        } else={
            :log info "$logPrefix: no available IP address on $ddnsnic, clear records..."
        }
        :local url "https://dynv6.com/api/update?zone=$zone&amp;token=$token&amp;ipv4=-&amp;ipv6prefix=-"
        /tool fetch http-method=get url=$url keep-result=no
    } else={
        :local ipv4str $ipv4
        :local ipv6str $ipv6
        :if ([:typeof $ipv4]="nothing") do={ :set ipv4str "-" }
        :if ([:typeof $ipv6]="nothing") do={ :set ipv6str "-" }

        :local url "https://dynv6.com/api/update?zone=$zone&amp;token=$token&amp;ipv4=$ipv4str&amp;ipv6prefix=$ipv6str"
        /tool fetch http-method=get url=$url keep-result=no
        :log info "$logPrefix: $zone records updated: [$ipv4str] [$ipv6str]"
    }

    :set ddnsCurIPv4 $ipv4
    :set ddnsCurIPv6 $ipv6
}
</code></pre>
<p>如果使用其他提供商，可以参考着修改脚本，应该不难理解。</p>
<blockquote>
<p><strong>脚本详解</strong></p>
<ol>
<li>从指定的接口读取 IPv4 与 IPv6 并预处理为需要的格式（针对 dynv6 API）。</li>
<li>若 IPv4 与 IPv6 均不存在则清除已有的 DNS 记录。</li>
<li>若地址发生改变则更新 DNS 为新地址或清除记录。</li>
<li>更新本地缓存，用于识别下一次地址变更。</li>
</ol>
</blockquote>
<p>同样，我们利用 PPP 的 profile 来在重新拨号时触发 DDNS 更新，并且设置一个定时任务来兜底，具体参见「端口映射 - IP 更新」章节，这里不赘述了。</p>
<h3>多设备 IPv6</h3>
<p>理论上每个设备都有不同的 IPv6 地址，所以需要分别部署 DDNS，显然这非常麻烦甚至不现实。一个优化方案是编写复杂的路由器脚本来分别更新（因为路由器理应知道每个子设备的地址），这依然很麻烦而且不够稳定（会大量调用更新 API）。</p>
<p>更好的方案是，利用 IPv6 可以固定后缀的特性，只更新运营商分配给我们（会改变）的前缀，手动输入后缀，然后让 DDNS 服务商帮我们组合成真实地址。dynv6.com 就支持这种功能（而阿里云 DNSPOD 等传统服务不行）。</p>
<blockquote>
<p><strong>提示</strong>：部分地区屏蔽了 dynv6 的免费域名访问，所以建议使用自己的域名 CNAME 过去，或者干脆把一个子域（比如 <code>dns.example.com</code>）接入 dynv6，如果需要的解析的主机名很多这样会更简单，而且也不会影响根域名的解析。</p>
</blockquote>
<p>上述脚本就是基于前缀更新功能，具体来说，假设更新的 zone 是 <code>chenhe.dns.navy</code>，脚本实际更新的是 <code>chenhe.dns.navy</code> 的 IPv4 地址以及 IPv6 前缀。现在有一个主机 <code>nas.chenhe.dns.navy</code> 希望通过域名从公网访问，那么需要前往 dynv6 后台「Zones - Records」添加 2 条记录：</p>
<ul>
<li>AAAA, nas.chenhe.dns.navy, v6DATA</li>
<li>CNAME, *.chenhe.dns.navy, chenhe.dns.navy</li>
</ul>
<p>其中 v6DATA 根据路由器配置的不同可以是 NAS IPv6 的后缀，也可以是它的 mac 地址（SLAAC 默认通过 mac 地址计算 IPv6 后缀）。dynv6 会自动把配置的后缀与 zone 的前缀拼接起来。</p>
<p>至于 CNAME 的泛解析，是为了让 IPv4 栈也可以顺利访问，当然，它的前提是 ①有公网地址；②路由器配置好了端口映射。如果不需要 IPv4 公网访问，就不要配置这条记录。</p>
<h2>IPv6</h2>
<h3>获取公网地址</h3>
<blockquote>
<p><strong>警告</strong>：获取公网地址意味着你的路由器（甚至所有子网设备）均已暴露在互联网，我的 ROS Quick Set 默认配置了一些防火墙规则以保证安全。如果你的防火墙规则是空白，请参阅其他帖子补全之。</p>
</blockquote>
<p>首先要启用 IPv6，进入「PPP - Profiles」点击实际使用的那个（通常是 default），在 Protocols 节中确认 Use IPv6 为 yes。</p>
<p><img src="https://img.chenhe.cc/i/2024/10/03/66fea81f98e46.webp" alt="enable IPv6 in PPP profile" /></p>
<p>其次进入「IPv6 - Settings」，确认<strong>没有</strong>选中 Disable IPv6 并开启 IPv6 Forward。</p>
<p><img src="https://img.chenhe.cc/i/2024/10/03/66fea95d5a644.webp" alt="IPv6 Settings" /></p>
<p>接下来设置 IPv6 DHCP 客户端以便从运营商那里拿到一个前缀。点击「IPv6 - DHCP Client - Add New」：</p>
<ul>
<li>Interface: 拨号的逻辑接口，通常是 pppoe-out1。</li>
<li>Request: <code>prefix</code> 通常运营商都是分配前缀。</li>
<li>Pool Name: 自定义，例如 <code>ipv6</code>。</li>
<li>Pool Prefix Length: 无子网划分需求填写 64 即可，即往下分配的都是具体的 IPv6 地址。</li>
<li>Prefix Hint: 用于指示希望得到的前缀长度，但实营运商有决定权，所以用处不大。</li>
<li>Use Peer DNS: 关闭，如果你之前已经自己配置了 DNS 服务器的话。（IPv4 的 DNS 也可能解析出 IPv6 的地址，不一定要配置地址为 IPv6 的上游 DNS 服务器）。</li>
</ul>
<p>添加后应该就可以取得公网 IPv6 地址了，如图：</p>
<p><img src="https://img.chenhe.cc/i/2024/10/03/66feab5c9a727.webp" alt="IPv6 Address" /></p>
<h3>分配给内网设备</h3>
<blockquote>
<p><strong>背景知识</strong></p>
<p>有两种方式分配 IPv6：SLAAC (Stateless Address Autoconfiguration) 与 DHCPv6。SLAAC 没有中心服务器来「分配」，各个主机通过协议自行生成、协商、通告地址。<strong>SLAAC 是唯一全平台支持的方式</strong>。<a href="https://issuetracker.google.com/issues/36949085?pli=1#comment374">Android 明确不会支持有状态 DHCPv6</a>（尽管近期略微松口），谷歌认为有状态协议对于终端用户没有明显优点，还会造成隐私问题，属于 IPv4 时代的陋习。</p>
<p>其实 SLAAC 可以与 DHCPv6 搭配使用，称为无状态 DHCPv6。此时 IP 地址本身还是通过 SLAAC 生成，但通过 DHCPv6 服务器获取其他参数，例如网关地址、DNS 等。</p>
<p>SLAAC 的关键数据是路由器定期发送的 RA（路由通告），其包括了前缀信息，以及是否使用无状态 DHCPv6 的指示。</p>
</blockquote>
<p>这里我们配置纯无状态 SLAAC。ROS 中默认启用 RA，通告的具体数据由多项 IPv6 子菜单的设置共同决定。</p>
<p>为了让 RA 广播可用的前缀，需要点击「IPv6 - Addresses - Add New」中为 LAN 接口分配地址：</p>
<ul>
<li>Address: 保持默认让系统自动生成。</li>
<li>From Pool: 就是之前 DHCPv6 Client 里配置的地址池。</li>
<li>Interface: 选择 LAN 接口，通常是 bridge。</li>
<li>EUI64: 若勾选则系统根据 LAN 接口的 mac 生成地址。</li>
<li>Advertise: 勾选，从而通告这一地址。</li>
</ul>
<p>添加后「IPv6 - ND - Prefixes」中应该自动出现了一个带有 <code>D</code> 标记的前缀，这就是刚才添加的地址自动生成的：</p>
<p><img src="https://img.chenhe.cc/i/2024/10/04/66fec9667fdfa.webp" alt="Auto generated ND prefix" /></p>
<blockquote>
<p>当从运营商获取的前缀变化时，这里以及 Addresses 处分配的地址都会自动更新，不用担心。</p>
</blockquote>
<p>根据 <a href="https://www.rfc-editor.org/rfc/rfc9096#name-recommended-option-lifetime">RFC 9096 Improving the Reaction of Customer Edge Routers to IPv6 Renumbering Events</a> 的建议，<strong>在这里点击「Default」按钮 修改通告的前缀有效期：</strong></p>
<ul>
<li>Valid Lifetime: <code>01:30:00</code> 90 分钟。</li>
<li>Preferred Lifetime: <code>00:45:00</code> 45 分钟。</li>
</ul>
<p><img src="https://img.chenhe.cc/i/2024/10/04/66fec90de25aa.webp" alt="Edit RA prefix default lifetime" /></p>
<p>这样可以缓解网络信息突然变化（路由器因故没有发送相应信号，例如突然重启）时对客户端的影响。</p>
<p>现在，所有子网设备应该都可以得到公网 IPv6 地址了。</p>
<h3>公网访问</h3>
<blockquote>
<p>IPv6 DDNS 说明请参考 DDNS 章节。</p>
</blockquote>
<p>分配了公网 IP，有了 DDNS，理论上可以从公网访问任何一个设备了——如果防火墙允许的话。ROS 缺省的防火墙会阻止所有不是 LAN 口的未知流量，主要是这两个规则：</p>
<pre><code> 9    ;;; defconf: drop everything else not coming from LAN
      chain=input action=drop in-interface-list=!LAN

 21    ;;; defconf: drop everything else not coming from LAN
      chain=forward action=drop in-interface-list=!LAN
</code></pre>
<p><code>input</code> 与 <code>forwa</code> 链分别阻止了访问路由器本身和其他子网设备的流量。添加新的放行策略注意顺序，建议在最前面插入，流量按顺序匹配到一个规则后即停止匹配。</p>
<p>本来事情很简单，防火墙放通一下就行了，但偏偏前缀是动态的，于是机智的开发者们搞出了反掩码，比如 <code>240e:3a3::aaaa:bbbb:cccc:eeee/-64</code> 表示只匹配后 64 位，个别老旧的系统不支持缩写，那么就得这么表示 ``240e:3a3::aaaa:bbbb:cccc:eeee/::ffff:ffff:ffff:ffff`。而 ROS 就厉害了，人家全！都！不支持，是的，2021 年就<a href="https://forum.mikrotik.com/viewtopic.php?t=179161">有人提出</a>，直到现在 (2024.10) 依然不支持。</p>
<p>变通方法是创建 address-list 并在防火墙规则里引用。然后编写脚本来定期更新 address-list 里的地址：</p>
<pre><code>#!rsc by RouterOS
#
# Used to update prefixes in IPv6 firewall's address-list to 
# keep them in sync with the prefix assigned by the ISP via DHCP-v6.
# This is an workaround since stubborn ROS still doesn't provide 
# reverse masks feature in 2024.
#
# Args:
#   nic: the name of pppoe interface
#   keyword: RegExp used to match ipv6 firewall address-list item comment

:local nic "pppoe-out1"
:local keyword "\\[dp\\]"

:local ipv6 [/ipv6 dhcp-client get pppoe-out1 prefix]
:set ipv6 [:pick $ipv6 0 [:find $ipv6 "/"]]

:if ([:typeof $ipv6]="nothing") do={
    :log info "v6firewall: no IPv6 prefix on $nic, ignore..."
} else={
    :foreach i in=[/ipv6/firewall/address-list find where comment~$keyword] do={
        :local addr [/ipv6/firewall/address-list get $i address]
        :set addr [:pick $addr 0 [:find $addr "/"]]
        
        :local newAddr ([:toip6 $ipv6]|([:toip6 $addr]&amp;::ffff:ffff:ffff:ffff))
        :if ($newAddr!=$addr) do={
            /ipv6/firewall/address-list set $i address=$newAddr
            :log info "v6firewall: update $addr -&gt; $newAddr"
        }
    }
}
</code></pre>
<p>可以自定义 <code>keyword</code> 参数。至于脚本的定时执行以及在 PPPoE 重新拨号后触发请参阅「端口映射 - IP 更新」章节。</p>
<p>现在可以点击「IPv6 - Firewall - Address Lists - Add New」添加新条目：</p>
<ul>
<li>Comment: [dp] xxx (<code>[dp]</code> 是脚本识别用的关键字)</li>
<li>List: 起个名字，比如 <code>nas</code>。</li>
<li>Address: 设备的 IPv6 完整地址。</li>
</ul>
<p>返回 Filter Rules，可以创建防火墙规则并引用这个地址列表了：</p>
<pre><code>/ipv6/firewall/filter add chain=forward in-interface=pppoe-out1 dst-address-list=nas protocol=tcp dst-port=443 action=accept place-before=0
</code></pre>
<blockquote>
<p>使用 WebUI 原理一样的，对照着输入参数就行。<code>place-before</code> 表示位置，可以添加后再移动。</p>
</blockquote>
<p>另一个方案是配置 ULA 给设备分配固定的内网前缀，然后通过 NAT6 做端口映射，和 IPv4 一样。个人感觉这实在是太丑陋了，从心理反感这种历史倒车。</p>
<h2>ZeroTier 组网</h2>
<h3>启动 ZeroTier</h3>
<p>ROS 已支持 ZeroTier 扩展，从<a href="https://mikrotik.com/download">官网</a>下载 Extra Package 解压后，从后台上传 ZeroTier 安装包，直接重启就会安装上去了。如果你想我一样买了原厂 arm 硬件，注意下是 ARM 版还是 ARM64 版，搞错了装不上的。而且扩展包版本必须与主系统版本一致。</p>
<p>现在 (ROS 7.16) web 管理已支持 ZeroTier。可以看到有一个默认的已禁用实例 (instance) 叫 <code>zt1</code>，直接启用它。然后进入主选项卡 (ZeroTier) 点击 Add New 新建一个 Interface，典型情况下只需要改两个地方：</p>
<ul>
<li>Network: 输入 ZeroTier 的网络 ID。</li>
<li>Instance: 绑定一个 ZeroTier 实例，可以选择默认的 <code>zt1</code>。</li>
</ul>
<p>![Add ZeroTier](/Users/chenhe/Library/Application Support/typora-user-images/image-20241006191837108.png)</p>
<p>对应的命令为：</p>
<pre><code>/zerotier enable zt1
/zerotier/interface add network=xxx instance=zt1
</code></pre>
<blockquote>
<p>这里的「实例」Instance 其实是 ZeroTier 的 Control Panel，默认创建的是使用 ZeroTier 官方托管的服务器。如有需要也可以自建。</p>
</blockquote>
<p>其他的部分选项这里简单解释一下：</p>
<ul>
<li>Allow Managed: ZeroTier 会给接入网络的设备分配内网 IP，这个选项表示接受这一分配。不接受的话其实可以手动指定的。</li>
<li>Allow Default: 是否允许缺省路由。我们可以在 ZeroTier 控制面板指定路由，如果配置了 <code>0.0.0.0/0</code> 的路由条目，这就是<em>缺省路由</em>。一旦生效，几乎所有的流量都会从这里出去。假设我们在 ZT 配置了路由 <code>0.0.0.0/0</code> via <code>192.168.192.254</code>，并且 ROS 中勾选了这一项，那么应该可以在 ROS 的路由表中看到一个新条目 <code>0.0.0.0/1</code> via <code>192.168.192.254</code>。比较有趣的是这里的掩码长度可能为 1，其目的是确保覆盖原先的缺省路由（越长的掩码条目优先级越高）。</li>
</ul>
<p>现在应该已经加入网络了，记得去 ZeroTier 后台允许这个新设备。<strong>但是 ZeroTier 作为一个独立的接口，目前不被允许访问路由器本身，或其他设备</strong>，从而几乎失去了组网的意义。所以下一步要配置防火墙。</p>
<h3>配置防火墙</h3>
<pre><code>/ip firewall filter
# 允许 ZT 访问内网设备
add action=accept chain=forward in-interface=zerotier1 place-before=1 comment="zt: allow all from zerotier access others"
# 允许 ZT 访问路由器
add action=accept chain=input in-interface=zerotier1 place-before=1 comment="zt: allow all from zerotier access router"

# IPv6 防火墙
/ipv6/firewall/filter
add action=accept chain=forward in-interface=zerotier1 place-before=0 comment="zt: allow all"
add action=accept chain=input in-interface=zerotier1 place-before=0 comment="zt: allow all"
</code></pre>
<blockquote>
<p>ROS 防火墙缺省策略是放行，而自动生成的规则对于 forward 仅仅是屏蔽了所有来自 WAN 的流量，所以即使不显式放行也可以从 ZeroTier 访问内网其他设备。不过从性能已经标准性来看，建议手动放行，这样流量无需匹配整个防火墙规则。</p>
</blockquote>
<p>如果希望通过 ZeroTier 访问 ROS 的 web 管理页面，记得点击「IP - Services - www」，设置「Available From」，加入 ZeroTier 的网段，例如 <code>192.168.192.0/24</code>，否则只是可以 ping 通，访问 http 会提示 "Empty reply from server"。</p>
<p><strong>UPnP 问题</strong></p>
<p>根据<a href="https://help.mikrotik.com/docs/display/ROS/ZeroTier">官方指南</a>，建议开启 UPnP 获得更好的性能。但很多中国的流氓喜欢白嫖用户宽带当成自己的 CDN，这里建议利用防火墙仅对指定的设备（需要安装 ZeroTier 客户端组网的设备）开放 UPnP（:1900/udp），虽然难以做到进程级拦截，多少也可以把电视盒子之类的东西拒之门外。</p>
<p>配置开启 UPnP 比较简单，进入「IP - UPnP」页面，勾选 Enable 保存。然后点击「Interfaces - Add New」按钮，添加两个接口分别定义内网和外网。一般来说内网绑定 <code>bridge</code>，外网绑定 <code>pppoe-out1</code>。如下：</p>
<p><img src="https://img.chenhe.cc/i/2024/10/07/670357f7c28b7.webp" alt="UPnP Interfaces" /></p>
<p>回到「IP - Firewall」，创建两个 Filter Rules：</p>
<p><strong>1. 放行需要的设备</strong></p>
<ul>
<li>Chain: input</li>
<li>Src. Address: 内网地址</li>
<li>Protocol: udp</li>
<li>Dst. Port: 1900</li>
<li>Action: accept</li>
</ul>
<p><strong>2. 禁止所有设备 UPnP</strong></p>
<ul>
<li>Src. Address: 留空</li>
<li>Action: drop</li>
<li>其他和上一个一样。</li>
</ul>
<p><strong>千万注意规则顺序，防火墙从上往下匹配，只要命中一个就终止匹配，所以必须把更精确的匹配（这里是放行的条目）放在上面。</strong></p>
<p><img src="https://img.chenhe.cc/i/2024/10/07/6703596343e6a.webp" alt="UPnP Firewall" /></p>
<h2>参考</h2>
<ul>
<li>在RouterOS上解决本地网络回流问题: https://www.youtube.com/watch?v=UkdPf0ctN3g</li>
<li><a href="https://blog.linux-code.com/articles/thread-1370.html">Ros端口转发、映射及DDNS配置</a></li>
</ul>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2024-10-09T05:58:17.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[1Panel 安装 Traefik 与 OpenResty 并存]]></title>
        <id>https://chenhe.me/zh/posts/1panel-install-traefik/</id>
        <link href="https://chenhe.me/zh/posts/1panel-install-traefik/"/>
        <updated>2024-07-27T23:29:26.000Z</updated>
        <summary type="html"><![CDATA[1Panel 是现代化的开源 Linux 服务器面板，与传统（且流氓）的宝塔相比，其使用 go 编写，安装部署简单。内部完全基于 Docke...]]></summary>
        <content type="html"><![CDATA[<h2>背景</h2>
<p><a href="https://1panel.cn/">1Panel</a> 是现代化的开源 Linux 服务器面板，与传统（且流氓）的宝塔相比，其使用 go 编写，安装部署简单。内部完全基于 Docker，安装应用不会污染主机环境，备份与迁移也更简单。可惜面板为了能更细致地控制 web 服务器，实现 WAF 等高级功能，1Panel 强依赖 OpenResty。如果大量 HTTP 应用基于 Docker 安装，则需要手动创建多个反向代理，各自可能还需要单独创建证书，非常麻烦。</p>
<p><a href="https://doc.traefik.io/traefik/">Traefik</a> 则是专为云原生时代设计的 web 服务器，它能自动发现基于容器运行的 web 服务，并创建相应的路由（反向代理），而且默认集成了 ACME 协议可自动申请部署 HTTPS 证书。说人话就行，解析好域名，起一个容器，剩下就不用管了。</p>
<p>作为 web 服务器，显然 OpenResty 与 Traefik 默认都占用 80 与 443 端口。这里的解决方案是让 Traefik 作为主服务器处理外部请求，OpenResty 作为普通的容器，受 Traefik 管理。你问我为什么不倒过来？倒过来你不又得自己配置反代了？</p>
<h2>安装 OpenResty</h2>
<p>这一步比较简单，在面板的应用商店里安装即可，需要注意的是手动指定一下端口避免冲突。这里保持了默认的 host 模式。</p>
<p><img src="https://img.chenhe.cc/i/2024/07/28/66a571006d3fa.webp" alt="specify ports for openresty manually" /></p>
<h2>安装 Traefik</h2>
<h3>Compose 文件</h3>
<p>这里直接使用命令行通过 docker compose 安装 Traefik，不想用命令行的也可以从 1Panel 的 「容器 - 编排」处创建。</p>
<p><code>compose.yml</code> 文件如下：</p>
<pre><code>services:
  traefik:
    image: traefik:3.1
    restart: unless-stopped
    networks: ["traefik"]
    ports:
      - 443:443
      - 80:80
      - 81:8080 # Traefik dashboard 默认端口
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./data:/etc/traefik/
    extra_hosts: ["host.docker.internal:host-gateway"]

networks:
  traefik:
    external: true
</code></pre>
<p>这里要注意的是：</p>
<ul>
<li>没有使用 host 模式，因为 Traefik 只能自动发现与自己在同一网络中的服务。</li>
<li>网络 <code>traefik</code> 需要手动创建，这样可以防止 Traefik 容器意外销毁时网络同步销毁。</li>
<li><code>extra_hosts</code> 用于在容器内部通过 <code>..</code> 访问宿主机（别忘了我们的 Traefik 是 host 模式）。</li>
<li><code>./data</code> 目录里存放 Traefik 的配置文件，见下文。</li>
</ul>
<p>启动之前记得先创建网络（别忘了先做好下面的静态配置再启动）：</p>
<pre><code>docker network create traefik
</code></pre>
<h3>静态配置</h3>
<p>创建 <code>./data/traefik.yml</code> 文件，注意文件名不可更改：</p>
<pre><code>global:
  checkNewVersion: false
  sendAnonymousUsage: false

entryPoints:
  web:
    address: :80

api:
  dashboard: true
  insecure: true

providers:
  file:
    directory: "/etc/traefik"
    watch: true
</code></pre>
<p><code>api.insecure</code> 设置为 <code>true</code> 方便通过 IP 地址访问 Traefik Dashboard。其实这个东西是只读的，并不能修改什么配置，所以问题不大。介意的话后续可以关掉或配置成域名 HTTPS 访问。</p>
<p><code>providers.file</code> 配置的是动态配置文件的监听目录，与 <code>compose.yml</code> 中的文件映射对应。后面手动创建路由会用到。</p>
<h3>HTTPS 配置</h3>
<p>要想自动部署 HTTPS 包括两个方面：</p>
<ul>
<li>自动申请 HTTPS 证书。</li>
<li>监听 HTTPS 端口。</li>
</ul>
<blockquote>
<p>Traefik 会自动申请证书。如果要整合 1Panel 的证书会麻烦一点，自己配置吧。</p>
</blockquote>
<p>在静态配置文件中添加下面的内容：</p>
<pre><code>entryPoints:
	# 监听 https 端口
  websecure:
    address: :443
    asDefault: true
    http:
      tls:
        certResolver: letsenc

# 自动申请证书
certificatesResolvers:
  # 名字随便起，与 entryPoints 中的设置对应
  letsenc:
    acme:
      email: "cert@example.com"
      storage: "/etc/traefik/acme.json"
      tlsChallenge: {}
</code></pre>
<p>这里默认使用 <em>TLS-ALPN-01</em> ACME challenge，Traefik 会自动处理一切事务。但如果你的域名使用了 CDN，建议配置为 HTTP challenge，也可使用 DNS challenge，详见 <a href="https://doc.traefik.io/traefik/https/acme/">Traefik 文档</a>。</p>
<h2>创建路由</h2>
<blockquote>
<p>创建路由其实是 Traefik 的使用问题了，这里只解释如何为 OpenResty 中的网站绑定域名，其他场景请参阅 <a href="https://doc.traefik.io/traefik/routing/routers/">Traefik 文档</a>。</p>
</blockquote>
<p>虽然 Traefik 可以自动发现服务，但它并不知道你想给他绑定哪个域名呀。所以还是需要为每个容器设置一些东西。这里有两种方式：</p>
<ul>
<li>Container Label</li>
<li>配置文件</li>
</ul>
<p><strong>对于 OpenResty 必须使用配置文件方式</strong>，因为它是 host 模式，不会被 Traefik 扫描到。而且这个容器是 1Panel 管理的，我们并不方便修改 Label。</p>
<p>方便起见，我们利用动态配置功能来写，可以实时生效，无需重启 Traefik 容器。编辑 Traefik compose 目录中的 <code>./data/dynamic.yml</code>:</p>
<pre><code>http:
  routers:
    my_website:
      rule: Host(`www.example.com`)
      service: openresty
  services:
    openresty:
      loadBalancer:
        servers:
          - url: 'http://host.docker.internal:8080/'
</code></pre>
<p><code>service</code> 可以理解为后端（上游），这里是我们的宿主机地址，端口则是 OpenResty 的监听端口。</p>
<p>每创建一个站点，记得都要在这里创建一个新的 Router (Service 一个就够)。虽然稍微麻烦一点，但我们既然用了 Traefik，肯定大部分服务都是容器化的，这些不需要手动配置。</p>
<blockquote>
<p>如果你配置了 HTTPS，建议确认域名解析生效后再添加 Router，否则将无法申请证书。</p>
</blockquote>
<h2>常见问题</h2>
<h3>HTTPS</h3>
<p>这里建议 HTTPS 由 Traefik 自动处理（包括 HTTP 自动跳转），手动在 1Panel 或 OpenResty 中创建的站点 (vhost) 使用 HTTP 就好。</p>
<p>注意，即使在 OpenResty 中配置了证书，Traefik 的 HTTPS 也不可省略。因为后者才是真正与客户端握手的程序，OpenResty 的证书只在 Traefik &lt;---&gt; OpenResty 之间的连接起作用，不会影响客户端。鉴于这两个程序都是本地的，所以 HTTPS 意义不大，徒增损耗。</p>
<h3>1Panel 证书</h3>
<p>针对 <a href="https://letsencrypt.org/docs/challenge-types/#http-01-challenge">HTTP-01 challenge</a> 方式申请的 HTTPS 证书，1Panel 需要 OpenResty 的配合来自动放置验证文件。但我们把 Traefik 放在了 OpenResty 前面，如果你恰好 Traefik 中也配置的 HTTP challenge，那么验证请求将被拦截，无法到达容器(OpenResty)。这里没有太好的解决方法，建议：</p>
<ul>
<li>放弃 1Panel 的证书功能，或通过 DNS 申请。</li>
<li>修改 Traefik，使用 DNS 或 tls 申请。</li>
</ul>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2024-07-27T23:29:26.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[PostgreSQL 权限管理 101]]></title>
        <id>https://chenhe.me/zh/posts/pgsql-privilege-management-101/</id>
        <link href="https://chenhe.me/zh/posts/pgsql-privilege-management-101/"/>
        <updated>2024-06-23T19:35:36.000Z</updated>
        <summary type="html"><![CDATA[数据库的用户或角色与系统无关，只是习惯上喜欢对应地设置。PostgreSQL 中授权的对象是角色。而「用户」其实是一个特殊的「角色」，即它...]]></summary>
        <content type="html"><![CDATA[<h2>用户与角色</h2>
<h3>角色属性</h3>
<blockquote>
<p>数据库的用户或角色与系统无关，只是习惯上喜欢对应地设置。</p>
</blockquote>
<p>PostgreSQL 中授权的对象是角色。而「用户」其实是一个特殊的「角色」，即它额外拥有登录属性。</p>
<pre><code>-- 创建角色
CREATE ROLE name;

-- 修改角色
ALTER ROLE name CREATEDB;

-- 删除角色
DROP ROLE name;
</code></pre>
<p>要创建用户，只需要顺便给予 LOGIN 属性就行了，<code>CREATE USER</code> 语法糖就做了这件事情。</p>
<pre><code>-- 创建用户
CREATE ROLE name LOGIN;
-- 等价写法
CREATE USER name;
</code></pre>
<p>上面提到的「属性」可以理解为特殊的权限，与常规权限不同，这些属性作用域是整个数据库集群，而不是绑定到具体的对象（例如一个表或一个库）。大部分属性默认都是不赋予的，要想知道具体默认值可参考 <a href="https://www.postgresql.org/docs/current/sql-createrole.html"><code>CREATE ROLE</code></a>，常用的有：</p>
<ul>
<li><code>LOGIN</code>: 可登录。更严谨的说法是可用作数据库连接的初始角色名。拥有此属性的「角色」即视为「用户」。</li>
<li><code>SUPERUSER</code>: 上帝模式，绕过所有权限检查，可以做任何事情。</li>
<li><code>CREATEDB</code>: 允许创建数据库。</li>
<li><code>CREATEROLE</code>: 允许创建角色。</li>
<li><code>PASSWORD</code>: 口令，也就是密码啦。记得配合 <code>LOGIN</code>。</li>
</ul>
<p>比如我们现在想创建一个用户 <code>user1</code>，并让他有权创建新角色：</p>
<pre><code>CREATE ROLE user1 LOGIN CREATEROLE PASSWORD 'password';
</code></pre>
<h3>角色继承</h3>
<p>角色可以继承，就像 RBAC 八股文一样。但 PostgreSQL 中引入了一个单独的属性 <code>INHERIT</code>（默认）与 <code>NOINHERIT</code> 来控制当前角色是否继承其父角色（如果有）的权限。当角色 B 是 A 的成员时，可以说 B 继承了 A。</p>
<p>要想使用非继承的权限，需要显式地设置当前用户的角色身份。例如我们创建 2 个普通角色与 1 个用户：</p>
<pre><code>CREATE ROLE joe LOGIN;
CREATE ROLE dev;
CREATE ROLE test NOINHERIT;
GRANT dev TO test;
GRANT test TO joe;
-- 此时继承关系为：dev &lt;- test &lt;- joe
</code></pre>
<p>这时候 <code>joe</code> 默认就能使用 <code>test</code> 的权限，但不能使用 <code>dev</code> 的权限。因为 <code>dev</code> 是通过 <code>test</code> 间接继承的，而 <code>test</code> 通过 <code>NOINHERIT</code> 属性配置了不继承上游角色的权限。如果想使用 <code>dev</code> 的权限，需要执行：</p>
<pre><code>SET ROLE dev;
</code></pre>
<p>但是注意，执行之后将暂时失去 <code>test</code> 以及直接授权给 <code>joe</code> 的权限，要想恢复默认状态，可任选下面其一执行：</p>
<pre><code>SET ROLE joe;
SET ROLE NONE;
RESET ROLE;
</code></pre>
<blockquote>
<p>注意，上面那些「属性」作为特殊权限是不可继承的。例如即使角色 <code>admin</code> 拥有 <code>CREATEDB</code> 属性，它的成员依然不能创建数据库。要行使这一权限，需要显式地设置角色身份，并且创建的数据库的所有者也是角色，而不是具体用户。</p>
</blockquote>
<h2>权限</h2>
<h3>权限管理</h3>
<p>在 PostgreSQL 中，权限是针对对象而言的。而对象有一个「所有者」，所有者默认拥有此对象的全部权限（包括删除）。比如一个最朴素的例子，以用户 A 的身份创建一个表，那么自然用户 A 可以随意操作这个表，而无需显式授权。</p>
<blockquote>
<p>对象的权限是可以继承的普通权限。所以哪怕对象的所有者是角色，默认情况下它的成员用户无需显式切换就可以修改此对象。</p>
</blockquote>
<p>权限有很多种，每一种的可用性以及具体的含意根据对象的不同而不同，<a href="https://www.postgresql.org/docs/current/ddl-priv.html#DDL-PRIV-SELECT">文档</a>列出了具体的解释。</p>
<p>授予权限使用 <a href="https://www.postgresql.org/docs/16/sql-grant.html"> <code>GRANT</code></a> 命令，撤销使用 <a href="https://www.postgresql.org/docs/16/sql-revoke.html"><code>REVOKE</code></a>：</p>
<pre><code>-- 授予角色 joe 对 messages 表的 UPDATE 权限
GRANT UPDATE ON messages TO joe;
</code></pre>
<p><code>ALL</code> 可以表示与对象相关的所有权限，而 <code>PUBLIC</code> 表示系统中所有角色：</p>
<pre><code>-- 撤销 PUBLIC 关于表 messages 的一切权限
REVOKE ALL ON messages FROM PUBLIC;
</code></pre>
<blockquote>
<p><strong>注意</strong></p>
<p>PUBLIC 并不是角色的集合，实际上它类似于 java 中的 Object，是一个公共父角色。所以撤销 <code>PUBLIC</code> 是不会影响一个角色从其他渠道获得的权限的。</p>
<p>比如：</p>
<pre><code>GRANT UPDATE ON messages TO joe;
REVOKE ALL ON messages FROM PUBLIC;
-- 此时 joe 依然有 messages 上的 UPDATE 权限。
</code></pre>
</blockquote>
<p>psql 中有一些命令可以查看当前权限配置：</p>
<ul>
<li>
<p><code>\l+</code> 列出所有数据库的详细信息。</p>
</li>
<li>
<p><code>\dn+</code> 列出当前数据库中所有模式 (schema) 的详细信息。</p>
</li>
<li>
<p><code>\dp</code> 列出当前数据库中所有表、视图与 sequence 的权限信息。</p>
</li>
<li>
<p><code>\ddp</code> 查看默认权限 (DEFAULT PRIVILEGES) 的配置。</p>
</li>
</ul>
<h3>默认权限</h3>
<p>所有的权限管理语句都只对当前已经存在的对象生效，如果想设置未来创建的对象的权限，就需要通过 <a href="https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html"><code>ALTER DEFAULT PRIVILEGES</code></a> 配置。当前的默认权限可通过 psql 的 <code>\ddp</code> 命令查看。基本语法为</p>
<pre><code>ALTER DEFAULT PRIVILEGES
    [ FOR { ROLE | USER } target_role [, ...] ]
    [ IN SCHEMA schema_name [, ...] ]
    abbreviated_grant_or_revoke
</code></pre>
<p>这里要强调一下很多人遗漏的 <code>FOR ROLE xxx</code> 部分。<strong>设置的默认权限只能对指定的角色所创建的对象生效</strong>，不允许全局设置。若省略 <code>FOR</code> 部分，则默认是当前的角色。很多时候我们人工管理数据库时登录的用户与业务系统使用的不一样，这就造成了「默认权限不生效」的问题。</p>
<h2>Recipe</h2>
<h3>创建只读用户</h3>
<p>假设我们已有角色 <code>readonly</code>，希望将其设置为可只读访问数据库 <code>db</code> （包括所有表）。</p>
<p>PostgreSQL 默认允许所有角色在 <code>public</code> 模式下创建表（即 <code>PUBLIC</code> 对于 模式 <code>public</code> 有 <code>UC</code> 权限），所以首先要撤销这一默认行为:</p>
<pre><code>-- 禁止在 public 模式下创建对象
REVOKE CREATE ON SCHEMA public FROM PUbLIC;

-- 以防万一，撤销 PUBLIC 对数据库的所有权限
REVOKE ALL ON DATABASE db FROM PUBLIC;
</code></pre>
<p>注意上述语句中小写的 <code>public</code> 指的是模式，大写的 <code>PUBLIC</code> 是一个关键字，指所有角色。后者也可以小写。</p>
<p>接下来给 <code>readonly</code> 授予数据库级别的权限 <code>CONNECT</code> 与 <code>TEMPORARY</code>：</p>
<pre><code>-- TEMPORARY 是可选的，允许创建临时表在分析复杂数据的时候可能有用
GRANT CONNECT, TEMPORARY ON DATABASE pc_dev TO readonly;
</code></pre>
<p>然后授予模式的 <code>USAGE</code> 权限：</p>
<pre><code>GRANT USAGE ON SCHEMA public TO readonly;
-- 不支持类似 GRANT xxx ON ALL SCHEMAS 的写法，
-- 如果有多个 schema，必须分别配置。
-- PUBLIC 默认对 public 模式拥有 USAGE 权限，可以不用显式授予。
</code></pre>
<p>最后授予所有表的所需权限：</p>
<pre><code>GRANT SELECT ON ALL TABLES IN SCHEMA public [,...] TO readonly;
</code></pre>
<p>别忘了修改默认权限，让未来创建的对象也能够被读取：</p>
<pre><code>-- 允许读取新的 SCHEMA
ALTER DEFAULT PRIVILEGES GRANT USAGE ON SCHEMAS TO readonly;
-- 允许读取新的表（任意 SCHEMA）
ALTER DEFAULT PRIVILEGES GRANT SELECT ON TABLES TO readonly;
</code></pre>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2024-06-23T19:35:36.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[群晖 Docker 的迷惑配置]]></title>
        <id>https://chenhe.me/zh/posts/synology-docker-configuration/</id>
        <link href="https://chenhe.me/zh/posts/synology-docker-configuration/"/>
        <updated>2024-06-16T23:32:22.000Z</updated>
        <summary type="html"><![CDATA[碎碎念 咱也不知道中国厂商都有什么大病，非常喜欢魔改一些东西。并且改的非常反人类。首先，群晖在很早之前就把自己的 Docker 改名容器服...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>碎碎念
咱也不知道中国厂商都有什么大病，非常喜欢魔改一些东西。并且改的非常反人类。</p>
</blockquote>
<p>首先，群晖在很早之前就把自己的 Docker 改名容器服务，套件中心里叫 「Container Manager」，提供了一个非常适合小白的 GUI，可以轻松修改源（注册表）。<strong>但此处的设置终端不生效！</strong> 如果希望使用 <code>docker</code> 命令愉快地玩耍，得自己编辑一下配置文件。</p>
<p>魔改版的配置文件是 <code>/var/packages/ContainerManager/etc/dockerd.json</code>，旧版系统注意把 <code>ContainerManager</code> 替换成 <code>Docker</code>。一般来讲里面应该有一些默认设置，记得不要删掉。按照原版配置文件的语法修改镜像部分就好：</p>
<pre><code>{
    "registry-mirrors": [
        "https://noohub.ru",
        "https://huecker.io",
        "https://dockerhub.timeweb.cloud"
    ],
    // 下面三个选项是默认的，不要动它
    "data-root": "/var/packages/ContainerManager/var/docker",
    "log-driver": "db",
    "storage-driver": "btrfs"
}
</code></pre>
<p>修改完需要重启服务：</p>
<pre><code># DMS 7 优先试试这个
sudo synosystemctl restart pkgctl-ContainerManager

# 手动方案
# 旧版系统记得替换 ContainerManager -&gt; Docker
/var/packages/ContainerManager/scripts/start-stop-status stop
/var/packages/ContainerManager/scripts/start-stop-status start
</code></pre>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2024-06-16T23:32:22.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[SSO 单点登录的明星协议 SAML2.0]]></title>
        <id>https://chenhe.me/zh/posts/sso-saml2/</id>
        <link href="https://chenhe.me/zh/posts/sso-saml2/"/>
        <updated>2024-05-18T22:06:00.000Z</updated>
        <summary type="html"><![CDATA[SAML 是 2001 年问世的古董的协议。相比于 2010 年成为 RFC 标准的 OAuth2.0，即使是 SAML2 也早在 2005...]]></summary>
        <content type="html"><![CDATA[<h2>简介</h2>
<p>SAML 是 2001 年问世的古董的协议。相比于 2010 年成为 RFC 标准的 OAuth2.0，即使是 SAML2 也早在 2005 年就发布了，廉颇老矣，尚能饭否？答曰：能。事实上直到今天（2024年）SAML2.0 依然是不少企业实现 SSO 的首选协议，这其中既少不了 Cisco 等不思进取的传统企业的推波助澜，也是许多大型公司屎山过重基于兼容性考虑的不得已而为之，当然，SMAL2.0 也有它自己的优点。</p>
<blockquote>
<p>如无特别说明，本文 SAML 均指 SAML 2.0。</p>
</blockquote>
<p>SAML 同时适用于认证 (Authentication / AuthN) 与授权 (Authroization / AuthZ)。考虑到部分同学可能傻傻分不清，这里就浅显地再解释下：</p>
<ul>
<li>认证 (AuthN) 指的是识别用户身份，与「登录」属于同一个领域，认证失败常用 HTTP 状态码 401 表示。</li>
<li>授权 (AuthZ) 指的是已经识别用户身份的情况下，判断此人具有哪些权限，授权失败常用 HTTP 状态码 403 表示。</li>
</ul>
<p>授权通常基于认证，但是广义来说未登录（认证）的用户也可以有权限，例如「游客」角色。</p>
<p>作为古董协议，SAML 使用 XML 格式来传输数据。</p>
<h2>术语</h2>
<p>作为一个古老的协议，它使用的许多术语与当今技术生态格格不入，但实际上都有类似的平替概念，这里快速过一下以免下文看的一脸懵逼。</p>
<blockquote>
<p>为了便于理解，我们假设一个场景：「用户使用公司邮箱登录 Zoom，而他们公司使用 Auth0 管理账号」</p>
</blockquote>
<p>SAML 牵扯 3 个实体（参与方），它们分别是：</p>
<ul>
<li><strong>User Agent</strong>: 也许你对这个单词很熟悉，的确，大部分情况下指的就是浏览器。确切的说是用户使用的客户端程序。</li>
<li><strong>SP</strong>: 全称 Service Provider，即用户想访问（登录） 的系统。</li>
<li><strong>IDP</strong>: 全称 Identify Provider，即提供认证的服务，通常也是账号储存的地方。</li>
</ul>
<p>在上面假设的场景中，Zoom 客户端（或网页）是 User Agent，Zoom 是 SP，Auth0 是 IDP。需要注意的是 SP 一般特指后端程序，而 UA 指的是用户使用的前端。</p>
<p>这三个实体之间的通信涉及以下名词：</p>
<ul>
<li><strong>SAML Assertion</strong>: 平替为 JWT Token，即按照固定格式储存了用户身份数据的 XML 字符串，配有相应的签名防止篡改或伪造。</li>
<li><strong>SAML Metadata</strong>: SP 与 IDP 配置具体通信协议/数据格式的配置文件，包含了一方所需的全部配置项。通常以 XML 文件，或一个返回文件内容的 URL 的形式提供。也可以手动配置无需 Metadata 文件。</li>
<li><strong>Bindings</strong>: 指明 IDP 与 SP 之间如何传输数据（发送请求）。</li>
<li><strong>ACS</strong>: 全称 Assertion Consumer Service，指 SP 接受 SAML Accertion 的 endpoint（一个 URL）。用户登录后 IDP 得把结果告诉 SP 吧，就通过这个。</li>
</ul>
<h2>流程</h2>
<p>启动 SAML 认证（登录）流程有两种方式：</p>
<ul>
<li>SP-INIT</li>
<li>IDP-INIT</li>
</ul>
<h3>SP-INIT</h3>
<p>顾名思义，此方式中由 SP 发起登录请求。在上文假设的场景中，就是用户直接访问 Zoom，然后从 Zoom 跳转到公司网站（很可能是 Auth0 托管的）来登录。</p>
<p>具体流程如下：</p>
<ol>
<li>用户访问 SP。（用户访问 Zoom）</li>
<li>SP 发现用户未登录，将其重定向到 IDP 。通常 SP 会提供多种登录方式，只有用户选择 SSO 时 SP 才会重定向到对应的 IDP。（用户在 Zoom 点击 SSO 跳转到公司登录页）</li>
<li>用户在 IDP 提供的网页中完成登录，IDP 生成已签名的断言 (Assertion)，将用户重定向回 SP。</li>
<li>SP 读取断言，校验签名，取得用户数据，视为已登录。</li>
</ol>
<h3>IDP-INIT</h3>
<p>与 SP-INIT 相反，IDP-INIT 中由 IDP 主动发起登录，然后跳到 SP。这种情况不多见，仅有的用例是企业可能会提供一个面板（书签），里面有他们所购买的所有外部服务的入口。员工从这里点进去直接就是已登录的状态。</p>
<p>流程如下：</p>
<ol>
<li>用户访问 IDP。（用户访问公司内网）</li>
<li>完成登录后 IDP 生成已签名的断言 (Assertion)。</li>
<li>用户被重定向到 SP，SP 读取并验证断言，视为用户已登录。</li>
</ol>
<p>注意不是所有的 SP 都接受 IDP-INIT。</p>
<h2>Metadata 配置文件</h2>
<p>一个典型的 IDP 提供给 SP 的配置文件如下（省略了命名空间等）：</p>
<pre><code>&lt;md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://sso.example.com/saml2/sp/xxxxx"&gt;
  &lt;md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"&gt;
    &lt;md:KeyDescriptor use="signing"&gt;
      &lt;ds:KeyInfo&gt;
        &lt;ds:X509Data&gt;
          &lt;ds:X509Certificate&gt;xxxxx&lt;/ds:X509Certificate&gt;
        &lt;/ds:X509Data&gt;
      &lt;/ds:KeyInfo&gt;
    &lt;/md:KeyDescriptor&gt;
    &lt;md:NameIDFormat&gt;urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified&lt;/md:NameIDFormat&gt;
    &lt;md:NameIDFormat&gt;urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress&lt;/md:NameIDFormat&gt;
    &lt;md:NameIDFormat&gt;urn:oasis:names:tc:SAML:2.0:nameid-format:persistent&lt;/md:NameIDFormat&gt;
    &lt;md:NameIDFormat&gt;urn:oasis:names:tc:SAML:2.0:nameid-format:transient&lt;/md:NameIDFormat&gt;
    &lt;md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.example.com/saml2/sp/xxxxx/sso"/&gt;
    &lt;md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.example.com/saml2/sp/xxxxx/sso"/&gt;
  &lt;/md:IDPSSODescriptor&gt;
&lt;/md:EntityDescriptor&gt;
</code></pre>
<p>顶级元素 <code>md:EntityDescriptor</code> 定义了一个 SAML 实体，在这个具体的例子中是一个 IDP。其属性 <code>entityID</code> 是此实体的唯一标识符，可以为任意字符串，但为了避免冲突通常都是一个 URL。</p>
<p>次级元素 <code>IDPSSODescriptor</code> 定义了 SSO 相关的信息。虽然理论上 SAML 不仅仅用于 SSO，但事实上这几乎是 SAML 唯一的用例（要不是被逼的，谁会用如此古老反人类的协议呢）。</p>
<p>元素 <code>KeyDescriptor</code> 包含了此实体的公钥（一般是 <code>X.509</code> 格式的，就是最常见的那个，HTTPS 用的那种格式啦）。可能有多个 <code>KeyDescriptor</code> 元素，通过属性 <code>use</code> 指明用途，例如 <code>signing</code> 或 <code>encryption</code>。</p>
<p>元素 <code>NameIDFormat</code> 指明了IDP 能提供的 NameID 格式（若是 SP 生成给 IDP 的 metadata 则表示 SP 希望得到的 NameID 格式）。常见的有（省略前缀）：</p>
<ul>
<li>
<p><code>unspecified</code>: 由 IDP 自行决定（可能是任意字符串）。</p>
</li>
<li>
<p><code>emailAddress</code>: 电子邮箱。</p>
</li>
<li>
<p><code>persistent</code>: 永久 ID，即每次登录都不变，可用于映射 SP 本地用户。</p>
</li>
<li>
<p><code>transient</code>: 临时 ID，同一个用户下次登录可能就变了。</p>
</li>
</ul>
<p>元素 <code>SingleSignOnService</code> 指明了实现单点登录的信息，通过 <code>Binding</code> 和 <code>Location</code> 属性指示 SP 应以何种方式请求哪个地址。常见 Binding 类型如下（省略前缀）：</p>
<ul>
<li><code>HTTP-Redirect</code>: 多用于 SP 主动请求认证。说人话就是把用户重定向到这个地址（附带一些参数，比如编码后的 <code>SAMLRequest</code>），他们就可以登录了。</li>
<li><code>HTTP-POST</code>: 用于发送一些较大的数据。（HTTP GET URL 长度有限，用 POST 可解决）</li>
</ul>
<h2>实例</h2>
<p>这里演示一个 SP-INIT 实现登录的完整请求流程。简单起见，具体的 SSO 实现由 aws cognito 代劳。</p>
<h3>重定向到 IDP</h3>
<p>首先我们的业务系统（也就是 SP）发现用户未登录，给出几个登录选项，当用户选择某家 SSO 登录时，跳转到对应的 IDP。这里的目标 URL 形如 <code>https://sso.example.com/saml2/sp/xxxxx/sso?SAMLRequest=xxxx&amp;RelayState=yyyy&amp;SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&amp;Signature=ssss</code>。</p>
<p>稍微有点乱，我们拆解一下（注意查询参数部分都经过了 URL 编码）。</p>
<h4>Host 与 Path</h4>
<p><code>https://sso.example.com/saml2/sp/xxxxx/sso</code> 与 IDP 提供的 metadata 中 <code>Binding</code> 相关配置一致。此处也进一步说明了 Binding 的意义与作用：即指明如何传输数据。</p>
<h4>SAMLRequest</h4>
<p>这个查询参数的值是 XML 格式的 SAML 请求数据，但经过了 zip 压缩 + base64 编码。<a href="https://www.samltool.com/decode.php">还原</a>后如下：</p>
<pre><code>&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;saml2p:AuthnRequest 
    AssertionConsumerServiceURL="https://myserver.com/saml2/idpresponse" 
    Destination="https://sso.example.com/saml2/sp/xxxxx/sso" 
    ID="_6bd701a4-f3dc-46fc-899a-003a2782cbea"
    IssueInstant="2024-05-18T18:05:39.843Z"
    Version="2.0"
    xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"
&gt;
    &lt;saml2:Issuer
        Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity"
        xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
    &gt;
        urn:amazon:cognito:sp:xxxx
    &lt;/saml2:Issuer&gt;
    &lt;saml2p:NameIDPolicy
        AllowCreate="false"
        Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
    /&gt;
&lt;/saml2p:AuthnRequest&gt;
</code></pre>
<p>直白解释下，这个请求包含了这些信息：</p>
<ul>
<li>请求谁 (<code>Destination</code>)：一般就是 IDP 提供的 URL。</li>
<li>响应给谁 (<code>AssertionConsumerServiceURL</code>)：一般是 SP 专门接收 SAML 响应的 URL。</li>
<li>谁发出的请求 (<code>saml2:Issuer</code>)：这里是 aws cognito，如果是自己实现的 SAML 客户端可以自定义此字段。</li>
<li>希望得到的用户名类型 (<code>saml2p:NameIDPolicy</code>)：注意这里只是建议，IDP 有最终决定权。</li>
</ul>
<h4>RelayState</h4>
<p>这个参数在 SAML 层面没有意义，IDP 不会处理这个参数，但会在给 SP 的响应（回调）中原样带回。设计目的与它名字一样，是方便 SP 保留状态。包括 OAuth 在内的其他协议也有类似的设计。</p>
<h4>签名相关</h4>
<p><code>SigAlg</code> 指明签名使用的算法。<code>Signature</code> 是真正的签名，由请求发送者（此处是 SP）使用自己的私钥对 <code>SAMLRequest</code> 签名。如果接收方（此处是 IDP）想验证发送者的身份，需要预先配置对方的公钥。</p>
<h3>返回 SP</h3>
<p>用户在 IDP 处完成登录后 IDP 签署 SAML 响应并回调 SP，也就是 ACS，通常以 HTTP POST 形式完成。</p>
<p>在这个实例中，从浏览器的开发者工具可以看到 <code>https://myserver.com/saml2/idpresponse</code> 的 POST 请求，请求体是 <code>application/x-www-form-urlencoded</code> 格式的数据，包括两个字段。其中一个是 <code>RelayState</code>，前面已经说过了，就是原样回带 SP 请求时的值。</p>
<p>重点看 <code>SAMLResponse</code>，因为这次是放在 POST 请求体中的，没有长度与字符限制，所以一般就不压缩了，直接 base64 解码可得原文：</p>
<pre><code>&lt;samlp:Response&gt;
    &lt;saml:Issuer&gt;...&lt;/saml:Issuer&gt;
    &lt;ds:Signature&gt;...&lt;/ds:Signature&gt;
    &lt;samlp:Status&gt;
        &lt;samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/&gt;
    &lt;/samlp:Status&gt;
    &lt;saml:Assertion IssueInstant="2024-05-18T18:05:41Z" Version="2.0"&gt;
        &lt;saml:Issuer&gt;...&lt;/saml:Issuer&gt;
        &lt;ds:Signature&gt;...&lt;/ds:Signature&gt;
        &lt;saml:Subject&gt;
            &lt;saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"&gt;example@example.org&lt;/saml:NameID&gt;
            ...
        &lt;/saml:Subject&gt;
        &lt;saml:Conditions NotBefore="2024-05-18T18:05:11Z" NotOnOrAfter="2024-05-18T18:10:41Z"&gt;
            &lt;saml:AudienceRestriction&gt;
                &lt;saml:Audience&gt;urn:amazon:cognito:sp:xxx&lt;/saml:Audience&gt;
            &lt;/saml:AudienceRestriction&gt;
        &lt;/saml:Conditions&gt;
        &lt;saml:AuthnStatement&gt;...&lt;/saml:AuthnStatement&gt;
        &lt;saml:AttributeStatement&gt;
            &lt;saml:Attribute
                Name="app.email"
                NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"
                &gt;
                &lt;saml:AttributeValue xsi:type="xs:string"&gt;example@example.org&lt;/saml:AttributeValue&gt;
            &lt;/saml:Attribute&gt;
        &lt;/saml:AttributeStatement&gt;
    &lt;/saml:Assertion&gt;
&lt;/samlp:Response&gt;
</code></pre>
<p>作为古老的企业协议，响应有点复杂和冗余也可以理解对吧。不过需要重点关注的也就那么几部分：</p>
<ul>
<li>
<p><strong>签名</strong>：<code>ds:Signature</code> 保存了 IDP 的签名信息，SP 应该使用预先配置的 IDP 公钥校验签名的正确性，防止有人伪造 SAML Response。</p>
</li>
<li>
<p><strong>Subject</strong>：<code>saml:NameID</code> 标签包含了用户的唯一 ID 以及其格式（类型）。SP 通常应该通过这个字段将外部用户与自己的用户数据库建立关联。</p>
</li>
<li>
<p><strong>Conditions</strong>：这里列出了当前 SAML 断言的生效前提，一般是限制有效期，也有可能限制接收者。不满足条件的断言应当被视为无效，防止可能的安全问题。</p>
</li>
<li>
<p><strong>AttributeStatement</strong>：IDP 额外提供的用户信息字段（通常需要告知 IDP 维护人员手动配置）。</p>
</li>
</ul>
<p>SP 收到这个请求后一般应该返回一个 HTTP 302，将用户重定回到自己的页面。至此，从用户视角看，完成了整个登录流程：</p>
<ol>
<li>从 SP 跳转到 IDP</li>
<li>完成登录</li>
<li>跳转回 SP</li>
</ol>
<p>从服务器视角，SP 利用 HTTP GET 向 IDP 发送了登录请求，IDP 通过 HTTP POST 将 SAML 断言传递给 SP。</p>
<h2>SAML 与 OAuth 的异同</h2>
<p>显然 SAML 与 OAuth 都可以实现第三方登录，但细节上有许多区别，也正是这些区别导致了 SAML 成为企业级 SSO 的首选协议，而 OAuth 则霸占了个人账户市场。</p>
<blockquote>
<p>为了方便表述，此处统一使用 SAML 中术语。</p>
</blockquote>
<table>
<thead>
<tr>
<th></th>
<th>SAML</th>
<th>OAuth</th>
</tr>
</thead>
<tbody>
<tr>
<td>信任关系</td>
<td>服务商与组织</td>
<td>服务商与最终用户</td>
</tr>
<tr>
<td>交互性</td>
<td>弱，仅用于登录</td>
<td>强，双向交互</td>
</tr>
<tr>
<td>单点注销</td>
<td>支持</td>
<td>不支持</td>
</tr>
</tbody>
</table>
<h3>信任关系</h3>
<p>SAML 中信任关系在 SP 与 IDP 之间直接建立，最终用户不需要关心 SP 是否值得信任。说人话就是，如果一个服务商 A 需要通过 SAML 接入其客户 B 的系统，那么就需要这个 A 的负责人与 B 的负责人线下交流，两家公司（组织）级别信任后交换配置，完成 SSO 接入。B 的员工通过工作账号登录 A 时无需关心 A 是否可信，B 的系统通常也不会询问 B 是否允许将其账户信息共享给 A。</p>
<p>OAuth 则相反，通常 IDP 不会严格审查 SP 的资质。用户通过 OAuth 登录服务商 A 时，ISP 会询问用户是否允许 A 获取其账号信息。一个典型例子是任何人都可以轻松在 GitHub 注册 OAuth 客户端，从而让自己的系统可以支持 GitHub 登录。显然 GitHub 并不为这个系统做任何担保，一切操作都需要用户授权，同时 GitHub 也会提醒用户登录时确认 A 是否可信。</p>
<p><strong>说白了 SAML 中账号被视为公司资产，决定权属于 IDP。OAuth 更强调账号的私密性，决定权在用户。</strong></p>
<h3>交互性</h3>
<p>在实践上，SAML 更多的是单一的认证。即 IDP 把用户信息发送给 SP 就完事了，SP 后续基本不会再与 IDP 沟通。</p>
<p>OAuth 则更注重交互性，登录只是第一步，此时 SP 可获得 IDP 颁发的令牌。通过这个令牌 SP 可以随时请求 IDP，例如获取更多数据，以用户的名义执行一些操作（比如发帖等）。</p>
<p>也正是因为 OAuth 的交互性更丰富，它的认证流程还包括了鉴权，SP 请求时需要明确申请权限（例如是只获取基本信息，还是需要以用户名义发帖），用户登录时可对不同的 SP 授予不同的权限。</p>
<h3>单点注销</h3>
<p>经过配置 SAML 可实现单点注销。例如公司 B 的用户通过 SAML 登录了 A 服务。若此用户在 A 处注销登录，那么他在公司网站上也退出登录了。尽管实践上大部分系统不会配置单点注销，但 SAML 提供了这项能力。</p>
<p>OAuth 原生不支持注销功能。这一点我们应该深有体会，例如通过微信登录美团后，退出美团不会影响微信的登录状态。</p>
<h2>常见问题</h2>
<p><strong>IDP 一定是储存账号数据的地方吗？</strong></p>
<p>不一定。IDP 其实只是提供 SAML 服务而已，IDP 程序可能从其他位置，通过其他协议读取账号数据。互联网协议不就是不断套娃嘛。但这些对 SP 来说是透明的，SP 只管和 IDP 交互就行了。</p>
<p><strong>SP 通过 SAML 接入 SSO 后，自己的用户数据库扮演什么角色？</strong></p>
<p>具体要看 SP 的系统设计。通常来讲大部分 SP 会把从 IDP 得到的用户数据映射到一个本地用户（即 SP 自己的用户），毕竟还是和自己的数据库交互舒服呀。至于映射的逻辑，以及本地用户不存在时的行为，就看业务需求了。但是注意，大部分情况下通过 IDP 映射的本地用户应该与真正的本地账号隔离——不应该允许从 SAML SSO 以外的渠道登录。</p>
<h2>附录</h2>
<h3>IDP Metadata</h3>
<pre><code>&lt;md:EntityDescriptor xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:xenc="http://www.w3.org/2001/04/xmlenc#" xmlns:xenc11="http://www.w3.org/2009/xmlenc11#" entityID="https://sso-2781261d.sso.duosecurity.com/saml2/sp/DIY0T3WG6QRVD2U1SI1F/metadata"&gt;
  &lt;md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"&gt;
    &lt;md:KeyDescriptor use="signing"&gt;
      &lt;ds:KeyInfo&gt;
        &lt;ds:X509Data&gt;
          &lt;ds:X509Certificate&gt;MIIDDTCCAfWgAwIBAgIUP1HDN7PUbmwWMl+nPxKrvWaYhHkwDQYJKoZIhvcNAQEL
BQAwNjEVMBMGA1UECgwMRHVvIFNlY3VyaXR5MR0wGwYDVQQDDBRESVkwVDNXRzZR
UlZEMlUxU0kxRjAeFw0yNDA0MjMxMDUzMjdaFw0zODAxMTkwMzE0MDdaMDYxFTAT
BgNVBAoMDER1byBTZWN1cml0eTEdMBsGA1UEAwwURElZMFQzV0c2UVJWRDJVMVNJ
....
kWgMs7M565vs6vUmOiQWvwA=
&lt;/ds:X509Certificate&gt;
        &lt;/ds:X509Data&gt;
      &lt;/ds:KeyInfo&gt;
    &lt;/md:KeyDescriptor&gt;
    &lt;md:NameIDFormat&gt;urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified&lt;/md:NameIDFormat&gt;
    &lt;md:NameIDFormat&gt;urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress&lt;/md:NameIDFormat&gt;
    &lt;md:NameIDFormat&gt;urn:oasis:names:tc:SAML:2.0:nameid-format:persistent&lt;/md:NameIDFormat&gt;
    &lt;md:NameIDFormat&gt;urn:oasis:names:tc:SAML:2.0:nameid-format:transient&lt;/md:NameIDFormat&gt;
    &lt;md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://example.sso.duosecurity.com/saml2/sp/DIY0T3WG6QRVD2U1SI1F/sso"/&gt;
    &lt;md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://example.sso.duosecurity.com/saml2/sp/DIY0T3WG6QRVD2U1SI1F/sso"/&gt;
  &lt;/md:IDPSSODescriptor&gt;
&lt;/md:EntityDescriptor&gt;
</code></pre>
<h3>SAML Request</h3>
<pre><code>&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;saml2p:AuthnRequest AssertionConsumerServiceURL="https://example.com/saml2/idpresponse" Destination="https://example.sso.duosecurity.com/saml2/sp/DIY0T3WG6QRVD2U1SI1F/sso" ID="_6bd701a4-f3dc-46fc-899a-003a2782cbea" IssueInstant="2024-05-18T18:05:39.843Z" Version="2.0"
    xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"&gt;
    &lt;saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity"
        xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"&gt;urn:amazon:cognito:sp:eu-west-1_xxx
    &lt;/saml2:Issuer&gt;
    &lt;saml2p:NameIDPolicy AllowCreate="false" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"/&gt;
&lt;/saml2p:AuthnRequest&gt;
</code></pre>
<h3>SAML Response</h3>
<pre><code>&lt;samlp:Response
    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
    xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
    xmlns:xs="http://www.w3.org/2001/XMLSchema"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
    xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
    xmlns:xenc="http://www.w3.org/2001/04/xmlenc#"
    xmlns:xenc11="http://www.w3.org/2009/xmlenc11#" Version="2.0" ID="DUO_a4fdf2812dee053117dd6d9d71a7bb94bed80394" IssueInstant="2024-05-18T18:05:41Z" Destination="https://example.com/saml2/idpresponse" InResponseTo="_6bd701a4-f3dc-46fc-899a-003a2782cbea"&gt;
    &lt;saml:Issuer&gt;https://example.sso.duosecurity.com/saml2/sp/DIY0T3WG6QRVD2U1SI1F/metadata&lt;/saml:Issuer&gt;
    &lt;ds:Signature&gt;
        &lt;ds:SignedInfo&gt;
            &lt;ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/&gt;
            &lt;ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/&gt;
            &lt;ds:Reference URI="#DUO_a4fdf2812dee053117dd6d9d71a7bb94bed80394"&gt;
                &lt;ds:Transforms&gt;
                    &lt;ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/&gt;
                    &lt;ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/&gt;
                &lt;/ds:Transforms&gt;
                &lt;ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/&gt;
                &lt;ds:DigestValue&gt;+cg2uZOb0Y4qaif83LOdCpcBSAXGOjGgqJL8Gr5qZNQ=&lt;/ds:DigestValue&gt;
            &lt;/ds:Reference&gt;
        &lt;/ds:SignedInfo&gt;
        &lt;ds:SignatureValue&gt;xxxxx==&lt;/ds:SignatureValue&gt;
        &lt;ds:KeyInfo&gt;
            &lt;ds:X509Data&gt;
                &lt;ds:X509Certificate&gt;MIIDDTCCAfWgAwIBAgIUP1HDN7PUbmwWMl+nPxKrvWaYhHkwDQYJKoZIhvcNAQELBQAwNjEVMBMGA1UECgwMRHVvIFNlY3VyaXR5MR0wGwYDVQQDDBRESVkwVDNXRzZRUlZEMlUxU0kxRjAeFw0yNDA0MjMxMDUzMjdaFw0zODAxMTkwMzE0MDdaMDYxFTATBgNVBAoMDER1byBTZWN1cml0eTEdMBsGA1UEAwwURElZMFQzV0c2UVJWRDJVMVNJMUYwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCzVye7WgL2ntwoDt3aPp+0VNGLWDve1KQAPc6u+qAsMqszTULudNHeEUDcBru8EQjahN4K7ID5CstOKmuy70Ob5grzC+bldQcYpcYhNqEfy/5Ycv91U6jeCqWEiNuBxxWlA63rVdfWJ3TF6Rh52Phd76f008afN9GZAsgajik/7jxakofHHDSNGZTnvv65kJz+YglE8byEswKuzPv6oioaVGgF5oI9yRoFi6vSA1o/t74nBfd4BzV8AWUD3065D6mHYOSABFimlnNqRno2TT43PHsgszpJUE/RxxstDlp13FfXdQMSxJj5E5Zm2tFZa13h0xIKehuXGbAqHpC0Q7uZAgMBAAGjEzARMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAExG2xlV9iubyV0fl0SbsEBGO56Jel+QabvX3TOy5joKBp0kw8MN8YjWxAV6Er5QTi/qXJf9ckwu9nGnmX1bSmkwZ9Ep0VgslTVVsZ9h1HQt/VcT9bxSTFeh0KMvktQ+RlewOjCJSTfnOOTrGYgzdfVVZXvL+xUkey/DEAmLkg+ilBSoxy8hOHIq/OG1l15PuNOf5UmVOpH4HJXvexGMrpRDeXNpIb/Dfv4RlCIqStPdty1CFpoZBaaMasoOk3Rrp5g5zzysWwrHMYr0ZWNQAzQZ6OSWfsKo5qssY8Rpxbl/WNk/zpAO95ClgccpSGAdkWgMs7M565vs6vUmOiQWvwA=&lt;/ds:X509Certificate&gt;
            &lt;/ds:X509Data&gt;
        &lt;/ds:KeyInfo&gt;
    &lt;/ds:Signature&gt;
    &lt;samlp:Status&gt;
        &lt;samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/&gt;
    &lt;/samlp:Status&gt;
    &lt;saml:Assertion ID="DUO_8af9692e4fdf32993dfacd15286a22c85c352c43" IssueInstant="2024-05-18T18:05:41Z" Version="2.0"&gt;
        &lt;saml:Issuer&gt;https://sso-2781261d.sso.duosecurity.com/saml2/sp/DIY0T3WG6QRVD2U1SI1F/metadata&lt;/saml:Issuer&gt;
        &lt;ds:Signature&gt;
            &lt;ds:SignedInfo&gt;
                &lt;ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/&gt;
                &lt;ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/&gt;
                &lt;ds:Reference URI="#DUO_8af9692e4fdf32993dfacd15286a22c85c352c43"&gt;
                    &lt;ds:Transforms&gt;
                        &lt;ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/&gt;
                        &lt;ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/&gt;
                    &lt;/ds:Transforms&gt;
                    &lt;ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/&gt;
                    &lt;ds:DigestValue&gt;cXP/SDoDRZG4CaAqqHcVq8+Canpa/3PknTJTMT+PmtA=&lt;/ds:DigestValue&gt;
                &lt;/ds:Reference&gt;
            &lt;/ds:SignedInfo&gt;
            &lt;ds:SignatureValue&gt;xxxxxx==&lt;/ds:SignatureValue&gt;
            &lt;ds:KeyInfo&gt;
                &lt;ds:X509Data&gt;
                    &lt;ds:X509Certificate&gt;MIIDDTCCAfWgAwIBAgIUP1HDN7PUbmwWMl+nPxKrv...5ClgccpSGAdkWgMs7M565vs6vUmOiQWvwA=&lt;/ds:X509Certificate&gt;
                &lt;/ds:X509Data&gt;
            &lt;/ds:KeyInfo&gt;
        &lt;/ds:Signature&gt;
        &lt;saml:Subject&gt;
            &lt;saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"&gt;example@gmail.com&lt;/saml:NameID&gt;
            &lt;saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"&gt;
                &lt;saml:SubjectConfirmationData NotOnOrAfter="2024-05-18T18:10:41Z" Recipient="https://example.com/saml2/idpresponse" InResponseTo="_6bd701a4-f3dc-46fc-899a-003a2782cbea"/&gt;
            &lt;/saml:SubjectConfirmation&gt;
        &lt;/saml:Subject&gt;
        &lt;saml:Conditions NotBefore="2024-05-18T18:05:11Z" NotOnOrAfter="2024-05-18T18:10:41Z"&gt;
            &lt;saml:AudienceRestriction&gt;
                &lt;saml:Audience&gt;urn:amazon:cognito:sp:eu-west-1_xxx&lt;/saml:Audience&gt;
            &lt;/saml:AudienceRestriction&gt;
        &lt;/saml:Conditions&gt;
        &lt;saml:AuthnStatement AuthnInstant="2024-05-18T18:05:41Z" SessionIndex="DUO_8af9692e4fdf32993dfacd15286a22c85c352c43"&gt;
            &lt;saml:AuthnContext&gt;
                &lt;saml:AuthnContextClassRef&gt;urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport&lt;/saml:AuthnContextClassRef&gt;
            &lt;/saml:AuthnContext&gt;
        &lt;/saml:AuthnStatement&gt;
        &lt;saml:AttributeStatement&gt;
            &lt;saml:Attribute Name="app.email" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"&gt;
                &lt;saml:AttributeValue xsi:type="xs:string"&gt;example@gmail.com&lt;/saml:AttributeValue&gt;
            &lt;/saml:Attribute&gt;
        &lt;/saml:AttributeStatement&gt;
    &lt;/saml:Assertion&gt;
&lt;/samlp:Response&gt;
</code></pre>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2024-05-18T22:06:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[OpenWrt 开启 IPv6 公网访问全指南]]></title>
        <id>https://chenhe.me/zh/posts/openwrt-config-ipv6-public-access/</id>
        <link href="https://chenhe.me/zh/posts/openwrt-config-ipv6-public-access/"/>
        <updated>2024-02-10T16:32:00.000Z</updated>
        <summary type="html"><![CDATA[阅读本文要求具有基础网络知识，例如知道什么是网络掩码、路由、DHCP 等。当前环境：江苏电信，光猫为桥接模式。x86 软路由，安装 iSt...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>阅读本文要求具有基础网络知识，例如知道什么是网络掩码、路由、DHCP 等。</p>
<p>当前环境：</p>
<ul>
<li>江苏电信，光猫为桥接模式。</li>
<li>x86 软路由，安装 <a href="https://www.istoreos.com/">iStoreOS</a>。</li>
</ul>
</blockquote>
<h2>基础概念</h2>
<h3>什么是 IPv6</h3>
<p>IPv6 与 v4 不同。v4 时代运营商一般只给用户分配最多 1 个公网 ip，然后用户自己的路由器通过 NAT 再给局域网设备分配内网 IP，也就是 <code>192.168.x.x</code> 这种。这种情况下内网设备没有独立的公网 IP，要想从公网访问必须配置路由器端口转发。随着 v4 资源枯竭，现在运营商默认已经不再分配公网 IP 了。要想正常从外部访问，必须做内网穿透。</p>
<p>IPv6 有无数个地址可供分配，“可以给地球上的每一粒沙子都分配一个 IP 地址”。因此运营商分配的策略也会变化。v4 分配的是一个地址，而 v6 分配的是一个前缀，也就是所谓的 <code>pd</code>，相当于是一整个网段，我们可以自己继续往下分配，从而使得每一个局域网设备都能获得公网 IP 地址，甚至可以划分自己的多层子网。</p>
<p>典型的 IPv6 地址由 8 组十六进制数字表示，一共有 128 bits (16B)。</p>
<pre><code>|----- 网络号 ------| 子网号|--------- 主机号 ---------| 前缀长度|
0123 : 4567 : 89ab : cdef : 0123 : 4567 : 89ab : cdef /64
</code></pre>
<p>通常习惯上只有子网号是我们可以自行往下划分的部分，即前缀长度应该在 49~64 范围内。</p>
<p>IPv6 每组的前缀 0 可以省略，多组连续的 0 也可以省略，但要用 <code>::</code> 表示，例如下面两种写法等价：</p>
<pre><code>204e:0000:0000:0000:0000:0000:0000:1
204e::1
</code></pre>
<h3>Scope</h3>
<p>与 v4 不同，一个接口可以同时具有多个 v6 的 IP 地址，并且多数情况下都会超过一个。因为 v6 地址分为不同的 Scope，也就是说有效范围不同，常见的（不是全部）包括：</p>
<ul>
<li><strong>Global</strong>：全局地址，全局可路由，相当于「公网 IP」。</li>
<li><strong>Unique Local</strong>：只在网关内部使用，相当于 <code>192.168.x.x</code> 之类的局域网地址。多个子网可以通过此类地址互相访问（通过路由器）。</li>
<li><strong>Link Local</strong>：每个接口自动生成的链路地址，永远不被路由。只在本地链路（冲突域）中使用。</li>
</ul>
<p>其中只有「Link Local」在 IPv4 中没有明确对应，因为它的存在主要为了解决 IPv6 特有的一个问题：一个接口有多个地址，那么建立路由时很可能学习到重复的下一跳，所以需要一个唯一标识来区分设备，这就是 Link Local 地址。</p>
<h2>拨号获取 IPv6</h2>
<p>进入 OpenWrt 后台「网络-接口」，编辑 <code>wan</code> 接口（通常都是这个名字），修改这些选项：</p>
<ul>
<li><strong>获取 IPv6 地址</strong>：自动</li>
<li><strong>委托 IPv6 前缀</strong>：勾选</li>
</ul>
<p><code>wan</code> 口的「DHCP 服务器 - IPv6 设置」：</p>
<ul>
<li><strong>指定的主接口</strong>：不勾选</li>
<li><strong>RA 服务</strong>：禁用</li>
<li><strong>DHCPv6 服务</strong>：禁用</li>
<li><strong>NDP 代理</strong>：禁用</li>
</ul>
<p>保存应用后通常会多出一个名为 <code>wan_6</code> 的虚拟动态接口，因为大部分营运商是通过 DHCPv6 下发地址的，而我们之前选择了「自动」，OpenWrt 识别到之后就会新建一个客户端。若运营商支持现在应该就能看到获取的前缀了（PD）：</p>
<p><img src="https://img.chenhe.cc/i/2024/02/10/65c75af86cbcf.webp" alt="获取的 PD" /></p>
<blockquote>
<p>注意，若只有 <code>fe80::</code> 开头的地址则说明未获取到 IPv6，这个只是自动生成的链路地址而已。</p>
</blockquote>
<h2>分配 IPv6</h2>
<p>路由器获得了一个网段，下面要做的就是给每一个设备都分配一个公网地址。有两种方案，可以单独选择也可以同时使用，分别是 SLAAC 与 DHCPv6。</p>
<h3>SLAAC</h3>
<p>SLAAC 是无状态地址自动配置协议，顾名思义，它不再需要 DHCP 服务器来维护状态，而是各个客户端自行生成、协商、通告地址。<strong>SLAAC 是唯一全平台支持的协议</strong>，<a href="https://issuetracker.google.com/issues/36949085?pli=1#comment374">Android 明确不会支持有状态 DHCPv6</a>，谷歌认为有状态协议对于终端用户没有明显优点，还会造成隐私问题，属于 IPv4 时代的陋习。</p>
<p>SLAAC 的一个重要数据是路由器定期发送的 RA（路由通告），其包含前缀信息，以及是否应该尝试通告 DHCPv6 请求地址。</p>
<p>要配置纯 SLAAC，需要进入 「<code>lan</code> 口的设置 - 高级设置」：</p>
<ul>
<li><strong>委托 IPv6 前缀</strong>：自选，决定到下级设备能否获得前缀（不影响 IPv6 地址本身的分配）。不懂可以勾上。</li>
<li><strong>IPv6 分配长度</strong>：启用委托前缀时决定分配下去的前缀长度，划分多个子网时需要，不懂可以填 64 或保持默认。</li>
<li><strong>IPv6 分配提示</strong>：保持默认就行，划分子网是可选用的。</li>
<li><strong>IPv6 后缀</strong>：设置<strong>当前接口</strong>的 IPv6 地址后缀，可以填写 <code>::1</code>，那么此接口的地址就类似 <code>240e:aaaa:bbbb:cccc::1</code>。</li>
</ul>
<p>另外还要配置「<code>lan</code> 口的设置 - DHCP 服务器 - IPv6 设置」：</p>
<ul>
<li><strong>指定的主接口</strong>：不勾选</li>
<li><strong>RA 服务</strong>：服务器模式</li>
<li><strong>DHCPv6 服务</strong>：禁用</li>
<li><strong>本地 IPV6 DNS 服务器</strong>：勾选</li>
<li><strong>NDP 代理</strong>：禁用</li>
</ul>
<p>对应地，修改 「IPv6 RA 设置」：</p>
<ul>
<li><strong>默认路由器</strong>：自动</li>
<li><strong>启用 SLAAC</strong>：勾选</li>
<li><strong>RA 标记</strong>：无，因为我们根本没有 DHCPv6 服务器。</li>
</ul>
<p>这样保存应用之后，应该所有的下属设备都可以生成公网 IPv6 地址了。</p>
<h3>DHCPv6</h3>
<blockquote>
<p><a href="https://issuetracker.google.com/issues/36949085?pli=1#comment374">Android 明确不会支持有状态 DHCPv6</a></p>
</blockquote>
<p>DHCPv6 本身也分为有状态和无状态两种：</p>
<ul>
<li>有状态：通过 DHCPv6 分配 IP。</li>
<li>无状态：IP 依然采用 SLAAC 生成（通过 RA），但其他参数，例如 DNS，网关地址等则通过 DHCPv6 获取。</li>
</ul>
<p>要启用 DHCPv6，「<code>lan</code> 口的设置 - 高级设置」与上文 SLAAC 配置一致，「<code>lan</code> 口的设置 - DHCP 服务器 - IPv6 设置」如下：</p>
<ul>
<li><strong>RA 服务</strong>：服务器模式</li>
<li><strong>DHCPv6 服务</strong>：服务器模式</li>
<li><strong>本地 IPV6 DNS 服务器</strong>：勾选</li>
<li><strong>NDP 代理</strong>：禁用，用于多级路由器之间转发邻居发现协议的流量。</li>
</ul>
<p>对应地， 「IPv6 RA 设置」应为：</p>
<ul>
<li><strong>启用 SLAAC</strong>：禁用</li>
<li><strong>RA 标记</strong>：M + O，表示通过 DHCPv6 获取 IP 与其他配置参数。</li>
</ul>
<blockquote>
<p>若希望配置为无状态 DHCPv6，则需要启用 SLAAC，并把 RA 标记设置为 O。即通过 SLAAC 生成 IP 但通过 DHCPv6 获取参数。</p>
</blockquote>
<h2>防火墙放行</h2>
<p>现在我们的每一个设备都有公网地址了，但要想被外部访问，需要防火墙放行才行。这里只说 OpenWrt 的防火墙，至于设备自己的（群晖 NAS，Windows 等）自行设置。</p>
<p>进入「网络 - 防火墙 - 通信规则」点击下面的添加按钮：</p>
<p><strong>【常规设置】</strong></p>
<ul>
<li><strong>协议</strong>：按需选择，注意 ping 使用的是 ICMP 协议，不是 TCP/UDP。</li>
<li><strong>源区域</strong>：wan</li>
<li><strong>目标区域</strong>：lan（如果要访问路由器自己则选择「设备」）</li>
<li><strong>目标端口</strong>：自行设置</li>
<li><strong>操作</strong>：接受</li>
</ul>
<p><strong>【高级设置】</strong></p>
<ul>
<li><strong>地址族限制</strong>：仅 IPv6</li>
</ul>
<p>保存一下就好。</p>
<blockquote>
<p>注意，这样配置防火墙实际上是允许以 IPv6 访问<strong>任意</strong>子网设备的指定端口。</p>
</blockquote>
<p>如果希望只放行特定的目标设备，可以指定 IP 后缀。因为运营商分配给我们的前缀是动态变化的，所以不能直接指定 IP，而后缀无论是使用 DHCPv6 还是 SLAAC（使用 eui64），<strong>经过配置</strong>都可以确保不变。然后添加防火墙规则时填写「目标地址」为 <code>::aaaa:bbbb:cccc:dddd/-64</code>，其中 <code>-64</code> 的意思是匹配从右往左的 64 位。若部分系统不支持这种缩写，可以回退到 IPv4 的掩码表示形式：<code>::aaaa:bbbb:cccc:dddd/::ffff:ffff:ffff:ffff</code>。</p>
<p><img src="https://img.chenhe.cc/i/2024/02/11/65c7a2dde9d7e.webp" alt="匹配地址后缀" /></p>
<h2>附录</h2>
<h3>常见前缀</h3>
<ul>
<li><code>240e::/20</code>: 中国电信</li>
<li><code>2409:8000::/20</code>: 中国移动</li>
<li><code>2408:8000::/20</code>: 中国联通</li>
<li><code>2000::/3</code>: 全局单播地址。也就是全球可路由的公网地址。上述三个都属于这个。</li>
<li><code>FE80::/10</code>: 链路本地地址。</li>
<li><code>2002::/16</code>: 仅供 6to4 隧道使用</li>
</ul>
<h3>特殊地址</h3>
<ul>
<li><code>::</code> - 相当于 <code>0.0.0.0</code></li>
<li><code>::1/128</code> - 本地回环地址，相当于 <code>127.0.0.1/32</code></li>
</ul>
<h3>在线小工具</h3>
<ul>
<li><a href="https://ipw.cn/ipv6ping/">IPv6 Ping</a></li>
<li><a href="https://ipv6proxy.cn/">使用 IPv6 打开网页</a></li>
</ul>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2024-02-10T16:32:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[OpenWrt 纯浏览器设置访问桥接模式下的光猫]]></title>
        <id>https://chenhe.me/zh/posts/config-openwrt-to-access-bridge-mode-modem/</id>
        <link href="https://chenhe.me/zh/posts/config-openwrt-to-access-bridge-mode-modem/"/>
        <updated>2024-02-10T06:51:00.000Z</updated>
        <summary type="html"><![CDATA[默认情况下，如果使用 OpenWrt 类原生系统的路由器拨号连接桥接模式的光猫，就无法访问光猫的管理页面了，带来很多不便。修复的方法有很多...]]></summary>
        <content type="html"><![CDATA[<p>默认情况下，如果使用 OpenWrt 类原生系统的路由器拨号连接桥接模式的光猫，就无法访问光猫的管理页面了，带来很多不便。修复的方法有很多，这里记录一个只需要浏览器的方法。</p>
<h2>Web UI 设置教程</h2>
<h3>新建接口</h3>
<p>在「网络-接口」处新建一个接口：</p>
<ul>
<li><strong>名称</strong>随意，比如「modem」。</li>
<li><strong>协议</strong>选择「静态地址」，因为桥接模式下光猫通常不会开启 DHCP 服务器。</li>
<li><strong>设备</strong>指定为 wan 口对应的设备。不知道的可以进入 wan 接口的设置，看看绑定哪个物理接口。</li>
</ul>
<p>如图：</p>
<p><img src="https://img.chenhe.cc/i/2024/02/10/65c71957ec848.webp" alt="新建接口" /></p>
<h3>设置 IP</h3>
<p>接着编辑刚刚新建的接口，「常规设置」中要填写三个东西：</p>
<ul>
<li><strong>IPv4 地址</strong>设置为与光猫同网段的任意地址，但不要与已有设备重复。例如光猫为 192.168.1.1，这里可设置为 192.168.1.199。</li>
<li><strong>IPv4 子网掩码</strong>顾名思义，通常家用局域网掩码都是 255.255.255.0。</li>
<li><strong>IPv4 网关</strong>填写光猫的地址，设备背后标签一般会有。</li>
</ul>
<h3>设置跃点</h3>
<p>然后还需要修改「高级设置-使用网关跃点」，填写一个比较大的数字，例如 100。如果不配置这个可能失去外网连接。</p>
<p><img src="https://img.chenhe.cc/i/2024/02/10/65c71b3878722.webp" alt="网关跃点" /></p>
<blockquote>
<p>若应用设置后无法连接公网，可以尝试：</p>
<ul>
<li>确认 modem 接口的「高级设置-使用网关跃点」以填写。</li>
<li>尝试填写 wan 接口的「高级设置-使用网关跃点」，并且确保比 modem 更小，例如 20。</li>
</ul>
</blockquote>
<h3>加入防火墙区域</h3>
<p>最后一定在接口的「防火墙」设置中加入 wan 对应的防火墙区域，否则只有路由器自己能访问光猫，其他设备不可以。</p>
<p><img src="https://img.chenhe.cc/i/2024/02/10/65c71b73833be.webp" alt="防火墙区域" /></p>
<p>一切设置好后不要忘记保存并应用。</p>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2024-02-10T06:51:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[PLEX 电视端无法切换大屏布局]]></title>
        <id>https://chenhe.me/zh/posts/plex-tv-installation/</id>
        <link href="https://chenhe.me/zh/posts/plex-tv-installation/"/>
        <updated>2024-02-06T05:44:00.000Z</updated>
        <summary type="html"><![CDATA[Plex 是一个商业的个人影院系统，类似的还有 Emby, Jellyfin 等。Plex 的服务端搭建这里不再赘述，个人使用 Docker...]]></summary>
        <content type="html"><![CDATA[<h2>简介</h2>
<p><a href="https://www.plex.tv/">Plex</a> 是一个商业的个人影院系统，类似的还有 Emby, Jellyfin 等。Plex 的服务端搭建这里不再赘述，个人使用 Docker 跑在群晖 DS720+ 上。</p>
<p>在电视端曾经主流的方法是使用 KODI + Plex 插件，但后者已经不怎么更新了，而且 Plex 作为商业系统多个平台的 App 做的很优秀，更新也很积极，没有理由不直接用官方客户端（反正我买了终身订阅）。</p>
<p>Plex 电视端不需要专用的版本，官方在 Google Play 发布的普通版本内置了大屏幕适配。对于国产电视或盒子，可以自己去 <a href="https://www.apkmirror.com/apk/plex-inc/plex/">APKMirror</a> 下载 APK 后手动安装。</p>
<h2>CPU 架构</h2>
<p>下载安装包的时候需要注意两点，首先是格式。「BUNDLE」是更先进的安装包格式，它可以动态加载资源，但在大陆水土不服，并且系统默认无法直接安装。<strong>所以一定要选择 APK 格式的。</strong></p>
<p><img src="https://img.chenhe.cc/i/2024/02/06/65c1c430f1c50.png" alt="" /></p>
<p>其次要注意的是 CPU 架构，架构不匹配的话会安装失败，或安装成功但打开闪退。常见的类型如下：</p>
<ul>
<li>universal：通用，不懂的下载这个准没错。</li>
<li>arm64-v8a：64 位格式，性能最好，但电视（盒子）支持的还不算太多。</li>
<li>armeabi-v7a：最常见的 arm 格式，一般不会有问题。</li>
<li>x86：常用于计算机，电视（盒子）很少见。</li>
</ul>
<p><strong>看不懂的话优先下载「universal」的，其次选择「armeabi-v7a」的。</strong></p>
<h2>切换电视模式</h2>
<p>目前（v10.8）Plex 在电视上打开后会主动提示切换大屏模式，尴尬的是无论怎么确认都没有效果，重启后循环提示。据说是因为 Plex 检测了系统参数，而国产电视（盒子）都是魔改手机系统，而不是正规的 Android TV，因此不被识别。</p>
<p>幸运的是老版本的 Plex 没有额外判断，可以先安装 <a href="https://www.apkmirror.com/apk/plex-inc/plex/plex-9-10-1-36024-release/">9.10.1 版本</a>（<a href="https://dl.chenhe.me/Software/PLEX">备用链接</a>），切换电视模式并登录后再更新到最新，设置会保留下来。</p>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2024-02-06T05:44:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[记一次sni导致的反代异常]]></title>
        <id>https://chenhe.me/zh/posts/resolving-sni-caused-nginx-problem/</id>
        <link href="https://chenhe.me/zh/posts/resolving-sni-caused-nginx-problem/"/>
        <updated>2024-01-06T16:14:37.000Z</updated>
        <summary type="html"><![CDATA[一直用 Authenlia 作为反代认证，给各种私有服务添加了统一登录。架构如下：当用户希望访问「内网服务#1」时：访问反代服务器（Ngin...]]></summary>
        <content type="html"><![CDATA[<h2>背景</h2>
<p>一直用 <a href="https://www.authelia.com/">Authenlia</a> 作为反代认证，给各种私有服务添加了统一登录。架构如下：</p>
<p><img src="https://img.chenhe.cc/i/2024/01/07/65998e293cc9e.png" alt="Authelia 架构" /></p>
<p>当用户希望访问「内网服务#1」时：</p>
<ol>
<li>访问反代服务器（Nginx）。</li>
<li>反代服务器向认证服务器查询是否已登录，若认证成功则继续。否则显示登录页面。</li>
<li>反代服务器访问内网服务取得资源。</li>
<li>反代服务器把结果返回给用户。</li>
</ol>
<p>其中反代服务器与内网服务器处于同一局域网内，可以使用 http 明文通信。但与认证服务器的交互需要 https 加密，这就是出问题的地方。</p>
<p>按照 Anthelia 的文档，在反代服务器这里有这么一段 Nginx 配置：</p>
<pre><code>set $upstream_authelia https://authelia.chenhe.me/api/verify;

## Virtual endpoint created by nginx to forward auth requests.
location /authelia {
    internal;
    proxy_pass $upstream_authelia;

    proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
		# others ...
}

auth_request /authelia;
</code></pre>
<p>显然是利用 <code>proxy_pass</code> 来请求认证服务器。</p>
<h2>故障表现</h2>
<p>一开始认证服务器是用 lnmp 一键安装包搭建的，一切正常。直到我换成了 <a href="https://1panel.cn/">1Panel</a> 面板，通过 Docker 安装的 OpenResty，所有反代服务器向认证服务器的请求都失败了。</p>
<p>从用户视角来说，访问任何反代服务器均得到 500 错误。查看 Nginx 的错误日志如下：</p>
<pre><code>2024/01/06 02:36:39 [error] 14970#14970: *2168 auth request unexpected status: 502 while sending to client, client: 192.168.32.1, server: router.nas-xz.chenhe.me, request: "GET / HTTP/2.0", host: "router.nas-xz.chenhe.me:xxxx"

2024/01/06 02:37:22 [error] 14970#14970: *2168 SSL_do_handshake() failed (SSL: error:14094438:SSL routines:ssl3_read_bytes:tlsv1 alert internal error:SSL alert number 80) while SSL handshaking to upstream, client: 192.168.32.1, server: router.nas-xz.chenhe.me, request: "GET / HTTP/2.0", subrequest: "/authelia", upstream: "https://43.xx.xx.xx:443/api/verify", host: "router.nas-xz.chenhe.me:xxxx"
</code></pre>
<p>显然是证书有问题导致上游请求返回 502，无法判断用户是否已登录进而触发 500。</p>
<p>折腾了很久，检查证书有效性，检查允许的算法套件，甚至尝试把 ED 的证书换回 RSA 都无济于事。原来故障原因是 Nginx 请求上游时默认不发送 SNI，它是个啥？</p>
<h2>初识 SNI</h2>
<p>在 http 时代，可以在一个物理主机（IP 地址上）部署多个网站，称为虚拟主机。服务器程序依靠 http 的 <code>Host</code> 头判断用户究竟想访问哪一个。接着 https 流行了，它把一切都封装在 tls 层里，当然也包括 http header 报文。于是出现了鸡🐔和蛋🥚的问题：</p>
<ul>
<li>要想建立 tls 连接就需要服务器发送证书。</li>
<li>服务器需要知道用户访问哪个网站才能发送匹配的证书。</li>
<li>但 <code>Host</code> 被加密了（其实在 tls 建立前压根不存在），无法读取。</li>
</ul>
<p>于是 SNI 出现了，<strong>它就像 http 的 host</strong>，客户端以明文的形式把目标主机名发送给服务器 —— 在建立 tls 之前。</p>
<p>虽然这个 SNI 这个词可能有点陌生，但它早就处在互联网的方方面面。因为大部分服务器都会部署超过一个网站（域名），我们的浏览器（或其他 https 客户端）一直按照 SNI 规范发送主机名，从而顺利建立连接。</p>
<blockquote>
<p>等等，SNI 是明文的，那不是很危险吗？是的... 这就是大名鼎鼎的 SNI 阻断了💩。
不过真正的数据还是安全的，只是访问的主机名（域名）泄露了而已。</p>
</blockquote>
<h2>测试 SNI</h2>
<p>来对比看看前后两个认证服务器在建立 tls 连接时如何选择并发送证书。使用 openssl 命令可以指定如何发送 SNI：</p>
<pre><code>openssl s_client -connect 43.xx.xx.xx:443 -servername authelia.chenhe.me/
</code></pre>
<p><code>servername</code> 参数就是要发送的 SNI，不指定则不发送。</p>
<p>首先尝试连接新的基于 OpenResty 镜像的认证服务器：</p>
<pre><code>&gt; openssl s_client -connect 43.xx.xx.xx:443 -servername authelia.chenhe.me
CONNECTED(00000003)
depth=2 C = US, ST = New Jersey, L = Jersey City, O = The USERTRUST Network, CN = USERTrust RSA Certification Authority
verify return:1
depth=1 C = AT, O = ZeroSSL, CN = ZeroSSL RSA Domain Secure Site CA
verify return:1
depth=0 CN = chenhe.me
verify return:1
...
</code></pre>
<p>没问题，返回了我配置的证书，如果不发送 SNI 呢？</p>
<pre><code># 不发送 SNI
&gt; openssl s_client -connect 43.xx.xx.xx:443

CONNECTED(00000003)
00873F4FF87F0000:error:0A000438:SSL routines:ssl3_read_bytes:tlsv1 alert internal error:ssl/record/rec_layer_s3.c:1586:SSL alert number 80
---
no peer certificate available
---
No client certificate CA names sent
---
SSL handshake has read 7 bytes and written 297 bytes
Verification: OK
...
</code></pre>
<p>果然，服务器没返回任何证书，自然 tls 握手失败。</p>
<p>再看看旧的 lnmp 搭建的 Nginx，发送 SNI 时表现一样，关键是不发送 SNI：</p>
<pre><code># 不发送 SNI
&gt; openssl s_client -connect 43.xx.xx.xx:443
CONNECTED(00000003)
depth=2 C = US, ST = New Jersey, L = Jersey City, O = The USERTRUST Network, CN = USERTrust RSA Certification Authority
verify return:1
depth=1 C = AT, O = ZeroSSL, CN = ZeroSSL RSA Domain Secure Site CA
verify return:1
depth=0 CN = chenhe.me
verify return:1
...
</code></pre>
<p>竟然也返回了证书。看来应该是在某处配置了默认证书，当 SNI 不存在或不匹配时使用。事实上如果什么证书都不配 Nginx 默认会使用一个自签发的证书。</p>
<h2>修复</h2>
<p>知道原因修复就很简单啦，只需添加 <code>proxy_ssl_server_name on;</code>，如下：</p>
<pre><code>location /authelia {
    internal;
    proxy_pass $upstream_authelia;
    proxy_ssl_server_name on;
    # others ...
}
</code></pre>
<p>如有需要也可以通过 <code>proxy_ssl_name www.example.com;</code> 手动指定 SNI 的主机名。</p>
<h2>后记</h2>
<p>事情结束了，有个问题依然困扰我：https 已经如此流行，在反代中使用 https 作为上游应该非常常见。既然默认不传递 SNI 那么大概率应该无法匹配合法的证书，为什么很少见报错呢？</p>
<p>原来默认情况下 Nginx 不校验上游证书的合法性，只用来加密但挡不住中间人攻击。恰好对应地，上游 Nginx 若 SNI 不匹配则返回自签发证书，一来一回程序就跑起来了。</p>
<p>通过 <code>proxy_ssl_verify on;</code> 可以打开证书校验，但要手动配置公钥才行。若使用的证书是正规 CA 签发的则可以配置系统默认的 CA 证书，具体位置根据系统的不同而不同。</p>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2024-01-06T16:14:37.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[LeetCode4 两个有序数组的中位数]]></title>
        <id>https://chenhe.me/zh/posts/leetcode4-median-of-two-sorted-arrays/</id>
        <link href="https://chenhe.me/zh/posts/leetcode4-median-of-two-sorted-arrays/"/>
        <updated>2024-01-04T18:38:34.000Z</updated>
        <summary type="html"><![CDATA[传送门：4. Median of Two Sorted Arrays 这是一道 Hard 级别的题目，主要难点是要求实现 $O(\log(m...]]></summary>
        <content type="html"><![CDATA[<h2>题目</h2>
<p>传送门：<a href="https://leetcode.com/problems/median-of-two-sorted-arrays/">4. Median of Two Sorted Arrays</a></p>
<p>这是一道 Hard 级别的题目，主要难点是要求实现 $O(\log(m+n))$ 的时间复杂度。</p>
<h2>暴力算法</h2>
<p>最容易想到的就是双指针，分别指向两个数组的首个元素。比较两个指针元素的大小，向后移动较小的一个并计数。此算法时间复杂度为 $O(m+n)$，其实不算差，但不满足题目要求。</p>
<h2>二分排除</h2>
<ul>
<li>时间复杂度：$ O(\log(k)) $ = $ O(\log(m+n)) $</li>
<li>空间复杂度：$ O(1) $</li>
</ul>
<p><strong>首先「求中位数」可以转换为「求第 k 小的数 (k&gt;=1)」</strong>，根据总元素个数 <code>totalNum</code> 的不同，就可以轻松得出中位数：</p>
<ul>
<li><code>totalNum</code> 为奇数时，中位数是第 <code>totalNum / 2</code> 小的数。</li>
<li><code>totalNum</code> 为偶数时，中位数是第 <code>totalNum / 2</code> 小的数与第 <code>totalNum / 2 + 1</code> 小的数的平均数。</li>
</ul>
<p>把 <code>O(N)</code> 时间复杂度降低到 <code>O(log N)</code>，最常见的就是二分法。暴力算法中每次排除一个元素，现在可以尝试每次排除 <code>k/2</code> 个元素。当然这里 <code>k</code> 是动态的。</p>
<p>给定数组 <code>nums1</code> 和 <code>nums2</code> 以及各自的查找范围 <code>[start1, end1], [start2, end2]</code>。我们比较它们各自的第 <code>k/2</code> 个元素，也就是下标为 <code>start+k/2-1 (k&gt;=2)</code> 的元素，令：</p>
<ul>
<li><code>halfKIndex1 = star1 + k/2 - 1</code></li>
<li><code>halfKIndex2 = star2 + k/2 - 1</code></li>
</ul>
<p>（注意此处暂未考虑数组长度不够的情况）</p>
<p>那么有三个可能性：</p>
<ul>
<li><code>nums1[halfKIndex1] &gt; nums2[halfKIndex2]</code>：这意味着 <code>nums2</code> 的前 k/2 个元素不可能是要找的。因为此时在 <code>nums2[halfKIndex2]</code> 之前（包括自己）最多只可能有 <code>k-1</code> 个元素，它们是：<code>nums2[start2 .. halfKIndex2]</code>（k/2 个） 以及 <code>nums1[start1 .. halfKIndex1-1]</code>（k/2-1 个），显然第 k 个在它们后面。只需在剩下范围内继续找第 <code>k - (halfKIndex1 - start1 + 1)</code> 小的元素，其中 <code>(halfKIndex1 - start1 + 1)</code> 是本次排除的元素个数。不直接用 <code>k/2</code> 是因为数组可能没那么长。</li>
<li><code>nums1[halfKIndex1] &lt; nums2[halfKIndex2]</code>：同理可以排除 <code>nums1</code> 的前 k/2 个元素。</li>
<li><code>nums1[halfKIndex1] = nums2[halfKIndex2]</code>：同理，可以扔掉任意一个（因为相等，留哪个都一样）。</li>
</ul>
<blockquote>
<p>注意，<code>nums1[halfKIndex1] &gt; nums2[halfKIndex2]</code> 时不可以排除 <code>nums1[halfKIndex1]</code> 之前的元素，因为我们无从得知这些元素与 <code>nums2[halfKIndex2]</code> 之后元素的大小关系。已知：<code>A &lt; B</code>, <code>D &lt; E &lt; F</code>, <code>B &gt; E</code>，无法得出 <code>A</code> 与 <code>F</code> 的关系。</p>
<p>例如：<code>nums1 = [4,5]</code>, <code>nums2 = [1,2,3,6]</code>，令 <code>k=4</code>, <code>halfKIndex1=1</code>，但第 4 小的数却是 <code>nums1[halfKIndex1-1]</code>。</p>
</blockquote>
<p>另外注意一下边界情况：</p>
<ul>
<li>若有数组的查找范围是空，则另一个数组中的第 <code>k</code> 个元素就是答案。</li>
<li>若 <code>k==1</code> 则只需比较两个数组的第一个元素，取较小的一个。</li>
</ul>
<pre><code>class Solution {
    public double findMedianSortedArrays(int[] nums1, int[] nums2) {
        int totalNums = nums1.length + nums2.length;
        if (totalNums % 2 == 0) {
            return (findKthNum(nums1, 0, nums2, 0, totalNums / 2) +
                    findKthNum(nums1, 0, nums2, 0, totalNums / 2 + 1)) / 2.0;
        } else {
            return findKthNum(nums1, 0, nums2, 0, totalNums / 2 + 1);
        }
    }

    private int findKthNum(int[] nums1, int start1, int[] nums2, int start2, int k) {
        if (start1 &gt;= nums1.length)
            return nums2[start2 + k - 1];
        if (start2 &gt;= nums2.length)
            return nums1[start1 + k - 1];
        if (k == 1)
            return Math.min(nums1[start1], nums2[start2]);

        int halfKIndex1 = start1 + Math.min(nums1.length - start1, k / 2) - 1;
        int halfKIndex2 = start2 + Math.min(nums2.length - start2, k / 2) - 1;

        if (nums1[halfKIndex1] &gt; nums2[halfKIndex2]) {
            return findKthNum(nums1, start1, nums2, halfKIndex2 + 1, k - (halfKIndex2 - start2 + 1));
        } else {
            return findKthNum(nums1, halfKIndex1 + 1, nums2, start2, k - (halfKIndex1 - start1 + 1));
        }
    }
}
</code></pre>
<h2>二分分割</h2>
<ul>
<li>时间复杂度：$ O(\log(\min(m,n))) $</li>
<li>空间复杂度：$ O(1) $</li>
</ul>
<p>所谓中位数，就是把一个有序序列分成两个长度相同序列的元素（根据元素个数奇偶的不同略有区别）。如果有两个序列，可以推广为找两个分割点，分别把它们分成两个部分：A 分成 A1, A2，B 分成 B1, B2，且满足两个条件：</p>
<ol>
<li>len(A1) + len(B1) = len(A2) + len(B2)，相当于单个数组中左右两部分元素个数相同。</li>
<li>max(A1, B1) &lt;= min(A2, B2)，相当于单个数组中左边所有元素均小于或等于右边。</li>
</ol>
<p>由于第一个条件的存在，实际上只要找一个分割点，另一个可直接计算出来。对于长度为 n 的数组，可能的分割点有 n+1 个。显然我们应该从较短的那个数组来尝试。若枚举分割点则时间复杂度为 $O(\min(m,n))$，但若使用二分查找寻找分割点，则可以优化为 $O(\log(\min(m,n)))$。</p>
<p>若 A 的分割点太靠左，可以想象，此时 <code>A</code> 左侧的最大值会偏小，而 <code>B</code> 右侧的最小值会偏大，由此可以推测出分割点往右移动的条件，反之则应向左移动。</p>
<pre><code>class Solution {
    public double findMedianSortedArrays(int[] nums1, int[] nums2) {
        // make sure nums1 is shorter than nums2
        if (nums1.length &gt; nums2.length) {
            return findMedianSortedArrays(nums2, nums1);
        }

        int totalNums = nums1.length + nums2.length;

        // the split point range of nums1
        int left = 0, right = nums1.length;

        while (left &lt;= right) {
            // [split, len-1] belongs to the right part
            int split1 = left + (right - left) / 2;
            int split2 = (nums1.length - 2 * split1 + nums2.length) / 2;

          	// corner cases
            int leftMax1 = split1 &gt;= 1 ? nums1[split1 - 1] : Integer.MIN_VALUE;
            int leftMax2 = split2 &gt;= 1 ? nums2[split2 - 1] : Integer.MIN_VALUE;
            int rightMin1 = split1 &lt; nums1.length ? nums1[split1] : Integer.MAX_VALUE;
            int rightMin2 = split2 &lt; nums2.length ? nums2[split2] : Integer.MAX_VALUE;

            int leftMax = Math.max(leftMax1, leftMax2);
            int rightMin = Math.min(rightMin1, rightMin2);
            if (leftMax &lt;= rightMin) {
                if (totalNums % 2 == 0) {
                    return (leftMax + rightMin) / 2.0;
                } else {
                    return rightMin;
                }
            } else if (leftMax1 &lt; rightMin2) {
                // split point should move towards right
                left = split1 + 1;
            } else if (leftMax1 &gt; rightMin2) {
                // split point should move towards left
                right = split1 - 1;
            }
        }
        throw new IllegalStateException();
    }
}
</code></pre>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2024-01-04T18:38:34.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[IntelliJ / Android Studio 全家桶常见问题]]></title>
        <id>https://chenhe.me/zh/posts/intellij-faq/</id>
        <link href="https://chenhe.me/zh/posts/intellij-faq/"/>
        <updated>2023-12-02T20:07:51.000Z</updated>
        <summary type="html"><![CDATA[本文章没有任何技术含量，但是解决了 JetBrains 一拍脑子的反人类改动。本文没有什么技术含量，主要记录 IntelliJ 全家桶（I...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>本文章没有任何技术含量，但是解决了 JetBrains 一拍脑子的反人类改动。</p>
</blockquote>
<p>本文没有什么技术含量，主要记录 IntelliJ 全家桶（IDEA, Goland, Android Studio 等）常见的小毛病。</p>
<h2>Precommit Check</h2>
<p><strong>【问题描述】</strong></p>
<p>发现版本：</p>
<ul>
<li>JetBrains: 2023</li>
<li>Android Studio: Hedgehog</li>
</ul>
<p>JetBrains 全家桶更新到 2023 版本，以及衍生产品 Android Studio 更新到 Hedgehog 后，在 IDE 内提交 Git Commit 时，静态代码检查（lint）在提交成功后才姗姗来迟。于是不得不添加新的提交来修复，或者使用 <code>git commit --amend</code> 重写，简直把强迫症逼死了😡。而在之前的版本中需要等待检查完成才允许提交，此时如果发现问题可以及时修复或手动忽略。</p>
<p><img src="https://img.chenhe.cc/i/2023/12/03/656b903788c44.png" alt="提交前检查" /></p>
<p><strong>【解决方案】</strong></p>
<p>这个改动也在社区引发了极大不满，<a href="https://youtrack.jetbrains.com/issue/IDEA-311316/Allow-turning-off-background-precommit-check#focus=Comments-27-7826680.0-0">其中一个帖子</a>给出了解决方案，打开 IDE 的 registry 设置（可以在 <code>help</code>-<code>find action</code> 直接搜索），关闭 <code>vcs.non.modal.post.commit.checks</code> 就行了。</p>
<p><img src="https://img.chenhe.cc/i/2023/12/03/656b914dac536.png" alt="" /></p>
<h2>悬浮文档不显示</h2>
<p><strong>【问题描述】</strong></p>
<p>正常情况下把鼠标放在一段代码上应该自动弹出一个注释文档窗口（官方叫 Quick Document）。但突然某天就再也不显示了。</p>
<p><img src="https://img.chenhe.cc/i/2023/12/12/65774ee91bee2.png" alt="Quick Document" /></p>
<p><strong>【解决方案】</strong></p>
<p>进入 IDEA （或其他全家桶成员）的设置，<code>Settings | Editor | Code Editing</code>，此处有两个相关选项：</p>
<ul>
<li><code>Quick Documentation | Show quick documentation on hover</code>，要勾选。</li>
<li><code>Editor Tooltips | Tooltip delay</code> 设置显示的触发时间。</li>
</ul>
<p>关键是 <code>Quick Documentation</code> 这一选项可能压根就没有，<a href="https://youtrack.jetbrains.com/issue/IDEA-314717/Quick-documentation-on-mouse-hover-not-working">官方论坛</a>说和屏幕阅读器冲突了。关闭 <code>Appearance &amp; Behavior | Appearance | Accessibility | Support screen readers</code> 后就好了。</p>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2023-12-02T20:07:51.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[在 Spring 中正确注入 EntityManager]]></title>
        <id>https://chenhe.me/zh/posts/inject-entitymanager-in-spring-correctly/</id>
        <link href="https://chenhe.me/zh/posts/inject-entitymanager-in-spring-correctly/"/>
        <updated>2023-10-13T19:44:11.000Z</updated>
        <summary type="html"><![CDATA[TL;DR: 保持默认设置，优先使用 @Autowired，没有线程安全问题。本文基于 Spring Boot 3.1.3，使用 io.sp...]]></summary>
        <content type="html"><![CDATA[<p><strong>TL;DR: 保持默认设置，优先使用 <code>@Autowired</code>，没有线程安全问题。</strong></p>
<blockquote>
<p>本文基于 Spring Boot 3.1.3，使用 <code>io.spring.dependency-management</code> 1.1.3 依赖管理。</p>
</blockquote>
<h2>注入注解之争</h2>
<p>Spring 中最常用的注解恐怕非依赖注入 <code>@Autowired</code> 莫属了，默认情况下每个类 Spring 只会创建一个实例来节约资源，但若我们需要的是一个 <code>EntityManager</code> 对象，事情就稍微复杂了一点。即使在最简单的单个数据库场景下，也逃不过线程安全问题。<strong>根据 <a href="https://docs.jboss.org/hibernate/stable/entitymanager/reference/en/html/transactions.html">hibernate 的文档</a> <code>EntityManager</code> 默认不是线程安全的</strong>，因此许多<a href="https://www.bilibili.com/video/BV1Mt4y137X9/?p=19&amp;t=606">视频教程</a>与<a href="https://stackoverflow.com/a/58891587/9150068">文本资料</a>都说必须使用 <code>@PersistenceContext</code> 注入 <code>EntityManager</code> 从而每次获得不同的实例。</p>
<p>然而事实是大多数情况下 <code>@PersistenceContext</code> 不过是心里安慰罢了。我们习惯于把对象注入到成员变量，而「注入」这一行为只会发生一次。例如下面的代码：</p>
<pre><code>@Service
public class UserService {
  @PersistenceContext
  private EntityManager em;
  // ...
}
</code></pre>
<p>显然，整个程序中只会存在一个 <code>UserService</code> 实例，它的 <code>em</code> 也固定不变，而 <code>UserService</code> 很可能被多个线程使用，<code>em</code> 也就再次陷入危机。也有同学作了<a href="https://stackoverflow.com/a/66075526/9150068">安慰用的封装</a>，只要最终注入到了成员变量，大概率就不能解决这个问题。</p>
<h2>Persistence Context</h2>
<h3>EntityManager</h3>
<p>与 <code>@Autowired</code> 相比，<code>@PersistenceContext</code> 语义不是很清晰，在讨论解决办法之前我们先来搞清楚这个注解到底与 <code>@Autowired</code> 有什么区别。</p>
<p><code>@Autowired</code> 大家应该很熟悉了，它的作用很直接：从 <strong>Spring IOC 容器</strong>中取出一个对象，默认 Spring 会保留实例，某次注入都是同一个对象。</p>
<p><code>@PersistenceContext</code> 是 JPA （不是 Spring JPA）定义的注解，其名称来源于一个概念 「Persistence Context」。我们知道 JPA 强制使用一级缓存，主要表现在：</p>
<ul>
<li>相同查询只执行一次。</li>
<li>Entity 的修改删除不会实时提交到数据库。</li>
</ul>
<p>这些缓存需要存储于某个地方，这就是 Persistence Context，它管理着所有的 Entity 实例。等等，这不就是 <code>EntityManager</code> 吗？！没错 <code>EntityManager</code> 是与 Persistence Context 交互的接口。更形象地说，Persistence Context 是设计/概念上的东西，<code>EntityManager</code> 是它的具体表现。注意不要搞混，<code>EntityManager</code> 是接口，它具象化了概念上定义的功能，但具体的代码逻辑还要看它的实现类。到此为止用 <code>@PersistenceContext</code> 注入 <code>EntityManager</code> 就合理了，毕竟他俩本来就是一个东西。</p>
<h3>Container / Application Managed</h3>
<p><code>EntityManager</code> 作为数据库与应用之间的中间人，需要在适当的时候把更改同步到数据库、释放资源或执行其他操作，那谁来管理 <code>EntityManager</code> 呢？从宏观角度有两个选择：容器管理或应用管理。</p>
<p>容器指的是 JavaEE Container（最常见的是 Tomcat）。Spring 大大简化了 Java Web 开发，导致部分同学忽略了一些底层概念，实际上 Java Web 程序（包括 Spring Web）都是 Servlet。一个完整的 Web 服务器至少要实现下面两个功能：</p>
<ol>
<li>监听端口，处理底层数据流，实现一些协议（例如 http）。</li>
<li>接收请求，处理业务逻辑，响应请求。</li>
</ol>
<p>显然第一个功能非常复杂但也很通用，如果每一个程序都自己实现一遍就太麻烦了，所以由 Web Server 代劳。一个服务器上往往运行着多个业务的服务程序，把这些功能写在一个程序里肯定不是个好主意。于是 Sun 公司制定了 Servlet 规范，表现为 Java interface。任何实现了这个接口的程序都能被识别，拿到匹配的 Web 请求并响应。这样不同的业务就可以拆分到不同的 Servlet 程序里。现在有了一堆 Servlet，自然需要一个东西来管理它们，这就是容器 Container。</p>
<p>狭义上容器介于 Web Server 与 Servlet 之间，但往往容器本身也是一个 Web Server，尽管实际使用中我们倾向于外面套一个更专业的服务器，比如 Nginx。默认情况下容器采用单实例多线程的策略来管理 Servlet。即每个 Servlet 程序只启动一次，每有一个新请求就从线程池取一个线程来调用 Servlet。</p>
<blockquote>
<p>随着 Spring Boot 和微服务的流行，多 Servlet 架构逐渐被遗忘。更常见的做法是一个服务器程序是一个 Servlet 捆绑一个 Tomcat。不同业务的请求由上层网关（至少是 Nginx 这种专业的 Web Server）分发到不同的后端，可能是 Tomcat + Servlet 也可能是其他程序。</p>
</blockquote>
<p><strong><code>@PersistenceContext</code> 获取到的 <code>EntityManager</code> 实例默认是由容器管理的</strong>。通常容器的策略是一个线程一个 <code>EntityManager</code>，和管理请求的策略类似，所以等效为每个请求有自己的 <code>EntityManager</code>，其生命周期在 Servlet 返回后结束。<strong>但注意，并不是所有容器都实现了此功能，开源的 Tomcat 就没有实现。</strong></p>
<p>除非我们参与一些大型、专业、古老的企业项目，否则使用的是应用管理的 <code>EntityManager</code>。「应用」指的是整个 Servlet 程序，既包括我们自己写的代码，也包括 Spring 提供的功能。</p>
<h2>Spring Data JPA 的魔法</h2>
<h3><code>@Autowired</code> 注入了啥</h3>
<p><code>@Autowired</code> 注解由 Spring 核心的 <code>AutowiredAnnotationBeanPostProcessor</code> 处理，注入的值来源于 <code>BeanFacotry</code>，也就是俗称的 Spring IoC 容器。它遵循先类型后名称的匹配顺序，所以要想知道注入的 <code>EntityManager</code> 到底是啥，就得看看容器中注册了啥。</p>
<h4>默认情况</h4>
<p>Spring Data JPA 的自动配置类 <code>JpaRepositoriesAutoConfiguration</code> 与手动加上注解 <code>@EnableJpaRepositories</code> 等效，它们都直接或间接导入(<code>@Import</code>)了 <code>JpaRepositoriesRegistrar</code>，它的父类 <code>AbstractRepositoryConfigurationSourceSupport</code> 实现了接口 <code>ImportBeanDefinitionRegistrar</code>，顾名思义其中一个功能就是注册 BeanDefinition，具体逻辑代理给了 <code>RepositoryConfigurationDelegate</code> 并传入一个 <code>RepositoryConfigurationExtension</code> 参数，如下：</p>
<pre><code>// AbstractRepositoryConfigurationSourceSupport
@Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry,
    BeanNameGenerator importBeanNameGenerator) {
  RepositoryConfigurationDelegate delegate = new RepositoryConfigurationDelegate(
      getConfigurationSource(registry, importBeanNameGenerator), this.resourceLoader, this.environment);
  delegate.registerRepositoriesIn(registry, getRepositoryConfigurationExtension());
}
</code></pre>
<p>这给 delegate 内部调用了 <code>RepositoryConfigurationExtension.registerBeansForRoot()</code>，看名字就非常可疑，我们关注一下这里的实现类 <code>JpaRepositoryConfigExtension</code>。</p>
<pre><code>// JpaRepositoryConfigExtension
@Override
public void registerBeansForRoot(BeanDefinitionRegistry registry, RepositoryConfigurationSource config) {
  super.registerBeansForRoot(registry, config);
  registerSharedEntityMangerIfNotAlreadyRegistered(registry, config);
  Object source = config.getSource();
  registerLazyIfNotAlreadyRegistered(
      () -&gt; new RootBeanDefinition(EntityManagerBeanDefinitionRegistrarPostProcessor.class), registry,
      // emBeanDefinitionRegistrarPostProcessor
      EM_BEAN_DEFINITION_REGISTRAR_POST_PROCESSOR_BEAN_NAME, source);
  // ...
}
</code></pre>
<blockquote>
<p>小声低估：EmtityManager 那么重要的东西竟然缩写成了 <code>em</code> 还是个纯大写的常量名，差点没看到。</p>
</blockquote>
<p>可以看到它注册了 <code>EntityManagerBeanDefinitionRegistrarPostProcessor</code> 并手动指定 BeanName 为缩写的常量。这个名字又臭又长以至于我连复制粘贴都嫌麻烦的 PostProcessor 的注释倒是很清晰地说明了它两个主要功能：</p>
<ul>
<li>为每一个 <code> EntityManagerFactory</code> 注册一个对应的 <code>SharedEntityManagerCreator </code>，这样就能在构造函数中使用自动注入 <code>EntityManager</code> 了。 （<code>@PersistenceContext</code> 不支持构造函数注入）</li>
<li>把 <code> EntityManagerFactory</code> 的名字添加为 qualifier 以便在有多个 <code>EntityManagerFactory</code> 实例的情况下也能正确注入。</li>
</ul>
<p>添加 BeanDefinition 的核心代码如下：</p>
<pre><code>// EntityManagerBeanDefinitionRegistrarPostProcessor
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
	// ...
  for (EntityManagerFactoryBeanDefinition definition : getEntityManagerFactoryBeanDefinitions(beanFactory)) {
    // ...
    BeanDefinitionBuilder builder = BeanDefinitionBuilder
        .rootBeanDefinition("org.springframework.orm.jpa.SharedEntityManagerCreator");
    builder.setFactoryMethod("createSharedEntityManager");
    builder.addConstructorArgReference(definition.getBeanName());
    // ...
  }
}
</code></pre>
<p>顺藤摸瓜，终于找到了万恶之源 <code>SharedEntityManagerCreator</code>，赶紧来看一下它的 <code>createSharedEntityManage()</code> 方法，层层重载方法的调用后，果不其然是一个动态代理：</p>
<pre><code>// SharedEntityManagerCreator.createSharedEntityManager()
return (EntityManager) Proxy.newProxyInstance(
				(cl != null ? cl : SharedEntityManagerCreator.class.getClassLoader()),
				ifcs, new SharedEntityManagerInvocationHandler(emf, properties, synchronizedWithTransaction));
</code></pre>
<p>需要注意的是 <code>SharedEntityManagerCreator</code> 本身没有实现 <code>EntityManagerFactory</code>，它仅仅在 Spring 容器中用来生成 <code>EntityManager</code>。<strong>这意味着使用依赖注入取得 <code>EntityManagerFactory</code> 实例不可能得到 <code>SharedEntityManagerCreator</code>。</strong></p>
<p><strong>至此可以得出结论，默认情况下 <code>@Autowired</code> 注入的 <code>EntityManager</code> 是由 <code>SharedEntityManagerInvocationHandler</code> 处理的单例动态代理对象。</strong></p>
<h4>自定义情况</h4>
<p>不少网络上的博客与问答都提到自己注册一个 Bean：</p>
<pre><code>@Bean
public EntityManager entityManager(EntityManagerFactory entityManagerFactory){
  return  entityManagerFactory.createEntityManager();
}
</code></pre>
<p>上文刚刚强调过 <code>SharedEntityManagerCreator</code> 不是 <code>EntityManagerFactory</code>，事实上这里的参数传入的是原生实现（Hibernate 环境下是 <code>SessionFactoryImpl</code>）的动态代理，由 <code>AbstractEntityManagerFactoryBean.ManagedEntityManagerFactoryInvocationHandler</code> 处理。它调用 <code>ExtendedEntityManagerCreator.createApplicationManagedEntityManager()</code> 创建了原生 <code>EntityManager</code> 的动态代理作为 <code>createEntityManager()</code> 方法的返回值。下面 <code>@PersistenceContext</code> 章节做了详细分析。</p>
<p><strong>在自定义 Bean 的情况下，<code>@Autowired</code> 注入的 <code>EntityManager</code> 是由 <code>ExtendedEntityManagerCreator.ExtendedEntityManagerInvocationHandler</code>处理的单例动态代理对象。</strong></p>
<h3><code>@PersistenceContext</code> 注入了啥</h3>
<h4>注解处理</h4>
<p>上文提到 <code>@PersistenceContext</code> 是 Sun 制定的标准，用于从 Servlet Container 中取得实例，通常与线程绑定。但常见的 Servlet Container (tomcat) 并未实现管理 <code>EntityManager</code> 的功能，为什么 <code>@PersistenceContext</code> 依然可以正常工作呢？<strong>因为 Spring 也处理了此注解。</strong></p>
<p><code>spring-orm</code> 模块中有一个默认注册的类 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/orm/jpa/support/PersistenceAnnotationBeanPostProcessor.html"><code>org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor</code></a>，它接管了 Spring 环境中 <code>@PersistenceContext</code> 注解的处理。默认从 Spring IoC 容器中取得 <code>EntityManagerFactory</code>，经过配置也能从 JNDI 中获取来生成 <code>EntityManager</code>。</p>
<p>我们关注它的核心方法 <code>postProcessProperties()</code>：</p>
<pre><code>// PersistenceAnnotationBeanPostProcessor
@Override
public PropertyValues postProcessProperties(PropertyValues pvs, Object bean, String beanName) {
  InjectionMetadata metadata = findPersistenceMetadata(beanName, bean.getClass(), pvs);
  metadata.inject(bean, beanName, pvs);
  // exception handle...
  return pvs;
}
</code></pre>
<p>并不复杂，注入的详情封装在 metadata 里了，那就看看怎么找到这个元数据的：</p>
<pre><code>// PersistenceAnnotationBeanPostProcessor
private InjectionMetadata findPersistenceMetadata(String beanName, Class&lt;?&gt; clazz, @Nullable PropertyValues pvs) {
  // cache...
  metadata = buildPersistenceMetadata(clazz);
  return metadata;
}
</code></pre>
<p>原来是在缓存中找啊... 省略掉与缓存有关的代码后找到了真正的构建方法：</p>
<pre><code>// PersistenceAnnotationBeanPostProcessor
private InjectionMetadata buildPersistenceMetadata(Class&lt;?&gt; clazz) {
	// ...

  List&lt;InjectionMetadata.InjectedElement&gt; elements = new ArrayList&lt;&gt;();
  Class&lt;?&gt; targetClass = clazz;

  do {
    final List&lt;InjectionMetadata.InjectedElement&gt; currElements = new ArrayList&lt;&gt;();
		// 处理注解字段字段
    ReflectionUtils.doWithLocalFields(targetClass, field -&gt; {
      if (field.isAnnotationPresent(PersistenceContext.class) ||
          field.isAnnotationPresent(PersistenceUnit.class)) {
        if (Modifier.isStatic(field.getModifiers())) {
          throw new IllegalStateException("Persistence annotations are not supported on static fields");
        }
        currElements.add(new PersistenceElement(field, field, null));
      }
    });
		// 处理注解的方法
    ReflectionUtils.doWithLocalMethods(targetClass, method -&gt; {
      Method bridgedMethod = BridgeMethodResolver.findBridgedMethod(method);
      // Omit some checks like above
      PropertyDescriptor pd = BeanUtils.findPropertyForMethod(bridgedMethod, clazz);
      currElements.add(new PersistenceElement(method, bridgedMethod, pd));
    });

    elements.addAll(0, currElements);
    // 继续处理父类
    targetClass = targetClass.getSuperclass();
  }
  while (targetClass != null &amp;&amp; targetClass != Object.class);
  return InjectionMetadata.forElements(elements, clazz);
}
</code></pre>
<p>代码比较长，其实逻辑不复杂：</p>
<ul>
<li>外层 <code>do...while</code> 循环是为了处理父类。</li>
<li>内层两个循环分别是寻找带有 <code>@PersistenceContext</code> 或 <code>@PersistenceUnit</code> 注解的字段与方法。找到后创建一个 <code>PersistenceElement</code> 加入待处理列表。</li>
</ul>
<p>实际执行注入的是 <code>InjectionMetadata.InjectedElement.inject()</code>，也就是 <code>PersistenceElement</code> 的父类。其调用了 <code>getResourceToInject()</code> 获取要注入的值，我们看看子类的重写：</p>
<pre><code>// PersistenceAnnotationBeanPostProcessor - PersistenceElement
@Override
protected Object getResourceToInject(Object target, @Nullable String requestingBeanName) {
  // Resolves to EntityManagerFactory or EntityManager.
  if (this.type != null) {
    return (this.type == PersistenceContextType.EXTENDED ?
        resolveExtendedEntityManager(target, requestingBeanName) :
        resolveEntityManager(requestingBeanName));
  } else {
    // OK, so we need an EntityManagerFactory...
    return resolveEntityManagerFactory(requestingBeanName);
  }
}
</code></pre>
<p>可以看到根据类型的不同，注入的值有三种可能性：</p>
<table>
<thead>
<tr>
<th>PersistenceContextType</th>
<th>注入的值</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>TRANSACTION（默认值）</td>
<td>EntityManager</td>
<td></td>
</tr>
<tr>
<td>EXTENDED</td>
<td>ExtendedEntityManager</td>
<td></td>
</tr>
<tr>
<td>NULL (<code>@PersistenceUnit</code>)</td>
<td>EntityManagerFactory</td>
<td></td>
</tr>
</tbody>
</table>
<p>继续看一下各自的方法。（<code>@PersistenceUnit</code> 不是本文的重点先忽略）</p>
<p><strong>TRANSACTION</strong> 对应的 <code>resolveEntityManager()</code> 不复杂，这里不放源码了，逻辑如下：</p>
<pre><code>graph TB
em[Get em from JNDI]
emf1[Get em factory from JNDI]
emf2[Get em factory from Spring]
wrap[SharedEntityManagerCreator 创建代理 em]
r(Return)

em--OK--&gt;r
em--NULL--&gt;emf1
emf1--OK--&gt;wrap
emf1--NULL--&gt;emf2
emf2--&gt;wrap

wrap--&gt;r
</code></pre>
<p>因为默认未配置 JNDI 相关参数所以全是 NULL，最终从 Spring 容器中获取 <code>EntityManagerFactory</code>，之后就和 <em><code>@Autowired</code> 的默认情况</em>差不多，注入一个 Spring 代理的 <code>EntityManager</code>。</p>
<p><strong>EXTENDED</strong> 对应的是 <code>resolveExtendedEntityManager()</code> ，基本逻辑和 TRANSCATION 版本的差不多，但调用的是 <code>ExtendedEntityManagerCreator</code> 来创建 <code>EntityManager</code> 的动态代理。</p>
<h4>EntityManagerFactory 来源</h4>
<p>既然 Transaction 与 Extended 两种模式都用到了 Spring 容器中的 <code>EntityManagerFactory</code>，那它是哪里来的呢？</p>
<p>自动配置类  <code>JpaBaseConfiguration</code> 中注册了 <code>LocalContainerEntityManagerFactoryBean</code>：</p>
<pre><code>// org.springframework.boot.autoconfigure.orm.jpa.JpaBaseConfiguration
@Bean
@Primary
@ConditionalOnMissingBean({ LocalContainerEntityManagerFactoryBean.class, EntityManagerFactory.class })
public LocalContainerEntityManagerFactoryBean entityManagerFactory(EntityManagerFactoryBuilder factoryBuilder,
    PersistenceManagedTypes persistenceManagedTypes)
</code></pre>
<p><code>FactoryBean</code> 是 Spring 中一个特殊的 Bean，它本身不被直接获取，而是通过实现 <code>getObject()</code> 方法生产对象供使用。这里的实现在父类 <code>AbstractEntityManagerFactoryBean</code> 中：</p>
<pre><code>// AbstractEntityManagerFactoryBean
@Override
public Class&lt;? extends EntityManagerFactory&gt; getObjectType() {
  return (this.entityManagerFactory != null ? this.entityManagerFactory.getClass() : EntityManagerFactory.class);
}

@Override
@Nullable
public EntityManagerFactory getObject() {
  return this.entityManagerFactory;
}
</code></pre>
<p>可以看到它是生产工厂(<code>EntityManagerFactory</code>)的工厂 ，仅仅返回一个成员变量。这个变量在 <code>afterPropertiesSet()</code> 中被赋值，这是 <code>InitializingBean</code> 接口的方法，用于 Bean 的初始化。当 Spring 设置好 Bean 的所有属性后，若其实现了这个接口则自动调用此方法。</p>
<pre><code>// AbstractEntityManagerFactoryBean
@Override
public void afterPropertiesSet() throws PersistenceException {
  // ...
  AsyncTaskExecutor bootstrapExecutor = getBootstrapExecutor();
  if (bootstrapExecutor != null) {
    this.nativeEntityManagerFactoryFuture = bootstrapExecutor.submit(this::buildNativeEntityManagerFactory);
  } else {
    // buildNativeEntityManagerFactory() 内部调用了抽象函数 createNativeEntityManagerFactory()
    this.nativeEntityManagerFactory = buildNativeEntityManagerFactory();
  }
  this.entityManagerFactory = createEntityManagerFactoryProxy(this.nativeEntityManagerFactory);
}
</code></pre>
<p>抽象函数 <code>createNativeEntityManagerFactory()</code> 取得了原生的 <code>EntityManagerFactory</code> 对象保存在成员变量 <code>nativeEntityManagerFactory</code> 中，大部分情况下应该是 Hibernate 的 <code>SessionFactoryImpl</code>。</p>
<p><code>createEntityManagerFactoryProxy()</code> 顾名思义创建了一个原生工厂的的动态代理类，大部分的方法调用转给了 <code>invokeProxyMethod()</code> 处理。这个代理的主要目的是拦截 <code>createEntityManager()</code> 方法，从而返回一个由 Spring 管理的、线程安全的套娃  <code>EntityManager</code>，如下：</p>
<pre><code>// AbstractEntityManagerFactoryBean
Object invokeProxyMethod(Method method, @Nullable Object[] args) throws Throwable {
  if (method.getDeclaringClass().isAssignableFrom(EntityManagerFactoryInfo.class)) {
    return method.invoke(this, args);
  }
  else if (method.getName().equals("createEntityManager") &amp;&amp; args != null &amp;&amp; args.length &gt; 0 &amp;&amp;
      args[0] == SynchronizationType.SYNCHRONIZED) {
    EntityManager rawEntityManager = (args.length &gt; 1 ?
        getNativeEntityManagerFactory().createEntityManager((Map&lt;?, ?&gt;) args[1]) :
        getNativeEntityManagerFactory().createEntityManager());
    postProcessEntityManager(rawEntityManager);
    // 返回了套娃的 EntityManager
    return ExtendedEntityManagerCreator.createApplicationManagedEntityManager(rawEntityManager, this, true);
  }
  // ...
  Object retVal = method.invoke(getNativeEntityManagerFactory(), args);
  if (retVal instanceof EntityManager rawEntityManager) {
    // Any other createEntityManager variant - expecting non-synchronized semantics
    postProcessEntityManager(rawEntityManager);
    // 还是套娃的 EntityManager
    retVal = ExtendedEntityManagerCreator.createApplicationManagedEntityManager(rawEntityManager, this, false);
  }
  return retVal;
}
</code></pre>
<p>虽然根据参数的不同具体行为略有区别，但本质都是通过 <code>getNativeEntityManagerFactory().createEntityManager()</code> 拿到原生的 <code>EntityManager</code>，用 <code>ExtendedEntityManagerCreator.createApplicationManagedEntityManager()</code> 创建动态代理套娃一层返回。</p>
<h3>小结</h3>
<p><code>@Autowired</code> 注入的 <code>EntityManager</code> 分为两种情况：</p>
<ul>
<li>若采用默认配置则是 <code>SharedEntityManagerCreator</code> 搞出的动态代理。</li>
<li>若自己注册了 Bean 则且 Name 匹配则以设置的为准。具体来说因为太多同学人云亦云用容器中的 <code>EntityManagerFactory</code> 注册了 <code>EntityManager</code> 的 bean，所以常见结果是注入了 <code>ExtendedEntityManagerCreator</code> 搞出的动态代理。</li>
</ul>
<p><strong>虽然两种情况最终都是 Spring 创建的动态代理，但它们的代理逻辑不一样，不可以混为一谈。</strong></p>
<p>Spring 内部处理了 <code>@PersistenceContext</code> 注解，因此即使 JavaEE Container 不支持也能正常得到 <code>EntityManager</code>。 根据 <code>type</code> 参数的不同，注入 <code>SharedEntityManagerCreator</code> 或 <code>ExtendedEntityManagerCreator</code> 创建出的代理对象。</p>
<p>Spring 容器中的 <code>EntityManagerFactory</code> 是原生工厂的代理对象，它所生产的 <code>EntityManager</code> 也是原生版本的代理对象，由 <code>ExtendedEntityManagerCreator</code> 创建。</p>
<h2>两种动态代理区别何在</h2>
<p>分析到这，我们知道无论是 <code>@Autowired</code> 还是 <code>@PersistenceContext</code>，典型场景下只可能得到两种对象：</p>
<ul>
<li><code>SharedEntityManagerCreator</code> 创建的动态代理（事务型 / transaction scope）</li>
<li><code>ExtendedEntityManagerCreator</code> 创建的动态代理（扩展型 / extended scope）</li>
</ul>
<p>于是它俩的区别成为如何选用注解的核心问题。</p>
<p>事务型代理背后可能有多个 <code>EntityManager</code>，代理的方法执行时从 <code>TransactionSynchronizationManager</code> 中获取。这可以保证同一个事务中的 EntityManager 共享同一个 Persistence Context，避免两个 <code>@Transactional</code> 方法嵌套调用，后者读不到前者修改的诡异问题。</p>
<p>扩展型代理在创建时就固定了背后的 <code>EntityManager</code>，代理的方法执行时取得当前线程已经启动的事务并加入。但这不能保证它与已经启动事务的其他 <code>EntityManager</code> 共享 Persistence Context。在事务提交前，不同的 EntityManager 可能无法看见对方的修改。</p>
<p>在实际开发中，事务方法嵌套调用，且后者依赖前者更改的情况并不罕见。例如用户注册后可能调用其他可复用的方法初始化一些属性，如果此时使用扩展型代理可能修改时发现记录不存在。</p>
<h2>总结</h2>
<p>在不启用 JavaEE 那一套玩意，且保持默认设置的前提下，<code>@Autowired</code> 与 <code>@PersistenceContext</code> 没有什么区别，但前者支持构造器注入，因此更推荐使用。后者提供更多选项，例如可以显式设置需要 Extended Scope 的 EntityManager，在需要时可以使用，但应该不常用。</p>
<p><strong>在绝大部分情况下都不要自作主张地手动注册 <code>EntityManager</code> 的 Bean</strong>，否则将导致意外地注入成扩展型的代理，进而出现非预期的变更不可见。</p>
<p><strong>无论哪种注解，都无需担心线程安全问题</strong>，因为它们背后都是 Spring 的代理类，都是线程安全的。</p>
<h2>参考</h2>
<ul>
<li><a href="https://zhuanlan.zhihu.com/p/520513892">SpringDataJPA+Hibernate框架源码剖析（六）</a></li>
</ul>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2023-10-13T19:44:11.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[速通 Java 动态代理]]></title>
        <id>https://chenhe.me/zh/posts/speedrun-java-dynamic-proxy/</id>
        <link href="https://chenhe.me/zh/posts/speedrun-java-dynamic-proxy/"/>
        <updated>2023-09-14T19:40:47.000Z</updated>
        <summary type="html"><![CDATA[要学习动态代理，首先要知道什么是代理。简单说，若我们想访问对象 A，但现在不直接调用，而是访问对象 B，由 B 去调用 A 并且把 A 的返...]]></summary>
        <content type="html"><![CDATA[<h2>什么是代理</h2>
<p>要学习动态代理，首先要知道什么是代理。简单说，若我们想访问对象 A，但现在不直接调用，而是访问对象 B，由 B 去调用 A 并且把 A 的返回值返回给我们，此时 B 就是一个代理。那么为什么要脱裤子放屁呢？</p>
<p>看一个现实例子。假设某人有个多余房产希望出售/出租，于是他找了一个房产中介作为代理。潜在的购房人/求租人直接与中介联系即可。那么中介有什么优势？</p>
<ul>
<li>中介分担了业主的职责，业主只需在意成交价格即可。</li>
<li>中介承担了一些通用的工作，比如看房，起草协议。就不要求每一个业主都掌握这些了。</li>
</ul>
<p>对应到代码中，就是：</p>
<ul>
<li>分担被代理对象的职责，被代理对象只需关注自己的核心业务。</li>
<li>实现 AOP（面向切面编程），可批量插入代码，例如日志/统计等。</li>
</ul>
<p>因为代理对象最终要调用实际对象的方法，<strong>因此代理对象必须拥有原始对象所有想被代理的方法，体现在 jdk 动态代理中就是它们需要实现相同的接口</strong>，接口中的方法就是需要被代理的方法。为什么强调 jdk 动态代理？因为还有一个叫做 cglib 的开源库，也被广泛用于实现动态代理，它通过创建子类做到「代理对象拥有原始对象的所有方法」。</p>
<h2>静态代理</h2>
<p>所谓静态代理，就是在编译时就已经存在的代理，编译完成后以字节码的形式存在于程序中，是程序的一部分。说人话就是：<strong>一个普通的类</strong>。</p>
<p>用上面房产中介的例子来实现一个静态代理。</p>
<pre><code>public interface Owner {
    String sell(String buyer);
    String let(String customer);
}

/* 业主，被代理 */
public class HouseOwner implements Owner {
    @Override
    public String sell(String buyer) {
        return buyer + "，成交！";
    }
    @Override
    public String let(String customer) {
        return customer + "，欢迎入住！";
    }
}

/* 房产中介，代理 */
public class HouseAgent implements Owner {
    private Owner obj; // 原始对象，被代理的对象
    public HouseAgent(Owner obj) {
        this.obj = obj;
    }
    @Override
    public String sell(String buyer) {
        System.out.println("带" + buyer + "看房");
        System.out.println("签署销售协议");
        return obj.sell(buyer); // 调用被代理对象的方法，透传参数与返回值
    }
    @Override
    public String let(String customer) {
        System.out.println("带" + customer + "看房");
        System.out.println("签署租赁协议");
        return obj.let(customer); // 调用被代理对象的方法，透传参数与返回值
    }
}


// 使用
HouseOwner houseOwner = new HouseOwner();
HouseAgent agent  = new HouseAgent(houseOwner);
agent.sell("Chenhe");
</code></pre>
<h2>jdk 动态代理</h2>
<p>上面的静态代理的确分担了业主的职责，但不够灵活。比如无论是出租还是销售都先看房，最后通知业主。倘若业主还有更多需要先看房业务：长租/短租/民宿... 那我们不得不把每一个方法都代理一遍，并且都写上看房和通知业主的代码，非常冗余。</p>
<p>动态代理可以解决这个问题。所谓「动态」就是编译时这个代理类并不存在，而是在运行时实时生成字节码。显然这种技术依赖于反射。要使用 jdk 动态代理，需要满足以下条件：</p>
<ul>
<li>目标类（要代理的类）实现了接口。</li>
<li>要代理的方法属于这些接口。</li>
</ul>
<p>依然是实现一个上面房产中介的例子，因为代理是动态生成的，所以无需编写 <code>HouseAgent</code> 类。</p>
<pre><code>public class HouseAgentUtil {
    public static Owner getAgent(Owner houseOwner) {
        return (Owner) Proxy.newProxyInstance(
                HouseAgentUtil.class.getClassLoader(),
                new Class[]{Owner.class},
                (proxy, method, args) -&gt; {
                    System.out.println("带" + args[0] + "看房");
                    switch (method.getName()) {
                        case "sell" -&gt; System.out.println("签署销售协议");
                        case "let" -&gt; System.out.println("签署租赁协议");
                    }
                    // 调用被代理对象的方法，透传参数与返回值
                    return method.invoke(houseOwner, args);
                }
        );
    }
}

// 使用
HouseOwner houseOwner = new HouseOwner();
Owner agent = HouseAgentUtil.getAgent(houseOwner) // 动态创建代理对象
agent.sell("Chenhe"); // 此处会触发 InvocationHandler，args 值为 ["Chenhe"]
</code></pre>
<p>这里调用 jdk 的 <code>Proxy.newProxyInstance()</code> 方法生成并实例化了一个类。相当于我们在<strong>运行时</strong>编写 <code>HouseAgent</code> java  代码，其背后当然比较复杂，幸运的是 jdk 帮我们封装好了。这个方法有三个参数：</p>
<ol>
<li><code>ClassLoader</code>，因为是运行时生成的字节码，肯定需要先被 jvm 加载才可以运行，所以需要一个类加载器。通常使用当前类的加载器就行。</li>
<li><code>Class&lt;?&gt;[] interfaces</code> 要实现的接口，也就是要代理哪些方法。允许实现多个接口所以这里以数组形式传入。</li>
<li><code>InvocationHandler</code> 是最核心的参数，它控制着代理的行为，相当于静态代理中编写的一个个接口实现。</li>
</ol>
<p><code>InvocationHandler</code> 是个接口，本质上是一个回调。当这个代理类被调用时会触发，从而执行具体的代理行为。这里使用匿名内部类（lambda 语法）实现。它也接受三个参数：</p>
<ol>
<li><code>Object proxy</code> 是代理对象，也就是动态生成的这个代理类的一个实例。注意不是被代理的对象。</li>
<li><code>Method method</code> 调用的方法。</li>
<li><code>Object[] args</code> 传入的参数。</li>
</ol>
<p>由此看出，静态代理中接口方法的实现被整合在了一个方法中，因此可以方便地执行统一代码（比如看房和通知业主），还可以根据具体的方法（<code>method</code>）执行不同的逻辑。</p>
<h2>cglib 动态代理</h2>
<p>使用 jdk API 实现动态代理要求要代理的方法必须在接口中声明，这大大限制了适用场景。尤其是我们希望代理第三方类时，很难保证想要代理的方法正好被抽象到接口中。</p>
<p>cglib 则通过继承来实现动态代理。具体来说 cglib 尝试生成目标类的子类，覆盖（重写）要代理的方法，根据需要通过 <code>super</code> 调用父类（目标类）原始方法，并可加入其他代码。从原理不难看出，cglib 的动态代理有如下要求；</p>
<ul>
<li>目标类必须不是 final 的。</li>
<li>要代理的方法也不是 final。</li>
</ul>
<p>要使用 cglib 首先得添加<a href="https://mvnrepository.com/artifact/cglib/cglib">依赖</a>：</p>
<pre><code>implementation("cglib:cglib:3.3.0")
</code></pre>
<p>继续实现房产中介的例子。</p>
<pre><code>public class HouseAgentUtil {
    public static HouseOwner getCglibAgent(HouseOwner houseOwner) {
        // 创建代理类并设置为 HouseOwner 的子类
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(HouseOwner.class);
        // 设置方法调用回调
        enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -&gt; {
            System.out.println("带" + args[0] + "看房");
            switch (method.getName()) {
                case "sell" -&gt; System.out.println("签署销售协议");
                case "let" -&gt; System.out.println("签署租赁协议");
            }
            // 调用被代理对象的方法，透传参数与返回值
            return proxy.invokeSuper(houseOwner, args);
        });
        return (HouseOwner) enhancer.create();
    }
}

// 使用
HouseOwner houseOwner = new HouseOwner();
HouseOwner agent = HouseAgentUtil.getCglibAgent(houseOwner);
agent.sell("Chenhe");
</code></pre>
<p>可以看到在这个实现中完全没有使用到接口 <code>Owner</code>。<code>MethodInterceptor</code> 相当于是 jdk 动态代理中的 <code>InvocationHandler</code>，都是方法调用的回调。不同是的它有四个参数，前三个与 jdk 实现类似，最后一个是多出来的。</p>
<ol>
<li><code>Object obj</code> 是代理对象，也就是 cglib 动态创建的代理类的对象。</li>
<li><code>Method method</code> 是调用的方法，属于代理对象。</li>
<li><code>Object[] args</code> 传入的参数。</li>
<li><code>MethodProxy proxy</code> 专门用于调用原对象方法的参数，通过 <code>proxy.invokeSuper()</code> 实现。</li>
</ol>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2023-09-14T19:40:47.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Z-Library: 加入书籍的乌托邦]]></title>
        <id>https://chenhe.me/zh/posts/z-library-guide/</id>
        <link href="https://chenhe.me/zh/posts/z-library-guide/"/>
        <updated>2023-09-01T21:53:14.000Z</updated>
        <summary type="html"><![CDATA[Z-Library 旨在让每一个人平等地获得书籍。当然，无论多么高端的口号都掩盖不了法律角度上盗版和侵权的事实。不过法律是矛盾调和的产物，它...]]></summary>
        <content type="html"><![CDATA[<h2>简介</h2>
<p>Z-Library 旨在让每一个人平等地获得书籍。当然，无论多么高端的口号都掩盖不了法律角度上盗版和侵权的事实。不过法律是矛盾调和的产物，它只能解决很小的一部分问题。从某种程度上，Z-Library 满足了我对互联网乌托邦的幻想。因此决定写下这个文章，帮助更多人轻松使用它。</p>
<p>对普通人来讲，只需知道 Z-Library 是一个很全的书库，并且没有什么套路直接就能下载 pdf。</p>
<blockquote>
<p>Z-Library 可以免费直接下载 pdf（每天有额度限制）。任何强制捐赠/关注公众号/绑定银行卡的网站都是假的！</p>
</blockquote>
<h2>访问方式</h2>
<blockquote>
<p>⚠️ 警告：Z-Library 的使用需要畅通的国际网络。即使现在有个别域名可以从大陆无需 VPN 连接，被封禁也是早晚的事情。</p>
</blockquote>
<h3>浏览器插件</h3>
<p>Z-Library 项目已经被各国利益团体以及警察盯上了，所以不会再有一个长期的固定的可靠的域名来访问。为此维护团队开发了一个浏览器扩展，用于实时查询最新的可用网址。</p>
<p><a href="https://chrome.google.com/webstore/detail/z-library-finder/eebjmekegoofamhbnjoboeifabhbbddn/related">Chrome 浏览器点击此处安装</a><br />
<a href="https://addons.mozilla.org/en-US/firefox/addon/z-library/">Firefox 火狐浏览器点击此处安装</a></p>
<p>安装后点击 <code>Z-Library Finder</code> 插件就能自动打开一个可用网站。Z-Library 的所有官方域名账户都是互通的，但密码管理器（包括浏览器自动保存密码的功能）默认只识别注册时的域名，这个问题要注意一下。</p>
<h3>Telegram 机器人</h3>
<p>Z-Library 账户可以链接一个 Telegram 机器人，不仅防走失，还能享受跨平台的轻量级服务。同样处于防止封杀的目的，Z-Library 不提供官方机器人，而是需要用自己的 Telegram 账户创建一个机器人并交给 Z-Library 来维护。</p>
<p>具体步骤如下（自行安装并注册 Telegram，需要翻墙）：
<img src="https://img.chenhe.cc/i/2023/09/02/64f262d50b11f.png" alt="" /></p>
<ol>
<li>
<p>搜索 <a href="https://t.me/BotFather">BotFather</a>，点击 start 按钮。</p>
</li>
<li>
<p>输入或点击 <code>/newbot</code> 菜单。</p>
</li>
<li>
<p>发送机器人的名字，比如 <code>My ZLibrary</code>。</p>
</li>
<li>
<p>发送机器人用户名，必须以 <code>bot</code> 结尾，比如 <code>chenhes_zlibrary_bot</code>。</p>
</li>
<li>
<p>当 BotFather 回复如下一大段内容的时候表示创建成功。复制这些内容。</p>
<pre><code>Done! Congratulations on your new bot. You will find it at t.me/chenhes_zlibrary_bot. You can now add a description, about section and profile picture for your bot, see /help for a list of commands. By the way, when you've finished creating your cool bot, ping our Bot Support if you want a better username for it. Just make sure the bot is fully operational before you do this.

Use this token to access the HTTP API:
6591135100:AAEXbxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Keep your token secure and store it safely, it can be used by anyone to control your bot.

For a description of the Bot API, see this page: https://core.telegram.org/bots/api
</code></pre>
</li>
<li>
<p>进入 Z-Library 网站并登录，点击菜单按钮，点击 <code>Z-Access</code>，点击 <code>Telegram Bot</code>，把刚才复制的一大段内容粘贴进去完成绑定。
<img src="https://img.chenhe.cc/i/2023/09/02/64f2634343c6f.png" alt="" /></p>
</li>
</ol>
<p>这样就完成了。在 Telegram 里搜索自己的机器人 id，直接私聊书名或其他需要查询的内容就行。</p>
<blockquote>
<p>⚠️ Z-Library 作了限制，机器人只能响应创建者的请求，所以无法把自己的机器人分享给其他人使用。</p>
</blockquote>
<p>演示通过 Telegram Bot 向 ZLibrary 查询《三体》：</p>
<p><img src="https://img.chenhe.cc/i/2023/09/02/64f263dc769ef.png" alt="" /></p>
<h3>Tor 洋葱网络</h3>
<p>Tor 是另一个大话题。简单说，这是一个匿名的分布式网络，难以被取缔。不过从大陆访问 Tor 本身也需要代理，并可能被警察叔叔警告。因为无必要不建议使用本方式。以 <code>.onion</code> 结尾的一长串域名是 Tor 域名，无法通过常规浏览器访问。</p>
<p>Tor 使用方式也很简单，首先去<a href="https://www.torproject.org/zh-CN/download/">官网下载 Tor 浏览器</a>，支持各种操作系统。打开后连接 Tor 网络，直接输入 Z-Library 域名就可以访问：http://loginzlib2vrak5zzpcocc3ouizykn6k5qecgj2tzlnab5wcbqhembyd.onion</p>
<h3>客户端</h3>
<p>Z-Library 提供了 windows/mac/linux/android 的客户端。进入 Z-Library 网站并登录，点击菜单按钮，点击 <code>Z-Access</code> 后就能看到下载链接。ios 因为需要经过苹果审查所以无法上架。</p>
<p>我备份了官方客户端，供暂时找不到官网的小伙们们下载，<a href="https://dl.chenhe.me/t/zlib">点击跳转</a>。</p>
<h2>加速下载</h2>
<p>未捐赠的账户下载速度受到限制，但可以转存到谷歌网盘（Google Drive）再取回本地，实现高速下载。</p>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2023-09-01T21:53:14.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[爱尔兰留学全攻略-从出发到安家]]></title>
        <id>https://chenhe.me/zh/posts/ireland/</id>
        <link href="https://chenhe.me/zh/posts/ireland/"/>
        <updated>2023-04-04T21:56:39.000Z</updated>
        <summary type="html"><![CDATA[这有可能是你看过的最真实、最详细的爱尔兰留学生活攻略，具体来说：不仅会给出建议，而且会给出背后的原因供你自己判断决策。不仅会给出框架指引，还...]]></summary>
        <content type="html"><![CDATA[<p><strong>这有可能是你看过的最真实、最详细的爱尔兰留学生活攻略</strong>，具体来说：</p>
<ul>
<li>不仅会给出建议，而且会给出背后的原因供你自己判断决策。</li>
<li>不仅会给出框架指引，还会给出具体方案，包括所需网站链接、具体地点的地图定位。</li>
</ul>
<p>有问题可以通过 Telegram 联系我：<a href="https://t.me/chenhe">https://t.me/chenhe</a>，不用微信，谢谢。</p>
<p><strong>！除非得到明确授权，否则禁止以任何形式转载到短视频、小红书、公众号等任何平台！</strong></p>
<h2>你必须知道的背景</h2>
<p>要正确使用这份攻略，你必须了解以下写作背景：</p>
<ul>
<li>本文默认货币单位使用欧元（eur）</li>
<li>本文写于 2023 年 4 月，更新于 2023.8，某些信息具有时效性，但我会尽量给出能查询最新信息的链接。</li>
<li><strong>本文所有地图链接均使用谷歌地图，在大陆需自行解决访问问题。本文所有地点都分类标记在了<a href="https://www.google.com/maps/d/u/0/edit?mid=1xJ0CxbiSQZ-Kg6FUyakpgA9rOGBarBY&amp;usp=sharing">我的谷歌地图</a>中，打开就能查看，也可以加入到自己的谷歌地图。</strong></li>
<li>本文不会赘述被人说烂的东西，除非有必要的补充。</li>
<li>我的学校在都柏林，其他地区可能部分信息不适用。</li>
<li>这里默认是不支持银联卡的。</li>
<li><strong>少看小红书！少看小红书！少看小红书！</strong></li>
</ul>
<p>&lt;iframe src="https://www.google.com/maps/d/embed?mid=1xJ0CxbiSQZ-Kg6FUyakpgA9rOGBarBY&amp;ehbc=2E312F" width="640" height="480"&gt;&lt;/iframe&gt;</p>
<h2>出发前</h2>
<h3>建议携带</h3>
<p>先上清单（忽略掉常规物品）：</p>
<ul>
<li>小型电饭锅（无需多功能，但最好带压力）</li>
<li>驾照与翻译以及配套用品（见下文）</li>
</ul>
<p><strong>「电饭锅」</strong></p>
<p>包括我在内很多人可能觉得很傻（背一口锅去留学？？？）但这是非常明智的选择。原因如下：</p>
<ul>
<li>饮食习惯不同。这边人不常吃米，即使吃他们米的种类与烹饪方法与大陆截然不同，也买不到合适的电饭锅。我对他们米饭的评价是「味同嚼蜡」，注意这不是比喻，而是真真切切的体会。他们的米饭真的与直接吃白蜡烛一样，我吃两口就倒掉了。</li>
<li>买二手的潜在风险。首先部分同学可能有洁癖就不说了。开学季需求旺盛，不一定能买到合适的电饭锅。</li>
<li>行李额度占用不大。其实带一口锅没有想象中那么占用行李空间，锅的内部完全可以塞其他零散行李充分利用起来。</li>
</ul>
<p>需要注意的是<strong>不建议买多功能的电饭锅</strong>（带有火锅、煲汤等功能）。多功能电饭锅通常体积较大，不好携带。而且这类锅要么不带压力，要么需要两套锅盖才能实现多功能，非常占地方。而且我们假设米饭是必备的，那么其他功能就只能排队使用。事实上很少有这个闲情雅致先蒸米饭再煲汤。到地方买一个普通的锅，让电饭锅专注于米饭是更好的选择。</p>
<p>如果可能的话<strong>尽量买压力锅</strong>，可大幅节约烹饪时间。相信我，当你又要学习又要娱乐还要顾着做饭时，每顿少等 15 分钟可大幅提高幸福感。不要纠结与压力锅搞出来的米饭是不是不够香，身在国外，有米饭就已经很幸福了。</p>
<p><strong>「驾照与翻译」</strong></p>
<p>除非你铁了心的不出去玩，否则周边有很多国家值得旅游，并且花费比国内去三亚还要便宜。不过外国可没有中国那么发达便利的交通，自驾游几乎是爽玩的唯一选择。因此你需要的物品有：</p>
<ul>
<li>驾照（没啥可说的，注意有效期）</li>
<li>翻译件。没必要去公证，很贵而且语种单一。<a href="https://www.zuzuche.com/">租租车</a>注册付邮费可申请免费的翻译件，在国外租车很好用。</li>
<li>手机支架。不要买大的，不好带。推荐<a href="https://detail.tmall.com/item.htm?id=645367551346">这一款</a>，我知道看起来好像不太结实，但其实很结实！而且相信我，爱尔兰真的连个好用的车载手机支架都买不到。别嫌贵，来了爱尔兰更贵。</li>
<li>车载充电器。相信我，爱尔兰连个支持快冲的车载充电器都买不到（或者巨贵）。</li>
</ul>
<p><img src="https://img.chenhe.cc/i/2023/04/05/642ca8b7427bc.png" alt="" /></p>
<p>另外记得在国内把驾驶技术练好一点，至少：</p>
<ul>
<li>会停车，在没有辅助线的情况下倒库、侧方。</li>
<li>敢开，高速不低于 110km/h。闹市区会避让乱窜的行人。</li>
<li>有条件最好开个山路，国内有不少适合自驾游的山路。</li>
</ul>
<h3>不建议携带</h3>
<ul>
<li>国产手机，尤其是华为</li>
<li>零食等消耗品</li>
<li>过多的药品</li>
</ul>
<p><strong>「国产手机」</strong></p>
<p>尤其不要把华为设备带过来用！</p>
<p><strong>从2023.9开始工信部新规生效，未经备案的 App 无法安装或无法联网，意味着你无法安装必备的谷歌地图等软件。</strong></p>
<p>国产手机有两个问题：</p>
<ul>
<li>没有或阉割谷歌服务框架。注意谷歌服务不是普通的 App，无法简单地安装。这会造成需要国外必备应用无法正常使用。而华为更是与谷歌服务相互屏蔽，不要为了爱过给自己找麻烦。</li>
<li>另外，因为我们是优秀的社会主义民主国家，很多不和谐的东西被屏蔽了，而这个屏蔽有点一刀切。进而造成即使在国外网络畅通，使用国产设备你也无法访问一些哪怕是合法的东西。因为系统层面给限制了。</li>
</ul>
<p>所以如果用苹果设备，那么万事大吉。如果用 Android 手机，至少要买能刷成国际版系统的型号。（个人在三星，不刷国际版也能满足需求，毕竟不是国产）</p>
<p><strong>另外不建议到爱尔兰再买新手机</strong>，原因如下：</p>
<ul>
<li>爱尔兰电子设备较贵。</li>
<li>一些手机有锁区，或者运营商合约，如果回国可能不好用。</li>
<li>西方关于预装应用的法律不如中国完善，这边系统比较臃肿而且很多不能卸载。</li>
</ul>
<p><strong>「零食等消耗品」</strong></p>
<p>大概很多小伙伴已经听说爱尔兰是美食荒漠了，但千万不要傻乎乎地让一大堆零食占用宝贵的行李额度，理由是：</p>
<ul>
<li>零食消耗很快，哪怕是满满一箱零食，最多也就吃一两个月。</li>
<li>这边也能买到中国零食（包括老干妈等蘸料），并且价格不是非常离谱。</li>
</ul>
<p>如果实在想吃，强烈建议找转运公司使用铁路运输。行李额度还是留给紧急必要或者贵重的物品吧。</p>
<p><strong>「过多药品」</strong></p>
<p>首先爱尔兰是制药大国，无需担心买不到药。常见的布洛芬、扑热息痛等在这里同样非常常见，并且个人感觉效果比国产的好，而且不贵。另外携带过多的药品可能会在边检被拦下。</p>
<p>大概也有小伙伴听说这里医生很贵，排队很长之类的。事实上学校会提供全科医生（也就是 GP），我这里问诊是 25eur 一次，一周内就能预约到，可以开处方。所以哪怕是处方药也不用过分携带。</p>
<h2>到达后</h2>
<blockquote>
<p><strong>孤身一人初来乍到，学校国际处就是你的家人，是你在爱尔兰最大的保障！遇到任何事情都可以毫不吝啬地寻求国际处帮助。遇到紧急事件（例如手机被盗）除了报警外也务必通知国际处，他们会帮助你的。</strong>
优先直接联系学校国际处，不要联系中国招生部门，他们也只能转达，不如自己联系更快。</p>
</blockquote>
<p>到达爱尔兰后你需要完成的事有：</p>
<ul>
<li>离开机场（废话</li>
<li>买电话卡</li>
<li>办理公交卡 (TFI Leap Card)</li>
<li>办理居留卡 IRP</li>
<li>办理银行卡</li>
</ul>
<p>IRP 和银行卡是需要学校的介绍信的（不可以用 Offer 或校园卡）</p>
<h3>行李丢失</h3>
<p>恩...你可能感觉自己很不幸，但都柏林行李延误率巨高（2023 年有明显改善），所以也不用太过懊恼。在行李提取处找工作人员填申报单，他会给你一个回执上面有进度查询网址。一般 24h 后会有进度，行李会跟随后面的航班抵达，抵达后免费送到你填的住址。我当时 4 天后收到了，也有同学等了 11 天。</p>
<p><strong>因行李延误所购买的生活必需品（内裤，充电器等）是可以报销的，记得保留收据并在 1 个月内找对应的航空公司申诉</strong>，额度大约在 50eur 左右。申诉途径五花八门，总体思路是去航司官网，找对应的菜单。找不到就找客服发邮件（国外邮件联系很常见，一定及时查看收件箱）</p>
<h3>离开机场</h3>
<p>其实从机场到市区（这里把大柱子，也就是<a href="https://goo.gl/maps/MtYoGQ3nugn785ep6">都柏林尖塔</a>作为市中心）公交还是很方便的（公交指南请看下文专区）。但鉴于大家初来乍到，行李也比较多，手头又没有公交卡和零钱，所以也可以打车。这边打车软件是 <a href="https://www.free-now.com/ie/">FreeNow</a>，Uber 用的少。另外爱尔兰不允许私家网约车，只有正规出租。在机场没必要使用 App 打车，出门就能看见出租车候车区，找不到就找人问。</p>
<p>不知道怎么描述目的地就给司机看地图（记得把手机调成英文）。这边的出租都有明显标识与注册号，没有宰客现象。不过巨贵，可以用 FreeNow 看估价。<strong>注意出租车号悬挂在车顶而不是车牌号</strong>，应用打车的话别看错地方了。</p>
<blockquote>
<p>没有公交卡坐车按照距离一般不超过 3.3eur。可以告诉司机要去地方问价格。投币没有找零。</p>
</blockquote>
<h3>电话卡</h3>
<p>这边运营商主要有：</p>
<ul>
<li>3 - Three</li>
<li>沃达丰 vodafone</li>
<li>48</li>
</ul>
<p>爱尔兰的电话没有实名制，什么都不需要，直接去一个网点付钱就行了。就像买水果一样简单。充值可以去官网。AIB 的 App 也支持充值 vodafone/meteor/three/eir/tesco 的手机卡，打开 AIB App，点击左上角菜单，选择 Pay&amp;Transfer - Mobile top up 就行了。</p>
<p>各家信号的覆盖可下载 <a href="https://www.speedtest.net/">「Speed Test」的 App</a> 查看。</p>
<p><strong>注意国外充值的一个大坑，套餐没到期一定不要让余额超过套餐价格！否则它会立即扣费并重置套餐（重新计算套餐用量和日期）。所以每次充值都要卡着时间卡着金额。建议去官网注册自动充值。</strong></p>
<blockquote>
<p>电话卡上写的号码都是 0 开头的，比如 0920874759，用爱尔兰卡可直接拨打。但若加国家代码 353 就必须把开头的 0 去掉，比如 353920874759。</p>
</blockquote>
<p><strong>「3 Three」</strong></p>
<p><a href="https://www.three.ie/buy/prepay.html#sim-only">官网套餐介绍</a></p>
<p>爱尔兰比较大运营商之一，我在用。标准套餐是 20eur/28天，无限 5G 流量，19G 欧盟流量，无限短信，无限同运营商电话，1 小时其他运营商电话。信号一般。<a href="https://goo.gl/maps/Aejk3sAhSnZWj2FK9">市中心的网点</a>可直接购买。</p>
<p>9 月初可能会有活动，同等套餐降低到 15eur/28天，可优惠一年，此时就比 48 更有竞争力了。之前办理的不能享受优惠，但可以新办一个。</p>
<p>Three 官网的自动充值要先在官网用银行卡手动充值一遍才可以成功注册自动充值，否则没有任何错误提示就是注册不上去，非常恶心！第一次手动充值的时候别手贱冲多了，否则就被直接扣掉来重置套餐。</p>
<p><strong>「48」</strong></p>
<p><a href="https://48.ie/">官网</a></p>
<p>48 是 Three 的子品牌，虚拟运营商，主营廉价套餐。信号很差，比 Three 差。缺钱的可以选这个，需要在官网在线订购，会送到家里。激活后赠送 1G 流量，记得及时加入会员，也就是开通套餐。</p>
<p>12.99eur/28天。无限 4G 流量，12G 欧盟流量，300 分钟通话，无限短信。</p>
<p><strong>「vodafone」</strong></p>
<p><a href="https://n.vodafone.ie/shop/pay-as-you-go-plans.html">官网套餐介绍</a></p>
<p>沃达丰应该是信号最好的（曾经），也是最贵的。</p>
<p>无限 4G 流量限速 10Mbps，20eur/28天。5G 高速套餐是 25eur/28天。通话时长分为周末无限和其他时间，详细去官网看看吧。明显比其他运营商贵，但信号也明显好。</p>
<h3>办理公交卡</h3>
<p>爱尔兰公交票价现金超过 2eur，普通卡是 2eur，学生卡五折。另外刷卡乘车 90 分钟内免费无限换乘，还是很有必要。学生卡必须<a href="https://www.leapcard.ie/en/NavigationPages/CardPurchase.aspx">在官网申请购买</a>，过程中需上传照片（生活大头照也行）并支付 5eur 押金与 5eur 预存款，显然，银联卡是不行的。</p>
<p>付款后会收到邮件，在 14 天内到网点领取。一般学校内部会有代理点，具体位置与营业时间可以找学校国际处或学生会问，嫌麻烦就直接去<a href="https://goo.gl/maps/JAmQteBvdttHCUTd6">市中心的网点 Dublin Bus Head Office</a> 取。记得带 offer/校园卡/学校信其一都行。</p>
<p><strong>拿到卡后记得手动<a href="https://www.leapcard.ie/en/NavigationPages/CardRegistration.aspx">去官网注册</a></strong>，不注册如果丢了余额就没了！</p>
<blockquote>
<p>关于公交详细信息见下面专区。</p>
</blockquote>
<h3>办理居留卡 IRP</h3>
<p>IRP 用于证明你在爱尔兰的合法居留权，学生期间主要用于在签证失效后入境爱尔兰。即使你不打算离开爱尔兰旅游按照法律要求也得办。</p>
<blockquote>
<p>办理 IRP 必须要学校的证明信，offer/校园卡都不行。请联系学校国际处开具，他们轻车熟路，</p>
</blockquote>
<p><a href="https://www.irishimmigration.ie/registering-your-immigration-permission/how-to-register-your-immigration-permission-for-the-first-time/">移民局官网</a>支持简体中文，详细介绍了如何初次申请 IRP。都柏林和其他地区稍有不同，这里以居住在都柏林为例。原来是网上预约，后来大概是预约的人太多了，他们改成了电话预约，详细见<a href="https://www.irishimmigration.ie/burgh-quay-appointments/">这里</a>。</p>
<blockquote>
<p>IRP 预约后等待时间很长，如果没人恰好取消预约，可能要等 1-2 个月。不过 IRP 短期内也用不到，所以不必人在大陆就着急打电话预约。如果刚开学就要出国玩，申请多次签证就行了，签证有效期应该是 3 个月+，足够让你在拿到 IRP 之前多次进入爱尔兰。</p>
</blockquote>
<p>接线员良莠不齐，有的人可能非常有耐心语速很慢，有的可能就比较暴躁，甚至会让你找个英语好的朋友再打电话。不要气馁多打几次，准备好自己的护照，你需要报出护照号，姓名，国籍，出生日期，电子邮箱等。</p>
<p>遇到容易混淆的字母可使用单词辅助，例如「e, e for elephant」。有选择恐惧症不会组词的可参考<a href="https://baike.baidu.com/item/%E5%8C%97%E7%BA%A6%E9%9F%B3%E6%A0%87%E5%AD%97%E6%AF%8D/4157760">国际无线电字母解释法</a>的规范。</p>
<p>预约成功后会收到邮件，里面包含需要携带的资料，务必准时到达移民局，出示预约邮件（无需打印），<strong>记得带一支笔</strong>！注册需要缴纳 300eur，可以刷卡。具体来讲需要携带（原件/打印件）：</p>
<ul>
<li>护照</li>
<li>课程证明（需要学校信，offer 不行）</li>
<li>学费缴费证明（支付截图/回执等都行，比较随意，不用翻译）</li>
<li>保险单</li>
<li>一支笔用来填表</li>
</ul>
<p>要提供一个地址用来邮寄 IRP 卡，包括邮编（eircode）。邮编与实际地址转换可以在<a href="https://www.eircode.ie/">这个网站</a>查询。</p>
<p>移民局地址是 <a href="https://goo.gl/maps/PzGyHmnMFkZfKkVi6">13-14 Burgh Quay, Dublin 2</a>。</p>
<blockquote>
<p>预约后如果要改时间或错过预约等需要再次致电。</p>
</blockquote>
<h3>办理税号 PPSN 公共服务卡 PSC</h3>
<p><strong>PPSN 只有参与公共服务才需要，通常使用场景是工作和考驾照</strong>，没有需求的同学可以不办。PPSN 只是一个号码，PSC 是 PPSN 的实体卡，大部分情况下 PPSN 足以。不过办理 PSC 的过程也是认证 MyGovID 账号的过程（类似 12306 的线下核验），有了认证之后大部分业务可以在线办理，比较方便。</p>
<p><strong>申请 PPSN 无需 IRP</strong>，甚至人在中国就能去 <a href="https://services.mywelfare.ie/en/topics/identity-services/personal-public-service-pps-number/">MyWelfare</a> 在线申请，大概要 1-3 个月，非常离谱。通过后 PPSN 会邮寄到爱尔兰家里（没错，只是一个号码也要邮寄）。如果某一天发现申请记录没了，也没收到拒信就是通过了，耐心等待邮件吧。</p>
<p><strong>线下办理 PSC</strong> 可以在<a href="https://www.gov.ie/en/publication/be29c7-psc-online-registration-centres/">这里</a>查询网点，有些需要预约有些不用，需要携带的东西有：</p>
<ul>
<li>护照</li>
<li>地址证明（School Letter，带有地址的账单，IRP 邮件等任何物理信件）</li>
<li>手机和爱尔兰号码，需要现场收验证码</li>
<li>PPSN（如果有的话）</li>
</ul>
<blockquote>
<p>有些网点网站上说无需预约，实际上到了之后会给你安排一个当天的时间（可能是 N 小时后），注意留出充足时间。</p>
</blockquote>
<p>PSC 卡依然是邮寄过来，等 1-2 周。</p>
<h3>办理银行卡</h3>
<h4>传统银行</h4>
<blockquote>
<p>办理银行卡必须要学校的证明信，offer/校园卡都不行。请联系学校国际处开具，他们轻车熟路，</p>
</blockquote>
<p>大部分学生会选择 AIB 银行，你也可以选择 BOI 或其他银行。AIB 可以在 App <a href="https://play.google.com/store/apps/details?id=aib.ibank.android">「AIB Mobile」</a> 上在线申请，<strong>下载应用之前务必把应用商店切换到爱尔兰区</strong>。</p>
<p>打开 AIB 的 App 就应该能看到硕大的申请按钮，如果没有，那么别折腾了，估计是地区或系统问题，借同学手机或者去网点办理吧。手机申请需要填写一大堆信息，耐心一点。记得选择 <code>Student Plus</code> 帐户，会有各种减免。最后需要在 App 内部与工作人员视频连线，在此之前会告诉你需要准备哪些文件，找一个安静的地方按照要求依次把各种文件用摄像头对准就行。</p>
<p>完成申请后会显示你的申请编号，<strong>一定要记好！</strong> 然后等邮件（实体邮件！）大概要几周到一个月不等（你会慢慢习惯爱尔兰的效率的）收到这封信后里面是个确认码，使用确认号登录 App，按照信里的指示输入确认码，然后继续等待。银行卡就寄到家里了。</p>
<p>实测 AIB 的学生银行卡可在都柏林大大小小 ATM 上取钱，没有额外的手续费。</p>
<h4>数字银行</h4>
<p>传统银行在国际转账等方面手续费比较高。或者一旦毕业失去学生优惠，甚至每一笔交易都要扣钱，非常万恶的资本主义。欧洲比较流行的数字银行是 <a href="https://revolut.com/referral/?referral-code=chenhep1ua!MAY1-23-AR-L1">Revolut</a>，它的使用体验有点像支付宝，但属于正规的银行，你的帐户就是银行帐户，可以申请免费虚拟银行卡用于网络交易，或添加到 Google Pay 用于线下支付。也可以支付一点邮费申请实体卡用于 ATM 取现或大额支付。</p>
<p><strong>关于如何以优惠汇率还款中国信用卡，以及国际货币转换都用到 Revolut，详见下文「金融服务」章节。</strong></p>
<p>Revolut 另一个优势是提供免费的一次性卡，用于一些不太靠谱的网络支付，可避免信息泄漏被盗刷。</p>
<p>它的申请比较简单，全程在客户端操作就行了。如果有 IRP 就上传 IRP，否则要把爱尔兰签证与入境章一起上传才可以通过。欢迎使用我的邀请链接：<a href="https://revolut.com/referral/?referral-code=chenhep1ua!MAY1-23-AR-L1">https://revolut.com/referral/?referral-code=chenhep1ua!MAY1-23-AR-L1</a></p>
<h2>落脚安家</h2>
<h3>租房黑名单</h3>
<p>这里整理一些<strong>经过我亲自确认，并且网络反响恶劣的</strong>房东黑名单，请各位务必远离：</p>
<ul>
<li><strong>D11F662，微信昵称「雅琪」</strong>。房东在<em>收押金后</em>拒绝入住，拒绝协商，几乎害租客当天流落街头。并且疑似有被迫害妄想症。连续两年被人挂到网上。<a href="https://www.xiaohongshu.com/explore/64d8137d000000001201d356">查看详情</a></li>
</ul>
<h3>金融服务</h3>
<p>金融问题牵扯以下几点，如果不注意的话将损失巨额手续费/汇率差：</p>
<ul>
<li>如何在爱尔兰花中国的钱</li>
<li>如何把爱尔兰的钱转回中国</li>
<li>如何在爱尔兰之外的地方花钱</li>
</ul>
<blockquote>
<p>这里基于留学生的身份考虑，所以默认没有巨额交易。</p>
</blockquote>
<h4>中国-&gt;爱尔兰</h4>
<p><strong>TL;DR 最佳方案是：使用第三方公司汇款到爱尔兰💸，或直接刷中国两免信用卡 💳。</strong></p>
<p>在爱尔兰花中国的钱一般有两种途径：</p>
<ul>
<li>💳 直接刷中国的信用卡。<br />
根据卡片的不同收费不同。以最著名的中国银行卓隽卡为例，免年份，免交易费，免货币兑换费。所以没有任何损失并且可以获得积分。<br />
需要现金💵的话卓隽卡每月前 5 笔 ATM 取现免费，并且同样免货币兑换费。<br />
<em>关于信用卡还款怎么实惠，请参阅下面章节。</em></li>
<li>💸 把中国的钱汇过来然后刷爱尔兰的卡。<br />
银行电汇需要支付或多或少的「汇出行手续费+中间行费用+收款行入账费用」，并且没有什么汇率优惠，因此不推荐。第三方公司可能会有一些优惠，比如熊猫速汇，Remitly，价格实时变动需要自己比较。</li>
</ul>
<p>以人民币形式入账国外卡没意义也不现实，因为非离岸人民币不是国际货币。</p>
<h4>爱尔兰-&gt;中国</h4>
<p><strong>TL;DR 最佳方案是使用 Revolut 国际转账（外币入账），或其他第三方公司汇款（转换人民币入账）</strong></p>
<p>把爱尔兰的钱转回中国有两种类型：</p>
<ul>
<li>💶 以欧元的形式入账中国帐户，不存在货币转换。</li>
<li>💴 以人民币形式入账，需要货币转换。</li>
</ul>
<p>有小伙伴好奇，把以欧元形式入账中国，有啥意义呢？可以用来还信用卡呀！毕竟购汇还款汇率不好而且占用个人购汇额度。</p>
<p>不管哪种类型，也有两种渠道：</p>
<ul>
<li>🏦 银行汇款
与 中国-&gt;爱尔兰 类似，需要各种费用，不推荐。</li>
<li>💸 第三方公司比如 Revolut，熊猫速汇，Remitly。<br />
注意 Revolut 不支持转换人民币，而熊猫速汇不支持欧元入账中国卡。Remitly 没用过。</li>
</ul>
<p><strong>不要尝试办理爱尔兰卡然后在其他国家刷，这边非常资本主义，各种交易费、转换费让你亏到毛都不剩！</strong></p>
<h4>其他国家地区</h4>
<p>宗旨：尽量用中国的卡，或者用 Revolut 等数字银行的卡。他们的交易费/货币转换费较低甚至免费，具体🧐咨询你的发卡银行。</p>
<p><strong>千万不要用 AIB 等爱尔兰传统银行的卡！</strong></p>
<h4>中国信用卡还款</h4>
<blockquote>
<p>这里以中国银行卓隽卡为例</p>
</blockquote>
<p><strong>TL;DR 如果你一直用银行购汇，那么可选人民币记账，否则选外币记账。</strong></p>
<p>中行信用卡账单有两种模式，各有优劣：</p>
<ul>
<li><strong>人民币记账</strong><br />
优势：使用人民币还款，不牵扯购汇，不占用个人便捷购汇额度。<br />
劣势：中行汇率比较亏。如果额度不够，无法通过预存款的方式进行大额交易。</li>
<li><strong>外币记账</strong><br />
优势：可自行选择更优的外币兑换渠道，用外币还款。可以通过预先购汇、预存款的方式解决额度不足导致无法进行大额外币交易的问题。<br />
劣势：如果使用银行官方购汇，那么将占用便捷购汇额度，并且没有汇率优势。</li>
</ul>
<p><strong>如果使用外币记账，如何用更优汇率还款呢？</strong></p>
<ul>
<li>如果你手里有外币，通过上面说的途径以<em>外币入账</em>形式汇到中行储蓄帐户，直接还款。相当于没有汇率问题。</li>
<li>否则可以用上面说的优惠途径，先把人民币汇到外国银行，再以外币入账的形式弄回中国帐户。不够这样要支付多次汇款费用，具体是亏是赚需要实时计算。</li>
</ul>
<p><strong>如果手里的外币与银行卡外币不匹配呢？</strong></p>
<p>例如使用的是💵美元版卓隽卡，手里只有💶欧元。Revolut 提供每个月 €1000 免费货币兑换额度，用它把你的欧元换成美元，再转回中行的储蓄卡。</p>
<p>如果可能的话，仅尽量还是申请欧元版卓隽卡。虽然免货币兑换费，但每一次欧元-&gt;美元转换都依照卡组织汇率进行，还是有一点点损失的。</p>
<h4>关于外币钞/汇</h4>
<p><strong>TL;DR 尽量使用现汇。中国银行信用卡还款同时接受钞/汇，无转换手续费。但还款之后的溢存款（如果有的话）自动变成钞。</strong></p>
<p>钞/汇的概念反映的是中国的银行对外币实物保存、运输的成本。只有当需要对帐户里外币进行汇款/取现/结汇（也就是换成人民币）时才有实质影响。钞-汇转换要扣除转换费用。</p>
<ul>
<li><strong>钞</strong>：只能取现。用于汇款要先转换成汇。换成人民币比较亏。</li>
<li><strong>汇</strong>：只能汇款，用于取现要先转换成钞。可换成人民币。</li>
</ul>
<p>入账形式：</p>
<ul>
<li>外国银行以<strong>外币入账</strong>的形式汇款到中国银行，以<strong>现汇</strong>形式入账。</li>
<li>你拿着外币现金去中国银行存款，以<strong>现钞</strong>形式入账。</li>
<li>在中国银行 App 购买外汇，可自由选择入账形式。</li>
</ul>
<h3>家居五金</h3>
<p><strong>市中心的五金小店东西非常贵！请优先选择 Homebase。</strong></p>
<p>家居五金类一般有三个店买：</p>
<ul>
<li>IKEA</li>
<li>Penneys</li>
<li>Homebase</li>
</ul>
<blockquote>
<p>本文所有地点都分类标记在了<a href="https://www.google.com/maps/d/u/0/edit?mid=1xJ0CxbiSQZ-Kg6FUyakpgA9rOGBarBY&amp;usp=sharing">我的谷歌地图</a>中，打开就能查看，也可以加入到自己的谷歌地图。</p>
</blockquote>
<h4>IKEA</h4>
<p>就是宜家，没啥好说的，全球统一服务，统一装修风格。刚来这一般都去这里采购，商品都比较熟悉，不过货物种类比中国肯定是少很多。宜家在中国不算便宜，但是在这里已经属于平价商店了。每年 9-10 月开学季可能有活动，印象中力度还不小，好像是满 50 减 10 还是多少来着。记得留意海报，可以多人拼着买。</p>
<p>这边宜家标价不太清晰，建议下载个应用 <a href="https://play.google.com/store/apps/details?id=com.ingka.ikea.app&amp;hl=en_US">「IKEA」</a> 扫描商品条形码自己看价格。</p>
<p>网站：<a href="https://www.ikea.com/ie/en/">https://www.ikea.com/ie/en/</a> （注意链接里的 ie 表示爱尔兰地区）</p>
<blockquote>
<p>宜家的瑞典牛丸是你能廉价吃到的唯一全球味道一样的东西，完全没有本地优化。</p>
</blockquote>
<h4>Penneys</h4>
<p>小型廉价家居用品店，门店较多，也会卖一些低价服装。很多同学会来这里买床上套装，比宜家便宜大概 10-20eur，但质量也差一些。</p>
<p>网站：<a href="https://www.primark.com/en-ie">https://www.primark.com/en-ie</a></p>
<h4>Homebase</h4>
<p>同样是大型商场。这里更像是装修市场，包括五金/电动工具/油漆胶水/花园用品等，但也卖收纳盒等小型东西。因为很多人不熟悉刚来爱尔兰不敢去，更偏向宜家。但其实这里的东西更便宜。</p>
<p><img src="https://img.chenhe.cc/i/2023/08/20/64e131f53e7cb.jpg" alt="" />
<img src="https://img.chenhe.cc/i/2023/08/20/64e13212477a7.jpg" alt="" /></p>
<h3>食材</h3>
<p><strong>「肉类」</strong></p>
<p>爱尔兰的肉比蔬菜便宜。常规的猪肉、羊肉、牛肉、牛排在大型超市都能买到，无需特地去中超。注意一些名称对应：</p>
<table>
<thead>
<tr>
<th>中文</th>
<th>英文</th>
</tr>
</thead>
<tbody>
<tr>
<td>五花肉（猪肉）</td>
<td>Pork Belly</td>
</tr>
<tr>
<td>各种熏肉（有味道的肉）</td>
<td>包含关键词 bacon</td>
</tr>
<tr>
<td>羊肉</td>
<td>lamb</td>
</tr>
<tr>
<td>牛肉</td>
<td>beef</td>
</tr>
<tr>
<td>肋眼牛排</td>
<td>Rib eye steak</td>
</tr>
<tr>
<td>鸡胸肉</td>
<td>Chicken Breast</td>
</tr>
</tbody>
</table>
<p><strong>「蔬菜」</strong></p>
<p>这边蔬菜种类远不如中国全，<strong>当地超市</strong>接近一半的蔬菜可能我们都吃不惯。不过有些还是能买到的：</p>
<ul>
<li>番茄</li>
<li>小葱/姜/蒜/盐/胡椒粉/孜然粉</li>
<li>蘑菇</li>
<li>黄瓜</li>
<li>上海青（大号的）</li>
<li>菜花</li>
<li>西兰花</li>
<li>生菜</li>
<li>土豆
爱尔兰土豆巨便宜，但是比较黏，适合炖不适合炒。</li>
<li>四季豆角</li>
</ul>
<p>当然，这里肯定列不全，自己去逛一下。<strong>喜欢吃辣🌶️的建议去融兴行买小米椒，其他的都很淡</strong>。</p>
<p><strong>「水果」</strong></p>
<p>这边有些水果比国内便宜，推荐多吃点这些：</p>
<ul>
<li>🍌香蕉</li>
<li>🍊橘子</li>
<li>🫐蓝莓</li>
<li>🍐梨</li>
<li>🍈甜瓜</li>
<li>🍎苹果</li>
<li>树莓</li>
</ul>
<p>爱尔兰水果严重依赖进口，所以季节性不是很明显，几乎一年四季都能买到这些东西。</p>
<h3>公交</h3>
<h4>简介</h4>
<p>爱尔兰公交有三种：</p>
<ul>
<li>公交车 Bus</li>
<li>有轨电车 Luas</li>
<li>火车 Dart</li>
</ul>
<blockquote>
<p>办理 Leap Card 后无论是不是学生卡都享受 90 分钟免费换乘，包括 Bus-Luas-Dart 之间的换乘，还是很良心的。市区范围内免费，太远会扣差价。</p>
<p>打开 「Leap Top-up」 App 把卡靠近手机 NFC 读取后，App 顶部会显示免费换乘剩余时长（如果有的话）。</p>
</blockquote>
<p>公交车的运营时间一般是 5:00-24:00，部分线路 24h 运行。最简单的办法是用谷歌导航，选择一下出发时间，就能推荐出尚在运营的线路。<a href="https://www.dublinpublictransport.ie/timetables">官网</a>可以查询不同公交不同线路的运营时间。</p>
<p><a href="https://www.transportforireland.ie/fares/bus-fares/">这里</a>可查询公交票价（刷卡价）</p>
<h4>充值</h4>
<p>最便捷的方法就是安装 「Leap Top-up」 App 绑定银行卡用手机 NFC 充值。<a href="https://play.google.com/store/apps/details?id=ie.leapcard.tnfc">Google Play</a> / <a href="https://apps.apple.com/ie/app/leap-top-up/id1535791064">iOS</a>。如果 Google Play 搜不到这个应用，要么是你没有切换到爱尔兰区，要么是你的手机不支持 NFC。很多国产手机，尤其是 OV 系的都会阉割掉这个功能。</p>
<p>除此之外还可以去网点充值，或者 Luas/Dart 的售票机充值。详细见<a href="https://leapcard.ie/en/NavigationPages/LoginSelection.aspx">官网</a>。</p>
<h4>公交车 Bus</h4>
<p>爱尔兰公交车是按里程计费。1-3 站是一个价格，4-13 站是一个价格，超过 13 站是另一个价格。<strong>但是公交只需要上车刷卡</strong>，如果要乘坐的距离超过 13 站那么在右侧刷卡机自助刷卡就行。否则可以把卡贴在司机旁边的机器上，告诉他要去的位置，就可以省一点。</p>
<blockquote>
<p>爱尔兰公交站很密集，大部分时候都会超过 13 站。谷歌导航的时候会告诉你乘坐几站。</p>
</blockquote>
<p><strong>公交车下车需要按按钮</strong>，当车上有显示「Bus Stopping」的时候代表有人按过了，下一站会停车。<strong>等车的时候需要招手</strong>，所以在人少的站台别光顾着玩手机了。好心的司机会按喇叭提醒，但更多的时候好不容易等来的公交车只会呼啸而过... 另外并<em>不是</em>需要等报站更新后才能按，因为站很密集，有时候车开得快报站器来不及更新的，你不按他就不停。</p>
<p>爱尔兰公交站命名极为混乱，经常报站与名字不符，或者<strong>多个站重名</strong>。一定要看着点地图的位置，或者根据站台编号来找。不要依赖报站。</p>
<p>谷歌地图上的公交时间是根据时刻表和大数据推算出来的，并不准确。准确的实时公交得看站台显示器，或者下载 <a href="https://play.google.com/store/apps/details?id=com.trapezegroup.TFILive.nta">「TFI Live」</a> App 看。</p>
<blockquote>
<p>公交车下车如果人少（比如就你一个人要下车）或者碰上心情好，可以对司机说「Thank you」，不说也并无大碍。</p>
</blockquote>
<h4>有轨电车 Luas</h4>
<p>这边的电车非常鸡肋，实在想不出应用场景。千万不要把它视为地铁的平替。Luas 速度相比 Bus 没有快多少，且轨道在地面所以同样要等红绿灯。平常导航的时候很少要求做 Luas。</p>
<p>Luas 每站必停不用按按钮。站台附近要刷卡机，<strong>上下车都要刷卡</strong>。尤其是上车，在站台刷卡千万别忘了，被查到要罚款的。</p>
<blockquote>
<p>奥对了，Luas 的红线，实际车厢颜色是橘黄色🟧...</p>
</blockquote>
<h4>火车 Dart</h4>
<p>火车通往郊区的城镇，周末出去玩经常坐。与中国火车不同，无论是乘坐方式还是客运距离，这里的火车更像是地铁的平替。<strong>Dart 入口和出口有时有闸机有时没有，如果没有的话千万记得要主动找刷卡机刷卡（一般在月台或月台入口），上下车都要刷！</strong> 上车不刷被随机查票逮到好像是罚款 50eur，下车不刷会按最高里程扣费。</p>
<p>车站一般没有卫生间，但车里有。通常在最后一列车厢。</p>
<p>火车每站必停<strong>但门不会自动打开</strong>，需要上下车主动按一下门中间或旁边的按钮就行了。只有按钮显示绿色才可以按，否则按了也没用。</p>
<h2>日常生活</h2>
<blockquote>
<p>本文所有地点都分类标记在了<a href="https://www.google.com/maps/d/u/0/edit?mid=1xJ0CxbiSQZ-Kg6FUyakpgA9rOGBarBY&amp;usp=sharing">我的谷歌地图</a>中，打开就能查看，也可以加入到自己的谷歌地图。</p>
</blockquote>
<h3>超市</h3>
<blockquote>
<p>虽然欧洲食品卫生比中国严格，但因这边人力成本昂贵，其法律也有对应的调整。例如超市有可能会出现不新鲜甚至已经发霉（极少）的水果，没有及时下架。买的时候要自己看清楚，否则是不能退款或投诉的。</p>
</blockquote>
<h4>本土超市</h4>
<p>爱尔兰少有中国版的菜市场，这里的食材水果零食日用品都从超市购买。尽量不要去校园里的超市以及 SPAR 便利店，很贵。</p>
<p>常见的大型连锁超市有：</p>
<ul>
<li>Lidl
记得下载 App <a href="https://play.google.com/store/apps/details?id=com.lidl.eci.lidlplus">「Lidl Plus」</a> 每天都有不同品类优惠券。但这些商品不一定还有货。结帐时把二维码对着收银台旁边的扫码机就行。</li>
<li>Tesco
记得下载 App <a href="https://play.google.com/store/apps/details?id=com.puca.tesco&amp;hl=en_US">「Tesco Ireland」</a> 领取会员码，特价商品（黄色价格标签）还是不少的。</li>
<li>ALDI（在中国叫奥乐齐）
价格低著称。<strong>他家的盒装抽纸非常便宜好用</strong>，一盒只要 0.75eur（其他超市要 2eur 啊喂）</li>
<li>Dealz
比 ALDI 更便宜的廉价超市，适合买零食。不卖食材。</li>
<li>Dunnes Stores（分超市和百货店，详见下文）</li>
</ul>
<p>还有一些小型超市，包括杂货店，也可供选择：</p>
<ul>
<li>SuperValu</li>
<li>Mr. Price</li>
</ul>
<p><strong>「Dunnes Stores」</strong></p>
<p>这是一家大型混合超市，有两种门店，一种是百货商场，主打家居生活用品，另一种才是常见的超市。有时候两种店在一起，有时候独立，<strong>去的时候在地图上看清楚，两种店图标不一样</strong>。</p>
<p><img src="https://img.chenhe.cc/i/2023/04/05/642cc64534356.png" alt="" /></p>
<p>Dunnes Stores 有满 25 减 5 循环优惠券，适合每周固定时间来屯菜🥬。App <a href="https://play.google.com/store/apps/details?id=com.dunnes.valueclubcard">「Dunnes Stores」</a> 首次注册可获得初始 25-5 电子优惠券。提前注册好，点击后可能要等 20 多分钟才能领取成功，有效期 1 个月吧。后面的就只有 7 天了，是打印在小票上的别丢了（如果在结账时刷了会员码则优惠券可在 App 中查看）。</p>
<p>我常去的是<a href="https://goo.gl/maps/qmKKjvNPZYycDBR37">市中心 ILAC 里的这一家 Dunnes Stores</a>，比较大，货品全。</p>
<h4>中国超市（亚洲超市）</h4>
<p>常去的中超有：</p>
<ul>
<li><a href="https://goo.gl/maps/8bMEqjEHxvNqRHqV6">东方行</a>
除了米等少数商品，拿学生卡有折扣（好像是9折？）</li>
<li><a href="https://goo.gl/maps/uNgmkAAufMMFGU8o8">亚洲行</a>
很大，货比较全。正如其名，除了中国也有亚洲其他国家的商品。
在线商店：<a href="https://www.asiamarket.ie">https://www.asiamarket.ie</a> 支持配送</li>
<li><a href="https://goo.gl/maps/4oc7VoCQTEdRkgJz9">融兴行</a>
菜比较新鲜但也比较贵。经常有买一送一活动还是很合适的。
在线商店：<a href="https://www.353go.com">https://www.353go.com</a> 支持配送</li>
<li><a href="https://goo.gl/maps/iQm2PjFjgfiLFwC78">天福记</a>
比融兴行便宜</li>
<li><a href="https://goo.gl/maps/zy5y4FC5ZVyCVmNm7">金海湾</a>
不大，24 小时营业</li>
<li><a href="https://goo.gl/maps/begZ4TV49fo6EAiS9">东方市场</a>
除了中国，也有东南亚的一些东西。旁边还有个 Lidl。</li>
</ul>
<h3>餐厅</h3>
<p>爱尔兰因人力成本问题，在外吃饭也是巨贵，仅供不时体验，不建议长期这样，饭还是要自己做的。</p>
<ul>
<li><a href="https://www.wingsworldcuisine.ie/">Wing's World Cuisine</a> 自助，我认为都柏林性价比最高的自助。周一到周四中午有折扣，非常实惠。内部有现做的炒冰卷/鸡蛋饼。饮料也是正品百事七喜等。还提供冻酸奶/冰激凌等。</li>
<li><a href="https://goo.gl/maps/qv9crbeRGbuktE5x6">巴蜀人家 M&amp;L Szechuan Chinese</a> 川菜店。</li>
<li>McDonald's 麦当劳低价填饱肚子。下载爱尔兰麦当劳 App 每日都有优惠套餐。</li>
<li><a href="https://goo.gl/maps/f32miP4SqU9fYNNe7">翠鸟联排别墅酒店</a>。据说是都柏林最好吃的炸鱼薯条，非常具有本地特色，实测味道尚可。<strong>选择打包要比堂食便宜，一样的东西。</strong></li>
<li><a href="https://goo.gl/maps/SFSbUqeMrgCos2Pk8">Bullet Duck &amp; Dumplings 港式茶餐廳</a>，靠近 AIB，烤鸭和牛肉面都很好吃。</li>
<li><a href="https://goo.gl/maps/kPGYct1QYS1tTHDV9">Hilan Chinese and Korean Restaurant 海澜江</a> 口碑还可以的中餐店。</li>
</ul>
<p><strong>❌ 餐厅黑名单</strong></p>
<ul>
<li>福满楼一楼自助。非常难吃！！价格相比 Wing's World Cuisine 并没有便宜很多。任何时候都不要去！</li>
<li>海底捞。这是个假的海底捞，也有自助小火锅，不好吃。非要吃建议自带锅底。</li>
<li>福满楼二楼自助火锅。比海底捞便宜，菜品更少，但二楼允许选择一楼的东西，非要吃建议自带锅底。</li>
<li>李记。常规餐厅，没有多好吃，多次因为卫生问题被整改。</li>
</ul>
<h3>洗护用品</h3>
<p>Boots 是爱尔兰比较大的洗漱护肤美容用品店。结帐的时候办理会员卡并出示学生卡有折扣。<strong>后面必须同时带两张卡才能享受折扣</strong>。</p>
<p><strong>最好购买认识的大品牌</strong>，比如妮维雅(NIVEA)，欧莱雅(L'OREAL)，海飞丝(head&amp;shoulders)，露得清(Neutrogena)等。奇奇怪怪的品牌可能会有惊喜（惊吓）。<strong>不要去廉价超市买太便宜的（1-2eur 一大瓶）</strong>，尽管他们也是大品牌，但明显去污能力差还难闻。</p>
<p>也不见得非要去 Boots，Tesco 或 Dunnes Stores 等大超市甚至药店 Warehouse 也有卖的。也可以去亚马逊看看。</p>
<h3>药品</h3>
<p>爱尔兰最便宜的药店是 <a href="https://goo.gl/maps/jcZRYYFrVWXtEr7y6">Warehouse</a>，在市中心，价格明显便宜，并且品类齐全质量可靠。拿着处方也可以买到处方药。这里也卖保健品。<strong>因为爱尔兰严重缺少光照，推荐买瓶维生素 D 吃吃</strong>。</p>
<p>尽量不要在校园内的药店买东西，很贵。</p>
<h3>邮寄</h3>
<h4>中国 -&gt; 爱尔兰</h4>
<p>中国到爱尔兰常用的有两个渠道：</p>
<ul>
<li>官方渠道（中国邮政，顺丰国际等）</li>
<li>集运（铁路/海运/空运）</li>
</ul>
<p>官方渠道有保障，价格昂贵并且需要交税，仅适合运输证件等物品。大部分证件只能通过官方渠道寄递，集运不收。</p>
<p>集运是个统称，随便搜一搜有无数商家在做，小红书上的软文更是铺天盖地。所谓集运就是第三方（甚至第四方）货运代理把你和其他人的货打包成集装箱运输过来，再拆开通过当地快递（通常是 UPS）派送上面。收件流程与官方快递没有什么不同。价格便宜，部分线路包税，但是有风险。不同的代理公司不同渠道风险不同，一旦被扣基本东西就没了，也不会得到什么赔偿。传言被扣的概率是 3/1000。只需要把东西寄到集运公司的仓库（通常在广东）就行，非常适合淘宝后批量运输。</p>
<p>官方渠道没啥好说了，集运我个人用过三个，目前比较推荐的是「顺吉速运」。我用它寄过电动自行车，也寄过零食衣服电器混装的包裹，都在时效内按时送达了。<strong>不要随意相信小红书！不要相信小红书！不要相信小红书！</strong></p>
<p>集运价格与公司/线路/数量有关。通常到爱尔兰铁路/海运价格为 40-50CNY/kg，时效为 1-2 月。空运价格为 80CNY/500g，时效为 2-4 周。<strong>不要相信商家给出的离谱时效，因为他把等待飞机起飞的时间忽略了。</strong> 运输大型物品/敏感物品前和商家私聊沟通，确认能否运输以及有没有优惠。</p>
<h4>回寄</h4>
<p>回寄也有两个渠道</p>
<ul>
<li>官方（anpost）</li>
<li>第三方</li>
</ul>
<p>从爱尔兰寄回国时两者没有明显的价格差异。通常选择 anpost 就行，至于某些东西能不能寄直接询问客服。注意这边通常要自己打包，Homebase 可以买到纸箱，缓冲物等打包用品。</p>
<h3>爱尔兰驾照</h3>
<p>中国大陆驾照配合国际翻译件（或英文公证）可合法驾驶一年。超过一年虽然可以继续租车但原则上是违法的。</p>
<p>爱尔兰驾照考试流程有三个主要步骤：</p>
<ol>
<li>理论考试，通过后取得 Learning Permit （L 驾照）。</li>
<li>完成强制性 12 学时（小时）学习 (EDT)，并在取得 L 驾照后至少过了 6 个月。</li>
<li>通过路考取得正式驾照 Full License。</li>
</ol>
<p>一些爱尔兰学车的要点提示：</p>
<ul>
<li>爱尔兰没有强制性驾校，需要自主报名。</li>
<li>持有中国驾照超过 6 个月可申请减免考试前 6 个月等待期。</li>
<li>持有中国驾照超过 2 年可申请减免 6 学时的 EDT（所需文件和申请方式见下文）。</li>
<li>L 驾照必须有超过 2 年正式爱尔兰驾照的人在副驾培同才可上路（没错，通过理论就能上路），但不允许上高速。</li>
<li>EDT 教练通常有教学车辆，考试需要用自己的车，没有可租车考。</li>
</ul>
<h4>理论考试</h4>
<p>报名理论考试 (Driver Theory Test / DTT) 的前提是有 PPSN 或 PSC 实体卡，理论考试自己在<a href="https://theorytest.ie/book-your-theory-test/driver-theory-test-car-or-bike/">官网</a>预约，目前价格是 45eur。理论考试分为 AW 摩托车与 BW 汽车/拖车，别点错了。通常预约的时间在半个月后，运气好的话可以约到一周之内的。预约之后也可以更改时间。</p>
<blockquote>
<p>建议把视力测试一起预约，考试通过后申请 L Permit 需要。视力报告有效期一个月。</p>
</blockquote>
<p>理论考试直接用<a href="https://theorytest.ie/learning-app/">官方的 App</a> 刷题就行了（英文），三四天基本可以刷完，没必要买书。在意收费的可以自己去找第三方平台，愿意忍受广告和题库不全就行。</p>
<p>考试当天携带这些去考场（具体请查看预约邮件）：</p>
<ul>
<li>
<p>护照</p>
</li>
<li>
<p>预约邮件（无需打印）</p>
</li>
</ul>
<p>考场有免费的带锁储物柜，身上一切东西都要暂存，进入考场前有金属探测仪检查。考完试提交后可以立即查看分数，几分钟后也会自动通过邮件发送成绩。</p>
<h4>申请 L Permit</h4>
<p>原则上考试通过后可以立即申请 L Permit，但是系统会提示为避免数据不同步，尽量 6 小时后再提交申请。可以预约线下申请，也可以在线申请，无论何种方式都需要去官网 <a href="https://www.ndls.ie/">www.ndls.ie</a> 操作，申请费为 35eur。</p>
<p>申请 L Permit 需要视力报告，测试得预约。<a href="https://www.specsavers.ie/">Specsavers</a> 是爱尔兰比较大的连锁眼镜店，可官网预约驾驶视力测试，眼镜店会提供纸质报告无需自己打印。不同地区的预约时间和价格不一样，20~45eur 左右。高峰期可能关闭在线预约，可致电询问。有眼镜的话记得带过去，若裸眼视力不合格需要重新测试矫正视力，这种情况下驾照会备注 01（需要戴眼镜开车）。</p>
<p><strong>线上申请需要拥有 <em>经过认证</em> 的 MyGovID 账户</strong>，在办理 PSC 实体卡之后能获得认证，若只有 PPSN 要么选择先办理 PSC 要么只能预约线下申请。申请 L Permit 需要以下材料（具体请在申请或预约页面查看）：</p>
<ul>
<li>一个月内的视力报告</li>
<li>DTT 成绩单。在结果邮件中有查询地址，里面可以打印成 pdf。</li>
<li>六个月内的地址证明（School Letter，带有地址的银行账单等均可）（<em>仅线下需要</em>）</li>
<li>PPSN 证明（发给你 PPSN 的信件）（<em>仅线下需要</em>）</li>
<li>护照（<em>仅线下需要</em>）</li>
</ul>
<p>申请后 L Permit 会通过邮件寄过来，大概等 2 周。</p>
<h4>申请 EDT 减免</h4>
<blockquote>
<p>务必在 L Permit 申请通过后（邮件没到无所谓）再申请 EDT 减免！</p>
</blockquote>
<p><a href="https://www.rsa.ie/services/learner-drivers/learner-permit/the-6-months-rule">EDT 减免官方介绍传送门</a></p>
<p>所需材料：</p>
<ul>
<li>打印并填写的 EDT 申请表（只需第一页） <a href="https://www.rsa.ie/docs/default-source/services/1257-application-form-for-reduced-edt-and-or-exemption-from-6-months-waiting-time-dec-2022-web-final.pdf?sfvrsn=291e360_7">点击下载</a></li>
<li>中国驾照<strong>原件</strong></li>
<li>中国驾照翻译件原件（英文公证，租租车翻译件等权威翻译都可以）</li>
<li>Letter of Entitlement（详见下文）</li>
</ul>
<p><strong>EDT 申请表的 Address 认真填写，他们会按照这个地址回寄材料</strong>。准备好这些材料后去 Anpost 邮寄，他会给你一个对应大小的文件袋。邮费 3-5eur 左右，不放心也可以选择可追踪的方式，大概 11eur。邮寄地址 EDT 申请表有写，是：</p>
<blockquote>
<p>Reduced EDT, National Driving Licence Service, Po Box 858, Southside Delivery Ofice, Cork</p>
</blockquote>
<p>这个地址看起来可能有点随意，甚至没有邮编，不用管，直接抄到信封上就行了，没啥格式要求。</p>
<p><strong>关于 Letter of Entitlement</strong></p>
<p>可以线下或找人帮忙去车管所开安全驾驶证明。最简单的方法是在 「交管12123」App - 驾驶证 - 安全驾驶记录，在线申请。申请后约 2 小时可下载。</p>
<p>在线申请的文件可能不包含所有爱尔兰要求的信息：姓名/住址/生日/驾驶证号/起止有效期/初次领证日期/驾驶证类别和类别代码的解释。并且还要求提供由爱尔兰翻译协会认证的英文翻译件。</p>
<blockquote>
<p><strong>以上都是官方的要求。实际上有几个技巧</strong>：</p>
<ul>
<li>驾驶证类别和解释在驾驶证翻译件上都有，Letter 中可以不写。</li>
<li>任何权威的翻译，比如公证或者淘宝上有翻译公司章的翻译服务都可以。</li>
<li>PDF 是可以编辑的。并且交管生成的 PDF 里的电子章是一个独立的透明图层。并且如果是交管直接出具的英文版，就不需要翻译认证了。话我就说到这，怎么理解看你了。</li>
</ul>
</blockquote>
<h4>找教练完成 EDT</h4>
<p>拿到 L Permit 就可以约教练，EDT 减免还没申请或者还没通过也无所谓，只影响考试不影响练车（需要和教练沟通）。EDT 减免通过后会免除第 2,3,4,8,11,12 课，<strong>可以先学习 1,5,6,7,9,10 课</strong>（如果你对自己减免申请有信心的话）。</p>
<p>爱尔兰的教练叫 ADI (Approved Driving Instructor)，这边无需寻找驾校，只要是正规的 ADI 都可以。<a href="https://www.rsa.ie/services/learner-drivers/driving-lessons/find-an-instructor/approved-driving-instructor">RSA 官网</a> 可以找到所有经过认证的 ADI 列表。自己翻翻看联系他们，或者问熟人推荐。当前价格一般为 45-65eur/学时。可以一次性付款，也可以只买一课时，具体自己和教练商量。<em>不建议找小红书/短视频的推荐，一来很可能是营销号推广，二来知道的人太多了可能要排大队。</em></p>
<p>我比较懒，直接找了口碑较好的驾校（也就是中介平台）<a href="https://airportdrivingschool.com/">Airport Driving School</a>，他们负责安排教练，上门接送练车。一次性买 6 课时价格是 63eur/课时，属于贵的。</p>
<p>已经完成和需要完成的学习可以在 <a href="https://www.rsa.ie/services/learner-drivers/my-edt-portal">MyEDT Portal</a> 查看。每节课结束由你找的教练负责上报这些记录。教练通常会给一个培训记录本，这个是官方性质的文件，妥善保存。</p>
<blockquote>
<p>没有车的小伙伴可以自己用中国驾照租车练。在租车练习的过程中<strong>不要</strong>悬挂 L 标记，遇到交警检查（不太可能）直接出示中国驾照。非必要勿透露自己有 L 驾照/正在练车，不要找麻烦。</p>
</blockquote>
<h3>户外</h3>
<p>这里暂不包括需要租车/长途大巴才能前往的旅行目的地，而只列出适合单日结伴踏青的地方。</p>
<p>下面这些只是抛砖引玉，爱尔兰海岸线很长，自然环境非常优美。景区没有过度开发，大部分都保持了原生态的样子。千万不要辜负了这里的美景。</p>
<blockquote>
<p>本文所有地点都分类标记在了<a href="https://www.google.com/maps/d/u/0/edit?mid=1xJ0CxbiSQZ-Kg6FUyakpgA9rOGBarBY&amp;usp=sharing">我的谷歌地图</a>中，打开就能查看，也可以加入到自己的谷歌地图。</p>
</blockquote>
<h4>Howth</h4>
<p><a href="https://goo.gl/maps/AhiVQufWQgpbFCSt7">Howth</a> 是郊区最值得去的地方之一，Dart 和 Bus 都可以前往。Howth 是个港口，也包括了山，灯塔等。</p>
<p><img src="https://img.chenhe.cc/i/2023/08/20/64e1469632ecc.jpg" alt="" /></p>
<p><img src="https://img.chenhe.cc/i/2023/08/20/64e146dea1ae0.jpg" alt="" /></p>
<h4>Bray</h4>
<p><a href="https://goo.gl/maps/LWaN4Rq4ZgMo6NPS8">Bray</a> 是郊区最值得去的地方之二。Dart 和 155 Bus 都可以轻松到达，Dart 甚至有沿海轨道一直开到 Greystones。包含海岸线与小山，山顶有个标志性的十字架。</p>
<p><img src="https://img.chenhe.cc/i/2023/08/20/64e143e99f794.jpg" alt="" /></p>
<p><img src="https://img.chenhe.cc/i/2023/08/20/64e144acc04f8.jpg" alt="" /></p>
<p><img src="https://img.chenhe.cc/i/2023/08/20/64e144fb69198.jpg" alt="" /></p>
<h4>凤凰公园</h4>
<p><a href="https://goo.gl/maps/UEyQGnoGb81VwKkJ8">凤凰公园</a> 毫无疑问是都柏林市区必去的地方之一，有一望无际的草坪，闲逛的鹿群。其面积也是非常之大，有自行车的小伙伴建议骑行前往。</p>
<p><img src="https://img.chenhe.cc/i/2023/08/20/64e1479f5afc3.jpg" alt="" /></p>
<h2>关于物价</h2>
<p>爱尔兰物价上涨严重，如果想提前了解物价，或者了解下什么东西能买到什么买不到，在大陆就能访问这边的一些网站搜索看看：</p>
<ul>
<li>亚洲行：<a href="https://www.asiamarket.ie">https://www.asiamarket.ie</a></li>
<li>融兴行：<a href="https://www.353go.com">https://www.353go.com</a></li>
<li>宜家（爱尔兰）：<a href="https://www.ikea.com/ie/en/">https://www.ikea.com/ie/en/</a></li>
<li>亚马逊（英国区）：<a href="https://www.amazon.co.uk/?">https://www.amazon.co.uk/?</a></li>
<li>亚马逊（德国区）：<a href="https://www.amazon.de/?">https://www.amazon.de/?</a></li>
</ul>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2023-04-04T21:56:39.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[pmset 电源管理让 M1 MacBook 乖乖小憩 | 极致续航]]></title>
        <id>https://chenhe.me/zh/posts/m1-macbook-power-nap/</id>
        <link href="https://chenhe.me/zh/posts/m1-macbook-power-nap/"/>
        <updated>2023-03-14T19:27:28.000Z</updated>
        <summary type="html"><![CDATA[自从 M1 芯片的 macbook 买到手，搁着不用总是一周就没电了。昨晚充满观察了一下，一夜合盖休眠竟然消耗了 10% 的电。这篇文章比较...]]></summary>
        <content type="html"><![CDATA[<p>自从 M1 芯片的 macbook 买到手，搁着不用总是一周就没电了。昨晚充满观察了一下，一夜合盖休眠竟然消耗了 10% 的电。</p>
<blockquote>
<p>这篇文章比较硬核，伸手党请直接在「终端」应用中执行 <code>sudo pmset -b powernap 0</code> 关掉小憩，一晚上合盖 1% 都没掉。想撤销修改可执行 <code>sudo pmset -b powernap 1</code>。</p>
</blockquote>
<h2>mac 如何待机</h2>
<p>首先要明白「待机」是<strong>泛指</strong>计算机进入一个低功耗的、非正常工作的模式。当我们点击「睡眠」按钮，或者合上笔记本盖子，实际上它只是进入了笼统的待机，具体来讲有两个模式：</p>
<ul>
<li><strong>睡眠 (sleep)</strong>：持续向内存供电，可快速唤醒。</li>
<li><strong>休眠 (hibernate)</strong>：将内存数据写到硬盘，然后断电。唤醒时需要从硬盘恢复内存数据，较慢。</li>
</ul>
<blockquote>
<p>有些人把这两个模式反着翻译，请以英文为准。另外本文的翻译与 Windows 保持一致以减少混淆。</p>
</blockquote>
<p>那么电脑是否应该待机？待机后是睡眠还是休眠？这些由一系列开关、定时器、锁共同控制。</p>
<ul>
<li>开关：顾名思义，是否开启（允许）一个功能。</li>
<li>定时器：在指定的时间后进入某一模式。</li>
<li>锁：忽略所有东西，强行<strong>阻止</strong>待机。锁不控制具体的待机模式。</li>
</ul>
<p>在是否待机这个问题上，「锁」是开关+定时器的结果，也是系统决定的唯一参照。简单来说，mac 就像打工人，无时无刻都想待机，完全靠「锁」强撑着。而开关与定时器的意义就在于适时的加上/释放锁。若某一时刻没有锁了，就睡了。<br />
而在具体的待机模式选择上，就没有锁什么事了，要看开关与定时间的设置。</p>
<h2>消失的小憩</h2>
<p>在进入硬核的命令行工具之前，先来看看系统给了我们哪些可视化的设置。在 MacBook 的电池设置（台式机叫「节能」）里一直有两个开关：</p>
<ul>
<li>唤醒以供网络访问</li>
<li>启用电能小憩</li>
</ul>
<p>这俩的具体含义在<a href="https://support.apple.com/zh-cn/guide/mac-help/mchlfc3b7879/13.0/mac/13.0">官方支持</a>上可以找到，简单来说：</p>
<ul>
<li>唤醒以供网络访问：当电脑<strong>被访问</strong>时允许唤醒以提供服务。比如向外共享打印机或文件。同时也会定期唤醒来通知其他设备文件有更新（如果启用了文件共享的话）。<strong>注意，这个选项的意思<em>不是</em>「唤醒以便本机程序可以联网更新」。</strong></li>
<li>小憩：睡眠时允许唤醒来<strong>更新自身的数据</strong>，例如邮件、iCloud 同步以及时间机器。</li>
</ul>
<p>那么这两个选项各自的作用应该可以整明白了。在 macOS 12 的系统设置里，只对「小憩」做了解释，还算清晰。可是 macOS 13 增加了对「唤醒以供网络访问」的解释，用词非常迷惑，<strong>并且 m1 芯片的设备不再显示「小憩」的开关</strong>。</p>
<p><img src="https://img.chenhe.cc/i/2023/03/14/64104888df3c3.jpg" alt="系统设置" /></p>
<p>从上图可以看出，macOS 13 中对「唤醒以供网络访问」的解释非常类似小憩，<strong>给人一种这两个选项合并</strong>的假象。根据这个解释，可以理解为：关掉网络唤醒，就不会在后台更新数据了。<strong>然而事实是，无论关不关「网络唤醒」，「小憩」始终是打开的，休眠时始终进行后台更新，这就是休眠耗电的元凶了。</strong></p>
<p>感觉苹果好心机啊，对 M 芯片功耗太过骄傲，自以为是地默认开启后台更新。为了不让用户找麻烦，还修改了另一个选项的描述让你感觉可以关闭。看到网上有大量 m1 设备待机耗电的抱怨，各种分析最后也没彻底解决。</p>
<h2>pmset 电源管理</h2>
<p><code>pmset</code> (Power Management Set) 是一个 macOS 的命令行电源管理工具，系统设置里的相关选项与它互联，不过 pmset 提供了更详细的配置。</p>
<blockquote>
<p>我怎么认定「网络唤醒」与「小憩」没有合并的呢？在 <code>pmset</code> 中，「网络唤醒」管理的是 <code>womp</code>，「小憩」关联的是 <code>powernap</code>。</p>
</blockquote>
<p>注意，不同设备所支持的配置参数不同，很多参数比如 <code>standbydelaylow/high</code> 在 m1 设备上无效。大概还是因为苹果的自信，就去掉这些细节的节电策略了。</p>
<h3>配置查询</h3>
<p>注意区分：</p>
<ul>
<li>睡眠 sleep：保持内存供电。</li>
<li>休眠 hibernate：内存数据写入硬盘，内存断电。</li>
<li>standby：强调的是「睡眠→休眠」 这一过程，而不是一个模式。</li>
</ul>
<p>查询当前生效的配置（不一定是你设置的配置，因为其他程序可能会请求保持唤醒，也就是加锁）：</p>
<pre><code>pmset -g
</code></pre>
<p>查询你的设置：</p>
<pre><code>pmset -g custom
</code></pre>
<p>给出一个对照表：</p>
<table>
<thead>
<tr>
<th>属性</th>
<th>单位</th>
<th>备注</th>
<th>系统设置 (macOS 13)</th>
</tr>
</thead>
<tbody>
<tr>
<td>standby/autopoweroff*</td>
<td>0/1</td>
<td>允许从睡眠切换到休眠</td>
<td></td>
</tr>
<tr>
<td>powernap</td>
<td>0/1</td>
<td>电源小憩</td>
<td>电池 - 选项 - 启用电能小憩</td>
</tr>
<tr>
<td>networkoversleep</td>
<td>0/1</td>
<td>睡眠时如何处理共享网络。不支持修改</td>
<td></td>
</tr>
<tr>
<td>disksleep</td>
<td>分钟</td>
<td>关闭硬盘等待时间，0 为关闭</td>
<td></td>
</tr>
<tr>
<td>sleep*</td>
<td>分钟</td>
<td>睡眠等待时间，0 为不睡眠</td>
<td></td>
</tr>
<tr>
<td>hibernatemode*</td>
<td>0/3/25</td>
<td>待机模式。0:睡眠，25:休眠，3:先睡眠后休眠</td>
<td></td>
</tr>
<tr>
<td>ttyskeepawake</td>
<td>0/1</td>
<td>有活跃的 tty（终端会话）时不休眠</td>
<td></td>
</tr>
<tr>
<td>displaysleep</td>
<td>分钟</td>
<td>关闭显示器等待时间</td>
<td>锁定屏幕 - 不活跃时关闭显示器</td>
</tr>
<tr>
<td>tcpkeepalive*</td>
<td>0/1</td>
<td>允许 tcp 连接</td>
<td></td>
</tr>
<tr>
<td>lowpowermode</td>
<td>0/1</td>
<td>省电模式</td>
<td>电池 - 低电量模式</td>
</tr>
<tr>
<td>womp</td>
<td>0/1</td>
<td>允许网络唤醒</td>
<td>电池 - 选项- 唤醒以供网络访问</td>
</tr>
<tr>
<td>gpuswitch</td>
<td>0/1/2</td>
<td>0:集成显卡，1:独立显卡，2:自动</td>
<td></td>
</tr>
<tr>
<td>standbydelayhigh/low*</td>
<td>秒</td>
<td>从睡眠切换到休眠的等待时间</td>
<td></td>
</tr>
<tr>
<td>highstandbythreshold</td>
<td>0-100</td>
<td>剩余电量超过这个数 <code>standbydelayhigh</code> 生效，否则 low 生效</td>
<td></td>
</tr>
<tr>
<td>proximitywake</td>
<td>0/1</td>
<td>登录同一帐户的设备靠近时唤醒</td>
<td></td>
</tr>
</tbody>
</table>
<blockquote>
<p>如果执行 <code>pmset -g custom</code> 发现缺少一些属性，就是当前设备不支持。</p>
</blockquote>
<p>这些属性实际效果不能光看字面意思，很多时候是配合着用的，下面是几个常见的注意事项：</p>
<ul>
<li>sleep: 即使 sleep 计时器条件满足也不见得一定会进入待机，比如「屏幕开启」会阻止睡眠。需要所有条件都满足才可以睡眠。m1 macbook 电池模式下默认 <code>sleep=1</code>，也就是说睡眠等待时间实际上由 <code>displaysleep</code> 控制。</li>
<li>hibernatemode：具体是否会把内存写入硬盘还同时受到 <code>standby</code> 和 <code>autopoweroff</code> 的控制。
<ul>
<li>0：仅睡眠，不会把持久化内存中的数据。若掉电则丢失未保存的数据。</li>
<li>25：仅休眠。每次待机都持久化内存，并停止内存供电。重启时从硬盘恢复内存。显然唤醒时会慢一点。</li>
<li>3：混合模式，先睡眠再休眠。</li>
</ul>
</li>
<li>tcpkeepalive: 通过命令行关闭时有警告，会影响系统核心功能，比如 FindMyMac。实测开启影响不大，只需关闭 <code>powernap</code> 足够了。</li>
<li>standby/autopoweroff：这俩目前看作用一样，只是出现的背景不同。<code>standby</code> 是在 macbook 上延长续航，<code>autopoweroff</code> 为了让台式机满足欧盟节能要求，所以 macbook 没有这个属性。</li>
</ul>
<h3>修改配置</h3>
<blockquote>
<p>执行 <code>sudo pmset restoredefaults</code> 可恢复默认。</p>
</blockquote>
<p><strong>修改配置需要 sudo 权限</strong></p>
<p>修改配置命令格式为：</p>
<pre><code>pmset [-a | -b | -c | -u] [setting value] [...]
</code></pre>
<ul>
<li><code>-a</code>: 对所有模式生效</li>
<li><code>-b</code>: 对电池模式生效</li>
<li><code>-c</code>: 电源适配器下生效</li>
<li><code>-u</code>: UPS 供电下有效</li>
</ul>
<blockquote>
<p>若 <code>pmset -g custom</code> 的输出没有某些模式，就是设备不支持。</p>
</blockquote>
<p>下面给几个常用配置命令：</p>
<ul>
<li>
<p><strong>电池下关闭小憩（推荐）</strong>：</p>
<pre><code>sudo pmset -b powernap 0
</code></pre>
</li>
<li>
<p>电池下禁止 TCP 连接（可能导致部分系统功能不可用）：</p>
<pre><code>sudo pmset -b tcpkeepalive 0
</code></pre>
</li>
<li>
<p>电池下待机时强制休眠（持久化内存，断电）：</p>
<pre><code>sudo pmset -b hibernatemode 25
</code></pre>
</li>
</ul>
<h3>唤醒分析</h3>
<p><strong>查看从开机以来的睡眠/唤醒次数：</strong></p>
<pre><code>pmset -g stats
</code></pre>
<p>输出的结果：</p>
<table>
<thead>
<tr>
<th>字段</th>
<th>注释</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>Sleep Count</td>
<td>睡眠次数</td>
<td></td>
</tr>
<tr>
<td>Dark Wake Count</td>
<td>后台唤醒次数（不亮屏）</td>
<td></td>
</tr>
<tr>
<td>User Wake Count</td>
<td>亮屏唤醒次数（通常是用户手动唤醒）</td>
<td></td>
</tr>
</tbody>
</table>
<p><strong>查看详细后台唤醒记录：</strong></p>
<pre><code>pmset -g log | grep -e "Wake from" -e "DarkWake" -e "due to"
</code></pre>
<p>比较难看懂</p>
<ul>
<li><code>AOP.OutboxNotEmpty spu_queue_overflow_ep42</code>: 1-2 小时一次是正常的</li>
</ul>
<p><strong>查看待机锁：</strong></p>
<pre><code>pmset -g assertions
</code></pre>
<p>有时系统或应用程序会阻止进入待机，这些运行时的临时待机锁称为 <code>assertions</code>，它们会反映在实时电源配置里(<code>pmset -g</code>)，也可以通过上面命令查询。输出如下：</p>
<pre><code>Assertion status system-wide:
   BackgroundTask                 0
   ApplePushServiceTask           0
   UserIsActive                   1
   PreventUserIdleDisplaySleep    0
   PreventSystemSleep             0
   ExternalMedia                  0
   PreventUserIdleSystemSleep     1
   NetworkClientActive            0
Listed by owning process:
   ........
</code></pre>
<p>注意后面的数组代表对应锁是否启动，而不是锁的个数。下面会显示具体哪个进程启动了锁，以及是否有超时时间。通常 <code>UserIsActive</code> 和 <code>PreventUserIdleSystemSleep</code> 这两个锁都是启动的：</p>
<ul>
<li>UserIsActive: 有个系统进程在判断用户是否活跃，超时 120 秒</li>
<li>PreventUserIdleSystemSleep: 有个系统进程因为屏幕已点亮而持有这个锁，屏幕休眠后会自动释放的。</li>
</ul>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2023-03-14T19:27:28.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[塞尔维亚滑雪|科帕奥尼克白色世界全指南]]></title>
        <id>https://chenhe.me/zh/posts/skiing-in-serbia/</id>
        <link href="https://chenhe.me/zh/posts/skiing-in-serbia/"/>
        <updated>2023-02-26T19:56:00.000Z</updated>
        <summary type="html"><![CDATA[欧洲有许多滑雪天堂，但考虑到价格的话，很多天堂就要变成地狱了。除了一个发展中国家——塞尔维亚，总统武契奇，也就是577！这次滑雪之旅还是有一...]]></summary>
        <content type="html"><![CDATA[<p>欧洲有许多滑雪天堂，但考虑到价格的话，很多天堂就要变成地狱了。除了一个发展中国家——塞尔维亚，总统武契奇，也就是577！</p>
<p>这次滑雪之旅还是有一点波折，主要是网上尤其是小红书上那些所谓「完全攻略」，说好听点是内容空洞，说难听点就是屁话连篇，一句有用的都没有。真希望 Github 能出个权威的旅游攻略集锦，看看我们程序猿们是怎么写攻略的哼~</p>
<p>出行时间：2023.2.19</p>
<blockquote>
<p>本文默认使用塞尔维亚当地货币单位 rsd（塞尔维亚第纳尔）</p>
<p>本文默认跳转谷歌地图与 Google Play 应用商店，需自行解决网络访问问题。</p>
<p>雪场 Ski Pass 支持刷银联卡，其他大部分地方仅支持 Visa/Mastercard。既然出国，还是办一张外币卡吧。</p>
</blockquote>
<h2>准备</h2>
<p>萌新不建议买专业的装备，但以下是必备的（无需专门滑雪品牌）：</p>
<ul>
<li>滑雪袜子（绝不能含有棉，怕冷可以买美利奴羊毛的）
机缘巧合下白嫖了一双 X-SOCKS Ski Control 4.0 控制者袜子，虽然贵但真的舒服。朋友迪卡侬买的滑雪袜到下午就冷了，我这一双汗脚，丝毫没感到潮湿寒冷。个人认为袜子值得多花一点钱。</li>
<li>防水手套</li>
<li>速干内衣（一定不能穿含棉的普通内衣）</li>
<li>滑雪裤（可用防水裤子代替）</li>
<li>滑雪服（可用防水冲锋衣代替）</li>
<li>滑雪眼镜，可用太阳镜代替但摔倒容易掉。</li>
</ul>
<blockquote>
<p>为啥一直强调不能含棉呢。因为有时温差较大，中午太阳当空，傍晚寒风凛冽。滑雪又是激烈运动，经常流了汗乘缆车，棉的衣服不能及时排湿，怕是第一天就要感冒。</p>
</blockquote>
<p>想学单板的自带小乌龟。这边人们非常彪悍，完全不怕摔，这就意味着现场根本买不到这种东西。</p>
<p>萌新建议现场租用的东西：</p>
<ul>
<li>雪鞋</li>
<li>雪板</li>
<li>头盔</li>
</ul>
<p>我们当时的价格大约是 5000rsd/4天。</p>
<h2>交通指南</h2>
<p>塞尔维亚公交官网：https://www.bgprevoz.rs，市区报亭可以买公交卡，72 路与 A1 机场线是仅有的可现场找零购票的公交。</p>
<h3>贝尔格莱德-科帕奥尼克</h3>
<p>土豪或多人请自行联系住宿接机直达雪山🏔️，大概150欧元/车/单程。</p>
<p>否则可乘坐大巴🚌前往，大巴时间表可在 <a href="https://www.polazak.com/en/timetable/Beograd-Belgrade-RS/Kopaonik-RS/">Polazak</a> 查询但需要线下买票，贝尔格莱德始发站是 <a href="https://goo.gl/maps/9AhReQjWae9tHxEp8">Central Bus Station（塞尔维亚汽车中心站）</a>，从市区前往科帕奥尼克可刷卡，返程只接受现金。<strong>注意不要购买往返票，否则只能乘坐同一个公司的大巴</strong>，实际上一天只有四班车，而这四班分属 3 个不同的公司。车费 1880rsd/人，行李箱单独收费 30rsd/人，需要现金。</p>
<p>返程的时间表同样可以在上面网站查询，注意返程的车最早是下午才发车，因为需要先从市区开过来，注意时间安排。返程没有单独售票点，在大巴停车场找车向司机直接买就行。具体位置可以问住宿，或谷歌地图 <a href="https://goo.gl/maps/zc6s7WLMUSEGMKQY6">Teniski Tereni Parking</a>。</p>
<p>大巴单程近 6 小时。</p>
<h3>机场-贝尔格莱德</h3>
<p>赶时间可选择出租🚕，看下面的出租注意事项。打车到汽车中心站大概是 2200rsd。</p>
<p>建议乘坐 72 路公交车或 A1 机场巴士。这两个是仅有的支持现金找零购票的公交，都可以到达汽车中心站（也就是去科帕奥尼克大巴的始发站）。用谷歌地图导航的话给出的也是这两个线路。72 路是 150rsd/人，机场大巴略贵一点。这两个公交几乎是 24 小时的，具体可以在谷歌地图上指定出发时间导航，如果出现了相应线路的结果就代表有车。</p>
<h3>雪场-小镇</h3>
<p><img src="https://img.chenhe.cc/i/2023/02/27/63fbc7dc969e5.png" alt="" /></p>
<p>雪场住宿区域非常方便，出门就能滑雪了，也用不到什么交通。</p>
<p>如果不幸订完了或价格太高，就只能住在山下的小镇（比如我们）。尽管从小镇到雪场也有公交，但相信我，<strong>这绝对不是个合适的选择</strong>。滑雪本身就很累了，扛着雪板等公交，再走回家非常痛苦，估计没人能坚持下来。所以唯一的选择就是出租，打表单程大约 1200rsd，多数民宿都有合作的司机，不是正规出租，可以讲到 1000rsd 单程，电话随叫随到，还是不错的。</p>
<blockquote>
<p>如果住在山下小镇，并且乘坐大巴前往，记得在 <a href="https://goo.gl/maps/RCcLd1gNaYG1JF238">AS Market</a> 停车的时候下车，否则一路开到雪场就得花 1000+rsd 打车回来了。这里也是山下唯一的超市，东西挺便宜的，买点零食吧，可以刷卡。</p>
</blockquote>
<h3>出租</h3>
<p>塞尔维亚出租宰客<strong>特别严重</strong>，计价器都被改动过，而且即使是正常的价格也不低。部分攻略说可以在机场领取出租车乘车券，上面标明了价格防止被坑，事实是很多司机拒载。</p>
<p>一定只乘坐带有公司名称的出租车，比如 Pink Taxi。塞尔维亚没有统一的网约车平台，每个公司有自己的 app，可以不用绑定银行卡，到时候给现金就行。比如 Pink 在 Android 上的 app 就是 <a href="https://play.google.com/store/apps/details?id=com.netinformatika.pinkbeograd">Pink Taxi Beograd</a>。</p>
<p>机场到大巴站大约 2200rsd，市区内部应该不超过 1000rsd。明显价格异常可以拒付并报警，塞尔维亚治安较好，不太会升级到暴力事件。周围会英语的市民也很乐意帮忙。比如有人从机场到大巴站付了 100 欧元，明显有问题。</p>
<h2>滑雪指南</h2>
<p>注意英文表达：双板⛷才叫滑雪 Skiing；单板🏂叫 Snowboard。</p>
<h3>雪具租用</h3>
<p>如果有幸住在雪场，也就是 Ski Center，那么遍地都是租雪具的，找一个看着顺眼位置合适的就行。</p>
<p>如果住在山下小镇，建议打车<strong>不要</strong>到 Ski Center，需要走的有点远，而且还要穿着雪鞋爬楼梯。最好到②号雪道入口(no.2 slope entrance)，位置在<a href="https://goo.gl/maps/DXuaTM4qaSy7DgzK7">一家住宿附近</a>。这边有两家出租店，入口是②号道的末尾 1/4 处，顺斜坡滑下去就是缆车，从上面滑下来可以顺势回到入口，收摊时无需爬坡。（当然，纯萌新第一天可能需要拖着板下去，扛着板上来）</p>
<p><img src="https://img.chenhe.cc/i/2023/02/27/63fbd3195733f.png" alt="2号道入口" /></p>
<p>雪具租用一般只收现金，不用押金但是要押护照。如果租多天晚上可以带回家，不必在下班时归还。但是中途更换单双板要收费，一般是免费更换一次，之后10欧元/次，可以先问清楚。</p>
<h3>雪场</h3>
<blockquote>
<p>我这个菜鸡技术有限，这里只介绍萌新与刚入门的推荐场地。</p>
</blockquote>
<p>雪场官网为：https://www.kopaonik.rs/</p>
<p>雪场本身是免费的，其他设施包括但不限于：新手村魔毯、牵引梯、缆车都是收费的，使用 Ski Pass 通行证，一次购买当天可无限制使用所有设施。只要你想好好滑，哪怕是萌新也需要购买 Ski Pass，否则扛雪橇上坡就会消耗超过 1/2 的体力，得不偿失。</p>
<p>Ski Pass 据说买 2 天及以上会便宜点，我们一次买了两天，不包含夜场，每人是 7480rsd。官网上有具体票价，可刷卡。<strong>购买后把票放在<em>左侧</em>裤子/袖子/外套的口袋，不要和手机混在一起</strong>，过闸机自动感应，无需拿出来。</p>
<blockquote>
<p>小技巧，临时玩一天可以尝试在 13:00 左右去收当天不玩的人的通行证，大概 15 欧元。虽然通行证是实名的，也有拍照。但实际上根本没人管，我已经帮你忙试过了。在休息区看那些带着雪橇衣服有点湿，不想动的人，问「Have you finished?」就有概率可以买到二手票。</p>
</blockquote>
<p>雪场最底部（北边）有两个新手村，三个缆车和一个牵引梯。</p>
<p>一个新手村是东西向的，较长，很平缓。另一个在 3 号道结尾，较短，陡一点点。两个新手村均有魔毯（传送带电梯）。</p>
<p>除了新手村外科帕奥尼克有蓝道、红道、黑道，从零学了一两天基本就能上道了。每个雪道都有圆形数字标识，牌子的背景代表级别，蓝色最简单。</p>
<p>刚刚离开新手村的同学建议乘坐最东北的缆车，出来后左转，滑⑦号道（不是 7A 或 7B）。或者乘坐最西面的缆车（不是牵引梯）下来左转滑 ③号道。<strong>千万不要作死乘坐中间的 4 号缆车直达④号红道，否则可能面临下不来的尴尬场面。</strong></p>
<p><img src="https://img.chenhe.cc/i/2023/02/27/63fbdcd7d6745.png" alt="" /></p>
<p>7/3 觉得差不多了后试试看 7A/B。之后可以下③号缆车后右转玩①号道。①道不会滑到山底，而是到半山腰乘坐另一个缆车返回起点，大幅节约在山底缆车排队的时间。</p>
<p><img src="https://img.chenhe.cc/i/2023/02/27/63fbde9833557.png" alt="" /></p>
<p>7/3/2/1 都玩顺溜之后可以试试看红道④。<strong>④道也是在半山腰结束，终点有两个缆车，靠上的回到⑦道的起点，靠下的到更高的山顶</strong>，根据对④号的感觉自己选择，别坐错了！</p>
<p><img src="https://img.chenhe.cc/i/2023/02/27/63fbe0589ae20.png" alt="" /></p>
<h3>紧急救援</h3>
<p>整个雪场信号覆盖都很好，有手机就能呼叫救援。买了 Ski Pass 后背面有三个电话，<a href="https://www.gss.rs/">GSS</a> 是塞尔维亚的非营利山地救援服务，第一个 GSS KOP 就是科帕奥尼克雪场的紧急电话。</p>
<p>基本的救援服务是<strong>免费</strong>的，包括雪地摩托运送、x 光拍摄（如有必要）、石膏固定等。至于进一步治疗是否收费目前不清楚。如果语言不通可以请求附近人帮忙打电话。</p>
<p>确认受伤后急救中心会开具凭证，可退还剩余 Ski Pass 的费用。被拉走后会送到雪场旁边的临时急救中心（Emergency），亲友找人问一下就知道位置了。</p>
<h3>教练</h3>
<p>萌新如果想好好玩，务必找个教练。虽然教的内容和 B 站几乎一样，但关键在能帮你纠正动作，这些自己一个人很难察觉，往往摔了好几次都不知道因为啥，其实可能只是发力不对而已。</p>
<p>2023 年一对一教练价格统一为 40欧元/小时（已经比国内便宜了啊喂）。私下可以协商折扣，比如买五赠一或35欧元/小时等。根据同行小伙伴经验，买 2 个课时（也就是 2 小时）就能自己上蓝道了。</p>
<p>这边教练也是鱼龙混杂，其中一个比较大的机构是 SnowStarsTeam，官网为 https://www.skolaskijanja.rs/，建议<strong>电话预约</strong>，不要网上预订，他们系统巨难用。<strong>只收现金</strong>。这家在最东北的缆车附近有个红色的小桌子称为 MeetingPoint，到预约的时间在这等着就行了。</p>
<blockquote>
<p>他们的英文不是很好，建议只使用简单句，甚至关键词，例如：</p>
<p>I'd like to book a skiing(snowboard) coach/instructor/teacher. Tomorrow, 12 o'clock.</p>
<p>教练的三种说法换着说，有些人只能听懂其中一个单词。</p>
</blockquote>
<p><strong>双板可以临时找，单板至少提前一天预约，否则很可能找不到教练。</strong></p>
<p>然后是找教练的几个注意点：</p>
<ol>
<li>即使有教练，也需要买 Ski Pass，否则不能使用缆车。纯萌新第一节课可以不用 Pass。</li>
<li>教练从见到你就开始计时，包括缆车排队的时间。所以如果有能力就尽早上 1 道这种终点在半山腰的，那里排队少。</li>
<li>如果有预约务必准时，否则从预约时间开始计时，不会等你。</li>
<li>沟通清楚自己已经会了什么需要学习什么，避免浪费时间浪费钱。一般至少要说清楚是不是已经会梨式（八字）刹车/转弯。</li>
<li>如果觉得不错单独要一下教练电话，后面私下预订，方便并且可以要折扣。<strong>注意 +381 才是塞尔维亚区号，+382 是黑山，买的手机卡默认打不了</strong>。</li>
</ol>
<h2>生活指南</h2>
<h3>电话卡与现金</h3>
<p>机场出来就能买电话卡，买 Yettel. 的，信号好不少。当时是 600rsd 15G 流量 15 天有效期，可打电话。十分便宜。不要使用中国的国际漫游，贵不说，墙还会继续存在，回来连谷歌地图都打不开。</p>
<p>现金记得多带一点欧元，1000eur 7天滑雪足够了，500eur 其实差不多。机场汇率没有很坑，可以直接换。虽然 ATM 也能取现吧，汇率贵了 10%。所以上策是兑换现金，其次是直接刷卡，尽量不要取现。</p>
<blockquote>
<p>因为汇率问题，消费 rsd 时对于十位或个位不要特别计较，大部分情况当小费就行了。餐厅可以把找零当作小费，或者给 100~200 rsd 左右。</p>
<p>PS：截止写文 100rsd=6.25cny，计较 10rsd 相等于计较 6 毛钱。</p>
</blockquote>
<h3>饮食</h3>
<p>塞尔维亚饮食巨巨巨咸。我这种本来就重口味的都受不了了，在贝尔格莱德市区体验一两次当地食物就行了，之后还是找<strong>中餐或者麦当劳</strong>吧。在科帕奥尼克，如果住在雪场那就比较繁华，有各种（很贵）的饭店供君挑选。</p>
<p>如果和我一样住在山下小镇的话，这里一共也就是三四家餐厅，其中大部分只供应巨咸的食品。强烈推荐在<strong>唯一个超市</strong> <a href="https://goo.gl/maps/RCcLd1gNaYG1JF238">AS Market</a> 正对面的餐厅。名字不会打，看图吧：</p>
<p><img src="https://img.chenhe.cc/i/2023/02/27/63fbe7dcf0044.png" alt="很赞的餐厅" /></p>
<p>这家价格合理而且咸度算是适中的一个了，一份饭 600~1300rsd 左右。主要分为烤肉、披萨、沙拉（是咸的你敢信‼️）和主食 Main Dish。推荐牛肉 Beef Main Dish，应该是最符合国人口味，同时也是较便宜的一种（620rsd），自带的土豆泥不够吃可以配一个 120rsd 的圆饼 bread（这个饼真的贵）。</p>
<p>方便起见，同时也为了省钱和节约时间，<strong>晚饭之后再买一份打包回去，作为第二天的早饭</strong>，一般民宿应该是提供微波炉的。<strong>对面超市买点饼干面包</strong>，中午一两点就在雪场临时吃一点。否则在雪场吃的话，不仅很贵，而且很麻烦，并且要穿着雪鞋爬楼梯😩</p>
<p>稍有基础的可以背着小包滑雪，萌新还是算了，对重心影响挺大的。可以<strong>找个塑料袋把食物挂在新手村的围栏上</strong>，放心，没人偷的。</p>
<p>雪场<strong>里面</strong>的小餐馆（酒吧）可以讨免费的山泉水喝（SpringWater / TapWater），但是 Ski Center 的餐馆就没有免费水了，有点反直觉。最好还是自己灌一两瓶水放新手村栏杆附近，也比较好找。</p>
<h3>自驾</h3>
<p>我们这次没有租车，所以没太关注自驾相关信息。山顶路上经常有雪注意安全。至少我们去的时候，这里没有车装防滑链。可能因为这几天天气晴朗，遇上大雪或许就需要了。</p>
<p>②号雪道入口的停车场是 700rsd/天，4200rsd/周。Ski Center 没注意。自驾的话民宿选择带有停车的。山路比较窄，路边也几乎都被圈起来作为私家停车场了。</p>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2023-02-26T19:56:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Android 从 ListView 到 RecyclerView 的进化]]></title>
        <id>https://chenhe.me/zh/posts/android-from-listview-to-recyclerview/</id>
        <link href="https://chenhe.me/zh/posts/android-from-listview-to-recyclerview/"/>
        <updated>2023-01-31T17:56:12.000Z</updated>
        <summary type="html"><![CDATA[如果是近期才开始学习 Android 开发，恐怕连 ListView 都不知道是啥。遥想高中写第一个 App，Android 开发还在用 E...]]></summary>
        <content type="html"><![CDATA[<p>如果是近期才开始学习 Android 开发，恐怕连 <code>ListView</code> 都不知道是啥。遥想高中写第一个 App，Android 开发还在用 Eclipse 的年代，<code>ListView</code> 用的不亦乐乎，转眼间就成了众矢之的了。</p>
<h2>ListView</h2>
<p>与 <code>LinearLayout</code> 里插入一大堆 View 相比，<code>ListView</code> 也称得上是高性能。之所以这么讲，主要是它实现了两个关键功能：懒加载与复用。所谓懒加载就是只有某一项需要显示时才尝试解析 View、绑定数据。而复用则是随着列表的滚动，新项目直接使用那些不再可见项目的 View，只是重新绑定一下数据而已。</p>
<blockquote>
<p>当然，<code>ListView</code> 的复用不是天然的，需要开发者配合实现。同时为了节约多次调用 <code>findViewById()</code> 的开销，开发者还要记得套一层 ViewHolder 记录各个子 View。一个常见的写法是把创建的 ViewHolder 以 tag 的形式附着在 View 上。</p>
</blockquote>
<h3>二层缓存</h3>
<p><a href="https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/AbsListView.java;l=6988;drc=50e15241898fb194489835e49cad7f5f63147cec">官方</a>说 <code>ListView</code> 是二级缓存。具体负责缓存的类是 <a href="https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/AbsListView.java;drc=50e15241898fb194489835e49cad7f5f63147cec;l=6997"><code>AbsListView.RecycleBin</code></a>，每一个 <code>ListView</code> 内部都有它的实例 <code>mRecycler</code>，这里面可以看见两个缓存的定义：</p>
<pre><code>/**
 * Views that were on screen at the start of layout. This array is populated at the start of
 * layout, and at the end of layout all view in mActiveViews are moved to mScrapViews.
 * Views in mActiveViews represent a contiguous range of Views, with position of the first
 * view store in mFirstActivePosition.
 */
private View[] mActiveViews = new View[0];

/**
 * Unsorted views that can be used by the adapter as a convert view.
 */
private ArrayList&lt;View&gt;[] mScrapViews;
</code></pre>
<p><code>mActiveViews</code> 缓存屏幕上正在显示的 View，它主要在「数据没有改变但触发了重新布局」时发挥作用。注意 <code>Adapter.notifyDataSetChanged()</code> 方法中 ListView 默认数据改变了，不会做额外的检查。因此这个缓存的适用场景缩窄到了 <code>ListView.onLayout()</code> 因故被多次调用时。</p>
<p><code>mScrapViews</code> 则是一个列表数组，存储的是从屏幕上移出的 View。其外层数组下标是 <code>viewType</code>，这也是 ListView 要求 viewType 必须是从 0 开始的连续整数的原因。有两种情况会填充/利用这层缓存：</p>
<ul>
<li>当 <code>notifyDataSetChanged()</code> 被调用时，屏幕上的所有项目的 View 都会按照 viewType 保存到 <code>mScrapViews</code> 里，然后取出来重新绑定数据再显示。</li>
<li>当列表滑动时，不再处于显示范围内的 View 按照类型保存起来。新显示的项目则提取对应类型已缓存的 View，重新绑定数据后显示。</li>
</ul>
<p>由此可见，ListView 的离屏缓存（暂且这么叫吧）是不能缓存数据的，每次取出都得重新填充。而且这一步需要开发者的配合（在 <code>Adapter.getView()</code> 的实现里判断传入的 <code>convertView</code> 是否为空，采取不同的操作），要是代码写的不好，这层缓存就废了。</p>
<h2>RecyclerView</h2>
<h3>运行机制</h3>
<h4>宏观原理</h4>
<p>RecyclerView 三个最著名的组件是：<code>RecyclerView</code>, <code>LayoutManager</code> 和 <code>Adapter</code>，宏观的运行机制如下：</p>
<pre><code>flowchart LR
RecyclerView--&gt;LayoutManager
LayoutManager-.Request View.-&gt;Adapter
Adapter-.ViewHolder.-&gt;LayoutManager
</code></pre>
<p>这个宏观图有两个要点：</p>
<ul>
<li>是 <code>LayoutManager</code> 主动请求 <code>View</code>，而不是 <code>Adapter</code> 主动。只有 <code>LayoutManager</code> 才知道需要加载几个项目的 View，因为每个项目的大小都不确定，<code>Adapter</code> 没有能力得知当前屏幕可以摆下多少个。</li>
<li><code>LayoutManager</code> 与 <code>Adapter</code> 不直接耦合，它们中间还藏着一个组件。</li>
</ul>
<p>更加完整的图是这样：</p>
<pre><code>flowchart LR
RecyclerView--&gt;LayoutManager
LayoutManager--Request View--&gt;Recycler
Recycler--"onCreateViewHolder()"--&gt;Adapter
Recycler--"onBindViewHolder()"--&gt;Adapter
Adapter--ViewHolder--&gt;Recycler
Recycler--View--&gt;LayoutManager
</code></pre>
<p><code>Recycler</code> 接到 View 请求时首先检查自己的缓存，看是否有可用的，若有则直接返回。若没有则请求 <code>Adapter</code> 创建新的 View 或复用已存在的 View 但重新绑定数据。这里也蕴含这单一可信数据源的思想，<code>Recycler</code> 作为唯一可信的提供 View  的源，避免了 <code>Adapter</code> 同时负责创建和缓存，状态容易搞乱的问题。</p>
<h4>两次布局</h4>
<p>当数据改变，<code>RecyclerView</code> 重新布局时，实际上会向 <code>LayoutManager</code> 请求两次布局，分别叫做 pre-layout 与 post-layout。目的是实现动画效果。</p>
<p>假设目前屏幕上显示 ABC 三个项目，当删除 C 时，新项目 D 应该显示，并带有进入动画。提到动画，就少不了初始位置与结束位置。结束位置显而易见，初始位置就不好办了。根据 LayoutManager 的不同，D 应该从哪里出现是不确定的。因此需要两次布局，pre-layout 计算数据改变前的布局，<strong>但是要把即将要显示的项目计算出来（在这个例子中就是 D）</strong>。post-layout 自然是计算数据改变后的布局。有了前后两个布局，动画也就水到渠成。</p>
<blockquote>
<p>更新某项目，尤其是只更新它的个别 View 甚至 UI 上没有更新时，闪烁问题的罪魁祸首也是动画。默认项目改变动画的效果是：旧的 View 淡出，新 View 淡入。解决方案有两个：</p>
<ul>
<li>关闭预测性动画</li>
<li>使用真局部刷新</li>
</ul>
</blockquote>
<p>对于表项数据改变的情况，两次布局用到的 ViewHolder（以及背后的 View）必须是不同的对象。因为它们要用来做动画的，显然我们不能在同一个 View 上同时执行淡入和淡出效果。</p>
<h3>缓存</h3>
<p>首先最大的区别是 <code>RecyclerView</code> 吸取了开发者利用 ViewHolder 的经验，并改为强制使用。因此缓存的单位也从 View 变成了 ViewHolder。</p>
<p><code>ListView</code> 中保存缓存的是 <code>RecycleBin</code>，<code>RecyclerView</code> 中也有个类似的东西，是 <a href="https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/com/android/internal/widget/RecyclerView.java;l=5290"><code>RecyclerView.Recycler</code></a>，其内部核心成员是：</p>
<pre><code>final ArrayList&lt;ViewHolder&gt; mAttachedScrap = new ArrayList&lt;&gt;();
ArrayList&lt;ViewHolder&gt; mChangedScrap = null;
final ArrayList&lt;ViewHolder&gt; mCachedViews = new ArrayList&lt;ViewHolder&gt;();
RecycledViewPool mRecyclerPool;
</code></pre>
<h4>一级缓存</h4>
<p><code>mCachedViews</code> 中的 View 连重新填充都不用就可以直接显示，类似 ListView 中的 <code>mActiveViews</code>，但作用场景更广泛了，主要在滑动/回滚情况下能大幅提高效率。在 ListView 中这种操作最多只能使用 <code>mScrapViews</code> 级别的缓存，需要重新填充数据。在默认情况下 <code>mCachedViews</code> 最多允许 2 个元素，新缓存的 ViewHolder 会替换旧的，这一限制可通过 <code>RecyclerView.setItemViewCacheSize()</code> 更改。</p>
<p>具体来说，假设列表向上滚动，最顶上的项目将被移出屏幕，此时他们的 ViewHolder 会被保存到 <code>mCachedViews</code> 中。若用户又往下滚动，刚刚消失的项目重新显示，此时可直接从 <code>mCachedViews</code> 取出，不需要重新填充数据，即不会触发 <code>onBindViewHolder()</code> 方法。</p>
<p><code>mCachedViews</code> 中按照 position 查找，与 viewType 无关。</p>
<h4>二级缓存</h4>
<p><code>RecycledViewPool</code> 则对应 ListView 中的 <code>mScrapViews</code>，其内部缓存的 ViewHolder 按照 viewType 分类，取用时需要重新填充数据。<strong><code>mCachedViews</code> 因容量不足被挤出的元素会进入 <code>RecycledViewPool</code></strong>。<code>RecycledViewPool</code> 中定义了 <code>ScrapData</code> 来包装 ViewHolder，所以本质上，存储 ViewHolder 的是一个 Map 结构，类似 <code>SparseArray&lt;ArrayList&lt;ViewHolder&gt;&gt;</code>。由此可以推断出，RecyclerView 解除了 viewType 必须从 0 开始且连续的限制。</p>
<blockquote>
<p><code>SparseArray</code> 是针对 Android 优化的 Map 结构，相当于 Key 只能是 Int 的 Map。</p>
</blockquote>
<p>默认情况下每个 viewType 最多留有 5 个缓存，当然，也可以通过 <code>RecycledViewPool.setMaxRecycledViews()</code> 来修改。如果单一 viewType 显示的比较密集，通常可以考虑改成更大的值。</p>
<p>不过为啥要把这层缓存单独封装成一个类呢？一个明显的优势就是可以复用啦！<code>RecyclerView.setRecycledViewPool()</code> 可以手动指定回收池。例如 Google Play Store 竖直摆放了多个横向的滑动列表，它们的 ItemView 都是一样的，那么就完全可以共享一个回收池。</p>
<p>在 <code>mCachedViews</code> 未命中的情况下将尝试从 <code>RecycledViewPool</code> 根据 viewType 取缓存，若取到则重新填充数据后显示。</p>
<h4>局部刷新暂存</h4>
<blockquote>
<p>局部刷新有两层含义：</p>
<ol>
<li>只刷新数据改变了的那一项。</li>
<li>在某一项中，只刷新个别改变了的 View。</li>
</ol>
<p>这两个 RecyclerView 都支持，这里主要讨论第一个。</p>
</blockquote>
<p>调用 <code>notifyDataSetChanged()</code> 等方法后视为数据有更新，与 ListView 一样，这种场景下将跳过一级缓存，现有的 ViewHolder 直接进入二级缓存（<code>RecycledViewPool</code>）。这就有个小问题，<code>RecycledViewPool</code> 中每个 viewType 所允许的缓存个数是有限的，这个限制并不是针对这一场景而优化，因此很可能不够用，每一次数据全量刷新都要触发几次布局解析。其实就算命中缓存，也需要重新填充数据，这也是没必要的开销。</p>
<p><strong>所以记得调用局部刷新专用的 API，不要偷懒一股脑全量刷新了。</strong></p>
<p>局部刷新时，那些数据没有改变的项目的 ViewHolder 应该缓存以备后用。乍一听似乎应该由一级缓存 <code>mCachedViews</code> 负责，毕竟不需要填充数据就能用，但别忘了，它也有大小限制，并且不是为数据刷新这一场景优化的，所以额外引入了个无限制大小的缓存数组 <code>mAttachedScrap</code>。那些数据没有改变的项目重新布局时优先从这里取。</p>
<p>一些「RecyclerView 有四级缓存」的说法把 <code>mAttachedScrap</code> 算作第一层缓存，这样并不准确。多层缓存的说法往往意味着，数据总是按层读取。但 <code>mAttachedScrap</code> 相对来说有独立的数据流与使用场景：</p>
<ul>
<li><code>mAttachedScrap</code> 的生命周期局限于单次布局内部。</li>
<li>一次布局结束后，<code>mAttachedScrap</code> 里剩余的元素均要被移动到 <code>RecycledViewPool</code> 中，自身清空。</li>
</ul>
<p>对应的，改变了的项目的 ViewHolder 也会被暂存起来，放入另一个数组 <code>mChangedScrap</code> 中。</p>
<p>为啥要分开暂存？前面两次布局那一节提到过，对于「项目数据改变」这一场景，需要两个 ViewHolder，一个显示旧数据，一个显示新数据。pre-layout 时可以从 <code>mChangedScrap</code> 与 <code>mAttachedScrap</code>  两个暂存区取用，post-layout 时只能从 <code>mAttachedScrap</code> 取用。这样可以保证新旧数据一定不是同一个 ViewHolder 对象。<strong>由此可见，<code>mChangedScrap</code> 仅仅是为了防止重复取用而被分离出来，所以若是不需要动画，这个暂存区也就不工作了，而是所有元素都进入 <code>mAttachedScrap</code>，实践中有两个场景：</strong></p>
<ul>
<li>关闭预测性动画。</li>
<li>使用真局部刷新（调用双参数的 <code>notifyItemChanged()</code>），也就是本节开头说的局部刷新的第二层含义。</li>
</ul>
<h4>小节</h4>
<p>总结一下 RecyclerView 相比 ListView 在缓存上的改进：</p>
<ul>
<li>优化了一级缓存，使其适用场景变广（扩展了回滚场景）。</li>
<li>封装了二级缓存，可以多个 RecyclerView 共享回收池。</li>
<li>二级缓存可以根据 ViewType 设置不同的容量上限。</li>
<li>减少了 ViewType 值的限制，现在只要是 int 就可以。</li>
<li>支持局部加载，并设有专用的暂存区，免受缓存上限的影响。</li>
</ul>
<p>具体的缓存使用顺序写在 <a href="https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/com/android/internal/widget/RecyclerView.java;l=5531"><code>RecyclerView.tryGetViewHolderForPositionByDeadline()</code></a> 里：</p>
<pre><code>flowchart TB

subgraph "No Rebinding"
pre{{is pre-layout?}}
pre--YES--&gt;changedScrap[ChangedScrap]
changedScrap--&gt;AHC["AttachedScrap/Hidden/CachedViews (via pos)"]
pre--NO--&gt;AHC
AHC--&gt;stableId{{stable id?}}
stableId--YES--&gt;ids["AttachedScrap/CachedViews (via id)"]
ids--&gt;custom[custom cache]
stableId--NO--&gt;custom
end

subgraph "Need Rebinding"
custom--&gt;pool["RecycledViewPool (via type)"]
pool--&gt;new[create new]
end

</code></pre>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2023-01-31T17:56:12.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Android 架构漫谈-从 MVC 到 MVI]]></title>
        <id>https://chenhe.me/zh/posts/android-architecture-from-mvc-to-mvi/</id>
        <link href="https://chenhe.me/zh/posts/android-architecture-from-mvc-to-mvi/"/>
        <updated>2023-01-29T20:35:21.000Z</updated>
        <summary type="html"><![CDATA[与后端相比，移动开发的架构比较后知后觉。毕竟「应用」这个东西在一开始就是轻量级的「软件」，只不过后来有了太多不可承受之重。后端最著名的 MV...]]></summary>
        <content type="html"><![CDATA[<h2>混沌时代</h2>
<p>与后端相比，移动开发的架构比较后知后觉。毕竟「应用」这个东西在一开始就是轻量级的「软件」，只不过后来有了太多不可承受之重。后端最著名的 MVC 架构搬到 Android 上有各种水土不服，于是发展出 MVP，MVVM 以及最新的 MVI 等。</p>
<p>架构一直是很神奇的东西，几乎每个正规的工程师都会用到，但又很少有人能说清楚它们到底是什么。无非是祭出那几个都包浆了的架构图片说，瞧，这就是 MVC。我一直认为，工程学的东西本来就没有理学那么精准（也是我水平有限）。当然也有不少方法尝试用理学那套东西对工程来建模，力求达到数学甚至哲学般的准确与普适。但在更多的场景下，这套高时间成本与人力成本的建模难以实践。</p>
<p>显然这只是<s>水</s>一篇博文，而不是个论文，所谓「漫谈」就是我只聊聊自己对这些架构的理解。一千人心中有一千个哈姆雷特，对于架构，恐怕一千人程序员中得有两千个对架构的理解。我不求准确定义它，只要大家能达成宏观的共识，至于实现的细节，相信每个团队有最适合自己的规范。</p>
<h2>各种架构</h2>
<h3>MVC</h3>
<p>顾名思义 MVC 有三部分：</p>
<ul>
<li>Model：对业务的建模。业务代码主要都在这。</li>
<li>View：视图</li>
<li>Controller：控制器。负责接收 UI 事件，调用 Model 并更新 UI。</li>
</ul>
<blockquote>
<p>到底是 Controller 更新 View 还是 Model 更新 View 没有绝对的说法。在 Android 上结合实际情况一般是 Controller 更新 View。</p>
</blockquote>
<p>MVC 起源于(http)后端开发，后端的程序是比较清晰的三步走策略：接收请求、处理数据、返回结果（网页或结构化数据）。但在 Android 上业务模型显然没有那么简单，相比后端一个个独立的请求来说，应用的生命周期更是复杂，所以 MVC 模型自然也无法严格照搬，也就造成了 MVC 具体实现百花齐放（乱七八糟）的场面。</p>
<p>在这个基础上，普遍认同的观点是 xml 布局文件是 View，<code>Activity</code> 之流属于 Controller。按照这个模型，我们回想一下八股文般的写法：</p>
<pre><code>class MainActivity: Activity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    val data: String = model.getData()
    text.setText(data)
  }
}
</code></pre>
<p>尽管名义上 Controller 与 View 分开了：</p>
<ul>
<li>Activity 不负责布局，而只是加载一个布局文件。</li>
<li>Activity 只负责要求 View 显示某数据，而不负责具体的渲染。</li>
</ul>
<p>不要太理论派，事实已经证明，<strong>在 Android 中 View 与 Controller 耦合太重</strong>。<code>Activity</code> 中经常充斥着各种 View 的代码，这些代码又和 xml 布局文件息息相关。毫无疑问，布局文件的变更势必引发 <code>Activity</code> 的变更。</p>
<p>哪怕退一步，理想中如何展示数据是 View 层的工作，也就是说 Controller 只需要把数据整体丢给 View 就好，而不必指明哪里显示标题，哪里显示图片等等这些细节。随着应用复杂度的上升，几乎整个 <code>Activity</code> 都在处理 UI 的细节，远超 Controller 的职责。</p>
<p><strong>总的来说，Android 中的 Controller 对 View 的控制太细节了，从而导致耦合</strong>。这一切的根源是 xml 布局文件作为 View 层功能太少，不足以履行所有职责。</p>
<blockquote>
<p>后来的 View Binding 一定程度上加强了 View 层的能力，但仍然没有彻底释放 Controller 层。比如复杂的 <code>RecyclerView</code> 或 <code>ViewPager</code> 等依然需要在 Controller 层面编写大量代码。另外数据绑定本身是 MVVM 的核心思想，因此不再归为 MVC。</p>
</blockquote>
<h3>MVP</h3>
<p>如果祭出祖传的结构图，大部分人可能都难以看出 MVP (Model-View-Presenter) 与 MVC 的本质区别，不信你看看？</p>
<p><img src="https://img.chenhe.cc/i/2023/01/28/63d51365e3196.png" alt="" /></p>
<p>注意：究竟是 Model 还是 Presenter 来更新 View 并不属于 MVC 与 MVP 的本质区别。因为在实践上，即使采用 MVC 模型通常也是 Activity 更新 UI，而不是把 View 对象传递到 Model 层。换句话说，图中多了或少了几个箭头不重要。</p>
<p>除了箭头...似乎唯一的区别就是把 <code>Controller</code> 改成了 <code>Persenter</code>？果然科技以换壳为本。<strong>MVP 与 MVC 的思想是一样的，它的目的是改进 Controller 在 Android 上的实现。因此相比 MVC，MVP 是代码结构上的变化，而不是思想。</strong></p>
<p>Android 中 MVC 的问题主要是 Controller 与 View 耦合过重，职责模糊不清。既然打不过，那就加入呗。在 MVP 中，<code>Activity</code> 正式归入 View 层，我们单独建一个类履行 Controller 的职责，为了区分，这一层叫做 <code>Presenter</code>。</p>
<p>现在，<code>Activity</code> 可以名正言顺地负责 View 的细节（因为它就是 View 层），而 Persenter 为了控制 View，自然需要 <code>Activity</code> 的实例。这下又开始耦合了：如果 View 层有变动，Presenter 就需要对应地更改。如果一组数据有不同的呈现方式，就得写多个 Presenter。<strong>因此 Presenter 与 View 的通讯应该通过接口来实现</strong>。</p>
<p>典型的 Presenter 实现如下（如有必要，Presenter 本身也可以定义为接口）：</p>
<pre><code>class Presenter(private val iView: IView) {
  interface IView {
    fun showData(data: List&lt;String&gt;)
  }

  fun init() {
    val data = model.getData()
    iView.showData(data)
  }
}
</code></pre>
<p>这看似简单的一步让 MVP 转向了面向接口编程，自然获得了不少好处：</p>
<ul>
<li>便于扩展：一组数据可以有不同的界面来显示，只需实现 <code>IView</code> 接口即可。</li>
<li>便于测试：可以创建假的 View 而不必真正部署到设备。</li>
</ul>
<p><strong>个人理解，MVP 才是 Android 上真正的 MVC 实现，而所谓的 Android MVC 是在 sdk 有限、架构不发达的年代，邯郸学步强行搞出的东西。基于这个结论，Android MVC 该淘汰了，至少也得用到 MVP 才行。</strong></p>
<blockquote>
<p>MVP 通过把 <code>Activity</code> 合入 View 层，抽出 Controller 层来实现更明确的区分。倒过来，我们也可以把 <code>Activity</code> 变成真正的 Controller，把 View 层抽出来。这种模式也可视为 MVC 在 Android 上的更严格的实现。</p>
<p>例如可以创建一个根布局的子类，把 xml 中的根布局换成这个子类。子类中接收整体数据，在内部控制具体显示。大概是因为我们都尽量不想碰 <code>View</code> 吧，所以 MVP 才是更常见的选择。</p>
</blockquote>
<h3>MVVM</h3>
<p>细聊 MVVM (Model-View-ViewModel) 之前需要先明确一下逻辑与状态。逻辑分为业务逻辑与界面逻辑：</p>
<ul>
<li>业务逻辑：例如插入数据库等与业务有关的操作。</li>
<li>界面逻辑：点击了按钮、滚动列表等。</li>
</ul>
<p>虽然界面逻辑通常会触发业务逻辑，但这两个应该有明确的边界，这个边界往往区分开了业务状态与界面状态：</p>
<ul>
<li>业务状态：需要持久化保存，至少其存在的生命的周期比较长。</li>
<li>界面状态：与界面生命周期一致。</li>
</ul>
<p>界面状态与业务状态经常不一致。例如有一个姓名编辑框，当用户修改内容但还没保存时，界面状态就没有持久化为业务状态。反之，如果其他地方修改了数据库里的姓名，但 UI 没有及时刷新，则是更严重的不一致（可称为 bug）。</p>
<p>更具体来说，在传统的 Android View 体系中，界面状态分为外部与内部。例如我们从数据库读取了姓名到一个变量中，再显示到编辑框。当用户修改后，不仅界面状态与业务状态不一致，甚至外部界面状态（姓名变量）与内部界面状态（<code>TextView</code> 内部保存/显示的字符串）都不一样。</p>
<p>手动维护三种状态，很难确保它们的一致性，如下图所示，必须维护好 4 个事件：</p>
<pre><code>graph LR

持久化存储--"①"--&gt;内存--"②"--&gt;界面
界面--"③"--&gt;内存--"④"--&gt;持久化存储
</code></pre>
<ol>
<li>数据库改变时刷新变量。</li>
<li>变量改变时刷新 UI。</li>
<li>UI 改变时更新变量。</li>
<li>修改后的变量记得同步到数据库。</li>
</ol>
<p>MVC/MVP 都没有解决这个麻烦。<strong>MVVM 与其说是一个架构，不妨说是框架。它延续了 MVP 的分层思想，额外引入数据绑定，把上图中的 ②③ 自动化</strong>。</p>
<blockquote>
<p>「架构」是一个指导思想，其具体代码通常由各位开发者自己实现。而「框架」则是写好的代码，实现了具体功能。</p>
</blockquote>
<p>需要注意的是，MVVM 中的 VM 表示 ViewModel，而 Jetpack 中恰好也有一个组件叫 <code>ViewModel</code>，这俩不是一个东西。MVVM 中的 ViewModel 核心目的是实现数据绑定，其次是履行 MVP 中的 Presenter，也是标准 MVC 中 Controller 的职责。而 Jetpack 中提供的 <code>ViewModel</code> 主要实现了「在 Activity 旋转重建等场景下保留状态」的能力。<strong>只通过自己写的 Presenter + DataBinding 就足以实现 MVVM，至于 <code>ViewModel</code> 库，因为的确实用因此普遍也会用上，但与 MVVM 没啥关系</strong>。</p>
<h3>MVI</h3>
<p>MVI (Model-View-Intent) 是近些日子（2022）才提出来的概念，如果说 MVVM 强调的是数据绑定，那么 MVI 强调的就是单向数据流，其次是对 View 与 ViewModel 层交互的优化。</p>
<p>MVVM 中的数据绑定严格来讲是双向绑定，即变量的更新自动反映到 UI，而 UI 的更改也自动更新变量。但实际操作中，更多项目喜欢只通过 <code>LiveData</code> 实现单向绑定。就我个人而言，不喜欢双向绑定原因有下：</p>
<ul>
<li>不够直观。双向绑定隐藏了太多细节，就像 Java 的注解一样，一旦滥用，往往不知道某些操作到底是谁执行的（一些人对 Spring 后端开发中 Lombok 插件的异议也源于此）。</li>
<li>不够灵活。例如希望实时校验 <code>EditText</code> 的输入，拒绝不合法的字符，这个需求难以完美实现。</li>
</ul>
<p>于是单向数据流（配合着单一可信数据源）的概念应运而生，也是现在 Google 主推的思想。</p>
<pre><code>graph LR
Model--&gt;View
View--用户操作--&gt;Intent
Intent--&gt;Model
</code></pre>
<p>这个模型下，原本的 ViewModel/Presenter/Controller 不再被动地接受 UI 更新，而是只接受 UI 更新请求，内部计算后给出新的 UI 状态。换句话说，UI 自己不应该刷新，而只能根据状态来显示。例如，编辑框永远只能显示变量中的字符串，当用户键入新字符时，不会立即显示在编辑框中，而且向中间层发送一个请求(Intent)，中间层经过计算，把新值更新到变量中，此时 View 才能根据变量来刷新。</p>
<p>现在双向绑定的缺点得到了解决：</p>
<ul>
<li>直观。每一个 UI 事件都显式地发送请求(Intent)。</li>
<li>灵活。收到 Intent 完全可以拒绝更新，或更新成其他值。</li>
</ul>
<p>不过鉴于 Android View 系统的设计，严格执行单向数据流不太容易。因为大多数 View 内部维护了一个状态，内部状态的更改不受外部控制。<strong>因此私以为在 Compose 中 MVI 最能体现优势</strong>。基于此，MVI 也顺道解决了 Compose 中的其他小问题：</p>
<ul>
<li>状态变量太多。Compose 组件不存在内部状态，因此业务状态与 UI 状态都要开发者自己保存，变量越来越多。而 MVI 把所有状态打包进了一个 ViewState 对象，不仅简化了传递，也解决了多个状态同时更新的并发问题。</li>
<li>回调函数太多。MVVM 中需要写很多函数，对应不同的 UI 事件。在 Compose 中每一层组件都要传一堆 lambda 表达式参数，非常麻烦。MVI 把这些 UI 事件抽象成 Intent，配合 Kotlin <code>sealed class</code> 特性，减少回调函数个数的同时也能实现类型安全地传递不同参数。</li>
</ul>
<p>当然，MVI 也引入了一些问题。例如把所有状态打包进一个 ViewState 对象，就意味着某一个小状态变动就要刷新整个界面。Compose 的特性可以天然解决这个问题，传统的 View 体系中还需另费功夫。另外单向数据流的确解决了状态不一致的问题，但某些场景下也大大增加了模板代码的数量。比如「退出确认」这个常见需求现在需要建模成「请求退出/等待确认/退出/已退出」四个状态，以及各种 Intent，光是想想就恶心。</p>
<h2>总结</h2>
<p>MVC 是分层思想的基础，MVP/MVVM 都是在 Android 上对 MVC 的实现。所谓「Android MVC」是特定时代与技术背景下，Android 开发行业对后端 MVC 不成熟的模仿与生搬硬套，应该淘汰。</p>
<p>MVP 则是 Android 上更标准的 MVC 实现，它更合理地映射了 Android 组件与 MVC 分层的关系。</p>
<p>MVVM 通过 DataBinding 解决了 MVP 中内存数据与 UI 不一致的问题，建议使用。</p>
<p>MVI 在特定环境下（Jetpack Compose）减少了模板代码，也使程序逻辑更直观灵活。但不是每个项目是适合。</p>
<p>MVC/MVP/MVVM/MVI 背后的分层思想都是 MVC，这一点没有改变。</p>
<p>最后，不要盲目跟随 Google 的指导，事实证明，Google 很喜欢一拍脑袋做决定，一些复杂甚至常见的业务逻辑在最佳实践的模型下不好实现。我们更应该记录那些难以实现的需求，尝试在大的架构思想下给出自己的解决方案，方能培养出自己的架构思路。</p>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2023-01-29T20:35:21.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[北非摩洛哥 | 没有网红点的救赎之路]]></title>
        <id>https://chenhe.me/zh/posts/morocco/</id>
        <link href="https://chenhe.me/zh/posts/morocco/"/>
        <updated>2023-01-20T09:54:00.000Z</updated>
        <summary type="html"><![CDATA[这是一个 DeBuff 叠满的旅行：第一次出国自由行 非英语国家 第一次租车旅游 没有详细规划、没有预订酒店 原则上不推荐这么冒险，但悄咪...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p>这是一个 DeBuff 叠满的旅行：</p>
<ul>
<li>第一次出国自由行</li>
<li>非英语国家</li>
<li>第一次租车旅游</li>
<li>没有详细规划、没有预订酒店</li>
</ul>
<p>原则上不推荐这么冒险，但悄咪咪地告诉你，有条件的话在淡季整这么一出真的爽。</p>
</blockquote>
<h2>写在前面</h2>
<p>摩洛哥对中国免签，如果你想体验一下真正的自由行，自由到没有规划，这是一个绝佳的机会。因为免签意味着你不需要提前订好每一天的酒店和返程机票。而且摩洛哥消费低廉，即使多逗留数日也不会造成过大损失。</p>
<h3>旅行团？</h3>
<p>谈到旅行，免不了两个选择：跟团还是自己玩。我知道很多人对出国旅游有这样那样的担忧，尤其是一个连英语都不通用的国家。但这里我严肃地说，在摩洛哥跟团是「极差」的体验。如果你在国内有过完全自由行的经验，拥有初中级别的英语水平，能够熟练使用地图与翻译应用，能够熟练使用搜索引擎（而不是小红书），那么强烈推荐你不要选择旅游团。</p>
<p>黑旅行团似乎成了某种程度上的政治正确。这里从事实出发，我一般把目的地分为两类：</p>
<ul>
<li>景点明确，参观与赶路分明。例如：泰山、重庆、济南</li>
<li>全域旅游，景在路中。例如：西藏、四川</li>
</ul>
<p>前者可以考虑跟团，但规划简单不见得需要旅行团。后者规划复杂，时间紧张可以跟团，但会损失很多意外收获。</p>
<h3>包车？</h3>
<p>旅行团也不傻，发现自己逐渐成为千夫所指之后，开始推出所谓「轻奢游」「私人成团」「独享司机」等旅行产品。似乎是完美的解决方案。之前去内蒙古就是购买的这种产品，这一次去摩洛哥也有考虑过。</p>
<p><strong>注意，包车游与真正的自由行相差甚远：</strong></p>
<ul>
<li>尽管名义上你可以在路中要求停车欣赏，但因为他们有既定的线路与时间节点，为了能赶上，更多时候会告诉你「前面有更好看的」，或者不时催促一下。</li>
<li>如果看到旁边比较感兴趣的地方想绕各道，大概率是不行的。</li>
<li>因为司机的住宿与伙食也是既定的，因此你不能在某地多呆一天或略过某个景点。</li>
</ul>
<p>当然，不是每个人都喜欢意外。如果你看到一片绿洲，并没有徒步探索一下的冲动；看到戈壁的一个山坡，不想爬上去玩玩，那包车的确是最佳选择。</p>
<h3>网红与小红书</h3>
<p>不能说网红的东西都是不好的，但至少，一个好的东西一旦成为网红，往往就变了味。打卡式旅游固然也是一种选择，但不是我的选择。我一直抵制短视频与小红书，甚至到现在都没有它们的帐号。因为如果非要把一大堆内容压缩到十几秒，还要足够吸引人，那留下的必然是各种滤镜照骗。这也是「舍夫沙万」口碑下跌的原因，它是蓝色小镇，但没有小红书上那么蓝，于是大家失望了。而我就不同，<strong>不要迷失在网红造星的氛围中，从朴素的生活角度去观察，很多东西都值得一看</strong>。</p>
<p>因此，前面说真正的自由行要求会使用搜索引擎，而不是小红书。<strong>把零碎的信息整合成自己的规划，这叫自由。复刻别人的行程，这叫读稿子</strong>。诚然，成熟的稿子一定包含了华丽的辞藻，但这些辞藻已经充斥我们的生活，又何必大费周章去亲自拆穿这些美好的谎言？</p>
<h2>避坑与生活全攻略</h2>
<p>作为发展中国家，与中国类似，细节管理不到位，国内有的坑人手段这里都有。但有一点比国内某些地区好：纠纷仅停留在金钱上面，如果你执意不给（并且占理），他们会溜走而不是升级到暴力事件。</p>
<p><strong>宗旨：小费最多给 20 MAD，收超过这个数违法，不要理会。不要和任何主动说中文/日语/韩语的人交流，最多打个哈哈就行了。</strong></p>
<p><strong>建议带个瑞士军刀，91mm 或 111mm 的，既实用也可以防身，至少遇到纠纷可以壮胆。</strong> 这种工具刀可以托运。</p>
<h3>货币</h3>
<p>机场汇率很不好，举个例子，我们用欧元兑换，当时机场大概 1eur 兑换 9.83 MAD，马拉喀什市区则是 1:10.5。</p>
<p>市区换汇可以用地图搜索 「cash plus」，但注意不是所有的门店都有换汇服务。老城区步行街有很多换汇的，看到 <code>change/exchange</code> 字样都可以换。</p>
<p>建议在机场一人换 30 欧元就行了。</p>
<p><strong>摩洛哥刷卡很不发达，大部分民宿甚至酒店、加油站都不能刷卡。ATM 可以使用 VISA 取现，极少可用银联。</strong></p>
<h3>SIM 卡</h3>
<p>机场就能看到办理 SIM 卡的。会贴心的给你把旧卡粘在手机壳里。<strong>强烈建议摩洛哥电信（Maroc Telecom）或 Inwi（紫色），不要用 Orange（橙色）信号巨差，1/2 时间都没信号。</strong></p>
<p>一定要当场确定能上网（单独确认应用有网并且浏览器能访问网页），能打电话（可以和小伙伴互打）后再走。否则阿拉伯语和法语客服会让你怀疑人生。</p>
<h3>出租车</h3>
<p>摩洛哥景区与机场出租车乱叫价十分严重，通常超过正常价格一倍还多。要求使用计价器大概率被直接拒绝，何况你也无法清楚地用阿拉伯语表达出「计价器」这个东西。</p>
<p>强烈建议安装<a href="https://indriver.com/en/home/">「inDrive」</a>应用，类似中国的滴滴，但只有私家车没有出租车。系统会帮你估算合理的价格，即使不用它打车，至少心理有个数。</p>
<p><strong>如果自认为脸皮薄不会砍价，一定不要回应任何拉客或打招呼的出租车。如果不幸回应了，达不到心理预期直接走人不用回头也不用解释。</strong></p>
<p>机场等封闭区域无法直接使用 inDrive 叫车，出租车更是肆无忌惮。但只需要步行一点点回到公共道路，就能得到四折平价的车费。以马拉喀什机场为例：</p>
<p><img src="https://img.chenhe.cc/i/2023/01/20/63ca82c24d449.png" alt="" /></p>
<h3>带路费</h3>
<p>摩洛哥任何城市的老城区（麦地那）都有各种收小费的人。最多的就是带路费。老城小路错综复杂，有时部分道路还会关闭。会有大量人主动给你带路，完全不提钱的事情，问也不说，如果你跟着走，最后就是 100MAD。</p>
<p><strong>首先，不要理会任何主动带路的人！不要理会任何主动问你去哪的人！</strong> 脸皮要厚，无论他说什么，怎么跟着你，只要你不理，就不会出事。也不会像某些地区那样，以不尊重为理由把你打一顿。</p>
<p><strong>根据我的经验，Google 地图完全够用了！</strong> 前提是你得会看地图，而不是只会听语音往哪里拐。遇到死路往回走，只要方向对很快就能出去。</p>
<p>如果迷路，<strong>只询问有店面的商家，并且拒绝任何带路！只需要指一下就行了。</strong> 如果强行带路，你不跟着走，无论他说啥，你不跟着就没事。</p>
<p>如果不幸被索要 100MAD，只拿出 20（我当时只给了 10）。他会先买惨，说自己有残疾，有证明。然后会叫来同伴，期间甚至会加价到 200。最后非常严肃的问你「You pay or not?」（你给还是不给）大概会吓到很多人，我的小伙伴当时好像被吓到了。这时候只要把 20（或 10）给他手里，并说 「Take it or nothing」（就那么多爱要不要），然后直接走人就行了。<strong>无需担心，老城区里到处都是警察和特警，这些垃圾人类似中国的电脑城。你只要不给钱，一点事都没有，一旦给了钱，就是你自愿的，警察都没办法</strong>。 经济纠纷警察只会和稀泥，但他们不敢升级到暴力，否则警察就出动了，这算是他们与警察之间的默契。</p>
<p><strong>不要怕！他们就是欺负老实人，也知道华人内敛喜欢息事宁人，所以总是缠着中国人。你硬一点，软的就是他们。</strong></p>
<h3>停车费</h3>
<p>老城区没有停车场，都是在路边。<strong>不要跟随任何带路停车的！</strong></p>
<p>自己找个地方停进去，会有人收钱，<strong>无论报价多少（比如 100）最多给 20MAD 一夜，不愿意的话和上面带路费的处理方式一样，车也不用挪</strong>。租车的时候买一下全保，啥都不怕。</p>
<p><strong>再说一遍，无论报价多少，最多给 20MAD。</strong> 这是民宿房东说的，是他们法律的规定。我也是这么执行的。</p>
<blockquote>
<p>有牌子的停车场除外，按照牌子上的价格支付。</p>
</blockquote>
<p><strong>第二天离开的时候也许有人二次收钱，你说交过了就行 (I paid it yesterday)，正常情况下不会纠缠。</strong></p>
<p>我也遇到过纠缠的，<strong>关上窗户锁上门直接开，他挡着就撞（别真撞，我的意思是用怠速车一点点挪出去），一点点事都没有。</strong> 还是那句话，他们就是能坑一个是一个，你不给也就算了。</p>
<h3>餐饮与消费</h3>
<p>摩洛哥的景点（景区）附近的餐饮没有明显贵于其他地方，因此不必特地远离景区吃饭，当然，也不必特地去景区吃。但其他的东西，尤其是明显游客才会感兴趣的东西价格就开始放飞自我。一般来说砍价至少是原价的 2/3，也可以对半或更激进地砍。<strong>但注意，砍的越过分，一旦成交了最好不要反悔，所以把自己的报价定得低一点</strong>。</p>
<p>离开大城市后午餐经常在山路附近的镇子上吃，<strong>警惕镇子入口处餐厅，警惕规模过大的餐厅，警惕多语言的餐厅</strong>，它们的价格是普通店铺的两倍。下面这种法语+阿拉伯语的街头小店才是物美价廉的地方：</p>
<p><img src="https://img.chenhe.cc/i/2023/01/20/63cab2b84f5aa.jpg" alt="物美价廉的当地人快餐" /></p>
<h3>价格表示法</h3>
<p>阿拉伯数字是通用的，并且即使在摩洛哥数字的阅读顺序也与中国一致。如果语言上难以沟通价格，可以用笔写，或打开手机计算器让店家输入。</p>
<p>特别要注意的是，摩洛哥数字的手势表示法与我们习惯不一样。大概是考虑到阅读顺序，当地人不会用两个手表示两位数，<strong>而是用累加法。比如伸出两次「五」表示的是 5+5=10，而不是 55</strong>。同理，伸出一个「五」和一个「四」表示 5+4=9。</p>
<h3>交通规则</h3>
<p>摩洛哥左舵右行，与中国一致。几乎所有交叉口都是环岛，进岛一定要让岛内和出岛的，否则可能会被骂或伸国际友好中指🖕🏻。</p>
<p>千万注意禁止入内（单行道）标志 ⛔️，<strong>Google 地图的导航不会考虑这个</strong>。我们被逮了一次，那个警察比较善良，看我们初来乍到还都是学生，就没有罚款。</p>
<p><strong>最最最重要的是一定要注意停车检查标志</strong>。在 「拉巴特-舍夫沙万-非斯-梅尔祖卡」 这个区间的郊外尤为繁多。停车检查标志不一定有英文，而且行色各异，主要有以下几个特征：</p>
<ul>
<li>牌子一般是放在地上的，而不是固定的。</li>
<li>牌子上有两行文字，一般是阿拉伯语+法语，但<strong>没有</strong>什么符号。</li>
<li>牌子一般有一圈 LED 但白天不一定亮。</li>
<li>这个牌子之前一般有连续密集的减速限速牌。</li>
</ul>
<p><strong>看见这个一定要停在牌子前！看见这个一定要停在牌子前！看见这个一定要停在牌子前！</strong></p>
<p><strong>警察不给你明确的手势一定不要走！不要跟前面车一起走！</strong></p>
<p><strong>警察可能会故意愣几秒不理你，千万不要擅自开走！可以用远光提示一下警察。</strong></p>
<p>这里属于高速警察，和城里的警察不一样。腐败频发，被逮到就是 400MAD 罚单，求情可以减到 200MAD，但是没有罚单（大家都懂吧）因此不要妄想对方心软给你免除处罚，这就是他们的创收方式。</p>
<p>如果不小心没看见，恰好速度也不慢，如果警察没有肉身拦截就可以闯卡，吹哨可以不理，没有什么后果。这里没有摄像头，警察忙着腐败也懒得管你。</p>
<p>下面给出两种常见的停车检查的牌子：</p>
<p><img src="https://img.chenhe.cc/i/2023/01/20/63cab801e9def.jpg" alt="停车检查" /></p>
<h2>行程</h2>
<p>不少网上攻略都是卡萨布兰卡机场落地。这是摩洛哥最大的城市（不是首都），这就意味着住宿巨贵，并且这个城市没有什么可玩的。因此更建议马拉喀什机场落地。</p>
<p>我们是 7 天的环线自驾行程，实际耗费 8 天。线路比较经典：</p>
<p>马拉喀什 - 瓦尔扎扎特 - 梅尔祖卡 - 非斯 - 舍夫沙万 - 卡萨布兰卡 - 马拉喀什</p>
<p>但我们增加了很多探索性质的东西，略去了一些著名或网红地方。</p>
<blockquote>
<p>本文所有图片由 Samsung Galaxy S21 拍摄。未经任何后期优化、调色。系统相机自动执行的优化除外。</p>
</blockquote>
<blockquote>
<p>我们 2023.1.8 从欧洲出发落地马拉喀什机场。属于当地淡季。</p>
</blockquote>
<blockquote>
<p>下面的嵌入式以及跳转地图均为 Google Map，大陆访问需要解决网络问题。</p>
</blockquote>
<h3>马拉喀什 Marrakech</h3>
<p>马拉喀什几乎是市内景点最多的地方：</p>
<ul>
<li>德吉玛广场 Jamaa el Fna Square（傍晚黄昏再去）</li>
<li>老城区（也叫麦地那 Medinas）</li>
<li>马洛雷勒花园</li>
<li>巴迪皇宫</li>
<li>巴西亞王宮</li>
<li>YSL 博物馆</li>
<li>马拉喀什博物馆</li>
</ul>
<p>我们都不是文艺青年，因此几乎忽略了这个城的所有景点，只是在老城区逛了一逛，德吉玛广场找个二楼咖啡馆要了杯果汁静待太阳下山。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cabdc89f4f0.jpg" alt="德吉玛广场" /></p>
<p>这种夜市欧洲人比较稀罕，我们大中国的人应该多少都见识过。不过严格来讲这里不算是「专坑外地人的小吃街」，尽管价格略贵一些，但不像北京南锣鼓巷那么离谱。喜欢的话还是可以买一买的。</p>
<p>要是想体验更传统的市场，离广场不远有个<a href="https://goo.gl/maps/QPZsYC7MPk3gQPt86">类似菜市场</a>的地方，也是黄昏到晚上营业：</p>
<p>&lt;iframe src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d1210.7840371025468!2d-7.997847240331552!3d31.63297378411969!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0xdafefaa90be3d8d%3A0x64e35f5ba2dca997!2sSnack%20Friture%20de%20Poisson!5e0!3m2!1szh-CN!2sie!4v1674231945587!5m2!1szh-CN!2sie" width="600" height="450" style="border:0;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade"&gt;&lt;/iframe&gt;</p>
<h3>瓦尔扎扎特 Ouarzazate</h3>
<p>我们在飞机落地的那天晚上就完成了马拉喀什的行程，第二天开往瓦尔扎扎特。途径著名的筑垒村阿伊特本哈杜。</p>
<p>原则上应该在中午之前到达阿伊特本哈杜，否则拍摄会逆光。但一开始我们还不太熟练，驾驶比较慢，再加上初来乍到，经常停下来观光，多消耗了一点时间，17 点才到这个村子。</p>
<p>一路上将翻越阿特拉斯山脉，这也是直到离开沙漠后最后看一眼这郁郁葱葱了⛰️：</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cac9edc99c2.jpg" alt="" /></p>
<p>分享路上一个<a href="https://goo.gl/maps/HjkyLxj2zsmw5TW78">很当地的镇子</a>：</p>
<p>&lt;iframe src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d6411.120041942525!2d-7.399131014706186!3d31.440889468650568!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0xda5350d4c3d2061%3A0xb21b4339d187ffac!2sPharmacie%20zerkten!5e0!3m2!1szh-CN!2sie!4v1674232624995!5m2!1szh-CN!2sie" width="600" height="450" style="border:0;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade"&gt;&lt;/iframe&gt;</p>
<p>不知道是正好赶上集市，还是每天都这样。路过的时候特别热闹，地摊各种商品应有尽有，而且明显不是为游客准备的。赶集的人也多穿当地服饰，鲜有人会说英文。这里吃到了几乎是全程最好吃的烤羊排🤤，以及几乎是最恶心的乳状物🤣。餐馆环境如下图右下角，毕竟是集市地摊，要求高雅或者有洁癖的同学就不要尝试了。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cac4b0e90fc.jpg" alt="" /></p>
<p>集市如下图。这已经是接近末尾的地方，入口处非常热闹。这大概也是全程我最满意的一个集市，吆喝声，叫卖声，马声车声交响；柴火，烤肉烟雾缭绕。配上坐落于左边的山与右边的壑之间的土制砖房，的确是回到了那个驼铃声声的年代。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cac61103880.jpg" alt="" /></p>
<p>阿伊特本哈杜本身是免费的，有两个坑：</p>
<ol>
<li>从大路上很难直接找到进入筑垒村的方向。向导收费 60 欧元。</li>
<li>靠近入口的一侧上村顶要经过住户，小费 10-20 MAD。但另一侧就是自由出入。</li>
</ol>
<p>筑垒村与现代村庄有一个几乎干涸的河床隔开。曾经这里要么蹚着稀泥过去，要么付费骑骆驼或马。现在政府修了一个桥，地图没有标出，但是卫星图模式下可以明显看出来。这个桥可以视为景区入口，地图坐标<a href="https://goo.gl/maps/x5NCsWGdYsp5Nh5KA">点击跳转</a>。</p>
<p>车就在主路靠边停就好，离桥稍微远一点就没人收费了。整个区域有两个高点，筑垒村全景照需要从东边 高点B 看，完全免费。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cacd8b7016c.png" alt="" /></p>
<p>虽然筑垒村看起来依然有生活气息，经过与商家聊天得知，那些都是为了游客做出来的。他们实际居住在桥另一边（也就是主路那一边）的新城。筑垒村内部与重庆类似层层叠叠，好在村子不大，不用刻意导航，按照大致方向走就可以。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cace1f8f9ba.jpg" alt="" /></p>
<p>瓦尔扎扎特没有什么好玩的，只作为驿站，而且距离阿伊特本哈杜也很近。因此如果来的比较晚，不妨在筑垒村看看日落。去瓦尔扎扎特市区的路上基本没有山了，大概是这趟旅行第一次感受空灵。（夜晚停车记得靠边，打开双闪）</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cacfcac111d.jpg" alt="" /></p>
<p>瓦尔扎扎特市区推荐一个餐馆，也许也有更好的，只是那天恰好找到这里，感觉很不错。名为 Snack Mehdi，坐标<a href="https://goo.gl/maps/6EW4DPfLwRirrPa37">点击跳转</a>。</p>
<h3>水库与绿洲</h3>
<p>本来今天应该是从瓦尔扎扎特直达沙漠入口梅尔祖卡，但中间探索了一下水库与绿洲，就在廷吉尔(Tinerhir)休息一晚。过了瓦尔扎扎特基本就是戈壁了，在这一望无际的黄色中，蓝色的湖泊与翠绿的棕榈林引发了我们无限遐想。这就是自由行的魅力吧：思绪飞到哪，我们就开到哪。</p>
<p>从地图上可以明显看到瓦尔扎扎特东面的巨大湖泊，也许与其他著名汪洋相比不算大，但在这茫茫戈壁中，如此大小光是从图上看就令人窒息，仿佛坠入马里亚纳深渊。湖泊南部有一条名为 <code>P1513</code> 的公路直达岸边，似乎还有一个水坝。但这是重要的战略资源，有军方把守不让参观。而它北部的 <code>N10</code> 公路也有一部分靠近岸边，同时 <code>N10</code> 也是去梅尔祖卡本来就要走的，自然是我们的不二之选。</p>
<p>岸边有一个假日酒店，我们定的时候已经满房了，周围没再有其他住宿，到了之后才知道这是怎么一回事。</p>
<p>因为干旱和高温，湖泊岸线已经明显收缩，从假日酒店给出的图片来看，窗户外就是湖，周围虽不能说绿树成荫，那也是植被覆盖。如今却只能看见湖的边缘，绿地也变成了干裂的碱地。也许还有新冠的冲击，整个小镇死气沉沉，大量城堡别墅烂尾，甚至一些装修到一半就此停滞。进入里面依稀可见灶台、浴室的痕迹，但更多的是自然的洗礼。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cad80970911.png" alt="废弃的城堡酒店" /></p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cad82ab6418.png" alt="" /></p>
<p>也许当地政府与居民为此投入了巨额资金，倾注大量心血，报以无限希望，渴望这座城能在这条旅游环线中脱颖而出，而不仅仅是一个驿站。然而这就是大自然，它不会和你讲什么经济学原理，我们能做的，只有敬重。</p>
<p>踏过河床，翻越沟渠，我们如愿以偿看到了水库。绿色再次出现，而它边上的枯枝也没有真正死去，应该是在等待着下一个雨季的到来。但是真的等得到吗？</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cad9f4a84bb.png" alt="" /></p>
<p><strong>也许你会说，不过如此嘛。瞧，这就是旅行的意义了。</strong> 从图片与文字中，尤其是我这样非专业文学工作者的游记中，你体会不到那种探索与发现的美妙。然而事实是，与所谓的世界文化遗产相比，这些留给我们的记忆更加深刻。</p>
<hr />
<p>告别了水库，我们继续往梅尔祖卡方向，同时也是廷吉尔方向前行。</p>
<p>廷吉尔的布局非常神奇，中间是洼地绿洲延伸，两侧逐渐形成峭壁。峭壁的半腰有公路与村庄。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cade539349d.png" alt="廷吉尔" /></p>
<p>更神奇的是，看起就在对面的村庄，需要绕很远才能开过去。但如果你愿意尝试穿越这些村庄下到棕榈林里，按图索骥，可以发现原来它们的联系是如此紧密！</p>
<p>在 Google 地图中关于此地的评价里，大多数略爱探索的游客请了一位向导来协助穿越。实际上这片棕榈正如它表面看起来一样，静谧而安详，没有猛兽恶鬼。尽管的确容易迷路，但一直都有信号。即使没有导航，按照方向也可以回到村庄脚下。所以如果稍有地理知识，能分清东南西北，至少会看指南针，建议自己走一趟。（对了，注意手机的指南针需要不时地 8 字校准）</p>
<p>很多地方都可以下到树林里，综合便利与成就感，我们选择了峭壁上的村庄作为起点（坐标 <code>(31.548554,-5.564386)</code> <a href="https://maps.app.goo.gl/rUJmRZR5TkLWz59U7">点击跳转</a> 谷歌地图 App 打开可显示图钉📍），对面峭壁的村庄作为终点。</p>
<p>我们询问在路边的三个大概是初中生如何进入树林，他们欣然指路（并试图通过我们来练习英语口语？）后假装索要 1MAD，随后三人腼腆（猥琐？）地凑在一起咯咯笑。我们也不吝啬顺势给了一枚硬币后，转变为了难以抑制的大笑，蹦跳着走了，仿佛看到曾经耍聪明得到 5 角钱蹦跳着买零食去的自己。</p>
<blockquote>
<p>这个行为是否会引导他们变成古城里那些讨厌的人不得而知。至少这时，我们都是单纯的。</p>
</blockquote>
<p>绿洲里有清澈的小溪，神奇的植物，规整的农田，孩子们的秘密基地，可爱的狗狗以及劳作的人民。这些家长里短难以用图片表达他们当时对我的冲击。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cae2aa16f54.png" alt="" /></p>
<p>在另一面的村子里，我们遇到了自己加盖房屋，正在屋顶工地休息的村民，遇到了刚刚下课骑着自行车在峭壁边吹风的高中生，遇到了排排坐在屋门台阶闲聊生活琐事的妇女。他们用好奇的眼光看着我们这些罕见的外国游客，最终都没忍住来打招呼，互相用着蹩脚的肢体语言、智障的翻译软件和爽朗的笑声来交换着各自的生活与理想。我想无论是跟团还是包车，恐怕都得不到这样的经历。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cae475ccaa9.png" alt="" /></p>
<p>傍晚时分，大概是他们做礼拜的时间，整个绿洲回响起类似于中国山歌一样的对唱。一村接一村，一起又一起。尤其难得的是，这就是他们的真实生活，而不是为了游客精心编排的演出。「<strong>这些人乐意让我们闯入生活，大概也是因为罕见吧。要是游客成群，恐怕难再保持这真实姿态与或腼腆或豪放的笑容</strong>」返回停车那一侧时，我与小伙伴呢喃着。</p>
<p>夜宿村子里的民宿，这里光污染已经比较少，可以看见星星了。现在是淡季，整个民宿只有我们一个旅客，老板热情地站在门口迎接，并耐心地等待我们到凌晨一点欣赏完星光，锁好门才回去休息。早餐咖啡牛奶、鲜榨橙汁、摩洛哥茶、果酱蜂蜜、面包酥饼煎蛋毫无缩水。再看看某些地区旺季出来捞钱，淡季关门大吉一走了之的民宿，真是令人唏嘘。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cae8820da8c.png" alt="" /></p>
<h3>撒哈拉！梅尔祖卡 Merzouga</h3>
<p>前往沙漠的公路横劈这幅员辽阔的戈壁。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63caf8c9c8043.png" alt="" /></p>
<p>上面这图咋拍的？注意看右下角路边，那是我们的车。这不是高速公路，因此可以停在旁边的砾石路基上。左边一个小凸起映入眼帘，我们决定征服它！（坐标 <code>(31.450300,-5.282490)</code> <a href="https://goo.gl/maps/UxHcb4n2HJCo3bXL7">点击跳转</a>）</p>
<p>显然，我们不是第一个有这想法而且实现的人。似乎全球都有用石头堆个小塔的习惯，爬到顶峰时已经有两个了。于是卷起来，我堆了一个更高的☺️ 顶峰眺望偶然看到一口非常刺客信条的水井。这游戏诚不起我，前不着村后不着店的地方还真有个井。一时兴起打一通水，没想到引来几头野...驴？</p>
<p>（小惊喜）两人对着它一顿输出（拍摄），弄得人家都不好意思喝水啦</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cafa46972b0.png" alt="" /></p>
<p>租不起四驱 SUV，不敢开得太往里，稍微野一点~</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cafb7e789db.png" alt="" /></p>
<p>去沙漠有两个选择：</p>
<ol>
<li>住沙漠中的帐篷⛺️。这个更原汁原味一点，价格看起来也比较便宜。但去帐篷是强制骑乘骆驼或沙漠车（自己租的车性能不够），这个交通是 70 欧元/人甚至更贵。并且帐篷的条件显然也不太好。</li>
<li>住梅尔祖卡的酒店，他们与沙漠接壤，能步行进入沙漠但难以深入。综合价格非常便宜，环境也更好。</li>
</ol>
<p>我纠结了很长时间，后来发现无论怎么选都会后悔的。跟随你的第六感吧。帐篷通常会组织当地人给你升篝火并跳舞，但正如上面在绿洲的时候提到了，这些是为了游客而精心编排的。尽管他们是本地人，舞可能也的确是传统舞蹈，但总感觉少了些什么重要但难以名状的东西。</p>
<p>最终我们住在酒店并在夜晚和凌晨分别进入沙漠🏜️。理论上撒哈拉是摩洛哥行程中非常重要的一站，也是寻找救赎的重要一站，但这里是用来感受的。照片千篇一律，沙子就是沙子，星空还是那些星空。何况因为设备与技术原因，我的照片看起来更普通和廉价。</p>
<p>你需要亲身站在那，在经历晚上找不到车，有车找不到路后看见漫天的星光。在经历裹着大衣顶着寒风凌晨出发后，看到沙丘升起的太阳，才知道：哦，这是撒哈拉，地球上最大的沙漠。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63caec0bacc2d.png" alt="" /></p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63caec172c09c.png" alt="" /></p>
<p>（这沙子真的细，像粉一样）</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cafc51a7672.png" alt="" /></p>
<p>（像假的一样的沙漠）</p>
<h3>非斯 福兹 Fes</h3>
<p>从梅尔祖卡到非斯需要一天的行程。中间会经过伊芙兰，称为摩洛哥的欧洲小镇。不过我平常就生活在欧洲，所以...没啥兴趣。另外伊芙兰也是滑雪圣地，但今年，这里一点雪都没有。</p>
<p>路上停车撒尿的时候意外发现了个世外桃源。有小溪，水渠和石桥。三位淳朴的村民正傍渠而坐把酒言欢。他们热情地邀请我们品尝炖羊肉与传统摩洛哥茶，<strong>给钱坚持不要，只是指了指天空似乎有强大的信仰</strong>。遇上他们，感觉这一趟就值了🫶</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cafcfc94d82.png" alt="淳朴的村民" /></p>
<p>（这里不再给出坐标，只是一个路边小村子，可遇不可求的）</p>
<p>非斯非常大，明显地分为老城与新城。为了避免停车纠纷同时也是方便起见，我们原本希望住在新城。但这个老城闻名中外的旅游城市，所有的住宿都在古城区，新城几乎一家酒店都没有（也可能是定满了？）。</p>
<p>好在非斯的麦地那不像云南大理一样坑人。这里的人民虽难免向旅游业靠拢，但依然保持着正常的生活状态，没有给人特别假特别商业化的感觉。里面菜市场、五金店等非旅游店铺依旧遍布小巷，有孩童与学生不时穿梭。<strong>因此希望各位不要像我们一样抱有非常抵触的情绪</strong>。对了，这里的民宿淡季非常超值，外面看起来建筑非常老旧，但内部空间巨大，装修独具一格，Booking 上的照片基本都是真的。价格与城里酒店相当，值得一住。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63caef25f001a.png" alt="" /></p>
<h4>皮革坊</h4>
<p>非斯古城显然比马拉喀什更加复杂。但地图也是非常清晰，稍有相关技能的话都没必要请向导。要参观这里最著名的皮革坊，问下民宿老板就能得到大概位置。靠近这个区域立刻有很多商家邀请你去参观。非斯没有免费公共的观景台来参观皮革坊全貌，各家店铺如果不买皮制品，参观费用也就 10-20MAD/人，还是非常值的（不要相信免费参观的鬼话）。那么唯一的问题是，谁的角度最好？</p>
<p>很多店铺会挂出照片来证明自己的角度，一般都是真实的。我们当时蹭了一个旅游团跟到了一家叫 <code>Leather Shop with rooftop terrace</code> 的店铺，感觉还可以，<a href="https://goo.gl/maps/46YAiguGevvwquiG7">点击跳转</a>。</p>
<p>&lt;iframe src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d1047.0826674046625!2d-4.9710453384378726!3d34.06608754585205!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0xd9ff37b989aaecd%3A0x5cc6bcb19f5e9c84!2sLeather%20Shop%20with%20rooftop%20terrace!5e0!3m2!1szh-CN!2sie!4v1674244132884!5m2!1szh-CN!2sie" width="600" height="450" style="border:0;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade"&gt;&lt;/iframe&gt;</p>
<p>其入口如下图，比较深。还是那句话，不用害怕，这里鲜有暴力事件，没有强制消费。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63caf0beecaab.png" alt="" /></p>
<p>入口会给你一个薄荷叶来掩盖恶臭，实际上收效甚微。<strong>除非你像我一样自备一个口罩，把薄荷叶放在口罩里面</strong>，效果倍棒！可以从容地欣赏拍照啦。（口罩戴反了，不要在意这些细节~）</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63caf1a30f3b9.png" alt="自备口罩" /></p>
<p>（真心怀疑那些坛子里真的不是尿和屎吗？最后还是没好意思问出口）</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63caf2bc513f8.png" alt="image-20230120195953072" /></p>
<h4>博物馆与古迹</h4>
<p>古城背面外侧有个兵器博物馆 <a href="https://goo.gl/maps/YEquXLVEEM96zxhD6">「Borj Nord」</a>，门票只要 10MAD，里面有从冷兵器到火器，以及盔甲等装备展览，二楼还有观景台，值得一玩。可以开车去，附近的路边可以停车。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63caf66569a71.png" alt="" /></p>
<p>博物馆的东北方向就是<a href="https://goo.gl/maps/9P6e2rqvSdReV2yMA">「Marinid Tombs」</a>古迹，免费的，可以从博物馆步行过去，嫌远的话开车也行。可以观赏古城全景，也可以瞥见南面的新城。这里适合静待日落🌇，看着阴影一点点移动，感受时光的流逝，眼下这些建筑历经千年依然矗立。真是闲云潭影日悠悠，物换星移几度秋。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63caf809f41ce.png" alt="" /></p>
<h3>舍夫沙万</h3>
<p>告别非斯前往舍夫沙万，大地终于从土黄变为碧绿。让人不禁想起那句广告：北纬 47 度天然牧场...</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cb090a0e3dc.png" alt="image-20230120213503225" /></p>
<p>舍夫沙万是本趟行程中唯一的网红地。本来是没有安排的，后来因为太喜欢摩洛哥，延长了行程，本着「来都来了」的态度就去了一趟。因为没有抱有太大希望，所以也谈不上什么失望。我没看过小红书上的照骗，直面这座被成为童话小镇的城市，它的确足够蓝。尽管大多墙壁已饱经岁月而逐渐褪色，但其巷子里还有几面墙留有足够的颜色深度，大概网红拍摄地也是这吧。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cb0a26665f7.png" alt="" /></p>
<p>Google 地图上有个<a href="https://goo.gl/maps/4urpwtLKgyry7Hbp9">「Camping azila」野营地</a>，即使是淡季也停满了欧洲来度假的房车。他们仅有一个简陋房屋供没有房车的旅客住宿。最后考虑到过于简陋，而且离城区也不远，没有房车与帐篷其实野营感不是很足，我们就没有住下。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cb0af1b05ad.png" alt="" /></p>
<p>在舍夫沙万终于吃到了心心念的蜗牛，配上辣椒粉和孜然，还真挺好吃的，比马拉喀什广场便宜一倍。当然，这个因人而异。网上有人表示口感怪怪的，蜗牛汤也难以接受。我倒觉得汤挺好喝，甚至还喝了两碗。买蜗牛汤免费哦。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cb0b9d51359.png" alt="" /></p>
<p>这里是个文艺的地方，带妹子的也许喜欢在这拍拍拍，我们没有过多逗留，夜宿一晚就启程。其实到此摩洛哥旅行基本结束，后面都是半旅行半度假性质，享受摩洛哥的物价与阳光了。</p>
<h3>卡萨布兰卡</h3>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cb0fd394c7f.png" alt="" /></p>
<p>从舍夫沙万回到马拉喀什还车本来就路过卡萨布兰卡。世界第三大，唯一允许非穆斯林参观的哈桑二世清真寺不妨顺路去看看。附近有个收费停车场，可驱车前往。这个地方是要门票的，价格与开放时间见<a href="https://www.fmh2.ma/">官网</a>。</p>
<p>清真寺里面有个 Hammam，也就是当地人的澡堂。第一感觉这里应该很贵，但实际上比其他市区的都便宜。所以时间允许的话不妨在这里洗个澡吧。</p>
<p>摩洛哥的 Hammam 需要穿泳裤进入。如果没有专门的泳裤，普通内裤也行，但记得再带一条干的。拖鞋澡堂会提供，毛巾等是收费的。这边不像中国有泡澡或淋浴。他们是两个水龙头，一个水桶和水瓢用来冲洗。去角质（也就是搓灰）是在地上铺个薄毯子躺上去，比较简陋。不过地是热的，这个到不用担心。浴资比中国贵，但对于欧洲留学生来说已经非常便宜了。反正跑了六七天大概也累了，洗个澡何乐不为呢。</p>
<p>另外清真寺外的海岸线有许多卖现榨果汁的，也是平价好货，不妨买一杯吹吹海风。</p>
<p><img src="https://img.chenhe.cc/i/2023/01/21/63cb0e8138baa.png" alt="" /></p>
<h2>尾声</h2>
<p>中间我几次假装是日本人询问当地人对中国游客的印象，他们提到，中国人太冷漠，不爱打招呼或聊天。我当然非常理解同胞们。一来我们本来就比较内敛，而且语言不通，另外也是被坑怕了不敢随便回应。但我觉得，如果有能力，还是不要一棍子打死。比如非景点区一些打招呼的外国人，或某个路边地摊饭店老板有意聊几句，不妨耐心交流一下，实在聊不下去伸个大拇指开怀大笑一下，总好过无视人家的热情。我们这趟旅程几乎一直在和老板、房东、村民沟通。甚至一个餐馆老板还加了我们的 Whats App。想象一下，假如我们生活在一个数月不见生面孔的小镇，突然两个年轻的外国人来就餐，我们用蹩脚的英语加上手势交流一番，互换了微信和照片，这难道不是一件值得与家人分享，值得发朋友圈炫耀，值得时不时回忆的事情吗？对他们来讲也一样。</p>
<p>中国自古就说，有朋自远方来不亦乐乎。现在我们到了远方，不要让他们对中国文化的期盼落空吧。当然，我们更应该学会不惹事不怕事。在法律的范围内、在当地风俗的弹性范围内，要坚定地维护自己利益。根本就没有大事化小这种道理，有的只有得寸进尺。<strong>不要寒了淳朴村民的心，更不要助长了贪心黄牛的嚣张气焰</strong>。</p>
<p>这篇攻略兼游记记录了许多非典型景点。如果你跟着我的足迹🐾，也许无法获得我的奇妙感受，因为你已经知道了它的样貌，心里有了基准。既然没能成为景点，大概率这也是它们所能展现的最高水平了。<strong>本文提到的所有小探索，目的不是让你复刻这些行程（尽管我提供了坐标），而是想说，自由行远不止不跟团那么简单。要敢于遐想，敢于尝试，敢于探索，能够接受无功而返，如此，便能收获不期而遇。</strong></p>
<p>很多地方，受限于消费、签证、管理等各种因素，我们实现不了真正的自由行。而在摩洛哥，它给了你这样的机会，尝试一下吧。</p>
<blockquote>
<p>对了，撒哈拉没有救赎。引用盗墓笔记中的桥段：雷城不能抚平一切遗憾，甚至，雷城本身就是个遗憾。</p>
</blockquote>
<p>对于不想自驾的，可参见<a href="https://www.mafengwo.cn/gonglve/ziyouxing/15132.html">这篇主要城市交通大全</a>。</p>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2023-01-20T09:54:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[LeetCode 回溯]]></title>
        <id>https://chenhe.me/zh/posts/leetcode-backtracking/</id>
        <link href="https://chenhe.me/zh/posts/leetcode-backtracking/"/>
        <updated>2023-01-05T16:57:20.000Z</updated>
        <summary type="html"><![CDATA[递归对于算法萌新宝宝们已经很难了，回溯是递归的升级版，恐怕更是难倒不少英雄汉。这篇就总结一下 LeetCode 上常见的回溯题目，这些鬼问题...]]></summary>
        <content type="html"><![CDATA[<p>递归对于算法萌新宝宝们已经很难了，回溯是递归的升级版，恐怕更是难倒不少英雄汉。这篇就总结一下 LeetCode 上常见的回溯题目，这些鬼问题单独看一个都不算难，全部做一遍之后大概就晕了。</p>
<h2>总结</h2>
<p>回溯问题通常包含对某一集合的递归遍历，在遍历的过程中选取元素，在回溯的过程中撤销选取。最终达到遍历所有可选取的组合，记录符合条件的那些。</p>
<h3>排列与组合</h3>
<p>递归遍历的过程中，每次都从第一个元素开始，还是从上一层遍历的位置开始呢？<strong>对于排列问题，每次都要从头开始；对于组合问题，需要接续遍历，因此递归函数需要一个参数指明从哪开始遍历</strong>。</p>
<ul>
<li>排列：选中集合的不同顺序视为多个有效答案。</li>
<li>组合：选中集合的不同顺序只视为一个有效答案。</li>
</ul>
<p>需要强调的是，题目要求答案有固定的顺序，不代表就是排列问题。这里我们只看每一个选中元素的集合，其不同排列有几个是有效的。如果只有一个有效，那么就是组合问题。</p>
<ul>
<li>
<p><strong>排列问题：需要全量遍历，即每一层递归都要从头开始遍历每一个元素。</strong> 需要 <code>used</code> 数组或其他方式记录已选取的元素。</p>
</li>
<li>
<p><strong>组合问题：需要接续遍历，即每一层递归从上一层的位置开始继续遍历元素。</strong> 至于要不要从下一个开始，取决于题目是否允许多次选取同一个元素。</p>
</li>
</ul>
<h3>去重</h3>
<p>排列或组合的回溯问题通常要求答案去重。</p>
<h4>排序跳过去重</h4>
<p>在原集合允许排序的情况下，排序跳过是简单高效的去重方案。</p>
<p><strong>对于接续遍历，遇到两个连续的相同元素时跳过即可</strong>。需要注意的是只能向前去重，代码如下：</p>
<pre><code>if (i &gt; 0 &amp;&amp; nums[i] == nums[i - 1]) continue;
</code></pre>
<p>如果向后则会漏解。例如计算集合 <code>{1,2,2}</code> 的组合，只能得到 <code>{1,2}</code> 而漏掉 <code>{1,2,2}</code>。</p>
<p><strong>对于全量遍历，需要与 <code>used</code> 标记配合一起去重</strong>。代码如下：</p>
<pre><code>if (i &gt; 0 &amp;&amp; nums[i] == nums[i - 1] &amp;&amp; !used[i - 1]) continue;
</code></pre>
<p><code>!used[i - 1]</code> 的作用是把去重范围限制在同一层递归中。<strong>只要任意数字在<em>每一位</em>最多只出现一次，答案就不会有重复。而第 N 层递归正是决定这组答案的第 N 位是什么数字。</strong></p>
<p>例如求集合 <code>{1,1,2}</code> 的排列，若不加这个额外条件则会漏掉 <code>{1,1,2}</code> 或 <code>{2,1,1}</code> 这样的解，此时的实际语义是「任意数字在<em>任意位置</em>最多只出现一次」。</p>
<h4>哈希表去重</h4>
<p>有时不允许对原集合进行排序，那么就得用哈希表。</p>
<p>哈希表记录元素的值还是下标？那要看题目要求，如果值重复就算答案重复，自然要记录值。如果选取多个相等的元素不算重复，那么就记录下标。</p>
<p><strong>最后，每层递归都该有自己的哈希表</strong>。理由和刚才一样，独立哈希表实现的是「任意数字在<em>每一位</em>最多只出现一次」，这也是题目去重的含义。而全局哈希表实现的是「任意数字在<em>任意位置</em>最多只出现一次」。</p>
<h2>37 需要提前结束的回溯</h2>
<blockquote>
<p><a href="https://leetcode.com/problems/sudoku-solver/">传送门</a></p>
<p>这题是解数独，是个经典的回溯问题。虽然标记为 hard 但知道思想后不难。</p>
</blockquote>
<p>这题的特点是只有一个解，因此找到一个解后应该立即返回来节约时间。需要注意的是递归不是循环，光一次返回不行，<strong>所有递归调用的地方都必须对递归返回值进行判断，如果已经找到解则立即结束当前递归</strong>。</p>
<p>数独的递归体现在每一个格子都需要尝试填入每一个数字，回溯体现在如果填入某数字导致后续无解，就要按层依次撤销。</p>
<p>答案如下：</p>
<pre><code>class Solution {
  public void solveSudoku(char[][] board) {
    backtracking(board, 0, 0);
  }

  private boolean backtracking(char[][] board, int row, int col) {
    if (row == 9) return true; // 已填完所有格子
    int nextRow = col == 8 ? row + 1 : row;
    int nextCol = col == 8 ? 0 : col + 1;
    if (board[row][col] != '.')
      return backtracking(board, nextRow, nextCol); // 不能修改题目填好的数字
    for (char ch = '1'; ch &lt;= '9'; ch++) {
      if (!valid(board, row, col, ch)) continue;
      board[row][col] = ch;
      if (backtracking(board, nextRow, nextCol))
        return true; // 找到一个答案，结束当前递归
      board[row][col] = '.'; // 无解，撤销更改
    }
    return false; // 尝试了所有值都不行，无解
  }

  private boolean valid(char[][] board, int row, int col, char val) {
    for (int i = 0; i &lt; 9; i++) {
      if (board[i][col] == val) return false;
      if (board[row][i] == val) return false;
    }
    int startRow = row / 3 * 3, startCol = col / 3 * 3;
    for (int r = startRow; r &lt; startRow + 3; r++)
      for (int c = startCol; c &lt; startCol + 3; c++)
        if (board[r][c] == val) return false;
    return true;
  }
}
</code></pre>
<h2>17 组合问题</h2>
<blockquote>
<p><a href="https://leetcode.com/problems/letter-combinations-of-a-phone-number/">传送门</a></p>
<p>Given a string containing digits from <code>2-9</code> inclusive, return all possible letter combinations that the number could represent. Return the answer in <strong>any order</strong>.</p>
<p>...</p>
</blockquote>
<p>首先来分析是排列还是组合。</p>
<p>这道题的本质是：给一串数字，每个数字可以对应多个字符，我们要分别选取它们组成字符串。例如 <code>2-a/b/c, 3-d/e/f</code>，给定数字序列 <code>23</code>，答案有 9 个集合（集合是无需的）：<code>{ad},{ae},{af},{bd},{be},{bf},{cd},{ce},{cf}</code>，那么对于每个集合，其多个顺序的排列有几个是有效答案？例如第一个集合有两个排列：<code>&lt;ad&gt;,&lt;da&gt;</code>，显然只有 <code>&lt;ad&gt;</code> 有效。因此这是组合问题。</p>
<p>java 答案：</p>
<pre><code>class Solution {
  private final char[][] board = {
    {'a', 'b', 'c'}, {'d', 'e', 'f'}, {'g', 'h', 'i'}, {'j', 'k', 'l'},
    {'m', 'n', 'o'}, {'p', 'q', 'r', 's'}, {'t', 'u', 'v'}, {'w', 'x', 'y', 'z'},
  };
  LinkedList&lt;String&gt; result = new LinkedList&lt;&gt;();

  public List&lt;String&gt; letterCombinations(String digits) {
    int[] ds = new int[digits.length()];
    for (int i = 0; i &lt; ds.length; i++)
      ds[i] = digits.charAt(i) - '0';
    backtracking(ds, 0, new StringBuilder(digits.length()));
    return result;
  }

  private void backtracking(int[] digits, int index, StringBuilder sb) {
    if (index == digits.length) { // 根据题目要求定义的结束条件（遍历完所有数字）
      if (sb.length() &gt; 0) result.add(sb.toString());
      return;
    }
    for (char ch : board[digits[index] - 2]) {
      sb.append(ch);
      backtracking(digits, index + 1, sb);
      sb.setLength(sb.length() - 1);
    }
  }
}
</code></pre>
<h2>39 可重复的组合问题</h2>
<blockquote>
<p><a href="https://leetcode.com/problems/combination-sum/">传送门</a></p>
<p>Given an array of <strong>distinct</strong> integers <code>candidates</code> and a target integer <code>target</code>, return <em>a list of all <strong>unique combinations</strong> of</em> <code>candidates</code> <em>where the chosen numbers sum to</em> <code>target</code><em>.</em> You may return the combinations in <strong>any order</strong>.</p>
<p>The <strong>same</strong> number may be chosen from <code>candidates</code> an <strong>unlimited number of times</strong>.</p>
</blockquote>
<p>这是组合问题吗？</p>
<p>例如给定 <code>candidates = [2,3], target = 7</code>，则 <code>{2,2,3}</code> 是一个解集合，其多个排列 <code>&lt;2,2,3&gt;, &lt;2,3,2&gt;, ...</code> 视为重复，因此只有一个有效解。所以是组合问题。</p>
<p>按照总结，组合问题需要「接续遍历」，也就是从上层递归遍历到的位置开始。<strong>但这题规定数字可以多次使用，所以具体来讲，得严格从上层的位置再次遍历，而不是从它的下一个元素开始</strong>。</p>
<p><strong>[小优化1]</strong></p>
<p>本题限制 <code>candidates[x] &gt; 0 &amp;&amp; target &gt; 0</code>，因此若中间某一步发现和已经超过 <code>target</code> 就不用继续递归了。</p>
<p><strong>[小优化2]</strong></p>
<p>进一步，可以把 <code>candidates</code> 排序一下，若中间某一步的和超过 <code>target</code>，不仅不用继续递归，当前循环也可以退出了（因为后边的数更大，其和一定超过 <code>target</code>）</p>
<h2>491 不允许排序的去重</h2>
<blockquote>
<p><a href="https://leetcode.com/problems/non-decreasing-subsequences/">传送门</a></p>
<p>Given an integer array <code>nums</code>, return <em>all the different possible non-decreasing subsequences of the given array with at least two elements</em>. You may return the answer in <strong>any order</strong>.</p>
<hr />
<p>解释一下题目，虽然写的是「subsequences」，但实际上不要求连续。例如对于数组 <code>nums = [4,6,7,7]</code>,<code>[4,7]</code> 也是一个有效答案。</p>
</blockquote>
<p>按照前几个总结，先提取一下这题目的关键要求：</p>
<ul>
<li>子序列：答案的每一项只有一个有效顺序，即多个顺序只算作一个答案，因此这是组合题，递归函数要有起始下标参数。</li>
<li>答案要去重：考虑排序跳过法或哈希表法</li>
</ul>
<p>关于这是组合题还是排序题可能有同学有疑问。<strong>再说一遍「组合」并不代表顺序不重要，而是说相同的几个元素不同的排列只有一个有效答案</strong>。传统的组合中，不同的排列视为重复，因此只有一个有效答案。这道题中不同的排列只有一个合法，最终也只有一个有效答案。</p>
<p>接下来是是去重。之前我们通过对给定数组排序，然后比较当前元素与循环的上一个元素来跳过重复。但是这道题不允许进行排序，否则元素的相对位置改变，得出的答案就不再是「子序列」了，例如 <code>[4,7]</code> 不是 <code>[7,4,3]</code> 的子序列。但原数组排序后变成 <code>[3,4,7]</code>，那么 <code>[4,7]</code> 就成了有效答案。</p>
<p>所以这题必须用哈希表来去重。接下来的问题是用值去重还是下标去重？显然用值。因为 <code>[4,6,7], [4,6,7]</code> 这两个子序列即使用的是不同的 <code>7</code>，也算作重复答案。</p>
<p>答案如下：</p>
<pre><code>class Solution {
  LinkedList&lt;List&lt;Integer&gt;&gt; result = new LinkedList&lt;&gt;();

  public List&lt;List&lt;Integer&gt;&gt; findSubsequences(int[] nums) {
    backtracking(nums, 0, new LinkedList&lt;&gt;());
    return result;
  }

  private void backtracking(int[] nums, int start, LinkedList&lt;Integer&gt; sub) {
    if (sub.size() &gt;= 2) { // 题目要求至少有两个元素
      result.add(new ArrayList&lt;&gt;(sub));
    }
    HashSet&lt;Integer&gt; used = new HashSet&lt;&gt;();
    for (int i = start; i &lt; nums.length; i++) {
      // 去重
      if (used.contains(nums[i])) continue;
      // 确保答案是非递减序列
      if (!sub.isEmpty() &amp;&amp; nums[i] &lt; sub.getLast()) continue;
      sub.add(nums[i]);
      used.add(nums[i]);
      backtracking(nums, i + 1, sub);
      sub.removeLast();
    }
  }
}
</code></pre>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2023-01-05T16:57:20.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[LeetCode 动态规划]]></title>
        <id>https://chenhe.me/zh/posts/leetcode-dynamic-programming/</id>
        <link href="https://chenhe.me/zh/posts/leetcode-dynamic-programming/"/>
        <updated>2022-12-11T22:23:53.000Z</updated>
        <summary type="html"><![CDATA[动态规划通过保存已经计算过的结果，优化计算所有可能性的过程。下面几种问题都牵扯所有可能性的计算：优化问题：在所有可能的解决方案中，寻找用时最...]]></summary>
        <content type="html"><![CDATA[<h2>总结</h2>
<p><strong>动态规划通过保存已经计算过的结果，优化计算所有可能性的过程。</strong> 下面几种问题都牵扯所有可能性的计算：</p>
<ul>
<li>
<p><strong>优化问题</strong>：
在所有可能的解决方案中，寻找用时最短/代价最少的等。
【例】<a href="https://leetcode.com/problems/min-cost-climbing-stairs/">爬楼的最低成本</a></p>
</li>
<li>
<p><strong>求所有解</strong>：
需要求出所有的情况，而不是选出其中的一个。</p>
<p>【例】<a href="https://leetcode.com/problems/fibonacci-number/">斐波那契数列</a></p>
</li>
<li>
<p><strong>背包问题</strong>：
给定候选物品与限制，求最佳选择方案。</p>
</li>
</ul>
<h2>解题步骤</h2>
<p>动态规划是一个思想，不是某个具体的算法。它分为好几个步骤，不同步骤要寻找各自的算法。</p>
<ol>
<li><strong>寻找递推公式。</strong>
动态规划问题通常包含递归思想，我们要先找到递归的「基本情况」，然后寻找递推公式来得出后续的结果。为此，尝试自顶向下分解问题，思考每一步分解如何倒过来计算下一步的答案。</li>
<li><strong>储存子问题的计算结果，避免重复计算。</strong>
选择合适的数据结构存储递归中重复计算的东西，通常牵扯到大名鼎鼎的 <code>dp</code> 数组。</li>
<li><strong>转为迭代。</strong>
注意数组右边界情况。</li>
<li><strong>优化 dp 数组。</strong>
只保留用于下一次迭代的元素。
注意，不是所有问题 dp 数组都可以优化。</li>
</ol>
<h2>优化问题 - 746 爬楼的最低成本</h2>
<blockquote>
<p><a href="https://leetcode.com/problems/min-cost-climbing-stairs/">传送门</a></p>
<p>You are given an integer array <code>cost</code> where <code>cost[i]</code> is the cost of <code>ith</code> step on a staircase. Once you pay the cost, you can either climb one or two steps.</p>
<p>You can either start from the step with index <code>0</code>, or the step with index <code>1</code>.</p>
<p>Return <em>the minimum cost to reach the top of the floor</em>.</p>
</blockquote>
<p>按照动态规划的解题步骤：</p>
<h4>寻找递推公式</h4>
<p><img src="https://img.chenhe.cc/i/2022/11/07/6368dfd91e723.jpg" alt="" />
以 <code>cost = [10, 15, 20]</code> 为例。要到达楼顶有两个选择：要么从 15 那一级爬，要么从 20 那一级爬。要求最低代价，所以要挑选累计代价最低的一个。而无论是 15 还是 20，到达它们的最低代价都取决于前面的轨迹。这就找到了递归。并且可以得出到达层 n 的最低代价递推公式为：<code>mincost(n) = min(mincost(n-1), mincost(n-2)) + cost[n]</code>。</p>
<p>显然 <code>n=0/1</code> 是我们的基本情况，因为这两级可以作为起始点。</p>
<p>总结一下，得到完整递推公式：</p>
<ul>
<li>mincost(i) = min(mincost(i-1), mincost(i-2)) + cost[i]</li>
<li>mincost(0) = cost[0]</li>
<li>mincost(1) = cost[1]</li>
</ul>
<blockquote>
<p>可能有小伙伴觉得这题已经解决了，干嘛还要动态规划呢？</p>
<p>这是一个递归算法，递归函数中有两次递归调用，这意味着：</p>
<ul>
<li>时间复杂度为 $O(2^N)$</li>
<li>空间复杂度为 $O(N)$（函数栈开销）</li>
</ul>
<p>显然是不可接受的。</p>
</blockquote>
<h4>储存中间结果</h4>
<p>暴力递归法会产生指数级别的重复运算。比如计算 <code>mincost(i-1)</code> 时必定要计算 <code>mincost(i-2)</code> 与 <code>mincost(i-3)</code>，而计算 <code>mincost(i-2)</code> 时还要计算 <code>mincost(i-3)</code>，这就重复计算了。可以想象，计算 <code>mincost(i-3)</code> 时往下递归，又会有一大堆重复计算。</p>
<p>这些重复计算的结果就是我们要存储起来的。那么用什么保存？这取决于递归函数的参数。<strong>因为我们选择的容器最好可以通过参数来随机访问元素</strong>。显然数组是个不错的选择。事实上，大部分题目中都使用数组，就是常见的 <code>dp</code> 数组。<strong>这一步的关键是定义清楚 <code>dp</code> 的下标与值代表什么</strong>。</p>
<p>这样一样，到达每一级阶梯的最小代价只需要计算一遍，因此时间复杂度降低到 $O(N)$。</p>
<p>到此为止动态规划的核心步骤已经完成了。只是空间复杂度严格来说是 $2N$，因为除了记录中间结果的数组，还有递归的调用栈开销，这里还有优化的空间。</p>
<h4>改写迭代</h4>
<p>从递推公式我们就能隐约感觉，完全可以从基本情况开始逐步迭代，从而一步步计算最终答案。有了 dp 数组，实现这个目标相对而言轻松许多了。</p>
<p>只需要从前往后遍历我们的 dp 数组，利用同一套递归公式，就可以完成迭代。</p>
<p><strong>唯一要注意的是数组右边界情况。</strong> 通常题目要求的答案不在数组中，所以循环完成后应该再手动迭代一次，这才是答案。</p>
<p>虽然空间复杂度没有变化，其实是节省了函数调用栈的开销。</p>
<h4>优化 dp 数组</h4>
<p>在上面的迭代算法中，不难看出我们一直在使用 dp 数组中最新的两位。既然这样，何不只保留这两位呢？</p>
<p>现在 dp 数组只需要两个元素，每一次迭代先计算新的结果，然后把 dp[1] 挪到 dp[0]，把新结果写入 dp[1]，就可以继续迭代了。</p>
<p>优化后空间复杂度降低到常量级别 $O(1)$。</p>
<h4>代码 (java)</h4>
<pre><code>class Solution {
    public int minCostClimbingStairs(int[] cost) {
        int[] dp = new int[2];
        dp[0] = cost[0];
        dp[1] = cost[1];
        for (int i = 2; i &lt; cost.length; i++) {
            int c = cost[i] + Math.min(dp[0], dp[1]);
            dp[0] = dp[1];
            dp[1] = c;
        }
        return Math.min(dp[0], dp[1]);
    }
}
</code></pre>
<h2>01背包问题</h2>
<p>背包问题是动态规划的一类，01 背包又是背包问题的一个子类。</p>
<p>背包问题的典型模型是：给定一些物品，他们各自有不同的重量与价值。有一个固定容量的背包，问最多可以拿取的物品价值是多少。所谓「01 背包」就是每种物品<strong>最多只能拿取一次</strong>，因此每个物品都有拿/不拿两个状态，由此得名。</p>
<p>实际面试中不太会直接考背包问题，而是让我们自己把原始问题转化为背包模型去解决。现在先不说题目本身，得把背包问题通用的理论搞明白才行。</p>
<h3>理论</h3>
<h4>寻找递推关系</h4>
<p>很多背包问题的文章都从 dp 数组讲起，我为了加强对动态规划通用解题步骤的理解，这里依然先寻找递推公式，从递归写法开始。背包问题的递推公式是二维的，一个维度是候选物品，另一个维度是背包容量。我们定义递归函数如下：</p>
<pre><code>/**
 * @param weight 物品的重量
 * @param value  物品的价值
 * @param i      允许拿取 [0,i) 的物品
 * @param cap    背包容量
 */
private int maxValue(int[] weight, int[] value, int i, int cap)
</code></pre>
<p>假设每个物品的价值都大于 0，<strong>那么基本情况就是：没有候选元素，或背包容量为 0，那么可拿取的最大物品价值也是 0</strong>。</p>
<p>非基本情况下，对于当前物品（下标为 i-1 的物品）有两个选择：拿或不拿。决策树如下：</p>
<pre><code>graph TB
A[/是否超过总容量/]
A --是--&gt; B[不拿]
A --否--&gt; C[/拿了是否增加总价值/]
C --否--&gt; B
C --是--&gt; D[拿取]
</code></pre>
<blockquote>
<p>初学的小伙伴可能好奇，拿了不是一定增加总价值吗？</p>
<p>其实不是的，拿了这个物品留给其他物品的容量就少了，但这个物品的价值不一定有其他物品高。</p>
</blockquote>
<p>这两种选择的总价值计算方法如下：</p>
<ul>
<li><strong>不拿</strong>：把总容量全部分配给 [0, i-1) 的物品去选取。</li>
<li><strong>拿</strong>：把总容量减去这个物品的重量，然后分配给 [0, i-1) 的物品选取，它们的价值加上这个物品的价值，就是总价值。</li>
</ul>
<p>这就是递推关系啦。</p>
<p>代码如下：</p>
<pre><code>private int maxValue(int[] weight, int[] value, int i, int cap) {
    if (i == 0 || cap == 0) return 0; // 基本情况
    // 如果不拿这个(物品，那么所有容量都分配给前面的物品。
    int notTake = maxValue(weight, value, i - 1, cap);
    if (weight[i - 1] &gt; cap) {
        // 这个物品超过了背包的总容量，肯定不能拿
        return notTake;
    }
    // 如果拿这个物品，那么留给前几个物品的容量就是总容量减去这个物品重量
    // 此时的总价值是前几个物品的价值加上当前这个物品的价值
    int take = maxValue(weight, value, i - 1, cap - weight[i - 1]) + value[i - 1];
    return Math.max(notTake, take);
}

// 调用
public int maxValue(int[] weight, int[] value, int cap) {
    return maxValue(weight, value, weight.length, cap);
}
</code></pre>
<p>现在尝试用数组缓存中间结果，省掉重复的计算。我们的递归函数有两个变量 <code>i</code> 和 <code>cap</code>，所以缓存数组是二维的，可以这么定义：</p>
<pre><code>// dp[i][c] 表示从 [0,i) 个物品中挑选，总容量为 c，可拿取的最大价值是多少
int[][] dp = new int[weight.length + 1][cap + 1];
</code></pre>
<p>注意，根据 dp 的定义，<code>dp[weight.length][cap]</code> 是有意义的，表示从所有物品中挑选，容量就是给定的最大容量，因此建立 dp 数组的时候记得给容量 +1。缓存之后的代码就不放了，比较简单，递归函数的结果存入缓存，递归计算之前先从缓存里检查一下就行。</p>
<h4>改写迭代</h4>
<p>根据 dp 数组的定义，以及递归写法中的基本情况，<strong>可以得出初始化 dp 数组的方法：第一行和第一列全部置 0</strong>。</p>
<ul>
<li>第一行置 0：无论容量多大，没有可以拿取的东西，则最大价值为 0.</li>
<li>第一列置 0：无论有多少东西可以拿，容量为 0，则最大价值为 0.</li>
</ul>
<p>（java 中默认是 0，不用设置了）</p>
<p>那么迭代方向呢？同样从递归写法中可以看出，要计算 <code>dp[i][c]</code>，需要 <code>dp[i-1][c-?]</code>，这里的 <code>?</code> 可能是大于等于 0 的任何数。换句话说，要计算一个元素，需要它上一行（截止到 <code>c</code> 列）的每一个元素。那么从上往下依次迭代每一行就行了。</p>
<p>代码如下：</p>
<pre><code>public int maxValue(int[] weight, int[] value, int cap) {
    // dp[i][c] 表示从 [0,i) 个物品中挑选，总容量为 c，可拿取的最大价值是多少
    int[][] dp = new int[weight.length + 1][cap + 1];
    for (int row = 1; row &lt;= weight.length; row++) {
        for (int curCap = 1; curCap &lt;= cap; curCap++) {
            int notTake = dp[row - 1][curCap];
            // 别忘了 row 表示可以拿 [0,row) 的物品，所以当前物品是 row-1
            if (weight[row - 1] &gt; curCap) {
                dp[row][curCap] = notTake;
            } else {
                int take = dp[row - 1][curCap - weight[row - 1]] + value[row - 1];
                dp[row][curCap] = Math.max(take, notTake);
            }
        }
    }
    return dp[weight.length][cap];
}
</code></pre>
<h4>优化 dp 数组</h4>
<p>这里有个更专业的名字，叫改写滚动数组。</p>
<p>既然对于每一个元素的计算，只需要其上一行的元素，那也就只需保留一行历史记录就行，加上当前计算的一行，一共两行。这一步优化也不难，就不单独给出代码了。</p>
<p>更进一步，其实只要一行数组，也就是一维数组就够了，把新的一行的计算直接覆盖到数组里。<strong>不过此时要求从右往左计算</strong>。一开始转为迭代时，我们总结要计算一个元素，可能需要它上一行（<strong>截止到 <code>c</code> 列</strong>）的每一个元素。当时「截止到 <code>c</code> 列」没有起到什么作用，现在就很关键了。</p>
<p>假如从左往右计算，计算完 <code>dp[c]</code>，它的数值就被覆盖了。那么计算 <code>dp[c+1]</code> 参考的就是新的 <code>dp[c]</code>，倒过来计算就可以解决这个问题。同时，对于「当前物品重量超过了容量」这一情况，直接忽略不更新数组就行。</p>
<blockquote>
<p>如果从左往右算，相当于同一个物品可以拿取多次，具体见下面完全背包问题的理论部分。</p>
</blockquote>
<p>代码如下：</p>
<pre><code>public int maxValue(int[] weight, int[] value, int cap) {
    int[] dp = new int[cap + 1];
    for (int row = 1; row &lt;= weight.length; row++) {
        for (int curCap = cap; curCap &gt; 0; curCap--) {
            if (weight[row - 1] &lt;= curCap) { // 容量足够放下当前物品
                int notTake = dp[curCap];
                int take = dp[curCap - weight[row - 1]] + value[row - 1];
                dp[curCap] = Math.max(take, notTake);
            }
        }
    }
    return dp[cap];
}
</code></pre>
<h4>拓展问题</h4>
<p>这些问题帮助理解背包 dp 数组的本质，而不只是背代码。</p>
<p><strong>拓展问题 1：未优化 dp 时，能否按行从右往左计算？</strong></p>
<p>可以。只要上一行计算完毕了，当前行怎么算都行。随机算都行。而我们的起始行是手动填充的，视为计算完毕。</p>
<p><strong>拓展问题 2：未优化 dp 时，先迭代计算不同物品，再计算不同容量行不行？也就是列优先计算。</strong></p>
<p>可以。反正计算某元素，只需要它上一行截止到 <code>c</code> 列的元素，所以列优先计算也满足这个条件。</p>
<p><strong>拓展问题 3：优化 dp 后能否列优先计算？</strong></p>
<p>不可以。此时的 dp 只能保存一行数据，所以必须按行计算，才能确保 dp 里的数据是「上一行」的数据。</p>
<h3>416 - 分割数组</h3>
<blockquote>
<p><a href="https://leetcode.com/problems/partition-equal-subset-sum/">传送门</a></p>
<p>Given a <strong>non-empty</strong> array <code>nums</code> containing <strong>only positive integers</strong>, find if the array can be partitioned into two subsets such that the sum of elements in both subsets is equal.</p>
<p>简单说，就是看能否把一个数组分割成和相等的两部分。</p>
</blockquote>
<h4>转化为背包问题</h4>
<p>猛一看这题和背包没什么关系。这才是面试题目的常态。</p>
<p>分成两个和相等的数组，那每一个数组的和肯定是原先和的一半。这句话都想不通的请先去睡一觉 💤 或者眺望一下远方。</p>
<p><strong>注意一个小坑：如果原先和是奇数，那必然不可能分成两个和一样的数组。</strong></p>
<p>可以这么建模：</p>
<ul>
<li>背包的容量是原先和的一半。</li>
<li>每一个元素就是一个物品，其重量和价值都是元素本身。</li>
<li>每个元素只能选取一次。</li>
<li>求，能否把背包刚好装满。</li>
</ul>
<p>瞧，这就是 01 背包问题了。</p>
<p>「刚好装满」和传统背包问题问法不太一样，但实际没啥区别。为了装满，肯定要尽可能地多装，只需要最后判断一下，这个尽力多装的结果，有没有塞满就行。</p>
<h4>代码 (java)</h4>
<pre><code>class Solution {
  public boolean canPartition(int[] nums) {
    int sum = Arrays.stream(nums).sum();
    if (sum % 2 != 0) return false;
    int half = sum / 2;
    int[] dp = new int[half + 1];
    for (int row = 1; row &lt;= nums.length; row++) {
      for (int cap = half; cap &gt; 0; cap--) {
        if (cap &gt;= nums[row - 1]) { // 能不能塞下当前元素？
          // dp[cap] (更新前): 不要当前元素，能塞多少？
          // dp[cap - nums[row - 1]]: 塞了当前元素，剩余空间最多塞多少？
          dp[cap] = Math.max(dp[cap], dp[cap - nums[row - 1]] + nums[row - 1]);
        }
      }
    }
    return dp[half] == half; // 塞满了吗？
  }
}
</code></pre>
<h3>494 - 目标和</h3>
<blockquote>
<p><a href="https://leetcode.com/problems/target-sum/description/">传送门</a></p>
<p>You are given an integer array <code>nums</code> and an integer <code>target</code>.</p>
<p>You want to build an <strong>expression</strong> out of nums by adding one of the symbols <code>'+'</code> and <code>'-'</code> before each integer in nums and then concatenate all the integers.</p>
<ul>
<li>For example, if <code>nums = [2, 1]</code>, you can add a <code>'+'</code> before <code>2</code> and a <code>'-'</code> before <code>1</code> and concatenate them to build the expression <code>"+2-1"</code>.</li>
</ul>
<p>Return the number of different <strong>expressions</strong> that you can build, which evaluates to <code>target</code>.</p>
</blockquote>
<p>这道题不再是求最值，而更像是组合问题，所以递推关系式也不是 max 函数了，有一点变化。</p>
<h4>转为背包问题</h4>
<p>这题可以这么理解：尝试把数组分为和分别为 A 与 B 的两部分。A 部分元素全部添加正号，B 部分全部添加负号，则题目所求目标可转化为 <code>A-B=target</code>。显然 <code>A+B=Sum</code>，<code>Sum</code> 就是所有元素的和。综合这两个式子可以得出 <code>A=(target+Sum)/2</code>。</p>
<p>类比 416 那道题，现在可以转为背包问题了：背包容量为 <code>(target+Sum)/2</code>，有多少种选取元素的组合可以装满它？</p>
<p>注意两个细节：</p>
<ol>
<li>如果 <code>target &gt; Sum</code> 或 <code>target &lt; -Sum</code>，则无论如何也无法达到要求，此时结果为 0。</li>
<li>如果 <code>(target+Sum)/2</code> 是奇数，也无法达到要求。</li>
</ol>
<p>现在 <code>dp[c]</code> 定义为：容量为 <code>c</code> 的背包，有多少种选取元素的组合可以装满它。显然 <code>dp[0]=1</code>，因为一个没有容量的背包，只要什么都不选就“满了”。「什么都不选」也是一种选择方案，且「没有候选元素」也符合「什么都不选」的条件。</p>
<blockquote>
<p>注意：容量为 0 不代表不能选取任何元素，比如 0 元素就不占用容量。</p>
</blockquote>
<p>每遇到一个新的候选元素，还是有两个选择：拿取或不拿。但是这里不是求最大价值，而是可能的方案数，因此拿或不拿都是一种方案，<strong>两者的结果应该相加</strong>。</p>
<ul>
<li>拿取：新方案数是之前的元素填满<strong>剩余容量</strong>的方案数。</li>
<li>不拿：新方案数是之前的元素填满<strong>全部容量</strong>的方案数。</li>
</ul>
<blockquote>
<p>可能有同学好奇，这里似乎没有判断「正好装满」。那是因为 <code>dp</code> 定义为满足条件的方案数，如果不能正好装满则方案数为 0。同时动态规划有「无后效性」，即当前状态的计算依赖上一个状态，而无需关心上一个状态如何计算出来（以及是否正确）。那么整个算法的正确性依赖初始状态。初始状态是我们自己定义的：<code>dp[0]=1</code>, <code>dp[?]=0 (?&gt;0)</code>，可以保证正确。所以代码中不用额外判断了。</p>
<p>那为什么 416 题需要判断呢？因为那一题的 <code>dp</code> 仅仅代表塞进背包的值，「是否装满」不属于 dp 的一部分，自然也不被算法本身所保证。</p>
</blockquote>
<h4>代码 (java)</h4>
<pre><code>class Solution {
  public int findTargetSumWays(int[] nums, int target) {
    int sum = Arrays.stream(nums).sum();
    if (Math.abs(target) &gt; sum) return 0;
    if ((target + sum) % 2 != 0) return 0;
    int cap = (target + sum) / 2;

    // dp[c] 表示容量为 c，有多少个数字的组合能装满
    int[] dp = new int[cap + 1];
    dp[0] = 1;
    for (int row = 1; row &lt;= nums.length; row++) {
      //
      for (int c = cap; c &gt; 0; c--) {
        if (cap &gt;= nums[row - 1]) {
          // dp[c]: 不选取当前数字，只选之前的数字，有多少个组合能装满
          // dp[c - nums[row - 1]]: 选取当前数字，再从之前的数字中选一些，有多少个组合能装满
          dp[c] = dp[c] + dp[c - nums[row - 1]];
        }
      }
    }
    return dp[cap];
  }
}
</code></pre>
<h2>完全背包问题</h2>
<h3>理论</h3>
<p><em>务必先看完前面 01 背包的理论再来看这个</em>。</p>
<p><strong>完全背包与 01 背包唯一的区别是：每个物品都有无限个，即每个物品可以多次放入背包。</strong></p>
<p>在 01 背包中对于每个物品只有两个选择：拿与不拿。而在完全背包中有多个选择：不拿或拿 N 个，我们需要在所有选项中取总价值最大的一个。当然，也不是无限制拿的，需要容量足够才行。<strong>也就是说最多可以拿的个数，是把所有容量全部分配给当前物品，所能容纳的个数。</strong></p>
<p>搞清了这个，完全背包问题的递归算法就不难了：</p>
<pre><code>/**
 * @param i   允许拿取下标为 [0,i) 的物品
 * @param cap 容量限制
 * @return 可以拿取的最大物品价值
 */
private int maxValue(int[] weight, int[] value, int i, int cap) {
  if (i == 0 || cap == 0) return 0; // 基本情况
  int result = 0;
  // 分别计算不拿、拿 N 个当前物品的情况，取价值最大的一个
  for (int count = 0; count &lt;= cap / weight[i - 1]; count++) {
    // 取 count 个当前物品，其价值为 value[i - 1] * count
    // 留给其他物品的容量为 cap - weight[i - 1] * count
    int newValue = maxValue(weight, value, i - 1, cap - weight[i - 1] * count) + value[i - 1] * count;
    result = Math.max(result, newValue);
  }
  return result;
}

// 调用
public int maxValue(int[] weight, int[] value, int cap) {
  return maxValue(weight, value, weight.length, cap);
}
</code></pre>
<p>可以看到表现在代码中唯一的区别是拿取当前物品的策略。其他的框架是一样的。那我们就快进到优化后的 dp 数组。</p>
<pre><code>public int maxValue(int[] weight, int[] value, int cap) {
  // dp[c] 表示容量为 c，最多可以拿取多少价值的物品。
  int[] dp = new int[cap + 1];
  dp[0] = 0; // 没有可拿的物品且容量为 0，则最大拿取价值为 0
  for (int i = 1; i &lt;= weight.length; i++) {
    // 允许拿取下标为 [0,i) 的物品
    for (int curCap = 0; curCap &lt;= cap; curCap++) {
      if (weight[i - 1] &lt;= curCap) {
        dp[curCap] = Math.max(dp[curCap], dp[curCap - weight[i - 1]] + value[i - 1]);
      }
    }
  }
  return dp[cap];
}
</code></pre>
<p>在 01 背包中我们强调，优化后的 dp 数组必须从右往左遍历，这里相反，必须从左往右遍历。</p>
<p>开始遍历新的物品 <code>i</code> 时，<code>dp[c]</code> 相等于未优化时的「上一行」，它意思是容量为 <code>c</code> 只允许选取 <code>[0, i-1)</code> 的物品，装满后的最大价值是多少。此时新增允许拿取下标为 <code>i-1</code> 的物品，并更新最大价值。</p>
<p>接着遍历下一个容量 <code>c+1</code>，此时 <code>dp[c]</code> 的含义发生了变化，相等于未优化时的「当前行」，它意思是容量为 <code>c</code>，允许拿取 <code>[0, i)</code> 的商品，装满后的最大价值是多少。但是外层循环 <code>i</code> 没变，相当于有机会再拿一次 <code>i-1</code> 商品。在这个商品大循环中，容量小循环每一次都有机会多拿一个 <code>i-1</code> 商品。由此实现了允许重复拿取同一商品的策略，也是和 01 背包在代码层面的差别。</p>
<blockquote>
<p><strong>优化 dp 后能否列优先计算？</strong></p>
<p>那时另一个需求了。行优先计算（外层遍历物品）得出的是组合数。列优先计算（外层遍历容量）得出的是排列数。具体看下面 377 的例子。</p>
</blockquote>
<h3>518 兑换硬币 II</h3>
<blockquote>
<p><a href="https://leetcode.com/problems/coin-change-ii/description/">传送门</a></p>
<p>You are given an integer array <code>coins</code> representing coins of different denominations and an integer <code>amount</code> representing a total amount of money.</p>
<p>Return <em>the number of combinations that make up that amount</em>. If that amount of money cannot be made up by any combination of the coins, return <code>0</code>.</p>
<p>You may assume that you have an infinite number of each kind of coin.</p>
<p>The answer is <strong>guaranteed</strong> to fit into a signed <strong>32-bit</strong> integer.</p>
</blockquote>
<p>这题可以类比 <a href="https://leetcode.com/problems/target-sum/description/">494-目标和</a>，这俩都是组合问题而不是求最值，因此状态转移公式都应该是加法而不是 max 函数。区别在于本题一个硬币允许选取多次，是完全背包问题。</p>
<h4>转为完全背包问题</h4>
<p>题目的描述可以等价改写成：钱包容量为 <code>amount</code>，<code>coins</code> 是可供选择的硬币，每个硬币可拿取多次。问有多少中组合正好装满钱包。直接套用公式不难做出来，但为了加深理解，这里还是一步步来分析。</p>
<p><code>dp[c]</code> 数组定义为：容量为 <code>c</code> 的钱包，有多少种硬币组合可以装满它。默认情况下没有任何硬币可供选择，钱包容量为 0，恰好可以满足要求（什么也不拿也是一种方案）因此 <code>dp[0]=1</code>。</p>
<p>遇到一个新的可供选择的硬币 <code>i</code> 时，<code>dp[c]</code> 表示从 <code>[0,i-1)</code> 中选择硬币，容量为 <code>c</code>，有多少中组合装满。此时有两个选择：</p>
<ul>
<li>不选取新的硬币，答案为用 <code>[0,i-1)</code> 的硬币装满，有多少个组合。</li>
<li>拿取新硬币，答案为用 <code>[0,i-1)</code> 的硬币装满剩余的容量，有多少个组合。</li>
</ul>
<p>两个方案的答案加起来作为新的值更新到 <code>dp[c]</code> 上。接着从左往右遍历到 <code>dp[c+1]</code>，此时 <code>dp[c]</code> 已更新，其含义是：从 <code>[0,i)</code> 中选择硬币有多少个方案，现在依然有两个选择：</p>
<ul>
<li>不再次拿取当前硬币。</li>
<li>再拿一次当前硬币。</li>
</ul>
<p>依次类推，循环到 <code>dp[amount]</code> 时就可以得出允许多次拿取某一硬币，总共有多少种方案。</p>
<h4>代码 (java)</h4>
<pre><code>class Solution {
  public int change(int amount, int[] coins) {
    // dp[c] 表示容量为 c 时有多少个组合可以正好装满
    int[] dp = new int[amount + 1];
    dp[0] = 1; // 容量为 c，无可选硬币时，只有一个方案：什么都不选
    for (int i = 1; i &lt;= coins.length; i++) {
      // 允许选取下标为 [0,i) 的硬币
      for (int cap = 0; cap &lt;= amount; cap++) {
        if (cap &gt;= coins[i - 1]) {
          dp[cap] = dp[cap] + dp[cap - coins[i - 1]];
        }
      }
    }
    return dp[amount];
  }
}
</code></pre>
<h3>377 - 组成和 IV</h3>
<blockquote>
<p><a href="https://leetcode.com/problems/combination-sum-iv/">传送门</a></p>
<p>Given an array of <strong>distinct</strong> integers <code>nums</code> and a target integer <code>target</code>, return <em>the number of possible combinations that add up to</em> <code>target</code>.</p>
<p>The test cases are generated so that the answer can fit in a <strong>32-bit</strong> integer.</p>
<p>Note that different sequences are counted as different combinations.</p>
</blockquote>
<p>乍一看和上一题差不多，但本题不同的选取顺序视为不同的方案。</p>
<h4>排列问题</h4>
<p>在上一题中我们按行遍历，它的核心思想是：对于固定的可选物品范围，随着容量增大，能否尽力多拿几个最新的物品。虽然动态规划解法无法得出选择的方案详情，但我们可以想象出来，这样遍历得出的方案，总是按照物品本身的顺序排列的。</p>
<p>比如 <code>nums=[1,2,3], target=4</code>，算法给出的答案是 4，脑补一下，这 4 个方案其实是这样的：</p>
<pre><code>1. {1, 1, 1, 1}
2. {1, 1, 2} &lt;- 2 在 1 后面
3. {2, 2}
4. {1, 3} &lt;- 3 在 1 后面
</code></pre>
<p>如果改成按列遍历，其核心思想就变成了：对于固定的容量，随着可选物品范围的增大，能否拿<strong>一个</strong>最新的物品。到新的一轮容量大循环时，再依次看看能不能<strong>基于之前大循环的所有方案</strong>再拿一个当前物品，这样对物品的拿取就被分散到了不同的循环中。</p>
<p>比如 <code>target=3, i=1</code> 时，只允许拿 <code>1</code> 这个数字，但这个限制只对当前轮有效。之前大循环 <code>target=0/1/2</code>一共有四个方案：<code>{}, {1}, {1,1}, {2}</code>，分别基于这四个方案尝试再拿一个 <code>1</code>，所以除了 <code>{1,1,1}</code> 这个方案外，<code>{2,1}</code> 也可以，当然，<code>{1,2}</code> 是不行的，因为当前轮不允许拿 <code>2</code>。</p>
<p>如此一来得到的就是考虑顺序的组合个数了，也称为排列数。</p>
<blockquote>
<p>到底求组合还是求排列，关键在于<strong>是否需要考虑把物品装入背包的顺序</strong>，如果需要考虑就是排列，否则就是组合。</p>
<p>看一下 139 题，很容易混淆。</p>
</blockquote>
<h4>代码 (java)</h4>
<p>理论比较难懂，代码写起来很简单，<strong>把内外层循环对调一下</strong>就行了。</p>
<pre><code>class Solution {
  public int combinationSum4(int[] nums, int target) {
    int[] dp = new int[target + 1];
    dp[0] = 1;
    for (int cap = 0; cap &lt;= target; cap++) { // 外层遍历容量
      for (int i = 1; i &lt;= nums.length; i++) {
        if (cap &gt;= nums[i - 1]) {
          dp[cap] = dp[cap] + dp[cap - nums[i - 1]];
        }
      }
    }
    return dp[target];
  }
}
</code></pre>
<h3>139 - 分割单词</h3>
<blockquote>
<p><a href="https://leetcode.com/problems/word-break/description/">传送门</a></p>
<p>Given a string <code>s</code> and a dictionary of strings <code>wordDict</code>, return <code>true</code> if <code>s</code> can be segmented into a space-separated sequence of one or more dictionary words.</p>
<p><strong>Note</strong> that the same word in the dictionary may be reused multiple times in the segmentation.</p>
</blockquote>
<h4>转化为背包问题</h4>
<p><code>s</code> 是背包，<code>wordDict</code> 是物品列表。问能否用这些物品恰好装满背包。每个单词可以重复使用，因此是完全背包问题。</p>
<p><strong>注意！这是一个顺序敏感题目</strong>。也许有同学和我一开始一样，被题目描述误导，认为这题在问能否用字典中的单词组成某个字符串，既然是组成，那顺序就不重要啦。就题目本身来讲确实不重要，但是我们把它转化为了背包问题，转化的过程中注意等价性。背包问题的本质是从一堆物品中选择一些，装满背包。「装满」是个比较模糊的说法，在前几题中，所谓装满就是数值的和与背包容量一致，这种情况下 A+B 还是 B+A 没啥区别。但这题，装满其实有两个含义：</p>
<ol>
<li>字典中的单词可以拼凑出字符串。</li>
<li>必须按顺序选取单词装入背包。</li>
</ol>
<p><strong>相同的字典，选择不同的顺序装，答案可能不一样，我们必须全部都试试才能得到正确结果</strong>。所以要按排列问题来算。</p>
<p>然后 <code>dp</code> 数组定义为 <code>dp[c]</code> 表示 <code>[0,c)</code> 的子串能否用字典表示。则遇到一个新词，有两个选择：</p>
<ol>
<li>不使用它。结果是能否用之前的单词表示这个子串。</li>
<li>使用它。结果是能否用之前的单词表示剩下的子串。</li>
</ol>
<p>这两个方案<strong>任意一个</strong>成立就视为可以表示。</p>
<blockquote>
<p>如果把 dp 定义为 bool 型不太理解，可以先定义为 int，含义是：<code>[0,c)</code> 的子串有多少种方式用字典表示。</p>
</blockquote>
<h4>代码 (java)</h4>
<pre><code>class Solution {
  public boolean wordBreak(String s, List&lt;String&gt; wordDict) {
    // dp[c] 表示 [0,c) 子串能否用字典表示
    boolean[] dp = new boolean[s.length() + 1];
    dp[0] = true;
    for (int c = 0; c &lt; s.length(); c++) { // 排列问题，外层遍历背包
      for (String word : wordDict) {
        if (c &gt;= word.length()) { // 相当于：背包容量必须能放下当前物品
          dp[c] = dp[c] || (s.startsWith(word, c - word.length()) &amp;&amp; dp[c - word.length()]);
          // 如果目前的物品（单词）足够表示（装满背包）了，那么再添加新候选单词必然也能表示
          // 所以没必要继续判断，提高性能。
          // 但如果 dp 定义为 int 则不建议跳出，因为新的单词可能有新的表示方式，继续计算才符合 dp 的定义
          break;
        }
      }
    }
    return dp[s.length()];
  }
}
</code></pre>
]]></content>
        <author>
            <name>Chenhe</name>
            <uri>https://chenhe.me/</uri>
        </author>
        <published>2022-12-11T22:23:53.000Z</published>
    </entry>
</feed>