2024年3月

大家好,我是小富~

好久没发这种实用贴了,最近用到了一些能提升工作效率的
IDEA
插件,给小伙伴们分享一下。相信我,我分享的这些插件,都是实实在在能解决实际开发场景中痛处的。

以往的两篇IDEA插件分享:

10个 解放双手的 IDEA 插件,少些冤枉代码

10个解放双手的 IDEA 插件,这些代码真不用手写(第二弹)

1、POJO to JSON

开发工作中,常常在设计完
API
后,会使用如
GsonFormat
工具来将设计文档上的JSON格式数据生成Java实体类,这可以节省很多时间。

不过,在某些情况下,我们需要将已有实体类转换为JSON格式数据,那我通常不得不依赖于手动执行单元测试或在
main
方法中,使用JSON处理库(例如
fastjson
等)来实现对象到JSON字符串的转换,是一件比较麻烦的事情。

使用
POJO to JSON
插件就让这件事容易的多,只要在实体类名上右键执行
MakeJson
,即可生成该类对应的JSON数据,而且它支持多层级的对象。

2、Cool Request

Cool Request
是一款基于IDEA的HTTP调试工具,可以看成是轻量版的postman,它会自动扫描项目代码中所有API路径,按项目分组管理。一个类被定义为
Controller
且其中的方法被
@RequestMapping
或者
@XXXMapping
注解标注以后就会被扫描到。

对应方法左侧会出现一个标志,点击展开右侧边栏就可以像在
Postman
中一样进行调试。它支持CURL命令的导入导出、环境设置等功能,基本满足开发调试需求。而且,还支持将API导出到
Apifox
,使得文档导出变得非常简单。整体而言,这个功能确实很实用。

3、CamelCase

CamelCase
是个命名风格转换插件,在进行代码规范扫描审查时,我觉得这个插件非常实用,修改起来很简单。举个例子:假设我将静态变量命名为aaaBaa,但作为一名资深程序员来说,这样命名静态变量显然不够专业啊!

public static final String aaaBaa = "aaaBaa";

为了更加规范些,我们要将变量名改为大写字母下划线分割的格式。使用
CamelCase
可以在
kebab-case

SNAKE_CASE

PascalCase

camelCase

snake_case
和 空格风格之间切换。

mac快捷键为 ⇧+⌥+ U ,windows 下为 Shift + Alt +U。

4、any-rule

any-rule
是一个正则表达式字典库,尽管在日常开发中其使用频率可能不是特别高,但它无疑是一个极为实用的工具。它涵盖了广泛的正则表达式类型,几乎包括了你能想到的所有规则。

使用直接右键选择
AnyRule
,支持搜索、正则验证、示例展示。

5、Grep Console

Grep Console
可以自定义控制台输出格式,对控制台中不同级别的日志设置成不同的颜色。

验证一下不同级别的日志在控制台输出的情况,不同级别显示不同颜色,这样在控制台查看日志就一目了然了。

@Test
public void logTest() {
    log.info("公众号:程序员小富");
    log.error("公众号:程序员小富");
    log.warn("公众号:程序员小富");
    log.debug("公众号:程序员小富");
    log.trace("公众号:程序员小富");
}

6、GenerateO2O

GenerateO2O
插件可以让我们少些很多代码,比如在开发接口时,常常需要将查询得到的PO对象转换为DTO对象。通常情况下,需要手动编写
Converter
转换方法,但借助这个插件,可以自动生成转化属性的代码,可以少写很多无趣代码。

7、Smart input

Smart input
解决了一件很烦的事,它能帮我们在不同的编码区域内切换输入法状态。就是这个插件可以在我要写注释时它切换成中文输入,写代码就会切换成英文输入法,不用在手动切换了。

8、Squaretest

这款插件能够自动生成Mock单元测试,之前我认为它是一个可有可无的插件。直至公司开始实施代码质量管控并将单元测试覆盖率列为KPI的一部分。不过,对于习惯编写单元测试的同学来说,这个插件无疑是一把解放双手的利器!

这个插件生成的mock测试代码基本上可以直接运行,质量很高。不过好像现在收费了,退而求其次可以使用
TestMe

9、TONGYI Lingma

通义灵码是阿里推出的一款AI编程插件,经过我的实际体验后,勉勉强强可以平替
GPT

Copilot
,更重要的是它免费!

该插件可以通过自然语言描述你想要的功能,可直接在编辑器区生成代码;根据代码上下文补全代码;支持根据JUnit、Mockito、Spring Test、unit test、pytest 等框架生成单元测试(和
Squaretest
一样);它还具备和GPT一样的问答功能。

10、Auto filling Java call arguments

例如,当在方法A中调用方法B时,由于方法B具有较多参数,使用该插件可以自动填充调用方法的参数,无需逐个手动填写,从而节省大量复制粘贴的操作。

总结

感谢这些插件的开发者们,为我们提供了如此便利高效的工具,极大地提升了我们编程的效率。希望大佬们继续为开发者社区带来更多优秀的工具和创新,让编程变得更加愉快和高效。

我是小富~ 下期见!


系统选择


目前市面上主流的桌面操作系统在大多数人眼里只有Windows和MacOS,那为什么我没选择它们两呢?

首先,不选MacOS的原因,就是太贵。当然这是我的原因不是苹果的原因,我最早使用Linux写代码的时候是2018年,那时候刚毕业上班不久,根本买不起Mac(虽然现在也觉得有点贵)。

在没有接触Linux之前,我也是使用Winows的。众所周知,其实Windows也是收费的,那时候用的基本是破解版本的Windows系统。虽然当时买笔记本电脑会预装Windows系统,但是预装的基本都是家庭版。依稀记得当时Windows家庭版无法开启某些Windows功能(没记错的话当时是无法开启Windows的虚拟化功能),所以就算我买了预装Windows系统的笔记本电脑,依然还是需要自行重装Windows专业版系统,那时候就是使用激活软件破解Windows专业版。但是作为程序员,我个人是很反感破解软件的,因为我不想以后我写的软件也被别人破解。然后在某些机缘巧合之下,我加入了一些Linux爱好者社区,慢慢开始接触Linux桌面。


重新认识Linux


我第一次接触Linux是大学的课程,那时候教学用的是Centos。那时候在我印象中Linux就是黑乎乎的命令行窗口,只能部署一些服务端的的程序,毕业以后从事Java后端开发,接触的Linux也是服务端版本,所以我根本不知道Linux还有桌面环境!

直到一次偶然的机会,在B站刷到一位大佬自己整活的Linux桌面,相当个性化,让我深受震撼。然后就开始根据视频信息去搜索Linux桌面相关的知识,也因此加入了一些Linux社区,从此开始我的Linux桌面探险之旅。


Linux桌面


