<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0"><channel><title>guqing's blog</title><link>https://guqing.io</link><atom:link href="https://guqing.io/rss.xml" rel="self" type="application/rss+xml"/><description>毕生所求无它，爱与自由而已</description><generator>Halo v2.20.16</generator><language>zh-cn</language><image><url>https://guqing.io/logo</url><title>guqing's blog</title><link>https://guqing.io</link></image><lastBuildDate>Thu, 12 Mar 2026 07:13:48 GMT</lastBuildDate><item><title><![CDATA[2024 年终总结：远方与热爱]]></title><link>https://guqing.io/archives/2024-life-and-love</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=2024%20%E5%B9%B4%E7%BB%88%E6%80%BB%E7%BB%93%EF%BC%9A%E8%BF%9C%E6%96%B9%E4%B8%8E%E7%83%AD%E7%88%B1&amp;url=/archives/2024-life-and-love" width="1" height="1" alt="" style="opacity:0;">
<p>2024 年，生活因为远行和热爱而显得格外丰富多彩。这一年，不仅踏足了梦寐以求的远方，也在多样的经历中感受了心灵的震撼与满足。</p>
<h2 id="远方的呼唤大西北与河西走廊">远方的呼唤：大西北与河西走廊</h2>
<p>今年国庆节，我终于实现了多年来的一个愿望——亲自走一趟大西北，穿越河西走廊。这片土地承载了中华历史上无数的辉煌与故事。从纪录片《河西走廊》到书籍的描绘，我一直深深向往那里，终于在这个秋天用自己的双脚踏上了这片神秘又壮丽的大地。</p>
<p>行程从兰州开始，途经武威、金昌、张掖，再到敦煌和青海——每一步都带来了深深的文化震撼。</p>
<h3 id="祁连山中华民族的精神家园">祁连山：中华民族的精神家园</h3>
<p>这次旅行中，最让我感触深刻的，是巍峨的<strong>祁连山</strong>。作为横亘河西走廊的重要山脉，它不仅是自然的奇观，更是中华民族历史与文化的丰碑，见证了无数影响深远的事件。</p>
<p>这里是河西走廊的生命之源，滋养着无数的绿洲与生灵。从霍去病“饮马翰海，封狼居胥，西规大河，列郡祁连”，到隋炀帝西巡祁连，穿越隘险山路，风雪晦暝中感受大山的雄奇；再到唐代玄奘取经途中仰望祁连的巍峨，吐蕃东扩、西夏拓疆，以及成吉思汗策马征战，祁连山始终作为中华民族历史舞台的背景，书写着不朽的篇章。</p>
<p>不仅如此，祁连山的存在也是一种精神的象征。这座山脉不仅塑造了河西走廊的地理格局，也见证了民族的融合与交流。它所承载的，不仅是大漠孤烟、长河落日的苍凉之美，还有历朝历代开疆拓土的壮志豪情。</p>
<p>站在祁连山脚下，仰望连绵不绝的山脉，历史的厚重感扑面而来。那些耳熟能详的名字和故事仿佛不再遥远，它们就在这片土地上真实地发生过。祁连山不仅是一个地理存在，更是中华民族共同的精神家园，永远耸立于我们的文化记忆和历史深处。</p>
<p>这一次，我终于亲自走进了祁连山，用眼睛去感受它的雄伟，用心去聆听它的故事，用手触摸它的脉络，并带回了一瓶土作为纪念。这是一次难忘的文化与自然的交融，一种对中华历史与精神家园的深刻敬意。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_0638.jpeg&amp;size=m" alt="祁连山草原"></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_0978.jpeg&amp;size=m" alt="祁连山荒漠"></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1007.jpeg&amp;size=m" alt="祁连山脚下"></p>
<h4 id="张掖塞上江南与历史的交汇">张掖：塞上江南与历史的交汇</h4>
<p>如果说祁连山是河西走廊的脊梁，那么张掖便是它的灵魂。这座位于祁连山北麓的古城，自汉朝设郡以来便成为东西文化的交汇点。作为河西四郡之一，张掖不仅有着“塞上江南”的美誉，更是一座承载着厚重历史的传奇之城。</p>
<p>张掖的历史如同一幅绵延不绝的画卷：</p>
<p>这里是匈奴、月氏、柔然、突厥、吐蕃、蒙古等几十个民族曾经的繁衍生息之地，苍凉的黄沙百丈与铁马冰河的金戈声交织在这片土地上；这里也是想象与现实交汇的地方，周穆王与西王母的瑶池会面、老子骑青牛入流沙的奇幻传说都深深植根于张掖的文化记忆；而《西游记》中那些熟悉的名字——黑河、弱水、流沙河、通天河——也源于张掖及其周边的真实地貌。</p>
<p>从霍去病策马西征，到张骞出使西域带回丝绸之路的起点；从隋炀帝在此召见西域各国使臣，到成吉思汗的铁蹄踏碎西夏的繁华旧梦；从肃王的藩邸迎来裕固新民族，到左宗棠栽下的杨柳与千年不倒的胡杨在此扎根。张掖的历史是东西文化交流、南北民族交融的缩影。</p>
<p>当我踏入张掖时，脑海中浮现的是丝绸之路上驼铃声声、胡杨下长河落日的景象，那种历史的厚重感和文化的积淀让我心生敬畏。张掖的地标不仅仅是广为人知的丹霞地貌，它更是一座时间之城，连接过去与现在的桥梁，让我在旅行中重新认识了中华文明的深远意义。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_0892.JPG&amp;size=m" alt="七彩丹霞-1"></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_0952.JPG&amp;size=m" alt="七彩丹霞-2"></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_0824.jpeg&amp;size=m" alt="七彩丹霞-3"></p>
<h3 id="敦煌丝绸之路的璀璨明珠">敦煌：丝绸之路的璀璨明珠</h3>
<p><strong>敦煌</strong>，这座大漠深处的璀璨明珠，是我此次旅途中最震撼人心的文化与历史盛宴。作为丝绸之路上的重要节点，敦煌不仅承载着东西方文明交融的辉煌，也以其无与伦比的艺术成就留名千古。</p>
<p>当我踏入<strong>莫高窟</strong> 时，眼前那些绚丽的壁画和精美的雕塑，仿佛带我穿越千年时光。窟内的一砖一石、一笔一画，无不记录着丝路商旅的繁荣、佛教文化的兴盛与中西文化的碰撞。从九色鹿王的传奇故事，到飞天飘舞的优雅姿态，每一幅画作都栩栩如生，诉说着千年不朽的信仰与艺术。此刻我才真正体会到了余秋雨在《文化苦旅》一书中所著的一段话：</p>
<p>“如果仅仅为了听佛教故事，那么它多姿的神貌和色泽就显得有点浪费。如果仅仅为了学绘画技法，那么它就吸引不了那么多普通的游客。如果仅仅为了历史和文化，那么它至多只能成为厚厚著述中的插图。它似乎还要深得多，复杂得多，也神奇得多。</p>
<p>它是一种聚会，一种感召。它把人性神化，付诸造型，又用造型引发人性，于是，它成了民族心底一种彩色的梦幻，一种圣洁的沈淀，一种永久的向往。</p>
<p>它是一种聚会，一种感召。它把人性神化，付诸造型，又用造型引发人性，于是，它成了民族心底一种彩色的梦幻，一种圣洁的沈淀，一种永久的向往。</p>
<p>它是一种仪式，一种超越宗教的宗教。佛教理义已被美的火焰蒸馏，剩下了仪式应有的玄秘、洁净和高超。只要是知闻它的人，都会以一生来投奔这种仪式，接受它的洗礼和熏陶。”</p>
<p>离开了莫高窟后，我去观看了盛名在外的《乐动敦煌》表演。这场演出以现代科技和传统文化结合的形式，将敦煌壁画中的飞天、乐舞、驼铃商旅等元素生动地搬上舞台，伴随着绚丽的光影和悠扬的古乐，我仿佛置身于千年前的丝绸之路，感受敦煌在历史长河中的璀璨辉煌。</p>
<p>接着，又走进了敦煌另一处著名的自然奇观——鸣沙山与月牙泉。这里是沙漠的柔情，也是自然的奇迹。漫步在金色的沙丘之上，细腻的沙粒在脚下滑动，耳边仿佛能听到风吹沙鸣的声音。在月牙泉旁，我看到了大漠深处那一抹柔情。清澈的泉水如一轮弯月镶嵌在沙漠之中，泉水四周环绕着一片绿洲，这种生命与荒凉并存的景象令人感慨万分：”这里是大自然的艺术，既是严酷的，又是诗意的。“</p>
<p>夜幕降临，敦煌的另一面展现出独特的魅力。漫步于<strong>沙洲夜市</strong>，我完全被琳琅满目的手工艺品吸引，忍不住挑选了许多精美的纪念品。最让我喜欢的是一块手绘的壁画泥板，工艺与莫高窟的壁画相同，每一道线条都透露着敦煌特有的艺术风格；还有一只小巧精致的手绘鼻烟壶，水墨绘有花鸟山石，枝上疏花秀蕊，一只麻雀俏立枝头，鸣叫顾盼，灵动而细腻，这不禁让我联想起了宋徽宗的《梅花绣眼图》于是将其带走。每一件纪念品都仿佛带着这座古城的灵魂，让人难以拒绝。</p>
<p>这一晚的敦煌，不仅让我沉浸在历史与艺术的余韵中，更让我感受到这座城市在新时代焕发的独特魅力。它的每一处角落、每一个细节，都深深打动着我，让我为这片土地的过去与现在而感到震撼与自豪。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1309.JPG&amp;size=m" alt="莫高窟门匾"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1316.JPG&amp;size=m" alt="莫高窟九层塔"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1352.JPG&amp;size=m" alt="莫高窟九层塔窗子光影"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1383.JPG&amp;size=m" alt="莫高窟九层塔风铃"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1181.JPG&amp;size=m" alt="鸣沙山"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1255.JPG&amp;size=m" alt="敦煌夜市-1"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1265.JPG&amp;size=m" alt="敦煌夜市-2"></p>
<h3 id="青海跨越山脉感受自然的壮美与宁静"><strong>青海：跨越山脉，感受自然的壮美与宁静</strong></h3>
<p>从敦煌出发，我继续向南，越过阳关，横跨<strong>阿尔金山脉</strong>，进入了青海省。在这片高原大地上，辽阔的风景和纯净的自然气息同样让我深深震撼。从海西州一路前行，途经海南、海北，见证了高原上最动人的自然画卷。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1461.JPG&amp;size=m" alt="刚翻过阿尔金山脉"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1465.JPG&amp;size=m" alt="阿尔金山之后的路"></p>
<ul>
 <li>
  <p><strong>翡翠湖</strong>：<br>
    翡翠湖的色彩让人仿佛置身画中。湖水因矿物质的丰富而呈现出层次分明的翡翠绿与碧蓝色，每一汪水潭都像一颗宝石镶嵌在戈壁滩上。在这里，我被那种大自然调色盘般的惊艳深深吸引，感叹它的鬼斧神工。</p>
  <p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1508.JPG&amp;size=m" alt="蓝色翡翠湖"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1710.JPG&amp;size=m" alt="翡翠湖入口"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1631.JPG&amp;size=m" alt="翡翠色的翡翠湖"></p></li>
 <li>
  <p><strong>茶卡盐湖</strong>：<br>
    被誉为“天空之镜”的茶卡盐湖，是我此行中最梦幻的地方。站在湖面上，天与地仿佛融为一体，脚下是闪烁着阳光的盐晶，倒映着蓝天和云朵，仿佛走进了另一个世界。那种宁静与纯粹，让人忘却了一切喧嚣，只想静静感受这份高原独有的清凉与广阔。<br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1934.JPG&amp;size=m" alt="茶卡盐湖-1"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1781.JPG&amp;size=m" alt="茶卡盐湖-2"></p></li>
 <li>
  <p><strong>黑马河与青海湖</strong>：<br>
    沿着黑马河一路行进，我终于抵达了<strong>青海湖</strong>。这片中国最大的咸水湖像一块湛蓝的宝石镶嵌在高原上，环绕四周的雪山和草原相映成趣，呈现出一种难以言喻的壮美。在青海湖边，我看到牧民的帐篷点缀在草地上，牦牛悠闲地吃草，湖面在阳光下泛着粼粼波光。日出时，金色的光芒洒满湖面，整个天地仿佛被一种神圣的力量笼罩，那一刻，我真正感受到了宁静与和谐的极致。<br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1959.JPG&amp;size=m" alt="青海湖-1"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_2330.JPG&amp;size=m" alt="青海湖-2"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_2266.JPG&amp;size=m" alt="青海湖-3"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_2369.JPG&amp;size=m" alt="青海湖雪山"></p></li>
</ul>
<p>这一路穿越了青海的海西、海南、海北三州，亲历了高原湖泊的纯净与壮丽。高山、湖泊、戈壁、草原，它们共同交织，形成了一幅幅瑰丽的自然画卷。每一处景点，都让我切身体会到这片土地的辽阔与神秘。跨过阿尔金山脉，仿佛也跨越了一条时光的分界线，从苍凉的敦煌走向了高原的纯粹，这种对自然的敬畏与热爱，将成为我旅途中最珍贵的记忆之一。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_2521.JPG&amp;size=m" alt="骑马"></p>
<p>一路 2800 公里，虽然大部分时间都在路上，但路边的风景也同样辽阔壮丽：戈壁、山川、湖泊、草地、雪原，像极了一场泰拉瑞亚游戏——如梦、似幻。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1474.JPG&amp;size=m" alt="青海的牧场"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1743.JPG&amp;size=m" alt="青海的草地和雪山"></p>
<h2 id="重庆山城之美与火锅之魅">重庆：山城之美与火锅之魅</h2>
<p>此外，今年还去了重庆。从南山一望无际的山川，到长江两岸的繁华都市，重庆以其独特的地理优势和人文底蕴，吸引着无数游客和文人墨客。山水之间，蕴藏着独特的韵味与风情。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_5015.jpeg&amp;size=m" alt="重庆城市风光"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F991ffa26169015eebe3083db6bb4a0d6.jpg&amp;size=m" alt="俯瞰嘉陵江大桥"></p>
<h2 id="理小路阿坝的秋色">理小路：阿坝的秋色</h2>
<p>今年的秋天，我还去了阿坝。那里的秋色如诗如画，漫山遍野的金黄与绚丽，还有雪山与阳光。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_2730.JPG&amp;size=m" alt="理小路山色"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_2786.JPG&amp;size=m" alt="理小路雪山"></p>
<h2 id="热爱的现场音乐与斯诺克">热爱的现场：音乐与斯诺克</h2>
<p>这一年里，我还参与了几场令人难忘的现场活动，让热爱从屏幕跳脱到现实。</p>
<ul>
 <li>
  <p><strong>凤凰传奇演唱会</strong>：<br>
    这是我看过气氛最强的一场演唱会，从《最炫民族风》到《月亮之上》，每一首歌都将全场带入欢呼与共鸣的高潮。站在人群之中，跟着旋律合唱、呐喊，仿佛回到了最单纯的青春岁月，快乐得毫无保留。这种感觉，只有在现场才能真正体会。<br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F20250107-fhcq.jpeg&amp;size=m" alt="凤凰传奇演唱会现场-1"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F17a957fd8224f6891f4d8ef56323f0dd.jpg&amp;size=m" alt="凤凰传奇演唱会现场-2"></p></li>
 <li>
  <p><strong>武侯斯诺克明星邀请赛</strong>：<br>
    作为一名斯诺克球迷，这场邀请赛绝对是今年的另一大亮点。第一次如此近距离地看到喜欢的球员——<strong>丁俊晖、奥沙利文和特鲁姆普</strong>，他们的球技依然精湛，但作为邀请赛，比赛少了几分常规赛事中那种拼搏到底的紧张感，尤其是奥沙利文很快就结束了比赛，令人稍显遗憾。不过能够亲眼见到这些世界冠军，已经足够让我感到满足和兴奋。</p></li>
</ul>
<h2 id="生活的新篇章"><strong>生活的新篇章</strong></h2>
<p>这一年最重要的事件莫过于结婚了。和另一半一起走进婚姻的殿堂，在亲朋好友的见证下开启了人生的新阶段。这是一段忙碌、幸福和感动交织的旅程，达成了一个重要成就。</p>]]></description><guid isPermaLink="false">/archives/2024-life-and-love</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_2330.JPG&amp;size=m" type="image/jpeg" length="0"/><category>生活</category><pubDate>Tue, 7 Jan 2025 11:00:00 GMT</pubDate></item><item><title><![CDATA[2024 开源贡献与成长]]></title><link>https://guqing.io/archives/2024-opensource-yearly-report</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=2024%20%E5%BC%80%E6%BA%90%E8%B4%A1%E7%8C%AE%E4%B8%8E%E6%88%90%E9%95%BF&amp;url=/archives/2024-opensource-yearly-report" width="1" height="1" alt="" style="opacity:0;">
<p>2024 年，我在开源领域的足迹扎实且精彩。这一年，不仅用代码推动了项目的发展，也在技术提升和社区协作中深刻感受到了开源精神的魅力。我参与和推动了多个开源项目，同时还担任了开源之夏活动的导师，带领新人顺利完成了项目结项，收获了技术和合作的双重成长。</p>
<p>2024 年，我在开源领域留下了充满成就感的一年。这一年，除了参与多个开源社区的贡献，我也在 Halo 项目的发展中扮演了重要角色，为其引入了诸多新特性与改进，推动项目从 <strong>2.12 版本</strong>发布到 <strong>2.20 版本</strong>，让这个开源博客系统变得更加强大和灵活。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F2024-github-contributions-calendar.png&amp;size=m" alt="2024 github contributionis calendar heatmap"></p>
<h2 id="贡献总览">贡献总览</h2>
<ul>
 <li><strong>GitHub 贡献</strong>：全年累计 <strong>1919 次</strong>，活跃天数达到 <strong>252 天</strong>，平均每天改动 <strong>186 行代码</strong>，展现了持续的开发热情。</li>
 <li><strong>Pull Requests</strong>：提交 <strong>308 个 Pull Requests</strong>，涵盖功能开发、性能优化、bug 修复等内容。</li>
 <li><strong>PR Review</strong>：参与 <strong>586 次代码评审</strong>，与全球开发者共同推进代码质量提升。</li>
 <li><strong>全年编码时间</strong>：累计编码 <strong>956 小时</strong>，日均编码 <strong>3 小时 46 分钟</strong>，专注度排名前 3% 的开发者。</li>
 <li><strong>开源之夏导师</strong>：首次担任 <strong>开源之夏</strong>活动导师，带领新人完成项目结项。这不仅让我体验到传承的意义，也收获了指导新人开发的责任感与成就感。</li>
</ul>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fcode-stats-for-2024.png&amp;size=m" alt="guqing's wakatime code stats for 2024"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F2024-github-wrapped.png&amp;size=m" alt="guqing's github wrapped for 2024"></p>
<h2 id="halo-项目推动核心功能进化">Halo 项目：推动核心功能进化</h2>
<p>这一年，我在 <strong>Halo</strong> 项目中投入了大量精力，为其新增了众多关键特性，并持续优化系统性能，助力从 <strong>2.12</strong> 到 <strong>2.20 大版本</strong>的发布。这些特性极大地提升了系统的安全性、可用性与灵活性：</p>
<ol>
 <li>
  <p><strong>自定义模型索引机制</strong></p>
  <p>优化查询效率与内存占用，为大数据量场景提供更优的解决方案。<a href="https://github.com/halo-dev/halo/pull/5121">#5121</a></p></li>
 <li>
  <p><strong>登录安全改进</strong></p>
  <ul>
   <li>新增用户密码修改后，踢除所有已登录会话的功能，提升账户安全性。<a href="https://github.com/halo-dev/halo/pull/5757">#5757</a></li>
   <li>新增设备管理功能，可查看并移除已登录的设备，增强对账号的掌控力。<a href="https://github.com/halo-dev/halo/pull/6100">#6100</a></li>
   <li>增加保持登录会话的机制，优化用户登录体验。<a href="https://github.com/halo-dev/halo/pull/5929">#5929</a></li>
   <li>引入基于持久化 Token 的 RememberMe 机制，增强安全性与灵活性。<a href="https://github.com/halo-dev/halo/pull/6131">#6131</a></li>
  </ul></li>
 <li>
  <p><strong>内容管理优化</strong></p>
  <ul>
   <li>分类支持设置统一的渲染模板，提供更灵活的展示方式。<a href="https://github.com/halo-dev/halo/pull/6106">#6106</a></li>
   <li>新增独立分类选项，用于控制子分类下的文章显示，提升内容管理的灵活性。<a href="https://github.com/halo-dev/halo/pull/6083">#6083</a></li>
   <li>支持为分类设置隐藏选项，优化前端展示的精确度。<a href="https://github.com/halo-dev/halo/pull/6116">#6116</a></li>
  </ul></li>
 <li>
  <p><strong>附件与响应式图片支持</strong></p>
  <ul>
   <li>图片附件支持生成多尺寸图片，并支持文章内的响应式图片显示，提升性能与用户体验。<a href="https://github.com/halo-dev/halo/pull/6454">#6454</a></li>
   <li>本地存储策略新增单文件大小与类型限制功能，提升文件管理能力。<a href="https://github.com/halo-dev/halo/pull/6390">#6390</a></li>
  </ul></li>
</ol>
<p>这一系列新增功能和优化，让 Halo 的用户体验和安全性显著提升，也为开发者提供了更高效、易用的开发工具链。我还提交了大量修复和性能优化的 Pull Requests，这些改进在细节处让系统更加稳定和流畅。</p>
<h2 id="参与全球开源社区">参与全球开源社区</h2>
<p>除了 Halo 项目，我还积极参与多个知名开源项目，拓展了技术视野和社区协作经验：</p>
<ul>
 <li><strong><a href="https://github.com/spring-projects/spring-framework">spring-framework</a></strong>：提交 <strong>2 个 issue</strong>，其中一个问题已经被接受处于内部讨论中，另一个则还没有回复。</li>
 <li><strong><a href="https://github.com/r2dbc/r2dbc-h2">r2dbc-h2</a></strong>：提交 <strong>1 个 issue</strong>，虽然该项目活跃度不高，但我依然坚持为社区提供改进建议。</li>
 <li><strong><a href="https://github.com/iluwatar/java-design-patterns">java-design-patterns</a></strong>：贡献了 <strong>1 个 PR</strong>，完善 Async Method Invocation 设计模式代码，为开发者提供了更清晰的参考。</li>
 <li><strong><a href="https://github.com/pf4j/pf4j">pf4j</a></strong>：提交了一个 bug 反馈 issue，官方团队迅速修复问题，充分体现了开源社区的协作精神。</li>
