--- url: /src/technology/all.md --- --- --- url: /src/technology/docker/快速上手.md --- # Docker 快速上手 ## Centos7 安装 Docker 以下命令均以root权限执行 ### 卸载docker旧版本 ```bash yum remove docker docker-client docker-client-latest docker-common \ docker-latest docker-latest-logrotate docker-logrotate \ docker-selinux docker-engine-selinux docker-engine ``` ### 安装相关工具类 ```bash yum install -y yum-utils device-mapper-persistent-data lvm2 ``` ### 配置docker仓库 ```bash yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo # aliyun的源 yum-config-manager --add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo ``` ### 安装docker ```bash yum install docker-ce ``` ### 启动关闭重启docker ```bash systemctl start docker ``` ### 设置开机自启 ```bash chkconfig docker on ``` ### 配置国内镜像与存储目录 vim /etc/docker/daemon.json ```json { "registry-mirrors": [ "https://d9fafc8e81bc44498235a34713053f3e.mirror.swr.myhuaweicloud.com" ] } ``` ### 重启和关闭docker ```bash systemctl daemon-reload systemctl restart docker systemctl stop docker ``` ### 升级docker ce ```bash yum -y upgrade ``` ### 卸载docker ce ```bash yum remove docker-ce rm -rf /var/lib/docker ``` --- --- url: /src/technology/docker/Dockerfile常用参数.md --- # Dockerfile常用参数 **核心概念:** * **Dockerfile:** 一个文本文件,包含一系列用于构建 Docker 镜像的指令。 * **镜像层:** 每条指令(除了极少数如`ARG`)在执行时都会在基础镜像之上创建一个新的只读层。 * **构建上下文:** 运行 `docker build` 时指定的路径(或 URL)。Docker 守护进程在构建开始时会将整个上下文(递归地)发送给它。指令如 `COPY`、`ADD` 操作的文件必须位于此上下文中。 *** ## 一、基础与必备指令 ### FROM * **用途:** **指定基础镜像**。这是 Dockerfile 的**第一条**有效指令(`ARG` 可以在它之前,用于定义后续 `FROM` 使用的变量)。 * **语法:** ```dockerfile FROM [--platform=] [:] [AS ] ``` * **说明:** * ``: 基础镜像名称(如 `ubuntu`, `python`, `nginx`)。 * ``: 镜像标签(如 `20.04`, `3.9-slim`, `latest`)。**强烈建议指定明确版本**,避免因 `latest` 变化导致构建失败或不一致。 * `[AS ]`: 在**多阶段构建**中,为当前构建阶段命名,方便后续阶段引用。 * `--platform`: 指定目标平台(如 `linux/amd64`, `linux/arm64`)。 * **示例:** ```dockerfile FROM ubuntu:22.04 FROM python:3.11-slim-bullseye AS builder FROM --platform=linux/arm64 nginx:1.23-alpine ``` ### RUN * **用途:** **在构建过程中执行命令**。这些命令在构建时运行,用于安装软件包、编译代码、配置环境等。每条 `RUN` 都会创建一个新的镜像层。 * **语法 (两种形式):** * **Shell 形式:** `RUN ` (默认在 `/bin/sh -c` 下执行) ```dockerfile RUN apt-get update && apt-get install -y curl RUN echo "Hello from the build process" > /tmp/greeting.txt ``` * **Exec 形式:** `RUN ["executable", "param1", "param2"]` (直接执行可执行文件,不通过 shell 解析) ```dockerfile RUN ["/bin/bash", "-c", "echo 'Exec form' > /tmp/exec.txt"] ``` * **区别与最佳实践:** * **Shell 形式更常见,更易读。** 可以使用环境变量、管道 `|`、逻辑运算符 `&&`、`||`、`;`。 * **Exec 形式** 避免 shell 字符串解析可能带来的意外行为(特别是当参数包含空格或特殊字符时),并且明确指定了要运行的二进制文件。 **对于不依赖 shell 功能的简单命令或需要避免 shell 干扰的场景更合适。** * **关键点:** 将多个命令合并到一个 `RUN` 指令中(使用 `&&` 和 `\` 换行),**减少不必要的镜像层数量**。安装后记得清理缓存(如 `rm -rf /var/lib/apt/lists/*`)。 * **示例:** ```dockerfile RUN apt-get update \ && apt-get install -y --no-install-recommends \ git \ build-essential \ ca-certificates \ && rm -rf /var/lib/apt/lists/* \ && pip install --no-cache-dir some-package ``` ### CMD * **用途:** **为容器提供默认的启动命令和参数**。在容器启动时执行。一个 Dockerfile 中只能有一个有效的 `CMD` (如果写了多个,只有最后一个生效)。 * **语法 (三种形式):** * **Exec 形式 (推荐):** `CMD ["executable","param1","param2"]` * **Shell 形式:** `CMD command param1 param2` (会被包装成 `/bin/sh -c`) * **作为 `ENTRYPOINT` 的参数:** `CMD ["param1","param2"]` (必须与 `ENTRYPOINT` 的 Exec 形式结合使用) * **说明与行为:** * `CMD` 定义的是**默认**行为。用户在 `docker run` 命令行中指定的任何参数都会**覆盖整个 `CMD`**。 * **Exec vs Shell 形式:** 推荐使用 **Exec 形式**,原因与 `RUN` 类似:避免不必要的 shell 介入,信号处理更直接(容器中的 PID 1 是应用程序本身,能正确接收 SIGTERM 等信号)。 * `CMD` 的主要目的是让容器运行一个默认的进程。如果该进程退出,容器也会停止。 * **示例:** ```dockerfile CMD ["nginx", "-g", "daemon off;"] # 推荐写法,直接运行 nginx 主进程 CMD python app.py # Shell 形式,PID 1 是 /bin/sh CMD ["/bin/bash"] # 启动 bash shell ``` ### ENTRYPOINT * **用途:** **配置容器启动时运行的可执行文件**。可以理解为容器的主程序。 * **语法 (两种形式):** * **Exec 形式 (推荐):** `ENTRYPOINT ["executable", "param1", "param2"]` * **Shell 形式:** `ENTRYPOINT command param1 param2` (会被包装成 `/bin/sh -c`) * **说明、行为与联系 (`CMD` & `ENTRYPOINT`):** * `ENTRYPOINT` 定义的是容器启动时**必须运行**的命令。 * `CMD` 定义的参数可以作为 `ENTRYPOINT` 的**默认参数**。 * **组合使用 (最常见模式):** * `ENTRYPOINT` 定义要执行的程序。 * `CMD` 定义该程序的默认参数。 * 用户在 `docker run` 命令行中指定的参数会**覆盖 `CMD` 的内容**,并作为 `ENTRYPOINT` 的参数。 * **覆盖规则:** * 用户可以在 `docker run` 中使用 `--entrypoint` 标志覆盖 `ENTRYPOINT`。 * 用户在 `docker run` 命令行末尾指定的参数会覆盖 `CMD`。 * **Exec vs Shell 形式:** **强烈推荐使用 Exec 形式**。Shell 形式会使你的程序成为 shell 的子进程(PID 不是 1),导致无法正确接收 Unix 信号(如 SIGTERM),并且 `docker stop` 可能无法正常停止容器。 * **示例:** ```dockerfile ENTRYPOINT ["/usr/bin/wc"] CMD ["--help"] # 默认运行 `wc --help`。用户运行 `docker run myimage -l` 实际执行 `/usr/bin/wc -l` ENTRYPOINT ["top", "-b"] # 直接运行 top 命令,忽略 CMD 和用户命令行参数。用户运行 `docker run myimage -H` 会报错,因为 -H 被当作 top 的参数,而 top 可能不支持 -H。 ``` ### `COPY` vs `ADD` * **共同用途:** **从构建上下文复制文件或目录到镜像中**。 * **语法:** ```dockerfile COPY [--chown=:] [--chmod=] ... ADD [--chown=:] [--chmod=] ... ``` * ``: 构建上下文中的源路径(文件或目录)。支持通配符 (`*`, `?`)。**必须在构建上下文中!** * ``: 镜像中的目标绝对路径,或相对于 `WORKDIR` 的路径。 * `--chown`, `--chmod`: 设置复制后文件的所有权/权限 (仅在 Linux 容器中有效)。 * **关键区别:** | 特性 | `COPY` | `ADD` | | :--------- | :----------------------------------- | :------------------------------------------ | | **核心功能** | 仅复制本地文件/目录 (来自构建上下文) | 在 `COPY` 功能基础上**增加**了两个功能: | | **额外功能1**| 无 | **自动解压**:如果 `` 是本地压缩文件 (`tar`, `gzip`, `bzip2`, `xz`) 且目标路径不以斜杠结尾,则自动解压到 ``。 | | **额外功能2**| 无 | **远程 URL 支持**:`` 可以是 URL。Docker 会下载该 URL 的内容到 ``。\*\*注意:下载的文件不会被自动解压! \*\* | * **最佳实践与选择:** * **`COPY` 是首选:** 对于简单的复制本地文件/目录需求,**总是使用 `COPY`**。它的行为更清晰、更可预测。 * **何时使用 `ADD`:** * 需要**自动解压**本地压缩文件到镜像中时。 * 需要从 **URL 下载文件**并直接放入镜像中(且不需要解压时)。**注意:** 通常更推荐在 `RUN` 指令中使用 `curl` 或 `wget` 下载文件,因为这样可以更好地控制下载过程(如处理失败、校验和)、清理下载缓存以及利用 Docker 的层缓存机制。 * **一般建议:** 除非你明确需要 `ADD` 的自动解压或 URL 下载功能,否则坚持使用 `COPY`。 ### WORKDIR * **用途:** **为后续的 `RUN`, `CMD`, `ENTRYPOINT`, `COPY`, `ADD` 指令设置工作目录**。如果目录不存在,会被自动创建。 * **语法:** `WORKDIR /path/to/workdir` * **说明:** * 可以多次使用。后续路径如果是相对路径,将基于前一个 `WORKDIR` 设置。 * **非常重要!** 它使得 Dockerfile 中的路径引用更加清晰和安全,避免了在 `RUN` 等指令中写冗长的绝对路径。 * 相当于 `cd` 命令的效果,但效果是持久的(影响后续指令)。 * **示例:** ```dockerfile WORKDIR /app COPY . . # 将构建上下文当前目录复制到镜像的 /app 目录下 RUN python setup.py install CMD ["python", "main.py"] # 在 /app 下执行 main.py WORKDIR /logs RUN touch output.log # 在 /logs 下创建 output.log ``` ### ENV * **用途:** **设置环境变量**。这些变量在构建阶段和容器运行阶段都可用。 * **语法:** ```dockerfile ENV = ... # 一次设置一个或多个变量 (推荐) ENV # 旧式写法 (一次设置一个变量) ``` * **说明:** * 使用 `=` 形式可以清晰地一次设置多个变量。 * 设置的环境变量可以在后续的 Dockerfile 指令(如 `RUN`)和运行中的容器内部访问。 * 常用于配置应用程序所需的参数(如数据库连接字符串、日志级别、路径)。 * **示例:** ```dockerfile ENV APP_VERSION=1.0.0 \ NODE_ENV=production \ PATH=/usr/local/myapp/bin:$PATH RUN echo "Building version $APP_VERSION for $NODE_ENV" # 构建阶段使用 CMD ["myapp"] # 运行时 myapp 可以访问 NODE_ENV 等变量 ``` *** ## 二、其他重要指令 ### ARG * **用途:** **定义在构建时传递给 Docker 构建器的变量**。只在构建阶段 (`docker build`) 有效,在容器运行阶段 (`docker run`) **不可用**。有作用域限制(声明后生效,直到构建阶段结束或下一个 `FROM` 指令)。 * **语法:** ```dockerfile ARG [=] ``` * **说明与联系 (`ENV`):** * 使用 `docker build --build-arg =` 在构建时覆盖默认值或提供值。 * 常用于控制构建过程的参数化,如指定要下载的软件版本、选择不同的构建配置。 * 与 `ENV` 的关键区别:`ARG` 是**构建时**变量,构建完成后消失;`ENV` 是**运行时**环境变量(也在构建时可用)。 * `ARG` 值可以在构建时通过 `ENV` 指令转换成运行时的环境变量。 * **示例:** ```dockerfile ARG USERNAME=defaultuser ARG APP_VERSION=latest RUN useradd -ms /bin/bash $USERNAME ENV APP_VERSION=$APP_VERSION # 将 ARG 的值传递给 ENV,使其在运行时可用 COPY --chown=$USERNAME app-$APP_VERSION.tar.gz /app/ ``` ```bash docker build --build-arg USERNAME=produser --build-arg APP_VERSION=2.1.0 -t myapp:2.1.0 . ``` ### USER * **用途:** **设置运行后续指令 (`RUN`, `CMD`, `ENTRYPOINT`) 以及容器运行时的默认用户名或 UID**。 * **语法:** `USER [:]` 或 `USER [:]` * **说明:** * **安全最佳实践!** 强烈建议不要一直以 root 用户运行容器。使用 `USER` 切换到非 root 用户,遵循最小权限原则。 * 确保在切换用户之前,该用户(和组)已经通过 `RUN`(如 `useradd`)在镜像中创建好。 * 影响 `COPY` 的 `--chown` 默认行为(如果不指定 `--chown`,文件所有权由 `USER` 决定)。 * **示例:** ```dockerfile RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app COPY --chown=appuser:appuser . . # 明确设置所有权 USER appuser CMD ["python", "app.py"] # 以 appuser 身份运行 ``` ### EXPOSE * **用途:** **声明容器在运行时监听的网络端口**。这是一个**文档性**指令和元数据设置,并不实际发布端口或导致网络连接。 * **语法:** `EXPOSE [/...]` * **说明:** * 默认协议是 TCP。如需 UDP,需明确指定 (如 `EXPOSE 53/udp`)。 * 主要作用: * 告知镜像使用者/运维人员该容器设计上会监听哪些端口。 * 在使用 `docker run -P` 时,Docker 会自动将容器中所有 `EXPOSE` 的端口映射到宿主机的高位随机端口。 * 在 `docker network` 连接容器时,`EXPOSE` 的端口信息有助于服务发现。 * **实际发布端口(使外部可访问)必须在 `docker run` 时使用 `-p` 或 `-P` 标志显式完成。** * **示例:** `EXPOSE 80/tcp 443/tcp 8080` * ### VOLUME * **用途:** **创建一个具有指定名称的挂载点,并将其标记为从宿主机或其他容器挂载外部卷的位置。** * **语法:** `VOLUME ["/data"]` 或 `VOLUME /data /otherdata` * **说明:** * 主要目的是**数据持久化和共享**。存储在 `VOLUME` 指定路径下的数据不会被打包进镜像层,并且在容器停止后仍然存在。 * 在 `docker run` 时,如果没有显式使用 `-v` 或 `--mount` 绑定宿主机目录或命名卷,Docker 会自动创建一个匿名的数据卷绑定到这个挂载点。 * 在 Dockerfile 中使用 `VOLUME` 可以明确告知用户镜像中哪些路径是设计用于存放持久化数据的。 * 在 `VOLUME` 之后对该路径的修改(`RUN`、`COPY`)**不会生效**(因为挂载发生在运行时,会覆盖镜像层中的内容)。**最佳实践:** 确保 `VOLUME` 指令**在**你需要将文件复制到该目录的操作(如 `COPY`)**之后**声明。 * **示例:** ```dockerfile COPY initial-data.json /data/ # 将初始数据复制到 /data VOLUME /data # 声明 /data 为卷。运行时,如果用户不绑定卷,Docker 会创建匿名卷,initial-data.json 会被复制到这个匿名卷中。如果用户绑定了一个宿主机目录(可能为空)到 /data,则 initial-data.json 会被隐藏。 ``` *** ## 三、总结与对比表 | 指令 | 主要用途 | 作用阶段 | 是否创建层 | 关键点/区别/联系 | |:-----------------|:---------------------------|:----------|:------|:-------------------------------------------------------------| | **`FROM`** | 指定基础镜像 | 构建 | 是 | **必须**是第一条有效指令。多阶段构建核心。 | | **`RUN`** | 在构建时执行命令 | 构建 | 是 | 合并命令减少层。Shell vs Exec 形式。 | | **`CMD`** | 容器默认启动命令/参数 | 容器**运行**时 | **否** | 定义默认行为,可被 `docker run` 覆盖。常与 `ENTRYPOINT` 配合 (提供默认参数)。 | | **`ENTRYPOINT`** | 容器主启动程序 | 容器**运行**时 | **否** | 定义容器主程序。可被 `--entrypoint` 覆盖。常与 `CMD` 配合 (`CMD` 作默认参数)。 | | **`COPY`** | 复制本地文件/目录 (上下文 -> 镜像) | 构建 | 是 | **首选**复制方式。行为明确。 | | **`ADD`** | 复制 + 自动解压本地压缩包 + 下载 URL 文件 | 构建 | 是 | 功能多于 `COPY` (解压、下载)。仅在需要这些额外功能时使用。 | | **`WORKDIR`** | 设置后续指令的工作目录 | 构建 & 运行 | **否** | 影响后续 `RUN`/`CMD`/`ENTRYPOINT`/`COPY`/`ADD` 的路径。相当于持久化的 `cd`。 | | **`ENV`** | 设置环境变量 | 构建 & 运行 | **否** | 构建和运行时均可用。用于配置应用程序。 | | **`ARG`** | 定义构建时变量 | **仅构建** | **否** | 只在 `docker build` 时有效。可通过 `--build-arg` 传递。常转为 `ENV` 供运行时用。 | | **`USER`** | 设置运行用户 (UID/GID) | 构建 & 运行 | **否** | **安全最佳实践!** 切换到非 root 用户。需确保用户已创建。 | | **`EXPOSE`** | 声明容器监听端口 | **元数据** | **否** | **文档性/声明性**。不实际发布端口。`-P` 依赖它。`-p` 才是实际发布。 | | **`VOLUME`** | 声明数据卷挂载点 | **元数据** | **否** | **声明性**。用于持久化和共享数据。运行时自动创建匿名卷或需用户绑定。 | **核心联系:** 1. **`ENTRYPOINT` & `CMD`:** 共同定义容器启动时的行为。`ENTRYPOINT` 是主程序,`CMD` 是其默认参数。用户 `docker run` 参数覆盖 `CMD`。 2. **`COPY` & `ADD`:** `ADD` 是 `COPY` 的超集,增加了自动解压和 URL 下载功能。简单复制用 `COPY`。 3. **`ENV` & `ARG`:** `ARG` 用于构建时参数化,构建后消失;`ENV` 设置的环境变量在构建和运行时都可用。可以将 `ARG` 的值赋给 `ENV` 使其在运行时保留。 4. **`WORKDIR`:** 为 `RUN`, `CMD`, `ENTRYPOINT`, `COPY`, `ADD` 提供相对路径的基准。 5. **层创建 (`RUN`, `COPY`, `ADD`):** 这些指令会创建新的镜像层。优化原则是:合并相关操作(尤其是 `RUN` ),清理不必要的临时文件,减少最终镜像层数和大小。 **最佳实践总结:** * 使用明确的 `FROM` 标签。 * 合并 `RUN` 指令,清理缓存。 * **优先使用 `COPY` 而非 `ADD`**。 * 使用 `WORKDIR` 设置绝对路径。 * `CMD` 和 `ENTRYPOINT` 使用 **Exec 形式**。 * 利用 `ENTRYPOINT` 和 `CMD` 的组合。 * 使用 `ENV` 设置配置。 * 使用 `ARG` 参数化构建。 * **始终使用非 root 用户 (`USER`) 运行容器。** * 使用 `EXPOSE` 声明端口。 * 使用 `VOLUME` 声明持久化数据位置。 * 注意指令顺序(如 `VOLUME` 在复制初始数据之后)。 掌握这些指令及其区别联系,你就能编写出高效、安全、可维护的 Dockerfile 了。 --- --- url: /src/technology/docker/docker run常用参数说明.md --- ### **1. `privileged` 参数** #### **作用** * 当容器以 `--privileged` 模式运行时,会获得宿主机的几乎所有内核权限(类似于宿主机的 root 权限)。 * 容器可以访问宿主机的设备(如 `/dev` 下的设备),并能够执行一些需要高权限的操作(如修改内核参数、挂载文件系统等)。 #### **使用场景** * 需要容器直接操作宿主机硬件(如 GPU、USB 设备)。 * 运行需要内核级功能的工具(如 `systemd`、`Docker-in-Docker`)。 * 调试容器内部问题(如使用 `strace` 或 `perf` 工具)。 #### **示例** ```bash docker run --privileged -it ubuntu /bin/bash ``` #### **注意事项** * **安全风险**:特权模式会破坏容器隔离性,可能被恶意程序利用。 * 替代方案:尽量使用 `--cap-add` 或 `--device` 参数授予最小必要权限(如 `--cap-add SYS_ADMIN`)。 *** ### **2. `restart` 参数** #### **作用** * 控制容器在退出时的自动重启策略。 * 支持多种策略,确保容器在意外终止或宿主机重启后恢复运行。 #### **可选值** | 值 | 行为 | |----|------| | `no` | 默认,不自动重启。 | | `on-failure[:max-retries]` | 仅在非零退出码时重启(可指定最大重试次数)。 | | `always` | 无论退出状态如何,始终重启(宿主机重启后也会启动)。 | | `unless-stopped` | 类似 `always`,但若容器被手动停止,则不会自动重启。 | #### **使用场景** * 生产环境中确保服务高可用(如 Web 服务器、数据库)。 * 宿主机重启后自动恢复容器。 #### **示例** ```bash docker run --restart=always nginx docker run --restart=on-failure:5 redis ``` #### **注意事项** * `always` 和 `unless-stopped` 的区别: * `always`:即使容器被手动停止,宿主机重启后仍会启动。 * `unless-stopped`:手动停止后,宿主机重启也不会启动。 *** ### **3. `network` 参数** #### **作用** * 指定容器的网络模式,控制容器如何与宿主机、其他容器或外部网络通信。 #### **常见模式** | 模式 | 行为 | |------|------| | `bridge` | 默认模式,容器连接到 Docker 的虚拟网桥(`docker0`),通过 NAT 与外部通信。 | | `host` | 容器共享宿主机的网络命名空间,直接使用宿主机 IP 和端口。 | | `none` | 禁用所有网络(仅保留回环接口 `lo`)。 | | `container:` | 共享指定容器的网络命名空间。 | | 自定义网络 | 用户定义的桥接或覆盖网络,支持 DNS 自动发现和多容器通信。 | #### **使用场景** * `host`:需要高性能网络(如负载均衡器)。 * `bridge`:隔离容器网络,适用于大多数场景。 * 自定义网络:多容器应用(如微服务)需要内部 DNS 通信。 #### **示例** ```bash # 使用宿主机网络 docker run --network=host nginx # 创建自定义网络并连接容器 docker network create mynet docker run --network=mynet --name app1 myapp docker run --network=mynet --name app2 myapp ``` #### **注意事项** * `host` 模式会暴露容器端口到宿主机,可能引发端口冲突。 * 自定义网络支持更灵活的服务发现(通过容器名通信)。 *** --- --- url: /src/technology/docker/docker网络详解.md --- Docker 网络是容器之间以及容器与外部世界通信的核心组件,理解 Docker 网络的不同模式、配置方法和实际应用场景非常重要。以下是详细的解释和示例: *** ### **一、Docker 网络基础** #### **1. 默认网络模式** 安装 Docker 后,默认会创建以下三种网络: ```bash docker network ls ``` 输出示例: ``` NETWORK ID NAME DRIVER SCOPE abc123... bridge bridge local def456... host host local ghi789... none null local ``` * **bridge**:默认桥接网络,容器默认连接到此网络。 * **host**:容器直接使用宿主机的网络栈。 * **none**:禁用所有网络(只有 `lo` 回环接口)。 *** ### **二、Docker 网络模式详解** #### **1. 桥接网络(Bridge)** * **特点**: * 容器通过虚拟网桥 `docker0` 通信。 * 每个容器分配独立的内部 IP(如 `172.17.0.2`)。 * 容器间可以相互通信(通过 IP),但需手动配置端口映射才能与宿主机或外部通信。 * **适用场景**:单机环境下的容器隔离和通信。 ##### **示例 1:使用默认桥接网络** ```bash # 运行一个 Nginx 容器,映射宿主机端口 8080 到容器的 80 端口 docker run -d --name web -p 8080:80 nginx # 访问宿主机 IP:8080 即可访问容器内的 Nginx 服务 curl http://localhost:8080 ``` ##### **示例 2:容器间通过 IP 通信** ```bash # 运行两个容器(使用默认桥接网络) docker run -d --name container1 alpine sleep 3600 docker run -d --name container2 alpine sleep 3600 # 进入 container1,ping container2 的 IP docker exec -it container1 sh ping 172.17.0.3 # 假设 container2 的 IP 是 172.17.0.3 ``` #### **2. Host 网络模式** * **特点**: * 容器共享宿主机的网络命名空间,直接使用宿主机的 IP 和端口。 * 无需端口映射(如容器运行在 80 端口,宿主机 80 端口直接被占用)。 * **适用场景**:需要高性能网络(如负载均衡、实时流媒体)。 ##### **示例:使用 Host 模式** ```bash # 运行一个 Nginx 容器,直接使用宿主机的网络 docker run -d --name web-host --network=host nginx # 访问宿主机 IP:80 即可(无需端口映射) curl http://localhost:80 ``` #### **3. None 网络模式** * **特点**: * 容器只有 `lo` 回环接口,无法与外部通信。 * 适用于完全隔离或自定义网络配置的场景。 * **适用场景**:安全测试、手动配置网络栈。 ##### **示例:使用 None 模式** ```bash docker run -it --network=none --name isolated-container alpine sh # 进入容器后执行 `ifconfig`,只能看到 `lo` 接口 ``` #### **4. 容器共享网络(Container)** * **特点**: * 新容器共享指定容器的网络命名空间(共用 IP 和端口)。 * 类似“网络双胞胎”,适合日志收集或监控容器。 * **示例**: ```bash # 运行一个主容器 docker run -d --name main-container nginx # 新容器共享 main-container 的网络 docker run -it --network=container:main-container alpine sh # 此时,两个容器的网络完全相同 ``` *** ### **三、自定义网络** Docker 允许创建自定义桥接网络,提供更好的容器间通信(支持 DNS 自动解析容器名称)。 #### **1. 创建自定义桥接网络** ```bash docker network create my-network ``` #### **2. 将容器连接到自定义网络** ```bash # 运行两个容器并连接到自定义网络 docker run -d --name web-app --network=my-network nginx docker run -it --name debug-tool --network=my-network alpine sh ``` #### **3. 容器间通过名称通信** 在 `debug-tool` 容器内: ```bash ping web-app # 直接通过容器名 ping 通(无需 IP) curl http://web-app:80 # 通过容器名访问服务 ``` #### **4. 自定义网络的优点** * **DNS 自动解析**:容器可通过名称互相通信。 * **隔离性**:不同自定义网络中的容器默认无法通信。 * **灵活性**:支持配置子网、网关和 IP 地址范围。 *** ### **四、Docker 网络常用命令** #### **1. 查看网络列表** ```bash docker network ls ``` #### **2. 检查网络详情** ```bash docker network inspect bridge # 查看默认桥接网络的详细信息 ``` #### **3. 连接/断开容器与网络** ```bash docker network connect my-network existing-container docker network disconnect my-network existing-container ``` #### **4. 删除网络** ```bash docker network rm my-network ``` *** ### **五、端口映射详解** 通过 `-p` 参数将容器端口映射到宿主机: ```bash # 格式:-p <宿主机端口>:<容器端口> docker run -d -p 8080:80 --name web nginx # 绑定特定 IP docker run -d -p 192.168.1.100:8080:80 nginx # 映射 UDP 端口 docker run -d -p 53:53/udp dns-server ``` *** ### **六、常见问题与排查** #### **1. 容器无法访问外部网络** * 检查宿主机的防火墙或 iptables 规则。 * 确保 Docker 服务正常运行:`systemctl status docker`。 #### **2. 容器间无法通信** * 确认容器在同一网络中。 * 检查自定义网络的 DNS 解析是否正常。 #### **3. 端口冲突** * 使用 `docker ps` 查看已占用的端口。 * 更换宿主机端口或停止冲突的容器。 *** ### **七、总结** | **网络模式** | **特点** | **适用场景** | |----------------|-----------------------------------|--------------------------| | **bridge** | 默认隔离,需端口映射 | 大多数单机应用 | | **host** | 直接使用宿主机网络 | 高性能需求(如负载均衡) | | **none** | 完全无网络 | 安全测试/手动配置网络 | | **container** | 共享其他容器的网络 | 监控/日志收集容器 | | **自定义网络** | DNS 自动解析,隔离性强 | 多容器协作(如微服务) | 通过合理选择网络模式,您可以优化容器的通信效率和安全性。实际使用中,推荐优先使用自定义网络以简化服务发现。 --- --- url: /src/technology/docker/容器相关命令.md --- # Docker容器相关命令 ## **1. 容器生命周期管理** #### **1.1 创建并启动容器** ```bash # 创建并启动容器(前台模式) docker run <镜像名> # 创建并启动容器(后台模式) docker run -d <镜像名> # 指定容器名称 docker run --name my_container <镜像名> # 端口映射(宿主机端口:容器端口) docker run -p 8080:80 <镜像名> # 挂载数据卷(宿主机目录:容器目录) docker run -v /宿主机路径:/容器路径 <镜像名> # 设置环境变量 docker run -e "ENV_VAR=value" <镜像名> ``` #### **1.2 启动/停止/重启容器** ```bash # 启动已停止的容器 docker start <容器名/ID> # 停止运行中的容器 docker stop <容器名/ID> # 强制停止容器(SIGKILL) docker kill <容器名/ID> # 重启容器 docker restart <容器名/ID> ``` #### **1.3 删除容器** ```bash # 删除已停止的容器 docker rm <容器名/ID> # 强制删除运行中的容器 docker rm -f <容器名/ID> # 删除所有已停止的容器 docker container prune ``` *** ## **2. 容器日志操作** ```bash # 查看容器日志(实时) docker logs -f <容器名/ID> # 查看最后 N 行日志 docker logs --tail=100 <容器名/ID> # 查看指定时间范围的日志(例如最近1小时) docker logs --since 1h <容器名/ID> # 导出日志到文件 docker logs <容器名/ID> > container.log ``` *** ## **3. 进入容器与执行命令** ```bash # 进入容器终端(推荐方式,退出不停止容器) docker exec -it <容器名/ID> /bin/bash # 以 root 用户进入容器 docker exec -u root -it <容器名/ID> /bin/sh # 在容器内执行单条命令 docker exec <容器名/ID> ls /app # 临时附加到容器(慎用,退出可能导致容器停止) docker attach <容器名/ID> ``` *** ## **4. 文件复制** #### **4.1 宿主机 → 容器** ```bash docker cp /宿主机文件路径 <容器名/ID>:/容器路径 # 示例: docker cp ./config.json my_container:/app/config/ ``` #### **4.2 容器 → 宿主机** ```bash docker cp <容器名/ID>:/容器文件路径 /宿主机路径 # 示例: docker cp my_container:/var/log/nginx/error.log ./ ``` *** ## **5. 容器信息查看** ```bash # 查看运行中的容器 docker ps # 查看所有容器(包括已停止的) docker ps -a # 查看容器详细信息 docker inspect <容器名/ID> # 查看容器进程列表 docker top <容器名/ID> # 查看容器资源使用统计(实时) docker stats <容器名/ID> # 查看容器端口映射 docker port <容器名/ID> ``` *** ## **6. 容器导入导出** ```bash # 导出容器文件系统为 tar 包(不包含历史层) docker export <容器名/ID> > container.tar # 从 tar 包导入为镜像 docker import container.tar new_image:tag # 保存镜像为 tar 文件(包含所有层) docker save -o image.tar <镜像名> # 加载镜像 tar 文件 docker load -i image.tar ``` *** ## **7. 网络相关** ```bash # 查看容器 IP 地址 docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' <容器名/ID> # 将容器连接到自定义网络 docker network connect my_network <容器名/ID> # 查看容器网络配置 docker network inspect my_network ``` *** ## **8. 资源限制** ```bash # 限制内存使用(例如 512MB) docker run -m 512m <镜像名> # 限制 CPU 使用(例如 1.5 个核心) docker run --cpus=1.5 <镜像名> ``` *** ## **9. 容器更新** ```bash # 修改容器重启策略(如 always) docker update --restart=always <容器名/ID> # 动态调整资源限制(如内存) docker update -m 1g <容器名/ID> ``` *** ## **10. 实用技巧** ```bash # 清理所有停止的容器、未使用的网络和悬空镜像 docker system prune -a # 批量停止所有运行中的容器 docker stop $(docker ps -q) # 批量删除所有容器 docker rm -f $(docker ps -aq) # 修改容器时间与宿主机同步 docker run -v /etc/localtime:/etc/localtime:ro <镜像名> ``` *** ## **注意事项** 1. 容器名/ID 可用 `docker ps` 查看,支持短ID(前3位以上字符)。 2. 操作生产环境容器前,务必确认影响范围。 3. 数据持久化建议使用 `-v` 挂载卷,避免容器删除后数据丢失。 --- --- url: /src/technology/docker/镜像相关命令.md --- # Docker镜像相关命令 ## **1. 镜像基础操作** #### **1.1 拉取镜像** ```bash # 从仓库拉取镜像(默认最新标签) docker pull nginx # 拉取指定版本镜像 docker pull nginx:1.23-alpine # 从私有仓库拉取 docker pull registry.example.com/myapp:v2 ``` #### **1.2 查看镜像** ```bash # 列出所有镜像 docker images # 显示镜像详细信息 docker image inspect nginx:latest # 显示镜像构建历史 docker history nginx # 搜索公共镜像 docker search ubuntu ``` *** ## **2. 镜像构建** #### **2.1 基础构建** ```bash # 构建镜像(使用当前目录 Dockerfile) docker build -t myapp:1.0 . # 指定不同 Dockerfile docker build -f Dockerfile.prod -t myapp:prod . # 构建时不使用缓存 docker build --no-cache -t fresh-build . ``` #### **2.2 高级构建** ```bash # 多阶段构建 docker build --target builder-stage -t builder . # 构建参数传递 docker build --build-arg NODE_ENV=production -t myapp:prod . # 设置构建平台 docker build --platform linux/arm64 -t arm-image . ``` *** ## **3. 镜像管理** #### **3.1 镜像标记** ```bash # 添加新标签 docker tag myapp:1.0 registry.example.com/myapp:latest # 修改镜像名称 docker tag old-name:1.0 new-name:2.0 ``` #### **3.2 删除镜像** ```bash # 删除单个镜像 docker rmi nginx:1.23 # 强制删除(被容器使用的镜像) docker rmi -f myapp:test # 删除所有悬空镜像(未被标记且无容器使用) docker image prune # 删除指定名称模式的镜像 docker images | grep "none" | awk '{print $3}' | xargs docker rmi ``` *** ## **4. 镜像导入导出** #### **4.1 保存/加载** ```bash # 保存镜像为 tar 文件(保留完整层结构) docker save -o nginx.tar nginx:latest # 加载镜像 docker load -i nginx.tar # 批量导出所有镜像 docker save $(docker images -q) -o all-images.tar ``` #### **4.2 导出/导入容器快照** ```bash # 导出容器文件系统(不包含元数据/层) docker export my_container > container.tar # 从容器快照创建镜像 cat container.tar | docker import - my_image:snapshot ``` *** ## **5. 镜像分发** #### **5.1 推送镜像** ```bash # 登录镜像仓库 docker login registry.example.com # 推送镜像到仓库 docker push registry.example.com/myapp:1.0 # 批量推送多个标签 docker push registry.example.com/myapp:{1.0,latest} ``` #### **5.2 镜像迁移** ```bash # 直接跨服务器传输 docker save myapp:1.0 | ssh user@server 'docker load' ``` *** ## **6. 镜像分析** ```bash # 查看镜像层大小 docker history --no-trunc myapp:1.0 # 分析镜像内容 docker run --rm -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive myapp:1.0 # 检查镜像元数据 docker image inspect --format '{{.Config.Entrypoint}}' myapp:1.0 ``` *** ## **7. 镜像优化** ```bash # 压缩镜像体积 docker export my_container | docker import --change 'CMD ["/app"]' - myapp:minimal # 合并镜像层(需要 Docker 20.10+) docker build --squash -t flattened-image . ``` *** ## **8. 镜像安全** ```bash # 扫描镜像漏洞 docker scan myapp:1.0 # 查看镜像签名验证 docker trust inspect --pretty registry.example.com/myapp # 查看镜像摘要 docker images --digests ``` *** ## **9. 镜像清理** ```bash # 删除所有未被使用的镜像 docker image prune -a # 删除早于指定时间的镜像 docker image prune -a --filter "until=24h" # 按标签过滤删除 docker rmi $(docker images --filter "reference=*:test" -q) ``` *** ## **10. 特殊镜像操作** ```bash # 从运行容器创建新镜像 docker commit -m "Added config" my_container myapp:config-update # 构建时跳过平台验证(危险操作) docker build --platform=linux/amd64 --allow platform.mismatch -t cross-image . # 查看镜像构建缓存 docker builder prune --filter type=build-cache ``` *** ## **注意事项** 1. `docker save` 与 `docker export` 区别: * `save` 保存完整镜像(包含层结构) * `export` 导出容器文件系统快照 2. 镜像命名规范: * 私有仓库格式:`//:` * 官方镜像格式:`:` 3. 生产环境建议: * 始终使用明确版本标签(避免使用 latest) * 定期扫描镜像漏洞 * 设置镜像保留策略 --- --- url: /src/technology/docker/docker-compose相关命令.md --- # docker-compose镜像相关命令 ## **1. 基础操作** #### **1.1 启动服务** ```bash # 启动所有服务(前台模式) docker-compose up # 启动服务并后台运行 docker-compose up -d # 仅启动指定服务(带依赖服务) docker-compose up -d web redis # 强制重建容器 docker-compose up -d --force-recreate # 拉取最新镜像强制重建容器 docker-compose up -d --pull=always --force-recreate # 启动时构建镜像 docker-compose up --build ``` #### **1.2 停止服务** ```bash # 停止并删除容器 docker-compose down # 停止但不删除容器 docker-compose stop # 停止并删除容器+网络 docker-compose down --remove-orphans # 停止并删除所有资源(包括volumes) docker-compose down --volumes --rmi local ``` *** ## **2. 服务管理** #### **2.1 服务状态** ```bash # 查看运行中的容器 docker-compose ps # 查看所有容器状态 docker-compose ps -a # 显示服务日志(实时) docker-compose logs -f # 查看指定服务日志 docker-compose logs -f web # 显示服务进程树 docker-compose top ``` #### **2.2 服务控制** ```bash # 重启所有服务 docker-compose restart # 重启指定服务 docker-compose restart web # 暂停/恢复服务 docker-compose pause web docker-compose unpause web # 扩容服务实例 docker-compose up --scale web=3 -d ``` *** ## **3. 环境管理** #### **3.1 多环境配置** ```bash # 使用指定环境文件 docker-compose --env-file .env.prod up # 多文件组合配置 docker-compose -f docker-compose.yml -f docker-compose.prod.yml up ``` #### **3.2 环境变量操作** ```bash # 在命令中覆盖环境变量 docker-compose run -e DEBUG=1 web # 显示解析后的配置 docker-compose config ``` *** ## **4. 调试与维护** #### **4.1 进入容器** ```bash # 进入运行中的服务容器 docker-compose exec web bash # 以root身份执行命令 docker-compose exec --user root web sh # 在停止的容器中执行命令(需先启动) docker-compose run --rm web python manage.py migrate ``` #### **4.2 服务调试** ```bash # 查看服务端口映射 docker-compose port web 80 # 检查服务依赖关系 docker-compose config --services # 显示服务IP地址 docker-compose exec web hostname -i # 健康检查状态 docker-compose ps | grep '(healthy)' ``` *** ## **5. 镜像与构建** #### **5.1 镜像管理** ```bash # 构建所有服务的镜像 docker-compose build # 强制重建镜像(不使用缓存) docker-compose build --no-cache # 仅构建指定服务镜像 docker-compose build web # 拉取所有服务镜像 docker-compose pull ``` #### **5.2 镜像标记** ```bash # 推送镜像到仓库 docker-compose push # 仅推送指定服务镜像 docker-compose push web ``` *** ## **6. 网络与卷** #### **6.1 网络管理** ```bash # 查看网络配置 docker-compose network ls # 创建自定义网络 docker-compose network create backend # 检查网络详情 docker-compose network inspect myapp_default ``` #### **6.2 卷管理** ```bash # 查看所有数据卷 docker-compose volume ls # 删除未使用的数据卷 docker-compose volume prune # 创建命名卷 volumes: db_data: driver: local ``` *** ## **7. 实用技巧** #### **7.1 性能优化** ```bash # 并行操作多个容器 docker-compose up --parallel # 限制服务资源 services: web: deploy: resources: limits: cpus: '0.5' memory: 512M ``` #### **7.2 特殊场景** ```bash # 仅执行一次性命令(容器自动删除) docker-compose run --rm web bash # 服务依赖控制(等待数据库就绪) version: '3.8' services: web: depends_on: db: condition: service_healthy # 使用服务别名访问 extra_hosts: - "api.example.com:10.5.0.5" ``` *** ## **8. 常用配置示例** #### **8.1 基础模板** ```yaml version: '3.8' services: web: build: . ports: - "8000:8000" volumes: - .:/code depends_on: - redis redis: image: redis:alpine volumes: - redis_data:/data volumes: redis_data: ``` #### **8.2 生产配置** ```yaml services: web: deploy: replicas: 3 update_config: parallelism: 2 delay: 10s restart_policy: condition: on-failure ``` *** ## **9. 调试工具** #### **9.1 配置验证** ```bash # 验证配置文件语法 docker-compose config # 显示最终合并的配置 docker-compose config > final-config.yml ``` #### **9.2 性能监控** ```bash # 实时资源监控 docker-compose stats # 显示服务CPU/内存使用 docker-compose top web ``` *** ## **10. 跨平台支持** #### **10.1 多架构构建** ```yaml services: app: platform: linux/arm64 build: . ``` #### **10.2 环境切换** ```bash # 使用不同环境配置文件 docker-compose -f docker-compose.yml -f docker-compose.override.yml up # 快速切换开发/生产模式 profiles: - dev services: debugger: profiles: ["dev"] image: debug-tool ``` *** ## **注意事项** 1. 版本兼容性: * 使用 `version: '3.8'` 以上支持最新功能 * 旧版 Docker 需使用 `docker-compose` 命令,新版 Docker 可用 `docker compose` 命令 2. 生产建议: ```bash # 使用指定版本号 docker-compose -f docker-compose.prod.yml up -d # 设置资源限制 ulimit: nproc: 65535 nofile: soft: 20000 hard: 40000 ``` 3. 安全实践: ```yaml # 避免敏感信息硬编码 environment: DB_PASSWORD: ${DB_PASSWORD} ``` --- --- url: /src/technology/java/spring-cloud/OpenFeign.md --- # OpenFeign ## Spring Cloud OpenFeign 的核心功能 [OpenFeign](https://springdoc.cn/spring-cloud-openfeign/) 是一个声明式的 HTTP 客户端,旨在简化微服务间的远程调用。其核心功能包括: 1. **声明式接口**\ 通过注解定义 HTTP 请求,例如 `@FeignClient(name="service-name")` 标注接口,方法上使用 `@GetMapping`、`@PostMapping` 等映射具体 API。 2. **集成服务发现与负载均衡**\ 结合服务注册中心(如 Eureka、Consul)自动解析服务实例,并通过负载均衡器(如 Spring Cloud LoadBalancer)分发请求。 3. **请求/响应编解码**\ 默认支持 JSON 格式,通过集成 Jackson 自动序列化请求体和反序列化响应体。可扩展支持 XML、Protobuf 等格式。 4. **错误处理**\ 支持自定义错误解码器(`ErrorDecoder`),处理非 2xx 的 HTTP 状态码,例如将 404 转换为自定义异常。 5. **日志与监控**\ 提供日志记录功能,可配置请求/响应的详细输出,便于调试。 *** ## 进阶用法 1. **自定义配置** * **超时控制**:配置连接和读取超时(默认单位毫秒): ```yaml feign: client: config: default: connectTimeout: 5000 readTimeout: 30000 ``` * **重试机制**:结合 Spring Retry 实现重试逻辑,需添加依赖并配置策略。 2. **拦截器(RequestInterceptor)**\ 实现身份验证或添加统一请求头: ```java public class AuthInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { template.header("Authorization", "Bearer token"); } } ``` 3. **替换 HTTP 客户端**\ 使用高性能客户端(如 Apache HttpClient 或 OkHttp)替代默认实现: * 添加依赖(如 `io.github.openfeign:feign-okhttp`)。 * 在配置中启用:`feign.okhttp.enabled=true`。 4. **自定义编解码器**\ 支持 Protobuf 等格式: ```java @Bean public Encoder protobufEncoder() { return new ProtobufEncoder(); } ``` 5. **断路器集成**\ 结合 Resilience4J 实现熔断: * 添加 `spring-cloud-starter-circuitbreaker-resilience4j` 依赖。 * 配置熔断规则并在 `@FeignClient` 中指定 Fallback 类。 *** ## 最佳实践 1. **接口设计规范** * 将 Feign 客户端接口独立为单独模块,供多个服务复用。 * 避免重复定义 DTO,将模型类置于公共模块。 2. **异常处理** * 自定义 `ErrorDecoder` 统一转换异常,例如将 500 错误转换为 `BusinessException`。 * 使用 Fallback 类提供降级逻辑,避免级联故障。 3. **性能优化** * 启用 HTTP 连接池(如 OkHttp)减少连接建立开销。 * 合理设置超时与重试策略,避免阻塞线程。 4. **安全增强** * 通过拦截器传递 JWT 或 API 密钥。 * 敏感头信息(如 `Cookie`、`Authorization`)需显式配置不敏感,确保跨服务传递: ```yaml feign: client: sensitive-headers: # 默认为 Cookie,Set-Cookie,Authorization ``` 5. **日志与监控** * 按需开启详细日志(需配置日志级别为 `FULL`): ```java @Configuration public class FeignConfig { @Bean Logger.Level feignLoggerLevel() { return Logger.Level.FULL; } } ``` * 结合 Micrometer 和 Prometheus 监控调用指标。 6. **版本管理** * 使用 Spring Cloud 的 BOM(Bill of Materials)管理依赖版本,避免兼容性问题。 *** ## 实战技巧 ### 动态注册拦截器 通过 @PostConstruct + 动态注册拦截器的机制,实现 Feign 客户端配置的灵活性和解耦,避免硬编码,便于扩展和维护。 ::: code-group ```java [FeignConfig.java] @Configuration @Slf4j public class FeignConfig { public static final Map> FEIGN_REQUEST_INTERCEPTORS = new ConcurrentHashMap<>(); @Bean public RequestInterceptor requestInterceptor() { return template -> { Target target = template.feignTarget(); Consumer requestTemplateConsumer = FEIGN_REQUEST_INTERCEPTORS.get(target.name()); if (requestTemplateConsumer != null) { requestTemplateConsumer.accept(template); } }; } } ``` ```java [TestAClient.java] @FeignClient(name = TestAClient.NAME, url = "http://localhost:7777") public interface TestAClient extends TestAApi { String NAME = "TestAClient"; Logger logger = LoggerFactory.getLogger(TestAClient.class); @PostConstruct default void init() { FeignConfig.FEIGN_REQUEST_INTERCEPTORS.put(TestAClient.NAME, template -> { TestAService testAService = SpringUtil.getBean(TestAService.class); logger.info("TestAClient拦截器"); template.header("Authorization", testAService.getToken()); template.header("Content-Type", "application/json;charset=utf-8"); }); } } ``` ```java [TestBClient.java] @FeignClient(name = TestBClient.NAME, url = "http://localhost:7777") public interface TestBClient extends TestBApi { String NAME = "TestBClient"; Logger logger = LoggerFactory.getLogger(TestAClient.class); @PostConstruct default void init() { FeignConfig.FEIGN_REQUEST_INTERCEPTORS.put(TestBClient.NAME, template -> { logger.info("TestBClient拦截器"); template.header("Content-Type", "application/json;charset=utf-8"); }); } } ``` ::: ::: tip @FeignClient接口的代理对象是Spring Bean,且该Bean继承了接口的default方法。Spring在初始化这个Bean时, 会执行所有带有@PostConstruct注解的方法(包括接口中的默认方法) ::: --- --- url: /src/technology/java/netty/Netty01-nio.md --- # 一. NIO 基础 non-blocking io 非阻塞 IO ## 1. 三大组件 ### 1.1 Channel & Buffer channel 有一点类似于 stream,它就是读写数据的**双向通道**,可以从 channel 将数据读入 buffer,也可以将 buffer 的数据写入 channel,而之前的 stream 要么是输入,要么是输出,channel 比 stream 更为底层 ```mermaid graph LR channel --> buffer buffer --> channel ``` 常见的 Channel 有 * FileChannel * DatagramChannel * SocketChannel * ServerSocketChannel buffer 则用来缓冲读写数据,常见的 buffer 有 * ByteBuffer * MappedByteBuffer * DirectByteBuffer * HeapByteBuffer * ShortBuffer * IntBuffer * LongBuffer * FloatBuffer * DoubleBuffer * CharBuffer ### 1.2 Selector selector 单从字面意思不好理解,需要结合服务器的设计演化来理解它的用途 #### 多线程版设计 ```mermaid graph TD subgraph 多线程版 t1(thread) --> s1(socket1) t2(thread) --> s2(socket2) t3(thread) --> s3(socket3) end ``` #### ⚠️ 多线程版缺点 * 内存占用高 * 线程上下文切换成本高 * 只适合连接数少的场景 #### 线程池版设计 ```mermaid graph TD subgraph 线程池版 t4(thread) --> s4(socket1) t5(thread) --> s5(socket2) t4(thread) -.-> s6(socket3) t5(thread) -.-> s7(socket4) end ``` #### ⚠️ 线程池版缺点 * 阻塞模式下,线程仅能处理一个 socket 连接 * 仅适合短连接场景 #### selector 版设计 selector 的作用就是配合一个线程来管理多个 channel,获取这些 channel 上发生的事件,这些 channel 工作在非阻塞模式下,不会让线程吊死在一个 channel 上。适合连接数特别多,但流量低的场景(low traffic) ```mermaid graph TD subgraph selector 版 thread --> selector selector --> c1(channel) selector --> c2(channel) selector --> c3(channel) end ``` 调用 selector 的 select() 会阻塞直到 channel 发生了读写就绪事件,这些事件发生,select 方法就会返回这些事件交给 thread 来处理 ## 2. ByteBuffer 有一普通文本文件 data.txt,内容为 ``` 1234567890abcd ``` 使用 FileChannel 来读取文件内容 ```java @Slf4j public class ChannelDemo1 { public static void main(String[] args) { try (RandomAccessFile file = new RandomAccessFile("helloword/data.txt", "rw")) { FileChannel channel = file.getChannel(); ByteBuffer buffer = ByteBuffer.allocate(10); do { // 向 buffer 写入 int len = channel.read(buffer); log.debug("读到字节数:{}", len); if (len == -1) { break; } // 切换 buffer 读模式 buffer.flip(); while(buffer.hasRemaining()) { log.debug("{}", (char)buffer.get()); } // 切换 buffer 写模式 buffer.clear(); } while (true); } catch (IOException e) { e.printStackTrace(); } } } ``` 输出 ``` 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 读到字节数:10 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 1 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 2 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 3 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 4 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 5 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 6 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 7 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 8 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 9 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 0 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 读到字节数:4 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - a 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - b 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - c 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - d 10:39:03 [DEBUG] [main] c.i.n.ChannelDemo1 - 读到字节数:-1 ``` ### 2.1 ByteBuffer 正确使用姿势 1. 向 buffer 写入数据,例如调用 channel.read(buffer) 2. 调用 flip() 切换至**读模式** 3. 从 buffer 读取数据,例如调用 buffer.get() 4. 调用 clear() 或 compact() 切换至**写模式** 5. 重复 1~4 步骤 ### 2.2 ByteBuffer 结构 ByteBuffer 有以下重要属性 * capacity * position * limit 一开始 写模式下,position 是写入位置,limit 等于容量,下图表示写入了 4 个字节后的状态 flip 动作发生后,position 切换为读取位置,limit 切换为读取限制 #### 💡 调试工具类 ```java public class ByteBufferUtil { private static final char[] BYTE2CHAR = new char[256]; private static final char[] HEXDUMP_TABLE = new char[256 * 4]; private static final String[] HEXPADDING = new String[16]; private static final String[] HEXDUMP_ROWPREFIXES = new String[65536 >>> 4]; private static final String[] BYTE2HEX = new String[256]; private static final String[] BYTEPADDING = new String[16]; static { final char[] DIGITS = "0123456789abcdef".toCharArray(); for (int i = 0; i < 256; i++) { HEXDUMP_TABLE[i << 1] = DIGITS[i >>> 4 & 0x0F]; HEXDUMP_TABLE[(i << 1) + 1] = DIGITS[i & 0x0F]; } int i; // Generate the lookup table for hex dump paddings for (i = 0; i < HEXPADDING.length; i++) { int padding = HEXPADDING.length - i; StringBuilder buf = new StringBuilder(padding * 3); for (int j = 0; j < padding; j++) { buf.append(" "); } HEXPADDING[i] = buf.toString(); } // Generate the lookup table for the start-offset header in each row (up to 64KiB). for (i = 0; i < HEXDUMP_ROWPREFIXES.length; i++) { StringBuilder buf = new StringBuilder(12); buf.append(NEWLINE); buf.append(Long.toHexString(i << 4 & 0xFFFFFFFFL | 0x100000000L)); buf.setCharAt(buf.length() - 9, '|'); buf.append('|'); HEXDUMP_ROWPREFIXES[i] = buf.toString(); } // Generate the lookup table for byte-to-hex-dump conversion for (i = 0; i < BYTE2HEX.length; i++) { BYTE2HEX[i] = ' ' + StringUtil.byteToHexStringPadded(i); } // Generate the lookup table for byte dump paddings for (i = 0; i < BYTEPADDING.length; i++) { int padding = BYTEPADDING.length - i; StringBuilder buf = new StringBuilder(padding); for (int j = 0; j < padding; j++) { buf.append(' '); } BYTEPADDING[i] = buf.toString(); } // Generate the lookup table for byte-to-char conversion for (i = 0; i < BYTE2CHAR.length; i++) { if (i <= 0x1f || i >= 0x7f) { BYTE2CHAR[i] = '.'; } else { BYTE2CHAR[i] = (char) i; } } } /** * 打印所有内容 * @param buffer */ public static void debugAll(ByteBuffer buffer) { int oldlimit = buffer.limit(); buffer.limit(buffer.capacity()); StringBuilder origin = new StringBuilder(256); appendPrettyHexDump(origin, buffer, 0, buffer.capacity()); System.out.println("+--------+-------------------- all ------------------------+----------------+"); System.out.printf("position: [%d], limit: [%d]\n", buffer.position(), oldlimit); System.out.println(origin); buffer.limit(oldlimit); } /** * 打印可读取内容 * @param buffer */ public static void debugRead(ByteBuffer buffer) { StringBuilder builder = new StringBuilder(256); appendPrettyHexDump(builder, buffer, buffer.position(), buffer.limit() - buffer.position()); System.out.println("+--------+-------------------- read -----------------------+----------------+"); System.out.printf("position: [%d], limit: [%d]\n", buffer.position(), buffer.limit()); System.out.println(builder); } private static void appendPrettyHexDump(StringBuilder dump, ByteBuffer buf, int offset, int length) { if (isOutOfBounds(offset, length, buf.capacity())) { throw new IndexOutOfBoundsException( "expected: " + "0 <= offset(" + offset + ") <= offset + length(" + length + ") <= " + "buf.capacity(" + buf.capacity() + ')'); } if (length == 0) { return; } dump.append( " +-------------------------------------------------+" + NEWLINE + " | 0 1 2 3 4 5 6 7 8 9 a b c d e f |" + NEWLINE + "+--------+-------------------------------------------------+----------------+"); final int startIndex = offset; final int fullRows = length >>> 4; final int remainder = length & 0xF; // Dump the rows which have 16 bytes. for (int row = 0; row < fullRows; row++) { int rowStartIndex = (row << 4) + startIndex; // Per-row prefix. appendHexDumpRowPrefix(dump, row, rowStartIndex); // Hex dump int rowEndIndex = rowStartIndex + 16; for (int j = rowStartIndex; j < rowEndIndex; j++) { dump.append(BYTE2HEX[getUnsignedByte(buf, j)]); } dump.append(" |"); // ASCII dump for (int j = rowStartIndex; j < rowEndIndex; j++) { dump.append(BYTE2CHAR[getUnsignedByte(buf, j)]); } dump.append('|'); } // Dump the last row which has less than 16 bytes. if (remainder != 0) { int rowStartIndex = (fullRows << 4) + startIndex; appendHexDumpRowPrefix(dump, fullRows, rowStartIndex); // Hex dump int rowEndIndex = rowStartIndex + remainder; for (int j = rowStartIndex; j < rowEndIndex; j++) { dump.append(BYTE2HEX[getUnsignedByte(buf, j)]); } dump.append(HEXPADDING[remainder]); dump.append(" |"); // Ascii dump for (int j = rowStartIndex; j < rowEndIndex; j++) { dump.append(BYTE2CHAR[getUnsignedByte(buf, j)]); } dump.append(BYTEPADDING[remainder]); dump.append('|'); } dump.append(NEWLINE + "+--------+-------------------------------------------------+----------------+"); } private static void appendHexDumpRowPrefix(StringBuilder dump, int row, int rowStartIndex) { if (row < HEXDUMP_ROWPREFIXES.length) { dump.append(HEXDUMP_ROWPREFIXES[row]); } else { dump.append(NEWLINE); dump.append(Long.toHexString(rowStartIndex & 0xFFFFFFFFL | 0x100000000L)); dump.setCharAt(dump.length() - 9, '|'); dump.append('|'); } } public static short getUnsignedByte(ByteBuffer buffer, int index) { return (short) (buffer.get(index) & 0xFF); } } ``` ### 2.3 ByteBuffer 常见方法 #### 分配空间 可以使用 allocate 方法为 ByteBuffer 分配空间,其它 buffer 类也有该方法 ```java Bytebuffer buf = ByteBuffer.allocate(16); ``` #### 向 buffer 写入数据 有两种办法 * 调用 channel 的 read 方法 * 调用 buffer 自己的 put 方法 ```java int readBytes = channel.read(buf); ``` 和 ```java buf.put((byte)127); ``` #### 从 buffer 读取数据 同样有两种办法 * 调用 channel 的 write 方法 * 调用 buffer 自己的 get 方法 ```java int writeBytes = channel.write(buf); ``` 和 ```java byte b = buf.get(); ``` get 方法会让 position 读指针向后走,如果想重复读取数据 * 可以调用 rewind 方法将 position 重新置为 0 * 或者调用 get(int i) 方法获取索引 i 的内容,它不会移动读指针 #### mark 和 reset mark 是在读取时,做一个标记,即使 position 改变,只要调用 reset 就能回到 mark 的位置 > **注意** > > rewind 和 flip 都会清除 mark 位置 #### 字符串与 ByteBuffer 互转 ```java ByteBuffer buffer1 = StandardCharsets.UTF_8.encode("你好"); ByteBuffer buffer2 = Charset.forName("utf-8").encode("你好"); debug(buffer1); debug(buffer2); CharBuffer buffer3 = StandardCharsets.UTF_8.decode(buffer1); System.out.println(buffer3.getClass()); System.out.println(buffer3.toString()); ``` 输出 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| e4 bd a0 e5 a5 bd |...... | +--------+-------------------------------------------------+----------------+ +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| e4 bd a0 e5 a5 bd |...... | +--------+-------------------------------------------------+----------------+ class java.nio.HeapCharBuffer 你好 ``` #### ⚠️ Buffer 的线程安全 > Buffer 是**非线程安全的** ### 2.4 Scattering Reads 分散读取,有一个文本文件 3parts.txt ``` onetwothree ``` 使用如下方式读取,可以将数据填充至多个 buffer ```java try (RandomAccessFile file = new RandomAccessFile("helloword/3parts.txt", "rw")) { FileChannel channel = file.getChannel(); ByteBuffer a = ByteBuffer.allocate(3); ByteBuffer b = ByteBuffer.allocate(3); ByteBuffer c = ByteBuffer.allocate(5); channel.read(new ByteBuffer[]{a, b, c}); a.flip(); b.flip(); c.flip(); debug(a); debug(b); debug(c); } catch (IOException e) { e.printStackTrace(); } ``` 结果 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 6f 6e 65 |one | +--------+-------------------------------------------------+----------------+ +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 74 77 6f |two | +--------+-------------------------------------------------+----------------+ +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 74 68 72 65 65 |three | +--------+-------------------------------------------------+----------------+ ``` ### 2.5 Gathering Writes 使用如下方式写入,可以将多个 buffer 的数据填充至 channel ```java try (RandomAccessFile file = new RandomAccessFile("helloword/3parts.txt", "rw")) { FileChannel channel = file.getChannel(); ByteBuffer d = ByteBuffer.allocate(4); ByteBuffer e = ByteBuffer.allocate(4); channel.position(11); d.put(new byte[]{'f', 'o', 'u', 'r'}); e.put(new byte[]{'f', 'i', 'v', 'e'}); d.flip(); e.flip(); debug(d); debug(e); channel.write(new ByteBuffer[]{d, e}); } catch (IOException e) { e.printStackTrace(); } ``` 输出 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 66 6f 75 72 |four | +--------+-------------------------------------------------+----------------+ +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 66 69 76 65 |five | +--------+-------------------------------------------------+----------------+ ``` 文件内容 ``` onetwothreefourfive ``` ### 2.6 练习 网络上有多条数据发送给服务端,数据之间使用 \n 进行分隔 但由于某种原因这些数据在接收时,被进行了重新组合,例如原始数据有3条为 * Hello,world\n * I'm zhangsan\n * How are you?\n 变成了下面的两个 byteBuffer (黏包,半包) * Hello,world\nI'm zhangsan\nHo * w are you?\n 现在要求你编写程序,将错乱的数据恢复成原始的按 \n 分隔的数据 ```java public static void main(String[] args) { ByteBuffer source = ByteBuffer.allocate(32); // 11 24 source.put("Hello,world\nI'm zhangsan\nHo".getBytes()); split(source); source.put("w are you?\nhaha!\n".getBytes()); split(source); } private static void split(ByteBuffer source) { source.flip(); int oldLimit = source.limit(); for (int i = 0; i < oldLimit; i++) { if (source.get(i) == '\n') { System.out.println(i); ByteBuffer target = ByteBuffer.allocate(i + 1 - source.position()); // 0 ~ limit source.limit(i + 1); target.put(source); // 从source 读,向 target 写 debugAll(target); source.limit(oldLimit); } } source.compact(); } ``` ## 3. 文件编程 ### 3.1 FileChannel #### ⚠️ FileChannel 工作模式 > FileChannel 只能工作在阻塞模式下 #### 获取 不能直接打开 FileChannel,必须通过 FileInputStream、FileOutputStream 或者 RandomAccessFile 来获取 FileChannel,它们都有 getChannel 方法 * 通过 FileInputStream 获取的 channel 只能读 * 通过 FileOutputStream 获取的 channel 只能写 * 通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式决定 #### 读取 会从 channel 读取数据填充 ByteBuffer,返回值表示读到了多少字节,-1 表示到达了文件的末尾 ```java int readBytes = channel.read(buffer); ``` #### 写入 写入的正确姿势如下, SocketChannel ```java ByteBuffer buffer = ...; buffer.put(...); // 存入数据 buffer.flip(); // 切换读模式 while(buffer.hasRemaining()) { channel.write(buffer); } ``` 在 while 中调用 channel.write 是因为 write 方法并不能保证一次将 buffer 中的内容全部写入 channel #### 关闭 channel 必须关闭,不过调用了 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 close 方法会间接地调用 channel 的 close 方法 #### 位置 获取当前位置 ```java long pos = channel.position(); ``` 设置当前位置 ```java long newPos = ...; channel.position(newPos); ``` 设置当前位置时,如果设置为文件的末尾 * 这时读取会返回 -1 * 这时写入,会追加内容,但要注意如果 position 超过了文件末尾,再写入时在新内容和原末尾之间会有空洞(00) #### 大小 使用 size 方法获取文件的大小 #### 强制写入 操作系统出于性能的考虑,会将数据缓存,不是立刻写入磁盘。可以调用 force(true) 方法将文件内容和元数据(文件的权限等信息)立刻写入磁盘 ### 3.2 两个 Channel 传输数据 ```java String FROM = "helloword/data.txt"; String TO = "helloword/to.txt"; long start = System.nanoTime(); try (FileChannel from = new FileInputStream(FROM).getChannel(); FileChannel to = new FileOutputStream(TO).getChannel(); ) { from.transferTo(0, from.size(), to); } catch (IOException e) { e.printStackTrace(); } long end = System.nanoTime(); System.out.println("transferTo 用时:" + (end - start) / 1000_000.0); ``` 输出 ``` transferTo 用时:8.2011 ``` 超过 2g 大小的文件传输 ```java public class TestFileChannelTransferTo { public static void main(String[] args) { try ( FileChannel from = new FileInputStream("data.txt").getChannel(); FileChannel to = new FileOutputStream("to.txt").getChannel(); ) { // 效率高,底层会利用操作系统的零拷贝进行优化 long size = from.size(); // left 变量代表还剩余多少字节 for (long left = size; left > 0; ) { System.out.println("position:" + (size - left) + " left:" + left); left -= from.transferTo((size - left), left, to); } } catch (IOException e) { e.printStackTrace(); } } } ``` 实际传输一个超大文件 ``` position:0 left:7769948160 position:2147483647 left:5622464513 position:4294967294 left:3474980866 position:6442450941 left:1327497219 ``` ### 3.3 Path jdk7 引入了 Path 和 Paths 类 * Path 用来表示文件路径 * Paths 是工具类,用来获取 Path 实例 ```java Path source = Paths.get("1.txt"); // 相对路径 使用 user.dir 环境变量来定位 1.txt Path source = Paths.get("d:\\1.txt"); // 绝对路径 代表了 d:\1.txt Path source = Paths.get("d:/1.txt"); // 绝对路径 同样代表了 d:\1.txt Path projects = Paths.get("d:\\data", "projects"); // 代表了 d:\data\projects ``` * `.` 代表了当前路径 * `..` 代表了上一级路径 例如目录结构如下 ``` d: |- data |- projects |- a |- b ``` 代码 ```java Path path = Paths.get("d:\\data\\projects\\a\\..\\b"); System.out.println(path); System.out.println(path.normalize()); // 正常化路径 ``` 会输出 ``` d:\data\projects\a\..\b d:\data\projects\b ``` ### 3.4 Files 检查文件是否存在 ```java Path path = Paths.get("helloword/data.txt"); System.out.println(Files.exists(path)); ``` 创建一级目录 ```java Path path = Paths.get("helloword/d1"); Files.createDirectory(path); ``` * 如果目录已存在,会抛异常 FileAlreadyExistsException * 不能一次创建多级目录,否则会抛异常 NoSuchFileException 创建多级目录用 ```java Path path = Paths.get("helloword/d1/d2"); Files.createDirectories(path); ``` 拷贝文件 ```java Path source = Paths.get("helloword/data.txt"); Path target = Paths.get("helloword/target.txt"); Files.copy(source, target); ``` * 如果文件已存在,会抛异常 FileAlreadyExistsException 如果希望用 source 覆盖掉 target,需要用 StandardCopyOption 来控制 ```java Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); ``` 移动文件 ```java Path source = Paths.get("helloword/data.txt"); Path target = Paths.get("helloword/data.txt"); Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); ``` * StandardCopyOption.ATOMIC\_MOVE 保证文件移动的原子性 删除文件 ```java Path target = Paths.get("helloword/target.txt"); Files.delete(target); ``` * 如果文件不存在,会抛异常 NoSuchFileException 删除目录 ```java Path target = Paths.get("helloword/d1"); Files.delete(target); ``` * 如果目录还有内容,会抛异常 DirectoryNotEmptyException 遍历目录文件 ```java public static void main(String[] args) throws IOException { Path path = Paths.get("C:\\Program Files\\Java\\jdk1.8.0_91"); AtomicInteger dirCount = new AtomicInteger(); AtomicInteger fileCount = new AtomicInteger(); Files.walkFileTree(path, new SimpleFileVisitor(){ @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { System.out.println(dir); dirCount.incrementAndGet(); return super.preVisitDirectory(dir, attrs); } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { System.out.println(file); fileCount.incrementAndGet(); return super.visitFile(file, attrs); } }); System.out.println(dirCount); // 133 System.out.println(fileCount); // 1479 } ``` 统计 jar 的数目 ```java Path path = Paths.get("C:\\Program Files\\Java\\jdk1.8.0_91"); AtomicInteger fileCount = new AtomicInteger(); Files.walkFileTree(path, new SimpleFileVisitor(){ @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { if (file.toFile().getName().endsWith(".jar")) { fileCount.incrementAndGet(); } return super.visitFile(file, attrs); } }); System.out.println(fileCount); // 724 ``` 删除多级目录 ```java Path path = Paths.get("d:\\a"); Files.walkFileTree(path, new SimpleFileVisitor(){ @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { Files.delete(file); return super.visitFile(file, attrs); } @Override public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { Files.delete(dir); return super.postVisitDirectory(dir, exc); } }); ``` #### ⚠️ 删除很危险 > 删除是危险操作,确保要递归删除的文件夹没有重要内容 拷贝多级目录 ```java long start = System.currentTimeMillis(); String source = "D:\\Snipaste-1.16.2-x64"; String target = "D:\\Snipaste-1.16.2-x64aaa"; Files.walk(Paths.get(source)).forEach(path -> { try { String targetName = path.toString().replace(source, target); // 是目录 if (Files.isDirectory(path)) { Files.createDirectory(Paths.get(targetName)); } // 是普通文件 else if (Files.isRegularFile(path)) { Files.copy(path, Paths.get(targetName)); } } catch (IOException e) { e.printStackTrace(); } }); long end = System.currentTimeMillis(); System.out.println(end - start); ``` ## 4. 网络编程 ### 4.1 非阻塞 vs 阻塞 #### 阻塞 * 阻塞模式下,相关方法都会导致线程暂停 * ServerSocketChannel.accept 会在没有连接建立时让线程暂停 * SocketChannel.read 会在没有数据可读时让线程暂停 * 阻塞的表现其实就是线程暂停了,暂停期间不会占用 cpu,但线程相当于闲置 * 单线程下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持 * 但多线程下,有新的问题,体现在以下方面 * 32 位 jvm 一个线程 320k,64 位 jvm 一个线程 1024k,如果连接数过多,必然导致 OOM,并且线程太多,反而会因为频繁上下文切换导致性能降低 * 可以采用线程池技术来减少线程数和线程上下文切换,但治标不治本,如果有很多连接建立,但长时间 inactive,会阻塞线程池中所有线程,因此不适合长连接,只适合短连接 服务器端 ```java // 使用 nio 来理解阻塞模式, 单线程 // 0. ByteBuffer ByteBuffer buffer = ByteBuffer.allocate(16); // 1. 创建了服务器 ServerSocketChannel ssc = ServerSocketChannel.open(); // 2. 绑定监听端口 ssc.bind(new InetSocketAddress(8080)); // 3. 连接集合 List channels = new ArrayList<>(); while (true) { // 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信 log.debug("connecting..."); SocketChannel sc = ssc.accept(); // 阻塞方法,线程停止运行 log.debug("connected... {}", sc); channels.add(sc); for (SocketChannel channel : channels) { // 5. 接收客户端发送的数据 log.debug("before read... {}", channel); channel.read(buffer); // 阻塞方法,线程停止运行 buffer.flip(); debugRead(buffer); buffer.clear(); log.debug("after read...{}", channel); } } ``` 客户端 ```java SocketChannel sc = SocketChannel.open(); sc.connect(new InetSocketAddress("localhost", 8080)); System.out.println("waiting..."); ``` #### 非阻塞 * 非阻塞模式下,相关方法都会不会让线程暂停 * 在 ServerSocketChannel.accept 在没有连接建立时,会返回 null,继续运行 * SocketChannel.read 在没有数据可读时,会返回 0,但线程不必阻塞,可以去执行其它 SocketChannel 的 read 或是去执行 ServerSocketChannel.accept * 写数据时,线程只是等待数据写入 Channel 即可,无需等 Channel 通过网络把数据发送出去 * 但非阻塞模式下,即使没有连接建立,和可读数据,线程仍然在不断运行,白白浪费了 cpu * 数据复制过程中,线程实际还是阻塞的(AIO 改进的地方) 服务器端,客户端代码不变 ```java // 使用 nio 来理解非阻塞模式, 单线程 // 0. ByteBuffer ByteBuffer buffer = ByteBuffer.allocate(16); // 1. 创建了服务器 ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); // 非阻塞模式 // 2. 绑定监听端口 ssc.bind(new InetSocketAddress(8080)); // 3. 连接集合 List channels = new ArrayList<>(); while (true) { // 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信 SocketChannel sc = ssc.accept(); // 非阻塞,线程还会继续运行,如果没有连接建立,但sc是null if (sc != null) { log.debug("connected... {}", sc); sc.configureBlocking(false); // 非阻塞模式 channels.add(sc); } for (SocketChannel channel : channels) { // 5. 接收客户端发送的数据 int read = channel.read(buffer);// 非阻塞,线程仍然会继续运行,如果没有读到数据,read 返回 0 if (read > 0) { buffer.flip(); debugRead(buffer); buffer.clear(); log.debug("after read...{}", channel); } } } ``` #### 多路复用 单线程可以配合 Selector 完成对多个 Channel 可读写事件的监控,这称之为多路复用 * 多路复用仅针对网络 IO、普通文件 IO 没法利用多路复用 * 如果不用 Selector 的非阻塞模式,线程大部分时间都在做无用功,而 Selector 能够保证 * 有可连接事件时才去连接 * 有可读事件才去读取 * 有可写事件才去写入 * 限于网络传输能力,Channel 未必时时可写,一旦 Channel 可写,会触发 Selector 的可写事件 ### 4.2 Selector ```mermaid graph TD subgraph selector 版 thread --> selector selector --> c1(channel) selector --> c2(channel) selector --> c3(channel) end ``` 好处 * 一个线程配合 selector 就可以监控多个 channel 的事件,事件发生线程才去处理。避免非阻塞模式下所做无用功 * 让这个线程能够被充分利用 * 节约了线程的数量 * 减少了线程上下文切换 #### 创建 ```java Selector selector = Selector.open(); ``` #### 绑定 Channel 事件 也称之为注册事件,绑定的事件 selector 才会关心 ```java channel.configureBlocking(false); SelectionKey key = channel.register(selector, 绑定事件); ``` * channel 必须工作在非阻塞模式 * FileChannel 没有非阻塞模式,因此不能配合 selector 一起使用 * 绑定的事件类型可以有 * connect - 客户端连接成功时触发 * accept - 服务器端成功接受连接时触发 * read - 数据可读入时触发,有因为接收能力弱,数据暂不能读入的情况 * write - 数据可写出时触发,有因为发送能力弱,数据暂不能写出的情况 #### 监听 Channel 事件 可以通过下面三种方法来监听是否有事件发生,方法的返回值代表有多少 channel 发生了事件 方法1,阻塞直到绑定事件发生 ```java int count = selector.select(); ``` 方法2,阻塞直到绑定事件发生,或是超时(时间单位为 ms) ```java int count = selector.select(long timeout); ``` 方法3,不会阻塞,也就是不管有没有事件,立刻返回,自己根据返回值检查是否有事件 ```java int count = selector.selectNow(); ``` #### 💡 select 何时不阻塞 > * 事件发生时 > * 客户端发起连接请求,会触发 accept 事件 > * 客户端发送数据过来,客户端正常、异常关闭时,都会触发 read 事件,另外如果发送的数据大于 buffer 缓冲区,会触发多次读取事件 > * channel 可写,会触发 write 事件 > * 在 linux 下 nio bug 发生时 > * 调用 selector.wakeup() > * 调用 selector.close() > * selector 所在线程 interrupt ### 4.3 处理 accept 事件 客户端代码为 ```java public class Client { public static void main(String[] args) { try (Socket socket = new Socket("localhost", 8080)) { System.out.println(socket); socket.getOutputStream().write("world".getBytes()); System.in.read(); } catch (IOException e) { e.printStackTrace(); } } } ``` 服务器端代码为 ```java @Slf4j public class ChannelDemo6 { public static void main(String[] args) { try (ServerSocketChannel channel = ServerSocketChannel.open()) { channel.bind(new InetSocketAddress(8080)); System.out.println(channel); Selector selector = Selector.open(); channel.configureBlocking(false); channel.register(selector, SelectionKey.OP_ACCEPT); while (true) { int count = selector.select(); // int count = selector.selectNow(); log.debug("select count: {}", count); // if(count <= 0) { // continue; // } // 获取所有事件 Set keys = selector.selectedKeys(); // 遍历所有事件,逐一处理 Iterator iter = keys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); // 判断事件类型 if (key.isAcceptable()) { ServerSocketChannel c = (ServerSocketChannel) key.channel(); // 必须处理 SocketChannel sc = c.accept(); log.debug("{}", sc); } // 处理完毕,必须将事件移除 iter.remove(); } } } catch (IOException e) { e.printStackTrace(); } } } ``` #### 💡 事件发生后能否不处理 > 事件发生后,要么处理,要么取消(cancel),不能什么都不做,否则下次该事件仍会触发,这是因为 nio 底层使用的是水平触发 ### 4.4 处理 read 事件 ```java @Slf4j public class ChannelDemo6 { public static void main(String[] args) { try (ServerSocketChannel channel = ServerSocketChannel.open()) { channel.bind(new InetSocketAddress(8080)); System.out.println(channel); Selector selector = Selector.open(); channel.configureBlocking(false); channel.register(selector, SelectionKey.OP_ACCEPT); while (true) { int count = selector.select(); // int count = selector.selectNow(); log.debug("select count: {}", count); // if(count <= 0) { // continue; // } // 获取所有事件 Set keys = selector.selectedKeys(); // 遍历所有事件,逐一处理 Iterator iter = keys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); // 判断事件类型 if (key.isAcceptable()) { ServerSocketChannel c = (ServerSocketChannel) key.channel(); // 必须处理 SocketChannel sc = c.accept(); sc.configureBlocking(false); sc.register(selector, SelectionKey.OP_READ); log.debug("连接已建立: {}", sc); } else if (key.isReadable()) { SocketChannel sc = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(128); int read = sc.read(buffer); if(read == -1) { key.cancel(); sc.close(); } else { buffer.flip(); debug(buffer); } } // 处理完毕,必须将事件移除 iter.remove(); } } } catch (IOException e) { e.printStackTrace(); } } } ``` 开启两个客户端,修改一下发送文字,输出 ``` sun.nio.ch.ServerSocketChannelImpl[/0:0:0:0:0:0:0:0:8080] 21:16:39 [DEBUG] [main] c.i.n.ChannelDemo6 - select count: 1 21:16:39 [DEBUG] [main] c.i.n.ChannelDemo6 - 连接已建立: java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:60367] 21:16:39 [DEBUG] [main] c.i.n.ChannelDemo6 - select count: 1 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 68 65 6c 6c 6f |hello | +--------+-------------------------------------------------+----------------+ 21:16:59 [DEBUG] [main] c.i.n.ChannelDemo6 - select count: 1 21:16:59 [DEBUG] [main] c.i.n.ChannelDemo6 - 连接已建立: java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:60378] 21:16:59 [DEBUG] [main] c.i.n.ChannelDemo6 - select count: 1 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 77 6f 72 6c 64 |world | +--------+-------------------------------------------------+----------------+ ``` #### 💡 为何要 iter.remove() > 因为 select 在事件发生后,就会将相关的 key 放入 selectedKeys 集合,但不会在处理完后从 selectedKeys 集合中移除,需要我们自己编码删除。例如 > > * 第一次触发了 ssckey 上的 accept 事件,没有移除 ssckey > * 第二次触发了 sckey 上的 read 事件,但这时 selectedKeys 中还有上次的 ssckey ,在处理时因为没有真正的 serverSocket 连上了,就会导致空指针异常 #### 💡 cancel 的作用 > cancel 会取消注册在 selector 上的 channel,并从 keys 集合中删除 key 后续不会再监听事件 #### ⚠️ 不处理边界的问题 以前有同学写过这样的代码,思考注释中两个问题,以 bio 为例,其实 nio 道理是一样的 ```java public class Server { public static void main(String[] args) throws IOException { ServerSocket ss=new ServerSocket(9000); while (true) { Socket s = ss.accept(); InputStream in = s.getInputStream(); // 这里这么写,有没有问题 byte[] arr = new byte[4]; while(true) { int read = in.read(arr); // 这里这么写,有没有问题 if(read == -1) { break; } System.out.println(new String(arr, 0, read)); } } } } ``` 客户端 ```java public class Client { public static void main(String[] args) throws IOException { Socket max = new Socket("localhost", 9000); OutputStream out = max.getOutputStream(); out.write("hello".getBytes()); out.write("world".getBytes()); out.write("你好".getBytes()); max.close(); } } ``` 输出 ``` hell owor ld� �好 ``` 为什么? #### 处理消息的边界 ```mermaid sequenceDiagram participant c1 as 客户端1 participant s as 服务器 participant b1 as ByteBuffer1 participant b2 as ByteBuffer2 c1 ->> s: 发送 01234567890abcdef3333\r s ->> b1: 第一次 read 存入 01234567890abcdef s ->> b2: 扩容 b1 ->> b2: 拷贝 01234567890abcdef s ->> b2: 第二次 read 存入 3333\r b2 ->> b2: 01234567890abcdef3333\r ``` 服务器端 ```java private static void split(ByteBuffer source) { source.flip(); for (int i = 0; i < source.limit(); i++) { // 找到一条完整消息 if (source.get(i) == '\n') { int length = i + 1 - source.position(); // 把这条完整消息存入新的 ByteBuffer ByteBuffer target = ByteBuffer.allocate(length); // 从 source 读,向 target 写 for (int j = 0; j < length; j++) { target.put(source.get()); } debugAll(target); } } source.compact(); // 0123456789abcdef position 16 limit 16 } public static void main(String[] args) throws IOException { // 1. 创建 selector, 管理多个 channel Selector selector = Selector.open(); ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); // 2. 建立 selector 和 channel 的联系(注册) // SelectionKey 就是将来事件发生后,通过它可以知道事件和哪个channel的事件 SelectionKey sscKey = ssc.register(selector, 0, null); // key 只关注 accept 事件 sscKey.interestOps(SelectionKey.OP_ACCEPT); log.debug("sscKey:{}", sscKey); ssc.bind(new InetSocketAddress(8080)); while (true) { // 3. select 方法, 没有事件发生,线程阻塞,有事件,线程才会恢复运行 // select 在事件未处理时,它不会阻塞, 事件发生后要么处理,要么取消,不能置之不理 selector.select(); // 4. 处理事件, selectedKeys 内部包含了所有发生的事件 Iterator iter = selector.selectedKeys().iterator(); // accept, read while (iter.hasNext()) { SelectionKey key = iter.next(); // 处理key 时,要从 selectedKeys 集合中删除,否则下次处理就会有问题 iter.remove(); log.debug("key: {}", key); // 5. 区分事件类型 if (key.isAcceptable()) { // 如果是 accept ServerSocketChannel channel = (ServerSocketChannel) key.channel(); SocketChannel sc = channel.accept(); sc.configureBlocking(false); ByteBuffer buffer = ByteBuffer.allocate(16); // attachment // 将一个 byteBuffer 作为附件关联到 selectionKey 上 SelectionKey scKey = sc.register(selector, 0, buffer); scKey.interestOps(SelectionKey.OP_READ); log.debug("{}", sc); log.debug("scKey:{}", scKey); } else if (key.isReadable()) { // 如果是 read try { SocketChannel channel = (SocketChannel) key.channel(); // 拿到触发事件的channel // 获取 selectionKey 上关联的附件 ByteBuffer buffer = (ByteBuffer) key.attachment(); int read = channel.read(buffer); // 如果是正常断开,read 的方法的返回值是 -1 if(read == -1) { key.cancel(); } else { split(buffer); // 需要扩容 if (buffer.position() == buffer.limit()) { ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2); buffer.flip(); newBuffer.put(buffer); // 0123456789abcdef3333\n key.attach(newBuffer); } } } catch (IOException e) { e.printStackTrace(); key.cancel(); // 因为客户端断开了,因此需要将 key 取消(从 selector 的 keys 集合中真正删除 key) } } } } } ``` 客户端 ```java SocketChannel sc = SocketChannel.open(); sc.connect(new InetSocketAddress("localhost", 8080)); SocketAddress address = sc.getLocalAddress(); // sc.write(Charset.defaultCharset().encode("hello\nworld\n")); sc.write(Charset.defaultCharset().encode("0123\n456789abcdef")); sc.write(Charset.defaultCharset().encode("0123456789abcdef3333\n")); System.in.read(); ``` #### ByteBuffer 大小分配 * 每个 channel 都需要记录可能被切分的消息,因为 ByteBuffer 不能被多个 channel 共同使用,因此需要为每个 channel 维护一个独立的 ByteBuffer * ByteBuffer 不能太大,比如一个 ByteBuffer 1Mb 的话,要支持百万连接就要 1Tb 内存,因此需要设计大小可变的 ByteBuffer * 一种思路是首先分配一个较小的 buffer,例如 4k,如果发现数据不够,再分配 8k 的 buffer,将 4k buffer 内容拷贝至 8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能,参考实现 * 另一种思路是用多个数组组成 buffer,一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗 ### 4.5 处理 write 事件 #### 一次无法写完例子 * 非阻塞模式下,无法保证把 buffer 中所有数据都写入 channel,因此需要追踪 write 方法的返回值(代表实际写入字节数) * 用 selector 监听所有 channel 的可写事件,每个 channel 都需要一个 key 来跟踪 buffer,但这样又会导致占用内存过多,就有两阶段策略 * 当消息处理器第一次写入消息时,才将 channel 注册到 selector 上 * selector 检查 channel 上的可写事件,如果所有的数据写完了,就取消 channel 的注册 * 如果不取消,会每次可写均会触发 write 事件 ```java public class WriteServer { public static void main(String[] args) throws IOException { ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); ssc.bind(new InetSocketAddress(8080)); Selector selector = Selector.open(); ssc.register(selector, SelectionKey.OP_ACCEPT); while(true) { selector.select(); Iterator iter = selector.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); if (key.isAcceptable()) { SocketChannel sc = ssc.accept(); sc.configureBlocking(false); SelectionKey sckey = sc.register(selector, SelectionKey.OP_READ); // 1. 向客户端发送内容 StringBuilder sb = new StringBuilder(); for (int i = 0; i < 3000000; i++) { sb.append("a"); } ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString()); int write = sc.write(buffer); // 3. write 表示实际写了多少字节 System.out.println("实际写入字节:" + write); // 4. 如果有剩余未读字节,才需要关注写事件 if (buffer.hasRemaining()) { // read 1 write 4 // 在原有关注事件的基础上,多关注 写事件 sckey.interestOps(sckey.interestOps() + SelectionKey.OP_WRITE); // 把 buffer 作为附件加入 sckey sckey.attach(buffer); } } else if (key.isWritable()) { ByteBuffer buffer = (ByteBuffer) key.attachment(); SocketChannel sc = (SocketChannel) key.channel(); int write = sc.write(buffer); System.out.println("实际写入字节:" + write); if (!buffer.hasRemaining()) { // 写完了 key.interestOps(key.interestOps() - SelectionKey.OP_WRITE); key.attach(null); } } } } } } ``` 客户端 ```java public class WriteClient { public static void main(String[] args) throws IOException { Selector selector = Selector.open(); SocketChannel sc = SocketChannel.open(); sc.configureBlocking(false); sc.register(selector, SelectionKey.OP_CONNECT | SelectionKey.OP_READ); sc.connect(new InetSocketAddress("localhost", 8080)); int count = 0; while (true) { selector.select(); Iterator iter = selector.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); if (key.isConnectable()) { System.out.println(sc.finishConnect()); } else if (key.isReadable()) { ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024); count += sc.read(buffer); buffer.clear(); System.out.println(count); } } } } } ``` #### 💡 write 为何要取消 只要向 channel 发送数据时,socket 缓冲可写,这个事件会频繁触发,因此应当只在 socket 缓冲区写不下时再关注可写事件,数据写完之后再取消关注 ### 4.6 更进一步 #### 💡 利用多线程优化 > 现在都是多核 cpu,设计时要充分考虑别让 cpu 的力量被白白浪费 前面的代码只有一个选择器,没有充分利用多核 cpu,如何改进呢? 分两组选择器 * 单线程配一个选择器,专门处理 accept 事件 * 创建 cpu 核心数的线程,每个线程配一个选择器,轮流处理 read 事件 ```java public class ChannelDemo7 { public static void main(String[] args) throws IOException { new BossEventLoop().register(); } @Slf4j static class BossEventLoop implements Runnable { private Selector boss; private WorkerEventLoop[] workers; private volatile boolean start = false; AtomicInteger index = new AtomicInteger(); public void register() throws IOException { if (!start) { ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.bind(new InetSocketAddress(8080)); ssc.configureBlocking(false); boss = Selector.open(); SelectionKey ssckey = ssc.register(boss, 0, null); ssckey.interestOps(SelectionKey.OP_ACCEPT); workers = initEventLoops(); new Thread(this, "boss").start(); log.debug("boss start..."); start = true; } } public WorkerEventLoop[] initEventLoops() { // EventLoop[] eventLoops = new EventLoop[Runtime.getRuntime().availableProcessors()]; WorkerEventLoop[] workerEventLoops = new WorkerEventLoop[2]; for (int i = 0; i < workerEventLoops.length; i++) { workerEventLoops[i] = new WorkerEventLoop(i); } return workerEventLoops; } @Override public void run() { while (true) { try { boss.select(); Iterator iter = boss.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); if (key.isAcceptable()) { ServerSocketChannel c = (ServerSocketChannel) key.channel(); SocketChannel sc = c.accept(); sc.configureBlocking(false); log.debug("{} connected", sc.getRemoteAddress()); workers[index.getAndIncrement() % workers.length].register(sc); } } } catch (IOException e) { e.printStackTrace(); } } } } @Slf4j static class WorkerEventLoop implements Runnable { private Selector worker; private volatile boolean start = false; private int index; private final ConcurrentLinkedQueue tasks = new ConcurrentLinkedQueue<>(); public WorkerEventLoop(int index) { this.index = index; } public void register(SocketChannel sc) throws IOException { if (!start) { worker = Selector.open(); new Thread(this, "worker-" + index).start(); start = true; } tasks.add(() -> { try { SelectionKey sckey = sc.register(worker, 0, null); sckey.interestOps(SelectionKey.OP_READ); worker.selectNow(); } catch (IOException e) { e.printStackTrace(); } }); worker.wakeup(); } @Override public void run() { while (true) { try { worker.select(); Runnable task = tasks.poll(); if (task != null) { task.run(); } Set keys = worker.selectedKeys(); Iterator iter = keys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); if (key.isReadable()) { SocketChannel sc = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(128); try { int read = sc.read(buffer); if (read == -1) { key.cancel(); sc.close(); } else { buffer.flip(); log.debug("{} message:", sc.getRemoteAddress()); debugAll(buffer); } } catch (IOException e) { e.printStackTrace(); key.cancel(); sc.close(); } } iter.remove(); } } catch (IOException e) { e.printStackTrace(); } } } } } ``` #### 💡 如何拿到 cpu 个数 > * Runtime.getRuntime().availableProcessors() 如果工作在 docker 容器下,因为容器不是物理隔离的,会拿到物理 cpu 个数,而不是容器申请时的个数 > * 这个问题直到 jdk 10 才修复,使用 jvm 参数 UseContainerSupport 配置, 默认开启 ### 4.7 UDP * UDP 是无连接的,client 发送数据不会管 server 是否开启 * server 这边的 receive 方法会将接收到的数据存入 byte buffer,但如果数据报文超过 buffer 大小,多出来的数据会被默默抛弃 首先启动服务器端 ```java public class UdpServer { public static void main(String[] args) { try (DatagramChannel channel = DatagramChannel.open()) { channel.socket().bind(new InetSocketAddress(9999)); System.out.println("waiting..."); ByteBuffer buffer = ByteBuffer.allocate(32); channel.receive(buffer); buffer.flip(); debug(buffer); } catch (IOException e) { e.printStackTrace(); } } } ``` 输出 ``` waiting... ``` 运行客户端 ```java public class UdpClient { public static void main(String[] args) { try (DatagramChannel channel = DatagramChannel.open()) { ByteBuffer buffer = StandardCharsets.UTF_8.encode("hello"); InetSocketAddress address = new InetSocketAddress("localhost", 9999); channel.send(buffer, address); } catch (Exception e) { e.printStackTrace(); } } } ``` 接下来服务器端输出 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 68 65 6c 6c 6f |hello | +--------+-------------------------------------------------+----------------+ ``` ## 5. NIO vs BIO ### 5.1 stream vs channel * stream 不会自动缓冲数据,channel 会利用系统提供的发送缓冲区、接收缓冲区(更为底层) * stream 仅支持阻塞 API,channel 同时支持阻塞、非阻塞 API,网络 channel 可配合 selector 实现多路复用 * 二者均为全双工,即读写可以同时进行 ### 5.2 IO 模型 同步阻塞、同步非阻塞、同步多路复用、异步阻塞(没有此情况)、异步非阻塞 * 同步:线程自己去获取结果(一个线程) * 异步:线程自己不去获取结果,而是由其它线程送结果(至少两个线程) 当调用一次 channel.read 或 stream.read 后,会切换至操作系统内核态来完成真正数据读取,而读取又分为两个阶段,分别为: * 等待数据阶段 * 复制数据阶段 * 阻塞 IO * 非阻塞 IO * 多路复用 * 信号驱动 * 异步 IO * 阻塞 IO vs 多路复用 #### 🔖 参考 UNIX 网络编程 - 卷 I ### 5.3 零拷贝 #### 传统 IO 问题 传统的 IO 将一个文件通过 socket 写出 ```java File f = new File("helloword/data.txt"); RandomAccessFile file = new RandomAccessFile(file, "r"); byte[] buf = new byte[(int)f.length()]; file.read(buf); Socket socket = ...; socket.getOutputStream().write(buf); ``` 内部工作流程是这样的: 1. java 本身并不具备 IO 读写能力,因此 read 方法调用后,要从 java 程序的**用户态**切换至**内核态**,去调用操作系统(Kernel)的读能力,将数据读入**内核缓冲区**。这期间用户线程阻塞,操作系统使用 DMA(Direct Memory Access)来实现文件读,其间也不会使用 cpu > DMA 也可以理解为硬件单元,用来解放 cpu 完成文件 IO 2. 从**内核态**切换回**用户态**,将数据从**内核缓冲区**读入**用户缓冲区**(即 byte\[] buf),这期间 cpu 会参与拷贝,无法利用 DMA 3. 调用 write 方法,这时将数据从**用户缓冲区**(byte\[] buf)写入 **socket 缓冲区**,cpu 会参与拷贝 4. 接下来要向网卡写数据,这项能力 java 又不具备,因此又得从**用户态**切换至**内核态**,调用操作系统的写能力,使用 DMA 将 **socket 缓冲区**的数据写入网卡,不会使用 cpu 可以看到中间环节较多,java 的 IO 实际不是物理设备级别的读写,而是缓存的复制,底层的真正读写是操作系统来完成的 * 用户态与内核态的切换发生了 3 次,这个操作比较重量级 * 数据拷贝了共 4 次 #### NIO 优化 通过 DirectByteBuf * ByteBuffer.allocate(10) HeapByteBuffer 使用的还是 java 内存 * ByteBuffer.allocateDirect(10) DirectByteBuffer 使用的是操作系统内存 大部分步骤与优化前相同,不再赘述。唯有一点:java 可以使用 DirectByteBuf 将堆外内存映射到 jvm 内存中来直接访问使用 * 这块内存不受 jvm 垃圾回收的影响,因此内存地址固定,有助于 IO 读写 * java 中的 DirectByteBuf 对象仅维护了此内存的虚引用,内存回收分成两步 * DirectByteBuf 对象被垃圾回收,将虚引用加入引用队列 * 通过专门线程访问引用队列,根据虚引用释放堆外内存 * 减少了一次数据拷贝,用户态与内核态的切换次数没有减少 进一步优化(底层采用了 linux 2.1 后提供的 sendFile 方法),java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据 1. java 调用 transferTo 方法后,要从 java 程序的**用户态**切换至**内核态**,使用 DMA将数据读入**内核缓冲区**,不会使用 cpu 2. 数据从**内核缓冲区**传输到 **socket 缓冲区**,cpu 会参与拷贝 3. 最后使用 DMA 将 **socket 缓冲区**的数据写入网卡,不会使用 cpu 可以看到 * 只发生了一次用户态与内核态的切换 * 数据拷贝了 3 次 进一步优化(linux 2.4) 1. java 调用 transferTo 方法后,要从 java 程序的**用户态**切换至**内核态**,使用 DMA将数据读入**内核缓冲区**,不会使用 cpu 2. 只会将一些 offset 和 length 信息拷入 **socket 缓冲区**,几乎无消耗 3. 使用 DMA 将 **内核缓冲区**的数据写入网卡,不会使用 cpu 整个过程仅只发生了一次用户态与内核态的切换,数据拷贝了 2 次。所谓的【零拷贝】,并不是真正无拷贝,而是在不会拷贝重复数据到 jvm 内存中,零拷贝的优点有 * 更少的用户态与内核态的切换 * 不利用 cpu 计算,减少 cpu 缓存伪共享 * 零拷贝适合小文件传输 ### 5.3 AIO AIO 用来解决数据复制阶段的阻塞问题 * 同步意味着,在进行读写操作时,线程需要等待结果,还是相当于闲置 * 异步意味着,在进行读写操作时,线程不必等待结果,而是将来由操作系统来通过回调方式由另外的线程来获得结果 > 异步模型需要底层操作系统(Kernel)提供支持 > > * Windows 系统通过 IOCP 实现了真正的异步 IO > * Linux 系统异步 IO 在 2.6 版本引入,但其底层实现还是用多路复用模拟了异步 IO,性能没有优势 #### 文件 AIO 先来看看 AsynchronousFileChannel ```java @Slf4j public class AioDemo1 { public static void main(String[] args) throws IOException { try{ AsynchronousFileChannel s = AsynchronousFileChannel.open( Paths.get("1.txt"), StandardOpenOption.READ); ByteBuffer buffer = ByteBuffer.allocate(2); log.debug("begin..."); s.read(buffer, 0, null, new CompletionHandler() { @Override public void completed(Integer result, ByteBuffer attachment) { log.debug("read completed...{}", result); buffer.flip(); debug(buffer); } @Override public void failed(Throwable exc, ByteBuffer attachment) { log.debug("read failed..."); } }); } catch (IOException e) { e.printStackTrace(); } log.debug("do other things..."); System.in.read(); } } ``` 输出 ``` 13:44:56 [DEBUG] [main] c.i.aio.AioDemo1 - begin... 13:44:56 [DEBUG] [main] c.i.aio.AioDemo1 - do other things... 13:44:56 [DEBUG] [Thread-5] c.i.aio.AioDemo1 - read completed...2 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 0d |a. | +--------+-------------------------------------------------+----------------+ ``` 可以看到 * 响应文件读取成功的是另一个线程 Thread-5 * 主线程并没有 IO 操作阻塞 #### 💡 守护线程 默认文件 AIO 使用的线程都是守护线程,所以最后要执行 `System.in.read()` 以避免守护线程意外结束 #### 网络 AIO ```java public class AioServer { public static void main(String[] args) throws IOException { AsynchronousServerSocketChannel ssc = AsynchronousServerSocketChannel.open(); ssc.bind(new InetSocketAddress(8080)); ssc.accept(null, new AcceptHandler(ssc)); System.in.read(); } private static void closeChannel(AsynchronousSocketChannel sc) { try { System.out.printf("[%s] %s close\n", Thread.currentThread().getName(), sc.getRemoteAddress()); sc.close(); } catch (IOException e) { e.printStackTrace(); } } private static class ReadHandler implements CompletionHandler { private final AsynchronousSocketChannel sc; public ReadHandler(AsynchronousSocketChannel sc) { this.sc = sc; } @Override public void completed(Integer result, ByteBuffer attachment) { try { if (result == -1) { closeChannel(sc); return; } System.out.printf("[%s] %s read\n", Thread.currentThread().getName(), sc.getRemoteAddress()); attachment.flip(); System.out.println(Charset.defaultCharset().decode(attachment)); attachment.clear(); // 处理完第一个 read 时,需要再次调用 read 方法来处理下一个 read 事件 sc.read(attachment, attachment, this); } catch (IOException e) { e.printStackTrace(); } } @Override public void failed(Throwable exc, ByteBuffer attachment) { closeChannel(sc); exc.printStackTrace(); } } private static class WriteHandler implements CompletionHandler { private final AsynchronousSocketChannel sc; private WriteHandler(AsynchronousSocketChannel sc) { this.sc = sc; } @Override public void completed(Integer result, ByteBuffer attachment) { // 如果作为附件的 buffer 还有内容,需要再次 write 写出剩余内容 if (attachment.hasRemaining()) { sc.write(attachment); } } @Override public void failed(Throwable exc, ByteBuffer attachment) { exc.printStackTrace(); closeChannel(sc); } } private static class AcceptHandler implements CompletionHandler { private final AsynchronousServerSocketChannel ssc; public AcceptHandler(AsynchronousServerSocketChannel ssc) { this.ssc = ssc; } @Override public void completed(AsynchronousSocketChannel sc, Object attachment) { try { System.out.printf("[%s] %s connected\n", Thread.currentThread().getName(), sc.getRemoteAddress()); } catch (IOException e) { e.printStackTrace(); } ByteBuffer buffer = ByteBuffer.allocate(16); // 读事件由 ReadHandler 处理 sc.read(buffer, buffer, new ReadHandler(sc)); // 写事件由 WriteHandler 处理 sc.write(Charset.defaultCharset().encode("server hello!"), ByteBuffer.allocate(16), new WriteHandler(sc)); // 处理完第一个 accpet 时,需要再次调用 accept 方法来处理下一个 accept 事件 ssc.accept(null, this); } @Override public void failed(Throwable exc, Object attachment) { exc.printStackTrace(); } } } ``` --- --- url: /src/technology/java/netty/Netty02-入门.md --- # 二. Netty 入门 Netty-学习大纲 ## 1. 概述 ### 1.1 Netty 是什么? ``` Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients. ``` Netty 是一个异步的、基于事件驱动的网络应用框架,用于快速开发可维护、高性能的网络服务器和客户端 ### 1.2 Netty 的作者 他还是另一个著名网络应用框架 Mina 的重要贡献者 ### 1.3 Netty 的地位 Netty 在 Java 网络应用框架中的地位就好比:Spring 框架在 JavaEE 开发中的地位 以下的框架都使用了 Netty,因为它们有网络通信需求! * Cassandra - nosql 数据库 * Spark - 大数据分布式计算框架 * Hadoop - 大数据分布式存储框架 * RocketMQ - ali 开源的消息队列 * ElasticSearch - 搜索引擎 * gRPC - rpc 框架 * Dubbo - rpc 框架 * Spring 5.x - flux api 完全抛弃了 tomcat ,使用 netty 作为服务器端 * Zookeeper - 分布式协调框架 ### 1.4 Netty 的优势 * Netty vs NIO,工作量大,bug 多 * 需要自己构建协议 * 解决 TCP 传输问题,如粘包、半包 * epoll 空轮询导致 CPU 100% * 对 API 进行增强,使之更易用,如 FastThreadLocal => ThreadLocal,ByteBuf => ByteBuffer * Netty vs 其它网络应用框架 * Mina 由 apache 维护,将来 3.x 版本可能会有较大重构,破坏 API 向下兼容性,Netty 的开发迭代更迅速,API 更简洁、文档更优秀 * 久经考验,16年,Netty 版本 * 2.x 2004 * 3.x 2008 * 4.x 2013 * 5.x 已废弃(没有明显的性能提升,维护成本高) ## 2. Hello World ### 2.1 目标 开发一个简单的服务器端和客户端 * 客户端向服务器端发送 hello, world * 服务器仅接收,不返回 加入依赖 ```xml io.netty netty-all 4.1.39.Final ``` ### 2.2 服务器端 ```java new ServerBootstrap() .group(new NioEventLoopGroup()) // 1 .channel(NioServerSocketChannel.class) // 2 .childHandler(new ChannelInitializer() { // 3 protected void initChannel(NioSocketChannel ch) { ch.pipeline().addLast(new StringDecoder()); // 5 ch.pipeline().addLast(new SimpleChannelInboundHandler() { // 6 @Override protected void channelRead0(ChannelHandlerContext ctx, String msg) { System.out.println(msg); } }); } }) .bind(8080); // 4 ``` 代码解读 * 1 处,创建 NioEventLoopGroup,可以简单理解为 `线程池 + Selector` 后面会详细展开 * 2 处,选择服务 Scoket 实现类,其中 NioServerSocketChannel 表示基于 NIO 的服务器端实现,其它实现还有 - 3 处,为啥方法叫 childHandler,是接下来添加的处理器都是给 SocketChannel 用的,而不是给 ServerSocketChannel。ChannelInitializer 处理器(仅执行一次),它的作用是待客户端 SocketChannel 建立连接后,执行 initChannel 以便添加更多的处理器 - 4 处,ServerSocketChannel 绑定的监听端口 - 5 处,SocketChannel 的处理器,解码 ByteBuf => String - 6 处,SocketChannel 的业务处理器,使用上一个处理器的处理结果 ### 2.3 客户端 ```java new Bootstrap() .group(new NioEventLoopGroup()) // 1 .channel(NioSocketChannel.class) // 2 .handler(new ChannelInitializer() { // 3 @Override protected void initChannel(Channel ch) { ch.pipeline().addLast(new StringEncoder()); // 8 } }) .connect("127.0.0.1", 8080) // 4 .sync() // 5 .channel() // 6 .writeAndFlush(new Date() + ": hello world!"); // 7 ``` 代码解读 * 1 处,创建 NioEventLoopGroup,同 Server * 2 处,选择客户 Socket 实现类,NioSocketChannel 表示基于 NIO 的客户端实现,其它实现还有 - 3 处,添加 SocketChannel 的处理器,ChannelInitializer 处理器(仅执行一次),它的作用是待客户端 SocketChannel 建立连接后,执行 initChannel 以便添加更多的处理器 - 4 处,指定要连接的服务器和端口 - 5 处,Netty 中很多方法都是异步的,如 connect,这时需要使用 sync 方法等待 connect 建立连接完毕 - 6 处,获取 channel 对象,它即为通道抽象,可以进行数据读写操作 - 7 处,写入消息并清空缓冲区 - 8 处,消息会经过通道 handler 处理,这里是将 String => ByteBuf 发出 - 数据经过网络传输,到达服务器端,服务器端 5 和 6 处的 handler 先后被触发,走完一个流程 ### 2.4 流程梳理 #### 💡 提示 > 一开始需要树立正确的观念 > > * 把 channel 理解为数据的通道 > * 把 msg 理解为流动的数据,最开始输入是 ByteBuf,但经过 pipeline 的加工,会变成其它类型对象,最后输出又变成 ByteBuf > * 把 handler 理解为数据的处理工序 > * 工序有多道,合在一起就是 pipeline,pipeline 负责发布事件(读、读取完成...)传播给每个 handler, handler 对自己感兴趣的事件进行处理(重写了相应事件处理方法) > * handler 分 Inbound 和 Outbound 两类 > * 把 eventLoop 理解为处理数据的工人 > * 工人可以管理多个 channel 的 io 操作,并且一旦工人负责了某个 channel,就要负责到底(绑定) > * 工人既可以执行 io 操作,也可以进行任务处理,每位工人有任务队列,队列里可以堆放多个 channel 的待处理任务,任务分为普通任务、定时任务 > * 工人按照 pipeline 顺序,依次按照 handler 的规划(代码)处理数据,可以为每道工序指定不同的工人 ## 3. 组件 ### 3.1 EventLoop 事件循环对象 EventLoop 本质是一个单线程执行器(同时维护了一个 Selector),里面有 run 方法处理 Channel 上源源不断的 io 事件。 它的继承关系比较复杂 * 一条线是继承自 j.u.c.ScheduledExecutorService 因此包含了线程池中所有的方法 * 另一条线是继承自 netty 自己的 OrderedEventExecutor, * 提供了 boolean inEventLoop(Thread thread) 方法判断一个线程是否属于此 EventLoop * 提供了 parent 方法来看看自己属于哪个 EventLoopGroup 事件循环组 EventLoopGroup 是一组 EventLoop,Channel 一般会调用 EventLoopGroup 的 register 方法来绑定其中一个 EventLoop,后续这个 Channel 上的 io 事件都由此 EventLoop 来处理(保证了 io 事件处理时的线程安全) * 继承自 netty 自己的 EventExecutorGroup * 实现了 Iterable 接口提供遍历 EventLoop 的能力 * 另有 next 方法获取集合中下一个 EventLoop 以一个简单的实现为例: ```java // 内部创建了两个 EventLoop, 每个 EventLoop 维护一个线程 DefaultEventLoopGroup group = new DefaultEventLoopGroup(2); System.out.println(group.next()); System.out.println(group.next()); System.out.println(group.next()); ``` 输出 ``` io.netty.channel.DefaultEventLoop@60f82f98 io.netty.channel.DefaultEventLoop@35f983a6 io.netty.channel.DefaultEventLoop@60f82f98 ``` 也可以使用 for 循环 ```java DefaultEventLoopGroup group = new DefaultEventLoopGroup(2); for (EventExecutor eventLoop : group) { System.out.println(eventLoop); } ``` 输出 ``` io.netty.channel.DefaultEventLoop@60f82f98 io.netty.channel.DefaultEventLoop@35f983a6 ``` #### 💡 优雅关闭 优雅关闭 `shutdownGracefully` 方法。该方法会首先切换 `EventLoopGroup` 到关闭状态从而拒绝新的任务的加入,然后在任务队列的任务都处理完成后,停止线程的运行。从而确保整体应用是在正常有序的状态下退出的 #### 演示 NioEventLoop 处理 io 事件 服务器端两个 nio worker 工人 ```java new ServerBootstrap() .group(new NioEventLoopGroup(1), new NioEventLoopGroup(2)) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer() { @Override protected void initChannel(NioSocketChannel ch) { ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { ByteBuf byteBuf = msg instanceof ByteBuf ? ((ByteBuf) msg) : null; if (byteBuf != null) { byte[] buf = new byte[16]; ByteBuf len = byteBuf.readBytes(buf, 0, byteBuf.readableBytes()); log.debug(new String(buf)); } } }); } }).bind(8080).sync(); ``` 客户端,启动三次,分别修改发送字符串为 zhangsan(第一次),lisi(第二次),wangwu(第三次) ```java public static void main(String[] args) throws InterruptedException { Channel channel = new Bootstrap() .group(new NioEventLoopGroup(1)) .handler(new ChannelInitializer() { @Override protected void initChannel(NioSocketChannel ch) throws Exception { System.out.println("init..."); ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG)); } }) .channel(NioSocketChannel.class).connect("localhost", 8080) .sync() .channel(); channel.writeAndFlush(ByteBufAllocator.DEFAULT.buffer().writeBytes("wangwu".getBytes())); Thread.sleep(2000); channel.writeAndFlush(ByteBufAllocator.DEFAULT.buffer().writeBytes("wangwu".getBytes())); ``` 最后输出 ``` 22:03:34 [DEBUG] [nioEventLoopGroup-3-1] c.i.o.EventLoopTest - zhangsan 22:03:36 [DEBUG] [nioEventLoopGroup-3-1] c.i.o.EventLoopTest - zhangsan 22:05:36 [DEBUG] [nioEventLoopGroup-3-2] c.i.o.EventLoopTest - lisi 22:05:38 [DEBUG] [nioEventLoopGroup-3-2] c.i.o.EventLoopTest - lisi 22:06:09 [DEBUG] [nioEventLoopGroup-3-1] c.i.o.EventLoopTest - wangwu 22:06:11 [DEBUG] [nioEventLoopGroup-3-1] c.i.o.EventLoopTest - wangwu ``` 可以看到两个工人轮流处理 channel,但工人与 channel 之间进行了绑定 再增加两个非 nio 工人 ```java DefaultEventLoopGroup normalWorkers = new DefaultEventLoopGroup(2); new ServerBootstrap() .group(new NioEventLoopGroup(1), new NioEventLoopGroup(2)) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer() { @Override protected void initChannel(NioSocketChannel ch) { ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG)); ch.pipeline().addLast(normalWorkers,"myhandler", new ChannelInboundHandlerAdapter() { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { ByteBuf byteBuf = msg instanceof ByteBuf ? ((ByteBuf) msg) : null; if (byteBuf != null) { byte[] buf = new byte[16]; ByteBuf len = byteBuf.readBytes(buf, 0, byteBuf.readableBytes()); log.debug(new String(buf)); } } }); } }).bind(8080).sync(); ``` 客户端代码不变,启动三次,分别修改发送字符串为 zhangsan(第一次),lisi(第二次),wangwu(第三次) 输出 ``` 22:19:48 [DEBUG] [nioEventLoopGroup-4-1] i.n.h.l.LoggingHandler - [id: 0x251562d5, L:/127.0.0.1:8080 - R:/127.0.0.1:52588] REGISTERED 22:19:48 [DEBUG] [nioEventLoopGroup-4-1] i.n.h.l.LoggingHandler - [id: 0x251562d5, L:/127.0.0.1:8080 - R:/127.0.0.1:52588] ACTIVE 22:19:48 [DEBUG] [nioEventLoopGroup-4-1] i.n.h.l.LoggingHandler - [id: 0x251562d5, L:/127.0.0.1:8080 - R:/127.0.0.1:52588] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 7a 68 61 6e 67 73 61 6e |zhangsan | +--------+-------------------------------------------------+----------------+ 22:19:48 [DEBUG] [nioEventLoopGroup-4-1] i.n.h.l.LoggingHandler - [id: 0x251562d5, L:/127.0.0.1:8080 - R:/127.0.0.1:52588] READ COMPLETE 22:19:48 [DEBUG] [defaultEventLoopGroup-2-1] c.i.o.EventLoopTest - zhangsan 22:19:50 [DEBUG] [nioEventLoopGroup-4-1] i.n.h.l.LoggingHandler - [id: 0x251562d5, L:/127.0.0.1:8080 - R:/127.0.0.1:52588] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 7a 68 61 6e 67 73 61 6e |zhangsan | +--------+-------------------------------------------------+----------------+ 22:19:50 [DEBUG] [nioEventLoopGroup-4-1] i.n.h.l.LoggingHandler - [id: 0x251562d5, L:/127.0.0.1:8080 - R:/127.0.0.1:52588] READ COMPLETE 22:19:50 [DEBUG] [defaultEventLoopGroup-2-1] c.i.o.EventLoopTest - zhangsan 22:20:24 [DEBUG] [nioEventLoopGroup-4-2] i.n.h.l.LoggingHandler - [id: 0x94b2a840, L:/127.0.0.1:8080 - R:/127.0.0.1:52612] REGISTERED 22:20:24 [DEBUG] [nioEventLoopGroup-4-2] i.n.h.l.LoggingHandler - [id: 0x94b2a840, L:/127.0.0.1:8080 - R:/127.0.0.1:52612] ACTIVE 22:20:25 [DEBUG] [nioEventLoopGroup-4-2] i.n.h.l.LoggingHandler - [id: 0x94b2a840, L:/127.0.0.1:8080 - R:/127.0.0.1:52612] READ: 4B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 6c 69 73 69 |lisi | +--------+-------------------------------------------------+----------------+ 22:20:25 [DEBUG] [nioEventLoopGroup-4-2] i.n.h.l.LoggingHandler - [id: 0x94b2a840, L:/127.0.0.1:8080 - R:/127.0.0.1:52612] READ COMPLETE 22:20:25 [DEBUG] [defaultEventLoopGroup-2-2] c.i.o.EventLoopTest - lisi 22:20:27 [DEBUG] [nioEventLoopGroup-4-2] i.n.h.l.LoggingHandler - [id: 0x94b2a840, L:/127.0.0.1:8080 - R:/127.0.0.1:52612] READ: 4B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 6c 69 73 69 |lisi | +--------+-------------------------------------------------+----------------+ 22:20:27 [DEBUG] [nioEventLoopGroup-4-2] i.n.h.l.LoggingHandler - [id: 0x94b2a840, L:/127.0.0.1:8080 - R:/127.0.0.1:52612] READ COMPLETE 22:20:27 [DEBUG] [defaultEventLoopGroup-2-2] c.i.o.EventLoopTest - lisi 22:20:38 [DEBUG] [nioEventLoopGroup-4-1] i.n.h.l.LoggingHandler - [id: 0x79a26af9, L:/127.0.0.1:8080 - R:/127.0.0.1:52625] REGISTERED 22:20:38 [DEBUG] [nioEventLoopGroup-4-1] i.n.h.l.LoggingHandler - [id: 0x79a26af9, L:/127.0.0.1:8080 - R:/127.0.0.1:52625] ACTIVE 22:20:38 [DEBUG] [nioEventLoopGroup-4-1] i.n.h.l.LoggingHandler - [id: 0x79a26af9, L:/127.0.0.1:8080 - R:/127.0.0.1:52625] READ: 6B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 77 61 6e 67 77 75 |wangwu | +--------+-------------------------------------------------+----------------+ 22:20:38 [DEBUG] [nioEventLoopGroup-4-1] i.n.h.l.LoggingHandler - [id: 0x79a26af9, L:/127.0.0.1:8080 - R:/127.0.0.1:52625] READ COMPLETE 22:20:38 [DEBUG] [defaultEventLoopGroup-2-1] c.i.o.EventLoopTest - wangwu 22:20:40 [DEBUG] [nioEventLoopGroup-4-1] i.n.h.l.LoggingHandler - [id: 0x79a26af9, L:/127.0.0.1:8080 - R:/127.0.0.1:52625] READ: 6B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 77 61 6e 67 77 75 |wangwu | +--------+-------------------------------------------------+----------------+ 22:20:40 [DEBUG] [nioEventLoopGroup-4-1] i.n.h.l.LoggingHandler - [id: 0x79a26af9, L:/127.0.0.1:8080 - R:/127.0.0.1:52625] READ COMPLETE 22:20:40 [DEBUG] [defaultEventLoopGroup-2-1] c.i.o.EventLoopTest - wangwu ``` 可以看到,nio 工人和 非 nio 工人也分别绑定了 channel(LoggingHandler 由 nio 工人执行,而我们自己的 handler 由非 nio 工人执行) #### 💡 handler 执行中如何换人? 关键代码 `io.netty.channel.AbstractChannelHandlerContext#invokeChannelRead()` ```java static void invokeChannelRead(final AbstractChannelHandlerContext next, Object msg) { final Object m = next.pipeline.touch(ObjectUtil.checkNotNull(msg, "msg"), next); // 下一个 handler 的事件循环是否与当前的事件循环是同一个线程 EventExecutor executor = next.executor(); // 是,直接调用 if (executor.inEventLoop()) { next.invokeChannelRead(m); } // 不是,将要执行的代码作为任务提交给下一个事件循环处理(换人) else { executor.execute(new Runnable() { @Override public void run() { next.invokeChannelRead(m); } }); } } ``` * 如果两个 handler 绑定的是同一个线程,那么就直接调用 * 否则,把要调用的代码封装为一个任务对象,由下一个 handler 的线程来调用 #### 演示 NioEventLoop 处理普通任务 NioEventLoop 除了可以处理 io 事件,同样可以向它提交普通任务 ```java NioEventLoopGroup nioWorkers = new NioEventLoopGroup(2); log.debug("server start..."); Thread.sleep(2000); nioWorkers.execute(()->{ log.debug("normal task..."); }); ``` 输出 ``` 22:30:36 [DEBUG] [main] c.i.o.EventLoopTest2 - server start... 22:30:38 [DEBUG] [nioEventLoopGroup-2-1] c.i.o.EventLoopTest2 - normal task... ``` > 可以用来执行耗时较长的任务 #### 演示 NioEventLoop 处理定时任务 ```java NioEventLoopGroup nioWorkers = new NioEventLoopGroup(2); log.debug("server start..."); Thread.sleep(2000); nioWorkers.scheduleAtFixedRate(() -> { log.debug("running..."); }, 0, 1, TimeUnit.SECONDS); ``` 输出 ``` 22:35:15 [DEBUG] [main] c.i.o.EventLoopTest2 - server start... 22:35:17 [DEBUG] [nioEventLoopGroup-2-1] c.i.o.EventLoopTest2 - running... 22:35:18 [DEBUG] [nioEventLoopGroup-2-1] c.i.o.EventLoopTest2 - running... 22:35:19 [DEBUG] [nioEventLoopGroup-2-1] c.i.o.EventLoopTest2 - running... 22:35:20 [DEBUG] [nioEventLoopGroup-2-1] c.i.o.EventLoopTest2 - running... ... ``` > 可以用来执行定时任务 ### 3.2 Channel channel 的主要作用 * close() 可以用来关闭 channel * closeFuture() 用来处理 channel 的关闭 * sync 方法作用是同步等待 channel 关闭 * 而 addListener 方法是异步等待 channel 关闭 * pipeline() 方法添加处理器 * write() 方法将数据写入 * writeAndFlush() 方法将数据写入并刷出 #### ChannelFuture 这时刚才的客户端代码 ```java new Bootstrap() .group(new NioEventLoopGroup()) .channel(NioSocketChannel.class) .handler(new ChannelInitializer() { @Override protected void initChannel(Channel ch) { ch.pipeline().addLast(new StringEncoder()); } }) .connect("127.0.0.1", 8080) .sync() .channel() .writeAndFlush(new Date() + ": hello world!"); ``` 现在把它拆开来看 ```java ChannelFuture channelFuture = new Bootstrap() .group(new NioEventLoopGroup()) .channel(NioSocketChannel.class) .handler(new ChannelInitializer() { @Override protected void initChannel(Channel ch) { ch.pipeline().addLast(new StringEncoder()); } }) .connect("127.0.0.1", 8080); // 1 channelFuture.sync().channel().writeAndFlush(new Date() + ": hello world!"); ``` * 1 处返回的是 ChannelFuture 对象,它的作用是利用 channel() 方法来获取 Channel 对象 **注意** connect 方法是异步的,意味着不等连接建立,方法执行就返回了。因此 channelFuture 对象中不能【立刻】获得到正确的 Channel 对象 实验如下: ```java ChannelFuture channelFuture = new Bootstrap() .group(new NioEventLoopGroup()) .channel(NioSocketChannel.class) .handler(new ChannelInitializer() { @Override protected void initChannel(Channel ch) { ch.pipeline().addLast(new StringEncoder()); } }) .connect("127.0.0.1", 8080); System.out.println(channelFuture.channel()); // 1 channelFuture.sync(); // 2 System.out.println(channelFuture.channel()); // 3 ``` * 执行到 1 时,连接未建立,打印 `[id: 0x2e1884dd]` * 执行到 2 时,sync 方法是同步等待连接建立完成 * 执行到 3 时,连接肯定建立了,打印 `[id: 0x2e1884dd, L:/127.0.0.1:57191 - R:/127.0.0.1:8080]` 除了用 sync 方法可以让异步操作同步以外,还可以使用回调的方式: ```java ChannelFuture channelFuture = new Bootstrap() .group(new NioEventLoopGroup()) .channel(NioSocketChannel.class) .handler(new ChannelInitializer() { @Override protected void initChannel(Channel ch) { ch.pipeline().addLast(new StringEncoder()); } }) .connect("127.0.0.1", 8080); System.out.println(channelFuture.channel()); // 1 channelFuture.addListener((ChannelFutureListener) future -> { System.out.println(future.channel()); // 2 }); ``` * 执行到 1 时,连接未建立,打印 `[id: 0x749124ba]` * ChannelFutureListener 会在连接建立时被调用(其中 operationComplete 方法),因此执行到 2 时,连接肯定建立了,打印 `[id: 0x749124ba, L:/127.0.0.1:57351 - R:/127.0.0.1:8080]` #### CloseFuture ```java @Slf4j public class CloseFutureClient { public static void main(String[] args) throws InterruptedException { NioEventLoopGroup group new NioEventLoopGroup(); ChannelFuture channelFuture = new Bootstrap() .group(group) .channel(NioSocketChannel.class) .handler(new ChannelInitializer() { @Override // 在连接建立后被调用 protected void initChannel(NioSocketChannel ch) throws Exception { ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG)); ch.pipeline().addLast(new StringEncoder()); } }) .connect(new InetSocketAddress("localhost", 8080)); Channel channel = channelFuture.sync().channel(); log.debug("{}", channel); new Thread(()->{ Scanner scanner = new Scanner(System.in); while (true) { String line = scanner.nextLine(); if ("q".equals(line)) { channel.close(); // close 异步操作 1s 之后 // log.debug("处理关闭之后的操作"); // 不能在这里善后 break; } channel.writeAndFlush(line); } }, "input").start(); // 获取 CloseFuture 对象, 1) 同步处理关闭, 2) 异步处理关闭 ChannelFuture closeFuture = channel.closeFuture(); /*log.debug("waiting close..."); closeFuture.sync(); log.debug("处理关闭之后的操作");*/ closeFuture.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { log.debug("处理关闭之后的操作"); group.shutdownGracefully(); } }); } } ``` #### 💡 异步提升的是什么 * 有些同学看到这里会有疑问:为什么不在一个线程中去执行建立连接、去执行关闭 channel,那样不是也可以吗?非要用这么复杂的异步方式:比如一个线程发起建立连接,另一个线程去真正建立连接 * 还有同学会笼统地回答,因为 netty 异步方式用了多线程、多线程就效率高。其实这些认识都比较片面,多线程和异步所提升的效率并不是所认为的 思考下面的场景,4 个医生给人看病,每个病人花费 20 分钟,而且医生看病的过程中是以病人为单位的,一个病人看完了,才能看下一个病人。假设病人源源不断地来,可以计算一下 4 个医生一天工作 8 小时,处理的病人总数是:`4 * 8 * 3 = 96` 经研究发现,看病可以细分为四个步骤,经拆分后每个步骤需要 5 分钟,如下 因此可以做如下优化,只有一开始,医生 2、3、4 分别要等待 5、10、15 分钟才能执行工作,但只要后续病人源源不断地来,他们就能够满负荷工作,并且处理病人的能力提高到了 `4 * 8 * 12` 效率几乎是原来的四倍 要点 * 单线程没法异步提高效率,必须配合多线程、多核 cpu 才能发挥异步的优势 * 异步并没有缩短响应时间,反而有所增加 * 合理进行任务拆分,也是利用异步的关键 ### 3.3 Future & Promise 在异步处理时,经常用到这两个接口 首先要说明 netty 中的 Future 与 jdk 中的 Future 同名,但是是两个接口,netty 的 Future 继承自 jdk 的 Future,而 Promise 又对 netty Future 进行了扩展 * jdk Future 只能同步等待任务结束(或成功、或失败)才能得到结果 * netty Future 可以同步等待任务结束得到结果,也可以异步方式得到结果,但都是要等任务结束 * netty Promise 不仅有 netty Future 的功能,而且脱离了任务独立存在,只作为两个线程间传递结果的容器 | 功能/名称 | jdk Future | netty Future | Promise | | ------------ | ------------------------------ | ------------------------------------------------------------ | ------------ | | cancel | 取消任务 | - | - | | isCanceled | 任务是否取消 | - | - | | isDone | 任务是否完成,不能区分成功失败 | - | - | | get | 获取任务结果,阻塞等待 | - | - | | getNow | - | 获取任务结果,非阻塞,还未产生结果时返回 null | - | | await | - | 等待任务结束,如果任务失败,不会抛异常,而是通过 isSuccess 判断 | - | | sync | - | 等待任务结束,如果任务失败,抛出异常 | - | | isSuccess | - | 判断任务是否成功 | - | | cause | - | 获取失败信息,非阻塞,如果没有失败,返回null | - | | addLinstener | - | 添加回调,异步接收结果 | - | | setSuccess | - | - | 设置成功结果 | | setFailure | - | - | 设置失败结果 | #### 例1 同步处理任务成功 ```java DefaultEventLoop eventExecutors = new DefaultEventLoop(); DefaultPromise promise = new DefaultPromise<>(eventExecutors); eventExecutors.execute(()->{ try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } log.debug("set success, {}",10); promise.setSuccess(10); }); log.debug("start..."); log.debug("{}",promise.getNow()); // 还没有结果 log.debug("{}",promise.get()); ``` 输出 ``` 11:51:53 [DEBUG] [main] c.i.o.DefaultPromiseTest2 - start... 11:51:53 [DEBUG] [main] c.i.o.DefaultPromiseTest2 - null 11:51:54 [DEBUG] [defaultEventLoop-1-1] c.i.o.DefaultPromiseTest2 - set success, 10 11:51:54 [DEBUG] [main] c.i.o.DefaultPromiseTest2 - 10 ``` #### 例2 异步处理任务成功 ```java DefaultEventLoop eventExecutors = new DefaultEventLoop(); DefaultPromise promise = new DefaultPromise<>(eventExecutors); // 设置回调,异步接收结果 promise.addListener(future -> { // 这里的 future 就是上面的 promise log.debug("{}",future.getNow()); }); // 等待 1000 后设置成功结果 eventExecutors.execute(()->{ try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } log.debug("set success, {}",10); promise.setSuccess(10); }); log.debug("start..."); ``` 输出 ``` 11:49:30 [DEBUG] [main] c.i.o.DefaultPromiseTest2 - start... 11:49:31 [DEBUG] [defaultEventLoop-1-1] c.i.o.DefaultPromiseTest2 - set success, 10 11:49:31 [DEBUG] [defaultEventLoop-1-1] c.i.o.DefaultPromiseTest2 - 10 ``` #### 例3 同步处理任务失败 - sync & get ```java DefaultEventLoop eventExecutors = new DefaultEventLoop(); DefaultPromise promise = new DefaultPromise<>(eventExecutors); eventExecutors.execute(() -> { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } RuntimeException e = new RuntimeException("error..."); log.debug("set failure, {}", e.toString()); promise.setFailure(e); }); log.debug("start..."); log.debug("{}", promise.getNow()); promise.get(); // sync() 也会出现异常,只是 get 会再用 ExecutionException 包一层异常 ``` 输出 ``` 12:11:07 [DEBUG] [main] c.i.o.DefaultPromiseTest2 - start... 12:11:07 [DEBUG] [main] c.i.o.DefaultPromiseTest2 - null 12:11:08 [DEBUG] [defaultEventLoop-1-1] c.i.o.DefaultPromiseTest2 - set failure, java.lang.RuntimeException: error... Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.RuntimeException: error... at io.netty.util.concurrent.AbstractFuture.get(AbstractFuture.java:41) at com.itcast.oio.DefaultPromiseTest2.main(DefaultPromiseTest2.java:34) Caused by: java.lang.RuntimeException: error... at com.itcast.oio.DefaultPromiseTest2.lambda$main$0(DefaultPromiseTest2.java:27) at io.netty.channel.DefaultEventLoop.run(DefaultEventLoop.java:54) at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:918) at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) at java.lang.Thread.run(Thread.java:745) ``` #### 例4 同步处理任务失败 - await ```java DefaultEventLoop eventExecutors = new DefaultEventLoop(); DefaultPromise promise = new DefaultPromise<>(eventExecutors); eventExecutors.execute(() -> { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } RuntimeException e = new RuntimeException("error..."); log.debug("set failure, {}", e.toString()); promise.setFailure(e); }); log.debug("start..."); log.debug("{}", promise.getNow()); promise.await(); // 与 sync 和 get 区别在于,不会抛异常 log.debug("result {}", (promise.isSuccess() ? promise.getNow() : promise.cause()).toString()); ``` 输出 ``` 12:18:53 [DEBUG] [main] c.i.o.DefaultPromiseTest2 - start... 12:18:53 [DEBUG] [main] c.i.o.DefaultPromiseTest2 - null 12:18:54 [DEBUG] [defaultEventLoop-1-1] c.i.o.DefaultPromiseTest2 - set failure, java.lang.RuntimeException: error... 12:18:54 [DEBUG] [main] c.i.o.DefaultPromiseTest2 - result java.lang.RuntimeException: error... ``` #### 例5 异步处理任务失败 ```java DefaultEventLoop eventExecutors = new DefaultEventLoop(); DefaultPromise promise = new DefaultPromise<>(eventExecutors); promise.addListener(future -> { log.debug("result {}", (promise.isSuccess() ? promise.getNow() : promise.cause()).toString()); }); eventExecutors.execute(() -> { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } RuntimeException e = new RuntimeException("error..."); log.debug("set failure, {}", e.toString()); promise.setFailure(e); }); log.debug("start..."); ``` 输出 ``` 12:04:57 [DEBUG] [main] c.i.o.DefaultPromiseTest2 - start... 12:04:58 [DEBUG] [defaultEventLoop-1-1] c.i.o.DefaultPromiseTest2 - set failure, java.lang.RuntimeException: error... 12:04:58 [DEBUG] [defaultEventLoop-1-1] c.i.o.DefaultPromiseTest2 - result java.lang.RuntimeException: error... ``` #### 例6 await 死锁检查 ```java DefaultEventLoop eventExecutors = new DefaultEventLoop(); DefaultPromise promise = new DefaultPromise<>(eventExecutors); eventExecutors.submit(()->{ System.out.println("1"); try { promise.await(); // 注意不能仅捕获 InterruptedException 异常 // 否则 死锁检查抛出的 BlockingOperationException 会继续向上传播 // 而提交的任务会被包装为 PromiseTask,它的 run 方法中会 catch 所有异常然后设置为 Promise 的失败结果而不会抛出 } catch (Exception e) { e.printStackTrace(); } System.out.println("2"); }); eventExecutors.submit(()->{ System.out.println("3"); try { promise.await(); } catch (Exception e) { e.printStackTrace(); } System.out.println("4"); }); ``` 输出 ``` 1 2 3 4 io.netty.util.concurrent.BlockingOperationException: DefaultPromise@47499c2a(incomplete) at io.netty.util.concurrent.DefaultPromise.checkDeadLock(DefaultPromise.java:384) at io.netty.util.concurrent.DefaultPromise.await(DefaultPromise.java:212) at com.itcast.oio.DefaultPromiseTest.lambda$main$0(DefaultPromiseTest.java:27) at io.netty.util.concurrent.PromiseTask$RunnableAdapter.call(PromiseTask.java:38) at io.netty.util.concurrent.PromiseTask.run(PromiseTask.java:73) at io.netty.channel.DefaultEventLoop.run(DefaultEventLoop.java:54) at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:918) at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) at java.lang.Thread.run(Thread.java:745) io.netty.util.concurrent.BlockingOperationException: DefaultPromise@47499c2a(incomplete) at io.netty.util.concurrent.DefaultPromise.checkDeadLock(DefaultPromise.java:384) at io.netty.util.concurrent.DefaultPromise.await(DefaultPromise.java:212) at com.itcast.oio.DefaultPromiseTest.lambda$main$1(DefaultPromiseTest.java:36) at io.netty.util.concurrent.PromiseTask$RunnableAdapter.call(PromiseTask.java:38) at io.netty.util.concurrent.PromiseTask.run(PromiseTask.java:73) at io.netty.channel.DefaultEventLoop.run(DefaultEventLoop.java:54) at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:918) at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) at java.lang.Thread.run(Thread.java:745) ``` ### 3.4 Handler & Pipeline ChannelHandler 用来处理 Channel 上的各种事件,分为入站、出站两种。所有 ChannelHandler 被连成一串,就是 Pipeline * 入站处理器通常是 ChannelInboundHandlerAdapter 的子类,主要用来读取客户端数据,写回结果 * 出站处理器通常是 ChannelOutboundHandlerAdapter 的子类,主要对写回结果进行加工 打个比喻,每个 Channel 是一个产品的加工车间,Pipeline 是车间中的流水线,ChannelHandler 就是流水线上的各道工序,而后面要讲的 ByteBuf 是原材料,经过很多工序的加工:先经过一道道入站工序,再经过一道道出站工序最终变成产品 先搞清楚顺序,服务端 ```java new ServerBootstrap() .group(new NioEventLoopGroup()) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer() { protected void initChannel(NioSocketChannel ch) { ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){ @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { System.out.println(1); ctx.fireChannelRead(msg); // 1 } }); ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){ @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { System.out.println(2); ctx.fireChannelRead(msg); // 2 } }); ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){ @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { System.out.println(3); ctx.channel().write(msg); // 3 } }); ch.pipeline().addLast(new ChannelOutboundHandlerAdapter(){ @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { System.out.println(4); ctx.write(msg, promise); // 4 } }); ch.pipeline().addLast(new ChannelOutboundHandlerAdapter(){ @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { System.out.println(5); ctx.write(msg, promise); // 5 } }); ch.pipeline().addLast(new ChannelOutboundHandlerAdapter(){ @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { System.out.println(6); ctx.write(msg, promise); // 6 } }); } }) .bind(8080); ``` 客户端 ```java new Bootstrap() .group(new NioEventLoopGroup()) .channel(NioSocketChannel.class) .handler(new ChannelInitializer() { @Override protected void initChannel(Channel ch) { ch.pipeline().addLast(new StringEncoder()); } }) .connect("127.0.0.1", 8080) .addListener((ChannelFutureListener) future -> { future.channel().writeAndFlush("hello,world"); }); ``` 服务器端打印: ``` 1 2 3 6 5 4 ``` 可以看到,ChannelInboundHandlerAdapter 是按照 addLast 的顺序执行的,而 ChannelOutboundHandlerAdapter 是按照 addLast 的逆序执行的。ChannelPipeline 的实现是一个 ChannelHandlerContext(包装了 ChannelHandler) 组成的双向链表 * 入站处理器中,ctx.fireChannelRead(msg) 是 **调用下一个入站处理器** * 如果注释掉 1 处代码,则仅会打印 1 * 如果注释掉 2 处代码,则仅会打印 1 2 * 3 处的 ctx.channel().write(msg) 会 **从尾部开始触发** 后续出站处理器的执行 * 如果注释掉 3 处代码,则仅会打印 1 2 3 * 类似的,出站处理器中,ctx.write(msg, promise) 的调用也会 **触发上一个出站处理器** * 如果注释掉 6 处代码,则仅会打印 1 2 3 6 * ctx.channel().write(msg) vs ctx.write(msg) * 都是触发出站处理器的执行 * ctx.channel().write(msg) 从尾部开始查找出站处理器 * ctx.write(msg) 是从当前节点找上一个出站处理器 * 3 处的 ctx.channel().write(msg) 如果改为 ctx.write(msg) 仅会打印 1 2 3,因为节点3 之前没有其它出站处理器了 * 6 处的 ctx.write(msg, promise) 如果改为 ctx.channel().write(msg) 会打印 1 2 3 6 6 6... 因为 ctx.channel().write() 是从尾部开始查找,结果又是节点6 自己 图1 - 服务端 pipeline 触发的原始流程,图中数字代表了处理步骤的先后次序 ### 3.5 ByteBuf 是对字节数据的封装 #### 1)创建 ```java ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(10); log(buffer); ``` 上面代码创建了一个默认的 ByteBuf(池化基于直接内存的 ByteBuf),初始容量是 10 输出 ``` read index:0 write index:0 capacity:10 ``` 其中 log 方法参考如下 ```java private static void log(ByteBuf buffer) { int length = buffer.readableBytes(); int rows = length / 16 + (length % 15 == 0 ? 0 : 1) + 4; StringBuilder buf = new StringBuilder(rows * 80 * 2) .append("read index:").append(buffer.readerIndex()) .append(" write index:").append(buffer.writerIndex()) .append(" capacity:").append(buffer.capacity()) .append(NEWLINE); appendPrettyHexDump(buf, buffer); System.out.println(buf.toString()); } ``` #### 2)直接内存 vs 堆内存 可以使用下面的代码来创建池化基于堆的 ByteBuf ```java ByteBuf buffer = ByteBufAllocator.DEFAULT.heapBuffer(10); ``` 也可以使用下面的代码来创建池化基于直接内存的 ByteBuf ```java ByteBuf buffer = ByteBufAllocator.DEFAULT.directBuffer(10); ``` * 直接内存创建和销毁的代价昂贵,但读写性能高(少一次内存复制),适合配合池化功能一起用 * 直接内存对 GC 压力小,因为这部分内存不受 JVM 垃圾回收的管理,但也要注意及时主动释放 #### 3)池化 vs 非池化 池化的最大意义在于可以重用 ByteBuf,优点有 * 没有池化,则每次都得创建新的 ByteBuf 实例,这个操作对直接内存代价昂贵,就算是堆内存,也会增加 GC 压力 * 有了池化,则可以重用池中 ByteBuf 实例,并且采用了与 jemalloc 类似的内存分配算法提升分配效率 * 高并发时,池化功能更节约内存,减少内存溢出的可能 池化功能是否开启,可以通过下面的系统环境变量来设置 ```java -Dio.netty.allocator.type={unpooled|pooled} ``` * 4.1 以后,非 Android 平台默认启用池化实现,Android 平台启用非池化实现 * 4.1 之前,池化功能还不成熟,默认是非池化实现 #### 4)组成 ByteBuf 由四部分组成 最开始读写指针都在 0 位置 #### 5)写入 方法列表,省略一些不重要的方法 | 方法签名 | 含义 | 备注 | | ------------------------------------------------------------ | ---------------------- | ------------------------------------------- | | writeBoolean(boolean value) | 写入 boolean 值 | 用一字节 01|00 代表 true|false | | writeByte(int value) | 写入 byte 值 | | | writeShort(int value) | 写入 short 值 | | | writeInt(int value) | 写入 int 值 | Big Endian,即 0x250,写入后 00 00 02 50 | | writeIntLE(int value) | 写入 int 值 | Little Endian,即 0x250,写入后 50 02 00 00 | | writeLong(long value) | 写入 long 值 | | | writeChar(int value) | 写入 char 值 | | | writeFloat(float value) | 写入 float 值 | | | writeDouble(double value) | 写入 double 值 | | | writeBytes(ByteBuf src) | 写入 netty 的 ByteBuf | | | writeBytes(byte\[] src) | 写入 byte\[] | | | writeBytes(ByteBuffer src) | 写入 nio 的 ByteBuffer | | | int writeCharSequence(CharSequence sequence, Charset charset) | 写入字符串 | | > 注意 > > * 这些方法的未指明返回值的,其返回值都是 ByteBuf,意味着可以链式调用 > * 网络传输,默认习惯是 Big Endian 先写入 4 个字节 ```java buffer.writeBytes(new byte[]{1, 2, 3, 4}); log(buffer); ``` 结果是 ``` read index:0 write index:4 capacity:10 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 01 02 03 04 |.... | +--------+-------------------------------------------------+----------------+ ``` 再写入一个 int 整数,也是 4 个字节 ```java buffer.writeInt(5); log(buffer); ``` 结果是 ``` read index:0 write index:8 capacity:10 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 01 02 03 04 00 00 00 05 |........ | +--------+-------------------------------------------------+----------------+ ``` 还有一类方法是 set 开头的一系列方法,也可以写入数据,但不会改变写指针位置 #### 6)扩容 再写入一个 int 整数时,容量不够了(初始容量是 10),这时会引发扩容 ```java buffer.writeInt(6); log(buffer); ``` 扩容规则是 * 如何写入后数据大小未超过 512,则选择下一个 16 的整数倍,例如写入后大小为 12 ,则扩容后 capacity 是 16 * 如果写入后数据大小超过 512,则选择下一个 2^n,例如写入后大小为 513,则扩容后 capacity 是 2^10=1024(2^9=512 已经不够了) * 扩容不能超过 max capacity 会报错 结果是 ``` read index:0 write index:12 capacity:16 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 01 02 03 04 00 00 00 05 00 00 00 06 |............ | +--------+-------------------------------------------------+----------------+ ``` #### 7)读取 例如读了 4 次,每次一个字节 ```java System.out.println(buffer.readByte()); System.out.println(buffer.readByte()); System.out.println(buffer.readByte()); System.out.println(buffer.readByte()); log(buffer); ``` 读过的内容,就属于废弃部分了,再读只能读那些尚未读取的部分 ``` 1 2 3 4 read index:4 write index:12 capacity:16 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 00 00 00 05 00 00 00 06 |........ | +--------+-------------------------------------------------+----------------+ ``` 如果需要重复读取 int 整数 5,怎么办? 可以在 read 前先做个标记 mark ```java buffer.markReaderIndex(); System.out.println(buffer.readInt()); log(buffer); ``` 结果 ``` 5 read index:8 write index:12 capacity:16 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 00 00 00 06 |.... | +--------+-------------------------------------------------+----------------+ ``` 这时要重复读取的话,重置到标记位置 reset ```java buffer.resetReaderIndex(); log(buffer); ``` 这时 ``` read index:4 write index:12 capacity:16 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 00 00 00 05 00 00 00 06 |........ | +--------+-------------------------------------------------+----------------+ ``` 还有种办法是采用 get 开头的一系列方法,这些方法不会改变 read index #### 8)retain & release 由于 Netty 中有堆外内存的 ByteBuf 实现,堆外内存最好是手动来释放,而不是等 GC 垃圾回收。 * UnpooledHeapByteBuf 使用的是 JVM 内存,只需等 GC 回收内存即可 * UnpooledDirectByteBuf 使用的就是直接内存了,需要特殊的方法来回收内存 * PooledByteBuf 和它的子类使用了池化机制,需要更复杂的规则来回收内存 > 回收内存的源码实现,请关注下面方法的不同实现 > > `protected abstract void deallocate()` Netty 这里采用了引用计数法来控制回收内存,每个 ByteBuf 都实现了 ReferenceCounted 接口 * 每个 ByteBuf 对象的初始计数为 1 * 调用 release 方法计数减 1,如果计数为 0,ByteBuf 内存被回收 * 调用 retain 方法计数加 1,表示调用者没用完之前,其它 handler 即使调用了 release 也不会造成回收 * 当计数为 0 时,底层内存会被回收,这时即使 ByteBuf 对象还在,其各个方法均无法正常使用 谁来负责 release 呢? 不是我们想象的(一般情况下) ```java ByteBuf buf = ... try { ... } finally { buf.release(); } ``` 请思考,因为 pipeline 的存在,一般需要将 ByteBuf 传递给下一个 ChannelHandler,如果在 finally 中 release 了,就失去了传递性(当然,如果在这个 ChannelHandler 内这个 ByteBuf 已完成了它的使命,那么便无须再传递) 基本规则是,**谁是最后使用者,谁负责 release**,详细分析如下 * 起点,对于 NIO 实现来讲,在 io.netty.channel.nio.AbstractNioByteChannel.NioByteUnsafe#read 方法中首次创建 ByteBuf 放入 pipeline(line 163 pipeline.fireChannelRead(byteBuf)) * 入站 ByteBuf 处理原则 * 对原始 ByteBuf 不做处理,调用 ctx.fireChannelRead(msg) 向后传递,这时无须 release * 将原始 ByteBuf 转换为其它类型的 Java 对象,这时 ByteBuf 就没用了,必须 release * 如果不调用 ctx.fireChannelRead(msg) 向后传递,那么也必须 release * 注意各种异常,如果 ByteBuf 没有成功传递到下一个 ChannelHandler,必须 release * 假设消息一直向后传,那么 TailContext 会负责释放未处理消息(原始的 ByteBuf) * 出站 ByteBuf 处理原则 * 出站消息最终都会转为 ByteBuf 输出,一直向前传,由 HeadContext flush 后 release * 异常处理原则 * 有时候不清楚 ByteBuf 被引用了多少次,但又必须彻底释放,可以循环调用 release 直到返回 true TailContext 释放未处理消息逻辑 ```java // io.netty.channel.DefaultChannelPipeline#onUnhandledInboundMessage(java.lang.Object) protected void onUnhandledInboundMessage(Object msg) { try { logger.debug( "Discarded inbound message {} that reached at the tail of the pipeline. " + "Please check your pipeline configuration.", msg); } finally { ReferenceCountUtil.release(msg); } } ``` 具体代码 ```java // io.netty.util.ReferenceCountUtil#release(java.lang.Object) public static boolean release(Object msg) { if (msg instanceof ReferenceCounted) { return ((ReferenceCounted) msg).release(); } return false; } ``` #### 9)slice 【零拷贝】的体现之一,对原始 ByteBuf 进行切片成多个 ByteBuf,切片后的 ByteBuf 并没有发生内存复制,还是使用原始 ByteBuf 的内存,切片后的 ByteBuf 维护独立的 read,write 指针 例,原始 ByteBuf 进行一些初始操作 ```java ByteBuf origin = ByteBufAllocator.DEFAULT.buffer(10); origin.writeBytes(new byte[]{1, 2, 3, 4}); origin.readByte(); System.out.println(ByteBufUtil.prettyHexDump(origin)); ``` 输出 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 02 03 04 |... | +--------+-------------------------------------------------+----------------+ ``` 这时调用 slice 进行切片,无参 slice 是从原始 ByteBuf 的 read index 到 write index 之间的内容进行切片,切片后的 max capacity 被固定为这个区间的大小,因此不能追加 write ```java ByteBuf slice = origin.slice(); System.out.println(ByteBufUtil.prettyHexDump(slice)); // slice.writeByte(5); 如果执行,会报 IndexOutOfBoundsException 异常 ``` 输出 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 02 03 04 |... | +--------+-------------------------------------------------+----------------+ ``` 如果原始 ByteBuf 再次读操作(又读了一个字节) ```java origin.readByte(); System.out.println(ByteBufUtil.prettyHexDump(origin)); ``` 输出 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 03 04 |.. | +--------+-------------------------------------------------+----------------+ ``` 这时的 slice 不受影响,因为它有独立的读写指针 ```java System.out.println(ByteBufUtil.prettyHexDump(slice)); ``` 输出 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 02 03 04 |... | +--------+-------------------------------------------------+----------------+ ``` 如果 slice 的内容发生了更改 ```java slice.setByte(2, 5); System.out.println(ByteBufUtil.prettyHexDump(slice)); ``` 输出 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 02 03 05 |... | +--------+-------------------------------------------------+----------------+ ``` 这时,原始 ByteBuf 也会受影响,因为底层都是同一块内存 ``` System.out.println(ByteBufUtil.prettyHexDump(origin)); ``` 输出 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 03 05 |.. | +--------+-------------------------------------------------+----------------+ ``` #### 10)duplicate 【零拷贝】的体现之一,就好比截取了原始 ByteBuf 所有内容,并且没有 max capacity 的限制,也是与原始 ByteBuf 使用同一块底层内存,只是读写指针是独立的 #### 11)copy 会将底层内存数据进行深拷贝,因此无论读写,都与原始 ByteBuf 无关 #### 12)CompositeByteBuf 【零拷贝】的体现之一,可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免拷贝 有两个 ByteBuf 如下 ```java ByteBuf buf1 = ByteBufAllocator.DEFAULT.buffer(5); buf1.writeBytes(new byte[]{1, 2, 3, 4, 5}); ByteBuf buf2 = ByteBufAllocator.DEFAULT.buffer(5); buf2.writeBytes(new byte[]{6, 7, 8, 9, 10}); System.out.println(ByteBufUtil.prettyHexDump(buf1)); System.out.println(ByteBufUtil.prettyHexDump(buf2)); ``` 输出 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 01 02 03 04 05 |..... | +--------+-------------------------------------------------+----------------+ +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 06 07 08 09 0a |..... | +--------+-------------------------------------------------+----------------+ ``` 现在需要一个新的 ByteBuf,内容来自于刚才的 buf1 和 buf2,如何实现? 方法1: ```java ByteBuf buf3 = ByteBufAllocator.DEFAULT .buffer(buf1.readableBytes()+buf2.readableBytes()); buf3.writeBytes(buf1); buf3.writeBytes(buf2); System.out.println(ByteBufUtil.prettyHexDump(buf3)); ``` 结果 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 01 02 03 04 05 06 07 08 09 0a |.......... | +--------+-------------------------------------------------+----------------+ ``` 这种方法好不好?回答是不太好,因为进行了数据的内存复制操作 方法2: ```java CompositeByteBuf buf3 = ByteBufAllocator.DEFAULT.compositeBuffer(); // true 表示增加新的 ByteBuf 自动递增 write index, 否则 write index 会始终为 0 buf3.addComponents(true, buf1, buf2); ``` 结果是一样的 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 01 02 03 04 05 06 07 08 09 0a |.......... | +--------+-------------------------------------------------+----------------+ ``` CompositeByteBuf 是一个组合的 ByteBuf,它内部维护了一个 Component 数组,每个 Component 管理一个 ByteBuf,记录了这个 ByteBuf 相对于整体偏移量等信息,代表着整体中某一段的数据。 * 优点,对外是一个虚拟视图,组合这些 ByteBuf 不会产生内存复制 * 缺点,复杂了很多,多次操作会带来性能的损耗 #### 13)Unpooled Unpooled 是一个工具类,类如其名,提供了非池化的 ByteBuf 创建、组合、复制等操作 这里仅介绍其跟【零拷贝】相关的 wrappedBuffer 方法,可以用来包装 ByteBuf ```java ByteBuf buf1 = ByteBufAllocator.DEFAULT.buffer(5); buf1.writeBytes(new byte[]{1, 2, 3, 4, 5}); ByteBuf buf2 = ByteBufAllocator.DEFAULT.buffer(5); buf2.writeBytes(new byte[]{6, 7, 8, 9, 10}); // 当包装 ByteBuf 个数超过一个时, 底层使用了 CompositeByteBuf ByteBuf buf3 = Unpooled.wrappedBuffer(buf1, buf2); System.out.println(ByteBufUtil.prettyHexDump(buf3)); ``` 输出 ``` +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 01 02 03 04 05 06 07 08 09 0a |.......... | +--------+-------------------------------------------------+----------------+ ``` 也可以用来包装普通字节数组,底层也不会有拷贝操作 ```java ByteBuf buf4 = Unpooled.wrappedBuffer(new byte[]{1, 2, 3}, new byte[]{4, 5, 6}); System.out.println(buf4.getClass()); System.out.println(ByteBufUtil.prettyHexDump(buf4)); ``` 输出 ``` class io.netty.buffer.CompositeByteBuf +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 01 02 03 04 05 06 |...... | +--------+-------------------------------------------------+----------------+ ``` #### 💡 ByteBuf 优势 * 池化 - 可以重用池中 ByteBuf 实例,更节约内存,减少内存溢出的可能 * 读写指针分离,不需要像 ByteBuffer 一样切换读写模式 * 可以自动扩容 * 支持链式调用,使用更流畅 * 很多地方体现零拷贝,例如 slice、duplicate、CompositeByteBuf ## 4. 双向通信 ### 4.1 练习 实现一个 echo server 编写 server ```java new ServerBootstrap() .group(new NioEventLoopGroup()) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer() { @Override protected void initChannel(NioSocketChannel ch) { ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){ @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { ByteBuf buffer = (ByteBuf) msg; System.out.println(buffer.toString(Charset.defaultCharset())); // 建议使用 ctx.alloc() 创建 ByteBuf ByteBuf response = ctx.alloc().buffer(); response.writeBytes(buffer); ctx.writeAndFlush(response); // 思考:需要释放 buffer 吗 // 思考:需要释放 response 吗 } }); } }).bind(8080); ``` 编写 client ```java NioEventLoopGroup group = new NioEventLoopGroup(); Channel channel = new Bootstrap() .group(group) .channel(NioSocketChannel.class) .handler(new ChannelInitializer() { @Override protected void initChannel(NioSocketChannel ch) throws Exception { ch.pipeline().addLast(new StringEncoder()); ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { ByteBuf buffer = (ByteBuf) msg; System.out.println(buffer.toString(Charset.defaultCharset())); // 思考:需要释放 buffer 吗 } }); } }).connect("127.0.0.1", 8080).sync().channel(); channel.closeFuture().addListener(future -> { group.shutdownGracefully(); }); new Thread(() -> { Scanner scanner = new Scanner(System.in); while (true) { String line = scanner.nextLine(); if ("q".equals(line)) { channel.close(); break; } channel.writeAndFlush(line); } }).start(); ``` ### 💡 读和写的误解 我最初在认识上有这样的误区,认为只有在 netty,nio 这样的多路复用 IO 模型时,读写才不会相互阻塞,才可以实现高效的双向通信,但实际上,Java Socket 是全双工的:在任意时刻,线路上存在`A 到 B` 和 `B 到 A` 的双向信号传输。即使是阻塞 IO,读和写是可以同时进行的,只要分别采用读线程和写线程即可,读不会阻塞写、写也不会阻塞读 例如 ```java public class TestServer { public static void main(String[] args) throws IOException { ServerSocket ss = new ServerSocket(8888); Socket s = ss.accept(); new Thread(() -> { try { BufferedReader reader = new BufferedReader(new InputStreamReader(s.getInputStream())); while (true) { System.out.println(reader.readLine()); } } catch (IOException e) { e.printStackTrace(); } }).start(); new Thread(() -> { try { BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(s.getOutputStream())); // 例如在这个位置加入 thread 级别断点,可以发现即使不写入数据,也不妨碍前面线程读取客户端数据 for (int i = 0; i < 100; i++) { writer.write(String.valueOf(i)); writer.newLine(); writer.flush(); } } catch (IOException e) { e.printStackTrace(); } }).start(); } } ``` 客户端 ```java public class TestClient { public static void main(String[] args) throws IOException { Socket s = new Socket("localhost", 8888); new Thread(() -> { try { BufferedReader reader = new BufferedReader(new InputStreamReader(s.getInputStream())); while (true) { System.out.println(reader.readLine()); } } catch (IOException e) { e.printStackTrace(); } }).start(); new Thread(() -> { try { BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(s.getOutputStream())); for (int i = 0; i < 100; i++) { writer.write(String.valueOf(i)); writer.newLine(); writer.flush(); } } catch (IOException e) { e.printStackTrace(); } }).start(); } } ``` --- --- url: /src/technology/java/netty/Netty03-进阶.md --- # 三. Netty 进阶 ## 1. 粘包与半包 ### 1.1 粘包现象 服务端代码 ```java public class HelloWorldServer { static final Logger log = LoggerFactory.getLogger(HelloWorldServer.class); void start() { NioEventLoopGroup boss = new NioEventLoopGroup(1); NioEventLoopGroup worker = new NioEventLoopGroup(); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.channel(NioServerSocketChannel.class); serverBootstrap.group(boss, worker); serverBootstrap.childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG)); ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { log.debug("connected {}", ctx.channel()); super.channelActive(ctx); } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { log.debug("disconnect {}", ctx.channel()); super.channelInactive(ctx); } }); } }); ChannelFuture channelFuture = serverBootstrap.bind(8080); log.debug("{} binding...", channelFuture.channel()); channelFuture.sync(); log.debug("{} bound...", channelFuture.channel()); channelFuture.channel().closeFuture().sync(); } catch (InterruptedException e) { log.error("server error", e); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); log.debug("stoped"); } } public static void main(String[] args) { new HelloWorldServer().start(); } } ``` 客户端代码希望发送 10 个消息,每个消息是 16 字节 ```java public class HelloWorldClient { static final Logger log = LoggerFactory.getLogger(HelloWorldClient.class); public static void main(String[] args) { NioEventLoopGroup worker = new NioEventLoopGroup(); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.channel(NioSocketChannel.class); bootstrap.group(worker); bootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { log.debug("connetted..."); ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { log.debug("sending..."); Random r = new Random(); char c = 'a'; for (int i = 0; i < 10; i++) { ByteBuf buffer = ctx.alloc().buffer(); buffer.writeBytes(new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}); ctx.writeAndFlush(buffer); } } }); } }); ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 8080).sync(); channelFuture.channel().closeFuture().sync(); } catch (InterruptedException e) { log.error("client error", e); } finally { worker.shutdownGracefully(); } } } ``` 服务器端的某次输出,可以看到一次就接收了 160 个字节,而非分 10 次接收 ``` 08:24:46 [DEBUG] [main] c.i.n.HelloWorldServer - [id: 0x81e0fda5] binding... 08:24:46 [DEBUG] [main] c.i.n.HelloWorldServer - [id: 0x81e0fda5, L:/0:0:0:0:0:0:0:0:8080] bound... 08:24:55 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x94132411, L:/127.0.0.1:8080 - R:/127.0.0.1:58177] REGISTERED 08:24:55 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x94132411, L:/127.0.0.1:8080 - R:/127.0.0.1:58177] ACTIVE 08:24:55 [DEBUG] [nioEventLoopGroup-3-1] c.i.n.HelloWorldServer - connected [id: 0x94132411, L:/127.0.0.1:8080 - R:/127.0.0.1:58177] 08:24:55 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x94132411, L:/127.0.0.1:8080 - R:/127.0.0.1:58177] READ: 160B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................| |00000010| 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................| |00000020| 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................| |00000030| 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................| |00000040| 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................| |00000050| 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................| |00000060| 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................| |00000070| 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................| |00000080| 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................| |00000090| 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................| +--------+-------------------------------------------------+----------------+ 08:24:55 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x94132411, L:/127.0.0.1:8080 - R:/127.0.0.1:58177] READ COMPLETE ``` ### 1.2 半包现象 客户端代码希望发送 1 个消息,这个消息是 160 字节,代码改为 ```java ByteBuf buffer = ctx.alloc().buffer(); for (int i = 0; i < 10; i++) { buffer.writeBytes(new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}); } ctx.writeAndFlush(buffer); ``` 为现象明显,服务端修改一下接收缓冲区,其它代码不变 ```java serverBootstrap.option(ChannelOption.SO_RCVBUF, 10); ``` 服务器端的某次输出,可以看到接收的消息被分为两节,第一次 20 字节,第二次 140 字节 ``` 08:43:49 [DEBUG] [main] c.i.n.HelloWorldServer - [id: 0x4d6c6a84] binding... 08:43:49 [DEBUG] [main] c.i.n.HelloWorldServer - [id: 0x4d6c6a84, L:/0:0:0:0:0:0:0:0:8080] bound... 08:44:23 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x1719abf7, L:/127.0.0.1:8080 - R:/127.0.0.1:59221] REGISTERED 08:44:23 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x1719abf7, L:/127.0.0.1:8080 - R:/127.0.0.1:59221] ACTIVE 08:44:23 [DEBUG] [nioEventLoopGroup-3-1] c.i.n.HelloWorldServer - connected [id: 0x1719abf7, L:/127.0.0.1:8080 - R:/127.0.0.1:59221] 08:44:24 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x1719abf7, L:/127.0.0.1:8080 - R:/127.0.0.1:59221] READ: 20B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................| |00000010| 00 01 02 03 |.... | +--------+-------------------------------------------------+----------------+ 08:44:24 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x1719abf7, L:/127.0.0.1:8080 - R:/127.0.0.1:59221] READ COMPLETE 08:44:24 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x1719abf7, L:/127.0.0.1:8080 - R:/127.0.0.1:59221] READ: 140B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 00 01 02 03 |................| |00000010| 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 00 01 02 03 |................| |00000020| 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 00 01 02 03 |................| |00000030| 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 00 01 02 03 |................| |00000040| 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 00 01 02 03 |................| |00000050| 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 00 01 02 03 |................| |00000060| 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 00 01 02 03 |................| |00000070| 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 00 01 02 03 |................| |00000080| 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |............ | +--------+-------------------------------------------------+----------------+ 08:44:24 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x1719abf7, L:/127.0.0.1:8080 - R:/127.0.0.1:59221] READ COMPLETE ``` > **注意** > > serverBootstrap.option(ChannelOption.SO\_RCVBUF, 10) 影响的底层接收缓冲区(即滑动窗口)大小,仅决定了 netty 读取的最小单位,netty 实际每次读取的一般是它的整数倍 ### 1.3 现象分析 粘包 * 现象,发送 abc def,接收 abcdef * 原因 * 应用层:接收方 ByteBuf 设置太大(Netty 默认 1024) * 滑动窗口:假设发送方 256 bytes 表示一个完整报文,但由于接收方处理不及时且窗口大小足够大,这 256 bytes 字节就会缓冲在接收方的滑动窗口中,当滑动窗口中缓冲了多个报文就会粘包 * Nagle 算法:会造成粘包 半包 * 现象,发送 abcdef,接收 abc def * 原因 * 应用层:接收方 ByteBuf 小于实际发送数据量 * 滑动窗口:假设接收方的窗口只剩了 128 bytes,发送方的报文大小是 256 bytes,这时放不下了,只能先发送前 128 bytes,等待 ack 后才能发送剩余部分,这就造成了半包 * MSS 限制:当发送的数据超过 MSS 限制后,会将数据切分发送,就会造成半包 本质是因为 TCP 是流式协议,消息无边界 > 滑动窗口 > > * TCP 以一个段(segment)为单位,每发送一个段就需要进行一次确认应答(ack)处理,但如果这么做,缺点是包的往返时间越长性能就越差 > > * 为了解决此问题,引入了窗口概念,窗口大小即决定了无需等待应答而可以继续发送的数据最大值 > > * 窗口实际就起到一个缓冲区的作用,同时也能起到流量控制的作用 > > * 图中深色的部分即要发送的数据,高亮的部分即窗口 > * 窗口内的数据才允许被发送,当应答未到达前,窗口必须停止滑动 > * 如果 1001~2000 这个段的数据 ack 回来了,窗口就可以向前滑动 > * 接收方也会维护一个窗口,只有落在窗口内的数据才能允许接收 > MSS 限制 > > * 链路层对一次能够发送的最大数据有限制,这个限制称之为 MTU(maximum transmission unit),不同的链路设备的 MTU 值也有所不同,例如 > > * 以太网的 MTU 是 1500 > > * FDDI(光纤分布式数据接口)的 MTU 是 4352 > > * 本地回环地址的 MTU 是 65535 - 本地测试不走网卡 > > * MSS 是最大段长度(maximum segment size),它是 MTU 刨去 tcp 头和 ip 头后剩余能够作为数据传输的字节数 > > * ipv4 tcp 头占用 20 bytes,ip 头占用 20 bytes,因此以太网 MSS 的值为 1500 - 40 = 1460 > > * TCP 在传递大量数据时,会按照 MSS 大小将数据进行分割发送 > > * MSS 的值在三次握手时通知对方自己 MSS 的值,然后在两者之间选择一个小值作为 MSS > Nagle 算法 > > * 即使发送一个字节,也需要加入 tcp 头和 ip 头,也就是总字节数会使用 41 bytes,非常不经济。因此为了提高网络利用率,tcp 希望尽可能发送足够大的数据,这就是 Nagle 算法产生的缘由 > * 该算法是指发送端即使还有应该发送的数据,但如果这部分数据很少的话,则进行延迟发送 > * 如果 SO\_SNDBUF 的数据达到 MSS,则需要发送 > * 如果 SO\_SNDBUF 中含有 FIN(表示需要连接关闭)这时将剩余数据发送,再关闭 > * 如果 TCP\_NODELAY = true,则需要发送 > * 已发送的数据都收到 ack 时,则需要发送 > * 上述条件不满足,但发生超时(一般为 200ms)则需要发送 > * 除上述情况,延迟发送 ### 1.4 解决方案 1. 短链接,发一个包建立一次连接,这样连接建立到连接断开之间就是消息的边界,缺点效率太低 2. 每一条消息采用固定长度,缺点浪费空间 3. 每一条消息采用分隔符,例如 \n,缺点需要转义 4. 每一条消息分为 head 和 body,head 中包含 body 的长度 #### 方法1,短链接 以解决粘包为例 ```java public class HelloWorldClient { static final Logger log = LoggerFactory.getLogger(HelloWorldClient.class); public static void main(String[] args) { // 分 10 次发送 for (int i = 0; i < 10; i++) { send(); } } private static void send() { NioEventLoopGroup worker = new NioEventLoopGroup(); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.channel(NioSocketChannel.class); bootstrap.group(worker); bootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { log.debug("conneted..."); ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG)); ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { log.debug("sending..."); ByteBuf buffer = ctx.alloc().buffer(); buffer.writeBytes(new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}); ctx.writeAndFlush(buffer); // 发完即关 ctx.close(); } }); } }); ChannelFuture channelFuture = bootstrap.connect("localhost", 8080).sync(); channelFuture.channel().closeFuture().sync(); } catch (InterruptedException e) { log.error("client error", e); } finally { worker.shutdownGracefully(); } } } ``` 输出,略 > 半包用这种办法还是不好解决,因为接收方的缓冲区大小是有限的 #### 方法2,固定长度 让所有数据包长度固定(假设长度为 8 字节),服务器端加入 ```java ch.pipeline().addLast(new FixedLengthFrameDecoder(8)); ``` 客户端测试代码,注意, 采用这种方法后,客户端什么时候 flush 都可以 ```java public class HelloWorldClient { static final Logger log = LoggerFactory.getLogger(HelloWorldClient.class); public static void main(String[] args) { NioEventLoopGroup worker = new NioEventLoopGroup(); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.channel(NioSocketChannel.class); bootstrap.group(worker); bootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { log.debug("connetted..."); ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG)); ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { log.debug("sending..."); // 发送内容随机的数据包 Random r = new Random(); char c = 'a'; ByteBuf buffer = ctx.alloc().buffer(); for (int i = 0; i < 10; i++) { byte[] bytes = new byte[8]; for (int j = 0; j < r.nextInt(8); j++) { bytes[j] = (byte) c; } c++; buffer.writeBytes(bytes); } ctx.writeAndFlush(buffer); } }); } }); ChannelFuture channelFuture = bootstrap.connect("192.168.0.103", 9090).sync(); channelFuture.channel().closeFuture().sync(); } catch (InterruptedException e) { log.error("client error", e); } finally { worker.shutdownGracefully(); } } } ``` 客户端输出 ``` 12:07:00 [DEBUG] [nioEventLoopGroup-2-1] c.i.n.HelloWorldClient - connetted... 12:07:00 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0x3c2ef3c2] REGISTERED 12:07:00 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0x3c2ef3c2] CONNECT: /192.168.0.103:9090 12:07:00 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0x3c2ef3c2, L:/192.168.0.103:53155 - R:/192.168.0.103:9090] ACTIVE 12:07:00 [DEBUG] [nioEventLoopGroup-2-1] c.i.n.HelloWorldClient - sending... 12:07:00 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0x3c2ef3c2, L:/192.168.0.103:53155 - R:/192.168.0.103:9090] WRITE: 80B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 61 61 61 00 00 00 00 62 00 00 00 00 00 00 00 |aaaa....b.......| |00000010| 63 63 00 00 00 00 00 00 64 00 00 00 00 00 00 00 |cc......d.......| |00000020| 00 00 00 00 00 00 00 00 66 66 66 66 00 00 00 00 |........ffff....| |00000030| 67 67 67 00 00 00 00 00 68 00 00 00 00 00 00 00 |ggg.....h.......| |00000040| 69 69 69 69 69 00 00 00 6a 6a 6a 6a 00 00 00 00 |iiiii...jjjj....| +--------+-------------------------------------------------+----------------+ 12:07:00 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0x3c2ef3c2, L:/192.168.0.103:53155 - R:/192.168.0.103:9090] FLUSH ``` 服务端输出 ``` 12:06:51 [DEBUG] [main] c.i.n.HelloWorldServer - [id: 0xe3d9713f] binding... 12:06:51 [DEBUG] [main] c.i.n.HelloWorldServer - [id: 0xe3d9713f, L:/192.168.0.103:9090] bound... 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] REGISTERED 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] ACTIVE 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] c.i.n.HelloWorldServer - connected [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 61 61 61 00 00 00 00 |aaaa.... | +--------+-------------------------------------------------+----------------+ 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 62 00 00 00 00 00 00 00 |b....... | +--------+-------------------------------------------------+----------------+ 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 63 63 00 00 00 00 00 00 |cc...... | +--------+-------------------------------------------------+----------------+ 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 64 00 00 00 00 00 00 00 |d....... | +--------+-------------------------------------------------+----------------+ 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 00 00 00 00 00 00 00 00 |........ | +--------+-------------------------------------------------+----------------+ 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 66 66 66 66 00 00 00 00 |ffff.... | +--------+-------------------------------------------------+----------------+ 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 67 67 67 00 00 00 00 00 |ggg..... | +--------+-------------------------------------------------+----------------+ 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 68 00 00 00 00 00 00 00 |h....... | +--------+-------------------------------------------------+----------------+ 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 69 69 69 69 69 00 00 00 |iiiii... | +--------+-------------------------------------------------+----------------+ 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 6a 6a 6a 6a 00 00 00 00 |jjjj.... | +--------+-------------------------------------------------+----------------+ 12:07:00 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0xd739f137, L:/192.168.0.103:9090 - R:/192.168.0.103:53155] READ COMPLETE ``` 缺点是,数据包的大小不好把握 * 长度定的太大,浪费 * 长度定的太小,对某些数据包又显得不够 #### 方法3,固定分隔符 服务端加入,默认以 \n 或 \r\n 作为分隔符,如果超出指定长度仍未出现分隔符,则抛出异常 ```java ch.pipeline().addLast(new LineBasedFrameDecoder(1024)); ``` 客户端在每条消息之后,加入 \n 分隔符 ```java public class HelloWorldClient { static final Logger log = LoggerFactory.getLogger(HelloWorldClient.class); public static void main(String[] args) { NioEventLoopGroup worker = new NioEventLoopGroup(); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.channel(NioSocketChannel.class); bootstrap.group(worker); bootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { log.debug("connetted..."); ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG)); ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { log.debug("sending..."); Random r = new Random(); char c = 'a'; ByteBuf buffer = ctx.alloc().buffer(); for (int i = 0; i < 10; i++) { for (int j = 1; j <= r.nextInt(16)+1; j++) { buffer.writeByte((byte) c); } buffer.writeByte(10); c++; } ctx.writeAndFlush(buffer); } }); } }); ChannelFuture channelFuture = bootstrap.connect("192.168.0.103", 9090).sync(); channelFuture.channel().closeFuture().sync(); } catch (InterruptedException e) { log.error("client error", e); } finally { worker.shutdownGracefully(); } } } ``` 客户端输出 ``` 14:08:18 [DEBUG] [nioEventLoopGroup-2-1] c.i.n.HelloWorldClient - connetted... 14:08:18 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0x1282d755] REGISTERED 14:08:18 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0x1282d755] CONNECT: /192.168.0.103:9090 14:08:18 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0x1282d755, L:/192.168.0.103:63641 - R:/192.168.0.103:9090] ACTIVE 14:08:18 [DEBUG] [nioEventLoopGroup-2-1] c.i.n.HelloWorldClient - sending... 14:08:18 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0x1282d755, L:/192.168.0.103:63641 - R:/192.168.0.103:9090] WRITE: 60B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 0a 62 62 62 0a 63 63 63 0a 64 64 0a 65 65 65 |a.bbb.ccc.dd.eee| |00000010| 65 65 65 65 65 65 65 0a 66 66 0a 67 67 67 67 67 |eeeeeee.ff.ggggg| |00000020| 67 67 0a 68 68 68 68 0a 69 69 69 69 69 69 69 0a |gg.hhhh.iiiiiii.| |00000030| 6a 6a 6a 6a 6a 6a 6a 6a 6a 6a 6a 0a |jjjjjjjjjjj. | +--------+-------------------------------------------------+----------------+ 14:08:18 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0x1282d755, L:/192.168.0.103:63641 - R:/192.168.0.103:9090] FLUSH ``` 服务端输出 ``` 14:08:18 [DEBUG] [nioEventLoopGroup-3-5] c.i.n.HelloWorldServer - connected [id: 0xa4b3be43, L:/192.168.0.103:9090 - R:/192.168.0.103:63641] 14:08:18 [DEBUG] [nioEventLoopGroup-3-5] i.n.h.l.LoggingHandler - [id: 0xa4b3be43, L:/192.168.0.103:9090 - R:/192.168.0.103:63641] READ: 1B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 |a | +--------+-------------------------------------------------+----------------+ 14:08:18 [DEBUG] [nioEventLoopGroup-3-5] i.n.h.l.LoggingHandler - [id: 0xa4b3be43, L:/192.168.0.103:9090 - R:/192.168.0.103:63641] READ: 3B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 62 62 62 |bbb | +--------+-------------------------------------------------+----------------+ 14:08:18 [DEBUG] [nioEventLoopGroup-3-5] i.n.h.l.LoggingHandler - [id: 0xa4b3be43, L:/192.168.0.103:9090 - R:/192.168.0.103:63641] READ: 3B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 63 63 63 |ccc | +--------+-------------------------------------------------+----------------+ 14:08:18 [DEBUG] [nioEventLoopGroup-3-5] i.n.h.l.LoggingHandler - [id: 0xa4b3be43, L:/192.168.0.103:9090 - R:/192.168.0.103:63641] READ: 2B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 64 64 |dd | +--------+-------------------------------------------------+----------------+ 14:08:18 [DEBUG] [nioEventLoopGroup-3-5] i.n.h.l.LoggingHandler - [id: 0xa4b3be43, L:/192.168.0.103:9090 - R:/192.168.0.103:63641] READ: 10B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 65 65 65 65 65 65 65 65 65 65 |eeeeeeeeee | +--------+-------------------------------------------------+----------------+ 14:08:18 [DEBUG] [nioEventLoopGroup-3-5] i.n.h.l.LoggingHandler - [id: 0xa4b3be43, L:/192.168.0.103:9090 - R:/192.168.0.103:63641] READ: 2B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 66 66 |ff | +--------+-------------------------------------------------+----------------+ 14:08:18 [DEBUG] [nioEventLoopGroup-3-5] i.n.h.l.LoggingHandler - [id: 0xa4b3be43, L:/192.168.0.103:9090 - R:/192.168.0.103:63641] READ: 7B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 67 67 67 67 67 67 67 |ggggggg | +--------+-------------------------------------------------+----------------+ 14:08:18 [DEBUG] [nioEventLoopGroup-3-5] i.n.h.l.LoggingHandler - [id: 0xa4b3be43, L:/192.168.0.103:9090 - R:/192.168.0.103:63641] READ: 4B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 68 68 68 68 |hhhh | +--------+-------------------------------------------------+----------------+ 14:08:18 [DEBUG] [nioEventLoopGroup-3-5] i.n.h.l.LoggingHandler - [id: 0xa4b3be43, L:/192.168.0.103:9090 - R:/192.168.0.103:63641] READ: 7B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 69 69 69 69 69 69 69 |iiiiiii | +--------+-------------------------------------------------+----------------+ 14:08:18 [DEBUG] [nioEventLoopGroup-3-5] i.n.h.l.LoggingHandler - [id: 0xa4b3be43, L:/192.168.0.103:9090 - R:/192.168.0.103:63641] READ: 11B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 6a 6a 6a 6a 6a 6a 6a 6a 6a 6a 6a |jjjjjjjjjjj | +--------+-------------------------------------------------+----------------+ 14:08:18 [DEBUG] [nioEventLoopGroup-3-5] i.n.h.l.LoggingHandler - [id: 0xa4b3be43, L:/192.168.0.103:9090 - R:/192.168.0.103:63641] READ COMPLETE ``` 缺点,处理字符数据比较合适,但如果内容本身包含了分隔符(字节数据常常会有此情况),那么就会解析错误 #### 方法4,预设长度 在发送消息前,先约定用定长字节表示接下来数据的长度 ```java // 最大长度,长度偏移,长度占用字节,长度调整,剥离字节数 ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 1, 0, 1)); ``` 客户端代码 ```java public class HelloWorldClient { static final Logger log = LoggerFactory.getLogger(HelloWorldClient.class); public static void main(String[] args) { NioEventLoopGroup worker = new NioEventLoopGroup(); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.channel(NioSocketChannel.class); bootstrap.group(worker); bootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { log.debug("connetted..."); ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG)); ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { log.debug("sending..."); Random r = new Random(); char c = 'a'; ByteBuf buffer = ctx.alloc().buffer(); for (int i = 0; i < 10; i++) { byte length = (byte) (r.nextInt(16) + 1); // 先写入长度 buffer.writeByte(length); // 再 for (int j = 1; j <= length; j++) { buffer.writeByte((byte) c); } c++; } ctx.writeAndFlush(buffer); } }); } }); ChannelFuture channelFuture = bootstrap.connect("192.168.0.103", 9090).sync(); channelFuture.channel().closeFuture().sync(); } catch (InterruptedException e) { log.error("client error", e); } finally { worker.shutdownGracefully(); } } } ``` 客户端输出 ``` 14:37:10 [DEBUG] [nioEventLoopGroup-2-1] c.i.n.HelloWorldClient - connetted... 14:37:10 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0xf0f347b8] REGISTERED 14:37:10 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0xf0f347b8] CONNECT: /192.168.0.103:9090 14:37:10 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0xf0f347b8, L:/192.168.0.103:49979 - R:/192.168.0.103:9090] ACTIVE 14:37:10 [DEBUG] [nioEventLoopGroup-2-1] c.i.n.HelloWorldClient - sending... 14:37:10 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0xf0f347b8, L:/192.168.0.103:49979 - R:/192.168.0.103:9090] WRITE: 97B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 09 61 61 61 61 61 61 61 61 61 09 62 62 62 62 62 |.aaaaaaaaa.bbbbb| |00000010| 62 62 62 62 06 63 63 63 63 63 63 08 64 64 64 64 |bbbb.cccccc.dddd| |00000020| 64 64 64 64 0f 65 65 65 65 65 65 65 65 65 65 65 |dddd.eeeeeeeeeee| |00000030| 65 65 65 65 0d 66 66 66 66 66 66 66 66 66 66 66 |eeee.fffffffffff| |00000040| 66 66 02 67 67 02 68 68 0e 69 69 69 69 69 69 69 |ff.gg.hh.iiiiiii| |00000050| 69 69 69 69 69 69 69 09 6a 6a 6a 6a 6a 6a 6a 6a |iiiiiii.jjjjjjjj| |00000060| 6a |j | +--------+-------------------------------------------------+----------------+ 14:37:10 [DEBUG] [nioEventLoopGroup-2-1] i.n.h.l.LoggingHandler - [id: 0xf0f347b8, L:/192.168.0.103:49979 - R:/192.168.0.103:9090] FLUSH ``` 服务端输出 ``` 14:36:50 [DEBUG] [main] c.i.n.HelloWorldServer - [id: 0xdff439d3] binding... 14:36:51 [DEBUG] [main] c.i.n.HelloWorldServer - [id: 0xdff439d3, L:/192.168.0.103:9090] bound... 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] REGISTERED 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] ACTIVE 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] c.i.n.HelloWorldServer - connected [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] READ: 9B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 61 61 61 61 61 61 61 61 |aaaaaaaaa | +--------+-------------------------------------------------+----------------+ 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] READ: 9B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 62 62 62 62 62 62 62 62 62 |bbbbbbbbb | +--------+-------------------------------------------------+----------------+ 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] READ: 6B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 63 63 63 63 63 63 |cccccc | +--------+-------------------------------------------------+----------------+ 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] READ: 8B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 64 64 64 64 64 64 64 64 |dddddddd | +--------+-------------------------------------------------+----------------+ 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] READ: 15B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 65 65 65 65 65 65 65 65 65 65 65 65 65 65 65 |eeeeeeeeeeeeeee | +--------+-------------------------------------------------+----------------+ 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] READ: 13B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 66 66 66 66 66 66 66 66 66 66 66 66 66 |fffffffffffff | +--------+-------------------------------------------------+----------------+ 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] READ: 2B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 67 67 |gg | +--------+-------------------------------------------------+----------------+ 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] READ: 2B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 68 68 |hh | +--------+-------------------------------------------------+----------------+ 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] READ: 14B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 69 69 69 69 69 69 69 69 69 69 69 69 69 69 |iiiiiiiiiiiiii | +--------+-------------------------------------------------+----------------+ 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] READ: 9B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 6a 6a 6a 6a 6a 6a 6a 6a 6a |jjjjjjjjj | +--------+-------------------------------------------------+----------------+ 14:37:10 [DEBUG] [nioEventLoopGroup-3-1] i.n.h.l.LoggingHandler - [id: 0x744f2b47, L:/192.168.0.103:9090 - R:/192.168.0.103:49979] READ COMPLETE ``` ## 2. 协议设计与解析 ### 2.1 为什么需要协议? TCP/IP 中消息传输基于流的方式,没有边界。 协议的目的就是划定消息的边界,制定通信双方要共同遵守的通信规则 例如:在网络上传输 ``` 下雨天留客天留我不留 ``` 是中文一句著名的无标点符号句子,在没有标点符号情况下,这句话有数种拆解方式,而意思却是完全不同,所以常被用作讲述标点符号的重要性 一种解读 ``` 下雨天留客,天留,我不留 ``` 另一种解读 ``` 下雨天,留客天,留我不?留 ``` 如何设计协议呢?其实就是给网络传输的信息加上“标点符号”。但通过分隔符来断句不是很好,因为分隔符本身如果用于传输,那么必须加以区分。因此,下面一种协议较为常用 ``` 定长字节表示内容长度 + 实际内容 ``` 例如,假设一个中文字符长度为 3,按照上述协议的规则,发送信息方式如下,就不会被接收方弄错意思了 ``` 0f下雨天留客06天留09我不留 ``` > 小故事 > > 很久很久以前,一位私塾先生到一家任教。双方签订了一纸协议:“无鸡鸭亦可无鱼肉亦可白菜豆腐不可少不得束修金”。此后,私塾先生虽然认真教课,但主人家则总是给私塾先生以白菜豆腐为菜,丝毫未见鸡鸭鱼肉的款待。私塾先生先是很不解,可是后来也就想通了:主人把鸡鸭鱼肉的钱都会换为束修金的,也罢。至此双方相安无事。 > > 年关将至,一个学年段亦告结束。私塾先生临行时,也不见主人家为他交付束修金,遂与主家理论。然主家亦振振有词:“有协议为证——无鸡鸭亦可,无鱼肉亦可,白菜豆腐不可少,不得束修金。这白纸黑字明摆着的,你有什么要说的呢?” > > 私塾先生据理力争:“协议是这样的——无鸡,鸭亦可;无鱼,肉亦可;白菜豆腐不可,少不得束修金。” > > 双方唇枪舌战,你来我往,真个是不亦乐乎! > > 这里的束修金,也作“束脩”,应当是泛指教师应当得到的报酬 ### 2.2 redis 协议举例 ```java NioEventLoopGroup worker = new NioEventLoopGroup(); byte[] LINE = {13, 10}; try { Bootstrap bootstrap = new Bootstrap(); bootstrap.channel(NioSocketChannel.class); bootstrap.group(worker); bootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new LoggingHandler()); ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { // 会在连接 channel 建立成功后,会触发 active 事件 @Override public void channelActive(ChannelHandlerContext ctx) { set(ctx); get(ctx); } private void get(ChannelHandlerContext ctx) { ByteBuf buf = ctx.alloc().buffer(); buf.writeBytes("*2".getBytes()); buf.writeBytes(LINE); buf.writeBytes("$3".getBytes()); buf.writeBytes(LINE); buf.writeBytes("get".getBytes()); buf.writeBytes(LINE); buf.writeBytes("$3".getBytes()); buf.writeBytes(LINE); buf.writeBytes("aaa".getBytes()); buf.writeBytes(LINE); ctx.writeAndFlush(buf); } private void set(ChannelHandlerContext ctx) { ByteBuf buf = ctx.alloc().buffer(); buf.writeBytes("*3".getBytes()); buf.writeBytes(LINE); buf.writeBytes("$3".getBytes()); buf.writeBytes(LINE); buf.writeBytes("set".getBytes()); buf.writeBytes(LINE); buf.writeBytes("$3".getBytes()); buf.writeBytes(LINE); buf.writeBytes("aaa".getBytes()); buf.writeBytes(LINE); buf.writeBytes("$3".getBytes()); buf.writeBytes(LINE); buf.writeBytes("bbb".getBytes()); buf.writeBytes(LINE); ctx.writeAndFlush(buf); } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf buf = (ByteBuf) msg; System.out.println(buf.toString(Charset.defaultCharset())); } }); } }); ChannelFuture channelFuture = bootstrap.connect("localhost", 6379).sync(); channelFuture.channel().closeFuture().sync(); } catch (InterruptedException e) { log.error("client error", e); } finally { worker.shutdownGracefully(); } ``` ### 2.3 http 协议举例 ```java NioEventLoopGroup boss = new NioEventLoopGroup(); NioEventLoopGroup worker = new NioEventLoopGroup(); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.channel(NioServerSocketChannel.class); serverBootstrap.group(boss, worker); serverBootstrap.childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG)); ch.pipeline().addLast(new HttpServerCodec()); ch.pipeline().addLast(new SimpleChannelInboundHandler() { @Override protected void channelRead0(ChannelHandlerContext ctx, HttpRequest msg) throws Exception { // 获取请求 log.debug(msg.uri()); // 返回响应 DefaultFullHttpResponse response = new DefaultFullHttpResponse(msg.protocolVersion(), HttpResponseStatus.OK); byte[] bytes = "

Hello, world!

".getBytes(); response.headers().setInt(CONTENT_LENGTH, bytes.length); response.content().writeBytes(bytes); // 写回响应 ctx.writeAndFlush(response); } }); /*ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { log.debug("{}", msg.getClass()); if (msg instanceof HttpRequest) { // 请求行,请求头 } else if (msg instanceof HttpContent) { //请求体 } } });*/ } }); ChannelFuture channelFuture = serverBootstrap.bind(8080).sync(); channelFuture.channel().closeFuture().sync(); } catch (InterruptedException e) { log.error("server error", e); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); } ``` ### 2.4 自定义协议要素 * 魔数,用来在第一时间判定是否是无效数据包 * 版本号,可以支持协议的升级 * 序列化算法,消息正文到底采用哪种序列化反序列化方式,可以由此扩展,例如:json、protobuf、hessian、jdk * 指令类型,是登录、注册、单聊、群聊... 跟业务相关 * 请求序号,为了双工通信,提供异步能力 * 正文长度 * 消息正文 #### 编解码器 根据上面的要素,设计一个登录请求消息和登录响应消息,并使用 Netty 完成收发 ```java @Slf4j public class MessageCodec extends ByteToMessageCodec { @Override protected void encode(ChannelHandlerContext ctx, Message msg, ByteBuf out) throws Exception { // 1. 4 字节的魔数 out.writeBytes(new byte[]{1, 2, 3, 4}); // 2. 1 字节的版本, out.writeByte(1); // 3. 1 字节的序列化方式 jdk 0 , json 1 out.writeByte(0); // 4. 1 字节的指令类型 out.writeByte(msg.getMessageType()); // 5. 4 个字节 out.writeInt(msg.getSequenceId()); // 无意义,对齐填充 out.writeByte(0xff); // 6. 获取内容的字节数组 ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos); oos.writeObject(msg); byte[] bytes = bos.toByteArray(); // 7. 长度 out.writeInt(bytes.length); // 8. 写入内容 out.writeBytes(bytes); } @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws Exception { int magicNum = in.readInt(); byte version = in.readByte(); byte serializerType = in.readByte(); byte messageType = in.readByte(); int sequenceId = in.readInt(); in.readByte(); int length = in.readInt(); byte[] bytes = new byte[length]; in.readBytes(bytes, 0, length); ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes)); Message message = (Message) ois.readObject(); log.debug("{}, {}, {}, {}, {}, {}", magicNum, version, serializerType, messageType, sequenceId, length); log.debug("{}", message); out.add(message); } } ``` 测试 ```java EmbeddedChannel channel = new EmbeddedChannel( new LoggingHandler(), new LengthFieldBasedFrameDecoder( 1024, 12, 4, 0, 0), new MessageCodec() ); // encode LoginRequestMessage message = new LoginRequestMessage("zhangsan", "123", "张三"); // channel.writeOutbound(message); // decode ByteBuf buf = ByteBufAllocator.DEFAULT.buffer(); new MessageCodec().encode(null, message, buf); ByteBuf s1 = buf.slice(0, 100); ByteBuf s2 = buf.slice(100, buf.readableBytes() - 100); s1.retain(); // 引用计数 2 channel.writeInbound(s1); // release 1 channel.writeInbound(s2); ``` 解读 #### 💡 什么时候可以加 @Sharable * 当 handler 不保存状态时,就可以安全地在多线程下被共享 * 但要注意对于编解码器类,不能继承 ByteToMessageCodec 或 CombinedChannelDuplexHandler 父类,他们的构造方法对 @Sharable 有限制 * 如果能确保编解码器不会保存状态,可以继承 MessageToMessageCodec 父类 ```java @Slf4j @ChannelHandler.Sharable /** * 必须和 LengthFieldBasedFrameDecoder 一起使用,确保接到的 ByteBuf 消息是完整的 */ public class MessageCodecSharable extends MessageToMessageCodec { @Override protected void encode(ChannelHandlerContext ctx, Message msg, List outList) throws Exception { ByteBuf out = ctx.alloc().buffer(); // 1. 4 字节的魔数 out.writeBytes(new byte[]{1, 2, 3, 4}); // 2. 1 字节的版本, out.writeByte(1); // 3. 1 字节的序列化方式 jdk 0 , json 1 out.writeByte(0); // 4. 1 字节的指令类型 out.writeByte(msg.getMessageType()); // 5. 4 个字节 out.writeInt(msg.getSequenceId()); // 无意义,对齐填充 out.writeByte(0xff); // 6. 获取内容的字节数组 ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos); oos.writeObject(msg); byte[] bytes = bos.toByteArray(); // 7. 长度 out.writeInt(bytes.length); // 8. 写入内容 out.writeBytes(bytes); outList.add(out); } @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws Exception { int magicNum = in.readInt(); byte version = in.readByte(); byte serializerType = in.readByte(); byte messageType = in.readByte(); int sequenceId = in.readInt(); in.readByte(); int length = in.readInt(); byte[] bytes = new byte[length]; in.readBytes(bytes, 0, length); ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes)); Message message = (Message) ois.readObject(); log.debug("{}, {}, {}, {}, {}, {}", magicNum, version, serializerType, messageType, sequenceId, length); log.debug("{}", message); out.add(message); } } ``` ## 3. 聊天室案例 ### 3.1 聊天室业务介绍 ```java /** * 用户管理接口 */ public interface UserService { /** * 登录 * @param username 用户名 * @param password 密码 * @return 登录成功返回 true, 否则返回 false */ boolean login(String username, String password); } ``` ```java /** * 会话管理接口 */ public interface Session { /** * 绑定会话 * @param channel 哪个 channel 要绑定会话 * @param username 会话绑定用户 */ void bind(Channel channel, String username); /** * 解绑会话 * @param channel 哪个 channel 要解绑会话 */ void unbind(Channel channel); /** * 获取属性 * @param channel 哪个 channel * @param name 属性名 * @return 属性值 */ Object getAttribute(Channel channel, String name); /** * 设置属性 * @param channel 哪个 channel * @param name 属性名 * @param value 属性值 */ void setAttribute(Channel channel, String name, Object value); /** * 根据用户名获取 channel * @param username 用户名 * @return channel */ Channel getChannel(String username); } ``` ```java /** * 聊天组会话管理接口 */ public interface GroupSession { /** * 创建一个聊天组, 如果不存在才能创建成功, 否则返回 null * @param name 组名 * @param members 成员 * @return 成功时返回组对象, 失败返回 null */ Group createGroup(String name, Set members); /** * 加入聊天组 * @param name 组名 * @param member 成员名 * @return 如果组不存在返回 null, 否则返回组对象 */ Group joinMember(String name, String member); /** * 移除组成员 * @param name 组名 * @param member 成员名 * @return 如果组不存在返回 null, 否则返回组对象 */ Group removeMember(String name, String member); /** * 移除聊天组 * @param name 组名 * @return 如果组不存在返回 null, 否则返回组对象 */ Group removeGroup(String name); /** * 获取组成员 * @param name 组名 * @return 成员集合, 没有成员会返回 empty set */ Set getMembers(String name); /** * 获取组成员的 channel 集合, 只有在线的 channel 才会返回 * @param name 组名 * @return 成员 channel 集合 */ List getMembersChannel(String name); } ``` ### 3.2 聊天室业务-登录 ```java @Slf4j public class ChatServer { public static void main(String[] args) { NioEventLoopGroup boss = new NioEventLoopGroup(); NioEventLoopGroup worker = new NioEventLoopGroup(); LoggingHandler LOGGING_HANDLER = new LoggingHandler(LogLevel.DEBUG); MessageCodecSharable MESSAGE_CODEC = new MessageCodecSharable(); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.channel(NioServerSocketChannel.class); serverBootstrap.group(boss, worker); serverBootstrap.childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new ProcotolFrameDecoder()); ch.pipeline().addLast(LOGGING_HANDLER); ch.pipeline().addLast(MESSAGE_CODEC); ch.pipeline().addLast(new SimpleChannelInboundHandler() { @Override protected void channelRead0(ChannelHandlerContext ctx, LoginRequestMessage msg) throws Exception { String username = msg.getUsername(); String password = msg.getPassword(); boolean login = UserServiceFactory.getUserService().login(username, password); LoginResponseMessage message; if(login) { message = new LoginResponseMessage(true, "登录成功"); } else { message = new LoginResponseMessage(false, "用户名或密码不正确"); } ctx.writeAndFlush(message); } }); } }); Channel channel = serverBootstrap.bind(8080).sync().channel(); channel.closeFuture().sync(); } catch (InterruptedException e) { log.error("server error", e); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); } } } ``` ```java @Slf4j public class ChatClient { public static void main(String[] args) { NioEventLoopGroup group = new NioEventLoopGroup(); LoggingHandler LOGGING_HANDLER = new LoggingHandler(LogLevel.DEBUG); MessageCodecSharable MESSAGE_CODEC = new MessageCodecSharable(); CountDownLatch WAIT_FOR_LOGIN = new CountDownLatch(1); AtomicBoolean LOGIN = new AtomicBoolean(false); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.channel(NioSocketChannel.class); bootstrap.group(group); bootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new ProcotolFrameDecoder()); // ch.pipeline().addLast(LOGGING_HANDLER); ch.pipeline().addLast(MESSAGE_CODEC); ch.pipeline().addLast("client handler", new ChannelInboundHandlerAdapter() { // 接收响应消息 @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { log.debug("msg: {}", msg); if ((msg instanceof LoginResponseMessage)) { LoginResponseMessage response = (LoginResponseMessage) msg; if (response.isSuccess()) { // 如果登录成功 LOGIN.set(true); } // 唤醒 system in 线程 WAIT_FOR_LOGIN.countDown(); } } // 在连接建立后触发 active 事件 @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { // 负责接收用户在控制台的输入,负责向服务器发送各种消息 new Thread(() -> { Scanner scanner = new Scanner(System.in); System.out.println("请输入用户名:"); String username = scanner.nextLine(); System.out.println("请输入密码:"); String password = scanner.nextLine(); // 构造消息对象 LoginRequestMessage message = new LoginRequestMessage(username, password); // 发送消息 ctx.writeAndFlush(message); System.out.println("等待后续操作..."); try { WAIT_FOR_LOGIN.await(); } catch (InterruptedException e) { e.printStackTrace(); } // 如果登录失败 if (!LOGIN.get()) { ctx.channel().close(); return; } while (true) { System.out.println("=================================="); System.out.println("send [username] [content]"); System.out.println("gsend [group name] [content]"); System.out.println("gcreate [group name] [m1,m2,m3...]"); System.out.println("gmembers [group name]"); System.out.println("gjoin [group name]"); System.out.println("gquit [group name]"); System.out.println("quit"); System.out.println("=================================="); String command = scanner.nextLine(); String[] s = command.split(" "); switch (s[0]){ case "send": ctx.writeAndFlush(new ChatRequestMessage(username, s[1], s[2])); break; case "gsend": ctx.writeAndFlush(new GroupChatRequestMessage(username, s[1], s[2])); break; case "gcreate": Set set = new HashSet<>(Arrays.asList(s[2].split(","))); set.add(username); // 加入自己 ctx.writeAndFlush(new GroupCreateRequestMessage(s[1], set)); break; case "gmembers": ctx.writeAndFlush(new GroupMembersRequestMessage(s[1])); break; case "gjoin": ctx.writeAndFlush(new GroupJoinRequestMessage(username, s[1])); break; case "gquit": ctx.writeAndFlush(new GroupQuitRequestMessage(username, s[1])); break; case "quit": ctx.channel().close(); return; } } }, "system in").start(); } }); } }); Channel channel = bootstrap.connect("localhost", 8080).sync().channel(); channel.closeFuture().sync(); } catch (Exception e) { log.error("client error", e); } finally { group.shutdownGracefully(); } } } ``` ### 3.3 聊天室业务-单聊 服务器端将 handler 独立出来 登录 handler ```java @ChannelHandler.Sharable public class LoginRequestMessageHandler extends SimpleChannelInboundHandler { @Override protected void channelRead0(ChannelHandlerContext ctx, LoginRequestMessage msg) throws Exception { String username = msg.getUsername(); String password = msg.getPassword(); boolean login = UserServiceFactory.getUserService().login(username, password); LoginResponseMessage message; if(login) { SessionFactory.getSession().bind(ctx.channel(), username); message = new LoginResponseMessage(true, "登录成功"); } else { message = new LoginResponseMessage(false, "用户名或密码不正确"); } ctx.writeAndFlush(message); } } ``` 单聊 handler ```java @ChannelHandler.Sharable public class ChatRequestMessageHandler extends SimpleChannelInboundHandler { @Override protected void channelRead0(ChannelHandlerContext ctx, ChatRequestMessage msg) throws Exception { String to = msg.getTo(); Channel channel = SessionFactory.getSession().getChannel(to); // 在线 if(channel != null) { channel.writeAndFlush(new ChatResponseMessage(msg.getFrom(), msg.getContent())); } // 不在线 else { ctx.writeAndFlush(new ChatResponseMessage(false, "对方用户不存在或者不在线")); } } } ``` ### 3.4 聊天室业务-群聊 创建群聊 ```java @ChannelHandler.Sharable public class GroupCreateRequestMessageHandler extends SimpleChannelInboundHandler { @Override protected void channelRead0(ChannelHandlerContext ctx, GroupCreateRequestMessage msg) throws Exception { String groupName = msg.getGroupName(); Set members = msg.getMembers(); // 群管理器 GroupSession groupSession = GroupSessionFactory.getGroupSession(); Group group = groupSession.createGroup(groupName, members); if (group == null) { // 发生成功消息 ctx.writeAndFlush(new GroupCreateResponseMessage(true, groupName + "创建成功")); // 发送拉群消息 List channels = groupSession.getMembersChannel(groupName); for (Channel channel : channels) { channel.writeAndFlush(new GroupCreateResponseMessage(true, "您已被拉入" + groupName)); } } else { ctx.writeAndFlush(new GroupCreateResponseMessage(false, groupName + "已经存在")); } } } ``` 群聊 ```java @ChannelHandler.Sharable public class GroupChatRequestMessageHandler extends SimpleChannelInboundHandler { @Override protected void channelRead0(ChannelHandlerContext ctx, GroupChatRequestMessage msg) throws Exception { List channels = GroupSessionFactory.getGroupSession() .getMembersChannel(msg.getGroupName()); for (Channel channel : channels) { channel.writeAndFlush(new GroupChatResponseMessage(msg.getFrom(), msg.getContent())); } } } ``` 加入群聊 ```java @ChannelHandler.Sharable public class GroupJoinRequestMessageHandler extends SimpleChannelInboundHandler { @Override protected void channelRead0(ChannelHandlerContext ctx, GroupJoinRequestMessage msg) throws Exception { Group group = GroupSessionFactory.getGroupSession().joinMember(msg.getGroupName(), msg.getUsername()); if (group != null) { ctx.writeAndFlush(new GroupJoinResponseMessage(true, msg.getGroupName() + "群加入成功")); } else { ctx.writeAndFlush(new GroupJoinResponseMessage(true, msg.getGroupName() + "群不存在")); } } } ``` 退出群聊 ```java @ChannelHandler.Sharable public class GroupQuitRequestMessageHandler extends SimpleChannelInboundHandler { @Override protected void channelRead0(ChannelHandlerContext ctx, GroupQuitRequestMessage msg) throws Exception { Group group = GroupSessionFactory.getGroupSession().removeMember(msg.getGroupName(), msg.getUsername()); if (group != null) { ctx.writeAndFlush(new GroupJoinResponseMessage(true, "已退出群" + msg.getGroupName())); } else { ctx.writeAndFlush(new GroupJoinResponseMessage(true, msg.getGroupName() + "群不存在")); } } } ``` 查看成员 ```java @ChannelHandler.Sharable public class GroupMembersRequestMessageHandler extends SimpleChannelInboundHandler { @Override protected void channelRead0(ChannelHandlerContext ctx, GroupMembersRequestMessage msg) throws Exception { Set members = GroupSessionFactory.getGroupSession() .getMembers(msg.getGroupName()); ctx.writeAndFlush(new GroupMembersResponseMessage(members)); } } ``` ### 3.5 聊天室业务-退出 ``` @Slf4j @ChannelHandler.Sharable public class QuitHandler extends ChannelInboundHandlerAdapter { // 当连接断开时触发 inactive 事件 @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { SessionFactory.getSession().unbind(ctx.channel()); log.debug("{} 已经断开", ctx.channel()); } // 当出现异常时触发 @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { SessionFactory.getSession().unbind(ctx.channel()); log.debug("{} 已经异常断开 异常是{}", ctx.channel(), cause.getMessage()); } } ``` ### 3.6 聊天室业务-空闲检测 #### 连接假死 原因 * 网络设备出现故障,例如网卡,机房等,底层的 TCP 连接已经断开了,但应用程序没有感知到,仍然占用着资源。 * 公网网络不稳定,出现丢包。如果连续出现丢包,这时现象就是客户端数据发不出去,服务端也一直收不到数据,就这么一直耗着 * 应用程序线程阻塞,无法进行数据读写 问题 * 假死的连接占用的资源不能自动释放 * 向假死的连接发送数据,得到的反馈是发送超时 服务器端解决 * 怎么判断客户端连接是否假死呢?如果能收到客户端数据,说明没有假死。因此策略就可以定为,每隔一段时间就检查这段时间内是否接收到客户端数据,没有就可以判定为连接假死 ```java // 用来判断是不是 读空闲时间过长,或 写空闲时间过长 // 5s 内如果没有收到 channel 的数据,会触发一个 IdleState#READER_IDLE 事件 ch.pipeline().addLast(new IdleStateHandler(5, 0, 0)); // ChannelDuplexHandler 可以同时作为入站和出站处理器 ch.pipeline().addLast(new ChannelDuplexHandler() { // 用来触发特殊事件 @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception{ IdleStateEvent event = (IdleStateEvent) evt; // 触发了读空闲事件 if (event.state() == IdleState.READER_IDLE) { log.debug("已经 5s 没有读到数据了"); ctx.channel().close(); } } }); ``` 客户端定时心跳 * 客户端可以定时向服务器端发送数据,只要这个时间间隔小于服务器定义的空闲检测的时间间隔,那么就能防止前面提到的误判,客户端可以定义如下心跳处理器 ```java // 用来判断是不是 读空闲时间过长,或 写空闲时间过长 // 3s 内如果没有向服务器写数据,会触发一个 IdleState#WRITER_IDLE 事件 ch.pipeline().addLast(new IdleStateHandler(0, 3, 0)); // ChannelDuplexHandler 可以同时作为入站和出站处理器 ch.pipeline().addLast(new ChannelDuplexHandler() { // 用来触发特殊事件 @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception{ IdleStateEvent event = (IdleStateEvent) evt; // 触发了写空闲事件 if (event.state() == IdleState.WRITER_IDLE) { // log.debug("3s 没有写数据了,发送一个心跳包"); ctx.writeAndFlush(new PingMessage()); } } }); ``` --- --- url: /src/technology/java/netty/Netty04-优化与源码.md --- # 四. 优化与源码 ## 1. 优化 ### 1.1 扩展序列化算法 序列化,反序列化主要用在消息正文的转换上 * 序列化时,需要将 Java 对象变为要传输的数据(可以是 byte\[],或 json 等,最终都需要变成 byte\[]) * 反序列化时,需要将传入的正文数据还原成 Java 对象,便于处理 目前的代码仅支持 Java 自带的序列化,反序列化机制,核心代码如下 ```java // 反序列化 byte[] body = new byte[bodyLength]; byteByf.readBytes(body); ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(body)); Message message = (Message) in.readObject(); message.setSequenceId(sequenceId); // 序列化 ByteArrayOutputStream out = new ByteArrayOutputStream(); new ObjectOutputStream(out).writeObject(message); byte[] bytes = out.toByteArray(); ``` 为了支持更多序列化算法,抽象一个 Serializer 接口 ```java public interface Serializer { // 反序列化方法 T deserialize(Class clazz, byte[] bytes); // 序列化方法 byte[] serialize(T object); } ``` 提供两个实现,我这里直接将实现加入了枚举类 Serializer.Algorithm 中 ```java enum SerializerAlgorithm implements Serializer { // Java 实现 Java { @Override public T deserialize(Class clazz, byte[] bytes) { try { ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes)); Object object = in.readObject(); return (T) object; } catch (IOException | ClassNotFoundException e) { throw new RuntimeException("SerializerAlgorithm.Java 反序列化错误", e); } } @Override public byte[] serialize(T object) { try { ByteArrayOutputStream out = new ByteArrayOutputStream(); new ObjectOutputStream(out).writeObject(object); return out.toByteArray(); } catch (IOException e) { throw new RuntimeException("SerializerAlgorithm.Java 序列化错误", e); } } }, // Json 实现(引入了 Gson 依赖) Json { @Override public T deserialize(Class clazz, byte[] bytes) { return new Gson().fromJson(new String(bytes, StandardCharsets.UTF_8), clazz); } @Override public byte[] serialize(T object) { return new Gson().toJson(object).getBytes(StandardCharsets.UTF_8); } }; // 需要从协议的字节中得到是哪种序列化算法 public static SerializerAlgorithm getByInt(int type) { SerializerAlgorithm[] array = SerializerAlgorithm.values(); if (type < 0 || type > array.length - 1) { throw new IllegalArgumentException("超过 SerializerAlgorithm 范围"); } return array[type]; } } ``` 增加配置类和配置文件 ```java public abstract class Config { static Properties properties; static { try (InputStream in = Config.class.getResourceAsStream("/application.properties")) { properties = new Properties(); properties.load(in); } catch (IOException e) { throw new ExceptionInInitializerError(e); } } public static int getServerPort() { String value = properties.getProperty("server.port"); if(value == null) { return 8080; } else { return Integer.parseInt(value); } } public static Serializer.Algorithm getSerializerAlgorithm() { String value = properties.getProperty("serializer.algorithm"); if(value == null) { return Serializer.Algorithm.Java; } else { return Serializer.Algorithm.valueOf(value); } } } ``` 配置文件 ```properties serializer.algorithm=Json ``` 修改编解码器 ```java /** * 必须和 LengthFieldBasedFrameDecoder 一起使用,确保接到的 ByteBuf 消息是完整的 */ public class MessageCodecSharable extends MessageToMessageCodec { @Override public void encode(ChannelHandlerContext ctx, Message msg, List outList) throws Exception { ByteBuf out = ctx.alloc().buffer(); // 1. 4 字节的魔数 out.writeBytes(new byte[]{1, 2, 3, 4}); // 2. 1 字节的版本, out.writeByte(1); // 3. 1 字节的序列化方式 jdk 0 , json 1 out.writeByte(Config.getSerializerAlgorithm().ordinal()); // 4. 1 字节的指令类型 out.writeByte(msg.getMessageType()); // 5. 4 个字节 out.writeInt(msg.getSequenceId()); // 无意义,对齐填充 out.writeByte(0xff); // 6. 获取内容的字节数组 byte[] bytes = Config.getSerializerAlgorithm().serialize(msg); // 7. 长度 out.writeInt(bytes.length); // 8. 写入内容 out.writeBytes(bytes); outList.add(out); } @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws Exception { int magicNum = in.readInt(); byte version = in.readByte(); byte serializerAlgorithm = in.readByte(); // 0 或 1 byte messageType = in.readByte(); // 0,1,2... int sequenceId = in.readInt(); in.readByte(); int length = in.readInt(); byte[] bytes = new byte[length]; in.readBytes(bytes, 0, length); // 找到反序列化算法 Serializer.Algorithm algorithm = Serializer.Algorithm.values()[serializerAlgorithm]; // 确定具体消息类型 Class messageClass = Message.getMessageClass(messageType); Message message = algorithm.deserialize(messageClass, bytes); // log.debug("{}, {}, {}, {}, {}, {}", magicNum, version, serializerType, messageType, sequenceId, length); // log.debug("{}", message); out.add(message); } } ``` 其中确定具体消息类型,可以根据 `消息类型字节` 获取到对应的 `消息 class` ```java @Data public abstract class Message implements Serializable { /** * 根据消息类型字节,获得对应的消息 class * @param messageType 消息类型字节 * @return 消息 class */ public static Class getMessageClass(int messageType) { return messageClasses.get(messageType); } private int sequenceId; private int messageType; public abstract int getMessageType(); public static final int LoginRequestMessage = 0; public static final int LoginResponseMessage = 1; public static final int ChatRequestMessage = 2; public static final int ChatResponseMessage = 3; public static final int GroupCreateRequestMessage = 4; public static final int GroupCreateResponseMessage = 5; public static final int GroupJoinRequestMessage = 6; public static final int GroupJoinResponseMessage = 7; public static final int GroupQuitRequestMessage = 8; public static final int GroupQuitResponseMessage = 9; public static final int GroupChatRequestMessage = 10; public static final int GroupChatResponseMessage = 11; public static final int GroupMembersRequestMessage = 12; public static final int GroupMembersResponseMessage = 13; public static final int PingMessage = 14; public static final int PongMessage = 15; private static final Map> messageClasses = new HashMap<>(); static { messageClasses.put(LoginRequestMessage, LoginRequestMessage.class); messageClasses.put(LoginResponseMessage, LoginResponseMessage.class); messageClasses.put(ChatRequestMessage, ChatRequestMessage.class); messageClasses.put(ChatResponseMessage, ChatResponseMessage.class); messageClasses.put(GroupCreateRequestMessage, GroupCreateRequestMessage.class); messageClasses.put(GroupCreateResponseMessage, GroupCreateResponseMessage.class); messageClasses.put(GroupJoinRequestMessage, GroupJoinRequestMessage.class); messageClasses.put(GroupJoinResponseMessage, GroupJoinResponseMessage.class); messageClasses.put(GroupQuitRequestMessage, GroupQuitRequestMessage.class); messageClasses.put(GroupQuitResponseMessage, GroupQuitResponseMessage.class); messageClasses.put(GroupChatRequestMessage, GroupChatRequestMessage.class); messageClasses.put(GroupChatResponseMessage, GroupChatResponseMessage.class); messageClasses.put(GroupMembersRequestMessage, GroupMembersRequestMessage.class); messageClasses.put(GroupMembersResponseMessage, GroupMembersResponseMessage.class); } } ``` ### 1.2 参数调优 #### 1)CONNECT\_TIMEOUT\_MILLIS * 属于 SocketChannal 参数 * 用在客户端建立连接时,如果在指定毫秒内无法连接,会抛出 timeout 异常 * SO\_TIMEOUT 主要用在阻塞 IO,阻塞 IO 中 accept,read 等都是无限等待的,如果不希望永远阻塞,使用它调整超时时间 ```java @Slf4j public class TestConnectionTimeout { public static void main(String[] args) { NioEventLoopGroup group = new NioEventLoopGroup(); try { Bootstrap bootstrap = new Bootstrap() .group(group) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 300) .channel(NioSocketChannel.class) .handler(new LoggingHandler()); ChannelFuture future = bootstrap.connect("127.0.0.1", 8080); future.sync().channel().closeFuture().sync(); // 断点1 } catch (Exception e) { e.printStackTrace(); log.debug("timeout"); } finally { group.shutdownGracefully(); } } } ``` 另外源码部分 `io.netty.channel.nio.AbstractNioChannel.AbstractNioUnsafe#connect` ```java @Override public final void connect( final SocketAddress remoteAddress, final SocketAddress localAddress, final ChannelPromise promise) { // ... // Schedule connect timeout. int connectTimeoutMillis = config().getConnectTimeoutMillis(); if (connectTimeoutMillis > 0) { connectTimeoutFuture = eventLoop().schedule(new Runnable() { @Override public void run() { ChannelPromise connectPromise = AbstractNioChannel.this.connectPromise; ConnectTimeoutException cause = new ConnectTimeoutException("connection timed out: " + remoteAddress); // 断点2 if (connectPromise != null && connectPromise.tryFailure(cause)) { close(voidPromise()); } } }, connectTimeoutMillis, TimeUnit.MILLISECONDS); } // ... } ``` #### 2)SO\_BACKLOG * 属于 ServerSocketChannal 参数 ```mermaid sequenceDiagram participant c as client participant s as server participant sq as syns queue participant aq as accept queue s ->> s : bind() s ->> s : listen() c ->> c : connect() c ->> s : 1. SYN Note left of c : SYN_SEND s ->> sq : put Note right of s : SYN_RCVD s ->> c : 2. SYN + ACK Note left of c : ESTABLISHED c ->> s : 3. ACK sq ->> aq : put Note right of s : ESTABLISHED aq -->> s : s ->> s : accept() ``` 1. 第一次握手,client 发送 SYN 到 server,状态修改为 SYN\_SEND,server 收到,状态改变为 SYN\_REVD,并将该请求放入 sync queue 队列 2. 第二次握手,server 回复 SYN + ACK 给 client,client 收到,状态改变为 ESTABLISHED,并发送 ACK 给 server 3. 第三次握手,server 收到 ACK,状态改变为 ESTABLISHED,将该请求从 sync queue 放入 accept queue 其中 * 在 linux 2.2 之前,backlog 大小包括了两个队列的大小,在 2.2 之后,分别用下面两个参数来控制 * sync queue - 半连接队列 * 大小通过 /proc/sys/net/ipv4/tcp\_max\_syn\_backlog 指定,在 `syncookies` 启用的情况下,逻辑上没有最大值限制,这个设置便被忽略 * accept queue - 全连接队列 * 其大小通过 /proc/sys/net/core/somaxconn 指定,在使用 listen 函数时,内核会根据传入的 backlog 参数与系统参数,取二者的较小值 * 如果 accpet queue 队列满了,server 将发送一个拒绝连接的错误信息到 client netty 中 可以通过 option(ChannelOption.SO\_BACKLOG, 值) 来设置大小 可以通过下面源码查看默认大小 ```java public class DefaultServerSocketChannelConfig extends DefaultChannelConfig implements ServerSocketChannelConfig { private volatile int backlog = NetUtil.SOMAXCONN; // ... } ``` 课堂调试关键断点为:`io.netty.channel.nio.NioEventLoop#processSelectedKey` oio 中更容易说明,不用 debug 模式 ```java public class Server { public static void main(String[] args) throws IOException { ServerSocket ss = new ServerSocket(8888, 2); Socket accept = ss.accept(); System.out.println(accept); System.in.read(); } } ``` 客户端启动 4 个 ```java public class Client { public static void main(String[] args) throws IOException { try { Socket s = new Socket(); System.out.println(new Date()+" connecting..."); s.connect(new InetSocketAddress("localhost", 8888),1000); System.out.println(new Date()+" connected..."); s.getOutputStream().write(1); System.in.read(); } catch (IOException e) { System.out.println(new Date()+" connecting timeout..."); e.printStackTrace(); } } } ``` 第 1,2,3 个客户端都打印,但除了第一个处于 accpet 外,其它两个都处于 accept queue 中 ```java Tue Apr 21 20:30:28 CST 2020 connecting... Tue Apr 21 20:30:28 CST 2020 connected... ``` 第 4 个客户端连接时 ``` Tue Apr 21 20:53:58 CST 2020 connecting... Tue Apr 21 20:53:59 CST 2020 connecting timeout... java.net.SocketTimeoutException: connect timed out ``` #### 3)ulimit -n * 属于操作系统参数 #### 4)TCP\_NODELAY * 属于 SocketChannal 参数 #### 5)SO\_SNDBUF & SO\_RCVBUF * SO\_SNDBUF 属于 SocketChannal 参数 * SO\_RCVBUF 既可用于 SocketChannal 参数,也可以用于 ServerSocketChannal 参数(建议设置到 ServerSocketChannal 上) #### 6)ALLOCATOR * 属于 SocketChannal 参数 * 用来分配 ByteBuf, ctx.alloc() #### 7)RCVBUF\_ALLOCATOR * 属于 SocketChannal 参数 * 控制 netty 接收缓冲区大小 * 负责入站数据的分配,决定入站缓冲区的大小(并可动态调整),统一采用 direct 直接内存,具体池化还是非池化由 allocator 决定 ### 1.3 RPC 框架 #### 1)准备工作 这些代码可以认为是现成的,无需从头编写练习 为了简化起见,在原来聊天项目的基础上新增 Rpc 请求和响应消息 ```java @Data public abstract class Message implements Serializable { // 省略旧的代码 public static final int RPC_MESSAGE_TYPE_REQUEST = 101; public static final int RPC_MESSAGE_TYPE_RESPONSE = 102; static { // ... messageClasses.put(RPC_MESSAGE_TYPE_REQUEST, RpcRequestMessage.class); messageClasses.put(RPC_MESSAGE_TYPE_RESPONSE, RpcResponseMessage.class); } } ``` 请求消息 ```java @Getter @ToString(callSuper = true) public class RpcRequestMessage extends Message { /** * 调用的接口全限定名,服务端根据它找到实现 */ private String interfaceName; /** * 调用接口中的方法名 */ private String methodName; /** * 方法返回类型 */ private Class returnType; /** * 方法参数类型数组 */ private Class[] parameterTypes; /** * 方法参数值数组 */ private Object[] parameterValue; public RpcRequestMessage(int sequenceId, String interfaceName, String methodName, Class returnType, Class[] parameterTypes, Object[] parameterValue) { super.setSequenceId(sequenceId); this.interfaceName = interfaceName; this.methodName = methodName; this.returnType = returnType; this.parameterTypes = parameterTypes; this.parameterValue = parameterValue; } @Override public int getMessageType() { return RPC_MESSAGE_TYPE_REQUEST; } } ``` 响应消息 ```java @Data @ToString(callSuper = true) public class RpcResponseMessage extends Message { /** * 返回值 */ private Object returnValue; /** * 异常值 */ private Exception exceptionValue; @Override public int getMessageType() { return RPC_MESSAGE_TYPE_RESPONSE; } } ``` 服务器架子 ```java @Slf4j public class RpcServer { public static void main(String[] args) { NioEventLoopGroup boss = new NioEventLoopGroup(); NioEventLoopGroup worker = new NioEventLoopGroup(); LoggingHandler LOGGING_HANDLER = new LoggingHandler(LogLevel.DEBUG); MessageCodecSharable MESSAGE_CODEC = new MessageCodecSharable(); // rpc 请求消息处理器,待实现 RpcRequestMessageHandler RPC_HANDLER = new RpcRequestMessageHandler(); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.channel(NioServerSocketChannel.class); serverBootstrap.group(boss, worker); serverBootstrap.childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new ProcotolFrameDecoder()); ch.pipeline().addLast(LOGGING_HANDLER); ch.pipeline().addLast(MESSAGE_CODEC); ch.pipeline().addLast(RPC_HANDLER); } }); Channel channel = serverBootstrap.bind(8080).sync().channel(); channel.closeFuture().sync(); } catch (InterruptedException e) { log.error("server error", e); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); } } } ``` 客户端架子 ```java public class RpcClient { public static void main(String[] args) { NioEventLoopGroup group = new NioEventLoopGroup(); LoggingHandler LOGGING_HANDLER = new LoggingHandler(LogLevel.DEBUG); MessageCodecSharable MESSAGE_CODEC = new MessageCodecSharable(); // rpc 响应消息处理器,待实现 RpcResponseMessageHandler RPC_HANDLER = new RpcResponseMessageHandler(); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.channel(NioSocketChannel.class); bootstrap.group(group); bootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new ProcotolFrameDecoder()); ch.pipeline().addLast(LOGGING_HANDLER); ch.pipeline().addLast(MESSAGE_CODEC); ch.pipeline().addLast(RPC_HANDLER); } }); Channel channel = bootstrap.connect("localhost", 8080).sync().channel(); channel.closeFuture().sync(); } catch (Exception e) { log.error("client error", e); } finally { group.shutdownGracefully(); } } } ``` 服务器端的 service 获取 ```java public class ServicesFactory { static Properties properties; static Map, Object> map = new ConcurrentHashMap<>(); static { try (InputStream in = Config.class.getResourceAsStream("/application.properties")) { properties = new Properties(); properties.load(in); Set names = properties.stringPropertyNames(); for (String name : names) { if (name.endsWith("Service")) { Class interfaceClass = Class.forName(name); Class instanceClass = Class.forName(properties.getProperty(name)); map.put(interfaceClass, instanceClass.newInstance()); } } } catch (IOException | ClassNotFoundException | InstantiationException | IllegalAccessException e) { throw new ExceptionInInitializerError(e); } } public static T getService(Class interfaceClass) { return (T) map.get(interfaceClass); } } ``` 相关配置 application.properties ``` serializer.algorithm=Json cn.itcast.server.service.HelloService=cn.itcast.server.service.HelloServiceImpl ``` #### 2)服务器 handler ```java @Slf4j @ChannelHandler.Sharable public class RpcRequestMessageHandler extends SimpleChannelInboundHandler { @Override protected void channelRead0(ChannelHandlerContext ctx, RpcRequestMessage message) { RpcResponseMessage response = new RpcResponseMessage(); response.setSequenceId(message.getSequenceId()); try { // 获取真正的实现对象 HelloService service = (HelloService) ServicesFactory.getService(Class.forName(message.getInterfaceName())); // 获取要调用的方法 Method method = service.getClass().getMethod(message.getMethodName(), message.getParameterTypes()); // 调用方法 Object invoke = method.invoke(service, message.getParameterValue()); // 调用成功 response.setReturnValue(invoke); } catch (Exception e) { e.printStackTrace(); // 调用异常 response.setExceptionValue(e); } // 返回结果 ctx.writeAndFlush(response); } } ``` #### 3)客户端代码第一版 只发消息 ```java @Slf4j public class RpcClient { public static void main(String[] args) { NioEventLoopGroup group = new NioEventLoopGroup(); LoggingHandler LOGGING_HANDLER = new LoggingHandler(LogLevel.DEBUG); MessageCodecSharable MESSAGE_CODEC = new MessageCodecSharable(); RpcResponseMessageHandler RPC_HANDLER = new RpcResponseMessageHandler(); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.channel(NioSocketChannel.class); bootstrap.group(group); bootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new ProcotolFrameDecoder()); ch.pipeline().addLast(LOGGING_HANDLER); ch.pipeline().addLast(MESSAGE_CODEC); ch.pipeline().addLast(RPC_HANDLER); } }); Channel channel = bootstrap.connect("localhost", 8080).sync().channel(); ChannelFuture future = channel.writeAndFlush(new RpcRequestMessage( 1, "cn.itcast.server.service.HelloService", "sayHello", String.class, new Class[]{String.class}, new Object[]{"张三"} )).addListener(promise -> { if (!promise.isSuccess()) { Throwable cause = promise.cause(); log.error("error", cause); } }); channel.closeFuture().sync(); } catch (Exception e) { log.error("client error", e); } finally { group.shutdownGracefully(); } } } ``` #### 4)客户端 handler 第一版 ```java @Slf4j @ChannelHandler.Sharable public class RpcResponseMessageHandler extends SimpleChannelInboundHandler { @Override protected void channelRead0(ChannelHandlerContext ctx, RpcResponseMessage msg) throws Exception { log.debug("{}", msg); } } ``` #### 5)客户端代码 第二版 包括 channel 管理,代理,接收结果 ```java @Slf4j public class RpcClientManager { public static void main(String[] args) { HelloService service = getProxyService(HelloService.class); System.out.println(service.sayHello("zhangsan")); // System.out.println(service.sayHello("lisi")); // System.out.println(service.sayHello("wangwu")); } // 创建代理类 public static T getProxyService(Class serviceClass) { ClassLoader loader = serviceClass.getClassLoader(); Class[] interfaces = new Class[]{serviceClass}; // sayHello "张三" Object o = Proxy.newProxyInstance(loader, interfaces, (proxy, method, args) -> { // 1. 将方法调用转换为 消息对象 int sequenceId = SequenceIdGenerator.nextId(); RpcRequestMessage msg = new RpcRequestMessage( sequenceId, serviceClass.getName(), method.getName(), method.getReturnType(), method.getParameterTypes(), args ); // 2. 将消息对象发送出去 getChannel().writeAndFlush(msg); // 3. 准备一个空 Promise 对象,来接收结果 指定 promise 对象异步接收结果线程 DefaultPromise promise = new DefaultPromise<>(getChannel().eventLoop()); RpcResponseMessageHandler.PROMISES.put(sequenceId, promise); // promise.addListener(future -> { // // 线程 // }); // 4. 等待 promise 结果 promise.await(); if(promise.isSuccess()) { // 调用正常 return promise.getNow(); } else { // 调用失败 throw new RuntimeException(promise.cause()); } }); return (T) o; } private static Channel channel = null; private static final Object LOCK = new Object(); // 获取唯一的 channel 对象 public static Channel getChannel() { if (channel != null) { return channel; } synchronized (LOCK) { // t2 if (channel != null) { // t1 return channel; } initChannel(); return channel; } } // 初始化 channel 方法 private static void initChannel() { NioEventLoopGroup group = new NioEventLoopGroup(); LoggingHandler LOGGING_HANDLER = new LoggingHandler(LogLevel.DEBUG); MessageCodecSharable MESSAGE_CODEC = new MessageCodecSharable(); RpcResponseMessageHandler RPC_HANDLER = new RpcResponseMessageHandler(); Bootstrap bootstrap = new Bootstrap(); bootstrap.channel(NioSocketChannel.class); bootstrap.group(group); bootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new ProcotolFrameDecoder()); ch.pipeline().addLast(LOGGING_HANDLER); ch.pipeline().addLast(MESSAGE_CODEC); ch.pipeline().addLast(RPC_HANDLER); } }); try { channel = bootstrap.connect("localhost", 8080).sync().channel(); channel.closeFuture().addListener(future -> { group.shutdownGracefully(); }); } catch (Exception e) { log.error("client error", e); } } } ``` #### 6)客户端 handler 第二版 ```java @Slf4j @ChannelHandler.Sharable public class RpcResponseMessageHandler extends SimpleChannelInboundHandler { // 序号 用来接收结果的 promise 对象 public static final Map> PROMISES = new ConcurrentHashMap<>(); @Override protected void channelRead0(ChannelHandlerContext ctx, RpcResponseMessage msg) throws Exception { log.debug("{}", msg); // 拿到空的 promise Promise promise = PROMISES.remove(msg.getSequenceId()); if (promise != null) { Object returnValue = msg.getReturnValue(); Exception exceptionValue = msg.getExceptionValue(); if(exceptionValue != null) { promise.setFailure(exceptionValue); } else { promise.setSuccess(returnValue); } } } } ``` ## 2. 源码分析 ### 2.1 启动剖析 我们就来看看 netty 中对下面的代码是怎样进行处理的 ```java //1 netty 中使用 NioEventLoopGroup (简称 nio boss 线程)来封装线程和 selector Selector selector = Selector.open(); //2 创建 NioServerSocketChannel,同时会初始化它关联的 handler,以及为原生 ssc 存储 config NioServerSocketChannel attachment = new NioServerSocketChannel(); //3 创建 NioServerSocketChannel 时,创建了 java 原生的 ServerSocketChannel ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.configureBlocking(false); //4 启动 nio boss 线程执行接下来的操作 //5 注册(仅关联 selector 和 NioServerSocketChannel),未关注事件 SelectionKey selectionKey = serverSocketChannel.register(selector, 0, attachment); //6 head -> 初始化器 -> ServerBootstrapAcceptor -> tail,初始化器是一次性的,只为添加 acceptor //7 绑定端口 serverSocketChannel.bind(new InetSocketAddress(8080)); //8 触发 channel active 事件,在 head 中关注 op_accept 事件 selectionKey.interestOps(SelectionKey.OP_ACCEPT); ``` 入口 `io.netty.bootstrap.ServerBootstrap#bind` 关键代码 `io.netty.bootstrap.AbstractBootstrap#doBind` ```java private ChannelFuture doBind(final SocketAddress localAddress) { // 1. 执行初始化和注册 regFuture 会由 initAndRegister 设置其是否完成,从而回调 3.2 处代码 final ChannelFuture regFuture = initAndRegister(); final Channel channel = regFuture.channel(); if (regFuture.cause() != null) { return regFuture; } // 2. 因为是 initAndRegister 异步执行,需要分两种情况来看,调试时也需要通过 suspend 断点类型加以区分 // 2.1 如果已经完成 if (regFuture.isDone()) { ChannelPromise promise = channel.newPromise(); // 3.1 立刻调用 doBind0 doBind0(regFuture, channel, localAddress, promise); return promise; } // 2.2 还没有完成 else { final PendingRegistrationPromise promise = new PendingRegistrationPromise(channel); // 3.2 回调 doBind0 regFuture.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { Throwable cause = future.cause(); if (cause != null) { // 处理异常... promise.setFailure(cause); } else { promise.registered(); // 3. 由注册线程去执行 doBind0 doBind0(regFuture, channel, localAddress, promise); } } }); return promise; } } ``` 关键代码 `io.netty.bootstrap.AbstractBootstrap#initAndRegister` ```java final ChannelFuture initAndRegister() { Channel channel = null; try { channel = channelFactory.newChannel(); // 1.1 初始化 - 做的事就是添加一个初始化器 ChannelInitializer init(channel); } catch (Throwable t) { // 处理异常... return new DefaultChannelPromise(new FailedChannel(), GlobalEventExecutor.INSTANCE).setFailure(t); } // 1.2 注册 - 做的事就是将原生 channel 注册到 selector 上 ChannelFuture regFuture = config().group().register(channel); if (regFuture.cause() != null) { // 处理异常... } return regFuture; } ``` 关键代码 `io.netty.bootstrap.ServerBootstrap#init` ```java // 这里 channel 实际上是 NioServerSocketChannel void init(Channel channel) throws Exception { final Map, Object> options = options0(); synchronized (options) { setChannelOptions(channel, options, logger); } final Map, Object> attrs = attrs0(); synchronized (attrs) { for (Entry, Object> e: attrs.entrySet()) { @SuppressWarnings("unchecked") AttributeKey key = (AttributeKey) e.getKey(); channel.attr(key).set(e.getValue()); } } ChannelPipeline p = channel.pipeline(); final EventLoopGroup currentChildGroup = childGroup; final ChannelHandler currentChildHandler = childHandler; final Entry, Object>[] currentChildOptions; final Entry, Object>[] currentChildAttrs; synchronized (childOptions) { currentChildOptions = childOptions.entrySet().toArray(newOptionArray(0)); } synchronized (childAttrs) { currentChildAttrs = childAttrs.entrySet().toArray(newAttrArray(0)); } // 为 NioServerSocketChannel 添加初始化器 p.addLast(new ChannelInitializer() { @Override public void initChannel(final Channel ch) throws Exception { final ChannelPipeline pipeline = ch.pipeline(); ChannelHandler handler = config.handler(); if (handler != null) { pipeline.addLast(handler); } // 初始化器的职责是将 ServerBootstrapAcceptor 加入至 NioServerSocketChannel ch.eventLoop().execute(new Runnable() { @Override public void run() { pipeline.addLast(new ServerBootstrapAcceptor( ch, currentChildGroup, currentChildHandler, currentChildOptions, currentChildAttrs)); } }); } }); } ``` 关键代码 `io.netty.channel.AbstractChannel.AbstractUnsafe#register` ```java public final void register(EventLoop eventLoop, final ChannelPromise promise) { // 一些检查,略... AbstractChannel.this.eventLoop = eventLoop; if (eventLoop.inEventLoop()) { register0(promise); } else { try { // 首次执行 execute 方法时,会启动 nio 线程,之后注册等操作在 nio 线程上执行 // 因为只有一个 NioServerSocketChannel 因此,也只会有一个 boss nio 线程 // 这行代码完成的事实是 main -> nio boss 线程的切换 eventLoop.execute(new Runnable() { @Override public void run() { register0(promise); } }); } catch (Throwable t) { // 日志记录... closeForcibly(); closeFuture.setClosed(); safeSetFailure(promise, t); } } } ``` `io.netty.channel.AbstractChannel.AbstractUnsafe#register0` ```java private void register0(ChannelPromise promise) { try { if (!promise.setUncancellable() || !ensureOpen(promise)) { return; } boolean firstRegistration = neverRegistered; // 1.2.1 原生的 nio channel 绑定到 selector 上,注意此时没有注册 selector 关注事件,附件为 NioServerSocketChannel doRegister(); neverRegistered = false; registered = true; // 1.2.2 执行 NioServerSocketChannel 初始化器的 initChannel pipeline.invokeHandlerAddedIfNeeded(); // 回调 3.2 io.netty.bootstrap.AbstractBootstrap#doBind0 safeSetSuccess(promise); pipeline.fireChannelRegistered(); // 对应 server socket channel 还未绑定,isActive 为 false if (isActive()) { if (firstRegistration) { pipeline.fireChannelActive(); } else if (config().isAutoRead()) { beginRead(); } } } catch (Throwable t) { // Close the channel directly to avoid FD leak. closeForcibly(); closeFuture.setClosed(); safeSetFailure(promise, t); } } ``` 关键代码 `io.netty.channel.ChannelInitializer#initChannel` ```java private boolean initChannel(ChannelHandlerContext ctx) throws Exception { if (initMap.add(ctx)) { // Guard against re-entrance. try { // 1.2.2.1 执行初始化 initChannel((C) ctx.channel()); } catch (Throwable cause) { exceptionCaught(ctx, cause); } finally { // 1.2.2.2 移除初始化器 ChannelPipeline pipeline = ctx.pipeline(); if (pipeline.context(this) != null) { pipeline.remove(this); } } return true; } return false; } ``` 关键代码 `io.netty.bootstrap.AbstractBootstrap#doBind0` ```java // 3.1 或 3.2 执行 doBind0 private static void doBind0( final ChannelFuture regFuture, final Channel channel, final SocketAddress localAddress, final ChannelPromise promise) { channel.eventLoop().execute(new Runnable() { @Override public void run() { if (regFuture.isSuccess()) { channel.bind(localAddress, promise).addListener(ChannelFutureListener.CLOSE_ON_FAILURE); } else { promise.setFailure(regFuture.cause()); } } }); } ``` 关键代码 `io.netty.channel.AbstractChannel.AbstractUnsafe#bind` ```java public final void bind(final SocketAddress localAddress, final ChannelPromise promise) { assertEventLoop(); if (!promise.setUncancellable() || !ensureOpen(promise)) { return; } if (Boolean.TRUE.equals(config().getOption(ChannelOption.SO_BROADCAST)) && localAddress instanceof InetSocketAddress && !((InetSocketAddress) localAddress).getAddress().isAnyLocalAddress() && !PlatformDependent.isWindows() && !PlatformDependent.maybeSuperUser()) { // 记录日志... } boolean wasActive = isActive(); try { // 3.3 执行端口绑定 doBind(localAddress); } catch (Throwable t) { safeSetFailure(promise, t); closeIfClosed(); return; } if (!wasActive && isActive()) { invokeLater(new Runnable() { @Override public void run() { // 3.4 触发 active 事件 pipeline.fireChannelActive(); } }); } safeSetSuccess(promise); } ``` 3.3 关键代码 `io.netty.channel.socket.nio.NioServerSocketChannel#doBind` ```java protected void doBind(SocketAddress localAddress) throws Exception { if (PlatformDependent.javaVersion() >= 7) { javaChannel().bind(localAddress, config.getBacklog()); } else { javaChannel().socket().bind(localAddress, config.getBacklog()); } } ``` 3.4 关键代码 `io.netty.channel.DefaultChannelPipeline.HeadContext#channelActive` ```java public void channelActive(ChannelHandlerContext ctx) { ctx.fireChannelActive(); // 触发 read (NioServerSocketChannel 上的 read 不是读取数据,只是为了触发 channel 的事件注册) readIfIsAutoRead(); } ``` 关键代码 `io.netty.channel.nio.AbstractNioChannel#doBeginRead` ```java protected void doBeginRead() throws Exception { // Channel.read() or ChannelHandlerContext.read() was called final SelectionKey selectionKey = this.selectionKey; if (!selectionKey.isValid()) { return; } readPending = true; final int interestOps = selectionKey.interestOps(); // readInterestOp 取值是 16,在 NioServerSocketChannel 创建时初始化好,代表关注 accept 事件 if ((interestOps & readInterestOp) == 0) { selectionKey.interestOps(interestOps | readInterestOp); } } ``` ### 2.2 NioEventLoop 剖析 NioEventLoop 线程不仅要处理 IO 事件,还要处理 Task(包括普通任务和定时任务), 提交任务代码 `io.netty.util.concurrent.SingleThreadEventExecutor#execute` ```java public void execute(Runnable task) { if (task == null) { throw new NullPointerException("task"); } boolean inEventLoop = inEventLoop(); // 添加任务,其中队列使用了 jctools 提供的 mpsc 无锁队列 addTask(task); if (!inEventLoop) { // inEventLoop 如果为 false 表示由其它线程来调用 execute,即首次调用,这时需要向 eventLoop 提交首个任务,启动死循环,会执行到下面的 doStartThread startThread(); if (isShutdown()) { // 如果已经 shutdown,做拒绝逻辑,代码略... } } if (!addTaskWakesUp && wakesUpForTask(task)) { // 如果线程由于 IO select 阻塞了,添加的任务的线程需要负责唤醒 NioEventLoop 线程 wakeup(inEventLoop); } } ``` 唤醒 select 阻塞线程`io.netty.channel.nio.NioEventLoop#wakeup` ```java @Override protected void wakeup(boolean inEventLoop) { if (!inEventLoop && wakenUp.compareAndSet(false, true)) { selector.wakeup(); } } ``` 启动 EventLoop 主循环 `io.netty.util.concurrent.SingleThreadEventExecutor#doStartThread` ```java private void doStartThread() { assert thread == null; executor.execute(new Runnable() { @Override public void run() { // 将线程池的当前线程保存在成员变量中,以便后续使用 thread = Thread.currentThread(); if (interrupted) { thread.interrupt(); } boolean success = false; updateLastExecutionTime(); try { // 调用外部类 SingleThreadEventExecutor 的 run 方法,进入死循环,run 方法见下 SingleThreadEventExecutor.this.run(); success = true; } catch (Throwable t) { logger.warn("Unexpected exception from an event executor: ", t); } finally { // 清理工作,代码略... } } }); } ``` `io.netty.channel.nio.NioEventLoop#run` 主要任务是执行死循环,不断看有没有新任务,有没有 IO 事件 ```java protected void run() { for (;;) { try { try { // calculateStrategy 的逻辑如下: // 有任务,会执行一次 selectNow,清除上一次的 wakeup 结果,无论有没有 IO 事件,都会跳过 switch // 没有任务,会匹配 SelectStrategy.SELECT,看是否应当阻塞 switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) { case SelectStrategy.CONTINUE: continue; case SelectStrategy.BUSY_WAIT: case SelectStrategy.SELECT: // 因为 IO 线程和提交任务线程都有可能执行 wakeup,而 wakeup 属于比较昂贵的操作,因此使用了一个原子布尔对象 wakenUp,它取值为 true 时,表示该由当前线程唤醒 // 进行 select 阻塞,并设置唤醒状态为 false boolean oldWakenUp = wakenUp.getAndSet(false); // 如果在这个位置,非 EventLoop 线程抢先将 wakenUp 置为 true,并 wakeup // 下面的 select 方法不会阻塞 // 等 runAllTasks 处理完成后,到再循环进来这个阶段新增的任务会不会及时执行呢? // 因为 oldWakenUp 为 true,因此下面的 select 方法就会阻塞,直到超时 // 才能执行,让 select 方法无谓阻塞 select(oldWakenUp); if (wakenUp.get()) { selector.wakeup(); } default: } } catch (IOException e) { rebuildSelector0(); handleLoopException(e); continue; } cancelledKeys = 0; needsToSelectAgain = false; // ioRatio 默认是 50 final int ioRatio = this.ioRatio; if (ioRatio == 100) { try { processSelectedKeys(); } finally { // ioRatio 为 100 时,总是运行完所有非 IO 任务 runAllTasks(); } } else { final long ioStartTime = System.nanoTime(); try { processSelectedKeys(); } finally { // 记录 io 事件处理耗时 final long ioTime = System.nanoTime() - ioStartTime; // 运行非 IO 任务,一旦超时会退出 runAllTasks runAllTasks(ioTime * (100 - ioRatio) / ioRatio); } } } catch (Throwable t) { handleLoopException(t); } try { if (isShuttingDown()) { closeAll(); if (confirmShutdown()) { return; } } } catch (Throwable t) { handleLoopException(t); } } } ``` #### ⚠️ 注意 > 这里有个费解的地方就是 wakeup,它既可以由提交任务的线程来调用(比较好理解),也可以由 EventLoop 线程来调用(比较费解),这里要知道 wakeup 方法的效果: > > * 由非 EventLoop 线程调用,会唤醒当前在执行 select 阻塞的 EventLoop 线程 > * 由 EventLoop 自己调用,会本次的 wakeup 会取消下一次的 select 操作 参考下图 `io.netty.channel.nio.NioEventLoop#select` ```java private void select(boolean oldWakenUp) throws IOException { Selector selector = this.selector; try { int selectCnt = 0; long currentTimeNanos = System.nanoTime(); // 计算等待时间 // * 没有 scheduledTask,超时时间为 1s // * 有 scheduledTask,超时时间为 `下一个定时任务执行时间 - 当前时间` long selectDeadLineNanos = currentTimeNanos + delayNanos(currentTimeNanos); for (;;) { long timeoutMillis = (selectDeadLineNanos - currentTimeNanos + 500000L) / 1000000L; // 如果超时,退出循环 if (timeoutMillis <= 0) { if (selectCnt == 0) { selector.selectNow(); selectCnt = 1; } break; } // 如果期间又有 task 退出循环,如果没这个判断,那么任务就会等到下次 select 超时时才能被执行 // wakenUp.compareAndSet(false, true) 是让非 NioEventLoop 不必再执行 wakeup if (hasTasks() && wakenUp.compareAndSet(false, true)) { selector.selectNow(); selectCnt = 1; break; } // select 有限时阻塞 // 注意 nio 有 bug,当 bug 出现时,select 方法即使没有时间发生,也不会阻塞住,导致不断空轮询,cpu 占用 100% int selectedKeys = selector.select(timeoutMillis); // 计数加 1 selectCnt ++; // 醒来后,如果有 IO 事件、或是由非 EventLoop 线程唤醒,或者有任务,退出循环 if (selectedKeys != 0 || oldWakenUp || wakenUp.get() || hasTasks() || hasScheduledTasks()) { break; } if (Thread.interrupted()) { // 线程被打断,退出循环 // 记录日志 selectCnt = 1; break; } long time = System.nanoTime(); if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) { // 如果超时,计数重置为 1,下次循环就会 break selectCnt = 1; } // 计数超过阈值,由 io.netty.selectorAutoRebuildThreshold 指定,默认 512 // 这是为了解决 nio 空轮询 bug else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 && selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) { // 重建 selector selector = selectRebuildSelector(selectCnt); selectCnt = 1; break; } currentTimeNanos = time; } if (selectCnt > MIN_PREMATURE_SELECTOR_RETURNS) { // 记录日志 } } catch (CancelledKeyException e) { // 记录日志 } } ``` 处理 keys `io.netty.channel.nio.NioEventLoop#processSelectedKeys` ```java private void processSelectedKeys() { if (selectedKeys != null) { // 通过反射将 Selector 实现类中的就绪事件集合替换为 SelectedSelectionKeySet // SelectedSelectionKeySet 底层为数组实现,可以提高遍历性能(原本为 HashSet) processSelectedKeysOptimized(); } else { processSelectedKeysPlain(selector.selectedKeys()); } } ``` `io.netty.channel.nio.NioEventLoop#processSelectedKey` ```java private void processSelectedKey(SelectionKey k, AbstractNioChannel ch) { final AbstractNioChannel.NioUnsafe unsafe = ch.unsafe(); // 当 key 取消或关闭时会导致这个 key 无效 if (!k.isValid()) { // 无效时处理... return; } try { int readyOps = k.readyOps(); // 连接事件 if ((readyOps & SelectionKey.OP_CONNECT) != 0) { int ops = k.interestOps(); ops &= ~SelectionKey.OP_CONNECT; k.interestOps(ops); unsafe.finishConnect(); } // 可写事件 if ((readyOps & SelectionKey.OP_WRITE) != 0) { ch.unsafe().forceFlush(); } // 可读或可接入事件 if ((readyOps & (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT)) != 0 || readyOps == 0) { // 如果是可接入 io.netty.channel.nio.AbstractNioMessageChannel.NioMessageUnsafe#read // 如果是可读 io.netty.channel.nio.AbstractNioByteChannel.NioByteUnsafe#read unsafe.read(); } } catch (CancelledKeyException ignored) { unsafe.close(unsafe.voidPromise()); } } ``` ### 2.3 accept 剖析 nio 中如下代码,在 netty 中的流程 ```java //1 阻塞直到事件发生 selector.select(); Iterator iter = selector.selectedKeys().iterator(); while (iter.hasNext()) { //2 拿到一个事件 SelectionKey key = iter.next(); //3 如果是 accept 事件 if (key.isAcceptable()) { //4 执行 accept SocketChannel channel = serverSocketChannel.accept(); channel.configureBlocking(false); //5 关注 read 事件 channel.register(selector, SelectionKey.OP_READ); } // ... } ``` 先来看可接入事件处理(accept) `io.netty.channel.nio.AbstractNioMessageChannel.NioMessageUnsafe#read` ```java public void read() { assert eventLoop().inEventLoop(); final ChannelConfig config = config(); final ChannelPipeline pipeline = pipeline(); final RecvByteBufAllocator.Handle allocHandle = unsafe().recvBufAllocHandle(); allocHandle.reset(config); boolean closed = false; Throwable exception = null; try { try { do { // doReadMessages 中执行了 accept 并创建 NioSocketChannel 作为消息放入 readBuf // readBuf 是一个 ArrayList 用来缓存消息 int localRead = doReadMessages(readBuf); if (localRead == 0) { break; } if (localRead < 0) { closed = true; break; } // localRead 为 1,就一条消息,即接收一个客户端连接 allocHandle.incMessagesRead(localRead); } while (allocHandle.continueReading()); } catch (Throwable t) { exception = t; } int size = readBuf.size(); for (int i = 0; i < size; i ++) { readPending = false; // 触发 read 事件,让 pipeline 上的 handler 处理,这时是处理 // io.netty.bootstrap.ServerBootstrap.ServerBootstrapAcceptor#channelRead pipeline.fireChannelRead(readBuf.get(i)); } readBuf.clear(); allocHandle.readComplete(); pipeline.fireChannelReadComplete(); if (exception != null) { closed = closeOnReadError(exception); pipeline.fireExceptionCaught(exception); } if (closed) { inputShutdown = true; if (isOpen()) { close(voidPromise()); } } } finally { if (!readPending && !config.isAutoRead()) { removeReadOp(); } } } ``` 关键代码 `io.netty.bootstrap.ServerBootstrap.ServerBootstrapAcceptor#channelRead` ```java public void channelRead(ChannelHandlerContext ctx, Object msg) { // 这时的 msg 是 NioSocketChannel final Channel child = (Channel) msg; // NioSocketChannel 添加 childHandler 即初始化器 child.pipeline().addLast(childHandler); // 设置选项 setChannelOptions(child, childOptions, logger); for (Entry, Object> e: childAttrs) { child.attr((AttributeKey) e.getKey()).set(e.getValue()); } try { // 注册 NioSocketChannel 到 nio worker 线程,接下来的处理也移交至 nio worker 线程 childGroup.register(child).addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { if (!future.isSuccess()) { forceClose(child, future.cause()); } } }); } catch (Throwable t) { forceClose(child, t); } } ``` 又回到了熟悉的 `io.netty.channel.AbstractChannel.AbstractUnsafe#register` 方法 ```java public final void register(EventLoop eventLoop, final ChannelPromise promise) { // 一些检查,略... AbstractChannel.this.eventLoop = eventLoop; if (eventLoop.inEventLoop()) { register0(promise); } else { try { // 这行代码完成的事实是 nio boss -> nio worker 线程的切换 eventLoop.execute(new Runnable() { @Override public void run() { register0(promise); } }); } catch (Throwable t) { // 日志记录... closeForcibly(); closeFuture.setClosed(); safeSetFailure(promise, t); } } } ``` `io.netty.channel.AbstractChannel.AbstractUnsafe#register0` ```java private void register0(ChannelPromise promise) { try { if (!promise.setUncancellable() || !ensureOpen(promise)) { return; } boolean firstRegistration = neverRegistered; doRegister(); neverRegistered = false; registered = true; // 执行初始化器,执行前 pipeline 中只有 head -> 初始化器 -> tail pipeline.invokeHandlerAddedIfNeeded(); // 执行后就是 head -> logging handler -> my handler -> tail safeSetSuccess(promise); pipeline.fireChannelRegistered(); if (isActive()) { if (firstRegistration) { // 触发 pipeline 上 active 事件 pipeline.fireChannelActive(); } else if (config().isAutoRead()) { beginRead(); } } } catch (Throwable t) { closeForcibly(); closeFuture.setClosed(); safeSetFailure(promise, t); } } ``` 回到了熟悉的代码 `io.netty.channel.DefaultChannelPipeline.HeadContext#channelActive` ```java public void channelActive(ChannelHandlerContext ctx) { ctx.fireChannelActive(); // 触发 read (NioSocketChannel 这里 read,只是为了触发 channel 的事件注册,还未涉及数据读取) readIfIsAutoRead(); } ``` `io.netty.channel.nio.AbstractNioChannel#doBeginRead` ```java protected void doBeginRead() throws Exception { // Channel.read() or ChannelHandlerContext.read() was called final SelectionKey selectionKey = this.selectionKey; if (!selectionKey.isValid()) { return; } readPending = true; // 这时候 interestOps 是 0 final int interestOps = selectionKey.interestOps(); if ((interestOps & readInterestOp) == 0) { // 关注 read 事件 selectionKey.interestOps(interestOps | readInterestOp); } } ``` ### 2.4 read 剖析 再来看可读事件 `io.netty.channel.nio.AbstractNioByteChannel.NioByteUnsafe#read`,注意发送的数据未必能够一次读完,因此会触发多次 nio read 事件,一次事件内会触发多次 pipeline read,一次事件会触发一次 pipeline read complete ```java public final void read() { final ChannelConfig config = config(); if (shouldBreakReadReady(config)) { clearReadPending(); return; } final ChannelPipeline pipeline = pipeline(); // io.netty.allocator.type 决定 allocator 的实现 final ByteBufAllocator allocator = config.getAllocator(); // 用来分配 byteBuf,确定单次读取大小 final RecvByteBufAllocator.Handle allocHandle = recvBufAllocHandle(); allocHandle.reset(config); ByteBuf byteBuf = null; boolean close = false; try { do { byteBuf = allocHandle.allocate(allocator); // 读取 allocHandle.lastBytesRead(doReadBytes(byteBuf)); if (allocHandle.lastBytesRead() <= 0) { byteBuf.release(); byteBuf = null; close = allocHandle.lastBytesRead() < 0; if (close) { readPending = false; } break; } allocHandle.incMessagesRead(1); readPending = false; // 触发 read 事件,让 pipeline 上的 handler 处理,这时是处理 NioSocketChannel 上的 handler pipeline.fireChannelRead(byteBuf); byteBuf = null; } // 是否要继续循环 while (allocHandle.continueReading()); allocHandle.readComplete(); // 触发 read complete 事件 pipeline.fireChannelReadComplete(); if (close) { closeOnRead(pipeline); } } catch (Throwable t) { handleReadException(pipeline, byteBuf, t, close, allocHandle); } finally { if (!readPending && !config.isAutoRead()) { removeReadOp(); } } } ``` `io.netty.channel.DefaultMaxMessagesRecvByteBufAllocator.MaxMessageHandle#continueReading(io.netty.util.UncheckedBooleanSupplier)` ```java public boolean continueReading(UncheckedBooleanSupplier maybeMoreDataSupplier) { return // 一般为 true config.isAutoRead() && // respectMaybeMoreData 默认为 true // maybeMoreDataSupplier 的逻辑是如果预期读取字节与实际读取字节相等,返回 true (!respectMaybeMoreData || maybeMoreDataSupplier.get()) && // 小于最大次数,maxMessagePerRead 默认 16 totalMessages < maxMessagePerRead && // 实际读到了数据 totalBytesRead > 0; } ``` --- --- url: /src/technology/go/go.md --- # Go [中文学习文档](https://golang.halfiisland.com/) --- --- url: /src/life/all.md --- --- --- url: /src/goodfood/all.md --- --- --- url: /src/goodfood/kao-rou/烤肉.md --- # 电饼铛烤肉,美汁汁儿 生菜包烤肉 ## 猪五花肉 三层肉精五花肉16一斤左右,买肥瘦相间的。冷冻一下,好切一些 ### 原味五花肉 切好片摆盘直接烤 ### 腌制五花肉 * 生抽少来一点,避免太咸,可以蘸干碟 抓拌均匀,腌制20~40分钟 ## 牛肉 优选雪花鲜牛肉,切片 * 生抽少来一点,避免太咸,可以蘸干碟 抓拌均匀,腌制20~40分钟 --- --- url: /src/goodfood/tang-lei/玉米排骨汤.md --- # 玉米排骨汤 玉米、胡萝卜、山药等切段 ## 焯水 排骨、小葱段、姜片、料酒冷水下锅焯水,撇去浮沫,捞出备用 ## 第一段 * 锅中倒入开水,焯水后的排骨、玉米、胡萝卜,姜片 * 沸腾后小火慢炖40分钟 ## 第二段 * 稍微多一点点(就一点点)盐和鸡精、山药、枸杞、红枣加入锅中 * 小火慢炖20分钟 ## 出锅 * 撒入葱花,ok --- --- url: /src/test/all.md --- --- --- url: /src/randomwriting/all.md --- --- --- url: >- /src/technology/dateblog/2025/05/20250526-alg(alloy-loki-grafana)现代轻量级的日志管理解决方案.md --- # ALG(Alloy + Loki + Grafana)现代轻量级的日志管理解决方案 Alloy + Loki + Grafana(简称 ALG)是一个现代轻量级的日志管理解决方案,适用于云原生环境和大规模分布式系统。以下是对该系统的详细讲解,涵盖各组件的作用、协同工作原理及配置示例。 官网地址:[https://grafana.com](https://grafana.com/) 中文官网地址: Loki Ai助手: *** ### **1. 组件详解** #### **1.1 Alloy(Grafana Alloy)** * **定位**:Grafana Alloy 是 Grafana Labs 推出的统一可观测性数据收集器,整合了日志、指标、跟踪的采集与转发功能,替代了早期的 Grafana Agent。 * **核心功能**: * **数据采集**:支持从多种来源(如应用日志文件、Prometheus 指标、OpenTelemetry 追踪数据)收集数据。 * **数据处理**:通过配置管道(Pipeline)对日志进行过滤、转换、添加标签(Labels)等操作。 * **数据转发**:将处理后的日志推送到 Loki,指标发送到 Prometheus/Mimir,追踪发送到 Tempo。 * **优势**: * 单一二进制部署,资源占用低。 * 支持模块化配置,灵活适配复杂场景。 * 与 Grafana 生态无缝集成。 #### **1.2 Loki** * **定位**:由 Grafana Labs 开发的高效日志聚合系统,专为云原生设计,类似 Prometheus 的日志方案。 * **核心特性**: * **标签索引**:仅对日志的标签(Labels)建立索引,而非全文,降低存储成本。 * **水平扩展**:支持分布式架构,组件包括 Distributor(分发)、Ingester(写入)、Querier(查询)等。 * **存储分离**:日志数据存储于对象存储(如 S3、MinIO),索引存储于键值库(如 BoltDB、Cassandra)。 * **查询语言**:LogQL(类似 PromQL),支持日志筛选、聚合和实时分析。 #### **1.3 Grafana** * **定位**:开源的可视化与分析平台,支持多数据源(包括 Loki、Prometheus、MySQL 等)。 * **核心功能**: * **日志展示**:通过 Explore 界面直接查询 Loki 日志。 * **仪表盘**:创建交互式面板,结合日志与指标数据(如错误日志与 CPU 使用率关联)。 * **告警**:基于 LogQL 设置规则,触发异常日志告警。 *** ### **2. ALG 系统架构与工作流程** ```plaintext +----------------+ +-------------+ +------------+ +---------+ | 数据源 | ---> | Alloy | ---> | Loki | <--- | Grafana | | (应用、K8s等) | | (采集&处理) | | (存储&索引)| | (可视化)| +----------------+ +-------------+ +------------+ +---------+ ``` 1. **日志采集**:Alloy 从文件、容器、系统服务等收集原始日志。 2. **数据处理**:Alloy 通过配置的 Pipeline 清洗日志(如提取字段、过滤噪声)。 3. **标签注入**:为日志添加标签(如 `app=web`, `env=prod`),便于 Loki 高效索引。 4. **存储到 Loki**:Alloy 将带标签的日志批量推送到 Loki 的 Distributor 组件。 5. **查询与可视化**:Grafana 连接 Loki 数据源,用户通过 LogQL 查询日志并创建仪表盘。 *** ### **3. 配置示例** #### **3.1 Alloy 配置(`alloy.yaml`)** ```yaml # 采集容器日志(Docker/K8s) prometheus.scrape "containers" { targets = [ { "__address__" = "cadvisor:8080" }, ] forward_to = [loki.write.endpoint.receiver] } # 处理日志并添加标签 loki.process "filter_logs" { forward_to = [loki.write.endpoint.receiver] stage { docker {} } stage { labels = { "job" = "{{ .Job }}", "container" = "{{ .ContainerName }}", } } } # 输出到 Loki loki.write "endpoint" { endpoint { url = "http://loki:3100/loki/api/v1/push" } } ``` #### **3.2 Loki 配置(`loki.yaml`)** ```yaml auth_enabled: false server: http_listen_port: 3100 common: storage: object_store: s3 s3: endpoint: minio:9000 access_key_id: minio secret_access_key: minio123 replication_factor: 1 schema_config: configs: - from: 2023-01-01 store: boltdb-shipper object_store: s3 schema: v11 index: prefix: index_ period: 24h ``` #### **3.3 Grafana 连接 Loki** 1. 在 Grafana 中添加数据源,选择 Loki。 2. 填写 Loki 地址 `http://loki:3100`。 3. 使用 Explore 页面输入 LogQL 查询: ``` {container="web-app"} |= "error" ``` *** ### **4. 优势与适用场景** * **轻量高效**:相比 ELK(Elasticsearch、Logstash、Kibana),Loki 的索引更小,存储成本降低 90%。 * **云原生友好**:天然支持 Kubernetes 标签,适合动态伸缩环境。 * **统一可观测性**:Alloy 可同时处理日志、指标、追踪,简化运维栈。 *** ### **5. 常见问题与优化** * **标签设计**:避免高基数标签(如 `user_id`),否则影响 Loki 性能。 * **存储优化**:使用压缩的日志格式(如 JSON),合理设置日志保留策略。 * **查询加速**:通过 LogQL 的 `rate()` 或 `pattern` 分析高频错误。 *** 通过 ALG 组合,用户能够以低成本实现高效的日志全生命周期管理,尤其适合需要快速定位问题的 DevOps 团队。 --- --- url: /src/technology/dateblog/2026/04/20260406-armbian-h96-m9配置ap热点.md --- # Armbian 服务器快速开启 Wi-Fi 热点教程 在 Armbian 系统上,利用 `nmcli`(NetworkManager 命令行工具)可以非常方便地将无线网卡配置为 AP 模式,并共享有线网络(`eth0`)的互联网连接。相比手动配置 `hostapd` 和 `dnsmasq`,这种方法更简单、可靠,且由 NetworkManager 自动处理 DHCP 和 NAT。 本文记录了在 Armbian 服务器上创建热点 `H96-AP`(密码 `12345678`)的完整步骤,并支持开机自启。 *** ## 1. 准备工作 * 确保你的 Armbian 已安装 `NetworkManager`(通常默认已安装)。 * 无线网卡(如 `wlan0`)支持 AP 模式,且驱动正常。 * 有线网络(`eth0`)已连通互联网。 *** ## 2. 清理旧配置(可选) 如果你之前尝试过手动配置热点或其他静态 IP 设置,建议先删除可能冲突的连接配置: ```bash nmcli connection delete Hotspot 2>/dev/null nmcli connection delete wlan0-static 2>/dev/null ``` > `2>/dev/null` 用于忽略“连接不存在”的错误提示。 *** ## 3. 创建热点连接 使用 `nmcli` 一次性创建完整的 Wi-Fi AP 配置。关键点在于 `ipv4.method shared`,它会让 NetworkManager 自动为该接口启用 DHCP 服务器,并做 NAT 转发(共享上行网络)。 ```bash nmcli connection add \ type wifi \ ifname wlan0 \ con-name Hotspot \ ssid H96-AP \ mode ap \ ipv4.method shared \ wifi-sec.key-mgmt wpa-psk \ wifi-sec.psk 12345678 ``` **参数说明**: | 参数 | 含义 | |------|------| | `type wifi` | 连接类型为 Wi-Fi | | `ifname wlan0` | 使用的无线网卡 | | `con-name Hotspot` | 连接名称(后续操作使用) | | `ssid H96-AP` | 热点名称(可自定义) | | `mode ap` | 工作模式为接入点(Access Point) | | `ipv4.method shared` | 启用共享模式,自动 NAT + DHCP | | `wifi-sec.key-mgmt wpa-psk` | 加密方式为 WPA2-PSK | | `wifi-sec.psk 12345678` | 热点密码(至少8位) | *** ## 4. 启动热点 ```bash nmcli connection up Hotspot ``` 此时,你的手机或其他设备应当能搜索到 `H96-AP`,输入密码 `12345678` 即可连接,并且能自动获取 IP 地址(如 `10.42.0.x`),不再卡在“获取 IP 地址”阶段。 *** ## 5. 开启 IP 转发并配置 NAT 规则 虽然 `ipv4.method shared` 会自动设置 NAT,但有时 Armbian 默认未开启 IP 转发,或需要显式添加防火墙规则以确保网络共享正常。建议执行以下命令(一次性设置,重启后需重新执行,后续我们会用开机脚本固化): ```bash # 开启 IP 转发(内核参数) echo 1 > /proc/sys/net/ipv4/ip_forward # 清空 NAT 表并添加 MASQUERADE 规则,让流量从 eth0 出去 iptables -t nat -F iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE # 允许转发所有包(默认策略已为 ACCEPT 时可省略,此处确保) iptables -P FORWARD ACCEPT ``` > **注意**:如果你重启了系统,上述 `iptables` 和 `ip_forward` 设置会丢失。下文会介绍如何永久保存。 *** ## 6. 重启热点使规则生效 ```bash nmcli connection down Hotspot nmcli connection up Hotspot ``` *** ## 7. 设置开机自动启动热点 ```bash nmcli connection modify Hotspot connection.autoconnect yes ``` 这样系统每次启动后,NetworkManager 会自动拉起该热点。 *** ## 8. 永久保存 IP 转发和 iptables 规则 ### 8.1 永久开启 IP 转发 编辑 `/etc/sysctl.conf`,取消或添加下面一行: ```bash net.ipv4.ip_forward = 1 ``` 然后执行 `sysctl -p` 使其立即生效。 ### 8.2 永久保存 iptables 规则 安装 `iptables-persistent`(Debian/Ubuntu 系): ```bash apt update && apt install iptables-persistent -y ``` 在配置好规则后,执行: ```bash iptables-save > /etc/iptables/rules.v4 ``` 这样重启后规则会自动恢复。 如果你不希望安装额外软件,也可以将规则写入 `/etc/rc.local` 或创建一个 systemd service。 *** ## 9. 日常管理命令 | 操作 | 命令 | |------|------| | 关闭热点 | `nmcli connection down Hotspot` | | 开启热点 | `nmcli connection up Hotspot` | | 查看热点状态 | `nmcli connection show Hotspot` | | 修改 SSID | `nmcli connection modify Hotspot ssid 新名称` | | 修改密码 | `nmcli connection modify Hotspot wifi-sec.psk 新密码` | | 删除热点 | `nmcli connection delete Hotspot` | *** ## 10. 故障排查 ### 10.1 手机连接后无法上网 * 确认 `eth0` 本身能上网(`ping 8.8.8.8`)。 * 检查 IP 转发:`cat /proc/sys/net/ipv4/ip_forward` 应返回 `1`。 * 检查 NAT 规则:`iptables -t nat -L -n -v` 应看到 `MASQUERADE` 规则。 * 重启 NetworkManager:`systemctl restart NetworkManager`,然后重新启动热点。 ### 10.2 热点创建失败(错误:No support for AP mode) * 执行 `iw list | grep "Supported interface modes" -A 8`,查看是否包含 `AP`。如果没有,说明网卡不支持 AP 模式,需更换网卡或驱动。 ### 10.3 手机连接时一直显示“正在获取 IP 地址” * 通常是因为 `ipv4.method` 没有设为 `shared`。请按本文步骤重新创建连接。 * 也可以检查 `/etc/NetworkManager/NetworkManager.conf` 中 `[main]` 下是否有 `dns=dnsmasq`(一般无影响,但如果冲突可注释掉)。 *** ## 结语 通过 `nmcli` 的 `shared` 模式,我们仅需一条 `connection add` 命令就能在 Armbian 上快速建立 Wi-Fi 热点,无需手动配置 `hostapd` 和 `dnsmasq`。配合 IP 转发和 iptables 规则,即可让连接热点的设备通过服务器的有线网络访问互联网。 此方法同样适用于其他使用 NetworkManager 的 Linux 发行版(如 Ubuntu、Debian、Raspberry Pi OS 等)。希望这篇教程能帮助你轻松搭建属于自己的服务器热点! ### 结论先说 你的无线芯片 **aicwf\_sdio(AIC8800)** 👉 **是支持 5GHz 频段的,属于 2.4G + 5G 双频 WiFi** H96-M9 配这款芯片,基本都是**双频 WiFi6**,不是单频 2.4G。 *** ### 一键验证(最准) 直接运行这条: ```bash iw list | grep -i "5ghz" ``` * **有输出** → 支持 5G * **完全没输出** → 不支持 你这块芯片**99%会有输出**。 *** ### 为什么你现在速度只有 3MB/s? 因为你的热点**默认跑在 2.4G 频段**,干扰大、速度慢。 只要切到 **5G 频段**,速度立刻能到 **10MB/s+**。 *** ### 把热点改成 5G 频段(复制即用) ```bash # 强制热点使用 5G 频段 nmcli connection modify Hotspot 802-11-wireless.band a # 5G 常用干净信道(36/40/44/48 任选一个) nmcli connection modify Hotspot 802-11-wireless.channel 36 # 重启热点生效 nmcli connection down Hotspot nmcli connection up Hotspot ``` 切完再测速度,会明显快很多。 --- --- url: /src/technology/dateblog/2025/06/20250606-cas与oauth.md --- # CAS和OAuth CAS(Central Authentication Service)和 OAuth(Open Authorization)都是处理身份验证和授权的协议/框架,但它们**核心目标、设计原理和应用场景有显著区别**,也存在一定的联系。以下详细说明: ## **核心区别总结:** | 特性 | CAS (Central Authentication Service) | OAuth (Open Authorization) | | :----------- | :------------------------------------------------ | :----------------------------------------------- | | **核心目标** | **单点登录 (SSO)** | **授权委派 (Authorization Delegation)** | | **主要功能** | 集中验证用户身份,提供跨应用访问凭证。 | 允许第三方应用在用户同意下访问用户在资源服务器的受限资源。 | | **信任关系** | 应用信任认证中心。 | 用户信任认证服务器授权第三方应用访问资源。 | | **关键票据** | TGT (Ticket Granting Ticket), ST (Service Ticket) | Access Token (访问令牌), Refresh Token (刷新令牌) | | **用户身份** | 直接向应用提供用户身份信息。 | 不直接向第三方应用暴露用户身份(只提供访问权限)。 | | **典型场景** | 企业内部系统SSO、校园网系统登录。 | 社交媒体登录、API访问授权、应用间数据共享。 | | **协议层** | 认证协议。 | 授权框架。 | ## **详细解析:** 1. **核心目标与功能:** * **CAS:** * **目标:** 实现**单点登录 (SSO)**。用户只需在一个地方(CAS Server)登录一次,即可访问所有信任该 CAS Server 的**后端集成应用 (CAS Clients)**,无需在每个应用重复输入用户名密码。 * **功能:** 核心是**集中式身份认证**。它验证用户的凭据(如用户名密码),生成代表该用户会话的票据(TGT),并为用户请求访问的每个具体应用生成一次性服务票据(ST)。应用使用 ST 向 CAS Server 验证该票据的有效性并获取用户的身份标识(通常是用户名)。 * **OAuth:** * **目标:** 实现**授权委派**。允许**用户**授权**第三方应用 (Client Application)** 代表自己访问存储在**资源服务器 (Resource Server)** 上的特定资源(如 Google Drive 文件、Facebook 个人资料),而无需将用户在资源服务器上的主凭证(用户名密码)直接交给第三方应用。 * **功能:** 核心是**颁发访问令牌 (Access Token)**。OAuth 定义了资源所有者(用户)、客户端(第三方应用)、授权服务器和资源服务器之间的交互流程,最终使客户端获得一个访问令牌。客户端使用此令牌向资源服务器请求受保护的资源。OAuth **本身并不定义用户身份认证**,尽管它常与 OpenID Connect 结合使用来实现认证。 2. **关键概念与流程:** * **CAS (简化流程):** 1. 用户访问 App A (Protected Service)。 2. App A 重定向用户到 CAS Server (Login URL)。 3. 用户在 CAS Server 登录(输入用户名密码)。 4. CAS Server 验证凭据,创建 TGT(存储于服务器端),设置 Cookie(通常是 `TGC`),生成针对 App A 的 ST。 5. CAS Server 重定向用户回 App A(携带 ST)。 6. App A 使用 ST 向 CAS Server 的 `/validate` 或 `/serviceValidate` 端点验证 ST。 7. CAS Server 验证 ST 有效,返回用户的身份标识(XML 格式)。 8. App A 建立用户本地会话,允许访问。 9. 用户访问 App B(也集成 CAS)。 10. App B 重定向用户到 CAS Server(携带 Service URL)。 11. CAS Server(用户有 TGC Cookie)发现用户已登录,生成针对 App B 的 ST。 12. CAS Server 重定向用户回 App B(携带 ST)。 13. App B 验证 ST,获取用户身份,建立会话。**SSO 完成。** * **OAuth 2.0 (授权码流程 - 最常见):** 1. 用户尝试在 Client App 中使用其 Resource Server 账户(如“用 Google 登录”)。 2. Client App 重定向用户到 Authorization Server (AS)。 3. 用户在 AS 登录(认证发生在此,但 OAuth 本身不定义如何认证)。 4. AS 要求用户授权 Client App 访问请求的范围(Scope,如 `read profile`, `write files`)。 5. 用户同意授权。 6. AS 重定向用户回 Client App(携带一个**授权码 (Authorization Code)**)。 7. Client App(在后端)使用授权码 + 自己的 Client ID/Secret 向 AS 的 `/token` 端点请求。 8. AS 验证授权码和客户端凭证,颁发 **访问令牌 (Access Token)** 和可选的刷新令牌 (Refresh Token)。 9. Client App 使用 Access Token 访问 Resource Server 的 API(如获取用户资料)。 10. Resource Server 验证 Access Token(通常通过内省或 JWKS),如果有效则返回请求的资源。 3. **信任模型:** * **CAS:** 基于**应用对认证中心的信任**。所有 CAS Clients 都信任 CAS Server 对用户身份的断言。CAS Client 拿到 ST 后直接向 CAS Server 验证并获取明确的用户身份信息。 * **OAuth:** 基于**用户对授权服务器和资源服务器的信任**,以及**资源服务器对授权服务器颁发的令牌的信任**。第三方应用 (Client) 只获得一个代表授权范围的 Access Token,**通常不直接知道用户的原始身份**(除非通过 API 如 `/userinfo` 获取,这依赖于资源服务器提供此类 API 和 scope)。资源服务器信任授权服务器签发的有效令牌。 4. **用户身份信息:** * **CAS:** **核心是传输用户身份**。CAS Server 在验证 ST 后,会明确地将用户的标识符(通常是用户名)返回给 CAS Client。这是 SSO 的关键。 * **OAuth:** **核心是传输访问权限 (Token)**。Access Token 代表的是访问特定资源的权限,**不必然包含或直接暴露用户的原始身份标识**给 Client。Client 知道它有权访问*某些*资源,但资源与哪个具体用户的关联是由 Resource Server 在验证 Token 时确定的。要获取用户身份,Client 通常需要额外调用一个 API(如 OpenID Connect 的 `/userinfo`)。 5. **典型应用场景:** * **CAS:** * 企业内部多个系统(邮箱、HR 系统、报销系统)的 SSO。 * 大学校园网系统(选课系统、图书馆系统、成绩查询)的统一登录。 * 任何需要用户一次登录即可访问多个后端集成 Web 应用的场景。 * **OAuth:** * “使用 Google/Facebook/Twitter/微信 登录” 第三方网站或 App。 * 允许第三方 App(如邮件客户端)访问用户在 Gmail/Yahoo Mail 中的邮件。 * 允许数据分析工具访问用户在云存储(如 Dropbox)中的特定文件。 * 微服务架构中服务间的 API 授权。 * 移动 App 访问后端 API。 ## **联系与结合:** 1. **不是互斥,可结合使用:** * 一个系统可以同时实现 CAS 和 OAuth。例如: * 使用 CAS 作为内部员工访问所有企业应用的 SSO 解决方案。 * 使用 OAuth 来授权外部合作伙伴应用访问企业 API 或特定资源。 * CAS Server **可以充当 OAuth 的 Authorization Server**。现代 CAS 实现(如 Apereo CAS)通常内置了 OAuth 2.0 / OpenID Connect 支持。这样,同一个认证中心既能处理内部应用的 SSO(通过 CAS 协议),也能处理对外部应用或 API 的授权(通过 OAuth 2.0 协议)。 * **OAuth 可用于实现某种形式的 SSO (Social Login SSO):** 当多个网站都使用同一个 OAuth Provider(如 Google)登录时,用户如果在 Google 是登录状态,通常只需授权一次(或之前已授权)即可快速登录这些网站。但这本质上是利用了 OAuth Provider 的会话(其自身的登录状态),是 OAuth 流程的副产品,而不是像 CAS 那样有明确的票据流转和验证机制在多个*后端集成应用*间同步登录状态。CAS 的 SSO 通常更严格和可控(尤其是注销时)。 2. **OpenID Connect (OIDC) - OAuth 的认证扩展:** * OIDC 在 OAuth 2.0 基础上添加了身份认证层。它定义了标准的 `/userinfo` 端点,Client 可以使用 Access Token 获取经过认证的用户身份信息(Claims)。 * **这使得 OAuth (with OIDC) 不仅能做授权,也能做认证,并且是实现“社交登录”类 SSO 的标准方式。** 在这个场景下,OAuth/OIDC Provider 的功能与 CAS Server 在认证方面有重叠。但核心区别仍在:OIDC 仍是基于令牌和 API 调用的授权框架扩展,而 CAS 是专门为应用间 SSO 设计的基于票据的协议。 ## **总结:** * **CAS 是专为 SSO 设计的协议。** 它解决的核心问题是“一次登录,处处通行”(在信任圈内)。它明确地在认证中心和应用间传递用户身份。 * **OAuth 是专为授权委派设计的框架。** 它解决的核心问题是“让第三方应用代表我安全地访问我拥有的资源,而无需交出我的密码”。它传递的是访问资源的令牌,而非直接的用户身份(OIDC 弥补了这点)。 * **选择:** * 需要为多个**后端集成应用**提供严格、可控的**SSO**?**CAS** 是经典且强大的选择。 * 需要实现“**用 XX 登录**”、允许**第三方应用访问用户在你服务器上的资源**、或者进行**API 访问授权**?**OAuth 2.0 (通常结合 OIDC)** 是标准解决方案。 * 大型组织可能需要两者:**CAS 处理内部员工 SSO,OAuth/OIDC 处理外部应用集成和 API 访问控制**。现代认证服务器(如 Keycloak, Okta, Auth0, Apereo CAS)通常同时支持这两种协议。 理解它们的本质差异(SSO vs 授权委派)和核心流程(票据 vs 令牌)是正确应用它们的关键。虽然 OAuth/OIDC 在“社交登录”场景下提供了类似 SSO 的用户体验,但其底层机制和目标与传统的 CAS SSO 有根本不同。 --- --- url: /src/technology/dateblog/2025/06/20250606-cas处理多个不同域名Web应用及桌面应用的单点登录(SSO).md --- 使用CAS(Central Authentication Service)处理多个不同域名Web应用及桌面应用的单点登录(SSO)时,需解决跨域Cookie共享、非Web协议集成等核心挑战。以下是分场景的解决方案及关键技术要点: *** ### 🔗 **一、跨域Web应用(不同域名)的CAS集成** #### **核心问题** 浏览器默认禁止跨域共享Cookie,导致CAS的TGC(Ticket Granting Cookie)无法在不同域名间传递。 #### **解决方案** 1. **CAS中央认证流程**: * **首次登录应用A**:用户访问`appA.com` → 重定向至CAS认证中心(`cas.com`) → 用户登录后,CAS生成TGT(Ticket Granting Ticket)并存储于`cas.com`域下的Cookie(TGC),同时签发ST(Service Ticket)返回给`appA.com`。 * **应用A验证ST**:`appA.com`后台向`cas.com`验证ST有效性,获取用户身份,建立本地Session(局部会话)。 * **访问应用B**:用户访问`appB.com` → 重定向至`cas.com` → 浏览器携带`cas.com`的TGC → CAS验证TGC有效后,直接签发新ST给`appB.com` → `appB.com`验证ST后建立局部会话,无需用户重复登录。 2. **关键配置**: * **CAS服务端**:注册所有信任的Web应用域名(Service属性),例如: ```json { "serviceId": "^(https?://appA\.com|appB\.com).*", "id": 1001 } // 允许appA.com和appB.com ``` * **客户端**:在Web应用中配置CAS Filter,指定`cas.com`的登录/验证地址(如`edu.yale.its.tp.cas.client.filter.loginUrl`)。 *** ### 💻 **二、桌面应用集成CAS** #### **核心问题** 桌面应用无法直接使用浏览器Cookie机制,需通过协议桥接实现认证。 #### **解决方案** 1. **嵌入式浏览器认证**: * 桌面应用内嵌WebView组件加载CAS登录页。 * 用户登录后,CAS重定向至`desktopapp://callback?st=XXX`(自定义Scheme)。 * 桌面应用监听URL变化,从回调地址中提取ST,向后端兑换访问令牌。 2. **后端API协作**: * 桌面应用调用后端API时携带访问令牌。 * 后端服务向CAS验证令牌有效性(如调用`/proxyValidate`接口)。 3. **OAuth 2.0扩展**: * CAS可作为OAuth授权服务器,桌面应用使用**授权码模式**: 1. 用户通过浏览器登录CAS,授权后返回授权码至本地回调服务。 2. 桌面应用用授权码向CAS兑换Access Token。 *** ### 🔒 **三、安全性与最佳实践** 1. **跨域安全**: * **HTTPS强制**:所有CAS通信启用HTTPS,防止TGC/ST泄露。 * **Cookie属性**:设置TGC为`Secure`、`HttpOnly`,限定Domain为CAS主域(如`.cas.com`)。 * **ST一次性**:限制ST有效期(如5分钟)和单次使用。 2. **桌面应用安全**: * **PKCE扩展**:防止授权码劫持(OAuth 2.0 PKCE)。 * **令牌存储加密**:桌面应用本地安全存储访问令牌(如系统密钥库)。 3. **统一登出(SLO)**: * 任一应用触发登出 → 通知CAS清除TGT → CAS主动回调所有已登录应用的登出接口(需提前注册`logoutUrl`)。 *** ### 🛠️ **四、实施部署建议** 1. **域名规划**: * Web应用使用共享父域名(如`appA.example.com`、`appB.example.com`),使CAS Cookie可通过`.example.com`共享。 * 完全独立域名需依赖CAS重定向流程传递ST。 2. **证书与信任**: * CAS服务端配置SSL证书(如Keytool生成自签名证书,导入客户端JDK信任库)。 * 桌面应用需信任CAS的HTTPS证书链。 3. **客户端适配**: * **Web应用**:使用CAS Client库(如Java的`cas-client-core`)。 * **桌面应用**:实现OAuth 2.0客户端或自定义ST回调解析。 *** ### 💎 **五、总结:多类型应用集成方案对比** | **应用类型** | **认证流程** | **关键技术点** | |--------------------|-----------------------------------------------------------------------------|---------------------------------------------| | **跨域Web应用** | 浏览器重定向 + CAS TGC共享 + ST验证 | CAS Service注册、Cookie域控制 | | **桌面应用** | WebView登录/OAuth授权码 + 本地回调 + 令牌兑换 | 自定义URL Scheme、PKCE扩展、后端令牌验证 | | **混合架构** | Web应用走CAS协议,桌面应用走OAuth协议,CAS统一作为认证中心 | CAS同时开启CAS和OAuth协议支持 | > 💡 **实施建议**: > > * 优先使用CAS 5.x+版本,原生支持OAuth/OIDC协议,简化桌面应用集成。 > * 对于企业内网桌面应用,可结合系统级SSO(如Kerberos)减少用户交互,但需与CAS联邦集成。 --- --- url: /src/technology/dateblog/2025/11/20251107-centos创建具有管理员权限的用户.md --- 在CentOS中创建一个具有管理员权限的用户,主要有两种推荐方法:一是将用户加入 `wheel` 组(最常用),二是通过修改sudoers文件直接授权。下面的表格汇总了这两种方法的核心步骤与特点,你可以根据需要选择。 | 特性 | 方法一:加入wheel组 | 方法二:配置sudoers文件 | | :--- | :--- | :--- | | **操作核心** | 使用 `usermod` 命令将用户加入 `wheel` 组。 | 使用 `visudo` 命令编辑 `/etc/sudoers` 文件,为用户添加权限。 | | **推荐度** | ⭐⭐⭐⭐⭐ (标准做法) | ⭐⭐⭐⭐ (灵活配置) | | **安全性** | 高,便于集中管理有管理员权限的用户。 | 高,可以精确控制用户能执行的命令。 | | **灵活性** | 组内成员拥有统一的sudo权限。 | 可以为不同用户定制不同的权限。 | | **适用场景** | 需要快速为普通用户授予完整管理员权限。 | 需要为用户授予特定范围的管理员权限。 | ### 👤 创建用户与设置密码 在配置管理员权限前,你首先需要创建一个用户并为其设置密码。 1. **创建用户**:使用 `useradd` 命令。以下命令会创建一个名为 `newadmin` 的用户(请替换为你想要的用户名)。 ```bash sudo useradd newadmin ``` *注意:`adduser` 在某些精简系统中可能不可用,`useradd` 是更通用的命令。* 2. **设置密码**:使用 `passwd` 命令。执行后系统会提示你输入并确认密码。 ```bash sudo passwd newadmin ``` ### 🔧 配置管理员权限的两种方法 完成用户创建后,就可以通过以下任一方法为其授予管理员权限了。 #### 方法一:将用户加入 wheel 组(推荐) 在CentOS中,默认情况下,`wheel` 组的成员被授予了使用 `sudo` 的权限。 1. **将用户添加到 wheel 组**:使用 `usermod` 命令。`-aG` 选项确保将用户追加(`-a`)到指定的附加组(`-G`)中,而不会影响其其他所属组。 ```bash sudo usermod -aG wheel newadmin ``` 2. **(可选)检查是否添加成功**:可以使用以下命令查看 `wheel` 组的成员列表。 ```bash grep wheel /etc/group ``` #### 方法二:通过 sudoers 文件直接授权 这种方法允许你为特定用户进行精细化的权限控制。 1. **安全地编辑sudoers文件**:**务必**使用 `visudo` 命令来编辑,因为它会在保存时检查语法,防止错误的配置导致所有sudo权限失效。 ```bash sudo visudo ``` 2. **添加权限配置**:在打开的文件中,找到类似的行,并在下面添加你的配置。 * **授予完整权限**:这行配置允许用户 `newadmin` 在所有主机上以所有用户的身份执行所有命令,且在sudo时无需输入密码。 ``` newadmin ALL=(ALL) NOPASSWD: ALL ``` * **授予需密码的完整权限**:如果出于安全考虑,希望执行sudo时需要输入用户自己的密码,可以这样配置: ``` newadmin ALL=(ALL) ALL ``` ### ✅ 验证权限配置 权限配置好后,需要验证是否生效。 1. **切换到新用户**: ```bash su - newadmin ``` 2. **测试sudo权限**:尝试执行一个需要root权限的命令,例如查看只有root能访问的文件。 ```bash sudo cat /etc/shadows ``` * 如果配置的是**需要密码**的权限,系统会提示你输入 `newadmin` 用户的密码。 * 如果配置了 `NOPASSWD`,命令会直接返回结果。 * 如果成功显示文件内容,则证明管理员权限已生效。 ### 💡 重要安全提示 * **慎用Root权限**:强烈建议你日常使用普通用户账户,仅在需要执行管理任务时才使用 `sudo`,这遵循了"最小权限原则",能显著提升系统安全性。 * **警惕危险配置**:尽量避免使用修改用户UID为0等方法直接创建"第二个root",这会增加权限混乱和安全风险。 --- --- url: /src/technology/dateblog/2025/05/20250521-css伪类实现鼠标悬停父元素时子元素才显示.md --- # css伪类实现鼠标悬停父元素时子元素才显示 实现了只有当鼠标悬停在特定父元素上时,对应的子元素才会显示的效果。 ## css代码 ::: code-group ```css .mouse-active-show-container:hover .mouse-active-show-element { display: inline; } .mouse-active-show-element { display: none; } ``` ```scss .mouse-active-show-container { &:hover .mouse-active-show-element { display: inline; } } .mouse-active-show-element { display: none; } ``` ::: ## 解释 1. **`.mouse-active-show-element { display: none; }`**: * 这段CSS选择器选择了所有带有类名 `mouse-active-show-element` 的元素。 * `display: none;` 表示这些元素默认情况下不会显示在页面上。它们会被完全隐藏,既不占据空间也不可见。 2. **`.mouse-active-show-container:hover .mouse-active-show-element { display: inline; }`**: * 这段CSS选择器选择了当鼠标悬停在带有类名 `mouse-active-show-container` 的元素上时,其内部的所有带有类名 `mouse-active-show-element` 的子元素。 * `:hover` 是一个伪类,表示当用户将鼠标指针移动到指定的元素上时应用的样式。 * `display: inline;` 表示当鼠标悬停在 `.mouse-active-show-container` 上时,`.mouse-active-show-element` 元素会以行内元素的形式显示出来。 这样就实现了只有当鼠标悬停在特定父元素上时,对应的子元素才会显示的效果。 ## html示例 ```html Hover Effect

a

a1

b

b1
``` --- --- url: /src/technology/dateblog/2026/03/20260325-docker-buildx-完全指南:构建缓存、多平台与高级特性.md --- # Docker Buildx 完全指南:构建缓存、多平台与高级特性 > 从“构建太慢”到“秒级构建”的必经之路 在 Docker 容器化开发中,构建镜像是最频繁的操作之一。随着项目复杂度增加,重复下载依赖、构建时间过长成为痛点。Docker Buildx 的出现彻底改变了这一局面,它不仅带来了 **BuildKit** 构建引擎的卓越性能,还提供了**多平台构建**、**高级缓存管理**等企业级特性。本文将带你深入理解 Docker Buildx,从安装配置到实战技巧,让构建效率提升一个数量级。 *** ## 一、什么是 Docker Buildx? Docker Buildx 是 Docker 官方提供的一个 CLI 插件,它是 `docker build` 命令的增强版。Buildx 基于新一代构建引擎 **BuildKit** 构建,旨在解决传统 `docker build` 的诸多限制: * **性能差**:无法复用缓存,每次构建都需重新下载依赖。 * **功能弱**:不支持高级缓存挂载、并行构建、SSH 转发等。 * **平台单一**:无法在一个镜像中同时构建多个架构(如 amd64 + arm64)。 Buildx 完美解决了上述问题,并已成为 Docker 官方推荐的标准构建工具。它既可以作为 `docker build` 的替代品,又能通过插件系统扩展更多功能。 *** ## 二、安装与启用 Buildx ### 2.1 检查 Docker 版本 Buildx 需要 Docker 版本 ≥ 19.03,建议使用 20.10 以上版本。查看当前版本: ```bash docker version ``` ### 2.2 安装 Buildx 插件 #### 方式一:通过包管理器(推荐) Ubuntu/Debian 用户: ```bash sudo apt update sudo apt install docker-buildx-plugin ``` CentOS/RHEL 用户: ```bash sudo yum install docker-buildx-plugin ``` #### 方式二:手动安装二进制 ```bash # 创建插件目录 mkdir -p ~/.docker/cli-plugins # 下载(以 linux/amd64 为例) curl -SL https://github.com/docker/buildx/releases/download/v0.12.0/buildx-v0.12.0.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx # 赋予执行权限 chmod +x ~/.docker/cli-plugins/docker-buildx ``` #### 方式三:Docker Desktop(macOS/Windows) Docker Desktop 已内置 Buildx,无需额外安装。 ### 2.3 验证安装 ```bash docker buildx version ``` 正常输出类似: ``` github.com/docker/buildx v0.12.0 3b5e0d8 ``` ### 2.4 启用 BuildKit 环境变量 在构建时需设置环境变量 `DOCKER_BUILDKIT=1`(Buildx 默认启用)。通常将其添加到 shell 配置文件或 CI 环境中: ```bash export DOCKER_BUILDKIT=1 ``` *** ## 三、核心概念:BuildKit 与构建器 ### 3.1 BuildKit 是什么? BuildKit 是 Docker 构建引擎的后端,它重新设计了构建流程,实现了: * **并发执行**:多个构建阶段可并行运行。 * **智能缓存**:细粒度的缓存策略,减少重复工作。 * **安全挂载**:支持 `--mount=type=secret`、`--mount=type=ssh` 等高级挂载。 BuildKit 默认被 Buildx 使用,无需额外配置。 ### 3.2 构建器(Builder)与驱动 Buildx 通过“构建器”来管理构建环境,每个构建器可以有不同的驱动(driver)。常见驱动: | 驱动名称 | 特点 | 适用场景 | |---------|------|----------| | `docker` | 默认驱动,与 `docker build` 行为一致,但不支持多平台构建。 | 快速单平台构建 | | `docker-container` | 启动一个 BuildKit 容器作为构建后端,支持多平台、高级缓存导出等。 | 生产环境、CI/CD | | `kubernetes` | 在 Kubernetes 集群中运行 BuildKit 实例。 | 大规模分布式构建 | 创建并使用 `docker-container` 驱动的构建器: ```bash # 创建一个名为 mybuilder 的构建器 docker buildx create --name mybuilder --driver=docker-container --use # 查看所有构建器 docker buildx ls # 切换构建器 docker buildx use mybuilder ``` *** ## 四、Dockerfile 中的高级缓存挂载 ### 4.1 语法解析 在 Dockerfile 中使用 `--mount=type=cache` 来挂载一个可重用的缓存目录: ```dockerfile # syntax=docker/dockerfile:1.4 FROM golang:latest AS builder WORKDIR /app # 先复制依赖定义文件 COPY go.mod go.sum ./ # 配置 Go 代理 RUN go env -w GOPROXY=https://goproxy.cn # 使用缓存挂载下载 Go 模块 RUN --mount=type=cache,target=/go/pkg/mod \ go mod download -x # 构建二进制(同样使用缓存挂载) RUN --mount=type=cache,target=/go/pkg/mod \ CGO_ENABLED=0 GOOS=linux go build -o myapp . FROM alpine:latest COPY --from=builder /app/myapp . CMD ["./myapp"] ``` ### 4.2 为什么使用缓存挂载? * **避免重复下载**:Go 模块缓存在 `/go/pkg/mod`,后续构建直接复用,构建时间从几分钟缩短到几秒。 * **节省带宽**:减少对外部模块源的重复请求。 * **支持多阶段构建**:不同阶段可共享同一缓存目录。 * **自动清理**:BuildKit 会自动管理缓存的生命周期,无需手动维护。 ### 4.3 支持的缓存类型 除了 `type=cache`,BuildKit 还支持: * `--mount=type=bind`:挂载宿主目录(只读)。 * `--mount=type=tmpfs`:临时内存文件系统。 * `--mount=type=secret`:安全挂载敏感文件(如 SSH 私钥)。 * `--mount=type=ssh`:转发 SSH agent。 *** ## 五、缓存存储位置与管理 ### 5.1 缓存存在哪里? 缓存的实际存储位置取决于构建器的驱动类型。 #### 驱动为 `docker` * 缓存存储在 Docker 根目录下的 `buildkit` 子目录,通常为: ```bash /var/lib/docker/buildkit/ ``` * 该目录由 Docker 守护进程管理,不建议直接操作。 #### 驱动为 `docker-container` * BuildKit 运行在一个容器中,缓存挂载到一个 Docker Volume,卷名格式为: ```bash buildx_buildkit_<构建器名>_state ``` * 查看卷信息: ```bash docker volume inspect buildx_buildkit_mybuilder_state ``` ### 5.2 自定义缓存位置 如果你希望将缓存放在指定路径(例如挂载到数据盘),可以使用 **local 缓存后端**。 1. **使用 `docker-container` 驱动**(必须): ```bash docker buildx create --name mybuilder --driver=docker-container --use ``` 2. **构建时指定缓存目录**: ```bash docker buildx build \ --cache-to type=local,dest=/data/build-cache \ --cache-from type=local,src=/data/build-cache \ -t myapp:latest . ``` * `--cache-to`:将构建产生的缓存导出到指定目录。 * `--cache-from`:从指定目录导入已有的缓存。 * 导出的缓存以 OCI 镜像布局格式存储,便于备份和共享。 3. **也可以使用 registry 缓存后端**: ```bash docker buildx build \ --cache-to type=registry,ref=myregistry/cache:latest \ --cache-from type=registry,ref=myregistry/cache:latest \ -t myapp:latest . ``` ### 5.3 清理缓存 随着构建次数增加,缓存会不断累积,占用磁盘空间。使用以下命令管理: ```bash # 查看构建缓存使用情况 docker buildx du # 清理未使用的缓存(保留当前构建依赖) docker builder prune # 深度清理所有缓存(包括正在使用的) docker builder prune --all # 按时间清理(例如清理超过48小时的缓存) docker builder prune --filter until=48h ``` *** ## 六、多平台构建实战 Buildx 最强大的功能之一是多平台镜像构建。例如,同时构建 linux/amd64 和 linux/arm64 镜像: ```bash docker buildx build \ --platform linux/amd64,linux/arm64 \ -t myuser/myapp:latest \ --push . ``` 这依赖于 QEMU 模拟器(用于 arm64 构建)和 `docker-container` 驱动。首次使用时,需安装 QEMU: ```bash docker run --privileged --rm tonistiigi/binfmt --install all ``` *** ## 七、CI/CD 环境中的常见问题与解决方案 ### 7.1 Jenkins 容器内使用 Buildx 报错 **错误信息**: ``` ERROR: BuildKit is enabled but the buildx component is missing or broken. ``` **原因**:Jenkins 容器内没有 Buildx 插件,虽然挂载了 `docker.sock`,但客户端插件仍需容器内有对应二进制文件。 **解决方案**:挂载宿主机的插件目录到 Jenkins 容器。 假设宿主机 Buildx 插件位于 `/usr/libexec/docker/cli-plugins`,在 Jenkins 容器启动时添加卷挂载: ```yaml volumes: - /var/run/docker.sock:/var/run/docker.sock - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins - /usr/bin/docker:/usr/bin/docker # 可选,保持 docker 版本一致 ``` 这样容器内的 `docker buildx` 命令即可正常使用。 ### 7.2 挂载缓存后构建仍很慢 确保构建时指定了正确的缓存源。如果使用了 `docker-container` 驱动,但未使用 `--cache-from`,缓存不会自动加载。 ### 7.3 多平台构建在 CI 中失败 需要确保 CI 节点安装了 QEMU 模拟器,并且 Buildx 构建器驱动为 `docker-container`。 *** ## 八、总结 Docker Buildx 是现代容器化开发中不可或缺的工具,它带来了: * **高效构建**:通过 BuildKit 缓存挂载和并发执行,大幅缩短构建时间。 * **多平台支持**:一次构建,多架构分发。 * **灵活缓存管理**:支持本地目录、远程镜像仓库等多种缓存后端。 * **易于集成**:与现有 CI/CD 系统无缝衔接。 掌握 Buildx 的使用,不仅能提升个人开发效率,更能为团队构建流水线带来质的飞跃。建议所有 Docker 使用者尽快迁移到 Buildx,体验新一代构建引擎的强大能力。 *** **参考资源**: * [Docker Buildx 官方文档](https://docs.docker.com/build/buildx/) * [BuildKit 仓库](https://github.com/moby/buildkit) * [Dockerfile 语法参考](https://docs.docker.com/engine/reference/builder/) *** *如果觉得本文对你有帮助,欢迎分享给更多朋友。遇到问题可以在评论区留言,我会尽力解答。* --- --- url: /src/technology/dateblog/2025/05/20250508-docker-多阶段构建.md --- # Docker 多阶段构建 Docker 多阶段构建是一种优化镜像大小和安全性的技术,允许在单个 Dockerfile 中定义多个构建阶段,每个阶段独立运行,最终仅保留所需文件。以下是详细讲解: *** ### **1. 核心概念** * **多阶段分离**:将构建过程分为多个阶段(如编译、测试、打包),每个阶段使用不同的基础镜像。 * **选择性复制**:仅将必要文件(如编译后的二进制文件)从一个阶段复制到后续阶段,丢弃中间依赖(如编译器、测试工具)。 * **最终镜像精简**:仅包含运行时必需的组件,减少体积和潜在攻击面。 *** ### **2. 实现步骤** #### **Dockerfile 示例** ```dockerfile # 第一阶段:构建应用程序(命名为 builder) FROM golang:1.18 AS builder WORKDIR /app COPY . . RUN go build -o myapp # 第二阶段:运行环境 FROM alpine:latest WORKDIR /root/ COPY --from=builder /app/myapp . # 从 builder 阶段复制文件 CMD ["./myapp"] ``` #### **阶段解析** 1. **构建阶段(builder)**: * 使用 `golang` 镜像安装依赖、编译代码。 * 生成可执行文件 `myapp`。 2. **运行阶段**: * 使用轻量级 `alpine` 镜像。 * 通过 `COPY --from=builder` 复制 `myapp`,忽略编译环境的冗余内容。 *** ### **3. 核心优势** * **减小镜像体积**:例如,Go 编译环境约 800MB,而 Alpine 仅 5MB,最终镜像仅增加二进制文件大小。 * **提升安全性**:去除不必要的工具(如编译器),降低漏洞风险。 * **优化构建缓存**:未修改的阶段可复用缓存,加速构建。 *** ### **4. 适用场景** * **编译型语言**:如 Go、Rust、C++,分离编译和运行环境。 * **前端应用**:使用 Node.js 构建后,将静态文件复制到 Nginx 镜像。 ```dockerfile FROM node:14 AS build WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html ``` * **测试隔离**:在独立阶段运行测试,确保通过后才进入生产阶段。 *** ### **5. 注意事项** * **Docker 版本**:需 17.05 及以上。 * **文件路径**:确保 `COPY --from` 的源路径正确。 * **权限管理**:跨阶段复制时可能需要 `chmod` 调整权限。 * **多阶段命名**:使用 `AS ` 定义阶段名称,便于引用。 *** ### **6. 高级用法** * **多阶段复用**:可从任意阶段复制文件,如: ```dockerfile COPY --from=stage1 /path/file . COPY --from=stage2 /path/file . ``` * **外部镜像作为阶段**:直接复制其他镜像中的文件: ```dockerfile COPY --from=nginx:latest /etc/nginx/nginx.conf ./nginx.conf ``` *** ### **总结** 多阶段构建通过分离构建与运行环境,显著优化 Docker 镜像,是生产环境的最佳实践。掌握其用法可提升部署效率,保障应用安全。 --- --- url: /src/technology/dateblog/2026/05/20260512-docker-compose-yml-挂载jar包运行.md --- # Docker Compose 挂载 JAR 包运行完整教程 复制就能用**的 `docker-compose.yml` 方案,用**挂载本地 JAR 包\*\*的方式运行 Java 应用,不用重新构建镜像,修改 JAR 后直接重启容器即可生效。 ## 一、项目目录结构(必须按这个放) ``` 项目文件夹/ ├── docker-compose.yml # 编排文件 └── app.jar # 你的 Java 可执行 JAR 包 ``` ## 二、完整 docker-compose.yml(推荐版本) 这是最通用、稳定、生产可用的版本: ```yaml services: java-app: # 官方 OpenJDK 轻量级镜像(JRE 足够运行 JAR) # swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/openjdk:8-jdk # swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/openjdk:21 # swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/openjdk:17 image: openjdk:17-jre-slim # 容器名称 container_name: my-java-app # 挂载本地 JAR 到容器内(核心!修改本地 jar 容器自动生效) volumes: - ./app.jar:/app/app.jar # 工作目录 working_dir: /app # 启动命令:运行 JAR command: ["java", "-jar", "app.jar"] # 端口映射(宿主机:容器) ports: - "8080:8080" # network_mode: host # extra_hosts: # - "host.docker.internal:host-gateway" # 开机自启 restart: always # 环境变量(可选,SpringBoot 专用) # environment: #- TZ=Asia/Shanghai #- SPRING_PROFILES_ACTIVE=prod ``` ## 三、启动命令 进入目录执行: ```bash # 后台启动 docker compose up -d --build # 查看日志 docker compose logs -f # 停止 docker compose down ``` ## 四、关键说明(必看) 1. **挂载原理** * `./app.jar:/app/app.jar`:把**本地当前目录的 JAR** 映射到容器内部,本地修改 JAR,容器直接读取最新版 * 不用构建 Dockerfile,不用重新打包镜像 2. **JDK 版本选择** * 你的项目是 JDK 8 → 用 `openjdk:8-jre-slim` * JDK 11 → `openjdk:11-jre-slim` * JDK 17 → `openjdk:17-jre-slim` 3. **端口修改** * 如果你的项目端口是 `9090`,把 `ports` 改成: ```yaml ports: - "9090:9090" ``` 4. **JVM 内存配置(推荐加上)** 防止内存溢出,在 `command` 中添加: ```yaml command: ["java", "-Xms256m", "-Xmx512m", "-jar", "app.jar"] ``` ## 五、常见问题 1. **启动报错找不到 app.jar** * 检查 JAR 文件名必须是 `app.jar` * 检查 `docker-compose.yml` 和 JAR 在**同一级目录** 2. **端口被占用** * 修改宿主机端口,例如 `8081:8080` 3. **SpringBoot 配置文件挂载** 如果你想挂载 `application.yml`: ```yaml volumes: - ./app.jar:/app/app.jar #- ./application.yml:/app/application.yml ``` *** ### 总结 1. 把 `docker-compose.yml` 和 `app.jar` 放在同一目录 2. 执行 `docker-compose up -d` 即可运行 3. 本地更新 JAR 后,执行 `docker-compose restart` 就能生效 4. 不用构建镜像,纯挂载运行,开发/测试非常方便 --- --- url: /src/technology/dateblog/2025/05/20250508-dockerfile中run、cmd、add、copy-指令.md --- # Dockerfile中RUN、CMD、ADD、COPY 指令 在 Dockerfile 中,`RUN`、`CMD`、`ADD` 和 `COPY` 是常用的指令,但它们的作用和适用场景有显著区别。以下是详细对比及示例: *** ### **1. RUN 命令 vs CMD 命令** #### **核心区别** | **特性** | **RUN** | **CMD** | |----------------|----------------------------------|----------------------------------| | **作用时机** | **镜像构建阶段**执行命令(如安装软件、编译代码) | **容器启动时**指定默认执行的命令或参数 | | **执行次数** | 每个 `RUN` 生成一个镜像层 | 一个 Dockerfile 中只能有一个生效的 `CMD`(多个时以最后一个为准) | | **覆盖性** | 无法被覆盖,固化在镜像中 | 可以被 `docker run` 后的参数覆盖 | | **典型用途** | 安装依赖、创建目录、修改文件权限等 | 定义容器启动后运行的默认程序 | #### **示例** ```dockerfile # RUN 示例:构建阶段安装 Python RUN apt-get update && apt-get install -y python3 # CMD 示例:容器启动时运行 Python 脚本 CMD ["python3", "app.py"] ``` #### **细节说明** * **`RUN` 的两种格式**: ```dockerfile # Shell 格式(默认使用 /bin/sh -c 执行) RUN echo "Hello" > /tmp/hello.txt # Exec 格式(直接执行命令,避免 shell 解析) RUN ["/bin/bash", "-c", "echo 'Hello' > /tmp/hello.txt"] ``` * **`CMD` 的三种格式**: ```dockerfile # Exec 格式(推荐,避免 shell 干扰信号传递) CMD ["nginx", "-g", "daemon off;"] # Shell 格式(实际会包装成 /bin/sh -c) CMD nginx -g "daemon off;" # 为 ENTRYPOINT 提供默认参数 ENTRYPOINT ["nginx"] CMD ["-g", "daemon off;"] ``` *** ### **2. ADD 命令 vs COPY 命令** #### **核心区别** | **特性** | **ADD** | **COPY** | |----------------|----------------------------------|----------------------------------| | **功能扩展** | 支持自动解压 **本地 tar 文件**,支持从 **URL 下载文件** | 仅复制本地文件/目录到镜像中 | | **行为透明性** | 可能隐含解压或下载操作,需谨慎使用 | 行为明确,仅复制文件 | | **推荐场景** | 需要解压 tar 包或从 URL 获取文件 | 普通文件复制(更推荐优先使用) | #### **示例** ```dockerfile # ADD 示例:自动解压本地 tar 包到镜像的 /app 目录 ADD app.tar.gz /app/ # ADD 示例:从 URL 下载文件到镜像(不推荐,需注意网络稳定性) ADD https://example.com/file.txt /tmp/ # COPY 示例:复制本地文件到镜像的 /app 目录 COPY app.py /app/ COPY requirements.txt /app/ ``` #### **细节说明** * **`ADD` 的隐式行为**: * 如果源路径是 **本地 tar 文件**(如 `.tar`, `.tar.gz`),`ADD` 会自动解压到目标路径。 * 若源路径是 **URL**,`ADD` 会尝试下载文件(但下载后的文件权限默认是 `600`,需手动调整)。 * **`COPY` 的明确性**: * 仅支持本地文件复制,无副作用,更符合最小化操作原则。 * 支持多阶段构建中的 `--from` 参数,从其他阶段复制文件。 *** ### **3. 最佳实践总结** 1. **优先使用 `COPY`**: * 除非需要 `ADD` 的解压或 URL 下载功能,否则优先用 `COPY` 保证行为透明。 2. **减少 `RUN` 的层数**: * 合并多个 `RUN` 命令以减少镜像层数(例如用 `&&` 连接命令): ```dockerfile RUN apt-get update \ && apt-get install -y python3 \ && rm -rf /var/lib/apt/lists/* ``` 3. **合理选择 `CMD` 格式**: * 使用 **Exec 格式**避免 Shell 信号传递问题(例如容器优雅退出)。 4. **谨慎使用 `ADD` 的 URL 功能**: * 下载文件建议在 `RUN` 中结合 `wget` 或 `curl`,以便处理错误和权限。 *** ### **完整 Dockerfile 示例** ```dockerfile # 使用基础镜像 FROM ubuntu:22.04 # 安装依赖(RUN) RUN apt-get update \ && apt-get install -y python3 \ && rm -rf /var/lib/apt/lists/* # 复制本地文件(COPY) COPY app.py /app/ COPY requirements.txt /app/ # 解压 tar 包(ADD) ADD data.tar.gz /app/data/ # 设置工作目录 WORKDIR /app # 定义容器启动命令(CMD) CMD ["python3", "app.py"] ``` *** 通过合理选择指令,可以优化镜像构建效率、减少体积,并确保容器行为符合预期。 --- --- url: /src/technology/dateblog/2026/03/20260326-docker安装gitea和drone实现cicd.md --- ## gitea ```yml networks: 1panel-network: external: true services: gitea: container_name: ${CONTAINER_NAME} deploy: resources: limits: cpus: ${CPUS} memory: ${MEMORY_LIMIT} environment: - USER_UID=1000 - USER_GID=1000 - GITEA__database__DB_TYPE=${PANEL_DB_TYPE} - GITEA__database__HOST=${PANEL_DB_HOST}:${PANEL_DB_PORT} - GITEA__database__NAME=${PANEL_DB_NAME} - GITEA__database__USER=${PANEL_DB_USER} - GITEA__database__PASSWD=${PANEL_DB_USER_PASSWORD} image: commitgo/gitea-ee:25.4.3 labels: createdBy: Apps networks: - 1panel-network ports: - ${HOST_IP}:${PANEL_APP_PORT_HTTP}:3000 - ${HOST_IP}:${PANEL_APP_PORT_SSH}:22 restart: always volumes: - ./data:/data - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro ``` ## drone ```yml services: drone: image: drone/drone container_name: drone environment: - DRONE_GITEA_SERVER=https://gitea.xxx.top - DRONE_GITEA_CLIENT_ID=xxxxxx-1fcf-xxxx-bc58-42ca3096de9f - DRONE_GITEA_CLIENT_SECRET=gto_xnvxxxxxxxxxxxxxxxxxxxxxxxiyk4sqbbeod7ukiq - DRONE_RPC_SECRET=xxxxx-xxxxxx-undecided - DRONE_SERVER_HOST=drone.xxxx.top - DRONE_SERVER_PROTO=https - DRONE_USER_CREATE=username:xxxxxx,admin:true restart: always volumes: - ./data:/data extra_hosts: - "host.docker.internal:host-gateway" ports: - 10005:80 - 10006:443 ``` ## drone-runner-docker ```yml services: runner: image: drone/drone-runner-docker container_name: drone-runner-docker environment: - DRONE_RPC_PROTO=https - DRONE_RPC_HOST=drone.xxxxx.top - DRONE_RPC_SECRET=xxxxx-xxxxxx-undecided - DRONE_RUNNER_CAPACITY=2 - DRONE_RUNNER_NAME=my-runner restart: always volumes: - /var/run/docker.sock:/var/run/docker.sock - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins ports: - 10007:3000 extra_hosts: - "host.docker.internal:host-gateway" ``` --- --- url: /src/technology/dateblog/2026/03/20260326-docker安装jenkins.md --- ## docker-compose.yml ```yml networks: 1panel-network: external: true services: jenkins: container_name: jenkins environment: - JENKINS_UC="https://mirrors.tuna.tsinghua.edu.cn/jenkins/" - JENKINS_UC_DOWNLOAD="https://mirrors.tuna.tsinghua.edu.cn/jenkins/" - JENKINS_OPTS="-Dhudson.model.UpdateCenter.updateCenterUrl=https://mirrors.tuna.tsinghua.edu.cn/jenkins/updates/update-center.json" image: jenkins/jenkins:2.541.3-lts labels: createdBy: Apps networks: - 1panel-network extra_hosts: - "host.docker.internal:host-gateway" ports: - 8080:8080 privileged: true restart: always user: root volumes: - ./data:/var/jenkins_home - /var/run/docker.sock:/var/run/docker.sock - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins - /usr/bin/docker:/usr/bin/docker ``` --- --- url: >- /src/technology/dateblog/2026/03/20260324-docker容器使用host-docker-internal域名容器内部访问宿主机.md --- 您可以通过 `host.docker.internal` 域名从容器内访问宿主机,但需要显式配置 DNS 解析。以下是两种场景的具体用法: *** ### 1. 使用 `docker run` 在 `docker run` 命令中添加 `--add-host` 参数: ```bash docker run --add-host="host.docker.internal:host-gateway" -it your-image ``` * `host-gateway` 是 Docker 内置的特殊值,会自动替换为宿主机在容器网络中的网关 IP(即宿主机地址)。 * 添加后,容器内即可通过 `host.docker.internal` 访问宿主机。 **示例**:运行一个交互式容器并测试连通性 ```bash docker run --add-host="host.docker.internal:host-gateway" -it alpine sh # 在容器内执行 ping host.docker.internal ``` *** ### 2. 使用 `docker-compose` 在 `docker-compose.yml` 文件中添加 `extra_hosts` 配置: ```yaml extra_hosts: - "host.docker.internal:host-gateway" ``` 例如: ```yaml services: your-service: image: your-image extra_hosts: - "host.docker.internal:host-gateway" ``` **完整示例**(`docker-compose.yml`): ```yaml version: '3.8' services: app: image: nginx extra_hosts: - "host.docker.internal:host-gateway" ports: - "80:80" ``` 启动服务: ```bash docker-compose up ``` *** ### 说明 * **跨平台兼容**:在 Linux 上使用原生 Docker Engine 时,`host.docker.internal` 默认不可用,必须通过上述方式添加。在 Docker Desktop(Mac/Windows)上该名称通常已内置,但显式添加也完全可行。 * **`host-gateway` 的原理**:Docker 会自动解析出宿主机在容器网络中的网关地址,并写入容器的 `/etc/hosts` 文件,效果等同于直接指定 IP。 * **验证**:进入容器后,可以执行 `cat /etc/hosts` 查看是否已正确添加条目。 通过这种方式,无论您使用的是 `docker run` 还是 `docker-compose`,容器都能稳定地访问宿主机上运行的服务(如数据库、API 等)。 --- --- url: /src/technology/dateblog/2025/11/20251128-docker挂载部署mysql8-4-2.md --- # docker挂载部署mysql8.4.2 ## 目录结构 ```shell mkdir -p mysql/{logs,data,conf} ``` ```markdown mysql/ ├── docker-compose.yml ├── logs/ ├── data/ ├── conf/ │ └── my.cnf ``` ## docker-compose.yml ```yaml services: mysql: image: mysql:8.4.2 container_name: mysql restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: your_root_password # 设置 root 用户密码 MYSQL_DATABASE: my_database # 可选:初始化时创建的数据库 MYSQL_USER: app_user # 可选:创建普通用户 MYSQL_PASSWORD: user_password # 可选:普通用户的密码 TZ: Asia/Shanghai # 设置时区 ports: - "3306:3306" # 端口映射:宿主机端口:容器端口[citation:1] volumes: # 挂载自定义配置文件目录 - ./conf/my.cnf:/etc/mysql/conf.d/my.cnf # 挂载数据目录以实现持久化 - ./data:/var/lib/mysql # 可选:挂载日志目录 - ./logs:/var/log/mysql ``` ## my.cnf ``` # ====================== # 客户端配置(连接MySQL时使用) # ====================== [client] port = 3306 # 客户端连接端口(默认3306) socket = /var/run/mysqld/mysqld.sock # Socket文件路径(用于本地连接) default-character-set = utf8mb4 # 默认字符集(支持emoji表情) # ====================== # MySQL客户端配置(mysql命令行工具) # ====================== [mysql] default-character-set = utf8mb4 # 客户端默认字符集 no-auto-rehash # 禁用自动重新解析(加速命令行启动) # ====================== # MySQL服务端核心配置 # ====================== [mysqld] # -------------------- 基础设置 -------------------- user = mysql # MySQL服务运行用户(安全起见,不要用root) port = 3306 # 服务监听端口 datadir = /var/lib/mysql # 数据存储目录(Docker中必须与volume挂载路径一致) socket = /var/run/mysqld/mysqld.sock # Socket文件路径(本地连接用) pid-file = /var/run/mysqld/mysqld.pid # PID文件路径(用于进程管理) character-set-server = utf8mb4 # 服务器默认字符集(必须设置!) collation-server = utf8mb4_unicode_ci # 排序规则(支持多语言) skip-name-resolve # 禁用DNS反向解析(提升连接速度,避免DNS延迟) # -------------------- 连接设置 -------------------- max_connections = 1000 # 最大并发连接数(根据服务器资源调整) max_connect_errors = 10000 # 允许的连接错误次数(防攻击) back_log = 300 # 连接请求队列长度 thread_cache_size = 64 # 线程缓存池大小(减少线程创建开销) interactive_timeout = 28800 # 交互式连接超时时间(秒) wait_timeout = 28800 # 非交互式连接超时时间(秒) # -------------------- 日志与监控 -------------------- log_error = /var/log/mysql/error.log # 错误日志路径 slow_query_log = 1 # 启用慢查询日志 slow_query_log_file = /var/log/mysql/slow-query.log # 慢查询日志路径 long_query_time = 2 # 慢查询阈值(秒) log_queries_not_using_indexes = 1 # 不记录未使用索引的查询(建议设为1) log_slow_admin_statements = 1 # 记录慢的管理语句(如ALTER) log_throttle_queries_not_using_indexes = 60 # 未使用索引查询的节流间隔(秒) # -------------------- 二进制日志 -------------------- server-id = 1 # 服务器ID(主从复制必需) log_bin = /var/log/mysql/mysql-bin.log # 二进制日志路径 binlog_format = ROW # 二进制日志格式(ROW安全,STATEMENT有风险) binlog_expire_logs_seconds = 604800 # 二进制日志保留时间(7天) max_binlog_size = 500M # 单个二进制日志最大大小 binlog_cache_size = 1M # 二进制日志缓存大小 sync_binlog = 1 # 每次事务提交刷写二进制日志(最安全) ``` ## 启动命令 ```shell # 清空data和logs目录 rm -rf ./data/* ./logs/* # 给权限 chmod -R 777 ./data ./logs # 运行启动 docker-compose up -d ``` * docker ps 查看容器状态 * 在logs目录下error.log查看日志 --- --- url: /src/technology/dateblog/2025/10/20251029-docker搭建redis哨兵集群.md --- # docker搭建redis哨兵集群 ## 准备工作 ### 目录结构 ```markdown redis-sentinel/ ├── docker-compose.yml ├── master/ │ ├── conf/redis.conf │ ├── data/ │ └── logs/ ├── slave1/ │ ├── conf/redis.conf │ ├── data/ │ └── logs/ ├── slave2/ │ ├── conf/redis.conf │ ├── data/ │ └── logs/ └── sentinel/ ├── sentinel1.conf ├── sentinel2.conf └── sentinel3.conf ``` ### docker-compose.yml ```yaml services: # Redis 主节点 redis-master: image: redis:6.2 container_name: redis-master ports: - "7381:7381" - 17381:17381 volumes: - ./master/conf/redis.conf:/etc/redis/redis.conf - ./master/data:/data - ./master/logs:/logs command: ["redis-server", "/etc/redis/redis.conf"] networks: redis-sentinel: ipv4_address: 172.28.0.10 # Redis 从节点1 redis-slave1: image: redis:6.2 container_name: redis-slave1 ports: - "7382:7382" - 17382:17382 volumes: - ./slave1/conf/redis.conf:/etc/redis/redis.conf - ./slave1/data:/data - ./slave1/logs:/logs command: ["redis-server", "/etc/redis/redis.conf"] depends_on: - redis-master networks: redis-sentinel: ipv4_address: 172.28.0.11 # Redis 从节点2 redis-slave2: image: redis:6.2 container_name: redis-slave2 ports: - "7383:7383" - 17383:17383 volumes: - ./slave2/conf/redis.conf:/etc/redis/redis.conf - ./slave2/data:/data - ./slave2/logs:/logs command: ["redis-server", "/etc/redis/redis.conf"] depends_on: - redis-master networks: redis-sentinel: ipv4_address: 172.28.0.12 # 哨兵节点1 redis-sentinel1: image: redis:6.2 container_name: redis-sentinel1 ports: - "7384:7384" - 17384:17384 volumes: - ./sentinel/sentinel1.conf:/etc/redis/sentinel.conf - ./sentinel/logs:/logs command: ["redis-sentinel", "/etc/redis/sentinel.conf"] depends_on: - redis-master - redis-slave1 - redis-slave2 networks: redis-sentinel: ipv4_address: 172.28.0.20 # 哨兵节点2 redis-sentinel2: image: redis:6.2 container_name: redis-sentinel2 ports: - "7385:7385" - 17385:17385 volumes: - ./sentinel/sentinel2.conf:/etc/redis/sentinel.conf - ./sentinel/logs:/logs command: ["redis-sentinel", "/etc/redis/sentinel.conf"] depends_on: - redis-master - redis-slave1 - redis-slave2 networks: redis-sentinel: ipv4_address: 172.28.0.21 # 哨兵节点3 redis-sentinel3: image: redis:6.2 container_name: redis-sentinel3 ports: - "7386:7386" - 17386:17386 volumes: - ./sentinel/sentinel3.conf:/etc/redis/sentinel.conf - ./sentinel/logs:/logs command: ["redis-sentinel", "/etc/redis/sentinel.conf"] depends_on: - redis-master - redis-slave1 - redis-slave2 networks: redis-sentinel: ipv4_address: 172.28.0.22 networks: redis-sentinel: driver: bridge ipam: config: - subnet: 172.28.0.0/16 gateway: 172.28.0.1 ``` ### 各节点配置文件 ::: code-group ```[redis-master redis.conf] port 7381 bind 0.0.0.0 daemonize no pidfile /var/run/redis.pid logfile "/logs/redis.log" dir /data # 持久化配置 save 900 1 save 300 10 save 60 10000 rdbcompression yes dbfilename dump.rdb # 主从配置 masterauth "123456" requirepass "123456" # 集群配置 cluster-enabled no appendonly yes appendfilename "appendonly.aof" # 集群节点通告 IP(客户端连接时使用的 IP) # 重要:必须设置为节点真实公网 IP 或内网 IP # 注意:如果使用 Docker,需设置为宿主机 IP 或容器网络 IP cluster-announce-ip 10.1.62.102 # 集群节点通告端口(客户端连接时使用的端口) # 重要:必须与实际服务端口一致(6381) cluster-announce-port 7381 # 集群节点通告集群总线端口(节点间通信端口) # 说明:通常为主端口 + 10000(6381 + 10000 = 16381) # 重要:必须开放此端口(防火墙/安全组) cluster-announce-bus-port 17381 ``` ```[redis-slave1 redis.conf] port 7382 bind 0.0.0.0 daemonize no pidfile /var/run/redis.pid logfile "/logs/redis.log" dir /data # 持久化配置 save 900 1 save 300 10 save 60 10000 rdbcompression yes dbfilename dump.rdb # 主从配置 slaveof 10.1.62.102 7381 masterauth "123456" requirepass "123456" # 集群配置 cluster-enabled no appendonly yes appendfilename "appendonly.aof" # 集群节点通告 IP(客户端连接时使用的 IP) # 重要:必须设置为节点真实公网 IP 或内网 IP # 注意:如果使用 Docker,需设置为宿主机 IP 或容器网络 IP cluster-announce-ip 10.1.62.102 # 集群节点通告端口(客户端连接时使用的端口) # 重要:必须与实际服务端口一致(6381) cluster-announce-port 7382 # 集群节点通告集群总线端口(节点间通信端口) # 说明:通常为主端口 + 10000(6381 + 10000 = 16381) # 重要:必须开放此端口(防火墙/安全组) cluster-announce-bus-port 17382 ``` ```[redis-slave2 redis.conf] port 7383 bind 0.0.0.0 daemonize no pidfile /var/run/redis.pid logfile "/logs/redis.log" dir /data # 持久化配置 save 900 1 save 300 10 save 60 10000 rdbcompression yes dbfilename dump.rdb # 主从配置 slaveof 10.1.62.102 7381 masterauth "123456" requirepass "123456" # 集群配置 cluster-enabled no appendonly yes appendfilename "appendonly.aof" # 集群节点通告 IP(客户端连接时使用的 IP) # 重要:必须设置为节点真实公网 IP 或内网 IP # 注意:如果使用 Docker,需设置为宿主机 IP 或容器网络 IP cluster-announce-ip 10.1.62.102 # 集群节点通告端口(客户端连接时使用的端口) # 重要:必须与实际服务端口一致(6381) cluster-announce-port 7383 # 集群节点通告集群总线端口(节点间通信端口) # 说明:通常为主端口 + 10000(6381 + 10000 = 16381) # 重要:必须开放此端口(防火墙/安全组) cluster-announce-bus-port 17383 ``` ```[redis-sentinel1 redis.conf] # 哨兵服务监听端口(默认26379,这里自定义为7384) port 7384 # 绑定IP地址(0.0.0.0表示监听所有网络接口) bind 0.0.0.0 # 以守护进程方式运行(no表示前台运行,适合Docker环境) daemonize no # 哨兵进程ID文件路径 pidfile /var/run/redis-sentinel.pid # 日志文件路径(哨兵日志) logfile "/logs/sentinel.log" # 工作目录(用于存储临时文件,如sentinel的配置文件) dir /tmp # ================== 哨兵监控配置 ================== # 监控主节点配置 # mymaster: 自定义主节点标识名(需与后续配置一致) # 10.1.62.102: 主节点的IP地址(必须是能被哨兵访问的IP) # 7381: 主节点的端口号(与主节点配置中的port一致) # 2: 哨兵最低通过票数(quorum),表示需要至少2个哨兵同意才能进行故障转移 sentinel monitor mymaster 10.1.62.102 7381 2 # 设置主节点的认证密码 # mymaster: 与sentinel monitor中定义的标识名一致 # 123456: 主节点的密码(与主节点配置中的requirepass一致) sentinel auth-pass mymaster 123456 # 主节点主观下线检测时间(毫秒) # 5000: 如果主节点连续5000毫秒无响应,则标记为主观下线 sentinel down-after-milliseconds mymaster 5000 # 故障转移时并行同步的从节点数量 # 1: 故障转移时,同时最多有1个从节点进行同步(避免主节点负载过高) sentinel parallel-syncs mymaster 1 # 故障转移超时时间(毫秒) # 10000: 如果故障转移过程超过10000毫秒未完成,则视为失败 sentinel failover-timeout mymaster 10000 # 集群节点通告 IP(客户端连接时使用的 IP) # 重要:必须设置为节点真实公网 IP 或内网 IP # 注意:如果使用 Docker,需设置为宿主机 IP 或容器网络 IP cluster-announce-ip 10.1.62.102 # 集群节点通告端口(客户端连接时使用的端口) # 重要:必须与实际服务端口一致(6381) cluster-announce-port 7384 # 集群节点通告集群总线端口(节点间通信端口) # 说明:通常为主端口 + 10000(6381 + 10000 = 16381) # 重要:必须开放此端口(防火墙/安全组) cluster-announce-bus-port 17384 ``` ```[redis-sentinel2 redis.conf] port 7385 bind 0.0.0.0 daemonize no pidfile /var/run/redis-sentinel.pid logfile "/logs/sentinel.log" dir /tmp sentinel monitor mymaster 10.1.62.102 7381 2 sentinel auth-pass mymaster 123456 sentinel down-after-milliseconds mymaster 5000 sentinel parallel-syncs mymaster 1 sentinel failover-timeout mymaster 10000 # 集群节点通告 IP(客户端连接时使用的 IP) # 重要:必须设置为节点真实公网 IP 或内网 IP # 注意:如果使用 Docker,需设置为宿主机 IP 或容器网络 IP cluster-announce-ip 10.1.62.102 # 集群节点通告端口(客户端连接时使用的端口) # 重要:必须与实际服务端口一致(6381) cluster-announce-port 7385 # 集群节点通告集群总线端口(节点间通信端口) # 说明:通常为主端口 + 10000(6381 + 10000 = 16381) # 重要:必须开放此端口(防火墙/安全组) cluster-announce-bus-port 17385 ``` ```[redis-sentinel3 redis.conf] port 7386 bind 0.0.0.0 daemonize no pidfile /var/run/redis-sentinel.pid logfile "/logs/sentinel.log" dir /tmp sentinel monitor mymaster 10.1.62.102 7381 2 sentinel auth-pass mymaster 123456 sentinel down-after-milliseconds mymaster 5000 sentinel parallel-syncs mymaster 1 sentinel failover-timeout mymaster 10000 # 集群节点通告 IP(客户端连接时使用的 IP) # 重要:必须设置为节点真实公网 IP 或内网 IP # 注意:如果使用 Docker,需设置为宿主机 IP 或容器网络 IP cluster-announce-ip 10.1.62.102 # 集群节点通告端口(客户端连接时使用的端口) # 重要:必须与实际服务端口一致(6381) cluster-announce-port 7386 # 集群节点通告集群总线端口(节点间通信端口) # 说明:通常为主端口 + 10000(6381 + 10000 = 16381) # 重要:必须开放此端口(防火墙/安全组) cluster-announce-bus-port 17386 ``` ::: ## 启动和验证 ```shell # 文件权限 chmod -R 777 redis-sentinel # 启动服务 docker-compose up -d # 查看服务状态 docker-compose ps # 检查主从复制状态 docker exec -it redis-master redis-cli -a 123456 info replication # 检查哨兵状态 docker exec -it redis-sentinel1 redis-cli -p 26379 info sentinel ``` ### springboot配置示例 ```yml spring: redis: password: 123456 database: 0 timeout: 6000ms lettuce: pool: max-active: 8 max-wait: -1ms max-idle: 8 min-idle: 0 sentinel: master: mymaster nodes: - 10.1.62.102:7384 - 10.1.62.102:7385 - 10.1.62.102:7386 ``` --- --- url: /src/technology/dateblog/2025/10/20251028-docker搭建redis的3主3从集群测试.md --- # docker搭建redis的3主3从集群测试 [参考文档](https://www.cnblogs.com/wt20/p/17515038.html) ## 1、准备工作 ### 使用脚本创建目录结构 ```shell #!/bin/bash # 集群节点通告 IP(客户端连接时使用的 IP) # 重要:必须设置为节点真实公网 IP 或内网 IP # 注意:如果使用 Docker,需设置为宿主机 IP 或容器网络 IP cluster_announce_ip=10.1.62.102 for port in $(seq 6381 6386); do conf_dir=./${port}/conf conf_file=${conf_dir}/redis.conf log_dir=./${port}/logs if [ ! -d ${conf_dir} ]; then mkdir -p ${conf_dir} fi if [ ! -d ${log_dir} ]; then mkdir -p ${log_dir} fi if [ -f ${conf_file} ]; then rm -f ${conf_file} fi touch ${conf_file} cat << EOF > ${conf_file} bind 0.0.0.0 port ${port} requirepass 123456 masterauth 123456 dir /data logfile /logs/nodes-${port}.log appendonly yes cluster-enabled yes cluster-config-file nodes-${port}.conf cluster-node-timeout 5000 cluster-announce-ip ${cluster_announce_ip} cluster-announce-port ${port} cluster-announce-bus-port 1${port} EOF done ``` ### docker-compose.yml ```yaml services: redis-6381: image: redis:6.2 container_name: redis-6381 ports: - 6381:6381 - 16381:16381 volumes: - ./6381/conf/redis.conf:/etc/redis.conf - ./6381/data:/data - ./6381/logs:/logs command: ["redis-server", "/etc/redis.conf"] environment: - TZ=Asia/Shanghai networks: redis-cluster: aliases: - redis-6381 ipv4_address: 172.19.0.101 redis-6382: image: redis:6.2 container_name: redis-6382 ports: - 6382:6382 - 16382:16382 volumes: - ./6382/conf/redis.conf:/etc/redis.conf - ./6382/data:/data - ./6382/logs:/logs command: ["redis-server", "/etc/redis.conf"] environment: - TZ=Asia/Shanghai networks: redis-cluster: aliases: - redis-6382 ipv4_address: 172.19.0.102 redis-6383: image: redis:6.2 container_name: redis-6383 ports: - 6383:6383 - 16383:16383 volumes: - ./6383/conf/redis.conf:/etc/redis.conf - ./6383/data:/data - ./6383/logs:/logs command: ["redis-server", "/etc/redis.conf"] environment: - TZ=Asia/Shanghai networks: redis-cluster: aliases: - redis-6383 ipv4_address: 172.19.0.103 redis-6384: image: redis:6.2 container_name: redis-6384 ports: - 6384:6384 - 16384:16384 volumes: - ./6384/conf/redis.conf:/etc/redis.conf - ./6384/data:/data - ./6384/logs:/logs command: ["redis-server", "/etc/redis.conf"] environment: - TZ=Asia/Shanghai networks: redis-cluster: aliases: - redis-6384 ipv4_address: 172.19.0.104 redis-6385: image: redis:6.2 container_name: redis-6385 ports: - 6385:6385 - 16385:16385 volumes: - ./6385/conf/redis.conf:/etc/redis.conf - ./6385/data:/data - ./6385/logs:/logs command: ["redis-server", "/etc/redis.conf"] environment: - TZ=Asia/Shanghai networks: redis-cluster: aliases: - redis-6385 ipv4_address: 172.19.0.105 redis-6386: image: redis:6.2 container_name: redis-6386 ports: - 6386:6386 - 16386:16386 volumes: - ./6386/conf/redis.conf:/etc/redis.conf - ./6386/data:/data - ./6386/logs:/logs command: ["redis-server", "/etc/redis.conf"] environment: - TZ=Asia/Shanghai networks: redis-cluster: aliases: - redis-6386 ipv4_address: 172.19.0.106 networks: redis-cluster: name: redis-cluster driver: bridge ipam: config: - subnet: 172.19.0.0/16 gateway: 172.19.0.1 ``` ### redis.conf ``` # 监听所有网络接口(0.0.0.0 表示接受来自任何 IP 的连接) # 重要:生产环境建议绑定特定 IP 或使用防火墙限制访问 bind 0.0.0.0 # Redis 服务端口(默认 6379,这里设置为 6381) port 6381 # 设置 Redis 访问密码(客户端必须提供此密码才能连接) # 重要:生产环境必须设置强密码,避免使用简单密码 requirepass 123456 # 当主从复制时,从节点连接主节点所需的密码(必须与 requirepass 一致) # 用于集群中节点间通信的认证 masterauth 123456 # 数据库工作目录(存储持久化文件如 RDB/AOF) # 重要:确保该目录存在且 Redis 进程有写入权限 dir /data # 日志文件路径(记录 Redis 运行日志) # 重要:建议使用绝对路径,确保日志目录存在 logfile /logs/nodes-6381.log # 启用 AOF 持久化(Append Only File) # 优点:数据安全性高,可恢复到几乎最新的状态 # 缺点:性能略低于 RDB,文件较大 appendonly yes # 集群配置文件(自动创建/更新,用于保存集群拓扑信息) # 重要:必须与 cluster-node-timeout 配合使用 cluster-enabled yes # 集群节点配置文件(存储集群节点信息) # 重要:文件名需与当前节点端口匹配(如 6381.conf) cluster-config-file nodes-6381.conf # 集群节点超时时间(毫秒) # 说明:超过此时间未收到节点心跳,视为失效 # 建议值:5000-10000(根据网络环境调整) cluster-node-timeout 5000 # 集群节点通告 IP(客户端连接时使用的 IP) # 重要:必须设置为节点真实公网 IP 或内网 IP # 注意:如果使用 Docker,需设置为宿主机 IP 或容器网络 IP cluster-announce-ip 10.1.62.102 # 集群节点通告端口(客户端连接时使用的端口) # 重要:必须与实际服务端口一致(6381) cluster-announce-port 6381 # 集群节点通告集群总线端口(节点间通信端口) # 说明:通常为主端口 + 10000(6381 + 10000 = 16381) # 重要:必须开放此端口(防火墙/安全组) cluster-announce-bus-port 16381 ``` ## 2、启动集群工作 ```shell # 创建网络 docker network create redis-cluster # 赋予目录权限 chmod -R 777 挂载目录 # 启动redis docker-compose up -d # 进入任意redis容器 docker exec redis-6381 /bin/bash # 清空所有redis数据 redis-cli -h 172.19.0.101 -p 6381 -a 123456 FLUSHALL && redis-cli -h 172.19.0.101 -p 6381 -a 123456 CLUSTER RESET redis-cli -h 172.19.0.102 -p 6382 -a 123456 FLUSHALL && redis-cli -h 172.19.0.102 -p 6382 -a 123456 CLUSTER RESET redis-cli -h 172.19.0.103 -p 6383 -a 123456 FLUSHALL && redis-cli -h 172.19.0.103 -p 6383 -a 123456 CLUSTER RESET redis-cli -h 172.19.0.104 -p 6384 -a 123456 FLUSHALL && redis-cli -h 172.19.0.104 -p 6384 -a 123456 CLUSTER RESET redis-cli -h 172.19.0.105 -p 6385 -a 123456 FLUSHALL && redis-cli -h 172.19.0.105 -p 6385 -a 123456 CLUSTER RESET redis-cli -h 172.19.0.106 -p 6386 -a 123456 FLUSHALL && redis-cli -h 172.19.0.106 -p 6386 -a 123456 CLUSTER RESET # 创建集群 redis-cli -p 6381 -a 123456 --cluster create 172.19.0.101:6381 172.19.0.102:6382 172.19.0.103:6383 172.19.0.104:6384 172.19.0.105:6385 172.19.0.106:6386 --cluster-replicas 1 ``` ## 3、测试集群功能 ```shell docker exec -it redis-1 redis-cli -c set key1 "key1" docker exec -it redis-1 redis-cli -c set key2 "key2" docker exec -it redis-2 redis-cli -c get key1 docker exec -it redis-3 redis-cli -c get key2 ``` ### springboot配置示例 ```yaml spring: redis: cluster: nodes: - 10.1.62.102:6381 - 10.1.62.102:6382 - 10.1.62.102:6383 - 10.1.62.102:6384 - 10.1.62.102:6385 - 10.1.62.102:6386 max-redirects: 3 # 最大重定向次数,在集群中执行命令遇到重定向错误时,最大重试次数 timeout: 5000ms lettuce: pool: max-active: 8 # 连接池最大连接数(使用负值表示没有限制) max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制) max-idle: 8 # 连接池中的最大空闲连接 min-idle: 0 # 连接池中的最小空闲连接 password: 123456 ``` ## 4、总结 * 高可用 * 主节点故障时,从节点自动接管 * 服务零中断,业务无感知 * 高性能 * 数据分片到3主节点,并发处理 * 支持读写分离(需客户端配置) * 高扩展 * 在线扩容,不停机添加节点 * 数据自动重分布,无需人工干预 * 数据安全 * 每个主节点都有完整副本 * 多节点备份,数据不丢失 --- --- url: >- /src/technology/dateblog/2025/06/20250612-element-ui-dialog和drawer-jsx-封装命令式调用.md --- # element ui dialog和drawer jsx 封装函数式调用 ## VNode和组件定义对象的区别 * VNode比较轻量 * 返回的是渲染结果,不是组件定义 * 可以直接插入模板中使用 * 没有创建新组件,无额外实例开销 * 不能拥有自己的状态或生命周期 * 适合简单内容渲染 * 组件定义对象 * 创建了一个全新的 Vue 组件 * 有自己的组件生命周期和状态管理 * 会产生额外的组件实例开销 * 适合需要封装独立逻辑的场景 ## 封装 el-dialog ::: code-group ```jsx [dialog.jsx] import Vue, {defineComponent, ref} from 'vue'; /** * * @param options * @param {Function | Object} options.content -- 渲染函数 或者 组件定义对象 * @param {Object} [options.props] -- dialog属性配置 * @param {Object} [options.slots] -- dialog插槽配置 * @param {Object} [options.on] -- dialog事件配置 * @param {Boolean} [options.drag] -- dialog拖拽 默认true * @returns {Object} - instance返回dialog实例 * @property {Function} destroy - 销毁dialog实例 */ function create(options) { const { content: Content, props = {}, slots, on = {}, drag = true, } = options; const visible = ref(false); const closed = on['closed'] on['update:visible'] = val => visible.value = val on['closed'] = () => { closed?.(); clearDom() } const directives = [] if (drag) { directives.push({ name: 'dialogDrag', value: true, }) } const contentRef = ref(null) const Profile = Vue.extend(defineComponent({ setup() { return () => { typeof Content === 'function' ? Content() : contentRef.value = el}/> } } })) const instance = new Profile(); instance.$mount(); document.body.appendChild(instance.$el); visible.value = true; function clearDom() { instance.$destroy(); instance.$el.remove() } function destroy() { visible.value = false } function getContentRef() { if (typeof Content === 'function') { return } return contentRef } instance.destroy = destroy instance.getContentRef = getContentRef return instance } export default { create } ``` ::: ## 封装el-drawer ::: code-group ```jsx [drawer.jsx] import Vue, {defineComponent, onMounted, ref} from 'vue'; /** * * @param options * @param {Object} [options.props] -- drawer属性配置 * @param {Object} [options.slots] -- drawer插槽配置 * @param {Object} [options.on] -- drawer事件配置 * @param {Function | Object} options.content -- VNode 或者 组件定义对象 * * @returns {Object} - instance返回el-drawer实例 * @property {Function} destroy - 销毁drawer实例 */ function create(options) { const { content: Content, props = {}, slots, on = {} } = options const visible = ref(false); const drawerRef = ref(null); const contentRef = ref(null) const Profile = Vue.extend(defineComponent({ setup() { const closed = on['closed'] on['update:visible'] = val => visible.value = val on['closed'] = () => { closed?.(); clearDom() } return () => drawerRef.value = el} {...{on}} scopedSlots={slots} props={{ modal: false, direction: 'rtl', size: '75%', visible: visible.value, withHeader: false, 'append-to-body': true, 'destroy-on-close': true, ...props }}> { typeof Content === 'function' ? Content() : contentRef.value = el}/> } } })) const instance = new Profile(); instance.$mount(); document.body.appendChild(instance.$el); visible.value = true function clearDom() { instance.$destroy(); instance.$el.remove() } function destroy() { drawerRef.value.closeDrawer() } function getRef() { return drawerRef.value } function getContentRef() { if (typeof Content === 'function') { return } return contentRef } instance.destroy = destroy instance.getRef = getRef instance.getContentRef = getContentRef return instance } export default {create} ``` ::: ## 用例 ### VNode用例 ```js [VNode用例] dialog.create({ content: () =>
hello
}) ``` ### 组件定义对象用例 ```js [组件定义对象用例] dialog.create({ // 也可引入.vue文件等 -- Vue组件都行 content: defineComponent({ setup() { return () =>
hello
} }) }) ``` --- --- url: /src/technology/dateblog/2025/06/20250612-element-ui-大图预览函数式调用.md --- # element ui 大图预览函数式调用 ## 上代码 ```js import ImageViewer from 'element-ui/packages/image/src/image-viewer.vue'; import Vue, {defineComponent} from "vue"; import download from "@/plugins/download"; /** * @description 图片预览 * @param {Object} options * @param {Boolean} [options.showDownload] 是否显示下载按钮 * @param {Function} [options.customDownload] 自定义下载方法 * @param {Array} options.urlList 图片地址列表 * @param {Number} [options.zIndex] z-index 默认9999 * @param {Array} [options.actions] 返回VNodes的数组 自定义操作按钮 * @returns {Vue} */ function create(options) { const { showDownload = true, customDownload = (url) => { download.urlDownload(url, Date.now() + url.substring(url.lastIndexOf('.'))) }, urlList, zIndex = 9999, actions = [], } = options; if (showDownload) { actions.unshift(() => customDownload(instance.currentImg)}/>) } const Profile = Vue.extend(ImageViewer); const instance = new Profile({ propsData: { urlList, zIndex, onClose: destroy } }); const container = document.createElement('div'); document.body.appendChild(container); instance.$mount(container); const iconInstances = [] Vue.nextTick(() => { const actionInner = instance.$el.querySelector('.el-image-viewer__actions__inner'); actions.forEach(action => { const ActionIconComp = Vue.extend(defineComponent({ setup() { return () => action() } })); const iconInstance = new ActionIconComp(); iconInstance.$mount(); iconInstances.push(iconInstance); actionInner.appendChild(iconInstance.$el) }) }) function destroy() { iconInstances.forEach(iconInstance => iconInstance.$destroy()); instance.$destroy(); container.remove() } instance.destroy = destroy return instance; } export default {create}; ``` ## 使用示例 ```js import ImageViewer from './image-viewer.js'; // instance 可以获取 'element-ui/packages/image/src/image-viewer.vue'的实例 // instance.currentImg 获取当前图片url const instance = ImageViewer.create({ urlList: [ 'https://****.jpg', 'https://****.jpg', ], actions: [ () => { console.log('instance.currentImg', instance.currentImg) }}/> ] }); ``` --- --- url: >- /src/technology/dateblog/2025/04/20250409-element-ui中el-select-选中其中一项所有项都被选中的bug-使用整个对象作为value值.md --- # element-ui中el-select 选中其中一项所有项都被选中的bug 使用整个对象作为value值 ::: tip 如果 Select 的绑定值为对象类型,请务必指定 value-key 作为它的唯一性标识。 ::: ```html ``` --- --- url: /src/technology/dateblog/2026/04/20260424-erp系统.md --- 我需要多台电脑,多人同时使用,数据同步更新,能够同时多台电脑看到数据实时更新,能在不同地方都可以使用。 界面如我上图所示。要求有以下几点。 1,产品出库要有客户名称选择,产品名称选择,产品型号选择,产品的规格尺寸选择,然后出库数量,装箱的箱数自由填写, 出库日期备注,出库的时候,可以选择同一个客户多种产品出库,每一款产品都要能够体现出它的箱数是多少。 2,产品入库也要有客户名称选择,产品名称选择,产品型号选择,产品的规格和尺寸选择,入库数量,入库日期, 然后入库的时候能够同一个客户多款产品同时入库。 3,产品退货也要有客户名称选择,产品名称选择、产品型号选择、产品的规格尺寸选择, 能够同一个客户退多款产品。就是退货日期。 4,产品存档,要有客户名称选择。然后产品的名称、型号、规格、颜色单位单价, 当前的库存,还有就是产品的损耗率。只有有权限的账号才可以产品单价和损耗率的数值填写,无权限的账号无法填写, 5,客户档案,客户存档要有权限的账号才能看到,没有权限的账号看不到。 6,库存查询,库存查询里也要有客户名称选择、产品名称选择、产品型号选择、产品的规格尺寸选选择。 库存表里要有产品的损耗率,和实际库存。库存里不体现任何金额。 7,月度对账单,月度对账单里有客户选择,然后是月份选择生成对账单。 生成对账单要有产品名称,型号,规格尺寸。生成对账单还要有导出excel表格。对账单里不体现损耗率,只体现实际库存。 对于无价格的产品不显示金额,其他的数据都要显示。月度对账单无权限账号无法查看。 8,送货单打印,送货单我自己公司的名称可以自由修改。送货单打印要有选择客户,选择出库记录, 然后生成送货单,然后是打印,打印送货单的表格里不用显示客户地址联系人联系电话。打印机是针式打印机。 9,账号管理,账号管理里面要有新增账号,然后权限选择。 10,设置权限,分超级管理员,管理员,财务,无权限人员。 11,每日使用日志。 12,超级管理员和管理员都能查看每日使用日志。 --- --- url: /src/technology/dateblog/2025/05/20250523-git 钩子(hooks)介绍及使用.md --- Git 钩子(Hooks)是 Git 版本控制系统中的一种强大机制,允许开发者在特定 Git 事件(如提交、推送、合并等)发生时自动触发自定义脚本。这些脚本可以用于自动化任务、执行检查或强制项目规范。以下是关于 Git 钩子的详细介绍: *** ### **1. Git 钩子的基本概念** * **位置**:钩子脚本存储在 Git 仓库的 `.git/hooks` 目录中。 * **类型**:分为 **客户端钩子**(本地操作触发)和 **服务端钩子**(远程仓库操作触发)。 * **语言**:支持 Shell、Python、Ruby 等脚本语言,只需确保脚本可执行。 * **触发时机**:在特定 Git 生命周期事件(如提交前、推送后)自动执行。 *** ### **2. 客户端钩子(Client-Side Hooks)** #### **提交工作流相关** 1. **`pre-commit`** * **触发时机**:执行 `git commit` 命令时,在输入提交信息前。 * **典型用途**: * 检查代码风格(如 ESLint、Pylint)。 * 阻止包含调试语句(如 `console.log`)的提交。 * 运行自动化测试。 * **终止提交**:如果脚本以非零状态退出,提交会被中止。 2. **`commit-msg`** * **触发时机**:在提交信息被保存后,提交完成前。 * **典型用途**: * 检查提交信息是否符合规范(如遵循 [Conventional Commits](https://www.conventionalcommits.org/))。 * 禁止空提交信息。 * **参数**:接收一个包含提交信息文件的路径(如 `.git/COMMIT_EDITMSG`)。 3. **`post-commit`** * **触发时机**:提交完成后。 * **典型用途**: * 发送通知(如邮件、Slack)。 * 触发后续构建或部署流程。 *** #### **合并与变基相关** * **`pre-rebase`**:在 `git rebase` 前执行,可用于禁止对某些分支变基。 * **`post-merge`**:在 `git merge` 成功后执行,常用于恢复依赖或清理临时文件。 *** #### **推送相关** 1. **`pre-push`** * **触发时机**:`git push` 执行前,远程引用更新前。 * **典型用途**: * 运行完整测试套件。 * 检查推送的代码是否符合安全规则。 * **参数**:接收推送的目标远程名称和 URL。 *** ### **3. 服务端钩子(Server-Side Hooks)** #### **接收推送相关** 1. **`pre-receive`** * **触发时机**:远程仓库收到 `git push` 时,在更新引用前。 * **典型用途**: * 检查提交者的权限。 * 强制代码审查流程(如必须通过 Pull Request)。 * **终止推送**:非零退出状态会拒绝所有推送的提交。 2. **`update`** * **触发时机**:与 `pre-receive` 类似,但针对每个分支分别触发。 * **参数**:接收分支名、旧提交 ID、新提交 ID。 3. **`post-receive`** * **触发时机**:推送完成后。 * **典型用途**: * 触发持续集成(CI)或自动部署。 * 更新问题跟踪系统状态。 *** ### **4. 其他常用钩子** * **`post-checkout`**:在 `git checkout` 或 `git clone` 后触发,常用于切换依赖版本。 * **`pre-applypatch`**:应用补丁前检查代码。 * **`prepare-commit-msg`**:在提交信息编辑器打开前修改默认信息(如添加分支名)。 *** ### **5. 配置与使用** #### **创建钩子** 1. 进入 Git 仓库的 `.git/hooks` 目录。 2. 创建与钩子同名的文件(如 `pre-commit`),删除 `.sample` 后缀(如果有)。 3. 编写脚本并赋予执行权限: ```bash chmod +x .git/hooks/pre-commit ``` #### **示例:`pre-commit` 钩子** ```bash #!/bin/sh # 运行你的打包命令(例如:npm run build、mvn package 等) echo "▶️ 正在执行前端打包命令..." cd ui pnpm run build # 如果打包失败则终止提交 if [ $? -ne 0 ]; then echo "❌ 打包失败,提交中止!" exit 1 fi # 将打包后的文件添加到本次提交(如果需要) git add main/public/ echo "✅ 打包成功,继续提交..." exit 0 ``` *** ### **6. 实际应用场景** * **代码质量**:自动运行 Linter 或格式化工具(如 Prettier)。 * **安全审查**:检查敏感信息(如密码、密钥)是否被提交。 * **工作流集成**:同步任务状态到 JIRA/Trello。 * **部署自动化**:推送后触发生产环境部署。 *** ### **7. 注意事项** 1. **版本控制**:默认钩子不被 Git 跟踪。若需共享钩子,可将脚本存储在项目其他目录,通过符号链接或安装脚本复制到 `.git/hooks`。 2. **跳过钩子**:使用 `--no-verify` 选项(如 `git commit --no-verify`)绕过钩子检查。 3. **性能影响**:避免在钩子中执行耗时操作(如完整测试),否则会拖慢本地操作。 4. **运行路径**:客户端钩子脚本的运行路径是在仓库根目录 *** ### **总结** Git 钩子为开发者提供了高度定制化工作流的能力,通过自动化检查和任务,确保代码质量与流程规范。合理使用钩子可以显著提升团队协作效率,但需注意脚本的健壮性和执行效率。 --- --- url: /src/technology/dateblog/2026/03/20260319-go-http服务项目结构最佳实践.md --- 根据搜索结果和Go社区最佳实践,我为你整理了一份适合**多前台系统后端**的Go项目结构方案。考虑到你有Java背景,我会适当对比说明。 *** # 🏗️ Go HTTP服务项目结构最佳实践 ## 一、核心设计原则 > ⚠️ **Go与Java的重要区别** > > * Go强调 **"少即是多"**,不要为了看起来专业而堆砌目录 > * 先做出能跑的东西,再考虑拆包重构 > * `internal/`、`pkg/`、`cmd/` 是工具,不是必须的模式 ``` ✅ 模块化优先:根据功能边界划分模块 ✅ 标准布局:遵循Go社区约定 ✅ 显式优于隐式:通过目录结构清晰表达代码关系 ``` *** ## 二、推荐项目目录结构 ``` your-project/ ├── cmd/ # 【应用入口】类似Java的main类 │ └── server/ │ └── main.go # 程序启动入口 │ ├── internal/ # 【私有业务代码】类似Java的src/main/java/com/xxx │ ├── config/ # 配置加载与解析 │ │ └── config.go │ ├── handler/ # 【HTTP处理器】类似Java的Controller │ │ ├── user_handler.go │ │ ├── order_handler.go │ │ └── middleware/ # 中间件 │ │ ├── auth.go │ │ └── cors.go │ ├── service/ # 【业务逻辑层】类似Java的Service │ │ ├── user_service.go │ │ └── order_service.go │ ├── repository/ # 【数据访问层】类似Java的DAO/Mapper │ │ ├── user_repo.go │ │ └── order_repo.go │ ├── model/ # 【数据库实体】类似Java的Entity │ │ ├── user.go │ │ └── order.go │ ├── dto/ # 【数据传输对象】类似Java的DTO │ │ ├── request/ # 请求入参 │ │ │ ├── user_req.go │ │ │ └── order_req.go │ │ └── response/ # 响应出参 │ │ ├── user_resp.go │ │ └── common_resp.go │ ├── router/ # 【路由注册】类似Java的@Configuration + @RequestMapping │ │ └── router.go │ └── consts/ # 【常量定义】类似Java的Constants │ ├── code.go # 业务状态码 │ └── msg.go # 提示信息 │ ├── pkg/ # 【公共包】可被外部项目引用,类似Java的公共library │ ├── logger/ # 日志封装 │ ├── utils/ # 工具函数 │ │ ├── encrypt.go │ │ └── validate.go │ └── errors/ # 自定义错误 │ └── errors.go │ ├── api/ # 【API定义】类似Java的OpenAPI/Swagger定义 │ ├── openapi.yaml │ └── proto/ # 如果有gRPC │ └── v1/ │ ├── configs/ # 【配置文件】类似Java的application.yml │ ├── config.yaml │ ├── config.dev.yaml │ └── config.prod.yaml │ ├── scripts/ # 【脚本文件】类似Java的Maven/Gradle脚本 │ ├── init-db.sh │ └── deploy.sh │ ├── tests/ # 【集成测试】 │ └── integration/ │ └── user_test.go │ ├── docs/ # 【文档】 │ └── api.md │ ├── go.mod # 【模块定义】类似Java的pom.xml/build.gradle ├── go.sum # 依赖校验文件 ├── Makefile # 构建脚本(可选) └── README.md ``` *** ## 三、关键目录详解 ### 1️⃣ `cmd/` - 应用入口 ```go // cmd/server/main.go package main import ( "your-project/internal/config" "your-project/internal/router" "your-project/pkg/logger" ) func main() { // 1. 加载配置 cfg := config.Load() // 2. 初始化日志 logger.Init(cfg.Log) // 3. 注册路由 r := router.Setup() // 4. 启动服务 r.Run(":8080") } ``` > 💡 **Java对比**:相当于 `SpringBootApplication` 主类 *** ### 2️⃣ `internal/` - 私有业务代码 **⚠️ 重要**:`internal` 目录下的包**只能被当前项目内部引用**,其他项目无法导入,这是Go的强制约定。 #### 2.1 handler/ - HTTP处理器 ```go // internal/handler/user_handler.go package handler import ( "github.com/gin-gonic/gin" "your-project/internal/dto/request" "your-project/internal/dto/response" "your-project/internal/service" ) type UserHandler struct { userService *service.UserService } func (h *UserHandler) GetUser(c *gin.Context) { // 1. 解析请求参数 var req request.GetUserReq if err := c.ShouldBind(&req); err != nil { response.Error(c, err) return } // 2. 调用服务层 user, err := h.userService.GetByID(req.ID) // 3. 返回响应 response.Success(c, user) } ``` > 💡 **Java对比**:相当于 `@RestController` *** #### 2.2 service/ - 业务逻辑层 ```go // internal/service/user_service.go package service import ( "your-project/internal/model" "your-project/internal/repository" ) type UserService struct { userRepo *repository.UserRepository } func (s *UserService) GetByID(id int64) (*model.User, error) { // 业务逻辑处理 return s.userRepo.FindByID(id) } ``` > 💡 **Java对比**:相当于 `@Service` *** #### 2.3 repository/ - 数据访问层 ```go // internal/repository/user_repo.go package repository import ( "gorm.io/gorm" "your-project/internal/model" ) type UserRepository struct { db *gorm.DB } func (r *UserRepository) FindByID(id int64) (*model.User, error) { var user model.User err := r.db.First(&user, id).Error return &user, err } ``` > 💡 **Java对比**:相当于 `@Repository` / Mapper *** #### 2.4 model/ - 数据库实体 ```go // internal/model/user.go package model import "time" type User struct { ID int64 `gorm:"primaryKey" json:"id"` Username string `gorm:"uniqueIndex" json:"username"` Email string `json:"email"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } ``` > 💡 **Java对比**:相当于 `@Entity` 实体类 *** #### 2.5 dto/ - 数据传输对象 ```go // internal/dto/request/user_req.go package request type CreateUserReq struct { Username string `json:"username" binding:"required,min=3,max=20"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` } // internal/dto/response/user_resp.go package response type UserResp struct { ID int64 `json:"id"` Username string `json:"username"` Email string `json:"email"` } // 统一响应格式 type CommonResp struct { Code int `json:"code"` Message string `json:"message"` Data interface{} `json:"data,omitempty"` } ``` > 💡 **Java对比**:相当于 DTO/VO 类 *** #### 2.6 router/ - 路由注册 ```go // internal/router/router.go package router import ( "github.com/gin-gonic/gin" "your-project/internal/handler" "your-project/internal/handler/middleware" ) func Setup() *gin.Engine { r := gin.Default() // 全局中间件 r.Use(middleware.CORS()) r.Use(middleware.Logger()) // API路由组 api := r.Group("/api/v1") { // 需要认证的路由 auth := api.Group("") auth.Use(middleware.Auth()) { // 用户相关 userHandler := &handler.UserHandler{} auth.GET("/users/:id", userHandler.GetUser) auth.POST("/users", userHandler.CreateUser) // 订单相关 orderHandler := &handler.OrderHandler{} auth.GET("/orders", orderHandler.ListOrders) } } return r } ``` > 💡 **Java对比**:相当于 `@Configuration` + `@RequestMapping` *** #### 2.7 consts/ - 常量定义 ```go // internal/consts/code.go package consts // 业务状态码 const ( CodeSuccess = 0 CodeInvalidParam = 400 CodeUnauthorized = 401 CodeForbidden = 403 CodeNotFound = 404 CodeInternalError = 500 ) // internal/consts/msg.go package consts // 提示信息 const ( MsgSuccess = "操作成功" MsgInvalidParam = "参数错误" MsgUnauthorized = "未授权" ) ``` > 💡 **Java对比**:相当于 `Constants` 常量类 *** ### 3️⃣ `pkg/` - 公共包 **⚠️ 注意**:`pkg/` 下的代码**可以被外部项目引用**,放真正需要复用的代码。 ``` pkg/ ├── logger/ # 日志封装 ├── utils/ # 工具函数 ├── errors/ # 自定义错误 └── cache/ # 缓存封装 ``` ```go // pkg/utils/encrypt.go package utils import "golang.org/x/crypto/bcrypt" func HashPassword(password string) (string, error) { bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) return string(bytes), err } ``` > 💡 **Java对比**:相当于公共 `utils` library,可被其他项目依赖 *** ### 4️⃣ `configs/` - 配置文件 ```yaml # configs/config.yaml server: port: 8080 mode: debug database: host: localhost port: 3306 user: root password: ${DB_PASSWORD} dbname: mydb log: level: info format: json ``` ```go // internal/config/config.go package config import "github.com/spf13/viper" type Config struct { Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` Log LogConfig `mapstructure:"log"` } func Load() *Config { viper.SetConfigFile("configs/config.yaml") viper.ReadInConfig() var cfg Config viper.Unmarshal(&cfg) return &cfg } ``` > 💡 **Java对比**:相当于 `application.yml` + `@ConfigurationProperties` *** ## 四、多前台系统适配建议 由于你要服务**多个前台系统**,建议: ### 方案1:按业务域划分路由前缀 ``` /api/v1/app-a/users # A应用 /api/v1/app-b/users # B应用 /api/v1/app-c/users # C应用 ``` ### 方案2:统一API + 不同权限 ``` /api/v1/users # 统一接口 ``` 通过 `token` 或 `app_id` 区分不同前台系统,在中间件中做权限控制。 ### 方案3:多模块拆分(推荐大型项目) ``` your-project/ ├── cmd/ │ ├── admin-server/ # 管理后台服务 │ ├── app-server/ # C端应用服务 │ └── open-api-server/ # 开放平台服务 ├── internal/ │ ├── shared/ # 共享业务逻辑 │ ├── admin/ # 管理后台特有逻辑 │ └── app/ # C端应用特有逻辑 ``` *** ## 五、新手建议 | 建议 | 说明 | |------|------| | 🎯 **从小开始** | 初期可以简化结构,等业务复杂了再拆分 | | 📦 **善用go mod** | 用 `go mod init` 初始化模块,自动管理依赖 | | 🔧 **选择合适框架** | 推荐 [Gin](https://github.com/gin-gonic/gin) 或 [Echo](https://github.com/labstack/echo) | | 🧪 **写测试** | 每个 `handler` 和 `service` 都配单元测试 | | 📖 **读官方文档** | [Go Modules](https://go.dev/blog/using-go-modules) | *** ## 六、推荐依赖 ```bash # Web框架 go get github.com/gin-gonic/gin # ORM go get gorm.io/gorm go get gorm.io/driver/mysql # 配置管理 go get github.com/spf13/viper # 日志 go get go.uber.org/zap # 验证 go get github.com/go-playground/validator/v10 # 工具 go get github.com/google/uuid ``` *** 希望这个结构能帮助你快速上手!有任何具体问题欢迎继续提问 🚀 --- --- url: /src/technology/dateblog/2025/05/20250521-go-module使用.md --- # Go Module使用 Go Module 是 Go 语言官方推出的依赖管理工具(自 Go 1.11 起引入),用于解决 Go 项目的版本依赖和包管理问题。它彻底改变了传统的 `GOPATH` 模式,让项目管理更灵活。以下是新手需要掌握的核心知识点: *** ### **为什么需要 Go Module?** * **摆脱 GOPATH**:传统 Go 项目必须放在 `GOPATH` 目录下,Go Module 允许代码存放在任意位置。 * **版本控制**:明确依赖的版本,支持语义化版本(如 `v1.2.3`)和伪版本(如 `v0.0.0-20220504150020-abcdef12345`)。 * **依赖隔离**:每个项目独立管理依赖,避免全局冲突。 *** ### **核心命令** 1. **初始化模块**(项目根目录运行) ```bash go mod init <模块名> # 例如 go mod init github.com/yourname/project ``` 生成 `go.mod` 文件,记录模块信息和依赖。 2. **自动添加/清理依赖** ```bash go mod tidy # 根据代码自动添加缺少的依赖,删除未使用的依赖 ``` 3. **下载依赖** ```bash go mod download # 下载所有依赖到本地缓存(默认在 `$GOPATH/pkg/mod`) ``` 4. **安装/升级依赖** ```bash go get <包名>@<版本> # 例如 go get github.com/gin-gonic/gin@v1.9.1 ``` *** ### **go.mod 文件结构** 示例文件: ```go module github.com/yourname/project // 模块名 go 1.21 // 要求的 Go 版本 require ( github.com/gin-gonic/gin v1.9.1 // 直接依赖 golang.org/x/net v0.10.0 // indirect // 间接依赖(由其他依赖引入) ) // 高级用法(可选) replace github.com/old/pkg => github.com/new/pkg v1.2.3 // 替换依赖 exclude github.com/buggy/pkg v1.0.0 // 排除特定版本 ``` *** ### **使用场景** #### 1. **新建项目** ```bash mkdir myproject && cd myproject go mod init github.com/yourname/myproject ``` #### 2. **添加依赖** 直接写代码导入包(如 `import "github.com/gin-gonic/gin"`),然后运行: ```bash go mod tidy # 自动下载并记录依赖到 go.mod ``` #### 3. **更新依赖** ```bash go get -u github.com/gin-gonic/gin # -u 表示升级到最新版本 ``` #### 4. **克隆已有项目** ```bash git clone https://github.com/some/project cd project go mod download # 下载依赖 ``` *** ### **注意事项** 1. **Go 版本**:确保 Go 版本 ≥ 1.16(推荐 ≥ 1.18)。 2. **模块启用**: * Go 1.16+ 默认启用 Module。 * 旧版本需设置环境变量:`export GO111MODULE=on`。 3. **依赖冲突**:如果依赖版本冲突,使用 `go mod why <包名>` 或 `go mod graph` 排查依赖关系。 *** ### **总结** Go Module 是现代化 Go 开发的基石,掌握 `go mod init/tidy/get` 等命令和 `go.mod` 文件规则后,依赖管理将变得非常轻松。遇到问题时,多查阅 `go help mod` 或官方文档 [Go Modules Reference](https://go.dev/ref/mod)。 --- --- url: /src/technology/dateblog/2025/06/20250602-go-windows打包bat脚本示例.md --- # go-windows打包bat脚本示例 \#go打包#打包exe#打包linux ## 在windows下打包exe ```bat @echo off setlocal go env -w GOARCH=amd64 go env -w GOOS=windows echo 构建exe中。。。 REM "-w -s -H=windowsgui" -H=windowsgui 去掉cmd窗口 go build -o ./target/go-app.exe -ldflags "-w -s" -trimpath ./main/main.go if %errorlevel% neq 0 ( echo Error: 构建失败!!! exit /b 1 ) else ( echo exe构建成功!!! ) endlocal ``` ## 在windows下打包linux可执行文件 ```bat @echo off setlocal set GOARCH=arm64 set GOOS=linux echo 编译环境: GOOS=%GOOS%, GOARCH=%GOARCH% echo 正在构建 Linux 可执行文件 go build -o ./target/api-server.linux -ldflags "-w -s" -trimpath ./main.go if errorlevel 1 ( echo 构建失败! exit /b 1 ) else ( echo 构建成功 ) endlocal ``` --- --- url: >- /src/technology/dateblog/2025/05/20250524-go-embed(允许将静态文件(如文本、二进制文件、目录等,直接嵌入到编译后的可执行程序中).md --- # go:embed(允许将静态文件(如文本、二进制文件、目录等,直接嵌入到编译后的可执行程序中) Go 语言的 `go:embed` 指令是在 **Go 1.16** 版本中引入的一项功能,允许将静态文件(如文本、二进制文件、目录等)直接嵌入到编译后的可执行程序中。这使得程序无需依赖外部文件即可运行,特别适合分发单文件应用或需要内置资源的场景(如 Web 服务器的静态资源)。 *** ### **基本用法** 需导入 `embed` 标准库,并通过 `//go:embed` 注释指令指定要嵌入的文件或目录。 #### **支持的变量类型** * **`string`**: 嵌入单个文本文件。 * **`[]byte`**: 嵌入单个二进制文件。 * **`embed.FS`**: 嵌入多个文件或目录(文件系统结构)。 *** ### **示例代码** #### 1. **嵌入单个文件** ```go package main import ( _ "embed" "fmt" ) //go:embed version.txt var version string // 将 version.txt 的内容嵌入为字符串 func main() { fmt.Println("Version:", version) } ``` #### 2. **嵌入二进制文件** ```go //go:embed logo.png var logoBytes []byte // 嵌入为字节切片 ``` #### 3. **嵌入多个文件或目录** 使用 `embed.FS`(文件系统)类型: ```go package main import ( "embed" "fmt" ) //go:embed templates/*.html static/css/* var content embed.FS // 嵌入 templates/ 下的 HTML 和 static/css/ 下的文件 func main() { // 读取文件 data, _ := content.ReadFile("templates/index.html") fmt.Println(string(data)) } ``` *** ### **关键特性** 1. **路径规则**: * 路径是相对于 `go:embed` 指令所在 Go 文件的目录。 * 支持通配符: * `*` 匹配任意字符(非隐藏文件)。 * `**` 匹配多级目录(需 Go 1.18+)。 * `?` 匹配单个字符。 * 示例:`//go:embed images/*.png data/**/*.json` 2. **隐藏文件**: * 默认忽略以 `.` 或 `_` 开头的文件/目录。 * 若需包含,需显式指定,如 `//go:embed .config/*`。 3. **权限保留**: * 嵌入的文件保留其原始权限(仅 Unix 系统生效)。 4. **跨平台兼容性**: * 文件路径分隔符始终为 `/`(即使 Windows)。 *** ### **使用示例** * **Web 静态资源**:将 HTML/CSS/JS 嵌入到 Go 二进制文件中。 ```go package main import ( "embed" "github.com/gin-gonic/gin" "io/fs" "net/http" ) //go:embed public/* var embeddedFS embed.FS func main() { subFS, err := fs.Sub(embeddedFS, "public") if err != nil { panic("无法创建子文件系统: " + err.Error()) } engine := gin.Default() // 挂载嵌入的静态资源 即可通过 /static 访问静态资源 /static/index.html 等 engine.StaticFS("/static", http.FS(subFS)) engine.Run(":8080") } ``` * **配置文件**:内置默认配置。 * **模板文件**:无需运行时从磁盘加载。 *** ### **注意事项** 1. **不可嵌入符号链接**。 2. **变量声明必须紧邻 `//go:embed` 注释**(中间不能有空行)。 3. **仅支持包级变量**,不能用于函数内的局部变量。 4. **不支持绝对路径**,所有路径均为相对路径。 *** ### **与其他工具对比** * **优势**: * 官方支持,无需第三方库(如 `go-bindata`、`pkger`)。 * 语法简洁,无需生成额外代码。 * **劣势**: * 无法动态嵌入(所有文件需在编译时确定)。 *** ### **总结** `go:embed` 提供了一种轻量且标准化的方式将静态文件嵌入 Go 程序,极大简化了资源管理。适合需要单文件分发或减少外部依赖的场景,是 Go 工具链的重要补充。 --- --- url: >- /src/technology/dateblog/2025/05/20250524-golang原生database-sql-包的纯-go-sqlite-驱动程序使用.md --- # Golang原生database-sql 包的纯 Go SQLite 驱动程序使用 **仓库地址:** 这是 Golang 原生 database/sql 包的纯 Go SQLite 驱动程序。 该驱动程序本身嵌入了基于 Go 的 SQLite 实现(因此,不需要单独安装 SQLite) ## 使用示例 ``` go get github.com/glebarez/go-sqlite ``` ```go package bootstrap import ( "database/sql" _ "github.com/glebarez/go-sqlite" "go.uber.org/zap" "log" "low-file/src/global" "os" "path/filepath" ) var createTableStatements = []string{ // 文件记录表 ` CREATE TABLE IF NOT EXISTS file_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_name TEXT NOT NULL, file_path TEXT NOT NULL, file_size INTEGER NOT NULL, create_time INTEGER NOT NULL ) `, } func initSqlite() { // 获取数据库文件路径(分离目录和文件名) dataDir := filepath.Join(global.BasePath, "storage/data") // 目录路径 dbFile := filepath.Join(dataDir, "low-file.db") // 文件路径 // 创建数据目录(仅创建目录部分) if err := os.MkdirAll(dataDir, 0755); err != nil { log.Fatalf("sqlite目录创建失败: %v (路径: %s)", err, dataDir) } // 验证目录权限(可选) if fileInfo, err := os.Stat(dataDir); err != nil { log.Fatalf("sqlite目录访问失败: %v (路径: %s)", err, dataDir) } else if !fileInfo.IsDir() { log.Fatalf("sqlite路径不是目录: %s", dataDir) } else if fileInfo.Mode().Perm()&0200 == 0 { log.Fatalf("sqlite目录不可写: %s (权限: %s)", dataDir, fileInfo.Mode().String()) } // 初始化数据库连接 db, err := sql.Open("sqlite", dbFile+"?_foreign_keys=1&_journal_mode=WAL") if err != nil { log.Fatal("sqlite数据库连接失败:", err) } // 配置连接池(SQLite推荐设置) db.SetMaxOpenConns(1) // 重要!避免数据库锁定 db.SetMaxIdleConns(1) // 验证连接 if err := db.Ping(); err != nil { log.Fatal("sqlite数据库连接不可用:", err) } global.SQLite = db // 初始化表 initTable() // 记录版本信息 var version string if err := global.SQLite.QueryRow("SELECT sqlite_version()").Scan(&version); err != nil { global.Logger.Error("获取SQLite版本失败", zap.Error(err)) } else { global.Logger.Info("SQLite初始化完成", zap.String("version", version), zap.String("path", dbFile)) } } func initTable() { for _, val := range createTableStatements { _, err := global.SQLite.Exec(val) if err != nil { log.Fatal("初始化表失败:", err) } } } ``` ## 分页查询示例 ```go package handlers import ( "github.com/gin-gonic/gin" "low-file/src/common/model" "low-file/src/global" "math" ) type FileRecordListQueryParam struct { model.PageQuery } func FileRecordList(c *gin.Context) { resHandler, _ := global.GetLoggerAndResponseHandler(c) // 1. 参数绑定与校验 var pageQuery FileRecordListQueryParam if err := c.ShouldBind(&pageQuery); err != nil { global.ResFail(resHandler.WithMsg("查询参数绑定失败").WithError(err)) return } // 参数默认值设置 if pageQuery.PageNum < 1 { pageQuery.PageNum = 1 } if pageQuery.PageSize <= 0 || pageQuery.PageSize > 100 { pageQuery.PageSize = 10 } // 2. 查询总数(带错误处理) var count int64 err := global.SQLite.QueryRow( `SELECT COUNT(*) FROM file_records`, ).Scan(&count) if err != nil { global.ResFail(resHandler.WithMsg("总数查询失败").WithError(err)) return } // 快速返回空结果 if count == 0 { global.ResOk(resHandler.WithData(gin.H{ "rows": []interface{}{}, "total": 0, })) return } // 3. 分页查询(添加 ORDER BY 保证顺序) offset := (pageQuery.PageNum - 1) * pageQuery.PageSize rows, err := global.SQLite.Query( `SELECT * FROM file_records ORDER BY create_time DESC LIMIT ? OFFSET ?`, pageQuery.PageSize, offset, ) if err != nil { global.ResFail(resHandler.WithMsg("分页查询失败").WithError(err)) return } defer rows.Close() // 确保关闭结果集 // 4. 数据解析 var records []model.FileRecord for rows.Next() { var s model.FileRecord if err := rows.Scan(&s.Id, &s.FileName, &s.FilePath, &s.FileSize, &s.CreateTime); err != nil { global.ResFail(resHandler.WithMsg("解析记录行失败").WithError(err)) return } records = append(records, s) } // 5. 检查遍历错误 if err := rows.Err(); err != nil { global.ResFail(resHandler.WithMsg("结果集遍历错误").WithError(err)) return } // 6. 返回完整分页信息 _ = int(math.Ceil(float64(count) / float64(pageQuery.PageSize))) global.ResOk(resHandler.WithData(gin.H{ "rows": records, "total": count, })) } ``` --- --- url: /src/technology/dateblog/2025/04/20250430-go中的指针.md --- # go中的指针 Go 保留了指针,在一定程度上保证了性能,同时为了更好的 GC 和安全考虑,又限制了指针的使用。 ## 创建 关于指针有两个常用的操作符,一个是取地址符&,另一个是解引用符\*。 ## 取地址符& 对一个变量进行取地址,会返回对应类型的指针,例如: ```go func main() { num := 2 p := &num fmt.Println(p) } ``` 指针存储的是变量num的地址 ``` 0xc00001c088 ``` ## 解引用符\* 解引用符则有两个用途, ### 访问指针所指向的元素 第一个用途是访问指针所指向的元素,也就是解引用,例如 ```go func main() { num := 2 p := &num rawNum := *p fmt.Println(rawNum) } ``` p是一个指针,对指针类型解引用就能访问到指针所指向的元素 ``` 2 ``` ### 声明一个指针 第二个用途就是声明一个指针,例如: ```go func main() { var numPtr *int fmt.Println(numPtr) } ``` ```go ``` `*int`即代表该变量的类型是一个int类型的指针,不过指针不能光声明,还得初始化,需要为其分配内存,否则就是一个空指针, 无法正常使用。要么使用取地址符将其他变量的地址赋值给该指针,要么就使用内置函数new手动分配,例如: ```go func main() { var numPtr *int numPtr = new(int) fmt.Println(numPtr) } ``` 更多的是使用短变量 ``` func main() { numPtr := new(int) fmt.Println(numPtr) } ``` `new`函数只有一个参数那就是类型,并返回一个对应类型的指针,函数会为该指针分配内存,并且指针指向对应类型的零值,例如: ```go func main() { fmt.Println(*new(string)) fmt.Println(*new(int)) fmt.Println(*new([5]int)) fmt.Println(*new([]float64)) } ``` ```go 0 [0 0 0 0 0] [] ``` ## 禁止指针运算 在 Go 中是不支持指针运算的,也就是说指针无法偏移,先来看一段 C++代码 ```go int main() { int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9}; int *p = &arr[0]; cout << &arr << endl << p << endl << p + 1 << endl << &arr[1] << endl; } ``` ```go 0x31d99ff880 0x31d99ff880 0x31d99ff884 0x31d99ff884 ``` 可以看出数组的地址与数字第一个元素的地址一致,并且对指针加一运算后,其指向的元素为数组第二个元素。 Go 中的数组也是如此,不过区别在于指针无法偏移,例如 ```go func main() { arr := [5]int{0, 1, 2, 3, 4} p := &arr println(&arr[0]) println(p) // 试图进行指针运算 p++ fmt.Println(p) } ``` 这样的程序将无法通过编译,报错如下 ```go main.go:10:2: invalid operation: p++ (non-numeric type *[5]int) ``` :::tip 标准库unsafe提供了许多用于低级编程的操作,其中就包括指针运算,前往标准库-unsafe了解细节。 ::: --- --- url: /src/technology/dateblog/2025/05/20250526-go定时任务库.md --- # go定时任务库 在 Go 语言中,使用 Gin 框架集成定时任务非常常见,尤其是需要定期执行某些操作(如消费 Redis Stream 数据、清理缓存、监控任务等)。以下是实现定时任务的详细方法和示例代码。 *** ### **1. 使用 `cron` 库(推荐)** Go 社区中最流行的定时任务库是 [`robfig/cron/v3`](https://github.com/robfig/cron),它支持灵活的调度表达式(如 `@every 5s`、`0 * * * * *` 等)。 #### **步骤** 1. **安装依赖**: ```bash go get github.com/robfig/cron/v3 ``` 2. **代码示例**: ```go package main import ( "github.com/gin-gonic/gin" "github.com/robfig/cron/v3" "log" "time" ) func main() { // 初始化定时任务 c := cron.New() _, err := c.AddFunc("@every 5s", func() { log.Println("定时任务执行:消费 Redis Stream 数据") // 在此处添加消费 Redis Stream 的逻辑 }) if err != nil { panic(err) } c.Start() defer c.Stop() // 初始化 Gin 路由 r := gin.Default() r.POST("/data", func(c *gin.Context) { // 接收并处理数据 // 将数据写入 Redis Stream c.JSON(200, gin.H{"status": "数据已接收"}) }) // 启动 Gin 服务 r.Run(":8080") } ``` #### **关键点** * **调度表达式**:`@every 5s` 表示每 5 秒执行一次,也可以使用标准的 Unix cron 表达式(如 `0 * * * * *` 表示每分钟执行一次)。 * **并发安全**:`cron` 默认为每个任务启动一个新的 goroutine,适合需要并发执行的场景。 * **优雅关闭**:通过 `defer c.Stop()` 确保程序退出时停止定时任务。 *** ### **2. 使用 Goroutine + `time.Ticker`** 如果需求简单(如固定间隔执行任务),可以直接使用 `time.Ticker` 实现。 #### **代码示例** ```go package main import ( "github.com/gin-gonic/gin" "log" "time" ) func main() { // 启动定时任务(每 5 秒执行一次) go func() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: log.Println("定时任务执行:消费 Redis Stream 数据") // 在此处添加消费 Redis Stream 的逻辑 } } }() // 初始化 Gin 路由 r := gin.Default() r.POST("/data", func(c *gin.Context) { // 接收并处理数据 // 将数据写入 Redis Stream c.JSON(200, gin.H{"status": "数据已接收"}) }) // 启动 Gin 服务 r.Run(":8080") } ``` #### **关键点** * **简单场景**:适合不需要复杂调度表达式的场景。 * **手动控制**:需要手动管理 ticker 的生命周期(如退出时停止 ticker)。 *** ### **3. 动态管理定时任务** 如果需要动态添加、删除或修改定时任务,可以结合 `cron` 库和配置中心(如 Etcd、数据库)实现。 #### **代码示例** ```go package main import ( "github.com/gin-gonic/gin" "github.com/robfig/cron/v3" "log" "time" ) func main() { // 初始化 cron 调度器 c := cron.New() c.Start() defer c.Stop() // 动态添加任务(例如从数据库读取配置) jobID, err := c.AddFunc("@every 10s", func() { log.Println("动态任务执行") }) if err != nil { panic(err) } // 动态删除任务(例如根据条件移除) c.Remove(jobID) // 初始化 Gin 路由 r := gin.Default() r.POST("/data", func(c *gin.Context) { // 接收并处理数据 c.JSON(200, gin.H{"status": "数据已接收"}) }) // 启动 Gin 服务 r.Run(":8080") } ``` *** ### **4. 集成 Redis Stream 消费** 如果需要从 Redis Stream 中消费数据,可以结合 `go-redis` 客户端库实现。 #### **代码示例** ```go package main import ( "github.com/gin-gonic/gin" "github.com/robfig/cron/v3" "github.com/go-redis/redis/v8" "context" "log" "time" ) var rdb *redis.Client func init() { rdb = redis.NewClient(&redis.Options{ Addr: "localhost:6379", Password: "", DB: 0, }) } func main() { // 初始化定时任务 c := cron.New() _, err := c.AddFunc("@every 5s", func() { ctx := context.Background() // 从 Redis Stream 读取数据 streamData, err := rdb.XRead(ctx, &redis.XReadArgs{ Streams: []string{"my-stream", "0"}, Count: 10, Block: 0, AutoAck: false, }).Result() if err != nil { log.Println("读取 Redis Stream 失败:", err) return } for _, entry := range streamData[0].Messages { log.Printf("消费数据: %s -> %v\n", entry.ID, entry.Values) } }) if err != nil { panic(err) } c.Start() defer c.Stop() // 初始化 Gin 路由 r := gin.Default() r.POST("/data", func(c *gin.Context) { // 将数据写入 Redis Stream err := rdb.XAdd(ctx, &redis.XAddArgs{ Stream: "my-stream", Values: map[string]interface{}{ "data": "example", }, }).Err() if err != nil { c.AbortWithStatusJSON(500, gin.H{"error": "写入 Redis 失败"}) return } c.JSON(200, gin.H{"status": "数据已接收"}) }) // 启动 Gin 服务 r.Run(":8080") } ``` *** ### **总结** * **推荐方式**:使用 `robfig/cron/v3` 库,支持灵活的调度表达式,适合大多数场景。 * **简单需求**:使用 `time.Ticker` 快速实现固定间隔任务。 * **动态管理**:结合配置中心动态调整任务。 * **Redis 集成**:使用 `go-redis` 客户端库消费 Redis Stream 数据。 根据具体需求选择合适的方法,并确保定时任务与 Gin 服务的生命周期管理(如启动、停止)协调一致。 --- --- url: /src/technology/dateblog/2025/05/20250524-go语言为什么默认采用值传递而非引用传递.md --- # Go语言为什么默认采用值传递而非引用传递 Go 语言默认采用值传递而非引用传递,这一设计决策与其核心设计哲学、性能考量及语言简洁性密切相关。以下是详细解释: *** ### 1. **设计哲学:简单性与明确性** * **减少副作用**\ 值传递默认复制数据,函数内部对参数的修改不会影响原始值。这确保了代码的可预测性,避免因隐式引用传递导致的意外副作用。开发者必须显式使用指针(`&`和`*`)才能修改原数据,这使得代码意图更加清晰。 * **显式优于隐式**\ Go 强调代码的明确性,通过强制开发者显式传递指针,避免了其他语言(如 Java 或 Python)中引用传递可能带来的歧义。 *** ### 2. **性能优化:由开发者控制** * **小数据的高效复制**\ 对于基本类型(如 `int`, `string`)或小型结构体,复制的成本远低于引用传递的间接访问(解引用指针、堆内存管理等),因此值传递更高效。 * **大数据的显式指针传递**\ 若需要传递大型结构体,开发者可以显式使用指针(如 `func (s *Struct) Method()`),从而主动控制性能与内存的平衡。这种设计将优化权交给开发者,而非强制所有场景接受引用传递的开销。 *** ### 3. **类型安全与内存安全** * **避免悬空指针风险**\ 值传递默认隔离函数内外的数据,减少了因引用传递导致的悬空指针(Dangling Pointer)或意外并发修改的可能性。 * **简化并发模型**\ 在并发编程中,值传递天然避免了数据竞争(Data Race),因为每个协程操作的是独立副本。若需共享数据,开发者必须显式使用指针或通道(Channel),这促使更安全的并发设计。 *** ### 4. **引用类型的底层一致性** * **Slice、Map、Channel 的“伪引用”行为**\ Go 的引用类型(如 `slice`、`map`、`channel`)虽在行为上类似引用传递,但实际仍为值传递:它们的值包含指向底层数据的指针(如 `slice` 的头部结构包含指针、长度和容量)。函数内修改元素会反映到原数据(因为指针指向同一内存),但若修改 `slice` 头部(如 `append` 导致重新分配),原 `slice` 不会受影响。这保持了语言机制的一致性(所有参数传递均为值传递)。 **示例**: ```go func modifySlice(s []int) { s[0] = 100 // 修改底层数组,影响原 slice s = append(s, 200) // 修改头部指针,不影响原 slice(除非传递指针) } ``` *** ### 5. **与 C 语言的传承与改进** * **继承值传递默认机制**\ Go 的设计者(如 Rob Pike、Ken Thompson)有深厚的 C 语言背景,继承了 C 的值传递默认行为,但通过更清晰的语法(如自动解引用指针 `s.Field` 替代 `s->Field`)简化了指针的使用。 *** ### 总结:为何值传递是合理的默认选择? * **代码可维护性**:减少隐式副作用,提升代码可读性。 * **性能可控性**:小数据高效复制,大数据由开发者显式优化。 * **安全性**:隔离数据修改,降低并发风险。 * **一致性**:所有类型统一采用值传递机制,仅通过指针或引用类型实现共享行为。 *** ### 何时使用指针? * 需要修改原数据时。 * 传递大型结构体以避免复制开销。 * 实现接口方法时需共享状态(如 `func (s *Struct) Method()`)。 通过显式使用指针,Go 在保持简洁性的同时,赋予开发者对内存和性能的精准控制权。 --- --- url: /src/technology/dateblog/2026/04/20260417-grafana-oncall-部署.md --- ## docker-compose.yml ```yml x-environment: &oncall-environment DATABASE_TYPE: sqlite3 BROKER_TYPE: redis BASE_URL: http://engine:8080 SECRET_KEY: LQvXRJjAUe9piep89ljDvqAGQktmxXLG FEATURE_PROMETHEUS_EXPORTER_ENABLED: ${FEATURE_PROMETHEUS_EXPORTER_ENABLED:-false} PROMETHEUS_EXPORTER_SECRET: ${PROMETHEUS_EXPORTER_SECRET:-} REDIS_URI: redis://:redis_redis@redis:6379/0 DJANGO_SETTINGS_MODULE: settings.hobby CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,mattermost,webhook,retry,celery,grafana" CELERY_WORKER_CONCURRENCY: "1" CELERY_WORKER_MAX_TASKS_PER_CHILD: "100" CELERY_WORKER_SHUTDOWN_INTERVAL: "65m" CELERY_WORKER_BEAT_ENABLED: "True" GRAFANA_API_URL: http://grafana:3000 services: engine: image: grafana/oncall:latest container_name: oncall_engine restart: always ports: - "10258:8080" command: sh -c "uwsgi --ini uwsgi.ini" networks: - 1panel-network extra_hosts: - "host.docker.internal:host-gateway" environment: *oncall-environment volumes: - ./oncall_data:/var/lib/oncall depends_on: oncall_db_migration: condition: service_completed_successfully celery: image: grafana/oncall:latest container_name: oncall_celery restart: always command: sh -c "./celery_with_exporter.sh" environment: *oncall-environment volumes: - ./oncall_data:/var/lib/oncall networks: - 1panel-network extra_hosts: - "host.docker.internal:host-gateway" depends_on: oncall_db_migration: condition: service_completed_successfully oncall_db_migration: image: grafana/oncall:latest command: python manage.py migrate --noinput environment: *oncall-environment volumes: - ./oncall_data:/var/lib/oncall grafana: image: grafana/grafana:12.4.3 container_name: grafana restart: always ports: - "13000:3000" networks: - 1panel-network extra_hosts: - "host.docker.internal:host-gateway" environment: GF_FEATURE_TOGGLES_ENABLE: externalServiceAccounts GF_SECURITY_ADMIN_USER: admin GF_SECURITY_ADMIN_PASSWORD: proposal8-proud-modulator GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app GF_INSTALL_PLUGINS: grafana-oncall-app GF_AUTH_MANAGED_SERVICE_ACCOUNTS_ENABLED: true volumes: - ./grafana_data:/var/lib/grafana - ./grafana.ini:/etc/grafana/grafana.ini networks: 1panel-network: external: true ``` ## 宿主机执行执行 ```bash curl -X POST 'http://admin:proposal8-proud-modulator@localhost:13000/api/plugins/grafana-oncall-app/settings' \ -H "Content-Type: application/json" \ -d '{ "enabled": true, "jsonData": { "stackId": 5, "orgId": 100, "onCallApiUrl": "http://engine:8080/", "grafanaUrl": "http://grafana:3000/" } }' ``` --- --- url: /src/technology/dateblog/2025/12/20251212-hbuilderx最新版配置安卓配置离线打包.md --- ### 官方文档 https://nativesupport.dcloud.net.cn/AppDocs/usesdk/android.html # HBuilderX 配置 Android 离线打包指南 本文档将指导您完成基于 HBuilderX 开发的 uni-app 项目,在最新版(4.87)环境下进行 Android 离线打包的完整配置流程。 > **离线打包**:指将 uni-app 项目(包括代码、资源及所需原生插件)导出,在本地 Android 开发环境(Android Studio)中编译生成最终 APK 文件的过程。 ## 📦 准备工作:下载所需资源 1. **Android 开发环境** * 下载并安装 [Android Studio](https://developer.android.google.cn/studio?hl=zh-cn)。 * 安装完成后,确保 Android SDK 及必要的构建工具已正确配置。 2. **离线 SDK** * 下载最新的 [Android 平台 App 离线 SDK](https://nativesupport.dcloud.net.cn/AppDocs/download/android.html)。 * 解压下载的压缩包,稍后需要将其中的文件复制到您的工程中。 ## 🔑 第一步:创建并获取离线 AppKey AppKey 是 DCloud 应用生态的标识,用于识别您的应用。离线打包必须配置。 1. **登录开发者中心** * 访问 [DCloud 开发者中心](https://dev.dcloud.net.cn/),登录您的账户。 2. **创建或选择应用** * 在应用列表中,找到您要打包的 uni-app 应用,点击进入其详情页。 3. **配置 Android 平台信息** * 在应用详情页,找到 **“各平台信息”** 区域。 * 点击 **“新增”**,选择 Android 平台。 4. **生成并获取证书指纹 (SHA1/SHA256)** * **生成证书**:在命令行中执行以下命令,创建一个 Android 签名证书 (keystore 文件)。 ```bash keytool -genkey -alias testalias -keyalg RSA -keysize 2048 -validity 36500 -keystore test.keystore ``` * `testalias`: 证书别名,可自定义。 * `test.keystore`: 生成的证书文件名,可自定义。 * `36500`: 有效期(天),建议设置较长时间。 * 按提示输入信息(密码、姓名、组织等)。 * **查看指纹**:生成后,在命令行中执行以下命令查看证书的 SHA1 和 SHA256。 ```bash keytool -list -v -keystore test.keystore ``` 5. **提交信息并获取 AppKey** * 回到开发者中心页面,将从证书中获取的 **SHA1** 和 **SHA256** 填入申请表单。 * 填写其他必要信息(包名等),然后点击 **“提交”**。 6. **获取离线 AppKey** * 提交成功后,在该平台信息条目中,点击 **“创建离线 Appkey”**。 * 创建完成后,即可在页面中查看您的 **离线 AppKey**。请妥善记录,后续需要配置到工程中。 ## 🛠️ 第二步:配置离线打包工程 此步骤将把 uni-app 资源与离线 SDK 结合,准备 Android Studio 工程。 1. **准备基础工程** * 将下载的 **离线 SDK** 解压,将 **"HBuilder-Integrate-AS""** 导入到Android Studio中。 * 参考 [官方 Android 离线 SDK 文档](https://nativesupport.dcloud.net.cn/AppDocs/usesdk/android.html),将其 `SDK` 目录下的相关资源,按照文档指引,复制或合并到您从 SDK 中获取的示例工程(或您自己的原生工程)的对应目录中。 2. **配置工程文件** * **AndroidManifest.xml** * 将您在开发者中心获取的 **离线 AppKey** 配置到 `application` 节点的 `meta-data` 中。 * 根据需要配置权限、Activity 等。 * **build.gradle** * 检查 `dependencies` 块,确保已正确引入离线 SDK 提供的所有 `.aar` 或 `.jar` 依赖。 * 配置 `minSdkVersion`, `targetSdkVersion` 等。 * **dcloud\_control.xml** * 找到此文件,将其中的 `appid` 修改为您 uni-app 项目的真实 **应用标识 (AppID)**。 3. **集成原生插件(可选)** * 如果您的项目使用了 uni-app 原生插件,请根据 [Android 离线打包使用插件指南](https://nativesupport.dcloud.net.cn/NativePlugin/offline_package/android.html),将插件的原生部分代码和资源配置到当前 Android 工程中。 ## 📱 第三步:生成安装包 所有配置完成后,即可在 Android Studio 中生成最终的安装包。 1. 在 Android Studio 中,确保您的工程已成功同步(Sync)。 2. 点击菜单栏:**Build** → **Generate Signed Bundle / APK...** * 选择生成 **APK**(直接安装)或 **Android App Bundle (.aab)**(用于上架 Google Play)。 3. 在签名配置窗口中: * **Key store path**: 选择您之前生成的 `.keystore` 文件。 * 输入正确的 **Key store password**, **Key alias**, **Key password**。 4. 选择构建变体(`release`)及 CPU 架构,然后点击 `Finish` 开始构建。 5. 构建完成后,在输出目录即可找到您的 APK 或 AAB 文件,可用于测试或发布。 *** > **提示**:整个过程中,请务必保持网络畅通,以便 Android Studio 和 Gradle 下载必要的依赖。如遇问题,请首先核对 [官方参考文档](https://nativesupport.dcloud.net.cn/AppDocs/usesdk/android.html) 中的每一步。 ### Android离线打包使用插件 https://nativesupport.dcloud.net.cn/NativePlugin/offline\_package/android.html --- --- url: /src/technology/dateblog/2025/06/20250619-浏览器html无构建步骤使用vue3加jsx示例.md --- # html无构建步骤使用vue3加jsx加es模块化开发 此方案可以在无构建步骤的情况下使用jsx语法,实际上使用了js的完全编程能力 增强了模板功能,当然也缺失的vue的模板语法的性能优化 支持es模块化开发 ## 单html使用 ```html Vue 3 JSX 内联方案
``` ## 模块化使用 **目录结构** ```markdown project/ ├── index.html ├── jsx-sw.js ├── App.js └── HelloWorld.js ``` ::: code-group ```html [index.html] vue3 jsx
``` ```js [jsx-sw.js] const CONFIG = { // A 拦截所有js,要忽略的js需添加?no-jsx参数 B 只拦截参数中带有?jsx的js interceptor_mode: 'A', // babel/standalone的地址 babel_url: 'https://unpkg.com/@babel/standalone@7.27.6/babel.min.js', } self.addEventListener('install', e => { console.log('[sw] install') // 强制跳过等待阶段 e.waitUntil( getBabel().then(() => self.skipWaiting()) ); }); // 新增activate事件处理 self.addEventListener('activate', e => { // 立即接管所有客户端 e.waitUntil(Promise.all([self.clients.claim(), self.clients.matchAll().then((clients) => { clients.forEach((client) => { client.postMessage('Hello from Service Worker!'); }); })])); }); self.addEventListener('fetch', event => { const url = new URL(event.request.url); if (!url.pathname.endsWith('.js')) { return; } if (CONFIG.interceptor_mode === 'A') { if (url.searchParams.has('no-jsx')) { // 参数标识no-jsx 不拦截 return; } } else if (CONFIG.interceptor_mode === 'B') { if (!url.searchParams.has('jsx')) { // 参数没有标识jsx 不拦截 return; } } event.respondWith(handleRequest(event.request)) }) async function getBabel() { const r = await fetch(CONFIG.babel_url) eval(await r.text()) } async function handleRequest(request) { const url = new URL(request.url) const r = await fetch(request) if (r.status === 200 & url.host === location.host && url.pathname.endsWith('.js')) { const jsx = await r.text() const js = Babel.transform(jsx, { presets: ['react'], plugins: [ ['transform-react-jsx', { pragma: 'Vue.h', pragmaFrag: 'Vue.Fragment' }] ] }).code return new Response(js, r) } else { return r } } ``` ```js [App.js] const {createApp, ref} = Vue; import HelloWorld from './component/HelloWorld.js' const app = createApp({ setup() { const count = ref(0); function increment() { count.value++ } return () =>
{{default: () => `count ${count.value}`}}
} }); app.mount('#app'); ``` ```js [HelloWorld.js] export default defineComponent(() => { return () =>
{{ default: () => 'HelloWorld' }}
; }) ``` ::: ## 说明 在 Vue.js 中使用 JSX 时,注释 `/* @jsx Vue.h */` 的作用是 **指定 Babel 转换 JSX 语法时使用的创建元素函数**。以下是详细解释: *** ### 核心作用: 1. **覆盖默认的 JSX 转换函数**\ Babel 默认将 JSX 转换为 `React.createElement()`,但该注释强制 Babel 改用 `Vue.h` 函数(Vue 的虚拟 DOM 创建函数)。 2. **适配 Vue 的渲染机制**\ Vue 使用 `h()` 函数(`createElement` 的别名)创建虚拟 DOM 节点。该注释确保 JSX 被正确转换为 Vue 兼容的格式。 *** ### 技术原理: * **Babel 的 JSX 转换规则**\ 当 Babel 处理 JSX 文件时(如 `.jsx` 或 `.tsx`),会通过插件(如 `@babel/plugin-transform-react-jsx`)将 JSX 语法转换为函数调用。\ 例如:`
Hello
` → `h('div', null, 'Hello')` * **`@jsx` 编译指示(Pragma)**\ 注释 `/* @jsx Vue.h */` 是一种编译指示(pragma),明确告诉 Babel: > “将本文件中的所有 JSX 转换为 `Vue.h(...)` 调用,而非默认的 `React.createElement(...)`。” *** ### 在 Vue 中的必要性: * **Vue 2 场景**\ Vue 2 的渲染函数使用 `Vue.h`(或 `vm.$createElement`)。若未指定此注释,Babel 会错误地使用 React 的函数导致报错。 * **Vue 3 场景**\ 通常直接从 `vue` 导入 `h` 函数: ```jsx import { h } from 'vue'; /* @jsx h */ // 直接指向导入的 h 函数 ``` *** ### 示例对比: **未指定 `@jsx` 时(默认 React 转换)**: ```jsx const element =
Hello
; // 被转换为: const element = React.createElement('div', null, 'Hello'); // 报错:Vue 中没有 React ``` **指定 `/* @jsx Vue.h */` 后**: ```jsx /* @jsx Vue.h */ const element =
Hello
; // 被正确转换为: const element = Vue.h('div', null, 'Hello'); // Vue 可识别 ``` --- --- url: /src/technology/dateblog/2025/08/20250822-java8中最核心和常用的函数式接口及其使用.md --- # Java 8 中最核心和常用的函数式接口及其使用 Java 8 在 `java.util.function` 包中引入了众多内置的函数式接口。所谓函数式接口(Functional Interface),就是**只包含一个抽象方法**的接口。你可以使用 `@FunctionalInterface` 注解来标记它,编译器会帮你检查是否符合规则。 ### 1. `Runnable` - 无参无返回值 **所在包:** `java.lang` **抽象方法:** `void run()` **用途:** 通常用于定义在线程中执行的任务,或者任何不需要参数和返回值的操作。 **使用示例:** ```java // 传统匿名内部类方式 Runnable r1 = new Runnable() { @Override public void run() { System.out.println("Hello World!"); } }; // Lambda 表达式方式 Runnable r2 = () -> System.out.println("Hello Lambda!"); // 执行 new Thread(r1).start(); new Thread(r2).start(); new Thread(() -> System.out.println("直接传入Lambda")).start(); ``` *** ### 2. `Consumer` - 消费型接口 **所在包:** `java.util.function` **抽象方法:** `void accept(T t)` **用途:** 接收一个参数,并对其进行某种操作(消费),不返回任何结果。 **常用变体:** * `BiConsumer`: 接收两个参数。 * `IntConsumer`: 接收一个 `int` 参数,避免装箱拆箱。 **使用示例:** ```java // 定义一个消费字符串的Consumer Consumer printConsumer = (str) -> System.out.println(str); printConsumer.accept("Hello Consumer!"); // 输出: Hello Consumer! // 集合的 forEach 方法就接收一个Consumer List names = Arrays.asList("Alice", "Bob", "Charlie"); names.forEach(name -> System.out.println("Hello, " + name)); // 使用方法引用更简洁: names.forEach(System.out::println); // 使用 andThen 进行组合消费 Consumer c1 = s -> System.out.print("Length: "); Consumer c2 = s -> System.out.println(s.length()); Consumer combined = c1.andThen(c2); combined.accept("Java"); // 输出: Length: 4 ``` *** ### 3. `Supplier` - 供给型接口 **所在包:** `java.util.function` **抽象方法:** `T get()` **用途:** 不需要参数,返回一个结果。类似于“工厂”或“提供者”。 **常用变体:** * `BooleanSupplier`, `IntSupplier`, `LongSupplier`, `DoubleSupplier` **使用示例:** ```java // 定义一个生成随机数的Supplier Supplier randomSupplier = () -> Math.random(); System.out.println(randomSupplier.get()); // 输出一个随机数 // 延迟执行/懒加载的经典场景 public static String getExpensiveValue() { // 假设这是一个耗时操作 return "Expensive Value"; } public static String lazyGet(Supplier supplier) { if (someCondition) { // 只有在某些条件下才真正调用获取值的方法 return supplier.get(); } return "Default Value"; } // 调用 lazyGet 时,并不会立即执行 getExpensiveValue 方法 // 只有当 supplier.get() 被调用时才会执行 String result = lazyGet(() -> getExpensiveValue()); ``` *** ### 4. `Predicate` - 断言型接口 **所在包:** `java.util.function` **抽象方法:** `boolean test(T t)` **用途:** 接收一个参数,返回一个布尔值。常用于过滤、条件判断。 **常用变体:** * `BiPredicate`: 接收两个参数。 * `IntPredicate`: 接收一个 `int` 参数。 **默认方法:** * `and(Predicate other)`: 与 * `or(Predicate other)`: 或 * `negate()`: 非 **使用示例:** ```java // 定义一个判断字符串是否为空的Predicate Predicate isEmpty = String::isEmpty; // s -> s.isEmpty() System.out.println(isEmpty.test("")); // true System.out.println(isEmpty.test("Hi")); // false // 集合的 stream().filter() 方法就接收一个Predicate List list = Arrays.asList("Java", "Python", "C", "", "JavaScript"); List nonEmpty = list.stream() .filter(str -> !str.isEmpty()) // 过滤掉空字符串 .collect(Collectors.toList()); // 输出: [Java, Python, C, JavaScript] // 组合使用:判断字符串长度是否大于3并且不是空字符串 Predicate longerThan3 = s -> s.length() > 3; Predicate notEmpty = s -> !s.isEmpty(); Predicate combinedPredicate = notEmpty.and(longerThan3); List result = list.stream() .filter(combinedPredicate) .collect(Collectors.toList()); // 输出: [Java, Python, JavaScript] ``` *** ### 5. `Function` - 函数型接口 **所在包:** `java.util.function` **抽象方法:** `R apply(T t)` **用途:** 接收一个参数(类型 T),返回一个结果(类型 R)。用于类型的转换或映射。 **常用变体:** * `BiFunction`: 接收两个参数 (T, U),返回 R。 * `UnaryOperator`: `Function` 的特例,接收 T 返回 T。 * `BinaryOperator`: `BiFunction` 的特例,接收两个 T 返回一个 T。 * `IntFunction`, `ToIntFunction` 等:用于基本类型,提高效率。 **默认方法:** * `compose(Function before)`: 先执行 before 的 apply,再执行当前 apply。 * `andThen(Function after)`: 先执行当前 apply,再执行 after 的 apply。 **使用示例:** ```java // 定义一个将字符串转换为整数的Function Function parseInt = Integer::parseInt; // s -> Integer.parseInt(s) Integer number = parseInt.apply("123"); // 返回 123 // 集合的 stream().map() 方法就接收一个Function List names = Arrays.asList("alice", "bob", "charlie"); List upperCaseNames = names.stream() .map(name -> name.toUpperCase()) // 将每个元素映射为大写 .collect(Collectors.toList()); // 输出: [ALICE, BOB, CHARLIE] // 使用方法引用: .map(String::toUpperCase) // 组合使用:先乘以2,然后再将结果平方 Function timesTwo = x -> x * 2; Function squared = x -> x * x; Function composed = timesTwo.andThen(squared); Integer result = composed.apply(3); // (3 * 2) ^ 2 = 36 System.out.println(result); // UnaryOperator 示例 (Function) UnaryOperator greet = s -> "Hello, " + s; System.out.println(greet.apply("World")); // Hello, World ``` *** ### 6. `Callable` - 可调用接口 **所在包:** `java.util.concurrent` **抽象方法:** `V call() throws Exception` **用途:** 类似于 `Runnable`,但它可以返回值并且可以抛出受检异常。通常用于 `ExecutorService` 提交有返回值的任务。 **使用示例:** ```java // 传统方式 Callable callable = new Callable() { @Override public String call() throws Exception { // 模拟一些工作 Thread.sleep(1000); return "Result from Callable"; } }; // Lambda 表达式方式 (因为只有一个抽象方法,所以可以使用) Callable callableWithLambda = () -> { Thread.sleep(1000); return "Result from Lambda Callable"; }; // 配合线程池使用 ExecutorService executor = Executors.newSingleThreadExecutor(); Future future = executor.submit(callableWithLambda); // 获取结果(会阻塞) String result = future.get(); System.out.println(result); // 输出: Result from Lambda Callable executor.shutdown(); ``` ### 总结与最佳实践 | 接口 | 方法 | 用途 | 典型场景 | | :--- | :--- | :--- | :--- | | **`Consumer`** | `void accept(T t)` | **消费**一个参数 | `forEach`, 打印, 数据持久化 | | **`Supplier`** | `T get()` | **提供**一个结果 | 懒加载, 工厂方法, 生成配置 | | **`Predicate`** | `boolean test(T t)` | **判断**条件 | `filter`, 数据校验, 条件检查 | | **`Function`** | `R apply(T t)` | **转换/映射**一个值 | `map`, 类型转换, 计算衍生值 | | **`Runnable`** | `void run()` | **执行**一个动作 | 线程任务, 无参无返回的操作 | | **`Callable`** | `V call()` | **执行**并返回结果 | 线程池提交有返回值的任务 | **最佳实践:** 1. **优先使用内置接口:** 在绝大多数情况下,都应优先使用这些内置接口,而不是自己重新定义。 2. **使用方法引用:** 当 Lambda 表达式仅仅是调用一个已有方法时,用方法引用(如 `System.out::println`, `String::length`)会使代码更简洁。 3. **注意组合操作:** 利用 `Predicate`, `Function`, `Consumer` 提供的 `and`, `or`, `negate`, `andThen`, `compose` 等方法,可以将简单的函数组合成更复杂的逻辑,让代码更具声明性和可读性。 4. **区分有无异常:** `Runnable` 和 `Callable` 的主要区别在于后者能抛出受检异常并有返回值。根据你的任务需求选择。 --- --- url: /src/technology/dateblog/2025/10/20251016-java类的生命周期测试.md --- # java类的生命周期测试 ```java public class Test1 { public static void main(String[] args) { System.out.println("A"); new Test1(); new Test1(); } public Test1() { System.out.println("B"); } { System.out.println("C"); } static { System.out.println("D"); } } ``` ## 1. 类加载阶段(只执行一次静态代码块) 当 Java 程序启动时,`Test1` 类被加载到内存,**静态代码块**会立即执行。 ```java static { System.out.println("D"); } ``` **输出:** `D` ## 2. 执行 `main` 方法 ```java public static void main(String[] args) { System.out.println("A"); ... } ``` **输出:** `A` ## 3. 创建第一个 `Test1` 对象 ```java new Test1(); ``` 创建对象时,按顺序执行: 1. **实例初始化块**(非静态代码块) 2. **构造方法** ```java { System.out.println("C"); } public Test1() { System.out.println("B"); } ``` 所以先输出 `C`,再输出 `B`。 **输出:** `C` `B` ## 4. 创建第二个 `Test1` 对象 ```java new Test1(); ``` 同样执行: * 实例初始化块 → `C` * 构造方法 → `B` **输出:** `C` `B` ## 5. 最终输出顺序 ``` D A C B C B ``` --- --- url: /src/technology/dateblog/2025/10/20251028-java线程池的工作流程.md --- # Java线程池的工作流程 ### 第一部分:线程池的核心参数 以 `ThreadPoolExecutor` 最完整的构造函数为例,它有7个核心参数: ```java public ThreadPoolExecutor( int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) ``` 下面我们逐一解释: #### 1. `corePoolSize` - 核心线程数 * **定义**:线程池中长期存活的、即使处于空闲状态也不会被销毁的线程数量(除非设置了 `allowCoreThreadTimeOut` 为 true)。 * **作用**:它们是线程池的基本“劳动力”。 * **工作流程**:当有新任务提交时,线程池会优先创建核心线程来处理任务,即使有其他核心线程空闲也会创建,直到达到 `corePoolSize`。 #### 2. `maximumPoolSize` - 最大线程数 * **定义**:线程池允许创建的最大线程数量。 * **作用**:这是线程池的“劳动力”上限。 * **工作流程**:当核心线程都在忙,并且工作队列也已经排满时,线程池才会创建新的临时线程(非核心线程)来处理任务,但总线程数不能超过 `maximumPoolSize`。 #### 3. `keepAliveTime` + `unit` - 线程存活时间 * **定义**:当线程数超过 `corePoolSize` 时,那些多余的“临时”线程在空闲状态下等待新任务的最长时间。如果超过这个时间还没有新任务,它们就会被销毁。 * **单位**:`unit` 参数是 `keepAliveTime` 的时间单位(如 `TimeUnit.SECONDS`)。 * **作用**:为了节省系统资源,避免在低负载时维持过多线程。 #### 4. `workQueue` - 工作队列 * **定义**:一个用于存放待处理任务的阻塞队列(`BlockingQueue`)。 * **作用**:在任务激增时,起到缓冲和排队的作用。 * **常用队列类型**: * **`LinkedBlockingQueue`**(无界队列):任务可以无限堆积,会导致 `maximumPoolSize` 参数失效,永远不会创建超过 `corePoolSize` 的线程。可能引起内存溢出。 * **`ArrayBlockingQueue`**(有界队列):队列大小固定。这是最常用的场景,可以配合 `maximumPoolSize` 一起防止资源耗尽。 * **`SynchronousQueue`**(同步移交队列):它不存储元素。每个插入操作必须等待另一个线程的移除操作。因此,如果没有立即可用的线程来执行任务,就会创建新线程(如果没达到最大线程数),否则任务会被拒绝。这要求线程池的 `maximumPoolSize` 足够大。 #### 5. `threadFactory` - 线程工厂 * **定义**:用于创建新线程的工厂。 * **作用**:可以自定义线程的名称、优先级、是否为守护线程等,方便问题排查和监控。如果不指定,会使用默认的 `Executors.defaultThreadFactory()`。 #### 6. `handler` - 拒绝策略 * **定义**:当线程池已经关闭,或者线程池和工作队列都已达到饱和状态(即所有线程都在忙且队列已满)时,对新提交的任务采取的处理策略。 * **作用**:这是系统的“安全阀”,防止任务无限堆积导致系统崩溃。 *** ### 第二部分:线程池的工作流程(参数如何协同工作) 理解参数后,我们再通过一个流程图来看它们是如何配合的: ```mermaid flowchart TD A[提交新任务] --> B{核心线程
是否已满?
(< corePoolSize)} B -- 否 --> C[创建新的核心线程执行任务] B -- 是 --> D{工作队列
是否已满?} D -- 否 --> E[将任务放入工作队列] D -- 是 --> F{线程总数
是否已达上限?
(< maximumPoolSize)} F -- 否 --> G[创建新的临时线程执行任务] F -- 是 --> H[执行拒绝策略] ``` 1. 任务提交。 2. 如果当前运行的线程数 **小于** `corePoolSize`,则**创建新线程**(核心线程)来执行任务。 3. 如果当前运行的线程数 **大于等于** `corePoolSize`,则尝试将任务**放入工作队列** (`workQueue`)。 4. 如果工作队列**已满**,则尝试**创建新的临时线程**(非核心线程)来执行任务,直到线程数达到 `maximumPoolSize`。 5. 如果线程数也已达到 `maximumPoolSize`,并且队列也满了,则**触发拒绝策略** (`handler`)。 *** ### 第三部分:拒绝策略 (RejectedExecutionHandler) JDK 内置了四种常见的拒绝策略,它们都实现了 `RejectedExecutionHandler` 接口。 #### 1. `AbortPolicy` - **中止策略(默认)** * **行为**:直接抛出 `RejectedExecutionException` 异常。 * **优点**:明确地通知调用者任务被拒绝了,不会无声无息地丢失任务。 * **缺点**:调用者需要捕获并处理异常。 * **适用场景**:**大多数关键业务场景**,需要及时感知到系统过载。 #### 2. `CallerRunsPolicy` - **调用者运行策略** * **行为**:不抛弃任务,也不抛异常,而是将某些被拒绝的任务**回退给调用者线程**来执行。 * **优点**: * 不会丢失任务。 * 提交任务的线程(调用者)需要自己去执行任务,这会占用调用者的时间,从而**减缓了新任务提交的速度**,相当于一种负反馈机制,给了线程池一些喘息的时间。 * **适用场景**:不允许任务丢失,且能承受任务执行速度变慢的场景。例如,可以在Web服务器中防止队列过载时,将压力回传到数据源(如数据库连接池)。 #### 3. `DiscardPolicy` - **丢弃策略** * **行为**:默默地将无法处理的任务**丢弃,不做任何通知**。 * **优点**:实现简单。 * **缺点**:**任务无声无息地丢失了**,很难发现问题。 * **适用场景**:**不推荐使用**,除非你非常确定可以丢弃某些不重要的任务(如日志记录、心跳检测等)。 #### 4. `DiscardOldestPolicy` - **丢弃最老策略** * **行为**:丢弃**工作队列中队首**(即下一个将要被执行)的旧任务,然后尝试重新提交当前这个新任务。 * **优点**:给了新任务一次机会。 * **缺点**:可能会丢弃非常重要的老任务。 * **适用场景**:后到来的任务优先级更高的场景。例如,一个实时消息系统,新的消息比旧的消息更有价值。 ### 总结与建议 | 参数 | 说明 | 设置建议 | | :--- | :--- | :--- | | `corePoolSize` | 常驻核心线程数 | 根据任务类型(CPU密集型/IO密集型)和机器配置来定。 | | `maximumPoolSize` | 最大线程数 | 设置一个上限,防止资源耗尽。通常和 `corePoolSize` 及队列容量一起权衡。 | | `workQueue` | 任务队列 | 推荐使用**有界队列**(如 `ArrayBlockingQueue`),配合拒绝策略,避免资源耗尽。 | | `keepAliveTime` | 线程空闲时间 | 对于突发流量的系统,可以设置得短一些,以便及时回收资源。 | | `handler` | 拒绝策略 | **生产环境强烈建议使用 `AbortPolicy` 或 `CallerRunsPolicy`**,避免使用默认的或丢弃策略,以便及时发现问题。 | **最佳实践**: * 不要使用 `Executors` 的快捷方法(如 `newFixedThreadPool`, `newCachedThreadPool`),因为它们使用无界队列或无限最大线程数,容易导致OOM。 * 应该根据实际情况,**手动创建 `ThreadPoolExecutor`**,并为其指定合适的参数,特别是使用**有界队列**和**明确的拒绝策略**。 * 对于需要记录被拒绝任务的场景,可以自定义拒绝策略,在拒绝任务前记录日志。 --- --- url: /src/technology/dateblog/2025/10/20251028-守护线程和非守护线程.md --- 好的,我们来详细讲解 Java 中的守护线程(Daemon Thread)和非守护线程(User Thread,也称用户线程)的区别。 ### 核心定义 1. **守护线程 (Daemon Thread)** * 它是为**其他线程提供服务**的辅助性线程。 * 它的存在依赖于创建它的线程,当 JVM 中**所有的用户线程都结束**时,无论守护线程是否正在执行,JVM 都会立即退出,守护线程也会被强制终止。 * 典型的例子:Java 的垃圾回收线程。 2. **非守护线程 / 用户线程 (User Thread)** * 它是应用程序的**核心线程**,用于执行主要的业务逻辑。 * JVM 会**等待所有用户线程都执行完毕后**才会正常关闭。 * 我们平常在 `main` 方法中创建的线程,默认都是用户线程。 *** ### 核心区别对比 | 特性 | 守护线程 (Daemon Thread) | 非守护线程 (User Thread) | | :--- | :--- | :--- | | **存在目的** | 为其他线程提供辅助服务 | 执行核心业务逻辑 | | **JVM 退出机制** | **不阻止 JVM 退出**。所有用户线程结束时,JVM 立即退出,守护线程被强制终止。 | **会阻止 JVM 退出**。JVM 必须等待所有用户线程执行完毕。 | | **生命周期** | 依赖于创建它的线程,生命周期较短。 | 独立存在,生命周期由其执行任务决定。 | | **默认类型** | 线程默认是**非守护线程**。 | 线程默认是**用户线程**。 | | **适用场景** | 后台支持任务,如垃圾回收、内存管理、心跳检测、监控统计等。 | 程序的主要工作,如处理用户请求、计算任务等。 | | **finally 块执行** | **不保证执行**。当 JVM 退出时,如果守护线程正在运行,其 `finally` 代码块可能没有机会执行。 | **保证执行**。只要流程进入 `try` 块,`finally` 块在线程结束前一定会被执行。 | | **子线程继承性** | 由守护线程创建的新线程**默认也是守护线程**。 | 由用户线程创建的新线程**默认是用户线程**。 | *** ### 代码示例与演示 让我们通过两个简单的代码示例来直观地感受它们的区别。 #### 示例1:用户线程阻止JVM退出 ```java public class UserThreadExample { public static void main(String[] args) { Thread userThread = new Thread(() -> { for (int i = 0; i < 5; i++) { try { Thread.sleep(1000); // 模拟工作 System.out.println("用户线程正在工作... " + i); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("用户线程执行完毕。"); }); // 默认就是用户线程,所以不需要显式设置 // userThread.setDaemon(false); userThread.start(); // 主线程(也是一个用户线程)很快结束 System.out.println("主线程执行完毕。"); } } ``` **输出结果:** ``` 主线程执行完毕。 用户线程正在工作... 0 用户线程正在工作... 1 用户线程正在工作... 2 用户线程正在工作... 3 用户线程正在工作... 4 用户线程执行完毕。 ``` **分析:** 尽管 `main` 主线程已经结束,但 JVM 检测到还有一个用户线程 `userThread` 在运行,所以 JVM 不会退出,直到该线程完成任务。 *** #### 示例2:守护线程不阻止JVM退出 ```java public class DaemonThreadExample { public static void main(String[] args) { Thread daemonThread = new Thread(() -> { for (int i = 0; i < 5; i++) { try { Thread.sleep(1000); // 模拟工作 System.out.println("守护线程正在工作... " + i); } catch (InterruptedException e) { e.printStackTrace(); } } // 这行很可能不会被执行到! System.out.println("守护线程执行完毕。"); }); // !!!关键:设置为守护线程 !!! daemonThread.setDaemon(true); daemonThread.start(); // 主线程(用户线程)很快结束 System.out.println("主线程执行完毕。"); // 当主线程结束时,JVM发现没有其他用户线程,立即退出,终止守护线程。 } } ``` **可能的输出结果:** ``` 主线程执行完毕。 守护线程正在工作... 0 ``` 或者 ``` 主线程执行完毕。 守护线程正在工作... 0 守护线程正在工作... 1 ``` **分析:** 主线程结束后,JVM 发现只剩下一个守护线程在运行,于是**立即退出**,守护线程被强制终止。因此,它可能只执行了部分循环,甚至 `finally` 块(如果有)和最后的打印语句都可能不会执行。 *** ### 如何设置与使用 * **判断线程类型**:`thread.isDaemon()` * **设置守护线程**:`thread.setDaemon(true)` * **必须在 `thread.start()` 方法调用之前设置**,否则会抛出 `IllegalThreadStateException`。 ```java Thread daemonThread = new Thread(() -> { // 守护线程的任务 }); daemonThread.setDaemon(true); // 在 start() 之前调用 daemonThread.start(); ``` ### 最佳实践与注意事项 1. **谨慎使用**:由于守护线程可能在任何时候被中断,**不要将需要保证完整执行的任务(如 I/O 操作的完整性、事务操作)放在守护线程中**,否则可能导致数据不一致或资源未释放。 2. **资源清理**:守护线程中的 `finally` 代码块**不保证会执行**,所以不要在守护线程中持有需要显式关闭的资源(如文件句柄、数据库连接),或者确保有其他的清理机制。 3. **典型用例**: * **垃圾回收器**:这是最经典的守护线程。 * **定时任务/监控**:一些周期性的、非核心的统计、心跳检测任务。 * **日志处理**:某些后台的、异步的日志写入服务。 4. **线程池中的守护线程**:通过 `ThreadFactory` 可以创建守护线程来构建线程池。这样,当主应用退出时,这些线程池中的线程不会阻止 JVM 关闭。 ### 总结 * **用户线程是“主子”**,JVM 必须等它们全部“驾崩”才敢关门。 * **守护线程是“仆人”**,主人们都走了,仆人也得立刻下班(即使活没干完)。 理解两者的区别对于编写稳定、可控的 Java 应用程序至关重要,尤其是在需要优雅关闭服务的场景下。 --- --- url: /src/technology/dateblog/2025/10/20251022-java面试题.md --- # java面试题 ### 一、JVM内存模型 (JVM Memory Model) 我们通常所说的JVM内存模型指的是**运行时数据区 (Runtime Data Areas)**,即JVM在执行Java程序时会把它管理的内存划分为若干个不同的数据区域。 **主要分为两大块:线程共享区和线程私有区。** #### 1. 线程共享区 (所有线程共享访问) * **堆 (Heap)** * **作用**:存放对象实例和数组。几乎所有通过 `new` 关键字创建的对象都在这里分配内存。**这是GC(垃圾收集)管理的主要区域。** * **特点**: * 是JVM中最大的一块内存区域。 * 线程共享,因此存在线程安全问题。 * 为了优化GC性能,现代垃圾收集器通常又将堆细分为: * **新生代 (Young Generation)**:新创建的对象首先在这里分配。它又分为一个 **Eden区** 和两个 **Survivor区 (S0, S1)**。 * **老年代 (Old Generation/Tenured)**:在新生代中经历了多次GC后仍然存活的对象会被移到这里。 * 可以通过 `-Xms` 和 `-Xmx` 参数设置堆的初始大小和最大大小。 * **方法区 (Method Area)** * **作用**:存储已被虚拟机加载的**类信息、常量、静态变量、即时编译器编译后的代码缓存**等数据。 * **特点**: * 线程共享。 * 在HotSpot VM中,方法区的实现被称为 **“永久代” (PermGen)**,但在Java 8及以后,已被 **元空间 (Metaspace)** 取代。元空间不再使用JVM内存,而是使用本地内存,从而避免了永久代常见的 `OutOfMemoryError: PermGen space` 错误。 #### 2. 线程私有区 (每个线程独享) * **虚拟机栈 (JVM Stack)** * **作用**:描述Java方法执行的内存模型。每个方法在执行的同时都会创建一个**栈帧 (Stack Frame)**,用于存储**局部变量表、操作数栈、动态链接、方法出口**等信息。 * **特点**: * 每个线程都有自己独立的虚拟机栈。 * 我们常说的堆内存和栈内存,其中的“栈”就是指这里。局部变量(基本数据类型和对象引用)存放在栈的局部变量表中。 * 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 `StackOverflowError`;如果栈可以动态扩展但无法申请到足够内存,则抛出 `OutOfMemoryError`。 * **本地方法栈 (Native Method Stack)** * **作用**:与虚拟机栈非常相似,区别在于虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的**Native(本地)方法**服务。 * **程序计数器 (Program Counter Register)** * **作用**:一块较小的内存空间,可以看作是**当前线程所执行的字节码的行号指示器**。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。 * **特点**: * 线程私有,各条线程之间互不影响。 * 是JVM规范中**唯一没有规定任何 `OutOfMemoryError` 情况的区域**。 *** ### 二、GC算法 (Garbage Collection Algorithms) GC的核心任务是回收堆内存中已经“死去”的对象,释放其占用的空间。 #### 1. 判断对象是否可回收的算法 * **引用计数法 (Reference Counting)** * **原理**:给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减1。任何时刻计数器为0的对象就是不可能再被使用的。 * **缺点**:**无法解决对象之间循环引用的问题**(例如,A引用B,B也引用A,但再无其他引用指向它们),因此主流JVM均不采用此算法。 * **可达性分析算法 (Reachability Analysis)** * **原理**:通过一系列称为 **“GC Roots”** 的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链”。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。 * **哪些对象可以作为GC Roots?** * 虚拟机栈(栈帧中的局部变量表)中引用的对象。 * 方法区中类静态属性引用的对象。 * 方法区中常量引用的对象。 * 本地方法栈中JNI(即Native方法)引用的对象。 #### 2. 垃圾收集算法 (用于回收的算法) * **标记-清除算法 (Mark-Sweep)** * **过程**:首先标记出所有需要回收的对象,在标记完成后,统一回收所有被标记的对象。 * **缺点**:**效率不高,且会产生大量不连续的内存碎片**。 * **复制算法 (Copying)** * **过程**:将可用内存按容量分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。 * **优点**:实现简单,运行高效,没有内存碎片。 * **缺点**:**将可用内存缩小了一半**,代价高昂。 * **应用**:**是新生代GC的主流算法**。在HotSpot中,将新生代分为一个较大的Eden区和两个较小的Survivor区(通常比例为8:1:1)。每次使用Eden和其中一个Survivor。回收时,将Eden和Survivor中存活的对象一次性复制到另一个Survivor空间上,最后清理掉Eden和刚用过的Survivor。 * **标记-整理算法 (Mark-Compact)** * **过程**:标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。 * **优点**:避免了内存碎片问题。 * **缺点**:移动对象成本较高。 * **应用**:**适合老年代**的垃圾收集。 * **分代收集算法 (Generational Collection)** * **思想**:当前商业虚拟机的垃圾收集都采用“分代收集”算法。它只是根据对象存活周期的不同将内存划分为几块(一般是新生代和老年代)。然后根据各个年代的特点采用最适当的收集算法。 * **在新生代中**,每次垃圾收集时都有大批对象死去,只有少量存活,那就选用**复制算法**,只需要付出少量存活对象的复制成本就可以完成收集。 * **在老年代中**,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用**标记-清除”或“标记-整理”** 算法来进行回收。 *** ### 三、类加载机制 (Class Loading Mechanism) JVM把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被JVM直接使用的Java类型,这个过程就是类加载机制。 #### 1. 类加载的生命周期 包括:**加载 (Loading) -> 连接 (Linking) -> 初始化 (Initialization)**。其中连接又分为三步:**验证 (Verification) -> 准备 (Preparation) -> 解析 (Resolution)**。 * **加载** * 通过一个类的全限定名来获取定义此类的二进制字节流。 * 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。 * 在内存中生成一个代表这个类的 `java.lang.Class` 对象,作为方法区这个类的各种数据的访问入口。 * **验证** * 确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。 * 包括文件格式验证、元数据验证、字节码验证、符号引用验证。 * **准备** * 为**类变量(static变量)** 分配内存并设置**初始零值**(如 `0`, `false`, `null` 等)。 * **注意**:这里设置的是初始零值,而不是代码中赋予的值。例如 `public static int value = 123;` 在准备阶段后 `value` 的值为 `0`,赋值 `123` 的动作在初始化阶段才执行。但对于 `final static` 常量(如 `public static final int value = 123;`),其值会在准备阶段被直接赋值为 `123`。 * **解析** * 将常量池内的**符号引用**替换为**直接引用**的过程。 * **初始化** * 执行类构造器 `()` 方法的过程。`()` 方法是由编译器自动收集类中的所有**类变量的赋值动作**和**静态语句块(static{}块)** 中的语句合并产生的。 * **触发时机**:当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的那个类),虚拟机会先初始化这个主类。或者当遇到 `new`, `getstatic`, `putstatic`, `invokestatic` 等字节码指令时,如果类没有初始化,则需要先触发其初始化。 #### 2. 类加载器 (ClassLoader) 与双亲委派模型 * **类加载器的作用**:实现“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块。 * **双亲委派模型 (Parents Delegation Model)** * **工作过程**:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求**委派给父类加载器**去完成,每一个层次的类加载器都是如此。因此所有的加载请求最终都应该传送到顶层的**启动类加载器**中。只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。 * **优势**: 1. **确保Java核心库的类型安全**:例如 `java.lang.Object` 类,无论哪个类加载器要加载它,最终都委派给启动类加载器,从而保证了在程序中所用的都是同一个 `Object` 类。 2. **避免类的重复加载**。 * **三层主要的类加载器**: 1. **启动类加载器 (Bootstrap ClassLoader)**:负责加载 `JAVA_HOME/lib` 目录中的核心类库,如 `rt.jar`。由C++实现,是JVM的一部分。 2. **扩展类加载器 (Extension ClassLoader)**:负责加载 `JAVA_HOME/lib/ext` 目录中的类库。 3. **应用程序类加载器 (Application ClassLoader)**:也叫系统类加载器,负责加载用户类路径 (ClassPath) 上指定的类库。一般情况下,这就是程序默认的类加载器。 *** 好的,我们来深入探讨Spring框架的这些核心概念。 *** ### 四、Spring IOC / AOP 实现原理 #### 1. IOC (控制反转) 实现 **核心思想**:将对象的创建、依赖装配和生命周期的控制权从应用程序代码**反转**到Spring容器(IOC容器)来管理。 **实现原理**: * **核心接口**:`BeanFactory` 和 `ApplicationContext`。`ApplicationContext` 是 `BeanFactory` 的子接口,提供了更多企业级功能,是更常用的容器。 * **配置元数据**:容器通过读取配置元数据(XML、Java注解或Java Config)来知道如何创建、配置和组装应用中的对象。 * **工作流程**: 1. **加载与解析**:容器启动,加载并解析配置文件(如 `applicationContext.xml`)或扫描注解(如 `@Component`, `@Service` 等)。 2. **创建Bean定义**:根据解析结果,为每个Bean创建一个 `BeanDefinition` 对象,它包含了Bean的类名、作用域、属性值、依赖关系等信息。 3. **实例化**:通过Java的**反射机制**,调用类的无参或有参构造函数来创建Bean的实例。 4. **依赖注入**:根据Bean定义中的依赖关系,容器将所需的依赖(其他Bean或基本类型值)**注入**到目标Bean的属性或构造函数中。这解决了对象间的耦合问题。 5. **初始化**:如果Bean实现了 `InitializingBean` 接口或配置了 `init-method`,容器会调用这些初始化方法。 6. **就绪**:此时,Bean已经准备就绪,可以被应用程序使用。 7. **销毁**:当容器关闭时,如果Bean实现了 `DisposableBean` 接口或配置了 `destroy-method`,容器会调用这些销毁方法。 **总结**:IOC容器就像一个对象工厂,你只需要告诉它你需要什么对象(通过配置),以及对象之间的依赖关系,它就会在合适的时机为你创建好并组装起来。 #### 2. AOP (面向切面编程) 实现 **核心思想**:将那些遍布在应用多个模块中的**横切关注点**(如日志、事务、安全等)从业务逻辑中分离出来,形成一个独立的模块(切面),从而提高代码的模块化和可维护性。 **核心概念**: * **切面**:横切关注点的模块化,即一个类,如 `LoggingAspect`。 * **通知**:切面要完成的工作,即在特定的**连接点**执行的动作。类型有: * **前置通知**:在方法执行前执行。 * **后置通知**:在方法执行后执行(无论成功与否)。 * **返回通知**:在方法成功执行后执行。 * **异常通知**:在方法抛出异常时执行。 * **环绕通知**:最强大的通知,可以自定义在方法调用前后执行的行为,并决定是否执行目标方法。 * **连接点**:程序执行过程中可以插入切面的点,如方法调用、异常抛出等。在Spring AOP中,连接点总是代表**方法的执行**。 * **切点**:一个表达式,用于匹配哪些连接点会被通知增强。例如:`execution(* com.example.service.*.*(..))`。 * **引入**:向现有的类添加新的方法或属性。 * **织入**:将切面应用到目标对象并创建新的代理对象的过程。 **实现原理**:Spring AOP 默认使用**动态代理**。 * **JDK 动态代理**:如果目标对象**实现了接口**,Spring会使用JDK的 `java.lang.reflect.Proxy` 类来创建代理对象。代理对象会实现与目标对象相同的接口。 * **CGLIB 代理**:如果目标对象**没有实现任何接口**,Spring会使用CGLIB库来生成一个目标对象的**子类**作为代理对象。 **过程**:当调用一个Bean的方法时,实际上调用的是其**代理对象**的方法。代理对象在调用目标方法的前后,会根据切点表达式判断是否需要执行相应的**通知**逻辑。 *** ### 五、循环依赖解决 **循环依赖**:指两个或多个Bean相互依赖,形成了一个闭环。例如:Bean A 依赖 Bean B,同时 Bean B 也依赖 Bean A。 **Spring 解决循环依赖的条件**: 1. 必须是**单例** Bean(默认就是单例)。 2. 不能是**构造器注入**的循环依赖,只能解决**Setter注入**或**字段注入**的循环依赖。 **解决原理:三级缓存** Spring容器内部维护了三个Map,称为三级缓存: 1. **一级缓存(单例池)** `singletonObjects`:存放已经完全初始化好的、成熟的Bean。我们日常从Spring容器 `getBean` 就是从这里取。 2. **二级缓存** `earlySingletonObjects`:存放**早期暴露**的Bean对象。这些Bean已经实例化,但还未进行属性填充和初始化。用于解决循环依赖。 3. **三级缓存** `singletonFactories`:存放Bean的**对象工厂** `ObjectFactory`,用于生成早期暴露的Bean。 **解决流程(以 A 依赖 B,B 依赖 A 为例)**: 1. 开始创建A。调用A的构造器,实例化A(此时A还是一个"半成品")。 2. 将A的 `ObjectFactory` 放入**三级缓存**。 3. 准备为A注入属性,发现A依赖B。于是去创建B。 4. 调用B的构造器,实例化B(此时B也是一个"半成品")。 5. 将B的 `ObjectFactory` 放入**三级缓存**。 6. 准备为B注入属性,发现B依赖A。 7. 首先从**一级缓存**找A,没有。 8. 然后从**二级缓存**找A,也没有。 9. 最后从**三级缓存**中找到A的 `ObjectFactory`,并通过它**获取到A的早期引用**(可能是一个原始对象,也可能是经过AOP代理后的对象)。此时,将这个早期引用A放入**二级缓存**,并从三级缓存中移除A的工厂。 10. B成功获得了A的早期引用,完成了属性注入,并执行后续的初始化步骤,最终成为一个完整的Bean,被放入**一级缓存**。 11. 此时,A的创建流程继续,它成功从一级缓存中拿到了已经初始化好的B,完成了自己的属性注入和初始化,最终也被放入一级缓存。 通过这种“提前暴露”不完全初始化的Bean引用的机制,Spring巧妙地解决了单例Bean的Setter/字段注入循环依赖问题。 *** ### 六、事务传播机制 事务传播行为定义了当一个事务方法被另一个事务方法调用时,事务应该如何传播。 **七种传播行为**: 1. **REQUIRED(默认)**: * 如果当前存在事务,则加入该事务。 * 如果当前没有事务,则创建一个新的事务。 * **适用场景**:大多数业务方法。 2. **SUPPORTS**: * 如果当前存在事务,则加入该事务。 * 如果当前没有事务,则以非事务的方式继续运行。 * **适用场景**:查询方法,可以适应有无事务的环境。 3. **MANDATORY**: * 如果当前存在事务,则加入该事务。 * 如果当前没有事务,则抛出异常。 * **适用场景**:强制要求必须在事务中运行的方法。 4. **REQUIRES\_NEW**: * 创建一个新的事务,如果当前存在事务,则把当前事务**挂起**。 * **适用场景**:子事务的执行结果不能影响父事务,如日志记录(即使业务失败,日志也必须记录)。 5. **NOT\_SUPPORTED**: * 以非事务方式运行,如果当前存在事务,则把当前事务挂起。 * **适用场景**:不支持事务的操作,如某些耗时的批量处理。 6. **NEVER**: * 以非事务方式运行,如果当前存在事务,则抛出异常。 * **适用场景**:强制要求不能在事务中运行的方法。 7. **NESTED**: * 如果当前存在事务,则在**嵌套事务**内执行。 * 如果当前没有事务,则行为与 `REQUIRED` 一样。 * **特点**:嵌套事务是外部事务的一部分,只有外部事务提交时,嵌套事务才会提交。嵌套事务可以独立于外部事务进行回滚,而外部事务回滚会导致嵌套事务也回滚。 * **适用场景**:复杂的业务场景,如订单创建(主事务)和库存扣减(嵌套事务),库存扣减失败可以回滚而不影响订单创建。 *** ### 七、SpringBoot 自动配置原理 SpringBoot的核心理念是“约定优于配置”,其自动配置能力大大简化了Spring应用的初始搭建和开发过程。 **核心原理**:`@SpringBootApplication` 注解和 `spring.factories` 文件。 **详细流程**: 1. **启动入口**:主类上的 `@SpringBootApplication` 注解。 ```java @SpringBootApplication public class MyApplication { public static void main(String[] args) { SpringApplication.run(MyApplication.class, args); } } ``` 2. **核心注解**:`@SpringBootApplication` 是一个复合注解,它包含: * `@SpringBootConfiguration`:标记该类为配置类。 * `@ComponentScan`:开启组件扫描,扫描当前包及其子包下的 `@Component`, `@Service`, `@Repository`, `@Controller` 等。 * **`@EnableAutoConfiguration`**:**这是开启自动配置的关键**。 3. **`@EnableAutoConfiguration` 的奥秘**: * 它使用 `@Import` 注解导入了 `AutoConfigurationImportSelector` 类。 * `AutoConfigurationImportSelector` 会调用 `SpringFactoriesLoader.loadFactoryNames()` 方法,去扫描所有jar包类路径下的 `META-INF/spring.factories` 文件。 4. **`spring.factories` 文件**: * 这个文件是一个Key-Value形式的配置文件。 * 在 `spring-boot-autoconfigure-x.x.x.x.jar` 中,有一个 `META-INF/spring.factories` 文件,它的 `EnableAutoConfiguration` key 下列出了**所有自动配置类的全限定名**(如 `org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration`)。 5. **条件化配置**: * 这些自动配置类并不会全部生效,它们上面都标有 `@Conditional` 及其衍生注解(**条件注解**)。 * **常见条件注解**: * `@ConditionalOnClass`:类路径下存在指定的类时,配置才生效。 * `@ConditionalOnMissingBean`:容器中不存在指定的Bean时,配置才生效。**这是用户自定义配置可以覆盖默认配置的关键**。 * `@ConditionalOnProperty`:指定的属性有指定的值时,配置才生效。 * `@ConditionalOnWebApplication`:当前应用是Web应用时,配置才生效。 * **过程**:SpringBoot会逐个判断这些自动配置类上的条件是否满足。如果满足,则加载该配置类,并按其内部的 `@Bean` 定义向容器中注册Bean。 **总结流程**: `@SpringBootApplication` -> `@EnableAutoConfiguration` -> `AutoConfigurationImportSelector` -> 读取 `spring.factories` -> 加载**自动配置类** -> 根据**条件注解**决定是否生效 -> 向容器注册Bean。 这使得我们只需引入一个Starter依赖(如 `spring-boot-starter-web`),SpringBoot就能自动为我们配置好Tomcat、Spring MVC等组件,实现了开箱即用的效果。 好的,Redis缓存的击穿、穿透和雪崩是三个经典的高并发场景下缓存异常问题,它们都会导致大量请求直接落到数据库上,从而可能压垮数据库。我们来详细讲解一下。 *** ### 八、redis缓存的击穿、穿透、雪崩 #### 一、缓存穿透 ##### 1. 是什么? 缓存穿透是指**查询一个数据库中和缓存中都不存在的数据**。由于缓存不具备存储该key的能力,导致每次请求都会穿过缓存,直接查询数据库。 **打个比方**:有人不停地用不存在的身份证号来查信息,派出所的档案室(缓存)里没有,每次都得去总人口库(数据库)里查,结果总是查无此人,但这个过程依然消耗了总人口库的资源。 ##### 2. 产生原因 * 恶意攻击:黑客故意构造大量不存在的key进行请求。 * 业务逻辑bug:程序错误地生成了大量无效的查询。 ##### 3. 解决方案 1. **缓存空对象**: * **做法**:当数据库查询也为空时,仍然将这个空结果(比如 `null`)进行缓存,并设置一个较短的过期时间(例如 1-5分钟)。 * **优点**:实现简单,能有效应对短时间的恶意攻击。 * **缺点**: * 可能会缓存大量无用的空键,浪费内存空间。 * 在缓存过期的时间内,数据可能被真实写入数据库,导致短期数据不一致(需要业务能容忍)。 2. **布隆过滤器**: * **做法**:在缓存之前,加一个布隆过滤器。布隆过滤器是一个高效的数据结构,**用于判断一个元素“一定不存在”或“可能存在”于某个集合中**。 * **流程**: 1. 将所有可能存在的键(如商品ID、用户ID)预先加载到布隆过滤器中。 2. 请求来时,先让布隆过滤器判断key是否存在。 * 如果布隆过滤器说 **“不存在”**,那么这个key一定不存在,直接返回空,无需查询缓存和数据库。 * 如果布隆过滤器说 **“可能存在”**,那么再继续后续的缓存和数据库查询流程。 * **优点**:内存占用极少,能从根本上防御穿透攻击。 * **缺点**: * 存在一定的误判率(但不会误判“不存在”的情况)。 * 无法删除数据(传统的布隆过滤器不支持删除,不过有变种如计数布隆过滤器可以)。 *** #### 二、缓存击穿 ##### 1. 是什么? 缓存击穿是指一个**访问非常频繁的热点key**(如明星绯闻、秒杀商品),在**缓存过期的瞬间**,同时有大量的请求进来。这些请求发现缓存过期,都会去数据库加载数据,并回设缓存。这个过程中,大量并发请求瞬间穿透到数据库,造成数据库压力激增。 **打个比方**:一个热门店铺的优惠券在上午10点准时开抢。10点整,旧的缓存刚好失效,成千上万人同时点击,导致请求全部涌向数据库。 ##### 2. 产生原因 * 热点数据。 * 缓存过期。 * 高并发请求。 ##### 3. 解决方案 1. **设置热点数据永不过期**: * **做法**:对于极热点数据,不设置过期时间。然后通过后台任务或程序逻辑,在数据更新时主动刷新缓存。 * **优点**:从根本上避免了因过期导致的击穿问题。 * **缺点**:需要人工识别热点数据,并编写额外的更新逻辑。 2. **互斥锁**: * **做法**:当缓存失效时,不是所有线程都去查询数据库,而是让这些线程**竞争一个分布式锁**(可以用Redis的 `SETNX` 命令实现)。只有拿到锁的线程才有资格去查询数据库并重建缓存,其他线程则等待或重试。 * **优点**:能很好地保护数据库,保证只有一个线程去查询。 * **缺点**:性能有损耗,可能存在死锁风险,实现复杂度较高。 3. **逻辑过期**: * **做法**:不给缓存数据设置Redis的物理过期时间,而是在存储的**value值中封装一个逻辑过期时间字段**。 * **流程**: 1. 线程从缓存中取出数据,检查其逻辑过期时间。 2. 如果数据未逻辑过期,直接返回。 3. 如果数据已逻辑过期,则尝试获取互斥锁。 * 拿到锁的线程开启一个新线程去异步更新缓存,自己则返回旧的、已过期的数据。 * 没拿到锁的线程也直接返回旧的、已过期的数据。 * **优点**:用户体验好,不会出现所有线程都等待的情况,保证了高可用性。 * **缺点**:会返回过期数据,只能保证**最终一致性**,不能保证强一致性。 *** #### 三、缓存雪崩 ##### 1. 是什么? 缓存雪崩是指**大量的缓存key在同一时间段内集中过期**,或者**Redis缓存服务直接宕机**。导致所有请求这些数据的请求都会落到数据库上,引起数据库压力过大甚至宕机,进而导致整个系统崩溃。 **打个比方**:缓存击穿是“一颗子弹击穿了防线”,而缓存雪崩是“整个防线的地雷在同一时刻全部被引爆”,后果是毁灭性的。 ##### 2. 产生原因 * 大量的key设置了相同的过期时间(例如,缓存数据在每天零点统一刷新)。 * Redis集群宕机。 ##### 3. 解决方案 1. **错开过期时间**: * **做法**:在设置key的过期时间时,加上一个随机值,让key的过期时间均匀分布。 * **例如**:`set_redis_key(key, value, time + random(0, 300))` // 在基础时间上增加一个0到5分钟的随机数。 * **优点**:简单有效,能避免大量key同时失效。 2. **构建高可用的Redis集群**: * **做法**:通过主从复制、哨兵模式或Redis Cluster来搭建集群,实现服务的高可用。即使个别节点宕机,集群仍然可以提供服务。 * **优点**:从根本上解决了因单点故障导致的雪崩。 3. **服务降级与熔断**: * **做法**:当非核心服务出现故障时,暂时将其关闭(降级);当检测到数据库压力过大时,直接拒绝部分请求或返回预定义的默认值(熔断),保护数据库不被拖垮。 * **工具**:可以使用Hystrix、Sentinel等组件实现。 * **优点**:保证了核心服务的可用性,牺牲了部分非核心功能或用户体验。 4. **持久化缓存**: * **做法**:同缓存击穿,对部分非常重要的数据设置永不过期,通过异步方式更新。 *** #### 总结与对比 | 问题类型 | **核心特征** | **根本原因** | **解决方案** | | :------- | :----------------------------------------------- | :----------------------------------------- | :--------------------------------------------------------------------------------------------------------- | | **缓存穿透** | **查询不存在的数据** | 数据在DB和缓存中都不存在 | 1. 缓存空对象2. **布隆过滤器** | | **缓存击穿** | **单个热点key过期**时面临高并发 | 热点key失效 + 高并发 | 1. 永不过期2. **互斥锁**3. **逻辑过期** | | **缓存雪崩** | **大量key同时过期**或**Redis服务宕机** | 缓存大规模失效 | 1. **错开过期时间**2. **构建高可用集群**3. **服务降级与熔断** | 在实际项目中,通常需要根据业务场景,**组合使用**这些方案来构建一个健壮的缓存系统。例如,使用布隆过滤器防止穿透,用互斥锁/逻辑过期防止击穿,用随机过期时间和集群防止雪崩。 --- --- url: /src/technology/dateblog/2025/05/20250508-linux-的环境变量的配置.md --- # linux 的环境变量的配置 在 Linux 系统中,环境变量的配置可分为**全局配置**(影响所有用户)和**用户级配置**(仅影响当前用户),具体方法和区别如下: *** ### **1. 全局环境变量配置**(所有用户生效) #### **配置文件** * **`/etc/environment`** * **作用**:系统级环境变量定义,所有用户和进程共享。 * **语法**:直接写入键值对,**不**支持脚本语法(如 `export`): ```bash JAVA_HOME=/usr/local/java PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" ``` * **生效方式**:需重启系统或重新登录用户。 * **`/etc/profile` 或 `/etc/profile.d/*.sh`** * **作用**:登录 Shell(如 SSH 登录或 `su - user`)加载的全局配置,支持脚本逻辑。 * **语法**:使用 `export` 定义变量: ```bash export JAVA_HOME=/usr/local/java export PATH="$PATH:$JAVA_HOME/bin" ``` * **生效方式**: ```bash source /etc/profile # 临时生效 或重新登录用户。 ``` #### **适用场景** * 需要所有用户共享的变量(如系统服务依赖的 `JAVA_HOME`)。 * 安装全局工具时(如 Docker、Node.js 等)。 *** ### **2. 用户级环境变量配置**(仅当前用户生效) #### **配置文件** * **`~/.bashrc`** * **作用**:每次打开新的 **非登录 Shell**(如终端窗口)时加载。 * **语法**:使用 `export` 定义变量: ```bash export MY_TOOLS="$HOME/my_tools" export PATH="$PATH:$MY_TOOLS/bin" ``` * **生效方式**: ```bash source ~/.bashrc # 临时生效 或新开终端窗口。 ``` * **`~/.profile` 或 `~/.bash_profile`** * **作用**:用户登录 Shell(如 SSH 登录或 `su - user`)时加载。 * **语法**:使用 `export` 定义变量: ```bash export EDITOR=vim export LANG=en_US.UTF-8 ``` * **生效方式**:重新登录用户。 #### **适用场景** * 用户自定义工具路径(如 `~/bin`)。 * 开发环境配置(如 Python 虚拟环境变量)。 *** ### **3. 临时环境变量配置**(仅当前 Shell 会话生效) * **命令行直接设置**: ```bash export TEMP_VAR="hello" ``` * **单次命令生效**: ```bash TEMP_VAR="hello" some_command ``` *** ### **4. 核心区别总结** | **配置方式** | 作用范围 | 生效条件 | 适用场景 | |--------------------|---------------|-----------------------|----------------------------| | `/etc/environment` | 所有用户和进程 | 需重启或重新登录 | 系统级固定变量(如语言设置) | | `/etc/profile` | 所有用户 | 登录 Shell 或 `source` | 全局工具路径(如 Java) | | `~/.bashrc` | 当前用户 | 新开终端或 `source` | 频繁使用的开发变量 | | `~/.profile` | 当前用户 | 登录 Shell 或 `source` | 用户登录时的初始化配置 | | 命令行 `export` | 当前 Shell | 立即生效 | 临时调试或测试 | *** ### **5. 配置示例** #### **全局配置(以 Java 为例)** 1. 编辑 `/etc/profile`: ```bash sudo vim /etc/profile ``` 2. 添加以下内容: ```bash export JAVA_HOME=/usr/local/java export PATH="$PATH:$JAVA_HOME/bin" ``` 3. 生效配置: ```bash source /etc/profile # 临时生效 ``` #### **用户级配置(以 Python 虚拟环境为例)** 1. 编辑 `~/.bashrc`: ```bash vim ~/.bashrc ``` 2. 添加以下内容: ```bash export WORKON_HOME=$HOME/.virtualenvs source /usr/local/bin/virtualenvwrapper.sh ``` 3. 生效配置: ```bash source ~/.bashrc # 新开终端自动加载 ``` *** ### **6. 注意事项** 1. **优先级冲突**: * 用户级配置会覆盖全局配置(如 `~/.bashrc` 中的 `PATH` 优先级高于 `/etc/profile`)。 * 使用 `echo $PATH` 查看当前生效的路径顺序。 2. **安全权限**: * 修改全局文件(如 `/etc/environment`)需 `sudo` 权限。 3. **配置验证**: * 使用 `env` 或 `printenv` 查看所有环境变量。 * 使用 `echo $VAR_NAME` 查看具体变量值。 *** 通过合理选择配置方式,可以实现灵活的环境变量管理,满足不同场景需求。 --- --- url: /src/technology/dateblog/2026/04/20260414-linux安装nfs服务.md --- # Linux NFS 服务完整安装配置与使用指南 ## 一、概述 NFS(Network File System,网络文件系统)是Linux/Unix系统原生的分布式文件系统协议,可通过网络实现跨主机目录与文件共享,支持将Linux远端目录直接映射为Windows/Linux客户端的本地盘符/挂载点,实现跨设备文件无缝读写。 ### 1.1 核心特性与适用场景 | 核心优势 | 局限性 | 最佳适用场景 | |----------|--------|--------------| | Linux原生支持,配置极简、资源占用低 | 公网环境安全性弱,不推荐直接暴露公网 | 局域网内Linux与Windows/多Linux主机长期文件共享 | | 局域网大文件传输性能顶尖,读写效率优异 | Windows家庭版无原生NFS客户端,需专业/企业版 | 内网开发环境代码/资源共享、大文件高频传输场景 | | 支持权限精细化管控,兼容多系统架构 | 跨公网传输无原生加密,需配合VPN使用 | Linux集群环境统一文件存储访问、内网媒体/文件库共享 | ### 1.2 版本说明 * **NFSv3**:当前最通用、兼容性最好的版本,配置简单,Windows原生客户端完美支持,**本指南默认以NFSv3为核心**,覆盖99%的日常使用场景。 * **NFSv4**:新一代版本,统一端口(仅2049)、安全性更强、支持Kerberos认证,适合进阶生产环境,指南单独提供极简配置方案。 ### 1.3 前置环境要求 1. 服务端:Linux主流发行版(Debian/Ubuntu、RHEL/CentOS/Fedora、Arch等),具备sudo/root管理员权限 2. 客户端:Windows 10/11 专业版/企业版、Linux主机,与服务端网络互通(可互相ping通) 3. 网络要求:防火墙放行NFS相关端口,无安全组/内网拦截 *** ## 二、Linux服务端 NFS 服务安装 根据你的Linux发行版,执行对应安装命令,全程使用sudo/root权限操作。 ### 2.1 分发行版安装命令 #### 2.1.1 Debian/Ubuntu 系列(Ubuntu 20.04+/Debian 10+) ```bash # 更新软件源 sudo apt update # 安装NFS服务核心包与依赖 sudo apt install -y nfs-kernel-server nfs-common # 验证安装(查看版本信息) sudo nfsstat -v ``` #### 2.1.2 RHEL/CentOS/Fedora 系列(CentOS 7+/RHEL 8+/Fedora 34+) ```bash # 安装NFS服务核心包 sudo dnf install -y nfs-utils # 验证安装 sudo nfsstat -v ``` > 注:CentOS 7 可将`dnf`替换为`yum`执行。 #### 2.1.3 Arch Linux 系列 ```bash sudo pacman -S nfs-utils ``` ### 2.2 服务基础管理命令 ```bash # 启动NFS服务 sudo systemctl start nfs-server # 设置开机自启(必做,服务器重启后自动生效) sudo systemctl enable --now nfs-server # 查看服务运行状态(active (running) 即为启动成功) sudo systemctl status nfs-server # 重启服务 sudo systemctl restart nfs-server # 停止服务 sudo systemctl stop nfs-server ``` *** ## 三、核心配置文件 `/etc/exports` 全详解 `/etc/exports` 是NFS服务的唯一核心配置文件,用于定义**允许共享的目录、可访问的客户端、访问权限与规则**,#开头的为注释行,不生效。 ### 3.1 配置文件基础格式 每一行对应一条共享规则,固定格式如下: ```ini <共享目录绝对路径> <客户端1(权限参数1,权限参数2,...)> <客户端2(权限参数1,权限参数2,...)> ``` * **共享目录绝对路径**:必须是Linux系统中已存在目录的完整绝对路径(如`/home/user/share`),不可使用相对路径。 * **客户端**:允许访问该共享的主机,支持IP、网段、主机名等多种写法。 * **权限参数**:括号内为该客户端对应的访问规则,多个参数用**英文逗号分隔,逗号与参数之间不能有空格**。 ### 3.2 客户端常用写法 | 客户端写法 | 说明 | 推荐场景 | |------------|------|----------| | `192.168.1.100` | 仅允许指定单个IP的主机访问 | 单台固定客户端,安全性最高 | | `192.168.1.0/24` | 允许指定网段内的所有主机访问 | 局域网多客户端,最常用 | | `*.example.com` | 允许指定域名后缀的所有主机访问 | 有内网DNS的企业环境 | | `nfs-client.example.com` | 允许指定主机名的主机访问 | 内网固定主机名场景 | | `*` | 允许所有主机访问 | 极度不推荐,存在严重安全风险 | ### 3.3 核心权限参数详解 #### 3.3.1 基础权限参数 | 参数 | 核心作用 | |------|----------| | `rw` | 允许客户端读写权限,默认仅允许只读`ro` | | `ro` | 强制客户端只读访问,无法修改/新建/删除文件 | | `sync` | 同步写入模式:数据先持久化到服务端磁盘后再返回成功,**数据安全性高,推荐默认使用** | | `async` | 异步写入模式:数据先写入内存缓存,后台异步落盘,**性能更高,但断电/宕机易丢失数据** | #### 3.3.2 权限映射与安全参数 | 参数 | 核心作用 | |------|----------| | `root_squash` | root压缩(默认开启):将客户端root用户映射为服务端匿名用户nobody,禁止客户端拥有服务端root权限,提升安全性 | | `no_root_squash` | 关闭root压缩:允许客户端root拥有服务端root权限,**解决Windows客户端读写权限不足的核心参数** | | `all_squash` | 全用户压缩:将客户端所有用户都映射为服务端匿名用户,适合公共共享场景 | | `anonuid=UID` | 指定匿名用户映射的服务端用户UID,配合all\_squash使用,解决Windows权限问题 | | `anongid=GID` | 指定匿名用户映射的服务端用户GID,与anonuid配对使用 | #### 3.3.3 性能与兼容性参数 | 参数 | 核心作用 | |------|----------| | `no_subtree_check` | 关闭子目录检查:禁用共享目录子树权限校验,大幅提升读写性能,**推荐默认开启** | | `insecure` | 允许客户端使用大于1024的非特权端口连接,解决部分客户端端口受限无法挂载的问题 | ### 3.4 默认示例配置拆解 对应你提供的系统默认配置文件,官方示例含义如下: ```ini # NFSv3 示例:共享/srv/homes目录,hostname1主机可读写,hostname2主机仅只读 /srv/homes hostname1(rw,sync,no_subtree_check) hostname2(ro,sync,no_subtree_check) # NFSv4 示例:定义NFSv4根目录(fsid=0为固定标识),/srv/nfs4为根,子目录单独共享 /srv/nfs4 gss/krb5i(rw,sync,fsid=0,crossmnt,no_subtree_check) /srv/nfs4/homes gss/krb5i(rw,sync,no_subtree_check) ``` *** ## 四、NFS共享实战配置全流程(Windows兼容首选) 以最常用的「局域网Windows客户端访问Linux共享目录」为场景,从零完成完整配置。 ### 4.1 步骤1:创建共享目录并配置系统权限 > 重点提醒:NFS共享的最终权限,**Linux系统目录本身的权限优先级最高**,共享配置的`rw`权限无法突破系统目录的权限限制。 ```bash # 1. 创建共享目录(替换为你的目标路径,示例为/home/yourname/linux_nfs_share) sudo mkdir -p /home/yourname/linux_nfs_share # 2. 配置目录所有权(yourname替换为你的Linux普通用户名,避免root归属导致权限不足) sudo chown -R yourname:yourname /home/yourname/linux_nfs_share # 3. 配置目录系统权限,确保客户端有读写权限 sudo chmod -R 755 /home/yourname/linux_nfs_share ``` ### 4.2 步骤2:编写共享配置规则 ```bash # 先备份原始配置文件,避免出错无法恢复 sudo cp /etc/exports /etc/exports.bak # 编辑配置文件 sudo nano /etc/exports ``` 在配置文件**末尾**添加以下规则,根据你的实际环境修改路径、网段: ```ini # 局域网Windows客户端读写共享配置 /home/yourname/linux_nfs_share 192.168.1.0/24(rw,sync,no_root_squash,no_subtree_check) ``` > 配置说明: > > * 共享目录:与步骤1创建的路径完全一致 > * 允许访问:192.168.1.0/24局域网网段(替换为你的实际网段) > * 权限:读写、同步写入、关闭root压缩、关闭子目录检查,完美适配Windows客户端 ### 4.3 步骤3:重载配置使生效 修改配置文件后,无需重启服务,执行重载命令即可立即生效: ```bash sudo exportfs -ra ``` > 常用exportfs辅助命令: > > * `exportfs -v`:查看已加载的共享配置详情 > * `exportfs -u`:停用所有共享 > * `exportfs -a`:启用所有共享 ### 4.4 步骤4:验证配置是否生效 执行以下命令,查看当前已发布的共享列表: ```bash sudo showmount -e localhost ``` 成功生效的输出示例: ``` Export list for localhost: /home/yourname/linux_nfs_share 192.168.1.0/24 ``` ### 4.5 步骤5:防火墙放行NFS服务 必须放行防火墙规则,否则客户端无法访问,根据你的防火墙工具执行对应命令: #### 4.5.1 UFW 防火墙(Debian/Ubuntu默认) ```bash # 放行NFS服务 sudo ufw allow nfs # 重载防火墙规则 sudo ufw reload # 查看放行状态 sudo ufw status ``` #### 4.5.2 Firewalld 防火墙(RHEL/CentOS/Fedora默认) ```bash # 永久放行NFS及依赖服务 sudo firewall-cmd --add-service=nfs --permanent sudo firewall-cmd --add-service=rpc-bind --permanent sudo firewall-cmd --add-service=mountd --permanent # 重载防火墙规则 sudo firewall-cmd --reload # 查看放行状态 sudo firewall-cmd --list-all ``` ### 4.6 步骤6:SELinux 权限放行(仅RHEL/CentOS/Fedora系列) RHEL系列系统默认开启SELinux,不配置会导致客户端无法读写共享目录: ```bash # 允许NFS共享家目录(若共享目录在/home下) sudo setsebool -P use_nfs_home_dirs on # 若共享目录不在家目录,执行以下命令标记SELinux安全上下文 sudo chcon -t nfs_t /home/yourname/linux_nfs_share -R ``` *** ## 五、Windows 客户端挂载NFS共享(映射为本地盘符) ### 5.1 前置步骤:启用Windows NFS客户端功能 > 注意:Windows 家庭版无原生NFS客户端功能,需升级到专业版/企业版。 1. 打开「控制面板」→「程序」→「程序和功能」→ 左侧点击「启用或关闭Windows功能」 2. 在弹出的窗口中,找到「NFS服务」,展开后**勾选「NFS客户端」** 3. 点击「确定」,等待系统安装组件,完成后按提示**重启电脑** ### 5.2 方法1:图形界面映射(新手首选) 1. 重启电脑后,打开「此电脑」,顶部菜单栏点击「映射网络驱动器」(或空白处右键选择该选项) 2. 在弹出的窗口中完成配置: * 驱动器:选择一个未被占用的盘符(如Z:、Y:) * 文件夹:按固定格式填写 `\\Linux服务端IP\共享目录绝对路径` * 示例:`\\192.168.1.100\home\yourname\linux_nfs_share` * 注意:Linux路径中的`/`需替换为`\`,IP和路径必须与服务端配置完全一致 * 勾选「登录时重新连接」:实现Windows开机自动挂载 * 无需勾选「使用其他凭据连接」,NFS默认通过IP网段控制权限,无额外账号密码 3. 点击「完成」,即可完成挂载,此电脑中会出现对应的盘符,可像本地磁盘一样直接读写文件。 ### 5.3 方法2:命令行挂载(管理员权限) 1. 以管理员身份打开CMD或PowerShell 2. 执行挂载命令: ```cmd # 挂载命令格式:mount \\LinuxIP\共享目录绝对路径 盘符: mount \\192.168.1.100\home\yourname\linux_nfs_share Z: ``` 3. 卸载命令: ```cmd # 卸载指定盘符 umount Z: # 卸载所有NFS挂载 umount -a ``` ### 5.4 Windows客户端权限不足问题修复 #### 问题现象:能打开目录,但无法新建/修改/删除文件 **核心原因**:Windows NFS客户端默认使用匿名用户访问,服务端匿名用户无对应目录的读写权限。 **解决方案**: 1. 在Linux服务端查询你的用户UID和GID: ```bash # yourname替换为你的Linux用户名 id yourname ``` 输出示例(核心值为uid=1000,gid=1000): ``` uid=1000(yourname) gid=1000(yourname) groups=1000(yourname) ``` 2. 修改服务端`/etc/exports`配置,添加`anonuid`和`anongid`参数: ```ini /home/yourname/linux_nfs_share 192.168.1.0/24(rw,sync,no_root_squash,no_subtree_check,anonuid=1000,anongid=1000) ``` 3. 重载配置生效: ```bash sudo exportfs -ra ``` 4. 重新挂载Windows端盘符,即可正常读写。 *** ## 六、进阶配置 ### 6.1 Linux客户端挂载NFS共享 1. 安装客户端工具: ```bash # Debian/Ubuntu sudo apt install -y nfs-common # RHEL/CentOS/Fedora sudo dnf install -y nfs-utils ``` 2. 临时挂载: ```bash # 创建挂载点 sudo mkdir -p /mnt/nfs_share # 挂载命令:mount 服务端IP:共享目录 本地挂载点 sudo mount 192.168.1.100:/home/yourname/linux_nfs_share /mnt/nfs_share # 验证挂载 df -h ``` 3. 开机自动挂载: 编辑`/etc/fstab`文件,添加以下内容,添加`_netdev`参数确保网络就绪后再挂载: ```ini 192.168.1.100:/home/yourname/linux_nfs_share /mnt/nfs_share nfs defaults,_netdev 0 0 ``` 执行`sudo mount -a`验证配置,无报错即为生效。 ### 6.2 NFSv4 极简配置指南 NFSv4仅需2049一个端口,无需放行多个依赖端口,安全性更强,配置步骤如下: 1. 创建NFSv4根目录与绑定目录: ```bash # 创建NFSv4根目录 sudo mkdir -p /srv/nfs4 # 创建共享子目录绑定路径 sudo mkdir -p /srv/nfs4/share # 绑定实际共享目录到NFSv4根目录下 sudo mount --bind /home/yourname/linux_nfs_share /srv/nfs4/share # 开机自动绑定,编辑/etc/fstab添加以下内容: /home/yourname/linux_nfs_share /srv/nfs4/share none bind 0 0 ``` 2. 编辑`/etc/exports`配置: ```ini # NFSv4根目录,fsid=0为固定必填参数 /srv/nfs4 192.168.1.0/24(rw,sync,fsid=0,crossmnt,no_subtree_check,no_root_squash) # 共享子目录 /srv/nfs4/share 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash) ``` 3. 重载配置生效: ```bash sudo exportfs -ra ``` 4. 客户端挂载路径: * Windows:`\\192.168.1.100\share` * Linux:`192.168.1.100:/share` *** ## 七、常见问题排查指南 ### 7.1 Windows无法挂载,提示"网络路径不存在" 1. 验证网络连通性:Windows端ping Linux服务端IP,确认可正常通信 2. 检查服务状态:Linux服务端NFS服务是否正常运行(`systemctl status nfs-server`) 3. 检查防火墙:Linux服务端是否已放行NFS相关端口与服务 4. 检查路径一致性:Windows端填写的路径是否与服务端`/etc/exports`中的路径完全一致 5. 验证客户端功能:Windows NFS客户端是否已启用并完成重启 ### 7.2 挂载成功,但无法读写文件,提示"权限被拒绝" 1. 检查Linux系统目录权限:确认共享目录的`chmod`和`chown`配置正确,目录有读写权限 2. 检查共享配置:`/etc/exports`中是否添加了`rw`参数,而非默认的`ro` 3. 检查SELinux:RHEL系列系统是否已配置SELinux放行规则 4. 配置权限映射:添加`no_root_squash`+`anonuid/anongid`参数,修复Windows匿名用户权限问题 ### 7.3 修改配置后不生效 1. 必须执行`sudo exportfs -ra`重载配置,仅重启服务可能不生效 2. 检查配置格式:括号内的参数之间、客户端与括号之间不能有空格 3. 验证共享目录:确认共享目录存在,且使用绝对路径 ### 7.4 开机自动挂载失效 1. Windows端:检查是否勾选「登录时重新连接」,确认开机后网络先于映射驱动器完成连接 2. Linux客户端:`/etc/fstab`中添加`_netdev`参数,确保网络就绪后再执行挂载 *** ## 八、安全最佳实践 1. **严禁直接暴露公网**:NFS协议无原生强认证,公网环境极易被攻击,公网访问必须通过VPN、内网加密隧道实现 2. **最小权限原则**:仅允许指定IP/网段访问,禁止使用`*`允许所有主机访问 3. **权限管控**:非必要不使用`no_root_squash`,生产环境优先使用`root_squash`+`anonuid`指定低权限用户 4. **只读优先**:仅需读取文件的场景,强制使用`ro`只读权限,避免误修改/删除 5. **端口收敛**:生产环境优先使用NFSv4,仅放行2049端口,关闭其他不必要的端口与服务 6. **定期更新**:及时更新NFS相关软件包,修复安全漏洞 --- --- url: /src/technology/dateblog/2025/05/20250508-linux操作系统远程下载文件示例.md --- # Linux操作系统远程下载文件示例 在不同的 Linux 发行版中,下载远程文件的方式基本一致,但安装工具和包管理器可能略有差异。以下是针对常见 Linux 发行版(如 Debian/Ubuntu、Red Hat/CentOS、Fedora、Arch 等)的下载方法总结,涵盖命令行和图形化工具。 *** ### **1. 常用命令行工具** #### **1.1 `wget`** * **功能**:支持 HTTP/HTTPS/FTP 协议下载文件,支持断点续传。 * **安装**: * **Debian/Ubuntu**: ```bash sudo apt update sudo apt install wget ``` * **Red Hat/CentOS**: ```bash sudo yum install wget ``` * **Fedora**: ```bash sudo dnf install wget ``` * **Arch**: ```bash sudo pacman -S wget ``` * **使用示例**: ```bash # 下载单个文件 wget http://example.com/file.zip # 断点续传 wget -c http://example.com/large-file.iso # 后台下载 wget -b http://example.com/big-file.tar.gz ``` *** #### **1.2 `curl`** * **功能**:支持多种协议(HTTP/HTTPS/FTP/SFTP),可发送请求并下载文件。 * **安装**: * **Debian/Ubuntu**: ```bash sudo apt update sudo apt install curl ``` * **Red Hat/CentOS**: ```bash sudo yum install curl ``` * **Fedora**: ```bash sudo dnf install curl ``` * **Arch**: ```bash sudo pacman -S curl ``` * **使用示例**: ```bash # 下载文件并保存为原名 curl -O http://example.com/file.txt # 下载并指定保存名称 curl -o new-name.txt http://example.com/file.txt # 限制下载速度(100KB/s) curl --limit-rate 100k http://example.com/large-file.iso ``` *** #### **1.3 `scp`** * **功能**:基于 SSH 的安全文件传输工具,适合从远程服务器下载文件。 * **安装**: * **Debian/Ubuntu/Red Hat/CentOS/Fedora/Arch**:通常预装(依赖 OpenSSH)。 * **使用示例**: ```bash # 从远程服务器下载单个文件 scp user@remote_host:/path/to/remote/file.txt /local/path/ # 下载整个目录 scp -r user@remote_host:/path/to/remote/folder /local/path/ # 指定 SSH 端口(如非默认端口) scp -P 2222 user@remote_host:/path/to/remote/file.txt /local/path/ ``` *** #### **1.4 `rsync`** * **功能**:增量传输工具,适合大文件或目录同步。 * **安装**: * **Debian/Ubuntu**: ```bash sudo apt install rsync ``` * **Red Hat/CentOS**: ```bash sudo yum install rsync ``` * **Fedora**: ```bash sudo dnf install rsync ``` * **Arch**: ```bash sudo pacman -S rsync ``` * **使用示例**: ```bash # 从远程服务器下载文件(保留权限和时间戳) rsync -avz user@remote_host:/path/to/remote/file.txt /local/path/ # 排除特定目录 rsync -avz --exclude='tmp/' user@remote_host:/path/to/remote/folder /local/path/ # 限速下载(200KB/s) rsync -avz --bwlimit=200 user@remote_host:/path/to/remote/file.txt /local/path/ ``` *** #### **1.5 `lrzsz`(rz/sz)** * **功能**:通过 ZModem 协议实现本地与远程服务器之间的文件传输(需终端支持,如 Xshell、SecureCRT)。 * **安装**: * **Debian/Ubuntu**: ```bash sudo apt install lrzsz ``` * **Red Hat/CentOS**: ```bash sudo yum install lrzsz ``` * **Fedora**: ```bash sudo dnf install lrzsz ``` * **Arch**: ```bash sudo pacman -S lrzsz ``` * **使用示例**: ```bash # 从本地上传文件到服务器 rz # 从服务器下载文件到本地 sz remote_file.txt ``` *** ### **2. 图形化工具** #### **2.1 FileZilla** * **功能**:跨平台图形化 FTP/SFTP 客户端,支持拖拽操作。 * **安装**: * **Debian/Ubuntu**: ```bash sudo apt install filezilla ``` * **Red Hat/CentOS**: ```bash sudo yum install filezilla ``` * **Fedora**: ```bash sudo dnf install filezilla ``` * **Arch**: ```bash sudo pacman -S filezilla ``` * **使用步骤**: 1. 打开 FileZilla,点击“文件” > “站点管理器”。 2. 配置远程服务器信息(协议、IP、用户名、密码、端口)。 3. 连接后,左侧显示远程文件,右侧显示本地文件,拖拽即可下载/上传。 *** #### **2.2 WinSCP(Windows 用户)** * **功能**:Windows 上的 SFTP/SCP 客户端,支持可视化操作。 * **安装**:[官网下载](https://winscp.net/)。 * **使用步骤**: 1. 配置 Linux 服务器信息(协议、IP、用户名、密码、端口)。 2. 双击远程文件即可下载到本地。 *** ### **3. 特殊场景** #### **3.1 通过 U 盘挂载传输** * **适用场景**:服务器无网络或需物理接触。 * **步骤**: ```bash # 挂载 U 盘 sudo mount /dev/sdX1 /mnt/usb # 复制文件到 U 盘 cp /path/to/local/file.txt /mnt/usb/ # 卸载 U 盘 sudo umount /mnt/usb ``` #### **3.2 使用 `axel` 加速下载** * **功能**:多线程下载工具,适合大文件。 * **安装**: * **Debian/Ubuntu**: ```bash sudo apt install axel ``` * **Red Hat/CentOS**: ```bash sudo yum install axel ``` * **Arch**: ```bash sudo pacman -S axel ``` * **使用示例**: ```bash axel http://example.com/large-file.iso ``` *** ### **4. 总结** | **工具** | **适用场景** | **优点** | **缺点** | |----------------|----------------------------------|-----------------------------------|---------------------------| | `wget` | HTTP/FTP 下载 | 支持断点续传 | 需手动处理复杂参数 | | `curl` | 多协议下载/发送请求 | 功能强大,支持自定义请求 | 学习曲线较陡 | | `scp` | 安全传输(SSH) | 加密传输,简单直接 | 依赖 SSH 服务 | | `rsync` | 大文件/目录同步 | 增量传输,节省带宽 | 配置稍复杂 | | `lrzsz` | 终端交互式传输 | 快速上传下载 | 需终端支持 ZModem | | FileZilla | 图形化操作 | 可视化,适合新手 | 依赖 GUI 环境 | | `axel` | 大文件加速下载 | 多线程加速 | 不支持 HTTPS 断点续传 | 根据需求选择合适的工具即可! --- --- url: /src/technology/dateblog/2026/04/20260411-logto的client-secret和pkce.md --- **原生应用可以在没有 Client Secret 的情况下使用授权码流程**,这是完全可以且推荐的做法。[(1)](https://blog.logto.io/reveal-public-and-confidential-clients)[(2)](https://blog.logto.io/oauth-security-recap) ## 为什么原生应用不需要 Client Secret 原生应用属于**公共客户端(Public clients)**,具有以下特征:[(1)](https://blog.logto.io/reveal-public-and-confidential-clients) 原生应用是安装并在资源所有者的设备上执行的。协议数据和凭据可以被资源所有者访问。通常假定应用程序中包含的任何客户端身份验证凭据都可以被提取出来。[(1)](https://blog.logto.io/reveal-public-and-confidential-clients) 由于这个原因:[(1)](https://blog.logto.io/reveal-public-and-confidential-clients) * 单页应用(SPA)和原生应用是公共客户端 * 在 Logto 中创建 SPA 或原生应用时,**没有 App secret** * 这是因为公共客户端的密钥无法保证安全 ## 有和没有 Client Secret 的区别 ### 对于公共客户端(原生应用、SPA) 在 Logto 中:[(2)](https://blog.logto.io/oauth-security-recap) 对于公共客户端,客户端凭据通常硬编码在客户端,例如在 JavaScript 包或原生平台的应用程序包中。与机密客户端相比,由于客户端凭据在客户端代码中的固有暴露,凭据泄露的风险更高。**在令牌交换流程中,提供 Client Secret 是可选的**。Logto 默认不会信任来自公共客户端的这些凭据。[(2)](https://blog.logto.io/oauth-security-recap) ### 对于机密客户端(传统 Web 应用) 相比之下:[(2)](https://blog.logto.io/oauth-security-recap) 对于机密客户端,所有授权相关的凭据(包括客户端凭据)都安全地存储在服务器端。此外,所有中间交换请求都经过加密以确保数据的机密性。机密客户端的客户端凭据泄露风险非常低,使其本质上更安全。因此,机密客户端默认被视为更高的安全级别。**在令牌交换流程中,提供 Client Secret 是必需的**。[(2)](https://blog.logto.io/oauth-security-recap) ## 原生应用的安全保障:PKCE 由于公共客户端无法安全存储 Client Secret,OAuth 引入了 **PKCE(Proof Key for Code Exchange)** 来解决这个问题:[(2)](https://blog.logto.io/oauth-security-recap) PKCE 通过在每个授权流程开始时临时生成一个 code verifier 来解决这个问题,该 verifier 存储在本地并经过哈希处理生成 code challenge,然后发送到授权服务器。在交换访问令牌时,code verifier 会再次发送到授权服务器。授权服务器验证 code verifier 和 code challenge 是否匹配,这确保公共客户端没有被冒充。[(2)](https://blog.logto.io/oauth-security-recap) PKCE 中的 code verifier 实际上充当动态的客户端密钥。其安全性由哈希算法的不可逆性来保证。[(2)](https://blog.logto.io/oauth-security-recap) ## OAuth 2.1 的要求 在 OAuth 2.1 中,这一点得到了进一步强化:[(3)](https://blog.logto.io/oauth-2-1) OAuth 2.1 中最重要的变化之一是,对于所有使用授权码流程的 OAuth 客户端,**PKCE 现在是强制性的**。PKCE 是一个安全扩展,可以防止授权码拦截攻击。它对于无法安全存储客户端密钥的移动应用和单页应用(SPA)特别有用。[(3)](https://blog.logto.io/oauth-2-1) 在 Logto 中,**带 PKCE 的授权码流程是所有 SPA 和移动应用支持的唯一流程**。授权码流程通过交换授权码提供了一种更安全的方式来获取访问令牌。[(3)](https://blog.logto.io/oauth-2-1) **总结**:原生应用不仅可以在没有 Client Secret 的情况下使用授权码流程,而且**必须使用 PKCE** 来确保安全性。PKCE 替代了 Client Secret 的作用,为公共客户端提供了同等甚至更强的安全保障。 --- --- url: /src/randomwriting/dateblog/2025/05/20250527-markdown中表示目录结构两种工风格方式.md --- # Markdown中表示目录结构两种工风格方式 在 Markdown 中表示目录结构时,通常使用**无序列表**和**缩进**来展示层级关系。以下是两种常见风格的示例: *** ### 示例 1:简单列表风格 ```markdown - project/ - src/ - components/ - Header.js - Footer.js - utils/ - helpers.js - public/ - index.html - images/ - logo.png - README.md - package.json ``` *** ### 示例 2:树状结构风格(使用符号模拟) ```markdown project/ ├── src/ │ ├── components/ │ │ ├── Header.js │ │ └── Footer.js │ └── utils/ │ └── helpers.js ├── public/ │ ├── index.html │ └── images/ │ └── logo.png ├── README.md └── package.json ``` *** ### 特殊符号复制 ```markdown │ ``` ```markdown ── ``` ```markdown ├── ``` ```markdown └── ``` *** ### 注意事项: 1. **缩进**:用 2 或 4 个空格表示层级(需保持一致)。 2. **符号**: * `-`、`*` 或 `+` 表示列表项。 * 树状风格中可用 `├──`(分支)和 `└──`(末尾分支)增强视觉效果。 3. **斜杠**:文件夹名称后加 `/`(如 `src/`),文件无需斜杠。 4. **文件扩展名**:建议保留(如 `.js`, `.md`)以明确类型。 实际使用中,简单列表风格更易维护,而树状风格更适合直接展示给读者。 --- --- url: >- /src/technology/dateblog/2025/03/20250327-Mybatis-Plus中UpdateWrapper在set设置Oracle值为null报错.md --- ## 问题描述说明 ::: info Mybatis-Plus中UpdateWrapper在set设置Oracle数据库值为null报错, 原因是JdbcType为Other,不被支持。而在Mysql数据库中此操作正常。 解决方案包括修改全局配置为field-strategy:IGNORE或针对特定字段设置jdbcType。 ::: ## 解决方案 ::: tip 更改配置中的jdbc-type-for-null 为 'null' ::: ```yaml mybatis-plus: configuration: jdbc-type-for-null: 'null' ``` --- --- url: /src/technology/dateblog/2025/06/20250608-mysql-my-cnf-配置文件深度解析.md --- # MySQL my.cnf 配置文件深度解析 ## MySQL `my.cnf` 配置文件深度解析 `my.cnf` 是 MySQL 的核心配置文件,控制数据库服务器的所有行为。下面从多个维度全面解析这个关键文件: *** ### 一、文件基础特性 1. **文件位置**(优先级从高到低): * 命令行指定:`mysqld --defaults-file=/path/to/my.cnf` * 平台默认位置: * Linux:`/etc/my.cnf` → `/etc/mysql/my.cnf` → `~/.my.cnf` * Windows:`C:\ProgramData\MySQL\MySQL Server 8.0\my.ini` * 查看当前加载路径: ```sql SHOW VARIABLES LIKE 'config_file'; ``` 2. **配置继承关系**: ```ini !includedir /etc/mysql/conf.d/ # 包含目录下的所有.cnf文件 !include /etc/mysql/extra.cnf # 包含特定文件 ``` *** ### 二、核心配置区块详解 #### 1. `[mysqld]` - 服务端核心配置 | **类别** | **关键参数** | **推荐值** | **作用说明** | |----------------|-----------------------------|-----------------|--------------------------------| | **基础设置** | `datadir` | /var/lib/mysql | 数据存储目录 | | | `socket` | /tmp/mysql.sock | UNIX域套接字文件 | | | `port` | 3306 | 监听端口 | | **内存管理** | `innodb_buffer_pool_size` | 物理内存的70-80%| InnoDB缓冲池大小 | | | `key_buffer_size` | 64M | MyISAM索引缓存 | | | `query_cache_size` | 0 | 查询缓存(8.0已移除) | | **连接控制** | `max_connections` | 500 | 最大并发连接数 | | | `thread_cache_size` | 50 | 线程缓存数量 | | | `wait_timeout` | 300 | 非交互连接超时(秒) | | **日志管理** | `log_error` | /var/log/mysql/error.log | 错误日志路径 | | | `slow_query_log` | ON | 启用慢查询日志 | | | `binlog_format` | ROW | 二进制日志格式 | #### 2. `[client]` - 客户端通用配置 ```ini [client] port = 3306 socket = /tmp/mysql.sock default-character-set = utf8mb4 ``` #### 3. `[mysql]` - 命令行客户端专属 ```ini [mysql] auto-rehash # 启用TAB补全 prompt = "\\u@\\h:\\d> " # 自定义提示符 ``` #### 4. `[mysqld_safe]` - 服务安全配置 ```ini [mysqld_safe] log-error = /var/log/mysql/error.log pid-file = /var/run/mysql/mysqld.pid ``` *** ### 三、InnoDB 引擎专项优化 ```ini [mysqld] # 缓冲池配置 innodb_buffer_pool_instances = 8 # 多实例提高并发 innodb_buffer_pool_chunk_size = 128M # 日志系统 innodb_log_file_size = 2G # 重做日志大小(太大恢复慢) innodb_log_files_in_group = 2 # 日志文件数量 innodb_flush_log_at_trx_commit = 1 # ACID保障级别 # 写性能优化 innodb_flush_method = O_DIRECT # 直接IO避免双缓冲 innodb_io_capacity = 10000 # SSD建议值 innodb_thread_concurrency = 0 # 0=无限制 ``` *** ### 四、安全加固配置 ```ini [mysqld] # 访问控制 bind-address = 10.0.0.2 # 限制监听IP skip_name_resolve = ON # 禁用DNS解析 # 权限限制 local_infile = OFF # 禁止本地文件加载 secure_file_priv = /mysql-files # 文件导入导出目录 # 密码策略 validate_password.policy = MEDIUM # 密码强度策略 default_password_lifetime = 90 # 密码有效期(天) ``` *** ### 五、性能监控与诊断 ```ini [mysqld] # 性能监控 performance_schema = ON # 性能数据收集 innodb_monitor_enable = all # InnoDB监控 # 诊断日志 log_queries_not_using_indexes = ON # 记录无索引查询 long_query_time = 1 # 慢查询阈值(秒) log_throttle_queries_not_using_indexes = 100 # 限流日志量 ``` *** ### 六、配置最佳实践 #### 1. 内存分配策略 ```ini # 计算公式: # 总内存 = innodb_buffer_pool_size # + key_buffer_size # + (max_connections * (sort_buffer_size + read_buffer_size)) # + 2GB系统预留 ``` #### 2. 版本兼容性处理 ```ini # 解决8.0默认认证插件问题 default_authentication_plugin = mysql_native_password # 处理5.7到8.0的SQL模式变更 sql_mode = ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION ``` #### 3. 字符集统一配置 ```ini [mysqld] character_set_server = utf8mb4 collation_server = utf8mb4_unicode_ci ``` *** ### 七、配置文件调试技巧 1. **配置有效性检查**: ```bash mysqld --verbose --help | grep -A1 'Default options' ``` 2. **配置变更热加载**: ```sql -- 动态修改全局变量 SET GLOBAL max_connections = 1000; -- 持久化到配置文件 SET PERSIST max_connections = 1000; ``` 3. **配置状态验证**: ```sql -- 查看当前生效配置 SHOW VARIABLES LIKE '%buffer%'; -- 查看运行状态 SHOW STATUS LIKE 'Innodb_buffer_pool%'; ``` *** ### 八、常见配置误区 1. **过度分配内存**: ```ini # 错误示范:分配超过物理内存 innodb_buffer_pool_size = 32G # 在16G服务器上 ``` 2. **日志配置冲突**: ```ini # 错误:同时启用多个慢查询日志 slow_query_log = 1 slow_query_log_file = /log/slow.log log_output = TABLE, FILE # 应只选一种输出 ``` 3. **过时的参数**: ```ini # MySQL 8.0已移除的参数 query_cache_size = 64M # 在8.0中无效 ``` *** ### 九、配置管理进阶 1. **多实例配置**: ```ini [mysqld@replica01] datadir = /var/lib/mysql-replica port = 3307 socket = /tmp/mysql-replica.sock ``` 2. **环境差异化配置**: ```ini !include /etc/mysql/env.conf # 包含环境变量 [mysqld] innodb_buffer_pool_size = ${BUFFER_POOL_SIZE} ``` 3. **配置版本控制**: ```bash # 使用git管理配置变更 /etc/mysql/ ├── my.cnf └── conf.d/ ├── production.cnf └── development.cnf ``` 通过深度理解 `my.cnf` 的每个配置项,可以显著提升数据库的性能、稳定性和安全性。建议每次修改后使用 `mysqladmin variables` 验证实际生效值,并通过监控系统观察变更影响。 --- --- url: /src/technology/dateblog/2025/07/20250702-mysql中的事务和锁.md --- # MySQL中的事务和锁 * 一下基于innodb引擎 ## 一、MySQL的锁 ### 占有模式 #### 共享锁 (S Lock / Share Lock): * 目的: 用于读取操作。多个事务可以同时持有同一数据资源(如一行记录)的共享锁 * 兼容性: S 锁之间是兼容的。一个事务持有 S 锁时,不影响其他事务也来获取同一资源的 S 锁。 * 阻塞: * 如果一个事务持有资源的 S 锁,其他事务请求该资源的 X 锁会被阻塞,直到 S 锁释放。 * 如果一个事务持有资源的 X 锁,其他事务请求该资源的 S 锁会被阻塞,直到 X 锁释放。 #### 排他锁 (X Lock / Exclusive Lock): * 目的: 用于写入操作(INSERT, UPDATE, DELETE)。保证一个事务在修改数据时,其他事务不能读取(脏读在 RC/RR 下由 MVCC 避免,但锁层面会阻塞 S 锁)或修改同一数据。 * 兼容性: X 锁与任何其他锁(无论是 S 锁还是另一个 X 锁)都是不兼容的。 * 阻塞: * 如果一个事务持有资源的 X 锁,其他事务请求该资源的 S 锁或 X 锁都会被阻塞,直到 X 锁释放。 * 如果一个事务请求资源的 X 锁,而该资源已被其他事务加了 S 锁或 X 锁,该请求会被阻塞,直到所有冲突锁释放。 **兼容性矩阵 (简化版)** | 当前锁状态 \ 请求锁类型 | 请求 S 锁 | 请求 X 锁 | |---------------|:------:|:------:| | 无锁 | 允许 | 允许 | | 持有 S 锁 | 允许 | 阻塞 | | 持有 X 锁 | 阻塞 | 阻塞 | * S 锁兼容 S 锁: ✅ (允许多个读) * S 锁不兼容 X 锁: ❌ (读阻塞写,写阻塞读) * X 锁不兼容 S 锁: ❌ (写阻塞读) * X 锁不兼容 X 锁: ❌ (写阻塞写) ### 加锁示例 ```sql SELECT * FROM table WHERE id = 3 LOCK IN SHARE MODE; ``` * 加锁类型: `共享锁 (S Lock)`。 * 加锁范围: 在 READ COMMITTED 或 REPEATABLE READ 隔离级别下,如果 id=3 是主键或唯一索引,通常只锁住 id=3 这一行。 如果 id 是非唯一索引或没有索引,情况会更复杂(可能锁住满足条件的行,甚至锁范围或升级) * 目的:显式地告诉数据库,这个 SELECT 语句需要读取最新的、已提交的数据版本,并且在读取期间,不允许其他事务修改这行数据(即阻塞其他事务对该行加 X 锁)。其他事务仍然可以同时对该行加 S 锁进行读取。 * 与普通 SELECT (快照读) 的区别: 普通 SELECT 利用 MVCC 读取历史快照(在 RR 下是事务开始时的快照,在 RC 下是语句开始时的快照),不需要加任何行锁。LOCK IN SHARE MODE 则是当前读,它读取最新已提交版本并加 S 锁。 ```sql SELECT * FROM table WHERE id = 3 FOR UPDATE; ``` * 加锁类型: `排他锁 (X Lock)`。 * 加锁范围: 同样,如果 id=3 是主键或唯一索引,通常只锁住 id=3 这一行。非唯一索引或无索引情况复杂。 * 目的: 显式地告诉数据库,这个 SELECT 语句需要读取最新的、已提交的数据版本,并且在读取期间,不允许其他事务读取(FOR UPDATE/SHARE MODE)或修改这行数据(即阻塞其他事务对该行加 S 锁 和 X 锁)。这是为了后续可能要基于这个查询结果进行更新操作做准备。 * 与普通 SELECT (快照读) 的区别: 同样是当前读,但加的是更强的 X 锁。 ```sql INSERT INTO table (id, name) VALUES (3, '3'); ``` * 加锁类型: `排他锁 (X Lock)`。 * 加锁范围: InnoDB 在执行 INSERT 时,会对新插入的行加上排他锁 (X Lock)。这个锁是在行被实际插入到索引中时加上的。 * 潜在冲突: 如果另一个事务试图在相同的键值(如主键 id=3)上执行 INSERT、UPDATE 或 DELETE,或者执行 SELECT ... FOR UPDATE / SELECT ... LOCK IN SHARE MODE 定位到这一行(如果它已存在), 就会发生锁冲突。INSERT 操作本身还会检查唯一键约束,这可能导致等待其他事务释放 S 锁(例如在检查唯一约束时需要读取索引记录)。 ```sql UPDATE table SET name= '3' WHERE id = 3; ``` * 加锁类型: `排他锁 (X Lock)。` * 加锁过程:与 UPDATE 极其相似 * 定位阶段 (读取): 找到要删除的行(id=3),尝试对其加 X 锁(当前读)。 * 删除阶段 (写入): 在 X 锁保护下,标记该行为已删除(产生删除标记),旧版本进入 Undo Log。物理删除可能延迟发生(Purge 线程)。 * 加锁范围: 被删除的行上持有 X 锁。同样,非唯一索引或无索引可能导致范围锁。 ```sql DELETE FROM table WHERE id = 3; ``` * 加锁类型: `排他锁 (X Lock)。` * 加锁过程: 与 UPDATE 极其相似。 * 位阶段 (读取): 找到要删除的行(id=3),尝试对其加 X 锁(当前读) * 删除阶段 (写入): 在 X 锁保护下,标记该行为已删除(产生删除标记),旧版本进入 Undo Log。物理删除可能延迟发生(Purge 线程)。 * 加锁范围: 被删除的行上持有 X 锁。同样,非唯一索引或无索引可能导致范围锁。 ### 锁的类型 #### 1. 行锁 (Record Lock) **作用**:锁定单行记录,防止其他事务修改该行\ **类型**: * 共享锁(S Lock):允许多个事务同时读取同一行 * 排他锁(X Lock):只允许一个事务修改行 **示例**: ```sql -- 共享锁:多个事务可同时读取 SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE; -- 排他锁:阻止其他事务读写 SELECT * FROM orders WHERE order_id = 100 FOR UPDATE; ``` **关键特性**: 1. 依赖索引生效 * 使用主键索引:精准锁定单行 ✅ ```sql UPDATE products SET stock=10 WHERE id=5; -- id是主键 ``` * 无索引:退化为表锁 ❌ ```sql UPDATE users SET status=0 WHERE phone='13800138000'; -- phone无索引 ``` 2. 锁升级机制 * 当SQL扫描大量行时,可能自动升级为表锁 ```sql -- 可能触发锁升级 UPDATE logs SET archived=1 WHERE create_time < '2023-01-01'; ``` 3. 非主键索引的锁定 * 锁定二级索引 + 对应主键 ```mermaid graph LR A[二级索引 age=25] --> B[主键 id=101] C[事务锁定 age=25] --> D[同时锁定 id=101] ``` *** #### 2. 间隙锁 (Gap Lock) **作用**:锁定索引值之间的"空隙",防止幻读\ **范围**:开区间 `(10, 20)` 不包括边界值 **示例场景**: ```sql -- 表:products (price: 10, 20, 30) -- 事务A(RR隔离级别) SELECT * FROM products WHERE price BETWEEN 15 AND 25 FOR UPDATE; -- 锁定(10,20)和(20,30)间隙 -- 事务B尝试插入(被阻塞) INSERT INTO products (price) VALUES (18); -- 在(10,20)间隙内 ``` **关键特性**: 1. 只在RR/Serializable级别生效 2. 防止范围内插入新数据 3. 兼容规则: * 不同间隙:互不影响 ✅ ```sql -- 事务A锁定(10,20) -- 事务B可插入price=5(在(-∞,10)间隙) ``` * 相同间隙:互斥 ❌ ```sql -- 事务A锁定(10,20) -- 事务B无法插入price=15 ``` *** #### 3. 临键锁 (Next-Key Lock) **作用**:行锁 + 间隙锁组合,InnoDB默认锁机制\ **范围**:左开右闭区间 `(10, 20]` **示例**: ```sql -- 表:students (score: 60,70,80,90) -- 事务A SELECT * FROM students WHERE score > 75 AND score < 85 FOR UPDATE; -- 锁定(70,80]和(80,90]区间 -- 阻塞操作: UPDATE students SET grade='B' WHERE score=80; -- 行记录 INSERT INTO students (score) VALUES (78); -- 间隙插入 ``` **锁范围图示**: ```mermaid graph LR A[60] --> B(70] B --> C(80] C --> D(90] style B stroke:#f66,stroke-width:2px style C stroke:#f66,stroke-width:2px 锁定区域:::locked classDef locked fill:#f9f,stroke:#333,stroke-width:1px ``` *** #### 4. 表锁 (Table Lock) **作用**:锁定整张表,粒度最粗的锁\ **使用场景**: * 全表更新操作 * 备份操作 * 无索引的写操作 **显式加锁**: ```sql LOCK TABLES orders WRITE; -- 排他锁 LOCK TABLES products READ; -- 共享锁 UNLOCK TABLES; -- 释放锁 ``` **隐式加锁**: ```sql -- 无索引更新触发表锁 UPDATE config SET value=1 WHERE description LIKE '%debug%'; ``` **性能影响**: * 并发性大幅降低 ⚠️ * 仅推荐在维护操作中使用 *** #### 5. 意向锁 (Intention Lock) **作用**:表级锁,快速判断表中是否有行锁\ **类型**: * IS(意向共享锁):准备加行级S锁 * IX(意向排他锁):准备加行级X锁 **工作流程**: 1. 事务加行锁前,先申请表级意向锁 2. 其他事务通过意向锁判断表级操作是否安全 **兼容矩阵**: | | X | IX | S | IS | |----------|-----|-----|-----|-----| | **X** | ❌ | ❌ | ❌ | ❌ | | **IX** | ❌ | ✅ | ❌ | ✅ | | **S** | ❌ | ❌ | ✅ | ✅ | | **IS** | ❌ | ✅ | ✅ | ✅ | **示例**: ```sql -- 事务A SELECT * FROM accounts WHERE id=1 FOR UPDATE; -- 步骤: -- 1. 加表级IX锁 -- 2. 加行级X锁 -- 事务B尝试加表锁 LOCK TABLES accounts WRITE; -- 检测到IX锁 → 阻塞等待 ``` *** #### 6. 死锁 (Deadlock) **本质**:事务间循环等待资源\ **发生条件**: 1. 互斥访问 2. 持有并等待 3. 不可剥夺 4. 循环等待 **经典场景**: ```mermaid sequenceDiagram participant T1 as 事务A participant T2 as 事务B T1->>T1: 锁定行R1 (UPDATE users SET... WHERE id=1) T2->>T2: 锁定行R2 (UPDATE orders SET... WHERE id=100) T1->>T2: 尝试锁定R2 (阻塞) T2->>T1: 尝试锁定R1 (阻塞) ``` **解决方案**: 1. **死锁检测**(默认开启)\ InnoDB自动回滚代价最小的事务 ```sql SHOW ENGINE INNODB STATUS; -- 查看死锁日志 ``` 2. **锁超时** ```sql SET innodb_lock_wait_timeout = 30; -- 设置等待超时(秒) ``` 3. **预防措施**: * 按固定顺序访问资源 ```python # 正确顺序:先更新users表,再更新orders表 def update_data(): update_user() update_order() ``` * 使用覆盖索引减少锁冲突 * 保持事务短小精悍 *** ### 最佳实践总结 1. **索引优化**:确保WHERE条件使用索引 2. **隔离级别**: * 读多写少:`READ COMMITTED` * 防幻读:`REPEATABLE READ` 3. **锁监控**: ```sql SHOW OPEN TABLES WHERE In_use > 0; -- 查看表锁 SELECT * FROM information_schema.INNODB_LOCKS; -- 查看行锁 ``` 4. **避免长事务**:减少锁持有时间 5. **设计原则**:访问资源的顺序保持一致 > 通过合理使用锁机制,可在保证数据一致性的前提下,显著提升数据库并发性能。实际开发中应结合EXPLAIN分析执行计划,避免意外锁升级。 * 显式加锁读: LOCK IN SHARE MODE 加 S 锁 (共享读锁),FOR UPDATE 加 X 锁 (排他写锁)。它们都是当前读,读取最新已提交数据并上锁。 * 隐式加锁写: INSERT、UPDATE、DELETE 在执行过程中(主要是定位和修改目标行时)隐式地对受影响的行加 X 锁。 * 兼容性是核心: * S 锁只和 S 锁兼容 -> 允许多个事务并发读。 * X 锁和任何锁都不兼容 -> 保证写操作的独占性,防止脏写、不可重复读(在锁层面)和更新丢失。 * 隔离级别影响: 上面描述主要基于 READ COMMITTED 和 REPEATABLE READ。在 REPEATABLE READ 下,InnoDB 还会使用间隙锁 (Gap Locks) 和 临键锁 (Next-Key Locks) 来防止幻读,这会改变锁的范围(不仅锁记录,还锁记录之间的“间隙”),影响兼容性范围。在 READ COMMITTED 下,通常只有记录锁(行锁),没有间隙锁(除了外键检查和唯一键冲突检查等特殊情况)。 * 索引影响: 索引类型(主键、唯一、非唯一、无索引)会极大影响锁的范围(行锁 vs 范围锁/表锁)。使用合适的索引是减少锁冲突、提高并发性的关键。 理解 S 锁和 X 锁的占有模式、兼容性以及不同 SQL 语句如何获取这些锁,是分析和解决数据库并发问题(如死锁、锁等待超时)的基础。 ## 二、MySQL的事务 MySQL的事务是一组操作序列,这些操作要么全部执行,要么全部不执行,是数据库管理系统执行过程中的一个逻辑单位。通过事务,MySQL能够保证即使在系统或其他故障的情况下,数据库也不会处于不一致的状态。 ### 事务的ACID特性 * 原子性(Atomicity) - 事务中的所有操作要么全部提交成功,要么全部失败回滚,对于一个事务来说,不可能只执行其中的一部分操作。 * 一致性(Consistency) - 事务必须使数据库从一个一致性状态转换到另一个一致性状态。 * 隔离性(Isolation) - 并发执行的事务之间不能互相干扰,多个并发事务之间要相互隔离。 * 持久性(Durability) - 一旦事务提交,则其所做的修改将永久保存在数据库中,即使系统崩溃也不会丢失。 ### 事务的实现原理 MySQL中,事务的实现依赖于存储引擎,不同的存储引擎对事务的支持不同。以支持事务的InnoDB存储引擎为例,其事务的实现原理包括: * Undo日志:保证事务的原子性。当事务进行中发生错误或者用户执行回滚操作时,系统可以利用Undo日志撤销已经进行的修改。 * Redo日志:保证事务的持久性。即使数据库发生故障,已经提交的事务也不会丢失,因为这些修改操作会记录在Redo日志中。 * 锁机制:保证事务的隔离性。通过对数据加锁,防止多个事务并发执行时互相干扰。 * MVCC(多版本并发控制):也是为了保证隔离性,在InnoDB中,通过保存数据的多个版本,来允许读写操作并发进行,提高性能。 隔离级别是数据库管理系统处理并发事务时的重要机制,它定义了事务间的"可见性规则"。MySQL支持四种标准隔离级别,控制着事务间的相互影响程度: ```mermaid graph LR A[隔离级别] --> B[读未提交 Read Uncommitted] A --> C[读已提交 Read Committed] A --> D[可重复读 Repeatable Read] A --> E[串行化 Serializable] 并发性[并发性] -->|高| B 并发性 -->|中高| C 并发性 -->|中| D 并发性 -->|低| E 数据一致性 -->|低| B 数据一致性 -->|中| C 数据一致性 -->|高| D 数据一致性 -->|最高| E ``` ## 1. 读未提交 (Read Uncommitted) **核心特点** * **能看到其他事务未提交的修改**("脏读") * 隔离性最低,并发性最高 * 实际开发中极少使用 **问题示例:脏读** ```sql -- 事务A(转账操作) START TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 余额减100 UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 余额加100 -- 事务B(查询余额) SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; SELECT balance FROM accounts WHERE id = 1; -- 看到-100后的余额(未提交) ``` > 此时事务A如果回滚,事务B读到的就是无效的"脏数据" **适用场景** * 数据准确性要求极低的场景 * 只读的统计报表(可容忍暂时不一致) * MySQL默认不采用此级别 ## 2. 读已提交 (Read Committed) **核心特点** * **只能看到已提交的数据**(解决脏读) * 可能发生不可重复读 * Oracle默认级别,MySQL常用级别 **问题示例:不可重复读** ```sql -- 事务A(两次查询) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT * FROM products WHERE id = 5; -- 第一次查询:库存=10 -- 事务B更新并提交 START TRANSACTION; UPDATE products SET stock=5 WHERE id=5; COMMIT; SELECT * FROM products WHERE id = 5; -- 第二次查询:库存=5 ``` > 同一事务内两次读取结果不一致(库存从10→5) **MVCC实现机制** ```mermaid sequenceDiagram participant T as 事务 participant V1 as 版本1(stock=10) participant V2 as 版本2(stock=5) T->>V1: 事务开始(TID=100) T->>V1: 第一次读取(使用TID=100) Note right of V2: 事务B提交新版本 T->>V2: 第二次读取(新ReadView) Note left of T: 看到新提交的stock=5 ``` **适用场景** * 多数Web应用(用户注册、内容管理) * 需要看到最新提交数据的系统 * 可接受同一事务内查询结果变化 ## 3. 可重复读 (Repeatable Read) **核心特点** * **同一事务内多次读取结果一致**(解决不可重复读) * InnoDB默认隔离级别 * 通过间隙锁防止幻读 **防止幻读示例** ```sql -- 事务A(范围查询) SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM orders WHERE amount > 1000; -- 返回3条记录 -- 事务B插入新订单 START TRANSACTION; INSERT INTO orders(amount) VALUES (1500); -- 被间隙锁阻塞 COMMIT; SELECT * FROM orders WHERE amount > 1000; -- 仍返回3条记录 COMMIT; -- 提交后新订单才可见 ``` **MVCC+锁机制** ```mermaid graph TB A[事务开始] --> B[创建ReadView] B --> C[第一次查询] C --> D[快照冻结] D --> E[后续查询使用相同ReadView] F[写操作] --> G[间隙锁锁定范围] G --> H[阻止新数据插入] ``` **幻读与间隙锁** * **幻读**:同一事务内相同查询返回不同行数 * **间隙锁解决方案**: ```sql -- 表:orders (id: 101,102,105) SELECT * FROM orders WHERE id BETWEEN 100 AND 200 FOR UPDATE; -- 锁定范围:(负无穷,101), (101,102), (102,105), (105,正无穷) -- 阻止在间隙中插入新记录(如id=103) ``` **适用场景** * 金融交易系统(账户余额计算) * 库存管理系统 * 需要事务内数据一致的场景 ## 4. 串行化 (Serializable) **核心特点** * **完全隔离事务**(最高隔离级别) * 所有操作加锁,类似单线程执行 * 彻底解决幻读,但性能最低 **工作机制** ```sql -- 事务A SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION; SELECT * FROM accounts; -- 自动加共享锁 -- 事务B尝试写入 START TRANSACTION; UPDATE accounts SET balance=0; -- 被阻塞直到事务A结束 ``` **锁机制对比** | 操作 | 可重复读 | 串行化 | |--------------|---------------------|----------------------| | SELECT | 快照读(无锁) | 加共享锁 | | SELECT...FOR UPDATE| 加临键锁 | 加排他锁 | | INSERT | 可能被间隙锁阻塞 | 一定被阻塞 | **适用场景** * 银行核心系统(账户迁移) * 对数据一致性要求极高的场景 * 可接受低并发的系统 ## 隔离级别对比总结 | 特性 | 读未提交 | 读已提交 | 可重复读 | 串行化 | |--------------|---------------|----------------|------------------|----------------| | **脏读** | 可能发生 | 不可能 | 不可能 | 不可能 | | **不可重复读**| 可能发生 | 可能发生 | 不可能 | 不可能 | | **幻读** | 可能发生 | 可能发生 | 可能(但被阻止) | 不可能 | | **性能** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐ | | **加锁强度** | 最弱 | 中等 | 较强 | 最强 | | **默认级别** | - | Oracle | MySQL InnoDB | - | ## 如何选择隔离级别? 1. **优先使用默认级别(可重复读)** ```sql -- 查看当前隔离级别 SELECT @@transaction_isolation; ``` 2. **需要最新数据 → 读已提交** ```sql SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; ``` 3. **需要绝对一致 → 串行化** ```sql SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; ``` 4. **性能优化建议**: * 避免长事务(减少锁持有时间) * 使用覆盖索引(减少锁范围) * 精确查询条件(避免全表扫描) > 实践提示:95%场景使用默认级别即可,特殊需求再调整。金融系统建议使用可重复读+应用层校验双重保障。 通过合理选择隔离级别,可在数据一致性和系统性能间取得最佳平衡。 --- --- url: /src/technology/dateblog/2025/06/20250603-nginx设置前端SPA应用缓存控制.md --- # 优化Nginx配置:为SPA应用设置HTML不缓存与高效部署 \#html不缓存#hash文件名静态文件长期缓存 在部署Vue、React等单页面应用(SPA)时,合理的Nginx配置至关重要。本文将详细介绍如何为SPA应用配置Nginx,特别是实现HTML文件不缓存的关键策略。 ## 为什么SPA需要特殊缓存配置? SPA应用的核心特点: * 只有一个入口文件(通常是index.html) * 静态资源(JS/CSS/图片)使用哈希文件名(如`chunk-fc7d1e68.05334c7d.js`) * 前端路由处理页面导航 这种架构带来两个关键需求: 1. **index.html必须不缓存** - 确保用户总是获取最新版本 2. **静态资源应长期缓存** - 哈希文件名保证内容变化时URL会改变 ## Nginx配置解决方案 ```nginx # SPA应用入口 - /crm路径 location /crm { # 物理路径映射(注意:结尾必须有斜杠) alias /usr/local/nginx/html/crm/dist/; # 开启预压缩文件支持(需提前生成.gz文件) gzip_static on; # 目录请求默认返回index.html index index.html; # 支持Vue Router的history模式 try_files $uri $uri/ /crm/index.html; # 静态资源缓存设置 (CSS/JS/字体/图片) location ~* \.(?:css|js|woff2?|ttf|eot|svg|gif|jpe?g|png|ico|webp)$ { # 设置1年长期缓存(推荐) add_header Cache-Control "public, max-age=31536000, immutable"; # 可选:添加ETag验证(确保资源更新时能及时失效) etag on; } # 针对HTML文件设置不缓存(保持不变) location ~* \.html?$ { add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; add_header Expires 0; } } ``` ## 关键配置解析 ### 1. HTML缓存控制策略 ```nginx location ~* \.html?$ { add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; add_header Expires 0; } ``` 这三个响应头协同工作,确保浏览器绝不缓存HTML文件: * `Cache-Control: no-cache, no-store, must-revalidate` - 现代浏览器的严格缓存控制 * `Pragma: no-cache` - 兼容旧版HTTP/1.0 * `Expires: 0` - 设置立即过期 ### 2. SPA路由支持 ```nginx try_files $uri $uri/ /crm/index.html; ``` 这是支持前端路由的关键配置: 1. 先尝试匹配请求的文件($uri) 2. 再尝试匹配目录($uri/) 3. 都不存在时返回index.html,由前端路由处理 ## 验证配置是否生效 检查HTML文件的响应头: 观察每次请求是否都重新请求(没有走本地缓存): ## 常见问题解决 **Q:配置后HTML仍然被缓存?**\ A:检查以下可能原因: 1. 浏览器强缓存(测试时开启"禁用缓存") 2. CDN或代理服务器缓存(清除CDN缓存) 3. 配置未重载(执行`nginx -s reload`) **Q:如何只针对特定SPA设置?**\ A:使用嵌套location块: ```nginx location /crm { # ...其他配置 # 仅对/crm下的HTML禁用缓存 location ~* \.html?$ { add_header Cache-Control "no-cache, no-store, must-revalidate"; # ...其他头 } } ``` ## 总结 合理的Nginx配置是SPA应用高性能运行的基础。通过: 1. 严格禁用HTML缓存 2. 长期缓存静态资源 3. 支持前端路由 4. 启用压缩优化 这些配置组合既能保证用户总是获取最新版本的应用,又能充分利用缓存提升加载速度。根据本文指南配置你的Nginx服务器,将为你的SPA应用提供稳定高效的运行环境。 --- --- url: /src/technology/dateblog/2025/05/20250507-nodejs-获取指定文件夹下所有文件及文件夹.md --- # Nodejs 获取指定文件夹下所有文件及文件夹 ## 文件递归扁平化输出 ### 代码 ```js const fs = require('fs').promises; const path = require('path'); // 递归获取文件夹下所有文件 async function flatFiles(dirPath) { try { const filesList = []; await readFile(dirPath, filesList); return filesList; } catch (error) { console.error('Error reading directory:', error); throw error; } } // 遍历读取文件 async function readFile(currentPath, filesList) { const files = await fs.readdir(currentPath); for (const file of files) { const filePath = path.join(currentPath, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) { await readFile(filePath, filesList); } else { // 创建一个对象保存信息 const obj = {}; obj.size = stats.size; // 文件大小,以字节为单位 obj.name = file; // 文件名 obj.path = filePath; // 文件绝对路径 filesList.push(obj); } } } const dirPath = 'E:/.temp/pdf/.idea'; (async () => { try { const list = await flatFiles(dirPath); console.log(JSON.stringify(list, null, 2)); } catch (error) { console.error('Failed to get files and folders:', error); } })(); ``` ### 用例结果 ```json [ { "size": 190, "name": ".gitignore", "path": "E:/.temp/pdf/.idea/.gitignore" }, { "size": 0, "name": "1.txt", "path": "E:/.temp/pdf/.idea/a/1.txt" }, { "size": 265, "name": "modules.xml", "path": "E:/.temp/pdf/.idea/modules.xml" }, { "size": 330, "name": "pdf.iml", "path": "E:/.temp/pdf/.idea/pdf.iml" }, { "size": 2912, "name": "workspace.xml", "path": "E:/.temp/pdf/.idea/workspace.xml" } ] ``` ## 文件及文件夹以树状结构输出 ### 代码 ```js const fs = require('fs').promises; const path = require('path'); // 获取文件夹下所有文件和子文件夹 async function treeFolderFiles(dirPath) { try { const items = await fs.readdir(dirPath); const result = []; for (const item of items) { const itemPath = path.join(dirPath, item); const stat = await fs.stat(itemPath); if (stat.isDirectory()) { let data = { type: 'folder', name: item, children: [] }; let children = await treeFolderFiles(itemPath); if (children.length > 0) { data.children = children; } result.push(data); } else { result.push({ type: 'file', name: item, path: itemPath, size: stat.size }); } } return result; } catch (error) { console.error(`Error reading directory ${dirPath}:`, error); throw error; } } const dirPath = 'E:/.temp/pdf/.idea'; (async () => { try { const list = await treeFolderFiles(dirPath); console.log(JSON.stringify(list, null, 2)); } catch (error) { console.error('Failed to get files and folders:', error); } })(); ``` ### 用例结果 ```json [ { "type": "file", "name": ".gitignore", "path": "E:/.temp/pdf/.idea/.gitignore", "size": 190 }, { "type": "folder", "name": "a", "children": [ { "type": "file", "name": "1.txt", "path": "E:/.temp/pdf/.idea/a/1.txt", "size": 0 } ] }, { "type": "file", "name": "modules.xml", "path": "E:/.temp/pdf/.idea/modules.xml", "size": 265 }, { "type": "file", "name": "pdf.iml", "path": "E:/.temp/pdf/.idea/pdf.iml", "size": 330 }, { "type": "file", "name": "workspace.xml", "path": "E:/.temp/pdf/.idea/workspace.xml", "size": 2912 } ] ``` --- --- url: /src/technology/dateblog/2025/04/20250418-nodejs操作mysql-mysql2-中文文档.md --- # nodejs操作mysql mysql2 中文文档 node操作mysql [地址](https://sidorares.github.io/node-mysql2/zh-CN/docs) --- --- url: /src/technology/dateblog/2025/04/20250420-node发送邮件库nodemailer简单使用.md --- # node发送邮件库nodemailer简单使用 [npm地址](https://www.npmjs.com/package/nodemailer) ## 安装 ```shell npm install nodemailer --save ``` ## 示例 ```js const nodemailer = require('nodemailer'); let transporter = nodemailer.createTransport({ // host: 'smtp.qq.com', service: 'qq', // 使用了内置传输发送邮件 查看支持列表:https://nodemailer.com/smtp/well-known/ port: 465, // SMTP 端口 secureConnection: true, // 使用了 SSL auth: { user: 'xxxxxx@qq.com', // 这里密码不是qq密码,是你设置的smtp授权码 pass: 'xxxxxx', } }); let mailOptions = { from: '"Shang" ', // sender address to: 'xxxxxxxx@163.com', // list of receivers subject: 'Hello', // Subject line // 发送text或者html格式 // text: 'Hello world?', // plain text body html: 'Hello world?' // html body }; // send mail with defined transport object transporter.sendMail(mailOptions, (error, info) => { if (error) { return console.log(error); } console.log('Message sent: %s', info.messageId); // Message sent: <04ec7731-cc68-1ef6-303c-61b0f796b78f@qq.com> }); ``` --- --- url: /src/technology/dateblog/2025/07/20250701-npm镜像源设置.md --- # npm镜像源设置 \#npm镜像源#npm国内镜像设置 * 官方镜像:https://registry.npmjs.org/ * 淘宝镜像:https://registry.npmmirror.com ## 设置npm镜像 ```shell npm config set registry https://registry.npmmirror.com ``` ## 查看当前镜像源 ```shell npm get registry ``` --- --- url: /src/technology/dateblog/2026/03/20260304-ollama指南.md --- Ollama 是一个强大的本地大模型部署工具,它通过简洁的命令行和丰富的 API,让开发者能在个人电脑上轻松运行和管理前沿的开源模型。本文将带你全面了解 Ollama 的核心用法,从基础的客户端命令、对话指令,到进阶的 API 集成与性能优化。 # Ollama 完全指南:从命令行到API的本地大模型部署实战 ## Ollama 简介 Ollama 是一个开源的本地大模型部署工具,旨在简化大型语言模型(LLM)的运行和管理。通过简单命令,用户可以在消费级设备上快速启动和运行开源模型(如 Llama、DeepSeek 等),无需复杂配置。它提供 OpenAI 兼容的 API,支持 GPU 加速,并允许自定义模型开发。 ## 核心命令行操作 ### 模型管理高频命令 运行 `ollama help` 可查看所有命令,以下是高频命令总结: | 命令 | 作用描述 | |------|----------| | `ollama serve` | 启动 Ollama 服务(后台运行) | | `ollama create` | 通过 Modelfile 创建自定义模型 | | `ollama run <模型名>` | 运行指定模型(如 `ollama run llama3 --gpu`) | | `ollama list` | 列出所有已下载模型 | | `ollama ps` | 查看正在运行的模型 | | `ollama rm <模型名>` | 删除指定模型 | | `ollama pull <模型名>` | 从注册表拉取模型(如 `ollama pull deepseek-r1:70b`) | | `ollama stop <模型名>` | 停止正在运行的模型 | | `ollama show <模型名>` | 显示模型详细信息 | | `ollama cp <源> <目标>` | 复制模型创建别名 | ### 模型下载与运行 下载并运行模型非常简单,一条命令即可完成: ```bash ollama run llama3.2 ``` 运行命令后会自动下载模型文件(体积较大,请耐心等待,取决于网络速度和模型大小)。下载完成后,终端会显示输入提示,即可直接与模型进行对话。 其他常见模型运行命令: * DeepSeek-R1: `ollama run deepseek-r1` * Llama 3.2 Vision: `ollama run llama3.2-vision` * Phi 4: `ollama run phi4` * Mistral: `ollama run mistral` * Code Llama: `ollama run codellama` ### 对话交互技巧 #### 基础对话 在终端直接和模型互动,例如输入: ``` 你是谁? ``` 模型会即时响应。每次对话结束,可通过 `Ctrl+D` 退出,或直接关闭窗口。 #### 多行输入 使用 `"""` 包装文本输入多行内容: ``` >>> """你好, ... 帮我写一段Python求和代码 ... """ ``` #### 多模态模型使用 如果需要让模型分析图片(多模态模型),直接在命令里带上图片路径: ```bash ollama run gemma3 "这张图片里有什么?/Users/xxx/Desktop/test.png" ``` ## API 调用详解 Ollama 内置 HTTP RESTful API,默认监听在 `localhost:11434`。你可以通过 Apifox 等工具或直接用代码访问。 ### 核心 API 端点 | API 端点 | 方法 | 功能描述 | |----------|------|----------| | `/api/generate` | POST | 生成文本响应 | | `/api/chat` | POST | 生成聊天对话中的下一条消息 | | `/api/create` | POST | 创建模型 | | `/api/tags` | GET | 列出本地可用的模型 | | `/api/show` | POST | 显示模型的详细信息 | | `/api/copy` | POST | 复制模型 | | `/api/delete` | DELETE | 删除模型 | | `/api/pull` | POST | 从Ollama库下载模型 | | `/api/push` | POST | 将模型上传到Ollama库 | | `/api/embed` | POST | 生成文本的嵌入向量 | | `/api/ps` | GET | 列出当前加载到内存中的模型 | | `/api/version` | GET | 获取Ollama版本信息 | ### 生成文本响应 使用 `/api/generate` 端点根据给定的提示生成文本响应: ```bash curl http://localhost:11434/api/generate -d '{ "model": "llama3.2", "prompt": "Why is the sky blue?", "stream": false }' ``` **请求参数说明**: * `model`: 需调用的已下载模型名 * `prompt`: 输入的提示文本,即你想问的问题 * `stream`: 是否流式输出(`true` 或 `false`) **响应示例**: ```json { "model": "llama3.2", "response": "The sky is blue because...", "done": true } ``` ### 聊天对话 使用 `/api/chat` 端点生成聊天对话中的下一条消息,支持对话历史: ```bash curl http://localhost:11434/api/chat -d '{ "model": "llama3.2", "messages": [ { "role": "user", "content": "why is the sky blue?" } ] }' ``` **响应示例**: ```json { "message": { "role": "assistant", "content": "Hello! How are you today?" } } ``` ### 模型管理 API #### 列出本地模型 ```bash curl http://localhost:11434/api/tags ``` 响应示例: ```json { "models": [ { "name": "llama3:latest", "size": 3825819519 } ] } ``` #### 创建模型 ```bash curl http://localhost:11434/api/create -d '{ "model": "mario", "from": "llama3.2", "system": "You are Mario" }' ``` ### 使用 Python 调用 API Ollama 提供了原生的 Python 客户端库,方便集成: ```python import ollama response = ollama.chat( model='llama3.2', messages=[ {'role': 'user', 'content': 'Explain Python decorators in simple terms.'} ] ) print(response['message']['content']) ``` 安装 Python 库\[citation-5]: ```bash pip install ollama ``` ### 使用 JavaScript 调用 API Ollama 也提供了 JavaScript 库: ```javascript import ollama from "ollama"; const response = await ollama.chat({ model: "llama3.2", messages: [{ role: "user", content: "Why is the sky blue?" }], }); console.log(response.message.content); ``` 安装 JavaScript 库: ```bash npm install ollama ``` ## 高级特性 ### 自定义模型创建 通过 Modelfile 可以创建自定义模型,就像 Dockerfile 一样定义模型行为。 #### Modelfile 示例: ``` FROM mistral SYSTEM "You are Dev_assistant, a witty assistant who always replies with puns but is also extremely helpful." PARAMETER temperature 0.8 ``` **Modelfile 指令说明**: | 指令 | 描述 | 示例 | |------|------|------| | `FROM` | 基础模型 | `FROM mistral` | | `SYSTEM` | 系统提示词 | `You are a helpful assistant` | | `PARAMETER` | 修改模型参数 | `PARAMETER temperature 0.8` | | `ADD` | 添加文件 | `ADD config.json /app/config.json` | #### 创建和运行自定义模型: ```bash # 创建模型 ollama create Dev_assistant -f Modelfile # 运行自定义模型 ollama run Dev_assistant ``` ### 上下文长度调优 Context Length(上下文长度)是模型能"记住"的对话/文本内容上限,单位是 token。Ollama 默认是 4096 个 token,但可以根据需要调整。 **设置方法**: ```bash OLLAMA_CONTEXT_LENGTH=64000 ollama serve ``` **验证设置是否生效**:执行 `ollama ps`,查看输出中的 `CONTEXT` 列,数值就是当前生效的上下文长度。 ⚠️ **注意**:调大上下文长度会消耗更多显存,需根据硬件配置合理设置。 ### 模型量化 如果显卡显存不够,可通过"量化"牺牲一点点精度,换更快的运行速度: ```bash # 把FP16格式的gemma3模型量化成q4_K_M(平衡速度和精度) ollama create --quantize q4_K_M my-quant-model -f Modelfile ``` 常用量化级别: * `q8_0`:8位,精度高 * `q4_K_M`:4位,速度快 * `q4_K_S`:4位,超轻量化 ## 实用技巧与最佳实践 ### 模型存储路径优化 默认路径可能占用系统盘空间,可以通过环境变量修改: **Windows**:新建系统变量 `OLLAMA_MODELS`,路径设为 `D:\ollama\models` **Linux/macOS**: ```bash echo 'export OLLAMA_MODELS=/path/to/your/models' >> ~/.bashrc source ~/.bashrc ``` ### 性能监控 使用 `--verbose` 参数监控资源消耗: ```bash ollama run deepseek-r1:70b --verbose ``` 输出示例: ``` total duration: 12m1.056s # 总耗时 load duration: 1.810s # 模型加载时间 eval rate: 2.09 tokens/s # 生成速度 ``` ### 常见问题处理 #### 模型下载卡在 99% ```bash Ctrl+C 取消下载 → 再次运行 `ollama pull <模型名>` # 进度保留,后续速度可能恢复正常 ``` #### 删除无用模型释放空间 ```bash ollama rm 模型名 # 删除指定模型 ollama prune # 清理未引用的文件 ``` ### 安全加固 默认风险:Ollama 默认监听 `0.0.0.0:11434`,可能暴露公网。 解决方案: ```bash # 仅允许本地访问 export OLLAMA_HOST=127.0.0.1:11434 # 或通过环境变量设置 OLLAMA_HOST=127.0.0.1:11434 ollama serve ``` ## 云模型与 Web 搜索 ### 云模型 Ollama 支持云模型,可以在本地运行更大的模型: ```bash ollama run qwen3-coder:480b-cloud ``` 可用的云模型: * `qwen3-coder:480b-cloud` * `gpt-oss:120b-cloud` * `deepseek-v3.1:671b-cloud` ### Web 搜索 API Ollama 提供了 Web 搜索 API,可以增强模型的最新信息获取能力。 **cURL 示例**: ```bash curl https://ollama.com/api/web_search \ --header "Authorization: Bearer $OLLAMA_API_KEY" \ -d '{"query": "what is ollama?"}' ``` **Python 示例**: ```python import ollama response = ollama.web_search("What is Ollama?") print(response) ``` ## 集成 Open WebUI 可以通过 Docker Compose 部署 Open WebUI,获得图形化界面: ```yaml version: '3.8' services: webui: image: ghcr.io/open-webui/open-webui:main container_name: ollama-webui ports: - "3000:8080" volumes: - ./webui-data:/app/backend/data environment: - OLLAMA_BASE_URL=http://127.0.0.1:11434 restart: unless-stopped ``` 访问地址:`http://IP:3000` ## 总结 Ollama 为本地大模型部署提供了简洁而强大的解决方案。通过本文介绍的客户端命令、对话指令和 API 调用方法,你可以快速上手并深入使用 Ollama。无论是基础模型运行、自定义模型创建,还是通过 API 集成到应用中,Ollama 都能满足你的需求。 **硬件建议**: * 7B 模型需至少 8GB 内存 * 13B 模型需 16GB 内存 * 33B 模型需 32GB 内存 **安全第一**:避免将 Ollama 端口暴露公网,定期更新版本。 **模型选择**:根据需求选择,DeepSeek 适合代码生成,Qwen 适合多语言任务。 --- --- url: >- /src/technology/dateblog/2025/05/20250509-python-pptx-是一个用于创建、读取和更新-powerpoint-的-python-库.md --- # [python-pptx](https://python-pptx.readthedocs.io/en/latest/index.html) 是一个用于创建、读取和更新 PowerPoint 的 Python 库 --- --- url: /src/technology/dateblog/2025/10/20251029-redis哨兵集群节点故障与恢复详解.md --- # Redis哨兵集群节点故障与恢复详解 - 以1主2从3哨兵为例 ## 集群架构概述 在1主2从3哨兵的架构中: * **1个主节点**:处理所有写操作和读操作 * **2个从节点**:复制主节点数据,处理读操作 * **3个哨兵节点**:监控Redis实例,实现自动故障转移 ## 哨兵节点故障与恢复 ### 1. 单个哨兵节点故障 **故障影响**: * 集群仍能正常工作 * 剩余2个哨兵仍能达成共识(满足多数原则) * 故障检测和故障转移能力不受影响 **恢复过程**: ```bash # 1. 重启故障哨兵节点 redis-sentinel /path/to/sentinel.conf # 2. 或者直接启动 redis-server /path/to/sentinel.conf --sentinel ``` **哨兵恢复后的行为**: * 自动重新加入哨兵集群 * 从其他哨兵获取最新拓扑信息 * 开始参与监控和投票 ### 2. 多个哨兵节点故障 **场景分析**: * **2个哨兵故障**:剩余1个哨兵无法达成多数,故障转移无法进行 * **3个哨兵全故障**:监控完全失效,但Redis数据服务仍正常运行 **恢复步骤**: ```bash # 逐个恢复哨兵节点 # 第一个恢复的哨兵会尝试选举自己为leader,但需要多数同意 redis-sentinel /path/to/sentinel.conf # 等待多数哨兵恢复后,集群恢复正常监控能力 ``` ## 数据节点故障与恢复 ### 1. 从节点故障 **故障检测**: ```bash # 哨兵日志示例 +sdown slave 127.0.0.1:6380 127.0.0.1 6380 @ mymaster 127.0.0.1 6379 ``` **恢复过程**: ```bash # 自动恢复 1. 从节点重启成功 2. 哨兵检测到节点恢复:-sdown slave 127.0.0.1:6380 3. 从节点重新同步主节点数据 4. 恢复完成:+reboot slave 127.0.0.1:6380 ``` **手动干预**(如果需要): ```bash # 检查从节点状态 redis-cli -p 6380 info replication # 如果同步有问题,重新配置主从 redis-cli -p 6380 SLAVEOF 127.0.0.1 6379 ``` ### 2. 主节点故障 - 故障转移全过程 #### 阶段一:故障检测 ```bash # 哨兵日志序列 +sdown master mymaster 127.0.0.1 6379 +odown master mymaster 127.0.0.1 6379 #quorum 3/2 +new-epoch 1 +try-failover master mymaster 127.0.0.1 6379 ``` #### 阶段二:领导者选举 * 哨兵节点通过Raft算法选举领导者 * 需要多数哨兵同意(3个哨兵需要至少2票) #### 阶段三:故障转移执行 ```bash # 选择新的主节点(基于优先级、复制偏移量等) +vote-for-leader sentinel1 1 +selected-slave slave 127.0.0.1:6380 127.0.0.1 6380 @ mymaster 127.0.0.1 6379 # 提升从节点为主节点 +failover-state-send-slaveof-noone slave 127.0.0.1:6380 127.0.0.1 6380 @ mymaster 127.0.0.1 6379 # 重新配置其他从节点 +failover-state-reconf-slaves master mymaster 127.0.0.1 6379 +slave-reconf-sent slave 127.0.0.1:6381 127.0.0.1 6381 @ mymaster 127.0.0.1 6379 # 故障转移完成 +failover-end master mymaster 127.0.0.1 6379 +switch-master mymaster 127.0.0.1 6379 127.0.0.1 6380 ``` ### 3. 原主节点恢复 **恢复后的状态**: * 原主节点重启后自动成为新主节点的从节点 * 自动开始数据同步 **哨兵日志**: ```bash # 原主节点恢复检测 -sdown slave 127.0.0.1:6379 127.0.0.1 6379 @ mymaster 127.0.0.1 6380 # 重新配置为从节点 +convert-to-slave slave 127.0.0.1:6379 127.0.0.1 6379 @ mymaster 127.0.0.1 6380 ``` ## 配置说明 ### 关键哨兵配置参数 ```conf sentinel monitor mymaster 127.0.0.1 6379 2 sentinel down-after-milliseconds mymaster 30000 sentinel failover-timeout mymaster 180000 sentinel parallel-syncs mymaster 1 ``` **参数说明**: * `quorum=2`:至少需要2个哨兵同意才能标记主节点故障 * `down-after-milliseconds=30000`:30秒无响应认为节点下线 * `failover-timeout=180000`:故障转移超时时间3分钟 ## 监控和诊断命令 ### 哨兵状态检查 ```bash # 查看哨兵主节点信息 redis-cli -p 26379 sentinel master mymaster # 查看哨兵从节点信息 redis-cli -p 26379 sentinel slaves mymaster # 查看哨兵节点信息 redis-cli -p 26379 sentinel sentinels mymaster # 获取当前主节点地址 redis-cli -p 26379 sentinel get-master-addr-by-name mymaster ``` ### 节点状态验证 ```bash # 检查所有节点角色 redis-cli -p 6379 info replication redis-cli -p 6380 info replication redis-cli -p 6381 info replication ``` ## 最佳实践建议 1. **哨兵部署**: * 哨兵节点分散在不同物理机器 * 避免哨兵与Redis实例同时故障 2. **网络配置**: * 确保节点间网络连通性 * 合理设置超时参数 3. **监控告警**: * 监控哨兵日志和状态 * 设置故障转移告警 4. **恢复测试**: * 定期进行故障转移演练 * 验证数据一致性 这种架构能够提供高可用性,在单点故障时自动恢复,确保Redis服务的持续可用。 --- --- url: /src/technology/dateblog/2025/10/20251029-redis网络分区(脑裂)详解.md --- # 网络分区(脑裂)详解 ## 🎯 **什么是网络分区(脑裂)?** **网络分区**(Network Partition),俗称**脑裂**(Split-Brain),是指分布式系统中由于网络故障导致集群节点被分割成两个或多个无法通信的子集群,每个子集群都认为自己是唯一存活的集群,从而同时对外提供服务。 ## 🔍 **脑裂的直观比喻** ``` 想象一个团队被隔音玻璃墙分开: - 左边团队:认为右边团队已经"死亡",自己继续工作 - 右边团队:认为左边团队已经"死亡",自己继续工作 - 结果:两边都在处理相同的任务,造成混乱和冲突 ``` ## 🛠️ **Redis集群脑裂场景** ### 典型脑裂场景 ```bash # 原始集群:6节点(3主3从) 节点分布:A[7001], B[7002], C[7003], D[7004], E[7005], F[7006] # 网络故障导致分区: 分区1:A(7001), D(7004), E(7005) # 2主1从 分区2:B(7002), C(7003), F(7006) # 1主2从 ``` ## ⚡ **脑裂发生过程** ### 阶段1:网络中断 ```bash # 节点间无法通信,互相认为对方宕机 >>> Node 127.0.0.1:7002 is unreachable >>> Node 127.0.0.1:7003 is unreachable >>> Node 127.0.0.1:7006 is unreachable ``` ### 阶段2:各自选举 ```bash # 分区1(拥有多数主节点): >>> Cluster state: ok - 认为自己是有效集群 >>> 继续处理读写请求 # 分区2(少数主节点): >>> Cluster state: fail - 如果没有足够主节点,可能停止服务 # 或者如果分区2有足够的主节点,也会认为自己是有效集群 ``` ### 阶段3:数据冲突 ```bash # 两个分区同时对同一个key进行写操作 分区1:set user:1000 "value_from_partition1" 分区2:set user:1000 "value_from_partition2" # 产生数据不一致! ``` ## 🔧 **Redis防脑裂机制** ### 1. **多数派原则(Quorum)** ```bash # Redis集群需要大多数主节点同意才能进行写操作 # 通常需要 N/2 + 1 个主节点同意 # 3主节点集群:需要至少2个主节点同意 # 5主节点集群:需要至少3个主节点同意 ``` ### 2. **故障检测机制** ```bash # 节点超时检测 cluster-node-timeout 15000 # 15秒超时 # 如果节点在指定时间内无响应,被认为故障 ``` ### 3. **配置纪元(Config Epoch)** ```bash # 每个主节点有唯一的配置纪元 # 用于解决冲突,纪元高的配置优先 ``` ## 💥 **脑裂的严重后果** ### 数据不一致 ```bash # 不同分区对同一数据的不同操作 分区1:INCR counter # 结果:100→101 分区2:INCR counter # 结果:100→101 # 网络恢复后,counter的值不确定! ``` ### 数据丢失 ```bash # 网络恢复后,Redis需要解决冲突 # 可能会丢弃其中一个分区的写操作 ``` ### 客户端混乱 ```bash # 客户端可能连接到不同分区 # 看到不同的数据状态 # 业务逻辑出现异常 ``` ## 🛡️ **预防和解决脑裂** ### 配置优化 ```bash # 合理设置超时时间 cluster-node-timeout 15000 # 最小主节点数量要求 min-slaves-to-write 1 # 至少1个从节点确认 min-slaves-max-lag 10 # 从节点延迟不超过10秒 ``` ### 网络架构设计 ```bash # 使用高质量网络设备 # 多网络路径冗余 # 监控网络健康状况 ``` ### 监控告警 ```bash # 监控集群节点状态 redis-cli cluster nodes | grep fail # 监控网络分区事件 # 设置自动告警 ``` ## 🔄 **脑裂恢复过程** ### 网络恢复后 ```bash # 1. 节点重新发现彼此 >>> Node 127.0.0.1:7002 is now connected # 2. 配置纪元比较 >>> Higher config epoch wins - 高纪元配置获胜 # 3. 数据同步 >>> Nodes sync data based on winning partition's state # 4. 可能的数据丢失 >>> Some writes from the losing partition may be discarded ``` ## 📊 **脑裂影响总结** | 方面 | 影响程度 | 恢复难度 | |------|----------|----------| | 数据一致性 | 🔴 严重 | 🔴 困难 | | 数据完整性 | 🟡 中等 | 🟡 中等 | | 服务可用性 | 🔴 严重 | 🟢 容易 | | 业务影响 | 🔴 严重 | 🔴 困难 | ## 💡 **最佳实践** 1. **合理规划集群规模**:至少3个主节点 2. **网络质量保证**:避免单点网络故障 3. **监控告警**:实时检测网络分区 4. **客户端重试机制**:处理临时不可用 5. **数据备份**:定期备份重要数据 **脑裂是分布式系统中最棘手的问题之一**,理解其原理和预防措施对维护Redis集群的稳定性至关重要。 --- --- url: /src/technology/dateblog/2025/10/20251029-redis集群节点故障与恢复详解.md --- # Redis集群节点故障与恢复详解 ## 🔄 **主节点宕机后恢复的完整过程** ### 1. **主节点宕机检测** ```bash # 集群检测到主节点不可用 >>> Node 127.0.0.1:7001 is unreachable >>> Cluster state changed: fail ``` ### 2. **自动故障转移** ```bash # 从节点自动升级为主节点 >>> Failover election won: promoting slave 127.0.0.1:7004 to master >>> Configuring 127.0.0.1:7004 as master for slots 0-5460 ``` ### 3. **原主节点重新上线** ```bash # 宕机的主节点重新启动 redis-server /path/to/redis.conf # 节点自动重新加入集群 >>> Node 127.0.0.1:7001 rejoined the cluster ``` ### 4. **角色转换过程** ```bash # 重新加入后,原主节点变为新主节点的从节点 127.0.0.1:7001> cluster nodes # 输出显示: # 127.0.0.1:7004 master - 负责槽位 0-5460 # 127.0.0.1:7001 slave 127.0.0.1:7004 - 变为从节点 ``` ### 5. **数据同步** ```bash # 新从节点开始同步数据 >>> 127.0.0.1:7001 starting replication from 127.0.0.1:7004 >>> SYNC with master, discarding 150 bytes of pre-sync data >>> MASTER <-> REPLICA sync: receiving 2048 bytes from master ``` ## ⚠️ **其他可能的故障场景** ### 场景1:**多个主节点同时宕机** ```bash # 情况:2个主节点同时故障 >>> 127.0.0.1:7001 and 127.0.0.1:7002 are unreachable # 影响: # - 如果超过半数主节点宕机,集群将不可用 # - 客户端收到 CLUSTERDOWN 错误 # - 需要人工干预恢复 ``` ### 场景2:**主节点和其从节点同时宕机** ```bash # 情况:主节点7001和其从节点7004同时故障 >>> No available slaves for master 127.0.0.1:7001 # 影响: # - 该主节点负责的槽位数据不可访问 # - 集群部分功能受损 # - 需要从备份恢复或等待节点重启 ``` ### 场景3:**网络分区(脑裂)** ```bash # 情况:集群被网络分割成两个部分 Part A: 7001(master), 7004(slave), 7002(master) Part B: 7003(master), 7005(slave), 7006(slave) # 可能发生: # - 两个分区都认为自己是有效集群 # - 数据写入冲突 # - 网络恢复后需要解决数据一致性 ``` ### 场景4:**从节点宕机** ```bash # 情况:从节点7004宕机 >>> Slave 127.0.0.1:7004 is unreachable # 影响: # - 主节点7001失去备份 # - 集群仍然可用,但冗余性降低 # - 自动重连后重新同步数据 ``` ### 场景5:**节点数据不一致** ```bash # 情况:节点重启后数据版本冲突 >>> Node 127.0.0.1:7001 has inconsistent config epoch >>> Cluster state changed: fail # 解决:需要手动修复 redis-cli --cluster fix 127.0.0.1:7001 ``` ## 🛠️ **故障恢复命令** ### 检查集群状态 ```bash # 查看集群健康状态 redis-cli --cluster check 127.0.0.1:7001 # 查看节点信息 redis-cli -c -p 7001 cluster nodes # 查看集群信息 redis-cli -c -p 7001 cluster info ``` ### 手动故障转移 ```bash # 在从节点上执行手动故障转移 redis-cli -p 7004 cluster failover # 强制故障转移(即使主节点可用) redis-cli -p 7004 cluster failover force ``` ### 修复集群状态 ```bash # 修复集群配置 redis-cli --cluster fix 127.0.0.1:7001 # 重新分片恢复 redis-cli --cluster reshard 127.0.0.1:7001 ``` ## 📊 **故障影响分析表** | 故障场景 | 集群状态 | 数据可用性 | 自动恢复 | 需要人工干预 | |---------|----------|------------|----------|-------------| | 单主节点宕机 | 部分降级 | 部分影响 | ✅ 是 | ❌ 否 | | 多主节点宕机 | 可能不可用 | 严重影响 | ❌ 否 | ✅ 是 | | 主从同时宕机 | 部分不可用 | 数据丢失 | ❌ 否 | ✅ 是 | | 网络分区 | 分裂 | 不一致 | ⚠️ 部分 | ✅ 是 | | 从节点宕机 | 正常 | 完全可用 | ✅ 是 | ❌ 否 | ## 🔧 **预防措施** ### 配置优化 ```bash # 合理设置超时时间 cluster-node-timeout 15000 # 启用持久化 appendonly yes appendfsync everysec # 监控配置 # 设置适当的副本数 --cluster-replicas 1 ``` ### 监控告警 ```bash # 定期检查集群状态 redis-cli --cluster check 127.0.0.1:7001 # 监控关键指标 - 节点连接状态 - 内存使用情况 - 复制延迟 - 集群状态 ``` ## 💡 **最佳实践建议** 1. **至少配置1个从节点**确保高可用 2. **定期备份**集群数据 3. **监控集群健康状态** 4. **测试故障恢复流程** 5. **使用支持集群的客户端** 通过理解这些故障场景和恢复机制,可以更好地设计和维护Redis集群,确保业务的连续性和数据的安全性。 --- --- url: /AGENTS.md --- # shangjundragon.github.io — 个人博客 / VitePress 站点 ## 项目概述 * **框架**: VitePress 2.0.0-alpha.8 + Vue 3.6.0-alpha.1 * **UI 库**: Naive UI(SSR 通过 `@css-render/vue3-ssr` 注入样式) * **评论系统**: Waline(客户端 `@waline/client`) * **包管理**: pnpm * **部署**: Netlify → `https://shangjundragon.netlify.app/` * **远程**: `git@github.com:shangjundragon/shangjundragon.github.io.git` ## 开发命令 ```bash pnpm docs:dev # 开发服务器(端口 15187,硬编码在 .vitepress/vite-config.js) pnpm docs:build # 生产构建 pnpm docs:preview # 预览构建产物 ``` ## 目录结构关键点 * `.vitepress/components/` — 17 个 Vue 组件目录,全局注册于 `.vitepress/theme/index.js` * `.vitepress/data-js/` — VitePress 数据加载器(`createContentLoader`),按分类聚合 markdown 内容 * `.config.js` / `.config.development.js` / `.config.production.js` — 环境配置,通过 `env-config.js` 按 `import.meta.env.MODE` 合并 * `src/` — 博客 markdown 内容,按标签目录组织(technology、life、goodfood、test、randomwriting) ## 内容规范 * 每篇 markdown 需包含 frontmatter:`title`、`createDate`(排序依据,降序)、`tabs`(逗号分隔)、`exclude`(设为 `true` 排除) * `src/all.md` 汇总全部文章,通过 `getAllUnExcludeDataAndSort.data.js` 加载 * 管理后台: `https://shangjundragon.netlify.app/admin/index.html` ## 技术栈注意事项 * **Auto-imports**: `unplugin-auto-import` 自动导入 Vue API + Naive UI(`useDialog`、`useMessage`、`useNotification`、`useLoadingBar`) * **组件自动注册**: `unplugin-vue-components` + Naive UI Resolver * **别名**: `@` → 项目根目录 * **SSR**: Naive UI 需 SSR 处理,已在 `theme/index.js` 配置 `@css-render/vue3-ssr` * **搜索**: Algolia(凭据在 `.env` 和 `theme-config.js`) * **OSS**: 七牛云(`qiuNiuOss` 函数代理)和 NAS 静态资源(`nasAssets` 拼接路径) ## 构建 & 部署 * 构建产物在 `.vitepress/dist/` * CI/CD: 通过 Netlify 自动部署(监听 master 分支);`.github/workflows/` 未配置 * 构建插件: `vite-plugin-compression` 压缩产物、`vitepress-plugin-llms` 生成 LLM 索引 --- --- url: /src/technology/dateblog/2025/10/20251027-spring-boot-启动时的自动装配原理和顺序.md --- ### 一、核心思想:约定优于配置 自动装配的本质是 **“约定优于配置”**。Spring Boot 根据你引入的依赖(Jar 包),自动推测并组装你需要的 Bean,从而极大地减少了繁琐的 XML 或 Java 配置。 *** ### 二、自动装配的“总开关”:@SpringBootApplication 一切始于启动类上的 `@SpringBootApplication` 注解。 ```java @SpringBootApplication public class MyApplication { public static void main(String[] args) { SpringApplication.run(MyApplication.class, args); } } ``` 这个注解是一个**复合注解**,它包含了几个至关重要的注解: ```java @SpringBootConfiguration @EnableAutoConfiguration // <--- 自动装配的核心入口 @ComponentScan public @interface SpringBootApplication { // ... } ``` 其中,**`@EnableAutoConfiguration`** 就是开启自动装配大门的钥匙。 *** ### 三、自动装配的核心原理与流程 整个自动装配过程可以概括为:**通过 `@EnableAutoConfiguration` 触发,读取 `META-INF/spring.factories` 文件,加载所有声明的自动配置类,然后根据一系列条件注解(如 `@ConditionalOnClass`)进行过滤,最终将符合条件的配置类中定义的 Bean 加载到 IoC 容器中。** 下面是详细的步骤分解: #### 步骤 1:触发自动配置 `@EnableAutoConfiguration` 注解本身又导入了一个关键的选择器:`AutoConfigurationImportSelector`。 ```java @AutoConfigurationPackage @Import(AutoConfigurationImportSelector.class) public @interface EnableAutoConfiguration { // ... } ``` `AutoConfigurationImportSelector` 是自动装配逻辑的“大脑”。 #### 步骤 2:加载候选配置类 在 Spring Boot 应用启动过程中(具体是在 `ConfigurationClassPostProcessor` 处理配置类时),会调用 `AutoConfigurationImportSelector` 的 `selectImports` 方法。 这个方法的核心工作是: 1. 通过 `SpringFactoriesLoader` 工具类,从 classpath 下**所有** Jar 包的 `META-INF/spring.factories` 配置文件中,读取 `EnableAutoConfiguration` 这个 key 对应的所有配置类的全限定名。 2. 这些配置类就是“候选”的自动配置类。 **以 `spring-boot-autoconfigure-2.x.x.RELEASE.jar` 为例,其 `spring.factories` 文件片段如下:** ```properties # Auto Configure org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ org.springframework.boot.autoconfigure.admin.SpringApplicationAdminJmxAutoConfiguration,\ org.springframework.boot.autoconfigure.aop.AopAutoConfiguration,\ org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration,\ org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration,\ org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration,\ org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration,\ // ... 这里列出了上百个配置类 ``` 此时,Spring Boot 拿到了一个非常长的、所有可能的自动配置类列表。 #### 步骤 3:过滤与条件装配 如果直接把所有候选配置类都加载,会导致大量不必要的 Bean 被创建,甚至出现 `ClassNotFoundException`(比如你没有引入 Redis 依赖,却加载了 Redis 的配置类)。 因此,**最关键的一步——“按需加载”** 发生了。Spring Boot 通过一系列 **`@Conditional` 条件注解** 来过滤这个候选列表。 这些条件注解是自动装配的灵魂: * **`@ConditionalOnClass`**:类路径下存在指定的类时,配置才生效。 * *例如:`DataSourceAutoConfiguration` 上就有 `@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })`,只有当你引入了数据库相关的 Jar 包(包含了 `DataSource` 类),配置才会被加载。* * **`@ConditionalOnBean`**:容器中存在指定的 Bean 时,配置才生效。 * **`@ConditionalOnMissingBean`**:容器中**不存在**指定的 Bean 时,配置才生效。**这是实现配置覆盖的关键。** * *例如:Spring Boot 自动配置了一个 `DataSource`,但如果你在自定义的 `@Configuration` 类中自己定义了一个 `DataSource` Bean,那么自动配置的 `DataSource` 就不会被加载。* * **`@ConditionalOnProperty`**:指定的配置属性有指定的值时,配置才生效。 * **`@ConditionalOnWebApplication` / `@ConditionalOnNotWebApplication`**:根据是否是 Web 应用来决定是否加载。 **过滤过程:** Spring Boot 在真正加载这些配置类之前,会使用 `ConditionEvaluator` 来逐一检查每个配置类上的条件注解。只有所有条件都满足的配置类,才会被最终解析并导入到 Spring 容器中。 #### 步骤 4:执行自动配置 经过过滤后,剩下的自动配置类就会被 Spring 容器正常加载,它们本身就是 `@Configuration` 配置类。这些类中定义了大量的 `@Bean` 方法,这些 Bean 就被创建并加入到 IoC 容器中,供我们使用。 *** ### 四、自动装配的“顺序”问题 你可能会问,这么多自动配置类,它们的执行顺序是怎样的? 1. **`@AutoConfigureOrder` 注解**: 这个注解类似于 `@Order`,可以定义配置类的加载顺序。但**实际上,自动配置类很少使用它**,因为自动装配更多地依赖于条件注解,而不是严格的顺序。 2. **`@AutoConfigureAfter` / `@AutoConfigureBefore`**: 这两个注解是 Spring Boot 为自动配置类提供的、更细粒度的顺序控制注解。 * `@AutoConfigureAfter(A.class)`:表示本配置类必须在 A 配置类之后处理。 * `@AutoConfigureBefore(B.class)`:表示本配置类必须在 B 配置类之前处理。 * **使用场景**:当 A 配置类定义的 Bean 是 B 配置类的依赖时,就需要确保 A 在 B 之前加载。 *示例:`RabbitAutoConfiguration` 可能会标注 `@AutoConfigureAfter(MessageSourceAutoConfiguration.class)`,以确保消息源先配置好。* 3. **隐式顺序**: 大部分情况下,自动配置是“幂等”的,顺序并不重要。Bean 的最终覆盖权由 `@ConditionalOnMissingBean` 决定。后定义的 Bean 如果满足条件,可以覆盖先定义的 Bean。而**用户通过 `@Configuration` 定义的配置类,其优先级默认高于自动配置类**,这也是为什么我们可以轻松地用自定义配置覆盖默认配置的原因。 *** ### 五、总结与流程图 **核心流程总结:** 1. **启动**:由 `@SpringBootApplication` -> `@EnableAutoConfiguration` 触发。 2. **加载**:`AutoConfigurationImportSelector` 扫描 `spring.factories`,获取所有候选配置类。 3. **过滤**:利用 `@ConditionalOnXxx` 条件注解,根据当前项目的**类路径**、**已有 Bean**、**配置属性**等环境信息,进行筛选,得到最终需要生效的配置类。 4. **装配**:加载生效的配置类,将其中定义的 Bean 注册到 IoC 容器。 **流程图:** ```mermaid graph TD A[@SpringBootApplication] --> B[@EnableAutoConfiguration]; B --> C[AutoConfigurationImportSelector]; C --> D[扫描所有 META-INF/spring.factories]; D --> E[获取 EnableAutoConfiguration 对应的候选配置类列表]; E --> F{条件注解过滤
@ConditionalOnClass等}; F -- 条件满足 --> G[加载有效的自动配置类]; F -- 条件不满足 --> H[丢弃该配置类]; G --> I[执行@Bean方法, 注册Bean到IoC容器]; I --> J[自动装配完成]; ``` ### 六、如何调试和查看 * **开启调试日志**:在 `application.properties` 中添加 `debug=true`。启动时,控制台会打印两份报告: * `Positive matches`:报告哪些自动配置类**生效了**。 * `Negative matches`:报告哪些自动配置类**未生效**及其原因。 * **查看已生效的配置类**:使用 Actuator 的 `/conditions` 端点(需要引入 `spring-boot-starter-actuator` 依赖),它可以提供一个更详细的 HTML 报告。 通过理解这个原理,你就能明白为什么“引入一个 Starter,功能自然就有了”,以及如何通过自定义配置来覆盖 Spring Boot 的默认行为。 --- --- url: /src/technology/dateblog/2025/04/20250409-springcloud-openfeign-上传文件.md --- # SpringCloud OpenFeign 上传文件 ## Feign配置Bean ```java @Autowired private ObjectFactory messageConverters; @Bean public Encoder feignFormEncoder() { return new SpringFormEncoder(new SpringEncoder(messageConverters)); } ``` ## FeignClient接口定义 ```java // 上传临时素材 @PostMapping(value = "/cgi-bin/media/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) JSONObject media_upload(@RequestParam(value = "access_token", required = false) String access_token, @RequestParam(value = "type", required = false) String type, @RequestParam(value = "debug", required = false) String debug, @RequestPart("media") MultipartFile file); ``` ## 调用示例 ```java JSONObject jsonObject = qyWxClient.media_upload(null, "file", "1", new MockMultipartFile( "media", file.getName(), "application/octet-stream", fileInputStream )); ``` --- --- url: /src/technology/dateblog/2025/07/20250701-springcloud-openfeign-流式响应文件.md --- # SpringCloud OpenFeign 流式响应文件 \#流式传输#分块传输#Feign#OpenFeign ## @FeignClient代码 ```java import cn.hutool.json.JSONObject; import feign.Response; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @FeignClient(name = "documentConver", url = "http://127.0.0.1:15676") public interface DocumentConverClient { @PostMapping("/html2pdf/url") Response html2pdfUrl(@RequestBody JSONObject jsonObject); } ``` ## Controller转化Feign流式响应文件 ```java package com.ruoyi.web.controller.oa; import cn.hutool.core.codec.Base64; import cn.hutool.json.JSONUtil; import com.ruoyi.common.rpc.htm2pdf.DocumentConverClient; import feign.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletResponse; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; @RestController @RequestMapping("/test") @RequiredArgsConstructor @Slf4j public class TestController { private final DocumentConverClient documentConverClient; @GetMapping("/url_to_pdf") public void urlToPdf(String url, HttpServletResponse response) { String decodeUrl = ""; try { decodeUrl = Base64.decodeStr(url, StandardCharsets.UTF_8); // 获取Feign响应对象 try (Response feignResponse = documentConverClient.htmlUrl2Pdf(JSONUtil.createObj().set("url","https://www.baidu.com"))) { // 检查响应状态 if (feignResponse.status() != 200) { throw new RuntimeException("PDF服务返回错误状态: " + feignResponse.status()); } // 可选:复制原始响应头 feignResponse.headers().forEach((key, values) -> { if (key != null && values != null && !key.equalsIgnoreCase("Transfer-Encoding")) { values.forEach(value -> response.addHeader(key, value)); } }); // 设置响应头 response.setContentType("application/pdf"); response.setCharacterEncoding("UTF-8"); // 流式传输 try (InputStream inputStream = feignResponse.body().asInputStream(); OutputStream outputStream = response.getOutputStream()) { byte[] buffer = new byte[8192]; int bytesRead; while ((bytesRead = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); } } } } catch (Exception e) { log.error("查看pdf失败 url={} decodeUrl={}", url, decodeUrl, e); throw new RuntimeException("查看pdf失败: " + e.getMessage()); } } } ``` --- --- url: /src/technology/dateblog/2025/04/20250430-spring中文网文档.md --- # Spring中文网文档 [Spring中文网文档](https://springdoc.cn/) --- --- url: /src/technology/dateblog/2025/06/20250605-ssh(secure-shell)协议.md --- # SSH(Secure Shell)协议 ## 一、SSH 是什么? SSH 是一种**网络协议**,用于在**不安全的网络**(如互联网)上提供**安全的远程登录**和**其他安全的网络服务**。它的核心目标是解决传统网络工具(如 Telnet, rlogin, FTP)存在的**严重安全问题**——这些工具在传输数据(包括用户名、密码、命令本身、文件内容)时都是**明文的**,很容易被网络上的攻击者窃听和篡改。 SSH 通过强大的**加密技术**、**主机验证**和**用户身份验证**机制,在客户端和服务器之间建立一个**加密的通信隧道**,确保通过该隧道传输的所有数据都是**机密性**和**完整性**的。 ## 二、SSH 的核心功能 1. **安全的远程命令行登录:** * 这是 SSH 最基本和最常用的功能。 * 用户可以从本地计算机(SSH 客户端)安全地登录到远程计算机(SSH 服务器),并在远程计算机上执行命令,就像坐在那台机器前操作一样。 * 替代了不安全的 `telnet` 和 `rlogin`。 2. **安全的远程命令执行:** * 无需登录交互式 Shell,可以直接通过 SSH 在远程服务器上执行单条命令,并获取命令的输出结果。 * 常用于自动化脚本、远程管理和部署任务。 * 例如:`ssh user@hostname "ls -l /tmp"` 3. **安全的文件传输:** * SSH 提供了两种主要方式在本地主机和远程主机之间安全地传输文件: * **SCP (Secure Copy Protocol):** 基于 SSH 的命令行文件传输工具。语法类似于传统的 `cp` 命令。 * 例如:`scp localfile.txt user@remotehost:/remote/directory/` * **SFTP (SSH File Transfer Protocol):** 一个独立的、交互式文件传输协议,运行在 SSH 之上。它提供类似 FTP 的功能(列出目录、上传、下载、删除文件等),但所有通信都是加密的。**注意:SFTP 和 FTPS (FTP over SSL/TLS) 是不同的协议。** * 替代了不安全的 `ftp` 和 `rcp`。 4. **端口转发(隧道) - SSH Tunneling:** * 这是 SSH 非常强大且灵活的功能,允许用户通过加密的 SSH 连接来转发其他网络应用程序的流量。 * **主要类型:** * **本地端口转发:** 将本地主机上的一个端口绑定到 SSH 连接上。发送到这个本地端口的数据会被 SSH 加密,并通过隧道转发到远程主机上的指定端口。 * **用途:** 访问远程主机后面的服务(如数据库)、绕过本地防火墙限制访问远程服务、加密不安全的协议流量。 * **示例命令:** `ssh -L [local_port]:[destination_host]:[destination_port] user@ssh_server` * **场景:** 本地机器无法直接访问数据库服务器 `db.internal.com:3306`,但可以通过 SSH 服务器 `gateway.example.com` 访问。在本地运行 `ssh -L 63306:db.internal.com:3306 user@gateway.example.com`,然后连接本地的 `localhost:63306` 就等于安全地连接到了 `db.internal.com:3306`。 * **远程端口转发:** 将远程主机上的一个端口绑定到 SSH 连接上。发送到这个远程端口的数据会被 SSH 加密,并通过隧道转发回本地主机上的指定端口。 * **用途:** 将本地开发的服务临时暴露给外网访问、让外部访问内网服务(需谨慎使用,有安全风险)。 * **示例命令:** `ssh -R [remote_port]:[localhost]:[local_port] user@ssh_server` * **场景:** 你在内网开发了一个 Web 服务运行在 `localhost:8000`,想让外网的朋友通过 `gateway.example.com:8080` 访问。在本地运行 `ssh -R 8080:localhost:8000 user@gateway.example.com`,朋友访问 `http://gateway.example.com:8080` 的流量就会被转发到你的本地 `localhost:8000`。 * **动态端口转发(SOCKS 代理):** 在本地创建一个 SOCKS 代理服务器端口。任何配置为使用这个 SOCKS 代理的应用程序,其流量都会通过 SSH 隧道进行加密和转发,最终由 SSH 服务器发出。 * **用途:** 安全地浏览网页(绕过本地网络限制或监控)、加密所有支持 SOCKS 代理的应用程序流量。 * **示例命令:** `ssh -D [local_socks_port] user@ssh_server` * **场景:** 在咖啡厅连公共 Wi-Fi,运行 `ssh -D 1080 user@myhomepc.com`,然后在浏览器网络设置中配置 SOCKS 代理为 `localhost:1080`。之后所有浏览器流量都通过家里的电脑加密传输,公共 Wi-Fi 无法窃听。 5. **X11 转发:** * 允许用户在本地显示远程 Linux/Unix 服务器上运行的图形界面 (GUI) 应用程序。 * 通过 SSH 隧道安全地传输 X Window System 协议数据。 * 使用 `ssh -X user@hostname` 连接,然后在远程命令行启动 GUI 程序(如 `xclock`),窗口就会显示在本地。 6. **代理转发:** * 允许用户使用存储在本地 SSH 密钥代理(如 `ssh-agent`)中的私钥进行身份验证,以跳转到另一台服务器,而无需将私钥复制到中间服务器上。 * 提高了使用密钥认证在多台服务器间跳转的便利性和安全性(私钥始终只留在本地)。 * 使用 `ssh -A user@jumphost` 或在配置文件中设置 `ForwardAgent yes`(需谨慎评估中间服务器是否可信)。 ## 三、SSH 如何实现安全?核心机制 1. **加密:** * SSH 在客户端和服务器建立连接时协商使用哪种**对称加密算法**(如 AES, ChaCha20)来加密整个会话的数据。这确保了传输数据的**机密性**,即使被截获也无法被解读。 2. **主机验证:** * 客户端首次连接服务器时,会收到服务器的**公钥**并记录在本地(通常是 `~/.ssh/known_hosts` 文件)。 * 后续连接时,客户端会用这个存储的公钥来验证服务器的身份,防止 **“中间人攻击”**。如果密钥不匹配,会发出严重警告。 3. **用户身份验证:** * SSH 支持多种方式验证用户的身份: * **密码认证:** 用户输入远程服务器上的账户密码(密码传输也是加密的)。 * **公钥认证(最推荐):** 用户生成一对密钥(公钥 + 私钥)。将**公钥**上传到远程服务器用户目录下的 `~/.ssh/authorized_keys` 文件中。连接时,客户端用**私钥**(通常受密码保护)向服务器证明身份。这种方式更安全(避免密码猜测),且便于自动化。 * 其他(较少用):基于主机的认证、键盘交互认证(用于一次性密码等)。 4. **数据完整性保护:** * SSH 使用**消息认证码**来验证传输的数据在途中没有被篡改,确保数据的**完整性**。 ## 四、总结 SSH 能实现的功能(一句话概括) **SSH 的核心功能是在不安全的网络上创建一个安全的加密通信通道,通过这个通道,你可以安全地进行远程系统登录、执行命令、传输文件、转发其他应用程序的端口流量(隧道)、甚至安全地运行远程图形程序。它从根本上解决了传统网络工具明文传输数据的安全隐患。** ### 图解:SSH 隧道(端口转发)概念 ``` +----------------+ (加密的SSH隧道) +-----------------+ | 你的本地电脑 | <=============================> | 远程SSH服务器 | | (SSH Client) | | (SSH Server) | | | | | | Local App | | | | (e.g., Browser)| --(流量发送到)--> localhost:1080--+ | | | (动态转发/SOCKS) | | | | | | | Local App | | | | (e.g., DB GUI) | --(连接到)-----> localhost:63306-+ +--> 最终目标服务 | | (本地转发) | | (e.g., Database) | | | | +----------------+ +-----------------+ ``` **何时使用 SSH?** * 管理远程 Linux/Unix 服务器(几乎是必备技能)。 * 在公共 Wi-Fi 等不安全网络环境下安全地访问资源。 * 安全地传输敏感文件。 * 绕过某些网络限制(需遵守法律法规和网络政策)。 * 安全地访问位于防火墙或 NAT 后面的内部服务(如数据库)。 * 自动化脚本需要安全地连接远程服务器执行命令或传输文件。 SSH 是现代 IT 基础设施、系统管理、开发和运维工作中不可或缺的安全基石。理解并熟练运用 SSH,尤其是其端口转发功能,能极大提升工作效率和安全性。 ## 五、端口转发详解 好的,我们来深入探讨一下 **SSH 端口转发(也称为 SSH 隧道)** 这个强大且灵活的功能。它本质上是利用 SSH 协议建立的加密通道,来安全地传输**其他应用程序**的网络流量。 **核心思想:** 想象一下 SSH 连接是一条安全的、加密的隧道。端口转发就是在这条隧道上开“侧门”,让原本不加密或者无法直接到达的网络流量,通过这个“侧门”进入加密隧道,安全地到达隧道的另一端(SSH 服务器),然后再由 SSH 服务器将流量转发到最终的目标地址。 **为什么需要它?** 1. **绕过防火墙/网络限制:** 访问被防火墙阻止的内部服务(如数据库、内部网站)。 2. **加密不安全协议:** 保护像 VNC、数据库连接、邮件传输等原本不加密的协议的通信安全。 3. **安全访问跳板机后的资源:** 通过一个可访问的“跳板机”(SSH 服务器)安全地访问其后方网络中的其他机器。 4. **临时暴露本地服务:** 将本地开发环境临时暴露给外部或特定服务器访问。 5. **安全浏览:** 在公共 Wi-Fi 上加密所有网络流量(通过动态转发/SOCKS 代理)。 **SSH 端口转发主要分为三种类型:** 1. **本地端口转发** 2. **远程端口转发** 3. **动态端口转发 (SOCKS 代理)** *** ### 1. 本地端口转发 (`-L`) * **作用:** 将**客户端(你本地机器)** 上的一个端口绑定到 SSH 连接上。发送到这个**本地端口**的数据会被 SSH **加密**,通过隧道传输到 **SSH 服务器**,然后由 SSH 服务器**解密**并转发到指定的 **最终目标主机:端口**。 * **命令格式:** ```bash ssh -L [bind_address:]本地端口:目标主机:目标端口 用户名@SSH服务器地址 ``` * `[bind_address:]`: (可选) 指定本地哪个网络接口监听。通常省略(表示 `localhost` 或 `127.0.0.1`)或指定 `0.0.0.0`(监听所有接口,需注意安全!)。 * `本地端口`: 你在本地机器上打开的端口号。 * `目标主机:目标端口`: SSH 服务器**能够访问到**的最终目标机器和端口。`目标主机` 可以是 `localhost`(指 SSH 服务器本身)、IP 地址或主机名(需能从 SSH 服务器解析)。 * `用户名@SSH服务器地址`: 你的 SSH 登录凭据和服务器地址。 * **数据流向:** ``` 你的本地应用 -> localhost:本地端口 -> (加密隧道) -> SSH服务器 -> 目标主机:目标端口 ``` * **典型场景:** * **访问防火墙后的数据库:** 数据库服务器 `db.internal.com:3306` 只允许 SSH 服务器 `jumpbox.example.com` 访问。你在本地运行: ```bash ssh -L 63306:db.internal.com:3306 user@jumpbox.example.com ``` 然后,你在本地的数据库客户端连接 `localhost:63306`。客户端认为它在连接本地端口 63306,但实际上流量被安全地转发到了 `jumpbox` 后面的 `db.internal.com:3306`。 * **访问内部 Web 服务:** 类似地,转发一个内部网站 `intranet.internal:8080`: ```bash ssh -L 8888:intranet.internal:8080 user@jumpbox.example.com ``` 然后在本地浏览器访问 `http://localhost:8888` 即可看到内部网站。 * **加密 VNC 连接:** VNC 默认不加密。将 VNC 流量 (通常是端口 5900 或 5901) 通过本地转发隧道传输: ```bash ssh -L 5901:localhost:5901 user@remote-vnc-host ``` 然后你的 VNC 客户端连接 `localhost:5901`,流量被加密传输到 `remote-vnc-host` 的 5901 端口。 *** ### 2. 远程端口转发 (`-R`) * **作用:** 将 **SSH 服务器** 上的一个端口绑定到 SSH 连接上。发送到这个**远程(SSH 服务器)端口**的数据会被 SSH 服务器接收,然后通过 **加密隧道** 传输回 **客户端(你的本地机器)**,最后由客户端解密并转发到指定的 **本地目标主机:端口**。 * **命令格式:** ```bash ssh -R [bind_address:]远程端口:目标主机:目标端口 用户名@SSH服务器地址 ``` * `[bind_address:]`: (可选) 指定 SSH 服务器上哪个网络接口监听。通常省略(表示 SSH 服务器的 `localhost`)或指定 `0.0.0.0`(监听所有接口,**安全风险高!通常需要服务器配置允许**)。 * `远程端口`: 在 SSH 服务器上打开的端口号。 * `目标主机:目标端口`: **你的本地机器**或**本地网络**中能够访问到的最终目标机器和端口。`目标主机` 通常是 `localhost`(指你的本地机器)或本地网络中另一台机器的 IP。 * `用户名@SSH服务器地址`: 你的 SSH 登录凭据和服务器地址。 * **数据流向:** ``` 访问SSH服务器远程端口的应用 -> SSH服务器:远程端口 -> (加密隧道) -> 你的本地机器 -> 目标主机:目标端口 ``` * **典型场景:** * **将本地开发环境暴露给外部/同事:** 你在本地 `localhost:3000` 运行了一个 Web 应用开发环境。你在本地运行: ```bash ssh -R 8080:localhost:3000 user@public-server.example.com ``` 然后,**任何能访问 `public-server.example.com` 的人**(或你自己)访问 `http://public-server.example.com:8080`,流量就会通过隧道安全地转发到你本地的 `localhost:3000`。 * **让跳板机访问你内网的服务:** 你在公司内网有一台机器 `nas.local:80` 提供文件服务,你想让家里的电脑(通过家里的 SSH 服务器 `home-server`)访问它。在公司的电脑上运行: ```bash ssh -R 8888:nas.local:80 user@home-server ``` 然后在家里的电脑上访问 `http://localhost:8888` (或者如果 `home-server` 监听了 `0.0.0.0` 且防火墙允许,直接访问 `home-server:8888`) 就能访问到公司内网的 `nas.local`。 * **重要限制与安全警告:** * SSH 服务器默认只允许绑定到 `localhost` (`127.0.0.1`)。要绑定到 `0.0.0.0` 让其他机器访问,通常需要在服务器的 SSH 配置文件 (`/etc/ssh/sshd_config`) 中设置 `GatewayPorts yes` 并重启 `sshd` 服务。**这有安全风险!** * 防火墙可能阻止对远程端口的访问。 * **慎用 `GatewayPorts yes` 和 `0.0.0.0`!** 这会将你本地的服务暴露给**任何能访问 SSH 服务器**的人。 *** ### 3. 动态端口转发 / SOCKS 代理 (`-D`) * **作用:** 在你的**本地机器**上创建一个 **SOCKS 代理服务器**(监听一个指定端口)。任何配置为使用这个 SOCKS 代理的应用程序,其 **所有网络流量** 都会被重定向到这个本地端口,然后通过 **SSH 加密隧道** 传输到 **SSH 服务器**,最后由 SSH 服务器**以其自身的 IP 地址** 发出请求并将响应通过隧道返回。**它不指定特定目标端口,而是按需转发。** * **命令格式:** ```bash ssh -D [bind_address:]本地SOCKS端口 用户名@SSH服务器地址 ``` * `[bind_address:]`: (可选) 指定本地哪个网络接口监听 SOCKS 代理。通常省略(`localhost`)或 `0.0.0.0`(允许网络内其他机器使用此代理,需注意安全)。 * `本地SOCKS端口`: 你在本地机器上为 SOCKS 代理服务打开的端口号(常用 `1080`)。 * `用户名@SSH服务器地址`: 你的 SSH 登录凭据和服务器地址。 * **数据流向:** ``` 配置了SOCKS代理的应用程序 -> localhost:本地SOCKS端口 -> (加密隧道) -> SSH服务器 -> 互联网目标 ``` * **典型场景:** * **在公共 Wi-Fi 上安全浏览:** 在咖啡馆运行: ```bash ssh -D 1080 user@my-home-server.com ``` 然后在浏览器(或系统)网络设置中配置 SOCKS 代理为 `localhost:1080`(SOCKS v5)。之后所有浏览器的流量都会通过家里的 `my-home-server.com` 加密转发出去,公共 Wi-Fi 无法窃听。 * **绕过本地网络限制:** 如果本地网络限制了某些网站(如社交媒体),通过连接到不受限制的 SSH 服务器(如公司服务器或 VPS)建立动态隧道,然后配置代理访问。 * **加密所有支持 SOCKS 的应用流量:** 邮件客户端、游戏客户端、即时通讯软件等,只要它们支持配置 SOCKS 代理,其流量都可以被加密转发。 * **特点:** * 最灵活,无需为每个目标服务单独配置转发规则。 * 应用程序需要显式配置使用 SOCKS 代理(通常在网络设置里)。 *** **关键概念与技巧:** 1. **`-f`, `-N`, `-T` 选项:** * `-f`: 让 SSH 在认证后转入后台运行。 * `-N`: 不执行远程命令。仅用于端口转发时使用。 * `-T`: 禁用伪终端分配。 * **组合使用:** 建立“静默”的后台隧道: ```bash ssh -fNT -L 63306:db.internal.com:3306 user@jumpbox.example.com ``` 这条命令建立隧道后就会在后台运行,不会打开远程 shell。 2. **`-g` 选项 (谨慎使用):** * 在本地转发 (`-L`) 中,允许**远程主机**连接到本地的转发端口(默认只允许 `localhost` 连接)。需要在 `sshd_config` 中可能设置 `GatewayPorts` 或处理防火墙。**有安全风险!** 3. **`GatewayPorts` 配置 (服务器端 `/etc/ssh/sshd_config`):** * `no` (默认): 远程转发 (`-R`) 绑定的端口只监听 `localhost` (`127.0.0.1`)。 * `clientspecified`: 允许客户端在 `-R` 命令中指定 `bind_address` (如 `0.0.0.0`)。 * `yes`: 所有远程转发 (`-R`) 默认绑定到 `0.0.0.0` (监听所有接口)。 * **修改后需重启 SSH 服务 (`sudo systemctl restart sshd`)。启用 `yes` 或 `clientspecified` 并绑定 `0.0.0.0` 会显著增加暴露风险!** 4. **主机密钥验证:** 首次连接 SSH 服务器时会提示接受其主机密钥,存储在 `~/.ssh/known_hosts` 中。后续连接会验证密钥是否匹配,防止中间人攻击。如果服务器密钥改变(如重装系统),会发出警告,需要手动确认或删除旧的记录。 5. **保持隧道连接:** 长时间运行的隧道可能会因为网络波动或超时而断开。可以使用工具如 `autossh` 自动重连: ```bash autossh -M 0 -fNT -L 63306:db.internal.com:3306 user@jumpbox.example.com ``` (`-M 0` 禁用 autossh 的内部监控端口,使用 SSH 自己的保活机制)。 **总结:** SSH 端口转发是一个非常强大的工具,它打破了网络连接的限制,在加密的保护下实现了: * **本地转发 (`-L`):** “我从本地安全地访问远程后面的服务”。 * **远程转发 (`-R`):** “让远程的人安全地访问我本地(或内网)的服务”。(**慎用,注意安全暴露!**) * **动态转发 (`-D`):** “把我所有的网络流量都通过这个加密隧道安全地发出去”。 理解并熟练运用这三种转发方式,能极大提升你在复杂网络环境下的连接能力、安全性和工作效率,是系统管理员、开发者和安全人员的必备技能。务必在使用时,尤其是涉及监听 `0.0.0.0` 或 `GatewayPorts` 时,仔细评估安全风险。 --- --- url: /src/technology/dateblog/2026/04/20260414-ubuntu安装与配置samba详细指南.md --- *** # 一、Ubuntu 安装与配置 Samba(全步骤) ## 1. 安装 Samba ```bash sudo apt update sudo apt install samba samba-common-bin -y ``` ## 2. 创建要共享的目录 例如共享 `/mnt/data/samba-share` ```bash sudo mkdir -p /mnt/data/samba-share sudo chmod 777 /mnt/data/samba-share ``` ## 3. 编辑配置文件 ```bash sudo nano /etc/samba/smb.conf ``` **拉到文件最末尾,添加如下内容(直接复制):** ```ini [myshare] comment = Ubuntu Samba Share path = /mnt/data/samba-share browseable = yes read only = no valid users = root create mask = 0666 directory mask = 0777 force user = root ``` * `[myshare]` 是**共享名**,Windows 要用到 * `valid users = root` 允许 root 用户访问 ## 4. 检查配置是否正确 ```bash testparm ``` 出现 `Loaded services file OK.` 就是正常。 ## 5. 创建 Samba 用户密码(关键!) Samba 密码**独立于系统密码**,必须单独设置: ```bash sudo smbpasswd -a root ``` 输入两次你要设置的密码,例如: ``` 123456 ``` 记住这个密码,Windows 要用。 ## 6. 重启服务生效 ```bash sudo systemctl restart smbd nmbd sudo systemctl enable smbd nmbd ``` ## 7. 查看 Ubuntu IP ```bash hostname -I ``` 记下类似: ``` 192.168.1.105 ``` *** # 二、Windows 终端操作(CMD) ## 1. 查看当前已有的网络共享连接 ```cmd net use ``` ## 2. 如果有旧连接,先删除(避免报错 1326) ```cmd net use * /delete /y ``` ## 3. 挂载 Samba 到 X 盘(最终正确命令) 格式: ```cmd net use 盘符: \\IP\共享名 /user:用户名 密码 ``` 示例: ```cmd net use X: \\192.168.1.105\myshare /user:root 123456 ``` 成功提示: ``` 命令成功完成。 ``` ## 4. 挂载为开机自动挂载 ```cmd net use X: \\192.168.1.105\myshare /user:root 123456 /persistent:yes ``` ## 5. 再次查看连接是否成功 ```cmd net use ``` 会显示: ``` X: \\192.168.1.105\myshare 已连接 Microsoft Windows Network ``` ## 6. 断开挂载(如需) ```cmd net use X: /delete ``` *** # 三、常见错误说明 1. **系统错误 1326:用户名或密码不正确** * 没执行 `smbpasswd -a root` * 密码输错 * Windows 缓存了旧凭据 → 执行 `net use * /delete /y` 2. **找不到网络路径** * IP 错 * 共享名写错(不是 Linux 路径,是 `[myshare]`) * Ubuntu 防火墙拦截(可临时关闭测试:`ufw disable`) 3. **权限不足** * 配置里加了 `force user = root` 基本不会出现 *** 需要我根据你**实际 IP 和密码**直接生成一条可复制的 `net use` 命令吗? --- --- url: /src/technology/dateblog/2025/04/20250420-vercel-vercel-json-配置定时任务.md --- # vercel vercel.json 配置定时任务 ## 注意事项 * vercel的服务器时间比国内早八小时 * (免费版)定时任务触发时间点可能有一小时左右误差 * 可以在vercel控制台看到服务的定时任务和日志 ## vercel.json 配置示例 ```json { "version": 2, "builds": [ { "src": "index.js", "use": "@vercel/node" } ], "routes": [ { "src": "/(.*)", "dest": "index.js" } ], "crons": [ { "path": "/cron/test", "schedule": "30 12 * * *" } ] } ``` --- --- url: /src/technology/dateblog/2025/04/20250416-vercel-环境变量使用.md --- # vervel 环境变量使用 前提 * 已经在[Vercel](https://vercel.com/)中添加了项目,推荐使用github添加项目 ## 安装Vercel CLI 如果你还没有安装Vercel CLI,可以使用以下命令进行全局安装: ```shell npm install -g vercel ``` ## 登录Vercel ```shell vercel login ``` ## 开发时项目使用 ### 链接vercel中的项目 :::tip 以下命令在项目中运行 ::: ```shell vercel link ``` ### 列出和查看环境变量 ```shell vercel env ls ``` ### 拉取环境变量 ::: tip 为了保护敏感信息 将 .env.local 添加到 .gitignore 文件中以避免意外泄露 如何没有指定--environment 默认拉取开发环境变量 建议指定 ::: ```shell # 拉取开发环境变量 vercel env pull .env.development --environment=development # 拉取生产环境变量 vercel env pull .env.development --environment=development ``` ### 开发时使用 ```shell # cross-env 用于在不同操作系统上设置环境变量的一致性 # dotenv 加载环境变量 npm install cross-env dotenv ``` ``` # package.json中启动命令设置环境变量NODE_ENV "start": "cross-env NODE_ENV=development node index.js", ``` ```js // 在入口启动文件添加 if (process.env.NODE_ENV === 'development') { require('dotenv').config({path: '.env.development'}); } ``` --- --- url: /src/technology/dateblog/2025/04/20250425-vitepress配置使用algolia.md --- # vitepress配置使用algolia [vitepress三方教程](https://vitepress.yiov.top/) [algolia网址](https://www.algolia.com) [algolia控制台](https://dashboard.algolia.com/) [algolia爬虫api](https://www.algolia.com/doc/tools/crawler/apis/configuration/) ## 申请使用Algolia DocSearch [docsearch 申请地址](https://docsearch.algolia.com/apply/) 提交申请表单不成功需要科学上网(似乎不申请也可自己创建账号添加索引、爬虫等) **申请成功后,Algolia官方会给你填写的邮箱发送邮件,提醒你接受邀请** 这里有几点需要注意: * 您必须是该网站的所有者,或至少有更新其内容的权限 * 你的网站必须是公开的 * 你的网站必须是一个开源项目或技术博客的技术文档,不授权于商业内容 * 你的网站必须到生产环境 :::tip 如果是点击邮件里的邀请地址,Algolia会默认创建好一个应用,且应用中已经有一个索引,如果没有,自己单独创建一个即可。 ::: ## algolia控制台优化配置 **配置语言优化** ## 配置vitepress 配置 .vitepress/config.js中的themeConfig选项中的search **appId和apiKey** **indexName** ```js const themeConfig = { /** * 其它themeConfig配置 */ // 搜索 search: { provider: 'algolia', options: { // appId 和 apiKey看上面图片地方复制 appId: 'NDR4MJ654N', apiKey: 'c76b36eaab0b39fbda9a6cb1804f3301', // 上面图片中的indexName indexName: 'shangjundragon', // 网站首页 爬虫起始页面 start_urls: ["https://shangjundragon.netlify.app"], // vitepress 需要配置生成sitemap.xml 会在根路径自动生成sitemap.xml sitemaps: ['https://shangjundragon.netlify.app/sitemap.xml'], searchParameters: { // 新增搜索参数 hitsPerPage: 10, // 每页结果数 attributesToSnippet: ['content:30'], // 从内容中截取片段 snippetEllipsisText: '...', // 截断符号 distinct: true, // 去重相似结果 typoTolerance: 'min', // 容错级别 advancedSyntax: true // 启用高级搜索语法 }, translations: { button: { buttonText: '搜索文档', buttonAriaLabel: '搜索文档' }, modal: { searchBox: { resetButtonTitle: '清除查询条件', resetButtonAriaLabel: '清除查询条件', cancelButtonText: '取消', cancelButtonAriaLabel: '取消' }, startScreen: { recentSearchesTitle: '搜索历史', noRecentSearchesText: '没有搜索历史', saveRecentSearchButtonTitle: '保存至搜索历史', removeRecentSearchButtonTitle: '从搜索历史中移除', favoriteSearchesTitle: '收藏', removeFavoriteSearchButtonTitle: '从收藏中移除' }, errorScreen: { titleText: '无法获取结果', helpText: '你可能需要检查你的网络连接' }, footer: { selectText: '选择', navigateText: '切换', closeText: '关闭', searchByText: '搜索提供者' }, noResultsScreen: { reportMissingResultsText: '你认为该有搜索结果?', reportMissingResultsLinkText: '点击反馈', noResultsText: '暂时没有找到相关内容', suggestedQueryText: '试试这些关键词:', suggestedQueries: ['入门指南', 'API 参考', '常见问题'], } } } } }, } export default themeConfig ``` ok --- --- url: /src/technology/dateblog/2025/06/20250606-vmware安装ubuntu-server-amd64-iso.md --- # VMware安装ubuntu-server-amd64.iso ## 确认网络正确 * 桥接模式检查vmware虚拟网咯编辑器桥接模式的网卡是否正确 * 主机虚拟机互ping测试,虚拟机ping外网测试 [参考](/src/technology/dateblog/2025/03/20250327-VmWare虚拟机桥接互ping不通.html#vmware虚拟机设置桥接和宿主机互ping不通) ## 切换设置root用户 * 切换root用户 ```shell sudo -sH ``` * 设置root用户密码 ```shell sudo passwd root ``` * 设置允许root用户ssh登录权限 [参考](https://www.oryoy.com/news/ru-he-zai-ubuntu-16-04-zhong-kai-qi-root-yong-hu-ssh-yuan-cheng-deng-lu-quan-xian.html) 开启Root用户SSH远程登录权限的关键在于修改SSH服务的配置文件sshd\_config。 1. 备份原始配置文件(可选,好习惯) ```bash sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak ``` 2. 查找并修改相关配置项: 在打开的配置文件中,找到以下两行配置,并进行相应修改 * PermitRootLogin: 默认情况下,这一项可能是PermitRootLogin prohibit-password,表示禁止Root用户通过密码登录。将其修改为PermitRootLogin yes,以允许Root用户通过密码登录。 ``` PermitRootLogin yes ``` * PasswordAuthentication: 确保这一项设置为yes,以允许密码认证。 ``` PasswordAuthentication yes ``` 3. 重启SSH服务 ```shell sudo service ssh restart ``` **此时系统还没有安装ssh服务,无法使用XShell等工具进行ssh远程登录** ## apt源设置 * 备份编辑/etc/apt/sources.list * 设置国内阿里云镜像源[阿里镜像源](https://developer.aliyun.com/mirror/ubuntu?spm=a2c6h.13651102.0.0.3e221b11sVsbJg) ```shell apt-get update ``` ## 安装ssh服务 * ssh连接不上安装 ```shell # 没有内容说明没有ssh服务 ps -ef | grep ssh # 安装ssh openssh-server apt-get install openssh-server systemctl start ssh systemctl enable ssh ``` ## 安装docker 这里采用手动安装 [参考](https://www.cnblogs.com/acatsmiling/p/18474276) [docker官网安装文档](https://docs.docker.com/engine/install/binaries/) [下载安装二进制文件压缩包](https://download.docker.com/linux/static/stable/x86_64/)上传到/usr/local目录下 1. 解压 ```shell cd /usr/local tar xzvf /usr/local/docker.tgz ``` 2. 将二进制文件移动到可执行路径上的目录 ```shell cp docker/* /usr/bin/ ``` 3. 建立 Docker 的数据存放目录/home/data/docker ```shell mkdir -p /home/data/docker ``` 4. 创建 Docker 的 service 文件/etc/systemd/system/docker.service ``` [Unit] Description=Docker Application Container Engine Documentation=https://docs.docker.com After=network-online.target firewalld.service Wants=network-online.target [Service] Type=notify # the default is not to use systemd for cgroups because the delegate issues still # exists and systemd currently does not support the cgroup feature set required # for containers run by docker # docker可执行文件 ExecStart=/usr/bin/dockerd --data-root /home/data/docker ExecReload=/bin/kill -s HUP $MAINPID # Having non-zero Limit*s causes performance problems due to accounting overhead # in the kernel. We recommend using cgroups to do container-local accounting. LimitNOFILE=infinity LimitNPROC=infinity LimitCORE=infinity # Uncomment TasksMax if your systemd version supports it. # Only systemd 226 and above support this version. #TasksMax=infinity TimeoutStartSec=0 # set delegate yes so that systemd does not reset the cgroups of docker containers Delegate=yes # kill only the docker process, not all processes in the cgroup KillMode=process # restart the docker process if it exits prematurely Restart=on-failure StartLimitBurst=3 StartLimitInterval=60s [Install] WantedBy=multi-user.target ``` 5. 配置 Docker 的 /etc/docker/daemon.json ```json { "registry-mirrors": [ "https://d9fafc8e81bc44498235a34713053f3e.mirror.swr.myhuaweicloud.com" ] } ``` 6. 生效配置 ```shell systemctl daemon-reload systemctl start docker systemctl enable docker ``` 7. 验证 ```shell systemctl status docker docker info docker run hello-world ``` --- --- url: /src/technology/dateblog/2025/03/20250327-VmWare虚拟机桥接互ping不通.md --- # VMware虚拟机设置桥接和宿主机互ping不通 ## 解决步骤 ::: info * 尝试关闭主机和虚拟机防火墙 互相ping尝试 * VmWare 点击编辑 -- 虚拟网络编辑器 -- 更改设置,设置桥接模式网卡为当前宿主机网卡名称 ::: ## 如果宿主机不想关闭防火墙可以开机imcp(虚拟机未尝试) --- --- url: /src/technology/dateblog/2025/08/20250828-vue-中的受控与非受控组件.md --- # Vue 中的受控与非受控组件 --- --- url: /src/technology/dateblog/2025/06/20250618-vue-函数式组件使用场景与普通组件区别详解.md --- # Vue 函数式组件使用场景与普通组件区别详解 ## 函数式组件的核心概念 函数式组件是 Vue 中一种特殊类型的组件,它具有以下特点: * **无状态**:不维护自己的响应式数据 * **无实例**:没有 `this` 上下文 * **轻量级**:渲染开销比普通组件小 * **纯函数**:输出完全由输入(props)决定 ## 函数式组件与普通组件的区别 | 特性 | 普通组件 | 函数式组件 | |------|----------|------------| | **实例** | 有组件实例 (`this`) | 无实例 | | **状态管理** | 可以有 data、computed、watch | 无状态,仅依赖 props | | **生命周期** | 支持完整生命周期钩子 | 无生命周期钩子 | | **性能** | 相对较重 | 轻量高效 | | **使用场景** | 复杂交互、状态管理 | 纯展示、简单逻辑 | | **上下文访问** | 通过 `this` | 通过函数参数 (`context`) | | **模板语法** | 支持完整模板功能 | 仅支持渲染函数 | | **响应式系统** | 完全参与 | 不参与响应式系统 | ## 函数式组件的使用场景 ### 1. 高性能列表项 当渲染大量相似项时(如大型列表、表格单元格),函数式组件可显著提升性能。 ```vue ``` ### 2. 高阶组件(HOC) 创建包装组件来增强其他组件功能,而不添加额外状态。 ```javascript // 高阶组件示例 export default { functional: true, render(h, { props, children }) { return h( 'div', { class: 'border-wrapper' }, [ h(props.component, { ...props.attrs }, children) ] ) } } ``` ### 3. 条件渲染包装器 创建无状态的条件渲染容器。 ```vue ``` ### 4. 简单的 UI 组件 对于纯展示型的简单组件(如标签、徽章、图标)。 ```vue ``` ### 5. 动态组件选择器 根据 props 动态选择要渲染的组件。 ```javascript export default { functional: true, render(h, { props }) { const components = { primary: PrimaryButton, secondary: SecondaryButton, danger: DangerButton } return h(components[props.type] || 'button', props.attrs, props.slots) } } ``` ## 函数式组件的实现方式 ### Vue 2 实现方式 ```vue ``` ### Vue 3 实现方式 ```javascript import { h } from 'vue' const FunctionalComponent = (props, { slots, attrs }) => { return h('div', attrs, [ h('h1', props.title), slots.default() ]) } FunctionalComponent.props = ['title'] ``` ## 函数式组件的上下文对象 函数式组件的 `context` 参数包含以下属性: | 属性 | 描述 | |------|------| | `props` | 组件接收的 props | | `children` | VNode 子节点数组 | | `slots()` | 返回插槽对象的函数 | | `data` | 整个数据对象(包含 attrs, on 等) | | `parent` | 父组件引用 | | `listeners` | 事件监听器对象(Vue 2) | | `emit` | 触发事件方法(Vue 3) | | `attrs` | 非 prop 的 attribute | ## 性能对比演示 ```vue ``` ## 何时选择函数式组件 ### 使用函数式组件 * 纯展示型组件(无状态变化) * 需要渲染大量简单元素的场景 * 作为高阶组件包装器 * 需要最大化性能的场景 * 仅依赖 props 的简单逻辑组件 ### 使用普通组件 * 需要维护内部状态的组件 * 需要生命周期钩子的组件 * 包含复杂交互逻辑的组件 * 需要使用响应式数据(data, computed) * 需要组件实例引用的场景 * 使用作用域插槽的组件 ## 函数式组件的最佳实践 1. **命名规范**:使用 `Functional` 前缀(如 `FunctionalButton`) 2. **明确 props**:即使没有模板,也要声明 props 3. **避免副作用**:保持函数式组件的纯粹性 4. **适度使用**:仅在性能敏感区域使用 5. **文档说明**:在组件文档中注明是函数式组件 6. **单元测试**:针对 props 输入测试渲染输出 7. **组合使用**:与普通组件配合使用,而不是完全替代 ## Vue 3 中的变化 在 Vue 3 中,函数式组件的行为有所变化: * 不再需要 `functional` 选项 * 所有函数式组件都是普通函数 * 性能差距缩小(普通组件也进行了优化) * 支持通过 `defineComponent` 定义类型 * 更好的 TypeScript 支持 ```typescript import { defineComponent, h } from 'vue' const FunctionalButton = defineComponent({ props: { type: { type: String, default: 'primary' } }, setup(props, { slots }) { return () => h('button', { class: ['btn', `btn-${props.type}`] }, slots.default?.()) } }) ``` ## 总结 函数式组件是 Vue 性能优化工具箱中的重要工具,特别适合渲染大量简单元素或创建无状态的高阶组件。它们通过牺牲组件实例和状态管理能力来换取更轻量的渲染开销。 在实际项目中: * **优先使用普通组件**:满足大多数需求 * **在性能关键路径使用函数式组件**:如大型列表、表格单元格 * **避免过度优化**:只在性能测试表明需要时使用 理解函数式组件与普通组件的区别,能帮助你在 Vue 开发中做出更合适的架构决策,在保持代码可维护性的同时优化应用性能。 --- --- url: >- /src/technology/dateblog/2025/04/20250418-vue2-element-ui-el-dialog-命令式开窗dialog.md --- # Vue2 element-ui el-dialog 命令式开窗Dialog [vue3 写法](/src/technology/dateblog/2025/04/20250418-vue3-dialog-封装命令式dialog组件.html) ## 文件结构 ``` |-base.vue // 基础组件 |-index.js // 命令式js函数封装 |-input-content.vue // 内容组件 |-use-example.js //使用示例js ``` ## 封装基础组件 :::code-group ```vue [base.vue] ``` ::: :::tip ```vue ``` 约定内容组件抛出事件名称固定为emit ::: ## 编写命令式js方法 :::code-group ```js [index.js] import Vue from 'vue'; import Main from './base.vue' const Constructor = Vue.extend(Main) /** * option 可传入回调方法名称 * @param option * @param {Object} option.dialogOption -- el-dialog属性配置 * @param {Object} option.dialogContent -- 内容组件 * @param {Object} [option.contentOption] -- 内容组件属性配置 */ export function FunctionDialog(option) { let { dialogOption = {}, dialogContent, contentOption = {}, } = option; dialogOption = {...{width: '58%', title: '开窗', showClose: false}, ...dialogOption} function destroy() { document.body.removeChild(instance.$el) instance.$destroy(); } const instance = new Constructor({ propsData: { dialogOption, dialogContent, contentOption, handleEmit: (emit) => { const {name, data, callFunc} = emit; option?.[name]?.(data, callFunc, name); }, beforeClose: () => { destroy() }, }, }); instance.$mount(); document.body.appendChild(instance.$el); return destroy; } ``` ::: ## 使用示例 ### 编写内容组件 :::code-group ```vue [input-content.vue] ``` ::: ### 使用 ::: code-group ```js [use-example.js] import {FunctionDialog} from './index.js' import InputContent from './input-content.vue' const destroy = FunctionDialog({ dialogContent: InputContent, dialogOption: { title: '输入文字input', width: '400px' }, contentOption: { placeholder: '内容组件的placeholder' }, // 此处自己写内容组件中emitFunc的name方法名称 confirm: (data, callFunc, name) => { // 打印InputContent的input字符串 console.log('data', data) // 调用回调函数 callFunc() // 卸载命令式dialog destroy() }, cancel: (data, callFunc, name) => { destroy() } }) ``` ::: :::tip 这种方式使用命令式弹窗,使用起来方便快捷,复用起来非常简便 包括el-drawer等其它vue2组件库的组件都可以类似封装为命令式函数使用 ::: --- --- url: /src/technology/dateblog/2025/06/20250610-vue2-7-jsx使用.md --- # vue2.7 jsx使用 [vue2 jsx插件文档](https://github.com/vuejs/jsx-vue2#installation) ## 自定义指令 用原生vnode的数据格式使用自定义指令: 注意属性名一定是directives!!!不能改! [#深入数据对象](https://v2.cn.vuejs.org/v2/guide/render-function.html#%E6%B7%B1%E5%85%A5%E6%95%B0%E6%8D%AE%E5%AF%B9%E8%B1%A1) ```js // 格式 const directives = [ { name: 'loading', value: true, modifiers: {fullscreen: false, lock: false} } ] return
``` --- --- url: /src/technology/dateblog/2025/04/20250418-vue3-dialog-封装命令式dialog组件.md --- # Vue3 dialog 封装命令式Dialog组件 [参考vue2封装](/src/technology/dateblog/2025/04/20250418-vue2-element-ui-el-dialog-命令式开窗dialog.html) ```js import { createApp } from 'vue' import Main from './base.vue' /** * option 可传入回调方法名称 * @param option * @param {Object} option.dialogOption -- dialog属性配置 * @param {Object} option.dialogContent -- 内容组件 * @param {Object} [option.contentOption] -- 内容组件属性配置 */ export function FunctionDialog(option) { const { dialogOption = {}, dialogContent, contentOption = {}, } = option; function destroy() { app.unmount() appBox.remove() close() } const appBox = document.createElement('div') document.body.appendChild(appBox) const app = createApp(Main, { closeable, slotContent, position, contentProps, contentComponent, handleBeforeClose: () => { setTimeout(() => destroy(), 200) return true }, handleContentComponentEmit: emitData => { const { name, data, callFunc } = emitData options?.[name]?.(data, callFunc, name) destroy() } }) app.mount(appBox); return destroy } ``` :::tip vue3 使用[createApp](https://cn.vuejs.org/api/application.html#createapp) 创建实例 ::: --- --- url: /src/technology/dateblog/2025/05/20250519-vue3-naive-ui加jsx的简单增删改查页面示例.md --- # vue3+naive ui+jsx的简单增删改查页面示例 ## 摘要 * vue3 * naive ui * jsx * flex布局 table数据flex:1占满剩余空间 ```vue ``` --- --- url: >- /src/technology/dateblog/2025/04/20250425-vue3-naive-ui-jsx函数命令式文件监听复制文件上传组件示例.md --- # vue3+naive-ui+jsx函数/命令式文件监听复制文件上传组件示例 ## 支持功能 * 支持复制粘贴文件上传 * 支持拖拽 * 打开文件管理器点击上传 ## 函数式组件代码 ```jsx import {ArchiveOutline} from '@vicons/ionicons5' import {ref} from "vue"; import _ from 'lodash-es' import {v4 as uuidv4} from 'uuid' /** * 文件上传组件 * @param {Object} options - 配置选项 * @param {Object} options.modal - naive-ui modal实例 * @param {Object} options.loadingBar - naive-ui loadingBar实例 * @param {Object} [options.multiple] - 默认false 是否支持多个文件 多选 * @param {Object} [options.max] - naive-ui 默认undefined(不做限制) 限制上传文件数量 * @param {Function} options.uploadFunc - 上传方法,点击上传后会传入文件信息file * @param {Object} [options.emits] - 事件发射器 * @param {Function} [options.emits.uploadSuccess] - 事件发射器,上传成功防抖回调,会在所有文件上传后执行 * @param {Object} [options.otherNUploadProps] - 其它n-upload属性配置 ,通过v-bind绑定 * @returns {ModalReactive} */ export default (options) => { const {modal, loadingBar, emits, uploadFunc, multiple = false, max, otherNUploadProps = {}} = options const uploadContainer = ref(null) // 上传容器引用 const fileList = ref([]) // 文件列表 const uploadRef = ref(null) // 上传组件引用 let cleanupInterval = null // 清理定时器 /* 弹窗出现回调*/ async function onAfterEnter() { if (otherNUploadProps?.disabled === true) { return } // 初始化剪贴板监听 uploadContainer.value?.addEventListener('paste', handlePaste) uploadContainer.value?.focus() // 保持容器焦点(解决部分浏览器粘贴需要焦点的问题) cleanupInterval = setInterval(() => { uploadContainer.value?.focus() }, 500) } function onBeforeLeave() { // 资源清理 clearInterval(cleanupInterval) uploadContainer.value?.removeEventListener('paste', handlePaste) } // 处理复制事件 function handlePaste(event) { console.log('handlePaste') // 处理剪贴板粘贴事件 const clipboardData = event.clipboardData if (!clipboardData) { return } const newFiles = Array.from(clipboardData.items) .filter(item => item.kind === 'file') .map((item, index) => { const file = item.getAsFile(); // 创建标准化文件对象 const uuid = uuidv4().substring(0, 8) return { batchId: uuid, file, fullPath: '', id: uuid, name: `${uuid}_${file.name}`, percentage: 0, status: 'pending', thumbnailUri: null, type: file.type, url: null } }) if (newFiles.length > 0) { event.preventDefault() fileList.value = [...fileList.value, ...newFiles] } } /* 上传逻辑 */ async function customUpload(options) { try { await uploadFunc(options) handleUploadComplete() } catch (error) { loadingBar.error() } } function handleUploadComplete() { try { $message.success('上传成功') resetUploadState() notifyUploadSuccess() } finally { loadingBar.finish() } } function resetUploadState() { // 重置上传状态 uploadRef.value?.clear() fileList.value = [] } const notifyUploadSuccess = _.debounce(() => { emits?.uploadSuccess?.() }, 1000) /* 渲染模态框 */ const instance = modal.create({ title: '上传文件', preset: 'card', style: {width: '600px', padding: '1rem'}, onAfterEnter, onBeforeLeave, maskClosable: false, content: () => (
{ // options 包含之前的文件和本次上传的文件 fileList.value = options.fileList }} directory-dnd >
复制 | 点击 | 拖动文件到该区域来上传
uploadRef.value?.submit()} > 上传
) }) return instance } ``` ## 使用示例 ```js // 点击上传文件 function handleClickUpload() { clearSelectFile() const instance = funcUploadFile({ modal, loadingBar, multiple: true, async uploadFunc(options) { console.log('options', options) const formData = new FormData() formData.append('file', options.file.file); const filename = currentFolder.value ? `${currentFolder.value}/${options.file.name}` : options.file.name; formData.append('filename', filename); const res = await request.loadingRequest({ loadingBar, method: 'post', url: '/upload', data: formData, }); console.log('res', res) return res }, emits: { uploadSuccess() { instance.destroy() getList() } }, }) } ``` --- --- url: >- /src/technology/dateblog/2025/05/20250521-vue3中jsx中使用withmodifiers阻止子元素点击事件冒泡到父元素.md --- # vue3中jsx中使用withModifiers阻止子元素点击事件冒泡到父元素 **[withModifiers](https://cn.vuejs.org/api/render-function.html#withmodifiers)** 用于向事件处理函数添加内置 v-on 修饰符。 类型 ```jsx const columns = [ { title: '文件名', key: 'name', render: (row) => { return
handleClickName(row)} style="cursor: pointer;width: 100%" class="fx al-ct ju-sb fx-gp02 mouse-active-show-container">
{readerFileIcon(row)} {row.name}
{ row.type === 'folder' ? handleClickDeleteFolder(row), ['stop'])}/> : null }
} } ] ``` --- --- url: /src/technology/dateblog/2025/04/20250408-vue3使用watch监听多个参数.md --- # Vue3使用Watch监听多个参数 watch监听多个响应式变量写法 ```js const test1 = ref(1) const test2 = ref(2) watch(() => [test1.value, test2.value], ([new1, new2], [old1, old2]) => { }) ``` --- --- url: /src/technology/dateblog/2025/06/20250618-vue3加naive-ui-cdn-示例.md --- # Vue3加Naive UI CDN 示例 通过CDN在HTML中使用Vue 3和Naive UI,无需任何构建步骤 ```html Vue3加Naive UI CDN 示例
``` --- --- url: /src/technology/dateblog/2025/07/20250710-vue中watch新值和旧值的有效性分支结构.md --- # vue中watch新值和旧值的有效性分支结构 ## 完整分支结构: ```javascript const watchFn = (val, oldV) => { if (val === oldV) { // 值未变化 if (val) { // 值A -> 值A (真值不变,如 true → true) } else { // 假值不变 (如 null → null) } } if (val !== oldV) { // 值发生变化 if (val && oldV) { // 有效值A -> 有效值B (如 1 → 2) } else if (val && !oldV) { // 假值 → 真值 (初始化或状态翻转,如 null → 42) } else if (!val && oldV) { // 真值 → 假值 (状态清除,如 42 → null) } else { // 假值A → 假值B (如 0 → "" 或 undefined → null) } } }; ``` ## 使用场景总结: | 变化类型 | 示例 | 典型场景 | |-------------------|-------------------|----------------------------| | 真值 → 相同真值 | `true` → `true` | 相同对象/数组引用更新 | | 假值 → 相同假值 | `null` → `null` | 空状态保持 | | 真值A → 真值B | `1` → `2` | 有效值更新 | | 假值 → 真值 | `null` → `42` | 初始化/重新赋值 | | 真值 → 假值 | `42` → `null` | 清除状态 | | 假值A → 假值B | `0` → `""` | 空状态类型转换 | --- --- url: /src/technology/dateblog/2025/10/20251009-vue二次封装防止按钮重复点击组件.md --- # vue二次封装防止按钮重复点击组件 ## 二次封装代码 RepeatButton.vue ```vue ``` ## 使用示例 ```vue ``` --- --- url: /src/technology/dateblog/2025/08/20250829-vue数据选择组件设计分析.md --- # Vue数据选择组件设计分析 ## 多选 ### 事件触发时机分析 * `select`:当用户选择(增加)选项时触发 * `change`:任何选择变化时触发(增加或减少) * `clear`:点击清空按钮时触发 ### 事件触发时机说明 根据需求,组件的事件触发时机如下: 1. **select事件**: * 当用户选择(增加)一个选项时触发 * 传递被选择的选项作为参数 2. **change事件**: * 任何选择变化时触发(增加或减少选项) * 减少到零时也会触发 * 传递当前已选择的数组作为参数 3. **clear事件**: * 点击清空按钮时触发 * 不传递参数 ## 单选 ### 事件触发时机分析 对于单选数据选择组件,事件触发时机如下: 1. **select事件**: * 当用户选择一个选项时触发(无论之前是否已选择其他选项) * 传递当前选择的选项作为参数 2. **change事件**: * 当选择的值发生变化时触发(从无到有、从有到无、从一个值变为另一个值) * 如果选择相同的值,则不触发change事件 * 传递当前选择的值作为参数(未选择时为null) 3. **clear事件**: * 点击清空按钮时触发 * 不传递参数 ### 事件触发时机详细说明 1. **select事件** * 当用户选择一个选项时触发(无论之前是否已选择) * 即使选择相同的选项也会触发 * 传递当前选择的选项作为参数 2. **change事件** * 当选择的值发生变化时触发 * 从无到有:从未选择状态到选择某个选项 * 从有到无:从选择某个选项到清空选择 * 从一个值变为另一个值:选择不同的选项 * 选择相同的选项不会触发change事件 * 传递当前选择的值作为参数(未选择时为null) 3. **clear事件** * 点击清空按钮时触发 * 不传递参数 --- --- url: /src/technology/dateblog/2025/06/20250618-vue模板中渲染vnode.md --- # Vue模板中渲染VNode [参考stackoverflow文章](https://stackoverflow.com/questions/49352525/can-you-render-vnodes-in-a-vue-template) ## vue2 ```vue ``` ## vue3 ### 单个VNode ```vue ``` ### 多个VNode ```vue ``` --- --- url: /src/technology/dateblog/2025/03/20250327-Vue配置keep-alive缓存不生效.md --- ## 问题描述说明 ``` 1、vue keep-alive include 设置了路由名称 缓存 不生效 ``` ## 原因分析 ``` 1、路由组件名称和页面名称不一致 ``` ## 解决方案思路 ::: info * keep-alive的include缓存页面设置好 * 保持路由组件name名称和页面名称一致 ::: ::: info vue2设置name选项,vue3设置defineOptions中的name ::: --- --- url: /src/technology/dateblog/2026/04/20260406-windows-启动目录详解:路径、区别与快速访问技巧.md --- ## #开机自启#windows开机启动#exe开机启动 在日常使用 Windows 系统时,我们常常需要设置某些程序开机自动启动。了解启动目录(Startup)的位置,是高效管理开机项的关键。本文将为你详细介绍系统启动目录与用户启动目录的区别,并提供快速访问的方法。 ## 一、两种启动目录 Windows 系统中存在两个启动目录,分别控制不同范围的自动启动行为。 ### 1. 系统启动目录(对所有用户生效) 将程序的快捷方式放入此目录后,**任何用户登录该计算机时**,该程序都会自动启动。 **路径:** ``` C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp ``` > 注意:`ProgramData` 文件夹默认是隐藏的,你可以在文件资源管理器的“查看”选项卡中勾选“隐藏的项目”来显示它。 ### 2. 用户启动目录(仅对当前用户生效) 放入此目录的快捷方式,**只会在当前登录用户启动时运行**,不会影响其他用户。 **路径:** ``` C:\Users\你的用户名\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup ``` 其中“你的用户名”需要替换为实际登录的用户名。 ## 二、如何快速打开启动目录 不需要逐级点击文件夹,Windows 提供了快捷命令: 1. 按下键盘上的 **`Win + R`** 组合键,打开“运行”对话框。 2. 输入以下命令之一,然后回车: * **打开系统启动目录**:`shell:Common Startup` * **打开当前用户的启动目录**:`shell:Startup` 系统会立刻打开对应的文件夹,方便你直接添加或删除快捷方式。 ## 三、使用建议与注意事项 * **添加开机启动项**:只需将程序的快捷方式(`.lnk` 文件)复制到上述任一启动目录即可。 * **删除开机启动项**:从启动目录中删除对应的快捷方式即可阻止其开机运行。 * **不建议放置过多程序**:太多开机启动项会显著拖慢系统启动速度和运行性能。 * **优先使用用户启动目录**:如果是个人使用的程序,建议放在用户启动目录中,避免影响其他用户或造成不必要的系统权限问题。 ## 四、扩展方法:通过任务管理器管理启动项 除了启动目录,Windows 也提供了更现代化的管理界面: * 按 `Ctrl + Shift + Esc` 打开**任务管理器**。 * 切换到“启动”选项卡,可以在这里启用/禁用启动项,也能看到每个启动项对启动速度的影响评估。 不过需要注意的是,任务管理器中显示的启动项并不仅限于启动目录中的内容,还包括注册表等其他位置的启动配置。 ## 结语 掌握启动目录的位置和管理方法,可以让你更灵活地控制 Windows 的开机自动运行程序。无论是为了提高开机速度,还是设置常用工具自动启动,这都是一项实用的小技巧。 *** --- --- url: /src/technology/dateblog/2025/06/20250618-windows修改hosts文件.md --- # windows修改hosts文件 ## 文件路径 ``` C:\Windows\System32\drivers\etc ``` ## 示例 ``` 127.0.0.1 www.facebook.com ``` ## 刷新 DNS 缓存 ``` ipconfig /flushdns ``` --- --- url: /src/technology/dateblog/2025/06/20250604-windows和linux中根据端口杀死进程的方法.md --- # windows和linux中根据端口杀死进程的方法 ## Windows 系统根据端口号终止进程的方法 ### 命令行工具(CMD/PowerShell) 1. **查找占用端口的进程ID(PID)** * 打开命令提示符(CMD)或 PowerShell: * 按 `Win + R`,输入 `cmd` 或 `powershell`,按回车。 * 输入以下命令(以端口号 `8080` 为例): ```bash netstat -ano | findstr "8080" ``` * 输出示例:`TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 1234` * 最后一列 `1234` 即为占用端口的进程PID。 2. **终止进程** * 使用 `taskkill` 命令强制终止进程: ```bash taskkill /F /PID 1234 ``` * `/F` 表示强制终止。 ## Linux 系统根据端口号终止进程的方法 ### 方法一:使用 `lsof` 命令 1. **查找占用端口的进程ID(PID)** ```bash sudo lsof -i :8080 ``` * 输出示例:`COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME` * 第二列 `PID` 即为目标进程ID。 2. **终止进程** * 优先使用默认信号终止(允许进程清理资源): ```bash sudo kill ``` * 若进程无响应,强制终止: ```bash sudo kill -9 ``` ### 方法二:使用 `netstat` 或 `ss` 命令 1. **查找PID** * 使用 `netstat`: ```bash sudo netstat -tulnp | grep :8080 ``` * 或使用更高效的 `ss`: ```bash sudo ss -tulnp | grep :8080 ``` * 输出示例:`tcp LISTEN 0 128 *:8080 *:* users:(("java",pid=1234,fd=45))` * `pid=1234` 即为进程ID。 2. **终止进程** ```bash sudo kill # 默认终止 sudo kill -9 # 强制终止 ``` ### 方法三:直接通过端口号终止进程(无需手动查PID) * 使用 `fuser` 命令: ```bash sudo fuser -k 8080/tcp ``` * `-k` 表示直接终止占用端口的进程。 *** ## 注意事项 1. **权限问题**: * Linux 中可能需要 `sudo` 权限才能查看或终止其他用户的进程。 * Windows 中建议以管理员身份运行CMD/PowerShell。 2. **强制终止的风险**: * `kill -9` 或 `taskkill /F` 会立即终止进程,可能导致数据丢失。优先使用默认终止方式。 3. **验证进程是否终止**: * 重新运行查找命令(如 `lsof -i :8080`),若无输出则表示进程已终止。 --- --- url: /src/technology/dateblog/2025/06/20250617-windows多版本java切换方案(使用jenv).md --- # windows多版本java切换方案(使用JEnv) [github地址](https://github.com/FelixSelter/JEnv-for-Windows) ## 下载解压后在环境变量path配置jenv ## 管理员身份运行cmd 执行 ```bat jenv.bat ``` ## 相关命令 安装多个jdk版本,推荐压缩包形式 ```shell # 添加jdk版本 jenv add jdk16 D:\java17 jenv add jdk8 C:\D\dev\jdk1.8.0_451 # 查看所有的java版本 jenv list # 改变jdk版本 jenv change jdk8 # 通过这种方式来进行修改java的版本。 # 然后通过java -version来进行验证。 # 这种change是直接修改了全局版本。 # 还有其他的操作在GitHub上面有其他的,这里就不进行赘述了。 ``` ## 注意 * 先要配置JAVA\_HOME环境变量(先指向一个默认jdk) * 再通过jenv添加和设置jdk --- --- url: /src/technology/dateblog/2025/05/20250527-三个脚本的实现启动、停止、重启一个可执行程序,并记录其pid.md --- # 三个脚本的实现启动、停止、重启一个可执行程序,并记录其PID [脚本下载地址](https://tunnel.atshang.top/oss/2026%2F03%2Fscript.zip) ## 示例目录结构 ```markdown dir/ ├── app.linux # 可执行文件 ├── conf/ │ ├── config.yml ├── start.sh ├── stop.sh ├── restart.sh ├── app.log # 运行脚本自动生成 程序运行时输入日志的记录到这里 └── app.pid # 运行脚本自动生成 进程pid ``` ## 1. `start.sh` ```bash #!/bin/bash # 获取脚本所在目录的绝对路径 __DIRNAME=$(dirname "$(readlink -f "$0")") APP_NAME="app名称" START_COMMAND="${__DIRNAME}/app.linux" # 仅包含命令路径 ARGS="run ${__DIRNAME}/conf/config.yml" # 参数单独保存 PID_FILE="${__DIRNAME}/app.pid" LOG_FILE="${__DIRNAME}/app.log" # 检查是否已经运行 if [ -f "$PID_FILE" ]; then PID=$(cat "$PID_FILE") if kill -0 "$PID" >/dev/null 2>&1; then echo "程序 $APP_NAME 已经在运行 (PID: $PID)" exit 1 else echo "发现陈旧的 PID 文件,清理后启动..." rm -f "$PID_FILE" fi fi # 启动程序并记录 PID 输入日志到LOG_FILE nohup $START_COMMAND $ARGS > "$LOG_FILE" 2>&1 & echo $! > "$PID_FILE" echo "程序 $APP_NAME 已启动 (PID: $!)" ``` ## 2. `stop.sh` * 停止时检查现在pid实际运行命令是否包含`start.sh`中的启动命令 ```bash #!/bin/bash # 获取脚本所在目录的绝对路径 __DIRNAME=$(dirname "$(readlink -f "$0")") APP_NAME="app名称" PID_FILE="${__DIRNAME}/app.pid" # 关键:匹配进程实际运行的预期包含命令(示例中匹配 "app.linux" 关键字) EXPECTED_CMD="app.linux" # 检查 PID 文件是否存在 if [ ! -f "$PID_FILE" ]; then echo "错误:PID 文件不存在,程序可能未运行" exit 1 fi # 读取 PID 并检查有效性 PID=$(cat "$PID_FILE") if [ -z "$PID" ]; then echo "错误:PID 文件内容为空,清理并退出" rm -f "$PID_FILE" exit 1 fi # 检查 PID 是否存在 if ! kill -0 "$PID" >/dev/null 2>&1; then echo "警告:进程 $PID 不存在,清理 PID 文件" rm -f "$PID_FILE" exit 1 fi # 关键验证:检查 PID 对应的进程是否是目标程序 PROCESS_CMD=$(ps -p "$PID" -o cmd=) # 获取进程完整命令行 if [[ "$PROCESS_CMD" != *"$EXPECTED_CMD"* ]]; then echo "错误:PID $PID 不属于 $APP_NAME,进程命令不包含:$EXPECTED_CMD,实际进程命令为:$PROCESS_CMD" exit 1 fi # 执行终止操作 kill "$PID" echo "程序 $APP_NAME (PID: $PID) 已终止" rm -f "$PID_FILE" ``` ## 3. `restart.sh` ```bash #!/bin/bash # 获取脚本所在目录的绝对路径 __DIRNAME=$(dirname "$(readlink -f "$0")") "${__DIRNAME}/stop.sh" sleep 1 # 可选:等待进程完全停止 "${__DIRNAME}/start.sh" ``` *** ### 使用方法: 1. 将上述脚本保存为 `start.sh`, `stop.sh`, `restart.sh` 2. 替换 `APP_NAME="myapp"` 为你的可执行程序名称 3. 赋予执行权限: ```bash chmod +x start.sh stop.sh restart.sh ``` 4. 启动程序: ```bash ./start.sh ``` 5. 停止程序: ```bash ./stop.sh ``` 6. 重启程序: ```bash ./restart.sh ``` *** ### 关键点说明: 1. **PID 文件**:脚本通过 `app.pid` 文件记录进程ID 2. **日志记录**:程序输出会重定向到 `app.log` 3. **防重复启动**:`start.sh` 会检查现有进程是否运行 4. **陈旧 PID 处理**:`stop.sh` 会自动清理无效的 PID 文件 5. **权限要求**:确保脚本和程序有可执行权限 6. **unix换行符**:确保脚本文件使用 Unix 换行符(LF),避免 Windows 换行符(CRLF)的问题 可以根据实际需求调整日志路径、等待时间等参数。 --- --- url: /src/technology/dateblog/2026/03/20260325-三种拷贝命令性能对比:50gb-场景下大文件-vs-海量小文件.md --- # 三种拷贝命令性能对比:50GB 场景下大文件 vs 海量小文件 本文对比 `cp -a`、`tar` 管道、`rsync -av` 三种方式在 50GB 总数据量下,处理**少量大文件**与**海量小文件**时的性能差异与优势。 所有命令均将 `/data/a/` 下所有文件(含隐藏文件)完整拷贝至 `/data/b/`。 *** ## 命令示例 ```bash # 1. cp -a cp -a /data/a/. /data/b/ # 2. tar 管道 tar -C /data/a -cf - . | tar -C /data/b -xpf - # 3. rsync -av rsync -av /data/a/ /data/b/ ``` *** ## 性能与速度对比(50GB 总数据量) ### 少量大文件(例如 5 个文件,每个 10GB) | 命令 | 耗时 | 特点 | |--------------|-----------|--------------------------------------------------------------| | **cp -a** | 基准最快 | 直接内核拷贝,顺序 I/O 效率最高,内存开销极低 | | **tar 管道** | 慢 5–10% | 打包→管道→解包增加额外 CPU 开销,但仍是顺序读写,差距不大 | | **rsync -av**| 慢 10–20% | 首次拷贝需分块校验与计算 checksum,略慢;后续增量同步优势明显 | **结论**:大文件场景下,`cp -a` 速度最佳,`rsync` 首次稍慢但适合重复同步。 *** ### 海量小文件(例如 50 万个文件,平均 100KB) | 命令 | 耗时 | 特点 | |--------------|------------|--------------------------------------------------------------| | **cp -a** | 慢 | 每个文件都要单独 open/close、更新元数据,频繁系统调用成为瓶颈 | | **tar 管道** | 快 30–50% | 一次性读取目录树并打包为流,减少元数据操作次数,管道直接解包 | | **rsync -av**| 中等 | 首次拷贝比 tar 慢(需对比每个文件),但增量同步时可跳过未改文件 | **结论**:小文件海量时,`tar` 管道由于合并了元数据操作,速度显著优于 `cp`;`rsync` 首次略慢,但若目录有大量未变化文件则后续极快。 *** ## 各自优势 | 命令 | 优势 | |--------------|--------------------------------------------------------------------------------| | **cp -a** | 简单、无需依赖;大文件场景速度最快;适合一次性本地全量拷贝 | | **tar 管道** | 小文件场景性能最优;不依赖额外工具;可通过 SSH 无缝转为远程传输(`ssh host tar ... \| tar ...`) | | **rsync -av**| 增量传输(节省时间与带宽);支持排除规则、断点续传、进度显示;本地/远程通用 | *** ## 使用建议 * **一次性拷贝,且为大文件** → `cp -a` * **一次性拷贝,海量小文件** → `tar` 管道 * **重复同步或需远程传输** → `rsync -av` 三种方式均能完整保留文件属性(权限、时间戳等)及隐藏文件。 --- --- url: /src/life/dateblog/2025/08/20250806-九月成都旅游.md --- # 九月成都旅游 ## 9.11(周四) * 22:10 杭州萧山机场 - 01:05 成都天府机场T2 * 下飞机打车到FUNX民宿春熙路店 ## 9.12(周五) * 早上7点川西团来接 * **跟团游玩** * 成都 -- 四姑娘山.双桥沟 - 丹巴 ## 9.13(周六) * **跟团游玩** * 丹巴 - 墨石公园 - 塔公公园 - 鱼子西 - 新都桥 ## 9.14(周日) * **跟团游玩** * 新都桥 - 高尔寺 - 434机场路 - 红海子 - 木格措景区 -- 成都 * 晚上住FUNX民宿春熙路店 ## 9.15(周一) * 12:50 成都天府机场T2 - (经停荆州45分钟) - 17:15杭州萧山机场T3 --- --- url: /src/randomwriting/dateblog/2025/04/20250424-五一假期杭州三天两夜计划.md --- # 五一假期杭州三天两夜计划(5.1 ~ 5.4) 5.1星期四 - 5.4 星期天 ## **Day 1** ### **上午:良渚古城遗址公园** **补充** * 良渚古城微信小程序预约60/人 * 允许参观时间:上午9点 到下午五点,做好防晒,戴帽子、伞, 景区有点大(观光车20/人,绕古城一圈,随上随下) * 路线:杭州地铁2号线良渚站D口转公交 || 打车 ### **下午:中国美术学院象山校区** **补充** * 微信搜索**微国美**公众号预约 ### **晚上:天目里创意园区** * **推荐理由**:现代艺术与商业融合的网红地标,有茑屋书店、艺术展览和独立设计师店铺。夜晚灯光下氛围浪漫,适合散步后体验园区内的沉浸式密室逃脱或VR游戏馆。 * **美食推荐**:园区内的“%Arabica”咖啡或“18号酒馆”的精酿啤酒搭配西餐。 ## **Day 2** ### **上午:西溪湿地** ### **下午:密室逃脱/剧本杀** ### **晚上:城市阳台灯光秀** * **推荐理由**:钱江新城的免费灯光秀(每周二、五、六晚七点半和八点半各一场,每场十分钟),结合音乐与光影,可提前占位江边餐厅,边用餐边欣赏夜景。 ## **Day 3** ### **上午:杭州动物园** **补充** * 门票20/人 ### **下午:DIY蛋糕体验** ## **Day 4:休息 回城** ### 上午 * 休息 ### 下午 * 16:04 临平南站 - 16:33 嘉善南站 * 16:50 嘉善南站 222公交 --- --- url: /src/technology/dateblog/2025/05/20250526-使用alg监控本地文件中的日志示例.md --- # 使用ALG收集监控日志文件示例 [ALG(Alloy + Loki + Grafana)现代轻量级的日志管理解决方案](https://shangjundragon.netlify.app/src/technology/dateblog/2025/05/20250526-ALG%EF%BC%88Alloy-Loki-Grafana%EF%BC%89%E7%8E%B0%E4%BB%A3%E8%BD%BB%E9%87%8F%E7%BA%A7%E7%9A%84%E6%97%A5%E5%BF%97%E7%AE%A1%E7%90%86%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88.html) ## Alloy配置文件示例 * 配置语法: * 可以配置多组 * VsCode 配置文件搜索 Grafana Alloy 插件 ```text config.alloy ``` ``` local.file_match "local_files" { path_targets = [ { "__path__" = "/usr/crm/logs/*.log", "host" = "10.1.56.207", "app" = "crm", "env" = "prod", }, { "__path__" = "/usr/oa/logs/*.log", "host" = "10.1.56.207", "app" = "oa", "env" = "prod", }, ] sync_period = "5s" } loki.source.file "log_scrape" { targets = local.file_match.local_files.targets forward_to = [loki.write.grafana_loki.receiver] tail_from_end = true } loki.write "grafana_loki" { endpoint { url = "http://loki:3100/loki/api/v1/push" // basic_auth { // username = "admin" // password = "admin" // } } } ``` ## Alloy配置文件讲解说明 ### 配置文件解析 #### 1. 文件目标定义 (local.file\_match) ``` local.file_match "local_files" { path_targets = [ { "__path__" = "/usr/crm/logs/*.log", "host" = "10.1.56.207", "app" = "crm", "env" = "prod", }, { "__path__" = "/usr/oa/logs/*.log", "host" = "10.1.56.207", "app" = "oa", "env" = "prod", }, ] sync_period = "5s" } ``` * **作用**:动态发现需要监控的日志文件 * **关键配置项**: * `path_targets`:定义文件路径和标签 * `__path__`:使用通配符匹配日志文件(支持`*`和`**`递归匹配) * `host`、 `app`、 `env`:自定义设置日志来源标识(Loki查询时的重要标签) * `sync_period`:文件系统同步间隔(默认30s) #### 2. 日志收集配置 (loki.source.file) ``` loki.source.file "log_scrape" { targets = local.file_match.local_files.targets forward_to = [loki.write.grafana_loki.receiver] tail_from_end = true } ``` * **作用**:持续跟踪并收集日志文件内容 * **关键配置项**: * `targets`:引用前边定义的文件匹配结果 * `forward_to`:指定日志处理器(可多个) * `tail_from_end`:控制在没有找到已存储的读取位置(positions file)时,是否从日志文件的末尾开始读取日志 * `true`:组件会忽略日志文件中已有的内容,只读取新追加的日志行。这对于处理大型日志文件时非常有用,可以避免一开始就读取全部历史内容,只关注后续新增的日志 * `false`(默认值):组件会从文件的开头开始读取所有内容,包括历史日志。 #### 3. Loki写入配置 (loki.write) ``` loki.write "grafana_loki" { endpoint { url = "http://loki:3100/loki/api/v1/push" // basic_auth { // username = "admin" // password = "admin" // } } } ``` * **作用**:配置日志存储后端 * **关键配置项**: * `url`:Loki服务地址(支持HTTPS) * `basic_auth`:基础认证配置(按需启用) ### 配置注意事项 1. **路径匹配**: * `*.log` 匹配同级目录所有.log文件 * `**/logs/` 递归匹配所有子目录中的logs目录 * 使用绝对路径保证可靠性 2. **标签设计**: * 最少但具有区分度的标签组合 * 避免高频变化的标签值(如时间戳) * 推荐标签:job, env, team, cluster 3. **性能调优**: * 生产环境建议 `sync_period` ≥ 1m * 大日志文件(>100MB)建议增加 `read_buffer_size` * 高负载时调整 `max_concurrent_tails` 4. **安全配置**: ``` endpoint { url = "https://loki.secure.com" bearer_token = "xxxxxx" tls_config { ca_file = "/path/to/ca.crt" insecure_skip_verify = false } } ``` ## Loki配置文件示例 ```text loki-config.yaml ``` ```yaml # auth_enabled: false # 禁用认证模块(生产环境不建议关闭) auth_enabled: false # 服务端配置 server: http_listen_port: 3100 # HTTP API 监听端口(查询/写入接口) grpc_listen_port: 9096 # gRPC 通信端口(组件间通信) log_level: info # 服务日志级别 grpc_server_max_concurrent_streams: 1000 # gRPC 最大并发流数 # 公共配置 common: instance_addr: 127.0.0.1 # 实例对外地址(集群部署需改为实际IP) path_prefix: /home/loki/data # 数据存储根目录 storage: filesystem: chunks_directory: /home/loki/data/chunks # 块数据存储路径 rules_directory: /home/loki/data/rules # 告警规则存储路径 replication_factor: 1 # 数据副本数(单机模式为1) ring: # 一致性哈希环配置 kvstore: store: inmemory # 使用内存存储哈希环(集群需改为 consul 等) # 查询范围缓存 query_range: results_cache: cache: embedded_cache: # 嵌入式查询结果缓存 enabled: true # 启用缓存 max_size_mb: 100 # 最大缓存容量 100MB # 运行时限制配置 limits_config: metric_aggregation_enabled: true # 启用指标聚合 allow_structured_metadata: true # 允许结构化元数据 volume_enabled: true # 启用日志体积统计 retention_period: 720h # 日志保留周期(30天) # 存储架构配置 schema_config: configs: - from: 2020-10-24 # 配置生效起始时间 store: tsdb # 使用 TSDB 存储引擎(v2.4+) object_store: filesystem # 对象存储类型(本地文件系统) schema: v13 # 架构版本 index: prefix: index_ # 索引文件前缀 period: 24h # 索引分片周期 # 模式分析模块 pattern_ingester: enabled: true # 启用日志模式分析功能 metric_aggregation: loki_address: localhost:3100 # 指标聚合服务地址 # 告警规则引擎 ruler: enable_alertmanager_discovery: true # 自动发现 Alertmanager enable_api: true # 启用规则管理 API # 查询前端配置 frontend: encoding: protobuf # 查询结果使用 Protobuf 编码 # 压缩与清理 compactor: working_directory: /home/loki/data/retention # 清理工作目录 delete_request_store: filesystem # 删除请求存储方式 retention_enabled: true # 启用自动数据清理 ``` ## docker-compose.yml ```text docker-compose.yml ``` ```yaml services: alloy: image: grafana/alloy:v1.7.5 ports: - 12345:12345 - 4317:4317 - 4318:4318 volumes: - ./alloy-data:/var/lib/alloy/data - ./config.alloy:/etc/alloy/config.alloy - /usr/local/app/logs:/usr/local/app/logs # 宿主机要监听的日志挂载到alloy容器中 command: run --server.http.listen-addr=0.0.0.0:12345 --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy depends_on: - loki networks: - loki loki: image: grafana/loki:3.4.2 ports: - "3100:3100" volumes: - ./loki-config.yaml:/etc/loki/local-config.yaml # 挂载loki数据 - ./loki-data:/home/loki/data command: -config.file=/etc/loki/local-config.yaml privileged: true networks: - loki grafana: image: grafana/grafana:11.6.0 environment: # 允许匿名访问并设置管理员权限 # - GF_FEATURE_TOGGLES_ENABLE=grafanaManagedRecordingRules # - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin # - GF_AUTH_ANONYMOUS_ENABLED=true # - GF_AUTH_BASIC_ENABLED=false # 设置grafana的管理员账号和密码 - GF_SECURITY_ADMIN_USER=admin - GF_SECURITY_ADMIN_PASSWORD=YourStrongPassword123! - GF_AUTH_ANONYMOUS_ENABLED=false # 禁用匿名访问 ports: - 3000:3000/tcp volumes: - ./grafana.ini:/etc/grafana/grafana.ini - ./grafana-data:/var/lib/grafana entrypoint: - sh - -euc - | mkdir -p /etc/grafana/provisioning/datasources cat < /etc/grafana/provisioning/datasources/ds.yaml apiVersion: 1 datasources: - name: Loki type: loki access: proxy orgId: 1 url: 'http://loki:3100' basicAuth: false isDefault: true version: 1 editable: true EOF /run.sh networks: - loki networks: loki: ``` ## 注意事项 * 提前创建alloy-data、loki-data、grafana-data目录并赋予777权限 ## 启动服务 ```bash docker-compose up -d ``` ## 访问grafana - `Explore` -> `选择数据源` -> `Label browser` -> 输入 `app=crm` -> 点击 `Run query` 即可查看到日志信息 --- --- url: /src/technology/dateblog/2025/04/20250418-使用html-audio面向对象方式播放音频简单示例.md --- # 使用html audio面向对象方式播放音频简单示例 [MDN Audio类型 ](https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLAudioElement/Audio) [MDN html audio标签](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Reference/Elements/audio) ## html代码示例 ```html 音频播放器
0:00
0:00
``` ## js Audio类型介绍 JavaScript中的`Audio`对象是HTML5中引入的一个非常有用的API,它允许开发者在网页中嵌入和控制音频播放。`Audio`对象可以通过创建一个新的实例来使用,或者通过获取页面上已有的`