在开始介绍我的Linux探险之旅前,先简单说一下Linux的组成,Linux的组成部分可以分为以下三个主要部分:


  1. Linux内核(Kernel)
    : Linux内核是操作系统的核心,负责管理硬件资源,提供系统服务,以及允许软件应用进行控制硬件的基础。它是一个由Linus Torvalds在1991年首次发布的开源软件项目,至今仍在不断的发展和更新中。 GitHub - torvalds/linux: Linux kernel source tree

  2. Linux发行版(Distributions)
    : Linux发行版是基于Linux内核的操作系统,包括了内核以及一套完整的软件和应用程序,如GNU工具集、桌面环境、办公软件等。每个发行版都可能有不同的包管理系统、默认桌面环境、预装软件、用户界面以及独特的安装和配置工具。常见的Linux发行版包括Ubuntu、Fedora、Debian、CentOS、Arch Linux、Red Hat Enterprise Linux等。

  3. 桌面环境(Desktop Environment, DE)
    : 桌面环境是提供图形用户界面(GUI)的软件集合,使得用户可以通过图形窗口和菜单进行交互,而不是仅通过命令行。桌面环境通常包括窗口管理器、图标、工具栏、文件管理器、终端模拟器等组件。常见的桌面环境有GNOME、KDE Plasma、XFCE等。

Linux内核和发行版以及桌面环境的组合提供了所需的完整Linux桌面系统。用户可以根据自己的需要选择合适的发行版和桌面环境,以满足个人偏好和特定用途。

这里再顺便提一下GNU和Linux的关系,可能大家经常会看到”GNU/Linux“这样的词汇。

GNU和Linux是两个密切相关但各自独立的概念,它们共同构成了现代Linux操作系统的基础:


GNU

: GNU是一个自由软件操作系统项目,由Richard Stallman在1983年启动,目的是创建一个完全由自由软件组成的操作系统。"GNU"是"GNU's Not Unix"的递归缩写,表明GNU虽然类似于Unix,但不包含任何Unix代码。GNU项目创建了许多用于操作系统的核心组件,如GNU Compiler Collection (GCC)、GNU C Library (glibc)和GNU Core Utilities (coreutils)。GNU项目也提倡自由软件哲学,推动了GNU通用公共许可证(GPL)的编制和普及。

今天谈论的Linux操作系统,通常指的是"GNU/Linux"系统,它是指使用Linux内核和GNU软件及工具的操作系统。Linux提供了操作系统的核心,即内核,而GNU提供了构建完整操作系统所需的许多基本工具和库。

但是大多数情况下,通常将整个操作系统简称为"Linux",但严格来讲,当包括了大量GNU软件的时候,它应该被称为"GNU/Linux"。


那些年我折腾过的Linux桌面


上面给大家说了,在安装Linux的时候我们需要考虑三个部分,Linux内核、发行版、桌面环境(DE)。但是发行版其实就是Linux内核+工具集,所以发行版是依赖Linux内核才能运行的。因此大多数时候你选择了一个发行版里面就包含了内核,就不需要再选择Linux内核了(安装完成后可以更换内核)。

而我第一款Linux桌面系统是Ubuntu+GNOME(DE 桌面环境)的组合。至于为什么是Ubuntu+GNOME,因为在那时候我只接触过Centos和Ubuntu。而我去网上搜索Centos和Ubuntu的桌面版本时,大多数网站都是建议Ubuntu桌面,事实上也是如此。而Ubuntu桌面版默认的桌面环境(DE)就是GNOME。

这么多年过去了,我陆续使用过很多Linux发行版和DE的不同组合。发行版使用过Manjaro、大蜥蜴、Debian、Deepin(非UOS)、Arch、CutfishOS等,桌面环境使用过GNOME、KDE、XFCE、DDE(国产Deepin DE)、I3等。目前(2024-03-11)使用的是Arch+KDE,在写这篇文章的时候刚好KDE6发布了,这几天也折腾了一下KDE6,给大家看看效果:


使用Linux遇到的问题?


上面讲了我是如何选择Linux桌面的,接下来讲讲大家可能最关心的问题,那就是Linux桌面能不能像Windows和MacOS那样正常办公或者开发?

Linux桌面相比Windows和MacOS目前最大的问题就是生态,我记得我刚开始使用Linux桌面的时候国内很多软件都没有Linux版本,大多数情况下都是使用Wine等技术来兼容使用Windows版本。目前情况改善了很多,目前主流的国产办公软件都有Linux版本了,比如我们常用的钉钉、飞书、腾讯会议等。QQ、微信也都出了Linux原生版本。

这里不得不吐槽一下腾讯了,QQ是2023年开始推出全平台重构版本的,在这之前的QQ Linux版本几乎就不可用;而微信截至目前(2024-3-11)还只有一个原生Linux测试版本,而且还有白名单限制仅限UOS和麒麟系的Linux发行版使用。反观人家WPS,Linux版本的WPS一直都没有广告,虽然前不久WPS也官宣Windows版本去掉广告了,而且WPS Linux版本一直活跃更新,功能也不比Windows版本少。

而开发相关的软件,作为Java后端常用的Idea、DataGrip、Navicat等都有Linux版本,其他的开发工具大多数也都有Linux版本,比如VsCode。这里不得不说国外的软件在全平台支持上确实要比国内软件好太多了。

这里给大家列一下我常用的一些软件:

功能 软件名称 官方地址
截图 Flameshot https://flameshot.org/
邮件 Mailspring https://www.getmailspring.com/
文件管理 Spacedrive https://github.com/spacedriveapp/spacedrive
文档处理 Wps https://www.wps.com/office/linux/
看图 Gthumb https://github.com/GNOME/gthumb
SSH Xterminal https://xterminal.cn/
视频剪辑 Kdenlive https://kdenlive.org/zh/
字幕 Arctime https://arctime.org/
Java IDE IntelliJ IDEA https://www.jetbrains.com/idea/
DataBase IDE DataGrip https://www.jetbrains.com/datagrip/
Vpn V2raya https://v2raya.org/
输入法 Rime + Fcitx5 https://rime.im/
文本编辑 Neovim https://neovim.io/
系统备份 Timeshift https://github.com/linuxmint/timeshift
接口测试 Insomnia https://insomnia.rest/
Redis客户端 Redisinsight https://github.com/RedisInsight/RedisInsight
终端 Warp https://www.warp.dev/


Linux桌面的优缺点


在这里总结一下我认为的Linux桌面的优点和缺点。

  • 优点:
    1. 开源免费,无论是Windows还是MacOS都是收费的,而Linux是开源免费的,开源项目总比闭源项目在安全性上更可靠。
    2. 发行版众多,Linux有很多发行版,可以根据自己的喜好选择不同的发行版,不会像Windows和MacOS那样被一家公司或者组织束缚。
    3. 自由度高,Linux上面你可以随便折腾,一个发行版可以换不同的桌面环境,不会像Windows和MacOS那样只有一个桌面环境,而且桌面环境可以高度个性化。
    4. 稳定性好,Linux的稳定性比Widnows要好很多,这也是服务器操作系统大多数都是Linux的原因之一。
    5. 技术支持好,这个跟发行版有很大的关系,比如我正在使用的Arch Linux,有非常完善的Wiki系统,而且有非常活跃的社区。当你在使用中出现问题,绝大多数情况下你都可以在Arch wiki上找到解决方案,极少数找不到解决方案的问题你也可以在Arch社区发帖提问,会有很多热心的大佬给你提供建议和帮助。这点我想如果你不是Widnows企业用户的话估计很难得到技术支持。
  • 缺点:
    1. 软件生态差,目前Linux相比Windows和MacOS最大的问题就是软件应用生态不足了,但是如果只是普通办公和开发也完全足够了。
    2. 对新手不友好,虽然Linux桌面环境已经在努力改善了,但是依然会出现很多和开发相关的名词和知识,对于非计算机行业的朋友可能不太友好。
    3. 游戏支持差,对于喜欢玩游戏的朋友来说,Linux就不适合了,虽然Steam也有Linux版,但是里面可玩的游戏很少。