</ul>]]></description><guid isPermaLink="false">/archives/2024-opensource-yearly-report</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F2024-github-contributions-calendar.png&amp;size=m" type="image/jpeg" length="0"/><category>开源贡献</category><pubDate>Tue, 7 Jan 2025 11:00:00 GMT</pubDate></item><item><title><![CDATA[Halo 附件缩略图功能的设计与实现]]></title><link>https://guqing.io/archives/halo-thumbnail-rfc</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=Halo%20%E9%99%84%E4%BB%B6%E7%BC%A9%E7%95%A5%E5%9B%BE%E5%8A%9F%E8%83%BD%E7%9A%84%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E7%8E%B0&amp;url=/archives/halo-thumbnail-rfc" width="1" height="1" alt="" style="opacity:0;">
<h2 id="背景">背景</h2>
<p>在当前的 Halo 系统中，附件管理功能虽然支持多种存储策略（如本地存储和通过插件扩展的 S3 协议存储），但缺乏缩略图生成功能。这导致在管理端上传大尺寸图片时，页面加载速度显著降低。</p>
<p>具体来说，在管理后台的附件管理界面中，由于图片文件往往较大，浏览器需要加载原始尺寸的图片，这会导致页面加载时间过长，影响管理效率。同样地，在主题端，图片加载缓慢也会显著影响用户体验，尤其是在网络状况不佳或访问量较大的情况下，这种问题会更加突出。</p>
<p>为了提升图片加载性能，缩略图功能显得尤为重要。缩略图不仅可以显著减少管理端加载图片的时间，还可以让主题端在展示图片时，根据不同设备和网络情况加载合适尺寸的图片，提升整体访问速度和用户体验。因此，引入一个灵活且可扩展的缩略图生成机制，支持不同存储策略下的缩略图生成与管理，成为当前 Halo 系统优化的关键。</p>
<h2 id="已有需求">已有需求</h2>
<p>用户和开发者对于附件管理的需求日益增加，尤其是在高效管理和展示图片资源方面。以下是系统中已经存在的一些需求，这些需求促使我们考虑引入缩略图功能：</p>
<ol>
 <li><strong>附件展示问题</strong>: 在管理后台中，图片仅有原图的访问地址，这导致图片列表加载时非常缓慢，影响页面流畅度 ​ (<a href="https://github.com/halo-dev/halo/issues/3167">halo-dev/halo#3167</a>)</li>
 <li><strong>渲染性能问题</strong>: 多个 issue 提到在附件库中，加载大量大图会导致页面卡顿，甚至出现浏览器崩溃的情况，这凸显了对缩略图的迫切需求 ​ (<a href="https://github.com/halo-dev/halo/issues/3830">halo-dev/halo#3830</a>)​ (<a href="https://github.com/halo-dev/halo/issues/2387">halo-dev/halo#2387</a>)</li>
 <li><strong>用户体验问题</strong>: 当用户上传过大的图片时，访问附件库和前端页面时加载速度缓慢，影响了整体用户体验 ​ (<a href="https://github.com/halo-dev/halo/issues/5196">halo-dev/halo#5196</a>)</li>
</ol>
<p>由于 Halo 中缺乏缩略图功能，导致在管理和展示图片时出现性能瓶颈。为了解决这个问题，Halo 被迫进行了一些功能上的调整：</p>
<ul>
 <li><strong>使用 CSS3 硬件加速</strong>：通过利用 CSS3 的硬件加速特性，优化了附件库中图片的渲染性能，试图减轻大尺寸图片带来的加载压力。(<a href="https://github.com/halo-dev/halo/pull/3831">halo-dev/halo#3831</a>)</li>
 <li><strong>调整默认排版模式</strong>：为了减少界面渲染的负担，附件管理界面的默认排版模式从缩略图视图调整为列表模式，以减少页面加载时间。(<a href="https://github.com/halo-dev/console/pull/827">halo-dev/console#827</a>)</li>
</ul>
<p>这些调整在一定程度上缓解了由于缺少缩略图功能而带来的在附件管理上的性能问题，但它们并不能根本上解决问题。因此，缩略图功能的引入仍然是满足当前系统需求的关键所在。</p>
<h2 id="目标">目标</h2>
<p>本 RFC 的主要目标是在 Halo 博客平台中引入一个灵活且可扩展的缩略图生成功能，以解决目前由于缺乏缩略图功能而导致的性能问题和用户体验下降。具体目标如下：</p>
<ol>
 <li>
  <p><strong>提升系统性能</strong>：</p>
  <ul>
   <li>通过生成和使用缩略图，减少管理端和主题端加载大尺寸图片时的网络带宽占用和页面渲染时间，从而显著提升系统的整体性能。</li>
  </ul></li>
 <li>
  <p><strong>优化用户体验</strong>：</p>
  <ul>
   <li>在主题端使用缩略图替代原始图片，以加快页面加载速度，提升用户浏览体验，减少因图片加载慢而导致的页面卡顿或延迟问题。</li>
  </ul></li>
 <li>
  <p><strong>支持多样化的存储策略</strong>：</p>
  <ul>
   <li>为不同的存储策略（如本地存储、S3 协议等）提供灵活的缩略图生成和调用机制。针对本地存储，系统将自动生成并存储缩略图；针对支持参数化缩略图生成的云存储，允许实现者根据配置动态生成缩略图 URL。</li>
  </ul></li>
 <li>
  <p><strong>提供可扩展的开发接口</strong>：</p>
  <ul>
   <li>设计并实现一个扩展点，使开发者能够根据具体的存储需求和策略自定义缩略图生成逻辑。通过这个扩展点，开发者可以轻松地集成自定义的缩略图生成插件，以满足不同的业务需求。</li>
  </ul></li>
 <li>
  <p><strong>提高管理效率</strong>：</p>
  <ul>
   <li>在管理后台，使用缩略图替代大尺寸图片，提升附件管理界面的加载和操作速度，使管理者能够更高效地浏览和处理附件资源。</li>
  </ul></li>
 <li>
  <p><strong>保持兼容性与灵活性</strong>：</p>
  <ul>
   <li>在引入缩略图功能的同时，确保与现有的附件管理和存储机制保持兼容，并为未来的功能扩展预留灵活性。</li>
  </ul></li>
</ol>
<p>通过实现这些目标，Halo 博客平台将能够更好地应对当前和未来的需求，不仅提升性能和用户体验，还为开发者提供更强大的扩展能力。</p>
<h2 id="非目标">非目标</h2>
<p>本 RFC 的目标是引入和实现缩略图生成功能，以提升系统性能和用户体验，但以下内容不在本 RFC 的范围之内：</p>
<ol>
 <li>
  <p><strong>不涉及图像内容的分析与处理</strong>：</p>
  <ul>
   <li>本功能不包含任何图像内容分析（如图像识别、分类）或处理（如滤镜应用、图像增强）的功能。缩略图功能仅限于生成和管理不同尺寸的图像缩略图。</li>
  </ul></li>
 <li>
  <p><strong>不改变现有的存储策略实现</strong>：</p>
  <ul>
   <li>本 RFC 并不涉及对现有存储策略（如本地存储、S3 协议扩展）的底层逻辑和实现方式进行修改。缩略图功能将以插件或扩展点的形式集成到现有系统中。</li>
  </ul></li>
 <li>
  <p><strong>不提供图片的格式转换</strong>：</p>
  <ul>
   <li>本功能不会提供图片格式的转换功能，所有缩略图将使用与原图相同的格式进行生成和存储，避免涉及到图片格式的兼容性问题。</li>
  </ul></li>
 <li>
  <p><strong>不影响现有的附件管理功能</strong>：</p>
  <ul>
   <li>引入缩略图功能不会影响现有的附件上传、下载、删除等基本功能。这些功能将保持不变，并与缩略图功能兼容。</li>
  </ul></li>
</ol>
<h2 id="方案">方案</h2>
<p>实现方案分为两大部分：<strong>缩略图生成</strong>和<strong>缩略图的使用</strong>。下面将分别介绍这两部分的设计和实现。</p>
<h3 id="缩略图生成">缩略图生成</h3>
<p>在缩略图生成方面，有以下几个关键问题需要解决：</p>
<ol>
 <li><strong>缩略图尺寸的设计</strong>：如何定义和选择合适的缩略图尺寸，以满足不同场景的需求。</li>
 <li><strong>缩略图生成策略</strong>：如何根据原始图片生成不同尺寸的缩略图，以保证图片质量和加载速度。</li>
 <li><strong>缩略图存储策略</strong>：如何将生成的缩略图存储在系统中，并与原始图片关联。</li>
 <li><strong>缩略图生成的性能优化</strong>：如何通过缓存和预生成等方式，提升缩略图生成的性能和效率。</li>
 <li><strong>缩略图生成的扩展性</strong>：如何设计和实现一个可扩展的缩略图生成机制，以支持不同的存储策略和需求。</li>
</ol>
<p>考虑到使用方式和性能优化等因素，我们选择使用固定尺寸的缩略图，而非由具体宽度动态生成。这种设计有以下优势：</p>
<ul>
 <li><strong>性能优化</strong>：使用预定义的固定尺寸缩略图，可以减少缩略图滥用的计算和生成压力，在满足图片加载速度的同时，可以减小缩略图的数量和对资源的消耗。</li>
 <li><strong>一致性和可控性</strong>：固定尺寸的缩略图可以保证图片展示的一致性和布局稳定性，过定义固定的缩略图尺寸，开发者可以更好地控制图片在不同场景中的展示效果。这样可以避免动态生成的图片尺寸不一致，导致页面布局问题或视觉不统一。</li>
 <li><strong>缓存利用</strong>：固定尺寸的缩略图更容易被浏览器和 CDN 缓存，有助于加快图片加载速度，并减少对服务器的重复请求。</li>
</ul>
<h4 id="缩略图尺寸设计">缩略图尺寸设计</h4>
<p>基于以上考虑，我们为系统预定义了以下几种常见的缩略图尺寸：</p>
<ul>
 <li><strong>Small (S)</strong>：宽度 400px</li>
 <li><strong>Medium (M)</strong>：宽度 800px</li>
 <li><strong>Large (L)</strong>：宽度 1200px</li>
 <li><strong>Extra Large (XL)</strong>：宽度 1600px</li>
</ul>
<p>定义枚举类型以便扩展点接口中使用：</p>
<pre><code class="hljs language-java"><span class="hljs-keyword">public</span> <span class="hljs-keyword">enum</span> <span class="hljs-title class_">ThumbnailSize</span> {
    S(<span class="hljs-number">400</span>),
    M(<span class="hljs-number">800</span>),
    L(<span class="hljs-number">1200</span>),
    XL(<span class="hljs-number">1600</span>);

    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> <span class="hljs-type">int</span> width;

    ThumbnailSize(<span class="hljs-type">int</span> width) {
        <span class="hljs-built_in">this</span>.width = width;
    }

    <span class="hljs-keyword">public</span> <span class="hljs-type">int</span> <span class="hljs-title function_">getWidth</span><span class="hljs-params">()</span> {
        <span class="hljs-keyword">return</span> width;
    }
}
</code></pre>
<h4 id="自定义模型设计">自定义模型设计</h4>
<p>为了将图片与缩略图关联同时避免重复生成，需要定义一个自定义模型类，用于存储图片的 URL 和缩略图的 URL 之间的映射关系：</p>
<pre><code class="hljs language-yaml"><span class="hljs-attr">apiVersion:</span> <span class="hljs-string">storage.halo.run/v1alpha1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">Thumbnail</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">thumbnail-1</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">imageSignature:</span> <span class="hljs-string">e99a18c428cb38d5f260853678922e03</span>
  <span class="hljs-attr">imageUri:</span> <span class="hljs-string">/path/to/original.jpg</span>
  <span class="hljs-attr">size:</span> <span class="hljs-string">L</span>
  <span class="hljs-attr">thumbnailUri:</span> <span class="hljs-string">/path/to/thumbnail-L.jpg</span>
</code></pre>
<p>基于 <code>imageUri</code> 生成的 MD5 签名，然后为 <code>imageSignature</code> 和 <code>size</code> 建立组合索引，一方面可以显著提高查询性能，同时也可以减少索引的大小。</p>
<pre><code class="hljs language-java">indexSpec.add(<span class="hljs-keyword">new</span> <span class="hljs-title class_">IndexSpec</span>()
   .setName(Thumbnail.ID_INDEX)
   .setIndexFunc(simpleAttribute(Thumbnail.class, Thumbnail::idIndexFunc))
);

<span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> String <span class="hljs-title function_">idIndexFunc</span><span class="hljs-params">(Thumbnail thumbnail)</span> {
   <span class="hljs-keyword">return</span> idIndexFunc(thumbnail.getSpec().getImageSignature(),
      thumbnail.getSpec().getSize().name());
}

<span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> String <span class="hljs-title function_">idIndexFunc</span><span class="hljs-params">(String imageHash, String size)</span> {
   <span class="hljs-keyword">return</span> imageHash + <span class="hljs-string">"-"</span> + size;
}
</code></pre>
<h4 id="扩展点接口设计">扩展点接口设计</h4>
<p>为了保证缩略图功能的灵活性，我们设计了一个扩展点接口，使开发者能够根据不同的存储策略自定义缩略图生成逻辑。<br>
  例如，OSS 可能提供了根据参数生成缩略图的 API，可以避免在 Halo 生成：</p>
<pre><code class="hljs language-java"><span class="hljs-keyword">public</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">ThumbnailProvider</span> <span class="hljs-keyword">extends</span> <span class="hljs-title class_">ExtensionPoint</span> {

   <span class="hljs-comment">/**
    * 根据给定的图片 URL 和尺寸生成缩略图 URL。
    * <span class="hljs-doctag">@param</span> context 缩略图上下文，包含图片 URL 和尺寸等信息
    * <span class="hljs-doctag">@return</span> 缩略图 URL
    */</span>
   Mono&lt;String&gt; <span class="hljs-title function_">generate</span><span class="hljs-params">(ThumbnailContext context)</span>;

   <span class="hljs-comment">/**
    * 根据给定的图片 URL 删除对应的缩略图文件
    * <span class="hljs-doctag">@param</span> imageUrl 原图片的 URL
    */</span>
   Mono&lt;Void&gt; <span class="hljs-title function_">delete</span><span class="hljs-params">(String imageUrl)</span>;

    <span class="hljs-comment">/**
     * 判断当前提供者是否支持给定的图片 URL。
     *
     * <span class="hljs-doctag">@param</span> imageUrl 图片的 URL
     * <span class="hljs-doctag">@return</span> 如果支持，返回 true；否则返回 false
     */</span>
   Mono&lt;Boolean&gt; <span class="hljs-title function_">supports</span><span class="hljs-params">(ThumbnailContext context)</span>;

   <span class="hljs-meta">@Data</span>
   <span class="hljs-meta">@Builder</span>
   <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ThumbnailContext</span> {
      <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> String imageUrl;
      <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> ThumbnailSize size;
   }
}
</code></pre>
<p>系统将提供一个默认的 <code>ThumbnailProvider</code> 实现，针对本地存储生成缩略图。对于其他存储策略（如 S3 协议的 OSS），开发者可以通过插件实现该接口，动态生成和返回缩略图 URL。<br>
  如果找不到合适的 <code>ThumbnailProvider</code> 实现，则不生成缩略图，避免因为用户本来就不需要缩略图而浪费资源。</p>
<p>缩略图会在首次使用时调用 <code>generate</code> 方法生成链接，然后将其记录到 <code>Thumbnail</code> 自定义模型中，而这个链接对应的缩略图是否已经生成取决于实现者，可能在 <code>generate</code> 方法调用时就已经生成，也可能是缩略图 URL 被访问时生成。</p>
<p>当附件被删除时，会调用 <code>delete</code> 方法删除对应的缩略图文件，以避免无用的缩略图占用存储空间。</p>
<h4 id="本地存储缩略图生成">本地存储缩略图生成</h4>
<p>对于本地存储，我们将提供一个默认的 <code>ThumbnailProvider</code> 实现，用于生成和存储缩略图。具体实现如下：</p>
<ol>
 <li>根据参数给定的图片 URL 和尺寸，生成缩略图 URL，但不生成缩略图文件。</li>
 <li>将生成的缩略图 URL 和原始图片 URL 以及存储路径等信息保存到自定义模型中。</li>
 <li>通过 Reconciler 监听资源变化，为其生成缩略图文件。</li>
 <li>当缩略图 URL 被访问时，根据缩略图 URL 查询关联的缩略图资源并在查询到时检查缩略图文件是否存在，如果不存在则生成缩略图文件，否则返回 404。</li>
</ol>
<h5 id="本地存储的自定义模型设计">本地存储的自定义模型设计</h5>
<p>自定义模型设计如下:</p>
<pre><code class="hljs language-yaml"><span class="hljs-attr">apiVersion:</span> <span class="hljs-string">storage.halo.run/v1alpha1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">LocalThumbnail</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">local-thumbnail-1</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">imageSignature:</span> <span class="hljs-string">e99a18c428cb38d5f260853678922e03</span>
  <span class="hljs-attr">imageUri:</span> <span class="hljs-string">/path/to/original.jpg</span>
  <span class="hljs-attr">thumbnailUri:</span> <span class="hljs-string">/path/to/thumbnail-L.jpg</span>
  <span class="hljs-attr">thumbSignature:</span> <span class="hljs-string">e99a18c428cb38d5f260853678922e03</span>
  <span class="hljs-attr">filePath:</span> <span class="hljs-string">/attachments/thumbnails/2024/w1200/2024-08-09.jpg</span>
  <span class="hljs-attr">size:</span> <span class="hljs-string">L</span>
</code></pre>
<p>索引设计如下:</p>
<pre><code class="hljs language-java">schemeManager.register(LocalThumbnail.class, indexSpec -&gt; {
   indexSpec.add(<span class="hljs-keyword">new</span> <span class="hljs-title class_">IndexSpec</span>()
         .setName(<span class="hljs-string">"spec.imageSignature"</span>)
         .setIndexFunc(simpleAttribute(LocalThumbnail.class,
            thumbnail -&gt; thumbnail.getSpec().getImageSignature())
         ));
   indexSpec.add(<span class="hljs-keyword">new</span> <span class="hljs-title class_">IndexSpec</span>()
         .setName(<span class="hljs-string">"spec.thumbSignature"</span>)
         .setUnique(<span class="hljs-literal">true</span>)
         .setIndexFunc(simpleAttribute(LocalThumbnail.class,
            thumbnail -&gt; thumbnail.getSpec().getThumbSignature())
         ));
});
</code></pre>
<p>定义 <code>LocalThumbnailEndpoint</code> 用于提供缩略图 URL 的访问，本地存储策略的图片缩略图规则为：</p>
<ul>
 <li>Endpoint: <code>/upload/thumbnails/{year}/w{size}/{image-name}</code></li>
 <li>存储路径: <code>attachments/thumbnails/{year}/w{size}/{image-name}</code></li>
</ul>
<p>示例：</p>
<ul>
 <li>Endpoint: <code>/upload/thumbnails/2024/w1200/2022-01-01.jpg</code></li>
 <li>存储路径: <code>attachments/thumbnails/2024/w1200/2022-01-01.jpg</code></li>
</ul>
<h5 id="缩略图生成库选择">缩略图生成库选择</h5>
<p>在库的选择上，<a href="https://github.com/coobird/thumbnailator">Thumbnailator</a> 和 <a href="https://github.com/rkalla/imgscalr">imgscalr</a> 是最常用的两个缩略图生成库，简单易用且高效。</p>
<p>我们选择使用 imgscalr 作为 Halo 的缩略图生成库，主要基于以下考虑：</p>
<ul>
 <li><strong>性能优化：</strong> imgscalr 对性能进行了较好的优化，尤其适合处理大量图像或要求较高图像处理速度的场景。</li>
 <li><strong>高质量的图像处理：</strong> imgscalr 提供了多种图像处理模式，可以在性能和图像质量之间找到平衡。</li>
</ul>
<p>示例代码：</p>
<pre><code class="hljs language-java"><span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> URL imageUrl;
<span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> ThumbnailSize size;
<span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> Path storePath;

<span class="hljs-keyword">public</span> Mono&lt;Void&gt; <span class="hljs-title function_">generate</span><span class="hljs-params">()</span> {
   <span class="hljs-keyword">return</span> Mono.fromRunnable(() -&gt; {
      <span class="hljs-type">String</span> <span class="hljs-variable">formatName</span> <span class="hljs-operator">=</span> getFormatName(imageUrl);
      <span class="hljs-keyword">try</span> {
            <span class="hljs-type">var</span> <span class="hljs-variable">img</span> <span class="hljs-operator">=</span> ImageIO.read(imageUrl);
            <span class="hljs-type">BufferedImage</span> <span class="hljs-variable">thumbnail</span> <span class="hljs-operator">=</span> Scalr.resize(img, size.getWidth());
            <span class="hljs-type">var</span> <span class="hljs-variable">thumbnailFile</span> <span class="hljs-operator">=</span> getThumbnailFile(formatName);
            ImageIO.write(thumbnail, formatName, thumbnailFile);
      } <span class="hljs-keyword">catch</span> (IOException e) {
            <span class="hljs-keyword">throw</span> Exceptions.propagate(e);
      }
   });
}

<span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> String <span class="hljs-title function_">getFormatName</span><span class="hljs-params">(URL input)</span> {
   <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">return</span> doGetFormatName(input);
   } <span class="hljs-keyword">catch</span> (IOException e) {
      <span class="hljs-comment">// 获取不到图片格式时，返回 jpg</span>
      <span class="hljs-keyword">return</span> <span class="hljs-string">"jpg"</span>;
   }
}

<span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> String <span class="hljs-title function_">doGetFormatName</span><span class="hljs-params">(URL input)</span> <span class="hljs-keyword">throws</span> IOException {
   <span class="hljs-keyword">try</span> (<span class="hljs-type">var</span> <span class="hljs-variable">inputStream</span> <span class="hljs-operator">=</span> input.openStream();
         <span class="hljs-type">ImageInputStream</span> <span class="hljs-variable">imageStream</span> <span class="hljs-operator">=</span> ImageIO.createImageInputStream(inputStream)) {
      Iterator&lt;ImageReader&gt; readers = ImageIO.getImageReaders(imageStream);
      <span class="hljs-keyword">if</span> (!readers.hasNext()) {
            <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">IOException</span>(<span class="hljs-string">"No ImageReader found for the image."</span>);
      }
      <span class="hljs-type">ImageReader</span> <span class="hljs-variable">reader</span> <span class="hljs-operator">=</span> readers.next();
      <span class="hljs-keyword">return</span> reader.getFormatName().toLowerCase();
   } <span class="hljs-keyword">catch</span> (IOException e) {
      <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">IIOException</span>(<span class="hljs-string">"Can't get input stream from URL!"</span>, e);
   }
}
</code></pre>
<h3 id="缩略图使用">缩略图使用</h3>
<h4 id="主题端">主题端</h4>
<p>在主题端展示图片时，开发者可以利用 <code>srcset</code> 来实现响应式图片加载。通过在 <code>srcset</code> 属性中定义多个尺寸的图片 URL，浏览器会根据设备的屏幕分辨率和窗口大小，自动选择最合适的图片加载。</p>
<p>选择使用 srcset 的原因包括：</p>
<ul>
 <li>
  <p>响应式设计：srcset 能够根据不同设备的分辨率和屏幕大小，自动选择最佳的图片尺寸。这种机制确保了在高分辨率设备上显示高质量图片的同时，也能够在低分辨率设备上节省带宽。</p></li>
 <li>
  <p>提升加载性能：通过为图片提供多个尺寸的缩略图，浏览器可以选择最适合当前视窗的图片进行加载，从而减少不必要的带宽使用，提升页面加载速度，改善用户体验。</p></li>
 <li>
  <p>简单集成：srcset 的使用非常直观，并且已经得到了主流浏览器的广泛支持。通过在图片标签中定义不同尺寸的图片 URL，开发者可以很容易地利用这个功能。</p></li>
</ul>
<p>开发者可以通过定义缩略图的不同尺寸，在 <code>srcset</code> 中实现自动选择最适合的图片。例如：</p>
<pre><code class="hljs language-html"><span class="hljs-tag">&lt;<span class="hljs-name">img</span>
  <span class="hljs-attr">th:src</span>=<span class="hljs-string">"${imageUri}"</span>
  <span class="hljs-attr">th:srcset</span>=<span class="hljs-string">"|
      ${thumbnail.gen(imageUri, 's')} 400w,
      ${thumbnail.gen(imageUri, 'm')} 800w,
      ${thumbnail.gen(imageUri, 'l')} 1200w,
      ${thumbnail.gen(imageUri, 'xl')} 1600w
   |"</span>
  <span class="hljs-attr">sizes</span>=<span class="hljs-string">"(max-width: 1600px) 100vw, 1600px"</span>
  <span class="hljs-attr">alt</span>=<span class="hljs-string">"Example Image"</span>
/&gt;</span>
</code></pre>
<p>通过调用 <code>thumbnail.gen(imageUri, size)</code> 方法，开发者可以简便地生成缩略图 URL，并将其应用在 <code>srcset</code> 中，实现图片的响应式加载。</p>
<h4 id="文章内容中的图片">文章内容中的图片</h4>
<p>可以考虑同时使用以下两种方式支持:</p>
<ol>
 <li>通过实现 <code>ReactivePostContentHandler</code> 扩展点，然后在 <code>handle</code> 方法中获取文章的 HTML 内容并解析 <code>img</code> 标签的 <code>src</code> 属性得到图片 URL，再根据图片 URL 生成缩略图 URL<br>
   设置到 <code>srcset</code> 属性中，设置 <code>srcset</code> 属性前需要先判断是否已经存在 <code>srcset</code> 属性，如果存在则不再设置，避免覆盖开发者自定义的 <code>srcset</code> 属性。</li>
 <li>富文本编辑器中插入图片时，自动为图片设置 <code>srcset</code> 属性，以便在文章发布时自动加载缩略图。</li>
</ol>
<h4 id="管理端">管理端</h4>
<p>在管理端，通过获取 Attachment 的 status 中保存的 thumbnails 使用。该值是由 AttachmentReconciler 监听附件的变化，当 permalink 被生成后，生成缩略图 URL 并填充到 status 中。</p>
<pre><code class="hljs language-java"><span class="hljs-comment">// when permalink is generated</span>
<span class="hljs-keyword">if</span> (AttachmentUtils.isImage(attachment)) {
   populateThumbnails(permalink, status);
}

<span class="hljs-keyword">void</span> <span class="hljs-title function_">populateThumbnails</span><span class="hljs-params">(String permalink, AttachmentStatus status)</span> {
   <span class="hljs-type">var</span> <span class="hljs-variable">imageUri</span> <span class="hljs-operator">=</span> URI.create(permalink);
   Flux.fromArray(ThumbnailSize.values())
      .flatMap(size -&gt; thumbnailService.generate(imageUri, size)
            .map(thumbUri -&gt; Map.entry(size.name(), thumbUri.toString()))
      )
      .collectMap(Map.Entry::getKey, Map.Entry::getValue)
      .doOnNext(status::setThumbnails)
      .block();
}
</code></pre>
<h3 id="总结">总结</h3>
<p>通过以上详细的实现方案设计，Halo 的缩略图功能将具备高度的灵活性和可扩展性，能够满足不同存储策略下的需求，并显著提升系统的性能和用户体验。<br>
  开发者在实现和使用该功能时，将能够享受更简洁的代码风格和更优雅的集成方式。</p>
<p>功能实现见 PR <a href="https://github.com/halo-dev/halo/pull/6454">#6454</a></p>]]></description><guid isPermaLink="false">/archives/halo-thumbnail-rfc</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_5666.JPG&amp;size=m" type="image/jpeg" length="0"/><category>开源贡献</category><category>Java后端</category><pubDate>Wed, 7 Aug 2024 09:12:49 GMT</pubDate></item><item><title><![CDATA[离乡路远，归途已断]]></title><link>https://guqing.io/archives/longing-for-home</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E7%A6%BB%E4%B9%A1%E8%B7%AF%E8%BF%9C%EF%BC%8C%E5%BD%92%E9%80%94%E5%B7%B2%E6%96%AD&amp;url=/archives/longing-for-home" width="1" height="1" alt="" style="opacity:0;">
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_2311.jpg&amp;size=m" alt="IMG_2311.jpg"><br>
  每次踏上回乡的路，我心中总有一种难以言喻的情感。<strong>故乡，那片孕育我成长的土地，依然静静地躺在那儿，似乎未曾改变。</strong> 然而，每次回到家乡，我都能感受到微妙的变化：曾经喧闹的街道变得冷清，熟悉的邻里变得陌生。家乡没有恶待我，然而我却一次次选择离开，追求更广阔的天地。</p>
<p>从四年级开始，我便离开家乡住校。最初的离别总是伴随着对回家的盼望。那时每次回家，都像是久别重逢的节日，充满了喜悦与期待。然而，十多年过去了，在一次次的回家与离开的过程中，我渐渐明白，故乡已不再是记忆中的样子。每当我回到家乡，看见爷爷奶奶渐渐苍老的身影，我心中不禁涌起一种莫名的酸楚。这些变化无不在提醒我，时间在无情地流逝，而归期却越来越难以觅得。</p>
<p>离家十多年，我踏上征途，是为了生存，也是为了更好地回到故乡。然而，随着时间的推移，我愈发觉得故乡已是回不去了。每年回家，对家里的一切都越来越陌生，找不到东西的存放位置。家乡的变化和发展，让儿时玩耍的地方早已面目全非。家乡的山有的被推平了，有的树木和植被愈发茂密，儿时的小路也已消失无踪。那些捉过鱼的小溪干涸了，爬过的果树也已年老枯死，放过牛的草地也被改成了河道或农田，记忆中的美好景象都已不复存在。</p>
<p>鲁迅在《故乡》中，也描述了类似的感受。他回到故乡时，看到儿时的好友闰土已变成一个满脸沧桑的中年人。记忆中的闰土，那个快乐自由的少年，如今已被生活的重担压弯了腰。鲁迅笔下的故乡，在他离开多年后，已经物是人非。闰土的变化让他深感时间的无情和故乡的沉寂。这种割裂的感受让我深刻体会到，追梦的路上，不仅有对未来的向往，还有对故乡的怀念和无奈的告别。</p>
<p>每当我踏入都市的喧嚣，面对充满挑战与机遇的生活，我都能感受到自己的成长。而回到家乡，那种静止的感觉让我无法忽视。尽管内心对故乡充满眷恋，但现实的无奈让我不得不一次次地离开。</p>
<p>时间是无情的，对每个人都是公平的。每次回家，我都能清晰地看到父母额头上增添的皱纹，发间多了几缕白发。他们的衰老提醒着我，时间在悄无声息地流逝。同学聚会时，我们谈论着各自的生活，曾经的玩伴如今已走上不同的道路，这种变化无时无刻不在提醒我时间的无情。</p>
<p>在这无情的时间洪流中，亲情却显得格外温暖。父母每天忙碌的身影，对我的关心和牵挂，让我感受到无比的温暖。他们日常生活中的爱与关怀，是我在外打拼的最大动力。时间无情，但亲情的温暖总能给我力量，让我在追求梦想的路上不再孤单。</p>
<p>虽然家乡有着无数的美好，但我深知，那并不是我的归宿。我不愿再种一辈子地，我渴望追求更多的精神自由和个人梦想。每一次的离开，都是一次自我的突破和成长。</p>
<p>通过鲁迅的《故乡》，我逐渐明白，个人的追求和梦想是多么重要。家乡的美好无法满足我的精神需求，只有不断追求，才能找到真正属于自己的生活方式。正如鲁迅所写：<strong>“在我的心里早已有了一个新的‘故乡’，它在那里，它在每一个人的心里。”</strong></p>
<p>我承认，自己对衰老充满恐惧，但并不畏惧死亡。衰老带来的不仅是身体的变化，还有精神上的煎熬。我希望在有限的时间里，尽可能延长快乐的时光，过上自己满意的生活。</p>
<p>《故乡》中，少年闰土的影像和如今的变化让我意识到时间的无情，但也让我更加珍惜现在的时光。<strong>对我来说，美好生活不仅仅是物质上的满足，更是精神上的富足。我希望能够在有限的时间里，尽可能地追求自己的梦想，过上充实而有意义的生活。</strong></p>
<p><strong>人的一生，还需要求得些什么呢？</strong><br>
  在追求梦想的过程中，我逐渐领悟到，最珍贵的往往是那些最简单的幸福。每当繁忙的都市生活让我感到疲惫不堪时，我总是会想起故乡的宁静和温暖。</p>
<p>我希望父母健康，自己无病无灾，能够平安度日。这种简单的生活愿望，蕴含着对平凡而持久幸福的渴望。同时，我也希望能够多看看世界，多了解人生，追求爱与自由。生活不需要太复杂，只要能在有限的时间里体验到美好，就已经足够了。</p>
<p>在追求梦想的路上，我希望能够找到平衡点，让自己和家人都能过上幸福的生活。正如鲁迅在《故乡》中所言：“我想，这就是我永远的‘故乡’了。”那份对故乡的眷恋与对美好事物的追求，都是我心中最真实的情感，让生活变得更加丰富和有意义。</p>]]></description><guid isPermaLink="false">/archives/longing-for-home</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_2311.jpg&amp;size=m" type="image/jpeg" length="0"/><category>生活</category><pubDate>Thu, 18 Jul 2024 10:11:03 GMT</pubDate></item><item><title><![CDATA[Halo 2.17 为什么重构 RememberMe 机制的实现方式]]></title><link>https://guqing.io/archives/persistent-token-based-remember-me</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=Halo%202.17%20%E4%B8%BA%E4%BB%80%E4%B9%88%E9%87%8D%E6%9E%84%20RememberMe%20%E6%9C%BA%E5%88%B6%E7%9A%84%E5%AE%9E%E7%8E%B0%E6%96%B9%E5%BC%8F&amp;url=/archives/persistent-token-based-remember-me" width="1" height="1" alt="" style="opacity:0;">
<h2 id="前言">前言</h2>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FSCR-20241205-qarm.png&amp;size=m" alt="SCR-20241205-qarm.png"></p>
<p>Halo 是一个强大易用的开源建站工具，配合上丰富的主题和插件，帮助用户快速搭建个人博客、企业站点、知识库、文档站等多种类型的网站。具备可插拔架构、主题套用、富文本编辑器等多重特性，支持用户根据自己的喜好选择不同类型的插件及主题模板来定制化自己的站点功能及外观。让内容创作和发布更加便捷生动。</p>
<p>截至今日，Halo 已经在 GitHub 上拥有 32.5 k+ Star，Docker Hub 获得了超过 220 万次下载，并拥有一百多名社区贡献者。</p>
<h2 id="背景介绍">背景介绍</h2>
<p>在 Halo 2.16 版本中，我为 Halo <a href="https://github.com/halo-dev/halo/pull/6131">添加了一个记住我（Remember Me）的功能</a>，用户可以选择在登录时勾选 "Remember Me"，这样下次访问时就不需要重新登录。</p>
<p>该功能是通过以下方式生成 Token，并将其作为 cookie 发送回客户端：</p>
<pre><code class="hljs language-text"> username + ":" + expiryTime + ":" + algorithmName + ":"
   + algorithmHex(username + ":" + expiryTime + ":" + password + ":" + key)
</code></pre>
<p>这样实现的优点在于无需在后端存储 Token 就可以进行验证，并且用户密码的更改会自动使 Token 失效而无需做任何额外的处理。然而，它的主要缺点是缺乏管理能力，例如无法手动撤销 Token。</p>
<p>在 2.17 之前由于没有需要手动撤销 Token 的需求，因此这个实现是足够的。但是在 2.17 中，我引入了设备管理功能，基于原有的 RememberMe Token 机制，撤销设备的登录状态时无法撤销勾选了 RememberMe 的设备登录状态，因为会自动登录，因此需要一个可管理的 RememberMe Token 机制。</p>
<p>基于这样的原因，决定采用了持久化 Token 的方式，并通过随机生成 TokenValue 的方法来提高安全性，而不将用户名和密码直接签名在 Token 中。实现机制采用 Barry Jaspan 提出的<a href="http://jaspan.com/improved_persistent_login_cookie_best_practice">改进的持久登录 Cookie 最佳实践</a>。</p>
<h2 id="基于签名-token-的实现">基于签名 Token 的实现</h2>
<p>在原有的 Token 机制中，用户勾选 "Remember Me" 后，系统会生成一个包含用户名和 Token 的 cookie。服务器端不需要存储 Token，只需在用户访问时验证 Token 的有效性即可。系统会解密 Token 并验证用户名和密码是否匹配，若匹配则设置登录状态。</p>
<h3 id="实现方式和原理">实现方式和原理</h3>
<p>关键代码如下：</p>
<p><code>autoLogin</code> 方法会在用户访问且没有登录的状态下执行一次，用于尝试自动登录。</p>
<ol>
 <li>首先从 <code>ServerWebExchange</code> 中获取名为 <code>remember-me</code> 的 Cookie 的值，这件事由 <code>RememberMeCookieResolver</code> 完成。</li>
 <li>如果没有获取到 Cookie，则返回一个空的 <code>Mono</code>，表示没有自动登录。</li>
 <li>如果获取到了 Cookie，则解码 Cookie 的值，获取到 <code>cookieTokens</code>，然后调用 <code>processAutoLoginCookie</code> 方法进行处理。<code>cookieTokens</code> 由以下部分组成，使用 <code>:</code> 分隔： 
  <ul>
   <li>用户名</li>
   <li>过期时间</li>
   <li>算法名称</li>
   <li>签名 algorithmHex(username + ":" + expiryTime + ":" + password + ":" + key)</li>
  </ul></li>
</ol>
<pre><code class="hljs language-java"><span class="hljs-keyword">public</span> Mono&lt;Authentication&gt; <span class="hljs-title function_">autoLogin</span><span class="hljs-params">(ServerWebExchange exchange)</span> {
    <span class="hljs-type">var</span> <span class="hljs-variable">rememberMeCookie</span> <span class="hljs-operator">=</span> rememberMeCookieResolver.resolveRememberMeCookie(exchange);
    <span class="hljs-keyword">if</span> (rememberMeCookie == <span class="hljs-literal">null</span>) {
        <span class="hljs-keyword">return</span> Mono.empty();
    }
    log.debug(<span class="hljs-string">"Remember-me cookie detected"</span>);
    <span class="hljs-keyword">return</span> Mono.defer(
            () -&gt; {
                String[] cookieTokens = decodeCookie(rememberMeCookie.getValue());
                <span class="hljs-keyword">return</span> processAutoLoginCookie(cookieTokens, exchange);
            })
        .flatMap(user -&gt; {
            <span class="hljs-built_in">this</span>.userDetailsChecker.check(user);
            log.debug(<span class="hljs-string">"Remember-me cookie accepted"</span>);
            <span class="hljs-keyword">return</span> createSuccessfulAuthentication(exchange, user);
        })
        .onErrorResume(ex -&gt; handleError(exchange, ex));
}

<span class="hljs-keyword">protected</span> String[] decodeCookie(String cookieValue) <span class="hljs-keyword">throws</span> InvalidCookieException {
    <span class="hljs-type">int</span> <span class="hljs-variable">paddingCount</span> <span class="hljs-operator">=</span> <span class="hljs-number">4</span> - (cookieValue.length() % <span class="hljs-number">4</span>);
    <span class="hljs-keyword">if</span> (paddingCount &lt; <span class="hljs-number">4</span>) {
        <span class="hljs-type">char</span>[] padding = <span class="hljs-keyword">new</span> <span class="hljs-title class_">char</span>[paddingCount];
        Arrays.fill(padding, <span class="hljs-string">'='</span>);
        cookieValue += <span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>(padding);
    }
    String cookieAsPlainText;
    <span class="hljs-keyword">try</span> {
        cookieAsPlainText = <span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>(Base64.getDecoder().decode(cookieValue.getBytes()));
    } <span class="hljs-keyword">catch</span> (IllegalArgumentException ex) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">InvalidCookieException</span>(
            <span class="hljs-string">"Cookie token was not Base64 encoded; value was '"</span> + cookieValue + <span class="hljs-string">"'"</span>);
    }
    String[] tokens = StringUtils.delimitedListToStringArray(cookieAsPlainText, DELIMITER);
    <span class="hljs-keyword">for</span> (<span class="hljs-type">int</span> <span class="hljs-variable">i</span> <span class="hljs-operator">=</span> <span class="hljs-number">0</span>; i &lt; tokens.length; i++) {
        tokens[i] = URLDecoder.decode(tokens[i], StandardCharsets.UTF_8);
    }
    <span class="hljs-keyword">return</span> tokens;
}
</code></pre>
<blockquote>
 <p>需要注意的是 <code>Cookie</code> 的值是经过 <code>Base64</code> 编码的，但是 Cookie 值不允许出现 <code>=</code>，因此在编码时会移除 <code>=</code>，而解码时需要补全 <code>=</code>，补全机制根据 Base64 编码后的长度必须是 4 的倍数不足的部分用 <code>=</code> 补全的特点作为依据。</p>
</blockquote>
<p>得到 <code>cookieTokens</code> 后调用 <code>processAutoLoginCookie</code> 方法进行自动登录的校验和处理逻辑。</p>
<ol>
 <li>首先校验 <code>cookieTokens</code> 的长度是否为 4，不是则抛出异常。</li>
 <li>校验 <code>cookieTokens</code> 的第二个元素的过期时间是否在当前时间之后，不是则抛出异常。</li>
 <li>获取 <code>cookieTokens</code> 的第一个元素作为用户名，根据用户名从数据库中获取用户信息, 如果用户不存在则抛出异常。</li>
 <li>从 <code>cookieTokens</code> 中获取算法名称和签名，根据用户名、过期时间、密码和密钥计算签名，如果生成的签名与 <code>cookieTokens</code> 中的签名不一致则抛出异常。</li>
 <li>最终返回 <code>UserDetails</code> 由 <code>createSuccessfulAuthentication</code> 方法创建 <code>Authentication</code>。</li>
</ol>
<pre><code class="hljs language-java"><span class="hljs-keyword">protected</span> Mono&lt;UserDetails&gt; <span class="hljs-title function_">processAutoLoginCookie</span><span class="hljs-params">(String[] cookieTokens,
    ServerWebExchange exchange)</span> {
    <span class="hljs-keyword">if</span> (!isValidCookieTokensLength(cookieTokens)) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">InvalidCookieException</span>(
            <span class="hljs-string">"Cookie token did not contain 3 or 4 tokens, but contained '"</span> + Arrays.asList(
                cookieTokens) + <span class="hljs-string">"'"</span>);
    }

    <span class="hljs-type">long</span> <span class="hljs-variable">tokenExpiryTime</span> <span class="hljs-operator">=</span> getTokenExpiryTime(cookieTokens);
    <span class="hljs-keyword">if</span> (isTokenExpired(tokenExpiryTime)) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">InvalidCookieException</span>(
            <span class="hljs-string">"Cookie token[1] has expired (expired on '"</span> + <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(tokenExpiryTime)
                + <span class="hljs-string">"'; current time is '"</span> + <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>() + <span class="hljs-string">"')"</span>);
    }

    <span class="hljs-comment">// Check the user exists. Defer lookup until after expiry time checked, to</span>
    <span class="hljs-comment">// possibly avoid expensive database call.</span>
    <span class="hljs-keyword">return</span> getUserDetailsService().findByUsername(cookieTokens[<span class="hljs-number">0</span>])
        .switchIfEmpty(Mono.error(<span class="hljs-keyword">new</span> <span class="hljs-title class_">UsernameNotFoundException</span>(<span class="hljs-string">"User '"</span> + cookieTokens[<span class="hljs-number">0</span>]
            + <span class="hljs-string">"' not found"</span>)))
        .flatMap(userDetails -&gt; {
            <span class="hljs-comment">// Check signature of token matches remaining details. Must do this after user</span>
            <span class="hljs-comment">// lookup, as we need the DAO-derived password. If efficiency was a major issue,</span>
            <span class="hljs-comment">// just add in a UserCache implementation, but recall that this method is usually</span>
            <span class="hljs-comment">// only called once per HttpSession - if the token is valid, it will cause</span>
            <span class="hljs-comment">// SecurityContextHolder population, whilst if invalid, will cause the cookie to</span>
            <span class="hljs-comment">// be cancelled.</span>
            String actualTokenSignature;
            <span class="hljs-type">String</span> <span class="hljs-variable">actualAlgorithm</span> <span class="hljs-operator">=</span> DEFAULT_ALGORITHM;
            <span class="hljs-comment">// If the cookie value contains the algorithm, we use that algorithm to check the</span>
            <span class="hljs-comment">// signature</span>
            <span class="hljs-keyword">if</span> (cookieTokens.length == <span class="hljs-number">4</span>) {
                actualTokenSignature = cookieTokens[<span class="hljs-number">3</span>];
                actualAlgorithm = cookieTokens[<span class="hljs-number">2</span>];
            } <span class="hljs-keyword">else</span> {
                actualTokenSignature = cookieTokens[<span class="hljs-number">2</span>];
            }
            <span class="hljs-keyword">return</span> makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
                userDetails.getPassword(), actualAlgorithm)
                .doOnNext(expectedTokenSignature -&gt; {
                    <span class="hljs-keyword">if</span> (!equals(expectedTokenSignature, actualTokenSignature)) {
                        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">InvalidCookieException</span>(
                            <span class="hljs-string">"Cookie contained signature '"</span> + actualTokenSignature
                                + <span class="hljs-string">"' but expected '"</span>
                                + expectedTokenSignature + <span class="hljs-string">"'"</span>);
                    }
                })
                .thenReturn(userDetails);
        });
}

<span class="hljs-keyword">protected</span> Mono&lt;String&gt; <span class="hljs-title function_">makeTokenSignature</span><span class="hljs-params">(<span class="hljs-type">long</span> tokenExpiryTime, String username, String password, String algorithm)</span> {
    <span class="hljs-keyword">return</span> getKey()
        .handle((key, sink) -&gt; {
            <span class="hljs-type">String</span> <span class="hljs-variable">data</span> <span class="hljs-operator">=</span> username + <span class="hljs-string">":"</span> + tokenExpiryTime + <span class="hljs-string">":"</span> + password + <span class="hljs-string">":"</span> + key;
            <span class="hljs-keyword">try</span> {
                <span class="hljs-type">MessageDigest</span> <span class="hljs-variable">digest</span> <span class="hljs-operator">=</span> MessageDigest.getInstance(algorithm);
                sink.next(<span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>(Hex.encode(digest.digest(data.getBytes()))));
            } <span class="hljs-keyword">catch</span> (NoSuchAlgorithmException ex) {
                sink.error(
                    <span class="hljs-keyword">new</span> <span class="hljs-title class_">IllegalStateException</span>(<span class="hljs-string">"No "</span> + algorithm + <span class="hljs-string">" algorithm available!"</span>));
            }
        });
}

<span class="hljs-keyword">private</span> <span class="hljs-type">long</span> <span class="hljs-title function_">getTokenExpiryTime</span><span class="hljs-params">(String[] cookieTokens)</span> {
    <span class="hljs-keyword">try</span> {
        <span class="hljs-keyword">return</span> Long.parseLong(cookieTokens[<span class="hljs-number">1</span>]);
    } <span class="hljs-keyword">catch</span> (NumberFormatException nfe) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">InvalidCookieException</span>(
            <span class="hljs-string">"Cookie token[1] did not contain a valid number (contained '"</span> + cookieTokens[<span class="hljs-number">1</span>]
                + <span class="hljs-string">"')"</span>);
    }
}

<span class="hljs-keyword">protected</span> <span class="hljs-type">long</span> <span class="hljs-title function_">calculateExpireTime</span><span class="hljs-params">(ServerWebExchange exchange,
  Authentication authentication)</span> {
  <span class="hljs-type">var</span> <span class="hljs-variable">tokenLifetime</span> <span class="hljs-operator">=</span> rememberMeCookieResolver.getCookieMaxAge().toSeconds();
  <span class="hljs-keyword">return</span> Instant.now().plusSeconds(tokenLifetime).toEpochMilli();
}
</code></pre>
<p>详情请查看 <a href="https://github.com/halo-dev/halo/pull/6131">GitHub Halo PR #6131</a></p>
<p><strong>问题</strong>：</p>
<ol>
 <li><strong>难以管理</strong>：Token 只能通过过期失效，无法手动让其失效，导致难以实现精细化的设备管理。</li>
 <li><strong>安全隐患</strong>：一旦 Token 被盗，攻击者可以持续访问直到 Token 过期，而用户可能未察觉到账户被盗用。</li>
</ol>
<h2 id="新的持久化-token-机制">新的持久化 Token 机制</h2>
<p>Barry Jaspan 提出的持久化 Token 机制改进了上述不足，其核心思想是通过 "系列标识符" 和 "Token" 的组合来实现更安全和可管理的持久化登录机制。</p>
<h3 id="实现方式和原理-1">实现方式和原理</h3>
<p><strong>登录时生成系列标识符和 Token</strong>：</p>
<ul>
 <li>用户成功登录并勾选 "Remember Me" 后，系统会生成一个包含用户名、系列标识符（series）和 Token 的 cookie。</li>
 <li>系列标识符和 Token 都是随机生成且难以猜测的字符串，服务器会将这三个元素存储在数据库中。</li>
 <li></li>
</ul>
<p>以下是持久化 Token 的自定义模型设计：</p>
<pre><code class="hljs language-yaml"><span class="hljs-attr">apiVersion:</span> <span class="hljs-string">security.halo.run/v1alpha1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">RememberMeToken</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">token-TrpNE</span>
  <span class="hljs-attr">creationTimestamp:</span> <span class="hljs-number">2024-07-08T10:00:41.796765067Z</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">username:</span> <span class="hljs-string">guqing</span>
  <span class="hljs-attr">series:</span> <span class="hljs-string">12dFjDr4Ugspgk0oMugxap==</span>
  <span class="hljs-attr">tokenValue:</span> <span class="hljs-string">tF269aPzYCnQmKL/rX5uJo==</span>
  <span class="hljs-attr">lastUsed:</span> <span class="hljs-number">2024-07-08T10:01:45.520Z</span>
</code></pre>
<p><code>series</code> 作为唯一标识符，用于查询 <code>tokenValue</code>。</p>
<p><strong>验证过程</strong>：</p>
<ul>
 <li>当用户携带该 cookie 访问时，系统会比对用户名、<code>series</code> 和 <code>tokenValue</code>。</li>
 <li>若匹配，用户通过验证，旧 Token 被移除并生成一个新的 Token，更新数据库和 cookie。</li>
 <li>若系列标识符(series)匹配但 Token 不匹配，系统假定发生了盗用行为，用户所有的持久化登录会被注销，并提示安全警告。</li>
 <li>若用户名和系列标识符都不匹配，忽略该 cookie。</li>
</ul>
<p><strong>Token 的生成和验证</strong>：<br>
  基于持久化 Token 的机制，Cookie 的值由 <code>series</code> 和 <code>tokenValue</code> 组成，并使用 <code>:</code> 分隔后进行 <code>Base64</code> 编码。</p>