上面的优缺点我认为是对所有人都适用的,我作为Java后端使用Linux开发还有其他的优势:

  1. 容器化技术原生支持,比如Docker,就是使用Linux命名空间(namespaces)和资源隔离(CGroups)实现的。
  2. Linux上写的Java代码直接部署在Linux服务器上能避免一些操作系统差异引起的bug,虽然Java号称跨平台的,但是还是可能会因为一些操作系统上的差异导致的bug。比如Tomcat默认端口是80,如果你在Widnows上开发是能正常运行的,但是你将写好的程序打包到Linux上以非root用户是无法运行的,因为Linux上1024以下的端口是普通用户无法使用的。
  3. 能学习更多的Linux知识,对于工作中需要使用Linux的人来说,这确实能提升技术能力。

以上就是我分享的为什么使用Linux做开发的经验,当然也不是劝大家使用Linux桌面,这个根据个人情况自己决定,本文只是纯经验分享。

本文使用
markdown.com.cn
排版

在 2023 年的年底,我终于有时间下定决心把我的
UtilMeta 项目官网
进行翻新,主要的原因是之前的官网是用 Vue2 实现的一个 SPA 应用,对搜索引擎 SEO 很不友好,这对于介绍项目的官网来说是一个硬伤

所以在调研一圈后,我准备用
Vite-SSG
+
Vue3
+
Vuetify3
把官网重新来过,前后花了两周左右的时间,本文记录着开发过程中的思考和总结,要点主要有

  • 为什么 SPA 应用不应该用于搭建项目官网?
  • SSG 项目的结构是怎样的,如何配置页面的路由?
  • 如何搭建多语言的静态站,编写支持多语言的页面组件,以及使用
    lang
    /
    hreflang
    为页面指定不同的语言版本?
  • 如何用
    unhead
    库为每个页面配置不同的 html 头部元信息,优化搜索引擎收录?
  • 如何使用
    @media
    CSS 媒体规则处理响应式页面在不同设备的首屏加载问题?
  • 如何优雅处理 404 问题,避免 soft 404 对搜索收录的影响?

为什么不应该用 SPA 开发官网

这里我们先收窄一下定义,把【官网】定义为一个介绍性质为主的网站,比如产品介绍,定价方案,关于我们等等,而不是一个直接交互的动态产品(比如各种各样的 2C 内容平台,社交平台),对于动态产品而言使用 SPA 其实无妨,如果想优化搜索收录可以定期把一些固定的 profile 页面或者文章页面提交给搜索引擎

所以就是一个原因,
SEO
。这是老生常谈的问题,SPA 只会生成单个 index.html,爬取你网站上的任何 URL 都只会返回同样的内容,其中还往往不包括即将渲染出的文本,关键词和链接等信息,这就导致搜索引擎呈现的结果一塌糊涂,不仅如此,在 Twiiter, Discord 等社交媒体直接抓取链接元信息(标题,描述,插图)并渲染的平台上,你的每个网页都只会呈现一样的信息

对于一个需要在互联网上获客的项目,我们都不应该忽视来自搜索引擎的流量,尤其是国际化的项目。即使我们来到了 AIGC 纪元,以 ChatGPT 为代表的大模型训练语料获取仍然以爬取网页数据为主,这时你的项目各页面如果能够提供清晰的,包含足够准确的关键词和信息的,符合 Web 规范的 HTML 结果,你的项目或文档也有可能会被 AI 收录并整合到它们的输出结果中,所以我认为对网页结构和渲染的优化其实就是可以统称为 Agent Optimization,即【对来自搜索引擎或大模型的】网络爬取优化,依然十分重要

合适的姿势是?

SSR(服务端渲染) / SSG(服务端生成) 都是介绍性官网开发的合适姿势,对于不需要太多渲染逻辑的静态页面来说,SSG 就足矣,你只需要把生成出来的 HTML 扔到任何页面托管网站上都可以直接提供访问,对 CDN 也足够友好,如果自己喜欢折腾也可以搞自己的服务器来部署,我自己就是使用 nginx 来部署 SSG 生成的静态页面作为 CDN 的回源

SSG 项目结构

与 SPA 应用相比,SSG 项目最主要的区别是:路由与对应的页面模板是固定的,并且在构建阶段会直接生成每个页面的 html 文件,而不是像 SPA 一样只生成一个
index.html

反映到 Vue 项目的文件结构上,SPA 应用往往需要一个 router 文件来定义 vue-router 的路由和对应的组件,而 SSG 应用则可以把每个页面的路由和对应的 Vue 页面组件直接定义在一个文件夹中(往往命名为
pages

所以 Vite-SSG 项目的
main.js
一般长这个样子:

import App from './App.vue'
import { ViteSSG } from 'vite-ssg'
import routes from '~pages';
import vuetify from './plugins/vuetify';

export const createApp = ViteSSG(
  App,
  // vue-router options
  {routes, scrollBehavior: () => ({ top: 0 }) },
  // function to have custom setups
  ({ app, router, routes, isClient, initialState }) => {
    // install plugins etc.
    app.use(vuetify)
  },
)

我们用 vite-ssg 定义的
ViteSSG
来代替 Vue 默认的
createApp
,在导入路由时,我们使用了

import routes from '~pages';

这是来自
vite-plugin-pages
插件的支持,你可以直接把一个文件夹下的 Vue 组件转化为对应的页面路由,只需要在
vite.config.js
中配置

// Plugins
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import Pages from 'vite-plugin-pages'

export default defineConfig(
  ({command, mode}) => {
    return {
      plugins: [
        Pages({
          extensions: ['vue', 'md'],
        }),
        ...
      ],
      ...
    }
  }
)

处理多语言页面路由

如果你的官网需要给来自世界各地的用户介绍你们的项目,多语言就基本上是一个必选项了,我们以支持中文与英文为例,其他的语言支持方式可以依此类推

之前我对于多语言的处理是根据 IP 属地返回语言然后前端直接设置语言,并没有反应到 URL 上,这其实是一种 Bad Practice,对于用户访问的时候看到的是什么语言版本的页面完全不可控(因为他们可能使用了代理),用户在分享页面时他的受众也是同理,搜索引擎也无法完全抓取所有的语言版本(因为 Google 的爬虫主要在美国),所以 Google 也在
文档
中说明很不建议这样的做法

对于 SSG 的页面路由,我的多语言实现实践是:为每个页面实现一个
通用的页面组件
,其中定义一个属性
lang
,组件中展示的所有文字都可以根据这个
lang
属性选择对应的语言版本,由于页面的属性在 SSG 构建时会直接传入,所以会生成不同语言版本的 HTML 页面文件,一个最简化的页面组件示例如下

<script setup>
const props = defineProps({
  lang: {
    type: String,
    default() {
      return 'en'
    }
  },
})

const messages = {
  zh: {
    title: '构建数字世界的基础设施'
  },
  en: {
    title: 'Building the infrastructure of the digital world'
  },
}

const msg = messages[props.lang];
</script>

<template>
  <div>
    {{ msg.title }}
  </div>
</template>

接下来我们就可以搭建我们多语言页面的文件夹结构了,你可以选择把不同的语言都作为不同的子路由,比如

/pages
    /en
        index.vue
    /zh
        index.vue
    /ja
        index.vue
    /..

这样访问
/en
会进入英文页面,访问
/zh
会进入中文页面

还有一种方式是选择一种语言作为默认语言,如英语,然后将它的子路由置于与其他语言目录平行的位置,比如

/pages
    /zh
        index.vue
    /ja
        index.vue
    index.vue     # en

utilmeta.com
采用的是第二种模式,因为我想让官网的域名是可以直接访问和链接的,保持简洁,所以我对它的路由是这样规划的

/pages
    /zh
        index.vue ------ 首页(中文)
        about.vue ------ 关于我们(中文)
        solutions.vue -- 解决方案(中文)
        py.vue --------- UtilMeta Python 框架介绍(中文)
    index.vue    ------- 首页(英语)
    about.vue ---------- 关于我们(英语)
    solutions.vue ------ 解决方案(英语)
    py.vue ------------- UtilMeta Python 框架介绍(英语)

按照 JavaScript 的惯例,
index
就会被处理为与它的目录一致的路由,其他的名称会根据名称分配路由

其中,每个语言的页面组件都可以直接引入它对应的通用页面组件,然后将
lang
属性传入通用页面组件中,比如
/zh/about.vue
是中文的 “关于我们” 页面组件

<script setup>
import About from "@/views/About.vue";
import AppWrapper from "@/components/AppWrapper.vue";
</script>

<template>
  <AppWrapper lang="zh" route="about">
     <About lang="zh"></About>
  </AppWrapper>
</template>

其中
@/views/About.vue
是 “关于我们” 页面的通用组件,我们传入了
lang="zh"
,而 AppWrapper 是我编写的一个通用的页面骨架组件,包含着每个页面都需要的顶栏,底栏,边栏等页面架构

语言切换

对于支持多语言的官网,我们可以需要在其中添加一个让用户主动切换语言的按钮,它的逻辑也非常简单,只需要将用户展示一个支持的语言列表,然后每个语言按钮都能将用户切换到对应的页面路由,比如

<template>
	<v-menu open-on-click>
	  <template v-slot:activator="{ props }">
		<v-btn v-bind="props">
		  <v-icon>mdi-translate</v-icon>
		</v-btn>
	  </template>
	  <v-list color="primary">
		<v-list-item
          v-for="(l, i) in languages"
          :to="getLanguageRoute(l.value)"
          :active="lang === l.value"
          :key="i"
        >
          <v-list-item-title>{{ l.text }}</v-list-item-title>
        </v-list-item>
	  </v-list>
	</v-menu>
</template>

<script setup>
  const props = defineProps({
    lang: {
      type: String,
      default(){
        return 'en'
      }
    },
    route: {
      type: String,
      default(){
        return ''
      }
    }
  });

  const languages = [{
    value: 'en',
    text: 'English'
  }, {
    value: 'zh',
    text: '中文'
  }];
	
  function getLanguageRoute(l){
    if(l === 'en'){
      return '/' + props.route;
    }
    if(!props.route){
      return `/${l}`
    }
    return `/${l}/` + props.route
  }
</script>

还是以上面的 About 页面为例,如果用户目前处于
https://utilmeta.com/about
路由(英语),而点击了
中文
语言,就需要被引导到
https://utilmeta.com/zh/about
页面,从用户视角看来,页面的结构完全一致,只不过语言从英语切换到了中文

使用 unhead 为页面注入元信息

对于静态页面而言,
<head>
中的头信息与页面元信息非常重要,它决定着搜索引擎收录的索引与关键词,也决定着页面链接在社交媒体分享时渲染的信息,一般来说 Vue 的页面组件只是编写
<body>
中的元素,但只需要使用一个名为
unhead
的库,你就可以为不同的页面编写不同的头信息了,比如以下是我在 UtilMeta 中文首页的页面组件中编写的元信息

<script setup>
import { useHead } from '@unhead/vue'

const title = 'UtilMeta | 全周期后端 API 应用 DevOps 解决方案';
const description = '面向后端 API 应用的全生命周期解决方案,助力每个创造者,我们的产品有 UtilMeta Python 框架,一个面向后端 API 开发的渐进式元框架,API 管理平台,以及 utype';

useHead({
  title: title,
  htmlAttrs: {
    lang: 'zh'
  },
  link: [
    {
      hreflang: 'en',
      rel: 'alternate',
      href: 'https://utilmeta.com'
    }
  ],
  meta: [
    {
      name: 'description',
      content: description,
    },
    {
      property: 'og:title',
      content: title
    },
    {
      property: 'og:image',
      content: 'https://utilmeta.com/img/zh.index.png'
    },
    {
      property: 'og:description',
      content: description
    }
  ],
})

import Index from '@/views/Index.vue'
import AppWrapper from "@/components/AppWrapper.vue";

</script>

<template>
  <AppWrapper lang="zh">
    <Index lang="zh"></Index>
  </AppWrapper>
</template>

其中重要的属性有

  • title
    :页面的标题,直接影响着用户在浏览器中看到的页面标题与搜索引擎收录的网页中的标题
  • htmlAttrs.lang
    :可以直接在
    html
    根元素中编辑语言属性
    lang
    的值
  • hreflang
    :通过插入含有
    hreflang
    属性的
    <link>
    元素,你可以为页面指定不同的语言版本,这里我们就指定了首页的英文版本的链接,这样的属性能够更好地为搜索引擎的多语言呈现提供便利
  • meta.description
    :元信息中的描述,
  • og:*
    按照社交媒体渲染链接所通用的
    Open Graph 协议
    规定的属性,可以决定着你在把链接分享到如 Twitter(X), Discord 等社交媒体或聊天软件中时,它们的标题,描述和插图

元信息的注入应该是页面级的,也就是对于不同语言的页面,你也应该注入该语言版本的元信息

实现静态页面的响应式

你当然希望你的官网在宽屏电脑,平板和手机中都能有着不错的显示效果(或者至少不要出现元素错乱重叠),想要做到这些,就需要开发响应式的网页

我开发 UtilMeta 官网使用的是 Vue 组件库是 Vuetify,Vuetify 已经提供了一套 Display 系统和 breakpoints 机制,能够提供一系列响应式的断点,让我们在开发时为不同的设备指定不同的显示效果

比如

<v-row>
	<v-col :cols="display.xs.value ? 12 : 6">
	</v-col>
	<v-col :cols="display.xs.value ? 12 : 6">
	</v-col>
<v-row>

这样你就可以通过行列调节内容在不同尺寸设备上的显示了,示意如下

模板语法的问题

一切看起来都不错吧?你发现本地调试时确实能够做到响应式,但是当网站上线时却发现了问题

那就是,网页在电脑端加载时,也会默认保持移动端的样式,直到 js 加载完毕后,才会根据屏幕尺寸调整到合适的样式,这样在加载或刷新时,用户会看到网页的元素在几秒内发生了跳变,这是很奇怪的体验,那么为什么会造成这样的问题呢?

我打开了 vite-ssg 生成的 html 后发现,SSG 在生成时会直接把模板中的配置进行固定和渲染,对于类似下面的响应式代码

<v-col :cols="display.xs.value ? 12 : 6">
	<h1 :style='{fontSize: display.xs.value ? "32px" : "48px"}'></h1>
</v-col>

其实在构建成 HTML 文件时就会渲染成

<div class="v-col-12">
	<h1 :style="font-size: 32px"></h1>
</div>

渲染程序会直接把
display.xs.value
(以及其他的响应式条件)作为 true 来处理,得到的 HTML 文件就会把某一个设备的样式给固定,所以用户在加载时就只能等到控制响应式的 js 代码加载完毕才能够根据设备尺寸重新渲染,就会造成短暂的元素跳变的问题

救星 -
@media
CSS 媒体规则

那么如何正确处理静态页面的响应式样式呢?我探索出的答案是使用
@media
媒体规则,它可以让你根据屏幕的大小创建不同的样式规则,这样你的响应式样式就
完全由 CSS 控制
了,当页面渲染出来的时候(依赖的 css 加载完毕)就会完全按照 CSS 规则进行渲染,在不同设备刷新时也都会直接呈现适配对应设备尺寸的渲染结果,不会出现元素跳变的问题

比如我把 About 页面的标题添加了
about-title
类,然后在对应的 CSS 中编写

  .about-title{
    font-size: 60px;
    line-height: 72px;
    max-width: 800px;
    margin: 6rem auto 0;
  }

  @media (max-width: 600px){
    .about-title{
      font-size: 36px;
      line-height: 48px;
      margin: 3rem auto 0;
    }
}

这样,About 页面的标题在尺寸小于
600px
的设备中就可以按照
@media
块中定义的样式展现了

处理 v-row / v-col

Vuetify 提供的网格(v-row 控制行,v-col 控制列)系统可以很大程度提升响应式网页开发的效率,但是我们往往需要让行列的显示在不同的设备上保持响应式,然而
@media
属性尚不支持为不同的设备尺寸赋予不同的 HTML class,那么如何处理网格系统在 SSG 应用中的响应式呢?

下面是我的实践,仅供参考:对于需要在移动端切换行数的
v-col
组件,我们可以直接把它在移动端对应的行数命名为一个类,比如
xs-12-col

<v-row>
    <v-col :cols="6" class="xs-12-col">
    </v-col>
    <v-col :cols="6" class="xs-12-col">
    </v-col>
</v-row>

然后我们使用
@media
规则,在移动端尺寸的设备中直接为这些类指定网格样式参数,比如

@media (max-width: 600px) {
	.xs-12-col{
	  flex: 0 0 100%!important;
	  max-width: 100%!important;
	}
	.xs-10-col{
	  flex: 0 0 83.3%!important;
	  max-width: 83.3%!important;
	}
	.xs-2-col{
	  flex: 0 0 16.6%!important;
	  max-width: 16.6%!important;
	}
}

这样,我们的网格系统也可以支持 SSG 中的响应式样式,而不会出现加载跳变了

部署静态网站

优雅处理 404

在 SSG 静态页面中,我们的网站支持的路由是预先定义和生成好的,其他的路径访问都应该直接返回 404,但为了给用户更好的体验,一般常见的做法是单独制作一个
404 Notfound
页面,在访问路径没有页面时展示给用户,让他能方便地转回首页或其他页面,比如 UtilMeta 官网的 404 页面如下

使用 Vite-SSG 实现这样的效果并不困难,你只需要在
pages
文件夹中增加两个组件

  • 404.vue
  • [...all].vue

这两个组件中的内容都是相同的,都放置着 404 页面的组件代码,
[...all].vue
会作为所有没有匹配到路由的页面请求的返回页面,而
404.vue
会输出一个显式的路由
404.html
,方便在 nginx 中直接进行重定向

完成我们的 SSG 页面开发后,我们可以调用下面的命令将页面构建出对应的 HTML 文件

vite-ssg build

对于我的 UtilMeta 官网而言,生成的文件如下

/dist
    /zh
        about.html
        py.html
        solutions.html
    404.html
    zh.html
    about.html
    index.html
    py.html
    solutions.html

接着,你就可以将这些静态文件上传到页面托管服务或者自行搭建的静态服务器上即可提供访问了,我搭建 UtilMeta 官网的静态服务器使用的 nginx 配置如下

server{
    listen 80;
    server_name utilmeta.com;
    rewrite ^/(.*)/$ /$1 permanent;
    
    location ~ /(css|js|img|font|assets)/{
        root /srv/utilmeta/dist;
        try_files $uri =404;
    }
    location /{
        root /srv/utilmeta/dist;
        index index.html;
        try_files $uri $uri.html $uri/index.html =404;
    }

    error_page 404 403 500 502 503 504 /404.html;

    location = /404.html {
        root /srv/utilmeta/dist;
    }
}

配置中监听 80 而非 443 端口是因为我的官网作为静态站,官网需要的静态资源已经全部托管给 CDN 了(包括 SSL 证书),这里的 nginx 配置的是 CDN 的回源服务器,所以提供 HTTP 访问就 ok 了

nginx 配置中
rewrite ^/(.*)/$ /$1 permanent
的作用是将目录的访问映射到对相应 HTML 文件的访问,比如将
https://utilmeta.com/zh/
映射到
https://utilmeta.com/zh
,否则 Nginx 会出现 403 Forbidden 的错误

因为 vite-ssg 默认的生成策略会把位于目录路径的
index.vue
文件生成为与目录同名的 html 文件,而不是放置于目录中的
index.html
文件,所以如果不进行
rewrite
去掉路径结尾的
/
的话,
https://utilmeta.com/zh/
就会直接访问到
/zh/
目录上,这对于 nginx 来说是禁止的行为

值得注意的是,对于 404 页面的返回,最好需要伴随着一个真正的
404 响应码(Status Code)
,而不是使用 200 OK 的响应(那样一般称为软 404),因为对于搜索引擎而言,只有检测到 404 响应码,才会把这个路由视为无效,而不是判断返回页面中的文字,尤其当你的站点进行翻新时,老站点的一些路由就会失效了,如果它们一直留在搜索引擎的结果中误导用户,也会给访客造成很大的困扰

在上面的 nginx 配置中,我们把所有
try_files
指令最后都附上了
=404
,也就是在匹配不到任何文件时生成 404 的响应码,然后使用
error_page
把包括 404 在内的常见的错误或故障响应码的错误页面指定为
/404.html
,也就是我们之前编写的 404 页面,这样我们就解决了软 404 的问题,所有无法匹配的路径都会返回正确的 404 响应码以及制作好的 404 页面

总结

总结一下我们学到和完成的东西

  • 用 Vite-SSG 编写一个 SSG 官网项目,了解了 SSG 项目的页面路由方式
  • 编写可复用的多语言的 SSG 页面组件,通过路由切换实现语言切换功能
  • 使用
    unhead
    为每个页面注入头部元信息,使得每个页面在搜索引擎与社交媒体上都能正确美观地展示
  • 使用
    @media
    解决实现 SSG 静态页面的响应式中的问题,以及 Vuetify 网格布局在 SSG 响应式中的实践
  • 优雅处理静态页面的 404 问题,避免软 404,提高页面收录质量和用户体验

如果你觉得这篇文章有帮助,可以逛一下这篇文章中我最终构建的项目官网
utilmeta.com
,也可以关注一下我的
X(Twitter)
,我会不定期分享一些技术实践和项目

好家伙,狠狠地补一下代码量

本篇我们来尝试使用原生js实现vue的响应式

使用原生js,即代表没有v-bind,v-on,也没有v-model,所有语法糖我们都用原生实现

1.给输入框绑个变量

<body>
<input id="input_1"></input>
</body>
<script>let datavalue= "66666"const input_1= document.getElementById("input_1")
input_1.value
=datavalue
input_1.addEventListener(
'input', function(e) {
datavalue
=e.target.value
console.log(datavalue)
})
</script>

诶,似乎这样就完成了

但我们要让他更像vue

2.加上Dep,Watcher

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<input id="input_1"></input>
</body>
<script>
//模拟 Vue 实例
let data ={
message:
'Hello'};
const input_1
= document.getElementById("input_1")
input_1.value
=data.message
input_1.addEventListener(
'input', function(e) {
e.target.value
=data.message
console.log(datavalue)
})
functiondefineReactive(obj, key, value) {
let dep
= new Dep(); //依赖容器
Object.defineProperty(obj, key, {
get:
function() {if(Dep.target) {
dep.addDep(Dep.target);
}
return value;
},
set:
function(newValue) {
value
=newValue;
dep.notify();
}
});
}
//依赖容器
functionDep() {
this.deps
=[];

this.addDep
= function(dep) {
this.deps.push(dep);
};

this.notify
= function() {
this.deps.forEach(dep
=>{
dep.update();
});
};
}

Dep.target
= null;//Watcher
functionWatcher(updateFunc) {
this.update
=updateFunc;
}
//初始化响应式数据
defineReactive(data, 'message', data.message);//模拟 Watcher
let watcher = new Watcher(function() {
console.log(
'Message updated:', data.message);
input_1.value
=data.message
});
//模拟视图更新
Dep.target =watcher;
data.message;
//触发依赖收集
setTimeout(() =>{
data.message
= '6666'; //触发更新
}, 1000)</script>

</html>

3.效果图

4.代码解释

  1. defineReactive
    函数用来定义一个响应式属性,其中通过
    Object.defineProperty
    给属性添加 getter 和 setter 方法。在 getter 方法中,会判断
    Dep.target
    是否存在,如果存在则将当前 Watcher 对象添加到依赖容器 Dep 中;在 setter 方法中,更新属性的值,并通过依赖容器 Dep 的
    notify
    方法通知所有依赖的 Watcher 进行更新。

  2. Dep
    函数是一个简单的依赖容器,其中包含了一个 deps 数组用来存储依赖(Watcher),
    addDep
    方法用来添加依赖,
    notify
    方法用来通知所有依赖进行更新。

  3. Watcher
    函数用来创建 Watcher 对象,其中包含一个
    update
    方法,用来在属性发生变化时执行相应的更新操作。

  4. 在初始化响应式数据时,调用
    defineReactive
    函数定义了一个名为
    message
    的响应式属性。

  5. 创建了一个 Watcher 对象
    watcher
    ,并在其构造函数中定义了一个回调函数,用来在属性变化时输出消息并更新视图。


  6. watcher
    赋值给
    Dep.target
    ,然后访问
    data.message
    ,触发依赖收集,将
    watcher
    添加到依赖容器 Dep 中。

5.补充

一张响应式原理图


0. 前言


Kubernetes
架构中,
controller manager
是一个永不休止的控制回路组件,其负责控制集群资源的状态。通过监控
kube-apiserver
的资源状态,比较当前资源状态和期望状态,如果不一致,更新
kube-apiserver
的资源状态以保持当前资源状态和期望状态一致。

image

1. kube-controller-manager

下面从源码角度分析
kube-controller-manager
的工作方式。

kube-controller-manager
使用
Cobra
作为应用命令行框架,和
kube-scheduler

kube-apiserver
初始化过程类似,其流程如下:

image

这里,简要给出初始化代码示例:

# kubernetes/cmd/kube-controller-manager/app/controllermanager.go
func NewControllerManagerCommand() *cobra.Command {
    // 创建选项
    s, err := options.NewKubeControllerManagerOptions()
    ...
    cmd := &cobra.Command{
        ...
        RunE: func(cmd *cobra.Command, args []string) error {
            ...
            // 根据选项,创建配置
            c, err := s.Config(KnownControllers(), ControllersDisabledByDefault(), ControllerAliases())
			if err != nil {
				return err
			}
            ...
            return Run(context.Background(), c.Complete())
        },
        ...
    }
    ...
}

进入
Run
函数,看
kube-controller-manager
是怎么运行的。

# kubernetes/cmd/kube-controller-manager/app/controllermanager.go
func Run(ctx context.Context, c *config.CompletedConfig) error {
    ...
    run := func(ctx context.Context, controllerDescriptors map[string]*ControllerDescriptor) {
        // 创建上下文
		controllerContext, err := CreateControllerContext(logger, c, rootClientBuilder, clientBuilder, ctx.Done())
		if err != nil {
			logger.Error(err, "Error building controller context")
			klog.FlushAndExit(klog.ExitFlushTimeout, 1)
		}

        // 开始控制器,这是主运行逻辑
		if err := StartControllers(ctx, controllerContext, controllerDescriptors, unsecuredMux, healthzHandler); err != nil {
			logger.Error(err, "Error starting controllers")
			klog.FlushAndExit(klog.ExitFlushTimeout, 1)
		}

        // 启动 informer
		controllerContext.InformerFactory.Start(stopCh)
		controllerContext.ObjectOrMetadataInformerFactory.Start(stopCh)
		close(controllerContext.InformersStarted)

		<-ctx.Done()
	}

    // No leader election, run directly
	if !c.ComponentConfig.Generic.LeaderElection.LeaderElect {
        // 创建控制器描述符
		controllerDescriptors := NewControllerDescriptors()
		controllerDescriptors[names.ServiceAccountTokenController] = saTokenControllerDescriptor
		run(ctx, controllerDescriptors)
		return nil
	}
    ...
}


kube-scheduler
类似,
kube-controller-manager
也是多副本单实例运行的组件,需要
leader election
作为 leader 组件运行。这里不过多介绍,具体可参考
Kubernetes leader election 源码分析

运行控制器管理器。首先,在
NewControllerDescriptors
中注册资源控制器的描述符。

# kubernetes/cmd/kube-controller-manager/app/controllermanager.go
func NewControllerDescriptors() map[string]*ControllerDescriptor {
    register := func(controllerDesc *ControllerDescriptor) {
		...
		controllers[name] = controllerDesc
	}

    ...
    // register 函数注册资源控制器
    register(newEndpointsControllerDescriptor())
	register(newEndpointSliceControllerDescriptor())
	register(newEndpointSliceMirroringControllerDescriptor())
	register(newReplicationControllerDescriptor())
	register(newPodGarbageCollectorControllerDescriptor())
	register(newResourceQuotaControllerDescriptor())
    ...

    return controllers
}

# kubernetes/cmd/kube-controller-manager/app/apps.go
func newReplicaSetControllerDescriptor() *ControllerDescriptor {
	return &ControllerDescriptor{
		name:     names.ReplicaSetController,
		aliases:  []string{"replicaset"},
		initFunc: startReplicaSetController,
	}
}

每个资源控制器描述符包括
initFunc
和启动控制器函数的映射。


run

StartControllers
运行控制器。

# kubernetes/cmd/kube-controller-manager/app/controllermanager.go
func StartControllers(ctx context.Context, controllerCtx ControllerContext, controllerDescriptors map[string]*ControllerDescriptor,
	unsecuredMux *mux.PathRecorderMux, healthzHandler *controllerhealthz.MutableHealthzHandler) error {
    ...
    // 遍历获取资源控制器描述符
    for _, controllerDesc := range controllerDescriptors {
		if controllerDesc.RequiresSpecialHandling() {
			continue
		}

        // 运行资源控制器
		check, err := StartController(ctx, controllerCtx, controllerDesc, unsecuredMux)
		if err != nil {
			return err
		}
		if check != nil {
			// HealthChecker should be present when controller has started
			controllerChecks = append(controllerChecks, check)
		}
	}

    ...
    return nil
}

func StartController(ctx context.Context, controllerCtx ControllerContext, controllerDescriptor *ControllerDescriptor,
	unsecuredMux *mux.PathRecorderMux) (healthz.HealthChecker, error) {
    ...
    // 获取资源控制器描述符的启动函数
    initFunc := controllerDescriptor.GetInitFunc()
    
    // 启动资源控制器
	ctrl, started, err := initFunc(klog.NewContext(ctx, klog.LoggerWithName(logger, controllerName)), controllerCtx, controllerName)
	if err != nil {
		logger.Error(err, "Error starting controller", "controller", controllerName)
		return nil, err
	}
    ...
}

kubernetes
有多个控制器,这里以
Replicaset
控制器为例,介绍控制器是怎么运行的。

进入
Replicaset
控制器的
initFunc
函数运行控制器。

# kubernetes/cmd/kube-controller-manager/app/apps.go
func startReplicaSetController(ctx context.Context, controllerContext ControllerContext, controllerName string) (controller.Interface, bool, error) {
	go replicaset.NewReplicaSetController(
		klog.FromContext(ctx),
		controllerContext.InformerFactory.Apps().V1().ReplicaSets(),
		controllerContext.InformerFactory.Core().V1().Pods(),
		controllerContext.ClientBuilder.ClientOrDie("replicaset-controller"),
		replicaset.BurstReplicas,
	).Run(ctx, int(controllerContext.ComponentConfig.ReplicaSetController.ConcurrentRSSyncs))
	return nil, true, nil
}

运行
initFunc
实际上运行的是
startReplicaSetController

startReplicaSetController
启动一个
goroutine
运行
replicaset.NewReplicaSetController

ReplicaSetController.Run

replicaset.NewReplicaSetController
创建了
informer

Eventhandler

ReplicaSetController.Run
负责对
EventHandler
中加入队列的资源做处理。示意图如下:

image

首先,进入
replicaset.NewReplicaSetController
查看函数做了什么。

# kubernetes/pkg/controller/replicaset/replica_set.go
func NewReplicaSetController(logger klog.Logger, rsInformer appsinformers.ReplicaSetInformer, podInformer coreinformers.PodInformer, kubeClient clientset.Interface, burstReplicas int) *ReplicaSetController {
	...
	return NewBaseController(logger, rsInformer, podInformer, kubeClient, burstReplicas,
		apps.SchemeGroupVersion.WithKind("ReplicaSet"),
		"replicaset_controller",
		"replicaset",
		controller.RealPodControl{
			KubeClient: kubeClient,
			Recorder:   eventBroadcaster.NewRecorder(scheme.Scheme, v1.EventSource{Component: "replicaset-controller"}),
		},
		eventBroadcaster,
	)
}

func NewBaseController(logger klog.Logger, rsInformer appsinformers.ReplicaSetInformer, podInformer coreinformers.PodInformer, kubeClient clientset.Interface, burstReplicas int,
	gvk schema.GroupVersionKind, metricOwnerName, queueName string, podControl controller.PodControlInterface, eventBroadcaster record.EventBroadcaster) *ReplicaSetController {

	rsc := &ReplicaSetController{
		GroupVersionKind: gvk,
		kubeClient:       kubeClient,
		podControl:       podControl,
		eventBroadcaster: eventBroadcaster,
		burstReplicas:    burstReplicas,
		expectations:     controller.NewUIDTrackingControllerExpectations(controller.NewControllerExpectations()),
		queue:            workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), queueName),
	}

	rsInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
		AddFunc: func(obj interface{}) {
			rsc.addRS(logger, obj)
		},
		UpdateFunc: func(oldObj, newObj interface{}) {
			rsc.updateRS(logger, oldObj, newObj)
		},
		DeleteFunc: func(obj interface{}) {
			rsc.deleteRS(logger, obj)
		},
	})
	...

	podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
		AddFunc: func(obj interface{}) {
			rsc.addPod(logger, obj)
		},
		UpdateFunc: func(oldObj, newObj interface{}) {
			rsc.updatePod(logger, oldObj, newObj)
		},
		DeleteFunc: func(obj interface{}) {
			rsc.deletePod(logger, obj)
		},
	})
	...

	rsc.syncHandler = rsc.syncReplicaSet

	return rsc
}