<pre><code class="hljs language-java">setCookie(<span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>[] {token.getSeries(), token.getTokenValue()}, exchange);

<span class="hljs-keyword">void</span> <span class="hljs-title function_">setCookie</span><span class="hljs-params">(String[] cookieTokens, ServerWebExchange exchange)</span> {
  <span class="hljs-type">String</span> <span class="hljs-variable">cookieValue</span> <span class="hljs-operator">=</span> encodeCookie(cookieTokens);
  rememberMeCookieResolver.setRememberMeCookie(exchange, cookieValue);
}
</code></pre>
<p>因此，在验证时需要解码 Cookie 的值，获取 <code>series</code> 和 <code>tokenValue</code>，然后根据 <code>series</code> 从数据库中获取 <code>tokenValue</code> 进行比对。</p>
<pre><code class="hljs language-java"><span class="hljs-keyword">protected</span> Mono&lt;UserDetails&gt; <span class="hljs-title function_">processAutoLoginCookie</span><span class="hljs-params">(String[] cookieTokens,
    ServerWebExchange exchange)</span> {
    <span class="hljs-keyword">if</span> (cookieTokens.length != <span class="hljs-number">2</span>) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">InvalidCookieException</span>(
            <span class="hljs-string">"Cookie token did not contain "</span> + <span class="hljs-number">2</span> + <span class="hljs-string">" tokens, but contained '"</span>
                + Arrays.asList(cookieTokens) + <span class="hljs-string">"'"</span>);
    }
    <span class="hljs-type">String</span> <span class="hljs-variable">presentedSeries</span> <span class="hljs-operator">=</span> cookieTokens[<span class="hljs-number">0</span>];
    <span class="hljs-type">String</span> <span class="hljs-variable">presentedToken</span> <span class="hljs-operator">=</span> cookieTokens[<span class="hljs-number">1</span>];
    <span class="hljs-keyword">return</span> <span class="hljs-built_in">this</span>.tokenRepository.getTokenForSeries(presentedSeries)
        <span class="hljs-comment">// No series match, so we can't authenticate using this cookie</span>
        .switchIfEmpty(Mono.error(<span class="hljs-keyword">new</span> <span class="hljs-title class_">RememberMeAuthenticationException</span>(
            <span class="hljs-string">"No persistent token found for series id: "</span> + presentedSeries))
        )
        .flatMap(token -&gt; {
            <span class="hljs-comment">// We have a match for this user/series combination</span>
            <span class="hljs-keyword">if</span> (!presentedToken.equals(token.getTokenValue())) {
                <span class="hljs-comment">// Token doesn't match series value. Delete all logins for this user and throw</span>
                <span class="hljs-comment">// an exception to warn them.</span>
                <span class="hljs-keyword">return</span> <span class="hljs-built_in">this</span>.tokenRepository.removeUserTokens(token.getUsername())
                    .then(Mono.error(<span class="hljs-keyword">new</span> <span class="hljs-title class_">CookieTheftException</span>(
                        <span class="hljs-string">"Invalid remember-me token (Series/token) mismatch. Implies previous "</span>
                            + <span class="hljs-string">"cookie theft"</span>
                            + <span class="hljs-string">" attack."</span>)));
            }

            <span class="hljs-keyword">if</span> (isTokenExpired(token)) {
                <span class="hljs-keyword">return</span> Mono.error(
                    <span class="hljs-keyword">new</span> <span class="hljs-title class_">RememberMeAuthenticationException</span>(<span class="hljs-string">"Remember-me login has expired"</span>));
            }

            <span class="hljs-comment">// Token also matches, so login is valid. Update the token value, keeping the</span>
            <span class="hljs-comment">// *same* series number.</span>
            log.debug(<span class="hljs-string">"Refreshing persistent login token for user '{}', series '{}'"</span>,
                token.getUsername(), token.getSeries());
            <span class="hljs-type">var</span> <span class="hljs-variable">newToken</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">PersistentRememberMeToken</span>(token.getUsername(), token.getSeries(),
                generateTokenData(), <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>());
            <span class="hljs-keyword">return</span> Mono.just(newToken);
        })
        .flatMap(newToken -&gt; updateToken(newToken)
            .doOnSuccess(unused -&gt; addCookie(newToken, exchange))
            .onErrorMap(ex -&gt; {
                log.error(<span class="hljs-string">"Failed to update token: "</span>, ex);
                <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">RememberMeAuthenticationException</span>(
                    <span class="hljs-string">"Autologin failed due to data access problem"</span>);
            })
            .then(getUserDetailsService().findByUsername(newToken.getUsername()))
        );
}

<span class="hljs-keyword">protected</span> String <span class="hljs-title function_">generateSeriesData</span><span class="hljs-params">()</span> {
    <span class="hljs-type">byte</span>[] newSeries = <span class="hljs-keyword">new</span> <span class="hljs-title class_">byte</span>[<span class="hljs-built_in">this</span>.seriesLength];
    <span class="hljs-built_in">this</span>.random.nextBytes(newSeries);
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>(Base64.getEncoder().encode(newSeries));
}

<span class="hljs-keyword">protected</span> String <span class="hljs-title function_">generateTokenData</span><span class="hljs-params">()</span> {
    <span class="hljs-type">byte</span>[] newToken = <span class="hljs-keyword">new</span> <span class="hljs-title class_">byte</span>[<span class="hljs-built_in">this</span>.tokenLength];
    <span class="hljs-built_in">this</span>.random.nextBytes(newToken);
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>(Base64.getEncoder().encode(newToken));
}
</code></pre>
<p>关于上述实现的具体细节参考 <a href="https://github.com/halo-dev/halo/pull/6131">GitHub Halo PR #6131</a></p>
<p><strong>遇到的问题:</strong></p>
<p>经过使用后发现，部分用户反馈记住我功能失效的问题，经过排查发现是由于每次使用后都会更新 Token，但这个更新的过程中可能由于并发问题或者用户关闭浏览器导致 Cookie 的值未被写入浏览器导致用户下次访问时无法自动登录，并且判定为盗用行为。因此不再每次使用后都更新 Token，只更新最后使用时间。<br>
  参考 <a href="https://github.com/halo-dev/halo/pull/6329/files">GitHub Halo PR #6329</a></p>
<h3 id="新机制的优点">新机制的优点</h3>
<ol>
 <li>
  <p><strong>提升安全性</strong>：</p>
  <ul>
   <li>可以直观的查看 Token 最后用于登录的时间，并且可以随时可以撤销 Token。</li>
   <li>引入系列标识符，有效检测并应对 cookie 盗用行为。</li>
  </ul></li>
 <li>
  <p><strong>改进用户体验</strong>：</p>
  <ul>
   <li>用户无需频繁重新登录，持久化登录体验更流畅。</li>
   <li>支持设备管理，用户可以查看和管理所有登录设备，提高透明度和控制力。</li>
  </ul></li>
</ol>
<h2 id="结论">结论</h2>
<p>通过这次重构，Halo 不仅增强了系统的安全性和可靠性，还大幅提升了用户体验，进一步巩固了其作为开源建站工具的竞争力。这一改进将为广大用户带来更加安全和便捷的使用体验。</p>
<p>同时，这次重构也让我更加深入理解了持久化 Token 的机制，对于后续的系统设计和开发工作也有了更多的启发和借鉴，功能的完善和优化是一个不断迭代的过程，不会在一开始就完美，就像本次重构一样，只有真正适合当前使用场景的实现方式才是最好的，过度设计和不必要的复杂性只会增加系统的维护成本，当实现方式不再适用时，及时调整和优化才是最重要的。</p>]]></description><guid isPermaLink="false">/archives/persistent-token-based-remember-me</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FSCR-20241205-qarm.png&amp;size=m" type="image/jpeg" length="0"/><pubDate>Thu, 18 Jul 2024 05:22:50 GMT</pubDate></item><item><title><![CDATA[我的开源之旅：2023 年终总结]]></title><link>https://guqing.io/archives/2023-github-opensource-journey</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E6%88%91%E7%9A%84%E5%BC%80%E6%BA%90%E4%B9%8B%E6%97%85%EF%BC%9A2023%20%E5%B9%B4%E7%BB%88%E6%80%BB%E7%BB%93&amp;url=/archives/2023-github-opensource-journey" width="1" height="1" alt="" style="opacity:0;">
<p>随着 2023 年的落幕，回首这一年，依旧是一段不平凡的开源旅程。在 GitHub 的宇宙中，我以一名 Java 软件工程师的身份，不断探索和创造，将我的代码和热情贡献给了多个令人敬畏的项目。</p>
<h2 id="追求卓越不断增长的数字">追求卓越：不断增长的数字</h2>
<p>今年，我在 GitHub 上的贡献：</p>
<ul>
 <li><strong>提出了 86 个 Issues</strong>：我不仅解决问题，也提出问题，促进了项目的进步。</li>
 <li><strong>提交了 174 个 Pull Requests</strong>：每一次提交都是我对代码质量的承诺。</li>
 <li><strong>825 次 Code Reviews</strong>：我认真审视每一行代码，确保其达到我认为的最高标准。</li>
 <li><strong>383 次 Commits</strong>：这些提交见证了我的坚持和项目的进步。</li>
 <li><strong>274 个贡献活跃日</strong>：几乎每天都有我的身影，我的热情在代码中焕发光芒。</li>
 <li><strong>最长连续贡献 25 天</strong>：这段时间，我全身心投入开源项目，享受着编码带来的乐趣。</li>
</ul>
<p>主要偏重于对于 <strong>halo 主仓库</strong> 的贡献，我为其贡献了 <strong>~43k 行代码</strong>。<br>
  去年在 GitHub 的贡献包括 <strong>49,000 行代码添加</strong>和<strong>16,000 行代码删除</strong>。这不仅展示了我对开源的热情，更是我对提升项目质量和持续推动项目发展的坚定承诺的体现。</p>
<h2 id="时间与热情的投入">时间与热情的投入</h2>
<p>在这一年中，我共计投入了 <strong>1012 小时</strong> 编写代码，平均每天 <strong>3 小时 49 分钟</strong>。这个数字背后，是我对技术的热爱和对开源精神的执着。特别是，在 <strong>Java</strong> 语言上，我投入了 <strong>676 小时 52 分钟</strong>，这些时间里，我不断学习、实践并优化我的技术。</p>
<h3 id="创新与合作新的开始和伙伴关系">创新与合作：新的开始和伙伴关系</h3>
<p>在这一年里，我新创建并完成了 <strong>10 个代码仓库</strong>，包括:</p>
<ul>
 <li><a href="https://www.halo.run/store/apps/app-yffxw">plugin-docsme</a>：Docsme 插件为 Halo 提供项目文档管理功能，支持多文档项目、多语言、多版本。</li>
 <li><a href="https://github.com/halo-sigs/halo-gradle-plugin">halo-gradle-plugin</a>：一个 Gradle 插件，为 Halo 插件开发的工具链，用于提升 Halo 插件开发的体验和流程。</li>
 <li><a href="https://github.com/guqing/willow-mde">willow-mde</a>：是一款漂亮、现代且可定制的 &nbsp;<a href="https://github.com/halo-dev/halo">Halo</a>&nbsp; 的 Markdown 编辑器插件。使用 Ink MDE、Vue.js、Typescript 以及 &nbsp;<a href="https://github.com/codemirror">CodeMirror</a>&nbsp; 构建。</li>
 <li><a href="https://github.com/halo-sigs/plugin-oauth2">plugin-oauth2</a>: 适用于 Halo 2.x 的 OAuth2 第三方登录插件。</li>
 <li><a href="https://github.com/guqing/ghu-events-moments">ghu-events-moments</a>：定时同步 GitHub 的 User public events 为 Halo 的瞬间用于在自己博客展示在 GitHub 上的贡献活动。</li>
 <li><a href="https://github.com/halo-sigs/plugin-links">plugin-links</a>: Halo 2.x 的链接管理插件，用于为网站提供友情链接管理和展示功能。</li>
 <li><a href="https://github.com/halo-dev/plugin-sitemap">plugin-sitemap</a>：Halo 2.x 的站点 Sitemap 生成插件，便于搜索引擎抓取网站数据，优化 SEO。</li>
 <li><a href="https://github.com/guqing/halo-plugin-colorless">halo-plugin-colorless</a>：实现让 Halo 主题都展示为灰白效果，一个展示如何扩展 Halo 主题端的示例项目。</li>
 <li>telegram-bot-halo：Telegram 机器人，用于快捷发送瞬间到自己的博客。</li>
</ul>
<p>等，每一个都是我探索新领域和技术的见证。</p>
<p>此外，我还参与了多个项目的贡献，包括:</p>
<ul>
 <li><a href="https://github.com/halo-dev/halo">halo-dev/halo</a>: 强大易用的开源建站工具。</li>
 <li><a href="https://github.com/halo-sigs/plugin-moments">plugin-moments</a>：适用于 Halo 2.x 的瞬间插件，可以用来管理和发布杂记和心情。</li>
 <li><a href="https://github.com/guqing/halo-theme-higan">guqing-higan</a>：适用于 Halo 2.x 的一款简洁而不失优雅的博客主题。</li>
 <li><a href="https://www.halo.run/store/apps/app-dHakX">plugin-backup</a>：Halo 2.x 的增强备份插件，针对原有备份功能进行增强，可实现定时备份，同步备份至远端存储，为数据保驾护航。</li>
 <li><a href="https://github.com/halo-sigs/plugin-bytemd">plugin-bytemd</a>：为 Halo 2.x 集成 ByteMD 编辑器，提供 Markdown 编辑功能。</li>
 <li><a href="https://github.com/halo-sigs/plugin-katex">plugin-katex</a>：为 Halo 2.x 默认编辑器和文章渲染提供 KaTeX 支持。</li>
 <li><a href="https://github.com/halo-sigs/plugin-lightgallery">plugin-lightgallery</a>：Halo 2.x 的插件，集成 lightgallery.js，支持在内容页面放大显示图片</li>
 <li><a href="https://github.com/halo-dev/plugin-comment-widget">plugin-comment-widget</a>：通用的 Halo 2.x 评论组件插件。</li>
 <li><a href="https://github.com/halo-sigs/plugin-migrate">plugin-migrate</a>：支持多种平台的数据迁移 Halo 的 Halo 2.x 插件。</li>
 <li><a href="https://github.com/halo-sigs/awesome-halo">awesome-halo</a>：与 Halo 相关的周边生态资源列表。</li>
 <li><a href="https://github.com/wxyShine/halo-plugin-webhook">halo-plugin-webhook</a>：为 Halo 2.0 提供 Webhook 支持，该插件允许用户在特定事件发生时（如文章发布、更新等）发送通知到指定的 Webhook URL。</li>
 <li>plugin-app-store-server: Halo 应用市场。</li>
</ul>
<p>等。这些贡献经历不仅丰富了我的技术栈，也让我结识了来自世界各地的优秀开发者，共同推动了开源项目的发展。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F2023-contributions.png&amp;size=m" alt="2023-contributions.png"></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F2023-github-wrapped.png&amp;size=m" alt="2023-github-wrapped.png"></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fwakatime.com_a-look-back-at-2023.png&amp;size=m" alt="wakatime.com_a-look-back-at-2023.png"></p>
<h2 id="展望未来">展望未来</h2>
<p>回顾这一年的旅程，我为我的成长和所取得的成就感到自豪。在即将到来的一年里，我期待着踏上新的旅程，继续我的开源贡献，与全球的开发者们一起，共同推动技术的边界。</p>]]></description><guid isPermaLink="false">/archives/2023-github-opensource-journey</guid><dc:creator>guqing</dc:creator><category>开源贡献</category><pubDate>Tue, 2 Jan 2024 14:01:00 GMT</pubDate></item><item><title><![CDATA[不要成为无聊的大人]]></title><link>https://guqing.io/archives/never-become-a-boring-adult</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E4%B8%8D%E8%A6%81%E6%88%90%E4%B8%BA%E6%97%A0%E8%81%8A%E7%9A%84%E5%A4%A7%E4%BA%BA&amp;url=/archives/never-become-a-boring-adult" width="1" height="1" alt="" style="opacity:0;">
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FSCR-20231213-n4m.jpeg&amp;size=m" alt="SCR-20231213-n4m.jpeg"></p>
<p>曾经，在那些灿烂星光似的年华里，我们怀揣着改变世界的梦想。每一颗心中，都燃烧着对未知的向往，对平凡的轻视。在我们的眼中，父母、前辈们的生活仿佛是一潭死水，我们誓言，绝不会步他们的后尘。</p>
<p>我们渴望的，是一段非凡的旅程，一个非比寻常的人生。我们勇往直前，心怀理想，却不知理想的路途如此坎坷。岁月如沙，悄然从指间溜走，每一次跌倒，每一次心灵的创伤，都在无声地教会我们生活的残酷和现实的重量。</p>
<p>年轻的激情渐被现实磨平，那些曾经雄心勃勃的梦想，似乎也在悄然退色。我们开始意识到，自己也许并不比别人更特别。工作的压力、灵鑫的空虚、生活的磨难……这一切像巨石般压在胸口，让人喘不过气来。那个曾经振翅高飞的自我，现在似乎只能在现实的桎梏中，过着那种“普通”的人生。</p>
<p>无人愿意承认自己的平凡，无人愿意面对失败的人生。但不论我们如何抗拒，时间总会悄然改变我们。那些年少轻狂的梦想，仿佛已被封存于记忆的最深处。</p>
<p>然而，即使我们最终都成为了曾经不愿成为的“大人”，那份对美好生活的向往，仍旧藏在心底。也许，真正的成长，不是放弃理想，而是在理想与现实之间，找到了属于自己的平衡。</p>
<p>我们的每一步，虽不再轻盈，却更加坚定。我们学会了在平凡中寻找意义，在琐碎中发现快乐。生活，不再是一场盲目的追逐，而是成为了一种内心的安宁和自我实现。</p>
<p>如此，我们继续行走在人生的道路上，带着曾经的梦想，面向未来的无限可能。</p>]]></description><guid isPermaLink="false">/archives/never-become-a-boring-adult</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FSCR-20231213-n4m.jpeg&amp;size=m" type="image/jpeg" length="0"/><category>生活</category><pubDate>Wed, 13 Dec 2023 08:52:00 GMT</pubDate></item><item><title><![CDATA[Halo 通知机制 RFC]]></title><link>https://guqing.io/archives/halo-notification-mechanism-rfc</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=Halo%20%E9%80%9A%E7%9F%A5%E6%9C%BA%E5%88%B6%20RFC&amp;url=/archives/halo-notification-mechanism-rfc" width="1" height="1" alt="" style="opacity:0;">
<h2>背景</h2>
<p>在 Halo 系统中，具有用户协作属性，如当用户发布文章后被访客评论而访客希望在作者回复评论时被提醒以此完成进一步互动，而在没有通知功能的情况下无法满足诸如以下描述的使用场景：</p>
<ol>
 <li>访客只能在评论后一段时间内访问被评论的文章查看是否被回复。</li>
 <li>Halo 的用户注册功能无法让用户验证邮箱地址让恶意注册变的更容易。</li>
</ol>
<p>在这些场景下，为了让用户收到通知或验证消息以及管理和处理这些通知，我们需要设计一个通知功能，以实现根据用户的订阅和偏好推送通知并管理通知。</p>
<h2>已有需求</h2>
<ul>
 <li>访客评论文章后希望收到被回复的通知，而文章作者也希望收到文章被评论的通知。</li>
 <li>用户注册功能希望验证注册者填写的邮箱实现一个邮箱只能注册一个账号，防止占用别人邮箱，在一定程度上减少恶意注册问题。</li>
 <li>关于应用市场插件，管理员希望在用户下单后能收到新订单通知。</li>
 <li>付费订阅插件场景，希望给付费订阅用户推送付费文章的浏览链接。</li>
</ul>
<h2>目标</h2>
<p>设计一个通知功能，可以根据以下目标，实现订阅和推送通知：</p>
<ul>
 <li>支持扩展多种通知方式，例如邮件、短信、Slack 等。</li>
 <li>支持通知条件并可扩展，例如 Halo 有新文章发布事件如果用户订阅了新文章发布事件但付费订阅插件决定了此文章只有付费用户才可收到通知、按照付费等级不同决定是否发送新文章通知给对应用户等需要通过实现通知条件的扩展点来满足对应需求。</li>
 <li>支持定制化选项，例如是否开启通知、通知时段等。</li>
 <li>支持通知流程，例如通知的发送、接收、查看、标记等。</li>
 <li>通知内容支持多语言。</li>
 <li>事件类型可扩展，插件可能需要定义自己的事件以通知到订阅事件的用户，如应用市场插件。</li>
</ul>
<h2>非目标</h2>
<ul>
 <li>Halo 只会实现站内消息和邮件通知，更多通知方式需要插件去扩展。</li>
 <li>定时通知、通知频率或摘要通知功能属于非必要功能，可由插件去扩展。</li>
 <li>多语言支持，目前只会支持中文和英文两种，更多语言支持不是此阶段的目标。</li>
 <li>可定制的通知模板：通知默认模板由事件定义者提供，如需修改可考虑使用特定的 Notifier 去适配事件。</li>
</ul>
<h2>方案</h2>
<p>为了实现上述目标，我们设计了以下方案：</p>
<h3>通知数据模型</h3>
<h4>通知事件类别和事件</h4>
<p>首先通过定义事件来声明此通知事件包含的数据和发送此事件时默认使用的模板。</p>
<p><code>ReasonType</code> 是一个自定义模型，用于定义事件类别，一个事件类别由多个事件表示。</p>
<pre><code class="language-yaml">apiVersion: notification.halo.run/v1alpha1
kind: ReasonType
metadata:
  name: comment
spec:
  displayName: "Comment Received"
  description: "The user has received a comment on an post."
  properties:
    - name: postName
      type: string
      description: "The name of the post."
      optional: false
    - name: postTitle
      type: string
      optional: true
    - name: commenter
      type: string
      description: "The email address of the user who has left the comment."
      optional: false
    - name: comment
      type: string
      description: "The content of the comment."
      optional: false
</code></pre>
<p><code>Reason</code> 是一个自定义模型，用于定义通知原因，它属于 <code>ReasonType</code> 的实例。</p>
<p>当有事件触发时，创建 <code>Reason</code> 资源来触发通知，如当文章收到一个新评论时：</p>
<pre><code class="language-yaml">apiVersion: notification.halo.run/v1alpha1
kind: Reason
metadata:
  name: comment-axgu
spec:
  # a name of ReasonType
  reasonType: comment
  author: 'guqing'
  subject:
    apiVersion: 'content.halo.run/v1alpha1'
    kind: Post
    name: 'post-axgu'
    title: 'Hello World'
    url: 'https://guqing.xyz/archives/1'
  attributes:
    postName: "post-fadp"
    commenter: "guqing"
    comment: "Hello! This is your first notification."
</code></pre>
<h4>Subscription</h4>
<p><code>Subscription</code> 自定义模型，定义了特定事件时与要被通知的订阅者之间的关系, 其中 <code>subscriber</code> 表示订阅者用户, <code>unsubscribeToken</code> 表示退订时的身份验证 token, <code>reason</code> 订阅者感兴趣的事件。</p>
<p>用户可以通过 <code>Subscription</code> 来订阅自己感兴趣的事件，当事件触发时会收到通知：</p>
<pre><code class="language-yaml">apiVersion: notification.halo.run/v1alpha1
kind: Subscription
metadata:
  name: user-a-sub
spec:
  subscriber:
    name: guqing
  unsubscribeToken: xxxxxxxxxxxx
  reason:
    reasonType: new-comment-on-post
    subject:
      apiVersion: content.halo.run/v1alpha1
      kind: Post
      name: 'post-axgu'
</code></pre>
<p>订阅退订链接 API 规则：<br><code>/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe?token={unsubscribeToken}</code>。</p>
<h4>用户通知偏好设置</h4>
<p>通过在用户偏好设置的 ConfigMap 中存储一个 <code>notification</code> key 用于保存事件类型与通知方式的关系设置，当用户订阅了如 ‘new-comment-on-post’ 事件时会获取对应的通知方式来给用户发送通知。</p>
<pre><code class="language-yaml">apiVersion: v1alpha1
kind: ConfigMap
metadata:
  name: user-preferences-guqing
data:
  notification: |
    {
      reasonTypeNotification: {
        'new-comment-on-post': {
          enabled: true,
          notifiers: [
            email-notifier,
            sms-notifier
          ]
        },
        new-post: {
          enabled: true,
          notifiers: [
            email-notifier,
            webhook-router-notifier
          ]
        }
      },
    }
</code></pre>
<h4>Notification 站内通知</h4>
<p>当用户订阅到事件后会创建 <code>Notification</code>, 它与通知方式（notifier）无关，<code>recipient</code> 为用户名，类似站内通知，如用户 <code>guqing</code> 订阅了评论事件那么当监听到评论事件时会创建一条记录可以在个人中心的通知列表看到一条通知消息。</p>
<pre><code class="language-yaml">apiVersion: notification.halo.run/v1alpha1
kind: Notification
metadata:
  name: notification-abc
spec:
  # username
  recipient: "guqing"
  reason: 'comment-axgu'
  title: 'notification-title'
  content: 'notification-body'
  unread: true
  lastReadAt: '2023-08-04T17:01:45Z'
</code></pre>
<p>个人中心通知自定义 APIs:</p>
<ol>
 <li>获取个人中心获取用户通知列表的 APIs 规则：<br><code>GET /apis/api.console.halo.run/v1alpha1/userspaces/{username}/notifications</code></li>
 <li>将通知标记为已读：<code>PUT /apis/api.console.halo.run/v1alpha1/userspaces/{username}/notifications/mark-as-read</code></li>
 <li>批量将通知标记为已读：<code>PUT /apis/api.console.halo.run/v1alpha1/userspaces/{username}/notifications/mark-specified-as-read</code></li>
</ol>
<h4>通知模板</h4>
<p><code>NotificationTemplate</code> 自定义模型用于定义事件的通知模板，当事件触发时会根据事件的通知模板来渲染通知内容。<br>
  它通过定义 <code>reasonSelector</code> 来引用事件类别，当事件触发时会根据用户的语言偏好和触发事件的类别来选择一个最佳的通知模板。<br>
  选择通知模板的规则为：</p>
<ol>
 <li>根据用户设置的语言，选择从通知模板中定义的 <code>spec.reasonSelector.language</code> 的值从更具体到不太具体的顺序（例如，gl_ES 的值将比 gl 的值具有更高的优先级）。</li>
 <li>当通过语言成功匹配到模板时，匹配到的结果可能不止一个，如 <code>language</code> 为 <code>zh_CN</code> 的模板有三个那么会根据 <code>NotificationTemplate</code> 的 <code>metadata.creationTimestamp</code> 字段来选择一个最新的模板。</li>
</ol>
<p>这样的规则有助于用户可以个性化定制某些事件的模板内容。</p>
<p>模板语法使用 ThymeleafEngine 渲染，纯文本模板使用 <code>textual</code> 模板模式，语法参考: <a href="https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#textual-syntax">usingthymeleaf.html#textual-syntax</a></p>
<p><code>HTML</code> 则使用标准表达式语法在标签属性中取值，语法参考：<a href="https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#standard-expression-syntax">standard-expression-syntax</a></p>
<p>在通知中心渲染模板时会在 <code>ReasonAttributes</code> 中提供额外属性包括：</p>
<ul>
 <li>siteTitle: 站点标题</li>
 <li>siteSubtitle: 站点副标题</li>
 <li>siteLogo: 站点 LOGO</li>
 <li>subscriber: 订阅者的用户名</li>
 <li>unsubscribeUrl: 退订链接，用于取消订阅</li>
</ul>
<p>因此，任何模板都可以使用这几个属性，但事件定义者需要注意避免使用这些保留属性。</p>
<pre><code class="language-yaml">apiVersion: notification.halo.run/v1alpha1
kind: NotificationTemplate
metadata:
  name: template-new-comment-on-post
spec:
  reasonSelector:
    reasonType: new-comment-on-post
    language: zh_CN
  template:
    title: "你的文章 [(${postTitle})] 收到了一条新评论"
    body: |
      [(${commenter})] 评论了你的文章 [(${postTitle})]，内容如下：
      [(${comment})]
</code></pre>
<h4>通知器声明及扩展</h4>
<p><code>NotifierDescriptor</code> 自定义模型用于声明通知器，通过它来描述通知器的名称、描述和关联的 <code>ExtensionDefinition</code> 名称，让用户可以在用户界面知道通知器是什么以及它可以做什么,<br>
  还让 NotificationCenter 知道如何加载通知器和准备通知器需要的设置以发送通知。</p>
<pre><code class="language-yaml">apiVersion: notification.halo.run/v1alpha1 
kind: NotifierDescriptor
metadata:
  name: email-notifier
spec:
  displayName: '邮件通知器'
  description: '支持通过邮件的方式发送通知。'
  notifierExtName: '通知对应的扩展名称'
  senderSettingRef:
    name: 'email-notifier'
    group: 'sender'
  receiverSettingRef:
    name: 'email-notifier'
    group: 'receiver'
</code></pre>
<p>通知器声明了 senderSettingRef 和 receiverSettingRef 后，对应用户端可以通过以下 APIs 获取和保存配置：</p>
<p>管理员获取和保存通知器发送配置的 APIs:</p>
<ol>
 <li>获取通知器发送方配置：<code>GET /apis/api.console.halo.run/v1alpha1/notifiers/{name}/senderConfig</code></li>
 <li>保存通知器发送方配置：<code>POST /apis/api.console.halo.run/v1alpha1/notifiers/{name}/senderConfig</code></li>
</ol>
<p>个人中心用户获取和保存对应通知器接收消息配置的 APIs:</p>
<ol>
 <li>获取通知器接收消息配置：<code>GET /apis/api.console.halo.run/v1alpha1/notifiers/{name}/receiverConfig</code></li>
 <li>获取通知器接收消息配置：<code>POST /apis/api.console.halo.run/v1alpha1/notifiers/{name}/receiverConfig</code></li>
</ol>
<p>通知器扩展点用于实现发送通知的方式：</p>
<pre><code class="language-java">public interface ReactiveNotifier extends ExtensionPoint {

    /**
     * Notify user.
     *
     * @param context notification context must not be null
     */
    Mono&lt;Void&gt; notify(NotificationContext context);
}

@Data
public class NotificationContext {
    private Message message;

    private ObjectNode receiverConfig;

    private ObjectNode senderConfig;

    @Data
    static class Message {
      private MessagePayload payload;

      private Subject subject;

      private String recipient;

      private Instant timestamp;
    }

    @Data
    public static class Subject {
      private String apiVersion;
      private String kind;
      private String name;
      private String title;
      private String url;
    }

    @Data
    static class MessagePayload {
      private String title;

      private String body;

      private ReasonAttributes attributes;
    }
}
</code></pre>
<p>通知数据结构交互图</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fimage-knhw.png&amp;size=m" alt="Notification datastructures interaction"></p>
<p>通知功能 UI 设计<br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fnotification-ui.png&amp;size=m" alt="Notification UI"></p>
<h3>通知模块功能</h3>
<ul>
 <li>发送通知：当触发通知事件时，系统会根据 subscriber 的偏好设置获取到事件对应的通知方式再根据偏好设置自动发送通知。</li>
 <li>接收通知：用户可以选择接收通知的方式，例如邮件、短信、自定义路由通知等。</li>
 <li>查看通知：用户可以在 Halo 中查看所有的通知，包括已读和未读的通知。</li>
 <li>标记通知：用户可以标记通知为已读或未读状态，以便更好地管理和处理通知。</li>
</ul>
<h3>通知管理列表条件筛选</h3>
<p>我们支持以下通知条件筛选策略：</p>
<ul>
 <li>按事件类型：列出特定类型的事件通知，例如新文章，新评论、状态更新等。</li>
 <li>按已读状态：根据通知是否已读列出，方便用户查看未读通知。</li>
 <li>按关键词：列出通知中包含特定关键词的事件通知，例如包含用户名称、标题等关键词的通知。</li>
 <li>按时间：列出在特定时间段内发生的事件通知，例如最近一周、最近一个月等时间段内的通知。</li>
</ul>
<h3>定制化选项</h3>
<p>如果后续有足够的使用场景，可以考虑支持以下定制化选项：</p>
<ul>
 <li>通知时间段：用户可以设置通知的时间段，例如只在工作时间内推送通知。</li>
 <li>通知频率：用户可以设置通知的频率，例如每天、每周、每月等。</li>
 <li>摘要通知：用户可以设置接收每周摘要，总结一周内的通知合并为一条通知并通过如邮件等方式接收。</li>
</ul>
<h2>结论</h2>
<p>通过以上方案和实现，我们设计了一个通知功能，可以根据用户的需求和偏好，自动筛选和推送通知。同时，为了支持更多的事件类型、通知方式和通知条件筛选策略，系统具有良好的可扩展性。</p>]]></description><guid isPermaLink="false">/archives/halo-notification-mechanism-rfc</guid><dc:creator>guqing</dc:creator><category>开源贡献</category><category>Java后端</category><pubDate>Fri, 4 Aug 2023 09:43:00 GMT</pubDate></item><item><title><![CDATA[使用 JavaScript 对图像进行量化并提取主要颜色]]></title><link>https://guqing.io/archives/k-means-main-color</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E4%BD%BF%E7%94%A8%20JavaScript%20%E5%AF%B9%E5%9B%BE%E5%83%8F%E8%BF%9B%E8%A1%8C%E9%87%8F%E5%8C%96%E5%B9%B6%E6%8F%90%E5%8F%96%E4%B8%BB%E8%A6%81%E9%A2%9C%E8%89%B2&amp;url=/archives/k-means-main-color" width="1" height="1" alt="" style="opacity:0;">
<h2>前言</h2>
<p>前段时间在 <a href="https://github.com/halo-dev/halo">Halo</a> 的 <a href="https://halo.run/store/apps">应用市场</a> 中遇到希望主题和插件的封面图背景色为封面图主色的问题，于是乎需要根据封面图提取主色就想到使用 K-Means 算法来提取。</p>
<p>在图像处理中，图像是由像素点构成的，每个像素点都有一个颜色值，颜色值通常由 RGB 三个分量组成。因此，我们可以将图像看作是一个由颜色值构成的点云，每个点代表一个像素点。</p>
<p>为了更好地理解，我们可以将图像的颜色值可视化为一个 Scatter 3D 图。在 Scatter 3D 图中，每个点的坐标由 RGB 三个分量组成，点的颜色与其坐标对应的颜色值相同。</p>
<h2>图像的颜色值量化</h2>
<p>以下面的图片为例</p>
<div align="center">
 <img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fk-means-example-whzc.jpg&amp;size=m" alt="k-means-example-whzc.jpg">
</div>
<p>它的色值分布为如下的图像<br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fk-means-figure_1-pvsb.png&amp;size=m" alt="k-means-figure_1.png"><br>
  从上述 <code>RGB 3D Scatter Plot</code> 图如果将相似的颜色值归为一类可以看出图像大概有三种主色调蓝色、绿色和粉色：<br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fk-means-figure_2-zjbn.jpg&amp;size=m" alt="k-means-figure_2.jpg"><br>
  如果我们从三簇中各选一个中心，如以 A、B、C三点表示 <code>A(50, 150, 200)</code>、<code>B(240, 150, 200)</code>、<code>C(50, 100, 50)</code> 并将每个数据点分配到最近的中心所在的簇中这个过程称之为聚类而这个中心称之为聚类中心，这样就可以得到 K 个以聚类中心为坐标的主色值。而 K-Means 算法是一种常用的聚类算法，它的基本思想就是将数据集分成 K 个簇，每个簇的中心点称为聚类中心，将每个数据点分配到最近的聚类中心所在的簇中。</p>
<p><code>K-Means</code> 算法的实现过程如下：</p>
<ol>
 <li>
  <p>初始化聚类中心：随机选择 K 个点作为聚类中心。</p></li>
 <li>
  <p>分配数据点到最近的聚类中心所在的簇中：对于每个数据点，计算它与每个聚类中心的距离，将它分配到距离最近的聚类中心所在的簇中。</p></li>
 <li>
  <p>更新聚类中心：对于每个簇，计算它的所有数据点的平均值，将这个平均值作为新的聚类中心。</p></li>
 <li>
  <p>重复步骤 2 和步骤 3，直到聚类中心不再改变或达到最大迭代次数。</p></li>