函数定义了
ReplicaSetController

podInformer
,负责监控
kube-apiserver

ReplicaSet

Pod
的变化,根据资源的不同变动触发对应的
Event Handler

接着,进入
Run
查看函数做了什么。

# kubernetes/pkg/controller/replicaset/replica_set.go
func (rsc *ReplicaSetController) Run(ctx context.Context, workers int) {
	...
	// 同步缓存和 kube-apiserver 中获取的资源
	if !cache.WaitForNamedCacheSync(rsc.Kind, ctx.Done(), rsc.podListerSynced, rsc.rsListerSynced) {
		return
	}

	for i := 0; i < workers; i++ {
		// worker 负责处理队列中的资源
		go wait.UntilWithContext(ctx, rsc.worker, time.Second)
	}

	<-ctx.Done()
}

func (rsc *ReplicaSetController) worker(ctx context.Context) {
	// worker 是永不停止的
	for rsc.processNextWorkItem(ctx) {
	}
}

func (rsc *ReplicaSetController) processNextWorkItem(ctx context.Context) bool {
	// 读取队列中的资源
	key, quit := rsc.queue.Get()
	if quit {
		return false
	}
	defer rsc.queue.Done(key)

	// 处理队列中的资源
	err := rsc.syncHandler(ctx, key.(string))
	if err == nil {
		rsc.queue.Forget(key)
		return true
	}

	...
	return true
}