</ol>
<p>在图像处理中，我们可以将每个像素点的颜色值看作是一个三维向量，使用欧几里得距离计算两个颜色值之间的距离。对于每个像素点，我们将它分配到距离最近的聚类中心所在的簇中，然后将它的颜色值替换为所在簇的聚类中心的颜色值，如 <code>A1(10, 140, 170)</code> 以距离它最近的距离中心 A 的坐标表示即 <code>A1 = A(50, 150, 200)</code>。这样，我们就可以将图像中的颜色值进行量化，将相似的颜色值归为一类。</p>
<p>最后，我们可以根据聚类中心的颜色值，计算每个颜色值在图像中出现的次数，并按出现次数从大到小排序，取前几个颜色作为主要颜色。</p>
<pre><code class="hljs language-javascript">&lt;script&gt;
  <span class="hljs-keyword">const</span> img = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Image</span>();
  img.<span class="hljs-property">src</span> = <span class="hljs-string">"https://guqing-blog.oss-cn-hangzhou.aliyuncs.com/image.jpg"</span>;
  img.<span class="hljs-title function_">setAttribute</span>(<span class="hljs-string">"crossOrigin"</span>, <span class="hljs-string">""</span>);

  img.<span class="hljs-property">onload</span> = <span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) {
    <span class="hljs-keyword">const</span> canvas = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">createElement</span>(<span class="hljs-string">"canvas"</span>);
    <span class="hljs-keyword">const</span> ctx = canvas.<span class="hljs-title function_">getContext</span>(<span class="hljs-string">"2d"</span>);
    canvas.<span class="hljs-property">width</span> = img.<span class="hljs-property">width</span>;
    canvas.<span class="hljs-property">height</span> = img.<span class="hljs-property">height</span>;
    ctx.<span class="hljs-title function_">drawImage</span>(img, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>);

    <span class="hljs-keyword">const</span> imageData = ctx.<span class="hljs-title function_">getImageData</span>(<span class="hljs-number">0</span>, <span class="hljs-number">0</span>, canvas.<span class="hljs-property">width</span>, canvas.<span class="hljs-property">height</span>);
    <span class="hljs-keyword">const</span> data = imageData.<span class="hljs-property">data</span>;

    <span class="hljs-keyword">const</span> k = <span class="hljs-number">3</span>; <span class="hljs-comment">// 聚类数</span>
    <span class="hljs-keyword">const</span> centers = <span class="hljs-title function_">quantize</span>(data, k);
    <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(centers)

    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> color <span class="hljs-keyword">of</span> centers) {
      <span class="hljs-keyword">const</span> div = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">createElement</span>(<span class="hljs-string">"div"</span>);
      div.<span class="hljs-property">style</span>.<span class="hljs-property">width</span> = <span class="hljs-string">"50px"</span>;
      div.<span class="hljs-property">style</span>.<span class="hljs-property">height</span> = <span class="hljs-string">"50px"</span>;
      div.<span class="hljs-property">style</span>.<span class="hljs-property">backgroundColor</span> = color;
      <span class="hljs-variable language_">document</span>.<span class="hljs-property">body</span>.<span class="hljs-title function_">appendChild</span>(div);
    }
  };

  <span class="hljs-keyword">function</span> <span class="hljs-title function_">quantize</span>(<span class="hljs-params">data, k</span>) {
    <span class="hljs-comment">// 将颜色值转换为三维向量</span>
    <span class="hljs-keyword">const</span> vectors = [];
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; data.<span class="hljs-property">length</span>; i += <span class="hljs-number">4</span>) {
      vectors.<span class="hljs-title function_">push</span>([data[i], data[i + <span class="hljs-number">1</span>], data[i + <span class="hljs-number">2</span>]]);
    }

    <span class="hljs-comment">// 随机选择 K 个聚类中心</span>
    <span class="hljs-keyword">const</span> centers = [];
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; k; i++) {
      centers.<span class="hljs-title function_">push</span>(vectors[<span class="hljs-title class_">Math</span>.<span class="hljs-title function_">floor</span>(<span class="hljs-title class_">Math</span>.<span class="hljs-title function_">random</span>() * vectors.<span class="hljs-property">length</span>)]);
    }

    <span class="hljs-comment">// 迭代更新聚类中心</span>
    <span class="hljs-keyword">let</span> iterations = <span class="hljs-number">0</span>;
    <span class="hljs-keyword">while</span> (iterations &lt; <span class="hljs-number">100</span>) {
      <span class="hljs-comment">// 分配数据点到最近的聚类中心所在的簇中</span>
      <span class="hljs-keyword">const</span> clusters = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Array</span>(k).<span class="hljs-title function_">fill</span>().<span class="hljs-title function_">map</span>(<span class="hljs-function">() =&gt;</span> []);
      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; vectors.<span class="hljs-property">length</span>; i++) {
        <span class="hljs-keyword">let</span> minDist = <span class="hljs-title class_">Infinity</span>;
        <span class="hljs-keyword">let</span> minIndex = <span class="hljs-number">0</span>;
        <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> j = <span class="hljs-number">0</span>; j &lt; centers.<span class="hljs-property">length</span>; j++) {
          <span class="hljs-keyword">const</span> dist = <span class="hljs-title function_">distance</span>(vectors[i], centers[j]);
          <span class="hljs-keyword">if</span> (dist &lt; minDist) {
            minDist = dist;
            minIndex = j;
          }
        }
        clusters[minIndex].<span class="hljs-title function_">push</span>(vectors[i]);
      }

      <span class="hljs-comment">// 更新聚类中心</span>
      <span class="hljs-keyword">let</span> converged = <span class="hljs-literal">true</span>;
      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; centers.<span class="hljs-property">length</span>; i++) {
        <span class="hljs-keyword">const</span> cluster = clusters[i];
        <span class="hljs-keyword">if</span> (cluster.<span class="hljs-property">length</span> &gt; <span class="hljs-number">0</span>) {
          <span class="hljs-keyword">const</span> newCenter = cluster
            .<span class="hljs-title function_">reduce</span>(<span class="hljs-function">(<span class="hljs-params">acc, cur</span>) =&gt;</span> [
              acc[<span class="hljs-number">0</span>] + cur[<span class="hljs-number">0</span>],
              acc[<span class="hljs-number">1</span>] + cur[<span class="hljs-number">1</span>],
              acc[<span class="hljs-number">2</span>] + cur[<span class="hljs-number">2</span>],
            ])
            .<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">val</span>) =&gt;</span> val / cluster.<span class="hljs-property">length</span>);
          <span class="hljs-keyword">if</span> (!<span class="hljs-title function_">equal</span>(centers[i], newCenter)) {
            centers[i] = newCenter;
            converged = <span class="hljs-literal">false</span>;
          }
        }
      }

      <span class="hljs-keyword">if</span> (converged) {
        <span class="hljs-keyword">break</span>;
      }

      iterations++;
    }

    <span class="hljs-comment">// 将每个像素点的颜色值替换为所在簇的聚类中心的颜色值</span>
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; data.<span class="hljs-property">length</span>; i += <span class="hljs-number">4</span>) {
      <span class="hljs-keyword">const</span> vector = [data[i], data[i + <span class="hljs-number">1</span>], data[i + <span class="hljs-number">2</span>]];
      <span class="hljs-keyword">let</span> minDist = <span class="hljs-title class_">Infinity</span>;
      <span class="hljs-keyword">let</span> minIndex = <span class="hljs-number">0</span>;
      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> j = <span class="hljs-number">0</span>; j &lt; centers.<span class="hljs-property">length</span>; j++) {
        <span class="hljs-keyword">const</span> dist = <span class="hljs-title function_">distance</span>(vector, centers[j]);
        <span class="hljs-keyword">if</span> (dist &lt; minDist) {
          minDist = dist;
          minIndex = j;
        }
      }
      <span class="hljs-keyword">const</span> center = centers[minIndex];
      data[i] = center[<span class="hljs-number">0</span>];
      data[i + <span class="hljs-number">1</span>] = center[<span class="hljs-number">1</span>];
      data[i + <span class="hljs-number">2</span>] = center[<span class="hljs-number">2</span>];
    }

    <span class="hljs-comment">// 计算每个颜色值在图像中出现的次数，并按出现次数从大到小排序</span>
    <span class="hljs-keyword">const</span> counts = {};
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; data.<span class="hljs-property">length</span>; i += <span class="hljs-number">4</span>) {
      <span class="hljs-keyword">const</span> color = <span class="hljs-string">`rgb(<span class="hljs-subst">${data[i]}</span>, <span class="hljs-subst">${data[i + <span class="hljs-number">1</span>]}</span>, <span class="hljs-subst">${data[i + <span class="hljs-number">2</span>]}</span>)`</span>;
      counts[color] = counts[color] ? counts[color] + <span class="hljs-number">1</span> : <span class="hljs-number">1</span>;
    }
    <span class="hljs-keyword">const</span> sortedColors = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">keys</span>(counts).<span class="hljs-title function_">sort</span>(
      <span class="hljs-function">(<span class="hljs-params">a, b</span>) =&gt;</span> counts[b] - counts[a]
    );

    <span class="hljs-comment">// 取前 k 个颜色作为主要颜色</span>
    <span class="hljs-keyword">return</span> sortedColors.<span class="hljs-title function_">slice</span>(<span class="hljs-number">0</span>, k);
  }

  <span class="hljs-keyword">function</span> <span class="hljs-title function_">distance</span>(<span class="hljs-params">a, b</span>) {
    <span class="hljs-keyword">return</span> <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">sqrt</span>(
      (a[<span class="hljs-number">0</span>] - b[<span class="hljs-number">0</span>]) ** <span class="hljs-number">2</span> + (a[<span class="hljs-number">1</span>] - b[<span class="hljs-number">1</span>]) ** <span class="hljs-number">2</span> + (a[<span class="hljs-number">2</span>] - b[<span class="hljs-number">2</span>]) ** <span class="hljs-number">2</span>
    );
  }

  <span class="hljs-keyword">function</span> <span class="hljs-title function_">equal</span>(<span class="hljs-params">a, b</span>) {
    <span class="hljs-keyword">return</span> a[<span class="hljs-number">0</span>] === b[<span class="hljs-number">0</span>] &amp;&amp; a[<span class="hljs-number">1</span>] === b[<span class="hljs-number">1</span>] &amp;&amp; a[<span class="hljs-number">2</span>] === b[<span class="hljs-number">2</span>];
  }
&lt;/script&gt;
</code></pre>
<h2>自动选取 K 值</h2>
<p>在实际应用中，我们可能不知道应该选择多少个聚类中心，即 K 值。一种常用的方法是使用 Gap 统计量法，它的基本思想是比较聚类结果与随机数据集的聚类结果之间的差异，选择使差异最大的 K 值。</p>
<p>Gap 统计量法的实现过程如下：</p>
<ol>
 <li>
  <p>对原始数据集进行 K-Means 聚类，得到聚类结果。</p></li>
 <li>
  <p>生成 B 个随机数据集，对每个随机数据集进行 K-Means 聚类，得到聚类结果。</p></li>
 <li>
  <p>计算聚类结果与随机数据集聚类结果之间的差异，使用 Gap 统计量表示。</p></li>
 <li>
  <p>选择使 Gap 统计量最大的 K 值。</p></li>
</ol>
<p>下面是使用 JavaScript 实现 Gap 统计量法的示例代码：</p>
<pre><code class="hljs language-javascript"><span class="hljs-keyword">function</span> <span class="hljs-title function_">gap</span>(<span class="hljs-params">data, maxK</span>) {
  <span class="hljs-keyword">const</span> gaps = [];
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> k = <span class="hljs-number">1</span>; k &lt;= maxK; k++) {
    <span class="hljs-keyword">const</span> quantized = <span class="hljs-title function_">quantize</span>(data, k);
    <span class="hljs-keyword">const</span> gap = <span class="hljs-title function_">logWk</span>(quantized) - <span class="hljs-title function_">logWk</span>(<span class="hljs-title function_">randomData</span>(data.<span class="hljs-property">length</span>));
    gaps.<span class="hljs-title function_">push</span>(gap);
  }
  <span class="hljs-keyword">const</span> maxGap = <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">max</span>(...gaps);
  <span class="hljs-keyword">return</span> gaps.<span class="hljs-title function_">findIndex</span>(<span class="hljs-function">(<span class="hljs-params">gap</span>) =&gt;</span> gap === maxGap) + <span class="hljs-number">1</span>;
}

<span class="hljs-keyword">function</span> <span class="hljs-title function_">logWk</span>(<span class="hljs-params">quantized</span>) {
  <span class="hljs-keyword">const</span> counts = {};
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; quantized.<span class="hljs-property">length</span>; i++) {
    counts[quantized[i]] = counts[quantized[i]] ? counts[quantized[i]] + <span class="hljs-number">1</span> : <span class="hljs-number">1</span>;
  }
  <span class="hljs-keyword">const</span> n = quantized.<span class="hljs-property">length</span>;
  <span class="hljs-keyword">const</span> k = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">keys</span>(counts).<span class="hljs-property">length</span>;
  <span class="hljs-keyword">const</span> wk = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">values</span>(counts).<span class="hljs-title function_">reduce</span>(<span class="hljs-function">(<span class="hljs-params">acc, cur</span>) =&gt;</span> acc + cur * <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">log</span>(cur / n), <span class="hljs-number">0</span>);
  <span class="hljs-keyword">return</span> <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">log</span>(n) + wk / n;
}

<span class="hljs-keyword">function</span> <span class="hljs-title function_">randomData</span>(<span class="hljs-params">n</span>) {
  <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Uint8ClampedArray</span>(n * <span class="hljs-number">4</span>);
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; data.<span class="hljs-property">length</span>; i++) {
    data[i] = <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">floor</span>(<span class="hljs-title class_">Math</span>.<span class="hljs-title function_">random</span>() * <span class="hljs-number">256</span>);
  }
  <span class="hljs-keyword">return</span> data;
}
</code></pre>
<p>使用：</p>
<pre><code class="hljs language-javascript"><span class="hljs-keyword">const</span> k = <span class="hljs-title function_">gap</span>(data, <span class="hljs-number">10</span>)
<span class="hljs-comment">// const k = 3; // 聚类数</span>
<span class="hljs-keyword">const</span> centers = <span class="hljs-title function_">quantize</span>(data, k);
</code></pre>
<p>好吧，挺麻烦的，最终直接将封面图再作为背景图添加 <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter">backdrop-filter</a> 来实现了 🤐。</p>
<h2>附录</h2>
<p>Python 绘制图片 Scatter 3D：</p>
<pre><code class="hljs language-python"><span class="hljs-keyword">import</span> matplotlib.pyplot <span class="hljs-keyword">as</span> plt
<span class="hljs-keyword">import</span> numpy <span class="hljs-keyword">as</span> np

<span class="hljs-keyword">from</span> mpl_toolkits.mplot3d <span class="hljs-keyword">import</span> Axes3D

<span class="hljs-keyword">def</span> <span class="hljs-title function_">visualize_rgb</span>(<span class="hljs-params">image_path</span>):
    image = plt.imread(image_path)
    height, width, _ = image.shape

    <span class="hljs-comment"># Reshape the image array to a 2D array of pixels</span>
    pixels = np.reshape(image, (height * width, <span class="hljs-number">3</span>))

    <span class="hljs-comment"># Extract RGB values</span>
    red = pixels[:, <span class="hljs-number">0</span>]
    green = pixels[:, <span class="hljs-number">1</span>]
    blue = pixels[:, <span class="hljs-number">2</span>]

    <span class="hljs-comment"># Create 3D scatter plot</span>
    fig = plt.figure()
    ax = fig.add_subplot(<span class="hljs-number">111</span>, projection=<span class="hljs-string">'3d'</span>)
    ax.scatter(red, green, blue, c=pixels/<span class="hljs-number">255</span>, alpha=<span class="hljs-number">0.3</span>)

    ax.set_xlabel(<span class="hljs-string">'Red'</span>)
    ax.set_ylabel(<span class="hljs-string">'Green'</span>)
    ax.set_zlabel(<span class="hljs-string">'Blue'</span>)
    ax.set_title(<span class="hljs-string">'RGB 3D Scatter Plot'</span>)

    plt.show()

<span class="hljs-comment"># 调用函数并传入图像路径</span>
visualize_rgb(<span class="hljs-string">'image.jpg'</span>)
</code></pre>]]></description><guid isPermaLink="false">/archives/k-means-main-color</guid><dc:creator>guqing</dc:creator><category>工具</category><category>Web前端</category><pubDate>Thu, 27 Jul 2023 08:43:00 GMT</pubDate></item><item><title><![CDATA[社区例会 Vol.06 从零开始的 Halo 插件开发]]></title><link>https://guqing.io/archives/community-meeting-v6-halo-plugin-development</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E7%A4%BE%E5%8C%BA%E4%BE%8B%E4%BC%9A%20Vol.06%20%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E7%9A%84%20Halo%20%E6%8F%92%E4%BB%B6%E5%BC%80%E5%8F%91&amp;url=/archives/community-meeting-v6-halo-plugin-development" width="1" height="1" alt="" style="opacity:0;">
<p>Halo 社区例会 五月的全新议题「和 guqing 一起开发插件」将基于 <a href="https://github.com/halo-sigs/plugin-repos">plugin-repos</a> 插件为用户演示如何从零开始创建一个 Halo 插件。</p>
<p>本次例会是「和 guqing 一起开发插件」系列的第一期。开场环节，会议主持人 guqing 介绍了 Halo 插件开发的基本概念和开发环境。你也可以查阅官方文档中的 <a href="https://docs.halo.run/developer-guide/plugin/introduction/">「插件开发」</a> 篇目来获取更详尽的信息。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fimage-20230517113142495.png&amp;size=m" alt="document for halo plugin development"></p>
<p>随后，<a href="https://github.com/guqing">guqing</a> 以将 GitHub 的个人仓库信息同步到 Halo 的自定义模型中为例，展示了在 Halo 中创建插件、运行插件的整体过程。在此期间，涉及了插件生命周期、如何使用自定义模型、自定义模型对象的生命周期介绍、如何使用自动生成的 API、如何创建自定义 API 等演示说明，并初步实现了以下功能：<br>
  通过 GitHub API 获取个人仓库信息；</p>
<ul>
 <li>创建自定义模型 Repository；</li>
 <li>将 GitHub 仓库信息通过自定义 API 存储到 Repository 模型中；</li>
 <li>通过 Repository 的自定义模型 API 列出数据；</li>
</ul>
<p>参会人员对演示内容进行了提问和讨论，并提出了一些开发中的问题和建议，如：</p>
<ul>
 <li>开发体验有待优化，可以考虑在用户界面提供 Devtools 相关功能，仅在插件开发时用于对用户界面的自动刷新和插件热重载。</li>
 <li>针对 plugin-repos 示例插件，提出了可以定义一个自定义模型用于存储仓库数据源用于同步数据。</li>
</ul>
<h4 id="%E4%B8%8B%E6%9C%9F%E9%A2%84%E5%91%8A" tabindex="-1">下期预告</h4>
<p>下期例会中，我们将继续围绕 plugin-repos 插件开发进行分享和探讨。预计实现的功能：</p>
<ul>
 <li>使用一个新的自定义模型来存储仓库源，以便同步上游仓库数据；</li>
 <li>通过 Reconciler 来 watch 仓库源并定期同步；</li>
 <li>自定义主题模板页和路由展示仓库信息。</li>
</ul>
<p>如果你也对 Halo 插件开发抱有兴趣、或对 plugin-repos 插件本身持有改进想法或实现建议，欢迎你在该系列会议进行的同时就参与到协作开发中来，在实践中理解 Halo、熟悉 Halo、成为 Halo 的贡献者。想想下次例会中，大家围绕你的创意或代码展开的讨论会如何。</p>
<p>感谢与会者的发言，同时感谢所有人的参与和聆听。下期再会咯～</p>
<center>
 扫码收看会议录像 / 密码：halo
</center>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fmeet-replay-halo-plugin-1.png&amp;size=m" alt="meet-replay-halo-plugin-1.png"></p>
<blockquote>
 <p>如果你对插件主题开发有任何灵感，欢迎加入 <a href="https://github.com/halo-sigs">Halo 特别兴趣小组</a> 参与贡献。如果你在开发过程中遇到问题，Halo 项目组成员乐意为你提供解答和帮助。你可以通过 <a href="https://bbs.halo.run">论坛</a> 和我们取得联系。<br>
   Halo 社区例会将持续聚焦于大家最最关注的话题领域。这个无界沟通的场所，我们期待通过每一次的畅谈，解决你的疑问、了解你的需求、一起探讨 Halo 的未来与诸多可能。最后，能够对想要参与社区贡献的你给予便利和帮助，同时也提供一个成员互助的契机。</p>
</blockquote>]]></description><guid isPermaLink="false">/archives/community-meeting-v6-halo-plugin-development</guid><dc:creator>guqing</dc:creator><category>开源贡献</category><pubDate>Wed, 17 May 2023 03:40:00 GMT</pubDate></item><item><title><![CDATA[泛太湖游仙岛书事]]></title><link>https://guqing.io/archives/tai-hu-xian-dao</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E6%B3%9B%E5%A4%AA%E6%B9%96%E6%B8%B8%E4%BB%99%E5%B2%9B%E4%B9%A6%E4%BA%8B&amp;url=/archives/tai-hu-xian-dao" width="1" height="1" alt="" style="opacity:0;">
<p>三月四日，我来到了无锡市鼋头渚风景区游览了一番，太湖，这个千年古湖，如今依旧，它那澄澈的湖水、美丽的风景、壮阔的历史，让每一个到此游玩的人为之倾倒。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_8983.jpg&amp;size=m" alt=""></p>
<p>夕阳西下，天地渐昏，太湖波光粼粼，宛如水晶镶嵌，一片昏黄中透着淡淡的蓝色，犹如温柔而又坚韧的女子。微风拂过太湖仙岛，树影斑驳，湖面上泛起点点涟漪，波光如丝，恍若仙境。岸边的樱花树在晚风中轻轻摇曳，洒下淡淡的花瓣，让整个太湖的景色更加柔和浪漫。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1913.jpg&amp;size=m" alt=""></p>
<p>此刻，我置身于太湖仙岛湖边的看台，眼前是夕阳西下，远处湖面上还有几只海鸥在飞翔，一点点黑色的身影在天空中流转。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1912.jpg&amp;size=m" alt=""></p>
<p>突然，一缕鱼肚白透过了树梢，绕过了山丘，直到我的脚下。夕阳的余辉如此美丽，它在湖面上泛起了一片金色的光芒，而我则不禁陷入了对太湖的美丽深深的迷恋之中。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1909.jpg&amp;size=m" alt=""></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1901.jpg&amp;size=m" alt=""></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1879.jpg&amp;size=m" alt=""></p>
<p>沉醉在太湖之中，仿佛时间已经停滞，仿佛历史已经倒流:</p>
<p>我与那些文人雅士置身一地，胸怀壮志，品茶赏景，追求浪漫，看白居易写下”水天向晚碧沉沉，树影霞光重叠深“，与他”烟渚云帆“，飘舟太湖、澄波皓月.</p>
<p>真可谓“余尝登君山而见识何谓江山如画，余尝过长江而领略何谓横无际涯，故略知山水之乐也”。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1865.jpg&amp;size=m" alt=""></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1894.jpg&amp;size=m" alt=""></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1858.jpg&amp;size=m" alt=""></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1852.jpg&amp;size=m" alt=""></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1854.jpg&amp;size=m" alt=""></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1764.jpg&amp;size=m" alt=""></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1709.jpg&amp;size=m" alt=""></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1820.jpg&amp;size=m" alt=""></p>]]></description><guid isPermaLink="false">/archives/tai-hu-xian-dao</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1909.jpg&amp;size=m" type="image/jpeg" length="0"/><category>生活</category><pubDate>Sun, 5 Mar 2023 07:00:00 GMT</pubDate></item><item><title><![CDATA[2022 code stats]]></title><link>https://guqing.io/archives/2022-code-stats</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=2022%20code%20stats&amp;url=/archives/2022-code-stats" width="1" height="1" alt="" style="opacity:0;">
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2FIMG_1305-merged.png&amp;size=m" alt="guqing's 2022 github contributions"> <img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fwakatime.com_a-look-back-at-2022.png&amp;size=m" alt="guqing's 2022 wakatime stats"> <img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fgithub-wrapped.png&amp;size=m" alt="guqing's 2022 github wrapped"></p>]]></description><guid isPermaLink="false">/archives/2022-code-stats</guid><dc:creator>guqing</dc:creator><category>生活</category><pubDate>Sun, 1 Jan 2023 06:57:00 GMT</pubDate></item><item><title><![CDATA[一款美观小巧的 Oh My Zsh 主题]]></title><link>https://guqing.io/archives/oh-my-zsh-zhu-ti-guqing</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E4%B8%80%E6%AC%BE%E7%BE%8E%E8%A7%82%E5%B0%8F%E5%B7%A7%E7%9A%84%20Oh%20My%20Zsh%20%E4%B8%BB%E9%A2%98&amp;url=/archives/oh-my-zsh-zhu-ti-guqing" width="1" height="1" alt="" style="opacity:0;">
<p>由于之前从 <a href="https://github.com/ohmyzsh/ohmyzsh" target="_blank">Oh My Zsh</a> 切换到 <a href="https://github.com/fish-shell/fish-shell" target="_blank">Fish shell</a> 后一直在用 <a href="https://github.com/fish-shell/fish-shell/blob/master/share/tools/web_config/sample_prompts/terlar.fish" target="_blank">Terlar prompt</a>，用了一段时间后还是发现 <a href="https://github.com/ohmyzsh/ohmyzsh" target="_blank">Oh My Zsh</a> 又改回来了，但是看习惯了 Terlar prompt，所以 DIY 了一个适用于 Oh My Zsh 的主题。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fimage-1668671685604.png%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" alt="image-1668671685604"><br>
  它分为两行，第一行显示用户名、路径、git还有时间等，第二行为输入。<br>
  特性：</p>
<ul>
 <li>箭头符号在命令正常执行时显示为 user color，输入执行错误后显示红色</li>
 <li>目录提示符只会显示当前所在目录名，上级目录只显示首字母</li>
 <li>在一个 Git 仓库时会显示分支名称和工作区状态，当工作区 clean 时分支名为绿色，dirty 时分支名为红色，还有勾、叉、星等具体的状态提示符<br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fimage-1668672053502.png%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" alt="image-1668672053502"></li>
</ul>
<p>使用方式：</p>
<ol>
 <li><code>cd ~/.oh-my-zsh/custom/themes</code></li>
 <li>下载一下文件到此目录</li>
 <li><code>vim ~/.zshrc</code> 然后修改<code>ZSH_THEME</code>的值为<code>ZSH_THEME="guqing"</code></li>
 <li><code>source ~/.zshrc</code> 就完成啦🥳</li>
</ol>
<script src="https://gist.github.com/guqing/6be899457771c503daa09f424c0993ad.js"></script>]]></description><guid isPermaLink="false">/archives/oh-my-zsh-zhu-ti-guqing</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fimage-1668672053502.png&amp;size=m" type="image/jpeg" length="0"/><category>Linux</category><pubDate>Thu, 17 Nov 2022 08:06:00 GMT</pubDate></item><item><title><![CDATA[Halo 2.0 如何开发一个插件 [WIP]]]></title><link>https://guqing.io/archives/how-to-development-a-halo-plugin</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=Halo%202.0%20%E5%A6%82%E4%BD%95%E5%BC%80%E5%8F%91%E4%B8%80%E4%B8%AA%E6%8F%92%E4%BB%B6%20%5BWIP%5D&amp;url=/archives/how-to-development-a-halo-plugin" width="1" height="1" alt="" style="opacity:0;">
<p>模板仓库：<a href="https://github.com/halo-dev/plugin-starter">https://github.com/halo-dev/plugin-starter</a></p>
<p>前期准备：</p>
<ol>
 <li>使用此仓库作为模版创建仓库</li>
 <li>克隆新创建好的仓库到本地</li>
</ol>
<p>使用模板仓库开发友情链接插件示例：<a href="https://github.com/halo-sigs/plugin-links">https://github.com/halo-sigs/plugin-links</a></p>
<h3 id="%E9%A1%B9%E7%9B%AE%E7%BB%93%E6%9E%84%E4%BB%8B%E7%BB%8D" tabindex="-1">项目结构介绍</h3>
<pre><code class="language-text">.
├── LICENSE
├── README.md
├── admin-frontend
│&nbsp;&nbsp; ├── ...
├── build
│&nbsp;&nbsp; ├── classes
│&nbsp;&nbsp; ├── libs
│&nbsp;&nbsp; │&nbsp;&nbsp; ├── halo-plugin-template-0.0.1-SNAPSHOT-plain.jar
│&nbsp;&nbsp; ├── resources
├── build.gradle
├── lib
│&nbsp;&nbsp; └── halo-2.0.0-SNAPSHOT-plain.jar
├── settings.gradle
└── src
    └── main
        ├── java
        │&nbsp;&nbsp; └── io
        │&nbsp;&nbsp;     └── github
        │&nbsp;&nbsp;         └── guqing
        │&nbsp;&nbsp;             └── template
        │&nbsp;&nbsp;                 ├── Apple.java
        │&nbsp;&nbsp;                 ├── ApplePlugin.java
        │&nbsp;&nbsp;                 └── ApplesController.java
        └── resources
            ├── admin
            │&nbsp;&nbsp; ├── main.js
            │&nbsp;&nbsp; └── style.css
            ├── extensions
            │&nbsp;&nbsp; ├── apple.yaml
            │&nbsp;&nbsp; ├── reverseProxy.yaml
            │&nbsp;&nbsp; └── roleTemplate.yaml
            ├── plugin.yaml
            └── static
                ├── ...
</code></pre>
<p>对于以上目录树：</p>
<ul>
 <li>
  <p><code>admin-frontend</code>： 插件前端项目目录，为一个 vue 项目；</p></li>
 <li>
  <p><code>build</code>：插件后端构建目录，<code>build/libs</code> 下的 jar 包为最终插件产物；</p></li>
 <li>
  <p><code>lib</code>：为临时的 halo 依赖，为了使用 halo 中提供的类在 <code>build.gradle</code> 中作为编译时依赖引入 <code>compileOnly files("lib/halo-2.0.0-SNAPSHOT-plain.jar")</code>，待 <code>2.0</code> 正式发布会将其发布到 <code>maven</code> 中央仓库，便可通过 <code>gradle </code>依赖；</p></li>
 <li>
  <p><code>src</code>: 为插件后端源码目录；</p></li>
 <li>
  <p><code>Apple.java</code> 为自定义模型</p></li>
</ul>
<pre><code class="language-java">@GVK(group = "apple.guqing.xyz", kind = "Apple",
    version = "v1alpha1", singular = "apple", plural = "apples")
@Data
@EqualsAndHashCode(callSuper = true)
public class Apple extends AbstractExtension {}
</code></pre>
<p>关键在于标注 <code>@GVK </code>注解和 <code>extends AbstractExtension</code>，当如此定义了一个模型后，插件启动时会根据 <code>@GVK</code> 配置自动生成<code>CRUD</code>的 <code>RESTful API</code>，以此为例子会生成如下<code>APIs</code></p>
<pre><code class="language-text">GET /apis/apple.guqing.xyz/v1alpha1/apples

POST /apis/apple.guqing.xyz/v1alpha1/apples

GET /apis/apple.guqing.xyz/v1alpha1/apples/{name}

PUT /apis/apple.guqing.xyz/v1alpha1/apples/{name}

DELETE /apis/apple.guqing.xyz/v1alpha1/apples/{name}
</code></pre>
<p>生成规则见：<a href="https://github.com/halo-dev/rfcs/tree/main/extension">Halo extension RFC</a></p>
<ul>
 <li><code>ApplePlugin.java</code>：插件生命周期入口，它继承 <code>BasePlugin</code>，可以通过<code>getApplicationContext()</code>方法获取到 <code>SchemeManager</code>，然后在 <code>start()</code> 方法中注册自定义模型，这一步必不可少，所有定义的自定义模型都需要在此注册，并在 <code>stop()</code> 生命周期方法中清理资源。</li>
</ul>
<pre><code class="language-java">@Override
public void start() {
  schemeManager.register(Apple.class);
}

@Override
public void stop() {
  // TODO 优化取消注册自定义模型的写法
  Scheme scheme = schemeManager.get(Apple.class);
  schemeManager.unregister(scheme);
}
</code></pre>
<p>注意：该类不能标注 <code>@Component</code> 等能将其声明为 <code>Spring Bean</code> 的注解</p>
<ul>
 <li><code>ApplesController.java</code>：如果根据模型自动生成的 <code>CURD RESTful APIs</code> 无法满足业务需要，可以写常规 <code>Controller</code> 来自定义<code>APIs</code>，示例：</li>
</ul>
<pre><code class="language-java">@ApiVersion("v1alpha1")
@RestController
@RequestMapping("colors")
public class ApplesController {

    @GetMapping
    public Mono&lt;String&gt; hello() {
        return Mono.just("Hello world");
    }
}
</code></pre>
<p>插件定义 <code>Controller</code> 必须要标注 <code>@ApiVersion</code> 注解，否则启动时会报错。如果定义了这样的 <code>Controller</code> ，插件启动后会生成如下的 <code>APIs</code></p>
<pre><code class="language-text">GET /api/v1alpha1/plugins/apples/colors
</code></pre>
<p>生成规则为 <code>/api/{version}/plugins/{plugin-name}/{mapping-in-class}/{mapping-in-method}</code></p>
<p>其中:</p>
<ul>
 <li>
  <p><code>version</code>：来自 <code>@ApiVersion("v1alpha1") </code>的 value，详情参考：<a href="https://github.com/halo-dev/rfcs/blob/main/plugin/pluggable-design.md#api-%E6%9E%84%E6%88%90%E8%AE%A8%E8%AE%BA">Halo plugin API composition</a></p></li>
 <li>
  <p><code>plugin-name</code>：值来自 <code>plugin.yaml</code> 中的 <code>metadata.name</code> 属性</p></li>
 <li>
  <p><code>mapping-in-class</code>：来自标注在类上的 <code>@RequestMapping("colors")</code></p></li>
 <li>
  <p><code>mapping-in-method</code>：来自标注在方法上的 <code>@GetMapping</code></p></li>
</ul>
<p>插件还允许使用 <code>@Service</code>、<code>@Component</code> 注解将其声明为一个 <code>Spring Bean</code>，便可通过依赖注入使用 <code>Spring Bean</code>，示例：</p>
<pre><code class="language-java">@Service
public class ColorService {
  public String getColor(String appleName) {
    return "red";
  }
}

@ApiVersion("v1alpha1")
@RestController
@RequestMapping("colors")
public class ApplesController {
	private final ColorService colorService;
  // 构造器注入，当然也同样允许 @Autowired 注入 和 setter 方法注入
  public ApplesController(ColorService colorService) {
    this.colorService = colorService;
  }
}
</code></pre>
<ul>
 <li><code>resources</code>：目录为插件资源目录</li>
</ul>
<p><code>admin</code> 目录下为插件前端打包后的产物存放目录，固定为 <code>main.js</code> 和 <code>style.css </code>两个文件</p>
<p><code>extensions</code> 存放自定义模型资源配置</p>
<p><code>plugin.yaml</code>为插件描述配置</p>
<p><code>static</code> 为静态资源示例目录</p>
<pre><code>        ├── admin
        │   ├── main.js
        │   └── style.css
        ├── extensions
        │   ├── apple.yaml
        │   ├── reverseProxy.yaml
        │   └── roleTemplate.yaml
        ├── plugin.yaml
        └── static
            ├── image.jpeg
            ├── some.txt
            └── test.html
</code></pre>
<p>插件启动时会加载 <code>extensions</code> 目录的 <code>yaml</code> 保存到数据库，<code>apple.yaml</code> 为本项目自定义的模型的数据示例，当启用插件后调用</p>
<pre><code class="language-text">GET /apis/apple.guqing.xyz/v1alpha1/apples
</code></pre>
<p>便可查询到 <code>apple.yaml</code> 中定义的记录</p>
<pre><code class="language-json">[
  {
    "spec": {
      "varieties": "Fuji",
      "color": "red",
      "size": "middle",
      "producingArea": "China"
    },
    "apiVersion": "apple.guqing.xyz/v1alpha1",
    "kind": "Apple",
    "metadata": {
      "name": "Fuji-apple",
      "labels": {
        "plugin.halo.run/plugin-name": "apples"
      },
      "version": 0,
      "creationTimestamp": "2022-06-24T04:03:22.890741Z"
    }
  }
]
</code></pre>
<p><code>reverseProxy.yaml</code> 为 Halo 中提供的模型，表示反向代理，允许插件配置规则代理到插件目录</p>
<pre><code class="language-yaml">apiVersion: plugin.halo.run/v1alpha1
kind: ReverseProxy
metadata:
  name: reverse-proxy-template
rules:
  - path: /static/**
    file:
      directory: static
</code></pre>
<p>它表示访问 <code>GET /assets/{plugin-name}/static/**</code> 时代理访问到插件的 <code>resources/static</code> 目录，本插件便可访问到一下静态资源</p>
<pre><code class="language-">GET /assets/apples/static/image.jpeg
GET /assets/apples/static/some.txt
GET /assets/apples/static/test.html
</code></pre>
<p><code>roleTemplate.yaml</code> 文件中 <code>kind: Role</code> 表示插件允许提供角色模版，定义格式如下：</p>
<pre><code class="language-yaml">apiVersion: v1alpha1
kind: Role
metadata:
  name: a name here
  labels:
    plugin.halo.run/role-template: "true"
  annotations:
    plugin.halo.run/module: "module name"
    plugin.halo.run/alias-name: "display name"
rules:
  # ...
</code></pre>
<p>必须带有<code>plugin.halo.run/role-template: "true"</code> labels，表示该角色为角色模版，当用户启用插件后，创建角色或修改角色时会将其列在权限列表位置。</p>
<p>插件如果不提供角色模版除非是超级管理员否则其他账号没有权限访问，因为 Halo 规定 <code>/api</code> 和 <code>/apis</code> 开头的 <code>api</code> 都需要授权才能访问，因此插件不提供角色模版的自定义资源，就无法将其分配给用户。</p>
<p>更多详情参考：<a href="https://github.com/halo-dev/rfcs/blob/main/identity/002-security.md">Halo security RFC</a></p>
<h3 id="%E6%89%93%E5%8C%85" tabindex="-1">打包</h3>
<h4 id="%E6%89%93%E5%8C%85%E5%89%8D%E7%AB%AF%E9%A1%B9%E7%9B%AE" tabindex="-1">打包前端项目</h4>
<ol>
 <li>到 <code>admin-frontend</code> 目录下执行</li>
</ol>
<pre><code class="language-shell">pnpm install
</code></pre>
<ol start="2">
 <li>开发时执行</li>
</ol>
<pre><code class="language-shell">pnpm run dev
</code></pre>
<ol start="3">
 <li>打包时执行</li>
</ol>
<pre><code class="language-shell">pnpm run build
</code></pre>
<h4 id="%E6%9E%84%E5%BB%BA%E5%90%8E%E7%AB%AF" tabindex="-1">构建后端</h4>
<ol>
 <li>
  <p>开发时可以使用 <code>command + F9/ctrl + F9</code></p></li>
 <li>
  <p>生产时：点击 <code>gradle</code> 的 <code>build</code></p></li>
</ol>
<p>或者执行</p>
<pre><code class="language-">./gradlew -x build
</code></pre>
<p>然后只需复制例如<code>build/libs/halo-plugin-template-0.0.1-SNAPSHOT-plain.jar</code> 的 <code>jar</code> 包即可使用</p>]]></description><guid isPermaLink="false">/archives/how-to-development-a-halo-plugin</guid><dc:creator>guqing</dc:creator><category>Java后端</category><pubDate>Fri, 24 Jun 2022 08:02:00 GMT</pubDate></item><item><title><![CDATA[我们很激动的宣布 Halo 1.5.0 发布了]]></title><link>https://guqing.io/archives/halo-150-released</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E6%88%91%E4%BB%AC%E5%BE%88%E6%BF%80%E5%8A%A8%E7%9A%84%E5%AE%A3%E5%B8%83%20Halo%201.5.0%20%E5%8F%91%E5%B8%83%E4%BA%86&amp;url=/archives/halo-150-released" width="1" height="1" alt="" style="opacity:0;">
<blockquote>
 <p>距离我们 2020 年 9 月 24 号发布 1.4.0 已经过去了 545 天了，期间虽然有一些版本更新，但大多数都是 patch 修复版本。终于，在今天正式发布 1.5.0 版本。其中带来了大量的优化更新，下面为大家简单介绍一些亮点功能，详细更新日志可在本文末尾查阅。</p>
</blockquote>
<h2 id="%E7%89%88%E6%9C%AC%E4%BA%AE%E7%82%B9">版本亮点</h2>
<h3 id="%E6%96%87%E7%AB%A0%E8%A1%A8%E6%8B%86%E5%88%86">文章表拆分</h3>
<p>在此版本中，新增了 <code>contents</code> 表专门用于存储文章内容。因为在以前的版本中，当站点有大量文章的时候会出现明显拖慢页面渲染速度的情况，这是因为在之前的版本中，查询文章列表会将内容字段也包含在其中，这就会导致数据越多就会越慢。所以在这个版本中，原本的 <code>posts</code> 表就不再包含 <code>originalContent</code> 和 <code>formatContent</code> 字段，在列表查询的时候也不会包含。需要注意的是，如果你是从 1.4 版本升级的话，目前我们是没有自动清空这两个字段的内容的。当然，你可以手动清空，以获得更好的效果。</p>
<p>性能对比：</p>
<ul>
 <li>版本：1.4.17 vs 1.5.0</li>
 <li>测试数据：文章数 1000，每篇 4000 字符。</li>
 <li>测试工具：Apache Benchmark</li>
 <li>测试平台：MacBook Pro M1 Pro</li>
 <li>测试参数：<code>ab -c 100 -n 10000 http://localhost:8090/</code></li>
</ul>
<p>1.4.17：</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252015.55.46.png&amp;size=m" alt="Screen Shot 2022-03-23 at 15.55.46"></p>
<p>1.5.0：</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252016.03.46.png&amp;size=m" alt="Screen Shot 2022-03-23 at 16.03.46"></p>
<blockquote>
 <p>注意：此测试可能并不是特别严谨，仅供参考。</p>
</blockquote>
<h3 id="%E6%96%87%E7%AB%A0%E4%BF%9D%E5%AD%98%E9%80%BB%E8%BE%91%E4%BF%AE%E6%94%B9">文章保存逻辑修改</h3>
<p>目前后台已经修改为直接保存编辑器渲染的 html 内容，保证编写时和最终发布结果完全一致。另外，数学公式和 mermaid 图表已经针对此特性做了优化。数学公式仅需在前台引入 katex css 即可。mermaid 图表会被渲染为 svg 并保存，所以无需再引入 mermaid 依赖。</p>
<p>数学公式可以在前台引入：</p>
<pre><code class="language-html">&lt;link rel="stylesheet" href="https://unpkg.com/katex@0.12.0/dist/katex.min.css" /&gt;
</code></pre>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.41.51.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.41.51"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.42.22.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.42.22"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.44.14.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.44.14"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.44.24.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.44.24"></p>
<p>目前，我们已经统一使用 <a href="https://github.com/halo-dev/js-sdk/tree/master/packages/markdown-renderer" target="_blank">@halo-dev/markdown-renderer</a> 进行 Markdown 渲染。</p>
<h3 id="%E7%BC%96%E8%BE%91%E5%99%A8%E9%87%8D%E6%9E%84">编辑器重构</h3>
<p>编辑器编辑区域修改为 Codemirror 编辑器，支持 Markdown 语法高亮，浏览文章会更加方便。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.53.18.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.53.18"></p>
<p>编辑器可以通过快捷键打开图片选择，目前也已经支持多选图片和选择之后直接插入文章。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.52.41.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.52.41"></p>
<p>预览区域支持代码块高亮。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.51.53.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.51.53"></p>
<p>支持更多 Markdown 标记语法。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.54.15.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.54.15"></p>
<p>表格编辑体验优化。众所周知，使用 Markdown 语法编写表格的体验是非常难受的，不仅编写困难，而且格式会非常难以阅读。所以我们使用了一个叫做 <a href="https://github.com/susisu/mte-kernel" target="_blank">mte-kernel</a> 的库来解决这个问题，不仅可以让编辑体验升级，而且会自动格式化表格。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FCleanShot%25202022-03-23%2520at%252014.54.38.gif&amp;size=m" alt="CleanShot 2022-03-23 at 14.54.38"></p>
<h3 id="%E6%A0%87%E7%AD%BE%E6%94%AF%E6%8C%81%E8%AE%BE%E7%BD%AE%E9%A2%9C%E8%89%B2">标签支持设置颜色</h3>
<p>在这个版本中，我们添加了对标签设置颜色的功能，灵感来自于 GitHub Issues 的标签颜色。这样可以比较直观地区分不同的标签。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.13.04.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.13.04"><br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.13.17.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.13.17"></p>
<h3 id="%E6%96%87%E7%AB%A0%E7%AE%A1%E7%90%86%E9%87%8D%E6%9E%84">文章管理重构</h3>
<p>重构了批量操作的逻辑，目前已经不需要提前通过文章状态筛选文章之后才能批量选择。可以任意批量选择之后进行文章状态的批量操作。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.16.14.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.16.14"></p>
<p>文章列表不再展示回收站的文章，改为单独为回收站提供一个入口。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.16.00.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.16.00"></p>
<p>文章设置弹窗进行了重构，改为更直观地显示方式，并且可以在弹窗中进行上下篇的切换。可以非常快速的预览文章设置并进行对文章设置的修改。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.16.39.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.16.39"></p>
<h3 id="%E9%99%84%E4%BB%B6%E7%AE%A1%E7%90%86%E9%87%8D%E6%9E%84">附件管理重构</h3>
<p>目前支持将鼠标放在附件项即可显示勾选图标，不再需要提前进入批量管理功能。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.15.17.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.15.17"></p>
<p>附件详情也和文章设置一样，修改为弹窗的形式，可以进行上下项的快速切换，方便预览以及进行修改删除等操作。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.15.32.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.15.32"></p>
<h3 id="%E4%B8%BB%E9%A2%98%E8%AE%BE%E7%BD%AE%E9%87%8D%E6%9E%84">主题设置重构</h3>
<p>主题设置由原来的全屏抽屉形式改为单独的页面。并优化了布局，不再显示主题缩略图，将更多的空间留给设置表单。同时在界面上也展示了主题的相关信息。</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.17.16.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.17.16"></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhalo.run%2Fupload%2F2022%2F03%2FScreen%2520Shot%25202022-03-23%2520at%252014.18.44.png&amp;size=m" alt="Screen Shot 2022-03-23 at 14.18.44"></p>
<h2 id="%E7%89%88%E6%9C%AC%E6%97%A5%E5%BF%97">版本日志</h2>
<h3 id="breaking-changes">Breaking changes</h3>
<ul>
 <li>评论表单中的邮箱地址不再作为必填项。 <a href="https://github.com/halo-dev/halo/pull/1535" target="_blank">halo-dev/halo#1535</a> <a href="https://github.com/jerry-shao" target="_blank">@jerry-shao</a></li>
 <li>重构文章表结构。 
  <ul>
   <li>提供单独的 <code>contents</code> 表存储文章内容，将不再使用 <code>posts</code> 表中的 <code>originalContent</code> 和 <code>formatContent</code> 字段。 <a href="https://github.com/halo-dev/halo/pull/1617" target="_blank">halo-dev/halo#1617</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
   <li><code>formatContent</code> 已经废弃，将在下一个大版本中移除。后续使用 <code>content</code> 字段代替。 <a href="https://github.com/halo-dev/halo/pull/1617" target="_blank">halo-dev/halo#1617</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
   <li>提供 <code>content_patch_logs</code> 表用于存储变更记录。 <a href="https://github.com/halo-dev/halo/pull/1617" target="_blank">halo-dev/halo#1617</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
  </ul></li>
 <li>后台使用 @halo-dev/admin-api 进行接口请求。 <a href="https://github.com/halo-dev/halo-admin/pull/378" target="_blank">halo-dev/halo-admin#378</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li>修改后台页面标题后缀，由 <code>Halo Dashboard</code> 改为 <code>Halo</code>。 <a href="https://github.com/halo-dev/halo-admin/pull/426" target="_blank">halo-dev/halo-admin#426</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>默认后台布局改为左侧菜单布局。 <a href="https://github.com/halo-dev/halo-admin/pull/441" target="_blank">halo-dev/halo-admin#441</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>重构文章/自定义页面编辑逻辑，改为保存前端渲染内容，放弃后端渲染。 <a href="https://github.com/halo-dev/halo-admin/pull/439" target="_blank">halo-dev/halo-admin#439</a> <a href="https://github.com/halo-dev/halo-admin/pull/440" target="_blank">halo-dev/halo-admin#440</a> <a href="https://github.com/halo-dev/halo-admin/pull/449" target="_blank">halo-dev/halo-admin#449</a> <a href="https://github.com/halo-dev/halo/pull/1668" target="_blank">halo-dev/halo#1668</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li>Content API 的评论列表接口不再返回 <code>ipAddress</code> 和 <code>email</code> 字段。 <a href="https://github.com/halo-dev/halo/pull/1729" target="_blank">halo-dev/halo#1729</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
</ul>
<h3 id="features">Features</h3>
<ul>
 <li>Content API 添加获取所有图库图片分组的接口（PhotoController#listTeams）。 <a href="https://github.com/halo-dev/halo/pull/1515" target="_blank">halo-dev/halo#1515</a> <a href="https://github.com/fuzui" target="_blank">@fuzui</a></li>
 <li>Content API 添加根据 <code>themeId</code> 获取主题详情和设置的接口。 <a href="https://github.com/halo-dev/halo/pull/1660" target="_blank">halo-dev/halo#1660</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li>图库支持点赞。 <a href="https://github.com/halo-dev/halo/pull/1537" target="_blank">halo-dev/halo#1537</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li>文章标签支持设置颜色。 <a href="https://github.com/halo-dev/halo/pull/1566" target="_blank">halo-dev/halo#1566</a> <a href="https://github.com/halo-dev/halo-admin/pull/395" target="_blank">halo-dev/halo-admin#395</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li>重构后台文章分类管理，支持排序。 <a href="https://github.com/halo-dev/halo/pull/1650" target="_blank">halo-dev/halo#1650</a> <a href="https://github.com/halo-dev/halo/pull/1657" target="_blank">halo-dev/halo#1657</a> <a href="https://github.com/halo-dev/halo-admin/pull/435" target="_blank">halo-dev/halo-admin#435</a> <a href="https://github.com/lan-yonghui" target="_blank">@lan-yonghui</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li>文章设置界面支持重新生成别名。 <a href="https://github.com/halo-dev/halo-admin/pull/368" target="_blank">halo-dev/halo-admin#368</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>Admin API 提供批量删除图库图片的接口。 <a href="https://github.com/halo-dev/halo/pull/1680" target="_blank">halo-dev/halo#1680</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>Admin API 提供批量更新图库图片的接口。 <a href="https://github.com/halo-dev/halo/pull/1679" target="_blank">halo-dev/halo#1679</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>后台评论管理提供日志评论管理的界面。 <a href="https://github.com/halo-dev/halo-admin/pull/480" target="_blank">halo-dev/halo-admin#480</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>后台菜单分组支持修改分组名。 <a href="https://github.com/halo-dev/halo-admin/pull/499" target="_blank">halo-dev/halo-admin#499</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>后台个人资料头像修改支持输入链接。 <a href="https://github.com/halo-dev/halo-admin/pull/505" target="_blank">halo-dev/halo-admin#505</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>后台待审核评论弹框点击评论支持进入评论管理。 <a href="https://github.com/halo-dev/halo-admin/pull/517" target="_blank">halo-dev/halo-admin#517</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>支持配置 Redis 缓存。 <a href="https://github.com/halo-dev/halo/pull/1751" target="_blank">halo-dev/halo#1751</a> <a href="https://github.com/luoxmc" target="_blank">@luoxmc</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
</ul>
<h3 id="improvements">Improvements</h3>
<ul>
 <li>更新 Logo。 <a href="https://github.com/halo-dev/halo-admin/pull/366" target="_blank">halo-dev/halo-admin#366</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>重构附件上传的文件命名逻辑，文件作为第一次上传的时候文件名不添加随机字符串。 <a href="https://github.com/halo-dev/halo/pull/1500" target="_blank">halo-dev/halo#1500</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li>优化后台编辑文章时的预览体验，预览文章或临时保存内容不会修改已经发布的内容。 <a href="https://github.com/halo-dev/halo/pull/1617" target="_blank">halo-dev/halo#1617</a> <a href="https://github.com/halo-dev/halo-admin/pull/439" target="_blank">halo-dev/halo-admin#439</a> <a href="https://github.com/guqing" target="_blank">@guqing</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>重构后台主题设置界面，提供单独的设置页面，支持展示主题的相关信息。 <a href="https://github.com/halo-dev/halo-admin/pull/380" target="_blank">halo-dev/halo-admin#380</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>弱化后台登录页面的动画效果。 <a href="https://github.com/halo-dev/halo-admin/pull/369" target="_blank">halo-dev/halo-admin#369</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>优化后台附件管理列表预览图片样式。 <a href="https://github.com/halo-dev/halo-admin/pull/374" target="_blank">halo-dev/halo-admin#374</a> <a href="https://github.com/halo-dev/halo-admin/pull/382" target="_blank">halo-dev/halo-admin#382</a> <a href="https://github.com/623337308" target="_blank">@623337308</a> <a href="https://github.com/cetr" target="_blank">@cetr</a></li>
 <li>重构后台附件详情界面，取消原有抽屉的设计，改为弹窗。 <a href="https://github.com/halo-dev/halo-admin/pull/375" target="_blank">halo-dev/halo-admin#375</a> <a href="https://github.com/halo-dev/halo-admin/pull/381" target="_blank">halo-dev/halo-admin#381</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>重构后台文章/自定义页面设置界面，取消原有抽屉的设计，改为弹窗。 <a href="https://github.com/halo-dev/halo-admin/pull/376" target="_blank">halo-dev/halo-admin#376</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>重构后台操作日志列表界面，取消原有抽屉的设计，提供单独的页面。 <a href="https://github.com/halo-dev/halo-admin/pull/419" target="_blank">halo-dev/halo-admin#419</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>重构后台图片选择弹框组件，支持直接插入到编辑器，支持多选。 <a href="https://github.com/halo-dev/halo-admin/pull/420" target="_blank">halo-dev/halo-admin#420</a> <a href="https://github.com/halo-dev/halo-admin/pull/421" target="_blank">halo-dev/halo-admin#421</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>后台文章设置选择标签列表改为根据名称排序。 <a href="https://github.com/halo-dev/halo-admin/pull/429" target="_blank">halo-dev/halo-admin#429</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>优化后台附件管理中批量操作附件的逻辑。 <a href="https://github.com/halo-dev/halo-admin/pull/431" target="_blank">halo-dev/halo-admin#431</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>后台使用重构版本的编辑器，编辑区域支持高亮语法，优化数学公式和图表等渲染，优化表格的编辑体验。 <a href="https://github.com/halo-dev/halo-admin/pull/447" target="_blank">halo-dev/halo-admin#447</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>优化文章加密和分类加密的逻辑。 <a href="https://github.com/halo-dev/halo/pull/1678" target="_blank">halo-dev/halo#1678</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li>优化后台登录页面样式。 <a href="https://github.com/halo-dev/halo-admin/pull/456" target="_blank">halo-dev/halo-admin#456</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>重构后台文章评论列表弹窗。 <a href="https://github.com/halo-dev/halo-admin/pull/463" target="_blank">halo-dev/halo-admin#463</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>重构后台图库管理页面，支持批量操作图片以及批量从附件库添加图片。 <a href="https://github.com/halo-dev/halo-admin/pull/468" target="_blank">halo-dev/halo-admin#468</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>重构后台文章管理页面，文章列表将不再展示回收站状态的文章，提供单独的回收站入口。 <a href="https://github.com/halo-dev/halo-admin/pull/475" target="_blank">halo-dev/halo-admin#475</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>优化后台文章/自定义页面设置的保存逻辑，提供转为发布/草稿的按钮。保存按钮不再影响到文章状态。 <a href="https://github.com/halo-dev/halo-admin/pull/476" target="_blank">halo-dev/halo-admin#476</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>优化后台折叠菜单的体验，解决折叠时 Logo 和 菜单动画不同步的问题。 <a href="https://github.com/halo-dev/halo-admin/pull/493" target="_blank">halo-dev/halo-admin#493</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>缓存后台折叠菜单的状态，刷新页面不再会恢复到初始状态。 <a href="https://github.com/halo-dev/halo-admin/pull/493" target="_blank">halo-dev/halo-admin#493</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>优化后台判断是否初始化的逻辑，修改为每次页面加载只请求一次，切换路由不再请求。 <a href="https://github.com/halo-dev/halo-admin/pull/495" target="_blank">halo-dev/halo-admin#495</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>重构后台主题设置保存预览功能。 <a href="https://github.com/halo-dev/halo-admin/pull/502" target="_blank">halo-dev/halo-admin#502</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>重构日志发布功能，使用 Markdown 编辑器替换 textarea，并支持保存前端渲染的 Markdown 结果。 <a href="https://github.com/halo-dev/halo/pull/1739" target="_blank">halo-dev/halo#1739</a> <a href="https://github.com/halo-dev/halo-admin/pull/506" target="_blank">halo-dev/halo-admin#506</a> <a href="https://github.com/guqing" target="_blank">@guqing</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
</ul>
<h3 id="bug-fixes">Bug Fixes</h3>
<ul>
 <li>修复修改加密文章或分类时没有清除用户访问权限的问题。 <a href="https://github.com/halo-dev/halo/pull/1540" target="_blank">halo-dev/halo#1540</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li>修复重置密码没有校验密码长度的问题。 <a href="https://github.com/halo-dev/halo/pull/1636" target="_blank">halo-dev/halo#1636</a> <a href="https://github.com/halo-dev/halo-admin/pull/403" target="_blank">halo-dev/halo-admin#403</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li>修复后台文章设置中无法仅选择父级分类的问题。 <a href="https://github.com/halo-dev/halo-admin/pull/367" target="_blank">halo-dev/halo-admin#367</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>修复后台菜单管理中移动菜单项到其他分组的时候，导致子菜单丢失的问题。 <a href="https://github.com/halo-dev/halo-admin/pull/422" target="_blank">halo-dev/halo-admin#422</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>更新默认主题的 submodule 提交，修复模板中部分因为数字中带逗号导致的渲染异常。 <a href="https://github.com/halo-dev/halo/pull/1682" target="_blank">halo-dev/halo#1682</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>修复评论默认头像因为修改了默认类型但 options 接口没有返回字段导致评论头像无法显示的问题。 <a href="https://github.com/halo-dev/halo/pull/1692" target="_blank">halo-dev/halo#1692</a> <a href="https://github.com/lan-yonghui" target="_blank">@lan-yonghui</a></li>
 <li>修复使用 leveldb 的情况下，解析错误而没有清空缓存导致无法正常使用系统的问题。 <a href="https://github.com/halo-dev/halo/pull/1695" target="_blank">halo-dev/halo#1695</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li>修复后台在文章编辑页面切换左侧菜单收缩的时候出现的样式异常。 <a href="https://github.com/halo-dev/halo-admin/pull/465" target="_blank">halo-dev/halo-admin#465</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>修复当前版本如果为 <code>alpha</code> 版本，安装主题无法通过版本验证的问题。 <a href="https://github.com/halo-dev/halo/pull/1705" target="_blank">halo-dev/halo#1705</a> <a href="https://github.com/JohnNiang" target="_blank">@JohnNiang</a></li>
 <li>修复评论部分因为没有添加事务，导致批量删除评论等操作时报错的问题。 <a href="https://github.com/halo-dev/halo/pull/1716" target="_blank">halo-dev/halo#1716</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li>修复后台点击后台 Halo Logo 进入开发者选项过快可能会导致计数为负的问题。 <a href="https://github.com/halo-dev/halo-admin/pull/492" target="_blank">halo-dev/halo-admin#492</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>修复后台附件列表分页之后，可能会导致无法正常更新图片 dom 导致图片显示为上一页图片的问题。 <a href="https://github.com/halo-dev/halo-admin/pull/496" target="_blank">halo-dev/halo-admin#496</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li>修复后台分类/标签/友情链接修改表单切换修改对象之后没有清除表单验证的问题。 <a href="https://github.com/halo-dev/halo-admin/pull/501" target="_blank">halo-dev/halo-admin#501</a> <a href="https://github.com/halo-dev/halo-admin/pull/503" target="_blank">halo-dev/halo-admin#503</a> <a href="https://github.com/Yorksh1re" target="_blank">@Yorksh1re</a></li>
 <li>修复当前版本如果为 <code>alpha</code> 版本，安装主题无法通过版本验证的问题。 <a href="https://github.com/halo-dev/halo/pull/1747" target="_blank">halo-dev/halo#1747</a> <a href="https://github.com/JohnNiang" target="_blank">@JohnNiang</a></li>
</ul>
<h3 id="dependencies">Dependencies</h3>
<ul>
 <li>升级 Spring Boot 版本。 <a href="https://github.com/halo-dev/halo/pull/1635" target="_blank">halo-dev/halo#1635</a> <a href="https://github.com/halo-dev/halo/pull/1677" target="_blank">halo-dev/halo#1677</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a> <a href="https://github.com/JohnNiang" target="_blank">@JohnNiang</a></li>
 <li>升级 Gradle 版本到 7.4。 <a href="https://github.com/halo-dev/halo/pull/1697" target="_blank">halo-dev/halo#1697</a> <a href="https://github.com/guqing" target="_blank">@guqing</a></li>
 <li><code>halo-dev/halo-admin</code> 常规依赖升级。 <a href="https://github.com/halo-dev/halo-admin/pull/453" target="_blank">halo-dev/halo-admin#453</a> <a href="https://github.com/halo-dev/halo-admin/pull/513" target="_blank">halo-dev/halo-admin#513</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
 <li><code>halo-dev/halo-admin</code> 修改用于切换后台样式的 less 依赖 CDN 为 unpkg。</li>
 <li>后台更新 @halo-dev/editor 版本。 <a href="https://github.com/halo-dev/halo-admin/pull/507" target="_blank">halo-dev/halo-admin#507</a> <a href="https://github.com/ruibaby" target="_blank">@ruibaby</a></li>
</ul>
<hr>
<p><a href="https://github.com/halo-dev/halo/compare/v1.4.17...v1.5.0" target="_blank">halo-dev/halo@<code>v1.4.17…v1.5.0</code></a></p>
<h2 id="%E7%9B%B8%E5%85%B3%E4%BF%A1%E6%81%AF">相关信息</h2>
<ul>
 <li>GitHub Release：<a href="https://github.com/halo-dev/halo/releases/tag/v1.5.0" target="_blank">https://github.com/halo-dev/halo/releases/tag/v1.5.0</a></li>
 <li>升级文档：<a href="https://docs.halo.run/getting-started/upgrade" target="_blank">https://docs.halo.run/getting-started/upgrade</a></li>
 <li>论坛交流：<a href="https://bbs.halo.run/d/2227-halo-150" target="_blank">https://bbs.halo.run/d/2227-halo-150</a></li>
</ul>]]></description><guid isPermaLink="false">/archives/halo-150-released</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fblog_cover_photo2022-04-01.jpg%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" type="image/jpeg" length="0"/><category>生活</category><pubDate>Fri, 25 Mar 2022 14:03:02 GMT</pubDate></item><item><title><![CDATA[2021 我与开源]]></title><link>https://guqing.io/archives/kai-yuan-2021</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=2021%20%E6%88%91%E4%B8%8E%E5%BC%80%E6%BA%90&amp;url=/archives/kai-yuan-2021" width="1" height="1" alt="" style="opacity:0;">
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fwww.githubtrends.io_wrapped_guqing_1641021482802.png&amp;size=m" alt="www.githubtrends.io_wrapped_guqing"></p>]]></description><guid isPermaLink="false">/archives/kai-yuan-2021</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fwww.githubtrends.io_wrapped_guqing_1641021482802.png&amp;size=m" type="image/jpeg" length="0"/><category>生活</category><pubDate>Sat, 1 Jan 2022 07:23:12 GMT</pubDate></item><item><title><![CDATA[如何使用不同云厂商的服务器搭建 K3S 集群]]></title><link>https://guqing.io/archives/how-to-install-k3s-cluster-using-servers-from-different-regions</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8%E4%B8%8D%E5%90%8C%E4%BA%91%E5%8E%82%E5%95%86%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%99%A8%E6%90%AD%E5%BB%BA%20K3S%20%E9%9B%86%E7%BE%A4&amp;url=/archives/how-to-install-k3s-cluster-using-servers-from-different-regions" width="1" height="1" alt="" style="opacity:0;">
<h3 id="简介">简介</h3>
<p>由于买的国内厂商打折服务器，大部分情况下都无法在同一家厂商买到多台优惠服务器，此时想搭建 K3S 集群就需要走公网，直接通过K3S 的安装方式节点之间无法通信，因此需要使用 <code>WireGuard</code>来组网。</p>
<p>在阅读本篇之前建议你先阅读 <code>WireGuard</code> 的<code>Quickstart</code>来了解它的概念，这将对接下来配置<code>WireGuard</code>十分重要:</p>
<blockquote>
 <p><a href="https://www.wireguard.com/quickstart/">https://www.wireguard.com/quickstart/</a></p>
</blockquote>
<p>本篇以两台服务器为例信息如下:</p>
<table>
 <thead>
  <tr>
   <th>集群节点类型</th>
   <th>厂商</th>
   <th>公网 IP 地址</th>
   <th>内网 IP 地址</th>
   <th>操作系统</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>master</td>
   <td>腾讯云</td>
   <td>42.xxx.xxx.60</td>
   <td>172.17.16.4</td>
   <td>CentOS Linux 7.9</td>
  </tr>
  <tr>
   <td>node-1</td>
   <td>qingcloud</td>
   <td>139.xx.xx.46</td>
   <td>10.190.19.38</td>
   <td>CentOS Linux 7.9</td>
  </tr>
 </tbody>
</table>
<p>首先会通过 <code>WireGuard</code>为两台机器创建一块虚拟网卡并指定虚拟 ip 形如：</p>
<pre><code class="language-shell">[root@k3s-master ~]# ifconfig wg0
wg0: flags=209&lt;UP,POINTOPOINT,RUNNING,NOARP&gt;  mtu 1420
        inet 192.168.2.1  netmask 255.255.255.255  destination 192.168.2.1
        unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00  txqueuelen 1000  (UNSPEC)
        RX packets 1764  bytes 441372 (431.0 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 1341  bytes 165156 (161.2 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
</code></pre>
<p>最终信息如下：</p>
<pre><code>+---------------------------------------+          +--------------------------------------------+
|                                       |          |                                            |
| Master    腾讯云  公:42.xxx.xxx.60     |          | Node-1    QingCloud    公：139.xx.xx.46     |
|                                       |          |                                            |
| 内：172.17.16.4    虚拟Ip: 192.168.2.1 |          | 内：10.190.19.38        虚拟IP: 192.168.2.2 |
|                                       |          |                                            |
+---------------------------------------+          +--------------------------------------------+
</code></pre>
<h2 id="安装wireguard">安装WireGuard</h2>
<p>在搭建跨云的 <code>k3s</code> 集群前，需要先把 <code>WireGuard</code> 安装好，<code>WireGuard</code> 对内核有要求，要升级到 <code>5.15.2-1.el7.elrepo.x86_64</code>以上</p>
<p>分别在<code>Master</code>(表示腾讯云的服务器在集群中作为master节点使用)和<code>Node-1</code>（表示QingCloud的服务器在集群中作为工作节点使用）执行如下命令以开启 IP 地址转发：</p>
<pre><code class="language-shell">echo "net.ipv4.ip_forward = 1" &gt;&gt; /etc/sysctl.conf

echo "net.ipv4.conf.all.proxy_arp = 1" &gt;&gt; /etc/sysctl.conf

sysctl -p /etc/sysctl.conf
</code></pre>
<p>对两台服务器修改主机名称</p>
<pre><code class="language-shell"># 腾讯云执行
hostnamectl  set-hostname k3s-master
# QingCloud执行
hostnamectl  set-hostname k3s-node-1
</code></pre>
<h3 id="修改-iptables-以允许nat">修改 iptables 以允许NAT</h3>
<p>对两台服务器都进行如下设置，添加 iptables 规则，允许本机的 NAT 转换，执行前需要将如下的<code>192.168.1.1/24</code>修改为在简介中决定设定的虚拟ip:</p>
<pre><code>iptables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables -A FORWARD -i wg0 -o wg0 -m conntrack --ctstate NEW -j ACCEPT
iptables -t nat -A POSTROUTING -s 192.168.1.1/24 -o eth0 -j MASQUERADE
</code></pre>
<p>释意:</p>
<p><code>wg0</code>: 为虚拟网卡名称,两台服务器可以都叫<code>wg0</code></p>
<p><code>192.168.1.1</code>: 为虚拟 IP 地址段, <code>Master</code>设置为<code>192.168.2.1</code>, <code>Node-1</code>改为<code>192.168.2.2</code></p>
<p><code>eth0</code>: 为服务器的物理网卡</p>
<h3 id="升级内核">升级内核</h3>
<p>配置好 iptables 只有升级内核以安装<code>WireGuard</code>，否则安装<code>WireGuard</code>时会出现如下错误:</p>
<pre><code>[#] ip link add wg0 type wireguard
RTNETLINK answers: Operation not supported
Unable to access interface: Protocol not supported
[#] ip link delete dev wg0
Cannot find device "wg0"
</code></pre>
<p>在所有节点都执行如下操作：</p>
<ol>
 <li>载入公钥</li>
</ol>
<pre><code class="language-shell">rpm --import https://www.elrepo.org/RPM-GPG-KEY-elrepo.org
</code></pre>
<ol start="2">
 <li>升级安装 <code>elrepo</code></li>
</ol>
<pre><code class="language-shell">rpm -Uvh http://www.elrepo.org/elrepo-release-7.0-5.el7.elrepo.noarch.rpm
</code></pre>
<ol start="3">
 <li>载入 <code>elrepo-kernel</code>元数据</li>
</ol>
<pre><code class="language-shell">yum --disablerepo=\* --enablerepo=elrepo-kernel repolist
</code></pre>
<ol start="4">
 <li>安装最新版本的内核</li>
</ol>
<pre><code class="language-shell">yum --disablerepo=\* --enablerepo=elrepo-kernel install  kernel-ml.x86_64  -y
</code></pre>
<ol start="5">
 <li>删除旧版本工具包</li>
</ol>
<pre><code class="language-shell">yum remove kernel-tools-libs.x86_64 kernel-tools.x86_64  -y
</code></pre>
<ol start="6">
 <li>然后修改默认内核版本，首先查看当前实际启动顺序</li>
</ol>
<pre><code class="language-shell">grub2-editenv list
</code></pre>
<ol start="7">
 <li>查看内核插入顺序</li>
</ol>
<pre><code>grep "^menuentry" /boot/grub2/grub.cfg | cut -d "'" -f2
</code></pre>
<ol start="8">
 <li>设置默认启动, 将<code>CentOS Linux (5.15.2-1.el7.elrepo.x86_64) 7 (Core)</code>改为步骤6中列出的最新内核信息</li>
</ol>
<pre><code class="language-shell">grub2-set-default 'CentOS Linux (5.15.2-1.el7.elrepo.x86_64) 7 (Core)'
</code></pre>
<ol start="9">
 <li>重新创建内核配置</li>
</ol>
<pre><code class="language-shell">grub2-mkconfig -o /boot/grub2/grub.cfg
</code></pre>
<ol start="10">
 <li>重启服务器</li>
</ol>
<pre><code class="language-shell">reboot
</code></pre>
<ol start="11">
 <li>验证当前内核版本</li>
</ol>
<pre><code class="language-shell">uname -r
</code></pre>
<h3 id="安装wireguard-1">安装WireGuard</h3>
<p>在两台服务器都需要执行如下操作，安装流程非常简单，CentOS 内核更新到 5.15.2以上版本后，其中就已经包含了 <code>WireGuard</code> 的内核模块，只需要安装 <code>wireguard-tools</code> 包即可</p>
<pre><code class="language-shell">yum install epel-release https://www.elrepo.org/elrepo-release-7.el7.elrepo.noarch.rpm
</code></pre>
<pre><code class="language-shell">yum install yum-plugin-elrepo kmod-wireguard wireguard-tools -y
</code></pre>
<h3 id="配置-wireguard">配置 WireGuard</h3>
<p><code>wireguard-tools</code> 包提供了我们所需的工具 <code>wg</code> 和 <code>wg-quick</code>，可以使用它们来分别完成手动部署和自动部署。</p>
<p>先按照<a href="https://www.wireguard.com/quickstart/">官方文档</a>描述的形式，生成<code>Master</code>节点和<code>Node-1</code>节点服务器 用于加密解密的密钥</p>
<h4 id="在master节点操作">在Master节点操作</h4>
<pre><code class="language-shell">wg genkey | tee privatekey | wg pubkey &gt; publickey
</code></pre>
<p>然后在当前目录下就生成了 <code>privatekey</code> 和 <code>publickey</code> 两个文件</p>
<p>注意：</p>
<p>密钥是配置到本机的，而公钥是配置到其它机器里的,结果示例如下：</p>
<pre><code>cat privatekey publickey
EMWcI01iqM4zkb7xfbaaxxxxxxxxDo2GJUA=
0ay8WfGOIHndWklSIVBqrsp5LDWxxxxxxxxxxxxxxQ=
</code></pre>
<p>然后编写配置文件，以供 <code>wg-quick</code> 使用</p>
<pre><code class="language-shell">vim /etc/wireguard/wg0.conf
</code></pre>
<p>然后写入如下内容</p>
<pre><code>[Interface]
PrivateKey = EMWcI01iqM4zkb7xfbaaxxxxxxxxDo2GJUA=
Address = 192.168.2.1
ListenPort = 5418

[Peer]
PublicKey = Node-1服务器的 publickey后续Node-1生成后再改
EndPoint = 139.xx.xx.46:5418
AllowedIPs = 192.168.2.2/32
</code></pre>
<h4 id="在node-1节点操作">在Node-1节点操作</h4>
<pre><code class="language-shell">wg genkey | tee privatekey | wg pubkey &gt; publickey
</code></pre>
<p>查看结果</p>
<pre><code class="language-shell">[root@k3s-node-1 ~] cat privatekey publickey
QGl72V7FyFokmF15cPGLcLWkOOBV+CHw6KWL+MtUj2o=
b/yQJHLEo1NisJcE3eBewjX+wFBDROQ3njGRQhZpADQ=
</code></pre>
<p>然后编写配置文件，以供 <code>wg-quick</code> 使用</p>
<pre><code class="language-shell">vim /etc/wireguard/wg0.conf
</code></pre>
<p>然后写入如下内容</p>
<pre><code>[Interface]
PrivateKey = QGl72V7FyFokmF15cPGLcLWkOOBV+CHw6KWL+MtUj2o=
Address = 192.168.2.2
ListenPort = 5418

[Peer]
# PublicKey为Master机器的
PublicKey = 0ay8WfGOIHndWklSIVBqrsp5LDWxxxxxxxxxxxxxxQ=
EndPoint = 42.xxx.xxx.60:5418
AllowedIPs = 192.168.2.1/32
</code></pre>
<p>然后到Master机器使用<code>vim /etc/wireguard/wg0.conf</code>编辑<code>Peer</code>下的<code>PublicKey</code>为<code>Node-1</code>的<code>PublicKey</code></p>
<h4 id="开放端口">开放端口</h4>
<p>到云厂商的防火墙（安全组）规则配置处开放 <code>5418</code> 端口，下行规则，协议为UDP</p>
<h4 id="配置说明">配置说明</h4>
<p>Interface: 小节是属于本机的配置.</p>
<p>Address: 是分配给本机在简介部分约定的虚拟 IP，</p>
<p>ListenPort: 是主机之间通讯使用的端口，是 UDP 协议的。</p>
<p>Peer: 是属于需要通信的服务器的信息，有多少需要通信的主机，就添加多少个 Peer 小节，更多内容参阅WireGuard官网配置。</p>
<p>EndPoint: 由于这里是跨厂商所以这里的EndPoint为服务器的公网 IP 与 WireGuard 监听的 UDP 端口，如果你的机器通过内网也能通信，直接用内网 IP 也可以，当然要注意这个 IP 需要所有加入该局域网的主机都能通信才行。</p>
<p>AllowedIPs: 是指本机发起连接的哪些 IP 应该将流量转发到这个节点，比如给主机 B 分配了内网 IP 192.168.1.2，那么在主机 A 上发送到 192.168.1.2 的数据包，都应该转发到这个 EndPoint 上，它其实起的是一个过滤作用。而且多个 Peer 时，这里配置的 IP 地址不能有冲突。</p>
<p>各个节点生产的 privatekey 和 publickey 分别如下</p>
<p>各个节点配置文件如下:</p>
<p>在Master 节点<code>cat /etc/wireguard/wg0.conf</code>:</p>
<pre><code>[Interface]
PrivateKey = EMWcI01iqM4zkb7xfbaaxxxxxxxxDo2GJUA=
Address = 192.168.2.1
ListenPort = 5418

[Peer]
PublicKey = b/yQJHLEo1NisJcE3eBewjX+wFBDROQ3njGRQhZpADQ=
EndPoint = 139.xx.xx.46:5418
AllowedIPs = 192.168.2.2/32
</code></pre>
<p>在Node-1节点<code>cat /etc/wireguard/wg0.conf</code>:</p>
<pre><code>[Interface]
PrivateKey = QGl72V7FyFokmF15cPGLcLWkOOBV+CHw6KWL+MtUj2o=
Address = 192.168.2.2
ListenPort = 5418

[Peer]
PublicKey = 0ay8WfGOIHndWklSIVBqrsp5LDWxxxxxxxxxxxxxxQ=
EndPoint = 42.xxx.xxx.60:5418
AllowedIPs = 192.168.2.1/32
</code></pre>
<h3 id="启动-wireguard">启动 WireGuard</h3>
<p>边写好配置文件后，对所有节点执行 <code>wg-quick</code> 工具来创建虚拟网卡</p>
<pre><code class="language-shell">wg-quick up wg0
</code></pre>
<p>上面命令中的 <code>wg0</code> 对应的是 <code>/etc/wireguard/wg0.conf</code> 这个配置文件，其自动创建的网卡设备，名字就是 <code>wg0</code>。</p>
<p>创建好虚拟网卡后测试是否连通</p>
<p>例如在 Master 节点ping Node-1的虚拟ip要能 ping 通否则请检查配置是否正确</p>
<pre><code class="language-shell">[root@k3s-master ~]# ping 139.xx.xx.46
PING 139.xx.xx.46 (139.xx.xx.46) 56(84) bytes of data.
64 bytes from 139.xx.xx.46: icmp_seq=1 ttl=50 time=48.8 ms
64 bytes from 139.xx.xx.46: icmp_seq=2 ttl=50 time=45.1 ms
^C
--- 139.xx.xx.46 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1000ms
rtt min/avg/max/mdev = 45.119/47.008/48.897/1.889 ms
</code></pre>
<p>在Node-1上ping Master亦如是</p>
<p>然后就可以使用 <code>wg</code> 命令来查看通信情况</p>
<pre><code>[root@k3s-master ~]# wg
interface: wg0
  public key: 0ay8WfGOIHndWklSIVBqrsp5LDWxxxxxxxxxxxxxxQ=
  private key: (hidden)
  listening port: 5418

peer: 0f0dn60+tBUfYgzw7rIihKbqxxxxxxxxa6Wo=
  endpoint: 122.xx.xx.155:5418
  allowed ips: 192.168.1.3/32
  latest handshake: 3 minutes, 3 seconds ago
  transfer: 35.40 KiB received, 47.46 KiB sent
</code></pre>
<h3 id="自动化配置">自动化配置</h3>
<p>系统重启后，<code>WireGuard</code> 创建的网卡设备就会丢失，<code>WireGuard</code>提供了自动化的脚本来解决这件事，在两台服务器都执行如下操作</p>
<pre><code class="language-shell">systemctl enable wg-quick@wg0
</code></pre>
<p>使用上述命令生成 systemd 守护脚本，开机会自动运行 up 指令。</p>
<h3 id="配置热重载">配置热重载</h3>
<p>wg-quick 并未提供重载相关的指令，但是提供了 <code>strip</code> 指令，可以将 conf 文件转换为 wg 指令可以识别的格式。</p>
<pre><code class="language-shell">wg syncconf wg0 &lt;(wg-quick strip wg0)
</code></pre>
<p>即可实现热重载。</p>
<p>完成 <code>WireGuard</code> 的安装配置以后，接下来就可以安装 k3s 的集群了。</p>
<h2 id="安装-k3s-集群">安装 K3S 集群</h2>
<h3 id="master-节点安装">Master 节点安装</h3>
<pre><code class="language-shell">export MASTER_EXTERNAL_IP=42.xx.xx.60
export MASTER_VIRTUAL_IP=192.168.2.1
</code></pre>
<p>安装 K3S</p>
<pre><code>curl -sfL http://rancher-mirror.cnrancher.com/k3s/k3s-install.sh | INSTALL_K3S_MIRROR=cn sh  -s -  --node-external-ip $MASTER_EXTERNAL_IP --advertise-address $MASTER_EXTERNAL_IP --node-ip $MASTER_VIRTUAL_IP --flannel-iface wg0
</code></pre>
<p>参数说明：</p>
<ul>
 <li>--node-external-ip: 42.xx.xx.60 设置为当前节点的外部 IP</li>
 <li>--advertise-address: 42.xx.xx.12 用于设置 kubectl 工具以及子节点进行通讯使用的地址，可以是 IP，也可以是域名，在创建 apiserver 证书时会将此设置到有效域中。</li>
 <li>--node-ip： 10.20.30.1 上文中约定的的虚拟ip</li>
 <li>--flannel-iface wg0 wg0 是 WireGuard 创建的网卡设备，我需要使用虚拟局域网来进行节点间的通信，所以这里需要指定为 wg0。</li>
</ul>
<p>由于 WireGuard 的所有流量都是加密传输的，通过它来进行节点间的通信，就已经能够保证通信安全，也就没有必要改用其它的 CNI 驱动，使用默认的就可以了。</p>
<p>在主节点执行上述命令后，一分钟不到就可以看到脚本提示安装完成。通过命令查看下主控端的运行情况</p>
<pre><code>systemctl status k3s
</code></pre>
<p>如果运行正常，那么就看看容器的运行状态是否正常</p>
<pre><code>kubectl get pods -A
</code></pre>
<p><code>-A</code> 参数用于查看所有命名空间，如果容器都处于 running 状态，那么安装就成功了，接下来要可以添加被控节点。</p>
<h3 id="agent-安装">Agent 安装</h3>
<p>有了上述安装主控的经验，安装 work 节点更加简单，参数需要一定的调整</p>
<p>在Node-1节点 执行</p>
<pre><code class="language-shell">export MASTER_VIRTUAL_IP=192.168.2.1
export NODE_EXTERNAL_IP=139.xx.xx.46
export NODE_VIRTUAL_IP=192.168.2.2
export K3S_TOKEN=K1031c4c705056a0752a09801e99b56cf0f89571b4d678a138a44deb49a0e1d9722::server:5d89600a5330100489c4c3dbcce1dec0
</code></pre>
<p>将 <code>K3S_TOKEN</code> 的值改为在Master节点执行<code>/var/lib/rancher/k3s/server/node-token</code>后得到的值</p>
<p>将<code>NODE_EXTERNAL_IP</code>设置为Node-1的公网ip</p>
<p>安装</p>
<pre><code>curl -sfL http://rancher-mirror.cnrancher.com/k3s/k3s-install.sh | INSTALL_K3S_MIRROR=cn K3S_URL=https://$MASTER_VIRTUAL_IP:6443 K3S_TOKEN=$K3S_TOKEN sh -s - --node-external-ip $NODE_EXTERNAL_IP --node-ip $NODE_VIRTUAL_IP --flannel-iface wg0
</code></pre>
<p>执行后稍等一会，安装成功后，照例查看服务运行状态</p>
<pre><code>systemctl status k3s-agent
</code></pre>
<p>都安装好以后 在 Master 节点检查,可以看到两个节点</p>
<pre><code>kubectl get nodes -o wide 
</code></pre>
<p>至此 多云 K3S 集群已经搭建完毕。</p>
<p>参考文档:</p>
<p>[1] <a href="https://blog.51cto.com/cnsre/4637134">cnsre运维博客之搭建 K3S 集群</a></p>
<p>[2] <a href="https://www.wireguard.com/quickstart/">WireGuard官方文档</a></p>
<p>[3] <a href="https://zh.wikipedia.org/wiki/%E7%BD%91%E7%BB%9C%E5%9C%B0%E5%9D%80%E8%BD%AC%E6%8D%A2">Network Address Translation</a></p>
<p>[4] <a href="https://github.com/k3s-io/k3s">K3S</a></p>
<p>[5] <a href="https://www.kernel.org/doc/htmldocs/networking/API-struct-net-device.html">struct-net-device</a></p>]]></description><guid isPermaLink="false">/archives/how-to-install-k3s-cluster-using-servers-from-different-regions</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fhalloween-ghost-wallpaper_1639652375977.jpg%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" type="image/jpeg" length="0"/><category>Linux</category><pubDate>Thu, 16 Dec 2021 10:56:00 GMT</pubDate></item><item><title><![CDATA[Kubenetes使用及配置模板]]></title><link>https://guqing.io/archives/how-to-use-kubenetes</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=Kubenetes%E4%BD%BF%E7%94%A8%E5%8F%8A%E9%85%8D%E7%BD%AE%E6%A8%A1%E6%9D%BF&amp;url=/archives/how-to-use-kubenetes" width="1" height="1" alt="" style="opacity:0;">
<blockquote>
 <p>minikube version: v1.16.0</p>
</blockquote>
<h2 id="pod的分类">Pod的分类</h2>
<p>Pod的分类：</p>
<ul>
 <li>自主式Pod： Pod退出了，此类型的Pod不会被创建</li>
 <li>控制器管理的Pod：在控制器的生命周期里，始终要维持Pod的副本数目</li>
</ul>
<h2 id="init容器">Init容器</h2>
<p><code>init c</code>探测模板</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: myapp-pod
  labels:
    app: myapp
spec:
  containers:
    - name: myapp-container
      image: busybox
      command: ["sh", "-c", "echo The app is running! &amp;&amp; sleep 3600"]
  initContainers:
    - name: init-myservice
      image: busybox
      # myservice对应svc
      command:
        [
          "sh",
          "-c",
          "until nslookup myservice;do echo waiting for myservice;sleep 2;done;",
        ]
    - name: init-mydb
      image: busybox
      # 解析mydb的主机名与svc有关如果有mydb对应的svc那么k8s内部的dns会将mydb这个svc解析为对应的ip
      command:
        [
          "sh",
          "-c",
          "until nslookup mydb;do echo waiting for mydb;sleep 2;done;",
        ]

</code></pre>
<p>创建以下两个svc(service的简称)</p>
<p>myservice</p>
<pre><code class="language-yaml">kind: Service
apiVersion: v1
metadata:
  name: myservice
spec:
  ports:
    - protocol: TCP
      port: 80
      targetPort: 9376
</code></pre>
<p>mydb</p>
<pre><code class="language-yaml">kind: Service
apiVersion: v1
metadata:
  name: mydb
spec:
  ports:
    - protocol: TCP
      port: 80
      targetPort: 9377
</code></pre>
<h2 id="探针">探针</h2>
<p>探针是由kubelet对容器执行的定期诊断，要执行诊断，kubelet调用由容器实现的Handler。有三种类型的处理程序</p>
<ul>
 <li>ExecAction: 在容器内执行指定命令。如果命令退出时返回码为0则认为诊断成功</li>
 <li>TCPSocketAction: 对指定端口上的容器的IP地址进行TCP检查。如果端口打开，则诊断被认为是成功的。</li>
 <li>HTTPGetAction: 对执行的端口和路径上的容器IP地址执行HTTP Get请求。如果响应的状态码大于的等于200且小于400,则诊断被认为是成功的</li>
</ul>
<p>每次探针都将获得以下三种结果之一</p>
<ul>
 <li>成功: 容器通过了诊断。</li>
 <li>失败：容器未通过诊断</li>
 <li>未知：诊断失败，因此不会采取任何行动</li>
</ul>
<p>探测方案有两种</p>
<ul>
 <li>LivenessProbe: 指示容器是否正在运行。如果存活探测失败，则kubectl会杀死容器，并且容器将受到其重启策略的影响。如果容器不提供存活探针，则默认状态为Success</li>
 <li>ReadinessProbe: 指示容器是否准备好服务请求。如果探测失败，端点控制器将从与Pod匹配的所有Service的端点中删除该Pod的IP地址。初始延迟之前的就绪状态默认为Failure。如果容器不提供就绪探针，则默认状态为Success</li>
</ul>
<h3 id="探针就绪检测">探针就绪检测</h3>
<p>ReadinessProbe-httpget</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: readiness-httpget-pod
  namespace: default
spec:
  containers:
    - name: readiness-httpget-container
      image: nginx:1.19.6
      imagePullPolicy: IfNotPresent
      readinessProbe:
        httpGet:
          port: 80
          path: /index1.html
        initialDelaySeconds: 1
        periodSeconds: 3
</code></pre>
<h3 id="生存检测">生存检测</h3>
<p>livenessProbe-exec</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: liveness-exec-pod
spec:
  containers:
    - name: liveness-exec-container
      image: nginx:1.19.6
      imagePullPolicy: IfNotPresent
      # 启动时创建/temp/live文件经过60s后删除它
      # 在liveness检测时启动1分钟内文件都存在容器正常
      # 1分钟后文件不存在，liveness检测不通过容器就会被k8s杀死,pod里的容器死亡pod就会重启
      command:
        [
          "/bin/sh",
          "-c",
          "touch /tmp/live;sleep 60; rm -rf /tmp/live; sleep 3600;",
        ]
      livenessProbe:
        exec:
          # 测试文件是否存在，如果存在返回值为0表正常
          command: ["test", "-e", "/tmp/live"]
        initialDelaySeconds: 1
        periodSeconds: 3

</code></pre>
<p>livenessProbe-httpget</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: liveness-httpget-pod
spec:
  containers:
    - name: liveness-httpget-container
      image: nginx:1.19.6
      imagePullPolicy: IfNotPresent
      ports:
        - name: http
          containerPort: 80
      livenessProbe:
        httpGet:
          port: http
          path: /index.html
        initialDelaySeconds: 1
        periodSeconds: 3
</code></pre>
<p>livenessProbe-tcp</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: liveness-tcp-pod
spec:
  containers:
    - name: nginx
      image: nginx:1.19.6
      imagePullPolicy: IfNotPresent
      livenessProbe:
        tcpSocket:
          # 监听80端口是否可连接
          port: 80
        timeoutSeconds: 1
        initialDelaySeconds: 5
        periodSeconds: 3
</code></pre>
<p><code>Initc</code>、<code>readiness</code>、<code>liveness</code>可以相互配合使用</p>
<h2 id="启动退出动作">启动退出动作</h2>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: lifecycle-demo
spec:
  containers:
    - name: nginx
      image: nginx:1.19.6
      imagePullPolicy: IfNotPresent
      lifecycle:
        # 启动之后
        postStart:
          exec:
            command:
              [
                "/bin/sh",
                "-c",
                "echo Hello from the postStart handler &gt; /usr/share/message.txt",
              ]
        # 停止之前可以料理后事
        preStop:
          exec:
            command: ["/bin/sh", "-c", "echo preStop死前料理后事"]
</code></pre>
<p>Status状态有以下几种值</p>
<ul>
 <li>
  <p>Pending（挂起）: Pod已被<code>Kubernetes</code>系统接受，但有一个或多个容器镜像尚未创建。等待时间包括调度Pod的时间和通过网络下载镜像的时间</p></li>
 <li>
  <p>Running（运行中）：该Pod已经绑定到了一个节点上，Pod中所有的容器都已被创建。至少有一个容器正在运行。或正处于启动或重启状态</p></li>
 <li>
  <p>Succeeded（成功）：Pod中的所有容器都被成功终止，并且不会再重启</p></li>
 <li>
  <p>Failed（失败）：Pod中的所有容器都已终止了，并且至少有一个容器是因为失败终止。也就是说，容器以非0状态退出或者被系统终止</p></li>
 <li>
  <p>Unknown（未知）：因为某些原因无法取得Pod的状态，通常是因为与Pod所在主机通信失败</p></li>
</ul>
<h2 id="replica-sets">Replica Sets</h2>
<p><code>ReplicaSet</code>（RS）是Replication Controller（RC）的升级版本。<code>ReplicaSet</code> 和 <a href="http://docs.kubernetes.org.cn/437.html">Replication Controller</a>之间的唯一区别是对选择器的支持。<code>ReplicaSet</code>支持<a href="http://docs.kubernetes.org.cn/247.html#Labels">labels user guide</a>中描述的set-based选择器要求， 而Replication Controller仅支持equality-based的选择器要求。</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: frontend
spec:
  # 指定rs副本数
  replicas: 3
  selector:
    matchLabels:
      tier: frontend
  template:
    metadata:
      labels:
        tier: frontend
    spec:
      containers:
        - name: nginx
          image: nginx:1.19.6
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 80
</code></pre>
<p>RS与Deployment的关联</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Frs-deployment-relaction_1639555086670.jpeg&amp;size=m" alt="img"></p>
<h2 id="deployment">Deployment</h2>
<p>Deployment为<a href="http://docs.kubernetes.org.cn/312.html">Pod</a>和<a href="http://docs.kubernetes.org.cn/314.html">Replica Set</a>（升级版的 <a href="http://docs.kubernetes.org.cn/437.html">Replication Controller</a>）提供声明式更新。</p>
<p>你只需要在 Deployment 中描述您想要的目标状态是什么，Deployment controller 就会帮您将 Pod 和ReplicaSet 的实际状态改变到您的目标状态。您可以定义一个全新的 Deployment 来创建 ReplicaSet 或者删除已有的 Deployment 并创建一个新的来替换。</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  replicas: 3
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx:1.18
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 80
</code></pre>
<p>执行</p>
<pre><code class="language-shell"># record参数会记录执行过程
kubectl apply -f deployment-demo.yaml --record
</code></pre>
<p><code>Deployment</code>使用申明式定义方法所以用<code>apply</code>创建</p>
<p>查看运行状态</p>
<pre><code class="language-shell">$ kubectl get deployment

NAME               READY   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   3/3     3            3           3m37s
</code></pre>
<p>创建Deployment会被创建RS可以通过以下命令验证</p>
<pre><code class="language-shell">$ kubectl get rs

NAME                          DESIRED   CURRENT   READY   AGE
nginx-deployment-76ccf9dd9d   3         3         3       4m14s
</code></pre>
<p>扩容</p>
<pre><code class="language-shell">$ kubectl scale deployment nginx-deployment --replicas=4
</code></pre>
<p>更新镜像</p>
<pre><code class="language-shell">$ kubectl set image deployment/nginx-deployment nginx=nginx:1.19
</code></pre>
<p>执行过程,会看到多出一个<code>RS</code></p>
<pre><code class="language-shell">$ kubectl set image deployment/nginx-deployment nginx=nginx:1.19
deployment.apps/nginx-deployment image updated
$ kubectl get deployment
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   3/3     1            3           105s
$ kubectl get rs
NAME                          DESIRED   CURRENT   READY   AGE
nginx-deployment-67dfd6c8f9   3         3         3       111s
nginx-deployment-7cf55fb7bb   1         1         0       14s
$ kubectl get rs
NAME                          DESIRED   CURRENT   READY   AGE
nginx-deployment-67dfd6c8f9   0         0         0       2m
nginx-deployment-7cf55fb7bb   3         3         3       23
</code></pre>
<p>回滚(默认回滚到上一个版本)</p>
<pre><code class="language-shell">$ kubectl rollout undo deployment/nginx-deployment
</code></pre>
<p>其他常用命令</p>
<pre><code class="language-shell"># 查看回滚状态
$ kubectl rollout status deployment/nginx-deployment
# 查看回滚历史
$ kubectl rollout history deployment/nginx-deployment
# 回退到指定版本通过--to-reversion指定
$ kubectl rollout undo deployment/nginx-deployment --to-reversion=2
# 暂停deployment的更新
$ kubectl rollout pause deployment/nginx-deployment
</code></pre>
<h2 id="demonset">DemonSet</h2>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: DaemonSet
metadata:
  # step1
  name: daemonset-example
  labels:
    app: daemonset-demo
spec:
  selector:
    matchLabels:
      # 与step1保持一致
      name: daemonset-example
  template:
    metadata:
      labels:
        name: daemonset-example
    spec:
      containers:
        - name: nginx
          image: nginx:1.19.6
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 80
</code></pre>
<h2 id="job">Job</h2>
<pre><code class="language-yaml">apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  template:
    metadata:
      name: pi
    spec:
      containers:
        - name: pi-container
          image: perl
          # 计算圆周率
          command: ["perl", "-Mbignum=bpi", "-wle", "print bpi(2000)"]
      restartPolicy: Never
</code></pre>
<p>查看输出日志就能看到2000位的圆周率输出</p>
<pre><code class="language-shell">$ kubectl logs job/pi
</code></pre>
<h2 id="cronjob">CronJob</h2>
<p>声明式使用<code>apply</code>创建</p>
<p>创建job的操作应该是幂等的</p>
<pre><code class="language-yaml">apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: hello
spec:
  # 分 时 日 月 周
  schedule: "*/1 * * * *"
  jobTemplate:
    # job
    spec:
      template:
        # pod
        spec:
          containers:
            - name: hello
              image: busybox:1.32
              args:
                - /bin/sh
                - -c
                - date;echo Hello from the Kubernetes cluster
          restartPolicy: Never