可以看到,
rsc.syncHandler
处理队列中的资源,
rsc.syncHandler
实际执行的是
ReplicaSetController.syncReplicaSet

理清了代码的结构,我们以一个删除
Pod
示例看
kube-controller-manager
是怎么运行的。

1.1 删除 Pod 示例

1.1.1 示例条件

创建
Replicaset
如下:

# helm list
NAME    NAMESPACE       REVISION        UPDATED                                 STATUS          CHART           APP VERSION
test    default         1               2024-02-29 16:24:43.896757193 +0800 CST deployed        test-0.1.0      1.16.0

# kubectl get replicaset
NAME                       DESIRED   CURRENT   READY   AGE
test-6d47479b6b            1         1         1       10d

# kubectl get pods
NAME                             READY   STATUS    RESTARTS   AGE
test-6d47479b6b-5k6cb            1/1     Running   0          9d

删除 pod 查看
kube-controller-manager
是怎么运行的。

1.1.2 运行流程

删除 pod:

# kubectl delete pods test-6d47479b6b-5k6cb

删除 pod 后,
podInformer

Event handler
接受到 pod 的变化,调用
ReplicaSetController.deletePod
函数:

func (rsc *ReplicaSetController) deletePod(logger klog.Logger, obj interface{}) {
	pod, ok := obj.(*v1.Pod)

	...
	logger.V(4).Info("Pod deleted", "delete_by", utilruntime.GetCaller(), "deletion_timestamp", pod.DeletionTimestamp, "pod", klog.KObj(pod))
	...
	rsc.queue.Add(rsKey)
}