</code></pre>
<p>查看cronjob</p>
<pre><code class="language-shell">$ kubectl get cronjob
</code></pre>
<h2 id="svc">SVC</h2>
<p>Deployment与SVC绑定演示</p>
<p>创建Deployment</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
      release: stabel
  template:
    metadata:
      labels:
        app: myapp
        release: stabel
        env: test
    spec:
      containers:
        - name: myapp
          image: wangyanglinux/myapp:v2
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 80
</code></pre>
<p>创建Service</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
  name: myapp
spec:
  type: ClusterIP
  # 通过标签匹配pod
  selector:
    app: myapp
    release: stabel
  ports:
    - name: http
      port: 80
      targetPort: 80
</code></pre>
<h3 id="headlessservice">HeadlessService</h3>
<p>有时不需要或不想要负载均衡以及单独的Service IP。遇到这种情况，可以通过指定Cluster IP(spec.clusterIP)的值为None来创建Headless Service。这类Service并不会分配Cluster IP， kube-proxy不会处理他们，而且平台也不会为他们进行负载均衡和路由</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
  name: myapp-headless
  namespace: default
spec:
  selector:
    app: myapp
  clusterIP: "None"
  ports:
    - port: 80
      targetPort: 80
</code></pre>
<p>创建svc后查看输出像这样</p>
<pre><code>$ kubectl get svc
NAME             TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
kubernetes       ClusterIP   10.96.0.1       &lt;none&gt;        443/TCP   23h
myapp-headless   ClusterIP   None            &lt;none&gt;        80/TCP    26s
</code></pre>
<p>SVC一旦创建成功会写入到<code>coredns</code>中去，写入格式为<code>svc名称.所属名称空间名.集群域名</code>可以获取K8S的dns地址</p>
<pre><code class="language-shell"># 如果访问不到 进入minkube容器内部访问 执行: minikube ssh
$ kubectl get pod -n kube-system -o wide
</code></pre>
<p>输出像这样</p>
<pre><code>NAME                               READY   STATUS    RESTARTS   AGE   IP             NODE       NOMINATED NODE   READINESS GATES
coredns-54d67798b7-5trfs           1/1     Running   1          23h   172.17.0.2     minikube   &lt;none&gt;           &lt;none&gt;
etcd-minikube                      1/1     Running   1          23h   192.168.49.2   minikube   &lt;none&gt;           &lt;none&gt;
kube-apiserver-minikube            1/1     Running   1          23h   192.168.49.2   minikube   &lt;none&gt;           &lt;none&gt;
kube-controller-manager-minikube   1/1     Running   1          23h   192.168.49.2   minikube   &lt;none&gt;           &lt;none&gt;
kube-proxy-zm25c                   1/1     Running   1          23h   192.168.49.2   minikube   &lt;none&gt;           &lt;none&gt;
kube-scheduler-minikube            1/1     Running   1          23h   192.168.49.2   minikube   &lt;none&gt;           &lt;none&gt;
storage-provisioner                1/1     Running   2          23h   192.168.49.2   minikube   &lt;none&gt;           &lt;none&gt;
</code></pre>
<p>可以看到<code>coredns</code></p>
<pre><code class="language-shell"># 通过coredns ip进行svc解析
dig -t A myapp-headless.default.svc.cluster.local. @172.17.0.2
</code></pre>
<p>输出结果像这样</p>
<pre><code class="language-shell">docker@minikube:~$ dig -t A myapp-headless.default.svc.cluster.local. @172.17.0.2

; &lt;&lt;&gt;&gt; DiG 9.16.1-Ubuntu &lt;&lt;&gt;&gt; -t A myapp-headless.default.svc.cluster.local. @172.17.0.2
;; global options: +cmd
;; Got answer:
;; WARNING: .local is reserved for Multicast DNS
;; You are currently testing what happens when an mDNS query is leaked to DNS
;; -&gt;&gt;HEADER&lt;&lt;- opcode: QUERY, status: NOERROR, id: 34601
;; flags: qr aa rd; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
; COOKIE: 1a5ce669b9476b67 (echoed)
;; QUESTION SECTION:
;myapp-headless.default.svc.cluster.local. IN A

;; ANSWER SECTION:
myapp-headless.default.svc.cluster.local. 30 IN	A 172.17.0.5
myapp-headless.default.svc.cluster.local. 30 IN	A 172.17.0.4
myapp-headless.default.svc.cluster.local. 30 IN	A 172.17.0.3

;; Query time: 3 msec
;; SERVER: 172.17.0.2#53(172.17.0.2)
;; WHEN: Wed Jan 20 07:55:14 UTC 2021
;; MSG SIZE  rcvd: 249
</code></pre>
<h3 id="nodeport">NodePort</h3>
<p>将服务暴露给外部用户，nodePort的原理在于在node上开了一个端口，将该端口的流量导入到kube-proxy，然后由kube-proxy进一步导给对应的pod</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
  name: myapp
  namespace: default
spec:
  type: NodePort
  selector:
    app: myapp
  ports:
    - name: http
      port: 80
      targetPort: 80
</code></pre>
<h3 id="loadbalancer">LoadBalancer</h3>
<p>LoadBalancer和NodePort其实是同一种方式，区别在于LoadBalancer比NodePort多了一步，就是可以调用cloud provider去创建LB来向节点导流</p>
<p>ExternalName</p>
<p>这种类型的Service通过返回CNAME和它的值，可以将服务映射到externalName字段的内容例如:k8s.guqing.xyz。ExternalName Service是Service的特例，它没有selector,也没有定义任何的端口Endpoint。相反的，对于运行在集群外部的服务，它通过返回该外部服务的别名这种方式提供服务</p>
<pre><code class="language-yaml">kind: Service
apiVersion: v1
metadata:
  name: my-service-1
  namespace: default
spec:
  type: ExternalName
  externalName: my.database.example.com
</code></pre>
<p>当查询主机my-service.default.svc.cluster.local（格式为:SVC_NAME.NAMESPACE.svc.cluster.local）时，集群的DNS服务将返回一个值 my.database.example.com的CNAME记录。访问这个服务的工作方式和其他相同，唯一不同的是重定向发生在DNS层，而且不会进行代理或转发</p>
<p>创建svc如下</p>
<pre><code class="language-shell">$ kubectl get svc

NAME           TYPE           CLUSTER-IP      EXTERNAL-IP               PORT(S)        AGE
kubernetes     ClusterIP      10.96.0.1       &lt;none&gt;                    443/TCP        3d
my-service-1   ExternalName   &lt;none&gt;          my.database.example.com   &lt;none&gt;         3s
</code></pre>
<p>解析过程如下</p>
<pre><code class="language-shell">docker@minikube:~$ dig -t A my-service-1.default.svc.cluster.local. @172.17.0.2

; &lt;&lt;&gt;&gt; DiG 9.16.1-Ubuntu &lt;&lt;&gt;&gt; -t A my-service-1.default.svc.cluster.local. @172.17.0.2
;; global options: +cmd
;; Got answer:
;; WARNING: .local is reserved for Multicast DNS
;; You are currently testing what happens when an mDNS query is leaked to DNS
;; -&gt;&gt;HEADER&lt;&lt;- opcode: QUERY, status: NOERROR, id: 24122
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
; COOKIE: 9ba11bfc7ec49fd9 (echoed)
;; QUESTION SECTION:
;my-service-1.default.svc.cluster.local.	IN A

;; ANSWER SECTION:
my-service-1.default.svc.cluster.local.	30 IN CNAME my.database.example.com.

;; Query time: 1007 msec
;; SERVER: 172.17.0.2#53(172.17.0.2)
;; WHEN: Fri Jan 22 08:53:19 UTC 2021
;; MSG SIZE  rcvd: 154
</code></pre>
<h3 id="ingress-nginx">Ingress-Nginx</h3>
<p><a href="https://github.com/kubernetes/ingress-nginx">github地址</a></p>
<p>Here is a simple example where an Ingress sends all its traffic to one Service:</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fimage-20210122171022997_1639555084217.png%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" alt="image-20210122171022997"></p>
<p>minikube安装<code>ingress-nginx</code></p>
<pre><code class="language-shell">$ minikube addons enable ingress
</code></pre>
<p><a href="https://kubernetes.github.io/ingress-nginx/deploy/">k8s安装参考官网</a></p>
<pre><code class="language-shell">$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v0.43.0/deploy/static/provider/baremetal/deploy.yaml
</code></pre>
<p>验证安装</p>
<pre><code class="language-shell">$ kubectl get pods -n ingress-nginx
</code></pre>
<h4 id="ingress-http代理访问">Ingress HTTP代理访问</h4>
<p>deployment</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-dm
spec:
  replicas: 2
  selector:
    matchLabels:
      name: nginx
  template:
    metadata:
      labels:
        name: nginx
    spec:
      containers:
        - name: nginx
          image: wangyanglinux/myapp:v1
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 80
</code></pre>
<p>Service</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
  name: nginx-svc
spec:
  selector:
    name: nginx
  ports:
    - port: 80
      targetPort: 80
      protocol: TCP
</code></pre>
<p>Ingress</p>
<p><a href="https://kubernetes.io/docs/concepts/services-networking/ingress/">官方文档</a></p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-test
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
    - host: www1.guqing.xyz
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx-svc
                port:
                  number: 80
</code></pre>
<p>执行过程</p>
<pre><code class="language-shell">$ minikube addons enable ingress
* The 'ingress' addon is enabled

$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v0.43.0/deploy/static/provider/baremetal/deploy.yaml
namespace/ingress-nginx created
serviceaccount/ingress-nginx created
configmap/ingress-nginx-controller created
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created

$ kubectl get pods -n ingress-nginx
NAME                                        READY   STATUS              RESTARTS   AGE
ingress-nginx-admission-create-58mz2        0/1     ContainerCreating   0          11s
ingress-nginx-admission-patch-s9c2c         0/1     ContainerCreating   0          11s
ingress-nginx-controller-697b57b496-hj9cb   0/1     ContainerCreating   0          12s
# 创建deployment
$ kubectl apply -f deployment.yaml
# 创建svc
$ kubectl apply -f svc.yaml
service/nginx-svc created

$ kubectl get svc
NAME         TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1        &lt;none&gt;        443/TCP   4m47s
nginx-svc    ClusterIP   10.103.242.217   &lt;none&gt;        80/TCP    2m41s
# 创建ingress
$ kubectl apply -f ingress.yaml

$ kubectl get svc -n ingress-nginx
NAME                                 TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)                      AGE
ingress-nginx-controller             NodePort    10.111.21.135   &lt;none&gt;        80:30793/TCP,443:30261/TCP   7m10s
ingress-nginx-controller-admission   ClusterIP   10.102.127.39   &lt;none&gt;        443/TCP                      7m10s
# 查看访问地址
$ minikube service -n ingress-nginx ingress-nginx-controller --url
http://172.17.0.16:30162
http://172.17.0.16:30747

$ vim /etc/hosts
# 添加记录指向ingress域名
# 172.17.0.16 www1.guqing.xyz

$ curl www1.guqing.xyz
Hello MyApp | Version: v1 | &lt;a href="hostname.html"&gt;Pod Name&lt;/a&gt;
</code></pre>
<h4 id="ingress域名代理访问示例">Ingress域名代理访问示例</h4>
<p>以下步骤将在K8S提供的Minikube终端下运行 <a href="https://kubernetes.io/zh/docs/tutorials/hello-minikube/">Launch Terminal</a></p>
<p>minikube version: v1.8.1</p>
<p>结构图示如下：</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fimage-20210125182937226_1639555086793.png%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" alt="image-20210125182937226"></p>
<ol>
 <li>启动ingress插件</li>
</ol>
<pre><code class="language-shell">$ minikube addons enable ingress
* The 'ingress' addon is enabled

$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v0.43.0/deploy/static/provider/baremetal/deploy.yaml

namespace/ingress-nginx created
serviceaccount/ingress-nginx created
configmap/ingress-nginx-controller created
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
role.rbac.authorization.k8s.io/ingress-nginx created
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
service/ingress-nginx-controller-admission created
service/ingress-nginx-controller created
deployment.apps/ingress-nginx-controller created
</code></pre>
<ol start="2">
 <li>创建两个deployment和两个svc分别对应</li>
</ol>
<p><code>deployment-svc-1.yaml</code>标签为<code>name=nginx1</code>,容器名<code>nginx1</code>并与<code>svc-1绑定</code></p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: deployment1
spec:
  replicas: 2
  selector:
    matchLabels:
      name: nginx1
  template:
    metadata:
      labels:
        name: nginx1
    spec:
      containers:
        - name: nginx1
          # 使用v1版本
          image: wangyanglinux/myapp:v1
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: svc-1
spec:
  selector:
    name: nginx1
  ports:
    - port: 80
      targetPort: 80
      protocol: TCP
</code></pre>
<p><code>deployment-svc-2.yaml</code>标签为<code>name=nginx2</code>,容器名<code>nginx2</code>并与<code>svc-2绑定</code></p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: deployment2
spec:
  replicas: 2
  selector:
    matchLabels:
      name: nginx2
  template:
    metadata:
      labels:
        name: nginx2
    spec:
      containers:
        - name: nginx2
          # 使用v2版本
          image: wangyanglinux/myapp:v2
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: svc-2
spec:
  selector:
    name: nginx2
  ports:
    - port: 80
      targetPort: 80
      protocol: TCP
</code></pre>
<p>创建</p>
<pre><code class="language-shell">$ kubectl apply -f deployment-svc-1.yaml
$ kubectl apply -f deployment-svc-2.yaml
</code></pre>
<p>验证是否符合预期</p>
<pre><code class="language-shell">$ kubectl get svc
NAME         TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1     &lt;none&gt;        443/TCP   31m
svc-1        ClusterIP   10.98.45.49   &lt;none&gt;        80/TCP    23m
svc-2        ClusterIP   10.98.55.46   &lt;none&gt;        80/TCP    20m

# 访问svc-1
$ curl 10.98.45.49
Hello MyApp | Version: v1 | &lt;a href="hostname.html"&gt;Pod Name&lt;/a&gt;

# 访问svc-2
$ curl 10.98.55.46
Hello MyApp | Version: v2 | &lt;a href="hostname.html"&gt;Pod Name&lt;/a&gt;
</code></pre>
<ol start="3">
 <li>创建Ingress规则(<code>ingress-rules.yaml</code>),创建<code>ingress-1</code>与<code>svc-1</code>绑定,<code>ingress-2</code>与<code>svc-2</code>绑定</li>
</ol>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: ingress-1
spec:
  rules:
    - host: ingress1.guqing.xyz
      http:
        paths:
          - path: /
            backend:
              serviceName: svc-1
              servicePort: 80
---
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: ingress-2
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
    - host: ingress2.guqing.xyz
      http:
        paths:
          - path: /
            backend:
              serviceName: svc-2
              servicePort: 80
</code></pre>
<p>创建</p>
<pre><code class="language-shell">$ kubectl apply -f ingress-rules.yaml
</code></pre>
<p>进入到<code>ingress</code>容器中查看<code>nginx</code>配置</p>
<pre><code class="language-shell">$ kubectl get pods -n ingress-nginx
NAME                                        READY   STATUS      RESTARTS   AGE
ingress-nginx-admission-create-fw2r6        0/1     Completed   0          38m
ingress-nginx-admission-patch-rsh8f         0/1     Completed   0          38m
ingress-nginx-controller-697b57b496-r8vpk   1/1     Running     0          38m
$ kubectl exec ingress-nginx-controller-697b57b496-r8vpk -n ingress-nginx -it -- /bin/bash
bash-5.0$ cat /etc/nginx/nginx.conf
</code></pre>
<p>此时会看到nginx的配置文件中自动被配置了<code>ingress-rules.yaml</code>中设置的两个<code>host</code>对应的域名</p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fimage-20210125155816426_1639555334156.png%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" alt="image-20210125155816426"></p>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fimage-20210125160026174_1639555084221.png%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" alt="image-20210125160026174"></p>
<ol start="3">
 <li>配置<code>hosts</code>文件模拟访问域名</li>
</ol>
<pre><code class="language-shell">$ kubectl get ingress
NAME        HOSTS                 ADDRESS       PORTS   AGE
ingress-1   ingress1.guqing.xyz   172.17.0.33   80      4m1s
ingress-2   ingress2.guqing.xyz   172.17.0.33   80      4m1s

$ vim /etc/hosts
</code></pre>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fimage-20210125164146482_1639555086701.png%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" alt="image-20210125164146482"></p>
<p>查看访问端口</p>
<pre><code class="language-shell">$ kubectl get svc -n ingress-nginx
NAME                                 TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)                      AGE
ingress-nginx-controller             NodePort    10.110.79.62    &lt;none&gt;        80:31394/TCP,443:30567/TCP   4m1s
ingress-nginx-controller-admission   ClusterIP   10.110.32.131   &lt;none&gt;        443/TCP                      4m1s
</code></pre>
<p>访问</p>
<pre><code class="language-shell">$ curl ingress1.guqing.xyz:31394
Hello MyApp | Version: v1 | &lt;a href="hostname.html"&gt;Pod Name&lt;/a&gt;

$ curl ingress2.guqing.xyz:31394
Hello MyApp | Version: v2 | &lt;a href="hostname.html"&gt;Pod Name&lt;/a&gt;
</code></pre>
<h4 id="ingress-https代理访问">Ingress HTTPS代理访问</h4>
<p>创建证书及cert存储方式</p>
<pre><code class="language-shell">$ open ssl req -x500 -sha256 -nodes -days 365 -newkey rsa:2048 -keyout tls.key -out tls.crt -subj "/CN=nginxsvc/O=nginxsvc"

$ kubectl create secret tls tls-secret --key tls.crt
</code></pre>
<p>Ingress配置示例</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: ingress-3
spec:
  tls:
    - hosts:
        - foo.bar.com
      # 与tls指定的名称一致
      secretName: tls-secret
  rules:
    - host: foo.bar.com
      http:
        paths:
          - path: /
            backend:
              serviceName: svc-3
              servicePort: 80
</code></pre>
<h4 id="nginx-basic-auth">Nginx Basic Auth</h4>
<pre><code class="language-shell">$ yum -y install httpd
# 创建文件为auth 用户名为foo
$ htpasswd -c auth foo
$ kubectl create secret generic basic-auth --form-file=auth
</code></pre>
<p>创建ingress yaml</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: ingress-with-auth
  annotations:
    nginx.ingress.kubernetes.io/auth-type: basic
    nginx.ingress.kubernetes.io/auth-secret: basic-auth
    nginx.ingress.kubernetes.io/auth-realm: "Authentication Required - foo"
spec:
  rules:
    - host: foo2.bar.com
      http:
        paths:
          - path: /
            backend:
              serviceName: svc-4
              servicePort: 80
</code></pre>
<h4 id="nginx重写">Nginx重写</h4>
<table>
 <thead>
  <tr>
   <th>名称</th>
   <th>描述</th>
   <th>值</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>nginx.ingress.kubernets.io/rewrite-target</td>
   <td>必须重定向流量到目标URL</td>
   <td>串</td>
  </tr>
  <tr>
   <td>nginx.ingress.kubernetes.io/ssl-redirect</td>
   <td>指定位置部分是否仅可访问SSL(当Ingress包含证书时默认为True)</td>
   <td>布尔</td>
  </tr>
  <tr>
   <td>nginx.ingress.kubernetes.io/force-ssl-redirect</td>
   <td>即使Ingress未启用TLS，也强制重定向到HTTPS</td>
   <td>布尔</td>
  </tr>
  <tr>
   <td>nginx.ingress.kubernetes.io/app-root</td>
   <td>定义Controller必须重定向的应用程序根，如果它在/上下文中</td>
   <td>串</td>
  </tr>
  <tr>
   <td>nginx.ingress.kubernetes.io/use-regex</td>
   <td>指示Ingress上定义的路径是否使用正则表达式</td>
   <td>布尔</td>
  </tr>
 </tbody>
</table>
<p>示例</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: ingress-rewrite
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: http://foo.bar.com:31795/hostname.html
spec:
  rules:
    - host: foo3.bar.com
      http:
        paths:
          - path: /
            backend:
              serviceName: svc-5
              servicePort: 80
</code></pre>
<h2 id="存储机制">存储机制</h2>
<h3 id="config-map">Config Map</h3>
<p>ConfigMap功能在Kubernetes1.2版本中引入，许多应用程序会从配置文件、命令行参数或环境变量中读取配置信息。ConfigMap API给我们提供了向容器中注入配置信息的机制，ConfigMap可以被用来保存单个属性，也可以用来保存整个配置文件或者JSON二进制大对象</p>
<h4 id="configmap的创建">ConfigMap的创建</h4>
<h5 id="1使用目录创建">1.使用目录创建</h5>
<pre><code class="language-shell">$ ls docs/user-guide/configmap/kubectl/
game.properties
ui.properties

$ cat docs/user-guide/config/kubectl/game.properties
enemies=aliens
lives=3
enemies.cheat=true
enemies.cheat.level=noGoodRotten
secret.code.passphrase=UUDDLRLRBABAS
secret.code.allowed=true
secret.code.lives=30

$ cat docs/user-guide/configmap/kubectl/ui.properties
color.good=purple
color.bad=yellow
allow.textmode=true
how.nice.to.look=fairlyNice

$ kubectl create configmap game-config --from-file=docs/user-guide/configmap/kubectl
</code></pre>
<p><code>--from-file</code>指定在目录下的所有文件都会被用在ConfigMap里面创建一个键值对，键的名称就是文件名，值就是文件内容</p>
<pre><code class="language-shell">$ kubectl get configmap
NAME               DATA   AGE
game-config        2      30s
kube-root-ca.crt   1      7d22h
</code></pre>
<p>查看具体信息内容像这样(<code>cm</code>是<code>configmap</code>的简称)</p>
<pre><code class="language-shell">$ kubectl get cm game-config -o yaml
apiVersion: v1
data:
  game.properties: |
    enemies=aliens
    lives=3
    enemies.cheat=true
    enemies.cheat.level=noGoodRotten
    secret.code.passphrase=UUDDLRLRBABAS
    secret.code.allowed=true
    secret.code.lives=30
  ui.properties: |
    color.good=purple
    color.bad=yellow
    allow.textmode=true
    how.nice.to.look=fairlyNice
kind: ConfigMap
metadata:
  creationTimestamp: "2021-01-27T06:57:37Z"
  managedFields:
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:data:
        .: {}
        f:game.properties: {}
        f:ui.properties: {}
    manager: kubectl-create
    operation: Update
    time: "2021-01-27T06:57:37Z"
  name: game-config
  namespace: default
  resourceVersion: "513536"
  uid: 74e9713b-8b5f-4512-a348-8691eb5afaca
</code></pre>
<h5 id="2-使用文件创建">2. 使用文件创建</h5>
<p>只要指定为一个文件就可以从单个文件中创建ConfigMap</p>
<pre><code class="language-shell">$ kubectl create configmap game-config-2 --from-file=docs/user-guide/configmap/kubectl/game.properties

$ kubectl get configmaps game-config-2 -o yaml
</code></pre>
<p><code>--from-file</code>这个参数可以使用多次，例如可以使用两次分别指定上个示例中两个配置文件，效果就和指定整个目录一样</p>
<h5 id="3使用字面值创建">3.使用字面值创建</h5>
<p>使用文字值创建，利用<code>--from-literal</code>参数传递配置信息，该参数可以使用多次，格式如下:</p>
<pre><code class="language-shell">$ kubctl create configmap special-config --from-literal=special.how=very --from-literal=special.type=charm

$ kubectl get configmaps special-config -o yaml
</code></pre>
<h4 id="pod中使用configmap">Pod中使用ConfigMap</h4>
<h5 id="1使用configmap来替代环境变量">1.使用ConfigMap来替代环境变量</h5>
<pre><code class="language-yaml">apiVersion: v1
kind: ConfigMap
metadata:
  name: special-config
  namespace: default
data:
  special.how: very
  special.type: charm
</code></pre>
<pre><code class="language-yaml">apiVersion: v1
kind: ConfigMap
metadata:
  name: env-config
  namespace: default
data:
  log_level: INFO
</code></pre>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: dapi-test-pod
spec:
  containers:
    - name: test-container
      image: nginx:1.19.6
      command: ["/bin/sh", "-c", "env"]
      env:
        - name: SPECIAL_LEVEL_KEY
          valueFrom:
            configMapKeyRef:
              name: special-config
              key: special.how
        - name: SPECIAL_TYPE_KEY
          valueFrom:
            configMapKeyRef:
              name: special-config
              key: special.type
      envFrom:
        - configMapRef:
            name: env-config
  restartPolicy: Never
</code></pre>
<p>查看pod日志效果如下</p>
<pre><code class="language-shell">$ kubectl logs dapi-test-pod
KUBERNETES_SERVICE_PORT=443
KUBERNETES_PORT=tcp://10.96.0.1:443
NGINX_SVC_SERVICE_HOST=10.102.29.151
HOSTNAME=dapi-test-pod
HOME=/root
NGINX_SVC_PORT=tcp://10.102.29.151:80
NGINX_SVC_SERVICE_PORT=80
PKG_RELEASE=1~buster
# special.type=charm
SPECIAL_TYPE_KEY=charm
NGINX_SVC_PORT_80_TCP_ADDR=10.102.29.151
NGINX_SVC_PORT_80_TCP_PORT=80
NGINX_SVC_PORT_80_TCP_PROTO=tcp
KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1
NGINX_VERSION=1.19.6
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
KUBERNETES_PORT_443_TCP_PORT=443
NJS_VERSION=0.5.0
KUBERNETES_PORT_443_TCP_PROTO=tcp
NGINX_SVC_PORT_80_TCP=tcp://10.102.29.151:80
# special.how=very
SPECIAL_LEVEL_KEY=very
# log_level=INFO
log_level=INFO
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443
KUBERNETES_SERVICE_HOST=10.96.0.1
PWD=/
</code></pre>
<h5 id="2用configmap设置命令行参数">2.用ConfigMap设置命令行参数</h5>
<pre><code class="language-yaml">apiVersion: v1
kind: ConfigMap
metadata:
  name: special-config
  namespace: default
data:
  special.how: very
  special.type: charm
</code></pre>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: configmap-test-pod
spec:
  containers:
    - name: test-container
      image: nginx:1.19.6
      command:
        ["/bin/sh", "-c", "echo $(SPECIAL_LEVEL_KEY) $(SPECIAL_TYPE_KEY)"]
      env:
        - name: SPECIAL_LEVEL_KEY
          valueFrom:
            configMapKeyRef:
              name: special-config
              key: special.how
        - name: SPECIAL_TYPE_KEY
          valueFrom:
            configMapKeyRef:
              name: special-config
              key: special.type
  restartPolicy: Never
</code></pre>
<p>查看日志效果如下</p>
<pre><code class="language-shell">$ kubectl logs configmap-test-pod
very charm
</code></pre>
<h5 id="3通过数据卷插件使用configmap">3.通过数据卷插件使用ConfigMap</h5>
<pre><code class="language-yaml">apiVersion: v1
kind: ConfigMap
metadata:
  name: special-config
  namespace: default
data:
  special.how: very
  special.type: charm
</code></pre>
<p>在数据卷里面使用这个ConfigMap，有不同的选项。最基本的就是将文件填入数据卷，在这个文件中，键就是文件名，键值就是文件内容</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: configmap-volume-pod
spec:
  containers:
    - name: test-container
      image: nginx:1.19.6
      command:
        ["/bin/sh", "-c", "ls /etc/config/ &amp;&amp; cat /etc/config/special.how"]
      volumeMounts:
        - name: config-volume
          mountPath: /etc/config
  volumes:
    - name: config-volume
      configMap:
        name: special-config
  restartPolicy: Never
</code></pre>
<p>查看pod日志如下</p>
<pre><code class="language-shell">$ kubectl logs  configmap-volume-pod
# 两个文件
special.how
special.type
# special.how内容
very
</code></pre>
<h4 id="configmap热更新">ConfigMap热更新</h4>
<pre><code class="language-yaml">apiVersion: v1
kind: ConfigMap
metadata:
  name: log-config
  namespace: default
data:
  log_level: INFO
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      run: my-nginx
  template:
    metadata:
      labels:
        run: my-nginx
    spec:
      containers:
        - name: my-nginx
          image: nginx:1.19.6
          ports:
            - containerPort: 80
          volumeMounts:
            - name: config-volume
              mountPath: /etc/config
      volumes:
        - name: config-volume
          configMap:
            name: log-config
</code></pre>
<p>创建后查看内容</p>
<pre><code class="language-shell">$ kubectl get pods
NAME                        READY   STATUS      RESTARTS   AGE
my-nginx-854969cf95-fmmp9   1/1     Running     0          23s 
$ kubectl exec my-nginx-854969cf95-fmmp9 -it -- cat /etc/config/log_level
# 结果为INFO
INFO
</code></pre>
<p>此时修改ConfigMap</p>
<pre><code class="language-shell">$ kubectl edit configmap log-config
</code></pre>
<p>修改<code>log_level</code>的值未<code>DEBUG</code>等待约10s后再次查看环境变量的值</p>
<pre><code class="language-shell">$ kubectl exec my-nginx-854969cf95-fmmp9 -icat /etc/config/log_level
# 值变为了DEBUG
DEBUG
</code></pre>
<h3 id="secret">Secret</h3>
<p>Secret解决了密码、token、密钥等名干数据的配置问题，而不需要把这些敏感数据暴露到镜像或者Pod Spec中。Secret可以以Volume或者环境变量的方式使用</p>
<p>Secret有三种类型：</p>
<ul>
 <li>Service Account: 用来访问Kubernetes API, 由Kubernetes自动创建，并且会自动挂载到Pod的<code>/run/secrets/kubernetes.io/serviceaccount</code>目录中</li>
 <li>Opaque： base64编码格式的Secret，用来存储密码、密钥等</li>
 <li>kubernetes.io/dockerconfigjson: 用来存储私有docker registry的认证信息</li>
</ul>
<h4 id="service-account">Service Account</h4>
<p>Service Account 用来访问Kubernetes API, 由Kubernetes自动创建，并且会自动挂载到Pod的<code>/run/secrets/kubernetes.io/serviceaccount</code>目录中</p>
<h4 id="opaque-secret">Opaque Secret</h4>
<ol>
 <li><strong>创建说明</strong></li>
</ol>
<p>Opaque类型的数据是一个map类型，要求value是base64编码格式</p>
<pre><code class="language-shell">$ echo -n "admin" | base64
YWRtaW4=
$ echo -n "1f2d1e2e67df" | base64
MWYyZDFlMmU2N2Rm
</code></pre>
<p>secrets.yaml</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Secret
metadata:
  name: mysecret
type: Opaque
data:
  username: YWRTAW4=
  password: MWYyZDFlMmU2N2Rm
</code></pre>
<p>执行过程</p>
<pre><code class="language-shell">$ vim secrets.yaml
# 填充上述 secrets.yaml内容
$ kubectl apply -f secrets.yaml 
secret/mysecret created
$ kubectl get secret
NAME                  TYPE                                  DATA   AGE
default-token-kxgcl   kubernetes.io/service-account-token   3      8d
mysecret              Opaque 
</code></pre>
<p>可以看到默认有一个<code>default-token</code>,k8s会为每一个 namepace下创建一个SA(<code>Service Account</code>)用于Pod挂载</p>
<ol start="2">
 <li><strong>使用方式</strong></li>
</ol>
<p>方式一：将secret挂载到Volume中</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: secret-test
  labels:
    name: secret-test
spec:
  volumes:
    - name: secrets
      secret:
        secretName: mysecret
  containers:
    - name: db
      image: nginx:1.19.6
      volumeMounts:
        - name: secrets
          mountPath: /etc/secrets
          readOnly: true
</code></pre>
<p>执行示例</p>
<pre><code class="language-shell">$ kubectl get pods
NAME                        READY   STATUS      RESTARTS   AGE
secret-test                 1/1     Running     0          6s
$ kubectl exec secret-test -it -- /bin/sh
&gt; ls
password  username
&gt; cat username
admin
&gt; cat password
1f2d1e2e67df
</code></pre>
<p>方式二：将Secret导入到环境变量中</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: pod-deployment
spec:
  replicas: 2
  selector:
    matchLabels:
      app: pod-deployment
  template:
    metadata:
      labels:
        app: pod-deployment
    spec:
      containers:
        - name: pod-1
          image: nginx:1.19.6
          ports:
            - containerPort: 80
          env:
            - name: TEST_USER
              valueFrom:
                secretKeyRef:
                  name: mysecret
                  key: username
            - name: TEST_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysecret
                  key: password
</code></pre>
<p>执行示例:</p>
<pre><code class="language-shell">$ kubectl apply -f pod-deployment.yaml
deployment.apps/pod-deployment created
$ kubectl get pods
NAME                             READY   STATUS    RESTARTS   AGE
pod-deployment-6b988699d-ql596   1/1     Running   0          3s
pod-deployment-6b988699d-vwtcw   1/1     Running   0          3s
$ kubectl exec pod-deployment-6b988699d-ql596 -it -- /bin/sh
&gt; echo $TEST_USER
admin
&gt; echo $TEST_PASSWORD
1f2d1e2e67df
</code></pre>
<h4 id="kubernetesiodockerconfigjson">kubernetes.io/dockerconfigjson</h4>
<p>使用Kubeclt创建docker registry认证的secret</p>
<pre><code class="language-shell">$ kubectl create secret docker-registry myregistrykey --docker-server=DOCKER_REGISTRY_SERVER --docker-username=DOCKERUSER --docker-password=DOCKER_PASSWORD --docker-email=DOCKER_EMAIL

secret "myregistrykey" created
</code></pre>
<p>在创建Pod的时候，通过<code>imagePullSecrets</code>来引用刚创建的<code>myregistrykey</code></p>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: foo
spec:
  containers:
    - name: foo
      # 私有镜像
      image: guching/nginx:1.19.6
  imagePullSecrets:
    - name: myregistrykey
</code></pre>
<p>使用hub.docker.com仓库示例</p>
<pre><code class="language-shell">$ kubectl create secret docker-registry myregistrykey --docker-username=guching --docker-password=xxx
secret/myregistrykey created
# foo-pod.yaml内容如上
$ kubectl create -f foo-pod.yaml
pod/foo created
$ kubectl get pods -w
NAME                             READY   STATUS              RESTARTS   AGE
foo                              0/1     ContainerCreating   0          3s
# 失败了一次
foo                              0/1     ErrImagePull        0          34s
# 运行成功
foo                              1/1     Running   0          2m36s
</code></pre>
<h3 id="volume">Volume</h3>
<p><a href="https://kubernetes.io/docs/concepts/storage/volumes/">官网文档</a></p>
<p>容器磁盘上的文件的生命周期是短暂的，这就使得在容器中运行重要应用时会出现一些问题。首先，当容器崩溃时，kubelet会重启它，但是容器中的文件将丢失——容器以干净的状态（镜像最初的状态）重新启动，其次，在Pod中同时运行多个容器时，这些容器之间通常需要共享文件。Kubernetes中的<code>Volume</code>抽象就很好的解决了这些问题。</p>
<h4 id="背景">背景</h4>
<p>Kubernetes中的卷有明确的寿命——与封装它的Pod相同。所以，卷的生命比Pod中的所有容器都长，当这个容器重启时数据仍然得以保存。当然，当Pod不再存在时，卷将不复存在。也许更重要的是，Kubernetes支持多种类型的卷，Pod可以同时使用任意数量的卷</p>
<h4 id="卷的类型">卷的类型</h4>
<p>Kubernetes支持以下类型的卷:</p>
<ul>
 <li><code>awsElasticBlockStore</code>、<code>azureDisk</code>、<code>azureFile</code>、<code>cephfs</code>、<code>csi</code>、<code>downwardAPI</code>、<code>emptyDir</code></li>
 <li><code>fc</code>、<code>flocker</code>、<code>gcePersistentDisk</code>、<code>gitRepo</code>、<code>glusterfs</code>、<code>hostPath</code>、<code>iscsi</code>、<code>nfs</code></li>
 <li><code>persistentVolumeClaim</code>、<code>projected</code>、<code>portworxVolume</code>、<code>quobyte</code>、<code>rbd</code>、<code>scaleIO</code>、<code>secret</code></li>
 <li><code>storageos</code>、<code>vsphereVolume</code></li>
</ul>
<h4 id="emptydir">emptyDir</h4>
<p>当Pod被分配给节点时，首先会创建<code>emptyDir</code>卷，并且只要该POd在该节点上运行，该卷就会存在。正如卷的名字所述，它最初是空的。Pod中容器可以读取和写入<code>emptyDir</code>卷中的相同文件，尽管该卷可以挂载到每个容器中的相同或不同路径上。当出于任何原因从节点中删除Pod时，<code>emptyDir</code>中的数据将被永久删除（注：容器崩溃时不会从节点中移除Pod，因此<code>emptyDir</code>卷中的数据在容器崩溃时是安全的）</p>
<p><code>emptyDir</code>的用法有：</p>
<ul>
 <li>暂存空间，例如用于基于磁盘的合并排序</li>
 <li>用作长时间计算崩溃恢复时的检查点</li>
 <li>Web服务器容器提供数据时，保存内容管理器容器提取的文件</li>
</ul>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: test-pod
spec:
  containers:
    - name: test-container
      image: nginx:1.19.6
      volumeMounts:
        - mountPath: /cache
          # 挂载cache-volume卷
          name: cache-volume
  volumes:
    - name: cache-volume
      emptyDir: {}
</code></pre>
<p>不同的容器可以使用不同的<code>mountPath</code>指向同一个卷例如</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: test-pod
spec:
  containers:
    - name: test-container
      image: nginx:1.19.6
      volumeMounts:
        - mountPath: /cache
          name: cache-volume
    - name: busybox
      image: busybox:1.32
      imagePullPolicy: IfNotPresent
      command: ["/bin/sh", "-c", "sleep 3600s"]
      volumeMounts:
        - mountPath: /test
          name: cache-volume
  volumes:
    - name: cache-volume
      emptyDir: {}
</code></pre>
<p>如上<code>test-container</code>容器的卷路径为<code>cache</code>，而<code>busybox</code>容器的卷路径为<code>test</code>都是指向同一个卷<code>cache-volume</code>内容是共享的只是路径不同</p>
<h4 id="hostpath">hostPath</h4>
<p><code>hostPath</code>卷将主机节点的文件系统中的文件或目录挂载到集群中</p>
<p><code>hostPath</code>的用途如下：</p>
<ul>
 <li>运行需要访问Docker内部的容器，使用<code>/var/lib/docker</code>的<code>hostPath</code></li>
 <li>在容器中运行<code>cAdvisor</code>使用<code>/dev/cgroups</code>的<code>hostPath</code></li>
</ul>
<p>除了所需要的<code>path</code>属性之外，用户还可以为<code>hostPath</code>卷指定<code>type</code></p>
<table>
 <thead>
  <tr>
   <th>值</th>
   <th>行为</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td></td>
   <td>空白字符串（默认）用于向后兼容，这意味着在挂载hostPath卷之前不会执行任何检查</td>
  </tr>
  <tr>
   <td>DirectoryOrCreate</td>
   <td>如果在给定的路径上没有任何东西存在，那么将根据需要在那里创建一个空目录，权限设置为755,与Kubelet具有相同的组和所有权</td>
  </tr>
  <tr>
   <td>Directory</td>
   <td>给定的路径下必须存在目录</td>
  </tr>
  <tr>
   <td>FileOrCreate</td>
   <td>如果在给定的路径上没有任何东西存在，那么会根据需要创建一个空文件，权限设置为0644,与Kubelet具有相同的组和所有权</td>
  </tr>
  <tr>
   <td>File</td>
   <td>给定的路径下必须存在文件</td>
  </tr>
  <tr>
   <td>Socket</td>
   <td>给定的路径下必须存在UNIX套接字</td>
  </tr>
  <tr>
   <td>CharDevice</td>
   <td>给定的路径下必须存在字符设备</td>
  </tr>
  <tr>
   <td>BlockDevice</td>
   <td>给定的路径下必须存在块设备</td>
  </tr>
 </tbody>
</table>
<p>使用这种卷类型时请注意：</p>
<ul>
 <li>由于每个节点上的文件都不同，具有相同的配置（例如从PodTemplate创建）的Pod在不同节点上的行为可能会有所不同</li>
 <li>当Kubernetes按照计划添加资源感知调度时，将无法考虑hostPath使用的资源</li>
 <li>在底层主机上创建的文件或目录只能由root写入。你需要在特权容器中以root身份运行进程，或修改主机上的文件权限以便写入hostPath卷</li>
</ul>
<pre><code class="language-yaml">apiVersion: v1
kind: Pod
metadata:
  name: test-pod-1
spec:
  containers:
    - name: test-container
      image: nginx:1.19.6
      volumeMounts:
        - mountPath: /cache
          name: cache-volume
  volumes:
    - name: cache-volume
      hostPath:
        # 主机中的目录路径
        path: /data
        # 类型，可选值
        type: Directory
</code></pre>
<p>注意：如果使用<code>minikube driver=docker</code>方式执行则<code>hostPath.path</code>在<code>minikube</code>容器中</p>
<h3 id="persistent-volume">Persistent Volume</h3>
<p>Persistent Volume简称PV,是由管理员设置的存储，它是集群的一部分，就像节点是集群中的资源一样，PV也是集群中的资源。PV是Volume之类的卷插件，但具有独立于使用PV的Pod的生命周期。此API对象包含存储实现的细节，即NFS、ISCSI或特定与云供应商的存储系统</p>
<p><code>PersistentVolumesClaim</code></p>
<p>PersistentVolumesClaim是用户存储的请求，简称PVC。它与Pod相似，Pod消耗节点资源，PVC消耗PV资源。Pod可以请求特定级别的资源(CPU和内存)。声明可以请求特定的大小和访问模式（例如，可以读/写一次或只读多次模式挂载）</p>
<p>静态PV</p>
<p>集群管理员创建一些PV，他们带有可供集群用户使用的实际存储的细节。他们存在与Kubernetes API中，可以用于消费</p>
<p>动态</p>
<p>当管理员创建的静态PV都不匹配用户的<code>PersistentVolumeClaim</code>时，集群可能会尝试动态的为PVC创建卷。此配置基于<code>StorageClasses</code>:PVC必须请求[存储类],并且管理员必须创建并配置该类才能进行动态创建。声明该类为<code>""</code>可以有效的禁用其动态配置</p>
<p>要器用基于存储级别的动态存储配置，集群管理员需要器用API server上的DefaultStorageClass[准入控制器]。例如，通过确保DefaultStorageClass位于API Server组建的<code>--admission-control</code>标志，使用逗号分隔</p>
<p>44</p>]]></description><guid isPermaLink="false">/archives/how-to-use-kubenetes</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fsamurai-jack-wallpaper_1639556298785.jpg%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" type="image/jpeg" length="0"/><category>Linux</category><pubDate>Wed, 15 Dec 2021 07:57:00 GMT</pubDate></item><item><title><![CDATA[Kubernetes学习之安装Minikube环境]]></title><link>https://guqing.io/archives/install-minikube</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=Kubernetes%E5%AD%A6%E4%B9%A0%E4%B9%8B%E5%AE%89%E8%A3%85Minikube%E7%8E%AF%E5%A2%83&amp;url=/archives/install-minikube" width="1" height="1" alt="" style="opacity:0;">
<h2 id="简介">简介</h2>
<p>minikube是什么?</p>
<p>minikube is local Kubernetes, focusing on making it easy to learn and develop for Kubernetes.</p>
<p>All you need is Docker (or similarly compatible) container or a Virtual Machine environment, and Kubernetes is a single command away: <code>minikube start</code></p>
<h3 id="what-youll-need重点">What you’ll need（重点）</h3>
<ul>
 <li>2 CPUs or more</li>
 <li>2GB of free memory</li>
 <li>20GB of free disk space</li>
 <li>Internet connection</li>
 <li>Container or virtual machine manager, such as: Docker, Hyperkit, Hyper-V, KVM, Parallels, Podman, VirtualBox, or VMWare</li>
</ul>
<blockquote>
 <p>引用自<a href="https://minikube.sigs.k8s.io/docs/start/">minikube官网</a></p>
</blockquote>
<p>当然不安装minikube也可以方便的学习Kubernetes，Kubernetes官网提供了交互式的学习教程并提供了minikube的控制台，点击<a href="https://kubernetes.io/docs/tutorials/hello-minikube/">Hello Minikube</a>查看。建议使用代理以获得良好的体验</p>
<h2 id="先决条件">先决条件</h2>
<p>安装 <a href="https://kubernetes.io/docs/tasks/tools/install-kubectl/">kubectl</a></p>
<pre><code>$ curl -LO https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/darwin/amd64/kubectl
$ chmod +x ./kubectl
$ sudo mv ./kubectl /usr/local/bin/kubectl
$ kubectl version --client
</code></pre>
<p>Minikube在不同操作系统上支持不同的驱动</p>
<ul>
 <li>
  <p>macOS</p>
  <ul>
   <li>
    <p><a href="https://minikube.sigs.k8s.io/docs/drivers/docker/?spm=a2c6h.12873639.0.0.ab202043Z4yFYH">Docker</a> 缺省驱动</p></li>
   <li>
    <p><a href="https://github.com/kubernetes/minikube/blob/master/docs/drivers.md?spm=a2c6h.12873639.0.0.ab202043Z4yFYH#xhyve-driver">xhyve driver</a> , <a href="https://www.virtualbox.org/wiki/Downloads?spm=a2c6h.12873639.0.0.ab202043Z4yFYH">VirtualBox</a> 或 <a href="https://www.vmware.com/products/fusion?spm=a2c6h.12873639.0.0.ab202043Z4yFYH">VMware Fusion</a></p></li>
  </ul></li>
 <li>
  <p>Linux</p>
  <ul>
   <li><a href="https://www.virtualbox.org/wiki/Downloads?spm=a2c6h.12873639.0.0.ab202043Z4yFYH">VirtualBox</a> 或 <a href="https://minikube.sigs.k8s.io/docs/drivers/kvm2/?spm=a2c6h.12873639.0.0.ab202043Z4yFYH">KVM2</a></li>
   <li><a href="https://minikube.sigs.k8s.io/docs/drivers/docker/?spm=a2c6h.12873639.0.0.ab202043Z4yFYH">Docker</a> <code>--driver</code>缺省时驱动</li>
  </ul></li>
 <li>
  <p>Windows</p>
  <ul>
   <li><a href="https://www.virtualbox.org/wiki/Downloads?spm=a2c6h.12873639.0.0.ab202043Z4yFYH">VirtualBox</a> 或 <a href="https://github.com/kubernetes/minikube/blob/master/docs/drivers.md?spm=a2c6h.12873639.0.0.ab202043Z4yFYH#hyperV-driver">Hyper-V</a></li>
  </ul></li>
</ul>
<h2 id="安装minikube">安装MiniKube</h2>
<p>该安装方式针对于Linux用户，其他平台差不多具体参考<a href="https://minikube.sigs.k8s.io/docs/start/">MiniKube官网安装文档</a>,这里只解决服务器没有代理无法访问到google镜像仓库的问题。</p>
<p>提示</p>
<ul>
 <li>官方推荐使用<code>--driver=docker</code>即缺省参数,此时不支持使用root用户，否则无法安装</li>
 <li>使用docker作为运行时环境,如果没有则需要提前安装,<code>centos7/8</code>可以使用<code>daocloud</code>提供的自动安装脚本</li>
</ul>
<pre><code>curl -sSL https://get.daocloud.io/docker | sh
</code></pre>
<p><strong>Mac OSX</strong></p>
<pre><code>curl -Lo minikube https://kubernetes.oss-cn-hangzhou.aliyuncs.com/minikube/releases/v1.16.0/minikube-darwin-amd64 &amp;&amp; chmod +x minikube &amp;&amp; sudo mv minikube /usr/local/bin/
</code></pre>
<p><strong>Linux</strong></p>
<pre><code>curl -Lo minikube https://kubernetes.oss-cn-hangzhou.aliyuncs.com/minikube/releases/v1.16.0/minikube-linux-amd64 &amp;&amp; chmod +x minikube &amp;&amp; sudo mv minikube /usr/local/bin/
</code></pre>
<p><strong>Windows</strong><br>
  下载 <a href="https://kubernetes.oss-cn-hangzhou.aliyuncs.com/minikube/releases/v1.16.0/minikube-windows-amd64.exe?spm=a2c6h.12873639.0.0.ab202043Z4yFYH&amp;file=minikube-windows-amd64.exe">minikube-windows-amd64.exe</a> 文件，并重命名为 minikube.exe</p>