ReplicaSetController.deletePod
将删除的 pod 加入到队列中。接着,worker 中的
ReplicaSetController.processNextWorkItem
从队列中获取删除的 pod,进入
ReplicaSetController.syncReplicaSet
处理。

func (rsc *ReplicaSetController) syncReplicaSet(ctx context.Context, key string) error {
	...
	namespace, name, err := cache.SplitMetaNamespaceKey(key)
	...

	// 获取 pod 对应的 replicaset
	rs, err := rsc.rsLister.ReplicaSets(namespace).Get(name)
	...

	// 获取所有 pod
	allPods, err := rsc.podLister.Pods(rs.Namespace).List(labels.Everything())
	if err != nil {
		return err
	}

	// Ignore inactive pods.
	filteredPods := controller.FilterActivePods(logger, allPods)

	// 获取 replicaset 下的 pod
	// 这里 pod 被删掉了,filteredPods 为 0
	filteredPods, err = rsc.claimPods(ctx, rs, selector, filteredPods)
	if err != nil {
		return err
	}

	// replicaset 下的 pod 被删除
	// 进入 rsc.manageReplicas
	var manageReplicasErr error
	if rsNeedsSync && rs.DeletionTimestamp == nil {
		manageReplicasErr = rsc.manageReplicas(ctx, filteredPods, rs)
	}
	...
}

继续进入
ReplicaSetController.manageReplicas

func (rsc *ReplicaSetController) manageReplicas(ctx context.Context, filteredPods []*v1.Pod, rs *apps.ReplicaSet) error {
	diff := len(filteredPods) - int(*(rs.Spec.Replicas))
	...
	if diff < 0 {
		logger.V(2).Info("Too few replicas", "replicaSet", klog.KObj(rs), "need", *(rs.Spec.Replicas), "creating", diff)
		...
		successfulCreations, err := slowStartBatch(diff, controller.SlowStartInitialBatchSize, func() error {
			err := rsc.podControl.CreatePods(ctx, rs.Namespace, &rs.Spec.Template, rs, metav1.NewControllerRef(rs, rsc.GroupVersionKind))
			if err != nil {
				if apierrors.HasStatusCause(err, v1.NamespaceTerminatingCause) {
					// if the namespace is being terminated, we don't have to do
					// anything because any creation will fail
					return nil
				}
			}
			return err
		})
		...
	}
	...
}


filteredPods
小于 Replicaset 中
spec
域定义的
Replicas
时,进入
rsc.podControl.CreatePods
创建 pod:

func (r RealPodControl) CreatePods(ctx context.Context, namespace string, template *v1.PodTemplateSpec, controllerObject runtime.Object, controllerRef *metav1.OwnerReference) error {
	return r.CreatePodsWithGenerateName(ctx, namespace, template, controllerObject, controllerRef, "")
}

func (r RealPodControl) CreatePodsWithGenerateName(ctx context.Context, namespace string, template *v1.PodTemplateSpec, controllerObject runtime.Object, controllerRef *metav1.OwnerReference, generateName string) error {
	...
	return r.createPods(ctx, namespace, pod, controllerObject)
}

func (r RealPodControl) createPods(ctx context.Context, namespace string, pod *v1.Pod, object runtime.Object) error {
	...
	newPod, err := r.KubeClient.CoreV1().Pods(namespace).Create(ctx, pod, metav1.CreateOptions{})
	...
	logger.V(4).Info("Controller created pod", "controller", accessor.GetName(), "pod", klog.KObj(newPod))
	...

	return nil
}

接着,回到
ReplicaSetController.syncReplicaSet

func (rsc *ReplicaSetController) syncReplicaSet(ctx context.Context, key string) error {
	...
	newStatus := calculateStatus(rs, filteredPods, manageReplicasErr)
	updatedRS, err := updateReplicaSetStatus(logger, rsc.kubeClient.AppsV1().ReplicaSets(rs.Namespace), rs, newStatus)
	if err != nil {
		return err
	}
	...
}

虽然 pod 重建过,不过这里的
filteredPods
是 0,
updateReplicaSetStatus
会更新 Replicaset 的当前状态为 0。

更新了
Replicaset
的状态又会触发
Replicaset

Event Handler
,从而再次进入
ReplicaSetController.syncReplicaSet
。这时,如果 pod 重建完成,
filteredPods
将过滤出重建的 pod,调用
updateReplicaSetStatus
更新
Replicaset
的当前状态到期望状态。

2. 小结

本文介绍了
kube-controller-manager
的运行流程,并且从一个删除 pod 的示例入手,看
kube-controller-manager
是如何控制资源状态的。