<h2 id="启动">启动</h2>
<pre><code>minikube start
</code></pre>
<p>为了访问海外的资源，使用阿里云提供的镜像，可按照如下参数进行配置。其中常见参数：</p>
<ul>
 <li><code>--driver=xxx</code> 从1.5.0版本开始，Minikube缺省使用本地最好的驱动来创建Kubernetes本地环境，测试过的版本 docker, kvm</li>
 <li><code>--image-mirror-country=cn</code> 将缺省利用 registry.cn-hangzhou.aliyuncs.com/google_containers 作为安装Kubernetes的容器镜像仓库 （阿里云版本可选）</li>
 <li><code>--iso-url=xxx</code> 利用阿里云的镜像地址下载相应的 .iso 文件 （阿里云版本可选）</li>
 <li><code>--registry-mirror=xxx</code> 为了拉取Docker Hub镜像，需要为 Docker daemon 配置镜像加速，参考阿里云镜像服务</li>
 <li><code>--cpus=2</code>: 为minikube虚拟机分配CPU核数</li>
 <li><code>--memory=2048mb</code>: 为minikube虚拟机分配内存数</li>
 <li><code>--kubernetes-version=xxx</code>: minikube 虚拟机将使用的 kubernetes 版本</li>
</ul>
<p><strong>启动示例推荐</strong></p>
<pre><code>minikube start --driver=docker --image-mirror-country=cn --registry-mirror=https://kaakiyao.mirror.aliyuncs.com
</code></pre>
<p>结果</p>
<pre><code>😄  Centos 8.3.2011 上的 minikube v1.16.0
✨  根据用户配置使用 docker 驱动程序
✅  正在使用镜像存储库 registry.cn-hangzhou.aliyuncs.com/google_containers
👍  Starting control plane node minikube in cluster minikube
🔥  Creating docker container (CPUs=2, Memory=2200MB) ...
    &gt; kubectl.sha256: 64 B / 64 B [--------------------------] 100.00% ? p/s 0s
    &gt; kubeadm.sha256: 64 B / 64 B [--------------------------] 100.00% ? p/s 0s
    &gt; kubelet.sha256: 64 B / 64 B [--------------------------] 100.00% ? p/s 0s
    &gt; kubeadm: 37.40 MiB / 37.40 MiB [---------------] 100.00% 14.54 MiB p/s 3s
    &gt; kubectl: 38.37 MiB / 38.37 MiB [----------------] 100.00% 4.21 MiB p/s 9s
    &gt; kubelet: 108.69 MiB / 108.69 MiB [------------] 100.00% 11.48 MiB p/s 10s

    ▪ Generating certificates and keys ...
    ▪ Booting up control plane ...
    ▪ Configuring RBAC rules ...
🔎  Verifying Kubernetes components...
🌟  Enabled addons: default-storageclass, storage-provisioner
🏄  Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default
</code></pre>]]></description><guid isPermaLink="false">/archives/install-minikube</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Ff3590e8d9d0d6218c4c1fcd4df597c6c-tuya_1625383034215.jpeg%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" type="image/jpeg" length="0"/><category>Linux</category><pubDate>Thu, 7 Jan 2021 10:07:40 GMT</pubDate></item><item><title><![CDATA[笛卡尔积工具类]]></title><link>https://guqing.io/archives/cartesian-product</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E7%AC%9B%E5%8D%A1%E5%B0%94%E7%A7%AF%E5%B7%A5%E5%85%B7%E7%B1%BB&amp;url=/archives/cartesian-product" width="1" height="1" alt="" style="opacity:0;">
<p>在数学中，两个集合X和Y的笛卡儿积，又称直积，在集合论中表示为X x Y，是所有可能的有序对组成的集合，其中有序对的第一个对象是X的成员，第二个对象是Y的成员。</p>
<pre><code>X×Y={(x,y)∣x∈X∧y∈Y}
</code></pre>
<p>举个实例,假设有两个集合 A 和 B:</p>
<pre><code>A = {0,1}
B = {2,3,4}
</code></pre>
<p>那么A和B的笛卡尔积 A×B 表示如下：</p>
<pre><code>A×B = {（0，2），（1，2），（0，3），（1，3），（0，4），（1，4）}

</code></pre>
<p>笛卡尔积的<code>Java</code>实现如下:</p>
<pre><code class="language-java">/**
 * 笛卡尔积工具类
 *
 * @author guqing
 * @date 2020-10-13
 */
public class CartesianUtils {
    public static&lt;T&gt; List&lt;List&lt;T&gt;&gt; cartesianProduct(List&lt;List&lt;T&gt;&gt; dimensionValue) {
        List&lt;List&lt;T&gt;&gt; result = new LinkedList&lt;&gt;();
        cartesianProduct(dimensionValue, result, 0, new ArrayList&lt;&gt;());
        return result;
    }

    private static&lt;T&gt; void cartesianProduct(List&lt;List&lt;T&gt;&gt; dimensionValue, List&lt;List&lt;T&gt;&gt; result, int layer, List&lt;T&gt; currentList) {
        if (layer &lt; dimensionValue.size() - 1) {
            if (dimensionValue.get(layer).size() == 0) {
                cartesianProduct(dimensionValue, result, layer + 1, currentList);
            } else {
                for (int i = 0; i &lt; dimensionValue.get(layer).size(); i++) {
                    List&lt;T&gt; list = new ArrayList&lt;&gt;(currentList);
                    list.add(dimensionValue.get(layer).get(i));
                    cartesianProduct(dimensionValue, result, layer + 1, list);
                }
            }
        } else if (layer == dimensionValue.size() - 1) {
            if (dimensionValue.get(layer).size() == 0) {
                result.add(currentList);
            } else {
                for (int i = 0; i &lt; dimensionValue.get(layer).size(); i++) {
                    List&lt;T&gt; list = new ArrayList&lt;&gt;(currentList);
                    list.add(dimensionValue.get(layer).get(i));
                    result.add(list);
                }
            }
        }
    }
}
</code></pre>]]></description><guid isPermaLink="false">/archives/cartesian-product</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fwallhaven-x1z1mz_1590149686367.jpg%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" type="image/jpeg" length="0"/><category>Java后端</category><pubDate>Wed, 14 Oct 2020 12:32:24 GMT</pubDate></item><item><title><![CDATA[模板方法模式实现Redis缓存查询简化]]></title><link>https://guqing.io/archives/20200420204031</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E6%A8%A1%E6%9D%BF%E6%96%B9%E6%B3%95%E6%A8%A1%E5%BC%8F%E5%AE%9E%E7%8E%B0Redis%E7%BC%93%E5%AD%98%E6%9F%A5%E8%AF%A2%E7%AE%80%E5%8C%96&amp;url=/archives/20200420204031" width="1" height="1" alt="" style="opacity:0;">
<h2 id="简述">简述</h2>
<p>在高并发场景下查询缓存时很容易出现缓存击穿(本文针对单机没有使用分布式锁)，这时由于并发用户特别多，同时读缓存没读到数据，又同时去数据库去取数据，引起数据库压力瞬间增大，造成过大压力,因此查询缓存需要进行加锁,但这种代码每次写多了很烦,而且容易写错,因此本文采用模板方法模式简化缓存查询及并发处理</p>
<h2 id="快速开始">快速开始</h2>
<p>Redis Dependency:</p>
<pre><code class="language-xml">&lt;dependency&gt;
    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
    &lt;artifactId&gt;spring-boot-starter-data-redis&lt;/artifactId&gt;
&lt;/dependency&gt;
</code></pre>
<p>Redis Service</p>
<pre><code class="language-java">public interface RedisService {
    /**
     * 存储数据
     */
    void set(String key, String value);

    /**
     * SET key value EX seconds，设置key-value和超时时间（秒）
     * 存储数据并设置过期时间
     *
     * @param key cache key
     * @param value cache value
     * @param expire expire time,time unit is seconds
     */
    void set(String key, String value, long expire);

    /**
     * 存储数据并设置过期时间通知指定过期时间的单位
     * @param key 数据的键
     * @param value 值
     * @param expire 过期时间
     * @param timeUnit 过期时间的单位
     */
    void set(String key, String value, long expire, TimeUnit timeUnit);

    /**
     * 获取数据
     */
    String get(String key);

    /**
     * 设置超期时间
     * @param key
     * @param expire 过期时间
     * @return 设置成功返回true, 否则返回false
     */
    boolean expire(String key, long expire);

    /**
     * 删除数据
     */
    void remove(String key);

    /**
     * 自增操作
     * @param delta 自增步长
     */
    Long increment(String key, long delta);
}
</code></pre>
<p>Redis Service Impl</p>
<pre><code class="language-java">@Service
public class RedisServiceImpl implements RedisService {
    private static final long DEFAULT_EXPIRE_SECONDS = 10;

    private final StringRedisTemplate redisTemplate;

    @Autowired
    public RedisServiceImpl(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void set(String key, String value) {
        redisTemplate.opsForValue().set(key, value, DEFAULT_EXPIRE_SECONDS, TimeUnit.MINUTES);
    }

    @Override
    public void set(String key, String value, long expire) {
        redisTemplate.opsForValue().set(key, value, expire, TimeUnit.SECONDS);
    }

    @Override
    public void set(String key, String value, long expire, TimeUnit timeUnit) {
        redisTemplate.opsForValue().set(key, value, expire, timeUnit);
    }

    @Override
    public String get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    @Override
    public boolean expire(String key, long expire) {
        Boolean result = redisTemplate.expire(key, expire, TimeUnit.SECONDS);
        if(result == null) {
            return false;
        }
        return result;
    }

    @Override
    public void remove(String key) {
        redisTemplate.delete(key);
    }

    @Override
    public Long increment(String key, long delta) {
        return redisTemplate.opsForValue().increment(key,delta);
    }
}
</code></pre>
<p>Redis Key Util:</p>
<pre><code class="language-java">public class RedisUtils {
    /**
     * 主数据系统标识
     */
    private static final String KEY_PREFIX = "halo";
    /**
     * 分割字符，默认[:]，使用:可用于rdm分组查看
     */
    private static final String KEY_SPLIT_CHAR = ":";

    /**
     * redis的key键规则定义
     * @param module 模块名称
     * @param service service名称
     * @param args 参数..
     * @return key
     */
    public static String keyBuilder(String module, String service, String... args) {
        return keyBuilder(null, module, service, args);
    }

    /**
     * redis的key键规则定义
     * @param module 模块名称
     * @param service service名称
     * @param objStr 对象.toString()
     * @return key
     */
    public static String keyBuilder(String module, String service, String objStr) {
        return keyBuilder(null, module, service, new String[]{objStr});
    }

    /**
     * redis的key键规则定义
     * @param prefix 项目前缀
     * @param module 模块名称
     * @param service service名称
     * @param objStr 对象.toString()
     * @return key
     */
    public static String keyBuilder(String prefix, String module, String service, String objStr) {
        return keyBuilder(prefix, module, service, new String[]{objStr});
    }

    /**
     * redis的key键规则定义
     * @param prefix 项目前缀
     * @param module 模块名称
     * @param service 方法名称
     * @param args 参数..
     * @return key
     */
    public static String keyBuilder(String prefix, String module, String service, String... args) {
        // 项目前缀
        if (prefix == null) {
            prefix = KEY_PREFIX;
        }
        StringBuilder key = new StringBuilder(prefix);
        // KEY_SPLIT_CHAR 为分割字符
        key.append(KEY_SPLIT_CHAR).append(module).append(KEY_SPLIT_CHAR).append(service);
        for (String arg : args) {
            key.append(KEY_SPLIT_CHAR).append(arg);
        }
        return key.toString();
    }

    /**
     * redis的key键规则定义
     * @param cacheEnum 枚举对象
     * @param objStr 对象.toString()
     * @return key
     */
    public static String keyBuilder(CacheEnum cacheEnum, String objStr) {
        return keyBuilder(cacheEnum.getPrefix(), cacheEnum.getModule(), cacheEnum.getService(), objStr);
    }
}
</code></pre>
<p>Cache Enum</p>
<pre><code class="language-java">@Data
@AllArgsConstructor
public enum CacheEnum {
    CMS_POST("halo", "cms", "PostService", "文章缓存");
    //other enum....
    
   /**
     * key前缀
     */
    private String prefix;
    /**
     * 模块名称
     */
    private String module;
    /**
     * service名称
     */
    private String service;
    /**
     * 描述
     */
    private String desc;
}
</code></pre>
<p>CacheTemplate:</p>
<pre><code class="language-java">
@Component
public class CacheTemplate {
    @Autowired
    private RedisService redisService;

    public &lt;T&gt; T findCache(String key, Class&lt;T&gt; clazz, LoadCallback&lt;T&gt; loadCallback) {
        String cacheValueString =  redisService.get(key);
        if(StringUtils.isEmpty(cacheValueString)) {
            synchronized (this) {
                cacheValueString =  redisService.get(key);
                if(ObjectUtils.isEmpty(cacheValueString)) {
                    T dataOfMysql = loadCallback.load();

                    // 如果为null也放入缓存,过期时间为1分钟
                    if (dataOfMysql == null) {
                        redisService.set(key, "null", 60L);
                    } else {
                        // 否则放入缓存,过期时间为10分钟
                        redisService.set(key, JSON.toJSONString(dataOfMysql));
                    }

                    return dataOfMysql;
                }

                return JSON.parseObject(cacheValueString, clazz);
            }
        } else {
            return JSON.parseObject(cacheValueString, clazz);
        }
    }

    public &lt;T&gt; T findCache(String key, TypeReference&lt;T&gt; typeReference, LoadCallback&lt;T&gt; loadCallback) {
        String cacheValueString =  redisService.get(key);
        if(StringUtils.isEmpty(cacheValueString)) {
            synchronized (this) {
                cacheValueString =  redisService.get(key);
                if(ObjectUtils.isEmpty(cacheValueString)) {
                    T dataOfMysql = loadCallback.load();
                    // 如果为null也放入缓存,过期时间为1分钟
                    if (dataOfMysql == null) {
                        redisService.set(key, "null", 60L);
                    } else {
                        // 否则放入缓存,过期时间为10分钟
                        redisService.set(key, JSON.toJSONString(dataOfMysql));
                    }

                    return dataOfMysql;
                }

                return JSON.parseObject(cacheValueString, typeReference);
            }
        } else {
            return JSON.parseObject(cacheValueString, typeReference);
        }
    }

    public &lt;T&gt; T findCache(String key, long expire, TypeReference&lt;T&gt; typeReference, LoadCallback&lt;T&gt; loadCallback) {
        String cacheValueString =  redisService.get(key);

        if(StringUtils.isEmpty(cacheValueString)) {
            synchronized (this) {
                cacheValueString =  redisService.get(key);
                if(ObjectUtils.isEmpty(cacheValueString)) {
                    T dataOfMysql = loadCallback.load();

                    // 如果为null也放入缓存,过期时间为1分钟
                    if (dataOfMysql == null) {
                        redisService.set(key, "null", 60L);
                    } else {
                        // 否则放入缓存,过期时间为10分钟
                        redisService.set(key, JSON.toJSONString(dataOfMysql), expire);
                    }

                    return dataOfMysql;
                }

                return JSON.parseObject(cacheValueString, typeReference);
            }
        } else {
            return JSON.parseObject(cacheValueString, typeReference);
        }
    }
}
</code></pre>
<p>LoadCallback</p>
<pre><code class="language-java">@FunctionalInterface
public interface LoadCallback&lt;T&gt; {
    /**
     * 从数据库查询数据逻辑,查询后的结果会被优先放入缓存
     * @return 返回查询结果对象
     */
    T load();
}
</code></pre>
<p>Example:</p>
<pre><code class="language-java">@Slf4j
@Service
public class PostServiceImpl extends BasePostServiceImpl&lt;Post&gt; implements PostService {
    private final PostRepository postRepository;
    private final CacheTemplate cacheTemplate;
    
    @Autowired
    public PostServiceImpl(PostRepository postRepository,
                          CacheTemplate cacheTemplate) {
        this.postRepository = postRepository;
        this.cacheTemplate = cacheTemplate;
    }
    
     public Post getBy(PostStatus status, String slug) {
         // 构建缓存key
         String cacheKey = RedisUtils.keyBuilder(CacheEnum.CMS_POST, "status:" + status.getValue() + ":slug:" + slug);
         return cacheTemplate.findCache(cacheKey, Post.class, ()-&gt;{
             // 从数据库查询数据的逻辑,这里可以简化为一行写
             return super.getBy(status, slug);
         });
    }
}
</code></pre>]]></description><guid isPermaLink="false">/archives/20200420204031</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F66_1590134180960.jpg%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" type="image/jpeg" length="0"/><category>Java后端</category><pubDate>Mon, 20 Apr 2020 12:40:32 GMT</pubDate></item><item><title><![CDATA[vue 计算属性传参]]></title><link>https://guqing.io/archives/202004181639</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=vue%20%E8%AE%A1%E7%AE%97%E5%B1%9E%E6%80%A7%E4%BC%A0%E5%8F%82&amp;url=/archives/202004181639" width="1" height="1" alt="" style="opacity:0;">
<p>使用JavaScript闭包，进行传值操作。</p>
<pre><code class="language-javascript">export default {
    name: 'Achievement',
    data () {
        return {
            ...
        }
    },
    methods: {
        ...
    },
    computed: {
        myfilter() {
            return function(index){
                return this.arr[index].username.match(this.name)!==null;         
            }           
        } 
    }
}
</code></pre>
<p>使用计算属性传参</p>
<pre><code class="language-html">&lt;tr v-for="(item,index) in arr" v-if="myfilter(index)"&gt;
    &lt;td&gt;{{item.username}}&lt;/td&gt;
    &lt;td&gt;{{item.sex}}&lt;/td&gt;
    &lt;td&gt;{{item.grade}}&lt;/td&gt;
    &lt;td&gt;
        &lt;a href="#" @click="delClick(index)"&gt;删除&lt;/a&gt;
    &lt;/td&gt;
&lt;/tr&gt;
</code></pre>]]></description><guid isPermaLink="false">/archives/202004181639</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F28_1590134179141.jpg%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" type="image/jpeg" length="0"/><category>Web前端</category><pubDate>Sat, 18 Apr 2020 08:39:00 GMT</pubDate></item><item><title><![CDATA[SpringBoot如何优雅的处理参数校验]]></title><link>https://guqing.io/archives/springboot-valid</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=SpringBoot%E5%A6%82%E4%BD%95%E4%BC%98%E9%9B%85%E7%9A%84%E5%A4%84%E7%90%86%E5%8F%82%E6%95%B0%E6%A0%A1%E9%AA%8C&amp;url=/archives/springboot-valid" width="1" height="1" alt="" style="opacity:0;">
<h3 id="引言">引言</h3>
<p>对于一个web项目而言后端经常需要对前端参数进行校验，传统方式常常是在<code>controller</code>中使用大量<code>if else</code>进行参数合法性校验，这样做的缺点显而易见，便不在赘述。</p>
<h3 id="解决方案">解决方案</h3>
<p>对于以上问题，<code>SpringBoot</code>项目我列举了三种处理方式：</p>
<p>对于前端传参的实体我们可以写这样一个类例如<code>UserParam</code>，然后使用<code>javax.validation</code>提供的注解进行参数条件限制</p>
<pre><code class="language-java">@Data
public class UserParam {
    private Integer id;

    @NotBlank(message="用户名不能为空")
    private String username;

    @Pattern(regexp = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\\\.[a-zA-Z0-9_-]+)+$", message = "邮箱地址格式不正确")
    private String email;

    @Pattern(regexp="^((13[0-9])|(15[^4,\\D])|(18[0,5-9]))\\d{8}$", message="手机号格式不正确")
    private String telephone;

    @Max(value=144, message="长度必须小于144个字符")
    private String description;
}
</code></pre>
<p>基于这样一个类，提出了两种关于<code>aop</code>的方式和一种异常处理的方式</p>
<h3 id="aop参数校验方式一">AOP参数校验方式一</h3>
<p>写一个<code>AOP</code>参数校验的类：</p>
<pre><code class="language-java">@Aspect
@Component
public class ValidParamAdvice {
    /**
     * 定义切点，切点为xyz.guqing.storycard.controller包和子包里任意方法的执行
     */
    @Pointcut("execution(* xyz.guqing.storycard.controller..*(..))")
    public void controllerPointCut() {
    }

    @Around("controllerPointCut() &amp;&amp; args(.., bindingResult)")
    public Object doAround(ProceedingJoinPoint joinPoint, BindingResult bindingResult) throws Throwable {
        // 收集并返回参数校验错误信息，这里根据实际返回结果类自己封装
        if(bindingResult.hasErrors()) {
            Map&lt;String, String&gt; errors = new HashMap&lt;&gt;(16);
            List&lt;FieldError&gt; fieldErrors = bindingResult.getFieldErrors();
            fieldErrors.forEach(fieldError -&gt; {
                errors.put(fieldError.getField(), fieldError.getDefaultMessage());
            });
            return errors.toString();
        }

        return joinPoint.proceed(joinPoint.getArgs());
    }
}
</code></pre>
<p>然后在<code>controller</code>中使用如下,只需要在参数<code>UserParam</code>前添加<code>@Valid</code>即可完成参数校验</p>
<pre><code class="language-java">@RestController
@CrossOrigin
@RequestMapping("/user")
public class UserController {

    @PostMapping("/advice-valid")
    public String exceptionAdviceValidUser(@RequestBody @Valid UserParam userParam) {
        return userParam.toString();
    }
}
</code></pre>
<h3 id="aop参数校验方式二">AOP参数校验方式二</h3>
<p>同样写一个<code>aop</code>参数处理类</p>
<pre><code class="language-java">@Aspect
@Component
public class ValidParamAdvice {
    /**
     * 定义切点，切点为xyz.guqing.storycard.controller包和子包里任意方法的执行
     */
    @Pointcut("execution(* xyz.guqing.storycard.controller..*(..))")
    public void controllerPointCut() {
    }

    @Around("controllerPointCut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        BindingResult bindingResult = null;
        // 使用遍历参数的方式找到BindingResult
        for(Object arg:joinPoint.getArgs()){
            if(arg instanceof BindingResult){
                bindingResult = (BindingResult) arg;
            }
        }

        if(bindingResult != null &amp;&amp; bindingResult.hasErrors()){
            Map&lt;String, String&gt; errors = new HashMap&lt;&gt;(16);
            List&lt;FieldError&gt; fieldErrors = bindingResult.getFieldErrors();
            fieldErrors.forEach(fieldError -&gt; {
                errors.put(fieldError.getField(), fieldError.getDefaultMessage());
            });
            return errors.toString();
        }
        return joinPoint.proceed();
    }
}
</code></pre>
<p>在<code>controller</code>中使用</p>
<pre><code class="language-java">@RestController
@CrossOrigin
@RequestMapping("/user")
public class UserController {
    @PostMapping("/aop-valid")
    public String aopValidUser(@RequestBody @Valid UserParam userParam, BindingResult result) {
        return userParam.toString();
    }
}
</code></pre>
<p>如上，不同点在于不仅需要在<code>UserParam</code>参数前写<code>@Valid</code>，还需要多添加一个参数<code>BindingResult result</code>才可以</p>
<h3 id="全局异常处理参数校验">全局异常处理参数校验</h3>
<p>写这样一个异常处理类</p>
<pre><code class="language-java">@RestControllerAdvice
@Slf4j
public class ValidExceptionAdvice {

    /**
     * 当使用@Valid不带@RequestBody request参数时:
     * 对象验证失败，验证将引发BindException而不是MethodArgumentNotValidException
     * @param e 参数绑定异常
     * @return 返回参数校验失败的错误信息
     */
    @ExceptionHandler(BindException.class)
    public Object validExceptionHandler(BindException e){
        // 将错误的参数的详细信息封装到统一的返回实体
        return validParam(e.getBindingResult());
    }

    /**
     * 使用@Valid并且带有@RequestBody request参数时
     * 参数教研失败将抛出MethodArgumentNotValidException异常，由此方法捕获处理
     * @param e 方法参数校验失败的异常
     * @return 返回校验失败错误信息
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Object validExceptionHandler(MethodArgumentNotValidException e){
        return validParam(e.getBindingResult());
    }

    private Object validParam(BindingResult bindResult) {
        List&lt;FieldError&gt; fieldErrors = bindResult.getFieldErrors();
        Map&lt;String, String&gt; map = new HashMap&lt;&gt;(16);
        fieldErrors.forEach(fieldError -&gt; {
            map.put(fieldError.getField(), fieldError.getDefaultMessage());
        });

        return map;
    }
}
</code></pre>
<p>在<code>controller</code>中使用</p>
<pre><code class="language-java">@RestController
@CrossOrigin
@RequestMapping("/user")
public class UserController {

    @PostMapping("/advice-valid")
    public String exceptionAdviceValidUser(@RequestBody @Valid UserParam userParam) {
        return userParam.toString();
    }
}
</code></pre>
<p>只需要在需要进行参数校验的参数<code>UserParam</code>前添加<code>@Valid</code>即可，当校验不通过时会被<code>ValidExceptionAdvice</code>类拦截处理</p>
<h3 id="valid注解说明">@Valid注解说明</h3>
<p><code>java</code>的<code>JSR303</code>声明了<code>@Valid</code>这类接口，而<code>Hibernate-validator</code>对其进行了实现,因此具体注解使用参考<a href="http://hibernate.org/validator/documentation/">Hibernate Validator</a>,下面列举一些常见注解的使用说明：</p>
<table>
 <thead>
  <tr>
   <th>注解</th>
   <th>备注</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>@Null</td>
   <td>只能为null</td>
  </tr>
  <tr>
   <td>@NotNull</td>
   <td>必须不为null</td>
  </tr>
  <tr>
   <td>@Max(value)</td>
   <td>必须为一个不大于 value 的数字</td>
  </tr>
  <tr>
   <td>@Min(value)</td>
   <td>必须为一个不小于 value 的数字</td>
  </tr>
  <tr>
   <td>@AssertFalse</td>
   <td>必须为false</td>
  </tr>
  <tr>
   <td>@AssertTrue</td>
   <td>必须为true</td>
  </tr>
  <tr>
   <td>@DecimalMax(value)</td>
   <td>必须为一个小于等于 value 的数字</td>
  </tr>
  <tr>
   <td>@DecimalMin(value)</td>
   <td>必须为一个大于等于 value 的数字</td>
  </tr>
  <tr>
   <td>@Digits(integer,fraction)</td>
   <td>必须为一个小数，且整数部分的位数不能超过integer，小数部分的位数不能超过fraction</td>
  </tr>
  <tr>
   <td>@Past</td>
   <td>必须是 日期 ,且小于当前日期</td>
  </tr>
  <tr>
   <td>@Future</td>
   <td>必须是 日期 ,且为将来的日期</td>
  </tr>
  <tr>
   <td>@Size(max,min)</td>
   <td>字符长度必须在min到max之间</td>
  </tr>
  <tr>
   <td>@Pattern(regex=,flag=)</td>
   <td>必须符合指定的正则表达式</td>
  </tr>
  <tr>
   <td>@NotEmpty</td>
   <td>必须不为null且不为空（字符串长度不为0、集合大小不为0）</td>
  </tr>
  <tr>
   <td>@NotBlank</td>
   <td>必须不为空（不为null、去除首位空格后长度不为0），不同于@NotEmpty，@NotBlank只应用于字符串且在比较时会去除字符串的空格</td>
  </tr>
  <tr>
   <td>@Email</td>
   <td>必须为Email，也可以通过正则表达式和flag指定自定义的email格式</td>
  </tr>
 </tbody>
</table>]]></description><guid isPermaLink="false">/archives/springboot-valid</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F14_1590134172212.jpg%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" type="image/jpeg" length="0"/><category>Java后端</category><pubDate>Mon, 16 Mar 2020 06:20:00 GMT</pubDate></item><item><title><![CDATA[通俗解释JAVA设计模式之观察者模式]]></title><link>https://guqing.io/archives/oberver-pattern</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E9%80%9A%E4%BF%97%E8%A7%A3%E9%87%8AJAVA%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E4%B9%8B%E8%A7%82%E5%AF%9F%E8%80%85%E6%A8%A1%E5%BC%8F&amp;url=/archives/oberver-pattern" width="1" height="1" alt="" style="opacity:0;">
<h2 id="1初步认识">1、初步认识</h2>
<p><strong>观察者模式的定义：</strong><br>
  　　在对象之间定义了一对多的依赖，这样一来，当一个对象改变状态，依赖它的对象会收到通知并自动更新。<br><strong>理解：</strong><br>
  　　当被观察者状态改变时通知观察者处理，注意观察者模式与发布订阅模式不同的是观察者模式没有调度中心通俗讲叫经纪人</p>
<h2 id="2这个模式的结构图">2、这个模式的结构图</h2>
<p><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2Fimage_1576584151218.png%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" alt="image.png"><br>
  上图解释：</p>
<ul>
 <li>抽象被观察者角色：也就是一个抽象主题，它把所有对观察者对象的引用保存在一个集合中，每个主题都可以- - 有任意数量的观察者。抽象主题提供一个接口，可以增加和删除观察者角色。一般用一个抽象类和接口来实现。</li>
 <li>抽象观察者角色：为所有的具体观察者定义一个接口，在得到主题通知时更新自己。</li>
 <li>具体被观察者角色：也就是一个具体的主题，在集体主题的内部状态改变时，所有登记过的观察者发出通知。</li>
 <li>具体观察者角色：实现抽象观察者角色所需要的更新接口，一边使本身的状态与制图的状态相协调。</li>
</ul>
<h2 id="3使用场景例子">3、使用场景例子</h2>
<p>有一个微信公众号服务，不定时发布一些消息，此时，用户关注公众号那么用于就相当于观察者而公众号则是被观察者，当被观察者状态改变时(有新文章)则观察者会观察到状态改变(收到文章)</p>
<h2 id="4观察者模式具体实现">4、观察者模式具体实现</h2>
<p>1、定义一个抽象被观察者接口</p>
<pre><code class="language-java">/**
 * 抽象被观察者接口
 * 声明了添加、删除、通知观察者方法
 * @author guqing
 * @date 2019-12-17 19:25
 */
public interface Observerable {
    void addObserver(Observer o);
    void removeObserver(Observer o);
    void notifyObserver();
}
</code></pre>
<p>2、定义一个抽象观察者接口</p>
<pre><code class="language-java">/**
 * 抽象观察者
 * @author guqing
 * @date 2019-12-17 19:30
 */
public interface Observer {
    /**
     * 定义了一个update()方法，当被观察者调用
     * notifyObservers()方法时，观察者的update()方法会被回调。
     * @param message 消息
     */
    void update(String message);
}
</code></pre>
<p>3、定义被观察者，实现了Observerable接口，对Observerable接口的三个方法进行了具体实现，同时有一个List集合，用以保存注册的观察者，等需要通知观察者时，遍历该集合即可。</p>
<pre><code class="language-java">
/**
 * 被观察者，也就是微信公众号服务
 * 实现了Observerable接口，对Observerable接口的三个方法进行了具体实现
 * @author guqing
 * @date 2019-12-17 19:32
 */
@Data
public class WechatServer implements Observerable{
    private List&lt;Observer&gt; list = new ArrayList&lt;&gt;();
    private String message;

    @Override
    public void addObserver(Observer o) {
        this.list.add(o);
    }

    @Override
    public void removeObserver(Observer o) {
        if(!list.isEmpty()) {
            list.remove(o);
        }
    }

    @Override
    public void notifyObserver() {
        list.forEach(observer -&gt; {
            observer.update(message);
        });
    }

    public void setInfomation(String s) {
        this.message = s;
        System.out.println("微信服务更新消息： " + s);
        //消息更新，通知所有观察者
        notifyObserver();
    }
}
</code></pre>
<p>4、定义具体观察者，微信公众号的具体观察者为用户User</p>
<pre><code class="language-java">
/**
 * 观察者
 * 实现了update方法
 * @author guqing
 * @date 2019-12-17 19:52
 */
public class User implements Observer {
    private String name;
    private String message;

    public User(String name) {
        this.name = name;
    }

    @Override
    public void update(String message) {
        this.message = message;
        read();
    }

    private void read() {
        System.out.println(name + " 收到推送消息： " + message);
    }
}
</code></pre>
<p>5、编写一个测试类</p>
<p>首先注册了三个用户，ZhangSan、LiSi、WangWu。公众号发布了一条消息<code>PHP是世界上最好用的语言！</code>，三个用户都收到了消息。<br>
  用户ZhangSan看到消息后颇为震惊，果断取消关注(观察者取消观察)，这时公众号又推送了一条消息，此时用户ZhangSan已经收不到消息，其他用户还是正常能收到推送消息。</p>
<pre><code class="language-java">
/**
 * 观察者模式测试类
 * @author guqing
 * @date 2019-12-17 19:54
 */
public class ObserverTest {
    public static void main(String[] args) {
        WechatServer server = new WechatServer();

        // 创建三个用户
        Observer userZhang = new User("ZhangSan");
        Observer userLi = new User("LiSi");
        Observer userWang = new User("WangWu");

        // 让三个用户 关注(叫观察会更贴切些) 该服务(被观察者)
        server.addObserver(userZhang);
        server.addObserver(userLi);
        server.addObserver(userWang);

        // 向服务器（被观察者状态改变）中发送消息
        server.setInfomation("PHP是世界上最好用的语言！");

        /*
         * 输出结果:
         * 微信服务更新消息： PHP是世界上最好用的语言！
         * ZhangSan 收到推送消息： PHP是世界上最好用的语言！
         * LiSi 收到推送消息： PHP是世界上最好用的语言！
         * WangWu 收到推送消息： PHP是世界上最好用的语言！
         */

        System.out.println("---------------------------------");
        // 张三一听不是Java恼火了取消观察
        server.removeObserver(userZhang);

        // 被观察者数据改变，观察者监听到改变作出相应处理
        server.setInfomation("JAVA是世界上最好用的语言！");
        /*
         * 输出结果：
         * 微信服务更新消息： JAVA是世界上最好用的语言！
         * LiSi 收到推送消息： JAVA是世界上最好用的语言！
         * WangWu 收到推送消息： JAVA是世界上最好用的语言！
         */
    }
}
</code></pre>
<p>测试结果：<br><img src="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F20191217201248_1576584909545.png%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" alt="20191217201248"></p>
<blockquote>
 <p>原文内容来自：神仙果 本站略作修改<br><a href="https://www.cnblogs.com/luohanguo/p/7825656.html">点击查看原文</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/oberver-pattern</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F20_1590134173668.jpg%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" type="image/jpeg" length="0"/><category>Java后端</category><pubDate>Tue, 17 Dec 2019 12:06:00 GMT</pubDate></item><item><title><![CDATA[给你的Arch Linux 清理空间腾出地方]]></title><link>https://guqing.io/archives/clean-archlinux</link><description><![CDATA[<img src="https://guqing.io/plugins/feed/assets/telemetry.gif?title=%E7%BB%99%E4%BD%A0%E7%9A%84Arch%20Linux%20%E6%B8%85%E7%90%86%E7%A9%BA%E9%97%B4%E8%85%BE%E5%87%BA%E5%9C%B0%E6%96%B9&amp;url=/archives/clean-archlinux" width="1" height="1" alt="" style="opacity:0;">
<h2 id="引言">引言</h2>
<p>使用Arch Linux 时间长了，空间越来越少，不禁想到要清理一下空间。 我将清理的内容分成三部分，清理安装包缓存，清理孤立的软件包，以及清理日志。</p>
<h2 id="清理安装包缓存">清理安装包缓存</h2>
<p>使用如下命令</p>
<pre><code class="language-shell">$ sudo pacman -Scc
</code></pre>
<p>不仅会删除未安装或旧版本的包文件缓存，也会将安装着的包的包文件缓存也一并删除。这部分是最占空间的，我大概有7-8G。</p>
<h2 id="清理孤立的软件包">清理孤立的软件包</h2>
<p>使用如下命令</p>
<pre><code class="language-shell">sudo pacman -Rns $(pacman -Qtdq)
</code></pre>
<p>就可以删除孤立的软件包。什么是孤立的软件包呢？比如我想要吃西餐，我需要买刀叉。刀叉即西餐的一个依赖，西餐依赖于刀叉。如果我再也不想要吃西餐了，那么已经买来的刀叉也没用了。这个刀叉即孤立的软件包。既然用不上了，那么可以删掉。我大概清理了2-3G。</p>
<h2 id="清理日志">清理日志</h2>
<p>使用如下命令</p>
<pre><code class="language-shell">journalctl --vacuum-size=50M
</code></pre>
<p>可以限制日志记录大小在50M，我使用一年没清理过，日志记录大概在2G左右。设置了固定大小为50M后，多的日志就会被删掉。</p>
<blockquote>
 <p>本文章作者: 杨宇昊<br><br>
   链接: <a href="https://qsctech-sange.github.io/arch-linux-clean">https://qsctech-sange.github.io/arch-linux-clean</a></p>
</blockquote>]]></description><guid isPermaLink="false">/archives/clean-archlinux</guid><dc:creator>guqing</dc:creator><enclosure url="https://guqing.io/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fguqing-blog.oss-cn-hangzhou.aliyuncs.com%2F16_1590134173050.jpg%3Fx-oss-process%3Dstyle%2Fcommon_style&amp;size=m" type="image/jpeg" length="0"/><category>Linux</category><pubDate>Mon, 9 Dec 2019 05:12:43 GMT</pubDate></item></channel></rss>