# 司机带你开车 > 技术无国界, 折腾无止境 ## Article List ## [pev-init](https://blog.dong4j.site/posts/13e613ba.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 ## 安装完成后 IP 不对 启动后出现了一张 `vmbr0` 网卡, 这个是桥接网卡, 可以查看绑定的物理网卡: ```bash ip link show master vmbr0 ``` 修改一下 3 个文件: ```bash # /etc/network/interfaces auto lo iface lo inet loopback iface enp92s0 inet manual auto vmbr0 iface vmbr0 inet static address 192.168.100.100/24 gateway 192.168.100.1 bridge-ports enp92s0 bridge-stp off bridge-fd 0 # /etc/issue Welcome to the Proxmox Virtual Environment. Please use your web browser to configure this server - connect to: https://192.168.100.100:8006/ # /etc/hosts 127.0.0.1 localhost.localdomain localhost 192.168.100.100 nuc.ihome nuc ``` **参考:** - https://www.zhihu.com/tardis/zm/art/492484833?source_id=1003 ## 更换源 ```bash sudo apt install apt-transport-https ca-certificates wget https://mirrors.ustc.edu.cn/proxmox/debian/proxmox-release-bookworm.gpg -O /etc/apt/trusted.gpg.d/proxmox-release-bookworm.gpg ``` ### 通用软件源 ```bash cp /etc/apt/sources.list /etc/apt/sources.list.bak vim /etc/apt/sources.list ``` ```bash # 默认注释了源码镜像以提高 apt update 速度,如有需要可自行取消注释 deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib non-free-firmware # deb-src https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib non-free-firmware deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib non-free-firmware # deb-src https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib non-free-firmware deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib non-free-firmware # deb-src https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib non-free-firmware deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free-firmware # deb-src https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free-firmware ``` ### pve 软件源 ```bash cp /etc/apt/sources.list.d/pve-enterprise.list /etc/apt/sources.list.d/pve-enterprise.list.bak # 清空 /etc/apt/sources.list.d/pve-enterprise.list ``` ```bash # 使用Proxmox非企业版源 echo "deb https://mirrors.ustc.edu.cn/proxmox/debian bookworm pve-no-subscription" > /etc/apt/sources.list.d/pve-no-subscription.list ``` ### Ceph 源 ```bash cp /etc/apt/sources.list.d/ceph.list /etc/apt/sources.list.d/ceph.list.bak echo "deb https://mirrors.ustc.edu.cn/proxmox/debian/ceph-quincy bookworm no-subscription" > /etc/apt/sources.list.d/ceph.list ``` ### CT 镜像下载源 将 /usr/share/perl5/PVE/APLInfo.pm 文件中默认的源地址 http://download.proxmox.com 替换为 https://mirrors.tuna.tsinghua.edu.cn/proxmox 即可。 ```bash cp /usr/share/perl5/PVE/APLInfo.pm /usr/share/perl5/PVE/APLInfo.pm.bak sed -i 's|http://download.proxmox.com|https://mirrors.ustc.edu.cn/proxmox|g' /usr/share/perl5/PVE/APLInfo.pm ``` ``` { host => "mirrors.ustc.edu.cn", url => "https://mirrors.ustc.edu.cn/turnkeylinux/metadata/pve", file => 'aplinfo.dat', } ``` ```bash systemctl restart pvedaemon.service pveam update pveam available ``` ## 删除订阅弹窗 ```bash sed -Ezi.bak "s/(Ext.Msg.show\(\{\s+title: gettext\('No valid sub)/void\(\{ \/\/\1/g" /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && systemctl restart pveproxy.service ``` ## oh-my-zsh ```bash apt update && apt install git zsh curl \ && sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting \ && git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions \ && git clone https://github.com/zsh-users/zsh-completions ${ZSH_CUSTOM:-${ZSH:-~/.oh-my-zsh}/custom}/plugins/zsh-completions ``` ## 连接 WiFi ```bash apt-get install wpasupplicant apt-get install wireless-tools ip link set wlp91s0 up iwlist wlp91s0 scanning | grep "ESSID" wpa_supplicant [WIFI名称] [WIFI密码] ``` > [No wpa_supplicant executable](https://raspberrypi.stackexchange.com/questions/137121/no-wpa-supplicant-executable) 会得到类似以下的输出 ``` network={ ssid="[WIFI名称]" #psk="[WIFI密码]" psk=[WIFI密码的密钥值] } ``` 我们需要复制这个 PSK 值。 --- 1. 打开`/etc/network/interfaces`文件 2. 添加以下内容 ``` auto [网卡名称] # 如果你使用静态IP iface [网卡名称] inet static address [你要分配的IP地址,如192.168.4.1/24] gateway [网关地址] wpa-ssid [WIFI名称] wpa-psk [WIFI密码的密钥值] # 如果你使用动态IP iface [网卡名称] inet dhcp wpa-ssid [WIFI名称] wpa-psk [WIFI密码的密钥值] ``` > 为了方便管理,我们推荐你使用静态 IP 3. 重启网络 ``` ifdown [网卡名称] ifup [网卡名称] ``` ## Web 打不开 `501 no such file '/PVE/StdWorkspace.js'` ```bash apt install --reinstall proxmox-widget-toolkit apt update && apt upgrade apt install -f apt dist-upgrade pvecm updatecerts --force service pveproxy restart ``` ## pve_source 脚本 https://bbs.x86pi.cn/thread?topicId=20 稳定版 ```bash wget -q -O /root/pve_source.tar.gz 'https://bbs.x86pi.cn/file/topic/2023-11-28/file/01ac88d7d2b840cb88c15cb5e19d4305b2.gz' && tar zxvf /root/pve_source.tar.gz && /root/./pve_source ``` 开发版 (PVE 系统配置 IOMMU、核显直通、核显 SR-IOV 调整为定制向导+推荐方案) ```bash wget -q -O /root/pve_source.tar.gz 'https://bbs.x86pi.cn/file/topic/2024-01-06/file/24f723efc6ab4913b1f99c97a1d1a472b2.gz' && tar zxvf /root/pve_source.tar.gz && /root/./pve_source ``` ## 迁移 Ubuntu 系统到 PVE **列举一下已存在的 M.2 固态硬盘信息:** ![20250218100813_9YKCDhZZ.webp](https://cdn.dong4j.site/source/image/20250218100813_9YKCDhZZ.webp) 一共 1T \*4 M.2 固态硬盘, 其中 **nvme0n1** 安装的 PVE, 其他 3 根还未使用. ### 镜像打包 原来安装 Ubuntu 的 M.2 500G 固态硬盘已经从机器上拔下来了, 为了将此固态硬盘中的系统打包成镜像然后恢复到 PVE 中, 这里使用了 Clonezilla 来完成这项工作. 使用到的工具: 1. [Clonezilla](https://clonezilla.org/) 2. [Ventoy](https://www.ventoy.net/) ![20250218105012_ChdOGfq8.webp](https://cdn.dong4j.site/source/image/20250218105012_ChdOGfq8.webp) ![20250221012151_nPtdL8DT.webp](https://cdn.dong4j.site/source/image/20250221012151_nPtdL8DT.webp) 执行完成后会在指定的 U 盘生成一些系统文件, 但是这个还不能直接使用, 所以我又使用 Clonezilla 将这些文件打包成 ISO. > 最终的目的是将 Ubuntu 系统打包成 ISO, 然后在 PVE 中使用虚拟机安装. **参考** - [clonezilla(再生龙)克隆 linux 系统 操作指南](https://blog.csdn.net/weixin_36432129/article/details/130502411) - [使用再生龙 CloneZilla 进行 Linux 系统的镜像完全封装和还原](https://zhuanlan.zhihu.com/p/354584111) ### 安装到 PVE 在 PVE 下查看 P40 显卡的信息: ```bash lspci | grep 3D 81:00.0 3D controller: NVIDIA Corporation GP102 [P102-100] (rev a1) ``` 要直通这块卡给 Ubuntu 24.04 虚拟机,需要准备: - 开启 pcie 直通 - 安装 Ubuntu 24.04 虚拟机(就是上一步打包的 ISO) ## 安装 Windows https://www.mulingyuer.com/archives/1027/ https://willxup.top/archives/pve-install-win11 https://imacos.top/2023/09/07/pve-windows-11/ ### 核显直通与虚拟核显直通 **物理核显直通 vs SRIOV vGPU 的优缺点对比:** | | 优点 | 缺点 | | ------------------ | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 物理核显直通 | 1、性能强 2、支持物理显示输出 (VGA/DP/HDMI/Type-C) 3、兼容性好,不妨碍 PVE 升级。 | 独占(不支持直通给多个虚拟机) 直通后,PVE 宿主不能同时用核显。 | | SRIOV 虚拟核显直通 | 可分为多个 VF 虚拟显卡, 给不同的虚拟机使用。 | 1、性能拆分(分成多个 VF 显卡具体如何分配性能不详) 如 N100 等不建议超过 3 个 VF 显卡,具有性能高核显的处理器可以分为最高 7 个); 2、不支持物理显示输出,虽可使用 Todesk 等软件远程桌面,但 CPU 占用率较高。在作为服务器的小主机上,让原本紧张的资源更加捉襟见肘。 3、对内核 header 依赖(6.1-6.5),PVE 升级受限。 4、兼容性稳定性一般,部分流媒体软件硬解/转码功能受限。 | ### 虚拟核显直通 ```bash apt install pve-kernel-$(uname -r) proxmox-boot-tool kernel pin $(uname -r) apt install pve-headers-$(uname -r) wget https://github.com/moetayuko/intel-gpu-i915-backports/releases/download/I915MT65-24.1.19-6/intel-i915-dkms_1.24.1.19.240119.1.nodrm+i6-1_all.deb apt update & apt install build-* pve-headers-$(uname -r) git dkms sysfsutils flex bison -y wget -O /lib/firmware/updates/i915/tgl_guc_70.9.1.bin https://github.com/intel-gpu/intel-gpu-firmware/blob/main/firmware/tgl_guc_70.9.1.bin dpkg -i intel-i915-dkms_1.24.1.19.240119.1.nodrm+i6-1_all.deb vim /etc/default/grub ``` 在 `quiet` 后添加 `intel_iommu=on iommu=pt i915.enable_guc=3 i915.max_vfs=7` ```bash update-grub update-initramfs -u apt install -y sysfsutils ``` ```bash echo "devices/pci0000:00/0000:00:02.0/sriov_numvfs = 3" > /etc/sysfs.conf ---------------- #有修改虚拟核显数量的需求 nano /etc/sysfs.conf #将原来写入的参数注释掉 #devices/pci0000:00/0000:00:02.0/sriov_numvfs = 3 #改成你需要的数量,例如下述为5个 devices/pci0000:00/0000:00:02.0/sriov_numvfs = 5 ``` ```bash reboot ``` ```bash $ dkms status intel-i915-dkms/1.24.1.19.240119.1.nodrm, 6.8.12-8-pve, x86_64: installed ``` ```bash $ dmesg |grep i915 ... [ 4.564128] i915 0000:00:02.0: Enabled 3 VFs ``` ```bash vim /etc/pve/qemu-server/100.conf args: -set device.hostpci0.addr=02.0 -set device.hostpci0.x-igd-gms=0x2 ``` **参考** - [【PVE】All in One 的快乐之系统配置及核显 SR-IOV 直通](https://www.cloudstaymoon.com/2024/04/10/all-in-one-1) - [一边原神一边 galgame :同时独显直通和核显虚拟化](https://zhuanlan.zhihu.com/p/571224296) - [玩转 AIGC:打造本地大模型地基,PVE 配置显卡直通](https://juejin.cn/post/7364224622681112610) - [PVE8 直通 ubuntu 22.04](https://skyao.io/learning-computer-hardware/graphics/p102/pve-ubuntu2204/) - [Proxmox VE (Tesla P40) vGPU 配置](https://azhuge233.com/proxmox-ve-tesla-p40-vgpu-%e9%85%8d%e7%bd%ae/) - [Proxmox VE 8 Tesla P40 vGPU 配置](https://azhuge233.com/proxmox-ve-8-tesla-p40-vgpu-%E9%85%8D%E7%BD%AE/) - [Proxmox VE 显卡(Tesla P40)直通](https://azhuge233.com/proxmox-ve-%E6%98%BE%E5%8D%A1%EF%BC%88tesla-p40%EF%BC%89%E7%9B%B4%E9%80%9A/) - [PVE 直通显卡 & Intel SRIOV](https://juejin.cn/post/7330920549771837481) - [PCI Passthrough](https://pve.proxmox.com/wiki/PCI_Passthrough) --- ## 安装 Ubuntu PVE(Proxmox VE)中的网络模型选择取决于 **虚拟机的用途和性能需求**。不同的网卡模型有不同的**兼容性**和**性能**,以下是对比: **1. VirtIO(半虚拟化)(✅ 推荐)** - **优点**: - **性能最佳**,支持 **高吞吐量**,**低 CPU 开销**。 - **延迟最低**,适合高性能服务器、数据库、Web 服务等应用。 - 现代 Linux(Ubuntu、Debian、CentOS)**自带 VirtIO 驱动**,无需额外安装。 - **缺点**: - Windows 需要 **手动安装 VirtIO 驱动**(可以从[这里](https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/)下载)。 ✅ **推荐用于:Linux 虚拟机、Windows(安装 VirtIO 驱动)、高性能应用** **2. Intel E1000 / E1000E(🟡 兼容性好,但性能一般)** - **优点**: - 兼容性**非常好**,几乎所有操作系统都**自带驱动**,无需额外安装。 - 适用于**老旧操作系统**(如 Windows XP/2003)。 - **缺点**: - **CPU 开销大**,数据包处理效率低。 - **带宽限制在 1Gbps**,无法发挥现代硬件的最大性能。 🟡 **适用于:老旧操作系统(如 Windows XP/2003)、兼容性优先的环境** **3. Realtek RTL8139(⚠️ 极老,几乎不推荐)** - **优点**: - 兼容性好,适用于一些**极老的操作系统**(如 Windows 98)。 - **缺点**: - **性能最差**,带宽 **100Mbps**,完全跟不上现代网络需求。 - 高 CPU 负载,不适合生产环境。 ❌ **不推荐,除非你要运行 Windows 98 这类老系统。** **4. VMware vmxnet3(🟡 适用于 VMware 兼容环境)** - **优点**: - **适合从 VMware 迁移的虚拟机**,可无缝兼容 VMware 环境。 - 低 CPU 开销,性能比 Intel E1000 好。 - **缺点**: - 需要安装 **VMware Tools** 才能正常工作,否则性能低下。 - 如果不是从 VMware 迁移过来的 VM,**VirtIO 仍然是更好的选择**。 🟡 **适用于:从 VMware 迁移过来的虚拟机** **总结** | **选项** | **性能** | **兼容性** | **适用场景** | **推荐度** | | ---------------------- | ----------- | -------------------------------------- | ------------------------------------------- | ---------- | | **VirtIO(半虚拟化)** | **✅ 最佳** | 需要驱动(Linux 自带,Windows 需安装) | 现代 Linux、Windows(安装驱动)、高吞吐应用 | ⭐⭐⭐⭐⭐ | | **Intel E1000/E1000E** | 🟡 一般 | **无需驱动** | 老旧 Windows/Linux 兼容性优先 | ⭐⭐⭐ | | **Realtek RTL8139** | ❌ 最差 | **无需驱动** | **极老系统(Windows 98/2000)** | ⭐ | | **VMware vmxnet3** | 🟡 较好 | 需要 VMware Tools | 从 VMware 迁移的 VM | ⭐⭐⭐ | **推荐** - **Linux(Ubuntu、Debian、CentOS)→ 选 VirtIO** - **Windows(10/11/Server)→ 选 VirtIO(安装 VirtIO 驱动)** - **Windows XP/2003 → 选 Intel E1000** - **VMware 迁移过来的 VM → 选 vmxnet3** **✅ 最终建议:如果是 Linux 或现代 Windows,**请使用 **VirtIO(半虚拟化)**,它的性能最佳。 ### 开启 SSH 登录 1. 安装 openssh-server ```bash sudo apt install openssh-server ``` 2. 安装完成后,SSH 服务默认自动启动,你可以通过以下命令校验服务运行状态: ```bash sudo systemctl status ssh ``` 3. 如果你启用了防火墙,请确保防火墙打开了 SSH 端口,命令如下: ```bash sudo ufw allow ssh ``` 4. 允许远程登录 ```bash sudo nano /etc/ssh/sshd_config # 修改配置 PubkeyAuthentication yes ``` 5. 重启 ssh 服务 ```bash service ssh restart ``` ### 开启远程桌面 因为通过 PVE 控制台链接 Ubuntu 后, 进入设置页面打开远程桌面老是卡死, 所以直接通过命令行操作. ```bash sudo apt update && sudo apt install xrdp -y && sudo systemctl enable --now xrdp ``` ### 安装 docker ```bash sudo apt remove -y docker docker-engine docker.io containerd runc \ $$ sudo mkdir -p /etc/apt/keyrings \ && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo tee /etc/apt/keyrings/docker.asc > /dev/null \ && sudo chmod a+r /etc/apt/keyrings/docker.asc \ && sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg /etc/apt/keyrings/docker.asc \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null \ && sudo apt update \ && sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ``` ### 直通 nvme 因为同品牌 nvme 固态硬盘在 PVE WebUI 的硬件列表中无法区分, 所以需要在命令行在找到具体的 PCI ID 才能直通给虚拟机, 避免错误的将安装了 PVE 系统的 nvem 分配给虚拟机导致 PVE 崩溃. ```bash # 查看 nvme 的 PCI 列表 $ lspci | grep -i nvme 01:00.0 Non-Volatile memory controller: Yangtze Memory Technologies Co.,Ltd ZHITAI TiPro5000 NVMe SSD (rev 01) 81:00.0 Non-Volatile memory controller: Yangtze Memory Technologies Co.,Ltd ZHITAI TiPro5000 NVMe SSD (rev 01) # 查看系统安装在哪块盘 $ lsblk -o NAME,MAJ:MIN,RM,TYPE,SIZE,MOUNTPOINT,UUID NAME MAJ:MIN RM TYPE SIZE MOUNTPOINT UUID nvme0n1 259:0 0 disk 953.9G └─nvme0n1p1 259:2 0 part 953.9G 1a8fd1dd-021a-4e59-bcde-d77502ecc0e7 nvme1n1 259:1 0 disk 953.9G ├─nvme1n1p1 259:3 0 part 1007K ├─nvme1n1p2 259:4 0 part 1G /boot/efi 4773-0652 └─nvme1n1p3 259:5 0 part 952.9G 5v5ZMp-dJCX-EEep-rBgv-U5ry-q4E4-8vGhuC ├─pve-swap 252:0 0 lvm 8G [SWAP] ad3ef0fd-b31a-49a3-b72d-8a059358f99a ├─pve-root 252:1 0 lvm 96G / 877cad13-9e68-4d5c-b4bf-fe92ffc241d8 ├─pve-data_tmeta 252:2 0 lvm 8.3G │ └─pve-data-tpool 252:4 0 lvm 816.2G │ ├─pve-data 252:5 0 lvm 816.2G │ └─pve-vm--102--disk--0 252:6 0 lvm 32G └─pve-data_tdata 252:3 0 lvm 816.2G └─pve-data-tpool 252:4 0 lvm 816.2G ├─pve-data 252:5 0 lvm 816.2G └─pve-vm--102--disk--0 252:6 0 lvm 32G ``` 上面的输出得知 PVE 安装在 **nvme1n1**, 因此 **nvme0n1** 就是应该直通给虚拟机的盘, 让我们找出它的 PCI ID: ```bash $ udevadm info -q path -n /dev/nvme0n1 | grep -oP '\d+:\d+\.\d+' 00:01.0 01:00.0 ``` 找到了: **01:00.0**. > 或者这样也行: > > ```bash > $ cd /sys/block/nvme0n1/device && ls -al > > ... > lrwxrwxrwx 1 root root 0 Feb 19 17:23 device -> ../../../0000:01:00.0 > ... > ``` ### LVM 扩容 刚开始只给了 32G 磁盘, 后面光安装显卡驱动就不够用了, 所以在 PVE 控制天新添加了 100G 空间给 Ubuntu: ```bash ➜ ~ lsblk NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS loop0 7:0 0 73.9M 1 loop /snap/core22/1748 loop1 7:1 0 44.4M 1 loop /snap/snapd/23545 sda 8:0 0 32G 0 disk ├─sda1 8:1 0 1M 0 part ├─sda2 8:2 0 2G 0 part /boot └─sda3 8:3 0 30G 0 part ├─ubuntu--vg-ubuntu--lv 252:0 0 15G 0 lvm / └─ubuntu--vg-lv--0 252:1 0 15G 0 lvm /home sdb 8:16 0 100G 0 disk sr0 11:0 1 2.6G 0 rom nvme0n1 259:0 0 953.9G 0 disk └─nvme0n1p1 259:1 0 953.9G 0 part ``` 其中 **sdb** 是新增的磁盘, 需要将这 100G 空间添加到根分区中: ```bash # 将 sdb 添加为一个物理卷(PV): sudo pvcreate /dev/sdb # 将新创建的物理卷添加到现有的卷组中 sudo vgextend ubuntu-vg /dev/sdb # 扩展的是根分区(/dev/ubuntu-vg/ubuntu-lv) sudo lvextend -l +100%FREE /dev/ubuntu-vg/ubuntu-lv # 扩展完逻辑卷后,需要扩展文件系统,使其能够使用新的空间(ext4 文件系统) sudo resize2fs /dev/ubuntu-vg/ubuntu-lv # xfs 文件系统 # sudo xfs_growfs /dev/ubuntu-vg/ubuntu-lv # 检查文件系统是否成功扩展 df -h ``` **参考**: - [PVE 系列教程(二十二)、安装 Ubuntu24.04(桌面版)](http://www.huerpu.cc:7000/?p=729) - [玩转 AIGC:打造本地 AI 大模型地基,PVE 制作 Ubuntu 24.04 LTS 模板](https://juejin.cn/post/7365764222838210598) ## 磁盘共享 ### Ceph 节点简介 - Ceph MON:Ceph 监视器(**Mon**itor),负责维护集群状态映射,帮助协调 Ceph 守护进程,并负责管理守护进程与客户端之间的身份验证。通常需要至少三个 MON 节点。 - Ceph MGR:Ceph 管理器守护进程(**M**ana**g**e**r** Daemon),负责跟踪运行时指标和 Ceph 当前运行状态,并负责管理和公开 Ceph 集群信息。通常需要至少两个 MAN 节点。 - Ceph OSD:Ceph 对象存储守护进程(**O**bject **S**torage **D**aemon),负责存储数据、处理数据复制、恢复、重新平衡,并通过检查其他 OSD 心跳向 Ceph 监视器和管理器提供监视信息。通常需要至少三个 OSD 节点。 - Ceph MDS:Ceph 元数据服务器(**M**eta**d**ata **S**erver),负责存储元数据,并允许 CephFS 用户运行基本命令,而不为 Ceph 存储集群带来负担。 #### 时间同步 ```bash apt install ntpdate -y ntpdate ntp1.aliyun.com ``` ```bash crontab -e 0 0 * * 6 ntpdate ntp1.aliyun.com ``` ### 安装 Ceph 直接在 PVE Web 端安装, 但是安装后报错: ``` rados_connect failed - No such file or directory (500) ``` 原因是 MON 节点启动失败, 但是在 Web 端有没有 MON 节点显示, 创建的时候报错: ``` Multiple IPs for ceph public network '192.168.31.99/24' detected on nuc: 192.168.31.99 192.168.31.9 use 'mon-address' to specify one of them. (500) ``` 所以直接使用命令行来处理: ```bash # NUC 节点 pveceph mon create --mon-address 192.168.31.99 # Station 节点 pveceph mon create --mon-address 192.168.31.66 ``` ### 配置 Ceph NUC 节点上的 nvme: ```bash $ lsblk NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS nvme3n1 259:0 0 953.9G 0 disk nvme2n1 259:1 0 953.9G 0 disk nvme1n1 259:2 0 931.5G 0 disk ├─nvme1n1p1 259:3 0 1007K 0 part ├─nvme1n1p2 259:4 0 1G 0 part /boot/efi └─nvme1n1p3 259:5 0 930.5G 0 part ├─pve-swap 252:0 0 8G 0 lvm [SWAP] ├─pve-root 252:1 0 96G 0 lvm / ├─pve-data_tmeta 252:2 0 8.1G 0 lvm │ └─pve-data-tpool 252:4 0 794.3G 0 lvm │ ├─pve-data 252:5 0 794.3G 1 lvm │ ├─pve-vm--100--disk--0 252:6 0 4M 0 lvm │ ├─pve-vm--100--disk--1 252:7 0 200G 0 lvm │ ├─pve-vm--100--disk--2 252:8 0 4M 0 lvm │ └─pve-vm--103--disk--0 252:9 0 128G 0 lvm └─pve-data_tdata 252:3 0 794.3G 0 lvm └─pve-data-tpool 252:4 0 794.3G 0 lvm ├─pve-data 252:5 0 794.3G 1 lvm ├─pve-vm--100--disk--0 252:6 0 4M 0 lvm ├─pve-vm--100--disk--1 252:7 0 200G 0 lvm ├─pve-vm--100--disk--2 252:8 0 4M 0 lvm └─pve-vm--103--disk--0 252:9 0 128G 0 lvm nvme0n1 259:6 0 931.5G 0 disk ``` 所以有 `nvme0n1`, `nvme2n1` 和 `nvme3n1` 可以使用, Station 上的 nvme: ```bash $ lsblk NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS nvme0n1 259:0 0 953.9G 0 disk ├─nvme0n1p1 259:1 0 1007K 0 part ├─nvme0n1p2 259:2 0 1G 0 part /boot/efi └─nvme0n1p3 259:3 0 952.9G 0 part ├─pve-swap 252:0 0 8G 0 lvm [SWAP] ├─pve-root 252:1 0 96G 0 lvm / ├─pve-data_tmeta 252:2 0 8.3G 0 lvm │ └─pve-data-tpool 252:4 0 816.2G 0 lvm │ ├─pve-data 252:5 0 816.2G 1 lvm │ ├─pve-vm--102--disk--0 252:6 0 32G 0 lvm │ └─pve-vm--102--disk--1 252:7 0 100G 0 lvm └─pve-data_tdata 252:3 0 816.2G 0 lvm └─pve-data-tpool 252:4 0 816.2G 0 lvm ├─pve-data 252:5 0 816.2G 1 lvm ├─pve-vm--102--disk--0 252:6 0 32G 0 lvm └─pve-vm--102--disk--1 252:7 0 100G 0 lvm ``` 上面是 PVE 系统盘, 另一块直通给 Ubuntu 使用了, 所以没有显示出来, 这个在数据迁移完成后再处理, 我们先将 NUC 节点的 3 块 nvme 添加到 OSD. 这里将每块 NVMe 盘拆分为 4 个分区,然后 将这些分区分别添加为独立的 OSD。这样可以让 Ceph 更加 高效利用 NVMe 资源,同时 减少 IO 阻塞,提高并发性能。 ```bash for disk in /dev/nvme0n1 /dev/nvme2n1 /dev/nvme3n1; do parted --script $disk mklabel gpt parted --script $disk mkpart primary 0% 25% parted --script $disk mkpart primary 25% 50% parted --script $disk mkpart primary 50% 75% parted --script $disk mkpart primary 75% 100% done ``` ```bash $ lsblk NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS nvme3n1 259:0 0 953.9G 0 disk ├─nvme3n1p1 259:11 0 238.5G 0 part ├─nvme3n1p2 259:16 0 238.5G 0 part ├─nvme3n1p3 259:21 0 238.5G 0 part └─nvme3n1p4 259:22 0 238.5G 0 part nvme2n1 259:1 0 953.9G 0 disk ├─nvme2n1p1 259:12 0 238.5G 0 part ├─nvme2n1p2 259:13 0 238.5G 0 part ├─nvme2n1p3 259:17 0 238.5G 0 part └─nvme2n1p4 259:18 0 238.5G 0 part nvme1n1 259:2 0 931.5G 0 disk ├─nvme1n1p1 259:3 0 1007K 0 part ├─nvme1n1p2 259:4 0 1G 0 part /boot/efi └─nvme1n1p3 259:5 0 930.5G 0 part └─ ... nvme0n1 259:6 0 931.5G 0 disk ├─nvme0n1p1 259:8 0 232.9G 0 part ├─nvme0n1p2 259:9 0 232.9G 0 part ├─nvme0n1p3 259:10 0 232.9G 0 part └─nvme0n1p4 259:14 0 232.9G 0 part ``` **使用 pveceph osd create 命令,将这些分区作为 OSD:** ```bash $ for part in {1..4}; do pveceph osd create /dev/nvme0n1p$part pveceph osd create /dev/nvme2n1p$part pveceph osd create /dev/nvme3n1p$part done ``` **检查结果** ```bash $ ceph osd tree ID CLASS WEIGHT TYPE NAME STATUS REWEIGHT PRI-AFF -1 2.77271 root default -3 2.77271 host nuc 0 ssd 0.22739 osd.0 up 1.00000 1.00000 1 ssd 0.23289 osd.1 up 1.00000 1.00000 2 ssd 0.23289 osd.2 up 1.00000 1.00000 3 ssd 0.22739 osd.3 up 1.00000 1.00000 4 ssd 0.23289 osd.4 up 1.00000 1.00000 5 ssd 0.23289 osd.5 up 1.00000 1.00000 6 ssd 0.22739 osd.6 up 1.00000 1.00000 7 ssd 0.23289 osd.7 up 1.00000 1.00000 8 ssd 0.23289 osd.8 up 1.00000 1.00000 9 ssd 0.22739 osd.9 up 1.00000 1.00000 10 ssd 0.23289 osd.10 up 1.00000 1.00000 11 ssd 0.23289 osd.11 up 1.00000 1.00000 ``` 接下来还需要配置存储池, 并将存储池添加到 RBD 和 CephFS. ### 使用 #### 在 PVE 虚拟机中挂载 CephFS 1. 创建 CephFS: ```javascript ceph fs new cephfs cephfs_metadata cephfs_data ``` 2. 添加到 PVE Web UI 中的 CephFS: 3. 在 VM 或 LXC 中挂载 CephFS: ```bash mkdir /mnt/cephfs mount -t ceph 192.168.31.99:6789:/ /mnt/cephfs -o name=admin,secret= ``` #### 在虚拟机中使用 RBD(块存储) 1. 查看 Ceph 存储池: ```bash rbd pool ls ``` 2. 创建 RBD: ```sql rbd create --size 10G vm_storage/test-disk ``` 3. 在 VM 内部映射 RBD 磁盘并进行基本设置: ```bash rbd map vm_storage/test-disk --name client.admin mkfs.ext4 /dev/rbd0 mount /dev/rbd0 /mnt ``` **参考**: - [Proxmox VE + Ceph 超融合集群部署实录](https://macesuted.moe/article/hyper-convergence) - [proxmox+ceph 集群完整方案/完整方案](https://zhuanlan.zhihu.com/p/617024637) > PVE 上使用 Ceph 的想法算了总结了, 太复杂了, 现在 12 个 OSD 跑了一晚上数据还没有同步完成, 所以打算玩玩 ZFS, 家用应该也够了. > > [For Best Performance - Proxmox Cluster with CEPH or ZFS?](https://forum.proxmox.com/threads/for-best-performance-proxmox-cluster-with-ceph-or-zfs.129635/) ### 卸载 Ceph 🙉 **1. 停止并禁用 Ceph 服务** 首先,确保停止所有与 Ceph 相关的服务,包括 **Monitor(mon)**、**OSD**、**MDS**、**Manager(mgr)** 等: ``` systemctl stop ceph-mon.target systemctl stop ceph-osd.target systemctl stop ceph-mgr.target systemctl stop ceph-mds.target ``` 然后,禁用这些服务,以确保它们不会在系统重启后自动启动: ``` systemctl disable ceph-mon.target systemctl disable ceph-osd.target systemctl disable ceph-mgr.target systemctl disable ceph-mds.target ``` **2. 卸载 Ceph 软件包** 卸载所有 Ceph 相关的包: ``` apt-get remove --purge ceph ceph-common ceph-mds ceph-mon ceph-osd ceph-mgr ceph-fuse librados2 librgw1 libcephfs1 librados-dev librgw-dev libboost-iostreams1.58.0 libboost-filesystem1.58.0 libboost-system1.58.0 librados2 librgw1 ``` 这将删除所有 Ceph 相关的软件包及其依赖。 **3. 删除 Ceph 配置和数据** 删除 Ceph 的配置文件和数据目录: ``` rm -rf /etc/ceph rm -rf /var/lib/ceph rm -rf /var/log/ceph ``` 这些目录包含了 Ceph 的所有配置、日志和数据文件。 **4. 清理 LVM 卷和 Ceph OSD 数据** 如果您使用了 **LVM** 来管理 Ceph OSD,确保删除所有的 LVM 卷和卷组。首先,查看当前的 LVM 配置: ``` sudo lvdisplay sudo vgdisplay sudo pvdisplay ``` 如果存在 Ceph 使用的 **LVM 逻辑卷**,**卷组** 或 **物理卷**,可以使用以下命令删除它们: 1. 删除 LVM 逻辑卷: ``` sudo lvremove /dev/ceph// ``` 2. 删除 LVM 卷组: ``` sudo vgremove ``` 3. 删除物理卷: ``` sudo pvremove /dev/ ``` 4. 删除 Ceph OSD 设备: ``` sudo wipefs -a /dev/ # 用实际的设备路径替换 ``` **5. 删除 Ceph 相关的系统服务文件** 清理 Ceph 服务文件,确保没有服务文件残留: ``` rm -f /etc/systemd/system/ceph-* rm -f /etc/systemd/system/ceph-mgr@*.service rm -f /etc/systemd/system/ceph-mon@*.service rm -f /etc/systemd/system/ceph-mds@*.service rm -f /etc/systemd/system/ceph-osd@*.service ``` **6. 清除 Ceph 集群配置和密钥** 如果还没有删除 Ceph 集群的密钥环和配置文件,可以手动清理: ``` rm -f /etc/ceph/ceph.client.admin.keyring rm -f /etc/ceph/ceph.mon.keyring rm -f /etc/ceph/ceph-osd.keyring ``` **7. 删除 ceph-crash** ```bash systemctl stop ceph-crash systemctl disable ceph-crash systemctl mask ceph-crash rm -rf /etc/systemd/system/multi-user.target.wants/ceph-crash.service rm -rf /var/lib/ceph/crash rm -f /usr/bin/ceph-crash ``` **8. 清理网络和存储设置** 如果配置了 Ceph 使用的特定 **网络接口** 或 **防火墙规则**,请手动清理这些设置: - 清理防火墙规则:使用 iptables 或 firewalld 清理 Ceph 使用的端口。 - 删除 **虚拟网络接口**(如 virtio 等)和 **存储挂载点**。 **9. 重启系统** 完成以上步骤后,最好重启系统以确保所有 Ceph 组件完全卸载: ``` reboot ``` **10. 检查系统状态** 重启后,可以使用以下命令检查系统中是否仍然存在与 Ceph 相关的进程或配置: 1. 检查是否有 Ceph 相关的进程: ``` ps aux | grep ceph ``` 2. 检查 Ceph 配置是否被完全清除: ``` ls /etc/ceph ls /var/lib/ceph ``` 3. 检查 LVM 是否有残留的 Ceph 卷: ``` sudo lvdisplay ``` 4. 使用以下命令检查系统日志是否仍有 Ceph 相关的错误: ``` tail -f /var/log/syslog ``` --- ## ZFS 我 2 块 1T 的 pcie4 Gen3 的 nvme 固态硬盘, 分别是 nvme2n1 和 nvme3n1, 还有一块 1T 的 pcie4 Gen4: nvme0n1. 而且后期我可能还会增加其他 nvme 和 ssd. 现在我想在 pve 集群中使用 ZFS, 初步计划是让 nvme0n1 划分为 2 个分区, 分别作为 zfs 的读写缓存, 而其他盘作为数据盘. 下面是具体操作: ```bash parted /dev/nvme0n1 (parted) mklabel gpt (parted) print (parted) mkpart primary 0GB 500GB (parted) mkpart primary 500GB 100% (parted) quit ``` ```bash zpool create nvmes mirror /dev/nvme2n1 /dev/nvme3n1 zfs set secondarycache=all nvmes zfs set recordsize=128k nvmes zpool add nvmes cache /dev/nvme0n1p1 zpool add nvmes log /dev/nvme0n1p2 ``` ![20250221012158_iZntJA26.webp](https://cdn.dong4j.site/source/image/20250221012158_iZntJA26.webp) ### 使用 **1. 在 PVE 宿主机中使用 ZFS 存储** PVE 支持直接在宿主机中使用 ZFS 存储池。可以将 ZFS 存储池挂载到宿主机的特定目录,然后将虚拟机和 LXC 容器配置为使用该存储。 **挂载 ZFS 存储:** ``` zfs set mountpoint=/mnt/nvmes nvmes ``` 这将把你的 mypool ZFS 存储池挂载到 /mnt/nvmes 目录。可以在这个目录下创建文件并访问。 **创建 ZFS 快照:** 如果你想对 ZFS 存储池进行快照,可以使用: ``` zfs snapshot nvmes@snapshot_name ``` **2. 在虚拟机中使用 ZFS 存储** 要在虚拟机中使用 ZFS 存储,可以通过以下两种方式: **方式 1: 使用 ZFS 作为虚拟机的磁盘** 在宿主机中创建一个 ZFS 数据集作为虚拟机的磁盘: ``` zfs create nvmes/nvmes-disk ``` 在 PVE 中的虚拟机配置中,将 nvmes/nvmes-disk 数据集作为虚拟磁盘挂载给虚拟机。例如,通过 PVE Web 界面,可以在虚拟机的硬盘设置中选择该数据集,或者通过命令行使用: ``` qm set VMID -ide0 /dev/zvol/nvmes/nvmes-disk ``` **方式 2: 使用 ZFS 文件共享给虚拟机** 如果不希望直接将 ZFS 存储池作为虚拟磁盘,可以将 ZFS 存储池中的某个目录挂载为共享目录,虚拟机通过网络访问。 1. 在宿主机中创建目录: ``` mkdir /mnt/nvmes/share ``` 2. 设置权限: ``` chmod -R 777 /mnt/nvmes/share ``` 3. 在虚拟机内挂载 NFS 或 Samba 共享(见下面的“共享 ZFS 存储到局域网”部分)。 **3. 在 LXC 容器中使用 ZFS 存储** LXC 容器与虚拟机类似,也可以直接访问宿主机上的 ZFS 存储池。可以通过以下方式将 ZFS 存储挂载到容器内部: 1. **挂载 ZFS 存储到容器目录:** 可以将宿主机上的 ZFS 存储池挂载到容器的某个目录。假设将 nvmes/share 挂载到容器中的 /mnt/storage,可以在容器配置中添加以下配置: 编辑 /etc/pve/lxc/VMID.conf 配置文件,添加: ``` mp0: /mnt/nvmes/share,mp=/mnt/storage ``` 2. **重新启动容器:** ``` pct restart VMID ``` 容器就能访问宿主机上的 ZFS 存储了。 **4. 在局域网中共享 ZFS 存储** 如果希望将 ZFS 存储共享给局域网中的其他设备(比如其他计算机、服务器或虚拟机),可以使用 NFS 或 Samba。 **使用 NFS 共享 ZFS 存储:** 1. 安装 NFS 服务: ``` apt install nfs-kernel-server ``` 2. 配置 NFS 共享目录: 在 /etc/exports 文件中添加 ZFS 存储目录: ``` /mnt/nvmes/share *(rw,sync,no_subtree_check) ``` 3. 重新启动 NFS 服务: ``` systemctl restart nfs-kernel-server ``` 4. 在客户端(其他计算机)挂载共享: ``` mount -t nfs <宿主机_IP>:/mnt/nvmes/share /mnt/nfs_share ``` **使用 Samba 共享 ZFS 存储:** 1. 安装 Samba: ``` apt install samba ``` 2. 配置共享目录: 编辑 /etc/samba/smb.conf 文件,添加以下配置: ``` [nvmes] path = /mnt/nvmes browsable = yes read only = no guest ok = yes # 允许匿名访问 create mask = 0775 # 设置文件的创建权限 directory mask = 0775 # 设置目录的创建权限 ``` ```bash chmod -R 775 /mnt/nvmes chown -R nobody:nogroup /mnt/nvmes ``` 3. 重启 Samba 服务: ``` systemctl restart smbd ``` 4. 在客户端(其他计算机)访问共享: 通过 smb:// 协议挂载共享: ``` smbclient //宿主机_IP/nvmes ``` ## 网络 ### qemu-guest-agent 虚拟机执行命令: ```bash sudo apt install qemu-guest-agent -y sudo systemctl start qemu-guest-agent ``` 不过报错了: ``` (base) ➜ ~ sudo systemctl start qemu-guest-agent ^@A dependency job for qemu-guest-agent.service failed. See 'journalctl -xe' for details. Feb 20 06:26:26 ai systemd[1]: dev-virtio\x2dports-org.qemu.guest_agent.0.device: Job dev-virtio\x2dports-org.qemu.guest_agent.0.device/start timed out. Feb 20 06:26:26 ai systemd[1]: Timed out waiting for device dev-virtio\x2dports-org.qemu.guest_agent.0.device - /dev/virtio-ports/org.qemu.guest_agent.0. ``` **虚拟机内的 QEMU Guest Agent 无法找到 /dev/virtio-ports/org.qemu.guest_agent.0 设备**,通常是因为 **PVE 的 VM 配置没有正确启用 QEMU Guest Agent**。 所以需要在 PVE 宿主机上开启虚拟机的 Guest Agent: ```bash qm set 102 --agent enabled=1 qm reboot 102 ``` 然后还是启动失败: ```bash (base) ➜ ~ sudo systemctl status qemu-guest-agent ○ qemu-guest-agent.service - QEMU Guest Agent Loaded: loaded (/usr/lib/systemd/system/qemu-guest-agent.service; static) Active: inactive (dead) Feb 20 06:34:46 ai systemd[1]: qemu-guest-agent.service: Bound to unit dev-virtio\x2dports-org.qemu.guest_agent.0.device, but unit isn't active. Feb 20 06:34:46 ai systemd[1]: Dependency failed for qemu-guest-agent.service - QEMU Guest Agent. Feb 20 06:34:46 ai systemd[1]: qemu-guest-agent.service: Job qemu-guest-agent.service/start failed with result 'dependency'. ``` 查阅资料后发现是需要添加一个 **virtio-serial** 的设备: ```bash qm set 102 -serial0 socket qm reboot 102 ``` 然后再次启动 agent: ```bash (base) ➜ ~ sudo systemctl status qemu-guest-agent ● qemu-guest-agent.service - QEMU Guest Agent Loaded: loaded (/usr/lib/systemd/system/qemu-guest-agent.service; static) Active: active (running) since Thu 2025-02-20 06:37:46 UTC; 3s ago Main PID: 1549 (qemu-ga) Tasks: 2 (limit: 38427) Memory: 416.0K (peak: 840.0K) CPU: 5ms CGroup: /system.slice/qemu-guest-agent.service └─1549 /usr/sbin/qemu-ga Feb 20 06:37:46 ai systemd[1]: Started qemu-guest-agent.service - QEMU Guest Agent. ``` 显示启动成功了, 能够正常获取 IP 信息: ![20250221012204_Kp7lflpS.webp](https://cdn.dong4j.site/source/image/20250221012204_Kp7lflpS.webp) 服务器上查看虚拟机信息: ```bash qm agent fsfreeze-freeze fsfreeze-status fsfreeze-thaw fstrim #查看ssd——trim get-fsinfo #查看磁盘信息 get-host-name #查看主机名 get-memory-block-info #查看内存块 信息 get-memory-blocks #查看您内存 get-osinfo #查看系统信息 get-time #查看时间 get-timezone #查看时区 get-users #用户 get-vcpus #查看CPU数量 info #查看支持的命令 network-get-interfaces #查看网络 ping #不明 shutdown #关机 suspend-disk #休眠,储存到硬盘 suspend-hybrid #休眠,混合 suspend-ram #挂起/休眠 内存 ``` **参考:** - [PVE 中 qemu guest agent 的安装和使用](https://foxi.buduanwang.vip/virtualization/pve/530.html/) ## Cloud-init **参考** - [在 PVE 8 中使用 Cloud-init 初始化 ubuntu cloud-image 并创建模板](https://never666.uk/2107/) - [一键变身!Cloud-Init 让 PVE 镜像华丽转身,快来看看怎么做!](https://blog.csdn.net/sinat_28521487/article/details/140126760) ## 备份 https://zhuanlan.zhihu.com/p/661921259 https://kb.synology.cn/zh-cn/DSM/tutorial/back_up_restore_proxmox_vm ## 参考 - [2024 年 PVE8 最新安装使用指南|新手入门|安装|优化|Proxmox VE 8.1](https://post.smzdm.com/p/akle62mk/) - [NUC11 AIO 折腾小记](https://taplus.me/nuc11-aio-tinkering/) - [**国光的 PVE 环境搭建教程**](https://github.com/sqlsec/PVE) - [PVE 学习笔记](https://skyao.io/learning-pve/) https://github.com/skyao/learning-pve - [PVE doc](https://pve.proxmox.com/pve-docs/) - [2.配置 & 探索 Proxmox VE WebGUI 管理页面](https://quentin-blog.vercel.app/post/2---proxmox-ve-webgui-) - [PVE 系统最佳实践](https://tendcode.com/subject/article/pve-used/) ## [Ubuntu 使用备忘录:好记性不如烂笔头](https://blog.dong4j.site/posts/91f73d3b.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 家中的服务器有 3 台全部安装的是 Ubuntu, 有时候需要折腾一下. 所以想用一个文档来记录 Ubuntu 上遇到过的问题, 另外就是做一个备忘录, 将已操作过的记录保存下来, 以便在其他服务器上重现, 避免重复查询资料. ## 优化建议 ### 启用 Ubuntu Pro Ubuntu Pro 是 Canonical 提供的企业级专业支持服务,个人用户和小团队可免费使用(最多 5 台设备,超过需付费)。它提供了长达 10 年的安全更新支持。 1 打开「软件和更新」,切换到「Ubuntu Pro」选项卡。 2 点击「启用 Ubuntu Pro」。 3 访问 [Ubuntu Pro Dashboard](https://ubuntu.com/pro/dashboard) 拿到分配给你的`Token`,填入「手动添加令牌」中,然后点击「确定」。 ![20250215174904_W7jq8Wuq.webp](https://cdn.dong4j.site/source/image/20250215174904_W7jq8Wuq.webp) ![20250215150635_1GJSbDWq.webp](https://cdn.dong4j.site/source/image/20250215150635_1GJSbDWq.webp) ### 参考 - [新手必备:安装 Ubuntu 24.04 LTS 后的 10 项基本建议](https://www.sysgeek.cn/10-essential-tips-after-installing-ubuntu-24-04/) ### Landscape Server **参考:** - [[Landscape 安装配置速记](https://linux.do/t/topic/68022)](https://linux.do/t/topic/68022) --- ## 系统克隆 原来的系统安装在一块 PCIe Gen3 x4 的 M.2 固态上, 查阅资料得知这个插槽支持 PCIe Gen4 x4, 为了不浪费资源, 所以需要使用一块 PCIe Gen4 x4 的 M.2 固态替换原来那块系统盘, 但是又不想重新安装系统, 所以想通过系统克隆的方式迁移. ### 使用 dd 命令 1. **备份数据**:在进行克隆操作之前,确保你已经备份了重要数据,以防万一。 2. **关闭系统**:关闭你的 Ubuntu 系统,并插入新的 1T M.2 固态硬盘。 3. **进入 Live 环境**:使用 Ubuntu 安装介质(如 USB 启动盘)启动到 Live 环境。 4. **打开终端**:在 Live 环境中打开终端。 5. **查找硬盘标识**:使用`lsblk`或`fdisk -l`命令查找新旧硬盘的标识(例如`/dev/nvme0n1`和`/dev/nvme1n1`)。 6. **克隆硬盘**:使用`dd`命令进行克隆。以下命令将旧硬盘克隆到新硬盘: ``` sudo dd if=/dev/nvme0n1 of=/dev/nvme1n1 bs=4M status=progress ``` 注意:此操作将克隆整个硬盘,包括空闲空间,因此需要时间较长。 7. **调整分区大小**:克隆完成后,使用`gparted`或`fdisk`调整新硬盘的分区大小以利用额外的空间。 8. **更新引导器**:如果需要,更新引导器配置,例如使用`bootctl update`。 9. **重启系统**:重启系统,从新硬盘启动。 #### 参考 - [Ubuntu 硬盘克隆完全指南 高效实现数据备份与恢复](https://my.oschina.net/emacs_8850517/blog/17420517) - [Ubuntu 无损迁移、克隆系统](https://zhuanlan.zhihu.com/p/731828327) - [Ubuntu 整系统迁移到另一个硬盘中](https://blog.csdn.net/weixin_51995147/article/details/136380218) - [Ubuntu 系统迁移实践](https://www.gaitpu.com/os/linux/ubuntu-system-migration) - [怎么把 ubuntu 移植到 centos 无界面系统中 ubuntu 系统迁移到新硬盘](https://blog.51cto.com/u_16099179/10728092) ### 使用群晖的 ABB 这个应该算是最简单的方法了, 正好我也有群晖, 所以直接使用这种方式恢复镜像即可. #### 参考 - [【NAS】整机备份还原 Windows/Linux 系统,群晖最强套件 ABB 教程](https://zhuanlan.zhihu.com/p/556955811) - [实体伺服器备份及还原-Linux](https://kb.synology.com/zh-tw/DSM/help/ActiveBackup/activebackup_business_physicalserver_linux?version=7) - [Active Backup for Business 管理员指南 - 适用于 Linux](https://kb.synology.cn/zh-cn/UG/Synology_ABB_admin_guide_Linux/5) --- ## KVM {% folding green, KVM 常用命令 %} **1. 安装 KVM 和 libvirt** 首先,确保已安装 KVM 和 libvirt,这通常是管理 KVM 虚拟机的基础: ``` # 安装 KVM 和 libvirt sudo apt update sudo apt install qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils # 启动并启用 libvirt 服务 sudo systemctl enable --now libvirtd ``` **2. 虚拟机管理(使用 virsh)** virsh 是管理 KVM 虚拟机的命令行工具,以下是一些常见命令: **2.1 列出所有虚拟机** 列出所有虚拟机,包括运行中的和已关闭的虚拟机: ``` sudo virsh list --all ``` **2.2 启动虚拟机** 启动指定的虚拟机: ``` sudo virsh start ``` 例如: ``` sudo virsh start myvm ``` **2.3 停止虚拟机** 优雅地关闭虚拟机: ``` sudo virsh shutdown ``` 如果虚拟机未响应,可以使用强制停止命令: ``` sudo virsh destroy ``` **2.4 查看虚拟机的状态** 查看虚拟机的详细状态: ``` sudo virsh dominfo ``` **2.5 查看虚拟机的控制台** 连接到虚拟机的控制台,适用于命令行界面虚拟机: ``` sudo virsh console ``` **3. 虚拟机自启动** 启用或禁用虚拟机的自启动(在系统重启时自动启动虚拟机): **3.1 启用自启动** ``` sudo virsh autostart ``` **3.2 禁用自启动** ``` sudo virsh autostart --disable ``` **3.3 查看自启动虚拟机列表** ``` sudo virsh autostart --list ``` **4. 创建、删除虚拟机** **4.1 创建虚拟机** 使用 XML 配置文件创建虚拟机: ``` sudo virsh create ``` 例如: ``` sudo virsh create /path/to/your/vm_config.xml ``` **4.2 删除虚拟机** 删除虚拟机和关联的配置文件: ``` sudo virsh undefine --remove-all-storage ``` **5. 虚拟机磁盘管理** **5.1 查看虚拟机磁盘** 查看虚拟机的磁盘配置: ``` sudo virsh vol-list --pool default ``` **5.2 创建虚拟机磁盘** 使用 qemu-img 创建一个虚拟硬盘镜像: ``` qemu-img create -f qcow2 /path/to/disk_image.qcow2 20G ``` **5.3 增加虚拟机磁盘大小** 增加虚拟机磁盘的大小(例如将磁盘大小增加 10GB): ``` qemu-img resize /path/to/disk_image.qcow2 +10G ``` **6. 网络配置** **6.1 查看网络桥接配置** 列出所有虚拟网络: ``` sudo virsh net-list --all ``` **6.2 启用虚拟网络** 启用虚拟网络(例如默认的 default 网络): ``` sudo virsh net-start default ``` **6.3 配置虚拟网络** 使用 virsh 编辑虚拟网络的配置: ``` sudo virsh net-edit default ``` **7. 备份和迁移虚拟机** **7.1 虚拟机快照** 创建虚拟机的快照: ``` sudo virsh snapshot-create-as ``` **7.2 迁移虚拟机** 迁移虚拟机到另一台主机(需要 libvirt 支持迁移): ``` sudo virsh migrate --live qemu+ssh://@/system ``` **8. 使用 virt-manager(图形界面)** **8.1 安装 virt-manager** 在 Ubuntu 上安装 virt-manager: ``` sudo apt install virt-manager ``` **8.2 启动 virt-manager** 运行 virt-manager 图形界面,管理虚拟机: ``` virt-manager ``` 通过图形界面,你可以创建、启动、停止虚拟机,查看日志、资源使用情况等。 **9. 监控虚拟机资源使用情况** **9.1 查看虚拟机的 CPU、内存和磁盘使用情况** ``` sudo virsh stats ``` **9.2 查看虚拟机的实时性能数据** 你可以使用 virt-top 命令来实时查看虚拟机的性能: ``` sudo virt-top ``` **10. 安装虚拟机操作系统** 通过 virt-install 安装一个新虚拟机操作系统: ``` sudo virt-install \ --name \ --vcpus 2 \ --memory 2048 \ --cdrom /path/to/iso_file.iso \ --disk size=20 \ --os-type linux \ --os-variant ubuntu20.04 ``` {% endfolding %} --- ## Cockpit https://cockpit-project.org/ ```bash # 主服务 sudo apt install cockpit # KVM 管理插件 sudo apt install cockpit-machines ``` ### Docker Cockpit 强推 Podman 容器,Docker 管理插件在很早之前就没有维护了。好在民间有维护一个可用版本:[chabad360/cockpit-docker: Cockpit UI for docker containers (ported from cockpit-podman) (github.com)](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fchabad360%2Fcockpit-docker)。 ```bash git clone git@github.com:chabad360/cockpit-docker.git \ && cd cockpit-docker \ && export NODE_ENV=production \ && make \ && sudo make install ``` `sudo make install` 将包安装到 usr/local/share/cockpit/。这依赖于 dist 目标,该目标生成分发压缩包。你也可以运行 make rpm 来构建本地安装的 RPM。 在生产模式下,源文件会自动压缩和混淆。如果你想复制这个行为,请设置 NODE_ENV=production。 [其他插件列表](https://cockpit-project.org/applications.html) ### Sensors ```bash wget https://github.com/ocristopfer/cockpit-sensors/releases/latest/download/cockpit-sensors.tar.xz && \ tar -xf cockpit-sensors.tar.xz cockpit-sensors/dist && \ mv cockpit-sensors/dist /usr/share/cockpit/sensors && \ rm -r cockpit-sensors && \ rm cockpit-sensors.tar.xz ``` ## 软件更新-磁盘空间不足 有一天主机 `/boot` 空间分配太少了, 一到更新就报 `磁盘空间不足`: > 此升级一共需要 188 M 的空闲空间,磁盘为 "/boot"。请额外释放至少 69.5 M 的 "/boot" 磁盘空间。您可以通过使用 "sudo apt remove"命令移除旧内核,还可以在 /etc/initramfs-tools/initramfs.conf 中添加“set COMPRESS=xz"选项来减少 initramfs 的大小。 ### 删除旧内核 ```bash $ uname -r 6.8.0-48-generic # dong4j @ station in ~/cockpit-docker on git:main o [16:35:06] $ dpkg --list | grep linux-image ii linux-image-6.8.0-45-generic 6.8.0-45.45~22.04.1 amd64 Signed kernel image generic ii linux-image-6.8.0-48-generic 6.8.0-48.48~22.04.1 amd64 Signed kernel image generic ii linux-image-generic-hwe-22.04 6.8.0-48.48~22.04.1 amd64 Generic Linux kernel image # 删除旧内核 sudo apt remove --purge linux-image-6.8.0-45-generic # 清理无用依赖 sudo apt autoremove --purge # 删除旧内核后,更新 initramfs 文件以确保当前内核的文件是最新的 sudo update-initramfs -u ``` ### 压缩减少 initramfs 文件大小 编辑 `/etc/initramfs-tools/initramfs.conf` 文件,添加 `set COMPRESS=xz `来压缩 `initramfs` 文件,从而减少空间占用。 **编辑 initramfs.conf 文件** ```bash sudo vim /etc/initramfs-tools/initramfs.conf COMPRESS=xz # 更新 initramfs sudo update-initramfs -u ``` ### 自动清理旧内核 ```bash sudo apt install byobu sudo byobu-disable ``` ### boot 扩容 参考: - [Ubuntu boot 分区扩容及分区建议](https://www.cnblogs.com/ldylan/p/14238205.html) - [解决/boot 空间不足问题](https://blog.csdn.net/TengYun_zhang/article/details/121858598) - [Linux boot 和根目录扩容](https://www.cnblogs.com/monkey6/p/18121267) --- ## Speedtest 来源: https://u.sb/debian-speedtest/ ### 安装 Speedtest CLI [Speedtest CLI](https://www.speedtest.net/apps/cli) 是 [Ookla](https://www.speedtest.net/) 官方推出的 Linux / BSD 下的 CLI 工具,方便我们在服务器里直接测试公网带宽速度。 首先,导入 GPG Key 并添加源: ```bash apt install -y lsb-release ca-certificates apt-transport-https curl gnupg dpkg curl -sSL https://packagecloud.io/ookla/speedtest-cli/gpgkey | gpg --dearmor > /usr/share/keyrings/speedtest.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/speedtest.gpg] https://packagecloud.io/ookla/speedtest-cli/ubuntu/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/speedtest.list ``` 然后更新系统并安装 `speedtest`: ```bash apt update apt install speedtest -y ``` ### 使用 Speedtest CLI 安装完毕后我们即可使用默认的 `speedtest` 命令选择最近的节点并使用默认的网络测速,提示 `Do you accept the license? [type YES to accept]` 时,输入 `YES` 并回车即可: ![20250216223018_cLmr6owe.webp](https://cdn.dong4j.site/source/image/20250216223018_cLmr6owe.webp) ### 高级用法 输入 `speedtest -h` 即可查看 `speedtest` 的命令参数: ```bash root@debian ~ # speedtest -h Speedtest by Ookla is the official command line client for testing the speed and performance of your internet connection. Version: speedtest 1.1.1.28 Usage: speedtest [] -h, --help Print usage information -V, --version Print version number -L, --servers List nearest servers -s, --server-id=# Specify a server from the server list using its id -I, --interface=ARG Attempt to bind to the specified interface when connecting to servers -i, --ip=ARG Attempt to bind to the specified IP address when connecting to servers -o, --host=ARG Specify a server, from the server list, using its host's fully qualified domain name -p, --progress=yes|no Enable or disable progress bar (Note: only available for 'human-readable' or 'json' and defaults to yes when interactive) -P, --precision=# Number of decimals to use (0-8, default=2) -f, --format=ARG Output format (see below for valid formats) --progress-update-interval=# Progress update interval (100-1000 milliseconds) -u, --unit[=ARG] Output unit for displaying speeds (Note: this is only applicable for ‘human-readable’ output format and the default unit is Mbps) -a Shortcut for [-u auto-decimal-bits] -A Shortcut for [-u auto-decimal-bytes] -b Shortcut for [-u auto-binary-bits] -B Shortcut for [-u auto-binary-bytes] --selection-details Show server selection details --ca-certificate=ARG CA Certificate bundle path -v Logging verbosity. Specify multiple times for higher verbosity --output-header Show output header for CSV and TSV formats Valid output formats: human-readable (default), csv, tsv, json, jsonl, json-pretty Machine readable formats (csv, tsv, json, jsonl, json-pretty) use bytes as the unit of measure with max precision Valid units for [-u] flag: Decimal prefix, bits per second: bps, kbps, Mbps, Gbps Decimal prefix, bytes per second: B/s, kB/s, MB/s, GB/s Binary prefix, bits per second: kibps, Mibps, Gibps Binary prefix, bytes per second: kiB/s, MiB/s, GiB/s Auto-scaled prefix: auto-binary-bits, auto-binary-bytes, auto-decimal-bits, auto-decimal-bytes ``` 比较实用的有: 指定出口网卡: ```bash speedtest -I 指定网卡名称 ``` 指定出口 IP: ```bash speedtest -i IP 地址 ``` _注意指定网卡或 IP 后可能会出现 `[error] Error: [0] Cannot open socket` 的错误提示,忽略即可。_ 查看附近的测速节点列表: ```bash speedtest -L ``` 指定某个测速节点: ```bash speedtest -s 测速节点 ID ``` ## systemd-networkd-wait-online.service 如果用 `netplan` 控制网络设备,同时把设备设置成固定 ip。如果对应的设备不存在,那么在系统启动后 `systemd-networkd-wait-online.service` 就会卡住几分钟,导致后续的服务无法执行。 这个问题有多种方式可以解决: 1. 如何使用 `NetworkManager` 管理网络, 可以在 `netplan` 配置文件中添加 `renderer: NetworkManager`; 2. 修改 `systemd-networkd-wait-online.service` 的超时时间: ```bash [Service] ExecStart= ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --timeout=5 ``` 3. 使用 `NetworkManager-wait-online.service` 代替; 因为我已经使用 `NetworkManager` 来接管网络了, 所以完全可以禁用 `systemd-networkd-wait-online.service`, 而且 `NetworkManager` 也自带一个 `NetworkManager-wait-online.service`, 可以避免依赖网络的自启动服务在启动时出现问题: ```bash sudo systemctl disable systemd-networkd-wait-online.service \ && sudo systemctl mask systemd-networkd-wait-online.service \ && sudo systemctl stop systemd-networkd-wait-online.service ``` > `systemd mask` 的作用是 **彻底屏蔽一个服务,防止它被启动**,即使其他服务或依赖项尝试启动它,也不会生效。 > > | 操作 | 作用 | 是否可以被手动启动? | 依赖的服务能否启动它? | > | --------- | ------------ | ------------------------- | ---------------------- | > | `disable` | 取消开机自启 | ✅ 可以 `systemctl start` | ✅ 其他服务可以启动 | > | `mask` | 完全屏蔽 | ❌ 无法手动 `start` | ❌ 其他服务无法启动 | > > **简单理解:** > > - disable **只是禁止开机自启**,但仍然可以手动或被其他服务调用启动。 > - mask **彻底屏蔽**,即使你手动 systemctl start 也不会启动,会报错: > > ```bash > Failed to start systemd-networkd-wait-online.service: Unit is masked. > ``` > > **应用场景**: > > 可以用 mask 来屏蔽那些 **你不需要且可能干扰系统** 的服务,比如: > > 1. **systemd-networkd-wait-online.service 影响启动速度**(如果你用 NetworkManager ) > 2. **systemd-resolved.service 冲突**(如果你用 dnsmasq ) > 3. **某些自动更新服务**(如果不希望它们运行) ### 参考: - [systemd-networkd-wait-online 拖慢 Ubuntu 18.04 云主机开机的排查手记](https://xzclip.cn/tech-records/systemd-networkd-wait-online-stuck-boot-ubuntu-1804/) ## [使用 Cloudflare 增强公网服务安全性的实践](https://blog.dong4j.site/posts/5fa20a9e.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 前几天在 Cloudflare 上买了一个 dev 域名, 想着使用 Cloudflare 强大的 proxy 功能增加暴露到公网服务的安全性, 今天尝试了一下, 感觉基本满足要求, 下面说说折腾过程. ## 现状 ![20250213003153_vrv0wkOy.webp](https://cdn.dong4j.site/source/image/20250213003153_vrv0wkOy.webp) 上图是现在的流量路径: 1. 路由器将 `1000` 端口的流量全部转发到雷池 WAF 所在的服务器上(192.168.1.2:1000); 2. 雷池 WAF 的 1000 端口配置了 HTTPS 证书, 然后在反向代理到局域网内真实的服务器上; 现在的问题是可以通过暴露出去的域名得到我真实的公网 IP 地址, 虽然公网 IP 会不定期变更, 但是感觉还不是特别安全, 所以打算使用 Cloudflare 来增加安全防护. ## 使用 Cloudflare 我这里以在外网访问家中的 NAS 为例, 方便理解. ![20250213004537_s0Ig2NR4.webp](https://cdn.dong4j.site/source/image/20250213004537_s0Ig2NR4.webp) 我预期的效果为 **通过 https://nas.dong4j.dev 访问家中的 NAS**. 我们都知道家用宽带的 80 和 443 端口是被运营商封了的, 为了实现上面的效果, 我们需要对 CloudFlare 有个基础的了解. --- ### CloudFlare CDN 支持的端口 在我们使用 CloudFlare CDN 的时候,对其 CDN 支持的一些端口不太熟悉。以为 CloudFlare 是只允许 80/443 端口走 CDN 的。其实不然,CloudFlare 有一些端口是允许 HTTP/HTTPS 流量走 CDN 的。默认情况下,Cloudflare 会代理发往下面列出的 HTTP/HTTPS 端口的流量: | **HTTP 端口** | **HTTPS 端口** | **禁用缓存的端口** | | ------------- | -------------- | ------------------ | | 80 | 443 | 2052 | | 8080 | 2053 | 2053 | | 8880 | 2083 | 2082 | | 2052 | 2087 | 2083 | | 2082 | 2096 | 2086 | | 2086 | 8443 | 2087 | | 2095 | | 2095 | | | | 2096 | | | | 8880 | | | | 8443 | 意思是假设 `nas.dong4j.dev` 对应的 IP 为 `11.11.11.11`, 当我们访问 `https://nas.dong4j.dev:2053` 时, 会被代理到 `https://11.11.11.11:2053`, 但是不再上述列表的端口则无法代理, 比如 `https://nas.dong4j.dev:12053` 所以我们可以利用这点来代理我们内网的服务: ![20250213112400_2psIANxI.webp](https://cdn.dong4j.site/source/image/20250213112400_2psIANxI.webp) **路由器端口转发配置:** ![20250213112522_V7f18YZR.webp](https://cdn.dong4j.site/source/image/20250213112522_V7f18YZR.webp) **雷池代理配置** ![20250213112754_qL4jNqd7.webp](https://cdn.dong4j.site/source/image/20250213112754_qL4jNqd7.webp) 完成后我们即可通过 `https://nas.dong4j.dev:8443` 访问内网的 NAS. ![20250213113556_3IB6zEr0.webp](https://cdn.dong4j.site/source/image/20250213113556_3IB6zEr0.webp) 但我们最终的目的是使用 `https://nas.dong4j.dev`, 即在不加端口的情况下访问, 这里就要使用 Cloudflare 的 Origin Rules. ### Cloudflare 的 Rules Cloudflare 包含了众多的 Rules, 相当于过滤器能对进入的流量按照规则一步一步处理: ![20250213114150_xF30VFzi.webp](https://cdn.dong4j.site/source/image/20250213114150_xF30VFzi.webp) 为了实现上述需求, 我们有多种方式: #### Page Rules ![20250213115101_NT2lFPKB.webp](https://cdn.dong4j.site/source/image/20250213115101_NT2lFPKB.webp) 将 `https://tonas.dong4j.dev` 重定向到 `https://nas.dong4j.dev:8443` #### Origin Rules 因为 Page Rules 免费用户最多添加 3 条, 数量比较宝贵, 所以我选择 Origin Rules 来实现我上面的需求. ![20250213115516_L2P6Piwy.webp](https://cdn.dong4j.site/source/image/20250213115516_L2P6Piwy.webp) 这样我们访问 `https://nas.dong4j.dev` 时会自动重定向到 `https://nas.dong4j.dev:8443` ### Cloudflare Workers 最后一种方法是利用功能强大的 Cloudflare Workers,但是需要写代码。一个简单的转发所有请求到新网址的 worker 代码示例: ```javascript export default { async fetch(request) { const base = "https://nas.dong4j.dev:8443"; // 定义目标域名和端口 const statusCode = 301; // 设置 HTTP 状态码为 301,表示永久重定向 const url = new URL(request.url); // 获取当前请求的 URL 对象 const { pathname, search } = url; // 从 URL 中提取路径(pathname)和查询字符串(search) const destinationURL = `${base}${pathname}${search}`; // 构建目标 URL,将 base 与请求的路径和查询参数拼接在一起 return Response.redirect(destinationURL, statusCode); // 返回一个 HTTP 重定向响应,指向构建的目标 URL,并使用 301 状态码 }, }; ``` 通过 Cloudflare worker,实际上已经能实现我们所有的需求了,但是部署和调试更麻烦,适合高级玩家。Cloudflare worker 另外一个缺点是免费账户每天请求限制 10 万次,当然对于个人用途也足够了。 ### Cloudflare SSL 在 Cloudflare 购买的 dev 域名可以免费获取 SSL 证书, 有效期为 3 个月, 不过不用担心, Cloudflare 会自动续期并部署, 这一切都不需要我们做任何操作. 这里我们需要了解一下 Cloudflare SSL 相关的基础知识: ![20250213120432_XXRyhrfY.webp](https://cdn.dong4j.site/source/image/20250213120432_XXRyhrfY.webp) Cloudflare 作为中间代理, 流量需要经过 Cloudflare 服务器, 然后再转发到源服务器, 所以这中间需要 2 个 SSL 证书: - 浏览器到 Cloudflare 的流量, 通过 **边缘证书** 来保证数据安全, 这个 **边缘证书** 由 Cloudflare 管理, 当然你也可以使用自己的证书, 不过需要升级到 Business 计划; - Cloudflare 到源服务器的流量, 通过 **源服务器证书** 来保证数据安全, 这个证书就是我们平时使用 **[Let's Encrypt](https://letsencrypt.org/)** 申请的证书, 这个需要配置到源服务器上, 比如 Nginx; Cloudflare 真的配得上 『**赛博佛祖**』 的称号, 免费的服务基本上能够满足绝大多数个人需求, 这里我还从 Cloudflare 白嫖了一个 15 年的免费 TLS 证书: ![20250213121227_57gxNRu6.webp](https://cdn.dong4j.site/source/image/20250213121227_57gxNRu6.webp) 上面一番配置后, 我们的真实 IP 被 Cloudflare 隐藏了: ```bash $ dig nas.dong4j.dev ; <<>> DiG 9.18.30-0ubuntu0.24.04.2-Ubuntu <<>> nas.dong4j.dev ;; global options: +cmd ;; Got answer: ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 48841 ;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1 ;; OPT PSEUDOSECTION: ; EDNS: version: 0, flags:; udp: 65494 ;; QUESTION SECTION: ;nas.dong4j.dev. IN A ;; ANSWER SECTION: nas.dong4j.dev. 1 IN A 104.21.22.178 nas.dong4j.dev. 1 IN A 172.67.206.92 ;; Query time: 6 msec ;; SERVER: 127.0.0.53#53(127.0.0.53) (UDP) ;; WHEN: Thu Feb 13 12:16:14 CST 2025 ;; MSG SIZE rcvd: 75 ``` 且还具备完善的 SSL 加密, 所以下一步的计划是将暴露到外网的 API 全部使用 Cloudflare 来代理, 增强 Homelab 的安全性. ## 总结 这是第一次使用 Cloudflare, 我的体会是 Cloudflare 就像一个 Nginx, 能够在上面配置各种规则, 比如缓存, 重定向, 负载均衡等等, 当然这只是其中一小部分功能, 其他的功能我会持续挖掘, 比如强大的 Cloudflare Workers. ## 参考 - [How to enable Cloudflare's proxy for additional ports](https://developers.cloudflare.com/fundamentals/reference/network-ports/) - [Where to? Introducing Origin Rules](https://blog.cloudflare.com/origin-rules/) - [30 天入门Cloudflare](https://medium.com/chouhsiang/cloudflare-30-days/home) ## [Ubuntu 系统下 LCD4Linux 的安装与配置指南](https://blog.dong4j.site/posts/b0f649a0.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 很久以前买 R2S 的时候一起买了一个 LCD 小屏幕, 一直没用起来, 这次装了个小主机, 想着看能不能废物利用一下. [LCD4Linux](https://wiki.lcd4linux.tk/doku.php/start) 是一个小程序,它从内核和一些子系统抓取信息并将其显示在外部*液晶显示器*上。 下面是一些运行 LCD4Linux 的图片: **LCD4Linux 在两个不同尺寸的显示器上** ![20250212210620_gB8IzF5R.webp](https://cdn.dong4j.site/source/image/20250212210620_gB8IzF5R.webp) **Iomega HMNHD-CE 上 的 LCD4Linux** ![20250212210620_XB3aHdmZ.webp](https://cdn.dong4j.site/source/image/20250212210620_XB3aHdmZ.webp) ### 安装 lcd4linux ``` $ sudo apt-get install lcd4linux ``` 查看 USB 屏幕的设备名称 ``` $ lsusb ``` 将会看到有`lcd2usb interface`的设备出现 ```bash Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub Bus 002 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub Bus 003 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub Bus 003 Device 003: ID 8087:0032 Intel Corp. AX210 Bluetooth Bus 003 Device 013: ID 0403:c630 Future Technology Devices International, Ltd lcd2usb interface Bus 003 Device 014: ID 2c7c:0125 Quectel Wireless Solutions Co., Ltd. EC25 LTE modem Bus 004 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub Bus 004 Device 002: ID 0bda:8156 Realtek Semiconductor Corp. USB 10/100/1G/2.5G LAN ``` > 如果看不到, 可以更换一根线试试, 我就是这么解决的. 这里我们可以看到 当前设备的连接位置是在: ```bash Bus 003 Device 013: ID 0403:c630 Future Technology Devices International, Ltd lcd2usb interface ``` 然后可以在 `/dev/bus/usb/003/013` 看到我们的 USB 屏幕,记下这个路径, 后续会在配置文件中使用到. ### 配置 由于安全原因(配置可能包含邮件帐户的用户名/密码),配置文件必须确保仅具有用户的权限。小组或其他人不得读写,否则 lcd4linux 拒绝工作! 因此,如果以 root 身份运行 lcd4linux, `/etc/libd4linux.conf` 必须是: ``` chmod 600 /etc/lcd4linux.conf chown root.root /etc/lcd4linux.conf ``` ``` $ cd /etc $ sudo vi lcd4linux.conf ``` 先来个简单的 CPU 监控显示配置: ``` Display LCD2USB { Driver 'LCD2USB' # 指定使用LCD2USB驱动 Size '16x2' # LCD dimension: 1602 Port '`/dev/bus/usb/003/002`' # Port: /dev/usbdev3.2 } # CPU使用率部件 Widget CPU { class 'Text' #部件类型指定为文本 expression proc_stat::cpu('busy', 500) prefix 'CPU:' #前缀 postfix '% |' #后缀 width 10 #部件占用字符数 precision 1 align 'L' # L R 分别表示左对齐 和右对齐 update 500 # 更新频率 500毫秒 } Widget RAM { class 'Text' expression meminfo('MemTotal')/1024 prefix 'RAM:' postfix ' MB' width 8 precision 0 align 'R' update 500 } Widget IPaddress { class 'Text' # Type: Text expression netinfo::ipaddr('vmbr0') # eth0's ip prefix '' # display "IP:" width 16 # display width: 16 align 'C' # display: central update 1000 } Widget Time { class 'Text' expression strftime('%a %H:%M:%S',time()) width 16 align 'C' update 1000 } Layout Default { Row1 { Col1 'IPaddress' # Display Widget IPaddress in the first row and first column } Row2 { Col1 'CPU' # Display Widget Time in the second row and first column # Col9 'RAM' } } Display 'LCD2USB' Layout 'Default' ``` ### 启动 启动 lcd4linux 前请确定配置文件 `lcd4linux.conf`的权限是 600. 启动命令如下, `-v` 表示显示启动日志,如果失败将会显示错误原因 ``` $ pkill lcd4linux $ lcd4linux -v ``` ![20250212210621_PfhvFnBe.webp](https://cdn.dong4j.site/source/image/20250212210621_PfhvFnBe.webp) 我的这块是 16x2 的, 显示不了多少信息, 后面看能不能换一个大点的玩玩. ## 详细配置解释 > 配文件内容分 3 个部分 ### 屏幕配置 ``` Display LCD2USB { Driver 'LCD2USB' # 指定使用LCD2USB驱动 Size '16x2' # LCD dimension: 1602 Port '/dev/usbdev3.2' # Port: /dev/usbdev3.2 } ``` ### 定义显示部件 此处可以定义多个部件,部件中的表达式可以参考 [官方提供的部件列表 Plugins](https://wiki.lcd4linux.tk/doku.php/plugins) 常用到的有: #### plugin_meminfo 内存插件 > 该插件提供了 /proc/meminfo 文件的接口。 > `meminfo(key)/proc/meminfo 并返回的值` > 'key'参数没有任何固定值,但作为搜索键进入/ proc / meminfo 文件。常用键是“MemTotal”或“MemFree”。执行'cat / proc / meminfo'以查看系统上可用的值。 ``` Widget RAM { class 'Text' expression meminfo('MemTotal')/1024 postfix ' MB RAM' width 11 precision 0 align 'R' update 0 } ``` #### plugin_proc_stat 系统状态插件 | 表达式 | 解释 | | ----------------------------------- | --------------------------- | | proc_stat(key) | 从`/proc/stat`直接取值 | | proc_stat(key, delay) | 从`/proc/stat`取变化量 | | proc_stat::cpu(key, delay) | 从`/proc/stat`获取 CPU 信息 | | proc_stat::disk(device, key, delay) | 从`/proc/stat`获取硬盘信息 | 示例:`CPU:12%` ``` Widget CPU { class 'Text' expression proc_stat::cpu('busy', 500) prefix 'CPU:' postfix '% |' width 10 precision 1 align 'L' update tick } Widget CPUBar { class 'Bar' expression proc_stat::cpu('busy', 500) expression2 proc_stat::cpu('system', 500) length 10 align 'L' direction 'E' update tack } ``` #### uptime 启动时间插件 > 此插件以秒或以用户定义的格式返回当前系统的正常运行时间 | 表达式 | 解释 | | -------------- | ---------------------- | | uptime() | 返回系统启动的秒数 | | uptime(format) | 以用户定义格式返回时间 | format 的可选格式(格式指定类似于 `printf()`方法) | 表达式 | 解释 | | ------ | ----------------- | | %s | 总秒数 | | %S | 从 00-59 的秒数 | | %m | 总分钟数 | | %M | 从 00-59 的分钟数 | | %h | 总小时数 | | %H | 从 00-23 的小时数 | | %d | 总天数 | 例子: `Run 12 days 12:32:59` ``` Widget Uptime { class 'Text' expression uptime('%d days %H:%M:%S') width 20 align 'L' prefix 'Run ' update tick } ``` 这里是一部分表达式的示例,其余可以通过[官方提供的部件列表 Plugins](https://link.juejin.cn?target=https%3A%2F%2Flcd4linux.bulix.org%2Fwiki%2FPlugins) 进行查看 ### 指定布局 > 通过`Row 行`与`Col 格`进行布局安排, 后面的数字表示具体的行数和格数 ``` Layout Default { Row1 { Col1 'MyInfo' # 从第一行第一格开始显示 } Row2 { Col1 'CPU' # 从第二行第1格开始显示 Col11 'MEM' # 从第二行第11格开始显示(我的设备总计20格每行) } Row3 { Col1 'IPaddress' } Row4 { Col1 'Uptime' } } ``` ## 参考资料 - [详述群晖 NAS 配置 DPF 数码相框历程,分享 lcd4linux 精心配置 CONF](https://www.gebi1.com/forum.php?mod=viewthread&tid=243605&extra=page%3D1) - [编译 LCD4Linux 增加 Image,VNC,X11 驱动](https://blog.joylau.cn/2024/10/31/Daily-LCD4Linux/) - [Matrix - LCD2USB](https://wiki.friendlyelec.com/wiki/index.php/Matrix_-_LCD2USB) ## [树莓派与移远EC20 4G网卡集成及自动拨号方案解析](https://blog.dong4j.site/posts/c78c58c7.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 前段时间在小黄鱼上捡了 2 张移远的 EC20, 今天折腾一下这张 4G 网卡. ## 实战 - 移远 EC20 mini PCIE 模块 - usb 转 mini PCIE 模块 - ipex1 代转 sma 内孔转接线 - sma 内针 4G 天线 ![20250212192830_2BtPoWN6.webp](https://cdn.dong4j.site/source/image/20250212192830_2BtPoWN6.webp) 加上风扇: ![20250212192830_NWDXX1ut.webp](https://cdn.dong4j.site/source/image/20250212192830_NWDXX1ut.webp) 合体后的样子: ![20250212192830_UdmIlPRM.webp](https://cdn.dong4j.site/source/image/20250212192830_UdmIlPRM.webp) 检查 USB 设备中是否存在 4G 模块 ```bash lsusb Bus 001 Device 003: ID 2c7c:0125 Quectel Wireless Solutions Co., Ltd. EC25 LTE modem Bus 001 Device 002: ID 2109:3431 VIA Labs, Inc. Hub Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub ``` 获取 4G 模块 USB 转串口终端 ```bash ls /dev/ttyUSB* /dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2 /dev/ttyUSB3 ``` ### ppp0 拨号 [第三节 树莓派 EC20 之 PPP 拨号上网\_树莓派拨号上网-CSDN 博客](https://blog.csdn.net/qq_30112663/article/details/124115912) **安装拨号工具** ```bash sudo apt install wvdial # 打开配置文件 sudo vim /etc/wvdial.conf ``` [Wvdial 拨号上网 - Waveshare Wiki](https://www.waveshare.net/wiki/Wvdial%E6%8B%A8%E5%8F%B7%E4%B8%8A%E7%BD%91) ```bash # 电信 [Dialer T] Init1 = ATZ Init2 = ATQ0 V1 E1 S0=0 &C1 &D2 +FCLASS=0 Modem Type = Analog Modem Baud = 9600 New PPPD = yes Modem = /dev/ttyUSB3 ISDN = 0 Phone = *99# Password = card Username = card # 联通 [Dialer U] Init1 = ATZ Init2 = ATQ0 V1 E1 S0=0 &C1 &D2 +FCLASS=0 Init3 = at+cgdcont=1,"ip","uninet" Modem Type = Analog Modem Baud = 9600 New PPPD = yes Modem = /dev/ttyUSB3 ISDN = 0 Phone = *99# Password = card Username = card ``` **拨号** ```bash ➜ ~ sudo wvdial T --> WvDial: Internet dialer version 1.61 --> Initializing modem. --> Sending: ATZ OK --> Sending: ATQ0 V1 E1 S0=0 &C1 &D2 +FCLASS=0 ATQ0 V1 E1 S0=0 &C1 &D2 +FCLASS=0 OK --> Modem initialized. --> Sending: ATDT*99# --> Waiting for carrier. ATDT*99# CONNECT 150000000 --> Carrier detected. Waiting for prompt. --> Don't know what to do! Starting pppd and hoping for the best. --> Starting pppd at Wed Feb 12 16:16:27 2025 --> Pid of pppd: 43894 --> Using interface ppp0 --> local IP address 10.61.4.81 --> remote IP address 10.64.64.64 --> primary DNS address 61.139.2.69 --> secondary DNS address 218.6.200.139 ``` ```bash ➜ ppp0 ip a | grep ppp0 23: ppp0: mtu 1500 qdisc pfifo_fast state UNKNOWN group default qlen 3 inet 10.61.4.81 peer 10.64.64.64/32 scope global ppp0 ``` 已经成功拨号 **测试** ```bash ➜ ~ ping -I ppp0 8.8.8.8 PING 8.8.8.8 (8.8.8.8) from 10.61.4.81 ppp0: 56(84) bytes of data. 64 bytes from 8.8.8.8: icmp_seq=1 ttl=54 time=133 ms 64 bytes from 8.8.8.8: icmp_seq=2 ttl=54 time=44.1 ms 64 bytes from 8.8.8.8: icmp_seq=3 ttl=54 time=46.1 ms 64 bytes from 8.8.8.8: icmp_seq=4 ttl=54 time=52.8 ms ``` **从公网访问 HomeLab**: ```bash ➜ ~ iperf3 -c zzz.xxx.yyy Connecting to host zzz.xxx.yyy, port 12421 [ 5] local 10.61.4.81 port 41632 connected to 123.123.123.123 port 12421 [ ID] Interval Transfer Bitrate Retr Cwnd [ 5] 0.00-1.00 sec 852 KBytes 6.98 Mbits/sec 0 64.5 KBytes [ 5] 1.00-2.00 sec 1.24 MBytes 10.4 Mbits/sec 0 126 KBytes [ 5] 2.00-3.00 sec 2.22 MBytes 18.6 Mbits/sec 0 226 KBytes [ 5] 3.00-4.00 sec 2.53 MBytes 21.2 Mbits/sec 8 147 KBytes [ 5] 4.00-5.00 sec 1.85 MBytes 15.5 Mbits/sec 0 167 KBytes [ 5] 5.00-6.00 sec 2.22 MBytes 18.6 Mbits/sec 0 176 KBytes [ 5] 6.00-7.00 sec 1.91 MBytes 16.0 Mbits/sec 10 138 KBytes [ 5] 7.00-8.00 sec 1.85 MBytes 15.5 Mbits/sec 0 146 KBytes [ 5] 8.00-9.00 sec 1.85 MBytes 15.5 Mbits/sec 0 149 KBytes [ 5] 9.00-10.00 sec 1.85 MBytes 15.5 Mbits/sec 1 155 KBytes - - - - - - - - - - - - - - - - - - - - - - - - - [ ID] Interval Transfer Bitrate Retr [ 5] 0.00-10.00 sec 18.4 MBytes 15.4 Mbits/sec 19 sender [ 5] 0.00-10.08 sec 17.5 MBytes 14.6 Mbits/sec receiver ``` ### 更换默认路由 ```bash # 查看路由 sudo route -n Kernel IP routing table Destination Gateway Genmask Flags Metric Ref Use Iface 0.0.0.0 192.168.31.1 0.0.0.0 UG 100 0 0 eth0 0.0.0.0 192.168.21.1 0.0.0.0 UG 600 0 0 wlan0 10.64.64.64 0.0.0.0 255.255.255.255 UH 0 0 0 ppp0 # 添加 ppp0 网卡设备为默认路由 sudo route add default dev ppp0 # 删除操作 sudo route del default dev ppp0 ``` 最后,其实 ppp0 拨号,是 4g 时代以前所使用的拨号方式,也限制 ec20 的入网速度,对于现在 4g/5g 时代,ppp0 拨号已经无法发挥模块的性能,官方也不再建议使用 ppp0 拨号,目前最常用的 GobiNet,而且树莓派内核已经支持了 qmi_wwan 的驱动. ### qmi_wwan 拨号 [第四节 树莓派 EC20 之 QMI_WWAN 拨号\_quectel-cm-CSDN 博客](https://blog.csdn.net/qq_30112663/article/details/124140466) ## 自动拨号 [第五节 树莓派 EC20 自动拨号脚本编写\_ec20 拨号脚本-CSDN 博客](https://blog.csdn.net/qq_30112663/article/details/124228764) ```bash #! /bin/bash #运行步骤变量初始化 ec20_step=0 #超时计数初始化 over_time=0 #循环 while [ 1 ] do #第一步先检查驱动 if [ $ec20_step -eq 0 ]; then #使用lsusb查看是否有ec20驱动 grep查询结果是否包含Quectel result=$(lsusb | grep Quectel) echo "1. 检查驱动: " $result if [[ $result =~ "EC25" ]]; then ec20_step=1 else ec20_step=0 fi #延时2s sleep 2 #第二步 开始使用 wvdial 拨号 elif [ $ec20_step -eq 1 ]; then echo "2. wvdial 拨号" echo "password" | sudo nohup wvdial T & ec20_step=2 sleep 2 #第三步 查询路由是否包含 ppp0 网卡,拨号成功则会包含有 ppp0 网卡 elif [ $ec20_step -eq 2 ]; then result=$(route -n | grep ppp0) echo "3. 检查是否存在 ppp0 网卡" $result if [[ $result =~ "ppp0" ]]; then echo "3.1 包含网卡,添加默认路由" #若包含网卡,则添加默认路由 echo "dusj@5282010" | sudo route add default dev ppp0 ec20_step=3 over_time=0 else echo "3.2 不包含网卡, 再次循环查询, 循环次数:" $over_time #超时计数 let over_time++ fi #若一分钟都没有路由网卡则说明没有拨号成功 if [ $over_time -eq 12 ]; then echo "12 次检查不存在路由网卡, 说明没有拨号成功, 进入重启逻辑" over_time=0 #超时拨号则跳入重启步骤 ec20_step=4 fi sleep 5 #第四步 通过 ping 命令检查网络状态 elif [ $ec20_step -eq 3 ]; then result=$(ping -I ppp0 -c 1 www.baidu.com) echo "4. 通过 ping 命令检查网络状态" $result if [[ $result =~ "1 received" ]]; then echo "4.1 ping 成功" over_time=0 else echo "4.2 ping 失败, 再次循环查询" let over_time++ fi #超时则杀掉拨号线程,并进入重启步骤 if [ $over_time -eq 6 ]; then echo "6 次 ping 失败, 进入重启逻辑" over_time=0 ec20_step=4 echo "dusj@5282010" | sudo pkill wvdial fi sleep 5 #第五步重启模块 elif [ $ec20_step -eq 4 ]; then echo "重启网卡" echo -e "AT+CFUN=1,1\r\n" > /dev/ttyUSB2 ec20_step=0 #重启命令后延时稍微长一点 sleep 15 fi done exit 0 ``` 开机自启动 ```bash sudo vim /etc/rc.local //在文件exit 0前加入此句,:wq保存,重启即可生效 # ppp0 拨号 bash /root/ppp0.start >> /root/ppp0.log 2>&1 ``` 另一种方式: ```bash [Unit] Description=ppp0 service After=network.target [Service] Restart=on-failure RestartSec=5 ExecStart=/bin/bash /root/ppp0.start StandardOutput=/root/ppp0.log StandardError=/root/ppp0.log [Install] WantedBy=multi-user.target ``` 查看日志 ```bash journalctl -u ppp0.service -f ``` 查看服务信息 ```bash systemctl show ppp0 ``` ### 发送短信 [让你的树莓派和手机备号不再吃灰:短信转发\_电脑整机\_什么值得买](https://post.smzdm.com/p/a4wme8zx/) 看这个 [树莓派+4G 模块接收短信实时转发到邮箱-CSDN 博客](https://blog.csdn.net/tiaga/article/details/127975344) [树莓派 EC20 模块联网 | yan blog](https://yancoding.github.io/posts/3c0ad28a2fa4/) **得先拨号并设置 ppp0 为默路由** ```bash sudo apt-get install gammu sudo gammu-config ``` ![20250212211654_FkHN4YkE.webp](https://cdn.dong4j.site/source/image/20250212211654_FkHN4YkE.webp) ```bash # 查看设备信息 sudo gammu --identify ``` ```bash sudo gammu sendsms TEXT 手机号 -text "这是一条短信,from raspi" -unicode echo "a test sms from ec20" | sudo gammu sendsms TEXT 18628362906 ``` 配置文件保存在 /root/.gammurc ```bash # This is a generated gammurc file. # It was generated by Gammu configurator 0.4 # In Unix/Linux : copy it into your home directory and name it .gammurc # or into /etc and name it gammurc # In Win32 : copy it into directory with Gammu.exe and name gammurc # Port : in Windows/DOS: "com*:", # (instead of "*" please put "1", "2", etc.) # in other (Linux/Unix) "/dev/ttyS%" # or "/dev/ircomm%" ("irda" connection) # (instead of "%" please put "0", "1", "2", etc.) # Model : use only, when Gammu doesn't recognize your phone model. # Put it here. Example values: "6110", "6150", "6210", "8210" # Connection : type of connection. Use "fbus" or "mbus" or "dlr3" or # "irda" (Infrared over sockets) or "infrared" (DirectIR) # or "at19200" (AT commands on 19200, 8 bits, None parity, # 1 stop bit, no flow control) or "at115200" (AT commands on # 115200, 8 bits, None parity, 1 stop bit, no flow control) # or "atblue" (AT over BlueTooth) or "dlr3blue" (FBUS # over BlueTooth) # SynchronizeTime: if you want to set time from computer to phone during # starting connection. Do not rather use this option when want # to reset phone during connection (in some phones need to # set time again after restart) # Logfile : Use, when want to have logfile from communication. # Logformat : What debug info and format should be used: # "nothing" - no debug level, "text" - transmission dump in # text format, "textall" - all possible info in text format, # "errors" - errors in text format, "binary" - transmission # dump in binary format # Use_Locking : under Unix/Linux use "yes", if want to lock used device # to prevent using it by other applications # GammuLoc : name of localisation file [gammu] port = /dev/ttyUSB3 model = connection = at19200 synchronizetime = yes logfile = logformat = nothing use_locking = gammuloc = ``` ### 拨打电话 [【树莓派】4G 模块打电话\_树莓派打电话-CSDN 博客](https://blog.csdn.net/weixin_44566432/article/details/110095361) ### GPS [19. 使用 4G 模块 — [野火]快速使用手册——基于 STM32MP157 开发板 文档](https://doc.embedfire.com/linux/stm32mp1/quick_start_guide/zh/latest/quick_start/4g_module/ec20_4g_module.html) [blog.51cto.com/u_16213606/8883744](https://blog.51cto.com/u_16213606/8883744) [树莓派+4G 模块获取 gps 坐标 - guwei4037 - 博客园](https://www.cnblogs.com/guwei4037/p/14259371.html) [EC20 模块在树莓派下查看 GPS 数据的演示\_ec20 是全系列支持 gps 么-CSDN 博客](https://blog.csdn.net/hzxiao1981/article/details/108295789) ```bash sudo apt-get install minicom sudo minicom -D /dev/ttyUSB2 # 开启 GPS AT+QGPS=1 # 新开 console # 查看 GPS 数据 sudo minicom -D /dev/ttyUSB1 ``` 三、通过 gpsd 查看 gps 数 据 minicom 查看 gps 数据不太好看(数据没有格式化显示),有 gpsd 工具帮助我们更好的观察数据变化。 **1.安装 gpsd** ```bash sudo apt-get install gpsd gpsd-clients python3-gps ``` **2.配置 gpsd** ```bash sudo gpsd /dev/ttyUSB1 -N -D 9 -F /var/run/gpsd.sock -S 3333 ``` 其中 3333 是端口号,可以自行定义 **3.监听 gpsd** 新开一个终端,执行 `cgps -s localhost:3333` 启动之后,如果出现短时间收不到数据的情况,请耐心等待几分钟。一般过个 1 分钟左后会收到 gps 数据的。 最后,读者可以自己写程序监控 ttyUSB1 串口输出,解析数据就可以得到 gps 信息了。然后上传 gps 坐标,可以通过 socket 或其它形式与服务器通信(前提已配置 4G 卡无线上网),把坐标信息保存到服务器数据库。 ### AT [blog.51cto.com/u_13640625/3029461](https://blog.51cto.com/u_13640625/3029461) ```bash sudo apt-get install minicom sudo minicom -s sudo minicom -b 9600 -o -D /dev/ttyUSB0 busybox microcom -s 115200 /dev/ttyUSB2 ``` ### 全集 [使用 EC20 模块配合 asterisk 及 freepbx 实现短信转发和网络电话 | Sparktour's Blog](https://blog.sparktour.me/posts/2022/10/08/quectel-ec20-asterisk-freepbx-gsm-gateway/) ```bash ttyUSB0 ttyUSB1 PCM语音,GPS信号 ttyUSB2 控制命令 ttyUSB3 ``` ![20250212211654_FMYNXrjX.webp](https://cdn.dong4j.site/source/image/20250212211654_FMYNXrjX.webp) EC20 挂载系统成功后,在 Windows 环境下会有三个 com 口,分别为 AT Port、DM Port、NMEA Port。其中 AT Port 用于 AT 指令的收发,而 NMEA Port 用于 GPS NMEA 数据的接收。 在 Linux 系统下,EC20 被成功识别并加载后,会有四个/dev/ttyUSBx 设备文件, ttyUSB2 用于 AT 指令收发,ttyUSB1 用于 GPS NMEA 的接收。 ```bash /dev/ttyUSB0:DM 功能(Diagnostic and Monitoring,诊断和监控) /dev/ttyUSB1:GPS NMEA 数据接收 /dev/ttyUSB2:AT 指令收发 /dev/ttyUSB3:PPP 连接或者 AT 指令收发 ``` ## 参考资料 ### SIM7600CE - [树莓派系列教程:通过 SIM7600 4G 模块 NDIS 拨号 - 树莓派入门教程 微雪课堂](https://www.waveshare.net/study/portal.php?mod=view&aid=962) - [树莓派系列教程:通过 SIM7600 4G 模块 NDIS 拨号](https://spotpear.cn/index/study/detail/id/268.html) ### EC20 - [树莓派 4B 4G 随身 wifi - Zok 的博客](https://zhangkunzhi.com/2022/05/17/%E6%A0%91%E8%8E%93%E6%B4%BE4B-4G%E9%9A%8F%E8%BA%ABwifi/) - [树莓派+4G 模块接收短信实时转发到邮箱\_树莓派外接 sim 卡模块-CSDN 博客](https://blog.csdn.net/tiaga/article/details/127975344) - [4G 模块 :EC20 模块———AT 指令收发短信-CSDN 博客](https://blog.csdn.net/weixin_51600893/article/details/131969350) - [树莓派使用 EC20 上网\_ec20 模块通过物理串口联网-CSDN 博客](https://blog.csdn.net/LawSome/article/details/125743634) - [树莓派专用外壳版 EC20 4GLTE 模块 语音短信 GPS Ubuntu SAM9X25—基础版 - 树莓派 4G 模块 Mcuzone 商城](http://www.mcuzone.com/SHOP/?product-468.html) - [树莓派 EC20 模块联网 | yan blog](https://yancoding.github.io/posts/3c0ad28a2fa4/) - [第三节 树莓派 EC20 之 PPP 拨号上网\_树莓派拨号上网-CSDN 博客](https://blog.csdn.net/qq_30112663/article/details/124115912) - [第四节 树莓派 EC20 之 QMI_WWAN 拨号\_quectel-cm-CSDN 博客](https://blog.csdn.net/qq_30112663/article/details/124140466) - [第五节 树莓派 EC20 自动拨号脚本编写\_ec20 拨号脚本-CSDN 博客](https://blog.csdn.net/qq_30112663/article/details/124228764) ## [使用 TTL 连接到树莓派 Zero 2W 的详细指南](https://blog.dong4j.site/posts/b13a9376.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 今天折腾一下使用 TTL 连接到树莓派 Zero 2W. > 串口连接是一种常用的通信方式,通常用于计算机与外部设备之间的数据传输。它是通过一根或多根电缆将设备通过串行接口连接起来,数据一位一位地按顺序传输。串口通信可以分为两种类型:RS-232 和 TTL,分别代表不同的电气标准和通信协议。 ## 开启串口 ```bash sudo raspi-config->Interfacing options-> Serial->Yes ``` ## 安装驱动 macOS 14+ 后自带 CH340X 驱动, 因此直接用就可以了: ```bash ls /dev/cu.* 输出: cu.usbmodem57590428591 ``` ## screen ```bash brew install screen # 这里的 11520 是波特率,在树莓派上的 /boot/cmdline.txt 进行设置 screen /dev/cu.usbmodem57590428591 115200 ``` ## Notice 执行完会进入一个空界面,此时按 Enter 键,就会出现 Raspberry Pi 的登录提示了。 将树莓派进行 shutdown 之后,如果想再次连接,需要将数据线拔掉,然后再插上。此时可以再次连接,否则可能会出现 could not find PTY 的错误提示。 终端退出的时候不会自动断开与树莓派的连接: 这时如果直接拔掉 USB 串口板,会造成系统重启。需要执行:ps -x|grep tty,得到串口连接的进程号,然后:kill 进程号。 如果只是不小心给关了,需要再次连接,同样需要 kill 一下,然后再 screen 进行连接,否则也可能会出现 could not find PTY 的错误提示。 ```bash ps -ef | grep -v grep | grep --color=auto /dev/cu.usbmodem57590428591 ``` ## screen ```bash 按 ctrl + a ,ctrl不放,再按 d 键暂时退出终端 # 查看会话 screen -ls There is a screen on: 1995.ttys000.hepingdeMacBookPro1 (Detached) 1 Socket in /var/folders/gl/843qz1j92z5gh6j25h_r09vh0000gn/T/.screen. screen -r 1995 #-r就是指定会话id 按 ctrl + a ,ctrl不放,再按 k 键干掉screen进程 按k的时候,会询问要不要干掉这个会话,直接打y即可 ``` ![20250212192829_bhkQZP09.webp](https://cdn.dong4j.site/source/image/20250212192829_bhkQZP09.webp) ## USB 端 ![20250212192830_YllQTeNl.webp](https://cdn.dong4j.site/source/image/20250212192830_YllQTeNl.webp) ## 设备端 ![20250212192830_Met2hLTd.webp](https://cdn.dong4j.site/source/image/20250212192830_Met2hLTd.webp) ## 树莓派 4B 连接 Zero 2W - [树莓派 4B 安装 CH340 驱动](https://blog.csdn.net/qq_18677445/article/details/131191383) - [树莓派安装 CH340 驱动(USB 转串口)](https://blog.csdn.net/lwpo2008/article/details/103802483) - [Linux 笔记 | CH340/CH34x 驱动安装笔记-ROS 技术空间](http://keaa.net/linux-ch34x-driver.html) - [使用 USB 转 UART 串行线登录树莓派 5 | 树莓派实验室](https://shumeipai.nxez.com/2024/05/07/login-to-your-raspberry-pi-5-using-a-usb-to-uart-cable.html) ## 参考 - [Mac 上使用串口登陆树莓派 3](https://www.yanxurui.cc/posts/project/2017-08-28-login-raspberry3-via-uart-on-mac/) ## [Hexo 部署利器:GitHub Actions 实现自动化发布](https://blog.dong4j.site/posts/a754e8c8.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 一直在使用 hexo-deploy-git 插件部署我的部署到 Github Page, 不知道最近抽什么风, Github Pages 部署一直失败. 今天有空查看了日志才发现一直使用的是 Jekyll 来编译我的静态文件, 应该是我以前的博客需要使用 Jekyll 来编译, 不过现在已经不需要了, 因为我上传的已经是 Hexo 编译后的静态文件了, 最近应该是修改了某些插件导致 Jekyll 解析 URL 出现了问题所以才暴露了这个问题. ## 解决方法 因为我直接上传的 Hexo 编译后的今天文件, 所以只需要将静态文件通过 Github Action 拷贝到 Github Pages 即可, 所以部署应该更简单, 下面是详细过程. ### 修改 Pages 配置 Github Pages 默认使用 Jekyll 来部署: ![20250211200353_faujOtZF.webp](https://cdn.dong4j.site/source/image/20250211200353_faujOtZF.webp) 所以这里需要修改为使用自定义 action 来部署: ![20250211200508_J2LdVbxd.webp](https://cdn.dong4j.site/source/image/20250211200508_J2LdVbxd.webp) ### 添加 action 配置 ```yaml # Simple workflow for deploying static content to GitHub Pages name: Deploy static content to Pages on: # Runs on pushes targeting the default branch push: branches: ["deploy"] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: group: "pages" cancel-in-progress: false jobs: # Single deploy job since we're just deploying deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Pages uses: actions/configure-pages@v5 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: # Upload entire repository path: '.' - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ``` 意思是会将 `deploy` 分支部署到 GitHub Pages, 操作步骤一目了然. ### 部署命令 这里放弃了使用 `hexo-deploy-git` 插件, 直接使用自定义命令来执行部署, 定制化更强. 需要定制的操作是 Github Action 的配置如何处理. 因为我们知道 `hexo-deploy-git` 其实是将编译到 `public` 目录下的文件全部拷贝到 `.deploy_git` 目录下, 然后执行强推覆盖仓库的文件的, 所以我们需要想办法将 GitHub Action 配置添加到仓库, 下面是全部的部署命令: ```bash mkdir -p .deploy_git \ && cd .deploy_git \ && git init \ && git remote add origin git@github.com:dong4j/dong4j.github.io.git \ && git checkout -b deploy \ && mkdir -p .github/workflows && cp ../.nojekyll ./ && cp ../.github/workflows/static.yml .github/workflows/ \ && cp -r ../public/* ./ \ && git add . \ && git commit -m "auto deploy" > /dev/null 2>&1 \ && git push origin deploy --force ``` > 因为我使用 makefile 来管理 hexo 的整个生命周, 而上面的命令应该是 hexo g 之后才能执行. 关键的操作为: - 使用 deploy 分支, 这个需要和 Github Action 配置保持一致; - 拷贝 `public` 目录下编译后的文件到 `.deploy_git` 目录中; - 添加 `.github/workflows` 中的 Action 配置文件, 添加 `.nojekyll` 文件从而禁用 Jekll(可选); - 强制推送到远端仓库; ### 问题 一套流程下来, 执行 GitHub Action 是出现了问题: ![20250211205142_cMa0f4WG.webp](https://cdn.dong4j.site/source/image/20250211205142_cMa0f4WG.webp) 需要修改一下 rules, 删除这个 rule 即可: ![20250211205458_osSahphX.webp](https://cdn.dong4j.site/source/image/20250211205458_osSahphX.webp) ## 总结 这套流程操作下来, 减少了部署时间, 主要是我沿用了老博客的 Github Pages 配置, 但是今天才爆出问题我也非常诧异, 没想到 Jekll 的兼容性这么好. ## [捡垃圾的快乐:因为一张显卡组装了一台服务器](https://blog.dong4j.site/posts/ded15d16.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 > 一年前购买了一张 Tesla P40, 花了 700+, 现在已经涨价到 1500+, 显卡真的是理财产品. 年前将装在公司服务器上的的 Tesla P40 带回了家, 想着如何在家将这张卡再次利用起来, 找了一圈了最终选择使用猛兽峡谷 11 代计算卡 + 外置机箱来配合这张卡. ## 硬件配置 我比较喜欢开放型机箱, 相比有大比笨重的传统机箱, 我更喜欢看到内部的元器件, 所以这次还是挑选的一个开放型平台: 1. 1200W CSPS 电源 + CSPS 转接板; 2. 猛兽峡谷 11 代, i7 11700B 计算卡; 3. Tesla P40 显卡; 4. 华为 SP310 双光口万兆网卡; 5. 温控版; 6. 其他小配件; ### CSPS 电源 第一次玩儿 CSPS 电源, 小黄鱼有大量改装配置, 相比于传统的 SFX 电源便宜的多: > CSPS 是 Common Slot Power Supply 的缩写,如今,随着 CPU 和显卡频率的增加,对于电源的需求也越来越大,而现在的好的电源非常贵,而以往 500 瓦走天下的时代已经一去不复返了,这导致玩家只能一边骂电源厂商一边当韭菜,而这时,CSPS 的优点就显现出来了,第一 CSPS 的体积比 SFX 电源小,而且,功率要比非常贵的 SFX 电源大很多,这时可能有人就说服务器电源太丑了,而且噪音很大不过那已经时候 80plus 黄金时代的老黄历了,随着铂金时代的到来和温控风扇的普及,CSPS 在噪音上也有了和廉价大功率 ATX 电源一战的能力,毕竟你不能要求这个比 SFX 电源的小东西既能有大功率输出也能枕在耳边入眠,不过对于用 N 手风扇和几十块钱的显卡的垃圾佬来说,这东西永远不是噪音的主要贡献者,而且世界市场前二的占有率即使面对矿潮也涨幅不大,而且 1200 瓦的功率也足以应对早晚要变成矿渣的 3090, > > CSPS 标准的 4.5mm 脚距也可以兼容便宜的连接器,而且英特尔最新的 12vo 标准是完全 12V 化,表面上高兴的是电源厂商,又可以接着新标准卖一批新电源,而最大的赢家就是 CSPS,毕竟服务器电源早已完全 12v 化,而对于目前的 ATX 标准,可以去看 [@AlphaArea](https://space.bilibili.com/1292029/) 这个视频: [https://www.bilibili.com/video/BV12A411N7AG](https://www.bilibili.com/video/BV12A411N7AG) 里面详细讲解了改造的过程 | ![20250214181102_C8y6T5RT.webp](https://cdn.dong4j.site/source/image/20250214181102_C8y6T5RT.webp) | ![20250214181103_GtjSOqGX.webp](https://cdn.dong4j.site/source/image/20250214181103_GtjSOqGX.webp) | | :-------------------------------: | :-------------------------------: | | 转接板 | 核心板 | ### 猛兽峡谷 11 代 ![20250214181103_udVt3WAY.webp](https://cdn.dong4j.site/source/image/20250214181103_udVt3WAY.webp) 我买的是 Intel® Core™ i7-11700B + 16G 内存 + 500G 硬盘, 这家伙自带 3 个 M.2 插槽, 11 代底板上还有一个, 总共 4 个 M.2 插槽, 比 M920x 多了 2 个. 这里有一份 [规格书](https://dlcdnets.asus.com/pub/ASUS/NUC/NUC_11_Extreme_Kit/NUC_11_Extreme_Kit_NUC11BT_DB_TPS.pdf): {% pdf https://dlcdnets.asus.com/pub/ASUS/NUC/NUC_11_Extreme_Kit/NUC_11_Extreme_Kit_NUC11BT_DB_TPS.pdf %} 可以了解一下计算卡上的接口, 方便后期扩展. **11 代底板:** ![20250214181103_KhcGJEbM.webp](https://cdn.dong4j.site/source/image/20250214181103_KhcGJEbM.webp) 11 代底板有 3 个 PCIe 插槽, 计算卡和显卡各占一个 x16 插槽, 剩下的一个 x4 还能接网卡. ![20250214172317_uSAngBCa.webp](https://cdn.dong4j.site/source/image/20250214172317_uSAngBCa.webp) **计算卡的 IO 接口:** ![20250214172359_AemEQ5Hi.webp](https://cdn.dong4j.site/source/image/20250214172359_AemEQ5Hi.webp) ### M.2 固态硬盘 这张计算卡支持 4 个 M.2 固态, 其中 3 个在计算卡上, CPU 左边 2 个是 PCIe 3 x4, 右边是 PCIe 4 x4, 剩下的一个在底板上, 为 PCIe 4 x4. ![20250215001939_i0v8s7dv.webp](https://cdn.dong4j.site/source/image/20250215001939_i0v8s7dv.webp) M2 固态当然要支持国产, 所以买了 2 条全新的 **致态 Ti600 1TB** 的 PCIe Gen4 x4 NVMe 固态硬盘, 然后小黄鱼上捡了 2 条 **三星 PM981A 1TB** 的 PCIe Gen3 x4 NVMe 固态硬盘, 打算将 2 条 **三星 PM981A** 组个 RAID1, 另外 2 条 PCIe Gen4 x4 NVMe 独立使用. todo > 三星的 M.2 NVMe 固态硬盘命名一直分不清, 查了下资料: > > 9 字开头的都是 M.2 支持 NVMe 的 PCIe 固态,前面带 PM 的是 OEM 产品(通俗点讲这是个批发货,批给电脑厂商的)有 PM981、PM961 等;9 后面两个数字可以看成是代数,越大越新,出的时间越晚。针对民用零售版本后面还有后缀如 970 Pro、970 Evo 甚至还会有 Qvo,这三个字母代表等级,主要是颗粒上的区别:Pro 是 MLC 的颗粒,Evo 是 TLC 颗粒,Qvo 是 QLC 的颗粒,价格一般是这个排序,性能也是这个排序。 **需要注意的是**, 当使用底板上的 M.2 插槽时, 底板上的 PCIe x16 会被拆分成 **x8 + 2 x4**: ![20250215001717_0OnZmq7b.webp](https://cdn.dong4j.site/source/image/20250215001717_0OnZmq7b.webp) ### 内存 内存原装给了 2 张 8G 的, 现在都 2025 年了, 16G 够谁用啊, 所以另购了 2 张 32G DDR4 3200 的海力士笔记本内存, 组成双通道 64G 内存: todo ### SP310 双光口万兆网卡 小黄鱼挑了张便宜的万兆网卡: ![20250214181105_AJD5pEp6.webp](https://cdn.dong4j.site/source/image/20250214181105_AJD5pEp6.webp) 本打算买张 Mellanox CX4121A 的, 不过光模块都快赶上 SP310 了, 而且家里主要还是 10G 的设备居多, 所以还是先买张 10G 的先用着, 等 25G 的价格再腰斩的时候再升级到 25G. 这次购买的是分开的 LC-LC 光纤, 连接的时候需要注意的是 2 个光模块的光纤线要交叉, 比如上面接的是黄色的, 那么接入路由器的那一端上面就要接白色的. ![20250214181105_4eqE2nrz.webp](https://cdn.dong4j.site/source/image/20250214181105_4eqE2nrz.webp) #### 散热 我低估了 SP310 的发热量, 因为是开放型机箱没有风道, 积热严重, 所以不得不加上一个散热风扇. todo ### 温控版 ![20250214181105_Z5US99QL.webp](https://cdn.dong4j.site/source/image/20250214181105_Z5US99QL.webp) P40 的涡轮风扇噪音太大, 所以加了个温控版来控制启涡轮风扇的启停. 这个温控版非常好用, 所以一次性买了 5 个, 它可以设置开启风扇的温度范围, 这样 P40 的涡轮风扇就不用一直转了, 毕竟噪音还是挺大的. ![20250214181105_x9N79aS2.webp](https://cdn.dong4j.site/source/image/20250214181105_x9N79aS2.webp) 我将温度探头安装到了显卡背板上, 温控版设置为 35 度开,30 度关, 这样显卡温度基本上能够很好控制在理想范围内. ### 最终效果 接了个原来剩下的 LCD, 拿来显示 CPU 和 RAM 的使用情况. ![20250214181106_qdjsM4Li.webp](https://cdn.dong4j.site/source/image/20250214181106_qdjsM4Li.webp) 外接了散热风扇, USB 转 2.5G 网卡和一个 4G 模块. ![20250214181106_l9U422bW.webp](https://cdn.dong4j.site/source/image/20250214181106_l9U422bW.webp) **上电开机** 正面照: 加了个电压显示模块, 不得不说这个 CSPS 转接板输出的电压真稳定, 一直 12.2V 没有跳动过. ![20250214181106_Htk7JHbD.webp](https://cdn.dong4j.site/source/image/20250214181106_Htk7JHbD.webp) 侧面照: 网卡刚好能够塞的下, 所以不要买那种散热片上面带风扇的网卡, 宽度不够. ![20250214181106_8JnrW2th.webp](https://cdn.dong4j.site/source/image/20250214181106_8JnrW2th.webp) --- ## 安装系统 正好手上有一个 Ubuntu Server 22.04 TLS 的启动 U 盘, 所以这里先安装 22.04, 然后再升级到 24.04 TLS. 这里先进 BIOS 修改视频输出, **IGFX** 是集显, **PEG Slot** 是独显, 原来是独显输出, 这里修改为 **IGFX**: ![20250211194210_DKLLC8uq.webp](https://cdn.dong4j.site/source/image/20250211194210_DKLLC8uq.webp) Ubuntu 的安装就不赘述了, 网上一大堆教程. ### 网卡问题 系统安装完成后, 重启插上 Tesla P40, 准备升级系统并安装显卡驱动和 CUDA. 重点来了, 进入系统后发现网卡灯灭了, 无法获取到 IP, 也就无法在线升级了, 所以需要先解决这个问题. 先问了一下 ChatGPT 可能的原因: 1. **PCIe 资源冲突** 猛兽峡谷(Intel NUC 11 Extreme)可能在插上独立显卡后,主板的 PCIe 资源重新分配,导致某些设备(如内置网卡)失效。 2. **BIOS 设置问题** 部分 BIOS 可能会在插入独立显卡后,调整 PCIe 通道分配,从而禁用某些设备,包括网卡。 3. **供电或硬件冲突** 部分主板在插入独立显卡后可能会优先为显卡供电,而导致某些设备(如网卡)无法正常工作。 4. **Linux 设备驱动问题** Ubuntu 可能没有正确加载网卡驱动,或者插入显卡后,网卡的 PCIe ID 发生变化,导致内核没有识别到网卡。 网卡在进入 BIOS 且在进入系统前都是没问题的, 但是进入系统后网卡就灭了, 因为是在插入显卡后出现的问题, 所以首先怀疑是 PCIe 资源冲突导致的, 所以先检查了一下 PCIe 资源: ```bash $ lspci 00:00.0 Host bridge: Intel Corporation 11th Gen Core Processor Host Bridge/DRAM Registers (rev 05) 00:01.0 PCI bridge: Intel Corporation 11th Gen Core Processor PCIe Controller #1 (rev 05) 00:02.0 VGA compatible controller: Intel Corporation TigerLake-H GT1 [UHD Graphics] (rev 01) 00:06.0 PCI bridge: Intel Corporation 11th Gen Core Processor PCIe Controller #0 (rev 05) 00:07.0 PCI bridge: Intel Corporation Tiger Lake-H Thunderbolt 4 PCI Express Root Port #1 (rev 05) 00:07.2 PCI bridge: Intel Corporation Tiger Lake-H Thunderbolt 4 PCI Express Root Port #2 (rev 05) 00:08.0 System peripheral: Intel Corporation GNA Scoring Accelerator module (rev 05) 00:0d.0 USB controller: Intel Corporation Tiger Lake-H Thunderbolt 4 USB Controller (rev 05) 00:0d.2 USB controller: Intel Corporation Tiger Lake-H Thunderbolt 4 NHI #0 (rev 05) 00:0d.3 USB controller: Intel Corporation Tiger Lake-H Thunderbolt 4 NHI #1 (rev 05) 00:14.0 USB controller: Intel Corporation Tiger Lake-H USB 3.2 Gen 2x1 xHCI Host Controller (rev 11) 00:14.2 RAM memory: Intel Corporation Tiger Lake-H Shared SRAM (rev 11) 00:16.0 Communication controller: Intel Corporation Tiger Lake-H Management Engine Interface (rev 11) 00:1b.0 PCI bridge: Intel Corporation Device 43c2 (rev 11) 00:1b.3 PCI bridge: Intel Corporation Device 43c3 (rev 11) 00:1f.0 ISA bridge: Intel Corporation WM590 LPC/eSPI Controller (rev 11) 00:1f.3 Audio device: Intel Corporation Tiger Lake-H HD Audio Controller (rev 11) 00:1f.4 SMBus: Intel Corporation Tiger Lake-H SMBus Controller (rev 11) 00:1f.5 Serial bus controller: Intel Corporation Tiger Lake-H SPI Controller (rev 11) 01:00.0 3D controller: NVIDIA Corporation GP102GL [Tesla P40] (rev a1) 02:00.0 Non-Volatile memory controller: Silicon Motion, Inc. SM2263EN/SM2263XT (DRAM-less) NVMe SSD Controllers (rev 03) 59:00.0 Network controller: Intel Corporation Wi-Fi 6E(802.11ax) AX210/AX1675* 2x2 [Typhoon Peak] (rev 1a) 5a:00.0 Ethernet controller: Intel Corporation Ethernet Controller I225-LM (rev 03) ``` 看着没啥问题, 网卡和显卡都识别到了, 所以不是 PCIe 的问题. 后来插件网卡才发现问题: ```bash $ ip link show enp90s0 2: enp90s0: mtu 1500 qdisc mq state DOWN mode DEFAULT group default qlen 1000 ``` 输出中 **state DOWN**,说明网卡是被禁用的, 手动启动并重新获取 IP 就可以了: ```bash $ sudo ip link set enp90s0 up $ sudo dhclient enp90s0 ``` 因为 Ubuntu Server 版本(特别是 22.04+)默认使用 systemd-networkd 进行网络管理,而不是 NetworkManager。如果 systemd-networkd 没有正确配置,网卡可能不会自动启用。且在 `/etc/systemd/network/` 下没有发现任何网络配置, 因此可以断定是这个原因了. 那么接下来就是配置网卡: ```bash sudo vim /etc/systemd/network/10-enp90s0.network [Match] Name=enp90s0 [Network] DHCP=yes IPv6AcceptRA=yes ``` ```bash # 重启 systemd-networkd sudo systemctl restart systemd-networkd sudo systemctl enable systemd-networkd ``` ### 配置 WiFi 网卡 因为使用 `wpa_supplicant` 配置 WiFi 一直报错, 所以这里打算使用 `NetworkManager` 代替 `systemd-networkd` 来管理网络. ```bash sudo apt update \ && sudo apt install network-manager \ && sudo systemctl enable NetworkManager \ && sudo systemctl start NetworkManager \ && sudo systemctl status NetworkManager ``` **查看所有网络接口:**: ```bash $ nmcli device DEVICE TYPE STATE CONNECTION enp91s0 ethernet unmanaged -- # 自带网卡 enx00e04c68bbcf ethernet unmanaged -- # USB 转 2.5G 网卡 wlp90s0 wifi unmanaged -- # 自带 WiFi 网卡 enp2s0f0 ethernet unmanaged -- # 10G 光口 1 enp2s0f1 ethernet unmanaged -- # 10G 光口 2 lo loopback unmanaged -- ``` `enp90s0` 和新增的 USB 转 2.5G 网卡 `enx00e04c68bbcf` 又得重新配置了. 因为现在使用 SSH 链接的服务器, 为了避免配置有线网卡时断开连接, 这里先配置好 WiFi 网卡. ```bash nmcli device wifi list # 显示所有可用的 WiFi 网络 sudo nmcli device wifi connect "WiFi_SSID" password "WiFi_Password" ``` 顺利的话应该就能直接连上了: ```bash Device 'wlp89s0' successfully activated with '83298781-7335-4b6e-b293-676411eaa1df'. ``` 配置自动连接 WiFi: ```bash sudo nmcli connection modify "WiFi_SSID" connection.autoconnect yes nmcli connection show "WiFi_SSID" ``` > 在输出中,确保 connection.autoconnect 设置为 yes > > **断开并重新连接** > > 如果需要,你可以断开当前 Wi-Fi 连接并通过命令重新连接: > > ```bash > nmcli connection down "WiFi_SSID" > nmcli connection up "WiFi_SSID" > ``` --- 接下来是配置 2 张有线网卡和万兆网卡, 禁用 `systemd-networkd` 并切换到 `NetworkManager`. 首先通过 WiFi 链接服务器, 然后再进行下面的操作. ```bash # 删除 `/etc/systemd/network/` 目录下的网络配置文件 sudo rm -rf /etc/systemd/network/*.network sudo systemctl stop systemd-networkd sudo systemctl disable systemd-networkd ``` ### 使用 NetworkManager ```bash # 检查网卡 nmcli device # 通过 nmcli 配置有线网卡 sudo nmcli connection add type ethernet ifname enp90s0 con-name "2.5G.T" autoconnect yes sudo nmcli connection add type ethernet ifname enx00e04c68bbcf con-name "2.5G.U" autoconnect yes sudo nmcli connection add type ethernet ifname enp2s0f0 con-name "10G.T" autoconnect yes sudo nmcli connection add type ethernet ifname enp2s0f1 con-name "10G.U" autoconnect yes # 这一步会报错, 所有我这里重启了服务器, 然后使用 wifi 链接 sudo nmcli connection up "2.5G.T" sudo nmcli connection up "2.5G.U" sudo nmcli connection up "10G.T" sudo nmcli connection up "10G.U" # 查看连接信息 nmcli connection show ``` ### 设置 MTU 万兆网卡需要将 MTU 设置为 `9000`: ```bash sudo nmcli connection modify "10G.T" 802-3-ethernet.mtu 9000 sudo nmcli connection modify "10G.U" 802-3-ethernet.mtu 9000 sudo nmcli connection up "10G.T" sudo nmcli connection up "10G.U" ``` 验证: ```bash $ ip link show enp2s0f0 3: enp2s0f0: mtu 9000 qdisc mq state UP mode DEFAULT group default qlen 1000 link/ether 18:9b:a5:80:5a:05 brd ff:ff:ff:ff:ff:ff ``` ## 升级系统 升级系统到最新的 24.04 TLS ```bash sudo apt upgrade -y do-release-upgrade ``` ### 安装 docker ```bash sudo apt update && sudo apt upgrade -y \ && sudo apt install -y ca-certificates curl gnupg \ && sudo install -m 0755 -d /etc/apt/keyrings \ && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo tee /etc/apt/keyrings/docker.asc > /dev/null \ && sudo chmod a+r /etc/apt/keyrings/docker.asc \ && echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null \ && sudo apt update \ && sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ``` **验证 Docker 是否安装成功**: ```bash sudo systemctl enable --now docker \ && sudo docker version ``` **验证 Docker Compose**: ```bash docker compose version ``` **普通用户运行 Docker**: ```bash sudo usermod -aG docker $USER \ && newgrp docker ``` ### 安装 Node 和 pm2 ```bash curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - \ && sudo apt install -y nodejs \ && sudo npm install -g pm2 \ && pm2 -v ``` ## 安装显卡驱动 ```bash $ ubuntu-drivers devices ... vendor : NVIDIA Corporation model : GP102GL [Tesla P40] driver : nvidia-driver-550 - distro non-free recommended driver : nvidia-driver-535-server - distro non-free driver : nvidia-driver-470-server - distro non-free driver : nvidia-driver-535 - distro non-free ... ``` 推荐的版本是 `nvidia-driver-550`, 安装它: ```bash sudo apt install nvidia-driver-550 ``` 重启后可使用 `nvidia-smi` 查看显卡信息: ```bash +-----------------------------------------------------------------------------------------+ | NVIDIA-SMI 550.120 Driver Version: 550.120 CUDA Version: 12.4 | |-----------------------------------------+------------------------+----------------------+ | GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC | | Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. | | | | MIG M. | |=========================================+========================+======================| | 0 Tesla P40 Off | 00000000:01:00.0 Off | Off | | N/A 37C P8 11W / 250W | 0MiB / 24576MiB | 0% Default | | | | N/A | +-----------------------------------------+------------------------+----------------------+ +-----------------------------------------------------------------------------------------+ | Processes: | | GPU GI CI PID Type Process name GPU Memory | | ID ID Usage | |=========================================================================================| | No running processes found | +-----------------------------------------------------------------------------------------+ ``` ### 关闭 ECC Tesla 系列 GPU 默认开启了 ECC(error correcting code, 错误检查和纠正)功能,该功能可以提高数据的正确性,随之而来的是可用内存的减少和性能上的损失。 ECC 内存支持:P4 支持 ECC 校验,开启后会损失一部分显存 开启过后,显存可用为 7611MB,关闭后可用为 8121MB。 通过`nvidia-smi | grep Tesla`查看前面 GPU 编号:0 ``` $ nvidia-smi | grep Tesla | 0 Tesla P40 Off | 00000000:01:00.0 Off | Off | ----------------------------------------------------------------------------------------- nvidia-smi -i n -e 0/1 可关闭(0)/开启(1) , n是GPU的编号 ``` 执行关闭 ECC `sudo nvidia-smi -i 0 -e 0`, 重启后该设置生效。 > **值得注意的是开启 ECC 关闭 ECC 这个操作是有寿命的,开关几千次就不能再继续开关了。** --- ## 安装 CUDA > 更新安装方式: https://docs.nvidia.com/cuda/cuda-installation-guide-linux/#network-repo-installation-for-ubuntu `nvidia-smi` 信息中有个非常重要的可能产生误导的问题,列表中的 **CUDA Version: 12.4**,只是代表该显卡支持的 CUDA 最高版本是 12.4,即该显卡只能安装**12.4** 以下版本的 CUDA。并不是说已经安装了该版本,下一步根据本步骤的信息,安装 CUDA 12.4。为了避免兼容性,这里安装 12.4.0,没有安装 update 的版本。 [访问 NVIDIA 开发者网站 CUDA 下载页面](https://developer.nvidia.com/cuda-downloads?target_os=Linux), 默认是最新版本, 通过 `Archive of Previous CUDA Releases` 选择其他版本: ![20250210003613_tmki4llf.webp](https://cdn.dong4j.site/source/image/20250210003613_tmki4llf.webp) [CUDA Toolkit 12.4.0 Downloads](https://developer.nvidia.com/cuda-12-4-0-download-archive?target_os=Linux&target_arch=x86_64&Distribution=Ubuntu&target_version=22.04&target_type=deb_local): ![20250211194215_YLSZBV30.webp](https://cdn.dong4j.site/source/image/20250211194215_YLSZBV30.webp) 依次选择操作系统及其版本, 然后按照安装步骤复制粘贴回车即可. CUDA 12.4.0 支持的 Ubuntu 最高版本为 22.04,如果系统为 24.04,则在安装时可能会遇到问题。 ### 问题 ``` $ sudo apt-get -y install cuda-toolkit-12-4 Reading package lists... Done Building dependency tree... Done Reading state information... Done Some packages could not be installed. This may mean that you have requested an impossible situation or if you are using the unstable distribution that some required packages have not yet been created or been moved out of Incoming. The following information may help to resolve the situation: The following packages have unmet dependencies: nsight-systems-2023.4.4 : Depends: libtinfo5 but it is not installable E: Unable to correct problems, you have held broken packages. ``` 这是因为 Ubuntu 24.04 默认使用 `libtinfo6`,而某些软件包仍依赖于旧版本的 `libtinfo5`。 为了解决这个问题,可以手动安装 `libtinfo5`。 以下是具体步骤: 1. **下载 `libtinfo5` 包**: 打开终端,使用 `wget` 命令下载适用于 Ubuntu 24.04 的 `libtinfo5` 包: ```bash wget http://security.ubuntu.com/ubuntu/pool/universe/n/ncurses/libtinfo5_6.3-2ubuntu0.1_amd64.deb ``` 请注意,`libtinfo5` 包的版本可能会随着时间更新,建议访问 [Ubuntu 官方软件包存档](http://security.ubuntu.com/ubuntu/pool/universe/n/ncurses/) 获取最新版本的下载链接。 2. **安装 `libtinfo5` 包**: 下载完成后,使用 `dpkg` 命令安装该包: ```bash sudo dpkg -i libtinfo5_6.3-2ubuntu0.1_amd64.deb ``` 如果在安装过程中遇到依赖问题,可以使用以下命令修复: ```bash sudo apt --fix-broken install ``` 3. **安装 CUDA Toolkit 12.4**: 安装 `libtinfo5` 后,您可以继续安装 CUDA Toolkit 12.4: ```bash sudo apt-get -y install cuda-toolkit-12-4 ``` ### 验证 安装完成后, 执行 `nvcc -V`: ```bash Command 'nvcc' not found, but can be installed with: sudo apt install nvidia-cuda-toolkit ``` 这是因为 cuda 的路径没有配置到环境变量中, 我们可以先通过 `dpkg -l | grep cuda` 验证 cuda 是否安装成功: ```bash ii cuda-cccl-12-4 12.4.99-1 amd64 CUDA CCCL ii cuda-command-line-tools-12-4 12.4.0-1 amd64 CUDA command-line tools ii cuda-compiler-12-4 12.4.0-1 amd64 CUDA compiler ii cuda-crt-12-4 12.4.99-1 amd64 CUDA crt ii cuda-cudart-12-4 12.4.99-1 amd64 CUDA Runtime native Libraries ii cuda-cudart-dev-12-4 12.4.99-1 amd64 CUDA Runtime native dev links, headers ii cuda-cuobjdump-12-4 12.4.99-1 amd64 CUDA cuobjdump ii cuda-cupti-12-4 12.4.99-1 amd64 CUDA profiling tools runtime libs. ii cuda-cupti-dev-12-4 12.4.99-1 amd64 CUDA profiling tools interface. ii cuda-cuxxfilt-12-4 12.4.99-1 amd64 CUDA cuxxfilt ii cuda-documentation-12-4 12.4.99-1 amd64 CUDA documentation ii cuda-driver-dev-12-4 12.4.99-1 amd64 CUDA Driver native dev stub library ii cuda-gdb-12-4 12.4.99-1 amd64 CUDA-GDB ii cuda-libraries-12-4 12.4.0-1 amd64 CUDA Libraries 12.4 meta-package ii cuda-libraries-dev-12-4 12.4.0-1 amd64 CUDA Libraries 12.4 development meta-package ii cuda-nsight-12-4 12.4.99-1 amd64 CUDA nsight ii cuda-nsight-compute-12-4 12.4.0-1 amd64 NVIDIA Nsight Compute ii cuda-nsight-systems-12-4 12.4.0-1 amd64 NVIDIA Nsight Systems ii cuda-nvcc-12-4 12.4.99-1 amd64 CUDA nvcc ii cuda-nvdisasm-12-4 12.4.99-1 amd64 CUDA disassembler ii cuda-nvml-dev-12-4 12.4.99-1 amd64 NVML native dev links, headers ii cuda-nvprof-12-4 12.4.99-1 amd64 CUDA Profiler tools ii cuda-nvprune-12-4 12.4.99-1 amd64 CUDA nvprune ii cuda-nvrtc-12-4 12.4.99-1 amd64 NVRTC native runtime libraries ii cuda-nvrtc-dev-12-4 12.4.99-1 amd64 NVRTC native dev links, headers ii cuda-nvtx-12-4 12.4.99-1 amd64 NVIDIA Tools Extension ii cuda-nvvm-12-4 12.4.99-1 amd64 CUDA nvvm ii cuda-nvvp-12-4 12.4.99-1 amd64 CUDA Profiler tools ii cuda-opencl-12-4 12.4.99-1 amd64 CUDA OpenCL native Libraries ii cuda-opencl-dev-12-4 12.4.99-1 amd64 CUDA OpenCL native dev links, headers ii cuda-profiler-api-12-4 12.4.99-1 amd64 CUDA Profiler API ii cuda-repo-ubuntu2204-12-4-local 12.4.0-550.54.14-1 amd64 cuda repository configuration files ii cuda-sanitizer-12-4 12.4.99-1 amd64 CUDA Sanitizer ii cuda-toolkit-12-4 12.4.0-1 amd64 CUDA Toolkit 12.4 meta-package ii cuda-toolkit-12-4-config-common 12.4.99-1 all Common config package for CUDA Toolkit 12.4. ii cuda-toolkit-12-config-common 12.4.99-1 all Common config package for CUDA Toolkit 12. ii cuda-toolkit-config-common 12.4.99-1 all Common config package for CUDA Toolkit. ii cuda-tools-12-4 12.4.0-1 amd64 CUDA Tools meta-package ii cuda-visual-tools-12-4 12.4.0-1 amd64 CUDA visual tools ``` 看样子是安装成功了, 所以我们可以设置一下环境变量: ```bash $ vim ~/.bashrc export PATH=/usr/local/cuda-12.4/bin:$PATH export LD_LIBRARY_PATH=/usr/local/cuda-12.4/lib64:$LD_LIBRARY_PATH $ source ~/.bashrc ``` 现在执行 `nvcc -V` 就没问题了: ```bash $ nvcc -V nvcc: NVIDIA (R) Cuda compiler driver Copyright (c) 2005-2024 NVIDIA Corporation Built on Tue_Feb_27_16:19:38_PST_2024 Cuda compilation tools, release 12.4, V12.4.99 Build cuda_12.4.r12.4/compiler.33961263_0 ``` ## 安装 CuDNN CuDNN(CUDA Deep Neural Network library)是由 NVIDIA 提供的高性能深度学习库,它专门为深度学习应用程序(如卷积神经网络、递归神经网络等)优化了 GPU 加速的计算操作。CuDNN 是基于 CUDA(NVIDIA 的并行计算平台)构建的,旨在加速深度学习框架中的神经网络计算,提供高效的计算性能。 ### CuDNN 的主要功能 CuDNN 主要提供以下几种功能: 1. **卷积操作(Convolutional Operations)**:CuDNN 针对卷积神经网络(CNN)的核心操作进行了优化,支持常见的卷积计算、反卷积(转置卷积)等操作。它优化了在 GPU 上执行这些操作的速度,适合用于图像处理和计算机视觉等任务。 2. **池化操作(Pooling Operations)**:CuDNN 提供了优化的池化操作,特别是在 max-pooling 和 average-pooling 计算方面,提高了执行效率。 3. **归一化操作(Normalization Operations)**:如批归一化(Batch Normalization)等,CuDNN 提供了高效的实现。 4. **激活函数(Activation Functions)**:CuDNN 提供了多种激活函数(如 ReLU、Sigmoid、Tanh 等)以及它们的反向传播计算,所有这些都进行了 GPU 优化。 5. **RNN 操作(Recurrent Neural Network Operations)**:CuDNN 还提供了对 RNN(递归神经网络)操作的支持,包括 LSTM(长短期记忆)和 GRU(门控递归单元)的高效实现。 6. **张量计算(Tensor Operations)**:CuDNN 提供对张量运算的高效支持,帮助深度学习框架在 GPU 上执行复杂的矩阵运算。 ### 为什么使用 CuDNN? 1. **性能优化**:CuDNN 对深度学习框架中的许多常见操作(如卷积、池化、激活等)进行了专门优化,利用 GPU 并行计算的优势显著提高了计算速度,尤其在大规模训练中能够大幅缩短训练时间。 2. **与深度学习框架兼容**:CuDNN 与大多数主流深度学习框架兼容,如 TensorFlow、PyTorch、Caffe 等。这些框架都利用 CuDNN 来加速计算,从而大大提高了训练和推理的效率。 3. **GPU 加速**:由于 CuDNN 基于 NVIDIA 的 CUDA 构建,它能够充分发挥 NVIDIA GPU 的计算能力,极大提升深度学习应用的性能。 4. **易于集成**:对于开发人员来说,CuDNN 提供了简单的 API,可以方便地集成到现有的深度学习项目中,极大减少了优化和调试的工作量。 ### CuDNN 与 CUDA 的关系 CuDNN 是构建在 CUDA 上的库,它利用 CUDA 提供的并行计算能力来加速深度学习计算。CUDA 提供了底层的硬件加速接口,而 CuDNN 则在此基础上实现了针对深度学习操作的优化。因此,CuDNN 需要与 CUDA 一起使用。 ### 如何安装 CuDNN > 更新安装方式: https://docs.nvidia.com/deeplearning/cudnn/installation/latest/linux.html# > > ```bash > sudo apt-get install zlib1g > sudo apt-get -y install cudnn9-cuda-12 > ``` 访问 [CuDNN 网站](https://developer.nvidia.com/rdp/cudnn-archive)。需要注册一个 NVIDIA 开发者账号才可以下载。 ![20250210010433_BrMD7eJW.webp](https://cdn.dong4j.site/source/image/20250210010433_BrMD7eJW.webp) > deb 文件仅提供本地源,安装时仍需从网络下载相应的包文件。需要先安装本地源: `sudo dpkg -i cudnn-local-repo-ubuntu2204-8.9.7.29_1.0-1_amd64.deb`,根据输出中的提示安装 GPG key,再使用 apt 工具更新和安装依赖包。 Tar 包是完整的安装文件, 为了避免网络安装的不稳定性, 这里选择下载全部文件到本地安装. ```bash tar -xvJf cudnn-linux-x86_64-8.9.7.29_cuda12-archive.tar.xz \ && sudo cp cudnn-linux-x86_64-8.9.7.29_cuda12-archive/include/cudnn.h /usr/local/cuda/include \ && sudo cp -P cudnn-linux-x86_64-8.9.7.29_cuda12-archive/lib/libcudnn* /usr/local/cuda-12.4/lib64 \ && sudo chmod a+r /usr/local/cuda/include/cudnn.h \ && sudo chmod a+r /usr/local/cuda/lib64/libcudnn* \ && sudo dpkg -i cudnn-local-repo-ubuntu2204-8.9.7.29_1.0-1_amd64.deb \ && sudo apt-get install libcudnn8 \ && sudo apt-get install libcudnn8-dev \ && sudo apt-get install libcudnn8-samples ``` #### 验证 ```bash $ sudo ldconfig $ sudo ldconfig -p | grep cudnn libcudnn_ops_train.so.8 (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn_ops_train.so.8 libcudnn_ops_train.so.8 (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn_ops_train.so.8 libcudnn_ops_train.so (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn_ops_train.so libcudnn_ops_train.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn_ops_train.so libcudnn_ops_infer.so.8 (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn_ops_infer.so.8 libcudnn_ops_infer.so.8 (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn_ops_infer.so.8 libcudnn_ops_infer.so (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn_ops_infer.so libcudnn_ops_infer.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn_ops_infer.so libcudnn_cnn_train.so.8 (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn_cnn_train.so.8 libcudnn_cnn_train.so.8 (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn_cnn_train.so.8 libcudnn_cnn_train.so (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn_cnn_train.so libcudnn_cnn_train.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn_cnn_train.so libcudnn_cnn_infer.so.8 (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn_cnn_infer.so.8 libcudnn_cnn_infer.so.8 (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn_cnn_infer.so.8 libcudnn_cnn_infer.so (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn_cnn_infer.so libcudnn_cnn_infer.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn_cnn_infer.so libcudnn_adv_train.so.8 (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn_adv_train.so.8 libcudnn_adv_train.so.8 (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn_adv_train.so.8 libcudnn_adv_train.so (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn_adv_train.so libcudnn_adv_train.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn_adv_train.so libcudnn_adv_infer.so.8 (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn_adv_infer.so.8 libcudnn_adv_infer.so.8 (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn_adv_infer.so.8 libcudnn_adv_infer.so (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn_adv_infer.so libcudnn_adv_infer.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn_adv_infer.so libcudnn.so.8 (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn.so.8 libcudnn.so.8 (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn.so.8 libcudnn.so (libc6,x86-64) => /usr/local/cuda/targets/x86_64-linux/lib/libcudnn.so libcudnn.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libcudnn.so ``` ## 安装 Conda ```bash mkdir -p ~/miniconda3 \ && wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda3/miniconda.sh \ && bash ~/miniconda3/miniconda.sh -b -u -p ~/miniconda3 \ && rm ~/miniconda3/miniconda.sh \ && source ~/miniconda3/bin/activate \ && conda init --all ``` ### 验证 ```bash conda create -n ai_base python=3.12 conda activate ai_base pip install torch torchvision ``` python 脚本: ```python import torch # 生成一个5x3的随机矩阵 print(torch.rand(5, 3)) # 检查CUDA是否可用 print(torch.cuda.is_available()) # 如果CUDA可用,获取更多显卡信息 if torch.cuda.is_available(): # 获取CUDA设备数量 print('CUDA device count:', torch.cuda.device_count()) # 获取当前设备的名称 print('Device name:', torch.cuda.get_device_name(0)) # 获取当前设备的总内存 print('Device total memory (GB):', torch.cuda.get_device_properties(0).total_memory / 1e9) # 获取当前设备的CUDA版本 print('CUDA version:', torch.version.cuda) # 获取当前设备的计算能力 print('Compute capability:', torch.cuda.get_device_properties(0).major, '.', torch.cuda.get_device_properties(0).minor) # 检查是否有可用的GPU print('GPU available:', torch.cuda.is_available()) # 获取当前设备的索引 print('Current device index:', torch.cuda.current_device()) ``` ``` $ python gpu.py tensor([[0.7034, 0.4482, 0.1233], [0.2208, 0.0810, 0.9165], [0.9785, 0.3103, 0.5675], [0.1876, 0.9308, 0.2961], [0.6886, 0.5146, 0.2812]]) True CUDA device count: 1 Device name: Tesla P40 Device total memory (GB): 25.62588672 CUDA version: 12.4 Compute capability: 6 . 1 GPU available: True Current device index: 0 ``` ## 添加到监控面板 ![20250211194220_zb6fDv7h.webp](https://cdn.dong4j.site/source/image/20250211194220_zb6fDv7h.webp) 1. 开启 GPU 监控 2. 开启温度监控 ## 通过雷雳接口于 macOS 连接 NUC11 自带2个雷雳4接口, 所以玩一下 Linux 如何通过雷雳接口于 macOS 建立连接. ### macOS 配置 > 在 [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 中的 「多台 Mac 互联」一节中详细讲解了如何通过雷雳接口连接多台 macOS. 首先需要确定连接的哪个雷雳接口: ![20250216223015_r1hcUdNe.webp](https://cdn.dong4j.site/source/image/20250216223015_r1hcUdNe.webp) macOS 于 NUC 连接后, 直接识别到对端为 **nuc(我设置的 hostname)**, 插孔显示为 **1**. 然后需要新建一个雷雳网桥, 选择雷雳 1 接口: ![20250216223016_Jm6IfLe4.webp](https://cdn.dong4j.site/source/image/20250216223016_Jm6IfLe4.webp) 最后就是 **添加服务**: ![20250216223017_Yuhowi8h.webp](https://cdn.dong4j.site/source/image/20250216223017_Yuhowi8h.webp) 手动修改 IP: ![20250216223018_99XYwggY.webp](https://cdn.dong4j.site/source/image/20250216223018_99XYwggY.webp) ### NUC 配置 ```bash sudo nmcli connection add type ethernet ifname thunderbolt0 con-name to.mbp ipv4.method manual ipv4.addresses 1.1.1.9/24 ``` NUC 的 IP 设置为 `1.1.1.9`, 然后是喜闻乐见的测速环节: ```bash $ iperf3 -c 1.1.1.9 Connecting to host 1.1.1.9, port 5201 [ 5] local 1.1.1.8 port 61027 connected to 1.1.1.9 port 5201 [ ID] Interval Transfer Bitrate [ 5] 0.00-1.00 sec 1.62 GBytes 13.8 Gbits/sec [ 5] 1.00-2.00 sec 1.72 GBytes 14.8 Gbits/sec [ 5] 2.00-3.00 sec 1.72 GBytes 14.8 Gbits/sec [ 5] 3.00-4.01 sec 1.73 GBytes 14.8 Gbits/sec [ 5] 4.01-5.00 sec 1.72 GBytes 14.8 Gbits/sec [ 5] 5.00-6.00 sec 1.73 GBytes 14.8 Gbits/sec [ 5] 6.00-7.01 sec 1.73 GBytes 14.8 Gbits/sec [ 5] 7.01-8.00 sec 1.72 GBytes 14.8 Gbits/sec [ 5] 8.00-9.00 sec 1.73 GBytes 14.8 Gbits/sec [ 5] 9.00-10.00 sec 1.73 GBytes 14.8 Gbits/sec - - - - - - - - - - - - - - - - - - - - - - - - - [ ID] Interval Transfer Bitrate [ 5] 0.00-10.00 sec 17.1 GBytes 14.7 Gbits/sec sender [ 5] 0.00-10.01 sec 17.1 GBytes 14.7 Gbits/sec receiver iperf Done. ``` 理论上应该有 20Gbit/sec, 没有我 macOS 于 macOS 连接快: ```bash $ iperf3 -c 1.0.0.4 Connecting to host 1.0.0.4, port 5201 [ 5] local 1.0.0.5 port 65277 connected to 1.0.0.4 port 5201 [ ID] Interval Transfer Bitrate [ 5] 0.00-1.00 sec 2.30 GBytes 19.8 Gbits/sec [ 5] 1.00-2.00 sec 2.17 GBytes 18.7 Gbits/sec [ 5] 2.00-3.00 sec 2.22 GBytes 19.1 Gbits/sec [ 5] 3.00-4.00 sec 2.17 GBytes 18.6 Gbits/sec [ 5] 4.00-5.00 sec 2.24 GBytes 19.2 Gbits/sec [ 5] 5.00-6.00 sec 2.20 GBytes 18.9 Gbits/sec [ 5] 6.00-7.00 sec 2.20 GBytes 18.9 Gbits/sec [ 5] 7.00-8.00 sec 2.23 GBytes 19.1 Gbits/sec [ 5] 8.00-9.00 sec 2.11 GBytes 18.1 Gbits/sec [ 5] 9.00-10.00 sec 2.21 GBytes 19.0 Gbits/sec - - - - - - - - - - - - - - - - - - - - - - - - - [ ID] Interval Transfer Bitrate [ 5] 0.00-10.00 sec 22.0 GBytes 18.9 Gbits/sec sender [ 5] 0.00-10.00 sec 22.0 GBytes 18.9 Gbits/sec receiver iperf Done. ``` #### 参考 - [Linux网络管理工具-netplan](https://zoe.red/2024/254.html) - [雷电局域网搭建](https://zoe.red/2024/169.html/comment-page-1) ## 测试 ### CPU 测试 在 Ubuntu 系统中,我们可以使用 apt-get 命令来安装 Stress 工具。首先,打开终端并运行以下命令更新软件包列表: ``` sudo apt-get update sudo apt-get install stress ``` 安装完成后,我们就可以在终端中直接运行 Stress 工具了。 1. 模拟 CPU 压力 使用 Stress 工具模拟 CPU 压力非常简单。以下命令将在 8 个工作线程上模拟 CPU 压力: ```bash # 这将使CPU保持高负载状态,以测试系统的稳定性和性能。 stress --cpu 8 ``` 2. 模拟内存压力 为了模拟内存压力,我们可以使用以下命令: ```bash # 这将使系统分配128MB的内存,并持续进行读写操作,以模拟内存压力。 stress --vm 1 --vm-bytes 128M ``` 3. 模拟 I/O 压力 要模拟 I/O 压力,我们可以使用以下命令: ```bash # 这将使系统同时执行4个I/O操作,以模拟磁盘读写压力。 stress --io 4 ``` 以上仅是 Stress 工具的部分示例,它还支持模拟磁盘空间压力等多种场景。具体用法和参数可以参考 Stress 工具的官方[文档](https://cloud.baidu.com/product/doc.html)。 ### GPU 测试 这里使用 **[gpu-burn](https://github.com/wilicc/gpu-burn)** 来测试: ```bash git clone https://github.com/wilicc/gpu-burn \ && cd gpu-burn \ && make && make clean ``` ```bash $ ./gpu_burn -h GPU Burn Usage: gpu-burn [OPTIONS] [TIME] -m X Use X MB of memory. -m N% Use N% of the available GPU memory. Default is 90% -d Use doubles -tc Try to use Tensor cores -l Lists all GPUs in the system -i N Execute only on GPU N -c FILE Use FILE as compare kernel. Default is compare.ptx -stts T Set timeout threshold to T seconds for using SIGTERM to abort child processes before using SIGKILL. Default is 30 -h Show this help message Examples: gpu-burn -d 3600 # burns all GPUs with doubles for an hour gpu-burn -m 50% # burns using 50% of the available GPU memory gpu-burn -l # list GPUs gpu-burn -i 2 # burns only GPU of index 2 ``` 我们使用 `gpu-burn -d 600` 来跑 10 分钟测试: ```bash $ ./gpu_burn -d 600 Using compare file: compare.ptx Burning for 600 seconds. GPU 0: Tesla P40 (UUID: GPU-14413e65-6006-ecbe-19fb-de88575d8a3e) Initialized device 0 with 24438 MB of memory (24278 MB available, using 21850 MB of it), using DOUBLES Results are 536870912 bytes each, thus performing 40 iterations ``` 因为我的涡轮风扇使用温度传感器检测显卡背板温度, 如果高于 30 度就开始工作, 刚跑了 30 秒钟就开始全功率运行了, 使用 `watch nvidia-smi` 观察显卡的数据变化: ```bash +-----------------------------------------------------------------------------------------+ | NVIDIA-SMI 550.120 Driver Version: 550.120 CUDA Version: 12.4 | |-----------------------------------------+------------------------+----------------------+ | GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC | | Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. | | | | MIG M. | |=========================================+========================+======================| | 0 Tesla P40 Off | 00000000:01:00.0 Off | Off | | N/A 28C P0 53W / 250W | 21665MiB / 24576MiB | 100% Default | | | | N/A | +-----------------------------------------+------------------------+----------------------+ +-----------------------------------------------------------------------------------------+ | Processes: | | GPU GI CI PID Type Process name GPU Memory | | ID ID Usage | |=========================================================================================| | 0 N/A N/A 13338 C ./gpu_burn 21662MiB | +-----------------------------------------------------------------------------------------+ ``` 基本上没有超过 30 度, 这就尴尬了, 不晓得 GPU 会不会感冒 🙉. ### AI 推理 这里使用最简单的 Ollama 进行快速测试: ```bash curl https://ollama.ai/install.sh | sh ``` ```bash >>> Creating ollama user... >>> Adding ollama user to render group... >>> Adding ollama user to video group... >>> Adding current user to ollama group... >>> Creating ollama systemd service... >>> Enabling and starting ollama service... Created symlink /etc/systemd/system/default.target.wants/ollama.service → /etc/systemd/system/ollama.service. >>> NVIDIA GPU installed. ``` Ollama 直接添加自启动, 但是我不需要, 所以禁用了: ```bash sudo systemctl disable ollama ``` 如何需要开放局域网访问, 需要修改 `/etc/systemd/system/ollama.service` ```bash ... Environment="OLLAMA_HOST=0.0.0.0:11434" ... ``` ```bash sudo systemctl daemon-reload \ && sudo systemctl restart ollama ``` 使用 `llama3.2` 来测试: ```bash ollama run llama3.2 ``` ```bash ./llava-v1.5-7b-q4.llamafile --server --gpu NVIDIA --host 0.0.0.0 ``` ### Stable Diffusion WebUI ```basj mkdir -p ~/miniconda3 wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda3/miniconda.sh bash ~/miniconda3/miniconda.sh -b -u -p ~/miniconda3 conda init --all conda create -n sdwebui python=3.10 conda activate sdwebui pip install torch torchvision torchaudio ``` ```basj git clone https://github.com/AUTOMATIC1111/stable-diffusion-webui.git cd stable-diffusion-webui vim webui.sh ``` ```bash #!/bin/bash ######################################################### # Uncomment and change the variables below to your need:# ######################################################### # Install directory without trailing slash #install_dir="/home/$(whoami)" # Name of the subdirectory #clone_dir="stable-diffusion-webui" # Commandline arguments for webui.py, for example: export COMMANDLINE_ARGS="--medvram --opt-split-attention" export COMMANDLINE_ARGS="--api --listen --port 7860 --gradio-auth dong4j:dusj@5282010 --enable-insecure-extension-access" # python3 executable python_cmd="/home/dong4j/miniconda3/envs/sdwebui/bin/python3.10" # git executable #export GIT="git" # python3 venv without trailing slash (defaults to ${install_dir}/${clone_dir}/venv) venv_dir="-" # script to launch to start the app #export LAUNCH_SCRIPT="launch.py" # install command for torch #export TORCH_COMMAND="pip install torch==1.12.1+cu113 --extra-index-url https://download.pytorch.org/whl/cu113" # Requirements file to use for stable-diffusion-webui #export REQS_FILE="requirements_versions.txt" # Fixed git repos #export K_DIFFUSION_PACKAGE="" #export GFPGAN_PACKAGE="" # Fixed git commits #export STABLE_DIFFUSION_COMMIT_HASH="" #export CODEFORMER_COMMIT_HASH="" #export BLIP_COMMIT_HASH="" # Uncomment to enable accelerated launch #export ACCELERATE="True" # Uncomment to disable TCMalloc #export NO_TCMALLOC="True" ########################################### ``` ```bash bash webui.sh ``` #### 问题 ``` × Building wheel for tokenizers (pyproject.toml) did not run successfully. │ exit code: 1 ╰─> [49 lines of output] running bdist_wheel running build running build_py creating build/lib.linux-x86_64-cpython-312/tokenizers copying py_src/tokenizers/__init__.py -> build/lib.linux-x86_64-cpython-312/tokenizers creating build/lib.linux-x86_64-cpython-312/tokenizers/models copying py_src/tokenizers/models/__init__.py -> build/lib.linux-x86_64-cpython-312/tokenizers/models creating build/lib.linux-x86_64-cpython-312/tokenizers/decoders copying py_src/tokenizers/decoders/__init__.py -> build/lib.linux-x86_64-cpython-312/tokenizers/decoders creating build/lib.linux-x86_64-cpython-312/tokenizers/normalizers copying py_src/tokenizers/normalizers/__init__.py -> build/lib.linux-x86_64-cpython-312/tokenizers/normalizers creating build/lib.linux-x86_64-cpython-312/tokenizers/pre_tokenizers copying py_src/tokenizers/pre_tokenizers/__init__.py -> build/lib.linux-x86_64-cpython-312/tokenizers/pre_tokenizers creating build/lib.linux-x86_64-cpython-312/tokenizers/processors copying py_src/tokenizers/processors/__init__.py -> build/lib.linux-x86_64-cpython-312/tokenizers/processors creating build/lib.linux-x86_64-cpython-312/tokenizers/trainers copying py_src/tokenizers/trainers/__init__.py -> build/lib.linux-x86_64-cpython-312/tokenizers/trainers creating build/lib.linux-x86_64-cpython-312/tokenizers/implementations copying py_src/tokenizers/implementations/sentencepiece_unigram.py -> build/lib.linux-x86_64-cpython-312/tokenizers/implementations copying py_src/tokenizers/implementations/sentencepiece_bpe.py -> build/lib.linux-x86_64-cpython-312/tokenizers/implementations copying py_src/tokenizers/implementations/base_tokenizer.py -> build/lib.linux-x86_64-cpython-312/tokenizers/implementations copying py_src/tokenizers/implementations/char_level_bpe.py -> build/lib.linux-x86_64-cpython-312/tokenizers/implementations copying py_src/tokenizers/implementations/byte_level_bpe.py -> build/lib.linux-x86_64-cpython-312/tokenizers/implementations copying py_src/tokenizers/implementations/bert_wordpiece.py -> build/lib.linux-x86_64-cpython-312/tokenizers/implementations copying py_src/tokenizers/implementations/__init__.py -> build/lib.linux-x86_64-cpython-312/tokenizers/implementations creating build/lib.linux-x86_64-cpython-312/tokenizers/tools copying py_src/tokenizers/tools/__init__.py -> build/lib.linux-x86_64-cpython-312/tokenizers/tools copying py_src/tokenizers/tools/visualizer.py -> build/lib.linux-x86_64-cpython-312/tokenizers/tools copying py_src/tokenizers/__init__.pyi -> build/lib.linux-x86_64-cpython-312/tokenizers copying py_src/tokenizers/models/__init__.pyi -> build/lib.linux-x86_64-cpython-312/tokenizers/models copying py_src/tokenizers/decoders/__init__.pyi -> build/lib.linux-x86_64-cpython-312/tokenizers/decoders copying py_src/tokenizers/normalizers/__init__.pyi -> build/lib.linux-x86_64-cpython-312/tokenizers/normalizers copying py_src/tokenizers/pre_tokenizers/__init__.pyi -> build/lib.linux-x86_64-cpython-312/tokenizers/pre_tokenizers copying py_src/tokenizers/processors/__init__.pyi -> build/lib.linux-x86_64-cpython-312/tokenizers/processors copying py_src/tokenizers/trainers/__init__.pyi -> build/lib.linux-x86_64-cpython-312/tokenizers/trainers copying py_src/tokenizers/tools/visualizer-styles.css -> build/lib.linux-x86_64-cpython-312/tokenizers/tools running build_ext running build_rust error: can't find Rust compiler If you are using an outdated pip version, it is possible a prebuilt wheel is available for this package but pip is not able to install from it. Installing from the wheel would avoid the need for a Rust compiler. To update pip, run: pip install --upgrade pip and then retry package installation. If you did intend to build this package from source, try installing a Rust compiler from your system package manager and ensure it is on the PATH during installation. Alternatively, rustup (available at https://rustup.rs) is the recommended way to download and update the Rust compiler toolchain. [end of output] note: This error originates from a subprocess, and is likely not a problem with pip. ERROR: Failed building wheel for tokenizers ERROR: Failed to build installable wheels for some pyproject.toml based projects (Pillow, tokenizers) ``` **问题处理** ```bash curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source $HOME/.cargo/env pip install --no-cache-dir tokenizers ``` ``` Could not find openssl via pkg-config: pkg-config exited with status code 1 > PKG_CONFIG_ALLOW_SYSTEM_CFLAGS=1 pkg-config --libs --cflags openssl The system library openssl required by crate openssl-sys was not found. The file openssl.pc needs to be installed and the PKG_CONFIG_PATH environment variable must contain its parent directory. The PKG_CONFIG_PATH environment variable is not set. HINT: if you have installed the library, try setting PKG_CONFIG_PATH to the directory containing openssl.pc. cargo:warning=Could not find directory of OpenSSL installation, and this -sys crate cannot proceed without this knowledge. If OpenSSL is installed and this crate had trouble finding it, you can set the OPENSSL_DIR environment variable for the compilation process. See stderr section below for further information. --- stderr Could not find directory of OpenSSL installation, and this -sys crate cannot proceed without this knowledge. If OpenSSL is installed and this crate had trouble finding it, you can set the OPENSSL_DIR environment variable for the compilation process. Make sure you also have the development packages of openssl installed. For example, libssl-dev on Ubuntu or openssl-devel on Fedora. If you're in a situation where you think the directory *should* be found automatically, please open a bug at https://github.com/sfackler/rust-openssl and include information about your system as well as this message. $HOST = x86_64-unknown-linux-gnu $TARGET = x86_64-unknown-linux-gnu openssl-sys = 0.9.106 warning: build failed, waiting for other jobs to finish... error: cargo rustc --lib --message-format=json-render-diagnostics --manifest-path Cargo.toml --release -v --features pyo3/extension-module --crate-type cdylib -- failed with code 101 [end of output] note: This error originates from a subprocess, and is likely not a problem with pip. ERROR: Failed building wheel for tokenizers ERROR: Failed to build installable wheels for some pyproject.toml based projects (Pillow, tokenizers) ``` **问题处理** ```bash sudo apt install -y libssl-dev pkg-config ``` ``` error: could not compile tokenizers (lib) due to 1 previous error; 3 warnings emitted Caused by: process didn't exit successfully: /home/dong4j/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rustc --crate-name tokenizers --edition=2018 tokenizers-lib/src/lib.rs --error-format=json --json=diagnostic-rendered-ansi,artifacts,future-incompat --crate-type lib --emit=dep-info,metadata,link -C opt-level=3 -C embed-bitcode=no --cfg 'feature="cached-path"' --cfg 'feature="clap"' --cfg 'feature="cli"' --cfg 'feature="default"' --cfg 'feature="dirs"' --cfg 'feature="esaxx_fast"' --cfg 'feature="http"' --cfg 'feature="indicatif"' --cfg 'feature="onig"' --cfg 'feature="progressbar"' --cfg 'feature="reqwest"' --check-cfg 'cfg(docsrs,test)' --check-cfg 'cfg(feature, values("cached-path", "clap", "cli", "default", "dirs", "esaxx_fast", "fancy-regex", "http", "indicatif", "onig", "progressbar", "reqwest", "unstable_wasm"))' -C metadata=dcfeed9efd370df2 -C extra-filename=-6d0cff9823c410a3 --out-dir /tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps -C strip=debuginfo -L dependency=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps --extern aho_corasick=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libaho_corasick-806902cb00c4532e.rmeta --extern cached_path=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libcached_path-140c0420b639fee2.rmeta --extern clap=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libclap-f0a196de7c2c2d55.rmeta --extern derive_builder=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libderive_builder-2c35d20c5dcdd1b2.rmeta --extern dirs=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libdirs-2a450a96233c46e8.rmeta --extern esaxx_rs=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libesaxx_rs-14b460a83d36cffb.rmeta --extern getrandom=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libgetrandom-e4e984aab09fca54.rmeta --extern indicatif=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libindicatif-4bd79d992ed7623e.rmeta --extern itertools=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libitertools-d3748b68b90d39fc.rmeta --extern lazy_static=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/liblazy_static-293673978ef0d67b.rmeta --extern log=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/liblog-eeeeba1bbfa2ffb1.rmeta --extern macro_rules_attribute=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libmacro_rules_attribute-d837ae137eb1c6b5.rmeta --extern monostate=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libmonostate-ee413b2bc414638b.rmeta --extern onig=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libonig-fdcb261852f9dd25.rmeta --extern paste=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libpaste-e8c11b814c73abf8.so --extern rand=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/librand-1a565600c8701f83.rmeta --extern rayon=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/librayon-327814f00a0af0eb.rmeta --extern rayon_cond=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/librayon_cond-4c94b6c4149cf439.rmeta --extern regex=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libregex-b19fe03f86732b4c.rmeta --extern regex_syntax=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libregex_syntax-2d5a08e62adc8bc5.rmeta --extern reqwest=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libreqwest-2e0f1b3d46ba2d8c.rmeta --extern serde=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libserde-09bbdc6f8d206673.rmeta --extern serde_json=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libserde_json-f9dd1e99e29af66a.rmeta --extern spm_precompiled=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libspm_precompiled-b00102097bfa2810.rmeta --extern thiserror=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libthiserror-533f4e0aa82c3f92.rmeta --extern unicode_normalization_alignments=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libunicode_normalization_alignments-9913c65b13ec4f88.rmeta --extern unicode_segmentation=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libunicode_segmentation-175194bc41713dcf.rmeta --extern unicode_categories=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/deps/libunicode_categories-fb12b97a420ed313.rmeta -L native=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/build/bzip2-sys-1373a3f19d1e511d/out/lib -L native=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/build/zstd-sys-6d5eafba8c9430e7/out -L native=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/build/esaxx-rs-9028bfd7929bddde/out -L native=/tmp/pip-install-dnunfm0j/tokenizers_40edca6a2fef4b23bfab7d044c44a2a3/target/release/build/onig_sys-9ef1a52efbdf7afc/out (exit status: 1) warning: build failed, waiting for other jobs to finish... error: cargo rustc --lib --message-format=json-render-diagnostics --manifest-path Cargo.toml --release -v --features pyo3/extension-module --crate-type cdylib -- failed with code 101 [end of output] note: This error originates from a subprocess, and is likely not a problem with pip. ERROR: Failed building wheel for tokenizers ERROR: Failed to build installable wheels for some pyproject.toml based projects (Pillow, tokenizers) ``` **问题处理** ```bash sudo apt update && sudo apt install -y build-essential cmake sudo apt install -y python3-dev ``` 最终问题是我使用了 python3.12, 切换到 3.10 即可. ``` OSError: Can't load tokenizer for 'openai/clip-vit-large-patch14'. If you were trying to load it from 'https://huggingface.co/models', make sure you don't have a local directory with the same name. Otherwise, make sure 'openai/clip-vit-large-patch14' is the correct path to a directory containing all relevant files for a CLIPTokenizer tokenizer. ``` **参考** - [玩转 AIGC:Ubuntu 24.04 LTS 安装配置 Stable Diffusion WebUI](https://cloud.tencent.com/developer/article/2416536) ## 参考资料 - [Tesla P40 技术规格]([GeForce GTX 1080 Ti 与 P40 比较?](https://www.zhihu.com/question/267786456)) - [Tesla P40 发布资料]([NVIDIA 发布 AI 计算卡 Tesla P40:完整版 GP102 大核心,24GB 显存](https://www.expreview.com/49499.html)) - [Tesla P40 与 GTX 1080TI 对比]([GeForce GTX 1080 Ti 与 P40 比较?](https://www.zhihu.com/question/267786456)) - [NVIDIA CUDA 列表]([CUDA Toolkit Archive](https://link.zhihu.com/?target=https%3A//developer.nvidia.com/cuda-toolkit-archive)) ## [探索在线封面生成工具:打造专属博客封面的捷径](https://blog.dong4j.site/posts/cd1cca40.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 在数字化时代,一张吸引人的封面图片对于博客来说至关重要。它不仅能够提升文章的视觉效果,还能在第一时间吸引读者的注意力。制作封面的方法多种多样,从自行设计到基于现有模板进行修改,每种方式都有其独特之处。然而,如果你希望以最快捷的方式输入标题或基本信息,直接生成封面,那么在线封面生成器无疑是你的最佳选择。今天,就让我带你一起探索几款优秀的在线封面生成工具,为你的技术博客增添一抹亮色。 ## CoverView:开发者的首选 ![20250211194200_tjcJIPk6.webp](https://cdn.dong4j.site/source/image/20250211194200_tjcJIPk6.webp) **链接:** [CoverView](https://coverview.vercel.app/) | [GitHub 仓库](https://github.com/rutikwankhade/CoverView) CoverView 以其丰富的内置模板和图标而著称,尤其是针对编程语言的图标,这使得它成为开发者制作技术博客封面的理想工具。无论是 Java、Python 还是 React,你都能在这里找到合适的图标和模板,快速打造出专业且符合技术氛围的封面。 ## Cover-Image-Generator:灵活调整,创意无限 ![20250211194200_aYfVrY87.webp](https://cdn.dong4j.site/source/image/20250211194200_aYfVrY87.webp) **链接:** [Cover-Image-Generator](https://blogcover.vercel.app/) | [GitHub 仓库](https://github.com/PJijin/Cover-Image-Generator) 虽然没有内置模板,但 Cover-Image-Generator 的灵活性却为其赢得了众多粉丝。你可以自由移动标题和副标题的位置,调整字体、颜色和大小,直至达到满意的效果。这种自由度让创意无限延伸,为你的博客封面增添个性色彩。 ## PicProse:简洁而不简单 ![20250211194200_RjSyhkBw.webp](https://cdn.dong4j.site/source/image/20250211194200_RjSyhkBw.webp) **链接:** [PicProse](https://picprose.net/zh) | [GitHub 仓库](https://github.com/jaaronkot/picprose) PicProse 以其简洁的界面和实用的功能著称。它提供了基本的封面设计元素,让你能够快速上手,同时又不失专业感。无论是简单的文字封面还是需要添加图片的复杂设计,PicProse 都能轻松应对。 ## imgsrc:个性化定制,彰显特色 ![20250211141043_kt8UBSxC.webp](https://cdn.dong4j.site/source/image/20250211141043_kt8UBSxC.webp) **链接:** [imgsrc](https://imgsrc.io/) | [GitHub 仓库](https://github.com/FadyMak/imgsrc-app) imgsrc 允许你上传自定义图标和图片,为封面增添个性化元素。此外,它还提供了多种背景和布局选择,让你能够根据博客内容定制专属封面。无论是展示网站还是横向图片,imgsrc 都能帮你实现。 ## Free Open Graph Generator:无水印,更专业 ![20250211194201_FWOlacrG.webp](https://cdn.dong4j.site/source/image/20250211194201_FWOlacrG.webp) **链接:** [Free Open Graph Generator](https://og.indiehub.best/) 基于 imgsrc 修改而来的 Free Open Graph Generator 去掉了水印功能,让你的封面看起来更加专业。它保留了 imgsrc 的实用功能,同时提升了封面的整体美感。 ## OG Image Maker:模板丰富,自定义度高 ![20250211194201_NjJkKtqj.webp](https://cdn.dong4j.site/source/image/20250211194201_NjJkKtqj.webp) **链接:** [OG Image Maker](https://ogimagemaker.com/) OG Image Maker 内置了多种模板供你选择,同时支持修改颜色、背景图片和底部的按钮等元素。这种高度的自定义性让你能够打造出独具特色的博客封面。 ## Open Graph Image Generator:专注网站展示 ![20250211194201_SZgoX96u.webp](https://cdn.dong4j.site/source/image/20250211194201_SZgoX96u.webp) **链接:** [Open Graph Image Generator](https://tailwind-generator.com/og-image-generator/generator) 这款生成器特别适合用来展示网站或横向图片。虽然上传的图标大小固定,但其专业的设计感使得它成为展示网站内容的理想选择。 ## Free Open Graph Image Generator - Placid.app:简洁实用 ![20250211194201_0I9olZZk.webp](https://cdn.dong4j.site/source/image/20250211194201_0I9olZZk.webp) **链接:** [Free Open Graph Image Generator - Placid.app](https://placid.app/tools/free-open-graph-image-generator) Placid.app 提供的这款生成器界面简洁明了,操作简便。你只需输入标题和副标题,选择合适的布局即可生成满意的封面图片。 ## Open Graph Image Generator | BoilerplateHub:快速生成,简单高效 ![20250211194201_uoI2Oz33.webp](https://cdn.dong4j.site/source/image/20250211194201_uoI2Oz33.webp) **链接:** [Open Graph Image Generator | BoilerplateHub](https://boilerplatehub.com/free-tools/open-graph-image-generator) BoilerplateHub 的这款生成器以其快速和简单著称。它提供了两种布局选择,让你只需输入标题和副标题即可生成封面。对于追求效率的博主来说,这是一个不可多得的工具。 ## Vercel OG Image Playground:官方出品,品质保证 ![20250211194201_ZJNWNN3S.webp](https://cdn.dong4j.site/source/image/20250211194201_ZJNWNN3S.webp) **链接:** [Vercel OG Image Playground](https://og-playground.vercel.app/) 作为 Vercel 官方推出的 OG 图像生成器,这款工具提供了高质量的设计体验。你可以在右下角导出 svg 格式的图片,如果需要 png 格式,只需在右上角的 tab 栏中切换到 png(satori + resvg-js)模式。无论是设计感还是实用性,Vercel OG Image Playground 都堪称一流。 ## cover-paint:自部署,灵活使用 ![20250211194201_fYCmHyXG.webp](https://cdn.dong4j.site/source/image/20250211194201_fYCmHyXG.webp) **链接:** [cover-paint](https://coverpaint.xiaole.site/) | [GitHub 仓库](https://github.com/youngle316/cover-paint) 虽然目前官网可能无法访问,但你可以通过 GitHub 仓库在 Vercel 或 Netlify 等平台自行部署使用。cover-paint 提供了丰富的自定义选项,让你能够根据个人需求打造独特的封面设计。 ## 总结 目前正在使用 `CoverView` , 不过每次都需要去配置 + 下载 + 移动到博客目录, 感觉还是有很多步骤要手动操作. 而 `Free Open Graph Generator` 提供了图像生成的 API, 那么就自动化生成 cover 提供了可能. 目前是这样打算的: 1. hexo 编译时通过钩子获取博客文章的元数据, 然后构建 API 参数调用后端自建的随机 API 接口; 2. 新增一个随机 API 接口, 用于处理 cover 的生成: 1. 根据 `abbrlink` 获取 CDN 图片,如果存在则直接返回; 2. 如果不存在则调用 `Free Open Graph Generator` 接口生成并转换为 webp, 上传到图床, 最后返回 CDN 地址; 感觉这一套组合拳下来, 博客 cover 图片的问题就彻底解决了. 其实这类图片可以使用 [Open Graph 协议](https://ogp.me/) 来生成, 像这类的项目非常多, 比如: - https://github.com/sanwebinfo/og-image - https://github.com/samholmes/node-open-graph?tab=readme-ov-file - https://github.com/kvnang/workers-og - https://github.com/kentaro-m/catchy-image - https://github.com/svycal/og-image?tab=readme-ov-file 最核心的图像生成已经有了, 那么剩下的就是将整个流程串联起来, 实现自动化生成博客文章的 cover 图像. 等实现了再来水一篇博客 🙉. ## [从零开始开发一个 Hexo 插件:以 hexo-plugin-llmstxt 为例](https://blog.dong4j.site/posts/b902d8fd.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 前言 在 AI 时代,越来越多的开发者开始使用 AI 编程助手来提升开发效率。为了让 AI 更好地理解和学习我们的技术文档,社区提出了 `llms.txt` 规范。本文将以开发一个生成 `llms.txt` 的 Hexo 插件为例,介绍 Hexo 插件开发的基本流程。 ## 什么是 llms.txt? ### 背景 随着 ChatGPT、Claude 等大语言模型的普及,越来越多的开发者开始使用 AI 编程助手。但是这些 AI 助手在访问网站内容时,往往需要处理复杂的 HTML 结构,这不仅增加了处理成本,还可能影响理解的准确性。 ### llms.txt 规范 `llms.txt` 类似于 `robots.txt`,它是一个专门为 AI 准备的纯文本格式的站点内容索引。通过提供结构化的纯文本内容,可以帮助 AI 更好地理解和学习网站的内容。 主要特点: 1. 纯文本格式,易于解析 2. 包含文章的标题、描述和链接 3. 可选包含完整的文章内容 4. 支持 Markdown 格式 查看本站的 [llmx.txt](https://blog.dong4j.site/llms.txt) 和 [llmx-full.txt](https://blog.dong4j.site/llms-full.txt) ## Hexo 插件开发基础 ### 插件类型 Hexo 支持多种类型的插件: - Generator:用于生成静态文件 - Renderer:用于渲染文件 - Helper:用于辅助模板渲染 - Deployer:用于部署 - Processor:用于处理源文件 - Tag:用于在文章中插入特定内容 - Console:用于添加控制台命令 我们的 `llms.txt` 生成插件属于 Generator 类型。 ### 基本结构 一个典型的 Hexo 插件目录结构如下: ``` . ├── index.js ├── package.json ├── README.md └── LICENSE ``` ### 插件命名规范 Hexo 插件的命名需要遵循以下规则: - 以 `hexo-` 开头 - 全小写 - 使用连字符(-)连接单词 ## 实战:开发 hexo-plugin-llmstxt ### 1. 初始化项目 首先创建项目目录并初始化: ```bash mkdir hexo-plugin-llmstxt cd hexo-plugin-llmstxt npm init ``` ### 2. 编写 package.json ```json { "name": "hexo-plugin-llmstxt", "version": "1.0.0", "description": "Generate llms.txt for Hexo sites", "main": "index.js", "keywords": [ "hexo", "llms", "ai" ], "dependencies": {} } ``` ### 3. 实现核心功能 在 `index.js` 中实现插件的核心功能: ```javascript 'use strict'; const fs = require('fs'); const path = require('path'); // 注册生成器 hexo.extend.generator.register('llms', function (locals) { // 获取配置 const config = Object.assign({ generate_llms_full: false, debug: false, postsHeader: hexo.config.title, description: hexo.config.description, sort: 'desc' }, hexo.config.llmstxt); // 生成文件内容 // ... }); ``` ### 4. 处理文章内容 ```javascript // 处理每篇文章 locals.posts.sort('date', sortOrder) .filter(post => post.published !== false) .forEach(post => { const { title, raw, description, } = post; // 移除 Front-matter const content = raw.replace(/^---[\s\S]+?---\n/, '').trim(); // 生成链接和内容 // ... }); ``` ### 5. 文件生成 ```javascript // 写入文件 try { fs.writeFileSync(llmsFilePath, llmsContent); if (config.debug) { console.log('生成 llms.txt 成功'); } } catch (err) { console.error('写入文件时发生错误:', err); } ``` ## 开发过程中的注意事项 1. **错误处理** - 确保目录存在 - 处理文件写入错误 - 添加调试日志 2. **配置处理** - 提供默认值 - 支持用户自定义 - 配置项文档化 3. **文件路径** - 使用 `path.join` 处理路径 - 考虑跨平台兼容性 4. **性能优化** - 避免重复操作 - 合理使用内存 5. **代码风格** - 遵循 JavaScript 规范 - 添加适当的注释 - 保持代码整洁 ## 测试和发布 ### 本地测试 1. 在本地 Hexo 项目中使用 npm link: ```bash cd hexo-plugin-llmstxt npm link cd /path/to/hexo/project npm link hexo-plugin-llmstxt ``` 2. 运行 Hexo 命令测试: ```bash hexo clean hexo generate ``` ### 发布到 NPM 1. 登录 NPM: ```bash npm login ``` 2. 发布包: ```bash npm publish ``` ## 源码: https://github.com/dong4j/hexo-plugin-llms ## 参考资料 - [Hexo 官方文档](https://hexo.io/docs/) - [LLMs.txt 规范](https://llmstxt.org) - [具有 LLMs.txt 的站点](https://llmstxt.site/) - [AI时代的站点地图](https://juejin.cn/post/7447083753187328050) - [SiliconCloud 使用文档与 Cursor](https://docs.siliconflow.cn/use-docs-with-cursor) - [使用 Firecrawl 生成 llmstxt](https://github.com/mendableai/llmstxt-generator) - [使用 sitemap.xml 生成 llmstxt](https://github.com/dotenvx/llmstxt) ## [Lazydocker:终端中的懒人 Docker 管理神器](https://blog.dong4j.site/posts/edfbbdc9.md) ![20250211170022_5u6teeGg.webp](https://cdn.dong4j.site/source/image/20250211170022_5u6teeGg.webp) ## 简介 在 Docker 生态中,命令行操作虽然强大,但繁琐的命令记忆和跨终端窗口的容器管理往往让开发者头疼。**Lazydocker** 应运而生,这是一款基于终端的 Docker 管理工具,凭借简洁的 UI 设计和一键式操作,成为众多开发者提升效率的利器。本文将从功能、安装到实际体验,全面解析这一工具。 --- ## Lazydocker 的核心功能 1. **一站式容器管理** 通过终端 UI 界面,Lazydocker 支持实时查看 Docker 容器、镜像、卷和网络的运行状态,无需在多个终端窗口切换。 2. **快捷操作与调试** - **日志流分类查看**:支持按服务或容器分类显示日志,快速定位问题。 - **一键重启/重建容器**:按下快捷键即可重启、重建或删除容器,尤其适合调试服务故障。 - **镜像与磁盘管理**:查看镜像层级结构,清理无用镜像或卷以释放磁盘空间。 3. **自定义与扩展性** 用户可绑定自定义命令或快捷键,甚至通过配置文件修改界面布局,满足个性化需求。 --- ## 安装指南(支持多平台) Lazydocker 支持多种安装方式,以下是主流操作系统的快速安装方法: 1. **Linux/macOS** - **一键脚本安装**(推荐): ```bash curl https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | bash ``` - **Homebrew**(macOS): ```bash brew install lazydocker ``` - **手动安装**: 从 [GitHub Release 页面](https://github.com/jesseduffield/lazydocker/releases) 下载对应平台的二进制包,解压后添加到系统路径即可。 2. **Windows** 使用 Scoop 包管理器安装: ```powershell scoop install lazydocker ``` 3. **Docker 容器运行** 若不想本地安装,可直接通过 Docker 运行: ```bash docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock lazyteam/lazydocker ``` 此方式适合临时使用或隔离环境调试。 --- ## 使用场景与实战技巧 1. **实时监控容器状态** 输入 `lazydocker` 启动后,界面会分栏显示容器列表、日志、状态统计等信息(如图)。通过方向键或鼠标点击切换焦点,按 `R` 键重启容器,按 `L` 查看实时日志流。 2. **快速调试服务异常** 例如,当某个容器崩溃时,可在 Lazydocker 中直接查看其日志流,按 `r` 重启服务,或按 `b` 进入容器 Shell 手动调试。 3. **批量管理资源** 支持批量选择容器进行重启或删除操作,同时可查看镜像的磁盘占用,清理无用数据。 --- ## 优缺点分析 **优点**: - **零学习成本**:界面直观,快捷键提示清晰,适合 Docker 初学者。 - **轻量高效**:基于 Go 语言开发,资源占用低,响应速度快。 - **跨平台支持**:覆盖 Linux、macOS、Windows 和 Docker 容器环境。 **缺点**: - **功能局限**:复杂场景仍需依赖 Portainer 等 Web 管理工具。 - **偶发 Bug**:部分用户反馈容器状态显示异常(如运行状态未更新)。 --- ## 总结与推荐 Lazydocker 完美契合“懒人开发者”的需求,将 Docker 管理的复杂性简化为终端内的可视化操作。尽管存在小范围功能限制,但其轻量化和高效率的特点,使其成为日常开发和调试的必备工具。对于偏好命令行操作的用户,Lazydocker 是 Portainer 的绝佳补充。 **项目地址**:[GitHub - jesseduffield/lazydocker](https://github.com/jesseduffield/lazydocker) **扩展阅读**: - 官方文档提供了完整的快捷键列表和配置示例。 - B 站有开发者分享了 Lazydocker 的实际操作演示([视频链接](https://m.bilibili.com/video/BV1AA4m137XJ/)) 尝试 Lazydocker,让你的 Docker 管理从此“懒”得高效! ## [哪吒面板配置指南:自托管与安全防控详解](https://blog.dong4j.site/posts/adc3ac9e.md) ![20250211170256_jLzrA4QP.webp](https://cdn.dong4j.site/source/image/20250211170256_jLzrA4QP.webp) ## 简介 哪吒面板是一个开源的监控解决方案,旨在帮助用户轻松实现服务器和应用的实时监控。通过 Dashboard、Agent 以及前后台前端资源的配合,用户可以全面掌握系统运行状态。 v1.x 版本相较于老版本简化了部署方式, 我主要用于监控家中的设备, 并开放到了公网, 为了安全起见做了响应的防护, 接下来将简单介绍一下自托管与安全防控. ## 部署 可参考 [官方文档](https://nezha.wiki/guide/dashboard.html) 部署, 因为我需要对前端页面做相应的修改, 所以这里选择使用源码的方式部署. ### 资源下载 - **Dashboard**: [nezha/releases](https://github.com/nezhahq/nezha/releases) - **Agent**: [agent/releases](https://github.com/nezhahq/agent/releases) - **后台前端资源**: [admin-frontend/releases](https://github.com/nezhahq/admin-frontend/releases) - **前台前端资源**: [nezha-dash-v1/releases](https://github.com/hamster1963/nezha-dash-v1/releases) 本人使用 pm2 在家中的服务器上部署, 为了简化部署就写了一键部署脚本, 所以这里需要讲一下部署包的目录结构: ``` . ├── agent # 代理服务目录, 包含代理配置和二进制文件 │ ├── config.yml │ └── nezha-agent ├── dashboard # dashboard 服务目录 │ ├── admin-dist # 后台前端静态资源目录 │ ├── nezha-dash-v1 # 前台前端项目 │ ├── user-dist # 前端前端静态资源目录(nezha-dash-v1 项目编译后会自动将静态资源拷贝到此目录) │ ├── dashboard-linux-amd64 # dashboard 二进制文件 │ ├── config.yaml # dashboard 配置文件 │ └── sqlite.db # dashboard 数据库 ├── deploy-dashboard.sh # 自动化部署脚本 ├── ecosystem.config.js # pm2 部署配置 └── download.sh # 从服务器同步最新的数据库文件等关键数据 ``` 将上述的资源文件下载后存放到对应的目录, 然后为二进制文件赋予执行权限: ```bash chmod +x agent/nezha-agent chmod +x dashboard/dashboard-linux-amd64 ``` 如果不需要对前台前端页面做任何改动, 直接下载 releases 的 dist 部署即可, 因为我需要修改前台页面, 所以这里下载 nezha-dash-v1 源码, 本地修改完成后自行编译部署. ### nezha-dash-v1 修改 个人对前端页面做了如下修改: #### 添加 umami 统计 在 index.html 中添加自建的 umami 统计服务: ![20250211180300_vA7FAiiR.webp](https://cdn.dong4j.site/source/image/20250211180300_vA7FAiiR.webp) #### 隐藏公网的登录入口 为暴露到公网的监控面板做了点安全防控, 避免被人爆破, 修改文件: `src/components/Header.tsx`: ```javascript ... const [showDashboardLink, setShowDashboardLink] = useState(false); useEffect(() => { // 检查当前页面的主机名和端口号是否为本地部署的 dashboard 地址, 比如: 192.168.1.2:8888 if (window.location.host === '192.168.1.2:8888') { setShowDashboardLink(true); } }, []); ... ``` 然后将此文件中的 `` 修改为: ``` {showDashboardLink && } ``` 这样只有在内网访问时才会显示登录按钮. 当然这是前端入口的简单防控, 外部用户仍然可以绕过, 比如在本地通过 Nginx 代理到我的公网域名, 这样 `window.location.host` 获取到的也是 `192.168.1.2:8888`, 所以这里我并没有将真实的局域网暴露出来. 为了进一步加载, 接下来是从接口上进行限制. 因为使用 Nginx 将内网的监控面板暴露到了公网, 所以通过对 Nginx 配置可以限制接口访问: ``` # 禁止访问 /dashboard 以及 /dashboard 后面的路径 location ^~ /dashboard { deny all; # 拒绝所有访问 return 403; # 返回 403 Forbidden 错误 } ``` 这样外网也无法通过 API 进行爆破. > 另外可以在服务器端检查请求的来源 IP 地址,确保只有来自内网 IP 地址的请求才能访问登录页面。但字段仍然可以伪造. > > 如果不需要暴露到外网, 最好使用 VPN 这种方式访问家中的服务. --- #### 页面整体放大 目前的前端页面看着太小了, 所以将页面整体放大到原来的 1.25 倍, 在 `index.css` 中添加: ```css @media (min-width: 1920px) and (min-height: 1080px) { html { transform: scale(1.25); transform-origin: top; width: 100vw; height: 100vh; } } ``` ### 一键部署 #### 安装依赖 首先需要在服务器上安装 Node.js 和 pm2(因为哪吒面板使用 root 运行, 所以我也使用 root 用户安装了 Node.js 和 pm2): ```bash curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - \ && sudo apt install -y nodejs \ && sudo npm install -g pm2 \ && pm2 -v ``` #### 一键更新脚本 为了避免数据库被本地覆盖, 所以在部署前使用更新脚本拉取服务器上最新的数据: ```bash #!/bin/zsh # 获取当前脚本的所在目录 SCRIPT_DIR=$(dirname "$(realpath "$0")") cd "$SCRIPT_DIR" || exit 1 # 配置项 REMOTE_SSH_ALIAS="m920x" # 服务器的 SSH 别名 REMOTE_DIRECTORY="/opt/nezha" # 远程目录 LOCAL_DIRECTORY="$SCRIPT_DIR" # 本地目录 # 时间戳 TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S") # 执行 rsync 同步 echo "[$TIMESTAMP] 开始同步..." rsync -avz --verbose \ --exclude '.DS_Store' \ --exclude '._*' \ --exclude '__MACOSX' \ --exclude 'logs/' \ --exclude 'dashboard-linux-amd64' \ --exclude 'nezha-agent' \ --exclude 'admin-dist/' \ --exclude 'user-dist/' \ "$REMOTE_SSH_ALIAS:$REMOTE_DIRECTORY/" "$LOCAL_DIRECTORY/" # 检查同步是否成功 if [ "${PIPESTATUS[0]}" -eq 0 ]; then echo "[$TIMESTAMP] 同步完成!" else echo "[$TIMESTAMP] 同步失败!请检查日志。" fi ``` > 这里的 `m920x` 是我配置到 ssh 别名, 需要进行免密登录配置 #### pm2 部署配置 `ecosystem.config.js` 配置内容: ```javascript module.exports = { apps: [ { name: "nezha-dashboard", // 应用名称 namespace: "nezha", // 指定命名空间 version: "1.0.0", // 应用版本 cwd: "/opt/nezha/dashboard", // 当前工作目录 script: "./dashboard-linux-amd64", // 主脚本路径,相对于 cwd args: " -c ./config.yaml -db ./sqlite.db", // 传递给脚本的参数 watch: false, // 是否启用文件监控 ignore_watch: [], // 忽略监控的文件或目录 exec_mode: "fork", instances: 1, // 应用实例数量 autorestart: true, // 是否自动重启 env: {}, log_date_format: "YYYY-MM-DD HH:mm:ss", // 日志时间格式 error_file: "./logs/error.log", // 错误日志文件 out_file: "./logs/out.log", // 输出日志文件 merge_logs: true, // 是否合并日志 }, ], }; ``` > `cwd` 需要修改为实际的部署目录, 默认是 `/opt/nezha`. #### 部署脚本 ```bash #!/bin/bash # 获取当前脚本的所在目录 SCRIPT_DIR=$(dirname "$(realpath "$0")") cd "$SCRIPT_DIR" || exit 1 # 配置项 REMOTE_SSH_ALIAS="m920x" # 远程服务器的 SSH 别名 LOCAL_PATH="$SCRIPT_DIR" # 本地文件路径 REMOTE_PATH="/opt/nezha" # 远程文件路径(完整路径,包括文件名) # 提示用户点击回车继续 echo "Press Enter to continue with the deployment..." read -r # 部署前端 cd "$SCRIPT_DIR/dashboard/nezha-dash-v1" || exit 1 bun install rm -rf dist bun run build cd "$SCRIPT_DIR" || exit 1 # 拷贝 dist 到 user-dist rsync -avz --delete \ --exclude '.DS_Store' \ --exclude '._*' \ --exclude '__MACOSX' \ "$LOCAL_PATH/dashboard/nezha-dash-v1/dist/" "$LOCAL_PATH/dashboard/user-dist/" # 先同步数据库文件到本地, 避免覆盖 # rsync -avz --verbose \ # "$REMOTE_SSH_ALIAS:$REMOTE_PATH/dashboard/data/sqlite.db" "$LOCAL_PATH/dashboard/data/sqlite.db" # 上传文件到远程服务器 rsync -avz --delete \ --exclude '.DS_Store' \ --exclude '._*' \ --exclude '__MACOSX' \ --exclude "deploy-dashboard.sh" \ --exclude "download.sh" \ --exclude "agent/" \ --exclude "nezha-dash-v1/" \ "$LOCAL_PATH/" "$REMOTE_SSH_ALIAS:$REMOTE_PATH/" # 上传完成 echo "Upload complete." ssh "$REMOTE_SSH_ALIAS" "source /root/.nvm/nvm.sh && pm2 stop nezha-dashboard" ssh "$REMOTE_SSH_ALIAS" "source /root/.nvm/nvm.sh && pm2 delete nezha-dashboard" ssh "$REMOTE_SSH_ALIAS" "source /root/.nvm/nvm.sh && pm2 start $REMOTE_PATH/ecosystem.config.js" if [ $? -ne 0 ]; then echo "Error: Failed to reload nezha-dashboard on server '$REMOTE_SSH_ALIAS'." exit 1 fi echo "Server configuration successfully updated and reloaded on '$REMOTE_SSH_ALIAS'." ``` 1. 编译 `nezha-dash-v1` 并将 dist 中的静态文件拷贝到 `user-dist`; 2. 上传指定文件到远程服务器; 3. 使用 pm2 启动服务, (这里是先删除然后重新启动, 因为我这里使用 `restart` 会有点问题); > 记得使用 `source /root/.nvm/nvm.sh`, 直接使用 pm2 会找不到文件. 其实最终调用的启动命令为: ```bash /path/to/dashboard -c /path/to/config.config.yaml -db /path/to/sqlite.db ``` - **首次运行**: 建议移除 `-db` 参数,让程序自动生成配置文件和数据库。 - **配置文件说明**: - `listenport`: 设定 Dashboard 和 Agent 的监听端口,需做好反向代理配置。 - `agentsecretkey`: 用于 Agent 连接 Dashboard 的密钥。 #### 运行 Agent 上述脚本只是为了部署 dashboard, Agent 因为一般不会频繁修改, 所有这里使用命令行的方式直接启动即可: ```bash /path/to/agent -c /path/to/config.yaml ``` - **首次运行**: 使用 `./nezha-agent edit` 命令生成配置文件,按照提示完成设置。 - **配置文件说明**: - `client_secret`: 输入 Dashboard 配置中的 `agentsecretkey`。 - `server`: 指定 Dashboard 的服务器地址和端口。 > 如果使用 `agent.sh` 脚本安装的, 可以直接使用 `systemctl restart nezha-agent` 重启代理服务. --- ## 命令行操作 ```bash $ sudo ./nezha-agent -h NAME: nezha-agent - 哪吒监控 Agent USAGE: nezha-agent [global options] command [command options] VERSION: 1.6.1 COMMANDS: edit 编辑配置文件 service 服务操作 help, h 显示命令列表或帮助信息 GLOBAL OPTIONS: --config value, -c value 配置文件路径 --help, -h 显示帮助信息 --version, -v 显示版本号 ``` macOS 重启 agent: ```bash $ sudo ./nezha-agent service restart ``` Linux 重启 agent: ```bash $ sudo systemctl restart nezha-agent ``` **服务操作示例:** ```bash $ sudo ./nezha-agent service -h NAME: nezha-agent service - 服务管理 USAGE: OPTIONS: --config value, -c value 配置文件路径 --help, -h 显示帮助信息 ``` ## 参考资料 - [设置 OAuth 2.0 绑定](https://nezha.wiki/guide/q14.html) - [Nezha Dashboard V1 前端源码](https://github.com/hamster1963/nezha-dash-v1?tab=readme-ov-file) - [自定义代码](https://nezhadash-docs.buycoffee.top/custom-code) - [服务器公开备注生成器](https://nezhainfojson.pages.dev/) - [美化 1](https://blog.zmyos.com/nezha-theme.html) - [美化 2](https://misaka.es/archives/33.html) ### 安全配置 - [自定义 Agent 监控项目](https://nezha.wiki/guide/q7.html) - [哪吒探针关闭网页 ssh](https://blog.weedsstars.com/index.php/archives/26/) - [哪吒 v1 关闭 ssh 脚本](https://www.nodeseek.com/post-232313-1) > Dashboard 开启 Debug 模式后可以访问路径 /swagger/index.html 查看详情 ## [自建服务:使用 AI 生成文章摘要并使用 Kokoro TTS 生成语音播报](https://blog.dong4j.site/posts/66ccb3f1.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 一直在使用 [TianliGPT](https://docs_s.tianli0.top/) 作为博客的摘要生成服务, 便宜好用. 年前爆火的 DeepSeek-R1 算是把 LLM API 的价格打下来了, 便有了替换 AI 文章摘要的想法. 其实在 [[npx-card-dev|使用 Node.js 开发数字名片并集成 Chat 服务]] 这篇文章已经将本地的 LLM API 服务搭建好了, 因为 TianliGPT 的语音播报服务无法使用, 所以一直在等 [Kokoro TTS](https://huggingface.co/hexgrad/Kokoro-82M) 的中文模型, 正好今天开源了, 所以又可以开始折腾了. ## 需求整理 - 使用自建 AI API 代替 TianliGPT API 来生成文章摘要; - 使用 Kokoro TTS 将文章摘要生成语音并播放; 需求其实挺简单, 就是替换成部署到 HomeLab 的 API, So easy 🙉. --- ## 架构图 ![20250208223047_6SH1UonG.webp](https://cdn.dong4j.site/source/image/20250208223047_6SH1UonG.webp) ## 一、环境准备 ### 1. 技术栈概述 - 博客框架:Hexo - AI 服务:本地部署的 LLM 以及在线免费的 LLM 服务 API, 通过 one-api 代理(替代 TianliGPT) - 语音合成:Kokoro TTS ### 2. 硬件与软件要求 - 服务器环境: - Mac mini M2(16G 内存), 用于部署 Kokoro TTS; - M920x: 用于部署摘要服务和语音生成客户端服务; - 开发工具: - Node.js: 摘要服务; - Python: 语音生成客户端服务; - pm2: 服务器部署使用; --- ## 二、实现细节 ### 1. Hexo 代码修改 通过直接修改 TianliGPT 代码实现, 安知鱼主题中的 `themes/anzhiyu/source/js/anzhiyu/ai_abstract.js` 是文章摘要服务的主要逻辑, 我们需要将这个文件拷贝出来进行相应的修改: ```javascript (function () { const { randomNum, basicWordCount, btnLink, apiUrl, audioUrl, key: AIKey, Referer: AIReferer, gptName, switchBtn, mode: initialMode, } = GLOBAL_CONFIG.postHeadAiDescription; const { title, postAI, pageFillDescription } = GLOBAL_CONFIG_SITE; let lastAiRandomIndex = -1; let animationRunning = true; let mode = initialMode; let refreshNum = 0; let prevParam; let audio = null; let isPaused = false; let summaryID = null; const post_ai = document.querySelector(".post-ai-description"); const aiTitleRefreshIcon = post_ai.querySelector( ".ai-title .anzhiyufont.anzhiyu-icon-arrow-rotate-right" ); let aiReadAloudIcon = post_ai.querySelector(".anzhiyu-icon-circle-dot"); const explanation = post_ai.querySelector(".ai-explanation"); let aiStr = ""; let aiStrLength = ""; let delayInit = 600; let indexI = 0; let indexJ = 0; let timeouts = []; let elapsed = 0; const observer = createIntersectionObserver(); const aiFunctions = [ introduce, aiTitleRefreshIconClick, aiRecommend, aiGoHome, subscribe, ]; const aiBtnList = post_ai.querySelectorAll(".ai-btn-item"); const filteredHeadings = Array.from(aiBtnList) .filter((heading) => heading.id !== "go-tianli-blog") .filter((heading) => heading.id !== "read-audio") .filter((heading) => heading.id !== "go-comment"); filteredHeadings.forEach((item, index) => { item.addEventListener("click", () => { aiFunctions[index](); }); }); document.getElementById("ai-tag").addEventListener("click", onAiTagClick); aiTitleRefreshIcon.addEventListener("click", onAiTitleRefreshIconClick); document.getElementById("go-tianli-blog").addEventListener("click", () => { window.open(btnLink, "_blank"); }); document.getElementById("go-comment").addEventListener("click", () => { anzhiyu.scrollToDest(document.body.scrollHeight, 500); }); document.getElementById("read-audio").addEventListener("click", readAloud); aiReadAloudIcon.addEventListener("click", readAloud); async function readAloud() { if (!summaryID) { anzhiyu.snackbarShow("摘要还没加载完呢,请稍后。。。"); return; } aiReadAloudIcon = post_ai.querySelector(".anzhiyu-icon-circle-dot"); aiReadAloudIcon.style.opacity = "0.2"; if (audio && !isPaused) { audio.pause(); isPaused = true; aiReadAloudIcon.style.opacity = "1"; aiReadAloudIcon.style.animation = ""; aiReadAloudIcon.style.cssText = "animation: ''; opacity: 1;cursor: pointer;"; return; } if (audio && isPaused) { audio.play(); isPaused = false; aiReadAloudIcon.style.cssText = "animation: breathe .5s linear infinite; opacity: 0.2;cursor: pointer"; return; } const options = { key: AIKey, Referer: AIReferer, }; const requestParams = new URLSearchParams({ key: options.key, id: summaryID, }); const requestOptions = { method: "GET", headers: { "Content-Type": "application/json", Referer: options.Referer, }, }; try { const response = await fetch( `${audioUrl}?${requestParams}`, requestOptions ); if (response.status === 403) { console.error("403 refer与key不匹配。"); } else if (response.status === 500) { console.error("500 系统内部错误"); } else { const audioBlob = await response.blob(); const audioURL = URL.createObjectURL(audioBlob); audio = new Audio(audioURL); audio.play(); aiReadAloudIcon.style.cssText = "animation: breathe .5s linear infinite; opacity: 0.2;cursor: pointer"; audio.addEventListener("ended", () => { audio = null; aiReadAloudIcon.style.opacity = "1"; aiReadAloudIcon.style.animation = ""; }); } } catch (error) { console.error("请求发生错误❎"); } } if (switchBtn) { document .getElementById("ai-Toggle") .addEventListener("click", changeShowMode); } aiAbstract(); showAiBtn(); function createIntersectionObserver() { return new IntersectionObserver( (entries) => { let isVisible = entries[0].isIntersecting; animationRunning = isVisible; if (animationRunning) { delayInit = indexI === 0 ? 200 : 20; timeouts[1] = setTimeout(() => { if (indexJ) { indexI = 0; indexJ = 0; } if (indexI === 0) { explanation.innerHTML = aiStr.charAt(0); } requestAnimationFrame(animate); }, delayInit); } }, { threshold: 0 } ); } function animate(timestamp) { if (!animationRunning) { return; } if (!animate.start) animate.start = timestamp; elapsed = timestamp - animate.start; if (elapsed >= 20) { animate.start = timestamp; if (indexI < aiStrLength - 1) { let char = aiStr.charAt(indexI + 1); let delay = /[,.,。!?!?]/.test(char) ? 150 : 20; if (explanation.firstElementChild) { explanation.removeChild(explanation.firstElementChild); } explanation.innerHTML += char; let div = document.createElement("div"); div.className = "ai-cursor"; explanation.appendChild(div); indexI++; if (delay === 150) { post_ai.querySelector(".ai-explanation .ai-cursor").style.opacity = "0.2"; } if (indexI === aiStrLength - 1) { observer.disconnect(); explanation.removeChild(explanation.firstElementChild); } timeouts[0] = setTimeout(() => { requestAnimationFrame(animate); }, delay); } } else { requestAnimationFrame(animate); } } function clearTimeouts() { if (timeouts.length) { timeouts.forEach((item) => { if (item) { clearTimeout(item); } }); } } function startAI(str, df = true) { indexI = 0; indexJ = 1; clearTimeouts(); animationRunning = false; elapsed = 0; observer.disconnect(); explanation.innerHTML = df ? "生成中. . ." : "请等待. . ."; aiStr = str; aiStrLength = aiStr.length; observer.observe(post_ai); } async function aiAbstract(num = basicWordCount) { if (mode === "online") { await aiAbstractTianli(num); } else { aiAbstractLocal(); } } async function aiAbstractTianli(num) { indexI = 0; indexJ = 1; clearTimeouts(); animationRunning = false; elapsed = 0; observer.disconnect(); num = Math.max(10, Math.min(2000, num)); const options = { key: AIKey, Referer: AIReferer, }; const truncateDescription = (title + pageFillDescription) .trim() .substring(0, num); const url = new URL(location.href); const pathSegments = url.pathname.split("/").filter(Boolean); // 过滤掉空字符串 const id = pathSegments[pathSegments.length - 1]; // 获取最后一个部分 const requestBody = { key: options.key, content: truncateDescription, url: id, }; const requestOptions = { method: "POST", headers: { "Content-Type": "application/json", Referer: options.Referer, }, body: JSON.stringify(requestBody), }; try { let animationInterval = null; let summary; if (animationInterval) clearInterval(animationInterval); animationInterval = setInterval(() => { const animationText = "生成中" + ".".repeat(indexJ); explanation.innerHTML = animationText; indexJ = (indexJ % 3) + 1; }, 500); const response = await fetch(apiUrl, requestOptions); let result; if (response.status === 403) { result = { summary: "403 refer与key不匹配。", }; } else if (response.status === 500) { result = { summary: "500 系统内部错误", }; } else { result = await response.json(); } summary = result.summary.trim(); summaryID = result.id; setTimeout(() => { aiTitleRefreshIcon.style.opacity = "1"; }, 300); if (summary) { startAI(summary); } else { startAI("摘要获取失败!!!请检查 AI 摘要服务是否正常!!!"); } clearInterval(animationInterval); } catch (error) { console.error(error); explanation.innerHTML = "发生异常" + error; } } function aiAbstractLocal() { const strArr = postAI.split(",").map((item) => item.trim()); if (strArr.length !== 1) { let randomIndex = Math.floor(Math.random() * strArr.length); while (randomIndex === lastAiRandomIndex) { randomIndex = Math.floor(Math.random() * strArr.length); } lastAiRandomIndex = randomIndex; startAI(strArr[randomIndex]); } else { startAI(strArr[0]); } setTimeout(() => { aiTitleRefreshIcon.style.opacity = "1"; }, 600); } function aiRecommend() { indexI = 0; indexJ = 1; clearTimeouts(); animationRunning = false; elapsed = 0; explanation.innerHTML = "生成中. . ."; aiStr = ""; aiStrLength = ""; observer.disconnect(); timeouts[2] = setTimeout(() => { explanation.innerHTML = recommendList(); }, 600); } function recommendList() { let thumbnail = document.querySelectorAll(".relatedPosts-list a"); if (!thumbnail.length) { const cardRecentPost = document.querySelector( ".card-widget.card-recent-post" ); if (!cardRecentPost) return ""; thumbnail = cardRecentPost.querySelectorAll(".aside-list-item a"); let list = ""; for (let i = 0; i < thumbnail.length; i++) { const item = thumbnail[i]; list += `
${ i + 1 }:${item.title}
`; } return `很抱歉,无法找到类似的文章,你也可以看看本站最新发布的文章:
${list}
`; } let list = ""; for (let i = 0; i < thumbnail.length; i++) { const item = thumbnail[i]; list += `
推荐${ i + 1 }:${item.title}
`; } return `推荐文章:
${list}
`; } function aiGoHome() { startAI("正在前往博客主页...", false); timeouts[2] = setTimeout(() => { if (window.pjax) { pjax.loadUrl("/"); } else { location.href = location.origin; } }, 1000); } function subscribe() { startAI("正在前往订阅页面...", false); timeouts[2] = setTimeout(() => { if (window.pjax) { pjax.loadUrl("/subscribe/"); } else { location.href = location.origin; } }, 1000); } function introduce() { if (mode == "online") { startAI( "我是文章辅助AI: SummaryGPT,点击下方的按钮,让我生成本文简介、推荐相关文章等。" ); } else { startAI( `我是文章辅助AI: ${gptName}GPT,点击下方的按钮,让我生成本文简介、推荐相关文章等。` ); } } function aiTitleRefreshIconClick() { aiTitleRefreshIcon.click(); } function onAiTagClick() { if (mode === "online") { post_ai .querySelectorAll(".ai-btn-item") .forEach((item) => (item.style.display = "none")); document.getElementById("go-tianli-blog").style.display = "block"; startAI( "你好 🎉!我是 dong4j 博客的 AI 文章摘要生成助理 SummaryGPT,一款基于本地部署的大型语言模型提供的生成式 AI 服务。我的主要职责是预生成和展示文章摘要。请注意,你无法直接与我交流。如果你也想拥有一个这样的文章摘要助手,请查阅下方的详细部署指南。" ); } else { post_ai .querySelectorAll(".ai-btn-item") .forEach((item) => (item.style.display = "block")); document.getElementById("go-tianli-blog").style.display = "none"; startAI( `你好 🎉,我是本站文章摘要生成助理 ${gptName}GPT,使用了预先生成的文章摘要。我在这里只负责摘要的显示,你无法与我直接沟通。` ); } } function onAiTitleRefreshIconClick() { const truncateDescription = (title + pageFillDescription) .trim() .substring(0, basicWordCount); aiTitleRefreshIcon.style.opacity = "0.2"; aiTitleRefreshIcon.style.transitionDuration = "0.3s"; aiTitleRefreshIcon.style.transform = "rotate(" + 360 * refreshNum + "deg)"; if (truncateDescription.length <= basicWordCount) { let param = truncateDescription.length - Math.floor(Math.random() * randomNum); while ( param === prevParam || truncateDescription.length - param === prevParam ) { param = truncateDescription.length - Math.floor(Math.random() * randomNum); } prevParam = param; aiAbstract(param); } else { let value = Math.floor(Math.random() * randomNum) + basicWordCount; while ( value === prevParam || truncateDescription.length - value === prevParam ) { value = Math.floor(Math.random() * randomNum) + basicWordCount; } aiAbstract(value); } refreshNum++; } function changeShowMode() { mode = mode === "online" ? "local" : "online"; if (mode === "online") { document.getElementById("ai-tag").innerHTML = "SummaryGPT"; aiReadAloudIcon.style.opacity = "1"; aiReadAloudIcon.style.cursor = "pointer"; } else { aiReadAloudIcon.style.opacity = "0"; aiReadAloudIcon.style.cursor = "auto"; if ((document.getElementById("go-tianli-blog").style.display = "block")) { document .querySelectorAll(".ai-btn-item") .forEach((item) => (item.style.display = "block")); document.getElementById("go-tianli-blog").style.display = "none"; } document.getElementById("ai-tag").innerHTML = gptName + " GPT"; } aiAbstract(); } function showAiBtn() { if (mode === "online") { document.getElementById("ai-tag").innerHTML = "SummaryGPT"; } else { document.getElementById("ai-tag").innerHTML = gptName + " GPT"; } } })(); ``` 当然还需要修改对应的 pug 模版文件(`themes/anzhiyu/layout/includes/anzhiyu/ai-info.pug`): ```javascript - let pageFillDescription = get_page_fill_description() - let gptName = theme.post_head_ai_description.gptName - let mode = theme.post_head_ai_description.mode - let switchBtn = theme.post_head_ai_description.switchBtn if (pageFillDescription && page.ai) .post-ai-description .ai-title i.fa-regular.fa-robot.fa-fade .ai-title-text 摘要助手 if (switchBtn) #ai-Toggle 切换 i.anzhiyufont.anzhiyu-icon-arrow-rotate-right i.anzhiyufont.anzhiyu-icon-circle-dot(title="朗读摘要") #ai-tag if mode == "online" = "SummaryGPT" else = gptName + "GPT" .ai-explanation AI 初始化中... .ai-btn-box .ai-btn-item 介绍自己 🙈 .ai-btn-item 生成摘要 👋 .ai-btn-item 推荐文章 📖 .ai-btn-item 前往主页 🏠 .ai-btn-item 前往订阅 💥 .ai-btn-item#go-comment 前往评论 💬 .ai-btn-item#read-audio Kokoro TTS 🎙️ .ai-btn-item#go-tianli-blog 👀 部署教程 script(data-pjax src=url_for(theme.asset.ai_abstract_js)) ``` 最后就是配置: ```yaml post_head_ai_description: enable: true gptName: Local mode: online # 默认模式 可选值: online/local switchBtn: true # 可以配置是否显示切换按钮 以切换 online/local btnLink: https://github.com/dong4j/blog-summary-assistant-server randomNum: 3 # 按钮最大的随机次数,也就是一篇文章最大随机出来几种 basicWordCount: 1999 # 最低获取字符数, 最小1000, 最大1999 apiUrl: # 文章摘要生成服务 api audioUrl: # 语音生成服务 api key: # 根据自己需要看是否要验证 key Referer: # 根据自己需要看是否要验证 Referer, 我这里没有验证 ``` > Hexo 修改没有多大难度, 主要是直接利用 anzhiyu 主题集成的 TianliGPT 来完成服务调用与数据展示, 所以这部分就见仁见智了, 个人可随意修改. --- ### 2. 摘要服务部署与集成 LLM API 直接使用 [[npx-card-dev|使用 Node.js 开发数字名片并集成 Chat 服务]] 这篇文章中已部署好的 one-api, 好处是不需要将厂商 Key 放在前端代码中, 可以避免一点的安全问题, 另外在 one-api 我可以随时添加 LLM 厂商服务, 且可控制指定 Key 的 Token 总量与有效期, 所以并不是太担心自己的 Token 被恶意使用, 即使第三方的 Token 被恶意消耗完, 我还可以使用本地部署 LLM 服务, 得益于 one-api 的代理功能, 简单的修改 one-api 配置即可使用, 不需要重新部署博客. #### 2.1 功能 - 提供一个 `/api/summary` 接口给 Hexo 博客调用, 并将传入的博客内容通过 one-api api 传给 LLM, 并处理返回的结果; - 生成的文章摘要会被缓存到 Redis 中, 避免重复生成; - 通过 pm2 启动服务; - 提供自动部署脚本一键部署; #### 2.2 使用方法 ```bash git clone git@github.com:dong4j/blog-summary-assistant-server.git cd summary-server cp .env.template .env ``` 修改 .env 配置: ```bash SERVER_PORT=3000 # 缓存配置 REDIS_HOST=192.168.1.2 REDIS_PORT=6379 REDIS_PASSWORD=password REDIS_DB=0 # 摘要过期时间 KEY_EXPIRATION=31536000 # LLM 服务 OPENAI_API_KEY=sk-***** # 配置为厂商的 OpenAI API 地址 OPENAI_API_BASE=https://api.openai.com/v1 # 模型名称 OPENAI_MODEL=xxx ``` #### 2.3 启动服务 ```bash npm install npm run server ``` 测试: ```bash curl --request POST \ --url http://192.168.1.3:3000/api/summary \ --header 'Accept: */*' \ --header 'Accept-Encoding: gzip, deflate, br' \ --header 'Connection: keep-alive' \ --header 'Content-Type: application/json' \ --header 'User-Agent: PostmanRuntime-ApipostRuntime/1.1.0' \ --data '{ "key": "xxxx", "content": "HomeLab 先导篇:入门指南-开启你的个人云端实验室之旅前提说明, 什么是 HomeLab, 为什么选择自建 HomeLab, HomeLab 的原则, 硬件成本, 软件成本, HomeLab 的硬件, 网络架构, 自托管服务, 数据存储与备份, 总结中年男人的三大爱好充电头软路由这三大爱好不仅为我们的生活带来了便利也成为了我们生活的一部分作为一个软件开发者我一直梦想着拥有自己的服务器而和软路由则是我通往这个梦想的桥梁自从购买了我的第一台以来便打开了一扇新世界的大门即网络附加存储它不仅提供了一个安全的数据存储解决方案还让我能够实现数据的备份和共享随着时间的推移我陆续购买了其他硬件产品如软路由器服务器等逐步搭建起了属于我的今天我想和大家分享一下我搭建的过程希望能够帮助到那些同样有志于搭建的朋友在接下来的博客文章中我将详细介绍如何选购合适的设备软路由器以及服务器并分享我在搭建过程中遇到的挑战和解决方案并非遥不可及只要我们用心去探索和实践就能开启属于自己的个人云端实验室之旅让我们一起学习交流和成长共同打造一个属于我们的数字王国前提说明虽然关于的文章已经很多了但我还是想记录下自己搭建的经历和遇到的问题以及如何解决这些问题主要会涉及到以下几个方面先导篇我的概要硬件篇介绍我所拥有的硬件设备网络篇包括网络环境异地组网与网络安全服务篇使用搭建的各类服务数据篇包括数据存储方案备份方案和数据恢复方案数据同步构建高效的数据同步网络数据备份打造坚实的数据安全防线网络续集升级网络再战年内网穿透详解揭秘网络连接背后的奥秘什么是顾名思义就是家庭实验室它可以理解为家庭版的云服务器用来搭建各种服务比如个人网盘媒体服务器等等的硬件设备通常包括服务器可以是物理服务器或虚拟机用于搭建各类服务存储设备如和硬盘用于存储数据网络设备如软路由和硬路由用于管理网络其他设备如摄像头传感器等用于收集数据为什么选择自建对于我来说搭建是一种浪漫的折腾我的目标是搭建各种感兴趣的服务的实验室作为一个喜欢尝试新技术的人来说搭建各类服务非常有趣我可以快速尝试和验证新的技术和方案拥有一套自己的实验室可以让我更加自由地探索保证数据安全我对数据安全非常重视所以我会把所有的数据都存储在自己的服务器上而不是使用云存储服务这样可以保证我的数据不会被第三方控制我已经受够了七牛云的域名变更导致我大量图片无法访问更好的隐私保护家人的照片儿子的成", "url": "abbrlink.html" }' ``` 响应结果: ```json { "summary": "🤖 这篇文章介绍了: HomeLab的概念、自建HomeLab的原因、原则、硬件和软件成本、硬件设备、网络架构、自托管服务、数据存储与备份等。作者分享了自己搭建HomeLab的过程,包括选购设备、搭建过程中遇到的挑战和解决方案,旨在帮助有志于搭建HomeLab的朋友。文章还涉及了网络环境、异地组网、网络安全、数据存储方案、备份方案和数据恢复方案等内容,强调了数据安全和隐私保护的重要性。", "id": "abbrlink", "fromCache": false } ``` 第一次会调用 one-api api 生成摘要,第二次会从缓存中获取摘要。 #### 2.4 部署 修改 `ecosystem.config.js` 相关配置: ```javascript module.exports = { apps: [ { name: "summary-server", // 应用名称 namespace: "blog", // 指定命名空间(可选) version: "1.0.0", // 应用版本(可选) cwd: "/path/to/deploy", // 部署到服务器的工作目录 script: "./summaryServer.js", // 主脚本路径,相对于 cwd watch: true, // 是否启用文件监控 ignore_watch: ["node_modules", "logs"], // 忽略监控的文件或目录 exec_mode: "fork", instances: 1, // 应用实例数量 autorestart: true, // 是否自动重启 env: { VERSION: "1.0.0", // 设置版本号环境变量 }, log_date_format: "YYYY-MM-DD HH:mm:ss", // 日志时间格式 error_file: "./logs/error.log", // 错误日志文件 out_file: "./logs/out.log", // 输出日志文件 merge_logs: true, // 是否合并日志 }, ], }; ``` 修改 `deploy.sh` 脚本中的 `DEFAULT_SSH_ALIAS` 和 `DEFAULT_REMOTE_DIR`: - DEFAULT_SSH_ALIAS: .ssh 中的 config 配置别名, 这里做了免密登录处理; - DEFAULT_REMOTE_DIR: 部署到服务器的工作目录; 最后执行部署脚本: ```bash ./deploy.sh ``` 第一次部署需要在服务器的部署目录安装依赖: ```bash npm install ``` #### 2.5 外网配置 家里的宽带有公网 IP, 所以我是直接部署到 HomeLab 的服务器上, 然后绑定绑定自定义域名即可, 假设绑定的域名为: `https://summary.dong4j.tele:3000`, 那么 Hexo 的 AI 摘要配置就要修改为: ```yaml post_head_ai_description: ... apiUrl: https://summary.dong4j.tele:3000/api/summary ... ``` 效果如下: ![20250208212138_yyglInIu.webp](https://cdn.dong4j.site/source/image/20250208212138_yyglInIu.webp) ### 3. Kokoro TTS 部署 #### 3.1 部署 我使用的是 [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI), 使用 CPU 模式部署到 Mac mini M2 上: ```bash git clone https://github.com/remsky/Kokoro-FastAPI.git cd Kokoro-FastAPI cd docker/cpu docker compose up -d --build ``` 不过最新的 **0.2.0** 版本使用 docker 部署还有一点 [问题](https://github.com/remsky/Kokoro-FastAPI/issues/127), 解决方法如下: 修改文件 `docker/cpu/Dockerfile`: ```dockerfile # Install dependencies and check espeak location RUN mkdir -p /usr/share/espeak-ng-data \ && apt-get update && apt-get install -y \ espeak-ng \ espeak-ng-data \ git \ libsndfile1 \ curl \ ffmpeg \ g++ \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && ln -s /usr/lib/*/espeak-ng-data/* /usr/share/espeak-ng-data/ ``` **应该先执行 `mkdir -p /usr/share/espeak-ng-data`**. > 如果你需要 web-ui 服务, 需要修改 docker-compose.yml, 删除 web-ui 服务的注释. 部署没问题的话, 应该能看到相关的容器: ![20250208223048_VDtpJCBF.webp](https://cdn.dong4j.site/source/image/20250208223048_VDtpJCBF.webp) #### 3.2 切换语言 Kokoro-FastAPI 默认使用英语生成语音, 所以我们要修改一下代码来支持中文: 修改 `api/src/services/tts_service.py` 第 297 行: ```python # 将 lang_code 修改为 'z' quiet_pipeline = KPipeline(lang_code='z', model=False) ``` 然后重新使用 docker-compose 部署即可. **Gradio WebUI**: ![20250208214224_0h3tBPgJ.webp](https://cdn.dong4j.site/source/image/20250208214224_0h3tBPgJ.webp) **自带 WebUI**: ![20250208220534_NqhGwId3.webp](https://cdn.dong4j.site/source/image/20250208220534_NqhGwId3.webp) #### 3.3 停顿问题 如果使用中文标点服务会出现停顿时间较短的问题, 解决方法就是将中文标点服务修改为英文标点服务. [相关讨论](https://huggingface.co/hexgrad/Kokoro-82M/discussions/89) > 这个已在 `audio-server` 处理过了. #### 3.4 中英文混合导致英语发音不正常 这个问题还没有解决. --- ### 4. 语音服务 API 部署与集成 #### 4.1 功能 - 提供一个 `/audio` 接口给 Hexo 博客调用, 通过 id 获取 Redis 中的摘要文本, 然后调用 Kokoro TTS FastAPI 生成语音; - 生成的 mp3 会保存到 `audios 目录`, 避免重复生成; - 通过 pm2 启动服务; - 提供自动部署脚本一键部署; #### 4.2 使用 ```bash git clone git@github.com:dong4j/blog-summary-assistant-server.git cd audio-server cp config.ini.template config.ini # 初始化 python 环境并安装依赖 python3 -m venv venv source venv/bin/activate pip install -r requirements.txt ``` 修改 `config.ini` 中的配置信息: ```ini [redis] host=192.168.1.2 port=6379 password=password db=0 [kokoro] base_url=http://192.168.31.5:8880/v1 ``` 测试: ```python python kokorotts_with_request.py ``` 成功后会在 `audios` 目录下生成 mp3 文件, 如果音色不满意可以修改相关代码. #### 4.3 部署 修改 `ecosystem.config.js` 相关配置: ```javascript module.exports = { apps: [ { name: "audio-server", // 应用名称 namespace: "blog", // 指定命名空间 version: "1.0.0", // 应用版本 cwd: "/path/to/deploy", // 部署到服务器的工作目录 script: "./audioServer.py", // 主脚本路径,相对于 cwd interpreter: "/mnt/4.860.ssd/audio-server/venv/bin/python3", watch: true, // 是否启用文件监控 ignore_watch: ["__pycache__", "venv", "audios", "logs"], // 忽略监控的文件或目录 exec_mode: "fork", instances: 1, // 应用实例数量 autorestart: true, // 是否自动重启 env: {}, log_date_format: "YYYY-MM-DD HH:mm:ss", // 日志时间格式 error_file: "./logs/error.log", // 错误日志文件 out_file: "./logs/out.log", // 输出日志文件 merge_logs: true, // 是否合并日志 }, ], }; ``` 修改 `deploy.sh` 脚本中的 `DEFAULT_SSH_ALIAS` 和 `DEFAULT_REMOTE_DIR`: - DEFAULT_SSH_ALIAS: .ssh 中的 config 配置别名, 这里做了免密登录处理; - DEFAULT_REMOTE_DIR: 部署到服务器的工作目录; 最后执行部署脚本: ```bash ./deploy.sh ``` 第一次部署需要在服务器的部署目录安装依赖: ```python python3 -m venv venv source venv/bin/activate pip install -r requirements.txt ``` #### 4.4 外网配置 假设绑定的域名为: `https://audio.dong4j.tele:6668`, 那么 Hexo 的 AI 摘要配置就要修改为: ```yaml post_head_ai_description: ... audioUrl: https://audio.dong4j.tele:6668/audio ... ``` 效果如下: ![20250209003039_KL0AlEW7.webp](https://cdn.dong4j.site/source/image/20250209003039_KL0AlEW7.webp) {% audio https://cdn.dong4j.site/source/image/66ccb3f1.mp3 %} ## 三、注释事项 - 如果是部署到自己的服务器上, 需要在 Nginx 处理一下跨域的问题; --- ## 四、附录 - 开源代码仓库地址: [https://github.com/dong4j/blog-summary-assistant-server](https://github.com/dong4j/blog-summary-assistant-server) - 相关工具与资源链接: - [Kokoro TTS 音色对比](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/SAMPLES.md) - [Kokoro TTS 在线体验](https://huggingface.co/spaces/hexgrad/Kokoro-TTS) ## [使用 PM2 守护 Flask 应用:从安装到配置详解](https://blog.dong4j.site/posts/ba2a34a5.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 PM2 是一个功能强大的 Node.js 进程管理工具,不仅适用于 JavaScript 项目,也能很好地守护 Python 应用。本文将详细介绍如何在服务器上使用 PM2 来守护一个基于 Flask 的 Python 应用。 --- ### **1. 安装环境** #### **安装 Node.js** PM2 是基于 Node.js 的工具,因此需要先安装 Node.js 环境: ```bash sudo apt update && sudo apt install curl -y curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - sudo apt install nodejs -y ``` #### **安装 PM2** 使用 npm 全局安装 PM2: ```bash npm install pm2 -g ``` 验证安装是否成功: ```bash pm2 -v # 查看版本 pm2 list # 列出当前管理的所有进程 ``` --- ### **2. 准备 Flask 应用** #### **生成依赖文件** 在 Python 项目中,通常会使用 `requirements.txt` 来管理依赖包。如果尚未生成该文件,可以通过以下命令创建: ```bash pip freeze > requirements.txt ``` #### **配置虚拟环境(可选但推荐)** 为了保持项目的独立性,建议为 Flask 应用创建一个 Python 虚拟环境。 ```bash python3 -m venv my_flask_env # 创建虚拟环境 source my_flask_env/bin/activate # 激活虚拟环境 ``` 激活后,在虚拟环境中安装项目依赖: ```bash pip install -r requirements.txt ``` #### **编写启动脚本** Flask 应用通常需要通过 `gunicorn` 或 `uwsgi` 来运行,以提高性能和并发处理能力。 假设你的 Flask 应用文件为 `app.py`,以下是使用 `gunicorn` 启动的配置示例: ```bash # 安装 gunicorn pip install gunicorn # 使用 gunicorn 启动 Flask 应用 gunicorn -b "0.0.0.0:8000" --workers 4 app:app ``` 你也可以为 `gunicorn` 配置一个参数文件(如 `gunicorn_config.py`),以进一步优化性能: ```python # gunicorn_config.py bind = '0.0.0.0:8000' # 绑定地址和端口 workers = 4 # 工作进程数,根据 CPU 核心调整 worker_class = 'gevent' # 使用异步工作者模式 timeout = 60 # 请求超时时间(秒) accesslog = '-' # 输出访问日志到标准输出 errorlog = '-' # 输出错误日志到标准输出 ``` --- ### **3. 使用 PM2 守护 Flask 应用** #### **直接启动** 你可以将 `gunicorn` 命令通过 PM2 管理: ```bash pm2 start gunicorn -c gunicorn_config.py wsgi:app --name=flask_app ``` #### **使用自定义脚本** 为了更好地管理,可以编写一个启动脚本(如 `start.sh`): ```bash #!/bin/bash # 激活虚拟环境(如果需要) source /path/to/my_flask_env/bin/activate # 使用 gunicorn 启动 Flask 应用 gunicorn -c gunicorn_config.py wsgi:app ``` 然后通过 PM2 管理该脚本: ```bash pm2 start start.sh --name=flask_app ``` #### **配置文件(可选)** 你也可以为 PM2 编写一个 JSON 配置文件(如 `ecosystem.config.js`),以便管理和部署多个应用。 ```javascript module.exports = { apps: [ { name: "Flask App", script: "./start.sh", // 启动脚本路径 cwd: "/path/to/your/project", // 工作目录 env: { NODE_ENV: "production", }, watch: false, // 是否监听文件变化(默认 false) max_memory_restart: "100M", // 内存限制,超过后自动重启 }, ], }; ``` 然后运行: ```bash pm2 start ecosystem.config.js ``` ### 4. PM2 命令列表 | 命令 | 描述 | | ----------------------------------------------- | --------------------------- | | `pm2 start [--name=]` | 启动应用 | | `pm2 stop ` | 停止应用 | | `pm2 restart ` | 重启应用 | | `pm2 delete ` | 删除应用 | | `pm2 list` | 列出所有应用 | | `pm2 show ` | 显示应用详细信息 | | `pm2 info ` | 显示应用信息(等同于 show) | | `pm2 status` | 显示 PM2 状态 | | `pm2 logs` | 显示所有应用日志 | | `pm2 logs ` | 显示指定应用日志 | | `pm2 flush` | 清空所有日志文件 | | `pm2 reloadLogs` | 重新加载日志 | | `pm2 deploy` | 部署应用 | | `pm2 deploy ` | 使用配置文件部署到指定环境 | | `pm2 monit` | 监控所有应用 | | `pm2 cpu ` | 显示应用 CPU 使用情况 | | `pm2 memory ` | 显示应用内存使用情况 | | `pm2 startup` | 生成启动脚本 | | `pm2 save` | 保存当前应用列表 | | `pm2 resurrect` | 恢复之前保存的应用列表 | | `pm2 update` | 更新 PM2 到最新版本 | | `pm2 scale ` | 扩展应用实例数量 | | `pm2 gracefulReload ` | 优雅重启应用 | | `pm2 trigger ` | 触发自定义动作 | | `pm2 set ` | 设置 PM2 配置项 | | `pm2 unset ` | 取消设置 PM2 配置项 | | `pm2 help` | 显示帮助信息 | | `pm2 home` | 打开 PM2 官网 | | `pm2 plus` | 打开 PM2 Plus 服务页面 | | `pm2 module:list` | 列出所有 PM2 模块 | | `pm2 module:install ` | 安装 PM2 模块 | | `pm2 module:uninstall ` | 卸载 PM2 模块 | | 参数 | 描述 | | ---------------------- | ------------------------------------ | | `--name` | 设置应用的名称 | | `--cwd` | 指定启动应用时的工作目录 | | `--watch` | 监听文件变化,自动重启应用 | | `--ignore-watch` | 忽略监听的文件或目录 | | `--max-memory-restart` | 设置应用内存使用上限,超过后自动重启 | | `--exec-path` | 指定 Node.js 的执行路径 | | `--instances` | 设置应用实例的数量 | | `--instance` | 指定启动的实例编号 | | `--env` | 设置环境变量 | | `--port` | 指定应用的端口号 | | `--cron` | 设置定时重启任务 | | `--interpreter` | 指定解释器路径 | | `--interpreter-args` | 传递给解释器的参数 | | `--output` | 指定输出日志文件路径 | | `--error` | 指定错误日志文件路径 | | `--pid` | 指定 PID 文件路径 | | `--log-date-format` | 设置日志日期格式 | | `--merge-logs` | 合并日志文件 | | `--no-daemon` | 不以守护进程方式运行 | | `--no-vizion` | 禁用 vizion 版本控制 | | `--no-autorestart` | 禁用自动重启 | | `--restart-delay` | 设置重启延迟时间 | | `--force` | 强制重启或停止应用 | | `--update-env` | 更新环境变量 | | `--only` | 只启动指定的应用 | | `--kill-timeout` | 设置强制杀死进程的超时时间 | | `--wait-ready` | 等待应用准备好后再继续 | | `--ready-delay` | 设置等待准备好的延迟时间 | | `--attach` | attaching to application output | | `--no-attach` | do not attach to application output | ## 常见问题 目前使用 PM2 来管理了多种类型的服务, 比如 python, js, golan 等, 期间也遇到了一些问题, 这里做一下总结. ### pm2 找不到 我一般都是在客户端上使用 shell 脚本通过 pm2 来管理服务, 所以会有下面这样的启动命令: ```bash ssh "$SSH_ALIAS" "pm2 list" ``` 上面的脚本执行会报下面的错误: ```bash zsh:1: command not found: pm2 ``` 当通过 SSH 远程执行命令时,系统会启动一个非交互式的 shell。在这种模式下,某些启动文件(如 .bashrc 或 .zshrc)可能不会被自动加载,导致环境变量(如 PATH)未被正确设置。因此,系统无法找到 pm2 命令。 所以正确的方式应该是: ```bash ssh "$SSH_ALIAS" "source ~/.nvm/nvm.sh && pm2 list" ``` ### watch 问题 PM2 提供一个 watch 参数, 但文件发生变化时会重启服务, 但是在服务运行过程中可能会生成一些临时文件或改动文件, 这会导致服务重启, 这里我们可以设置 `ignore_watch` 参数来避免这种问题, 比如: ```javascript module.exports = { apps: [ { name: "summary-server", // 应用名称 namespace: "blog", // 指定命名空间 version: "1.0.0", // 应用版本 cwd: "/mnt/4.860.ssd/summary-server", // 当前工作目录 script: "./summaryServer.js", // 主脚本路径,相对于 cwd watch: true, // 是否启用文件监控 ignore_watch: ["node_modules", "logs"], // 忽略监控的文件或目录 exec_mode: "fork", instances: 1, // 应用实例数量 autorestart: true, // 是否自动重启 env: { VERSION: "1.0.0", // 设置版本号环境变量 }, log_date_format: "YYYY-MM-DD HH:mm:ss", // 日志时间格式 error_file: "./logs/error.log", // 错误日志文件 out_file: "./logs/out.log", // 输出日志文件 merge_logs: true, // 是否合并日志 }, ], }; ``` 这里排除了 `node_modules` 和 `logs` 目录的监控. ### 用户权限问题 我服务器上使用了普通用户安装的 Node.js 和 pm2, 但是某些服务需要使用 root 用户运行时, 需要修改启动命令: ```bash ssh "$REMOTE_SSH_ALIAS" "source /home/{username}/.nvm/nvm.sh && pm2 start $REMOTE_PATH/ecosystem.config.js" ``` ![20250211193332_ydJdiJxp.webp](https://cdn.dong4j.site/source/image/20250211193332_ydJdiJxp.webp) 比如上面的服务使用 root 用户运行. 这样也会带来问题, 即在服务器上 root 用户使用 pm2 管理服务需要使用下面的命令: ```bash /home/{username}/.nvm/versions/node/{version}/bin/pm2 list ``` 而普通用户也无法通过 `sudo pm2 xxx` 管理服务, 所以最简单的方式就是使用 root 用户再安装一个 pm2, 简单粗暴往往是最好的方式 🤣. ## [使用 Node.js 开发数字名片并集成 Chat 服务](https://blog.dong4j.site/posts/bb4018e9.md) ![/images/cover/20250115192416_nhXEcB8o.webp](https://cdn.dong4j.site/source/image/20250115192416_nhXEcB8o.webp) ## 简介 最近在使用 Node.js 写一个数字名片的小项目, 直接使用 `npx dong4j` 就可以使用, 大概是这样的: ![20250115122542_gj3ZW9Jq.webp](https://cdn.dong4j.site/source/image/20250115122542_gj3ZW9Jq.webp) 其中有一个 `Chat with AI Assistant 🤖` 的小功能: ![20250115122637_6LJLDOkA.webp](https://cdn.dong4j.site/source/image/20250115122637_6LJLDOkA.webp) 这个功能使用 one-api 对接各家 LLM 接口来提供 Chat 服务: ![npx-card-a-chat.drawio.svg](https://cdn.dong4j.site/source/image/npx-card-a-chat.drawio.svg) 我的需求很简单: - 用户能够免费使用; - 我也不需要付费购买 token; 在 Github 找了一圈后, [LLM Red Team](https://github.com/LLM-Red-Team) 最符合需求, 所以这里记录一下这个 Chat 服务的搭建过程. ## one-api [one-api](https://github.com/songquanpeng/one-api) 是一个 OpenAI API 接口管理工具, 相当于一个代理, 提供了多个渠道以及 key 管理功能, 它的衍生项目也非常多, 感兴趣的可以了解一下. 不过最近爆出来 one-api 镜像被 [投毒](https://github.com/songquanpeng/one-api/issues/2000)了, 还好我使用的是老版本: > 2024 年 12 月 27 日,One API 项目的 Docker Hub 镜像被发现存在安全问题。攻击者获取了项目维护者的 Docker Hub 凭证,并重新推送了包含挖矿程序的恶意镜像版本(v0.6.5 至 v0.6.9)。这些被污染的镜像会导致服务器 CPU 使用率异常升高,影响系统正常运行。 ![20250115122020_OIPp6GEm.webp](https://cdn.dong4j.site/source/image/20250115122020_OIPp6GEm.webp) 我上面接入了 [LLM Red Team](https://github.com/LLM-Red-Team) 提供的 free-api, 因为各大厂商注册就送 token 的活动, 重新使用小号注册了几个, 一起配置到 one-api, 应该会比 free-api 稳定一些. ## 接入 LLM - 详细文档: [https://llm-red-team.github.io/free-api/](https://llm-red-team.github.io/free-api/) 我这里只记录一下获取 token 的关键步骤. ### Kimi 从 [kimi.moonshot.cn](https://kimi.moonshot.cn/) 获取 refresh_token 进入 kimi 随便发起一个对话,然后 F12 打开开发者工具,从 Application > Local Storage 中找到`refresh_token`的值,这将作为 Authorization 的 Bearer Token 值:`Authorization: Bearer TOKEN` ![20250115192421_NQUVlhaJ.webp](https://cdn.dong4j.site/source/image/20250115192421_NQUVlhaJ.webp) 如果你看到的`refresh_token`是一个数组,请使用`.`拼接起来再使用。 ### 跃问 从 [stepchat.cn](https://stepchat.cn/) 获取 Oasis-Token 进入 StepChat 随便发起一个对话,然后 F12 打开开发者工具,从 Application > Cookies 中找到`Oasis-Token`的值,这将作为 Authorization 的 Bearer Token 值:`Authorization: Bearer TOKEN` ![20250115192421_IEUX061W.webp](https://cdn.dong4j.site/source/image/20250115192421_IEUX061W.webp) **多账号接入** 你可以通过提供多个账号的 refresh_token 并使用`,`拼接提供: ``` Authorization: Bearer TOKEN1,TOKEN2,TOKEN3 ``` 每次请求服务会从中挑选一个。 ### 通义千问 从 [通义千问](https://tongyi.aliyun.com/qianwen) 登录 进入通义千问随便发起一个对话,然后 F12 打开开发者工具,从 Application > Cookies 中找到`login_tongyi_ticket`的值,这将作为 Authorization 的 Bearer Token 值:`Authorization: Bearer TOKEN` ![20250115192421_Y8xlFKKA.webp](https://cdn.dong4j.site/source/image/20250115192421_Y8xlFKKA.webp) ### 智谱清言 从 [智谱清言](https://chatglm.cn/) 获取 refresh_token 进入智谱清言随便发起一个对话,然后 F12 打开开发者工具,从 Application > Cookies 中找到`chatglm_refresh_token`的值,这将作为 Authorization 的 Bearer Token 值:`Authorization: Bearer TOKEN` ![20250115192421_Hl97Cr17.webp](https://cdn.dong4j.site/source/image/20250115192421_Hl97Cr17.webp) ### 秘塔 AI 从 [秘塔 AI 搜索](https://metaso.cn/) 获取`uid`和`sid`并使用`-`拼接: 进入秘塔 AI 搜索,登录账号(**建议登录账号,否则可能遭遇奇怪的限制**),然后 F12 打开开发者工具,从 Application > Cookies 中找到`uid`和`sid`的值。 将 uid 和 sid 拼接:`uid-sid`,如 `65e91a6b2bac5b600dd8526a-5e7acc465b114236a8d9de26c9f41846`。 这将作为 Authorization 的 Bearer Token 值:`Authorization: Bearer uid-sid` ![20250115192421_tbxPNjKH.webp](https://cdn.dong4j.site/source/image/20250115192421_tbxPNjKH.webp) ### 讯飞星火 从 [xinghuo.xfyun.cn](https://xinghuo.xfyun.cn/) 获取 ssoSessionId 进入 Spark 登录并发起一个对话,从 Cookie 获取 `ssoSessionId` 值,由于星火平台禁用 F12 开发者工具,请安装 `Cookie-Editor` 浏览器插件查看你的 Cookie。 ![20250115192421_IsjurZ27.webp](https://cdn.dong4j.site/source/image/20250115192421_IsjurZ27.webp) 这个值将作为 Authorization 的 Bearer Token 值:`Authorization: Bearer TOKEN` **注意:如果退出登录或重新登录将可能导致 ssoSessionId 失效!** ### 海螺 AI 从 [海螺 AI](https://hailuoai.com/) 获取 token 进入海螺 AI 随便发起一个对话,然后 F12 打开开发者工具,从 Application > LocalStorage 中找到`_token`的值,这将作为 Authorization 的 Bearer Token 值:`Authorization: Bearer TOKEN` ![20250115192421_Wpt7QPzS.webp](https://cdn.dong4j.site/source/image/20250115192421_Wpt7QPzS.webp) ### DeepSeek 从 [DeepSeek](https://chat.deepseek.com/) 获取 userToken value 进入 DeepSeek 随便发起一个对话,然后 F12 打开开发者工具,从 Application > LocalStorage 中找到`userToken`中的 value 值,这将作为 Authorization 的 Bearer Token 值:`Authorization: Bearer TOKEN` ![20250115192421_dT5Lqsnd.webp](https://cdn.dong4j.site/source/image/20250115192421_dT5Lqsnd.webp) ### docker 部署: ```yaml services: kimi-free-api: container_name: kimi-free-api image: vinlic/kimi-free-api:latest restart: always ports: - "10001:8000" environment: - TZ=Asia/Shanghai step-free-api: container_name: step-free-api image: vinlic/step-free-api:latest restart: always ports: - "10002:8000" environment: - TZ=Asia/Shanghai qwen-free-api: container_name: qwen-free-api image: vinlic/qwen-free-api:latest restart: always ports: - "10003:8000" environment: - TZ=Asia/Shanghai glm-free-api: container_name: glm-free-api image: vinlic/glm-free-api:latest restart: always ports: - "10004:8000" environment: - TZ=Asia/Shanghai metaso-free-api: container_name: metaso-free-api image: vinlic/metaso-free-api:latest restart: always ports: - "10005:8000" environment: - TZ=Asia/Shanghai spark-free-api: container_name: spark-free-api image: vinlic/spark-free-api:latest restart: always ports: - "10006:8000" environment: - TZ=Asia/Shanghai hailuo-free-api: container_name: hailuo-free-api image: vinlic/hailuo-free-api:latest restart: always ports: - "10007:8000" environment: - TZ=Asia/Shanghai deepseek-free-api: container_name: deepseek-free-api image: vinlic/deepseek-free-api:latest restart: always ports: - "10008:8000" environment: - TZ=Asia/Shanghai ``` ## 接入 one-api ### free-api 接入 以智谱清言为例, 说明一下如何将上面的 free-api 接入到 one-api 中. 假设 docker 部署在 192.168.1.2 服务器上, 智谱清言的 free-api 端口是: `10004`. 在 one-api 的渠道管理页面添加新的渠道: ![20250115130424_oOpkxMA4.webp](https://cdn.dong4j.site/source/image/20250115130424_oOpkxMA4.webp) - 类型: 自定义渠道 - Base URL: docker 部署智谱清言的地址 - 名称: 随便 - 分组: 默认即可 - 模型: 我这里写的自定义的模型名称, 这个需要和 OpenAI client 中的 `model` 对应; - 密钥: 前面获取的 `chatglm_refresh_token` 添加完成后可在渠道管理页面进行测试. ### 正常渠道接入 现在各大厂商为了推广自己的 AI API, 注册都会赠送一定额度的 Token, 还是以智谱清言为例, 讲讲如何接入到 one-api. 注册并登录 [智谱清言开放平台](https://bigmodel.cn/), 注册就送大礼包, 不过只有 1 个月有效期: ![20250115131737_uC7IYg8j.webp](https://cdn.dong4j.site/source/image/20250115131737_uC7IYg8j.webp) [智谱清言开放平台-个人中心-项目管理-API keys](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) 获取 API key. 然后再 one-api 添加新的渠道: ![20250115131948_PEUucxaL.webp](https://cdn.dong4j.site/source/image/20250115131948_PEUucxaL.webp) **密钥** 为智谱清言开放平台提供的 API key. 需要注意的是 **模型** 和 **模型重定向**: - **模型**: OpenAI API 接口中的 `model` 名称会匹配 one-api 的模型名称, 只有匹配上了才能正常调用, npx-card 其实只会使用到 `hybrid-model`, 我是为了方便在 one-api 上测试, 所以添加了前面 2 个; - **模型重定向**: one-api 会根据这里的配置修改最终的 `model` 名称, 这里的意思是将 client 传入的 `hybrid-model` 修改为 `glm-4-plus`, 最后调用智谱清言的接口. 最后就是在 npx 中使用了: ```javascript // 初始化 OpenAI 客户端 this.openai = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl, }); this.model = config.model; const stream = await this.openai.chat.completions.create({ model: this.model, messages: [...this.context, { role: "user", content: message }], stream: true, }); for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content || ""; if (content) { process.stdout.write(content); } } ``` --- ## 安全防控 因为我将 one-api 暴露到公网为后面的 LLM 提供代理服务, 为保证服务器的安全, 需要做一定的安全设置. 我的服务通过 雷池 Safeline 进行代理, 而 Safeline 不允许直接修改 Nginx 配置(会定时覆盖), 所以需要为配置文件添加只读权限: ```bash chattr +i 配置名 ``` 下面是具体的安全设置. ### 禁用注册 内网登录 one-api 控制台, 禁用注册功能: ![20250115121052_DbPKMk9f.webp](https://cdn.dong4j.site/source/image/20250115121052_DbPKMk9f.webp) 修改密码强度, 这个就不贴图了. ### 禁用主路径 因为 OpenAI API 只会访问 `/v1` 的接口, 所以禁用除 `/v1/**` 之外的其他所有路径: ```bash server { listen 443 ssl; server_name oneapi.dong4j.ink; # SSL 证书配置 ssl_certificate /path/to/certificate.crt; ssl_certificate_key /path/to/private.key; # 仅允许访问 /v1 和 /v1/** 路径 location ^~ /v1 { proxy_pass http://backend_39; # 转发到后端 proxy_set_header Host $http_host; include proxy_params; } # 禁止访问其他路径 location / { return 403; # 返回 403 Forbidden } } ``` ### 只允许 POST OpenAI API 只会使用 POST, 所以禁用其他请求方式: ```bash location ^~ /v1 { limit_except POST { deny all; # 非 POST 请求返回 405 Method Not Allowed } } ``` ## 性能调优 ``` # 允许访问 /v1 和 /v1/** 路径 location ^~ /v1 { limit_except POST { deny all; # 非 POST 请求返回 405 Method Not Allowed } proxy_pass http://backend_39; # 代理优化配置 proxy_buffering off; chunked_transfer_encoding on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 120; ... } # 禁止访问其他路径,包括 / location / { return 403; # 禁止访问 } ``` **解释如下:** ``` # 关闭代理缓冲。当设置为off时,Nginx会立即将客户端请求发送到后端服务器,并立即将从后端服务器接收到的响应发送回客户端。 proxy_buffering off; # 启用分块传输编码。分块传输编码允许服务器为动态生成的内容分块发送数据,而不需要预先知道内容的大小。 chunked_transfer_encoding on; # 开启TCP_NOPUSH,这告诉Nginx在数据包发送到客户端之前,尽可能地发送数据。这通常在sendfile使用时配合使用,可以提高网络效率。 tcp_nopush on; # 开启TCP_NODELAY,这告诉Nginx不延迟发送数据,立即发送小数据包。在某些情况下,这可以减少网络的延迟。 tcp_nodelay on; # 设置保持连接的超时时间,这里设置为120秒。如果在这段时间内,客户端和服务器之间没有进一步的通信,连接将被关闭。 keepalive_timeout 120; ``` ## [从零开始开发 VSCode 插件:从 Hello World 到图片处理工具](https://blog.dong4j.site/posts/621bb677.md) ![/images/cover/20250114004440_j6VHuwA4.webp](https://cdn.dong4j.site/source/image/20250114004440_j6VHuwA4.webp) ## 引言 VSCode 插件开发可能对初学者来说有些陌生。本文将从最简单的 Hello World 插件开始,一步步带你实现一个实用的图片处理工具。我们会先开发一个最基础的插件,然后逐步添加功能,最终实现一个可以帮助博主快速处理图片的工具。 ## 第一部分:Hello World 插件 ### 1. 环境准备 首先,确保你的电脑上已安装: - Node.js(建议 14.x 或更高版本) - Visual Studio Code - Git(可选) ### 2. 创建第一个插件 1. 安装 VSCode 插件生成器: ```bash npm install -g yo generator-code ``` 2. 创建插件项目: ```bash yo code ``` 3. 按照提示填写基本信息: ``` ? What type of extension do you want to create? New Extension (TypeScript) ? What's the name of your extension? hello-world ? What's the identifier of your extension? hello-world ? What's the description of your extension? My first extension ? Initialize a git repository? Yes ? Which package manager to use? npm ``` ### 3. 理解基础项目结构 生成的项目包含以下主要文件: ``` hello-world/ ├── .vscode/ # VSCode 配置 ├── src/ │ └── extension.ts # 插件主要代码 ├── package.json # 插件配置文件 └── README.md # 说明文档 ``` ### 4. 实现 Hello World 1. 修改 `package.json` 添加命令: ```json { "contributes": { "commands": [ { "command": "hello-world.helloWorld", "title": "Hello World" } ] } } ``` 2. 在 `src/extension.ts` 中实现命令: ```typescript import * as vscode from "vscode"; export function activate(context: vscode.ExtensionContext) { let disposable = vscode.commands.registerCommand( "hello-world.helloWorld", () => { vscode.window.showInformationMessage("Hello World!"); } ); context.subscriptions.push(disposable); } ``` 3. 按 F5 运行插件,在命令面板(Cmd/Ctrl + Shift + P)输入 "Hello World" 测试。 ## 第二部分:图片处理插件 在掌握了基础知识后,让我们来开发一个实用的图片处理插件。 ### 1. 需求分析 - 在文件资源管理器中右键图片文件 - 执行自定义的图片处理脚本 - 支持配置不同的处理方式 ### 2. 基础版本实现 1. 修改 `package.json` 添加右键菜单: ```json { "contributes": { "commands": [ { "command": "script-runner.run", "title": "处理图片" } ], "menus": { "explorer/context": [ { "command": "script-runner.run", "when": "resourceExtname =~ /\\.(png|jpg|jpeg)$/" } ] } } } ``` 2. 实现基础功能: ```typescript import * as vscode from "vscode"; import * as cp from "child_process"; export function activate(context: vscode.ExtensionContext) { let disposable = vscode.commands.registerCommand( "script-runner.run", (uri: vscode.Uri) => { // 获取文件路径 const filePath = uri.fsPath; // 执行简单的压缩命令 const command = `convert '${filePath}' -quality 85 '${filePath}'`; cp.exec(command, (error) => { if (error) { vscode.window.showErrorMessage("图片处理失败"); return; } vscode.window.showInformationMessage("图片处理成功"); }); } ); context.subscriptions.push(disposable); } ``` ### 3. 添加配置支持 1. 在 `package.json` 中添加配置项: ```json { "contributes": { "configuration": { "title": "Script Runner", "properties": { "scriptRunner.imageQuality": { "type": "number", "default": 85, "description": "图片压缩质量" } } } } } ``` 2. 读取配置: ```typescript const config = vscode.workspace.getConfiguration("scriptRunner"); const quality = config.get("imageQuality") || 85; ``` ### 4. 进阶功能 逐步添加更多功能: 1. 支持多种图片处理命令 2. 添加进度提示 3. 支持取消操作 4. 错误处理 [完整代码实现见上文] ## 开发技巧和注意事项 1. **调试技巧** - 使用 `console.log` 输出调试信息 - F5 启动调试环境 - 使用 VSCode 的调试控制台查看输出 2. **常见问题** - 命令不显示:检查 `package.json` 的 `contributes` 配置 - 脚本执行失败:检查命令路径和权限 - 配置不生效:检查配置项名称是否正确 3. **最佳实践** - 使用 TypeScript 类型检查 - 及时释放资源 - 提供清晰的错误提示 ## 总结 通过这个教程,我们从最简单的 Hello World 开始,逐步实现了一个实用的图片处理插件。这个过程展示了 VSCode 插件开发的基本流程和关键概念。建议初学者: 1. 先从简单的 Hello World 开始 2. 理解插件的基本结构 3. 掌握命令和菜单的配置 4. 逐步添加更复杂的功能 ## 参考资源 - [VSCode 插件开发文档](https://code.visualstudio.com/api) - [VSCode 插件示例](https://github.com/microsoft/vscode-extension-samples) - [Publishing Extensions](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) ## 实战案例:开发一个通用脚本执行助手 ### 开发背景 在日常博客写作过程中,我遇到了以下问题: 1. 使用 CleanShot X 截图后,图片体积较大,需要压缩处理 2. 为了优化博客加载速度,需要将图片转换为 WebP 格式 3. 虽然有现成的脚本可以处理这些任务,但每次都需要: - 打开终端 - 切换到正确的目录 - 执行脚本命令 4. 这个流程很繁琐,影响写作效率 ### 解决方案 开发一个 VSCode 插件,具有以下特点: 1. 通过右键菜单直接处理文件 2. 支持自定义脚本配置 3. 不限于图片处理,可以处理任何文件类型 ### 具体实现 1. 配置文件设计: ```json { "scriptRunner.scripts": { "compressImage": { "name": "压缩图片", "command": "convert '${file}' -quality 85 -resize '1920x1080>' '${file}'", "fileTypes": [".png", ".jpg", ".jpeg"], "showInContextMenu": true }, "convertToWebp": { "name": "转换为WebP", "command": "cwebp -q 85 '${file}' -o '${file}.webp'", "fileTypes": [".png", ".jpg", ".jpeg"] }, "formatMarkdown": { "name": "格式化Markdown", "command": "prettier --write '${file}'", "fileTypes": [".md"] } } } ``` 2. 动态注册命令: ```typescript function registerScriptCommands(context: vscode.ExtensionContext) { const config = vscode.workspace.getConfiguration("scriptRunner"); const scripts = config.get("scripts") as { [key: string]: ScriptConfig }; Object.entries(scripts).forEach(([id, script]) => { const command = vscode.commands.registerCommand( `script-runner.${id}`, async (uri: vscode.Uri) => { await executeScript(uri, script); } ); context.subscriptions.push(command); }); } ``` 3. 脚本执行逻辑: ```typescript async function executeScript(uri: vscode.Uri, script: ScriptConfig) { const filePath = uri.fsPath; const fileDir = path.dirname(filePath); // 替换命令中的变量 const command = script.command .replace(/\${file}/g, filePath) .replace(/\${fileDir}/g, fileDir); try { await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: `执行脚本: ${script.name}`, cancellable: true, }, async (progress, token) => { return new Promise((resolve, reject) => { const process = cp.exec(command, { cwd: fileDir }, (error) => { if (error) { reject(error); return; } resolve(null); }); token.onCancellationRequested(() => { process.kill(); }); }); } ); vscode.window.showInformationMessage(`${script.name} 执行成功`); } catch (error) { vscode.window.showErrorMessage(`${script.name} 执行失败: ${error.message}`); } } ``` ### 使用效果 ![20250114002138_4Zbq9P9q.webp](https://cdn.dong4j.site/source/image/20250114002138_4Zbq9P9q.webp) 1. 在文件资源管理器中右键图片文件,可以看到配置的脚本命令 2. 点击命令后,插件会: - 显示进度提示 - 执行相应的脚本 - 显示执行结果 **配置**: ![20250114002252_E9ETIJxR.webp](https://cdn.dong4j.site/source/image/20250114002252_E9ETIJxR.webp) ### 开发心得 1. **配置驱动开发** - 通过配置文件定义脚本,而不是硬编码 - 用户可以根据需求自定义脚本 - 支持多种文件类型和处理方式 2. **用户体验优化** - 添加进度提示 - 支持取消操作 - 清晰的成功/失败提示 3. **扩展性考虑** - 支持变量替换 - 可配置工作目录 - 灵活的文件类型匹配 4. **实际收益** - 大大提高了图片处理效率 - 减少了重复操作 - 提升了写作体验 通过这个插件的开发,不仅解决了实际问题,也积累了 VSCode 插件开发的经验。希望这个案例能给想要开发 VSCode 插件的同学一些参考。 ## [Chrome 插件开发实战:从零开始开发一个图片上传工具](https://blog.dong4j.site/posts/af11d9f5.md) ![/images/cover/20250113235017_acCTt6s4.webp](https://cdn.dong4j.site/source/image/20250113235017_acCTt6s4.webp) ## 简介 在这篇文章中,我将以一个实际项目为例,带你从零开始学习 Chrome 插件开发。我们将开发一个图片上传工具,它能帮助博主快速处理和上传图片。通过这个项目,你将学习到 Chrome 插件开发的基础知识和实战技巧。 ## 为什么要开发这个插件? 作为一名技术博主,我经常需要在文章中插入图片。每次处理图片都需要经过以下步骤: 1. 在网上找到合适的图片 2. 下载到本地 3. 压缩图片 4. 转换格式 5. 上传到图床 6. 复制链接 7. 插入 Markdown 标签 这个过程不仅耗时,而且每添加一张图片都要重复一遍。作为一个程序员,我觉得这种重复性的工作应该被自动化。这就是我开发这个 Chrome 插件的初衷。 ## 解决方案:Chrome 扩展 为了解决这个问题,我开发了一个 Chrome 扩展:Image Uploader。它能让你通过简单的右键点击就完成上述所有步骤。 ### 主要功能 ![20250114002704_iki2zCGF.webp](https://cdn.dong4j.site/source/image/20250114002704_iki2zCGF.webp) 1. **一键上传**:右键点击网页上的任何图片,选择"上传图片"即可 2. **自动压缩**:可配置的图片压缩功能,平衡图片质量和文件大小 3. **格式转换**:支持将图片转换为 WebP 格式,进一步优化加载性能 4. **多语言支持**:支持中文和英文界面 5. **自定义配置**:可设置自己的图床 API 地址 6. **即时反馈**:上传完成后会显示通知,并自动复制 Markdown 格式的图片链接 ### 技术实现 目前我使用的 PicList 的 API 来上传图片, 所以我的方法就是在插件中设置上传 API, 这样能够兼容大多数的图床, 比如 SM.MS、Imgur、阿里云 OSS、腾讯云 COS 等, 这样我也不需要再为每个图床写一个上传方法了, 最重要的是不需要为每个图床添加不同的设置, 只需要设置一个图床的 API 就可以了, 图床的配置有本地的上传工具负责, 我只需要传一个需要上传的文件和配置名即可. 这个扩展的核心功能主要包括: 1. **图片处理**: - 使用 Canvas API 进行图片压缩 - 支持 WebP 格式转换 - 保持图片质量的同时减小文件体积 2. **用户界面**: - 简洁的设置页面 - 现代化的 UI 设计 - 支持深色/浅色主题 3. **后台功能**: - 异步上传处理 - 自动重试机制 - 错误处理和用户提醒 ### 使用效果 使用这个扩展后,之前繁琐的图片处理流程简化为: 1. 在网页上找到合适的图片 2. 右键点击 -> 选择"上传图片" 3. 自动完成压缩、转换、上传 4. 图片链接自动复制到剪贴板 整个过程从原来的几分钟缩短到几秒钟,极大提高了写作效率。 ## Chrome 插件开发基础 ### 什么是 Chrome 插件? Chrome 插件(Chrome Extension)是一个用 Web 技术(如 HTML、CSS 和 JavaScript)构建的软件程序,可以定制浏览器功能和行为。它就像是浏览器的"小助手",能够增强我们的浏览体验。 ### 插件的核心组件 1. **manifest.json**:插件的配置文件,定义了插件的各种属性和权限 ```json { "manifest_version": 3, "name": "Image Uploader", "version": "1.0", "description": "Quick image upload tool for bloggers", "permissions": ["contextMenus", "storage", "notifications"], "action": { "default_popup": "popup.html" }, "background": { "service_worker": "background.js" }, "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" } } ``` 2. **Background Script**:插件的后台脚本,常驻运行 ```javascript // background.js chrome.runtime.onInstalled.addListener(() => { // 创建右键菜单 chrome.contextMenus.create({ id: "uploadImage", title: "Upload Image", contexts: ["image"], }); }); // 处理右键菜单点击 chrome.contextMenus.onClicked.addListener((info, tab) => { if (info.menuItemId === "uploadImage") { // 处理图片上传 } }); ``` 3. **Popup**:点击插件图标时显示的弹出窗口 ```html ``` 4. **Options Page**:插件的设置页面 ```html
``` ### 重要的 Chrome API 1. **chrome.contextMenus**:创建右键菜单 2. **chrome.storage**:数据存储 3. **chrome.notifications**:显示通知 4. **chrome.runtime**:处理插件生命周期 ## 实战:开发图片上传插件 ### 1. 项目结构 ``` image-uploader-extension/ ├── manifest.json # 配置文件 ├── background.js # 后台脚本 ├── popup.html # 弹出窗口 ├── popup.js ├── options.html # 设置页面 ├── options.js ├── i18n.js # 国际化 ├── icons/ # 图标 └── _locales/ # 语言文件 ``` ### 2. 核心功能实现 #### 2.1 图片压缩 ```javascript async function compressImage(imageUrl, quality) { return new Promise((resolve) => { const img = new Image(); img.onload = () => { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); canvas.toBlob((blob) => resolve(blob), "image/jpeg", quality / 100); }; img.src = imageUrl; }); } ``` #### 2.2 WebP 转换 ```javascript async function convertToWebP(blob) { return new Promise((resolve) => { const img = new Image(); img.onload = () => { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); canvas.toBlob((webpBlob) => resolve(webpBlob), "image/webp"); }; img.src = URL.createObjectURL(blob); }); } ``` #### 2.3 上传功能 ```javascript async function uploadImage(blob) { const formData = new FormData(); formData.append("image", blob); const response = await fetch(apiUrl, { method: "POST", body: formData, }); return await response.json(); } ``` ### 3. 用户界面设计 #### 3.1 设置页面 我们使用现代的 UI 框架(Tailwind CSS)来构建设置页面: ```html

Settings

80%
``` #### 3.2 状态通知 使用 Chrome 的通知 API 提供即时反馈: ```javascript function showNotification(title, message) { chrome.notifications.create({ type: "basic", iconUrl: "icons/icon128.png", title: title, message: message, }); } ``` ### 4. 国际化支持 #### 4.1 语言文件 ```json // _locales/en/messages.json { "upload_success": { "message": "Image uploaded successfully!" }, "upload_error": { "message": "Failed to upload image: $ERROR$", "placeholders": { "error": { "content": "$1" } } } } ``` #### 4.2 使用翻译 ```javascript function getMessage(key, substitutions) { return chrome.i18n.getMessage(key, substitutions); } ``` ## 开发技巧和最佳实践 1. **模块化设计**:将功能分散到不同文件中,便于维护 2. **错误处理**:添加适当的错误处理和用户提示 3. **性能优化**: - 使用异步操作处理图片 - 合理设置压缩参数 - 缓存常用数据 4. **安全考虑**: - 验证 API 响应 - 限制文件大小 - 使用 HTTPS ## 调试技巧 1. **使用 Chrome 开发者工具**: - 背景页面调试:chrome://extensions - 控制台日志 - 网络请求监控 2. **常见问题解决**: - 权限问题:检查 manifest.json - 跨域问题:添加适当的权限 - 资源加载:使用相对路径 ## 发布流程 1. **打包插件**: - 压缩代码 - 生成 zip 文件 2. **上传到 Chrome Web Store**: - 准备描述材料 - 设置价格(免费/付费) - 等待审核 ## 项目实例 完整的项目代码已开源在 GitHub:[image-uploader-extension](https://github.com/dong4j/image-uploader-extension) 如果你也经常写博客,欢迎试用这个扩展。同时,我也欢迎各位开发者: - 提出建议和意见 - 贡献代码 - 报告问题 - 分享使用体验 ## 未来计划 这个扩展还有很多可以改进的地方,比如: 1. ~~支持批量上传~~ 2. 添加更多图片处理选项 3. 支持更多图床服务: 兼容更多 API 4. 优化压缩算法 5. 添加上传历史记录 ## 扩展阅读 1. [Chrome 文档](https://developer.chrome.com/docs?hl=zh-cn) 2. [Chrome 扩展开发文档](https://developer.chrome.com/docs/extensions/) 3. [Manifest V3 迁移指南](https://developer.chrome.com/docs/extensions/mv3/intro/) ## 结语 开发 Chrome 插件是一个很好的学习 Web 技术的机会。通过这个项目,你不仅能解决实际问题,还能掌握很多实用的开发技能。 希望这篇教程能帮助你开始 Chrome 插件开发之旅。如果你有任何问题或建议,欢迎在 GitHub 上提出 issue 或 PR。 ## [利用 AI 对博客文章进行智能分类](https://blog.dong4j.site/posts/f5478013.md) ![/images/cover/20250103004028_E1ep6rty.webp](https://cdn.dong4j.site/source/image/20250103004028_E1ep6rty.webp) 在上一篇 [[generate-blog-tags-using-ai|AI助力博客创作:自动生成摘要与标签的实战指南]] 中使用 AI 自动生成文章摘要和 Tags, 这次我们依旧利用 AI 来帮我们为文章进行智能分类. ## 理解分类和标签 ### 分类 (Categories) 在图书馆中,每本书都会被归入一个特定的分类。我们所熟知的图书馆分类系统,例如杜威十进制分类法,便是依据主题将书籍进行系统划分的工具。杜威分类法将书籍划分为十个主要类别(如哲学、社会科学、语言学、自然科学等),并在此基础上根据主题进一步细分为子类。 将这一概念类比至博客分类,可以视作一种类似图书馆的分类体系,旨在将文章按照主题进行有序组织。例如,“编程技术”作为一个主要类别,类似于图书馆中的“技术科学类”,在此主类别下,又可细分为“前端开发”、“后端开发”、“数据库”等子分类,这与图书馆的子类系统颇为相似。 **主要特点**: - **层次感强**:一个大类下可以有多个子分类,比如 “编程” 下面可以再分成 “Java”、“Python” 等。 - **一个分类为主**:每篇文章通常会归入一个主要的分类,帮助读者明确文章的核心主题。 **作用**: - **帮助读者导航**:让人一进来就知道文章讲的是哪个大方向。 - **SEO 加分**:搜索引擎更容易搞清楚你的网站架构,利于提升排名。 **示例**: - **[阮一峰的网络日志](http://www.ruanyifeng.com/blog/)**:阮一峰的博客分类清晰,比如 “科技”、“翻译”、“编程” 等,帮助读者快速找到感兴趣的内容。 ![20250102230848_dVi8wHDq.webp](https://cdn.dong4j.site/source/image/20250102230848_dVi8wHDq.webp) - **[廖雪峰的官方网站](https://www.liaoxuefeng.com/)**:廖雪峰的站点分类以 “Python”、“Git” 等技术内容为主,每个大类下都有丰富的教程。 ![20250102230848_FMBK4Gkd.webp](https://cdn.dong4j.site/source/image/20250102230848_FMBK4Gkd.webp) --- ### 标签 (Tags) 除了按类别分类,图书馆还会给每本书打上关键词,用来描述书的内容和特点。这些关键词可以帮助读者从多个角度去搜索和查找书籍,**主题词表**(也称为 “标引”)就是起到这样的作用。比如一本书可能既和“人工智能” 有关,又和 “深度学习” 有关,那么图书馆会给它同时打上 “人工智能”、“深度学习” 这两个主题词。 **类比到博客标签**就像图书馆给书籍打的关键词,它们没有层次关系,但能从不同维度描述文章的内容。比如一篇关于 “Python 爬虫” 的文章,可能打上 “Python”、“爬虫”、“数据抓取” 等多个标签,这样读者可以通过任意一个标签找到文章。 **特点**: - **平面化,没有层次**:标签不像分类那样有父子结构,所有标签是平等的。 - **一篇文章可以有多个标签**:标签更多是帮作者从多个角度来描述文章的内容。 **作用:** - **方便用户查找**:读者通过标签,可以找到更多相似主题的文章,体验会好很多。 - **提升搜索优化**:多打一些标签,也能让搜索引擎更容易抓取到你的文章内容。 ## 分类和标签的区别 **分类和标签的关系**有点像主菜和配菜。分类是主线,明确说明这篇文章属于哪个 “菜系”,比如“编程”、“产品管理”;而标签则是附加的调味料,说明这道“菜” 有哪些特点,比如“Python”、“效率工具”。 - **分类**是结构化的、层次感强的,用来划分大的内容模块。 - **标签**是灵活的,用来描述文章的细节和具体内容,通常用来补充分类无法覆盖到的多维度信息。 **举个例子**:一篇介绍用 Python 写爬虫的文章,分类可能是 “编程技术 - Python”,而标签可以是“Python”、“爬虫”、“数据抓取” 等,这样读者既能通过分类找到这篇文章,也能通过标签找到相关的文章。 ## 如何设计自己的分类和标签 最开始也没有太多的思路,所以就去看看好的博客网站怎么做的; 这里推荐一个开源项目:[中文独立博客列表](https://github.com/timqian/chinese-independent-blogs),这里面记录了大量的中文独立博客网站; 简单总结一下: **分类要简洁清晰**:分类不宜过多,也不要太乱,一般来说,**10 个左右的大分类**比较合适,最好一眼就能看懂。每个大分类可以有几层子分类,这样也更有条理。 **标签要灵活丰富**:标签没有数量限制,可以根据每篇文章的内容灵活添加。想想读者可能会用哪些关键词来查找这篇文章,然后用这些词作为标签。 **分类和标签的结合**是最有效的组织方式:分类帮助梳理大的结构,标签则帮助覆盖到更多内容细节。 **举个例子**: - **编程技术** - **生活感悟** - **产品经理** - **博客建站** - **数据科学** 在**编程技术**下,可以细分成**前端开发**、**后端开发**、**移动开发**,然后每篇文章再打上具体的标签。比如一篇文章关于用 Vue.js 写前端项目,分类是**编程技术 - 前端开发**,标签可以是 **Vue.js**、**JavaScript**、**前端优化**。 ``` 以上是来自下面的作者的博客内容 ==================================== 文章作者: MinChess 文章来源: 九陌斋—MinChess's Blog 文章链接: https://blog.jiumoz.com/archives/bo-ke-wen-zhang-zen-me-she-ji-fen-lei-yu-biao-qian 版权声明: 内容遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。 ``` --- ## 我的分类设计 大概参考了一下别人的分类已经对自己今后需要主攻的方向后, 将现在的博文暂时分为一下几类: | 分类名称 | 内容描述 | | -------------------------- | --------------------------------------------------------------------------------------- | | 新时代码农 | 只要是涉及到开发相关的博客, 都划分到此分类下, 不再划分如 Java, 中间件, 数据库等细分分类 | | HomeLab:中年男人的快乐源泉 | HomeLab 相关的文章 | | AI:人工智能 | AI, LLM, RAG, AIGC 等相关的文章 | | 生活:生下来活下去 | 个人生活记录 | | 转载内容 | 转载的优秀博文 | | 经验分享 | 分享自己的经验总结 | | 我的项目 | 记录自己的开源项目 | | 闲聊杂谈 | 想到什么写什么 | | 软件推荐 | 推荐用着顺手的软件 | | 好物推荐 | 推荐用着顺手的工具 | | 翻译内容 | 学习一下英语, 翻译一下外网优秀的博文 | ## 使用 AI 对文章进行分类 在上一篇 [[generate-blog-tags-using-ai|AI助力博客创作:自动生成摘要与标签的实战指南]] 使用的是 Ollama, 但是因为模型的原因生成的结果不是特别满意, 所以这次使用 [LM Studio](https://lmstudio.ai/) 作为服务端为脚本提供 AI 服务, 模型选择的是 [glm-4-9b-chat-1m](https://huggingface.co/bartowski/glm-4-9b-chat-1m-GGUF), 使用的是 **F16** 的版本. 模型有 **18.97GB**, 在 64G 内存的 Macbook Pro Apple M1 Max 跑起来还算流畅, 不得不称赞一下 Apple M 系列的芯片, 只有在跑模型的时候才会烫手, 其他时候都非常安静. 在使用模型时, GPU 基本上都是 100%: ![20250102025931_CKM98ncG.webp](https://cdn.dong4j.site/source/image/20250102025931_CKM98ncG.webp) macOS 本地跑模型的话比较推荐使用 LM Studio, 模型下载, 本地服务, OpenAI API 兼容接口等等常规功能一应俱全: ![20250102190449_IrQ3oZvd.webp](https://cdn.dong4j.site/source/image/20250102190449_IrQ3oZvd.webp) ### 需求整理 1. 提供自动和手动 2 种方式, 自动则调用 AI 直接使用推荐的分类替代当前的分类, 手动的话则通过交互方式选择分类; 2. 因为 AI 生成推荐的分类需要一定时间, 交互式也需要时间去判断选择, 所以需要使用 2 个线程执行这 2 个操作, 避免等待时间过长; 3. 提供一个额外的文本记录已被处理过的文件, 避免重复处理; ### 实现 #### generate_category.py 基于上述需求, 使用 python 写一个简单的脚本: ```python from pydantic import BaseModel from openaiapi.client import generate as client_openaiapi from ollama.client import generate as client_ollama import unittest import json from utils import clean_md_whitespace class Document(BaseModel): recommend: str options: list[str] def generate(content, type="openaiapi", auto_replace=True): categories ={ "options": [ "新时代码农", "HomeLab:中年男人的快乐源泉", "AI:人工智能", "生活:生下来活下去", "转载内容", "经验分享", "我的项目", "闲聊杂谈", "软件推荐", "好物推荐", "翻译内容" ] } if not auto_replace: return json.dumps(categories) # 构造 prompt prompt = f""" 请根据我提供给你的博客内容的核心主题为其选择一个最合适的文章分类。 附加说明: 1. 生成的分类必须是我给定的以下几个选项之一: {categories['options']} 2. 如果涉及到编程与开发相关的关键字, 比如 Java, Spring, Redis, Rabbitmq, SQL, MySQL, JVM等, 全部划分到 '新时代码农' 这个分类。 3. 你必须以JSON格式响应,键为'recommend',值是字符串格式的分类名称, 键为'options',值是我给定的分类列表。 """ if type == "openaiapi": return client_openaiapi(prompt, content, response_format=Document) elif type == "ollama": prompt = prompt + f""" 请分析'CONTENT START HERE'和'CONTENT END HERE'之间的文本 CONTENT START HERE {content} CONTENT END HERE """ return client_ollama(prompt) ``` 得益于 [OpenAI API 的结构化输出](https://platform.openai.com/docs/guides/structured-outputs), 我们可以自定义最终返回的结构: ```python class Document(BaseModel): recommend: str options: list[str] ``` - `recommend`: AI 生成的推荐分类; - `options`: 我的文章分类; 上述代码会按照我给定的结构返回最终结果, 我们只需要根据规则提取指定字段即可. 这里还简单的分装了一下 `Ollama` 和 `OpenAPI API`, 一个是为了兼容其他脚本, 二是想测试一下不同的服务提供者对提示词的响应区别: #### LLM API ##### Ollama Client ```python import requests def generate(prompt, model="qwen2"): # 设置 Ollama API 的 URL url = f"http://localhost:11434/api/generate" data = { "model": model, "prompt": prompt, "format": "json", "stream": False } # 发送 POST 请求到 Ollama API try: response = requests.post(url, json=data, stream=False) response.raise_for_status() # 检查请求是否成功 data = response.json() # 提取生成的摘要 if "response" in data: return data["response"] else: print("没有从响应中获取到摘要内容!") return None except requests.exceptions.RequestException as e: print(f"请求失败: {e}") return None ``` ##### OpenAI API ```python from openai import OpenAI def generate(prompt, content, usemodel="glm-4-9b-chat-1m", response_format=None): client = OpenAI(base_url="http://localhost:1234/v1", api_key="lm-studio") completion = client.beta.chat.completions.parse( model=usemodel, messages=[ {"role": "system", "content": prompt}, {"role": "user", "content": content} ], temperature=0.7, # 根据需要调整这个参数 response_format=response_format, # 动态传入的格式 ) return completion.choices[0].message.content ``` #### replace_category.py 最后是替换分类的脚本: ```python import os import sys import time import json import threading import queue from utils import log, get_process_md_files, split_md, dump_md_yaml, clean_md_whitespace, get_md_category, save_processed_file, load_processed_files from generate_category import generate as generate_category_from_ai # 配置路径 PROCESSED_FILE = "./processed_category_files.txt" # 已处理的文件记录 # 全局队列 task_queue = queue.Queue() result_queue = queue.Queue() def fetch_category_from_ai(md_file, auto_replace111): """ 调用 ai 接口生成分类。 这是占位函数,具体逻辑请实现后替换。 """ content = clean_md_whitespace(md_file) # print(blog_content) category = generate_category_from_ai(content, auto_replace=auto_replace111) if category: return json.loads(category) return None def replace_md_category(md_file, original_categorie, new_category): # 调用函数并获取 body 和 data result = split_md(md_file) if not result: return data = result['data'] body = result['body'] # 替换 categories data['categories'] = [new_category] dump_md_yaml(md_file, data, body) # 保存更新后的 YAML 和 body log(f"修改分类: {md_file}") log(f"{original_categorie} -> {new_category}") def ai_worker(auto_replace): """ 负责调用 ai 的线程。 """ while True: md_file = task_queue.get() if md_file is None: # 检查是否结束 break data = fetch_category_from_ai(md_file, auto_replace) result_queue.put((md_file, data)) task_queue.task_done() def interactive_worker(auto_replace): """ 负责交互式处理的线程。 """ processed_files = load_processed_files(PROCESSED_FILE) while True: md_file, data = result_queue.get() if data is None: # 标识当前任务还未完成,阻塞等待 log(f"正在等待为 {md_file} 生成分类...") result_queue.put((md_file, None)) # 重新将任务放回队列以等待生成完成 time.sleep(1) # 等待片刻再检查 continue if not data: # 如果明确返回空列表,说明分类生成失败 log(f"未生成分类,跳过 {md_file}") continue original_category = get_md_category(md_file) if auto_replace: # 提取分类列表 new_category = data.get("recommend", '') replace_md_category(md_file, original_category, new_category) else: # 提取分类列表 options = data.get("options", []) # 处理分类逻辑 print(f"\n为文件 {md_file} 生成的分类如下:") for i, option in enumerate(options): print(f"{i + 1}: {option}") choice = input("请选择分类编号: ").strip() if choice.isdigit(): choice = int(choice) if choice > 0 and choice <= len(options): new_category = options[choice - 1] replace_md_category(md_file, original_category, new_category) else: log(f"未替换 {md_file} 的分类。") save_processed_file(md_file, PROCESSED_FILE) processed_files.add(md_file) result_queue.task_done() def main(): # True 使用 AI 自动替换, False 使用交互式替换 auto_replace = True dicts = get_process_md_files(sys.argv[1:]) # 确保发布目录存在 os.makedirs(dicts.get('publish_dir'), exist_ok=True) md_files_to_process = dicts.get('files') # 加载已处理文件 processed_files = load_processed_files(PROCESSED_FILE) # 获取所有 Markdown 文件 md_files_to_process = [f for f in md_files_to_process if f not in processed_files] # 检查是否有未处理的文件 if not md_files_to_process: log("没有需要处理的 Markdown 文件。") return # 启动 Ollama 调用线程 threading.Thread(target=ai_worker, args=(auto_replace,), daemon=True).start() # 将任务添加到队列 for md_file in md_files_to_process: task_queue.put(md_file) # 启动交互线程 threading.Thread(target=interactive_worker, args=(auto_replace,), daemon=True).start() # 等待任务完成 task_queue.join() result_queue.join() log("==================category 处理完成==================") if __name__ == "__main__": main() ``` **功能概述**: 1. **读取 Markdown 文件**:从指定目录读取 Markdown 文件。 2. **调用 AI 接口生成分类**:通过 AI 接口生成新的分类。 3. **替换分类**:将生成的分类替换到 Markdown 文件的 Front-matter 中。 4. **记录已处理文件**:将已处理的文件记录保存到文本文件中,以便下次运行时跳过这些文件。 **工作流程** 1. **初始化**: - 读取配置文件和已处理文件列表。 - 获取待处理的 Markdown 文件列表。 2. **启动工作线程**: - 启动 AI 工作线程,从`task_queue`中获取任务并调用 AI 接口。 - 启动交互式工作线程,从`result_queue`中获取 AI 生成的分类并处理用户输入。 3. **处理任务**: - AI 工作线程调用 AI 接口生成分类,并将结果放入`result_queue`。 - 交互式工作线程从`result_queue`中获取结果,根据用户输入替换分类,并保存已处理文件。 4. **结束**: - 所有任务完成后,打印处理完成的消息。 手动和自动需要去脚本中修改一下 ```python # True 使用 AI 自动替换, False 使用交互式替换 auto_replace = True ``` --- ## 总结 目前已经使用 AI 替换了博文的标题, Tags, 分类以及生成摘要, 后续继续研究一下将所有博文使用 AI 创建一个知识库, 在站点上提供一个 AI 对话功能, 现在调研的有 [Dify](https://dify.ai/zh), [扣子](https://www.coze.cn/), [MaxKB](https://maxkb.cn/), [FastGPT](https://tryfastgpt.ai/) 以及 [PostChat](https://postchat.zhheo.com/), 这类都支持 WebSDK 集成, 等有成果的时候再总结一下. 上述代码已经上传到 [仓库](https://github.com/dong4j/hexo-deploy-workflow). ## 参考 - [博客文章怎么设计分类与标签](https://blog.jiumoz.com/archives/bo-ke-wen-zhang-zen-me-she-ji-fen-lei-yu-biao-qian) - [如何规划 blog 的标签(tag)和分类](https://www.cnblogs.com/Leo_wl/archive/2012/11/05/2755677.html) ## [AI助力博客创作:自动生成摘要与标签的实战指南](https://blog.dong4j.site/posts/87c223f.md) ![/images/cover/20241231185358_E3E6CGOm.webp](https://cdn.dong4j.site/source/image/20241231185358_E3E6CGOm.webp) 在信息爆炸的时代,如何让您的博客内容在浩如烟海的资讯中脱颖而出,成为吸引读者眼球的关键。而标签和摘要在这一过程中扮演着至关重要的角色。今天,我们将探讨如何利用 AI 技术为博客自动生成标签和摘要,从而提升内容的可发现性和阅读体验。 ## 标签与摘要的重要性 标签是博客内容的“关键词”,它们能够简洁、直观地反映文章的主题和核心内容。好的标签不仅有助于搜索引擎优化(SEO),还能引导读者快速找到感兴趣的内容。 摘则是博客的“门面”,它以简短的文字概括文章的主要内容,激发读者的阅读兴趣。一个吸引人的摘要能够有效地提高文章的点击率。 ## AI 在标签和摘要生成中的优势 传统上,标签和摘要的生成依赖于人工撰写,这不仅耗时耗力,而且难以保证一致性和准确性。而 AI 技术的引入,为这一领域带来了革命性的变化: 1. **高效性**:AI 能够快速处理大量文本,生成标签和摘要在短时间内完成,大大提高了内容发布的效率。 2. **准确性**:通过机器学习算法,AI 能够准确识别文章的主题和关键信息,生成相关度高的标签和摘要。 3. **个性化**:AI 可以根据不同的内容和风格需求,定制化的生成标签和摘要,满足多样化的内容创作需求。 ## AI 生成标签和摘要的实现过程 目前正在使用 [TianliGPT](https://docs_s.tianli0.top/), 它是一个专业的文字摘要生成工具,你可以将需要提取摘要的文本内容发送给TianliGPT,稍等一会他就可以给你发送一个基于这段文本内容的摘要。 ![20241231185358_1atLeTR9.webp](https://cdn.dong4j.site/source/image/20241231185358_1atLeTR9.webp) - 实时生成的摘要 - 自动生成,无需人工干预 - 一次生成,再次生成无需消耗key - 包含文字审核过滤,适用于中国大陆 - 支持中国大陆访问 到 [爱发电](https://afdian.net/item/f18c2e08db4411eda2f25254001e7c00)中购买,原价10元5万字符(Heo限量限时折扣9元)。请求过的内容再次请求不会消耗key,可以无限期使用。 作者同时提供多种博客的插件, 接入方式也非常简单. 但是今天 **TianliGPT** 不是我们的重点, 重点是利用本地自建的 LLM 服务来生成摘要和标签. 实现方式也非常简单, 我所使用的 [安知鱼主题](https://docs.anheyu.com/) 中集成了 **TianliGPT**, 只需要配置 key 和 Referer 即可, 其中还有一个可选项: ```yaml post_head_ai_description: enable: true gptName: xxx mode: local # 默认模式 可选值: tianli/local switchBtn: true # 可以配置是否显示切换按钮 以切换tianli/local ... ``` 在页面上可以选择使用 **local** 还是 **tianli** 来生成摘要: ![20241230155911_MvGDK7JW.webp](https://cdn.dong4j.site/source/image/20241230155911_MvGDK7JW.webp) 如果切换到本地, 则会显示自定义的 GPT 名称与自定义摘要内容: ![20241230160007_8ZRZXs8g.webp](https://cdn.dong4j.site/source/image/20241230160007_8ZRZXs8g.webp) 在文章的 `Front-matter `配置 `ai: true` 使用 `tianli gpt` 需将 mode 改为 `tianli` 然后在需要 ai 摘要的文章的 `Front-matter 配置 ai: true` 如果使用 `local`,需要按照以下方式配置 ``` --- title: AnZhiYu主题快速开始 ai: - 本教程介绍了如何在博客中安装基于Hexo主题的安知鱼主题,并提供了安装、应用主题、修改配置文件、本地启动等详细步骤及技术支持方式。教程的内容针对最新的主题版本进行更新,如果你是旧版本教程会有出入。 - 本文真不错 --- ``` 意思是只要在 `Front-matter` 中配置 **ai** 标签即可在博客中显示摘要, 那么我们的目标就很明确了: 1. 使用 LLM 生成文章摘要; 2. 填写到文章的 `ai` 标签中; 3. 将 `mode` 设置为 `local`, 默认使用本地的摘要; 4. 借助 **TianliGPT** 在页面显示自定义摘要; ### 更多支持TianliGPT的项目 [Post-Summary-AI](https://github.com/qxchuckle/Post-Summary-AI) - 轻笑开发的博客摘要生成工具 [hexo-ai-excerpt](https://github.com/rootlexblog/hexo-ai-excerpt) - 在本地部署时添加AI摘要 --- ### 搭建 LLM 服务 这个我相信老铁们都很熟悉了. 为了偷了懒我直接在 Mac mini M2 上运行了一个 Ollama 服务, 使用 **glm4** 模型来为文章生成摘要. 我相信在 macOS 上部署 Ollama 应该是一种最简单快速部署 LLM 的方式了, 相关教程也非常多, 这里就不再赘述了. 需要说明的是为了让其他主机能够使用 LLM 服务, 需要特殊配置一下: ```bash # 允许跨域 launchctl setenv OLLAMA_ORIGINS "*" # 修改 bind launchctl setenv OLLAMA_HOST "0.0.0.0" ``` ### 自动化 #### 脚本 先来一个简单的脚本, 通过调用 Ollama 的 API 来获取文章摘要: ```python import requests import json import re def generate_summary(content, model="default"): """ 调用 Ollama API 生成博客摘要。 :param content: 博客内容 (字符串) :param model: 使用的 Ollama 模型 (默认是 'default') :return: 生成的摘要 (JSON 字符串) """ # 构造 prompt prompt = f""" 你是一个专业的内容总结生成助手。你的任务是为给定的博客内容进行总结, 字数在 100 字内。 请分析'CONTENT START HERE'和'CONTENT END HERE'之间的文本,以下是规则: 1. 仅返回一个完整的总结,不要添加额外的信息。 2. 如果内容中包含 HTML 标签,请忽略这些标签,仅提取文本内容进行分析。 以下是需要处理的博客内容: CONTENT START HERE {content} CONTENT END HERE 你必须以JSON格式响应,键为'summary',值是字符串格式的总结内容。 """ # print(blog_content) # 设置 Ollama API 的 URL url = f"http://localhost:11434/api/generate" data = { "model": model, "prompt": prompt, "format": "json", "stream": False } # 发送 POST 请求到 Ollama API try: response = requests.post(url, json=data, stream=False) response.raise_for_status() # 检查请求是否成功 data = response.json() # 提取生成的摘要 if "response" in data: return data["response"] else: print("没有从响应中获取到摘要内容!") return None except requests.exceptions.RequestException as e: print(f"请求失败: {e}") return None if __name__ == "__main__": # 输入博客内容 # blog_content = """ # 标题:Ollama 模型的使用方法 # 内容:Ollama 是一个强大的语言模型框架,可以用于生成内容、总结文档等。它支持多种模型类型, # 并且可以通过简单的 API 调用来实现复杂的文本生成任务。 # 在这篇博客中,我们将介绍如何使用 Ollama,以及如何通过 Python 调用其 API 来生成内容。 # """ with open("md 文档路径", "r", encoding="utf-8") as file: blog_content = file.read() # 删除多余空行,但保留段落间的分隔 blog_content = re.sub(r'\n\s*\n', '\n', blog_content) # 删除每行行首和行尾的多余空白符 blog_content = "\n".join(line.strip() for line in blog_content.splitlines()) # 生成摘要 summary = generate_summary(blog_content, model="glm4") # 输出摘要 if summary: print(summary) ``` 保存为 `generate_summary.py` , 然后运行: `python generate_summary.py`, 输出结果: ```json { "summary": "文章讲述了从书房设备升级到万兆网络的过程和经验。主要涉及到硬件升级、网络环境搭建、问题解决以及性能优化等方面。通过一周时间的努力,实现了上传和下载速度接近或达到理论值的效果。文中还提到了将书房主要设备升级为支持更大带宽的可能方案,并链接了多个与HomeLab相关主题的文章,如硬件、服务、数据管理等。" } ``` 简直是一气呵成. 我的目标是将博客部署到云平台之前, 自动为博客生成摘要和 tags, 所以我们需要一个脚本去修改 `Front-matter` 中 `ai` 和 `tags` 这 2 个标签的内容, 我们先来看自动化脚本: ```python import os import sys import requests import re from io import StringIO from ruamel.yaml import YAML import json import re from datetime import datetime """ 生成标签和总结并替换原文本的内容 """ def log(message): """ 打印日志信息,包含时间戳。 """ print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}") # 自定义 YAML Dump 函数,保持缩进和格式 def dump_yaml(data): yaml = YAML() yaml = YAML() yaml.preserve_quotes = True yaml.indent(mapping=2, sequence=4, offset=2) # 使用 StringIO 来捕获输出 stream = StringIO() yaml.dump(data, stream) # 获取字符串值并返回 return stream.getvalue() def get_all_md_files(directory, exclude_dir=None): """ 遍历指定目录,获取所有 Markdown 文件(排除指定目录)。 """ md_files = [] for root, dirs, files in os.walk(directory): # 排除指定的目录s if exclude_dir and os.path.abspath(exclude_dir) in os.path.abspath(root): continue for file in files: if file.endswith('.md'): md_files.append(os.path.join(root, file)) return md_files def find_md_file(base_dir, filename, exclude_dir=None): """ 在指定目录中查找特定的 Markdown 文件(排除指定目录)。 """ for root, dirs, files in os.walk(base_dir): # 排除指定的目录 if exclude_dir and os.path.abspath(exclude_dir) in os.path.abspath(root): continue for file in files: if file == filename: return os.path.join(root, file) return None def replace_ai_tags_in_md(md_file, base_dir, publish_dir): """ 替换 Markdown 文件中的 `ai` 标签,并保存到发布目录。 """ log(f"开始处理文件: {md_file}") with open(md_file, 'r', encoding='utf-8') as file: content = file.read() # 提取 Front-matter 部分 front_matter_pattern = r"---\n(.*?)\n---\n" match = re.match(front_matter_pattern, content, re.DOTALL) if not match: log(f"文件 {md_file} 不包含有效的 Front-matter,跳过处理。") return front_matter = match.group(1) body = content[match.end():] # Markdown 正文内容 # 使用 YAML 解析 Front-matter data = YAML().load(front_matter) # 检查 ai 标签 md_ai_tag = data.get('ai') description_tag = data.get('description') # 判断是否需要生成摘要 need_generate_summary = not isinstance(md_ai_tag, list) or not description_tag need_update =False if need_generate_summary: # 替换 `ai` 标签内容 ai_data = generate_summary_and_tags(body) # 调用摘要生成函数 summary = ai_data.get("summary", "").strip() if summary: if not isinstance(md_ai_tag, list): data['ai'] = [summary] # 设置或替换 ai 标签 log(f"文件 {md_file} 生成 ai 摘要") if not description_tag: data['description'] = summary # 设置或替换 description 标签 log(f"文件 {md_file} 生成 description 标签") need_update = True else: log(f"未获取摘要,跳过文件") md_tags = data.get('tags') if not isinstance(md_tags, list): # 替换 `tags` 标签内容 ai_data = generate_summary_and_tags(body) # 调用摘要生成函数 tags = ai_data.get("tags", "") if tags: data['tags'] = tags log(f"文件 {md_file} 生成 tags") need_update = True else: log(f"未获取 tags,跳过文件") if need_update: # 将更新后的 Front-matter 转换回 YAML 格式 updated_front_matter = dump_yaml(data) updated_content = f"---\n{updated_front_matter}---\n{body}" # 保存更新后的内容到原文件 with open(md_file, 'w', encoding='utf-8') as file: file.write(updated_content) log(f"已更新文件: {md_file}") else: log(f"不需要更新文件") def generate_summary_and_tags(content): # 删除多余空行,但保留段落间的分隔 content = re.sub(r'\n\s*\n', '\n', content) # 删除每行行首和行尾的多余空白符 content = "\n".join(line.strip() for line in content.splitlines()) # 生成摘要 summary = generate_summary_from_ai(content, model="qwen2") # 解析 JSON 格式字符串,提取 summary 的值 try: return json.loads(summary) except json.JSONDecodeError as e: log(f"解析 JSON 数据失败: {e}") return '' def generate_summary_from_ai(content, model="default"): """ 调用 Ollama API 生成博客摘要。 :param content: 博客内容 (字符串) :param model: 使用的 Ollama 模型 (默认是 'default') :return: 生成的摘要 (字符串) """ # 构造 prompt prompt = f""" 你是一个专业的内容总结生成助手。你的任务是为给定的博客内容进行总结以及帮助进行自动生成标签。 请分析'CONTENT START HERE'和'CONTENT END HERE'之间的文本,以下是总结规则: 1. 生成的总结内容,长度在 100 到 300 字符之间。 2. 仅返回一个完整的总结,不要添加额外的信息。 3. 如果内容中包含 HTML 标签,请忽略这些标签,仅提取文本内容进行分析。 下面是标签生成规则: - 目标是多种多样的标签,包括广泛类别、特定关键词和潜在的子类别。 - 标签语言必须为中文。 - 标签最好是文案中的词, 比如 HomeLab, Java 等, 这些应该按照原文中出现的词来生成。 - 如果是著名网站,你也可以为该网站添加一个标签。如果标签不够通用,不要包含它。 - 内容可能包括cookie同意和隐私政策的文本,在标签时请忽略这些。 - 目标是3-5个标签。 - 如果没有好的标签,请留空数组。 以下是需要处理的博客内容: CONTENT START HERE {content} CONTENT END HERE 你必须以JSON格式响应,键为'summary',值是字符串格式的总结内容, 键为'tags',值是字符串标签的数组。 """ # print(blog_content) # 设置 Ollama API 的 URL url = f"http://localhost:11434/api/generate" data = { "model": model, "prompt": prompt, "format": "json", "stream": False } # 发送 POST 请求到 Ollama API try: response = requests.post(url, json=data, stream=False) response.raise_for_status() # 检查请求是否成功 data = response.json() # 提取生成的摘要 if "response" in data: return data["response"] else: print("没有从响应中获取到摘要内容!") return None except requests.exceptions.RequestException as e: print(f"请求失败: {e}") return None def main(): args = sys.argv[1:] script_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.join(script_dir, '..', 'source/_posts') publish_dir = os.path.join(base_dir, 'publish') # 初始化要处理的 Markdown 文件列表 md_files_to_process = [] """ 1. 不传任何参数, 则处理 source/_posts 下所有的文档(不包括 publish 目录); 2. 传入年份参数,则处理指定年份的 Markdown 文件(不包括 publish 目录); 3. 传入 Markdown 文件名,则处理指定的 Markdown (文件不包括 publish 目录); """ if not args: # 处理所有 Markdown 文件 md_files_to_process = get_all_md_files(base_dir, exclude_dir=publish_dir) elif len(args) == 1 and args[0].isdigit(): # 处理指定年份的 Markdown 文件 year_dir = os.path.join(base_dir, args[0]) if os.path.isdir(year_dir): md_files_to_process = get_all_md_files(year_dir, exclude_dir=publish_dir) else: log(f"年份目录 {args[0]} 不存在。") return elif len(args) == 1 and args[0].endswith('.md'): # 处理指定的 Markdown 文件 md_filename = args[0] md_file = find_md_file(base_dir, md_filename, exclude_dir=publish_dir) if md_file: md_files_to_process.append(md_file) else: log(f"未找到 Markdown 文件 {md_filename}。") return else: log("参数数量错误。") return # 循环处理所有确定的 Markdown 文件 for md_file in md_files_to_process: replace_ai_tags_in_md(md_file, base_dir, publish_dir) if __name__ == "__main__": main() ``` 逻辑也非常简单了: 1. 自动获取 md 文件内容, 简单的处理一下后给 LLM 生成摘要和标签; 2. 自动替换 `Front-matter` 中的 `ai` 和 `tags` 标签; 这里需要先说一下我的 Hexo 博客的目录结构: ``` . ├── script │ ├── 其他各种脚本 │ └── generate_summary_and_tags_and_replace.py # 就是上面的脚本 ├── source │ ├── _posts │ │ ├── 2012 │ │ ├── 2013 │ │ ├── 2014 │ │ ├── 2015 │ │ ├── 2016 │ │ ├── 2017 │ │ ├── 2018 │ │ ├── 2019 │ │ ├── 2020 │ │ ├── 2021 │ │ ├── 2022 │ │ ├── 2023 │ │ ├── 2024 │ │ └── publish │ └── 其他目录 ├── makefile └── themes ``` 1. 我的所有脚本都是在 `script` 目录下; 2. `_posts` 下按照年份划分不同的目录; 3. `makefile` 用于部署工作流配置; --- #### hexo-deploy-workflow 这个不是 Hexo 的插件, 是我通过 **markfile** 实现的一个部署工作流程: ``` # 定义伪目标,避免与文件名冲突 .PHONY: clean_images convert_and_rename upload_images generate_summary_tags push deploy-m920x deploy-github clean ########## 需要终端在 hexo 顶层目录才能正常执行 # 默认目标 all: clean_images convert_and_rename upload_images generate_summary_tags push deploy-m920x deploy-github clean # 删除未被引用的图片, 不传任何参数则全部处理, 传 2023 则只处理 2023 目录下的文件, 传 md 文件名, 则只处理这一个文件 clean_images: @echo "==================Step 1: Cleaning images==================" python script/clean_images.py # 将图片转换为 webp 且重命名(年月日时分秒_8位随机字符串.webp) convert_and_rename: @echo "==================Step 2: Cleaning images==================" python script/convert_and_rename.py # 上传图片 upload_images: @echo "==================Step 3: Cleaning images==================" python script/upload_images.py generate_summary_tags: @echo "==================Step 3: Cleaning images==================" python script/generate_summary_and_tags_and_replace.py # 执行 git-push.sh push: generate_summary_tags @echo "==================Step 4: Pushing changes to Git==================" script/git-push.sh "删除重复的文章" # 执行 deploy.sh deploy-m920x: push @echo "==================Step 5: Deploying application==================" script/deploy.sh deploy-github: push @echo "==================Step 6: Deploying Github==================" hexo deploy clean: @echo "==================Step 7: Cleaning up==================" hexo clean && rm -rf .deploy_git ``` 其中涉及到摘要生成并发布的步骤为: ``` generate_summary_tags: @echo "==================Step 3: Cleaning images==================" python script/generate_summary_and_tags_and_replace.py # 执行 git-push.sh push: generate_summary_tags @echo "==================Step 4: Pushing changes to Git==================" script/git-push.sh "删除重复的文章" ``` 意思是只要我执行 `make push`, 即会在执行 `push` 之前先执行 `generate_summary_tags`, 通过 makefile 的编排实现了一个简单的工作流. --- ### 效果 脚本处理后的 md 文件: ```yml --- title: HomeLab数据备份:打造坚实的数据安全防线 ai: - 这篇博文详细阐述了作者构建的家庭实验室(Homelab)的数据备份策略和体系。作者使用多种工具和技术如Time Machine、Apple Boot Camp (ABB)、Synology Drive Client、Abbackup、Syphon以及Hyper Backup等进行不同设备的备份,确保数据安全性和可用性。文中还提到了数据存储方案、备份计划、数据冗余以及远程访问等方面的内容,并分析了各设备在数据备份中的角色和责任。此外,文章指出所有备份操作都是为了应对潜在的数据丢失风险并确保快速恢复,同时也评估了整体备份体系的稳定性与可靠性。 swiper_index: 7 top_group_index: 7 tags: - Homelab - Data Backup - MacOS - Linux - Synology - Time Machine - ABB - Docker - WebDAV - Aliyun Pan categories: - HomeLab cover: /images/cover/20241229154732_oUxZug2L.webp date: 2020-04-25 00:00:00 main_color: description: 这篇博文详细阐述了作者构建的家庭实验室(Homelab)的数据备份策略和体系。作者使用多种工具和技术如Time Machine、Apple Boot Camp (ABB)、Synology Drive Client、Abbackup、Syphon以及Hyper Backup等进行不同设备的备份,确保数据安全性和可用性。文中还提到了数据存储方案、备份计划、数据冗余以及远程访问等方面的内容,并分析了各设备在数据备份中的角色和责任。此外,文章指出所有备份操作都是为了应对潜在的数据丢失风险并确保快速恢复,同时也评估了整体备份体系的稳定性与可靠性。 --- ``` ![20241230164842_v4Y7Y4m8.webp](https://cdn.dong4j.site/source/image/20241230164842_v4Y7Y4m8.webp) ## 总结 不是说 **TianliGPT** 这类在线服务不好用或者太贵用不起, 只是觉得本地这么折腾下来更有意思. 未来我相信随着个人设备的性能足够强劲或者技术发展到 AI 模型也能够在廉价设备上运行时, 在人们更加关注隐私与安全性的时候, 本地模型才是最终的归属. ## [Hexo博客部署与图片处理全攻略:自动化流程大揭秘](https://blog.dong4j.site/posts/461c6733.md) ![/images/cover/20241231185353_hbs1dqDw.webp](https://cdn.dong4j.site/source/image/20241231185353_hbs1dqDw.webp) ## 简介 随着博客文章的数量不断增加,尤其是长篇文章中需要插入大量图片,发布一篇博客变得更加复杂。这包括图片的剪切、格式转换、清理多余图片、上传图床、替换 Markdown 中的图片标签,以及最终发布到站点。如果全程手动操作,无疑会非常繁琐。为了解决这个问题,我将这些步骤全部实现为独立的脚本,最后通过 Makefile 将它们串联起来,打造了一套完整的 Hexo 部署工作流。 那么接下来就是讲怎么实现这个流程了, 这里就以 Hexo 为例, 只要了解整个思路, 我觉得其他的任何博客都可以实现这套流程. ## 图片处理 一图胜千言,因此我非常喜欢在博客中插入大量图片。无论是截图、网络图片,还是用 Drawio 绘制的 SVG,精心挑选的配图不仅能够提升博客的视觉效果,还能直观地增强内容的表达力和吸引力。 以前我对图片的处理步骤大致为: 1. 第一步是使用截图工具简单的处理一下图片, 比如截图, 调整尺寸, 打马赛克, 添加圆角, 添加阴影等等; 2. 第二步是将图片转换成 webp, 尽量在保证图片质量的前提下减小图片尺寸; 3. 第三步就是上传到图床, 然后替换原来的图片标签; 上面的步骤是一个正向流程, 但是可能会遇到这样的问题: 1. 图片忘了处理敏感信息; 2. 截取的图片没有达到预期; 3. 某个地方的配图需要更换为新图片; 可能还有一些其他原因需要重新处理或更换图片的话, 上面的图片处理流程要重新来一遍, 还得手动删除不再使用的图片. 一篇博客的发布, 可能大量时间都在处理图片. 所以为了规避这个问题, 本着能偷懒就偷懒的原则, 我开始尝试使用脚本处理图片, 所以接下来就是介绍图片的处理流程. ### 插入图片 > 我写博客的主力工具是 Typora, 还会结合 VSCode 来管理整个博客的文件, 截图工具使用了 [CleanShot X](https://cleanshot.com/). [Typora](https://typoraio.cn/) 有一个很棒的功能: **插入图片时** 执行指定的操作. 比如我这里就是直接复制到 **指定目录** (这个操作同样适用于网络图片, Typora 会直接将原始图片下载到指定目录). ![20241231190525_BwyfBnUw.webp](https://cdn.dong4j.site/source/image/20241231190525_BwyfBnUw.webp) 按照上面的配置之后, Typora 插入的图片标签格式为: ``` ![20241231103443_bT7yAiud](./hexo-deploy-workflow/20241231103443_bT7yAiud.png) ``` 使用 Hexo 作为博客系统的朋友都知道, Hexo 可以将与 Markdown 文件同名的目录作为资源目录, 所以我在 Typora 中配置的就是 `./${filename}`. 不过 Hexo 需要配置一下(`_config.yml`) : ```yaml post_asset_folder: true relative_link: false marked: prependRoot: true postAsset: true ``` **设置详解:** - `post_asset_folder: true`: 执行 `hexo new post xxx `时,会同时生成 `./source/_posts/xxx.md`文件和 `./source/_posts/xxx` 目录,可以将该文章相关联的资源放置在该资源目录中。 - `relative_link: false`: 不要将链接改为与根目录的相对地址。此为默认配置。 - `prependRoot: true`: 将文章根路径添加到文章内的链接之前。此为默认配置。 - `postAsset: true`: 在 `post_asset_folder` 设置为 `true` 的情况下,在根据 `prependRoot` 的设置在所有链接开头添加文章根路径之前,先将文章内资源的路径解析为相对于资源目录的路径。 **举例说明:** 执行 `hexo new post demo` 后,在 `demo` 文章的资源路径下存放了 `a.jpg`,目录结构如下: ``` ./source/_posts ├── demo.md └── demo └── a.jpg ``` Hexo 正确显示图片的写法应该是: ``` ![](./hexo-deploy-workflow/a.jpg) ``` 所以就会存在这样的问题: **在 Typora 中可以正常显示图片, 而 Hexo 网页中则无法显示**. 这个问题有 2 种解决方案: 1. 在 Typora 中写完博客后, 全局手动将 `./demo/` 替换成空字符串; 2. 修改 `hexo-renderer-marked` 插件代码; 这里我们介绍第二种方式(其实我使用的是第一种方式, 因为我不想轻易修改 Hexo 的原始代码, 会为后续升级带来一些问题). 因为 `hexo-renderer-marked` 渲染插件默认的图片相对路径根目录是 `./source/_posts/demo/`,我们需要让这个路径向上回退一层,变成 `demo.md` 文件所在的目录,与本地编辑器预览时默认的根目录一致,这样既满足了本地编辑器的渲染需求,又能 让 Hexo 正确加载网页中的图片。 打开 `./node_modules/hexo-renderer-marked/lib/renderer.js`,搜索 `image(href, title, text)` 定位到修改图片相对路径的代码: ```javascript // Prepend root to image path image(href, title, text) { ... if (!/^(#|\/\/|http(s)?:)/.test(href) && !relative_link && prependRoot) { if (!href.startsWith('/') && !href.startsWith('\\') && postPath) { const PostAsset = hexo.model('PostAsset'); // findById requires forward slash const asset = PostAsset.findById(join(postPath, href.replace(/\\/g, '/'))); // asset.path is backward slash in Windows if (asset) href = asset.path.replace(/\\/g, '/'); } href = url_for.call(hexo, href); } ... } ``` 修改为: ```javascript // Prepend root to image path image(href, title, text) { ... if (!/^(#|\/\/|http(s)?:)/.test(href) && !relative_link && prependRoot) { if (!href.startsWith('/') && !href.startsWith('\\') && postPath) { const PostAsset = hexo.model('PostAsset'); // findById requires forward slash const fixPostPath = join(postPath, '../'); const asset = PostAsset.findById(join(fixPostPath, href.replace(/\\/g, '/'))); // asset.path is backward slash in Windows if (asset) href = asset.path.replace(/\\/g, '/'); } href = url_for.call(hexo, href); } ... } ``` 简单地说,这里的修改就是将文章路径 `postPath` 换成了它的上一级路径 `fixPostPath`,更换的方法就是在 `postPath` 后面加上`../`。 现在,切换到 `demo.md`,保留 FrontMatte 中 `cover` 的图片路径,将文章中的图片路径变更为 `demo/a.jpg`: 需要注意的是 `./node_modules` 一般来说不被 Git 追踪,而且相关插件在更新后会覆盖掉人为修改,所以这个改动一般难以跨设备同步。现阶段可以采用的办法之一便是在仓库里另外保存 `renderer.js`,并在部署时、安装插件后,使用自动指令覆盖插件中的文件。 **参考资料** - Github 仓库中有关此问题的 issue 及解决方案:[#216](https://github.com/hexojs/hexo-renderer-marked/issues/216#issuecomment-1214703941) --- ### 图片清理 在某些情况下我们可能需要替换图片, 使用 Typora 是重新创建了一个图片, 原始图片要么通过 Typora 删除, 那么自己去图片目录手动删除, 为了解决这个手动操作的问题, 我们使用脚本来一次性清理未被引用的图片资源: ```python """ 清理 source/_posts 目录下未被引用的图片. """ import os import sys from utils import extract_image_urls_from_md def log(message): """ 打印中文日志信息。 """ print(f"日志:{message}") def get_all_md_files(directory): """ 获取指定目录下的所有Markdown文件。 """ md_files = [] for root, _, files in os.walk(directory): for file in files: if file.endswith('.md'): md_files.append(os.path.join(root, file)) return md_files def find_md_file(directory, filename): """ 在指定目录及其子目录中查找指定的Markdown文件。 """ for root, _, files in os.walk(directory): if filename in files: return os.path.join(root, filename) return None def get_referenced_images(md_file): """ 获取Markdown文件中引用的所有图片。 """ with open(md_file, 'r', encoding='utf-8') as file: content = file.read() return extract_image_urls_from_md(content) def clean_unreferenced_images(md_file, exclude_extensions=None): """ 清理未引用的图片,支持排除特定格式的文件。 :param md_file: Markdown 文件路径 :param exclude_extensions: 要排除的文件扩展名列表(如 ['.keep', '.txt']),默认 None """ if exclude_extensions is None: exclude_extensions = [] image_dir = os.path.splitext(md_file)[0] if not os.path.isdir(image_dir): return referenced_images = get_referenced_images(md_file) for root, _, files in os.walk(image_dir): for file in files: file_path = os.path.join(root, file) _, ext = os.path.splitext(file) # 检查文件是否被引用或是否在排除列表中 if file not in referenced_images and ext not in exclude_extensions: os.remove(file_path) log(f"已删除未引用的图片:{file_path}") def main(): args = sys.argv[1:] script_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.join(script_dir, '..', 'source/_posts') log(f"博客文章的基准目录:{base_dir}") if not args: # 处理所有文档和图片 md_files = get_all_md_files(base_dir) log("正在处理所有Markdown文件和图片。") elif len(args) == 1 and args[0].isdigit(): # 处理指定年份的文档和图片 year_dir = os.path.join(base_dir, args[0]) md_files = get_all_md_files(year_dir) log(f"正在处理年份 {args[0]} 的Markdown文件和图片。") elif len(args) == 1 and args[0].endswith('.md'): # 处理指定的Markdown文档和其资源文件 md_filename = args[0] md_file = find_md_file(base_dir, md_filename) if md_file: md_files = [md_file] log(f"正在处理Markdown文件:{md_file}") else: log(f"未找到Markdown文件 {md_filename}。") return else: log("参数无效。") return for md_file in md_files: clean_unreferenced_images(md_file, exclude_extensions=['.svg']) log("==================图片清理完成==================") if __name__ == '__main__': main() ``` > **重要提醒**: > > 最好修改一下脚本, 不要直接删除图片, 比如移动到另外的目录, 这样在误删图片的情况下还能恢复. ### 图片转换 使用 [CleanShot X](https://cleanshot.com/) 截取的图片默认是 png 格式, 且图片格式非常大, 基本上都在 1M 以上, 光写 HomeLab 相关的文章的图片加起来都超过 1G, 所以觉得对现有的图片进行全局压缩, 且后期其他图片全部使用 WebP 代替. > **WebP** 是一种现代图片格式,旨在为网络上的图片提供出色的无损和有损压缩。WebP 格式由 Google 开发,派生自 VP8 图像编码格式,支持有损和无损压缩。 > > WebP 格式具有以下特点: > > - **压缩效率高**:WebP 格式可以在保持相同图像质量的情况下,将文件大小显著减小。例如,WebP 格式的文件通常比 JPEG 文件小约 30%。 > - **支持无损和有损压缩**:WebP 支持两种压缩方式,无损压缩适用于需要完全保留原始图像细节的场景,而有损压缩则适用于可以接受一定图像质量损失以换取更小文件大小的场景。 > - **硬件加速**:WebP 格式支持硬件加速解码,可以提高图片加载速度。 > - **开源**:WebP 格式是开源的,这意味着它可以被广泛应用于不同的平台和设备上。 > > WebP 格式的优势包括: > > - **提高网页加载速度**:由于文件大小显著减小,使用 WebP 格式的图片可以显著提高网页的加载速度,提升用户体验。 > - **节省带宽**:较小的文件大小意味着可以减少数据传输量,从而节省带宽资源。 > - **兼容性好**:现代浏览器如 Chrome、Firefox、eged、safair 等都已经支持 WebP 格式,使得这种格式在实际应用中具有很好的兼容性。 因为我的图床图片是通过图片名称确定唯一性的, 相同的图片名称上传会替换原有图片, 这样就能减少垃圾图片的产生. 所以在图片转换过程中就索性将图片重命名以确保唯一性, 规则为: `{年月日时分秒}_{8 位随机字符串}.webp`. 下面是处理脚本: ```python import os import re import sys import subprocess import random import string from datetime import datetime from utils import find_all_image_tags, extract_image_url_from_tag, extract_image_urls_from_md, get_all_md_files, find_md_file, is_url SUPPORTED_IMAGE_FORMATS = {'.png', '.jpg', '.jpeg', '.bmp'} # 添加更多支持的格式 def log(message): """ 打印中文日志信息。 """ print(f"日志:{message}") def is_valid_filename(filename): """ 检查文件名是否已满足特定的命名规则。 """ naming_pattern = re.compile(r'^\d{14}_[a-zA-Z0-9]{8}\.webp$') return naming_pattern.match(filename) is not None def generate_random_string(length=8): """ 生成指定长度的随机字符串。 """ return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) def convert_image_to_webp(image_path, quality=75): """ 使用ffmpeg将支持的图片格式转换为webp格式,如果图片已经是webp或不是支持的格式则跳过。 """ # log(f"尝试转换图片 {image_path} 到webp格式") # 检查是否为URL或不是支持的图片格式 if (is_url(image_path) or not os.path.splitext(image_path)[1].lower() in SUPPORTED_IMAGE_FORMATS): # log(f"路径 {image_path} 是一个 URL 或不是支持的图片格式,跳过转换。") return image_path # 检查是否已存在同名的webp文件 webp_path = os.path.splitext(image_path)[0] + '.webp' if os.path.exists(webp_path): # log(f"同名 webp 文件已存在:{webp_path},跳过转换。") return webp_path # 生成输出路径 output_path = os.path.splitext(image_path)[0] + '.webp' # 构建并执行ffmpeg命令 command = f"ffmpeg -i '{image_path}' -q:v {quality} '{output_path}' -loglevel quiet" subprocess.run(command, shell=True, check=True) # 添加check=True以捕获错误 log(f"图片 {image_path} 已转换为 {output_path}") return output_path def rename_webp_file(webp_path, starts_with_images=False): """ 根据规则重命名webp文件,如果文件已存在则跳过。 """ # 检查文件是否为webp文件 if not webp_path.lower().endswith('.webp'): # log(f"文件 {webp_path} 不是webp文件,跳过重命名。") return os.path.basename(webp_path) # 检查文件名是否已满足规则 if is_valid_filename(os.path.basename(webp_path)): # log(f"文件 {webp_path} 名称已满足规则") if starts_with_images: return "/images/cover/" + os.path.basename(webp_path) else: return os.path.basename(webp_path) timestamp = datetime.now().strftime('%Y%m%d%H%M%S') random_string = generate_random_string() new_name = f"{timestamp}_{random_string}.webp" new_path = os.path.join(os.path.dirname(webp_path), new_name) if os.path.exists(new_path): log(f"文件 {new_path} 已存在,跳过重命名。") return os.path.basename(webp_path) os.rename(webp_path, new_path) if starts_with_images: # 图片路径以 /images 开头 new_name = "/images/cover/" + new_name log(f"文件 {webp_path} 重命名为 {new_path}") return new_name def update_md_image_tags(md_file, image_tag_map): """ 更新Markdown文件中的图片标签。 """ with open(md_file, 'r+', encoding='utf-8') as file: content = file.read() # 跳过不必要的替换 updated = False for old_tag, new_tag in image_tag_map.items(): # log(f"正在处理图片标签:{old_tag} -> {new_tag}") if old_tag != new_tag and old_tag in content: content = content.replace(old_tag, new_tag) updated = True if '/images/cover/' in old_tag: log(f"替换 cover 标签") content = content.replace('cover: ' + extract_image_url_from_tag(old_tag), 'cover: ' + extract_image_url_from_tag(new_tag)) # 只有在有更新时才写回文件 if updated: file.seek(0) file.write(content) file.truncate() else: log(f"文件 {md_file} 中没有需要更新的图片标签。") def get_referenced_images(md_file): """ 获取Markdown文件中引用的所有图片。 """ with open(md_file, 'r', encoding='utf-8') as file: content = file.read() return extract_image_urls_from_md(content) def process_md_file(md_file): """ 处理单个Markdown文件及其图片,避免重复处理。 """ log(f"正在处理 Markdown 文件:{md_file}") image_dir = os.path.splitext(md_file)[0] if not os.path.isdir(image_dir): return image_tag_map = {} all_image_tags = find_all_image_tags(md_file) print(all_image_tags) for image_tag in all_image_tags: image_path = extract_image_url_from_tag(image_tag) if image_path.startswith('http'): log(f"已经是图床图片, 不需要转换 {image_path} ") continue # Skip external images if image_path.startswith('/images'): # 图片路径以 /images 开头,需要在 source/images 目录下查找 source_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'source') full_image_path = os.path.join(source_dir, 'images', image_path[len('/images'):].lstrip('/')) else: # 图片路径不是以 /images 开头,直接在文章目录下查找 full_image_path = os.path.join(image_dir, image_path) if os.path.isfile(full_image_path): webp_path = convert_image_to_webp(full_image_path) # 检查 webp_path 是否为网络图片 if not is_url(webp_path): new_name = rename_webp_file(webp_path, starts_with_images=True if image_path.startswith('/images') else False) new_tag = f"![{new_name}](./hexo-deploy-workflow/{new_name})" image_tag_map[image_tag] = new_tag else: log(f"路径 {webp_path} 是一个URL,跳过重命名和标签替换。") if image_tag_map: update_md_image_tags(md_file, image_tag_map) def main(): args = sys.argv[1:] script_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.join(script_dir, '..', 'source/_posts') log(f"博客文章的基准目录:{base_dir}") # 初始化要处理的Markdown文件列表 md_files_to_process = [] if not args: # 处理所有Markdown文件 md_files_to_process = get_all_md_files(base_dir) elif len(args) == 1 and args[0].isdigit(): # 处理指定年份的Markdown文件 year_dir = os.path.join(base_dir, args[0]) if os.path.isdir(year_dir): md_files_to_process = get_all_md_files(year_dir) else: log(f"年份目录 {args[0]} 不存在。") elif len(args) == 1 and args[0].endswith('.md'): # 处理指定的Markdown文件 md_filename = args[0] md_file = find_md_file(base_dir, md_filename) if md_file: md_files_to_process.append(md_file) else: log(f"未找到Markdown文件 {md_filename}。") return else: log("参数数量错误。") return # 循环处理所有确定的Markdown文件 for md_file in md_files_to_process: process_md_file(md_file) log("==================图片转换完成==================") if __name__ == "__main__": main() ``` 上面的脚本核心是使用 `ffmpeg` 将图片转换为 WebP, 使用上述脚本将所有图片转换成 WebP 后, 所有图片从原来的 1G 减少到现在的 100M+, 效果非常明确. --- ### 图片上传 macOS 可选的图片上传方案非常多, 比较常见的有: 1. [iPic](https://apps.apple.com/us/app/ipic-image-file-upload-tool/id1101244278?mt=12) (macOS, Freemium) 2. [uPic](https://github.com/gee1k/uPic) (macOS, OpenSource) 3. [PicGo-Core](https://github.com/PicGo/PicGo-Core) 4. [PicGo.app](https://picgo.github.io/PicGo-Doc/zh/) 5. [Upgit](https://github.com/pluveto/upgit) 这里我选择了 [PicGo-Core](https://github.com/PicGo/PicGo-Core), 通过命令行方式上传图片. 我的逻辑如下: 1. 拷贝 md 文件到 `source/_posts/publish` 目录下, 此目录作为最终需要发布的博客文章目录; 2. 解析标签并通过 `picgo upload` 批量上传图片到图床; 关于第一点这里需要解释一下 **为什么还要专门搞一个目录来存放最终发布文章目录**. 以前将图片上传到图床, 但是跑路了, 本地的博客中的图片全都是上传图床后的在线地址, 且本地的图片也没有备份, 所以图片全部丢失. 痛定思痛后想出了现在这个方法: **本地存放原始的图片和博客文章**, 需要发布到线上时, 拷贝一份原始博客出来, 然后替换其中的图片地址, 将这个文档作为编译的版本并发布到线上. 这样我本地留有原始的博客和图片, 再也不怕图床跑路了, 大不了我换一家图床重新上传并发布一次. 这里先说图片上传的操作, **在上传之前拷贝原始博客以及本地和发布线上版本的 Hexo 配置** 可以在 [文章处理](#文章处理) 一节中查看. ```python import re import os import sys import shutil import subprocess from utils import find_all_image_tags, extract_image_url_from_tag, extract_image_urls_from_md, get_all_md_files, find_md_file, is_url def log(message): """ 打印中文日志信息。 """ print(f"日志:{message}") def upload_image(image_path): # 使用picgo命令上传图片,并获取输出 result = subprocess.run(['picgo', 'upload', image_path], capture_output=True, text=True) # 提取图床地址,只匹配以https开头的字符串 url_match = re.search(r'https://[^ ]+', result.stdout) if url_match: return url_match.group().strip() else: raise Exception(f"无法从输出中提取图床地址: {result.stdout}") def replace_image_tags_in_md(md_file, base_dir, publish_dir): print(f"正在处理Markdown文件:{md_file}") # 计算Markdown文件相对于base_dir的路径 relative_path = os.path.relpath(md_file, start=base_dir) # 构建发布目录下的Markdown文件路径 publish_md_file = os.path.join(publish_dir, relative_path) # 确保发布目录下的子目录存在 os.makedirs(os.path.dirname(publish_md_file), exist_ok=True) # 检查发布目录下的Markdown文件是否已存在 if os.path.exists(publish_md_file): print(f"文件已存在:{publish_md_file}") return # 文件存在,退出函数 # 复制原始Markdown文件到发布目录 shutil.copyfile(md_file, publish_md_file) # 读取发布目录下的Markdown文件内容 with open(publish_md_file, 'r', encoding='utf-8') as file: content = file.read() # 提取所有图片标签 image_tags = find_all_image_tags(md_file) for tag in image_tags: # 从标签中提取图片文件名 image_name = extract_image_url_from_tag(tag) # 如果 image_name 为空字符串,跳过当前循环 if not image_name or is_url(image_name): print(f"标签 {tag} 中未找到有效的图片路径或者已经是图床地址,跳过。") continue if image_name.startswith('/images'): # 图片路径以 /images 开头,需要在 source/images 目录下查找 source_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'source') image_path = os.path.join(source_dir, 'images', image_name[len('/images'):].lstrip('/')) else: # 图片路径不是以 /images 开头,直接在文章目录下查找 image_path = os.path.join(os.path.splitext(md_file)[0] , image_name) # 检查图片文件是否存在 if os.path.isfile(image_path): # 上传图片并获取图床地址 image_url = upload_image(image_path) # 只替换括号()内的内容 new_tag = re.sub(r'\(.*?\)', f'({image_url})', tag) content = content.replace(tag, new_tag) if image_name.startswith('/images'): log(f"替换 cover 图片地址") content = content.replace('cover: ' + image_name, 'cover: ' + image_url) log(f"替换标签 {tag} 为 {new_tag}") else: print(f"图片文件不存在: {image_path}") # 保存修改后的Markdown文件到发布目录 with open(publish_md_file, 'w', encoding='utf-8') as file: file.write(content) def main(): args = sys.argv[1:] script_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.join(script_dir, '..', 'source/_posts') # 构建发布目录路径,确保它在source/_posts下 publish_dir = os.path.join(base_dir, 'publish') # 确保发布目录存在 os.makedirs(publish_dir, exist_ok=True) log(f"博客文章的基准目录:{base_dir}") # 初始化要处理的Markdown文件列表 md_files_to_process = [] if not args: # 处理所有Markdown文件 md_files_to_process = get_all_md_files(base_dir, exclude_dir='publish') elif len(args) == 1 and args[0].isdigit(): # 处理指定年份的Markdown文件 year_dir = os.path.join(base_dir, args[0]) if os.path.isdir(year_dir): md_files_to_process = get_all_md_files(base_dir, exclude_dir='publish') else: log(f"年份目录 {args[0]} 不存在。") return elif len(args) == 1 and args[0].endswith('.md'): # 处理指定的Markdown文件 md_filename = args[0] md_file = find_md_file(base_dir, md_filename, exclude_dir='publish') if md_file: md_files_to_process.append(md_file) else: log(f"未找到Markdown文件 {md_filename}。") return else: log("参数数量错误。") return # 循环处理所有确定的Markdown文件 for md_file in md_files_to_process: replace_image_tags_in_md(md_file, base_dir, publish_dir) log("==================图片上传完成==================") if __name__ == "__main__": main() ``` ## 文章处理 ### 添加标签和摘要 自动为博客添加 tags 和 AI 摘要, 这个可以看 [[generate-blog-tags-using-ai|另一篇博客]]. ### 创建发布文件 我的需求是拷贝一份 md 文档, 在这个文件中进行图片标签替换, 那么现在的问题是我如何处理本地开发与线上环境的切换: 1. 在 Typora 写完后, 我使用 VSCode(或者命令行) 启动 Hexo 的服务端, 在 Web 端检查一下是否有问题, 这里使用的是本地的图片; 2. 发布到线上的时候, 需要拷贝一份原始的 md 文件, 上传完文件后替换这份文件中的图片标签, 最后发布到线上. 我的目录结构如下: ``` . ├── script │ ├── 其他各种脚本 │ └── generate_summary_and_tags_and_replace.py # 就是上面的脚本 ├── source │ ├── _posts │ │ ├── 2012 │ │ │ ├── demo1 │ │ │ ├── demo1.md │ │ │ ├── demo2 │ │ │ └── demo2.md │ │ ├── 2013 │ │ ├── 2014 │ │ ├── 2015 │ │ ├── 2016 │ │ ├── 2017 │ │ ├── 2018 │ │ ├── 2019 │ │ ├── 2020 │ │ ├── 2021 │ │ ├── 2022 │ │ ├── 2023 │ │ ├── 2024 │ │ └── publish │ │ ├── demo1.md │ │ └── demo2.md │ │ │ └── 其他目录 ├── makefile └── themes ``` `source/_posts` 目录下按照年划分子目录, 最后一个 `publish` 目录为最终发布到现在的版本, 此目录下只有处理完成后的 md 文件. 所以现在需要解决的问题是在本地预览时需要编译 `_posts` 目录下除 `publish` 之外的所有目录中的 md 文件, 而发布时只需要编译 `publish` 目录下的 md 文件. Hexo 有几个配置跟我的需求相关: 1. `skip_render`: 跳过指定文件的渲染, 匹配到的文件将会被不做改动地复制到 public 目录中. 但如果是 `_posts` 目录下的文件, 则是直接忽略编译, 也不会复制到 public 目录; 2. `include` , `ignore` 和 `exclude`: _include 和 exclude 选项只会应用到 `source/` ,而 ignore 选项会应用到所有文件夹._ **不能使用 `exclude` 来忽略 `source/_posts/` 中的文件**, 只能使用 `skip_render` 才能处理 `_posts` 的文件. 另外在文件名之前加一个下划线 `_` 也会被 Hexo 忽略. 明白了 Hexo 的忽略文件的规则后, 只有 `skip_render` 能满足我的需求, 所以接下来就是根据不同的环境使用不同的配置了. 作为 Spring Boot 的老手, Hexo 和 Spring Boot 的配置文件情况差不多: 都是通过 yaml 加载配置且允许存在多个配置(`_config.yml` 和 `_config.[theme].yml`) 类似于 `application.yml` 和 `application-prod.yml`, 那么肯定会存在一个配置优先级以及配置合并的操作, 那么我们可不可以通过不同的配置来实现根据 **根据环境来选择不同的配置从而实现发布不同的博文**? #### Hexo 配置 在翻看了 Hexo 的官方文档后, 跟我上面的猜想一样, 所以我们首先要了解一下 Hexo 如何加载配置以及配置的优先级. #### 指定配置文件 Hexo 可以在 `hexo-cli` 中使用 `--config` 参数来指定自定义配置文件的路径。 使用一个 YAML 或 JSON 文件的路径,也可以使用逗号分隔(无空格)的多个 YAML 或 JSON 文件的路径。 ``` # use 'custom.yml' in place of '_config.yml' $ hexo server --config custom.yml # use 'custom.yml' & 'custom2.json', prioritizing 'custom2.json' $ hexo server --config custom.yml,custom2.json ``` 当你指定了多个配置文件以后,Hexo 会按顺序将这部分配置文件合并成一个 `_multiconfig.yml`。 后面的值优先。 这个原则适用于任意数量、任意深度的 YAML 和 JSON 文件。 请注意: **列表中不允许有空格**。 如果 `custom.yml` 中指定了 `foo: bar`,在 custom2.json 中指定了 `"foo": "dinosaur"`,那么在 `_multiconfig.yml` 中你会得到 `foo: dinosaur`。 一句话总结: `--config` 中的配置文件的优先级越来越高. 所以我只需要把需要替换的配置放在最后即可. 最后一个问题是 `_config.yml` 和 `_config.[theme].yml` 的优先级: **Hexo 在合并主题配置时,Hexo 配置文件中的 `theme_config` 的优先级最高,其次是 `_config.[theme].yml` 文件。 最后是位于主题目录下的 `_config.yml` 文件。** 所以我们可以还原出 `hexo server` 加载配置的完整命令: ``` hexo server --config _config.yml,_config.[theme].yml ``` 所以我只需要在本地预览和发布时加载一个自定义配置即可. **预览命令**: ```bash hexo clean && hexo generate --config _config.yml,_config.anzhiyu.yml,_config.local.yml && hexo server --config _config.yml,_config.anzhiyu.yml,_config.local.yml ``` **发布命令**: ```bash hexo clean && hexo recommend --config _config.yml,_config.anzhiyu.yml,_config.publish.yml && hexo generate --config _config.yml,_config.anzhiyu.yml,_config.publish.yml ``` 区别是 `_config.local.yml` 和 `_config.publish.yml`: **\_config.local.yml** ```yaml skip_render: # 忽略 _posts/publish/** 中所有文件 - _posts/publish/** ``` **\_config.publish.yml** ```yaml skip_render: # 只处理 _posts/publish/** 文件, 其他全部忽略 - _posts/[0-9][0-9][0-9][0-9]/** # - _posts/publish/** ``` > 如果博客文章较多, 可以选择只预览指定的目录, 比如我现在新的博客在 2024 这个目录下, 意思是本地预览时只需要预览这个目录下新写的文章, 所以在 `_config.local.yml` 可以这样配置: > > ```yaml > skip_render: > # 注释即代表本次需要处理的目录, 其他的全部忽略 > - _posts/[0-9][0-9][0-9][0-9]/** > - _posts/publish/** > ``` > > 这样就只会编译 2024 这个目录下的 md 文件, 大大加快 Hexo 启动速度. **这节描述的文章处理需要在上传时拷贝 md 文件到 publish 目录, 逻辑在** [图片上传](#图片上传) 的脚本中. --- ## 部署到本地服务器 博客文章会同步部署到我本地的 M920x 服务器和 GitHub 上. M920x 服务器通过 Nginx 提供了静态站点, 我只需要将编译后的文件上传到指定目录即可, 下面是一个简单的脚本, 通过 `rsync` 增量上传文件: ```bash #!/bin/bash # 获取当前脚本的所在目录 SCRIPT_DIR=$(dirname "$(realpath "$0")") # 切换到 Makefile 所在的工作目录 (即脚本所在目录的父目录) cd "$SCRIPT_DIR/.." || exit 1 # 定义变量 REMOTE_HOST="m920x" # m920x Nginx REMOTE_DIR="/opt/1panel/apps/openresty/openresty/www/sites/blog/index" LOCAL_DIR="public" # 上传 public 中所有的文件 # 生成最新的文件 echo "正在执行 hexo clean && hexo g 以生成最新的文件..." hexo clean && hexo recommend --config _config.yml,_config.anzhiyu.yml,_config.publish.yml && hexo generate --config _config.yml,_config.anzhiyu.yml,_config.publish.yml # 检查 public 目录是否生成成功 if [ ! -d "$LOCAL_DIR" ]; then echo "public 目录生成失败,请检查 Hexo 配置!" exit 1 fi # 上传文件到远程并覆盖 echo "正在上传 public 目录下的所有文件到 $REMOTE_HOST:$REMOTE_DIR..." rsync -azqhP --delete \ --exclude '.DS_Store' \ --exclude '._*' \ --exclude '__MACOSX' \ "$LOCAL_DIR/" "$REMOTE_HOST:$REMOTE_DIR" | tee /dev/null # 检查上传是否成功 if [ $? -eq 0 ]; then echo "文件上传成功!" else echo "文件上传失败,请检查连接或权限配置。" exit 1 fi ``` > 如果使用 Hexo 的 [hexo-deployer-rsync](https://github.com/hexojs/hexo-deployer-rsync) 插件(`npm install hexo-deployer-rsync --save`) 替换上面的脚本部署到服务器上, 需要使用下面的命令: > > ```bash > hexo clean && hexo deploy --config _config.yml,_config.anzhiyu.yml,_config.publish.yml > ``` > > 这里再补充一下插件的完整配置: > > ```yaml > deploy: > - type: rsync > host: m920x > user: root > root: /opt/1panel/apps/openresty/openresty/www/sites/blog/index # 实际的 hexo 部署目录 > port: 22 # 默认端口 22 > delete: true # 同步删除云服务器上的文件 > progress: true # 显示同步进度 > args: --exclude='.DS_Store' --exclude='._*' --exclude='__MACOSX' # 添加要忽略的文件 > rsh: # 可选,指定要使用的远程 shell > key: # 可选,自定义 SSH 私钥路径 > verbose: true # 显示详细信息 > ignore_errors: false # 不忽略错误 > create_before_update: false # 首先创建不存在的文件,然后更新现有文件 > ``` 这里补充一下 `hexo deploy` 的执行逻辑: > `hexo deploy` 执行时会先编译文件, 然后将 `public` 目录下的所有文件拷贝到同级目录下的 `.deploy_git` 中, 然后再推送到指定的地方, 比如配置的服务器目录或 GitHub 仓库. --- ## 部署到 GitHub 这里直接使用插件来完成 GitHub 的部署: ```bash npm install hexo-deployer-git --save ``` **配置如下:** ```bash ## Docs: https://hexo.io/docs/one-command-deployment deploy: - type: git repo: https://github.com/{username}/{username}.github.io branch: master ``` **部署命令**: ```bash hexo clean && hexo deploy --config _config.yml,_config.anzhiyu.yml,_config.publish.yml ``` 如何使用 GitHub Pages 部署 Hexo 可以参考其他文章, 这里不再赘述: - [在 GitHub Pages 上部署 Hexo](https://hexo.io/zh-cn/docs/github-pages) - [使用 Github Action 自动部署](https://blog.anheyu.com/posts/asdx.html) --- ## 备份 在 [[home-data|HomeLab存储与备份:数据堡垒-保障数据和隐私的存储解决方案]] 中有讲到如何使用 **3-2-1 备份原则** 来指导如何备份重要文件. 这里的原始文件文件我已经使用 **Synology Drive Client** 的备份功能备份到了 NAS 上, 那剩下的就是云端备份了, 这里当然是白嫖 GitHub 和 Gitee 了. 同时推送到 GitHub 和 Gitee: ```bash #!/bin/bash # 获取当前脚本的所在目录 SCRIPT_DIR=$(dirname "$(realpath "$0")") # 切换到 Makefile 所在的工作目录 (即脚本所在目录的父目录) cd "$SCRIPT_DIR/.." || exit 1 # 使用第一个参数作为提交信息,如果未提供参数,则使用默认信息 COMMIT_MESSAGE=${1:-"摘要生成"} # 执行 Git 操作 git add . git commit -m "$COMMIT_MESSAGE" git push -u github main git push -u gitee main #!/bin/bash # 获取当前脚本的所在目录 SCRIPT_DIR=$(dirname "$(realpath "$0")") # 切换到 Makefile 所在的工作目录 (即脚本所在目录的父目录) cd "$SCRIPT_DIR/.." || exit 1 # 使用第一个参数作为提交信息,如果未提供参数,则使用默认信息 COMMIT_MESSAGE=${1:-"摘要生成"} # 执行 Git 操作 git add . git commit -m "$COMMIT_MESSAGE" git push -u github main git push -u gitee main ``` 因为一开始提交了大量图片, 导致触发了 Gitee 的仓库容量限制阈值, 解决办法可以看 [[git-clean|解决 git 仓库体积过大导致 push 失败的问题]]. --- ## 部署流程化 前面的步骤都是独立运行的, 为了将整个流程串起来, 我使用了 **makefile**, 在 VSCode 中需要安装 ~~[Makefile buttons](https://marketplace.visualstudio.com/items?itemName=hablof.makefile-buttons)~~ (推荐使用[vscode-makefile-term](https://github.com/lfmunoz/vscode-makefile-term) 来运行)插件来支持运行流程: ![20241231185714_Kvn3dfgp.webp](https://cdn.dong4j.site/source/image/20241231185714_Kvn3dfgp.webp) **makefile** 配置如下: ```bash # 定义伪目标,避免与文件名冲突 .PHONY: clean_images convert_and_rename upload_images generate_summary_tags push deploy-m920x deploy-github clean ########## 需要终端在 hexo 顶层目录才能正常执行 # 默认目标 all: clean_images convert_and_rename upload_images generate_summary_tags push deploy-m920x deploy-github clean # 本地运行 dev: @echo "==================Step 5: Deploying application==================" hexo clean && hexo generate --config _config.yml,_config.anzhiyu.yml,_config.local.yml && hexo server --config _config.yml,_config.anzhiyu.yml,_config.local.yml # 删除未被引用的图片, 不传任何参数则全部处理, 传 2023 则只处理 2023 目录下的文件, 传 md 文件名, 则只处理这一个文件 clean_images: @echo "==================Step 1: Cleaning images==================" python script/clean_images.py # 将图片转换为 webp 且重命名(年月日时分秒_8位随机字符串.webp) convert_and_rename: @echo "==================Step 2: Cleaning images==================" python script/convert_and_rename.py # 上传图片 upload_images: @echo "==================Step 3: Cleaning images==================" python script/upload_images.py # 生成摘要和标签 generate_summary_tags: @echo "==================Step 3: Cleaning images==================" python script/generate_summary_and_tags_and_replace.py # 执行 git-push.sh push: @echo "==================Step 4: Pushing changes to Git==================" script/git-push.sh "恢复被删除的 svg" # 执行 deploy.sh deploy-m920x: push @echo "==================Step 5: Deploying application==================" script/deploy.sh # 发布到 github deploy-github: push @echo "==================Step 6: Deploying Github==================" hexo deploy clean: @echo "==================Step 7: Cleaning up==================" hexo clean && rm -rf .deploy_git ``` 可以直接在 **makefile** 点击 **all** 执行所有流程, 也可以在命令行中执行: ```bash make all ``` 当然每个流程也支持独立执行, 这样就不用再去记忆大量的命令. ## 总结 ![20250102025932_4SCvYvPO.webp](https://cdn.dong4j.site/source/image/20250102025932_4SCvYvPO.webp) ![20250102025932_NrFdBkVf.webp](https://cdn.dong4j.site/source/image/20250102025932_NrFdBkVf.webp) 以上就是我的博客的整个工作流程, 以后还会增加更多的处理步骤, 比如使用 AI 自动生成分类, 使用 AI 修改错别字等等操作, 我只需要在 `script` 中新增脚本, 然后添加到 **makefile** 的流程中即可. 以上脚本还值得优化, 比如提出公共方法到 `utils.py` 中, 这个后续会慢慢迭代 博客中涉及到的所有脚本已上传到 [仓库](https://github.com/dong4j/hexo-deploy-workflow), 可根据自己的情况自行修改. ## [群晖 NAS Docker 网络优化:配置 HTTP/SOCKS5 代理的终极指南](https://blog.dong4j.site/posts/13a784a6.md) 如果你使用的是群晖 NAS (本文在 DSM 7.2 版本测试通过),可能会遇到无法下载 Docker 镜像的情况。通过配置代理,你可以轻松绕过这些限制,顺利下载和更新镜像。本教程教你如何为 Docker 设置代理,让 Docker 本身的网络请求走你指定的 HTTP 或 SOCKS5 代理服务器。 #### 为什么需要代理? 国内目前网络环境可能会限制你访问 Docker 镜像库(例如 `docker pull` 等操作),尤其是官方的国外镜像库。配置 HTTP 或 SOCKS5 代理可以让你绕过这些限制,顺畅地拉取镜像。 ### 操作步骤 #### 1. 登录到群晖 NAS 首先,你需要通过 SSH 连接到你的群晖 NAS。在 DSM 中,进入 **控制面板 > 终端机与 SNMP**,启用 SSH 服务。然后打开命令行工具(Windows 用 PuTTY,Mac 或 Linux 用 Terminal),输入以下命令:(命令回车执行) > [[CMD] Windows SSH 连接服务器教程 (系统自带方式)](https://www.itblogcn.com/article/2266.html) 替换 `<用户名>` 为你的群晖管理员用户名,`` 为 NAS 的 IP 地址。接着切换到 root 权限(需要输入管理员密码): #### 2. 为 Docker 创建一个配置文件夹 在 SSH 终端执行以下命令: ```bash ssh <用户名>@ ``` #### 3. 创建并编辑代理配置文件 创建一个配置文件来存放代理信息: ```bash sudo -i ``` 用 vi 文本编辑器打开它: ```bash mkdir -p /etc/systemd/system/pkg-ContainerManager-dockerd.service.d ``` 进入插入模式:按下 i 键,这时你会看到底部出现 — INSERT — 的提示,表示可以开始编辑文本。 输入或粘贴内容: 在文件中添加以下内容,设置 HTTP 和 HTTPS 代理:(`shift+insert`可粘贴) ```bash touch /etc/systemd/system/pkg-ContainerManager-dockerd.service.d/http-proxy.conf ``` 如果你使用的是 Socks5 代理,改为: ```bash vi /etc/systemd/system/pkg-ContainerManager-dockerd.service.d/http-proxy.conf ``` 如果需要认证,格式如下: ```bash [Service] Environment="HTTP_PROXY=http://192.168.1.3:10808" Environment="HTTPS_PROXY=http://192.168.1.3:10808" Environment="NO_PROXY=localhost,127.0.0.1" ``` 注意:替换 192.168.1.3:10808 和 用户名: 密码 为你自己的信息。 保存并退出: - 按 Esc 键,退出插入模式。 - 输入 :wq 然后按 Enter 键,表示保存并退出。 如果在 vi 中不小心进入了错误模式,你可以按 Esc 键,然后输入 :q! 再按 Enter 退出而不保存任何更改。 #### 4. 重新加载并重启 Docker 服务 完成配置后,需要重新加载一下配置文件,并重启 Docker 服务: ```bash [Service] Environment="HTTP_PROXY=socks5://192.168.1.3:10808" Environment="HTTPS_PROXY=socks5://192.168.1.3:10808" Environment="NO_PROXY=localhost,127.0.0.1" ``` #### 5. 验证代理是否设置成功 接下来,我们验证一下代理设置是否生效。使用以下命令查看 Docker 的环境变量: ```bash [Service] Environment="HTTP_PROXY=socks5://用户名:密码@192.168.1.3:10808" Environment="HTTPS_PROXY=socks5://用户名:密码@192.168.1.3:10808" Environment="NO_PROXY=localhost,127.0.0.1" ``` 如果显示的内容中有你刚刚设置的代理信息,说明配置成功: ```bash systemctl daemon-reload systemctl restart pkg-ContainerManager-dockerd.service ``` #### 6. 测试 Docker 代理 最后,我们通过拉取镜像测试代理是否生效。运行以下命令: 如果镜像能正常下载,说明 Docker 已经通过代理成功连接网络。 另外如果要在 Docker 容器内通过代理访问网络,你可以在运行 Docker 容器时,设置环境变量来指定代理,例如: ```bash systemctl show --property=Environment pkg-ContainerManager-dockerd.service ``` ### 总结 恭喜!你已经成功为 Docker 设置了代理。在受限网络环境中,代理是非常有用的工具,帮助你顺利拉取镜像。按照本教程,你只需登录 NAS,创建并编辑配置文件,然后重启 Docker 服务,就能让 Docker 使用代理了。 希望这个简单的指南对你有帮助!赶快试试吧~ ### 参考 - [为群晖 Container Manager 配置代理](https://blog.chai.ac.cn/posts/docker-proxy) - [怎样才能让我的 docker 走代理](https://v2ex.com/t/874777) ## [图片过多导致GitHub/Gitee仓库爆仓?这里有解决方案!](https://blog.dong4j.site/posts/d4c941b5.md) ![/images/cover/20241229170536_bsenV6FP.webp](https://cdn.dong4j.site/source/image/20241229170536_bsenV6FP.webp) ## 背景 最近在进行博客迁移, 以前吃过图床的亏, 所以这次将图片全部保留在本地, 并使用 GitHub 和 Gitee 来作备份, 但是因为大量的图片提交导致出发了 Gitee 的仓库体积限制, 在最近几次提交时, 出现了如下错误: ```bash remote: Powered by GITEE.COM [1.1.5] remote: Set trace flag 784c0784 remote: Repo size: 1077.199MB, exceeds quota 1024MB remote: Push rejected for repository [size exceeds limit] remote: HelpLink: https://gitee.com/help/articles/4232 remote: Repository GC: https://gitee.com/xxx/hexo-site/settings#git-gc remote: Enterprise Edition: https://gitee.com/enterprises#commerces To gitee.com:dong4j/hexo-site.git ! [remote rejected] main -> main (pre-receive hook declined) error: failed to push some refs to 'gitee.com:xxx/hexo-site.git' ``` ## 解决 根据 Gitee 的官方文档介绍, Gitee 平台目前对仓库的配额如下: | 套餐 | 免费版 | 基础版 | 标准版 | 高级版 | 尊享版 | | ------ | ----------- | ----------- | ----------- | ----------- | ----------- | | 单仓库 | 最大 500 MB | 最大 1 GB | 最大 1 GB | 最大 2 GB | 最大 3 GB | | 单文件 | 最大 50 MB | 最大 100 MB | 最大 100 MB | 最大 200 MB | 最大 300 MB | ### 1. 清理不必要的对象 首先可以尝试清理一些不必要的 Git 对象: ```bash git gc --prune=now ``` 这个命令会压缩 Git 仓库中的历史,清理松散对象,并删除无法访问的对象。 > 存储库 GC 可以检查仓库中未使用的对象和资源,将其删除或压缩成更小的对象,从而优化 Git 在存储库性能,减少存储库磁盘占用。 > > 当仓库体积膨胀导致访问速度下降时,通过 Git GC 将可能提高仓库的访问速度。 > > 阶段时间内使用多次强推从仓库中删除了大量数据,使用 Git GC 可以删除未使用的对象,释放存储空间。 ![20241228154907_R1PsKEVu.webp](https://cdn.dong4j.site/source/image/20241228154907_R1PsKEVu.webp) 执行后并没有减少仓库体积, 所以还要继续. ### 2. 删除大文件的历史记录 前面说了我的仓库提交了很多图片和少量 mp4 等大体积文件, 所以我们可以使用 `git-filter-repo` 工具来删除它们的历史记录。 在 macOS 下安装 `git-filter-repo`: ```bash brew install git-filter-repo ``` 然后可以运行以下命令来删除特定文件类型的历史记录: ```bash git filter-repo --invert-paths --path-regex '\.(jpg|jpeg|png|gif|mp4|webp|svg)$' --force ``` 执行之后的效果: ![20241228155218_2lI2gpOj.webp](https://cdn.dong4j.site/source/image/20241228155218_2lI2gpOj.webp) 清理效果非常好, **但是** 把我仓库里面的所有图片全部清空了, 我要的是只删除历史记录中提交的图片..... 还有我有备份, 从来吧.. ### 3. bfg-repo-cleaner 这次使用一个开源项目 [bfg-repo-cleaner](https://github.com/rtyley/bfg-repo-cleaner?tab=readme-ov-file), 下载 jar 文件然后重命名为 **bfg.jar**, 比如我要删除大于 1M 的所有历史记录: ```bash $ java -jar bfg.jar --strip-blobs-bigger-than 100M some-big-repo.git $ cd some-big-repo.git $ git reflog expire --expire=now --all && git gc --prune=now --aggressive ``` 效果还不错: ![20241229144920_hpRQYnln.webp](https://cdn.dong4j.site/source/image/20241229144920_hpRQYnln.webp) [其他示例](https://rtyley.github.io/bfg-repo-cleaner/) ## [ComfyUI Desktop 安装攻略:自动化安装失败问题解决](https://blog.dong4j.site/posts/85d50e5.md) ![/images/cover/20241229132734_VH9upmpx.webp](https://cdn.dong4j.site/source/image/20241229132734_VH9upmpx.webp) ## 简介 最近 [ComfyUI Desktop](https://github.com/Comfy-Org/desktop) 发布了 Bate 版本,但是安装的时候遇到了一些问题,记录一下。 ## 问题 这次我尝试第二次安装 ComfyUI-Desktop,版本 0.3.33(241212)。自动安装没有成功。在查看日志并手动运行缺失的命令后,安装成功。想给遇到同样问题的朋友一个解决方法。我不知道这是否是一个 bug,但它在我的机器上没有正确部署。 ## 解决 [这是我第一次安装时遇到的问题](https://github.com/Comfy-Org/desktop/issues/398),似乎 0.3.33 版本已经修复了它,但我在 zsh 下仍然遇到问题, 所以这次我将仅使用初始 `.zshrc` 文件: ```bash export ZSH="$HOME/.oh-my-zsh" ZSH_THEME="robbyrussell" plugins=(git) source $ZSH/oh-my-zsh.sh ``` 然后尝试首次启动: ![20241229144920_LQXrM6d9.webp](https://cdn.dong4j.site/source/image/20241229144920_LQXrM6d9.webp) 日志如下: ``` [2024-12-14 22:01:21.481] [info] Running command: /Users/dong4j/comfy/ComfyUI/.venv/bin/python -m ensurepip --upgrade in /Users/dong4j/comfy/ComfyUI [2024-12-14 22:01:23.353] [info] Successfully created virtual environment at /Users/dong4j/comfy/ComfyUI/.venv [2024-12-14 22:01:23.354] [info] Installing PyTorch Nightly for macOS. [2024-12-14 22:01:23.354] [info] Running uv command: /Applications/ComfyUI.app/Contents/Resources/uv/macos/uv pip install -U --prerelease allow torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/nightly/cpu ... [2024-12-14 22:01:23.367] [info] �]2;clear; /Applications/ComfyUI.app/Contents/Resources/uv/macos/uv "pip" "-U" ��]1;clear;� [2024-12-14 22:01:23.369] [info] [2024-12-14 22:01:23.375] [info] error: unexpected argument '--end-1734184883354:0' found tip: to pass '--end-1734184883354:0' as a value, use '-- --end-1734184883354:0' Usage: uv pip install --upgrade --prerelease |--editable > <--index |--default-index |--index-url |--extra-index-url |--find-links |--no-index> For more information, try '--help'. ``` **关键点是**: ```bash /Applications/ComfyUI.app/Contents/Resources/uv/macos/uv pip install -U --pre torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/nightly/cpu ``` 应该是之前尝试安装过 [PyTorch](https://developer.apple.com/metal/pytorch/),但是不知道什么原因失败了,所以直接使用了虚拟环境中安装的 python(启动 ComfyUI-Desktop 时设置的目录:Users/dong4j/comfy): ```bash /Users/dong4j/comfy/ComfyUI/.venv/bin/pip3 install -U --pre torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/nightly/cpu ``` 执行完成后可以进行如下测试: ```python import torch if torch.backends.mps.is_available(): mps_device = torch.device("mps") x = torch.ones(1, device=mps_device) print (x) else: print ("MPS device not found.") ``` 测试结果: ```bash /Users/dong4j/comfy/ComfyUI/.venv/bin/python torch_test.py tensor([1.], device='mps:0') ``` 安装后的软件包列表: ``` ➜ ComfyUI /Users/dong4j/comfy/ComfyUI/.venv/bin/pip3 list Package Version ----------------- ------------------ filelock 3.16.1 fsspec 2024.10.0 Jinja2 3.1.4 MarkupSafe 3.0.2 mpmath 1.3.0 networkx 3.4.2 numpy 2.2.0 pillow 11.0.0 pip 24.0 setuptools 75.6.0 sympy 1.13.1 torch 2.6.0.dev20241214 torchaudio 2.6.0.dev20241214 torchvision 0.22.0.dev20241214 typing_extensions 4.12.2 ``` 然后再次重启 ComfyUI-Desktop:然后再次重启 ComfyUI-Desktop: ![20241229144920_DtK80vkZ.webp](https://cdn.dong4j.site/source/image/20241229144920_DtK80vkZ.webp) 继续进行后续安装步骤应该就正常了,但是还缺少一些软件包,此时的软件包列表如下: ``` ➜ ComfyUI /Users/dong4j/comfy/ComfyUI/.venv/bin/pip3 list Package Version ------------------ ------------------ certifi 2024.12.14 cffi 1.17.1 charset-normalizer 3.4.0 click 8.1.7 cryptography 44.0.0 Deprecated 1.2.15 filelock 3.16.1 fsspec 2024.10.0 gitdb 4.0.11 GitPython 3.1.43 huggingface-hub 0.26.5 idna 3.10 Jinja2 3.1.4 markdown-it-py 3.0.0 MarkupSafe 3.0.2 matrix-client 0.4.0 mdurl 0.1.2 mpmath 1.3.0 networkx 3.4.2 numpy 1.26.4 packaging 24.2 pillow 11.0.0 pip 24.0 pycparser 2.22 PyGithub 2.5.0 Pygments 2.18.0 PyJWT 2.10.1 PyNaCl 1.5.0 PyYAML 6.0.2 regex 2024.11.6 requests 2.32.3 rich 13.9.4 safetensors 0.4.5 setuptools 75.6.0 shellingham 1.5.4 smmap 5.0.1 sympy 1.13.1 tokenizers 0.21.0 torch 2.6.0.dev20241214 torchaudio 2.6.0.dev20241214 torchvision 0.22.0.dev20241214 tqdm 4.67.1 transformers 4.47.0 typer 0.15.1 typing_extensions 4.12.2 urllib3 1.26.20 wrapt 1.17.0 ``` 这次我尝试直接使用 ComfyUI 的 `requirements.txt` 安装依赖项(之前我已经尝试过一步一步手动安装依赖项并成功启动,按照提示安装:psutil、einops、scipy、torchsde、aiohttp、spandrel、kornia [使用`~/comfy/ComfyUI/.venv/bin/pip3 install xxx`]): ```bash ~/comfy/ComfyUI/.venv/bin/pip3 install -r /Applications/ComfyUI.app/Contents/Resources/ComfyUI/requirements.txt ``` 接下来是第三次发射: ![20241229144920_8l1KQne1.webp](https://cdn.dong4j.site/source/image/20241229144920_8l1KQne1.webp) ![20241229144920_0WkEqAyt.webp](https://cdn.dong4j.site/source/image/20241229144920_0WkEqAyt.webp) ## 总结 1. PyTorch 安装失败,导致后续步骤无法执行; 2. 无法自动安装其他依赖项。 ## [智能化的内容管理工具:Hoarder的AI书签革命](https://blog.dong4j.site/posts/90130153.md) ![/images/cover/20241231185355_QB2Byvhq.webp](https://cdn.dong4j.site/source/image/20241231185355_QB2Byvhq.webp) ## 简介 在这个信息泛滥的时代,我们每天都在互联网上接收到大量的内容,包括吸引人的文章、实用的工具和转瞬即逝的灵感。我们都希望能够随时访问这些内容。 然而,我过去习惯使用 Things、Memos 或 Reeder 来收集这些内容,但随着时间的推移,收藏的文章数量越来越多,查找起来变得非常麻烦。而且,另一个让我不满意的地方是,这些工具只能展示链接,而无法显示标题和简要内容,这给后来的整理工作带来了一些困扰。 更多的时候,我们只是在收藏这些信息,却从未再次打开过它们,这并没有解决我们对信息管理的根本问题。 因此,今天我要介绍的是 Hoarder——一款专为数据收集者量身定做的自托管书签应用。它集成了 AI 智能标签和强大的全文搜索功能,彻底改变了我们的信息处理和保存方式。 想象一下,无论是深夜阅读到的深度文章还是清晨灵感迸发的图片,都可以轻松一键保存,随时随地进行自由访问。即使在无法一键保存的情况下,将链接复制到 Hoarder 中也能显示标题、图片等关键信息,这使得它比 Things、Memos 这类工具更加好用。 ## Hoarder 是什么 [Hoarder](https://github.com/hoarder-app/hoarder) 是一个开源的 “Bookmark Everything” 应用程序,专门为需要收集和整理书签信息的人而设计的。它除了手动自定义分类标签,还支持利用人工智能 AI 技术,帮助我们快速保存和管理链接、笔记或者图片。同时它还可以在多个平台上使用,包括浏览器扩展和手机应用。 它具有以下亮点特征: - 🔗 为链接添加书签、做简单的笔记并存储图像和 pdf。 - ⬇️ 自动获取链接标题、描述和图像。 - 📋 将书签排序到列表中。 - 🔎 对存储的所有内容进行全文搜索。 - ✨ 基于 AI(又名 chatgpt)的自动标记。支持使用 ollama 的本地模型! - 🎆 OCR 用于从图像中提取文本。 - 🔖 用于快速书签的 [Chrome 插件](https://chromewebstore.google.com/detail/hoarder/kgcjekpmcjjogibpjebkhaanilehneje)和 [Firefox 插件](https://addons.mozilla.org/en-US/firefox/addon/hoarder/)。 - 📱 一个 [iOS 应用程序和](https://apps.apple.com/us/app/hoarder-app/id6479258022)一个 [Android 应用程序](https://play.google.com/store/apps/details?id=app.hoarder.hoardermobile&pcampaignid=web_share)。 - 📰 来自 RSS 源的自动囤积。 - 🌐 REST API 的 API 中。 - 🗄️ 整页存档(使用[单体](https://github.com/Y2Z/monolith))以防止链接腐烂。使用 [youtube-dl](https://github.com/marado/youtube-dl) 自动存档视频。 - ☑️ 批量操作支持。 - 🔐 SSO 支持。 - 🌙 深色模式支持。 - 💾 首先是自托管。 - [计划]下载内容以供离线阅读。 它还提供了演示 Demo,有兴趣的小伙伴可以先体验,再部署:[https://try.hoarder.app/signin](https://try.hoarder.app/signin) ![20250103120629_U3v2klcD.webp](https://cdn.dong4j.site/source/image/20250103120629_U3v2klcD.webp) --- ## 如何使用 Hoarder 按照官方文档使用 docker-compose 快速的实现服务部署. ### 本地部署 ```yml version: "3.8" services: web: image: ghcr.io/hoarder-app/hoarder:${HOARDER_VERSION:-release} restart: unless-stopped volumes: - ./data:/data ports: - 3333:3000 env_file: - .env environment: MEILI_ADDR: http://meilisearch:7700 BROWSER_WEB_URL: http://chrome:9222 DATA_DIR: /data chrome: image: gcr.io/zenika-hub/alpine-chrome:123 restart: unless-stopped command: - --no-sandbox - --disable-gpu - --disable-dev-shm-usage - --remote-debugging-address=0.0.0.0 - --remote-debugging-port=9222 - --hide-scrollbars meilisearch: image: getmeili/meilisearch:v1.11.1 restart: unless-stopped env_file: - .env environment: # 禁用匿名数据收集 MEILI_NO_ANALYTICS: "true" volumes: - ./meili:/meili_data volumes: meilisearch: data: ``` **.env** ``` HOARDER_VERSION=release NEXTAUTH_SECRET=[36 位随机字符串] MEILI_MASTER_KEY=[36 位随机字符串] NEXTAUTH_URL=http://localhost:3000 ``` 在你注册完账号后,在环境变量里 `禁用账号注册` 功能。前面 `.env` 部分加入这句话: ``` DISABLE_SIGNUPS=true ``` --- ### AI 加持 Hoarder 一大亮点是通过 AI 为收藏的链接生成标签, 要使用这个功能, 需要设置 AI 服务: ```yaml environment: # 使用 Ollama 提供 AI 服务 OLLAMA_BASE_URL: http://192.168.31.5:11434 INFERENCE_TEXT_MODEL: glm4 INFERENCE_LANG: chinese ``` 我这里使用了 Ollama 来提供 AI 服务, 使用 **glm4** 这个模型.. 在 Mac mini M2 上部署 Ollama, 如果需要给其他主机提供服务, 还需要进行一些特殊设置: ```bash # 允许跨域 launchctl setenv OLLAMA_ORIGINS "*" # 修改 bind launchctl setenv OLLAMA_HOST "0.0.0.0" ``` 因为我的 Mac mini M2 SSD 只有 256G, 目前已经没有多少剩余空间可使用, 所以我将 LLM 模型上传到 DS923+, 然后通过 SMB 挂载到本地, 所以还需要修改 Ollama 的模型地址: ```bash # 更改模型位置 launchctl setenv OLLAMA_MODELS "/Volumes/AI/models/ollama" ``` 如果使用 Docker 部署 Ollama, 可能会遇到以下错误: ``` httpconnectionpool(host=127.0.0.1, port=11434): max retries exceeded with url:/cpi/chat (Caused by NewConnectionError(': fail to establish a new connection:[Errno 111] Connection refused')) httpconnectionpool(host=localhost, port=11434): max retries exceeded with url:/cpi/chat (Caused by NewConnectionError(': fail to establish a new connection:[Errno 111] Connection refused')) ``` 这个错误是因为 Docker 容器无法访问 Ollama 服务。localhost 通常指的是容器本身,而不是主机或其他容器。要解决此问题,你需要将 Ollama 服务暴露给网络。 **在 Mac 上设置环境变量**: 如果 `Ollama` 作为 `macOS` 应用程序运行,则应使用以下命令设置环境变量`launchctl`: 1. 通过调用`launchctl setenv`设置环境变量: ``` launchctl setenv OLLAMA_HOST "0.0.0.0" ``` 2. 重启 Ollama 应用程序。 3. 如果以上步骤无效,可以使用以下方法: 问题是在 docker 内部,你应该连接到`host.docker.internal`,才能访问 docker 的主机,所以将`localhost`替换为`host.docker.internal`服务就可以生效了: ``` http://host.docker.internal:11434 ``` **在 Linux 上设置环境变量**: 如果 Ollama 作为 systemd 服务运行,应该使用`systemctl`设置环境变量: 1. 通过调用`systemctl edit ollama.service`编辑 systemd 服务。这将打开一个编辑器。 2. 对于每个环境变量,在`[Service]`部分下添加一行`Environment`: ``` [Service] Environment="OLLAMA_HOST=0.0.0.0" ``` 3. 保存并退出。 4. 重载`systemd`并重启 Ollama: ``` systemctl daemon-reload systemctl restart ollama ``` > #### Ollama 的可用环境变量 > > ``` > HTTPS_PROXY > HTTP_PROXY > NO_PROXY > OLLAMA_DEBUG:false > OLLAMA_FLASH_ATTENTION:false > OLLAMA_GPU_OVERHEAD:0 > OLLAMA_HOST:http://0.0.0.0:11434 > OLLAMA_KEEP_ALIVE:5m0s > OLLAMA_KV_CACHE_TYPE: > OLLAMA_LLM_LIBRARY: > OLLAMA_LOAD_TIMEOUT:5m0s > OLLAMA_MAX_LOADED_MODELS:0 > OLLAMA_MAX_QUEUE:512 > OLLAMA_MODELS:/Volumes/AI/models/ollama > OLLAMA_MULTIUSER_CACHE:false > OLLAMA_NOHISTORY:false > OLLAMA_NOPRUNE:false > OLLAMA_NUM_PARALLEL:0 > OLLAMA_ORIGINS:[http://localhost https://localhost http://localhost:* https://localhost:* http://127.0.0.1 https://127.0.0.1 http://127.0.0.1:* https://127.0.0.1:* http://0.0.0.0 https://0.0.0.0 http://0.0.0.0:* https://0.0.0.0:* app://* file://* tauri://* vscode-webview://*] > OLLAMA_SCHED_SPREAD:false > http_proxy: > https_proxy: > no_proxy: > ``` > > - **OLLAMA_HOST:**设置网络监听端口。当我们设置 OLLAMA_HOST 为 0.0.0.0 时,就相当于开放端口,可以让人意外部网络访问。 > - **OLLAMA_MODELS:**设置模型的存储路径。当我们设置 OLLAMA_MODELS=F:\OllamaCache,就相当于给模型们在 F 盘建了一个仓库,让它们远离 C 盘。 > - **OLLAMA_KEEP_ALIVE:** 它决定了我们的模型们可以在内存里的存活时间。设置 OLLAMA_KEEP_ALIVE=24h,就好比给模型们装上了一块超大容量电池,让它们可以连续工作 24 小时,时刻待命。 > - **OLLAMA_PORT:**用来修改 ollama 的默认端口,默认是 11434,可以在这里改为你想要的端口。 > - **OLLAMA_NUM_PARALLEL:**限制了 Ollama 可以同时加载的模型数量 > - **OLLAMA_MAX_LOADED_MODELS:**可以确保系统资源得到合理分配。 #### AI 设置 在 Web 端可以设置 AI 提示词: ![20250103152259_NHFYurFM.webp](https://cdn.dong4j.site/source/image/20250103152259_NHFYurFM.webp) ### RSS 订阅 Hoarder 同时支持 RSS 订阅, 但是我并不打算使用这个功能, Hoarder 更多的还是定位为链接管理, RSS 订阅我会使用 Reeder 和 Follow. ![20250103152306_QlbeRrH4.webp](https://cdn.dong4j.site/source/image/20250103152306_QlbeRrH4.webp) ### API 密钥管理 Hoarder 会自动为使用账号登录的客户端生成密钥, 如果使用 Alfred 等第三方社区应用, 需要手动添加 API 密钥: ![20250103150455_KhpEUxay.webp](https://cdn.dong4j.site/source/image/20250103150455_KhpEUxay.webp) ### 编辑链接 ![20250103152319_Ln4wOeCn.webp](https://cdn.dong4j.site/source/image/20250103152319_Ln4wOeCn.webp) AI 根据文章内容做出的分类标签,也可以自行添加`自定义的标签`,以及`备注信息` 同时还可以修改标题, Banner 和 截图: ![20250103152326_SIUDVF1X.webp](https://cdn.dong4j.site/source/image/20250103152326_SIUDVF1X.webp) ### 标签管理 你能够在 **标签** 页面管理所有的标签: ![20250103152333_ksDGtMGS.webp](https://cdn.dong4j.site/source/image/20250103152333_ksDGtMGS.webp) ### 搜索 此功能使用 [Meilisearch](https://www.meilisearch.com/?utm_campaign=oss&utm_source=github&utm_medium=meilisearch&utm_content=intro) 实现, 因为目前的收藏还比较少, 还没有凸显出 Meilisearch 的优势: ![20250103151317_6VJmmmyj.webp](https://cdn.dong4j.site/source/image/20250103151317_6VJmmmyj.webp) ## Hoarder 生态 ### 浏览器插件 ![20250103141301_utK0Ks22.webp](https://cdn.dong4j.site/source/image/20250103141301_utK0Ks22.webp) 配置你自己的服务器地址: ![20250103141401_stmVk7Uk.webp](https://cdn.dong4j.site/source/image/20250103141401_stmVk7Uk.webp) ### APP - [iOS 应用程序](https://apps.apple.com/us/app/hoarder-app/id6479258022) - [Android 应用程序](https://play.google.com/store/apps/details?id=app.hoarder.hoardermobile&pcampaignid=web_share) ### 社区项目 1. Raycast [扩展](https://www.raycast.com/luolei/hoarder) 一款用户友好的 Raycast 扩展程序,可与 Hoarder 无缝集成,让您轻松实现强大的书签管理功能。通过 Raycast 直观的界面,快速保存、搜索和整理您的书签、文本和图像。 2. Alfred [Alfred 工作流程](https://www.alfredforum.com/topic/22528-hoarder-workflow-for-self-hosted-bookmark-management/)可快速储存东西或访问您储存的书签! 3. 电报 [机器人](https://github.com/Madh93/hoarderbot) 一个 Telegram Bot,用于直接通过 Telegram 将书签保存到 Hoarder。 ## 参考 - [体验高效的阅读和收藏,NAS 部署基于 AI 的书签和个人知识库管理工具『Hoarder』](https://post.smzdm.com/p/adm85p5d/) ## [ZSH 启动慢,原来是这个问题!](https://blog.dong4j.site/posts/5eb89a7b.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 背景 事情的起因是 ComfyUI 官网出桌面版了, 虽然是 Bete 版本, 当还是准备试用一下, 结果第一步安装环境就卡住了: ![20241229144917_jrgVhQ2O.webp](https://cdn.dong4j.site/source/image/20241229144917_jrgVhQ2O.webp) `The default interactive shell is now zsh. To update your account to use zsh, please run 'chsh -s bin/zsh'.` 这个再熟悉不过了, 提示我们更新 shell 为 zsh, 但是我并不想更新, 我并不想把 zsh 用作我的默认 shell, 因为 zhs 的启动时间太长. 这个问题是在将老系统迁移到新买的 MBP 时出现的, 现象是将 zsh 作为默认 shell 后, 每次打开终端都需要等待 1-2 分钟, 才出现提示符. 这显然是不能接受的. 后来使用 `Command` 直接指定 `/bin/zsh` 就可以了, 也就没在深究这个问题. ![20241229144917_9dgdO327.webp](https://cdn.dong4j.site/source/image/20241229144917_9dgdO327.webp) 但今天这个问题逃不过去了, 就开始研究一下, 彻底解决这个问题. ## shell 配置加载顺序 因为现象是使用默认的 `/bin/bash` , 然后直接是用 `/bin/zsh` 切换到 zsh 打开速度很快. 但是将 zsh 设置为系统默认 shell 时就会卡住, 且 Title 处会有多个 job 循环显示, 所以我猜测是 zsh 的配置文件加载顺序有问题. 在 Unix 和类 Unix 操作系统中,shell 有两种类型的会话: - `interactive login shell`:这种类型的 shell 在 **用户登录时被启动**,通常是用户首次打开终端或连接到服务器时所见的 shell。login shell 负责加载与系统配置相关的文件,例如/etc/profile、~/.profile 等。这些文件用于设置环境变量、配置别名和函数、加载系统级别的 bash 插件等。 - `interactive non-login shell`:这种类型的 shell 在 **用户已经登录并且不涉及新会话的情况下启动**,例如通过 bash -i 或通过某些图形界面工具打开的终端窗口。non-login shell 通常不会加载与登录相关的文件,而是从当前用户的.bashrc 或.zshrc(如果是使用 zsh)等个人配置文件中读取设置。 ### bash 配置加载顺序 对于 Bash,它们的工作原理如下。阅读相应的列。执行 A,然后执行 B,然后执行 C 等等。B1、B2、B3 表示它只执行找到的第一个文件。 | config | interactive login shell | interactive non-login shell | script | | ------------------ | ----------------------- | --------------------------- | ------ | | `/etc/profile` | A | | | | `/etc/bash.bashrc` | | A | | | `~/.bashrc` | | B | | | `~/.bash_profile` | B1 | | | | `~/.bash_login` | B2 | | | | `~/.profile` | B3 | | | | `BASH_ENV` | | | A | | | | | | | `~/.bash_logout` | C | | | 以 `interactive login shell` 为例: 1. 首先读取 `/etc/profile` 2. 然后加载 `~/.bash_profile`, `~/.bash_login`, `~/.profile` 三者中能找到的第一个配置; 3. 用户注销时执行 `~/.bash_logout`(如果存在的话); 执行顺序图: ![bash_load_config_order.drawio.svg](https://cdn.dong4j.site/source/image/bash_load_config_order.drawio.svg) **1. 是否为交互式 Shell (Interactive?)** - **Yes**:进入交互式模式,即用户可以在终端输入命令。 - **No**:非交互式模式,通常用于脚本执行。 **2. 交互式模式下** **是否为登录 Shell (Login shell?)** - **Yes**(登录 Shell): - 如果 `--noprofile` 参数被指定,则 **不加载任何配置文件**。 - 如果没有 `--noprofile`: - **加载** `/etc/profile`。 - 然后依次查找以下文件中的第一个存在的文件: - `~/.bash_profile` - `~/.bash_login` - `~/.profile` - 这些文件可能包含对 ~/.bashrc 的引用。 - **No**(非登录 Shell): - 如果指定 `--rcfile `,则加载指定的配置文件。 - 如果没有指定 --rcfile: - **没有** `--norc` **参数**:加载 `/etc/bash.bashrc` 和 `~/.bashrc`。 - **有** `--norc` **参数**:不加载任何文件。 **3. 非交互式模式下** - 如果指定 `--login` 参数,则加载 `/etc/profile` 及登录 Shell 配置文件。 - 如果没有 `--login`,但设置了 `$BASH_ENV` 变量,则加载该变量指定的文件。 #### 总结 - **登录 Shell** 通常在用户首次登录时启动,会加载 `/etc/profile` 和用户特定的登录配置文件(如 `~/.bash_profile`)。 - **非登录 Shell** 常用于终端内启动的子 Shell,主要加载 `~/.bashrc`。 - **非交互式 Shell** 主要用于脚本执行,加载 `$BASH_ENV` 指定的环境。 --- ### zsh 配置加载顺序 对于 zsh:如果`~/.zshrc`不存在, zsh 似乎也会读取`~/.profile`) | config | interactive login shell | interactive non-login shell | script | | --------------- | ----------------------- | --------------------------- | ------ | | `/etc/zshenv` | A | A | A | | `~/.zshenv` | B | B | B | | `/etc/zprofile` | C | | | | `~/.zprofile` | D | | | | `/etc/zshrc` | E | C | | | `~/.zshrc` | F | D | | | `/etc/zlogin` | G | | | | `~/.zlogin` | H | | | | | | | | | `~/.zlogout` | I | | | | `/etc/zlogout` | J | | | #### 总结 - 对于 `bash`,请将内容放入 `~/.bashrc` 中,然后使用 `~/.bash_profile` 来获取它; - 对于 `zsh`,将内容放入 `~/.zshrc` 中,该操作始终执行; ## 问题排查 在清楚 bash 和 zsh 的配置加载顺序之后, 逐渐缩下了排查问题的范围, 现象是 zsh 作为默认终端加载慢(`interactive non-login shell`), 而在命令行中执行 `/bin/zsh` 切换到 zsh 就很快, 所以应该是 `/etc/zprofile` 和 `~/.zprofile` 配置的问题. 这是 `/etc/zprofile` 的配置: ```bash # System-wide profile for interactive zsh(1) login shells. # Setup user specific overrides for this in ~/.zprofile. See zshbuiltins(1) # and zshoptions(1) for more details. if [ -x /usr/libexec/path_helper ]; then eval `/usr/libexec/path_helper -s` fi ``` 应该没有什么问题, 注释说的是 **`~/.zprofile` 中的配置会覆盖这里的配置**. 那么见证奇迹的时刻出现了, 当我打开 `~/.zprofile` 后, 感觉不得不写一篇博客来记录一下: ![20241229144917_3xJ4AaVs.webp](https://cdn.dong4j.site/source/image/20241229144917_3xJ4AaVs.webp) 这个文件中有 4000+ 行相同的 `eval "$(/opt/homebrew/bin/brew shellenv)"` 配置....... 而在 `.zshrc` 存在如下配置: ```bash ... echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> /Users/dong4j/.zprofile eval "$(/opt/homebrew/bin/brew shellenv)" ... ``` 那问题现在就明了了: **因为每次启动 zsh 最终都会读取 `.zshrc` 文件, 然后 `.zshrc` 又会向 `.zprofile` 添加一行 `eval "$(/opt/homebrew/bin/brew shellenv)"` , 而第一次启动 zsh 的时候又要执行 `.zprofile` 里面几千行同样的命令**...... 😂😂😂 `eval "$(/opt/homebrew/bin/brew shellenv)"` 的作用是将 **Homebrew** 的环境变量配置到当前的 Shell 中,使得 Homebrew 的命令和软件可以正常使用。具体来说: `/opt/homebrew/bin/brew shellenv` 会输出一系列环境变量设置命令: ```bash export HOMEBREW_PREFIX="/opt/homebrew"; export HOMEBREW_CELLAR="/opt/homebrew/Cellar"; export HOMEBREW_REPOSITORY="/opt/homebrew"; fpath[1,0]="/opt/homebrew/share/zsh/site-functions"; PATH="/opt/homebrew/bin:/opt/homebrew/sbin:..........; export PATH; [ -z "${MANPATH-}" ] || export MANPATH=":${MANPATH#:}"; export INFOPATH="/opt/homebrew/share/info:${INFOPATH:-}"; ``` 而 `eval` 会执行字符串形式的命令,将 brew shellenv 输出的环境变量立即加载到当前 Shell 环境中。 **总结起来**: - **Homebrew** 在 macOS 上默认安装在 `/opt/homebrew`(Apple Silicon 或 Homebrew 自行编译的环境),为了让终端能够找到 brew 命令及其安装的软件,需要将其路径加入到 PATH 和其他环境变量中。 - 直接执行 eval 命令可以立即生效,而无需重启终端或手动修改配置文件。 而在翻看 **Homebrew** 的 **Discussions** 正好看到一个 [相关的讨论](https://github.com/orgs/Homebrew/discussions/446): ![20241229144917_npHEw9tq.webp](https://cdn.dong4j.site/source/image/20241229144917_npHEw9tq.webp) 所以解决的办法就是删除 `.zshrc` 中的相关配置, 然后清理 `.zprofile`. ## macOS 设置 zsh 为默认 shell 从 macOS Catalina 开始,macOS 使用 zsh 作为默认登录 shell 和交互式 shell. ### 从命令行 在终端中,输入`$ chsh -s path`,其中*路径*是 /etc/shells 中列出的 shell 路径之一,例如 /bin/zsh、/bin/bash、/bin/csh、/bin/dash、/bin/ksh、/bin/sh 或 /bin/tcsh。 ### 从用户和群组设置 在 macOS Ventura 或更高版本中: 1. 选择苹果菜单  >“系统设置”,然后单击边栏中的“用户与群组”。 2. 按住 Control 键并点按右侧用户列表中的用户名或用户图片,然后选择“高级选项”。 3. 出现提示时输入您的用户名和密码。 4. 从“登录 shell”菜单中选择一个 shell,然后单击“确定”保存更改。 在早期版本的 macOS 中: 1. 选择苹果菜单  >“系统偏好设置”,然后单击“用户与群组”。 2. 单击锁![20241229144917_kSBIoKG7.webp](https://cdn.dong4j.site/source/image/20241229144917_kSBIoKG7.webp),然后输入您的用户名和密码。 3. 按住 Control 键并点按左侧用户列表中的用户名,然后选择“高级选项”。 4. 从“登录 shell”菜单中选择一个 shell,然后单击“确定”保存更改。 **参考:** - [Use zsh as the default shell on your Mac](https://support.apple.com/en-ca/102360) --- ## zsh 启动时间优化 相关教程: - [zsh 和 oh my zsh 冷启动速度优化](https://blog.skk.moe/post/make-oh-my-zsh-fly/) - [优化 zsh 的启动速度](https://zhuanlan.zhihu.com/p/464117825) - [解决 zsh 启动速度慢的优化方法](https://zhuanlan.zhihu.com/p/68303393) ## [HomeLab 网络续集:升级 10G 网络-再战 10 年](https://blog.dong4j.site/posts/7e09e56.md) ![/images/cover/20241229154732_0ssj32bq.webp](https://cdn.dong4j.site/source/image/20241229154732_0ssj32bq.webp) ## 简介 > 步入中年的人生道路,10GB 以太网口不只是性能的简单提升,它更像是一扇通往新生活的大门。这扇门之后,是丰盈的路由器、交换机和 NAS 的世界,能够让你的中年时光更加充实、更加快乐! 在我家 2.5G 网络的陪伴下, 已经度过了三个充满活力的年头. 这段时光里, 我的网络稳定可靠, 成为了家中信息高速公路的坚实基石. 然而, 随着科技的快速发展和生活需求的变化, 我发现原本强大的 2.5G 网络已经无法满足未来几年日益增长的需求. 期间一直在犹豫是否升级到万兆, 因为 MBP M1 作为我的主力机, 担心万兆网卡兼容性和稳定性问题, 当然也有成熟的雷雳网卡可供选择, 但是价格高的离谱. 最近, 各种升级方案陆续出现, 价格也逐渐符合我的预算. 同时, 家中使用的万兆设备也越来越多. 这一切让我意识到升级到万兆网络的时机已经到了. 在这篇博客中, 我将分享从犹豫不决到决定升级 10G 网络的决策过程. 详细介绍第一次接触的光纤和光模块的相关知识, 一步步地讲解设备购买、网络拓扑设计、网络环境配置和网络测试等方面的细节. --- **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## MBP 万兆参考方案 目前市面上有许多成品雷雳转万兆网卡可供选择. 这些网卡通常支持 RJ45 或 SFP+ 接口, 可以直接插入 MBP 的雷雳 3 或雷雳 4 端口, 无需额外的硬件转换. 只需连接一根光缆即可实现高速网络传输. 另一种方式是 DIY, 可以更低成本实现万兆网络. DIY 方案主要分为两种:雷雳转 PCIE 和 USB4 硬盘盒的 M2 转 PCIE. ### 成品网卡 我们先来看一下支持 MBP 的万兆成品网卡(反正不买, 看看总可以吧). 首先是威联通的 [QNA-T310G1T](https://www.qnap.com.cn/zh-cn/product/qna-t310g1t) 与 [QNA-T310G1S](https://www.qnap.com.cn/zh-cn/product/qna-t310g1s), 现在二手成本 1000+. ![20241229154732_nw3jFcPi.webp](https://cdn.dong4j.site/source/image/20241229154732_nw3jFcPi.webp) **QNAP T310G1T 和 T310G1S** 是两款高性能的万兆网卡, 分别采用 NBASE-T 和 SFP+ 接口, 支持 10G 连网能力. 它们体积小巧, 易于携带, 能够显著提升数据传输速度. **特点:** - **高速传输**:支持 10G 连网能力, 将数据传输速度提升至 10 倍以上. - **小巧轻便**:体积小巧, 易于携带, 方便用户随时随地使用. - **多种接口**:T310G1T 采用 NBASE-T 接口, 支持多种连接模式;T310G1S 采用 SFP+ 接口, 适用于多种场景. - **高效散热**:铝合金外壳具有良好的散热性能, 确保长时间运行. - **易于安装**:无需额外供电, 直接连接设备即可使用, Windows 系统需要安装驱动程序. --- [AKiTiO Thunder3 Dock Pro](https://www.akitio.com.tw/accessories/thunder3-dock-pro) ![20241229154732_DWd9MJMt.webp](https://cdn.dong4j.site/source/image/20241229154732_DWd9MJMt.webp) 市面上为数不多的拓展坞和万兆口结合产品, 价格感人. **特点:** - **Thunderbolt 3 技术**:提供高达 40Gbps 的传输速度, 支持 10GbE 网络、多种存储卡读取、多种接口连接等. - **多功能扩展**:内置 DisplayPort、USB .1、eSATA 等接口, 可扩展电脑的多种功能. - **充电功能**:支持 60W 充电, 可为笔记本等设备充电. - **兼容性**:支持 Windows 和 macOS 操作系统 另一款支持万兆的扩展坞 -- [OWC Thunderbolt Pro Dock](https://www.owcasia.com.tw/docks/owc-thunderbolt-pro-dock) 具有高速 Thunderbolt 3 连接、10GbE 乙太网、多种读卡器接口和丰富的 USB 连接, 可满足各种连接需求, 提升工作效率. ![20241229154732_wskVm3ki.webp](https://cdn.dong4j.site/source/image/20241229154732_wskVm3ki.webp) **特点**: - **高速连接**:Thunderbolt 3 和 10GbE 乙太网, 实现高速数据传输和连接. - **多功能读卡器**:CFexpress Type B 和 SD 4.0 读卡器, 方便快速读写卡. - **丰富接口**:多个 USB-A、USB-C 和 DisplayPort 接口, 满足各种外设连接需求. - **小巧便携**:紧凑设计, 便于携带和安装. --- [CalDigit 的 Thunderbolt 3 10Gb 以太网转接器](https://www.caldigit.com/zh/connect-10-g-zh/) ![20241229154732_yf5cSCWA.webp](https://cdn.dong4j.site/source/image/20241229154732_yf5cSCWA.webp) 该转接器可将 Thunderbolt 3 计算机连接到 10GbE 以太网络, 提供高达 10Gbps 的传输速度, 适用于企业、工作室、电竞和家庭网络等场景. **特点:** - 10Gbps 以太网速度, 比传统 1Gbps 以太网快 10 - 支持最长 100 米的连接距离 - 支持音频视频桥接 (AVB) 功能 - 可替换式 Thunderbolt 3 传输线 - 总线供电, 无需外接电源 - 兼容多种网络线缆, 包括 Cat 5e、Cat 6 和 Cat 6a - 支持网络唤醒功能 - 铝制外壳设计, 散热良好 - 支持 macOS 和 Windows 系统 --- [Sonnet 的 Solo10G SFP+ ](https://www.sonnettech.com/product/solo10g-sfp-tb3/overview.html) ![20241229154732_cYfOal47.webp](https://cdn.dong4j.site/source/image/20241229154732_cYfOal47.webp) 这是一款将 10GbE 网络连接到 Mac、Windows 和 Linux 计算机的设备. 该适配器通过 Thunderbolt 连接, 提供高速的网络连接, 并支持多种操作系统. **特点:** - 支持高达 10GbE 的网络速度 - 通过 Thunderbolt 连接, 提供高速数据传输 - 支持多种操作系统:Mac、Windows 和 Linux - 包含 SFP+ 光纤模块, 可连接到 10GbE 基础设施 - 轻巧便携, 无需外部电源 **其他品牌:** - [兮克雷雳转万兆网卡万兆电口兼容雷雳 3 雷雳 4 转万兆网卡 SKN-T310GT](https://item.jd.com/10121176306316.html) - [乐扩 USB4 万兆网卡兼容 雷雳 3/4 电口 RJ45 网络转换器 10G 网卡](https://item.jd.com/10102887511740.html) - [奥睿科雷雳 3 转 10GbE 万兆网络转换器](https://item.jd.com/10126348888148.html) --- 王炸网卡--[ATTO ThunderLink TLNS-3252](https://www.atto.com/products/thunderlink-adapters/) 双端口 Thunderbolt™ 3/4 至双端口 25Gb 以太网适配器, 附带 SFP28 模块. 支持 macOS 和 Windows 操作系统. 非常适合带宽密集型应用, 例如数据备份和恢复、集群计算、IP 内容交付、医学成像和视频渲染. ![20241229154732_j10NgGDz.webp](https://cdn.dong4j.site/source/image/20241229154732_j10NgGDz.webp) - 双端口 25Gb/s Thunderbolt 3, 带 DisplayPort 和设备菊花链支持 (兼容 Thunderbolt 4) - 每个端口吞吐量高达 25Gb/s - 包含 SFP28 模块 - 支持 macOS® 和 Windows® - 支持[ATTO 360](https://www.atto.com/landing/atto360) - TCP、UDP 和 IPv4 校验和卸载、IPsec 卸载以及 Tx/TCP 分段卸载 - 独家[先进数据流 (ADS™) 技术](https://www.atto.com/wp-content/uploads/WebCollateral/tech-brief/TechBrief-ATTOAdvancedDataStreamingTechnology.pdf) - Kensington 安全插槽可通过[Kensington 锁提供额外的安全保护](https://www.atto.com/redir/www.kensington.com/us/us/v/4482/1866/microsaver-ds-ultrathin-keyed-laptop-locks) --- ### DIY 相对于价格高昂但稳定的成品网卡来说, DIY 方式才应该是我的最终选择, 因为 MBP 升级到万兆并不是必须, 性价比显然比稳定性更重要. 现在主流的 2 种 DIY 万兆网卡方案为: - Thunderbolt 转 PCIe, 主控芯片一般 Intel 的 **JHL6X40** 或**JHL7X40**; - USB4.0 转 PCIe, 主控芯片一般 ASMedia 的 **ASM2464PD**; 为了搞清楚 Intel 和 ASMedia 主控芯片对速度的影响, 决定先学习一下 2 种主控芯片的差异, 传输速度的区别. ### 雷雳 4/3 和 USB4.0 首先简单介绍一下关于雷雳 4/3 和 USB4.0, 它们都是目前常见的、用于高速数据传输和视频输出的接口标准. 共享相同的 USB-C 接口, 但各自有不同的特点和优势. #### 雷雳 4/3 - **核心技术**: 由 Intel 主导开发, 基于 PCIe 协议, 提供高速数据传输、视频输出和供电功能. - 主要特点: - **高速传输**: 最高可达 40Gbps 的传输速度, 适用于大文件传输和高分辨率视频传输. - **视频输出**: 支持多个 4K 显示器或 1 个 8K 显示器, 满足高品质视频需求. - **供电能力**: 最高可提供 100W 的供电, 可为笔记本电脑等设备充电. - **优势**: 性能强劲, 功能丰富, 适用于对性能要求较高的用户, 如视频编辑、游戏玩家等. #### USB4.0 - **核心技术**: 由 USB-IF 组织制定, 整合了雷雳 3 的先进技术, 向下兼容 USB 3.2 等协议. - 主要特点: - **高速传输**: 最高可达 40Gbps 的传输速度, 与雷雳 4 相同. - **视频输出**: 支持和雷雳 3 同等的视频拓展能力. - **供电能力**: 具备大功率 PD 电力传输功能. - **优势**: 兼容性强, 向下兼容 USB 3.2 等协议, 价格相对较便宜. **雷雳 4/3 和 USB4.0 的区别**: | 特点 | 雷雳 4/3 | USB4.0 | | ------ | ---------------- | ----------------------- | | 开发者 | Intel | USB-IF 组织 | | 协议 | PCIe | `USB` | | 兼容性 | 向下兼容 USB 3.2 | 向下兼容 USB 3.2 等协议 | | 价格 | 较高 | 相对较低 | | 功能 | 更丰富, 性能更强 | 兼容性更广 | 总结来说雷雳 4/3 更适合对性能有较高要求的用户, 如专业创作者、游戏玩家等. **USB4.0** 具有更好的兼容性, 适用于普通用户, 性价比更高. --- ### 主控芯片 #### JHL7440 **JHL7440** 是来自英特尔生产的控制芯片, 得益于自家是雷雳接口的主要开发者, 在性能和传兼容性上有很好的表现. 其实你会发现大多数的硬盘盒品牌在介绍的时候宣称 **JHL7440** 与 Thunderbolt 3/4 兼容, 事实上这样说好像也没什么问题. 但其实该芯片只是兼容 Thunderbolt 4, 但实际上只是 Thunderbolt 3 时期的产物. **JHL7440** 芯片组, 该芯片组提供高达 **24Gbps** 的传输速度, 实际速度最高约为 **2800 MB/s**. 很多朋友可能好奇, 雷雳 4/3、USB4.0 接口不是最高支持 **40Gbps** 的传输速度吗, 怎么到这里最高仅提供 **24Gbps (2800MB/s)** 的速度. 确实, Thunderbolt 3/4 和 USB4.0 接口理论上支持高达 **40Gbps** 的传输速度. 然而, 在实际应用中, 由于硬件设计和技术限制, 某些控制芯片 (如 **Intel JHL7440**) 并不能完全达到这个速率. 下面是造成这种差异的几个原因: **1. 带宽分配限制** - **JHL7440** 芯片设计之初, 定位主要是 **Thunderbolt 3** 规范, 并主要用于接口扩展. 虽然它兼容 Thunderbolt 3 的高达 40Gbps 的带宽, 但在具体设计上, 往往只能分配其中的 **24Gbps** 用于数据传输. - Thunderbolt 接口的 40Gbps 是总带宽, 实际应用中需要分配给 **数据传输、显示输出** 等用途. 而 **JHL7440** 主要为数据传输设计, 因此仅分配 **24Gbps**. **2. PCIe 通道限制** - **JHL7440** 连接到主板时, 通常仅通过 **PCIe 3.0 x2 通道**与系统通信, 而非全速的 PCIe x4. 因此, 这一设计在接口层面上就限制了最大数据传输速度, 约为 **24Gbps**, 也就是实际的 **2800MB/s** 左右. **3. 兼容性和成本考量** - **JHL7440** 的设计主要面向性价比市场, 意在降低设备成本并提供可靠的 Thunderbolt 3 性能, 因此对极限性能的支持相对较低. - 对于日常数据传输需求, 2800MB/s 已经能满足大多数场景 (如高清视频剪辑、数据备份等) , 因此对于多数厂商而言, **JHL7440** 是一个性能和成本的平衡之选. **4. 实际应用中的速率损耗** - 实际中, 传输速率会受多种因素影响, 如 **硬盘类型、散热情况、数据传输协议** 等. 即便硬件接口支持更高速度, 但 SSD 本身的写入/读取速度也会成为瓶颈. --- ##### 各型号区别 `JHL6340`、`JHL6540`、`JHL7440`和 `JHL7540`几个型号: 1. **JHL6340 和 JHL6540**: - 这两款芯片都是 Intel 推出的 Thunderbolt 3 (雷雳 3) 主控芯片. 根据官方参数, JHL6340 和 JHL6540 与旧款的 DSL6340 和 DSL6540 在单双口的功耗、封装面积、DP 输出标准等方面没有区别. - JHL6540 的功耗为 2.2w, 相比之下, JHL6340 的功耗为 1.7w. 2. **JHL7440**: - JHL7440 也是一款雷雳 3 主控芯片, 但它被设计为同时支持 USB-C 的 Function, 提高了设备的运用弹性. 这使得新一代的 Dock/Hub/显示器可以“降级”到 USB-C 模式下使用. 3. **JHL7540**: - 与 JHL6540 相比, JHL7540 在显存读取带宽上提升了 10%, 其读取带宽可达到 3017MB/s, 而 6540 的读取带宽最多为 2750MB/s. 在性能测试中, 使用 JHL7540 的显卡坞在帧数上比使用 JHL6540 的显卡坞高出约 6.5%. 这些型号之间的主要区别在于性能和兼容性. JHL6340 和 JHL6540 是较早的型号, 而 JHL7440 和 JHL7540 提供了更高的性能和更好的兼容性. --- #### ASM2464PD **ASM2464PD** 是 ASMedia 公司 (华硕子公司) 推出的一款 **USB4 / Thunderbolt 控制芯片**, 其实该芯片是 USB4.0 的产物, 不过也同样兼容雷雳 4/3 接口. 与英特尔的 JHL7440 相比, ASM2464PD 更具性价比. 从实体体验上来说, **ASM2464PD** 芯片的硬盘盒似乎能提供更高的传输速度. 因为与 Thunderbolt 3 设计不同, 没有为 DisplayPort 或传统 USB 3.x 规范保留带宽. 所以, 你可以从网络上看到采用 **ASM2464PD** 芯片的硬盘盒的传输速度能来到 3000MB/s 出头的成绩. 虽然速度上更胜 **JHL7440**, 且价格相对低廉, 但其实 **ASM2464PD** 也有缺点, 在兼容性与发热上, **ASM2464PD** 似乎比 **JHL7440** 更严重. **不同主控芯片的硬盘盒速度对比:** | 供应商 | 主控芯片 | 上游接口 | 下游接口 | 理论速度 | 真实速度 | | ------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | --------------- | -------- | ------------- | | ASMedia | [ASM2464PD](https://www.asmedia.com.tw/search?keyword=ASM2464PD) | **USB4** with PCIe Gen4x4 | PCIe Gen**4×4** | 40Gb/s | 3200-3800MB/s | | Intel | [JHL7440](https://www.intel.com/content/www/us/en/products/sku/97401/intel-jhl7440-thunderbolt-3-controller/specifications.html) | **TB3** with PCIe Gen3x4 | PCIe Gen3x4 | 24Gb/s | 2600-2800MB/s | | Intel | [JHL6X40](https://ark.intel.com/content/www/us/en/ark/products/codename/56890/products-formerly-alpine-ridge.html) | **TB3** with PCIe Gen3x4 | PCIe Gen3x4 | 22Gb/s | 2500-2600MB/s | ### 参考方案 #### 硬盘盒转 PCIe 硬盘盒有 2 类能满足要求: - 基于 **JHL6X40** 或 **JHL7X40** 主控芯片的雷雳硬盘盒; - 基于 **ASM2464PD** 主控芯片的 USB4.0 硬盘盒; ##### 方案一(300+) 偶然刷到 [300 块搞定苹果雷雳万兆网卡](https://www.bilibili.com/video/BV1a4421Z7Rz/) 这个视频, Up 主使用 200+ 的 USB4.0 硬盘盒实现了 MBP 万兆: ![20241229154732_8HFJIg0I.webp](https://cdn.dong4j.site/source/image/20241229154732_8HFJIg0I.webp) 实现方式为: 1. USB4.0 硬盘盒(关键词[USB4.0 硬盘盒 m2 雷雳 4 硬盘盒 40G-nvme USB2.0 扩展坞音频改装万网卡]-某多多): 主控芯片是 **ASM2464PD**, 现在涨价到 **240+** 了; 2. m.2 转 PCIe 小板: 10+ 3. 免驱万兆网卡: 曙光单口万兆网卡(Intel X520-DA1) ; > 顾虑: > > - 不是 M 系列芯片且不是最新的系统; > - 不是双光口网卡 这种方案因为没有外接供电, 会出现掉卡的情况, 所以评论中就有外接供电的方案. --- ##### 方案二(600+) - ITGZ 双 M.2 硬盘盒, 主控:**JHL7440**; - m.2 转 PCIe 小板; - 12V DC 转 SATA 供电; - 网卡: Intel Intel X520-DA2; ![20241229154732_KjNxXAf0.webp](https://cdn.dong4j.site/source/image/20241229154732_KjNxXAf0.webp) 使用的是: [ITGZ 雷雳 3 双盘 M.2 NVMe 硬盘盒扩展坞](https://item.jd.com/10121657056426.html), 价格 500+. ![20241229154732_ibKckMOB.webp](https://cdn.dong4j.site/source/image/20241229154732_ibKckMOB.webp) > 顾虑: > > - 不知道作者的 MBP 具体配置是什么, 且整套设备价格超预算. --- ##### 方案三(300+) 另一个 UP 主方案: [雷雳万兆网卡廉价方案](https://www.bilibili.com/video/BV1kNDaY4Eix): - ITGZ USB4.0 硬盘盒, 主控:**ASM2464PD**; - m.2 转 PCIe 小板; - 12V DC 转 SATA 供电; - 网卡: Intel X520-DA1; ![20241229154732_KeqhmYPF.webp](https://cdn.dong4j.site/source/image/20241229154732_KeqhmYPF.webp) [ITGZ USB4.0 移动雷雳 4 硬盘盒](https://item.jd.com/10113618108196.html): ![20241229154732_3eAjrAuK.webp](https://cdn.dong4j.site/source/image/20241229154732_3eAjrAuK.webp) 另一款主控芯片为 [JHL7440](https://item.jd.com/10115097578948.html) 的同类产品, 价格贵了 100+. > 顾虑: > > - 不是 M 系列芯片且不是最新的系统; > - 不是双光口网卡 --- #### 雷雳扩展坞 这类方案就比较多了, 主要是通过一个雷雳 4/3 核心板外接其他元器件焊接成一个具备 PCIe 插槽的电路板, 方便的地方是不需要 m.2 转 PCIe 小板. ##### 雷雳核心板 动手能力强的还可直接购买雷雳核心板来组装, 价格 200+: ![20241229154732_4771BvgX.webp](https://cdn.dong4j.site/source/image/20241229154732_4771BvgX.webp) 一些雷雳拓展坞就是是用上面的核心板来提供雷雳功能, 比如 [UGREEN 绿联雷电 3 多功能 10 接口扩展坞 CM355](https://www.chongdiantou.com/archives/102735.html). ##### 方案一(500+) [雷雳 4 万兆 40G 网卡 DIY](https://www.bilibili.com/video/BV1tA411Z7op) : - 主控芯片: **JHL7440**; - 网卡: Intel X520-DA1; ![20241229154732_Yk8s2vBw.webp](https://cdn.dong4j.site/source/image/20241229154732_Yk8s2vBw.webp) 这个方案也是使用了上述的雷雳核心板: ![20241229154732_Navykfrx.webp](https://cdn.dong4j.site/source/image/20241229154732_Navykfrx.webp) > 顾虑: > > - 不是 M 系列芯片且不是最新的系统; > - 不是双光口网卡 ##### 方案二(600+) [最廉价的 Mac 电脑 M1 M2 芯片 雷雳转万兆网卡方案测试](https://www.bilibili.com/video/BV1Zk4y1E7dR): - 雷雳拓展坞(主控芯片: **JHL7440**); - 网卡: Intel X520-DA1; ![20241229154732_HG50zg8Q.webp](https://cdn.dong4j.site/source/image/20241229154732_HG50zg8Q.webp) > 顾虑: > > - 不是最新的系统; > - 不是双光口网卡; --- 在了解了 MBP 万兆的可行方案后, 接下就是其他的准备工作. ## 准备工作 ### 电口 Or 光口 万兆网络涉及到一个交换机网口选择问题--**使用光口还是电口**? 我目前的网络情况是 千兆和 2.5G 设备全部使用的电口, **M920x** 2 个万兆光口则使用 10G DAC 线连接交换机的 10G 光口. 我的需求是至少需要 7 个 10G 网口, 才能将主要设备通过交换机组成万兆局域网. 首先就排除了全电口方案, 一是 **成本考虑**, 二是 **噪音**. 8 口全电口交换机优点是可以兼容我现在所有的设备而不需要换线. 缺点就是比全光口的交换机贵上不少, 另外因为 **发热量大**, 必须得加散热风扇, 那么噪音又是一个需要考虑的问题. 选择全光口的话, 缺点就是需要重新布线, 以及为仅支持电口的设备添加 **万兆光转电模块**(100+). #### 万兆光转电模块 它的作用是可以将交换机上的光口转换为电口, 以满足交换机上 RJ45 端口不足的需求. 如图所示, 万兆电口模块一端是 SFP+端口, 另一端为 RJ45 接口 ![20241229154732_pkGrR7Y0.webp](https://cdn.dong4j.site/source/image/20241229154732_pkGrR7Y0.webp) ##### 优势 - 更灵活的连接方式:SFP+转 RJ45 端口提供了一种灵活的解决方案, 将 SFP+端口转换为 RJ45, 使其更适合数据中心和企业网络的需要. - 成本效益:采用普通 RJ45 连接器, 支持向下兼容, 使网络升级成本更低. 相比额外购买 10GBASE-T 电口交换机, 更经济高效. - 传输距离:SFP+转 RJ45 端口模块可以在 100 米的距离内提供 10Gb/s 的数据传输, 解决了 SFP+ DAC 高速线缆只能传输有限距离的问题. - 节省能源:与嵌入式 10GBASE-T RJ45 端口相比, 使用 SFP+转 RJ45 端口模块每个端口至少节省 0.5W 的功耗, 降低了能源消耗. - 多速率支持:支持多种传输速率 (10/100/1000Mbps 及 10Gbps) , 使其易于操作和管理. ##### 缺点 - 功耗和热量:连接多个端口时, 相比光纤布线, SFP+转 RJ45 端口解决方案的功耗和热量可能会较高. - 网络延迟:在连接多个节点时, 可能会增加网络延迟. **万兆光转电模块关键数据** | **规格** | **描述** | | -------------- | ---------------------------------------------------- | | **传输速率** | 10Gbps (向下兼容 1Gbps 和 100Mbps) | | **光纤端口** | SFP+ 插槽, 支持多模和单模光模块 (如 850nm, 1310nm) | | **电口** | RJ45 接口, 支持 Cat6a 或 Cat7 电缆 | | **光模块标准** | 10GBASE-SR (最大 300 米) / 10GBASE-LR (最大 10 公里) | | **供电方式** | 5V 或 12V 外接电源, 部分支持 PoE | | **协议支持** | IEEE 802.3 标准, 支持全双工和半双工模式 | | **工作温度** | -20℃ 到 70℃ | | **功耗** | 2W 至 10W | | **物理尺寸** | 桌面型或插卡式 (机架安装) | --- 而其他设备则全部使用光模块连接. 说到这里, 也会有 3 种选择--无缘铜缆(**DAC**), 有源光缆(**AOC**)和独立的光纤+光模块. #### DAC 线缆 Direct Attach Cable, 简称 DAC, 一般译为直接连接电缆或直连铜缆, 是指直接连接服务器和网络设备的高速铜缆, 它采用差分信号传输技术, 能够提供高速率、低损耗的数据传输. DAC 高速线缆通常用于短距离的设备互联, 如服务器集群、存储区域网络 (SAN) 等. Direct Attach Cable 分两种: 1. 10G SFP+DAC 2. 40G QSFP+ 转 4 个 SFP+ 这种线缆都不可更换端口, 模块头和铜缆不能分离. ![20241229154732_MSl5eF3O.webp](https://cdn.dong4j.site/source/image/20241229154732_MSl5eF3O.webp) #### AOC 线缆 Active Optical Cable,简称 AOC, 译为有源光缆, 是由集成光电器件组成的, 用于数据中心、高性能计算机、大容量存储器等设备间进行高速率、高可靠性互连的传输设备, 它通常满足工业标准的点接口, 通过内部的电-光-电转换, 使用光缆的优越性进行数据的传输. 这种线缆同样不可更换端口. ![20241229154732_cW6WdzyU.webp](https://cdn.dong4j.site/source/image/20241229154732_cW6WdzyU.webp) **DAC 与 AOC 的区别**: | **属性** | **DAC (Direct Attach Copper)** | **AOC (Active Optical Cable)** | | ---------------- | ------------------------------------------------------ | -------------------------------------------- | | **传输介质** | 铜线 | 光纤 | | **类型** | 被动 DAC (无信号放大器) 或主动 DAC (带信号放大器) | 主动型, 内置光电转换模块 | | **传输距离** | 通常支持 1-7 米, 最远约 15 米 | 通常支持 10 米到 100 米, 部分型号可达数百米 | | **功耗** | 较低 (特别是被动 DAC, 几乎不耗电) | 较高 (需要光电转换模块工作) | | **价格** | 相对便宜, 适合短距离连接 | 相对昂贵, 适合长距离连接 | | **柔韧性** | 较差, 铜线较硬, 不适合狭小空间或需要多次弯折的环境 | 更灵活, 光纤材质可以弯曲, 适合复杂布线 | | **信号完整性** | 短距离内信号完整性较好, 但距离增加会受限于铜线电磁干扰 | 优秀, 光纤抗电磁干扰能力强, 适合长距离传输 | | **接口兼容性** | 支持 SFP/SFP+、QSFP/QSFP+ 等标准接口 | 同样支持 SFP/SFP+、QSFP/QSFP+ 等标准接口 | | **典型应用场景** | 服务器机柜内短距离连接, 交换机到服务器 | 机柜间、楼层间、数据中心之间的长距离高速连接 | #### 光纤+光模块 | **属性** | **光纤 + 光模块** | **成品线缆 (DAC/AOC)** | | ---------------- | --------------------------------------------------------- | ------------------------------------------------------ | | **结构** | 光纤线缆与可更换光模块的组合 | 一体化设计, 线缆与模块固定为一体 | | **传输距离** | 支持较长距离传输 (几百米到数公里, 取决于光模块和光纤类型) | 通常适用于短距离连接 | | **灵活性** | 光模块和光纤可根据需求自由更换 | 受限于固定长度和规格 | | **价格** | 光模块和光纤分开购买, 整体成本较高 | 相对便宜, 尤其是在短距离应用中 | | **功耗** | 光模块需要供电, 功耗相对较高 | DAC 无需供电, 功耗最低;AOC 有源设计, 功耗较光模块略低 | | **维护与扩展** | 更换单个光模块或光纤即可灵活升级或维修 | 整根线缆损坏需整体更换 | | **接口类型支持** | 支持多种接口标准, 如 SFP、QSFP 等 | 通常与网卡或设备固定接口匹配 | | **适用场景** | 数据中心核心网络、长距离互联 | 数据中心机架内或机架间短距离连接 | 二手的 DAC 和 AOC 线缆大概 10+ 一根, 不过上次给 M920x 选购 AOC 线缆时出现了不兼容的问题, 后来就换成了 DAC 线缆, 这次准备使用光纤+光模块的方案, 可以更换光模块的方式应该更灵活一些, 二手的价格也贵不了多少. 接下来就是介绍一下光纤和光模块了, 因为是第一次接触光纤, 所以需要先学习了解一下基础知识. --- ### 光纤技术 在现代网络通信中, 光纤传输技术是高速和远距离数据传输的关键. 它通过光信号在光纤中传输数据, 以实现更高的带宽、更低的延迟和更远的传输距离. 与传统的铜缆相比, 光纤在数据中心、企业网络和广域网中已成为主流选择. 因为目前搭建万兆光纤网络离不开对 **光纤类型**, **光纤接口类型** 和 **光模块类型** 的基本了解, 所以就从这三个方面介绍一下基础知识. #### 光纤类型 光纤是由玻璃或塑料制成的一种传输介质, 能够通过光信号传递数据. 根据用途和传输距离, 光纤分为两种主要类型: - 单模光纤 (Single-mode Fiber) : 只允许单一模式的光通过, 适用于长距离通信 (可达几十公里甚至上百公里) , 适用于跨楼层或跨建筑物的通信. - 多模光纤 (Multimode Fiber) : 允许多种模式的光通过, 适合短距离传输 (通常在 300 米以内) , 成本较低, 多用于机房或局域网内设备的连接. ##### 颜色区分 通过光纤的外护套颜色可快速区分单模光纤和多模光纤. 根据 [TIA-598C 标准](https://en.wikipedia.org/wiki/TIA-598-C) 定义, **OS1/OS2** 单模光纤的外护套颜色为黄色, 而多模光纤的外护套颜色为橙色或水绿色, 其中 **OM1/OM2** 多模光纤采用橙色外护套, **OM3/OM4** 多模光纤采用水绿色外护套, **OM5** 多模光纤则采用绿色外护套. ![20241229154732_mHKYfwQe.webp](https://cdn.dong4j.site/source/image/20241229154732_mHKYfwQe.webp) ##### 传输距离 单模光纤常用于长距离传输, 多模光纤一般用于短距离传输. 而不同类型的单模和多模光纤应用在不同速率以太网中传输距离各不相同, 如下表所示: | 光纤类型 | 1000Base/1Gb (SX) 以太网 | 1000Base/1Gb (LX) 以太网 | 10Gb 以太网 | 40Gb 以太网 | 100Gb 以太网 | | ------------------------- | ------------------------ | ------------------------ | ----------- | ----------- | ------------ | | OS1/OS2 单模光纤 (1310nm) | 5Km | 20Km | 40Km | 40Km | 80Km | | OM1 多模光纤 (850nm) | 275m | 550m | 33m | / | / | | OM2 多模光纤 (850nm) | 550m | 550m | 82m | / | / | | OM3 多模光纤 (850nm) | 550m | 550m | 300m | 100m | 100m | | OM4 多模光纤 (850nm) | 550m | 550m | 550m | 150m | 150m | | OM5 多模光纤 (850nm) | / | / | 550m | 440m | 150m | --- - [通信用多模光纤主要有哪些类型?OM1 ~ OM5 有什么区别?](https://www.china-cic.cn/Detail/15/208/4526) ![20241229154732_3G5tddUR.webp](https://cdn.dong4j.site/source/image/20241229154732_3G5tddUR.webp) **参考**: - [“单模光纤”与“多模光纤”有什么区别?哪个好?怎么用?](https://post.smzdm.com/p/a0qvx75z/) --- ##### 纤芯的直径差异 单模光纤 (SMF) 的纤芯直径一般为 **9μm**, 由于纤芯窄, 它只能在 1310nm、1550nm 以及 WDM 波长上传输一种模式的光, 也正因如此, 单模光纤色散较小, 带宽高, 而多模光纤 (MMF) 的纤芯直径一般为 50/62.5μm, 纤芯宽, 它能在给定的工作波长 (850nm 或 1310nm 波长) 上传输多种模式的光, 但因为多模光纤中传输的模式多达数百个, 各个模式的传输常数和群速率不同, 导致多模光纤的带宽窄、色散大, 损耗也大. ![20241229154732_RaV3SBot.webp](https://cdn.dong4j.site/source/image/20241229154732_RaV3SBot.webp) ##### 光源差异 单模光纤和多模光纤采用的光源不同, 单模光纤一般采用 **激光光源**, 而多模光纤一般采用 **LED 光源**. ![20241229154732_vMmVt1Mz.webp](https://cdn.dong4j.site/source/image/20241229154732_vMmVt1Mz.webp) --- ##### 单模单芯/单模双芯/多模双芯 在光纤通信中, **单模单芯**、**单模双芯** 和 **多模双芯** 是常见的光纤类型. 它们的区别主要体现在光纤模式、芯数、传输性能以及适用场景上. ![20241229154732_N53zmwR4.webp](https://cdn.dong4j.site/source/image/20241229154732_N53zmwR4.webp) 以下是详细对比: **1. 定义与特性** | **类型** | **定义** | **特点** | | ------------ | --------------------------------------------------- | ----------------------------------------------------------- | | **单模单芯** | 单根单模光纤, 光以单一模式传播, 适用于长距离通信. | 支持远距离和高带宽传输, 常与波分复用 (WDM) 技术结合使用. | | **单模双芯** | 两根单模光纤, 分别用于发送 (TX) 和接收 (RX) . | 适合中短距离通信, 广泛用于数据中心和企业网络的设备互联. | | **多模双芯** | 两根多模光纤, 光以多种模式传播, 分别用于发送和接收. | 适合短距离高带宽传输, 主要用于数据中心内部设备间的高速互联. | **2. 光纤模式与传输性能** | **特性** | **单模单芯** | **单模双芯** | **多模双芯** | | -------------- | ------------------------- | ----------------------- | ------------------------------------- | | **光纤模式** | 单模 | 单模 | 多模 | | **传输距离** | 最长 (几十公里到上百公里) | 较长 (几公里到几十公里) | 最短 (一般在 300 米以内) | | **传输带宽** | 高 (支持 10Gbps~400Gbps) | 高 | 较低 (支持 10Gbps 或更高, 但距离受限) | | **抗干扰能力** | 极强 | 极强 | 较弱 (多模光纤模式干扰较多) | **3. 使用场景** | **类型** | **典型应用场景** | | ------------ | ------------------------------------------------------------------------------- | | **单模单芯** | 长距离通信, 如骨干网、跨机房连接、电信级应用. | | **单模双芯** | 数据中心、企业园区网络中短距离互联, 如交换机到服务器的连接. | | **多模双芯** | 数据中心内部短距离高速互联, 如 10Gbps 到 100Gbps 的设备互联, 通常为 300 米以内. | **4. 连接方式与成本对比** | **属性** | **单模单芯** | **单模双芯** | **多模双芯** | | ---------------- | ---------------------- | ----------------- | --------------------------- | | **光纤芯数** | 单根光纤 | 双根光纤 | 双根光纤 | | **布线复杂度** | 最低 | 较高 (需两根光纤) | 较高 | | **设备成本** | 较高 (需 WDM 技术支持) | 较低 | 最低 | | **光纤成本** | 最低 (只需一根光纤) | 较高 (双根光纤) | 较低 (多模光纤通常成本更低) | | **常用接口类型** | LC、SC | LC、SC | LC、SC | **5. 适配光模块** | **类型** | **推荐光模块类型** | | ------------ | --------------------------------------------------------------- | | **单模单芯** | 单纤双向光模块 (BiDi 模块) , 支持 WDM 技术. | | **单模双芯** | 双纤光模块 (如 SFP、SFP+ 模块) . | | **多模双芯** | 多模光模块 (如 OM3/OM4 支持的 SFP 模块) , 常用于短距离高速通信. | **总结** | **特性** | **单模单芯** | **单模双芯** | **多模双芯** | | ------------ | --------------------------- | --------------------------- | -------------------------- | | **传输距离** | 长距离 (几十公里到上百公里) | 中短距离 (几公里到几十公里) | 短距离 (一般在 300 米以内) | | **应用场景** | 长距离骨干网、跨区域通信 | 数据中心、企业园区网络 | 数据中心内部设备互联 | | **成本** | 光模块和设备成本高 | 成本适中 | 光纤和模块成本最低 | - **单模单芯**: 适合长距离、高带宽传输, 设备成本高, 适合电信级应用. - **单模双芯**: 中短距离的全双工通信优选方案, 兼顾性能和成本. - **多模双芯**: 经济高效的短距离方案, 适合数据中心内设备间的高速连接. --- #### 光纤接口类型 **1. FC 型光纤接口** 全名为 Ferrule Connector, 是一种常见的光纤接口类型. 它的外部加强方式是采用金属套, 紧固方式为螺丝扣. FC 接口在电信网络中被广泛采用, 具有牢靠、防灰尘的优点. 不过, 安装时间相对较长, 通常在 ODF (光纤配线架) 侧使用较多. **2. SC 型光纤接口** 即 Square Connector, 是一种卡接式接口. 它的外壳呈矩形, 紧固方式是采用插拔销闩式, 不需要旋转. SC 接口的使用非常方便, 但容易掉出来, 因此在路由器和交换机上使用较多. **3. ST 型光纤接口** 全称为 Straight Tip, 是一种圆形的接口. 它的紧固方式为螺丝扣, 插入后需要旋转半周以固定. ST 接口的缺点是容易折断, 但在光纤配线架中使用较多, 尤其是对于 10Base-F 连接来说. **4. LC 型光纤接口** 即 Lucent Connector, 是一种小型化的光纤接口. 它的外壳呈矩形, 紧固方式为插拔销闩式. LC 接口适用于 SFP 模块, 采用模块化插孔 (RJ) 闩锁机理制成, 便于操作和维护. ![20241229154732_aHeUAwZY.webp](https://cdn.dong4j.site/source/image/20241229154732_aHeUAwZY.webp) > 运营商进户光纤都是单芯单模皮线光纤, 到光猫这里都必须是单模光纤才能正常使用, 没有其他选择, 单模光纤通常都是 SC 接口. 到光猫这段距离通常也是运营商负责维护. 属于接入网 PON (Passive Optical Network, 无源光网络) . 是目前有线接入网的主流技术, FTTH (Fiber to the Home, 光纤到户) 让大家享受到了超高网速带来的便利性. --- #### 光模块类型 ##### 封装类型分类 光模块按照封装形式来分有以下几种常见类型: - **SFP**: GBIC 的升级版, 最高速率可达 4.25Gbps, 主要由激光器构成, 特点是小型、可热插拔. - **SFP+**: SFP 的加强版, 传输速率为 10Gbps, 可以满足 8.5G 光纤通道和 10G 以太网的应用. - **SFP28**: 传输速率为 25Gbps, 它的优点是功耗较低、端口密度较高, 且支持热插拔. - **QSFP+**: 传输速率为 40Gbps, 支持 MPO 光纤连接器和 LC 光纤连接器, 特点是小型、可热插拔. - **QSFP28**: 采用 4 个 25Gbit/s 通道并行传输, 传输速率为 100Gbps, 满足 100G 以太网的应用. ![20241229154732_g7eB1kRM.webp](https://cdn.dong4j.site/source/image/20241229154732_g7eB1kRM.webp) 更高规格的模块有: **QSFP-DD**: 采用 8 通道电接口, 单通道速率 25Gbps/50Gbps/100Gbps, 提供高达 200Gb/s 到 800Gb/s 总最大数据速率. **OSFP**: 是一种新型光模块, 比 QSFP-DD 略大, 同样具有 8 个高速电通道, 支持高达 800G 的总吞吐量. ![20241229154732_doKCLVnA.webp](https://cdn.dong4j.site/source/image/20241229154732_doKCLVnA.webp) ##### 光纤类型分类 光模块根据适用的光纤类型的不同可被分为 **单模光模块** 和 **多模光模块**, 其中单模和多模指的是光纤在光模块中的传输方式. 单模光模块多用在远距离传输中, 而多模光模块多用在短距离传输中. 多模光模块的工作波长为 **850nm**;单模光模块的工作波长为 **1310nm**、**1550nm**;单模光模块中使用的器件是多模光模块的两倍, 所以单模光模块的总体成本要远远高于多模光模块;单模光模块的传输距离可达 150 至 200km;多模光模块的传输距离可达 5km. ##### 光模块的命名 比如 **SPF-10G-XX**: SFP 是 **Small Form Pluggable** 的缩写, 意为可热插拔的小型模块, 可以简单理解为 GBIC(Gigabit Interface Converter 的缩写) 升级版本. 10G 代表其最大传输速率为 10.3 Gbps, 适用于 10G 以太网. XX 代表 SR, LR, LRM 等, 表示光模块的传输距离. ![20241229154732_SSBxbLhS.webp](https://cdn.dong4j.site/source/image/20241229154732_SSBxbLhS.webp) > SR, LR, LRM, ER 和 ZR 是 [10GIEEE 标准](https://zh.wikipedia.org/wiki/10%E5%90%89%E6%AF%94%E7%89%B9%E4%B9%99%E5%A4%AA%E7%B6%B2%E8%B7%AF) 中比较常见的模块类型,. 在光纤通信中, SR, LR, LRM, ER 和 ZR 是 10G 光模块传输中的一种距离术语. SR 表示短距离,LR 表示长距离, LRM 表示长度延伸多点模式, ER 表示超长距离, ZR 则表示最长距离. 目前市面上万兆光模组型号繁多, 如用于多模传输的 **SFP-10G-SR** 万兆光模组、用于单模传输的 **SFP-10G-LR** 万兆光模组、以及既能用于单模传输又能用于多模传输的 **SFP-10G-LRM** 万兆光模组, 下面是部分万兆光模组的区别: | 型号 | 波长 | 光纤类型 | 传输距离 | 接口 | 标准 | | ------------ | ------- | -------- | -------- | ------- | ----------- | | SFP-10G-SR | 850 nm | 多模 | 300 m | LC 双工 | 10GBASE-SR | | SFP-10G-LR | 1310 nm | 单模 | 10 km | LC 双工 | 10GBASE-LR | | SFP-10G-LRM | 1310 nm | 多模 | 220 m | LC 双工 | 10GBASE-LRM | | SFP-10G-LRM | 1310 nm | 单模 | 300 m | LC 双工 | 10GBASE-LRM | | SFP-10G-LRM2 | 1310 nm | 单模 | 2 km | LC 双工 | 10GBASE-LRM | - SFP-10G-SR (短距离) 是一种常见的 SFP 模块, 它可以在 **多模光纤** 中连接高达 300 米的光纤. 一般情况下 SR 模块多为多模, 工作波长为 850nm. 这些模块是可热插拔的, 相互之间可以轻松实现切换. - SFP-10G-LR(长距离) 用于远程数据传输, 如大型组合或地铁区域网络. 具有成本低、功耗低、体积小、密度高的优点. - SFP-10G-LRM(长度延伸多点模式) 符合 10GBASE-LRM 以太网的标准, 适用于传统光纤和新型多模光纤. 它在多模光纤上的最小支持距离为 220 米, 在单模光纤上的最小支持距离为 300 米. 还有超远距离传输的模块: - SFP-10G-ER(超长距离)在标准 **单模光纤** 上支持长达 **40 公**里 的传输距离. 广泛应用于数据中心和企业工业园区 . - SFP-10G-ZR (最长距离), 在标准 **单模光纤** 上支持高达 **80 公里** 的传输距离, 其波长为 1550nm. 符合 SF-8431、SF-8432 和 IEE802.11 ae 标准. 它可应用于数据网络/光网络. 参考: - [单模多模还傻傻分不清楚?关于光模块介绍, 看这一篇就够啦!](https://gitcode.csdn.net/66c590b213e4054e7e7c5bd3.html) --- #### 光纤和光模块组合 | 光模块类型 | 光纤类型 | 是否能正常工作 | | ---------- | -------- | -------------------------------------------------------------------- | | 单模光模块 | 多模光纤 | 短距离可以工作, 但无法保障效果(需要使用 **光纤收发器** 转换光纤类型) | | 单模光模块 | 单模光纤 | 正常工作 | | 多模光模块 | 单模光纤 | 无法工作 | | 多模光模块 | 多模光纤 | 正常工作 | - 单模光模块与单模光纤配套使用. 单模光纤传输频带宽, 传输容量大, 适用于长距传输. - 多模光模块与多模光纤配套使用. 多模光纤有模式色散缺陷, 其传输性能比单模光纤差, 但成本低, 适用于较小容量、短距传输. > 在了解相关基础知识后, 满足最低 10G 速度需求的前提下, 考虑性价比后, 目前多模光纤更合适, 因为搭配的多模光模块更便宜, 所以我我选择使用 **多模双芯光纤(OM3)** + **LC-LC 接口** + **多模光模块(SFP-10G-SR)** 来组建我的万兆光纤网络, 这应该是家庭万兆光纤网络比较常见的配置了. --- ### 网卡 #### MBP 网卡驱动 首先需要确定 **Macbook Pro M1 Max** 系统为 **macOS Sequoia** (版本 **15.1.1**) 能够免驱哪些万兆网卡. Apple 原生驱动有(`ls -d /System/Library/DriverExtensions/*AppleEthernet`): - com.apple.DriverKit-AppleEthernetE1000.dext: **Intel** 驱动 - **com.apple.DriverKit-AppleEthernetIXGBE.dext**: **Intel** 驱动 - com.apple.DriverKit-AppleEthernetIXL.dext: **Intel** 驱动 - com.apple.DriverKit-AppleEthernetMLX5.dext: **Mellanox** 驱动 **各驱动区别**: | **驱动名称** | **支持的硬件/芯片组** | **用途描述** | | ---------------------- | ---------------------------------------------------- | ----------------------------------- | | **AppleEthernetE1000** | Intel E1000 系列网卡 (千兆以太网) | 支持 Intel 的千兆网卡 | | **AppleEthernetIXGBE** | Intel IXGBE 系列网卡 (万兆以太网) | 支持 Intel 的万兆网卡 | | **AppleEthernetIXL** | Intel IXL 系列网卡 (25G/40G/100G Ethernet) | 支持 Intel 的 25G/40G/100G 高速网卡 | | **AppleEthernetMLX5** | Mellanox ConnectX-5 系列网卡 (25G/50G/100G Ethernet) | 支持 Mellanox 高性能网卡 (_Nvidia_) | 我们关注的是 10G 网卡驱动, 所以对应的是: **com.apple.DriverKit-AppleEthernetIXGBE.dext**. 下面是驱动信息: `cat /System/Library/DriverExtensions/com.apple.DriverKit-AppleEthernetIXGBE.dext/Info.plist` ```xml DriverKit_AppleEthernetIXGBE CFBundleIdentifier com.apple.DriverKit-AppleEthernetIXGBE CFBundleIdentifierKernel com.apple.iokit.IOSkywalkFamily IOClass IOUserNetworkEthernet IOPCIClassMatch 0x02000000&0xffffff00 IOPCIMatch 0x00008086&0x0000ffff IOPCITunnelCompatible IOProviderClass IOPCIDevice IOUserClass DriverKit_AppleEthernetIXGBE IOUserServerName com.apple.DriverKit-AppleEthernetIXGBE ``` 如上所示, 比较关键的是 `0x00008086&0x0000ffff`, 表示 PCI ID 为 8086 且忽略设备 ID. > PCI 供应商 ID 8086 将英特尔标识为设备制造商. > > [PCI 供应商 ID 可以在这里查询](https://devicehunt.com/all-pci-vendors). ##### Intel 网卡 [Intel 网卡](http://www.intel-china.com/product.html) 与驱动信息: | 型号 | 主芯片型号 | 协议标准 | 接口类型 | 驱动 | | -------------------------------------------------------------- | --------------- | --------- | ------------ | ------------------------------------------ | | [X520](http://www.intel-china.com/product.html?keyword=x520) | **Intel 82599** | **10GbE** | **SFP+** | **com.apple.DriverKit-AppleEthernetIXGBE** | | [X540](http://www.intel-china.com/info84.html) | Intel X540 | 10GbE | BASE-T | com.apple.DriverKit-AppleEthernetIXGBE | | [X550](http://www.intel-china.com/info126.html) | Intel X550 | 10GbE | BASE-T | com.apple.DriverKit-AppleEthernetIXGBE | | [X710](http://www.intel-china.com/product.html?keyword=x710) | Intel X710 | 10GbE | BASE-T/SFP+ | com.apple.DriverKit-AppleEthernetIXL | | [XXV710](http://www.intel-china.com/info135.html) | Intel XXV710 | 25GbE | SFP28 | com.apple.DriverKit-AppleEthernetIXL | | [XL710](http://www.intel-china.com/product.html?keyword=xl710) | Intel XL710 | 40GbE | BASE-T/QSFP+ | com.apple.DriverKit-AppleEthernetIXL | 网卡鉴赏环节: **Intel XXV710-DA2 Network Card** : ![20241229154732_6FVVlYgt.webp](https://cdn.dong4j.site/source/image/20241229154732_6FVVlYgt.webp) **Intel XL710-DA2 Network Card**: ![20241229154732_QinMzQmp.webp](https://cdn.dong4j.site/source/image/20241229154732_QinMzQmp.webp) ##### Mellanox | 型号 | 协议标准 | 接口类型 | 驱动 | | ----------------------- | -------- | -------- | ------------------------------------- | | CX4121A-ACAT ConnectX-4 | 25GbE | SFP28 | com.apple.DriverKit-AppleEthernetMLX5 | ![20241229154732_caeYgzvf.webp](https://cdn.dong4j.site/source/image/20241229154732_caeYgzvf.webp) > [最适合 MAC 的雷电 25G 网卡](https://www.bilibili.com/video/BV1fh4y1B7po), 这位 Up 主使用 `CX4121A-ACAT ConnectX-4` 网卡实现了在 Mac 下 25Gb 内网. > > 糟了, 心动的感觉..... > > 这不 25Gb 和 40Gb 的方案不就有了 🤣🤣🤣 --- ##### 网卡选择 满足需求的似乎就只有 **X520** 和 **X710** 这 2 个产品类别, 但价格上 **X520** 肯定更便宜, 而且网上的成功案例绝大部分都是 **X520**, 因此决定购买 **X520** 的双网口版本. 确认了网卡类别后, 就需要选择网口数量了, 我的需求是双光口, 而 **Intel X520** 双光口版本有多个, 分别是 **Intel X520-DA2** & **Intel X520-SR2** & **Intel X520-LR2**: ![20241229154732_dka4ET6j.webp](https://cdn.dong4j.site/source/image/20241229154732_dka4ET6j.webp) 其中 SR 和 DA 的区别仅在于购买时 **带不带光模块**(**SR 带, DA 不带**). 而 **Intel X520-LR2** 则需要 **单模** 的光模块, 它的价格更贵. X520 系列网卡的 Intel 82599 主芯片现在主要有 3 个型号: **82599EN** **82599ES** **82599EB** , 他们三者的区别: | **芯片型号** | **功耗 (典型)** | **特点** | **应用场景** | | ------------ | --------------- | -------------------------------------------------- | ------------------------------ | | **82599EB** | 5.2W | 支持 SR-IOV, 企业级功能, 虚拟化支持, 性能最强 | 高性能企业数据中心, 虚拟化环境 | | **82599ES** | 4.8W | 类似于 EB, 去除 SR-IOV, 较低功耗, 适合高吞吐量应用 | 数据中心, 高带宽需求的网络设备 | | **82599EN** | 4.2W | 低功耗和低成本, 去除高级功能, 适用于成本敏感应用 | 嵌入式系统, 低功耗和低成本应用 | > **SR-IOV** 是一种 I/O 虚拟化技术, 允许单一的物理设备 (如网卡) 被虚拟化并分配给多个虚拟机, 使得多个虚拟机能够直接访问硬件资源, 而无需经过传统的虚拟化中间层 (如 hypervisor 或虚拟化驱动) . SR-IOV 的核心特点包括: > > - **虚拟功能 (Virtual Functions, VFs)**:每个虚拟机被分配一个虚拟功能, 这些虚拟功能可以直接访问物理网卡的一部分资源. > - **物理功能 (Physical Function, PF)**:物理设备的主要功能, 它负责管理和分配虚拟功能, 并与虚拟机进行通信. > - **硬件直接访问**:通过 SR-IOV, 虚拟机可以绕过操作系统和 hypervisor 的网络堆栈, 直接与物理硬件交互, 提高性能, 减少延迟和 CPU 使用率. > > 总结起来就是如果需要玩 ESXi 和 PVE 的话, 最好还是上 **82599EB** 芯片的 X520. 这三个型号中, **82599EN** 只能支持**单口万兆**, **82599EB** 和 **82599ES** 则都能支持**双口万兆**, 这是它们三者之间最显著的差异. **82599ES** 相比 **82599EB** 则多了对 SFI(接 SFP+光模块) 和 10GBASE-KR(设备内部背板内接) 类型的支持, 对 SFI 接口的支持则表示 **82599ES** 芯片可以配合 SFP+ 模块使用, 也就是说 **82599ES 相比 82599EB 多了对光口的支持**, 最常用的 X520 **双口光纤万兆网卡就是采用的 82599ES 芯片**. --- 另外需要注意的是 **X520** 的总线类型是 **PCIe 2.0 x8**, 需要使用 **PCIEx8** 或 **PCIEx16** 的插槽, 如果使用 **PCIEx4**, 需要 3.0 版本, 否则无法达到线速, 下面贴一个 PCIe 各版本的的理论速度表: | **PCIe 版本** | **x1 理论带宽** | **x2 理论带宽** | **x4 理论带宽** | **x8 理论带宽** | **x16 理论带宽** | 传输速率 | | ------------- | --------------- | --------------- | --------------- | --------------- | ---------------- | --------- | | PCIe 2.0 | 0.5 GB/s | 1.0 GB/s | 2.0 GB/s | 4.0 GB/s | 8.0 GB/s | 5.0 GT/s | | PCIe 3.0 | 0.985 GB/s | 1.969 GB/s | **3.938 GB/s** | 7.877 GB/s | 15.754 GB/s | 8.0 GT/s | | PCIe 4.0 | 1.969 GB/s | 3.938 GB/s | 7.877 GB/s | 15.754 GB/s | 31.508 GB/s | 16.0 GT/s | | PCIe 5.0 | 3.938 GB/s | 7.877 GB/s | 15.754 GB/s | 31.508 GB/s | 63.015 GB/s | 32.0 GT/s | | PCIe 6.0 | 7.563 GB/s | 15.125 GB/s | 30.25 GB/s | 60.5 GB/s | 121.0 GB/s | 64.0 GT/s | 一路从 Apple 原生驱动到对网卡的关知识的基础了解, 最终确定选购的网卡为: [E10G42BTDA/X520-DA2](http://www.intel-china.com/info72.html), 目前二手价格也非常便宜, 应该算是最具性价比的万兆网卡. 参考: - [了解以太网术语 – 数据速率、互连介质和物理层](https://www.synopsys.com/zh-cn/china/resources/dwtb/dwtb-cn-ethernet-2017q2.html) - [10Gb 乙太网路连接规格概览:形形色色的 10GbE 连接方式](https://www.ithome.com.tw/tech/90786) #### Station 网卡 Station 主机原来有一张浪潮的 X540 万兆双电口网卡: ![20241229154732_QcCVPt6A.webp](https://cdn.dong4j.site/source/image/20241229154732_QcCVPt6A.webp) 这张卡可以称得上是 **年轻人的第一张万兆网卡**, 价格低的离谱, 因为这个卡是非标准的 x8+x1, 买回来不能直接用, 需要魔改一下(小黄鱼有成品), 参考 [双口万兆网卡低到 40 元, 这个万兆网卡性价比天花板](https://post.smzdm.com/p/arqvl82x/) 就行. 现在的问题是大热量大, 所以索性就一次性全部换成 [E10G42BTDA/X520-DA2](http://www.intel-china.com/info72.html), 还能少买 2 个光转电模块. --- #### M920x 网卡 网卡也是 **Intel X520-DA2**, 但是上次拆机时发现 PCB 已经弯曲变形了(不过并不影响使用), 等彻底报废的时候再考虑更换(或者下次升级到 40Gb? 😎) **网卡信息(82599ES 芯片):** ```bash $ inxi -n ... Device-2: Intel 82599ES 10-Gigabit SFI/SFP+ Network driver: ixgbe IF: enp1s0f0 state: up speed: 10000 Mbps duplex: full mac: xxx Device-3: Intel 82599ES 10-Gigabit SFI/SFP+ Network driver: ixgbe IF: enp1s0f1 state: up speed: 10000 Mbps duplex: full mac: yyy ... ``` --- ### 基础知识 #### GbE Gbps GBps | 术语 | 含义 | 单位 | 用途 | 换算关系 | | -------- | -------------------------------------------- | ----------- | ------------------------------------------ | ----------------- | | **GbE** | Gigabit Ethernet, 指 1 吉比特 **以太网协议** | 1 Gbps | 用于描述以太网协议的速率, 通常用于网络连接 | 1 GbE = 1 Gbps | | **Gbps** | Gigabits per second, 每秒传输的 **比特数** | 比特 (bit) | 用于描述数据传输速率, 常用于网络带宽 | 1 Gbps = 1/8 GBps | | **GBps** | Gigabytes per second, 每秒传输的 **字节数** | 字节 (Byte) | 用于存储设备、硬盘、SSD 等的性能测量 | 1 GBps = 8 Gbps | - **GbE** (Gigabit Ethernet) 通常用于描述网络连接的协议和带宽 (1Gbps) , 并表示该网络使用 **1 Gbps** 的速率. - **Gbps** 用于描述数据传输速率, 特别是网络带宽, 每秒传输的比特数, 也可以用 **Gb/s** 表示 - **GBps** 表示每秒传输的字节数, 通常用于描述存储设备的速度, 1 GB/s 等于 8 Gbps, 也可以用 **GB/s** 表示. #### 带宽与下载速度 | **概念** | **定义** | **单位** | **备注** | | ------------ | ---------------------------- | --------------------------------------- | ---------------------- | | **带宽** | 网络连接可以处理的数据传输量 | 兆比特每秒 (Mbps) 、千兆比特每秒 (Gbps) | 反映网络的最大传输能力 | | **下载速度** | 从网络中下载数据到设备的速率 | 字节每秒 (B/s) 、兆字节每秒 (MB/s) | 实际的数据传输速度 | | **单位换算** | 1 字节 = 8 比特 | | 下载速度 = 带宽 ÷ 8 | - **千兆带宽** (1 Gbps) 对应的理论下载速度为 **125 MB/s**. --- #### 网卡接口类型 | **术语** | **特点** | | ---------- | ----------------------------------------------------------------------------------- | | **BASE-T** | 使用 RJ-45 接口; 支持 Cat5e/Cat6/Cat6a 双绞线; 支持 1Gbps、2.5Gbps、5Gbps 和 10Gbps | | **SFP+** | 热插拔设计; 支持光纤或铜缆; 常用于万兆网络传输 | | **SFP28** | 与 SFP+ 外形兼容; 提供更高的速率 (25Gbps); 支持光纤或铜缆 | | **QSFP+** | 支持 4x10Gbps 或 1x40Gbps; 光纤接口为主; 热插拔设计 | ### MTU 最大传输单元 MTU (Maximum Transmission Unit) , 是指网络能够传输的最大数据包大小, 以字节为单位. MTU 的大小决定了发送端一次能够发送报文的最大字节数. 如果 MTU 超过了接收端所能够承受的最大值, 或者是超过了发送路径上途经的某台设备所能够承受的最大值, 就会造成报文分片甚至丢弃, 加重网络传输的负担. 如果太小, 那实际传送的数据量就会过小, 影响传输效率. 假设待发送的数据总共 _4000_ 字节, 假设以太网设备一帧最多只能承载 _1500_ 字节. 很明显, 数据需要划分成 _3_ 片, 再通过 _3_ 个帧进行发送: ![20241229154732_pNd2my0R.webp](https://cdn.dong4j.site/source/image/20241229154732_pNd2my0R.webp) **MTU 的特点** | 特点 | 描述 | | -------- | -------------------------------------------------------------------------------------------------- | | 默认值 | 以太网设备的 MTU 默认为 **1500 字节**. | | 影响 | **过小**:更多数据包, 增加 CPU 和网络设备的开销. **过大**:可能导致设备间不兼容, 数据包丢弃或分片. | | 分片问题 | 当数据包大小超过 MTU 时, 网络需要对数据包进行 **分片**, 可能增加延迟并带来丢包风险. | | 适配场景 | 不同场景下应根据需要调整 MTU, 以平衡性能和兼容性. | #### 巨型帧 | 特点 | 描述 | | -------- | ---------------------------------------------------------------------- | | 定义 | 将 MTU 设置为 **9000 字节** 或更大, 通常用于高性能传输场景. | | 优势 | 降低包头开销; 减少拆分与重组操作; 提高大文件传输效率 (约提升 10%-20%) | | 劣势 | 无 IEEE 标准, 可能存在兼容性问题; **需要网卡和交换机都支持巨型帧模式** | | 适用场景 | 大型文件传输、NAS 数据同步等高带宽需求场景. | ![20241229154732_sqsm5wVC.webp](https://cdn.dong4j.site/source/image/20241229154732_sqsm5wVC.webp) 当你在特定的网络任务下将 MTU 从 1500 设置为 9000, 会发现网络文件传输速度会有 10-20%的提升. 这是因为在巨型帧模式下, 网络传输设备相应地减少了拆开、组合数据包的次数, 一个巨型帧的数据包可以携带之前多个数据包所携带的数据量, 在大型文件传输的时候效率也就自然提高了. #### 使用建议 **双网卡双网络**: - 一个网络使用默认的 MTU (1500), 用于日常上网. - 另一个网络使用巨型帧 (9000), 用于高性能存储数据传输. 这正好符合我现在的网络环境(一电信宽带, 一联通宽带). **参考**: - [什么是 MTU](https://info.support.huawei.com/info-finder/encyclopedia/zh/MTU.html) - [入坑万兆网卡, 必须知道巨型帧与 MTU 的坑](https://www.bilibili.com/video/BV1Gz421z7CH/) - [家里网络复杂度提高, 如何最大限度发挥网络的效率](https://post.smzdm.com/p/alln7olp/) - [Jumbo Frames on vSphere 5](http://longwhiteclouds.com/2012/02/20/jumbo-frames-on-vsphere-5/) --- ### 交换机 我对交换机的要求为: - 能够设置 MTU; - 至少 8 个网口, 可以是 4 电口+ 4 光口, 也可以是 8 光口; - 体积不能过大, 最好没有内置散热, 需要时我可执行添加外置散热风扇随时启停; 交换机大致分为 **非管理型交换机(傻瓜交换机)**, **二层管理交换机** 和 **三层管理交换机**. 傻瓜交换机的工作原理是通过学习每个设备的 MAC 地址, 将设备的 MAC 地址和连接的端口映射到一个内部表 (称为**MAC 地址表**) , 当数据包进入交换机时, 交换机会查看数据包的目标 MAC 地址, 并查找对应的端口: - 如果 MAC 地址在表中, 数据包直接转发到目标端口; - 如果 MAC 地址不在表中, 交换机会将数据包广播到所有端口 (除源端口外) ; 傻瓜交换机的优势是简单, 不需要任何配置即可工作、便宜、稳定, 非常适合没有网络管理需求的场景. 要满足 **MTU 可设置的需求**, 则必须上二层或三层交换机, 那下面对两类交换机做个简单的对比. #### 二层 Or 三层 --- | 特性 | 二层交换机 | 三层交换机 | | ------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------- | | **工作层级** | 数据链路层 (OSI 模型的第 2 层) | 网络层 (OSI 模型的第 3 层) | | **主要功能** | 1. 根据 MAC 地址转发数据
2. 提供 VLAN 支持 | 1. 基于 IP 地址转发数据
2. 提供路由功能 | | **应用场景** | 1. 局域网内部设备互联
2. 适用于小型网络 | 1. 跨子网通信
2. 适用于大型网络与多子网 | | **硬件性能** | 1. 通常转发速度更快
2. 成本较低 | 包含路由功能, 成本较高 | | **VLAN 支持** | 支持 VLAN 的划分, 但需通过三层设备进行跨 VLAN 通信 | 支持跨 VLAN 路由, 直接实现不同 VLAN 间通信 | | **路由功能** | 不支持路由功能, 依赖外部路由器 | 内置路由功能, 可直接实现网络互联 | | **广播范围** | 广播域受 VLAN 限制 | 可隔离广播域, 减少广播对网络的影响 | | **MTU 支持** | 1. 仅负责在二层转发, 不解析 IP 报头
2. 不支持对 MTU 的动态调整 | 1. 可基于三层协议对 MTU 进行动态调整
2. 支持最大传输单元的优化配置 | **MTU 的对比** 1. **二层交换机中的 MTU** - 仅在数据链路层转发帧, 对 IP 分片和 MTU 超过时的处理不敏感. - 不支持动态调整, 默认 MTU 通常为 1500 字节, 适用于一般以太网传输. 2. **三层交换机中的 MTU** - 在网络层处理 MTU 时, 支持动态调整或告警 (如 ICMP "Fragmentation Needed" 消息) . - 支持更高 MTU (如 Jumbo Frame, 通常为 9000 字节) , 可减少分片开销, 提升网络传输效率. - 更适合高性能网络环境, 如数据中心或存储传输. **适用场景** 1. **二层交换机**: - 小型网络中设备互联. - 只需要在单一 VLAN 内通信时. - 适用于预算有限的简单网络需求. 2. **三层交换机**: - 需要跨子网通信或跨 VLAN 数据转发时. - 中大型网络中, 减少路由器的使用, 优化性能. - 需要隔离广播域以降低网络负载时. 所以家用万兆的话, 二层管理型交换机是最基本的硬件配置. #### 产品选择 | 厂商 | 型号 | 网口 | 管理类型 | 全新价格 | 二手价格 | | -------- | ------------------------------------------------------------------------ | ----------------------------------------------- | -------- | -------- | -------- | | TP-LINK | [**TL-ST5008F**](https://item.jd.com/10024251834106.html) | 8 个 10G 光口 + Management + RJ45 Console 口 | L3 | 1723 | 500+ | | TP-LINK | [**TL-ST2408PB**](https://item.jd.com/10126311926070.html) | 4 个 10G 电口(PoE) + 4 个 10G 光口 | 云管理 | 1999 | 无 | | 兮克 | [**SKS7300-4X4T**](https://item.jd.com/10099747620725.html) | 4 个 10G 电口 + 4 个 10G 光口 + RJ45 Console 口 | L2 | 1379 | 1000+ | | 兮克 | [**SKS8310-8X**](https://item.jd.com/10083994814588.html) | 8 个 10G 光口 + RJ45 Console 口 | L3 | 749 | 无 | | 兮克 | [**SKS8300-8X**](https://item.jd.com/10083994814588.html) | 8 个 10G 光口 + RJ45 Console 口 | L3 | 899 | 600+ | | 希力威视 | [**SR-ST1808F**](https://item.jd.com/10125095659788.html#crumb-wrap) | 8 个 10G 光口 | L3 | 599 | 无 | | 希力威视 | [**SR-ST3408F**](https://item.jd.com/10128375525835.html) | 4 个 10G 电口 + 4 个 10G 光口 + RJ45 Console 口 | L3 | 1139 | 800+ | | 希力威视 | [**SR-ST3808F**](https://item.jd.com/10090059711123.html#product-detail) | 8 个 10G 光口 + RJ45 Console 口 | L3 | 699 | 400+ | | 海思视讯 | [**F1100W-4SX-4XGT**](https://item.jd.com/10111892530726.html) | 4 个 10G 电口 + 4 个 10G 光口 | 轻管理型 | 1159 | 无 | | 海思视讯 | [**F1100W-8SX-SE**](https://item.jd.com/10095788058472.html) | 8 个 10G 光口 + RJ45 Console 口 | 轻管理型 | 499 | 无 | 上面这些是比较常见且符合需求的万兆交换机, 其他的品牌没有使用过不是很了解. 4 光 + 4 电 的方案应该是最合适的, 但是二手价格还是居高不下, 所以在对比后最终选择了 TP-LINK 的 [**TL-ST5008F**](https://www.tp-link.com.cn/product_1649.html), 二手价格还能接受, 且是三层管理型交换机, 可玩性更高. --- ## MBP 万兆最终方案 ### 首选方案 前面 [MBP 万兆参考方案](#MBP-万兆参考方案) 中一直存在些许顾虑: 一是 MBP 芯片和系统跟我不一样, 二是我需要双光口万兆网卡, 上述方案没有给到我是否兼容. 如果无法驱动该网卡, 可能需要借助 [SmallTree](https://small-tree.com/) 的驱动来解决. 然而, 这意味着我可能需要修改驱动的 PID 和 VID 才能正常工作, 这感觉有点复杂. 此外, 如果系统升级导致无法识别网卡, 我将不得不再次进行修改. 所以我更倾向于在 M1 系列的 MBP 上能够免驱的网卡. 好在了解了大致的方案以及对 [MBP 原生网卡驱动的了解](#MBP-网卡驱动) 后结合他人成功的案例, 最终敲定了首选方案: - 使用雷电扩展坞 (**JHL7540**) 方案(价格 345); - 使用 Intel X520-DA2 双光口万兆网卡(82599 芯片, 价格 80); 12V DC 电源和雷雳 3 线缆家里都用, 就不算到成本里面了. > 不要去搜 白苹果+X520, 价格会高出很多. ![20241229154732_B04TcIao.webp](https://cdn.dong4j.site/source/image/20241229154732_B04TcIao.webp) 看中的是具备下行雷雳接口, 可以组菊花链玩玩. ### 备选方案 - [ITGZ USB4.0 移动雷雳 4 硬盘盒](https://item.jd.com/10113618108196.html)(239); - M2 NVME 转 PCIe 转接板(15); - 12V DC 转 SATA 供电线(16); - Intel X520-DA2 万兆网卡 (80); ![20241229154732_dogEZEPL.webp](https://cdn.dong4j.site/source/image/20241229154732_dogEZEPL.webp) 想对 2 种方案做个对比, 即使备选方案失败或达不到要求, 也能将硬盘盒给 Mac mini M2 使用. ## 网络拓扑 ### 现状 我先梳理一下交换机和设备的总体情况: ![network-status-info-now.drawio.svg](https://cdn.dong4j.site/source/image/network-status-info-now.drawio.svg) - Mac mini 2018 和 Mac mini M2 自带 10G 电口; - DS923+ 和 Mac mini 2018 组了万兆网络, 其他设备全部使用 2.5G 网口连接(10G 口不够); - Station 的 10G 电口跑步满万兆, 因为万兆网口不够, 只能跑在 2.5G 带宽下; - M920x 的 2 个 10G 光口通过 DAC 分别连接到电信和联通网络, 但也只是 2.5G; 目前的现状总结一下就是: 具备万兆带宽能力的设备因为交换机的问题, 最高只能使用 2.5G 带宽, 万兆网络仅限于 NAS 和 Mac mini 2018, 无法满足其他设备的万兆需求. ### 规划 ![network-status-info.drawio.svg](https://cdn.dong4j.site/source/image/network-status-info.drawio.svg) - 添加一台万兆交换机, 将书房所有支持万兆的设备互联(万兆交换机已确认为 TP-LINK 的 [**TL-ST5008F**](https://www.tp-link.com.cn/product_1649.html)); - 电信网络作为 **万兆主干网**, 电信后面 2.5G 交换机的 10G 光口需要连接到万兆交换机, 因为距离较短, 直接使用 M920x 上的 DAC 线缆连接; - DS923+ 和 Mac mini 2018 通过原来的光模块连接到万兆交换机, 新增 1 个光转电模块给 Mac mini M2 连接到万兆交换机, 3 台设备其他网口连接到联通的 2.5G 网络; - MBP 和 Station 配置 [Intel X520-DA2](http://www.intel-china.com/info72.html) 网卡 , 分别连接到电信和联通万兆网络, 电信网络全设备万兆, 联通网络只有 MBP 和 Station 能够万兆直连; - M920x 原来剩下的一根 DAC 线缆连接连接万兆交换机, 与其他设备组成万兆局域网, 另一个万兆光口通过光纤+光模块连接联通后面 2.5G 交换机上的 10G 光口以接入联通网络; 将电信作为万兆主干网, **并将各设备的电信网卡作为第一优先级**, 平时在各设备共享文件时就能用上万兆网络. 联通作为备用网络, 全设备最低也是 2.5G 速度. ### 设备购买 梳理之后所需要的设备清单为: | 设备名称 | 规格 | 数量 | 单价 | 备注 | | ------------------------------------------------------------------- | --------------------------- | ---- | ---- | ---------------------------------------- | | [**TL-ST5008F V2.0**](https://www.tp-link.com.cn/product_1649.html) | 8 个 10G 光口 | 1 | 580 | 具备三层网络管理功能(需要修改 MTU 用) | | 光转电模块 | 万兆 | 1 | 120 | Mac mini M2 使用 | | 万兆网卡 | Intel X520-DA2 | 2 | 80 | MBP, Station 各一张 | | 光纤 | 多模双芯 OM3, LC 型光纤接口 | 5 | 5 | `2 * 1 米, 1 * 2 米, 2 * 5 米` 共计 5 条 | | 光模块 | SFP-10G-SR | 10 | 9 | 一条光纤 2 个光模块 | | 雷电扩展坞 | JHL7540 主控 | 1 | 345 | [MBP 万兆首选方案](#首选方案) 核心设备 | | ITGZ USB4.0 移动雷雳 4 硬盘盒 | ASM2464PD 主控 | 1 | 239 | [MBP 万兆备选方案](#备选方案) 核心设备 | | M2 NVME 转 PCIe 转接板 | - | 1 | 15 | [MBP 万兆备选方案](#备选方案) 配件 | | 12V DC 转 SATA 供电线 | - | 1 | 16 | [MBP 万兆备选方案](#备选方案) 配件 | 这次升级的总成本为 **1590**, 比直接买一个高端成品万兆网卡便宜, 而且还能将主要设备全部升级成万兆, 感觉还是挺划算的. **为什么不全部使用 DAC/AOC 线缆** 我的万兆设备都在书房, 最优解还是 DAC 线缆, 距离肯定没有问题(最长支持 10 米), 问题是 DAC 的线缆相对于光纤太粗太硬, 对于我现在的布线环境不是特别好处理, 而 AOC 线缆在 M920x 连接万兆光口时已经尝试过一次, 还是存在兼容性问题, 所以放弃了. 最主要的原因是想尝试一下光纤+光模块的方案, 顺带还可以学习了解下光纤相关的技术. ## 施工环节 ### 硬件 #### 加装散热 给 MBP 和 Station 使用的 Intel X520-DA2 网卡还是有必要加上散热风扇的, Station 是开放型机箱, 没有可靠的风道可以散热, MBP 就更不用说了, 只能外置. 还好有几个剩余的 4x4 CM 风扇, 怼上去就可以了, 直接用 USB 供电: ![20241229154732_5d1B0sxN.webp](https://cdn.dong4j.site/source/image/20241229154732_5d1B0sxN.webp) 给 Station 用的网卡就麻烦点, 用的是原来给浪潮 X540 配置的风扇, 但是供电线太短了, 需要外接. ![20241229154732_AbELRDHT.webp](https://cdn.dong4j.site/source/image/20241229154732_AbELRDHT.webp) 用玩树莓派时购买的 2PIN 2.54 杜邦线来延长一下供电线, 套上热缩管做绝缘: ![20241229154732_DBPlTyiV.webp](https://cdn.dong4j.site/source/image/20241229154732_DBPlTyiV.webp) 给点电就转, 完美: ![20241229154732_QDMi9akU.webp](https://cdn.dong4j.site/source/image/20241229154732_QDMi9akU.webp) 上机效果: ![20241229154732_ogS0vWp9.webp](https://cdn.dong4j.site/source/image/20241229154732_ogS0vWp9.webp) 顺便给交换机加个 `80x80cm` 的散热风扇, 反正空余的散热风扇还有很多: ![20241229154732_xH9BEfD5.webp](https://cdn.dong4j.site/source/image/20241229154732_xH9BEfD5.webp) #### MBP 网卡部署 小巧的**雷雳拓展坞**, 自带一个散热片, 桌面下正好留有一个 12V DC 供电线, 省去了供电线. ![20241229154732_tnUUaqSL.webp](https://cdn.dong4j.site/source/image/20241229154732_tnUUaqSL.webp) 组合后的样子, 加了 2 根扎带固定一下, 省去了一个 3D 打印支架: ![20241229154732_jxzfqHSe.webp](https://cdn.dong4j.site/source/image/20241229154732_jxzfqHSe.webp) 最后安装好的样子, 平时基本上不用动它. 用一根雷雳 3 的线连接到 MBP, 剩下的下行雷雳接口还未使用, 反正不能用这个雷雳接口连接到 Mac mini M2 上组菊花链, 会存在争抢网卡的情况: ![20241229154732_kz1hANjj.webp](https://cdn.dong4j.site/source/image/20241229154732_kz1hANjj.webp) --- **下面是使用 USB4.0 移动硬盘盒:** ![20241229154732_C5gKdVBT.webp](https://cdn.dong4j.site/source/image/20241229154732_C5gKdVBT.webp) ![20241229154732_161F7l0y.webp](https://cdn.dong4j.site/source/image/20241229154732_161F7l0y.webp) 说实话, 铝质的移动硬盘盒加上网卡确实比第一种方案要好看那么一点. #### 部署交换机 使用扎带安装到桌底的导线槽下面, Micro USB Console 端口连接到 MBP 拓展坞上面. 这是部署完成后的样子, 光转电模块加装了散热鳍片, 下面的风扇稍微有点风就能带走热量(噪音完全可以忽略, 因为风扇支持可变电压, 现在给的电压保证能转就行, 夏天的话噪音应该会大一点). ![20241229154732_GBqY52mh.webp](https://cdn.dong4j.site/source/image/20241229154732_GBqY52mh.webp) #### 光纤布线 了解光纤和光模块的类型后, 直接买的多模双芯的 OM3 光纤, LC-LC 接口: ![20241229154732_71Q9MNM4.webp](https://cdn.dong4j.site/source/image/20241229154732_71Q9MNM4.webp) 二手的 Intel 多模光模块, 价格便宜: ![20241229154732_JnVZWyq7.webp](https://cdn.dong4j.site/source/image/20241229154732_JnVZWyq7.webp) 光纤与光模块连接, 还闹了个笑话, 不知道 LC 接口还有一个保护壳, 一直纳闷为啥插不稳. 无意中掰了下 LC 接口的头子, 发现居然是个保护壳 🥲. ![20241229154732_ZNIlNeEH.webp](https://cdn.dong4j.site/source/image/20241229154732_ZNIlNeEH.webp) ### 软件 #### 万兆交换机 [TL-ST5008F V2.0安装手册1.1.2](https://service.tp-link.com.cn/download/202110/TL-ST5008F V2.0安装手册 1.1.2.pdf) {% pdf https://service.tp-link.com.cn/download/202110/TL-ST5008F%20V2.0%E5%AE%89%E8%A3%85%E6%89%8B%E5%86%8C%201.1.2.pdf %} ##### 网口连接 **TL-ST5008F** 的 业务端口默认 IP 为 `192.168.0.1`, 所以修改为同网段的 IP 即可访问 Web 管理端. 连接任意网口, 然后设置 IP 地址: ![20241229154732_S3nJ9Svk.webp](https://cdn.dong4j.site/source/image/20241229154732_S3nJ9Svk.webp) 使用 `http://192.168.0.1/` 访问 Web 管理端: ![20241229154732_7Aai7tAn.webp](https://cdn.dong4j.site/source/image/20241229154732_7Aai7tAn.webp) ##### Management 端口连接 **TL-ST5008F** 还提供一个 Management 管理口, 默认 IP 地址: `10.20.30.40`. 当电脑连接交换机管理口时, 需设置电脑 IP 地址为:`10.20.30.x`(`x` 为 1-254 间任意值, 不能为 40), 子网掩码设置为 255.255.255.0. ##### Console 端口连接 Console 端口用于和计算机或其他终端的串口相连以管理或配置交换机. **TL-ST5008F** 提供 1 个 Micro USB Console 端口和 1 个 RJ45 Console 端口, 两个端口 不能同时使用, 同时连接时只有 Micro USB Console 端口生效. 使用串口连接, 参数如下: - 波特率: TL-ST5012/TL-ST5008 波特率为 38400bps; - TL-ST5016F 波特率为 115200bps 数据位: - 8 位 奇偶校验:无 - 停止位:1 位 - 数据流控制:无 不知道是不是没有直接连接到 MBP(中间通过拓展坞链接), `ls /dev/cu.*` 并没有交换机的设备文件, 后面再尝试直连. ##### tenlet 连接 ![20241229154732_EcylLGlc.webp](https://cdn.dong4j.site/source/image/20241229154732_EcylLGlc.webp) 结束远程会话: ```bash TL-ST5008F>exit ``` 退出 telnet: ``` # 在 Telnet 会话中, 按下 Ctrl + ] 键, 这将带您回到 Telnet 的命令提示符. # 在 Telnet 命令提示符下, 输入 quit 命令, 然后按回车键, 即可退出 Telnet 会话. telnet> quit # 或者 telnet> close ``` ##### 远程管理 也没有连接成功, 一直提示 **设备未在线**, 后面再处理. ##### MTU 设置 **按理说这个是实时生效的, 结果却是需要重启一下交换机才能生效**. ![20241229154732_gIa5QUb9.webp](https://cdn.dong4j.site/source/image/20241229154732_gIa5QUb9.webp) **2025-02-16 更新** 因为忘交电费导致家里停电, 来电之后发现 DS923+ 于交换机只能协商到 5G 速度, 后来查阅资料后将光转电模块的网口全部强制设置为 10G 速度, 这样就可以了. > [土味的家用万兆网络TP-LINK厂TL-ST5008F光口全万兆交换机开箱](https://www.chiphell.com/forum.php?mobile=0&mod=viewthread&ordertype=1&tid=2244916) > > 交换机的默认设置是全10G协商,你可以改成Auto和10G、1000M、100M、10M依次。注意如果用光转电口模块接路由器的话吗,一定要把端口协商速度设置成1000M,不能用Auto!!! 第二个问题是交换机本身联网的问题, 我将 **TL-ST5008F** 的系统时间获取方式修改为 **从NTP服务器获取**, 但是却无法联网, 另外云管理功能也无法使用, 应该是设备本身没有连接到互联网. 我进行了如下配置: **修改管理口 IP** ![20250216223021_OKPNk3PA.webp](https://cdn.dong4j.site/source/image/20250216223021_OKPNk3PA.webp) **添加静态** ![20250216173610_Zy1N0rWT.webp](https://cdn.dong4j.site/source/image/20250216173610_Zy1N0rWT.webp) 然后将管理口通过 RJ45 连接到路由器, 这样就可以通过 `192.168.31.58` 登录交换机 Web 控制台了. #### MBP **系统识别到 PCI 设备**: ![20241229154732_G3LckVis.webp](https://cdn.dong4j.site/source/image/20241229154732_G3LckVis.webp) **X520** 的总线类型是 **PCIe 2.0 X8**, PCle 2.0 协议的每一条 Lane 支持 `5 * 8 / 10 = 4 Gbps = 500 MB/s` 的速率, x4 一共就是 16Gbps, 完全满足 10Gbps 的带宽. > [PCIE2.0/PCIE3.0/PCIE4.0/PCIE5.0 接口的带宽、速率计算](https://blog.51cto.com/u_3619476/5145033) --- 网卡的雷雳总线信息, 最高有 20Gb/s 的速度(按理说 **JHL7540** 最高应该有 24 Gb/s): ![20241229154732_qqWePtcX.webp](https://cdn.dong4j.site/source/image/20241229154732_qqWePtcX.webp) 以太网设备信息: ![20241229154732_aSBYnbPI.webp](https://cdn.dong4j.site/source/image/20241229154732_aSBYnbPI.webp) - `I225 LMVP` 是 CalDigit T4 的 2.5G 网卡; - 然后是 X520-DA2 2 个光口网卡; - 第四个是 USB 转 2.5G 网卡; - 最后一个是另一个扩展坞上面的 1G 网卡; --- 最后是网络设置中的硬件信息: 网卡速度支持 **1000base-SX**(1 Gbps) 和 **10Gbase-SR**(10 Gbps), 不支持 2.5G, 而且 MTU 最高只能设置到 `2034`: ![20241229154732_OjgPuJcA.webp](https://cdn.dong4j.site/source/image/20241229154732_OjgPuJcA.webp) --- **下面是使用 USB4.0 的硬盘盒的硬件信息:** 在 MBP 识别出的速度是 20 Gb/s(安装的万兆网卡), 而在 Mac mini M2 上则是 40Gb/s(安装的 m.2 固态): ![20241229154732_4l82CmVy.webp](https://cdn.dong4j.site/source/image/20241229154732_4l82CmVy.webp) `Intel X520-DA2` 网卡成功识别: ![20241229154732_RN2xdpyR.webp](https://cdn.dong4j.site/source/image/20241229154732_RN2xdpyR.webp) #### Mac mini Mac mini 2018 和 Mac mini M2 的网口都是万兆电口, 能够顺利开启 9000 巨帧, 且支持 `100M/1000M/2.5G/5G/10G` 速度: ![20241229154732_BbX76D1A.webp](https://cdn.dong4j.site/source/image/20241229154732_BbX76D1A.webp) #### Ubuntu **万兆设备全部接入到电信网络, 首先需要调整一下网卡的优先级.** M920x(`192.168.31.77`) 和 Station(`192.168.31.66`) 是 Ubuntu 系统, 直接在命令行下调整网卡优先级: ```bash # 首先查看一下路由表 ip route # 假设我需要调整网卡的优先级为: 10G.T > 1G.T > 10G.U > 1G.U (2 个新增的 10G 网卡, 系统自带的 2 个 1G 网卡) sudo nmcli connection modify 10G.T ipv4.route-metric 100 sudo nmcli connection modify 1G.T ipv4.route-metric 200 sudo nmcli connection modify 10G.U ipv4.route-metric 300 sudo nmcli connection modify 1G.U ipv4.route-metric 400 # 重启 NetworkManager sudo systemctl restart NetworkManager ``` 设置 **MTU 为 9000** 后, 测试一下在不分包的情况下整个链路是否能处理 9000 字节的数据包: ```bash $ ping -M do -s 8972 -c 2 192.168.31.66 PING 192.168.31.66 (192.168.31.66) 8972(9000) bytes of data. 8980 bytes from 192.168.31.66: icmp_seq=1 ttl=64 time=0.069 ms 8980 bytes from 192.168.31.66: icmp_seq=2 ttl=64 time=0.048 ms --- 192.168.31.66 ping statistics --- 2 packets transmitted, 2 received, 0% packet loss, time 3048ms rtt min/avg/max/mdev = 0.033/0.049/0.069/0.012 ms ``` > 使用 `-M do` 参数时, ping 会设置 `Don’t Fragment` (DF) 标志, 要求整个数据包不能被分段. 如果数据包大于网络路径中的任何设备的 MTU, 传输就会失败并返回错误. 比如: ```bash $ ping -M do -s 8973 -c 2 192.168.31.77 PING 192.168.31.77 (192.168.31.77) 8973(9001) bytes of data. ping: local error: message too long, mtu=9000 ping: local error: message too long, mtu=9000 --- 192.168.31.77 ping statistics --- 2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 3077ms ``` > macOS 上 `ping` 没有 `-M do` 参数, 正确的方式应该是: `ping -D -s 8972 192.168.31.66` 超过了 9000 直接报错. 还可以使用 `tracepath` 来获取整个链路的 MTU: ```bash $ tracepath 192.168.31.77 1?: [LOCALHOST] pmtu 9000 1: eab2b0924b06 0.400ms reached 1: eab2b0924b06 0.363ms reached Resume: pmtu 9000 hops 1 back 1 ``` #### DS923+ 因为在使用 [Synology Virtual Machine Manager ](https://www.synology.cn/zh-cn/dsm/feature/virtual_machine_manager), 安装了 **Open vSwitch**, 在 Web 端修改 MTU 9000 只是修改物理网卡, 对应的虚拟网卡还是 1500, 所以需要在命令行下去修改: ```bash # 显示 Open vSwitch 的接口: root@DS923:~# ovs-vsctl show ac42c09b-b421-4802-9883-0b2b9bb93cc9 Bridge "ovs_bond0" Port "ovs_bond0" Interface "ovs_bond0" type: internal Port "bond0" Interface "eth1" Interface "eth0" Bridge "ovs_eth2" Port "ovs_eth2" Interface "ovs_eth2" type: internal Port "eth2" Interface "eth2" ``` 万兆网卡对应的虚拟网卡是 `ovs_eth2`, 物理网卡 `eth2` MTU 已经修改到 9000, 只需要同步到虚拟网卡即可: ```bash ip link set ovs_eth2 mtu 9000 ``` 查看结果: ```bash ip addr show ovs_eth2 8: ovs_eth2: mtu 9000 qdisc noqueue state UP group default qlen 1 ... ``` 最后是测试全链路是否都是 9000: ```bash $ tracepath 192.168.31.2 1?: [LOCALHOST] pmtu 9000 1: 192.168.31.2 0.690ms reached 1: 192.168.31.2 0.320ms reached Resume: pmtu 9000 hops 1 back 1 ``` --- ## 效果测试 ### iperf3 #### 非 MBP 设备 除了 MBP, 其他设备不管是上传还是下载, 使用 iperf3 都能跑完 10G 带宽: ![20241229154732_DPk3ENs3.webp](https://cdn.dong4j.site/source/image/20241229154732_DPk3ENs3.webp) #### MBP MBP 使用[万兆首选方案](#首选方案), 因为 MTU 的原因, 上传能跑到接近 7G: ![20241229154732_PbfBPmmd.webp](https://cdn.dong4j.site/source/image/20241229154732_PbfBPmmd.webp) 下载能跑到 8G+: ![20241229154732_LGqe9sab.webp](https://cdn.dong4j.site/source/image/20241229154732_LGqe9sab.webp) 使用[万兆备选方案](#备选方案), 上传能跑到 5.5G, 下载能跑到 6G: ![20241229154732_9hK4Hp57.webp](https://cdn.dong4j.site/source/image/20241229154732_9hK4Hp57.webp) 所以还是选择了第一种方案, 这个硬盘盒就留给 Mac mini M2 使用了. 简单的测试了一下, 读取速度比 M2 自带的 256G 要高出 500MB/s 左右, 写入速度反而低了 500MB/s 😅, 可能跟我的固态有关, 这个问题就不去深究了, Mac mini M2 缺的是磁盘空间, 这次算是补足了. ![20241229154732_7vOQitgd.webp](https://cdn.dong4j.site/source/image/20241229154732_7vOQitgd.webp) ### 文件拷贝 测试使用 SMB 协议挂载 DS923+ 的机械硬盘到本地, 然后直接拷贝到本机. #### Mac mini 2018 ![20241229154732_YcHRYBAK.webp](https://cdn.dong4j.site/source/image/20241229154732_YcHRYBAK.webp) #### MBP ![20241229154732_dtrseCUV.webp](https://cdn.dong4j.site/source/image/20241229154732_dtrseCUV.webp) 使用 `Disk Speed Test` 测试结果: ![20241229154732_Xc9tf9Vi.webp](https://cdn.dong4j.site/source/image/20241229154732_Xc9tf9Vi.webp) ### scp 在 MBP 下测试, 速度稳定在 `150MB/s` + ![20241229154732_Ntmpoc0j.webp](https://cdn.dong4j.site/source/image/20241229154732_Ntmpoc0j.webp) ### rsync 在 MBP 下测试, 稳定在 `120MB/s` + ![20241229154732_ZAUBh8ng.webp](https://cdn.dong4j.site/source/image/20241229154732_ZAUBh8ng.webp) ## 遗留问题 ### MBP MTU 因为网卡驱动的问题, MTU 无法修改到 9000, 因为 Intel 官方并没有为其万兆网卡提供官方的驱动支持, 好在 [Small Tree](https://small-tree.com/) 这家公司为 8259x 芯片的网卡编写了 macOS 驱动, 该驱动同样可以适配到任何使用 8259x 芯片的网卡上, 包括我手头这张 X520. 但是根据 [Small Tree 提供的驱动](https://small-tree.com/support/downloads/10-gigabit-ethernet-driver-download-page/) , 它的文档明确说明了该驱动只会在 Subsystem ID 为 `000A` 的网卡上运行, 因此如果使用 Subsystem ID 不满足要求的话, 是无法驱动的. 所以需要通过一些办法强行修改为 `000A`, 然后再安装 Small Tree 的驱动即可. > 还没有实际操作, 等有时间再尝试一下. 修改方式参考: - [Intel X520 网卡的 Windows / macOS 驱动方法](https://malash.me/202207/intel-x520-drivers-on-windows-and-macos/) - [给 MacOS 主机升级万兆网卡记录](https://post.smzdm.com/p/akl5we7r/) ### 拓展坞带宽 因为给万兆网卡使用的雷雳拓展坞有 2 个雷雳接口, 原想让 MBP 通过一根雷雳 4 接口就可以把所有的设备全部带起来, 这些设备包括: - 2 台 4K 显示器; - 新增的万兆网卡; - 若干 USB 设备; 但是因为雷雳 4 带宽受限, 导致部分 USB 设备无法正常使用, 只能再使用一个雷雳接口单独连接网卡的雷雳拓展坞了, 所以这次升级略带遗憾的是没有考虑到带宽的问题. 2023 年 9 月 12 日, 英特尔发布了雷雳 5 (Thunderbolt 5) 规范, 兼容最新的 USB4 V2 规格. 在对称模式下, 它提供 80Gbps 的上下行传输带宽, 是雷雳 4 的两倍;而在非对称模式下单向带宽最高可达 120Gbps, 是雷雳 4 的三倍. 而 Mac mini M4 提供 3 个雷雳 5 接口, 科技发展的真的太快了, 钱包已经跟不上了 😂. ## 多台 Mac 互联 ### 雷雳技术 ![20241229154732_rHxPCdFF.webp](https://cdn.dong4j.site/source/image/20241229154732_rHxPCdFF.webp) 雷雳技术, 由英特尔开发, 是一种先进的连接标准, 它通过单一连接点提供电源、数据和视频信号. 雷雳技术的认证确保了电缆、电脑和配件满足强制性的最低要求, 以此保障不同设备和供应商之间的兼容性和互操作性. 对于终端用户来说, 基于雷雳技术的产品在连接各种配件时, 能够提供卓越的体验. 在 Apple Mac 系列产品中, 雷雳 3 和雷雳 4 端口实际上与 Intel 的雷雳 3 和雷雳 4 技术是相同的. Apple 常常为其软硬件产品赋予新的名称, 例如当 Mac 从 Intel 芯片转向 Apple 芯片时, 雷雳 3 的名称变更为“雷雳/USB4”, 尽管技术上仍然是雷雳 3. 而雷雳 4 (USB-C) 端口则是 Mac 上真正的雷雳 4 端口. USB4 和雷雳 4 的主要差异在于, 尽管它们的最高带宽标准相同, 但雷雳 4 的最低性能标准要高于 USB4. 例如, USB4 的带宽可以低至 20Gbps, 充电功率可以低于 100W, 且连接显示器时不必满足雷雳 4 的标准. 雷雳 4 技术提供以下特性: - **高带宽**:40 Gbps 的带宽可用于数据和视频传输, 能够动态分配, 适合处理繁重的工作负载和快速文件传输. - **快充**:支持高达 100W 的电源输出, 用于为笔记本电脑充电, 以及高达 15W 的电源输出, 用于为配件供电. - **丰富的显示选项**:单个连接可以支持两台 4K 60 Hz 的显示器或一台 8K 60 Hz 的显示器. USB-C 是一种连接器类型, 它不同于之前广泛使用的 USB-A 连接器和 Apple 的 Lightning 连接器. 雷雳 3、雷雳 4、USB4、USB3.2、USB2.0 等多种协议都可以使用 USB-C 连接器. 通常情况下, 雷雳 3 和雷雳 4 仅提供 USB-C 接口, 而 USB4 和 USB3.2 则可能同时提供 USB-C 和 USB-A 接口及线缆, 或者提供 USB-C 接口搭配 USB-A 转接头. 要区分 USB-C 的接口或线缆, 需要查看产品的印刷标志或详细参数说明. 以 Apple 的 Lightning 接口为例, 最新的 iPhone 15 系列随机附送的 USB-C 接口线缆实际上遵循的是 USB 2.0 标准, 其传输速率仅为 480Mbps. 若要提升速度, 用户需额外购买 USB 3.2 线缆或雷雳 3、雷雳 4 线缆. 值得注意的是, 只有 iPhone 15 Pro 和 iPhone 15 Pro Max 支持 USB 3.2 第 2 代, 能够实现最高 10Gbit/s 的快速数据传输. 关于 USB 版本协议的命名, 确实有些复杂. 但只需记住, USB3.2 的 10Gbit/s 是 USB3 系列的高标准速度 (而 USB3.2 Gen2x2 的 20Gbit/s 由于兼容性问题, 通常不作为主流标准) . 这一速度常用于市面上常见的 SSD 固态移动硬盘传输. USB4 有 20Gbit/s 和 40 Gbit/s 两个版本, 而雷雳 3 和雷雳 4 均提供 40Gbit/s 的带宽. 一根雷雳 3 线缆通常能够满足大多数单一设备的连接需求. 雷雳 3 和雷雳 4 均标榜一个接口多种用途, 因此, 当一根线缆通过扩展坞连接多个设备时, 其带宽是动态分配的. 雷雳 4 相较于雷雳 3, 在数据传输带宽上提供了更高的分配, 并且在连接显示器的标准上也更为严格. 因此, 如果你追求最高的数据传输速度或者需要连接多个高标准设备, 那么选择雷雳 4 线缆将是更佳的选择. 当然, 除了线缆本身, 设备间的传输接口和设备本身的性能也会对整体传输效果产生影响. > 如果你想尝试一下 **雷雳 5 80Gbps** 的极限速度, 最新的 [Mac mini M4](https://www.apple.com.cn/shop/buy-mac/mac-mini/m4) 或许是最好的选择. --- ### 雷雳组网 目前有 3 台 macOS 设备, 且都有空余的雷雳接口, 所以想着组个 20G 网络玩玩. 除了 Mac mini 2018 是雷雳 3 接口外, 其他 2 台全是雷雳 4 接口. 雷雳 3 理论也只有 22Gb/s 的带宽用于数据传输, 使用雷雳 4 端口+雷雳 4 数据线可以到达 32Gb/s 的理论传输速度. 下面是连接拓扑图: ![mac-thunderbolt.drawio.svg](https://cdn.dong4j.site/source/image/mac-thunderbolt.drawio.svg) 最开始是将 M2 作为 MBP 和 Mac mini 2018 的中继节点来连接的, 这样至少能保证 MBP 到 M2 能达到雷雳 4 的速度, 但是实践后发现 M2 和 MBP 的雷雳连接经常断开, 连接方式是 MBP 通过雷雳 4 连接 CalDigit TS4 后再连接到 M2, 可能因为 CalDigit TS4 (2 台 4K 显示器, 其他接口基本上插满了) 挂的设备太多不稳定导致的. 所有才使用了上述连接方案, 且 Mac mini 2018 正好有 2 个空闲的雷雳接口, M2 还能空出一个雷雳 4 接口以便后续连接存储设备. 这样连接后, 只有雷雳 3 的 22Gb/s 理论速度了, 不过稳定最重要, 速度至少比 10G 网线快. 下面是具体的连接方式. ### 雷雳网桥 > 需要了解的一个前提是, 雷雳 4 宣称的 40Gbit/s 带宽是基于比特 (bit) 这一单位的. 然而, 在软件和系统显示的速度单位通常是字节 (Byte) , 其中 1 字节等于 8 比特. 因此, 当移动硬盘标榜其传输速度为 1000MB/s 时, 这实际上相当于 8000Mbit/s, 或者大约等同于 7.8Gbit/s. 3 台 Mac 使用雷雳 3 连接后, 会自动在 **网络** 中创建一个名为 **雷雳网桥** 的接口, 然后只需要设置 IP 和子网掩码即可组网: ![20241229154732_ByFZlxZp.webp](https://cdn.dong4j.site/source/image/20241229154732_ByFZlxZp.webp) ### 测试 #### iperf3 使用 `iperf3` 进行内网测试: ```bash iperf3 -f MB --omit 5 --time 20 -с 1.0.0.4 ``` ![20241229154732_o9BLJ9Cd.webp](https://cdn.dong4j.site/source/image/20241229154732_o9BLJ9Cd.webp) **22 Gb/s 等于 2750 MBytes/s**, 那么大概只跑到了理论速度的 **90%**, 对于这个结果还是比较满意的. #### SMB 测试 打开 Mac mini 上的文件共享来传输文件, 连接使用 Mac mini 上雷雳网桥分配的 IP, 打开 MBP 上的访达, 按 ⌘ + K 健 (或点选菜单“前往 > 连接服务器“) , 输入 smb://1.0.0.4 , 以及对应的用户名密码就可以进行连接. **MBP ==> Mac mini 2018** ![20241229154732_UFoXi4OP.webp](https://cdn.dong4j.site/source/image/20241229154732_UFoXi4OP.webp) **Mac mini 2018 ==> MBP** MBP 的 SMB 居然还没有被限速的 256GB Mac mini 快, 我想可能的原因是 `CalDigit TS4` 的带宽被占用太多了, 有时间用雷雳接口直连再试试. ![20241229154732_TlkoYPuV.webp](https://cdn.dong4j.site/source/image/20241229154732_TlkoYPuV.webp) 这就有点尴尬了, 4T 的 MBP 本地测试可以跑到 6000MB/s +: ![20241229154732_eCAhuv1U.webp](https://cdn.dong4j.site/source/image/20241229154732_eCAhuv1U.webp) 拖动一个大于 5GB 的文件 (顺序读取) , 在菜单栏 iStat Menus 的网络监控中可以看到, 传输速度可以到达 800MB/s, 等于 6.4 Gb/s. 回传文件 (顺序写入) 则是到了 600MB/s 左右. 直接连接 USB3.2 协议的移动硬盘, 跟这个速度相差不大, 但是直接连接雷雳 4 协议的移动硬盘, 是可以轻松超过 2000MB/s 的传输速度. ![20241229154732_C9YLZacD.webp](https://cdn.dong4j.site/source/image/20241229154732_C9YLZacD.webp) ### scp 差一点突破 `200MB/s`: ![20241229154732_j5uObYLq.webp](https://cdn.dong4j.site/source/image/20241229154732_j5uObYLq.webp) ### 未知状态 如果使用自动添加的雷雳网桥的话, 虽然连接没有问题, 但是会出现 **未知状态** 的警告, 对于强迫症的人肯定不能忍, 所以翻看了一些 [教程](https://forums.macrumors.com/threads/problem-solved-help-configuring-thunderbolt-sharing-for-internet-between-two-m1-macs.2388249/) 来解决这个问题. 如果有多个雷雳接口, 连接其中一个雷雳接口的时候, 系统会自动创建一个 **雷雳网桥** 的接口, 这个接口里面包含了所有的雷雳接口, 如下图所示, M2 的 2 个雷雳接口全部被自动添加到了这个 **雷雳网桥** 中, 但是只有 **雷雳 2** 接口在使用. ![20241229154732_JD1hRYBF.webp](https://cdn.dong4j.site/source/image/20241229154732_JD1hRYBF.webp) 所以解决的办法就是删除这个 **雷雳网桥**, 然后自己创建一个雷雳接口: 点击 **设置->网络** 页面右下角的 **...**, 然后点击 **管理虚拟接口**: ![20241229154732_5Tpmknjm.webp](https://cdn.dong4j.site/source/image/20241229154732_5Tpmknjm.webp) 先删除这个接口, 然后新建一个雷雳接口: ![20241229154732_Zp9WYM2X.webp](https://cdn.dong4j.site/source/image/20241229154732_Zp9WYM2X.webp) 这里需要确认是第几个雷雳接口, 可以查看系统信息: ![20241229154732_Ihsgz8mi.webp](https://cdn.dong4j.site/source/image/20241229154732_Ihsgz8mi.webp) 所以上面就应该选择 **雷雳 2**, 创建之后稍等一会, 就会出现一个已自动分配 IP 的雷雳接口, 设置 IP 后即可显示正常: ![20241229154732_pVR4FkMO.webp](https://cdn.dong4j.site/source/image/20241229154732_pVR4FkMO.webp) 因为 Mac mini 2018 需要同时连接 Mac mini M2 和 MBP, 所以需要使用到雷雳网桥, 只需要将这个自动创建雷雳网桥中不需要的雷雳接口删除即可. 首先确认接口: ![20241229154732_LeVg4NCM.webp](https://cdn.dong4j.site/source/image/20241229154732_LeVg4NCM.webp) 修改 **雷雳网桥**, 移出多余的接口即可: ![20241229154732_enZ0kMhQ.webp](https://cdn.dong4j.site/source/image/20241229154732_enZ0kMhQ.webp) --- ## 总结 这次将书房的主要设备全部升级到万兆, 从学习 MBP 万兆方案, 到准备工作中对一些基础知识的了解, 最后的安装部署调试, 再到这篇博客, 耗时一周时间. 升级后的效果非常显著. 除了 MBP 由于驱动问题 MTU 无法修改到 9000 以外, 其他设备的上传和下载速度都接近或达到了理论值. 尽管在升级过程中也遇到了一些问题, 但这些问题的解决方法和未来的改进方向都已经明确. 等到换成雷雳 5 的设备时, 又可以考虑 25G 甚至时 40G 的升级方案了. **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## [为 Hexo 博客引入智能对话:集成 AI 聊天机器人全攻略](https://blog.dong4j.site/posts/385a05d5.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 最近在玩儿 [检索增强生成](https://aws.amazon.com/cn/what-is/retrieval-augmented-generation/)(RAG,Retrieval Augmented Generation), 本地部署了一套 dify, 应该算是 RAG 功能最全的开源项目了, 可以集成工大厂商的 AI API 以及自建的 LLM 服务. 所以就用 dify 做了一个个人知识库, 数据来源与博客内容. dify 的部署以及使用可查看官方文档, 写的非常详细, 这里只是介绍一下如何将 dify 集成到 hexo 的博客中. 根据 [官方文档](https://docs.dify.ai/zh-hans/guides/application-publishing/embedding-in-websites) 的说明, 我选择使用 `script 标签方式` 集成 dify 到博客中, 这种方式会有一个聊天机器人按钮, 不会影响博客的整体体验: ![20250211194203_i5qTaCtZ.webp](https://cdn.dong4j.site/source/image/20250211194203_i5qTaCtZ.webp) --- ## 集成 首先从 dify 获取嵌入到网站中的代码, 比如下面这样: ```javascript ``` `embed.min.js` 文件我下载到本地并上传到了 CDN, 然后对上述代码进行了拆分. > 根据个人 hexo 配置的不同部分步骤或代码不一定适用, 但是思路都是一样. 在 `source/js/dify` 目录下新建下面 2 个 js 文件(`/js/dify` 没有就新建, 当然你可以在其他地方创建, 只要能被 hexo 引用) `dify-chat.embed.min.js` 用于保存上述 `embed.min.js` 中的代码. 另一个是 `dify-chat.js`: ```javascript window.difyChatbotConfig = { // 必填项,由 Dify 自动生成 token: "xxxxxxxxxxxx", // 可选项,默认为 false isDev: false, // 可选项,当 isDev 为 true 时,默认为 '[https://dev.udify.app](https://dev.udify.app)',否则默认为 '[https://udify.app](https://udify.app)' baseUrl: "https://dify.dong4j.ink:1024", // 可选项,可以接受除 `id` 以外的任何有效的 HTMLElement 属性,例如 `style`、`className` 等 containerProps: {}, // 可选项,是否允许拖动按钮,默认为 `false` draggable: false, // 可选项,允许拖动按钮的轴,默认为 `both`,可以是 `x`、`y`、`both` dragAxis: "both", // 可选项,在 dify 聊天机器人中设置的输入对象 inputs: { // 键是变量名 // 例如: // name: "NAME" }, }; ``` 然后对 css 样式进行自定义修改, 可以新建 `dify-chat.css` 文件, 也可以根据官方文档, 在 `difu-chat.js` 进行添加, 我这里选择新建 css 文件: ```css :root { /* 按钮距离底部的距离,默认为 `1rem` */ --dify-chatbot-bubble-button-bottom: 4.5rem; /* 按钮距离右侧的距离,默认为 `1rem` */ --dify-chatbot-bubble-button-right: unset; /* 按钮距离左侧的距离,默认为 `unset` */ --dify-chatbot-bubble-button-left: 1.5rem; /* 按钮距离顶部的距离,默认为 `unset` */ --dify-chatbot-bubble-button-top: unset; /* 按钮背景颜色,默认为 `#155EEF` */ --dify-chatbot-bubble-button-bg-color: #155EEF; /* 按钮宽度,默认为 `50px` */ --dify-chatbot-bubble-button-width: 35px; /* 按钮高度,默认为 `50px` */ --dify-chatbot-bubble-button-height: 35px; /* 按钮边框半径,默认为 `25px` */ --dify-chatbot-bubble-button-border-radius: 30px; /* 按钮盒阴影,默认为 `rgba(0, 0, 0, 0.2) 0px 4px 8px 0px)` */ --dify-chatbot-bubble-button-box-shadow: rgba(0, 0, 0, 0.2) 0px 4px 8px 0px; /* 按钮悬停变换,默认为 `scale(1.1)` */ --dify-chatbot-bubble-button-hover-transform: scale(1.1); } ``` 我将按钮固定到了左下角, 这样不会影响右下角的 hexo 按钮. ### hexo 加载 dify 对于移动设备不是很友好, 所以我只在非移动设备上开启了 dify 聊天功能: ```javascript function loadStylesheet(href) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; document.head.appendChild(link); } function loadScript(src, async = true) { const script = document.createElement('script'); script.src = src; script.async = async; document.body.appendChild(script); } function isMobileDevice() { const userAgent = navigator.userAgent || navigator.vendor || window.opera; return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent.toLowerCase()); } if (!isMobileDevice()) { // 如果不是移动设备,动态加载CSS和JS文件 loadStylesheet('https://cdn.dong4j.site/source/static/dify-chat.css'); loadScript('https://cdn.dong4j.site/source/static/dify-chat.embed.min.js'); loadScript('https://cdn.dong4j.site/source/static/dify-chat.js'); } ``` 整体效果: ![20250211191000_YbycCSgV.webp](https://cdn.dong4j.site/source/image/20250211191000_YbycCSgV.webp) ## 总结 目前知识库还在持续建设, 因为现在 DS 的接口还不太稳定, 现在还使用的 GLM4 来提供 AI 对话服务, 后期会将 LLM 服务更换为 DeepSeek-R1. ## [树莓派集成 PCA9685 舵机控制与流媒体服务器视频推流的综合应用](https://blog.dong4j.site/posts/a21c0fd1.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 在 [[raspberry-pi-stream|基于树莓派的视频推流方案]] 我们尝试了通过树莓派推流到流媒体服务器, 然后通过 Web 查看视频, 这次我们来尝试一下通过树莓派控制舵机. 想法这这样的, 使用一个 Web 页面实时展示 2 个摄像头的画面, 然后通过 PCA9685 舵机来控制摄像头角度.这样就可以实现一个简单的监控了. ## PCA9685 PCA9685 是 NXP 生产的一款 16 通道 PWM(脉宽调制)控制器,主要用于驱动 LED 或舵机,广泛应用于机器人、灯光控制和 DIY 电子项目。 主要特点 - 16 路独立 PWM 输出(每个通道 12 位分辨率,0~4096 可调)。 - I²C 接口通信,地址可调(0x40~0x7F)。 - 频率可调,支持 24Hz~1526Hz 的 PWM 频率。 - 支持外部时钟(适用于需要更高精度的场景)。 - 可编程 LED 亮度控制,支持单独和分组控制。 - 工作电压:2.3V~5.5V(兼容 3.3V 和 5V 逻辑电平)。 - 最大输出电流:每个通道 25mA(默认),最大 400mA(所有通道总电流)。 ![20250212192831_nZXdtUU9.webp](https://cdn.dong4j.site/source/image/20250212192831_nZXdtUU9.webp) **接线方式:** ![20250212211654_e9WCc1VN.webp](https://cdn.dong4j.site/source/image/20250212211654_e9WCc1VN.webp) **外接供电:** ![20250212211654_iXBUaeEi.webp](https://cdn.dong4j.site/source/image/20250212211654_iXBUaeEi.webp) 驱动板右侧的黑黄蓝红 4 条线的接法毫无争议。关键是最底下我自己加上的一根紫色的 v+ 线,这根线要连接至电源才能驱动舵机,至于是 3v 电源还是 5v 电源,是树莓派 GPIO 口提供的还是外接电源都无所谓,只要接上电源即可。 一般接 3v 的就够用了,如果有扩展板的话,就接到树莓派的 1 号 3v 供电口。如果没有拓展板的话,3v 供电口已经被驱动板的 vcc 供电口占了,那接树莓派 2 号 5v 供电口也是可以的,这是比较简洁的接线方式。反正舵机如果没动静,多半是电源线的问题。 一般情况下,config.txt 文件配置完成后,电源如果接通的话,无需任何代码舵机就会开始旋转至最大角度。 树莓派和舵机驱动板按照教程分别连接对应 GND,SDA.0,SCL0,VCC,V+ 即可. 注意是 SDA.0,SCL.0,不要连成了 SDA.1,SCL.1 ## 舵机 购买的 SG90 MG90S 9g 舵机: ![20250212192831_8GLVKcak.webp](https://cdn.dong4j.site/source/image/20250212192831_8GLVKcak.webp) ## 树莓派配置 1. 树莓派开启 I2C ``` sudo raspi-config -> 5.Interfacing Options -> P5 I2C 设置enable,然后重启树莓派 ``` 2. i2c-tools 测试舵机连接状态 ``` sudo apt-get install i2c-tools sudo i2cdetect -y 1 ``` 3. 使用 PCA9685 python 库控制舵机 例子源码 [在这里](https://github.com/adafruit/Adafruit_Python_PCA9685.git)(example 目录下) ```python sudo pip3 install adafruit-pca9685 python3 ./simpletest.py ``` ## 集成 ### 启动流媒体服务器 上一篇文章中我已经写了一个启动脚本: ```bash #!/bin/bash # 检查是否有 -h 参数 if [[ "$1" == "-h" ]]; then echo "使用方法: $0 [协议] [摄像头编号] [宽度] [高度] [URL地址]" echo "示例: $0 rtmp 0 1920 1080 192.168.21.7/pi5b, 最终URL: rtmp://192.168.21.7/pi5b/0" echo echo "参数说明:" echo " 协议 : rtmp 或 rtsp (用于选择流媒体传输协议)" echo " 摄像头编号 : 摄像头编号 (例如 0 或 1)" echo " 宽度 : 分辨率宽度 (例如 1920)" echo " 高度 : 分辨率高度 (例如 1080)" echo " URL地址 : 基础的 URL 地址 (例如 192.168.21.7/pi5b)" echo echo "RTMP 推流说明:" echo " 192.168.21.7:1935/pi5b --> rtmp://192.168.21.7:1935/pi5b/0 推流至 m920x 的 zlm 服务, 默认端口 1935, 会出现在 WVP 的推流列表中" echo " 192.168.21.7:41935/pi5b --> rtmp://192.168.21.7:41935/pi5b/0 推流至 m920x 的 mediamtx 服务, 使用 mediamtx 服务的 WebRTC 访问: http://192.168.21.7:48889/pi5b/{CAMERA}" echo " 127.0.0.1:1935/pi5b --> 本地推流至 mediamtx 服务, 使用 WebRTC 访问: http://ip:8889/pi5b/{CAMERA}" echo " ====================================================" echo " ./stream.sh rtmp 0 1920 1080 192.168.21.7/pi5a" echo " ./stream.sh rtmp 0 2560 1440 192.168.21.7:41935/pi5a" echo " ./stream.sh rtmp 0 3840 2160 192.168.21.7/pi5a" echo " ./stream.sh rtmp 0 3840 2160 127.0.0.1/pi5a" echo echo "RTSP 推流说明:" echo " 192.168.21.7:554/pi5b --> rtsp://192.168.21.7:554/pi5a/0 推流至 m920x 的 zlm 服务, 默认端口 554, 会出现在 WVP 的推流列表中" echo " 192.168.21.7:48554/pi5b --> rtsp://192.168.21.7:48554/pi5b/0 推流至 m920x 的 mediamtx 服务, 使用 WebRTC 访问: http://192.168.21.7:48889/pi5b/{CAMERA}" echo " 127.0.0.1:8554/pi5b --> 本地推流至 mediamtx 服务, 使用 WebRTC 访问: http://ip:8889/pi5b/{CAMERA}" echo " ====================================================" echo " ./stream.sh rtsp 1 1920 1080 192.168.21.7/pi5b" echo " ./stream.sh rtsp 1 2560 1440 192.168.21.7:48554/pi5a" echo " ./stream.sh rtsp 1 3840 2160 192.168.21.7:8554/pi5a" echo " ./stream.sh rtsp 1 3840 2160 127.0.0.1:8554/pi5a" exit 0 fi # 参数赋值 PROTOCOL=$1 # 第一个参数为协议 (rtmp 或 rtsp) CAMERA=$2 # 第二个参数为摄像头编号 WIDTH=$3 # 第三个参数为宽度 1920x1080 2560x1440 3840x2160 HEIGHT=$4 # 第四个参数为高度 URL=$5 # 第五个参数为基础的 URL 地址 # 根据协议动态设置输出流地址和格式 if [ "$PROTOCOL" == "rtmp" ]; then OUTPUT_URL="rtmp://${URL}/${CAMERA}" FFMPEG_FORMAT="flv" elif [ "$PROTOCOL" == "rtsp" ]; then OUTPUT_URL="rtsp://${URL}/${CAMERA}" FFMPEG_FORMAT="rtsp" else echo "不支持的协议: $PROTOCOL" exit 1 fi # 运行命令 nohup bash -c "rpicam-vid --hflip --vflip -t 0 --camera $CAMERA --nopreview --codec yuv420 --width $WIDTH --height $HEIGHT --inline --listen -o - | ffmpeg -f rawvideo -pix_fmt yuv420p -s:v ${WIDTH}x${HEIGHT} -i /dev/stdin -c:v libx264 -preset ultrafast -tune zerolatency -f $FFMPEG_FORMAT $OUTPUT_URL" > ${PROTOCOL}-cam${CAMERA}.log 2>&1 & ``` 因为 Web 端处理 WebRTC 还有点麻烦, 所以这里先使用 ZLM 将视频流转成 mp4, 然后直接使用 `video` 标签播放视频. 使用以下命令将视频流推送到 WVP: ```bash # RTMP 推流 ./stream.sh rtmp 0 1920 1080 192.168.21.7/pi5a # RTSP 推流 ./stream.sh rtsp 1 1920 1080 192.168.21.7/pi5a ``` 然后在 WVP 控制台应该能看到视频流了: ![20250212185748_TCP4MjUl.webp](https://cdn.dong4j.site/source/image/20250212185748_TCP4MjUl.webp) ### 嵌入到 Web UI 并排显示 2 个摄像头的画面: ![20250212185909_ILxW2kgL.webp](https://cdn.dong4j.site/source/image/20250212185909_ILxW2kgL.webp) ### 舵机控制 我将舵机连接到了 Zero 2W 上, 然后摄像头与树莓派 5 连接, 所以舵机的控制我需要在 Zero 2W 上处理. ![20250212192838_Js0nHyfm.webp](https://cdn.dong4j.site/source/image/20250212192838_Js0nHyfm.webp) ```python from flask import Flask, render_template, request, jsonify from flask_cors import CORS import Adafruit_PCA9685 import time import threading import math from concurrent.futures import ThreadPoolExecutor import re app = Flask(__name__) CORS(app, resources={r"/*": {"origins": "*"}}) # 允许所有来源的跨域请求 # 初始化 PCA9685 pwm = Adafruit_PCA9685.PCA9685() # 配置舵机的最小和最大脉冲长度 servo_min = 150 servo_max = 600 step_size = 5 # 增加步长,但保持相对小的值 update_frequency = 100 # Hz,进一步增加更新频率 move_duration = 0.05 # 秒,减少单次移动的持续时间 # 设置频率为 60Hz pwm.set_pwm_freq(60) # 初始化舵机位置 servo0_position = 375 servo1_position = 445 # 创建一个锁来保护共享资源 lock = threading.Lock() # 创建一个事件来控制连续调整 continuous_event = threading.Event() # 更新舵机位置的函数 def update_servo(channel, position): pwm.set_pwm(channel, 0, position) # 初始化舵机的函数 def initialize_servos(): global servo0_position, servo1_position with lock: update_servo(0, servo0_position) update_servo(1, servo1_position) print("舵机已初始化到中间位置") # 平滑移动函数 def smooth_move(channel, start, end, duration): steps = int(duration * update_frequency) for i in range(steps): t = i / steps # 使用平方函数来创建更快但仍然平滑的加速和减速效果 smooth_t = t * t * (3 - 2 * t) position = int(start + (end - start) * smooth_t) update_servo(channel, position) time.sleep(1 / update_frequency) # 调整舵机的函数 def adjust_servo(direction): global servo0_position, servo1_position with lock: if direction == 'left': target = max(servo_min, servo0_position + step_size) smooth_move(0, servo0_position, target, move_duration) servo0_position = target elif direction == 'right': target = min(servo_max, servo0_position - step_size) smooth_move(0, servo0_position, target, move_duration) servo0_position = target elif direction == 'up': target = max(servo_min, servo1_position - step_size) smooth_move(1, servo1_position, target, move_duration) servo1_position = target elif direction == 'down': target = min(servo_max, servo1_position + step_size) smooth_move(1, servo1_position, target, move_duration) servo1_position = target return servo0_position, servo1_position def continuous_adjust(direction): while not continuous_event.is_set(): adjust_servo(direction) def reset_servos(): with lock: update_servo(0, 375) update_servo(1, 445) print("舵机已重置到中间位置") return 375, 445 # 新增:处理摇杆输入的函数 def handle_joystick(horizontal, vertical, last_servo0, last_servo1): global servo0_position, servo1_position # 使用上次记录的位置作为起始点 servo0_position = last_servo0 servo1_position = last_servo1 # 计算移动距离 distance = math.sqrt(horizontal**2 + vertical**2) # 如果移动距离太小,保持当前位置 if distance < 0.1: return servo0_position, servo1_position # 计算水平和垂直方向的移动量 horizontal_move = int(horizontal * step_size * 2) vertical_move = int(vertical * step_size * 2) with lock: # 更新水平舵机位置 new_servo0 = max(servo_min, min(servo_max, servo0_position + horizontal_move)) smooth_move(0, servo0_position, new_servo0, move_duration) servo0_position = new_servo0 # 更新垂直舵机位置 new_servo1 = max(servo_min, min(servo_max, servo1_position - vertical_move)) smooth_move(1, servo1_position, new_servo1, move_duration) servo1_position = new_servo1 return servo0_position, servo1_position def get_video_type(url): """ 根据 URL 确定视频类型 """ if re.search(r'\.flv($|\?)', url): return 'flv' elif re.search(r'\.m3u8($|\?)', url): return 'm3u8' elif re.search(r'\.mp4($|\?)', url): return 'mp4' else: # 如果无法确定,可以返回一个默认值或者 None return None @app.route('/') def index(): video_url = "http://192.168.21.7:9090/pi5a/0.live.mp4" # 从配置或数据库获取 video_type = get_video_type(video_url) if video_type is None: # 或者返回错误 return "无法确定视频类型", 400 return render_template('index.html', video_url=video_url, video_type=video_type) @app.route('/control', methods=['POST']) def control(): direction = request.json['direction'] action = request.json['action'] # 'single', 'start', 或 'stop' if action == 'single': servo0, servo1 = adjust_servo(direction) elif action == 'start': continuous_event.clear() threading.Thread(target=continuous_adjust, args=(direction,), daemon=True).start() servo0, servo1 = servo0_position, servo1_position else: # 'stop' continuous_event.set() servo0, servo1 = servo0_position, servo1_position return jsonify({ 'servo0': servo0, 'servo1': servo1 }) @app.route('/reset', methods=['POST']) def reset(): servo0, servo1 = reset_servos() return jsonify({ 'servo0': servo0, 'servo1': servo1 }) @app.route('/joystick-control', methods=['POST']) def joystick_control(): data = request.json horizontal = data['horizontal'] vertical = data['vertical'] last_servo0 = data['lastServo0'] last_servo1 = data['lastServo1'] servo0, servo1 = handle_joystick(horizontal, vertical, last_servo0, last_servo1) return jsonify({ 'servo0': servo0, 'servo1': servo1 }) @app.after_request def add_security_headers(response): # 完全禁用 CSP response.headers['Content-Security-Policy'] = "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;" # CORS headers response.headers['Access-Control-Allow-Origin'] = '*' response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' return response if __name__ == '__main__': # 在启动服务器之前初始化舵机 initialize_servos() app.run(host='0.0.0.0', port=5000, debug=False, threaded=True) ``` **完整的 HTML 代码:** ```html 摄像头控制系统

水平舵机位置: 375

垂直舵机位置: 445

``` 右边的视频是直接使用 `video` 标签写死的: `http://192.168.21.7:9090/pi5a/1.live.mp4`, 另一个使用 `videojs` 并从后端获取视频地址: `http://192.168.21.7:9090/pi5a/0.live.mp4` > 这里解释一下, 因为我们前面分别使用 rtsp 和 rtmp 将视频流推送到了 WVP, 这里的 9090 就 ZLM 服务的端口, 我们直接按照规则凭借 ZLM 即可, 如果使用过 ZLM 应该不难理解, 或者可以直接在 WVP 页面回去播放地址: > > ![20250212191120_4V6TuXsz.webp](https://cdn.dong4j.site/source/image/20250212191120_4V6TuXsz.webp) ## 效果展示 ![20250212191238_CRx4L2ui.webp](https://cdn.dong4j.site/source/image/20250212191238_CRx4L2ui.webp) {% video https://cdn.dong4j.site/source/image/PCA9685.mp4 %} 代码开源在 https://github.com/dong4j/pi-pca9685-controller ## 参考资料 - [树莓派搭建简易远程监控,利用舵机制作可旋转的摄像头](https://zhuanlan.zhihu.com/p/22805173) - [树莓派 3B+ PCA9685 舵机驱动板控制舵机](https://blog.csdn.net/zz531987464/article/details/100391715) - [树莓派搭建简易远程监控,利用舵机制作可旋转的摄像头](https://zhuanlan.zhihu.com/p/22805173) - [总线舵机驱动板 集成 ESP32 和控制电路 适用于 ST/RSBL 系列总线舵机](https://www.waveshare.net/shop/Bus-Servo-Driver-HAT-A.htm) - [【树莓派 C 语言开发】实验 14:PS2 游戏手柄模块(关联 PCF8591)\_树莓派 ps2 操纵杆实验-CSDN 博客](https://blog.csdn.net/muxuen/article/details/124753903) - [树莓派基础实验 14:PS2 操纵杆实验\_ps2 操纵杆和液晶显示器 将操纵杆的变化显示在液晶显示器上-CSDN 博客](https://blog.csdn.net/chinacqzgp/article/details/107137875) - [在树莓派 Pico 上使用摇杆 – 树莓派 Pico 实验室(RP2040)](https://pico.nxez.com/2023/11/25/how-to-use-the-joystick-on-raspberry-pi-pico.html) - [用本地网络控制的树莓派摄影云台 | 树莓派实验室](https://shumeipai.nxez.com/2018/07/17/raspberry-pi-cam-pan-tilt-control-over-local-inter.html) - [基于树莓派的多舵机控制的定位拍照云台 | 树莓派实验室](https://shumeipai.nxez.com/2018/06/21/pan-tilt-multi-servo-control.html) - [树莓派 4B-Python-使用 PCA9685 控制舵机云台+跟随人脸转动\_Python 资料\_Python 教程开发文档资料-Python 资料网](https://pythonziliao.com/post/821.html) - [树莓派,mediapipe,Picamera2 利用舵机云台追踪人手(PID 控制)\_树莓派云台追踪封装好的函数-CSDN 博客](https://blog.csdn.net/idfengming/article/details/135210978) - [使用树莓派 gpio 连接 ps2 手柄模块(附程序)「建议收藏」-腾讯云开发者社区-腾讯云](https://cloud.tencent.com/developer/article/2094624) - [咸鱼 ZTMR 实例—PS2 手柄-腾讯云开发者社区-腾讯云](https://cloud.tencent.com/developer/article/2072262) - [ps2 摇杆传感器控制舵机实验 – 树莓酱](https://www.shumeijiang.com/2022/10/30/ps2%E6%91%87%E6%9D%86%E4%BC%A0%E6%84%9F%E5%99%A8%E6%8E%A7%E5%88%B6%E8%88%B5%E6%9C%BA%E5%AE%9E%E9%AA%8C.html) - [树莓派通过 16 路 PCA9685 模块驱动舵机 | My-Blog](https://apidocs.cn/blog/backend/raspberrypi/%E6%A0%91%E8%8E%93%E6%B4%BE%E9%80%9A%E8%BF%8716%E8%B7%AFPCA9685%E6%A8%A1%E5%9D%97%E9%A9%B1%E5%8A%A8%E8%88%B5%E6%9C%BA.html) ## [智能监控燃气站:无人值守运维新时代](https://blog.dong4j.site/posts/20ab66ff.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 为了满足燃气站设备的安全、维护、效率、合规和技术发展等多方面的需求。通过实施该解决方案,实现实现无人值守的目的, 提高燃气站设备的安全性和运行效率,降低运营成本。 ## 需求 1. 通过连接本地 **IPC 和 NVR** 实现视频展示功能, 已达到安全巡检的目的; 2. 连接燃气站内部 **门禁** 系统, 通过局域网定时探活的方式监测门禁设备运行情况; 3. 使用 SNMP 协议监测 **网络交换机** 的实时运行情况, 包括以下内容: 1. 端口占用情况 2. 上下行的数据流量 3. 其他关键参数信息 4. 获取运行日志并上传 5. 温度、负荷 4. 监测 **UPS** 设备, 收集电池, 当前功率等相关的数据; 5. **空调** 控制, 包括温度调节, 启停等操作; 6. 环境监测, 包括温度, 湿度, 噪音等数据; 7. **柴油发电机**检测, 需要做到市电停自动启动柴发, 市电来只有停止柴发; 8. IPC, 网络交换机等关键设备能够实现 **远程重启**; 9. 收集所有设备到的 **状态(在线/离线)** 数据; 10. 站内数据可以集中展示在场站内部的 **显示屏** 上, 并能够实时**上报到云平台**; 11. 当市电断电且 UPS 电量耗尽后还能够通过 **4G** 的方式持续上报设备数据到云平台; 12. 提供扩展模块用于后续新设备的接入; ## 方案简介 此方案中, 我们将按照设备功能分为三类: 1. 动力设备; 2. 环境设备; 3. 安防设备; ![设备分类.svg](https://cdn.dong4j.site/source/image/%E8%AE%BE%E5%A4%87%E5%88%86%E7%B1%BB.svg) 基本原则: 1. 如果设备存在物联协议, 比如 RS485, 则可直接采集; 2. 如果是普通的硬件设备, 则需要添加相应的硬件通讯模块, 将普通硬件转换为智能硬件上报的监测主机; 3. 如果是监测主机不支持的物联协议, 则需要协议转换模块, 将不支持的协议转换为 RS485 协议; ![基本原则.drawio.svg](https://cdn.dong4j.site/source/image/%E5%9F%BA%E6%9C%AC%E5%8E%9F%E5%88%99.drawio.svg) ## 方案设计 设计原则是:**简单适用+稳定安全+可扩展易维护**。 由于燃气站的预算限制,监测系统的监控对象只覆盖核心设备,如:UPS、网络设备、视频等; 同时燃气站没有配备专门的机房管理人员,所以监控系统必须考虑其操作性必须简单; 而对于监控系统而言,最重要的一个指标就是其系统稳定性,监控系统通过一系列软件和硬件相结合的方式保证系统的安全稳定无误报; 同时对于新增的设备需要接入监控系统进行统一管理也是常见的需求,因此监测系统必须具备良好的系统扩展性,让新设备加入监测系统操作简单,可维护性良好。 **设计方案系统图如下:** ![整体方案.drawio.svg](https://cdn.dong4j.site/source/image/%E6%95%B4%E4%BD%93%E6%96%B9%E6%A1%88.drawio.svg) ### 基础功能 1. **动力设备监控**:监控系统能够实时监控 UPS 电源、开关柜、发电机 等动力设备的运行状态和参数。一旦检测到异常,系统会立即发出报警,确保设备故障得到及时处理。 2. **环境参数监控**:通过温湿度传感器、烟雾探测器等设备,系统可以实时监测机房内的环境参数。当环境参数超出预设范围时,系统会自动调节空调设备或启动通风系统,确保机房内环境稳定。 3. **漏水检测与报警**:机房内设有漏水检测装置,一旦检测到漏水情况,系统会立即发出报警并关闭相关水源,防止设备受损。 4. **门禁与视频监控**:监控系统集成了门禁系统和视频监控系统,实现对机房出入人员的严格管理和对机房内设备的实时监控。 5. **远程管理工具集成**:监控系统支持通过 Web 界面调用管理口,内置远程工具如 SSH、Telnet 等,方便管理员随时随地进行远程管理。这些工具不仅提供了强大的命令行操作功能,还支持图形化界面操作,简化了管理流程。 6. **报警与日志管理**:系统能够记录所有的监控数据和操作日志,并提供灵活的报警机制。管理员可以根据需要设置不同级别的报警阈值和报警方式(如声光报警、短信通知等),确保及时响应和处理各种异常情况。 7. **可扩展性与开放性**:监控系统采用模块化设计,具有良好的可扩展性和开放性。管理员可以根据机房的实际需求添加或删除监控模块,同时系统支持多种通信协议和接口标准,方便与其他系统进行集成和联动。 --- ### 连接方式 监控系统与其他设备的连接方式主要取决于设备的类型和接口标准。一般来说,监控系统支持多种类型的接口,如 RS232、RS485、以太网接口等,以便与不同类型的设备进行连接。 1. 对于具有标准接口的设备,如 UPS 电源、空调设备等,监控系统可以直接通过相应的接口与之连接。例如,通过 RS232 或 RS485 接口,系统可以读取设备的运行状态和参数,并进行实时监控和管理。 2. 对于不具有标准接口的设备,机房动环监控系统可能需要采用其他方式进行连接。例如,对于模拟量输出的传感器,系统可以通过模拟量输入接口进行连接,将传感器输出的模拟信号转换为数字信号进行处理。对于开关量输入的设备,如门禁系统、烟雾探测器等,系统可以通过开关量输入接口(DI)进行连接,读取设备的开关状态。 3. 此外,监控系统还支持通过网络接口与远程管理工具进行连接。管理员可以通过 Web 界面调用管理口,使用内置的远程工具如 SSH、Telnet 等进行远程管理。这种方式不仅方便灵活,还可以实现跨地域的远程监控和管理。 需要注意的是,监控系统与其他设备的连接应遵循相应的接口标准和通信协议,确保数据的准确性和可靠性。同时,在连接过程中还应注意设备的电气安全和防雷保护等措施,以保障系统的稳定运行和设备的安全。 --- ### **监控子系统** #### 动力监控 (UPS 监测) 通过 UPS 设备提供的 RS485 (或 RS232) 智能接口及通讯协议,采用总线的方式将 UPS 的监控信号直接 (或经通讯转换模块将 RS232 转换成 RS485 信号后) 接入监控服务器的串口,由监控平台软件进行 UPS 的实时监测。 #### 机房运行环境监测 通过系统安装部署在机房各个关键角落的温湿度感应探头、噪音传感器及漏水监测器,可对机房内的温度、湿度、噪音进行实时监控检测, 一旦机房内的温度、湿度、噪音达到或超过感应设备预先设定的阀值时,设备就会启动告警流程,并在实时监控平台告警界面上进行显示的登记,并通过与通信预警模块进行联动,在第一时间通知机房值班人员对故障点进行及时响应及处理。 #### 蓄电池监测 通过加装蓄电池检测仪与每节电池进行连线监测,多台蓄电池检测仪通过 RS485 智能接口及通讯协议采用总线方式将信号接入监控服务器的串口,由监控平台软件进行蓄电池的实时监测。 #### 市电监测 通过在配电柜中安装带液晶显示的电量仪对进线实现监测,既可在配电柜表面实时看到电量仪采集到的参数,亦可通过电量仪的 RS485 智能接口和通讯协议采用总线的方式将信号接入监控服务器的串口,由监控平台软件进行市电的实时监测。 #### 配电开关监测 通过高压交流隔离转换模块将配电开关下出线的强电信号转换成低压直流信号后接入 8 路隔离数字量输入模块中进行实时状态采集,再通过 8 路隔离数字量输入模块的 RS485 智能接口及通讯协议采用总线的方式将信号接入监控服务器的串口,由监控平台软件进行开关状态的实时监测。 #### 网络交换机监测 监测主机通过 RJ45 接口与网络交换机连接, 通过 Web 端设置 IP 与设备的绑定关系后, 通过 PING 指令对局域网内的所有设备进行探活监测, 且通过 SNMP 协议获取网络交换机的运行数据。 #### 视频监测 如果燃气站存在 NVR 设备, 可直接通过 RTSP 协议从 NVR 上拉取 IPC 的实时视频流, 也可查看历史视频记录, 如果需要远程查看视频监控画面, 需要通过 VPN 的方式组建一个大型局域网络。 ### 系统功能 1. 展示当前数据中心总能耗,IT 能耗,空调能耗,及其他能耗并且计算出当前数据中心实时 PUE 值,通过仪表盘形式直观展示。 2. 选择查看数据中心的中低压配电系统主接线图,并在一次图显示配电系统当前遥测、遥信数据和状态。实时监测各配电柜的电压、电流等电力参数,变电站的温湿度、烟感、水浸、门禁等环境情况。 3. 电气接点温度实时监测,断路器触头、触臂、母排和线缆连接等位置安装无线测温传感器监测接点温度,便于提前发现温度异常导致的事故。 4. 监测各变压器各项参数,包括负载率、频率、功率因数、三相不平衡度等,并且显示历时曲线图,数据实时变化。帮助用户直 5. 电能质量在线监测,可以监测电流和电压谐波畸变率、电压暂升暂降暂中断等暂态事件记录、ITIC 容忍曲线等 6. 系统采集 UPS 输入、输出端和旁路三相电压、电流、有功功率、功率因数频率,同时监测 UPS 温度、蓄电池电压、当前负载下的剩余时间等数据。 7. 展示单体电池电压、内阻和温度,预测电池带载时剩余时间,每节电池数据均可以设置异常报警,及时发现蓄电池异常。 8. 展示精密配电柜内进线和馈线回路电气参数,包括电流电压功率电能以及开关状态,并可以对数据进行报警设置和分级,数据取自精密配电柜测量模块。 9. 展示智能小母线的始端箱和插接箱电气参数,包括电流电压、开关状态、插接点温度,并对数据进行报警设置和分级。 10. 通过平面图显示数据中心能源分布,设备分布情况,并显示设备能耗数据,点击平面图上设备可以进入具体设备监控界面。 11. 实时显示当前数据中心 PUE 值以及历史 PUE 曲线。并且显示各分项用能的用能情况及用能排行。监测各变压器运行及负载情况,给出本月变压器输出电能排行。 12. 显示电能消耗日/月/年报表,并可对具体回路选择曲线图、饼图进行展示。对数据中心用电数据进行同比、环比分析比较,查看用电趋势。 13. 监测精密空调的回风温湿度,出回水温度,并可以设定精密空调的温湿度,达到更好的控制效果。 14. 监测数据中心温湿度、开关门、水浸、烟雾、噪声、气体浓度状态等参数。曲线图直观明了,同时支持历史数据查询 15. 通过列表显示各类报警事件数量,通过柱状图显示逐日报警数量,提供报警总数以及增长趋势。 监控解决方案通过新增传感器(如:温湿度传感器、漏水探测器、烟雾探测器等)或直接使用机房设备中的智能通讯接口,通过监控主机与机房内被监控设备建立通信,搭建一套完善的实时机房监控系统,对客户具有以下实用价值: 1. **机房无人值守**:机房监控系统 24 小时全方位自动监控机房中的设备运行,出现异常系统自动告警,无需机房管理人员人工巡查机房设备,无人值守大大减少降低维护人机房管理工作量。 2. **安全实时监测**:监控系统以毫秒级速率实时采集所有监控设备数据,同时将所有采集数据以图形化界面展现方式在系统中实时展现。 3. **机房异常预警告警**:监控系统实监测机房内所有设备的运行情况以及机房环境参数,若探测到异常,系统及时发出告警(支持声光、电话、短信、微信 App 等多种方式告警,确保通知到位),管理人员可及时排查处理相关问题,进而保证机房安全稳定运行。 4. **机房可视化监控**:通过 SCADA 或 3D 建模的方式,让管理人员随时随地可进行可视化地巡查机房。 5. **机房监控数据完整保存无丢失**:监测主机支持数据自动缓存及联网主动推送机制(设备特有功能),若出现传输异常情况,系统会自动缓存数据,待恢复正常后自动上传缓存数据,保证系统所有采集数据不丢失。 ## 方案价值 • 可视化管理,使得数据中心基础设施管理变得可见、直观、易用和高效; • 提高信息查询、处理和交互的及时性和有效性,提升数据中心管理效率; • 数据展现和信息互动,提供更友好和高效的用户界面,全局把控; ## [揭秘基础框架:如何简化软件开发?](https://blog.dong4j.site/posts/7774f295.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 首先我们需要明确什么是 **基础框架** 以及 **基础框架** 能给我们带来怎样的便利从而方便开发者快速根据业务需求构建可实施的业务项目. ### 1.1 什么是基础框架 #### 1.1.1 定义 > 框架(framework)是整个或部分系统的 **可重用** 设计,表现为一组抽象构件及构件实例间 **交互** 的方法,另一种定义为,框架是可被应用开发者定制的应用 **骨架**。 > > 框架是一个 **可复用** 的设计构件,通常以 **构件库** 的形式出现,但构架库只是框架的一个重要部分,框架的关键在于框架内对象间的的交互模式和控制流模式。 从定义可以得出, 框架是一种 **可复用** 构件, 以 **构件库** 的方式加入到业务代码中, 从而避免重复开发达到复用的目的. 每个公司都会或多或少根据自己公司业务封装内部的开发基础框架. 比如蚂蚁金服基于 Spring Boot 自研的 [SOFA](https://github.com/sofa), 属于金融级别的微服务框架; [Vert.x](https://github.com/vert-x3) 基于 Netty 封装的基于事件的异步框架; [Dubbo](https://github.com/apache/dubbo) 是一个高性能的、基于 Java 的开源 RPC 框架等. 考虑到面向的领域,以及实现编码实现,我们可以将将框架至少分为三类: 1. 基础类库: 包含多数项目所需要的类库, 开发人员将其作为一个类库使用,可以简化一些常用的算法逻辑; 2. 基础框架: 该框架应该整合或者实现 J2EE 开发所需要的常用功能, 为各类项目开发提供基础支持; 3. 平台框架: 针对于某种特定领域,实现特定领域所需要的常用功能; ### 1.2 为什么需要基础框架 软件系统随着业务的发展,变得越来越复杂,不同领域的业务所涉及到的知识、内容、问题非常非常多。如果每次都从头开发,那都是一个很漫长的事情,且并不一定能将它做好。团队协作开发时,没有了统一标准,大家各写各的,同样的重复的功能到处都是。由于没有统一调用规范,很难看懂别人写的代码,出现 Bug 或二次开发维护时,根本无从下手。 而一个成熟的基础框架,它是模板化的代码,它会帮我们实现很多基础性的功能,我们只需要专心的实现所需要的 **业务逻辑** 就可以了。而很多底层功能操作,就可以完完全全不用做太多的考虑,框架已帮我们实现了。这样的话,整个团队的开发效率可想而知。另外对于团队成员的变动,也不用太过担心,框架的代码规范让我们能轻松的看懂其他开发人员所写的代码。 #### 1.2.1 代码模板化 基础框架能够确保 **代码风格** 的一致性,同一分层的不同类代码,都是大同小异的模板化结构,方便使用模板工具统一生成,减少大量重复代码的编写。在学习时通常只要理解某一层有代表性的一个类,就等于了解了同一层的其他大部分类结构和功能,容易上手。团队中不同的人员采用类同的调用风格进行编码,很大程度提高了代码的可读性,方便维护与管理。 #### 1.2.2 可复用 基础框架能够确保层次清晰,不同开发人员开发时都会根据具体功能放到相同的位置,加上配合相应的开发文档,代码重用会非常高,想要调用什么功能直接进对应的位置去查找相关函数,而不是每个开发人员各自编写一套相同的方法。 #### 1.2.3 高内聚(封装) 基础框架中的功能会实现高内聚,开发人员将各种需要的功能封装在不同的层中,给大家调用,而大家在调用时不需要清楚这些方法里面是如果实现的,只需要关注输出的结果是否是自己想要的就可以了。 #### 1.2.4 规范 基础框架严格执行代码开发规范要求,做好命名、注释、架构分层、编码、文档编写等规范要求。因为基础框架是所有业务项目的基础, 要让开发者更加容易理解与掌握。 #### 1.2.5 可扩展 基础框架具备可扩展性,当业务逻辑更加复杂、数量记录量爆增、并发量增大时,可能只需要调整基础框架即可满足大部分业务需求, 避免在多个项目中去调整. #### 1.2.6 可维护 基础框架通过必要的技术手段, 保证业务代码的可维护性, 比如通过强制的代码检查保证代码格式统一, 提高可维护性; 统一的常用功能或特性保证开发者使用统一的方式处理通用逻辑, 同样提高了代码或项目的可维护性. #### 1.2.7 协作开发 有了基础框架,我们才能组织大大小小的团队更好的进行协作开发,成熟的框架将大大减轻项目开发的难度,加快开发速度,降低开发费用,减轻维护难度。 #### 1.2.8 通用性 同一行业或领域的框架,功能都是大同小异的,不用做太大的改动就可以应用到类似的项目中。在框架中,我们一般都会实现一些同质化的基础功能,比如权限管理、角色管理、菜单管理、日志管理、异常处理...... 或该行业中所要使用到的通用功能. #### 1.2.9 总结 总结起来, 使用基础框架的优点如下: 1. 重用代码大大增加,软件生产效率和质量也得到了提高; 2. 代码结构的规范化,降低程序员之间沟通以及日后维护的成本; 3. 知识的积累,可以让那些经验丰富的人员去设计框架和领域构件,而不必限于低层编程; 4. 软件设计人员要专注于对领域的了解,使需求分析更充分; 5. 允许采用快速原型技术; 有利于在一个项目内多人协同工作; 6. 大粒度的重用使得平均开发费用降低,开发速度加快,开发人员减少,维护费用降低,而参数化框架使得适应性、灵活性增强。 ### 1.3 与基础开发平台的区别 基础框架是基础开发平台的 **子集**, 是属于基础开发平台的中层的通用研发框架, 基础框架与基础开发平台的关系如下: ![difference.drawio.svg](https://cdn.dong4j.site/source/image/difference.drawio.svg) 常见的基础开发平台不仅仅只提供了 **基础开发框架**, 而是在此基础上提供了更多的服务治理, 服务监控等功能, 更具备完善的基础设施建设, 包括持续集成与部署, K8S 等. 这一套通常需要百人团队花费几年时间逐步完善, 且使用场景一般在 **分布式微服务** 的业务场景中. 综上数据, 目前搭建一个 **基础开发平台** 对我们来说暂时还没有必要, 当前阶段提供一个 **基础框架** 意义更大. ## 2. 基础框架架构 ![framework_1.0.0.svg](https://cdn.dong4j.site/source/image/framework_1.0.0.svg) ### 2.1 技术选型 技术选型一般按照以下几个原则: 1. 需要看软件特性是否满足需求, 包括兼容性, 扩展性和使用到的技术栈; 2. 根据目前自己或者团队的技术栈和技术能力,能否可以平滑的使用; 3. 技术社区的活跃度, 包括: 1. 是否有专人维护; 2. 是否有完善的文档; 3. 遇到的问题是否能够快速得到帮助并解决; 4. 是否在业内部被广泛使用; 按照上面的原则, 所选主要技术栈整理如下: ![20241229154732_SgCYXo9I.webp](https://cdn.dong4j.site/source/image/20241229154732_SgCYXo9I.webp) | 框架 | 说明 | | ------------------------------------------------------------------------------------------------ | --------------------- | | JDK 8 | 开发者工具 | | [Spring Boot](https://spring.io/projects/spring-boot) 2.3.12 | 底层框架 | | [Spring Cloud Alibaba](https://github.com/alibaba/spring-cloud-alibaba/tree/2.2.9.RELEASE) 2.2.9 | Spring Cloud 框架 | | Nacos 2.x | 配置中心 | | [xxl-job](https://github.com/xuxueli/xxl-job) | 分布式任务调配 | | [Hutool](https://hutool.cn/docs/#/) | 常用工具包 | | [Kafka](https://kafka.apache.org/) | 业务消息队列 | | [EMQ](https://www.emqx.com/zh) | 物联设备消息队列 | | [MySQL](https://www.mysql.com/cn/) 8.x | 数据库 | | [Druid](https://github.com/alibaba/druid) 1.2.15 | JDBC 连接池、监控组件 | | [MyBatis Plus](https://mp.baomidou.com/) 3.5.x | MyBatis 增强工具包 | | [Redis](https://redis.io/) 6.x | key-value 数据库 | | [Knife4j](https://gitee.com/xiaoym/knife4j) 3.0.3 | Swagger 增强 UI 实现 | | [MapStruct](https://mapstruct.org/) 1.5.3.Final | Java Bean 转换 | | [Lombok](https://projectlombok.org/) 1.18.24 | 消除冗长的 Java 代码 | | [JUnit](https://junit.org/junit5/) 5.8.2 | Java 单元测试框架 | ### 2.2 主要特性 - 使用 Maven 对项目依赖管理做了大量优化,抽象出公司顶级 Maven 父模块,统一管理公司所有子项目; - 项目模块分工明确,遵循单一职责,模块之间使用最小依赖原则,完全不存在循环依赖问题,精确控制 Maven 依赖传递,尽量避免依赖冲突 (如果存在依赖冲突,我们也会在编译时给出告警); - 使用不同的父项目将业务项目和框架项目进行隔离,并且提供 starter 的开发模板,简化和规范 starter 组件开发; - 使用 `Java Annotation Processor` 自动生成 starter 组件的元数据文件,减少重复配置,降低出错的概率; - 使用 `Maven Plugin` 自动生成打包配置,通用启动脚本,减少重复性工作,忽略不需要的配置以提高编译速度; - 将 BOM 管理项目依赖以统一全部项目的依赖,减少依赖冲突,且分为 3 层进行单独管理; - 使用 `ThinJar` 代替 `FatJar`, 方便修改配置与 Jar 替换且具备更高的可定制化; - 严格遵循阿里巴巴开发规范,且对代码进行了强制检查; - 在 Spring 基础上封装多个工具类,减少第三方依赖进而减少部署包体积; - 基于 `Spring Boot Starter` 封装多个基础组件且组件之间完全隔离,基于 SPI 自动设置默认配置,减少重复的开发工作; - 封装了 `Spring` 的事件处理器,彻底解决在 `Spring Cloud` 环境下事件监听器被多次执行的问题; - 封装了通用启动器,且在应用的整个生命周期提供各种扩展点以执行自定义逻辑; - 封装了统一的日志组件,减少业务项目的日志配置,且允许自定义日志配置以满足业务上的日志需求; - 封装了 `REST` 组件且提供了全局异常处理,统一的返回结果,接口参数验证,错误信息国际化,多种入参注入方式等; - 集成了 `Nacos` 作为配置中心,且对 `Nacos` 进行二次开发和优化,以满足现有业务需求; - 集成了分布式任务调度,以可视化的方式更加方便的管理定时任务,且提供 API 接口直接添加定时任务; - 集成了比 Swagger 更加优化的 API 文档工具; - 集成了多级缓存框架,封装了更加友好的分布式锁实现; - 封装了 MapStruct 简化实体间互相转换; - 集成了 `Mybatis-Plus` 简化单表操作,封装了通用的分页查询,枚举自动转换等; - 封装了 `Mybatis-Plus` 代码自动生成; - 封装了单元测试组件,使用 `Junit 5` 作为底层框架,且封装了并发测试工具,支持 `Mock` 等; ### 2.3 主要组件 #### 2.3.1 项目依赖管理 任何业务项目存在一个较大的问题就是 **项目依赖管理**, 如果稍不注意就会发生依赖冲突, 导致项目启动失败。 如果要修改依赖版本需要考虑到多个项目的依赖关系, 为了彻底解决这个问题, 公司级的项目依赖管理方案: 所有业务项目都需要以 `atom-business-parent` 为项目父依赖, 统一提供 **版本号管理**, **Maven 插件集成** 和 **公共配置**: 1. 项目依赖管理: 创建了一个顶层 Maven 项目, 用于规范所有业务项目的定义, 现在只需要简单依赖一个父项目, 就可以完全继承所有依赖的版本号, Maven 插件, 项目依赖上传地址等。 一是能够统一公司所有项目依赖的版本号, 以**减少依赖冲突** ; 二是减少业务开发时繁琐且重复的项目配置代码, 以**将重心方法需求开发上**。 为方便管理与根据不同的项目特点细化功能, 并抽离通用配置, 将父级项目细分为了**业务父项目**, **技术组件父项目** , **依赖管理父项目** 和 **一键部署父项目**, 业务方只需要关心 **业务父项目**, 在框架升级时, 只需要修改版本号即可。 2. Maven 插件: 提供了代码规范检查, 依赖冲突检查, 启动脚本自动生成, 项目一键部署等功能。 一是让所有项目的代码风格保持一致, **有效减小因为格式不统一导致的代码冲突** ; 二是通过一些自动化的方式**生成不可或缺的文件, 避免手动创建** ; 项目一键部署可**提高服务部署效率, 减少人工部署的时间**, 可从原来的 **30 分减少到 1 分钟之内**, 如果是多服务部署, 效果更加明显; 为了方便其他业务组开发业务相关的 Maven 插件, 我们还**提供插件模板, 能够快速开发一个项目级 Maven 插件.** 3. SPI 与 Spring Boot 文件生成 通过简单的注解为业务开发中使用到的 SPI 技术和 Spring Boot 自动生成配置文件, 无需手动创建, 且对开发透明无感知。 ##### 2.3.2.1 包含模块 | 模块 | 介绍 | | ----------------------------- | ----------------------------------------------------------------------------------- | | **atom-supreme** | 公司顶层项目, 用于规范所有后端服务的依赖, 提供常用的 Maven 插件 | | **atom-builder** | 框架顶层依赖, 用于管理通用配置, 依赖版本与项目依赖关系 | | → atom-project-builder | atom-business-parent 与 atom-component-parent 父依赖, 抽离公共配置 | | → atom-project-denpendencies | 维护业务开发中常用的第三方依赖版本, 避免依赖冲突 | | → atom-dependencies-parent | 公司级的依赖父项目, 管理所有依赖型项目 | | → atom-business-parent | 业务项目父级依赖, 集成自有插件与第三方插件, 并提供通用的插件配置 | | → atom-distribution-parent | 一键部署型项目父级依赖, 可简化子项目的配置 | | → atom-component-parent | 技术组件父级依赖, 可将源码上传到 Maven 私服, 方便本地调试 | | **atom-plugin** | Maven 插件项目 | | → atom-assist-maven-plugin | 框架开发辅助插件, 可根据项目类型禁用 / 启用插件, 生成项目配置等功能 | | → atom-checkstyle-plugin-rule | 代码格式检查插件 | | → atom-enforcer-plugin-rule | 项目依赖检查插件 | | → atom-makeself-maven-plugin | 项目启动脚本优化插件, 可在 atom-script-maven-plugin 的基础上生成可直接运行的部署包 | | → atom-plugin-common | 插件项目基础模块, 提供开发插件的工具包 | | → atom-pmd-plugin-rule | 代码质量检查插件 | | → atom-publish-maven-plugin | 一键部署插件 | | → atom-script-maven-plugin | 启动脚本生成插件, 可根据参数生成项目特定的启动脚本 | | **atom-starter-processor** | SPI 与 Spring Boot 项目相关的配置生成组件 | #### 2.3.3 开发工具包 我们封装了大量的常用工具包, 目标是使用一个工具方法代替一段复杂代码,从而最大限度的避免 “复制粘贴” 代码的问题, 彻底改变我们写代码的方式, 目的是为了减少代码搜索成本, 避免网络上参差不齐的代码出现导致的 BUG 。 ##### 2.3.3.1 包含模块 | 模块 | 介绍 | | ------------------------- | ----------------------------------------------------------------- | | atom-center-autoconfigure | 自动装配基础依赖 | | atom-center-common | 主要提供环境, 事件, 项目启动, 数据转换等功能 | | atom-center-core | 提供反射, 基础实体, JSON 处理, 枚举, Bean 操作等 | | atom-center-dependencies | 管理此项目的依赖, 业务端使用此模块即可, 无需关心版本号 | | atom-center-devtools | 代码自动生成 | | atom-center-notify | 通知相关基础接口相关实体 | | atom-center-test | 提供 Mock 与项目单元测试相关工具类, 通过一个注解即可实现集成测试 | | atom-center-validation | 正则, 参数验证, 分组验证等 | | atom-center-structure | 常用设计模式的封装 | | atom-center-spi | java SPI 的封装 | #### 2.3.4 IDE 插件 为提供开发效率, 我们将一些常用操作通过 IDE 插件的方式进行简化, 将以前复杂的操作以自动化或少量步骤来代替, 进一步节约开发时间。 ##### 2.3.4.1 包含模块 | 插件 | 介绍 | | ----------------- | ------------------------------------- | | atom-idea-commit | git commit 提交规范检查 | | atom-idea-common | IDE 插件基础模块 | | atom-idea-format | 统一代码样式插件 | | atom-idea-javadoc | Javadoc 快速生成 | | atom-idea-patch | 将部分代码打包成 jar, 以便于增量部署 | | atom-idea-root | IDE 插件父项目 | #### 2.3.5 技术组件 在业务开发中, 经常使用到一些通用的功能, 没有基础框架之前如果需要复用功能, 需要将代码硬拷贝到另一个工程,然后重新集成一遍。 为了提供代码复用性, 增强代码可维护性, 我们封装大量常用的技术组件, 业务端只需要引入一个简单的依赖即可使用, 还根据公司环境替换了默认配置, 这样业务端引入就可以使用, 大大减少了重复配置的时间。 另一方面, 对于复杂依赖关系,手动管理所有项目依赖并不理想,容易出现许多问题, 可能需要花费大量的时间去排查依赖问题, 而技术组件能够引入关联的间接依赖, 减少业务端的依赖配置。 技术组件能够以一种开箱即用的方式支撑业务开发, 减少重复的集成工作, 目前已有组件如下: ##### 2.3.5.1 包含组件 | 组件 | 介绍 | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | atom-starter-auth | 认证组件, 依赖后自动提供登录等相关接口, 减少业务端重复的登录接口逻辑编写 | | atom-starter-dependencies | 技术组件依赖管理模块, 配置了大量常用的依赖版本, 集中解决依赖版本冲突的问题 | | atom-starter-cache | 缓存组件, 提供分布式锁, 分布式缓存, Redis 订阅 / 发布模型, 二级缓存等功能 | | atom-starter-captcha | 验证码组件, 提供普通验证码, 动态验证码, 并配套提供验证码检查等接口 | | atom-starter-doc | 文档生成组件, 为接口层服务, 中台服务, RPC 服务提供接口文档一键生成, 减少手动编写接口时间 | | atom-starter-email | 邮件组件, 支持发送普通邮件, 富文本邮件, 附件邮件 | | atom-starter-enhance-starter | 增加组件, 将相关的技术组件合并为一个单独的依赖, 减少业务端引入依赖的数量 | | atom-starter-id | 分部署 ID 生成组件, 能够高效的生成分布式 ID, 提供多种 ID 生成算法, 满足多种业务场景 | | atom-starter-idempotent | 幂等组件, 解决业务上常见的接口请求幂等性判断 | | atom-starter-ip2region | IP 转地理位置组件, 能力快速的通过 IP 查询对应的地理问题, 主要用于安全检查 | | atom-starter-launcher | 启动组件, 简化了 Spring Boot 的启动类写法, 并提供默认配置的注入, 是所有项目的基础依赖 | | atom-starter-logsystem | 日志组件, 提供全局的日志配置, 减少业务端的配置文件数量, 并提供审计日志接口, 减少业务端重复的审计日志需求开发 | | atom-starter-metrics | 指标度量组件, 用于监控服务的各种指标是否正常, 也可添加业务指标, 用于统计 | | atom-starter-mq | 消息组件, 提供 Kafka, RocketMQ 的集成并暴露统一的接口, 业务端可无感知使用 | | atom-starter-mybatis-dynamic | 数据库操作组件, 可在不重启服务的情况下动态创建数据源 | | atom-starter-mybatis-mutli | 数据库操作组件, 提供多数据源支持 | | atom-starter-mybatis | 数据库操作组件, 封装常用的 list, page, 流式查询, SQL 慢查询, 非法 SQL 拦截, 分页等常用功能 | | atom-starter-qrcode | 二维码组件, 提供二维码生成接口, 比可自定义二维码生成样式 | | atom-starter-rest | API 层接口组件, 提供异常全局处理, 统一响应的数据结构, 参数校验, 参数转换, XSS 安全过滤等常用功能 | | atom-starter-retry | 重试组件, 可通过配置在发生服务调用异常时自动重试功能 | | atom-starter-schedule | 分布式定时任务组件, 可通知一个简单的组件声明任务, 然后通过配置的方式触发任务执行时间, 并提供 web 端管理所有的定时任务, 可实时监控任务执行情况 | | atom-starter-security | 安全组件, 提供接口权限检验, 数据权限校验, 动态加载安全校验规则等功能 | | atom-starter-sms | 短信组件, 集成了阿里云, 腾讯云和亿美等短信服务商接口, 减少业务端集成逻辑, 一键发送短信 | | atom-starter-template | starter 技术组件模板, 规范与简化技术组件的编码方式, 业务端可使用此模板编写业务上的组件并实现复用 | ## 3. 开发计划 整体的开发计划分为 4 个季度, Q1 主要目标是根据 **配电监测** 需要使用的技术栈进行开发, 主要涉及到 Launcher, Logsystem, REST, Mybatis 4 个 starter 组件. 为了保证以上 4 个组件的正常使用, 还必须完成 builder 和 center 层的开发. ![20241229154732_e9dOoXni.webp](https://cdn.dong4j.site/source/image/20241229154732_e9dOoXni.webp) ## 4. 代码规范 代码规范的意义在于提高代码的严谨性,减少 bug 的产生; 促进团队之间的合作,交流方便; 降低维护成本; 有助于代码审查; 提高代码的规范性,有利于提高程序员的自我成长. 因此制定一个符合自己公司情况的开发规范,认识到代码规范的重要性,坚持规范的开发习惯。 这部分会在 Q2 启动. ## 5. 其他 ### 5.1 技术文档 基础框架不仅仅只是交付可用的代码, 必须配套交付结构清晰, 语义明确的技术文档, 一方面是让团队快速上手开发, 另一方面是作为团队的技术沉淀, 因此会使用 [开源工具](https://vuepress-theme-hope.github.io/v2/zh/) 构建团队的 wiki 知识库, 提供提升团队的技术实力. ## [从通用到垂直:企业知识库的AI赋能](https://blog.dong4j.site/posts/f3b0024e.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ![AI-应用.drawio.svg](https://cdn.dong4j.site/source/image/AI-%E5%BA%94%E7%94%A8.drawio.svg) ![dify.drawio.svg](https://cdn.dong4j.site/source/image/dify.drawio.svg) 越来越多的企业和个人希望能够利用 LLM 和生成式人工智能来构建专注于其特定领域的具备 AI 能力的产品。目前,大语言模型在处理通用问题方面表现较好,但由于训练语料和大模型的生成限制,对于垂直专业领域,则会存在知识深度和时效性不足的问题。在信息时代,由于企业的知识库更新频率越来越高,并且企业所拥有的垂直领域知识库(例如文档、图像、音视频等)往往是未公开或不可公开的。因此,对于企业而言,如果想在大语言模型的基础上构建属于特定垂直领域的 AI 产品,就需要不断将自身的知识库输入到大语言模型中进行训练。 目前有两种常见的方法实现: - 微调(Fine-tuning):通过提供新的数据集对已有模型的权重进行微调,不断更新输入以调整输出,以达到所需的结果。这适用于数据集规模不大或针对特定类型任务或风格进行训练,但训练成本和价格较高。 - 提示调整(Prompt-tuning):通过调整输入提示而非修改模型权重,从而实现调整输出的目的。相较于微调,提示调整具有较低的计算成本,需要的资源和训练时间也较少,同时更加灵活。 综上所述,微调的方案投入成本较高,更新频率较低,并不适合所有企业。提示调整的方案是在向量库中构建企业的知识资产,通过 LLM+ 向量库构建垂直领域的深度服务。本质是利用数据库进行提示工程(Prompt Engineering)将企业知识库文档和实时信息通过向量特征提取然后存储到向量数据库,结合 LLM 可以让 Chatbot 的回答更具专业性和时效性,也更适合中小型企业构建企业专属 Chatbot。 在机器学习领域,为了能够处理大量的非结构化的数据,通常会使用人工智能技术提取这些非结构化数据的特征,并将其转化为特征向量,再对这些特征向量进行分析和检索以实现对非结构化数据的处理。将这种能存储、分析和检索特征向量的数据库称之为向量数据库。 ## 技术选型 ### 需求描述 打造 **特定领域知识 (Domain-specific Knowledge)** **问答** 系统,具体需求有: - 通过自然语言问答的形式,和用户交互,同时支持中文和英文。 - 理解用户不同形式的问题,找到与之匹配的答案。可以对答案进行二次处理,比如将关联的多个知识点进行去重、汇总等。 - 支持上下文。有些问题可能比较复杂,或者原始知识不能覆盖,需要从历史会话中提取信息。 - 准确。不要出现似是而非或无意义的回答。 --- ### 整体方案 使用 LLM 作为用户和搜索系统件沟通的介质,发挥其强大的 [自然语言处理](https://cloud.tencent.com/product/nlp?from_column=20065&from=20065) 能力:对用户请求进行纠错、提取关键点等预处理实现 “理解”;对输出结果在保证正确性的基础上二次加工,比如——概括、分析、推理等。 整个方案设计如下图所示由两部分组成: ![20241229144920_ZXCsfntf.webp](https://cdn.dong4j.site/source/image/20241229144920_ZXCsfntf.webp) 说明如下: 1. 使用 OpenAI 的 Embedding 接口将专业领域知识转化为向量,连同原始材料一并保存在 Redis 中。 2. 用户提问的搜索处理: 3. 使用 OpenAI API 对用户的问题进行 Embedding,获得向量。 4. 使用问题向量在 Redis 中搜索,找到与之最匹配的若干记录。将这些记录的原始材料返回。 5. 使用 **OpenAI** 的 Completion API 对这些原始材料进行加工完善,并将最终结果返回。 具体的做法是将知识库的内容通过某些格式保存到数据库中,然后每次提问的时候,先取数据库检索相关的内容,然后将内容和问题按照类似上面的 prompt 提交给 ChatGPT,经过 ChatGPT 来生成高质量的回答。而这个保存了书内容的数据库就是**外挂知识库**。 其中保存到数据库的过程是对原文本进行 Tokenizer(分词) + Embedding(向量化),数据库则称为 Vector Store (向量数据库) **整个过程如下:** ![20241229144920_SnCXbO1D.webp](https://cdn.dong4j.site/source/image/20241229144920_SnCXbO1D.webp) 名词解释: - 分词(Tokenizer): 将文本拆分成单个单词或词语,结构化为计算机可以处理的结构化形式,,比如 我每天六点下班 可以拆分为 “我”,“每天”,“六点下班”,常见的分词器有 markdown 分词器 [MarkdownTextSplitter](https://www.langchain.com.cn/modules/indexes/text_splitters/examples/markdown) - 向量化(Embedding):将文本数据转换为向量的过程。计算机无法直接处理文本,因此需要将文本转换为数学向量形式,以便算法能够理解和处理。文本和数学向量之间互相映射,但数学向量更便于计算机运算。对中文比较友好的向量模型库有 [shibing624/text2vec-base-chinese](https://github.com/shibing624/text2vec) - 向量数据库(Vector Store): 存储和管理向量化后的文本数据的数据库,能快速检索相似文本或进行文本相似性比较。 比如 [FAISS](https://github.com/facebookresearch/faiss) 这个库 --- ### LLM [Awesome Chinese LLM](https://github.com/HqWu-HITCS/Awesome-Chinese-LLM) 在上述方案中, 推荐选择具有 OpenAI API 的 LLM, 后期可以无感知的升级底层的 LLM. 常见的开源的 LLM 有以下几种: #### ChatGLM3 ChatGLM3-6B 是 ChatGLM3 系列中的开源模型,在保留了前两代模型对话流畅、部署门槛低等众多优秀特性的基础上,ChatGLM3-6B 引入了如下特性:更强大的基础模型: ChatGLM3-6B 的基础模型 ChatGLM3-6B-Base 采用了更多样的训练数据、更充分的训练步数和更合理的训练策略;更完整的功能支持: ChatGLM3-6B 采用了全新设计的 Prompt 格式,除正常的多轮对话外。同时原生支持工具调用(Function Call)、代码执行(Code Interpreter)和 Agent 任务等复杂场景;更全面的开源序列: 除了对话模型 ChatGLM3-6B 外,还开源了基础模型 ChatGLM3-6B-Base、长文本对话模型 ChatGLM3-6B-32K。以上所有权重对学术研究完全开放,在填写问卷进行登记后亦允许免费商业使用。 #### LLaMA2 该项目基于可商用的 LLaMA-2 进行二次开发决定在次开展 Llama 2 的中文汉化工作,包括 Chinese-LlaMA2: 对 Llama 2 进行中文预训练;第一步:先在 42G 中文预料上进行训练;后续将会加大训练规模;Chinese-LlaMA2-chat: 对 Chinese-LlaMA2 进行指令微调和多轮对话微调,以适应各种应用场景和多轮对话交互。同时我们也考虑更为快速的中文适配方案:Chinese-LlaMA2-sft-v0: 采用现有的开源中文指令微调或者是对话数据,对 LlaMA-2 进行直接微调 (将于近期开源)。 #### Baichuan 由百川智能开发的一个开源可商用的大规模预训练语言模型。基于 Transformer 结构,在大约 1.2 万亿 tokens 上训练的 70 亿参数模型,支持中英双语,上下文窗口长度为 4096。在标准的中文和英文权威 benchmark(C-EVAL/MMLU)上均取得同尺寸最好的效果。 #### Qwen 通义千问 是阿里云研发的通义千问大模型系列模型,包括参数规模为 18 亿(1.8B)、70 亿(7B)、140 亿(14B)和 720 亿(72B)。各个规模的模型包括基础模型 Qwen,即 Qwen-1.8B、Qwen-7B、Qwen-14B、Qwen-72B,以及对话模型 Qwen-Chat,即 Qwen-1.8B-Chat、Qwen-7B-Chat、Qwen-14B-Chat 和 Qwen-72B-Chat。数据集包括文本和代码等多种数据类型,覆盖通用领域和专业领域,能支持 8K 的上下文长度,针对插件调用相关的对齐数据做了特定优化,当前模型能有效调用插件以及升级为 Agent。 #### Mixtral 该项目基于 Mixtral-8x7B 稀疏混合专家模型进行了中文扩词表增量预训练,开源了 Chinese-Mixtral-8x7B 扩词表模型以及训练代码。该模型的的中文编解码效率较原模型显著提高。同时通过在大规模开源语料上进行的增量预训练,该模型具备了强大的中文生成和理解能力 --- ### 向量模型 向量模型可以将任意文本映射为低维稠密向量,以用于检索、分类、聚类或语义匹配等任务,并可支持为大模型调用外部知识。 简单而言就相当于一个“桥梁” —— 翻译:把图片,文字,视频以及音频全部转换为数字,并且包含了数据的信息,使得大模型都能”懂“,能利用这些数字去做训练和推理. embedding 模型的应用主要包含以下几点: - embedding 模型可以将各种数据(语言、图片等)转化为向量,并使用向量之间的距离来衡量数据的相关性。 - 在大模型时代,这种技术有助于解决大模型在回答问题时可能出现的问题,可以帮助大模型获取最新的知识。 - OpenAI、Google、Meta 等大厂也都推出了自己的语义向量模型和 API 服务,催生了大量的应用和工具,如 LangChain、Pinecone 等 各模型对应的地址如下: text2vec-base-chinese: [https://huggingface.co/shibing624/text2vec-base-chinese](https://link.zhihu.com/?target=https%3A//huggingface.co/shibing624/text2vec-base-chinese) text2vec-bge-large-chinese: [https://huggingface.co/shibing624/text2vec-bge-large-chinese](https://link.zhihu.com/?target=https%3A//huggingface.co/shibing624/text2vec-bge-large-chinese) M3E: BGE: OpenAi: [https://platform.openai.com/docs/api-reference/embeddings](https://link.zhihu.com/?target=https%3A//platform.openai.com/docs/api-reference/embeddings) 千帆大模型: [https://cloud.baidu.com/doc/WEN](https://link.zhihu.com/?target=https%3A//cloud.baidu.com/doc/WENXINWORKSHOP/s/alj562vvu) --- ### 向量数据库 因为喂给 Transformer 的知识首先需要做 embedding,所以用于存储 embedding 之后数据的数据库即可称为向量数据库。 因为向量数据库是基于 embedding 之后的向量的存储与检索。所以首先需要提供存储能力,其次更重要的是检索。 即如何根据一个 query 快速找到相关的 embedding 内容。 关于检索,主要是计算两个向量之间的相似度。 [向量数据库|一文全面了解向量数据库的基本概念、原理、算法、选型](https://cloud.tencent.com/developer/article/2312534) [主流向量数据库一览](https://zhuanlan.zhihu.com/p/628148081) #### Weaviate Weaviate 是一个开源矢量数据库,它同时存储对象和矢量,允许将矢量搜索与结构化过滤与云原生数据库的容错和可扩展性相结合,所有这些都可以通过 GraphQL、REST 和各种语言客户端访问。 #### Milvus #### Pinecone #### pgvector pgvector 是 PostgreSQL 的一个**向量搜索扩展**,它可以在 PostgreSQL 数据库中进行高效的向量相似度搜索。这种架构能够实现**功能强大且智能**的知识库问答服务,并且具有较高的性能、可靠性和可扩展性,是构建知识库型 AI 系统的一个非常好的选择。 --- ### 方案选择 目前已经有许多开源的方案,也有许多商业化的方案,基本上可以分为: 1. ChatGPT + Fine-tune: 微调出一个自己的模型,从一些大佬的反馈来看,这种方式成本高,需要花费很多精力去训练,效果不一定能够很好。可以看看 [如何使用 OpenAI fine-tuning(微调)训练属于自己的专有模型? - 知乎](https://www.zhihu.com/question/591066880) 和 [大模型外挂(向量)知识库 - 知乎](https://zhuanlan.zhihu.com/p/633671394) 2. ChatGPT + 外挂知识库: 这个有两个方案,第一个就是官方提供的插件 [chatgpt-retrieval-plugin](https://github.com/openai/chatgpt-retrieval-plugin) 来处理文档向量,缺点就是只能在 ChatGPT 源站点使用,并且要有插件开发者权限。另一个是利用 LangChain 处理生成向量库,然后调用 ChatGPT openapi , 带上检索出来的相关数据和问题去使用。 3. 开源 LLM + 微调: 就是利用开源的 LLM 微调训练目标的知识库,比如 [ChatGLM3](https://github.com/THUDM/ChatGLM3),当然训练成本也是在的,但可以做到数据不泄露,前面 2 种始终需要通过 ChatGPT,难免出现一些数据泄露。 4. LangChain + 开源 LLM: 如果不想自己训练,又想保证数据安全,那么结合 2,3 点的方案则是安全可靠的,用 LangChain 对文档进行向量化,然后检索内容,在调用 LLM 对得到的内容进行总结输出。 5. 成熟的开源项目: 主要有 [FastGPT](https://fastgpt.run/) 和 [Dify](https://dify.ai/zh) 6. 使用 [TianliGPT](https://docs_s.tianli0.top/) 作为技术文档的摘要. 上面几种方案,2,4 都是比较简单的方案,区别就是模型的问题和数据是否私有化,这里选择方案 4,不依赖 Openai,可以少处理点坑。最终选择的方案就是 [Langchain-Chatchat](https://github.com/chatchat-space/Langchain-Chatchat) + [chatglm3-6b](https://huggingface.co/THUDM/chatglm3-6b) ## [绩效考核新篇章:技术中心的绩效管理与评估方法](https://blog.dong4j.site/posts/3a683908.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 2. 技术中心组织架构 ### 2.1 整体架构 ![组织架构.drawio.svg](https://cdn.dong4j.site/source/image/%E7%BB%84%E7%BB%87%E6%9E%B6%E6%9E%84.drawio.svg) --- 1. 技术中心由 2 个业务线和 5 个职能团队构成。其中事业线分为为: 业务部门一、业务部门二 2. 职能团队为:开发团队、 测试团队、产品团队、UI 设计团队、运维团队和实施团队; 3. 技术中心领导团队构成由 技术中心总负责人、技术负责人、产品负责人和各事业线项目经理以及各团队 Leader 构成,实行双线管理,双岗双责机制; --- ### 2.2 工作汇报机制 1. 各事业线人员向项目经理汇报; 2. 产品团队、开发团队、测试团队、运维团队向各团队 Leader 汇 报,各团队 Leader 技术负责人和产品负责人汇报; 3. 项目经理、实施 Leader、 技术负责人和产品负责人向总负责人汇报; --- ### 2.3 部门划分与职能 #### 2.3.1 部门一 界定各部门的职责范围 #### 2.3.2 部门二 界定各部门的职责范围 --- ### 2.4 团队划分与职能 ![20241229144917_Puj2WMCy.webp](https://cdn.dong4j.site/source/image/20241229144917_Puj2WMCy.webp) --- #### 2.4.1 产品开发团队 **产品开发团队职责** 1. 技术引领与支持:关注国内外行业前沿发展动态及信息,研究并应用先进技术,管理公司整体核心技术,规划和引领公司开发技术发展,满足公司战略对技术提出的要求,以及系统更新产品升级的技术需求,向业务部门提供技术支持。 2. 标准与质量把控:组织制定公司技术标准,编写完善开发流程, 明确并落实文档编写种类、格式等方面的要求。评审重要需求,组织制定和实施重大技术决策和技术方案,整体把控项目开发进 度与质量。 3. 技术培训与赋能:开展技术培训、交流与分享,提升员工开发技术能力。从技术角度评价员工能力与绩效。 4. 完成公司领导交办的其他工作。 **团队人员构成** 产品开发团队由开发组、测试组和实施组构成。其中:开发组包括:架构师、前端开发工程师、Java 开发工程师、 Android 开发工程师等职位, 后期可根据情况增设数据挖掘与分析师、大数据研发工程师等数据类岗位. --- #### 2.4.2 产品设计团队 **产品设计团队职责** 1. 收集整理与分析行业动态,为公司决策层及产品策略提供依据。根据市场变化、技术演进、竞争对手、供应商、客户需求,制定产品策略 (包括市场调研、产品功能与用户体验设计,产品版本计划 等),对既有产品改良和新产品开发进行规划、设计、评审及成本效益评估。 2. 制定并完善团队工作相关作业的规范与流程。对经核定项目进行工作分配(包括客户需求及个性化定制方案等)、合作协调、资源授予,并建立反馈机制进行产品设计进度管控; 3. 建立完善知识体系,并开展知识、技能、行业信息等方面的培训、 交流与分享,提升员工能力。从产品设计能力角度评价与指导员工。 4. 完成公司领导交办的其他工作。 **团队人员构成** 产品设计团队人员主要由产品经理和 UI 设计师构成。 产品经理: 负责产品需求调研、收集、分析、规划、定义、设计、 追踪客户问题、上线运营监测等产品工作,组织开发、测试、验收、 上线项目管理工作; UI 设计师: 负责产品 UI 、平面设计,设计验收等; --- #### 2.4.3 技术中心总负责人 1. 按照公司的发展战略,制定产品战略规划、年度经营计划和预算方案。 2. 全面管理公司研发和技术支持工作,全面负责技术层面的整体运营,包括软件研发、项目实施、销售支持及技术管理。 3. 规划公司的技术发展路线与新产品开发,实现公司的技术创新目标,及时了解和监督技术发展战略规划的执行情况。 4. 洞察客户需求,捕捉商业机会,规划技术产品,确定产品的技术方向、技术思路、解决技术难题,带领团队实现组织目标。 5. 保证公司技术、产品及解决方案的市场领先性,领导公司技术发展方向及技术进步。 6. 参与重大技术项目的决策,编写项目总体技术方案,对各项目进行质量评估。 7. 负责相关的技术储备,积极推动技术创新工作的开展,探索提高 产品质量的有效途经。 8. 负责行业内新技术的收集、讨论和应用,组织并参与行业内产品 的讨论、研究、评审、汇报、会议。 9. 组织公司新产品的研究试制工作及现有产品的改进工作;负责软硬件产品的预研、试样、设计及开发。 10. 组织研发成果的鉴定和评审,汇总每个项目的可重用成果,形成内部技术和知识方面的的资源库。 11. 做好公司标准和软著、专利 (知识产权) 规划,实施相关标准及申请专利。 12. 培养公司技术团队,监督、指导及激励技术部门的工作。 13. 参与重大商务谈判和商务活动。 14. 公司领导安排的其他工作。 --- #### 2.4.4 技术负责人职责 引领公司技术发展,解决技术难题,制定和落实技术规范,对技术团队全员赋能。 1. 从技术角度出发,参与公司战略规划;完成各项技术数据统计分析报告,为企业提出合理化建议。 2. 把控公司核心技术和方向,解决技术难题。带领技术团队完成公司重大产品技术的开发。 3. 指导各部门进行技术开发,整体把控开 发进度,审核与控制产品的质量。 4. 负责公司产品技术的选型与框架搭建,系统架构设计和研讨,以 及通用核心部件的设计及开发。 5. 完善技术体系的规范和标准,制定相关的流程和制度,促使团队 工作流程规范化、标准化、程序化、提高团队工作效率。 6. 指导与建议搭建公司技术团队,负责技能培训工作,提升团队技术能力。 7. 完成公司领导交办的其他工作。 --- #### 2.4.5 产品负责人职责 根据公司的发展战略和方向,负责公司产品规划与策略的定制和 实施,研究和制定产品设计团队及产品管理的规章制度并监督执行,建立和完善产品管理体系,指导和监督部门的产品管理工作,确保产品满足公司现状和未来发展需要。 1. 关注行业动态,洞察行业和客户需求,掌握市场情况,包括竞争对手的产品策略,制定公司产品规划。 2. 建立与落实产品设计制度标准、工作流程与规范。指导各部门进 行产品规划与设计,整体把控产品设计进度,指导与评审产品设计 质量。 3. 牵头公司重大项目,包括统一协调公司相关部门与产品设计团队 之间的联络工作,确保信息的正常流转;开展外联活动,进行大客户拜访,协同外部资源与信息。 4. 产品团队管理:指导与建议各部门内部的产品设计人员配置,指 导员工的发展,进行相关行业知识与技能培训,提升员工的能力素质。 5. 完成公司领导交办的其他工作。 --- #### 2.4.6 项目经理职责 与项目相关人员进行协同配合,组织并监督全体人员全面完成本部职责范围内的各项工 作任务。 1. 关注、分享与交流行业技术动态、市场客户信息等,组织开展知 识、技能培训,提升团队能力。 2. 根据市场客户需求、公司战略,进行部门产品规划、产品开发、测试与发版,负责项目从启动、开发、实施到结束的全过程、全面的部署、指导和管控。 3. 指导与激励员工开展工作,负责团队建设,传递与引导员工践行公司文化,营造积极向上的团队氛围。 4. 对接到本部门的大客户组建业务管理档案,建立实施大客户调研和跟踪服务机制。 5. 负责对组员下达任务,组织进行开发、实施、验收、归档。 6. 负责项目协调、技术支持与售后服务工作。 7. 完成公司领导交办的其他工作。 --- ## 3. 完整的开发流程 ![20241229144917_9qZ2zrs5.webp](https://cdn.dong4j.site/source/image/20241229144917_9qZ2zrs5.webp) --- ### 3.1 立项流程 ![20241229144917_OXJV3jUs.webp](https://cdn.dong4j.site/source/image/20241229144917_OXJV3jUs.webp) 1. 根据市场需求等输入新产品或者特定产品的设计和开发来源; 2. 对产品可行性进行分析、评估; 3. 形成产品立项报告; 4. 成立项目开发小组。 立项后由产品经理组织需求评审,评审完成后由项目经理编制维格表格,进行生产排期。 --- ### 3.2 需求收集与评审 1. 产品经理在每次版本开始之前定期收集各方需求(需求池维护), 包括客户反馈、业务部门反馈、领导意见、市场调研及技术团队需求等来源,输出需求列表; 2. 在版本开始之前召开版本计划会议,参与者包括项目经理、产品经理及项目核心成员,按优先级梳理需求列表,输出下次版本的初步任务列表(根据评审情况可能会进行调整); 3. 产品经理基于初步任务列表完成需求分析说明书,组织团队成员——包括 UI、开发、测试,召开需求评审会议,输出 **评审意见** 及 **修正完成时间**; 4. 产品经理针对需求评审会议中团队提出的意见建议,在修正完成时间内及时修正需求文档,并及时通知团队相关成员,输出确定的需求文档。 5. 需求评审会议后,由项目经理对产品需求进行综合评估,如技术可行性分析、技术重点难点解决方案、工时评估等,并与产品经理协商初步确定转测、上线时间节点各个节点 6. 产品设计团队开始着手产品原型设计,界面交互设计;测试团队可以同步编写测试计划、测试用例等文档;UI 设计师筹备产品界面设计方案; --- ### 3.3 计划与排期 产品的开发计划由项目经理在产品修订完成并输出需求文档之后进行。产品经理负责编制《产品发布计划》: 1. 明确产品范围目标,识别项目开发过程中各类活动和任务,标识出关键路径; 2. 定义项目阶段及里程碑节点 , 在维格表中编写最终的需求点; 3. 识别各阶段需要输出的工作产品、识别并分配计划中各项任务所需的资源并安排时间进度; **本阶段产出物主要包含:** | 序号 | 产出物 | 负责人或团队 | 备注 | | ---- | ---------------- | ------------ | ------------------------------------------------------------------------ | | 1 | 需求评审纪要 | 产品经理 | 需求评审会后产出 | | 2 | 需求分析说明书 | 产品经理 | 产品立项后产出,评审会评 审主要依据 | | 3 | 系统综合评估报告 | 项目经理 | 对该系统的技术可行性、技 术难点等进行分析和评估, 对开发工时进行初步评估 | | 4 | 产品开发计划 | 项目经理 | 明确需求任务清单后,制定开发计划并记录到维格表 | | 5 | 产品发布计划 | 产品经理 | 记录到维格表 | --- ### 3.4 产品设计与开发 1. 产品经理在维格表中整理对应的计划和需求; 2. 项目经理在维格表中建立对应的项目,关联对应的产品和需求,将需求分解为任务清单,并分配给相应的开发人员; 3. 开发人员根据自身任务的期限,及时与依赖方沟通,确定依赖任 务的完成时间,以免影响自身任务进度,存在问题及时向项目经理反馈; 4. 产品经理完成系统原型设计和交互设计之后,可组织项目团队进行需求评审; 5. UI 设计完成后,相关开发人员与产品经理需对 UI 设计进行确认, 如果涉及内容较多,可组织 UI 评审会议(参会人员由产品经理或项 目经理权衡组织); 6. 涉及流程的开发任务必须有对应的详细设计,技术相关负责人负责对设计进行评审,没有审查过的设计不能投入开发。 7. 任务开发完成需要进行代码审查(code review)。开发人员完成相应的开发任务后,同步在维格表中关闭任务,以便于管理产品开发实时状态; 8. 测试团队编写完成测试用例后,需召开用例评审会议,由项目经理权衡组织参会人员。评审目的主要确保测试范围、边界和覆盖率, 最大可能的保障产品质量; 9. 产品经理或项目经理每周应召开周例会,总结团队上周的问题解决进度, 本周的工作完成情况和下周的工作计划, 持续跟进任务进度与问题,并及时协调处理, 以保障进度预期,会后形成《项目周例会会议纪要》; 10. 项目经理应当定期(每周一次)撰写《项目进展报告》,通报给上级领导和所有项目成员; 11. 在预定提测时间节点前一天,开发人员编写提测文档,描述本次版本调整内容(附上任务列表)及注意事项,并通知项目相关人员(邮件); **本阶段产出物主要包含:** | 序号 | 产出物 | 负责人或团队 | 备注 | | ---- | ------------------------- | ------------ | ------------ | | 1 | 产品原型及交互设计 | 产品经理 | 产品原型评估 | | 2 | 产品 UI 设计 | UI 设计师 | UI 设计评估 | | 3 | 测试计划 | 测试团队 | | | 4 | 测试用例 | 测试团队 | 测试用例评估 | | 5 | 项目周例会会议纪要 | 项目经理 | 周例会后更新 | | 6 | 项目进度管理 项目进展报告 | 项目经理 | 周例会后更新 | --- ### 3.5 问题与风险管理 项目经理和产品经理及时识别并解决产品开发过程的问题和风险,每周进行一次,完成《问题跟踪》、《风险跟踪》表格的记录和更新, 附在项目进展报告后。 **问题跟踪表:** | 编号 | 问题描述 | 解决方案 | 责任人 | 问题提 出时间 | 计划解 决时间 | 实际完 成时间 | | ---- | -------- | -------- | ------ | ------------- | ------------- | ------------- | | | | | | | | | **风险跟踪表:** | 编号 | 风险 描述 | 风险 来源 | 分类 | 应对 策略 | 严重 性 (1-3) | 可能 性 (1-3) | 风险 系数 | 优先 级 | 缓解 方案 | 应急 方案 | 提出 时间 | | ---- | --------- | --------- | ---- | --------- | ------------- | ------------- | --------- | ------- | --------- | --------- | --------- | | | | | | | | | | | | | | --- ### 3.6 测试与验收 1. 需求评审会议后,测试团队需对各功能模块编写测试用例文档, 并在提测前组织测试评审会议,对各功能各环节进行复核与查漏补缺; 2. 一次版本任务可根据情况分多次进行单元测试,并确定每轮提测的内容与时间节点; 3. 单元测试完成后,需在上线前进行集成测试、系统测试和验收测试; 4. 每轮测试完成,需将测试结论通报项目相关人员(邮件),包括遗留问题与是否达到上线要求结论; **注:产品经理可在提测后对开发实现进行验收,以确定开发是否符合需求实际,以便及时进行调整;** **本阶段产出物主要包含:** | 序号 | 产出物 | 负责人或团队 | 备注 | | ---- | -------- | ------------ | ---- | | 1 | 测试报告 | 测试团队 | | | 2 | 验收报告 | 测试团队 | | --- ### 3.7 产品发布 1. 产品经理需在上线前编写《产品上线功能、资源及验收清单》, 记录此次上线内容、资源需求和验收标准。 2. 项目经理组织相关人员对此次上线操作进行推演,对所涉及的所有操作按步骤进行记录,如数据库操作、代码合并、服务器资源及配置等,对可能存在的问题进行备注及对应的处理方案,并提交技术相关负责人审查; 3. 技术负责人结合测试结论及其它各方面情况,决策是否上线,并将意见通知到项目相关人员(邮件); 4. 项目经理与产品经理按照上线方案文档记录的步骤,依次完成上 线操作(上线操作最好至少由两人完成,一人操作,一人检视,避免出错); 5. **上线完成后,测试人员与产品经理对此次上线进行线上验证, 保线上功能流程无问题;** 6. 验证无误后,由产品经理将上线通知发布至利益相关者,包括项目团队所有成员及相关合作方,说明上线时间、上线内容、影响因素、 注意事项等 (对外有条件就使用邮件, 没有条件使用通讯群, 对内使用邮件通知); **注:产品上线前一周需再次通过各途径提前告知业务部门、客户 等。** **本阶段产出物主要包含:** | 序号 | 产出物 | 负责人或团队 | 备注 | | ---- | ---------------------------- | ------------ | ---- | | 1 | 产品上线功能、资源及验收清单 | 产品经理 | | | 2 | 产品发布通知 | | | --- ### 3.8 产品培训 产品发布上线前后均需要对干系人进行培训,培训次数不少于 2 次,做到: 1. 培训的节点:产品需求评审结束后开展第一轮讲解,阐述说明本次更新的要点和功能调整范围、影响范围以及发布时间,并根据业务人员或客户的反馈意见进行相关调整; 版本发布前 3 天,再次通过钉钉直播方式对 业务人员、客户业务人员等进行培训讲解,功能操作流程和步骤,再次强调功能调整范围和影响范围; 2. 对重要客户发布前和发布后还需要单独进行培训与讲解, 确保客户对系统的调整了解和能熟练掌握系统的使用 (此项由产品经理和技术实施团队负责); 3. 培训前,均需要做好培训材料的准备工作(如本次版本升级的 目标、功能调整的内容、影响范围、具体的业务流程和操作步 骤说明和发布时间等),培训后均需要记录培训人、培训对象、 培训内容及培训时间等关键信息。 **本阶段产出物主要包含:** | 序号 | 产出物 | 负责人或团队 | 备注 | | ---- | ---------------- | ------------ | ---- | | 1 | 产品培训手册 | 产品经理 | | | 2 | 产品培训记录表格 | 产品经理 | | ### 3.9 复盘与持续改善 版本结束后,项目经理根据情况对上个周期组织复盘总结会,总结存在的问题与原因,及后续规避的办法,总结积累的经验等。 **本阶段产出物主要包含:** | 序号 | 产出物 | 负责人或团队 | 备注 | | ---- | ---------------- | ------------ | -------------- | | 1 | 复盘总结会议纪要 | 项目经理 | 总结会议后产出 | | 2 | | | | 以上各阶段并不是完全串行推进的,相互之间存在一些穿插,比如下一版本需求的收集整理与当前版本的开发是并行推进的,开发与测试也可以以分阶段转测的形式并行推进。 --- ### 3.10 开发流程中各岗位职责说明 | | 启动立项 | 需求评审/计划排期 | 设计与开发 | 测试验收 | 发布上线 | 考核与培训 | | ---------- | ---------------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------- | | 决策层 | 参会决策 可行性分析 经济效益分析 下达产品开发任务 | 参会决策 | 参会决策 资源协调 规范管控 | 参会决策 规范管控 | 参会决策 规范管控 | 资源协调 | | 业务部门 | 收集并验证市场需求 整理业务/客户需求 参加会议 | 确认需求设计方案 | - | | | 参加培训 | | 技术负责人 | 技术路线分析 产品方案建议 组织可行性分析会议 成立项目小组 任命项目经理 | 参加会议 制定产品质量目标与计划 | 资源协调 方案把控 进度管理 | 对交付结果进行分 析、改进 | 交付核查 | 组织团队项目总 结会议 | | 项目经理 | 编制立项报告 召开项目小组周例会 | 参加需求评审会 技术可行性综合评估 编制《项目 WBS 及进度管理》 | 编制《详细设计》 组织设计评审 站立会/周例会 项目 WBS 及进度 管理 项目进展报告 Code review, 问题与风险管控 | 功能核验 | 上线推演 预上线发布 | 团队绩效考核 产品复盘总结 持续优化计划 | | 产品经理 | 需求收集与整理 产品设计解决方案 | 组织需求评审会议 输出需求设计文档 | 产品原型、PRD 及交互 设计 阶段性功能核验 | 功能核验 | 上线推演 根据《产品上线功 能、资源及验收清 单》核验 编制《产品发布通 知》并发布 | 编写培训手册 组织产品培训 编写用户手册 持续优化需求整 理 | | UI 设计 | - | 参加会议 | 产品 UI 设计 | UI 界面核验 | UI 界面验收 | - | | 开发团队 | - | 参加会议 | 编制《详细设计》 系统开发 单元测试 | 修复 bugs 代码优化改进 | 持续修复、改进 | - | | 测试团队 | - | 参加会议 | 编制《测试计划》 编制《测试用例》 | 单元测试 集成测试 系统测试 验收测试 | 测试报告 验收报告 | - | --- ## 4.项目管理工具 为了提高项目管理效率, 量化每一个阶段各岗位人员的工作, 需要记录开发过程中输出产物与计划安排, 现规定使用 **维格表 + NAS 协作** 的方式进行开发过程管理. ### 4.1 维格表 #### 4.1.1 项目分类 ##### 4.1.1.1 业务线 ##### 4.1.1.2 业务中台 ##### 4.1.1.3 其他 #### 4.1.2 项目结构 | 模块 | 负责人 | 添加时间 | 备注 | | ------------ | ----------- | -------------------------------------- | ----------------------------------- | | 项目总里程碑 | 产品经理 | 项目立项 | 项目立项之后添加 | | 产品版本管理 | 产品经理 | 需求确认后 | | | 迭代版本管理 | 产品经理 | 需求确认后 | | | 产品需求管理 | 产品经理 | 客户反馈/优化 | | | 开发任务管理 | 开发 Leader | 需求评审完成后 1 周内 (过程中实时更新) | 按版本添加与需求关联的开发任务 | | 测试用例管理 | 测试 Leader | 需求评审完成后 1 周内 | 按版本添加与需求关联的测试用例 | | BUG 缺陷管理 | 测试 Leader | 测试结果不符合预期 | 按版本添加与测试用例关联的 BUG 记录 | | | | | | | 项目 | 项目经理 | | 对整个项目过程管控和质量管控负责 | --- ### 4.2 NAS 协作 NAS 作为协作编辑与文档归档备份的工具, 可与他人对同一份文档进行写作编辑, 有效解决使用通讯工具发送文档副本带来的工作负担, 目前以为技术中心所有人开通了账号, 现将开始在技术中心推行 NAS 的使用. 以城服事业部为例, 说明目录结构 #### 4.2.1 目录结构 ``` . ├── 01 项目一 ├── 02 项目二 │   ├── 00 竞品分析 │   │   ├── 1 月 │   │   └── 竞品分析结构梳理.xmind │   ├── 01 计划阶段 │   │   └── 01 项目信息表 │   ├── 02 需求阶段 │   │   ├── 01 需求收集 │   │   ├── 02 需求文档 │   │   │   └── v1.0.0 │   │   │   └── 需求规格说明书.docx │   │   └── 03 需求评审 │   │   ├── v1.0.0 │   │   ├── v1.0.1 │   │   ├── v1.1.0 │   │   └── v2.0.0 │   ├── 03 设计阶段 │   │   ├── 00 UI 设计 │   │   │   └── v1.0.0 │   │   │   └── 评审会议纪要 │   │   ├── 01 功能设计 │   │   │   └── v1.0.0 │   │   │   └── 功能设计说明书.docx │   │   └── 02 技术评审 │   │   └── v1.0.0 │   │      └── 评审会议纪要 │   ├── 04 开发阶段 │   │   ├── 01 代码评审 │   │   │   └── v1.0.0 │   │   │    └── 评审会议纪要 │   │   └── 02 提测报告 │   │   └── v1.0.0 │   │   └── 系统提测申请单.xlsx │   ├── 05 测试阶段 │   │   ├── 01 测试用例 │   │   │   └── v1.0.0 │   │   │   └── 评审会议纪要 │   │   └── 02 测试报告 │   │   └── v1.0.0 │   │   └── 2024-03-04 │   │      └── 系统测试报告.docx │   ├── 06 上线阶段 │   │   ├── 01 部署说明 │   │   │   └── v1.0.0 │   │   └── 02 运维手册 │   │   │   └── v1.0.0 │   ├── 07 验收阶段 │   │   ├── 01 使用手册 │   │   │   └── v1.0.0 │   │   │   └── 系统使用手册.docx │   │   ├── 02 培训文档 │   │   │   └── v1.0.0 │   │   │   └── 培训文档.pptx │   │   └── 03 验收报告 │   │   └── v1.0.0 │   │   └── 系统竣工报告.docx │   ├── 08 项目复盘 │   │   └── v1.0.0 │   │      └── 会议纪要 │   ├── 09 项目管理 │   │   └── v1.0.0 │   │   ├── 会议纪要.docx │   │   ├── 项目周报.xlsx │   │   ├── 项目月报.xlsx │   │   └── 项目进度简报.docx │   ├── 10 招投标 │   └── 11 实施 │   │   ├── 01 项目实施方案 │   │   ├── 02 项目实施进度计划 │   │   └── 03 项目实施进度简表 │   └── 12 其他资料 └── 03 项目三 ``` #### 4.2.2 目录内容 | 目录 | 内容 | 负责人 | 输出时间 | 备注 | | ----------- | -------------------------------------------- | ----------------- | -------------------------- | -------------- | | 00 竞品分析 | 分析同类产品, 了解行业动态 | 产品经理 | 每月输出至少 1 篇 | 组织分享 | | 01 计划阶段 | 项目信息表 (项目信息,相关干系人等) | 项目经理 | 项目立项后, 每周更新 | - | | 02 需求阶段 | 需求规格说明书, 需求评审会议纪要 | 产品经理 | 需求整理完成后输出 | 按版本分子目录 | | 03 设计阶段 | 软件功能详细设计 技术方案评审会议记录 | 开发 Leader | 开发根据需求大小确认时间 | 按版本分子目录 | | 04 开发阶段 | 提测报告 代码评审 | 开发 Leader | 提测邮件发出前 上线前 2 天 | 按版本分子目录 | | 05 测试阶段 | 测试报告 | 测试 Leader | 每周总结性输出 | 版本 + 周 | | 06 上线阶段 | 部署手册 运维手册 | 项目经理 | 上线前 1 天 | 按版本分子目录 | | 07 验收阶段 | 使用手册 培训文档 验收报告 | 项目经理 产品经理 | 上线后 2 天内 | 按版本分子目录 | | 08 项目复盘 | 项目复盘记录 | 项目经理 | 上线后 2 天内 | 按版本分子目录 | | 09 项目管理 | 项目过程管理文档 | 项目经理 | 每周更新 | 按版本分子目录 | | 10 招投标 | 项目招投标文件 (抹除敏感信息) | 项目经理 | - | - | | 11 实施 | 实施方案 实施进度计划 实施进度简报 | 项目经理 | 每周更新 | - | | 12 其他资料 | 项目相关的其他资料, 比如业内规范, 参考资料等 | 项目经理 | - | - | --- ## 5. 绩效考核体系设计 ### 5.1 绩效评价指标 根据各个岗位设置不同的考核指标, 具体分为管理岗, 产品岗, 开发岗, 测试岗, 运维岗和实施岗位. ### 5.2 绩效评价周期 月初 ### 5.3 绩效评价方法 自评 + 上级考评 ### 5.4 绩效考核流程 1. 设定绩效目标 2. 收集数据和信息 3. 绩效评价 4. 反馈与改进 --- ## 6. 绩效考核标准 ### 6.1 指标分类 除 **管理团队** 外, 其他岗位使用以下指标: | 指标 | 权重 | 备注 | | ------------ | ---- | ---------------------- | | 工作业绩 | 70% | 按岗位细分 | | 岗位胜任能力 | 15% | 岗位通用 | | 工作态 度 | 15% | 岗位通用 | | 黑红事件 | - | 额外加减分项, 岗位通用 | **总分 = 自评总分 (工作业绩总分 + 岗位胜任能力总分 + 工作态度总分) _ 30% + 上级评分 (工作业绩总分 + 岗位胜任能力总分 + 工作态度总分) _ 70% + 黑红事件总分** --- #### 6.1.1 岗位胜任能力 ##### 1. 解决问题能力 - **5 分**,具备很强的专业技术知识,能够快速、准确的定位问题、提供良好的解决方案,总能以超出期望的时间和办法解决遇到的问题,并能在工作中提出建设性建议; - **4 分**,对与工作相关的专业知识充分了解,对于技术或产品或业务等方面的问题,能快速的定位并提供良好的解决方案,能及时、有效地解决遇到的问题; - **3 分**,基本具备与工作相关的专业知识,有时不能及时、有效地解决遇到的问题,但不影响协作进程; - **2 分**,与工作相关的专业知识有一定掌握,需在领导或同事的配合协助下,定位并处理问题,处理结果质量一般; - **1 分**,与工作相关的专业知识不够了解,在各方协助下,能处理解决问题,但是结果不理想; - **0 分**,专业知识不熟、对产品、业务不了解,解决问题能力较差,无法处理或解决问题; --- ##### 2. 协调能力 - **5 分**,善于协调各种关系,为顺利完成工作尽心尽力;乐于关心帮助他人,与人合作融洽,有很强的团队精神,能够主动分享技术、业务经验;能主动与配合部门协调工作,及时调动部门资源,无拖延不抵触,不需催办即可协作工作完成任务;能牵头完成某些需要他人配合的工作,且工作结果优异; - **4 分**,爱护团体,经常协助同事,乐意为群体做贡献;能够关心他人,并建立良好关系,注重集体利益,基本能够在各方面做到取长补短,能主动与配合部门协调工作,不拖延不抵触的协作完成工作任务; - **3 分**,能与部门内同事建立良好关系,能与部门内同事协调处理完成相应的工作,尚有配合意识,虽偶有意见异议但基本能协作工作、完成任务; - **2 分**,协调能力一般,不肯主动与人合作,配合意识不强,需有人督促,方能协作工作、完成任务; - **1 分**,仅在必须协调能力的工作上与人合作; - **0 分**,协调能力极差,严重影响到工作。 --- ##### 3. 沟通能力 - **5 分**,善于与人沟通且亲和力强,态度诚恳非常易被人接受,能积极有效的推动业务或问题解决,能快速与他人达成理解,并建立良好的合作关系;对不同的人,能用不同的方式和措辞让他人快速理解自己的观点或解释; - **4 分**,与人沟通能力良好,说服力良好,态度诚恳易被人接受,一般情况下能通过交流沟通与他人达成一致,能推动事情顺利解决;能快速准确捕捉并理解他人讲话内容中的重点; - **3 分**,与人沟通说服力尚可,态度诚恳易被人接受,基本能与他人进行沟通完成工作任务; - **2 分**,与人沟通说服力一般,有一定技巧,基本能够接受,对事情起不到实质性的推动作用; - **1 分**,与人沟通说服力较差,不善于引导,有时不易被人接受; - **0 分**,与人沟通态度生硬,缺乏沟通技巧,难以被人接受 --- #### 6.1.2 工作态度 ##### 1. 响应速度 - **5 分**,对于市场、客户或同事提出的问题,每次请求均得到及时响应,不需督促便能高质、高量完成分内工作,能够及时的、积极的响应、反馈和跟进,如邮件、电话以及各类群里@的响应速度、回复速度和问题解决速度,任务完成后主动汇报问题解决情况等,并能对问题总结整理归档的; - **4 分**,对于市场、客户或同事提出的问题,能够及时的响应、回复并快速的解决问题,质量较高; - **3 分**,对于市场、客户或同事提出的问题,虽有异议但能满足基本的响应需求,完成分内工作; 2 分,对于市场、客户或同事提出的问题,需在领导的提醒和督促下才响应和处理,响应速度(3 个小时内)、回复和问题处理速度较慢; - **1 分**,对于市场、客户或同事提出的问题,响应速度迟缓(半天内),需领导督促和协助才能处理、解决,问题处理结果不理想; - **0 分**,对于市场、客户或同事提出的问题,响应速度慢(半天以上),需借助领导或其他同事才能解决问题; - **-3 分**,响应速度受到市场或客户投诉一次扣 3 分; --- ##### 2. 周报月报 - **5 分**,能按时提交周报/月报,认真细致填写周/月报各项工作细节以及很好的执行周/月总结、下周/月计划的,对工作遇到的问题能很好的总结并提出建设性的意见; - **4 分**,能按时提交周报月报,认真填写周/月报的各工作细节,能对下周/月工作做简单计划,缺失工作总结或缺乏对工作遇到问题总结和梳理; - **3 分**,能提交周/月报(部分周缺失)并按格式填写相应内容,基本符合要求,周/月工作总结缺失、工作计划缺失,缺乏对工作遇到问题总结和梳理; - **2 分**,能提交周/月报(部分周缺失),周报内容完整但质量不高,无总结、计划,无问题梳理; - **1 分**,能提交周/月报(部分周缺失),周报内容完全应付了事的; - **0 分**,不提交周/月报,即使提交,周/月报内容敷衍了事;或者遗漏且不补交者; - - **3 分**,不提交周/月报,后续提醒督促仍敷衍了事; --- ##### 3. 责任心 - **5 分**,积极主动承担工作中的责任;在团队中传递正能量鼓舞士气,无需督促,主动完成工作并积极协助同事并分担工作或提前对工作进行计划、筹备、实施;能将某项工作跟踪到底,不半途而废,或走形式主意;敢于提出发现的潜在问题,并能提出相对合适的处理方案; - **4 分**,对独立承担工作中的责任,态度好,能偶尔传递正能量鼓舞士气,能主动完成工作并协助同事完成工作; - **3 分**,工作中能按要求完成职责内任务,积极性一般,主动性一般,协作能力一般,在一般情况下能够对自己的行为负责; - **2 分**,对领导分配或安排的任务拖延执行,主动性差,协作能力差,虽可以完成相应任务,但需要督促才执行或完成任务,有时还有抵触情绪;大多数时候被动接受工作,需要上级协调资源; 1 分,严重拖延领导分配或安排的任务,多次督促才执行任务,抵触情绪严重;在一般情况下能够对自己的行为负责,对工作中的失误有时进行逃避或推卸责任; - **0 分**,不执行领导分配或安排的任务,在团队中有发表负能量的言论,影响团队士气,工作态度差;对工作中的失误经常找借口,推卸责任。 - **-5 分**,在团队中多次(2 次以上)发表负能量的言论,严重影响团队士气和团队协作,影响团队工作氛围。 评估关键点:主动积极、工作态度、责任心、正能量、协作能力 --- #### 6.1.3 红黑事件 1. 考核期内受部门、公司、客户嘉奖;部门或公司内分享有价值的技术或设计能给团队带来实质性的收益的(各团队内部工作或技术分享除外) 2. 重难点工作/技术等有突破或对公司、团队有特殊业绩贡献 3. 考核期内渎职、失职、乱用职权,造成重大失误,绩效为 0;其他重大问题或生产事故,视严重情况,扣除 5 至 10 分/次 4. 有过激表现,给部门、公司带来负面影响或经济损失,绩效为 O --- #### 6.1.4 工作业绩 ##### 6.1.4.1 产品 ###### 1. 产品规划与设计 - **20 分**: 产品规划路线图非常清晰明确,规划内容兼顾现有市场变化及公司战略,前瞻性强、可执行度高。配套的产品规划方案、设计方案、原型设计及交互说明内容充实完善,流程完整,逻辑明确清晰,严格遵循设计规范,过程无改动,PRD 文档质量高,对现有产品和新产品的设计能够提出建设性意见或创新性意见; - **16-19 分**: 产品规划路线图清晰,规划内容基本兼顾现有市场变化及公司战略,可执行度高。配套的产品设计方案、原型设计及 PRD 文档内容框架完善,过程改动很少(3 次以内,以 PRD 和原型定稿为准),文档质量高,线前无逻辑性问题导致需求变更或临时增加需求、无因设计问题变更或增加需求及功能方案,对现有产品和新产品的设计能够提出比较实用的意见且绝大部分被接受采纳; - **10-15 分**: 产品规划路线基本清晰,规划内容基本满足现状需求(改动在 5 次以内,以 PRD 和原型定稿为准),有一定的可执行度(需进一步修改、完善)。配置有基本的产品原型设计及 PRD 文档,文档质量一般; - **5-9 分**: 产品规划路线图模糊,规划内容与现有市场存在脱节,可执行行较低。配套产品原型设计及 PRD 文档内容存在一定问题,文档质量差; - **1-4 分**: 无产品规划,配套产品原型设计及 PRD 文档内容存在很多问题,文档质量差; - **0 分**: 无产品规划,配套产品原型设计质量较差,无 PRD 文档。 评估关键点:规划线路设计、规划方案|设计方案、原型设计质量(原型设计清晰、原型设计字段说明清楚、业务流程细致周全、输入输出说明清晰明了、异常逻辑考虑周全、文案定义准确合理、页面流清晰)、PRD 质量,主要是格式、内容细节、准确度和精细度、战略性|前瞻性|可执行性。 上线前,存在逻辑性问题导致需求变更、调整或临时增加需求(一项扣 1 分)(上线前一天出现类似情况,翻倍扣除)、存在遗漏需求,视重要程度而定(一项扣 1-2 分);存在因设计问题变更、调整或临时增加需求及功能方案(一项扣 1 分),以变更或增加的需求点为考核点。 --- ###### 2. 需求管理 - **10 分**,积极主动与客户或相关部门同事采集、沟通需求,能经过加工、梳理和甄别真伪需求并形成详细的有价值的需求概要;根据调研或讨论结果,列入需求池,并跟据需求本身的变动,调整需求池中的相关状态,标注相关的记录,批注,能对需求池做好维护; - **8-9 分**,积极主动与客户或相关部门同事采集、沟通需求,能经过加工、梳理并形成详细的需求概要;列入需求池,并跟据需求本身的变动,调整需求池中的相关状态,标注相关的记录,批注,能对需求池做维护; - **6-7 分**,能与客户或相关部门同事采集、沟通需求,能提供基本的需求概要;理并形成需求概要;列入需求池,并跟据需求本身的变动,调整需求池中的相关状态,标注相关的记录,批注; - **4-5 分**,仅能与客户或相关部门同事交流、采集需求,无需求概要,能将需求记录入需求池; - **1-3 分**,根据调研或讨论结果,采集记录需求列入需求池中; - **0 分**,需求记录模糊不清,无需求池管理。 评估关键点:需求采集、需求甄别、梳理、分析、优先级排列、需求池建立及维护、跟进需求状态并及时变更、对客户需求及时反馈。 --- ###### 3. 日常项目跟进 - **10 分**,与项目经理和项目组成员保持密切沟通,每日能跟踪项目研发进度、功能验收、禅道上指派给自己的 bugs 跟踪、并更新工作计划表(如 WBS/进度表、看板、燃尽图等),能及时向主管领导、项目经理及成员反馈项目进度级存在问题,并能在 WBS 表格、周报、项目管理规范表格中更新、说明;对项目中出现的各类突发状况能及时响应并协调各部门(或各组)积极解决,解决结果能即时通知给主管领导、项目经理、测试以及实施等相关人员; - **8-9 分**,与项目经理和项目组成员保持沟通,定期(每周)跟踪项目研发进展情况、功能验收禅道上指派给自己的 bugs 跟踪、及工作计划表(如/进度表、看板等),对项目中出现的各类突发状况能及时响应并协调各部门(或各组)积极解决,解决结果能基本按时反馈; - **5-7 分**,与项目经理和项目组成员定期沟通,定期跟踪项目研发进展情况、功能验收禅道上指派给自己的 bugs 跟踪,对项目中出现的各类突发状况能够响应并协调各部门(或各组)解决,与市场销售、测试、实施等相关人员存在一定的延迟、效果一般或存在沟通不畅的情况导致项目进展不顺; - **1-4 分**,与项目经理和项目组成员沟通频率较低,项目研发进展情况不够了解,对项目中出现的各类突发状况响应速度较慢,不能主动协调各部门(或各组)推进解决,与市场销售、测试、实施等相关人员没有建立起良好的沟通机制,导致各方的满意度较低; - **0 分**,被动与项目经理和项目成员沟通,对项目研发进展不了解,不能解决各类突发事件,不能协调各部门(或各组)推进解决问题。 评估关键点:沟通、进度追踪频次和质量、工作计划表、功能验收、问题发现|突发状况及处理能力|资源协调能力|工作反馈 --- ###### 4. 评审管理 - **10 分**,评审前积极主动与客户或相关部门同事协调约定评审时间,讲评审文件发给相关干系人;评审中将需求从整体到细节,模块间关系、业务和系统逻辑清晰、有条理地讲解完善,页面完整,并将结论和争议点完整地记录下来;在评审结束后,根据评审结果输出评审报告,并做好对应地解决方案,找与会人员签字确认,在产品部做好归档;每周在指定时间内提交评审计划和评审归零报告; - **8-9 分**,评审前积极主动与客户或相关部门同事协调约定评审时间,讲评审文件发给相关干系人;评审中将需求能够从整体到细节,模块间关系、业务和系统逻辑基本讲解完善,页面完整,并将结论和争议点完整地记录下来;在评审结束后,根据评审结果输出评审报告,并做好对应的解决方案,找与会人员签字确认,在产品部做好归档;每周在指定时间内提交评审计划和评审归零报告; - **5-7 分**,评审前能客户或相关部门同事协调约定评审时间,讲评审文件发给相关干系人;评审中将需求基本准确地讲解出来,无过多的流程、逻辑问题,页面基本完整,并将结论和争议点记录下来;在评审结束后,根据评审结果较完整地输出评审报告,找与会人员签字确认,在产品部做好归档;每周在指定时间内提交评审计划和评审归零报告; - **1-4 分**,临时通知组织评审,没能把评审文件提前与客户或相关部门同事沟通传递,评审过程中讲解不清楚,存在过多逻辑问题,页面不完整。评审报告提交延迟,所产出的产品评审报告中解决方案差,质量差,存在漏项;在督促下才能提交评审计划和评审归零报告; - **0 分**,整个评审流程差,无评审报告,不提交评审计划和归零报告。 评估关键点:评审准备、评审过程讲解逻辑完善有条理、评审报告质量高、评审计划、评审归零报告。 评审文件准备不充分,没通知关键关系人(一项扣 1 分)、讲解混乱,评审现场提出过多逻辑错误,缺页面等问题,视重要程度而定(一项扣 1-2 分);评审报告质量差,不及时提交评审计划和评审归零报告(一项扣 1 分),以评审过程讲解为关键考核点。 --- ###### 5. 版本交付 - **10 分**,每一个里程碑事件或开发、测试或正式版本均能如期或提前交付; - **1-9 分**,在每一个里程碑事件或开发、测试或正式版本中,因任务未完成,导致无法发版,造成测试工作延期,每延期 0.5 天(不足按 0.5 天计)扣 2 分;禅道任务延期一项扣 1 分,有与其他人员协作任务(依赖关系),延期一项扣 2 分; - **0 分**,超过 2 天不能交付,该项绩效为 0。 其他:如因其他团队导致无法按时发版,按责任级别扣除相应分值,责任分级如下: - 负主要责任的扣:90%-100%; - 负次要责任的扣:50%; - 其他团队接受连带责任,扣:30%。 如因任务穿插,人员调配导致人力资源不足等特殊情况造成的延期,由技术中心管理小组综合评估另做考核。 --- ###### 6. 产品调研&竞品分析 - **10 分**,及时收集并研究行业、用户、竞争对手、渠道、产品等方面的市场信息,分析报告的内容充实、合理、针对性强,分析结果对公司产品规划决策具有强有力支持; - **9 分**,能够收集并研究行业、用户、竞争对手、渠道、产品等方面的市场信息,分析报告的内容有针对性,分析结果能够对公司产品规划决策提供支持; - **8 分**,能够收集并研究行业、用户、竞争对手、渠道、产品等方面的市场信息,分析报告能够对客户和市场需求提出分析,分析结果能够对公司产品规划决策提供支持; - **7 分**,能够收集并研究行业、用户、竞争对手、渠道、产品等方面的市场信息,分析报告能够对客户和市场需求提出分析,分析结果可对公司产品规划决策提供一般性的支持; - **6 分**,能够收集并研究行业、用户、竞争对手、渠道、产品等方面的市场信息,分析报告内容缺乏针对性,分析结果不能够对公司产品规划决策提供应有的支持; - **5 分**,能够收集并研究行业、用户、竞争对手、渠道、产品等方面的市场信息,分析报告的内容缺乏条理性和针对性,分析结果数据不足够对产品决策提供支持; - **1-4 分**,能够简单收集并研究行业、用户、竞争对手、渠道、产品等方面的市场信息或仅停留在无计划、无规划的查询、浏览层面,有报告但分析报告内容空泛,分析结果不能够对公司产品规划决策提供支持; - **0 分**,不能够也不会收集并研究行业、用户、竞争对手、渠道、产品等方面的市场信息;无分析报告。 评估关键点:有分析报告、采集信息或资料)、分析报告质量、结果影响力。如若无分析报告,此项为 0 分。 --- ##### 6.1.4.2 UI ###### 1. 产品设计品质 - **15 分**,能准确理解产品受众用户和行业特点,设计风格符合大众审美需求,并能服务于商业目标的达成;UI 设计布局合理、主次分明,信息内容有较好的层次感,基本无返工;UI 设计融入个人创意,并且获得客户赞赏或者用户好评;能多次对交互设计(UE)提出合理的优化建议并被采纳;能及时配合开发完成相关切图工作,保证与开发顺畅衔接;产品界面审核认真、细致、严谨,确保上线产品高质量的界面效果。 - **12-14 分**,能准确理解产品受众用户和行业特点,设计风格基本符合大众审美需求;UI 设计融入个人创意,界面设计布局合理、主次分明,信息内容有较好的层次感,有较少返工;能及时配合开发完成相关切图工作,保证与开发顺畅衔接;产品界面审核认真、严谨,能保证上线产品的界面效果。 - **9-11 分**,能够按照原型和借鉴其他产品风格,完成产品的 UI 设计;UI 设计成果基本满足要求,无明显亮点,有部分返工;能及时配合开发完成相关切图工作,保证与开发顺畅衔接;产品界面审核认真,基本能够确保上线产品界面与 UI 设计效果一致。 - **5-8 分**,能够按照低保真原型和借鉴其他产品风格,完成产品的 UI 设计;UI 设计成果在多次返工后基本满足要求,无明显亮点;能及时配合开发完成相关切图工作,保证与开发顺畅衔接;产品界面审核存在疏漏,较多界面与 UI 设计效果差异较大。 - **1-4 分**,UI 设计成果存在大量粗糙或者配色不合理的情况,离产品高保证 UI 设计要求差距较大;UI 设计成果在多次返工后仍未达到可用要求;能及时配合开发完成相关切图工作,保证与开发顺畅衔接。 - **0 分**,UI 设计严重延期,无法交付或设计结果质量无法达到可用标准,需返工重新设计。 评估关键点:设计风格、设计品质、设计创意、返工率、UE 设计、结果一致性 --- ###### 2. 工作完成效率 - **15 分**,工作效率很高,能高效、高品质的如期或提前完成产品 UI 设计的工作或任务,承担工作量大但能有计划有条理的开展并按时完成,能挑战有难度的工作,能为开发、测试团队提供准确的、强有力的支持,且反馈及时; - **12-14 分**,工作效率高,能保质保量的按时完成岗位职责工作,UI 设计有特色且质量较好,能为开发、测试团队提供很好的支持,保证正常的协作进程; - **9-11 分**,工作效率高,能完成岗位职责内的工作,分内工作均能按时按要求完成,UI 设计质量一般,能为开发、测试团队提供常规性支持,偶尔返工,但不影响协作进程; - **5-8 分**,工作效率一般,基本能按时完成职责工作;能独立按要求完成 UI 设计的工作但需要领导或同事的一定指导或修订才能完成任务,多次返工,但不影响协作进程; - **1-4 分**,工作效率低,需要别帮助才能完成岗位职责工作;需在上级领导的多次指导下才能完成产品原型设计或 UI 设计,经常不能按时按要求完成分内工作并经常返工,影响协作进程; - **0 分**,工作效率极低,不能按照岗位职责完成工作;能力不足,在多次指导下仍无法完成工作任务; 评估关键点:工作效率、提前|按时|延期完成、工作量、工作难度(新领域,没有指导;新领域,有可参照原型;部分要点需创新;常规性工作;重复性工作)、影响协作进程状况。 ###### 3. 产品设计交付 - **10 分**,能高效、高品质的如期或提前完成 UI 设计任务,并能主动检查验收里程碑事件版本; - **8-9 分**,能高质量的按时完成产品 UI 设计任务,基本无返工; 6-7 分,能独立按职责内完成产品 UI 设计任务,质量一般,偶尔返工,但对项目进度未造成影响; - **4-5 分**,能按要求完成产品 UI 设计任务,有时需要领导做一定指导才能达到交付标准,多次返工,对项目进度造成一定影响; - **1-3 分**,需要在上级领导的多次指导下才能完成产品 UI 设计,并经常返工,并造成项目进度延期; - **0 分**,能力不足,在多次指导下仍无法完成工作任务,无法交付设计结果。 考评关键点:交付物延期扣分:在每一项设计工作中,因设计任务未完成,导致无法正常进入开发状态,造成工作延期,每延期 0.5 天(不足按 0.5 天计)扣 2 分;禅道关联任务延期一项扣 1 分。 --- ###### 4. 审图 - **10 分**,各类设计图命名规范、标注清晰、描述详细准确,无文字或数据错误,能帮助开发或测试清晰的了解设计思想,与团队其他成员顺畅衔接,遵从公司过程管理规范,成果物能及时、正确提交; - **8-9 分**,各类设计图命名较规范、标注清晰,描述无歧义,偶尔有文案方面细微错误,能帮助开发或测试了解设计思想;遵从公司过程管理规范,成果物能正确提交; - **5-8 分**,各类设计图命名基本规范、标注清楚,描述不够详细、准确,须返工或沟通、确认才能理解设计意图,遵从公司过程管理规范,成果物能正确提交; - **1-4 分**,各类设计图命名不规范,标注部分混乱、描述不明确或有歧义,须返工或经过多次交流才能让人理解设计意图,遵从公司过程管理规范,成果物能正确提交; - **0 分**,无设计规范或设计图无标注,描述含糊不清,不能让开发或测试理解设计思想,无法达到交付标准。 评估关键点:设计规范)、标注与描述、顺畅协作、返工与交流成本、成果物交付。 --- ###### 5. 设计创新 - **15 分**,能持续学习,不断创新,能结合受众用户和行业特点,很好的将当前优秀的设计思想和理念融合到公司产品,很好的融合 UE 设计理念,帮助公司提高产品设计水平、产品用户体验,并且获得客户赞赏或者用户好评; - **12-14 分**,能通过学习实现创新,对公司软件或产品设计提出有创新的想法及见解,能主动对产品 UE 提出合理的优化建议并运用到设计中,能帮助公司提高产品设计水平和现有产品用户体验; - **9-11 分**,能通过学习实现创新,在领导的指导下或有可参考的设计原型下,进行二次创新和改进,帮助公司提高产品的品质和用户体验; - **5-8 分**,能通过学习解决工作中遇到一般问题,具有一定的学习、创新能力,能在领导的指导下帮助公司改进或优化产品设计、创新或创意设计; - **1-4 分**,能完成相应设计工作及任务,无明显创新能力或创新意识; - **0 分**,学习少,创新能力差,无法更新现有设计能力或水平。 评估关键点:持续学习和创新思维、受众分析与设计理念融合度、产品品质与用户体验提升度、好评率。 --- ##### 6.1.4.3 开发 ###### 1. 工作品质 - **15 分**,总是如期或提前完成工作,工作中无任何瑕疵; - **1-14 分**,Bugs 情况(数量、Bugs 重复激活数、连带 bugs)(8 分);工作结果综评(6 分); - Bugs 数量扣分标准:一类的 1 分、二类 0.5 分; - 当月一类 bugs 不能超过 7 个,二类不能超过 14 个,超出按 ① 规则扣除; - Bugs 重复激活数超过 3 次后,每出现一次扣 1 分; - 连带 bugs 情况由项目经理综合评估视具体情况扣除相应分值; - 当月无交付测试版本的,该项按工作结果由项目经理综合评估; - **0 分**,工作品质较低,出现重大瑕疵导致项目严重延期(3 天以上)无法交付版本; 考核点:工作结果总评(三类、四类 bugs 数量;连带 bugs 数量;无交付测试版本情况;其他情况) --- ###### 2. 工作完成效率 - **15 分**,工作效率非常高,能保质保量完成任务,绝大部分分内工作均提前完成,工作质量和效果超出预期,加快了协作进程,且反馈及时进度和结果;能对疑难点、功能点进行思考并对设计和开发提出创新或完善的建议或意见; - **13-14 分**,工作效率很高,能按时完成岗位职责工作,分内工作均能按时按要求完成,还可协助其他同事完成额外工作任务,保证正常的协作进程,能对疑难点、功能点进行提前的整理和提出实用的建议或意见。评估关键点(10 分为基础):按时(+0)、高效(+1)、协助(+1)、疑难问题建设性的建议(+1-2)。 - **10-12 分**,工作效率高,基本能按时完成岗位职责工作,分内工作能按基本要求完成,偶尔能对疑难点、功能点进行提前的整理和提出实用的建议或意见。评估关键点(10 分为基础):按时(+0)、分内工作仅按基本要求完成(+0)、使用的建议(+1-2)。 - **5-9 分**,工作效率一般,仅能按时完成职责工作,偶尔不能按时按要求完成分内工作,但不影响协作进程。评估关键点:不能按时完成工作部分,根据禅道任务每延期一项扣一分。 - **1-4 分**,工作效率低,需要别人帮助才能完成岗位职责工作,经常不能按时按要求完成分内工作,造成一定影响协作进程(视影响情况扣减评分);评估关键点:不能按时完成工作部分,根据禅道任务每延期一项扣一分。 - 0 分,工作效率极低,不能按照岗位职责完成工作,严重阻塞协作进程。如任务完成情况与实际评估工时不匹配,严重超时(超出预计工时 1/3 及以上者),则该项绩效为 0。 加分项:保质保量并提前完成工作,可根据实际酌情加 1-5 分。 --- ###### 3. 版本交付 - **15 分**,每一个里程碑事件或开发、测试或正式版本均能如期或提前交付; - **1-14 分**,在每一个里程碑事件或开发、测试或正式版本中,因任务未完成,导致无法发版,造成测试工作延期,每延期 0.5 天(不足按 0.5 天计)扣 2 分;禅道任务延期一项扣 1 分,有与其他人员协作任务(依赖关系),延期一项扣 2 分; - **0 分**,超过 2 天不能交付,按该项绩效为 0。 其他:如因其他团队导致无法按时发版,按责任级别扣除相应分值,责任分级如下:1、负主要责任的扣:90%-100%;2、负次要责任的扣:50%;3、其他团队接受连坐责任,扣:30%。 如因任务穿插,人员调配导致人力资源不足等特殊情况造成的延期,由技术中心管理小组综合评估另做考核。 --- ###### 4. 代码质量 - **5 分**,严格遵循编码规范,可读性强、可扩展性好,可维护性强,代码注释详细且有修改记录备注; - **4 分**,遵循编码规范,可读性较强、可扩展性较好、可维护性较强,代码注释较全; - **3 分**,代码规范化一般,可读性一般、可扩展性一般、可维护性一般,仅有关键部分代码注释; - **2 分**,编码不规范,可读性差、可扩展性差、可维护性差,仅有简单的代码注释; - **1 分**,编码毫无规范可言,可读性、可扩展性、可维护性较差,代码无注释; - **0 分**,代码无规范,毫无可读性、可扩展性、可维护性可言,代码无注释; --- ###### 5. 工作量 - **10 分**,工作饱和度 100%; - **1-9 分**,对应饱和度 50%-90%。根据 WBS 有效工时评估,每月有效工时按 144 时(18 个工作日)计。 - 0 分,工作量低于 50% 且不主动开展其他小组或部门其他任务者,或无任何工作任务,且不愿或不主动分担其他跟同事工作任务的情况。 说明:如在当期工作 100% 的饱和度情况下,仍能主动分担其他同事任务,并保质保量完成,可视实际情况酌情考虑加 1-5 分。 --- ###### 6. 文档规范性、及时性 包含: 重要的功能流程说明文档 系统框架或重要类库说明文档 重要的数据流说明文档 技术详细设计文档 其他重要的技术文档 - **5 分**,非常规范非常及时,随时都可以查阅任意相关文档;重要的功能模块、核心流程、重要的类库等有完整、详细的技术设计文档,重要的软件产品或系统服务的安装、配置或部署操作手册; 4 分,非常规范,较及时,文档编写滞后 2 天以内;有相关的技术设计文档; - **3 分**,规范化一般,文档滞后 3-5 天;有相关的、简要的技术设计文档或手册; - **2 分**,基本能按规范要求书写,常常难以查阅,文档滞后 6 天以上;缺失重要文档 2-3 项; - 1 分,需要上级领导指导才能完成书写,难以查阅,文档严重滞后或缺失; - **0 分**,不规范、不及时,常常难以查阅,甚至没有编写相关文档;或者三无:无技术设计文档、操作手册; --- ##### 6.1.4.4 测试 ###### 1. 需求熟悉程度 - **10 分**,需求理解无误,并能提出需求疑点甚至提出建议或意见; - **8-9 分**,完整理解需求,能根据需求准确定义用例; - **5-7 分**,理解需求,基本能根据需求定义出完整的用例; - **1-4 分**,在指导或多次讲解下能理解需求及流程,书写用例; - **0 分**,在指导下或多次讲解后仍不能准确理解需求,定义用例完整度较低。 --- ###### 2. 测试用例完成质量 - **10 分**,测试用例内容及步骤的详细度高、结构清晰,可执行性高,描述简洁明了,能准确描述测试预期结果,用例能很好归类定级,用例覆盖面全; - **8-9 分**,测试用例完整,内容及步骤描述清晰,可执行性高,有归类,重要逻辑基本覆盖; - **5-7 分**,测试用例内容及步骤描述简单,可执行性一般,用例覆盖面不周全; - 1-4,测试用例不完善,可执行性差; - **0 分**,测试用例不规范、不完善,无可执行性。 考核要点:内容及步骤的详细度、可执行度、是否能准确描述测试预期结果、归类与定级是否清晰、覆盖率。 --- ###### 3. BUG 描述质量 - **10 分**,BUG 描述规范清晰、简洁明了、图文并茂、注释清晰,能有效、准确的按步骤重现; - **8-9 分**,BUG 描述规范清晰、简洁明了、图文并茂,但截图或注释/描述说明不够清晰,能有效的按步骤重现; - **5-7 分**:BUG 描述一般,勉强能有效按步骤重现; - **1-4 分**:BUG 描述与实际有出入,通过沟通能重现; - **0**:BUG 描述混乱,不能理解。 --- ###### 4. 测试工作品质&版本交付 - **10 分**,总是如期或提前完成工作,工作中无任何瑕疵;每一个里程碑事件或开发、测试或正式版本均能如期或提前交付; 8-9 分,能近期完成工作,工作品质高,基本无瑕疵或存在有不影响正常使用的 bug(4 类); - **5-7 分**,基本能完成工作,偶尔有小瑕疵(4 类); - **1-4 分**,工作品质一般,偶尔有较大瑕疵(3 类); - **0 分**,工作品质较低,偶尔出现重大瑕疵(2 类); - **-10 分**,出现严重或致命的 bug 导致某个非核心功能模块无法正常运行(1 类或 2 类); 绩效为 0,出现严重或致命的 bug 导致某个或某几个核心功能无法正常运行。 版本交付考核点:在每一个里程碑事件或开发、测试或正式版本中,因任务未完成,导致无法发版,造成测试工作延期,每延期 0.5 天(不足按 0.5 天计)扣 2 分;禅道任务延期一项扣 1 分,有与其他人员协作任务,延期一项扣 2 分; 其他:如因其他团队导致无法按时发版,按责任级别扣除相应分值,责任分级如下:1、负主要责任的扣:90%-100%;2、负次要责任的扣:50%;3、其他团队接受连带责任,扣:30%。 如因任务穿插,人员调配导致人力资源不足等特殊情况造成的延期,由技术中心管理小组综合评估另做考核。 --- ###### 5. 工作完成效率 - **10 分**,工作效率很高,能保质保量完成任务,绝大部分分内工作均提前完成,工作质量和效果超出预期,加快了协作进程,且反馈及时; - **8-9 分**,工作效率高,能按时完成岗位职责工作,分内工作均能按时按要求完成,保证正常的协作进程; - **5-7 分**,工作效率一般,基本能按时完成职责工作,偶尔不能按时按要求完成分内工作,但不影响协作进程; - **1-4 分**,工作效率低,需要别帮助才能完成岗位职责工作,经常不能按时按要求完成分内工作,影响协作进程; - **0 分**,工作效率极低,不能按照岗位职责完成工作。 --- ###### 6. 进度更新,BUG 跟踪 - **5 分**,及时关注研发进度及 BUG 状态,有问题时能及时反映并能提出有效或建设性意见,推动测试进行;准时及提前完成测试任务; - **4 分**,能主动关注研发进度及更新 BUG 状态,能及时发现并反映问题,促进测试进行,能准时完成测试任务;; - **3 分**,经提醒后,能更新测试进度及 BUG 状态,基本如期完成测试任务上; - **1-2 分**,测试进度没有更新,发现已上线项目的 BUG 状态没有更新; - **0 分**,没有关注项目进度及 BUG 状态。 --- ###### 7. 工作量 - **10 分**,工作饱和度 100%; - **1-9 分**,对应饱和度 50%-90%。根据 WBS 有效工时评估,每月有效工时按 144 时(18 个工作日)计。 - **0 分**,工作量低于 50% 且不主动开展其他小组或部门其他任务者,或无任何工作任务,且不愿或不主动分担其他跟同事工作任务的情况。 说明:如在当期工作 100% 的饱和度情况下,仍能主动分担其他同事任务,并保质保量完成,可视实际情况酌情考虑加 1-5 分。 --- ###### 8. 测试用例评审报告、测试结论及报告质量 - **5 分**,测试用例评审报告、测试报告清晰明确并能及时发出,测试结果、覆盖率、通过率、重点问题等能准确、简单明了的表述并能在报告中采用合适的图表展现、体现; - **4 分**,测试用例评审报告、测试报告清晰明了并能及时发出,测试结果、覆盖率、通过率、重点问题等能在报告中采用合适的图表展现、体现; - **3 分**,测试用例评审报告、测试报告相对规范能及时发出,包含基本测试结论相关内容; - **1-2 分**,有测试用例评审报告、测试报告,但不规范; - **0 分**,无测试用例评审报告、测试报告 --- ##### 6.1.4.5 运维 ###### 1. 系统稳定性 (0-10 分) 确保系统正常运行时间达到预定目标,如 99.9% 或更高。可以通过系统故障的频率和持续时间来衡量。 ###### 2. 故障响应和处理 (0-10 分) 对系统故障的响应时间,以及解决问题的速度和效率。 ###### 3. 服务交付 (0-10 分) 按时完成运维相关的任务和服务请求,如服务器部署、配置变更等。 ###### 4. 变更管理 (0-10 分) 评估和实施系统变更,同时确保变更不会对现有服务造成负面影响。 ###### 5. 备份和恢复 (0-10 分) 定期执行数据备份,并能够在出现问题时快速恢复数据。 ###### 6. 安全性能 (0-10 分) 维护系统的安全性,防止数据泄露和未授权访问。可以通过安全事件的次数和安全漏洞的修复速度来衡量。 ###### 7. 性能优化 (0-10 分) 监控系统性能,及时识别和解决性能瓶颈,提高系统效率。 --- ##### 6.1.4.6 实施 ###### 1. 项目实施 (0-30 分) 按时完成项目任务和里程碑。 确保项目实施符合预定的技术规范和质量标准。 在预算和时间范围内完成项目实施工作。 ###### 2. 客户关系管理 (0-10 分) 建立和维护良好的客户关系。 确保客户满意度和忠诚度。 及时响应客户需求和反馈,提供优质的客户服务。 ###### 3. 项目文档和报告:(0-10 分) 准备和维护准确的项目文档和报告。 提供详细的项目状态报告和进展更新。 ###### 4. 风险管理:(0-10 分) 识别项目风险并制定应对策略。 监控风险,采取措施减少风险影响。 ###### 5. 合规性和安全性:(0-10 分) 确保项目实施符合相关的法规和标准。 关注并维护信息安全和数据保护。 --- ### 6.2 管理团队绩效考核 | 指标 | 权重 | 备注 | | -------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 工作业绩 | - | 1、如若管理人员还兼任(其他)部门或横向团队的开发、测试或产品设计等工作,那么工作业绩部分占比分数为 30%,管理分值占比 70%; 2、如若无兼任工作,则无工作业绩部分考核。 | | 目标与工作管理 | 50% | 管理团队特有 | | 团队管理 | 30% | 管理团队特有 | | 工作态度 | 20% | 岗位通用 | | 黑红事件 | - | 额外加减分项, 岗位通用 | 1. 管理人员兼任横向团队研发或设计任务: **总分 = `自评总分 [(工作业绩) * 30% + (目标与工作管理总分 + 团队管理总分 + 工作态度总分) * 70%] + 上级评分 [(工作业绩) * 30% + (目标与工作管理总分 + 团队管理总分 + 工作态度总分) * 70%] * 70%` + 黑红事件总分** 2. 纯管理: **总分 = `自评总分 (目标与工作管理总分 + 团队管理总分 + 工作态度总分) * 30% + 上级评分 (目标与工作管理总分 + 团队管理总分 + 工作态度总分) * 70% + 黑红事件总分`** --- #### 6.2.1 目标与工作管理 ##### 1. 计划、工作任务管理 (0-10 分) 能否对所分管或直管的部门工作有效的、合理的制定工作计划、分解分配工作任务,落实到每个人、(每月 6 日 23:00 前提交) 评估关键产出物:WBS & 进度管理、周、月工作计划 PPT 和表格 ##### 2. 工作进度管理 (0-10 分) 能否对所分管或直管的部门工作及时、有效的跟进工作进度,能及时的更新进度表,并发现和处理工作进度问题,及时有效的解决问题。 评价关键点:进度管理表格中,计划与结果匹配情况 ##### 3. 所分管或直管的部门工作目标完成情况 (0-30 分) - 能否对所分管或直管的部门工作进行监督与执行,定期巡检并督促各项工作的正常开展; - 是否如期交付,不打折扣、保质保量按时完成当月工作或任务; - 能否保证所负责或承担的工作,符合、达到或超出公司期望结果。 月度目标完成情况评分范围: - 低于 50%,则该项指标评分为 1-5 分; - > 50% & ≤60%,指标评分为 5-10 分; - > 60% & ≤70%,指标评分为 10-15 分; - > 70% & ≤80%,指标评分为 15-20 分; - > 80% & ≤90%,指标评分为 20-25 分; - ≥95%,指标评分为 25-30 分。 如若因公司通过评审会或公司领导根据当前形势对部门工作临时做指示或调整,导致部门无法按计划完成相关工作的,则根据具体事项及工作任务评估和考核。 #### 6.2.2 团队管理 ##### 1. 运行规范管理 (0-5 分) 产品的设计、开发及测试等关键环节或节点的规范化管理与执行,代码审查机制执行等 ##### 2. 执行力与工作态度、作风 (0-10 分) - 是否强执行力,积极主动、态度端正、主动承担工作和责任,作风硬朗,不官僚; - 是否贯彻落实公司管理政策及事项,是否迅速执行公司领导及分管领导安排的各项任务,及时响应、不拖沓、不懈怠; - 工作执行不力或因个人态度、作风等达不到预期结果者(如延期、敷衍了事、无法完成等),该项分值将低于 5 分。 ##### 3. 工作报告管理 (0-5 分) 能否按时的收集、整理并编写当周或当月的工作计划与工作完成情况。 以综合管理部统计为准。 ##### 4. 团队合作力 (0-5 分) 能团结团队成员,发掘团队成员潜力与优势能力,帮助团队成长,发挥团队精神、互补互助以达到团队最大工作效率的能力。 ##### 5. 部门内或部门间工作沟通、协调 (0-5 分) 能否及时、快速的响应部门内部或其他部门的业务需求或问题,能快速、高效高质的处理或解决问题 ## [基于树莓派的视频推流方案](https://blog.dong4j.site/posts/530746b7.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 因为项目上有很多设计到监控视频的工作, 所以想通过树莓派来研究一下流媒体相关的技术, 所以总结了一些在树莓派上实现视频推流的方案. 硬件: - 树莓派 5B: 8G 内存 - [RPi Camera V2](https://www.raspberrypi.com/products/camera-module-v2/): 800 万像素 - [IMX519](https://www.waveshare.net/shop/IMX519-78-16MP-AF-Camera.htm): 1600 万像素, 自动对焦 ![20250212192842_OAFqjO2i.webp](https://cdn.dong4j.site/source/image/20250212192842_OAFqjO2i.webp) ## 摄像头 一个摄像头使用了官方的 RPi Camera V2, 使用了索尼 IMX219 800 万像素传感器, 另一个使用了 [IMX519](https://www.waveshare.net/shop/IMX519-78-16MP-AF-Camera.htm), 具备 1600W 像素. RPi Camera V2 插上就能识别, 但是 IMX519 费了点功夫, 找到了几篇相关的讨论: - [Arducam 16MP AF camera on a Pi5 ? - Raspberry Pi Forums](https://forums.raspberrypi.com/viewtopic.php?t=360329) - [Arducam IMX519 not detected by Raspberry Pi 5](https://forums.raspberrypi.com/viewtopic.php?t=372176&sid=ad75eafdcf89f9fb413848edfa301c91) - [Setup IMX519 with any Raspberry Pi OS - Raspberry Pi Cameras - Arducam Camera Support Forum](https://forum.arducam.com/t/setup-imx519-with-any-raspberry-pi-os/2702/7) - [模板:IMX519 Driver Installation - Waveshare Wiki](https://www.waveshare.net/wiki/%E6%A8%A1%E6%9D%BF:IMX519_Driver_Installation) > ### 关于型号 > > | 感光芯片型号 | 支持的树莓派主板型号 | 支持的驱动类型 | > | :-----------------------------: | :------------------: | :-------------------: | > | OV5647 | 所有树莓派主板 | libcamera / Raspicam | > | OV9281 | 所有树莓派主板 | libcamera | > | IMX219 (树莓派官方) | 所有树莓派主板 | libcamera / Raspicam | > | IMX219 (第三方) | 树莓派计算模块 | libcamera | > | IMX290/ IMX327 | 所有树莓派主板 | libcamera | > | IMX378 | 所有树莓派主板 | libcamera | > | IMX477 (树莓派官方) | 所有树莓派主板 | libcamera / Raspicam | > | IMX477 (第三方) | 树莓派计算模块 | libcamera | > | IMX519 | 树莓派主板 | libcamera(另装驱动) | > | IMX708 (树莓派 Camera Module 3) | 所有树莓派主板 | libcamera | > | IMX296(树莓派 Global Camera) | 所有树莓派主板 | libcamera | > | IMX500(树莓派 AI Camera) | 所有树莓派主板 | libcamera | 下面是具体的实施步骤. ### 修改固件配置 ```bash sudo nano /boot/config.txt # 如果是 bookworm系统 sudo nano /boot/firmware/config.txt # 添加下面这段配置 camera_auto_detect=0 dtoverlay=imx219,cam0 dtoverlay=imx519,cam1 ``` ### 修改摄像头配置 从 [libcamera/src/ipa/rpi/pisp/data at arducam · ArduCAM/libcamera · GitHub](https://github.com/ArduCAM/libcamera/tree/arducam/src/ipa/rpi/pisp/data) 下载最新的 `imx519.json` 文件, 替换掉原来的配置: `/usr/share/libcamera/ipa/rpi/pisp/imx519.json`, 然后重启即可. ### 驱动检查 ```bash dmesg | grep imx [ 0.532486] platform 1f00110000.csi: Fixed dependency cycle(s) with /axi/pcie@120000/rp1/i2c@88000/imx219@10 [ 0.532607] platform 1f00128000.csi: Fixed dependency cycle(s) with /axi/pcie@120000/rp1/i2c@80000/imx519@1a [ 3.298575] imx519 4-001a: Device found is imx519 [ 3.311363] rp1-cfe 1f00110000.csi: found subdevice /axi/pcie@120000/rp1/i2c@88000/imx219@10 [ 3.312865] rp1-cfe 1f00128000.csi: found subdevice /axi/pcie@120000/rp1/i2c@80000/imx519@1a [ 3.312891] rp1-cfe 1f00128000.csi: Using sensor imx519 4-001a for capture [ 3.598311] rp1-cfe 1f00110000.csi: Using sensor imx219 6-0010 for capture ``` 可以看到 imx519 的驱动成功加载了. ### 检查摄像头 ```bash v4l2-ctl --list-devices pispbe (platform:1000880000.pisp_be): /dev/video20 /dev/video21 /dev/video22 /dev/video23 /dev/video24 /dev/video25 /dev/video26 /dev/video27 /dev/video28 /dev/video29 /dev/video30 /dev/video31 /dev/video32 /dev/video33 /dev/video34 /dev/video35 /dev/video36 /dev/video37 /dev/media2 /dev/media3 rp1-cfe (platform:1f00110000.csi): /dev/video8 /dev/video9 /dev/video10 /dev/video11 /dev/video12 /dev/video13 /dev/video14 /dev/video15 /dev/media0 rp1-cfe (platform:1f00128000.csi): /dev/video0 /dev/video1 /dev/video2 /dev/video3 /dev/video4 /dev/video5 /dev/video6 /dev/video7 /dev/media1 rpivid (platform:rpivid): /dev/video19 /dev/media4 ``` 主要关注 **rp1-cfe** 信息, 这个设备通常指的是连接到 Raspberry Pi 的 CSI (Camera Serial Interface) 设备, 可查看具体设备的详细信息: ```bash ➜ ~ v4l2-ctl -d /dev/video0 --all Driver Info: Driver name : rp1-cfe Card type : rp1-cfe Bus info : platform:1f00128000.csi Driver version : 6.6.31 Capabilities : 0xaca00001 Video Capture Metadata Capture Metadata Output Streaming Extended Pix Format Device Capabilities Device Caps : 0x24a00001 Video Capture Metadata Capture Streaming Extended Pix Format Media Driver Info: Driver name : rp1-cfe Model : rp1-cfe Serial : Bus info : platform:1f00128000.csi Media version : 6.6.31 Hardware revision: 0x00114666 (1132134) Driver version : 6.6.31 Interface Info: ID : 0x03000017 Type : V4L Video Entity Info: ID : 0x00000015 (21) Name : rp1-cfe-csi2_ch0 Function : V4L2 I/O Pad 0x01000016 : 0: Sink, Must Connect Link 0x02000037: from remote pad 0x1000006 of entity 'csi2' (Video Interface Bridge): Data Priority: 2 Video input : 0 (rp1-cfe-csi2_ch0: ok) Format Video Capture: Width/Height : 640/480 Pixel Format : 'pRAA' (10-bit Bayer RGRG/GBGB Packed) Field : None Bytes per Line : 800 Size Image : 384000 Colorspace : Raw Transfer Function : None YCbCr/HSV Encoding: ITU-R 601 Quantization : Full Range Flags : Format Metadata Capture: Sample Format : 'SENS' (Sensor Ancillary Metadata) Buffer Size : 16384 ``` ### 测试摄像头 自动对焦并拍摄照片 ```bash libcamera-still -t 10000 -n --autofocus-mode auto -o test1.jpg --camera 1 ``` ![20250212130816_rj2SNreE.webp](https://cdn.dong4j.site/source/image/20250212130816_rj2SNreE.webp) [Raspberry Pi Camera Autofocus: Complete Guide (V1, V2 & HQ)](https://www.arducam.com/raspberry-pi-camera/autofocus/) IMX219 直接使用 `rpicam-jpeg` 来拍摄一张图片: ```bash rpicam-jpeg -o test.jpg --camera 0 ``` ![20250212130730_Jods28xH.webp](https://cdn.dong4j.site/source/image/20250212130730_Jods28xH.webp) ## 通过 WebUI 控制摄像头 **[picamera2-WebUI](https://github.com/monkeymademe/picamera2-WebUI)** 是 Raspberry Pi 相机模块的轻量级 Web 界面,基于 Picamera2 Python 库并使用 Flask 构建。此项目提供了一个用户界面,用于配置相机设置、拍摄照片以及在基本图库中管理图像。 ### 部署 ```bash git clone git@github.com:monkeymademe/picamera2-WebUI.git cd picamera2-WebUI python app.py ``` 一切正常的话, 会在 8080 端口开启一个 Web 服务: ![20250212131525_cfMOZfTX.webp](https://cdn.dong4j.site/source/image/20250212131525_cfMOZfTX.webp) 2 个摄像头都成功识别到了. 还能够控制摄像头的分辨率: ![20250212131652_SJ6e76J2.webp](https://cdn.dong4j.site/source/image/20250212131652_SJ6e76J2.webp) ### 摄像头控制 ![20250212192857_K2bz1Poh.webp](https://cdn.dong4j.site/source/image/20250212192857_K2bz1Poh.webp) ### 查看视频流 {% videos, 2 %} {% video https://cdn.dong4j.site/source/image/stream1.mp4 %} {% video https://cdn.dong4j.site/source/image/stream2.mp4 %} {% endvideos %} 左边:**IMX219: 1080P** 右边: **IMX519 4K** ## 流媒体服务器 基于树莓派的流媒体服务器非常多, 下面 shi 一些常见的方案 ### 调研结果 1. mjpg-stream - web 可以直接访问,但是帧率太低,[参考](https://blog.csdn.net/m0_38106923/article/details/86562451) 2. vlc - 延迟高,大约 2s-5s 3. raspivid - 延迟 170ms 左右,并支持 h264 硬件编码,好像可以 [参考这里](https://blog.csdn.net/qq_39492932/article/details/84585152) 该工具已经默认集成到了树莓派之中 ``` raspivid -t 0 -w 1280 -h 720 -fps 20 -o - | nc -k -l 8090 ``` -t 表示延时;-o 表示輸出;-fps 表示帧率;端口号为 8090 -w 表示图像宽度;,-h 表示图像高度,此处设置的分辨率为 1280*720;我们可以修改 -w 1920 -h 1080 将分辨率设置为 1920*1080 该命令执行玩后不会出现任何打印信息即可 在局域网内的 linux 主机上安装 mplayer 工具(sudo apt-get install mplayer),然后执行命令 ``` mplayer -fps 200 -demuxer h264es ffmpeg://tcp://192.168.31.166:8090 ``` 即会弹出一个显示树莓派实时视频流的窗口,而且延迟尚可,大概在 200ms 左右,基本上可以满足实时性的要求了。 4. [pistreaming](https://github.com/waveform80/pistreaming) - 性能不错,延迟低 1. 安装依赖 ``` # 安装python3-picamera sudo apt-get install python3-picamera # 安装pip3 sudo apt-get install python3-pip # 安装ws4py sudo pip3 install ws4py # 安装ffmpeg sudo apt-get install ffmpeg ``` 2. 下载源码 ``` git clone https://github.com/waveform80/pistreaming.git ``` 3. 测试效果 ``` # 进入源码目录 cd pistreaming # 运行程序 python3 server.py ``` 浏览器访问查看效果 ``` http://pi_ip:8082/index.html ``` 5. [motion](https://motion-project.github.io/) - 卡顿很严重,延迟在 30s+ 6. [fswebcam](https://github.com/fsphil/fswebcam) - 采集摄像头数据保存为图片,用来做视频监控的话,性能和延迟都达不到要求 7. [Camkit](https://gitee.com/andyspider/Camkit) - 支持硬件编解码,比较小众,缺少维护 8. ffmpeg 硬解码推流 - 支持硬件编解码,但是延迟很高(不知道是推流原因还是播放原因),画质很差 流程比较复杂,整理成脚本保存在 ffmpeg 目录中 1. 安装 x264 硬件编码 install_x264.sh 2. 安装 ffmpeg install_ffmpeg.sh 3. 安装运行 nginx install_nginx.sh 4. 启动推流(注意 192.168.0.111 替换为你的 ip 地址) ``` /usr/local/bin/ffmpeg -ss 0 -pix_fmt yuv420p -i /dev/video0 -c:v h264_omx -f flv rtmp://192.168.0.111:1935/live/camera ``` 5. potplayer 播放 url rtmp://192.168.0.111:1935/live/camera 9. [webrtc](https://github.com/kclyu/rpi-webrtc-streamer) - 看视频貌似效果很好,工作量太大,后期验证 - [编译 webrtc for raspberry](https://antmedia.io/building-and-cross-compiling-webrtc-for-raspberry/) - [编译 webrtc for raspberry 2]() ### 最终方案 油管上找到一个 [测试树莓派 5 最佳直播方式的视频](https://youtu.be/rxtcyxV32nc), 对比了 WebRTC, TCP, UDP 和 RTSP 的延迟: ![20250212192857_p7dMvHQp.webp](https://cdn.dong4j.site/source/image/20250212192857_p7dMvHQp.webp) 看着 [MediaMTX](https://github.com/bluenviron/mediamtx) 延迟表现非常好, 决定直接使用它来作为媒体服务器使用, 另外一点是它内置了树莓派支持. ### 部署 MediaMTX ```bash wget https://github.com/bluenviron/mediamtx/releases/download/v1.9.3/mediamtx_v1.9.3_linux_arm64v8.tar.gz tar -zxvf mediamtx_v1.9.3_linux_arm64v8.tar.gz -C mediamtx_v1.9.3_linux_arm64v8 cd mediamtx_v1.9.3_linux_arm64v8 # 使用默认配置启动 ./mediamtx ``` ### 配置 MediaMTX #### 内置支持 MediaMTX 自带了树莓派摄像头支持, 配置如下 (mediamtx-rpiCamera.yml): ```yaml ... # 前面的配置未做任何修改 paths: cam0: # http://192.168.21.21:8889/cam0/ source: rpiCamera rpiCameraCamID: 0 rpiCameraWidth: 1920 rpiCameraHeight: 1080 rpiCameraHFlip: True rpiCameraVFlip: True cam1: # http://192.168.21.21:8889/cam1/ source: rpiCamera rpiCameraCamID: 1 rpiCameraWidth: 2560 rpiCameraHeight: 1440 rpiCameraHFlip: True rpiCameraVFlip: True rpiCameraBrightness: 0.0 rpiCameraContrast: 1 rpiCameraSaturation: 1 rpiCameraSharpness: 0 rpiCameraExposure: normal rpiCameraAWB: auto rpiCameraDenoise: "cdn_off" rpiCameraShutter: 0 rpiCameraMetering: centre rpiCameraGain: 0 rpiCameraEV: 0 rpiCameraHDR: false rpiCameraFPS: 30 rpiCameraIDRPeriod: 60 rpiCameraBitrate: 2000000 rpiCameraProfile: main rpiCameraLevel: "4.1" rpiCameraAfMode: continuous rpiCameraAfRange: normal rpiCameraAfSpeed: normal rpiCameraLensPosition: 0 ``` **启动方式:** ```bash # 导入 ldconfig, 它在 /usr/sbin 目录下, 不知道为啥找不到 export PATH=$PATH:/usr/sbin && nohup ./mediamtx mediamtx-rpiCamera.yml & ``` ![20250212135804_gljWVL4z.webp](https://cdn.dong4j.site/source/image/20250212135804_gljWVL4z.webp) #### RTMP 方式 **配置(mediamtx-rtmp.yml)** ```yaml paths: cam0: runOnInit: bash -c 'rpicam-vid --hflip --vflip -t 0 --camera 0 --nopreview --codec yuv420 --width 1920 --height 1080 --inline --listen -o - | ffmpeg -f rawvideo -pix_fmt yuv420p -s:v 1920x1080 -i /dev/stdin -c:v libx264 -preset ultrafast -tune zerolatency -f flv rtmp://192.168.21.7/live/pi5a-camera0' runOnInitRestart: yes cam1: runOnInit: bash -c 'rpicam-vid --hflip --vflip -t 0 --autofocus-mode auto --camera 1 --nopreview --codec yuv420 --width 2540 --height 1440 --inline --listen -o - | ffmpeg -f rawvideo -pix_fmt yuv420p -s:v 2560x1440 -i /dev/stdin -c:v libx264 -preset ultrafast -tune zerolatency -f flv rtmp://192.168.21.7/live/pi5a-camera1' runOnInitRestart: yes ``` 将视频流推送到 `192.168.21.7` 服务器, 这个是使用 WVP 搭建的另一个流媒体服务器: **启动** ```bash nohup ./mediamtx mediamtx-rtmp.yml & ``` ```bash ... Output #0, rtsp, to 'rtsp://localhost:8554/cam0': Metadata: encoder : Lavf59.27.100 Stream #0:0: Video: h264, yuv420p, 1920x1080, q=2-31, 25 fps, 90k tbn Metadata: encoder : Lavc59.37.100 libx264 Side data: cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A frame= 0 fps=0.0 q=0.0 Lsize=N/A time=00:00:00.00 bitrate=N/A speed= 0x video:0kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: unknown frame= 1863 fps= 18 q=18.0 size=N/A time=00:01:14.48 bitrate=N/A speed=0.721x ``` ![20250212142052_f8wpDOW9.webp](https://cdn.dong4j.site/source/image/20250212142052_f8wpDOW9.webp) RTMP 明显要比 WebRTC 延迟高, 基本上在 4 秒以上. #### RTSP 方式 **配置(mediamtx-rtsp.yml)** ```yaml paths: cam0: runOnInit: bash -c 'rpicam-vid --hflip --vflip -t 0 --camera 0 --nopreview --codec yuv420 --width 1920 --height 1080 --inline --listen -o - | ffmpeg -f rawvideo -pix_fmt yuv420p -s:v 1920x1080 -i /dev/stdin -c:v libx264 -preset ultrafast -tune zerolatency -f rtsp rtsp://localhost:$RTSP_PORT/$MTX_PATH' runOnInitRestart: yes cam1: runOnInit: bash -c 'rpicam-vid --hflip --vflip -t 0 --camera 1 --nopreview --codec yuv420 --width 2560 --height 1440 --inline --listen -o - | ffmpeg -f rawvideo -pix_fmt yuv420p -s:v 2560x1440 -i /dev/stdin -c:v libx264 -preset ultrafast -tune zerolatency -f rtsp rtsp://localhost:$RTSP_PORT/$MTX_PATH' runOnInitRestart: yes ``` **启动** ```bash nohup ./mediamtx mediamtx-rtsp.yml & ``` ```bash Output #0, rtsp, to 'rtsp://localhost:8554/cam1': Metadata: encoder : Lavf59.27.100 Stream #0:0: Video: h264, yuv420p(progressive), 2560x1440, q=2-31, 25 fps, 90k tbn Metadata: encoder : Lavc59.37.100 libx264 Side data: cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A frame= 1245 fps= 18 q=19.0 size=N/A time=00:00:49.76 bitrate=N/A speed=0.72x ``` 使用 WVP 的拉流代理: ![20250212142815_ndsfrSEN.webp](https://cdn.dong4j.site/source/image/20250212142815_ndsfrSEN.webp) 延迟有高出一大截, 毕竟是经过 2 个流媒体服务器多次转码的: ![20250212142639_WjSecgaP.webp](https://cdn.dong4j.site/source/image/20250212142639_WjSecgaP.webp) ### 延迟对比 ![20250212192910_CbVi4Iol.webp](https://cdn.dong4j.site/source/image/20250212192910_CbVi4Iol.webp) ### 启动脚本 为了方便测试, 写了一个启动脚本, 支持多种协议以及分辨率: ```bash #!/bin/bash # 检查是否有 -h 参数 if [[ "$1" == "-h" ]]; then echo "使用方法: $0 [协议] [摄像头编号] [宽度] [高度] [URL地址]" echo "示例: $0 rtmp 0 1920 1080 192.168.21.7/pi5b, 最终URL: rtmp://192.168.21.7/pi5b/0" echo echo "参数说明:" echo " 协议 : rtmp 或 rtsp (用于选择流媒体传输协议)" echo " 摄像头编号 : 摄像头编号 (例如 0 或 1)" echo " 宽度 : 分辨率宽度 (例如 1920)" echo " 高度 : 分辨率高度 (例如 1080)" echo " URL地址 : 基础的 URL 地址 (例如 192.168.21.7/pi5b)" echo echo "RTMP 推流说明:" echo " 192.168.21.7:1935/pi5b --> rtmp://192.168.21.7:1935/pi5b/0 推流至 m920x 的 zlm 服务, 默认端口 1935, 会出现在 WVP 的推流列表中" echo " 192.168.21.7:41935/pi5b --> rtmp://192.168.21.7:41935/pi5b/0 推流至 m920x 的 mediamtx 服务, 使用 mediamtx 服务的 WebRTC 访问: http://192.168.21.7:48889/pi5b/{CAMERA}" echo " 127.0.0.1:1935/pi5b --> 本地推流至 mediamtx 服务, 使用 WebRTC 访问: http://ip:8889/pi5b/{CAMERA}" echo " ====================================================" echo " ./stream.sh rtmp 0 1920 1080 192.168.21.7/pi5a" echo " ./stream.sh rtmp 0 2560 1440 192.168.21.7:41935/pi5a" echo " ./stream.sh rtmp 0 3840 2160 192.168.21.7/pi5a" echo " ./stream.sh rtmp 0 3840 2160 127.0.0.1/pi5a" echo echo "RTSP 推流说明:" echo " 192.168.21.7:554/pi5b --> rtsp://192.168.21.7:554/pi5a/0 推流至 m920x 的 zlm 服务, 默认端口 554, 会出现在 WVP 的推流列表中" echo " 192.168.21.7:48554/pi5b --> rtsp://192.168.21.7:48554/pi5b/0 推流至 m920x 的 mediamtx 服务, 使用 WebRTC 访问: http://192.168.21.7:48889/pi5b/{CAMERA}" echo " 127.0.0.1:8554/pi5b --> 本地推流至 mediamtx 服务, 使用 WebRTC 访问: http://ip:8889/pi5b/{CAMERA}" echo " ====================================================" echo " ./stream.sh rtsp 1 1920 1080 192.168.21.7/pi5b" echo " ./stream.sh rtsp 1 2560 1440 192.168.21.7:48554/pi5a" echo " ./stream.sh rtsp 1 3840 2160 192.168.21.7:8554/pi5a" echo " ./stream.sh rtsp 1 3840 2160 127.0.0.1:8554/pi5a" exit 0 fi # 参数赋值 PROTOCOL=$1 # 第一个参数为协议 (rtmp 或 rtsp) CAMERA=$2 # 第二个参数为摄像头编号 WIDTH=$3 # 第三个参数为宽度 1920x1080 2560x1440 3840x2160 HEIGHT=$4 # 第四个参数为高度 URL=$5 # 第五个参数为基础的 URL 地址 # 根据协议动态设置输出流地址和格式 if [ "$PROTOCOL" == "rtmp" ]; then OUTPUT_URL="rtmp://${URL}/${CAMERA}" FFMPEG_FORMAT="flv" elif [ "$PROTOCOL" == "rtsp" ]; then OUTPUT_URL="rtsp://${URL}/${CAMERA}" FFMPEG_FORMAT="rtsp" else echo "不支持的协议: $PROTOCOL" exit 1 fi # 运行命令 nohup bash -c "rpicam-vid --hflip --vflip -t 0 --camera $CAMERA --nopreview --codec yuv420 --width $WIDTH --height $HEIGHT --inline --listen -o - | ffmpeg -f rawvideo -pix_fmt yuv420p -s:v ${WIDTH}x${HEIGHT} -i /dev/stdin -c:v libx264 -preset ultrafast -tune zerolatency -f $FFMPEG_FORMAT $OUTPUT_URL" > ${PROTOCOL}-cam${CAMERA}.log 2>&1 & ``` --- ## 相关资源 - [Raspberry Pi 树莓派中文文档](https://github.com/raspberrypi/documentation) - [How to Control your Raspberry Pi Camera using a web UI | Tom's Hardware](https://www.tomshardware.com/raspberry-pi/how-to-control-your-raspberry-pi-camera-using-a-web-ui) - [MediaMTX](https://github.com/bluenviron/mediamtx) - [Raspberry Pi 5 Video Capture: Camera Module V3 Video Stream Latencies. Comparing UDP, TCP, RTSP, and WebRTC : 8 Steps - Instructables](https://www.instructables.com/Comparing-Raspberry-Pi-5-Camera-Module-V3-Video-St/) - [摄像头软件](https://rpicn.bsdcn.org/shu-mei-pai/she-xiang-tou-ruan-jian) - [RPi Camera V2 - Waveshare Wiki](https://www.waveshare.net/wiki/RPi_Camera_V2) - [RPi Camera (G) - Waveshare Wiki]() - [RPi Camera (F) - Waveshare Wiki]() - [Pi5-IMX219 - Waveshare Wiki](https://www.waveshare.net/wiki/Pi5-IMX219) - [树莓派 4B 安装摄像头教程-树莓派 4B 配置 CSI 摄像头](https://wangliguang.cn/?p=630) - [树莓派自动化推流摄像头到 Bilibili 直播 - 夸克之书](https://www.quarkbook.com/?p=733) - [利用树莓派将视频流推送到服务器 - ThingsPanel 讨论区](https://forum.thingspanel.cn/d/37) - [机器之眼:树莓派摄像头 | Yam](https://yam.gift/2021/07/03/Raspberrypi/2021-07-03-RaspberryPi-Camera/) - [聊聊树莓派 RaspiCam,libcamera,rpicam 套件区别,及 CSI/USB Camera 推流-CSDN 博客](https://blog.csdn.net/techfuture/article/details/136909088) - [基于 Raspberry 的 libcamera 使用 - 小淼博客 - 博客园](https://www.cnblogs.com/uestc-mm/p/17442508.html) - [Incompatibility with libcamera 0.1.0 and Raspberry Pi 5 · Issue #2581 · bluenviron/mediamtx · GitHub](https://github.com/bluenviron/mediamtx/issues/2581) - [Raspberry Pi 5 Video Stream Latencies: Comparing UDP, TCP, RTSP, and WebRTC | by Ievgenii Tkachenko | Medium](https://gektor650.medium.com/comparing-video-stream-latencies-raspberry-pi-5-camera-v3-a8d5dad2f67b) - [树莓派-显示屏和摄像头驱动本文是关于 raspberry pi 的显示屏和摄像头驱动记录,主要记录整体流程和过程中的一些坑 - 掘金](https://juejin.cn/post/7110540919065018404) - [ArduCam B0389 Mini 16MP IMX519 摄像头模块用户指南](https://manuals.plus/zh-CN/arducam/b0389-mini-16mp-imx519-camera-module-manual) - [GitHub - jacksonliam/mjpg-streamer: Fork of http://sourceforge.net/projects/mjpg-streamer/](https://github.com/jacksonliam/mjpg-streamer) - [GitHub - meinside/rpi-mjpg-streamer: Instructions and helper scripts for running mjpg-streamer on Raspberry Pi](https://github.com/meinside/rpi-mjpg-streamer) ## 高阶玩法 - [给山寨 IMX290 摄像头模组刷入 OpenIPC 并接入 HomeKit](https://akbwe.com/posts/openipc-and-homekit/) - [OpenIPC 树莓派 4B 地面站搭建指南 - 哔哩哔哩](https://www.bilibili.com/read/cv28142010/) - [推流记录 - LX2020 - 博客园](https://www.cnblogs.com/lx2035/p/17871406.html) - [Openipc fpv 开源高清图传 复用监控成品硬件清单 - 哔哩哔哩](https://www.bilibili.com/read/cv27762124/?jump_opus=1) - [开源 PHFC 树莓派 4G 图传 usb4G 网卡推流 移远 EC20 rtsp rtmp 推流\_哔哩哔哩\_bilibili](https://www.bilibili.com/video/BV1GL4y1M7WG/?vd_source=285e3ed2bb5db5afb00b4aab045d1b4b) - [Introduction - OpenIPC](https://openipc.org/) - [Fetching Title#r20r](https://arduino.me/a/2966) ## V4L2 ``` $ sudo apt-get install v4l-utils $ v4l2-ctl --list-devices bcm2835-codec-decode (platform:bcm2835-codec): /dev/video10 /dev/video11 /dev/video12 /dev/media1 bcm2835-isp (platform:bcm2835-isp): /dev/video13 /dev/video14 /dev/video15 /dev/video16 /dev/media0 mmal service 16.1 (platform:bcm2835-v4l2): /dev/video0 ``` ### 驱动程序 V4L2 驱动程序提供用于访问摄像头和编解码器功能的标准 Linux 接口。通常,Linux 会在启动期间自动加载驱动程序。但在某些情况下,可能需要 [明确加载摄像头驱动程序](https://github.com/raspberrypi/documentation/blob/develop/documentation/asciidoc/computers/camera/camera_software.adoc#configuration)。 ### 使用时的设备节点 `libcamera` | /dev/videoX | 默认操作 | | ----------- | --------------------------------------------------------------------------------------- | | `video0` | 第一个 CSI-2 接收器的 Unicam 驱动程序 | | `video1` | 用于第二个 CSI-2 接收器的 Unicam 驱动程序 | | `video10` | 视频解码 | | `video11` | 视频编码 | | `video12` | 简单的 ISP,除了 Bayer 到 RGB/YUV 的转换外,还可以执行 RGB/YUV 格式之间的转换和调整大小 | | `video13` | 输入至完全可编程 ISP | | `video14` | 完全可编程 ISP 的高分辨率输出 | | `video15` | 完全可编程 ISP 的结果输出较低 | | `video16` | 来自完全可编程 ISP 的图像统计数据 | | `video19` | HEVC 解码 | ### 使用 V4L2 驱动程序 参阅 [V4L2 文档](https://www.kernel.org/doc/html/latest/userspace-api/media/v4l/v4l2.html)。 ## [树莓派直播新选择:打造你的个人流媒体服务器](https://blog.dong4j.site/posts/5e9872c3.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 1. 背景 在 [[pi-dafang-monitor|树莓派 + 大方摄像头 打造婴儿监控]] 中简单介绍了通过刷大方摄像头第三方固件来解锁更多功能, 比如 RTSP, MQTT 等, 这里我们就使用 RTSP 结合树莓派来打造一个直播间. ## 2. 直播架构 复杂的架构图: ![20241229154732_uyt57iIf.webp](https://cdn.dong4j.site/source/image/20241229154732_uyt57iIf.webp) 简单的架构图: ![zhibo.drawio.svg](https://cdn.dong4j.site/source/image/zhibo.drawio.svg) ### 2.1 推流工具 - ffmpeg:https://www.ffmpeg.org/download.html - OBS studio:https://obsproject.com/download ### 2.2 拉流工具 - ffplay 播放器: https://www.ffmpeg.org/download.html 。 - cutv www.cutv.com/demo/live_test.swf flash 播放器。 - vlc 播放器。 - ijkplayer (基于 ffplay): 一个基于 FFmpeg 的开源 Android/iOS 视频播放器。开源,API 易于集成;编译配置可裁剪,方便控制安装包大小;支持硬件加速解码,更加省电简单易用,指定拉流 URL,自动解码播放.。 ### 2.3 流媒体服务器 - SRS :一款国人开发的优秀开源流媒体服务器系统。普通 C++ 语法,底层使用协程编写。 - BMS :也是一款流媒体服务器系统,但不开源,是 SRS 的商业版,比 SRS 功能更多。作者也是目前 SRS 的作者,这个是目前 SRS 作者在上一家公司 (guanzhiyun) 的产物。 - nginx :免费开源 web 服务器,也常用来配置流媒体服务器,集成 Rtmp_module 即可。C 语言编写。 - zlmediakit:开源,国人开发的优秀流媒体服务器,使用 C++11 多线程编写。 - Red5:是 java 写的一款稳定的开源的 rtmp 服务器。 ## 3. 环境搭建 ![aaa.drawio.svg](https://cdn.dong4j.site/source/image/aaa.drawio.svg) ### 3.1 推流工具 直接使用 大方摄像头的 RTSP 协议推流, 地址为: `rtsp://192.168.31.128:8554/unicast` 使用 VLC 验证一下: ![20241229154732_jh1kKfzc.webp](https://cdn.dong4j.site/source/image/20241229154732_jh1kKfzc.webp) 完全没问题, 内网播放非常流畅. ### 3.2 流媒体服务器 #### 3.2.1 ZLMediaKit https://docs.zlmediakit.com/zh/ ![20241229154732_3iL0oAds.webp](https://cdn.dong4j.site/source/image/20241229154732_3iL0oAds.webp) **ZLMediaKit** 是一套高性能的流媒体服务框架,目前支持 rtmp、rtsp、hls、http-flv 等流媒体协议,支持 linux、macos、windows 三大 PC 平台和 ios、android 两大移动端平台。 **主要功能:** 1. 基于 C++11 开发,避免使用裸指针,代码稳定可靠,性能优越。 2. 支持多种协议(RTSP/RTMP/HLS/HTTP-FLV/WebSocket-FLV/GB28181/HTTP-TS/WebSocket-TS/HTTP-fMP4/WebSocket-fMP4/MP4),支持协议互转。 3. 使用多路复用/多线程/异步网络 IO 模式开发,并发性能优越,支持海量客户端连接。 4. 代码经过长期大量的稳定性、性能测试,已经在线上商用验证已久。 5. 支持 linux、macos、ios、android、windows 全平台。 6. 支持画面秒开、极低延时(500 毫秒内,最低可达 100 毫秒)。 7. 提供完善的标准 C API,可以作 SDK 用,或供其他语言调用。 8. 提供完整的 MediaServer 服务器,可以免开发直接部署为商用服务器。 9. 提供完善的 restful api 以及 web hook,支持丰富的业务逻辑。 10. 打通了视频监控协议栈与直播协议栈,对 RTSP/RTMP 支持都很完善。 11. 全面支持 H265/H264/AAC/G711/OPUS。 > 总结: 同时支持 rtsp/rtmp 推拉流,而且支持 h265 的推拉流(推流端要支持 265 的 ffmpeg/拉流播放端也要支持 265 的播放器),支持各种格式拉流,使用者众多 #### 3.2.2 Monibuca https://m7s.live/#ui ![20241229154732_x3jiipX8.webp](https://cdn.dong4j.site/source/image/20241229154732_x3jiipX8.webp) Monibuca 是一个开源的流媒体服务器开发框架,适用于快速定制化开发流媒体服务器,可以对接 CDN 厂商,作为回源服务器,也可以自己搭建集群部署环境。 丰富的内置插件提供了流媒体服务器的常见功能,例如 rtmp server、http-flv、视频录制、QoS 等。除此以外还内置了后台 web 界面,方便观察服务器运行的状态。 也可以自己开发后台管理界面,通过 api 方式获取服务器的运行信息。 Monibuca 提供了可供定制化开发的插件机制,可以任意扩展其功能。 ![20241229154732_uYMVXrO1.webp](https://cdn.dong4j.site/source/image/20241229154732_uYMVXrO1.webp) **主要功能:** 1. 针对流媒体服务器独特的性质进行的优化,充分利用 Golang 的 goroutine 的性质对大量的连接的读写进行合理的分配计算资源,以及尽可能的减少内存 Copy 操作。使用对象池减少 Golang 的 GC 时间。 2. 专为二次开发而设计,基于 Golang 语言,开发效率更高;独创的插件机制,可以方便用户定制个性化的功能组合,更高效率的利用服务器资源。 3. 功能强大的仪表盘可以直观的看到服务器运行的状态、消耗的资源、以及其他统计信息。用户可以利用控制台对服务器进行配置和控制。点击右上角菜单栏里面的演示,可以看到演示控制台界面。 4. 纯 Go 编写,不依赖 cgo,不依赖 FFMpeg 或者其他运行时,部署极其方便,对服务器的要求极为宽松。 > 总结: 支持自定义插件, 扩展新功能非常方便, 缺点就是部分插件收费. #### 3.2.3 SRS https://ossrs.net/lts/zh-cn/ ![20241229154732_Ihggdcre.webp](https://cdn.dong4j.site/source/image/20241229154732_Ihggdcre.webp) SRS(Simple Realtime Server)是一个简单高效的实时视频服务器,支持 RTMP、WebRTC、HLS、HTTP-FLV、SRT 等多种实时流媒体协议。 SRS 作为当前非常普遍的运营级解决方案,具备非常全面的功能,包括集群、协议网关、CDN 功能等,主要功能如下: 1. SRS 定位是运营级的互联网直播服务器集群,追求更好的概念完整性和最简单实现的代码。 2. SRS 提供了丰富的接入方案将 RTMP 流接入 SRS, 包括推送 RTMP 到 SRS、推送 RTSP/UDP/FLV 到 SRS、拉取流到 SRS。 SRS 还支持将接入的 RTMP 流进行各种变换,譬如将 RTMP 流转码、流截图、 转发给其他服务器、转封装成 HTTP-FLV 流、转封装成 HLS、 转封装成 HDS、转封装成 DASH、录制成 FLV/MP4。 3. SRS 包含支大规模集群如 CDN 业务的关键特性, 譬如 RTMP 多级集群、源站集群、VHOST 虚拟服务器 、 无中断服务 Reload、HTTP-FLV 集群。 4. SRS 还提供丰富的应用接口, 包括 HTTP 回调、安全策略 Security、HTTP API 接口、 RTMP 测速。 5. SRS 在源站和 CDN 集群中都得到了广泛的应用 Applications。 > 总结: 支持 rtmp 推流,早期版本支持 rtsp 推流,不知道为何移除了。支持部分格式拉流,不支持 ws-flv 拉流,使用者众多 #### 3.2.4 EasyDarwin https://www.easydarwin.org/ ![20241229154732_Gu71mfT9.webp](https://cdn.dong4j.site/source/image/20241229154732_Gu71mfT9.webp) **EasyDarwin** 是由国内开源流媒体团队维护和迭代的一整套开源流媒体视频平台框架,Golang 开发,从 2012 年 12 月创建并发展至今,包含有单点服务的开源流媒体服务器,和扩展后的流媒体云平台架构的开源框架,开辟了诸多的优质开源项目,能更好地帮助广大流媒体开发者和创业型企业快速构建流媒体服务平台,更快、更简单地实现最新的移动互联网(安卓、iOS、H5、微信)流媒体直播与点播的需求,尤其是安防行业与互联网行业的衔接。 **主要功能:** 1. 基于 Golang 语言开发维护。 2. 支持 Windows、Linux、macOS 三大系统平台部署。 3. 支持 RTSP 推流分发(推模式转发)。 4. 支持 RTSP 拉流分发(拉模式转发)。 5. 服务端录像、检索、回放。 6. 支持关键帧缓存、秒开画面。 7. Web 后台管理。 8. 分布式负载均衡。 > 总结: 只支持 rtsp 推拉流,默认端口 5541,不支持其他格式拉流,如果仅仅是监控摄像头使用,非常方便,有个网页管理后台,不会过期可以一直用,缺点是功能单一,只能在他的后台查看视频流,或者用播放器播放. **上述 4 种流媒体服务对比:** ![20241229154732_kgiruT3T.webp](https://cdn.dong4j.site/source/image/20241229154732_kgiruT3T.webp) #### 3.2.5 MediaMTX https://github.com/bluenviron/mediamtx _MediaMTX_(以前称为*rtsp-simple-server*)是一个即用型、零依赖的实时媒体服务器和媒体代理,允许发布、读取、代理和记录视频和音频流。它被设想为一种“媒体路由器”,将媒体流从一端路由到另一端。 **主要功能:** - 将直播流发布到服务器 - 从服务器读取直播流 - 流自动从一种协议转换为另一种协议 - 在不同的路径中同时提供多个流 - 将流记录到磁盘 - 验证用户身份;使用内部或外部身份验证 - 将读取器重定向到其他 RTSP 服务器(负载平衡) - 通过 API 查询和控制服务器 - 在不断开现有客户端连接的情况下重新加载配置(热重载) - 读取 Prometheus 兼容的指标 - 当客户端连接、断开连接、读取或发布流时运行挂钩(外部命令) - 与 Linux、Windows 和 macOS 兼容,不需要任何依赖项或解释器,它是单个可执行文件 > 总结: 同时支持 rtsp/rtmp 推拉流,拉流还支持 hls/webrtc 两种方式,最新版本还支持了 srt 方式。推出来的 hls/webrtc 可以直接嵌入个 iframe 网页播放,没有任何依赖,如果希望直接在网页中播放无依赖,强烈推荐用 mediamtx #### 3.2.6 Nginx-rtmp-module https://github.com/arut/nginx-rtmp-module Nginx 本身是一个非常出色的 HTTP 服务器,FFMPEG 是非常好的音视频解决方案.这两个东西通过一个 nginx 的模块 nginx-rtmp-module,组合在一起即可以搭建一个功能相对比较完善的流媒体服务器. 这个流媒体服务器可以支持 RTMP 和 HLS(Live Http Stream)。 **主要特性:** 1. 支持 RTMP、HLS、MPEG-DASH 2. 支持 RTMP、HLS 点播 3. 可将直播视频分段存储 4. 支持 H.264 视频编解码、AAC 音频编解码 5. 支持 FFmpeg 命令内嵌 6. 支持回调 HTTP 7. 可使用 HTTP 对直播进行删除、录播等控制 8. 具有强大的缓冲功能,可确保在效率与码率间达到平衡 9. 支持多种操作系统(Linux、MacOS、Windows) > 总结: 只支持 rtmp 推拉流,默认端口 1935,不支持其他格式拉流,功能极其单一,不推荐。 流媒体服务器直接选择了相对比较熟悉的 **ZLMediaKit**, 能够比较方便的将 RTSP 协议转换成其他各种协议. ### 3.3 ZLMediaKit 部署 使用 docker 一键安装部署: ```yaml version: "3.3" services: app: image: "zlmediakit/zlmediakit:master" container_name: zlmediakit restart: unless-stopped environment: - MODE=standalone - TZ=Asia/Shanghai ports: - "1935:1935" - "8080:80" - "7443:443" - "8554:554" - "10000:10000" - "10000:10000/udp" - "8000:8000/udp" - "9000:9000/udp" volumes: - ./data/media/bin:/opt/media/bin - ./data/media/conf:/opt/media/conf ``` ![20241229154732_WXm0i7BP.webp](https://cdn.dong4j.site/source/image/20241229154732_WXm0i7BP.webp) 有上面的 WEB 管理端是使用了 [此项目](https://github.com/1002victor/zlm_webassist), 直接放在 zlm 的 www 根目录下即可. #### 3.3.1 按需拉流 使用 API 将 RTSP 流注册到 ZLM: ```bash curl 'http://192.168.31.11:8080/index/api/addStreamProxy?vhost=192.168.31.11&secret=AmcwaLke5ELUbJCgO46M47MR4qPiefqt&app=live1&stream=test1&url=rtsp://192.168.31.128:8554/unicast' { "code" : 0, "data" : { "key" : "192.168.31.11/live1/test1" } } ``` 然后根据 ZLM 的 [URL 播放地址说明](https://github.com/ZLMediaKit/ZLMediaKit/wiki/%E6%92%AD%E6%94%BEurl%E8%A7%84%E5%88%99), 我们选择使用 mp4 在浏览器进行播放: `http://192.168.31.11:8080/live1/test1.live.mp4` ## [宝宝动一下就知道:如何用树莓派和小米大方摄像头构建智能监控](https://blog.dong4j.site/posts/8dc39439.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 1. 背景 6 月儿子诞生, 又有折腾的接口了. 儿子睡着后会放到主卧, 老婆在客厅, 我在书房, 为了监控儿子的睡眠状态, 开始折腾家里的闲置设备. ## 2. 目标 1. 使用小米的大方摄像头捕获儿子的实时视频, 如果儿子动了就报警; 2. 使用树莓派搭建 MQ 服务器, 将大方的告警推送到手机上; ## 3. 整体架构 ![111.drawio.svg](https://cdn.dong4j.site/source/image/111.drawio.svg) 1. 大方摄像头监控儿子, 如果监测到动作, 这通过自带的 MQ client 发送动作告警; 2. 树莓派监听特定 topic, 如果符合预设值则通过 bark 发送动作告警; 3. 妈妈收到 bark 消息, 前去查看儿子是否需要宝宝; **完美的闭环** ## 4. 实施步骤 ### 4.1 大方摄像头刷固件 小米的大方摄像头官方固件不支持自定义 MQ, 感谢大佬开源了第三方固件, 感谢开源. [大方色摄像头第三方固件](https://github.com/EliasKotlyar/Xiaomi-Dafang-Hacks) 支持大方摄像头, 小方摄像头等多款摄像头, 提供了 RTSP, MQ, Telegram 等集成服务. 有了 RTSP 服务后, 可以接入 HomeKit 和 Home Assistant. 刷固件的教程官方写的非常详细, 这里提醒一点: > 10.你应该看到蓝色 LED 闪耀 5 秒钟(不闪烁)并开始移动(DaFang / Wyzecam Pan)。如果没有,出了点问题。您应该尝试使用另一张 microSD 卡,然后查看页面底部的社区提示。从第 1 步开始。 固件刷完重启后, 不一定能看到蓝灯, 这时可以不管官方的第 10 步, 直接按照后续步骤操作即可, 如果还是不行, 看看自己的官方固件版本是否太低, 最好更新官方固件后再刷. 固件刷新成功后, 使用 `https://ip` 登录 WEB 管理端, **IP** 可以通过路由器获取: ![20241229154732_gaUq7p9v.webp](https://cdn.dong4j.site/source/image/20241229154732_gaUq7p9v.webp) ### 4.2 设置 MQ Client ![20241229154732_9qu31MxZ.webp](https://cdn.dong4j.site/source/image/20241229154732_9qu31MxZ.webp) 我的 MQ Server 将会安装到 **192.168.31.11** 这台服务器上(树莓派), 端口为默认的 **1883**, 局域网就不进行鉴权了. 大方摄像头上的 MQ 配置文件路径为: `/system/sdcard/config/mqtt.conf` ``` export LD_LIBRARY_PATH='/thirdlib:/system/lib:/system/sdcard/lib' # Options for mosquitto_sub & mosquitto_pub USER= PASS= HOST=192.168.31.11 PORT=1883 # Define a location LOCATION="ihome" # Define device name DEVICE_NAME="dafang" # Define the base topic used by the camera # send a message to myhome/dafang/set with the payload help for help. # Results will be placed in myhome/dafang/${command} or topic/dafang/error - so please subscribe topic/dafang/# for testing purposes # 从这里我们可以知道最终的 topic 是 ihome/dafang TOPIC="$LOCATION/$DEVICE_NAME" # Define an autodiscovery prefix, if autodiscovery is desired: # AUTODISCOVERY_PREFIX="homeassistant" # Define additional options for Mosquitto here. # For example --cafile /system/sdcard/config/DST_Root_CA_X3.pem --tls-version tlsv1 # or use a special id to connect to brokers like azure MOSQUITTOOPTS="" # Add options for mosquitto_pub like -r for retaining messages MOSQUITTOPUBOPTS="" # Send a mqtt statusupdate every n seconds STATUSINTERVAL=30 # Define whether you would like to have light level exposed (by hardware sensor og virtual calcuations). # If the device doesn't come with a hardware sensor, your only option is to use 'virtual'. # # Options: # hw = Use 'hw' if the device has build in hardware light sensor. For more details, see . See issue eg. #1120 for more details... # virtual = Use 'virtual' calculations, based on the lightlevel on the image, also known as ISP exposure (from /proc/jz/isp/isp_info) # false = Use 'false' (default) to DISABLE the topic (It will not publishing details about light level) LIGHT_SENSOR="false" AUTODISCOVERY_PREFIX="dafang" ``` #### 4.2.1 设置动作检测 ![20241229154732_5lj7Yby3.webp](https://cdn.dong4j.site/source/image/20241229154732_5lj7Yby3.webp) #### 4.2.2 设置动作推送 ![20241229154732_JT8hisM3.webp](https://cdn.dong4j.site/source/image/20241229154732_JT8hisM3.webp) #### 4.2.3 测试 这里使用 MQTTX 进行测试: ![20241229154732_g6k1QbKW.webp](https://cdn.dong4j.site/source/image/20241229154732_g6k1QbKW.webp) ![20241229154732_d8u7WoLg.webp](https://cdn.dong4j.site/source/image/20241229154732_d8u7WoLg.webp) 订阅的 topic: `ihome/dafang/#` 可以看到有多个 topic 可使用: ``` ihome/dafang ihome/dafang/leds/blue ihome/dafang/leds/yellow ihome/dafang/leds/ir ihome/dafang/ir_cut ihome/dafang/rtsp_server ihome/dafang/night_mode ihome/dafang/night_mode/auto ihome/dafang/recording ihome/dafang/timelapse ihome/dafang/motion ihome/dafang/motion/detection ihome/dafang/motion/led ihome/dafang/motion/snapshot ihome/dafang/motion/video ihome/dafang/motion/mqtt_publish ihome/dafang/motion/mqtt_snapshot ihome/dafang/motion/send_mail ihome/dafang/motion/send_telegram ihome/dafang/motion/tracking ihome/dafang/motion ``` 我们只需要 `ihome/dafang/motion` 这个 topic, 当监测到动作时, 大方摄像头将向 `ihome/dafang/motion` 发送 `ON`, 动作结束后发送 `OFF`. 大方摄像头的监测配置同样可以使用配置文件配置, 路径为 `/system/sdcard/config/motion.conf`, 修改完成后使用 WEB 管理端重启 MQTT 服务即可. ### 4.3 树莓派安装 MQ 服务 MQTT 服务端选择使用轻量级的 **mosquitto**, 安装命令: ```shell sudo apt update sudo apt upgrade sudo apt install mosquitto mosquitto-clients ``` 安装完成后验证是否成功 ``` sudo systemctl status mosquitto ``` 此命令将返回 `mosquitto` 服务的状态。 如果服务已正常启动,应该会看到文本 `active (running)`. #### 4.3.1 测试 `mosquitto` 自带了 `mosquitto_pub` 和 `mosquitto_sub` 命令, 因此我们使用这两个命令进行简单的测试: 在一个终端执行: ``` mosquitto_sub -h localhost -t "mqtt/test" ``` 新开一个终端向 `mqtt/test` 发送消息: ``` mosquitto_pub -h localhost -t "mqtt/test" -m "hello baby" ``` ![20241229154732_TVrWlkGc.webp](https://cdn.dong4j.site/source/image/20241229154732_TVrWlkGc.webp) ### 4. 安装 bark 服务 #### 4.1 服务端 在 `192.168.31.33`服务器上 使用 docker 安装 [bark](https://github.com/adams549659584/bark-server): ``` version: '3.8' services: bark-server: # 这个镜像比官方的功能多一些, 比如支持 markdown image: adams549659584/bark-server container_name: barkd-server restart: unless-stopped volumes: - ./data:/data - ./web:/web ports: - "8082:8080" ``` #### 4.2 客户端 - [iOS](https://github.com/Finb/Bark) - [Android](https://github.com/xlvecle/PushLite) - [Chrome 插件](https://github.com/xlvecle/Bark-Chrome-Extension) - [在线定时发送](https://api.ihint.me/bark.html) - [Windows 推送客户端](https://github.com/HsuDan/BarkHelper) - [跨平台的命令行应用](https://github.com/JasonkayZK/bark-cli) - [GitHub Actions](https://github.com/harryzcy/action-bark) - [Quicker 动作](https://getquicker.net/Sharedaction?code=e927d844-d212-4428-758d-08d69de12a3b) - [Bark for Wox](https://github.com/Zeroto521/Wox.Plugin.Bark) - [bark-jssdk](https://github.com/afeiship/bark-jssdk) - [java-bark-server](https://gitee.com/hotlcc/java-bark-server) - [Python for Bark](https://github.com/funny-cat-happy/barknotificator) ### 5.4 推送告警到手机 来到最后一步了, 这里讲使用 python 来写一个 mosquitto client 并监听 `ihome/dafang/motion`, 如果值为 `ON` 则使用 `bark` 发送消息. #### 5.4.1 安装依赖 ``` pip install paho-mqtt ``` #### 5.4.2 编写脚本 (sub.py) ```python #!/usr/bin/env python # -*- coding: utf-8 -*- """ A small example subscriber """ import paho.mqtt.client as paho import requests # 定义连接成功后的回调函数 def on_connect(client, userdata, flags, rc): print("Connected with result code "+str(rc)) def on_message(mosq, obj, msg): print("%-20s %d %s" % (msg.topic, msg.qos, msg.payload.decode())) mosq.publish('pong', 'ack', 0) if msg.payload.decode() == 'ON': # 向 bark 发送消息 requests.get('http://192.168.31.33:8082/nmfxNpbf37LZC654nA2HVF/娃儿动了/快去看一下?icon=https://s2.loli.net/2023/10/28/Xoft5KR2xZm3aCd.jpg') if __name__ == '__main__': client = paho.Client() client.on_connect = on_connect client.on_message = on_message #client.tls_set('root.ca', certfile='c1.crt', keyfile='c1.key') client.connect("192.168.31.11", 1883, 60) client.subscribe("ihome/dafang/motion", 0) while client.loop() == 0: pass% ``` #### 5.4.3 执行脚本 ``` python sub.py ``` 在大方摄像头前面动一下应该就可以收到消息了: ![20241229154732_2pUUMdil.webp](https://cdn.dong4j.site/source/image/20241229154732_2pUUMdil.webp) #### 5.4.4 设置自动启动 为 sub.py 设置可执行权限: ```shell chmod +x sub.py ``` ##### 创建 Systemd 服务配置 建立一个新的 Systemd 服务单元配置文件,储存于`/etc/systemd/system/motion.service`: ``` [Unit] Description=dafang MQTT Motion [Service] Type=simple ExecStart=/home/dong4j/sub.py Restart=always [Install] WantedBy=multi-user.target ``` 相关命令 ``` # 开机启动 systemctl enable motion # 关闭开机启动 systemctl disable motion # 启动服务 systemctl start motion # 停止服务 systemctl stop motion # 重启服务 systemctl restart motion # 查看服务状态 systemctl status motion systemctl is-active motion.service # 结束服务进程(服务无法停止时) systemctl kill motion ``` ## 6. 参考 1. [Linux 添加开机自启动](https://www.cnblogs.com/jhxxb/p/10654554.html) 2. [linux Ubuntu 通过 systemd 添加开机自动启动程序方法](https://blog.p2hp.com/archives/8690) ## [构建自己的公网IP服务:Nginx Proxy Manager实战](https://blog.dong4j.site/posts/1fd7702b.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 1. 背景 因为 DDNS-GO 接入了阿里云的 DDNS 服务, 会出现频繁的调用配置的 URL 去获取公网 IP 并更新到阿里云, 如果部分 IP 查询网站设置了请求限制, 将导致我的域名失效. 因此考虑使用 Nginx 来搭建一个获取公网 IP 的服务, 用来给 DDNS-GO 使用. ## 2. 使用 Nginx 在网上查询资料后, 只需要对 Nginx 进行简单的配置即可: ``` server { listen 8000; listen [::]:8000; server_name ip.xxx ip.yyy; server_tokens off; location = /ip { add_header Content-Type text/plain; access_log off; return 200 "$remote_addr\n"; } } ``` 然后使用 `curl http://ip.xxx:8000/ip` 或者 `curl http://ip.yyy:8000/ip` 即可返回公网 IP. ## 3. 使用 Nginx Proxy Manager 因为网络环境的原因, 使用现成的 Nginx Proxy Manager 来代替 Nginx, 这里说一下我的网络环境: ![20241229154732_BK2F4R7y.webp](https://cdn.dong4j.site/source/image/20241229154732_BK2F4R7y.webp) 1. 电信宽带使用 AX9000 作为路由器, 在路由器将 **3200** 端口转发到 **192.168.10.10**(NPM 服务) 的 **32433** HTTPS 端口, 这样就可以使用 `https://ip.xxx.info:3200` 来访问 NPM 的代理; 2. 联通宽带使用 HD 作为路由器, 同样将 **3200** 端口转发到 **192.168.20.10**(NPM 服务) 的 32433 HTTPS 端口, 这样就可以使用 `https://ip.xxx.cc:3200` 来访问 NPM 的代理; 图上面的比如域名的范解析, 路由器的端口转发, NPM 的反向代理配置等配置就不再这里详细说明了, 如果需要的话可以参考网上关于 NPM 的使用方式. ### 3.1 域名泛解析 家里有 1000M 电信宽带和 1000M 联通宽带, 分别在阿里云和腾讯云购买了 2 个域名, 使用泛域名的好处是只需要配置一个泛域名即可支持无限多的二级域名, 这样家里新增了服务后就不需要再去云服务厂商配置域名了, 下面分别说一下域名泛解析的配置. #### 3.1.1 阿里云 ![20241229154732_iGIIYyxR.webp](https://cdn.dong4j.site/source/image/20241229154732_iGIIYyxR.webp) > _:泛解析,匹配其他所有域名, 比如配置了 _.aliyun.com, 将匹配: a.aliyun.com, b.aliyun,com #### 3.1.1 腾讯云 ![20241229154732_jJspV63h.webp](https://cdn.dong4j.site/source/image/20241229154732_jJspV63h.webp) 配置方式与阿里云一致. **www 其实不需要配置.** ### 3.2 路由器端口转发 家里都是小米路由器, 所以端口转发配置方式一致: #### 3.2.1 AX9000-电信 ![20241229154732_SAA9DagP.webp](https://cdn.dong4j.site/source/image/20241229154732_SAA9DagP.webp) 1. 外部端口, 比如: **3200**; 2. 内部 IP 地址, 比如: **192.168.10.10**; 3. 内部端口: 比如: **32443**, 这里的内部端口需要配置成 NPM 的 HTTPS 端口. 上述配置表示任何使用 3200 端口访问路由器的流量都会被转发到 **192.168.10.10** 服务器的 **32443** 端口. #### 3.2.2 HD-联通 将外部的 **3400** 端口转发到内部 **192.168.20.10** 服务器的 **32443** 端口; ### 3.3 NPM 代理 R5S 和 R2S 分别连接了电信和联通, NPM 使用 docker 部署, docker-compose.yml 如下: ```yaml version: '3.8' services: app: image: 'chishin/nginx-proxy-manager-zh:latest' restart: unless-stopped ports: # HTTP 端口 - '8080:80' # web 管理端端口 - '8081:81' # HTTPS 端口 - '32443:443' volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt ``` #### 3.3.1 R5S-电信 转发配置如下: ![20241229154732_oGR0KeTR.webp](https://cdn.dong4j.site/source/image/20241229154732_oGR0KeTR.webp) 使用 `https://ip.xxx.info:3200` 进入的流量将被转发到 `192.168.10.10:8000` 所在的服务, 而 **192.168.10.10** 所在的服务器就是 NPM 所在的寄主机(R5S), **8000** 端口需要后续的配置. #### 3.3.2 R2S-联通 ![20241229154732_AfBq8dyJ.webp](https://cdn.dong4j.site/source/image/20241229154732_AfBq8dyJ.webp) 使用 `https://ip.xxx.cc:3200` 进入的流量将被转发到 `192.168.20.10:8000` 所在的服务, 而 **192.168.20.10** 所在的服务器就是 NPM 所在的寄主机 (R2S), **8000** 端口需要后续的配置. 以上配置完成后, 网络拓扑图如下: ![xxx.drawio.svg](https://cdn.dong4j.site/source/image/xxx.drawio.svg) ### 3.4 配置 NPM 以支持获取公网 IP > R5S 和 R2S 的 NPM 配置一致, 这里就以 R5S 的配置为例来说明. 修改 `./data/nginx/proxy_host` 下的配置文件 (具体的路径根据自己的 docker-compose.yml 来), 具体步骤为: 1. 找到刚刚配置的代理配置文件, 比如我是 1.conf: ```ini # ------------------------------------------------------------ # ip.xxx.info # ------------------------------------------------------------ server { set $forward_scheme http; set $server "192.168.10.10"; set $port 8000; listen 80; listen [::]:80; listen 443 ssl http2; listen [::]:443 ssl http2; server_name ip.xxx.info; # Let's Encrypt SSL include conf.d/include/letsencrypt-acme-challenge.conf; include conf.d/include/ssl-ciphers.conf; ssl_certificate /etc/letsencrypt/live/npm-21/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/npm-21/privkey.pem; access_log /data/logs/proxy-host-22_access.log proxy; error_log /data/logs/proxy-host-22_error.log warn; location / { # Proxy! include conf.d/include/proxy.conf; } # Custom include /data/nginx/custom/server_proxy[.]conf; } ``` 2. 添加 IP 配置: ```ini # ------------------------------------------------------------ # ip.xxx.info # ------------------------------------------------------------ server { set $forward_scheme http; set $server "192.168.10.10"; set $port 8000; listen 80; listen [::]:80; listen 443 ssl http2; listen [::]:443 ssl http2; server_name ip.xxx.info; # Let's Encrypt SSL include conf.d/include/letsencrypt-acme-challenge.conf; include conf.d/include/ssl-ciphers.conf; ssl_certificate /etc/letsencrypt/live/npm-21/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/npm-21/privkey.pem; access_log /data/logs/proxy-host-22_access.log proxy; error_log /data/logs/proxy-host-22_error.log warn; location / { # Proxy! include conf.d/include/proxy.conf; } # 这是新增的 location = /ip { add_header Content-Type text/plain; access_log off; return 200 "$remote_addr\n"; } # Custom include /data/nginx/custom/server_proxy[.]conf; } ``` #### 3.4.1 重启 NPM ```shell docker-compose restart ``` 重启 NPM 后即可使用 ` curl https://ip.xxx.info:3200/ip` 获取公网 IP. #### 3.4.2 DDNS-GO 配置 ##### 3.4.2.1 电信 ![20241229154732_VXHcTW4M.webp](https://cdn.dong4j.site/source/image/20241229154732_VXHcTW4M.webp) ##### 3.4.2.2 联通 ![20241229154732_XtP1vVr9.webp](https://cdn.dong4j.site/source/image/20241229154732_XtP1vVr9.webp) ##### 3.4.2.3 重启 DDNS-GO ``` docker-compose restart ``` 完成后的网络拓扑图如下: ![yyy.drawio.svg](https://cdn.dong4j.site/source/image/yyy.drawio.svg) ## 4. 总结 1. DDNS-GO 一定要使用域名来获取公网 IP, 否则只能获取到局域网 IP; 2. 以上核心的还是下面的配置: ``` location = /ip { add_header Content-Type text/plain; access_log off; return 200 "$remote_addr\n"; } ``` 3. NPM 要区分 HTTP 和 HTTPS 端口, 如果路由器转发到 NPM 的 **8080** 则是 HTTP, 转发到 **32443** 则是 HTTPS; 4. HTTPS 端口需要配置 HTTPS , 可以使用 NPM 自带的 **Let's Encrypt** 服务获取, 具体的配置方式可以参考官网; ## 5. 参考 1. [使用 nginx 获取自己的公网 IP 地址](https://hellodk.cn/post/1133) 2. [利用 Nginx 实现简易的公网 IP 查询](https://zhuanlan.zhihu.com/p/629311484) 3. [纯 Nginx 打造 IP 地址查询接口](https://www.rehiy.com/post/467/) ## [业务场景全解:如何应对视频集成挑战](https://blog.dong4j.site/posts/a31e50a5.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 主要介绍了视频集成解决方案,包括视频监控数据流转、音视频传输协议对比、协议接入、主流的流媒体协议、播放协议对比、主流的封装格式、主要视频编码、常见的设备与连接方案、术语解释、业务场景、视频接入平台、流媒体服务选型以及平台选型等方面的内容。通过这篇文章,读者可以了解到视频集成常见的解决方案,并从现有方案中学习总结,以应对日益复杂的业务场景。 ## 1. 目的 随着承接的项目逐渐增多, 且大多数项目都会涉及到视频监管需求, 为了避免重复调研开发, 加快项目上线速度, 这里汇总总结了多种业务场景并提出对应的解决方案, 一方面是作为技术知识积累, 让团队人员了解视频集成常见的解决方案, 二是可以从现有方案中学习总结, 以应对日益复杂的业务场景. ## 2. 领域知识 ### 2.1 监控数据流 ![1.drawio.svg](https://cdn.dong4j.site/source/image/1.drawio.svg) 视频监控数据流转主要经过下面 5 个过程: 1. 采集: 通过硬件设备采集音视频数据, 如果硬件设备支持, 还可以对数据进行粗加工, 比如添加水印; 2. 推流: 设备端将音视频数据进`行编码压缩(H.264/H.265)后发送给流媒体服务器, 一般使用 RTSP (延迟最低); 3. 流媒体处理: 负责转码, 传输, 数据分发; 4. 拉流: 客户端请求流媒体服务器音视频资源, 流媒体服务器通过 RTSP, HLS 等流媒体传输协议传输数据; 5. 解码: 设备端在拿到音视频数据后需要通过硬解码或软解码的方式播放音视频流; ### 2.2 音视频传输协议对比 ![20241229154732_JlUVbWmH.webp](https://cdn.dong4j.site/source/image/20241229154732_JlUVbWmH.webp) ### 2.3 协议接入 #### 2.3.1 GB/T28181 ![20241229154732_OfCzxzaf.webp](https://cdn.dong4j.site/source/image/20241229154732_OfCzxzaf.webp) **GB/T28181 是摄像头主动向上级平台连接注册,摄像头不需要有固定的外网 IP,只要求流媒体服务器有固定的 IP 地址即可** #### 2.3.2 ONVIF ![20241229154732_2e3a6yQY.webp](https://cdn.dong4j.site/source/image/20241229154732_2e3a6yQY.webp) **ONVIF 协议是流媒体服务器主动去连接摄像头,然后进行控制或视频调取,因此在组网时需要流媒体服务器能够访问到摄像头的 IP 地址** ### 2.4 主流的流媒体协议 | 名称 | 推出机构 | 传输层协议 | 客户端 | 使用领域 | | -------- | -------------- | ---------- | ------- | ----------------- | | RTSP+RTP | IETF | TCP+UDP | VLC WMP | IPTV | | RTMP | Adobe Inc. | TCP | Flash | 互联网直播 | | RTMFP | Adobe Inc. | UDP | Flash | 互联网直播 | | MMS | Microsoft Inc. | TCP/UDP | WMP | 互联网直播 + 点播 | | HTTP | WWW IETF | TCP | Flash | 互联网点播 | ### 2.5 播放协议对比 RTMP:RTMP 协议比较全能。既可以用来推送又可以用来直播。其核心理念是将大块的视频帧和音频帧拆分,然后以小数据包的形式在互联网上进行传输,而且支持加密,因此隐私性相对比较理想,但拆包组包的过程比较复杂,所以在海量并发时也容易出现一些不可预期的稳定性问题。 FLV:FLV 协议由 Adobe 公司主推,格式极其简单,只是在大块的视频帧和音视频头部加入一些标记头信息,由于这种简洁,在延迟表现和大规模并发方面都很成熟,唯一的不足就是在手机浏览器上的支持非常有限,但是用作手机端 App 直播协议却异常合适。 HLS:苹果推出的解决方案,将视频分 5 秒 - 10 秒的视频小分片,然后用 m3u8 索引表进行管理,由于客户端下载到的视频都是 5 秒 - 10 秒的完整数据,故视频的流畅性很好,但也同样引入了很大的延迟(HLS 的一般延迟在 10 秒 - 30 秒左右)。相比于 FLV,HLS 在 iPhone 和大部分 Android 手机浏览器上的支持都非常好。 | 播放协议 | 优点 | 缺点 | 播放延迟 | | --------- | ---------------------- | ----------------------------------------- | -------- | | FLV | 成熟度高、高并发无压力 | 需集成 SDK 才能播放 | 2s-3s | | RTMP | 优质线路下理论延迟最低 | 高并发情况下表现不佳;需集成 SDK 才能播放 | 1s-3s | | HLS(m3u8) | 手机浏览器支持度高 | 延迟非常高 | 10s-30s | ### 2.6 主流的封装格式 | 名称 | 推出机构 | 视频编码 | 音频编码 | 使用领域 | | ---- | ------------------ | ---------------------------------------- | -------------------------------------- | -------------- | | AVI | Microsoft Inc. | 不支持几乎所有格式 | 几乎所有格式 | BT 下载影视 | | MP4 | MPEG | 支持 MPEG-2, MPEG-4, H.264, H.263 等 | AAC, MPEG-1,Layers I, II, III, AC-3 等 | 互联网视频网站 | | TS | MPEG | 支持 MPEG-1, MPEG-2, MPEG-4, H.264MPEG-1 | Layers I, II, III, AAC | IPTV,数字电视 | | FLV | Adobe Inc. | 支持 Sorenson, VP6, H.264 | MP3, ADPCM, Linear PCM, AAC 等 | 互联网视频网站 | | MKV | CoreCodec Inc. | 支持几乎所有格式 | 几乎所有格式 | 互联网视频网站 | | RMVB | Real Networks Inc. | 支持 RealVideo 8, 9, 10 | AAC, Cook Codec, RealAudio,Lossless | BT 下载影视 | ### 2.7 主要视频编码 | 名称 | 推出机构 | 推出时间 | 目前使用领域 | | ------------ | -------------- | -------- | ------------ | | HEVC (H.265) | MPEG/ITU-T | 2013 | 研发中 | | H.264 | MPEG/ITU-T | 2003 | 各个领域 | | MPEG4 | MPEG | 2001 | 不温不火 | | MPEG2 | MPEG | 1994 | 数字电视 | | VP9 | Google | 2013 | 研发中 | | VP8 | Google | 2008 | 不普及 | | VC-1 | Microsoft Inc. | 2006 | 微软平台 | | AVS | 中国 | | | | AVS+ | 中国 | | | ### 8. 常见的设备与连接方案 #### 2.8.1 监控系统中常见设备及作用 在监控系统中,我们要使用很多的设备,在此,我们只讲述一些通用的设备。 1. **摄像机**:这个是信号源,是视频图像的来源,摄像机也分很多种,比如:高速球、红外枪机等;一般来说摄像机后面接视频分配器(网络高清摄像机需要接解码器后才能接视频分配器)。 2. **视频分配器**:是将摄像机出来的视频信号进行分配的,一般是每路摄像机分配为 2 路相同的视频信号,将视频信号进行分配的目的是 1 路信号给监控矩阵,另一路给硬盘录像机,视频分配器的输入端接摄像机,输出端接硬盘录像机和监控矩阵。 3. **硬盘录像机**:将视频信息进行录制的,以备随时的调取视频资料,硬盘录像机的输入端接分配器的输出端,硬盘录像机的输出端一般接显示器。 4. **监控矩阵**:这个设备是监控系统中核心设备,是将摄像机过来经过分配器后的视频信号传输至终端显示设备上的,监控矩阵的输入端接分配器的输出端,监控矩阵的输出端接终端显示设备,此外监控矩阵的附带品是控制键盘,是用来控制视频切换的设备,与监控矩阵直接相连即可。 5. **终端显示**:是将视频信息展示的设备,终端显示设备一般有显示器、电视墙、LED 显示屏、拼接屏、监视器等,终端显示设备的输入端接监控矩阵的输出端,终端显示设备没有输出端的。 #### 2.8.2 监控系统中常见的连接方案 摄像机的输出端接视频分配器的输入端,视频分配器的输出端分成 2 个部分,第一部分接硬盘录像机,第二部分(视频信号与第一部分完全相同)接监控矩阵的输入端,监控矩阵的输出端接终端显示设备,此外监控矩阵和控制键盘用网线连接。 ![2.svg](https://cdn.dong4j.site/source/image/2.svg) #### 2.8.3 视频分配器 ![20241229154732_fxJlVXLo.webp](https://cdn.dong4j.site/source/image/20241229154732_fxJlVXLo.webp) ### 9. 术语解释 #### **GB/T28181** GB/T28181《安全防范视频监控联网系统信息传输、交换、控制技术要求》是由公安部科技信息化局提出,由全国安全防范报警系统标准化技术委员会、公安部一所等多家单位共同起草的一部国家标准。标准规定了城市监控报警联网系统中信息传输、交换、控制的互联结构、通信协议结构,传输、交换、控制的基本要求和安全性要求,以及控制、传输流程和协议接口等技术要求。该标准适用于安全防范监控报警联网系统的方案设计、系统检测、验收以及与之相关的设备研发、生产,其他信息系统可参考采用。联网系统应对前端设备、监控中心设备、用户终端 ID 进行统一编码,该编码具有全局唯一性。 #### **RTMP** RTMP 是 Real Time Messaging Protocol(实时消息传输协议)的首字母缩写。该协议基于 TCP,是一个协议族,包括 RTMP 基本协议及 RTMPT/RTMPS/RTMPE 等多种变种。RTMP 是一种设计用来进行实时数据通信的网络协议,主要用来在 Flash/AIR 平台和支持 RTMP 协议的流媒体 / 交互服务器之间进行音视频和数据通信。 #### **ONVIF** ONVIF(开放式网络视频接口论坛)是一个全球性的开放式行业论坛,其目标是促进开发和使用基于物理 IP 的安全产品接口的全球开放标准。ONVIF 创建了一个视频监控和其他物理安全领域的 IP 产品如何进行相互通信的标准。ONVIF 是由 Axis Communications,博世安防系统和索尼于 2008 年创立的。 #### **RTSP** RTSP(Real Time Streaming Protocol),RFC2326,实时流传输协议,是 TCP/IP 协议体系中的一个应用层协议,由哥伦比亚大学、网景和 RealNetworks 公司提交的 IETF RFC 标准。该协议定义了一对多应用程序如何有效地通过 IP 网络传送多媒体数据。 #### **GA/T 1400** GA/T 1400 与 GB/T 28181 有共性的地方,比如设备编码规范、信令交互规范等, GB/T28181 定义的是视频联网,GA/TGA/T 1400 定义的是图片传输,应用最多的是 IPC 传输图片及相关信息到后端设备 / 平台,以及视图库平台与平台对接等,比如人脸抓拍机传输人脸图片、人脸特征等到人脸应用平台,车辆抓拍机传输车辆图片、车牌信息到车辆卡口平台。 #### **FLV** FLV 是 FLASH VIDEO 的简称,FLV 流媒体格式是随着 Flash MX 的推出发展而来的视频格式。由于它形成的文件极小、加载速度极快,使得网络观看视频文件成为可能,它的出现有效地解决了视频文件导入 Flash 后,使导出的 SWF 文件体积庞大,不能在网络上很好的使用等问题。 #### **HLS** HLS (HTTP Live Streaming) 是 Apple 的动态码率自适应技术。主要用于 PC 和 Apple 终端的音视频服务。包括一个 m3u (8) 的索引文件,TS 媒体分片文件和 key 加密串文件。 #### **WebRTC** WebRTC,名称源自网页即时通信(英语:Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的 API。 #### **IPC** IPC 是网络摄像机(Internet Protocol Camera)的缩写。 #### **NVR** NVR 是网络硬盘录像机(Network Video Recorder)的缩写。 #### ICC ICC 是大华推出的一款智能物联综合管理平台, tigong 设备对接, 人员布控, 停车管理, 消费管理, 动环管理动众多功能. #### IVSS IVSS 是大华的一款硬件设备, 将人脸、人体、车辆、周界等众多“边界智能”融合一体的一体机系列, 具备 IPC、球机、DVR、NVR 等 IP 设备管理, 能够进行人脸识别、车辆识别、人脸比对、实时布控等 AI 计算. #### IVS IVS 智能监控系统可以根据用户的需求,在指定时间段内对目标进行实时监控。您还可以使用仪表盘和手动新建自定义告警模板。您可以根据实际情况自定义监控指标,系统会按照设定的指标收集业务数据。系统还可以根据业务需要,对异常事件、告警和事件进行分类统计和告警上报。 #### **视频流** 在网络上,视频数据按时间先后次序传输和播放的连续视频数据流。 #### **推流** 推流是指利用推流客户端或者推流工具,通过推流地址将 RTMP 协议的视频流推送到客户端。 #### **拉流** 拉流是指通过互联网环境,将视频流从指定平台或 IP 地址获取到视频观看客户端或设备。 ## 3. 业务场景 ### 3.1 固定地点 #### 3.1.1 有线接入 ![固定+有线接入.drawio.svg](https://cdn.dong4j.site/source/image/%E5%9B%BA%E5%AE%9A%2B%E6%9C%89%E7%BA%BF%E6%8E%A5%E5%85%A5.drawio.svg) ​ 1. IPC 和 NVR 在同一个局域网, NVR 有独立的 Web 端用于管理视频和录像; ​ 2. NVR 提供 RTSP 视频流, 供内部系统和外部系统集成; ​ 3. 外网可通过公网 IP 或 VPN 组网访问内部 NVR; 企业自建网络,本地环境有较多的 IPC,本地配置 NVR 设备,提供本地的视频监测。第三方一般不允许从远端访问当地的视频。 ![固定+有线接入2.drawio.svg](https://cdn.dong4j.site/source/image/%E5%9B%BA%E5%AE%9A%2B%E6%9C%89%E7%BA%BF%E6%8E%A5%E5%85%A52.drawio.svg) 针对于 **工作现场固定且能够方便布线** 的环境, 推荐使用传统方式:由网络摄像机+电源+网络电缆 的方案. 这种方式是最简单的一种方案, 且能够保证流媒体传输的稳定性. **解决方案** 因为 IPC 和 NVR 在一个局域网内, 因此只要 NVR 支持的协议的 IPC 都可以对接: 1. 登录 IPC 管理端进行摄像头设置; 2. 登录 NVR 添加 IPC (协议接入可根据 IPC 型号选择); NVR 还可以替换成具有 AI 识别的具有视频存储功能的硬件设备, 比如 IVSS. **针对于有线接入, 最重要的一点就是通过交换机将 IPC 和 NVR 划分到同一个局域网内, 这样 NVR 才能识别 IPC.** 1. 内部系统直接从 NVR 上拉取视频流; 2. 外部系统需要通过 NAT 访问 NVR 拉取视频流; **关键点:** > IPC/NVR 在内网, 业务系统在外网, 且业务系统能够通过 NAT 访问 IPC 或 NVR. #### 3.1.2 无线接入 ##### **场景三: 昆仑先锋变电站** 针对于 **工作现场固定但无法通过有线宽带接入** 的环境, 推荐使用 4G 路由器接入方案. **解决方案一:** ![固定+无线接入1.drawio.svg](https://cdn.dong4j.site/source/image/%E5%9B%BA%E5%AE%9A%2B%E6%97%A0%E7%BA%BF%E6%8E%A5%E5%85%A51.drawio.svg) 1. 针对于摄像头数量较少的工作地点可以不部署 NVR, 直接将 IPC 通过交换机和 4G 路由器一起组建一个局域网, 并通过 4G 上传视频流. 2. 因为 4G 物联网卡的 IP 不固定, 因此需要与远端的 NVR 通过 VPN 的方式组建局域网, NVR 才能接入 IPC. **关键地:** > IPC 在内网, NVR 在外网, 需要组建 VPN 专网; **解决方案二:** ![固定+无线接入2.drawio.svg](https://cdn.dong4j.site/source/image/%E5%9B%BA%E5%AE%9A%2B%E6%97%A0%E7%BA%BF%E6%8E%A5%E5%85%A52.drawio.svg) 1. 针对摄像头数量较多的工作地点, 建议直接在项目部部署至少一台 NVR, 用于本地存储视频, 避免因为 4G 网络的不稳定性和 IPC 数量较多的原因造成视频延迟的问题; 2. 网络拓扑图与方案一一致, 因为 **4G 路由器** 没有固定 IP, 为了 NVR 能够识别 IPC, 只能使用 VPN 专网; **关键点:** > IPC 在内网, 上级 NVR 在外网, 需要组建 VPN 专网; **解决方案三:** 我们可以使用 4G NVR 代替传统的 NVR + 4G 路由器, 其他与方案二一致. ### 3.2 非固定地点 针对于 **工作现场不固定** 的移动作业的业务场景, 一般使用 4G 摄像头采集视频, 因为 4G SIM 卡一般没有固定公网 IP, 故无法直接访问 IPC. 不同的厂商可能会有不同的平台用于对接 IPC, 比如海康威视的 **海康互联 APP**. **解决方案:** 通过 GB/T28181 平台直接管理 IPC/NVR ![移动作业.drawio.svg](https://cdn.dong4j.site/source/image/%E7%A7%BB%E5%8A%A8%E4%BD%9C%E4%B8%9A.drawio.svg) 1. 在 4G IPC 上设置 GB/T28181 接入平台相关信息, 主动向平台注册设备信息; 2. 平台管理接入的 IPC/NVR 等支持 GB/T28181 协议的设备; **关键点:** > 1. 待接入设备需要支持 GB/T28181 协议; > 2. 待接入设备能访问平台; #### 3.2.1 类似解决方案 移动作业的业务场景一般选用带有 4G 网卡的设备接入公网, 不过带来的问题就是 IP 不固定, 这样就不能直接通过远端连接到 IPC. 为了解决这类问题, 需要一种主动注册的方案, 即 4G IPC 设备主动向平台注册设备信息, 其中 GB/T28181 协议就是典型代表, 其底层基于 SIP 和 SUP 协议实现. 一些第三方厂商也会开发符合自身业务需求的其他协议, 下面介绍 2 种比较常用的类似协议. ##### 3.2.1.1 Ehome 协议 EHome 协议是海康威视基于移动监控场景下开发的设备主动注册私有协议,支持实时预览、录像回放、对讲、报警、定位等功能。 EHome 协议不仅实现了 GB/T28181 里所有的功能,并且在海康的不同类型设备上支持了其自定义的场合下的功能特性。包括智能报警、在低功耗场景下的设备控制及公网环境下的语音对讲指挥等场景都比 GB/T28181 协议更完善。 海康 EHome 协议架构包括三个主要部分:编码器、传输网络和监控中心。 1. 编码器:负责将音视频信号进行压缩编码,通过 IP 网络发送至监控中心。编码器支持多种分辨率和码率调整,满足不同场景需求。 2. 传输网络:基于 IP 网络,支持 TCP/IP、UDP 等协议,实现音视频数据的传输与通信。 3. 监控中心:接收来自编码器的音视频数据,进行解码、显示及存储等操作。监控中心支持多级架构,方便集中管理和调度。 **特点:** 1. 高清画质:采用先进的音视频压缩编码技术,提供高清画质,还原现场真实场景。 2. 低延迟:音视频传输延迟低,满足实时监控需求。 3. 双向通信:支持监控中心与编码器之间的双向通信,实现远程控制及报警通知等功能。 4. 稳定性:具备较高的稳定性和抗干扰能力,保证长时间稳定运行。 5. 安全性:采用加密传输和认证授权机制,确保数据安全和隐私保护。 ##### 3.2.1.2 萤石协议 萤石协议是萤石云开发的一种设备主动注册的私有协议, 类似于国标协议和 Ehome 协议, 如果设备同时支持萤石协议和 Ehome 协议, 只能二选一. ##### 3.2.1.3 DAHUA 协议 大华主动注册协议是类似海康 E-home、ISUP 协议,也是前端设备向中心平台和服务注册的一种主动注册协议,对于前端网络无固定 IP 情况下对视频的联网、视频上云等场景应用尤为适用。 ##### 3.2.1.5 JTT1078 JT/1078 即<**道路运输车辆卫星定位系统-视频通信协议**>,于 2016 年发布,经过几年的沉淀,逐渐应用于道路两客一危、中高端定制化货运、出租车运输等行业。 ##### 3.2.1.4 SDK 海康, 大华, 宇视, 乐橙等厂商的 SDK 都可以完成 shebeui 在互动注册逻辑, 需要进行二次开发. > 以上协议都能够解决设备无固定 IP 带来的问题, 但是除了 GB/T28181 协议外, 其他都是厂商的私有协议, 虽然在性能上优于 GB/T28181 协议, 但是需要特定的设备支持, 而 GB/T28181 协议 广泛被各大厂商设备支持, 因此选择使用 **GB/T28181 协议** 接入监控设备. ## 4. 视频接入平台 ### 4.1 为什么需要一个视频接入平台 在没有视屏接入平台之前, 每个项目都会根据业务场景选购不同厂商的 IPC/NVR, 流媒体服务器, AI 识别服务器等, 每个项目都需要经历一次视频对接的流程, 如果有了视频接入平台: ![xxx.drawio.svg](https://cdn.dong4j.site/source/image/xxx.drawio.svg) 1. 根据业务需求选购支持 GB/T28181 协议的 IPC/ NVR, 在局域网内可以使用双方支持的任意协议完成配网与对接, 比如 IPC 通过局域网接入到 NVR/ICC/IVSS; 2. 将 NVR/IVSS 等支持 GB/T28181 协议且能管理多台 IPC 的设备或将 IPC 直接接入 **GB/T28181 视频接入平台**; 3. 统一在 **GB/T28181 视频接入平台** 管理各种 IPC/NVR 等设备, 为上层业务系统提供点播, 录像查看, 视频分屏展示等常规功能; 这里的 **GB/T28181 视频接入平台** 相当与一个中介, 业务系统不再需要与各个设备厂商对接私有的视频接入协议, 而实施团队只需要将 IPC/NVR 等设备进行正确的组网即可, 这样能够大大缩短项目上线时间. > 简而言之, 视频接入平台对下能有效屏蔽不同厂商不同协议接入带来的复杂性, 对上能够兼容各种视频查看需求, 减少重复开发成本, 便于在一处统一管理视频设备, 从而加快项目进度. ### 4.2 什么是 GB/T28181 平台 > GB/T28181 协议指的是国家标准 GB/T 28181—2016《公共安全视频监控联网系统信息传输、交换、控制技术要求》1,该标准规定了公共安全视频监控联网系统的互联结构, 传输、交换、控制的基本要求和安全性要求, 以及控制、传输流程和协议接口等技术要求,是视频监控领域的国家标准。GB/T28181 协议信令层面使用的是 SIP(Session Initiation Protocol)协议 2,流媒体传输层面使用的是实时传输协议(Real-time Transport Protocol,RTP)协议 3,因此可以理解为 GB/T28181 是在国际通用标准的基础之上进行了私有化定制以满足视频监控联网系统互联传输的标准化需求。本文旨在说明在 FFmpeg 中增加对 GB/T28181 协议的支持,使其可以与支持 GB/T28181 协议的设备进行通信与控制,实现设备的注册、保活以及流媒体的传输。 因为是国家标准协议, 目前市面上大多数视频设备都已支持 GB/T28181 协议, 比如大华, 海康等主流的监控厂商. 一个完整的 GB/T28181 平台,通常由**管理平台、信令服务器、流媒体服务器、监控设备(IPC、NVR)、管理终端**等几部分组成: ![yyy.drawio.svg](https://cdn.dong4j.site/source/image/yyy.drawio.svg) 1. **信令服务器** 是与网络中 **监控设备** 与 **管理终端** 之间进行通信的代理,也是各级系统之间的代理,**信令服务器** 要有固定的 IP 地址或域名,如果要面向公网服务,还需要有公网 IP。由于是 **监控设备** 主动向信令服务器注册,所以 **监控设备** 不需要有固定的 IP。**监控设备** 注册到信令服务器上之后,管理者通过向信令服务器发送指令来管理 **监控设备**。 2. **流媒体服务器** 是视频传输的代理,该系统接受监控设备发送的视频流,向视频调取方转发视频。视频调取方可以是监控监控视频的用户、第三方业务系统或者是上下级平台。协议规定监控设备通过 rtp 协议向流媒体服务器发送视频流,但是没有约定流媒体服务器向其他方转发视频的协议,因此在视频转发时具有更大的灵活性,可以根据需要采用合适的协议转发视频。 国标 GB/T28181 接入服务包含设备管理,视频流协议转换,直播监控,录像回放等。 | 功能名称 | 功能描述 | | :------------: | :-------------------------------------------------------------------------------------------------------------------------: | | 设备管理 | 支持常见设备品牌接入到萤石开放平台进行设备的统一管理,远程控制。 | | 视频流协议转换 | 支持将 GB/T28181 视频协议转换为 RTMP、FLV、HLS、EZopen 协议,并进行分发。 | | 直播监控 | 支持主流直播标准协议 HLS、RTMP、 FLV 以及 EZopen 协议,轻松覆盖各种应用端:Android、iOS、小程序、PC、Web 等。 | | 录像回放 | 支持多路视频同时回放、倍速回访、进度条控制、云端录制等功能,客户可按需实现回放控制。 | | 对讲与设备控制 | 可以通过调用语音对讲功能的 API 实现语音对讲功能;同时支持云台控制相关设备控制功能。满足多项设备交互需求,适用更多场景需求。 | | 消息推送 | 支持获取包括设备报警、视频报警、设备故障报警等类型的设备端事件上报。 | | 存储与媒体处理 | 在国标协议基础上,支持设备抽帧,获取重点图片数据进行存储。并支持云端视频画面录制与存储,保存业务场景中的重点音视频数据。 | ### 4.3 整体流程 ![zzz.drawio.svg](https://cdn.dong4j.site/source/image/zzz.drawio.svg) 1. IPC/NVR 本地配置 SIP 相关信息, 然后定时向 SIP 信息服务器注册当前的设备信息, 比如 IP, 设备 ID, 设备类型等; 2. SIP 信令服务器会通过管理平台设置的相关信息进行设备验证, 比如 SIP 编号, 密码是否正确等; 3. 设备注册成功后将会展示在管理平台中, 可通过此平台维护设备的通道, 地理位置, 录像文件, 设备分组, 视频墙和流媒体服务器节点等信息; 4. 终端设备需要查看实时视频时, 管理平台会通过流媒体服务器下发拉流动作, 流媒体服务器向设备发送拉流请求后, 设备使用 RTP 协议向流媒体服务器推流; 5. 流媒体服务器在接收到 RTP 视频流后, 根据管理终端的需要可以转换成不同的视频流输出, 比如 FLV, MP4, HLS, TS, RTC ,RTMP, RTSP 等, 因此可以满足绝大多数的业务场景; 6. GB/T28181 规定 SIP 应该包含控制信号和视频信号, 控制信号用于控制球机的移动, 因此只要是支持 GB/T28181 的设备, 不再需要单独对接厂商的 SDK 去控制设备; 7. SIP 信令服务器还能够将设备的消息推送到管理平台, 比如离线, 设备监测到动作, 设备上线等, 而管理平台可以根据这些消息进行自定义业务处理; ## 5. 流媒体服务选型 ### 5.1 ZLMediaKit https://docs.zlmediakit.com/zh/ ![20241229154732_Eg8Am302.webp](https://cdn.dong4j.site/source/image/20241229154732_Eg8Am302.webp) **ZLMediaKit** 是一套高性能的流媒体服务框架,目前支持 rtmp、rtsp、hls、http-flv 等流媒体协议,支持 linux、macos、windows 三大 PC 平台和 ios、android 两大移动端平台。 **主要功能:** 1. 基于 C++11 开发,避免使用裸指针,代码稳定可靠,性能优越。 2. 支持多种协议(RTSP/RTMP/HLS/HTTP-FLV/WebSocket-FLV/GB28181/HTTP-TS/WebSocket-TS/HTTP-fMP4/WebSocket-fMP4/MP4),支持协议互转。 3. 使用多路复用/多线程/异步网络 IO 模式开发,并发性能优越,支持海量客户端连接。 4. 代码经过长期大量的稳定性、性能测试,已经在线上商用验证已久。 5. 支持 linux、macos、ios、android、windows 全平台。 6. 支持画面秒开、极低延时(500 毫秒内,最低可达 100 毫秒)。 7. 提供完善的标准 C API,可以作 SDK 用,或供其他语言调用。 8. 提供完整的 MediaServer 服务器,可以免开发直接部署为商用服务器。 9. 提供完善的 restful api 以及 web hook,支持丰富的业务逻辑。 10. 打通了视频监控协议栈与直播协议栈,对 RTSP/RTMP 支持都很完善。 11. 全面支持 H265/H264/AAC/G711/OPUS。 > 总结: 同时支持 rtsp/rtmp 推拉流,而且支持 h265 的推拉流(推流端要支持 265 的 ffmpeg/拉流播放端也要支持 265 的播放器),支持各种格式拉流,使用者众多 ### 5.2 Monibuca https://m7s.live/#ui ![20241229154732_HaiZHt44.webp](https://cdn.dong4j.site/source/image/20241229154732_HaiZHt44.webp) Monibuca 是一个开源的流媒体服务器开发框架,适用于快速定制化开发流媒体服务器,可以对接 CDN 厂商,作为回源服务器,也可以自己搭建集群部署环境。 丰富的内置插件提供了流媒体服务器的常见功能,例如 rtmp server、http-flv、视频录制、QoS 等。除此以外还内置了后台 web 界面,方便观察服务器运行的状态。 也可以自己开发后台管理界面,通过 api 方式获取服务器的运行信息。 Monibuca 提供了可供定制化开发的插件机制,可以任意扩展其功能。 ![20241229154732_1Z6xq7im.webp](https://cdn.dong4j.site/source/image/20241229154732_1Z6xq7im.webp) **主要功能:** 1. 针对流媒体服务器独特的性质进行的优化,充分利用 Golang 的 goroutine 的性质对大量的连接的读写进行合理的分配计算资源,以及尽可能的减少内存 Copy 操作。使用对象池减少 Golang 的 GC 时间。 2. 专为二次开发而设计,基于 Golang 语言,开发效率更高;独创的插件机制,可以方便用户定制个性化的功能组合,更高效率的利用服务器资源。 3. 功能强大的仪表盘可以直观的看到服务器运行的状态、消耗的资源、以及其他统计信息。用户可以利用控制台对服务器进行配置和控制。点击右上角菜单栏里面的演示,可以看到演示控制台界面。 4. 纯 Go 编写,不依赖 cgo,不依赖 FFMpeg 或者其他运行时,部署极其方便,对服务器的要求极为宽松。 > 总结: 支持自定义插件, 扩展新功能非常方便, 缺点就是部分插件收费. ### 5.3 SRS https://ossrs.net/lts/zh-cn/ ![20241229154732_Ne0IQhYm.webp](https://cdn.dong4j.site/source/image/20241229154732_Ne0IQhYm.webp) SRS(Simple Realtime Server)是一个简单高效的实时视频服务器,支持 RTMP、WebRTC、HLS、HTTP-FLV、SRT 等多种实时流媒体协议。 SRS 作为当前非常普遍的运营级解决方案,具备非常全面的功能,包括集群、协议网关、CDN 功能等,主要功能如下: 1. SRS 定位是运营级的互联网直播服务器集群,追求更好的概念完整性和最简单实现的代码。 2. SRS 提供了丰富的接入方案将 RTMP 流接入 SRS, 包括推送 RTMP 到 SRS、推送 RTSP/UDP/FLV 到 SRS、拉取流到 SRS。 SRS 还支持将接入的 RTMP 流进行各种变换,譬如将 RTMP 流转码、流截图、 转发给其他服务器、转封装成 HTTP-FLV 流、转封装成 HLS、 转封装成 HDS、转封装成 DASH、录制成 FLV/MP4。 3. SRS 包含支大规模集群如 CDN 业务的关键特性, 譬如 RTMP 多级集群、源站集群、VHOST 虚拟服务器 、 无中断服务 Reload、HTTP-FLV 集群。 4. SRS 还提供丰富的应用接口, 包括 HTTP 回调、安全策略 Security、HTTP API 接口、 RTMP 测速。 5. SRS 在源站和 CDN 集群中都得到了广泛的应用 Applications。 > 总结: 支持 rtmp 推流,早期版本支持 rtsp 推流,不知道为何移除了。支持部分格式拉流,不支持 ws-flv 拉流,使用者众多 ### 5.5 EasyDarwin https://www.easydarwin.org/ ![20241229154732_jMsecsHY.webp](https://cdn.dong4j.site/source/image/20241229154732_jMsecsHY.webp) **EasyDarwin** 是由国内开源流媒体团队维护和迭代的一整套开源流媒体视频平台框架,Golang 开发,从 2012 年 12 月创建并发展至今,包含有单点服务的开源流媒体服务器,和扩展后的流媒体云平台架构的开源框架,开辟了诸多的优质开源项目,能更好地帮助广大流媒体开发者和创业型企业快速构建流媒体服务平台,更快、更简单地实现最新的移动互联网(安卓、iOS、H5、微信)流媒体直播与点播的需求,尤其是安防行业与互联网行业的衔接。 **主要功能:** 1. 基于 Golang 语言开发维护。 2. 支持 Windows、Linux、macOS 三大系统平台部署。 3. 支持 RTSP 推流分发(推模式转发)。 4. 支持 RTSP 拉流分发(拉模式转发)。 5. 服务端录像、检索、回放。 6. 支持关键帧缓存、秒开画面。 7. Web 后台管理。 8. 分布式负载均衡。 > 总结: 只支持 rtsp 推拉流,默认端口 5541,不支持其他格式拉流,如果仅仅是监控摄像头使用,非常方便,有个网页管理后台,不会过期可以一直用,缺点是功能单一,只能在他的后台查看视频流,或者用播放器播放. **上述 4 种流媒体服务对比:** ![20241229154732_ZYApFZks.webp](https://cdn.dong4j.site/source/image/20241229154732_ZYApFZks.webp) ### 5.6 MediaMTX https://github.com/bluenviron/mediamtx _MediaMTX_(以前称为*rtsp-simple-server*)是一个即用型、零依赖的实时媒体服务器和媒体代理,允许发布、读取、代理和记录视频和音频流。它被设想为一种“媒体路由器”,将媒体流从一端路由到另一端。 **主要功能:** - 将直播流发布到服务器 - 从服务器读取直播流 - 流自动从一种协议转换为另一种协议 - 在不同的路径中同时提供多个流 - 将流记录到磁盘 - 验证用户身份;使用内部或外部身份验证 - 将读取器重定向到其他 RTSP 服务器(负载平衡) - 通过 API 查询和控制服务器 - 在不断开现有客户端连接的情况下重新加载配置(热重载) - 读取 Prometheus 兼容的指标 - 当客户端连接、断开连接、读取或发布流时运行挂钩(外部命令) - 与 Linux、Windows 和 macOS 兼容,不需要任何依赖项或解释器,它是单个可执行文件 > 总结: 同时支持 rtsp/rtmp 推拉流,拉流还支持 hls/webrtc 两种方式,最新版本还支持了 srt 方式。推出来的 hls/webrtc 可以直接嵌入个 iframe 网页播放,没有任何依赖,如果希望直接在网页中播放无依赖,强烈推荐用 mediamtx ### 5.7 Nginx-rtmp-module https://github.com/arut/nginx-rtmp-module Nginx 本身是一个非常出色的 HTTP 服务器,FFMPEG 是非常好的音视频解决方案.这两个东西通过一个 nginx 的模块 nginx-rtmp-module,组合在一起即可以搭建一个功能相对比较完善的流媒体服务器. 这个流媒体服务器可以支持 RTMP 和 HLS(Live Http Stream)。 **主要特性:** 1. 支持 RTMP、HLS、MPEG-DASH 2. 支持 RTMP、HLS 点播 3. 可将直播视频分段存储 4. 支持 H.264 视频编解码、AAC 音频编解码 5. 支持 FFmpeg 命令内嵌 6. 支持回调 HTTP 7. 可使用 HTTP 对直播进行删除、录播等控制 8. 具有强大的缓冲功能,可确保在效率与码率间达到平衡 9. 支持多种操作系统(Linux、MacOS、Windows) > 总结: 只支持 rtmp 推拉流,默认端口 1935,不支持其他格式拉流,功能极其单一,不推荐。 ## 6. 平台选型 ### 6.1 WVP WEB VIDEO PLATFORM 是一个开源的基于 GB28181-2016 标准实现的开箱即用的网络视频平台,负责实现核心信令与设备管理后台部分,支持 NAT 穿透,支持海康、大华、宇视等品牌的 IPC、NVR 接入。支持国标级联,支持将不带国标功能的摄像机/直播流/直播推流转发到其他国标平台。 流媒体服务基于@夏楚 ZLMediaKit [https://github.com/ZLMediaKit/ZLMediaKit](https://link.zhihu.com/?target=https%3A//github.com/ZLMediaKit/ZLMediaKit) 播放器使用@dexter jessibuca [https://github.com/langhuihui/jessibuca/tree/v3](https://link.zhihu.com/?target=https%3A//github.com/langhuihui/jessibuca/tree/v3) 前端页面基于@Kyle MediaServerUI [https://gitee.com/kkkkk5G/MediaServerUI](https://link.zhihu.com/?target=https%3A//gitee.com/kkkkk5G/MediaServerUI) 进行修改. 官网地址: [https://github.com/648540858/wv](https://link.zhihu.com/?target=https%3A//github.com/648540858/wvp-GB28181-pro) ![20241229154732_CtvQE7i1.webp](https://cdn.dong4j.site/source/image/20241229154732_CtvQE7i1.webp) **主要特性:** - 实现标准的 28181 信令,兼容常见的品牌设备,比如海康、大华、宇视等品牌的 IPC、NVR 以及平台。 - 支持将国标设备级联到其他国标平台,也支持将不支持国标的设备的图像或者直播推送到其他国标平台 - 前端完善,自带完整前端页面,无需二次开发可直接部署使用。 - 完全开源,且使用 MIT 许可协议。保留版权的情况下可以用于商业项目。 - 支持多流媒体节点负载均衡。 ### 6.2 LiveGBS LiveGBS 国标 GB/T28181 流媒体服务器软件,支持设备|平台 GB28181 注册接入、向上级联第三方国标平台, 可视化的 WEB 页面管理(页面源码开源);支持云台控制、设备录像检索、回放,支持语音对讲,用户管理, 多种协议流输出,实现浏览器无插件直播。 官网地址: [https://gbs.liveqing.com/docs/products/LiveGBS.html](https://gbs.liveqing.com/docs/products/LiveGBS.html) ![20241229154732_RPzdHD7W.webp](https://cdn.dong4j.site/source/image/20241229154732_RPzdHD7W.webp) **主要特性:** 1. LiveGBS 国标 (GB28181) 流媒体服务软件 2. 支持各个版本的 GB28181 协议; 3. 提供用户管理及 Web 可视化页面管理; 4. 提供设备状态管理,可实时查看设备是否掉线等信息; 5. 实时流媒体处理,PS(TS)转 ES; 6. 设备状态监测、云台控制、录像检索、回放; 7. 提供 RTSP、RTMP、HTTP-FLV、HLS 等多种协议流输出; 8. 对外提供服务器获取状态、信息,控制等 HTTP API 接口; 9. 企业私有云部署,支持 Linux & Windows; ![20241229154732_IywNDkIH.webp](https://cdn.dong4j.site/source/image/20241229154732_IywNDkIH.webp) ### 6.3 AeroGBD 基于 GB/T 28181-2016 协议标准,提供标准接入、标准媒体、标准应用、标准共享的国标服务能力。能够接入符合 GB/T 28181-2016 的视频监控平台、硬盘录像机、摄像机、无人机、机器人等市面各类厂家设备;能够实现按照 GB/T 28181-2016 方式进行国标共享输出,提供实时视频、下级/前端录像、系统管理等应用功能,具有联网能力强、开放兼容、易用便捷等的特点。适用于智慧城市、雪亮工程、行业视频联网等各行业视频标准联网接入场景。 ![20241229154732_Yl2AZeCG.webp](https://cdn.dong4j.site/source/image/20241229154732_Yl2AZeCG.webp) **主要特性:** 1. **接入性能强:**单套支持接入>10 个下级平台,>2 万路设备,可集群扩容; 2. **共享性能强:**单套支持>10 个上级共享推送,可集群扩容; 3. **视频并发高:**单套支持 500 路视频并发,可集群扩容; 4. **解码性能:**客户端最大支持 9 路视频同屏播放,2K 视频不低于 2 路; 5. **视频开流:**实时视频开流速度小于 3 秒;前端/下级录像调阅开流小于 3 秒。 6. **录像开流:** 下级平台录像开流小于 3 秒; 7. **用户并发:** 单套不低于 100 个用户并发使用; 8. **部署安装:** 一台起步,一键安装部署,单次标准服务器部署时间小于 10 分钟。 ### 6.4 EasyGBS EasyGBS 国标视频云服务提供流转发服务,负责将 GB28181 设备/平台推送的 PS 流转成 ES 流,然后提供 RTSP、RTMP、FLV、HLS 多种格式进行分发,实现 web 浏览器、手机浏览器、微信、PC 客户端等各种终端无插件播放。 ![20241229154732_oWxv8mHR.webp](https://cdn.dong4j.site/source/image/20241229154732_oWxv8mHR.webp) **主要特性:** 1. **可视化页面**: 提供用户管理及 web 可视化页面管理,及录像检索、回放 2. **设备状态管理**: 提供设备状态管理,可实时查看设备是否掉线等信息 3. **实时流处理**: 实时流媒体处理,PS(TS)转 ES,提供音视频转码能力 4. **云台控制**: 基于动态组网服务创建智能网络,按需选择需要组网的网络成员实现点点互联 5. **多种流输出**: 提供 RTSP、RTMP、HTTP-FLV、HLS 等多种协议流输出 6. **API 接口**: 对外提供服务器获取状态、信息,控制 HTTP API 接口 ### 6.5 NTV GBS NTV GBS 是一款成熟、功能完善、产品化程度很高的 GB28181 服务平台,从 2022 年推出以来迅速获得各技术平台报道转发,因其界面简洁舒畅、操作简单获得众多大小用户青睐。 NTV GBS 提供云服务和独立部署两种使用模式,云服务方式注册账号就可以使用,而且提供免费流量供使用;独立部署方式需要自己购买设备安装该软件。 官网地址: [GB28181 视频监控国标平台 - NTV GBS](https://link.zhihu.com/?target=http%3A//www.ntvgbs.com/%3Ff%3Dzh) ![20241229154732_PbEm8IGB.webp](https://cdn.dong4j.site/source/image/20241229154732_PbEm8IGB.webp) **主要功能:** 1. 视频监控设备远程接入、远程管理、云台控制和视频调阅,云端录像和回看 2. 支持各型号 GB28181 国标摄像头 IPC 和硬盘录像机 NVR/HVR/DVR 3. 支持各类 rtmp/rtsp 支持直播推流摄像机,兼容 ONVIF 协议 4. 视频接入分析服务,用于各种智慧应用场景,重新定义监控视频价值 5. 在网页、小程序、业务平台中无插件播放,提供 rtmp/rtsp/hls/http/websocket 播出地址 6. 支持**H265**视频编码,更省流量更流畅 7. 多级平台级联对接,实现上下级平台监控视频融合汇聚 ### 6.6 AKStream AKStream 是一套全功能的软 NVR 接口平台,软 NVR 指的是软件定义的 NVR(Network Video Recoder),AKStream 经过长达一年半的开发,测试与调优,已经具备了一定的使用价值,在可靠性,实用性方面都有着较为不错的表现,同时因为 AKStream 是一套完全开源的软件产品,在众多网友的一起加持下,AKStream 的安全性也得到了验证。 AKStream 集成了 ZLMediaKit 作为其流媒体服务器,AKStream 支持对 ZLMediaKit 的集群管理(通过 AKStreamKeeper-流媒体治理组件),可以将分布在不同服务器的多个 ZLMediaKit 集群起来,统一管理,统一调度。 官网地址:[https://github.com/chatop2020/AKStream](https://github.com/chatop2020/AKStream) ![20241229154732_dRpr9eti.webp](https://cdn.dong4j.site/source/image/20241229154732_dRpr9eti.webp) **主要特性:** 1. 控制台(流媒体服务性能监控) 2. 视频广场(多视频) 3. 设备管理 4. 录像计划(本地录制) 5. 录像回放(GB28181 和本地录制) 6. 视频推流 7. 视频拉流 8. 视频本地录制 9. 云台功能 ### 6.7 LiteCVR ![20241229154732_CeJAULOF.webp](https://cdn.dong4j.site/source/image/20241229154732_CeJAULOF.webp) LiteCVR 平台支持国标 GB/T28181、RTMP、RTSP/Onvif 协议等,以及海康 SDK、大华 SDK、海康 Ehome 等厂家私有协议,也支持标准的 API 开发接口,可集成至移动端 APP、小程序、其他业务平台播放,并提供分享链接和 iframe 地址,可直接在浏览器播放。 > 综合对比下来, 最终选择 WVP 平台作为视频接入平台, 主要原因如下: > > 1. WVP 平台全部开源且开源协议友好; > 2. WVP 平台的功能较为完善, 且有整套平台, 包括管理端, SIP 服务和流媒体服务; > 3. WVP 流媒体服务使用业内较为流行且社区活跃的 ZLMediaKit; > 4. WVP 管理端使用 Spring Boot 开发, 可以快速的进行二次开发; ## 7. WVP WEB VIDEO PLATFORM 是一个基于 GB28181-2016 标准实现的开箱即用的网络视频平台,负责实现核心信令与设备管理后台部分,支持 NAT 穿透,支持海康、大华、宇视等品牌的 IPC、NVR 接入。支持国标级联,支持将不带国标功能的摄像机/直播流/直播推流转发到其他国标平台。 **项目地址**:[wvp-GB28181-pro](https://gitee.com/pan648540858/wvp-GB28181-pro) [文档地址](https://doc.wvp-pro.cn/#/) ### 7.1 应用场景 - 支持浏览器无插件播放摄像头视频。 - 支持摄像机、平台、NVR 等设备接入。 支持国标级联。 - 支持 rtsp/rtmp 等视频流转发到国标平台。 - 支持 rtsp/rtmp 等推流转发到国标平台。 ### 7.2 主要特性 - 实现标准的 28181 信令,兼容常见的品牌设备,比如海康、大华、宇视等品牌的 IPC、NVR 以及平台。 - 支持将国标设备级联到其他国标平台,也支持将不支持国标的设备的图像或者直播推送到其他国标平台 - 前端完善,自带完整前端页面,无需二次开发可直接部署使用。 - 完全开源,且使用 MIT 许可协议。保留版权的情况下可以用于商业项目。 - 支持多流媒体节点负载均衡。 ### 7.3 基础功能 1. 视频预览; 2. 云台控制(方向、缩放控制); 3. 视频设备信息同步; 4. 离在线监控; 5. 录像查询与回放(基于 NVR/DVR,暂不支持快进、seek 操作); 6. 无人观看自动断流; 7. 支持 UDP 和 TCP 两种国标信令传输模式; 8. 集成 web 界面, 不需要单独部署前端服务, 直接利用 wvp 内置文件服务部署, 随 wvp 一起部署; 9. 支持平台接入, 针对大平台大量设备的情况进行优化; 10. 支持检索,通道筛选; 11. 支持自动配置 ZLM 媒体服务, 减少因配置问题所出现的问题; 12. 支持启用 udp 多端口模式, 提高 udp 模式下媒体传输性能; 13. 支持通道是否含有音频的设置; 14. 支持通道子目录查询; 15. 支持 udp/tcp 国标流传输模式; 16. 支持直接输出 RTSP、RTMP、HTTP-FLV、Websocket-FLV、HLS 多种协议流地址 17. 支持国标网络校时 18. 支持公网部署, 支持 wvp 与 zlm 分开部署 19. 支持播放 h265, g.711 格式的流(需要将 closeWaitRTPInfo 设为 false) 20. 报警信息处理,支持向前端推送报警信息 ### 7.4 部署 WVP 平台由 管理平台, SIP 服务和流媒体服务组成, 关系图如下: ![wvp.drawio.svg](https://cdn.dong4j.site/source/image/wvp.drawio.svg) 1. 使用 Spring 实现了 SIP 协议, 便于二次开发; 2. 使用 MySQL 存储结构化数据; 3. 使用 Redis 存储注册信息; 4. 流媒体服务器使用开源的高性能 ZLMediaKit 实现; 5. 使用 assist 服务用于存储视频并提供历史视频查看; WVP 平台部署可是使用 2 种方式, 一是使用源码自行编译部署, 二是使用已有 docker 镜像一键部署, 但是官方 docker 镜像最后更新与 2 年前, 因此最好的方式是使用源码编译部署, 不过为了可移植性和方便快捷的安装部署, 这里考虑使用源码打包为 docker 镜像安装部署, 具体过程后续再完善补充. **数据流向** ![数据流.drawio.svg](https://cdn.dong4j.site/source/image/%E6%95%B0%E6%8D%AE%E6%B5%81.drawio.svg) **实时视频流:** 1. 业务系统向 WVP 管理平台发起观看实时视频的请求; 2. WVP 接收到请求后, 根据设备编码和通道号查询设备是否存在, 然后将编码成 RTSP 视频流协议并调用流媒体服务器进行视频流转码; 3. 流媒体服务器接收到请求后转换成 H5 能够直接播放的视频流地址, 比如 mp4/FLV 等格式; **历史视频** 1. 流媒体服务器会将实时视频全部存储到服务器, 供业务端查询历史视频; 2. 流媒体服务器会将历史视频存储到 NAS; 3. IPC/执法记录仪等支持 FTP 的设备, 可以将本地的历史视频传输到 NAS 的 FTP 服务器; ### 7.5 使用 目前已在公司内部服务器使用最新的源码安装部署了一套 WVP 平台, 访问地址为: http://10.100.10.135:18080/#/login, 登录账号与密码: admin/admin. #### 7.5.1 控制台 能够展示服务器主要资源的使用情况以及设备接入数量: ![20241229154732_uk6e6kTz.webp](https://cdn.dong4j.site/source/image/20241229154732_uk6e6kTz.webp) #### 7.5.2 视频墙 自带 1/4/9 个视频窗口, 如果需要更多窗口需要二开 ![20241229154732_1bYIRYDQ.webp](https://cdn.dong4j.site/source/image/20241229154732_1bYIRYDQ.webp) #### 7.5.3 设备管理 目前已接入智慧工地的 IVSS, 公司内部的 NVR 和一个 4G IPC: ![20241229154732_05vro0nZ.webp](https://cdn.dong4j.site/source/image/20241229154732_05vro0nZ.webp) 实时视频: ![20241229154732_PoX5ufi9.webp](https://cdn.dong4j.site/source/image/20241229154732_PoX5ufi9.webp) 在点播视频后, 会将视频存储到服务器: ![20241229154732_Wzk1RDR2.webp](https://cdn.dong4j.site/source/image/20241229154732_Wzk1RDR2.webp) 也可以查看和下载 NVR 或 IPC 存储卡中的历史视频: ![20241229154732_fdR6jGCP.webp](https://cdn.dong4j.site/source/image/20241229154732_fdR6jGCP.webp) 云台控制: ![20241229154732_OFughEBk.webp](https://cdn.dong4j.site/source/image/20241229154732_OFughEBk.webp) 另一个有用的功能是电子地图, 不过目前没有页面可以直接设置, 如果后期需要可以进行二开: ![20241229154732_pWdpx07u.webp](https://cdn.dong4j.site/source/image/20241229154732_pWdpx07u.webp) #### 7.5.4 拉流代理 可以将不具备 GB/T28181 协议但是又固定 IP 的视频设备接入到此平台: ![20241229154732_0BLonsNd.webp](https://cdn.dong4j.site/source/image/20241229154732_0BLonsNd.webp) ### 7.6 设备对接 #### 7.6.1 服务端 设备接入主要是需要在设备上配置 28181 上级也就是 WVP 的信息,只有信息一致的情况才可以注册成功。设备注册成功后打开 WVP->国标设备,可以看到新增加的设备; 主要有以下字段需要配置: - sip->ip 本机 IP,不要使用 127.0.0.1/0.0.0.0, 除非你对项目及其熟悉 - sip->port 28181 服务监听的端口 - sip->domain domain 宜采用 ID 统一编码的前十位编码。 - sip->id 28181 服务 ID - sip->password 28181 服务密码 - 配置信息在如下位置 ![20241229154732_qgjUmtFS.webp](https://cdn.dong4j.site/source/image/20241229154732_qgjUmtFS.webp) #### 7.6.2 客户端 SIP 客户端设置基本一致, 这里以大华 NVR 为例: ![20241229154732_AV3XWako.webp](https://cdn.dong4j.site/source/image/20241229154732_AV3XWako.webp) 因为 NVR 与 WVP 平台部署在同一个局域网内, 因此 NVR 配置的 SIP 服务器 IP 为局域网 IP, 如果通过公网访问, SIP 服务器 IP 则配置为公网固定 IP. ### 7.7 业务系统对接 前面已经讲过, WVP 可以作为视频接入层, 负责支持 GB/T28181 协议的视频设备的接入管理, 且能够为上层业务提供多种格式的视屏流, 比如: ![20241229154732_oVR2eFvq.webp](https://cdn.dong4j.site/source/image/20241229154732_oVR2eFvq.webp) 业务系统只需要调用 API 获取视频流地址即可接入视频, 避免了对接多家厂商设备的烦恼, 且如果后期更换了不同厂商的视频设备, 业务系统不需要任何修改, 只需要在 WVP 平台中接入即可. 在原有视频设备组网方式不变的情况下, 下面 3 个系统可以根据自身业务情况选择是否将现有视频接口使用 WVP API 替换. #### 7.7.1 配电监测对接 配电监测目前已使用 ZLMediaKit 代替原来的 ICC 作为流媒体服务器,并将 NVR 中的录像使用 RTSP 推送到流媒体服务器并转换成 mp4 格式, 然后由前端的 EasyPlayer 播放器播放, 目前支持实时预览和历史回看. 因为现在已经将 NVR 接入到 WVP 平台, 因此只需简单的修改即可接入视频: 1. 在配电监测平台将摄像头的设备号与通道号与摄像头绑定 (通道编号 + 设备编号唯一确认可使用的通道); ![20241229154732_5icH1XyW.webp](https://cdn.dong4j.site/source/image/20241229154732_5icH1XyW.webp) 2. 在 WVP 平台中根据摄像头的设备号与通道号获取实时视频流地址; ![20241229154732_M2mSNZkd.webp](https://cdn.dong4j.site/source/image/20241229154732_M2mSNZkd.webp) 一次返回所有能使用的 url, 方便前端各种播放器对接 3. 在 WVP 平台中根据摄像头的设备号与通道号获取录像列表; ![20241229154732_39EelAbb.webp](https://cdn.dong4j.site/source/image/20241229154732_39EelAbb.webp) 此接口用于返回 NVR 或 IPC 本地存储卡上的录像列表 4. 使用录像播放接口播放最近的录像: 根据录像开始时间和结束时间点播录像. #### 7.7.2 作业管控平台对接 作业管控平台对接同样使用配电监测对接的逻辑, 主要使用以下 3 个接口: 1. 根据设备号和通道号获取实时播放 url; 2. 根据设备号和通道号获取录像列表; 3. 根据设备号, 通道号, 录像开始时间和录像结束时间点播录像; 如果需要对球机进行控制, 还需要额外接入球机控制接口. #### 7.7.3 大屏对接 大屏对接分为两种情况: 1. 大屏使用业务系统的接口获取实时视频或历史回看; 2. 大屏直接使用 WVP 的接口; 上面 2 种方式可以根据业务需求自行选择, 不过如果需要使用第二种方式, 前端需要确定设备号和通道号是否正确; #### 7.7.4 小程序对接 小程序对接和大屏对接一样, 2 种方式都支持, 不过推荐使用第一种方式, 因为业务系统会维护设备号和通道号, 小程序只需要使用自身的业务 id 即可获取视频 url, 播放器推荐使用 HLS 协议, 支持 Android 和 iOS. > 总的来说, 业务系统接入 WVP 视频接口工作量较小, 且 对现有业务系统影响较小, 新的业务可以直接对接 WVP. ### 7.8 大屏展示 WVP 平台自带分屏展示功能, 但是最多只支持 9 分屏, 如果需要增加分屏显示, 目前有 2 种方式: 1. 对 WVP 进行二次开发, 增加分屏数量; 2. 在业务大屏是直接开发分屏页面; 因为分屏逻辑相对简单, 无非就是在一个页面上多放几个播放器而已, 因此上述 2 种方式都能够快速实现, 所以可以根据自身业务需求自行选择, 比较推荐的做法: 1. 在调度中心使用 WVP 平台进行分屏展示; 2. 业务大屏使用自己的分屏功能展示多个视频; ## 8. 规划 1. 接管所有视频监控设备, 在一个平台即可查看所有视频与录像, 各部门领导可通过视频墙实时查看各项目作业情况, 满足安全审计要求; 2. 通过 API 简化业务系统对视频监控需求的集成, 缩短开发时间; 为满足以上 2 个目标, 需要以下前提: 1. 设备需要支持 GB/T28181 协议; 2. 设备能够访问到平台 (通过公网或 VPN 均可); 3. 业务系统能够访问视频流服务器(通过公网或 VPN 均可); **平台架构图:** ![规划.drawio.svg](https://cdn.dong4j.site/source/image/%E8%A7%84%E5%88%92.drawio.svg) ### 8.1 资源规划 #### 8.1.2 硬件资源 目前使用 8C 16G 服务器, 在接入 3 个设备, 共计 95 视频通道的情况下, 内存占用 40%, 因为未进行任何点播视频, CPU 占用较低(CPU 主要用于视频编解码), 且因为每次点播后会存储视频, 因此随着时间的推移, 磁盘占用会越来越大, 因此考虑到后期接入的设备数量以及视频保存时间, 在 3 年内的至少满足 100 路视频的接入需求下, 对硬件资源进行如下评估: ##### 8.1.2.1 磁盘 ###### 1. 存储方式 云厂商提供的 OSS 服务能够解决诸如视频, 图像等文件的存储和调取服务, 收费以便按照空间大小收费, 因此这里不考虑云存储方案, 一是因为视频文件较大, 存储费用会较高; 二是视频这类文件较为敏感, 使用云存储无法保证数据安全. ###### 2. 容量评估 目前业务上已使用到的监控设备如下: 1. 重庆地铁 15 号线项目, IPC 数量 7 个, 历史录像直接存储到项目部上的 NVR 中; 2. 智慧工地项目, IPC 数量 280 个, 历史录像直接存储到项目部上的 IVSS 中; 3. 雷波矿山项目, IPC 数量在一年后计划安装 100 +, 历史录像直接存储到项目部上的 NVR 中; 4. 变电站, 场站上的 IPC 目前在用的只有昆仑先锋变电站和铁山坡一共 10 台, 历史录像存储到调度中心的 NVR 中; 5. 集团安全监控项目, IPC 数量为 30 台, 历史录像存储在设备的 TF 卡中; 6. 执法记录仪数量 30 台, 历史录像存储在设备的 TF 卡中; 7. 无人机数量 8 个, 历史录像存在设备的 TF 卡中; 接入 WVP 平台的设备录像分为两种模式: 1. 云端录像: 只有主动查看设备实时视频时才会录制, 视频文件存储在 WVP 服务器中; 2. 设备录像: 支持本地录制的设备所存储的录像, 如果是 NVR 则保存在 NVR 服务器中, 如果是 IPC 则保存在 TF 卡中; **设备录像:** 因为设备录像都是滚动录制, 所以需要评估一个作业周期内的录像大小, 这里说明一下 IPC 所需要的 TF 卡存储大小评估方式: 1. 作业持续时间, 比如一天只录制 8 个小时视频; 2. 视频流码率: 码率越大所需要的存储空间越大, 比如码率为 1Mbps; 3. 录像存储时间, 比如执法记录仪需要保存至少 7 天的数据; 按照上述要求估算本地存储空间: `1Mbps * 1024Kbps/8 * 60 秒 * 60 分 * 8 小时 * 7 天 * 1 通道/1024/1024≈ 25G`, 再加上一些元数据的存储空间, 所以建议至少配置 32G 的 TF 卡. NVR 自带磁盘阵列且有一套完善的录像存储和扩容机制, 因为不同的项目对视频储存的要求不一样, 所以 NVR 的存储空间最好根据自身业务需求让厂家推荐并配置合理的磁盘阵列, 因为 WVP 对应的都是 **云端录像**, 所以这里不对 NVR 的本地存储做讨论. **云端录像:** 只有在主动点播视频的情况下才会触发云端录像功能, 且录制的文件保存在 WVP 的服务器中, 为了在满足业务需求的前提下减少硬件投入, 这里对 WVP 所需的磁盘阵列进行大小评估: **1. 点播录像:** 1. 按照总共 100 个视频通道, 每天查看 10 次, 每次查看存储 10 分钟的录像; 2. 每个视频流码率统一为 1Mbps; 3. 录像至少保存 7 天; 计算公式如下: `1Mbps * 1024Kbps/8 * 60 秒 * 10 分钟 * 10 次 * 7 天 * 100 通道/1024/1024 ≈ 512G` **2. 录像备份** 像执法记录仪这类具备录像文件上报的设备, 会通过 FTP 将录像文件上传到 WVP 服务器, 这类设备的所需的磁盘大小评估方式为: 1. 从作业管控-作业计划管控-记录仪录像可以看出, 10 分钟的视屏占用 300M; 2. 按照一天工作 8 小时计算; 3. 录像至少保存 7 天; 计算公式如下: `300M * 6 * 8 小时 * 7 天 * 30 个设备/1024/1024 ≈ 3T` 结合点播录像和录像备份的场景, 至少应该准备 4T 的磁盘, 且为了保证数据安全, 最少需要 2 块 4T 组成 RAID1, 至少保证在一块磁盘损坏的情况下数据不会丢失. ###### 3. 扩容方式 磁盘阵列首先使用 2 块 4T 磁盘组成 RAID1: > **RAID 1**: 2 块硬盘或以上,利用镜像技术,在损失不超过一个成员磁盘时提供容错功能。 > 优势:数据安全性最高,一块坏了,其它硬盘有完整数据,保障运行。 > 缺点:容量低,硬盘使用率为 50% 可用空间为 4T, 但是能够在一块磁盘损坏的情况下保证数据不丢失, 在后期如果磁盘空间不足, 需要进行无损扩容来保证业务的连续性, 这里给出几种扩容方案: **方案一:** 增加一块 4T 磁盘, 使用总共 3 块 4T 磁盘从 RAID1 迁移到 RAID5, 磁盘阵列可用容量从 4T 增加到 8T; > **RAID 5**: 3 块硬盘或以上,同时使用条带和奇偶校验技术。提供与 RAID0 相似的读取速度,在损失不超过一个成员磁盘时仍能正常运行。 > 优势:RAID5 兼顾 RAID0 与 RAID1 的优势。RAID5 最少需要三块硬盘,通用是用 4 块硬盘,其中有一块硬盘用来做数据冗余的,换新硬盘,系统会自动进行数据同步。 > 缺点:只允许单盘故障,一盘出现故障尽快处理。有盘坏情况下,IO/CPU 性能狂跌。 **方案二:** 增加 2 块 4T 磁盘, 从 RAID1 迁移到 RAID6; > **RAID 6**:4 块硬盘或以上,类似 RAID5,对数据安全性要求高,性能要求不高的可选择 > 优势:RAID6 是在 RAID5 基础上为加强数据保护而设计的。可允许损坏 2 块硬盘 > 缺点:性能提升不明显 ![20241229154732_WJZluMXm.webp](https://cdn.dong4j.site/source/image/20241229154732_WJZluMXm.webp) ![20241229154732_LSPTz6Jv.webp](https://cdn.dong4j.site/source/image/20241229154732_LSPTz6Jv.webp) ##### 8.1.2.2 服务器 **服务部署架构:** ![部署方案.drawio.svg](https://cdn.dong4j.site/source/image/%E9%83%A8%E7%BD%B2%E6%96%B9%E6%A1%88.drawio.svg) 需要部署的服务列表: | 服务名 | 所需资源 | 备注 | | ------------ | ---------- | -------------------------------- | | Nginx | 4C/1G/10G | 反向代理服务器 | | WVP 管理系统 | 4C/2G/100G | 视频管理 | | 流媒体服务 | 8C/8G/200G | 视频流转换, 拉流, 推流等 | | 录像服务 | 4C/2G/100G | 与流媒体部署在一起的用于点播录像 | | 缓存服务 | 2C/1G/10G | WVP 和 流媒体服务器使用的缓存 | | 数据库服务 | 8C/8G/200G | WVP 管理端使用的结构化数据存储 | 以上服务可以按照功能和所需资源大小部署到 2 台服务器中: | 硬件名称 | 配置 | 备注 | | ----------------------------------- | --------------- | ------------------------------------------- | | 数据库与缓存服务器 + 管理平台服务器 | 32C65G 1T | WVP 结构化数据与缓存服务 + WVP 管理平台服务 | | 流媒体服务器 | `32C64G *2 500G` | 流媒体服务器 | #### 8.1.3 网络资源 假设同时查看 32 路摄像头, 分比率统一为 1080P, 帧率为 25 fps, 视频编码类型为 H.265, 需要的带宽为: `(1920 * 1080 * 24 位色 * 25 * 32)/300压缩率/1024/1024 * 1.3带宽系数 ≈ 127M` 为保证更多数量的通道同时查看的需求, 最好使用 `200M` 的带宽. **硬件清单:** | 品类 | 型号 | 配置 | 数量 | 单价 | | -------- | -------------- | ------------------------------ | ---- | ----- | | 服务器 | DELL R750XS | 1U 共计 16 核 32 线程 64G 内存 | 2 | 26499 | | 固态硬盘 | 企业级固态 | 1.92T | 6 | 1500 | | CPU | 至强 Xeon-银牌 | 16 核 32 线程 | 2 | 4500 | | NAS | 群晖 DS923+ | `4*8T` | 1 | 11146 | 主机: DELL R750XS CPU: 4310 2 颗 内存: 16G 磁盘: `1.92T * 4` 阵列: H755 电源: 800W 一个 `26499 * 2 + 1500 * 6 + 4500 * 2 + 11146 = 82144` **2023-12-18 今日最新价格, 服务器 2U + 固态硬盘, 单台 37000 元**, 因此总价格变更为: `37000 * 2 + 11146 = 85146 元` 后期优化方案: | 企业交换机 | 华为交换机万兆三层核心 S5731S-S24T4X-A | 24 口千兆电 4 口万兆光汇聚全网管 | 1 | 8640 | | -------------- | -------------------------------------- | ---------------------------------- | --- | ----- | | 企业防火墙 | 华为企业级防火墙 USG6315E-AC | 带机 600|吞吐 1G|支持 500 条 VPN | 1 | 19999 | | 服务器万兆网卡 | X710 | 英特尔 X710 双端口 10GB (电口) | 2 | 1599 | | NAS-万兆网卡 | NAS 专用 RJ45 万兆单电口网卡 | 10GB | 1 | 1160 | #### 8.1.4 网络拓扑图 方案一: ![20241229154732_luDJhvo9.webp](https://cdn.dong4j.site/source/image/20241229154732_luDJhvo9.webp) ##### 方案二 使用万兆交换机组万兆局域网 ![20241229154732_R2loOIvu.webp](https://cdn.dong4j.site/source/image/20241229154732_R2loOIvu.webp) 1. 因为视频存储文件较大, 为加快文件访问速度以提升服务体验, 内网使用万兆通讯, 需要分别为服务器和 NAS 添加万兆网卡. 市面上万兆电口的交换器较少, 且电口发热量大, 目前选用万兆光口交换机; NAS 只支持万兆电口网卡, 因此需要增加一个万兆光转电口; 2 台服务器使用万兆光口接入交换机. 2. 其他千兆口用于接入其他客户端; 3. 流媒体服务器通过万兆交换机将视频文件存储到 NAS; ##### 方案三 使用千兆交换机, 小范围组万兆内网 ![20241229154732_BcMLwJMa.webp](https://cdn.dong4j.site/source/image/20241229154732_BcMLwJMa.webp) 1. 使用 NAS 自带千兆网口和 2 台服务器通过千兆交换机组成局域网, 其他客户端通过交换机与服务器和 NAS 连接; 2. 内部使用万兆网卡直连, 让服务器和 NAS 之间组成内部网络, 加快文件传输速度; **方案二** 比 **方案三** 组网方式简单, 但是价格且多出一个光转电接口, 整体价格较方案二贵. NAS 视频备份: ![20241229154732_H6Nvn6Vz.webp](https://cdn.dong4j.site/source/image/20241229154732_H6Nvn6Vz.webp) ![20241229154732_HZQ9jRyE.webp](https://cdn.dong4j.site/source/image/20241229154732_HZQ9jRyE.webp) ![20241229154732_nAOChXlw.webp](https://cdn.dong4j.site/source/image/20241229154732_nAOChXlw.webp) 群晖 DS923+ 自带 2 个千兆网口, 可再扩展一个万兆网卡. 服务器: ![20241229154732_b9ikqzmd.webp](https://cdn.dong4j.site/source/image/20241229154732_b9ikqzmd.webp) ## 脑图 ![20241229154732_kego3KSs.webp](https://cdn.dong4j.site/source/image/20241229154732_kego3KSs.webp) ## [IDEA插件开发中的国际化配置实践:轻量级ResourceBundle应用](https://blog.dong4j.site/posts/c1e0543.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 缘起 最近一段时间在开发 IDEA 插件, UI 界面需要使用到国际化配置, 于是就看了看 IDEA 是怎么实现的, 发现很简单, 正好能用到框架开发上. 打算为每个 `atom-kernel` 模块配置一个国际化配置, 同时将错误信息配置化. 因为是框架底层的组件, 如果使用 Spring Boot 的 i18n 实现就太重了, 因此需要一种超轻量级的实现方式. ## IDEA 中如何实现 i18n IDEA 使用 `ResourceBundle` 这个类实现了 i18n, 源码如下: ```java /** * 特定作用域捆绑包的基类(例如“vcs”捆绑包、“aop”捆绑包等)。 * 使用模式: * 创建一个扩展该类并为当前类构造函数提供目标bundle路径的类; * 可选地在子类中创建静态facade方法-创建单个共享实例并委托给它的getMessage(String,Object…) * * @author Denis Zhdanov */ public abstract class AbstractBundle { private static final Logger LOG = Logger.getInstance("#com.intellij.AbstractBundle"); private Reference myBundle; @NonNls private final String myPathToBundle; protected AbstractBundle(@NonNls @NotNull String pathToBundle) { myPathToBundle = pathToBundle; } @NotNull public String getMessage(@NotNull String key, @NotNull Object... params) { // CommonBundle.message() 主要用于参数替换与空值处理, 快捷键标识等 return CommonBundle.message(getBundle(), key, params); } private ResourceBundle getBundle() { ResourceBundle bundle = com.intellij.reference.SoftReference.dereference(myBundle); if (bundle == null) { bundle = getResourceBundle(myPathToBundle, getClass().getClassLoader()); myBundle = new SoftReference<>(bundle); } return bundle; } private static final Map> ourCache = ConcurrentFactoryMap.createWeakMap(k -> ContainerUtil.createConcurrentSoftValueMap()); /** * 使用 ResourceBundle.Control 来实现 i18n */ public static ResourceBundle getResourceBundle(@NotNull String pathToBundle, @NotNull ClassLoader loader) { Map map = ourCache.get(loader); ResourceBundle result = map.get(pathToBundle); if (result == null) { try { ResourceBundle.Control control = ResourceBundle.Control.getControl(ResourceBundle.Control.FORMAT_PROPERTIES); result = ResourceBundle.getBundle(pathToBundle, Locale.getDefault(), loader, control); } catch (MissingResourceException e) { LOG.info("Cannot load resource bundle from *.properties file, falling back to slow class loading: " + pathToBundle); ResourceBundle.clearCache(loader); result = ResourceBundle.getBundle(pathToBundle, Locale.getDefault(), loader); } map.put(pathToBundle, result); } return result; } } ``` **实现自己的 Bundle 类:** ```java public class IdeBundle extends AbstractBundle { public static String message(@NotNull @PropertyKey(resourceBundle = BUNDLE) String key, @NotNull Object... params) { return INSTANCE.getMessage(key, params); } public static final String BUNDLE = "messages.IdeBundle"; private static final IdeBundle INSTANCE = new IdeBundle(); private IdeBundle() { super(BUNDLE); } } ``` **添加配置文件:** 还需要在 classpath 中添加一个 messages 目录, 然后添加 `IdeBundle.properties` 配置文件, 或者可以直接使用 IDEA 新增资源包, 需要注意的是, `messages.IdeBundle` 表示在 `messages` 目录下的 `IdeBundle.properties` 文件, 一定不能错: ```properties error.malformed.url=Malformed url: {0} browsers.explorer=Internet Explorer ``` **使用方式:** ```java # 有占位符的 IdeBundle.message("error.malformed.url", "xxx") # 无占位符 IdeBundle.message("browsers.explorer") ``` 从上面可以看出, 直接 JDK 自带的 `ResourceBundle` 类实现了国际化和占位符替换的功能, 刚好符合我的要求, 因此打算使用这种方式来实现一下. ## 实现逻辑 我需要的功能: 1. 国际化; 2. 占位符替换; IDEA 的 `AbstractBundle` 有很多我不需要的功能, 做了一些简单的修改后完全符合我的要求: ```java @Slf4j public abstract class AbstractBundle { private static final Map> CACHE = ConcurrentFactoryMap.createWeakMap(k -> new ConcurrentSoftValueHashMap<>()); @NonNls private final String myPathToBundle; private Reference myBundle; @Contract(pure = true) protected AbstractBundle(@NonNls @NotNull String pathToBundle) { this.myPathToBundle = pathToBundle; } /** * 获取延迟加载的字符串 * * @param key 键 * @param params 参数 * @return Supplier字符串 */ @NotNull public Supplier getLazyMessage(@NotNull String key, Object... params) { return () -> this.getMessage(key, params); } /** * 获取字符串 * * @param key 键 * @param params 参数 * @return 字符串 */ @NotNull public String getMessage(@NotNull String key, Object... params) { return message(this.getResourceBundle(), key, params); } /** * 获取本地化字符串 * * @param bundle 资源包 * @param key 键 * @param params 参数 * @return 字符串 */ @Nls @NotNull public static String message(@NotNull ResourceBundle bundle, @NotNull String key, Object... params) { return messageOrDefault(bundle, key, null, params); } /** * 获取资源包 * * @return 资源包 */ public ResourceBundle getResourceBundle() { ResourceBundle bundle = SoftReference.dereference(this.myBundle); if (bundle == null) { bundle = this.getResourceBundle(this.myPathToBundle, this.getClass().getClassLoader()); this.myBundle = new SoftReference<>(bundle); } return bundle; } /** * 获取资源包 * * @param pathToBundle 资源包路径 * @param loader 类加载器 * @return 资源包 */ @NotNull public ResourceBundle getResourceBundle(@NotNull String pathToBundle, @NotNull ClassLoader loader) { Map map = CACHE.get(loader); ResourceBundle result = map.get(pathToBundle); if (result == null) { try { ResourceBundle.Control control = ResourceBundle.Control.getControl(ResourceBundle.Control.FORMAT_PROPERTIES); result = this.findBundle(pathToBundle, loader, control); } catch (MissingResourceException e) { log.info("无法从 *.properties 文件中加载资源包,降级为慢的类加载: " + pathToBundle); ResourceBundle.clearCache(loader); result = ResourceBundle.getBundle(pathToBundle, Locale.getDefault(), loader); } map.put(pathToBundle, result); } return result; } /** * 查找资源包 * * @param pathToBundle 资源包路径 * @param loader 类加载器 * @param control 控制 * @return 资源包 */ protected ResourceBundle findBundle(@NotNull String pathToBundle, @NotNull ClassLoader loader, @NotNull ResourceBundle.Control control) { return ResourceBundle.getBundle(pathToBundle, Locale.getDefault(), loader, control); } /** * 获取本地化字符串或默认值 * * @param bundle 资源包 * @param key 键 * @param defaultValue 默认值 * @param params 参数 * @return 字符串 */ public static String messageOrDefault(@Nullable ResourceBundle bundle, @NotNull String key, @Nullable String defaultValue, @NotNull Object... params) { if (bundle != null) { String value; try { value = bundle.getString(key); } catch (MissingResourceException e) { value = useDefaultValue(bundle, key, defaultValue); } return postprocessValue(bundle, value, params); } return defaultValue; } /** * 使用默认值 * * @param bundle 资源包 * @param key 键 * @param defaultValue 默认值 * @return 字符串 */ @NotNull static String useDefaultValue(ResourceBundle bundle, @NotNull String key, @Nullable String defaultValue) { if (defaultValue != null) { return defaultValue; } log.error("在资源包中 {} 未找到键: [{}]", bundle.getBaseBundleName(), key); return StringPool.NULL_STRING; } /** * 后处理值 * * @param bundle 资源包 * @param value 值 * @param params 参数 * @return 后处理后的值 */ static String postprocessValue(@NotNull ResourceBundle bundle, @NotNull String value, @NotNull Object @NotNull [] params) { if (params.length > 0 && value.indexOf('{') >= 0) { if (value.contains("{0")) { Locale locale = bundle.getLocale(); try { MessageFormat format = locale != null ? new MessageFormat(value, locale) : new MessageFormat(value); OrdinalFormat.apply(format); value = format.format(params); } catch (IllegalArgumentException e) { value = "!format 错误: `" + value + "`!"; } } else { value = StrFormatter.format(value, params); } } return value; } } ``` 创建绑定类: ```java public final class CoreBundle extends DynamicBundle { @NonNls private static final String BUNDLE = "i18n.CoreBundle"; private static final CoreBundle INSTANCE = new CoreBundle(); @Contract(pure = true) private CoreBundle() { super(BUNDLE); } @NotNull public static String message(@NotNull String key, Object... params) { return INSTANCE.getMessage(key, params); } public static @NotNull Supplier messagePointer(@NotNull String key, Object... params) { return INSTANCE.getLazyMessage(key, params); } } ``` 上面的代码基本上是固定的写法, 只需要修改 `BUNDLE` 即可, 然后创建国际化配置文件: ![20241229154732_JF2bLLEZ.webp](https://cdn.dong4j.site/source/image/20241229154732_JF2bLLEZ.webp) **测试一下:** ```java @Slf4j class CoreBundleTest { @Test void test_1() { // 有占位符但是没有参数, 占位符原样输出 log.info("{}", CoreBundle.message("code.param.verify.error")); // 不存在的 key log.info("{}", CoreBundle.message("aaa")); // 正常输出 log.info("{}", CoreBundle.messagePointer("code.param.verify.error", "aaaaa").get()); } } ``` 输出: ``` [main] INFO io.github.atom.kernel.core.CoreBundleTest - 参数校验失败: [{}] [main] ERROR io.github.atom.kernel.core.bundle.AbstractBundle - 在资源包 [i18n.CoreBundle] 未找到键: [aaa] [main] INFO io.github.atom.kernel.core.CoreBundleTest - N/A [main] INFO io.github.atom.kernel.core.CoreBundleTest - 参数校验失败: [aaaaa] ``` ## [通用的告警通知方案设计](https://blog.dong4j.site/posts/ca466aa0.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) > 监控告警模块用于实时监控各类设备数据,通过采集、分析和处理数据,生成有价值的指标和警报信息,并向管理员发送通知,确保业务稳定运行。 ### 1.1 目标 监控告警模块用于实时监控各类设备数据,通过采集、分析和处理数据,生成有价值的指标和警报信息,并向管理员发送通知,确保业务稳定运行。 - **保持业务稳定:**监控告警模块需要实时监控设备运行状态,并能够及时发现问题和异常情况,及时发出告警通知并迅速响应异常,以便管理员采取及时措施。 - **改善服务质量:**监控告警模块支持对设备数据进行采集和分析,生成有价值的指标和警报信息,及时发现并解决问题,避免用户受到影响,提高用户体验。例如通过监控设备的在线率,当出现大规模的设备离线时,能第一时间介入解决问题,避免设备因故障导致客户业务受阻。 ### 1.2 功能 ![111.drawio.svg](https://cdn.dong4j.site/source/image/111.drawio.svg) 主要包括数据采集、数据分析、告警通知、告警处理、数据展示、数据管理界面等多个功能模块。 - **数据采集:**负责采集各个模块的数据,包括但不限于业务平台系统、DB、设备等的数据。采集的数据会存储到对应的数据库中,供后续分析使用。 - **数据分析:**负责对采集到的数据进行处理、分析和计算,从而得出有价值的指标和警报信息。数据处理模块包括数据分析、告警规则和算法等子模块。 - **告警通知:**负责向管理员/其他系统发送数据分析模块生成的警报通知,包括短信、邮件、即时消息等多种形式。管理员可以根据自己的需求,选择接收告警通知的方式和通知人。 - **告警处理:**负责记录告警信息的处理情况,包括告警信息的处理节点、是否已经被处理、处理结果如何等。管理员在收到告警通知后,采取措施解决问题,并将处理情况记录,以便后续分析和跟踪。 - **数据展示:**负责将监控数据以及分析结果以 Dashboard 的形式展示出来,帮助管理员更直观地了解设备/场站等的运行状况。 - **管理界面:**提供监控告警的管理界面,管理员可以通过该界面进行警报设置、数据查看等操作。管理员可以在该界面中设置预警阈值等参数,用于数据分析模块的判断标准。 ## 2. 告警规则(条件) 告警规则需要结合业务需求,通过对监控指标进行分析和比对,判断当前状态是否正常,并生成相应的告警信息的规则。告警规则需要考虑多个因素,如监控指标的变化趋势、阈值设定、告警级别、告警通知方式等。常用的告警规则有: 1. **阈值告警规则:**该规则根据监控指标的阈值来触发警报,例如,温度高于阈值时,就会触发警报,并通知相关人员和部门。 2. **持续时间告警规则:**该规则根据监控指标的持续时间来触发警报,例如,当在线设备故障率超过了阈值,并持续 5 分钟以上时,就会触发警报,并通知相关人员和部门。 3. **模式告警规则:**该规则根据监控指标的模式和趋势来触发警报,例如,当在线设备的可用率在一段时间内一直处于下降趋势时,就会触发警报,并通知相关人员和部门。 4. **组合告警规则:**该规则是将多个告警规则进行组合,当满足其中一个或多个规则时,就会触发警报,并通知相关人员和部门。 5. **定时告警规则:**该规则根据时间设置来触发警报,例如,每天下午 4 点时,对设备进行一次巡检,若发现异常,则触发警报,并通知相关人员和部门。 6. **基于事件的告警规则:** 基于事件的告警规则可以根据事件的发生来触发警报。例如,通过对设备状态数据的监测,当出现设备异常故障这些事件时,可以自动触发警报,并通知相关人员进行故障诊断和修复。 设置告警规则的整体流程如下: ![222.drawio.svg](https://cdn.dong4j.site/source/image/222.drawio.svg) 1. 选择告警规则的数据来源, 将设备的物模型作为设置触发条件的入参; 2. 设置什么情况下触发告警, 比如某个设备的某个属性的当前值超过阈值; 某几个设备的的某几个值发生符合条件的变更, 则触发某种告警; 3. 根据告警严重等级设置告警级别, 不同的级别会有不同的处理方式和统计方法; 4. 设置告警方式; ### 2.1 设备源 告警规则支持两种设备源类型: - 设备类型:告警规则的作用范围包括设备类型下的所有设备(同一个类设置的物模型一致)。例如:比如某厂商的同一型号的智能水表,那么该类型下的所有智能设备,都可使用该告警规则,而无需为每个设备重复创建告警规则。 - 设备:告警规则的作用范围仅针对指定的一个或多个设备。 - 多个设备组合, 这种主要用于设置业务上告警规则, 比如某个场站的告警规则, 会由多个设备共同组合验证。 ### 2.2 触发条件 触发条件是告警规则中的重要部分,支持设置多个属性运算条件, 比如: 1. 水位高于某个阈值; 2. 某点管道流量低于某个阈值; 可同时添加多个触发条件,任意一个触发器满足条件或全部满足条件才会触发. #### 2.2.1 重复次数 设置重复次数后,告警条件首次触发时不会进入告警状态,也不会发送告警通知。当告警条件 **连续重复** 触发该次数后,进入告警状态并发送告警通知。 #### 2.2.2 持续时间 设置持续时间后,系统会在告警条件被 **连续触发** 达到该持续时间后,进入告警状态。 > 需要注意的是,当 **重复次数** 和 **持续时间** 同时设置时,两者必须同时满足,设备才会进入 **告警** 状态。 比如: 1. 触发条件是温度大于 29℃ 2. 重复次数是 3 次 3. 持续时间是 10 分钟 #### 2.2.3 有效时间段(待定) 当希望告警规则只在部分时段有效时,可以开启有效时段选项: 1. 全天每 30 分钟作为一个时段单位; 2. 可以选择任意想要的时段; 3. **无效时段允许告警恢复**; ### 2.3. 告警级别 告警级别用来区分告警的重要级别,用在告警历史和告警通知的显示文字中。例如,在短信通知方式中,告警级别会显示在短信特殊位置。 可选的告警级别包括: - 普通告警 - 重要告警 - 紧急告警 可以根据不同的告警级别进行灵活配置,如设置普通告警无需处理,但需要记录日志;严重告警需要及时通知相关人员,以便进行处理;紧急告警需要立即采取措施,以避免损失。 ## 3. 告警通知(动作) 当系统发现问题并生成告警时,告警通知模块会自动触发,并将告警信息通知给相关人员和部门,以便及时采取措施解决问题。 具体步骤如下: 1. **告警生成:**系统检测到异常情况并生成告警信息。 2. **告警分类:**告警通知模块对告警信息进行分类,根据不同的告警等级和类型,选择相应的通知方式和接收人员。 3. **通知方式选择:**告警通知模块根据用户设置的通知方式,选择合适的方式通知相关人员。例如,对于紧急的告警,可以通过短信或电话通知负责人员;对于重要的告警,可以通过邮件或即时通讯工具(企业微信或钉钉等)通知相关人员,普通告警则在大屏幕上进行展示即可。 - 邮件通知:将告警信息通过邮件发送给相关人员或部门。该方式适用于需要及时通知并且信息量较大的告警情况。 - 短信通知:将告警信息以短信的形式发送给相关人员或部门。该方式适用于需要紧急通知但信息量较少的告警情况。 - 语音电话通知:将告警信息通过语音电话形式通知相关人员或部门。该方式适用于需要紧急通知但又不能立即查看信息的告警情况。 - 微信/钉钉/企业微信等即时通讯工具通知:将告警信息通过即时通讯工具发送给相关人员或部门。该方式适用于需要及时通知且方便处理的告警情况。 - 大屏幕展示:将告警信息以可视化的形式展示在大屏幕上,方便相关人员实时了解监控情况。 - 站内通知:当监控系统产生告警信息时,可通过应用内通知的方式快速通知相关人员,并提供详细的告警信息。 4. **通知内容生成:**告警通知模块生成告警通知内容,并将告警信息、设备信息、时间等关键信息包含在通知中,以便相关人员了解问题的具体情况。 5. **通知发送:**通过自定义规则,告警通知模块将通知发送给预设的接收人员,同时记录发送时间、发送状态等信息,方便后续跟进和处理。 ### 3.1 通知组 告警通知方式前期主要考虑 **短信**, 后期可加入其他告警方式. 在使用通知方式之前, 需要创建 **通知组**. **如果不设置告警通知组, 则只会记录告警历史记录**. **通知组** 的作用是复用通知配置, 比如张三负责多个项目部, 在设置告警规则时, 不需要多次填写张三作为通知人, 只需要选择第一次创建好的通知组即可. 一个通知组只能选择一个通知方式,如果需要多种通知方式,则需要创建多个通知组。 ### 3.2 通知方式 目前只考虑 **短信** 通知. ### 3.3 每日通知上限 设置告警规则的每日总通知次数上限,超过上限后当日不再发送通知. ## 4. 告警规则的可用状态 每个告警规则可设置全局可用状态,用来启用或禁用该规则,对该告警规则的所有设备源都生效。 在全局可用状态开启的情况上,可以对关联的设备独立设置启用或禁用状态。例如,当对某个设备进行维护时,可临时关闭该设备的告警规则,但不影响告警规则关联的其它设备。 ## 5. 告警规则的告警状态 告警规则拥有以下几种的告警状态: - 正常(Ok):表示最近一次设备属性上报未触发告警规则。 - 告警(Alerting):表示最近一次设备属性上报已触发告警规则,且达到设置的重复次数和持续时间。如果未设置重复次数和持续时间,则首次触发会进入告警状态。 - 待定(Pending):表示最近一次设备属性上报已触发告警规则,但未达到设置的重复次数和持续时间。 - 未知(Unknown):表示告警规则暂时无明确的告警状态,例如:规则创建后一直没有相关设备属性上报,或者告警规则被禁用、不在有效时段等情况。 ![20241229154732_9Tt1ae8Q.webp](https://cdn.dong4j.site/source/image/20241229154732_9Tt1ae8Q.webp) ## 6. 警报信息处理 对已经发出来的告警信息进行处理以及记录处理的内容,可以清晰了解每个告警的处理状态和处理过程,更好地管理和维护系统。 ### 6.1. 告警信息的处理 当一个告警被触发并且通知给调度后,调度需要对这个告警信息进行处理。这个处理过程包括以下几个步骤: 1. **分析告警信息**:调度需要对告警信息进行分析,了解告警的来源、告警等级以及影响范围等,以便更好地判断告警的紧急程度和处理方法。 2. **判断告警的处理方法:**根据告警的紧急程度和影响范围,调度需要判断告警的处理方法。如果告警比较紧急且影响范围较大,调度需要第一时间联系一线运维进行现场处理;如果告警比较普通且影响范围较小,调度可以在合适的时间进行处理。 3. **处理告警**:具体措施包括合闸、重启设备等等。处理完成后,一线运维需要记录处理的内容,以便后续的跟踪和分析。 ### 6.2. 处理记录的跟踪 每个告警信息都应该有相应的处理记录,以便追踪告警的处理情况。处理记录的跟踪包括以下几个方面: 1. **记录告警的处理过程** 需要记录告警的处理过程,包括采取的措施、处理时间、处理结果等等。这些记录可以帮助了解告警的处理情况和处理效果。 2. **记录告警的处理人员** 需要记录处理告警的人员信息,包括处理人员的姓名、工号、联系方式等等。这些记录可以帮助了解告警的处理责任人和责任区域。 3. **记录告警的处理状态** 需要记录告警的处理状态,包括告警的开始时间、结束时间、处理状态等等。这些记录可以帮助了解告警的处理状态和处理效率。 **告警信息处理状态:** - 未处理:当系统接收到告警信息后,还没有进行任何处理,此时告警状态为未处理状态; - 处理中:当开始处理告警信息时,告警状态会被设置为处理中; - 已解决:当处理告警信息后,确定问题已经得到解决,告警状态将被设置为已解决状态; - 人工复归 - 自动复归 - 误报:当告警信息被判定为误报时,告警状态会被设置为误报状态; - 忽略:当管告警信息不需要被处理时,可以将告警状态设置为忽略状态; ## 6. 告警历史 可通过告警级别, 当前告警状态, 设备等条件查询告警历史. ## 7. 告警统计 直观的分析在过去的不同时段中,设备告警的出现频次。 ## 8. 站内通知 当设备触发告警或恢复正常时,控制台右上角会有通知提示,点击提示栏可以快速到达告警历史详情页。 ## 9. 界面设计 此方案包括以下功能模块: 1. **告警设置模块** 用于设置告警的规则和处理方式,如设置告警的级别、触发条件、告警通知方式、告警的处理方式等。 2. **告警列表模块** 包括当前所有的告警信息以及过去所有发生的告警信息,包括告警等级、告警类型、告警内容、告警时间等信息。 3. **告警详情** 展示选中告警的详细信息,包括告警的发生时间、告警的影响范围、告警的处理情况等信息。 4. **告警处理** 用于处理已经发生的告警,通常在告警详情页面进行处理通过该模块对告警信息进行处理,包括告警确认、告警分配、告警处理进展跟踪等。同时可以将处理结果记录在该模块中,便于后续的跟踪和分析。 5. **告警统计** 对所有告警信息进行统计分析,包括告警级别、告警类型、设备类型、告警时间、告警内容等等。通过该模块来了解告警情况的总体概括,同时也为监控系统的改进和优化提供数据支持。 6. 总览界面:展示系统中的所有告警信息,以及告警的处理情况和处理结果,并按照告警级别、告警类型等分类。 7. 数据可视化分析界面:结合具体的监控告警指标,通过图表的形式展示具体告警数据的趋势和变化,例如历史告警故障设备趋势、历史故障 SIM 卡分布等。 ## [告别文档恐惧症:轻松掌握技术文档写作技巧](https://blog.dong4j.site/posts/22b8af79.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 为什么你不爱写技术文档?以及怎样才能写好技术文档? 我以前看过一个投票,盘点程序员不喜欢的事,有两条和文档相关: > 不喜欢写文档; > 不喜欢项目文档太少。 看起来很矛盾,却很现实。基本上大家都认同:“项目文档很重要”,然而我们在项目中总是**短期高估文档的重要性,而长期低估文档的重要性。** ![20241229154732_szVGAweo.webp](https://cdn.dong4j.site/source/image/20241229154732_szVGAweo.webp) 结果就是口号喊的很响:要重视文档、要写好文档、要多写文档,然而随着项目的推进,总有比文档优先级更重要的任务,文档的优先级总是被有意无意推迟,导致项目的文档缺失、老旧、无人维护。 ### 1.1 为什么不爱写文档? 那么为什么程序员都不爱写文档呢?我总结了一下大致有下面这些原因。 - **不知道怎么写** 不知道怎么写文档的应该占很大一部分比例。 - **太忙没时间写或者懒得写** 程序员确实很忙,但总有不那么忙的时候,却也很少见有人利用这时间去写文档。包括我自己也这样,有时候没那么忙的时候,宁可去想想怎么重构下代码,却很少会愿意去写文档,主要还是太懒。 - **因为是敏捷开发,所以不用写文档?** 对于这个问题,我其实反驳过多次,[敏捷宣言](http://agilemanifesto.org/iso/zhchs/manifesto.html) 最后一句话明确指出:「尽管右项有其价值,我们更重视左项的价值。」也就是说敏捷从来没有否认文档的价值,只是更重视 「工作的软件」 罢了。 ### 1.2 为什么要写技术文档? 写文档,其实对个人、对项目、对团队,都是非常重要的事情。 #### 1.2.1 理清思路 我想你应该有这样的感受:写作的过程,就是一个思考的过程。 写文档,可以让你在写代码之前,梳理清楚思路,想清楚整体结构,比如说有哪些工作是重点难点;哪些要依赖其他人,需要及早协商的;哪些是要考虑安全性的。 如果上手就写代码,就很容易陷入到某个技术细节中,而忽略了整体结构。写的时候才发现一个技术难点无法解决,或者已经在某个不重要的细节上浪费了很多时间;或是发现有些依赖其他人提供的服务还没准备好;又或者是上线后才发现有安全漏洞。 **先写文档,就会抛开代码细节,去站在全局思考。**写的时候,各个模块之间的依赖关系、各种可能的安全隐患、各种可能需要其他人配合的地方,就都冒出来了,必须要去查资料,去找人讨论,反复缜密的思考后最终写出来。 **其目的是迫使你对设计展开缜密的思考,并收集他人的反馈,进而完善你的想法** **换个角度来说,如果你连文档都写不出来,那又怎么能指望代码写得好呢?** #### 1.2.2 便于维护和交接 「好记性不如烂笔头」,存在脑子里的内容是不可靠的,一个正常的项目组,如果需要长期维护,就需要一定的文档,把设计、操作流程、环境配置等内容记录下来,而不仅仅依赖于 **口口相传**。 #### 1.2.3 便于协作沟通 在一个项目组中,大家都有不同的分工,有人负责产品设计,有人负责架构设计,有人负责测试。而文档,就成为了团队成员很好的沟通工具。 比如说产品设计有雏型的时候,会有一个产品设计的评审会议,基于文档,项目成员可以一起参与其中,提出自己的意见和看法。这样就不至于等到产品设计出来之后,大家才对于设计有改进想法或意见,造成无法更改的结果。 ### 1.3 技术文档的注意事项 我参与过很多次设计评审,经常会发现**如下问题**: - 文档工具不统一,不同的小组、部门存在差异,有些甚至不知道是什么格式的文件,无法打开; - 过度拷贝需求文档,缺少软件设计的内容,不像软件设计文档; - 排版混乱,设计文档未按照标准模板顺序,缺少清晰的目录结构; - 设计文档太多图片,有些质量很差,且缺失原始文件,比如 EA 工具做的缺乏 eapx 文件,会导致文档迭代需要全部重新绘图,久而久之更加不愿意去维护更新文档了; - 没有统一的文档版本管理工具,缺少追溯和统计管理的能力; - 数据库表结构设计样式杂乱不统一,字段无中文描述(毕竟母语不是英语),且基本没有考虑主键和索引设计; - 程序流程基本比较简单,缺少主线,无法描述核心算法及关键点 (例如各种校验、事务、并发、缓存等处理); - 类图缺乏体现类之间的关系,有的直接用英文函数名,缺乏描述; - 时序图大多只描述与数据库的交互,缺少业务流程和程序执行的时序图; - 不理解设计文档的意义,很简单的任务需求就不需要写设计文档了; - 缺少对安全、性能、边界情况、性价比的思考,考虑还不够全面,评审把关不严; 总结了几点需要注意的问题给大家参考: 1. **文档撰写人**:架构设计师或功能的开发者; 2. **明确文档面向的读者**:是部门内部的开发者?伙伴实施人员?外部的开发者? 3. **设计先行**:设计文档在撰写应该是在编码之前,可以极大的避免后期出现返工的情况,也能提升开发效率; 4. **一图胜千言**:尽可能的使用图文的方式表达清楚设计思路; 5. **统一的绘图工具**:需要支持导入及导出,方便后续更新; 6. **统一的文档模板**:为了防止出现千奇百怪的文档、排版不一致、难以阅读等的问题; 7. **确定承载的形式**:可以从安全性(文档加密)、便于查看、版本管理等方面考虑,推荐内部的知识文档管理系统、类似 wiki, git, svn 的版本管理工具、内网微盘; 8. **好代码优于设计文档**:有时候写出优雅的代码和注释更胜于写一篇设计文档; 9. **版本迭代**:在软件功能迭代的过程中,可能经过几次迭代后功能和设计有了很大的变化,设计文档应该及时更新,以免给人传递错误的信息; ## 2. 如何写好技术文档 ### 2.1 方法论 #### 2.1.1 5W 法则 5W 法则相信大家已经听的多了,分别是 Who、What、When、Where、Why。这是一个广泛被用在各行各业的法则,写文档当然也不例外。 **Who**:文档的针对对象是谁,读者是谁。 **What**:明确文档写作的用途,通常仅需说明文档的用途和目的就能帮你搭建起整个文档的框架。 **When**:明确文档的创建、Review 和更新日期。因为文档也有时效性,明确相关日期可以避免阅读者踩坑。 **Where**:文档应该放在哪!建议一个组织或者团队有统一的永久文档存放地址,并且有版本控制。最好是方便查找、使用和分享。 **Why**:为什么要写这篇文档, 你期望读者读完后从文档中获得什么! #### 2.1.2 金字塔原则 我知道这是小学作文里都会用的套路。但是,it works。对了,比较专业一点的说法是叫做 **金字塔原理**。 金字塔原理: ``` 中心思想1 1. 支撑观点 2. 支撑观点 3. 支撑观点 中心思想2 4. 支撑观点 5. 支撑观点 6. 支撑观点 ``` #### 2.1.3 从模仿开始 [Vue.js](https://cn.vuejs.org/guide/introduction.html) [Dubbo](https://cn.dubbo.apache.org/zh-cn/overview/home/) [ShardingSphere](https://shardingsphere.apache.org/document/current/cn/overview/) [Mybatis-Plus](https://baomidou.com/) [GitBook](https://docs.gitbook.com/) [docsify](https://docsify.js.org/#/?id=docsify) [Docusaurus](https://docusaurus.io/zh-CN/) #### 2.1.4 从小文档开始 从一个小的技术实现细节开始, 到整个技术方案, 再到系统的技术方案。 #### 2.1.5 从粗到细, 迭代更新 **大纲由于细节**. 详细的技术文档要花很多时间精力,甚至可能写不下去;另一个原因就是在收集反馈的过程中,会有很多修改。**写得越细则无用功越多,最后,你甚至会因为不想改文档而抵触不同的意见。** 而从粗到细逐步迭代的方式就好多了,一开始的目的是为了梳理清楚思路,只要脑图这种级别的内容就好了,然后进行调整。因为文档很粗,调整也方便,等到基本确定后再写细节,就不会有大的反复。 ### 2.2 排版 文档排版我放在比较重要的位置, 因为我只要看到排版特别乱的文档就直接放弃阅读, 这也是为什么我不愿意接受或使用 Word 来写技术文档的原因(因为会花费大量的时间在排版上)。 这里简单的罗列一下好的排版: - **避免低级错误**:避免出现错别字、词语误用、标点符号错误等现象; - **首行不用缩进**:小时候老师经常教我们首行缩进,但是个人建议不缩进,因为你不是写论文; - **段落区分**:通过「空一行」来区分段落,并且每个段落的文字不要过长,否则你会发现一堆的文字堆砌在一起,密密麻麻的让人失去继续阅读下去的兴趣; - **保留空格**:当英文、数字和中文相遇的时候,他们之间要 **留一个空格**,这样阅读起来会更舒适; - **专有名词**:很多人把 Java 写成 JAVA,MySQL 写成 mysql,会显得很不专业。 **首行缩进和段落区分:** ![20241229154732_z2mHN74B.webp](https://cdn.dong4j.site/source/image/20241229154732_z2mHN74B.webp) **当英文、数字和中文相遇的时候:** ![20241229154732_2DaghWgm.webp](https://cdn.dong4j.site/source/image/20241229154732_2DaghWgm.webp) 1. [中文技术文档书写指东](https://sspai.com/post/68349) 2. [给程序员的中文写作指北](https://blog.csdn.net/weixin_39638014/article/details/112327421) ### 2.3 常用工具 1. Markdown: 基础语法 2. Typora: Markdown 编辑工具 3. draw.io: 跨平台的画图工具; IDEA、Visual Studio Code 插件 4. Figma: 原型工具 一般来说,只要别人发给我的文档是一份 Word 文档,我基本就把这份文档排在了最 Low 的一档。对于这种文档,我就想问三点: - 文档有更新怎么分发给每个需要这份文档的人? - Word 格式不兼容导致关键图表排版混乱怎么办? - 代码因系统不同存在的换位符不同导致的错误问题怎么解决? **不知道大家多年前有没有被 Windows 文本编辑器的 BOM 搞得鸡飞狗跳?** 对于 Word/Pdf 我是极力排斥的,因为很多文档都是需要更新的,这两种格式没法做到**动态更新** 。 #### 2.3.1 为什么用 Markdown - Markdown 时下最为火热的文本标记语言,是目前官方文档、技术博客中最主流的文档编排方式; - 不需要花费很长的时间学习 Markdown 的语法,它的语法真的非常简单; - 专注于文档编写,尽量少的精力关注复杂的格式。除一些项目设计书外,比较建议用 Word 来编写,绝大多数指导类文档、环境配置指导等文档都推荐使用 Markdown 来书写; - 专注于文档内容的编写,无需过多关系文本格式,支持跨平台文档,不需要考虑兼容性; - 更加符合程序员的编程规范,像编程一些编写文档,像代码工程一样演进文档; - 在项目开发中常常会需要编写 `REAMDE.md` 文档,特别在 Github 的开源演进中; Markdown 比较受开源社区的欢迎,因为它在表达力和简洁性之间找到了一个平衡点,但是它有一个致命问题就是 **无法应付稍微复杂一点的排版**。 #### 2.3.2 为什么使用 draw.io > [draw.io](http://draw.io/) 当前已经改名成 [diagrams.net](http://diagrams.net/),是一款免费的在线图表编辑工具,可以用来编辑 BPM, org charts, UML, ER 图, 网络拓朴图等各种覆盖的图。 > > 类似于 ProcessOn 的在线画图平台,但是 [draw.io](http://draw.io/) **跨平台** 且完全免费。支持在线直接画图,chrome 插件客户端,桌面客户端。 **推荐使用 [draw.io](http://draw.io/) 绘图,导出为 svg 图片,而不是直接使用截图, 原因有 2 点:** 1. svg 是矢量图, 无限放大不失真; 2. **可在原来的基础上再次编辑**; 列举了一些参考资料: 1. [流程图](http://www.woshipm.com/zhichang/2329530.html) 2. [时序图](http://www.woshipm.com/ucd/607593.html) 3. [类图](https://design-patterns.readthedocs.io/zh_CN/latest/read_uml.html) 4. [程序流程图](https://cn.vuejs.org/v2/guide/instance.html#生命周期图示) 5. [E-R 图](http://www.woshipm.com/pmd/4282120.html) ### 2.4 配图技巧 **一图胜千言, 无图无真相** ![20241229154732_hafXGYDV.webp](https://cdn.dong4j.site/source/image/20241229154732_hafXGYDV.webp) 1. 配图下面加上一些图片的关键描述 2. **使用 svg 代替 png** 3. 不要讲背景设置为网格 4. 整体使用低饱和颜色即可, 关键的地方可适当使用高饱和颜色 ### 2.5 引用/参考资料 - 相关需求单:比如某个业务所有相关的需求单都放在这里,方便其他人更进一步了解背景,也方便自己查找。 - 参考资料。 ![20241229154732_0fcUdhjY.webp](https://cdn.dong4j.site/source/image/20241229154732_0fcUdhjY.webp) ![20241229154732_zejNqXzR.webp](https://cdn.dong4j.site/source/image/20241229154732_zejNqXzR.webp) ### 2.6 变更历史 变更日志,一般开源项目都会记录每个版本的重要变更。 ![20241229154732_RiUlq0xE.webp](https://cdn.dong4j.site/source/image/20241229154732_RiUlq0xE.webp) **文档变更记录:** ![20241229154732_W4R6f7Gy.webp](https://cdn.dong4j.site/source/image/20241229154732_W4R6f7Gy.webp) ### 2.7 维护工具 对于文档的管理,我推荐使用 Git,像管理代码一样管理文档。另外我推荐使用一个静态网站来存放自己的文档,这样其他同事访问的时候看到的总是最新的文档。 ![20241229154732_dAKcrIAo.webp](https://cdn.dong4j.site/source/image/20241229154732_dAKcrIAo.webp) ## 3. 概要/详细设计 在写软件概要/详细设计说明书时,首先应该明确读者是 **专业的开发人员**,文档应该严谨细致,专业性强。 其次应该明确软件概要/详细说明书的设计依据是需求规格说明书,需求规格说明书中的要素应该能够与软件概要/详细说明书中的要素完全对应,即符合需求追踪表。 软件概要/详细设计说明书是从技术实现的角度将需求规格说明书进行展开,形成描述软件架构的文档。软件架构应该是按照「MECE 原则」拆分为多个模块的多层次的结构,这些模块能够完全穷尽需求规格说明书中的要求,同时又相互独立。使用 UML 图描述类图、结构图。 软件概要/详细设计说明书还需要写动态过程,借助流程图[泳道图、状态图,写清楚软件模块的执行过程。 软件概要/详细设计说明书拟制结束后,应该是多个互相独立解耦的模块,这些模块就可以分配给不同的程序员同步进行开发。 ## 4. 总结 那么什么样的文档才称得上是一个好的文档? 我认为应该满足以下几点: 1. **高度可维护的并且经常维护**; 2. 易于理解:表达准确、结构清晰、排版美观、风格统一; 有哪些方法论和工具能帮助我们去提供文档质量: 1. 多看优质的技术文档, 学习写作大纲、 配图、排版; 2. 多构思, 多写, 多练习, 形成一套自己的感谢做方式与方法; 3. 使用业内常用的、跨平台的工具, 一个团队最好全部统一; ## [Maven入门指南:掌握项目管理的核心工具](https://blog.dong4j.site/posts/6c5526f1.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 1. 为什么用构件管理工具 1. 一个项目就是一个工程 ​ 如果项目非常庞大,就不适合使用 package 来划分模块,最好是每一个模块对应一个工程,利于分工协作。借助于构件管理工具就可以将一个项目拆分成多个工程 2. 项目中使用 jar 包,需要“复制”、“粘贴”项目的 lib 中 ​ 同样的 jar 包重复的出现在不同的项目工程中,你需要做不停的复制粘贴的重复工作。借助于构件管理工具可以将 jar 包保存在“仓库”中,不管在哪个项目只要使用引用即可就行。 3. jar 包需要的时候每次都要自己准备好或到官网下载 ​ 借助于构件管理工具我们可以使用统一的规范方式下载 jar 包 L 4. jar 包版本不一致的风险 ​ 不同的项目在使用 jar 包的时候,有可能会导致各个项目的 jar 包版本不一致,导致未执行错误。借助于构件管理工具,所有的 jar 包都放在“仓库”中,所有的项目都使用仓库的一份 jar 包。 5. 一个 jar 包依赖其他的 jar 包需要自己手动的加入到项目中 ​ FileUpload 组件->IO 组件,commons-fileupload-1.3.jar 依赖于 commons-io-2.0.1.jar ​ 极大的浪费了我们导入包的时间成本,也极大的增加了学习成本。借助于构件管理工具,它会自动的将依赖的 jar 包导入进来。 6. 不同的项目不同的构建方式 在没有构件管理工具之前, 不同的项目可能有各种构建方式, 学习成本高, 不利于团队标准化建设; ## 2. 为什么选择 Maven | 构建工具类型 | 优点 | 缺点 | | ------------ | -------- | -------------------------------- | | 声明式 | 配置简单 | 灵活性不够 | | 过程式 | 灵活度高 | 编写复杂, 项目构建代码复用难度大 | Maven 是一种**声明式**项目管理工具,通过在 POM 中配置 "who","what","where"等信息,即可满足编译、测试、打包、发布等项目构建需求。 声明式的好处是,用户无需关心构建工具的实现细节,只需在 `pom.xml` 中配置好项目名,依赖等基础信息即可。坏处是,实现自定义的构建逻辑,相对复杂。(Maven 也提供了插件,如:`maven-antrun-plugin`,来运行用户自定义脚本。当然,插件最终 apply 到 Maven 的方式最终仍然是**声明式**的,即需要在 POM 中声明插件运行时机和插件相关配置。) 相对应地,Make 和 Ant 等构建工具是**过程式**项目管理工具,用户需要编写构建脚本并组织各脚本的依赖关系。过程式项目管理工具好处是,用户自由度很大;坏处是,项目管理经验无法复用,构建脚本编写较为复杂。 **Spring Boot 把 Maven 干掉了,正式拥抱 Gradle** ## 3. Maven 是什么 ### 3.1 名词解释 **1. 依赖:** 例如我们的项目中需要 servlet.jar,这个 jar 包就可以叫做依赖,或者说项目依赖 servlet.jar。我们在导入 a.jar 的时候发现还需要导入 b.jar,说明 a.jar 依赖 b.jar。 **2. 项目构建:** 项目构建描述的是代码的编译、运行、打包、部署等一系列**过程**。 - 1.项目清理:清理之前编译的代码。 - 2.编译:将程序源代码编译为虚拟机可执行的代码,也就是 class 文件。maven 可以帮我们批量编译一堆代码。 - 3.测试:maven 可以执行测试程序代码,验证你的功能是否正确。 - 4.打包:将所有的 class 文件、配置文件等资源放到一个压缩文件中。 对于 java 程序,压缩文件是 xx.jar,对于 web 应用,压缩文件扩展名是 xx.war。 - 5.安装:将 jar 文件或者 war 文件安装到本机仓库中。 - 6.部署:将程序部署到服务器上,我们可以通过网址进行访问。 ### 3.2 Maven 的作用 Maven 是 Apache 软件基金会旗下一款**自动化构建工具**,专注于 Java 平台的**项目构建**和**依赖管理**。 - Maven 的官网: https://maven.apache.org/ - Maven 下载地址:https://maven.apache.org/download.cgi - Maven 资源检索:https://search.maven.org/ **简单点说 Maven 就是一个好用的工具。我们以前做项目需要自己到网上找 jar 包,而用了 Maven 之后,我们只需要配置一下依赖的坐标,它会自动将 jar 包下载到我们电脑硬盘的某一个目录下。** 除了管理 jar 包,它还能帮我们干很多事情: - 自动下载需要的 jar 包,管理 jar 包的版本以及它们之间的依赖关系 - 帮你编译程序, 打包程序, 部署程序; - 生成 Javadoc site, 创建测试报告; - 帮你管理项目中各个模块的关系 - 统一开发结构, 提供标准的, 统一的项目结构 ### 3.3 主要特点和优势 1. 约定优于配置:Maven 遵循一定的目录结构和约定,降低了项目配置的复杂性。 2. 依赖管理:Maven 可以自动处理项目的依赖关系,包括下载、版本控制等。 3. 插件生态:Maven 拥有丰富的插件生态,提供了许多用于构建、测试、部署等任务的插件。 4. 生命周期管理:Maven 将项目构建过程划分为若干阶段,可以灵活地控制每个阶段的任务。 5. 多模块项目支持:Maven 支持多模块项目,可以方便地管理复杂的项目结构。 ## 4. Maven 的安装 略 ### 4.1 使用 Maven Wrapper Maven Wrapper 是受到 Gradle Wrapper 的启发而来的。它使 Maven 的一些配置 wrap 到项目里面,同时赋予了项目使用执行 Maven 版本的能力。 ``` . ├── .mvn │   ├── wrapper │   │ ├── MavenWrapperDownloader.java # 下载 maven 二进制文件(~/.m2/wrapper/dists) │   │ └── maven-wrapper.proerties # 下载地址配置 │   ├── jvm.properties # maven 的 JVM 参数配置 │   └── maven.config # mvn 命令的配置 ├── mvnw # linux/unix 脚本 ├── mvnw.cmd # Windows 脚本 └── pom.xml ``` ## 5. 标准的项目结构 ``` ├── src │   ├── main │   │   ├── java │   │   └── resources │   └── test │   ├── java │   └── resources ├── target │ ├── classes │ └── generated-sources └── pom.xml ``` 1. `pom.xml`: pom 是 project object model 的首字母缩写,是 maven 的项目配置文件,也是 maven 工具的核心; 2. `src/main/java`: Java 项目的源代码目录; 3. `src/main/resources`: Java 项目的资源文件目录; 4. `src/test`: 项目的测试代码包,测试用例存储的位置; 5. `target/classes`: 输出的字节码文件目录; ## 6. POM POM 文件是 Maven 的核心文件,包含项目构建相关的所有配置信息,如:项目源代码目录,class 文件输出目录等。Maven 执行 goal 时,会首先读取当前目录的 POM 文件,然后执行对应 goal。 ![pom.drawio.svg](https://cdn.dong4j.site/source/image/pom.drawio.svg) ```xml 4.0.0 ${project.basedir}/target ${project.build.directory}/classes ${project.artifactId}-${project.version} ${project.build.directory}/test-classes ${project.basedir}/src/main/java ${project.basedir}/src/main/scripts ${project.basedir}/src/test/java ${project.basedir}/src/main/resources ${project.basedir}/src/test/resources ``` ### 6.1 继承与聚合 - Project Inheritance: 继承已有`pom.xml`,便于做公共配置管理。Super POM 是 `Project Inheritance` 的典型例子; - Project Aggregation: 同一项目中存在多个`module`; ### 6.2 常用属性 用户可以使用该属性引用 POM 文件中对应元素的值,常用的 POM 属性包括: - ${project.build.sourceDirectory}:项目的主源码目录,默认为 src/main/java - ${project.build.testSourceDirectory}:项目的测试源码目录,默认为 src/test/java - ${project.build.directory}:项目构件输出目录,默认为 target/ - ${project.outputDirectory}:项目主代码编译输出目录,默认为 target/classes/ - ${project.testOutputDirectory}:项目测试代码编译输出目录,默认为 target/test-classes/ - ${project.groupId}:项目的 groupId - ${project.artifactId}:项目的 artifactId ### 6.3 version #### 6.3.1 组成 我们在引入其他依赖的时候,经常看到,比如 1.2.1、1.7、1.0-SNAPSHOT、1.1-release、2.3-alpha 等的 version。但它们的组成比较简单,如下,maven 的版本构成: <主版本>.<次版本>.<增量版本> - <里程碑版本> 一般情况下,主版本和次版本会一直存在,增量版本和里程碑版本见到的相对少的多。 1. 主版本: 表示项目的重大架构变化。如 struts2 和 struts1 采用不同的架构。 2. 次版本: 较大范围功能增加和变化,及 bug 修复,并且不涉及到架构变化的。 3. 增量版本:表示重大 bug 的修复,如果发现项目发布之后,出现影响功能的 bug,及时修复,则应该是增量版本的变化。 4. 里程碑版本:往往表示某个版本的里程碑,经常见到 snapshot,另外还有 alpha、beta,比如:1.0-SNAPSHOT、3.0-alpha-1、1.1.2-beta-2。 #### 6.3.2 快照 SNAPSHOT 表示快照版,它不是个稳定版本,属于开发过程中使用的版本。当我们项目处于不停的迭代开发期,如果存在依赖关系,比如 A 项目组开发后发布的新包,被 B 项目组引用,这时候使用快照版本 SNAPSHOT,能够在 A 项目组发布到仓库后,自动转为最新时间戳的后缀,供 B 项目组自动引用成功。 这样的好处是,当我们有依赖关系的两个项目组同时开发时,可以互不影响,每次 A 项目组发布后,B 项目组都会刷新、重新编译的方式,自动更新到最新的 A 项目开发的依赖包。只有当准备进入测试阶段,才会将里程碑版本号的 SNAPSHOT 替换成 alpha 或 beta,即测试版本。 #### 6.3.3 版本范围 完整的版本号范围说明如下:(x 为具体使用的版本号) ``` (,1.0] x <= 1.0 [1.0] x = 1.0 跟直接指定1.0没有区别 [1.2,1.3] 1.2 <= x <= 1.3 [1.0,2.0) 1.0 <= x < 2.0 [1.5,) x >= 1.5 (,1.0],[1.2,) x <= 1.0 or x >= 1.2 (,1.1),(1.1,) x < 1.1 or x > 1.1 即排除1.1的版本 ``` ## 7. 坐标和依赖 ### 7.1 GAV Maven 坐标为各种构件引入了秩序,任何一个构件都必须明确定义自己的坐标,而一组 Maven 坐标是通过一些元素定义的。Maven 坐标一般可以认为是一个五元组,即:(groupId,artifactId,version,type,classifier)。 - groupId:Maven 项目隶属的实际项目名称; - artifactId:实际项目中的一个模块名称; - version:版本号; - type: - `jar`: jar 包,包含依赖项目主代码 class 文件; - `test-jar`: jar 包,classifier 为 tests,包含依赖项目测试代码 class 文件。用于复用测试代码; - classifier: 用于区分从同一个 POM 中,构建出的不同 artifacts。比如:同一个项目可能同时提供 jdk11 和 jdk 8 对应的依赖; 上述五个元素中,groupId、artifactId、version 是必须定义的,type 是可选的(默认为 jar),而 classifier 是取决于对应依赖是否提供。 ### 7.2 Scope Scope 作用有两个: 1. 限制依赖传递; 2. 控制依赖是否出现在各个 classpath 中 #### 7.2.1 Dependency Scope Maven 中有五种 Scope,分别是: 1. compile: 默认, 对于编译、测试、运行三种 classpath 都有效; 2. provided: 只在开发、测试阶段使用,目的是不让 Servlet 容器和你本地仓库的 jar 包冲突 3. runtime: 只在运行时使用,如 JDBC 驱动,适用运行和测试阶段; 4. test: 只对于测试 classpath 有效, 用于编译和运行测试代码, 不会随项目发布; 5. system: 类似 provided,需要显式提供包含依赖的 jar,Maven 不会在 Repository 中查找它; Maven 中大致可以分成四类 class path: - main 代码编译 classpath:编译 main 代码的 classpath - main 代码运行 classpath:运行 main 代码的 classpath - test 代码编译 classpath:编译测试代码的 classpath - test 代码运行 classpath:运行测试代码的 classpath 结合依赖的 Scope 和四类 classpath 总结出 classpath 与依赖的关系: | classpath | 组成 | 备注 | | ---------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | main compile | scope 为 compile, provided, system | | | main run | 主代码编译的输出目录+ scope 为 compile 和 runtime 的依赖组成 | 一般情况下需要指定 classpath, 这也是为什么我们在 IDEA 里面直接运行 main code 中使用了 provided 依赖的 main 方法会报错的原因, 因为 provided 依赖不会加入 main run classpath 中 | | test run/compile | 主代码 class 输出目录 + scope 为 compile, provided, system, runtime, test 的依赖 + test class 的输出目录 | | #### 7.2.1 Dependency Management Scope ```xml com.xxx xxx-center-dependencies ${xxx-center-dependencies.version} import pom ``` ### 7.3 依赖传递 我们都知道依赖是会传递的,例如 B 模块引入了 A 模块,C 模块引入了 B 模块,就相当于 C 模块引入了 A 模块。 而在项目中我们知道 service 依赖 dao,controller 依赖 service。dao、service、controller 又都依赖 model 实体类,dao 和 service 又都需要用到 common 。但是我们不可能在他们三个中都添加 model 的依赖,在 dao 和 service 中都添加 common 的依赖。 根据依赖的传递性,我们可以在 common 模块中添加 model 的依赖,在 dao 中添加 common 的依赖,在 service 中添加 dao 的依赖,在 controller 中添加 service 的依赖。 ![依赖传递.drawio-6237674.svg](https://cdn.dong4j.site/source/image/%E4%BE%9D%E8%B5%96%E4%BC%A0%E9%80%92.drawio-6237674.svg) 依赖传递关系: - A -> B (compile) 第一关系 : A 依赖 B compile - B -> C (compile) 第二关系 : B 依赖 C compile 则 A 会自动依赖 C 详细的关系传递如下: | A/B | compile | test | provided | runtime | | -------- | -------- | ---- | -------- | -------- | | compile | compile | - | - | runtime | | test | test | - | - | test | | provided | provided | - | provided | provided | | runtime | runtime | - | - | runtime | **那么该如果禁止依赖传递?** ### 7.4 依赖调解 依赖调解遵循以下两大原则:路径最短优先、声明顺序优先, - 第一原则:路径最近者优先。 把当前模块当作顶层模块,直接依赖的包则作为次层模块,间接依赖的包则作为次层模块的次层模块,依次递推...,最后构成一棵引用依赖树。假设当前模块是 A,两种依赖路径如下所示: ``` A --> B --> X(1.1) // 层级(A->X) = 2 A --> C --> D --> X(1.0) // 层级(A->X) = 3 ``` 此时,Maven 可以按照第一原则自动调解依赖,结果是使用 X(1.1)作为依赖。 - 第二原则:第一声明者优先。若冲突依赖的路径长度相同,那么第一原则就无法起作用了。 假设当前模块是 A,两种依赖路径如下所示: ``` A --> B --> X(1.1) // 层级(A->X) = 2 A --> C --> X(1.0) // 层级(A->X) = 2 ``` 当路径长度相同,则需要根据 A 直接依赖包在 pom 文件中的先后顺序来判定使用那条依赖路径,如果次级模块相同则向下级模块推,直至可以判断先后位置为止。 ``` ... dependency B ... dependency C ``` 假设依赖 B 位置在依赖 C 之前,则最终会选择 X(1.1)依赖。 其它情况:**覆盖策略**。若相同类型但版本不同的依赖存在于同一个 pom 文件,依赖调解两大原则都不起作用,需要采用覆盖策略来调解依赖冲突,最终会引入最后一个声明的依赖。 ```xml a a 1.2 a a 1.4 a a 1.3 ``` ### 7.5 ClassNotFoundException 为什么本地开发没问题, 一到线上就出现比如 class 找不到, 方法签名不正确等问题? **maven healper** ## 8. 仓库 ![仓库.drawio.svg](https://cdn.dong4j.site/source/image/%E4%BB%93%E5%BA%93.drawio.svg) 当通过 Maven 构建项目时,Maven 按照如下顺序查找依赖的构件。 1. 从本地仓库查找构件,如果没有找到,跳到第 2 步,否则继续执行其他处理。 2. 从远程仓库查找构件,如果没有找到,并且已经设置其他远程仓库,然后移动到第 4 步;如果找到,那么将构件下载到本地仓库中使用。 3. 如果没有设置其他远程仓库,Maven 则会停止处理并抛出错误。 4. 在远程仓库查找构件,如果找到,则会下载到本地仓库并使用,否则 Maven 停止处理并抛出错误。 ![20241229154732_Nzl60lsR.webp](https://cdn.dong4j.site/source/image/20241229154732_Nzl60lsR.webp) ## 9. 生命周期 Maven 有三套独立的 Lifecycle:`default`、`clean` 和 `site`,每个 Lifecycle 包含多个 Phase。下图详细的展示了 Lifecycle 和 Phase 的关系。 ![生命周期.drawio.svg](https://cdn.dong4j.site/source/image/%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F.drawio.svg) ### 9.1 Clean clean 生命周期的目的主要是清理项目。 - pre-clean: 执行一些清理前需要完成的工作。 - **clean**: 清理上一次构建生成的文件。 - post-clean: 执行一些清理后需要完成的工作。 ### 9.2 Default default 生命周期定义了真正构建时所需要执行的所有步骤,它是所有生命周期中最核心的部分,其包含的阶段如下: - validate:验证项目是否合法以及项目构建信息是否完备。 - initialize:初始化。 - generate-sources: 生成源代码,如:`ANTLR` 插件会根据语法文件生成对应的 Java 源代码。 - process-sources: 处理项目主资源文件。一般来说,是对 src/main/resources 目录的内容进行变量替换等工作后,复制到项目输出的主 classpath 目录中。 - generate-resources:生成资源文件。 - process-resources:处理资源文件。 - **compile**:编译项目的主源码。一般来说,是编译 src/main/java 目录下的 Java 文件至项目输出的主 classpath 目录中。 - process-classes:处理 class 文件,如:字节码增强。 - generate-test-sources:生成测试源代码,如:`ANTLR`。 - process-test-sources:处理项目测试资源文件。一般来说,是对 src/test/resources 目录的内容进行变量替换等工作后,复制到项目输出的测试 classpath 目录中。 - generate-test-resources:生成测试资源文件。 - process-test-resources:拷贝或处理测试资源文件至目标测试目录。 - test-compile:编译项目的测试代码。一般来说,是编译 src/test/java 目录下的 Java 文件至项目输出的测试 classpath 目录中。 - process-test-classes:处理 test class 文件。 - **test**:使用单元测试框架运行测试,测试代码不会被打包或部署。 - prepare-package:打包前置工作。 - **package**:接受编译好的代码,打包成可发布的格式,如 JAR,WAR 等。 - pre-integration-test:集成测试的前置工作 - integration-test:集成测试。 - post-integration-test :集成测试后,需要做的一些事情。 - **verify**:检测所有的集成测试结果是否符合预期,保障代码质量。 - **install**:将包安装到 Maven 本地仓库,供本地其他 Maven 项目使用。 - **deploy**:将最终的包复制到远程仓库,供其他开发人员和 Maven 项目使用。 ### 9.3 Site site 生命周期用于生成代码站点文档并发布至对应 Web Server。 - pre-site:前置工作。 - **site**:生成代码对应的站点文档。 - post-site:site 后置工作,deploy 前置工作。 - site-deploy:发布站点文档至对应的 Web Server。 ## 10. 其他 ### 10.1 排除全部依赖 ```xml org.springframework.boot spring-boot-starter ${spring-boot-dependencies.version} org.springframework.boot spring-boot-starter-logging org.springframework.boot spring-boot-starter-log4j2 ${spring-boot-dependencies.version} * * org.springframework.boot spring-boot-starter-logging ${spring-boot-dependencies.version} * * ``` ### 10.2 mvnd maven-mvnd 是 A pache Maven 团队借鉴了 Gradle 和 Takari 后衍生出的更快的构建工具。mvnd 内嵌了 Maven,也正是因为这个原因我们可以无缝地将 Maven 切换 为 mvnd(也不需要单独安装 Maven) - 运行构建的 JVM 不需要为每个构建重新启动。 - Maven 插件类的类加载器缓存在多个构建中,插件 jars 只会被读取和解析一次。 - JVM 中 JIT 生成的本机代码会被保留。与 Maven 相比,JIT 编译花费的时间更少。在重复构建期间,JIT 优化的代码立即可用。这不仅适用于来自 Maven 插件和 Maven 内核的代码,也适用于来自 JDK 本身的所有代码。 ### 10.3 项目骨架 ![20241229154732_69thf4js.webp](https://cdn.dong4j.site/source/image/20241229154732_69thf4js.webp) ### 10.4 插件开发 参看 **xxx-maven-plugin** ## [代码审查的艺术:格式到功能的全面审视](https://blog.dong4j.site/posts/77edbced.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 让我们来谈谈代码审查。如果你花几分钟时间搜索有关代码审查的信息,你会看到很多文章讨论为什么代码审查是一件好事. 你还会看到很多关于如何使用代码审查工具的文档,比如 Upsource。但你不太容易找到一份指导你在审查他人代码时应该关注什么的指南。 可能没有明确文章解释应该寻找什么的原因是:有很多不同的事情需要考虑。并且,像任何一组要求(功能性的或非功能性的)一样,不同的组织对于每个方面都会有不同的优先级。 由于这是一个很大的主题,本章的目标只是概述在执行代码审查时,审阅者可能会关注的一些事情。确定每个方面的优先级并保持一致性检查是一个足够复杂的话题,足以成为一个独立的章节。 ## 在审查他人的代码时,你寻找什么? 无论你是通过 Upsource 这样的工具还是在与同事一起走查他们的代码,无论情况如何,有些东西比其他东西更容易评论。一些例子: - 格式化:空格和换行在哪里?他们使用制表符还是空格?花括号是如何排列的? - 风格:变量/参数是否被声明为 final?方法变量是在它们使用的代码附近定义还是在方法的开始处定义? - 命名:字段/常量/变量/类名的命名是否符合标准?名称过长吗? - 测试覆盖率:有没有针对这段代码的测试?这些都是有效的检查事项——你希望最小化在不同代码区域之间切换上下文,减少认知负荷,所以你的代码看起来越一致越好。 然而,让人类去找这些可能不是在你的组织中最佳的时间和资源的使用方式,因为许多这些检查都可以自动化。有很多工具可以确保您的代码格式保持一致,遵循命名和 final 关键字使用的标准,并且找出由简单的编程错误引起的常见错误。例如,您可以从命令行运行 IntelliJ IDEA 的检查,这样你就不必依赖所有团队成员在 IDE 中运行相同的检查。 ## 你应该寻找什么 人类真正擅长的是什么?在代码审查中,我们能注意到哪些事情是工具无法委托的? 结果是,有很多事情。这绝对不是一份详尽无遗的清单,我们也不会在这里详细讨论其中的任何一个。相反,这应该是你组织内关于目前你在代码审查中寻找哪些事情的对话的开始,以及可能你还应该寻找什么。 ## 设计 - 新代码如何与整体架构相匹配? - 代码是否遵循 SOLID 原则、领域驱动设计 ® 和其他团队青睐的设计范式? - 在新代码中使用了哪些设计模式?这些是否适当? - 如果代码库混合了标准或设计风格,新代码是否遵循当前的实践?代码是否朝着正确的方向迁移,还是它模仿了应该逐步淘汰的旧代码? - 代码是否位于正确的地方?例如,如果代码与订单相关,它是位于订单服务中吗? - 新代码是否有在现有代码中复用某种东西的可能性?新代码是否提供了可以在现有代码中复用的功能?新代码是否引入了重复内容?如果是的话,应该将其重构为更可重用的模式,还是在这个阶段可以接受? - 代码是否过度设计?它为了不必要的可复用性而构建了吗?团队是如何平衡考虑可复用性与 YAGNI10 的? ) ## 可读性和可维护性 - 字段、变量、参数、方法和类名的命名是否真正反映了它们代表的实体? - 通过阅读代码,我能否理解代码的功能? - 我能理解测试的功能吗? - 测试覆盖了好的案例子集吗?它们涵盖了正常路径和异常情况吗?是否有未被考虑的案例? - 异常错误消息是否容易理解? - 是否有令人困惑的代码部分已经被文档化、注释或者被可理解的测试(根据团队的偏好)所涵盖? ## 功能性 - 代码实际上是否做到了它应该做的事情?如果有自动化测试来确保代码的正确性,这些测试是否真的测试了代码是否符合约定的要求? - 代码看起来是否含有潜在的细微错误,比如使用错误的变量进行检查,或者是无意中使用“和”而不是“或”? # 你有考虑过… - 代码中是否有潜在的安全问题? - 是否需要满足监管要求? - 对于没有自动化性能测试覆盖的区域,新代码是否引入了可避免的性能问题,比如不必要的数据库调用或远程服务调用? - 作者是否需要创建公共文档,或者更改现有的帮助文件? - 用户界面消息是否已经被检查过正确性? - 是否有明显的错误会在生产环境中停止工作?代码是否会不小心指向测试数据库,或者是是否有硬编码的占位符应该被替换为实际的服务? # 测试 在前一章中,我们讨论了在代码审查中可以寻找的广泛内容。现在我们将重点放在一个领域:应该关注测试代码的哪些方面。 我们假设:“_你的团队已经为代码编写了自动化测试。_ 测试会在持续集成(CI)环境中定期运行。正在被审查的代码已经通过了自动编译/测试过程。” ## 提问 在这一章中,我们将探讨在代码审查中查看测试时审阅者可能会考虑的一些事项。 ## 是否有对新/修改后的代码进行测试? 对于新的或修改后的代码来说,无论是修复错误还是添加新功能,都很少不需要一个新或更新的测试来覆盖它。即使是出于“非功能性”原因的更改,如性能提升,也经常可以通过测试来证明。如果代码审查中没有包含任何测试,作为审阅者,你首先应该问的问题是:“为什么不?” ## 测试是否至少覆盖了复杂的代码部分? 超越简单地“是否有测试”的问题,我们需要回答的是:“重要的代码是否至少被一个测试所覆盖?” 检查测试覆盖率当然是我们可以自动化的事情。但我们不仅仅可以检查特定的百分比覆盖率:我们还可以使用覆盖率工具来确保正确的代码区域被覆盖。 考虑以下例子: xxxxxxxxxxxxx ## 我能否理解测试? 拥有提供充分覆盖率的测试是一件事情,但如果连我也作为人类都无法理解这些测试,它们的使用价值就有限了。当测试失败时会发生什么?将很难知道如何修复它们。 考虑以下情况: ## 测试是否与要求相符? 这是一个真正需要人类专业知识的地方。无论是被审查的代码满足的要求编码在某个正式文件中,还是位于用户故事的卡片上,或者是用户提出的错误报告内,正在被审查的代码应该与某些初始要求相关。 审阅者应该找到原始要求并查看: - 测试是否与其相符,无论它们是单元测试、端到端测试还是其他类型。例如,如果要求是“应该在密码字段中允许特殊字符‘#’、‘!’和‘&’”,那么应该有一个使用这些值的密码字段的测试。如果测试使用了不同的特殊字符,那么它不能证明代码符合标准。 - 测试覆盖了所有提到的标准。在我们的特殊字符例子中,要求可能继续说“…并且如果使用了其他特殊字符,则向用户提供错误消息”。在这里,审阅者应该检查是否有针对使用无效字符时会发生什么的测试。 ## 我能否想到现有测试未覆盖的情况? 通常我们的要求并没有明确规定。在这些情况下,审阅者应该考虑原始错误/问题/故事中没有被覆盖的边缘情况。 例如,如果我们新的功能是“赋予用户登录系统的能力”,审阅者可能会想,“如果用户输入 null 作为用户名会怎样?”或者“如果用户不存在于系统中会发生什么样的错误?”。如果在被审查的代码中存在这些测试,那么审阅者就可以增加对代码本身处理这些情况的信心。如果这些异常情况的测试不存在,那么审阅者必须检查代码以确定它们是否已得到处理。 如果代码存在但测试没有,这取决于你的团队来决定政策——你是否要求作者添加这些测试?或者你满意于代码审查证明了边缘情况被覆盖? ## 是否有测试来记录代码的限制? 作为一个审阅者,往往可以看到正在审查的代码中的限制。这些限制有时是故意的——例如,一个只能处理每个批次最多 1000 项的批量处理过程。 一种记录这些故意限制的方法就是明确地测试它们。在上面的例子中,我们可能有一个测试来证明如果你的批次大小超过 1000 会抛出某种异常。 不是强制的必须在自动化测试中表达这些限制,但如果作者写了一个显示他们已实施的限制的测试,那么有这个测试就意味着这些限制是故意的(并且被记录了),而不是仅仅是一个疏忽。 ## 代码审查中的测试是否是正确的类型/级别? 例如,作者是在做昂贵的集成测试,而单元测试可能就足够了吗?他们有没有编写那些在持续集成(CI)环境中无法有效或一致运行的性能微基准测试? 理想情况下,你的自动化测试应该尽可能地快地运行,这意味着不需要进行昂贵的端到端测试来检查所有类型的特性。一个执行某些数学功能或布尔逻辑检查的方法似乎是方法级单元测试的良好候选。 ## 是否有针对安全方面的测试? 安全性是代码审查可以真正带来好处的领域之一。我们会在以后专门写一篇关于安全性的文章,但在测试话题上,我们可以为许多常见问题编写测试。例如,如果我们正在编写上述的登录代码,我们可能还想要写一个测试来显示如果不能首先进行身份验证,我们就不能进入网站的受保护区域(或调用受保护的 API 方法)。 ## 性能测试 在前一章中,我提到了性能作为审阅者可能会检查的一个领域。自动化性能测试显然是本章可以探索的另一类测试,但我将把这些类型测试的讨论留到以后关于代码审查中的特定性能方面的章节。 ## 审阅者也可以编写测试 不同的组织对代码审查有不同的方法。有时非常清楚作者负责做出所有必要的代码更改;有时则更具有合作性,审阅者自己提交对代码的建议。 无论你采取哪种方法,作为一个审阅者,你可能发现编写一些额外的测试来检查审查中的代码非常有价值,这对于理解那片代码来说就像启动 UI 并与一个新特性互动一样有价值。某些方法和代码审查工具使实验代码变得更加容易,而其他则不那么容易。团队的利益在于尽可能轻松地查看和玩转代码,进行代码审查。 提交这些额外测试作为审查的一部分可能是有价值的,但同样也可能是没有必要的,例如如果实验已经给了我,作为审阅者,满意的答案来回答我的问题的话。 ## 总结 无论你的组织如何处理代码审查过程,进行代码审查都有许多优势。你可以在代码集成到主代码库之前,在它仍然容易修复并且上下文仍在开发者的大脑中时,使用代码审查来发现潜在的代码问题。 作为一个代码审阅者,你应该检查原始开发人员是否思考过他们的代码可能的使用方式、可能在哪些条件下会出错,并处理了边缘情况,可能通过自动化测试“文档化”预期的行为(在正常使用和异常情况下)。 如果审阅者寻找测试的存在性并检查测试的正确性,作为一个团队,你就可以对代码的功能有相当高的信心。此外,如果这些测试在一个 CI 环境中定期运行,你可以看到代码持续工作——它们提供了自动回归检测。如果代码审阅者高度重视他们正在审查的代码的良好质量测试,那么这项代码审查的价值在审阅者点击“接受”按钮之后也会继续存在。 ### 性能 在这一章中,我们将讨论在代码审查过程中可以寻找哪些关于被审查代码性能的问题。 就像所有架构/设计领域一样,系统性能的非功能性要求应该事先设定。无论你是在开发一个必须以纳秒响应的低延迟交易系统,还是在创建一个需要响应用户的在线购物网站,或者你在编写一个管理“待办事项”列表的手机应用程序,你应该有一些关于什么被认为是“太慢”的概念。 让我们来看看影响性能的一些因素,审阅者在代码审查过程中可以寻找这些因素。 性能要求 在我们决定是否基于性能进行代码审查之前,我们应该问自己几个问题。 #### 这部分功能是否有硬性的性能要求? 被审查的这段代码是否属于之前已经公布 SLA(服务等级协议)的区域?或者要求中是否说明了所需性能特性? 如果原始要求是一个“登录屏幕加载太慢”的错误报告,那么原始开发者应该澄清一个合适的加载时间是多少——否则审阅者或作者如何能够确信速度得到了足够的改进? 如果有硬性的性能要求,那么是否存在一个测试来证明它满足那些要求? 任何性能关键的系统都应该有自动化性能测试来确保发布 SLAs(例如:在 10 毫秒内处理完所有请求)得以实现。如果没有这些测试,你只能依靠用户告诉你没有达到你的 SLA 的情况。这不仅是一个糟糕的用户体验,还可能导致可以避免的罚款和费用。本系列最后一篇帖子详细介绍了代码审查中的测试。 ### 修复/新功能是否影响现有性能测试的结果? 如果你经常运行性能测试(或者你有可以在需要时运行的套件),检查新的代码在性能关键区域是否引入了系统性能下降的情况。这可能是一个自动化过程,但是由于作为 CI 环境一部分运行性能测试远不如运行单元测试常见,所以在审查中特别提到这一步骤是有价值的。 ### 如果没有硬性的性能要求呢? 花几个小时痛苦地思考那些能为你节省几个 CPU 周期的优化是没有多大价值的。但审阅者可以检查一些事情以确保代码不会受到完全可避免的常见性能陷阱的影响。查看其余列表以了解是否有一些易于取胜的方法来防止未来的性能问题。 ### 服务的/应用程序之外调用是昂贵的 任何需要网络跳转来使用你应用之外的系统都是一般会比你一个优化不佳的 equals() 方法要耗费更多成本。考虑: ### 数据库调用 最严重的违规者可能隐藏在像 ORMs(对象关系映射器)这样的抽象背后。但在代码审查中,你应该能够捕捉到性能问题的常见原因,比如在一个循环中调用的数据库单个调用——例如,加载一个 ID 列表,然后对每个对应的 ID 查询数据库。 数据库调用 例如,上面的第 19 行可能看起来相当无辜,但它位于一个 for 循环内——你不知道这段代码可能导致多少次数据库调用。 ### 不必要的网络调用 像数据库一样,远程服务有时可能会过度使用,有多个远程调用可能只需要一个,或者批处理或缓存可以防止昂贵的网络调用。同样地,像数据库一样,有时候抽象会隐藏实际上方法调用是在调用远程 API。 ### 移动/可穿戴应用程序过多调用后端 这基本上与“不必要的网络调用”相同,但还增加了在移动设备上,不必要地调用后端不仅会降低性能,还会消耗电池的问题。 高效使用资源 在讨论我们如何使用我们的网络资源之后,审阅者可以查看其他资源的用法来识别可能存在的性能问题。 代码是否使用锁来访问共享资源?这可能导致性能下降或死锁? 锁是性能杀手 11,并且在多线程环境中很难推理。可以考虑以下模式;只有一个线程写入/改变值,而所有其他线程都可以自由读取;或者使用无锁算法 12。 ## 代码中是否有可能引起内存泄露的东西? 在 Java 中,一些常见的原因可以是:可变的静态字段、使用 ThreadLocal 以及使用 ClassLoader13。 ## 应用程序的内存占用有无限增长的可能性吗? 这不同于内存泄露——内存泄露是未使用的对象不能被垃圾回收器收集。但任何语言,甚至是非垃圾回收的语言,都可以创建 11 12 13 数据结构无限增长。如果作为审阅者,你看到列表或映射中不断地添加新值,询问列表或映射何时被丢弃或修剪。 ## 无限内存占用 在上面的代码审查中,我们可以看到所有来自 Twitter 的消息都被添加到一个映射中。如果我们更全面地检查这个类,我们会发现 `allTwitterUsers` 映射永远不会被修剪,`TwitterUser` 中的推文列表也没有被清理。取决于我们正在监控多少用户以及我们多久添加一次推文,这个映射可能会变得非常大,速度非常快。 ## 代码是否关闭了连接/流? 忘记关闭连接或文件/网络流是很常见的。当你审查他人的代码时,无论使用什么语言,如果你正在使用的文件、网络或数据库连接没有被正确关闭,请确保它们被正确关闭。 关闭资源 原始代码作者很容易错过这个问题,因为上面的代码可以顺利编译。作为审阅者,你应该注意在方法退出之前连接、语句和结果集需要关闭。在 Java 7 中,由于 try-with-resources14 的存在,这变得容易管理了许多。下面的截图显示了一个代码审查的结果,其中作者已经将代码更改为使用 try-with-resources。 14 try-with-resources # 资源池是否配置正确? 一个环境的最佳配置将取决于许多因素,因此作为审阅者,你可能不会立即知道例如数据库连接池的大小是否合适。但你可以一眼看出一些事情,例如是否太小(比如设置为 1)或太大(数百万个线程)。这些值的微调需要在一个尽可能接近真实环境的环境中进行测试,但代码审查中可以很容易识别的一个共同问题是当资源池(例如线程池或连接池)真正太大时。逻辑表明越大越好,但当然每个这样的对象都占用资源,管理数千个它们的开销通常比保持它们可用带来的好处高得多。如果有疑问,默认设置通常是好的起点。偏离默认设置的代码应该通过某种测试或计算来证明其价值。 审阅者可以轻松识别的警告信号 一些类型的代码立即表明可能存在性能问题。这将取决于使用的语言和库(请在评论中告诉我们您环境中“代码异味”的情况)。 ## 反射 在 Java 中,使用反射比不使用反射要慢 15。如果你正在审查包含反射的代码,询问是否绝对需要这样。 15 反射 上面的截图显示了一个审阅者在 Upsource 中点击一个方法来检查它的来源,你可以看到这个方法是返回了 java.lang.reflect 包中的某个东西,这应该是一个警告信号。 ## 超时 当你审查代码时,你可能不知道操作的正确超时时间是什么,但你应该思考“当这个超时正在计时下降时,对整个系统的影响是什么?”。作为审阅者,你应该考虑最坏的情况——在 5 分钟的超时计时过程中应用程序是否被阻塞?如果将这个设置为 1 秒钟,最坏会发生什么?如果代码的作者不能证明超时长度的合理性,而你,作为审查者,不知道选定值的利弊,那么这是一个邀请了解这些影响的某人参与的好时机。不要等到你的用户告诉你性能问题时才行动。 ## 并行 代码是否使用多个线程来执行一个简单的操作?这会增加更多时间和复杂性而不是提高性能吗?在 Java 8 中,这可能比显式创建新线程更微妙:代码是否使用了 Java 8 的新颖并行流但没有从并行性中获得好处?例如,在一个小数目的元素上或在对一个执行非常简单操作的流上使用并行流可能比在一个顺序流上执行操作要慢。 并行 在上面的代码中,添加并行的使用不太可能给我们带来任何好处——这个流正在作用于推文,因此是 140 个字符的字符串。对一个将要处理这么少单词的操作进行并行化,可能不会提高性能,并且将操作分散到多个并行线程的成本几乎肯定高于任何收益。 ## 正确性 这些事情并不一定会影响您系统的性能,但它们在很大程度上与在多线程环境中运行有关,因此与主题相关。 ## 代码是否使用适用于多线程环境的数据结构? ## 线程安全 在上面的代码中,作者在第 12 行使用 ArrayList 存储所有会话。然而,这个代码,特别是 onOpen 方法,是由多个线程调用的,所以 sessions 字段需要是一个线程安全的集合结构。对于这种情况,我们有几种选项:我们可以使用 Vector16,我们可以使用 Collections.synchronizedList()17 来创建一个线程安全的 List,但最合适的选项可能是使用 CopyOnWriteArrayList18,因为列表变化的频率远低于它被读取的频率。 线程安全 17 18 ## 代码是否容易受到竞争条件的影响? 编写在多线程环境中使用时可能引起微妙竞争条件的代码是相当简单的。例如: ## 竞争条件 尽管增量代码只在一行(第 16 行),但在另一个线程在这段代码获取它和设置新值之间,仍然有可能增加 orders。作为审阅者,留意那些非原子的 get 和 set 组合 19。 ## 代码是否正确使用锁? 与竞争条件相关,作为审阅者你应该检查被审查的代码没有允许多个线程以可能冲突的方式修改值。代码可能需要同步 20、锁 21 或原子变量 22 来控制代码块的更改。 ## 性能测试对于代码是否有价值? 很容易写出差的微基准测试 23,例如。或者如果测试使用与生产数据完全不类似的数据,它可能会给出误导性的结果。 ## 缓存 虽然缓存可能是防止过多外部请求的一种方式,但它也带来了自己的挑战。如果被审查的代码使用了缓存,你应该寻找一些常见的问题,例如,不正确地失效缓存项。 ## 代码级别的优化 如果你正在审查代码,而你是一名开发人员,下面的部分可能有优化建议是你希望提出的。作为一个团队,你需要提前了解性能对你们有多重要,以及这些类型的优化是否对你的代码有好处。 对于大多数不是构建低延迟应用的机构来说,这些优化可能属于过早优化的 24 范畴。 - 代码是否不需要同步/锁时也使用了?如果代码总是运行在单个线程上,锁是不必要的开销。 - 代码是否在没有必要时使用线程安全的集合结构?例如,Vector 可以被 ArrayList 替换吗? - 代码是否使用性能较差的数据结构来执行动作?例如,使用链表但需要定期在其中搜索单项。 - 代码是否使用锁或同步时可以使用原子变量代替? - 代码能否从延迟加载中受益? - if 语句或其他逻辑能否通过将最快的评估放在首位来短路? - 代码中是否有大量字符串格式化?这能否更高效? - 日志语句是否使用了字符串格式化?它们是否被一个检查日志级别的 if 保护,或者使用一个求值懒惰的供应商? ## 日志 上面的代码只有在记录器设置为 FINEST 时才会记录消息。然而,昂贵的字符串格式会每次都发生,不管消息实际上是否被记录。 ### 日志 通过确保只在这个代码在日志级别设置到会将消息写入日志的价值上运行来提高性能,如上面的代码所示。 Logging 24 在 Java 8 中,这些性能收益可以通过使用 lambda 表达式来实现,而无需样板代码。由于 lambda 的使用方式,只有在消息实际上被记录时才会进行字符串格式化。这应该是 Java 8 中首选的方法。 ## 总结 正如我在原始列表中所列出的审查时要寻找的事项一样,本章突出了你的团队在审查期间可能希望一贯检查的一些区域。这将取决于你项目的性能要求。 尽管这个章节针对所有开发人员,但许多例子都是 Java / JVM 特定的。我想以一些简单的建议结束,供 Java 代码的审阅者查找,这将为 JVM 优化你的代码提供良好的机会,这样你就不用: - 编写小的方法和类 - 保持逻辑简单——没有深层嵌套的 if 或循环 对人类来说越容易理解的代码,JIT 编译器 25 就越有可能理解你的代码到足以优化的程度。这应该在代码审查期间很容易看出——如果代码看起来可理解和干净,它也有很好的机会表现良好。 当谈到性能时,要了解有些领域你可能能够在代码审查期间得到快速胜利(例如,不必要的数据库调用) that can be identified during code review, and some areas that will be tempting to comment on (like the code-level optimisations) that might not gain enough value for the needs of your system. 25 ## [打造个性化 GitHub 主页:实用模板与插件推荐](https://blog.dong4j.site/posts/d8463a4b.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 花了点时间装饰了一下 GitHub 主页, 感觉好过得去 🙉. GItHub 个人主页相关的项目非常多, 也没有一个个去尝试, 所以这里先做一个记录, 先做一个资源收集, 等有时间再去折腾. 目前的效果如下: [dong4j 的 GitHub 主页](https://github.com/dong4j) ![20250211142640_FMSbhYAb.webp](https://cdn.dong4j.site/source/image/20250211142640_FMSbhYAb.webp) ## 统计类 ### Metrics ![20250211145345_IvIe11kA.webp](https://cdn.dong4j.site/source/image/20250211145345_IvIe11kA.webp) 获得类似上图的 GitHub 数据统计,需要用到一个在线工具[「Metrics」](https://metrics.lecoq.io/embed?user=),打开网站之后,在左侧输入你的 GitHub ID,稍等一会,就会返回右侧所有和你相关的数据。 ### GitHub Stats Card 自述文件中获取动态生成的 GitHub 统计信息 --> [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) ![20250211145629_Yr4AUODL.webp](https://cdn.dong4j.site/source/image/20250211145629_Yr4AUODL.webp) ### Most used languages 自述文件中添加使用编程语言对比统计图 --> [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) ![20250211150016_PRbKlFSs.webp](https://cdn.dong4j.site/source/image/20250211150016_PRbKlFSs.webp) ### Github Profile Trophy 添加奖杯信息--> [github-profile-trophy](https://github.com/ryo-ma/github-profile-trophy) ![20250211150119_lR1xPDHT.webp](https://cdn.dong4j.site/source/image/20250211150119_lR1xPDHT.webp) ### GitHub Readme Activity Graph 动态生成的活动图,用于显示过去 31 天的 GitHub 活动。 --> [github-readme-activity-graph](https://github.com/Ashutosh00710/github-readme-activity-graph) ![20250211151121_AY6a7pzd.webp](https://cdn.dong4j.site/source/image/20250211151121_AY6a7pzd.webp) ### GitHub streak 在 README 中展示连续提交代码的次数。 --> [github-readme-streak-stats](https://github.com/DenverCoder1/github-readme-streak-stats) ![20250211151401_ufzhtpxc.webp](https://cdn.dong4j.site/source/image/20250211151401_ufzhtpxc.webp) ### GitHub Profile Views Counter [github-profile-views-counter](https://github.com/antonkomarev/github-profile-views-counter) ![20250211155918_8yGEdjik.webp](https://cdn.dong4j.site/source/image/20250211155918_8yGEdjik.webp) ``` ![Profile views](https://komarev.com/ghpvc/?username=dong4j&color=0366d6) ``` ### Visitor Badge https://visitor-badge.laobi.icu/ ![20250211160100_U8ImH8vl.webp](https://cdn.dong4j.site/source/image/20250211160100_U8ImH8vl.webp) ``` ![Visitors](https://visitor-badge.laobi.icu/badge?page_id=dong4j) ``` ### Stats Cards 在 README 中展示你在一些流行的网站的数据。 --> **[stats-cards](https://github.com/songquanpeng/stats-cards)** ![20250211151602_ATOCbJe3.webp](https://cdn.dong4j.site/source/image/20250211151602_ATOCbJe3.webp) ### Github OG Image [github-og-image](https://github.com/ccbikai/github-og-image) 提取 Github OpenGraph 图片用于卡片预览: ![20250211160837_xq6T4gRh.webp](https://cdn.dong4j.site/source/image/20250211160837_xq6T4gRh.webp) 另一个不依赖 Cloudflare 版本: https://github.com/dong4j/github-og-image-node 什么是: [Open Graph 协议](https://ogp.me/) ## 模版仓库 ### awesome-github-profiles **[awesome-github-profiles](https://github.com/EddieHubCommunity/awesome-github-profiles)** ![20250211152511_sRcGUCZq.webp](https://cdn.dong4j.site/source/image/20250211152511_sRcGUCZq.webp) ### awesome-github-profile-readme [awesome-github-profile-readme](https://github.com/abhisheknaiidu/awesome-github-profile-readme) ![20250211152412_yY3aozcb.webp](https://cdn.dong4j.site/source/image/20250211152412_yY3aozcb.webp) ### Awesome-Profile-README-templates [Awesome-Profile-README-templates](https://github.com/kautukkundan/Awesome-Profile-README-templates) ![20250211152719_Y3Rcdv84.webp](https://cdn.dong4j.site/source/image/20250211152719_Y3Rcdv84.webp) ### github-profile-readme-generator [github-profile-readme-generator](https://github.com/rahuldkjain/github-profile-readme-generator) ![20250211152919_2TCPdLJe.webp](https://cdn.dong4j.site/source/image/20250211152919_2TCPdLJe.webp) ### beautify-github-profile **[beautify-github-profile](https://github.com/rzashakeri/beautify-github-profile)** ![20250211154240_CobMgG77.webp](https://cdn.dong4j.site/source/image/20250211154240_CobMgG77.webp) ## GitHub Actions ### waka-readme-stats [waka-readme-stats](https://github.com/anmol098/waka-readme-stats) ![20250211154711_JxOuQ4K4.webp](https://cdn.dong4j.site/source/image/20250211154711_JxOuQ4K4.webp) ### blog-post-workflow [blog-post-workflow](https://github.com/gautamkrishnar/blog-post-workflow) ![20250211155132_AFCLjUHL.webp](https://cdn.dong4j.site/source/image/20250211155132_AFCLjUHL.webp) ### snk [snk](https://github.com/Platane/snk) ![20250211155403_2HTpjSAZ.webp](https://cdn.dong4j.site/source/image/20250211155403_2HTpjSAZ.webp) ## 其他 ### Shields(GitHub 徽章) 为你的开源项目生成高质量小徽章图标,直接复制链接使用。 --> [Shields.io](https://shields.io/) ![20250211150413_uYyk2TlC.webp](https://cdn.dong4j.site/source/image/20250211150413_uYyk2TlC.webp) **同类型推荐:** https://badgen.net/ ![20250211153716_0vSXxMPP.webp](https://cdn.dong4j.site/source/image/20250211153716_0vSXxMPP.webp) https://badgie.me https://github.com/pavi2410/PlayBadges https://nodei.co ### 3D 效果 [github-profile-3d-contrib](https://github.com/yoshi389111/github-profile-3d-contrib) ![20250211153931_bEFEnkOW.webp](https://cdn.dong4j.site/source/image/20250211153931_bEFEnkOW.webp) ### 打字特效 循环打字的效果,很炫酷,--> https://github.com/DenverCoder1/readme-typing-svg ![20250211151704_HXOUtfSR.webp](https://cdn.dong4j.site/source/image/20250211151704_HXOUtfSR.webp) ### emoji 符号及技能图标 https://www.webfx.com/tools/emoji-cheat-sheet https://simpleicons.org https://github.com/tandpfun/skill-icons https://github.com/alexandresanlim/Badges4-README.md-Profile ## 相关资源与教程 - [https://www.yuque.com/janeyork/blog/sw2sqlfgg47u1g2s?singleDoc#](https://www.yuque.com/janeyork/blog/sw2sqlfgg47u1g2s?singleDoc#) - https://zhuanlan.zhihu.com/p/454597068 - GitHub Token 生成地址: https://github.com/settings/tokens - 添加 action 的变量: https://github.com/{username}/{username}/settings/secrets/actions - 设置仓库 Action 权限 ![20250109143952_tqgikaid.webp](https://cdn.dong4j.site/source/image/20250109143952_tqgikaid.webp) ## [「Hexo」anzhiyu 主题:朋友圈部署教程](https://blog.dong4j.site/posts/f7489f5a.md) ![/images/cover/20250103184959_Qh3w85TN.webp](https://cdn.dong4j.site/source/image/20250103184959_Qh3w85TN.webp) 安知鱼主题自带朋友圈功能, 不过需要自行部署后端, 下面说说我的部署过程. ## 创建朋友圈页面 在 Hexo 博客根目录下打开终端,输入 ```bash hexo new page fcircle ``` 打开 `source/fcircle/index.md`,添加一行 `type: 'fcircle'`: ```yaml --- title: 朋友圈 date: 2022-11-21 17:06:17 comments: false aside: false top_img: false type: "fcircle" --- ``` ## 主题配置 配置菜单: ```yaml menu: 友链: 朋友圈: /fcircle/ || anzhiyu-icon-artstation ``` 开启朋友圈: ```yaml # 朋友圈配置 friends_vue: enable: true vue_js: xxx apiurl: xxx top_background: xxx ``` | 参数 | 备选值/类型 | 解释 | | :------------- | :---------- | :----------------------------- | | enable | boolean | 【必须】是否启用 | | vue_js | url | 【必须】朋友圈前端构建后的 url | | apiurl | string | 【必须】朋友圈后端 url | | top_background | url | 【可选】朋友圈顶部背景图 | 前端原项目地址:[hexo-circle-of-friends-front](https://github.com/anzhiyu-c/hexo-circle-of-friends-front), 需要自行编译此项目. ## 朋友圈前端 ```bash git clone git@github.com:anzhiyu-c/hexo-circle-of-friends-front.git cd hexo-circle-of-friends-front npm install ``` 修改配置文件(`src/utils/config.ts`): ```javascript const DefaultConfig: any = { private_api_url: "https://yourdomain/", public_api_url: "https://yourdomain/", page_default_number: 20, page_turning_number: 20, error_img: "https://sdn.geekzu.org/avatar/57d8260dfb55501c37dde588e7c3852c", sort_rule: "created", defaultFish: 100, hungryFish: 100, }; export default DefaultConfig; ``` 最后编译: ```bash npm run build ``` 执行成功后会在 `dist/assets/` 目录下生成一个 JS 文件, 我将它改名为 `friends.js`, 我将它上传到了图床, 然后在配置中使用 url 获取此文件. ```yaml # 朋友圈配置 friends_vue: enable: true vue_js: https://{friends.js}的图床地址 apiurl: xxx top_background: xxx ``` ## 朋友圈后端 后端服务我使用 Docker 部署到本地的服务器, 然后使用 Nginx 向外暴露服务 ```bash git clone git@github.com:Rock-Candy-Tea/hexo-circle-of-friends.git cd hexo-circle-of-friends python3 deploy.py ``` > 建议提前下载镜像: > > ```bash > docker pull yyyzyyyz/fcircle:latest > ``` 成功运行后会进入交互界面, 根据执行的情况选择即可: ``` $ python3 deploy.py 欢迎使用部署工具,选择部署方式: —————————————————————————————————— | 1、server | 2、docker | q、退出 | —————————————————————————————————— 2 请选择: —————————————————————————————————— | 1、部署 | 2、取消部署 | q、退出 | —————————————————————————————————— 1 指定api服务端口,按回车不输入则默认为8000 7773 989b605b330db943cf172dc893bf4dd3706f0460e18fc5bbaa48e7129a1d2f56 已部署! 欢迎使用部署工具,选择部署方式: —————————————————————————————————— | 1、server | 2、docker | q、退出 | —————————————————————————————————— q 再见! ``` 尝试访问 API 验证部署是否成功: ```bash curl http://192.168.31.7:7773/all ``` 出现数据即为部署成功。 接下来通过 Nginx 配置反向代理和 HTTPS 证书, 比如最终是 `https://friends.dong4j.tele/`, 朋友圈完成配置: ```yaml # 朋友圈配置 friends_vue: enable: true vue_js: https://{friends.js}的图床地址 apiurl: https://friends.dong4j.tele/ top_background: { 朋友圈页面头图地址 } ``` ## 效果 ![20250103180000_Iq6UCEvX.webp](https://cdn.dong4j.site/source/image/20250103180000_Iq6UCEvX.webp) 可以通过页面右下角的 **设置** 进入设置页面, 首次登录输入的密码将成为管理员密码, 详细的配置项可查看 [官方文档](https://fcircle-doc.yyyzyyyz.cn/#/settings?id=%e9%a1%b9%e7%9b%ae%e9%85%8d%e7%bd%ae) ## [个性化加载动画,让你的 Hexo 博客更炫酷!](https://blog.dong4j.site/posts/a239fff8.md) ![/images/cover/20250103184959_iDTIF6Dq.webp](https://cdn.dong4j.site/source/image/20250103184959_iDTIF6Dq.webp) 参考 [【Hexo博客】自定义Butterfly主题 Loading 加载动画](https://blog.meta-code.top/2022/06/18/2022-73/) 和 [Hexo的Butterfly下自定义加载动画之小汽车动画的实现](https://blog.zhheo.com/p/32776e99.html) 实现了在 anzhiyu 主题下的自定义加载动画。 ## 添加 loading 模版 新建目录: `themes/anzhiyu/layout/includes/loading/load_style`, 添加如下 pug: {% tabs loading template %} ```javascript #loading-box .carplay script. const preloader = { endLoading: () => { document.getElementById('loading-box').classList.add("loaded"); }, initLoading: () => { document.getElementById('loading-box').classList.remove("loaded") } } window.addEventListener('load',()=> { preloader.endLoading() }) setTimeout(function(){preloader.endLoading();},10000) document.getElementById('loading-box').addEventListener('click',()=> {preloader.endLoading()}) if (!{theme.pjax && theme.pjax.enable}) { document.addEventListener('pjax:send', () => { preloader.initLoading() }) document.addEventListener('pjax:complete', () => { preloader.endLoading() }) } ``` ```javascript #loading-box .loading-left-bg .loading-right-bg .spinner-box .configure-border-1 .configure-core .configure-border-2 .configure-core .loading-word= _p('loading') script. const preloader = { endLoading: () => { document.getElementById('loading-box').classList.add("loaded"); }, initLoading: () => { document.getElementById('loading-box').classList.remove("loaded") } } window.addEventListener('load',()=> { preloader.endLoading() }) setTimeout(function(){preloader.endLoading();},10000) document.getElementById('loading-box').addEventListener('click',()=> {preloader.endLoading()}) if (!{theme.pjax && theme.pjax.enable}) { document.addEventListener('pjax:send', () => { preloader.initLoading() }) document.addEventListener('pjax:complete', () => { preloader.endLoading() }) } ``` ```javascript #loading-box .gear-loader .gear-loader_overlay .gear-loader_cogs .gear-loader_cogs__top .gear-top_part .gear-top_part .gear-top_part .gear-top_hole .gear-loader_cogs__left .gear-left_part .gear-left_part .gear-left_part .gear-left_hole .gear-loader_cogs__bottom .gear-bottom_part .gear-bottom_part .gear-bottom_part .gear-bottom_hole script. const preloader = { endLoading: () => { document.getElementById('loading-box').classList.add("loaded"); }, initLoading: () => { document.getElementById('loading-box').classList.remove("loaded") } } window.addEventListener('load',()=> { preloader.endLoading() }) setTimeout(function(){preloader.endLoading();},10000) document.getElementById('loading-box').addEventListener('click',()=> {preloader.endLoading()}) if (!{theme.pjax && theme.pjax.enable}) { document.addEventListener('pjax:send', () => { preloader.initLoading() }) document.addEventListener('pjax:complete', () => { preloader.endLoading() }) } ``` ```javascript - loading_img = theme.preloader.avatar ? theme.preloader.avatar : theme.avatar.img #loading-box(onclick='document.getElementById("loading-box").classList.add("loaded")') .loading-bg img.loading-img(alt="加载头像" class='nolazyload' src=url_for(loading_img)) .loading-image-dot script. const preloader = { endLoading: () => { document.getElementById('loading-box').classList.add("loaded"); }, initLoading: () => { document.getElementById('loading-box').classList.remove("loaded") } } window.addEventListener('load',()=> { preloader.endLoading() }) setTimeout(function(){preloader.endLoading();},10000) if (!{theme.pjax && theme.pjax.enable}) { document.addEventListener('pjax:send', () => { preloader.initLoading() }) document.addEventListener('pjax:complete', () => { preloader.endLoading() }) } ``` ```javascript - var loadimage = theme.preloader.load_image ? theme.preloader.load_image : theme.error_img.post_page #loading-box .loading-left-bg .loading-right-bg img.load-image(src=url_for(loadimage) alt='') script. const preloader = { endLoading: () => { document.getElementById('loading-box').classList.add("loaded"); }, initLoading: () => { document.getElementById('loading-box').classList.remove("loaded") } } window.addEventListener('load',()=> { preloader.endLoading() }) setTimeout(function(){preloader.endLoading();},10000) document.getElementById('loading-box').addEventListener('click',()=> {preloader.endLoading()}) if (!{theme.pjax && theme.pjax.enable}) { document.addEventListener('pjax:send', () => { preloader.initLoading() }) document.addEventListener('pjax:complete', () => { preloader.endLoading() }) } ``` ```javascript #loading-box .loading-left-bg .loading-right-bg .iron-container.iron-circle .iron-box1.iron-circle.iron-center .iron-box2.iron-circle.iron-center .iron-box3.iron-circle.iron-center .iron-box4.iron-circle.iron-center .iron-box5.iron-circle.iron-center .iron-box6.iron-circle .iron-coil(style='--i: 0') .iron-coil(style='--i: 1') .iron-coil(style='--i: 2') .iron-coil(style='--i: 3') .iron-coil(style='--i: 4') .iron-coil(style='--i: 5') .iron-coil(style='--i: 6') .iron-coil(style='--i: 7') script. const preloader = { endLoading: () => { document.getElementById('loading-box').classList.add("loaded"); }, initLoading: () => { document.getElementById('loading-box').classList.remove("loaded") } } window.addEventListener('load',()=> { preloader.endLoading() }) setTimeout(function(){preloader.endLoading();},10000) document.getElementById('loading-box').addEventListener('click',()=> {preloader.endLoading()}) if (!{theme.pjax && theme.pjax.enable}) { document.addEventListener('pjax:send', () => { preloader.initLoading() }) document.addEventListener('pjax:complete', () => { preloader.endLoading() }) } ``` ```javascript link(rel="stylesheet", href=url_for(theme.preloader.pace_css_url || theme.asset.pace_default_css)) script(async src=url_for(theme.asset.pace_js), data-pace-options='{ "restartOnRequestAfter":false,"eventLag":false}') ``` ```javascript #loading-box .loading-left-bg .loading-right-bg .scarecrow .scarecrow__hat .scarecrow__ribbon .scarecrow__head .scarecrow__eye .scarecrow__eye .scarecrow__mouth .scarecrow__pipe .scarecrow__body .scarecrow__glove.scarecrow__glove--l .scarecrow__sleeve.scarecrow__sleeve--l .scarecrow__bow .scarecrow__shirt .scarecrow__coat .scarecrow__waistcoat .scarecrow__sleeve.scarecrow__sleeve--r .scarecrow__glove.scarecrow__glove--r .scarecrow__coattails .scarecrow__pants .scarecrow__arms .scarecrow__leg script. const preloader = { endLoading: () => { document.getElementById('loading-box').classList.add("loaded"); }, initLoading: () => { document.getElementById('loading-box').classList.remove("loaded") } } window.addEventListener('load',()=> { preloader.endLoading() }) setTimeout(function(){preloader.endLoading();},10000) document.getElementById('loading-box').addEventListener('click',()=> {preloader.endLoading()}) if (!{theme.pjax && theme.pjax.enable}) { document.addEventListener('pjax:send', () => { preloader.initLoading() }) document.addEventListener('pjax:complete', () => { preloader.endLoading() }) } ``` ```javascript #loading-box .triangles-wrap .triangles-eiz .triangles-seiz .triangles-sei .triangles-fei .triangles-feir .triangles-trei .triangles-dvai .triangles-ein .triangles-zero script. const preloader = { endLoading: () => { document.getElementById('loading-box').classList.add("loaded"); }, initLoading: () => { document.getElementById('loading-box').classList.remove("loaded") } } window.addEventListener('load',()=> { preloader.endLoading() }) setTimeout(function(){preloader.endLoading();},10000) document.getElementById('loading-box').addEventListener('click',()=> {preloader.endLoading()}) if (!{theme.pjax && theme.pjax.enable}) { document.addEventListener('pjax:send', () => { preloader.initLoading() }) document.addEventListener('pjax:complete', () => { preloader.endLoading() }) } ``` ```javascript #loading-box .loading-left-bg .loading-right-bg .wizard-scene .wizard-objects .wizard-square .wizard-circle .wizard-triangle .wizard .wizard-body .wizard-right-arm .wizard-right-hand .wizard-left-arm .wizard-left-hand .wizard-head .wizard-beard .wizard-face .wizard-adds .wizard-hat .wizard-hat-of-the-hat .wizard-four-point-star.--first .wizard-four-point-star.--second .wizard-four-point-star.--third script. const preloader = { endLoading: () => { document.getElementById('loading-box').classList.add("loaded"); }, initLoading: () => { document.getElementById('loading-box').classList.remove("loaded") } } window.addEventListener('load',()=> { preloader.endLoading() }) setTimeout(function(){preloader.endLoading();},10000) document.getElementById('loading-box').addEventListener('click',()=> {preloader.endLoading()}) if (!{theme.pjax && theme.pjax.enable}) { document.addEventListener('pjax:send', () => { preloader.initLoading() }) document.addEventListener('pjax:complete', () => { preloader.endLoading() }) } ``` {% endtabs %} ## 修改 loading 配置 修改 `themes/anzhiyu/layout/includes/loading/index.pug` ```javascript if theme.preloader.source === 'heo' include ./load_style/heo.pug else if theme.preloader.source === 'car' include ./load_style/car.pug else if theme.preloader.source === 'gear' include ./load_style/gear.pug else if theme.preloader.source === 'ironheart' include ./load_style/ironheart.pug else if theme.preloader.source === 'scarecrow' include ./load_style/scarecrow.pug else if theme.preloader.source === 'triangles' include ./load_style/triangles.pug else if theme.preloader.source === 'wizard' include ./load_style/wizard.pug else if theme.preloader.source === 'image' include ./load_style/image.pug else if theme.preloader.source === 'default' include ./load_style/default.pug else include ./load_style/heo.pug include ./load_style/pace.pug ``` ## 添加 loading 样式模版 新建动画样式模板存放的文件夹,如无特别提示,所有动画样式均存放在 `themes/anzhiyu/layout/includes/loading/load_style` 目录下: {% tabs loading style template %} 源文件来自于: [Hexo的Butterfly下自定义加载动画之小汽车动画的实现](https://blog.zhheo.com/p/32776e99.html), 不过做了相应适配, 可直接使用这里的样式. ```css .carplay { box-sizing: border-box; --black: #1a1c20; --white: #fff; --green: #00c380; --d-green: #019b66; --gray: #c1c1c1; --l-gray: #c4c4c4; --m-gray: #373838; --d-gray: #282724; --d-blue: #202428; --orange: #ef6206; --yellow: #dfa500; --l-yellow: #deb953; --light: #fbfbfb; --n-road: -4em; --p-road: 7em; background-color: var(--green); } .carplay *, .carplay *::before, .carplay *::after { box-sizing: inherit; } .carplay::before, .carplay::after { content: " "; position: absolute; z-index: 1002; } .carplay { margin: 0; height: 100vh; display: flex; overflow: hidden; position: relative; align-items: center; justify-content: center; background-repeat: no-repeat; animation: car 3.5s cubic-bezier(0.57, 0.63, 0.49, 0.65) infinite; background-image: /* ===rubber-l */ radial-gradient( circle at 49% 117%, var(--black) 37%, transparent 38% ), radial-gradient(circle at -24% 50%, var(--white) 31%, transparent 49%), radial-gradient(2.95em 2.5em at 52% 1.2%, var(--black) 37%, transparent 38%), radial-gradient(2.95em 2.5em at 52% 1.2%, var(--black) 37%, transparent 38%), linear-gradient(var(--black) 100%, transparent 0), /* rubber-l=== */ /* ===rubber-r */ radial-gradient(circle at 49% 117%, var(--black) 37%, transparent 38%), radial-gradient(circle at 124% 50%, var(--white) 31%, transparent 49%), radial-gradient(2.95em 2.5em at 48% 1.2%, var(--black) 37%, transparent 38%), radial-gradient(2.95em 2.5em at 48% 1.2%, var(--black) 37%, transparent 38%), linear-gradient(var(--black) 100%, transparent 0), /* rubber-r=== */ /* ===shadow */ linear-gradient(var(--d-green) 100%, transparent 0); /* shadow=== */ background-size: /* ===rubber-l */ 2.5em 2.5em, 0.7em 0.6em, 2.5em 0.9em, 2.5em 0.9em, 1.95em 3.9em, /* rubber-l=== */ /* ===rubber-r */ 2.5em 2.5em, 0.7em 0.6em, 2.5em 0.9em, 2.5em 0.9em, 1.95em 3.9em, /* rubber-r=== */ /* ===shadow */ 13em 0.9em; /* shadow=== */ background-position: /* ===rubber-l */ calc(50% - 6.4em) calc(50% - 1.7em), calc(50% - 5.26em) calc(50% - -3.4em), calc(50% - 6.5em) calc(50% - -3.8em), calc(50% - 4.3em) calc(50% - -3.2em), calc(50% - 6.58em) calc(50% - -1.5em), /* rubber-l=== */ /* ===rubber-r */ calc(50% - -6.45em) calc(50% - 1.7em), calc(50% - -5.26em) calc(50% - -3.4em), calc(50% - -6.5em) calc(50% - -3.8em), calc(50% - -4.3em) calc(50% - -3.2em), calc(50% - -6.58em) calc(50% - -1.5em), /* rubber-r=== */ /* ===shadow */ center calc(50% - -3.8em); /* shadow=== */ } .carplay::before { width: 15.5em; height: 62.9em; top: calc(50% - 26.2em); background-size: 24.6% 491%; background-repeat: no-repeat; background-position: center 0; animation: line 1.5s infinite linear, move-road 3.5s infinite linear; transform: perspective(311px) rotateX(83deg) translate3d(var(--n-road), -11.975em, 0); background-image: repeating-linear-gradient( to top, var(--white), var(--white) 4.6%, transparent 0, transparent 13.01% ); } .carplay::after { width: 15.2em; height: 13.2em; top: calc(50% - 8.8em); left: calc(50% - 7.55em); background-repeat: no-repeat; animation: light 1s linear infinite, shake 3.5s linear infinite; background-image: /* ===ceiling */ radial-gradient( 58em 20em at 50% 215%, transparent 20%, var(--white) 20.5%, var(--white) 20.8%, var(--green) 21.5% ), /* ceiling=== */ /* ===antenna */ radial-gradient(circle at center 100%, var(--black) 30%, transparent 0), /* antenna=== */ /* ===antenna */ linear-gradient(var(--white) 100%, transparent 0), /* antenna=== */ /* ===glass-l */ radial-gradient( 17.8em 37em at 70% 240%, var(--black) 30%, transparent 30.5% ), /* glass-l=== */ /* ===glass-r */ radial-gradient( 17.8em 37em at 31% 240%, var(--black) 30%, transparent 30.5% ), /* glass-r=== */ /* ===light */ radial-gradient( circle, var(--light) 48%, var(--black) 52%, var(--black) 59%, transparent 62% ), /* light=== */ /* ===light */ radial-gradient( circle, var(--light) 48%, var(--black) 52%, var(--black) 59%, transparent 62% ), /* light=== */ /* ===hood-ro */ radial-gradient(1em 1em at 102% 100%, var(--m-gray) 28%, #f3f3f3 30%), /* hood-ro=== */ /* ===hood-ro */ radial-gradient(1em 1em at 97% -7%, var(--m-gray) 28%, var(--l-gray) 30%), /* hood-ro=== */ /* ===hood-ro */ radial-gradient(1em 1em at -6% 102%, var(--m-gray) 28%, #efefef 30%), /* hood-ro=== */ /* ===hood-ro */ radial-gradient(1em 1em at -6% -1%, var(--m-gray) 28%, var(--l-gray) 30%), /* hood-ro=== */ /* ===hood-f */ linear-gradient( to top, var(--m-gray) 50%, var(--d-gray) 0, var(--d-gray) 58%, var(--m-gray) 0 ), /* hood-f=== */ /* ===hood-e */ linear-gradient( to top, var(--l-gray) 30%, var(--white) 100%, transparent 0 ), /* hood-e=== */ /* ===hood-l */ radial-gradient( 16.4em 30.1em at 209% 333%, var(--white) 30%, transparent 30.2% ), /* hood-l=== */ /* ===hood-r */ radial-gradient( 16.4em 30.1em at -109% 333%, var(--white) 30%, transparent 30.2% ), /* hood-r=== */ /* ===hood-o */ linear-gradient(var(--gray) 100%, transparent 0), /* hood-o=== */ /* ===hood */ linear-gradient(var(--white) 100%, transparent 0), /* hood=== */ /* ===mirror */ radial-gradient(6.7em 2.5em at 154% 8%, var(--black) 30%, transparent 33%), /* mirror=== */ /* ===mirror */ radial-gradient(6.7em 2.5em at -53% 8%, var(--black) 30%, transparent 33%), /* mirror=== */ /* ===guide-l */ linear-gradient(var(--orange) 100%, transparent 0), /* guide-l=== */ /* ===guide-r */ linear-gradient(var(--orange) 100%, transparent 0), /* guide-r=== */ /* ===plaque */ linear-gradient(var(--yellow) 100%, transparent 0), /* plaque=== */ /* ===plaque */ linear-gradient(var(--l-yellow) 100%, transparent 0), /* plaque=== */ /* ===bumper */ linear-gradient(var(--d-blue) 100%, transparent 0), /* bumper=== */ /* ===bumper-l */ radial-gradient(circle at 124% 39%, var(--d-blue) 60%, transparent 64%), /* bumper-l=== */ /* ===bumper-r */ radial-gradient(circle at -44% 39%, var(--d-blue) 60%, transparent 64%), /* bumper-r=== */ /* ===bumper-d */ radial-gradient( 13.2em 2em at 50% 59%, var(--l-gray) 96%, transparent 100% ), /* bumper-d=== */ /* ===bumper-l */ radial-gradient( 1.6em 1.6em at 75% -9%, var(--l-gray) 60%, transparent 64% ), /* bumper-l=== */ /* ===bumper-r */ radial-gradient( 1.6em 1.6em at 15% -9%, var(--l-gray) 60%, transparent 64% ), /* bumper-r=== */ /* ===floor */ linear-gradient(var(--d-blue) 100%, transparent 0), /* floor=== */ /* ===floor-l */ radial-gradient( 6.9em 4.6em at 295% 3%, var(--d-blue) 30%, transparent 31% ), /* floor-l=== */ /* ===floor-r */ radial-gradient( 6.9em 4.6em at -189% 3%, var(--d-blue) 30%, transparent 31% ); /* floor-r=== */ background-size: /*=== ceiling */ 61.5% 20%, /* ceiling=== */ /* ===antenna */ 5% 6%, /* antenna=== */ /*=== antenna */ 0.4% 39%, /* antenna=== */ /* ===glass-l */ 60% 30%, /* glass-l=== */ /* ===glass-r */ 60% 30%, /* glass-r=== */ /* ===light */ 16% 16%, /* light=== */ /* ===light */ 16% 16%, /* light=== */ /* ===hood-ro */ 2.4% 2%, /* hood-ro=== */ /* ===hood-ro */ 2.4% 2.3%, /* hood-ro=== */ /* ===hood-ro */ 2.4% 2.3%, /* hood-ro=== */ /* ===hood-ro */ 2.4% 2.3%, /* hood-ro=== */ /* ===hood-f */ 91% 12%, /* hood-f=== */ /* ===hood-e */ 93.9% 17%, /* hood-e=== */ /* ===hood-l */ 12% 17.5%, /* hood-l=== */ /* ===hood-r */ 12% 17.5%, /* hood-r=== */ /* ===hood-o */ 38% 1.1%, /* hood-o=== */ /* ===hood */ 77% 25%, /* hood=== */ /* ===mirror */ 9% 30%, /* mirror=== */ /* ===mirror */ 9% 30%, /* mirror=== */ /* ===guide-l */ 8.4% 3%, /* guide-l=== */ /* ===guide-r */ 8.4% 3%, /* guide-r=== */ /* ===plaque */ 33% 6.5%, /* plaque=== */ /* ===plaque */ 36% 9%, /* plaque=== */ /* ===bumper */ 87% 30%, /* bumper=== */ /* ===bumper-l */ 10% 12%, /* bumper-l=== */ /* ===bumper-r */ 10% 12%, /* bumper-r=== */ /* ===bumper-d */ 78% 35%, /* bumper-d=== */ /* ===bumper-l */ 11% 8%, /* bumper-l=== */ /* ===bumper-r */ 11% 8%, /* bumper-r=== */ /* ===floor */ 68% 8%, /* floor=== */ /* ===floor-l */ 5% 7%, /* floor-l=== */ /* ===floor-r */ 5% 7%; /* floor-r=== */ background-position: /*=== ceiling */ 50.5% 0, /* ceiling=== */ /* ===antenna */ 90% 37%, /* antenna=== */ /*=== antenna */ 88% 1.2%, /* antenna=== */ /* ===glass-l */ 0 11.7%, /* glass-l=== */ /* ===glass-r */ 100% 11.7%, /* glass-r=== */ /* ===light */ 5% 63%, /* light=== */ /* ===light */ 95% 63%, /* light=== */ /* ===hood-ro */ 4.1% 55.7%, /* hood-ro=== */ /* ===hood-ro */ 4.1% 65.9%, /* hood-ro=== */ /* ===hood-ro */ 95.8% 55.7%, /* hood-ro=== */ /* ===hood-ro */ 95.8% 65.8%, /* hood-ro=== */ /* ===hood-f */ center 62%, /* hood-f=== */ /* ===hood-e */ 49% 63.6%, /* hood-e=== */ /* ===hood-l */ 3.4% 46.2%, /* hood-l=== */ /* ===hood-r */ 96.5% 46.2%, /* hood-r=== */ /* ===hood-o */ center 40.9%, /* hood-o=== */ /* ===hood */ center 50.3%, /* hood=== */ /* ===mirror */ 5.7% 48.6%, /* mirror=== */ /* ===mirror */ 95% 48.6%, /* mirror=== */ /* ===guide-l */ 5% 75.2%, /* guide-l=== */ /* ===guide-r */ 95% 75.2%, /* guide-r=== */ /* ===plaque */ 51% 86%, /* plaque=== */ /* ===plaque */ 51.5% 87.3%, /* plaque=== */ /* ===bumper */ center 71.9%, /* bumper=== */ /* ===bumper-l */ -0.8% 77.8%, /* bumper-l=== */ /* ===bumper-r */ 101.7% 77.8%, /* bumper-r=== */ /* ===bumper-d */ center 80.2%, /* bumper-d=== */ /* ===bumper-l */ 4% 85.9%, /* bumper-l=== */ /* ===bumper-r */ 97% 85.9%, /* bumper-r=== */ /* ===floor */ center 92.5%, /* floor=== */ /* ===floor-l */ 11.7% 92.6%, /* floor-l=== */ /* ===floor-r */ 88.3% 92.6%; } @keyframes line { 100% { background-position: center 100%; } } @keyframes car { 15%, 23% { transform: translate3d(-2.3em, 0, 0); } 36%, 42% { transform: translate3d(-0.8em, 0, 0); } 61%, 71.5% { transform: translate3d(2.8em, 0, 0); } 81%, 88% { transform: translate3d(1.5em, 0, 0); } } @keyframes move-road { 5.5% { transform: perspective(311px) rotateX(83deg) translate3d(var(--n-road), -11.975em, 0); } 27%, 51% { transform: perspective(311px) rotateX(83deg) translate3d(var(--p-road), -11.975em, 0); } 73%, 100% { transform: perspective(311px) rotateX(83deg) translate3d(var(--n-road), -11.975em, 0); } } @keyframes light { 0%, 37% { --light: #fbfbfb; } 50% { --light: #f1f1f1; } 62% { --light: #fbfbfb; } 65% { --light: #f1f1f1; } 95% { --light: #fbfbfb; } 100% { --light: #f1f1f1; } } @keyframes shake { 5%, 26% { transform: rotate(-1.5deg); } 33%, 41% { transform: rotate(-0.5deg); } 48%, 69% { transform: rotate(1.5deg); } 80%, 95% { transform: rotate(0.5deg); } } ``` ```css if hexo-config('preloader') .carplay display: flex; width: 100%; height: 100%; position: fixed; z-index: 1001; opacity: 1; overflow: hidden; transition: 0.2s; &::-webkit-scrollbar display: none; #loading-box user-select none &.loaded .carplay display: none @keyframes loadingAction 0% { opacity: 1; } 100% { opacity: .4; } ``` ```css .loading-bg position fixed z-index 1000 width 50% height 100% background var(--preloader-bg) #loading-box .loading-left-bg @extend .loading-bg left 0 .loading-right-bg @extend .loading-bg right 0 &.loaded z-index -1000 .loading-left-bg transition all 1.0s transform translate(-100%, 0) .loading-right-bg transition all 1.0s transform translate(100%, 0) #loading-box .spinner-box position fixed z-index 1001 display flex justify-content center align-items center width 100% height 100vh .configure-border-1 position absolute padding 3px width 115px height 115px background #ffab91 animation configure-clockwise 3s ease-in-out 0s infinite alternate .configure-border-2 left -115px padding 3px width 115px height 115px background rgb(63, 249, 220) transform rotate(45deg) animation configure-xclockwise 3s ease-in-out 0s infinite alternate .loading-word position absolute color var(--preloader-color) font-size 16px .configure-core width 100% height 100% background-color var(--preloader-bg) &.loaded .spinner-box display none @keyframes configure-clockwise 0% transform rotate(0) 25% transform rotate(90deg) 50% transform rotate(180deg) 75% transform rotate(270deg) 100% transform rotate(360deg) @keyframes configure-xclockwise 0% transform rotate(45deg) 25% transform rotate(-45deg) 50% transform rotate(-135deg) 75% transform rotate(-225deg) 100% transform rotate(-315deg) ``` ```css #loading-box position fixed z-index 1000 width 100vw height 100vh overflow hidden text-align center &.loaded z-index -1000 .gear-loader display none .gear-loader height 100% position relative margin auto width 400px .gear-loader_overlay width 150px height 150px background transparent box-shadow 0px 0px 0px 1000px rgba(255, 255, 255, 0.67), 0px 0px 19px 0px rgba(0, 0, 0, 0.16) inset border-radius 100% z-index -1 position absolute left 0 right 0 top 0 bottom 0 margin auto .gear-loader_cogs z-index -2 width 100px height 100px top -120px !important position absolute left 0 right 0 top 0 bottom 0 margin auto .gear-loader_cogs__top position relative width 100px height 100px transform-origin 50px 50px -webkit-animation rotate 10s infinite linear animation rotate 10s infinite linear div &:nth-of-type(1) transform rotate(30deg) &:nth-of-type(2) transform rotate(60deg) &:nth-of-type(3) transform rotate(90deg) &.gear-top_part width 100px border-radius 10px position absolute height 100px background #f98db9 &.gear-top_hole width 50px height 50px border-radius 100% background white position absolute position absolute left 0 right 0 top 0 bottom 0 margin auto .gear-loader_cogs__left position relative width 80px transform rotate(16deg) top 28px transform-origin 40px 40px animation rotate_left 10s 0.1s infinite reverse linear left -24px height 80px div &:nth-of-type(1) transform rotate(30deg) &:nth-of-type(2) transform rotate(60deg) &:nth-of-type(3) transform rotate(90deg) &.gear-left_part width 80px border-radius 6px position absolute height 80px background #97ddff &.gear-left_hole width 40px height 40px border-radius 100% background white position absolute position absolute left 0 right 0 top 0 bottom 0 margin auto .gear-loader_cogs__bottom position relative width 60px top -65px transform-origin 30px 30px -webkit-animation rotate_left 10.2s 0.4s infinite linear animation rotate_left 10.2s 0.4s infinite linear transform rotate(4deg) left 79px height 60px div &:nth-of-type(1) transform rotate(30deg) &:nth-of-type(2) transform rotate(60deg) &:nth-of-type(3) transform rotate(90deg) &.gear-bottom_part width 60px border-radius 5px position absolute height 60px background #ffcd66 &.gear-bottom_hole width 30px height 30px border-radius 100% background white position absolute position absolute left 0 right 0 top 0 bottom 0 margin auto /* Animations */ @-webkit-keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @-webkit-keyframes rotate_left { from { transform: rotate(16deg); } to { transform: rotate(376deg); } } @keyframes rotate_left { from { transform: rotate(16deg); } to { transform: rotate(376deg); } } @-webkit-keyframes rotate_right { from { transform: rotate(4deg); } to { transform: rotate(364deg); } } @keyframes rotate_right { from { transform: rotate(4deg); } to { transform: rotate(364deg); } } ``` ```css if hexo-config('preloader') .loading-bg display: flex; width: 100%; height: 100%; position: fixed; background: var(--anzhiyu-card-bg); z-index: 1001; opacity: 1; overflow: hidden; transition: 0.2s; animation: showLoading 0.3s 0s backwards; &::-webkit-scrollbar display: none #loading-box user-select none .loading-img width: 100px; height: 100px; border-radius: 50%; margin: auto; border: 4px solid #f0f0f2; animation-duration: 0.2s; animation-name: loadingAction; animation-iteration-count: infinite; animation-direction: alternate; .loading-image-dot width: 30px; height: 30px; background: #6bdf8f; position: absolute; border-radius: 50%; border: 6px solid #fff; top: 50%; left: 50%; transform: translate(18px, 24px); &.loaded .loading-bg opacity: 0; z-index: -1000; @keyframes loadingAction 0% { opacity: 1; } 100% { opacity: .4; } ``` ```css .loading-bg position fixed z-index 1000 width 50% height 100% background var(--preloader-bg) #loading-box .loading-left-bg @extend .loading-bg left 0 .loading-right-bg @extend .loading-bg right 0 &.loaded z-index -1000 .loading-left-bg transition all 1.0s transform translate(-100%, 0) .loading-right-bg transition all 1.0s transform translate(100%, 0) #loading-box position fixed z-index 1000 display -webkit-box display flex -webkit-box-align center align-items center -webkit-box-pack center justify-content center -webkit-box-orient vertical -webkit-box-direction normal flex-direction column flex-wrap wrap width 100vw height 100vh overflow hidden .load-image position fixed z-index 1001 display flex &.loaded .load-image display none ``` ```css .loading-bg position fixed z-index 1000 width 50% height 100% background var(--preloader-bg) #loading-box .loading-left-bg @extend .loading-bg left 0 .loading-right-bg @extend .loading-bg right 0 &.loaded z-index -1000 .loading-left-bg transition all 1.0s transform translate(-100%, 0) .loading-right-bg transition all 1.0s transform translate(100%, 0) #loading-box position fixed z-index 1000 display -webkit-box display flex -webkit-box-align center align-items center -webkit-box-pack center justify-content center -webkit-box-orient vertical -webkit-box-direction normal flex-direction column flex-wrap wrap width 100vw height 100vh overflow hidden &.loaded .iron-container display none .iron-circle border-radius 50% .iron-center position absolute top 50% left 50% transform translate(-50%, -50%) .iron-container z-index 1001 position relative width 300px height 300px border 1px solid rgb(18, 20, 20) background-color #384c50 box-shadow 0 0 32px 8px rgb(18, 20, 20), 0 0 4px 1px rgb(18, 20, 20) inset .iron-box1 width 238px height 238px background-color rgb(22, 26, 27) box-shadow 0 0 4px 1px #52fefe .iron-box2 width 220px height 220px background-color #fff box-shadow 0 0 5px 1px #52fefe, 0 0 5px 4px #52fefe inset .iron-box3 width 180px height 180px background-color #073c4b box-shadow 0 0 5px 4px #52fefe, 0 0 6px 2px #52fefe inset .iron-box4 width 120px height 120px border 1px solid #52fefe background-color #fff box-shadow 0 0 2px 1px #52fefe, 0 0 10px 5px #52fefe inset .iron-box5 width 70px height 70px border 5px solid #1b4e5f box-shadow 0 0 7px 5px #52fefe, 0 0 10px 10px #52fefe inset .iron-box6 position relative width 100% height 100% animation ironrotate 3s linear infinite .iron-coil position absolute width 30px height 20px top calc(50% - 110px) left calc(50% - 15px) background-color #073c4b box-shadow 0 0 5px #52fefe inset transform rotate(calc(var(--i) * 45deg)) transform-origin center 110px @keyframes ironrotate 0% transform rotate(0) 100% transform rotate(360deg) ``` ```css .loading-bg position fixed z-index 1000 width 50% height 100% background var(--preloader-bg) #loading-box .loading-left-bg @extend .loading-bg left 0 .loading-right-bg @extend .loading-bg right 0 &.loaded z-index -1000 .loading-left-bg transition all 1.0s transform translate(-100%, 0) .loading-right-bg transition all 1.0s transform translate(100%, 0) #loading-box position fixed z-index 1000 display -webkit-box display flex -webkit-box-align center align-items center -webkit-box-pack center justify-content center -webkit-box-orient vertical -webkit-box-direction normal flex-direction column flex-wrap wrap width 100vw height 100vh overflow hidden &.loaded .scarecrow display none .scarecrow z-index 1001 position relative animation hop 0.2s ease-in alternate infinite .scarecrow__hat position relative border-top-left-radius 5px border-top-right-radius 5px border-top 45px solid #515559 border-left 1px solid transparent border-right 1px solid transparent width 55px margin 0 auto -3px z-index 1 &:before content "" position absolute top -87px right -23px background-color #515559 width 9px height 55px border-radius 100% transform rotate(50deg) &:after content "" position absolute top 12px left -15px background-color #515559 width 85px height 10px border-radius 40% 40% 70% 70% .scarecrow__ribbon width 55px height 12px background-color #d996b5 margin 0 auto .scarecrow__head position relative background-color #f2f2f2 width 70px height 55px margin 0 auto border-radius 50% display flex justify-content space-around flex-flow row wrap .scarecrow__eye width 6px height 6px background-color #000 border-radius 50% margin 20px 5px 0 .scarecrow__mouth width 45px height 15px background-color #fff border-radius 50% .scarecrow__pipe position absolute top 40px left 60px width 40px height 2px background-color #8c8070 &:before content "" position absolute width 9px height 17px background-color #8c8070 border-radius 3px left 40px top -7px .scarecrow__body position relative width 250px z-index 1 .scarecrow__coat position absolute top 15px left 0 right 0 margin-left auto margin-right auto border-top 100px solid #515559 border-left 5px solid transparent border-right 5px solid transparent width 75px .scarecrow__bow position absolute top 20px left 0 right 0 margin-left auto margin-right auto background-color #3a485d width 10px height 10px z-index 3 border-radius 2px &:before content "" position absolute top -10px left -25px width 0 height 10px border-top 10px solid transparent border-left 25px solid #5a6b8c border-bottom 10px solid transparent border-radius 8px &:after content "" position absolute top -10px right -25px width 0 height 10px border-top 10px solid transparent border-right 25px solid #5a6b8c border-bottom 10px solid transparent border-radius 8px .scarecrow__shirt position absolute top 8px left 0 right 0 margin-left auto margin-right auto width 30px height 35px z-index 2 &:before content "" position absolute top 0 left -5px height 100% width 70% background-color #dbb2c2 transform skew(1deg, 35deg) border-bottom-left-radius 90px border-top-left-radius 15px border-bottom-right-radius 15px border-top-right-radius 10px &:after content "" position absolute top 0 right -5px height 100% width 70% background-color #dbb2c2 transform skew(-1deg, -35deg) border-top-right-radius 15px border-bottom-right-radius 90px border-bottom-left-radius 15px border-top-left-radius 10px .scarecrow__waistcoat position absolute top 15px left -1px right 0 margin-left auto margin-right auto width 35px height 50px &:before content "" position absolute top 0 left -4px height 100% width 65% background-color #83a6bc transform skew(0deg, 40deg) border-bottom-left-radius 90px border-top-left-radius 90px border-bottom-right-radius 15px &:after content "" position absolute top 0 right -5px height 100% width 65% background-color #83a6bc transform skew(0deg, -40deg) border-top-right-radius 90px border-bottom-right-radius 90px border-bottom-left-radius 15px .scarecrow__coattails position absolute top 105px left 0 right 0 margin-left auto margin-right auto width 75px height 120px z-index 1 &:before content "" position absolute top 0 left 8px height 100% width 60% background-color #515559 transform-origin top transform skew(-25deg, 30deg) rotate(0deg) border-bottom-left-radius 50px border-bottom-right-radius 5px animation coattails-left 0.2s ease-in alternate infinite &:after content "" position absolute top 0 right 8px height 100% width 60% background-color #515559 transform-origin top transform skew(25deg, -30deg) rotate(0deg) border-bottom-right-radius 50px border-bottom-left-radius 5px animation coattails-right 0.2s ease-in alternate infinite .scarecrow__pants position absolute top 115px left 0 right 0 margin-left auto margin-right auto width 50px height 150px &:before content "" position absolute top 0 left -8px height 100% width 60% background-color #393c3e transform rotate(0deg) transform-origin top animation pants 0.5s linear alternate infinite &:after content "" position absolute top 0 right -8px height 100% width 60% background-color #393c3e transform rotate(0deg) transform-origin top animation pants 0.3s linear alternate infinite .scarecrow__sleeve position absolute top 15px background-color #515559 width 80px height 25px .scarecrow__sleeve--l left 10px &:before content "" position absolute top -3px left -22px width 0 height 25px border-top 3px solid transparent border-left 25px solid #515559 border-bottom 3px solid transparent border-radius 3px .scarecrow__sleeve--r right 10px &:before content "" position absolute top -3px right -22px width 0 height 25px border-top 3px solid transparent border-right 25px solid #515559 border-bottom 3px solid transparent border-radius 3px .scarecrow__glove position absolute top 12px width 0px height 12px &:before content "" position absolute top -7px border-radius 100% background-color #f2f2f2 width 35px height 15px .scarecrow__glove--l border-top 3px solid transparent border-right 20px solid #f2f2f2 border-bottom 3px solid transparent left -50px &:before transform-origin right left -30px transform rotate(0deg) animation glove-l 0.2s linear alternate infinite .scarecrow__glove--r border-top 3px solid transparent border-left 20px solid #f2f2f2 border-bottom 3px solid transparent right -50px &:before transform-origin left right -30px transform rotate(0deg) animation glove-r 0.2s linear alternate infinite .scarecrow__arms position absolute left 50% transform translate(-50%, -50%) background-color #8c8070 width 350px height 8px border-radius 5px margin 20px auto .scarecrow__leg position relative background-color #8c8070 width 8px height 380px border-bottom-left-radius 5px border-bottom-right-radius 5px margin 0 auto @keyframes hop 0% transform translateY(-10px) 100% transform translateY(10px) @keyframes coattails-left 0% transform skew(-25deg, 30deg) rotate(-3deg) 100% transform skew(-25deg, 30deg) rotate(3deg) @keyframes coattails-right 0% transform skew(25deg, -30deg) rotate(3deg) 100% transform skew(25deg, -30deg) rotate(-3deg) @keyframes pants 0% transform rotate(3deg) 100% transform rotate(-3deg) @keyframes glove-l 0% transform rotate(-50deg) 100% transform rotate(-30deg) @keyframes glove-r 0% transform rotate(50deg) 100% transform rotate(30deg) ``` ```css #loading-box position fixed z-index 1000 width 100vw height 100vh overflow hidden &.loaded z-index -1000 .triangles-wrap display none .triangles-wrap position absolute top 50% left 50% transform translate(-50%,-66.6666666666666666%) -ms-transform translate(-50%,-66.6666666666666666%) -webkit-transform translate(-50%,-66.6666666666666666%) -webkit-animation animascale 2s linear alternate infinite animation animascale 2s linear alternate both infinite .triangles-zero, .triangles-ein, .triangles-dvai, .triangles-trei, .triangles-feir, .triangles-fei, .triangles-sei, .triangles-seiz, .triangles-eiz width 0px height 0px position absolute top 50% left 50% transform translate(-50%,-66.6666666666666666%) -ms-transform translate(-50%,-66.6666666666666666%) -webkit-transform translate(-50%,-66.6666666666666666%) .triangles-zero border-style solid border-width 0 5px 8.7px 5px border-color transparent transparent #1274b6 transparent -webkit-animation anima 2s linear reverse both infinite 4s, animacolorzero 2s linear alternate both infinite animation anima 2s linear reverse both infinite 4s, animacolorzero 2s linear alternate both infinite -webkit-transform-origin top left .triangles-ein border-style solid border-width 0 10px 17.3px 10px border-color transparent transparent #167bbf transparent -webkit-animation anima 2s linear both infinite 4.2s, animacolorein 2s linear alternate both infinite animation anima 2s linear both infinite 4.2s, animacolorein 2s linear alternate both infinite -webkit-transform-origin top left .triangles-dvai border-style solid border-width 0 20px 34.6px 20px border-color transparent transparent #1b82c8 transparent -webkit-animation anima 2s linear reverse both infinite 4.4s, animacolordvai 2s linear alternate both infinite animation anima 2s linear reverse both infinite 4.4s, animacolordvai 2s linear alternate both infinite -webkit-transform-origin top left .triangles-trei border-style solid border-width 0 40px 69.3px 40px border-color transparent transparent #228bd2 transparent -webkit-animation anima 2s linear both infinite 4.6s, animacolortrei 2s linear alternate both infinite animation anima 2s linear both infinite 4.6s, animacolortrei 2s linear alternate both infinite -webkit-transform-origin top left .triangles-feir border-style solid border-width 0 80px 138.6px 80px border-color transparent transparent #2992d9 transparent -webkit-animation anima 2s linear reverse both infinite 4.8s, animacolorfeir 2s linear alternate both infinite animation anima 2s linear reverse both infinite 4.8s, animacolorfeir 2s linear alternate both infinite -webkit-transform-origin top left .triangles-fei border-style solid border-width 0 160px 277.1px 160px border-color transparent transparent #3498db transparent -webkit-animation anima 2s linear both infinite 5s, animacolorfei 2s linear alternate both infinite animation anima 2s linear both infinite 5s, animacolorfei 2s linear alternate both infinite -webkit-transform-origin top left .triangles-sei border-style solid border-width 0 320px 554.3px 320px border-color transparent transparent #3f9edd transparent -webkit-animation anima 2s linear reverse both infinite 5.2s, animacolorsei 2s linear alternate both infinite animation anima 2s linear reverse both infinite 5.2s, animacolorsei 2s linear alternate both infinite -webkit-transform-origin top left .triangles-seiz border-style solid border-width 0 640px 1108.5px 640px border-color transparent transparent #48a2de transparent -webkit-animation anima 2s linear both infinite 5.4s, animacolorseiz 2s linear alternate both infinite animation anima 2s linear both infinite 5.4s, animacolorseiz 2s linear alternate both infinite -webkit-transform-origin top left .triangles-eiz border-style solid border-width 0 1280px 2217.0px 1280px border-color transparent transparent #59aae0 transparent -webkit-animation anima 2s linear reverse both infinite 5.6s, animacoloreiz 2s linear alternate both infinite animation anima 2s linear reverse both infinite 5.6s, animacoloreiz 2s linear alternate both infinite -webkit-transform-origin top left @-webkit-keyframes anima from -webkit-transform: rotate(0deg) translate(-50%,-66.6666666666666666%) to -webkit-transform: rotate(360deg) translate(-50%,-66.6666666666666666%) @keyframes anima from transform rotate(0deg) translate(-50%,-66.6666666666666666%) to transform rotate(360deg) translate(-50%,-66.6666666666666666%) @-webkit-keyframes animacolorzero { 0%{border-color: transparent transparent #1274b6 transparent;} 12.5%{border-color: transparent transparent #167bbf transparent;} 25%{border-color: transparent transparent #1b82c8 transparent;} 37.5%{border-color: transparent transparent #228bd2 transparent;} 50%{border-color: transparent transparent #2992d9 transparent;} 62.5%{border-color: transparent transparent #3498db transparent;} 75%{border-color: transparent transparent #3f9edd transparent;} 87.5%{border-color: transparent transparent #48a2de transparent;} 100%{border-color: transparent transparent #59aae0 transparent;} } @keyframes animacolorzero { 0%{border-color: transparent transparent #1274b6 transparent;} 12.5%{border-color: transparent transparent #167bbf transparent;} 25%{border-color: transparent transparent #1b82c8 transparent;} 37.5%{border-color: transparent transparent #228bd2 transparent;} 50%{border-color: transparent transparent #2992d9 transparent;} 62.5%{border-color: transparent transparent #3498db transparent;} 75%{border-color: transparent transparent #3f9edd transparent;} 87.5%{border-color: transparent transparent #48a2de transparent;} 100%{border-color: transparent transparent #59aae0 transparent;} } @-webkit-keyframes animacolorein { 0%{border-color: transparent transparent #167bbf transparent;} 12.5%{border-color: transparent transparent #1b82c8 transparent;} 25%{border-color: transparent transparent #228bd2 transparent;} 37.5%{border-color: transparent transparent #2992d9 transparent;} 50%{border-color: transparent transparent #3498db transparent;} 62.5%{border-color: transparent transparent #3f9edd transparent;} 75%{border-color: transparent transparent #48a2de transparent;} 87.5%{border-color: transparent transparent #59aae0 transparent;} 100%{border-color: transparent transparent #1274b6 transparent;} } @keyframes animacolorein { 0%{border-color: transparent transparent #167bbf transparent;} 12.5%{border-color: transparent transparent #1b82c8 transparent;} 25%{border-color: transparent transparent #228bd2 transparent;} 37.5%{border-color: transparent transparent #2992d9 transparent;} 50%{border-color: transparent transparent #3498db transparent;} 62.5%{border-color: transparent transparent #3f9edd transparent;} 75%{border-color: transparent transparent #48a2de transparent;} 87.5%{border-color: transparent transparent #59aae0 transparent;} 100%{border-color: transparent transparent #1274b6 transparent;} } @-webkit-keyframes animacolordvai { 0%{border-color: transparent transparent #1b82c8 transparent;} 12.5%{border-color: transparent transparent #228bd2 transparent;} 25%{border-color: transparent transparent #2992d9 transparent;} 37.5%{border-color: transparent transparent #3498db transparent;} 50%{border-color: transparent transparent #3f9edd transparent;} 62.5%{border-color: transparent transparent #48a2de transparent;} 75%{border-color: transparent transparent #59aae0 transparent;} 87.5%{border-color: transparent transparent #1274b6 transparent;} 100%{border-color: transparent transparent #167bbf transparent;} } @keyframes animacolordvai { 0%{border-color: transparent transparent #1b82c8 transparent;} 12.5%{border-color: transparent transparent #228bd2 transparent;} 25%{border-color: transparent transparent #2992d9 transparent;} 37.5%{border-color: transparent transparent #3498db transparent;} 50%{border-color: transparent transparent #3f9edd transparent;} 62.5%{border-color: transparent transparent #48a2de transparent;} 75%{border-color: transparent transparent #59aae0 transparent;} 87.5%{border-color: transparent transparent #1274b6 transparent;} 100%{border-color: transparent transparent #167bbf transparent;} } @-webkit-keyframes animacolortrei { 0%{border-color: transparent transparent #228bd2 transparent;} 12.5%{border-color: transparent transparent #2992d9 transparent;} 25%{border-color: transparent transparent #3498db transparent;} 37.5%{border-color: transparent transparent #3f9edd transparent;} 50%{border-color: transparent transparent #48a2de transparent;} 62.5%{border-color: transparent transparent #59aae0 transparent;} 75%{border-color: transparent transparent #1274b6 transparent;} 87.5%{border-color: transparent transparent #167bbf transparent;} 100%{border-color: transparent transparent #1b82c8 transparent;} } @keyframes animacolortrei { 0%{border-color: transparent transparent #228bd2 transparent;} 12.5%{border-color: transparent transparent #2992d9 transparent;} 25%{border-color: transparent transparent #3498db transparent;} 37.5%{border-color: transparent transparent #3f9edd transparent;} 50%{border-color: transparent transparent #48a2de transparent;} 62.5%{border-color: transparent transparent #59aae0 transparent;} 75%{border-color: transparent transparent #1274b6 transparent;} 87.5%{border-color: transparent transparent #167bbf transparent;} 100%{border-color: transparent transparent #1b82c8 transparent;} } @-webkit-keyframes animacolorfeir { 0%{border-color: transparent transparent #2992d9 transparent;} 12.5%{border-color: transparent transparent #3498db transparent;} 25%{border-color: transparent transparent #3f9edd transparent;} 37.5%{border-color: transparent transparent #48a2de transparent;} 50%{border-color: transparent transparent #59aae0 transparent;} 62.5%{border-color: transparent transparent #1274b6 transparent;} 75%{border-color: transparent transparent #167bbf transparent;} 87.5%{border-color: transparent transparent #1b82c8 transparent;} 100%{border-color: transparent transparent #228bd2 transparent;} } @keyframes animacolorfeir { 0%{border-color: transparent transparent #2992d9 transparent;} 12.5%{border-color: transparent transparent #3498db transparent;} 25%{border-color: transparent transparent #3f9edd transparent;} 37.5%{border-color: transparent transparent #48a2de transparent;} 50%{border-color: transparent transparent #59aae0 transparent;} 62.5%{border-color: transparent transparent #1274b6 transparent;} 75%{border-color: transparent transparent #167bbf transparent;} 87.5%{border-color: transparent transparent #1b82c8 transparent;} 100%{border-color: transparent transparent #228bd2 transparent;} } @-webkit-keyframes animacolorfei { 0%{border-color: transparent transparent #3498db transparent;} 12.5%{border-color: transparent transparent #3f9edd transparent;} 25%{border-color: transparent transparent #48a2de transparent;} 37.5%{border-color: transparent transparent #59aae0 transparent;} 50%{border-color: transparent transparent #1274b6 transparent;} 62.5%{border-color: transparent transparent #167bbf transparent;} 75%{border-color: transparent transparent #1b82c8 transparent;} 87.5%{border-color: transparent transparent #228bd2 transparent;} 100%{border-color: transparent transparent #2992d9 transparent;} } @keyframes animacolorfei { 0%{border-color: transparent transparent #3498db transparent;} 12.5%{border-color: transparent transparent #3f9edd transparent;} 25%{border-color: transparent transparent #48a2de transparent;} 37.5%{border-color: transparent transparent #59aae0 transparent;} 50%{border-color: transparent transparent #1274b6 transparent;} 62.5%{border-color: transparent transparent #167bbf transparent;} 75%{border-color: transparent transparent #1b82c8 transparent;} 87.5%{border-color: transparent transparent #228bd2 transparent;} 100%{border-color: transparent transparent #2992d9 transparent;} } @-webkit-keyframes animacolorsei { 0%{border-color: transparent transparent #3f9edd transparent;} 12.5%{border-color: transparent transparent #48a2de transparent;} 25%{border-color: transparent transparent #59aae0 transparent;} 37.5%{border-color: transparent transparent #1274b6 transparent;} 50%{border-color: transparent transparent #167bbf transparent;} 62.5%{border-color: transparent transparent #1b82c8 transparent;} 75%{border-color: transparent transparent #228bd2 transparent;} 87.5%{border-color: transparent transparent #2992d9 transparent;} 100%{border-color: transparent transparent #3498db transparent;} } @keyframes animacolorsei { 0%{border-color: transparent transparent #3f9edd transparent;} 12.5%{border-color: transparent transparent #48a2de transparent;} 25%{border-color: transparent transparent #59aae0 transparent;} 37.5%{border-color: transparent transparent #1274b6 transparent;} 50%{border-color: transparent transparent #167bbf transparent;} 62.5%{border-color: transparent transparent #1b82c8 transparent;} 75%{border-color: transparent transparent #228bd2 transparent;} 87.5%{border-color: transparent transparent #2992d9 transparent;} 100%{border-color: transparent transparent #3498db transparent;} } @-webkit-keyframes animacolorseiz { 0%{border-color: transparent transparent #48a2de transparent;} 12.5%{border-color: transparent transparent #59aae0 transparent;} 25%{border-color: transparent transparent #1274b6 transparent;} 37.5%{border-color: transparent transparent #167bbf transparent;} 50%{border-color: transparent transparent #1b82c8 transparent;} 62.5%{border-color: transparent transparent #228bd2 transparent;} 75%{border-color: transparent transparent #2992d9 transparent;} 87.5%{border-color: transparent transparent #3498db transparent;} 100%{border-color: transparent transparent #3f9edd transparent;} } @keyframes animacolorseiz { 0%{border-color: transparent transparent #48a2de transparent;} 12.5%{border-color: transparent transparent #59aae0 transparent;} 25%{border-color: transparent transparent #1274b6 transparent;} 37.5%{border-color: transparent transparent #167bbf transparent;} 50%{border-color: transparent transparent #1b82c8 transparent;} 62.5%{border-color: transparent transparent #228bd2 transparent;} 75%{border-color: transparent transparent #2992d9 transparent;} 87.5%{border-color: transparent transparent #3498db transparent;} 100%{border-color: transparent transparent #3f9edd transparent;} } @-webkit-keyframes animacoloreiz { 0%{border-color: transparent transparent #59aae0 transparent;} 12.5%{border-color: transparent transparent #1274b6 transparent;} 25%{border-color: transparent transparent #167bbf transparent;} 37.5%{border-color: transparent transparent #1b82c8 transparent;} 50%{border-color: transparent transparent #228bd2 transparent;} 62.5%{border-color: transparent transparent #2992d9 transparent;} 75%{border-color: transparent transparent #3498db transparent;} 87.5%{border-color: transparent transparent #3f9edd transparent;} 100%{border-color: transparent transparent #48a2de transparent;} } @keyframes animacoloreiz { 0%{border-color: transparent transparent #59aae0 transparent;} 12.5%{border-color: transparent transparent #1274b6 transparent;} 25%{border-color: transparent transparent #167bbf transparent;} 37.5%{border-color: transparent transparent #1b82c8 transparent;} 50%{border-color: transparent transparent #228bd2 transparent;} 62.5%{border-color: transparent transparent #2992d9 transparent;} 75%{border-color: transparent transparent #3498db transparent;} 87.5%{border-color: transparent transparent #3f9edd transparent;} 100%{border-color: transparent transparent #48a2de transparent;} } @-webkit-keyframes animascale { 0%{-webkit-transform: scale(1);} 100%{-webkit-transform: scale(1.2);} } @keyframes animascale { 0%{-webkit-transform: scale(1);} 100%{-webkit-transform: scale(1.2);} } ``` ```css .loading-bg position fixed z-index 1000 width 50% height 100% background var(--preloader-bg) #loading-box .loading-left-bg @extend .loading-bg left 0 .loading-right-bg @extend .loading-bg right 0 &.loaded z-index -1000 .loading-left-bg transition all 1.0s transform translate(-100%, 0) .loading-right-bg transition all 1.0s transform translate(100%, 0) #loading-box position fixed z-index 1000 display -webkit-box display flex -webkit-box-align center align-items center -webkit-box-pack center justify-content center -webkit-box-orient vertical -webkit-box-direction normal flex-direction column flex-wrap wrap width 100vw height 100vh overflow hidden &.loaded .wizard-scene display none .wizard-scene position fixed z-index 1001 display -webkit-box display flex .wizard position relative width 190px height 240px .wizard-body position absolute bottom 0 left 68px height 100px width 60px background #3f64ce &::after content "" position absolute bottom 0 left 20px height 100px width 60px background #3f64ce -webkit-transform skewX(14deg) transform skewX(14deg) .wizard-right-arm position absolute bottom 74px left 110px height 44px width 90px background #3f64ce border-radius 22px -webkit-transform-origin 16px 22px transform-origin 16px 22px -webkit-transform rotate(70deg) transform rotate(70deg) -webkit-animation right_arm 10s ease-in-out infinite animation right_arm 10s ease-in-out infinite .right-hand position absolute right 8px bottom 8px width 30px height 30px border-radius 50% background #f1c5b4 -webkit-transform-origin center center transform-origin center center -webkit-transform rotate(-40deg) transform rotate(-40deg) -webkit-animation right_hand 10s ease-in-out infinite animation right_hand 10s ease-in-out infinite .wizard-right-hand &::after content "" position absolute right 0px top -8px width 15px height 30px border-radius 10px background #f1c5b4 -webkit-transform translateY(16px) transform translateY(16px) -webkit-animation right_finger 10s ease-in-out infinite animation right_finger 10s ease-in-out infinite .wizard-left-arm position absolute bottom 74px left 26px height 44px width 70px background #3f64ce border-bottom-left-radius 8px -webkit-transform-origin 60px 26px transform-origin 60px 26px -webkit-transform rotate(-70deg) transform rotate(-70deg) -webkit-animation left_arm 10s ease-in-out infinite animation left_arm 10s ease-in-out infinite .wizard-left-hand position absolute left -18px top 0 width 18px height 30px border-top-left-radius 35px border-bottom-left-radius 35px background #f1c5b4 &::after content "" position absolute right 0 top 0 width 30px height 15px border-radius 20px background #f1c5b4 -webkit-transform-origin right bottom transform-origin right bottom -webkit-transform scaleX(0) transform scaleX(0) -webkit-animation left_finger 10s ease-in-out infinite animation left_finger 10s ease-in-out infinite .wizard-head position absolute top 0 left 14px width 160px height 210px -webkit-transform-origin center center transform-origin center center -webkit-transform rotate(-3deg) transform rotate(-3deg) -webkit-animation head 10s ease-in-out infinite animation head 10s ease-in-out infinite .wizard-beard position absolute bottom 0 left 38px height 106px width 80px border-bottom-right-radius 55% background #ffffff &::after content "" position absolute top 16px left -10px width 40px height 20px border-radius 20px background #ffffff .wizard-face position absolute bottom 76px left 38px height 30px width 60px background #f1c5b4 &::before content "" position absolute top 0px left 40px width 20px height 40px border-bottom-right-radius 20px border-bottom-left-radius 20px background #f1c5b4 &::after content "" position absolute top 16px left -10px width 50px height 20px border-radius 20px border-bottom-right-radius 0px background #ffffff .wizard-adds position absolute top 0px left -10px width 40px height 20px border-radius 20px background #f1c5b4 &::after content "" position absolute top 5px left 80px width 15px height 20px border-bottom-right-radius 20px border-top-right-radius 20px background #f1c5b4 .wizard-hat position absolute bottom 106px left 0 width 160px height 20px border-radius 20px background #3f64ce &::before content "" position absolute top -70px left 50% -webkit-transform translatex(-50%) transform translatex(-50%) width 0 height 0 border-style solid border-width 0 34px 70px 50px border-color transparent transparent #3f64ce transparent &::after content "" position absolute top 0 left 0 width 160px height 20px background #3f64ce border-radius 20px .wizard-hat-of-the-hat position absolute bottom 78px left 79px width 0 height 0 border-style solid border-width 0 25px 25px 19px border-color transparent transparent #3f64ce transparent &::after content "" position absolute top 6px left -4px width 35px height 10px border-radius 10px border-bottom-left-radius 0px background #3f64ce -webkit-transform rotate(40deg) transform rotate(40deg) .wizard-four-point-star position absolute width 12px height 12px &::after -webkit-transform rotate(156.66deg) skew(45deg) transform rotate(156.66deg) skew(45deg) &.--first bottom 28px left 46px &.--second bottom 40px left 80px &.--third bottom 15px left 108px .wizard-head .wizard-hat .wizard-four-point-star::after, .wizard-head .wizard-hat .wizard-four-point-star::before content "" position absolute background #ffffff display block left 0 width 141.4213% top 0 bottom 0 border-radius 10% -webkit-transform rotate(66.66deg) skewX(45deg) transform rotate(66.66deg) skewX(45deg) .wizard-objects position relative width 200px height 240px .wizard-square position absolute bottom -60px left -5px width 120px height 120px border-radius 50% -webkit-transform rotate(-360deg) transform rotate(-360deg) -webkit-animation path_square 10s ease-in-out infinite animation path_square 10s ease-in-out infinite &::after content "" position absolute top 10px left 0 width 50px height 50px background #9ab3f5 .wizard-circle position absolute bottom 10px left 0 width 100px height 100px border-radius 50% -webkit-transform rotate(-360deg) transform rotate(-360deg) -webkit-animation path_circle 10s ease-in-out infinite animation path_circle 10s ease-in-out infinite &::after content "" position absolute bottom -10px left 25px width 50px height 50px border-radius 50% background #c56183 .wizard-triangle position absolute bottom -62px left -10px width 110px height 110px border-radius 50% -webkit-transform rotate(-360deg) transform rotate(-360deg) -webkit-animation path_triangle 10s ease-in-out infinite animation path_triangle 10s ease-in-out infinite &::after content "" position absolute top 0 right -10px width 0 height 0 border-style solid border-width 0 28px 48px 28px border-color transparent transparent #89beb3 transparent /** 10s animation - 10% = 1s */ @-webkit-keyframes right_arm 0% -webkit-transform rotate(70deg) transform rotate(70deg) 10% -webkit-transform rotate(8deg) transform rotate(8deg) 15% -webkit-transform rotate(20deg) transform rotate(20deg) 20% -webkit-transform rotate(10deg) transform rotate(10deg) 25% -webkit-transform rotate(26deg) transform rotate(26deg) 30% -webkit-transform rotate(10deg) transform rotate(10deg) 35% -webkit-transform rotate(28deg) transform rotate(28deg) 40% -webkit-transform rotate(9deg) transform rotate(9deg) 45% -webkit-transform rotate(28deg) transform rotate(28deg) 50% -webkit-transform rotate(8deg) transform rotate(8deg) 58% -webkit-transform rotate(74deg) transform rotate(74deg) 62% -webkit-transform rotate(70deg) transform rotate(70deg) @keyframes right_arm 0% -webkit-transform rotate(70deg) transform rotate(70deg) 10% -webkit-transform rotate(8deg) transform rotate(8deg) 15% -webkit-transform rotate(20deg) transform rotate(20deg) 20% -webkit-transform rotate(10deg) transform rotate(10deg) 25% -webkit-transform rotate(26deg) transform rotate(26deg) 30% -webkit-transform rotate(10deg) transform rotate(10deg) 35% -webkit-transform rotate(28deg) transform rotate(28deg) 40% -webkit-transform rotate(9deg) transform rotate(9deg) 45% -webkit-transform rotate(28deg) transform rotate(28deg) 50% -webkit-transform rotate(8deg) transform rotate(8deg) 58% -webkit-transform rotate(74deg) transform rotate(74deg) 62% -webkit-transform rotate(70deg) transform rotate(70deg) @-webkit-keyframes left_arm 0% -webkit-transform rotate(-70deg) transform rotate(-70deg) 10% -webkit-transform rotate(6deg) transform rotate(6deg) 15% -webkit-transform rotate(-18deg) transform rotate(-18deg) 20% -webkit-transform rotate(5deg) transform rotate(5deg) 25% -webkit-transform rotate(-18deg) transform rotate(-18deg) 30% -webkit-transform rotate(5deg) transform rotate(5deg) 35% -webkit-transform rotate(-17deg) transform rotate(-17deg) 40% -webkit-transform rotate(5deg) transform rotate(5deg) 45% -webkit-transform rotate(-18deg) transform rotate(-18deg) 50% -webkit-transform rotate(6deg) transform rotate(6deg) 58% -webkit-transform rotate(-74deg) transform rotate(-74deg) 62% -webkit-transform rotate(-70deg) transform rotate(-70deg) @keyframes left_arm 0% -webkit-transform rotate(-70deg) transform rotate(-70deg) 10% -webkit-transform rotate(6deg) transform rotate(6deg) 15% -webkit-transform rotate(-18deg) transform rotate(-18deg) 20% -webkit-transform rotate(5deg) transform rotate(5deg) 25% -webkit-transform rotate(-18deg) transform rotate(-18deg) 30% -webkit-transform rotate(5deg) transform rotate(5deg) 35% -webkit-transform rotate(-17deg) transform rotate(-17deg) 40% -webkit-transform rotate(5deg) transform rotate(5deg) 45% -webkit-transform rotate(-18deg) transform rotate(-18deg) 50% -webkit-transform rotate(6deg) transform rotate(6deg) 58% -webkit-transform rotate(-74deg) transform rotate(-74deg) 62% -webkit-transform rotate(-70deg) transform rotate(-70deg) @-webkit-keyframes right_hand 0% -webkit-transform rotate(-40deg) transform rotate(-40deg) 10% -webkit-transform rotate(-20deg) transform rotate(-20deg) 15% -webkit-transform rotate(-5deg) transform rotate(-5deg) 20% -webkit-transform rotate(-60deg) transform rotate(-60deg) 25% -webkit-transform rotate(0deg) transform rotate(0deg) 30% -webkit-transform rotate(-60deg) transform rotate(-60deg) 35% -webkit-transform rotate(0deg) transform rotate(0deg) 40% -webkit-transform rotate(-40deg) transform rotate(-40deg) 45% -webkit-transform rotate(-60deg) transform rotate(-60deg) 50% -webkit-transform rotate(10deg) transform rotate(10deg) 60% -webkit-transform rotate(-40deg) transform rotate(-40deg) @keyframes right_hand 0% -webkit-transform rotate(-40deg) transform rotate(-40deg) 10% -webkit-transform rotate(-20deg) transform rotate(-20deg) 15% -webkit-transform rotate(-5deg) transform rotate(-5deg) 20% -webkit-transform rotate(-60deg) transform rotate(-60deg) 25% -webkit-transform rotate(0deg) transform rotate(0deg) 30% -webkit-transform rotate(-60deg) transform rotate(-60deg) 35% -webkit-transform rotate(0deg) transform rotate(0deg) 40% -webkit-transform rotate(-40deg) transform rotate(-40deg) 45% -webkit-transform rotate(-60deg) transform rotate(-60deg) 50% -webkit-transform rotate(10deg) transform rotate(10deg) 60% -webkit-transform rotate(-40deg) transform rotate(-40deg) @-webkit-keyframes right_finger 0% -webkit-transform translateY(16px) transform translateY(16px) 10% -webkit-transform none transform none 50% -webkit-transform none transform none 60% -webkit-transform translateY(16px) transform translateY(16px) @keyframes right_finger 0% -webkit-transform translateY(16px) transform translateY(16px) 10% -webkit-transform none transform none 50% -webkit-transform none transform none 60% -webkit-transform translateY(16px) transform translateY(16px) @-webkit-keyframes left_finger 0% -webkit-transform scaleX(0) transform scaleX(0) 10% -webkit-transform scaleX(1) rotate(6deg) transform scaleX(1) rotate(6deg) 15% -webkit-transform scaleX(1) rotate(0deg) transform scaleX(1) rotate(0deg) 20% -webkit-transform scaleX(1) rotate(8deg) transform scaleX(1) rotate(8deg) 25% -webkit-transform scaleX(1) rotate(0deg) transform scaleX(1) rotate(0deg) 30% -webkit-transform scaleX(1) rotate(7deg) transform scaleX(1) rotate(7deg) 35% -webkit-transform scaleX(1) rotate(0deg) transform scaleX(1) rotate(0deg) 40% -webkit-transform scaleX(1) rotate(5deg) transform scaleX(1) rotate(5deg) 45% -webkit-transform scaleX(1) rotate(0deg) transform scaleX(1) rotate(0deg) 50% -webkit-transform scaleX(1) rotate(6deg) transform scaleX(1) rotate(6deg) 58% -webkit-transform scaleX(0) transform scaleX(0) @keyframes left_finger 0% -webkit-transform scaleX(0) transform scaleX(0) 10% -webkit-transform scaleX(1) rotate(6deg) transform scaleX(1) rotate(6deg) 15% -webkit-transform scaleX(1) rotate(0deg) transform scaleX(1) rotate(0deg) 20% -webkit-transform scaleX(1) rotate(8deg) transform scaleX(1) rotate(8deg) 25% -webkit-transform scaleX(1) rotate(0deg) transform scaleX(1) rotate(0deg) 30% -webkit-transform scaleX(1) rotate(7deg) transform scaleX(1) rotate(7deg) 35% -webkit-transform scaleX(1) rotate(0deg) transform scaleX(1) rotate(0deg) 40% -webkit-transform scaleX(1) rotate(5deg) transform scaleX(1) rotate(5deg) 45% -webkit-transform scaleX(1) rotate(0deg) transform scaleX(1) rotate(0deg) 50% -webkit-transform scaleX(1) rotate(6deg) transform scaleX(1) rotate(6deg) 58% -webkit-transform scaleX(0) transform scaleX(0) @-webkit-keyframes head 0% -webkit-transform rotate(-3deg) transform rotate(-3deg) 10% -webkit-transform translatex(10px) rotate(7deg) transform translatex(10px) rotate(7deg) 50% -webkit-transform translatex(0px) rotate(0deg) transform translatex(0px) rotate(0deg) 56% -webkit-transform rotate(-3deg) transform rotate(-3deg) @keyframes head 0% -webkit-transform rotate(-3deg) transform rotate(-3deg) 10% -webkit-transform translatex(10px) rotate(7deg) transform translatex(10px) rotate(7deg) 50% -webkit-transform translatex(0px) rotate(0deg) transform translatex(0px) rotate(0deg) 56% -webkit-transform rotate(-3deg) transform rotate(-3deg) /** 10s animation - 10% = 1s */ @-webkit-keyframes path_circle 0% -webkit-transform translateY(0) transform translateY(0) 10% -webkit-transform translateY(-100px) rotate(-5deg) transform translateY(-100px) rotate(-5deg) 55% -webkit-transform translateY(-100px) rotate(-360deg) transform translateY(-100px) rotate(-360deg) 58% -webkit-transform translateY(-100px) rotate(-360deg) transform translateY(-100px) rotate(-360deg) 63% -webkit-transform rotate(-360deg) transform rotate(-360deg) @keyframes path_circle 0% -webkit-transform translateY(0) transform translateY(0) 10% -webkit-transform translateY(-100px) rotate(-5deg) transform translateY(-100px) rotate(-5deg) 55% -webkit-transform translateY(-100px) rotate(-360deg) transform translateY(-100px) rotate(-360deg) 58% -webkit-transform translateY(-100px) rotate(-360deg) transform translateY(-100px) rotate(-360deg) 63% -webkit-transform rotate(-360deg) transform rotate(-360deg) @-webkit-keyframes path_square 0% -webkit-transform translateY(0) transform translateY(0) 10% -webkit-transform translateY(-155px) translatex(-15px) rotate(10deg) transform translateY(-155px) translatex(-15px) rotate(10deg) 55% -webkit-transform translateY(-155px) translatex(-15px) rotate(-350deg) transform translateY(-155px) translatex(-15px) rotate(-350deg) 57% -webkit-transform translateY(-155px) translatex(-15px) rotate(-350deg) transform translateY(-155px) translatex(-15px) rotate(-350deg) 63% -webkit-transform rotate(-360deg) transform rotate(-360deg) @keyframes path_square 0% -webkit-transform translateY(0) transform translateY(0) 10% -webkit-transform translateY(-155px) translatex(-15px) rotate(10deg) transform translateY(-155px) translatex(-15px) rotate(10deg) 55% -webkit-transform translateY(-155px) translatex(-15px) rotate(-350deg) transform translateY(-155px) translatex(-15px) rotate(-350deg) 57% -webkit-transform translateY(-155px) translatex(-15px) rotate(-350deg) transform translateY(-155px) translatex(-15px) rotate(-350deg) 63% -webkit-transform rotate(-360deg) transform rotate(-360deg) @-webkit-keyframes path_triangle 0% -webkit-transform translateY(0) transform translateY(0) 10% -webkit-transform translateY(-172px) translatex(10px) rotate(-10deg) transform translateY(-172px) translatex(10px) rotate(-10deg) 55% -webkit-transform translateY(-172px) translatex(10px) rotate(-365deg) transform translateY(-172px) translatex(10px) rotate(-365deg) 58% -webkit-transform translateY(-172px) translatex(10px) rotate(-365deg) transform translateY(-172px) translatex(10px) rotate(-365deg) 63% -webkit-transform rotate(-360deg) transform rotate(-360deg) @keyframes path_triangle 0% -webkit-transform translateY(0) transform translateY(0) 10% -webkit-transform translateY(-172px) translatex(10px) rotate(-10deg) transform translateY(-172px) translatex(10px) rotate(-10deg) 55% -webkit-transform translateY(-172px) translatex(10px) rotate(-365deg) transform translateY(-172px) translatex(10px) rotate(-365deg) 58% -webkit-transform translateY(-172px) translatex(10px) rotate(-365deg) transform translateY(-172px) translatex(10px) rotate(-365deg) 63% -webkit-transform rotate(-360deg) transform rotate(-360deg) ``` {% endtabs %} ## 修改 loading.styl 文件路径为: `themes/anzhiyu/source/css/_global/loading.styl`, 修改为根据配置加载对应的样式文件: ```css if hexo-config('preloader.enable') if hexo-config('preloader.source') == 'car' @import '../_layout/_load_style/car.css' @import '../_layout/_load_style/car' else if hexo-config('preloader.source') == 'heo' @import '../_layout/_load_style/heo' else if hexo-config('preloader.source') == 'gear' @import '../_layout/_load_style/gear' else if hexo-config('preloader.source') == 'ironheart' @import '../_layout/_load_style/ironheart' else if hexo-config('preloader.source') == 'scarecrow' @import '../_layout/_load_style/scarecrow' else if hexo-config('preloader.source') == 'triangles' @import '../_layout/_load_style/triangles' else if hexo-config('preloader.source') == 'wizard' @import '../_layout/_load_style/wizard' else if hexo-config('preloader.source') == 'image' @import '../_layout/_load_style/image' else if hexo-config('preloader.source') == 'default' @import '../_layout/_load_style/default' else @import '../_layout/_load_style/heo' ``` ## 修改主题配置 ```yaml # Loading Animation (加载动画) preloader: enable: true # load_style:控制加载动画的样式 # car 小汽车 # heo heo张洪同款 # default 是主题原版的盒子加载动画 # gear 是旋转齿轮加载动画 # ironheart 是钢铁侠核心加载动画 # scarecrow 是稻草人跳动加载动画 # triangles 是旋转三角加载动画 # wizard 是巫师施法加载动画 # image 为自定义添加静态图片或gif作为加载动画 # https://blog.anheyu.com/posts/52d8.html https://blog.zhheo.com/p/32776e99.html https://blog.meta-code.top/2022/06/18/2022-73/ source: car # pace theme (see https://codebyzach.github.io/pace/) pace_css_url: avatar: # 自定加载动画义头像, heo 动画使用 load_color: '#37474f' load_image: https://images.unsplash.com/photo-1572666341285-c8cb9790ca50?q=80&w=6000&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D # image 动画使用 ``` **配置项参数说明**: - enable: 控制加载动画的开关,取值有true和false,true开启,false关闭。 - load_color: 该配置项为自定义加载动画背景颜色。默认值为 `#37474f`,使用时注意用单引号''包起来,不然会被识别成注释符。这个配置项最大的作用是配合load_image使用的图片的背景色,可以用取色器提取自定义图片的主要色调,以达到图片和背景融为一体的效果。 - source: 控制加载动画的样式: - car: 小汽车 - heo: heo张洪同款 - default: 是主题原版的盒子加载动画 - gear: 是旋转齿轮加载动画 - ironheart: 是钢铁侠核心加载动画 - scarecrow: 是稻草人跳动加载动画 - triangles: 是旋转三角加载动画 - wizard: 是巫师施法加载动画 - image: 为自定义添加静态图片或gif作为加载动画。 - load_image:该配置项的生效前提是 load_style 设置为 image,内容填写图床链接或者本地相对地址。 ## 问题参考 按照上述配置完成后, 只需要修改主题配置即可方便切换 loading 动画样式. 上述除了 car loading 动画跟原博客有差异外(主要是做了主题适配), 其他配置项跟 [【Hexo博客】自定义Butterfly主题 Loading 加载动画](https://blog.meta-code.top/2022/06/18/2022-73/) 完全一致(除了文件路径). 其他问题可参考原博客: {% link 【Hexo博客】自定义Butterfly主题 Loading 加载动画, 百里飞洋 Barry-Flynn, https://blog.meta-code.top/2022/06/18/2022-73/ %} ## [为 Hexo 添加统计功能:Docker 部署 Busuanzi 服务教程](https://blog.dong4j.site/posts/a83edbc6.md) ![/images/cover/20250103184959_OX6XnOyd.webp](https://cdn.dong4j.site/source/image/20250103184959_OX6XnOyd.webp) [[hexo-hitokoto|自建 Hitokoto 服务]] [[hexo-rss|Hexo 添加 RSS 订阅功能]] [[hexo-load|Hexo 自定义加载动画]] ## 简介 同样是因为默认的 `https://busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js` 已无法打开, 所以参考 [self-hosted busuanzi](https://github.com/soxft/busuanzi) 在本地服务器自建一个. ## 部署 根据 [官方文档](https://gitee.com/soxft/busuanzi/wikis/install) 使用 docker-compose 直接部署: ```yaml services: busuanzi: image: xcsoft/busuanzi:latest ports: - 8888:8080 volumes: - ./data/config.yaml:/app/config.yaml # 如果不需要修改首页, 可以不需要挂载 - ./data/dist/index.html:/app/dist/index.html environment: WEB_LOG: true WEB_DEBUG: false WEB_CORS: "*" BSZ_EXPIRE: 0 BSZ_SECRET: 给一个 uuid 即可 API_SERVER: 需要修改成最后绑定你的域名 REDIS_ADDRESS: redis-ip:redis-port REDIS_PASSWORD: password REDIS_DATABASE: 0 BSZ_PATHSTYLE: true BSZ_ENCRYPT: MD516 ``` 配置文件: ```yaml Web: Address: 0.0.0.0:8080 # 监听地址 Cors: "https://xsot.cn,https://google.com" # 跨域访问 Debug: false # 是否开启debug模式 Log: false # 是否开启日志 Redis: Address: redis:6379 # redis地址 Password: Database: 0 TLS: false # 是否使用TLS连接redis Prefix: bsz # redis前缀 MaxIdle: 25 # 最大空闲连接数 MaxActive: 100 # 最大连接数 MinIdle: 25 # 最小空闲连接数 MaxRetries: 3 # 最大重试次数 Bsz: Expire: 0 # 统计数据过期时间 单位秒, 请输入整数 (无任何访问, 超过这个时间后, 统计数据将被清空, 0为不过期) Secret: "bsz" # JWT签名密钥 // 请设置为任意长度的随机值 Encrypt: "MD516" # 加密算法 (MD516 / MD532) 老版本请使用 MD532 PathStyle: true # 路径样式 (false: url&path, true: path) 老版本请使用 false, true 更便于数据迁移 # TIPS, 所有 config 内的设置, 均可使用 环境变量 覆盖 # Ex BSZ_SECRET=123 将覆盖 config.yaml 中的 Bsz.Secret ``` 上述的配置文件是官方提供的, 我未做任何修改, 因为在 docker-compose.yml 中都可以直接覆盖. 部署后访问 `http://ip:8888` 检查是否部署成功: ![20241229154732_dxuLdyt7.webp](https://cdn.dong4j.site/source/image/20241229154732_dxuLdyt7.webp) 我在上面挂载了 `index.html` 是因为需要修改 `` 不然就是官方默认的. ## Hexo 配置 ### 修改主题配置 ```yaml CDN: ... option: busuanzi: https://你的域名/js # http://busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js 默认的意无法打开 ``` ### 修改主题代码 修改文件 `themes/anzhiyu/layout/includes/additional-js.pug` 将一下代码: ```javascript script(async data-pjax src= theme.asset.busuanzi || '//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js')' ``` 修改为: ```javascript script(async data-pjax data-prefix="busuanzi_value" src= theme.asset.busuanzi || '//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js') ``` 记得一定要加上 `data-prefix="busuanzi_value"`, [新老版本兼容问题的处理](https://busuanzi.apifox.cn/doc-5083722). 最后 Hexo 三连击即可显示效果. ## [用 Docker 部署 Hitokoto API,让你的 Hexo 主题更酷!](https://blog.dong4j.site/posts/dd32a18c.md) ![/images/cover/Hitokoto.png](https://cdn.dong4j.site/source/image/Hitokoto.png) ## 前言 最近使用的 Hexo 主题 [hexo-theme-anzhiyu](https://github.com/anzhiyu-c/hexo-theme-anzhiyu) 默认的随机一言接口被限流了, 根据官方文档在家里的服务器上搭建了一个, 目前用于我的博客. ## 部署 根据 [官方文档](https://github.com/hitokoto-osc/hitokoto-api) 使用 docker-compose 部署: ```yml services: hitokoto_api: image: hitokoto/api:release container_name: hitokoto_api hostname: hitokoto_api environment: NODE_ENV: production url: https://你的域名 api_name: blog redis.host: 你的 redis ip redis.port: 你的 redis 端口 redis.password: 你的 redis 密码 redis.database: 你的 redis 数据库 ports: - 8888:8000 restart: unless-stopped volumes: - ./data:/usr/src/app/data ``` 配置文件: ```yaml name: "hitokoto" # 服务名称,例如:hitokoto url: "https://v1.hitokoto.cn" # 服务地址,例如:https://v1.hitokoto.cn api_name: "demo_prprpr" # 服务标识,取个好区分的标识吧,例如:cd-01-demo server: # 配置 HTTP 服务的信息 host: 127.0.0.1 # 监听的地址 port: 8000 # 监听的端口 compress_body: true # 是否使用 GZIP 压缩 redis: # 配置 Redis host: 127.0.0.1 # Redis 主机名 port: 6379 # Redis 端口 password: "" # Redis 密码 database: 0 # Redis 数据库 sentences_ab_switcher: # 本节是服务 AB 异步更新的配置,如果您不知道这个是什么意思,请保持默认 a: 1 # a 状态对应的 redis 数据库 b: 2 # b 状态对应的 redis 数据库 remote_sentences_url: https://cdn.jsdelivr.net/gh/hitokoto-osc/sentences-bundle@latest/ # 语句库地址,通常默认即可。如果您想使用您自己打包部署的语句库,您可以修改此项 workers: 1 # 启动 Worker 数目。0 表示启动和 CPU 核心数相同数量的 Worker extensions: # 控制扩展 netease: true # 网易云音乐接口 requests: enabled: false # 是否启用请求数目统计 hosts: # 需要单独统计的主机名 - sslapi.hitokoto.cn telemetry: # 遥测服务 performance: false # 性能监控 error: false # 错误报告 usage: false # 使用报告 debug: false # 是否启用调试模式(该模式会让遥测服务打印调试信息) ``` 上面官方的配置文件我只将 `requests` 和 `telemetry` 关闭了, 因为我并不需要这些功能. 部署后自己访问 ip:port 测试是否正常: ```json 保存 { "id": 9241, "uuid": "a444bb92-9800-4b8e-a3ab-054ade2e78a2", "hitokoto": "生活和漫画里面的那种热血还是不一样的。", "type": "h", "from": "影视飓风", "from_who": "Tim", "creator": "wssb", "creator_uid": 14374, "reviewer": 4756, "commit_from": "web", "created_at": "1679128135", "length": 19 } ``` 如果没问题就是域名配置了, 这部分就忽略了 ## Hexo 配置 安知鱼有 2 个地方会用到随机一言: ```yaml # Footer Settings footer: list: enable: true ... subTitle: enable: true ... # source 调用第三方服务 # source: false 关闭调用 # source: 1 调用一言网的一句话(简体) https://hitokoto.cn/ # source: 2 调用一句网(简体) http://yijuzhan.com/ # source: 3 调用今日诗词(简体) https://www.jinrishici.com/ # source: 4 调用一言网的一句话(简体) https://hitokoto.dong4j.ink:1024 # subtitle 会先显示 source , 再显示 sub 的内容 source: 4 ... ``` ```yaml # the subtitle on homepage (主页subtitle) subtitle: enable: true # source 调用第三方服务 # source: false 关闭调用 # source: 1 调用一言网的一句话(简体) https://hitokoto.cn/ # source: 2 调用一句网(简体) http://yijuzhan.com/ # source: 3 调用今日诗词(简体) https://www.jinrishici.com/ # source: 4 调用一言网的一句话(简体) https://hitokoto.dong4j.ink:1024 # subtitle 会先显示 source , 再显示 sub 的内容 source: 4 ... ``` 原来配置的 `source` 只有 3 个选择, 这里新增一个, 然后修改 `themes/anzhiyu/layout/includes/third-party/footerBarSubtitle.pug` 文件: 直接拷贝下面的代码到指定位置即可: ```pug when 4 script. function subtitleType () { fetch('https://你绑定的域名') .then(response => response.json()) .then(data => { if (!{effect}) { const from = '出自 ' + data.from const sub = !{JSON.stringify(subContent)} sub.unshift(data.hitokoto, from) window.typed = new Typed('#footer-type-tips', { strings: sub, startDelay: !{startDelay}, typeSpeed: !{typeSpeed}, loop: !{loop}, backSpeed: !{backSpeed}, }) } else { document.getElementById('footer-type-tips').innerHTML = data.hitokoto } }) } if (!{effect}) { if (typeof Typed === 'function') { subtitleType() } else { getScript('!{url_for(theme.asset.typed)}').then(subtitleType) } } else { subtitleType() } ``` 最后 Hexo 三连击即可显示效果, 比官方的快的多. ## [Hexo 博客升级指南:集成 RSS 订阅](https://blog.dong4j.site/posts/65647068.md) ![/images/cover/20250103184959_gB2NVpXf.webp](https://cdn.dong4j.site/source/image/20250103184959_gB2NVpXf.webp) Hexo 博客添加 RSS 订阅功能 插件 GitHub [https://github.com/hexojs/hexo-generator-feed](https://github.com/hexojs/hexo-generator-feed) 安装 hexo...... 这篇文章介绍了如何在 Hexo 博客中添加 RSS 订阅功能。需要使用时光插件,并提供了 GitHub 地址。在配置 RSS 时,可以选择原子或 RSS2 的类型,设置文件路径,决定展示文章的数量,还可以选择包含文章的全部内容或摘要。同时,也可以自定义订阅图标和订阅内容的顺序。在部署后,直接在根目录中访问配置的文件即可使用 RSS 订阅功能。 插件 GitHub 地址:[https://github.com/hexojs/hexo-generator-feed](https://github.com/hexojs/hexo-generator-feed) ## 安装 `hexo-generator-feed` 插件 ```bash npm install hexo-generator-feed --save ``` ## 修改 `_config.yml` 配置 ```yaml feed: type: atom path: atom.xml limit: false ``` `type`: RSS 的类型 (atom/rss2) `path`: 文件路径,默认是 atom.xml/rss2.xml `limit`: 展示文章的数量, 使用 0 或则 false 代表展示全部 `hub`: URL of the PubSubHubbub hubs (如果使用不到可以为空) `content`: (可选)设置 true 可以在 RSS 文件中包含文章全部内容,默认:false `content_limit`: (可选)摘要中使用的帖子内容的默认长度。 仅在内容设置为 false 且未显示自定义帖子描述时才使用。 `content_limit_delim`: (可选)如果 content_limit 用于缩短 post 内容,则仅在此分隔符的最后一次出现时进行剪切,然后才达到字符限制。默认不使用。 `icon`: (可选)自定义订阅图标,默认设置为主配置中指定的图标。 `order_by`: 订阅内容的顺序。 (默认: -date) ## 修改主题配置 ```yaml rss: /atom.xml ``` ## 重新生成静态文件 ```bash hexo clean && hexo g ``` 在 `public` 文件夹中会生成 `atom.xml` 文件,部署后直接在根目录中访问该文件即可。 ## [Spring Boot 3.3.x 脚手架重构:企业级应用实践](https://blog.dong4j.site/posts/321ba84a.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 前言 在老东家花了一年的时间,基于当时最新的 Spring Boot 2.5.x 写了一套脚手架工程,经过 1 年多的迭代, 这套脚手架已在技术中心多个部分应用. 脚手架包含最底层的 maven 依赖管理, 可以说是整个工程的灵魂, 期间重构过 3 次, 这个后面再说. 第二层是核心模块层, 就是我们常见的 core 包, 但是也分为至少 8 大模块. 第三层是基于 Spring Boot 2.5.x 封装的 starter 层, 这个层是整个脚手架工程的精髓所在, 也是我们后续要重点介绍的. 第四层是支撑层, 包括 Maven 插件和 IDEA 插件. 最后当然是示例工程, 包含每个 starter 组件的使用方式. 写这套脚手架的时候正好赶上 **业务中台** 火热的时期, 因此比如通用的 用户中心, 支付中心等也应运而生, 但是这部分涉及到公司的业务流程, 后面将不会过多描述. 今天决定开始新一轮的重构, 基于 Spring Boot 3.3.x 来写一个企业级的脚手架工程, 主要考虑以下几点: 1. 想体验一下最新的 Spring Boot 3.3.x 与 Spring AI; 2. 最新的 Spring Boot 3.3.x 与 2.x 版本相差太大, 直接升级会存在很多的兼容性问题; 3. 还有很多设想没有实现, 感觉还能优化一下; 4. 管理太累, 没有太多时间写代码, 一天不写代码感觉就像什么事都没有做一样; 5. 可以水几篇博客; 6. 因为想着开源, 有碍于老东家的代码知识产权的问题, 只能重写一份; 7. 自我感觉原来那套脚手架还是有可圈可点的地方, 想拿出来分享一下; 后面的系列博客会持续输出如何从 0 到 1 去开发一套自己的脚手架工程, 一是可以总结与巩固一下 Java 知识, 二是好好做一个自己的开源项目, 以此结实更多的 Javaer. ## 计划 第一步当然是为项目取一个名字了, 思考良久, 就叫: **watt** 吧, 不为别的, 好记单词少, 而且可以谐音 **瓦特**, 哈哈. 今天就到这里吧, 算是正式开始了. 给自己定几个小目标: 1. 把 Github 上免费的服务用个遍, 学习一下用 Github 的其他操作, 虽然现在是一个人, 万一以后有人了呢; 2. 把自己感兴趣的开源项目集成到这个脚手架里面, 统统玩个遍, 不管别人用不用得到, 只要我愿意, 我就玩; 3. 给其他用到的开源项目找 bug, 刷刷存在感; 4. 找 JetBrains 要个授权码玩玩, 免费用了这么多年, 还得是免费的香; 现在就把组织和第一个仓库创建了吧: - [watt-stack](https://github.com/watt-stack) - [watt-supreme](https://github.com/watt-stack/watt-supreme) 就问效率不效率 🥳 ## [Hexo Theme Aurora 主题图片居中显示的解决方案](https://blog.dong4j.site/posts/c1f388ed.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简介 记录一下如何将 `hexo-theme-aurora` 主题的博客图片居中显示 修改文件: `node_modules/hexo-theme-aurora/source/static/css/a14e1a22.css`: ```css .post-html img { margin: auto; cursor: zoom-in; transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 0.15s; } ``` ## [打造智能URL切换器:让内外网访问更便捷](https://blog.dong4j.site/posts/9f1d6e11.md) ![/images/cover/20250113234024_3rZyCVYe.webp](https://cdn.dong4j.site/source/image/20250113234024_3rZyCVYe.webp) ## 背景 作为一个智能家居爱好者,我在家里部署了多台服务器以及开发板。因为有公网 IP, 并通过 DDNS 绑定了域名, 我可以随时在外网访问家中的各种服务。 虽然我通过 Surge 的配置可以在外网通过局域网访问家中的内部服务, 当问题是我需要为同一个服务添加多个书签, 当服务数量增多时,书签管理变得异常繁琐。 ## 解决方案 为了解决这个问题,我开发了一个 Chrome 扩展 - URL Switcher Pro。这个扩展程序能够: 1. 自动检测当前网络环境 2. 根据配置智能切换内外网 URL 3. 支持批量 URL 配置管理 4. 提供 URL 可访问性检测 ## 技术实现 ### 1. 核心功能 扩展的核心功能主要包括: - **URL 匹配与切换**:通过正则表达式进行 URL 模式匹配 - **网络环境检测**:检测 URL 可访问性 - **配置同步**:利用 Chrome 存储 API 实现多设备配置同步 - **国际化支持**:内置中英文语言支持 ### 2. 主要技术栈 - Chrome Extension Manifest V3 - Chrome Storage API - Chrome Tabs & WebNavigation API - JavaScript ES6+ ### 3. 关键代码实现 ```javascript // URL自动检测 async function detectUrlPattern(url) { const configs = await chrome.storage.sync.get("siteConfigs"); for (const config of configs.siteConfigs || []) { if (url.includes(config.intranetUrl) || url.includes(config.internetUrl)) { // 检测URL可访问性并自动切换 const intranetAccessible = await checkUrl(config.intranetUrl); return intranetAccessible ? config.intranetUrl : config.internetUrl; } } return url; } // URL可访问性检测 async function checkUrl(url) { try { const response = await fetch(url, { method: "HEAD" }); return response.ok; } catch (error) { return false; } } ``` ## 使用方法 1. **安装扩展**: - 从 Chrome 商店安装或通过开发者模式加载 - 点击工具栏的扩展图标进行配置 2. **添加 URL 配置**: - 配置名称:用于识别不同的服务 - 内网地址:局域网访问地址 - 外网地址:公网访问域名 3. **启用自动切换**: - 打开全局开关 - 访问任意配置的 URL 时会自动切换到最佳访问地址 ## 特色功能 1. **批量配置管理**: - 支持导入/导出配置 - 方便在多个设备间同步配置 2. **状态检测**: - 检测 URL 可访问性 - 直观的状态指示器显示 3. **智能切换**: - 自动检测网络环境 - 无感知切换到最佳访问地址 ## 未来展望 1. **智能缓存**: - 实现 URL 访问性能缓存 - 减少检测请求频率 2. **批量导入**: - 支持从书签导入配置 - 支持批量 URL 格式转换 3. **更多自定义选项**: - 自定义检测间隔 - 更灵活的 URL 匹配规则 ## 结语 URL Switcher Pro 通过智能的 URL 切换机制,解决了内外网访问的痛点。它不仅提高了访问效率,也大大简化了 URL 管理的复杂度。对于经常需要在不同网络环境下访问内网服务的用户来说,这是一个不可多的效率工具。 欢迎访问[GitHub 仓库](https://github.com/dong4j/url-switcher-pro)获取更多信息或参与项目开发。 ## [HomeLab数据备份:打造坚实的数据安全防线](https://blog.dong4j.site/posts/6c25aa66.md) ![/images/cover/20241229154732_oUxZug2L.webp](https://cdn.dong4j.site/source/image/20241229154732_oUxZug2L.webp) [封面来源: Unsplash-Kvistholt Photography](https://unsplash.com/photos/photo-of-computer-cables-oZPwn40zCK4) ## 数据备份 备份是 **Homelab** 必不可少的一部分,按照不同的系统架构,备份大致有下列几类: - 文件级别的备份:直接在应用所属宿主机上运行定时任务,增量或者全量将文件复制到其他远程位置,可以使用 **Synologo Drive**, **Rsync**, **Rclone**, **Restic** 等这类工具进行备份; - 应用级别的备份:通过应用完成备份,例如 Gitlab, Portainer, 1Panle, Home Assistant 等自带的备份功能,可以将用户数据和数据库等数据一起备份到远程位置, - 系统级别的备份:例如 macOS 自带的备份(Time Machine),树莓派和 OpenWrt 的全系统备份, OpenWrt, DSM 等自带的系统级备份功能, 或者其他软件实现的全盘备份(比如 rsync + dd 命名)。 - 虚拟化备份:在有虚拟化的时候,整个操作系统等于多个文件,所以只需要将此文件备份便能实现整个虚拟机的备份,比如 **Parallels Desktop** 的 **\*.pvm** 虚拟机文件, UTM 的 **\*.utm** 虚拟机文件, KVM 的虚拟机文件等; - 存储卷备份: 一种 **完整备份策略**,会将整个存储卷(文件系统)作为一个单元进行备份, 比如 [**Synology Snapshot Replication**](https://www.synology.cn/zh-cn/dsm/feature/snapshot_replication); - RAID (磁盘冗余阵列):虽然 RAID 不能算作一种备份方式, 但是提供了一定程度的数据冗余与可靠性, 比如我的 2 台 NAS 都通过 SSD 组了 RAID1, 确保在一块 SSD 坏了的情况下系统仍然能够运行. > 数据备份这一章我会先从各个服务器开始总结, 因为最后备份文件都会汇总到 DS923+ 上, 所以会最后说明 Synology NAS 的备份. **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] --- ### 树莓派备份 树莓派设备有 4 个: - Raspberry Pi Zero 2 W - Raspberry Pi 4B - Raspberry Pi 5B \* 2 #### TF 复制 在 Linux 系统中一键备份树莓派系统 SD 卡的脚本 脚本文件来源: https://blog.csdn.net/qingtian11112/article/details/99825257 **使用方法:** step1:下载脚本文件 `rpi-backup.sh` 到 Linux 系统中 step2:把需要备份的 SD 卡插入 Linux 系统中,用 df -h 命令查询下 SD 卡对应的设备名。 step3:进入脚本文件 `rpi-backup.sh` 所在目录,只需要下面两行命令即可完成 SD 卡备份,最终 img 文件会生成在 `~/backupimg/` 文件夹下: ```bash # 赋可执行权限 sudo chmod +x rpi-backup.sh # 第一个参数是树莓派 SD 卡 /boot 分区的设备名,第二个参数是 / 分区的设备名, 视情况修改 ./rpi-backup.sh /dev/sdb1 /dev/sdb2 ``` #### dd 命令 ```bash # 1.使用 dd 复制文件到 NAS(提前使用 NFS 将 NAS 挂载到树莓派上) sudo dd if=/dev/mmcblk0 of=/mnt/ds923/remote/PI4/20240730.img bs=1MB status=progress # 2.使用 PiShrink.sh 压缩文件 (在 /mnt/lankxin.u/Developer 目录下执行) sudo ./pishrink.sh /mnt/ds923/remote/PI4/20240816.img /mnt/ds923/remote/PI4/20240816-lite.img ``` > 上述方式在树莓派上执行, 需要提前挂载 NAS 到本地. #### 通过网络复制 ```bash # 将 远程的 /dev/mmcblk1 备份到 本机的 backup_image.img ssh root@ "dd if=/dev/mmcblk1" | dd of=backup_image.img bs=1M status=progress ``` > 此方法同样适用 eMMC 备份, 出处: [**HOW TO CLONE EMMC (NANOPI NEO CORE)**](https://forum.armbian.com/topic/11404-how-to-clone-emmc-nanopi-neo-core/). 上述方式都会生成一个 img 文件, 后续如果要恢复, 可以使用像 **balenaEtcher** 这类工具将最新的 img 文件烧录到 SD 卡上. --- 使用第二种 **dd 命名** 备份树莓派 SD 卡时, 需要在每台树莓派上执行, 且还需要挂载 NAS, 所以我使用另一种方式集中化处理的方式. 有 2 种方式: 1. 直接在 NAS 上通过网络复制 SD 卡, 然后使用 **pishrink.sh** 减小镜像大小; 2. 在其他主机上执行, 使用 **pishrink.sh** 减小镜像大小后再自动上传到 DS923+; 第一种方案的问题是 **pishrink.sh** 无法直接在 DS923+ 上运行, 为了避免安装 **pishrink.sh** 的依赖对 NAS 有影响, 我的计划是在 M920x 上执行整个流程, 最后将压缩后的镜像上传到 DS923+ 上. 所以第一步就是在 M920x 上安装依赖并下载脚本: ```bash sudo apt update && sudo apt install -y wget parted gzip pigz xz-utils udev e2fsprogs wget https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh chmod +x pishrink.sh ``` 下面是自动化脚本: ```bash #!/bin/bash # 在 m920x 上备份树莓派的 SD 卡 # 用法: # ./backup_sd.sh pi4 /dev/mmcblk1 3 # ./backup_sd.sh zero2w /dev/mmcblk1 3 # ./backup_sd.sh pi51 /dev/mmcblk1 3 # ./backup_sd.sh pi52 /dev/mmcblk1 3 # 检查参数 if [[ $# -lt 3 ]]; then echo "Usage: $0 " echo "Example: $0 raspberrypi /dev/mmcblk1 3" exit 1 fi # 参数赋值 REMOTE_SD="$1" # 树莓派的 SSH 别名 REMOTE_DEVICE="$2" # 树莓派的 SD 卡设备 MAX_BACKUPS="$3" # NAS 上保留的最大镜像文件数 LOCAL_DIR="/mnt/2.870.ssd/pi.backup" # 本地备份存放目录 PISHRINK_PATH="${LOCAL_DIR}/pishrink.sh" # pishrink.sh 脚本路径 NAS_TARGET="/volume4/backups/SD/${REMOTE_SD}" # NAS 目标目录 TIMESTAMP=$(date "+%Y%m%d%H%M%S") # 时间戳 LOCAL_IMAGE="$LOCAL_DIR/temp_image_${REMOTE_SD}_$TIMESTAMP.img" LOCAL_IMAGE_LITE="$LOCAL_DIR/${REMOTE_SD}_lite_$TIMESTAMP.img" LOG_FILE="$LOCAL_DIR/backup_${REMOTE_SD}_${TIMESTAMP}.log" # 确保本地备份目录存在 mkdir -p "$LOCAL_DIR" # 日志记录函数 log_message() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" } # 检查并创建 NAS 目标目录 log_message "Checking and creating NAS target directory if it doesn't exist..." ssh ds923 "mkdir -p $NAS_TARGET" if [[ $? -ne 0 ]]; then log_message "Error creating NAS target directory. Exiting." exit 1 fi log_message "NAS target directory is ready: $NAS_TARGET" # 第一步:备份 SD 卡到本地 log_message "Starting SD card backup from $REMOTE_SD..." ssh $REMOTE_SD "dd if=$REMOTE_DEVICE" | dd of=$LOCAL_IMAGE bs=1M status=progress 2>>"$LOG_FILE" if [[ $? -ne 0 ]]; then log_message "Error during SD card backup. Exiting." exit 1 fi log_message "SD card backup completed: $LOCAL_IMAGE" # 第二步:压缩镜像文件 log_message "Compressing the backup image using pishrink..." $PISHRINK_PATH $LOCAL_IMAGE $LOCAL_IMAGE_LITE >>"$LOG_FILE" 2>&1 if [[ $? -ne 0 ]]; then log_message "Error during image compression. Exiting." exit 1 fi log_message "Compression completed: $LOCAL_IMAGE_LITE" # 删除原始未压缩镜像文件 rm -f "$LOCAL_IMAGE" log_message "Deleted uncompressed backup image: $LOCAL_IMAGE" # 第三步:上传到 NAS log_message "Uploading compressed image to NAS..." rsync -azvP $LOCAL_IMAGE_LITE ds923:$NAS_TARGET >>"$LOG_FILE" 2>&1 if [[ $? -ne 0 ]]; then log_message "Error during upload to NAS. Exiting." exit 1 fi log_message "Upload completed: $LOCAL_IMAGE_LITE" # 删除本地压缩镜像文件 rm -f "$LOCAL_IMAGE_LITE" log_message "Deleted local compressed backup image: $LOCAL_IMAGE_LITE" # 第四步:清理 NAS 上多余的备份 ssh ds923 " find ${NAS_TARGET} -type f -name '*.img' | sort -r | tail -n +$((MAX_BACKUPS + 1)) | while read -r file; do echo \"删除远程的旧备份: \$file\" rm -f \"\$file\" done " echo "备份完成并清理。" # 清理本地旧日志文件,保留最新 5 份 log_message "Cleaning up old local logs..." cd "$LOCAL_DIR" && ls -t backup_${REMOTE_SD}_*.log | tail -n +6 | xargs -r rm -f if [[ $? -ne 0 ]]; then log_message "Error during log cleanup." exit 1 fi log_message "Log cleanup completed. Retained latest 5 logs for $REMOTE_SD." log_message "Backup process finished successfully!" ``` **添加定时任务**: ```bash # pishrink.sh 需要 root 运行 sudo crontab -e ``` ```bash 0 10 * * 7 /mnt/2.870.ssd/pi.backup/backup.sd.sh pi4 /dev/mmcblk0 3 30 10 * * 7 /mnt/2.870.ssd/pi.backup/backup.sd.sh pi51 /dev/mmcblk0 3 0 11 * * 7 /mnt/2.870.ssd/pi.backup/backup.sd.sh pi52 /dev/mmcblk0 3 30 11 * * 7 /mnt/2.870.ssd/pi.backup/backup.sd.sh zero2w /dev/mmcblk0 3 ``` --- #### 参考 - [再推荐一个备份树莓派系统的脚本 | 树莓派实验室](https://shumeipai.nxez.com/2020/10/28/backup-the-raspberry-pi-system-as-an-img-file.html) - [【小白教程】给你的 openwrt 启动盘做个可恢复“快照”。-OPENWRT 专版-恩山无线论坛 - Powered by Discuz!](https://www.right.com.cn/forum/thread-4056313-1-1.html) - [树莓派系统备份\_51CTO 博客\_树莓派备份](https://blog.51cto.com/wangjichuan/5691223) - [树莓派安装系统和系统备份还原\_树莓派备份系统镜像\_红色小小螃蟹的博客-CSDN 博客](https://blog.csdn.net/yangcunbiao/article/details/123079103) - [树莓派备份系统到硬盘 | AllanHao](https://allanhao.com/2022/09/18/2022-09-18-rpi-backup/) - [GitHub - nanhantianyi/rpi-backup: raspberry pi backup,树莓派系统备份,最小镜像备份](https://github.com/nanhantianyi/rpi-backup) - [树莓派系统镜像一键备份脚本, 最小化镜像保存-次世代 BUG 池](https://neucrack.com/p/107) - [GitHub - mghcool/Raspberry-backup: 树莓派备份脚本](https://github.com/mghcool/Raspberry-backup) - [树莓派备份系统到硬盘 | AllanHao](https://allanhao.com/2022/09/18/2022-09-18-rpi-backup/) - [克隆树莓 Raspberry Pi Mode4 的 TF 卡\_tf 卡克隆-CSDN 博客](https://blog.csdn.net/zhuoqingjoking97298/article/details/114875177) - [教你树莓派 4B 的系统备份方法教程大全(全卡+压缩备份) - bongem - 博客园](https://www.cnblogs.com/bongem/p/12312878.html) - [GitHub - BigBubbleGum/RaspberryBackup: 在 Linux 系统中一键备份树莓派系统 SD 卡的脚本](https://github.com/BigBubbleGum/RaspberryBackup) - [PiShrink](https://github.com/Drewsif/PiShrink) && [在 macOS 中运行](https://github.com/Drewsif/PiShrink/issues?q=macos) - [树莓派系统压缩备份——PiShrink 应用实操-CSDN 博客](https://blog.csdn.net/m0_37728676/article/details/108581488) - [树莓派系统镜像一键备份脚本, 最小化镜像保存-次世代 BUG 池](https://neucrack.com/p/107) - [树莓派系统镜像备份及压缩至最小的方法\_树莓派压缩备份-CSDN 博客](https://blog.csdn.net/u013735688/article/details/121130583) - [GitHub - elespec/rpi-backup: RaspberryPi Backup shell](https://github.com/elespec/rpi-backup?tab=readme-ov-file) - [MAC 上备份(复制)树莓派镜像 | 个人笔记存档](https://www.zhangjc.tech/backup_or_copy_raspberrypi_image_on_mac/) --- ### OpenWrt 备份 OpenWrt 设备有 4 个: - R2S \* 3 - R5S #### 自动备份脚本 ```bash #!/bin/bash # 参数配置 DEST_DIR=$1 # 本地备份目的地目录(例如: /mnt/lankxin.u/backup) BACKUP_PREFIX=$2 # 备份文件前缀(例如: backup-r2s2) MAX_BACKUPS=$3 # 最大保留备份数量(例如: 5) REMOTE_DIR="/volume1/driver/Others/Router/backup/automatic" # 远程备份目录 # 检查参数是否足够 if [ -z "$DEST_DIR" ] || [ -z "$BACKUP_PREFIX" ] || [ -z "$MAX_BACKUPS" ]; then echo "使用方法: $0 <备份目的地目录> <备份文件前缀> <最大备份数量>" exit 1 fi # 备份文件路径 backup_file="/tmp/${BACKUP_PREFIX}-$(date +%F).tar.gz" # 创建备份 umask go= sysupgrade -b "$backup_file" echo "备份文件: ${backup_file}" # 将备份文件复制到指定目的地目录 cp "$backup_file" "$DEST_DIR" # 同步到远程服务器 rsync -azvP "$backup_file" "ds218:$REMOTE_DIR" # 删除临时备份文件 rm -rf /tmp/${BACKUP_PREFIX}-*.tar.gz # 保留本地备份数量 echo "检查并清理本地备份文件..." find "${DEST_DIR}" -type f -name "${BACKUP_PREFIX}-*.tar.gz" -printf '%T+ %p\n' | \ sort -r | \ tail -n +$((MAX_BACKUPS + 1)) | \ awk '{print $2}' | \ while read -r file; do echo "删除旧的本地备份: $file" rm -f "$file" done # 保留远程备份数量 echo "检查并清理远程备份文件..." ssh ds218 " find ${REMOTE_DIR} -type f -name '${BACKUP_PREFIX}-*.tar.gz' -printf '%T+ %p\n' | \ sort -r | \ tail -n +$((MAX_BACKUPS + 1)) | \ awk '{print \$2}' | \ while read -r file; do echo \"删除远程的旧备份: \$file\" rm -f \"\$file\" done " echo "备份完成并清理。" # bark 通知 curl http://192.168.x.x:port/token/系统备份成功\(R2S.U\)\?group\=System.Backup ``` 脚本的作用是自动生成备份文件, 然后通过 `rsync` 上传到 NAS 的 `/volume1/driver/Others/Router/backup/automatic` 目录下, 而 `/volume1/driver` 目录后续会被其他套件备份. > 脚本中的 **ds218** 是一个 ssh 别名, 需要在 .ssh/config 中配置, 且配置免密登录. 然后在 OpenWrt 系统中添加定时任务: ```bash # 每周 2, 4, 6 早上 5 点执行 0 5 * * 2,4,6 /root/backup.sh /mnt/lankxin.u/backup r2st 5 > /tmp/backup.log 2>&1 ``` ![20241229154732_GLxIfNZ5.webp](https://cdn.dong4j.site/source/image/20241229154732_GLxIfNZ5.webp) > crontab 规则: > > ``` > * * * * * 需要执行的命令 > - - - - - > | | | | | > | | | | ----- 一星期中的第几天 (0 - 6) (其中0表示星期日) > | | | ------- 月份 (1 - 12) > | | --------- 一个月中的第几天 (1 - 31) > | ----------- 一天中的第几小时 (0 - 23) > ------------- 一小时中的第几分钟 (0 - 59) > ``` > > 示例: > > | 分钟 0-59 | 小时 0-23 | 月内第几天 1-31 | 月份 1-12 | 每周第几天 0-6(0 表示周日) | 效果 | > | --------- | --------- | --------------- | --------- | -------------------------- | ------------------------------------------------------ | > | \* | \* | \* | \* | \* | 每分钟执行一次 | > | \*/5 | \* | \* | \* | \* | 每五分钟执行一次 | > | 12 | \*/3 | \* | \* | \* | 每过 3 个小时后的第 12 分钟执行一次 | > | 57 | 11 | 15 | 1,6,12 | \* | 在 1、6、12 月中的 15 号的 11 点 57 分各执行一次 | > | 25 | 6 | \* | \* | 1-5 | 工作日期间(周 1 到周 5),每天早上 6 点 25 分执行一次 | > | 0 | 0 | 4,12,26 | \* | \* | 每月的第 4、12、26 日,晚上 12 点执行一次 | #### WebUI 手动备份 在修改了配置后及时手动执行备份操作: ![20241229154732_mNVD9sKb.webp](https://cdn.dong4j.site/source/image/20241229154732_mNVD9sKb.webp) 下载后的备份文件我会直接扔到 **~Synology/Others/Router/backup/manual** 以同步到 NAS 的 **driver** 目录, 而此目录最终会被其他套件备份. ### eMMC 备份 eMMC 设备有 4 个: - H28K (Armbian) - HB1 Box (Armbian) - NanoPi NEO4 \* 2 (Ubuntu/Debian) 首先需要确认 eMMC 的分区: ```bash ➜ ~ fdisk -l Disk /dev/mmcblk1: 29.13 GiB, 31272730624 bytes, 61079552 sectors Units: sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disklabel type: gpt Disk identifier: B6CA70E8-D82F-BA42-AFC0-6A872D21A362 Device Start End Sectors Size Type /dev/mmcblk1p1 32768 557055 524288 256M Linux extended boot /dev/mmcblk1p2 557056 60456959 59899904 28.6G Linux filesystem ``` 可以确认分区为 **/dev/mmcblk1**, 然后使用下面的命令构建镜像: ```bash # 将 远程的 /dev/mmcblk1 备份到 本机的 backup_image.img ssh root@ "dd if=/dev/mmcblk1" | dd of=backup_image.img bs=1M status=progress ``` ![20241229154732_ZllEgtpX.webp](https://cdn.dong4j.site/source/image/20241229154732_ZllEgtpX.webp) 因为我有多个 eMMC 设备, 所以写了一个自动化脚本执行: ```bash #!/bin/bash # 脚本目录 SCRIPT_DIR=$(dirname "$0") # 日志文件路径 LOG_FILE="$SCRIPT_DIR/backup_emmc.log" # 将所有输出重定向到日志文件 exec > >(tee -a "$LOG_FILE") 2>&1 # ========================= # 自动化 eMMC 备份脚本 # ========================= # 参数 REMOTE_EMMC="$1" # 远程主机(.ssh/config 别名, 免密登录) REMOTE_DEVICE="$2" # 远程备份的设备路径 (如 /dev/mmcblk1) BACKUP_DIR="$3" # 本地备份存储路径 (如 /path/to/backups) BACKUP_PREFIX="$4" # 备份文件名前缀 (如 backup_emmc) MAX_BACKUPS="$5" # 最大保留备份数量 (如 5) # 检查参数是否完整 if [ -z "$REMOTE_EMMC" ] || [ -z "$REMOTE_DEVICE" ] || [ -z "$BACKUP_DIR" ] || [ -z "$BACKUP_PREFIX" ] || [ -z "$MAX_BACKUPS" ]; then echo "Usage: $0 " exit 1 fi # 创建备份目录(如果不存在) mkdir -p "$BACKUP_DIR" # 获取当前时间戳 TIMESTAMP=$(date +%Y%m%d%H%M%S) # 本地备份文件路径 BACKUP_FILE="$BACKUP_DIR/${BACKUP_PREFIX}_${TIMESTAMP}.img" # 开始备份 echo "Starting backup from $REMOTE_EMMC:$REMOTE_DEVICE to $BACKUP_FILE" ssh ${REMOTE_EMMC} "dd if=${REMOTE_DEVICE} bs=1M" | dd of=$BACKUP_FILE bs=1M status=progress # 检查备份是否成功 if [ $? -eq 0 ]; then echo "Backup completed: $BACKUP_FILE" else echo "Backup failed!" exit 1 fi # 删除多余的备份文件 echo "Cleaning up old backups..." find "$BACKUP_DIR" -type f -name "${BACKUP_PREFIX}_*.img" -printf '%T+ %p\n' | \ sort -r | \ tail -n +$((MAX_BACKUPS + 1)) | \ awk '{print $2}' | \ while read -r file; do echo "Deleting old backup: $file" rm -f "$file" done echo "Backup process completed." ``` 将上述脚本保存为 `backup.emmc.sh`,并赋予执行权限: ```bash chmod +x backup.emmc.sh ``` 运行脚本: ```bash ./backup.emmc.sh ``` - REMOTE_EMMC:要备份的设备的别名, 需要在 `.ssh/config` 配置, 并在设备端开启免密登录; - REMOTE_DEVICE:需要备份的设备路径(如 /dev/mmcblk1); - BACKUP_DIR:本地存储备份文件的目录; - BACKUP_PREFIX:备份文件名前缀(如 backup_emmc); - MAX_BACKUPS:保留的最大备份数量(如 5)。 示例: ```bash # 备份到当前目录下 ./backup.emmc.sh h28k /dev/mmcblk1 . h28k 5 ``` 添加定时任务: ```bash sudo crontab -e ``` 每天凌晨 2 点执行: ```bash 0 2 * * * /path/to/backup.emmc.sh ``` > 如果是在 NAS 上执行, 直接使用 WebUI 设置定时任务即可: > > 比如备份脚本路径: `/volume4/backups/eMMC/backup.emmc.sh`, 设置任务计划: > > ![20241229154732_km0ameIh.webp](https://cdn.dong4j.site/source/image/20241229154732_km0ameIh.webp) > > ```bash > /volume4/backups/eMMC/backup.emmc.sh h28k /dev/mmcblk1 /volume4/backups/eMMC h28k 5 > ``` --- ### Linux 备份 Linux 主机只包括 M920x 和 Station 2 个 Ubuntu Server 这种相对来说较大的主机, 其他的比如 Armbian 等衍生的 Linux 系统不包含在内. #### M920x 备份 在 [Synology NAS](#物理服务器备份) 一节中会使用 **Active Backup for Business** 的 [物理服务器备份功能](#物理服务器备份) 备份 M920x 的系统盘. M920x 是作为 Docker 主力机使用, 所以上面启动了大量的 Docker 容器以及少量 KVM 虚拟机, 这部分数据我打算使用 `rsync` 增量备份到另一块 SSD 上(M920x 有 4 块独立的 1T SSD): ```bash #!/bin/bash # 定义源目录和目标目录 SOURCE_DIR_DOCKER="/mnt/3.860.ssd/docker" SOURCE_DIR_KVM="/mnt/3.860.ssd/kvm" SOURCE_DIR_KVM_CONFIG="/etc/libvirt" # 假设 KVM 配置文件在这里,如果不对需要修改 DEST_DIR="/mnt/1.870.ssd" LOG_DIR="/mnt/1.870.ssd/backup_logs" # 定义日志目录 TIMESTAMP=$(date "+%Y-%m-%d_%H-%M-%S") LOG_FILE="$LOG_DIR/rsync_backup_$TIMESTAMP.log" # 增量备份 Docker 目录, 不使用 --progress 避免产生大量日志 echo "Starting backup of Docker directory..." >> "$LOG_FILE" rsync -avh --delete "$SOURCE_DIR_DOCKER" "$DEST_DIR" >> "$LOG_FILE" 2>&1 # 增量备份 KVM 目录 echo "Starting backup of KVM directory..." >> "$LOG_FILE" rsync -avh --progress --delete "$SOURCE_DIR_KVM" "$DEST_DIR" >> "$LOG_FILE" 2>&1 # 增量备份 KVM 配置文件 echo "Starting backup of KVM configuration files..." >> "$LOG_FILE" rsync -avh --progress --delete "$SOURCE_DIR_KVM_CONFIG" "$DEST_DIR" >> "$LOG_FILE" 2>&1 # 输出完成信息 echo "Backup completed on $(date)" >> "$LOG_FILE" # 保留最近的 5 个日志文件,删除旧日志 echo "Cleaning up old log files..." >> "$LOG_FILE" find "$LOG_DIR" -type f -name "rsync_backup_*.log" | sort -r | tail -n +6 | xargs -r rm -f # 确认完成 echo "Cleanup completed. Latest 5 logs are kept." >> "$LOG_FILE" ``` 此脚本将 **docker**, **KVM 虚拟机文件** 以及 **KVM 的配置文件** 从 `/mnt/3.860.ssd` 备份到 `/mnt/1.870.ssd` 目录下, 将上述脚本重命名为 `backup.sh` 并赋予执行权限, 然后添加到定时任务中: ```bash # 必须使用 sudo 运行 sudo crontab -e # 每天凌晨 2 点执行 0 2 * * * /mnt/1.870.ssd/backup.sh ``` > M920x 除了系统盘备份, `/mnt/3.860.ssd` 也会通过 **Active Backup for Business** 备份到 NAS 上, 相当于 2 份备份, 最后一份云端备份由 DS923+ 统一处理. --- #### Station Station 作为 AI 实验室使用, 目前只有 2 个 1T 的 m.2 固态, 模型数据和系统是分开的, 像 LLM 这些比较容易在网上获取的数据就没有必要备份了, 所以只需要备份系统盘即可. 在 [Synology NAS](#物理服务器备份) 一节中会使用 **Active Backup for Business** 的 [物理服务器备份功能](#物理服务器备份) 备份 Station 的系统盘. ### macOS 备份 macOS 设备有 3 台: - Macbook Pro Apple M1 Max - Mac mini 2018 - Mac mini Apple M2 MBP 是主力机, 其他两台放家里当服务器用, 因为 **AirPort Time Capsule** 只有 2T 的空间, 且备份速度较慢, 所以我打算将 MBP 备份到 DS923+ 上, 而其他 2 台 Mac 备份到 **AirPort Time Capsule**. #### 系统备份 ##### 使用 ABB 备份 我将使用 **Active Backup for Business** 的 macOS 备份功能备份整个 MBP, 具体方式看 [**Windows/macOS 备份**](#Windows/macOS-备份). ##### 使用 Time Machine 另外两台 Mac 直接使用 **AirPort Time Capsule 2T** 备份, 只需要在 macOS 上简单配置即可, 而 NAS 同样可以作为 Time Machine 备份 macOS. 首先打开 **启用通过 SMB 进行 Bonjour Time Machine 播送** (AFP 可能对最新的 macOS 存在兼容性问题, 所以推荐使用 SMB 协议): ![20241229154732_qsi93R7k.webp](https://cdn.dong4j.site/source/image/20241229154732_qsi93R7k.webp) 接着设置 Time Machine 的存储目录, 最好新建一个专门用于 Time Machine 的共享目录, 并启用 **索引** 功能, 后续可以直接使用 Mac Finder 搜索启动的文件和内容. 然后在 macOS 上配置 Time Machine: ![20241229154732_XyeMqjkU.webp](https://cdn.dong4j.site/source/image/20241229154732_XyeMqjkU.webp) 推荐使用 [TimeMachineEditor](https://tclementdev.com/timemachineeditor/), 可在特定时间启动 Time Machine 中的备份. 比如可以选择间隔或创建其他类型的计划: ![20241229154732_pgJJBWR8.webp](https://cdn.dong4j.site/source/image/20241229154732_pgJJBWR8.webp) #### 重要文件备份 使用 [Synology Driver Client](#Synology-Drive-Client) 备份. ### iCloud 数据备份 iCloud 比较重要的就 3 个: - Surge - Obsidian - 照片 对于前 2 个目录直接在 Mac mini 2018 上通过脚本自动备份 (`backup.icloud.sh`) : ```bash #!/bin/bash # 它会将 iCloud 中的 Surge 和 Obsidian 目录备份到指定的 Synology Drive 路径,并使用 yyyy-mm-dd 格式的日期作为备份文件夹名 # 定义源目录和目标目录 SOURCE_SURGE="$HOME/Library/Mobile Documents/iCloud~com~nssurge~inc/Documents" SOURCE_OBSIDIAN="$HOME/Library/Mobile Documents/iCloud~md~obsidian/Documents" # 这是 Synology Drive 的目录, 会自动同步到 NAS DEST_SURGE="$HOME/Library/CloudStorage/SynologyDrive-driver/macOS/apps/surge" DEST_OBSIDIAN="$HOME/Library/CloudStorage/SynologyDrive-driver/Obsidian" # 获取当前日期,格式为 yyyy-mm-dd DATE=$(date +%F) # 创建备份文件夹 SURGE_BACKUP="$DEST_SURGE/$DATE" OBSIDIAN_BACKUP="$DEST_OBSIDIAN/$DATE" mkdir -p "$SURGE_BACKUP" mkdir -p "$OBSIDIAN_BACKUP" # 开始备份 echo "Backing up Surge to $SURGE_BACKUP..." rsync -avh --delete "$SOURCE_SURGE/" "$SURGE_BACKUP/" echo "Backing up Obsidian to $OBSIDIAN_BACKUP..." rsync -avh --delete "$SOURCE_OBSIDIAN/" "$OBSIDIAN_BACKUP/" # 打印完成信息 echo "Backup completed: $DATE" ``` **使用 launchd 添加定时任务**: 1. 在 `~/Library/LaunchAgents` 目录下创建一个 `.plist` 文件 (`touch ~/Library/LaunchAgents/xx.xxx.icloud-backup.plist`): ```xml Label xx.xxx.icloud-backup ProgramArguments ~/Library/CloudStorage/SynologyDrive-driver/DevOps/batch-script/shell/backup/backup.icloud.sh StartCalendarInterval Hour 3 Minute 0 StandardOutPath /tmp/backup.log StandardErrorPath /tmp/backup_error.log RunAtLoad ``` 2. 加载 launchd 配置: ```bash launchctl load ~/Library/LaunchAgents/xx.xxx.icloud-backup.plist ``` 3. 检查任务状态: ```bash launchctl list | grep xx.xxx.icloud-backup ``` 4. 不用后可以卸载: ```bash launchctl unload ~/Library/LaunchAgents/xx.xxx.icloud-backup.plist ``` > 照片我则使用 **Synology Photos Mobile** 备份到 DS218+, 然后再自动同步到 DS923+. **其他参考:** - [使用 Docker 为 iCloud 照片生成本地备份](https://ios.sspai.com/post/90641) --- ### Synology NAS > 之所以先讲其他设备的备份方式, 是因为它们的备份文件最终都会写入到 DS923+ 中, 最后我只需要对 DS923+ 上的文件进行备份即可. [Synology NAS 提供多种备份方式](https://kb.synology.com/zh-tw/DSM/tutorial/How_to_back_up_your_Synology_NAS#x_anchor_id12), 他们的对比如下: | 备份目的地 | Hyper Backup | Snapshot Replication | USB Copy | Cloud Sync | | :------------------ | :----------- | :------------------- | :------- | :---------------------------------- | | 本地共用资料夹 | 可使用 | 可使用 | 不可使用 | 不可使用 | | 外接装置(USB) | 可使用 | 不可使用 | 可使用 | 不可使用 | | 另一台 Synology NAS | 可使用 | 可使用 | 不可使用 | 不可使用 | | 档案伺服器 | 可使用 | 不可使用 | 不可使用 | 仅支援 WebDAV 及 OpenStack 资料同步 | | 公有云 | 可使用 | 不可使用 | 不可使用 | 可使用 | **功能总结**: | 应用程式及系统设定备份 | 可使用 | 不可使用 | 不可使用 | 不可使用 | | --------------------------- | -------- | ------------------------------------------- | ---------- | ---------------- | | 备份及还原效能 | 低 | 高 | 中 | 中 | | 储存空间利用效率 | 中 | 高 | 低 | 低 | | 备份频率 | 每小时 | 每 5 分钟(共用资料夹) 每 15 分钟(iSCSI LUN) | 热插拔备份 | 于资料变更时同步 | | 透过 WriteOnce 保护备份资料 | 不可使用 | 仅支援不可变快照 | 不可使用 | 不可使用 | 根据上面的功能总结, 我计划的备份方案为: 1. 使用 **Snapshot Replication** 在当前 NAS 为重要共享目录创建快照作为第一层保护, 在手残误删文件时能够快速恢复 (**本地备份**); 2. 在 DS218+ 上使用 **Hyper Backup** 将重要的 APP 配置与数据备份到 DS923+, 同时通过 WebDAV 加密备份到云端, 还原颗粒度可以精确到文件级别 (**不同的存储介质备份**); 3. 在 DS218+ 上使用 **Hyper Backup** 将整个系统备份到 DS923+, 同时通过 WebDAV 加密备份到云端, 以便在系统崩溃或损坏时还原 (**不同的存储介质备份**); 4. 在 DS923+ 上使用 **Hyper Backup** 将所有照片和家庭视频通过 rsync 备份到 Mac mini 2018 连接的 **LaCie 8TB d2 Professional** (**不同的存储介质备份**); 5. 在 DS923+ 上使用 **Hyper Backup** 将重要的 APP 配置与数据备份(包括照片和家庭视频)通过 WebDAV 加密备份到云端 (**云端备份**); 6. 在 DS923+ 上使用 **Active Backup for Business** 整机备份 DS218+ (**不同的存储介质备份**); 7. 在 DS923+ 上使用 **Active Backup for Business** 物理服务器备份功能集中备份 M920x 和 Station 整个系统 (**不同的存储介质备份**); 8. 在 DS923+ 上使用 **Active Backup for Business** 文件服务器备份功能集中备份所有开发板和小主机的重要文件 (**不同的存储介质备份**); 9. 在 DS923+ 上使用外置存储定时备份 **Active Backup for Business** 产生的备份文件 (**不同的存储介质备份**); 10. 其他能够安装 **Synology Drive Client** 的主要设备上, 使用 **Drive** 自带的备份功能备份本机上重要的文件 (**不同的存储介质备份**); --- #### Snapshot Replication [**Snapshot Replication**](https://www.synology.cn/zh-cn/dsm/feature/snapshot_replication) 是 Synology 提供的一项数据备份功能,它通过利用 Btrfs 文件系统创建时间点副本,实现数据的块级保护和快速恢复。该功能支持多种复制策略,可灵活部署,并配备便捷的管理工具,帮助我们轻松实现数据的备份和灾难恢复. 在系统支持 Btrfs 文件格式的情况下,建议优先考虑使用快照复制作为备份策略。这种方式可以将备份周期设置得非常短,非常适合处理公共文件夹中同时进行大量编辑的情况。 当遇到问题(例如:不慎删除了大量数据,或是不知道进行了哪些修改)需要恢复文件或整个共享文件夹时,你会非常感激不需要按照一天或两天的时间单位来回溯。 优点是:备份周期越短,恢复时只需还原单个文件或整个文件夹,而且还原速度极快。这样的备份方案在紧急情况下能够大大减轻工作压力。 ![20241229154732_m09bLGED.webp](https://cdn.dong4j.site/source/image/20241229154732_m09bLGED.webp) 如果在勾选 **高级-让快照可见** 的选项后, 可在文件管理器中查看已保存的快照, 此目录保存在每个创建了快照的共享目录下, 且不会被其他群晖备份套件备份: ![20241229154732_d7mCWrSi.webp](https://cdn.dong4j.site/source/image/20241229154732_d7mCWrSi.webp) 同时还具备 **复制** 功能, 选择远程服务器作为复制目的地时, 比如另一台 NAS, 即可将快照在另一台 NAS **重现**, 不过我一直卡在循环验证的地方, 暂时并未使用此功能: ![20241229154732_smrtCyCo.webp](https://cdn.dong4j.site/source/image/20241229154732_smrtCyCo.webp) > Snapshot Replication 属于本地备份, 性能和还原速度都是最优的, 且快照占用的磁盘空间较小. --- #### Hyper Backup Hyper Backup 是几个备份套件中功能最全的一个, 除了整机备份, 还能单独备份共享目录和 APP 配置, 所以在 [整机备份](#NAS-整机备份) 的基础上, 我还是用它来单独备份个别共享目录和全部的 APP 配置, 这样我可以在不需要整机还原的情况下单独恢复部分数据或 APP: ![20241229154732_bpGajtWl.webp](https://cdn.dong4j.site/source/image/20241229154732_bpGajtWl.webp) Hyper Backup 备份 APP 时会一并将对应的共享目录一起备份: ![20241229154732_bQkbLo6V.webp](https://cdn.dong4j.site/source/image/20241229154732_bQkbLo6V.webp) > 在 DS218+ 上 使用此套件将文件备份到 DS923+, 并加密备份到阿里云盘, 相当于有 2 份备份, 且一份在异地. > > Hyper Backup 提供了灵活的方法来选择要备份的共享文件夹、文件夹和文件。可以勾选和取消勾选复选框以选择要备份的内容。有 3 种不同的备份选择可供选择: > > ![20241229154732_s5fn82qw.webp](https://cdn.dong4j.site/source/image/20241229154732_s5fn82qw.webp) --- #### Active Backup for Business [Active Backup for Business](https://www.synology.cn/zh-cn/dsm/feature/active_backup_business#pc) 是一款功能全面的备份与恢复工具,适合需要集中管理多种设备数据的需求. 目前我正在用它来集中备份多个开发板上的重要文件, Linux 整机备份以及 DS218+ 的整机备份. ##### NAS 整机备份 这个将在 [NAS 整机备份](#NAS-整机备份) 一节中详细介绍, 这里就不再赘述了. ##### Windows/macOS 备份 我目前没有 Windows 主机, 根据 [前面的计划](#macOS-备份), MBP 将使用此功能备份(可以享受万兆内网的速度), 另外 2 台 Mac 将使用 [Time Machine](#系统备份) 备份到 **AirPort Time Capsule 2T**. macOS 需要下载 **Active Backup for Business Agent**, 在 macOS 端主动连接到 DS923+ 的备份服务器: ![20241229154732_ZfhbpwPy.webp](https://cdn.dong4j.site/source/image/20241229154732_ZfhbpwPy.webp) 因为是内网连接, 可以不用管这个 SSL 证书问题, 点击 **仍然继续** 即可. 不过为了避免因为证书到期导致备份失败, 最好直接使用 **Active Backup for Business 证书**: ![20241229154732_a4IEYPy8.webp](https://cdn.dong4j.site/source/image/20241229154732_a4IEYPy8.webp) 启用后会自动在 **控制面板-安全性-证书** 中添加相应的证书: ![20241229154732_VnZMCUOS.webp](https://cdn.dong4j.site/source/image/20241229154732_VnZMCUOS.webp) 另一个问题是模版匹配: ![20241229154732_6v2p4f48.webp](https://cdn.dong4j.site/source/image/20241229154732_6v2p4f48.webp) 连接服务器时, 因为 **备份目的地格式不受支持** 而添加失败, 其实不是格式不受支持, **是备份的目的地目录不存在**. ![20241229154732_xKF38i63.webp](https://cdn.dong4j.site/source/image/20241229154732_xKF38i63.webp) 在 DS923+ 的 **Active Backup for Business** 修改模版中的 **目的地**, 默认是 **ActiveBackupforBusiness**, 但是我们的 NAS 根本就没有这个共享目录, 所以出错了(也有可能是我以前把这个目录手动删除了 😂). ![20241229154732_svmFryzc.webp](https://cdn.dong4j.site/source/image/20241229154732_svmFryzc.webp) 更换一个存在且可用的共享目录即可, 添加成功后的 **Agent 信息**: ![20241229154732_ZzsyQIdD.webp](https://cdn.dong4j.site/source/image/20241229154732_ZzsyQIdD.webp) **服务端信息**: ![20241229154732_o6QFY31x.webp](https://cdn.dong4j.site/source/image/20241229154732_o6QFY31x.webp) 后续就是根据自己的情况配置任务了. --- ##### 物理服务器备份 Active Backup for Business 目前支持以下 Linux 系统: - Debian:10, 11, 12 - Ubuntu:16.04, 18.04, 20.04, 22.04, 24.04 - Architechture: x86_64 连接方式也非常简单: 1. 下载官方的 agent, 这个在 **Active Backup for Business - 物理服务器 - 添加设备** 时会告知下载地址; 2. 修改模版(因为我没有这一步导致连接失败): **Active Backup for Business - 设置 - 模版**, 将 **共享文件夹** 修改成存在的共享目录; 3. 在 Linux 上安装 Agent, 可查看 **README** 的操作指南; 4. 安装完成后使用 **abb-cli -c** 连接 NAS: ```bash $ abb-cli -c Enter server address: NAS 的 IP Enter username: NAS 的用户名 Enter password: 对应的密码 Connecting... The SSL certificate of the Synology NAS is not trusted. To learn how to obtain a valid certificate, please have a registered domain by setting up DDNS (http://sy.to/ddns) and applying for Let's Encrypt (http://sy.to/letsencrypt) certificate. Proceed anyway? [y/n](default: y): y Enter a 6-digit verification code xxxxx (2FA code) Server address: x.x.x.x Username: xxx Backup type: Server Applied template: Default Backup destination: backup Source type: System volumes backup Restoration privilege: admin, xxx, administrators Back up by time: Monday, Tuesday, Wednesday, Thursday, Friday Start at: 03:00(Daily/Weekly) Retention policy: Advanced retention policy settings Backup window: Disabled Data transfer compression: Enable Data transfer encryption: Enable Bandwidth consumption limit: Disabled Compression at backup destination: Enable Encryption at backup destination: Disabled Backup verification: Disabled Pre/post script: Disabled Confirm authentication? [y/n](default: y): Confirming authentication... Successfully connected ``` 最后在 **Active Backup for Business** 端可修改备份计划: ![20241229154732_uMETHAjv.webp](https://cdn.dong4j.site/source/image/20241229154732_uMETHAjv.webp) 如果是整机还原, 需要使用 Linux 恢复媒体创建一个 [可启动的 USB 恢复驱动器](https://kb.synology.com/en-global/DSM/tutorial/How_do_I_create_a_bootable_USB_drive_for_restoring_Linux): ![20241229154732_PS1X66hN.webp](https://cdn.dong4j.site/source/image/20241229154732_PS1X66hN.webp) 如果只是文件还原, 可使用 **Active Backup for Business Portal** 操作: ![20241229154732_upTgftjF.webp](https://cdn.dong4j.site/source/image/20241229154732_upTgftjF.webp) ##### 文件服务器备份 将开发板连接到 **Active Backup for Business Portal** 集中备份: ![20241229154732_BiIE6axw.webp](https://cdn.dong4j.site/source/image/20241229154732_BiIE6axw.webp) 文件服务器备份使用的是 **rsync**, 且最好使用 root 用户, 不然某些目录由于权限问题无法备份: ![20241229154732_Wu9lAdyl.webp](https://cdn.dong4j.site/source/image/20241229154732_Wu9lAdyl.webp) ##### 虚拟机备份 **Active Backup for Business** 只能备份 VMware® vSphere™ 与 Microsoft® Hyper-V® 虚拟机, 所以 [M920x 上的 KVM 虚拟机我只能使用脚本备份](#M920x-备份), 而 Synology Virtual Machine Manager 中的虚拟机可以通过快照来备份, 另一种方式是将虚拟机导出到共享目录, 然后使用其他方式备份虚拟机文件. --- #### Synology Drive Client Synology Drive Client 在提供同步功能的同时还具备备份功能, 所以我将主力机上的重要文件使用它备份到 NAS 中, 备份的目的地只能选择当前登录用户的 home 目录: ![20241229154732_sF7Y1PVi.webp](https://cdn.dong4j.site/source/image/20241229154732_sF7Y1PVi.webp) --- #### DSM 系统冗余 首先需要了解一下 **RAID 的几种类型**: | RAID 类型 | 磁盘数量 | 数据分布 | 冗余能力 | 性能 | 最少磁盘要求 | | --------- | ---------- | ----------------- | ---------------------------- | ---- | ------------ | | RAID 0 | 2+ | 条带化 | 无 | 最高 | 2 | | RAID 1 | 2+(偶数) | 镜像 | 完全 | 一般 | 2 | | RAID 5 | 3+ | 条带化+奇偶校验 | 单磁盘故障 | 较高 | 3 | | RAID 6 | 4+ | 条带化+双奇偶校验 | 双磁盘故障 | 较高 | 4 | | RAID 10 | 4+(偶数) | 镜像+条带化 | 单磁盘故障(每个镜像组) | 高 | 4 | | RAID 50 | 6+ | RAID 5 + 条带化 | 单磁盘故障(每个 RAID 5 组) | 较高 | 6 | | RAID 60 | 8+ | RAID 6 + 条带化 | 双磁盘故障(每个 RAID 6 组) | 较高 | 8 | > 条带化(Striping)是一种数据分配技术,它将数据分割成小块(通常称为“条带”或“块”),并将这些小块分散存储在多个磁盘上。这种技术在 RAID(独立冗余磁盘阵列)配置中非常常见,尤其是在 RAID 0、RAID 5、RAID 6 和 RAID 10 等级别中。 **说明:** 1. **RAID 0**:速度快,但无任何冗余,适合不关心数据丢失的高性能场景。 2. **RAID 1**:安全性高,写入时需要复制数据,磁盘利用率较低。 3. **RAID 5**:提供奇偶校验,容忍单个磁盘故障,适合平衡性能与冗余需求。 4. **RAID 6**:双重奇偶校验,更高安全性,适用于关键数据保护。 5. **RAID 10(1+0)**:兼顾 RAID 1 的安全性和 RAID 0 的速度。 6. **RAID 50/60**:适合企业级大型存储系统,结合 RAID 0 的性能和 RAID 5/6 的冗余。 参考: - [RAID 类型](https://zh.wikipedia.org/wiki/RAID) - [Synology-选择 RAID 类型](https://kb.synology.cn/zh-cn/DSM/help/DSM/StorageManager/storage_pool_what_is_raid?version=7) - [什么是 Synology Hybrid RAID (SHR)](https://kb.synology.cn/zh-cn/DSM/tutorial/What_is_Synology_Hybrid_RAID_SHR) - [RAID 计算器](https://www.synology.cn/zh-cn/support/RAID_calculator) 除了上述这些常见的 RAID 类型, 一些硬件和软件厂商还会开发自己的磁盘冗余阵列技术或增强技术: {% folding 🪬 其他厂商的磁盘冗余阵列技术或增强技术 %} 1. **ZFS**(Zettabyte File System) - **厂商/开发者**:Sun Microsystems(现属于 Oracle) - **特点**: - ZFS 集文件系统和卷管理器于一体。 - 提供类似于 RAID 的多种模式(如 RAID-Z、RAID-Z2、RAID-Z3)。 - 支持快照、数据完整性校验、重复数据删除(deduplication)。 - 提供动态条带化,无需固定条带大小。 - **优势**: - 高数据完整性:基于校验码检测和修复数据损坏。 - 灵活性:支持在线扩展存储池。 - **适用场景**:家庭 NAS(如 TrueNAS)、企业存储、高可靠性备份。 2. **SHR**(Synology Hybrid RAID) - **厂商/开发者**:Synology - **特点**: - 提供类似于 RAID 的功能,但支持**混合硬盘容量**。 - 自动优化存储利用率,允许用户使用不同大小的硬盘组合。 - 支持 1 至 2 块硬盘冗余(类似 RAID 5 或 RAID 6)。 - **优势**: - 易用性:面向普通用户,无需手动配置复杂的 RAID 模式。 - 灵活性:允许随时添加更大容量的硬盘。 - **适用场景**:Synology NAS 用户,特别是家庭和小型企业存储系统。 3. UnRAID - **厂商/开发者**:Lime Technology - **特点**: - 与传统 RAID 不同,UnRAID 不做条带化或镜像,而是将数据独立存储在每块硬盘上。 - 提供一个专用的**奇偶校验盘**用于数据冗余。 - 支持灵活扩展,允许随时增加硬盘并使用不同容量的硬盘。​ - **优势**: - 数据独立性:即使多块硬盘损坏,未损坏的硬盘数据仍可直接读取。 - 灵活性:无需所有硬盘容量一致。 - **适用场景**:家庭媒体服务器、大容量存储需求。 4. HP’s ADG (Advanced Data Guarding) - **厂商/开发者**:Hewlett-Packard (HP) - **特点**: - 类似于 RAID 6,提供双重奇偶校验。 - 可允许两块磁盘同时故障而不丢失数据。 - 专为 HP 的企业级存储系统设计。 - **优势**: - 更高的容错性。 - 专为大型企业的高可用性环境而设计。 - **适用场景**:企业存储系统(如 HP ProLiant 服务器)。 5. NetApp RAID-DP - **厂商/开发者**:NetApp - **特点**: - 基于 RAID 4(单奇偶校验)改进,提供双奇偶校验(类似 RAID 6)。 - 专为企业级存储系统和大规模部署优化。 - **优势**: - 提高容错能力,可容忍两块磁盘同时故障。 - 集成在 NetApp 的 Data ONTAP 操作系统中,支持高效快照和备份。 - **适用场景**:大规模企业存储和虚拟化环境。 6. VMware VSAN(Virtual SAN) - **厂商/开发者**:VMware - **特点**: - 软件定义存储,将多个服务器的本地存储整合成共享存储池。 - 提供分布式数据冗余,支持 RAID 1、RAID 5 和 RAID 6 级别的保护。 - 数据跨节点分布,支持企业级高可用性。 - **优势**: - 高可扩展性,适合超融合基础架构。 - 与 VMware 环境深度集成。 - **适用场景**:虚拟化和云计算环境。 7. Dell EMC VxRail - **厂商/开发者**:Dell EMC - **特点**: - VxRail 是 Dell EMC 提供的超融合基础架构,整合计算、存储和网络。 - 存储采用分布式冗余技术,不直接使用传统 RAID,而是基于对象的存储分布。 - **优势**: - 高扩展性:节点扩展灵活,适合云和企业级存储。 - 提供企业级容错和冗余。 - **适用场景**:云计算、超融合数据中心。 8. Windows Storage Spaces - **厂商/开发者**:Microsoft - **特点**: - 提供类似于 RAID 的功能,如条带化(RAID 0)、镜像(RAID 1)和奇偶校验(RAID 5)。 - 支持混合硬盘容量,允许动态扩展存储池。 - **优势**: - 集成在 Windows 系统中,部署成本低。 - 易于配置,适合中小型企业和个人用户。 - **适用场景**:Windows Server 或家庭 Windows 系统上的存储管理。 9. Ceph - **厂商/开发者**:社区驱动(开源) - **特点**: - 分布式存储系统,不依赖传统 RAID。 - 数据存储在多个节点上,提供高可用性和故障恢复能力。 - 基于对象存储,支持动态扩展。 - **优势**: - 高扩展性和灵活性。 - 无需专用硬件,适合大型分布式存储系统。 - **适用场景**:云存储、超大规模企业环境。 10. IBM GPFS(General Parallel File System) - **厂商/开发者**:IBM - **特点**: - 提供分布式文件系统和并行访问功能。 - 数据分布在多个磁盘或节点上,提供高可靠性和高性能。 - 支持自动故障检测和数据修复。 - **优势**: - 高性能和高可靠性。 - 适合大规模、高吞吐量的存储需求。 - **适用场景**:企业数据中心、大数据分析、高性能计算(HPC)。 **总结** 现代厂商开发的磁盘冗余技术大多是对传统 RAID 的优化,提供更强的灵活性、扩展性和容错能力。这些技术主要服务于以下需求: - **家庭级存储**:如 Synology SHR、UnRAID、ZFS; - **企业级存储**:如 NetApp RAID-DP、Dell EMC VxRail、Ceph; - **超融合与分布式存储**:如 VMware VSAN、IBM GPFS; {% endfolding %} > 因为我的 **DS218+** 只有 2 个盘位, 而 **DS923+** 也只支持 2 块 M.2 的 SSD, 所以索性将他们全部更换为 SSD 并组 RAID1, 至少能保证坏了一块 SSD 的情况下系统仍然能够运行. --- #### DSM 配置备份 Synology 提供 [DSM 的配置备份](https://kb.synology.cn/zh-cn/DSM/help/DSM/AdminCenter/system_configbackup?version=7), 可以自动备份到你的 Synology 账户中, 且可手动导出备份到本地(**目前在寻找可自动导出备份文件的方法**): ![20241229154732_zlEIvRtE.webp](https://cdn.dong4j.site/source/image/20241229154732_zlEIvRtE.webp) #### NAS 整机备份 [Hyper Backup](https://www.synology.cn/dsm/feature/hyper_backup) 和 [Active Backup for Business](https://www.synology.cn/zh-cn/dsm/feature/active_backup_business#pc) 都提供了 NAS 整机备份的功能, 它们的区别为: - 备份方式不同: - Active Backup for Business 是作为备份 **目的地** 端的套件(A 备份到 B, 在 B 端操作); - Hyper Backup 是作为 **备份源** 为角色的套件(A 备份到 B, 在 A 端操作); - 还原方式不同: - 整机还原([还原步骤](https://kb.synology.cn/zh-cn/DSM/help/HyperBackup/restore?version=7)): - 两者都可以; - 部分数据还原: - Active Backup for Business 直接在远程 NAS 上操作; - Hyper Backup 只能在本机操作; - 数据查看方式不同: - Active Backup for Business 能够通过 **Active Backup for Business Portal** 查看共享目录下的任何文件; - Hyper Backup 无法友好的查看备份的文件目录与内容; - 备份数据格式不同: - Active Backup for Business 会将 NAS 系统备份为 数据盘.img 和 系统.img 2 个镜像文件, 只能通过 **Active Backup for Business Portal** 查看内部数据; - Hyper Backup 备份的是压缩文件. > 官方推荐的整机备份方式是 **Hyper Backup**, 可以备份 NAS 上的 **共享文件夹**、**应用程序设置** 和 **系统配置**,从而确保整机数据与环境一致性, 以便在系统出现问题需要重装或彻底坏掉后能够恢复到另一台 NAS 上, 但是我会同时使用 **Active Backup for Business** 备份全部的共享数据文件, 以便在数据被误删后能够单独恢复. --- ##### Hyper Backup 在 DS218+ 上创建整机备份任务: ![20241229154732_VUIY1Q8F.webp](https://cdn.dong4j.site/source/image/20241229154732_VUIY1Q8F.webp) 任务创建成功后, 可在远端 NAS(DS923+) 的 **Hyper Backup Vault** 查看备份任务: ![20241229154732_U8AJ0dt9.webp](https://cdn.dong4j.site/source/image/20241229154732_U8AJ0dt9.webp) ##### Active Backup for Business 在 DS923+ 上创建整机备份任务(需要在 DS218+ 上安装 Agent): ![20241229154732_haQLMkXU.webp](https://cdn.dong4j.site/source/image/20241229154732_haQLMkXU.webp) 备份完成后可通过 **Active Backup for Business Portal** 查看备份的数据: ![20241229154732_Vv4y25dU.webp](https://cdn.dong4j.site/source/image/20241229154732_Vv4y25dU.webp) > ##### 还原整个系统 > > **对于已完成首次设置的 Synology NAS 设备**: > > 1. 单击 **还原**,然后选择 **整个系统**。您将被重定向到 **控制面板**。 > 2. 单击 **还原系统** 并选择存储备份数据的还原来源。 > 3. 按向导完成还原。 > > **对于尚未完成首次设置的 Synology NAS 设备**: > > 1. 在欢迎页面上单击 **还原系统**。 > 2. 选择存储备份数据的还原来源。 > 3. 按向导完成还原。 ![20241229154732_VC4tJUte.webp](https://cdn.dong4j.site/source/image/20241229154732_VC4tJUte.webp) --- #### 参考 - [備份 Synology NAS 多種解決方案比較](https://www.cjkuo.net/synology-nas-backup/) - [当你拥有了第二台 NAS,这是你需要了解的数据迁移和同步方式-少数派](https://sspai.com/post/81509) - [担心群晖硬盘损坏丢失数据?不来试试 Hyper Backup ?](https://post.smzdm.com/p/a8pe67kn/) - [群晖 NAS 云盘备份神器,使用 Cloud Sync 打通 NAS 与无缝网盘同步](https://post.smzdm.com/p/all247z8/) - [Synology-企业应用篇](https://post.smzdm.com/p/az59k63n/) - [群晖 DSM6.1 数据安全三猛将 → 同步、备份、快照,+新兵 USB Copy2.0](https://post.smzdm.com/p/545304/) - [Synology-Backup Solution Guide 2023](https://cndl.synology.cn/download/Document/Software/WhitePaper/Package/ActiveBackup/All/enu/Synology_Backup_Solution_Guide_2023_enu.pdf) - [Synology-備份解決方案概覽](https://global.download.synology.com/download/Document/Software/WhitePaper/Os/DSM/All/cht/backup_solution_guide_cht.pdf) ### 冗余备份 因为所有的备份文件都会汇总到 DS923+ 上, 所以最后一道保险就是为 DS923+ 上的文件创建冗余备份. 正好有一个 8TB 的 **LaCie d2 Professional** 闲置, 可以直接用作 DS923+ 的冗余备份. **LaCie d2 Professional** 通过 type-c 连接到了我的 Mac mini 2018, 具有 10Gb/s 的速度, 在 NAS 上使用 **Hyper Backup** 套件, 合适的远程连接有 rsync 和 WebDAV 2 种, 尝试了在 Mac mini 2018 上分别部署 Rsync Server 和 WebDAV 后, 最终选择了第一种方式. 首先需要了解一下 rsync 基本参数: ``` 一般做增量同步时,直接使用 -avz 即可。 rsync参数: -a #归档模式传输, 等于-tropgDl -v #详细模式输出, 打印速率, 文件数量等。一般默认显示 info=all1,添加 -v 后会显示 info=all2 -z #传输时进行压缩以提高效率 -r #递归传输目录及子目录,即目录下得所有目录都同样传输。 -t #保持文件时间信息 -o #保持文件属主信息 -p #保持文件权限 -g #保持文件属组信息 -l #保留软连接 -P #显示同步的过程及传输时的进度等信息 -D #保持设备文件信息 -L #保留软连接指向的目标文件 -e #使用的信道协议,指定替代rsh的shell程序 ssh --exclude=PATTERN #指定排除不需要传输的文件模式 --exclude-from=file #文件名所在的目录文件 --bwlimit=100 #限速传输 --partial #断点续传 --delete #让目标目录和源目录数据保持一致,当 src 删除某个文件时,dst 会同步删除,默认是仅传输新增的数据,不会删除远端存在但本端不存在的文件 --debug=all4 #开启最高级别的debug,all 表示显示所有信息,4 为 loglevel 的最高级别。 ``` 基础命令 ``` rsync -avz --delete SRC DST ``` **参考资料**: - https://ss64.com/bash/rsync_options.html - [Linux 下 Rsync 和 Tar 增量备份梳理](https://www.cnblogs.com/kevingrace/p/6601088.html) - [备份数据的重要性以及 rsync 的基本使用](https://zhuanlan.zhihu.com/p/88338737) #### macOS 上搭建 Rsync Server ```bash $ tree . ├── rsync # 二进制文件 ├── rsyncd.conf.txt # rsync 的配置 ├── rsyncd.log # rsync 日志 ├── rsyncd.secrets.txt # 密钥 ├── rsyncd_nas.log # 同步日志 └── start # 启动脚本 ``` **启动脚本**: ```bash #!/bin/bash nohup /path/to/rsync -vvv --daemon --no-detach --ipv4 --config=/path/to/rsyncd.conf.txt . > /dev/null 2>&1 & ``` **配置文件(rsync.conf.txt)**: ```bash port = 8873 log file = /path/to/rsyncd.log use chroot = no [NAS] # 备份的目的地 path = /Volumes/LaCie/Backup/NAS comment = NAS log file = /path/to/rsyncd_nas.log transfer logging = true read only = false # 认证的用户 auth users = username secrets file = /path/to/rsyncd.secrets.txt ``` **认证配置(rsyncd.secrets.txt)** ```bash username:password ``` 在 DS923+ 上配置 **Hyper Backup** : ![20241229154732_pDQp416p.webp](https://cdn.dong4j.site/source/image/20241229154732_pDQp416p.webp) 上面的 Rsync Server 需要在 macOS 启动后手动执行 **start** 脚本启动, 为了减少手动操作, 我们可以通过 macOS 的 launchctl 来管理 Rsync Server 的自启动: 在 `~/Library/LaunchAgents` 下新增 `xx.xxx.rsync.plist` 文件: ```xml Label xx.xxx.rsync.plist UserName xxx KeepAlive ProgramArguments ./rsync -vvv --daemon --no-detach --ipv4 --config=rsyncd.conf.txt . WorkingDirectory ~/rsync-server RunAtLoad OnDemand LaunchOnlyOnce StandardErrorPath ~/rsync-server/err.log StandardOutPath ~/rsync-server/out.log ``` 加载 plist: ``` launchctl load ~/Library/LaunchAgents/xx.xxx.rsync.plist ``` 确认服务已运行: ```bash $ launchctl list | grep xx.xxx.rsync 3130 0 xx.xxx.rsync ``` ```bash $ ps -ef | grep -v grep | grep --color=auto rsync 501 3130 1 0 10:47AM ?? 0:00.09 ./rsync -vvv --daemon --no-detach --ipv4 --config=rsyncd.conf.txt . ``` ![20241229154732_GRk3xPdz.webp](https://cdn.dong4j.site/source/image/20241229154732_GRk3xPdz.webp) **同步日志**: ![20241229154732_7zbndo4b.webp](https://cdn.dong4j.site/source/image/20241229154732_7zbndo4b.webp) 如果需要卸载服务(临时停止并从启动项中移除): ```bash launchctl unload ~/Library/LaunchAgents/xx.xxx.rsync.plist ``` **参考**: - [macOS 设置开机启动任务](https://0clickjacking0.github.io/2020/05/20/macos%E8%AE%BE%E7%BD%AE%E5%BC%80%E6%9C%BA%E5%90%AF%E5%8A%A8%E4%BB%BB%E5%8A%A1/) - [macOS 实现软件开启自启动](https://www.liangguanghui.com/macos-login-startup/) - [Creating Launch Daemons and Agents](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html#//apple_ref/doc/uid/10000172i-SW7-BCIEDDBJ) - [Daemons and Services Programming Guide](https://developer.apple.com/library/content/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/Introduction.html) - [Technical Note TN2083: Daemons and Agents](http://developer.apple.com/library/mac/technotes/tn2083/) --- ### 云端备份 最后就是异地备份了, 这里直接使用 **阿里云盘的 WebDAV 服务**. > 为什么不用 **Cloud Sync** 利用单向同步的方式备份到云端: > > 1. **Cloud Sync** 更适合同步数据, 会保留原始的文件路径; > 2. **Cloud Sync** 一个任务只能选择一个共享目录; > 3. 没有 **Hyper Backup** 的 **压缩备份**, **块级传输**, **多版本管理** 等高级功能; **备份到云端**: Hyper Backup 支持备份到云端, 这里我直接使用 **阿里云盘 WebDAV** 这个第三方套件: ![20241229154732_OjkqZdJm.webp](https://cdn.dong4j.site/source/image/20241229154732_OjkqZdJm.webp) 然后 Hyper Backup 通过 **WevDAV** 备份到阿里云盘: ![20241229154732_4DViff2E.webp](https://cdn.dong4j.site/source/image/20241229154732_4DViff2E.webp) [**阿里云盘 WebDAV 套件使用教程**](https://imnks.com/3939.html), 获取到 **refresh_token** 后就可直接使用, 还能在 File Station 中通过 **远程连接** 挂载阿里云盘到本地, 可以方便的浏览云盘内容: ![20241229154732_X52qX4Ur.webp](https://cdn.dong4j.site/source/image/20241229154732_X52qX4Ur.webp) > 我还会使用 阿里云盘 WebDAV 配合 Cloud Sync 下载云盘内容, 工作流为: > > 1. 将待下载的文件保存到阿里云盘; > 2. 然后在 DS923+ 的 Cloud Sync 连接阿里云盘的 WebDAV 服务, 设置为 **单向同步**; > 3. 云盘内容下载完成后直接在 File Station 挂载的阿里云盘 WebDAV 中删除即可. 除了第一步, 后续流程完全不需要登录到官方的阿里云盘操作; ### 备份时间整理 因为我使用 NAS 作为备份中枢, 且 2 台 NAS 之间存在关联关系, 所以我需要整理备份的时间, 以保证备份的正确性与可用性. > 还需要考虑到 2 台路由器的重启计划: > > - 小米 AX9000 周 2 4 6 的 04:30 定时重启; > - 小米 6500Pro 周 1 3 5 04:00 定时重启; #### macOS | 源文件 | 备份方式 | 目的地 | 时间 | 备份数量 | | ------------------------ | ------------------------------------------ | -------------------------------- | ------------------- | -------- | | 系统卷 [MBP] | Active Backup for Business(物理服务器备份) | DS923+:/backups/ActiveBackupData | 周 1 3 5 21:00 启动 | 3 | | 整个磁盘 [MBP] | Time Machine | DS923+:/timemachine | 周 2 4 6 22:00 启动 | 自动轮转 | | 其他重要文件 [MBP] | Synology Drive Client | DS923+:/home/Backup | 每隔 8 小时启动 | 1 | | 整个磁盘 [Mac mini M2] | Time Machine | AirPort Time Capsule 2T | 每周 | 自动轮转 | | 整个磁盘 [Mac mini 2018] | Time Machine | AirPort Time Capsule 2T | 每周 | 自动轮转 | #### M920x | 源文件 | 备份方式 | 目的地 | 时间 | 备份数量 | | ------------ | ------------------------------------------ | ------------------------------- | ------------------- | -------- | | 系统卷 | Active Backup for Business(物理服务器备份) | DS923+:/backup/ActiveBackupData | 周 1 3 5 12:00 启动 | 3 | | 3.860.ssd | Active Backup for Business(物理服务器备份) | DS923+:/backup/ActiveBackupData | 周 2 4 6 12:00 启动 | 3 | | 3.860.ssd | [自动化脚本](#M920x-%20备份) | 1.870.ssd | 每天 2 点增量备份 | 1 | | 其他重要文件 | Active Backup for Business(文件服务器备份) | DS923+:/backup/m920x | 周 1 3 5 05:20 启动 | 5 | #### Station | 源文件 | 备份方式 | 目的地 | 时间 | 备份数量 | | ------------ | ------------------------------------------ | ------------------------------- | --------------- | -------- | | 系统卷 | Active Backup for Business(物理服务器备份) | DS923+:/backup/ActiveBackupData | 假日 22:00 启动 | 3 | | 其他重要文件 | Active Backup for Business(文件服务器备份) | DS923+:/backup/station | 假日 19:00 启动 | 5 | #### H28K | 源文件 | 备份方式 | 目的地 | 时间 | 备份数量 | | ------------ | ----------------------------------------------- | -------------------- | ------------------- | -------- | | eMMC | DS923+ 任务计划: [eMMC 备份脚本](#eMMC-%20备份) | DS923+:/backups/eMMC | 周 2 08:00 启动 | 3 | | 其他重要文件 | Active Backup for Business(文件服务器备份) | DS923+:/backup/H28K | 周 2 4 6 07:00 启动 | 5 | #### HK1Box | 源文件 | 备份方式 | 目的地 | 时间 | 备份数量 | | ------------ | ----------------------------------------------- | -------------------- | ------------------- | -------- | | eMMC | DS923+ 任务计划: [eMMC 备份脚本](#eMMC-%20备份) | DS923+:/backups/eMMC | 周 3 08:00 启动 | 3 | | 其他重要文件 | Active Backup for Business(文件服务器备份) | DS923+:/backup/HA | 周 1 3 5 09:00 启动 | 5 | #### NanoPI NEO4 2 个 NEO4, 第二个往后延长 30 分钟执行. | 源文件 | 备份方式 | 目的地 | 时间 | 备份数量 | | --------------------- | ----------------------------------------------- | --------------------- | --------------- | -------- | | 系统(eMMC) [NEO4.1] | DS923+ 任务计划: [eMMC 备份脚本](#eMMC-%20备份) | DS923+:/backups/eMMC | 周 6 19:00 启动 | 3 | | 其他重要文件 [NEO4.1] | Active Backup for Business(文件服务器备份) | DS923+:/backup/NEO4.1 | 周 6 20:00 启动 | 3 | | 系统(eMMC) [NEO4.2] | DS923+ 任务计划: [eMMC 备份脚本](#eMMC-%20备份) | DS923+:/backups/eMMC | 周 6 19:30 启动 | 3 | | 其他重要文件 [NEO4.2] | Active Backup for Business(文件服务器备份) | DS923+:/backup/NEO4.2 | 周 6 20:30 启动 | 3 | #### 树莓派 4 台树莓派, 每台间隔 30 分钟执行备份任务 | 源文件 | 备份方式 | 目的地 | 时间 | 备份数量 | | --------------------- | ------------------------------------------------- | ------------------------- | --------------- | -------- | | 系统(SD 卡) [pi4] | M920x 定时任务: [通过网络复制脚本](#通过网络复制) | DS923+:/backups/SD/pi4 | 周天 10:00 启动 | 3 | | 其他重要文件 [pi4] | Active Backup for Business(文件服务器备份) | DS923+:/backup/PI4 | 周天 11:00 启动 | 5 | | 系统(SD 卡) [pi51] | M920x 定时任务: [通过网络复制脚本](#通过网络复制) | DS923+:/backups/SD/pi51 | 周天 10:30 启动 | 3 | | 其他重要文件 [pi51] | Active Backup for Business(文件服务器备份) | DS923+:/backup/PI51 | 周天 11:30 启动 | 5 | | 系统(SD 卡) [pi52] | M920x 定时任务: [通过网络复制脚本](#通过网络复制) | DS923+:/backups/SD/pi52 | 周天 11:00 启动 | 3 | | 其他重要文件 [pi52] | Active Backup for Business(文件服务器备份) | DS923+:/backup/PI52 | 周天 12:00 启动 | 5 | | 系统(SD 卡) [zero2w] | M920x 定时任务: [通过网络复制脚本](#通过网络复制) | DS923+:/backups/SD/zero2w | 周天 11:30 启动 | 3 | | 其他重要文件 [zero2w] | Active Backup for Business(文件服务器备份) | DS923+:/backup/Zero | 周天 12:30 启动 | 5 | #### R2S && R5S | 源文件 | 备份方式 | 目的地 | 时间 | 备份数量 | | -------------------- | ------------------------------------------------------------------------- | -------------------------- | ------------------- | -------- | | 系统(SD 卡) [R2S.T] | DS923+ 任务计划: [eMMC 备份脚本](#eMMC-%20备份) | DS923+:/backups/eMMC | 周 1 10:00 启动 | 3 | | OpenWrt 配置 [R2S.T] | OpenWrt 任务计划: [OpenWrt 自动备份脚本](#OpenWrt-%20备份) + 手动临时备份 | 本地 U 盘 + DS218+:/driver | 周 1 06:00 启动 | 5 | | 其他重要文件 [R2S.T] | Active Backup for Business(文件服务器备份) | DS923+:/backup/R2ST | 周 1 3 5 14:00 启动 | 5 | | 系统(SD 卡) [R2S.U] | DS923+ 任务计划: [eMMC 备份脚本](#eMMC-%20备份) | DS923+:/backups/eMMC | 周 1 11:30 启动 | 3 | | OpenWrt 配置 [R2S.U] | OpenWrt 任务计划: [OpenWrt 自动备份脚本](#OpenWrt-%20备份) + 手动临时备份 | 本地 U 盘 + DS218+:/driver | 周 1 07:00 启动 | 5 | | 其他重要文件 [R2S.U] | Active Backup for Business(文件服务器备份) | DS923+:/backup/R2SU | 周 1 3 5 15:00 启动 | 5 | | 系统(SD 卡) [R2S.C] | DS923+ 任务计划: [eMMC 备份脚本](#eMMC-%20备份) | DS923+:/backups/eMMC | 周 1 12:30 启动 | 3 | | OpenWrt 配置 [R2S.C] | OpenWrt 任务计划: [OpenWrt 自动备份脚本](#OpenWrt-%20备份) + 手动临时备份 | 本地 U 盘 + DS218+:/driver | 周 1 08:00 启动 | 5 | | 其他重要文件 [R2S.C] | Active Backup for Business(文件服务器备份) | DS923+:/backup/R2SC | 周 1 3 5 16:00 启动 | 5 | | 系统(SD 卡) [R5S] | DS923+ 任务计划: [eMMC 备份脚本](#eMMC-%20备份) | DS923+:/backups/eMMC | 周 1 13:30 启动 | 3 | | OpenWrt 配置 [R5S] | OpenWrt 任务计划: [OpenWrt 自动备份脚本](#OpenWrt-%20备份) + 手动临时备份 | 本地 U 盘 + DS218+:/driver | 周 1 09:00 启动 | 5 | | 其他重要文件 [R5S] | Active Backup for Business(文件服务器备份) | DS923+:/backup/R5S | 周 1 3 5 17:00 启动 | 5 | #### NAS 快照 | 任务名 | 备份时间 | 源地址 | 目的地 | 备份数量 | | -------------------- | -------------- | ---------------- | -------------------------- | -------- | | DS218-photo-Snap | 每天 00:00 | DS218:/photo | DS218:/photo/#snapshot | 5 | | DS218-driver-Snap | 每天 01:00 | DS218:/driver | DS218:/driver/#snapshot | 5 | | DS218-1Panel-Snap | 每天 20:00 | DS218:/1Panel | DS218:/1Panel/#snapshot | 5 | | DS218-docker-Snap | 每天 20:10 | DS218:/docker | DS218:/docker/#snapshot | 5 | | DS218-Memos-Snap | 每天 22:00 | DS218:/Memos | DS218:/Memos/#snapshot | 5 | | DS218-homes-Snap | 每天 23:00 | DS218:/homes | DS218:/homes/#snapshot | 5 | | DS923-backup-Snap | 周 1 3 5 00:10 | DS923:/backup | DS923:/backup/#snapshot | 3 | | DS923-backups-Snap | 周 2 4 6 00:10 | DS923:/backups | DS923:/backups/#snapshot | 3 | | DS923-Developer-Snap | 周天 01:00 | DS923:/Developer | DS923:/Developer/#snapshot | 3 | | DS923-homes-Snap | 每天 01:30 | DS923:/homes | DS923:/homes/#snapshot | 5 | | DS923-photo-Snap | 每天 02:10 | DS923:/photo | DS923:/photo/#snapshot | 5 | | DS923-driver-Snap | 每天 05:00 | DS923:/driver | DS923:/driver/#snapshot | 5 | | DS923-1Panel-Snap | 每天 12:00 | DS923:/1Panel | DS923:/1Panel/#snapshot | 5 | | DS923-Memos-Snap | 每天 13:00 | DS923:/Memos | DS923:/Memos/#snapshot | 5 | | DS923-docker-Snap | 每天 23:00 | DS923:/docker | DS923:/docker/#snapshot | 5 | #### DS218+ | 任务名 | 备份套件 | 备份时间 | 备份源 | 目的地 | 备份数量 | | --------------- | ------------ | ---------- | ------------------- | --------------------------------- | -------- | | backup.apps | Hyper Backup | 周 3 14:00 | 所有 APP | DS923:/backups/DS218.Apps.hbk | 3 | | backup.datas | Hyper Backup | 周 2 14:00 | 部分共享目录 | DS923:/backups/DS218.Datas.hbk | 3 | | backup.system | Hyper Backup | 周 4 14:00 | 整个系统 | DS923:/backups/DS218.System.hbk | 3 | | backup.to.cloud | Hyper Backup | 周 5 14:00 | 所有 APP + 重要数据 | 阿里云盘:/backups/DS218.Datas.hbk | 3 | > **Hyper Backup** 整机备份无法使用 **WebDAV**, 只能是远程 NAS 或 Synology C2 Storage, 所以只能将 APP 和重要数据备份到阿里云盘. #### DS923+ | 任务名 | 备份套件 | 备份时间 | 备份源 | 目的地 | 备份数量 | | --------------- | ------------ | ---------- | ----------------------- | --------------------------------- | -------- | | backup.to.Lacie | Hyper Backup | 周天 16:00 | 所有 APP | Lacie:/Backup/NAS/DS923 | 3 | | backup.datas | Hyper Backup | 周 3 16:00 | 部分共享目录 | 阿里云盘:/backups/DS923.Datas.hbk | 3 | | VMS | - | 手动备份 | Virtual Machine Manager | DS923:/backups/NAS.VMs | 3 | ### 数据备份总结 ![data-backup.drawio.svg](https://cdn.dong4j.site/source/image/data-backup.drawio.svg) 1. macOS 使用 Time machine 和 ABB 进行整机备份, Synology Drive Client 则用于重要数据数据冗余备份; 2. OpenWrt 使用 ABB 文件备份, 并使用脚本进行整个系统备份; 3. Linux 使用 ABB 的物理服务器和文件备份, 还需要使用脚本在特殊情况下备份; 4. 开发版使用 ABB 的文件备份, 并使用自动化脚本对整个系统备份, 包括 eMMC 和 SD 卡; 5. 2 台 NAS 分别使用快照对重要共享目录进行备份; 6. DS218+ 使用 Hyper Backup 备份数据到 DS923+, 并使用 ABB 进行整机备份; 7. 其他设备的备份文件全部传输到 DS923+; 8. DS923+ 使用 Hyper Backup 备份数据到云盘和外置磁盘; 这一次算是把所有设备的备份全部梳理了一遍, 整个备份系搭建下来, 感觉 DS923+ 承担了所有, 就是不知道它扛不扛得住, 这就要通过时间去验证这套备份体系了. ## 总结 至此, 7 篇关于 HomeLab 的文章算是全部完结了, 我以为花个一周时间应该可以写个七七八八, 但是这几篇文章加起来花了接近一个月的时间, 看来还是太高估自己的能力了, 原因应该是花了大量时间查阅文档以及进行各种测试. 如今基于目前的架构, 应该可以支撑很长一段时间, 只要我不手残或不添置新的设备, 所以 HomeLab 相关的文章应该到此结束了, 或许下一篇 HomeLab 的开始应该就是另一种架构了, 比如全套 [UniFi](https://store.ui.com/us/en) + 机架了, 慢慢折腾吧. **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## [HomeLab数据同步:构建高效的数据同步网络](https://blog.dong4j.site/posts/273b6766.md) ![/images/cover/20241229154732_NXaPIT2E.webp](https://cdn.dong4j.site/source/image/20241229154732_NXaPIT2E.webp) [封面来源: Unsplash-Tianyi Ma](https://unsplash.com/photos/macbook-pro-on-white-surface-WiONHd_zYI4) ## 数据同步 我的数据同步需求主要涉及到工作文件, 常用的配置文件以及 docker-compose 容器编排文件, 这些文件需要在多台主机上使用, 所以直接采用 **Synology Drive** 在支持 **Synology Drive Client** 的主机上同步文件, 一些无法安装 **Synology Drive Client** 的开发板, 则直接使用 **Syncthing** 同步. **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ### Synology Drive **Synology Drive Server** 由 3 个独立的套件组成: - Synology Drive 管理控制台: 是 Synology Drive 应用的配置和监控中心, 可以配置同步任务, 备份版本控制, 客户端管理等功能; - Synology Drive ShareSync: 用于在不同 Synology NAS 设备之间同步共享文件夹; - Synology Drive: 可以理解为 NAS 上的 **Synology Drive Client**, 可以直接管理共享的文件; #### Synology Drive Server ![20241229154732_hUCM2IhN.webp](https://cdn.dong4j.site/source/image/20241229154732_hUCM2IhN.webp) **Synology Drive** 被我用来同步工作文件和常用配置: ![20241229154732_hH5ILzhY.webp](https://cdn.dong4j.site/source/image/20241229154732_hH5ILzhY.webp) 为了减少对本地磁盘的空间占用, **Synology Drive Client** 提供了 **按需同步** 的功能, 文件只有在使用时才会下载到本地(你也可以自行手动下载), 其他时候都是一个文件链接. 根据自己的需求, 在 **MBP** 上 docker-compose 容器编排文件是需要实时同步的, 而上图所示中的 **driver** 中的文件则采用 **按需同步**, 为了方便统一管理 **Synology Drive** 中的文件, 我使用软链接的方式将他们保存在 `~/Synology` 目录下(如果采用 **按需同步**, 会在 `~/Library/CloudStorage/SynologyDrive-xxx` 目录下创建一个同步目录). ![20241229154732_yJlCcGKr.webp](https://cdn.dong4j.site/source/image/20241229154732_yJlCcGKr.webp) 在 **Mac mini 2018** 上则全部使用实时同步, 一方面可以当做本地备份, 另一方面会将 **Mac mini 2018** 作为 **Syncthing** 的枢纽与其他不支持 **Synology Drive Client** 的开发板进行数据同步. 不过遗憾的是在 Linux 上 **Synology Drive Client** 没有 **按需同步** 的功能, 所以我也将 M920x(Ubuntu Server 24.04) 和 Station(Ubuntu Server 22.04) 作为 **Drive** 文件备份主机: #### Synology Drive ShareSync **Synology Drive ShareSync** 用于在不同的 Synology NAS 设备之间同步共享目录(准确的说是 Drive 的团队文件夹): ![20241229154732_vmqZaw1b.webp](https://cdn.dong4j.site/source/image/20241229154732_vmqZaw1b.webp) 我的 **Drive** 中的文件入口是 **DS218+**, 所有客户端都链接着这台 NAS, 然后会通过 **Synology Drive ShareSync** 将 DS218+ 中的 **Drive** 文件全量备份到 **DS923+** 上: ![20241229154732_DvG8faKw.webp](https://cdn.dong4j.site/source/image/20241229154732_DvG8faKw.webp) **DS923+** 的 **Synology Drive ShareSync** 上额外增加了 **photo** 共享目录的同步, 目的是确保在 **DS218+** 挂了之后, 我的家庭照片仍然可以通过 **DS923+** 快速恢复. --- ### Cloud Sync [Cloud Sync](https://www.synology.cn/zh-cn/dsm/feature/cloud_sync) 可以将 Synology NAS 中的任意文件同步至云端,支持多种云平台,并提供单向或双向同步选项, 单向可以用作下载使用, 双向用作同步. 因为 **Synology Drive** 只能同步共享目录下的文件而无法同步 **homes** 下的文件, 但是 Synology 的 **Photos Mobile** 会将个人照片同步到 **homes** 下的个人 home 目录下, 所以为了同步与备份全家手机上的照片, 我使用 **WebDAV 服务** 将 DS218+ 上的多个个人 home 目录暴露出来, 然后在 DS923+ 上使用 **Cloud Sync** 同步个人 home 目录下的 **Photos** 子目录和主要目录(非 Synology Drive 团队文件夹): ![20241229154732_VC7FApAg.webp](https://cdn.dong4j.site/source/image/20241229154732_VC7FApAg.webp) 这种方式的好处是当 Client 从 DS218+ 切换到 DS923+ 上后, 仍然可以通过 **Photos Mobile** 查看手机上保存的个人照片. > **Synology** 的文件同步方式非常多, 比如 **/Memos** 目录你也可以设置为 Synology Drive 团队文件夹, 然后使用 **Synology Drive ShareSync** 来同步: > > ![20241229154732_rbcrPTO2.webp](https://cdn.dong4j.site/source/image/20241229154732_rbcrPTO2.webp) #### 为什么不选择 Hyper Backup **Hyper Backup** 也能将数据同步/备份到远程 NAS 上, 且提供了 3 种方式: - 自带的 **备份到远程 NAS 设备**; - 使用 rsync; - 使用 WebDAV; 后面两种我们可以在 NAS 上部署相应的服务来完成上述需求(目前还没有尝试过), 问题是备份的文件是 **Hyper Backup** 特有的压缩文件, 无法浏览原始文件, 所以并不能满足照片的同步需求, 且实时性没有 **Cloud Sync** 高(**Hyper Backup** 以小时为单位, **Cloud Sync** 以秒为单位). ### Docker 容器文件同步 两台 NAS 上的 docker 容器文件都存储在本机的 `/docker/0.nas` 目录下, 然后使用 **Synology Drive ShareSync** 在两台 NAS 之间同步, 这样可以在某台 NAS 宕机后快速恢复服务. 而 `Vaultwarden` 这类比较重要的容器会同时在 2 台 NAS 上启动, DS218+ 作为主要服务提供者, 且通过 **Synology Drive ShareSync** 将 **Vaultwarden** 容器的数据文件实时同步到 DS923+ 上, 这样我可以随时切换 **Vaultwarden** 的服务提供者(**Vaultwarden** 的数据文件还会采用脚本定时备份到其他存储设备). docker-compose 的编排文件同样会使用 **Drive** + **Syncthing** 在全设备同步, 好处是能够在其他寄主机快速重建服务, 问题是产生的容器文件改如何维护. 比如某些配置是寄主机特定配置(服务端口不一样或网卡配置不一样等), 某些是通用配置(比如两台 NAS 的 docker 容器文件可以共用), 这就需要根据情况选择性同步容器文件, 所以我在同步的 **docker** 目录下创建了多个子目录: ```bash $ tree -d -L 3 docker ├── docker-compose │   ├── AI │   ├── apitable │   ├── dev │   ├── dockge │   │   ├── data │   │   └── stacks │   ├── ... │   └── v2ray │   └── data ├── docker-data │   ├── n8n │   ├── ... │   └── watchyourlan └── dockerfiles ├── anylink │   ├── .... └── zfile ``` 1. docker-compose 目录用于存储 docker-compose.yml 文件, 这个目录下的所有文件会全量同步, 所以会将通用的 docker 容器的挂在目录配置到 docker-compose.yml 的同级 data 目录下; 2. 其他不通用的 docker 容器文件会统一挂载到 **docker-data** 目录下, docker-data 只会在当前服务器上; 使用 **Drive** 同步 docker-compose 目录, 不同步 **docker-data** 目录, 通过这种方式既能保证 docker-compose.yml 文件在全设备同步和备份, 又能保证不通用的 docker 容器文件只保留在本地. --- ### Syncthing 上述同步 docker-compose 编排文件和 docker 容器文件的方式只适用于能够安装 **Synology Drive Client** 的主机, 而在不支持的服务器上则通过 **Syncthing** 进行文件同步. #### 自建发现和中继服务器 可以参考: [[homelab-service#Syncthing|自建 Syncthing 的发现服务器和中继服务器]] #### 全局忽略文件 Syncthing 通过 **.stignore** 文件来忽略同步的文件或目录, 但是这个文件确不能被 Syncthing 同步, 所有需要在每台安装了 **Syncthing** 的主机上去配置需要忽略的文件和目录. 这个问题有 2 种方式可以解决(可以参考: [Syncronize .stignore file](https://forum.syncthing.net/t/syncronize-stignore-file/557): - 使用软链接, 将 **.stignore** 修改为其他可以同步的文件名, 比如 **stignore**; - 使用全局忽略文件, 然后用 `#include 全局忽略文件`; 为了提高通用性, 我采用第二种方式, 具体实施方式如下: 比如现在 **Mac mini 2018** 上的 `~/Synology` 目录结构如下: ```bash $ tree -d -L 3 ~/Synology # 被 Synology Drive Client 接管 ├── docker │   ├── docker-compose # 自有 docker-compose.yml 和 docker 容器文件, 实时同步 (同样会被 Syncthing 接管) │   ├── docker-data # 忽略同步 │   └── dockerfiles # 第三方 docker-compose.yml, 实时同步 └── driver    ├── ...    ├── ...    └── Syncthing # 被 Syncthing 接管的目录, 会同步到无法安装 Synology Drive Client 的设备上       ├── NanoPI # NanoPI 设备专用       ├── PI # 树莓派专用       ├── share # 全设备共用       └── temp # 全设备共用 ``` **目录使用者为:** ![syncthing.drawio.svg](https://cdn.dong4j.site/source/image/syncthing.drawio.svg) 1. docker-compose 全设备同步, 目录下有 docker-compose.yml 和 docker 容器文件; 2. Share: 在全设备同步; 3. Temp: 临时文件, 在全设备同步; 4. NanoPI: 给 NanoPI 开发板使用的文件, 只会在 NanoPI 开发板设备之间同步; 5. PI: 只给树莓派使用的文件, 只会在树莓派之间同步; > 而前面说的 **Mac mini 2018** 作为 Syncthing 的枢纽就体现在这里: `/driver/Syncthing` 会被 **Drive** 同步到支持安装 **Synology Drive Client** 的设备上, 而 `Syncthing` 目录则用于在无法安装 **Synology Drive Client** 的设备之间同步数据, **Mac mini 2018** 会通过 **Syncthing** 服务链接其他客户端完成数据同步. --- ##### stglobalignore {% folding 🪬 stglobalignore 文件包含了通用的忽略文件配置 %} ``` $RECYCLE.BIN $WINDOWS.~BT (?d)(?i).DS_Store (?d)(?i)ehthumbs.db (?d)(?i)ehthumbs_vista.db (?d)(?i)Thumbs.db (?d)(?i)Thumbs.db:encryptable (?d)(?i)desktop.ini *.!Sync *.bak *.bts *.Cache *.crdownload *.git *.icloud *.lnk *.lock (?d)(?i)*.log nohup.out *.old *.part *.shm *.sqlite* *.stackdump *.svn *.swp *.sync *.SyncOld *.SyncPart *.SyncTemp *.tmp *.wal *CACHE* *cache* *inaccessible* *S*Conflict* *s*conflict* *Temporary* *~ .#* .*.swp .apdisk .AppleDB .AppleDesktop .AppleDouble .com.apple.timemachine.donotpresent .DocumentRevisions-V100 .escheck.tmp .fseventsd .gvfs .local/share/trash .LSOverride .Prullenbak .Shared .Spotlight-V100 .svn .sync .SyncArchive .SyncID .SyncIgnore .TemporaryItems .thumbnails .Trash* .trash* .Trashes .VolumeIcon.icns ._* .~lock. @eaDir asset-cache a_writable captcha_tmp checkouts data/searchIndex dev/ files_trashbin/ forms.json lost+found LOST.DIR Network Trash Folder nobackup pagefile.sys proc/ run/ searchIndex selinux/ staging.* sys/ System*Volume* (?d)temp templates_c thumbnails tmp/ tmp_uploads trash Trash* var/lib/lxcfs/ ~$* ``` {% endfolding %} - 以 `(?i)` 前缀的模式会启用大小写不敏感。如 `(?!)test` 匹配: - `test` - `TEST` - `sEsT` `(?!)` 可以与其它模式结合,例如:`(?!)!picture*.png` 会指定 `Picture1.PNG` 不被忽略。在 macOS 和 Windows 上,模式永远是大小写不敏感的。 - 以 `(?d)` 前缀的模式,如果这些文件在阻止目录的删除,就会删除这些文件。这个前缀应该被任何 OS 生成的能随意移除的文件使用。 ##### .stignore 在每个需要同步的目录下都需要配置一个这样的文件, 用于配置当前目录的同步规则, 首先需要在此文件中引入 **stglobalignore** 文件, 然后可以根据需求添加其他需要忽略的文件或目录. 我的目标是让 **stglobalignore** 能够通过 **Syncthing** 同步, 所以我将 **stglobalignore** 放在了 **share** 目录下, 那么根据前面的 **Mac mini 2018** 目录结构: ```bash $ tree -d -L 3 ~/Synology ├── docker │   ├── docker-compose │   ├── docker-data │ └── dockerfiles └── driver    ├── ...    ├── ...    └── Syncthing       ├── NanoPI       ├── PI       ├── share │ └── stglobalignore 文件 ├── temp       └── 后续其他可能需要添加的目录 ``` 1. NanoPI, PI, temp 目录下的 `.stignore` 配置: `#include ../share/stglobalignore`; 2. share 目录下的 `.stignore` 配置: `#include stglobalignore`; 3. docker-compose 目录下的 `.stignore` 配置: `#include ../../driver/Syncthing/share/stglobalignore` > 因为历史原因, **docker-compose** 目录并没有放到 **driver/Syncthing** 目录下. > > 但是在其他无法部署 **Synology Drive Client** 的设备上, **docker-compose** 同样是在 **Syncthing** 目录下, 所以此时 docker-compose 目录下的 `.stignore` 应该配置为: `#include ../share/stglobalignore`; 至此我们将 **stglobalignore** 通过 **Syncthing** 同步到全设备, 然后只需要在每个设备上配置一次即可全设备使用, 后面如果需要添加其他通用的忽略配置, 只需要在任意一台设备上修改 **stglobalignore** 配置即可. Mac mini 2018 负责所有目录的同步, 而其他设备只需要同步自己关心的目录即可. ![20241229154732_07FruUGY.webp](https://cdn.dong4j.site/source/image/20241229154732_07FruUGY.webp) #### 参考 - [Syncthing-Ignoring Files](https://docs.syncthing.net/users/ignoring.html) - [Syncthing 忽略模式](https://publish.obsidian.md/chinesehelp/01+2021%E6%96%B0%E6%95%99%E7%A8%8B/Syncthing%E5%BF%BD%E7%95%A5%E6%A8%A1%E5%BC%8F+by+%E6%B7%B3%E5%B8%85%E4%BA%8C%E4%BB%A3) - [使用 Syncthing 忽略模式,用白名单的方式同步指定格式文件](https://forum-zh.obsidian.md/t/topic/731) - [Syncthing global ignore file](https://gist.github.com/marksharrison/ec7646f9539a770f2e86b53c7fc7d309) - [Syncthing 由「忽略模式」导致的同步异常](https://keenwon.com/syncthing-error-caused-by-ignoring-mode/) --- ### 数据同步总结 ![data-sync.drawio.svg](https://cdn.dong4j.site/source/image/data-sync.drawio.svg) 1. 使用 **Synology Drive Client** 同步工作文件和常用配置等一些需要经常使用到的文件, 可以选择 **实时同步** 和 **按需同步**( Linux 客户端不支持 **按需同步**); 2. **DS218+** 作为所有 **Synology Drive Client** 的外部入口, 所有的 Drive 数据都先通过 DS218+ 进入 HomeLab; 3. 在 DS923+ 上使用 **Synology Drive ShareSync** 与 DS218+ 上的 **photo** , **docker** 和 **driver** 3 个共享目录同步; 4. 在 DS923+ 上使用 **Cloud Sync** 与 DS218+ 上的个人 home 目录下的 Photos 目录同步; 5. **Mac mini 2018** 作为 **HomeLab** 中重要数据同步的枢纽, 为不支持 **Synology Drive Client** 的主机提供通过 **Syncthing** 同步数据; 上述方式分别结合 **Drive** 和 **Syncthing**, 把 **Mac mini 2018** 作为同步枢纽, 将重要文件在全平台进行了同步. 后续只需要在某一台主机上操作 **~/Synology** 目录下的文件即可同步到所有设备. 我现在的操作方式为: - 感兴趣的 Docker 容器直接在 **docker-compose** 目录下创建 **docker-compose.yml** 文件, 根据需要选择是否同步到其他设备(需要同步的就挂载在 docker-compose 下, 不需要同步的就挂载 docker-data 目录下), 这样我就能在全平台任意一台主机上部署这个 Docker 服务; - 关键的工作文件直接扔到 **~/Synology/driver/工作文件** 目录下, 这样在家也能随时编辑工作文件; - 脑图, Obsidian, Drawio, Surge 配置等文件直接扔到 **~/Synology/driver/** 指定目录下, 同步的同时也能备份到其他设备上; - 需要对开发板, 树莓派等批量处理的文件, 全部扔到 **~/Synology/driver/Syncthing** 目录下, 它们会自动同步到指定的设备上, 不再需要通过 FTP 一个个拷贝; - 自定义脚本全部扔到 **~/Synology/driver/DevOps** 目录下, - 多台 macOS 共用的配置文件全部扔到 **~/Synology/driver/macOS** 目录下, 比如 **.zshrc**, **.ssh/config**, **SSH 密钥**, **Alfred 配置** 等, 此目录同时兼备文件备份的功能, 比如多平台的 **Wireguard** 配置, **Royal TSX 配置**, **SnippetsLab 文件** 等; **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## [HomeLab 存储与备份:数据堡垒-保障数据和隐私的存储解决方案](https://blog.dong4j.site/posts/b808f350.md) ![/images/cover/20241229154732_woi0CTG0.webp](https://cdn.dong4j.site/source/image/20241229154732_woi0CTG0.webp) [封面来源: Unsplash-Taylor Vick](https://unsplash.com/photos/cable-network-M5tzZtFCOfs) **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## 简介 在本篇博客中,我们将深入探讨如何在 HomeLab 中实现高效的数据存储、备份和同步方案。 随着个人数据的不断增长,数据的安全性和可用性变得越来越重要。为此,我们可以采用一种被称为 **3-2-1 原则** 的备份策略,以确保我们的数据在发生意外时能够得到及时恢复。 **[3-2-1 备份原则](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 的核心要点如下:** (另外一些细节分析可以看韦易笑在知乎的回答:[《如何长时间保存重要数据?》](https://www.zhihu.com/question/313837243/answer/660457814?edition=yidianzixun&utm_source=yidianzixun)) - **3 份备份数据**:我们不应依赖单一的数据备份,而应该拥有至少两份数据副本作为额外的安全保障。这样可以大大降低因单一备份故障导致数据丢失的风险。 - **2 种不同的存储形式**:为了进一步确保数据的冗余和安全,我们应该使用两种不同类型的媒介来存储数据。例如,除了传统的本地硬盘外,我们还可以考虑将数据上传到云服务提供商,或者制作物理媒体如 DVD 作为备份数据的存储方式。 - **1 份异地备份**:考虑到自然灾害、火灾等不可抗力因素可能导致本地备份失效的情况,我们应该至少有一份数据备份在离本地较远的地方。这样即使发生了极端情况,我们也能从异地备份中恢复数据。 在执行 321 原则之前,我们还需要进行一步重要的工作——对数据进行分类。不是所有的数据都需要进行严格的备份保护。例如,一些可以通过互联网轻松获取的电影和游戏文件,可能就不需要作为重点备份对象。相反,我们应该集中精力对那些具有高价值、难以恢复或不可替代的数据进行全面的备份保护。 通过遵循 321 原则并合理分类数据,我们可以在 HomeLab 中建立一个强大的数据堡垒,确保我们的数据和隐私得到有效保障。接下来,我们将详细介绍如何在 NAS 系统中实现这些备份和同步策略,并提供一些实用的技巧和建议。让我们一起探索如何打造一个既安全又高效的数据存储解决方案吧! --- ## 备份策略 以下是为 HomeLab 实施有效备份策略的关键步骤: 1. **确定备份内容**:确定需要备份的数据,包括系统文件、应用程序配置、个人文件等。 2. **选择备份工具**:根据需求和设备选择合适的备份工具,如 Rsync、Duplicati 或其他商业备份解决方案。 3. **制定备份计划**:根据数据的重要性和变动频率制定备份计划,如每天、每周或每月进行备份。 4. **配置备份目标**:为备份数据选择合适的存储介质和位置,确保遵循 3-2-1 备份原则。 5. **监控和验证备份**:定期检查备份是否成功完成,并验证备份数据的完整性。 6. **测试数据恢复**:定期进行数据恢复测试,确保在实际需要时能够快速恢复数据。 ## 确定备份内容 ### 数据分类 在 **HomeLab** 环境中, 数据文件通常可以分为已下几类: #### 系统与配置 这些文件主要用于系统恢复和环境重建,确保 HomeLab 稳定运行,避免因系统故障导致数据丢失或服务不可用。 1. **系统镜像**: - 树莓派、开发板、NAS(如 DSM)、虚拟机镜像(KVM)等; - 操作系统完整备份(如 OpenWrt 路由系统、Ubuntu Server); 2. **配置文件**: - **服务配置**:例如 docker-compose.yml 文件、Nginx Proxy Manager 配置等; - **网络配置**:WireGuard 配置、Surge 配置、DNS 记录等; - **开发相关的配置**: IntelliJ IDEA 配置和插件、Maven 配置、Obsidian 配置和插件等; - **系统个性化设置**:macOS/Linux 上的 dotfiles(zsh、vim、tmux、ssh 配置); #### 数据类 数据类文件包括所有由个人用户生成或存储的数据,这些文件通常是不可替代的,必须重点保护。 1. **个人与家庭数据**: - 家庭照片、视频、个人记录(笔记、电子书库); - 重要文档:工作文件、简历、报表、手稿等。 2. **工作与代码存储**: - 源代码、项目文档、开发环境数据。 - Git 仓库备份(可在本地或远程镜像保存)。 3. **数据库与应用数据**: - 自托管服务的数据备份,如 Bitwarden 数据库、Home Assistant 配置。 - MySQL、PostgreSQL、MongoDB 等数据库定期导出和备份; #### 媒体与资源 主要包括视频、音乐、电子书等非独一无二的资源,备份时可降低优先级,主要确保可访问性。 1. **影视资源**:电影、电视剧、综艺节目等; 2. **音乐资源**:NAS 中的无损音乐; 3. **电子书与学习资料**:课程资源、PDF 文件、技术书籍等; #### 虚拟机与容器备份 对于运行在 HomeLab 中的虚拟机(VM)和容器化应用,备份镜像和数据是重建服务的关键。 1. **虚拟机备份**:KVM/QEMU、VMware ESXi、Proxmox VE 虚拟机镜像; 2. **容器备份**:Docker 镜像与容器卷(Volume)数据; 参考: - [4 Easy Ways to Backup Docker Volumes](https://dev.to/code42cate/4-easy-ways-to-backup-docker-volumes-cjg) - [docker-volume-backup](https://github.com/offen/docker-volume-backup) - [Backup and Restore of Docker Volumes: A Step-by-Step Guide](https://osmosys.co/blog/backup-and-restore-of-docker-volumes-a-step-by-step-guide/) - [Back Up and Share Docker Volumes with This Extension](https://www.docker.com/blog/back-up-and-share-docker-volumes-with-this-extension/) #### 自动化脚本与工具 在 HomeLab 环境中,很多任务依赖自动化脚本和工具,备份这些内容可以节省重建时间. 1. **自动化脚本**:Shell 脚本、Ansible Playbook、Python 脚本等自动化任务; 2. **工具配置**:如 CI/CD 流水线配置(GitLab Runner、Jenkins), 自动备份任务(Cron Job、systemd 定时任务); --- ### 备份原则 #### 自产内容备份 自产内容是个人资产的精华,它们不仅包含了宝贵的数据,还体现了个人的成长经历。为了确保这些内容的安全,我们需要对其进行细致的分类和备份。 ##### 内容类 内容类数据是个人生活中不可或缺的部分,主要包括以下几类: - **家庭照片**:记录了家庭生活的点点滴滴,是珍贵的回忆。备份时需确保照片的原始质量和分辨率不受损失。 - **个人笔记**:包括学习笔记、日记、灵感记录等,是个人成长和思考的见证。需备份为可检索的格式,以便日后查阅。 - **工作文件与源代码**:工作中产生的文档、报告、项目源代码等,关系到职业发展和工作成果的保存。应采用版本控制工具进行备份,以便追溯历史版本。 ##### 配置类 配置类数据虽然不直接产生内容,但它们是保证 HomeLab 硬件和软件正常运行的关键。主要包括: - **OpenWrt 系统配置**:网络设备的操作系统,备份后可快速恢复网络环境。 - **DSM 配置**:群晖 NAS 的配置文件,包含存储池、共享文件夹、用户权限等重要信息。 - **树莓派系统镜像**:小型服务器或实验设备的操作系统,备份后可快速恢复系统状态。 - **docker-compose.yml**:容器编排文件,确保服务的一致性和可迁移性。 - **macOS 配置文件**:包括系统设置、应用配置等,便于在新设备上快速恢复工作环境。 - **自动化脚本**: 一些自用的小脚本, 包括自启动, 定时任务等启动方式的配置; - 关键自托管服务的数据, 比如 **Bitwarden 数据**; 以上两类数据均需遵循 **3-2-1 备份原则**,即至少保留三份备份,其中两份存储在不同介质上,一份存放在异地。 #### 珍贵资源 珍贵资源主要是来自互联网或其他渠道的共享资源,比如学习资料、电子书、工具软件、技术文档等。这类资源虽然不具备唯一性,但整理与归档仍然能提升存储效率与可用性。 对于这类数据,可以采取以下备份策略: - **本地双备份**:在同一存储设备上保存两份副本,以防数据丢失。 - **定期检查**:定期检查备份文件的完整性和可访问性,确保数据安全。 #### 影视文件 影视文件包括电影、电视剧、综艺节目等。这类文件通常体积较大,但并非稀缺资源,重新获取的成本较低。因此,存储时应注重高效率与便利性。 影视文件通常体积较大,备份时需考虑存储空间和访问便捷性。 **存储建议** 1. **网络云盘**: - 选择存储空间大、支持在线播放的云盘,如 **115 网盘**、**阿里云盘** 和 **百度网盘**。 - 使用 **Alist** 等工具,直接挂载云盘资源,提供流媒体播放体验。 2. **本地存储(可选)**: - 对于非常喜欢的影视作品,可以使用 NAS 或外接硬盘进行存储,并配合 **Plex**、**Emby** 等媒体服务器管理与播放。 - 可以设置自动化下载工具(如 **qBittorrent**)定期整理和下载新资源。 --- ## 工具选择 在选择备份工具时,主要考虑以下几个标准: - **支持本地和云备份**:这样可以确保数据在本地和远程都有备份。 - **支持增量备份**:只备份自上次备份以来发生变化的数据,节省时间和存储空间。 - **支持压缩和加密**:保护数据安全的同时,减少存储需求。 - **支持将备份数据分割成一定大小**:这对于远程备份尤其有用,可以提高小文件的上传速度,降低大文件上传失败的风险。 - **支持定时备份**:自动化的定时备份可以确保数据始终是最新的。 - **稳定性和易用性**:工具需要稳定运行,并且用户界面友好,易于操作。 以下是一些常用的备份工具. ### Duplicati [Duplicati](https://github.com/duplicati/duplicati) 拥有一系列强大的功能,使其成为自助式云备份的首选工具: ![20241229154732_itkz82Ci.webp](https://cdn.dong4j.site/source/image/20241229154732_itkz82Ci.webp) - **强大的加密**:Duplicati 使用 AES-256 加密(或 GNU 隐私卫士)来在上传之前保护所有数据的安全性。 - **增量备份**:Duplicati 首次上传完整备份,然后存储较小的增量更新,以节省带宽和存储空间。 - **定时备份**:内置的计划程序可以自动保持备份最新。 - **集成应用提醒**:Duplicati 会在备份完成,失败后及时通知你,让你睡个好觉 - **支持多种目标**:加密的备份文件可以传输到 FTP、Cloudfiles、WebDAV、SSH(SFTP)、Amazon S3 等目标。 在备份过程中把小文件打包成块, 块大小可以调到很大, 可以更好地支持小文件不友好的后端, 上传速度更高, 但依赖本地数据库且 CPU 占用高. 恢复文件慢. --- ### Duplicacy [Duplicacy](https://github.com/gilbertchen/duplicacy): 不依赖本地数据库, 但在服务器上会产生大量小文件, 备份失败率相对更高一些, 速度比较慢. 恢复文件相对较快. **CLI** 开源且对个人免费, GUI 版本收费. Duplicacy 目前提供以下存储后端: - Local disk - SFTP - Dropbox - Amazon S3 - Wasabi - DigitalOcean Spaces - Google Cloud Storage - Microsoft Azure - Backblaze B2 - Google Drive - Microsoft OneDrive - Hubic - OpenStack Swift - **WebDAV (under beta testing)** - pcloud (via WebDAV) - Box.com (via WebDAV) - File Fabric by Storage Made Easy **参考**: - [Duplicati 助你守护家庭数据](https://nasdaddy.com/how-to-install-duplicati-on-your-nas/) - [Big Comparison - Borg vs Restic vs Arq 5 vs Duplicacy vs Duplicati](https://forum.duplicati.com/t/big-comparison-borg-vs-restic-vs-arq-5-vs-duplicacy-vs-duplicati/9952) --- ### Kopia [Kopia](https://github.com/kopia/kopia/) 支持将 [加密](https://kopia.io/docs/features/#end-to-end-zero-knowledge-encryption) 和 [压缩的](https://kopia.io/docs/features/#compression) 快照保存到以下所有 [存储位置](https://kopia.io/docs/features/#save-snapshots-to-cloud-network-or-local-storage): - **Amazon S3**以及任何**与 S3 兼容的云存储** - **Azure Blob 存储** - **Backblaze B2** - **Google 云端存储** - 任何支持**WebDAV 的远程服务器或云存储** - 任何支持**SFTP 的远程服务器或云存储** - **Rclone**支持的一些云存储选项 - 除了 Kopia 之外,还需要下载并设置 Rclone,但之后 Kopia 会管理/运行 Rclone - Rclone 支持是实验性的:并非所有 Rclone 支持的云存储产品都经过测试可以与 Kopia 配合使用,有些可能无法与 Kopia 配合使用;Kopia 已经通过 Rclone 测试可以与**Dropbox**、**OneDrive**和**Google Drive 配合使用** - 本地计算机以及任何网络连接存储或服务器 - 通过设置 [Kopia 存储库服务器来拥有自己的服务器](https://kopia.io/docs/repository-server/) {% asciinema 299387 %} --- ### 常用工具 - [Nextcloud](https://nextcloud.com/): 私有云盘,完成 PC 端文件同步、版本控制,提供 web 端、移动端 app; - [Immich](https://immich.app/): 相册备份、浏览,提供 web 端、移动端 app; - [Resilio Sync](https://www.resilio.com/sync/): P2P 文件同步,全平台文件同步; - [Restic](https://github.com/restic/restic):支持 Linux、Windows、mac 平台,支持备份、恢复和 mount 三种操作,用法简单,后端支持:本地文件夹、SFTP、HTTP rest server、AWS S3、Openstack Swift、BackBlaze B2、微软 Azure Blob 存储、Google Cloud Storage - [Rsync](https://github.com/RsyncProject/rsync): 一个用的比较多的同步工具,可以简单的实现文件级别的备份。 有很多人写过相关的文档: - [基于 rsync 的数据异地备份方案](http://wiki.lostsummer.love/Docker/基于rsync的数据异地备份方案.html) - https://www.cnblogs.com/kevingrace/p/6601088.html - https://www.jianshu.com/p/db46c42bf51e - https://blog.csdn.net/weixin_41843699/article/details/90246940 - https://averagelinuxuser.com/backup-and-restore-your-linux-system-with-rsync/ - [Rclone](https://github.com/rclone/rclone): 用于在云存储之间同步文件和目录。该程序支持多种云存储提供商,包括 Google Drive、S3、Dropbox 等,并提供多种功能,如文件同步、加密、压缩等. - [利用 Rclone 将 PVE 备份同步到 OneDrive](https://www.dolingou.com/article/rclone-pve-backup-onedrive) - [rclone 食用手册,快速上手自动备份,实现 Google Drive 同步网站的备份目录](https://vilark.com/167.html) - [Mackup](https://github.com/lra/mackup?tab=readme-ov-file): 它可以帮助用户在 macOS 和 Linux 系统中同步应用程序配置文件。Mackup 支持多种存储方案,如 Dropbox、Google Drive 和 iCloud,并支持多种应用程序,如 Git、1Password、Visual Studio Code 等。 ⚠️Mackup 在 Macos Sonoma 中无法正常工作,因为它不支持首选项的符号链接文件。运行此代码将破坏所有用户首选项,并且无法恢复。有关更多信息,请参阅问题 [#1924](https://github.com/lra/mackup/issues/1924)和 [2035](https://github.com/lra/mackup/issues/2035)。 ### 其他工具 - [awesome-sysadmin-Backups](https://github.com/awesome-foss/awesome-sysadmin?tab=readme-ov-file#backups) - [List of Backup Software](https://github.com/restic/others) ### 参考 - [Synchronization and backup programs - ArchWiki (archlinux.org)](https://wiki.archlinux.org/title/Synchronization_and_backup_programs#Chunk-based_increments) ## 需求整理 基于 HomeLab 数据特性的四象限分类: ![20241229154732_8WFfKaV8.webp](https://cdn.dong4j.site/source/image/20241229154732_8WFfKaV8.webp) ### 重要且敏感 - **象限一:关键的个人数据** - **特点**:包含个人敏感信息,对个人生活或工作至关重要; - **例子**:家庭照片和视频、个人身份信息、密码以及其他个人隐私相关和认证授权相关的文件(比如 2FA 二维码, 恢复密钥); - **备份策略**:采用 **3-2-1 备份原则**,加密后备份到云端; ### 重要不敏感 - **象限二:技术配置与项目数据** - **特点**:对 HomeLab 的运行至关重要,但不含敏感个人信息; - **例子**:系统配置文件、源代码、自动化脚本、虚拟机镜像、docker-compose.yml 等; - **备份策略**: 采用 **3-2-1 备份原则**,直接备份到云端, 方便直接查看文件内容; ### 不重要但敏感 - **象限三:敏感但非关键数据** - **特点**:可能包含敏感信息,但对 HomeLab 的日常运行影响不大; - **例子**:旧邮件、非活跃账户信息、不再使用但包含敏感数据的文档; - **备份策略**:本地冗余备份, 不存储到云端; ### 不重要不敏感 - **象限四:非关键公共数据** - **特点**:对 HomeLab 的运行影响较小,且不包含敏感信息; - **例子**:下载的影视文件、软件安装包、公共文档、非敏感的网络日志等; - **备份策略**:本地备份, 备份频率可以较低,仅在需要时备份。 ## 逻辑图 Synology 提供了非常多的文件同步与备份方案, 比如: - [备份解决方案概览](https://global.download.synology.com/download/Document/Software/WhitePaper/Os/DSM/All/cht/backup_solution_guide_cht.pdf) - [文件同步与共享方案](https://www.synology.cn/zh-cn/dsm/solution/cross-office_file_sharing) - [多平台文件备份方案](https://www.synology.cn/zh-cn/dsm/solution/personal_backup) - [系统, 服务服务器, 文件服务器与虚拟机备份方案](https://www.synology.cn/zh-cn/dsm/feature/active-backup-business/overview) 而我恰巧有 2 台 Synology NAS, 且 Synology 提供的方案完全能够满足我的需求, 所以我的整个数据同步与备份方案全部围绕 Synology NAS 展开: ![Synchronization-and-Backup.drawio.svg](https://cdn.dong4j.site/source/image/Synchronization-and-Backup.drawio.svg) ### 数据同步线 整体使用 **Synology Drive** 套件作为同步工具, Client 与 NAS 之间同步 **Drive** 数据, 2 台 NAS 之间使用 **Synology Drive ShareSync** 和 **Cloud Sync** 同步数据. ### 数据备份线 DS923+ 作为最终备份文件的存储地, 其他设备通过脚本或 Synology 备份套件将数据备份到 DS923+, 然后统一使用 **Hyper Backup** 备份到外置硬盘以及云端. **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## [HomeLab 服务篇:自托管的乐趣-探索和创造个人云端世界的旅程](https://blog.dong4j.site/posts/b2341239.md) ![/images/cover/20241229154732_hoB1PW2R.webp](https://cdn.dong4j.site/source/image/20241229154732_hoB1PW2R.webp) 在这个充满无限可能的数字时代, 我们有机会通过自托管的云服务平台来构建一个属于个人的云端世界. 这不是简单地访问远程服务器, 而是亲手打造、定制并管理自己的虚拟实验室, 进行各种新奇有趣的服务部署. 这种“折腾”不仅仅是一种乐趣, 更是一种自我挑战和创造力的释放. 从这篇博客开始, 我会不时地介绍一些我在自托管环境中安装和配置的新奇有趣服务. 这些服务或许并非我当下所需, 但正是这种创造需求的精神驱动着我不断地探索新领域、学习新技术. 在这个过程中, 我也希望能够与大家分享我的经验和见解, 一起探讨如何在个人云端世界中找到乐趣和价值. 在这里, 你将会看到我如何一步步搭建属于自己的实验环境, 从选择合适的硬件到配置各种软件服务. 无论是对技术充满热情的爱好者, 还是希望拓展技能的初学者, 这里都会有一些实用的技巧和建议. 让我们一起踏上这段旅程吧!让我们在自托管的乐趣中不断学习、创造和成长. 敬请关注接下来的博客文章, 期待与你的共同探讨和分享. **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## HomeLab 的核心: Docker 在个人云端实验室(HomeLab)中, Docker 赋予了我们强大的功能和服务部署能力. 它成为了 HomeLab 架构中不可或缺的一部分, 提供了诸多便利: 1. **容器化部署**:Docker 允许我们将应用程序及其依赖项打包在一个称为容器的轻量级执行环境中. 这意味着无论在哪个硬件和操作系统上, 只要安装了 Docker 引擎, 我们就可以轻松地部署和管理这些容器. 2. **可移植性与一致性**:通过使用 Docker, 我们可以保证在不同的开发和生产环境之间的一致性, 因为应用程序运行在相同的隔离环境下. 这大大简化了应用的迁移和部署过程. 3. **资源利用率**:Docker 容器与传统的虚拟机相比, 具有更低的系统开销, 因为它不需要额外的操作系统来支持. 这使得 Docker 能够更高效地利用主机资源. 因此, 是否能够支持 Docker 运行也成为了我在选择硬件时的重要考量因素之一, 至于为什么没有上 K8S, 还是因为对于目前的情况直接使用 Docker 完全能胜任, 暂时还不想因为引入 K8S 增加复杂性. 接下来我将分享在使用 Docker 过程中的实战经验和所遇到的问题, 并在这些底层基础服务稳定运行之后, 继续介绍我的其他服务和应用搭建过程. ### 远程管理 我拥有一个由多个 Docker 主机组成的网络. 为了更有效地管理和监控这些主机及它们上的容器, 我需要一个集中的管理解决方案. 这意味着我需要一种方法来从单一界面访问和控制所有 Docker 实例. 目前正在使用 **[Portainer](https://www.portainer.io/):** ![20241229154732_GOnzu0wY.webp](https://cdn.dong4j.site/source/image/20241229154732_GOnzu0wY.webp) Portainer Community Edition 是一个轻量级平台, 用于跨 Docker、Swarm、Kubernetes 和 ACI 环境管理容器化应用程序. 它提供用于管理资源的 GUI 和 API, 并且可以部署为 Linux 或 Windows 本机容器. Portainer 商业版建立在开源基础之上, 包含适合商业用户的高级功能. 社区版定期更新, 大约每几个月更新一次. 好用且免费, 是目前主要的管理工具. --- 另一个是刚部署不久的 **[DPanel](https://github.com/donknap/dpanel)**, 同样支持多 Docker 主机管理, 亮点是支持 [第三方应用商店](https://dpanel.cc/#/zh-cn/manual/setting/store), 不过用起来不是特别流畅, 目前算作备用选择: ![20241229154732_l4aJ1BNh.webp](https://cdn.dong4j.site/source/image/20241229154732_l4aJ1BNh.webp) **特性**: - 全中文的界面, 更适合中文环境使用. - 安装简单, 占用资源极少, 适合各种 Nas 设备、盒子以及小型服务器. - 以容器的方式运行, 不需要特权模式, 对宿主机没有依赖及侵入, 安全且可靠. - 提供完善的容器创建及管理功能, 并提供容器域名绑定功能适配简单使用场景. - 提供的文件管理功能, 可以方便、快速的查看及调试容器内的各种文件. - 提供完善的网络管理功能, 便于容器之间的互联、互通, 以及各种网络配置需求. - 支持文本、远程地址、挂载目录等多种 compose.yml 添加方式, 快速部署和管理 Compose 任务. - 提供多种语言的基础镜像和模板, 可以快速构建属于自己的镜像, 并可以通过 Zip 或是 Git 等方式, 快速实现可持续化构建. --- **Dockge** Uptime Kuma 作者的另一个开源项目-[Dockge](https://github.com/louislam/dockge) 是一个用于管理 Docker Compose YAML 文件的堆栈式管理工具. Dockge 提供了创建、编辑、启动、停止、重启和删除堆栈等功能, 并具有交互式编辑器和 Web 终端等特性. ![20241229154732_QEIZq3Kn.webp](https://cdn.dong4j.site/source/image/20241229154732_QEIZq3Kn.webp) **特性**: - **功能全面**:支持创建、编辑、启动、停止、重启和删除 Docker Compose 堆栈, 以及更新 Docker 镜像. - **式体验**:提供交互式编辑器和 Web 终端, 方便用户管理 Docker 环境. - **跨平台支持**:可在主流 Linux 发行版上运行, 包括 Ubuntu、Debian、Raspbian、CentOS、Fedora 和 ArchLinux. - **开源免费**:采用 MIT 许可协议, 免费开源. > Dockge 只能管理单台 Docker 主机, 它的重点是 dockerc-compose 的管理与维护. --- Docker 集中管控的前提是需要开启 Docker 主机的远程管理端口, 因为涉及到多种类型的系统, 我这里统一整理一下: #### Linux ```shell vim /usr/lib/systemd/system/docker.service # 追加 tcp ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock -H tcp://0.0.0.0:2375 # 重新加载 Docker 守护进程 systemctl daemon-reload # 重启 Docker 服务 systemctl restart docker ``` #### macOS 在 macOS 下无法直接修改配置文件来开启 Remote API 服务, 通过运行 `socat` 容器, 将 `unix socket` 上 Docker API 转发到 MacOS 指定的端口: ```shell docker run -d --restart=unless-stopped -v /var/run/docker.sock:/var/run/docker.sock -p 2375:2375 bobrik/socat TCP-LISTEN:2375,fork UNIX-CONNECT:/var/run/docker.sock ``` 或者使用 docker-compose: ```yaml services: docker-socat: image: bobrik/socat container_name: docker-socat restart: unless-stopped ports: - "2375:2375" volumes: - /var/run/docker.sock:/var/run/docker.sock command: "TCP-LISTEN:2375,fork UNIX-CONNECT:/var/run/docker.sock" ``` #### OpenWrt OpenWrt 直接通过 WebUI 修改: ![20241229154732_SxM9gN4W.webp](https://cdn.dong4j.site/source/image/20241229154732_SxM9gN4W.webp) 或者编辑配置文件: ```shell vim /etc/config/dockerd config globals 'globals' # 添加这一行 list hosts 'tcp://0.0.0.0:2375' ``` ``` # 重启 /etc/init.d/docker restart ``` #### NAS ```shell # DSM 7.2 之前 sudo vim /var/packages/Docker/etc/dockerd.json # DSM 7.2 sudo vim /var/packages/ContainerManager/etc/dockerd.json ``` 添加如下配置: ```json "hosts":["tcp://0.0.0.0:2375","unix:///run/docker.sock"] ``` ```shell # 重启 Docker sudo synosystemctl restart pkgctl-ContainerManager ``` #### 树莓派 ```shell vim /etc/default/docker # 末尾添加 DOCKER_OPTS="-H tcp://0.0.0.0:2375" # 重启 docker sudo systemctl restart docker ``` --- ### 一次配置, 到处运行 我的所有 Docker 容器全部使用 docker-compose 启动, 用 docker-compose.yml 文件(可以使用一些转换工具将 docker run 转换成 docker-compose.yml), 方便在不同环境中重现, 然后使用 Synology Drive + Syncthing 同步 docker-compose.yml 文件, 然后在多设备上运行: ![docker-sync.drawio.svg](https://cdn.dong4j.site/source/image/docker-sync.drawio.svg) 1. 具备安装 Synology Drive Client 的主机直接使用 Drive 同步数据; 2. 其他的则通过 Syncthing 同步, Mac mini 2018 作为 Synology Drive 和 Syncthing 的枢纽; 这样我在任意设备上新增或修改了 docker-compose.yml 都会通过到其他设备上, 具体配置方式将在下章的 [[homelab-data|数据篇]] 中详细说明. --- ### 数据目录迁移 M920x 的系统盘只有 512G, 因为 M920x 定位为 Docker 容器的主力机, 所以预计 Docker 镜像会占据大量空间, 因此考虑将 Docker 的数据目录迁移到 1T SSD 中. 迁移有两种方案: 1. 使用软链接; 2. 修改默认存储目录; #### 使用软链接 迁移之前需要停止 Docker, 但是在停止报错了: ``` Stopping 'docker.service', but its triggering units are still active: docker.socket ``` 这是因为 docker.socket 依然在运行, 它是 Docker 的监听套接字, 负责监听 Docker API 请求并触发 docker.service. 在停止 docker.service 之前, 需要先停止 docker.socket: ```shell sudo systemctl stop docker.socket sudo systemctl stop docker ``` 然后迁移数据: ```shell rsync -avzP /var/lib/docker /mnt/3.860.ssd/ ``` - **-a**: 归档模式, 表示递归传输并保持文件属性; - **-v**: 显示 rsync 过程中详细信息. 可以使用"-vvvv"获取更详细信息; - **-P**: 显示文件传输的进度信息. (实际上 `-P=--partial --progress`, 其中的 `--progress` 才是显示进度信息的); - **-z**: 传输时进行压缩提高效率; 备份数据目录: ```shell mv /var/lib/docker /var/lib/docker.bak ``` 重启 Docker ``` systemctl restart docker ``` 启动 Docker 之后, Docker 写入的路径依然是 `/var/lib/docker` , 但是因为软链接的设置, 实际已经是往新的目录写入了. 至此, 完成了 Docker 安装(存储)目录的迁移. 通过上述方法完成迁移之后, 在确认 Docker 能正常工作之后, 删除原目录备份数据: ```bash rm -rf /var/lib/docker.bak ``` #### 修改默认存储目录 迁移数据的过程都一样: ```shell rsync -avzP /var/lib/docker /mnt/3.860.ssd/ ``` 修改 `/etc/docker/daemon.json`: ```json { "data-root": "/mnt/3.860.ssd/docker", ... } ``` 使用 `data-root` 定义数据目录, 最后重启 docker, 验证目录: ```shell docker info | grep "Docker Root Dir" ``` 没问题就可以删除原目录了. ### 容器自动更新 在使用 Docker 的过程中, 虽然我们知道在容器对应的镜像后面添加 latest 标签, 然后通过手动编辑容器, 即可拉取最新镜像, 然后达成更新容器的目的. 但是当建立非常多的容器之后, 使用手动更新容器将会是一件非常繁琐的事情, 这里推荐一款非常优雅的容器更新工具-[Watchtower](https://github.com/containrrr/watchtower). Watchtower 是一个开源项目, 它可以监控你的 Docker 容器, 并在容器的基础镜像有更新时自动重启容器. 这个工具对于需要持续部署和集成的项目来说非常有用, 可以简化管理工作并确保你的应用始终运行最新的镜像. 我目前的配置如下: ```yaml services: watchtower: image: containrrr/watchtower restart: unless-stopped container_name: watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock command: --http-api-update --http-api-periodic-polls --cleanup environment: - WATCHTOWER_HTTP_API_TOKEN=xxx - WATCHTOWER_NOTIFICATIONS=email - WATCHTOWER_NOTIFICATION_EMAIL_FROM=xxx - WATCHTOWER_NOTIFICATION_EMAIL_TO=xxx - WATCHTOWER_NOTIFICATION_EMAIL_SERVER=xxx - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT=xxx - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER=xxx - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD=xxx - WATCHTOWER_NOTIFICATION_EMAIL_DELAY=2 labels: - "com.centurylinklabs.watchtower.enable=false" ports: - 8088:8080 ``` 1. 使用 `--http-api-update` 开始 API 手动更新功能; 2. 使用 `--http-api-periodic-polls` 开始自动定时更新功能; 3. 使用 `--cleanup` 删除旧的镜像; 4. 使用 `WATCHTOWER_NOTIFICATION_EMAIL` 将更新通过邮件通知, 当然还有其他通知方式; 其中 `WATCHTOWER_HTTP_API_TOKEN` 是 API 的 token, 调用 API 时需要添加到 Header 中, 并使用邮件通知更新的内容. 上述配置会监听当前主机所有的 Docker 容器, 目前适用我现在的场景, 更多的高级功能比如 更新特定容器, 忽略更新某些容器, 监控远程 Docker 容器等可查看官方文档了解. --- ### 常见操作备忘 #### 非 root 用户使用 Docker 命令 Docker 默认使用 Unix 套接字与守护进程通信, 而该套接字的权限只允许 root 用户和 docker 组用户访问, 因此需要将该用户添加到 docker 组. **群晖** ```shell sudo synogroup --add docker sudo synogroup --member docker $USER sudo chown root:docker /var/run/docker.sock ``` **Linux**: ```shell sudo groupadd docker sudo usermod -aG docker $USER # newgrp是一个Linux系统命令, 用于切换当前会话的有效组 newgrp docker ``` #### 批量删除镜像 ```shell docker rmi $(docker images -q) # 强制删除 docker rmi -f $(docker images -q) ``` #### 容器与寄主机之间拷贝文件 ```shell # 容器 --> 寄主机 docker cp <容器ID>:<容器内路径> <宿主机路径> # 寄主机 --> 容器 docker cp <宿主机路径> <容器ID>:<容器内路径> ``` #### docker run 互转 docker-compose - [composerize](https://github.com/composerize/composerize) - [decomposerize](https://github.com/composerize/decomposerize) #### docker 代理 ##### 运行时代理 1. 配置全局代理(适用于所有容器) 创建或编辑 Docker 守护进程配置文件: ```shell sudo mkdir -p /etc/systemd/system/docker.service.d sudo nano /etc/systemd/system/docker.service.d/http-proxy.conf ``` 添加以下内容: ```shell [Service] Environment="HTTP_PROXY=http://proxy.example.com:8080" Environment="HTTPS_PROXY=http://proxy.example.com:8080" Environment="NO_PROXY=localhost,127.0.0.1,.example.com" ``` 重新加载并重启 Docker 服务: ```shell sudo systemctl daemon-reload sudo systemctl restart docker ``` 2. 为特定容器配置代理 - 使用 docker run 时配置代理 ```shell docker run -e HTTP_PROXY=http://proxy.example.com:8080 \ -e HTTPS_PROXY=http://proxy.example.com:8080 \ -e NO_PROXY=localhost,127.0.0.1 \ ubuntu ``` - 在 docker-compose.yml 中配置代理 ```shell version: '3' services: web: image: nginx environment: - HTTP_PROXY=http://proxy.example.com:8080 - HTTPS_PROXY=http://proxy.example.com:8080 - NO_PROXY=localhost,127.0.0.1,.example.com ``` 3. 通过 .env 文件管理代理 ```shell # .env HTTP_PROXY=http://proxy.example.com:8080 HTTPS_PROXY=http://proxy.example.com:8080 NO_PROXY=localhost,127.0.0.1,.example.com ``` 在 docker-compose.yml 中引用环境变量: ```shell services: web: image: nginx environment: - HTTP_PROXY=${HTTP_PROXY} - HTTPS_PROXY=${HTTPS_PROXY} - NO_PROXY=${NO_PROXY} ``` 4. 在 Dockerfile 中配置代理 ```shell FROM ubuntu ARG HTTP_PROXY ARG HTTPS_PROXY ENV HTTP_PROXY=${HTTP_PROXY} ENV HTTPS_PROXY=${HTTPS_PROXY} RUN apt-get update && apt-get install -y curl ``` 构建时传递代理变量 ```shell docker build --build-arg HTTP_PROXY=http://proxy.example.com:8080 \ --build-arg HTTPS_PROXY=http://proxy.example.com:8080 \ -t my_image . ``` ##### 拉取镜像时使用代理 docker pull /push 的代理被 systemd 接管, 所以需要设置 systemd: ```bash $ sudo mkdir -p /etc/systemd/system/docker.service.d sudo vim /etc/systemd/system/docker.service.d/http-proxy.conf ``` ```bash [Service] Environment="HTTP_PROXY=http://127.0.0.1:8123" Environment="HTTPS_PROXY=https://127.0.0.1:8123" ``` ``` sudo systemctl daemon-reload && sudo systemctl restart docker ``` 检查确认环境变量已经正确配置: ```shell $ sudo systemctl show --property=Environment docker ``` 或从 `docker info` 的结果中查看配置项: ![20241229154732_roBMUc0U.webp](https://cdn.dong4j.site/source/image/20241229154732_roBMUc0U.webp) > docker 镜像由 docker daemon 管理, 所以 **不能用修改 shell 环境变量的方法使用代理服务**, 而是从 systemd 角度设置环境变量. **修改代理后出现的问题**: ``` Error response from daemon: Get "https://docker.n8n.io/v2/": proxyconnect tcp: net/http: TLS handshake timeout ``` **解决办法:** - 方式一: 将 https 代理的地址改成 http ```bash [Service] Environment="HTTP_PROXY=http://127.0.0.1:8123" Environment="HTTPS_PROXY=http://127.0.0.1:8123" ``` - 方式二: 请删除 `https://` 和 `http://` ```bash [Service] Environment="HTTP_PROXY=127.0.0.1:8123" Environment="HTTPS_PROXY=127.0.0.1:8123" ``` --- #### 容器访问主机网络 这里只是说一下 `host.docker.internal`: 比如, 在主机上运行 MySQL 服务器, Docker 容器可以通过网络访问连接到主机的 MySQL 具体名为 host.docker.internal:3306 . 当在 Windows 或 Mac 计算机上工作时, 这是最简单的技术. Linux 上的 Docker 引擎用户也可以通过 docker run 的 `--add-host` 标志启用主机的默认名称 `host.docker.internal`. **docker run**: ``` docker run -d --add-host host.docker.internal:host-gateway my-container:latest ``` **docker-compose** ```shell services: web: image: nginx ports: - "8080:80" extra_hosts: - "host.docker.internal:host-gateway" ``` extra_hosts 配置会将 host.docker.internal 映射到宿主机. `–add-host` 标志向容器的 `/etc/hosts` 文件添加一个条目. 上面显示的值将 `host.docker.internal` 映射到容器的主机网关 . > 上述方式在主机部署 LLM 模型, 而应用层使用 Docker 部署时经常使用到. ## Docker 服务 ### Docker 商店 为了简化安装部署 Docker 服务, 我目前使用了多个第三方应用商店: **[1Panel](https://1panel.cn/)** ![20241229154732_wWV312Dl.webp](https://cdn.dong4j.site/source/image/20241229154732_wWV312Dl.webp) - **开源, 现代化**:1Panel 是一款开源的 Linux 服务器运维管理面板, 提供 Web 图形界面进行高效管理. - **功能丰富**:支持主机监控、文件管理、数据库管理、容器管理等功能. - **快速建站**:深度集成 WordPress 和 Halo 建站工具, 实现一键绑定域名和配置 SSL 证书. - **安全可靠**:基于容器管理并部署应用, 提供病毒防护、防火墙和日志审计等功能. - **一键备份**:支持一键备份和恢复, 将数据备份到各类云端存储介质. **[CasaOS](https://casaos.io/)** ![20241229154732_e38oDLTM.webp](https://cdn.dong4j.site/source/image/20241229154732_e38oDLTM.webp) - 基于 Docker, 可运行在多种设备上, 包括 x86 和 Raspberry Pi. - 提供超过 20 个预安装应用和 50+ 个社区验证应用. - 支持社区贡献, 用户提交新的应用. - 用户界面简洁优雅, 易于操作. - 支持备份设置, 方便快速安装和恢复应用. [**Runtipi**](https://runtipi.io/) ![20241229154732_Z7Jb8Npn.webp](https://cdn.dong4j.site/source/image/20241229154732_Z7Jb8Npn.webp) - **免费开源**: Runtipi 是一款免费且开源的软件, 用户可以自由使用和修改. - **简化安装**: 通过一键安装, 用户可以轻松地将 200 多个流行的自托管应用程序部署到家中服务器. - **一键更新**: 用户可以轻松地更新应用程序, 确保它们始终保持最新状态. - **易于配置**: Runtipi 提供了一个简单的 Web UI, 用户可以轻松地自定义应用程序的配置. - **SSL 证书管理**: Runtipi 自动管理 SSL 证书, 确保应用程序的安全连接. - **快速部署**: 用户可以在几分钟内将应用程序从零部署到生产环境. **OpenWrt 中的第三方商店:** ![20241229154732_ctFyXsKX.webp](https://cdn.dong4j.site/source/image/20241229154732_ctFyXsKX.webp) OpenWrt 本身存在大量第三方应用, 使用 iStore 方便点. 推荐 2 个源地址: - [https://dl.photonicat.com](https://dl.photonicat.com/) - [https://dl.openwrt.ai](https://dl.openwrt.ai/) **NAS 中的第三方商店:** ![20241229154732_uwDcMglt.webp](https://cdn.dong4j.site/source/image/20241229154732_uwDcMglt.webp) 目前在用的几个第三方源: - **synocommunity**: https://packages.synocommunity.com/ - **spk7**: https://spk7.imnks.com/ - **云梦:** https://spk.520810.xyz:666 - **4saG SPK Server:** https://spk.4sag.ru/ - **Community Package Hub:** https://www.cphub.net --- > 我的绝大部分服务都已经通过 Docker 容器化技术部署在不同的设备上. 这种灵活的部署方式不仅提高了资源的利用率, 还使得服务的扩展和管理变得更加便捷. > > 选择设备时, 我主要考虑两个因素:资源和网络环境. 不同的服务器或设备可能有不同的计算能力、存储容量和网络连接速度, 因此我会根据服务的具体需求来选择最合适的硬件资源. > > 接下来, 我将按照设备的分类, 盘点一下我都安装了哪些自托管服务. ## NAS 2 台群晖 NAS 更多的是肩负着数据存储与同步的重任, 使用一主一备的方式保证重要数据的安全性. DS218+ 的任务是对外提供 Synology Drive 服务, 我的所有工作文件, 家庭照片全部存储在这上面, 而 DS923+ 则侧重于数据备份与影音文件管理, 首先会全量备份 DS218+ 的数据, 还会为其他设备提供备份服务, 其次为家人提供流媒体服务. > 这里推荐一个 NAS 相关的高质量玩机网站 - [我不是矿神](https://imnks.com/). ### DS218+ #### Vaultwarden [Vaultwarden](https://github.com/dani-garcia/vaultwarden) 是一个使用 Rust 编写的非官方 Bitwarden 服务器实现, 它与 [官方 Bitwarden 客户端](https://bitwarden.com/download/) 兼容, 非常适合不希望运行官方的占用大量资源的自托管部署, 它是理想的选择. Vaultwarden 主要面向个人、家庭和小型组织. ![20241229154732_ipFMiyMU.webp](https://cdn.dong4j.site/source/image/20241229154732_ipFMiyMU.webp) > 如果最近你的 iOS 客户端无法正常登录使用, 可以将服务端更新到最新版本, 我就是这样解决的. 考虑到安全问题, 目前正在使用 Vaultwarden 全面替代 1Password. 因为 Vaultwarden 强制要求通过 HTTPS 访问, 我目前使用 1Panel 来申请证书, 结合 WAF 使用. 如果只是在内网使用的话, 可以参考使用 [mkcert](https://github.com/FiloSottile/mkcert) 来开启 HTTPS. 为了防止数据丢失, 我做了两重保护: - 将 Vaultwarden 的数据使用 Drive 实时同步到 DS923+, 然后在 DS923+ 上面也启动一个 Vaultwarden, 避免 DS218+ 挂掉之后无法使用; - 使用脚本离线备份 Vaultwarden 数据文件: ```shell #!/bin/bash NOW=$(date +"%Y.%m.%d") # Vaultwarden 的数据目录 cd /volume1/docker/0.nas # 打包整个目录 zip -q -r vaultwarden_$NOW.zip vaultwarden/ # 移动到 Synology Drive 相关目录下, 这样就能通过到其他客户端上, 相当于多端备份 mv vaultwarden_$NOW.zip /volume1/driver/NAS/Bitwarden ``` 然后使用定时任务定期备份: ![20241229154732_e8P5IGnz.webp](https://cdn.dong4j.site/source/image/20241229154732_e8P5IGnz.webp) 相关教程: - [受够了密码数据泄漏事件?用 Bitwarden 做自己的安全负责人](https://sspai.com/post/79183) - https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS - [纯内网使用 Bitwarden](https://blog.cfandora.com/archives/449/#cl-2). --- #### MyIP [MyIP](https://github.com/jason5ng32/MyIP) 可能是最好用的 IP 工具箱. 轻松检查你的 IP, IP 地理位置, 检查 DNS 泄漏, 检查 WebRTC 连接, 速度测试, ping 测试, MTR 测试, 检查网站可用性, 查询 Whois 信息等等. 我主要拿来做一些连通性测试: ![20241229154732_WHL8FkrH.webp](https://cdn.dong4j.site/source/image/20241229154732_WHL8FkrH.webp) **特性**: - 🖥️ **看自己的 IP**:从多个 IPv4 和 IPv6 来源检测显示本机的 IP - 🕵️ **看 IP 信息**:显示所有 IP 的相关信息, 包括国家、地区、ASN、地理位置等 - 🚦 **可用性检测**:检测一些网站的可用性:Google, Github, Youtube, 网易, 百度等 - 🚥 **WebRTC 检测**:查看使用 WebRTC 连接时使用的 IP - 🛑 **DNS 泄露检测**:查看 DNS 出口信息, 以便查看在 VPN/代理的情况下, 是否存在 DNS 泄露隐私的风险 - 🚀 **网速测试**:利用边缘网络进行网速测试 - 🚏 **代理规则测试**:配合代理软件的规则设置, 测试规则设置是否正常 - ⏱️ **全球延迟测试**:从分布在全球的多个服务器进行延迟测试, 了解你与全球网络的连接速度 - 📡 **MTR 测试**:从分布在全球的多个服务器进行 MTR 测试, 了解你与全球的连接路径 - 🔦 **DNS 解析器**:从多个渠道对域名进行 DNS 解析, 获取实时的解析结果, 可用于污染判断 - 🚧 **封锁测试**:检查特定的网站在部分国家是否被封锁 - 📓 **Whois 查询**:对域名或 IP 进行 whois 信息查询 - 📀 **MAC 地址查询**:查询物理地址的归属信息 - 🌗 **暗黑模式**:根据系统设置自动切换暗黑/白天模式, 也可以手动切换 - 📱 **简约模式**:为移动版提供的专门模式, 缩短页面长度, 快速查看最重要的信息 - 🔍 **查任意 IP 信息**:可以通过小工具查询任意 IP 的信息 - 📲 **支持 PWA**:可以添加为手机应用以及电脑里的桌面应用, 方便使用 - ⌨️ **支持快捷键**:可以随时输入 `?` 查看快捷键菜单 - 🌍 根据可用性检测结果, 返回目前是否可以访问全世界网络的提示 - 🇺🇸 🇨🇳 🇫🇷 支持中文、英文、法文 --- #### DDNS-GO [DDNS-GO](https://github.com/jeessy2/ddns-go) 能够自动获得公网 IPv4 或 IPv6 地址, 并解析到对应的域名服务. ![20241229154732_KXWXUX58.webp](https://cdn.dong4j.site/source/image/20241229154732_KXWXUX58.webp) **特性**: - 支持 Mac、Windows、Linux 系统, 支持 ARM、x86 架构 - 支持的域名服务商 `阿里云` `腾讯云` `Dnspod` `Cloudflare` `华为云` `Callback` `百度云` `Porkbun` `GoDaddy` `Namecheap` `NameSilo` `Dynadot` - 支持接口/网卡/[命令](https://github.com/jeessy2/ddns-go/wiki/通过命令获取IP参考)获取 IP - 支持以服务的方式运行 - 默认间隔 5 分钟同步一次 - 支持同时配置多个 DNS 服务商 - 支持多个域名同时解析 - 支持多级域名 - 网页中配置, 简单又方便, 默认勾选`禁止从公网访问` - 网页中方便快速查看最近 50 条日志 - 支持 Webhook 通知 - 支持 TTL - 支持部分 DNS 服务商 [传递自定义参数](https://github.com/jeessy2/ddns-go/wiki/传递自定义参数), 实现地域解析/多 IP 等功能 > 在 [[homelab-network|网络篇]] 中详细的介绍. --- #### LibreSpeed [LibreSpeed](https://github.com/librespeed/speedtest) 一个基于 HTML5 的自托管网络速度测试工具. 它支持多种后端语言和数据库, 易于部署和使用, 并提供多种测试选项. ![20241229154732_eMc0tcC3.webp](https://cdn.dong4j.site/source/image/20241229154732_eMc0tcC3.webp) **特点**: - **功能丰富**:支持下载、上传、抖动测试, 并提供 IP 地址、ISP、距离等信息. - **兼容性强**:支持所有现代浏览器, 包括移动端. - **易于部署**:提供详细的指南和视频教程. - **支持多种后端语言**:包括 PHP、Node、Go、Rust 等. --- #### Speedtest Tracker [Speedtest Tracker](https://github.com/alexjustesen/speedtest-tracker) 是一个自托管应用程序, 可监控互联网连接的性能和正常运行时间. ![20241229154732_HntLQamY.webp](https://cdn.dong4j.site/source/image/20241229154732_HntLQamY.webp) 该应用使用 Ookla 的 Speedtest 服务进行网络速度测试, 并记录测试结果, 帮助我们了解自身网络性能和稳定性. **重要亮点**: - **功能**:监控网络性能和稳定性, 记录测试结果, 生成历史数据. - **部署方式**:容器化, 支持 Docker 和 Docker Compose 部署. - **数据库支持**:支持 SQLite、/MariaDB 和 PostgreSQL 数据库. - **优势**:开源免费, 可自定义配置, 适用于各种场景. --- #### Snapdrop [Snapdrop](https://github.com/SnapDrop/snapdrop) 是一个基于 WebRTC 的本地文件分享渐进式 Web 应用. 项目使用 HTML5、ES6、CSS3、WebRTC、WebSockets 和 NodeJS 等技术构建, 支持用户在浏览器中快速分享文件. ![20241229154732_5YIDLYVG.webp](https://cdn.dong4j.site/source/image/20241229154732_5YIDLYVG.webp) **重要亮点**: - **技术栈**: HTML5、ES6、CSS3、WebRTC、WebSockets、NodeJS - **功能**: 本地文件分享 **特性**: 支持渐进式 Web 应用 - **许可证**: GPL-3.0 - **开源**: 可在 GitHub 上免费下载和使用 > 方便的点是只要有浏览器即可进行文件共享, 这在与 Android 共享文件时比较方便. --- #### Memos [Memos](https://github.com/usememos/memos) 是一款轻量级、自托管的笔记工具, 支持 Markdown 语法, 并提供多种自定义选项. ![20241229154732_fxfM2D7h.webp](https://cdn.dong4j.site/source/image/20241229154732_fxfM2D7h.webp) **重要亮点**: - **隐私优先**:所有数据本地存储, 保障用户隐私. - **快速创建**:支持纯文本和 Markdown 语法, 方便格式化和分享. - **轻量级**:使用 Go 和 React 构建, 性能强大但占用资源少. - **可定制**:可自定义服务器名称、图标、样式等. - **开源免费**:代码开源, 可免费使用所有功能. macOS 上有免费的客户端-[MoeMemos](https://github.com/mudkipme/MoeMemos): ![20241229154732_fgToqa1H.webp](https://cdn.dong4j.site/source/image/20241229154732_fgToqa1H.webp) --- #### Homebox [Homebox](https://github.com/XGHeaven/homebox) 用于组建家庭局域网时, 对网络进行调试、检测、压测的工具集合. ![20241229154732_r6wS9X64.webp](https://cdn.dong4j.site/source/image/20241229154732_r6wS9X64.webp) **特性**: 1. 面向未来浏览器设计 2. 高达 10G 的浏览器速度测试 3. 自带 Ping 检测 4. 丰富的自定义测速参数 5. 服务端无需像传统文件拷贝一样需要固态的支持 6. 友好的 UI 交互 7. 针对低速网络(< 2.5G)优化测速资源占用 --- #### 1Panel [1Panel](https://github.com/1Panel-dev/1Panel) 是一款开源的 Linux 服务器运维管理工具. 提供 Web 界面, 支持主机监控、文件管理、数据库管理等功能, 并深度集成 WordPress 和 Halo 等建站软件. 因为现在 1Panel 不支持多主机管理, 所以在大多数常用服务器上都安装了 1Panel. ![20241229154732_IoLQHeCb.webp](https://cdn.dong4j.site/source/image/20241229154732_IoLQHeCb.webp) **重要亮点**: - 支持主机监控、文件管理、数据库管理等功能 深度集成 WordPress 和 Halo 等建站软件. - 提供一键安装指南和文档链接. - 存在专业版, 提供更多功能和技术支持服务 --- #### Syncthing [Syncthing](https://syncthing.net/) 是一个开源的文件同步客户端与服务器软件, 采用 Go 语言编写. 它可以在本地网络上的设备之间或通过 Internet 在远程设备之间同步文件, 使用了其独有的对等自由块交换协议. 通过发现服务器寻找节点, 如果节点不能直连的情况下, 通过中继服务器穿透内网传输数据. 用户可以自行搭建发现服务器和中继服务器, 在程序里面也可以指定使用相应的服务器. 并提供基于 Web 的控制界面, 这也便于远程服务器的使用. ![20241229154732_TRIl2gkR.webp](https://cdn.dong4j.site/source/image/20241229154732_TRIl2gkR.webp) **重要亮点**: - **私有性**: 文件仅在本地设备上存储, 无中央服务器, 保护数据安全. - **安全性**: 使用 TLS 加密, 确保通信安全, 并采用证书认证设备. - **开源**: 源代码开放, 确保透明可信任性. - **易用性**: 界面简洁, 配置简单, 易于上手. - **多平台**: 支持 macOS、Windows、Linux 等多种操作系统. > [[homelab-data|数据篇]] 再详细介绍如何结合 Synology Drive 同步文件的思路. 目前都是在局域网内共享文件, 为了方便放在公司的 R2S 也能同步文件, 需要搭建发现服务和中继服务. ##### 发现服务 Syncthing 依靠发现服务器在互联网上寻找对等点. 任何人都可以运行发现服务器并将 Syncthing 安装指向它. [discosrv](https://github.com/syncthing/discosrv) 下载并上传服务器后直接运行即可: ```shell ➜ discosrv ./stdiscosrv stdiscosrv v1.23.4 "Fermium Flea" (go1.20.2 linux-amd64) teamcity@build.syncthing.net 2023-04-05 13:25:55 UTC [purego] Server device ID is 1111111-2222222-3333333-4444444-5555555-6666666-7777777-8888888 ``` 拿到发现服务器的 ID 后, 将该 ID 填写至 **Syncthing** 客户端中, 填写位置如下: ![20241229154732_OAInVUPJ.webp](https://cdn.dong4j.site/source/image/20241229154732_OAInVUPJ.webp) 格式为: `https://服务器 IP:port/?id={发现服务器 ID}`. 记得防火墙开启 8443 端口, 发现服务器默认端口为 8443, 若想更改, 可以使用 `./stdiscosrv -listen=你的端口` 来启动. ##### 中继服务 Syncthing 依赖于社区贡献的中继服务器网络. 任何人都可以运行中继服务器, 它会自动加入中继池并可供 Syncthing 用户使用. [relaysrv](https://github.com/syncthing/relaysrv) 下载并上传服务器后直接运行即可: ```shell ➜ relaysrv ./strelaysrv -pools="" 2024/12/02 15:05:38 main.go:141: strelaysrv v1.22.1 "Fermium Flea" (go1.19.2 linux-amd64) teamcity@build.syncthing.net 2022-11-02 06:27:53 UTC 2024/12/02 15:05:38 main.go:147: Connection limit 52428 2024/12/02 15:05:38 main.go:259: URI: relay://0.0.0.0:{port}/?id=aaaaaaa-bbbbbbb-ccccccc-ddddddd-eeeeeee-fffffff-ggggggg-hhhhhhh&networkTimeout=2m0s&pingInterval=1m0s&statusAddr=%3A22070 ``` ![20241229154732_Ig5uN2Qn.webp](https://cdn.dong4j.site/source/image/20241229154732_Ig5uN2Qn.webp) 格式为:`relay://你的服务器IP:22067/?id=中继服务器ID&networkTimeout=2m0s&pingInterval=1m0s&statusAddr=%3A22070` 注意: - `-pools=""`: 意思是不加入任何中继池, 可以保持你的中继服务器为私有的; - 记得开放防火墙 22067 端口, 若想更换端口, 可以使用 `-listen=你的端口` 来更改端口; --- ### DS923+ #### Stirling-PDF [Stirling-PDF](https://www.stirlingpdf.com/) 是一款功能强大的本地托管 Web PDF 处理工具. 它支持各种 PDF 操作, 如拆分、合并、转换、旋转、压缩等, 并提供了丰富的功能和定制选项, 适用于个人和团队. ![20241229154732_wQbrqMTX.webp](https://cdn.dong4j.site/source/image/20241229154732_wQbrqMTX.webp) **重要亮点**: - **本地托管**:无需安装, 通过 Docker 运行, 方便快捷. - **功能丰富**:支持多种 PDF 操作, 如页面操作、转换操作、安全和权限、操作等. - **易于定制**:可自定义应用程序名称、标语、图标等. - **支持多种语言**:目前支持 37 种语言. - **可扩展性**:支持扩展 OCR 和压缩功能. --- #### Draw.io [Draw.io](https://github.com/jgraph) 是一款免费且开源的在线绘图工具, 现已更名为 **diagrams.net**. 它支持创建各种图表, 如流程图、网络拓扑图、UML 图、组织结构图等. Draw.io 提供直观的拖放式界面, 支持本地和云端存储(如 Google Drive、OneDrive、GitHub 等), 适合团队协作和个人使用, 广泛应用于软件开发、项目管理和文档设计等领域. ![20241229154732_TVib1acn.webp](https://cdn.dong4j.site/source/image/20241229154732_TVib1acn.webp) 做开发的朋友应该经常用到, Draw.io 在 IDEA, Visual Studio Code 等常见开发平台上都有集成, 目前使用最多的是 [桌面版](https://github.com/jgraph/drawio-desktop), 支持各大平台. **相关资源**: - [drawio-libs](https://github.com/jgraph/drawio-libs) - [other drawio-libs](https://github.com/JF-Dumont/drawio-libs) - [使用来自网络的自定义形状库](https://www.drawio.com/blog/public-custom-libraries) --- #### Excalidraw [Excalidraw](https://github.com/excalidraw/excalidraw) 是一个开源的虚拟手绘风格白板工具, 支持协作和端到端加密. 它可以帮助用户创建各种手绘风格的图表、线框图或其他图形, 适用于绘图、设计、会议记录等多种场景. ![20241229154732_WTC7w3BE.webp](https://cdn.dong4j.site/source/image/20241229154732_WTC7w3BE.webp) **主要特点**: - **无限画布**: 可以自由地拖动、缩放和移动画布, 不受限制. - **手绘风格**: 支持各种绘图工具, 包括线条、矩形、、箭头等, 并提供丰富的样式选项. - **自定义**: 可以自定义画布背景、工具栏、颜色等, 满足个性化需求. - **图片和形状库**: 支持导入图片和形状库, 方便快速创建图形. - **国际化**: 支持多种语言, 方便全球用户使用. - **导出功能**: 支持将图形导出为 PNG、SVG 和可复制粘贴的格式, 方便分享和编辑. > 目前依赖于 Draw.io 源文件可在 Markdown 查看且可直接编辑的特性, Excalidraw 还不具备, `.excalidraw` 格式文件虽然能二次编辑, 但是无法在 Markdown 中预览. 因此 Excalidraw 算备选方案偶尔使用. --- #### Portainer [Portainer](https://www.portainer.io/) 一款用于管理和部署 Docker、Swarm 和 Kubernetes 集群的容器管理软件. 它提供集中式界面, 简化容器化进程, 并支持多云和边缘环境. ![20241229154732_GOnzu0wY.webp](https://cdn.dong4j.site/source/image/20241229154732_GOnzu0wY.webp) **重要亮点**: - **跨平台管理**:支持 Docker、Swarm 和 Kubernetes, 适用于多云和边缘环境. - **用户友好的界面**:提供直观的 UI, 简化操作流程. - **安全访问**:支持集中式验证和授权. - **多云和边缘支持**:适用于不同环境, 包括数据中心、云和边缘设备. --- #### 小雅 Alist [docker-xiaoya](https://github.com/monlor/docker-xiaoya) 使用 Docker Compose 一键部署 Xiaoya 服务的全套解决方案, 支持 Alist + Emby + Jellyfin 的一键部署, 并兼容多种平台和架构. ![20241229154732_wCLUNTfT.webp](https://cdn.dong4j.site/source/image/20241229154732_wCLUNTfT.webp) **重要亮点**: - **一键部署**:简化了 Xiaoya 服务的部署流程, 无需人工干预. - **全平台支持**:兼容 Linux、Windows、Mac、Synology 等平台, 以及 X 和 Arm 架构. - **集成脚本**:所有脚本集成到 Docker 镜像, 避免污染系统环境. - **自动更新**:支持自动更新镜像、云盘数据、服务配置和媒体数据. - **多种云盘支持**:支持小雅 Alist、夸克网盘、PikPak 网盘和阿里云盘资源. --- #### RSSHub [RSSHub](https://github.com/DIYgod/RSSHub) 是一个可以将**任何**内容都可以抓取然后转换成 RSS 订阅的网站. 项目的 slogan **万物皆可 RSS**, 它不仅可以订阅各种博客、论坛、新媒体, 甚至社交媒体、推特等都不在话下, 很强, 详见[食用指南](https://docs.rsshub.app/zh/guide/). 该项目已经持续发展 6 年了, 一直在持续更新, 甚至今年[进行了一次重构](https://diygod.cc/6-year-of-rsshub), 不得不佩服大佬们的行动力. ![20241229154732_OObcuEZH.webp](https://cdn.dong4j.site/source/image/20241229154732_OObcuEZH.webp) 除此之外, 官方还提供了 [Radar 功能](https://docs.rsshub.app/zh/guide/#radar), 结合浏览器插件就可以发现你正在访问的站点 RSSHub 是否已经支持订阅了, 如果支持了可以一键转换成订阅的地址, 很方便. 不仅如此, 还支持移动端. ![20241229154732_HV9qq6Zt.webp](https://cdn.dong4j.site/source/image/20241229154732_HV9qq6Zt.webp) **重要亮点**: - **功能**: 将各种内容源转换为 RSS, 支持超过 5,000 个全球实例. - **特点**: 易于使用、功能强大、支持丰富的插件和扩展. - **相关项目**: RSSHub Radar、RSSBud、Aid 等. - **贡献者**: 超过 1,100 位贡献者. - **部署**: 支持 GitHub Pages、Vercel 等多种部署方式. --- #### Navidrome [Navidrome](https://github.com/navidrome/navidrome) 允许用户从任何浏览器或移动设备访问其音乐库, 并提供丰富的功能, 例如处理大型音乐库、支持多种音频格式、多用户支持、自动监控库等. ![20241229154732_ple0Nxse.webp](https://cdn.dong4j.site/source/image/20241229154732_ple0Nxse.webp) **重要亮点**: - Navidrome 是一个开源音乐服务器项目. - 支持从任何设备访问音乐库. - 支持多种音频格式. - 多用户支持, 每个用户拥有自己的播放等. - 自动监控音乐库变化. - 界面基于 Material UI. - 兼容 Subsonic/Madsonic/Airsonic 客户端. --- #### Music Tag Web [Music Tag Web](https://github.com/xhongc/music-tag-web) 是一款可以编辑歌曲的标题, 专辑, 艺术家, 歌词, 封面等信息的音乐标签编辑器程序, 支持 FLAC, APE, WAV, AIFF, WV, TTA, MP3, M4A, OGG, MPC, OPUS, WMA, DSF, DFF, MP4 等音频格式. 我主要使用自动刮削功能来批量修改音乐标签. 只有 V2 版本才具备高级功能, 需要付费使用. ![20241229154732_YQl8DLNI.webp](https://cdn.dong4j.site/source/image/20241229154732_YQl8DLNI.webp) **特性**: - 支持大部分音频格式元数据的查看、编辑和修改 - **支持批量自动修改(刮削)音乐标签** - 支持音乐指纹识别, 即使没有元数据也可以识别音乐 - 支持整理音乐文件, 按艺术家, 专辑分组, 或者自定义多级分组 - 支持文件排序, 按照文件名, 文件大小, 更新时间排序 - 支持批量转换音乐元数据繁体转简体, 或者简体转繁体 - 支持文件名称的拆分解包, 补充缺失元数据信息 - 支持文本替换, 批量替换音乐元数据中脏数据 - 支持音乐格式转换, 引入 ffmpeg 支持音乐格式转换 - 支持整轨音乐文件的切割 - 支持多种音乐标签来源 - 支持歌词翻译功能 - 支持显示操作记录 - 支持导出专辑封面文件, 支持自定义上传专辑封面 - 支持适配移动端 UI, 支持手机端访问 - 支持使用小爱同学播放本地音乐, 播放 NAS 本地音乐 --- #### Roon [Roon](https://roon.app/en/) 是一款专业的音乐播放和音乐库管理软件,旨在为音乐爱好者提供更丰富、更深入的音乐体验。 DS923+ 安装 Roon 的 Core Server, 将无损音乐全部导入到 Roon, 在 macOS 和 iPhone 就可以方便的听音乐了. ![20241229154732_kR2q8gAJ.webp](https://cdn.dong4j.site/source/image/20241229154732_kR2q8gAJ.webp) **主要功能**: - **音乐库管理**: 将所有音乐文件整合到一个中央库中,方便管理和浏览。 - **音乐信息丰富**: 提供艺术家、专辑、歌曲的详细信息,包括传记、评论、照片、歌词、巡演日期等。 - **无缝播放**: 支持多种音乐播放设备和平台, AirPlay、Chromecast、Roon Ready、Squeezebox、智能电视、智能音箱、USB/HDMI 播放器、移动设备等。 - **高保真音质**: 采用专业音频引擎,提供高保真音质,让音乐更真实、更生动。 - **Nucleus**: Roon 的专用服务器,提供更强大的音乐处理能力和更灵活的配置选项。 --- ## M920x M920x 是 x86 平台, 作为 Docker 容器的核心宿主机, 已部署多项服务. ### 雷池 Safeline ![20241229154732_QTQjeMVr.webp](https://cdn.dong4j.site/source/image/20241229154732_QTQjeMVr.webp) 在 [[homelab-network|网络篇]] 中有 WAF 的详细介绍以及应用场景. ### Dify [Dify](https://github.com/langgenius/dify) 是一个开源的 LLM 应用开发平台. Dify 提供直观的用户界面, 结合 AI 工作流、RAG 管道、代理能力、模型管理、可观察性功能等, 帮助用户快速从原型开发到生产部署. ![20241229154732_2pK3lVHY.webp](https://cdn.dong4j.site/source/image/20241229154732_2pK3lVHY.webp) **重要亮点**: - **功能丰富**:支持 AI 工作流、RAG 管道、代理、模型管理、可观察性等功能. - **模型支持**集成 GPT、Mistral、Llama3 等多种 LLM 模型. - **易于使用**:提供云端服务、社区版和企业版, 方便用户使用. - **开源免费**:遵循 Dify 开源许可证, 免费使用. ### Netmaker [**Netmaker**](https://github.com/gravitl/netmaker) 是一个开源的高性能、基于 **WireGuard** 的 **虚拟网络管理平台**. 它允许用户轻松构建和管理跨地域的安全虚拟网络(VPN), 适用于云环境、数据中心、物联网 (IoT) 设备等场景. Netmaker 通过自动化网络配置和管理, 大幅降低了运维成本, 并提供了与现有基础设施无缝集成的能力. ![20241229154732_iQxUFXbA.webp](https://cdn.dong4j.site/source/image/20241229154732_iQxUFXbA.webp) **主要功能** 1. **基于 WireGuard 的高性能网络**: 提供加密、高速的点对点通信, 通过 WireGuard 协议实现低延迟和高安全性. 2. **自动化网络配置**: 自动配置节点、子网和隧道, 大幅减少手动设置和管理步骤. 3. **多平台支持**: 支持 Linux、Windows、macOS, 以及容器化环境(Docker/Kubernetes). 4. **集中管理界面**: 提供 Web UI 和 API 接口, 便于集中管理网络配置、监控和运维. 5. **动态节点管理**: 支持动态 IP 分配、负载均衡, 以及节点的自动发现和连接. 6. **多云和混合环境支持**: 兼容多种云平台(如 AWS、Azure、GCP)和本地数据中心, 适用于混合云环境. 7. **高可扩展性**: 通过水平扩展支持大规模节点和复杂拓扑, 适用于企业级网络需求. 8. **ACL 和安全规则管理**: 支持访问控制列表 (ACL) 和自定义防火墙规则, 确保网络安全. ### Coolify [Coolify](https://coolify.io/) 是一款开源且可自托管的平台, 旨在为开发者提供类似 Heroku、Netlify 和 Vercel 的服务. Coolify 支持多种编程语言和框架, 允许用户将应用程序部署到任何服务器, 包括个人服务器、VPS、Raspberry Pi、云服务器等. 该平台提供了丰富的功能, 如推送部署、免费 SSL 证书、自动数据库备份 Webhook 集成、强大的 API 和实时终端等, 旨在提供高效、灵活的开发环境. ![20241229154732_sRYeCMVK.webp](https://cdn.dong4j.site/source/image/20241229154732_sRYeCMVK.webp) **重要亮点**: - **兼容性**:Coolify 兼容多种编程语言和框架, 支持静态网站、API、后端、数据库、等多种类型的应用程序. - **部署灵活性**:支持部署到任何服务器, 包括个人服务器、VPS、Raspberry Pi、云服务器等, 并支持 Docker 和 Docker Swarm. - **服务多样性**:可以部署任何与 Docker 兼容的服务, 并提供许多一键式服务. - **集成与自动化**:提供与 GitHub、GitLab、Bitbucket、Gitea 等平台的集成, 支持推送部署和自动数据库备份. - **安全性**:提供 SSL 证书, 确保数据传输安全, 并允许用户完全控制自己的数据. - **控制性**:提供强大的 API 和实时终端, 允许用户直接在浏览器中管理服务器, 并与 CI/CD 管道集成. - **社区和赞助**:Coolify 拥有一个活跃的社区, 并提供赞助商支持. ![20241229154732_XXA3r2Un.webp](https://cdn.dong4j.site/source/image/20241229154732_XXA3r2Un.webp) > 目前只安装部署了, 还没完全用起来 --- ### n8n [n8n](https://github.com/n8n-io/n8n) 是一款开源的流程自动化工具, 允许用户通过连接各种服务和应用程序来构建自动化流程. 它采用基于节点的编程方式, 易于使用且功能强大. ![20241229154732_5h6yU0pd.webp](https://cdn.dong4j.site/source/image/20241229154732_5h6yU0pd.webp) **主要特点**: - **易于使用**: n8n 采用拖放式的节点编辑器, 用户可以轻松地将节点连接起来, 创建复杂的自动化流程. - **可扩展性**: n8n 支持用户添加自定义节点, 以扩展其功能. - **丰富的**: n8n 提供了 200 多个集成节点, 包括常用的社交媒体、邮件、数据库、云服务等. - **开源**: n8n 是开源软件, 用户可以自由地使用、修改和分发它. **适用场景**: - **自动化日常任务**: 例如, 自动化邮件发送、数据同步、文件处理等. - **构建复杂的业务流程**: 例如, 自动化客户关系管理、供应链管理、数据分析等. 相关资源: - [n8n 的自托管 AI 入门套件](#AI-starter-kit) - [在本地运行 LLM:5 种最佳方法(+ 自托管 AI 入门套件)](https://blog.n8n.io/local-llm/) - [在群晖部署 n8n 的一些坑和经验](https://1q43.blog/post/5821/) ### Smokeping [SmokePing](https://github.com/oetiker/SmokePing) 是一个开源的网络延迟监控系统, 它通过定期测量目标主机的响应时间并生成图形化的结果, 帮助用户实时监控网络性能. 它基于 RRDtool 库进行数据存储和图形展示, 支持多种插件和定制化配置, 适用于各种网络环境和需求. ![20241229154732_zsOwYKR6.webp](https://cdn.dong4j.site/source/image/20241229154732_zsOwYKR6.webp) **重点功能:** 1. **网络延迟监控**:定期测量目标主机的响应时间, 并生成直观的图形化结果. 2. **动态 IP 支持**能够处理动态 IP 地址, 确保监控的连续性和准确性. 3. **插件扩展**:支持多种插件, 扩展功能, 例如数据源集成、通知系统等. 4. **Web 模板定制**:提供丰富的 Web 模板选项, 方便用户自定义图形界面. 5. **配置文件管理**:提供详细的配置文件, 方便用户根据实际需求进行定制. 6. **跨平台支持**:适用于各种 Unix 系统, 包括 Linux、MacOS 和. 7. **易于集成**:与其他网络监控工具和系统无缝集成. --- ### IT Tools [IT Tools](https://github.com/CorentinTh/it-tools) 是一个集合了多种开发者常用在线工具的平台, 拥有简洁的界面和良好的用户体验. 它旨在帮助开发者提高工作效率, 轻松处理各种开发任务. ![20241229154732_Y1Y3jCOi.webp](https://cdn.dong4j.site/source/image/20241229154732_Y1Y3jCOi.webp) **重点功能**: - **在线工具**: 提供多种在线工具, 例如代码格式化、代码压缩、在线 API 测试等, 覆盖开发、测试、文档等多个方面. - **工具分类**: 工具按照功能分类, 方便用户快速找到所需工具 **自定义工具**: 用户可以自定义添加工具, 满足个性化需求. - **集成**: 部分工具支持与其他平台集成, 例如 GitHub、GitLab 等. - **代码示例**: 每个工具都提供代码示例, 帮助用户快速上手. ### Uptime Kuma [Uptime Kuma](https://github.com/louislam/uptime-kuma) 是一款易于使用的自托管监控工具, 可以监控各种服务的可用性, 例如 HTTP(s)、TCP、Ping、DNS 记录、游戏服务器等. 它具有友好的界面、丰富的通知选项和多种部署方式, 非常适合个人和企业使用. ![20241229154732_kNhdmHz5.webp](https://cdn.dong4j.site/source/image/20241229154732_kNhdmHz5.webp) **重点功能**: - **多种监控类型**:支持 HTTP(s)、TCP、Ping、DNS 记录、游戏服务器、Docker 容器等多种监控类型. - **丰富的通知选项**:支持 Telegram、Discord、Gotify、Slack、Pushover、SMTP 邮件等多种通知方式, 并支持自定义通知模板. - **友好的界面**:提供响应式、快速的用户界面, 方便用户查看和管理监控数据. - **多语言支持**:支持多种语言, 方便全球用户使用. - **多种部署方式**:支持 Docker、Node.js 等多种部署方式, 方便用户根据需求选择合适的部署方式. - **状态页面**可以创建多个状态页面, 并将状态页面映射到特定的域名. - **Ping 图表**:可以查看历史监控数据, 并生成 Ping 图表. - **证书信息**:可以查看监控服务的 SSL/TLS 证书信息. - **代理支持**:支持代理设置, 方便用户通过代理进行监控. - **双因素认证**:支持双因素认证, 提高账户安全性. > 目前主要的监控工具, 使用 [Bark](https://bark.day.app) 进行消息通知. ### Homepage [Homepage](https://github.com/gethomepage/homepage) 是一个高度可定制的个人主页项目, 支持 Docker 和服务 API 集成. 项目提供快速搜索、书签、天气支持等功能, 并集成了超过 100 个服务和第三方应用程序. ![20241229154732_2gDay2gg.webp](https://cdn.dong4j.site/source/image/20241229154732_2gDay2gg.webp) **重要亮点**: - **高度可定制**:支持自定义主题、CSS、JS、布局和本地化. - **快速**:静态生成, 加载速度快. - **安全**: API 请求都通过代理, 保护 API 密钥安全. - **服务集成**:集成超过 100 个服务和应用程序, 例如 Radarr、Sonarr 等. - **信息小部件**:提供天气、时间、搜索等信息小部件. - **Docker 集成**:支持 Docker 集成和自动服务发现. 相关资源: - [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) ### Nezha Monitoring [Nezha Monitoring](https://github.com/nezhahq/nezha) 是一款开源的、轻量级的监控工具, 旨在帮助用户轻松监控服务器和网站状态. 它支持多种监控指标, 包括系统状态、HTTP、TCP、Ping 等, 并提供推送警报、定时任务和 Web 终端等实用功能. 该工具采用 Go 语言开发, 并提供中文和英文文档, 方便用户使用. ![20241229154732_02qgJDlI.webp](https://cdn.dong4j.site/source/image/20241229154732_02qgJDlI.webp) **主要功能:** - **服务器监控**: 监控服务器资源使用情况, 包括 CPU、内存、磁盘、网络等. - **服务监控**: 监控服务器上的服务状态, 例如 Web 服务、数据库服务等. - **任务管理**: 创建和管理自动化任务, 例如定时任务、脚本执行等. - **通知管理**: 设置接收服务器状态异常通知, 例如邮件、短信等. - **DDNS**: 自动更新 DNS 记录, 方便服务器. - **内网穿透**: 将内网服务器映射到公网, 方便远程访问. - **设置**: 配置监控项、数据展示等. - **分组**: 将服务器分组管理. ### APITable [APITable](https://github.com/apitable/apitable) 是一个面向 API 的低代码平台, 用于构建协作应用程序. 它提供了丰富的功能, 可以帮助用户轻松创建和管理各种应用程序, 例如项目管理、客户关系管理、业务智能等. ![20241229154732_7Ezss4Vw.webp](https://cdn.dong4j.site/source/image/20241229154732_7Ezss4Vw.webp) **重点亮点**: - **实时协作**: 支持多人实时协作编辑, 提高团队效率. - **自动表单**: 自动生成表单, 无需手动编写代码. - **API 首面板**: 提供直观的 API 面板, 用户使用 API. - **无限跨表链接**: 支持无限跨表链接, 实现数据关联. - **强大的行/列权限**: 支持行/列权限控制, 保障数据安全. - **嵌入式**: 支持嵌入式, 方便用户将 APITable 应用程序集成到其他应用程序中. - **丰富的数据库-电子表格 UI**: 提供多种视图类型, 例如网格视图、画廊视图、甘特图视图等. - **丰富的**: 提供多种官方模板, 方便用户快速创建应用程序. - **机器人自动化**: 支持机器人自动化, 提高工作效率. - **BI 仪表板**: 提供 BI 仪表板, 方便用户进行数据分析和可视化. - **可扩展性**: 支持自定义组件、图表、仪表板等, 满足用户个性化需求. --- ### CasaOS [CasaOS](https://casaos.io/) 是一款基于 Docker 的开源个人云操作系统, 旨在提供简单易用的个人云体验. 它支持丰富的应用程序, 并拥有友好的用户界面, 适合用于搭建媒体中心、私有云、智能家居等场景. ![20241229154732_JQAtbuPc.webp](https://cdn.dong4j.site/source/image/20241229154732_JQAtbuPc.webp) **重点亮点**: - **简单易用**:用户界面简洁直观, 易于上手. - **应用程序丰富**:支持超过 20 个预安装应用程序和 50+ 个社区验证应用程序. - **兼容性强**:支持 86 和 Raspberry Pi 设备. - **基于 Docker**:易于扩展和定制. --- ### Open WebUI [Open WebUI ](https://github.com/open-webui/open-webui) 一个功能丰富、易于使用的自托管 WebUI, 旨在完全离线运行. 它支持各种 LLM 运行器, 包括 Ollama 和兼容 OpenAI 的 API. ![20241229154732_8PC1JmDq.webp](https://cdn.dong4j.site/source/image/20241229154732_8PC1JmDq.webp) **重点亮点**: - **易于设置**:使用 Docker 或 Kubernetes (kubectl, kustomize 或 helm) 轻松安装, 支持带 :ollama 和 :cuda 标签的镜像. - **Ollama/OpenAI API 集成**:无缝集成 OpenAI 兼容的 API, 并支持 Ollama 模型. - **细粒度权限和用户组**:管理员可以创建详细的角色和权限, 确保安全的环境并定制用户体验. - **免提语音/视频通话**:集成免提语音和视频通话功能, 提供更动态的聊天环境. - **模型构建器**:通过 Web UI 轻松创建 Ollama 模型, 并支持导入来自 Open WebUI 社区的模型. - **本地 Python 函数调用工具**:支持在工具工作区中添加纯 Python 函数, 并实现 LLM 与自定义函数的无缝集成. - **RAG 集成**:支持检索增强生成 (RAG), 将文档交互无缝集成到聊天体验中. - **Web 搜索功能**:支持使用 SearXNG、Google PSE、Brave Search、serpstack、serper、Serply、DuckDuckGo、TavilySearch、SearchApi 和 Bing 等提供商进行 Web 搜索, 并将结果直接注入到聊天体验中. - **图像生成集成**:支持使用 AUTOMATIC1111 API、ComfyUI (本地) 和 OpenAI 的 DALL-E (外部) 进行图像生成. - **多模型对话**:可以轻松与各种模型同时进行对话, 利用它们的独特优势. - **管道和 Open WebUI 插件支持**:支持将自定义逻辑和 Python 库集成到 Open WebUI 中. --- ### LobeChat [LobeChat](https://github.com/lobehub/lobe-chat) 是一款功能强大的开源 AI 聊天框架, 致力于打造现代、高效的智能对话体验. 它支持多种 AI 服务提供商, 涵盖文本、图像、语音等多模态交互, 并提供知识库、插件系统、多用户管理等丰富功能, 让用户轻松构建个性化、可扩展的聊天机器人. Lobe Chat 旨在为开发者提供便捷、高效的工具, 推动 AI 聊天技术的发展. ![20241229154732_ff8ygTBy.webp](https://cdn.dong4j.site/source/image/20241229154732_ff8ygTBy.webp) **重点亮点**: - **多模态支持**: 支持文本、图像、语音等多种模态, 提供更丰富的交互体验. - **知识库**: 支持文件上传、知识管理和检索, 方便用户管理信息. - **插件系统**: 支持自定义插件, 扩展, 例如搜索、图像生成等. - **多用户管理**: 支持多用户登录和权限管理, 适合团队协作. - **PWA 支持**: 支持渐进式网页应用, 提供类似原生应用的流畅体验. - **自定义主题**: 支持自定义主题, 满足个性化需求. --- ### MaxKB [MaxKB](https://github.com/1Panel-dev/MaxKB) 是一款基于大语言模型和 RAG 的开源知识库问答系统, 广泛应用于智能客服、企业内部知识库、学术研究与教育等场景. ![20241229154732_hOhYdKMO.webp](https://cdn.dong4j.site/source/image/20241229154732_hOhYdKMO.webp) **重点亮点**: - **开箱即用**:支持直接上传文档/自动爬取在线文档, 支持文本自动拆分、向量化和 RAG(检索增强生成), 有效减少大模型幻觉, 智能问答交互体验好. - **模型中立**:支持对接大模型, 包括本地私有大模型(Llama 3 / Qwen 2 等)、国内公共大模型(通义千问 / 腾讯混元 / 字节豆包 / 百度千帆 / 智谱 AI / Kimi 等)和国外公共大模型(OpenAI / Claude / Gemini 等). - **灵活编排**:内置强大的工作流引擎和函数库, 支持编排 AI 工作过程, 满足复杂业务场景下的需求 **无缝嵌入**:支持零编码快速嵌入到第三方业务系统, 让已有系统快速拥有智能问答能力, 提高用户满意度. ### One API [One API](https://github.com/songquanpeng/one-api) 是一个 OpenAI 接口管理 & 分发系统, 支持多种大模型, 包括 Azure OpenAI API、Anthropic Claude、Google PaLM2/Gemini、智谱 ChatGLM、百度文心一言、阿里通义千问、讯飞星火认知、360 智脑以及腾讯混元等. ![20241229154732_vtlqwol5.webp](https://cdn.dong4j.site/source/image/20241229154732_vtlqwol5.webp) **重点亮点**: - **支持多种大模型**:无需切换 API, 即可通过统一的 API 格式访问所有大模型. - **支持 stream 模式**:通过流式传输实现打字机效果, 提供更流畅的交互体验. - **支持多机部署**:方便用户进行和升级. - **支持令牌管理**:设置令牌的过期时间、额度、允许的 IP 范围以及允许的模型访问. - **支持兑换码管理**:支持批量生成和导出兑换码, 方便用户进行充值. - **支持用户分组和渠道分组**:支持为不同分组设置不同的倍率. - **支持模型映射**:重定向用户的请求模型方便用户进行自定义. - **支持失败自动重试**:提高系统的健壮性. - **支持绘图接口**:方便用户进行绘图操作. - **支持丰富的自定义设置**:包括系统名称、logo、页脚、首页、关于页面等. - **支持系统访问令牌调用管理 API**:方便用户进行扩展和自定义. 同类产品: - [One Hub](https://github.com/MartialBE/one-hub?tab=readme-ov-file) --- ### Gitea [Gitea](https://github.com/go-gitea/gitea) 是一个开源的 Git 服务软件, 旨在提供易于使用且功能强大的自托管 Git 仓库托管平台. 它支持多种平台和架构, 并提供代码审查、团队协作等功能, 非常适合个人或团队进行软件开发. Gitea 的社区活跃, 文档完善, 易于扩展, 是构建自托管 Git 服务器的理想选择. ![20241229154732_epp5JO9j.webp](https://cdn.dong4j.site/source/image/20241229154732_epp5JO9j.webp) **重点亮点**: - **跨平台支持**:Gitea 支持 Linux、macOS、Windows 等平台, 并兼容多种架构. - **功能丰富**:提供 Git 仓库托管、代码审查、团队协作、包管理等功能. - **易于使用**:界面简洁易用, 新手也能快速上手. - **开源免费**:Gitea 是开源软件, 免费使用. - **社区活跃**:拥有庞大的社区和贡献者群体, 问题解决和功能迅速. #### 部署 ```yaml networks: gitea: external: false services: server: image: gitea/gitea:1.22.3 container_name: gitea environment: - USER_UID=1002 - USER_GID=1002 - GITEA__database__DB_TYPE=mysql - GITEA__database__HOST=192.168.31.7:3306 - GITEA__database__NAME=gitea - GITEA__database__USER={username} - GITEA__database__PASSWD={password} restart: always networks: - gitea volumes: - /mnt/4.860.ssd/docker/gitea:/data - /home/git/.ssh/:/data/git/.ssh - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro ports: - "3400:3000" - "2222:22" ``` 1. USER_UID 和 USER_GID 使用 git 用户 id; 2. 使用自建 MySQL; 3. 自定义 gitea 存储目录: /mnt/4.860.ssd/docker/gitea ([后期备份](https://docs.gitea.cn/administration/backup-and-restore/)); 4. 添加 `/home/git/.ssh/` 用于 SSH 直通; #### SSH 直通 1. 主机上生成 key ```bash sudo -u git ssh-keygen -t rsa -b 4096 -C "Gitea Host Key" ``` 2. 主机上创建脚本: `/usr/local/bin/gitea`, 并赋予执行权限: `chmod +x /usr/local/bin/gitea` ```bash ssh -p 2222 -o StrictHostKeyChecking=no git@127.0.0.1 "SSH_ORIGINAL_COMMAND=\"$SSH_ORIGINAL_COMMAND\" $0 $@" ``` 3. 主机上添加 `authorized_key`: ```bash echo "$(cat /home/git/.ssh/id_rsa.pub)" >> /home/git/.ssh/authorized_keys ``` 4. 在 gitea 上添加 ssh 配置后进行下面的配置: ```bash vim /mnt/4.860.ssd/docker/gitea/gitea/conf/app.ini [server] DOMAIN = gitea.lan SSH_DOMAIN = gitea.lan ROOT_URL = http://gitea.lan:3400/ ``` 主要是将 ip 修改为域名: 客户机配置 1. 添加 hosts: `192.168.31.7 gitea.lan` 5. 添加 .ssh/config 配置: ```bash Host gitea.lan HostName gitea.lan User git Port 2222 IdentityFile ~/.ssh/server ``` 测试: `ssh -T git@gitea.lan`: ```txt Hi there, xxx! You've successfully authenticated with the key named xxx.yyy, but Gitea does not provide shell access. If this is unexpected, please log in with password and setup Gitea under another user. ``` --- ### Jellyfin 代理配置 新建 `/etc/default/jellyfin_proxy.env` 并添加: ```shell http_proxy=192.168.1.88:7890 https_proxy=192.168.1.88:7890 all_proxy=socks5://192.168.1.88:8080 ``` 修改 `/etc/systemd/system/jellyfin.service.d/override.conf`: ```shell [Service] EnvironmentFile = /etc/default/jellyfin_proxy.env ``` 重启: ```shell sudo systemctl daemon-reload && sudo systemctl restart jellyfin.service ``` Jellyfin 有个坑点, 至少对于我测试的两个地址: 一个是环回地址 (127.0.0.1), 一个是局域网地址 (192.168.31.88, 使用局域网的另一台设备做代理服务器) 来说, 设置环境变量时不能加上协议头 (`http://` 或 `https://`), 否则就算设置成功也不会生效, 不清楚是不是 Jellyfin 的玄学 bug, 亦或者说是神奇 feature 😑. 所以上文写的都是没有加协议头的配置, 可以走代理并正常工作, 加了即使设置成功, 在 Jellyfin 里也无效 ~(然而在其他环境这两个环境变量的协议头是可加可不加的, 不影响)~. ### Nginx Proxy Manager [Nginx Proxy Manager](https://nginxproxymanager.com/) 是一个基于 Docker 的 Nginx 反向代理管理工具, 它提供了简单易用的界面来管理 Nginx 代理主机. ![20241229154732_VO44KEDv.webp](https://cdn.dong4j.site/source/image/20241229154732_VO44KEDv.webp) **重点亮点**: - **简单易用**:无需深入了解 Nginx 或 Let's Encrypt, 即可轻松创建转发域名、重定向、流和 404 主机. - **免费 SSL**:支持 Let's Encrypt 自动续订 SSL 证书, 或提供自定义 SSL 证书. - **功能丰富**:提供访问列表、基本 HTTP 认证、高级 Nginx 配置、用户管理、权限控制和审计日志等功能. - **适用于家庭网络**:可以轻松将家庭网络中的网站托管在公网上, 并支持端口转发和域名指向设置. 使用 DNSPOD 时报错: ``` ModuleNotFoundError: No module named 'zope' #2440 ``` [Fetching Title#rlbf](https://github.com/NginxProxyManager/nginx-proxy-manager/issues/2440) 解决方法: ```shell # 进入 docker 容器 docker exec -it /bin/bash xxxx pip install certbot-dns-dnspod pip install zope -i https://pypi.tuna.tsinghua.edu.cn/simple ``` 申请证书时报错: ``` Another instance of Certbot is already running ``` 解决方法: ``` find / -type f -name '.certbot.lock' -exec rm {} \; ``` --- ### 1Panel 使用 1Panel 安装了 `OpenResty`, 并创建了静态站点, 用于生成 Surge 的规则. 为了方便文件共享编辑, 将`/mnt/lankxin.u/docker/docker-compose/sub/data/subconverter/rules/ACL4SSR` 通过软链接的方式添加到了 `/opt/1panel/apps/openresty/openresty/www/sites/ACLSSR/index` 目录下, 配置后报 **404 问题**. 官方给的解决版本是设置 `disable_symlinks off`, 但是并不能解决上述问题, 因为默认就是 off: ![20241229154732_aZ8ulzeH.webp](https://cdn.dong4j.site/source/image/20241229154732_aZ8ulzeH.webp) 问题的原因是 OpenResty 使用 docker 容器启动, 进入容器查看发现识别不了 `/mnt/lankxin.u/docker/docker-compose/sub/data/subconverter/rules/ACL4SSR` 目录, 因此需要将此目录映射到容器中: ![20241229154732_CHLTtY12.webp](https://cdn.dong4j.site/source/image/20241229154732_CHLTtY12.webp) --- ### Hishtory [Hishtory](https://github.com/ddworken/hishtory) 是一个更好的 shell 历史记录. 它将您的 shell 历史记录存储在上下文中(哪个目录中运行命令、命令是否成功或失败、花费了多长时间等). 所有这些都存储在本地并进行端到端加密, 以便同步到其他计算机. 所有这些都可以通过`hishtory`CLI 轻松查询. 意思是能够在多台设备之间同步命令行记录. ![20241229154732_bPhPdPuS.webp](https://cdn.dong4j.site/source/image/20241229154732_bPhPdPuS.webp) 安装方式非常简单: ``` curl https://hishtory.dev/install.py | python3 - ``` 然后重新打开一个 shell(如果你使用 zsh, 直接 source .zshrc 也行), 使用 `Control+R` 即可查看历史记录. 使用 `hishtory status` 查看状态与密钥, 然后在另一台主机上通过上述命令安装, 并使用 `hishtory init {密钥}` 来同步历史记录. #### hishtory-server 当然也可以执行部署 `hoshtory-server` 来作为多台主机历史记录的同步服务器: ```yaml services: hishtory-server: image: lscr.io/linuxserver/hishtory-server:latest container_name: hishtory-server environment: - PUID=1000 - PGID=1000 - TZ=Asia/Shanghai - HISHTORY_SQLITE_DB=/config/hishtory.db #optional ports: - 4000:8080 restart: unless-stopped ``` 然后修改各个主机中的 `hishtory` 配置(我使用 `oh-my-zsh`, 所以直接修改 .zshrc 即可): ```shell export PATH="$PATH:/Users/dong4j/.hishtory" # 添加这一行 export HISHTORY_SERVER="http://{hoshtory-server 的 IP}:4000" source /Users/dong4j/.hishtory/config.zsh ``` 最后重新加载配置: ```shell source .zshrc ``` 这样就能使用自托管的 `hoshtory-server` 在多个主机之间同步命令行历史记录了. --- ### 常用中间件 #### MediaMTX [MediaMTX](https://github.com/bluenviron/mediamtx) 是一款功能强大的实时媒体服务器和媒体代理, 支持视频和音频流的发布、读取、代理、录制和播放. 它可以将不同协议的媒体流进行路由, 实现多种媒体流的互联互通. 我目前主要用来播放树莓派摄像头上的视频: ![20241229154732_cwXHOY7g.webp](https://cdn.dong4j.site/source/image/20241229154732_cwXHOY7g.webp) > [[raspberry-pi-stream|树莓派结合 MediaMTX/WVP + ZLMediaKit 实现视频流播放的教程]] **主要功能**: - **支持多种协议**: 支持 SRT、WebRTC、RTSP、RTMP、HLS 等多种主流媒体协议, 兼容性良好. - **实时处理**: 可实时处理媒体流, 包括转换、解码、编码、压缩等操作. - **多路复用**: 可同时处理多个媒体流, 并提供不同的访问路径. - **录制和回放**: 可将媒体流录制到磁盘, 并支持回放. - **安全认证**: 支持用户认证和访问控制, 保障媒体流的安全. - **灵活配置**: 提供丰富的配置选项, 可满足不同场景的需求. #### WVP PRO + ZLMediaKit [WVP PRO](https://github.com/648540858/wvp-GB28181-pro) 是一个基于 GB28181-2016 标准实现的开箱即用的网络视频平台, 负责实现核心信令与设备管理后台部分, 支持 NAT 穿透, 支持海康、大华、宇视等品牌的 IPC、NVR 接入. 支持国标级联, 支持将不带国标功能的摄像机/直播流/直播推流转发到其他国标平台, 流媒体服务基于 [ZLMediaKit](https://github.com/ZLMediaKit/ZLMediaKit). ![20241229154732_u8EjJ7jh.webp](https://cdn.dong4j.site/source/image/20241229154732_u8EjJ7jh.webp) **特性**: - 实现标准的 28181 信令, 兼容常见的品牌设备, 比如海康、大华、宇视等品牌的 IPC、NVR 以及平台. - 支持将国标设备级联到其他国标平台, 也支持将不支持国标的设备的图像或者直播推送到其他国标平台 - 前端完善, 自带完整前端页面, 无需二次开发可直接部署使用. - 完全开源, 且使用 MIT 许可协议. 保留版权的情况下可以用于商业项目. - 支持多流媒体节点负载均衡. #### JSON Crack [JSON Crack](https://github.com/AykutSarac/jsoncrack.com) 专注于简化 JSON 数据的处理和可视化. 它通过将 JSON 数据转换为交互式图表, 帮助用户更直观地理解数据结构和关系. 此外, 它还提供格式化、验证、代码生成等功能, 支持多种数据格式, 是处理 JSON 数据的理想选择. ![20241229154732_IxKLtILH.webp](https://cdn.dong4j.site/source/image/20241229154732_IxKLtILH.webp) **重点**: - **可视化 JSON 数据**: 将复杂的 JSON 结构以图表形式展现, 方便用户快速理解和分析数据. - **格式化与验证**: 自动格式化 JSON 数据, 并提供验证功能, 确保数据格式正确. - **多种格式支持**: 支持多种数据格式, 包括 JSON、YAML、CSV、XML 和 TOML, 满足不同需求. **亮点**: - **交互式体验**: 通过交互式图表, 用户可以展开和折叠数据结构, 探索数据细节. - **易于使用**: 界面简洁直观, 操作简单, 即使是非技术用户也能轻松上手. - **安全可靠**: 数据处理完全在本地进行, 无需上传或存储数据, 确保用户隐私安全. - **多功能性**: 除了可视化, 还提供格式化、验证、代码生成等功能, 满足 JSON 数据处理的全面需求. 相关资源: - [JSON Crack VS Code 插件](https://marketplace.visualstudio.com/items?itemName=AykutSarac.jsoncrack-vscode) - [IDEA 中的插件](https://plugins.jetbrains.com/plugin/22308-jsoncrack) ##### 部署 ```bash git clone git@github.com:AykutSarac/jsoncrack.com.git cd jsoncrack.com docker build -t jsoncrack . docker-compose up -d ``` 其他一些常用的开发中间件全部使用 1Panel 部署: - mongodb - nacos - kafka - mysql - postgresql - emqx - redis ## AI.Station 这台主机因为耗电量有点高, 只会在使用时开一下, 目前主要用作 AI 相关的测试环境, 后期打算换成 22G 版本的 2080Ti 玩一玩. ### Stable Diffusion [Stable Diffusion web UI](https://github.com/AUTOMATIC1111/stable-diffusion-webui) 是一个基于 Gradio 库实现的 Stable Diffusion 模型的网页界面. 它提供了一系列功能, 方便用户使用 Stable Diffusion 模型进行图像生成和编辑. 就是看了 [这篇文章](https://medium.com/@croath/%E4%BD%8E%E6%88%90%E6%9C%AC%E4%BD%93%E9%AA%8C%E7%94%9F%E6%88%90-ai-%E5%B0%8F%E5%A7%90%E5%A7%90%E7%85%A7%E7%89%87-85ffa7c13cd7)开始入坑的, 可以说是因为 **Stable Diffusion** 组装了 **AI.Station** 这台主机, 也是我尝试的第一款 AI 工具, 目前博客的 cover 图片也是通过它来生成的. ![20241229154732_tkgmSIsV.webp](https://cdn.dong4j.site/source/image/20241229154732_tkgmSIsV.webp) **主要功能**: - **图像生成**:支持 txt2img 和 img2img 模式, 可以输入文字描述生成图像, 或将图像作为输入进行风格迁移或内容编辑. - **图像编辑**:支持 outpainting、inpainting、color 等功能, 可以扩展图像内容、修复图像缺陷或改变图像颜色. - **模型扩展**:集成了 GFPGAN、CodeFormer、RealESRGAN、ESRGAN、SwinIR、Swin2SR、LDSR 等模型, 可以修复人脸、修复图像、提升图像分辨率等. - **参数控制**:提供丰富的参数设置选项, 包括采样方法、注意力机制、生成参数等, 可以控制图像生成的风格和质量 **批量处理**:支持批量处理图像, 可以同时生成多张图像. - **自定义脚本**:支持自定义脚本, 可以扩展功能或实现特定需求. **其他特点**: - **易于使用**:网页界面简洁易用, 操作方便. - **跨平台**:支持 Windows、Linux、macOS 等操作系统. - **开源**:开源代码, 方便用户学习和改进. #### 相关资源 ##### 模型资源 - [9 个 Stable Diffusion 模型网站](https://www.mdnice.com/writing/a6c04462013e469793b3ff41830a7b08) - [Stable Diffusion 常用模型下载与说明](https://blog.csdn.net/libaiup/article/details/139167972) ##### 教程 - [Stable Diffusion 教程](https://github.com/ai-vip/stable-diffusion-tutorial) - [外婆都能看懂的 Stable Diffusion 入门教程](https://www.uisdc.com/stable-diffusion-3) - [万字长文:Stable Diffusion 保姆级教程](https://blog.csdn.net/jarodyv/article/details/129387945) - [超详细的胎教级 Stable Diffusion 使用教程](https://mp.weixin.qq.com/s/eFi-xoVDQomzCBr5kO9nHA) - [视频教程-Stable Diffusion 教程 从入门到精通](https://www.youtube.com/playlist?list=PL4L5yXcAegdxwcD2RRffQntmXygv26auT) #### 相关问题 ``` ImportError: Using SOCKS proxy, but the 'socksio' package is not installed. Make sure to install httpx using `pip install httpx[socks]`. ``` ```shell pip install 'httpx[socks]' ``` > zsh 环境不能直接使用 `pip install httpx[socks]`, 这是 Zsh 的路径扩展机制导致的问题, 它会尝试将方括号 [] 视为通配符 验证: ``` python -c "import httpx; print(httpx.__version__)" ``` 然而上述操作并无法解决, 下面才是正确的方法: ``` unset all_proxy && unset ALL_PROXY ``` 因为我开启了终端代理, 需要先关闭代理. ### ComfyUI [ComfyUI](https://github.com/comfyanonymous/ComfyUI) 是一个功能强大的扩散模型图形界面、API 和后端工具, 提供基于图//流程图的界面, 让您无需编写代码即可设计和执行复杂的稳定扩散工作流程. ![20241229154732_rUrd5dZq.webp](https://cdn.dong4j.site/source/image/20241229154732_rUrd5dZq.webp) **主要特点**: - **图形界面**: 使用节点/图/流程图界面, 无需代码即可设计和执行工作流程. - **兼容多种模型**: 支持 SD1.x、SD2.x、SDXL、Stable Video Diffusion、Stable、SD3 和 Stable Audio 等多种扩散模型. - **高效管理**: 异步队列系统、智能内存管理、仅重执行更改部分等优化功能. - **模型兼容**: 支持加载 ckpt、safetensors 和 diffusers 模型/检查点, 以及独立的 VAE 和 CLIP 模型. - **文本处理**: 支持嵌入/文本反转、Loras (regular, locon 和 loha)、超网络等文本功能. - **工作流程管理**: 可以从 PNG、WebP 和 FLAC 文件加载包含种子的工作流程, 并保存/加载工作流程为 JSON 文件. - **多种工具**: 支持区域合成、修复、ControlNet 和 T2I-Adapter、升采样模型 (ESRGAN、SwinIR、Swin2SR 等)、unCLIP 模型、GLIGEN、模型合并、LCM 模型和 Loras、XL Turbo、AuraFlow、HunyuanDiT、TAESD 隐式预览等功能. - **离线工作**: 完全离线工作, 无需下载任何内容. - **配置文件**: 可以通过配置文件设置模型搜索路径. > ConfyUI 官网的 [桌面版](https://github.com/Comfy-Org/desktop) 最近开始公测了, 下载地址: > Windows (NVIDIA) NSIS x64: [Download](https://download.comfy.org/windows/nsis/x64) > macOS ARM: [Download](https://download.comfy.org/mac/dmg/arm64) #### 相关资源 - [ComfyUI 入门和进阶指南](https://www.uisdc.com/zt/comfyui) - [系列教程](https://comfyui-wiki.com/zh/tutorial) - [RunComfy 的教程](https://www.runcomfy.com/zh-CN/tutorials) - [视频教程-ComfyUI 入门到精通](https://www.youtube.com/playlist?list=PL4L5yXcAegdy6aZGttxu2lVhjSaYVAquX) - [视频教程-ComfyUI 系统教程](https://www.youtube.com/playlist?list=PLPr2bJ5ZkFrx4MmCLHemoHC3qWEGk6XN1) ### AI starter kit [Self-hosted AI starter kit](https://github.com/n8n-io/self-hosted-ai-starter-kit) 是一个开源的 Docker Compose 模板, 旨在快速搭建本地 AI 和低代码开发环境. 它由 **[n8n](#n8n)** 精心打造, 集成了自托管 n8n 平台和一系列兼容的 AI 产品和组件, 让您可以轻松开始构建自托管的 AI 工作流程. ![20241229154732_38N0rSw2.webp](https://cdn.dong4j.site/source/image/20241229154732_38N0rSw2.webp) **主要功能**: - **自托管 n8n 平台**:低代码平台, 拥有超过 400 个集成和高级 组件. - **Ollama**:跨平台 LLM 平台, 可安装和运行最新的本地 LLM. - **Qdrant**:开源、高性能向量存储, 拥有全面的 API. - **PostgreSQL**:数据工程领域的“工作马”, 安全处理大量数据. > 我还是用 ChatGLM4 把, 至少上面的问题它能回答出来: > > ![20241229154732_AlTfxeF4.webp](https://cdn.dong4j.site/source/image/20241229154732_AlTfxeF4.webp) #### 安装 Nvidia 容器工具包 1. 配置存储库 ```bash curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \ | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \ | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \ | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list sudo apt-get update ``` 2. 安装工具包 ```bash sudo apt-get install -y nvidia-container-toolkit ``` #### 配置 Docker 以使用 Nvidia 驱动程序 ```bash sudo nvidia-ctk runtime configure --runtime=docker sudo systemctl restart docker ``` 上述命令在 `/etc/docker/daemon.json` 添加如下内容: ```json "runtimes": { "nvidia": { "args": [], "path": "nvidia-container-runtime" } } ``` #### 部署 ```bash git clone https://github.com/n8n-io/self-hosted-ai-starter-kit.git cd self-hosted-ai-starter-kit docker compose --profile gpu-nvidia up -d ``` > 如果需要在非本机上访问 WebUI, environment 需要添加 `N8N_SECURE_COOKIE=false`. 启动后需要耐心等待 `llama3.2:latest` 模型下载完成后才能执行聊天. 下载模型花了大概 10 分钟: ![20241229154732_xut64rTN.webp](https://cdn.dong4j.site/source/image/20241229154732_xut64rTN.webp) --- ## MBP macOS 下推荐使用 [OrbStack](https://orbstack.dev/) , 它是一款替代 Docker Desktop 的轻量级 Docker 和 Linux 运行环境. 它具有启动快、占用资源少、易于集成等特点, 并提供容器、Kubernetes 和 Linux 发行版等功能. ![20241229154732_jbwSIPKq.webp](https://cdn.dong4j.site/source/image/20241229154732_jbwSIPKq.webp) **重要亮点**: - **轻量级**:占用资源少, 对电池续航影响小. - **速度快**:启动速度快, 网络性能优异. - **易于集成**:与 Docker Desktop 兼容, 支持远程 SSH 和文件共享. - **功能丰富**:支持容器、Kubernetes 和 Linux 发行版等功能. - **性能优异**:在性能测试中优于 Docker Desktop. ### LM Studio [LM Studio](https://lmstudio.ai/) 是一个可以本地运行大型语言模型(LLM)的工具, 用户无需连接互联网即可在电脑上运行各种 LLM, 包括 Llama、Mistral、Phi 等 ![20241229154732_ziXFTeHa.webp](https://cdn.dong4j.site/source/image/20241229154732_ziXFTeHa.webp) **重点亮点**: - **本地运行**:无需连接互联网, 在本地电脑上运行 LLM. - **模型库丰富**:支持多种 LLM 模型, 包括 Llama、Mistral、Phi、Gemma、DeepSeek、Qwen2.5 等. - **文档对话**:可以与本地文档进行对话, 获取信息或生成内容. - **多种接口**:支持通过 Chat UI 或 OpenAI 兼容的本地服务器使用模型. - **模型下载**:可以下载 Hugging Face 上的任何兼容模型文件. - **隐私保护**:不收集用户数据, 保护用户隐私. **优势**: - **无需互联网**:随时随地使用 LLM, 不受网络限制. - **隐私保护**:保护用户数据安全, 避免数据泄露风险. - **功能丰富**:满足多种 LLM 应用需求. - **易于使用**:简洁的界面, 易于上手. ![20241229154732_ehBTUUoX.webp](https://cdn.dong4j.site/source/image/20241229154732_ehBTUUoX.webp) 目前是我主要使用的本地 LLM 工具, 且兼容 OpenAI API, 这样我就可以在其他需要 LLM 的应用中使用. ### Ollama 相关问题 ```shell # 允许跨域 launchctl setenv OLLAMA_ORIGINS "*" # 更改模型位置 launchctl setenv OLLAMA_MODELS "/Users/dong4j/Library/CloudStorage/SynologyDrive-AI/models/ollama" # 修改 bind launchctl setenv OLLAMA_HOST "0.0.0.0" ``` 这里补充一下如果是在 Docker 中安装, 有可能会遇到 [Unable to make cors work in docker container](https://github.com/ollama/ollama/issues/3365) 问题, 不过最新版本已经修复了. ## Mac mini 2018 作为最后一代支持 Intel 芯片的 macOS 主机, 二手已经卖不起价了, 所以留着当台服务器用. 最大的问题就是发热严重, 现在散热风扇 24 小时开着, 一点不敢怠慢, 希望还能多用几年. ### Rsync Server Mac mini 2018 通过雷雳 3 连接着一个 8T 的 LaCie d2 Professional, 用作重要数据的冗余备份. 目前通过 Rsync 将 DS923+ 上的重要数据同步到 LaCie d2 上. 具体的配置方式将在 [[homelab-data|数据篇]] 详细说明, 主要涉及到 Rsync 的 Server 启动模式以及 macOS 上的自启动配置. ![20241229154732_9bWUOu7s.webp](https://cdn.dong4j.site/source/image/20241229154732_9bWUOu7s.webp) ### V2ray 另一种 VPN 方案, 使用 Surge 作为客户端连接家中的设备和服务: > 使用 V2ray 只作为连接家中网络, 不作其他用途. ```yaml services: v2ray: command: "v2ray -config /srv/docker/v2ray/config.json" image: "v2ray/official:latest" restart: always ports: - "54321:54321" volumes: - "/path/to/data:/srv/docker/v2ray" ``` `config.json` 配置如下: ```json { "inbounds": [ { "port": 54321, "protocol": "vmess", "settings": { "clients": [ { "id": "ac2a5d46-a3c4-4c35-9db1-614f403dedff", "alterId": 64 } ] } } ], "outbounds": [ { "protocol": "freedom", "settings": {} } ] } ``` Surge 配置如下: ``` 💻 V.Intel = vmess, your.domain.name, 54321, username=ac2a5d46-a3c4-4c35-9db1-614f403dedff ``` --- ### NextChat [NextChat](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web) 是一个基于 ChatGPT 和 Gemini 的跨平台应用, 支持 Web、PWA、Linux、Windows、MacOS 等多种平台. 它可以帮助用户一键部署自己的 ChatGPT 应用, 并提供 GPT3、GPT4 和 Gemini Pro 等多种 AI 模型支持. ![20241229154732_TdYndv4v.webp](https://cdn.dong4j.site/source/image/20241229154732_TdYndv4v.webp) **重点亮点**: - **一键部署**:只需在 Vercel 上进行简单操作, 即可在 1 分钟内完成部署. - **轻量级**:Linux/Windows/MacOS 版本的客户端体积小巧(约 5MB), 易于安装和使用. - **兼容自部署模型**:支持与自部署的 LLM 模型(如 RWKV-Runner 和 LocalAI)无缝配合. - **隐私保护**:所有数据都保存在本地浏览器中, 确保用户隐私安全. - **丰富的功能**:支持 Markdown、响应式设计、深色模式、PWA 等功能, 并提供预制角色海量内置 prompt 列表. - **多语言支持**:支持英语、简体中文、繁体中文、日语、西班牙语等多种语言. ### ChatGPT on WeChat [ChatGPT on WeChat](https://github.com/zhayujie/chatgpt-on-wechat) 是基于大模型搭建的聊天机器人, 同时支持 微信公众号、企业微信应用、飞书、钉钉 等接入, 可选择 GPT3.5/GPT-4o/GPT-o1/ Claude/文心一言/讯飞星火/通义千问/ Gemini/GLM-4/Claude/Kimi/LinkAI, 能处理文本、语音和图片, 访问操作系统和互联网, 支持基于自有知识库进行定制企业智能客服. ![20241229154732_G3FSok7x.webp](https://cdn.dong4j.site/source/image/20241229154732_G3FSok7x.webp) [效果演示](https://cdn.link-ai.tech/doc/cow_demo.mp4) ### Dify on WeChat [Dify on WeChat](https://github.com/hanfangyuan4396/dify-on-wechat) 项目为 [chatgpt-on-wechat](https://github.com/zhayujie/chatgpt-on-wechat) 下游分支, 额外对接了 LLMOps 平台 [Dify](https://github.com/langgenius/dify), 支持 Dify 智能助手模型, 调用工具和知识库, 支持 Dify 工作流. ![20241229154732_s934kJDk.webp](https://cdn.dong4j.site/source/image/20241229154732_s934kJDk.webp) Dify 接入微信生态的详细教程请查看文章 [**手摸手教你把 Dify 接入微信生态**](https://docs.dify.ai/v/zh-hans/learn-more/use-cases/dify-on-wechat). ## Mac mini M2 Mac mini M2 主要作为 AI 辅佐主机, 部署了一些小模型来辅佐 RAG 的建设, 比如: - reranker - m3e-large ## 软路由 ### R2S #### AdGuard Home [AdGuard Home](https://github.com/AdguardTeam/AdGuardHome) 是一款免费开源的网络广告和跟踪器屏蔽软件, 它通过充当 DNS 服务器来阻止跟踪域, 从而防止您的设备连接到这些服务器. ![20241229154732_wJLNXSok.webp](https://cdn.dong4j.site/source/image/20241229154732_wJLNXSok.webp) **特性**: - **网络级保护**: AdGuard Home 可以覆盖您家中所有设备的网络, 无需在设备上安装任何客户端软件. - **强大的功能**: 它不仅能够屏蔽广告和跟踪器, 还可以阻止恶意软件、钓鱼网站、成人内容, 并强制进行安全搜索. - **自定义规则**: 您可以添加自定义规则来屏蔽特定网站或广告. - **易于使用**: AdGuard Home 非常易于设置和使用, 即使是非技术用户也可以轻松上手. - **开源**: AdGuard Home 是开源软件, 这意味着您可以查看其源代码, 并根据自己的需求进行修改和扩展. R2S 的 OpenWrt 固件自带 **AdGuard Home** 服务, 目前主要用来拦截广告. --- #### OpenClash [OpenClash](https://github.com/vernesong/OpenClash) 是一个基于 OpenWrt 的 **Clash** 客户端插件, 旨在为 OpenWrt 路由器用户提供强大的网络代理管理功能. OpenClash 支持多种代理协议(如 VMess、Shadowsocks、Trojan 等), 并允许用户灵活配置规则进行流量分流. ![20241229154732_tsSGCxrN.webp](https://cdn.dong4j.site/source/image/20241229154732_tsSGCxrN.webp) **特性**: - **图形化管理界面**:通过 OpenWrt 的 Luci 界面, 用户可以轻松配置和管理 Clash. - **多配置文件支持**:允许用户切换和管理多个代理配置文件. - **实时日志与调试工具**:便于用户查看代理运行状态并快速排查问题. - **自动化更新**:支持订阅配置文件和规则的自动更新, 保持最新网络环境. #### SmartDNS [SmartDNS](https://github.com/pymumu/smartdns) 一个运行在本地的 DNS 服务器, 旨在通过获取最快的网站 IP 地址来提高网络访问速度. 它支持 DoH 和 DoT 协议, 并兼容多种操作系统, 如树莓派、OpenWrt 和 Windows. ![20241229154732_PmqxAXjC.webp](https://cdn.dong4j.site/source/image/20241229154732_PmqxAXjC.webp) **特性**: - **多虚拟 DNS 服务器**: 支持多个虚拟 DNS 服务器, 每个服务器拥有不同的端口、规则和客户端, 实现不同场景下的需求. - **多 DNS 上游服务器**: 支持配置多个上游 DNS 服务器, 并行查询并返回访问速度最快的解析结果, 提高查询效率和可靠性. - **客户端独立控制**: 支持基于 MAC 或 IP 地址控制客户端使用不同的查询规则, 实现家长控制等功能. - **返回最快 IP 地址**:域名所属 IP 地址列表中查找访问速度最快的 IP 地址, 并返回给客户端, 提高网络访问速度. - **多种查询协议**: 支持 UDP、TCP、DOT 和 DOH 查询及服务, 以及非 53 端口查询;支持通过 socks5, HTTP 代理查询. - **特定域名 IP 地址指定**: 支持指定域名的 IP 地址, 实现广告过滤、避免恶意网站等功能. - **域名高性能后缀匹配**:持域名后缀匹配模式, 简化过滤配置, 过滤效率高. - **域名分流**: 支持域名分流, 不同类型的域名向不同的 DNS 服务器查询, 支持 iptable 和 nftable 更好的分流;支持测速失败的情况下设置域名结果到对应 ipset 和 nftset 集合. - **多平台支持**: 支持标准 Linux 系统(树莓派)、OpenWrt 系统各种固件和华硕路由器原生固件. 支持 WSL(Windows Subsystem for Linux, 适用于 Linux 的 Windows 子系统). - **IPv4、IPv6 双栈**: 支持 IPv4 和 IPV 6 网络, 支持查询 A 和 AAAA 记录, 支持双栈 IP 速度优化, 并支持完全禁用 IPv6 AAAA 解析. --- #### PushBot [PushBot](https://github.com/zzsj0928/luci-app-pushbot) 支持多种推送服务, 包括钉钉、企业微信、微信、飞书等, 并提供设备状态监控、流量统计等功能. ![20241229154732_vrU8TQzI.webp](https://cdn.dong4j.site/source/image/20241229154732_vrU8TQzI.webp) **重要亮点**: - 支持多种推送服务, 包括钉钉、企业微信、微信、飞书等. - 提供设备状态监控、流量统计等功能. - 支持设备别名、白名单、名单等设置. - 代码基于 serverchan 提供的接口进行发送信息. ### R5S #### WireGuard Easy [WireGuard Easy](https://github.com/wg-easy/wg-easy) 是一个基于 Web 界面的 **WireGuard** VPN 管理工具, 旨在简化 WireGuard 服务器的设置和管理. 它提供了一个用户友好的界面, 使用户能够轻松创建、管理和监控 WireGuard 配置, 而无需手动编辑配置文件. ![20241229154732_AyoFVrAx.webp](https://cdn.dong4j.site/source/image/20241229154732_AyoFVrAx.webp) **特性**: - **简化的 WireGuard 配置管理**:通过 Web UI, 用户可以轻松地添加和管理 WireGuard 客户端和服务器配置. - **支持多用户管理**:可以为多个客户端生成和管理 WireGuard 密钥对, 方便分配和控制访问权限. - **实时状态监控**:提供 WireGuard 连接状态的实时更新和日志记录, 帮助用户监控 VPN 服务的运行状况. - **Docker 支持**:wg-easy 可以通过 Docker 容器部署, 便于跨平台使用. --- #### Bark [Bark](https://github.com/Finb/Bark) 允许用户将自定义通知推送到他们的 iPhone. 它可以将各种信息, 如短信、邮件、社交媒体更新等, 转换为语音或文字通知, 方便用户在无法查看手机时接收重要信息. Bark 支持多种通知方式, 包括 Pushover、IFTTT、HTTP API 等, 并可以与各种应用程序和服务进行集成. 用户可以通过 bark-server 搭建自己的 [Bark 服务器](https://github.com/Finb/bark-server), 实现完全自托管的解决方案. ![20241229154732_nKrY4HMo.webp](https://cdn.dong4j.site/source/image/20241229154732_nKrY4HMo.webp) 目前使用 Bark 作为推送服务的有: - Uptime Kuma - OpenWrt 的 PushBot - 脚本执行结果推送 - 浏览器插件推送 - Synology NAS 的 Webhook **相关资源:** - [Bar Server Markdown](https://github.com/adams549659584/bark-server) --- ### H28K #### Linux Command [Linux Command](https://github.com/jaywcjlove/linux-command) 是一个开源项目, 旨在收集整理 Linux 命令手册、详解、学习资源等内容, 并提供方便快捷的搜索工具. 该项目由 GitHub 用户 jaywcjlove 维护, 并生成了一个 Web 网站方便用户使用. ![20241229154732_etdTnBBP.webp](https://cdn.dong4j.site/source/image/20241229154732_etdTnBBP.webp) **主要特点**: - **内容全面**: 搜集了 580 多个 Linux 命令, 涵盖文件管理、文件传输、备份压缩、磁盘管理、系统设置、系统管理、文本处理网络通讯、设备管理、电子邮件与新闻组等多个方面. - **方便搜索**: 提供强大的搜索功能, 用户可以快速找到所需的命令及其相关信息. - **多种版本**: 提供多种版本, 包括 Web 版本、微信小程序、Chrome 插件、Raycast 版本、Alfred 版本、Android 版本、Mac/Win/Linux 版本等, 方便用户在不同平台上使用. - **易于部署**: 提供多种部署方式, Docker、Vercel、宝塔面板等, 方便用户自行部署. ## HK1 Box ### Home Assistant [Home Assistant](https://www.home-assistant.io/) 一款开源的智能家居自动化平台. 它支持多种设备和服务的集成, 提供强大的自动化功能, 并强调本地控制和隐私保护. 目前还没有花太多心思去做配置: ![20241229154732_gdpIiGkb.webp](https://cdn.dong4j.site/source/image/20241229154732_gdpIiGkb.webp) **重要亮点** - **开源社区**: 由全球爱好者和开发者共同维护. - **设备兼容性**: 支持超过 1000 种设备和服务的集成. - **自动化功能**: 可以根据时间、事件和条件自动控制智能家居设备. - **本地控制**: 数据存储和处理均在本地, 确保隐私安全. - **移动应用**: 提供官方移动应用, 方便远程控制和监控. ### Node-RED [Node-RED](https://nodered.org/) 是一个基于 Node.js 的低代码编程平台, 旨在简化事件驱动应用程序的开发. 它提供直观的浏览器编辑器, 允许用户通过拖放节点的方式连接硬件、API 和在线服务, 构建复杂的流程. Node-RED 适用于各种场景, 包括物联网、数据集成和自动化等. ![20241229154732_fGEzcSLj.webp](https://cdn.dong4j.site/source/image/20241229154732_fGEzcSLj.webp) **重点亮点**: - **低代码编程平台**: Node-RED 是一个用于事件驱动的应用程序的低代码编程平台, 它允许用户通过图形界面连接各种硬件设备、API 和数据源. - **可视化编程**: 用户可以通过拖放节点来构建应用程序, 无需编写代码, 这降低了开发难度, 并提高了开发效率. - **广泛的应用场景**: Node-RED 可用于各种场景, 例如物联网、自动化、数据分析等. - **开源项目** Node-RED 是一个开源项目, 由 OpenJS Foundation 维护. - **活跃社区**: Node-RED 拥有一个活跃的社区, 用户可以在这里获取帮助、分享经验和贡献代码. - **丰富的节点库**: Node-RED 提供了丰富的节点库, 用户可以从中选择所需的节点来构建应用程序. - **跨平台**: Node-RED 支持多种操作系统, 包括 Windows、Linux 和 macOS. --- ## 其他服务 ### Captive Portal 检测服务 大家肯定都连过公共场所的 wifi 热点, 比如麦当劳等地方的. 他们的 wifi 往往一连上去就会弹出一个要求登录或者微信关注之类的页面, 只有在这个页面完成操作了才能正常访问网络的. 之前看到这个很神奇, 为什么一连 wifi, 手机就会自动打开这个网页的, 就知道 android 系统应该是提供了一些接口的. 最近接触到这个, 查了一下才知道这个东西叫做 `captive portal`, 就是专门用来给后端的网关提供鉴权计费之类的服务的. 很多公共场合的 wifi 热点应该都用了这么一个技术, 比如酒店, 商场, 银行等等. `Captive Portal` 的作用就是检测网络的连通性, 这个在分流规则中非常常见, 下面是一些常用的 `Captive Portal` 站点: | 服务提供者 | 链接 | 大陆体验 | 境外体验 | http/https | IP Version | | ---------- | ---------------------------------------------------------- | -------- | -------- | ---------- | ---------- | | Google | http://www.gstatic.com/generate_204 | 5 | 10 | 204/204 | 4+6 | | Google | http://www.google-analytics.com/generate_204 | 6 | 10 | 204/204 | 4+6 | | Google | http://www.google.com/generate_204 | 0 | 10 | 204/204 | 4+6 | | Google | http://connectivitycheck.gstatic.com/generate_204 | 4 | 10 | 204/204 | 4+6 | | Apple | [http://captive.apple.com](http://captive.apple.com/) | 3 | 10 | 200/200 | 4+6 | | Apple | http://www.apple.com/library/test/success.html | 7 | 10 | 200/200 | 4+6 | | MicroSoft | http://www.msftconnecttest.com/connecttest.txt | 5 | 10 | 200/error | 4 | | Cloudflare | http://cp.cloudflare.com/ | 4 | 10 | 204/204 | 4+6 | | Firefox | http://detectportal.firefox.com/success.txt | 5 | 10 | 200/200 | 4+6 | | V2ex | http://www.v2ex.com/generate_204 | 0 | 10 | 204/301 | 4+6 | | 小米 | http://connect.rom.miui.com/generate_204 | 10 | 4 | 204/204 | 4 | | 华为 | http://connectivitycheck.platform.hicloud.com/generate_204 | 10 | 5 | 204/204 | 4 | | Vivo | http://wifi.vivo.com.cn/generate_204 | 10 | 5 | 204/204 | 4 | 我的目的是检测 WireGuard 是否能连接家中的网络, 所以准备在家中的某台服务器上自建一个 `Captive Portal` 服务, 减少外部网络的依赖. 直接在 Nginx 配置中添加如下内容即可: ```ini server { listen 80; listen 443 ssl http2; server_name your.domain.name; location = /generate_204 { return 204; } } ``` 比如 `Captive Portal` 服务部署在家中的 `192.168.10.33` 服务器上, 测试为: ```bash $ curl -i 192.168.10.33/generate_204 HTTP/1.1 204 No Content Server: openresty Date: Tue, 03 Dec 2024 14:17:49 GMT Connection: keep-alive ``` 然后就可以在 **Surge** 的 `proxy` 配置中使用, 比如: ``` 🌤️ H28K = wireguard, section-name=H28K, test-url=http://192.168.10.33/generate_204, ip-version=v4-only ``` `test-url` 表示 **Surge** 会通过此地址去测试 **WireGuard** 隧道是否正常(因为 **WireGuard** 也部署在内网, **Surge** 会将流量通过 **WireGuard** 发送到 `192.168.10.33/generate_204`, 所以只要能够测试通过就代表隧道是通畅的. 在没有自建 `Captive Portal` 服务之前, 我都是通过访问小米路由器的 WebUI 来检测网络是否正常, 但是较多的响应内容会增加测试的延迟, 所以就使用了上述方式来优化. --- ### 公网 IP 获取服务 因为使用别家的服务来给 DDNS-GO 会出现限流的情况, 所以在 ECS 上自行部署了一个这样的服务. #### 获取 JSON 格式 ```ini server { listen 80; listen [::]:80; listen 443 ssl http2; listen [::]:443 ssl http2; # 用以支持 HTTP/3, 若所用 Nginx 版本支持 HTTP/3, 可去掉注释 # listen 443 http3; # listen [::]:443 http3; server_name ipv4.ddnsip.cn ipv6.ddnsip.cn ddnsip.cn; # 用以支持 HTTP/3, 若所用 Nginx 版本支持 HTTP/3, 可去掉注释 # add_header Alt-Svc 'h3=":443"; ma=86400'; # HSTS add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload"; # 允许跨域(在其他站点调用接口会用到) add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept"; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; # 获取 IP 地址 location / { default_type application/json; return 200 '{"ip":"$remote_addr"}'; # 若使用 CDN 请将$remote_addr改为$http_x_forwarded_for } # 证书配置 ssl_certificate /root/.acme.sh/*.ddnsip.cn/fullchain.cer; ssl_certificate_key /root/.acme.sh/*.ddnsip.cn/*.ddnsip.cn.key; ssl_session_timeout 5m; ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE; ssl_prefer_server_ciphers on; } ``` #### 获取纯文本 ```ini server { listen 80; listen [::]:80; listen 443 ssl http2; listen [::]:443 ssl http2; # 用以支持 HTTP/3, 若所用 Nginx 版本支持 HTTP/3, 可去掉注释 # listen 443 http3; # listen [::]:443 http3; server_name ipv4.ddnsip.cn ipv6.ddnsip.cn ddnsip.cn; # 用以支持 HTTP/3, 若所用 Nginx 版本支持 HTTP/3, 可去掉注释 # add_header Alt-Svc 'h3=":443"; ma=86400'; # HSTS add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload"; # 获取 IP 地址 location / { default_type text/plain; return 200 $remote_addr; # 若使用 CDN 请将$remote_addr改为$http_x_forwarded_for } # 证书配置 ssl_certificate /root/.acme.sh/*.ddnsip.cn/fullchain.cer; ssl_certificate_key /root/.acme.sh/*.ddnsip.cn/*.ddnsip.cn.key; ssl_session_timeout 5m; ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE; ssl_prefer_server_ciphers on; } ``` 这有现成的: - [https://www.ddnsip.cn/](https://www.ddnsip.cn/) - [https://ip.ddnsip.cn/](https://ip.ddnsip.cn/) --- ### 分流规则服务 我有多种客户端且在多个地方需要用到分流规则: - Surge - OpenClash - 其他终端上的 Clash 为了简化规则的管理, 需要一个集中管理分流规则和规则转换的服务, 目前选择 [Subconverter Web](https://github.com/CareyWang/sub-web) + [Sub-Store](https://github.com/sub-store-org/Sub-Store) 结合的方式来完成规则统一管理与规则转换. #### 逻辑图 ![sub.drawio.svg](https://cdn.dong4j.site/source/image/sub.drawio.svg) 1. `Subconverter Server` 作为规则转换服务, 可以根据配置生成配置文件, 可选择客户端类型以及远程配置: ![20241229154732_VfhO8Aar.webp](https://cdn.dong4j.site/source/image/20241229154732_VfhO8Aar.webp) - 可以预先设置几个常用的配置, 而 **自定义远程配置地址** 可以直接使用 Nginx 搭建一个静态网站, 随时修改规则; - 转换后可以使用 **MyUrls** 短链服务自动生成短链接, 方便分享与使用; 2. 参数中可设置: Emoji, 是否开启 UDP, 排序节点, **关闭证书检查** 以及是否只返回节点列表. 因为有些订阅地址如果证书过期, sub-store 会无法解析, 所以需要先使用 `Subconverter Server` 处理一次(关闭证书检查), 然后将短链接给到 `sub-store` 使用. 3. 转换后的短链接会直接配置到 Clash 和 OpenClash 中, 保证多端规划一致; 4. sub-store 主要作用是收集整理订阅地址返回的节点列表, 虽然 `Subconverter Server` 也支持这个功能, 但是`sub-store` 拥有更多的节点配置功能, 比如 正则过滤, 区域过滤, 正则删除, 节点去重等等功能: ![20241229154732_wQmAUaFA.webp](https://cdn.dong4j.site/source/image/20241229154732_wQmAUaFA.webp) 5. `sub-store` 处理后的节点会给 Surge 使用(Surge 目前是单独的规则与配置, 主要是主力机在使用, 所以特殊的配置要多一些, 而 Clash 和 OpenClash 则是给内网的设备使用, 规则相对简单一些, 所以需要 `Subconverter Server` 去统一管理规则, 避免重复操作), `policy-path` 中配置 `sub-store` 的节点地址即可: ![20241229154732_e22kWb9J.webp](https://cdn.dong4j.site/source/image/20241229154732_e22kWb9J.webp) 6. `sub-store` 会在多台服务器上部署, 然后使用 Nginx 来进行负载, 而 `sub-store` 的数据文件则通过 **Syncthing** 在动态服务器之间同步; 7. `sub-store` 还有一个 Surge 版本, 需要在 Surge 客户端上安装相应的模块, 目前用的比较少, 主要是因为会存在循环依赖问题: 更新配置需要开启代理, 而开代理又必须先得更新配置..... #### 部署 `Subconverter Web`, `Subconverter Server` 和 `MyUrls` 可通过 [docker-compose 一键部署](https://github.com/stilleshan/dockerfiles/tree/main/sub). 如果只需要在内网使用, 记得修改一下 `config.js` 中的 `apiUrl` 和 `shortUrl`, docker-compose.yml 中的 `myurls.environment.MYURLS_DOMAIN` 也要做相应修改. 可以挂载下面的目录, 方便修改规则与其他配置: ```yaml services: subconverter: image: stilleshan/sub container_name: sub volumes: - ./conf:/usr/share/nginx/html/conf # 规则所在目录 - ./data/subconverter:/base - ./data/subweb:/usr/share/nginx/html ... ``` `Sub-Store` docker-compose 部署: ```yaml version: "3" services: sub-store: image: instartlove/sub-store container_name: sub-store restart: unless-stopped ports: - "8080:80" volumes: - ./data/root.json:/app/root.json - ./data/sub-store.json:/app/sub-store.json ``` #### 使用方式 `Subconverter` 和 `Sub-Store` 的功能侧重点不一样, 可以说是相辅相成的, 可以总结为: 1. 非 Surege 客户端使用 `Subconverter` 统一规则; 2. Surege 客户端只使用 `Sub-Store` 节点处理功能; 3. `Subconverter` 处理 `Sub-Store` 无法解析的 HTTPS 证书失效的订阅地址; 所以会结合两者的优点与侧重点按场景使用: 1. `Sub-Store` 使用原始订阅地址处理节点列表, 提供给 Surge 客户端使用; 2. 如果 HTTPS 证书失效, 会将原始订阅地址先使用 `Subconverter` 处理一次, 参数使用 **关闭证书检查** 并只返回节点列表, 然后将短链接给到 `Sub-Store` 使用; 3. `Subconverter` 使用 `Sub-Store` 处理后的节点列表, 配合自定义规则生成 `Clash` 和 `OpenClash` 的通用配置, 保证两端规则一致; `Sub-Store` 和 `Subconverter` 同时具备将多个订阅地址合并为同一个配置的功能(组合订阅), 我的方式是在 `Sub-Store` 中将节点合并, 然后将合并后的节点列表给到 `Subconverter` , 根据客户端类型选择不同的规则生成配置文件: ![20241229154732_7f0XSl03.webp](https://cdn.dong4j.site/source/image/20241229154732_7f0XSl03.webp) #### 使用场景 比如我有 3 个订阅地址, 分别是 A, B, C, 其中 C 的 HTTPS 证书过期, 以及一个自定义的 home 节点列表: ![20241229154732_OFztI62Y.webp](https://cdn.dong4j.site/source/image/20241229154732_OFztI62Y.webp) 分别在不同场景下使用 `Sub-Store` 和 `Subconverter`. ##### 在公司的设备上使用 ![sub-company.drawio.svg](https://cdn.dong4j.site/source/image/sub-company.drawio.svg) 公司的设备(R2S) 需要访问家中的网络与外网, 所以使用了 AIO + 自定义 Home 节点列表, 然后使用 `Subconverter` 的组合订阅功能生成最终配置. ##### 在家中的设备上使用 家中的设备不需要 Home 节点, 所以直接是用 AIO 节点列表, 然后使用 `Subconverter` 生成最终配置. ##### 在 Surge 客户端上使用 Surge 只需要 AIO 节点列表, 不需要 `Subconverter` 生成配置. #### 配置同步 配置同步只会涉及到 `Sub-Store`, 它自带通过 `Gist` 同步, 但是需要手动点击下载才能同步, 因为我在多个服务器上都部署了 `Sub-Store` , 所以这种同步方式不太优雅. `Sub-Store` 容器暴露出来文件只有 2 个, `root.json`是节点配置, `sub-store.json` 是服务配置, 所以我只需要将这 2 个文件同步到其他服务器即可, 最方便的就是是用 **Syncthing**. > **Syncthing** 相关的数据同步方案将在 [[homelab-data|数据篇]] 详细介绍. --- ### 网络 #### 连通性检查 通过 Ping 去检测网络是否正常, 如果不正常就要通知 Uptime Kuma ```bash #!/bin/bash # 检测网络是否畅通 function ping_domain() { # ping的域名或者DNS(阿里云的) local domain=223.5.5.5 # ping的次数 local tries=6 # 请求成功次数 local packets_responded=0 for i in $(seq 1 $tries); do if ping -c 1 $domain >/dev/null; then ((packets_responded++)) sleep 1 fi done # 如果请求成功总次数大于2, 则表示成功 if [ $packets_responded -ge 2 ]; then echo "true" else echo "false" fi } # 检测网络连接函数 function check_network() { # 如果ping 6次至少有2次包未响应, 则执行一下代码 if [ $(ping_domain) = "false" ]; then # 如果N无法连接网络, 则重启网络 echo "$(date '+%Y-%m-%d %H:%M:%S') 网络连接失败" curl {uptime-kuma webhook} else echo "$(date '+%Y-%m-%d %H:%M:%S') 网络连接正常" curl {uptime-kuma webhook} fi } check_network echo "$(date '+%Y-%m-%d %H:%M:%S') network 检查完毕" ``` #### WireGuard 检查 可以传入不同的 WireGuard 端口来检测多个 WireGuard 服务, 顺带检测一下 IPSec 服务. ```bash #!/bin/sh port=$1 # 检查 WireGuard 端口是否存在 WireGuard=$(netstat -an | egrep ":${port}" | awk '$1 == "udp" && $NF == "0.0.0.0:*" {print $0}' | wc -l) # 检查 IPSec 的端口是否存在 IPSec=$(netstat -an | egrep ":4500" | awk '$1 == "udp" && $NF == "0.0.0.0:*" {print $0}' | wc -l) if [ "$WireGuard" == 0 ]; then curl {uptime-kuma webhook} else curl {uptime-kuma webhook} fi if [ "$IPSec" == 0 ]; then curl {uptime-kuma webhook} else curl {uptime-kuma webhook} fi ``` #### VPN 检查 检查外部网络是否正常 ```bash #!/bin/bash # 检测网络是否畅通 function curl_domain() { http_code=$(curl -sIL --connect-timeout 5 -w "%{http_code}\n" -o /dev/null http://www.v2ex.com/generate_204) if [ "$http_code" != "204" ]; then echo 'OpenClash 未正常工作' curl {uptime-kuma webhook} else echo 'OpenClash 正常' curl {uptime-kuma webhook} fi } curl_domain ``` ## 资源推荐 - [Awesome-Selfhosted](https://awesome-selfhosted.net/): 包含超过 1500 个自托管的开源软件项目, 涵盖各种领域, 如博客、论坛、社交媒体、邮件服务、数据库、办公软件、媒体服务器、游戏等. ![20241229154732_fc7ddJ4c.webp](https://cdn.dong4j.site/source/image/20241229154732_fc7ddJ4c.webp) - [Awesome Homelab](https://www.awesome-homelab.com/): 收录超过 150 个开源应用, 涵盖智能家居、实验室建设、效率提升等多个方面. ![20241229154732_3zWlmZGe.webp](https://cdn.dong4j.site/source/image/20241229154732_3zWlmZGe.webp) - [theme.park](https://theme-park.dev/): 包含多款自托管服务主题: ![20241229154732_iG1943kQ.webp](https://cdn.dong4j.site/source/image/20241229154732_iG1943kQ.webp) **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## [NAT 内网穿透详解:揭秘网络连接背后的奥秘](https://blog.dong4j.site/posts/46fb4e89.md) ![/images/cover/20241229154732_ygRx9mbO.webp](https://cdn.dong4j.site/source/image/20241229154732_ygRx9mbO.webp) ## 前言 NAT (网络地址转换, Network Address Translation) 的由来与互联网的发展历史密切相关, 主要是为了解决 IPv4 地址资源紧张问题, 同时增强网络安全性和管理灵活性. **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ### 背景与由来 #### 1. IPv4 地址枯竭问题 IPv4 使用 32 位地址空间, 最多可以提供约 43 亿个唯一地址. 随着互联网的普及, 联网设备数量迅速增长, 特别是 1990 年代后期, 地址分配紧张的问题愈发明显. 早期解决方案如子网划分和无类别域间路由 (CIDR) 延缓了地址耗尽, 但并不能彻底解决问题. #### 2. 私有网络需求 - 很多组织和企业需要将大量内部设备联网, 但没有足够的公有 IPv4 地址. - [RFC 1918](https://www.rfc-editor.org/rfc/rfc1918) 在 1996 年提出了私有地址标准, 为内部网络预留了一些专用地址段: - `10.0.0.0/8` - `172.16.0.0/12` - `192.168.0.0/16` - 私有地址无法直接通过互联网通信, 需要一个机制将其转换为公有地址. #### 3. NAT 的诞生 - NAT 概念最早在 1994 年由 IETF 提出, 记录在 [RFC 1631](https://www.rfc-editor.org/rfc/rfc1631) 中, 作为一种解决 IPv4 地址短缺问题的方法. - NAT 允许多个设备通过一个或少量的公有 IP 地址访问互联网. 它通过在路由器或防火墙上修改 IP 数据包的源地址或目标地址, 实现地址的映射. ### NAT 的作用 1. **缓解地址枯竭**: 内部设备使用私有地址, 多个设备可以共享一个公有地址访问互联网. 2. **提高安全性**: 外部无法直接访问内部设备, 形成一定的网络隔离. 3. **网络管理灵活性**: 组织内部地址可以自行规划, 避免更改 ISP 时需调整内部网络结构. ### NAT 的类型 1. **静态 NAT**: 一个私有地址映射到一个固定的公有地址, 常用于需要外部访问的设备. 2. **动态 NAT**: 一组私有地址动态映射到一组公有地址, 基于需求分配. 3. **端口地址转换 (PAT/NAT Overload)**: 使用单个公有 IP 地址, 通过端口号区分多个内部设备的连接, 是最常见的 NAT 类型. ### 后续发展 - 随着 **IPv6** 的推出, NAT 的角色开始被重新审视. IPv6 提供了 128 位地址空间, 几乎可以为每台设备分配一个唯一地址. - 然而, 由于 IPv6 的部署进展缓慢, NAT 仍然广泛应用于 IPv4 网络, 并在双栈环境下继续扮演重要角色. --- ### 总结 NAT 的诞生充分体现了互联网早期技术对现实问题的灵活应对. 即使未来 IPv6 普及, NAT 的概念在某些场景 (如隐私保护) 中仍可能保留其价值. 接下来, 我将详细介绍 NAT 的实现细节, 包括 NAT 表的构建、地址转换过程, 以及如何通过 NAT 实现内网穿透. ## 概述 在我们家中的局域网内部, 网络设备通常都使用所谓的**私有 IP 地址**, 例如`192.168.xx.xx`. 当我们在网络上发送数据包时, 如果填写的是这样的私有 IP 地址, 那么在网络收到回复的数据包时可能会遇到一个难题:因为成千上万的用户都可能在使用同样的私有 IP 地址——比如`192.168.31.2`. 网络无法仅凭这些内部地址来判断究竟应该将数据包转发给哪一个设备. ![20241229154732_LxYJaHYG.webp](https://cdn.dong4j.site/source/image/20241229154732_LxYJaHYG.webp) 因此, 为了使局域网内的私有 IP 能够向外部公有 IP 发送和接收信息, 我们需要进行一种称为**网络地址转换 (NAT)**的机制. 简单来说就是将**内部的私有 IP 地址转换为外部的公有 IP 地址的过程**. 在局域网与互联网之间, 通常会有一个专门的设备或服务来执行这一转换, 它确保了即使多个内部设备共享相同的私有 IP 地址, 也能正确地接收和发送数据. 这个转换过程可以在路由器、防火墙或者 NAT 设备中完成. ## NAT 的工作原理 在你的家庭网络中, 你拥有了一个独立的公网 IP 地址 `20.20.20.20`, 并且这个 IP 地址已经配置在你家内置了 NAT 功能的路由器上. 你的手机、电脑以及其他需要连接到互联网的设备都组成了一个局域网, 每个设备都有一个私有 IP 地址. ### 发送流程 当你想在电脑上访问公网上的一个特定服务, 比如 IP 地址为 `30.30.30.30` 的服务时, 你需要通过 NAT 路由器来完成这个请求. 当你使用命令行工具 (如 ifconfig) 在你的电脑上查看网络配置时, 你会看到你的电脑的私有 IP 地址 `192.168.31.2`: ![20241229154732_u4I1HElD.webp](https://cdn.dong4j.site/source/image/20241229154732_u4I1HElD.webp) 在准备发送数据包的过程中, 你的电脑内核协议栈会构建一个包含源 IP 地址 `192.168.31.2` 和目标 IP 地址 `30.30.30.30` 的 IP 数据包. 这个 IP 数据包的报头包含了关于如何将数据包从源点传输到终点的全部信息. 当这个 IP 数据包被发送到 NAT 路由器时, 路由器的网络地址转换 (SNAT) 功能会介入: 将原始数据包中的私有 IP 地址 `192.168.31.2` 替换为分配给你的公网 IP 地址 `20.20.20.20`, 这个过程称为**源地址转换** (Source Network Address Translation) . 同时, NAT 路由器会记住这个映射关系, 即在内部维护一张映射表, 记录着私有 IP 地址和公网 IP 地址之间的对应关系. 这条映射信息对于后续将响应数据包正确地转发回原始设备至关重要. 修改后的数据包现在包含源 IP 地址 `20.20.20.20` 和目标 IP 地址 `30.30.30.30`, 并经过互联网上的多个路由器进行传输和转发. 这些路由器根据数据包中的目标 IP 地址来决定如何将数据包发送到下一个目的地. 到这里**发送流程**结束. ### 响应流程 ![20241229154732_vKx8zBTH.webp](https://cdn.dong4j.site/source/image/20241229154732_vKx8zBTH.webp) 当接收端处理完你的请求并发送响应时, 它会在其构造的数据包中填写自己的 IP 地址作为源地址, 即 `30.30.30.30`, 并将目标地址设置为你的公网 IP 地址, 即 `20.20.20.20`. 这个修改后的数据包会被发送回 NAT 路由器. NAT 路由器接收到来自公网的响应后, 会检查其内部维护的映射记录表. 在这个表中, 它会找到一个与公网 IP 地址 `20.20.20.20` 相关联的私有 IP 地址, 即之前留下的映射记录 `192.168.31.2 -> 20.20.20.20`. NAT 路由器根据这条映射信息知道原始数据包是从你的电脑发送出去的. 因此, 它会执行**目的地址转换** (Destination Network Address Translation, DNAT) 功能, 将数据包的目标 IP 地址从公网 IP 地址 `20.20.20.20` 修改为内部网络的私有 IP 地址 `192.168.31.2`. 随后, NAT 路由器会将这个修改后的数据包转发回局域网内的你的电脑. 你的电脑收到数据包后, 会根据其目标 IP 地址识别出这是一个针对它的响应. > NAT (网络地址转换) 在用户毫不知情的情况下, 隐秘地改变了 IP 数据包中的源地址和目标地址. 对于原始的发送者和接收者而言, 这种更改是完全看不见的. 这正是 NAT 运作的核心机制:**它确保了即使是使用私有 IP 地址的设备也能够安全、有效地与互联网上的任何公网 IP 进行通信, 而无需每个设备都有一个唯一的公网 IP 地址. ** --- ## NAPT 的工作原理 ### 概述 在了解了 NAT 的工作原理后, 你可能会产生一个疑问:局域网中不仅有你的电脑, 还有其他设备如手机、平板等, 它们各自的映射记录也将是 `192.168.xx.xx -> 20.20.20.20`. 这看起来没问题, 但当我们需要接收响应时, NAT 路由器将如何知道哪个响应应该发送给哪一个内部的设备呢? 这个问题确实很重要, 因为如果 NAT 无法区分内部网络的多个连接, 那么它就无法正确地将外部的响应数据包转发回特定的内部设备. 为了解决这个问题, 我们需要引入额外的信息来标识局域网内的每个网络连接. 一个简单而有效的解决方案是使用端口. 每个内部设备在发起网络请求时, 都会分配一个唯一的端口号, 这通常是通过传输控制协议 (TCP) 或用户数据报协议 (UDP) 的源端口实现的. 这样, NAT 路由器就可以根据这些端口号来区分不同的内部设备, 即使它们的私有 IP 地址相同. ### NAPT 原理解析 IP 数据包 (在网络层的协议) 不包含端口号信息. 端口号信息是存储在传输层 (如 TCP 或 UDP) 的数据报头中的. 当计算机发送一个 IP 数据包时, 它会在网络层包含源 IP 地址和目标 IP 地址. 但在传输层, TCP 或 UDP 协议会添加额外的头部, 其中包括源端口号和目标端口号. ![20241229154732_hDXcxREr.webp](https://cdn.dong4j.site/source/image/20241229154732_hDXcxREr.webp) ![20241229154732_RQZrPRqR.webp](https://cdn.dong4j.site/source/image/20241229154732_RQZrPRqR.webp) 于是流程就变成了下面这样子. 当你准备发送数据包的时候, 你的电脑内核协议栈就会先构造一个 TCP 或者 UDP 数据报头, 里面写入端口号, 比如发送端口是`5000`, 接收端口是`3000`, 然后在这个基础上, 加入 IP 数据报头, 填入发送端和接收端的 IP 地址. 那数据包长这样. ![20241229154732_2jxPYBwL.webp](https://cdn.dong4j.site/source/image/20241229154732_2jxPYBwL.webp) --- ### 数据包传输流程 假设, **发送端**IP 地址填的就是`192.168.31.2`, **接收端**IP 地址就是`30.30.30.30`. 将数据包发到 NAT 路由器中. 此时 NAT 路由器会将 IP 数据包里的**源 IP 地址和端口号**修改一下, 从`192.168.31.2:5000`改写成`20.20.20.20:6000`. 并且还会在 NAT 路由器内部留下一条 `192.168.31.2:5000 -> 20.20.20.20:6000`的映射记录. 之后数据包经过公网里各个路由器的转发, 发到了接收端`30.30.30.30:3000`, 到这里**发送流程**结束. ![20241229154732_xCgdZUIZ.webp](https://cdn.dong4j.site/source/image/20241229154732_xCgdZUIZ.webp) 当接收端准备发送响应时, 它会在数据包中写入原始请求源的 IP 地址 (即你的公网 IP `20.20.20.20`) 作为源地址, 以及目标 IP 地址 (即 NAT 路由器的内部端口 `6000`) , 因为这是它接收到数据的来源. 然后, 这个修改后的数据包会被发送回 NAT 路由器. NAT 路由器在处理这个响应时, 会查找到之前留下的映射记录:`192.168.31.2:5000 -> 20.20.20.20:6000`. 基于这个记录, NAT 会将数据包的目的 IP 地址和端口从 `20.20.20.20:6000` 修改回你的私有网络内的 IP 地址 `192.168.31.2` 以及对应的端口号 `5000`, 之后将其转发给你的电脑上. ![20241229154732_pOnKvvT7.webp](https://cdn.dong4j.site/source/image/20241229154732_pOnKvvT7.webp) 如果局域网内有多个设备, 他们就会映射到不同的公网端口上, 毕竟端口最大可达 65535, 完全够用. 这样大家都可以相安无事. 像这种同时转换 **IP 和端口**的技术, 就是**NAPT** (Network Address Port Transfer , **网络地址端口转换** ) . --- ### Ping 命令 这里出现了一个疑问. 通常我们认为只有那些包含端口信息的网络协议才能被 NAT (网络地址转换) 识别并进行转发. 然而, 这如何解释 ping 命令的工作原理呢?ping 命令是基于 ICMP 协议的, 而 ICMP 协议的报文中并不包含端口信息. 尽管如此, 我们仍然能够正常地使用 ping 命令与公网上的机器通信, 并接收到回应包. 这是怎么回事呢? ![20241229154732_e6uSRrHO.webp](https://cdn.dong4j.site/source/image/20241229154732_e6uSRrHO.webp) **实上针对 ICMP 协议, NAT 路由器做了特殊处理. ping 报文头里有个`Identifier`的信息, 它其实指的是放出 ping 命令的进程 id**. 对 NAT 路由器来说, 这个`Identifier`的作用就跟`端口`一样. > 另外, 当我们去抓包的时候, 就会发现有两个`Identifier`, 一个后面带个`BE (Big Endian) `, 另一个带个`LE (Little Endian) `. 其实他们都是**同一个数值**, 只不过**大小端不同**, 读出来的值不一样. 就好像同样的数字 345, 反着读就成了 543. 这是为了兼容不同操作系统 (比如 linux 和 Windows) 下大小端不同的情况. ![20241229154732_c6rHtCc5.webp](https://cdn.dong4j.site/source/image/20241229154732_c6rHtCc5.webp) --- ## 内网穿透 ### 概述 当使用 NAT (网络地址转换) 上网时, 一个基本的前提是内网机器必须主动向公网 IP 发起请求, 这样 NAT 才能将内网的 IP 地址和端口映射到外网的 IP 地址和端口上. 然而, 如果公网上的机器试图主动连接到内网机器, 这个请求将会在 NAT 路由器处受阻. 由于 NAT 路由器上没有相应的 IP 地址和端口映射记录, 因此它不会将数据转发到内网的任何机器. 例如, 假设你在家里电脑上启动了一个 HTTP 服务, 地址是`192.168.31.2:5000`, 但你在公司办公室试图通过手机访问这个服务时, 会发现无法连接. 这就引出了一个问题:有没有办法让外网机器访问内网的服务呢?答案是肯定的. 有一句话说得好, **任何问题都可以通过增加一个中间层来解决, 如果不行, 那就再加一层.** 在这个情况下, 这个原则同样适用. 归根结底, 由于 NAT (网络地址转换) 的存在, 我们通常只能从内网向外网主动发起连接. 如果内网机器没有首先建立连接, NAT 设备就不会创建相应的映射关系, 而没有这个映射关系, 外网的数据就无法被转发到内网. 为了解决这个问题, 我们可以在公网上部署一台服务器 x, 并为其分配一个可访问的域名. 然后, 让内网的服务主动连接到这台服务器 x, 这样 NAT 路由器就会建立相应的映射关系. 之后, 任何外网的访问请求都可以发送到服务器 x, 服务器 x 再将这些请求转发到内网机器, 并将内网机器的响应返回给请求者, 从而实现数据的双向通信. 这个过程就是我们所说的内网穿透. 至于服务器 x, 你并不需要自己搭建, 市面上已经有许多现成的内网穿透服务. 比如使用某壳提供的内网穿透解决方案, 或者开源的内网穿透服务: - [**frp (Fast Reverse Proxy)**](https://github.com/fatedier/frp) - [**ngrok**](https://github.com/inconshreveable/ngrok) - [**nps (Network Proxy Server)**](https://github.com/ehang-io/nps) - [**lanproxy**](https://github.com/ffay/lanproxy) ![20241229154732_y9pPR1xV.webp](https://cdn.dong4j.site/source/image/20241229154732_y9pPR1xV.webp) 到这里, 我们就可以回答这个问题:**为什么我在公司里访问不了家里的电脑**? 那是因为家里的电脑在局域网内, 局域网和广域网之间有个 NAT 路由器. 由于 NAT 路由器的存在, 外网服务无法主动连通局域网内的电脑. --- ### 即时通讯软件的网络通信机制 我家电脑位于我们小区的局域网中, 而老婆闺蜜家的电脑则位于她们小区的局域网内. 既然两台电脑都处于各自的局域网中, 且 NAT 只允许内网设备主动连接到外网, 那我电脑上登录的 QQ 是如何与老婆闺蜜电脑上的 QQ 建立连接的呢? ![20241229154732_fWxM3JVM.webp](https://cdn.dong4j.site/source/image/20241229154732_fWxM3JVM.webp) 这种提问方式隐含了一个错误的假设, 即认为两个 QQ 客户端应用能够直接建立点对点的连接. 但实际情况并非如此, 两个 QQ 客户端之间的通信是间接的, 它们之间还有一个中间环节, 那就是通讯软件服务器: ![20241229154732_aI9dT9wY.webp](https://cdn.dong4j.site/source/image/20241229154732_aI9dT9wY.webp) 换言之, 当两个位于内网的 QQ 客户端登录时, 它们都会主动与公网上的通讯服务器建立连接, 此时各自的 NAT 路由器会记录下相应的映射条目. 当一个客户端通过 QQ 发送消息时, 这些数据首先会传输到服务器, 然后由服务器将其转发到另一个客户端. 反之亦然, 通过这种机制, 两台处于内网的计算机得以实现数据的交换. --- ### 点对点 (P2P) 网络通信 在 P2P 下载等双向通信场景中, 通常会涉及到 NAT 穿越的问题. 假设还是 A 和 B 两个**局域网内**的机子, A 内网对应的 NAT 设备叫`NAT_A`, B 内网里的 NAT 设备叫`NAT_B`, 和一个第三方服务器`Server`. 通过第三方服务器帮助两台位于不同局域网的机器 A 和 B 建立连接的简化流程如下: 1. **Step1 + Step2**: A 机器首先主动连接第三方服务器, 其 NAT 设备 (NAT_A) 记录下 A 的内网和外网地址的映射关系. 同时, `Server` 记下了 A 的外网 IP 和端口. 2. **Step3 + Step4**: B 机器也进行同样的操作, 主动连接服务器, NAT_B 记录下 B 的内网和外网地址映射, `Server` 同样获取了 B 的外网 IP 和端口. 3. **Step5 + Step6 + Step7**: 关键步骤来了, `Server` 通知 A, 让 A 主动向 B 的外网 IP 和端口发送 UDP 消息. 此时 NAT_B 收到这个 A 的 UDP 数据包时, 这时候**根据 NAT_B 的设置不同** 可能存在 2 种情况: 1. NAT_B 上没有关于 A 的映射关系导致直接丢包, 不过丢包没关系, 这个操作的 **目的是** 给 NAT_A 上留下 **有关 B 的映射关系**; 2. 有可能因为延迟或消息达到的顺序问题, NAT_B 已经有了关于 A 的映射关系, 此时 A 和 B 就能正常通信了; 4. **Step8 + Step9 + Step10**: 跟 5,6,7 步骤一样, `Server`通知 B, 让 B 向 A 的外网 IP 和端口发送 UDP 消息. NAT_B 上也留下了关于 A 的映射关系, 这时候由于之前 NAT_A 上有过关于 B 的映射关系, 此时 NAT_A 就能正常接受 B 的数据包, 并将其转发给 A. 到这里 A 和 B 就能正常进行数据通信了. 这就是所谓的 **NAT 打洞**. 5. **Step11**: 注意, 之前我们都是用的 **UDP 数据包**, 目的只是为了在两个局域网的 NAT 上**打个洞**出来, 实际上大部分应用用的都是 TCP 连接, 所以, 这时候我们还需要 A 主动向 B 发起 TCP 连接(比如我们熟知的 **WireGuard**). 到此, 我们就完成了两端之间的通信. ![20241229154732_fq5jEgvm.webp](https://cdn.dong4j.site/source/image/20241229154732_fq5jEgvm.webp) 可能会有这样的疑问:既然 UDP 已经使用了一个端口, 那么 TCP 再使用相同的端口, 会不会出现端口重复占用的错误? 实际上, 这种情况并不会发生. 端口重复占用的错误通常出现在两个 TCP 连接没有使用 **SO_REUSEADDR** 选项, 并且尝试在同一个 IP 地址上使用相同的端口. 然而, UDP 和 TCP 之间并不会引发这样的错误. 原因在于, 在 Linux 内核中, 网络数据包是根据五元组 (**传输协议**、**源 IP**、**目的 IP**、**源端口**、**目的端口**) 来唯一确定接收者的. 当两个连接的五元组完全相同时, 内核就无法判断数据应该发送给哪个连接. 但由于 UDP 和 TCP 的 **传输协议** 不同, 即使它们使用了相同的 IP 地址和端口, 五元组也是不同的. 因此, UDP 和 TCP 可以共享相同的端口而不会发生冲突. --- ### UDP 打洞的局限性 从 2019 年开始, 一些传统的网络穿透技术 (如 pwnat) 不再有效. [pwnat](https://github.com/samyk/pwnat) 是一种创新的网络穿透方法, 它允许设备在没有代理服务器、第三方服务器、UPnP、DMZ 设置、源端口欺骗或 DNS 转换的情况下, 实现 NAT 环境下的点对点通信. **工作原理** **1. 服务端的操作** - **模拟发送请求:** 服务端启动后, 会不断向一个固定的、无法访问的 IP 地址 (比如 3.3.3.3) 发送 **ICMP 回显请求包** (ICMP echo request) . - 注意:3.3.3.3 只是一个占位符, 服务端不是真的期待从这个地址收到任何回应. - 这些请求包会被服务端的 NAT 记录为 "有一个数据包正在发往 3.3.3.3, 等待返回数据": `服务端内网 IP:端口 → NAT 公网 IP:端口 → 3.3.3.3` - **核心目标:** 服务端的 NAT 设备在等待这些 ICMP 回显请求包的响应, 但由于 3.3.3.3 并不存在, 响应包自然不会到达. **2. 客户端的操作** - **伪装成一个跳跃点:** 当客户端希望连接服务端时, 客户端需要知道 **服务端的 IP 地址**. 然后, 客户端发送一个 **ICMP 超时包** (ICMP Time Exceeded) 到服务端. - 这个超时包伪装成某个网络节点的响应, 告诉服务端:“你发往 3.3.3.3 的 ICMP 回显请求包超时了, 无法到达目的地. ” - 关键点在于, 客户端发送的超时包中, 包含了服务端原始发送的 ICMP 回显请求包的完整信息 (原始数据包的内容) . **3. NAT 的行为** - **识别匹配的请求:** 当客户端发送的 ICMP 超时包到达服务端的 NAT 时, NAT 会检查包的内容, 发现里面嵌套了服务端最初发往 3.3.3.3 的 ICMP 请求包. - **智能转发:** NAT 认为这个 ICMP 超时包是和服务端最初的 ICMP 请求包相关联的回应, 于是将包转发给 NAT 后面的服务端. **4. 服务端获取客户端的 IP 地址** - **提取 IP 信息:** 服务端收到客户端伪装的 ICMP 超时包后, 从中提取出客户端的真实 IP 地址和端口. - **完成连接:** 服务端和客户端此时可以通过提取的地址建立点对点连接. ![20241229154732_loEPi3DG.webp](https://cdn.dong4j.site/source/image/20241229154732_loEPi3DG.webp) - 服务端(`192.168.1.2`) 启动后它会开始向固定地址 3.3.3.3 发送 **固定的 ICMP 请求数据包**, 同时会创建一个 NAT 记录: `ICMP: 192.168.1.2:Identifier -> 40.40.40.40:xxx -> 3.3.3.3`, 这个请求数据包在经过 NAT 转换后的内容为: ``` IP Header ------------------------ 源 IP: 40.40.40.40 目标 IP: 3.3.3.3 ICMP Header ------------------- Type: 8 (Echo Request) Code: 0 Identifier: 1111 (用于区分会话, pwnat 使用的固定值, 方便与客户端发送的 ICMP 超时包匹配) Sequence Number: 2222 (用于追踪包的顺序, pwnat 使用的固定值, 方便与客户端发送的 ICMP 超时包匹配) ``` - 客户端(`192.168.31.2`) 在只知道服务端对应的公网 IP (`40.40.40.40`) 的情况下发送 **伪造的超时包**, 经过 NAT 转换后的内容为: ``` IP Header (伪造的超时包) ------------------------ 源 IP: 20.20.20.20 目标 IP: 40.40.40.40 ICMP Header (超时包) ------------------- Type: 11 (Time Exceeded) Code: 0 Data: (服务端原始发出的 ICMP 回显请求的 IP 和 ICMP 头) IP Header ------------------------ 源 IP: 40.40.40.40 目标 IP: 3.3.3.3 ICMP Header ------------------- Type: 8 (Echo Request) Code: 0 Identifier: 1111 (用于区分会话, pwnat 使用的固定值, 方便与客户端发送的 ICMP 超时包匹配) Sequence Number: 2222 (用于追踪包的顺序, pwnat 使用的固定值, 方便与客户端发送的 ICMP 超时包匹配) ``` 因为 ICMP 的请求和响应包匹配依赖于 **IP 层的源 IP 和目标 IP** 以及 **ICMP 数据包头中的字段**:Type、Code、Identifier 和 Sequence Number. 最主要的是构建 Identifier 和 Sequence Number, 因为前面服务器端发送的都是固定的 ICMP 请求数据包, 所以这里就非常容易伪造超时包. - 伪造的数据包首先达到服务器前面的 NAT 设备, 在检查内后发现与服务端之前发往 `3.3.3.3` 的请求相关联, 认为这是一个合法的响应. - 因为 NAT 设备 已经有相关的 NAT 记录了(`ICMP: 2.168.1.2:Identifier -> 40.40.40.40:xxx -> 3.3.3.3`), 就会把目标 IP 为 `40.40.40.40` 的数据包转发给 `192.168.1.2`. --- ## NAT 类型 ### 简介 NAT 分为两大类, 基本的 NAT 和 NAPT (即 **端口 NAT**, 英文全称为 **Network Address/Port Translator**) . ![20241229154732_JGgmpHKR.webp](https://cdn.dong4j.site/source/image/20241229154732_JGgmpHKR.webp) ### 基本 NAT 与 NAPT #### 基本 NAT 这种类型的 NAT (Basic NAT) 主要是进行 IP 地址的转换, 它将私有网络中的 IP 地址映射到公网上的一个或多个公网 IP 地址. 它的可以分为: - **静态 NAT (Static NAT)**: 静态 NAT 是一种将内部网络的私有 IP 地址映射到一个固定的公有 IP 地址的方法. 这种映射关系是由网络管理员手动配置的, 并且在映射建立后不会改变.特点为: - **一对一映射**:每个私有 IP 地址都对应一个唯一的公有 IP 地址. - **永久性**:映射关系是永久性的, 除非管理员手动更改配置. - **动态 NAT (Dynamic NAT)**: 动态 NAT 是一种将内部网络的私有 IP 地址动态地映射到公有 IP 地址池中的地址的方法. 映射关系不是永久固定的, 而是根据需要动态分配和释放. 特点为: - **多对多映射**:私有 IP 地址可以映射到公有 IP 地址池中的任何一个地址. - **临时性**:映射关系在会话结束时释放, IP 地址可以重新分配给其他内部主机. #### NAPT 也称为端口 NAT (Network Address/Port Translator) , 是一种更高级的 NAT 形式. 它不仅转换 IP 地址, 还转换传输层协议的端口号. NAPT 允许多个内部私有 IP 地址共享同一个公网 IP 地址, 通过不同的端口号来区分不同的数据流. 这是最常见的 NAT 类型, 广泛用于家庭和小型企业网络中, 因为它可以有效地解决公网 IP 地址不足的问题. NAPT 又分为**锥型 (Cone) 和对称型 (Symmetric)**: - **锥型 NAT (Cone NAT)**: 它在 NAT 转换时, 内部主机的私有 IP 地址和端口号与公网 IP 地址和端口号之间的映射关系是一致的, 不依赖于通信的目的地址. - **特点**:**映射一致性**, 一旦内部主机的私有端口映射到公网的一个端口, 无论目的地址是什么, 这个映射关系都保持不变. 如果 client A 使用内网端口 123 与 server A 通信, 并且 NAT 将公网端口 456 分配给 client A 的端口 123, 那么当 client A 使用同一个端口 123 向任何其他服务器 (如 server B) 发起通信时, NAT 仍然会将公网端口 456 分配给 client A 的端口 123. - **对称型 NAT (Symmetric NAT)**: 是一种更严格的 NAPT, 它在进行 NAT 转换时, 不仅考虑内部主机的私有 IP 地址和端口号, 还考虑通信的目的地址. 对于每个新的目的地, NAT 可能会分配一个新的公网端口. - **特点**:**映射依赖性**, 映射关系依赖于目的地址. 即使内部主机的源端口相同, 如果目的地址不同, NAT 也会分配不同的公网端口. 如果 client A 已经使用内网端口 123 与 server A 通信, 并且 NAT 将公网端口 456 分配给这个通信, 那么当 client A 想要与 server B 通信时, NAT 可能会分配一个新的公网端口 (如 789) 给同一个内网端口 123. **锥型 NAT 与 对称型 NAT 的区别:** - **锥型 NAT (Cone NAT)**:映射关系与目的地址无关, 只要源地址 (内部主机的 IP 和端口) 相同, 映射关系就相同. - **对称型 NAT**:映射关系不仅与源地址有关, 还与目的地址有关. 即使是同一个源地址, 如果目的地址不同, 映射关系也可能不同. --- ### 锥型 NAT 分类 锥形 NAT 又可分为:**完全圆锥体 (Full Cone NAT)**、**受限制的圆锥体 (Restricted Cone NAT)**、**端口受限制的圆锥体 NAT (Port Restricted Cone NAT) 三种**. #### 完全圆锥体 NAT Full Cone NAT **特性** - **最宽松的限制**; - 内网客户端与外部设备通信时, NAT 会创建一个映射 (内部 IP 和端口到外部 IP 和端口的映射) ; - **任何外部设备**, 只要知道这个映射的外部 IP 和端口, 都可以通过该端口直接与内网客户端通信; **规则** - 内网客户端主动发起连接时, NAT 创建的映射是固定的:**内部 IP:端口 → 外部 IP(路由器公网 IP):端口**; - 外部设备只需知道外部的 IP 和端口, 就能发包到内网客户端, **无需内网客户端与之通信过**; ![20241229154732_VtTs6uzK.webp](https://cdn.dong4j.site/source/image/20241229154732_VtTs6uzK.webp) 内部服务 `192.168.31.2:123` 主动访问 `30.30.30.30:789` 时. 创建的 NAT 记录为: ``` 内部IP:端口 (192.168.31.2:123) → 外址IP:端口 (20.20.20.20:456) ``` 此时 **任何外部服务** 都可以通过 `20.20.20.20:456` 访问到内部的 `192.168.31.2:123`. 因为 NAT 设备并不关心报文的源 IP 和源端口号 (即报文来自谁) , 只要收到 **匹配 NAT 记录的报文**, 都能发送到内网设备. 此时内部服务 `192.168.31.2:123` 访问其他的服务比如 `40.40.40.40:1234`, 因为源地址相同(`192.168.31.2:123`) 的缘故, NAT 则不会再创建映射: **NAT 不会为同一个内网地址和端口 (192.168.1.10:12345)** 针对不同目标服务器创建多个映射. --- #### 受限制的圆锥体 NAT Restricted Cone NAT **特性** - **中等限制**; - 内网客户端发起连接时, NAT 会创建一个映射; - **只有客户端主动通信过的外部设备**, 才能通过该映射访问内网客户端(**记录允许的 IP**). **规则** - 内网客户端发起连接时, NAT 创建映射:**内部 IP:端口 → 外部 IP(路由器公网 IP):端口 → 允许目标 IP** - 只有客户端通信过的外部设备 (即目标 IP 在 NAT 映射记录中且通讯过的外部设备, 记还要匹配允许的 IP) , 才能通过映射访问客户端. - 外部设备发来的数据包, **必须与客户端通信的外部 IP 和端口 一致**. ![20241229154732_NPRlSHn6.webp](https://cdn.dong4j.site/source/image/20241229154732_NPRlSHn6.webp) 内部服务 `192.168.31.2:123` 主动访问 `30.30.30.30:789` 时. 创建的 NAT 记录为: ``` 内网IP:端口 (192.168.31.2:123) → 外部IP:端口 (20.20.20.20:456) → 允许目标IP (30.30.30.30) ``` 此时只有来自 `30.30.30.30` 任意端口的请求才能通过 `20.20.20.20:456` 访问到内部的 `192.168.31.2:123`. 比如来自 `40.40.40.40` 的请求则不能访问内部服务, **因为它没有在允许目标地址中(内网设备正在与哪些外部设备通信)** 此时必须由内部服务先发起请求(`40.40.40.40:xxx`) 以增加新的允许的目标 IP 记录才能让 Server C 访问: ``` 内网IP:端口 (192.168.31.2:123) → 外部IP:端口 (20.20.20.20:456) → 允许目标IP (30.30.30.30 & 40.40.40.40) ``` > 这样的 NAT 安全性有一定的提高, 但是也提高了打洞难度. 两台内网设备需要互相给对方发送一个报文, 才能打洞成功. --- #### 端口受限制的圆锥体 NAT Port Restricted Cone NAT **特性** - **最严格的限制**; - 内网客户端发起连接时, NAT 会创建一个映射; - **只有客户端主动通信过的外部设备**, 才能通过该映射访问内网客户端(**记录允许的 IP + Port**). **规则** - 内网客户端发起连接时, NAT 创建映射:**内部 IP:端口 → 外部 IP(路由器公网 IP):端口 → 允许的目标 IP+端口** - 外部设备发来的数据包, **必须与客户端通信的外部 IP 和端口 一致**. - 比受限制的圆锥体 NAT 增加了一层对 **端口** 的限制. ![20241229154732_fwclbrwC.webp](https://cdn.dong4j.site/source/image/20241229154732_fwclbrwC.webp) 内部服务 `192.168.31.2:123` 主动访问 `30.30.30.30:789` 时. 创建的 NAT 记录为: ``` 内网IP:端口 (192.168.31.2:123) → 外部IP:端口 (20.20.20.20:456) → 允许的目标IP:端口 (30.30.30.30:789) ``` 此时来自 `30.30.30.30` **且端口是** `789` 的请求才能通过 `20.20.20.20:456` 访问到内部的 `192.168.31.2:123`. 同理如果想让 `30.30.30.30:987` 也能访问 `192.168.31.2.123`, 则必须 **由内部服务先发起请求以新增允许的目标 IP+端口记录**: ``` 内网IP:端口 (192.168.31.2:123) → 外部IP:端口 (20.20.20.20:456) → 允许的目标IP:端口 (30.30.30.30:789 & 30.30.30.30:987) ``` --- #### 总结 与完全圆锥形 NAT 相比, 受限圆锥形 NAT 在内网设备向外发送报文时, 路由器除了生成 NAT 记录, 还会根据报文的目的 IP, **记录下内网设备正在与哪些外部设备通信.** 这样, 只有内网设备先发送报文给外部设备, 外部设备回应的报文, 才会被转发到内网设备. 而其他外部设备发送过来的报文, 即使匹配 NAT 记录, 也无法发送到内网设备. 而 **受限制的圆锥体 NAT** 与 **端口受限制的圆锥体 NAT** 最显著的区别是受限制的范围不一样: 后者相对于前者添加了 **端口** 的限制. 我们回顾一下 **圆锥形 NAT** 的定义: **一旦内部主机的私有端口映射到公网的一个端口, 无论目的地址是什么, 这个映射关系都保持不变.** 而上述三种 **圆锥形 NAT** 的示例中自始至终都只有 1 条 NAT 记录, 不同的是 **是否需要通过允许的地址来放行请求**(与谁通信过的记录). 而 **对称型 NAT** 会同时根据内网设备出方向报文的 **源 IP**、**源端口号**、**目的 IP**、**目的端口号** 四个信息来建立 NAT 记录. 如果报文的目的 IP、目的端口号发生了变化, 映射到的外部端口号也会发生改变. ![20241229154732_bXSwBRvy.webp](https://cdn.dong4j.site/source/image/20241229154732_bXSwBRvy.webp) 内网设备首先和 ServerA 通信, 内网 IP 和内网端口号会被映射为一个外部 IP 和外部端口号: ``` 内网IP:端口 (192.168.31.2:123) → 外部IP:端口 (20.20.20.20:456) ``` 接下来, 内网设备和另一台设备 (Server B)通信, 相同的内网 IP 和内网端口号, 又会被映射为另外一个外部端口号: ``` 内网IP:端口 (192.168.31.2:123) → 外部IP:端口 (20.20.20.20:1456) ``` 我们以前文说的 [UDP 打洞](#点对点 (P2P) 网络通信) 为例, 说说为什么 **对称型 NAT** 无法成功打洞: 第一步都是一样的, A 和 B 同时向 Server 发送数据, 这时会分别在 NAT 设备上生成对应的 NAT 记录, 同时 Server 记录设备 A 和 B 对应的外网 IP 和端口: ![20241229154732_GlDOpN6t.webp](https://cdn.dong4j.site/source/image/20241229154732_GlDOpN6t.webp) 第二步是 Server 将对应的公网 IP 和外部端口发送给 A 和 B, 让它们互相访问: ![20241229154732_Nj3UnNvf.webp](https://cdn.dong4j.site/source/image/20241229154732_Nj3UnNvf.webp) 此时在两端的 NAT 设备上各新增了一条 NAT 记录, 但因为 **对称型 NAT** 的特性, 因为目标地址不同(IP 或端口不同), 即使是同一个内网 IP 通过相同的端口发送数据包, 在 NAT 设备生成的 NAT 记录的外部端口也会不一样, 即这次生成的外部端口为 `1456`, 经 NAT 转换后, 源地址为: `20.20.20.20:1456` 目标地址为 `40.40.40.40:789`. 而 B 端 NAT 记录中的 `40.40.40.40:789` 只能被源地址为 `30.30.30.30:5678` 访问, 所以 A 发送给 B 的数据包被丢失, 导致打洞失败. --- ### NAT 类型总结 基本的 NAT, 它仅将内网主机的私有 IP 地址转换成公网 IP 地址, 但并不将 TCP/UDP 端口信息进行转换, 有动态与静态之区分. 由于现在大部分都属于另一种类型, 即 NAPT, 其中对称 NAT 打洞困难很高, 但是安全性好;而锥型 NAT 打洞比较容易. 实际上大部运营商提供的 **光猫上网服务都是锥形 NAT**. **而光纤入户, 4G, 5G 网络, 公共 WiFi 等因为安全因素都是对称 NAT**, 另外目前**绝大多数的路由器都是非对称型 NAT(Cone NAT)**. 只要请求链接的对方是非端口限制锥型 nat 就都能实现打洞 P2P 链接的. 只有双方都是 **对称 NAT** 是一定无法实现, 或一方对称一方是 端口限制锥型 NAT 的情况也无法实现打洞. 下面是各种类型打洞总结: | 发送端 NAT 类型 | 接收端 NAT 类型 | 是否能打洞 | | ------------------ | ------------------ | ---------- | | 完全锥形 NAT | 完全锥形 NAT | ✅ | | 完全锥形 NAT | IP 限制性锥形 NAT | ✅ | | 完全锥形 NAT | 端口限制性锥形 NAT | ✅ | | 完全锥形 NAT | 对称式 NAT | ✅ | | IP 限制性锥形 NAT | IP 限制性锥形 NAT | ✅ | | IP 限制性锥形 NAT | 端口限制性锥形 NAT | ✅ | | IP 限制性锥形 NAT | 对称式 NAT | ✅ | | 端口限制性锥形 NAT | 端口限制性锥形 NAT | ✅ | | 端口限制性锥形 NAT | 对称式 NAT | 🚫 | | 对称式 NAT | 对称式 NAT | 🚫 | --- ## NAT 类型提升 简单回顾一下, NAT 的 4 个类型, 它们分别是:**NAT1、NAT2、NAT3、NAT4** - **NAT1: Full Cone NAT**, 全锥形 NAT, 这是最宽松的网络环境, 你想做什么, 基本没啥限制 IP 和端口都不受限 - **NAT2: Address-Restricted Cone NAT**, 受限锥型 NAT, 相比 NAT1, NAT2 增加了地址限制, 也就是 IP 受限, 而端口不受限 - **NAT3: Port-Restricted Cone NAT**, 端口受限锥型, 相比 NAT2, NAT3 增加了端口限制, 即 IP、端口都受限 - **NAT4: Symmetric NAT**, 对称型 NAT, 对称型 NAT 具有端口受限锥型的受限特性, 内部地址每一次请求一个特定的外部地址, 都会绑定到一个新的端口. 这种类型基本上就告别 P2P 了 从 NAT1 到 NAT4 限制越来越多, 为了满足各种需求, 我们希望提升 NAT 类型. 提升 NAT 类型的好处有, 浏览网页、观看视频、游戏等更顺畅, 下载速度更稳定快速, 特别是对那些玩游戏的, 提升改善 NAT 类型后联机速度更快, 游戏体验明显提高. ### 低延迟方案 > 想要游戏网速快, 延迟低, 就要 nat1, 公网, 桥接, unpn, 硬件 nat 加速 - **修改光猫工作模式** 把光猫工作模式设置为桥接模式(需要超级管理员账号密码, 很多都是默认, 可以根据地区上网搜索, 新款的直接联系维修工人), 修改模式, 因为运营商一般默认设置光猫工作在路由模式. 无线路由器直接连到猫上就可以上网的, 那么光猫是路由模式. **无线路由器需要 PPPoE 拨号上网的就是桥接模式**. - **更改路由器设置** 启用无线路由器的 uPnP 功能, uPnP 大部分路由器都支持. 把要提升 NAT 类型的主机 IP 设置为静态, 然后开启 DMZ(通过路由器拨号, 路由器最好要刷机, 然后选择 NAT1 模式) ### 总结 - 路由器层数越少, 越可能得到 NAT1 和 NAT2 类型 - NAT1 是最宽松的网络环境, 基本没限制. NAT4 是最严格的网络环境, 可能会玩不了游戏、P2P 下载都没速度 - 如果光猫是桥接模式, 路由器拨号上网的有可能是 NAT2 和 NAT3, 对上网、游戏和下载都没有太多限制 - 拨号能获得公网 IP 的, 可以优化到 NAT1, 拨号获得内网 IP 基本是 NAT4 - 中国电信、中国联通宽带一般是公网 IP, 中国移动、中国广电、长宽等基本是内网 IP. 因为移动公网 ip 池比其他运营商少, 所以一般都用对称型 nat(节约公网 ip, 因为断开连接就会解绑映射, 不过绑定关系的建立和解除会消耗 cpu 性能, 所以移动打游戏时不时跳 ping) --- ## UPnP **UPnP** (Universal Plug and Play) 是一种网络协议, 允许设备在网络中自动发现彼此, 并通过网络进行互操作. UPnP 使设备能够在网络中动态地发现、控制和配置其他设备, 而无需手动配置. 它广泛应用于家庭和办公环境中, 特别是在涉及到网络设备之间的协作时. **主要特点** 1. **自动发现**:设备能够自动发现网络中的其他设备, 并能够与其进行通信. 例如, 智能家电、路由器、打印机等设备通过 UPnP 协议可以自动连接并相互配合. 2. **零配置**:设备不需要用户进行复杂的网络配置 (如 IP 地址或端口映射) , UPnP 自动完成这些操作. 3. **设备控制**:通过 UPnP 协议, 可以控制其他设备的功能, 如播放音乐、控制视频播放、网络摄像头的调节等. **UPnP 与 NAT 的关系** **NAT** (Network Address Translation) 是一种用于网络层的技术, 用来将内网设备的私有 IP 地址转换为公共 IP 地址, 使得多个设备能够共享一个公共 IP 地址. NAT 的典型应用场景是家庭路由器, 它将内网设备的请求映射到一个公共的 IP 地址上, 从而允许多个设备通过一个公共 IP 地址访问互联网. 然而, NAT 会带来一些问题, 尤其是当多个设备需要与外部设备进行点对点通信时. 例如, NAT 不会自动将外部设备的请求转发到内网设备, 因此需要手动配置端口映射 (端口转发) , 才能允许外部设备通过公共 IP 地址访问内网设备. **UPnP 如何帮助 NAT 穿透** 具体来说, UPnP 协议允许内网设备通过 UPnP 协议自动请求路由器开放特定的端口并进行端口映射. 这使得内网设备能够在不手动配置端口转发的情况下, 允许外部设备访问内网服务, 突破了 NAT 的限制. **UPnP 工作原理** 1. **设备发现**:内网设备 (如计算机、游戏主机、智能摄像头等) 通过发送 **SSDP** (Simple Service Discovery Protocol) 请求, 寻找支持 UPnP 协议的路由器. 2. **端口映射请求**:内网设备通过 UPnP 向路由器发送请求, 要求路由器将指定的端口映射到设备的 IP 地址和端口上. 路由器通常会响应并将端口映射添加到 NAT 表中. 3. **外部访问**:外部设备通过路由器的公共 IP 地址和开放的端口访问内网设备. 路由器根据 UPnP 配置的端口映射, 将请求转发到正确的内网设备. **NAT 穿透的应用场景** UPnP 特别适用于以下几种情况: - **VoIP 和视频通话**:通过 UPnP 自动配置端口映射, 使得 VoIP 或视频会议可以穿透 NAT, 直接进行通信. - **P2P 文件共享**:如 BitTorrent、在线游戏等, 内网用户通过 UPnP 自动创建端口映射, 使得外部用户能够直接连接到内网用户. - **游戏控制台**:如 Xbox 或 PlayStation, 设备通过 UPnP 请求开放端口以便与其他设备进行联网对战. **UPnP 的安全性问题** 虽然 UPnP 提供了便利性, 但也存在一些安全隐患: 1. **自动端口映射**:如果 UPnP 功能启用, 路由器会允许内网设备自动请求端口映射, 这意味着恶意软件也可以通过 UPnP 请求将某些端口暴露到外部网络. 2. **未经授权的设备访问**:未经授权的设备可能会通过 UPnP 请求端口映射, 允许外部攻击者访问内网设备. 3. **防火墙绕过**:恶意软件可以通过 UPnP 自动打开端口, 从而绕过防火墙, 获取不应有的网络访问权限. **总结** - **UPnP** 是一种使设备能够自动发现并互相通信的协议, 广泛应用于家庭和办公网络中. - **NAT** 会阻止外部设备直接访问内网设备, 但 **UPnP** 可以自动化端口映射过程, 解决 NAT 穿透问题. - UPnP 对 NAT 进行了优化, 使得内网设备能够自动配置路由器进行端口映射, 从而使外部设备能够访问内网服务. ## DMA 上文中介绍的 NAT, 路由器会根据内网设备发出的报文, 自动形成 NAT 表项. 实际上, 用户还可以在路由器上手动配置端口映射关系, 让内网设备可被外部访问. 其中, **DMZ** 功能, 可以指定一台内网设备为 DMZ 主机. 到达路由器上的报文, 如果没有匹配 NAT 表项, 就会转发到 DMZ 主机. 从而使 DMZ 主机可被外部访问. DMZ 功能能让一台内网设备上的所有端口, 都能被公网访问. 但这样做也影响了内网设备的安全性, 如果没有特殊需要, 不建议打开这一功能. --- ## 端口映射 ![20241229154732_8PwJl34p.webp](https://cdn.dong4j.site/source/image/20241229154732_8PwJl34p.webp) 端口映射是指将外部网络 (通常是公共网络) 上的某个端口映射到内网设备上的相应端口, 允许外部用户通过路由器访问内网设备的服务. 它通常与 NAT 配合使用, 在路由器或防火墙上设置映射规则, 从而实现外部与内网的通信. **举个例子**: - 假设你有一台内部设备, IP 地址为 192.168.1.10, 并且这台设备上的某个服务监听的是端口 8080. - 你希望外部用户能够访问这个服务, 但内网的 192.168.1.10 地址无法直接暴露给外部网络. - 你可以在路由器上设置端口映射规则, 将外部的某个公共端口 (例如 80) 映射到内网设备的端口 8080. 这样, 外部用户访问路由器的 80 端口时, 实际上会转发到内网设备的 192.168.1.10:8080. **作用**: - 通过端口映射, 外部设备可以访问内网中的特定设备或服务. - 它用于解决 NAT 带来的“内网设备无法直接被外部访问”的问题. **NAT 和端口映射区别** - **NAT** 是一种技术或机制, 用于将私有 IP 地址转换为公共 IP 地址. - **端口映射** 是 NAT 的一种配置, 通过设置规则, 将外部访问的端口映射到内网设备的端口, 使得外部设备能够访问内网设备. --- ## 检测 NAT 类型 [pystun3](https://github.com/talkiq/pystun3) 是一个用于获取 NAT 类型和外部 IP 的 Python STUN 客户端 安装: ```sh pip install pystun3 ``` ![20241229154732_DglFSGxe.webp](https://cdn.dong4j.site/source/image/20241229154732_DglFSGxe.webp) 也可以使用自定义 STUN 服务器: ```shell pystun3 -d -H stun.l.google.com -P 19302 ``` ![20241229154732_msZYrKtd.webp](https://cdn.dong4j.site/source/image/20241229154732_msZYrKtd.webp) **pystun3** 默认的 STUN Server 在 `stun/__init__.py` 中定义: ```python STUN_SERVERS = ( 'stun.ekiga.net', 'stun.ideasip.com', 'stun.voiparound.com', 'stun.voipbuster.com', 'stun.voipstunt.com', 'stun.voxgratia.org' ) ``` 自己也可以搭建一个 [STUN Server](https://github.com/jselbie/stunserver): ```shell git clone https://github.com/jselbie/stunserver cd stunserver docker image build -t=stun-server . docker run -d -p 3478:3478/tcp -p 3478:3478/udp --name=stun-server stun-server ``` 使用自建服务器 ``` pystun3 -d -H [STUN Server 服务的域名或 IP] -P {STUN Server 的端口号} ``` 如果你做 **WebRTC** 相关工作的开发, 那么 [Always Online: STUN servers](https://github.com/pradt2/always-online-stun) 这个开源项目可以关注一下. --- ## 参考 - [42 张图详解 NAT : 换个马甲就能上网](https://mp.weixin.qq.com/s/nQBRhfZuufPB72_jGDXQwg) - [家庭网络中的「NAT」到底是什么?](https://sspai.com/post/68037) - [入网指南 04 | IP 地址大揭秘 - 少数派](https://sspai.com/post/64430) - [局域网游戏串流:让我们都做一回「云」玩家 - 少数派](https://sspai.com/post/62402) - [小白也能看懂的网络基础 04 | IP 地址是如何工作的 - 少数派](https://sspai.com/post/64688) - [小白也能看懂的网络基础 05 | IP 地址深度学习 - 少数派](https://sspai.com/post/65428) - [小白也能看懂的网络基础 07 | TCP 和 UDP 是如何工作的? - 少数派](https://sspai.com/post/65470) - [NAT 科普与类型提升](https://chenhe.me/post/nat-type) - [互联网网关设备协议 - 维基百科, 自由的百科全书](https://zh.wikipedia.org/wiki/%E4%BA%92%E8%81%94%E7%BD%91%E7%BD%91%E5%85%B3%E8%AE%BE%E5%A4%87%E5%8D%8F%E8%AE%AE) - [NAT 端口映射协议 - 维基百科, 自由的百科全书](https://zh.wikipedia.org/wiki/NAT%E7%AB%AF%E5%8F%A3%E6%98%A0%E5%B0%84%E5%8D%8F%E8%AE%AE) - [解决 NAT 错误和多人游戏问题 | Xbox Support](https://support.xbox.com/zh-CN/help/hardware-network/connect-network/xbox-one-nat-error) - [Port Control Protocol (PCP) - RFC6887](https://datatracker.ietf.org/doc/html/rfc6887) - [How NAT traversal works · Tailscale](https://tailscale.com/blog/how-nat-traversal-works) - [How Tailscale works · Tailscale](https://tailscale.com/blog/how-tailscale-works) - [QUIC, a multiplexed transport over UDP - The Chromium Projects](https://www.chromium.org/quic/) - [awesome-home-networking-cn](https://github.com/blanboom/awesome-home-networking-cn) **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## [HomeLab 网络篇:互联世界-构建高效的自托管网络环境](https://blog.dong4j.site/posts/e537d446.md) ![/images/cover/20241229154732_xLHg6epx.webp](https://cdn.dong4j.site/source/image/20241229154732_xLHg6epx.webp) 在构建个人云端实验室 (HomeLab) 的过程中, 硬件是基石, 而网络则是让这些硬件协同工作的血脉和灵魂. 本篇将深入探讨家庭网络的配置, 从基础的架构设计到高级的安全性和异地组网技术, 逐步展示如何搭建一个高效、稳定且安全的自托管网络环境. 我将从以下几个方面进行详细阐述: 1. **网络架构**: 介绍如何选择合适的网络设备 (如路由器、交换机等) , 以及如何在不同的房间和位置部署网络覆盖. 2. **安全性**: 讨论如何设置网络安全策略, 包括防火墙规则、VPN 配置、加密通信等, 以确保数据的安全性和隐私保护. 3. **异地组网**: 展示如何通过远程访问技术, 将家庭实验室扩展到外部网络, 实现异地管理和操作. 4. **实践案例**: 分享实际操作步骤和经验, 帮助读者更好地理解和应用这些网络技术和概念. 通过本篇的探讨, 你将能够了解到如何构建一个高效、安全且可扩展的自托管网络环境. 这不仅能够满足日常工作和学习的需求, 还能为你的个人云端实验室提供一个稳固的网络基础. 让我们一起揭开家庭网络配置的神秘面纱, 探索自托管的无限可能吧! **相关文章**: 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## 阅读指引 由于篇幅较长且不想切分多篇文章影响阅读连续性, 因此这里给出一个阅读指引, 以帮助快速理解章节直接的逻辑关系: 1. [从网络相关的硬件出发, 以网络布线开始到硬件设备连接, 重点介绍了弱电箱和书房的网络布局](#网络架构) 2. [通过外网, 内网和外网->家的网速测试验证宽带的可用性](#网速) 3. [有了硬件和基础网络后, 如何合理的分配设备 IP 地址](#设备-IP-管理) 4. [为了方便记忆, 为设备分配自定义域名](#设备域名管理) 5. [完成上面 2 步工作后, 接下来就是如何管理多台服务器](#设备连接管理) 6. [内网配置完成后, 接下来就要考虑如何高效的从外网访问家中的设备和服务](#暴露服务到公网) 7. [因为暴露到公网后, 首先需要考虑的就是网络安全问题](#网络安全) 8. [出门在外, 如何快速且安全的访问家中网络, 看个小电影, 听听歌](#外网访问家庭网络) 9. [为了提升网络体验, 需要考虑如何屏蔽广告](#广告过滤) 10. [在外能够流畅访问家中的网络, 那么在家该如何访问公司的网络呢? ](#异地组网) 11. [如何流畅的访问互联网世界, 只能说一点点](#分流) 12. [最后的最后, 我们应该如何实时了解网络情况](#网络监测与通知) --- ## 网络架构 ### 布线 从装修开始就考虑到一定会组建自己的 HomeLab, 但是考虑到弱电箱不够大且没有条件增大, 只能将硬件放置到各个房间, 因此在弱电布线时就做了冗余. 得益于开发商 2 根光纤入户, 使得宽带冗余方案得以实施, 同时遗憾的是无法增加更多的宽带数量. 从弱点箱预埋了总共 8 根八类网线, 客厅 3 根, 主卧和书房各 2 根, 次卧 1 根. ![平面图.drawio.svg](https://cdn.dong4j.site/source/image/%E5%B9%B3%E9%9D%A2%E5%9B%BE.drawio.svg) 电信和联通通过弱电箱光纤入户 **电信宽带**: 1. 电信光猫通过网线连接电信机顶盒; 2. 电信光猫通过网线连接电视柜中 `AX9000` 的千兆 wan 口, 由 `AX9000` 拨号上网; 3. `AX9000` 的 2.5G lan 口通过网线回连至弱电箱的 `TL-SH1005` 2.5G 交换机; 4. `TL-SH1005` 交换机通过网线连接主卧和书房, 实现主要房间 2.5G 电信有线网络; **联通宽带**: 1. 联通光猫通过网线连接弱电箱旁边 `6500Pro` 的 2.5G wan 口, 由 `6500Pro` 拨号上网; 2. `6500Pro` 通过 2.5G lan 口连接弱电箱中的另一个 `TL-SH1005` 交换机; 3. `TL-SH1005` 交换机通过网线连接主卧, 次卧和书房, 实现房间 2.5G 联通有线网络; 因为弱电箱小无法放下所有硬件, 因此只能采用这种迂回的方式布线, 关键点是需要额外预埋一条网线将网络从路由器接到交换机, 再由交换机将网络输送到各个房间. 如果在装修时没有考虑导致网线不够, 可以用其他方式实现: 1. 网线一分为二, 四芯用于连接无线路由器, 这种方式当然 **不推荐**, 网速达不到要求; 2. 使用电力猫连接 IPTV 电视盒子, 网线连接无线路由器, 这种方式会导致 IPTV 电视信号稳定性不高; ![20241229154732_QgATxj9Q.webp](https://cdn.dong4j.site/source/image/20241229154732_QgATxj9Q.webp) 3. 单线复用: 使用支持 VLAN 的交换机, 划分两个逻辑独立的网络. 这种连接方式会增加配置复杂度. [这里有详细的视频教程](https://www.youtube.com/watch?v=1CuyE5HXhA8) ![20241229154732_LTEiwoQJ.webp](https://cdn.dong4j.site/source/image/20241229154732_LTEiwoQJ.webp) ### 弱电箱 ![20241229154732_bBOS3J6w.webp](https://cdn.dong4j.site/source/image/20241229154732_bBOS3J6w.webp) 还好沙发后面有一个 10 公分宽的木架, 不然这些设备弱电箱还塞不下. #### 拓扑图 ![弱电箱.drawio.svg](https://cdn.dong4j.site/source/image/%E5%BC%B1%E7%94%B5%E7%AE%B1.drawio.svg) 1. 电视柜 3 个网口实现 IPTV, 路由器拨号上网与有线回程; 2. 弱电箱的电信交换机连接 R2S 和 R5S, 剩下的 2 个网口接到主卧和书房; 3. 联通光猫连接 6500Pro 并由路由器拨号上网, 6500Pro 连接交换机与 R5S; 4. 联通交换机连接 R2S, 其他网口接到主卧, 次卧和书房; 其中 R5S 和 R2S 都已将 **lan 修改为 wan 口**, 然后作为旁路由和轻量服务器是用, 跑了几个 Docker 服务. #### 光猫改桥接 光猫改桥接的优势: 1. **提升网络性能**: 在桥接模式下, 光猫只负责光电转换, 将路由功能交给专门的路由器处理, 这样可以减轻光猫的负担, 使网络性能得到提升. 同时, 所有的设备都位于同一子网内, 网络结构更加简单, 便于管理和配置. 2. **增强网络稳定性**: 光猫在桥接模式下, 只需完成光电转换, 简化了其任务, 从而降低了工作压力, 提升了家庭网络的稳定性. 3. **便于故障排查**: 由于路由功能由专门的路由器处理, 一旦出现问题, 可以迅速定位到路由器层面进行排查和修复. 而在路由模式下, 故障排查可能会涉及到光猫和路由器等多个设备, 增加了故障排查的难度. 4. **提高灵活性**: 桥接模式允许用户根据实际需求选择合适的路由器设备, 实现更灵活的网络配置. 例如, 用户可以选择具有更强路由性能的路由器来提升网络稳定性, 或者选择具有特殊功能的路由器来满足特定需求. 5. **实现特殊功能**: 光猫改桥接后, 路由器 PPPOE 拨号, 用户可以实现更多的功能. 比如可以单线双拨增加带宽. 改桥接的方法可以网上搜索, 保险起见直接让运营商修改即可. ### 电视柜 电视柜中的 AX9000, 剩下的 lan 分别连接了小米壁画电视和 Apple TV. ### 书房 书房承载了 80% 的网络设备, 因为设备是慢慢增加的, 所以一开始并没有直接购买太多网口的交换机, 后面设备增多后, 因为交换机体积的问题, 也没有直接更换成更多网口的交换机, 而是直接通过增加交换机数量进行网络扩展. 而因为接入了电信和联通, 每条宽带都需要增加双倍的交换机数量. ![20241229154732_9TrRPpHt.webp](https://cdn.dong4j.site/source/image/20241229154732_9TrRPpHt.webp) 书房 2 个网口, 分别有线接入电信和联通网络(2.5G). 除了 DS923+ 和单独的开发版, 书房所有供电全部来自于最右边的插座, 因此增加了一个小米智能插座来统计书房的耗电量. 因为书桌上设备太多, 总共通过 4 个 8 口 PDU 插座和 3 个小米插座来供电, 放一个书桌下面的走线图: ![20241229154732_ZtqbiHUy.webp](https://cdn.dong4j.site/source/image/20241229154732_ZtqbiHUy.webp) 不能说乱吧, 只能说隐蔽工程做的不太漂亮 😎. #### 网络拓扑图 ![network.drawio.svg](https://cdn.dong4j.site/source/image/network.drawio.svg) 1. 网络设备全部通过双网口接入电信和联通网络, 如果没有双网口的设备, 则通过 USB 或 type-c 转 2.5G 网口实现; 2. 现在还没有条件实现书房主要设备万兆局域网, 这将是下一步的升级目标; 由于早期申请了电信宽带, 当时申请**公网 IP**相对容易. 正是因为这个公网 IP, 当前宽带无法升级到 2000M. 经咨询, 升级可能会导致公网 IP 被收回. 鉴于现有带宽足够使用, 加上还有一条千兆联通宽带, 暂时没有升级的迫切需求. **宽带配置和使用情况** 1. **电信宽带**: - 主要满足日常上网需求. - 手机等常用设备优先接入电信网络. 2. **联通宽带**: - 最初为免费赠送的 300M 带宽, 后来通过活动以 **每月 19 元**升级到千兆. - 通过协商安装师傅, 也获取了一个公网 IP. - 联通宽带主要用于智能设备联网和文件下载, 作为电信宽带的**备用网络**. 3. **双宽带优势**: - 两条独立宽带确保当一条线路的网络服务出现问题 (如运营商线路故障或光缆被挖断) 时, 仍可通过另一条线路访问家中设备. - 但需注意, 如果停电, 两个网络都会中断, 目前还不具备部署大型备用电源的财力 😅. **网络优化方案** 为减少 WiFi 信号干扰, 进行了以下调整: 1. **WiFi 管理**: - 关闭光猫和非主要路由器的 WiFi 功能. - 使用 **AX9000** 及其 **Mesh 设备(2 台 AX1800)** 的 WiFi 提供全屋漫游覆盖. 2. **智能设备专网**: - 配置 **6500Pro** 作为智能硬件的网络接入点, 启用 **2.4G 和 5G 频段**. - 为访客提供专用的访客 WiFi, 保障主网络的安全性. 通过这种分工, 不仅可以优化网络性能, 还能减少干扰, 确保家庭网络的稳定性和可用性. ### 关于网线 刚开始并没有考虑到全网光纤, 这是一大败笔, 现在换成光纤成本有点高, 还好预埋了 **八类网线**, 不过当时功课没有最好, 感觉类型越高越好, 完全没有考虑价格, 导致弱电装修增加了不少成本. 家用网线超六类完全够用. 不同规格网线速率图: | 规格 | 类型 | 速率 | 接口 | 备注 | | --------------- | ------------------ | ------------------------------------------------------------------------------------------------------ | --------- | ----------------------------------------------------------------------- | | 五类线 CAT 5 | 100Base-T 10Base-T | 100Mbps | RJ45 | 不推荐 | | 超五类线 CAT 5E | 100Base-T | 1000Mbps 2.5Gbps[7](https://icyleaf.com/2023/01/how-to-homelab-part-1-hardware-and-architecture/#fn:7) | RJ45 | 2.5G 网络[仅限 100 米以内](https://www.bilibili.com/video/BV1p14y137v3) | | 六类线 CAT 6 | 100Base-T | 1Gbps 10Gbps | RJ45 | 万兆网络仅限 50 米内 | | 超六类线 CAT 6A | 100Base-T | 10Gbps | RJ45 | 200 米内可达万兆网络 没有 6E 标准 | | 七类线 CAT 7 | 100Base-T | 10Gbps | GG45/TERA | 带着遮蔽 | | 光纤 | - | - | - | 不懂, 详见[维基百科](https://zh.wikipedia.org/zh-cn/光纖通訊) | 因为七类以上的网线有遮蔽罩, 需要正确接地处理, 反正来给我打水晶头的人不知道怎么接, 不过因为速度还是能达到万兆网速, 所有还没有深究这个问题, 等到有时间再去折腾吧. ### 总结 上一篇说过为什么不通过双线多播和猫棒等手段来提升宽带速度, 主要是 **稳定性和容错** 考虑, 也否定了 **All In One** 的方案, 避免 **All In Boom** 的问题, 而是使用多个硬件承载不同的需求. 在硬件设备上, 我也实现了多台冗余的设计, 比如多个 R2S 设备, 网络相关的服务集群部署等, 主要是保证可用性. 多年互联网开发经验告诉我, 多台服务器比单台性能强劲的服务器更加靠谱和经济实惠, 既能保证高可能还能提高可扩展性, 唯一的缺点就是多服务器的管理和配置难度上升. --- ## 网速 ### 外网 **电信**: ![20241229154732_Fnvmpxzj.webp](https://cdn.dong4j.site/source/image/20241229154732_Fnvmpxzj.webp) **联通**: ![20241229154732_gnLMAsbn.webp](https://cdn.dong4j.site/source/image/20241229154732_gnLMAsbn.webp) ### 内网 内网主要设备全部实现 2.5G: ![20241229154732_cdFkeuv1.webp](https://cdn.dong4j.site/source/image/20241229154732_cdFkeuv1.webp) 通过雷雳 3 直连的 2 台 Mac mini 能达到 20G: ![20241229154732_b9rVcsdz.webp](https://cdn.dong4j.site/source/image/20241229154732_b9rVcsdz.webp) 通过万兆交换机连接的 Mac mini 2018 和 DS923+ 能达到 10G 速度: ![20241229154732_yx0Zo8we.webp](https://cdn.dong4j.site/source/image/20241229154732_yx0Zo8we.webp) ### 外网->家 ![20241229154732_GedNor8D.webp](https://cdn.dong4j.site/source/image/20241229154732_GedNor8D.webp) 给一个逆天的网速测试截图, 就当图了乐 🤩: ![20241229154732_CbAogrJy.webp](https://cdn.dong4j.site/source/image/20241229154732_CbAogrJy.webp) ## 设备 IP 管理 从书房的网络拓扑图可以看出, 主要硬件设备的 IP 地址分配都保持有序. 这种规划方式的优势是: **只需记住设备的最后一位数字**, 便能快速找到对应设备. 另一种常见方式是通过修改 **hosts 文件**, 为每个设备设置对应的域名. 最理想的解决方案是**自建 DNS 服务**, 以集中管理所有设备的 DNS 解析任务, 更高效地应对设备数量的增加和变化. ### IP 设置策略 由于设备较多, 且未来可能增加新设备, 因此未采用在设备端手动设置静态 IP 的方式. **原因**: 1. **灵活性**: 某些设备 (如开发板) 可能频繁切换网络或连接不同宽带. 2. **易管理**: 通过路由器实现静态 IP 绑定既简单又高效, 可统一管理设备的 IP 地址分配. 设备 IP 分配方案按照网口数量进行粗略分组, IP 地址基于其连接的电信或联通宽带进行划分. 这种规划既便于扩展, 又确保了网络结构的清晰和可控性. ### IP 分配 粗略按照网口对设备进行分类: | 网口数量 | 设备 | 所需 IP 数量 | | ------------- | -------------------------------------------------------------------------- | ----------------- | | WiFi | `移动设备 * 4` | 4 | | 单网口 | Apple TV, 小米电视 | 2 | | 单网口 + WiFi | `开发板 * 7` | 14 | | 双网口 | `R2S * 2`, DS218+, DS923+(2 个千兆网口链路聚合成 1 个网口, 另一个万兆网口) | 4(电信联通各一个) | | 双网口 + WiFI | MBP, Mac mini M2, Mac mini 2018 | 6 | | 3 网口 | R5S | 1 | | 4 网口 | M920x. 组装机 | 4 | 算下来只需要记住 35 个 IP 😅, 当然都是有规律的: 比如 MBP: - 电信 RJ45: 192.168.31.8 - 联通 RJ45: 192.168.21.8 - 电信 WiFi: 192.169.31.88 - 联通 WiFi: 192.169.21.88 **IP 分配规则** 为了便于管理和记忆, 主要设备的 IP 地址分配遵循以下规则: 1. **前 10 位 IP 分配给主要设备**: - 如设备需要连接 WiFi, 直接在有线 IP 的基础上 **double** (翻倍) 即可, 比如 RJ45 IP 为 `192.168.31.8`, WiFi IP 就是 `192.168.31.88`. - 如果某设备超过了 10 位范围, 且有额外的 WiFi 连接需求, 则在有线 IP 后面添加一个 **0**, 比如 RJ45 IP 为 `192.168.31.11`, WiFi IP 就是 `192.168.31.110`. 2. **超出规则时的处理**: - 当添加 **0** 导致 IP 超过 **254** 时, 直接将下一位 IP 地址递增, 比如 RJ45 IP 为 `192.168.31.33`, WiFi IP 就是 `192.168.31.34`. - 确保分配合法, 避免使用非法 IP. 3. **其他设备的分配**: - 无关紧要的设备使用 **200 之后**的 IP 地址. - 当前 **200 段**的 IP 数量尚充足, 如有需求可以再开放更多的 IP 段. **优势分析** 这种分配方式使**主要设备的 IP 规划简单易记**, 同时将无关紧要的设备划分至 **200 段**后, 可以减少对主要设备 IP 地址段的干扰, 确保未来扩展设备时的灵活性和有序性. --- ## 设备域名管理 ### Hosts 通过修改 **hosts 文件**, 为每个设备设置对应的域名是一种常见的本地解析方案. 然而, 这种方式存在以下问题: 1. **每台终端需单独配置**: 每次新增设备都需要在所有终端的 hosts 文件中手动添加配置. 2. **不支持泛域名解析**: 系统自带的 hosts 文件无法处理类似 `*.xx.com` 的泛域名. 因此这种方式管理效率较低, 扩展性差, 特别是设备数量多或频繁变动的场景, 显得尤为不适用. **推荐替代方案**: 通过自建 DNS 服务实现集中管理, 不仅支持泛域名解析, 还能减少每台终端的配置工作量, 提升管理效率和灵活性. ### DNS 服务 {% folding 🪬 常见的自托管 DNS 服务有: %} 1. **[dnsmasq](https://thekelleys.org.uk/dnsmasq/)** - **功能特点**: 轻量级 DNS 和 DHCP 服务器, 支持本地解析、DNS 缓存、DHCP 静态绑定. - **适用场景**: 家庭和小型局域网中使用, 配置简单, 资源占用极低. - **优点**: - 轻量化, 易配置. - 支持本地静态 IP 和域名绑定. - **缺点**: - 高级功能较少, 不适合大型网络或复杂解析场景. 2. **[CoreDNS](https://coredns.io/)** - **功能特点**: 模块化、插件化设计的 DNS 服务, 适合现代云环境. - **适用场景**: Kubernetes 集群、微服务架构的 DNS 管理. - **优点**: - 支持负载均衡、缓存、DNSSEC 等高级功能. - 插件化架构, 功能可扩展性强. - **缺点**: - 配置和学习曲线相对较高. - 对初学者可能显得过于复杂. 3. **[SmartDNS](https://pymumu.github.io/smartdns/)** - **功能特点**: 高性能 DNS 解析器, 专注于提升解析速度和可靠性. - **适用场景**: 需优化 DNS 解析速度的环境, 如加速海外解析、影音服务. - **优点**: - 支持多线路、快速 DNS 选择. - 优化海外解析性能. - **缺点**: - 功能专注于加速, 不适合复杂解析需求. - 高级功能较少, 生态支持不如其他方案. 4. **[AdGuard Home](https://adguard.com/en/adguard-home/overview.html)** - **功能特点**: 集成广告拦截的 DNS 服务, 提供友好的 Web 管理界面. - **适用场景**: 家庭网络广告屏蔽和基础 DNS 管理. - **优点**: - 易用的 Web 界面, 配置简单直观. - 集成广告拦截、隐私保护功能. - **缺点**: - 灵活性不足, 偏向家庭用户. - 插件和高级扩展支持较弱. 5. **[Pi-hole](https://pi-hole.net/)** - **功能特点**: 以广告拦截为核心的 DNS 服务, 可运行于 Raspberry Pi 或其他小型设备上. - **适用场景**: 家庭或小型办公网络中的广告屏蔽和基础 DNS 管理. - **优点**: - 强大的广告屏蔽功能. - 配置简单, 支持轻量级部署. - **缺点**: - 高级 DNS 功能支持较弱. - 更适合广告屏蔽用途, 不适合复杂网络需求. 6. **[Unbound](https://github.com/NLnetLabs/unbound)** - **功能特点**: 高性能递归 DNS 服务器, 支持 DNSSEC 和隐私保护. - **适用场景**: 需要安全、高效的递归 DNS 服务的环境. - **优点**: - 支持 DNSSEC, 适合需要隐私保护的场景. - 配置灵活, 性能出色. - **缺点**: - 缺少 Web 界面, 配置文件复杂度较高. 7. **[BIND 9](https://github.com/isc-projects/bind9)** - **功能特点**: 功能全面的权威 DNS 服务器, 广泛用于企业环境. - **适用场景**: 企业级、复杂解析需求的 DNS 服务. - **优点**: - 支持权威解析、递归解析、DNSSEC 等功能. - 功能全面, 企业标准. - **缺点**: - 学习成本高, 适合有经验的管理员. - 对系统资源的需求较高. **总结对比表** | 工具 | 轻量化 | 高性能 | 易用性 | 插件扩展 | 高级功能 | 适用场景 | | ------------ | ------ | ------ | ------ | -------- | -------- | ----------------------- | | **dnsmasq** | ★★★★★ | ★★★★☆ | ★★★★★ | ★☆☆☆☆ | ★★☆☆☆ | 家庭、小型网络 | | **CoreDNS** | ★★★☆☆ | ★★★★★ | ★★☆☆☆ | ★★★★★ | ★★★★☆ | Kubernetes、微服务环境 | | **SmartDNS** | ★★★★★ | ★★★★★ | ★★★★☆ | ★☆☆☆☆ | ★★☆☆☆ | DNS 加速、影音服务 | | **AdGuard** | ★★★★☆ | ★★★☆☆ | ★★★★★ | ★★☆☆☆ | ★★★☆☆ | 广告屏蔽、家庭网络 | | **Pi-hole** | ★★★★☆ | ★★★☆☆ | ★★★★☆ | ★★☆☆☆ | ★★★☆☆ | 广告屏蔽、基础 DNS 管理 | | **Unbound** | ★★★☆☆ | ★★★★★ | ★★☆☆☆ | ★★☆☆☆ | ★★★★★ | 安全递归 DNS 服务 | | **BIND 9** | ★★☆☆☆ | ★★★★☆ | ★★☆☆☆ | ★★★☆☆ | ★★★★★ | 企业环境、权威 DNS 服务 | **方案选择推荐**: - **轻量化和便捷配置**: 选择 **dnsmasq**. - **广告拦截和易用性**: 选择 **AdGuard Home** 或 **Pi-hole**. - **高性能递归解析**: 选择 **Unbound**. - **复杂企业级需求**: 选择 **BIND 9**. - **云原生环境或微服务架构**: 选择 **CoreDNS**. - **追求解析速度优化**: 选择 **SmartDNS**. 目前在用的是轻量级的 **SmartDNS** 和 **AdGuard Home**, R2S 的固件中自带这 2 个服务, 稍微配置即可实现自定义 DNS, 并具备广告拦截的功能. {% endfolding %} ### Surge Hosts 只需要满足主要设备能通过域名访问其他服务器或服务即可, 因此没必要单独部署一个 DNS 解析服务, **SmartDNS** 和 **AdGuard Home** 更多的是用来拦截广告. Surge 本身就具备 hosts 管理功能, 且支持泛域名解析(可以通过 Synology Drive 或 iCloud 服务来同步配置, 避免多端重复配置): ``` # R2ST 的 npm 代理, 所有的 *.npm 全部转发到 R2ST 的 80 端口, 然后再通过 NPM 转发到其他服务 *.npm = 192.168.31.10 ``` ### 方案总结 在实际使用中, 没有单一方案能够完全满足所有需求, 因此需要根据不同场景搭配多种方案. 我想实现的目标就是 **方便快捷的访问设备与服务**, 避免记忆大量 IP 地址及端口号, 提升访问效率. 其实, 主要设备就那么几台, 记住几个 IP 也不是那么难, 且主要设备的 IP 肯定不会经常改动. 问题是每台服务器的服务需要通过端口区分, 如果是 Web 服务, 直接用 Dashboard 这类服务来管理, 嫌麻烦就用书签, 推荐 [Markoob](https://chromewebstore.google.com/detail/markoob-%E4%B9%A6%E7%AD%BE%E5%90%AF%E5%8A%A8%E5%99%A8/lnhnllkaacmnkffnjgcnokifakeckido?hl=zh-CN): ![20241229154732_sTNm93mn.webp](https://cdn.dong4j.site/source/image/20241229154732_sTNm93mn.webp) 另一种则是通过自定义域名的方式减少需要记住的 IP 数量, 前面 [Surge Hosts](#Surge Hosts) 已经介绍过 hosts 功能, 我将结合 [Nginx Proxy Manager](https://nginxproxymanager.com/) 再次简化掉端口, 直接通过自定义域名访问所有服务. [具体的配置将在后续详细说明](#代理局域网自定义域名). 所以总结下来就以下几点: 1. 使用浏览器书签管理常用 Web 服务 (在服务篇会使用开源的 Dashboard 服务来管理); 2. [使用 NPM + Surge Hosts 实现 80 端口访问自定义域名](#代理局域网自定义域名), 简化域名并减少 IP 记忆; --- ## 设备连接管理 ### SSH 能通过 SSH 连接的设备我这里统称为 **服务器**, 那么在不算虚拟机的情况下, 总共有 20 台设备, 如果需要同时访问多台设备, 在使用 macOS 自带的终端或 iTerm2 一个个手敲的话效率非常低, 不过我们可以通过多种方式提升效率. #### 别名配置 首先想到的是为每台服务器设置一个简短好记的别名, 我们可以编辑 `.ssh/config` 文件来实现: ```shell Host 别名 HostName 服务器 IP User username Port ssh-port ``` 连接方式: ``` ssh 别名 ``` #### SSH 免密登录 为了避免每次都需要输入密码, 我们可以配置 SSH 的免密登录: ```shell # 客户端沈城 SSH 密钥对 () $ ssh-keygen -t rsa -b 4096 -C "your_email@example.com" Generating public/private rsa key pair. Enter file in which to save the key (/Users/dong4j/.ssh/id_rsa): 这里输入新的密钥保存路径 [我没有直接保存到默认的 id_rsa 中, 后续会共享这个密钥] # 使用 ssh-copy-id 工具将公钥传输到目标服务器 ssh-copy-id user@remote_server ``` 如果未安装 ssh-copy-id, 可以手动传输公钥: ```shell # 将公钥复制到剪贴板 cat ~/.ssh/server.pub | pbcopy # macOS cat ~/.ssh/server.pub | xclip # Linux # 登录服务器 ssh user@remote_server # 在服务器上将公钥添加到 authorized_keys echo "your-public-key" >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys ``` 验证免密是否生效: ```shell # 如果没有提示输入密码, 则配置成功 ssh user@remote_server ``` 拷贝公钥到其他服务器后, 为了实现局域网服务器之间 SSH 免密登录, 我会将第一步生成的 `server` 也拷贝到其他所有服务器. 值得说明的是, R2S 和 R5S 可以通过 WebUI 进行配置: ![20241229154732_SwNzaDIS.webp](https://cdn.dong4j.site/source/image/20241229154732_SwNzaDIS.webp) #### SSH 密钥认证 为了安全起见, 建议关闭密码认证, 只开启公钥认证. 编辑服务器上的 SSH 配置文件 /etc/ssh/sshd_config ```shell # 完全禁止使用密码认证, 强制所有用户使用公钥认证 PasswordAuthentication no # 启用公钥认证 PubkeyAuthentication yes # 禁用空密码登录 PermitEmptyPasswords no ``` 重启 SSH 服务以应用更改: ```shell # 注意 不同的系统重启命令可能不一样 sudo systemctl restart sshd ``` 配置本机的 `.ssh/config` ```shell Host 别名 HostName 服务器 IP User username Port ssh-port IdentityFile ~/.ssh/server ``` #### SSH 允许 root 登录 数据篇中会讲到如何 **使用 Synology NAS 备份服务器上的重要数据**, 前提是必须使用 root 账号登录服务器, 所以这里顺带把 SSH 允许 root 登录一起写一下 (开启 root 登录会增加潜在的安全风险, 及时只是在内网使用): 编辑服务器上的 SSH 配置文件 /etc/ssh/sshd_config ```shell # 允许 root 用户登录, 但仅限密钥认证, 不允许密码登录 PermitRootLogin prohibit-password ``` 以下是增加 SSH 安全性的常规手段: 1. 限制 root 登录的 IP 段; 2. 仅允许密钥登录; 3. 启用 MFA (多因素认证); 4. 使用 [Fail2Ban](https://github.com/fail2ban/fail2ban) 自动封禁多次失败的登录尝试; 5. 更改 SSH 默认端口; #### SSH 多因素认证 更进一步, 你可以为 SSH 开启多因素认证: ```shell # 安装 Google Authenticator PAM 模块: brew install google-authenticator-libpam # 为登录用户生成一个基于时间的一次性密码配置 google-authenticator # 扫描生成的二维码到 Google Authenticator、Authy 或其他支持 TOTP 的应用 # 编辑 PAM 文件 /etc/pam.d/sshd, 在文件的顶部添加以下内容: auth required /opt/homebrew/lib/security/pam_google_authenticator.so ``` 编辑 SSH 配置文件 /etc/ssh/sshd_config, 调整以下参数: ```shell # 启用挑战响应认证 ChallengeResponseAuthentication yes # 保留或禁用密码认证 (根据需要) PasswordAuthentication yes ``` macOS 下重启 SSH 服务 ```shell sudo launchctl unload /System/Library/LaunchDaemons/ssh.plist sudo launchctl load /System/Library/LaunchDaemons/ssh.plist ``` **注意事项** 1. **备份密钥与备用代码**: 确保生成的密钥和备用代码保存在安全的地方. 2. **排除特定用户**: 可在 PAM 配置中定义例外用户组, 跳过 MFA: ```shell # o_mfa_users 是无需 MFA 的用户组 auth [success=1 default=ignore] pam_succeed_if.so user ingroup no_mfa_users auth required pam_google_authenticator.so ``` 3. **结合 SSH 密钥**: 配置 SSH 密钥认证与 MFA 结合使用, 提升安全性. #### SSH 会话保持 同一主机的多次连接可以复用已有连接, 减少登录时间: ```shell Host * # 添加私钥到 ssh-agent 并允许其存储在 macOS 密钥链中 AddKeysToAgent yes # 通过 ssh-agent 管理私钥, UseKeychain 特别适用于 macOS, 将私钥安全存储 UseKeychain yes # 指定私钥路径, 对所有 SSH 连接生效 IdentityFile ~/.ssh/server # 启用 SSH 多路复用: 允许复用现有的 SSH 连接, 无需每次重新认证 ControlMaster auto # 指定控制套接字存储路径, 可使用 /tmp 或用户目录下的子文件夹 (如 ~/.ssh/session) ControlPath /tmp/%r@%h:%p # 即使没有活动会话, 主连接仍保持最多 4 小时 ControlPersist 4h # 启用 SSH 数据压缩, 尤其适合低带宽网络 Compression yes # 心跳包设置: 防止连接因空闲超时被断开 ServerAliveInterval 60 # 每 60 秒发送一次心跳包 ServerAliveCountMax 5 # 心跳失败超过 5 次后断开连接 ``` #### SSH 端口转发 经常使用到 SSH 且会用到端口转发功能, 这里仅做个记录. ##### 1. 本地转发 本地转发是一种通过 SSH 创建的隧道, 它允许你将本机的某个端口上的通信, 通过 SSH 服务器转发到另一台远程服务器的指定端口. 在这个过程中, SSH 服务器充当了一个中继的角色, 使得本地计算机能够访问那些它原本无法直接连接的远程服务. **本地转发是在本地机器上设置转发规则**. 语法如下, 其中会指定本地端口 (local-port) 、SSH 服务器 (tunnel-host) 、远程服务器 (target-host) 和远程端口 (target-port) . ```shell ssh -N -f -L local-port:target-host:target-port username@tunnel-host ``` 上面命令中, 有三个配置参数. `-L`: 转发本地端口. `-N`: 不发送任何命令, 只用来建立连接. 没有这个参数, 会在 SSH 服务器打开一个 Shell. `-f`: 将 SSH 连接放到后台. 没有这个参数, 暂时不用 SSH 连接时, 终端会失去响应. 假设本地开发机只能访问跳板机(`192.168.31.x`) 网络, 且跳板机因为多网卡能够访问 `192.168.21.x` 网络, 拓扑图如下: ![SSH-LocalForward.drawio.svg](https://cdn.dong4j.site/source/image/SSH-LocalForward.drawio.svg) ```shell # 在本地开发机上执行 ssh -N -f -L 9876:192.168.21.10:9876 root@192.168.31.33 # 关闭本地端口转发 ssh -O cancel -L 9876:192.168.21.10:9876 root@192.168.31.33 ``` 同时可以配置 `.ssh/config` 实现: ```shell Host lddns HostName 192.168.31.33 User root Port 22 IdentityFile ~/.ssh/server # LocalForward client-IP:client-port target-host:target-port LocalForward 9876 192.168.21.10:9876 ``` 当在本地机器上访问 localhost:9876 时, SSH 会将流量通过 **加密的 SSH 连接** 转发到远程服务器 192.168.21.10 的 9876 端口. 这里的 **加密的 SSH 连接** 是指 `192.168.31.33` SSH 连接通道. ![20241229154732_9jBdqLcR.webp](https://cdn.dong4j.site/source/image/20241229154732_9jBdqLcR.webp) **使用场景**: - **远程数据库访问**: 将本地的 3306 端口映射到远程服务器的 3306 端口, 这样就可以使用本地的数据库管理工具访问远程数据库. - **Web 服务访问**: 如果你正在远程服务器上开发 Web 应用, 并且需要从本地机器访问这个应用, 可以通过本地转发将远程服务器的端口映射到本地计算机的某个端口. - **VPN 替代方案**: 当你无法使用 VPN 时, 可以通过 SSH 本地转发来安全地访问远程网络中的资源. ##### 2. 远程转发 远程转发指的是在远程 SSH 服务器建立的转发规则. 它跟本地转发正好反过来. 建立本地计算机到远程 SSH 服务器的隧道以后, 本地转发是通过本地计算机访问远程 SSH 服务器, 而远程转发则是通过远程 SSH 服务器访问本地计算机. 它的命令格式如下. ```shell $ ssh -R remote-port:target-host:target-port -N remotehost ``` 上面命令中, `-R` 参数表示远程端口转发, `remote-port` 是远程 SSH 服务器的端口, `target-host` 和 `target-port` 是目标服务器及其端口, `remotehost `是远程 SSH 服务器. 远程转发主要针对内网的情况. 下面举两个例子. 第一个例子是内网某台服务器 `localhost` 在 80 端口开了一个服务, 可以通过远程转发将这个 80 端口, 映射到具有公网 IP 地址的 `my.public.server` 服务器的 8080 端口, 使得访问 `my.public.server:8080` 这个地址, 就可以访问到那台内网服务器的 80 端口. ```shell $ ssh -R 8080:localhost:80 -N my.public.server ``` 上面命令是在内网 `localhost` 服务器上执行, 建立从 `localhost` 到 `my.public.server` 的 SSH 隧道. 运行以后, 用户访问 `my.public.server:8080`, 就会自动映射到 `localhost:80`. 第二个例子是本地计算机 `local` 在外网, SSH 跳板机和目标服务器 `my.private.server `都在内网, 必须通过 SSH 跳板机才能访问目标服务器. 但是, 本地计算机 `local` 无法访问内网之中的 SSH 跳板机, 而 SSH 跳板机可以访问本机计算机. 由于本机无法访问内网 SSH 跳板机, 就无法从外网发起 SSH 隧道, 建立端口转发. 必须反过来, 从 SSH 跳板机发起隧道, 建立端口转发, 这时就形成了远程端口转发. 跳板机执行下面的命令, 绑定本地计算机 `local` 的 `2121` 端口, 去访问 `my.private.server:80`. ```shell $ ssh -R 2121:my.private.server:80 -N local ``` 上面命令是在 SSH 跳板机上执行的, 建立跳板机到 `local` 的隧道, 并且这条隧道的出口映射到 `my.private.server:80`. 显然, 远程转发要求本地计算机 `local` 也安装了 SSH 服务器, 这样才能接受 SSH 跳板机的远程登录. 执行上面的命令以后, 跳板机到 `local` 的隧道已经建立了. 然后, 就可以从本地计算机访问目标服务器了, 即在本机执行下面的命令. ```shell $ curl http://localhost:2121 ``` 本机执行上面的命令以后, 就会输出服务器 `my.private.server` 的 80 端口返回的内容. **示例**: 假设本地开发机(`192.168.31.8`)不能访问跳板机(`192.168.31.x`) 网络, 但是跳板机可以访问本地开发机, 且跳板机因为多网卡能够访问 `192.168.21.x` 网络, 这时本地开发机想访问 `192.168.21.10:9876` 服务. 拓扑图如下: ![SSH-RemoteForward.drawio.svg](https://cdn.dong4j.site/source/image/SSH-RemoteForward.drawio.svg) ```shell # 在跳板机上执行 ssh -R 1111:192.168.21.10:9876 -N dong4j@192.168.31.8 ``` 同时可以配置 `.ssh/config` 实现: ```shell Host rddns HostName 192.168.31.8 User dong4j Port 22 # RemoteForward remote-port target-host:target-port RemoteForward 1111 192.168.21.10:9876 ``` ![20241229154732_uDiQ0P2q.webp](https://cdn.dong4j.site/source/image/20241229154732_uDiQ0P2q.webp) **使用场景**: - 将还在公司加班的你本机上的 API 暴露到公网, 在家的同事就能和你愉快的调试接口了; - 你本地的软路由具备分流能力, 你兄弟需要临时用一下查阅点学习资料; ##### 3. 动态转发 动态转发指的是, 本机与 SSH 服务器之间创建了一个加密连接, 然后本机内部针对某个端口的通信, 都通过这个加密连接转发. 它的一个使用场景就是, 访问所有外部网站, 都通过 SSH 转发. 动态转发需要把本地端口绑定到 SSH 服务器. 至于 SSH 服务器要去访问哪一个网站, 完全是动态的, 取决于原始通信, 所以叫做动态转发. ```shell $ ssh -D local-port tunnel-host -N ``` 上面命令中, `-D `表示动态转发, `local-port `是本地端口, `tunnel-host `是 SSH 服务器, `-N `表示这个 SSH 连接只进行端口转发, 不登录远程 Shell, 不能执行远程命令, 只能充当隧道. 举例来说, 如果本地端口是 `2121`, 那么动态转发的命令就是下面这样. ```shell $ ssh -D 2121 tunnel-host -N ``` 注意, 这种转发采用了 SOCKS5 协议. 访问外部网站时, 需要把 HTTP 请求转成 SOCKS5 协议, 才能把本地端口的请求转发出去. 下面是 SSH 隧道建立后的一个使用实例. ```shell $ curl -x socks5://localhost:2121 http://www.example.com ``` 上面命令中, curl 的 `-x` 参数指定代理服务器, 即通过 SOCKS5 协议的本地 `2121` 端口, 访问 `http://www.example.com`. **示例**: ``` ssh -D 1234 intel -N curl -x socks5://localhost:1234 https://google.hk ``` 在 Mac mini 2018 上可以看到请求: ![20241229154732_SFzeDyB8.webp](https://cdn.dong4j.site/source/image/20241229154732_SFzeDyB8.webp) #### SSH 跳板机 ```shell # 跳板机 Host tjump HostName 公网 ip User {username} Port {port} Host nas HostName 192.168.1.20 User {username} Port {port} # 通过跳板登录到 192.168.1.20 Host jnas HostName 192.168.1.20 User {username} Port {port} ProxyJump tjump ``` 使用方式: ```shell ssh jnas # 或者 ssh -J tjump nas ``` #### SSH 两级跳板 首先, 我们需要在本机设置第一级 SSH 隧道. 执行以下命令: ``` $ ssh -L 1999:localhost:2999 tunnel1-host ``` 这条命令的作用是, 在本地机器上监听 `1999` 端口, 并将所有发往这个端口的请求, 通过 SSH 隧道转发到 `tunnel1-host` 这台机器的 `2999` 端口. 接下来, 在第一台跳板机 (`tunnel1-host`) 上, 我们需要建立第二级 SSH 隧道. 执行以下命令: ``` $ ssh -L 2999:target-host:3999 tunnel2-host -N ``` 这条命令的含义是, 将 `tunnel1-host` 上的 `2999` 端口通过 SSH 隧道连接到 `tunnel2-host`, 然后再由 `tunnel2-host` 转发到目标服务器 `target-host` 的 `3999` 端口. 这样设置后, 当我们访问本机的 `1999` 端口时, 请求实际上会被转发到 `target-host` 的 `3999` 端口. ![SSH-multi-stage.drawio.svg](https://cdn.dong4j.site/source/image/SSH-multi-stage.drawio.svg) **应用场景**: 这种多级端口转发的设置常用于以下情况: 1. 访问内网服务器: 当目标服务器位于多层内网中, 直接访问受限时, 可以通过多级跳板机进行访问. 2. 安全加固: 通过多级跳板机, 增加攻击者入侵的难度, 提高系统的安全性. 3. 网络隔离: 在需要隔离不同网络环境的情况下, 通过跳板机实现数据的传输. #### SSH 集中管理 iTerm2 通过 `Profiles` 来管理多个服务器(使用 `⌘ + O` 打开服务器列表), 但是并没有分组功能, 管理多个服务器不是特别方便. 这里有一些比较热门的终端管理工具: - [Termius](https://termius.com/) - [Warp](https://www.warp.dev/) - [Tabby](https://tabby.sh/) 目前我使用的是 [Royal TSX](https://royalapps.com/ts/mac/features), 目前觉得完全满足我的大部分需求, 且部分特性还能带来一些小惊喜: ![20241229154732_qJtszN5c.webp](https://cdn.dong4j.site/source/image/20241229154732_qJtszN5c.webp) **Royal TSX** 中的 `document` 概念是将所有被管理的 SSH 服务器集中持久化成一个文件(因为这个文件会存在服务器连接信息, 所以还支持加密), 我可以将这个文件通过 Synology Drive 同步到其他 Mac 上, 这就可以实现 `一次配置, 到处使用`; 另外像端口映射, 安全网关, 密钥管理等这些常规功能全部支持. 另外比较惊喜的就是 **Royal TSX** 能通过插件机制支持 **Web**, **RDP**, **VNC** 等远程管理功能: ![20241229154732_qPkpKpIh.webp](https://cdn.dong4j.site/source/image/20241229154732_qPkpKpIh.webp) 你一个比较惊喜的功能就是: `Broadcast Input to all Terminal Sessions`, 开启多个窗口, 在一台服务器上的操作会同步输出到其他服务器, 这个可以大大简化服务器的配置工作. **简洁的 SSH 转发设置**: ![20241229154732_Y4Nd46Ht.webp](https://cdn.dong4j.site/source/image/20241229154732_Y4Nd46Ht.webp) **串口连接**: ![20241229154732_wjJjhY4o.webp](https://cdn.dong4j.site/source/image/20241229154732_wjJjhY4o.webp) 但是 **Royal TSX** 也有它的不足, 比如 **文件管理不方便**, RDP 连接分辨率无法动态适配等, 所以我使用了另一款工具: **XTerminal**, 它的优点是文件管理和系统监控, 亮点是基于 AI 的命令行提示. ![20241229154732_ge0CQUf1.webp](https://cdn.dong4j.site/source/image/20241229154732_ge0CQUf1.webp) - 双击文件即可直接编辑文件 - 可拖拽上传下载文件 - 服务器监控面板 **AI 功能**: ![20241229154732_SEqIOKAz.webp](https://cdn.dong4j.site/source/image/20241229154732_SEqIOKAz.webp) **可视化的 SSH 端口转发配置**: ![20241229154732_bduEnWfP.webp](https://cdn.dong4j.site/source/image/20241229154732_bduEnWfP.webp) --- #### 参考 - [ssh 转发代理:ssh-agent 用法详解](https://www.cnblogs.com/f-ck-need-u/p/10484531.html): 避免重复输入 passphrase - [SSH agent forwarding 的應用](https://ihower.tw/blog/archives/7837): 在远程主机上使用本地的 ssh-agent 进行认证,比如在远程主机上进行 `git` 操作、`ssh` 到另一台主机等,避免将私钥 copy 到远程主机上。 - [How to Set Up SSH 2fa (Two-Factor Authentication)](https://blog.longwin.com.tw/2014/10/ssh-google-2-factor-authentication-2014/): 在 openssh server 上配置强制进行 2FA 双因素认证,每次登录都需要输入 Google Authenticator APP 提供的一次性动态密码。 ### RDP 上述 2 款工具都不能很好的支持完美的 RDP 远程桌面, 所有我使用了 [Microsoft Remote Desktop](https://apps.apple.com/us/app/windows-app/id1295203466?mt=12), ![20241229154732_mkDD0p2E.webp](https://cdn.dong4j.site/source/image/20241229154732_mkDD0p2E.webp) {% folding 如何删除 thinclient_drives %} **ubuntu**上安装 **xrdp** 搭建远程桌面, 后面远程桌面是可以了, 但是用户目录下生出了一个 thinclient_drives 文件夹, **无论是不是 root 都不能删除**, 如果你有强迫症, 你就感觉不舒服, 一定要删除, 下面介绍如何删除: 打开并修改 /etc/xrdp/sesman.ini 文件 ```shell sudo vim /etc/xrdp/sesman.ini # 将 FuseMountName=thinclient_drives 修改为 FuseMountName=.xrdp/thinclient_drives ``` 由于 ~/.xrdp 目录不存在, 所以不会创建 **thinclient_drives** 文件夹. 也可以将 **thinclient_drives** 放到 /run 目录下: ```shell FuseMountName=/run/user/%u/thinclient_drives ``` 然后删除 **thinclient_drives** 文件夹 ```shell sudo umount thinclient_drives sudo rm -rf thinclient_drives ``` 以后重新登陆也不会再出现 **thinclient_drives**, 并且远程连接共享剪贴板仍然有效. {% endfolding %} ### VNC 如果只是连接 macOS 的话, 官方的 **屏幕共享.app** 更值得推荐. 因为 **屏幕共享.app** 无法连接到树莓派, 所以使用 **RDP** 或 **VNC Viewer**. ![20241229154732_CI6WwtAR.webp](https://cdn.dong4j.site/source/image/20241229154732_CI6WwtAR.webp) {% folding 🪬 Ubuntu 远程桌面每次重启后 VNC 密码改变的解决方式 %} **问题描述**: - VNC 服务器在启动时如果密钥环未解锁, 将无法访问存储的加密 VNC 密码. - 结果是, **每次 VNC 服务器启动时**, 都会自动生成一个新的密码. **问题原因**: - Ubuntu 22.04 在自动启动期间不会自动解锁密钥环, 这影响了 VNC 服务器的正常使用. **解决方案**: 1. 打开“密码和密钥”实用工具: - 转到**实用工具**菜单, 选择**密码和密钥**. 2. 修改默认密钥环的密码: - 在**密码和密钥**管理器中, 右键单击**默认密钥环**. - 选择**更改密码**. 3. 输入并确认操作: - 系统将要求您输入当前的用户名密码. - 在**新密码**和**确认密码**字段中, 不要输入任何内容, 保持空白. 4. 接受风险警告: - 系统会警告您, 密钥环上存储的所有密码将不再加密, 并将保持未加密状态. - 如果您能够接受这个安全风险, 确认更改. 通过以上步骤, 可以解决 Ubuntu 22.04 中 VNC 服务器在自动启动时无法访问加密密码的问题. 但是这样做会降低密码的安全性, 因此可以采用下面推荐的方式. **推荐的方式**: 在**密码和密钥**应用程序中执行以下操作: 1. **创建一个新的无密码密钥环**: - 打开**密码和密钥**应用程序. - 创建一个新的密钥环, 并在创建过程中不设置密码. - 将这个新的无密码密钥环设置为默认. 2. **从登录密钥环中删除 VNC 密码**: - 在**密码和密钥**应用程序中, 找到登录密钥环. - 删除存储在登录密钥环中的 VNC 密码. 3. **重新启动计算机**: - 重启计算机以确保新的无密码密钥环成为默认密钥环. 重启后, 执行以下步骤: 1. **重新输入 VNC 密码**: - 在屏幕共享设置中重新输入 VNC 密码. - 这将把 VNC 密码存储在新的不安全密钥环中. 2. **恢复登录密钥环为默认**: - 回到**密码和密钥**应用程序. - 将登录密钥环重新设置为默认密钥环. 3. **再次重新启动计算机**: 现在 VNC 密码保持保存, 而默认密钥环也恢复为登录密钥环. 这种方法将所有密码保存为纯文本的不安全性降低到仅将 VNC 密码保存为纯文本. {% endfolding %} --- ## 暴露服务到公网 ### 关于 IPv4 截至 2024 年, IPv4 地址已经全球接近耗尽. 自 2011 年正式宣布 IPv4 地址枯竭以来, IPv4 地址池的可用数量持续减少, 导致剩余的地址在二级市场上的价格不断上涨 . IPv4 使用 32 位地址空间, 可以提供超过 40 亿个唯一地址. 然而, 随着互联网连接设备数量的激增, 这一地址池已经被耗尽. 北美、亚洲和欧洲是剩余 IPv4 地址的主要持有地区, 其他地区的 IPv4 地址资源相对匮乏 . 由于 IPv4 地址短缺, 许多组织和网络运营商开始依赖 NAT (网络地址转换) 技术, 允许多个设备共享一个公共 IP 地址. 此外, IPv6 的推广也成为解决 IPv4 地址耗尽问题的重要手段, 但由于兼容性问题以及过渡成本较高, IPv6 的部署进展较慢 . 目前, IPv4 地址的租赁和转售成为一种常见的做法, 尤其是在面临 IPv4 地址匮乏的企业中. 尽管如此, 这种做法仅是临时性的解决方案, 而 IPv6 的普及正在变得愈加迫切 . ### 为什么使用 DDNS 我早期申请的电信和联通的公网 IP 都是动态的, 会被运营商不定期的修改, 为了避免因公网 IP 变更无法访问家中的服务的问题, 现在最常用的方案就是使用 DDNS 服务. ### 什么是 DDNS 动态 DNS (DDNS) 是一项在 IP 地址发生变化时可以自动更新 DNS 记录的服务. 域名将网络 IP 地址转换为人类可读的名称, 便于识别和使用. 将名称映射到 IP 地址的信息以表格形式记录在 DNS 服务器上. 但是, 网络管理员会动态分配 IP 地址并经常更改. 每当 IP 地址发生变化时, DDNS 服务都会更新 DNS 服务器记录. 借助 DDNS, 域名管理变得更容易、更高效. ### 动态 DNS 是如何工作的 以下是一般步骤: 1. 向动态 DNS 服务提供商注册域名并配置 DNS 设置 2. 向提供商提供域名的初始 IP 地址 3. 使用更改的 IP 地址在设备或服务器实例上安装动态 DNS 客户端 DDNS 客户端持续监控 IP 地址并检测任何更改. 客户端向动态 DNS 提供商发送 DNS 记录更新通知, 通知其新的 IP 地址. 动态 DNS 提供商修改记录以指向新 IP 地址. 动态 DNS 客户端会继续监控 IP 地址以了解进一步的更改. 每当发生新的更改时, 该过程都会重复. ![20241229154732_h5KgUa28.webp](https://cdn.dong4j.site/source/image/20241229154732_h5KgUa28.webp) ![20241229154732_G1yaNntU.webp](https://cdn.dong4j.site/source/image/20241229154732_G1yaNntU.webp) 简单来说, 宽带运营商只会给我们动态的公网 IP, 这个 IP 会不定时变化, 为了能使域名映射到正确的 IP 上, 需要借助 DDNS 将当前最新的 IP 通过 API 通知到 DNS 服务运营商以修改为最新的 IP. 我所使用的域名服务商分别是阿里云和腾讯云, 为了正常使用 DDNS 服务, 需要到对应的域名运营商申请 API key, 后面使用 ddns-go 会用到. ### DDNS 服务配置 我目前的设备自带了 DDNS 服务的有: - Synology NAS: `外部访问-DDNS` - R2S 这类 OpenWrt 系统: `服务-阿里云 DDNS` 和 `服务-动态 DNS` 但是我并没有直接使用上述的服务, 而是选择使用 docker-compose 部署 [ddns-go](https://github.com/jeessy2/ddns-go), 这样可以方便的同步配置以便快速在其他设备上部署. #### dons-go 遇到的坑 ![20241229154732_zetykBpn.webp](https://cdn.dong4j.site/source/image/20241229154732_zetykBpn.webp) 1. TTL 设置错误导致 API 调用失败 DNS 运营商的 TTL 一般都是 600 秒, 再快就得加钱了, ddns-go 最好设置为自动, 避免 API 调用失败. 2. 通过接口获取 IPv4 太频繁被限流 ddns-go 自带了几个用于获取 IPv4 的 API 服务, 但是因为 ddns-go 频繁调用被 API 接口服务商限流; 3. 通过接口获取的 IPv4 错误 这是因为部署 ddns-go 的设备同时部署了分流服务, 需要修改分流规则, 我采用第二种方式: 不使用接口获取; 4. 通过网卡获取 这种方式只能获取到局域网 IP, 适用于 IPv6; 5. 通过命令获取 最终采用这种方式, 且返回 IPv4 地址的服务通过 Nginx 在云服务器自建了一个, 获取 IPv4 的命令如下: ```shell curl --interface 网卡名 url ``` 这种方式需要处理多网卡的情况, 因此需要 `--interface` 参数. 6. 通过网卡获取 IPv6 ![20241229154732_oi7eabiD.webp](https://cdn.dong4j.site/source/image/20241229154732_oi7eabiD.webp) IPv6 一般会有多个地址, 需要通过正则表达式筛选 > 电信为 240e 开头 (240e::/20) > > 移动为 2409 开头 (2409:8000::/20) > > 联通为 2408 开头 (2408:8000::/20) 7. ddns-go 启动前 10 分钟(具体几分钟记不得了, 反正越快越好)才能设置账号密码 ![20241229154732_XIzLrwv2.webp](https://cdn.dong4j.site/source/image/20241229154732_XIzLrwv2.webp) 最好禁用公网访问, 这样只能通过局域网 IP 访问. 8. ddns-go 可以配置 IP 变更通知, 我使用的是 [bark](https://bark.day.app/#/) 服务, 全平台可用, 且服务也能自托管: ![20241229154732_7ajHZeNd.webp](https://cdn.dong4j.site/source/image/20241229154732_7ajHZeNd.webp) #### 自建获取公网 IP 服务 我的目的很简单, 就是专为 ddns-go 提供的 IP 查询服务, 因此没有加 HTTPS 证书, 云服务器安装 Nginx 并添加配置, 关键的配置如下(记得在安全组开端口): ``` server { listen 端口号; listen [::]:端口号 ipv6only=on; server_name localhost; server_tokens off; location = /ip { add_header Content-Type text/plain; access_log off; return 200 "$remote_addr\n"; } } ``` 访问 `http://ip:port/ip` 即可返回文本格式的公网 IP. 如果想自行部署, 可参考 [利用 Nginx 实现简易的公网 IP 查询](https://cloud.tencent.com/developer/article/2286395). #### 获取正确的 IPv4 因为我有 2 个公网 IP, 域名分别对应不同的 DNS 服务商: | 宽带运营商 | 域名服务商 | 域名(假设) | 路由器 IP | | ---------- | ---------- | ----------- | ------------ | | 电信 | 阿里云 | dong4j.tele | 192.168.31.1 | | 联通 | 腾讯云 | dong4j.unic | 192.168.21.1 | #### 对于单网卡 比如当前设备是 `192.168.31.1` 这台路由器下的子设备, 按照上述表格, 应该获取到电信的公网 IP 并通知到阿里云的 DNS 已将 dong4j.tele 映射到正确的 IP 上. #### 对于多网卡 为避免单点问题, 我同时在 M920x, DS218+和 DS923+ 上部署了 ddns-go 服务, 它们同时具备多张网卡. 运行 `route -n` 显示的路由表如下: | 目标 | 网关 | 子网掩码 | 标志 | 跃点 | 引用 | 使用 | 接口 | | ------- | ------------ | -------- | ---- | ---- | ---- | ---- | --------------- | | 0.0.0.0 | 192.168.21.1 | 0.0.0.0 | UG | 100 | 0 | 0 | enx00e04c680012 | | 0.0.0.0 | 192.168.31.1 | 0.0.0.0 | UG | 103 | 0 | 0 | eno1 | **表头字段解析** - **目标 (Destination)**: 指向的目标网络或主机地址. `0.0.0.0` 表示默认路由, 适用于所有未匹配其他路由的流量. - **网关 (Gateway)**: 数据流量需要通过的下一跳 IP. 如果为 `0.0.0.0`, 表示目标为直连网络, 第一行是 `192.168.21.1`, 表示经过联通路由器. - **子网掩码 (Genmask)**: 定义目标网络的范围. - **标志 (Flags)**: - `U`: 路由是活动的. - `G`: 流量需要通过网关. - **跃点 (Metric)**: 优先级, 数值越小优先级越高 (**后面修改网卡优先级也就是修改这个值**). - **引用 (Ref)**: 保留字段, 通常为 `0`. - **使用 (Use)**: 该路由表条目被命中的次数. - **接口 (Iface)**: 数据流量通过的网络接口. **路由表内容分析** **第一行** | 目标 | 网关 | 子网掩码 | 标志 | 跃点 | 引用 | | ------- | ------------ | -------- | ---- | ---- | ---- | | 0.0.0.0 | 192.168.21.1 | 0.0.0.0 | UG | 100 | 0 | - **默认路由**: 匹配所有流量. - **网关**: `192.168.21.1`. - **接口**: `enx00e04c680012`. - **跃点**: `100`, 优先级高于第二行. **第二行** | 目标 | 网关 | 子网掩码 | 标志 | 跃点 | 引用 | | ------- | ------------ | -------- | ---- | ---- | ---- | | 0.0.0.0 | 192.168.31.1 | 0.0.0.0 | UG | 103 | 0 | - **默认路由**: 同样匹配所有流量. - **网关**: `192.168.31.1`. - **接口**: `eno1`. - **跃点**: `103`, 优先级低于第一行. **总结** 1. **多默认路由**: 两条默认路由同时存在, 系统会优先使用跃点较低的路由 (即 `192.168.21.1`) . 2. **故障转移**: 如果优先路由 (`192.168.21.1`) 不可用, 系统会自动尝试使用优先级较低的路由 (`192.168.31.1`) . 第一行跃点为 `100`, 且网关为 `192.168.21.1`, 表示 M920x 会优先使用 `enx00e04c680012` 这张网卡走联通网络. 同理我们整理一张表格: | 服务器 | 第一优先级 | 第二优先级 | | ------ | ------------------------------ | --------------- | | M920x | **enx00e04c680012** (**联通**) | eno1 (电信) | | DS218+ | **ovs_eth1**(**电信**) | ovs_eth0(联通) | | DS923+ | **ovs_eth2**(**联通**) | ovs_bond0(电信) | 为了保证 ddns-go 获取正确的 IPv4 地址, 应该这样配置: | ddns-go 所在的服务器 | 电信公网 IP (Domains: `*.dong4j.tele`) | 联通公网 IP (Domains: `*.dong4j.unic`) | | -------------------- | ---------------------------------------------- | ---------------------------------------------------- | | M920x | `curl --interface eno1 http://ip:port/ip` | `curl --interface enx00e04c680012 http://ip:port/ip` | | DS218+ | `curl --interface ovs_eth1 http://ip:port/ip` | `curl --interface ovs_eth0 http://ip:port/ip` | | DS923+ | `curl --interface ovs_bond0 http://ip:port/ip` | `curl --interface ovs_eth2 http://ip:port/ip` | - `http://ip:port/ip` 是前面说的自建的获取公网 IP 的服务; - 一定要注意第一优先级的网卡对应的是哪个路由器以及路由器对应的宽带服务商; ### 泛解析 前面通过使用 **DDNS (动态域名解析服务) **, 我们将动态公网 IP 与一个泛域名 (例如 `*.dong4j.tele`) 绑定. 这样一来: 1. **所有三级域名共享同一 IP 地址**: - 泛域名的配置意味着 `nas.dong4j.tele`、`blog.dong4j.tele`、`api.dong4j.tele` 等三级域名都会指向同一个动态公网 IP. - 无需为每个子服务单独创建域名解析记录. 2. **减少管理工作**: - 当公网 IP 变化时, DDNS 会自动更新与该 IP 关联的域名解析. - 无需手动更新每个子域名的记录, 提高了效率和维护便利性. 3. **便于扩展和服务管理**: - 不同的三级域名可以被路由到不同的内部服务或设备 (如 Web、FTP、SSH 等) . - 实现灵活的服务部署, 而不增加域名管理的复杂性. #### 示例 假设公网 IP 动态变化且通过 DDNS 绑定到了泛域名 `*.dong4j.tele`, 以下请求都能被正确路由: - `nas.dong4j.tele` → DS218+ WebUI - `blog.dong4j.tele` → 博客 - `api.dong4j.tele` → 接口服务 通过这种配置, 大大简化了域名管理流程, 同时适配动态 IP 环境. ### 端口映射 前面我们已经将公网 IP 成功绑定到了指定域名上, 比如我需要访问 DS218+ 的 WebUI, 只需要在路由器上开放指定的端口并映射到正确的 IP 上: ![20241229154732_yTkBViDh.webp](https://cdn.dong4j.site/source/image/20241229154732_yTkBViDh.webp) **最好修改外部端口号, 不要与内部端口号一样** 这样我们就可以通过 `http://nas.dong4j.tele:5000` 访问内部的 `http://192.168.31.3:5000`. 然而随着需要暴露的服务越来越多, 通过路由器进行端口转发管理会变得繁琐, 尤其是每个服务需要独占一个端口. 例如: - 服务 A → 8081 端口 - 服务 B → 8082 端口 - 服务 C → 8083 端口 这种方式不仅难以记忆, 还会造成端口冲突, 并且极度浪费端口资源. 如果熟悉 Nginx 反向代理的话, 除了通过端口区分服务, 还能通过域名区分: - `nas.dong4j.tele:5000` → 服务 A - `blog.dong4j.tele:5000` → 服务 B - `api.dong4j.tele:5000` → 服务 C 这样我们只需要一个端口即可代理多个服务. 以下是一个简单的 Nginx 配置, 用于将不同的域名路由到不同的内部服务: ```nginx server { listen 5000; server_name nas.dong4j.tele; location / { proxy_pass http://192.168.31.3:5000; # 映射到服务 A } } server { listen 5000; server_name blog.dong4j.tele; location / { proxy_pass http://192.168.31.4:8080; # 映射到服务 B } } server { listen 5000; server_name api.dong4j.tele; location / { proxy_pass http://192.168.31.5:1234; # 映射到服务 C } } ``` ### Nginx Proxy Manager 虽然 Nginx 的配置文件设计得非常简洁, 并支持热加载 (通过 `nginx -s reload`) , 无需完全重启服务, 但是在命令行下操作确实有点不方便. 可视化操作 Nginx 配置目前成熟的方案非常多, 比如: - [nginx-proxy-manager]() - [nginxWebU](https://www.nginxwebui.cn/) - [NGINX Config](https://do.co/nginxconfig) - [Nginx Proxy Manager](https://nginxproxymanager.com/) - 1Panel 中的 OpenResty - 宝塔面板中的 Nginx 这类服务选择一个顺手的就行, 目前我使用的是 [Nginx Proxy Manager](https://nginxproxymanager.com/): ![20241229154732_URyCJuXy.webp](https://cdn.dong4j.site/source/image/20241229154732_URyCJuXy.webp) **为了安全起见, 已将所有服务迁移到 雷池 Safeline**. #### 反向代理 ![20241229154732_D1jHvCKb.webp](https://cdn.dong4j.site/source/image/20241229154732_D1jHvCKb.webp) 反向代理配置非常简单, 只需要配置: - 域名 - 协议: 内网一般选择 http, 外部访问如果需要 HTTPS, 则需要在 SSL 选项卡中配置 HTTPS 证书, 这个后面详细介绍; - 转发主机: 内部服务所在的服务器 IP - 转发端口: 服务端口 我们以外网通过 HTTP 访问 NAS (`192.168.31.3`) 为例, 假设 Nginx Proxy Manager (后面简称 `NPM` )监听的 HTTP 端口为 `6080`, IP 为 `192.168.31.10` , 那么配置步骤如下: - 在路由器上配置端口转发: `1020` 端口指向 `192.168.168.31.10` 的 6080 端口; - 在 `NPM` 添加代理服务配置: ![20241229154732_xBkppcxh.webp](https://cdn.dong4j.site/source/image/20241229154732_xBkppcxh.webp) 访问 `nas.dong4j.tele:1020` 即可访问 NAS 的 WebUI 服务. ![NPM-HTTP.drawio.svg](https://cdn.dong4j.site/source/image/NPM-HTTP.drawio.svg) 整体的流程为: 1. ddns-go 通过定时调用 API 将获取到的最新公网 IP 推送给域名服务商; 2. 在路由器将特定端口绑定到 NPM 的 HTTP 端口, 比如 1020 -> 6080; 3. 在 NPM 进行代理服务配置, 将域名映射到指定服务; 这样我们就能通过 **某个固定的端口结合不同的域名访问不同的服务**, NPM 也提供 [REST API](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/develop/backend/schema/swagger.json), 你可以玩一些比较花的操作, 比如通过脚本随时关闭/开启某个代理服务. #### HTTPS 证书申请 NPM 还提供 `Let's Encrypt` 证书申请服务, 可申请 3 个月的免费证书并能够自动续期. 需要验证你对证书中域名的控制权, 也就是说你要能证明, 这个域名是属于你的. 有两种验证方式: 一种是基于 `HTTP` 的验证方式, 另一种是基于 `DNS` 的验证方式. 关于这方面的教程非常多, 所以这里就不在赘述了, 只说说我遇到的问题: 1. `ModuleNotFoundError: No module named 'zope' #2440` [这里是 Issues](https://github.com/NginxProxyManager/nginx-proxy-manager/issues/2440) 解决方法: ```shell # 进入 docker 容器 docker exec -it /bin/bash xxxx pip install certbot-dns-dnspod pip install zope -i https://pypi.tuna.tsinghua.edu.cn/simple ``` 2. `Another instance of Certbot is already running` ```shell find / -type f -name '.certbot.lock' -exec rm {} \; ``` 在使用的过程中, 第一次申请证书会消耗大量时间, 出现多次申请失败的情况, 且自动续期经常会失败, 再尝试了通过 `1Panel` 的证书管理功能后, 果断弃用了 NPM 的 HTTPS 证书管理功能, 这个在 [网络安全-HTTPS 证书](#HTTPS 证书) 一节中详细说明. **值得注意的是, 如果启用了 HTTPS, 在路由器的端口转发配置时, 需要将外部端口映射到 NPM 的 HTTPS 端口.** #### 开启 HTTPS 访问 HTTPS 证书申请后给域名添加 HTTPS, 需要注意你的 NPM 的 HTTPS 端口号, 比如是 `30443`, 那么在路由器配置端口转发时就要使用此端口号. ![NPM-HTTPS.drawio.svg](https://cdn.dong4j.site/source/image/NPM-HTTPS.drawio.svg) #### 自定义位置 自定义位置是在特殊场景下提供的一种定制化配置, 比如我的 `bark` 服务, 它的完整路径是这样的: ``` http://192.168.31.20:8088/iphone-uuid/ http://192.168.31.20:8088/mbp-uuid/ # xxx-uuid 通常为 22 位随机字符串 ``` 这个 URL 会作为 Webhook 在多个地方使用, 且会有多个客户端的 uuid, 比如上面的 `iphone-uuid` 就是将消息通知到手机, 而 `mbp-uuid` 则是通知到 MBP, 为了方便记忆, 且在 `bark` 重置 uuid 后不需要重新修改设备上的 Webhook 地址, 可以这样设置: ![20241229154732_Bt3WnONz.webp](https://cdn.dong4j.site/source/image/20241229154732_Bt3WnONz.webp) Webhook 地址则变更为: ``` http://bark.dong4j.tele:1020/iphone/ http://bark.dong4j.tele:1020/mbp/ ``` [这里有一个通过 NPM 搭建获取公网 IP 服务的说明](obtain_public_ip_address_using_nginx.md) #### 高级配置 如果 WebUI 上无法通过可视化配置满足你的需求, 你可以进入 NPM 挂载的目录直接修改配置: 可以按照如下方式添加自定义配置片段文件`/data/nginx/custom`: - `/data/nginx/custom/root_top.conf`: 包含在 nginx.conf 的顶部 - `/data/nginx/custom/root.conf`: 包含在 nginx.conf 的最末尾 - `/data/nginx/custom/http_top.conf`: 包含在主 http 块的顶部 - `/data/nginx/custom/http.conf`: 包含在主 http 块的末尾 - `/data/nginx/custom/events.conf`: 包含在事件块的末尾 - `/data/nginx/custom/stream.conf`: 包含在主流块的末尾 - `/data/nginx/custom/server_proxy.conf`: 包含在每个代理服务器块的末尾 - `/data/nginx/custom/server_redirect.conf`: 包含在每个重定向服务器块的末尾 - `/data/nginx/custom/server_stream.conf`: 包含在每个流服务器块的末尾 - `/data/nginx/custom/server_stream_tcp.conf`: 包含在每个 TCP 流服务器块的末尾 - `/data/nginx/custom/server_stream_udp.conf`: 包含在每个 UDP 流服务器块的末尾 更多的配置说明可自行查看 [NPM 官方文档](https://nginxproxymanager.com/advanced-config/) #### 代理局域网自定义域名 ##### Surge + NPM 后面会讲到 [使用 雷池 Safeline 代替 NPM](#用 WAF 替换 NPM), 所有代理服务都会迁移过去, 所以 NPM 就沦为了局域网自定义域名的代理工具, 接下来 **设备域名映射一节** 中关于如何使用 [NPM + Surge 来简化域名访问的详细配置](#通过自定义域名减少需要记忆的 IP 数量). 流程大致如下: ![NPM_Surge_proxy.drawio.svg](https://cdn.dong4j.site/source/image/NPM_Surge_proxy.drawio.svg) 1. Surge 配置自定义域名: ```txt # 所有的 *.npm 全部解析成 192.168.31.10 *.npm = 192.168.31.10 ``` 2. 配置 NPM: ![20241229154732_oWNfAFQJ.webp](https://cdn.dong4j.site/source/image/20241229154732_oWNfAFQJ.webp) 意思是来自 `nas.npm` 的请求会被代理到 `http://192.168.31.3:5000` 上, 即 DS218+ 的 WebUI 服务. **总结下来**: 1. Surge 将 `*.npm` 全部解析成 `192.168.31.10`; 2. 当请求 `xx.npm` 时被发送到 NPM 的 `80` 端口; 3. NPM 根据 **域名** 判断应该代理请求到哪个服务; 为了简化, 我们使用 NPM 的 `80` 端口, 所以需要注意的 NPM 的端口配置: ```yml services: app: image: 'chishin/nginx-proxy-manager-zh:latest' ports: - '80:80' # HTTP 代理端口 - '12443:443' # HTTPS 代理端口 - '1281:81' # WebUI 管理端口 ... ``` 接下来就是在 NPM 上重复添加代理配置了: ![20241229154732_Vc56ORPo.webp](https://cdn.dong4j.site/source/image/20241229154732_Vc56ORPo.webp) **我的自定义域名规则**: - `.npm` 作为顶级域名; - `{服务器}.npm` 作为二级域名, 比如: `nas.npm`; - `{服务名}.{服务器}.npm` 作为三级域名, 比如: `ddns.nas.npm` **值得注意的是, 因为 .npm 不是有效的 TLD**, 因此在 Chrome 第一次使用的时候, 需要输入完整的 URL: `http://nas.npm`, 否则会被当成搜索请求, 成功打开一次后即可, 后面就不需要完整输入 URL 了. 当然你也可以将 `.npm` 改成其他的比如: `.local`. **补充说明**: 因为 **NPM** 只能代理 **HTTP** 协议, 如果 **TCP/UDP** 协议也想使用域名, 你可以在 Surge 添加一条配置, 比如 DS218+ 的 IP 是 `192.168.31.3`, 我想使用 `ssh.nas.npm` 连接 SSH: ``` ssh.nas.npm = 192.168.31.3 *.npm = 192.168.31.10 ``` 使用方式: ```shell ssh root@ssh.nas.npm ``` ##### Surge + SmartDNS + NPM 其实还可以结合 SmartDNS 来达成同样的目的, 但是这种方式比 **Surge + NPM** 多了一层, 这是没有采用的 **原因之一**. **Surge** 只是用来获取指定域名的 DNS 地址, **SmartDNS** 用于 DNS 解析, NPM 的作用不变. **具体配置为**: 1. Surge Hosts 配置修改为: ```shell *.npm = server:192.168.31.10 ``` 表示 `*.npm` 域名使用部署在 `192.168.31.10` 上的 **SmartDNS** 提供的 DNS 服务来获取解析. 2. **SmartDNS** 配置如下(官方文档说是支持 `*.npm` 这种通配符解析的, 但是本地测试并没有成功, 这是没有使用的 **原因之二** ): ```shell address /nas.npm/192.168.31.10 address /tnas.npm/192.168.31.10 .... # 其他域名配置, 同样是指向 192.168.31.10 ``` **SmartDNS** 的意义在于作为一个 DNS 服务为局域网设备提供解析服务, 问题是需要在多台服务器上进行 DNS 地址配置, 我的目的只是在主要设备上实现域名访问, Surge 显然更适合, 所以这也是没有使用的 **原因之三**. ### IPv6 #### 中国的 IPv6 发展情况 截至 2024 年, 中国在 IPv6 的发展方面取得了显著进展. 以下是根据《中国 IPv6 发展状况白皮书 (2024) 》和相关报道总结的几个关键点: 1. **IPv6 用户数量**: 截至 2024 年 5 月, 中国的 IPv6 活跃用户数达到 7.94 亿, 占全体网民总数的 72.7%. 这个比例从 2017 年初的 0.51% 显著提高. 已分配 IPv6 地址的终端数达到 17.65 亿, 其中移动网络终端为 13.50 亿, 固定宽带接入网络终端为 4.15 亿. 2. **发展阶段**: 中国 IPv6 的发展经历了起步期 (2017 年至 2018 年中) 、爬升期 (2018 年中至 2023 年一季度) 和稳定期 (2023 年一季度至今) 三个阶段. 3. **网络流量**: 移动网络 IPv6 流量占比达到 64.56%, 固定网络 IPv6 流量占比为 21.21%. 城域网 IPv6 总流量占全网总流量的 21.21%. 4. **网络性能**: IPv6 网络性能得到显著提升, 其时延降低, 丢包率减少, 显示出在网络性能和稳定性方面的潜力. 5. **基础资源**: 中国在 IPv6 地址申请量和拥有量方面均位居世界第二, IPv6 AS (自治系统) 数量占比达到 70.43%. 6. **政策支持**: 中国政府高度重视 IPv6 的部署和应用, 自 2017 年《推进互联网协议第六版 (IPv6) 规模部署行动计划》发布以来, 已出台多项政策, 明确 IPv6 发展目标和时间表. #### 全球 IPv6 发展情况 全球范围内, IPv6 的部署和应用也在加速推进. 以下是根据《2024 全球 IPv6 发展指数报告》总结的几个关键点: 1. **全球覆盖率**: 2023 年, 全球 IPv6 网络部署显著提速, 总体覆盖率首次突破 30%. 部分领先国家的 IPv6 覆盖率已经达到或接近 70%, 其 IPv6 移动流量占比也已超过 IPv4. 2. **技术发展**: 随着 IPv6 网络的基本建成, 领先国家的 IPv6 规模部署及应用开始进入新的阶段, 政策聚焦逐渐从网络扩张转向 IPv6 Enhanced 技术的产品化落地和规模化应用. 3. **数字经济**: IPv6 作为数字基建的底座, 是互联网升级和网络技术创新的重要基石. IPv6 创新技术应用融合行业应用场景加速落地, 为各行业的数字化、智能化转型提供了关键支持. IPv6 都在迅速发展, 已成为成为支撑现代互联网和数字经济发展的关键技术. IPv6 对于个人用户的一个巨大优势在于, 即使宽带运营商不提供 IPv4 公网 IP, 也可以通过 IPv6 直接访问家中的设备. **IPv6 的优势**: 1. **无 NAT 限制** 在 IPv4 网络中, 运营商通常使用 NAT (网络地址转换) 来将多个用户共享一个公网 IP, 这限制了用户通过公网访问家中设备的能力. 而 IPv6 提供了几乎无限的地址空间, 每个设备都可以拥有独立的公网 IPv6 地址. 2. **端到端通信** IPv6 支持真正的端到端通信, 消除了 NAT 带来的复杂性. 这使得用户可以直接通过 IPv6 地址远程访问家中的路由器、NAS、摄像头等设备, 无需依赖 DDNS 或复杂的端口映射. 3. **更高的安全性** IPv6 内置了 IPsec 支持, 可以实现更安全的通信. 同时, IPv6 的分布式地址分配方式, 减少了攻击者扫描设备的可能性. 4. **与 5G 和物联网结合** 随着 5G 和智能家居的发展, 更多设备开始支持 IPv6, 这将进一步简化联网设备的配置和管理. #### 实现方式 1. **确认运营商支持 IPv6** 确保宽带提供商已经开通 IPv6 服务. 当前中国主要的运营商 (如中国电信、中国联通、中国移动) 正在大力推进 IPv6 部署. 2. **配置路由器** 确保家庭路由器支持并启用了 IPv6, 开启后将为家中设备分配公网 IPv6 地址. 3. **远程访问设备** 通过 IPv6 地址直接访问设备, 例如: `http://[your-ipv6-address]:port`. 也可以使用 DDNS 服务将复杂的 IPv6 地址映射为易记的域名. 4. **兼容性处理** 对于不支持 IPv6 的场景, 可以使用 IPv6 到 IPv4 的隧道技术 (如 6to4 或 Teredo) 来实现兼容. 通过这些方式, 即使没有 IPv4 公网地址, 也能利用 IPv6 更方便地管理家中的设备, 特别是在需要多设备远程访问的情况下. #### 现状 我目前已经实现了主要设备的 **IPv6** 访问, 且通过 DDNS-GO 绑定了域名, 但是因为安全性问题(安全认证登录并没有覆盖到所有服务, 因为绑定了域名, 如果被猜测出端口号, 就会被非法访问), 暂时关闭了所有的 **IPv6** 访问(开启路由器的 IPv6 防火墙). **与 IPv6 相关的服务配置, 比如 Surge, OpenClash, ShellClash, Pass Wall 等就自行搜索了, 本文不过多描述.** #### IPv6 相关资源 **IPv6 测试地址**: - [IPv6 连接测试](https://test-ipv6.com/) - [IPv6 查询与检测](https://ipw.cn/) - [ITDOG](https://www.itdog.cn/ping_ipv6/) **IPv6 报告**: - [国家 IPv6 发展监测平台](https://www.china-ipv6.cn/) - [中国 IPv6 发展状况(2019)](http://www.caict.ac.cn/kxyj/qwfb/ztbg/201907/P020190712576587138174.pdf) - [全球 IPv6 发展指数报告 2024](https://www.rolandberger.com/publications/publication_pdf/Global-IPv6-Development-Report-2024_CN.pdf) --- ## 网络安全 网络安全涉及到的方面非常多, 大体包括: 1. 强密码策略 + MFA 多因素认证; 2. 使用 vLAN 或子网进行网络隔离; 3. 使用 HTTPS 加密协议; 4. 部署防火墙; 5. 定期更新服务, 修复已知漏洞; 6. [蜜罐系统](https://github.com/paralax/awesome-honeypots); 在 Homelab 环境中, 部署物理防火墙虽然是理想的安全方案, 但成本高昂, 对于普通用户并不现实. 相比之下, **使用 HTTPS 加密协议** 是最容易实现且高效的安全措施之一. 而 `强密码策略 + MFA 多因素认证` 这个则是个人使用习惯, 我一般都会使用 `Bitwarden` 生成 **不重复且复杂的** 密码, 且开启具备 `MFA 多因素认证` 服务的二次认证, 在 macOS 使用了 [Step Two](https://steptwo.app/) 来管理一次性密码, 这样的 APP 还有很多, 比如还有 [Authenticator](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&pli=1). 最近打算部署一个 蜜罐系统玩玩儿, 它是一种攻防兼备的工具, 通过欺骗和分析攻击者来增强网络防御, 通过主动布防, 主动示弱攻击者, 并且进行反制, 使网络攻击不再是一个没有损失、只有收益的事情. ### HTTPS 证书 前面介绍过使用 [Nginx Proxy Manager 来申请并自动续期免费的 HTTPS 证书](#HTTPS 证书申请), 但因为不稳定而被弃用, 转而使用 1Panel 的 HTTPS 的证书申请与管理服务: ![20241229154732_SE63DzWC.webp](https://cdn.dong4j.site/source/image/20241229154732_SE63DzWC.webp) 1Panel 的证书申请非常快且成功率 100%, 可以采用手动的方式为 **雷池 Safeline** 添加证书, 也可以 [自动化部署 HTTPS 证书到 WAF](#自动部署 1Panel 申请的 HTTPS). ![20241229154732_WrtceKg5.webp](https://cdn.dong4j.site/source/image/20241229154732_WrtceKg5.webp) ### 雷池 Safeline [雷池](https://waf-ce.chaitin.cn/) 号称 **下一代 Web 应用防火墙**, 社区版功能完全够用(主要因为专业版太贵 🥲), 专业版多了告警, 负载均衡等功能: ![20241229154732_YRcE1Gho.webp](https://cdn.dong4j.site/source/image/20241229154732_YRcE1Gho.webp) 用 [官方的测试工具](https://github.com/chaitin/blazehttp) 玩了一下, 攻击检测日志中的信息非常全面: ![20241229154732_VxcQXWa4.webp](https://cdn.dong4j.site/source/image/20241229154732_VxcQXWa4.webp) 且还有 AI 攻击分析: ![20241229154732_iXXaoqbt.webp](https://cdn.dong4j.site/source/image/20241229154732_iXXaoqbt.webp) 后续我将使用 **WAF** 代替 **雷池 Safeline**. #### 用 WAF 替换 NPM 买不起硬件防火墙, 软件防火墙我们还不能白嫖吗? 所以为了玩一玩这个号称这么牛逼的软防火墙, 将 NPM 的代理全部迁移到了 WAF, 现在 NPM 则沦为 [内网自定义域名的代理服务](#代理局域网自定义域名). 所以就直接使用 2 台虚拟机通过 Docker 部署了 WAF, 分别替代电信和联通网络的 NPM: ![20241229154732_iUHGX8ah.webp](https://cdn.dong4j.site/source/image/20241229154732_iUHGX8ah.webp) ![20241229154732_idtQIkzN.webp](https://cdn.dong4j.site/source/image/20241229154732_idtQIkzN.webp) 防护功能要比 NPM 多几个, 且有 Dashboard 展示访问与拦截情况. #### 配置域名代理 与 NPM 不同的是 WAF 在配置域名时确认端口号, 而 NPM 在启动时就确认了 HTTP 和 HTTPS 端口 , 所以 WAF 的优势是能够为不同的域名设置不同的端口: ![20241229154732_YMIhiIMR.webp](https://cdn.dong4j.site/source/image/20241229154732_YMIhiIMR.webp) 如上配置的话, 就需要在路由器分别为 `1443` 和 `2443` 配置端口转发规则, 局域网 IP 添加部署 WAF 的服务器 IP. ##### NAS 域名代理 补充说明一下如何代理 `Synology Drive` 流量. `Synology Drive` 客户端全平台支持, 我在手机和电脑上使用了 Drive 客户端. 假设我想使用 `nas.dongj4.tele:1443` 来访问 NAS 上(`192.168.31.3`) 的 `Synology Drive` 服务, 那么只需要在 WAF 添加一个域名配置: ![20241229154732_KQWn57pG.webp](https://cdn.dong4j.site/source/image/20241229154732_KQWn57pG.webp) 熟悉 Synology 的朋友都知道 `5000` 是 NAS 的 WebUI 的 HTTP 端口. 接着在路由器上配置端口转发, 将 `1443` 转发到 `192.168.31.10:1443`, 然后在手机客户端上登录: ![20241229154732_tGBKjnmU.webp](https://cdn.dong4j.site/source/image/20241229154732_tGBKjnmU.webp) 配置没问题的话应该就可以正常登录了, 如果登录失败请自行检查端口转发, NAS 的端口号等是否设置正确. 然后如果你将上述的域名和端口配置到桌面版的 Drive client 会发现连接不上, 这是因为 **PC 端 Drive 程序开放的默认访问端口为 6690**, 对移动端的 App 开放的默认访问端口为 **5000**. > Synology Drive 客户端会根据输入的域名或 IP 自动选择默认端口进行访问: > > - **PC 客户端**: 如果未指定端口, 会默认使用 **6690** > - **移动客户端**: 默认使用 **5000** (HTTP) 或 **5001** (HTTPS) > > 如果用户明确填写了端口号, 客户端会优先使用指定的端口. > > 在局域网中, 直接输入 NAS 的 IP 地址即可连接, 是否加端口号均可;在外网访问时, 需要将外部端口转发到 NAS 的相应内部端口 (例如 6690) [DSM 服务使用的网络端口](https://kb.synology.cn/zh-cn/DSM/tutorial/What_network_ports_are_used_by_Synology_services) ![20241229154732_lHY8HjzL.webp](https://cdn.dong4j.site/source/image/20241229154732_lHY8HjzL.webp) 所以为了让桌面版的 Drive client 能正常使用, 我们需要在路由器上多配置一个端口转发规则 ([因为 WAF 它不支持 TCP/UDP 转发](#WAF 的代理的局限性)): ![20241229154732_nlmVvkzT.webp](https://cdn.dong4j.site/source/image/20241229154732_nlmVvkzT.webp) 桌面版 Drive 连接配置: ![20241229154732_m3hA6Drx.webp](https://cdn.dong4j.site/source/image/20241229154732_m3hA6Drx.webp) 这里 **启用 SSL 数据传输加密** 最好能勾选上, 但因为没有通过 WAF 转发, 所有无法使用 WAF 中的证书配置, 你需要在 NAS 中配置 HTTPS 证书: > 后面会讲到为什么使用 1Panel 的证书管理代理 WAF 的证书管理, 且实现 1Panel 申请证书并自动部署到 WAF. > > 这里会先用到 1Panel 的 2 个证书, 如果没啥头绪, 可以先看完 [自动部署 1Panel 申请的 HTTPS](#自动部署 1Panel 申请的 HTTPS) 再回过来看这里. NAS 新增证书: 1. 入口: 控制面板-安全性-证书 2. 新增证书-导入证书或从 Let's Encrypt 获取证书 (如果是更新证书就选第二项: **替换已有证书**)-导入证书(不要勾选 **设为默认证书**[如果你知道是什么操作可执行选择]): ![20241229154732_8lNaX5kY.webp](https://cdn.dong4j.site/source/image/20241229154732_8lNaX5kY.webp) 私钥: **privkey.pem** 证书: **fullchain.pem** 中间证书: 可不填 3. 为 Drive 配置自定义证书: ![20241229154732_LtfPm6CP.webp](https://cdn.dong4j.site/source/image/20241229154732_LtfPm6CP.webp) ##### NAS 自动部署 HTTPS 证书 当然这又是另一个话题了, 我会单独出一个指南, 通过 [acme.sh](https://github.com/acmesh-official/acme.sh) 将证书自动部署到 NAS 和 WAF. 相关链接: - [两个优化点 #660](https://github.com/chaitin/SafeLine/issues/660) - [证书增加使用路径导入方式 #782](https://github.com/chaitin/SafeLine/issues/782) - [群晖 DSM7.x 通过 acme.sh 全自动更新并部署 SSL 证书](https://blog.zakikun.com/archives/80.html) - [记我群晖上的 Docker 项目: ddns-go 和 acme.sh](https://www.iamlm.com/blog/119.%E8%AE%B0%E6%88%91%E7%BE%A4%E6%99%96%E4%B8%8A%E7%9A%84Docker%E9%A1%B9%E7%9B%AE%EF%BC%9Addns-go%E5%92%8Cacme.sh/) - [HTTPS certificates for your Synology NAS using acme.sh](https://github.com/acmesh-official/acme.sh/wiki/Synology-NAS-Guide) #### 不使用 WAF 的证书管理 **WAF** 无法申请泛域名证书, 所以就有点尴尬. ![20241229154732_cL5QfgRD.webp](https://cdn.dong4j.site/source/image/20241229154732_cL5QfgRD.webp) 不过最新的情况是官方已经在考虑 [通过 DNS 验证的方式来支持泛域名证书申请了](https://github.com/chaitin/SafeLine/issues/563), 所以还是可以期待一下. WAF 的其他 [开发计划](https://waf-ce.chaitin.cn/community) 也可以关注一下. #### WAF 的代理的局限性 **只能代理 HTTP 服务**, 不支持四层代理转发. 官方的回答是不支持, 没必要: **WAF 只关注七层网络协议, 专注于 HTTP 流量的检测与清洗**. 但是 WAF 一般都是部署在公网之后的那台服务器, 作为所有流量的入口, 然后才是转发到后面的 Nginx 或其他服务, 如果只支持七层代理转发的话, 诸如 SSH 这类 TCP/UDP 层的流量还得单独配置. [可以看看这里的讨论](https://github.com/chaitin/SafeLine/issues/123), 不知道官方会不会支持. 为了解决上述问题, 我目前使用的方案: - 如果是 HTTP 服务, 全部通过路由器转发到 WAF 所在的服务器上, 然后由 WAF 进行后续的转发; - 其他服务比如 WireGuard, Snell 等四层协议的流量则通过路由器直接转发至指定的服务器 (前面 [NAS 域名代理](#NAS 域名代理) 就是一个例子); #### WAF 社区版的局限性 和 NPM 的配置自由度比起来, WAF 社区版则不是那么具有可玩性, 因为 WAF 会定时覆盖手动修改的配置文件, 不过也有不是那么优雅的解决方案, 接下来通过一个例子来说明一下操作方式. 首先在 WAF 上创建一个站点, 用于返回 **当前时间** 和 **客户端真实 IP**: ![20241229154732_lXVNRKcT.webp](https://cdn.dong4j.site/source/image/20241229154732_lXVNRKcT.webp) 点击创建好的站点, 然后渠道 **静态资源** 选项卡, 添加一个 **index.html** 页面, 内容如下: ```html Current Time

``` 访问 `https://test.dong4j.tele:1234` 可返回当前时间. 接下来去服务器手动修改 WAF 的 nginx 配置文件: 刚刚添加的测试站点的配置文件为: `/opt/safeline/resources/nginx/sites-enabled/IF_backend_13`, 打开并编辑, 在 `server` 中添加如下配置: ``` location = /ip { add_header Content-Type text/plain; access_log on; return 200 "$remote_addr\n"; } ``` 保存退出, 为了防止 WAF 定期覆盖手动修改后的配置文件, 我们需要给刚刚的配置文件添加 **不可变属性** ```shell chattr +i IF_backend_13 ``` 重新加载 Nginx 配置: ```shell docker exec -it safeline-tengine nginx -t && \ docker exec -it safeline-tengine nginx -s reload ``` 最后访问 `https://test.dong4j.tele:1234/ip` 可获取到客户端 IP (使用蜂窝网络的手机访问). 关键点是 `chattr +i`, 防止 WAF 覆盖我们修改过后的文件, 如果需要再次编辑, 使用 `chattr -i 文件名` 恢复. ### 自动部署 1Panel 申请的 HTTPS 使用 WAF 一键部署脚本时会让你选择安装的目录, 我安装在 `/opt` 目录下, 所以 Nginx 的目录: `/opt/safeline/resources/nginx/sites-enabled/` (你可以查看 `/opt/safeline/conpose.yaml` 文件, 了解其他挂在的目录的位置). **我先添加一个测试站点**: ![20241229154732_1zJBR0CJ.webp](https://cdn.dong4j.site/source/image/20241229154732_1zJBR0CJ.webp) 对应的 Nginx 配置文件为 **IF_backend_15**: ``` upstream backend_15 { server 192.168.31.10:9876; keepalive 128; keepalive_timeout 75; } ... server { listen 0.0.0.0:1443 ssl http2; server_name test.dong4j.tele; ssl_certificate /etc/nginx/certs/cert_1.crt; ssl_certificate_key /etc/nginx/certs/cert_1.key; ... if ($host !~* ^(test\.dong4j\.tele)$) { rewrite ^ /not_found_page last; } .... location ^~ / { proxy_pass http://backend_15; ... } ``` 可以看到用到的公钥证书和私钥分别是: `/etc/nginx/certs/cert_1.crt`, `/etc/nginx/certs/cert_1.key` (如果已经有多个 HTTPS 证书, 后缀可能会是其他的数字). 他们对应的挂载目录为: `/opt/safeline/resources/nginx/certs` 所以我们的目标就是自动换 `certs` 中的证书, 我们使用脚本来实现这一自动化过程. 1. 在 1Panel 上配置 **推送证书到本地目录**: ![20241229154732_f0epxZwE.webp](https://cdn.dong4j.site/source/image/20241229154732_f0epxZwE.webp) 如果原来没有配置证书推送, 需要重新申请一次: ![20241229154732_XqtbWGyP.webp](https://cdn.dong4j.site/source/image/20241229154732_XqtbWGyP.webp) 然后指定目录下会出现 证书文件`fullchain.pem` 和密钥文件 `privkey.pem`, 因为 `fullchain.pem` 包含多个证书文件, 而 `openssl x509` 默认只处理单个证书, 所以我们可以直接拷贝 `fullchain.pem` 的内容, 接下来就是脚本部分(脚本名称为: `sync_certs.sh`): {% folding 🪬 查看 sync_certs.sh 脚本内容 %} **看到这里先别急着使用这个脚本, 接着往下看, 还有更好的方式, 这里只是记录一下折腾过程.** ```python #!/bin/bash # 使用说明 usage() { echo "Usage: $0 " echo "Example: $0 waf.t" exit 1 } # 检查是否传入参数 if [ -z "$1" ]; then usage fi # 定义变量 WAF="$1" CERT_FILE="fullchain.pem" KEY_FILE="privkey.pem" CERT_PATH="/opt/safeline/resources/nginx/certs/cert_1.crt" KEY_PATH="/opt/safeline/resources/nginx/certs/cert_1.key" DOCKER_CONTAINER="safeline-tengine" # 日志函数 log() { echo "[INFO] $1" } error() { echo "[ERROR] $1" >&2 exit 1 } # 检查文件是否存在 if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then error "证书文件或密钥文件不存在, 请确保 $CERT_FILE 和 $KEY_FILE 存在" fi # 复制证书和密钥 log "正在复制证书和密钥到 ${WAF}..." scp "$CERT_FILE" "${WAF}:${CERT_PATH}" || error "无法复制证书文件到 ${WAF}" scp "$KEY_FILE" "${WAF}:${KEY_PATH}" || error "无法复制密钥文件到 ${WAF}" log "证书和密钥文件已成功复制到 ${WAF}" # 修复文件权限 log "正在修复文件权限..." ssh "$WAF" "chmod 644 ${CERT_PATH} ${KEY_PATH}" || error "无法修复文件权限" log "文件权限修复完成" # 重启 Docker 容器 log "正在重启 Docker 容器 ${DOCKER_CONTAINER}..." ssh "$WAF" "docker restart ${DOCKER_CONTAINER}" > /dev/null || error "无法重启 Docker 容器" log "Docker 容器 ${DOCKER_CONTAINER} 已成功重启" log "证书同步和更新完成!" ``` {% endfolding %} --- 我的 `1Panel` 部署在 DS218+ 上, 且有 2 台 WAF 服务器分别对应电信和联通网络, 所以我将公共参数提出来, 使用脚本传参的方式处理: 1. `waf.t` 和 `waf.u` 需要在 `.ssh/config` 中配置, 如果你看过 [SSH 别名配置](#别名配置) 应该不难; 2. 还需要配置 [SSH 免密登录](#SSH 免密登录); 3. WAF 上的 `cert_1.crt` 和 `cert_1.key` 名称需要根据你自己的情况修改; 4. 如果你使用雷池一键安装脚本, Nginx 容器的名称应该是 `safeline-tengine`, 如果不是可自行修改; **值得注意的是, 在我的 DS218+ 上 1Panel 以 root 运行, 所以 `.ssh/config` 应该在 /root/.ssh 目录下设置**. **设置脚本**: ![20241229154732_IeHUcfoG.webp](https://cdn.dong4j.site/source/image/20241229154732_IeHUcfoG.webp) 最后在 1Panel 中重新申请证书即可: ![20241229154732_icVOxGzJ.webp](https://cdn.dong4j.site/source/image/20241229154732_icVOxGzJ.webp) **结果大意了没有闪**, 这种方式虽然能够成功替换挂载目录下的证书文件, 但是 **WAF** 它把 **证书数据存在数据库里面的** , 心中一万只草泥马飘过..... 我是怎么发现的呢? 我拿八倍望远镜发现的..... 重新申请证书后到 WAF 中的证书管理页面查看, 发现证书时间没有更新, 且编辑证书后不是新的证书. 在 `/opt/safeline` 目录下有个 `reset_tengine.sh` 文件. {% folding 🪬 查看 reset_tengine.sh 脚本内容 %} ```shell #!/bin/bash set -e SCRIPT_DIR=$(dirname "$0") confirm() { echo -e -n "\033[34m[SafeLine] $* \033[1;36m(Y/n)\033[0m" read -n 1 -s opt [[ "$opt" == $'\n' ]] || echo case "$opt" in 'y' | 'Y' ) return 0;; 'n' | 'N' ) return 1;; *) confirm "$1";; esac } if ! confirm "是否重新生成 tengine 的所有配置"; then exit 0 fi nginx_path="${SCRIPT_DIR}/resources/nginx" backup_path="${SCRIPT_DIR}"/resources/nginx."$(date +%s)" if [ ! -d "${nginx_path}" ]; then echo "website dir not found" exit 1 fi mv "${nginx_path}" "${backup_path}" docker restart safeline-tengine > /dev/null docker exec safeline-mgt gentenginewebsite if [ -d "${backup_path}/static" ]; then cp -r "${backup_path}/static" "${nginx_path}/static" fi ``` {% endfolding %} 我重新执行了里面的 `docker exec safeline-mgt gentenginewebsite`, 结果将挂载目录下的证书给还原回去了, 翻遍了所有的挂载目录都没有发现备份的证书文件, 这才发现不简单..... 所以看了 `compose.yaml` 文件, 其中就出现了 `postgres` 数据库, 那么是不是证书数据被存储到数据了? 带着这个疑问, 我们将 `postgres` 的端口暴露出来连上去看看(`postgres` 的密码在 同级目录下的 `.env` 中). ![20241229154732_a0FdkfT3.webp](https://cdn.dong4j.site/source/image/20241229154732_a0FdkfT3.webp) 如图所示, 证书数据被保存在了 `mgt_ssl_cert` 中, 看来只能通过 Web API 来操作了, 不过 WAF 并没有提供 API 文档, 也没有提供生成 API key 的操作, 控制台看了一下, 基于 JWT 的认证, 简单的写了脚本, 模拟登录获取 JWT, 然后调用 `POST /api/open/cert` 更新证书: {% folding 🪬 查看 update_certs.sh 脚本内容 %} ```shell #!/bin/bash # === 配置信息 === BASE_URL="https://ip:port/api" USERNAME="用户名" PASSWORD="密码" # 证书 id CERT_ID=1 CERT_FILE_PATH="fullchain.pem 文件路径" CERT_KEY_PATH="privkey.pem 文件路径" # === 函数: 输出错误信息并退出 === function error_exit { echo "[ERROR] $1" exit 1 } # === 1. 获取 CSRF Token === csrf_token=$(curl -k -s "${BASE_URL}/open/auth/csrf" | jq -r '.data.csrf_token') || error_exit "无法获取 CSRF Token" [ -z "$csrf_token" ] && error_exit "CSRF Token 为空" echo "[INFO] CSRF Token 获取成功" # === 2. 获取 JWT Token === JWT=$(curl -k -s -X POST "${BASE_URL}/open/auth/login" \ -H "Content-Type: application/json" \ -d "{\"username\":\"${USERNAME}\",\"password\":\"${PASSWORD}\",\"csrf_token\":\"${csrf_token}\"}" | jq -r '.data.jwt') || error_exit "无法获取 JWT" [ -z "$JWT" ] && error_exit "JWT Token 为空" echo "[INFO] JWT Token 获取成功" # === 3. 读取证书文件和密钥文件内容 === [ -f "$CERT_FILE_PATH" ] || error_exit "证书文件 ${CERT_FILE_PATH} 不存在" [ -f "$CERT_KEY_PATH" ] || error_exit "密钥文件 ${CERT_KEY_PATH} 不存在" CERT_CONTENT=$(jq -sRr '.' < "$CERT_FILE_PATH") || error_exit "读取证书文件失败" KEY_CONTENT=$(jq -sRr '.' < "$CERT_KEY_PATH") || error_exit "读取密钥文件失败" echo "[INFO] 证书和密钥文件已读取" # === 4. 更新证书 === UPDATE_PAYLOAD=$(jq -n \ --arg cert "$CERT_CONTENT" \ --arg key "$KEY_CONTENT" \ --argjson type 2 \ --argjson id $CERT_ID \ '{ manual: { crt: $cert, key: $key }, type: $type, id: $id }') result=$(curl -k -s -X POST "${BASE_URL}/open/cert" \ -H "Authorization: Bearer ${JWT}" \ -H "Content-Type: application/json" \ -d "$UPDATE_PAYLOAD") || error_exit "证书更新请求失败" # 解析并判断结果 err=$(echo "$result" | jq -r '.err') msg=$(echo "$result" | jq -r '.msg') if [ "$err" == "null" ]; then echo "[INFO] 证书更新成功" else echo "[ERROR] 证书更新失败: $msg" fi ``` {% endfolding %} `update_certs.sh` 脚本中只需要修改前面的参数运行即可. 我即将 `fullchain.pem`, `privkey.pem` 和 `update_certs.sh` 3 个文件全部放在了 1Panel 推送证书的目录下, 现在只需要在修改 **1Panel** 的脚本内容即可: ```shell ./update_certs.sh ``` > **`sync_certs.sh` 脚本已经没用了, 因为通过接口更新证书后, WAF 自己会将新的证书写入 `opt/safeline/resources/nginx/certs/` 目录下的 `cert_{id}.crt` 和 `cert_{id}.key`, 所以前面都是踩坑记录.** 后续会输出 [通过 acme.sh 将证书自动部署到 NAS 和 WAF](#NAS 自动部署 HTTPS 证书). ### 根据环境自动切换 Hosts > **前面已经使用 NPM 和 WAF 并都开启了 HTTPS, 这节所有的操作都在这个前提下进行.** 我的目的是 **根据网络环境自动切换映射的 IP**, 适应动态网络环境, 保持稳定的连接. 解决的问题是在外网通过公网访问家里的服务, **在家则直接映射到指定的服务器, 避免从公网绕一圈回来**. 其实这个需求只会用在需要随身携带的 MBP 上, 我们可以通过自定义 DNS 服务来实现, 但问题是需要手动切换 DNS 服务地址, 当然也是可以写脚本来实现自动化的. 得益于 `Surge` 这款强大的网络调试工具, 且支持自定义脚本, 所以我会是用 `Surge ` 来完成这样工作. > 如果路由器支持配置 Hosts 的话, 也是一种方案, 问题是不支持泛域名: > > ![20241229154732_KHTUHwzv.webp](https://cdn.dong4j.site/source/image/20241229154732_KHTUHwzv.webp) 我们首先对齐一下颗粒度: - NAS WebUI 局域网的地址为 `http://192.168.31.3:5000`; - 在公司通过 `nas.dong4j.tele:1234` 来访问 NAS 的 WebUI; - 在家还是使用 `nas.dong4j.tele:1234` 访问 NAS 的 WebUI; 一个站点的服务可以通过其 IP 地址和端口号来识别, 端口号不能改的情况下就只能修改域名所映射的 IP 地址. 前面说过我不会将服务原本的端口暴露到公网, 为了保证外网端口不改的情况下通过域名访问局域网内其他的服务(**但是上述需求必须保证 NPM 或 WAF 的 HTTPS 端口与路由器上配置的外网端口一致**), 我们必须在路由器后面添加一层代理. 接下来我们详细描述一下 **NPM** 和 **WAF** 如果完成上面的需求. 假设: | 服务名 | IP | 端口(HTTPS) | | ------ | ------------- | ----------- | | NPM | 192.168.31.2 | 1234 | | WAF | 192.168.31.20 | 1234 | #### NPM NPM 特殊一点就是在启动时将就必须确认 HTTPS 端口号, 所以为了能在家也能映射到 NPM, 必须保证 NPM 的 HTTPS 端口和路由器的转发端口保持一致. | 外网端口 | 转发的 IP | 转发的端口 | | -------- | ------------ | ---------- | | 1234 | 192.168.31.2 | 1234 | NPM 代理服务配置: ![20241229154732_rtZuyuZz.webp](https://cdn.dong4j.site/source/image/20241229154732_rtZuyuZz.webp) 上述配置后应该能通过 `https://nas.dong4j.tele:1234` 访问 NAS 的 WebUI 了. 这是在外网的情况, 在内网环境下, 我们需要为 `*.dong4j.tele` 设置 Hosts, 让它全部映射到 NPM 的 IP 地址: Surge 配置: ``` *.dong4j.tele = 192.168.31.2 ``` 使用 **dig** 或 **ping** 检查一下是否生效(Surge 需要配置一下才能获取真实的 IP: 设置-通用-高级: Always Real IP Hosts, 在列表中添加 `*.dong4j.tele`, 或者直接修改配置文件: General 分组下的 always-real-ip 配置): ``` $ dig nas.dong4j.tele ; <<>> DiG 9.10.6 <<>> nas.dong4j.tele ;; global options: +cmd ;; Got answer: ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 22000 ;; flags: qr; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0 ;; QUESTION SECTION: ;nas.dong4j.tele. IN A ;; ANSWER SECTION: nas.dong4j.tele. 30 IN A 192.168.31.2 ;; Query time: 1 msec ;; SERVER: 198.18.0.2#53(198.18.0.2) ;; WHEN: Thu Nov 21 14:30:34 CST 2024 ;; MSG SIZE rcvd: 64 ``` 这样我们就完成了整个链路: ![auto-switch-hosts.drawio.svg](https://cdn.dong4j.site/source/image/auto-switch-hosts.drawio.svg) #### WAF WAF 的配置大同小异, 与 NPM 不同的地方就是可以配置多个端口, 同时也需要在路由器上配置对应的端口转发规则. | 外网端口 | 转发的 IP | 转发的端口 | | -------- | ------------- | ---------- | | 1234 | 192.168.31.20 | 1234 | 路由器上配置端口转发规则: ![20241229154732_Nxbk3ukT.webp](https://cdn.dong4j.site/source/image/20241229154732_Nxbk3ukT.webp) WAF 的代理配置: ![20241229154732_bre3BT0s.webp](https://cdn.dong4j.site/source/image/20241229154732_bre3BT0s.webp) 修改 Surge 配置: ``` *.dong4j.tele = 192.168.31.20 ``` WAF 的配置就完成了. #### 自动切换域名映射 最后就是 Surge 如何动态修改 Hosts 了, 这个有多种实现方式: 1. 使用脚本动态修改 DNS 服务器: 需要架设 DNS 服务器, 固没有采用; 2. **使用脚本动态修改域名解析的 IP**: 目前使用的方案; 3. 使用模块动态修改 Hosts 映射: 备用方案; {% folding 🪬 虽然不用, 但是记录下如何实现 %} 1. 创建一个 `override-hosts.sgmodule` 文件: ``` #!name=Custom Hosts #!desc=在家使用下面的 hosts 覆盖默认 hosts, 不在家就使用默认 hosts (默认的 hosts 不要添加相关的映射) [Host] *.dong4j.tele = 192.168.31.2 ``` 2. 在 script 节点下添加配置: ``` # 根据当前网络重写 Hosts 配置 network-changed = script-path=network-changed.js,type=event,event-name=network-changed ``` 3. 在当前目录下添加 `network-changed.js`: ```javascript const name = "Custom Hosts"; let home = ["wifi1", "wifi2", "wifi2"].includes($network.wifi.ssid); const getModuleStatus = new Promise((resolve) => { $httpAPI("GET", "v1/modules", null, (data) => resolve(data.enabled.includes(name)) ); }); getModuleStatus.then((enabled) => { if (home && !enabled) { //家里,未开启模块 => 开启 $notification.post("Event", `开启${name}模块`, ""); enableModule(true); } else if (!home && enabled) { //不是家里,开启了模块 => 关闭 $notification.post("Event", `关闭${name}模块`, ""); enableModule(false); } else { //其他情况 => 重复触发 => 结束脚本 $done(); } }); function enableModule(status) { $httpAPI("POST", "v1/modules", { [name]: status }, () => $done()); } ``` 关闭/开启 WiFi 即可查看效果. **需要注意的是这种方式优先级高于第二种** {% endfolding %} 4. 使用 [Surge ponte](https://community.nssurge.com/d/1612-dns-ssid/12): 需要额外的 Mac 硬件, 有时间再折腾一下, 应该会是最简单的方式; 我所需要的就是简单的修改 Hosts 的映射关系, 所以我采用第二种方案, 具体配置如下: 1. 在 script 节点下添加配置: ``` # 使用脚本代替 DNS 解析 dns dnspod script-path=dnspod.js,debug=true ``` 2. 在当前目录下添加 `dnspod.js`: ```javascript let home = $network.wifi.ssid === "wifi ssid1" || $network.wifi.ssid === "wifi ssid2"; if (home) { $done({ address: "192.168.31.2" }); } else { $httpClient.get( "http://119.29.29.29/d?dn=" + $domain, function (error, response, data) { if (error) { $done({}); } else { $done({ addresses: data.split(";"), ttl: 600 }); } } ); } ``` 3. 修改 Surge Hosts 配置: ``` *.dong4j.tele = script:dnspod ``` 4. 开启 WiFi, 访问一下 `https://nas.dong4j.tele:1234` : ![20241229154732_nqMYDA7Y.webp](https://cdn.dong4j.site/source/image/20241229154732_nqMYDA7Y.webp) `*.dong4j.tele` 的 DNS 服务器已被成功修改为 `dnspod`. 脱离 Surge, 你可以写其他脚本来完成上述需求, 比如: - 当网络发生变更时, 执行脚本直接修改 Surge Hosts 的配置, 然后使用 [Surge CLI 重载配置](https://manual.nssurge.com/others/cli.html) (通常 Surge 会在检测到配置文件改动后自动重载); ### 内部服务使用 HTTPS 访问 一些特殊的场景下需要通过 HTTPS 访问内部服务, 比如 Bitwarden, 本地部署的 LLM 提供的 OpenAI API 等, 另一个就是开发场景. 有一些开源项目可以直接使用: - [nip.io](https://nip.io/) - [localtls](https://github.com/Corollarium/localtls) - [sslip.io](https://sslip.io/) 这里选择相对来说比较简单的方式来实现内网 HTTPS 访问. #### mkcert 的安装与使用 ```shell # 安装 brew install mkcert # 指定根证书存储路径 export CAROOT=/Users/dong4j/Synology/driver/NAS/Certs/mkcert # 安装本地 CA 根证书 mkcert -install # 为域名生成证书 mkcert "*.nas.tele" localhost 127.0.0.1 ::1 ``` 因为 HTTPS 证书只只支持二级以上的泛域名, 所以这里使用的是 `*.nas.tele`. 生成的证书在 `/Users/dong4j/Synology/driver/NAS/Certs/mkcert` 目录下: ``` $ mkcert "*.nas.tele" localhost 127.0.0.1 ::1 Created a new certificate valid for the following names 📜 - "*.nas.tele" - "localhost" - "127.0.0.1" - "::1" Reminder: X.509 wildcards only go one level deep, so this won't match a.b.nas.tele ℹ️ The certificate is at "./_wildcard.nas.tele+3.pem" and the key at "./_wildcard.nas.tele+3-key.pem" ✅ It will expire on 21 February 2027 🗓 ``` 在 NPM 上手动添加证书: ![20241229154732_XTrBTuwO.webp](https://cdn.dong4j.site/source/image/20241229154732_XTrBTuwO.webp) 添加一个站点: ![20241229154732_2Mb1tQ8V.webp](https://cdn.dong4j.site/source/image/20241229154732_2Mb1tQ8V.webp) 配置 HTTPS: ![20241229154732_8bCIWdPO.webp](https://cdn.dong4j.site/source/image/20241229154732_8bCIWdPO.webp) Surge 配置: ``` *.tele = 192.168.31.10 ``` 最终效果: ![20241229154732_CAYly5vr.webp](https://cdn.dong4j.site/source/image/20241229154732_CAYly5vr.webp) 参考: - [为 Homelab 环境创建自己的证书颁发机构 (CA)](https://support.tools/create-certificate-authority-homelab/) - [使用 mkcert 工具生成受信任的 SSL 证书, 解决局域网本地 https 访问问题](https://cloud.tencent.com/developer/article/2191830) - [教你秒建受信任的本地 SSL 证书, 彻底解决开发测试环境的无效证书警告烦恼!](https://cloud.tencent.com/developer/article/1514003) - [SSL 之 mkcert 构建本地自签证书,整合 SpringBoot3](https://cloud.tencent.com/developer/article/2379654) ### 不暴露服务到公网 为了减少网络安全问题, 可以不将内网服务暴露到公网, 只在局域网环境使用. 那么这种情况下, 我们可以使用 **VPN** 的方式访问家中的设备和服务. 目前家用比较流行的有 [Tailscale](https://tailscale.com/) 和 [ZeroTier](https://www.zerotier.com/), 然后另选了几个竞品做了对比: #### 功能对比 | **特性** | [Tailscale](https://tailscale.com/) | [Headscale](https://github.com/juanfont/headscale) | [ZeroTier](https://www.zerotier.com/) | [NetBird](https://netbird.io/) | [Netmaker](https://www.netmaker.io/) | | ------------------ | ----------------------------------- | -------------------------------------------------- | ------------------------------------- | ------------------------------ | ------------------------------------ | | **类型** | 商业工具, 免费计划可用 | 自托管的 Tailscale 替代 | 商业工具, 社区免费计划可用 | 开源工具, 自托管 | 开源工具, 自托管 | | **协议** | WireGuard | WireGuard | 自定义协议 | WireGuard | WireGuard | | **P2P 支持** | 是 | 是 | 是 | 是 | 是 | | **需要公网服务器** | 可选 | 是 | 可选 | 是 | 是 | | **内网穿透能力** | 强 | 强 | 强 | 强 | 强 | | **平台支持** | 全平台 | 全平台 (与 Tailscale 类似) | 全平台 | 全平台 | 全平台 | | **用户界面** | 图形化 | CLI | 图形化 | 图形化 | 图形化和 CLI | | **开源** | 部分开源 (客户端闭源) | 开源 | 部分开源 | 开源 | 开源 | {% folding 🪬 优势与劣势 %} ##### Tailscale - **优势**: - 使用 WireGuard, 提供高效、低延迟的加密通信. - 简单易用, 几乎零配置, 适合非技术用户. - 支持细粒度访问控制 (ACL) . - 提供免费计划, 可满足小型 HomeLab 的需求. - 内置 NAT 穿透, 适用于复杂网络环境. - **劣势**: - 依赖 Tailscale 官方控制服务器 (可通过 Headscale 替代) . - 客户端闭源, 部分用户可能有隐私顾虑. ##### **Headscale** - **优势**: - 开源的自托管版本 Tailscale 控制服务器, 完全免费. - 保留 Tailscale 的核心功能, 如 NAT 穿透和 ACL. - 数据完全本地掌控, 适合注重隐私的用户. - **劣势**: - 无图形化界面, 需要通过 CLI 或 API 管理. - 自托管和运维门槛较高, 不适合小白用户. ##### **ZeroTier** - **优势**: - 自定义协议, 性能优秀且可靠. - 支持高级网络功能, 如 SD-WAN 和多播. - 图形化界面友好, 适合小白和中小企业. - 社区免费计划可满足个人和小型团队需求. - **劣势**: - 部分核心闭源. - 加密性能略逊于 WireGuard. ##### **NetBird** - **优势**: - 完全开源, 基于 WireGuard 提供高效加密通信. - 类似 Tailscale, 更专注于自托管和隐私. - 支持 NAT 穿透, 适合跨网络设备通信. - **劣势**: - 功能相较 Tailscale 略弱, 用户社区较小. - 管理工具和文档不够成熟. ##### **Netmaker** - **优势**: - 完全开源, 支持 WireGuard, 性能强大且灵活. - 支持跨云和跨网络的大规模部署. - 提供企业级 VPN 服务功能. - **劣势**: - 配置较复杂, 不适合非技术用户. - 自动 DNS 和服务发现功能需要手动设置. #### 适用场景 | **场景** | **推荐工具** | **理由** | | ---------------------- | ------------------------------ | ------------------------------------------------------------------------- | | **HomeLab 或个人使用** | Tailscale / Headscale | 简单易用, 零配置, 支持 ACL, 适合跨设备、跨网络的轻量级使用场景. | | **注重隐私与自托管** | Headscale / NetBird / Netmaker | 开源工具, 数据完全掌控在本地. | | **企业级分布式网络** | ZeroTier / Netmaker | ZeroTier 提供强大的企业功能, Netmaker 适合需要自托管和跨云环境的企业场景. | | **需要图形化管理** | Tailscale / ZeroTier / NetBird | 图形界面便于管理, 适合用户规模不大的团队或个人. | | **开源社区支持** | Headscale / NetBird / Netmaker | 以开源为主, 社区活跃, 适合开发者和技术爱好者. | | **跨复杂网络通信** | Tailscale / ZeroTier / NetBird | 内置 NAT 穿透, 解决复杂网络环境下的连接问题. | #### 总结 - **Tailscale** 和 **Headscale**: 适合轻量化和个人使用, 尤其是 HomeLab 用户. - **ZeroTier**: 适合中小企业和对高级网络功能有需求的用户. - **NetBird** 和 **Netmaker**: 更适合自托管和隐私敏感用户, Netmaker 在大规模部署场景表现优异. {% endfolding %} 尝试过 **ZeroTier** 并自建根服务器后, 感觉还是没有直接使用 WireGuard 来的方便, 所以上述构建虚拟局域网的方案全部放弃了, 改用最原始的 [WireGuard 来完成 VPN 搭建工作](#WireGuard). 参考: - [更适合国内的远程访问方法: 自建根服务器打造基于 ZeroTier 虚拟内网 - 少数派](https://sspai.com/post/85130) - [GitHub - xubiaolin/docker-zerotier-planet: 一分钟私有部署 zerotier-planet 服务](https://github.com/xubiaolin/docker-zerotier-planet) ### 临时暴露服务到公网 某些特殊情况下可能需要将服务暴露给其他人使用. 有可能内部服务不是全部都有认证授权功能, 可以使用 **WAF** 的 **身份认证功能** (**NPM** 也有相关功能) 为服务加上一层最基础的保护: ![20241229154732_FjMnMZWz.webp](https://cdn.dong4j.site/source/image/20241229154732_FjMnMZWz.webp) ### 智能家居设备网络隔离 推荐阅读一下: [智能家居设备会带来哪些安全性问题?](https://sspai.com/post/69223) 我想过使用 VLAN 来隔离智能设备, 但是目前还没有硬件条件, 也没有时间调整目前的网络架构, 也许等后面规划好了 v2.0 版本再来水一篇文章. 理想中的应该是 [UniFi](https://ui.com/us/zh/introduction) 全家桶 + 独立机架 + 万兆内网. --- ## 广告过滤 ### Surge 因为主要设备都是 Apple 硬件, 且都安装了 Surge, 可以方便的通过 Surge 去过滤绝大多数广告, 且广告规则可自动更新, 我目前使用的是 [blackmatrix7/ios_rule_script](https://github.com/blackmatrix7/ios_rule_script) 这个开源项目, 里面的规则集非常全面. ### AdGuard Home 其他设备上的广告则可以通过广告过滤服务来集中处理. 比较主流的广告过滤服务有 [AdGuard Home](https://adguard.com/zh_cn/adguard-home/overview.html) 和 [Pi-hole](https://pi-hole.net/) , 后者在国外比较流行, 目前我在 2 台 R2S 上部署了 **AdGuard Hom**e, 分别为电信和联通网络提供广告过滤服务. ![20241229154732_DQKzhLN1.webp](https://cdn.dong4j.site/source/image/20241229154732_DQKzhLN1.webp) **AdGuard Home** 同样提供手机客户端. 因为是旁路由模式, 所以需要手动为客户端设置网关和 DNS: ![20241229154732_h2bCQiKN.webp](https://cdn.dong4j.site/source/image/20241229154732_h2bCQiKN.webp) 将路由器和 DNS 服务器全部设置为 **AdGuard Home** 所在服务器的 IP. --- ## 外网访问家庭网络 在开始这章之前, 有必要了解一下 [[nat-guide|NAT (Network Address Translation)]] ### WireGuard VPN #### 传统的 VPN 方案 ##### OpenVPN ![20241229154732_np8YDoTV.webp](https://cdn.dong4j.site/source/image/20241229154732_np8YDoTV.webp) **OpenVPN** 是一种热门且高度安全的协议, 许多 VPN 提供商都使用这种协议. 它运行在 TCP 或 UDP 互联网安全协议上. TCP 能确保数据以正确的顺序完整传输, 而 UDP 则专注于更快的速度. **优点** - **开放源码, **这表示代码是公开的. 任何人都可以检查代码中是否存在可能危及 VPN 安全的隐藏后门或漏洞; - **多功能性. **这种协议能与一系列不同的加密和流量协议一起使用, 可以针对不同的用途进行配置, 也可以根据需要进行安全或轻量的配置; - **安全性. **能搭配各种加密协议, 因此非常安全; - **绕过多数防火墙**: OpenVPN, 就能够轻松绕过防火墙; **缺点** - **设置复杂**: 多功能性意味着如果多数用户试图建立自己的 OpenVPN, 他们可能会因为复杂性而难以设置. 将用没必要上 **重量级的(相对 WireGuard 来说)** 的 OpenVPN, 专注主要功能即可. ##### L2TP/IPsec ![20241229154732_cZGNZPoV.webp](https://cdn.dong4j.site/source/image/20241229154732_cZGNZPoV.webp) L2TP (Layer 2 Tunneling Protocol) 和 IPSec (Internet Protocol Security) 是两种网络协议, 经常组合使用以提供安全的 VPN (虚拟专用网络) 连接: - **L2TP** 用于建立隧道 (Tunneling) , 将数据封装在 VPN 隧道中传输. - **IPSec** 用于加密和验证数据包, 确保数据的安全性和完整性. 这种组合方式被称为 **L2TP/IPsec**, 是一种常见的 VPN 协议, 用于在公共网络 (例如互联网) 上传输敏感数据, 需要占用 3 个端口: - 使用 **UDP 500** 和 **4500** (NAT 穿越) 进行密钥交换 (IKE 协议) . - L2TP 使用 **UDP 1701** 端口进行通信. **优点** - **安全性**: L2TP 根本不提供任何安全性, 但却相当安全. 这是因为它能搭配各种加密协议, 使协议按需要变得更安全或轻量. - **广泛可用**: L2TP 在几乎适用于所有操作系统, 这代表管理员能轻易找到支持并使其运行. **缺点** - **可能已被美国国家安全局 (NSA) 破解**: 与 IKEv2 一样, L2TP 通常与 IPsec 搭配使用, 因此它也存在前面提到的漏洞. - **速度慢**: 该协议封装数据两次, 这种方法对某些应用程序很有用, 但与只封装数据一次的其他协议相比, 速度较慢. - **无法绕过防火墙**: 与其他 VPN 协议不同, L2TP 没有其他方法来穿过防火墙. 面向监视的系统管理员使用防火墙来封锁 VPN, 而自行设置 L2TP 的人很容易被封锁. ##### IKEv2/IPsec ![20241229154732_8IBikwmw.webp](https://cdn.dong4j.site/source/image/20241229154732_8IBikwmw.webp) **IKEv2 (Internet Key Exchange version 2) ** 是一种提供安全密钥交换会话的隧道协议, 该协议是微软和思科合作的成果. 与 L2TP 类似, 它通常与 IPsec 配对使用以提供身份验证和加密功能. 它由 **IETF RFC 7296** 定义, 是 IKE (Internet Key Exchange) 协议的改进版本. IKEv2 的主要功能是协商和管理加密密钥, 确保通信双方能够以安全和高效的方式加密数据. 配置相对复杂, 特别是在手动部署服务器时, 且需要占用 2 个端口: - **UDP 500**: IKEv2 的默认端口, 用于初始连接和 IKE 协商. - **UDP 4500**: 当 NAT 穿越 (NAT-T) 被检测到时, IKEv2 切换到此端口. **优点** - **稳定性**: IKEv2/IPsec 使用一种名为 Mobility and Multi-homing Protocol (MOBIKE) 的技术, 当流量在互联网传输时, 该技术可确保建立 VPN 连接不中断, 使得 IKEv2/IPsec 成为移动设备最可靠和稳定的协议. - **安全性**: 作为 IPsec 套件的一部分, IKEv2/IPsec 可与大多数加密算法配合使用, 使其成为最安全的 VPN 协议. - **速度**: 运行时占用很少的宽带, 而且它的 NAT 穿透技术, 使其连接和通信更快速, 也有助于通过防火墙. **缺点** - **兼容性有限**: IKEv2/IPsec 与许多系统不兼容. 这对 Windows 用户来说不是问题, 因为微软参与制定此协议, 但其他操作系统则可能需要第三方软件才能支持. 因从 Wi-Fi 切换到移动数据时不会遗失 VPN 连接, **IKEv2/IPsec** 常用在移动设备端. #### WireGuard 是什么 ![20241229154732_cKnMu1Wu.webp](https://cdn.dong4j.site/source/image/20241229154732_cKnMu1Wu.webp) 是整个 VPN 行业都在谈论的最新、最快速的通道协议. 这种协议使用最先进的加密技术, 使当前的领导者——OpenVPN 和 IKEv2/IPsec 黯然失色. [**WireGuard**](https://github.com/pirate/wireguard-docs) 是由 `Jason Donenfeld` 等人用 `C` 语言编写的一个开源 VPN 协议, 被视为下一代 VPN 协议, 旨在解决许多困扰 `IPSec/IKEv2`、`OpenVPN` 或 `L2TP` 等其他 VPN 协议的问题. WireGuard 声称其性能比大多数 VPN 协议更好, [但这个事情有很多争议](https://www.ipfire.org/blog/why-not-wireguard). 与在用户空间中运行的 OpenVPN 不同, WireGuard 的优势在于直接集成到 Linux 内核(5.6 开始)中. 这种集成使其能够更高效地处理数据包, 同时最大限度地减少用户空间和内核空间之间的上下文切换. ![20241229154732_UGdpMK6l.webp](https://cdn.dong4j.site/source/image/20241229154732_UGdpMK6l.webp) WireGuard 仅使用 UDP, 并且通常在 **单个端口上** 运行, 从而简化了其设置和操作. 这与 OpenVPN 形成对比, 后者可以使用 TCP 或 UDP, 并且可能需要根据配置管理多个端口. WireGuard 使用单一协议和端口, 降低了网络配置和防火墙规则的复杂性, 从而提高了整体性能. 然而精简就意味着功能缺失, [WireGuard 的局限性](https://www.ipfire.org/blog/why-not-wireguard), 只能说目前 WireGurad 方式对我来说完全够用. **优点** - **免费和开源**: 任何人都能查看代码, 这使得部署、稽核和调试更加容易. - **精简且速度极快**: 仅由 4000 行代码组成, 是所有协议中“最精简”的协议. 相较之下, OpenVPN 代码行数是它的 100 倍. ![20241229154732_yJfQlqXb.webp](https://cdn.dong4j.site/source/image/20241229154732_yJfQlqXb.webp) **缺点** - **不完整**: WireGuard 有望成为“下一个大事件”, 但其实施仍处于早期阶段, 还有很大的改进空间. 目前, 它无法为用户提供任何级别的匿名性 (尽管完全匿名是不可能的) , 因此 VPN 提供商需要找到定制的解决方案, 在不损失速度的情况下提供必要的安全性. **使用时机**: 每当需要速度优先时, 都可以使用 WireGuard: 流媒体、在线游戏或下载大型文件. ##### 相关链接 关于性能比较的更多信息可以参考下面几篇文档: - [官方的基准测试](https://www.wireguard.com/performance/) - [两台具有 10 Gb 以太网的服务器之间的 WireGuard 基准测试](https://www.reddit.com/r/linux/comments/9bnowo/wireguard_benchmark_between_two_servers_with_10/) 关于 WireGuard 加密的更多资料请参考下方链接: - [WireGuard: 下一代内核网络隧道](https://www.wireguard.com/papers/wireguard.pdf) - [《WireGuard 协议的密码学分析》](https://eprint.iacr.org/2018/080.pdf) - [WireGuard 的安全性分析](https://courses.csail.mit.edu/6.857/2018/project/He-Xu-Xu-WireGuard.pdf) - [WireGuard 技术分享](https://www.wireguard.com/talks/blackhat2018-slides.pdf) - [WireGuard 评测: 新型 VPN 具有显著优势](https://arstechnica.com/gadgets/2018/08/wireguard-vpn-review-fast-connections-amaze-but-windows-support-needs-to-happen/) 下面是一些有助于密钥分发和部署的服务: - [WireGuard 点对点连接的工具](https://pypi.org/project/wireguard-p2p/) - [一组 Ansible 脚本, 简化 WireGuard 和 IPsec VPN 的设置](https://github.com/trailofbits/algo) - [Streisand](https://github.com/StreisandEffect/streisand) - [用 Bash 编写的 WireGuard 自动安装程序](https://github.com/its0x08/wg-install) - [WireGuard 配置生成器](https://github.com/brittson/wireguard_config_maker) - [Web 端 WireGuard 配置生成器](https://www.wireguardconfig.com) --- #### WireGuard 使用场景 利用 WireGuard 可以组建非常复杂的网络拓扑, 根据网络环境的不同通常有多种配置方式: ##### 1. 端到端直接连接 这是最简单的拓扑, 所有的节点要么在同一个局域网, 要么直接通过公网访问, 这样 `WireGuard` 可以直接连接到对端, 不需要中继跳转. ##### 2. 局域网到外网 比如在家访问公司局域网, 或者在公司访问家庭局域网. 最简单的方案是通过公网暴露的一端作为服务端, 另一端指定服务端的公网地址和端口, 然后通过 `persistent-keepalive` 选项维持长连接, 让 NAT 记得对应的映射关系. ##### 3. 两端都位于 NAT 之后 **3.1 使用中继服务器** 大多数情况下, 当通信双方都在 NAT 后面的时候, NAT 会做源端口随机化处理, 直接连接可能比较困难. 可以加一个 **中继服务器**, 通信双方都将中继服务器作为对端, 然后维持长连接, 流量就会通过中继服务器进行转发. **3.2 打洞** 上面也提到了, 当通信双方都在 NAT 后面的时候, 直接连接不太现实, 因为大多数 NAT 路由器对源端口的随机化相当严格, 不可能提前为双方协调一个固定开放的端口. 必须使用一个信令服务器 (`STUN `, [[nat-guide|自建 STUN Server?]]), 它会在中间沟通分配给对方哪些随机源端口. 通信双方都会和公共信令服务器进行初始连接, 然后它记录下随机的源端口, 并将其返回给客户端. > 这就是现代 P2P 网络中 `WebRTC` 的工作原理. 有时候, 即使有了信令服务器和两端已知的源端口, 也无法直接连接, 因为 NAT 路由器严格规定只接受来自原始目的地址 (信令服务器) 的流量, 会要求新开一个随机源端口来接受来自其他 IP 的流量 (比如其他客户端试图使用原来的通信源端口) . 运营商级别的 NAT 就是这么干的, 比如蜂窝网络和一些企业网络, 它们专门用这种方法来防止打洞连接. --- ### WireGuard 配置实例 #### 点对点 常见的应用场景: 将一台计算机连接到远程网络, 并使其能够像在本地网络中一样访问所有资源. 例如连接到公司的内部网络, 远程提交代码, 访问公司内部的 Wiki 等; 远程连到家庭网络看个小电影, 听听 NAS 的无损音乐什么的. 远程 WireGuard 端点中的位置非常灵活. 它可以位于防火墙、路由器或其他任何网络设备上. 这意味着我们可以根据实际需求选择最合适的部署位置. > 简单说明一下: > > **在 WireGuard 里, 客户端和服务端基本是平等的, 差别只是谁主动连接谁而已**. > > 双方都会监听一个 UDP 端口, 谁主动连接, 谁就是客户端. 主动连接的客户端需要指定对端的公网地址和端口, 被动连接的服务端不需要指定其他对等节点的地址和端口. > > 值得强调的是: **安装的 WireGuard 的服务器, 既可以作为服务端也可以作为客户端.** > > 后面我会直接是用 **服务端** 和 **客户端** 来简单区分不同的对端, 所以需要提前说明一下这个概念. **网络拓扑图**: ``` 公网 xxxxxx ppp0 ┌────────┐ ┌────┐ Xxx xxxx ──┤ 路由器 │ │ ├─ppp0 xxx xx └───┬────┘ │ │ xx x │ home 192.168.31.0/24 │ │ xxx xxx └───┬─────────┬─────────┐ └────┘ xxxxx │ │ │ ┌─┴─┐ ┌─┴─┐ ┌─┴─┐ │ │ │ │ │ │ │pi4│ │NAS│ │...│ │ │ │ │ │ │ └───┘ └───┘ └───┘ ``` 这幅图展示了典型的家庭/小型公司网络拓扑结构. 通常会有一个由 ISP 提供的光猫, 下面通过路由器拨号上网, 以及一些内部设备, 如 Raspberry PI、NAS 和其他设备. **安装方法**: 可以选择以下两种方法之一来部署 WireGuard: 1. **在路由器上部署**: 如果使用 **OpenWrt** 作为主路由系统的话, 这是最常见且简单的的方法; 2. **在局域网中的另一个系统上部署**: 比如在公司可能没有权限对路由器做任何配置(但是申请 **开放一个端口映射** 还是可以的), 或者路由器无法安装 **WireGuard** 的时, 只能使用这种方式(比如我在公司部署了一台 R2S 小型服务器); 在家庭网络也采用这种方式(因为考虑到稳定性, 使用 AX9000 和 6500Pro 作为主路由且没有开启 root 权限, 只作为普通路由器使用) **服务端和客户端**: 请注意, 在这种场景中部署的 **WireGuard** 我称为 **服务端**, 意思是不会有 **WireGuard Endpoint** 配置, Endpoint 配置应该在需要访问家庭/公司网络的外部设备上(主动连接家庭/公司网络中的 WireGuard 服务, 我称为 **客户端**). > 我没有 **OpenWrt** 系统的主路由器, 所以需要在主路由上配置端口转发规则, 将指定流量转发到部署了 **WireGuard** 的内部服务器上. > > 如果你的主路由器是 OpenWrt 系统, 可以自行查阅配置方式, 方式都大同小异. 下面我将在 **Ubuntu**, **Armbian**, **NAS** 和 **R2S** (OpenWrt 系统, 作为二级路由器) 上部署 **WireGuard** 服务, 这些系统覆盖了大多数常见的系统类型, 以此来说明在各个系统上如何部署, 配置与使用 **WireGuard**. 值得推荐的 **WireGuard** 管理工具: - [wg-easy](https://github.com/wg-easy/wg-easy): 简单易用的 **WireGuard 服务端**; - [wireguard-ui](https://github.com/ngoduykhanh/wireguard-ui): 比前者稍微复杂一点, 不过功能更多; --- ##### Ubuntu M920x 部署了 Ubuntu Server, 所以这里直接是用它来部署 **WireGuard**. 网络拓扑图 1: 只接入联通网络 ![ubuntu-wireguard.drawio.svg](https://cdn.dong4j.site/source/image/ubuntu-wireguard.drawio.svg) 1. 将 **10.10.1.0/24** 作为 **WireGuard** 的网络, 和现在的联通网络分开; 2. M920x 添加一个 **wg0** 网卡, 并使用 **10.10.1.1/32** (不需要自己去手动添加 **wg0** 网卡, **WireGuard** 部署启动成功后会自行添加); 3. 外网的 MBP 使用 **10.10.1.8/32** 作为它的 **WireGuard** 网络 IP; 4. 联通网络的 IP 段 为 **192.168.21.0/24**; > 我们的目的是让在外网的 MBP 能通过 **WireGuard** 接入到 M920x 上部署的 **wg0** 网络, 并且能访问家中联通网络下的任意设备. > > 接下来就是无聊的安装部署与配置环节. > > 总体流程: > > 1. 安装 WireGuard; > 2. 生成公钥和私钥; > 3. 添加 wg0.conf 配置; > 4. 配置防火墙和 IP 转发; > 5. 添加 Peer 节点; > 6. 客户端使用; --- **WireGuard** 可从默认的 Ubuntu 软件源中获得: ```bash sudo apt update sudo apt install wireguard ``` 这将安装 WireGuard 模块和工具, WireGuard 作为内核模块运行. > 下面的操作都是 root 用户. 为 M920x 生成密钥: ```shell mkdir /etc/wireguard # 生成私钥 wg genkey > m920x-private.key # 通过私钥生成公钥 wg pubkey < m920x-private.key > m920x-public.key ``` 为 MBP 生成密钥: ```shell wg genkey > mbp-private.key wg pubkey < mbp-private.key > mbp-public.key ``` 创建 **wg0.conf** 配置文件(是 **数字 0**, 不是字母 o, 我怕你字体有问题, 所以提醒一下): ```shell [Interface] Address = 10.10.1.1/32 ListenPort = 51000 PrivateKey = [Peer] # MBP PublicKey = AllowedIPs = 10.10.1.8/32 ``` > 这里不要搞混了, Interface 填写的是当前主机的 **私钥**, 对等点(Peer) 填写的是其他设备的 **公钥**. 配置防火墙和 IP 转发 (`vim /etc/sysctl.conf`): ```shell # 防火墙我直接关闭, 如果不关闭就要放行 51000 端口 sudo ufw allow 51000/udp # 配置 IPv4 转发, 用于允许内核在网络接口间转发流量 net.ipv4.ip_forward = 1 # 如果需要在 IPv6 环境下启用 WireGuard, 还需要添加下面的配置 net.ipv6.conf.all.forwarding=1 ``` > 使用 `sysctl -p` 使上述配置生效. 最后启动 **WireGuard**: ```shell wg-quick up wg0 # 停止命令为: wg-quick down wg0 ``` 启动成功后, 在 **M920x** 上使用 `ip a` 可以找到一个 **wg0** 的虚拟网卡, 大概是这样的: ```shell wg0: mtu 1420 qdisc noqueue state UNKNOWN group default qlen 1000 link/none inet 10.10.1.1/24 scope global wg0 valid_lft forever preferred_lft forever inet6 fd45:da8::1/64 scope global valid_lft forever preferred_lft forever ``` MBP 的 **WireGuard** 配置 (macOS 下的 WireGuard 在 **非国区** App Store 免费下载): ```shell [Interface] PrivateKey = Address = 10.10.1.8/32 [Peer] PublicKey = AllowedIPs = 10.10.1.0/24 Endpoint = PersistentKeepalive = 25 ``` ![20241229154732_Z8p68U3k.webp](https://cdn.dong4j.site/source/image/20241229154732_Z8p68U3k.webp) MBP 上 **WireGuard** 创建的网卡信息: ``` utun6: flags=8051 mtu 1420 options=6460 inet 10.10.1.8 --> 10.10.1.8 netmask 0xffffffff ``` 然后我们可以是用 **ping** 来验证是否成功: ![20241229154732_0ZJwRAa3.webp](https://cdn.dong4j.site/source/image/20241229154732_0ZJwRAa3.webp) 也能成功访问 M920x 上的服务: ![20241229154732_CinOUXRU.webp](https://cdn.dong4j.site/source/image/20241229154732_CinOUXRU.webp) 在 **M920x** 上使用 **wg** 查看 **WireGuard** 信息: ![20241229154732_4Yr9870g.webp](https://cdn.dong4j.site/source/image/20241229154732_4Yr9870g.webp) --- 上述配置将局域网内的 M920x 和外网的 MBP 成功的加入到 **10.10.1.0/24** 这个局域网内, 但是目前也只能在这个子网内通信, 如果我想让 MBP 访问 M920x 所在的 **192.168.21.0/24** 这个局域网, MBP 则需要如下配置: ```shell [Interface] PrivateKey = Address = 10.10.1.8/32 [Peer] PublicKey = AllowedIPs = 10.10.1.0/24, 192.168.21.0/24 Endpoint = PersistentKeepalive = 25 ``` `AllowedIPs = 10.10.1.0/24, 192.168.21.0/24` 表示: 来自 `10.10.1.0/24, 192.168.21.0/24` 的流量全部通过 **WireGuard** 的网卡(MBP 上的 utun6 网卡)发往 **M920x**, 我们来详细了解一下整个链路是怎样的: 1. MBP 请求 **http://192.168.21.1** 访问小米路由器的 WebUI; 2. 流量从 MBP 的 utun6 网卡发往 M920x: ```shell $ netstat -rn Routing tables Internet: Destination Gateway Flags Netif Expire ... 192.168.21 link#34 UCS utun6 192.168.21.1 link#34 UHW3I utun6 82 ``` {% folding 🪬 解释如下: %} 1. 192.168.21 - Destination: 目标网络 (网段) , 这里是 192.168.21, 通常表示子网. - Gateway: link#34 表示无需通过网关, 直接通过指定接口访问. - Flags: - U: 路由可达 (Up) . - C: 直接连接的网络 (Connected) . - S: 静态路由 (Static route) . - Netif: utun6 是虚拟网络接口, 常见于 VPN 或隧道连接. 2. 192.168.21.1 - Destination: 单个主机地址. - Gateway: link#34, 表示通过物理或虚拟接口访问. - Flags: - U: 路由可达. - H: 主机路由 (Host route) . - W3: 通常与 VPN 相关, 表示接口状态. - I: 可能是临时或动态的接口. - Netif: 使用 utun6 接口. - Expire: 82 表示该路由将在 82 秒后过期, 通常是动态路由. {% endfolding %} 3. 数据包进入主机后, 内核会检查: 如果数据包的目标端口是 **WireGuard** 配置的 UDP 监听端口 (51000) , 系统会将流量交给 wg0 接口进行解密, 否则就会被丢弃; 4. 解密后的流量会被系统路由表或 NAT 表处理, 决定最终的流量去向; 而 M920x 的路由表情况为: ```shell $ route -n 内核 IP 路由表 目标 网关 子网掩码 标志 跃点 引用 使用 接口 0.0.0.0 192.168.21.1 0.0.0.0 UG 101 0 0 enx00e04c680012 ... 10.10.1.8 0.0.0.0 255.255.255.255 UH 0 0 0 wg0 192.168.21.0 0.0.0.0 255.255.255.0 U 101 0 0 enx00e04c680012 ... ``` 上面表示 **192.168.21.0** 的流量会通过 M920x 的 `enx00e04c680012` 网卡处理; 在 MBP 上使用 `traceroute 192.168.21.1` 查看一下请求路径: ```shell $ traceroute 192.168.21.1 traceroute to 192.168.21.1 (192.168.21.1), 64 hops max, 40 byte packets 1 10.10.1.1 (10.10.1.1) 35.225 ms 34.738 ms 35.507 ms 2 192.168.21.1 (192.168.21.1) 37.286 ms 36.622 ms 38.947 ms ``` 1. **第一跳**: `10.10.1.1` 是 WireGuard 隧道对端的虚拟 IP, 通常是服务器的 WireGuard 接口地址. 这说明流量通过 wg0 接口成功进入 WireGuard 隧道. 2. **第二跳**: `192.168.21.1` 是目标地址, 表明 WireGuard 隧道对端 (服务器) 接收了流量, 并通过服务器上的路由规则转发到了 `192.168.21.0/24` 子网. 而 M920x 的路由规则为: ```shell $ ip route show 192.168.21.0/24 192.168.21.0/24 dev enx00e04c680012 proto kernel scope link src 192.168.21.7 metric 101 ``` 如果数据包的目标地址属于 `192.168.21.0/24` 子网, 并且没有其他更优的路由规则, 系统将通过 `enx00e04c680012` 接口发送这些数据包. > 值得说明的是, `net.ipv4.ip_forward = 1` 这个配置是转发流量的关键. --- 因为我的 M920x 分别连接了电信(192.168.31.0/24) 和联通(192.168.21.0/24) 网络, 我的目的是通过 M920x 的 **WireGuard** 访问 2 个网络下的所有设备和服务, 网络拓扑图 2: 接入电信和联通网络 ![ubuntu-wireguard-double.drawio.svg](https://cdn.dong4j.site/source/image/ubuntu-wireguard-double.drawio.svg) 1. M920x 具备双网卡, IP 分别是 `192.168.31.7/32`(电信) 和 `192.168.21.7/32` (联通); 1. MBP 的 **WireGuard** IP 保持不变; 为了满足上述需求, 我们需要修改 MBP 的 WireGuard 客户端配置: ```shell [Interface] PrivateKey = Address = 10.10.1.8/32 [Peer] PublicKey = AllowedIPs = 10.10.1.0/24, 192.168.21.0/24, 192.168.31.0/24 Endpoint = PersistentKeepalive = 25 ``` 配置完成后, 即可访问家中 2 个内网中所有的设备. **网卡优先级的问题** 上面的网络拓扑图我隐藏了一个细节: 只有在流量通过联通公网进入时才能访问家中的设备, 如果是从电信公网走, 你会发现 **WireGuard** VPN 通道都建立不上(这里的意思是指 MBP 的 WireGuard 配置中的 **Endpoint**, 联通配置的是 ` m920x.dong4j.unic:51000`, 电信配置的是 ` m920x.dong4j.tele:51000`). 原因是 **多网卡优先级导致的**: ![wireguard-nat1.drawio.svg](https://cdn.dong4j.site/source/image/wireguard-nat1.drawio.svg) **流量进入网卡后的具体流程**: 1. 电信网络握手请求: - 数据包从电信公网进入路由器,根据路由器上设置的端口映射将流量转发至 `192.168.31.7:51000`; - 数据包进入 M920x 的电信网卡 (`192.168.31.7`); 2. WireGuard 服务接收数据包: - WireGuard 接收请求,准备生成握手响应包。 3. 数据包从默认路由返回: - 因为联通网卡 (`192.168.21.7`) 的优先级高,握手响应包走联通网卡发出。 - 响应包的源 IP 为联通公网 IP(`40.40.40.40`),而客户端期待的是电信公网 IP(`20.20.20.20`)。 4. 客户端丢弃响应包: - 客户端验证响应包的源 IP (`40.40.40.40`),发现与握手请求的目标 IP(`20.20.20.20`) 不一致,认为这是无效包。 WireGuard 的握手过程依赖 UDP 数据包,并且会严格检查响应包的以下属性: 1. 源 IP 必须与目标 IP 匹配: - 客户端发送握手请求时记录目标 IP(比如电信公网 IP); - 如果服务端返回的包源 IP 是联通公网 IP,客户端无法将其与最初的请求匹配; 2. UDP 四元组验证: - 客户端会验证返回包的 UDP 四元组(源 IP、目标 IP、源端口、目标端口); - 如果源 IP 不对,整个握手流程就失败; > **M920x** 网卡优先级: enx00e04c680012(联通) > en01(电信) > > 当 WireGuard 服务端收到客户端的握手包时, 解密后需要发送回客户端, 因为 enx00e04c680012 (102.168.31.7) 优先级高于 en01, 所以会使用 enx00e04c680012(192.168.21.7) 网卡回复响应, 数据包经过联通路由器时, NAT 将源地址修改为联通公网 IP, 导致客户端无法验证响应包的源 IP, 最终握手失败. --- **添加自启动** ```shell systemctl enable wg-quick@{配置名} ``` 你可以在同一台服务器上部署多个 WireGuard 服务, 所以可以配置多个自启动, 比如现在的配置是 `wg0.conf`, 那么 **配置名** 就是 `wg0`; > 在清楚 **WireGuard** 的基础配置与使用流程后 , Linux 环境下推荐直接使用 [一键部署脚本](https://github.com/hwdsl2/wireguard-install) 来简化操作, 另一个 [一键部署脚本](https://github.com/angristan/wireguard-install). --- ##### NAS NAS 需要安装 **WireGuard** SPK 包才能正常使用, 这里是 [各个 DMS 版本的 SPK 包下载地址](https://www.blackvoid.club/wireguard-spk-for-your-synology-nas/), 如果你不放心也可以按照教程自行编译. 我使用第三方套件仓库安装的方式部署: 在 **套件中心** 新增套件来源 1. 矿神: https://spk7.imnks.com/ 2. SynoCommunity(同时推荐添加上这个, 有较多的第三方 SPK): https://packages.synocommunity.com/ ![20241229154732_LMIScRak.webp](https://cdn.dong4j.site/source/image/20241229154732_LMIScRak.webp) 使用 SSH 登录 NAS, 然后按照要求执行一下: ```shell sed -i 's/package/root/g' /var/packages/WireGuard/conf/privilege ``` 修改完可以确定一下, 如果有问题还可以用 vi 去编辑: ```shell root@DS218:~# cat /var/packages/WireGuard/conf/privilege { "defaults": { "run-as": "root" } } ``` 为了能通过 **WireGuard** 访问电信和联通的内网, 还需要 IP 转发 (`vim /etc/sysctl.conf`): ``` # 配置 IPv4 转发, 用于允许内核在网络接口间转发流量 net.ipv4.ip_forward = 1 ``` 后续的配置与在 Ubuntu 上一致, 最后使用 `wg-quick up {配置名}` 启动即可, 不需要显式设置自启动, DSM 系统会自己启动. --- ##### OpenWrt **OpenWrt** 同样支持可视化 UI 来配置 **WireGuard**, 且支持 **客户端** 模式. 推荐你直接刷自带 **WireGuard** 的 **OpenWrt 固件**, 如果没有你也可以按照下图所示去安装必要的软件包(可能需要替换软件源, 或直接通过 pkg 安装): ![20241229154732_XnylNG0m.webp](https://cdn.dong4j.site/source/image/20241229154732_XnylNG0m.webp) 在 **网络-接口** 选项卡下新增一个 **WireGuard** 接口: ![20241229154732_cXBSQwbO.webp](https://cdn.dong4j.site/source/image/20241229154732_cXBSQwbO.webp) > 在某些 **OpenWrt** 系统中, 只能添加一个 **WireGuard** 接口, 比如 NanoPI 的 FriendlyWrt, 推荐你早点换掉, 不然后面无法玩儿更多的功能. ![20241229154732_aCOYpRYa.webp](https://cdn.dong4j.site/source/image/20241229154732_aCOYpRYa.webp) 只需要填写 **私钥**, **监听端口** 和 IP 地址即可. ![20241229154732_bMy4D8kx.webp](https://cdn.dong4j.site/source/image/20241229154732_bMy4D8kx.webp) 只需要填写 **公钥**, **允许的 IP**, **勾选路由允许的 IP** 即可, 其他 3 个可以在客户端配置上修改. 最后是配置防火墙, 允许 **51000** 进出即可(因为我把 **WireGuard_Test** 划分到 wan 区域了, 所以这里是 **wan**). ![20241229154732_4ZWQaS6z.webp](https://cdn.dong4j.site/source/image/20241229154732_4ZWQaS6z.webp) 配置完成后, 最好重启一下 **WireGuard_Test** 接口, 最后配置客户端即可可到状态: ![20241229154732_jWU0hNzh.webp](https://cdn.dong4j.site/source/image/20241229154732_jWU0hNzh.webp) --- ###### lan 改 wan 口 像 R2S, R5S 和 H28K 这类 OpenWrt 系统, 网口分别是一个 WAN 口一个 LAN 口(R5S 有 2 个 2.5G LAN 口, 1 个千兆 WAN 口, 但是我需要使用到 2 个 2.5G 的网口作为 WAN 口连接电信和联通网络), 为了满足通过一台设备即可同时访问家中的 2 个内网, 需要将 LAN 口改成 WAN 口. 我以 H28K 为例, 记录一下修改方式: todo (等把 H28K 重新刷机再写) --- ##### wg-easy 部署 最简单的方式是使用 [wg-easy](https://github.com/wg-easy/wg-easy), 它支持在任何具有 WireGuard 内核的主机上使用 **Docker** 一键部署, 并自带 WebUI. 不过目前 **wg-easy** 还只能作为 **服务端** 使用, 幸运的是 [v15.0.0](https://github.com/wg-easy/wg-easy/milestone/4) 版本将开始支持 **客户端** 模式. > 这里只是简要说明一下 **wg-easy** 的部署方式, 不会详细展开客户端的使用方式, 所以需要你具备一点 **WireGuard** 使用基础. ```yaml volumes: etc_wireguard: services: wg-easy: environment: - LANG=chs - WG_HOST={你的公网固定 ip 或 DDNS 的域名} # Optional: - PASSWORD_HASH={2 次 hash 后的密码} # - PORT=WebUI 管理端口, 默认为 51821 # - WG_PORT=WireGuard 的 UDP 端口, 默认为 51820 # - WG_CONFIG_PORT=92820 - WG_DEFAULT_ADDRESS=10.10.8.x - WG_DEFAULT_DNS=223.5.5.5 - WG_MTU=1420 - WG_ALLOWED_IPS=192.168.31.0/24, 192.168.21.0/24, 10.10.8.0/24 - WG_PERSISTENT_KEEPALIVE=25 - WG_POST_UP=iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth1 -j MASQUERADE; iptables -t nat -A POSTROUTING -o eth2 -j MASQUERADE - WG_POST_DOWN=iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth1 -j MASQUERADE; iptables -t nat -D POSTROUTING -o eth2 -j MASQUERADE - UI_TRAFFIC_STATS=true - UI_CHART_TYPE=1 # (0 Charts disabled, 1 # Line chart, 2 # Area chart, 3 # Bar chart) image: ghcr.io/wg-easy/wg-easy container_name: wg-easy volumes: - etc_wireguard:/etc/wireguard ports: - "51820:51820/udp" - "51821:51821/tcp" restart: unless-stopped cap_add: - NET_ADMIN - SYS_MODULE # - NET_RAW # ⚠️ Uncomment if using Podman sysctls: - net.ipv4.ip_forward=1 - net.ipv4.conf.all.src_valid_mark=1 ``` > 更多的配置说明请查看官方文档. 需要说明的有 2 点: - [密码生成方式](https://github.com/wg-easy/wg-easy/blob/master/How_to_generate_an_bcrypt_hash.md): 生成出的密码记得将每个`$`符号替换为两个`$$`符号; - WG_POST_UP 和 WG_POST_DOWN: 因为我有 2 张网卡, 所以这里配置了 2 个 iptables; 添加一个客户端并下载配置, 或直接使用二维码导入到 WireGuard 的客户端. 最后一步就是在路由器上配置端口转发, 将 **51820** 的流量路由到 **wg-easy** 所在的服务器. ![20241229154732_lYMPhJNz.webp](https://cdn.dong4j.site/source/image/20241229154732_lYMPhJNz.webp) 因为目前 **wg-easy** 的局限性, 更推荐使用手动配置的方式部署 **WireGuard**, 这种方式更加灵活, 能够支持更复杂的网络拓扑架构. --- #### 中继 如果客户端和服务端都位于 NAT 后面, 需要加一个中继服务器, 客户端和服务端都指定中继服务器作为对等节点, 它们的通信流量会先进入中继服务器, 然后再转发到对端. ![wireguard_relay.drawio.svg](https://cdn.dong4j.site/source/image/wireguard_relay.drawio.svg) 接下来将要实现以阿里云作为中继服务器, 并将流量转发到电信和联通内网, 下面是一些基础情况: 1. WireGuard 的虚拟我们设置为局域网为 `10.10.4.0/24`; 2. M920x 2 张网卡分别接入了电信和联通网络; 3. 外部设备(MBP) 能够在外部访问 `10.10.4.0/24` 和电信联通内网中的所有设备和服务; **中继服务器配置如下**: ```shell [Interface] PrivateKey = <中继服务器私钥> Address = 10.10.4.1/32 ListenPort = <端口号> PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE # MBP [Peer] PublicKey = AllowedIPs = 10.10.4.8/32 # M920X [Peer] PublicKey = AllowedIPs = 10.10.4.7/32, 192.168.31.0/24, 192.168.21.0/24 ``` **M920x 配置如下 (前面已经有一个作为服务端的 wg0.conf 配置, 下面是新增的 wg-aliyun.conf 作为客户端的配置)**: ```shell [Interface] PrivateKey = Address = 10.10.4.7/32 [Peer] PublicKey = <中继服务器公钥> AllowedIPs = 10.10.4.0/24 Endpoint = 公网IP:<端口号> PersistentKeepalive = 25 ``` 启动: `wg-quick up wg-aliyun` **MBP 的配置如下**: ```shell [Interface] PrivateKey = Address = 10.10.4.8/32 [Peer] PublicKey = <中继服务器公钥> AllowedIPs = 10.10.4.0/24, 192.168.31.0/24, 192.168.21.0/24 Endpoint = 公网IP:<端口号> PersistentKeepalive = 25 ``` 上述配置能让在外网的 MBP 通过中继服务器, 将 `192.168.31.0/24` 和 `192.168.21.0/24` 的流量转发到 M920x, 而 M920x 正好在内网, 可以通过本机网卡流量转发来访问 2 个网段, 这里就实现了上述需求. 在 MBP 本地执行 `traceroute 192.168.31.1` 来看看跃点路径: ``` $ traceroute 192.168.31.1 traceroute to 192.168.31.1 (192.168.31.1), 64 hops max, 40 byte packets 1 10.10.4.1 (10.10.4.1) 12.971 ms 6.369 ms 6.320 ms 2 10.10.4.7 (10.10.4.7) 10.887 ms 11.182 ms 11.753 ms 3 192.168.31.1 (192.168.31.1) 12.963 ms 12.929 ms 17.214 ms ``` 1. 第一跳来到了中继服务器(`10.10.4.1`); 2. 第二跳为 M920x (`10.10.4.7`); 3. 第三跳则是 M920x 的联通网关 (`192.168.31.1`); 接下来是整个流程的分析. 我们知道在 WireGuard 启动完成后, `[Peer]` 中的每个 IP 段都会被解析为一个路由, 下面是 **中继服务器的路由表**: ``` ➜ ~ route -n Kernel IP routing table Destination Gateway Genmask Flags Metric Ref Use Iface 10.10.4.7 0.0.0.0 255.255.255.255 UH 0 0 0 wg0 10.10.4.8 0.0.0.0 255.255.255.255 UH 0 0 0 wg0 ... 192.168.21.0 0.0.0.0 255.255.255.0 U 0 0 0 wg0 192.168.31.0 0.0.0.0 255.255.255.0 U 0 0 0 wg0 ``` 从上面我们可以看出我们配置的 IP 的流量都会通过 **wg0** 网卡发出. 而 MBP 的路由表如下所示: ``` $ netstat -rn Routing tables Internet: Destination Gateway Flags Netif Expire ... 10.10.4.0/24 link#45 UCS utun6 10.10.4.7 link#45 UHWIi utun6 10.10.4.8 10.10.4.8 UH utun6 192.168.21 link#45 UCSI utun6 192.168.31 link#45 UCSI utun6 ... ``` 同样是创建了多条目标路由, 且由 WireGuard 网卡处理流量 而 M920x 只有一条: ``` $ route -n 内核 IP 路由表 目标 网关 子网掩码 标志 跃点 引用 使用 接口 0.0.0.0 192.168.31.1 0.0.0.0 UG 101 0 0 eno1 10.10.4.0 0.0.0.0 255.255.255.0 U 0 0 0 wg-aliyun ``` 所以整个流程如下: 1. **MBP 发出流量**: - MBP 需要访问 192.168.31.0/24 网络, 流量的源地址为 10.10.4.8, 目标地址为 192.168.31.1. - 根据 MBP 的 WireGuard 配置, 192.168.31.0/24 属于 AllowedIPs 列表. - 该流量会被路由到 MBP 的 WireGuard 隧道, 发送到中继服务器. ``` 源地址: 10.10.4.8 目标地址: 192.168.31.1 出口设备: utun6 ``` 2. **中继服务器处理流量**: - 根据中继服务器的配置: - AllowedIPs 包括 192.168.31.0/24, 因此流量被转发到与 M920x 的 WireGuard 隧道. - 中继服务器上的 MASQUERADE 规则被触发, 将源地址改为中继服务器的 WireGuard 地址 10.10.4.1. ``` 源地址: 10.10.4.1 (经过 NAT 转换) 目标地址: 192.168.31.1 出口设备: wg0 (到 M920x) ``` 3. **M920x 接收流量**: - M920x 收到从中继服务器来的流量: - 源地址为 10.10.4.1 - 目标地址为 192.168.31.1 - 根据 M920x 的 WireGuard 配置: - AllowedIPs 包含 192.168.31.0/24, 因此流量被路由到本地网络. - 流量离开 M920x, 进入其局域网接口 (eno1) , 并最终到达目标设备 192.168.31.1. ``` 源地址: 10.10.4.1 目标地址: 192.168.31.1 出口设备: eno1 ``` 4. **目标设备的响应**: - 目标设备 (192.168.31.1) 处理请求后, 发送响应回到源地址 10.10.4.1. - 响应流量返回到 M920x 的局域网接口. ``` 源地址: 192.168.31.1 目标地址: 10.10.4.1 出口设备: eno1 ``` 5. **M920x 返回中继服务器**: - 根据 WireGuard 隧道规则, 流量被发送回中继服务器. ``` 源地址: 192.168.31.1 目标地址: 10.10.4.1 出口设备: wg-aliyun ``` 6. **中继服务器返回 MBP**: - 根据中继服务器的 NAT 规则, 恢复原始源地址, 将目标地址改为 10.10.4.8. - 响应流量被转发回 MBP. ``` 源地址: 192.168.31.1 目标地址: 10.10.4.8 出口设备: wg0 (到 MBP) ``` 7. **MBP 接收响应** - MBP 收到从 192.168.31.1 返回的响应流量, 完成通信. 对于这种中继转发方式部署的 **WireGuard** 服务, 其中 PostUp 是关键: ``` PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE ``` 用于配置流量转发和 NAT: 1. `iptables -A FORWARD -i %i -j ACCEPT` - 添加一条规则, 允许从 WireGuard 接口 (%i 会被 WireGuard 自动替换为实际接口名, 如 wg0) 进入的流量被转发. - 目的: 确保来自 VPN 客户端的流量能够通过服务器转发. 2. `iptables -A FORWARD -o %i -j ACCEPT` - 添加一条规则, 允许从其他网络流出的流量被转发到 WireGuard 接口. - 目的: 允许外部网络的数据包返回到 VPN 客户端. 3. `iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE` - 在 NAT 表的 POSTROUTING 链中添加一条规则, 对通过 eth0 接口的流量进行地址伪装. - 目的: 将 VPN 客户端的私有 IP (如 10.10.4.0/24) 伪装成服务器的 eth0 公网 IP, 便于访问互联网或外部网络. #### 打洞 上面的 **中继服务器** 是通过转发流量的方式达到异地访问的目的, 比如可以将公司和家庭网络打通, 组成一个大的局域网, 达到异地互访的效果. 但是打洞则不同, 这里的中继服务器有点像 **Dubbo** 中的 **Zookeeper**, 起到一个注册中心的作用, 不转发流量, 当隧道打通后, 两端是通过 **直连** 访问的. 看过 [[nat-guide|NAT (Network Address Translation)]] 之后肯定清楚打洞的原理, 只要隧道被打通后, 在 NAT 记录失效之前(UDP 约 30 秒)更新 **WireGuard** 的配置并建立连接就可以直接通信, 实现细节可以参考这篇博客: [WireGuard Endpoint Discovery and NAT Traversal using DNS-SD](https://www.jordanwhited.com/posts/wireguard-endpoint-discovery-nat-traversal/), 很有启发意义. --- #### WireGuard 客户端 各大应用商店直接搜 **WireGuard** 即可, 免费好用. 但是会存在一个问题: **只能同时开启一个客户端配置**, 对我我这种有多个服务端且需要做复杂均衡的需求就满足不了, 所以官方的客户端就没在使用了, 接下来我将在 Surge 上配置多个 WireGuard 服务端, 且根据网络情况自动选择最优的服务端. ##### Surge 上配置 WireGuard Surge 配置 WireGuard 非常简单: ``` [WireGuard 节点名] private-key = <客户端私钥> self-ip = 客户端 IP (不用加子网掩码, 比如 10.10.4.8) dns-server = 自定义 mtu = 1420 peer = (public-key = <服务端公钥>, allowed-ips = "0.0.0.0/0, ::/0", endpoint = <服务端公网 IP>:<端口>, preshared-key = <预共享密钥, 没有就不加>, keepalive = 45) ``` **allowed-ips** 我这里配置是所有流量, 后续会通过 **Rule** 来分流特定流量. 然后在 **Proxy** 节点下使用此节点: ``` 🧬 Node = wireguard, section-name=节点名, test-url=可以是你内网的 IP(比如 http://192.168.31.1), ip-version=v4-only 🛜 AX9000 = direct, test-url=http://192.168.31.1, test-timeout=1 ``` 最后配置相应的规则: ``` [Rule] # 所有来自 192.168.31.0/24 的请求全部使用 WireGuard 处理 OR,((IP-CIDR,192.168.31.0/24,no-resolve), (DOMAIN-SUFFIX,xxx)),🧬 Node ... ``` 上述是一个最基础的配置, 其他更多的配置和注意事项可查看 [官方教程](https://manual.nssurge.com/policy/wireguard.html). 我的配置稍微复杂一点, 需求大致如下: 1. 在公司(异地)能访问家里的电信和联通内网; 2. 在公司访问公司内网走直连, 不走 WireGuard; 3. 在家能访问公司内网; 4. 在家访问电信和联通内网走直连, 不走 WireGuard; 5. 如果只连接了电信或联通其中某一个内网, 也能通过 WireGuard 访问另一个内网; 为了满足上述需求, 我们需要结合 Surge 的 **fallback** 和 **subnet** 相关的特性, 下面是具体配置. 1. 配置 **WireGuard** 节点: {% folding 🪬 多节点配置: %} ``` [WireGuard H28K] private-key = <客户端私钥> self-ip = 客户端 IP (不用加子网掩码, 比如 10.10.4.8) dns-server = 自定义 mtu = 1420 peer = (public-key = <服务端公钥>, allowed-ips = "0.0.0.0/0, ::/0", endpoint = <服务端公网 IP>:<端口>, preshared-key = <预共享密钥, 没有就不加>, keepalive = 25) [WireGuard R2S.T] private-key = <客户端私钥> self-ip = 客户端 IP (不用加子网掩码, 比如 10.10.4.8) dns-server = 自定义 mtu = 1420 peer = (public-key = <服务端公钥>, allowed-ips = "0.0.0.0/0, ::/0", endpoint = <服务端公网 IP>:<端口>, preshared-key = <预共享密钥, 没有就不加>, keepalive = 25) [WireGuard R2S.U] private-key = <客户端私钥> self-ip = 客户端 IP (不用加子网掩码, 比如 10.10.4.8) dns-server = 自定义 mtu = 1420 peer = (public-key = <服务端公钥>, allowed-ips = "0.0.0.0/0, ::/0", endpoint = <服务端公网 IP>:<端口>, preshared-key = <预共享密钥, 没有就不加>, keepalive = 25) [WireGuard R5S] private-key = <客户端私钥> self-ip = 客户端 IP (不用加子网掩码, 比如 10.10.4.8) dns-server = 自定义 mtu = 1420 peer = (public-key = <服务端公钥>, allowed-ips = "0.0.0.0/0, ::/0", endpoint = <服务端公网 IP>:<端口>, preshared-key = <预共享密钥, 没有就不加>, keepalive = 25) [WireGuard M920X] private-key = <客户端私钥> self-ip = 客户端 IP (不用加子网掩码, 比如 10.10.4.8) dns-server = 自定义 mtu = 1420 peer = (public-key = <服务端公钥>, allowed-ips = "0.0.0.0/0, ::/0", endpoint = <服务端公网 IP>:<端口>, preshared-key = <预共享密钥, 没有就不加>, keepalive = 25) [WireGuard DS218+] private-key = <客户端私钥> self-ip = 客户端 IP (不用加子网掩码, 比如 10.10.4.8) dns-server = 自定义 mtu = 1420 peer = (public-key = <服务端公钥>, allowed-ips = "0.0.0.0/0, ::/0", endpoint = <服务端公网 IP>:<端口>, preshared-key = <预共享密钥, 没有就不加>, keepalive = 25) [WireGuard DS923+] private-key = <客户端私钥> self-ip = 客户端 IP (不用加子网掩码, 比如 10.10.4.8) dns-server = 自定义 mtu = 1420 peer = (public-key = <服务端公钥>, allowed-ips = "0.0.0.0/0, ::/0", endpoint = <服务端公网 IP>:<端口>, preshared-key = <预共享密钥, 没有就不加>, keepalive = 25) [WireGuard R2S.C] private-key = <客户端私钥> self-ip = 客户端 IP (不用加子网掩码, 比如 10.10.4.8) dns-server = 自定义 mtu = 1420 peer = (public-key = <服务端公钥>, allowed-ips = "0.0.0.0/0, ::/0", endpoint = <服务端公网 IP>:<端口>, preshared-key = <预共享密钥, 没有就不加>, keepalive = 25) [WireGuard Aliyun] private-key = <客户端私钥> self-ip = 客户端 IP (不用加子网掩码, 比如 10.10.4.8) dns-server = 自定义 mtu = 1420 peer = (public-key = <服务端公钥>, allowed-ips = "0.0.0.0/0, ::/0", endpoint = <服务端公网 IP>:<端口>, preshared-key = <预共享密钥, 没有就不加>, keepalive = 25) ``` {% endfolding %} 2. 配置 **Proxy** 节点: ``` [Proxy] 🍺 阿里云 = wireguard, section-name=Aliyun ip-version=v4-only # ########## home ########### 🛜 AX9000 = direct, test-url=http://192.168.31.1, test-timeout=1 🛜 6500 = direct, test-url=http://192.168.21.1, test-timeout=1 # test-url 和 test-udp 交错, 用于验证双网卡是否正常 # | 🌤️ H28K | 🦠 R2S.T | 🧬 R2S.U | 🎊 R5S | 🤿 M920X | 🎉 DS218+ | 👺 DS923+ | # | 联通 | 电信 | 联通 | 电信 | 联通 | 电信 | 联通 | 🌤️ H28K = wireguard, section-name=H28K, test-url=http://192.168.31.1, ip-version=v4-only 🦠 R2S.T = wireguard, section-name=R2S.T, test-url=http://192.168.21.1, ip-version=v4-only 🧬 R2S.U = wireguard, section-name=R2S.U, test-url=http://192.168.31.1, ip-version=v4-only 🎊 R5S = wireguard, section-name=R5S, test-url=http://192.168.21.1, ip-version=v4-only 🤿 M920X = wireguard, section-name=M920X, test-url=http://192.168.31.1, ip-version=v4-only 🎉 DS218+ = wireguard, section-name=DS218+, test-url=http://192.168.21.1, ip-version=v4-only 👺 DS923+ = wireguard, section-name=DS923+, test-url=http://192.168.31.1, ip-version=v4-only # ########## home ########### # ########## work ########### 🛜 H3C = direct, test-url=http://192.168.100.1, test-timeout=1 🧩 R2S.C = wireguard, section-name=R2S.C, test-url=http://192.168.100.1, test-timeout=1 # ########## work ########### 🌐 全球直连 = direct 🚫 拒接连接 = reject ``` `test-url` 表示通过 WireGuard 隧道能访问的地址, 这里直接访问路由器的 WebUI 来测试, 如果愿意折腾的话, 也可以在 HomeLab 自建 CaptivePortal 检测服务. 🛜 AX9000 和 🛜 6500 都是直连, 且测试地址是路由器网关; 同理 🛜 H3C 的测试地址是公司内网的网关. 3. 配置 **Proxy Group** 节点: ``` [Proxy Group] 🤿 Team = fallback, 🍺 阿里云 🦠 ALL.in.One = url-test, 🛜 AX9000, 🛜 6500, 🌤️ H28K, 🦠 R2S.T, 🧬 R2S.U, 🎊 R5S, 🤿 M920X, 🎉 DS218+, 👺 DS923+, 🛜 H3C, 🧩 R2S.C, no-alert=true, interval = 600 🏠 iHome = url-test, 🌤️ H28K, 🦠 R2S.T, 🧬 R2S.U, 🎊 R5S, 🤿 M920X, 🎉 DS218+, 👺 DS923+, tolerance=30, interval = 600 💼 Company = url-test, 🧩 R2S.C, tolerance=30, interval = 600 # mini-intel 开启 surge ponte 🖥️ P.Intel = url-test, DEVICE:MINI-INTEL, hidden=true 🖥️ P.TV = url-test, DEVICE:APPLETV, hidden=true 🤿 Auto.T = fallback, 🛜 AX9000, 🏠 iHome, 🖥️ P.Intel, 🖥️ P.TV, no-alert=true 🥋 Auto.U = fallback, 🛜 6500, 🏠 iHome, 🖥️ P.Intel, no-alert=true 🚙 Work = subnet, default = 💼 Company, "192.168.100.1" = 🛜 H3C ``` 对于家庭网络直接是用 **fallback** 来处理, 表示优先是用 🛜 AX9000 和 🛜 6500, 因为前者是直连模式, 连接测试通过则表示连接了家庭网络, 则直接使用直连 模式; 如果没有连接家庭内网测试会失败, 则回退到第二个规则:🏠 iHome 组, 而 🏠 iHome 组 是由多个 **WireGuard** 节点组成, 会选择延迟最低的一个连接到 家庭网络. 公司网络则使用 `subnet` 处理, 表示如果当前网络的网关是 `192.168.100.1` (公司主路由器), 则使用 💼 Company 组中延迟最低的节点, 现在只有 1 个, 后 面还可以扩展. 4. 配置 **Rule** 节点: ``` [Rule] OR,((IP-CIDR,192.168.31.0/24,no-resolve), (DOMAIN-SUFFIX,tele)),🤿 Auto.T OR,((IP-CIDR,192.168.21.0/24,no-resolve), (DOMAIN-SUFFIX,unic)),🥋 Auto.U # ############################ work ############################# OR,((IP-CIDR,192.168.100.0/24,no-resolve),(DOMAIN-SUFFIX,yy.xxx.com), (DOMAIN-SUFFIX,xxx.info)),🚙 Work ``` 这就简单了, 根据 IP 或域名分流. 其中 **tele** 和 **unic** 是我自定义的内网域名, 分别对应电信和联通网络. 上述配置后, 2 个内网分别由多台服务器提供 WireGuard 节点, 因为 [网卡优先级的原因](#网卡优先级的问题) 分别划分到电信和联通宽带入口, 因为每台服务器至少存在 2 个网口且分别连接电信和联通内网, 所以我只要任意连接一台服务器就能访问家中 2 个网络, 多台设备起到负载均衡的作用且不会因为单点故障访问不了家庭网络(停电不算). ![20241229154732_KDb35qW4.webp](https://cdn.dong4j.site/source/image/20241229154732_KDb35qW4.webp) > 关于更多的 Surge 配置, 后续应该还会有文档输出. --- ### WireGuard 配置详解 #### 详细配置 **服务端**: ```ini [Interface] # [可选] 是否将 Endpoint 持久化到配置文件中, 默认为 false SaveConfig = false # [必须] 当前节点在 WireGuard 虚拟局域网的 IP Address = 10.10.1.1/32 # [必须] 服务端监听的端口 ListenPort = 51820 # [必须] PrivateKey = <服务端密钥> # [可选] 客户端将会使用这里指定的 DNS 服务器来处理 VPN 子网中的 DNS 请求, 但也可以在客户端中覆盖此选项 DNS = 1.1.1.1,8.8.8.8 # [可选] 定义 VPN 子网使用的路由表, 默认不需要设置, Table = auto (默认值) # 这里的 Table 是指: /etc/iproute2/rt_tables # 可以使用 ip route add default via 10.10.1.1 dev eno1 table 12345 来手动添加 Table = 12345 # [可选] 默认不需要设置, 一般由系统自动确定 MTU = 1500 # [可选] 启动 VPN 接口之前运行的命令. 这个选项可以指定多次, 按顺序执行 PreUp = /bin/example arg1 arg2 %i # [可选] 启动 VPN 接口之后运行的命令. 这个选项可以指定多次, 按顺序执行 PostUp = /bin/example arg1 arg2 %i # [可选] 停止 VPN 接口之前运行的命令. 这个选项可以指定多次, 按顺序执行 PreDown = /bin/example arg1 arg2 %i # [可选] 停止 VPN 接口之后运行的命令. 这个选项可以指定多次, 按顺序执行 PostDown = /bin/example arg1 arg2 %i # Peer: 定义能够为一个或多个地址路由流量的对等节点 (peer) 的 VPN 设置 [Peer] # [必须] 允许该对等节点 (peer) 发送过来的 VPN 流量中的源地址范围, 分别在流量出站和入站时匹配并进行响应的处理 AllowedIPs = 10.10.1.2/32 # [必须] PublicKey = <客户端公钥> # [可选] 根据场景配置 PersistentKeepalive = 25 # [可选] 预共享密钥, 使用此选项为 VPN 添加另一层加密保护 # 使用 wg genpsk 生成预共享密钥, 客户端的 Peer 节点需要配置相同的密钥 PresharedKey = xxxx ``` **客户端**: ```ini [Interface] # [必须] PrivateKey = +AbVMFfuV/b6WbZub9mxaza+YY7qHXA8QhW1wjouZng= # [必须] Address = 10.10.1.2/32 # [可选] 覆盖服务端 DNS 配置 DNS = 1.1.1.1,8.8.8.8 # [可选] 配置后将使用固定端口, 不在随机 ListenPort = 51820 # 其他的可选字段参考 服务端配置 [Peer] # [必须] PublicKey = p4U7Q+tz0brH/RFUU+4yFVL7LcrxNFfMBe+NgYiOYj8= # [必须] AllowedIPs = 10.10.1.0/24, 192.168.21.0/24, 192.168.31.0/24 # [必须] Endpoint = # [可选] 根据场景配置 PersistentKeepalive = 25 ``` #### Address: 24 vs 32 我看网上其他博客说 **如果是中继服务器, 就需要将服务端的 Address 配置成 /24 子网**, 但是我测试过 **/32** 子网也不会出现问题, 那么 **Address** 究竟定义的是什么, 带着这个疑问, 我们来看看不同配置下路由的具体情况: **Address = 10.10.1.1/32 时**: ```shell $ wg-quick up wg1 [#] ip link add wg1 type wireguard [#] wg setconf wg1 /dev/fd/63 [#] ip -4 address add 10.10.1.1/32 dev wg1 [#] ip link set mtu 1420 up dev wg1 [#] ip -4 route add 10.10.1.9/32 dev wg1 [#] ip -4 route add 10.10.1.8/32 dev wg1 ``` 启动 WireGuard 后, 自动执行的操作如下: 1. 创建名为 wg1 的 WireGuard 接口; 2. 从配置文件加载 WireGuard 的 peer 信息和密钥配置; 3. 给接口 wg1 分配 10.10.1.1/32 的 IP 地址; 4. 计算适当的 MTU (如果需要, 可以在配置中覆盖, WireGuard 推荐 1420) ; 5. 配置静态路由, 使 10.10.1.9 和 10.10.1.8 的流量通过 wg1 接口; 添加的路由为: ```shell $ ip route show ... 10.10.1.8 dev wg1 scope link 10.10.1.9 dev wg1 scope link ... ``` 意思是: **任何指向 10.10.1.8 或 10.10.1.9 的流量将直接通过 wg1 接口发送, 无需经过网关**. **Address = 10.10.1.1/24 时**: ```shell $ wg-quick up wg1 [#] ip link add wg1 type wireguard [#] wg setconf wg1 /dev/fd/63 [#] ip -4 address add 10.10.1.1/24 dev wg1 [#] ip link set mtu 1420 up dev wg1 ``` 添加的路由为: ```shell $ ip route show ... 10.10.1.0/24 dev wg1 proto kernel scope link src 10.10.1.1 ... ``` 这条路由表示: **所有指向 10.10.1.0/24 网络的流量将通过 wg1 接口直接发送, 且数据包的源地址为 10.10.1.1.** 和配置 **/24** 子网的唯一区别就是使用 `10.10.1.0/24` 代替了单独配置的 `10.10.1.8`和 `10.10.1.9`, 然后我们看一下 **10.10.1.1** 如何路由: ```shell $ ip route get 10.10.1.1 local 10.10.1.1 dev lo table local src 10.10.1.1 uid 0 cache $ ip route show table local # /32 子网输出如下 local 10.10.1.1 dev wg1 proto kernel scope host src 10.10.1.1 # /24 子网输出如下 local 10.10.1.1 dev wg1 proto kernel scope host src 10.10.1.1 broadcast 10.10.1.255 dev wg1 proto kernel scope link src 10.10.1.1 ``` 不管如何配置, 都是使用 local 路由表, 不同的是 **/24** 多了一条广播配置, 意思是当要向广播地址 10.10.1.255 发送数据包时, 流量会直接通过 wg1 接口广播到同一子网内的其他设备. **所以我只是想确认一个问题: /32 和 /24 子网分别在什么场景下使用**: | **场景** | **推荐子网** | **理由** | | -------------------------- | ------------ | ----------------------------------------- | | 点对点中继 | `/32` | 精确控制目标路由, 减少广播, 提升安全性. | | 多点或子网中继 | `/24` | 适合多个设备在同一子网通信, 简化路由管理. | | 需要节省路由表资源 | `/32` | 只处理特定 IP, 避免大范围广播. | | 需要全网自动发现和广播通信 | `/24` | 允许所有同一子网的设备自动互联. | 综上所述, **服务端的 Address 配置只是代表当前节点在局域网中的设备 IP. 如果非要说影响, 我觉得只能是创建的路由数量不同导致的占用系统资源不同罢了.** > 所以个人建议如果只是少量设备, 直接配置成 /32 子网即可, 至于什么才是 **少量设备** 就需要自己去定夺了, 反正我 10+ 节点全部配置成了 /32 子网. --- #### AllowedIPs 的作用 简单来说: **当发送数据包时, AllowedIPs 列表表现为一种路由表, 而当接收数据包时, 允许的 IP 列表表现为一种访问控制列表**: 前面我们已经知道启动 WireGuard 后, 会将 AllowedIPs IP 列表添加到路由表; 1. 发送数据时根据这个路由表选择合适的节点加密数据并发送; 2. 接受到数据时先解密, 成功后还需要对比指定节点的 AllowedIPs IP 列表与数据表的源地址是否匹配; 为了更好地理解 **WireGuard** 如何处理数据包的传输和安全性, 我们将详细介绍其加解密过程, 这将帮助我们深入理解 `AllowedIPs` 参数的重要性. **发送数据(加密过程)**: 1. **生成会话密钥**: - 每个 WireGuard 对等方 (客户端和服务端) 在建立连接时会使用 **Curve25519** 算法生成 **共享密钥** (Diffie-Hellman 密钥交换) . - 这个密钥交换基于双方的公钥和私钥来生成会话密钥, 该密钥是对称的, 意味着双方都能用它加密和解密数据. - 假设客户端的私钥为 sk_c, 公钥为 pk_c, 服务端的私钥为 sk_s, 公钥为 pk_s. 双方会通过交换公钥并用对方的公钥与自己私钥生成一个共享的会话密钥. 2. **加密数据**: - 数据包会使用 **ChaCha20** 算法进行加密. 使用共享的会话密钥来加密发送的数据. - **Poly1305** 用于数据包的认证, 确保数据在传输过程中未被篡改. 它会生成一个消息验证码 (MAC) , 并附加到加密数据上. - 加密的过程使用的是流加密的方式, 通过会话密钥生成的伪随机流来加密数据. 3. **封装数据包**: - 加密后的数据会被封装成 WireGuard 数据包, 其中包括: - 目标地址 - 加密后的有效负载 - 消息认证码 (MAC) - 发送者的身份标识 4. **发送数据包**: - 数据包会通过 UDP 发送到目标的远程端点 (例如, IP 地址和端口) . **接收数据(解密过程)**: 1. **接收数据包**: - 接收方通过 UDP 收到一个数据包, 这个数据包可能是加密的. 2. **验证消息认证码(MAC)**: - 解密前, 接收方会先使用 **Poly1305** 算法检查数据的完整性, 确保数据包在传输过程中未被篡改. - 如果验证失败, 数据包会被丢弃. 3. **解密数据**: - 如果消息验证通过, 接收方使用 **ChaCha20** 算法与共享密钥解密数据. - 共享密钥是通过双方的公钥和私钥生成的 Diffie-Hellman 会话密钥来解密数据. - 解密后, 恢复出原始的明文数据. 4. **检查数据包的来源**: - 解密后, 接收方还需要检查数据包的来源. 如果数据包是来自一个未被授权的源, 或者不符合接收方的安全策略, 数据包将被丢弃. --- 客户端在发送数据时, 会通过 **AllowedIPs** 选择 Peer 节点(源地址 IP 和 AoolwedIPs 匹配), 然后使用确定的 Peer **会话密钥**(服务端的公钥和 Peer 的私钥通过 **Curve25519** 生成)对数据进行加密; 当数据到达服务端时, 会用所有已知客户端的会话密钥逐一尝解密并试验证消息完整性,一旦匹配到有效的会话密钥, 即可确认数据包是由对应的客户端发送, 解密后的数据包中有源地址(IP Header) 中, 这时服务端的 WireGuard 就会将源地址和客户端的 **AllowedIPs** 进行匹配, 如果相同或在允许的子网内, 则接受数据包; 当服务端处理完后需要向客户端发送响应数据时, 服务端会查看响应数据的目标 IP, 并将其与每个客户端的 **AllowedIPs** 列表进行比较, 以确定将其发送到哪个客户端. 以上就是 WireGuard 的加解密过程, 从中我们可以明确 **AllowedIPs** 的作用. 那么问题来了: 1. 不同的 Peer 可以有相同的 **AllowedIPs** 吗?即 Peer1 和 Peer2 的 **AllowedIPs** 都为 `1.1.1.1/32` 或者都为 `1.1.1.0/24`; 2. 如果一个 Peer 的 **AllowedIPs** 包含了另外一个 Peer 的 **AllowedIPs**, 那么会落到哪个 Peer 上?即 Peer1 的 **AllowedIPs** 为 `1.1.1.0/24`, Peer2 的 **AllowedIPs** 为 `1.1.1.1/32`, 目的 IP 为 `1.1.1.1/32` 的包会发送给 Peer1 还是 Peer2 呢? 为了解释上面的问题, 我们来看一下 **AllowedIPs** 的具体实现: WireGuard 使用前缀树来维护 **AllowedIPs**, 下面是实现逻辑: ```go func (node *trieEntry) lookup(ip []byte) *Peer { // 用来存储找到的最近匹配的 Peer var found *Peer // IP 地址的长度, 通常是 4 (IPv4) 或 16 (IPv6) size := uint8(len(ip)) // 如果目标 IP 与当前节点的前缀匹配长度大于等于当前节点的 CIDR, 继续往下走 for node != nil && commonBits(node.bits, ip) >= node.cidr { // 如果当前节点关联了一个 Peer, 更新 found, 表示找到了一个更匹配的 Peer. if node.peer != nil { found = node.peer } // 如果已检查完目标 IP 的所有字节, 结束查找 if node.bitAtByte == size { break } // 根据当前 IP 地址的某一位决定往左子树 (0) 还是右子树 (1) 继续查找 bit := node.choose(ip) // 跳到子树节点 node = node.child[bit] } return found } ``` **整体流程**: 1. **逐层比较前缀**: 从根节点开始, 逐层比较 IP 地址的前缀部分, 确认是否匹配当前节点. 2. **更新匹配的** Peer: 如果当前节点匹配目标 IP 且关联 Peer, 将 Peer 记录下来. 3. **选择子树继续匹配**: 根据 IP 地址的位信息, 选择左子树或右子树. 4. **返回最终匹配**: 返回最长前缀匹配的 Peer. --- 如果 Peer1 的 **AllowedIPs** 为 10.10.1.0/24, Peer2 的 **AllowedIPs** 为 10.10.1.2/32, Peer3 的 **AllowedIPs** 为 192.168.31.0/24, 目的 IP 为 10.10.1.2/32, 它的前缀树看起来是这样子的: ``` [root] / \ 10.* ( ) 192.* ( ) | \ 10.10.* ( ) 168.* | \ 10.10.1.* (Peer1) 31.* (Peer3) | 10.10.1.2/32 (Peer2) ``` 所以对于上面 2 个问题的答案是: 1. 不能, 最后添加的 Peer 会覆盖前面的 Peer; 2. 第一次匹配到 `10.10.1.*`, 找到 `10.10.1.0/24`(Peer1), 然后再次匹配到 `10.10.1.2/32`(Peer2), 所以最后应该使用 **Peer2** 节点. --- #### 持久化 Endpoint 在服务端有这么一个配置: ``` [Interface] # [可选] 是否将 Endpoint 持久化到配置文件中, 默认为 false SaveConfig = false ``` 如果设置为 true, 则会在必要时将最新的 Peer 的公网 IP 和端口写入到配置文件中: 1. 正常断开连接时: 当 WireGuard 接口被关闭 (例如使用 wg-quick down 命令时) , WireGuard 会自动将当前连接的状态, 包括对等端的 **Endpoint** 信息, 写入到配置文件中. 2. 系统重启或服务重启时: 如果系统重启或 wg-quick 服务重启, WireGuard 会在重启前保存当前连接的状态, 包括 **Endpoint** 信息. 3. 动态更改时: 如果 WireGuard 通过接收到的数据包自动更新了对等端的 **Endpoint** (例如, 通过 NAT 穿越时发生远程端点 IP 或端口的变化) , 并且此时连接被手动断开或接口被重启, 最新的端点信息将被写入配置文件. 我们一般不会直接去配置服务端配置节点中的 EndPoint, 而客户端必须配置, 这样才能知道数据包应该发送到哪里, 而 WireGuard 需要知道回复的数据包应该发送给谁, 所以需要将接受到的数据包中的源地址 IP 和端口记下来, 而如果客户端的出口公网 IP 端端口变化了, 则服务端就会动态去更新指定节点的 Endpoint. --- #### DDNS 问题 WireGuard 只会在启动时解析域名, 如果你使用 `DDNS` 来动态更新域名解析, 那么每当 IP 发生变化时, 就需要重新启动 WireGuard. 粗暴点的解决方案是使用 `PostUp` 钩子每隔几分钟或几小时重新启动 WireGuard 来强制解析域名. > Linux 上内核实现的 WireGuard , 内核 API 只接受 AF_INET 或 AF_INET6 的 endpoint 地址, 所以域名是在用户态由 wg 配置工具在配置时刻解析的, 那么 DDNS 的话, 解析记录更新内核也无法感知. 还需要用户态的 daemon 监测并更新配置. 幸运的是, wireguard-tools 提供了一个示例脚本 `reresolve-dns.sh`, 它可以解析 WG 配置文件并自动重置端点地址. 需要定期运行 `reresolve-dns.sh /etc/wireguard/wg.conf` 以从已更改其 IP 的端点中恢复. 一种方法是通过 systemd 计时器每三十秒更新一次所有 WireGuard 端点: {% folding 🪬 reresolve-dns.sh 脚本: %} ```shell #!/bin/bash # SPDX-License-Identifier: GPL-2.0 # Copyright (C) 2015-2020 Jason A. Donenfeld . All Rights Reserved. # 启用错误捕获, 一旦出错, 脚本将立即退出 set -e # 启用无大小写匹配和扩展模式 shopt -s nocasematch shopt -s extglob # 设置语言环境为 C, 确保脚本在不同语言环境下行为一致 export LC_ALL=C # 获取第一个参数作为配置文件名 CONFIG_FILE="$1" # 如果参数是合法的文件名, 则使用 /etc/wireguard 路径下的配置文件 [[ $CONFIG_FILE =~ ^[a-zA-Z0-9_=+.-]{1,15}$ ]] && CONFIG_FILE="/etc/wireguard/$CONFIG_FILE.conf" # 使用正则提取接口名称 (从文件名中提取) [[ $CONFIG_FILE =~ /?([a-zA-Z0-9_=+.-]{1,15})\.conf$ ]] INTERFACE="${BASH_REMATCH[1]}" # 处理每个 Peer 的函数 process_peer() { # 只有当处于 [Peer] 段且 PublicKey 和 Endpoint 不为空时才处理 [[ $PEER_SECTION -ne 1 || -z $PUBLIC_KEY || -z $ENDPOINT ]] && return 0 # 检查 WireGuard 最新握手时间是否超过 135 秒 [[ $(wg show "$INTERFACE" latest-handshakes) =~ ${PUBLIC_KEY//+/\\+}\ ([0-9]+) ]] || return 0 (( ($EPOCHSECONDS - ${BASH_REMATCH[1]}) > 135 )) || return 0 # 更新 Peer 的 endpoint (wg set wg0 peer "公钥" endpoint "最新 IP") wg set "$INTERFACE" peer "$PUBLIC_KEY" endpoint "$ENDPOINT" reset_peer_section } # 重置 Peer 段信息 reset_peer_section() { PEER_SECTION=0 PUBLIC_KEY="" ENDPOINT="" } # 初始化 Peer 段 reset_peer_section # 逐行读取配置文件 while read -r line || [[ -n $line ]]; do # 去除注释部分 stripped="${line%%\#*}" # 提取键和值, 并去除首尾空格 key="${stripped%%=*}"; key="${key##*([[:space:]])}"; key="${key%%*([[:space:]])}" value="${stripped#*=}"; value="${value##*([[:space:]])}"; value="${value%%*([[:space:]])}" # 如果是新的段落, 处理当前 Peer 并重置段落状态 [[ $key == "["* ]] && { process_peer; reset_peer_section; } # 如果进入 [Peer] 段, 设置标志 [[ $key == "[Peer]" ]] && PEER_SECTION=1 # 如果处于 [Peer] 段, 提取 PublicKey 和 Endpoint 信息 if [[ $PEER_SECTION -eq 1 ]]; then case "$key" in PublicKey) PUBLIC_KEY="$value"; continue ;; Endpoint) ENDPOINT="$value"; continue ;; esac fi done < "$CONFIG_FILE" # 处理最后一个 Peer process_peer ``` 这个脚本用于自动更新 WireGuard 中继服务器配置文件中的 Peer 的 Endpoint, 当最新握手时间超过 135 秒时, 通过 wg set 更新 Peer 的 Endpoint 信息. **主要步骤**: 1. 读取配置文件路径, 并根据输入参数确定具体路径和接口名称; 2. 使用 process_peer 函数检查每个 Peer, 根据握手时间判断是否需要更新; 3. 通过正则匹配提取 PublicKey 和 Endpoint, 然后动态调整 WireGuard 配置; {% endfolding %} **下面是使用官方脚本的方式**: ```shell git clone https://git.zx2c4.com/wireguard-tools /usr/share/wireguard-tools ``` ```shell # sudo vim /etc/systemd/system/wireguard_reresolve-dns.timer [Unit] Description=Periodically reresolve DNS of all WireGuard endpoints [Timer] OnCalendar=*:*:0/30 [Install] WantedBy=timers.target ``` ```bash # sudo vim /etc/systemd/system/wireguard_reresolve-dns.service [Unit] Description=Reresolve DNS of all WireGuard endpoints Wants=network-online.target After=network-online.target [Service] Type=oneshot ExecStart=/bin/sh -c 'for i in /etc/wireguard/*.conf; do /usr/share/wireguard-tools/contrib/reresolve-dns/reresolve-dns.sh "$i"; done' ``` ```bash sudo systemctl enable wireguard_reresolve-dns.service wireguard_reresolve-dns.timer --now ``` 如果是 **OpenWrt** 系统安装的 **WireGuard** 且作为客户端使用的话, 可以直接使用自带的 **DDNS 看门狗脚本**, 定时任务添加以下 **cron** 即可: ```shell * * * * * /usr/bin/wireguard_watchdog ``` --- #### PostUP 和 PostDown **PostUP**: 启动 VPN 接口之后运行的命令, 这个选项可以指定多次, 按顺序执行. 例如: - 从文件或某个命令的输出中读取配置值: ```ini PostUp = wg set %i private-key /etc/wireguard/wg0.key <(some command here) ``` - 添加一行日志到文件中: ```ini PostUp = echo "$(date +%s) WireGuard Started" >> /var/log/wireguard.log ``` - 调用 WebHook: ```ini PostUp = curl https://events.example.dev/wireguard/started/?key=abcdefg ``` - 添加路由: ```ini PostUp = ip rule add ipproto tcp dport 22 table 1234 ``` - 添加 iptables 规则, 启用数据包转发: ```ini PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE ``` - 强制 WireGuard 重新解析对端域名的 IP 地址: ```ini PostUp = resolvectl domain %i "~."; resolvectl dns %i 192.0.2.1; resolvectl dnssec %i yes ``` **PostDown**: 停止 VPN 接口之后运行的命令. 这个选项可以指定多次, 按顺序执行. - 删除 iptables 规则, 关闭数据包转发: ```ini PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE ``` --- #### PersistentKeepalive 如果连接是从一个位于 NAT 后面的对等节点 (peer) 到一个公网可达的对等节点 (peer) , 那么 NAT 后面的对等节点 (peer) 必须定期发送一个出站 ping 包来检查连通性, 如果 IP 有变化, 就会自动更新`Endpoint`. 例如: - 本地节点与对等节点 (peer) 可直连: 该字段不需要指定, 因为不需要连接检查. - 对等节点 (peer) 位于 NAT 后面: 该字段不需要指定, 因为维持连接是客户端 (连接的发起方) 的责任. - 本地节点位于 NAT 后面, 对等节点 (peer) 公网可达: 需要指定该字段 `PersistentKeepalive = 25`, 表示每隔 `25` 秒发送一次 ping 来检查连接. #### 共享 Peer 配置 如果某个 `peer` 的公钥与本地接口的私钥能够配对, 那么 WireGuard 会忽略该 `peer`. 利用这个特性, 我们可以在所有节点上共用同一个 peer 列表, 每个节点只需要单独定义一个 `[Interface]` 就行了, 即使列表中有本节点, 也会被忽略. 具体方式如下: - 每个对等节点 (peer) 都有一个单独的 `/etc/wireguard/wg0.conf` 文件, 只包含 `[Interface]` 部分的配置. - 每个对等节点 (peer) 共用同一个 `/etc/wireguard/peers.conf` 文件, 其中包含了所有的 peer. - Wg0.conf 文件中需要配置一个 PostUp 钩子, 内容为 `PostUp = wg addconf /etc/wireguard/peers.conf`. --- #### 路由全部流量 如果你想通过 VPN 转发所有的流量, 包括 VPN 子网和公网流量, 需要在客户端的 `[Peer]` 的 `AllowedIPs` 中添加 `0.0.0.0/0, ::/0`. 即便只转发 `IPv4` 流量, 也要指定一个 `IPv6` 网段, 以避免将 `IPv6` 数据包泄露到 VPN 之外. 详情参考: [reddit.com/r/WireGuard/comments/b0m5g2/ipv6_leaks_psa_for_anyone_here_using_wireguard_to](https://icloudnative.io/go/?target=aHR0cHM6Ly93d3cucmVkZGl0LmNvbS9yL1dpcmVHdWFyZC9jb21tZW50cy9iMG01ZzIvaXB2Nl9sZWFrc19wc2FfZm9yX2FueW9uZV9oZXJlX3VzaW5nX3dpcmVndWFyZF90by8%3d) 例如: ``` [Interface] Address = 10.10.1.1/32 ListenPort = 44000 PrivateKey = <私钥> [Peer] PublicKey = <公钥> AllowedIPs = 0.0.0.0/0 ``` 如果某一个 peer 的 allowed ip 配置成 0.0.0.0/0, 则 wireguard 会 - 把 0.0.0.0/0 路由加入到路由表 51820 中 - 所有经过 wg0 网卡的流量包都打上 51820 标志 - 加一条策略路由规定不带 51820 标志的数据包才去查询路由表 51820 ``` $ wg-quick up wg2 [#] ip link add wg2 type wireguard [#] wg setconf wg2 /dev/fd/63 [#] ip -4 address add 10.10.1.1/32 dev wg2 [#] ip link set mtu 1420 up dev wg2 [#] wg set wg2 fwmark 51820 [#] ip -4 rule add not fwmark 51820 table 51820 [#] ip -4 rule add table main suppress_prefixlength 0 [#] ip -4 route add 0.0.0.0/0 dev wg2 table 51820 [#] sysctl -q net.ipv4.conf.all.src_valid_mark=1 [#] nft -f /dev/fd/63 ``` 因为 0.0.0.0/0 是默认路由, 而 main 表中肯定已经存在默认路由了, 所以必须插入到一个新的路由表中(`ip -4 rule add not fwmark 51820 table 51820`); 比如从浏览器发起一个远端访问, 流程为: 1. chrome 发出的数据包到达对应的 socket; 2. 该数据包经过内核协议栈时, 会首先做 route decision, 本次 routing decision 会命中 51820 路由表, 所以包会被发送到 wg0 网卡; 3. 该数据包通过字符驱动直接发送到了应用层应用 wireguard, wireguard- 会将该普通数据包封装成 wireguard 数据包, 并打上 fwmark 0xca6c; 4. wireguard 发出的 wireguard 数据包到达对应的 socket; 5. 该 wireguard 数据包经过内核协议栈时, 会首先做 routing decision, 由于该包带了 fwmark, 所以不会命中 51820 路由表, 而是命中默认路由策略, 被发送到了 eth0 网卡; 6. eth0 网卡将该 wireguard 数据包发送出去. 可以看出来, 如果不为数据包打上 fwmark 0xca6c, 不添加策略路由而是将路由添加到 main 表, 则步骤 5 中的 routing decision 会再次把包送回到 wg0 网卡, 形成路由环路. 如果配置 `SaveConfig = tue`, 启动完成后配置文件会变更为: ``` [Interface] Address = 10.10.1.1/32 SaveConfig = true ListenPort = 44000 FwMark = 0xca6c PrivateKey = <私钥> [Peer] PublicKey = <公钥> AllowedIPs = 0.0.0.0/0 ``` 会多出一个 **FwMark = 0xca6c** 配置项, 因为还没有客户端连接, Peer 下还不会持久化 Endpoint. --- #### IPv6 前面的例子主要使用 `IPv4`, WireGuard 也支持 `IPv6`. 例如: ```ini [Interface] AllowedIps = 10.10.1.1/32, fd45:da8::1/128 [Peer] ... AllowedIPs = 0.0.0.0/0, ::/0 ``` #### 常用命令汇总 **生成密钥** ```bash #生成私钥 $ wg genkey > example.key # 生成公钥 $ wg pubkey < example.key > example.key.pub ``` **启动与停止** ```bash $ wg-quick up /full/path/to/wg0.conf $ wg-quick down /full/path/to/wg0.conf # 获取在配置目录下 $ wg-quick up wg0 $ wg-quick down wg0 ``` ```bash # 启动/停止 VPN 网络接口 $ ip link set wg0 up $ ip link set wg0 down # 注册/注销 VPN 网络接口 $ ip link add dev wg0 type wireguard $ ip link delete dev wg0 # 注册/注销 本地 VPN 地址 $ ip address add dev wg0 10.10.4.8/32 $ ip address delete dev wg0 10.10.4.8/32 # 添加/删除 VPN 路由 $ ip route add 10.10.4.8/32 dev wg0 $ ip route delete 10.10.4.8/32 dev wg0 ``` **查看信息** 接口: ```bash # 查看系统 VPN 接口信息 $ ip link show wg0 # 查看 VPN 接口详细信息 $ wg show all $ wg show wg0 ``` 地址: ```bash # 查看 VPN 接口地址 $ ip address show wg0 ``` 路由: ```bash # 查看系统路由表 $ ip route show table main $ ip route show table local # 获取到特定 IP 的路由 $ ip route get 10.10.4.1 ``` 参考: - [Linux 上的 WireGuard 网络分析(一)](https://thiscute.world/posts/wireguard-on-linux/) - [wireguard protocol](https://www.wireguard.com/protocol/) - [WireGuard 到底好在哪?](https://zhuanlan.zhihu.com/p/404402933) - [Understanding modern Linux routing (and wg-quick)](https://ro-che.info/articles/2021-02-27-linux-routing) - 它的中文翻译:[WireGuard 基础教程:wg-quick 路由策略解读 - 米开朗基扬](https://icloudnative.io/posts/linux-routing-of-wireguard/) --- ### 回家方案总结 前面用大量篇幅介绍了我的 HomeLab 的网络架构, 重点关注稳定性与可用性, 目的是保证随时随地都能异地访问家庭网络, 总结起来就是下面几点: - 多宽带避免运营商故障: 灾备冗余; - 多设备避免单点故障: 负载均衡; 如果没有公网 IP, 我们还有很多方式实现异地访问: - 公网被回收则启用 IPv6; - 公网中继; - 内网穿透; [这里还有另一种方式](https://chi.miantiao.me/posts/without-ipv4/), 有机会可以尝试一下. --- ## 分流 {% folding 🪬 分流 %} 分流有 2 种方案: 1. 终端设备安装分流服务, 只服务于当前设备: - 优点: - **自主可控**: 每个设备独立运行分流服务, 配置灵活且不影响其他设备; - **无需更改网络基础设施**: 对路由器等网络设备无依赖, 避免复杂的网络配置; - **稳定性高**: 分流仅限于当前设备, 不会因其他设备或集中服务的故障而受影响; - 缺点: - **维护成本高**: 需要在每台设备上手动安装、配置和维护分流服务, 尤其当设备数量较多时工作量增加; - **统一管理困难**: 分流规则无法集中管理, 设备间的配置可能不一致 (**已通过规则服务实现分流规则集中化管理**); - **性能受限于终端设备**: 某些设备性能不足时, 分流可能会降低网络效率; 2. 部署集中式的分流服务, 服务于整个网络; - 优点: - **集中管理**: 所有设备共享一套分流规则, 配置统一且维护方便; - **设备接入简单**: 终端设备无需额外配置, 只需设置网关或 DNS; - **高效的网络流量分配**: 可充分利用高性能设备 (如专用服务器) 处理复杂的分流任务; - **便于扩展**: 新增设备时无需重复配置分流服务; - 缺点: - **单点故障风险**: 集中服务出现故障会导致所有设备分流失效; - **网络依赖性高**: 对路由器或网关的网络配置依赖较大, 复杂网络环境中可能难以部署; - **对硬件性能要求高**: 需要高性能设备支撑, 处理所有设备的分流流量; --- ### 基于 Surge 集中式的分流方案 #### 方式一 Surge 部署在 Mac mini M2 上, 配置也非常简单(类似的还有 R2S 的旁路由设置, 不同的是 DNS 也需要填写 R2S 的 IP 地址): - 开启 Surge 的增强模式 - 在其他设备上设置: | 配置名 | 配置值 | | :------- | :--------------------- | | 网关 | Mac mini M2 的 IP 地址 | | DNS | **198.18.0.2** (重点) | | 子网掩码 | 255.255.255.0 | | IP | 保持不变 | 虽然这种方式需要手动在每台终端上进行配置, 但分流这种操作本身比较特殊, 尽量在设备本地处理更为稳妥. 此外, 像 R2S 这样的旁路由设备虽然也能提供类似功能, 独立配置每台需要分流的设备可以确保更高的自主性和稳定性. **最怕的就是因为规则的问题把流量用完的情况, 因此最好在需要分流的设备上手动开启分流服务.** #### 方式二 **使用 Surge 提供 DHCP 服务** 可以让 Surge 接管路由器的 **DHCP 和 DNS 服务**, 具体操作如下: 1. 关闭路由器的 DHCP 功能. 2. 在 Surge 中启用 DHCP 功能. 3. 在终端设备上手动设置网关和 DNS (同上述配置) . **未采用原因**: - 关闭路由器 DHCP 功能后, 不确定静态 IP 绑定记录是否会保留. - 我可能会在将来切换回由路由器提供 DHCP 服务, 因此不希望因 Surge 退出使用而引发额外配置问题. - 主要设备上全部安装了 Surge, 非 macOS 的系统则会是用 R2S 的旁路由模式, 因此没必要采用这种集中式的方案. ### 经验总结 此前曾将分流服务部署在 root 过的 AX9000 路由器上, 接管全屋设备的上网需求, 以实现全屋设备无感知的分流, 但频繁的网络断线和部分网络不可用 (需要重启路由器) 违背了我追求稳定性的原则. 最终, 我将 AX9000 恢复为普通路由器, 仅用来提供静态 IP 绑定、端口映射等常规功能. 目前对分流就一个原则: **手动开启分流功能**. 每次手动开启虽然麻烦, 但是可以明确知道自己的操作会带来什么影响, 我觉得还是值得的. 对于 Apple 设备开始 Surge 还比较简单, 对于非 Apple 设备, 则提供了脚本以简化操作: ```shell proxy(){ export https_proxy=http://ip:port http_proxy=http://ip:port all_proxy=socks5://ip:port echo "HTTP Proxy on" } noproxy(){ unset http_proxy unset https_proxy unset all_proxy echo "HTTP Proxy off" } ``` 需要时执行 `proxy` 方法即可. ### 最终方案 目前分流需求仅限于少数特定设备, 我的方案是这是结合设备独立分流和旁路由模式: 1. **Apple 设备直接安装 Surge**, 采用设备独立分流方案, 配置通过 iCloud 或 Synology Drive 同步; 2. 非 Apple 设备则使用 R2S 旁路由模式或者设置 `http_proxy`, 确保分流服务的 **独立性和可控性**; {% endfolding %} --- ## 总结 我们已经基本介绍了家庭网络的配置,包括网络架构、安全性、异地组网等方面。每个章节都可以单独拿出来撰写一篇博客,但由于保持连贯性,我们选择将它们合并为一篇文章。 接下来,我们将转向服务相关的内容。作为软件开发者,我深知工具对于工作效率的重要性。因此,在接下来的内容中,我将重点关注各种工具类的自托管服务。这些服务可能会包括代码管理、持续集成和持续部署(CI/CD)工具、版本控制系统、日志管理、监控工具等。 虽然现在只是画出了一个大致的蓝图,但我相信随着时间的推移,我会不断探索和实践,将这些服务逐步应用到我的个人云端实验室中。我期待着与大家分享我在这个领域的经验、挑战和解决方案。 感谢大家的关注和支持,希望这些内容能够对您的自托管之旅有所帮助。如果您有任何建议或问题,欢迎在评论区留言,让我们一起探讨和学习。让我们继续前进,共同打造属于我们的高效、稳定且安全的个人云端实验室! **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## [HomeLab 硬件篇:构建基石-自托管硬件的选购与实践](https://blog.dong4j.site/posts/1c3b04a.md) ![/images/cover/20241229154732_REDUUGSE.webp](https://cdn.dong4j.site/source/image/20241229154732_REDUUGSE.webp) ## 简介 在打造个人云端实验室(HomeLab)的过程中,硬件选择是至关重要的第一步。它们不仅是实验的基础设施,也是整个系统稳定性和性能的保证。本篇将重点探讨如何在预算范围内,选择具有高稳定性、良好性价比、可扩展性以及兼容性的硬件。 以下是我在挑选硬件时需要考虑的关键因素: 1. **稳定性**:我将优先考虑那些可靠性高的硬件产品,避免因频繁故障而导致的维护成本和工作中断。 2. **性价比**:在确保硬件能满足基本需求的前提下,我会寻找性价比最高的选项。毕竟,资金是有限的资源,需要合理分配。 3. **可扩展性**:为了未来的发展和可能的升级,我倾向于选择那些能够轻松扩展的硬件解决方案。 4. **兼容性**:为了避免后期使用中的不兼容问题,我将选择那些在市场上得到广泛认可的、具有良好兼容性的组件。 在选择硬件时,我特别看重稳定性。我不想频繁地处理各种硬件故障,这不仅影响工作效率,也可能导致宝贵的数据丢失。因此,我排除了集成所有功能的“全功能一体机”(All In One)方案(这类方案又叫 **All In Boom**)。这类产品虽然在某些方面可能看起来很有吸引力,但它们往往牺牲了性能和升级的可能性。 相反,我会将服务部署到多个独立的服务器上。这种分布式架构意味着即使一台服务器出现故障,也不会影响到其他服务的正常运行。这种方法不仅提高了系统的可靠性,也确保了数据的安全和连续性。 在接下来的内容中,我们将详细探讨如何在实际操作中选择合适的硬件组件,并分享一些实际操作的案例和实践经验。让我们一起探索自托管硬件选购的奥秘,为构建一个稳定、高效的个人云端实验室打下坚实的基础。 **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## 硬件清单 ### 主机 | 硬件 | 型号 | 数量 | 备注 | | :---------------------: | :---------------------------------------------: | :--: | :------------------------------------------: | | MBP | 16 寸/M1 Pro Max 64G/4T | 1 | 主力机 | | Mac mini 2018 | i7/64G 内存/1T/万兆电口 | 1 | 原来的主力机, 现在沦为下载机与服务器 | | Mac mini 2023 | M2/16G 内存/256G/万兆电口 | 1 | 缓存服务器, 小模型的试验机 | | ThinkStation M920x Tiny | i7/64G 内存/4.5T/双万兆光口 | 1 | 主要的服务器, 大部分 Docker 和虚拟机的寄主机 | | 台式组装机 | 双路 E5 2680V4/256G 内存/2T/万兆电口/2080Ti+P40 | 1 | 炼丹炉 | 因为工作的原因, 从 2015 购买第一台 MBP 开始便一发不可收拾, 陆陆续续购买了 Apple 全家桶, 一是因为 macOS 非常适合开发, 二是简约的系统和完美的工业设计. 后来因为组建 HomeLab 和 AI 的爆火, 开始购买 X86 硬件与显卡, 组装了第一台家用服务器, 但是因为耗电量巨大, 不是 24 小时开机运行. 后续入手了联想的 M920X 准系统, 后续持续升级到现在的配置, 安装了 Ubuntu Server, 主要用于跑一些大型服务与重要数据的容灾备份. #### MackBook Pro ![20241229154732_psTBnior.webp](https://cdn.dong4j.site/source/image/20241229154732_psTBnior.webp) 这是我的第二台 MBP, 在如今 LLM 爆发的 AI 元年, 很庆幸买了 64G 内存的版本, 得益于 [llama.cpp](https://github.com/ggerganov/llama.cpp) 这样的优秀框架,即使在 CPU 上也能相对流畅地运行大型模型。 不得不说 Apple 从 Intel CPU 升级到 Apple Silicon, 给我最直观的感受就是基本上只有在跑 LLM 时才会稍微感受到一点温度, 其他时候都冷手. 目前,LLM(大型语言模型)正处于百花齐放的阶段,从自然语言交互到图像和语音生成,再到多模态处理,各领域的 LLM 应用层出不穷。然而,对于个人用户来说,部署 LLM 仍需要不小的硬件投入,尤其是在端侧,隐私问题和计算资源的高要求都未能很好地解决。如果未来能在树莓派这样的开发板上顺畅运行 LLM,那将是一个让每个人都可以轻松拥有、私密且低成本的个人化 LLM 时代美好时代。 #### Mac mini 2018 Mac mini 2018 是我第一台 Mac 主机, 主要看重它能够更换内存, 因此买了最小的 8G 版本, 然后升级到了 64G 内存, 不得不说但是的内存条跳水真的太夸张了, 第一条 32G 内存买成接近 3000, 隔了 3 个月购买第二张 32G 内存时, 降价到 1000+, 我只能用 "早买早享受" 来安慰自己了. ![20241229154732_96bJUcoX.webp](https://cdn.dong4j.site/source/image/20241229154732_96bJUcoX.webp) 内部结构, 看着赏心悦目 ![20241229154732_5onTic5f.webp](https://cdn.dong4j.site/source/image/20241229154732_5onTic5f.webp) ![20241229154732_9hC64AcE.webp](https://cdn.dong4j.site/source/image/20241229154732_9hC64AcE.webp) 一条超贵的 32G 内存条. 后续再次入手了一台同型号的 Mac mini 2018, 放在公司作为开发主力机, 还购买了 Blackmagic eGPU, 因为当时 Mac mini 2018 带 2 台 4K 显示器略微吃力, 在 Blackmagic eGPU 的加持下, IDEA 的流畅度大大增加. ![20241229154732_ChkVaXC2.webp](https://cdn.dong4j.site/source/image/20241229154732_ChkVaXC2.webp) 我只能说 Intel 芯片发热太严重了, 陆陆续续更换了多个外置散热, 最离谱的是还用过半导体散热方案, 不开散热一会就开始烫手, Apple Silicon YYDS... ![20241229154732_AGWL0V74.webp](https://cdn.dong4j.site/source/image/20241229154732_AGWL0V74.webp) 当 Mac mini M1 上市后, 还在观望, 查看了大量测试视频后, 决定后期购买 M2 芯片的 Mac mini, 因此出掉了一台 Mac mini 2018, Blackmagic eGPU 则卖给了朋友, 剩下的 Mac mini 2018 就当留作 Apple Intel CPU 的最后一代的见证(其实是 M 系列芯片上市后, Intel 系列的 Mac mini 降价太严重, 犹豫之后还是留下来当服务器用). #### Mac mini M2 Mac mini M2 的配置是: 16G 内存 + 256G SSD + 万兆电口, 但是应该是最具性价比的 M 系列小主机, 对于开发者来说, 内存比闪存更重要, 且当时也有更换闪存的成功案例, 因此想着后期能够直接更换更大的闪存且比官网便宜太多, 就购买了 256G 的版本. ![20241229154732_1FRBAwoW.webp](https://cdn.dong4j.site/source/image/20241229154732_1FRBAwoW.webp) 忽略杂乱的线, 前面显示器一盖就什么都看不到了(更乱的线还在后面 😂, 桌搭的博客安排上) 目前 Mac mini M2 主要用作缓存服务器, 且因为统一内存的架构, 还能跑一跑 GGUF 格式的小模型. 目前还没有完全用起来, 等更换了闪存后再开始重度使用(256G 闪存被阉割且真的太小了, 目前都不敢安装太多的 APP, 且 LLM 的模型文件还得放到 NAS 上才敢跑一下). #### ThinkStation M920x Tiny ![20241229154732_VUOwmbbQ.webp](https://cdn.dong4j.site/source/image/20241229154732_VUOwmbbQ.webp) 具体硬件参数: - CPU: i7-8700 - 内存: `32G *2` - 磁盘: M.2 512G 系统盘 + 1T `SSD * 4` - 网卡: Intel X520-DA2 双万兆 10G 光口网卡 + 板载千兆 I219 电口网卡 + USB 转 2.5G 电口网卡 + USB 蓝牙 & WiFi 模块 系统选择了 Ubuntu Server, 而不是 PVE 或者 ESXi 的原因是 ThinkStation M920x Tiny 定位是单机服务器, 主要满足个人需求: - 几乎所有服务都可以使用 Docker 运行,开销小; - 容器调用显卡比虚拟机调用显卡容易很多; 更何况这台 M920x 没装显卡; - 虚拟化支持是集成在 Linux 内核中的,任何发行版都可以安装 KVM/QEMU 来跑虚拟机; - 随着使用时间的增加,虚拟机的资源总是需要重新调整,而像文件系统这类的资源调整起来很麻烦; - 不需要虚拟诸如 OpenWrt, NAS 等系统, 我更倾向于购买专门的硬件来跑这类独立系统; 所以我目前就是 Ubuntu + Docker + KVM/QEMU,Docker 里跑着四十多个服务,共用文件系统和显卡,偶尔有跑其他内核系统的需求就在 KVM/QEMU 里开个虚拟机, 简单而不失优雅. ![20241229154732_iYf5ZAWP.webp](https://cdn.dong4j.site/source/image/20241229154732_iYf5ZAWP.webp) 目前只跑了 4 个虚拟机, WAF 作为防火墙是所有外网流量的第二层入口, 负责代理局域网的其他服务, 如果出现问题可以快速熔断. 其他 2 个虚拟机作为实验机, 使用纯净的系统避免因为端口冲突导致启动失败等问题出现(寄主机已经启动了太多的 Docker 容器, 管理端口确实有点麻烦, 用新的虚拟机直接 docker-compose 启动即可, 避免升级时 git pull 的冲突). #### 台式组装机 ![20241229154732_9NJuNO46.webp](https://cdn.dong4j.site/source/image/20241229154732_9NJuNO46.webp) 严重光污染警告!!! 具体硬件参数: - CPU: 双路 E5 2680V4; - 内存: 256G ECC 内存(全部插满, 要的就是这种满足感); - 磁盘: `1T SSD * 2` (后期会考虑扩充 HDD 硬盘); - 显卡: 2080Ti + P40; - 网卡: 板载双千兆电口网卡 + PCIe 转万兆电口网卡 + PCIe 转 2.5G 电口网卡 + USB 蓝牙 & WiFi 模块; 早期是真的想组装一台属于自己的服务器, 考虑过机架服务器, 便宜是真便宜, 奈何噪音机器体积都无法接受, 放家里肯定不合适. 所以选择了支持双路的华南主板, 搭配 2 颗 E5 2680V4, 性价比还是挺高的. 后续继续捡垃圾, 内存条全部插满, 系统也从 Windows 11 换成了 Ubuntu Server 22.04(E5 跑 Windows 11 卡的我怀疑人生). 存储倒是没有太多的要求, 所以直接搭配了 2 条 1T SSD, 后期在考虑扩展吧. 传统的封闭式机箱没有找到满意的, 索性就去咸鱼定制了一款开放型机箱, 看着还不错, 就是灰尘不好打理. 因为插了 2 张显卡, 这个耗电量着实有点吃不消, 目前也只会在测试最新模型时才会开机, 其他时间都在吃灰. 2080Ti 咸鱼二手购入, 但是 1800, 现在有魔改 22G 的版本, 后续可以尝试一下. 跑模型非常吃显存, 因此后续又购入了 Tesla P40, 24G 显存可以自己训练 Lora 模型了. 因为散热问题导致宕机过几次, 因此对 2080Ti 进行了魔改, 加装了散热钢板并改成了水冷, 目前跑模型还能压得住. Tesla P40 因为是服务器上使用的不具备主动散热, 因此去咸鱼上改了个散热, 目前用下来还勉强够用, 不过不是特别推荐, 一是速度太慢了, 二是有可能不被模型支持. ##### 接入米家 为了方便的开关机, 加装了一张 WiFi 开机卡, 接入米家后可直接语音开关机. ![20241229154732_ekHpddvV.webp](https://cdn.dong4j.site/source/image/20241229154732_ekHpddvV.webp) ### 群晖 NAS | 硬件 | 型号 | 数量 | 备注 | | :----: | :-----------------------------------------------------------------: | :--: | :------------------------------------: | | DS218+ | 10G 内存/2T SSD 组 Raid1/千兆电口+2.5G 电口 | 1 | 主要用于 Drive 数据同步, 跑轻量 Docker | | DS923+ | 36G 内存/2T SSD 组 Raid1 作为系统盘/ 6T + 8T + `10T*2` HDD/万兆电口 | 1 | 重要数据备份,影音服务, Docker 寄主机 | #### DS218+ ![20241229154732_CHgbTUwq.webp](https://cdn.dong4j.site/source/image/20241229154732_CHgbTUwq.webp) 从 2019 年入手第一台 DS218+ 开始, 便入了 NAS 的坑, 原来 NAS 还能这么好玩, 最开始购买的 `6*6` T HDD 硬盘因为断电损坏了一块, 导致现在对 HDD 的选择非常谨慎. 目前 DS218+ 主要通过 Synology Drive 来同步文件, 因为有公网 IP 的加持, 围绕 Drive 搭建了全平台的数据同步方案, 目前使用下来非常省心. 为了提高 DS218+ 的数据同步效率, 且我也不需要特别大的 HHD, 因此索性将 HDD 全部更换为 SSD, 并组了 Raid1 来保证数据安全. #### USB 2.5G 网卡 DS218+ 使用了 USB 转 2.5G 网卡将网络升级到了 2.5G, [这里](https://www.iplaysoft.com/synology-nas-25g.html) 是详细的教程, 不过现在通过第三方的套件更加方便: ![20241229154732_pXRy1ms9.webp](https://cdn.dong4j.site/source/image/20241229154732_pXRy1ms9.webp) 需要在套件中心添加第三方套件来源: ![20241229154732_zNrxABRp.webp](https://cdn.dong4j.site/source/image/20241229154732_zNrxABRp.webp) 套件地址汇总: - spk7: spk7.imnks.com - synocommunity: https://packages.synocommunity.com/ - 云梦: https://spk.520810.xyz:666 - 4sag: https://spk.4sag.ru/ #### DS923+ ![20241229154732_DO0n6x0e.webp](https://cdn.dong4j.site/source/image/20241229154732_DO0n6x0e.webp) 而 DS923+ 作为重要数据的备份从机, 会对 DS218+ 进行全盘备份, 同时跑一些影音服务, Docker 寄主机. 值得说明的是, DS923+ 官网说只支持 32G 内存, 试测是可以最大支持到 64G 的, 目前只购买了一张 32G ECC 内存, 加上原来的 4G 一共 36G. [DS923+ 支持 SSD 作为存储盘](https://post.smzdm.com/p/all3nvl8/), 可是坑爹的是只能使用官网的 M.2, 不过开源的 [Synology HDD db](https://github.com/007revad/Synology_HDD_db) 可以完美的绕过这个问题, 需要在 DSM 更新后重新运行该脚本。如果 DSM 设置为自动更新,则最佳选项是在 Synology 每次启动时运行该脚本,而最佳方法是设置计划任务以在启动时运行该脚本: ![20241229154732_3bSAtDBM.webp](https://cdn.dong4j.site/source/image/20241229154732_3bSAtDBM.webp) 还有其他几个项目可以尝试: - [Synology enable M2 volume](https://github.com/007revad/Synology_enable_M2_volume) - [Synology M2 volume](https://github.com/007revad/Synology_M2_volume) #### 万兆网卡 DS923+ 官网 10G 网卡: ![20241229154732_oFOXXxgS.webp](https://cdn.dong4j.site/source/image/20241229154732_oFOXXxgS.webp) 我选择 DS923+ 的原因就是支持万兆网卡, 为后期全屋万兆打下基础, 但是官方的网卡实在太贵, 抵得上 `1/5` 的 DS923+ 的价格了, 直到兼容的网卡出现, 400+ 的价格果断下手: ![20241229154732_rfOI8I5O.webp](https://cdn.dong4j.site/source/image/20241229154732_rfOI8I5O.webp) 现在 DS923+ 与 Mac mini 2018 通过 `HYWS-SGT0204S` 万兆交换机直连, 拷贝速度非常满意, 后期考虑将书房的主要设备全部升级到万兆(现在就差全口万兆交换机和雷电 4 转万兆扩展坞了, 还在犹豫全光口还是电口, 大概率会全光口, 发热量比电口小的多, 但是部分设备只能用电口, 因此还要增加光转电接口的成本). ![20241229154732_aUnLVbI4.webp](https://cdn.dong4j.site/source/image/20241229154732_aUnLVbI4.webp) 跑满了理论速度. ![20241229154732_rMPeFABT.webp](https://cdn.dong4j.site/source/image/20241229154732_rMPeFABT.webp) `HYWS-SGT0204S` 交换机只有 2 个万兆光口, 分别通过万兆光转电模块连接 DS923+ 和 Mac mini 2018, 发热量也是不小, 因此额外增加了散热风扇和散热片, 效果非常好. #### 链路聚合 ![20241229154732_vLCqG6GH.webp](https://cdn.dong4j.site/source/image/20241229154732_vLCqG6GH.webp) 因为购买了独立的万兆网卡, 自带的 2 个千兆网卡就可以玩玩链路聚合了, 不过因为没有连接到具备网管功能的交换机上, 目前也就只能算个自娱自乐, 还不具备带宽翻倍的效果. 可以参考以下链接来学习相关知识: - [SMB3 多通道和链路聚合的区别](https://kb.synology.cn/zh-cn/DSM/tutorial/smb3_multichannel_link_aggregation) - [nas 开启链路聚合,提升多设备访问带宽](https://post.smzdm.com/p/apm8p5mw/) 我的目的是实现提升多设备并发访问的效率, 理论上只需要添加一台具备网管功能的交换机即可(目前硬件已经有了, 但是需要改造线路, 后面再折腾吧). ### 硬路由 | 硬件 | 型号 | 数量 | 备注 | | :---------------------: | :----------------: | :--: | :----------------------------------------: | | 小米 AX9000 | 电信拨号, 主路由器 | 1 | 1G 宽带接入, 2.5G 网口用作局域网 | | 小米 AX1800 | AX9000 Mesh 路由器 | 2 | 书房和主卧各一个, 增加 WiFi 覆盖面积 | | 小米 6500Pro | 联通拨号, 主路由器 | 1 | 1G 宽带接入, 2.5G 网口用作局域网 | | 小米路由器 R3D | 6500Pro 二级路由器 | 1 | 淘汰下来的, 1T 硬盘当下载机用 | | 小米路由器 R1D | 6500Pro 二级路由器 | 1 | 淘汰下来的, 1T 硬盘当下载机用 | | AirPort Time Capsule 2T | AX9000 子路由 | 1 | 关闭了 WiFi, 用作 macOS 系统备份, 网口拓展 | | Airport Express | AX9000 子路由 | 1 | 关闭了 WiFi, 只用作 AirPlay2 放歌 | 家里是电信联通双千兆宽带入户, 电信作为主网, 用作家庭主要上网宽带; 联通作为辅网, 用作下载与物联设备联网, 互不干涉, 冗余设计. #### AX9000 ![20241229154732_NNLkCikg.webp](https://cdn.dong4j.site/source/image/20241229154732_NNLkCikg.webp) AX9000 是可以跑 Docker 的, 且能够通过 Docker 获取 root 权限, 但是尝试过后发现不是特别稳定, 所以还原成了. 专业的事情还是让专业的设备来干吧. ![20241229154732_NadeAzr8.webp](https://cdn.dong4j.site/source/image/20241229154732_NadeAzr8.webp) 目前带了差不多快 40 个终端设备, 稳定性还是可以的. #### AX1800 AX1800 通过有线与 AX9000 进行 Mesh 组网, 一个放在主卧, 另一个放在书房, 一是作为 WiFi 热点, 二是作为 Lan 扩展, 下级再连接交换机为开发板提供有线接入. #### 6500Pro ![20241229154732_LjKBfdVw.webp](https://cdn.dong4j.site/source/image/20241229154732_LjKBfdVw.webp) 购买 6500Pro 是看上了它内置的中枢网关, 家里大部分智能设备是小米旗下的, 基本上节省了一台网关的钱, 但是性能有点差强人意, 一是延迟较高, 二是散热设计非常不合理, 如果有意购买 6500Pro 的朋友, 可以先看看这个 [测评](https://www.bilibili.com/video/BV1zx4y147WQ?t=339.8), 后面可能会考虑进行改造. ![20241229154732_fgAIfLaz.webp](https://cdn.dong4j.site/source/image/20241229154732_fgAIfLaz.webp) 6500Pro 主要作为智能家居设备接入, 因此专门开了一个 `ihome.device` 的 2.4GHz 的频段, 并且另外开设了一个访客网络, 供来访的朋友使用. #### 小米路由器 R3D && R1D ![20241229154732_VELGdZq6.webp](https://cdn.dong4j.site/source/image/20241229154732_VELGdZq6.webp) ![20241229154732_Dw4yHaXG.webp](https://cdn.dong4j.site/source/image/20241229154732_Dw4yHaXG.webp) 2 款路由器都比较经典, 自带 1T HDD 且具有 SMBA 功能, 完全可以当轻 NAS 是用, 期间获取过 root 权限, 但是硬件素质太差, 不具备太大的可玩性. 不知道从何开始, 喜欢上折腾散热了, 能加装散热的设备通通不放过, 它两本身的散热一般, 夏天稍有点热, 现在的外加散热还是有点用, 但不是太多. #### AirPort Time Capsule 2T & Airport Express ![20241229154732_Ln4zBPrS.webp](https://cdn.dong4j.site/source/image/20241229154732_Ln4zBPrS.webp) ![20241229154732_uePyCTQR.webp](https://cdn.dong4j.site/source/image/20241229154732_uePyCTQR.webp) 2 款停产许久的产品, AirPort Time Capsule 2T 还是多年前去万象城人肉提回来的, 2T 的备份备份 3 台 macOS, 勉强够用吧, 另外作为千兆网口的扩展. Airport Express 后面咸鱼入手的, 也就拿来 AirPlay2 放歌用, 没有其他用途了. ![20241229154732_1zzg0yZM.webp](https://cdn.dong4j.site/source/image/20241229154732_1zzg0yZM.webp) 感觉 Apple 路由器设置还是蛮方便的. #### IPV6 两款主路由器都支持开启 IPV6, 且在外网能够正常通过 IPV6 访问家里的设备, 但是目前是关闭状态, 因为有部分服务还没有来得及设置安全认证, 总觉得直接暴露到公网会有安全问题(IPV6 因为长度的问题想要暴力猜测可能没那么容易, 奈何我绑定了域名, 这不一 Ping 就知道设备的唯一地址了, 还是先用 IPV4 吧, 至少能通过端口转发控制暴露出去的服务). #### 为什么不用猫棒代替光猫 经常看到有视频介绍 `猫棒+ 2.5G 路由器` 代替光猫突破千兆, 我并没有采用这种方案, 主要原因是: - 猫棒发热量巨大, 降低了稳定性; - 为了从 930Mbps 突破到 1100Mbps+, 额外购买猫棒和其他硬件, 我觉得性价比不是特别高; #### 为什么不考虑多线多拨、负载均衡、宽带叠加 同样会有大量视频推荐多线多拨、负载均衡、宽带叠加等实现 1+1 = 2 的网速, 但是这些方案我没有考虑的原因是: - 大多数运营商不支持多拨, 或者多拨也不会叠加网速, 我主要考虑稳定性; - 网速快不代表体验好, 联通宽带延迟比电信宽带延迟高, 我可以按需使用不同的宽带, 避免延迟不可控; - IP 乱跳: 我的电信和联通宽带都有公网 IP, 用上述方法肯定会导致出口 IP 随机变化, 当然可以通过配置解决, 但是会增加额外的硬件成本和配置复杂度; - 我目前根本用不到这么快的网速, 上述方式目前只停留在测速上, 对我来说没有实际应用场景; - 因为会存在单点问题, 并不想使用一个设备去承载 2 条宽带; 目前考虑的是双宽带主从冗余, 各司其职, 且另一个关键的原因是需要通过 Wireguard 进行异地组网, 会分别通过不同的宽带接入到家中的局域网, 因此不想更改目前的网络架构. ### 软路由 | 硬件 | 型号 | 数量 | 备注 | | :--: | :--------------: | :--: | :----------------------------------: | | R2S | 电信&联通 旁路由 | 3 | 轻量 Docker 容器, 广告过滤, 异地组网 | | R5S | 电信&联通 旁路由 | 1 | 轻量 Docker 容器, 广告过滤, 异地组网 | | H28K | 电信&联通 旁路由 | 1 | 轻量 Docker 容器, 广告过滤, 异地组网 | OpenWrt 又是一个打开新世界大门的伟大开源项目, 从未想过还能随心所欲的配置网络, 不过在配置的过程中也遇到了不少坑. 购买软路由的朋友, 怎么使用应该都知道吧, 所以这部分就不展开说了. #### R2S 最开始入手的 R2S, 小巧的外形加铝合金材质, 怎么看怎么喜欢, 后来加上了风扇, 通过温控脚本控制风扇转速. 刚开始因为对 OpenWtr 不是特别熟悉, 不知道 Lan 口还能改成 Wan 口, 就只作为电信宽带的子路由使用, 部署 Wireguard 后只能访问电信的局域网, 因此后面捡漏购买了另一个 R2S, 作为联通宽带的 Wireguard 服务器. 随着折腾的不断深入, 尝试将 Lan 口修改为 Wan 口, 现在 R2S 能够同时接入电信和联通宽带, 权当作为冗余吧, 也没有出掉多余的 R2S. ![20241229154732_kwknvZTL.webp](https://cdn.dong4j.site/source/image/20241229154732_kwknvZTL.webp) 放在书房桌面上, 作为电信的二级路由, 联通作为冗余宽带接入. ![20241229154732_qhHqVaPO.webp](https://cdn.dong4j.site/source/image/20241229154732_qhHqVaPO.webp) 放在弱电箱, 作为联通的二级路由, 电信作为冗余宽带接入. ![20241229154732_27qy1AB3.webp](https://cdn.dong4j.site/source/image/20241229154732_27qy1AB3.webp) 第三台 R2S 放在公司使用, 主要也是作为 Wireguard 服务器, 与家里的设备进行异地组网. 值得说明的是, R2S 的 Wan 口是通过 USB3.0 转换的, 测试过最多跑到 600M+, 但是因为使用场景的不同, 我目前对这个速率还不是那么敏感, 所以也没有升级的必要. ##### 将 Lan 改成 Wan 接口 R2S 主要作为旁路由使用, 因此并不需要设备与 Lan 直接, 所以多出来的 Lan 口可以用来做 Wan 口, 这样就可以同时接入电信和联通宽带了. 但是会存在网卡优先级的问题, 可以通过网关跃点解决: ![20241229154732_HVQ66tyu.webp](https://cdn.dong4j.site/source/image/20241229154732_HVQ66tyu.webp) 具体的配置方式会在网络篇中详细说明. ##### 温控脚本 我安装的固件并没有官方的温控脚本, 不过按照 [教程](https://github.com/coolsnowwolf/lede/issues/5681) 完美实现了温控. ```shell curl -o /usr/bin/start-rk3328-pwm-fan.sh https://github.com/friendlyarm/friendlywrt/blob/master-v19.07.1/target/linux/rockchip-rk3328/base-files/usr/bin/start-rk3328-pwm-fan.sh \ curl -o /etc/init.d/fa-rk3328-pwmfan https://github.com/friendlyarm/friendlywrt/blob/master-v19.07.1/target/linux/rockchip-rk3328/base-files/etc/init.d/fa-rk3328-pwmfan \ chmod +x /usr/bin/start-rk3328-pwm-fan.sh /etc/init.d/fa-rk3328-pwmfan \ /etc/init.d/fa-rk3328-pwmfan enable \ /etc/init.d/fa-rk3328-pwmfan start ``` #### R5S ![20241229154732_BJuukFNQ.webp](https://cdn.dong4j.site/source/image/20241229154732_BJuukFNQ.webp) 加上了散热风扇, 但是温控还没有搞定, 不过引出了 PWM 接口, 后续折腾的时候不用再拆机器了, 直接通过 PWM 取电, 替换现在的 USB. 还为 R5S 添加了 256G 的 M.2 SSD, 作用轻量服务器, 4G 的内存不能白白浪费了. #### H28K 非常喜欢这种小巧的铝合金机身, 因此忍不住又买了 H28K 4G 版本, 不过目前也仅仅是作为 Wireguard 服务器, 连 Docker 容器都还没有 🤣. ![20241229154732_2dNPRgDy.webp](https://cdn.dong4j.site/source/image/20241229154732_2dNPRgDy.webp) ![20241229154732_pYC2RuuP.webp](https://cdn.dong4j.site/source/image/20241229154732_pYC2RuuP.webp) 因为 H28K 没有 PWM 接口, 就自己焊接了一个 USB 来驱动风扇, 感觉还行 😎. ### 交换机 | 硬件 | 型号 | 数量 | 备注 | | :------------------: | :------------------------------: | :--: | :------------------------------: | | TP-Link TL-SH1005 | 电信&联通 `2.5G*5口 交换机` | 2 | 弱电箱出口交换机 | | 兮克 SKS1200-8GPY1XF | 电信 `2.5G*8口 + 1万兆口 交换机` | 1 | 书房入口交换机 | | DX-1009N | 联通 `2.5G*8口 + 1万兆口 交换机` | 1 | 书房入口交换机 | | TP-Link TL-SG2008D | 电信 `千兆*8口 交换机` | 1 | 二级交换机, 主要用于连接开发板 | | TP-Link TL-SG1008D | 联通 `千兆*8口 交换机` | 1 | 二级交换机, 主要用于连接开发板 | | iKuai IK-J3005D | 电信&联通 `千兆*5口 交换机` | 2 | 书房临时交换机 | | HYWS-SGT0204S | 联通 `2.5G*4口 + 万兆*2口交换机` | 1 | mac mini 2018 和 DS923+ 万兆直连 | 交换机陆陆续续换了好多款, 主要是从 千兆升级到 2.5G, 到现在的 2.5G 混合万兆网口. 后续会考虑为书房添加联通万兆交换机, 将主要设备进行万兆直连, 主要包括: - MBP: 通过雷电 4 转万兆光口; - Mac mini 2018: 自带万兆电口; - Mac mini M2: 自带万兆电口; - DS923+: 已升级到万兆电口; - M920X: 自带 2 个万兆光口; - 组装机: 自带 1 个万兆电口; 因此购买一个 8 光口的万兆交换机绰绰有余, 然后根据需要购买万兆光转电模块直连. #### TL-SH1005 ![20241229154732_A5NMXTPo.webp](https://cdn.dong4j.site/source/image/20241229154732_A5NMXTPo.webp) 弱电箱电信玩光猫光纤接入, 通过预埋的网线与对面电视柜中的 AX9000 连接并拨号上网, 在通过另一根预埋的网线接回到 `TL-SH1005` 交换机, 然后再接入到其他房间. 联通宽带接入就相对简单许多, 因为光猫, 6500Pro 还有交换机都在弱电箱附近, 直接通过短距离有线连接即可, 同样是光纤入户, 路由器拨号. 还好装修时预埋了多条网线: 客厅电视柜 3 根, 主卧和书房 2 根, 次卧 1 根. 如果埋少了恐怕整个 HomeLab 又是另一种架构了. #### SKS1200-8GPY1XF & DX-1009N ![20241229154732_eWULXP1G.webp](https://cdn.dong4j.site/source/image/20241229154732_eWULXP1G.webp) ![20241229154732_DNx5FYKP.webp](https://cdn.dong4j.site/source/image/20241229154732_DNx5FYKP.webp) 2 个交换机先后从咸鱼购买, 没有去细究具体参数, 本着捡垃圾的原则, 捡便宜的买就行. 从书房的 2 个网口分别接入电信和联通网络, 为书房其他设备提供有线网络接入 (基本上每台设备都具备双网口, 为了将冗余做到极致, 单网口的也要整个 USB 转 2.5G 网卡). #### IK-J3005D ![20241229154732_SrWEV0Zf.webp](https://cdn.dong4j.site/source/image/20241229154732_SrWEV0Zf.webp) 先后买了 2 个, 放在桌面上做临时的交换机, 小巧占地方. ![20241229154732_nIZrJBLi.webp](https://cdn.dong4j.site/source/image/20241229154732_nIZrJBLi.webp) #### HYWS-SGT0204S ![20241229154732_ILxjbwLr.webp](https://cdn.dong4j.site/source/image/20241229154732_ILxjbwLr.webp) 后来 2.5G 网口不够了, 且为了将 Mac mini 2018 与 DS923+ 进行万兆直连, 新增了这个交换机. 后续考虑换成全万兆交换机, 把主要设备全部升级到万兆互联. ### 影音硬件 | 硬件 | 型号 | 数量 | 备注 | | :----------: | :------------: | :--: | :------------------: | | Apple TV 4K | 64G | 1 | 客厅电视 | | HomePod | 第一代 | 1 | 书房音箱 | | HomePod mini | 第二代 | 2 | 客厅音箱 | | 无源音箱 | 组装 | 2 | 安装在书桌下, 听个响 | | 飞傲 K5 Pro | K5Pro ESS | 1 | 搭配耳机使用 | | 斐讯 T1 | 2G RAM+16G ROM | 1 | Android TV | #### Apple TV 4K ![20241229154732_ElyFwp0H.webp](https://cdn.dong4j.site/source/image/20241229154732_ElyFwp0H.webp) 配合 VidHub 播放 NAS 电影, 最新支持 Surge TV 版本, 偶尔看见 Youtube, 用的不是特别多. ![20241229154732_VsMwHGx7.webp](https://cdn.dong4j.site/source/image/20241229154732_VsMwHGx7.webp) #### HomePod & HomePod mini ![20241229154732_YSzmu4lT.webp](https://cdn.dong4j.site/source/image/20241229154732_YSzmu4lT.webp) 第一代的 HomePod, 听说第二代阉割了, 所以一直没有打算换二代, 目前配合桌下的无源音箱, 桌上桌下齐响, 环绕感还是挺强的. ![20241229154732_c89rF33q.webp](https://cdn.dong4j.site/source/image/20241229154732_c89rF33q.webp) ![20241229154732_DROEGZoZ.webp](https://cdn.dong4j.site/source/image/20241229154732_DROEGZoZ.webp) HomePod Mini 放在客厅沙发后面当 Apple TV 4K 的音源输出, 偶尔全屋 Airplay 播放, 感觉还是挺不错的. #### 无源音箱 ![20241229154732_IFntOP3X.webp](https://cdn.dong4j.site/source/image/20241229154732_IFntOP3X.webp) ![20241229154732_uVOqbKwb.webp](https://cdn.dong4j.site/source/image/20241229154732_uVOqbKwb.webp) 全套咸鱼购买, 自己组装 #### 飞傲 K5 Pro ![20241229154732_585f3N3G.webp](https://cdn.dong4j.site/source/image/20241229154732_585f3N3G.webp) 烧过一段时间 Hi-Fi, 后面出掉了随时设备, 就留下了这个和 WH-1000XM4 配合使用. #### 斐讯 T1 ![20241229154732_1FRBAwoW.webp](https://cdn.dong4j.site/source/image/20241229154732_1FRBAwoW.webp) 中间那个黑色黑子就是 T1, 刷了第三方 YYF 固件. 屏幕和 Mac mini M2 共用, 使用绿联的 KVM 切换视频输入. ![20241229154732_BrlibYQG.webp](https://cdn.dong4j.site/source/image/20241229154732_BrlibYQG.webp) 坑的地方是没有适配 Apple Silicon 的 macOS 系统, 因为我买了 2 个, Apple Intel CPU 就能很好的适配. 现在也在吃灰, 后面看能不能折腾下刷成 Armbian 系统玩玩. ### 云服务器 | 硬件 | 型号 | 数量 | 备注 | | :----: | :-----: | :--: | :-----------------------------: | | 阿里云 | 2 核 2G | 1 | 固定公网 ip, 博客服务, 异地组网 | 很多折腾的前提的要有一个固定公网 IP, 虽然现在也能通过 DDNS 来实现通过域名访问家中的服务, 但是经不起便宜啊, 99 元/年的价格, 怎么看怎么划算, 一口气买了 2 年. ### 其他硬件 | 硬件 | 型号 | 数量 | 备注 | | :-------------------: | :---------------: | :--: | :----------------------------: | | 树莓派 Zero 2W | 512M 内存 | 1 | 开发板 | | 树莓派 4B | 8G 内存 | 1 | 开发板 | | 树莓派 5B | 8G 内存 | 2 | 开发板 | | NanoPI NEO4 | 1G 内存/ 32G eMMC | 2 | 开发板 | | HK1 Box | 32GB 闪存/4G 内存 | 1 | Home Assistant | | LaCie d2 Professional | 8T | 1 | 冷备 | | UPS | APC Back-UPS 650 | 1 | 作为 DS923+ 和开发板的备用电源 | #### 树莓派 ![20241229154732_hGLITh7m.webp](https://cdn.dong4j.site/source/image/20241229154732_hGLITh7m.webp) ![20241229154732_V3BDovDE.webp](https://cdn.dong4j.site/source/image/20241229154732_V3BDovDE.webp) 感觉到了某个阶段, 编写软件已经无法满足好奇心了, 所以陆续买了几个树莓派玩一些新奇的东西, 通过软件控制硬件感觉非常酷, 目前主要玩儿的是工作相关的, 比如流媒体服务. #### NanoPI NEO4 ![20241229154732_LTBOWvRm.webp](https://cdn.dong4j.site/source/image/20241229154732_LTBOWvRm.webp) ![20241229154732_6zOsKEwl.webp](https://cdn.dong4j.site/source/image/20241229154732_6zOsKEwl.webp) RK3399 的发热量是真的大, 所以买了几个树莓派小风扇并加装了散热片. 开发板就买过 NanoPi 和树莓派, 其他家的还没尝试过, 但是现在还是更喜欢树莓派(资料多, 社区活跃). 应该能看到其中一台连接了摄像头, 因为资料的问题还没驱动起来. #### HK1 Box ![20241229154732_mrLkUdXc.webp](https://cdn.dong4j.site/source/image/20241229154732_mrLkUdXc.webp) ![20241229154732_49Ty7sy8.webp](https://cdn.dong4j.site/source/image/20241229154732_49Ty7sy8.webp) 咸鱼购买, 刷了 Armbian, 通过 Docker 安装 Home Assistant, 以前经常死机, 后来在 UART 接口焊接了排线并安装了散热风扇, 现在稳定运行. #### LaCie d2 Professional ![20241229154732_7INVu9M2.webp](https://cdn.dong4j.site/source/image/20241229154732_7INVu9M2.webp) 通过 雷雳 3 和 Mac mini 2018 连接, 通过 rsync 定时备份 DS923+ 的重要数据. #### UPS ![20241229154732_CkcTZ6A7.webp](https://cdn.dong4j.site/source/image/20241229154732_CkcTZ6A7.webp) 服役了几年, 主要是保护 DS923+, 最近更换过一次电池, 加装了电压显示模块. ## 总结 本篇文章罗列了个人拥有的硬件设备, 因为稳定性考虑, 更倾向是用独立硬件运行特定的系统, 取代 **All in One** 的方案, 网络方面也是独立分开, 互相冗余. 这个还是要看实际需求, 我需要重度依赖家中的 NAS 和其他服务, 花了大量去设计容错方案. 最后贴一下书房的总耗电量(设备应该启动了 80% 左右的情况下): ![20241229154732_6KeYfJoJ.webp](https://cdn.dong4j.site/source/image/20241229154732_6KeYfJoJ.webp) 更多内容敬请期待... **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## [HomeLab 先导篇:入门指南-开启你的个人云端实验室之旅](https://blog.dong4j.site/posts/15433dee.md) ![/images/cover/20241229154732_dJYxL1SG.webp](https://cdn.dong4j.site/source/image/20241229154732_dJYxL1SG.webp) 中年男人的三大爱好:充电头、NAS、软路由。这三大爱好不仅为我们的生活带来了便利,也成为了我们生活的一部分(🤡)。 作为一个软件开发者,我一直梦想着拥有自己的服务器,而 NAS 和软路由则是我通往这个梦想的桥梁。 自从购买了我的第一台 NAS 以来,便打开了一扇新世界的大门。NAS,即网络附加存储(Network Attached Storage),它不仅提供了一个安全的数据存储解决方案,还让我能够实现数据的备份和共享。随着时间的推移,我陆续购买了其他硬件产品,如软路由器、服务器等,逐步搭建起了属于我的 HomeLab。 今天,我想和大家分享一下我搭建 HomeLab 的过程,希望能够帮助到那些同样有志于搭建 HomeLab 的朋友。在接下来的博客文章中,我将详细介绍如何选购合适的 NAS 设备、软路由器以及服务器,并分享我在搭建过程中遇到的挑战和解决方案。 HomeLab 并非遥不可及,只要我们用心去探索和实践,就能开启属于自己的个人云端实验室之旅。让我们一起学习、交流和成长,共同打造一个属于我们的数字王国。 ## 前提说明 虽然关于 HomeLab 的文章已经很多了,但我还是想记录下自己搭建 HomeLab 的经历和遇到的问题,以及如何解决这些问题。主要会涉及到以下几个方面: 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## 什么是 HomeLab HomeLab,顾名思义,就是家庭实验室。它可以理解为家庭版的云服务器,用来搭建各种服务,比如个人网盘、媒体服务器等等。HomeLab 的硬件设备通常包括: 1. 服务器:可以是物理服务器或虚拟机,用于搭建各类服务。 2. 存储设备:如 NAS 和硬盘,用于存储数据。 3. 网络设备:如软路由和硬路由,用于管理网络。 4. 其他设备:如摄像头、传感器等,用于收集数据。 ## 为什么选择自建 HomeLab 对于我来说,搭建 HomeLab 是一种浪漫的“折腾”。我的目标是: 1. 搭建各种感兴趣的服务的实验室:作为一个喜欢尝试新技术的人来说,搭建各类服务非常有趣。我可以快速尝试和验证新的技术和方案,拥有一套自己的实验室可以让我更加自由地探索。 2. 保证数据安全:我对数据安全非常重视,所以我会把所有的数据都存储在自己的服务器上,而不是使用云存储服务。这样可以保证我的数据不会被第三方控制,我已经受够了七牛云的 OSS,域名变更导致我大量图片无法访问。 3. 更好的隐私保护:家人的照片、儿子的成长记录等私密数据本地私有化存储可以让我更加放心,不会担心数据泄露的问题。 ## HomeLab 的原则 **KISS 原则** : Keep It Simple, Stupid HomeLab 搭建是一件费时费力的过程, 为了避免占用大量个人时间, 我会尽量选择一些简单易用的方案, 避免复杂的配置和操作。 本着够用的原则, 不会选择较为复杂的软件架构. ### 硬件成本 对于重要的数据,我会直接选择成品 NAS,避免数据丢失。而对于其他不是特别重要的服务,我会使用 Docker 搭建,这样可以更加灵活地管理数据,也可以更好地控制成本。因此整个 HomeLab 的搭建围绕着 NAS 展开,而其他一些不重要的服务就直接去咸鱼捡垃圾。 ### 软件成本 对于 HomeLab 来说,最大的投入是硬件成本和时间。为了降低时间成本,本着 KISS 原则, 我会直接选择使用 Docker,而且不会搭建 K8S 这类比较折腾的服务,因为目前的服务数量和服务质量还不至于用上 K8S(也许在下次升级的时候会考虑)。 ## HomeLab 的硬件 ![20241229154732_lXrWkJfN.webp](https://cdn.dong4j.site/source/image/20241229154732_lXrWkJfN.webp) ![20241229154732_cxOH3POn.webp](https://cdn.dong4j.site/source/image/20241229154732_cxOH3POn.webp) 硬件介绍将在后续文章中详细介绍。 ## 网络架构 ![network.drawio.svg](https://cdn.dong4j.site/source/image/network.drawio.svg) 升级过程: 1. 电信宽带入户, 公网 IP, 全屋千兆; 2. 主要设备添加 2.5G 网口, 添加 2.5G 网口交换机; 3. 添加第二条宽带, 公网 IP, 全屋 2.5G 升级改造; 4. 添加万兆交换机, 主要设备升级万兆网口; ## 自托管服务 Dashboard 对于我来说, 就是一个展示我所有服务的面板, 不需要每个服务器的状态监控, 因此一个书签管理器就足够了, 目前比较满意的就是 Chrome 的插件: [Markoob](https://chromewebstore.google.com/detail/markoob-%E4%B9%A6%E7%AD%BE%E5%90%AF%E5%8A%A8%E5%99%A8/lnhnllkaacmnkffnjgcnokifakeckido?hl=zh-CN), 简约且不复杂. ![20241229154732_fEzFCGJj.webp](https://cdn.dong4j.site/source/image/20241229154732_fEzFCGJj.webp) 大部分服务使用 Docker 搭建,因此选择设备的刚性条件就是:是否支持虚拟化。 ## 数据存储与备份 ![20241229154732_9HV3mUqg.webp](https://cdn.dong4j.site/source/image/20241229154732_9HV3mUqg.webp) 主要围绕 NAS 搭建,包括家庭照片备份和服务器重要文件备份。 ## 总结 在搭建过程中遇到过非常多的问题,此系列文章的主要目的也是记录下这些问题和解决方案。因此不会详细介绍较为基础的知识,如果需要可以参考其他文章。 {% link 如何搭建家用 homelab: 先导篇,icyleaf,https://icyleaf.com/2022/02/how-to-homelab-part-0/, https://icyleaf.com/tutorials/how-to-homelab/part-0/cover.png %} {% link 如何搭建家用 homelab: 硬件和架构,icyleaf,https://icyleaf.com/2023/01/how-to-homelab-part-1-hardware-and-architecture/, https://images.unsplash.com/photo-1549319114-d67887c51aed?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2874&q=80 %} {% link 如何搭建家用 homelab: Openwrt 软路由,icyleaf,https://icyleaf.com/2023/04/how-to-homelab-part-2-openwrt-soft-router/, https://images.unsplash.com/photo-1521542464131-cb30f7398bc6?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2673&q=80 %} {% link 如何搭建家用 homelab: 数据存储,icyleaf,https://icyleaf.com/2023/07/how-to-homelab-part-3-storages/, https://images.unsplash.com/photo-1686705562930-4f3e46f620d8?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=3000&q=80 %} **相关文章:** 1. [[homelab-guide|先导篇]]:我的 HomeLab 概要; 2. [[homelab-hardware|硬件篇]]:介绍我所拥有的硬件设备; 3. [[homelab-network|网络篇]]:包括网络环境、异地组网与网络安全; 4. [[homelab-service|服务篇]]:使用 Docker 搭建的各类服务; 5. [[homelab-data|数据篇]]:包括数据存储方案、备份方案和数据恢复方案; 6. [[homelab-data-sync|HomeLab数据同步:构建高效的数据同步网络]] 7. [[homelab-data-backup|HomeLab数据备份:打造坚实的数据安全防线]] 8. [[homelab-upgrade-to-10g|HomeLab 网络续集:升级 10G 网络-再战 10 年]] 9. [[homelab-guide|NAT 内网穿透详解:揭秘网络连接背后的奥秘]] ## [私有云时代,NAS如何帮助企业实现高效协作?](https://blog.dong4j.site/posts/f5f7afe2.md) ![/images/cover/20241229154732_KKA3Ghw8.webp](https://cdn.dong4j.site/source/image/20241229154732_KKA3Ghw8.webp) 目前公司的数据存储模式比较落后, 主要问题在于**数据安全性差**;整体数据量大以及原有大量陈旧的数据难以存储;存在多操作系统平台,设备繁杂导致存放的数据难以共享协作和管理,造成效率低下;员工的离职造成资料丢失。我们需要一个满足需求又安全的方案来解决这样的普遍难题。 NAS 存储共享解决方案包含:**群晖 NAS 服务器**、Backup 插件。 **应用场景:** 企业数据集中存储/共享/备份,企业私有云文件管理器搭建。 ## 方案背景 随着企业信息化的发展,企业信息化带来的数据爆炸式增长向企业的数据管理方式提出了挑战,**一方面要应对数据容量的不断扩充,另一方面需要确保所有有效数据的高安全性和可管理性**。目前来看,企业的数据各自存放、分散管理,关联性不强,无法很好的针对不同的业务数据进行统一、有效的管理。 企业的数据存储模式比较落后,成本较高且效率低下,主要问题在于数据安全性差;整体数据量大以及原有大量陈旧的数据难以存储;存在多操作系统平台,设备繁杂导致存放的数据难以共享协作和管理,造成效率低下;员工的离职造成资料丢失。 从对企业单位数据存储的分析中可以看出,要使整个企业内部的数据得到统一管理和安全应用,就必须有一个安全、性价比好、应用方便、管理简单的物理介质来存储和管理企业内部的数据资料。把所有零散的数据和业务系统中的重要数据进行管理,避免在异常情况下数据的丢失。 ## 方案设计概述 NAS 网络存储设备是一款特殊设计的文件存储服务器,它能够将网络中的数据资料合理有效、安全地存放和管理起来。 对于企业集中数据中心解决方案而言,主要的建设目标有以下几个: - 实现网络办公环境下的集中资料共享、交互,改变以往单一的个人共享行为。 - 完成公司、个人、客户资料的安全存放,增强数据的可靠性,实现长期保存。 - 带动公司内部的数据流程,实现数据的知识共享和访问权限控制。 ## NAS 是什么 NAS(Network Attached Storage),直译是网络附加存储,简单来说是连接在网络上,具有资料存储功能的设备,也叫做网络存储服务器。实际上,NAS 的功能不单单局限于存储,群晖 NAS 已经成功打造了**丰富的软件生态**。 不仅是个人,工作室、中小企业、500 强企业都在使用 NAS。随着企业的发展,数据量不断增长,传统文件服务器功能有限,软件需要订阅付费,而使用公有云,费用随人员量、数据量的增长而增加。企业可以使用群晖在以下场景中: 1. 使用群晖 NAS 搭建**企业文件服务器**,可以集中存储各部门文件,权限设置简单,员工可在 Windows、macOS 和 Linux 平台之间实现文件无缝共享。 2. 除了可以用作文件服务器,群晖 NAS 还为业务数据提供一体化备份平台,整机保护员工电脑、物理服务器、虚拟机、公有云数据,通过直观的图形化界面,在一个平台上就可以管理所有备份任务,有效保护企业关键业务数据,避免误删、泄露、勒索病毒等意外导致的利益损失。 ## 主要功能 ### 1. 文件管理 Synology NAS 整合数据存储、存取和共享等一体化文件管理应用,通过 DSM 直观易用的管理界面,无论家庭用户,或是工作繁重的 IT 管理人员,都能轻松完成所有文件同步、安全设置,全权掌控数据。 #### 1.1 存取与共享 文件存取和共享,是 Synology NAS 的核心应用。DSM 支持多种主流通讯协议和多设备存取功能,为您打造顺畅的实时数据应用体验,同时为您的数据提供高安全层级保护。 **支持主流通信协议和操作系统:** 支持主流浏览器、移动平台和操作系统,可灵活融入家庭或企业办公环境,数据存取畅通无阻。 ![20241229154732_Wp8tXDs8.webp](https://cdn.dong4j.site/source/image/20241229154732_Wp8tXDs8.webp) **文件和文件夹共享:** 通过分享链接或二维码,可快速分享文件和文件夹,并提供多重安全保护和存取权限选项,为您的数据安全保驾护航。 ![20241229154732_JpGaf9hq.webp](https://cdn.dong4j.site/source/image/20241229154732_JpGaf9hq.webp) #### 1.2 同步与管理 ![20241229154732_rWYt4kYU.webp](https://cdn.dong4j.site/source/image/20241229154732_rWYt4kYU.webp) ![20241229154732_HPuSVats.webp](https://cdn.dong4j.site/source/image/20241229154732_HPuSVats.webp) ### 2. 用户管理 ![20241229154732_Koc3L4MW.webp](https://cdn.dong4j.site/source/image/20241229154732_Koc3L4MW.webp) ![20241229154732_nATaGC12.webp](https://cdn.dong4j.site/source/image/20241229154732_nATaGC12.webp) ### 3. 办公效率 #### 3.1 Office 团队成员可以随时通过 Web 界面同时编辑同一份文档、电子表格或幻灯片,并支持 Microsoft Office 文件导入编辑与导出分享,通过流畅、实时的线上协作,解决远程协作的沟通难题,从而大幅提升办公效率。 ##### 3.1.1 文档 ![20241229154732_Y2PzUj4U.webp](https://cdn.dong4j.site/source/image/20241229154732_Y2PzUj4U.webp) ##### 3.1.2 电子表格 ![20241229154732_fft2Uxbs.webp](https://cdn.dong4j.site/source/image/20241229154732_fft2Uxbs.webp) ##### 3.1.3 幻灯片 ![20241229154732_gOu0x64d.webp](https://cdn.dong4j.site/source/image/20241229154732_gOu0x64d.webp) #### 3.2 协同办公 ![20241229154732_3QIzKP9l.webp](https://cdn.dong4j.site/source/image/20241229154732_3QIzKP9l.webp) ### 4. 数据备份 ![20241229154732_5s7mHwWs.webp](https://cdn.dong4j.site/source/image/20241229154732_5s7mHwWs.webp) ![20241229154732_p9936ya2.webp](https://cdn.dong4j.site/source/image/20241229154732_p9936ya2.webp) ### 5. 安全性 ![20241229154732_7TOwpgwk.webp](https://cdn.dong4j.site/source/image/20241229154732_7TOwpgwk.webp) ![20241229154732_ntLmuG7o.webp](https://cdn.dong4j.site/source/image/20241229154732_ntLmuG7o.webp) ### 6. 系统管理 ![20241229154732_trkcLymW.webp](https://cdn.dong4j.site/source/image/20241229154732_trkcLymW.webp) ![20241229154732_DMBlNWnx.webp](https://cdn.dong4j.site/source/image/20241229154732_DMBlNWnx.webp) ## 使用场景 ### 1. 随时随地存取数据 ![20241229154732_DvOAAfzU.webp](https://cdn.dong4j.site/source/image/20241229154732_DvOAAfzU.webp) ![20241229154732_ORH181Ul.webp](https://cdn.dong4j.site/source/image/20241229154732_ORH181Ul.webp) ![20241229154732_5k4uQ7BU.webp](https://cdn.dong4j.site/source/image/20241229154732_5k4uQ7BU.webp) ### 2. 文件服务器 #### 2.1 跨平台即时共享 支持 SMB / NFS / FTP 等多种传输协议,局域网内共享。无需自建 FTP,即可跨平台、多终端随时存取;总部和分支机构间也能便捷下发和上收文件。 ![20241229154732_FfAg6Fj8.webp](https://cdn.dong4j.site/source/image/20241229154732_FfAg6Fj8.webp) #### 2.2 图形化的权限安全管理 图形化界面,操作直观简便,无需输入指令或导入表格。支持委派专人管理权限,并且可设置密码、有效期实现安全分享,通过导出权限报告实现权限审核。 ![20241229154732_GtPer2AH.webp](https://cdn.dong4j.site/source/image/20241229154732_GtPer2AH.webp) #### 2.3 不受地域限制的在线办公 通过网页或移动端 App,不受地域限制,即可实现移动办公,还可设置文件离线访问。并且支持在线 Office,多人协作提升团队办公效率。 **群晖跨电脑、跨地域同步及共享方案打通数据孤岛,实现自动同步和即时共享,建立企业文件中心。同时支持多版本备份,避免因误删、勒索病毒等导致文件丢失。** ![20241229154732_7wYwXGdO.webp](https://cdn.dong4j.site/source/image/20241229154732_7wYwXGdO.webp) ![20241229154732_VOIsJvSX.webp](https://cdn.dong4j.site/source/image/20241229154732_VOIsJvSX.webp) ![20241229154732_MzQHfGV2.webp](https://cdn.dong4j.site/source/image/20241229154732_MzQHfGV2.webp) ![20241229154732_KFwZCGSl.webp](https://cdn.dong4j.site/source/image/20241229154732_KFwZCGSl.webp) #### 2.4 文件管理与同步 ![20241229154732_0l8VaHxH.webp](https://cdn.dong4j.site/source/image/20241229154732_0l8VaHxH.webp) ![20241229154732_OpERLBVc.webp](https://cdn.dong4j.site/source/image/20241229154732_OpERLBVc.webp) ![20241229154732_8QQWajmE.webp](https://cdn.dong4j.site/source/image/20241229154732_8QQWajmE.webp) ![20241229154732_rvZ94CMa.webp](https://cdn.dong4j.site/source/image/20241229154732_rvZ94CMa.webp) ![20241229154732_Hd4dsvyx.webp](https://cdn.dong4j.site/source/image/20241229154732_Hd4dsvyx.webp) ![20241229154732_LiFfAnvn.webp](https://cdn.dong4j.site/source/image/20241229154732_LiFfAnvn.webp) #### 2.5 PB 级存储容量的在线扩容 添加扩展柜进行在线扩容,可支持多达 180 个硬盘,提供 PB 级净存储容量,轻松满足影视后期制作、大规模监控部署等大容量存储需求。 #### 2.6 容灾备份 为文件夹和 iSCSI LUN 建立快照保护,可快速恢复被病毒锁定的文件,防止企业数据丢失。同时可为服务器、虚拟机和 PC 实现免许可证的整机备份方案。 ![20241229154732_nUf2ln5g.webp](https://cdn.dong4j.site/source/image/20241229154732_nUf2ln5g.webp) ### 3. 文件同步与共享 ![20241229154732_yKeXtvgW.webp](https://cdn.dong4j.site/source/image/20241229154732_yKeXtvgW.webp) ### 4. 私有云企业网盘 ![20241229154732_kSNQKTiq.webp](https://cdn.dong4j.site/source/image/20241229154732_kSNQKTiq.webp) ![20241229154732_XrVyLXft.webp](https://cdn.dong4j.site/source/image/20241229154732_XrVyLXft.webp) ## 系统展示 ![20241229154732_XZqADQCq.webp](https://cdn.dong4j.site/source/image/20241229154732_XZqADQCq.webp) ![20241229154732_rPqOZbFH.webp](https://cdn.dong4j.site/source/image/20241229154732_rPqOZbFH.webp) ![20241229154732_QwWnhMmq.webp](https://cdn.dong4j.site/source/image/20241229154732_QwWnhMmq.webp) ![20241229154732_cooNFI9g.webp](https://cdn.dong4j.site/source/image/20241229154732_cooNFI9g.webp) ![20241229154732_CIu37ozq.webp](https://cdn.dong4j.site/source/image/20241229154732_CIu37ozq.webp) ![20241229154732_ezwtG796.webp](https://cdn.dong4j.site/source/image/20241229154732_ezwtG796.webp) ![20241229154732_hGMqv6Er.webp](https://cdn.dong4j.site/source/image/20241229154732_hGMqv6Er.webp) ![20241229154732_JaIdkVM5.webp](https://cdn.dong4j.site/source/image/20241229154732_JaIdkVM5.webp) ## [掌握Hexo加密,构建更安全的个人博客](https://blog.dong4j.site/posts/4633c2df.md) ![/images/cover/20241229132734_VH9upmpx.webp](https://cdn.dong4j.site/source/image/20241229132734_VH9upmpx.webp) ## 简介 加密测试 ## [KVM 虚拟机磁盘容量扩展指南:从文件到文件系统的全面解析](https://blog.dong4j.site/posts/107e2c9.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 增加 KVM 虚拟机磁盘容量 在 KVM 中扩展虚拟机磁盘容量分为两步:**扩展虚拟磁盘文件** 和 **扩展虚拟机内的分区和文件系统**。 --- ### 第一步:扩展虚拟磁盘文件 #### 1. 确认虚拟磁盘文件路径 使用以下命令查找虚拟机磁盘文件的位置: ```bash virsh domblklist <虚拟机名称> ``` 示例输出: ``` Target Source ------------------------------------------------ vda /var/lib/libvirt/images/vm-disk.qcow2 ``` 记下磁盘文件路径(例如 /var/lib/libvirt/images/vm-disk.qcow2)。 #### 2. 扩展磁盘文件 假设需要将磁盘扩展为 50 GB,根据磁盘格式选择以下命令: QCOW2 格式磁盘扩展 ```bash qemu-img resize /var/lib/libvirt/images/vm-disk.qcow2 50G ``` RAW 格式磁盘扩展 ```bash qemu-img resize /var/lib/libvirt/images/vm-disk.raw 50G ``` 验证磁盘扩展结果 ```bash qemu-img info /var/lib/libvirt/images/vm-disk.qcow2 ``` ### 第二步:扩展虚拟机内的分区和文件系统 #### 1. 启动虚拟机并登录 启动虚拟机: ```bash virsh start <虚拟机名称> ``` 通过控制台或 SSH 登录虚拟机: ```bash virsh console <虚拟机名称> ``` #### 2. 检查新增磁盘空间 登录虚拟机后,运行以下命令查看磁盘大小是否更新: ```bash lsblk ``` 示例输出: ```bash NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT vda 254:0 0 50G 0 disk └─vda1 254:1 0 20G 0 part / ``` - vda 是整个磁盘,vda1 是当前分区。 #### 3. 调整分区 比如我现在的分区: ```bash ➜ ~ fdisk -l Disk /dev/vda: 100 GiB, 107374182400 bytes, 209715200 sectors Units: sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disklabel type: dos Disk identifier: 0x2d23b3e0 Device Boot Start End Sectors Size Id Type /dev/vda1 2048 39942143 39940096 19G 83 Linux /dev/vda2 39944190 41940991 1996802 975M 5 Extended /dev/vda5 39944192 41940991 1996800 975M 82 Linux swap / Solaris ``` 使用 fdisk 扩展分区 1. 启动 fdisk: ```bash fdisk /dev/vda ``` 2. 删除原分区(不会丢失数据): - 输入 d 删除分区,依次删除 /dev/vda2 和 /dev/vda1。 3. 重新创建新分区: - 输入 n,选择分区类型(默认 primary)。 - 分区号(如 1)。 - 起始扇区和终止扇区(默认使用整个磁盘空间)。 4. 保存分区表并退出: - 输入 w。 5. 重新加载分区表: ```bash partprobe /dev/vda ``` #### 4. 扩展文件系统 根据分区的文件系统类型,运行以下命令扩展文件系统: EXT4 文件系统 ```bash resize2fs /dev/vda1 ``` 输出: ```bash resize2fs 1.47.0 (5-Feb-2023) Filesystem at /dev/vda1 is mounted on /; on-line resizing required old_desc_blocks = 3, new_desc_blocks = 13 The filesystem on /dev/vda1 is now 26214144 (4k) blocks long. ``` #### 5. 验证扩展结果 运行以下命令检查扩展后的文件系统大小: ```bash df -h ``` 输出: ```bash 文件系统 大小 已用 可用 已用% 挂载点 udev 1.9G 0 1.9G 0% /dev tmpfs 392M 1.8M 390M 1% /run /dev/vda1 99G 16G 79G 17% / tmpfs 2.0G 0 2.0G 0% /dev/shm tmpfs 5.0M 0 5.0M 0% /run/lock overlay 99G 16G 79G 17% /var/lib/docker/overlay2... ... tmpfs 392M 0 392M 0% /run/user/0 ``` ### 问题 因为原来的 SWAP 分区被删除了, 需要调整 fstab 文件。 ```bash # # / was on /dev/vda1 during installation UUID=1a373f29-fa77-43dd-8d01-e8d848b35b11 / ext4 errors=remount-ro 0 1 # swap was on /dev/vda5 during installation #UUID=efbc7849-ed9c-44ef-b34a-c5b8d7e8bab0 none swap sw 0 0 /dev/sr0 /media/cdrom0 udf,iso9660 user,noauto 0 0 ``` 将 `swap` 的挂在配置注释掉, 因为我也不打算使用 swap. ## 注意事项 1. 备份数据:扩展磁盘前,请确保虚拟机数据已备份。 2. LVM 情况: 如果虚拟机使用 LVM,需额外扩展逻辑卷和物理卷: - 扩展物理卷: ```bash pvresize /dev/vda1 ``` - 扩展逻辑卷: ```bash lvextend -l +100%FREE /dev/mapper/volume-group-logical-volume ``` - 扩展文件系统: ```bash resize2fs /dev/mapper/volume-group-logical-volume ``` 3. 确认文件系统类型:不同文件系统(如 EXT4、XFS)使用不同的扩展命令。 ## KVM 常用命令 ## 总结 扩展 KVM 虚拟机磁盘容量涉及两个关键步骤: 1. 使用 qemu-img 扩展磁盘文件。 2. 登录虚拟机,调整分区和文件系统。 ## [VuePress 搭建指南:从基础到部署](https://blog.dong4j.site/posts/71182928.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 使用 vuepress 搭建自己的博客 ## Vuepress 介绍 官网: [https://vuepress.vuejs.org/](https://vuepress.vuejs.org/) 类似 hexo 一个极简的静态网站生成器, 用来写技术文档不能在爽. 当然搭建成博客也不成问题. ## Vuepress 特点 - 响应式, 也可以自定义主题与 hexo 类似 - 内置 markdown (还增加了一些扩展), 并且可以在其使用 Vue 组件 - Google Analytics 集成 - PWA 自动生成 Service Worker ## 快速上手 ### 安装 初始化项目 ```bash yarn init -y # 或者 npm init -y ``` 安装 vuepress ```bash yarn add -D vuepress # 或者 npm install -D vuepress ``` 全局安装 vuepress ```bash yarn global add vuepress # 或者 npm install -g vuepress ``` 新建一个 docs 文件夹 ```bash mkdir docs ``` 设置下 package.json ```bash { "scripts": { "docs:dev": "vuepress dev docs", "docs:build": "vuepress build docs" } } ``` ### 写作 ```bash yarn docs:dev # 或者: npm run docs:dev ``` 也就是运行开发环境, 直接去 docs 文件下书写文章就可以, 打开 `http://localhost:8080/` 可以预览 ### 构建 build 生成静态的 HTML 文件, 默认会在 `.vuepress/dist` 文件夹下 ```bash yarn docs:build # 或者: npm run docs:build ``` ## 基本配置 在 `.vuepress` 目录下新建一个 `config.js`, 他导出一个对象 一些配置可以参考 [官方文档](https://vuepress.vuejs.org/config/#base) , 这里我配置常用及必须配置的 ### 网站信息 ```bash module.exports = { title: '游魂的文档', description: 'Document library', head: [ ['link', { rel: 'icon', href: `/favicon.ico` }], ], } ``` ### 导航栏配置 ```javascript module.exports = { themeConfig: { nav: [ { text: "主页", link: "/" }, { text: "前端规范", link: "/frontEnd/" }, { text: "开发环境", link: "/development/" }, { text: "学习文档", link: "/notes/" }, { text: "游魂博客", link: "https://www.iyouhun.com" }, // 下拉列表的配置 { text: "Languages", items: [ { text: "Chinese", link: "/language/chinese" }, { text: "English", link: "/language/English" }, ], }, ], }, }; ``` ### 侧边栏配置 可以省略 `.md` 扩展名, 同时以 `/` 结尾的路径将会被视为 `*/README.md` ```javascript module.exports = { themeConfig: { sidebar: { "/frontEnd/": genSidebarConfig("前端开发规范"), }, }, }; ``` 上面封装的 `genSidebarConfig` 函数 ```javascript function genSidebarConfig(title) { return [ { title, collapsable: false, children: [ "", "html-standard", "css-standard", "js-standard", "git-standard", ], }, ]; } ``` 支持侧边栏分组 (可以用来做博客文章分类) collapsable 是当前分组是否展开 ```javascript module.exports = { themeConfig: { sidebar: { "/note": [ { title: "前端", collapsable: true, children: [ "/notes/frontEnd/VueJS组件编码规范", "/notes/frontEnd/vue-cli脚手架快速搭建项目", "/notes/frontEnd/深入理解vue中的slot与slot-scope", "/notes/frontEnd/webpack入门", "/notes/frontEnd/PWA介绍及快速上手搭建一个PWA应用", ], }, { title: "后端", collapsable: true, children: [ "notes/backEnd/nginx入门", "notes/backEnd/CentOS如何挂载磁盘", ], }, ], }, }, }; ``` ## 默认主题修改 ### 主题色修改 在 `.vuepress` 目录下的创建一个 `override.styl` 文件 ```javascript $accentColor = #3eaf7c // 主题色 $textColor = #2c3e50 // 文字颜色 $borderColor = #eaecef // 边框颜色 $codeBgColor = #282c34 // 代码背景颜色 ``` ### 自定义页面类 有时需要在不同的页面应用不同的 css, 可以先在该页面中声明 ```javascript --- pageClass: custom-page-class --- ``` 然后在 `override.styl` 中书写 ```javascript .theme-container.custom-page-class { /* 特定页面的 CSS */ } ``` ## PWA 设置 设置 serviceWorker 为 true, 然后提供 Manifest 和 icons, 可以参考我之前的 [《PWA 介绍及快速上手搭建一个 PWA 应用》](https://www.shen.ee/article/27276.html) ```javascript module.exports = { head: [ ["link", { rel: "icon", href: `/favicon.ico` }], // 增加 manifest.json ["link", { rel: "manifest", href: "/manifest.json" }], ], serviceWorker: true, }; ``` ## 部署上线 ### 设置基础路径 在 `config.js` 设置 base 例如: 你想要部署在 [https://foo.github.io](https://foo.github.io) 那么设置 base 为 `/` ,base 默认就为 `/`, 所以可以不用设置 想要部署在 [https://foo.github.io/bar/,](https://foo.github.io/bar/,) 那么 `base` 应该被设置成 `"/bar/"` ```javascript module.exports = { base: "/documents/", }; ``` `base` 将会自动地作为前缀插入到所有以 `/` 开始的其他选项的链接中, 所以你只需要指定一次. ### 构建与自动部署 用 [gitHub](https://github.com) 的 pages 或者 [coding](https://coding.net/r/O5YOFA) 的 pages 都可以, 也可以搭建在自己的服务器上. 将 `dist` 文件夹中的内容提交到 git 上或者上传到服务器就好 ```javascript yarn docs:build # 或者: npm run docs:build ``` > 另外可以弄一个脚本, 设置持续集成, 在每次 push 代码时自动运行脚本 deploy.sh ```bash #!/usr/bin/env sh # 确保脚本抛出遇到的错误 set -e # 生成静态文件 npm run docs:build # 进入生成的文件夹 cd docs/.vuepress/dist # 如果是发布到自定义域名 # echo 'www.example.com' > CNAME git init git add -A git commit -m 'deploy' # 如果发布到 https://.github.io # git push -f git@github.com:/.github.io.git master # 如果发布到 https://.github.io/ git push -f git@github.com:/.git master:gh-pages cd - ``` ## 注意事项 (坑) - 把你想引用的资源都放在 `.vuepress` 目录下的 `public` 文件夹 - 给 git 仓库绑定了独立域名后, 记得修改 `base` 路径 - 设置侧边栏分组后默认会自动生成 上 / 下一篇链接 - 设置了自动生成侧边栏会把侧边栏分组覆盖掉 - 设置 PWA 记得开启 SSL ## [vuepress-theme-reco](https://vuepress-theme-reco.recoluan.com/) 该主题几乎继承 `VuePress` 默认主题的一切功能, 所以本文档只负责介绍该主题扩展的功能, 如果您想要了解默认主题的一些功能, 请移步 [官方文档](https://v1.vuepress.vuejs.org/zh/theme/default-theme-config.html). ### Branch | branch | vuepress | vuepress-theme-reco | | -------- | :------: | :-----------------: | | demo/0.x | 0.x | 0.x | | demo/1.x | 1.x | 1.x | ### 安装 ```bash npm install vuepress-theme-reco -dev--save # or yarn add vuepress-theme-reco ``` ### 使用 ```bash // 修改 /docs/.vuepress/config.js module.exports = { theme: 'reco' } ``` ### 分类和标签 #### 添加博客配置 ```javascript // change /docs/.vuepress/config.js module.exports = { theme: "reco", themeConfig: { // 博客设置 blogConfig: { category: { location: 2, // 在导航栏菜单中所占的位置, 默认 2 text: "Category", // 默认文案 “分类” }, tag: { location: 3, // 在导航栏菜单中所占的位置, 默认 3 text: "Tag", // 默认文案 “标签” }, }, }, }; ``` #### 写文章时添加分类和标签 ```javascript --- title: 【vue】跨域解决方案之 proxyTable date: 2019-12-28 categories: - frontEnd tags: - vue --- ``` > 请注意, `categories` 和 `categories` 要以数组的方式填写. 某些页面的侧边栏为 `false` 呢?因为您启用了分类, 这与自定义侧边栏功能有点冲突, 所以您全局打开自动侧边栏功能, 然后在不需要侧标记的地方关闭它. ### 添加时间轴 #### 添加导航按钮 ```javascript // change /docs/.vuepress/config.js module.exports = { theme: "reco", themeConfig: { nav: [{ text: "TimeLine", link: "/timeLine/", icon: "reco-date" }], }, }; ``` #### 添加所需的文件 **/docs/timeLine/README.md** ```javascript --- isTimeLine: true sidebar: false isComment: false --- ## Time Line ``` #### 写文章时添加日期 ```javascript --- title: 【vue】跨域解决方案之 proxyTable date: 2019-12-28 tags: - vue - webpack --- ``` ### 评论 (valine) 带有内置了 valine 评论功能, 如果要打开此功能, 只需配置你的 `config.js` ```javascript // 更改 /docs/.vuepress/config.js module.exports = { theme: "reco", themeConfig: { // valine valineConfig: { appId: "...", // your appId appKey: "...", // your appKey }, }, }; ``` **参数** | 参数 | 功能 | 默认值 | 是否必填 | | :---------: | ----------------------------------------------------------------------------------------------------- | :--------: | :------: | | appId | 从 LeanCloud 的应用中得到的 appId | 无 | yes | | appKey | 从 LeanCloud 的应用中得到的 APP Key | 无 | yes | | placeholder | 评论框占位提示符 | just go go | no | | notify | 评论回复邮件提醒, 请参考 [配置](https://github.com/xCss/Valine/wiki/Valine- 评论系统中的邮件提醒设置) | false | no | | verify | 验证码服务 | false | no | | avatar | Gravatar 头像展示方式, 更多信息请查看 [头像配置](https://valine.js.org/avatar.html) | retro | no | | visitor | 文章访问量统计 | true | no | | recordIP | recordIP | false | no | 如果 valine 的获取评论的接口报 `404` 错误的话, 不用担心, 这是因为你还没有添加评论, 只要存在 1 条评论, 就不会报错了, 这是 `leanCloud` 的请求处理操作而已. ### 加密功能 #### 项目加密 如果项目具有私密性, 不希望被公开, 只有填入密钥登录后(关闭标签后登录失效), 才能进入内容页面. 以数组的格式设置 `keys`, 可以设置多个密码, 数组的值必须是字符串. ```javascript // 更改 /docs/.vuepress/config.js module.exports = { theme: "reco", themeConfig: { // 密钥 keyPage: { keys: ["123456"], color: "#42b983", // 登录页动画球的颜色 lineColor: "#42b983", // 登录页动画线的颜色 }, }, }; ``` #### 文章加密 如果项目是公开的, 而某些文章可能需要加密, 需要在 `frontmatter` 以数组的格式设置 `keys`, 可以设置多个密码, 数组的值必须是字符串. ```javascript --- title: vuepress-theme-reco date: 2019-04-09 author: reco_luan keys: - '123456' --- ``` ### Config.js 配置 #### 移动端优化 在移动端, 搜索框在获得焦点时会放大, 并且在失去焦点后可以左右滚动, 这可以通过设置元来优化. ```javascript module.exports = { head: [ [ "meta", { name: "viewport", content: "width=device-width,initial-scale=1,user-scalable=no", }, ], ], }; ``` #### 图标 您可以在导航菜单中添加图标, 如下所示: ```javascript { text: 'Tags', link: '/tags/', icon: 'reco-tag' } ``` #### 备案信息和项目开始时间 ```javascript module.exports = { themeConfig: { // 备案号 record: "京ICP备17067634号-1", // 项目开始时间, 只填写年份 startYear: "2017", }, }; ``` #### 设置作者姓名 - 设置全局作者姓名 ```javascript module.exports = { themeConfig: { // author author: "reco_luan", }, }; ``` - 为单篇文章设置作者姓名 ```bash --- title: 你还没真的努力过, 就轻易输给了懒惰 date: 2019-04-23 categories: article author: 渡渡 --- ``` #### 华为文案 如果不希望显示 “华为” 文案, 可以这样关闭. ```javascript module.exports = { themeConfig: { huawei: false, }, }; ``` ### 首页配置 主题的主页的默认风格偏文档, 并不像一个博客, 所以从 `vuepress-theme-reco@1.0.0-alpha.25` 开始, 增加博客风格首页布局. #### 默认首页配置 ##### heroImage - 如果您的 heroImage 具有您的网站标题, 则可能需要设置值 `isShowTitleInHome` `false` 以使标题不显示. ```bash # this is your homepage --- home: true heroImage: /hero.png isShowTitleInHome: false --- ``` - 如果你想改变 heroImage 的风格, 你可以设置值 `heroImageStyle` 来实现你想要的效果 ```bash # 这是你的主页 --- home: true heroImage: /hero.png heroImageStyle: { maxHeight: '200px', display: block, margin: '6rem auto 1.5rem', borderRadius: '50%', boxShadow: '0 5px 18px rgba(0,0,0,0.2)' } --- ``` ##### 博客风格首页设置 - 指定 `type: 'blog'` ```js // change /docs/.vuepress/config.js module.exports = { theme: "reco", themeConfig: { type: "blog", }, }; ``` - 设置首页的背景图片和头像 ```bash # 这是你的主页 --- home: true bgImage: '/bg.png' faceImage: '/head.png' --- ``` ### 添加摘要 在 markdown 代码中, 您将看到注释, 注释前面的代码将显示在列表页面上的文章摘要中. ### vuepress-theme-reco-cli ```bash npm install vuepress-theme-reco-cli reco-cli init my-blog cd my-blog npm install npm run dev ``` if yarn, you can do this way: ```bash yarn add vuepress-theme-reco-cli reco-cli init my-blog cd my-blog yarn install yarn dev ``` ## Github Pages github pages 分为 2 中 - 个人站点 - 项目站点 2 种站点的 url 有点区别, 在使用 vuepress 部署时遇到点坑, 这里记录一下 ### 个人站点 个人站点在创建时, 必须使用 `username.github.io` 作为项目名, 这样不需要其他设置, 只需要 push html 代码即可直接部署 使用 vuepress 时, 不再需要设置 `base` ```javascript title: "Black House", description: '代码千万行, 注释第一行, 编码不规范, 同事两行泪.', dest: './docs/dist', // 如果使用个人站点, 不需要配置 base // base: '/vue-blog/', head: [ ['link', { rel: 'icon', href: '/favicon.ico' }], // 在移动端, 搜索框在获得焦点时会放大, 并且在失去焦点后可以左右滚动, 这可以通过设置元来优化 ['meta', { name: 'viewport', content: 'width=device-width,initial-scale=1,user-scalable=no' }] ], ``` ### 项目站点 项目站点 push 需要配置 github pages, url 为 `https://username.github.io/projectname/` 这时必须配置 `base`, 不然将导致页面样式错误 ## [开源项目汇总:Spring Cloud生态圈](https://blog.dong4j.site/posts/44d3ec9b.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## [zuihou-admin-cloud](https://github.com/zuihou/zuihou-admin-cloud) 基于  `SpringCloud(Hoxton.SR1)` + `SpringBoot(2.2.2.RELEASE)`  的 SaaS 型微服务脚手架,具备用户管理、资源权限管理、网关统一鉴权、Xss 防跨站攻击、自动代码生成、多存储系统、分布式事务、分布式定时任务等多个模块,支持多业务系统并行开发, 支持多服务并行开发,可以作为后端服务的开发脚手架。代码简洁,架构清晰,非常适合学习使用。核心技术采用 Nacos、Fegin、Ribbon、Zuul、Hystrix、JWT Token、Mybatis、SpringBoot、Seata、Nacos、Sentinel、 RabbitMQ、FastDFS 等主要框架和中间件。 希望能努力打造一套从  `SaaS基础框架` - `分布式微服务架构` - `持续集成` - `系统监测`  的解决方案。`本项目旨在实现基础能力,不涉及具体业务。` 部署方面,可以采用以下 4 种方式,并会陆续公布 jenkins 集合以下 3 种部署方式的脚本和配置文件: - IDEA 启动 - jar 部署 - docker 部署 - k8s 部署 ### 租户后台 和 开发&运营后台 2 者之间的关系是什么? A 公司 使用这套 SaaS 脚手架二次开发了一个 OA 或者商城, B 和 C 公司想使用 A 公司开发的这套系统,但土豪公司 B 有钱想要个性化功能,C 公司是个穷逼,不愿意多花钱 于是,A 公司就在 zuihou-admin-ui(开发&运营后台) 上新建了 租户 B 和租户 C, 并各自新建了账号 b1 和账号 c1, 分别给 B 公司和 C 公司 试用, B 公司和 C 公司分别拿着账号, 在 zuihou-ui(租户后台) 上试用, 试用很满意,但土豪 B 公司先要定制功能, 就跟 A 公司签了一个 500W 的定制大单,并要求独立部署在他们自己的服务器 穷逼 C 公司没钱, 就花了 20W 使用 A 公司部署的云环境, 服务器和数据等都存在 A 公司的云服务器上。 ### zuihou-admin-boot 演示地址 | 项目 | 演示地址 | 管理员账号 | 普通账号 | | --------------- | ------------------------------------------------------------------------------------------------ | ---------------- | ---------- | | 租户后台 | [http://42.202.130.216:10000/zuihou-ui](http://42.202.130.216:10000/zuihou-ui) | zuihou/zuihou | test/zuiou | | 开发 & 运营后台 | [http://42.202.130.216:10000/zuihou-admin-ui](http://42.202.130.216:10000/zuihou-admin-ui) | demoAdmin/zuihou | 无 | | swagger 文档 | [http://42.202.130.216:10000/api/gate/doc.html](http://42.202.130.216:10000/api/gate/doc.html) | 无 | 无 | | 定时任务 | [http://42.202.130.216:10000/zuihou-jobs-server](http://42.202.130.216:10000/zuihou-jobs-server) | zuihou/zuihou | 无 | ### 功能点介绍 1. **服务注册 & 发现与调用:** 基于 Nacos 来实现的服务注册与发现,使用使用 Feign 来实现服务互调,可以做到使用 HTTP 请求远程调用时能与调用本地方法一样的编码体验,开发者完全感知不到这是远程方法,更感知不到这是个 HTTP 请求。 2. **服务鉴权:** 通过 JWT 的方式来加强服务之间调度的权限验证,保证内部服务的安全性。 3. **负载均衡:** 将服务保留的 rest 进行代理和网关控制,除了平常经常使用的 node.js、nginx 外,Spring Cloud 系列的 zuul 和 ribbon,可以帮我们进行正常的网关管控和负载均衡。其中扩展和借鉴国外项目的扩展基于 JWT 的 Zuul 限流插件,方面进行限流。 4. **熔断机制:** 因为采取了服务的分布,为了避免服务之间的调用 “雪崩”,采用了 Hystrix 的作为熔断器,避免了服务之间的 “雪崩”。 5. **监控:** 利用 Spring Boot Admin 来监控各个独立 Service 的运行状态;利用 turbine 来实时查看接口的运行状态和调用频率;通过 Zipkin 来查看各个服务之间的调用链等。 6. **链路调用监控:** 利用 Zipkin 实现微服务的全链路性能监控, 从整体维度到局部维度展示各项指标,将跨应用的所有调用链性能信息集中展现,可方便度量整体和局部性能,并且方便找到故障产生的源头,生产上可极大缩短故障排除时间。有了它,我们能做到: > 请求链路追踪,故障快速定位:可以通过调用链结合业务日志快速定位错误信息。 可视化:各个阶段耗时,进行性能分析。 依赖优化:各个调用环节的可用性、梳理服务依赖关系以及优化。 数据分析,优化链路:可以得到用户的行为路径,汇总分析应用在很多业务场景。 7. **数据权限** 利用基于 Mybatis 的 DataScopeInterceptor 拦截器实现了简单的数据权限 8. **SaaS(多租户)的无感解决方案** 使用 Mybatis 拦截器实现对所有 SQL 的拦截,修改默认的 Schema,从而实现多租户数据隔离的目的。 并且支持可插拔。 9. **二级缓存** 采用 J2Cache 操作缓存,第一级缓存使用内存 (Caffeine),第二级缓存使用 Redis。 由于大量的缓存读取会导致 L2 的网络成为整个系统的瓶颈,因此 L1 的目标是降低对 L2 的读取次数。 该缓存框架主要用于集群环境中。单机也可使用,用于避免应用重启导致的缓存冷启动后对后端业务的冲击。 10. **优雅的 Bean 转换** 采用 Dozer 组件来对 DTO、DO、PO 等对象的优化转换 11. **前后端统一表单验证** 严谨的表单验证通常需要 前端 + 后端同时验证, 但传统的项目,均只能前后端各做一次检验, 后期规则变更,又得前后端同时修改。 故在  `hibernate-validator`  的基础上封装了  `zuihou-validator-starter`  起步依赖,提供一个通用接口,可以获取需要校验表单的规则,然后前端使用后端返回的规则, 以后若规则改变,只需要后端修改即可。 12. **防跨站脚本攻击(XSS)** - 通过过滤器对所有请求中的 表单参数 进行过滤 - 通过 Json 反序列化器实现对所有 application/json 类型的参数 进行过滤 13. **当前登录用户信息注入器** - 通过注解实现用户身份注入 14. **在线 API** 由于原生 swagger-ui 某些功能支持不够友好,故采用了国内开源的  `swagger-bootstrap-ui`,并制作了 stater,方便 springboot 用户使用。 15. **代码生成器** 基于 Mybatis-plus-generator 自定义了一套代码生成器, 通过配置数据库字段的注释,自动生成枚举类、数据字典注解、SaveDTO、UpdateDTO、表单验证规则注解、Swagger 注解等。 16. **定时任务调度器**: 基于 xxl-jobs 进行了功能增强。(如:指定时间发送任务、执行器和调度器合并项目、多数据源) 17. **大文件 / 断点 / 分片续传** 前端采用 webupload.js、后端采用 NIO 实现了大文件断点分片续传,启动 Eureka、Zuul、File 服务后,直接打开 docs/chunkUploadDemo/demo.html 即可进行测试。 经测试,本地限制堆栈最大内存 128M 启动 File 服务,5 分钟内能成功上传 4.6G + 的大文件,正式服耗时则会受到用户带宽和服务器带宽的影响,时间比较长。 18. **分布式事务** 集成了阿里的分布式事务中间件:seata,以  **高效**  并且对业务  **0 侵入**  的方式,解决 微服务 场景下面临的分布式事务问题。 ### 项目架构图 ![20241229154732_sgdUPRIO.webp](https://cdn.dong4j.site/source/image/20241229154732_sgdUPRIO.webp) ### 技术栈 / 版本介绍 - 所涉及的相关的技术有: - JSON 序列化:Jackson - 消息队列:RabbitMQ - 缓存:Redis - 缓存框架:J2Cache - 数据库: MySQL 5.7.9 (驱动 6.0.6) - 定时器:采用 xxl-jobs 项目进行二次改造 - 前端:vue - 持久层框架: Mybatis-plus - 代码生成器:基于 Mybatis-plus-generator 自定义 [[https://github.com/zuihou/zuihou-generator.git](https://github.com/zuihou/zuihou-generator.git)] - API 网关:Zuul - 服务注册与发现:Eureka -> Nacos - 服务消费:OpenFeign - 负载均衡:Ribbon - 配置中心:Nacos - 服务熔断:Hystrix - 项目构建:Maven 3.3 - 分布式事务: seata - 分布式系统的流量防卫兵: Sentinel - 监控: spring-boot-admin 2.x - 链路调用跟踪: zipkin 2.x - 文件服务器:FastDFS 5.0.5 / 阿里云 OSS / 本地存储 - Nginx - 部署方面: - 服务器:CentOS - Jenkins - Docker 18.09 - Kubernetes 1.12 本代码采用 Intellij IDEA (2018.1 EAP) 来编写,但源码与具体的 IDE 无关。 PS: Lombok 版本过低会导致枚举类型的参数无法正确获取参数,经过调试发现因为版本多低后,导致 EnumDeserializer 的 Object obj = p.getCurrentValue (); 取的值为空。 ## [microservices-platform](https://github.com/zlt2000/microservices-platform) ### 项目总体架构图 ![20241229154732_lCCGFgoH.webp](https://cdn.dong4j.site/source/image/20241229154732_lCCGFgoH.webp) ### 功能介绍 ![20241229154732_NdaIh5EJ.webp](https://cdn.dong4j.site/source/image/20241229154732_NdaIh5EJ.webp) ### 模块说明 ``` central-platform -- 父项目,公共依赖 │ ├─zlt-business -- 业务模块一级工程 │ │ ├─user-center -- 用户中心[7000] │ │ ├─file-center -- 文件中心[5000] │ │ ├─code-generator -- 代码生成器[7300] │ │ ├─search-center -- 搜索中心 │ │ │ ├─search-client -- 搜索中心客户端 │ │ │ ├─search-server -- 搜索中心服务端[7100] │ │─zlt-commons -- 通用工具一级工程 │ │ ├─zlt-auth-client-spring-boot-starter -- 封装spring security client端的通用操作逻辑 │ │ ├─zlt-common-core -- 封装通用操作逻辑 │ │ ├─zlt-common-spring-boot-starter -- 封装通用操作逻辑 │ │ ├─zlt-db-spring-boot-starter -- 封装数据库通用操作逻辑 │ │ ├─zlt-log-spring-boot-starter -- 封装log通用操作逻辑 │ │ ├─zlt-redis-spring-boot-starter -- 封装Redis通用操作逻辑 │ │ ├─zlt-ribbon-spring-boot-starter -- 封装Ribbon和Feign的通用操作逻辑 │ │ ├─zlt-sentinel-spring-boot-starter -- 封装Sentinel的通用操作逻辑 │ │ ├─zlt-swagger2-spring-boot-starter -- 封装Swagger通用操作逻辑 │ ├─zlt-config -- 配置中心 │ ├─zlt-doc -- 项目文档 │ ├─zlt-gateway -- api网关一级工程 │ │ ├─sc-gateway -- spring-cloud-gateway[9900] │ │ ├─zuul-gateway -- netflix-zuul[9900] │ ├─zlt-job -- 分布式任务调度一级工程 │ │ ├─job-admin -- 任务管理器[8081] │ │ ├─job-core -- 任务调度核心代码 │ │ ├─job-executor-samples -- 任务执行者executor样例[8082] │ ├─zlt-monitor -- 监控一级工程 │ │ ├─sc-admin -- 应用监控[6500] │ │ ├─log-center -- 日志中心[6200] │ ├─zlt-uaa -- spring-security认证中心[8000] │ ├─zlt-register -- 注册中心Nacos[8848] │ ├─zlt-web -- 前端一级工程 │ │ ├─back-web -- 后台前端[8066] │ ├─zlt-transaction -- 事务一级工程 │ │ ├─txlcn-tm -- tx-lcn事务管理器[7970] │ ├─zlt-demo -- demo一级工程 │ │ ├─txlcn-demo -- txlcn分布式事务demo │ │ ├─seata-demo -- seata分布式事务demo │ │ ├─sharding-jdbc-demo -- sharding-jdbc分库分表demo │ │ ├─rocketmq-demo -- rocketmq和mq事务demo ``` ## (open-capacity-platform)[https://gitee.com/owenwangwen/open-capacity-platform] ### **功能介绍** - 统一安全认证中心 - 支持 oauth 的四种模式登录 - 支持用户名、密码加图形验证码登录 - 支持第三方系统单点登录 - 微服务架构基础支撑 - 服务注册发现、路由与负载均衡 - 服务熔断与限流 - 统一配置中心 - 统一日志中心 - 分布式锁 - 分布式任务调度器 - 系统服务监控中心 - 服务调用链监控 - 应用吞吐量监控 - 服务降级、熔断监控 - 微服务服务监控 - 能力开放平台业务支撑 - 网关基于应用方式 API 接口隔离 - 网关基于应用限制调用次数 - 下游服务基于 RBAC 权限管理,实现细粒度控制 - 代码生成器中心 - 网关聚合服务内部 Swagger 接口文档 - 统一跨域处理 - 统一异常处理 - docker 容器化部署 - 基于 rancher 的容器化部署 - 基于 docker 的 elk 日志监控 - 基于 docker 的服务动态扩容 ### 代码结构 ![20241229154732_6fabptft.webp](https://cdn.dong4j.site/source/image/20241229154732_6fabptft.webp) ## [mall](https://github.com/macrozheng/mall) ### 项目介绍 mall 项目是一套电商系统,包括前台商城系统及后台管理系统,基于 SpringBoot+MyBatis 实现,采用 Docker 容器化部署。前台商城系统包含首页门户、商品推荐、商品搜索、商品展示、购物车、订单流程、会员中心、客户服务、帮助中心等模块。后台管理系统包含商品管理、订单管理、会员管理、促销管理、运营管理、内容管理、统计报表、财务管理、权限管理、设置等模块。 ### 系统架构图 ![20241229154732_I6pbMYqL.webp](https://cdn.dong4j.site/source/image/20241229154732_I6pbMYqL.webp) ## [spring-boot-plus](https://github.com/geekidea/spring-boot-plus) ### 主要特性 1. 集成 spring boot 常用开发组件集、公共配置、AOP 日志等 2. 集成 mybatis plus 快速 dao 操作 3. 快速生成后台代码: entity/param/vo/controller/service/mapper/xml 4. 集成 swagger2,可自动生成 api 文档 5. 集成 jwt、shiro/spring security 权限控制 6. 集成 redis、spring cache、ehcache 缓存 7. 集成 rabbit/rocket/kafka mq 消息队列 8. 集成 druid 连接池,JDBC 性能和慢查询检测 9. 集成 spring boot admin,实时检测项目运行情况 10. 使用 assembly maven 插件进行不同环境打包部署,包含启动、重启命令,配置文件提取到外部 config 目录 ### 项目架构 ![20241229154732_ghNYKhGb.webp](https://cdn.dong4j.site/source/image/20241229154732_ghNYKhGb.webp) ## [carefree-mongodb-spring-boot-starter](https://github.com/kweny/carefree-mongodb-spring-boot-starter) ## [Micro-Service-Skeleton](https://github.com/babylikebird/Micro-Service-Skeleton) ## [pig](https://gitee.com/log4j/pig) ### 特性 - 基于 Spring Cloud Hoxton 、Spring Boot 2.2、 OAuth2 的 RBAC 权限管理系统 - 基于数据驱动视图的理念封装 element-ui,即使没有 vue 的使用经验也能快速上手 - 提供对常见容器化支持 Docker、Kubernetes、Rancher2 支持 - 提供 lambda 、stream api 、webflux 的生产实践 ### 结构 ``` pig-ui -- https://gitee.com/log4j/pig-ui pig ├── pig-auth -- 授权服务提供[3000] ├── pig-codegen -- 图形化代码生成[5002] └── pig-common -- 系统公共模块 ├── pig-common-core -- 公共工具类核心包 ├── pig-common-log -- 日志服务 ├── pig-common-security -- 安全工具类 └── pig-common-swagger -- 接口文档 ├── pig-register -- Nacos Server[8848] ├── pig-gateway -- Spring Cloud Gateway网关[9999] ├── pig-monitor -- Spring Boot Admin监控 [5001] └── pig-upms -- 通用用户权限管理模块 └── pig-upms-api -- 通用用户权限管理系统公共api模块 └── pig-upms-biz -- 通用用户权限管理系统业务处理模块[4000] ``` ## [prex](https://gitee.com/kaiyuantuandui/prex) Prex 基于 Spring Boot 2.1.8 、Spring Cloud Greenwich.SR3、Spring Cloud Alibaba、Spring Security 、Spring cloud Oauth2 、Vue 的前后端分离的的 RBAC 权限管理系统,项目支持数据权限管理,支持后端配置菜单动态路由,第三方社交登录,努力做最简洁的后台管理系统 ### 技术栈 - 基于 Spring Boot 2.1.8 、Spring Cloud、Spring Cloud Alibaba、Spring Security、OAuth2 的 RBAC 权限管理系统。 - 基于 Nacos 做注册中心和配置中心。 - 基于 Sentinel 做方法限流和阻塞处理。 - 基于 Vue 前端框架 和最新 Ant Design 界面。 - 基于 Mybatis Plus 简化开发、数据隔离等。 - 项目均使用 Lambda 、Stream Api 的风格编码。 - 使用 Spring Social 三方登录。 - 提供 Spring Cloud Admin 做项目可视化监控。 - 基于 Swagger 提供统一 Api 管理。 ### 基本功能 - 用户管理:该功能主要完成系统用户配置,提供用户基础配置 (用户名、手机号邮箱等) 以及部门角色等。 - 角色管理:权限菜单分配,以部门基础设置角色的数据权限范围。 - 菜单管理:后端配置实现菜单动态路由,支持多级菜单,操作权限,按钮权限标识等。 - 部门管理:配置系统组织架构,树形表格展示,可随意调整上下级。 - 岗位管理:根据部门配置所属职位 - 租户管理:提供统一认证对权限管理平台,按照租户进行数据隔离 (微服务版暂不开放)。 - 社交账号管理:可以对绑定 Prex 系统对社交账号进行查看和解绑。 - 字典管理:对系统中经常使用的一些较为固定的数据进行维护,如:状态 (正常 / 异常),性别 (男 / 女) 等。 - 日志管理:可以删除和查看用户操作的日志。 - 异常日志:记录异常日志,方便开发人员定位错误。 ### 项目特点 - 前后端分离的微服务架构 - 使用 Nacos、Sentinel、SpringCloud 等最新流行组件和 UI - 可直接集成到企业微服务项目中 - 使用 Gateway 进行高性能的网关路由 - 独立的 UPMS 系统 - 使用 JWT 进行 Token 管理 - 提供插拔式密码和客户端两种模式到授权方式 - 对日志操作、短信、邮件、Redis、资源服务、Swagger 均提供插拔式使用 - 代码大量采用中文注释,极其简洁风格,上手快、易理解 - 采用 RESTFul API 规范开发 - 统一异常拦截,友好的错误提示 - 基于注解 + Aop 切面实现全方位日记记录系统 - 基于 Mybatis 拦截器 + 策略模式实现数据权限控制 - 提供解决前后分离第三方社交登录方案 - Spring Social 集成 Security 实现第三方社交登录 - 基于 Mybatis-Plus 实现 SaaS 多租户功能 (微服务版暂不开放) ## [Nepxion](https://github.com/Nepxion) ## [FEBS-Cloud](https://github.com/wuyouzhuguli/FEBS-Cloud) FEBS Cloud 是一款使用 Spring Cloud Hoxton.RELEASE、Spring Cloud OAuth2 & Spring Cloud Alibaba 构建的低耦合权限管理系统,前端(FEBS Cloud Web)采用 vue element admin 构建。FEBS 意指:**F**ast,**E**asy use,**B**eautiful 和  **S**afe。该系统具有如下特点: 1. 前后端分离架构,客户端和服务端纯 Token 交互; 2. 认证服务器与资源服务器分离,方便接入自己的微服务系统; 3. 微服务防护,客户端请求资源只能通过微服务网关获取; 4. 集成 Prometheus,SpringBootAdmin,多维度监控微服务; 5. 集成 Spring Cloud Alibaba Nacos 服务治理和集中配置管理; 6. 网关限流,网关黑名单限制,网关日志(WebFlux 编程实践); 7. ~~集成 Zipkin,方便跟踪 Feign 调用链~~,集成 Skywalking APM; 8. 集成 ELK,集中管理日志,便于问题分析; 9. 微服务 Docker 化,使用 Docker Compose 一键部署; 10. 支持 Kubernetes 集群部署; 11. 提供详细的使用文档和搭建教程; 12. 前后端请求参数校验,Excel 导入导出,代码生成等。 ### 文档与教程 项目导入及使用文档:[https://www.kancloud.cn/mrbird/spring-cloud/1263681](https://www.kancloud.cn/mrbird/spring-cloud/1263681)。 项目从零搭建到部署教程:[https://www.kancloud.cn/mrbird/spring-cloud/1263685](https://www.kancloud.cn/mrbird/spring-cloud/1263685)。 Kubernetes 集群部署脚本:[https://github.com/wuyouzhuguli/FEBS-Cloud-K8S](https://github.com/wuyouzhuguli/FEBS-Cloud-K8S)。 分布式事务方案(RocketMQ、TX-LCN、Seata):[https://www.kancloud.cn/mrbird/spring-cloud/1456142](https://www.kancloud.cn/mrbird/spring-cloud/1456142)。 ## [Spring Cloud Alibaba 组件使用](https://github.com/helloworlde/spring-cloud-alibaba-component) ### Nacos > Nacos 是一个配置和注册中心,类似 Spring Cloud Config 和 Eureka、ZooKeeper、Consul - [Spring Boot 使用 Nacos 作为配置中心](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/boot-config/README.md) - [Spring Cloud 使用 Nacos 作为配置中心](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/cloud-config/README.md) - [Spring Cloud 使用 Nacos 作为服务注册中心](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/cloud-discovery/README.md) ### Sentinel > Sentinel 是一个流量控制框架,支持流量控制,熔断降级,系统负载保护,类似 Hystrix、resilience4j - [Spring Cloud 使用 Sentinel 作为限流降级工具](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/sentinel-nacos-config/README.md) ### OSS > spring-cloud-starter-alicloud-oss 是用于阿里云 OSS 的 SpringBoot Starter,通过封装 SDK 实现对 OSS 的操作 - [Spring Cloud 使用阿里云 OSS](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/cloud-oss/README.md) ### Dubbo > Dubbo 是一个远程调用框架,用于实现方法的远程调用 推荐使用 ZooKeeper 作为注册中心,当前使用 Nacos 会有各种问题 - [Spring Cloud 使用 Dubbo 实现远程调用,Nacos 作为注册中心](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/cloud-dubbo-nacos/README.md) - [Spring Cloud 使用 Dubbo 实现远程调用,ZooKeeper 作为注册中心](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/cloud-dubbo-zk/README.md) ### Seata > Seata 是一个分布式事务框架,可以通过 Seata 框架的注解实现非侵入性的分布式事务 - [Spring Cloud 使用 Seata 实现分布式事务 - MyBatis](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/cloud-seata-mybatis/README.md) - [Spring Cloud 使用 Seata 实现分布式事务 - JPA](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/cloud-seata-jpa/README.md) - [Spring Cloud 使用 Seata 实现分布式事务 - Mybatis/Nacos](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/cloud-seata-nacos/README.md) - [Spring Cloud 使用 Seata 实现分布式事务 - Mybatis/Nacos/Dubbo](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/cloud-seata-dubbo-nacos/README.md) - [Spring Cloud 使用 Seata 实现分布式事务 - Mybatis/Eureka/Feign](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/cloud-seata-eureka/README.md) - [Spring Cloud 使用 Seata 实现分布式事务 - Mybatis 多数据源](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/cloud-seata-multi-datasource/README.md) - [Spring Cloud 使用 Seata 实现分布式事务 - Mybatis 多数据源 & MyBatisPlus](https://github.com/helloworlde/spring-cloud-alibaba-component/blob/master/cloud-seata-multi-datasource-mybatis-plus/README.md) MyBatis 和 JPA 通过 Seata 实现分布式事务都需要注入  `io.seata.rm.datasource.DataSourceProxy`, 不同的是,MyBatis 还需要额外注入  `org.apache.ibatis.session.SqlSessionFactory` ## [cloud-platform](https://gitee.com/geek_qi/cloud-platform) ### 架构摘要 1. 服务鉴权 通过 JWT 的方式来加强服务之间调度的权限验证,保证内部服务的安全性。 2. 监控 利用 Spring Boot Admin 来监控各个独立 Service 的运行状态;利用 Hystrix Dashboard 来实时查看接口的运行状态和调用频率等。 3. 负载均衡 将服务保留的 rest 进行代理和网关控制,除了平常经常使用的 node.js、nginx 外,Spring Cloud 系列的 zuul 和 ribbon,可以帮我们进行正常的网关管控和负载均衡。其中扩展和借鉴国外项目的扩展基于 JWT 的 Zuul 限流插件,方面进行限流。 4. 服务注册与调用 基于 Nacos 来实现的服务注册与调用,在 Spring Cloud 中使用 Feign, 我们可以做到使用 HTTP 请求远程服务时能与调用本地方法一样的编码体验,开发者完全感知不到这是远程方法,更感知不到这是个 HTTP 请求。 5. 熔断机制 因为采取了服务的分布,为了避免服务之间的调用 “雪崩”,采用了 Hystrix 的作为熔断器,避免了服务之间的 “雪崩”。 ## [open-cloud](https://github.com/liuyadu/open-cloud) ### 简介 搭建基于 OAuth2 的开放平台、为 APP 端、应用服务提供统一接口管控平台、为第三方合作伙伴的业务对接提供授信可控的技术对接平台 - 分布式架构,Nacos (服务注册 + 配置中心) 统一管理 - 统一 API 网关(参数验签、身份认证、接口鉴权、接口调试、接口限流、接口状态、接口外网访问) - 统一 oauth2 认证协议 ## [api-boot](https://gitee.com/minbox-projects/api-boot) ## [Moss](https://github.com/SpringCloud/Moss) [集成 jetcache](https://www.jianshu.com/p/c3a3a2aee709) ## [深入理解Spring Profiles:定制化配置的艺术](https://blog.dong4j.site/posts/278e3e03.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 1. 概述 这篇文章将阐述怎么在 Spring 中使用 Profile 从 Spring 3.1 开始,我们能够将 bean 映射到不同的 profile 上,如 dev, test, prod 等。 我们也能够根据环境 (environment) 来激活不同的 profile,从而加载我们需要的 bean。 ## 2. 在 Bean 上使用  `@Profile` 我们先从简单的例子开始,看看怎么把 bean 绑定到不同的 profile 上。 使用  `@Profile`  注解,我们可以将 bean 绑定到指定的 profile 上。这个注解支持绑定一个或多个 profile。 试想这样一个场景:我们有一个 bean,只在开发环境需要,线上环境不需要。那么我们可以通过注解将这个 bean 绑定到  `dev profile`  上。这样,这个 bean 只会存在于开发环境,而在其他环境中不会被加载。如下所示: ```java @Component @Profile("dev") public class DevDatasourceConfig ``` 上面的写法是指绑定 bean 到  `dev profile`。如果想绑定 bean 到除  `dev`  以外的 profile 呢。可以使用  **NOT 操作符**。如下所示: ```java @Component @Profile("!dev") public class DevDatasourceConfig ``` > 译者注: > `@Profile`  的声明如下: > > ``` > public @interface Profile { > String[] value(); > } > > ``` > > `value`  是个数组,支持多个值。绑定多个 profile,如下所示: > > ``` > @Profile(value = {"dev", "test"}) > > ``` ## 3. 在 XML 中声明 Profile 除了使用  `@Profile`  注解,还可以在 XML 中绑定 profile。 ``  标签有个  `profiles`  属性。多个 profile 之间使用逗号分隔: `profile还是profiles???` ```xml ``` ## 4. 设置 profile 上面只是将 bean 和 profile 进行了绑定,下一步需要设置和激活 profile,才能使不同的 bean 被注册到容器中。方法有很多种,下面是一些例子: ### 4.1 使用  `WebApplicationInitializer` interface 这是一种编程的方式 (与之对应是配置方式) 在 web 应用中,`WebApplicationInitializer`  可以被用来配置  `ServletContext`。 > 译者注: > `WebApplicationInitializer`  在 spring-web 中 方法如下: ```java @Configuration public class MyWebApplicationInitializer implements WebApplicationInitializer { @Override public void onStartup(ServletContext servletContext) throws ServletException { servletContext.setInitParameter( "spring.profiles.active", "dev"); } } ``` > 译者注: > 这种方式,直接把要绑定的 profile 硬编码到代码中,是非常不优雅,也不方便的。 ### 4.2 使用  `ConfigurableEnvironment` 你也可以直接在环境中设置 profile: ```java @Autowired private ConfigurableEnvironment env; ... env.setActiveProfiles("someProfile"); ``` > 译者注: > 问题同 4.1 ### 4.3 在  `web.xml`  中配置 ```xml contextConfigLocation /WEB-INF/app-config.xml spring.profiles.active dev ``` > 译者注: > 问题同 4.1,4.2。 本人不赞成任何硬编码的方式 ### 4.4 通过 JVM 参数设置 profile 的名字还可以通过 JVM 参数的方式设置。在启动的时候,添加类似如下参数: `-Dspring.profiles.active=dev` > 译者注: > 如  `java -jar xxx.jar -Dspring.profiles.active=dev` ### 4.5 通过环境变量设置 通过设置环境变量,也是可以的: `export spring_profiles_active=dev` ### 4.6 Maven Profile Spring profile 也可以结合 maven profile 使用,通过设置  `spring.profiles.active`  属性: ```xml dev true dev prod prod ``` 同时,还需要在  `application.properties`(如果使用 spring boot 的话) 中添加如下设置: ```java spring.profiles.active=@spring.profiles.active@ ``` 另外,还需要再  `pom.xml`  文件中添加如下配置: ```xml src/main/resources true ... ``` > 译者注: > 在  `pom.xml`  中添加的配置,意思是开启占位符替换。可参考:[Maven Filtering](https://maven.apache.org/plugins/maven-resources-plugin/examples/filter.html) 结合使用 maven profile 后,就可以在打包的时候激活某个 profile: ```shell mvn clean package -Pprod ``` > 译者注: > 个人认为,结合 maven profile 的这种办法,灵活度最高,也最方便,推荐。 ### 4.7 Test 中使用  `@ActiveProfiles` 在 test 时,如何指定 profile 呢?也很简单,通过  `@ActiveProfiles`  注解就可以了。 > @ActiveProfiles("dev") ## 5. 默认 profile 如果不指定任何 profile,那么这个 bean 就属于  `default` profile 当没有任何 profile 被激活时,spring 也支持设置默认 profile。就是通过  `spring.profiles.default`  参数。 ## 6. 获取被激活的 profile 一旦 profile 被激活,我们就可以通过  `Environment`,在运行时获取这些 profile 的信息: ```java public class ProfileManager { @Autowired Environment environment; public void getActiveProfiles() { for (final String profileName : environment.getActiveProfiles()) { System.out.println("Currently active profile - " + profileName); } } } ``` ## 7. 使用 Profile 的例子 理论总是抽象的,下面我们通过一些例子来深入理解。 试想这样一个场景:我们要分别针对开发环境和线上环境,对 datasource 进行设置。我们先创建一个  `DatasourceConfig`  接口。这个接口需要在两个环境中都被实现。 ```java public interface DatasourceConfig { public void setup(); } ``` 开发环境的实现: ```java @Component @Profile("dev") public class DevDatasourceConfig implements DatasourceConfig { @Override public void setup() { System.out.println("Setting up datasource for DEV environment. "); } } ``` 线上环境的实现: ```java @Component @Profile("production") public class ProductionDatasourceConfig implements DatasourceConfig { @Override public void setup() { System.out.println("Setting up datasource for PRODUCTION environment. "); } } ``` 下面我们写个单元测试,并注入  `DatasourceConfig`。那么我们通过设置不同的 profile,就会分别注入  `DevDatasourceConfig` bean 和  `ProductionDatasourceConfig` bean。 ```java public class SpringProfilesTest { @Autowired DatasourceConfig datasourceConfig; public void setupDatasource() { datasourceConfig.setup(); } } ``` 当  `dev` profile 被激活的时候,会有如下输出: > Setting up datasource for DEV environment. ## 8. 在 Spring Boot 中使用 Profile Spring Boot 除了支持所有的 profile 配置,还有提供一些额外功能。 `spring.profiles.active`  的初始化可以在配置文件中设定: ```java spring.profiles.active=dev ``` 当然也可以在程序中设定: ```java SpringApplication.setAdditionalProfiles("dev"); ``` 还可以在 pom.xml 文件中的  `spring-boot-maven-plugin`  中配置: ```xml org.springframework.boot spring-boot-maven-plugin dev ... ``` 用 maven 启动,可执行命令: ```shell mvn spring-boot:run ``` 但是 Spring Boot 带来的最重要的特性是  **profile-specific profiles**  文件,这些文件的命名方式需要遵循  `applications-{profile}.properties`  的格式。 举个例子:我们可以在开发环境和线上环境使用不同的数据库。如开发环境使用 h2,线上环境使用 mysql。那么,我们分别需要创建如下两个文件: **application-production.properties** ```java spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/db spring.datasource.username=root spring.datasource.password=root ``` **application-dev.properties** ```java spring.datasource.driver-class-name=org.h2.Driver spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1 spring.datasource.username=sa spring.datasource.password=sa ``` 如果激活的 profile 是  `dev`,则  **application-dev.properties**  配置文件会被自动加载。如果激活的 profile 是  `production`,则  **application-production.properties**  配置文件会被加载。 通过这种方式,我们就很容易地针对不同环境,配置不同的配置文件。 ## [API安全之道:深入解析认证、授权与凭证](https://blog.dong4j.site/posts/f50183c1.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 互联网是基于 HTTP 协议构建的,而 HTTP 协议因为简单流行开来,但是 HTTP 协议是无状态(通信层面上虚电路比数据报昂贵太多)的,为此人们为了追踪用户想出了各种办法,包括 cookie/session 机制、token、flash 跨浏览器 cookie 甚至浏览器指纹等。 ![20241229154732_huSD45E2.webp](https://cdn.dong4j.site/source/image/20241229154732_huSD45E2.webp) 把用户身份藏在每一个地方(浏览器指纹技术甚至不需要存储介质) 讲使用 spring security 等具体技术的资料已经很多了,这篇文章不打算写框架和代码的具体实现。我们会讨论认证和授权的区别,然后会介绍一些被业界广泛采用的技术,最后会聊聊怎么为 API 构建选择合适的认证方式。 ### 认证、授权、凭证 首先,认证和授权是两个不同的概念,为了让我们的 API 更加安全和具有清晰的设计,理解认证和授权的不同就非常有必要了,它们在英文中也是不同的单词。 ![20241229154732_D8H6UYj7.webp](https://cdn.dong4j.site/source/image/20241229154732_D8H6UYj7.webp) 认证是 authentication,指的是当前用户的身份,当用户登陆过后系统便能追踪到他的身份做出符合相应业务逻辑的操作。即使用户没有登录,大多数系统也会追踪他的身份,只是当做来宾或者匿名用户来处理。认证技术解决的是 “我是谁?” 的问题。 授权则不同,授权是 authorization,指的是什么样的身份被允许访问某些资源,在获取到用户身份后继续检查用户的权限。单一的系统授权往往是伴随认证来完成的,但是在开放 API 的多系统结构下,授权可以由不同的系统来完成,例如 OAuth。授权技术是解决 “我能做什么?” 的问题。 实现认证和授权的基础是需要一种媒介(credentials)来标记访问者的身份或权利,在现实生活中每个人都需要一张身份证才能访问自己的银行账户、结婚和办理养老保险等,这就是认证的凭证;在古代军事活动中,皇帝会给出战的将军颁发兵符,下级将领不关心持有兵符的人,只需要执行兵符对应的命令即可。在互联网世界中,服务器为每一个访问者颁发 session ID 存放到 cookie,这就是一种凭证技术。数字凭证还表现在方方面面,SSH 登录的密匙、JWT 令牌、一次性密码等。 用户账户也不一定是存放在数据库中的一张表,在一些企业 IT 系统中,对账户管理和权限有了更多的要求。所以账户技术 (accounting)可以帮助我们使用不同的方式管理用户账户,同时具有不同系统之间共享账户的能力。例如微软的活动目录(AD),以及简单目录访问协议(LDAP),甚至区块链技术。 还有一个重要的概念是访问控制策略(AC)。如果我们需要把资源的权限划分到一个很细的粒度,就不得不考虑用户以何种身份来访问受限的资源,选择基于访问控制列表(ACL)还是基于用户角色的访问控制(RBAC)或者其他访问控制策略。 在流行的技术和框架中,这些概念都无法孤立的被实现,因此在现实中使用这些技术时,大家往往为一个 OAuth2 是认证还是授权这种概念争论不休。为了容易理解,我在文末附上了一份常见技术和概念的术语表。下面我会介绍在 API 开发中常常使用的几种认证和授权技术:HTTP Basic AUthentication、HAMC、OAuth2,以及凭证技术 JWT token。 ### HTTP Basic Authentication 你一定用过这种方式,但不一定知道它是什么,在不久之前,当你访问一台家用路由器的管理界面,往往会看到一个浏览器弹出表单,要求你输入用户密码。 ![20241229154732_bErP0pMh.webp](https://cdn.dong4j.site/source/image/20241229154732_bErP0pMh.webp) 在这背后,当用户输入完用户名密码后,浏览器帮你做了一个非常简单的操作: 1. 组合用户名和密码然后 Base64 编码 2. 给编码后的字符串添加 Basic 前缀,然后设置名称为 Authorization 的 header 头部 ![20241229154732_C3NFqgST.webp](https://cdn.dong4j.site/source/image/20241229154732_C3NFqgST.webp) API 也可以非常简单的提供 HTTP Basic Authentication 认证方式,那么客户端可以很简单通过 Base64 传输用户名和密码即可: 1. 将用户名和密码使用冒号连接,例如 username:abc123456 2. 为了防止用户名或者密码中存在超出 ASCII 码范围的字符,推荐使用 UTF-8 编码 3. 将上面的字符串使用 Base 64 编码,例如 dXNlcm5hbWU6YWJjMTIzNDU2 4. 在 HTTP 请求头中加入 “Basic + 编码后的字符串”,即:Authorization: Basic QWxhZGRpbjpPcGVuU2VzYW1l 这种方式实现起来非常简单,在大量场景下被采用。当然缺点也很明显,Base64 只能称为编码,而不是加密 (实际上无需配置密匙的客户端并没有任何可靠地加密方式,我们都依赖 TSL 协议)。这种方式的致命弱点是编码后的密码如果明文传输则容易在网络传输中泄露,在密码不会过期的情况下,密码一旦泄露,只能通过修改密码的方式。 ### HMAC(AK/SK)认证 在我们对接一些 PASS 平台和支付平台时,会要求我们预先生成一个 access key(AK) 和 secure key(SK),然后通过签名的方式完成认证请求,这种方式可以避免传输 secure key,且大多数情况下签名只允许使用一次,避免了重放攻击。 这种基于 AK/SK 的认证方式主要是利用散列的消息认证码 (Hash-based MessageAuthentication Code) 来实现的,因此有很多地方叫 HMAC 认证,实际上不是非常准确。HMAC 只是利用带有 key 值的哈希算法生成消息摘要,在设计 API 时有具体不同的实现。 ![20241229154732_PQgm77Vd.webp](https://cdn.dong4j.site/source/image/20241229154732_PQgm77Vd.webp) HMAC 在作为网络通信的认证设计中作为凭证生成算法使用,避免了口令等敏感信息在网络中传输。基本过程如下: 1. 客户端需要在认证服务器中预先设置 access key(AK 或叫 app ID) 和 secure key(SK) 2. 在调用 API 时,客户端需要对参数和 access key 进行自然排序后并使用 secure key 进行签名生成一个额外的参数 digest 3. 服务器根据预先设置的 secure key 进行同样的摘要计算,并要求结果完全一致 4. **注意 secure key 不能在网络中传输,以及在不受信任的位置存放(浏览器等)** 为了让每一次请求的签名变得独一无二,从而实现重放攻击,我们需要在签名时放入一些干扰信息。 在业界标准中有两种典型的做法,质疑 / 应答算法(OCRA: OATH Challenge-Response Algorithm)、基于时间的一次性密码算法(TOTP:Time-based One-time Password Algorithm)。 #### 质疑 / 应答算法 质疑 / 应答算法需要客户端先请求一次服务器,获得一个 401 未认证的返回,并得到一个随机字符串(nonce)。将 nonce 附加到按照上面说到的方法进行 HMAC 签名,服务器使用预先分配的 nonce 同样进行签名校验,这个 nonce 在服务器只会被使用一次,因此可以提供唯一的摘要。 ![20241229154732_fdBO55EI.webp](https://cdn.dong4j.site/source/image/20241229154732_fdBO55EI.webp) #### 基于时间的一次性密码认证 为了避免额外的请求来获取 nonce,还有一种算法是使用时间戳,并且通过同步时间的方式协商到一致,在一定的时间窗口内有效(1 分钟左右)。 ![20241229154732_izhcSRpQ.webp](https://cdn.dong4j.site/source/image/20241229154732_izhcSRpQ.webp) 这里的只是利用时间戳作为验证的时间窗口,并不能严格的算作基于时间的一次性密码算法。标准的基于时间的一次性密码算法在两步验证中被大量使用,例如 Google 身份验证器不需要网络通信也能实现验证(但依赖准确的授时服务)。原理是客户端服务器共享密钥然后根据时间窗口能通过 HMAC 算法计算出一个相同的验证码。 ![20241229154732_vtsJa0Ul.webp](https://cdn.dong4j.site/source/image/20241229154732_vtsJa0Ul.webp) TOTP 基本原理和常见厂商 ### OAuth2 和 Open ID OAuth(开放授权)是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容。 OAuth 是一个授权标准,而不是认证标准。提供资源的服务器不需要知道确切的用户身份(session),只需要验证授权服务器授予的权限(token)即可。 ![20241229154732_odCaq0n1.webp](https://cdn.dong4j.site/source/image/20241229154732_odCaq0n1.webp) 上图只是 OAuth 的一个简化流程,OAuth 的基本思路就是通过授权服务器获取 access token 和 refresh token(refresh token 用于重新刷新 access token),然后通过 access token 从资源服务器获取数据 。在特定的场景下还有下面几种模式: 1. 授权码模式(authorization code) 2. 简化模式(implicit) 3. 密码模式(resource owner password credentials) 4. 客户端模式(client credentials) 如果需要获取用户的认证信息,OAuth 本身没有定义这部分内容,如果需要识别用户信息,则需要借助另外的认证层,例如 OpenID Connect。 #### 验证 access token 在一些介绍 OAuth 的博客中很少讲到资源服务器是怎么验证 access token 的。OAuth core 标准并没有定义这部分,不过在 OAuth 其他标准文件中提到两种验证 access token 的方式。 1. 在完成授权流程后,资源服务器可以使用 OAuth 服务器提供的 Introspection 接口来验证 access token,OAuth 服务器会返回 access token 的状态以及过期时间。在 OAuth 标准中验证 token 的术语是 Introspection。同时也需要注意 access token 是用户和资源服务器之间的凭证,不是资源服务器和授权服务器之间的凭证。资源服务器和授权服务器之间应该使用额外的认证(例如 Basic 认证)。 2. 使用 JWT 验证。授权服务器使用私钥签发 JWT 形式的 access token,资源服务器需要使用预先配置的公钥校验 JWT token,并得到 token 状态和一些被包含在 access token 中信息。因此在 JWT 的方案下,资源服务器和授权服务器不再需要通信,在一些场景下带来巨大的优势。同时 JWT 也有一些弱点,我会在 JWT 的部分解释。 #### refresh token 和 access token 几乎所有人刚开始了解 OAuth 时都有一个一疑问,为什么已经有了 access token 还需要 refresh token 呢? 授权服务器会在第一次授权请求时一起返回 access token 和 refresh token,在后面刷新 access token 时只需要 refresh token。 access token 和 refresh token 的设计意图是不一样的,access token 被设计用来客户端和资源服务器之间交互,而 refresh token 是被设计用来客户端和授权服务器之间交互。 某些授权模式下 access token 需要暴露给浏览器,充当一个资源服务器和浏览器之间的临时会话,浏览器和资源服务器之间不存在签名机制,access token 成为唯一凭证,因此 access token 的过期时间(TTL)应该尽量短,从而避免用户的 access token 被嗅探攻击。 由于要求 access token 时间很短,refresh token 可以帮助用户维护一个较长时间的状态,避免频繁重新授权。大家会觉得让 access token 保持一个长的过期时间不就可以了吗?实际上 refresh token 和 access token 的不同之处在于即使 refresh token 被截获,系统依然是安全的,客户端拿着 refresh token 去获取 access token 时同时需要预先配置的 secure key,客户端和授权服务器之前始终存在安全的认证。 #### OAuth、Open ID、OpenID Connect 认证方面的术语实在太多,我在搭建自己的认证服务器或接入第三方认证平台时,有时候到完成开发工作的最后一刻都无法理解这些术语。 OAuth 负责解决分布式系统之间的授权问题,即使有时候客户端和资源服务器或者认证服务器存在同一台机器上。OAuth 没有解决认证的问题,但提供了良好的设计利于和现有的认证系统对接。 Open ID 解决的问题是分布式系统之间身份认证问题,使用 Open ID token 能在多个系统之间验证用户,以及返回用户信息,可以独立使用,与 OAuth 没有关联。 OpenID Connect 解决的是在 OAuth 这套体系下的用户认证问题,实现的基本原理是将用户的认证信息(ID token)当做资源处理。在 OAuth 框架下完成授权后,再通过 access token 获取用户的身份。 这三个概念之间的关系有点难以理解,用现实场景来说,如果系统中需要一套独立的认证系统,并不需要多系统之间的授权可以直接采用 Open ID。如果使用了 OAuth 作为授权标准,可以再通过 OpenID Connect 来完成用户的认证。 ### JWT 在 OAuth 等分布式的认证、授权体系下,对凭证技术有了更多的要求,比如包含用户 ID、过期等信息,不需要再外部存储中关联。因此业界对 token 做了进一步优化,设计了一种自包含令牌,令牌签发后无需从服务器存储中检查是否合法,通过解析令牌就能获取令牌的过期、有效等信息,这就是 JWT (JSON Web Token)。 JWT 是一种包含令牌(self-contained token),或者叫值令牌 (value token),我们以前使用关联到 session 上的 hash 值被叫做引用令牌(reference token)。 ![20241229154732_VHELIyKJ.webp](https://cdn.dong4j.site/source/image/20241229154732_VHELIyKJ.webp) 简而言之,一个基本的 JWT 令牌为一段点分 3 段式结构。 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ 生成 JWT 令牌的流程为 ![20241229154732_toxO8k5v.webp](https://cdn.dong4j.site/source/image/20241229154732_toxO8k5v.webp) 1. header json 的 base64 编码为令牌第一部分 2. payload json 的 base64 编码为令牌第二部分 3. 拼装第一、第二部分编码后的 json 以及 secret 进行签名的令牌的第三部分 因此只需要签名的 secret key 就能校验 JWT 令牌,如果在消息体中加入用户 ID、过期信息就可以实现验证令牌是否有效、过期了,无需从数据库 / 缓存中读取信息。因为使用了加密算法,所以第一、二部分即使被修改(包括过期信息)也无法通过验证。JWT 优点是不仅可以作为 token 使用,同时也可以承载一些必要信息,省去多次查询。 注意: 1. JWT token 的第一、二部分只是 base64 编码,肉眼不可读,不应当存放敏感信息 2. JWT token 的自包含特性,导致了无法被撤回 3. JWT 的签名算法可以自己拟定,为了便于调试,本地环境可以使用对称加密算法,生产环境建议使用非对称加密算法 JWT token 在微服务的系统中优势特别突出。多层调用的 API 中可以直接传递 JWT token,利用自包含的能力,可以减少用户信息查询次数;更重要的是,使用非对称的加密方式可以通过在系统中分发密匙的方式验证 JWT token。 当然 OAuth 对 access token 等凭证所选用的技术并没有做出限制,OAuth 并不强制使用 JWT,在使用 JWT 自包含特性的优势时,必须考虑到 JWT 撤回困难的问题。在一些对撤回 token 要求很高的项目中不适合使用 JWT,即使采用了一些方案实现(whitelist 和 blacklist)也违背了设计 JWT 的初衷。 ### Cookie 、Token in Cookie、Session Token 依然被使用 在构建 API 时,开发者会发现我们的认证方式和网页应用有一些不同,除了像 ajax 这种典型的 web 技术外,如果我们希望 API 是无状态的,不推荐使用 Cookie。 使用 Cookie 的本质是用户第一次访问时服务器会分配一个 Session ID,后面的请求中客户端都会带上这个 ID 作为当前用户的标志,因为 HTTP 本身是无状态的,Cookie 属于一种内建于浏览器中实现状态的方式。如果我们的 API 是用来给客户端使用的,强行要求 API 的调用者管理 Cookie 也可以完成任务。 在一些遗留或者不是标准的认证实现的项目中,我们依然可以看到这些做法,快速地实现认证。 1. 使用 cookie,例如 web 项目中 ajax 的方式 2. 使用 session ID 或 hash 作为 token,但将 token 放入 header 中传递 3. 将生成的 token (可能是 JWT)放入 cookie 传递,利用 HTTPonly 和 Secure 标签保护 token ### 选择合适的认证方式 随着微服务的发展,API 的设计不仅仅是面向 WEB 或者 Mobile APP,还有 BFF(Backend for Frontend)和 Domain API 的认证,以及第三方服务的集成。 客户端到服务器之间认证和服务器到服务器之间认证是不同的。 我们把终端用户(Human)参与的通信,叫做 Human-to-machine (H2M),服务器与服务器之间的通信叫做 Machine-to-machine (M2M)。 H2M 的通信需要更高的安全性,M2M 的通信天然比 H2M 安全,因此更多的强调性能,在不同的场合下选择合适的认证技术就显得特别重要。例如 HTTP Basic Authentication 用来作为 H2M 认证显得有些落后,但是在 M2M 中被大量使用。 **另外值得一提的是,H2M 这种通信方式下,客户端不受控制,由于无法自主分发密匙,认证通信的安全高度依赖 HTTPS。** 从一个宏观的角度看待他们的关系,对我们技术选型非常有帮助。 ![20241229154732_uUfYDY8S.webp](https://cdn.dong4j.site/source/image/20241229154732_uUfYDY8S.webp) ### 术语表 1. Browser fingerprinting 通过查询浏览器的代理字符串,屏幕色深,语言等,然后这些值通过散列函数传递产生指纹,不需要通过 Cookie 就可以识别浏览器 2. MAC(Message authentication code) 在密码学中,讯息鉴别码,是经过特定算法后产生的一小段资讯,检查某段讯息的完整性 3. HOTP(HMAC-based One-time Password algorithm)基于散列消息验证码的一次性密码算法 4. Two-step verification 是一种认证方法,使用两种不同的元素,合并在一起,来确认使用者的身份,是多因素验证中的一个特例 5. OTP (One time password )一次性密码,例如注册邮件和短信中的认证码 ## [Java开发中遇到的一些常见问题](https://blog.dong4j.site/posts/6dc04ed2.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### Maven 相关问题 > 由于未统一管理多个模块的 jar 依赖, 每个模块都各自为政; > 模块之间的配置相互拷贝, 有用的没用的都拷了; > 升级版本麻烦, 每个相关模块都得改版本. ![20241229154732_FDHGxHtL.webp](https://cdn.dong4j.site/source/image/20241229154732_FDHGxHtL.webp) **1. 重复的依赖** redis-cache 模块的 pom.xml ![20241229154732_ugonm1Ts.webp](https://cdn.dong4j.site/source/image/20241229154732_ugonm1Ts.webp) ![20241229154732_1fVuV7vs.webp](https://cdn.dong4j.site/source/image/20241229154732_1fVuV7vs.webp) **2. 重复的插件** mamagesystem 模块的 pom.xml ![20241229154732_0GDyIBF8.webp](https://cdn.dong4j.site/source/image/20241229154732_0GDyIBF8.webp) **3. 依赖冲突** mamagesystem 模块的 pom.xml ![20241229154732_lWO9h9OG.webp](https://cdn.dong4j.site/source/image/20241229154732_lWO9h9OG.webp) **4. 重复的配置** ![20241229154732_h4IF53kV.webp](https://cdn.dong4j.site/source/image/20241229154732_h4IF53kV.webp) #### 导致的问题 有可能导致出现以下几种异常: 1. java.lang.ClassNotFoundException 2. java.lang.NoSuchMethodError 3. java.lang.NoClassDefFoundError #### 解决方案 > 所有模块使用一个父模块来管理 > 将重复配置迁移到 parent pom.xml 中; > 使用 `dependencyManagement` 来统一管理 jar 依赖; > 使用 `pluginManagement` 统一管理插件依赖; > 所有 jar 依赖的版本全部使用 `properties` 管理; > 使用 `excludes` 排除冲突的 jar 包; ## 代码问题 ### 1. 代码不规范 1. 格式化风格不统一 _会造成大量代码冲突_ 一个项目组应该使用统一的格式化风格 (google-java-style) 2. 编码不统一 _造成不同开发平台的同学乱码_ 3. 命名不规范; _不符合 Javaer 的核心价值观, 审美观_ **变量名:** 方法名你不用驼峰命名法, 你用下划线分隔, 你咋不去写 Python 呢 ### 2. 代码无注释 1. 类注释 对已有的类添加注释 ```java /** *

Company: xxxx公司

*

Description: callout 号码解析处理类

* * @author xxx * @date 2018-05-31 21:40 */ ``` 新建类时自动添加 ```java /** *

Company: xxxx公司

*

Description: ${description}

* * @author dong4j * @date ${YEAR}-${MONTH}-${DAY} ${HOUR}:${MINUTE} * @email dong4j@gmail.com */ ``` 2. 方法注释 ```java /** * 根据规则将数据插入相应的表 * * @param batchOfTask the batch of task 批次任务详情 * @param telNos the tel nos zip 包中处理后的号码 * @throws InterruptedException the interrupted exception sleep 失败时抛出此异常 */ ``` 3. 代码注释 成员变量用 `/** 注释 */` 行注释放在代码上一行, 不要使用行尾注释 大段代码注释后 建议使用 ```java // 多行注释代码 // ``` 或者 ```java //region Description 多行注释代码 //endregion ``` ### 3. 开发文档不全 应该为每个模块添加一个 `readme.md` 和 `changelog.md` 文件 `readme.md` 用于记录该模块的一些基本信息, 比如用到的技术, 功能, 使用方法, 部署方式等; `changelog.md` 用于记录该模块的更新记录; **便于维护与交接** ### 4. 代码的优化 **1. String 拼接** ```java List listSong = coreSongService.findTopicRingBoxByParam(params); if (listSong != null && listSong.size() > 0) { for (CoreSong c : listSong) { taskSongIds += c.getId() + "|"; } model.addAttribute("listSong", listSong); } ``` **2. 日志相关** **logger.error 的错误用法** ![20241229154732_kGZ5xPz5.webp](https://cdn.dong4j.site/source/image/20241229154732_kGZ5xPz5.webp) **logger.error 的正确使用姿势** ![20241229154732_45ESzjrR.webp](https://cdn.dong4j.site/source/image/20241229154732_45ESzjrR.webp) **使用 printStackTrace()** ![20241229154732_mSA1vWPz.webp](https://cdn.dong4j.site/source/image/20241229154732_mSA1vWPz.webp) **不使用 printStackTrace()** ![20241229154732_sAgeQ39m.webp](https://cdn.dong4j.site/source/image/20241229154732_sAgeQ39m.webp) > 应用中不可直接使用日志系统(Log4j, Logback)中的 API > 而是使用 SLF4J 中的 API,使用门面模式的日志框架,有利于维护和各个类的日志处理方式统一 ```java import org.slf4j.Logger; import org.slf4j.LoggerFactory; ... private static final Logger logger = LoggerFactory.getLogger(Abc.class); ``` **建议使用 log4j2 日志代替 log4j** 日志拼接的耗时对比 ```java @Test public void testLog1() { String[] traceIds = {"天王盖地虎", "小鸡炖蘑菇"}; StopWatch stopwatch = new StopWatch(); stopwatch.start(); for (int i = 0; i < 10000000; i++) { log.info("traceId[1] = " + traceIds[0] + "traceId[1] = " + traceIds[1] + "traceId[1] = " + traceIds[0] + "traceId[1] = " + traceIds[1]); } stopwatch.stop(); System.out.println(stopwatch.prettyPrint()); } @Test public void testLog2() { String[] traceIds = {"天王盖地虎", "小鸡炖蘑菇"}; StopWatch stopwatch = new StopWatch(); stopwatch.start(); for (int i = 0; i < 10000000; i++) { if (log.isInfoEnabled()) { log.info("traceId[1] = " + traceIds[0] + "traceId[1] = " + traceIds[1] + "traceId[1] = " + traceIds[0] + "traceId[1] = " + traceIds[1]); } } stopwatch.stop(); System.out.println(stopwatch.prettyPrint()); } @Test public void testLog3() { String[] traceIds = {"天王盖地虎", "小鸡炖蘑菇"}; StopWatch stopwatch = new StopWatch(); stopwatch.start(); for (int i = 0; i < 10000000; i++) { log.info("traceId[1] = {}, traceId[1] = {}, traceId[1] = {}, traceId[1] = {}", traceIds[0], traceIds[1], traceIds[0], traceIds[1]); } stopwatch.stop(); System.out.println(stopwatch.prettyPrint()); } ``` ```java StopWatch '': running time (millis) = 1268 ----------------------------------------- ms % Task name ----------------------------------------- 01268 100% StopWatch '': running time (millis) = 22 ----------------------------------------- ms % Task name ----------------------------------------- 00022 100% StopWatch '': running time (millis) = 38 ----------------------------------------- ms % Task name ----------------------------------------- 00038 100% ``` ## 推荐的工具 ### Markdown 工具 Mac 下有很多这种工具, 目前使用 MWeb; Windowns 的话, 建议使用 Typora, 配合 PicGo 上传图片; ### Chrome 插件 **1. 流程图制作工具** ![20241229154732_UO1YJr9v.webp](https://cdn.dong4j.site/source/image/20241229154732_UO1YJr9v.webp) **2. Imagus** 鼠标悬浮停留在图片上,自动弹出放大图片,不用再在新链接中打开看大图了。 **3. SimpleExtManager** ![20241229154732_EiDncgsn.webp](https://cdn.dong4j.site/source/image/20241229154732_EiDncgsn.webp) **4. Octotree** 同类插件 `GitCodeTree` ![20241229154732_RUf9YYSd.webp](https://cdn.dong4j.site/source/image/20241229154732_RUf9YYSd.webp) **5. Session Buddy** 下班了, 资料没看完, 可以保存下来 ![20241229154732_HVHOpUjd.webp](https://cdn.dong4j.site/source/image/20241229154732_HVHOpUjd.webp) ### Intellij IDEA 插件 **1. JRebel for IntelliJ** 热部署插件,Java WEB 开发必备,节省生命。**墙裂推荐** **2. Lombok Plugin** 使用注解自动生成代码,偷懒者必备。 **3. RestfulToolkit** Java WEB 开发必备,再也不用全局搜索 RequestMapping 了 ![20241229154732_hp123tER.webp](https://cdn.dong4j.site/source/image/20241229154732_hp123tER.webp) 直接搜索 RequestMapping ![20241229154732_dWncftDM.webp](https://cdn.dong4j.site/source/image/20241229154732_dWncftDM.webp) **4. Translation** 翻译插件,很好用。 **墙裂推荐** ![20241229154732_GcBDf7PW.webp](https://cdn.dong4j.site/source/image/20241229154732_GcBDf7PW.webp) **5. Grep Console** 高亮 log 不同级别日志,看日志的时候一目了然。 ![20241229154732_kTuQuaqO.webp](https://cdn.dong4j.site/source/image/20241229154732_kTuQuaqO.webp) **6. GenerateSerialVersionUID** Alt + Insert 生成 serialVersionUID **7. Alibaba Java Coding Guidelines** 阿里巴巴规范 **8. Rainbow Brackets** 彩虹括号。自动给代码块内花括号和括号加色,让视野更加注意在代码上。 ![20241229154732_EOEuuP5M.webp](https://cdn.dong4j.site/source/image/20241229154732_EOEuuP5M.webp) **9. Maven Helper** Maven 插件,安装后可查看依赖以及冲突,一目了然。 ![20241229154732_l9LObZVr.webp](https://cdn.dong4j.site/source/image/20241229154732_l9LObZVr.webp) **12. zookeeper** 就是太慢了, 没有用异步接口, 经常卡住 ![20241229154732_SEm37MkN.webp](https://cdn.dong4j.site/source/image/20241229154732_SEm37MkN.webp) **13. GenerateAllSetter** new POJO 类的快速生成 set 方法 ```java User user = new User(); // then alt+enter on User // will generate following user.setName(""); user.setPassword(""); ``` ### 终端工具 **1. zsh** 1. 大小写字母自动更正 2. 更强大的 tab 补全 3. 智能的切换目录 4. 命令选项补齐 5. kill 进程号补齐 **2. oh my zsh** 1. 主题 2. 插件 ## [Spring提供的不同序列化方式大对比](https://blog.dong4j.site/posts/ea742350.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 缓存服务组件 依赖于: 1. jedis 2. spring-data-redis 3. spring-session-data-redis redis 集群使用的是 ShardedJedisPool, redis 3.x 后自带集群负载 ## jar 中重要的类 1. JedisConnectionFactory 用于获取 jedis 实例,从而操作 redis 2. ShardedJedisPool 用于连接 redis 集群 ## cache 重要的类 1. RedisDataSource 使用 JedisConnectionFactory 从 ShardedJedisPool 连接池中获取 jedis 2. RedisClientTemplate 依赖 RedisDataSource 操作 redis 的具体模板方法 3. RedisCacheServiceImpl 对 RedisClientTemplate 再次封装 ## JedisPool(非切片链接池) 和 ShardedJedisPool(切片链接池) 有什么区别 JedisPool 连一台 Redis,ShardedJedisPool 连 Redis 集群, 通过一致性哈希算法决定把数据存到哪台上,算是一种客户端负载均衡, 所以添加是用这个(Redis 3.0 之后支持服务端负载均衡) 删除那个问题的答案就显而易见了,总不可能随机找一个 Redis 服务端去删吧 ## 集群 session 共享机制 现在集群中使用的 Session 共享机制有两种,分别是 session 复制和 session 粘性。 **Session 复制** 该种方式下,负载均衡器会根据各个 node 的状态,把每个 request 进行分发,使用这样的测试,必须在多个 node 之间复制用户的 session,实时保持整个集群中用户的状态同步。 其中 jboss 的实现原理是使用拦截器,根据用户的同步策略拦截 request,做完同步处理后再交给 server 产生响应。 优点:session 不会被绑定到具体的 node,只要有一个 node 存活,用户状态就不会丢失,集群能够正常工作。 缺点:node 之间通信频繁,响应速度有影响,高并发情况下性能下降比较厉害。 **Session 粘性** 该种方式下,当用户发出第一个 request 后,负载均衡器动态的把该用户分配至到某个节点,并记录该节点的 jvm 路由,以后该用户的所有的 request 都会绑定到这个 jvm 路由,用户只会和该 server 交互。 优点:响应速度快,多个节点之间无需通信 缺点:某个 node 死掉之后,它负责的所有用户都会丢失 session。 改进:servlet 容器在新建、更新或维护 session 时,向其它 no de 推送修改信息。这种方式在高并发情况下同样会影响效率。 以上这两种方式都需要负载均衡器和 Servlet 容器的支持,在部署时需要单独配置负载均衡器和 Servelt 容器。 **基于分布式缓存的 session 共享机制** 将会话 Session 统一存储在分布式缓存中,并使用 Cookie 保持客户端和服务端的联系, 每一次会话开始就生成一个 GUID 作为 SessionID,保存在客户端的 Cookie 中,在服务端通过 SessionID 向分布式缓存中获取 session。 实现思路:通过一个 Filter 过滤所有的 request 请求,在 Filter 创建 request 和 session 的代理,通过代理使用分布式缓存对 session 进行操作。这样实现对现有应用中对 request 对象的操作透明 扩展指定 server 利用 Servlet 容器提供的插件功能,自定义 HttpSession 的创建和管理策略,并通过配置的方式替换掉默认的策略。 不过这种方式有个缺点,就是需要耦合 Tomcat/Jetty 等 Servlet 容器的代码。 这方面其实早就有开源项目了,例如 memcached-session-manager ,以及 tomcat-redis-session-manager 。暂时都只支持 Tomcat6/Tomcat7。 设计一个 Filter 利用 HttpServletRequestWrapper,实现自己的 getSession() 方法,接管创建和管理 Session 数据的工作。spring-session 就是通过这样的思路实现的。 **spring-session** SpringSession 的几个关键类 1. SessionRepositoryFilter(order 是 Integer.MIN_VALUE + 50) 2. SessionRepositoryRequestWrapper 与 SessionRepositoryResponseWrapper,通过 SessionRepository 去操纵 session 3. SessionRepository 4. CookieHttpSessionStrategy ## redis 过期数据的删除方式 1. 定时删除:在设置键的过期时间的同时,创建一个定时器 (timer),让定时器在键的过期时间来临时,立即执行对键的删除操作。 2. 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。 3. 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。 redis 实际是以懒性删除 + 定期删除这种策略组合来实现过期键删除的, 导致 Spring 需要采用及时删除的策略(定时轮询),在过期的时候,访问一下该 key,然后及时触发惰性删除 Spring 的轮询如何保证时效性 ```java @Scheduled(cron = "0 * * * * *") //每分钟跑一次,每次清除前一分钟的过期键 public void cleanExpiredSessions() { long now = system.currentTimeMillis(); long prevMin = roundDownMinute(now); if (logger.isDebugEnabled()) { logger.debug("Cleaning up sessions expiring at " + new Date(prevMin)); } String expirationKey = getExpirationKey(prevMin); Set < String > sessionsToExpire = expirationRedisOperations.boundSetOps(expirationKey).members(); expirationRedisOperations.delete(expirationKey); for (String session: sessionsToExpire) { String sessionKey = getSessionKey(session); touch(sessionKey); } } ``` 这里的 touch 操作就是访问该 key,然后触发 redis 删除。 ```java /** * By trying to access the session we only trigger a deletion if it the TTL is expired. This is done to handle * https://github.com/spring-projects/spring-session/issues/93 * * @param key */ private void touch(String key) { sessionRedisOperations.hasKey(key); } ``` 主动删除 session ```java public void onDelete(ExpiringSession session) { long toExpire = roundUpToNextMinute(expiresInMillis(session)); String expireKey = getExpirationKey(toExpire); expirationRedisOperations.boundSetOps(expireKey).remove(session.getId()); } ``` 延长 session 过期时间 ```java public void onExpirationupdated(Long originalExpirationTimeInMilli, ExpiringSession session) { if (originalExpirationTimeInMilli != null) { long originalRoundedUp = roundUpToNextMinute(originalExpirationTimeInMilli); String expireKey = getExpirationKey(originalRoundedUp); expirationRedisOperations.boundSetOps(expireKey).remove(session.getId()); } long toExpire = roundUpToNextMinute(expiresInMillis(session)); String expireKey = getExpirationKey(toExpire); BoundSetOperations < String, String > expireOperations = expirationRedisOperations.boundSetOps(expireKey); expireOperations.add(session.getId()); long sessionExpireInSeconds = session.getMaxInactiveIntervalInSeconds(); String sessionKey = getSessionKey(session.getId()); expireOperations.expire(sessionExpireInSeconds + 60, TimeUnit.SECONDS); sessionRedisOperations.boundHashOps(sessionKey).expire(sessionExpireInSeconds, TimeUnit.SECONDS); } ``` ## 替换序列化方式 使用 GenericJackson2JsonRedisSerializer 替换 JdkSerializationRedisSerializer 使得存入 redis 的数据显示更友好 存在的问题 数据迁移 原来存在于 redis 中的数据 不能使用 GenericJackson2JsonRedisSerializer 反序列化 ## jedisPool 与 RedisTemplate 的区别 jedisPool 是直接通过获取 jedis 来操作 redis 而 RedisTemplate 是通过 spring 由 IOC 来配置依赖关系 ## Spring 提供的 redis 序列化方式的区别 1. JdkSerializationRedisSerializer 2. GenericJackson2JsonRedisSerializer ## [JDK 从5 到 10 的新特性介绍](https://blog.dong4j.site/posts/9c7f49a2.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## JDK5 新特性 Java5 开发代号为 Tiger(老虎),于 2004-09-30 发行 ### 1、泛型 所谓类型擦除指的就是 Java 源码中的范型信息只允许停留在编译前期,而编译后的字节码文件中将不再保留任何的范型信息。也就是说,范型信息在编译时将会被全部删除,其中范型类型的类型参数则会被替换为 Object 类型,并在实际使用时强制转换为指定的目标数据类型。而 C++中的模板则会在编译时将模板类型中的类型参数根据所传递的指定数据类型生成相对应的目标代码。 ```java Map squares = new HashMap(); ``` - 通配符类型:避免 unchecked 警告,问号表示任何类型都可以接受 ```java public void printList(List list, PrintStream out) throws IOException { for (Iterator i = list.iterator(); i.hasNext(); ) { out.println(i.next().toString()); } } ``` - 限制类型 ```java public static double sum(Box box1,Box box2){ double total = 0; for (Iterator i = box1.contents.iterator(); i.hasNext(); ) { total = total + i.next().doubleValue(); } for (Iterator i = box2.contents.iterator(); i.hasNext(); ) { total = total + i.next().doubleValue(); } return total; } ``` ### 2、枚举 - EnumMap ```java public void testEnumMap(PrintStream out) throws IOException { // Create a map with the key and a String message EnumMap antMessages = new EnumMap(AntStatus.class); // Initialize the map antMessages.put(AntStatus.INITIALIZING, "Initializing Ant..."); antMessages.put(AntStatus.COMPILING, "Compiling Java classes..."); antMessages.put(AntStatus.COPYING, "Copying files..."); antMessages.put(AntStatus.JARRING, "JARring up files..."); antMessages.put(AntStatus.ZIPPING, "ZIPping up files..."); antMessages.put(AntStatus.DONE, "Build complete."); antMessages.put(AntStatus.ERROR, "Error occurred."); // Iterate and print messages for (AntStatus status : AntStatus.values() ) { out.println("For status " + status + ", message is: " + antMessages.get(status)); } } ``` - switch 枚举 ```java public String getDescription() { switch(this) { case ROSEWOOD: return "Rosewood back and sides"; case MAHOGANY: return "Mahogany back and sides"; case ZIRICOTE: return "Ziricote back and sides"; case SPRUCE: return "Sitka Spruce top"; case CEDAR: return "Wester Red Cedar top"; case AB_ROSETTE: return "Abalone rosette"; case AB_TOP_BORDER: return "Abalone top border"; case IL_DIAMONDS: return "Diamonds and squares fretboard inlay"; case IL_DOTS: return "Small dots fretboard inlay"; default: return "Unknown feature"; } } ``` ## 3、自动拆箱/装箱 将 primitive 类型转换成对应的 wrapper 类型:Boolean、Byte、Short、Character、Integer、Long、Float、Double ## 4、可变参数 ```java private String print(Object... values) { StringBuilder sb = new StringBuilder(); for (Object o : values) { sb.append(o.toString()) .append(" "); } return sb.toString(); } ``` ## 5、注解 - Inherited 表示该注解是否对类的子类继承的方法等起作用 - Target 类型 - Rentation 表示 annotation 是否保留在编译过的 class 文件中还是在运行时可读。 ```java @Documented @Inherited @Retention(RetentionPolicy.RUNTIME) public @interface InProgress { } ``` ## 6、增强 for 循环 for/in for/in 循环办不到的事情: (1)遍历同时获取 index (2)集合逗号拼接时去掉最后一个 (3)遍历的同时删除元素 ## 7. 静态导入 ```java import static java.lang.System.err; import static java.lang.System.out; err.println(msg); ``` ## 8. print 输出格式化 ```java System.out.println("Line %d: %s%n", i++, line); ``` ## 9. 并发支持(JUC) - 线程池 - uncaught exception(可以抓住多线程内的异常) ```java class SimpleThreadExceptionHandler implements Thread.UncaughtExceptionHandler { public void uncaughtException(Thread t, Throwable e) { System.err.printf("%s: %s at line %d of %s%n", t.getName(), e.toString(), e.getStackTrace()[0].getLineNumber(), e.getStackTrace()[0].getFileName()); } ``` - blocking queue(BlockingQueue) - JUC 类库 ## 10. Arrays、Queue、线程安全 StringBuilder - Arrays 工具类 ```java Arrays.sort(myArray); Arrays.toString(myArray) Arrays.binarySearch(myArray, 98) Arrays.deepToString(ticTacToe) Arrays.deepEquals(ticTacToe, ticTacToe3) ``` - Queue(Queue 接口与 List、Set 同一级别,都是继承了 Collection 接口。LinkedList 实现了 Deque 接 口。) 避开集合的 add/remove 操作,使用 offer、poll 操作(不抛异常) ```java Queue q = new LinkedList(); //采用它来实现queue ``` - Override 返回类型 - 单线程 StringBuilder - java.lang.instrument JDK5 是 java 史上最重要的升级之一,具有非常重要的意义,虽然语法糖非常多。但可以使得我们的代码更加健壮,更加优雅。 ## JDK6 新特性 Java6 开发代号为 Mustang(野马),于 2006-12-11 发行. 1、Web Services 优先支持编写 XML web service 客户端程序。你可以用过简单的 annotaion 将你的 API 发布成.NET 交互的 web services. Mustang 添加了新的解析和 XML 在 Java object-mapping APIs 中, 之前只在 Java EE 平台实现或者 Java Web Services Pack 中提供. 2、Scripting(开启 JS 的支持,算是比较有用的) 现在你可以在 Java 源代码中混入 JavaScript 了,这对开发原型很有有用,你也可以插入自己的脚本引擎。 3、Database Mustang 将联合绑定 Java DB (Apache Derby). JDBC 4.0 增加了许多特性例如支持 XML 作为 SQL 数据类型,更好的集成 Binary Large OBjects (BLOBs) 和 Character Large OBjects (CLOBs) . 4、More Desktop APIs GUI 开发者可以有更多的技巧来使用 SwingWorker utility ,以帮助 GUI 应用中的多线程。, JTable 分类和过滤,以及添加 splash 闪屏。 很显然,这对于主攻服务器开发的 Java 来说,并没有太多吸引力 5、Monitoring and Management. 绑定了不是很知名的 memory-heap 分析工具 Jhat 来查看内核导出。 6、Compiler Access(这个很厉害) compiler API 提供编程访问 javac,可以实现进程内编译,动态产生 Java 代码。 7、Pluggable Annotation 8、Desktop Deployment. Swing 拥有更好的 look-and-feel , LCD 文本呈现, 整体 GUI 性能的提升。Java 应用程序可以和本地平台更好的集成,例如访问平台的系统托盘和开始菜单。Mustang 将 Java 插件技术和 Java Web Start 引擎统一了起来。 9、Security XML-数字签名(XML-DSIG) APIs 用于创建和操纵数字签名); 新的方法来访问本地平台的安全服务 10、The -ilities(很好的习惯) 质量,兼容性,稳定性。 80,000 test cases 和数百万行测试代码(只是测试活动中的一个方面). Mustang 的快照发布已经被下载 15 个月了,每一步中的 Bug 都被修复了,表现比 J2SE 5 还要好。 ## JDK7 新特性 Java7 开发代号是 Dolphin(海豚),于 2011-07-28 发行. ### 1. switch 中添加对 String 类型的支持 ```java public String generate(String name, String gender) { String title = ""; switch (gender) { case "男": title = name + " 先生"; break; case "女": title = name + " 女士"; break; default: title = name; } return title; } ``` 编译器在编译时先做处理: ①case 仅仅有一种情况。直接转成 if。 ② 假设仅仅有一个 case 和 default,则直接转换为 if…else…。 ③ 有多个 case。先将 String 转换为 hashCode,然后相应的进行处理,JavaCode 在底层兼容 Java7 曾经版本号。 ### 2. 数字字面量的改进 Java7 前支持十进制(123)、八进制(0123)、十六进制(0X12AB) Java7 添加二进制表示(0B11110001、0b11110001) 数字中可加入分隔符 Java7 中支持在数字量中间添加 `_` 作为分隔符。更直观,如(12_123_456)。下划线仅仅能在数字中间。编译时编译器自己主动删除数字中的下划线。 ```java int one_million = 1_000_000; ``` ### 3. 异常处理(捕获多个异常) try-with-resources #### catch 子句能够同一时候捕获多个异常 ```java public void testSequence() { try { Integer.parseInt("Hello"); } catch (NumberFormatException | RuntimeException e) { //使用'|'切割,多个类型,一个对象e } } ``` #### try-with-resources 语句 Java7 之前须要在 finally 中关闭 socket、文件、数据库连接等资源; Java7 中在 try 语句中申请资源,实现资源的自己主动释放(资源类必须实现 java.lang.AutoCloseable 接口,一般的文件、数据库连接等均已实现该接口,close 方法将被自己主动调用) ```java public void read(String filename) throws IOException { try (BufferedReader reader = new BufferedReader(new FileReader(filename))) { StringBuilder builder = new StringBuilder(); String line = null; while((line=reader.readLine())!=null){ builder.append(line); builder.append(String.format("%n")); } return builder.toString(); } } ``` ### 4. 增强泛型推断 ```java // 之前 Map> map = new HashMap>(); // 之后 Map> anagrams = new HashMap<>(); ``` ### 5. NIO2.0(AIO)新 IO 的支持 - bytebuffer ```java public class ByteBufferUsage { public void useByteBuffer() { ByteBuffer buffer = ByteBuffer.allocate(32); buffer.put((byte)1); buffer.put(new byte[3]); buffer.putChar('A'); buffer.putFloat(0.0f); buffer.putLong(10, 100L); System.out.println(buffer.getChar(4)); System.out.println(buffer.remaining()); } public void byteOrder() { ByteBuffer buffer = ByteBuffer.allocate(4); buffer.putInt(1); buffer.order(ByteOrder.LITTLE_ENDIAN); buffer.getInt(0); //值为16777216 } public void compact() { ByteBuffer buffer = ByteBuffer.allocate(32); buffer.put(new byte[16]); buffer.flip(); buffer.getInt(); buffer.compact(); int pos = buffer.position(); } public void viewBuffer() { ByteBuffer buffer = ByteBuffer.allocate(32); buffer.putInt(1); IntBuffer intBuffer = buffer.asIntBuffer(); intBuffer.put(2); int value = buffer.getInt(); //值为2 } /** * @param args the command line arguments */ public static void main(String[] args) { ByteBufferUsage bbu = new ByteBufferUsage(); bbu.useByteBuffer(); bbu.byteOrder(); bbu.compact(); bbu.viewBuffer(); } } ``` - filechannel ```java public class FileChannelUsage { public void openAndWrite() throws IOException { FileChannel channel = FileChannel.open(Paths.get("my.txt"), StandardOpenOption.CREATE, StandardOpenOption.WRITE); ByteBuffer buffer = ByteBuffer.allocate(64); buffer.putChar('A').flip(); channel.write(buffer); } public void readWriteAbsolute() throws IOException { FileChannel channel = FileChannel.open(Paths.get("absolute.txt"), StandardOpenOption.READ, StandardOpenOption.CREATE, StandardOpenOption.WRITE); ByteBuffer writeBuffer = ByteBuffer.allocate(4).putChar('A').putChar('B'); writeBuffer.flip(); channel.write(writeBuffer, 1024); ByteBuffer readBuffer = ByteBuffer.allocate(2); channel.read(readBuffer, 1026); readBuffer.flip(); char result = readBuffer.getChar(); //值为'B' } /** * @param args the command line arguments */ public static void main(String[] args) throws IOException { FileChannelUsage fcu = new FileChannelUsage(); fcu.openAndWrite(); fcu.readWriteAbsolute(); } } ``` ### 6. JSR292 与 InvokeDynamic JSR 292: Supporting Dynamically Typed Languages on the JavaTM Platform,支持在 JVM 上运行动态类型语言。在字节码层面支持了 InvokeDynamic。 - 方法句柄 MethodHandle ```java public class ThreadPoolManager { private final ScheduledExecutorService stpe = Executors .newScheduledThreadPool(2); private final BlockingQueue> lbq; public ThreadPoolManager(BlockingQueue> lbq_) { lbq = lbq_; } public ScheduledFuture run(QueueReaderTask msgReader) { msgReader.setQueue(lbq); return stpe.scheduleAtFixedRate(msgReader, 10, 10, TimeUnit.MILLISECONDS); } private void cancel(final ScheduledFuture hndl) { stpe.schedule(new Runnable() { public void run() { hndl.cancel(true); } }, 10, TimeUnit.MILLISECONDS); } /** * 使用传统的反射api */ public Method makeReflective() { Method meth = null; try { Class[] argTypes = new Class[]{ScheduledFuture.class}; meth = ThreadPoolManager.class.getDeclaredMethod("cancel", argTypes); meth.setAccessible(true); } catch (IllegalArgumentException | NoSuchMethodException | SecurityException e) { e.printStackTrace(); } return meth; } /** * 使用代理类 * @return */ public CancelProxy makeProxy() { return new CancelProxy(); } /** * 使用Java7的新api,MethodHandle * invoke virtual 动态绑定后调用 obj.xxx * invoke special 静态绑定后调用 super.xxx * @return */ public MethodHandle makeMh() { MethodHandle mh; MethodType desc = MethodType.methodType(void.class, ScheduledFuture.class); try { mh = MethodHandles.lookup().findVirtual(ThreadPoolManager.class, "cancel", desc); } catch (NoSuchMethodException | IllegalAccessException e) { throw (AssertionError) new AssertionError().initCause(e); } return mh; } public static class CancelProxy { private CancelProxy() { } public void invoke(ThreadPoolManager mae_, ScheduledFuture hndl_) { mae_.cancel(hndl_); } } } ``` - 调用 invoke ```java public class ThreadPoolMain { /** * 如果被继承,还能在静态上下文寻找正确的class */ private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); private ThreadPoolManager manager; public static void main(String[] args) { ThreadPoolMain main = new ThreadPoolMain(); main.run(); } private void cancelUsingReflection(ScheduledFuture hndl) { Method meth = manager.makeReflective(); try { System.out.println("With Reflection"); meth.invoke(hndl); } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { e.printStackTrace(); } } private void cancelUsingProxy(ScheduledFuture hndl) { CancelProxy proxy = manager.makeProxy(); System.out.println("With Proxy"); proxy.invoke(manager, hndl); } private void cancelUsingMH(ScheduledFuture hndl) { MethodHandle mh = manager.makeMh(); try { System.out.println("With Method Handle"); mh.invokeExact(manager, hndl); } catch (Throwable e) { e.printStackTrace(); } } private void run() { BlockingQueue> lbq = new LinkedBlockingQueue<>(); manager = new ThreadPoolManager(lbq); final QueueReaderTask msgReader = new QueueReaderTask(100) { @Override public void doAction(String msg_) { if (msg_ != null) System.out.println("Msg recvd: " + msg_); } }; ScheduledFuture hndl = manager.run(msgReader); cancelUsingMH(hndl); // cancelUsingProxy(hndl); // cancelUsingReflection(hndl); } } ``` ### 7. Path 接口(重要接口更新) - Path ```java public class PathUsage { public void usePath() { Path path1 = Paths.get("folder1", "sub1"); Path path2 = Paths.get("folder2", "sub2"); path1.resolve(path2); //folder1\sub1\folder2\sub2 path1.resolveSibling(path2); //folder1\folder2\sub2 path1.relativize(path2); //..\..\folder2\sub2 path1.subpath(0, 1); //folder1 path1.startsWith(path2); //false path1.endsWith(path2); //false Paths.get("folder1/./../folder2/my.text").normalize(); //folder2\my.text } /** * @param args the command line arguments */ public static void main(String[] args) { PathUsage usage = new PathUsage(); usage.usePath(); } } ``` - DirectoryStream ```java public class ListFile { public void listFiles() throws IOException { Path path = Paths.get(""); try (DirectoryStream stream = Files.newDirectoryStream(path, "*.*")) { for (Path entry: stream) { //使用entry System.out.println(entry); } } } /** * @param args the command line arguments */ public static void main(String[] args) throws IOException { ListFile listFile = new ListFile(); listFile.listFiles(); } } ``` - Files ```java public class FilesUtils { public void manipulateFiles() throws IOException { Path newFile = Files.createFile(Paths.get("new.txt").toAbsolutePath()); List content = new ArrayList(); content.add("Hello"); content.add("World"); Files.write(newFile, content, Charset.forName("UTF-8")); Files.size(newFile); byte[] bytes = Files.readAllBytes(newFile); ByteArrayOutputStream output = new ByteArrayOutputStream(); Files.copy(newFile, output); Files.delete(newFile); } /** * @param args the command line arguments */ public static void main(String[] args) throws IOException { FilesUtils fu = new FilesUtils(); fu.manipulateFiles(); } } ``` - WatchService ```java public class WatchAndCalculate { public void calculate() throws IOException, InterruptedException { WatchService service = FileSystems.getDefault().newWatchService(); Path path = Paths.get("").toAbsolutePath(); path.register(service, StandardWatchEventKinds.ENTRY_CREATE); while (true) { WatchKey key = service.take(); for (WatchEvent event : key.pollEvents()) { Path createdPath = (Path) event.context(); createdPath = path.resolve(createdPath); long size = Files.size(createdPath); System.out.println(createdPath + " ==> " + size); } key.reset(); } } /** * @param args the command line arguments */ public static void main(String[] args) throws Throwable { WatchAndCalculate wc = new WatchAndCalculate(); wc.calculate(); } } ``` ### 8. fork/join 计算框架 Java7 提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。 该框架为 Java8 的并行流打下了坚实的基础 ## JDK8 新特性 Java5 开发代号为 Tiger(老虎),于 2004-09-30 发行 ### 1. Lambda 表达式 Lambda 表达式,也可称为闭包,它是推动 Java 8 发布的最重要新特性。 Lambda 允许把函数作为一个方法的参数(函数作为参数传递进方法中)。 使用 Lambda 表达式可以使代码变的更加简洁紧凑。 Lambda 表达式可以说是 Java 8 最大的卖点,她将函数式编程引入了 Java。Lambda 允许把函数作为一个方法的参数,或者把代码看成数据。 一个 Lambda 表达式可以由用逗号分隔的参数列表、–>符号与函数体三部分表示。例如: ```java Arrays.asList( "p", "k", "u","f", "o", "r","k").forEach( e -> System.out.println( e ) ); ``` 为了使现有函数更好的支持 Lambda 表达式,Java 8 引入了函数式接口的概念。函数式接口就是只有一个方法的普通接口。java.lang.Runnable 与 java.util.concurrent.Callable 是函数式接口最典型的例子。为此,Java 8 增加了一种特殊的注解@FunctionalInterface ### 2. 接口的默认方法与静态方法 我们可以在接口中定义默认方法,使用 default 关键字,并提供默认的实现。所有实现这个接口的类都会接受默认方法的实现,除非子类提供的自己的实现。例如: ```java public interface DefaultFunctionInterface { default String defaultFunction() { return "default function"; } } ``` 我们还可以在接口中定义静态方法,使用 static 关键字,也可以提供实现。例如: ```java public interface StaticFunctionInterface { static String staticFunction() { return "static function"; } } ``` 接口的默认方法和静态方法的引入,其实可以认为引入了 C ++中抽象类的理念,以后我们再也不用在每个实现类中都写重复的代码了。 ### 3. 方法引用(含构造方法引用) 通常与 Lambda 表达式联合使用,可以直接引用已有 Java 类或对象的方法。一般有四种不同的方法引用: 1. 构造器引用。语法是 Class::new,或者更一般的 Class< T >::new,要求构造器方法是没有参数; 2. 静态方法引用。语法是 Class::static_method,要求接受一个 Class 类型的参数; 3. 特定类的任意对象方法引用。它的语法是 Class::method。要求方法是没有参数的; 4. 特定对象的方法引用,它的语法是 instance::method。要求方法接受一个参数,与 3 不同的地方在于,3 是在列表元素上分别调用方法,而 4 是在某个对象上调用方法,将列表元素作为参数传入; ### 4. 重复注解 在 Java 5 中使用注解有一个限制,即相同的注解在同一位置只能声明一次。Java 8 引入重复注解,这样相同的注解在同一地方也可以声明多次。重复注解机制本身需要用@Repeatable 注解。Java 8 在编译器层做了优化,相同注解会以集合的方式保存,因此底层的原理并没有变化。 ### 5. 扩展注解的支持(类型注解) Java 8 扩展了注解的上下文,几乎可以为任何东西添加注解,包括局部变量、泛型类、父类与接口的实现,连方法的异常也能添加注解 ```java private @NotNull String name; ``` ### 6. Optional Java 8 引入 Optional 类来防止空指针异常,Optional 类最先是由 Google 的 Guava 项目引入的。Optional 类实际上是个容器:它可以保存类型 T 的值,或者保存 null。使用 Optional 类我们就不用显式进行空指针检查了 可以通过以下实例来更好的了解 Optional 类的使用: ```java public static void main(String args[]) { Integer value1 = null; Integer value2 = new Integer(10); // 允许传递为null参数 Optional a = Optional.ofNullable(value1); // 如果传递的参数是null,抛出异常NullPointerException Optional b = Optional.of(value2); System.out.println(sum(a, b)); } public Integer sum(Optionala, Optionalb) { // 判断值是否存在 System.out.println("第一个参数值存在:" + a.isPresent()); System.out.println("第二个参数值存在:" + b.isPresent()); // 如果值存在,返回它,否则返回默认值 Integer value1 = a.orElse(new Integer(0)); // 获取值,值需要存在 Integer value2 = b.get(); return value1 + value2; } ``` 输出 ``` 第一个参数值存在: false 第二个参数值存在: true 10 ``` ### 7. Stream Stream API 是把真正的函数式编程风格引入到 Java 中。其实简单来说可以把 Stream 理解为 MapReduce,当然 Google 的 MapReduce 的灵感也是来自函数式编程。她其实是一连串支持连续、并行聚集操作的元素。从语法上看,也很像 linux 的管道、或者链式编程,代码写起来简洁明了,非常酷帅! ### 8. Date/Time API (JSR 310) Java 8 新的 Date-Time API (JSR 310)受 Joda-Time 的影响,提供了新的 java.time 包,可以用来替代 java.util.Date 和 java.util.Calendar。一般会用到 Clock、LocaleDate、LocalTime、LocaleDateTime、ZonedDateTime、Duration 这些类,对于时间日期的改进还是非常不错的。 ### 9. JavaScript 引擎 Nashorn Nashorn 允许在 JVM 上开发运行 JavaScript 应用,允许 Java 与 JavaScript 相互调用。 ### 10. Base64 在 Java 8 中,Base64 编码成为了 Java 类库的标准。Base64 类同时还提供了对 URL、MIME 友好的编码器与解码器。 以下实例演示了 Base64 的使用: ```java @Test public void base64Test() throws UnsupportedEncodingException { // 使用基本编码 String base64encodedString = Base64.getEncoder().encodeToString("java@crankz".getBytes("utf-8")); System.out.println("Base64 编码字符串 (基本) :" + base64encodedString); // 解码 byte[] base64decodedBytes = Base64.getDecoder().decode(base64encodedString); System.out.println("原始字符串: " + new String(base64decodedBytes, "utf-8")); } ``` 输出 ``` Base64 编码字符串 (基本) :amF2YUBjcmFua3o= 原始字符串: java@crankz ``` ### 11. JVM 的 PermGen 空间被移除 取代它的是 [Metaspace(JEP 122)](../jvm/metaspace.md) Java 8 是一次变化巨大的更新,耗费了工程师大量的时间,还借鉴了很多其它语言和类库。我们无法在这里一一详细列举,以后有机会一定给大家详细解读一下。 ### 12. HashMap 改进 ## JDK9 新特性 # 1 Java 平台模块化系统 该特性是 Java 9 最大的一个特性,Java 9 起初的代号就叫 Jigsaw,最近被更改为 Modularity,Modularity 提供了类似于 OSGI 框架的功能,模块之间存在相互的依赖关系,可以导出一个公共的 API,并且隐藏实现的细节,Java 提供该功能的主要的动机在于,减少内存的开销,我们大家都知道,在 JVM 启动的时候,至少会有 30 ~ 60MB 的内存加载,主要原因是 JVM 需要加载 rt.jar,不管其中的类是否被 classloader 加载,第一步整个 jar 都会被 JVM 加载到内存当中去,模块化可以根据模块的需要加载程序运行需要的 class,那么 JVM 是如何知道需要加载那些 class 的呢?这就是在 Java 9 中引入的一个新的文件 module.java 我们大致来看一下一个例子(module-info.java) ## 模块描述器 模块化的 JAR 文件都包含一个额外的模块描述器。在这个模块描述器中, 对其它模块的依赖是通过 “requires” 来表示的。另外, “exports” 语句控制着内部的哪些包是可以被其它模块访问到的。所有不被导出的包默认都封装在模块的里面。如下是一个模块描述器的示例,存在于 “module-info.java” 文件中 ``` module blog { exports com.pluralsight.blog; requires cms; } ``` ![20241229154732_TMNcVlSD.webp](https://cdn.dong4j.site/source/image/20241229154732_TMNcVlSD.webp) ## jlink:Java 连接器 # 2 新工具 ## 2.1 JShell Java 9 首次为 Java 语言提供了 REPL 工具,名为 JShell。我们可以在命令行或者在 IntelliJ IDEA 的终端中运行该 REPL。 ![20241229154732_ixluTsKJ.webp](https://cdn.dong4j.site/source/image/20241229154732_ixluTsKJ.webp) 115-1506508766622.gif 在 Java 8 出来的时候,很多人都喊着,这是要抢夺 Scala 等基于 JVM 动态语言的市场啊,其中有人给出了一个 Java 做不到的方向,那就是 Scala 可以当作脚本语言,Java 可以么?很明显在此之前 Java 不行,ta 也不具备动态性,但是此次 Java 9 却让 Java 也可以像脚本语言一样来运行了,主要得益于 JShell,我们来看一下这个演示 ``` jdk-9\bin>jshell.exe | Welcome to JShell -- Version 9 | For an introduction type: /help intro jshell> "This is my long string. I want a part of it".substring(8,19); $5 ==> "my long string" ``` 这是我们在 Jshell 这个控制台下运行,我们如何运行脚本文件呢? ``` jshell> /save c:\develop\JShell_hello_world.txt jshell> /open c:\develop\JShell_hello_world.txt Hello JShell! ``` ## 2.2 更多的诊断命令 记得在 Java 8 中,放弃了 Jhat 这个命令,但是很快在 Java 9 中增加了一些新的命令,比如我们要介绍到的 jcmd,借助它你可以很好的看到类之间的依赖关系 ``` jdk-9\bin>jcmd 14056 VM.class_hierarchy -i -s java.net.Socket 14056: java.lang.Object/null |--java.net.Socket/null | implements java.io.Closeable/null (declared intf) | implements java.lang.AutoCloseable/null (inherited intf) | |--org.eclipse.ecf.internal.provider.filetransfer.httpclient4.CloseMonitoringSocket | | implements java.lang.AutoCloseable/null (inherited intf) | | implements java.io.Closeable/null (inherited intf) | |--javax.net.ssl.SSLSocket/null | | implements java.lang.AutoCloseable/null (inherited intf) | | implements java.io.Closeable/null (inherited intf) ``` # 3 多版本兼容 Jar 我们最后要来着重介绍的这个特性对于库的维护者而言是个特别好的消息。当一个新版本的 Java 出现的时候,你的库用户要花费数年时间才会切换到这个新的版本。这就意味着库得去向后兼容你想要支持的最老的 Java 版本 (许多情况下就是 Java 6 或者 7)。这实际上意味着未来的很长一段时间,你都不能在库中运用 Java 9 所提供的新特性。幸运的是,多版本兼容 JAR 功能能让你创建仅在特定版本的 Java 环境中运行库程序时选择使用的 class 版本: ``` multirelease.jar ├── META-INF │ └── versions │ └── 9 │ └── multirelease │ └── Helper.class ├── multirelease ├── Helper.class └── Main.class ``` 在上述场景中, multirelease.jar 可以在 Java 9 中使用, 不过 Helper 这个类使用的不是顶层的 multirelease.Helper 这个 class, 而是处在“META-INF/versions/9”下面的这个。这是特别为 Java 9 准备的 class 版本,可以运用 Java 9 所提供的特性和库。同时,在早期的 Java 诸版本中使用这个 JAR 也是能运行的,因为较老版本的 Java 只会看到顶层的这个 Helper 类。 # 4 新的安全性 ## 4.1 数据报传输层安全性(DTLS) ## 4.2 禁用 sha - 1 证书 引入了 SHA-3 的 hash 算法 # 5 核心库新内容 ## 5.1 轻量级的 json 文本处理 api ## 5.2 多线程新内容 新增 ProcessHandle 类,该类提供进程的本地进程 ID、参数、命令、启动时间、累计 CPU 时间、用户、父进程和子进程。这个类还可以监控进程的活力和破坏进程。ProcessHandle。onExit 方法,当进程退出时,复杂未来类的异步机制可以执行一个操作。 包括一个可互操作的发布-订阅框架,以及对 CompletableFuture API 的增强。 在 Java 很早的版本中,提供了 Process 这样的 API 可以获得进程的一些信息,包括 runtime,甚至是用它来执行当前主机的一些命令,但是请大家思考一个问题,你如何获得你当前 Java 运行程序的 PID?很显然通过 Process 是无法获得的,需要借助于 JMX 才能得到,但是在这一次的增强中,你将会很轻松的得到这样的信息,我们来看一个简单的例子 ``` ProcessHandle self = ProcessHandle.current(); long PID = self.getPid(); ProcessHandle.Info procInfo = self.info(); Optional args = procInfo.arguments(); Optional cmd = procInfo.commandLine(); Optional startTime = procInfo.startInstant(); Optional cpuUsage = procInfo.totalCpuDuration(); ``` 已经获取到了 JVM 的进程,我们该如何将该进程优雅的停掉呢?下面的代码给出了答案 ``` childProc = ProcessHandle.current().children(); childProc.forEach(procHandle -> { assertTrue("Could not kill process " + procHandle.getPid(), procHandle.destroy()); }); ``` 通过上面的一小段代码,我们也发现了 Java 9 对断言机制同样增加了一些增强,多说一些题外话,我们目前的系统中运行一个严重依赖于 Hive beelineServer 的程序,beeline server 不是很稳定,经常出现卡顿,甚至假死,假死后也不回复的问题,这样就导致我们的程序也会出现卡顿,如果运维人员不对其进行清理,系统运行几个月之后会发现很多僵尸进程,于是增加一个获取当前 JVM PID 的功能,然后判断到超过给定的时间对其进行主动杀死,完全是程序内部的行为,但是获取 PID 就必须借助于 JMX 的动作,另外杀死它也必须借助于操作系统的命令,诸如 kill 这样的命令,显得非常的麻烦,但是 Java 9 的方式明显要优雅方便许多。 ### Publish-Subscribe Framework 在新版的 JDK 9 中提供了消息发布订阅的框架,该框架主要是由 Flow 这个类提供的,他同样会在 java.util.concurrent 中出现,并且提供了 Reactive 编程模式。 ## 5.3 基础类新内容 用少量的元素创建集合和映射的实例更容易。在列表、设置和映射接口上的新静态工厂方法使创建这些集合的不可变实例变得更加简单 例子: ``` Set alphabet = Set.of("a", "b", "c"); List strings = List.of("first", "second"); ``` 长期以来,Stream API 都是 Java 标准库最好的改进之一。通过这套 API 可以在集合上建立用于转换的申明管道。在 Java 9 中它会变得更好。Stream 接口中添加了 4 个新的方法:dropWhile, takeWhile, ofNullable。还有个 iterate 方法的新重载方法,可以让你提供一个 Predicate (判断条件)来指定什么时候结束迭代: ``` IntStream.iterate(1, i -> i < 100, i -> i + 1).forEach(System.out::println); ``` 第二个参数是一个 Lambda,它会在当前 IntStream 中的元素到达 100 的时候返回 true。因此这个简单的示例是向控制台打印 1 到 99。 除了对 Stream 本身的扩展,Optional 和 Stream 之间的结合也得到了改进。现在可以通过 Optional 的新方法 `stram` 将一个 Optional 对象转换为一个(可能是空的) Stream 对象: ``` Stream s = Optional.of(1).stream(); ``` 在组合复杂的 Stream 管道时,将 Optional 转换为 Stream 非常有用。 ### 增强的弃用标记 Java 9 提供了另外一个看起来很小的特性,那就是增强的弃用标记,能够让开发人员更好地理解代码的影响。以前,我们只能将代码标记为 deprecated 并在 Javadoc 中添加一些原因说明的文档,现在@Deprecated 新增了两个有用的属性:since 和 orRemoval。 ### Thread.onSpinWait Java 9 允许我们为 JVM 提供一些提示信息,便于实现性能的提升。具体来讲,如果你的代码需要在一个循环中等待某个条件发生的话,那么可以使用[Thread.onSpinWait](https://link.jianshu.com?t=http%3A%2F%2Fdownload.java.net%2Fjava%2Fjdk9%2Fdocs%2Fapi%2Fjava%2Flang%2FThread.html%23onSpinWait--)让运行时环境了解到这一点。 # 6 私有接口方法 Java 8 为我们带来了接口的默认方法。 接口现在也可以包含行为,而不仅仅是方法签名。 但是,如果在接口上有几个默认方法,代码几乎相同,会发生什么情况? 通常,您将重构这些方法,调用一个可复用的私有方法。 但默认方法不能是私有的。 将复用代码创建为一个默认方法不是一个解决方案,因为该辅助方法会成为公共 API 的一部分。 使用 Java 9,您可以向接口添加私有辅助方法来解决此问题: ```java public interface MyInterface { void normalInterfaceMethod(); default void interfaceMethodWithDefault() { init(); } default void anotherDefaultMethod() { init(); } // This method is not part of the public API exposed by MyInterface private void init() { System.out.println("Initializing"); } } ``` 如果您使用默认方法开发 API ,那么私有接口方法可能有助于构建其实现。 ```java interface InterfaceWithPrivateMethods { private static String staticPrivate() { return "static private"; } private String instancePrivate() { return "instance private"; } default void check() { String result = staticPrivate(); InterfaceWithPrivateMethods pvt = new InterfaceWithPrivateMethods() { // anonymous class }; result = pvt.instancePrivate(); } }} ``` 该特性完全是为了 Java 8 中 default 方法和 static 方法服务的。 # 7 java.net 新内容 就目前而言,JDK 提供的 Http 访问功能,几乎都需要依赖于 HttpURLConnection,但是这个类大家在写代码的时候很少使用,我们一般都会选择 Apache 的 Http Client,此次在 Java 9 的版本中引入了一个新的 package:java.net.http,里面提供了对 Http 访问很好的支持,不仅支持 Http1.1 而且还支持 HTTP2,以及 WebSocket,据说性能可以超过 Apache HttpClient,Netty,Jetty ``` URI httpURI = new URI("http://www.94jiankang.com"); HttpRequest request = HttpRequest.create(httpURI).GET(); HttpResponse response = request.response(); String responseBody = response.body(HttpResponse.asString()); ``` ## http/2 Java 9 中有新的方式来处理 HTTP 调用。这个迟到的特性用于代替老旧的 `HttpURLConnection` API,并提供对 WebSocket 和 HTTP/2 的支持。注意:新的 HttpClient API 在 Java 9 中以所谓的孵化器模块交付。也就是说,这套 API 不能保证 100% 完成。不过你可以在 Java 9 中开始使用这套 API: ```java HttpClient client = HttpClient.newHttpClient(); HttpRequest req = HttpRequest.newBuilder(URI.create(``"[http://www.google.com](http://www.google.com/)"``)).header(``"User-Agent"``,``"Java"``) .GET().build(); HttpResponse resp = client.send(req, HttpResponse.BodyHandler.asString()); ``` 除了这个简单的请求/响应模型之外,HttpClient 还提供了新的 API 来处理 HTTP/2 的特性,比如流和服务端推送。 # 8 其他 ## 8.1 Try-With-Resources 的改变 我们都知道,Try-With-Resources 是从 JDK 7 中引入的一项重要特征,只要接口继承了 Closable 就可以使用 Try-With-Resources,减少 finally 语句块的编写,在 Java 9 中会更加的方便这一特征 ``` MyAutoCloseable mac = new MyAutoCloseable(); try (mac) { // do some stuff with mac } try (new MyAutoCloseable() { }.finalWrapper.finalCloseable) { // do some stuff with finalCloseable } catch (Exception ex) { } ``` 我们的 Closeable 完全不用写在 try()中。 ## 8.2 Unsafe 类的命运 很早之前就传言 Java 会将 unsafe 这一个类屏蔽掉,不给大家使用,这次看他的官方文档,貌似所有已 sun 开头的包都将不能在 application 中使用,但是 java 9 提供了新的 API 供大家使用。 在 JDK 9 中提供了一个新的包,叫做 java.lang.invoke 里面有一系列很重要的类比如 VarHandler 和 MethodHandles,提供了类似于原子操作以及 Unsafe 操作的功能。 ## 8.3 Мulti-Resolution Image API 接口 java.awt.image.MultiResolutionImage 封装了一系列的不同分辨率图像到一个单独对象的 API,我么可以根据给定的 DPI 矩阵获取 resolution-specific。 关于 AWT 的东西,本人几乎不怎么接触,如果有用到的朋友,等 JDK 9 出来之后,自己体会使用一下吧。 # 9 JVM 优化 ## 9.1 使用 G1 垃圾回收器作为默认的垃圾回收器 移除很多已经被过期的 GCC 回收器(是移除哦,因为在 Jdk 8 中只是加了过期的标记) ### 1. 本地变量类型推断 什么是局部变量类型推断? ```java var javastack = "javastack"; System.out.println(javastack); ``` 大家看出来了,局部变量类型推断就是左边的类型直接使用 var 定义,而不用写具体的类型,编译器能根据右边的表达式自动推断类型,如上面的 String 。 ```java var javastack = "javastack"; 就等于: String javastack = "javastack"; ``` ### 2. 字符串加强 Java 11 增加了一系列的字符串处理方法,如以下所示。 ```java // 判断字符串是否为空白 " ".isBlank(); // true // 去除首尾空格 " Javastack ".strip(); // "Javastack" // 去除尾部空格 " Javastack ".stripTrailing(); // " Javastack" // 去除首部空格 " Javastack ".stripLeading(); // "Javastack " // 复制字符串 "Java".repeat(3);// "JavaJavaJava" // 行数统计 "A\nB\nC".lines().count(); // 3 ``` ### 3. 集合加强 自 Java 9 开始,Jdk 里面为集合(List/ Set/ Map)都添加了 of 和 copyOf 方法,它们两个都用来创建不可变的集合,来看下它们的使用和区别。 示例 1: ```java var list = List.of("Java", "Python", "C"); var copy = List.copyOf(list); System.out.println(list == copy); // true ``` 示例 2: ```java var list = new ArrayList(); var copy = List.copyOf(list); System.out.println(list == copy); // false ``` 示例 1 和 2 代码差不多,为什么一个为 true,一个为 false? 来看下它们的源码: ```java static List of(E... elements) { switch (elements.length) { // implicit null check of elements case 0: return ImmutableCollections.emptyList(); case 1: return new ImmutableCollections.List12<>(elements[0]); case 2: return new ImmutableCollections.List12<>(elements[0], elements[1]); default: return new ImmutableCollections.ListN<>(elements); } } static List copyOf(Collection coll) { return ImmutableCollections.listCopy(coll); } static List listCopy(Collection coll) { if (coll instanceof AbstractImmutableList && coll.getClass() != SubList.class) { return (List)coll; } else { return (List)List.of(coll.toArray()); } } ``` 可以看出 copyOf 方法会先判断来源集合是不是 AbstractImmutableList 类型的,如果是,就直接返回,如果不是,则调用 of 创建一个新的集合。 示例 2 因为用的 new 创建的集合,不属于不可变 AbstractImmutableList 类的子类,所以 copyOf 方法又创建了一个新的实例,所以为 false. 使用 of 和 copyOf 创建的集合为不可变集合,不能进行添加、删除、替换、排序等操作,不然会报 java.lang.UnsupportedOperationException 异常。 上面演示了 List 的 of 和 copyOf 方法,Set 和 Map 接口都有。 ```java public static void main(String[] args) { Set names = Set.of("Fred", "Wilma", "Barney", "Betty"); //JDK11之前我们只能这么写 System.out.println(Arrays.toString(names.toArray(new String[names.size()]))); //JDK11之后 可以直接这么写了 System.out.println(Arrays.toString(names.toArray(size -> new String[size]))); System.out.println(Arrays.toString(names.toArray(String[]::new))); } ``` **Collection.toArray(IntFunction)** 在 java.util.Collection 接口中添加了一个新的默认方法 toArray(IntFunction)。此方法允许将集合的元素传输到新创建的所需运行时类型的数组 ```java public static void main(String[] args) { Set names = Set.of("Fred", "Wilma", "Barney", "Betty"); //JDK11之前我们只能这么写 System.out.println(Arrays.toString(names.toArray(new String[names.size()]))); //JDK11之后 可以直接这么写了 System.out.println(Arrays.toString(names.toArray(size -> new String[size]))); System.out.println(Arrays.toString(names.toArray(String[]::new))); } ``` 新方法是现有 toArray(T [])方法的重载,该方法将数组实例作为参数。添加重载方法会导致次要源不兼容。以前,形式为 coll.toArray(null)的代码将始终解析为现有的 toArray 方法。使用新的重载方法,此代码现在不明确,将导致编译时错误。 (这只是源不兼容。现有的二进制文件不受影响。)应该更改模糊代码以将 null 转换为所需的数组类型,例如 toArray((Object [])null)或其他一些数组类型。请注意,将 null 传递给 toArray 方法指定为抛出 NullPointerException ### 4. Stream 加强 Stream 是 Java 8 中的新特性,Java 9 开始对 Stream 增加了以下 4 个新方法。 1. 增加单个参数构造方法,可为 null ```java Stream.ofNullable(null).count(); // 0 //JDK8木有ofNullable方法哦 ``` 源码可看看: ```java /** * @since 9 */ public static Stream ofNullable(T t) { return t == null ? Stream.empty() : StreamSupport.stream(new Streams.StreamBuilderImpl<>(t), false); } ``` 2. 增加 takeWhile 和 dropWhile 方法 ```java Stream.of(1, 2, 3, 2, 1) .takeWhile(n -> n < 3) .collect(Collectors.toList()); // [1, 2] ``` takeWhile 表示从开始计算,当 n < 3 时就截止。 ```java Stream.of(1, 2, 3, 2, 1) .dropWhile(n -> n < 3) .collect(Collectors.toList()); // [3, 2, 1] ``` dropWhile 这个和上面的相反,一旦 n < 3 不成立就开始计算 3. iterate 重载 这个 iterate 方法的新重载方法,可以让你提供一个 Predicate (判断条件)来指定什么时候结束迭代。 ```java public static void main(String[] args) { // 这构造的是无限流 JDK8开始 Stream.iterate(0, (x) -> x + 1); // 这构造的是小于10就结束的流 JDK9开始 Stream.iterate(0, x -> x < 10, x -> x + 1); } ``` ### 5. Optional 加强 Opthonal 也增加了几个非常酷的方法,现在可以很方便的将一个 Optional 转换成一个 Stream, 或者当一个空 Optional 时给它一个替代的。 ```java Optional.of("javastack").orElseThrow(); // javastack Optional.of("javastack").stream().count(); // 1 Optional.ofNullable(null) .or(() -> Optional.of("javastack")) .get(); // javastack ``` or 方法和 stream 方法显然都是新增的 ### 6. InputStream 加强 InputStream 终于有了一个非常有用的方法:transferTo,可以用来将数据直接传输到 OutputStream,这是在处理原始数据流时非常常见的一种用法,如下示例。 ```java var classLoader = ClassLoader.getSystemClassLoader(); var inputStream = classLoader.getResourceAsStream("javastack.txt"); var javastack = File.createTempFile("javastack2", "txt"); try (var outputStream = new FileOutputStream(javastack)) { inputStream.transferTo(outputStream); } ``` ### 7. HTTP Client API(重磅) 在 java9 及 10 被标记 incubator 的模块 jdk.incubator.httpclient,在 java11 被标记为正式,改为 java.net.http 模块。 这是 Java 9 开始引入的一个处理 HTTP 请求的的孵化 HTTP Client API,该 API 支持同步和异步,而在 Java 11 中已经为正式可用状态,你可以在 java.net 包中找到这个 API。 来看一下 HTTP Client 的用法: ```java var request = HttpRequest.newBuilder() .uri(URI.create("https://javastack.cn")) .GET() .build(); var client = HttpClient.newHttpClient(); // 同步 HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); // 异步 client.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApply(HttpResponse::body) .thenAccept(System.out::println); ``` 上面的 .GET() 可以省略,默认请求方式为 Get! 更多使用示例可以看这个 API,后续有机会再做演示。 现在 Java 自带了这个 HTTP Client API,我们以后还有必要用 Apache 的 HttpClient 工具包吗?我觉得没啥必要了 ### 8. 化繁为简,一个命令编译运行源代码 看下面代码 ``` // 编译 javac Javastack.java // 运行 java Javastack ``` 在我们的认知里面,要运行一个 Java 源代码必须先编译,再运行,两步执行动作。而在未来的 Java 11 版本中,通过一个 java 命令就直接搞定了,如以下所示。 ``` java Javastack.java ``` ### 9. 移除项 1. 移除了 com.sun.awt.AWTUtilities 2. 移除了 sun.misc.Unsafe.defineClass,使用 java.lang.invoke.MethodHandles.Lookup.defineClass 来替代 3. 移除了 Thread.destroy()以及 Thread.stop(Throwable)方法 4. 移除了sun.nio.ch.disableSystarraydsj@163.comWideOverlappingFileLockCheck、sun.locale.formatasdefault 属性 5. 移除了 jdk.snmp 模块 6. 移除了 javafx,openjdk 估计是从 java10 版本就移除了,oracle jdk10 还尚未移除 javafx,而 java11 版本则 oracle 的 jdk 版本也移除了 javafx 7. 移除了 Java Mission Control,从 JDK 中移除之后,需要自己单独下载 8. 移除了这些 Root Certificates :Baltimore Cybertrust Code Signing CA,SECOM ,AOL and Swisscom ### 10. 废弃项 1. 废弃了 Nashorn JavaScript Engine 2. 废弃了-XX+AggressiveOpts 选项 3. -XX:+UnlockCommercialFeatures 以及-XX:+LogCommercialFeatures 选项也不- 再需要 4. 废弃了 Pack200 工具及其 API ## [JetBrains IDEA插件开发:核心API与工具详解](https://blog.dong4j.site/posts/8366ec74.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) IDEA Plugin API ## 文件操作 ### Virtual File System `Virtual File System` 是处理文件的一套机制, 用于处理如何加载文件, 如果保存文件, 当文件变化时如何更新缓存等. IntelliJ Platform 将操作文件封装成了 `Virtual File System`, 提供了以下几点主要功能: 1. 封装处理文件的通用 API, 不论文件在磁盘, 存档, HTTP 服务器或者其他地方, 都使用同一套 API; 2. 提供快照功能, 能跟踪文件的修改; 3. 提供将附加持久数据与 VFS 中的文件相关联; 为了提供最后两个功能,VFS 管理用户硬盘的某些内容的持久快照。快照仅存储通过 VFS API 至少请求过一次的文件,并且异步更新以匹配磁盘上发生的更改。 快照是应用程序级别,而不是项目级别 - 因此,如果某个文件(例如,JDK 中的某个类)被多个项目引用,则其内容的一个副本将存储在 VFS 中。 所有 VFS 访问操作都通过快照。 如果通过 VFS API 请求某些信息但快照中没有这些信息,则会从磁盘加载并存储到快照中。如果快照中有可用信息,则返回快照数据。仅当访问了特定信息时,文件的内容和目录中的文件列表才存储在快照中 - 否则,仅存储名称,长度,时间戳,属性等文件元数据。 > 这意味着 IntelliJ Platform UI 中显示的文件系统状态和文件内容来自快照,快照可能并不总是与磁盘的实际内容相匹配。 > 例如, 在某些情况下,在 IntelliJ 平台选择删除之前,已删除的文件仍可在 UI 中显示一段时间。 在刷新操作期间从磁盘更新快照,这通常是异步发生的。通过 VFS 进行的所有写操作都是同步的 - 即内容立即保存到磁盘。 刷新操作将 VFS 的一部分状态与实际磁盘内容同步。IntelliJ 平台或插件代码显式调用刷新操作 - 即在 IDE 运行时在磁盘上更改文件时,VFS 不会立即获取更改。VFS 将在下一次刷新操作期间更新,其中包括其范围内的文件。 ### Virtual File 用于表示 `Virtual File System` 中的一个具体文件, 相当于本地系统文件, 也用于表示 jar 包中的文件中的类, 还可以表示版本管理中的旧文件. **`VFS` 仅处理二进制内容** #### 获取 VirtualFile ```java private void getVirtualFile(AnActionEvent e) { // 获取 VirtualFile 方式一: VirtualFile virtualFile = e.getData(PlatformDataKeys.VIRTUAL_FILE); // 获取多个 VirtualFile VirtualFile[] virtualFiles = e.getData(PlatformDataKeys.VIRTUAL_FILE_ARRAY); // 方式二: 从本地文件系统路径获取 VirtualFile virtualFileFromLocalFileSystem = LocalFileSystem.getInstance().findFileByIoFile(new File("path")); // 方式三: 从 PSI 文件 (如果 PSI 文件仅存在内存中, 则可能返回 null) PsiFile psiFile = e.getData(CommonDataKeys.PSI_FILE); if (psiFile != null) { psiFile.getVirtualFile(); } // 方式四: 从 document 中获取 Document document = Objects.requireNonNull(e.getData(PlatformDataKeys.EDITOR)).getDocument(); VirtualFile virtualFileFromDocument = FileDocumentManager.getInstance().getFile(document); } ``` ![20241229154732_TaWCxR2c.webp](https://cdn.dong4j.site/source/image/20241229154732_TaWCxR2c.webp) #### 遍历文件系统 遍历文件可以使用 File 的 API, 我们也可以通过 `VFS` 提供的 API 来实现 ```java private void iterateChildrenRecursively(VirtualFile virtualFile) { /** * 递归遍历子文件 * * @param root the root 父文件 * @param filter the filter 过滤器 * @param iterator the iterator 处理方式 * @return the boolean */ VfsUtilCore.iterateChildrenRecursively(virtualFile, new VirtualFileFilter() { @Override public boolean accept(VirtualFile file) { // todo-dong4j : (2019 年 03 月 15 日 13:02) [从 .gitignore 中获取忽略的文件] boolean allowAccept = file.isDirectory()&& !file.getName().equals(NODE_MODULES_FILE); if(allowAccept || file.getName().endsWith(MARKDOWN_FILE_TYPE)){ log.trace("accept = {}", file.getPath()); return true; } return false; } }, new ContentIterator() { @Override public boolean processFile(@NotNull VirtualFile fileOrDir) { // todo-dong4j : (2019 年 03 月 15 日 13:04) [处理 markdown 逻辑实现] if(!fileOrDir.isDirectory()){ log.trace("processFile = {}", fileOrDir.getName()); } return true; } }); } ``` `ContentIterator` 接口表示处理文件的具体方式, 需要自己实现. 由于在过滤器中已经将非 markdown 文件过滤掉, 因此此处只需要实现处理 markdown 文件的逻辑即可. ![20241229154732_qtx1O6IU.webp](https://cdn.dong4j.site/source/image/20241229154732_qtx1O6IU.webp) ### Document Document 是可编辑的 Unicode 字符序列, 对应的是 `VirtualFile` 中的文本内容. 文档中的换行符 **始终** 为 `\n`. 可以通过 Document 对文件做任何操作. #### 获取 Document ```java private void getDocument(AnActionEvent e){ // 从当前编辑器中获取 Document documentFromEditor = Objects.requireNonNull(e.getData(PlatformDataKeys.EDITOR)).getDocument(); // 从 VirtualFile 获取 (如果之前未加载文档内容,则此调用会强制从磁盘加载文档内容) VirtualFile virtualFile = e.getData(PlatformDataKeys.VIRTUAL_FILE); if (virtualFile != null) { Document documentFromVirtualFile = FileDocumentManager.getInstance().getDocument(virtualFile); // 从缓存中获取 Document documentFromVirtualFileCache = FileDocumentManager.getInstance().getCachedDocument(virtualFile); // 从 PSI 中获取 Project project = e.getProject(); if (project != null) { // 获取 PSI (一) PsiFile psiFile = PsiManager.getInstance(project).findFile(virtualFile); // 获取 PSI (二) psiFile = e.getData(CommonDataKeys.PSI_FILE); if (psiFile != null) { Document documentFromPsi = PsiDocumentManager.getInstance(project).getDocument(psiFile); // 从缓存中获取 Document documentFromPsiCache = PsiDocumentManager.getInstance(project).getCachedDocument(psiFile); } } } } ``` ![20241229154732_QSKPhUZG.webp](https://cdn.dong4j.site/source/image/20241229154732_QSKPhUZG.webp) 对 Document 操作时一定要注意内存泄漏的问题, 因为每次访问文件的 Document 对象是, 都是一个新的实例, 使用完成后一定要记得释放引用. #### 创建 Document 如果需要在磁盘上创建新文件, 不要直接创建 `Document`, 而是先创建 `PSI` 文件, 然后获取它的 `Document`. 如果需要创建一个没有绑定的 `Document` 的实例, 可以使用 `EditorFactory.createDocument`. #### Document Listener - 接收有关特定 `Document` 实例中的更改的通知 ```java Document.addDocumentListener ``` - 接收有关所有打开文档中的更改的通知 ```java EditorFactory.getEventMulticaster().addDocumentListener ``` #### write Document SDK 规定所有写操作必须通过异步执行, 因此需要将写操作包装到 command 中 ```java CommandProcessor.getInstance().executeCommand() ``` 比如: ```java WriteCommandAction.runWriteCommandAction(project, () -> { document.setText(string); psiDocumentManager.doPostponedOperationsAndUnblockDocument(document); psiDocumentManager.commitDocument(document); FileDocumentManager.getInstance().saveDocument(document); }); ``` ### Editor Editor 相关 API [editor-ui-api package](https://upsource.jetbrains.com/idea-ce/file/idea-ce-a7b3d4e9e48efbd4ac75105e9737cea25324f11e/platform/editor-ui-api),[Editor.java](https://upsource.jetbrains.com/idea-ce/file/idea-ce-a7b3d4e9e48efbd4ac75105e9737cea25324f11e/platform/editor-ui-api/src/com/intellij/openapi/editor/Editor.java),[EditorImpl.java](https://upsource.jetbrains.com/idea-ce/file/idea-ce-a7b3d4e9e48efbd4ac75105e9737cea25324f11e/platform/platform-impl/src/com/intellij/openapi/editor/impl/EditorImpl.java).[CommonDataKeys.java](https://upsource.jetbrains.com/idea-ce/file/idea-ce-a7b3d4e9e48efbd4ac75105e9737cea25324f11e/platform/editor-ui-api/src/com/intellij/openapi/actionSystem/CommonDataKeys.java),[DataKey.java](https://upsource.jetbrains.com/idea-ce/file/idea-ce-a7b3d4e9e48efbd4ac75105e9737cea25324f11e/platform/editor-ui-api/src/com/intellij/openapi/actionSystem/DataKey.java),[AnActionEvent](https://upsource.jetbrains.com/idea-ce/file/idea-ce-a7b3d4e9e48efbd4ac75105e9737cea25324f11e/platform/editor-ui-api/src/com/intellij/openapi/actionSystem/AnActionEvent.java),[DataContext](https://upsource.jetbrains.com/idea-ce/file/idea-ce-a7b3d4e9e48efbd4ac75105e9737cea25324f11e/platform/editor-ui-api/src/com/intellij/openapi/actionSystem/DataContext.java) ### PSI #### 获取 PSI ```java private void getPsiFile(AnActionEvent e){ // 从 action 中获取 PsiFile psiFileFromAction = e.getData(LangDataKeys.PSI_FILE); Project project = e.getProject(); if (project != null) { VirtualFile virtualFile = e.getData(PlatformDataKeys.VIRTUAL_FILE); if (virtualFile != null) { // 从 VirtualFile 获取 PsiFile psiFileFromVirtualFile = PsiManager.getInstance(project).findFile(virtualFile); // 从 document Document documentFromEditor = Objects.requireNonNull(e.getData(PlatformDataKeys.EDITOR)).getDocument(); PsiFile psiFileFromDocument = PsiDocumentManager.getInstance(project).getPsiFile(documentFromEditor); // 在 project 范围内查找特定 PsiFile FilenameIndex.getFilesByName(project, "fileName", GlobalSearchScope.projectScope(project)); } } } ``` #### 如果我知道它的名字但不知道路径,我如何找到文件? `FilenameIndex.getFilesByName()` #### 如何找到特定 PSI 元素的使用位置? `ReferencesSearch.search()` #### 如何重命名 PSI 元素? `RefactoringFactory.createRename()` #### 如何重建虚拟文件的 PSI? `FileContentUtil.reparseFiles()` ### Java 特定 #### 如何找到类的所有继承者? `ClassInheritorsSearch.search()` #### 如何通过限定名称查找课程? `JavaPsiFacade.findClass()` #### 如何通过短名称找到一个班级? `PsiShortNamesCache.getInstance().getClassesByName()` #### 如何找到 Java 类的超类? `PsiClass.getSuperClass()` #### 如何获取对 Java 类的包含的引用? ```java PsiJavaFile javaFile = (PsiJavaFile) psiClass.getContaningFile(); PsiPackage pkg = JavaPsiFacade.getInstance(project).findPackage(javaFile.getPackageName()); ``` #### 如何找到覆盖特定方法的方法 `OverridingMethodsSearch.search()` ## [IDEA插件开发指南(八):持久化配置与设置面板](https://blog.dong4j.site/posts/9fb70690.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) IDEA Plugin 配置持久化 ## 持久化与设置面板 上传到 Aliyun OSS 需要几个参数: 1. endpoint 2. accessKeyId 3. accessKeySecret 4. bucketName 5. filedir (此参数可不填) ![20241229154732_kTcuDeWY.webp](https://cdn.dong4j.site/source/image/20241229154732_kTcuDeWY.webp) ![20241229154732_o9WJRBVY.webp](https://cdn.dong4j.site/source/image/20241229154732_o9WJRBVY.webp) 本章节将介绍怎样创建设置面板和持久化配置 ### 设置面板 直接使用 IDEA 自带的 GUI 插件来画图, 需要开启 `UI Designer` 插件 ![20241229154732_BriAzGij.webp](https://cdn.dong4j.site/source/image/20241229154732_BriAzGij.webp) ### 持久化 `PersistentStateComponent` ## [IDEA插件开发(七):如何创建右键菜单操作](https://blog.dong4j.site/posts/56e5b99c.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 介绍 IDEA Plugin 开发入口 接下的文档都会根据前一篇的需求来找到解决方案, 对于不熟悉 IDEA 插件开发的同学 (说的就是我), 可能一时找不到各个公共的入口, 这个是否就要看一些开源的插件, 从中找到功能入口. ## 右键菜单入口 需求中提到 **「在编辑视图中直接右键 --> `upload to Aliyun OSS`」** 因此我们需要扩展右键菜单, 来添加我们自己的功能入口. 现在介绍一种新的添加 AnAction 的方式: ![20241229154732_CEGFQZTP.webp](https://cdn.dong4j.site/source/image/20241229154732_CEGFQZTP.webp) ![20241229154732_C6YLzipL.webp](https://cdn.dong4j.site/source/image/20241229154732_C6YLzipL.webp) 这么多 Group. 好了, 懵逼了.... 那么我们怎么来找我们需要的 Group 呢? 我这里用个笨办法, 看别人的插件配置呗, 这里通过看 `Alibaba Java Coding Guidelines` 这个插件的配置, 可以确定的有: **MainToolBar** ![20241229154732_OJGdzbLl.webp](https://cdn.dong4j.site/source/image/20241229154732_OJGdzbLl.webp) **ProjectViewPopupMenu** ![20241229154732_8APSpRb1.webp](https://cdn.dong4j.site/source/image/20241229154732_8APSpRb1.webp) **ChangesViewPopupMenu** ![20241229154732_DJgUdu85.webp](https://cdn.dong4j.site/source/image/20241229154732_DJgUdu85.webp) **EditorPopupMenu** ![20241229154732_FJ1uMI4G.webp](https://cdn.dong4j.site/source/image/20241229154732_FJ1uMI4G.webp) 先来第一个, 在编辑器右键菜单中添加我们的 action, 很明显是用 `EditorPopupMenu` ```xml ``` 不出意外的话: **MainToolBar** ![20241229154732_vhGD8UND.webp](https://cdn.dong4j.site/source/image/20241229154732_vhGD8UND.webp) **ProjectViewPopupMenu** ![20241229154732_67JQ03Yk.webp](https://cdn.dong4j.site/source/image/20241229154732_67JQ03Yk.webp) **ChangesViewPopupMenu** ![20241229154732_5HHFQPy2.webp](https://cdn.dong4j.site/source/image/20241229154732_5HHFQPy2.webp) **EditorPopupMenu** ![20241229154732_z6tbRhUZ.webp](https://cdn.dong4j.site/source/image/20241229154732_z6tbRhUZ.webp) ## 功能实现 由于需求实现不是本教程的重点, 所以会选取插件开发有关的问题作出说明. ### 怎么判断选中的对象是 Markdown 文件 首先我们得知道当前光标选中的是什么东西 ```java // 获取当前操作的文件 PsiFile psiFile = anActionEvent.getData(CommonDataKeys.PSI_FILE); if (psiFile != null) { log.info("language = {}", psiFile.getOriginalFile().getLanguage()); log.info("name = {}", psiFile.getOriginalFile().getName()); Messages.showMessageDialog(project, psiFile.getFileType().getName(), "File Type", null); } // 获取当前事件触发时,光标所在的元素 PsiElement psiElement = anActionEvent.getData(LangDataKeys.PSI_ELEMENT); // 如果光标选择的不是类,弹出对话框提醒 if (psiElement == null || !(psiElement instanceof PsiClass)) { Messages.showMessageDialog(project, "Please focus on a class", "Generate Failed", null); return; } ``` ``` 2019-03-12 18:16:00,899 [44799] INFO - .AliObjectStorageServiceAction - language = Language: JAVA 2019-03-12 18:16:00,899 [44799] INFO - .AliObjectStorageServiceAction - name = HelloOSS.java 2019-03-12 18:16:10,423 [54323] INFO - .AliObjectStorageServiceAction - project's base path = /Users/dong4j/Develop/codes/alibaba/aliyun-oss-java-sdk-demo-mvn 2019-03-12 18:16:14,372 [58272] INFO - .AliObjectStorageServiceAction - language = Language: TEXT 2019-03-12 18:16:14,372 [58272] INFO - .AliObjectStorageServiceAction - name = maven-demo.iml 2019-03-12 18:18:06,392 [170292] INFO - .AliObjectStorageServiceAction - language = Language: TEXT 2019-03-12 18:18:06,392 [170292] INFO - .AliObjectStorageServiceAction - name = 3-2-data-grip.md 2019-03-12 18:18:08,586 [172486] INFO - .AliObjectStorageServiceAction - project's base path = /Users/dong4j/Develop/knowledge ``` psiFile 还有其他的一些属性, 这个就 debug 看吧. 从上面我们知道了, 如果是 `Markdown` 文件, Language 为 TEXT, name 是文件名. 如果是类文件, 则是 JAVA. 可以使用 `PsiJavaFile` 来操作. ![20241229154732_rDOXCSLd.webp](https://cdn.dong4j.site/source/image/20241229154732_rDOXCSLd.webp) ![20241229154732_uShmvBHD.webp](https://cdn.dong4j.site/source/image/20241229154732_uShmvBHD.webp) ### 获取 Markdown 文件的所有内容 ```java String text = Objects.requireNonNull(psiFile.getViewProvider().getDocument()).getText(); ``` ![20241229154732_ISFOzO2Z.webp](https://cdn.dong4j.site/source/image/20241229154732_ISFOzO2Z.webp) ## update [👉 判断按钮什么时候可用](https://github.com/dong4j/aliyun-oss-upload/tree/2.update) ## [IntelliJ IDEA 插件开发攻略:Markdown 图片上传至 Aliyun OSS](https://blog.dong4j.site/posts/c9eda116.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 插件需求分析 先来梳理下需求: 开发一款插件将 `Markdown` 文档中的图片上传到 `Aliyun OSS`. 具体需求入下: ## 上传需求 解析所有 `![]()` 图片标签: 1. 如果图片在本地, 则上传到 Aliyun OSS; 2. 如果以 `http://` 或者 `https://` 开头, 则根据设置判断是否上传 (迁移图片到 Aliyun OSS) 上传完成后: 1. 根据设置将 `![](./03161253/xxx)` 标签转换成 `![](./03161253/xxx)` 标签 这里有 2 个设置: 1. 是否转换为 `` 标签: 1. 如果开启, 在判断是否显示大图 (这个主要针对 vuepress 构建的博客) ```html ![](http://xxxx) ``` 且要修改 `config.js` 文件, 添加如下: ```JavaScript // 让 Vuepress 支持图片放大功能 ['script', { src: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.slim.min.js' }], ['script', { src: 'https://cdnjs.cloudflare.com/ajax/libs/fancybox/3.5.2/jquery.fancybox.min.js' }], ['link', { rel: 'stylesheet', type: 'text/css', href: 'https://cdnjs.cloudflare.com/ajax/libs/fancybox/3.5.2/jquery.fancybox.min.css' }] ``` 1. 如果不开启, 则只转为普通的 `` 标签, 这样点击图片后, 能新开标签查看大图; ```html ![](http://xxxx) ``` ## 图片压缩 根据设置判断上传图片是否压缩, 给出百分比. 获取图片时是否压缩, 这个需要在 Aliyun OSS 端设置, 设置好后填入对应的 `styleName` ## 直接提供图片压缩的功能 作用范围: 1. 当前选中的图片; 2. 选中的目录中的所有图片; 3. 整个项目中的图片; ## 备份图片 图片上传完成后, 将已上传的图片按照目录备份到 **当前项目的主目录** 中 ## 插件作用范围 只会解析 `Markdown` 文档. 如果是单个文件, 只有是 `Markdown` 文档才会显示 `upload to Aliyun OSS`, 其他时候不可用; **注意:** 文件树可以多选文件 ### 当前选中 / 正在编辑的文件 1. 在编辑视图中直接右键 --> `upload to Aliyun OSS` ; 2. 在 Tools 菜单中 --> `upload to Aliyun OSS` ; 3. 在文件树选中文件后右键 --> `upload to Aliyun OSS` ; ### 目录 1. 在文件树目录上点击右键 --> `upload to Aliyun OSS` ; ### 项目 1. 在整个项目上点击右键 --> `upload to Aliyun OSS` ; ## 提示和日志 每一步给出日志 1. 解析标签时; 2. 拿到图片路径; 3. 上传; 4. 上传成功后备份图片, 不成功不备份; 5. 替换标签; 上传过程中给出进度条 所有操作完成后给出提示! ## 设置页 1. Aliyun OSS 设置; ![20241229154732_lQw5m47z.webp](https://cdn.dong4j.site/source/image/20241229154732_lQw5m47z.webp) ![20241229154732_mtyVTw4c.webp](https://cdn.dong4j.site/source/image/20241229154732_mtyVTw4c.webp) 1. 标签替换选项设置; 2. 图片压缩设置 (上传前后上传后); 3. 图床迁移设置; ## Aliyun OSS 控制台 1. 当前存储总量 2. 本月 Put 类请求 3. 本月 Get 类请求 4. SLA ### 刷新次数 1. 5 分钟刷新一次; 2. 打开时刷新一次; 3. 刷新按钮; 4. 关闭后停止刷新任务; ## [从基础到高级:IntelliJ IDEA 插件的四大分类](https://blog.dong4j.site/posts/50e2e964.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 这篇大致介绍一下 IDEA 插件的几种类型 ## 插件的主要类型 IntelliJ IDEA 的强大之一就是有一个易于扩展的插件系统, 所有插件都可在 [JetBrains 插件库](https://plugins.jetbrains.com/) 找到. 最常见的插件类型包括: - 自定义语言支持 - 框架集成 - 工具集成 - 用户界面附加组件 ## 自定义语言支持 自定义语言支持提供了使用特定编程语言的基本功能, 包括: - 文件类型识别 - 词汇分析 - 语法突出显示 - 格式化 - 代码洞察和代码完成 - 检查和快速修复 - 意图行动 ## 框架集成 框架集成包括改进的代码洞察功能, 这些功能对于给定的框架是典型的, 以及直接从 IDE 使用框架特定功能的选项. 有时它还包括自定义语法或 DSL 的语言支持元素. - 具体的代码见解 - 直接访问特定于框架的功能 ## 工具集成 通过工具集成, 可以直接从 IDE 操作第三方工具和组件, 而无需切换上下文. 比如: - 实施其他行动 - 相关的 UI 组件 - 访问外部资源 ## 用户界面附加组件 此类别中的插件会对 IDE 的标准用户界面应用各种更改. 一些新添加的组件是交互式的并提供新功能, 而其他组件仅限于视觉修改. 所述 [背景图像](https://plugins.jetbrains.com/plugin/72) 的插件可以作为一个例子. [👉 推荐一款主题](https://plugins.jetbrains.com/plugin/8006-material-theme-ui) ![20241229154732_KI8B9GrW.webp](https://cdn.dong4j.site/source/image/20241229154732_KI8B9GrW.webp)) ## [从0到1:学习如何在IntelliJ IDEA中查看和管理插件日志](https://blog.dong4j.site/posts/5649be19.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 查看 IDEA Plugin 开发日志 上一节已经说过, IntelliJ IDEA SDK 中已经有了日志框架, 用于输出日志, 但是在哪里看日志呢. 我们可以这样查看: ![20241229154732_NPAbokXw.webp](https://cdn.dong4j.site/source/image/20241229154732_NPAbokXw.webp) 最终我们知道 idea.log 会在 `.sandbox/system/log/idea.log`. 然后我们可以通过 `Grep Console` 这个插件的 `Tail File in Console` 或者 `Tail Current File in Console` 来实时查看日志. ![20241229154732_N9KspG77.webp](https://cdn.dong4j.site/source/image/20241229154732_N9KspG77.webp) ![20241229154732_Y25A0nYO.webp](https://cdn.dong4j.site/source/image/20241229154732_Y25A0nYO.webp) 然后我们会发现所有的日志等级都是 INFO, 在我们调试的时候 DEBUG 日志看不到, 如果全部设置成 INFO 级别调试的时候很方便, 但是大量打印日志有性能损耗, 因此我们需要将日志等级降低, 又能打印到文件中. 你可以这样设置: ![20241229154732_ScfYkmtI.webp](https://cdn.dong4j.site/source/image/20241229154732_ScfYkmtI.webp) ![20241229154732_6ACs81L3.webp](https://cdn.dong4j.site/source/image/20241229154732_6ACs81L3.webp) 说明已经写得很详细了, 按照上面的来, 然后将调试日志等级设置为 TRACE 即可看见日志. ![20241229154732_mKVA2Ar3.webp](https://cdn.dong4j.site/source/image/20241229154732_mKVA2Ar3.webp) 其他你也可以直接修改下面这个文件 ![20241229154732_KDnfNDP9.webp](https://cdn.dong4j.site/source/image/20241229154732_KDnfNDP9.webp) [👉 扩展阅读 - 怎么将老的 plugin 项目转换成 gradle 项目] 由于官方开发文档写得很烂, 有多东西都没有写到, 所有我们只有看别人的代码来了解未知的 API 的功能. 这里先推荐一款插件 `PsiViewer`, 能保证我们更加容易理解 `PSI` ![20241229154732_xxh4nkBm.webp](https://cdn.dong4j.site/source/image/20241229154732_xxh4nkBm.webp) 准备工作做好了, 接下来进入主题了. ## [使用 Gradle 快速入门 IntelliJ IDEA 插件开发](https://blog.dong4j.site/posts/ac0a5116.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 使用官方推荐的 Gradle 插件开发 这是 IDEA 插件开发第一篇, 整个系列记录了开发 `aliyun-oss-upload` 的整个细节, 将会把遇到的问题整理成文, 最终发布在 `idea-plugin-dev` 中. ## 前言 IDEA 插件开发分为 2 种方式: 1. 直接使用 IDEA 提供的 `IntelliJ Platform Plugin` ![20241229154732_4tizyBBP.webp](https://cdn.dong4j.site/source/image/20241229154732_4tizyBBP.webp)2. 使用 Gradle ![20241229154732_DJPYrLBy.webp](https://cdn.dong4j.site/source/image/20241229154732_DJPYrLBy.webp) 官方推荐使用 Gradle 方式, 因此选用第二种方式. 因为未使用过 Gradle, 肯定会遇到很多坑, 为了减少大家爬坑的时间, 我会尽量把注释写详细点, 如果发现错误的地方, 请告知我. ## 配置 使用 Gradle 创建好插件项目后, 直接拷贝以下配置到 `build.gradle` ```java // mavenCentral() 是一个插件仓库, 导入的插件将会在仓库中寻找并下载 buildscript { repositories { mavenCentral() maven { url "http://maven.aliyun.com/nexus/content/groups/public/" } maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } maven { url 'http://dl.bintray.com/jetbrains/intellij-plugin-service' } } } plugins { id 'org.jetbrains.intellij' version '0.4.4' } repositories { mavenCentral() } tasks.withType(JavaCompile) { options.encoding = "UTF-8" } // 导入插件 // 1. 编译, 测试插件 (Java, Groovy,Scala, War 等) // 2. 代码分析插件 (Checkstyle, FindBugs, Sonar 等) // 3. IDE 插件 (Eclipse, IDEA 等) // Java 是 Gradle 的核心插件, 是内置的, 内置插件不需要配置依赖路径 apply plugin: 'java' apply plugin: 'idea' apply plugin: 'org.jetbrains.intellij' sourceCompatibility = 1.8 intellij { version '2018.3.5' sandboxDirectory = project.rootDir.canonicalPath + "/.sandbox" // 插件生成的临时文件的地址,可以省略 } // 声明依赖使用下面的闭包 dependencies { // 解决 gradle 使用 lombok 的问题 annotationProcessor 'org.projectlombok:lombok:1.18.2' compileOnly 'org.projectlombok:lombok:1.18.2' testAnnotationProcessor 'org.projectlombok:lombok:1.18.2' testCompileOnly 'org.projectlombok:lombok:1.18.2' // 单元测试 testCompile group: 'junit', name: 'junit' , version: '4.12' } ``` **坑 1:** `lombok` 用习惯了, 因此这里也使用了. 但是有个坑, 必须按照上面的方式依赖 `lombok`, 不然会报找不到符号的错误, 还有第二种配置方式: ```java repositories { mavenCentral() } plugins { id 'net.ltgt.apt' version '0.10' } dependencies { compileOnly 'org.projectlombok:lombok:1.18.2' apt "org.projectlombok:lombok:1.18.2" } ``` 选择其中一种即可. **坑 2:** 习惯在项目中使用日志代替 `System.out.println();` 打印日志, 因此考虑在此项目使用 `slf4j2 + log4j2` 日志框架, 但是却失败了. 添加 `slf4j2 + log4j2` 依赖 ```java compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.11.0' compile group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.11.0' compile group: 'com.lmax', name: 'disruptor', version: '3.4.2' ``` 错误信息: ```txt SLF4J: No SLF4J providers were found. SLF4J: Defaulting to no-operation (NOP) logger implementation SLF4J: See http://www.slf4j.org/codes.html#noProviders for further details. SLF4J: Class path contains SLF4J bindings targeting slf4j-api versions prior to 1.8. ``` 这是由于 `slf4j-api` 1.8.x 的绑定机制的变化导致, 意思就是版本太高了, 这里修改为 1.7.x 版本可以消除这个错误. 联系一下 gradle 怎么排除依赖 ```java compile (group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.11.0') { // 排除高版本依赖 exclude group: 'org.slf4j', module: 'slf4j-api' } // 使用低版本 compile group: 'org.slf4j', name: 'slf4j-api', version: '1.7.25' ``` 还没解决完: ``` SLF4J: Class path contains multiple SLF4J bindings. SLF4J: Found binding in [jar:file:/Users/dong4j/Develop/codes/idea-plugin/aliyun-oss-upload/.sandbox/plugins/aliyun-oss-upload/lib/log4j-slf4j-impl-2.11.0.jar!/org/slf4j/impl/StaticLoggerBinder.class] SLF4J: Found binding in [jar:file:/Users/dong4j/.gradle/caches/modules-2/files-2.1/com.jetbrains.intellij.idea/ideaIC/2018.3.5/2465ddbc4af3619128cada78e216ffbb93e8b173/ideaIC-2018.3.5/lib/slf4j-log4j12-1.7.25.jar!/org/slf4j/impl/StaticLoggerBinder.class] SLF4J: See http://www.slf4j.org/codes.html#multiple_bindings for an explanation. ``` 原来 IntelliJ IDEA SDK 已经有了 `slf4j-log4j12`, 因此我们可以不用配置了....😂 (搞半天, 是自己给自己挖坑). 因此最终的配置就是最上面给出的那种, 如果对 gradle 不熟, 又想快速上手的, 直接复制就可以了, 先不去折腾 gradle 的配置了, 不然开发 idea 插件的热情就会在不断的填坑过程中慢慢散去.... **下载 intellij SDK 很慢, 准备好梯子吧** ## Hello world ``` 人之初, 性本善; 写代码, hello world. ``` ### AnAction `AnAction` 是什么先不管, 不就把它理解成 JavaScript 里面的 `onclick` 事件处理吧 ```java @Slf4j public class HelloAction extends AnAction { public HelloAction() { super("Hello"); } /** * 响应用户的点击事件 * * @param event the event */ @Override public void actionPerformed(@NotNull AnActionEvent event) { log.info(event.toString()); Project project = event.getProject(); Messages.showMessageDialog(project, "Hello world!", "Greeting", Messages.getInformationIcon()); } } ``` 再来一个 ```java public class AliyunOssUpload extends AnAction { @Override public void actionPerformed(@NotNull AnActionEvent actionEvent) { // 获取当前在操作的工程上下文 Project project1 = actionEvent.getProject(); // 获取当前操作的类文件 PsiFile psiFile = actionEvent.getData(CommonDataKeys.PSI_FILE); // 获取当前类文件路径 String classPath = ""; if (psiFile != null) { classPath = psiFile.getVirtualFile().getPath(); } String title = "hello world"; // 显示对话框 Messages.showMessageDialog(project1, classPath, title, Messages.getInformationIcon()); } } ``` ### 注册 在 `plugin.xml` 中注册上面的类, 启动后会通过反射来调用我们的处理逻辑. ```xml info.dong4j.aliyun-oss-upload Aliyun OSS upload 0.0.1 dong4j com.intellij.modules.lang Aliyun OSS upload, xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ### 运行 ![20241229154732_kLdFErTd.webp](https://cdn.dong4j.site/source/image/20241229154732_kLdFErTd.webp) ### 效果 ![20241229154732_YXuvDDzQ.webp](https://cdn.dong4j.site/source/image/20241229154732_YXuvDDzQ.webp) 点击 'Hello' 之后: ![20241229154732_BXw3O7QS.webp](https://cdn.dong4j.site/source/image/20241229154732_BXw3O7QS.webp) ## 总结 入门很快, 因此写得不是非常详细, 因为入门相关的教程已经非常多了, 可参见 [👉 官方入门教程](https://www.jetbrains.org/intellij/sdk/docs/basics/getting_started.html). 还有很多手把手教的入门教程, 一搜一大把, 因此此系列文章只会记录开发过程中遇到的问题和经验总结. 为了大家快速入手, 这里给出此项目的 [👉 github 地址](), 愿有缘人赶紧入坑, 一起来学习开发 idea 插件. 告诉你一个好消息, Intellij idea 开启了一个插件收费平台, 来让更多的插件开发中从中获利. ![20241229154732_jPBRqQCs.webp](https://cdn.dong4j.site/source/image/20241229154732_jPBRqQCs.webp) 详情可见 [👉 Marketplace](https://plugins.jetbrains.com/marketplace) 此篇文档涉及到的全部代码请看 [👉 Github](https://github.com/dong4j/aliyun-oss-upload/tree/1.hello-world). 给个 star 呗. ## [捋一捋 async-tool 的问题](https://blog.dong4j.site/posts/a42dfdd6.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 昨天做完精准营销的需求后, 提测版本一直连不上 MQ, 然后在本地启动后也未发现问题, 直到监听的消息队列有消息而且是**大量消息**时才会出现的错误: ```java javax.jms.JMSException: Cannot send, channel has already failed: tcp://172.31.205.58:61616 at org.apache.activemq.util.JMSExceptionSupport.create(JMSExceptionSupport.java:62) at org.apache.activemq.ActiveMQConnection.syncSendPacket(ActiveMQConnection.java:1409) at org.apache.activemq.ActiveMQConnection.ensureConnectionInfoSent(ActiveMQConnection.java:1496) at org.apache.activemq.ActiveMQConnection.createSession(ActiveMQConnection.java:325) at org.apache.activemq.pool.ConnectionPool$2.makeObject(ConnectionPool.java:105) at org.apache.activemq.pool.ConnectionPool$2.makeObject(ConnectionPool.java:90) at org.apache.commons.pool.impl.GenericKeyedObjectPool.borrowObject(GenericKeyedObjectPool.java:1179) at org.apache.activemq.pool.ConnectionPool.createSession(ConnectionPool.java:142) at org.apache.activemq.pool.PooledConnection.createSession(PooledConnection.java:174) at com.xxxx.msearch.core.support.activemq.producer.JmsProducer.sendMsg(JmsProducer.java:137) at com.xxxx.msearch.core.support.activemq.producer.JmsProducer.access$100(JmsProducer.java:19) at com.xxxx.msearch.core.support.activemq.producer.JmsProducer$2.run(JmsProducer.java:114) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) at java.lang.Thread.run(Thread.java:748) Caused by: org.apache.activemq.transport.InactivityIOException: Cannot send, channel has already failed: tcp://172.31.205.58:61616 at org.apache.activemq.transport.AbstractInactivityMonitor.doOnewaySend(AbstractInactivityMonitor.java:315) at org.apache.activemq.transport.AbstractInactivityMonitor.oneway(AbstractInactivityMonitor.java:304) at org.apache.activemq.transport.TransportFilter.oneway(TransportFilter.java:85) at org.apache.activemq.transport.WireFormatNegotiator.oneway(WireFormatNegotiator.java:104) at org.apache.activemq.transport.MutexTransport.oneway(MutexTransport.java:68) at org.apache.activemq.transport.ResponseCorrelator.asyncRequest(ResponseCorrelator.java:81) at org.apache.activemq.transport.ResponseCorrelator.request(ResponseCorrelator.java:86) at org.apache.activemq.ActiveMQConnection.syncSendPacket(ActiveMQConnection.java:1380) ... 13 more ``` 因此开始走上 debug 这条不归路... ## 定位问题 上面报的错误, 一开始怀疑是 ActiveMQ 消费者连接断开, 导致发送不了消息, 因此一来就开始 debug `sendMsg()` 这个最终发送消息的方法. ```java private void sendMsg(String queue, String jsonStr) throws Exception { Connection connection = null; Session session = null; MessageProducer producer = null; try { //从连接池工厂中获取一个连接 connection = this.connectionFactory.createConnection(); //false 参数表示 为非事务型消息,后面的参数表示消息的确认类型 session = connection.createSession(Boolean.FALSE, Session.AUTO_ACKNOWLEDGE); //PTP消息方式 Destination destination = session.createQueue(queue); //Destination is superinterface of Queue producer = createProducer(producer, session, destination); //map convert to javax message Message message = getMessage(session, jsonStr); producer.send(message); log.info("send message, producer = {}", producer.getClass()); } finally { closeSession(session); closeConnection(connection); } } ``` 先说这个方法的问题: 当每次发送消息时都会创建一个 ActiveMQ 连接, 然后创建一个 session, 最后创建一个 producer, 消息发送完成后关闭连接. 频繁的创建关闭连接将消耗大量系统资源, 降低性能, 因此一般使用连接池来保存连接. ![20241229154732_5j5D0l4d.webp](https://cdn.dong4j.site/source/image/20241229154732_5j5D0l4d.webp) ![20241229154732_7JQ0LlKY.webp](https://cdn.dong4j.site/source/image/20241229154732_7JQ0LlKY.webp) 从 debug 日志中也可以看出来, 连接后又 close 了. 因此将 Connection, Session, Producer 进行复用. (这也是 ActiveMQ 官方推荐的做法). 将创建 producer 的整个操作放到 init() 中, 只执行一次. ```java private MessageProducer producer = null; private void init() throws Exception { //设置JAVA线程池 this.threadPool = Executors.newFixedThreadPool(this.threadPoolSize); //ActiveMQ的连接工厂 ActiveMQConnectionFactory actualConnectionFactory = new ActiveMQConnectionFactory(this.userName, this.password, this.brokerUrl); actualConnectionFactory.setUseAsyncSend(this.useAsyncSendForJMS); //Active中的连接池工厂 this.connectionFactory = new PooledConnectionFactory(actualConnectionFactory); this.connectionFactory.setCreateConnectionOnStartup(true); this.connectionFactory.setMaxConnections(this.maxConnections); this.connectionFactory.setMaximumActiveSessionPerConnection(this.maximumActiveSessionPerConnection); Connection connection; Session session; //从连接池工厂中获取一个连接 connection = this.connectionFactory.createConnection(); //false 参数表示 为非事务型消息,后面的参数表示消息的确认类型 session = connection.createSession(Boolean.FALSE, Session.AUTO_ACKNOWLEDGE); //PTP消息方式 Destination destination = session.createQueue("BiUserStatusSignal"); //Destination is superinterface of Queue producer = createProducer(producer, session, destination); } ``` 重写 `sendMsg()` ```java private void sendMsg(String jsonStr) throws Exception { ActiveMQTextMessage message = new ActiveMQTextMessage(); message.setText(jsonStr); producer.send(message); log.info("send"); } ``` 当我以为就这么容易的把问题解决的时候, 新的错误又来了(如果问题就这么解决了, 我也不会写这个文档了). ```java Caused by: org.apache.activemq.transport.InactivityIOException: Cannot send, channel has already failed: tcp://172.31.205.58:61616 at org.apache.activemq.transport.AbstractInactivityMonitor.doOnewaySend(AbstractInactivityMonitor.java:315) at org.apache.activemq.transport.AbstractInactivityMonitor.oneway(AbstractInactivityMonitor.java:304) at org.apache.activemq.transport.WireFormatNegotiator.sendWireFormat(WireFormatNegotiator.java:168) at org.apache.activemq.transport.WireFormatNegotiator.sendWireFormat(WireFormatNegotiator.java:84) at org.apache.activemq.transport.WireFormatNegotiator.start(WireFormatNegotiator.java:74) at org.apache.activemq.transport.TransportFilter.start(TransportFilter.java:58) at org.apache.activemq.transport.TransportFilter.start(TransportFilter.java:58) at org.apache.activemq.ActiveMQConnectionFactory.createActiveMQConnection(ActiveMQConnectionFactory.java:273) ... 25 more java.lang.NullPointerException at com.xxxx.msearch.core.support.activemq.producer.JmsProducer.sendMsg(JmsProducer.java:155) at com.xxxx.msearch.core.support.activemq.producer.JmsProducer.access$100(JmsProducer.java:24) at com.xxxx.msearch.core.support.activemq.producer.JmsProducer$2.run(JmsProducer.java:137) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) at java.lang.Thread.run(Thread.java:748) ``` 这次不仅出现了 `NullPointerException`, 以前的异常还是存在, ActiveMQ 的连接依然很多 ![20241229154732_37jKEmDb.webp](https://cdn.dong4j.site/source/image/20241229154732_37jKEmDb.webp) 唯一改善的就是**异常出现的频率降低了**. 因此这个不是最根本的原因. 那么思考一下为什么会出现 `NullPointerException`. 上面的代码肯定是没有问题的, 除非是在**多线程环境**下. 另一个很严重的问题: ![20241229154732_JHWp8RuI.webp](https://cdn.dong4j.site/source/image/20241229154732_JHWp8RuI.webp) 😳😳 居然能有 40 多个线程池...... 好了, 基本知道什么原因导致的了, **并发 + 线程池**问题. 看看运行时的线程: ![20241229154732_3MHihWXd.webp](https://cdn.dong4j.site/source/image/20241229154732_3MHihWXd.webp) 垃圾回收频繁, 线程数还在不断增长..... ## 追踪代码 初始化 producer 的连接池就是 `init()` 的这段代码 ```java //设置JAVA线程池 this.threadPool = Executors.newFixedThreadPool(this.threadPoolSize); ``` 能初始化多个连接池, `init()` 肯定被错误的执行了多次. 通过查看 `BiUserStatusTask` 这个类,能执行 `init()` 的也只有下面的代码了. ```java JmsProducerFactory jmsProducerFactory = new JmsProducerFactory(producerJmsConfig); ... jmsProducerFactory.start(); ``` `start()` 方法会判断 JmsProducer 是否 null, 为 null 才会调用 `init()` 来创建线程池 ```java public synchronized void start() { if(started.get()) { return; } //配置下发到相关组件 deliverConfig(); started.set(true); } private void deliverConfig(){ if (jmsProducer == null) { jmsProducer = new JmsProducer(jmsConfig.getBrokerURL(), jmsConfig.getUserName(), jmsConfig.getPassword()); } } ``` 整个 `BiUserStatusTask` 类的代码也没有看出哪个地方会多次创建线程池. 没办法只有加日志. ![20241229154732_NirD1EBV.webp](https://cdn.dong4j.site/source/image/20241229154732_NirD1EBV.webp) 问题找到了, 是进入 catch 了 😥. ![20241229154732_o2lyOx1X.webp](https://cdn.dong4j.site/source/image/20241229154732_o2lyOx1X.webp) 原来是 `JsonUtil.jsonToMap(text)` 解析抛异常了 😰. (为什么我没有在 `BiUserStatusTask` 中打断点, 因为一直以为是 producer 发送消息的代码有问题). 最好的 debug 方式就是**日志**, 因为 debug 很慢, 而且多线程的时候并不好 debug. 上面的代码在 catch 中直接就发送异常消息然后入库, 导致日志中没有错误信息. catch 中一定要打印日志, 因为这个属于系统异常日志, 是给开发的人看的, 而需要入库的日志一般都是业务日志或者业务异常日志, 开发的时候谁再去查一下数据库有没有异常日志啊! 直接打印到日志不是更好吗? **效率就是生命**! 也请不要把所有代码都包裹在 try-catch 里面, 最好把异常分类处理, 一个 catch 就把所有异常捕获了倒是简单, 但是不好排查问题, 也会影响执行效率. 写代码的时候, 尽量把异常暴露出来, 不要忽略 catch. 在编码阶段就尽可能多的处理异常, 而不是上线了写到数据库, 然后统计异常数据来报警. 以前写过些方面的问题, 估计也没人在意吧. ## 进入主题 好了, 重点来了. catch 里面也会发送 MQ 消息, 会不会是这里面的问题呢? 那我们先来捋一捋 `LogSupportException.writeFuncExceptionLog()` 这个方法 ```java public class LogSupportException { private static LogService logService = LogServiceFactory.getLogService(); /** * 封装错误信息 */ public static void writeSupportExceptionLog(Exception e, String componentName, String methodName, String className, String inputParams, String logDesc, LogSupportException.ErrorLevel errorLevel) { ... saveException(logField); } ... } ``` `writeSupportExceptionLog()` 是封装处理信息的处理 (性能很低, 就不吐槽了, 自己去看吧). 会调用 `logService.saveLog()` 发送异步消息. 那么 `logService` 是哪里来的呢? ```java private static LogService logService = LogServiceFactory.getLogService(); ``` 是一个静态属性, 这里复习一下类的初始化顺序. .java 被编译成 .class 被 Classloader 加载到 JVM 的时候, 首先会调用 **static 代码块 **和初始化 **静态属性** (这个看 2 者代码的顺序), 如果新创建一个对象的时候, 会先执行**代码块**, 然后才是**构造方法**. 那么问题来了, 子类父类初始化的顺序是什么呢? `logService` 是一个静态属性, 会在被 JVM 加载的时候就初始化, 不管有没有创建这个类的实例. 因此我们进入到 `LogServiceFactory.getLogService()` ```java public class LogServiceFactory { private static ApplicationContext context = null; private static LogService logService = null; private static LogService logServiceB = null; private static boolean initFlag = false; synchronized static void init() { if (!initFlag) { context = new ClassPathXmlApplicationContext( "classpath*:applicationContext-jms.xml"); initFlag = true; } } synchronized static void initLogService() { if (initFlag && logService == null) { logService = (LogService) context.getBean("logServiceConcurrent"); logServiceB = (LogService) context.getBean("logServiceConcurrentB"); initFlag = true; } } public static LogService getLogService() { if (!initFlag) { init(); } if (initFlag && logService == null) { initLogService(); } return logService; } public static LogService getLogServiceB() { if (!initFlag) { init(); } if (initFlag && logServiceB == null) { initLogService(); } return logServiceB; } public static T getBean(String name) throws BeansException { return (T) context.getBean(name); } } ``` 哈哈哈 熟悉吧, 使用静态代码块来初始化 Spring 容器 ```java synchronized static void init() { if (!initFlag) { context = new ClassPathXmlApplicationContext( "classpath*:applicationContext-jms.xml"); initFlag = true; } } ``` 其实这里使用 `synchronized` 是多余的, 因为 Classloader 从 JVM 底层上就保证了加载一个类的同步性, 避免了并发问题. 记住哦, 这里是**第一次**使用 `new ClassPathXmlApplicationContext()` 来初始化 Spring 容器, 配置文件是 `applicationContext-jms.xml`, 在**第二次 **的时候再说这么做存在的问题. 那么 `logService` 从 Spring 容器中获取到了, 然后调用 `saveLog()`, 下面是 `saveLog()` 的实现: ```java @Override public boolean saveLog(String key, Object logMessage) { String queueName = JmsTemplateFacotry.getJmsConfig().getQueue(); ObjectEvent objectEvent = new ObjectEvent(key, logMessage); return sendToMq(queueName, objectEvent); } ``` 没什么特别之处, 就是从配置中获取队列名, 然后调用 `sendToMq()`, 但是得一步一步跟代码呀, 不然怎么知道有什么问题. ```java String queueName = JmsTemplateFacotry.getJmsConfig().getQueue(); ``` 那我们就看看 `JmsTemplateFacotry` 这个类, ```java public class JmsTemplateFacotry { private static JmsProducer jmsProducer; private static JmsConsumer jmsConsumer; private static JmsConfig jmsConfig; static { ApplicationContext context = new ClassPathXmlApplicationContext( "classpath*:applicationContext-jms.xml"); jmsConfig = (JmsConfig) context.getBean("jmsTemplateConfig"); } public static void initProducer(){ if (jmsProducer == null) { jmsProducer = new JmsProducer(jmsConfig.getBrokerURL(), jmsConfig.getUserName(), jmsConfig.getPassword()); } } public static void initConsumer(){ if(jmsConsumer == null){ jmsConsumer = new JmsConsumer(jmsConfig.getBrokerURL(),jmsConfig.getUserName(),jmsConfig.getPassword()); } } public static void messageSender(String queue,String jsonStr){ initProducer(); jmsProducer.send(queue, jsonStr); } public static JmsConsumer getJmsConsumer(){ initConsumer(); return jmsConsumer; } public static JmsConfig getJmsConfig(){ return jmsConfig; } } ``` 😁 看见没,又是个静态代码块, **第二次**通过 `new ClassPathXmlApplicationContext()` 初始化 Spring 容器了哦, 配置文件还是 `applicationContext-jms.xml`. ### Spring 初始化问题 那我们来说说两次初始化 Spring 容器的问题. ```java ApplicationContext context = new ClassPathXmlApplicationContext("classpath*:applicationContext-jms.xml"); ``` 如果调用多次上面的方法, 将导致初始化多个 Spring 容器 第一个: ![20241229154732_Vb1Unnl6.webp](https://cdn.dong4j.site/source/image/20241229154732_Vb1Unnl6.webp) ![20241229154732_BnXpQpfk.webp](https://cdn.dong4j.site/source/image/20241229154732_BnXpQpfk.webp) 第二个: ![20241229154732_71YaDYHx.webp](https://cdn.dong4j.site/source/image/20241229154732_71YaDYHx.webp) ![20241229154732_can7KSuN.webp](https://cdn.dong4j.site/source/image/20241229154732_can7KSuN.webp) 也就是说同一个 bean 会被初始化 2 次. **Spring 容器中的 bean 默认是单例的**, 说的是**同一个 Spring 容器**只能存在一个相同的 bean. 如果是 Spring + Spring MVC 相同的 bean 被初始化 2 次, 会导致事务不生效, @Value 不生效等各种各样的问题, 因此最佳实践是把 Spring 容器和 Spring MVC 容器分开加载, 每个容器只初始化对应的 bean. 重复的初始化造成资源浪费, 而且还会导致不确定性问题出现, 所以以前老的初始化方式不可取, 正确的做法: 子包只需要提供功能即可, **不要自作主张的初始化**. 初始化的工作统一由部署包(需要运行的主类或 Web)来做, 通过 import 子包的 xml 配置, 统一由父容器来管理所有 bean. 这样可以统一管控, 避免随意初始化. 对于配置文件也是这个道理. ### 并发问题 说了这么多, 终于要说根本的问题了. LogServiceAsyncJmsImpl 异步向 mq 中发送异常日志的方法 ```java private boolean sendToMq(String queue, ObjectEvent objectEvent) { try { String jsonEvent = objectEvent.getKey() + "-" + JsonUtil.objectToJson(objectEvent .getMsg()); // 注意重点代码 JmsTemplateFacotry.messageSender(queue, jsonEvent); return true; } catch (Exception ex) { log.error("JmsTemplateFacotry messageSender Error : {}", ex.getMessage()); } return false; } ``` messageSender() 的实现 ```java public static void messageSender(String queue,String jsonStr){ initProducer(); jmsProducer.send(queue, jsonStr); } public static void initProducer(){ if (jmsProducer == null) { jmsProducer = new JmsProducer(jmsConfig.getBrokerURL(), jmsConfig.getUserName(), jmsConfig.getPassword()); } } ``` `messageSender()` 会先判断 jmsProducer 是否为 null, 为 null 就实例化一个 `JmsProducer` 对象, 实例化 `JmsProducer` 对象时, 会调用上面创建线程池的 `init()`. 看着是个很合理的逻辑, 但是却没有考虑**并发**的问题. 如果是多线程, 会出现上面情况? 在分析之前, 先来复习下使用 new 创建一个对象的过程: ![20241229154732_iZvvDu57.webp](https://cdn.dong4j.site/source/image/20241229154732_iZvvDu57.webp) 一个类被 JVM 加载的时机: 1. 使用 new 关键字实例化对象的时候; 2. 读取或设置这个类的静态字段(被 final 修饰,已在编译器把结果放入常量池的静态字段除外)的时候; 3. 调用这个类的静态方法的时候; 4. 使用 java.lang.reflect 包对这个类进行反射调用的时候; 5. 当虚拟机启动, 直接指定一个要执行的类(也就是包含 main() 的主类); 上面是类的初始化的 5 种情况, 通过阅读 `JmsProducer` 类的代码, 我们可以确定**第一次初始化** `JmsProducer` 时, 就是通过 new 关键字. 因此先执行 `JmsProducer` 的初始化流程, 最终创建 `JmsProducer` 类的 class 对象. 注意哦, 如果一开始 JVM 没有加载过 `JmsProducer` 这个类, 会先对类进行加载从而生成当前类的 class 对象, 并不会生成 `JmsProducer` 类的实例对象. 以上流程, JVM 都能保证是同步的, 因此同一个类型只能被**同一个类加载器**加载一次. > 具体可见 「深入理解 Java 虚拟机」第 7 章 只有当使用 `new` 关键字时, 如果没有被 JVM 初始化就走上面的流程, 如果已被初始化了, 才开始走**类的实例化流程**, ![20241229154732_Zv0CZLC5.webp](https://cdn.dong4j.site/source/image/20241229154732_Zv0CZLC5.webp) 那我们来分析一下这段代码在多线程的情况下会出现上面问题: ```java public static void initProducer(){ if (jmsProducer == null) { jmsProducer = new JmsProducer(jmsConfig.getBrokerURL(), jmsConfig.getUserName(), jmsConfig.getPassword()); } } ``` **先说第一个好理解的情况:** 因为是多线程环境, 可能同时多个线程一起进入 if 判断逻辑, 因为 `jmsProducer == null` 为 true, 会执行多次实例化流程. **先来说说另一个复杂点的情况:** 当第一次执行 `JmsTemplateFacotry.initProducer()` 时, `jmsProducer == null` . 当 **线程 1** 进入 if 判断, 由于 `jmsProducer == null` 为 true, 会执行实例化流程. 这个时候 **线程 2** 进入 if 判断逻辑, 由于实例化流程也需要时间, 在还没有实例化完成之前, `jmsProducer == null` 为 true, 因此 **线程 2** 会再次实例化一个 `jmsProducer`. 总结一下实例化对象的过程: 1. 分配内存 2. 初始化对象(内存赋值) 3. 内存地址赋给 instance (instance != null) 以上原因也就直接**导致了创建多个线程池** !!! 就这么简单, 由于多线程并发执行同一段代码. 做事要验证, 那我们来验证一下 ```java @Test public void test1() throws InterruptedException { for (int index = 0; index < 1000; index++) { new Thread(new Runnable() { public void run() { try { log.info("jsmProducer = {}", JmsTemplateFacotry.getJmsProducer()); } catch (Exception e) { e.printStackTrace(); } } }).start(); } Thread.currentThread().join(); } ``` 输出: ``` com.xxxx.msearch.core.support.activemq.producer.JmsProducer@546dd457 com.xxxx.msearch.core.support.activemq.producer.JmsProducer@63666aa6 com.xxxx.msearch.core.support.activemq.producer.JmsProducer@63a9e6ce com.xxxx.msearch.core.support.activemq.producer.JmsProducer@65da9f79 com.xxxx.msearch.core.support.activemq.producer.JmsProducer@3c657f0b com.xxxx.msearch.core.support.activemq.producer.JmsProducer@679c614 com.xxxx.msearch.core.support.activemq.producer.JmsProducer@619ef7cf com.xxxx.msearch.core.support.activemq.producer.JmsProducer@27081999 com.xxxx.msearch.core.support.activemq.producer.JmsProducer@1e4acc1c com.xxxx.msearch.core.support.activemq.producer.JmsProducer@52370b34 com.xxxx.msearch.core.support.activemq.producer.JmsProducer@e8a0743 com.xxxx.msearch.core.support.activemq.producer.JmsProducer@d882d64 .... ``` 对吧, 实例化了好多次吧 ## 解决问题 知道了问题所在, 解决问题就很容易了, 我们只需要保证 `JmsProducer` 是单例的就可以了 [**单例的所有写法会了吗?**](http://interview.dong4j.info/design-patterns/singleton.html) 这里只使用最可靠最简单的一种方式: **枚举** (第二个推荐静态内部类, 不推荐 DCL, 因为 DCL 并不完全可靠) ```java /** *

Company: xxxx公司

*

Description: 枚举单例获取 JmsProducer, 保证只有一个实例

* * @author dong4j * @date 2019-03-08 11:04 * @email dong4j@gmail.com */ public enum JmsProducerEnum { INSTANCE; private JmsProducer instance; private JmsConfig jmsConfig; public void setJmsConfig(JmsConfig jmsConfig){ this.jmsConfig = jmsConfig; } public JmsProducer getInstance() { if(instance == null){ instance = new JmsProducer(jmsConfig.getBrokerURL(), jmsConfig.getUserName(), jmsConfig.getPassword()); } return instance; } } ``` 测试一下: ```java @Test public void test2() throws InterruptedException { for (int index = 0; index < 1000; index++) { new Thread(new Runnable() { public void run() { try { JmsProducerEnum instance = JmsProducerEnum.INSTANCE; instance.setJmsConfig(JmsTemplateFacotry.getJmsConfig()); log.info("jsmProducer = {}", instance.getInstance()); } catch (Exception e) { e.printStackTrace(); } } }).start(); } Thread.currentThread().join(); } ``` 输出: ``` com.xxxx.msearch.core.support.activemq.producer.JmsProducer@6aa6a851 com.xxxx.msearch.core.support.activemq.producer.JmsProducer@6aa6a851 com.xxxx.msearch.core.support.activemq.producer.JmsProducer@6aa6a851 com.xxxx.msearch.core.support.activemq.producer.JmsProducer@6aa6a851 com.xxxx.msearch.core.support.activemq.producer.JmsProducer@6aa6a851 com.xxxx.msearch.core.support.activemq.producer.JmsProducer@6aa6a851 ... ``` 最终的修改方案 ```java public class JmsTemplateFacotry { private static JmsProducer jmsProducer; private static JmsConsumer jmsConsumer; private static JmsConfig jmsConfig; static { ApplicationContext context = new ClassPathXmlApplicationContext( "classpath*:applicationContext-jms.xml"); jmsConfig = (JmsConfig) context.getBean("jmsTemplateConfig"); JmsProducerEnum instance = JmsProducerEnum.INSTANCE; instance.setJmsConfig(jmsConfig); jmsProducer = instance.getInstance(); } ... } ``` 这里还用了双重保证, JVM 保证 static 代码块只执行一次, 枚举单例再保证唯一实例. ## 为什么会出现并发问题 `BiUserStatusTask` 类中的初始化代码 ```java @Override public void init() { ringAdapter = (RingAdapter) DataCache.getContext().getBean("ringAdapter"); JmsConfig producerJmsConfig = new JmsConfig(); producerJmsConfig.setBrokerURL(MQ_URL); producerJmsConfig.setUserName(MQ_USER_NAME); producerJmsConfig.setPassword(MQ_USER_PASSWORD); jmsProducerFactory = new JmsProducerFactory(producerJmsConfig); JmsConfig consumerJmsConfig = new JmsConfig(); consumerJmsConfig.setBrokerURL(MQ_URL); JmsConsumerFactory jmsConsumerFactory = new JmsConsumerFactory(consumerJmsConfig); jmsConsumerFactory.getJmsConsumer().setQueue(MQ_SIGNAL_NAME); jmsConsumerFactory.getJmsConsumer().setQueuePrefetch(Integer.valueOf(QUEUE_SIZE)); jmsConsumerFactory.getJmsConsumer().setMessageListener(new MultiThreadMessageListener(Integer.parseInt(MQ_THREAD), new MessageHandler() { @Override public void handle(Message message) { try { logger.debug("执行任务:" + taskName + "休眠!"); Thread.sleep(Integer.parseInt(MQ_SLEEP)); getUserStatusData(message); } catch (Exception e) { e.printStackTrace(); } } })); try { jmsConsumerFactory.getJmsConsumer().start(); } catch (Exception e) { logger.error(CommonFunc.getExceptionStack(e)); LogSupportException.writeFuncExceptionLog(e, "异步工具", "run-BiUserStatusTask", this.getClass().getSimpleName(), LogSupportException.ErrorLevel.ERROR); } } ``` 用于接收 `BiUserStatusSignal` 队列消息的消费者也维护了一个线程池, 在 `MessageListener` 里面, 当拿到一批消息后, 会通过**多线程**来处理, 也就是 `getUserStatusData()` 方法, 当这个方法进入 catch 时, 最终会通过 `JmsTemplateFacotry` 来发送消息, 然后在实例化 `JmsProducer` 时没有考虑到多线程问题, 导致创建多个线程池. ## 可靠的单元测试 `iconmons-mq` 这个组件只测试了 `JMSProducer` 和 `JMSConsumer`, 却没有测试几个 Factory 类, 而且单元测试应该使用 Junit, **不要使用 main()**, 更不要使用 `System.out.println`. 尽量做到规范化, 可测试化. 为什么 `JMSProducer` 测试了 100w 次也没问题, 因为是通过手动 new 的方式创建, 而且只有一次, 这样就保证了 JMSProducer 单例. 最后进行集成测试 ![20241229154732_RW96blZf.webp](https://cdn.dong4j.site/source/image/20241229154732_RW96blZf.webp) 没再出现错误日志 运行时数据: ![20241229154732_IM7GfOOx.webp](https://cdn.dong4j.site/source/image/20241229154732_IM7GfOOx.webp) 长时间运行, GC 次数少, 线程数保持在 117. ## 问题总结 ![20241229154732_bZVrlIir.webp](https://cdn.dong4j.site/source/image/20241229154732_bZVrlIir.webp) 通过这次的问题修复, 我们涉及到了, 并发, 线程池, 类的初始化, 类的实例化, Spring 容器的初始化等相关知识点, 也清楚了说明了老代码中存在的一些框架问题. ## 对于 12530 重构的想法 个人觉得老框架不需要改了, 真改不动了, 如果没有改完或者没有经过大量的测试, 是很容易出现问题的. 主要是以前的代码结构太烂, 框架结构也不合理, 最重要的是相关依赖管理太随意了, 根本就没有**管理**.各组件的版本管理也不规范, 导致后期维护性大打折扣. 现在能做的就是在不动主体框架的情况下, 尽量重构代码. 当然我们也做过这方面的努力, 重构项目依赖管理, 重构日志, 重构配置, 重构组件, 但是改出来的东西却不尽人意, 给其他同事造成了工作上的负担. 不过这是重构阶段必须要经历的情况. 12530 业务多, 代码多, 组件多, 基本上是牵一发而动全身, 因此在没有大量测试的情况下, 很难保证重构后的正确性与稳定性. 因此按照我的建议就是不要改老代码了, 把业务迁移到 `ms-project` 上来, 至少依赖管理, 配置管理, 日志管理这些做的比老框架好, 代码也更规范. 怎么重构代码以前或多或少说过一点, 这里再重申一下: **只要把 IDEA 提示的警告改完就可以了**, 这种重构是对业务和测试影响最小的方式. 以前也说过怎么通过修改警告来学习底层的知识, 为什么 IDEA 对这段代码提出警告, 有没有更好的更规范的代码实现这段逻辑? 在写业务逻辑的时候是不是沿用以前的代码规范和思考逻辑? 以前的代码就一定正确吗? 有没有优化的空间呢? 举个例子: 在做精准营销的时候, 先把相关代码捋一遍, 清楚个大概逻辑, 不需要深入看代码. **第一遍: 把遇到的所有警告提示看一遍** ```java Map inputStrings = new HashMap(); ``` 这段代码有问题吗? IDEA 已经给出了提示 1. `new HashMap` 可以简化为 `new HashMap<>`; 2. 初始化 HashMap 时设置初始大小; 如果你看到这个提示, 你会不会想为什么能简化成 `new HashMap<>`? 为什么最好为 HashMap 设置初始值? 你就会去查资料, 因为 JDK7 的新特性, 叫钻石语法, 那么你还可以查一下 JDK7 其他新的语法, 看是否能用到项目中. 给 HashMap 设置初始值是为了合理分配内存, 减少 resize 的次数, 从而提高效率. 那你就会去看 HashMap 源码, 你就会知道: 1. 什么情况下回 resize; 2. resize 后的容量是多少; 3. 负载因子又是什么; 4. 为什么 HashMap 不是线程安全的; 5. 有没有线程安全的 HashMap, 有哪些; 6. HashMap 的存储方式是怎样的; JDK7 和 JDK8 的实现方式有什么不同; 7. 如果 key 是对象为什么要重写 hashCode() 和 equals()方法; 8. 为什么 HashMap 一般使用 String 做 key; 9. .... 然后再深入一些: 1. 在多线程的情况下, 使用 HashMap 存在的问题; 2. HashMap 与 ConcurrentHashMap 的区别; ConcurrentHashMap 又是怎么实现的; 3. 能不能说出 put() 的逻辑; 4. 更深入的了解 hashCode() 的作用; 5. 自己设计一个 hash 方法, 减少 hash 碰撞; 6. 能通过什么方式提高 HashMap 的查询效率; 7. .... 那说到 String, 又可以去看 String 的源码了, 然后你就会明白: 1. 为什么 String 是不可变类; 自己怎么设计一个不可变类; 2. 为什么我们在循环里面不使用 `+` 来拼接字符串; 3. 与 StringBuffer, StringBuilder 的区别是什么; 4. ... 然后再深入一些: 1. String 的在内存中的存储位置; 2. 从 JDK6 开始, 是怎么优化 String 的; 3. String 的不可变性是绝对的吗? 可不可以使用一些手段修改 String; 4. .... **就看 1 行代码, 你能联想到这些问题吗?** 不是说只写业务代码就不能学到东西, 学东西在哪儿都可以学到, 只要有这个心就行. 先把基础知识学好了, 框架这些都是锦上添花的事. **第二遍: 把代码结构梳理一遍** 简单的重构从第二遍开始, 一个方法超过 **80** 行, 就该拆分了. 1. 有没有代码是共用的? 2. 能不能抽离成工具类? 3. 注释写好了吗? 4. 代码逻辑清晰了吗? 5. 注释掉的代码删没删? 留着干嘛? 算代码行数? 我们有版本管理, 不要的请直接删除. 6. 字段名, 方法名命名规范吗? 7. 有魔法值吗? 8. tay-catch 合理包裹合理吗? 异常处理方式合理吗? 有没处理到的异常吗? 9. 必要的日志打印了吗? 日志等级设置合理吗? 10. 重载的方法写 `@Override` 注释了吗? 11. 方法的访问修饰符合理吗? 返回值合理吗? 入参合理吗? 12. switch case 到了全部情况吗? 13. if 判断合理吗? 14. return 的地方合理吗? 15. ... 把你看到的不合理的地方全部重构了, 这一步也全部是借助 IDEA 强大的重构功能, 比如选中你想抽离为方法的代码, `ctl + shift + m` (windows 的快捷键不清楚, 好像是这个)自动重构, 只需要命名就可以了 ![20241229154732_7VgzmY90.webp](https://cdn.dong4j.site/source/image/20241229154732_7VgzmY90.webp) 其他的重构快捷键和功能自己去了解和使用. **把吃饭的工具使用熟练是最基本的要求**, 然后就是效率问题, **能自动化绝不手动, 能节约 1 秒 时间的事, 我宁愿话 1 个小时来学习.** 现在使用的工具有没有更好的工具可以替代? 有没有去了解过同类工具? 请记住, **工具就是你的兵器**, 一把趁手的兵器比手无寸铁好得多 **第三遍: 梳理业务逻辑** 这一遍就可以开始开发业务了. 了解需求, 先想怎么写, 不要一上来就开始写代码, 想一个方案出来, 相关的单元测试写出来, 再想想: 1. 还有没有更好的实现方式? 2. 以前的代码存在的问题? 3. 以前的逻辑还能怎样优化? 4. 以前的接口定义合理吗? 5. 能不能运用到设计模式把业务抽离出来? 提高维护性和可扩展性? 6. .... 这部分没有太多话语权, 毕竟做的少. 举个例子吧: ![20241229154732_Ow8tJ5Na.webp](https://cdn.dong4j.site/source/image/20241229154732_Ow8tJ5Na.webp) 不是代码写得越多就越好, 不是方法越多代码结构就清晰. ![20241229154732_amOis5Tf.webp](https://cdn.dong4j.site/source/image/20241229154732_amOis5Tf.webp) 不要按照以前老的思路来写代码, 要有自己的思考. 以前的逻辑合理吗? 接口规范合理吗? 返回结果合理吗? ![20241229154732_Jqtt2ttF.webp](https://cdn.dong4j.site/source/image/20241229154732_Jqtt2ttF.webp) 业务端传入 servicedId 来查指定的业务的订购状态, 为什么还要通过接口的 serviceId 返回类型来判断是不是查询的当前业务? 难道我查询的什么业务还要通过接口来告诉我吗? 那传个 serviceId 还有什么意义? 当然这里也有历史原因, 或者都可以说全部是历史原因, 但是我们现在可以改变, 可以重构, 为了更好的将来, 为了下一批维护者少掉坑里面, 这些都可以改.... 写得代码不是**能跑通**就可以了(「又不是不能跑」 😳), 需要思考和反思. 最后多回顾自己的代码, 随着自己知识面的扩展, 知识点的加深, 再看看以前的代码是不是又有更好的实现方式, 再次重构啊. ## [Mac mini 开发环境搭建全攻略](https://blog.dong4j.site/posts/345a28fd.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 记录 mac mini 开发环境的搭建过程 ## 系统设置 ### 修改 Launchpad 图标大小 ``` 行: defaults write com.apple.dock.springboard-rows -int 7 列: defaults write com.apple.dock.springboard-columns -int 9 重启 dock 恢复: defaults write com.apple.dock.springboard-rows default defaults write com.apple.dock.springboard-columns default killall dock defaults write com.apple.dock springboard-columns -int 11;defaults write com.apple.dock springboard-rows -int 7;defaults write com.apple.dock ResetLaunchPad -bool TRUE;killall Dock ``` ### xxx.app 已损坏, 打不开. 你应该将它移到废纸篓 ``` sudo spctl --master-disable ``` ### Mac 三指拖动设置 1. 找到系统偏好设置 中的辅助功能 2. 选中鼠标和控制板 -> 触控板选项 3. 勾选启用拖移 -> 好 设置以上步骤就可以用三指自由拖动窗口了. ## JDK ## ssh ## brew ``` /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" ``` ## zsh ## oh-my-zsh ## IDEA ## Docker ## surge ``` brew install libfaketime ``` ``` FAKETIME_STOP_AFTER_SECONDS=30 faketime '2007-01-01 00:00:00' /Applications/Surge.app/Contents/MacOS/Surge & export https_proxy=http://127.0.0.1:6152;export http_proxy=http://127.0.0.1:6152;export all_proxy=socks5://127.0.0.1:6153 ``` ## 安装 code 命令 在 vscode 下通过快捷键 shift + command + p 运行命令 shell code ## 安装 zsh-syntax-highlighting 语法高亮插件 安装 zsh-syntax-highlighting 语法高亮插件 官网: [https://github.com/zsh-users/zsh-syntax-highlighting](https://github.com/zsh-users/zsh-syntax-highlighting) 安装: ``` git clone https://github.com/zsh-users/zsh-syntax-highlighting.git echo "source ${(q-)PWD}/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh" >> ${ZDOTDIR:-$HOME}/.zshrc ``` 生效: ``` source ~/.zshrc ``` ## 安装 Powerlevel9k --- 一个美观而又实用的 ZSH 主题 [https://www.jianshu.com/p/f84cf6132d1e](https://www.jianshu.com/p/f84cf6132d1e) [https://mp.weixin.qq.com/s/tWrxxrRyKAGohJfq8LUAGQ](https://mp.weixin.qq.com/s/tWrxxrRyKAGohJfq8LUAGQ) ``` git clone https://github.com/bhilburn/powerlevel9k.git ~/.oh-my-zsh/custom/themes/powerlevel9k ``` ``` ZSH_THEME="powerlevel9k/powerlevel9k" ``` 安装字体 ``` # clone git clone https://github.com/powerline/fonts.git # install cd fonts ./install.sh # clean-up a bit cd .. rm -rf fonts ``` ## 导出 iterm 配置 [https://www.jianshu.com/p/c251d26374c5](https://www.jianshu.com/p/c251d26374c5) ## 自动提示插件 zsh-autosuggestions ## [Java 常用四大线程池用法以及 ThreadPoolExecutor 详解](https://blog.dong4j.site/posts/3b7cca00.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 为什么用线程池 1. 创建 / 销毁线程伴随着系统开销, 过于频繁的创建 / 销毁线程, 会很大程度上影响处 - 理效率 2. 线程并发数量过多, 抢占系统资源从而导致阻塞 3. 对线程进行一些简单的管理 在 Java 中, 线程池的概念是 Executor 这个接口, 具体实现为 ThreadPoolExecutor 类, 学习 Java 中的线程池, 就可以直接学习他了对线程池的配置, 就是对 ThreadPoolExecutor 构造函数的参数的配置 ## 构造函数: ```java // 五个参数的构造函数 public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue) // 六个参数的构造函数 -1 public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory) // 六个参数的构造函数 -2 public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, RejectedExecutionHandler handler) // 七个参数的构造函数 public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) ``` 下面来解释下各个参数: - **int corePoolSize**: 该线程池中 **核心线程数最大值** **核心线程**: 线程池新建线程的时候, 如果当前线程总数小于 corePoolSize, 则新建的是核心线程, 如果超过 corePoolSize, 则新建的是非核心线程核心线程默认情况下会一直存活在线程池中, 即使这个核心线程啥也不干 (闲置状态). 如果指定 ThreadPoolExecutor 的 allowCoreThreadTimeOut 这个属性为 true, 那么核心线程如果不干活 (闲置状态) 的话, 超过一定时间( 时长下面参数决定), 就会被销毁掉. - **int maximumPoolSize**: 该线程池中 **线程总数最大值** 线程总数 = 核心线程数 + 非核心线程数. - **long keepAliveTime**: 该线程池中 **非核心线程闲置超时时长** 一个非核心线程, 如果不干活 (闲置状态) 的时长超过这个参数所设定的时长, 就会被销毁掉, 如果设置 allowCoreThreadTimeOut = true, 则会作用于核心线程. - **TimeUnit unit**: keepAliveTime 的单位 TimeUnit 是一个枚举类型, 其包括: NANOSECONDS : 1 微毫秒 = 1 微秒 / 1000 MICROSECONDS : 1 微秒 = 1 毫秒 / 1000 MILLISECONDS : 1 毫秒 = 1 秒 /1000 SECONDS : 秒 MINUTES : 分 HOURS : 小时 DAYS : 天 - **BlockingQueue workQueue**: 该线程池中的任务队列: 维护着等待执行的 Runnable 对象 当所有的核心线程都在干活时, 新添加的任务会被添加到这个队列中等待处理, 如果队列满了, 则新建非核心线程执行任务. 常用的 workQueue 类型: - **SynchronousQueue**: 这个队列接收到任务的时候, 会直接提交给线程处理, 而不保留它, 如果所有线程都在工作怎么办?那就新建一个线程来处理这个任务!所以为了保证不出现的错误, 使用这个类型队列的时候, maximumPoolSize 一般指定成 Integer.MAX_VALUE, 即无限大 - **LinkedBlockingQueue**: 这个队列接收到任务的时候, 如果当前线程数小于核心线程数, 则新建线程 (核心线程) 处理任务;如果当前线程数等于核心线程数, 则进入队列等待. 由于这个队列没有最大值限制, 即所有超过核心线程数的任务都将被添加到队列中, 这也就导致了 maximumPoolSize 的设定失效, 因为总线程数永远不会超过 corePoolSize - **ArrayBlockingQueue**: 可以限定队列的长度, 接收到任务的时候, 如果没有达到 corePoolSize 的值, 则新建线程 (核心线程) 执行任务, 如果达到了, 则入队等候, 如果队列已满, 则新建线程 (非核心线程) 执行任务, 又如果总线程数到了 maximumPoolSize, 并且队列也满了, 则发生错误 - **DelayQueue**: 队列内元素必须实现 Delayed 接口, 这就意味着你传进去的任务必须先实现 Delayed 接口. 这个队列接收到任务时, 首先先入队, 只有达到了指定的延时时间, 才会执行任务 - **ThreadFactory threadFactory**: 创建线程的方式, 这是一个接口, 你 new 他的时候需要实现他的 Thread newThread(Runnable r) 方法, 一般用不上. - **RejectedExecutionHandler handler**: 这玩意儿就是抛出异常专用的, 比如上面提到的两个错误发生了, 就会由这个 handler 抛出异常, 根本用不上. ## 向 ThreadPoolExecutor 添加任务 我们怎么知道 new 一个 ThreadPoolExecutor, 大概知道各个参数是干嘛的, 可是我 new 完了, 怎么向线程池提交一个要执行的任务啊? ```java ThreadPoolExecutor.execute(Runnable command) ``` 通过 ThreadPoolExecutor.execute(Runnable command) 方法即可向线程池内添加一个任务. ## ThreadPoolExecutor 的策略 这里给总结一下, 当一个任务被添加进线程池时, 执行策略: - 1. 线程数量未达到 corePoolSize, 则新建一个线程 (核心线程) 执行任务 - 2. 线程数量达到了 corePools, 则将任务移入队列等待 - 3. 队列已满, 新建线程 (非核心线程) 执行任务 - 4. 队列已满, 总线程数又达到了 maximumPoolSize, 就会由 (RejectedExecutionHandler) 抛出异常 ## 常见四种线程池: 如果你不想自己写一个线程池, Java 通过 Executors 提供了四种线程池, 这四种线程池都是直接或间接配置 ThreadPoolExecutor 的参数实现的. ### 1. 可缓存线程池 CachedThreadPool() 源码: ```java public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue()); } ``` 根据源码可以看出: 1. 这种线程池内部没有核心线程, 线程的数量是有没限制的. 2. 在创建任务时, 若有空闲的线程时则复用空闲的线程, 若没有则新建线程. 3. 没有工作的线程(闲置状态)在超过了 60S 还不做事, 就会销毁. 创建方法: ```java ExecutorService mCachedThreadPool = Executors.newCachedThreadPool(); ``` 用法: ```java // 开始下载 private void startDownload(final ProgressBar progressBar, final int i) { mCachedThreadPool.execute(new Runnable() { @Override public void run() { int p = 0; progressBar.setMax(10);// 每个下载任务 10 秒 while (p < 10) { p++; progressBar.setProgress(p); Bundle bundle = new Bundle(); Message message = new Message(); bundle.putInt("p", p); // 把当前线程的名字用 handler 让 textview 显示出来 bundle.putString("ThreadName", Thread.currentThread().getName()); message.what = i; message.setData(bundle); mHandler.sendMessage(message); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } }); } ``` ### 2.FixedThreadPool 定长线程池 源码: ```java public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue()); } ``` 根据源码可以看出: 1. 该线程池的最大线程数等于核心线程数, 所以在默认情况下, 该线程池的线程不会因为闲置状态超时而被销毁. 2. 如果当前线程数小于核心线程数, 并且也有闲置线程的时候提交了任务, 这时也不会去复用之前的闲置线程, 会创建新的线程去执行任务. 如果当前执行任务数大于了核心线程数, 大于的部分就会进入队列等待. 等着有闲置的线程来执行这个任务. 创建方法: ```java // nThreads => 最大线程数即 maximumPoolSize ExecutorService mFixedThreadPool= Executors.newFixedThreadPool(int nThreads); // threadFactory => 创建线程的方法, 用得少 ExecutorService mFixedThreadPool= Executors.newFixedThreadPool(int nThreads, ThreadFactory threadFactory); ``` 用法: ```java private void startDownload(final ProgressBar progressBar, final int i) { mFixedThreadPool.execute(new Runnable() { @Override public void run() { // .... 逻辑代码自己控制 } }); } ``` ### 3.SingleThreadPool 源码: ```java public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue())); } ``` 根据源码可以看出: 1. 有且仅有一个工作线程执行任务 2. 所有任务按照指定顺序执行, 即遵循队列的入队出队规则 创建方法: ExecutorService mSingleThreadPool = Executors.newSingleThreadPool(); 用法同上. ### 4.ScheduledThreadPool 源码: ```java public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } // ScheduledThreadPoolExecutor(): public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS, new DelayedWorkQueue()); } ``` 根据源码可以看出: DEFAULT_KEEPALIVE_MILLIS 就是默认 10L, 这里就是 10 秒. 这个线程池有点像是吧 CachedThreadPool 和 FixedThreadPool 结合了一下. 1. 不仅设置了核心线程数, 最大线程数也是 Integer.MAX_VALUE. 2. 这个线程池是上述 4 个中为唯一个有延迟执行和周期执行任务的线程池. 创建: ```java // nThreads => 最大线程数即 maximumPoolSize ExecutorService mScheduledThreadPool = Executors.newScheduledThreadPool(int corePoolSize); ``` 一般的执行任务方法和上面的都大同小异, 我们主要看看延时执行任务和周期执行任务的方法. ```java // 表示在 3 秒之后开始执行我们的任务. mScheduledThreadPool.schedule(new Runnable() { @Override public void run() { // .... } }, 3, TimeUnit.SECONDS); ``` ```java // 延迟 3 秒后执行任务, 从开始执行任务这个时候开始计时, 每 7 秒执行一次不管执行任务需要多长的时间. mScheduledThreadPool.scheduleAtFixedRate(new Runnable() { @Override public void run() { // .... } },3, 7, TimeUnit.SECONDS); ``` ```java /** 延迟 3 秒后执行任务, 从任务完成时这个时候开始计时, 7 秒后再执行, * 再等完成后计时 7 秒再执行也就是说这里的循环执行任务的时间点是 * 从上一个任务完成的时候. */ mScheduledThreadPool.scheduleWithFixedDelay(new Runnable() { @Override public void run() { // .... } },3, 7, TimeUnit.SECONDS); ``` ## [Apollo配置中心的使用心得分享:从入门到进阶](https://blog.dong4j.site/posts/a2959ba3.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 之前一直学习 SpringCloud, 对于配置中心,一直也是采用的 Spring Cloud Config,但是用久了,发现很多地方满足不了要求,同时也感觉很 low(个人看法勿喷)。在学习 Spring cloud config  的时候也有听到过携程的 apollo,但一直没时间去弄。直到昨天看了一张图,如下:使我下定决心去看看携程的 apollo 配置中心。 这张图也算是综合对比了 spring cloud config,netflix archaius, ctrip apollo, disconf, hawk 等配置中心的功能点。综合比较下来携程 apollo 更具有优势。 # 二、简单介绍携程 Apollo 配置中心 # 1、What is Apollo ## 1.1 背景 随着程序功能的日益复杂,程序的配置日益增多:各种功能的开关、参数的配置、服务器的地址…… 对程序配置的期望值也越来越高:配置修改后实时生效,灰度发布,分环境、分集群管理配置,完善的权限、审核机制…… 在这样的大环境下,传统的通过配置文件、数据库等方式已经越来越无法满足开发人员对配置管理的需求。 Apollo 配置中心应运而生! ## 1.2 Apollo 简介 Apollo(阿波罗)是携程框架部门研发的开源配置管理中心,能够集中化管理应用不同环境、不同集群的配置,配置修改后能够实时推送到应用端,并且具备规范的权限、流程治理等特性。 Apollo 支持 4 个维度管理 Key-Value 格式的配置: 1. application (应用) 2. environment (环境) 3. cluster (集群) 4. namespace (命名空间) 同时,Apollo 基于开源模式开发,开源地址:[https://github.com/ctripcorp/apollo](https://github.com/ctripcorp/apollo) ## 1.3 配置基本概念 既然 Apollo 定位于配置中心,那么在这里有必要先简单介绍一下什么是配置。 按照我们的理解,配置有以下几个属性: - **配置是独立于程序的只读变量** - 配置首先是独立于程序的,同一份程序在不同的配置下会有不同的行为。 - 其次,配置对于程序是只读的,程序通过读取配置来改变自己的行为,但是程序不应该去改变配置。 - 常见的配置有:DB Connection Str、Thread Pool Size、Buffer Size、Request Timeout、Feature Switch、Server Urls 等。 - **配置伴随应用的整个生命周期** - 配置贯穿于应用的整个生命周期,应用在启动时通过读取配置来初始化,在运行时根据配置调整行为。 - **配置可以有多种加载方式** - 配置也有很多种加载方式,常见的有程序内部 hard code,配置文件,环境变量,启动参数,基于数据库等 - **配置需要治理** - 权限控制 - 由于配置能改变程序的行为,不正确的配置甚至能引起灾难,所以对配置的修改必须有比较完善的权限控制 - 不同环境、集群配置管理 - 同一份程序在不同的环境(开发,测试,生产)、不同的集群(如不同的数据中心)经常需要有不同的配置,所以需要有完善的环境、集群配置管理 - 框架类组件配置管理 - 还有一类比较特殊的配置 - 框架类组件配置,比如 CAT 客户端的配置。 - 虽然这类框架类组件是由其他团队开发、维护,但是运行时是在业务实际应用内的,所以本质上可以认为框架类组件也是应用的一部分。 - 这类组件对应的配置也需要有比较完善的管理方式。 # 2、Why Apollo 正是基于配置的特殊性,所以 Apollo 从设计之初就立志于成为一个有治理能力的配置管理平台,目前提供了以下的特性: - **统一管理不同环境、不同集群的配置** - Apollo 提供了一个统一界面集中式管理不同环境(environment)、不同集群(cluster)、不同命名空间(namespace)的配置。 - 同一份代码部署在不同的集群,可以有不同的配置,比如 zk 的地址等 - 通过命名空间(namespace)可以很方便的支持多个不同应用共享同一份配置,同时还允许应用对共享的配置进行覆盖 - **配置修改实时生效(热发布)** - 用户在 Apollo 修改完配置并发布后,客户端能实时(1 秒)接收到最新的配置,并通知到应用程序 - **版本发布管理** - 所有的配置发布都有版本概念,从而可以方便地支持配置的回滚 - **灰度发布** - 支持配置的灰度发布,比如点了发布后,只对部分应用实例生效,等观察一段时间没问题后再推给所有应用实例 - **权限管理、发布审核、操作审计** - 应用和配置的管理都有完善的权限管理机制,对配置的管理还分为了编辑和发布两个环节,从而减少人为的错误。 - 所有的操作都有审计日志,可以方便的追踪问题 - **客户端配置信息监控** - 可以在界面上方便地看到配置在被哪些实例使用 - **提供 Java 和.Net 原生客户端** - 提供了 Java 和.Net 的原生客户端,方便应用集成 - 支持 Spring Placeholder, Annotation 和 Spring Boot 的 ConfigurationProperties,方便应用使用(需要 Spring 3.1.1+) - 同时提供了 Http 接口,非 Java 和.Net 应用也可以方便的使用 - **提供开放平台 API** - Apollo 自身提供了比较完善的统一配置管理界面,支持多环境、多数据中心配置管理、权限、流程治理等特性。 - 不过 Apollo 出于通用性考虑,对配置的修改不会做过多限制,只要符合基本的格式就能够保存。 - 在我们的调研中发现,对于有些使用方,它们的配置可能会有比较复杂的格式,而且对输入的值也需要进行校验后方可保存,如检查数据库、用户名和密码是否匹配。 - 对于这类应用,Apollo 支持应用方通过开放接口在 Apollo 进行配置的修改和发布,并且具备完善的授权和权限控制 - **部署简单** - 配置中心作为基础服务,可用性要求非常高,这就要求 Apollo 对外部依赖尽可能地少 - 目前唯一的外部依赖是 MySQL,所以部署非常简单,只要安装好 Java 和 MySQL 就可以让 Apollo 跑起来 - Apollo 还提供了打包脚本,一键就可以生成所有需要的安装包,并且支持自定义运行时参数 # 3、Apollo at a glance ## 3.1 基础模型 如下即是 Apollo 的基础模型: 1. 用户在配置中心对配置进行修改并发布 2. 配置中心通知 Apollo 客户端有配置更新 3. Apollo 客户端从配置中心拉取最新的配置、更新本地配置并通知到应用 ![20241229154732_DkK2ypQG.webp](https://cdn.dong4j.site/source/image/20241229154732_DkK2ypQG.webp) ## 3.2 界面概览 ![20241229154732_a2KZU0KU.webp](https://cdn.dong4j.site/source/image/20241229154732_a2KZU0KU.webp) 上图是 Apollo 配置中心中一个项目的配置首页 - 在页面左上方的环境列表模块展示了所有的环境和集群,用户可以随时切换。 - 页面中央展示了两个 namespace(application 和 FX.apollo) 的配置信息,默认按照表格模式展示、编辑。用户也可以切换到文本模式,以文件形式查看、编辑。 - 页面上可以方便地进行发布、回滚、灰度、授权、查看更改历史和发布历史等操作 ## 3.3 添加/修改配置项 用户可以通过配置中心界面方便的添加/修改配置项: ![20241229154732_qPwTCUq9.webp](https://cdn.dong4j.site/source/image/20241229154732_qPwTCUq9.webp) 输入配置信息: ![20241229154732_AJURlBSS.webp](https://cdn.dong4j.site/source/image/20241229154732_AJURlBSS.webp) ## 3.4 发布配置 通过配置中心发布配置: ![20241229154732_l6UchSjE.webp](https://cdn.dong4j.site/source/image/20241229154732_l6UchSjE.webp) 填写发布信息: ![20241229154732_S2vDJqCJ.webp](https://cdn.dong4j.site/source/image/20241229154732_S2vDJqCJ.webp) ## 3.5 客户端获取配置(Java API 样例) 配置发布后,就能在客户端获取到了,以 Java API 方式为例,获取配置的示例代码如下。更多客户端使用说明请参见 [Java 客户端使用指南](https://github.com/ctripcorp/apollo/wiki/Java%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8D%97)。 ``` Config config = ConfigService.getAppConfig(); Integer defaultRequestTimeout = 200; Integer requestTimeout = config.getIntProperty("request.timeout",defaultRequestTimeout); ``` ## 3.6 客户端监听配置变化(Java API 样例) 通过上述获取配置代码,应用就能实时获取到最新的配置了。 不过在某些场景下,应用还需要在配置变化时获得通知,比如数据库连接的切换等,所以 Apollo 还提供了监听配置变化的功能,Java 示例如下: ``` Config config = ConfigService.getAppConfig(); config.addChangeListener(new ConfigChangeListener() { @Override public void onChange(ConfigChangeEvent changeEvent) { for (String key : changeEvent.changedKeys()) { ConfigChange change = changeEvent.getChange(key); System.out.println(String.format( "Found change - key: %s, oldValue: %s, newValue: %s, changeType: %s", change.getPropertyName(), change.getOldValue(), change.getNewValue(), change.getChangeType())); } } }); ``` ## 3.7 Spring 集成样例 Apollo 和 Spring 也可以很方便地集成,只需要标注 `@EnableApolloConfig` 后就可以通过 `@Value` 获取配置信息: ``` @Configuration @EnableApolloConfig public class AppConfig {} ``` ``` @Component public class SomeBean { @Value("${request.timeout:200}") private int timeout; @ApolloConfigChangeListener private void someChangeHandler(ConfigChangeEvent changeEvent) { if (changeEvent.isChanged("request.timeout")) { refreshTimeout(); } } } ``` # 4、Apollo in depth 通过上面的介绍,相信大家已经对 Apollo 有了一个初步的了解,并且相信已经覆盖到了大部分的使用场景。 接下来会主要介绍 Apollo 的 cluster 管理(集群)、namespace 管理(命名空间)和对应的配置获取规则。 ## 4.1 Core Concepts 在介绍高级特性前,我们有必要先来了解一下 Apollo 中的几个核心概念: 1. **application (应用)** - 这个很好理解,就是实际使用配置的应用,Apollo 客户端在运行时需要知道当前应用是谁,从而可以去获取对应的配置 - 每个应用都需要有唯一的身份标识 - appId,我们认为应用身份是跟着代码走的,所以需要在代码中配置,具体信息请参见 [Java 客户端使用指南](https://github.com/ctripcorp/apollo/wiki/Java%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8D%97)。 2. **environment (环境)** - 配置对应的环境,Apollo 客户端在运行时需要知道当前应用处于哪个环境,从而可以去获取应用的配置 - 我们认为环境和代码无关,同一份代码部署在不同的环境就应该能够获取到不同环境的配置 - 所以环境默认是通过读取机器上的配置(server.properties 中的 env 属性)指定的,不过为了开发方便,我们也支持运行时通过 System Property 等指定,具体信息请参见 [Java 客户端使用指南](https://github.com/ctripcorp/apollo/wiki/Java%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8D%97)。 3. **cluster (集群)** - 一个应用下不同实例的分组,比如典型的可以按照数据中心分,把上海机房的应用实例分为一个集群,把北京机房的应用实例分为另一个集群。 - 对不同的 cluster,同一个配置可以有不一样的值,如 zookeeper 地址。 - 集群默认是通过读取机器上的配置(server.properties 中的 idc 属性)指定的,不过也支持运行时通过 System Property 指定,具体信息请参见 [Java 客户端使用指南](https://github.com/ctripcorp/apollo/wiki/Java%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8D%97)。 4. **namespace (命名空间)** - 一个应用下不同配置的分组,可以简单地把 namespace 类比为文件,不同类型的配置存放在不同的文件中,如数据库配置文件,rpc 配置文件,应用自身的配置文件等 - 应用可以直接读取到公共组件的配置 namespace,如 DAL,RPC 等 - 应用也可以通过继承公共组件的配置 namespace 来对公共组件的配置做调整,如 DAL 的初始数据库连接数 ## 4.2 自定义 Cluster > 【本节内容仅对应用需要对不同集群应用不同配置才需要,如没有相关需求,可以跳过本节】 比如我们有应用在 A 数据中心和 B 数据中心都有部署,那么如果希望两个数据中心的配置不一样的话,我们可以通过新建 cluster 来解决。 ### 4.2.1 新建 Cluster 新建 Cluster 只有项目的管理员才有权限,管理员可以在页面左侧看到“添加集群”按钮。 ![20241229154732_KiFCrigs.webp](https://cdn.dong4j.site/source/image/20241229154732_KiFCrigs.webp) 点击后就进入到集群添加页面,一般情况下可以按照数据中心来划分集群,如 SHAJQ、SHAOY 等。 不过也支持自定义集群,比如可以为 A 机房的某一台机器和 B 机房的某一台机创建一个集群,使用一套配置。 ![20241229154732_D5ZfQpSc.webp](https://cdn.dong4j.site/source/image/20241229154732_D5ZfQpSc.webp) ### 4.2.2 在 Cluster 中添加配置并发布 集群添加成功后,就可以为该集群添加配置了,首选需要按照下图所示切换到 SHAJQ 集群,之后配置添加流程和 [3.2 添加/修改配置项](http://nobodyiam.com/2016/07/09/introduction-to-apollo/#section-2) 一样,这里就不再赘述了。 ![20241229154732_c01v9OMS.webp](https://cdn.dong4j.site/source/image/20241229154732_c01v9OMS.webp) ### 4.2.3 指定应用实例所属的 Cluster Apollo 会默认使用应用实例所在的数据中心作为 cluster,所以如果两者一致的话,不需要额外配置。 如果 cluster 和数据中心不一致的话,那么就需要通过 System Property 方式来指定运行时 cluster: - -Dapollo.cluster=SomeCluster - 这里注意 `apollo.cluster` 为全小写 ## 4.3 自定义 Namespace > 【本节仅对公共组件配置或需要多个应用共享配置才需要,如没有相关需求,可以跳过本节】 如果应用有公共组件(如 hermes-producer,cat-client 等)供其它应用使用,就需要通过自定义 namespace 来实现公共组件的配置。 ### 4.3.1 新建 Namespace 以 hermes-producer 为例,需要先新建一个 namespace,新建 namespace 只有项目的管理员才有权限,管理员可以在页面左侧看到“添加 Namespace”按钮。 ![20241229154732_HkmfFyMH.webp](https://cdn.dong4j.site/source/image/20241229154732_HkmfFyMH.webp) 点击后就进入 namespace 添加页面,Apollo 会把应用所属的部门作为 namespace 的前缀,如 FX。 ![20241229154732_Kpuh1aBK.webp](https://cdn.dong4j.site/source/image/20241229154732_Kpuh1aBK.webp) ### 4.3.2 关联到环境和集群 Namespace 创建完,需要选择在哪些环境和集群下使用 ![20241229154732_JT15Rl8C.webp](https://cdn.dong4j.site/source/image/20241229154732_JT15Rl8C.webp) ### 4.3.3 在 Namespace 中添加配置项 接下来在这个新建的 namespace 下添加配置项 ![20241229154732_5Q0Vo9yD.webp](https://cdn.dong4j.site/source/image/20241229154732_5Q0Vo9yD.webp) 添加完成后就能在 FX.Hermes.Producer 的 namespace 中看到配置。 ![20241229154732_ii3xtcBo.webp](https://cdn.dong4j.site/source/image/20241229154732_ii3xtcBo.webp) ### 4.3.4 发布 namespace 的配置 ![20241229154732_FOxrOgXT.webp](https://cdn.dong4j.site/source/image/20241229154732_FOxrOgXT.webp) ### 4.3.5 客户端获取 Namespace 配置 对自定义 namespace 的配置获取,稍有不同,需要程序传入 namespace 的名字。更多客户端使用说明请参见 [Java 客户端使用指南](https://github.com/ctripcorp/apollo/wiki/Java%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8D%97)。 ``` Config config = ConfigService.getConfig("FX.Hermes.Producer"); Integer defaultSenderBatchSize = 200; Integer senderBatchSize = config.getIntProperty("sender.batchsize", defaultSenderBatchSize); ``` ### 4.3.6 客户端监听 Namespace 配置变化 ``` Config config = ConfigService.getConfig("FX.Hermes.Producer"); config.addChangeListener(new ConfigChangeListener() { @Override public void onChange(ConfigChangeEvent changeEvent) { System.out.println("Changes for namespace " + changeEvent.getNamespace()); for (String key : changeEvent.changedKeys()) { ConfigChange change = changeEvent.getChange(key); System.out.println(String.format( "Found change - key: %s, oldValue: %s, newValue: %s, changeType: %s", change.getPropertyName(), change.getOldValue(), change.getNewValue(), change.getChangeType())); } } }); ``` ### 4.3.7 Spring 集成样例 ``` @Configuration @EnableApolloConfig("FX.Hermes.Producer") public class AppConfig {} ``` ``` @Component public class SomeBean { @Value("${request.timeout:200}") private int timeout; @ApolloConfigChangeListener("FX.Hermes.Producer") private void someChangeHandler(ConfigChangeEvent changeEvent) { if (changeEvent.isChanged("request.timeout")) { refreshTimeout(); } } } ``` ## 4.4 配置获取规则 > 【本节仅当应用自定义了集群或 namespace 才需要,如无相关需求,可以跳过本节】 在有了 cluster 概念后,配置的规则就显得重要了。 比如应用部署在 A 机房,但是并没有在 Apollo 新建 cluster,这个时候 Apollo 的行为是怎样的? 或者在运行时指定了 cluster=SomeCluster,但是并没有在 Apollo 新建 cluster,这个时候 Apollo 的行为是怎样的? 接下来就来介绍一下配置获取的规则。 ### 4.4.1 应用自身配置的获取规则 当应用使用下面的语句获取配置时,我们称之为获取应用自身的配置,也就是应用自身的 application namespace 的配置。 ``` Config config = ConfigService.getAppConfig(); ``` 对这种情况的配置获取规则,简而言之如下: 1. 首先查找运行时 cluster 的配置(通过 apollo.cluster 指定) 2. 如果没有找到,则查找数据中心 cluster 的配置 3. 如果还是没有找到,则返回默认 cluster 的配置 图示如下: ![20241229154732_BMEnbzMK.webp](https://cdn.dong4j.site/source/image/20241229154732_BMEnbzMK.webp) 所以如果应用部署在 A 数据中心,但是用户没有在 Apollo 创建 cluster,那么获取的配置就是默认 cluster(default)的。 如果应用部署在 A 数据中心,同时在运行时指定了 SomeCluster,但是没有在 Apollo 创建 cluster,那么获取的配置就是 A 数据中心 cluster 的配置,如果 A 数据中心 cluster 没有配置的话,那么获取的配置就是默认 cluster(default)的。 ### 4.4.2 公共组件配置的获取规则 以**_FX.Hermes.Producer_**为例,hermes producer 是 hermes 发布的公共组件。当使用下面的语句获取配置时,我们称之为获取公共组件的配置。 ``` Config config = ConfigService.getConfig("FX.Hermes.Producer"); ``` 对这种情况的配置获取规则,简而言之如下: 1. 首先获取当前应用下的**_FX.Hermes.Producer_** namespace 的配置 2. 然后获取 hermes 应用下**_FX.Hermes.Producer_** namespace 的配置 3. 上面两部分配置的并集就是最终使用的配置,如有 key 一样的部分,以当前应用优先 图示如下: ![20241229154732_eZawyyZs.webp](https://cdn.dong4j.site/source/image/20241229154732_eZawyyZs.webp) 通过这种方式,就实现了对框架类组件的配置管理,框架组件提供方提供配置的默认值,应用如果有特殊需求,可以自行覆盖。 ## 4.5 总体设计 ![20241229154732_ePfZQkEn.webp](https://cdn.dong4j.site/source/image/20241229154732_ePfZQkEn.webp) 上图简要描述了 Apollo 的总体设计,我们可以从下往上看: - Config Service 提供配置的读取、推送等功能,服务对象是 Apollo 客户端 - Admin Service 提供配置的修改、发布等功能,服务对象是 Apollo Portal(管理界面) - Config Service 和 Admin Service 都是多实例、无状态部署,所以需要将自己注册到 Eureka 中并保持心跳 - 在 Eureka 之上我们架了一层 Meta Server 用于封装 Eureka 的服务发现接口 - Client 通过域名访问 Meta Server 获取 Config Service 服务列表(IP+Port),而后直接通过 IP+Port 访问服务,同时在 Client 侧会做 load balance、错误重试 - Portal 通过域名访问 Meta Server 获取 Admin Service 服务列表(IP+Port),而后直接通过 IP+Port 访问服务,同时在 Portal 侧会做 load balance、错误重试 - 为了简化部署,我们实际上会把 Config Service、Eureka 和 Meta Server 三个逻辑角色部署在同一个 JVM 进程中 ### 4.5.1 Why Eureka 为什么我们采用 Eureka 作为服务注册中心,而不是使用传统的 zk、etcd 呢?我大致总结了一下,有以下几方面的原因: - 它提供了完整的 Service Registry 和 Service Discovery 实现 - 首先是提供了完整的实现,并且也经受住了 Netflix 自己的生产环境考验,相对使用起来会比较省心。 - 和 Spring Cloud 无缝集成 - 我们的项目本身就使用了 Spring Cloud 和 Spring Boot,同时 Spring Cloud 还有一套非常完善的开源代码来整合 Eureka,所以使用起来非常方便。 - 另外,Eureka 还支持在我们应用自身的容器中启动,也就是说我们的应用启动完之后,既充当了 Eureka 的角色,同时也是服务的提供者。这样就极大的提高了服务的可用性。 - **这一点是我们选择 Eureka 而不是 zk、etcd 等的主要原因,为了提高配置中心的可用性和降低部署复杂度,我们需要尽可能地减少外部依赖。** - Open Source - 最后一点是开源,由于代码是开源的,所以非常便于我们了解它的实现原理和排查问题。 ## 4.6 客户端设计 ![20241229154732_KmL6Rzxj.webp](https://cdn.dong4j.site/source/image/20241229154732_KmL6Rzxj.webp) 上图简要描述了 Apollo 客户端的实现原理: 1. 客户端和服务端保持了一个长连接,从而能第一时间获得配置更新的推送。 2. 客户端还会定时从 Apollo 配置中心服务端拉取应用的最新配置。 - 这是一个 fallback 机制,为了防止推送机制失效导致配置不更新 - 客户端定时拉取会上报本地版本,所以一般情况下,对于定时拉取的操作,服务端都会返回 304 - Not Modified - 定时频率默认为每 5 分钟拉取一次,客户端也可以通过在运行时指定 System Property: `apollo.refreshInterval` 来覆盖,单位为分钟。 3. 客户端从 Apollo 配置中心服务端获取到应用的最新配置后,会保存在内存中 4. 客户端会把从服务端获取到的配置在本地文件系统缓存一份 - 在遇到服务不可用,或网络不通的时候,依然能从本地恢复配置 5. 应用程序可以从 Apollo 客户端获取最新的配置、订阅配置更新通知 ### 4.6.1 配置更新推送实现 前面提到了 Apollo 客户端和服务端保持了一个长连接,从而能第一时间获得配置更新的推送。 长连接实际上我们是通过 Http Long Polling 实现的,具体而言: - 客户端发起一个 Http 请求到服务端 - 服务端会保持住这个连接 30 秒 - 如果在 30 秒内有客户端关心的配置变化,被保持住的客户端请求会立即返回,并告知客户端有配置变化的 namespace 信息,客户端会据此拉取对应 namespace 的最新配置 - 如果在 30 秒内没有客户端关心的配置变化,那么会返回 Http 状态码 304 给客户端 - 客户端在服务端请求返回后会自动重连 考虑到会有数万客户端向服务端发起长连,在服务端我们使用了 async servlet(Spring DeferredResult) 来服务 Http Long Polling 请求。 ## 4.7 可用性考虑 配置中心作为基础服务,可用性要求非常高,下面的表格描述了不同场景下 Apollo 的可用性: | 场景 | 影响 | 降级 | 原因 | | :----------------------- | :------------------------------------ | :------------------------------------ | :----------------------------------------------------------------------------------------- | | 某台 config service 下线 | 无影响 |   | Config service 无状态,客户端重连其它 config service | | 所有 config service 下线 | 客户端无法读取最新配置,Portal 无影响 | 客户端重启时,可以读取本地缓存配置文件 |   | | 某台 admin service 下线 | 无影响 |   | Admin service 无状态,Portal 重连其它 admin service | | 所有 admin service 下线 | 客户端无影响,portal 无法更新配置 |   |   | | 某台 portal 下线 | 无影响 |   | Portal 域名通过 slb 绑定多台服务器,重试后指向可用的服务器 | | 全部 portal 下线 | 客户端无影响,portal 无法更新配置 |   |   | | 某个数据中心下线 | 无影响 |   | 多数据中心部署,数据完全同步,Meta Server/Portal 域名通过 slb 自动切换到其它存活的数据中心 | # 5、Contribute to Apollo Apollo 从开发之初就是以开源模式开发的,所以也非常欢迎有兴趣、有余力的朋友一起加入进来。 服务端开发使用的是 Java,基于 Spring Cloud 和 Spring Boot 框架。客户端目前提供了 Java 和.Net 两种实现。 ## [日志编码规范:编写高效可维护的日志代码](https://blog.dong4j.site/posts/b3fe09a9.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) > 现有架构日志存在的问题 1. 关键逻辑无日志埋点 2. 日志级别规范 3. 生产环境日志级别不正确, 不规范 4. 日志配置不统一 5. 日志框架不统一 6. 日志打语句不规范 因此针对以上问题, 对整个项目中的日志进行规范管理 ## 统一日志配置 新框架中统一使用 `log4j2` 日志框架来进行日志管理 具有的功能: 1. 根据不同的环境输出不同的日志级别 1. 开发环境, 输出等级为 debug 2. 测试和生成环境, 输出等级为 info 2. 开发环境, 只输出到 console 3. 测试和生产环境, 输出到 /usr/logs/app_name/port/all.log, 关闭 console 输出 4. 按天压缩日志 5. 自动删除 30 天以前的日志 6. 统一输出格式 7. 动态修改日志等级 ## 现有日志修改 现在的代码暂时不能全部迁移到新框架, 考虑到现在各个模块使用的日志配置不统一, 日志配置不合理, 这里规范一下日志相关的配置 ### 统一配置格式 由于老项目中一部分使用 log4j, 另一部分又使用了 log4j2 日志框架, 配置文件一部分是 xml, 另一部分又是 properties. 为了以后迁移方便, 这里统一全部改成 xml 的配置形式. 日志框架暂时不需要改动, 因为需要改动的依赖太多, 因此这里会分 log4j.xml 和 log4j2.xml 2 种配置分别说明. 已修改的日志配置的模块有: | 模块名 | 原日志配置 | 现日志配置 | | ----------------- | ---------------- | ---------- | | migu-game-service | log4j2.xml | log4j2.xml | | callout-tool | log4j.xml | log4j.xml | | redis-cache | log4j.properties | log4j.xml | | IavpProxy | log4j.properties | log4j.xml | | async-tool | log4j.xml | log4j.xml | log4j.xml 和 log4j2.xml 的配置方式不相同, 可以参考已修改的配置将还未修改的模块修改. ### 统一输出格式 log4j.xml 配置 ```xml ``` log4j2.xml 配置 ```xml %date{yyyy-MM-dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN} - [%t] %c{1.} :: %m%n ``` 大概是这个样子 ```java 2018-08-21 17:12:10.651 [ INFO] - [main] com.xxxx.msearch.toolsframework.main.Main :: 开始读取配置文件 2018-08-21 17:12:11.276 [ INFO] - [main] com.xxxx.msearch.toolsframework.main.Main :: org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool 2018-08-21 17:12:11.277 [ INFO] - [main] com.xxxx.msearch.toolsframework.main.Main :: org.quartz.threadPool.threadCount=2 2018-08-21 17:12:11.277 [ INFO] - [main] com.xxxx.msearch.toolsframework.main.Main :: org.quartz.threadPool.threadPriority=5 ``` > 日志必须显示 `日志等级`, 时间精确到毫秒 以前也说过, 显示日志等级,可以配合插件来高亮不同日志等级, 还可以设置声音提示. 显示日志等级也有利于在现网搜索特定级别的日志. 以后直接给运维说需要某段时间特定等级或关键字的日志, 要是运维直接扔全部的日志过来, 麻烦把下面的脚本发给他 ```shell # sed -n '/开始时间/,/结束时间/p' all.log |grep '关键字' | > snippet.log sed -n '/2018-08-21 16:27:57.569/,/2018-08-21 16:36:14.604/p' all.log |grep 'INFO' | > snippet.log ``` ## 统一输出路径 具体配置需要查看已修改的模块 log4j.xml ```xml ``` log4j2.xml ```xml /usr/logs ``` 测试环境和生成环境日志配置统一输出到文件, 且文件统一保存在一个日志目录下 以前的方式是将日志保存在 tomcat 的 log 目录下或者通过 JVM 启动的日志保存在启动目录下 这样不好统一管理日志, 每次查看日志时, 还得记住哪个 tomcat 下是哪个应用. 因此考虑将日志全部统一管理在同一个目录下, 通过应用名区分日志. > 1.应用名配置使用 '-' 分隔, 全部采用小写 不要用大写, linux 下还得按个大小写切换键, 麻烦 ```java async-tool iavp-proxy migu-game-service redis-cache service-meeting ``` **请按照上面的命名方式命名应用名** > 2.本地开发时需要修改为自己电脑的一个保存路径 log4j.xml ```xml ``` log4j2.xml ```xml 你的本机路径 ``` 新框架中不需要这样修改, 能根据不同的环境选择输出路径; 已修改的模块日志配置默认全部输出到 `/usr/logs/` 目录下, 如果是本地开发时, 没有这个目录肯定会报错. 因此需要修改为你本地的一个目录 **如果是 windows, 路径需要转义** 比如我会建立一个专门的日志目录, 将所有的软件日志, 开发日志都输出到这个目录下, 能方便的管理日志 ![20241229154732_GNaVfMic.webp](https://cdn.dong4j.site/source/image/20241229154732_GNaVfMic.webp) 推荐大家也采用这种方式 > 3.本地开发时, 需要修改日志等级 现在的日志配置针对 2 种环境 **本地开发环境** 本地开发时, 需要查看 DEBUG 信息, 且只用输出到 console **现网环境** 现网部署时, 最低日志等级为 INFO 就好, 不需要输出 debug 级别日志, 且只需要输出到文件 log4j.xml ```xml ... ``` log4j2.xml ```xml ``` 以上需要修改的配置, 在新框架中都不需要手动修改 ### 日志配置修改方式 其他未修改的模块, 只需要参考已修改的模块配置, 直接拷贝后, 需要修改的地方: 1. 应用名 2. 日志输出路径, 统一为 /usr/logs/应用名 3. start.sh start.sh 中会根据 properties 中的日志配置保存日志文件, 这里统一后, 不使用这种方式, 将日志相关配置全部迁移到 log4j.xml/log4j2.xml 中, 因此需要注释掉 start.sh 对日志的相关配置, 具体可参考已修改的模块 ## 日志最佳实践 现有代码存在的最大问题就是排查问题时, 没有日志可看, 不得不加入日志后再部署再看问题, 这样就很尴尬 **合理的日志等级以及日志埋点能够快速定位问题** 针对现在代码中存在的问题和以后的迁移工作, 在做需求开发时, 尽量按照以下方式修改日志相关的代码. ### 删除 printStackTrace() **删除所有的 printStackTrace() 方法 , 改用日志输出** e.printStackTrace 会直接输出到 System.err (如果是 tomcat 部署, 就会输出到 catalina.out) 我们的所有日志配置全部通过 log4j.xml 或者 log4j2.xml 控制, ```java try { // do something } catch (Exception e) { // todo 删除 printStackTrace(), 改用 log.error 输出 e.printStackTrace(); } ``` ### 使用 slf4j api ### 正确使用 error 日志级别 ```java public void error(String msg, Throwable t); ``` 错误的做法: ```java # 框架会发现最后一个参数是多余的,并查看其是否是一个异常对象,如果是则输出堆栈,否则忽略 log.error("Failed to format {}", s, e); ``` ### 使用 占位符 代替 连接符, 提高效率 ```java log.info("解析错误,错误码:" + status); ``` 替换为 ```java log.info("解析错误,错误码: {}", status); ``` ## 基本的日志编码规范 > 以下是规范的日志写法, 希望以后开发时, 注意以下几点 1. 获取 log, 日志对象名统一使用 **log** 如果有父类, 统一在父类中获取 log ```java protected log log = logFactory.getlog(getClass()); ``` 如果没有父类, 在当前类中获取 log ```java private static final log log = logFactory.getlog(类名.class); ``` 可以使用 live templates 快速输入, 需要自己设置 ![20241229154732_orhs0nZm.webp](https://cdn.dong4j.site/source/image/20241229154732_orhs0nZm.webp) 以上方式是没有使用 lombok 插件的情况, 如果使用, 直接在类上加 `@Slf4j` 即可, 然后使用 `log` 对象打印日志 > 如果使用新框架, 可以使用 `Logs` 工具类打印日志 2. 输出 Exceptions 的全部 Throwable 信息,因为 log.error(msg) 和 log.error(msg,e.getMessage()) 这样的日志输出方法会丢失掉最重要的 StackTrace 信息。 ```java void foo(){ try{ // do something } catch (Exception e) { log.error(e.getMessage()); // 错误 log.error("Bad things", e.getMessage()); // 错误 log.error("Bad things", e); // 正确 // error 允许拼接字符串, 因为 error 毕竟没有 info 和 debug 多, 而且需要输出参数信息时, 也只有这种方式 log.error("Bad things " + user, e); } } ``` 3. **不允许**记录日志后又抛出异常,因为这样会多次记录日志,只允许记录一次日志. ```java void foo() throw LogException { try{ // do something } catch (NoUserException e) { log.error("No user available", e); // 这里抛出异常后, 上级又会处理一次异常 throw new UserServiceException("Nouseravailable", e); } } ``` 4. **不允许**出现 System print(包括 System.out.println 和 System.error.println) 语句 5. **不允许**出现 printStackTrace ```java void foo() throw LogException { try{ // do something } catch (NoUserException e) { e.printStackTrace(); // 错误 log.error("No user available", e); } } ``` 6. 使用 slf4j 代替 log4j **slf4j 中的占位符—不再需要冗长的级别判断** 在 log4j 中,为了提高运行效率,往往在输出信息之前,还要进行级别判断,以避免无效的字符串连接操作。如下: ```java if (log.isDebugEnabled()){ log.debug("debug:" + name); } ``` slf4j 巧妙的解决了这个问题:先传入带有占位符的字符串,同时把其他参数传入,在 slf4j 的内容部实现中,如果级别合适再去用传入的参数去替换字符串中的占位符,否则不用执行。 ```java log.info("{} is {}", new String[]{“x",“y"}); ``` 7. 不在循环中打印日志 ```java void read() { while (hasNext()) { try { readData(); } catch {Exception e) { // this isn’t recommend log.error("error reading data", e); } } } ``` 如果 readData() 抛出异常并且 hasNext() 返回 true,这段代码就会不停在打印日志 ```java void read() { int exceptionsThrown = 0; while (hasNext()) { try { readData(); } catch {Exception e) { if (exceptionsThrown < THRESHOLD) { log.error(“error reading data", e); exceptionsThrown++; } else { // Now the error won’t choke the system. } } } } ``` 还有一个方法就是把日志操作从循环中去掉,在另外的地方进行打印,只记录第一个或者最后一个异常就好了 ## [避免调试陷阱:依赖日志的编程习惯](https://blog.dong4j.site/posts/286572f8.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 开发中日志这个问题,每个公司都强调,也制定了一大堆规范,但根据实际情况看,效果不是很明显,主要是这个东西不好测试和考核,没有日志功能一样跑啊。 但是没有日志, 一旦系统出现问题, 将导致排查问题时困难重重. 因此好的日志输出有利于快速定位问题 但是我们在什么时候打印日志? 需要打印什么信息? 用什么日志级别? 这些问题都将应用我们排查问题时的速度 因此这里制定一个日志规范, 将日志相关的常识性问题做一个总结. ## 日志等级说明 1. trace, debug: 理论上 "不属于错误", 只是打印一些状态, 提示信息, 以便 `开发过程` 中观察. 2. info: 理论上 "不属于错误", 只是一些提示性的信息, 但是即使在开发完成, 正式上线的系统中, 也有保留的价值. 3. warn: 属于轻微的 "警告", 程序中出现了一些异常情况, 但是影响不大, 还可以正常使用. 4. error: 属于 "普通的错误", 在程序可以控制的范围内, 不会造成连锁影响或巨大影响. 5. fatal: 属于 "致命错误", 可导致整个系统或者一系列功能无法使用, 甚至导致系统瘫痪, 关闭. ## 规范 > 1. 日志必须显示 `日志等级`, 时间精确到毫秒 以前也说过, 显示日志等级, 可以配合插件来高亮不同日志等级, 还可以设置声音提示. 显示日志等级也有利于在现网搜索特定级别的日志. 以下是利用 `Grep Console` 插件显示的效果, 能区分不同的日志等级 用以下命令获取某段时间特定等级或关键字的日志 ```bash # sed -n '/开始时间/,/结束时间/p' all.log | grep '关键字' | > snippet.log sed -n '/2018-08-21 16:27:57.569/,/2018-08-21 16:36:14.604/p' all.log | grep 'INFO' | > snippet.log ``` > 2. 修改(包括新增)操作必须打印日志 大部分问题都是修改导致的。数据修改必须有据可查 > 3. 条件分支必须打印条件值,重要参数必须打印 尤其是分支条件的参数,打印后就不用分析和猜测走那个分支了,很重要!如下面代码里面的 messageType,一定要打印值,因为他决定了代码走那个分支 ```java /** * Instance rocket mq handler. * 根据消息类型选择处理器处理消息 * * @param messageType the message type * @return the rocket mq handler */ @Override public MessageHandler instance(String messageType) { Class cls = null; log.info("message type = {}", messageType); switch (MessageType.valueOf(messageType)) { case BUSINESS_LOG: cls = BusinessLogHandler.class; break; case WORD_ANALYSIS: cls = WordAnalysisHandler.class; break; default: log.error("Unknown MessageType, messageType = {} ", messageType); break; } return SpringContext.getInstance(cls); } ``` > 4. 数据量大的时候需要打印数据量 前后打印日志和最后的数据量,主要用于分析性能,能从日志中知道查询了多少数据用了多久. 自己视情况而决定是否打印,我一般建议打印. > 5. 不要使用 System.out.println() 来记录日志 使用 System.out.println() 不会输出到日志文件. 本地开发时, 觉得方便就直接使用 System.out.println()输出, 本地是看得到信息, 但是一到线上环境, 日志全部输出到文件中, 使用 System.out.println() 输出的信息就全部没有了. System.out.println() 一般是开发时输出不重要的信息, 建议使用 log.debug 代替. 使用 lombok, 应该比 System.out.println() 更方便吧! ## 建议 日志这个东西,更多是靠自觉,项目组这么多人,不可能一个一个看代码,然后加日志. 打印日志更多的是一种习惯, 需要有意识的去培养这种习惯. 1. 不要依赖 debug,多依赖日志。 别人面对对象编程,你面对 debug 编程. 有些人无论什么语言,最后都变成了面对 debug 编程.... 这个习惯非常非常不好!debug 会让你写代码的时候偷懒不打日志,而且很浪费时间. 改掉这个恶习. 只有在必要的情况下才会 debug, 更多的是通过日志来分析流程, 因为在生产环境, 尤其是现公司的生产环境, 远程 debug 是不可能的, 只能依赖日志. 代码开发测试完成之后不要急着提交,先跑一遍看看日志是否看得懂. 日志是给人看的,只要热爱编程的人才能成为合格程序员, 不要匆匆忙忙写完功能测试 ok 就提交代码,日志也是功能的一部分. 要有精益求精的工匠精神! ## 日志最佳实践 现有代码存在的最大问题就是排查问题时, 没有日志可看, 不得不加入日志后再部署再看问题, 这样就很尴尬 **合理的日志等级以及日志埋点能够快速定位问题** 针对现在代码中存在的问题和以后的迁移工作, 在做需求开发时, 尽量按照以下方式修改日志相关的代码. ### 删除 printStackTrace() **删除所有的 printStackTrace() 方法 , 改用日志输出** e.printStackTrace 会直接输出到 System.err (如果是 tomcat 部署, 就会输出到 catalina.out) 我们的所有日志配置全部通过 log4j.xml 或者 log4j2.xml 控制, ```java try { // do something } catch (Exception e) { // todo 删除 printStackTrace(), 改用 log.error 输出 e.printStackTrace(); } ``` ### 使用 slf4j api ### 正确使用 error 日志级别 ```java public void error(String msg, Throwable t); ``` 错误的做法: ```java # 框架会发现最后一个参数是多余的,并查看其是否是一个异常对象,如果是则输出堆栈,否则忽略 log.error("Failed to format {}", s, e); ``` ### 使用 占位符 代替 连接符, 提高效率 ```java log.info("解析错误,错误码:" + status); ``` 替换为 ```java log.info("解析错误,错误码: {}", status); ``` ## 基本的日志编码规范 > 以下是规范的日志写法, 希望以后开发时, 注意以下几点 1. 获取 log, 日志对象名统一使用 **log** 如果有父类, 统一在父类中获取 log ```java protected log log = logFactory.getlog(getClass()); ``` 如果没有父类, 在当前类中获取 log ```java private static final log log = logFactory.getlog(类名.class); ``` 可以使用 live templates 快速输入, 需要自己设置 以上方式是没有使用 lombok 插件的情况, 如果使用, 直接在类上加 `@Slf4j` 即可, 然后使用 `log` 对象打印日志 > 如果使用新框架, 可以使用 `Logs` 工具类打印日志 2. 输出 Exceptions 的全部 Throwable 信息,因为 log.error(msg) 和 log.error(msg,e.getMessage()) 这样的日志输出方法会丢失掉最重要的 StackTrace 信息。 ```java void foo(){ try{ // do something } catch (Exception e) { log.error(e.getMessage()); // 错误 log.error("Bad things", e.getMessage()); // 错误 log.error("Bad things", e); // 正确 // error 允许拼接字符串, 因为 error 毕竟没有 info 和 debug 多, 而且需要输出参数信息时, 也只有这种方式 log.error("Bad things " + user, e); } } ``` 3. **不允许** 记录日志后又抛出异常,因为这样会多次记录日志,只允许记录一次日志. ```java void foo() throw LogException { try{ // do something } catch (NoUserException e) { log.error("No user available", e); // 这里抛出异常后, 上级又会处理一次异常 throw new UserServiceException("Nouseravailable", e); } } ``` 4. **不允许** 出现 System print(包括 System.out.println 和 System.error.println) 语句 5. **不允许** 出现 printStackTrace ```java void foo() throw LogException { try{ // do something } catch (NoUserException e) { e.printStackTrace(); // 错误 log.error("No user available", e); } } ``` 6. 使用 slf4j 代替 log4j **slf4j 中的占位符—不再需要冗长的级别判断** 在 log4j 中,为了提高运行效率,往往在输出信息之前,还要进行级别判断,以避免无效的字符串连接操作。如下: ```java if (log.isDebugEnabled()){ log.debug("debug:" + name); } ``` slf4j 巧妙的解决了这个问题:先传入带有占位符的字符串,同时把其他参数传入,在 slf4j 的内容部实现中,如果级别合适再去用传入的参数去替换字符串中的占位符,否则不用执行。 ```java log.info("{} is {}", new String[]{“x",“y"}); ``` 7. 不在循环中打印日志 ```java void read() { while (hasNext()) { try { readData(); } catch {Exception e) { // this isn’t recommend log.error("error reading data", e); } } } ``` 如果 readData()抛出异常并且 hasNext() 返回 true,这段代码就会不停在打印日志 ```java void read() { int exceptionsThrown = 0; while (hasNext()) { try { readData(); } catch {Exception e) { if (exceptionsThrown < THRESHOLD) { log.error(“error reading data", e); exceptionsThrown++; } else { // Now the error won’t choke the system. } } } } ``` 还有一个方法就是把日志操作从循环中去掉,在另外的地方进行打印,只记录第一个或者最后一个异常就好了 ## 日志追踪系统 仅仅依赖以上日志规范只能做到单个应用的日志规范记录, 但是这些分散的数据对于问题排查,或是流程优化都 帮助有限. 对于一个跨进程 / 跨线程的场景, 汇总收集并分析海量日志就显得尤为重要. 要能做到追踪每个请求的完整调用链路, 收集调用链路上每个服务的性能数据, 计算性能数据和比对性能指标等能有效排查问题, 分析系统瓶颈. 最后还能对日志进行大数据分析, 带来更高的利润. 因此以后会考虑做日志追溯系统 这里有一篇以前写的 [日志追踪系统设计](/nodes/log-trace-design.md) 和 [日志追踪系统实现](/nodes/log-trace-implement.md) ## [SSO单点登录优化:Shiro认证过程详解与代码封装技巧](https://blog.dong4j.site/posts/205ef910.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 多个模块有登录需求, 但是代码都是相互拷贝, 没有做统一处理 ## 优化方案 将登录逻辑封装成模块, 作为插件提供服务 ## shiro 认证过程 ### 1. 收集实体/凭据信息 ```java UsernamePasswordToken token = new UsernamePasswordToken(username, password, true); ``` UsernamePasswordToken 支持最常见的用户名/密码的认证机制。同时,由于它实现了 Rarraydsj@163.comemberMeAuthenticationToken 接口,我们可以通过令牌设置“记住我”的功能。 但是,“已记住”和“已认证”是有区别的: 已记住的用户仅仅是非匿名用户,你可以通过 subject.getPrincipals() 获取用户信息。但是它并非是认证通过的用户,当你访问需要认证用户的功能时,你仍然需要重新提交认证信息。 这一区别可以参考淘宝网站,网站会默认记住登录的用户,再次访问网站时,对于非敏感的页面功能,页面上会显示记住的用户信息,但是当你访问网站账户信息时仍然需要再次进行登录认证 ### 2. 提交实体/凭据消息 ```java SecurityUtils.getSubject().login(token); ``` ### 3. 认证 如果自定义 Realm 实现, 但执行第二步中的 login() 时, 会回调 `doGetAuthenticationInfo()` ```java protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken) throws AuthenticationException { // 获取基于用户名和密码的令牌 // 实际上这个 token 是从 LoginController 里面 SecurityUtils.getSubject().login(token) 传过来的 UsernamePasswordToken token = (UsernamePasswordToken) authcToken; String username = token.getUsername(); if (!StringUtils.isEmpty(username)) { // 从数据库中查询用户用信息 LoginModel model = authorityService.getLoginModel(username); // 原来没有判断 model 是否为 null 的语句, 会造成 model.getPassword() 语句 空指针异常 if(model != null){ // 此处无需比对,比对的逻辑 Shiro 会做, 我们只需返回一个和令牌相关的正确的验证信息 return new SimpleAuthenticationInfo(StringUtils.upperCase(username), model.getPassword(), getName()); } } return null; } ``` ### 4. 认证处理 ```java try { SecurityUtils.getSubject().login(token); } catch (AuthenticationException e) { model.addAttribute("message", "用户名或密码错误或用户已被禁用"); return "login"; } ``` 发生异常时, 给出提示信息, 返回到登录页面 如果 login 方法执行完毕且没有抛出任何异常信息,那么便认为用户认证通过。之后在应用程序任意地方调用 SecurityUtils.getSubject() 都可以获取到当前认证通过的用户实例,使用 subject.isAuthenticated() 判断用户是否已验证都将返回 true. 相反,如果 login 方法执行过程中抛出异常,那么将认为认证失败 ### 4. 登出操作 登出操作可以通过调用 subject.logout() 来删除你的登录信息,如: ``` SecurityUtils.getSubject().logout(); ``` ### SimpleAuthenticationInfo SimpleAuthenticationInfo 这里原理很简单,又有一些值得挖掘的东西。 ```java //此处使用的是user对象,不是username SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo( user, user.getPassword(), getName() ); ``` 这个东西是在 realm 中的,第一个参数 user,这里好多地方传的时候都是 user 对象,但是都在备注用户名。可是我如果传入 username,就会报类型转换问题。 但是在开涛大神的博客中,无状态的 shiro 里,那边给出的例子是传 username。我自己测试的,可以传 username,也可以传 user 对象,仅限他那边一段代码。网上有文章说,这里其实是 user 和 username 的集合,后端是分两个字段接收的。由于时间的问题,没有深入里了解这块,传 user 对象是 OK 的。 第二个字段是 user.getPassword(),注意这里是指从数据库中获取的 password。 第三个字段是 realm,即当前 realm 的名称。 看了几篇文章介绍说,这块对比逻辑是先对比 username,但是 username 肯定是相等的,所以真正对比的是 password。从这里传入的 password(这里是从数据库获取的)和 token(filter 中登录时生成的)中的 password 做对比,如果相同就允许登录,不相同就抛出异常。 如果验证成功,最终这里返回的信息 authenticationInfo 的值与传入的第一个字段的值相同(我这里传的是 user 对象)。 ## [从零开始:lanproxy与nginx配置攻略](https://blog.dong4j.site/posts/81cae783.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 需要的环境: 1. 具有公网 IP 的服务器, 运行 proxy-server 2. 一台内网 pc 或服务器, 运行 proxy-client 3. lanproxy 4. nginx 5. JDK ## 准备工作 使用 阿里云 服务器, ip 为 `111.111.111.111`, 已经安装好了 nginx 和 JDK8 下载 proxy-server 和 proxy-client https://github.com/ffay/lanproxy/releases 修改 hosts 文件 ``` 111.111.111.111 ivr.wechat.com 111.111.111.111 local.ivr.wechat.com ``` ## proxy-server 搭建 1. 解压 proxy-server.zip 2. 修改配置文件 `conf/config.properties`, 修改用户名和密码 3. 启动服务 `/usr/local/proxy-server/bin/startup.sh` config.properties ```lua server.bind=0.0.0.0 # 普通端口 server.port=4900 server.ssl.enable=true server.ssl.bind=0.0.0.0 # ssl 端口 server.ssl.port=4993 server.ssl.jksPath=test.jks server.ssl.keyStorePassword=123456 server.ssl.keyManagerPassword=123456 server.ssl.needsClientAuth=false config.server.bind=0.0.0.0 # server 后台端口 config.server.port=8090 # 用户名 config.admin.username=admin # 密码 config.admin.password=admin ``` 4. 使用 nginx 反向代理 访问 `http://ivr.wechat.com/` 代理到 `http://127.0.0.1:8090` ```lua server { listen 80; server_name ivr.wechat.com; charset utf-8; location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; client_max_body_size 1024m; client_body_buffer_size 128k; client_body_temp_path data/client_body_temp; proxy_connect_timeout 90; proxy_send_timeout 90; proxy_read_timeout 90; proxy_buffer_size 4k; proxy_buffers 4 32k; proxy_busy_buffers_size 64k; proxy_temp_file_write_size 64k; proxy_temp_path data/proxy_temp; proxy_pass http://127.0.0.1:8090; } } ``` 访问 http://ivr.wechat.com/ ![20241229154732_eWVWuj5D.webp](https://cdn.dong4j.site/source/image/20241229154732_eWVWuj5D.webp) ## proxy-server 配置 1. 登录 proxy-server,添加客户端,输入客户端备注名称,生成随机密钥,提交添加 ![20241229154732_fr4EBIOa.webp](https://cdn.dong4j.site/source/image/20241229154732_fr4EBIOa.webp) 2. 客户端列表中,配置管理中,都会出现新添加的客户端 ![20241229154732_TQkmxEDb.webp](https://cdn.dong4j.site/source/image/20241229154732_TQkmxEDb.webp) 3. 单击配置管理中的客户端,添加配置(每个客户端可以添加多个配置) ![20241229154732_FnzKC41D.webp](https://cdn.dong4j.site/source/image/20241229154732_FnzKC41D.webp) - 代理名称,推荐输入客户端要代理出去的端口,或者是客户端想要发布到公网的项目名称 - 公网端口,填入一个服务器空闲端口,用来转发请求给客户端 - 代理 IP 端口,填入客户端端口,公网会转发请求给该客户端端口 ## proxy-client 搭建 1. 解压 proxy-client 2. 修改配置文件 ```lua # 与在proxy-server配置后台创建客户端时填写的秘钥保持一致 client.key=0b1bad2253f945a28a2ea7d3e8f35545 ssl.enable=true ssl.jksPath=test.jks ssl.keyStorePassword=123456 # 这里填写实际的proxy-server地址;没有服务器默认即可,自己有服务器的更换为自己的proxy-server(IP)地址 server.host=ivr.wechat.com #default ssl port is 4993 #proxy-server ssl默认端口4993,默认普通端口4900 #ssl.enable=true时这里填写ssl端口,ssl.enable=false时这里填写普通端口 server.port=4993 ``` 1. 执行 `bin/startup.sh` 启动 client 启动成功后, 会显示 `在线` 标识 ![20241229154732_reJKuvzx.webp](https://cdn.dong4j.site/source/image/20241229154732_reJKuvzx.webp) 最后直接访问 `http://ivr.wechat.com:50002` 将会转发请求到 127.0.0.1:8080 ## 优化 nginx 再添加一次转发 ```lua server { listen 80; server_name local.ivr.wechat.com; charset utf-8; location /{ proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; client_max_body_size 1024m; client_body_buffer_size 128k; client_body_temp_path data/client_body_temp; proxy_connect_timeout 90; proxy_send_timeout 90; proxy_read_timeout 90; proxy_buffer_size 4k; proxy_buffers 4 32k; proxy_busy_buffers_size 64k; proxy_temp_file_write_size 64k; proxy_temp_path data/proxy_temp; proxy_pass http://127.0.0.1:50002; } } ``` 直接访问 `http://local.ivr.wechat.com` 将会转发到 proxy-server 的 50002 端口, 相当于直接访问 `http://ivr.wechat.com:50002`, 最后会转发到内网的 `127.0.0.1:8080` ## [动态调整日志:JMX与Zookeeper的强大组合](https://blog.dong4j.site/posts/8872e19a.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## why 为了减少日志文件的数量, 生产环境的日志等级都是 Error, 但是当遇到问题时, 错误日志可能不能快速准确的定位出错的地方, 如果能在不重启应用的情况下, 修改日志级别并且生效, 能更快的发现出错的地方. ## what 这里选择使用 JMX 来实现日志级别动态修改. JMX (Java Management Extensions) 是管理 Java 的一种扩展. 这种机制可以方便的管理, 监控正在运行中的 Java 程序. 常用于管理线程, 内存, 日志 Level, 服务重启, 系统环境等. 实现一个被 JMX 托管的 MBean 的方式: 1. MBean 的接口必须以 MBean 结尾, 比如 XxxxMBean 2. 实现必须以 Xxxx 命名因为接口定义是 XxxxMBean logback 定义的 MBean ![20241229154732_UPs9YUUI.webp](https://cdn.dong4j.site/source/image/20241229154732_UPs9YUUI.webp) jconsole 查看 MBean ![20241229154732_kefuJoBn.webp](https://cdn.dong4j.site/source/image/20241229154732_kefuJoBn.webp) ## how 动态修改日志级别的思路: ![20241229154732_WaZ9o14Z.webp](https://cdn.dong4j.site/source/image/20241229154732_WaZ9o14Z.webp) 1. API 调用 DynamicChangeLogLevel 修改日志级别 2. DynamicChangeLogLevel 通过 LogBackMBean 修改日志级别 3. 使用责任链, 修改 xxx-api 后, 后面相关的服务都会被修改 ### 存在的问题 **1. 在集群环境, 因为有负债均衡, 不同的请求被负载到不同的机器上面, 前面修改了日志级别, 下一次有可能不会生效** ![20241229154732_zSuSD7Y6.webp](https://cdn.dong4j.site/source/image/20241229154732_zSuSD7Y6.webp) **2. 不能单独修改具体应用的日志级别** ### 解决方案 使用 Zookeeper Watcher. 1. 每个应用看做一个单独的节点, 启动的时候向 Zookeeper 注册以项目服务名命名的节点, 并把 logback.xml 中设置的日志级别写入节点, 最后对这个节点监听. 2. API 调用时, 传入 applicationName 和 level, 修改具体节点下的数据 3. 节点数据被修改, 触发 watcher, 调用 LogBackMBean 修改日志级别 具体流程如下: ![20241229154732_pbG4MaHS.webp](https://cdn.dong4j.site/source/image/20241229154732_pbG4MaHS.webp) ### 具体步骤 1. 修改 logback.xml 配置, 添加 `` 开启 JMX, 添加 com.xxx 的日志级别 ```xml ... ``` 设置一个 `` 的原因在于, 当需要查找执行流程时, 只需要将 com.xxx 设置为 INFO, 这样只会输出 `com.xxx` 包及子包中的 INFO 信息. 如果没有 `com.xxx`, 我们只有设置 ROOT 为 INFO, 这样会输出诸如 dubbo, zookeeper 等第三方包中的所有 INFO 信息. 2. 添加一个 `ServerListener`, 一是解决 [动态修改日志级别时内存溢出](https://logback.qos.ch/manual/jmxConfig.html), 二是应用启动完成后向 Zookeeper 创建节点 ```java public class ServerListener implements ServletContextListener { private final Logger log = LoggerFactory.getLogger(this.getClass()); public void contextDestroyed(ServletContextEvent contextEvent) { // 防止动态修改日志级别时内存溢出 LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); loggerContext.stop(); } public void contextInitialized(ServletContextEvent contextEvent) { String applicationName = contextEvent.getServletContext().getServletContextName(); log.info("================================="); log.info("system [{}] start finish!!!", applicationName); log.info("================================="); log.info("servlet path [{}]", System.getProperty(contextEvent.getServletContext().getServletContextName())); log.info("create zookeeper node start"); String host = "127.0.0.1:2181"; String defaultLogLevel = DynamicChangeLogLevel.getCurrentlyLevel(new LogNode()); new LogNodeOperation(host, applicationName, defaultLogLevel); } } ``` 3. 实现 ChangeLogLevel API ### 部署问题 因为 MBean 是通过 ObjectName 来获取对象, logback 的默认 OBjectName 为 `ch.qos.logback.classic:Name=default,Type=ch.qos.logback.classic.jmx.JMXConfigurator` 当在同一个 Tomcat 中部署多个应用时, 每个 Web 应用程序中的记录器上下文相关联的各种实例将会相互冲突 **解决方法** 在 logback.xml 设置 contextName ```xml ${project.artifactId} ... ``` ![20241229154732_vmTKfeym.webp](https://cdn.dong4j.site/source/image/20241229154732_vmTKfeym.webp) 这样就能区分不同的应用 ![20241229154732_30Clgp3H.webp](https://cdn.dong4j.site/source/image/20241229154732_30Clgp3H.webp) # 新需求 要明确不同服务器上的不同应用, 能具体修改某一台服务器上的某一个应用的日志级别 ## [Spring AOP 动态代理深度解析:LTW 技术揭秘](https://blog.dong4j.site/posts/95aea2e1.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 代码织入实现方式: 1. 静态代理 1. AspectJ 织入器 weaver) 1. compile-time weaving 使用 aspectj 编译器进行编译源码 2. post-compile weaving 对 class 文件进行织入 3. load-time weaving(LTW) 当 class loader 加载类的时候,进行织入 2. 动态代理 1. JDK 动态代理 (接口) 2. CGlib(类) 这里使用 [AspectJ LTW](http://www.eclipse.org/aspectj/doc/next/devguide/ltw-configuration.html) 实现, 这种方式在类加载器织入代码. - **编译器织入** 会造成编译速度变慢, 而且必须使用 ajc 编译器 - **动态代理** 会生成大量代理类, 加速内存消耗 因此使用 **类加载期织入** 相对于其他两种方式, 更加轻便. LTW(Load Time Weaver),即加载期切面织入,是 ApsectJ 切面织入的一种方式,它通过 JVM 代理在类加载期替换字节码到达织入切面的目的。 ## 具体实现 首先定义个切面类,该切面功能非常简单,就是在 `com.xxx.server.rest.resource.impl` 及其所有子包下所有的类的 public 方法调用后打印执行时间 ```java @Slf4j @Aspect public class ProfilingAspect { @Pointcut("execution(* com.xxx.server.rest.resource.impl..*.*(..))") public void api() { } @Around("api()") public Object profile(ProceedingJoinPoint pjp) throws Throwable { StopWatch sw = new StopWatch(getClass().getSimpleName()); try { sw.start(pjp.getSignature().getName()); return pjp.proceed(); } finally { sw.stop(); log.debug("AOP --> {} ms", sw.getTotalTimeMillis()); } } } ``` 使用了 AspectJ 注解,想要了解更多的 Aspect 注解使用可以查看 AspectJ 相关的文档,在 `https://github.com/eclipse/org.aspectj/tree/master/docs` 下面有个 quick5.doc 文档列出了所有的注解,也可以查看源代码或者反编译 aspectweaver.jar,查看里面有哪些注解,在 org.aspectj.lang.annotation 这个包下面 编写目标测试 bean: ```java public class LTWBean { public void run() { System.out.println("LTWBean run..."); } } ``` 编写一个 XML 文件定义切面,该文件名及其所在路径只能固定的几种:META-INF/aop.xml、META-INF/aop-ajc.xml、org/aspectj/aop.xml,文件内容如下: ```xml > ``` **存在的坑** 按照官方文档配置 aop.xml, 一直不生效, 是因为没有将切面类 include 文件定义了切面以及切面编织器,这里的切面会被织入 `com.xxx.server.rest.resource.impl` 及其子包下的所有类。 编织器注入 Spring 容器中,并且定义目标测试 bean: ```xml ``` 上面的编织器 bean 的名称必须是 loadTimeWeaver,此外还有一种更简单的配置方式,使用 context 名称空间下的标签: ```xml ``` 上面两段配置起到的效果完全是一样的,bean 解析器在解析到 `context:load-time-weaver` 标签时, 会自动生成一个名称为 `loadTimeWeaver` 类型 `org.springframework.context.weaving.DefaultContextLoadTimeWeaver` 的 bean 以及一个类型 `org.springframework.context.weaving.AspectJWeavingEnabler` 的匿名 bean,这段代码在 `LoadTimeWeaverBeanDefinitionParser` 类中. aspectj-weaving 属性有三个枚举值: - on - off - autodetect 分别是打开、关闭、自动检测 这里设置成 autodetect,容器会检查程序是否定义了切面定义文件(即上面提到的 aop.xml 文件) 代码在 `LoadTimeWeaverBeanDefinitionParser` 的 `isAspectJWeavingEnabled` 方法。 编写测试代码: ```java BeanFactory context = new ClassPathXmlApplicationContext("spring/beans/ltw/ltw.xml"); LTWBean ltwBean = (LTWBean) context.getBean("ltwBean"); ltwBean.run(); ``` 运行测试代码,在运行时需要启动一个 JVM 代理,首先需要下载 spring-instrument-xxx.jar 包,在虚拟机参数中添加 ```bash -javaagent:/path/to/spring-instrument-4.3.10.RELEASE.jar -javaagent:/path/to/aspectjweaver-1.8.10.jar ``` 执行测试代码,打印下面日志,说明切面织入成功: ```bash LTWBean run... AOP --> xx ms ``` # 字节码转换和虚拟机代理 要了解 LTW 的原理,首先要对 JDK 的字节码转换框架和 JVM 代理有一定的了解: 从 JAVA5 开始,在 JDK 中添加了一个新包 java.lang.instrument,这个包就是字节码转换框架的基础。字节码转换框架是一项非常重要的技术,许多程序监控和调试工具比如 BTrace 都是在这个框架的基础上实现的。这个包下面有两个关键接口: - ClassFileTransformer:类字节码转换器,提供了一个 transform 方法,这个方法的用来转换提供的类字节码,并返回一个新的替换字节码。不同于 CGLIB、JDK 动态代理等字节码操作技术,ClassFileTransformer 是彻底的替换掉原类,而 CGLIB 和 JDK 动态代理是生成一个新子类或接口实现。 - Instrumentation:这个类提供检测 Java 编程语言代码所需的服务。检测是向方法中添加字节码,以搜集各种工具所使用的数据。由于更改完全是进行添加,所以这些工具不修改应用程序的状态或行为。这种无害工具的例子包括镜像代理、分析器、覆盖分析器和事件记录器。可以通过它的 addTransformer 方法把实现好的字节码转换器(ClassFileTransformer)注册到 JVM 中。 在普通代码中无需实现 Instrumentation 并且创建 Instrumentation 实例,要获取 Instrumentation 实例,可以通过 JVM 代理,JVM 代理可以通过两种方式启动: ## 程序启动时启动代理 在程序启动时指定代理,这时候虚拟机会创建一个 Instrumentation 实例实例传递给代理类的 premain 方法。需要在 META-INF/MANIFEST.MF 中通过 Premain-Class 指定代理入口类,并且代理入口类中必须定义 premain 方法,像上面提到的在运行程序时在虚拟机参数添加 -javaagent 就是在程序启动时指定了代理,我们可以看看 spring-instrument-3.2.9.RELEASE.jar 包中 MANIFEST.MF 文件的内容: ```bash Manifest-Version: 1.0 Created-By: 1.7.0_55 (Oracle Corporation) Implementation-Title: spring-instrument Implementation-Version: 3.2.9.RELEASE Premain-Class: org.springframework.instrument.InstrumentationSavingAgent Can-Redefine-Classes: true Can-Retransform-Classes: true Can-Set-Native-Method-Prefix: false ``` 看以看到通过 Premain-Class 指定了 org.springframework.instrument.InstrumentationSavingAge 作为代理入口类,看看 InstrumentationSavingAge 这个类的代码,有一个 premain 方法并且有一个 Instrumentation 参数: ```java public class InstrumentationSavingAgent { private static volatile Instrumentation instrumentation; /** * Save the {@link Instrumentation} interface exposed by the JVM. */ public static void premain(String agentArgs, Instrumentation inst) { instrumentation = inst; } /** * Return the {@link Instrumentation} interface exposed by the JVM. *

Note that this agent class will typically not be available in the classpath * unless the agent is actually specified on JVM startup. If you intend to do * conditional checking with respect to agent availability, consider using * {@link org.springframework.instrument.classloading.InstrumentationLoadTimeWeaver#getInstrumentation()} * instead - which will work without the agent class in the classpath as well. * @return the {@code Instrumentation} instance previously saved when * the {@link #premain} method was called by the JVM; will be {@code null} * if this class was not used as Java agent when this JVM was started. * @see org.springframework.instrument.classloading.InstrumentationLoadTimeWeaver#getInstrumentation() */ public static Instrumentation getInstrumentation() { return instrumentation; } } ``` # 程序运行时启动代理 还有一种方式是在程序启动之后启动代理,这种情况下可以在不暂停应用程序的情况下动态向注册类转换器对已加载的类进行重定义。这种方式需要在 META-INF/MANIFEST.MF 文件中通过 Agent-Class 指定代理入口类,并且入口代理类中定义 agentmain 方法,虚拟机把 Instrumentation 实例传递给这个 agentmain 方法,下面代码是 Druid 框架(Druid 是阿里巴巴一个开源的连接池框架)中拷贝过来的一段代码,演示了如何在应用运行时启动一个 JMX 代理: ```java private static String loadManagementAgentAndGetAddress(int vmid) throws IOException { VirtualMachine vm = null; String name = String.valueOf(vmid); try { vm = VirtualMachine.attach(name); } catch (AttachNotSupportedException x) { throw new IOException(x.getMessage(), x); } String home = vm.getSystemProperties().getProperty("java.home"); // Normally in ${java.home}/jre/lib/management-agent.jar but might // be in ${java.home}/lib in build environments. String agent = home + File.separator + "jre" + File.separator + "lib" + File.separator + "management-agent.jar"; File f = new File(agent); if (!f.exists()) { agent = home + File.separator + "lib" + File.separator + "management-agent.jar"; f = new File(agent); if (!f.exists()) { throw new IOException("Management agent not found"); } } agent = f.getCanonicalPath(); try { vm.loadAgent(agent, "com.sun.management.jmxremote"); } catch (AgentLoadException x) { throw new IOException(x.getMessage(), x); } catch (AgentInitializationException x) { throw new IOException(x.getMessage(), x); } // get the connector address Properties agentProps = vm.getAgentProperties(); String address = (String) agentProps.get(LOCAL_CONNECTOR_ADDRESS_PROP); vm.detach(); return address; } ``` 运行 management-agent.jar 作为代理,来看看 management-agent.jar 中的 META-INF/MANIFEST.MF 文件,同时指定了 Premain-Class 和 Agent-Class,代理启动之后虚拟机会调用代理入口类的 agentmain 方法,需要注意的是 Agent 类只有一个 String 参数的 agentmain 并没有定义带 Instrumentation 参数的 agentmain,因为 JMX 代理并不需要 Instrumentation 实例: ```bash Manifest-Version: 1.0 Premain-Class: sun.management.Agent Created-By: 1.5.0 (Sun Microsystems Inc.) Agent-Class: sun.management.Agent ``` # 实现原理 了解了 JDK 字节码框架和虚拟机代理之后,分析 LTW 的实现原理就简单得多了。 当容器检查到定义了名称为 loadTimeWeaver 的 bean 时,会注册一个 LoadTimeWeaverAwareProcessor 到容器中,代码在 AbstractApplicationContext 的 prepareBeanFactory 方法中: ```java protected void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory) { ... if (beanFactory.containsBean(LOAD_TIME_WEAVER_BEAN_NAME)) { beanFactory.addBeanPostProcessor(new LoadTimeWeaverAwareProcessor(beanFactory)); // Set a temporary ClassLoader for type matching. beanFactory.setTempClassLoader(new ContextTypeMatchClassLoader(beanFactory.getBeanClassLoader())); } ... } ``` LoadTimeWeaverAwareProcessor 是一个 BPP(BeanPostProcessor),这个 BPP 用来处理 LoadTimeWeaverAware 接口的,把 LTW 实例设置到实现了 LoadTimeWeaverAware 接口的 bean 中,从 LoadTimeWeaverAwareProcessor 的 postProcessBeforeInitialization 方法可以看出来: ```java public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof LoadTimeWeaverAware) { LoadTimeWeaver ltw = this.loadTimeWeaver; if (ltw == null) { Assert.state(this.beanFactory != null, "BeanFactory required if no LoadTimeWeaver explicitly specified"); ltw = this.beanFactory.getBean( ConfigurableApplicationContext.LOAD_TIME_WEAVER_BEAN_NAME, LoadTimeWeaver.class); } ((LoadTimeWeaverAware) bean).setLoadTimeWeaver(ltw); } return bean; } ``` 再来看下 AspectJWeavingEnabler 这个类的代码,它是一个 BFPP,同时也实现了 LoadTimeWeaverAware 接口,通过上面的分析,loadTimeWeaver 这个 bean 会自动注入到 AspectJWeavingEnabler 类型 bean 中。AspectJWeavingEnabler 的 postProcessBeanFactory 方法直接调用 enableAspectJWeaving 方法,来看看这个方法的代码: ```java public static void enableAspectJWeaving(LoadTimeWeaver weaverToUse, ClassLoader beanClassLoader) { if (weaverToUse == null) { if (InstrumentationLoadTimeWeaver.isInstrumentationAvailable()) { weaverToUse = new InstrumentationLoadTimeWeaver(beanClassLoader); } else { throw new IllegalStateException("No LoadTimeWeaver available"); } } weaverToUse.addTransformer(new AspectJClassBypassingClassFileTransformer( new ClassPreProcessorAgentAdapter())); } ``` weaverToUse 这个参数就是被容器自动注入的 loadTimeWeaver bean,从 bean 定义 XML 中可以知道这个 bean 是 DefaultContextLoadTimeWeaver 类型的,它的 addTransformer 方法代码如下: ```java public void addTransformer(ClassFileTransformer transformer) { this.loadTimeWeaver.addTransformer(transformer); } ``` DefaultContextLoadTimeWeaver 类也有个 loadTimeWeaver 属性,这个属性是在 setBeanClassLoader 方法中设置进去的: ```java public void setBeanClassLoader(ClassLoader classLoader) { LoadTimeWeaver serverSpecificLoadTimeWeaver = createServerSpecificLoadTimeWeaver(classLoader); if (serverSpecificLoadTimeWeaver != null) { if (logger.isInfoEnabled()) { logger.info("Determined server-specific load-time weaver: " + serverSpecificLoadTimeWeaver.getClass().getName()); } this.loadTimeWeaver = serverSpecificLoadTimeWeaver; } else if (InstrumentationLoadTimeWeaver.isInstrumentationAvailable()) { logger.info("Found Spring's JVM agent for instrumentation"); this.loadTimeWeaver = new InstrumentationLoadTimeWeaver(classLoader); } else { try { this.loadTimeWeaver = new ReflectiveLoadTimeWeaver(classLoader); logger.info("Using a reflective load-time weaver for class loader: " + this.loadTimeWeaver.getInstrumentableClassLoader().getClass().getName()); } catch (IllegalStateException ex) { throw new IllegalStateException(ex.getMessage() + " Specify a custom LoadTimeWeaver or start your " + "Java virtual machine with Spring's agent: -javaagent:org.springframework.instrument.jar"); } } } ``` 在方法里面判断了当前是否存在 Instrumentation 实例,最终会取 InstrumentationSavingAgent 类中的 instrumentation 的静态属性,判断这个属性是否是 null,从前面的分析可以知道 InstrumentationSavingAgent 这个类是 spring-instrument-3.2.9.RELEASE.jar 的代理入口类,当应用程序启动时启动了 spring-instrument-3.2.9.RELEASE.jar 代理时,即在虚拟机参数中设置了 -javaagent 参数,虚拟机会创建 Instrumentation 实例并传递给 premain 方法,InstrumentationSavingAgent 会把这个类保存在 instrumentation 静态属性中。所以在程序启动时启动了代理时 InstrumentationLoadTimeWeaver.isInstrumentationAvailable() 这个方法是返回 true 的,所以 loadTimeWeaver 属性会设置成 InstrumentationLoadTimeWeaver 对象。 接下来就看看 InstrumentationLoadTimeWeaver 类的 addTransformer 方法代码: ```java public void addTransformer(ClassFileTransformer transformer) { Assert.notNull(transformer, "Transformer must not be null"); FilteringClassFileTransformer actualTransformer = new FilteringClassFileTransformer(transformer, this.classLoader); synchronized (this.transformers) { if (this.instrumentation == null) { throw new IllegalStateException( "Must start with Java agent to use InstrumentationLoadTimeWeaver. See Spring documentation."); } this.instrumentation.addTransformer(actualTransformer); this.transformers.add(actualTransformer); } } ``` 从代码中可以看到,这个方法中,把类转换器 actualTransformer 通过 instrumentation 实例注册给了虚拟机。这里采用了修饰器模式,actualTransformer 对 transformer 进行修改封装,下面是 FilteringClassFileTransformer 这个内部类的代码: ```java private static class FilteringClassFileTransformer implements ClassFileTransformer { private final ClassFileTransformer targetTransformer; private final ClassLoader targetClassLoader; public FilteringClassFileTransformer(ClassFileTransformer targetTransformer, ClassLoader targetClassLoader) { this.targetTransformer = targetTransformer; this.targetClassLoader = targetClassLoader; } public byte[] transform(ClassLoader loader, String className, Class classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { if (!this.targetClassLoader.equals(loader)) { return null; } return this.targetTransformer.transform( loader, className, classBeingRedefined, protectionDomain, classfileBuffer); } @Override public String toString() { return "FilteringClassFileTransformer for: " + this.targetTransformer.toString(); } } ``` 这里面的 targetClassLoader 就是容器的 bean 类加载,在进行类字节码转换之前先判断执行类加载的加载器是否是 bean 类加载器,如果不是的话跳过类装换逻辑直接返回 null,返回 null 的意思就是不执行类转换还是使用原始的类字节码。什么情况下会有类加载不是 bean 的类加载器的情况?在我们上面列出的 AbstractApplicationContext 的 prepareBeanFactory 方法中有一行代码: ```java beanFactory.setTempClassLoader(new ContextTypeMatchClassLoader(beanFactory.getBeanClassLoader())); ``` 当容器中注册了 loadTimeWeaver 之后会给容器设置一个 ContextTypeMatchClassLoader 类型的临时类加载器,在织入切面时只有在 bean 实例化时织入切面才有意义,在进行一些类型比较或者校验的时候,比如判断一个 bean 是否是 FactoryBean、BPP、BFPP,这时候不涉及到实例化,所以做字节码转换没有任何意义,而且还会增加无谓的性能消耗,所以在进行这些类型比较时使用这个临时的类加载器执行类加载,这样在上面的 transform 方法就会因为类加载不匹配而跳过字节码转换,这里有一点非常关键的是,ContextTypeMatchClassLoader 的父类加载就是容器 bean 类加载器,所以 ContextTypeMatchClassLoader 类加载器是不遵循“双亲委派”的,因为如果它遵循了“双亲委派”,那么它的类加载工作还是会委托给 bean 类加载器,这样的话 if 里面的条件就不会匹配,还是会执行类转换。ContextTypeMatchClassLoader 的类加载工作会委托给 ContextOverridingClassLoader 类对象,有兴趣可以看看 ContextOverridingClassLoader 和 OverridingClassLoader 这两个类的代码。 这个临时的类加载器会在容器初始化快结束时,容器 bean 实例化之前被清掉,代码在 AbstractApplicationContext 类的 finishBeanFactoryInitialization 方法: ```java protected void finishBeanFactoryInitialization(ConfigurableListableBeanFactory beanFactory) { ... beanFactory.setTempClassLoader(null); // Allow for caching all bean definition metadata, not expecting further changes. beanFactory.freezeConfiguration(); // Instantiate all remaining (non-lazy-init) singletons. beanFactory.preInstantiateSingletons(); } ``` 再回头来看 FilteringClassFileTransformer 类的 transform 方法,调用 targetTransformer 执行字节码转换。来看看 targetTransformer 这个类转换器是在哪创建的,回头再看下 AspectJWeavingEnabler 类的 enableAspectJWeaving 方法,有下面这行代码: ```java weaverToUse.addTransformer(new AspectJClassBypassingClassFileTransformer( new ClassPreProcessorAgentAdapter())); ``` AspectJClassBypassingClassFileTransformer 类和 ClassPreProcessorAgentAdapter 类都实现了字节码转换接口 ClassFileTransformer: ```java private static class AspectJClassBypassingClassFileTransformer implements ClassFileTransformer { private final ClassFileTransformer delegate; public AspectJClassBypassingClassFileTransformer(ClassFileTransformer delegate) { this.delegate = delegate; } public byte[] transform(ClassLoader loader, String className, Class classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { if (className.startsWith("org.aspectj") || className.startsWith("org/aspectj")) { return classfileBuffer; } return this.delegate.transform(loader, className, classBeingRedefined, protectionDomain, classfileBuffer); } } ``` 这也是一个修饰器模式,最终会调用 ClassPreProcessorAgentAdapter 的 transform 方法执行字节码转换逻辑,在类加载器定义类时(即调用 defineClass 方法)会调用此类的 transform 方法来进行字节码转换替换原始类。ClassPreProcessorAgentAdapter 类中的代码比较多,这里就不列出来了,它的主要工作是解析 aop.xml 文件,解析类中的 Aspect 注解,并且根据解析结果来生成转换后的字节码。 在上面例子里面提到的通过 context 名称空间下的 load-time-weaver 标签来配置,其本质原理是一致的。通过在 context 的名称空间处理器 ContextNamespaceHandler 中可以看到 load-time-weaver 标签的解析器是 LoadTimeWeaverBeanDefinitionParser 类,看下这个类的代码: ```java class LoadTimeWeaverBeanDefinitionParser extends AbstractSingleBeanDefinitionParser { private static final String WEAVER_CLASS_ATTRIBUTE = "weaver-class"; private static final String ASPECTJ_WEAVING_ATTRIBUTE = "aspectj-weaving"; private static final String DEFAULT_LOAD_TIME_WEAVER_CLASS_NAME = "org.springframework.context.weaving.DefaultContextLoadTimeWeaver"; private static final String ASPECTJ_WEAVING_ENABLER_CLASS_NAME = "org.springframework.context.weaving.AspectJWeavingEnabler"; @Override protected String getBeanClassName(Element element) { if (element.hasAttribute(WEAVER_CLASS_ATTRIBUTE)) { return element.getAttribute(WEAVER_CLASS_ATTRIBUTE); } return DEFAULT_LOAD_TIME_WEAVER_CLASS_NAME; } @Override protected String resolveId(Element element, AbstractBeanDefinition definition, ParserContext parserContext) { return ConfigurableApplicationContext.LOAD_TIME_WEAVER_BEAN_NAME; } @Override protected void doParse(Element element, ParserContext parserContext, BeanDefinitionBuilder builder) { builder.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); if (isAspectJWeavingEnabled(element.getAttribute(ASPECTJ_WEAVING_ATTRIBUTE), parserContext)) { RootBeanDefinition weavingEnablerDef = new RootBeanDefinition(); weavingEnablerDef.setBeanClassName(ASPECTJ_WEAVING_ENABLER_CLASS_NAME); parserContext.getReaderContext().registerWithGeneratedName(weavingEnablerDef); if (isBeanConfigurerAspectEnabled(parserContext.getReaderContext().getBeanClassLoader())) { new SpringConfiguredBeanDefinitionParser().parse(element, parserContext); } } } protected boolean isAspectJWeavingEnabled(String value, ParserContext parserContext) { if ("on".equals(value)) { return true; } else if ("off".equals(value)) { return false; } else { // Determine default... ClassLoader cl = parserContext.getReaderContext().getResourceLoader().getClassLoader(); return (cl.getResource(AspectJWeavingEnabler.ASPECTJ_AOP_XML_RESOURCE) != null); } } protected boolean isBeanConfigurerAspectEnabled(ClassLoader beanClassLoader) { return ClassUtils.isPresent(SpringConfiguredBeanDefinitionParser.BEAN_CONFIGURER_ASPECT_CLASS_NAME, beanClassLoader); } } ``` 从上面的代码可以看出在解析 load-time-weaver 标签时,从 getBeanClassName 方法中可以看到,如果没有指定 weaver-class 属性,会自动给容器中注入一个 org.springframework.context.weaving.DefaultContextLoadTimeWeaver 类型的 bean,从 resolveId 方法中看到,该 bean 的名称为 loadTimeWeaver。在 doParse 方法中,还会注册一个类型为 org.springframework.context.weaving.AspectJWeavingEnabler 的匿名 bean。从此可以看出下面两段配置完全是等价的: ```xml ``` ```xml ``` ## [Redis Sentinel:实现高可用性与故障转移](https://blog.dong4j.site/posts/a87e6e25.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 使用 Redis Sentinel 重构现有架构 > 对于搭建高可用 Redis 服务,网上已有了很多方案,例如 Keepalived,Codis,Twemproxy,Redis Sentinel。 > 其中 Codis 和 Twemproxy 主要是用于大规模的 Redis 集群中,也是在 Redis 官方发布 Redis Sentinel 之前 豌豆荚 和 twitter 提供的开源解决方案。 > Redis Sentinel 可以理解为一个监控 Redis Server 服务是否正常的进程,并且一旦检测到不正常,可以自动地将备份 (slave)Redis Server 启用,使得外部用户对 > Redis 服务内部出现的异常无感知。 ## 原有架构 ![20241229154732_wkmt6CrY.webp](https://cdn.dong4j.site/source/image/20241229154732_wkmt6CrY.webp) ### 存在的问题 1. 配置部署复杂 2. 不稳定 ## Redis Sentinel 高可用 ![20241229154732_g8KXigVF.webp](https://cdn.dong4j.site/source/image/20241229154732_g8KXigVF.webp) 下面以 1 个主节点、2 个从节点、3 个 Sentinel 节点组成的 Redis Sentinel 为例子 **故障转移处理逻辑:** 1. 主节点出现故障, 此时两个从节点与主节点时区连接, 主从复制失败; ![20241229154732_yV32SN5g.webp](https://cdn.dong4j.site/source/image/20241229154732_yV32SN5g.webp) 2. 每个 Sentinel 节点通过定期监控发现主节点出现故障; ![20241229154732_dmt2MfIR.webp](https://cdn.dong4j.site/source/image/20241229154732_dmt2MfIR.webp) 3. 多个 Sentinel 节点对主节点的故障达成一致, 选举出 sentinel-3 节点作为领导者负责故障转移; ![20241229154732_ypMc084g.webp](https://cdn.dong4j.site/source/image/20241229154732_ypMc084g.webp) 1. 原来的从节点 slave-1 成为新的主节点后, 更新应用方的主节点信息; 2. 客户端命令另一个从节点 slave-2 去复制新的主节点; 3. 待原来的主节点恢复后, 让它去复制新的主节点; ![20241229154732_ZvDNSolX.webp](https://cdn.dong4j.site/source/image/20241229154732_ZvDNSolX.webp) 4. 故障转移后的结构图 ![20241229154732_8ZiZco7l.webp](https://cdn.dong4j.site/source/image/20241229154732_8ZiZco7l.webp) ### Redis Sentinel 功能 1. **监控:** Sentinel 节点会定期检测 Redis 数据节点和其余 Sentinel 节点是否可达; 2. **通知:** Sentinel 节点会将故障转移的结果通知给应用方; 3. **主节点故障转移:** 实现从节点晋升为主节点并维护后续正确的主从关系; 4. **配置提供者:** 客户端在初始化时, 连接 Sentinel 节点集群, 从中获取主节点信息; 采用多个 Sentinel 节点的优点: 1. 对于节点故障判断由多个 Sentinel 节点共同完成, 有效防止误判; 2. 避免单点故障; ### 几个概念 #### 三个定时监控任务 1. 每隔 10 秒. 每个 Sentinel 节点会向主节点和从节点发送 info 命令获取最新的拓扑结构; ![20241229154732_g9I5S7TW.webp](https://cdn.dong4j.site/source/image/20241229154732_g9I5S7TW.webp) 2. 每隔 2 秒, 每个 Sentinel 节点向 _sentinel_:hello 频道上发送该 Sentinel 节点对于主节点的判断一级当前 Sentinel 节点的信息, 同时每个 Sentinel 节点也会订阅该频道; ![20241229154732_ug9RgBvu.webp](https://cdn.dong4j.site/source/image/20241229154732_ug9RgBvu.webp) 3. 每隔 1 秒, 每个 Sentine 会向主从节点, 其他 Sentinel 节点发送 ping 命令做心跳检测, 来确保节点当前是否可达 ![20241229154732_OuXnSfQM.webp](https://cdn.dong4j.site/source/image/20241229154732_OuXnSfQM.webp) #### 主观下线(Subjectively Down, 简称 SDOWN) > 指的是单个 Sentinel 实例对服务器做出的下线判断。 每个 Sentinel 节点会每隔 1 秒对主节 点、从节点、其他 Sentinel 节点发送 ping 命令做心跳检测,当这些节点超过 down-after-milliseconds 没有进行有效回复,Sentinel 节点就会对该节点做失败 判定,这个行为叫做主观下线 ![20241229154732_7Uguxxes.webp](https://cdn.dong4j.site/source/image/20241229154732_7Uguxxes.webp) #### 客观下线(Objectively Down, 简称 ODOWN) > 指的是多个 Sentinel 实例在对同一个服务器做出 SDOWN 判断, 并且通过 SENTINEL is-master-down-by-addr 命令互相交流之后, 得出的服务器下线判断。 > (一个 Sentinel 可以通过向另一个 Sentinel 发送 SENTINEL is-master-down-by-addr 命令来询问对方是否认为给定的服务器已下线。) ## Redis Sentinel 安装与部署 下面将以 3 个 Sentinel 节点、1 个主节点、2 个从节点组成一个 Redis Sentinel 进行说明 ![20241229154732_ZsDsOhqv.webp](https://cdn.dong4j.site/source/image/20241229154732_ZsDsOhqv.webp) 具体的物理部署: | 角色 | ip | port | 别名 | | :--------- | :-------- | :---- | :--------- | | master | 127.0.0.1 | 6379 | 主节点 | | slave-1 | 127.0.0.1 | 6380 | slave-1 | | slave-2 | 127.0.0.1 | 6381 | slave-2 | | sentinel-1 | 127.0.0.1 | 26379 | sentinel-1 | | sentinel-2 | 127.0.0.1 | 26380 | sentinel-2 | | sentinel-3 | 127.0.0.1 | 26381 | sentinel-3 | **1. master 配置** ```lua daemonize yes dbfilename "6379.db" dir "/Users/codeai/Develop/logs/redis/db/" logfile "/Users/codeai/Develop/logs/redis/log/6379.log" port 6379 requirepass 1234 ``` **2. slave-1 配置** ```lua daemonize yes dbfilename "6380.db" dir "/Users/codeai/Develop/logs/redis/db/" logfile "/Users/codeai/Develop/logs/redis/log/6380.log" port 6380 slaveof 127.0.0.1 6379 # 设置 master 验证密码 masterauth 1234 # 设置 slave 密码 requirepass 1234 ``` **3. slave-2 配置** ```lua daemonize yes dbfilename "6381.db" dir "/Users/codeai/Develop/logs/redis/db/" logfile "/Users/codeai/Develop/logs/redis/log/6381.log" port 6381 slaveof 127.0.0.1 6379 # 设置 master 验证密码 masterauth 1234 # 设置 slave 密码 requirepass 1234 ``` **4. 启动 redis 服务** ```lua redis-server redis-6379.conf; redis-server redis-6380.conf; redis-server redis-6381.conf ``` ![20241229154732_MofUCgDd.webp](https://cdn.dong4j.site/source/image/20241229154732_MofUCgDd.webp) **5. 确认主从关系** 主节点视角 ```lua redis-cli -h 127.0.0.1 -p 6379 info replication # Replication # 主节点 role:master # 有 2 个 从节点 connected_slaves:2 # 从节点 1 信息 slave0:ip=127.0.0.1,port=6380,state=online,offset=112,lag=0 # 从节点 2 信息 slave1:ip=127.0.0.1,port=6381,state=online,offset=112,lag=0 master_replid:8f43dc48cca779f46fa1516a38e24fb8c5423d94 master_replid2:0000000000000000000000000000000000000000 master_repl_offset:112 second_repl_offset:-1 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:1 repl_backlog_histlen:112 ``` 从节点视角 ```lua redis-cli -h 127.0.0.1 -p 6380 info replication # Replication # 从节点 role:slave # 主节点信息 master_host:127.0.0.1 master_port:6379 master_link_status:up master_last_io_seconds_ago:2 master_sync_in_progress:0 slave_repl_offset:238 slave_priority:100 slave_read_only:1 connected_slaves:0 master_replid:8f43dc48cca779f46fa1516a38e24fb8c5423d94 master_replid2:0000000000000000000000000000000000000000 master_repl_offset:238 second_repl_offset:-1 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:1 repl_backlog_histlen:238 ``` **6. 部署 Sentinel 节点** ```lua port 26379 daemonize yes logfile "/Users/codeai/Develop/logs/redis/log/26379.log" dir "/Users/codeai/Develop/logs/redis/db/" # 监控 127.0.0.1:6379 主节点; 2 表示判断主节点失败至少需要 2 个 sentinel 节点同意 sentinel monitor mymaster 127.0.0.1 6379 2 # 设置监听的 master 密码 sentinel auth-pass mymaster 1234 # 30 秒内 ping 失败, sentinel 则认为 master 不可用 sentinel down-after-milliseconds mymaster 30000 # 在发生 failover 主备切换时,这个选项指定了最多可以有多少个 slave 同时对新的 master 进行同步 sentinel parallel-syncs mymaster 1 # 如果在该时间(ms)内未能完成 failover 操作,则认为该 failover 失败 sentinel failover-timeout mymaster 180000 ``` 其他节点只是端口不同 **7. 启动 sentinel 节点** ```lua # 方式一 redis-sentinel redis-sentinel-26379.conf; redis-sentinel redis-sentinel-26380.conf; redis-sentinel redis-sentinel-26381.conf # 方式二 redis-server redis-sentinel-26379.conf --sentinel; redis-server redis-sentinel-26380.conf --sentinel; redis-server redis-sentinel-26381.conf --sentinel; ``` ![20241229154732_zCpl5IS6.webp](https://cdn.dong4j.site/source/image/20241229154732_zCpl5IS6.webp) **8. 确认关系** ```lua redis-cli -h 127.0.0.1 -p 26379 info sentinel # Sentinel sentinel_masters:1 sentinel_tilt:0 sentinel_running_scripts:0 sentinel_scripts_queue_length:0 sentinel_simulate_failure_flags:0 master0:name=mymaster,status=ok,address=127.0.0.1:6379,slaves=2,sentinels=3 ``` ## 部署方案 1. Sentinel 部署在不同物理机上; 2. 部署至少三个且奇数个的 Sentinel 节点; ### 一套 Sentinel 监控所有主节点 ![20241229154732_iGgNGxWd.webp](https://cdn.dong4j.site/source/image/20241229154732_iGgNGxWd.webp) 优点: 1. 维护成本低 缺点: 1. 集群出现异常, 将导致服务不可用 2. 过多的网络连接 ### 每个主节点各一套 Sentinel ![20241229154732_Co7PVXld.webp](https://cdn.dong4j.site/source/image/20241229154732_Co7PVXld.webp) 优点: 1. 某个 Sentinel 集群出现故障, 不会影响其他业务 2. 网络连接少 缺点: 1. 维护成本高 > 如果监控同一个业务的多个主节点集合, 推荐使用方案一 > 如果是多个业务不同主节点集合, 推荐方案二 (推荐) ## Redis 连接数太多导致 ### 原因分析 Redis 默认最大连接数为 10000 个 1. 网络通信差, 按照 TCP 协议,客户端断开连接时,向服务器端发送 FIN 信号,但是服务端未接收到,客户端超时后放弃等待,直接断开,服务端由于通信故障,保持了 ESTABLISHED 状态; 2. 客户端异常, 客户端连接之后,由于代码运行过程中产生异常,导致未正常释放或者关闭连接; 3. client 设置不合理 (`client 数 * maxTotal` 是不能超过 redis 的最大连接数) ### 解决方案 **1. 修改 redis.config 配置** ``` # 连接的空闲实现超过 360s, 则主动关闭连接; 默认配置为 0 , 导致所有空闲 idle 连接未被释放, 服务端连接泄漏 timeout 360 # 默认关闭, 导致服务端不知客户端连接状态; 开启长连接, 服务端主动 (60s) 探测客户端 socket 状态 tcp-keeplive 60 ``` **2. 完善代码** > 客户端每次执行完 jedis 里面的方法之后必须关闭链接,释放资源 **3. redis-proxy 服务化** --- ## 重构方案 > 使用 Redis Sentinel 代替 Redis + Keepalived ### 架构 重构 redis-proxy 提供 6 种 连接模式 ![20241229154732_smjFqOxg.webp](https://cdn.dong4j.site/source/image/20241229154732_smjFqOxg.webp) ### component-redis 介绍 项目中有单独使用 Redis 的, 也有使用分片方式连接的, 也有使用 redis-proxy 组件来连接 Redis 的. 造成代码不好管理, 因此使用 component-redis 组件为其他模块提供连接 Redis 的功能, 统一管理 Redis 相关代码. 作为连接 Redis 的工具模块, 为其他模块提供操作 Redis 的功能, 具有多种模式选择. 客户端不需要再写连接 Redis 相关代码, 只需要按照要求配置即可, 减少了冗余代码; 兼容原有代码, 只需要将 redis-proxy 依赖替换成 component-redis 即可使用. #### 功能 1. 多种模式任君选择 (standalone, sentinel, shard, shard-sentinel, cluster, hybrid); 2. 使用简单, 引入 jar 即可使用; 3. 扩展方便, 如果 `RedisService` 满足不了现有业务需要, 可直接使用各种 Pool 获取 jedis, shardedJedis, jedisCluster 自由发挥; #### 使用方式 ##### 1. 引入依赖 ```xml com.xxxx.msearch component-redis 最新版本 ``` ##### 2. 配置 ```lua # jedis pool 配置 # 连接超时时间(毫秒) redis.connectionTimeout=2000 # 等待 Response 超时时间 (新增) redis.soTimeout=5000 redis.pool.maxActive=5000 redis.pool.maxWait=5000 redis.pool.maxIdle=200 redis.pool.minIdleTime=120 redis.pool.testOnBorrow=true redis.pool.testOnReturn=false # Redis 配置 redis.model=standalone redis.node=redis://127.0.0.1:6379 ``` ##### 3. 注入 service ```java @Autowired private RedisService redisService; @Test public void testRedisService() throws Exception { redisService.set("redisServiceTest", "redisServiceTest"); } ``` ### component-redis 配置规范 > 由于 component-redis 组件需要支持多种模式, 配置需要规范的格式才能避免出错. 1. 节点使用 `,` 分隔, 模式分组使用 `;` 分隔; 2. 使用某个 Redis 时, 不要把所有配置全部加上; jedisPool 配置是固定配置, 每种模式都会使用到, 只需要根据业务调整即可. ```lua ## 连接超时时间(毫秒) redis.connectionTimeout=2000 # 等待 Response 超时时间 (新增) redis.soTimeout=5000 # 连接池最大连接数(使用负值表示没有限制) redis.pool.maxActive=5000 # 连接池最大阻塞等待时间(使用负值表示没有限制) redis.pool.maxWait=5000 # 连接池中的最大空闲连接 redis.pool.maxIdle=200 # 连接池中的最小空闲连接 redis.pool.minIdleTime=120 # 当调用 borrow Object 方法时,是否进行有效性检查 redis.pool.testOnBorrow=true # 调用 return 一个对象方法时,是否检查其有效性 redis.pool.testOnReturn=false ``` ```lua redis.model= 模式名 redis.node= 业务名 #模式名://[:password@]ip:port[/database];... ``` 这里以现有业务为例子: ```lua redis.model=hybrid # callout 使用单机模式, biz 使用哨兵模式 redis.node=callout#standalone://:1234@127.0.0.1:6382;biz#sentinel://:5678@127.0.0.1:26379,sentinel://127.0.0.1:26380,sentinel://127.0.0.1:26381 ``` #### 配置优化 **使用标准的 uri 协议代替 host 和 port** 避免手动解析出错 格式如下: ```lua # 完整格式 redis://user:password@ip:port/database # 不需要用户名的格式 redis://:password@ip:port/database # 不需要密码的格式 redis://ip:port/database # 不需要 database 的配置, 将默认使用 0 db redis://ip:port ``` **密码设置** redis 的查询速度是非常快的,外部用户一秒内可以尝试多大 150K 个密码;所以密码要尽量长; 建议设置为 64 位长度密码 ##### standalone (单机) 模式配置 ```lua redis.model=standalone # password 前面的 : 不能少 redis.node=redis://:password@127.0.0.1:6382 ``` demo: ```lua redis.model=standalone redis.node=redis://127.0.0.1:6379 ``` ##### sentinel 哨兵模式是对单机模式高可用的一种实现方式, 可以实现故障主从自动切换 哨兵模式需要配置 master name, 和 `redis-sentinel`.conf 中的 `sentinel monitor masterName xxx` 保持一致 哨兵模式只能接收一个密码, 密码设置在任意节点即可 (第一个最好了) ```properties redis.model=sentinel redis.node=mymaster#redis://:1234@127.0.0.1:26379,redis://127.0.0.1:26380,redis://127.0.0.1:26381 ``` 哨兵模式就是单机模式的增强版, 需要配置多个哨兵节点 (避免造成主从切换失败, 最少 3 组哨兵), 节点之间使用 `,` 分隔 demo: ```lua redis.model=sentinel redis.node=mymaster#redis://:s4jRcLhAcUdKrNmqv9XQxwbEUZ6p4sK3kTFE4k9ts3PLahnswEzE4aPgXEQ6QdMa@127.0.0.1:26379,redis://127.0.0.1:26380,redis://127.0.0.1:26381 ``` ##### sharding (分片) 模式配置 **每组 redis 实例可以设置不同的密码** 分片实例之间使用 `,` 分隔 ```lua redis.model=sharding redis.node=redis://:1234@127.0.0.1:6382,redis://:5678@127.0.0.1:6382 ``` demo: ```lua # redis://:password@ip:port/database 没有密码则使用 redis://ip:port/database redis.model=sharding redis.node=redis://:1234@127.0.0.1:6382,redis://:s4jRcLhAcUdKrNmqv9XQxwbEUZ6p4sK3kTFE4k9ts3PLahnswEzE4aPgXEQ6QdMa@127.0.0.1:6379 ``` ##### sharding-sentinel 分片哨兵模式是对分片模式高可用的一种实现方式, 可以实现分片模式下, 故障主从自动切换 分片哨兵模式是哨兵模式和分片模式的结合, 配置可 demo: ```lua redis.model=sharding-sentinel redis.node=mymaster#redis://127.0.0.1:26379,redis://127.0.0.1:26380,redis://127.0.0.1:26381;mymaster1#redis://127.0.0.1:26379,redis://127.0.0.1:26380,redis://127.0.0.1:26381 ``` ##### cluster redis 3.x 远程集群模式 demo: ```lua redis.model=cluster redis.node=redis://127.0.0.1:6379,redis://127.0.0.1:6380,redis://127.0.0.1:6381 ``` ##### hybrid 混合模式, 为了兼容现有 Redis 环境, 一种临时的解决方案, 改造完成后, 将配置修改为 `sentinel` 即可 demo: ```lua redis.model=hybrid redis.node=callout#standalone://:1234@127.0.0.1:6382;mymaster#sentinel://:s4jRcLhAcUdKrNmqv9XQxwbEUZ6p4sK3kTFE4k9ts3PLahnswEzE4aPgXEQ6QdMa@127.0.0.1:26379,sentinel://127.0.0.1:26380,sentinel://127.0.0.1:26381 ``` 混合模式实现了 `standalone`, `sentinel`, `sharding` 模式的混合使用 ### 代码重构 1. 使用 `@Value` 实现自动配置, 代替 xml 中的 bean 标签; 2. 使用 logbok 简化代码; 3. 不在使用 RedisServiceFacotry 获取 redisService, 某个模块需要使用该组件时, import xml 即可 (多容器问题); 4. 使用 log4j2 代替 log4j; 5. 编译版本由 jdk1.6 改为 jdk1.7; 6. 使用代理类重构安全关闭 jedis 连接的方式; 重构前: ```java @Override public String set(String flag, final String key, final String value) throws Exception { return new RedisCallBack() { @Override public String doCallback(Jedis jedis) { return jedis.set(key, value); } }.callback(getJedisPoolByFlag(flag)); } ``` 重构后: ```java @Override public String set(String flag, final String key, final String value) { return RedisUtil.jedisProxy(model, flag).set(key, value); } ``` ## [Java开发者的痛:如何避免和解决常见的Jar包冲突问题](https://blog.dong4j.site/posts/a65dfb13.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Jar 包冲突是老生常谈的问题,几乎每一个 Java 程序猿都不可避免地遇到过,并且也都能想到通常的原因一般是同一个 Jar 包由于 maven 传递依赖等原因被引进了多个不同的版本而导致,可采用依赖排除、依赖管理等常规方式来尝试解决该问题,但这些方式真正能彻底解决该冲突问题吗?答案是否定的。笔者之所以将文章题目起为 “重新看待”,是因为之前对于 Jar 包冲突问题的理解仅仅停留在前面所说的那些,直到在工作中遇到的一系列 Jar 包冲突问题后,才发现并不是那么简单,对该问题有了重新的认识,接下来本文将围绕 Jar 包冲突的问题本质和相关的解决方案这两个点进行阐述。 # Jar 包冲突问题 ## 一、冲突的本质 Jar 包冲突的本质是什么?Google 了半天也没找到一个让人满意的完整定义。其实,我们可以从 Jar 包冲突产生的结果来总结,在这里给出如下定义(此处如有不妥,欢迎拍砖  -): > **Java 应用程序因某种因素,加载不到正确的类而导致其行为跟预期不一致。** 具体来说可分为两种情况:1)应用程序依赖的同一个 Jar 包出现了多个不同版本,并选择了错误的版本而导致 JVM 加载不到需要的类或加载了错误版本的类,为了叙述的方便,笔者称之为**第一类 Jar 包冲突问题**;2)同样的类(类的全限定名完全一样)出现在多个不同的依赖 Jar 包中,即该类有多个版本,并由于 Jar 包加载的先后顺序导致 JVM 加载了错误版本的类,称之为**第二类 Jar 包问题**。这两种情况所导致的结果其实是一样的,都会使应用程序加载不到正确的类,那其行为自然会跟预期不一致了,以下对这两种类型进行详细分析。 ### 1.1 同一个 Jar 包出现了多个不同版本 随着 Jar 包迭代升级,我们所依赖的开源的或公司内部的 Jar 包工具都会存在若干不同的版本,而版本升级自然就避免不了类的方法签名变更,甚至于类名的更替,而我们当前的应用程序往往依赖特定版本的某个类  **M** ,由于 maven 的传递依赖而导致同一个 Jar 包出现了多个版本,当 maven 的仲裁机制选择了错误的版本时,而恰好类  **M**  在该版本中被去掉了,或者方法签名改了,导致应用程序因找不到所需的类  **M**  或找不到类  **M**  中的特定方法,就会出现第一类 Jar 冲突问题。可总结出该类冲突问题发生的以下三个必要条件: - 由于 maven 的传递依赖导致依赖树中出现了同一个 Jar 包的多个版本 - 该 Jar 包的多个版本之间存在接口差异,如类名更替,方法签名更替等,且应用程序依赖了其中有变更的类或方法 - maven 的仲裁机制选择了错误的版本 ### 1.2 同一个类出现在多个不同 Jar 包中 同样的类出现在了应用程序所依赖的两个及以上的不同 Jar 包中,这会导致什么问题呢?我们知道,同一个类加载器对于同一个类只会加载一次(多个不同类加载器就另说了,这也是解决 Jar 包冲突的一个思路,后面会谈到),那么当一个类出现在了多个 Jar 包中,假设有  **A** 、 **B** 、 **C**  等,由于 Jar 包依赖的路径长短、声明的先后顺序或文件系统的文件加载顺序等原因,类加载器首先从 Jar 包  **A**  中加载了该类后,就不会加载其余 Jar 包中的这个类了,那么问题来了:如果应用程序此时需要的是 Jar 包  **B**  中的类版本,并且该类在 Jar 包  **A**  和  **B**  中有差异(方法不同、成员不同等等),而 JVM 却加载了 Jar 包  **A**  的中的类版本,与期望不一致,自然就会出现各种诡异的问题。 从上面的描述中,可以发现出现不同 Jar 包的冲突问题有以下三个必要条件: - 同一个类  **M**  出现在了多个依赖的 Jar 包中,为了叙述方便,假设还是两个: **A**  和  **B** - Jar 包  **A**  和  **B**  中的该类  **M**  有差异,无论是方法签名不同也好,成员变量不同也好,只要可以造成实际加载的类的行为和期望不一致都行。如果说 Jar 包  **A**  和  **B**  中的该类完全一样,那么类加载器无论先加载哪个 Jar 包,得到的都是同样版本的类  **M** ,不会有任何影响,也就不会出现 Jar 包冲突带来的诡异问题。 - 加载的类  **M**  不是所期望的版本,即加载了错误的 Jar 包 ## 二、冲突的产生原因 ### 2.1 maven 仲裁机制 当前 maven 大行其道,说到第一类 Jar 包冲突问题的产生原因,就不得不提  [maven 的依赖机制](https://link.jianshu.com/?t=https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html)了。传递性依赖是 Maven2.0 引入的新特性,让我们只需关注直接依赖的 Jar 包,对于间接依赖的 Jar 包,Maven 会通过解析从远程仓库获取的依赖包的 pom 文件来隐式地将其引入,这为我们开发带来了极大的便利,但与此同时,也带来了常见的问题——版本冲突,即同一个 Jar 包出现了多个不同的版本,针对该问题 Maven 也有一套仲裁机制来决定最终选用哪个版本,但** Maven 的选择往往不一定是我们所期望的**,这也是产生 Jar 包冲突最常见的原因之一。先来看下 Maven 的仲裁机制: - 优先按照依赖管理元素中指定的版本声明进行仲裁,此时下面的两个原则都无效了 - 若无版本声明,则按照 “短路径优先” 的原则(Maven2.0)进行仲裁,即选择依赖树中路径最短的版本 - 若路径长度一致,则按照 “第一声明优先” 的原则进行仲裁,即选择 POM 中最先声明的版本 从 maven 的仲裁机制中可以发现,除了第一条仲裁规则(这也是解决 Jar 包冲突的常用手段之一)外,后面的两条原则,对于同一个 Jar 包不同版本的选择,maven 的选择有点 “一厢情愿” 了,也许这是 maven 研发团队在总结了大量的项目依赖管理经验后得出的两条结论,又或者是发现根本找不到一种统一的方式来满足所有场景之后的无奈之举,可能这对于多数场景是适用的,但是**它不一定适合我——当前的应用**,因为每个应用都有其特殊性,该依赖哪个版本,maven 没办法帮你完全搞定,如果你没有规规矩矩地使用来进行依赖管理,就注定了逃脱不了第一类 Jar 包冲突问题。 ### 2.1 Jar 包的加载顺序 对于第二类 Jar 包冲突问题,即多个不同的 Jar 包有类冲突,这相对于第一类问题就显得更为棘手。为什么这么说呢?在这种情况下,两个不同的 Jar 包,假设为  **A**、 **B**,它们的名称互不相同,甚至可能完全不沾边,如果不是出现冲突问题,你可能都不会发现它们有共有的类!对于 A、B 这两个 Jar 包,maven 就显得无能为力了,因为 maven 只会为你针对同一个 Jar 包的不同版本进行仲裁,而这俩是属于不同的 Jar 包,超出了 maven 的依赖管理范畴。此时,当 A、B 都出现在应用程序的类路径下时,就会存在潜在的冲突风险,即 A、B 的加载先后顺序就决定着 JVM 最终选择的类版本,如果选错了,就会出现诡异的第二类冲突问题。 那么 Jar 包的加载顺序都由哪些因素决定的呢?具体如下: - Jar 包所处的加载路径,或者换个说法就是加载该 Jar 包的类加载器在 JVM 类加载器树结构中所处层级。由于 JVM 类加载的双亲委派机制,层级越高的类加载器越先加载其加载路径下的类,顾名思义,引导类加载器(bootstrap ClassLoader,也叫启动类加载器)是最先加载其路径下 Jar 包的,其次是扩展类加载器(extension ClassLoader),再次是系统类加载器(system ClassLoader,也就是应用加载器 appClassLoader),Jar 包所处加载路径的不同,就决定了它的加载顺序的不同。比如我们在 eclipse 中配置 web 应用的 resin 环境时,对于依赖的 Jar 包是添加到`Bootstrap Entries`中还是`User Entries`中呢,则需要仔细斟酌下咯。 - 文件系统的文件加载顺序。这个因素很容易被忽略,而往往又是因环境不一致而导致各种诡异冲突问题的罪魁祸首。因 tomcat、resin 等容器的 ClassLoader 获取加载路径下的文件列表时是不排序的,这就依赖于底层文件系统返回的顺序,那么当不同环境之间的文件系统不一致时,就会出现有的环境没问题,有的环境出现冲突。例如,对于 Linux 操作系统,返回顺序则是由 iNode 的顺序来决定的,如果说测试环境的 Linux 系统与线上环境不一致时,就极有可能出现典型案例:测试环境怎么测都没问题,但一上线就出现冲突问题,规避这种问题的最佳办法就是尽量保证测试环境与线上一致。 ## 三、冲突的表象 Jar 包冲突可能会导致哪些问题?通常发生在编译或运行时,主要分为两类问题:一类是比较直观的也是最为常见的错误是抛出各种运行时异常,还有一类就是比较隐晦的问题,它不会报错,其表现形式是应用程序的行为跟预期不一致,分条罗列如下: - **java.lang.ClassNotFoundException**,即 java 类找不到。这类典型异常通常是由于,没有在依赖管理中声明版本,maven 的仲裁的时候选取了错误的版本,而这个版本缺少我们需要的某个 class 而导致该错误。例如 httpclient-4.4.jar 升级到 httpclient-4.36.jar 时,类 org.apache.http.conn.ssl.NoopHostnameVerifier 被去掉了,如果此时我们本来需要的是 4.4 版本,且用到了 NoopHostnameVerifier 这个类,而 maven 仲裁时选择了 4.6,则会导致 ClassNotFoundException 异常。 - **java.lang.NoSuchMethodError**,即找不到特定方法,第一类冲突和第二类冲突都可能导致该问题——加载的类不正确。若是第一类冲突,则是由于错误版本的 Jar 包与所需要版本的 Jar 包中的类接口不一致导致,例如 antlr-2.7.2.jar 升级到 antlr-2.7.6.Jar 时,接口 antlr.collections.AST.getLine() 发生变动,当 maven 仲裁选择了错误版本而加载了错误版本的类 AST,则会导致该异常;若是第二类冲突,则是由于不同 Jar 包含有的同名类接口不一致导致,**典型的案例**:Apache 的 commons-lang 包,2.x 升级到 3.x 时,包名直接从 commons-lang 改为 commons-lang3,部分接口也有所改动,由于包名不同和传递性依赖,经常会出现两种 Jar 包同时在 classpath 下,org.apache.commons.lang.StringUtils.isBlank 就是其中有差异的接口之一,由于 Jar 包的加载顺序,导致加载了错误版本的 StringUtils 类,就可能出现 NoSuchMethodError 异常。 - **java.lang.NoClassDefFoundError**,**java.lang.LinkageError**  等,原因和上述雷同,就不作具体案例分析了。 - **没有报错异常,但应用的行为跟预期不一致**。这类问题同样也是由于运行时加载了错误版本的类导致,但跟前面不同的是,冲突的类接口都是一致的,但具体实现逻辑有差异,当我们加载的类版本不是我们需要的实现逻辑,就会出现行为跟预期不一致问题。这类问题通常发生在我们自己内部实现的多个 Jar 包中,由于包路径和类名命名不规范等问题,导致两个不同的 Jar 包出现了接口一致但实现逻辑又各不相同的同名类,从而引发此问题。 # 解决方案 ## 一、问题排查和解决 1. 如果有异常堆栈信息,根据错误信息即可定位导致冲突的类名,然后在 eclipse 中`CTRL+SHIFT+T`或者在 idea 中`CTRL+N`就可发现该类存在于多个依赖 Jar 包中 2. 若步骤 1 无法定位冲突的类来自哪个 Jar 包,可在应用程序启动时加上 JVM 参数`-verbose:class`或者`-XX:+TraceClassLoading`,日志里会打印出每个类的加载信息,如来自哪个 Jar 包 3. 定位了冲突类的 Jar 包之后,通过`mvn dependency:tree -Dverbose -Dincludes=:`查看是哪些地方引入的 Jar 包的这个版本 4. 确定 Jar 包来源之后,如果是第一类 Jar 包冲突,则可用 **排除不需要的 Jar 包版本或者在依赖管理**  中申明版本;若是第二类 Jar 包冲突,如果可排除,则用排掉不需要的那个 Jar 包,若不能排,则需考虑 Jar 包的升级或换个别的 Jar 包。当然,除了这些方法,还可以从类加载器的角度来解决该问题,可参考博文——[如果 jar 包冲突不可避免,如何实现 jar 包隔离](https://www.shop988.com/blog/%E5%A6%82%E4%BD%95%E5%AE%9E%E7%8E%B0jar%E5%8C%85%E9%9A%94%E7%A6%BB.html),其思路值得借鉴。 ## 二、有效避免 从上一节的解决方案可以发现,当出现第二类 Jar 包冲突,且冲突的 Jar 包又无法排除时,问题变得相当棘手,这时候要处理该冲突问题就需要较大成本了,所以,最好的方式是**在冲突发生之前能有效地规避之**!就好比数据库死锁问题,死锁避免和死锁预防就显得相当重要,若是等到真正发生死锁了,常规的做法也只能是回滚并重启部分事务,这就捉襟见肘了。那么怎样才能有效地规避 Jar 包冲突呢? ### 2.1 良好的习惯:依赖管理 对于第一类 Jar 包冲突问题,通常的做法是用排除不需要的版本,但这种做法带来的问题是每次引入带有传递性依赖的 Jar 包时,都需要一一进行排除,非常麻烦。maven 为此提供了集中管理依赖信息的机制,即依赖管理元素,对依赖 Jar 包进行统一版本管理,一劳永逸。通常的做法是,在 parent 模块的 pom 文件中尽可能地声明所有相关依赖 Jar 包的版本,并在子 pom 中简单引用该构件即可。 来看个示例,当开发时确定使用的 httpclient 版本为 4.5.1 时,可在父 pom 中配置如下: ``` 4.5.1 org.apache.httpcomponents httpclient ${httpclient.version} ``` 然后各个需要依赖该 Jar 包的子 pom 中配置如下依赖: ```xml dependencies> org.apache.httpcomponents httpclient ``` ### 2.2 冲突检测插件 对于第二类 Jar 包冲突问题,前面也提到过,其核心在于同名类出现在了多个不同的 Jar 包中,如果人工来排查该问题,则需要逐个点开每个 Jar 包,然后相互对比看有没同名的类,那得多么浪费精力啊?!好在这种费时费力的体力活能交给程序去干。**maven-enforcer-plugin**,这个强大的 maven 插件,配合** extra-enforcer-rules**  工具,能自动扫描 Jar 包将冲突检测并打印出来,汗颜的是,笔者工作之前居然都没听过有这样一个插件的存在,也许是没遇到像工作中这样的冲突问题,算是涨姿势了。其原理其实也比较简单,通过扫描 Jar 包中的 class,记录每个 class 对应的 Jar 包列表,如果有多个即是冲突了,故不必深究,我们只需要关注如何用它即可。 在**最终需要打包运行的应用模块 pom**  中,引入 maven-enforcer-plugin 的依赖,在 build 阶段即可发现问题,并解决它。比如对于具有 parent pom 的多模块项目,需要将插件依赖声明在应用模块的 pom 中。这里有童鞋可能会疑问,为什么不把插件依赖声明在 parent pom 中呢?那样依赖它的应用子模块岂不是都能复用了?这里之所以强调 “打包运行的应用模块 pom”,是因为冲突检测针对的是最终集成的应用,关注的是应用运行时是否会出现冲突问题,而每个不同的应用模块,各自依赖的 Jar 包集合是不同的,由此而产生的列表也是有差异的,因此只能针对应用模块 pom 分别引入该插件。 先看示例用法如下: ```xml org.apache.maven.plugins maven-enforcer-plugin 1.4.1 enforce enforce enforce-ban-duplicate-classes enforce javax.* org.junit.* net.sf.cglib.* org.apache.commons.logging.* org.springframework.remoting.rmi.RmiInvocationHandler true true org.codehaus.mojo extra-enforcer-rules 1.0-beta-6 ``` maven-enforcer-plugin 是通过很多预定义的标准规则([standard rules](https://link.jianshu.com/?t=http://maven.apache.org/enforcer/enforcer-rules/index.html))和用户自定义规则,来约束 maven 的环境因素,如 maven 版本、JDK 版本等等,它有很多好用的特性,具体可参见[官网](https://link.jianshu.com/?t=http://maven.apache.org/enforcer/maven-enforcer-plugin/)。而 Extra Enforcer Rules 则是* MojoHaus*  项目下的针对 maven-enforcer-plugin 而开发的提供额外规则的插件,这其中就包含前面所提的重复类检测功能,具体用法可参见[官网](https://link.jianshu.com/?t=http://www.mojohaus.org/extra-enforcer-rules/),这里就不详细叙述了。 # 典型案例 ## 第一类 Jar 包冲突 这类 Jar 包冲突是最常见的也是相对比较好解决的,已经在[三、冲突的表象](https://www.jianshu.com/p/100439269148#%E4%B8%89%E3%80%81%E5%86%B2%E7%AA%81%E7%9A%84%E8%A1%A8%E8%B1%A1)这节中列举了部分案例,这里就不重复列举了。 ## 第二类 Jar 包冲突 ### Spring2.5.6 与 Spring3.x Spring2.5.6 与 Spring3.x,从单模块拆分为多模块,Jar 包名称(artifactId)也从 spring 变为 spring-submoduleName,如 spring-context、spring-aop 等等,并且也有少部分接口改动(Jar 包升级的过程中,这也是在所难免的)。由于是不同的 Jar 包,经 maven 的传递依赖机制,就会经常性的存在这俩版本的 Spring 都在 classpath 中,从而引发潜在的冲突问题。 ## [Java代码审查:常见错误及改进指南](https://blog.dong4j.site/posts/c43d4ea4.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 整理一下项目中不好的代码写法 ![20241229154732_ZA3tXTde.webp](https://cdn.dong4j.site/source/image/20241229154732_ZA3tXTde.webp) 以下是一些具有代表性的问题, 都是一些一看就明白的问题, 还有一些代码的坑, 慢慢填吧. 只针对代码, 不针对谁, 如果写的不对的对方, 你咬我啊 ## 代码问题 ### 还失败重试? 失败重试个啥? 直接返回了 老铁!! 代码 1 后面, 获取了 batchResult, 不应该重新赋值 code 嘛? 代码 2 为修改后 ![20241229154732_GNL6YDkH.webp](https://cdn.dong4j.site/source/image/20241229154732_GNL6YDkH.webp) ### Intellij idea 是个好东西 ![20241229154732_l9cqCugh.webp](https://cdn.dong4j.site/source/image/20241229154732_l9cqCugh.webp) ![20241229154732_mOz94KIb.webp](https://cdn.dong4j.site/source/image/20241229154732_mOz94KIb.webp) 修改为: ![20241229154732_R03cnTDt.webp](https://cdn.dong4j.site/source/image/20241229154732_R03cnTDt.webp) catch 里面使用 printStackTrace(), 错误日志全部输出到 `catalina.out`, 你考虑过 catalina 的感受吗? **日志问题后面说** ### 这是先斩后奏吗? 前面都调用了 list 的 size 方法, 后面再来判断 list 是否为 null? 这种代码我看见起码不下 10 处, 系统能稳定吗老铁? ![20241229154732_poqachWZ.webp](https://cdn.dong4j.site/source/image/20241229154732_poqachWZ.webp) idea 都知道的问题, 你不应该不知道 ![20241229154732_pk5YyTrv.webp](https://cdn.dong4j.site/source/image/20241229154732_pk5YyTrv.webp) logger.info 输出问题: 用 `log.info("{}", xxxx)`, 不要自己拼接字符串 ### 老铁, 你可长点心吧 ![20241229154732_mQUneZBa.webp](https://cdn.dong4j.site/source/image/20241229154732_mQUneZBa.webp) ### 老铁, 我就服你 ![20241229154732_CjuLa8Xz.webp](https://cdn.dong4j.site/source/image/20241229154732_CjuLa8Xz.webp) google : logger.error() 正确使用姿势 ![20241229154732_EXMYUxyi.webp](https://cdn.dong4j.site/source/image/20241229154732_EXMYUxyi.webp) ### JDK7 之后的变化 JDK7 之 钻石语法 ![20241229154732_2D69JJJC.webp](https://cdn.dong4j.site/source/image/20241229154732_2D69JJJC.webp) ![20241229154732_IgcfD8YG.webp](https://cdn.dong4j.site/source/image/20241229154732_IgcfD8YG.webp) ### 画蛇添足 value 就是 String 类型了, 写 toString() 是为了练打字吗? ![20241229154732_sSB4f0pj.webp](https://cdn.dong4j.site/source/image/20241229154732_sSB4f0pj.webp) ### 强迫症可能要急死 看见黄色警告了吗? 知道怎么改吗? ![20241229154732_2EOmbK8I.webp](https://cdn.dong4j.site/source/image/20241229154732_2EOmbK8I.webp) ![20241229154732_Yl0GTxks.webp](https://cdn.dong4j.site/source/image/20241229154732_Yl0GTxks.webp) ### 你就告诉我需要多宽的显示器? 老铁, 公司没给配这么宽的显示器啊... 啊, 27 寸的也看不过来啊 **超过 120 列宽必须需要换行** ![20241229154732_Mi5SqZ8G.webp](https://cdn.dong4j.site/source/image/20241229154732_Mi5SqZ8G.webp) ![20241229154732_d4IxIPHI.webp](https://cdn.dong4j.site/source/image/20241229154732_d4IxIPHI.webp) **超过 5 个参数, 推荐使用实体类** ### 你还是去写 python 吧 ![20241229154732_D7RhYdaO.webp](https://cdn.dong4j.site/source/image/20241229154732_D7RhYdaO.webp) ### 知道什么叫 util 吗? ![20241229154732_Q99tRBQJ.webp](https://cdn.dong4j.site/source/image/20241229154732_Q99tRBQJ.webp) ### Intellij IDEA 都知道会有空指针, 你还这么写? ![20241229154732_uckFF7xE.webp](https://cdn.dong4j.site/source/image/20241229154732_uckFF7xE.webp) ### 不能直接 return 吗? 练打字吗? ![20241229154732_s6WPNs6L.webp](https://cdn.dong4j.site/source/image/20241229154732_s6WPNs6L.webp) ### 面试题之 String, StringBuilder, StringBuffer > JDK 5 以后 JVM 对字符串循环拼接的处理方式 ![20241229154732_RDocgeHo.webp](https://cdn.dong4j.site/source/image/20241229154732_RDocgeHo.webp) ### 老铁, 类注释, 方法注释呢 **类注释呢?** **方法注释虽然有, 但是不标准啊, 老铁** 没看见那么多黄色警告吗? ![20241229154732_nuQzSQA9.webp](https://cdn.dong4j.site/source/image/20241229154732_nuQzSQA9.webp) **代码规范我们后面说** ![20241229154732_9wGwkuIH.webp](https://cdn.dong4j.site/source/image/20241229154732_9wGwkuIH.webp) 每个模块都有一个 StringUtil, 还有叫 StringUtils 的 老铁, 写之前先看看能不能复用啊, 或者复制之前, 看是不是已经有了啊. ### 你以为把 DDL 语句拷贝过来就不用写字段注释了吗? 老铁, 你这样骚操作我很为难啊 ![20241229154732_1R7jMR50.webp](https://cdn.dong4j.site/source/image/20241229154732_1R7jMR50.webp) 在类上按 F1 看不到类注释啊 ![20241229154732_C6AqFxhb.webp](https://cdn.dong4j.site/source/image/20241229154732_C6AqFxhb.webp) 这样改啊 ![20241229154732_524c0JBV.webp](https://cdn.dong4j.site/source/image/20241229154732_524c0JBV.webp) F1 直接看类注释啊, 不用跳转了啊 ![20241229154732_H2KhddkH.webp](https://cdn.dong4j.site/source/image/20241229154732_H2KhddkH.webp) F1 直接看字段注释啊, 不用再去查 DDL 了啊, 不会在蒙圈了啊 ![20241229154732_guzkIspg.webp](https://cdn.dong4j.site/source/image/20241229154732_guzkIspg.webp) ### 老铁, 不是中文看不懂啊 额, 这个要怪 idea 了, 居然没有默认转换 ![20241229154732_QtY0zZE5.webp](https://cdn.dong4j.site/source/image/20241229154732_QtY0zZE5.webp) ![20241229154732_mByeIzng.webp](https://cdn.dong4j.site/source/image/20241229154732_mByeIzng.webp) 老铁, 把 transpartent 打开, 你就认识中文了 ![20241229154732_IvrhpyQY.webp](https://cdn.dong4j.site/source/image/20241229154732_IvrhpyQY.webp) 老铁, 看见黄色警告了? 如果是自己解析配置, 没有处理空白符的话, 又出 bug 了啊.. 啊. ### 老铁, 代码用 UTF-8 啊, 不然要乱码啊 ![20241229154732_iqEm4YvC.webp](https://cdn.dong4j.site/source/image/20241229154732_iqEm4YvC.webp) 全都要 UTF-8 啊, 要跟国际接轨啊 ![20241229154732_IY0UEuKK.webp](https://cdn.dong4j.site/source/image/20241229154732_IY0UEuKK.webp) ### 老铁, 0 是啥, 1 是啥, 2 又是啥啊? 脑壳都大了啊... 定义个常量啊, 常量名用拼音也比没有好啊, 老铁 ![20241229154732_5g7sLkNB.webp](https://cdn.dong4j.site/source/image/20241229154732_5g7sLkNB.webp) ![20241229154732_isLnyliN.webp](https://cdn.dong4j.site/source/image/20241229154732_isLnyliN.webp) ### 论 MVC 架构的职责 dao 就是对表的操作, 一个 dao 对应一张表; service 组合多个 dao 进行业务处理; controller 做参数检查, 结果封装, 跳转页面; ![20241229154732_MUIpQN0h.webp](https://cdn.dong4j.site/source/image/20241229154732_MUIpQN0h.webp) ### 你咋不把所有的 sql 都写在一个 xml 里面呢? ![20241229154732_e35X2mlx.webp](https://cdn.dong4j.site/source/image/20241229154732_e35X2mlx.webp) ### 这个也要注入? 也能注入? 😅😂🤣 ![20241229154732_CpUOdFER.webp](https://cdn.dong4j.site/source/image/20241229154732_CpUOdFER.webp) ### 多余的 finally > redis-proxy 已经对 jedis 资源的安全释放做了处理, 不用自己在写这些冗余的代码 ![20241229154732_0ggLpsRN.webp](https://cdn.dong4j.site/source/image/20241229154732_0ggLpsRN.webp) ### catch 里面不要做流程控制, OK? ![20241229154732_mUgmSDPp.webp](https://cdn.dong4j.site/source/image/20241229154732_mUgmSDPp.webp) 改为 ![20241229154732_KxMkMlP9.webp](https://cdn.dong4j.site/source/image/20241229154732_KxMkMlP9.webp) ### log 输出错误 > 日志的正确使用姿势, 你值得了解一下 推荐去搜一下 log 的正确输出方式. ![20241229154732_HE2fZKMw.webp](https://cdn.dong4j.site/source/image/20241229154732_HE2fZKMw.webp) ```java log.error("访问 redis 异常", e); ``` ### 做人能不能真诚一点, 写代码能不能简单一点 ![20241229154732_VwXgkaNB.webp](https://cdn.dong4j.site/source/image/20241229154732_VwXgkaNB.webp) 改为: ```java IavpResponse iavpResponse = HttpUtil.sendPost(inputParams, "gatherkey", Integer.parseInt(timeOut)); if(iavpResponse.getStatusCode() == HttpStatus.SC_OK){ return XmlConverUtil.readGatherKeyXmlOut(iavpResponse.getContent()); } ``` XmlConverUtil.java ```java public static List readGatherKeyXmlOut(String xml) { if(StringUtils.isBlank(xml)){ return null; } ... } ``` ### isNotEmpty 和 isNotBlank 的区别知道吗? ![20241229154732_3A8gDJsY.webp](https://cdn.dong4j.site/source/image/20241229154732_3A8gDJsY.webp) 改为: ![20241229154732_Aub2kvRm.webp](https://cdn.dong4j.site/source/image/20241229154732_Aub2kvRm.webp) ### 3 行代码搞定的事, 非要写几十行, 练打字吗? **原始代码** 用于检查是否是会员 ```java public boolean judgeMiguSuperVIP(String caller) { boolean VIPReturn = false; // isMiguGameMothMember 表示游戏会员状态,1 表示是包月会员,0 表示不是包月会员 String isMiguGameMothMember = "0"; try { GameAccount gameAccount = miguGameProvider.queryUserInfo(caller); if (gameAccount != null) { isMiguGameMothMember = gameAccount.getMiguSupperMember(); } else { isMiguGameMothMember = "0"; } if ("1".equals(isMiguGameMothMember)) { // 是咪咕超级会员 VIPReturn = true; return VIPReturn; } else { // 不是咪咕超级会员 VIPReturn = false; return VIPReturn; } } catch (Exception e) { // 查询游戏账号状态异常 VIPReturn = false; return VIPReturn; } } ``` **重构 1** 删除 boolean VIPReturn ```java public boolean judgeMiguSuperVIP(String caller, String type) { // isMiguGameMothMember 表示游戏会员状态,1 表示是包月会员,0 表示不是包月会员 String isMiguGameMothMember = "0"; try { GameAccount gameAccount = miguGameProvider.queryUserInfo(caller, type); if (gameAccount != null) { isMiguGameMothMember = gameAccount.getMiguSupperMember(); } else { isMiguGameMothMember = "0"; } return "1".equals(isMiguGameMothMember); } catch (Exception e) { return false; } } ``` **重构 2** 删除 isMiguGameMothMember ```java public boolean judgeMiguSuperVIP(String caller, String type) { // isMiguGameMothMember 表示游戏会员状态,1 表示是包月会员,0 表示不是包月会员 try { GameAccount gameAccount = miguGameProvider.queryUserInfo(caller, type); return gameAccount != null && "1".equals(gameAccount.getMiguSupperMember()); } catch (Exception e) { return false; } } ``` **重构 3** queryUserInfo 已经处理的下层抛出的异常, 这里不需要再处理 ```java public boolean judgeMiguSuperVIP(String caller, String type) { // isMiguGameMothMember 表示游戏会员状态,1 表示是包月会员,0 表示不是包月会员 GameAccount gameAccount = miguGameProvider.queryUserInfo(caller, type); return gameAccount != null && "1".equals(gameAccount.getMiguSupperMember()); } ``` ## 日志问题 ![20241229154732_TUqT6zHK.webp](https://cdn.dong4j.site/source/image/20241229154732_TUqT6zHK.webp) ### 老铁, 日志输出到文件要用 UTF-8 啊 不然乱码看不懂啊 ![20241229154732_7KCouGBj.webp](https://cdn.dong4j.site/source/image/20241229154732_7KCouGBj.webp) ### 老铁, 日志输出能不能统一格式啊? ### 老铁, 日志输出能不能分级别啊? ### 老铁, 日志框架能不能统一使用一个啊? 一会 log4j, 一会 log4j2 的 做人喜新厌旧可以 (log4j2 更新, 效率更好) 但也要专一, 说好放学别走就不能走, 要跑... 说好用 log4j2 + slf4j, 就不要用 System.out.println() OK? ## Maven 问题 ### 老铁, 一个模块这么多版本啊, 怎么管理啊 maven 用来管理项目中的依赖关系的, 这个没使用 maven 有什么区别? ![20241229154732_1JS1jb0y.webp](https://cdn.dong4j.site/source/image/20241229154732_1JS1jb0y.webp) ### 拷贝依赖的时候看没看是不是存在了? ![20241229154732_uvcka7Fo.webp](https://cdn.dong4j.site/source/image/20241229154732_uvcka7Fo.webp) ### 老铁, 不要只晓得拷贝依赖, 不看看依赖冲突啊 ![20241229154732_T2zcOs2y.webp](https://cdn.dong4j.site/source/image/20241229154732_T2zcOs2y.webp) ## 项目结构问题 ### 1000 个人眼里, 有 1000 个哈姆雷特, 1000 个人写代码, 就有 1000 种代码风格 ![20241229154732_Mz06nD0Q.webp](https://cdn.dong4j.site/source/image/20241229154732_Mz06nD0Q.webp) ## 下一篇 musearch-project 介绍 已经重构的模块 ![20241229154732_6WMOlgqE.webp](https://cdn.dong4j.site/source/image/20241229154732_6WMOlgqE.webp) ```lua . ├── musicsearch-business # 业务主模块 │ ├── business-common # 业务公共类库 │ ├── mservice-migu-game # migu-game 业务 │ └── service-meeting # meeting 服务模块 ├── musicsearch-common # musicsearch 项目 公共模块 ├── musicsearch-component # 组件主模块 │ ├── component-iavp # iavp 模块, 封装 iavp 相关实体和接口, 直接注入即可 │ ├── component-mybatis # mybatis 模块, 提供代码生成和 mybatis 相关功能 │ ├── component-redis # redis 模块, 注入 RedisService 即可, 提供多种模式 │ └── component-websocket # websocket 模块 netty-socket.io 封装 ├── musicsearch-demo # demo 主模块 │ ├── component-mybatis-demo │ └── component-redis-demo ├── musicsearch-dependencies-bom # 管理第三方 jar 版本和依赖关系 ├── musicsearch-monitor # 暂定 ├── musicsearch-parent # musicsearch 工程主模块, 管理整个功能的版本及依赖 │ └── docs # 放工程相关文档 └── musicsearch-support # 支撑模块主模块 ├── musicsearch-code-generator ├── musicsearch-management-system └── musicsearch-timer-task ``` ## [打造高效团队:统一编码风格的重要性](https://blog.dong4j.site/posts/a29c1a98.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 项目重构整理 ## 废话 此篇是 12530 架构重构第二篇, 以 [阿里巴巴开发手册](https://github.com/alibaba/p3c/) 为基础, 结合自己工作经验, 作为 `musicsearch-project` 重构方案的基础部分. 以此为约束, 希望构建一个 `稳定`, `易于维护`, `可扩展` 的重构方案. 一个项目最怕多种编码风格, 实体名一会 entity, 一会 model, 让维护的人身心疲惫, 因此在一个项目中保持唯一一种编码习惯, 有利于代码维护 (比如通过命名, 就知道作用以及所在的包名). **见名知意, 此乃命名的最高境界 (体会一下 `取名 10 分钟, 编码 1 分钟` 的境界 😂).** > 代码规范看起来比较枯燥, 看完一遍可能只有一点点印象, 因此我采用比较逗比的方式, 尽量让大家一遍就记住. > 代码规范比较偏向个人主义, 每个人的编码习惯都不一样, 所以希望多提出自己的建议, 一起改进 (没有最好的规范, 只有最合适的. 🙃) ## Let's go ### 开篇 话说盘古开天辟地之时... 亚当和夏娃诞生混沌之间, 他们从小青梅竹马, 一个会唱 200 首歌, 一个会跳 200 支舞, 后人称他们一个为 二百歌, 一个为 二百舞 ..... (用心去体会 😂) ### 这个才是开篇 江湖四分五裂, 一人一江湖... 但我们是一个团队,为了便于管理和维护,急需一份代码规范, 来约束我们的编码规则和习惯, 就像修炼一门武林绝学, 需要秘籍的指引 (葵花宝典?)。 请各位英雄好汉仔细阅读 严格遵守 如有不足之处 希望提出意见 共商武林统一霸业 从 1000 个哈姆雷特转变为同一个 **林志玲**, 代码风格保持统一有利于提高工作效率, 便于管理. 比如: 1. 一看到以 Controller 结尾的类, 就应该知道这个是接口, 用于参数验证, 调用业务类进行处理业务逻辑, 组装结果, 返回数据或者跳转页面; 2. 一看到以 Service 结尾的类, 就知道这个是业务接口类, 用于定义业务接口; 3. 一看到以 Impl 结尾的类, 就应该知道这个是业务实现类, 组合不同的 Dao, 实现业务逻辑; 4. 一看到以 Dao 结尾的类, 就应该知道这个是 DB 操作类, 对数据进行 CRUD 操作; 5. 一看到以 Dto 结尾的类, 就应该知道这个是数据传输对象, 用于展示层和服务层之间的数据传输; 6. .... ## 约定 为了避免歧义, 文档大量使用以下词汇, 解释如下: 1. `必须` (must): 绝对,严格遵循,请照做,无条件遵守; 2. `一定不可` (must not): 禁令,严令禁止; 3. `应该` (should): 强烈建议这样做,但是不强求; 4. `不该` (should not): 强烈不建议这样做,但是不强求; 5. `可以` (may) 和 可选 (optional): 选择性高一点,在这个文档内,此词语使用较少; 6. `推荐` (recommend): 个人推荐的做法, 不强求; ## 准备工作 > 行走江湖, 没有一件趁手的兵器怎么能在江湖中立足, 还在用 `eclipse` 的少侠们, 希望弃暗投明, 拥抱 Intellij IDEA, 你会发现编码效率提升不是 > **点巴点儿**, 而是 **蹭蹭蹭** 的往上涨 😁. **我不是富二代, 我没有赢在人生的起跑线上, 但是我用 Intellij IDEA, 我赢在了工作的起跑线上!** 推荐一个比较全面的 [Intellij IDEA 教程](https://github.com/judasn/IntelliJ-IDEA-Tutorial) **工欲善其事, 必先利其器. 一件趁手的兵器带你迎娶白富美, 走上人生巅峰, 统一江湖, 指日可待** ![20241229154732_uHJneu7y.webp](https://cdn.dong4j.site_uHJneu7y.webp) 为了简化手动操作和提高编码效率, 要求安装几个必要的插件, 以及对 IDEA 进行必要的优化配置 ### Intellij IDEA 插件 #### Alibaba Java Coding Guidelines > `必须` 安装, 代码规范检查的基础 **作用: 代码规则检查** 此插件是 阿里巴巴根据 [阿里巴巴开发手册](https://github.com/alibaba/p3c/) 开发的一个静态代码规范检查插件, 每个警告都提供了一个 demo, ![20241229154732_Hs7lxRAM.webp](https://cdn.dong4j.site_Hs7lxRAM.webp) 此篇以 [阿里巴巴开发手册](https://github.com/alibaba/p3c/) 为基础, 但是并不会一一罗列每条规范, 因为此插件已经能很好的检查了. **此篇重点在于这个插件不能检查的规范.** #### lombok > `必须` 安装, 不然代码跑不起来就尴尬了 **作用: 化繁为简. [官网](<[http://projectlombok.org/](http://projectlombok.org/)>)** 使用 `@Data` 代替烦人的 get/set 方法 ![20241229154732_0RVNjOoe.webp](https://cdn.dong4j.site_0RVNjOoe.webp) 使用 `@Slf4j` 代替获取 log 实例的代码 ```java private static final Logger log = LoggerFactory.getLogger(ApplicationTest.class); ``` ![20241229154732_FeOsPGD7.webp](https://cdn.dong4j.site_FeOsPGD7.webp) **使用方式**: 1. 在 pom 文件中添加: ```xml org.projectlombok lombok ${lombok.version} ``` 2. 在 IDEA 中添加插件 `lombok` (file->setting->plugins) 3. IDEA 设置 ![20241229154732_izw0KAnS.webp](https://cdn.dong4j.site_izw0KAnS.webp) #### Maven Helper > `必须` 安装, 不然依赖冲突了就不好了 **作用: 检查依赖冲突** 新加入一个 jar 包, 谁添加谁负责, 使用此插件检查是否有依赖冲突. 依赖冲突可能会导致: 1. java.lang.ClassNotFoundException 2. java.lang.NoSuchMethodError 3. java.lang.NoClassDefFoundError 4. 开发环境正常, 测试或者生产环境不正常.... ![20241229154732_TxMWYfwd.webp](https://cdn.dong4j.site_TxMWYfwd.webp) #### JavaDoc > `必须` 安装 **作用: 快速生成标准的 javadoc** ![1111.gif](https://cdn.dong4j.site #### JRebel > `推荐` 安装, 提高工作效率的插件. **作用: 代码热部署插件, 改了代码后, 重新编译, 不用重启应用就可查看效果.** 热部署插件, 谁用谁知道; [科学使用方法](http://blog.lanyus.com/search/JRebel/) (低调点) #### Mybatis Plugin > `推荐` 安装, 提高工作效率的插件. **作用: xml 和 dao 快速跳转; 快速生成 xml; xml 检查** ![222.gif](https://cdn.dong4j.site #### GenerateSerialVersionUID > `推荐` 安装, 提高工作效率的插件 **作用: 为实现了 Serializable 接口的实体快速添加 serialVersionUID, 提高效率** 因为要求实体 `必须` 实现 `Serializable` 接口, 而且 `必须` 添加 `serialVersionUID` 字段. ![333.gif](https://cdn.dong4j.site #### GenerateAllSetter > `推荐` 安装, 提高工作效率的插件 **作用: 快速生成 set 方法** ![444.gif](https://cdn.dong4j.site #### Translation > `推荐` 安装, 提高工作效率的插件 **作用: 翻译插件, 提供 百度, 有道, Google 翻译** 一款为像我这种英语渣的码农量身定做的插件 😂 ![555.gif](https://cdn.dong4j.site #### Grep Console > `推荐` 安装 **作用: 高亮显示 log 不同级别日志,看日志的时候一目了然; 具有 error 级别声音提醒功能 (可设置)** 效果: ![20241229154732_NW1Z8dTj.webp](https://cdn.dong4j.site_NW1Z8dTj.webp) 插件设置: ![20241229154732_CExDSzg1.webp](https://cdn.dong4j.site_CExDSzg1.webp) 此插件通过 log 输出中的 info/debug/warn/error 来匹配对应的颜色. 因此 log 输出中必须包含 **日志级别**, 这个 **日志规范** 中再说. #### RestfulToolkit > `推荐` 安装 **作用: 快速定位接口, Rest 请求模拟 ** ![666.gif](https://cdn.dong4j.site #### Restore Sql for iBatis/Mybatis > `推荐` 安装 **作用: 查看请求 sql, 可直接运行 ** 效果: `meeting-service` 调用 `/login` 接口后需要执行的 sql ![20241229154732_dUkaaD0g.webp](https://cdn.dong4j.site_dUkaaD0g.webp) ### Intellij IDEA 设置 #### 编码设置 > 编码 `必须` 使用 `UTF-8`, 且 `必须` 设置为 `with NO BOM` ![20241229154732_cNmFzTvf.webp](https://cdn.dong4j.site_cNmFzTvf.webp) `踩坑` **Windows 下请不要用记事本打开 UFT-8 编码的文本文件, 更不要保存 ** Windows 坑的很, UTF-8 编码的文本文件最前面会给你加上一个 BOM, 其他系统打开是正常显示, 但是不会显示这个 BOM, 造成文件解析出错. ##### html 设置编码为 UTF-8 ```html ``` > html 或者模板 `应该` 使用 html5 html4 升级为 html5 非常简单 ```html Document ``` 修改为: ```html Document ``` 简单的升级, 就能享受 30 多个新标签带来的便捷, 何乐而不为呢? 就跟超市打折一样, 还倒送你 30 块, 跳广场舞的大妈排着队去, 你还不去? ##### Tomcat 设置编码为 UTF-8 ```xml ``` 改为: ```xml ``` Tomcat7 默认编码为 ISO-8859-1, 到了 Tomcat8 后, 默认编码改为 UTF-8 因此以上修改只针对于 Tomcat7. #### todo 标识 > `必须` **作用: 方便搜索, 明确负责人, 说明 todo 原因 ** 这里扩展了 IDEA 自带的 todo 标识, 使用 `todo- 负责人: (时间) [原因]` 来规范 `todo` 用法 ![20241229154732_2ZRdEKBJ.webp](https://cdn.dong4j.site_2ZRdEKBJ.webp) ```lua todo- 负责人: ($date$ $time$) [$SELECTION$] ``` **date time 设置见 类注释一节 ** 效果: ![777.gif](https://cdn.dong4j.site #### fixme 标识 > `必须` ```lua fixme- 负责人: ($date$ $time$ [$SELECTION$]) ``` 设置和效果同上 #### 类注释 > `必须` 为每个类添加必要的注释 这里有 2 中方式: **1. 新建类时添加注释 ** ![888.gif](https://cdn.dong4j.site 设置方式: ![20241229154732_xjBq89Wf.webp](https://cdn.dong4j.site_xjBq89Wf.webp) ```java #if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end ##parse("File Header.java") /** *

Company: xxxx公司

*

Description: ${description}

¶* * @author 你的昵称 * @date ${YEAR}-${MONTH}-${DAY} ${HOUR}:${MINUTE} * @email 用户名@xxxx.com */ public class ${NAME} { } ``` **2. 为已存在的类添加注释 ** ![999.gif](https://cdn.dong4j.site 设置方式: ![20241229154732_FMVEAJ28.webp](https://cdn.dong4j.site_FMVEAJ28.webp) ```java /** *

Company: xxxx公司

*

Description: $END$

* * @author 你的昵称 * @date $date$ $time$ * @email 用户名@xxxx.com */ ``` #### 方法注释 > `推荐` 使用 JavaDoc 插件自动生成 JavaDoc 只能自动生成非 `private` 方法和属性的注释, 如果需要生成 `private` 级的注释, 可以通过修改 `public`, 生成完成后, 再修改回来 #### 代码注释 > `不该` 使用行尾注释; > `推荐` 使用 // 代替 `/*...*/` 多行注释; > `推荐` 使用 `/**..*/` 对字段进行注释 (单行); > 所有字段名, 方法名, 类名 都 `应该` 使用简单, 业界常用的单词命名, 不要为了注释而注释, 讲究个代码之美. 在查看大段代码时, `推荐` 使用 ```java // region 段注释 .... // endregion ``` **此注释能增加代码折叠功能, 便于快速梳理代码 ** ![11111.gif](https://cdn.dong4j.site ### Version Control 设置 > `必须` 忽略 target 目录 > `必须` 忽略 .idea > `必须` 忽略 `*.iml` > `必须` 添加 .ignore 文件 **如果使用 git, 所有忽略文件都可以添加到 .ignore 即可 ** **提交代码时的设置 ** ![20241229154732_RjJbNzVS.webp](https://cdn.dong4j.site_RjJbNzVS.webp) > `必须` 勾选 Optimize imports; (提交代码时, 自动删除不被使用的 import) > `推荐` 取消掉默认勾选的 Perform code analysis 和 Check TODO, 会增加提交代码的时间; > `不该` 勾选 Reformat code, 会格式化所有提交的代码, 格式化代码应该自己手动格式化, 这样能减少代码冲突的可能; ## 代码样式 以 intellij-java-google-style.xml 为基础, 做了一部分修改. > musicsearch-project `必须的必须` 使用此代码样式, 为了减少代码冲突, 同一代码样式 (同一门派, 你去修炼其他门派武功, 会被逐出师门的!). 比如: 1. 一行的代码长度不能超过 140 宽; 2. 等号对齐; 3. if 语句只有一行也 `必须` 加大括号; 4. 所有操作符两边 `必须` 有空格; 5. 类名, 方法名 `必须` 加空格后才跟 一个 `{`; 6. 使用 4 个空格代替制表符 (我们这里不讨论空格好还是制表符好, 统一使用一种是最最好的); 7. .... **以上列举的部分规范都可以使用格式化解决;** 本人对代码有着严 (变) 格(态)的要求, 严 (变) 格(态)到使用 `//` 后面 `必须` 跟一个空格, 然后才是注释内容; 英文和中文之间 `必须` 增加一个空格; 所有标点全部使用英文标点; 没有被使用的类, 变量都 `必须` 删除; 遇到的警告 `必须` 尽最大努力改正; 左边为重构之前的警告数量 (黄色), 右边为重构之后的 ![20241229154732_b8RAQPH9.webp](https://cdn.dong4j.site_b8RAQPH9.webp) 1. 英文和中文之间增加空格是为了便于阅读, 方便拷贝 (双击英文即可全选, 如果不增加空格, 中文英文则会全被选中. 我变态到 chrome 都要安装一个叫 `空格之神` 的插件, 用于在中英文之间追加空格); 2. 全部使用英文标点的好处不言而喻, Java 乃至所有的编程语言都不支持中文标点, 打个分号还得切换输入法, 想想都忧伤; 有时候就是因为中文标点的问题, 一直报错 (输入法可以设置中文时使用英文标点, `推荐` 这种设置); 3. 符号后面喜欢追加一个空格, 是受 Markdown 语法的影响, 以前用过很多 Markdown 编辑工具, 由于解析语法不一样, 迁移时渲染不生效, 加个空格或者换行就 Ok 了; 😂 4. 及时删除不需要的代码, 时刻保持代码干净 (IDEA 会帮我们检查未使用的代码). 5. **警告是 bug 的温床, 修复警告, 就是修复潜在的 bug**. 修复警告, 也能学到很多东西的 (😎) ## musicsearch-project 规范 以下作为 `musicsearch-project` 规范, 制定了大部分要求. 具体设计将在 第三篇 介绍 ### 项目结构规范 ```lua . ├── musicsearch-business # 业务主模块 │ ├── business-common # 业务公共类库 │ ├── mservice-migu-game # migu-game 业务 │ └── service-meeting # meeting 服务模块 ├── musicsearch-common # musicsearch 项目 公共模块 ├── musicsearch-component # 组件主模块 │ ├── component-iavp # iavp 模块, 封装 iavp 相关实体和接口, 直接注入即可 │ ├── component-mybatis # mybatis 模块, 提供代码生成和 mybatis 相关功能 │ ├── component-redis # redis 模块, 注入 RedisService 即可, 提供多种模式 │ └── component-websocket # websocket 模块 netty-socket.io 封装 ├── musicsearch-demo # demo 主模块 │ ├── component-mybatis-demo │ └── component-redis-demo ├── musicsearch-dependencies # 管理第三方 jar 版本和依赖关系 ├── musicsearch-monitor # 监控主模块 ├── musicsearch-parent # musicsearch 工程主模块, 管理整个功能的版本及依赖 │ └── docs # 放工程相关文档 │ └── database # 放工程 sql └── musicsearch-support # 支撑模块主模块 ├── musicsearch-code-generator ├── musicsearch-management-system └── musicsearch-timer-task ``` 目前重构后的模块, 可能还会有修改. 但是几个主要模块不会再修改了. > 整个项目结构采用 `Maven` 多模块的方式开发. > 目的是为了解决 jar 依赖混乱问题. **结构规范:** > 所有主模块 `必须` 以 `musicsearch-` 开始. 不为别的, 纯属统一前缀, 好看而已 > 模块名 `必须` 全部使用小写, 单词之间作用 `-` 分隔; #### musicsearch-parent 作为整个项目的 灵魂 模块, 管理所有自有模块的版本和依赖关系, 以及整个项目都会使用到的依赖, 比如 `lombok`, `junit` 等. ![20241229154732_FNQjsndy.webp](https://cdn.dong4j.site_FNQjsndy.webp) `dependencyManagement` 标签只是用于声明可能会被使用到的依赖 (就像定义变量), 不会真正添加依赖 `dependency` 才会真正真正引入依赖 这里全部为 `musicsearch-project` 自有模块, 以后新增的模块也 `必须` 将声明添加到此标签下 自有模块之间依赖就可直接使用. ![20241229154732_vXVvcVaj.webp](https://cdn.dong4j.site_vXVvcVaj.webp) 使用时, `一定不可` 添加 version 标签, 不然就会使用修改后的版本, 可能会造成依赖冲突. --- `musicsearch-project` 模块下有一个 doc 目录, 用于保存文本文档 个人认为, 项目的 `技术文档` 跟着代码走才是最正确的做法. 随时修改, 随时查看, 代码和文档一起更新提交, 才是最佳实践. ![20241229154732_Sc2zPY5K.webp](https://cdn.dong4j.site_Sc2zPY5K.webp) 技术文档 `推荐` 使用 `Markdown` 语法编写, 最好是 [GitHub Markdown 语法](https://guides.github.com/features/mastering-markdown/). 这里给出模块说明模板, 每个模块都 `必须` 有此文档 ```markdown # 模块名 ## 简介 xxx ## 打包方式 xxx ## 部署方式 xxx ## 使用说明 1. xxx 2. xxx 3. xxx ## 注意事项 xxx ## 更新历史 **更新时间 更新人 ** 1. xxx 2. xxx ``` --- `database` 用于保存需求需要更新的 sql 文件, `必须` 以 `.sql` 为后缀, `必须` 是 UTF-8 编码. #### musicsearch-dependencies 此模块是为了方便管理第三方 jar 依赖而特意添加的, 如果没有此模块, 第三方依赖也可以添加到 `musicsearch-project` 中, 但是会造成过度臃肿, 因此将第三方依赖拆分到此模块中进行统一管理. > 第三方依赖 `必须` 添加到此模块, 且 `必须` 将版本号设置到 properties 标签下. > 第三方依赖较大可能存在版本冲突, 因此此模块的版本从 `0.0.1` 开始, 不和 `musicsearch-parent` 相同, 如果此模块存在依赖问题, 修复后, 需要提升版本号, > 并且 `必须` 写更新记录. 此模块比较特殊, 修改会比较频繁, 依赖出错大部分出现在此模块, 因此单独写一个 `changes.md`, 用于记录更新日志. ![20241229154732_mPHwivSa.webp](https://cdn.dong4j.site_mPHwivSa.webp) **引入新依赖步骤 ** 1. 在 `musicsearch-dependencies` 添加新依赖; 2. 将版本信息写入到 pom 的 properties 标签内, 进行统一管理; 3. 在需要的模块中, 引入依赖; 4. 最后使用 `Maven Helper` 排查是否存在依赖冲突; 5. 如果存在冲突 需要使用 `` 排除相应的依赖; #### musicsearch-common `musicsearch-project` 项目的子模块, 作为最底层的依赖, 提供了公共 util 包, core 包, base 包等基础代码. > 如果被整个项目使用到的代码, 都 `必须` 放入此模块, 比如 StringUtils 工具类, 一些加密类, 整个项目都能使用到的常量类, 枚举类等; **必须要提出的是:** 不要多个模块多个 StringUtils 工具类, 最佳实践 `应该` 是 `musicsearch-common` 中编写一个通用的 `StringUtils` 类, 继承自 `org.apache.commons.lang3.StringUtils` 类, 业务模块继承 `musicsearch-common` 模块中的 `StringUtils` 类, 实现业务相关的字符串处理工具类.(这种方式同样适用于其他类) #### musicsearch-business 业务模块的父模块, `musicsearch-parent` 模块的子模块. 用于管理业务模块共用的 jar 依赖. > 所有业务模块 `必须` 是 `musicsearch-business` 的子模块 将业务代码全部整合在一个模块中, 使用业务名进行再分子模块的方式, 管理整个业务代码. 其他共用模块作为框架基础模块, 以后新项目还可以复用. > 不提供 `dubbo service` 的模块, `必须` 以 `service-` 开始. > 提供 `dubbo service` 的模块, `必须` 以 `mservice-` 开始. `mservice` 的意思是 `micro-service`. 这样便于区分不同的服务类型, 也方便分组. 我们现在使用 `dubbo` 服务治理框架, 如果以后有可能话, 可以很方便的迁移到 `SOFA` 或者 `Spring Cloud` (我就说说, 应该是不可能的事了). 业务子模块除了 `business-common` 模块, 其他子模块都以 Web 应用 或者 JVM 进程直接提供服务. #### business-common `musicsearch-business` 模块的子模块, 依赖于 `musicsearch-common`. > 业务模块共用的代码 `必须` 放入 `business-common` 模块. 比如所有业务都会用到的 redis key 常量类, 则 `必须` 放在 `business-common` 模块中; 而 `service-meeting` 这个模块的业务用到的 redis key 常量则 `必须` 放到 `service-meeting` 模块中. 讲究职责分离, 层级分明, 互不干涉; 你走你的阳关道, 我看我的 西虹市首富. ### 模块名 > `必须` 全部为小写, 单词之间 `必须` 使用 `-` 分隔. 不要随心所欲的命名, 要讲究的规则, 不然会走火入魔的. > provider 模块名 `必须` 以 `mservice-` 开头; provider 模块也有可能是服务消费者, 这类的模块, 还是 `应该` 使用 `mservice-` 开头 对于 provider 模块, 至少应该分为 2 层; 1. 服务名 -interface , 提供给 consumer 的依赖模块 2. 服务名 -service , 业务处理模块 > 服务名 -interface 模块 `必须` 包含用到的实体类, 接口定义, dubbo-consumer- 服务名.xml, 共用的 util 类, 枚举类等; **必须要提出的是:** `dubbo-consumer- 服务名.xml` 配置文件 `应该` 由 provider 来维护, 而 consumer 只需要 import 此配置文件即可, 而不是在自己的 Spring 配置文件或者自定义一个 dubbo 配置文件再来写引入的接口. 这就是为什么 `dubbo-consumer- 服务名.xml` `应该` 在 interface 模块的原因. 这里看看 migu-game 重构之前的结构 (有一些小改动) **配置关系:** ![20241229154732_Fx0TE6bY.webp](https://cdn.dong4j.site_Fx0TE6bY.webp) 这里的 dubbo.xml 即 provider 配置, 前面也说了, 这样的命名方式不够直观, 因此 `应该` 采用 `dubbo-provider- 服务名 ` 的方式命名; Spring-config.xml 是主配置, 导入了其他 5 个配置; ```xml ``` `applicationContext-jms.xml` 和 `applicationContext-redis.xml` 这 2 个配置原来是放在 web.xml 中的. 这里迁入到 Spring-config.xml 中, 因为 web.xml 只需要负责加载 Spring-config.xml 即可, web.xml 写好之后, 基本不需要修改, 所有配置关系全部在 Spring-config.xml 中管理. 那么问题来了 **dubbo 的 consumer 配置在哪里呢?** 找了半天, 原来在 funclib 模块的 `applicationContext-mainflowfunc.xml` 配置中... ![20241229154732_F89Aye00.webp](https://cdn.dong4j.site_F89Aye00.webp) 那么问题又来了 如果 migu-game 由合肥的同事开发, 需要增加几个接口, 使用 migu-game 服务的是成都的小明同学, 正在开发 funclib, 这个时候请问.... **小明中午吃了啥?** ![20241229154732_YSkFqKZ8.webp](https://cdn.dong4j.site_YSkFqKZ8.webp) > 如果 dubbo consumer 配置由 funclib 维护, 那么就要修改 funclib 模块的配置; > 如果 dubbo consumer 配置由 migu-game 维护, 合肥的同事只需要提供 migu-game-interface, funclib 只需引入 `dubbo-consumer-migugame.xml`; ` 服务名 -interface` 还应该依赖 `dubbo` 相关 jar 依赖, 这样 consumer 就不需要自己引入 `dubbo` 相关依赖了; 服务行业嘛, 要做就做全套 **最佳实践 ** 个人认为, 一个 `mservice` 最好分为 3 层; 1. interface 层; 2. service 层; 3. dao 层; interface 已经说过了; service 层, 作为业务层, 依赖于 dao 层和 interface 层; 各层的 pom 中只依赖每层需要的 jar 依赖. 比如 duubo 的依赖 `应该` 写在 interface 层, `mybatis` 和数据库驱动依赖 `应该` 写在 dao 层, 而 service 层依赖业务相关的 jar. 这样将所有依赖下放到不同层中, 以一种插件化的方式提供服务, 导入某层依赖, 连带引入了这层需要的依赖. 这样 jar 依赖关系明确, 也很好管理. `musicsearch-project` 的思想就是这样, 以 `分而治之` 的方式管理代码和依赖关系, 也就是个解耦的思想. 而不是将所有依赖全部扔到 common 或者 service 中, 方便是方便, 维护起来想死的心都有了. interface 模块, 就是一个提供给 consumer 的说明, 告诉 consumer, 我这个 interface 提供了哪些功能, 没有具体实现. 而 API 全称 Application Program Interface, 即应用程序接口, 是一组定义, 程序及协议的集合. 因此我将 服务名 -api 用于向外提供 rest api 的模块. 相关的包名也是如此. ### 包名 > 所有包名 `必须` 以 `com.xxxx.msearch` 开始 其他规则: - 组件模块: `com.xxxx.msearch.component. 组件名 ` - 公共类库: `com.xxxx.msearch.common` - 服务模块: `com.xxxx.msearch. 服务名 ` `推荐` 几个常用的包名 - `config` 用来放配置类 - `util` 工具类包 - `enums` 枚举类包 - `constant` 常量类包 (`推荐` 将常量按类别定义在不同的常量类中) - `service` service 接口包 - `service.impl` service 接口实现类包 - `dao` dao 接口包 - `interfaces` 特指 provider 提供的接口类 - `api` 特指 rest api ### 类名 类名 `必须` 使用 UpperCamelCase 风格 (首字母都大写),必须遵从驼峰形式. 例如: StringUtils > 抽象类命名 `必须` 使用 Abstract 或 Base 开头; > 异常类命名 `必须` 使用 Exception 结尾; > 测试类命名 `必须` 以它要测试的类的名称开始,以 Test 结尾; > 接口实现类 `必须` 以 Impl 结尾; > API 接口类必须以 `Controller` 结尾; > ORM 接口必须以 `Dao` 结尾; ### 方法名 Dao 接口名 `推荐` 使用以下命名方式 新增: - add(Entity entity) - insert(Entity entity) - save(Entity entity) 查询: - get(Long id) - getByXxx - findByXxx - selectByXxx 更新: - update(Entity entity) - updateByXxx 删除: - delete(Long id) - deleteByXxx ### 属性名 这个没什么好说的, 按照 Java 推荐命名方式即可. > 常量命名 `必须` 全部大写, 单词间用下划线分隔; ## 数据库规范 推荐 3 篇文章, 虽然是 mysql 的, 但是都是数据库啊 [赶集 mysql 军规](https://mp.weixin.qq.com/s?__biz=MjM5ODYxMDA5OQ==&mid=2651960775&idx=1&sn=1a9c9f4b94dfe71ad2528fb2c84f5ec7&chksm=bd2d001b8a5a890d302d139ea42e9ffde44407738a618865934e40b8e35486b13cafca2933f6&mpshare=1&scene=1&srcid=1228MzgFw9KLVzaHtjHvpb2p%23rd) [58 到家数据库 30 条军规解读](https://mp.weixin.qq.com/s?__biz=MjM5ODYxMDA5OQ==&mid=2651959906&idx=1&sn=2cbdc66cfb5b53cf4327a1e0d18d9b4a&chksm=bd2d07be8a5a8ea86dc3c04eced3f411ee5ec207f73d317245e1fefea1628feb037ad71531bc&scene=21#wechat_redirect) [再议数据库军规](https://mp.weixin.qq.com/s?__biz=MjM5ODYxMDA5OQ==&mid=2651959910&idx=1&sn=6b6853b70dbbe6d689a12a4a60b84d8b&chksm=bd2d07ba8a5a8eac6783bac951dba345d865d875538755fe665a5daaf142efe670e2c02b7c71&scene=21#wechat_redirect) ## 配置规范 为了解决开发, 测试, 现网部署时手动对比配置, 手动切换配置, 减少人工介入出错的几率, 节约时间等问题, `musicsearch-project` 采用多环境配置. 一次打包, 可部署到多个环境. 意思就是: 一次打包, 到处运行. ### 配置分类 **从数据来源可分为:** - 数据库 - NoSQL - 文件 - 网络 **从加载顺序上课分为:** - 启动时加载的配置 - dataSource 连接配置 - Redis 连接配置 - MQ 连接配置 - 日志配置 - ... - 运行期间动态获取 - 字典数据 其实最容易出错的且影响最大的就是运行期间动态获取的配置, 字典数据改错了就会影响现网业务, 而且不好排查. 连接配置启动时就需要, 如果错误可能会导致启动失败, 这类问题比较容易排查. ### local 环境 以前在开发时, 使用的 dev 环境. 可能会将某几个配置修改成本地环境或者 test 环境进行开发, 开发完成后, 又 `需要逐个改回来`; 改动的配置太多, 部分配置 `忘记改回来` 还提交了, 悲剧了; local 环境专门解决以上问题. 最初是一个空配置, 我们拉取代码后根据自己的环境修改 local 配置, 然后使用 idea `忽略提交` 功能, 忽略此文件. 每个开发者互不影响, 开发时只需要修改 local 环境即可, `不该` 直接修改其他环境配置,避免环境问题造成线上问题 ### dev 环境 现在的服务太多, 我们开发时不可能每个都启动起来, 这时我们可以将一些公共的服务部署到 dev 服务器, 这个配置就是 dev 环境的配置了, 一旦配置好, `不该` 轻易修改. ### test 环境 和 dev 环境差不多, 只是一些连接配置不一样, `不该` 轻易修改. ### prod 环境 生产环境经常改动的就是字典数据, 现在的字段数据太复杂了. 最好的方式是通过 kv 键值对, value 只是简单的字符串而已, 不要是一个复杂的 json, 这样能非常友好的管理所有配置, 修改配置也不容易出错. redis 构建工具我了解当的一个作用是为了防止修改出错, 做了一个保护措施. 先保存到数据库中, 检查一下, 确保没出错后使用构建工具同步到 redis. 如果是这个原因的话, 我想能不能使用一个弹出框显示修改前与修改后的值, 然后二次确认? 确认后修改数据库, 同时更新 redis. 获取动态数据的方式: 首先从 redis 中获取, 如果没有则从数据库中获取, 然后更新到 redis; 如果有则直接返回. 后台系统修改字典数据且二次确认后, 更新数据库和 redis. 这里有一个事务问题. 比如后台系统修改完字段数据后, 更新数据库成功, 但是更新 redis 失败, 这个时候 redis 中就是旧数据. **解决方案:** 1. 后台系统二次确认修改; 2. 更新数据库; 3. 将 redis 相应的 key 设置为失效; **分析:** 1. 第 2 步失败, 直接返回修改失败, 不会发生事务问题, 数据也不会被修改; 2. 第 2 步成功, 第 3 步失败; 1. 此时应用查询字典数据时, redis 为旧数据 (可以使用重试来解决) 3. 第 2 步成功, 第 3 步成功; 1. 此时应用查询字典数据时, redis 没有, 则去数据库中查询最新, 然后再更新到 redis 中. ### Dubbo 配置文件 > 消费者配置文件名 `必须` 以 `dubbo-consumer- 服务名.xml` 命名. > 服务提供者配置文件名 `必须` 以 `dubbo-provider- 服务名.xml` 命名. 配置名区分消费者和生产者, 这样职责明确, 方便搜索查看. 最操蛋的是搜索出几十个 dubbo.xml 配置, 还得一个个点进入看是消费者还是生产者配置. > 消费者配置 `必须` 单独写在 `dubbo-consumer- 服务名.xml` 中, 不要写在 Spring 配置中. 配置尽量分开到不同到配置文件中, 最后使用 import 聚合起来. 需要修改配置时, 能明确知道去哪个配置文件中修改. 你见 `葵花宝典` 里面写了怎么自宫的详细教程吗? 最多也就引用一下, `欲练此功, 必先自宫`. > 引入配置不要写在 web.xml, `必须` 使用 import 在 Spring 主配置文件中引入. `musicsearch-project` 不需要担心这个问题, 因为 web.xml 已经被干掉了 🤣 ### Spring 配置文件 - SpringMVC 配置文件统一命名: `spring-mvc.xml` - Web 应用的 Spring 配置文件统一命名: `spring-context.xml` - 其他组件的 Spring 配置文件统一命名: 模块名.xml - Spring Boot 应用的配置文件统一使用 application.properties `musicsearch-project` 使用 Spring Boot 为基础框架, 因此 `spring-mvc.xml` 和 `spring-context.xml` 已经使用 Java Config 的方式代替. **自动配置类 `必须` 以 服务名 +Configuration 方式命名 ** **启动类 `必须` 以 服务名 +Application 方式命名 ** **单元测试主类 `必须` 以 服务名 +ApplicationTest 方式命名 ** ## Maven 原来的项目是由多个模块由不同的 Maven 管理, 造成编译, 打包,debug 麻烦, 依赖关系复杂, 版本随意, 同一个模块多个不同版本, 维护困难. 为了解决以上问题, 现在将所有模块通过一个 主 pom 进行管理, 所有模块全部在 `musicsearch-porject` 目录下. 优点如下: - debug 时方便, 想在哪儿打断点就在哪儿打断点. 随处 debug; - 能借助 IDEA 进行全局重构; - jar 版本统一管理 - 绝对不会出现相同 jar 多个相同版本的问题. ### pom 规范 > groupId `必须` 为 `com.xxx` > artifactId `必须` 与 模块名相同 > version `必须` 与 父 pom version 相同 > `必须` 显式指定 `packaging` 如果没有设置 packaging 标签, 默认打包为 jar 格式, 这里 `必须` 设置此标签, 明确指定打包格式. > `必须` 写 `description` 标签 为此 pom 添加必要的描述信息 > 第三方依赖 `必须` 添加到 `musicsearch-dependencies` pom 中; > 如果 Maven 中央仓库没有的 jar 包, 从网上下载后, `必须` 上传到公司的 Maven, 不能直接使用 jar 包 1. 搜索 Maven 中央仓库, 关键字 + maven; 2. 搜索不到则找到 jar 上传到公司 Maven 仓库 ``` # 安装到私服 # DgroupId 和 DartifactId 构成了该 jar 包在 pom.xml 的坐标,项目就是依靠这两个属性定位。自己起名字也行。 # Dfile 表示需要上传的 jar 包的绝对路径。 # Durl 私服上仓库的位置,打开 nexus——>repositories 菜单,可以看到该路径。 # DrepositoryId 服务器的表示 id,在 nexus 的 configuration 可以看到。 要与 setting.xml 中的权限 id 一致 # Dversion 表示版本信息 mvn deploy:deploy-file -DgroupId=com.xxx -DartifactId=yyy -Dversion=x.x.x -Dpackaging=jar -Dfile=jar-path -Durl= 上传地址 -DrepositoryId=thirdparty ``` > 第三方依赖 `必须` 将版本迁入 `musicsearch-dependencies` 到 properties 标签下; > version `必须` 以 `artifactId.version` 的方式命名; ```xml ... 1.0.0 ... io.socket socket.io-client ${socket.io-client.version} ``` 由于 `musicsearch-project` 使用 Spring Boot 开发, 版本为 1.5.8.RELEASE. Spring Boot 每个版本都有一个 `spring-boot-dependencies` 项目用来维护所有第三方 jar 版本和 Maven 插件版本. ![20241229154732_HijijtDI.webp](https://cdn.dong4j.site_HijijtDI.webp) 我们直接使用 `spring-boot-dependencies` 中相关依赖的版本, 能有效减少版本冲突. > 如果我们新增的第三方包已经在 `spring-boot-dependencies` 声明, 则 `必须` 使用 `spring-boot-dependencies` 规定的版本, 即删除 version 标签. 怎么查看新引入的依赖是否在 `spring-boot-dependencies` 被声明了呢, 很简单 比如我需要添加如下依赖到模块中 ```xml org.projectlombok lombok 1.16.14 ``` 整个操作如下图所示: ![2222.gif](https://cdn.dong4j.site 我们新增的 lombok 版本为 1.16.14 但是当加入 pom 之后, 左边出现了一个向上的箭头, 点过去之后, 发现此依赖已经在 `spring-boot-dependencies` 被声明了, 并且版本为 1.16.18, 因此删除 version 就可以了. **添加新依赖的步骤:** 1. 选择合适的模块, 直接粘贴 maven 依赖配置到 pom 中, 2. 如果左边出现了向上的箭头, 则删除 version 标签, 搞定; 3. 如果没有, 则将此依赖配置粘贴到 `musicsearch-dependencies` 中, 将 version 添加到 properties 标签中; 4. 最后删除最开始那个配置的 version; `spring-boot-dependencies` 不可能定义到所有的依赖, 因此这里就有了 `musicsearch-dependencies` 这个模块, 用来管理项目需要但是没有在 `spring-boot-dependencies` 中定义的 jar 依赖. ## 日志规范 > `推荐` 使用 `@Slf4j` 获取 `log` 实例 使用 `lombok` 插件, 直接使用 @Slf4j 代替获取 log 实例的冗余代码. ```java import org.slf4j.Logger; import org.slf4j.LoggerFactory; ... private static final Logger log = LoggerFactory.getLogger(Xxx.class); ``` > `必须` 使用 `log4j2` 日志框架 统一使用效率更高的 log4j2 日志框架 > 日志输出到文件的编码 `必须` 为 UTF-8 ### 日志配置名 > Web 应用或者通过 JVM 进程提供服务的应用, `必须` 使用 `log4j2-spring.xml` 命名 Spring Boot 官方推荐优先使用带有 -spring 的文件名作为日志配置(如使用 log4j2-spring.xml,而不是 log4j2.xml) 原理可以查看 `Log4J2LoggingSystem.getCurrentlySupportedConfigLocations()` `AbstractLoggingSystem.getSpringConfigLocations()` ### 输出样式 ``` env: %d{yyyy.MM.dd HH:mm:ss.SSS} [%5p] ${sys:PID} -- [%15.15t] %-40.40c{1.} : %m%n%xwEx ``` 输出内容元素具体如下: - 环境名 - 时间日期 — 精确到毫秒 - 日志级别 — ERROR, WARN, INFO, DEBUG or TRACE - 进程 ID - 分隔符 —`--`标识实际日志的开始 - 线程名 — 方括号括起来(可能会截断控制台输出) - Logger 名 — 通常使用源代码的类名 - 日志内容 效果 : ``` dev: 2018.08.01 15:02:25.153 INFO 75012 -- [restartedMain] c.i.m.m.MeetingApplication : Starting MeetingApplication on dong4j with PID 75012 (/Users/codeai/Develop/work/ifly/musicsearch-project/musicsearch-business/service-meeting/target/classes started by codeai in /Users/codeai/Develop/work/ifly/musicsearch-project) dev: 2018.08.01 15:02:25.154 DEBUG 75012 -- [restartedMain] c.i.m.m.MeetingApplication : Running with Spring Boot v1.5.8.RELEASE, Spring v4.3.12.RELEASE dev: 2018.08.01 15:02:25.155 INFO 75012 -- [restartedMain] c.i.m.m.MeetingApplication : The following profiles are active: oracle ``` ### 日志保存路径 日志路径统一管理, 确保每台服务器上的日志都在同一目录下 ``` /path/to/logs/${APP_NAME} ``` `/path/to/logs/` 再议, 只要是一个有读写权限的目录且好记就可以了, 关键是保持统一. > 最后 `必须` 通过 APP_NAME 参数区分应用. ### 日志归档 > 日志文件 `必须` 1 天归档一次,压缩文件上限 `建议` 为 200MB 每天归档日志, 方便按日志查询日志 ## 单元测试规范 > 所有模块都 `必须` 有单元测试, 而不是使用 `main()` 来进行测试; IDEA 添加单元测试类非常简单 ![3333.gif](https://cdn.dong4j.site 直接通过快捷键自动生成单元测试类. > 集成测试时, `必须` 继承主测试类; 在每个 Web 模块中, 都会有一个 XxxApplicationTest 测试父类, 用于整合配置类, 测试端口随机等功能. ![20241229154732_JacCQkxj.webp](https://cdn.dong4j.site_JacCQkxj.webp) 其他集成测试类只需要继承此父类即可, 不需要写重复的注解 ![20241229154732_8Sa4oiU5.webp](https://cdn.dong4j.site_8Sa4oiU5.webp) 默认是单元测试 `必须` 全部通过才能打包, 当往往由于单元测试编写不规范造成打包失败, 编写好的单元测试难度也非常大, 因此这里不强制要求. 使用 3 种方式忽略单元测试 **方式 1:** `推荐` 这种 使用变量 ```xml true ``` 或者 ```xml true ``` **方式 2:** 使用 mvn 命令 ```lua mvn package -Dmaven.test.skip=true ``` **方式 3:** 使用插件 ```xml org.apache.maven.plugins maven-surefire-plugin ${maven-surefire-plugin.version} true ``` 方式 1 与方式 2 的区别在于: `skipTests` 不执行测试用例,但编译测试用例类生成相应的 class 文件至 target/test-classes 下 `maven.test.skip` 不但跳过单元测试的运行,也跳过测试代码的编译 ## [Redis Sentinel 搭建指南:从入门到精通](https://blog.dong4j.site/posts/70b9e46a.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 记录 Redis Sentinel 的搭建过程 ## 现有架构的问题 1. master 挂掉之后, 需要手动切换, 运维复杂 ## Redis Sentinel 高可用 ![20241229154732_8Zk7xKho.webp](https://cdn.dong4j.site/source/image/20241229154732_8Zk7xKho.webp) 下面以 1 个主节点、2 个从节点、3 个 Sentinel 节点组成的 Redis Sentinel 为例子 故障转移处理逻辑: 1. 主节点出现故障, 此时两个从节点与主节点时区连接, 主从复制失败; ![20241229154732_HwvXcbfZ.webp](https://cdn.dong4j.site/source/image/20241229154732_HwvXcbfZ.webp) 2. 每个 Sentinel 节点通过定期监控发现主节点出现故障; ![20241229154732_aQBkbupn.webp](https://cdn.dong4j.site/source/image/20241229154732_aQBkbupn.webp) 3. 多个 Sentinel 节点对主节点的故障达成一致, 选举出 sentinel-3 节点作为领导者负责故障转移; ![20241229154732_s7YZQex5.webp](https://cdn.dong4j.site/source/image/20241229154732_s7YZQex5.webp) 1. 原来的从节点 slave-1 称为新的主节点后, 更新应用方的主节点信息, 重新启动应用方; 2. 客户端命令另一个从节点 slave-2 去复制性的主节点; 3. 待原来的主节点恢复后, 让它去复制新的主节点; ![20241229154732_FUe3W94T.webp](https://cdn.dong4j.site/source/image/20241229154732_FUe3W94T.webp) 4. 故障转移后的结构图 ![20241229154732_znOFqeq4.webp](https://cdn.dong4j.site/source/image/20241229154732_znOFqeq4.webp) ## Redis Sentinel 功能 1. **监控:** Sentinel 节点会定期检测 Redis 数据节点和其余 Sentinel 节点是否可达; 2. **通知:** Sentinel 节点会将故障转移的结果通知给应用方; 3. **主节点故障转移:** 实现从节点晋升为主节点并维护后续正确的主从关系; 4. **配置提供者:** 客户端在初始化时, 连接 Sentinel 节点集群, 从中获取主节点信息; ## Redis Sentinel 安装与部署 下面将以 3 个 Sentinel 节点、1 个主节点、2 个从节点组成一个 Redis Sentinel 进行说明 ![20241229154732_2SRjp4lI.webp](https://cdn.dong4j.site/source/image/20241229154732_2SRjp4lI.webp) 具体的物理部署: | 角色 | ip | port | 别名 | | :--------- | :-------- | :---- | :--------- | | master | 127.0.0.1 | 6379 | 主节点 | | slave-1 | 127.0.0.1 | 6380 | slave-1 | | slave-2 | 127.0.0.1 | 6381 | slave-2 | | sentinel-1 | 127.0.0.1 | 26379 | sentinel-1 | | sentinel-2 | 127.0.0.1 | 26380 | sentinel-2 | | sentinel-3 | 127.0.0.1 | 26381 | sentinel-3 | **1. master 配置** ``` daemonize yes dbfilename "6379.db" dir "/Users/codeai/Develop/logs/redis/db/" logfile "/Users/codeai/Develop/logs/redis/log/6379.log" port 6379 requirepass 1234 ``` **2. slave-1 配置** ``` daemonize yes dbfilename "6380.db" dir "/Users/codeai/Develop/logs/redis/db/" logfile "/Users/codeai/Develop/logs/redis/log/6380.log" port 6380 slaveof 127.0.0.1 6379 # 设置 master 验证密码 masterauth 1234 # 设置 slave 密码 requirepass 1234 ``` **3. slave-2 配置** ``` daemonize yes dbfilename "6381.db" dir "/Users/codeai/Develop/logs/redis/db/" logfile "/Users/codeai/Develop/logs/redis/log/6381.log" port 6381 slaveof 127.0.0.1 6379 # 设置 master 验证密码 masterauth 1234 # 设置 slave 密码 requirepass 1234 ``` **4. 启动 redis 服务** ``` redis-server redis-6379.conf; redis-server redis-6380.conf; redis-server redis-6381.conf ``` ![20241229154732_gq307pmS.webp](https://cdn.dong4j.site/source/image/20241229154732_gq307pmS.webp) **5. 确认主从关系** 主节点视角 ``` redis-cli -h 127.0.0.1 -p 6379 info replication # Replication # 主节点 role:master # 有 2 个 从节点 connected_slaves:2 # 从节点 1 信息 slave0:ip=127.0.0.1,port=6380,state=online,offset=112,lag=0 # 从节点 2 信息 slave1:ip=127.0.0.1,port=6381,state=online,offset=112,lag=0 master_replid:8f43dc48cca779f46fa1516a38e24fb8c5423d94 master_replid2:0000000000000000000000000000000000000000 master_repl_offset:112 second_repl_offset:-1 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:1 repl_backlog_histlen:112 ``` 从节点视角 ``` redis-cli -h 127.0.0.1 -p 6380 info replication # Replication # 从节点 role:slave # 主节点信息 master_host:127.0.0.1 master_port:6379 master_link_status:up master_last_io_seconds_ago:2 master_sync_in_progress:0 slave_repl_offset:238 slave_priority:100 slave_read_only:1 connected_slaves:0 master_replid:8f43dc48cca779f46fa1516a38e24fb8c5423d94 master_replid2:0000000000000000000000000000000000000000 master_repl_offset:238 second_repl_offset:-1 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:1 repl_backlog_histlen:238 ``` **6. 部署 Sentinel 节点** ``` port 26379 daemonize yes logfile "/Users/codeai/Develop/logs/redis/log/26379.log" dir "/Users/codeai/Develop/logs/redis/db/" # 监控 127.0.0.1:6379 主节点; 2 表示判断主节点失败至少需要 2 个 sentinel 节点同意 sentinel monitor mymaster 127.0.0.1 6379 2 # 设置监听的 master 密码 sentinel auth-pass mymaster 1234 # 30 秒内 ping 失败, sentinel 则认为 master 不可用 sentinel down-after-milliseconds mymaster 30000 # 在发生 failover 主备切换时,这个选项指定了最多可以有多少个 slave 同时对新的 master 进行同步 sentinel parallel-syncs mymaster 1 # 如果在该时间(ms)内未能完成 failover 操作,则认为该 failover 失败 sentinel failover-timeout mymaster 180000 ``` 其他节点只是端口不同 **7. 启动 sentinel 节点** ``` # 方式一 redis-sentinel redis-sentinel-26379.conf; redis-sentinel redis-sentinel-26380.conf; redis-sentinel redis-sentinel-26381.conf # 方式二 redis-server redis-sentinel-26379.conf --sentinel; redis-server redis-sentinel-26380.conf --sentinel; redis-server redis-sentinel-26381.conf --sentinel; ``` ![20241229154732_5ay2F1RC.webp](https://cdn.dong4j.site/source/image/20241229154732_5ay2F1RC.webp) **8. 确认关系** ``` redis-cli -h 127.0.0.1 -p 26379 info sentinel # Sentinel sentinel_masters:1 sentinel_tilt:0 sentinel_running_scripts:0 sentinel_scripts_queue_length:0 sentinel_simulate_failure_flags:0 master0:name=mymaster,status=ok,address=127.0.0.1:6379,slaves=2,sentinels=3 ``` ## 通过 Jedis 操作 Redis [https://github.com/dong4j/redis-toolkit](https://github.com/dong4j/redis-toolkit) 包含 redis 配置文件, 搭建方式与单元测试 ### Jedis 操作 Redis 的 三种方式 #### 单机模式 直接通过 JedisPool 操作 Redis ```java Jedis jedis = null; try { // 从连接池获取一个 Jedis 实例 jedis = jedisPool.getResource(); jedis.set(key, value); log.info(jedis.get(key)); } catch (Exception e) { log.error("set error", e); } finally { if (null != jedis) { // 释放资源还给连接池 jedis.close(); } } ``` ```java try(Jedis jedis = jedisPool.getResource()) { jedis.set(key, value); log.info(jedis.get(key)); } catch (Exception e) { log.error("set error", e); } ``` #### 分片模式(ShardedJedis) 使用一致性哈希算法, 将 key 存储在对应实例中 ```java try(ShardedJedis jedis = shardedJedisPool.getResource();) { jedis.set(key, value); log.info(jedis.get(key)); } catch (Exception e) { log.error("set error", e); } ``` #### 集群模式(BinaryJedisCluster) 需要 Redis 3.0 以上才自带集群功能 ### 集成 Jedis 的两种方式 1. 使用 spring-data-redis 集成 redis, 使用 RedisTemplate 或者 JedisConnectionFactory 获取 jedis 操作 Redis 2. 直接使用 JedisPool , ShardedJedisPool, ShardedJedisPool, ShardedJedisSentinelPool 获取 jedis 操作 Redis #### spring-data-redis #### 原生 jedis ##### JedisPool ##### ShardedJedisPool #### 高可用的 Redis ##### JedisSentinelPool ##### ShardedJedisSentinelPool 代码详见: [https://github.com/dong4j/redis-toolkit](https://github.com/dong4j/redis-toolkit) ### 问题 ``` All sentinels down, cannot determine where is mymaster master is running... ``` sentinel 安全模式默认是打开的, 又因为没有绑定可以访问的 ip 和设置访问密码,就不允许从外部访问; redis 内部把 127 的地址转换成了 192.168.2.101 了,也就是你的本机的 ip 地址。所以访问 192 的地址就相当于从外部访问哨兵; ``` Cannot get master address from sentinel running @ 192.168.2.101:26379. Reason: redis.clients.jedis.exceptions.JedisDataException: DENIED Redis is running in protected mode because protected mode is enabled, no bind address was specified, no authentication password is requested to clients. In this mode connections are only accepted from the loopback interface. If you want to connect from external computers to Redis you may adopt one of the following solutions: 1) Just disable protected mode sending the command 'CONFIG SET protected-mode no' from the loopback interface by connecting to Redis from the same host the server is running, however MAKE SURE Redis is not publicly accessible from internet if you do so. Use CONFIG REWRITE to make this change permanent. 2) Alternatively you can just disable the protected mode by editing the Redis configuration file, and setting the protected mode option to 'no', and then restarting the server. 3) If you started the server manually just for testing, restart it with the '--protected-mode no' option. 4) Setup a bind address or an authentication password. NOTE: You only need to do one of the above things in order for the server to start accepting connections from the outside.. Trying next one ``` ## [Shiro 引入后 Springboot 项目的循环依赖解决方案](https://blog.dong4j.site/posts/62ae7a01.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 最近在使用 Spingboot 做项目的时候,在引入 shiro 后,启动项目一直报错 ```lua Error creating bean with name 'debtServiceImpl': Bean with name 'debtServiceImpl' has been injected into other beans [repayBillServiceImpl,investServiceImpl,receiveBillServiceImpl] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example. ``` 后来在网上找了半天说是依赖循环,检查了一下代码,确实存在循环依赖的现象,但是项目快要上线,再去改代码逻辑是来不及了,于是各种找解决方案,终于算是找到了。 首先说一下什么是依赖循环,比如:我现在有一个 ServiceA 需要调用 ServiceB 的方法,那么 ServiceA 就依赖于 ServiceB,那在 ServiceB 中再调用 ServiceA 的方法,就形成了循环依赖。Spring 在初始化 bean 的时候就不知道先初始化哪个 bean 就会报错。 ``` public class ClassA { @Autowired ClassB classB; } public class ClassB { @Autowired ClassA classA; } ``` 那如何解决循环依赖,当然最好的方法是重构你的代码,进行解耦,但是重构不是一时的事情,那就使用下面的方法: 第一种: ```xml ``` 在你的配置文件中,在互相依赖的两个 bean 的任意一个加上 lazy-init 属性。 第二种: ```java @Autowired @Lazy private ClassA classA; @Autowired @Lazy private ClassB classB; ``` 在你注入 bean 时,在互相依赖的两个 bean 上加上@Lazy 注解也可以。 以上两种方法都能延迟互相依赖的其中一个 bean 的加载,从而解决循环依赖的问题。 ## [SSH Config 那些你所知道和不知道的事](https://blog.dong4j.site/posts/ff561974.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) SSH(Secure Shell)是什么?是一项创建在应用层和传输层基础上的安全协议, 为计算机上的 Shell(壳层)提供安全的传输和使用环境. 也是专为远程登录会话和其他网络服务提供安全性的协议. 它能够有效防止远程管理过程中的信息泄露问题. 通过 SSH 可以对所有传输的数据进行加密, 也能够防止 DNS 欺骗和 IP 欺骗. 具体生成 SSH Key 方式请参考: [Github ssh key 生成, 免密登录服务器方法](https://deepzz.com/post/github-generate-ssh-key.html). 这里以 `id_ecdsa`(私钥) 和 `id_ecdsa.pub`(公钥) 为例. 本篇文章主要介绍 SSH 相关的使用技巧. 通过对 `~/.ssh/config` 文件的配置你可以大大简化 SSH 相关的操作, 如: ```bash Host example # 关键词 HostName example.com # 主机地址 User root # 用户名 # IdentityFile ~/.ssh/id_ecdsa # 认证文件 # Port 22 # 指定端口 ``` 通过执行 `$ ssh example` 我就可以登录我的服务器. 而不需要敲更多的命令 `$ ssh root@example.com`. 又如我们想要向服务器传文件 `$ scp a.txt example:/home/user_name`. 比以前方便多了. 更过相关帮助文档请参考 `$ man ssh_config 5`. ### 配置项说明 SSH 的配置文件有两个: ```bash $ ~/.ssh/config # 用户配置文件 $ /etc/ssh/ssh_config # 系统配置文件 ``` 下面来看看常用的配置参数. **Host** 用于我们执行 SSH 命令的时候如何匹配到该配置. - `*`, 匹配所有主机名. - `*.example.com`, 匹配以 .example.com 结尾. - `!*.dialup.example.com,*.example.com`, 以 ! 开头是排除的意思. - `192.168.0.?`, 匹配 192.168.0.[0-9] 的 IP. **AddKeysToAgent** 是否自动将 key 加入到 `ssh-agent`, 值可以为 no(default)/confirm/ask/yes. 如果是 yes, key 和密码都将读取文件并以加入到 agent , 就像 `ssh-add`. 其他分别是询问、确认、不加入的意思. 添加到 ssh-agent 意味着将私钥和密码交给它管理, 让它来进行身份认证. **AddressFamily** 指定连接的时候使用的地址族, 值可以为 any(default)/inet(IPv4)/inet6(IPv6). **BindAddress** 指定连接的时候使用的本地主机地址, 只在系统有多个地址的时候有用. 在 UsePrivilegedPort 值为 yes 的时候无效. **ChallengeResponseAuthentication** 是否响应支持的身份验证 chanllenge, yes(default)/no. **Compression** 是否压缩, 值可以为 no(default)/yes. **CompressionLevel** 压缩等级, 值可以为 1(fast)-9(slow). 6(default), 相当于 gzip. **ConnectionAttempts** 退出前尝试连接的次数, 值必须为整数, 1(default). **ConnectTimeout** 连接 SSH 服务器超时时间, 单位 s, 默认系统 TCP 超时时间. **ControlMaster** 是否开启单一网络共享多个 session, 值可以为 no(default)/yes/ask/auto. 需要和 ControlPath 配合使用, 当值为 yes 时, ssh 会监听该路径下的 control socket, 多个 session 会去连接该 socket, 它们会尽可能的复用该网络连接而不是重新建立新的. **ControlPath** 指定 control socket 的路径, 值可以直接指定也可以用一下参数代替: - %L 本地主机名的第一个组件 - %l 本地主机名(包括域名) - %h 远程主机名(命令行输入) - %n 远程原始主机名 - %p 远程主机端口 - %r 远程登录用户名 - %u 本地 ssh 正在使用的用户名 - %i 本地 ssh 正在使用 uid - %C 值为 %l%h%p%r 的 hash 请最大限度的保持 ControlPath 的唯一. 至少包含 %h, %p, %r(或者 %C). **ControlPersist** 结合 ControlMaster 使用, 指定连接打开后后台保持的时间. 值可以为 no/yes / 整数, 单位 s. 如果为 no, 最初的客户端关闭就关闭. 如果 yes/0, 无限期的, 直到杀死或通过其它机制, 如: ssh -O exit. **GatewayPorts** 指定是否允许远程主机连接到本地转发端口, 值可以为 no(default)/yes. 默认情况, ssh 为本地回环地址绑定了端口转发器. **HostName** 真实的主机名, 默认值为命令行输入的值(允许 IP). 你也可以使用 %h, 它将自动替换, 只要替换后的地址是完整的就 ok. **IdentitiesOnly** 指定 ssh 只能使用配置文件指定的 identity 和 certificate 文件或通过 ssh 命令行通过身份验证, 即使 ssh-agent 或 PKCS11Provider 提供了多个 identities. 值可以为 no(default)/yes. **IdentityFile** 指定读取的认证文件路径, 允许 DSA, ECDSA, Ed25519 或 RSA. 值可以直接指定也可以用一下参数代替: - %d, 本地用户目录 ~ - %u, 本地用户 - %l, 本地主机名 - %h, 远程主机名 - %r, 远程用户名 **LocalCommand** 指定在连接成功后, 本地主机执行的命令(单纯的本地命令). 可使用 %d, %h, %l, %n, %p, %r, %u, %C 替换部分参数. 只在 PermitLocalCommand 开启的情况下有效. **LocalForward** 指定本地主机的端口通过 ssh 转发到指定远程主机. 格式: LocalForward [bind_address:]post host:hostport, 支持 IPv6. **PasswordAuthentication** 是否使用密码进行身份验证, yes(default)/no. **PermitLocalCommand** 是否允许指定 LocalCommand, 值可以为 no(default)/yes. **Port** 指定连接远程主机的哪个端口, 22(default). **ProxyCommand** 指定连接的服务器需要执行的命令. %h, %p, %r 如: ProxyCommand /usr/bin/nc -X connect -x 192.0.2.0:8080 %h %p **User** 登录用户名 ### 相关技巧 #### 管理多组密钥对 有时候你会针对多个服务器有不同的密钥对, 每次通过指定 `-i` 参数也是非常的不方便. 比如你使用 github 和 coding. 那么你需要添加如下配置到 `~/.ssh/config`: ```bash Host github HostName %h.com IdentityFile ~/.ssh/id_ecdsa_github User git Host coding HostName git.coding.net IdentityFile ~/.ssh/id_rsa_coding User git ``` 当你克隆 coding 上的某个仓库时: ```bash # 原来 $ git clone git@git.coding.net:deepzz/test.git # 现在 $ git clone coding:deepzz/test.git ``` #### vim 访问远程文件 vim 可以直接编辑远程服务器上的文件: ```bash $ vim scp://root@example.com//home/centos/docker-compose.yml $ vim scp://example//home/centos/docker-compose.yml ``` #### 远程服务当本地用 通过 LocalForward 将本地端口上的数据流量通过 ssh 转发到远程主机的指定端口. 感觉你是使用的本地服务, 其实你使用的远程服务. 如远程服务器上运行着 Postgres, 端口 5432(未暴露端口给外部). 那么, 你可以: ```bash Host db HostName db.example.com LocalForward 5433 localhost:5432 ``` 当你连接远程主机时, 它会在本地打开一个 5433 端口, 并将该端口的流量通过 ssh 转发到远程服务器上的 5432 端口. 首先, 建立连接: ```bash $ ssh db ``` 之后, 就可以通过 Postgres 客户端连接本地 5433 端口: ```bash $ psql -h localhost -p 5433 orders ``` #### 多连接共享 什么是多连接共享?在你打开多个 shell 窗口时需要连接同一台服务器, 如果你不想每次都输入用户名, 密码, 或是等待连接建立, 那么你需要添加如下配置到 `~/.ssh/config`: ```bash ControlMaster auto ControlPath /tmp/%r@%h:%p ``` #### 禁用密码登录 如果你对服务器安全要求很高, 那么禁用密码登录是必须的. 因为使用密码登录服务器容易受到暴力破解的攻击, 有一定的安全隐患. 那么你需要编辑服务器的系统配置文件 `/etc/ssh/sshd_config`: ```bash PasswordAuthentication no ChallengeResponseAuthentication no ``` #### 关键词登录 为了更方便的登录服务器, 我们也可以省略用户名和主机名, 采用关键词登录. 那么你需要添加如下配置到 `~/.ssh/config`: ```bash Host deepzz # 别名 HostName deepzz.com # 主机地址 User root # 用户名 # IdentityFile ~/.ssh/id_ecdsa # 认证文件 # Port 22 # 指定端口 ``` 那么使用 `$ ssh deepzz` 就可以直接登录服务器了. #### 代理登录 有的时候你可能没法直接登录到某台服务器, 而需要使用一台中间服务器进行中转, 如公司内网服务器. 首先确保你已经为服务器配置了公钥访问, 并开启了 agent forwarding, 那么你需要添加如下配置到 `~/.ssh/config`: ```bash Host gateway HostName proxy.example.com User root Host db HostName db.internal.example.com # 目标服务器地址 User root # 用户名 # IdentityFile ~/.ssh/id_ecdsa # 认证文件 ProxyCommand ssh gateway netcat -q 600 %h %p # 代理命令 ``` 那么你现在可以使用 `$ ssh db` 连接了. ## [内存优化实战:从数据结构选择到GC策略](https://blog.dong4j.site/posts/b2f99cee.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### 内存优化对比 **数据量** > 外呼名单 10 万 > 白名单 100 万 > 黑名单 500 万 **JVM 参数** ``` -verbose:gc -XX:+HeapDumpOnOutOfMemoryError -server -Xms1g -Xmx1g -XX:PermSize=512m -XX:SurvivorRatio=2 -XX:+UseParallelGC ``` #### 优化之前 **对比耗时** ``` 2018-05-31 15:29:49 [INFO]] [pool-1-thread-1] [parseRegulation] 白名单匹配个数 = 25 2018-05-31 15:29:50 [INFO]] [pool-1-thread-1] [parseRegulation] 黑名单匹配个数 = 189 StopWatch '': running time (millis) = 906 ----------------------------------------- ms % Task name ----------------------------------------- 00906 100% 对比 ... diffList.size = 99810 telNos.size = 99810 ``` **内存消耗** 启动时第一次运行解析任务, 并且符合条件的 calloutList 为 99810; 最高使用 `762.5` M ![20241229154732_ky3i189B.webp](https://cdn.dong4j.site/source/image/20241229154732_ky3i189B.webp) 持续运行一段时间, 并且使用相同的号码包进行测试的结果 发生了 OOM ![20241229154732_jpeRRWzH.webp](https://cdn.dong4j.site/source/image/20241229154732_jpeRRWzH.webp) #### 优化之后 **对比耗时** ``` 2018-05-31 15:46:45 [INFO]] [pool-1-thread-1] [dealWhiteAndBlackList] 白名单匹配个数 = 25 2018-05-31 15:46:45 [INFO]] [pool-1-thread-1] [dealWhiteAndBlackList] 白名单匹配个数 = 189 StopWatch '': running time (millis) = 109 ----------------------------------------- ms % Task name ----------------------------------------- 00109 100% 对比 ... diffList.size = 99810 telNos.size = 99810 ``` **内存消耗** 启动时第一次运行解析任务, 并且符合条件的 calloutList 为 99810; 最高 `728` M ![20241229154732_898V9sAS.webp](https://cdn.dong4j.site/source/image/20241229154732_898V9sAS.webp) 持续运行一段时间, 并且使用相同的号码包进行测试的结果 ![20241229154732_sb34vgzR.webp](https://cdn.dong4j.site/source/image/20241229154732_sb34vgzR.webp) #### 优化方案 1. 使用 BloomFilter 代替 DataCache 来存储黑白名单; 2. 及时清理占用大内存的临时变量; ##### 布隆过滤器 ![20241229154732_PYx6qUxn.webp](https://cdn.dong4j.site/source/image/20241229154732_PYx6qUxn.webp) **简介:** 是一个很长的二进制向量和一系列随机映射函数. 布隆过滤器可以用于检索一个元素是否在一个集合中. 它的优点是空间效率和查询时间都远远超过一般的算法, 缺点是有一定的误识别率和删除困难. **原理:** 当一个元素被加入集合时, 通过 K 个散列函数将这个元素映射成一个位数组中的 K 个点, 把它们置为 1. 检索时, 我们只要看看这些点是不是都是 1 就(大约)知道集合中有没有它了: 如果这些点有任何一个 0, 则被检元素一定不在;如果都是 1, 则被检元素很可能在. **优点:** 相比于其它的数据结构, 布隆过滤器在空间和时间方面都有巨大的优势. 布隆过滤器存储空间和插入/查询时间都是常数(O(k)). 而且它不存储元素本身, 在某些对保密要求非常严格的场合有优势. **缺点**: 一定的误识别率和删除困难. #### 开发建议 程序的运行会直接影响系统环境的变化, 从而影响 GC 的触发. 若不针对 GC 的特点进行设计和编码, 就会出现内存驻留等一系列负面影响. 为了避免这些影响, 基本的原则就是尽可能地减少垃圾和减少 GC 过程中的开销. 具体措施包括以下几个方面: 1. 不要显式调用 `System.gc()` 此函数建议 JVM 进行主 GC, 虽然只是建议而非一定, 但很多情况下它会触发主 GC, 从而增加主 GC 的频率, 也即增加了间歇性停顿的次数. 2. 尽量减少临时对象的使用 临时对象在跳出函数调用后, 会成为垃圾, 少用临时变量就相当于减少了垃圾的产生. 3. 对象不用时最好显式置为 null 一般而言, 为 null 的对象都会被作为垃圾处理, 所以将不用的对象显式地设为 null, 有利于 GC 收集器判定垃圾, 从而提高了 GC 的效率. 4. 尽量少用静态对象变量 静态变量属于全局变量, 不会被 GC 回收, 它们会一直占用内存. ## [如何实现优雅的重试机制?两种方式大比拼!](https://blog.dong4j.site/posts/2c5b3e3a.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 文/LNAmp(简书作者) 原文链接: [http://www.jianshu.com/p/80c7777d48ad](http://www.jianshu.com/p/80c7777d48ad) ## 解决方案演化 这个问题的技术点在于能够触发重试, 以及重试情况下逻辑有效执行. ### 解决方案一: try-catch-redo 简单重试模式 包装正常上传逻辑基础上, 通过判断返回结果或监听异常决策是否重试, 同时为了解决立即重试的无效执行 (假设异常是有外部执行不稳定导致的), 休眠一定延迟时间重新执行功能逻辑. ```java public void commonRetry(Map dataMap) throws InterruptedException { Map paramMap = Maps.newHashMap(); paramMap.put("tableName", "creativeTable"); paramMap.put("ds", "20160220"); paramMap.put("dataMap", dataMap); boolean result = false; try { result = uploadToOdps(paramMap); if (!result) { Thread.sleep(1000); uploadToOdps(paramMap); // 一次重试 } } catch (Exception e) { Thread.sleep(1000); uploadToOdps(paramMap);// 一次重试 } } ``` ### 解决方案二: try-catch-redo-retry strategy 策略重试模式 述方案还是有可能重试无效, 解决这个问题尝试增加重试次数 retrycount 以及重试间隔周期 interval, 达到增加重试有效的可能性. ```java public void commonRetry(Map dataMap) throws InterruptedException { Map paramMap = Maps.newHashMap(); paramMap.put("tableName", "creativeTable"); paramMap.put("ds", "20160220"); paramMap.put("dataMap", dataMap); boolean result = false; try { result = uploadToOdps(paramMap); if (!result) { reuploadToOdps(paramMap,1000L,10);// 延迟多次重试 } } catch (Exception e) { reuploadToOdps(paramMap,1000L,10);// 延迟多次重试 } } ``` 方案一和方案二存在一个问题: 正常逻辑和重试逻辑强耦合, 重试逻辑非常依赖正常逻辑的执行结果, 对正常逻辑预期结果被动重试触发, 对于重试根源往往由于逻辑复杂被淹没, 可能导致后续运维对于重试逻辑要解决什么问题产生不一致理解. 重试正确性难保证而且不利于运维, 原因是重试设计依赖正常逻辑异常或重试根源的臆测. ## 优雅重试方案尝试 那有没有可以参考的方案实现正常逻辑和重试逻辑解耦, 同时能够让重试逻辑有一个标准化的解决思路?答案是有: 那就是基于代理设计模式的重试工具, 我们尝试使用相应工具来重构上述场景. ### 尝试方案一: 应用命令设计模式解耦正常和重试逻辑 命令设计模式具体定义不展开阐述, 主要该方案看中命令模式能够通过执行对象完成接口操作逻辑, 同时内部封装处理重试逻辑, 不暴露实现细节, 对于调用者来看就是执行了正常逻辑, 达到解耦的目标, 具体看下功能实现. (类图结构) ![20241229154732_eAN1LD66.webp](https://cdn.dong4j.site/source/image/20241229154732_eAN1LD66.webp) IRetry 约定了上传和重试接口, 其实现类 OdpsRetry 封装 ODPS 上传逻辑, 同时封装重试机制和重试策略. 与此同时使用 recover 方法在结束执行做恢复操作. 而我们的调用者 LogicClient 无需关注重试, 通过重试者 Retryer 实现约定接口功能, 同时 Retryer 需要对重试逻辑做出响应和处理, Retryer 具体重试处理又交给真正的 IRtry 接口的实现类 OdpsRetry 完成. 通过采用命令模式, 优雅实现正常逻辑和重试逻辑分离, 同时通过构建重试者角色, 实现正常逻辑和重试逻辑的分离, 让重试有更好的扩展性. ### 尝试方案二: spring-retry 规范正常和重试逻辑 [spring](http://lib.csdn.net/base/javaee "Java EE知识库")-retry 是一个开源工具包, 目前可用的版本为 1.1.2.RELEASE, 该工具把重试操作模板定制化, 可以设置重试策略和回退策略. 同时重试执行实例保证线程安全, 具体场景操作实例如下: ```java public void upload(final Map map) throws Exception { // 构建重试模板实例 RetryTemplate retryTemplate = new RetryTemplate(); // 设置重试策略, 主要设置重试次数 SimpleRetryPolicy policy = new SimpleRetryPolicy(3, Collections., Boolean> singletonMap(Exception.class, true)); // 设置重试回退操作策略, 主要设置重试间隔时间 FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy(); fixedBackOffPolicy.setBackOffPeriod(100); retryTemplate.setRetryPolicy(policy); retryTemplate.setBackOffPolicy(fixedBackOffPolicy); // 通过 RetryCallback 重试回调实例包装正常逻辑逻辑, 第一次执行和重试执行执行的都是这段逻辑 final RetryCallback retryCallback = new RetryCallback() { // RetryContext 重试操作上下文约定, 统一 spring-try 包装 public Object doWithRetry(RetryContext context) throws Exception { System.out.println("do some thing"); Exception e = uploadToOdps(map); System.out.println(context.getRetryCount()); throw e;// 这个点特别注意, 重试的根源通过 Exception 返回 } }; // 通过 RecoveryCallback 重试流程正常结束或者达到重试上限后的退出恢复操作实例 final RecoveryCallback recoveryCallback = new RecoveryCallback() { public Object recover(RetryContext context) throws Exception { System.out.println("do recory operation"); return null; } }; try { // 由 retryTemplate 执行 execute 方法开始逻辑执行 retryTemplate.execute(retryCallback, recoveryCallback); } catch (Exception e) { e.printStackTrace(); } } ``` 简单剖析下案例代码, RetryTemplate 承担了重试执行者的角色, 它可以设置 SimpleRetryPolicy(重试策略, 设置重试上限, 重试的根源实体), FixedBackOffPolicy(固定的回退策略, 设置执行重试回退的时间间隔). RetryTemplate 通过 execute 提交执行操作, 需要准备 RetryCallback 和 RecoveryCallback 两个类实例, 前者对应的就是重试回调逻辑实例, 包装正常的功能操作, RecoveryCallback 实现的是整个执行操作结束的恢复操作实例. RetryTemplate 的 execute 是线程安全的, 实现逻辑使用 ThreadLocal 保存每个执行实例的 RetryContext 执行上下文. pring-retry 工具虽能优雅实现重试, 但是存在两个不友好设计: 一个是 重试实体限定为 Throwable 子类, 说明重试针对的是可捕捉的功能异常为设计前提的, 但是我们希望依赖某个数据对象实体作为重试实体, 但 Sping-retry 框架必须强制转换为 Throwable 子类. 另一个就是重试根源的断言对象使用的是 doWithRetry 的 Exception 异常实例, 不符合正常内部断言的返回设计. Spring Retry 提倡以注解的方式对方法进行重试, 重试逻辑是同步执行的, 重试的“失败”针对的是 Throwable, 如果你要以返回值的某个状态来判定是否需要重试, 可能只能通过自己判断返回值然后显式抛出异常了. ## Spring 对于 Retry 的抽象 “抽象”是每个程序员必备的素质. 对于资质平平的我来说, 没有比模仿与理解优秀源码更好地进步途径了吧. 为此, 我将其核心逻辑重写了一遍... 下面就看看 Spring Retry 对于“重试”的抽象. ### “重试”逻辑 ```java while(someCondition()) { try{ doSth(); break; } catch(Throwable th) { modifyCondition(); wait(); } } if(stillFail) { doSthWhenStillFail(); } ``` 同步重试代码基本可以表示为上述, 但是 Spring Retry 对其进行了非常优雅地抽象, 虽然主要逻辑不变, 但是看起来却是舒服多了. 主要的接口抽象如下图所示: ![20241229154732_4FkeUK8O.webp](https://cdn.dong4j.site/source/image/20241229154732_4FkeUK8O.webp) - RetryCallback: 封装你需要重试的业务逻辑(上文中的 doSth) - RecoverCallback: 封装在多次重试都失败后你需要执行的业务逻辑 (上文中的 doSthWhenStillFail) - RetryContext: 重试语境下的上下文, 可用于在多次 Retry 或者 Retry 和 Recover 之间传递参数或状态(在多次 doSth 或者 doSth 与 doSthWhenStillFail 之间传递参数) - RetryOperations : 定义了“重试”的基本框架(模板), 要求传入 RetryCallback, 可选传入 RecoveryCallback; - RetryListener: 典型的“监听者”, 在重试的不同阶段通知“监听者”(例如 doSth, wait 等阶段时通知) - RetryPolicy : 重试的策略或条件, 可以简单的进行多次重试, 可以是指定超时时间进行重试(上文中的 someCondition) - BackOffPolicy: 重试的回退策略, 在业务逻辑执行发生异常时. 如果需要重试, 我们可能需要等一段时间 (可能服务器过于繁忙, 如果一直不间隔重试可能拖垮服务器), 当然这段时间可以是 0, 也可以是固定的, 可以是随机的(参见 tcp 的拥塞控制算法中的回退策略). 回退策略在上文中体现为 wait(); - RetryTemplate :RetryOperations 的具体实现, 组合了 RetryListener[], BackOffPolicy, RetryPolicy. ### 尝试方案三: guava-retryer 分离正常和重试逻辑 Guava retryer 工具与 spring-retry 类似, 都是通过定义重试者角色来包装正常逻辑重试, 但是 Guava retryer 有更优的策略定义, 在支持重试次数和重试频度控制基础上, 能够兼容支持多个异常或者自定义实体对象的重试源定义, 让重试功能有更多的灵活性. Guava Retryer 也是线程安全的, 入口调用逻辑采用的是 [Java](http://lib.csdn.net/base/javase "Java SE知识库").util.concurrent.Callable 的 call 方法, 示例代码如下: ```java public void uploadOdps(final Map map) { // RetryerBuilder 构建重试实例 retryer, 可以设置重试源且可以支持多个重试源, 可以配置重试次数或重试超时时间, 以及可以配置等待时间间隔 Retryer retryer = RetryerBuilder. newBuilder() .retryIfException().// 设置异常重试源 retryIfResult(new Predicate() {// 设置自定义段元重试源, @Override public boolean apply(Boolean state) {// 特别注意: 这个 apply 返回 true 说明需要重试, 与操作逻辑的语义要区分 return true; } }) .withStopStrategy(StopStrategies.stopAfterAttempt(5))// 设置重试 5 次, 同样可以设置重试超时时间 .withWaitStrategy(WaitStrategies.fixedWait(100L, TimeUnit.MILLISECONDS)).build();// 设置每次重试间隔 try { // 重试入口采用 call 方法, 用的是 java.util.concurrent.Callable 的 call 方法, 所以执行是线程安全的 boolean result = retryer.call(new Callable() { @Override public Boolean call() throws Exception { try { // 特别注意: 返回 false 说明无需重试, 返回 true 说明需要继续重试 return uploadToOdps(map); } catch (Exception e) { throw new Exception(e); } } }); } catch (ExecutionException e) { } catch (RetryException ex) { } } ``` 示例代码原理分析: RetryerBuilder 是一个 factory 创建者, 可以定制设置重试源且可以支持多个重试源, 可以配置重试次数或重试超时时间, 以及可以配置等待时间间隔, 创建重试者 Retryer 实例. RetryerBuilder 的重试源支持 Exception 异常对象 和自定义断言对象, 通过 retryIfException 和 retryIfResult 设置, 同时支持多个且能兼容. RetryerBuilder 的等待时间和重试限制配置采用不同的策略类实现, 同时对于等待时间特征可以支持无间隔和固定间隔方式. Retryer 是重试者实例, 通过 call 方法执行操作逻辑, 同时封装重试源操作. ## 优雅重试共性和原理 1. 正常和重试优雅解耦, 重试断言条件实例或逻辑异常实例是两者沟通的媒介. 2. 约定重试间隔, 差异性重试策略, 设置重试超时时间, 进一步保证重试有效性以及重试流程稳定性. 3. 都使用了命令设计模式, 通过委托重试对象完成相应的逻辑操作, 同时内部封装实现重试逻辑. 4. Spring-tryer 和 guava-tryer 工具都是线程安全的重试, 能够支持并发业务场景的重试逻辑正确性. ## 优雅重试适用场景 1. 功能逻辑中存在不稳定依赖场景, 需要使用重试获取预期结果或者尝试重新执行逻辑不立即结束. 比如远程接口访问, 数据加载访问, 数据上传校验等等. 2. 对于异常场景存在需要重试场景, 同时希望把正常逻辑和重试逻辑解耦. 3. 对于需要基于数据媒介交互, 希望通过重试轮询检测执行逻辑场景也可以考虑重试方案. ## [定制你的Spring Boot:探索maven-assembly-plugin](https://blog.dong4j.site/posts/d16c10eb.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## fat jar ## tomcat ## 自定义打包部署 暴露配置文件和静态资源文件 ### 前言 SpringBoot 默认有 2 种打包方式,一种是直接打成 jar 包,直接使用 java -jar 跑起来,另一种是打成 war 包,移除掉 web starter 里的容器依赖,然后丢到外部容器跑起来。 第一种方式的缺点是整个项目作为一个 jar,部署到生产环境中一旦有配置文件需要修改,则过程比较麻烦 linux 下可以使用 vim jar 包,找到配置文件修改后再保存 window 下需要使用 解压缩软件打开 jar 再找到配置文件,修改后替换更新 第二种方式的缺点是需要依赖外部容器,这无非多引入了一部分,很多时候我们很不情愿这么做 > spring boot 项目启动时 指定配置有 2 种方式:一种是启动时修改配置参数,像 java -jar xxxx.jar –server.port=8081 这样;另外一种是 指定外部配置文件加载,像 java -jar xxxx.jar -Dspring.config.location=applixxx.yml 这样 ### 目标 我们希望打包成 tomcat 或者 maven 那样的软件包结构,即 ``` --- bin --- start.sh --- stop.sh --- restart.sh --- start.bat --- stop.bat --- restart.bat --- boot --- xxxx.jar --- lib --- conf --- logs --- README.md --- LICENSE ``` - `bin`  目录放一些我们程序的启动停止脚本 - `boot`  目录放我们自己的程序包 - `lib`  目录是我们程序的依赖包 - `conf`  目录是项目的配置文件 - `logs`  目录是程序运行时的日志文件 - `README.md`  使用说明 - `LICENSE`  许可说明 ### 准备 - maven-jar-plugin : 打包我们写的程序包和所需的依赖包,并指定入口类,依赖包路径和 classpath 路径,其实就是在 MANIFEST.MF 这个文件写入相应的配置 - maven-assembly-plugin : 自定义我们打包的文件目录的格式 ### pom.xml 配置 ```xml org.apache.maven.plugins maven-jar-plugin false com.ahtsoft.AhtsoftBigdataWebApplication true ../lib/ ../conf/resources/ static/** *.yml org.apache.maven.plugins maven-assembly-plugin src/main/assembly/assembly.xml package single ``` 1. 将 spring boot 默认的打包方式 spring-boot-maven-plugin 去掉,使用现在的打包方式 2. maven-jar-plugin 配置中,制定了入口类,addClasspath 配置将所需的依赖包单独打包,依赖包打的位置在 lib 目录底下,在 MANIFEST.MF 这个文件写入相应的配置 3. 配置了 classpath 在 /conf/resources/ ,这个和后面的 assembly.xml 要相对应 4. 我单独把 spring boot 的配置文件 yml 文件 和 静态资源目录 static 单独拎了出来,在我们的源码包中并没有打进去,而是交给 assembly.xml 来单独打到一个独立的文件 conf 文件下 5. 这也是照应了 前面为什么要设置 classpath 为 /conf/resources/ 下面重要的是 assembly.xml 配置文件了,这个文件才是把我们的程序打成标准的目录结构 ### assembly.xml ```xml assembly tar.gz ${project.artifactId}-${project.version}/ target/${project.artifactId}-${project.version}.jar boot/ ${project.artifactId}-${project.version}.jar ./ ./ *.txt *.md src/main/bin bin/ *.sh *.cmd 0755 src/main/resources/static conf/resources/static/ * src/main/resources conf/resources *.properties *.conf *.yml true lib runtime *:* ${groupId}:${artifactId} org.springframework.boot:spring-boot-devtools ``` - 将最终的程序包打成 tar.gz ,当然也可以打成其他的格式如 zip,rar 等,fileSets 里面指定我们源码里的文件和路径打成标准包相对应的目录 - 需要注意的是,在最终的依赖库 lib 下 去掉我们的程序和开发时 spring boot 的热部署依赖 spring-boot-devtools,否则的会出问题 - 代码里的启动和停止脚本要赋予权限,否则在执行的时候可能提示权限的问题 ## jar 启动分离依赖 lib 和配置 ### 前言 - 先前发布 boot 项目的时候,改动一点东西,就需要将整个项目重新打包部署,十分不便,故把依赖 lib 从项目分离出来,每次部署只需要发布代码即可。 ### 半自动化步骤 #### 1. 更换 maven 的 jar 打包插件 - 先前使用的是 spring-boot-maven-plugin 来打包 - 这个插件会将项目所有的依赖打入 BOOT-INF/lib 下 - 替换为 maven-jar-plugin - addClasspath 表示需要加入到类构建路径 - classpathPrefix 指定生成的 Manifest 文件中 Class-Path 依赖 lib 前面都加上路径,构建出 lib/xx.jar ```xml org.apache.maven.plugins maven-jar-plugin ${maven.jar.plugin.version} true lib/ com.xxx.Start ``` #### 2. 拷贝依赖到 jar 外面的 lib 目录 ```xml org.apache.maven.plugins maven-dependency-plugin ${maven.dependency.plugin.version} copy-lib prepare-package copy-dependencies ${project.build.directory}/lib false false true compile ``` #### 3. 在和 jar 包同级的目录下新建一个 config 目录,放入 application.yml 文件 - 这里可能有小伙伴有疑问了,打包的 jar 里面不是应该有 application.yml 文件吗,这里为什么再放一份? - 这是因为 boot 读取配置有一个优先级,放在 jar 包外面 config 目录优先级最高,主要是便于从外部修改配置,而不是改 jar 包中的 application.yml 文件。优先级如下: - 当前目录的 config 目录下 - 当前目录 - classpath 的 config 目录下 - classpath 的根目录 #### 4. 注意一个依赖的坑, - 笔者多次通过 java -jar 的方式启动项目总是报如下错误: ``` ClassNotFoundException: org.springframework.boot.SpringApplication ``` - 后来发现时一个依赖的问题,问题详情可以见这个博客: -[SpringBoot 使用 yaml 作为配置文件之坑](https://my.oschina.net/bfleeee/blog/879209) ```xml org.yaml snakeyaml 1.21 ``` #### 5. 愉快的启动项目 - 加入–debug 可以让你可以看到比较详细的启动日志 ``` java -jar xxx-1.0.0.jar --debug ``` ### 全自动化步骤 - 前面介绍的步骤中,需要手动的拷贝 application.yml 文件,并且 jar 包内外都存在配置,总感觉怪怪的(偷笑…)。这里引入一种自动化配置,将所有东西打成 zip 文件,直接发布到服务目录,解压后,即可启动。 #### 自动化步骤 1 - 还是同上面步骤 1,2 所示,指定打包插件和拷贝依赖的插件。 #### 自动化步骤 2 - 排除 resources 下面的 yml(因为我们需要把它放在 jar 外部,不能让 jar 打包插件将其打入 jar 包 classpath 下去) ```xml src/main/resources **/application.yml ``` #### 自动化步骤 3,使用 maven-assembly-plugin 自定义打包 - 具体打包详情在 assembly.xml 配置中指定 ```xml maven-assembly-plugin false src/main/resources/assembly.xml make-assembly package single ``` - assembly.xml 具体配置如下: - 将 application.yml 放在外部 config 目录下 - 所有依赖打成 zip 压缩包 ```xml package zip true ${basedir}/src/main/resources *.yml true ${file.separator}config src/main/resources/runScript ${file.separator}bin ${project.build.directory}/lib ${file.separator}lib *.jar ${project.build.directory} ${file.separator} *.jar ``` #### 自动化步骤 4,解压 zip,启动 - 美滋滋的自动化 ## [Java打包进化论:Maven Jar的多种打包方式大揭秘](https://blog.dong4j.site/posts/79fceeeb.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 无依赖其他任何 jar ```xml org.apache.maven.plugins maven-jar-plugin 2.4 true lib/ com.think.TestMain ``` 运行:mvn clean package,在 target 中找到打包出来的,命令后运行 java -jar xxx.jar 即可,但是如果程序有依赖其他包,比如程序依赖 jdbc 去查询 db,这时候再执行就会出现找不到 jdbc 依赖,因为我们并没有将依赖包打进去 ## 解决依赖其他包时,可执行 jar 的打包 ```xml org.apache.maven.plugins maven-assembly-plugin 2.3 false jar-with-dependencies com.think.TestMain make-assembly package assembly ``` 但以上方式用的比较少,因为我们依赖的 jar,也会打进到我们最终生成的 jar,这样不太好,假如你生成的 jar 要给别人使用,最好给一个纯净的。 一般用 assembly 会再用他另外一个功能,将我们的 jar 归档,打包成一个 zip 2、打成一个 zip 包,发布项目的时候,将 zip 包 copy 到服务器上,直接 unzip xxx.zip,里面包含要运行到 jar 以及依赖的 lib,还有配置的 config 文件,即可直接启动服务 ```xml src/main/resources ${project.build.directory}/classes org.apache.maven.plugins maven-compiler-plugin 1.6 1.6 UTF-8 org.apache.maven.plugins maven-jar-plugin 2.4 false true lib/ com.think.TestMain org.apache.maven.plugins maven-assembly-plugin 2.4 src/main/assembly/assembly.xml make-assembly package single ``` 还有一个重要的文件,名字可以随便起,我上面用的是 src/main/assembly/assembly.xml ```xml bin false zip false false ${deploy.dir}/classes/ /conf *.xml *.properties ${project.build.directory} *.jar ``` 最终执行命令:mvn clean package,出来的是这样的 ![20241229154732_oM5QjCVp.webp](https://cdn.dong4j.site/source/image/20241229154732_oM5QjCVp.webp) ![20241229154732_PqvHi7bi.webp](https://cdn.dong4j.site/source/image/20241229154732_PqvHi7bi.webp) 解压 zip 包,我们看到我们想要的,good 3、还有一种打包方式,上面相当于把我们想要的东西打成一个 zip 包,全部放到一起,看着整洁好看,但有点繁琐,我们其实可以用另外一个插件来完成,不打包,即看到上图解压后的文件 ```xml 4.0.0 com.test myTestJar 1.0-SNAPSHOT jar myTestJar http://maven.apache.org ./target/ UTF-8 junit junit 3.8.1 test com.alibaba druid 1.0.9 mysql mysql-connector-java 5.1.32 myTest src/main/java src/main/resources ${project.build.directory} org.apache.maven.plugins maven-compiler-plugin lib 1.6 1.6 UTF-8 org.apache.maven.plugins maven-jar-plugin true lib/ com.think.TestMain org.apache.maven.plugins maven-dependency-plugin copy package copy-dependencies ${project.build.directory}/lib true true org.apache.maven.plugins maven-resources-plugin 2.3 UTF-8 maven-source-plugin 2.1 true UTF-8 compile jar ``` 这里采用的是 maven-dependency-plugin 插件,进行资源的 copy。 4、 ```xml ${project.build.directory}/classes src/main/resources true **/*.xml org.apache.maven.plugins maven-compiler-plugin 3.0 1.6 1.6 UTF-8 org.apache.maven.plugins maven-shade-plugin 2.0 package shade com.think.TestMain true executable ``` 这种方式打出来是柔和到一起,成为一个 jar, ![20241229154732_BzBPvldD.webp](https://cdn.dong4j.site/source/image/20241229154732_BzBPvldD.webp) 可以直接 java -jar xxx.jar 运行。 我们可以根据不同需要来打包,如果暴露给外面,可以采用第 4 种,如果是自己公司项目打包,建议 2,3 种,因为有时候只是改了个配置文件,不需要打包,直接把配置文件复制进去即可 ## [IntelliJ IDEA Git操作指南:轻松管理代码分支与合并](https://blog.dong4j.site/posts/22c0ef08.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 在 IDEA 中操作 Git GitLab 中有一个 git-branch-test 的项目 ![20241229154732_go0zdZYO.webp](https://cdn.dong4j.site/source/image/20241229154732_go0zdZYO.webp) 处于 master 分支 本地项目目录: ![20241229154732_36zcZdNW.webp](https://cdn.dong4j.site/source/image/20241229154732_36zcZdNW.webp) 所在分支: ![20241229154732_67TDHI3s.webp](https://cdn.dong4j.site/source/image/20241229154732_67TDHI3s.webp) 一般的工作流程: 1. master 作为主分支, 一般都是用来发布最终版本的分支 2. 当实现一个新需求时, 需要创建一个分支, 在新创建的分支上进行开发 这是已经实现的功能, 并且已经发布到 gitlab 服务器上的分支 (master): ```java public class HelloWorld { public static void main(String[] args) { String s = JOptionPane.showInputDialog("请输入一个整数"); int a = Integer.parseInt(s); System.out.println(a); if (isOdd(a)) System.out.println("奇数"); else { System.out.println("偶数"); } } /** * Is odd boolean. * 检测奇偶性 * @param i the * @return the boolean */ public static boolean isOdd(int i) { return i % 2 == 1; } } ``` 现在有一个新功能需要开发: 增加一个交换 2 个参数的值的方法 因为是新的需求, 我们不能直接在 master 分支上修改, 必须新建一个分支, 功能完成后经过测试才能正式提交到主分支上 ### 新建分支 **IDEA 设置** 一次就好 1. 设置 tasks ![20241229154732_A2F91Ijb.webp](https://cdn.dong4j.site/source/image/20241229154732_A2F91Ijb.webp) token 登录自己的 gitlab 进行申请 User Settings --> Access Tokens 设置成功后, IDEA 右上角会出现 task 下拉选择框 ![20241229154732_KKTwZLnE.webp](https://cdn.dong4j.site/source/image/20241229154732_KKTwZLnE.webp) 2. 新建分支 ![20241229154732_ZgMfkZpA.webp](https://cdn.dong4j.site/source/image/20241229154732_ZgMfkZpA.webp) "ok" 之后 自动切换到 sprint1 分支 开始新需求的开发 ```java public class HelloWorld { public static void main(String[] args) { String s = JOptionPane.showInputDialog("请输入一个整数"); int input = Integer.parseInt(s); System.out.println(input); if (isOdd(input)) System.out.println("奇数"); else { System.out.println("偶数"); } Integer[] value = {100,1}; switchValue(value); System.out.println("value[0] = " + value[0] + " \n" + "value[1] = " + value[1]); } /** * Is odd boolean. * 检测奇偶性 * @param i the * @return the boolean */ public static boolean isOdd(int i) { return i % 2 == 1; } /** * Switch value. * 交换值 * @param value the value */ public static void switchValue(Integer[] value){ // todo 未完成 } } ``` 假设此时,你突然接到一个电话说有个很严重的问题需要紧急修补,那么可以按照下面的方式处理: 1. 提交当前未完成的工作到本地工作区 2. 返回到 master 分支。 3. 为这次紧急修补建立一个新分支,并在其中修复问题。 4. 通过测试后,回到 master 分支,将修补分支合并进来,然后再推送到 gitlab 服务器上。 5. 切换到之前实现新需求的分支,继续工作。 这里使用 IDEA 自带工具进行分支的创建以及切换 #### 使用 IDEA 创建分支 先提交当前未完成的工作到本地工作区 ![20241229154732_gb79w0V1.webp](https://cdn.dong4j.site/source/image/20241229154732_gb79w0V1.webp) 然后切换到 master **创建分支** ![20241229154732_u5WKcnrc.webp](https://cdn.dong4j.site/source/image/20241229154732_u5WKcnrc.webp) 然后会自动切换到 iss55 分支上 [14908590481527](14908590481527.png) 所有代码都保持为 master 原样 ```java public class HelloWorld { public static void main(String[] args) { String s = JOptionPane.showInputDialog("请输入一个整数"); int a = Integer.parseInt(s); System.out.println(a); if (isOdd(a)) System.out.println("奇数"); else { System.out.println("偶数"); } } /** * Is odd boolean. * 检测奇偶性 * @param i the * @return the boolean */ public static boolean isOdd(int i) { return i % 2 == 1; } } ``` 现在开始修复 bug ```java public class HelloWorld { public static void main(String[] args) { String s = JOptionPane.showInputDialog("请输入一个整数"); int a = Integer.parseInt(s); System.out.println(a); if (isOdd(a)) System.out.println("奇数"); else { System.out.println("偶数"); } } /** * Is odd boolean. * 检测奇偶性 * @param i the * @return the boolean * 2017-03-30 15:35 dong4j * 修复输入负数时都为 false 的 bug */ public static boolean isOdd(int i) { return (i & 1) == 1; } } ``` 测试一番后提交到本地工作区 ![20241229154732_6cJT1Sem.webp](https://cdn.dong4j.site/source/image/20241229154732_6cJT1Sem.webp) 然后回到 master 分支, 把它合并进来, 然后发布到 gitlab **合并分支** IDEA 中操作 ![20241229154732_6KOAi9as.webp](https://cdn.dong4j.site/source/image/20241229154732_6KOAi9as.webp) 合并之后: ![20241229154732_UBlYc4TM.webp](https://cdn.dong4j.site/source/image/20241229154732_UBlYc4TM.webp) 合并之后 master 分支和 iss55 分支指向同一位置 ![20241229154732_bRQ8exRm.webp](https://cdn.dong4j.site/source/image/20241229154732_bRQ8exRm.webp) 然后将修改 push 到 gitlab ![20241229154732_FOsoHqSq.webp](https://cdn.dong4j.site/source/image/20241229154732_FOsoHqSq.webp) push 成功之后, origin 指向最新的一条记录 此时 iss55 问题已被修复, 可以删除掉 IDEA 上操作 ![20241229154732_TlA8aGZ1.webp](https://cdn.dong4j.site/source/image/20241229154732_TlA8aGZ1.webp) **最后回到原来的 sprint1 分支上继续完成没有完成的工作** ![20241229154732_Ra5FyrSQ.webp](https://cdn.dong4j.site/source/image/20241229154732_Ra5FyrSQ.webp) 代码变成: ```java public class HelloWorld { public static void main(String[] args) { String s = JOptionPane.showInputDialog("请输入一个整数"); int input = Integer.parseInt(s); System.out.println(input); if (isOdd(input)) System.out.println("奇数"); else { System.out.println("偶数"); } Integer[] value = {100,1}; switchValue(value); System.out.println("value[0] = " + value[0] + " \n" + "value[1] = " + value[1]); } /** * Is odd boolean. * 检测奇偶性 * @param i the * @return the boolean */ public static boolean isOdd(int i) { return i % 2 == 1; } /** * Switch value. * 交换值 * @param value the value */ public static void switchValue(Integer[] value){ // todo 未完成 } } ``` 此时可以看到 iss55 分支修改的代码在 sprint1 分支中并没有改变 不用担心之前 iss55 分支的修改内容尚未包含到 sprint1 中来。 如果确实需要纳入此次修补,可以用 git merge master 把 master 分支合并到 iss55; 或者等 sprint1 完成之后,再将 sprint1 分支中的更新并入 master。 先完成 sprint1 的开发工作 ```java public class HelloWorld { public static void main(String[] args) { String s = JOptionPane.showInputDialog("请输入一个整数"); int input = Integer.parseInt(s); System.out.println(input); if (isOdd(input)) System.out.println("奇数"); else { System.out.println("偶数"); } Integer[] value = {100,1}; switchValue(value); System.out.println("value[0] = " + value[0] + " \n" + "value[1] = " + value[1]); } /** * Is odd boolean. * 检测奇偶性 * @param i the * @return the boolean */ public static boolean isOdd(int i) { return i % 2 == 1; } /** * Switch value. * 交换值 * @param value the value */ public static void switchValue(Integer[] value){ value[0] = value[0] ^ value[1]; value[1] = value[0] ^ value[1]; value[0] = value[0] ^ value[1]; } } ``` 完成开发, 提交到本地工作空间 ![20241229154732_DKVTDHZ5.webp](https://cdn.dong4j.site/source/image/20241229154732_DKVTDHZ5.webp) **合并分支** 在问题 sprint1 相关的工作完成之后,可以合并回 master 分支。实际操作同前面合并 iss55 分支差不多,只需回到 master 分支,运行 git merge 命令指定要合并进来的分支: IDEA 操作与上面合并操作一样 ![20241229154732_H2dJUBkQ.webp](https://cdn.dong4j.site/source/image/20241229154732_H2dJUBkQ.webp) 最后 push 到 gitlab ## [IDEA插件推荐:Markdown Image Kit,让你的文档配图更简单!](https://blog.dong4j.site/posts/d03a684c.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 一个能在 IDEA 中方便管理图片的插件 > 审核已通过, 可直接在 `plugin` 中搜索 `Markdown Image Kit` 下载 ![20241229154732_2QWVIh3I.webp](https://cdn.dong4j.site/source/image/20241229154732_2QWVIh3I.webp) `Markdown Image Kit` 是一款在 IDEA 中方便高效得管理 Markdown 文档图片的插件. 在 IntelliJ IDEA 中写作(主要是技术文档), 配图成了一个大问题, 我们需要借助其他 APP 来完成这一操作. 为了解决现状, 因此开发了此插件, 能方便得给技术文档配图, 一键上传图片并直接替换为 markdown image 标签, 当然还提供其他一些附属功能. ## Features 1. 一键上传当前文档(所有文档)所有引用图片后自动替换, 体验最简单高效的一波流操作; 2. 支持多个图床, 还支持自定义图床, 没有你上传不了的图片; 3. 一键替换所有标签, 批量处理就是这么简单; 4. 粘贴图片, 复制就是 markdown image mark, 就是这么直接; 5. 图片直接压缩, 减少流量, 提高加载速度, 处处为你着想; 6. 可对一个 markdown image mark 单独处理, 灵活的不要不要的; 7. 图床迁移计划, 免费流量用完了? 迁移到另一个免费图床不就 ok 了; ## 功能演示 ### 复制粘贴直接输出 image mark ![save-image.gif](https://cdn.dong4j.site/source/image/save-image.gif) ### 复制粘贴直接上传到 OSS ![paste-upload.gif](https://cdn.dong4j.site/source/image/paste-upload.gif) ### 复制本地图片直接上传 ![local-image-upload.gif](https://cdn.dong4j.site/source/image/local-image-upload.gif) ### 单个标签上传 ![single-upload.gif](https://cdn.dong4j.site/source/image/single-upload.gif) ### 批量上传 ![multi-upload.gif](https://cdn.dong4j.site/source/image/multi-upload.gif) ### 图床迁移 ![MIK-wu5NqZ.gif](https://cdn.dong4j.site/source/image/MIK-wu5NqZ.gif) ### 标签替换 ![MIK-sPmXWd.gif](https://cdn.dong4j.site/source/image/MIK-sPmXWd.gif) ### 上传到不同图床 ![MIK-3az5GQ.gif](https://cdn.dong4j.site/source/image/MIK-3az5GQ.gif) ## 详细设置 ![20241229154732_RNhkTTKh.webp](https://cdn.dong4j.site/source/image/20241229154732_RNhkTTKh.webp) ### Clipboard 监控 ![20241229154732_WVUADAjI.webp](https://cdn.dong4j.site/source/image/20241229154732_WVUADAjI.webp) 如果开启了 `复制图片到目录`, 则会监控 Clipboard 中是否有 Image 类型的文件. 如果存在 Image 类型的文件, 直接使用 `粘贴` 操作即可将图片保存到指定目录. 如果开启了 `上传图片并替换`, 则在复制时将直接上传 clipboard 中的 Image 并替换为 markdown image mark. > 作为最方便的一个功能, 可以只启用 `上传图片并替换`, 如果需要将图片备份到本地, 也可同时开启上面 2 个功能. ### OSS 设置 ![20241229154732_Xb1WWGDz.webp](https://cdn.dong4j.site/source/image/20241229154732_Xb1WWGDz.webp) 第一版暂时只集成了 `微博图床`, `阿里云`, `七牛云`, 后期会慢慢集成其他图床. 只有正确设置认证信息且测试通过, 当前 OSS 才可用. > 填完认证信息后, 一定要点 `测试` 按钮测试认证信息. > 每次修改了认证信息后也需要进行测试, 不然将不可用. ### 全局设置 ![20241229154732_uu2pXnFj.webp](https://cdn.dong4j.site/source/image/20241229154732_uu2pXnFj.webp) #### 设置默认图床 必须设置默认图床, clipboard 监控上传和 `alt + enter` 都是上传到默认图床. > 第一版将 `微博` 初始化了为默认图床, 后期将使用 `sm.ms`, 不需要认证直接上传 #### 替换标签 **此功能主要是自用** 如果将 markdown image mark 加上 ``, 图片可点击并在新标签中打开. 如果你使用 vuepress 搭建博客, 可以使用 `点击看大图` 设置, 效果就是点击图片后即可放大图片 如果想要上面说的效果, 需要在 `config.js` 的 `head` 节点添加如下配置: ```javascript // 让 Vuepress 支持图片放大功能 [ "script", { src: "https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.slim.min.js", }, ], [ "script", { src: "https://cdnjs.cloudflare.com/ajax/libs/fancybox/3.5.2/jquery.fancybox.min.js", }, ], [ "link", { rel: "stylesheet", type: "text/css", href: "https://cdnjs.cloudflare.com/ajax/libs/fancybox/3.5.2/jquery.fancybox.min.css", }, ]; ``` #### 图片压缩 开启后会在保存图片, 上传图片时压缩. > gif 暂时未支持压缩, 因为 gif 是多帧图, 不好处理 #### 图片重命名 提供 3 中重命名方式, 但是 `微博图床` 不支持, 没有这个 API. 上传到 `微博图床` 后会返回一个 PID, URL 就是通过 PID 解析出来的, 如果加密方式改了, 这个插件就要升级了 (或者做个解析 PID 的 WEB 服务? `Help` 跳转到此页面就是这么做的). ## 其他 就这么几个功能, 用的 SDK 是 2018.3.5 的, 也不打算兼容老版本了, 能用就用吧. 如果你有什么想法或者新需求, 可以说出来听听, 万一实现了呢 😂. 欢迎提交 [issue](https://github.com/dong4j/markdown-image-kit/issues) 要是你在使用这款插件, 记得给我点个 [star](https://github.com/dong4j/markdown-image-kit) ## 修复报错 ``` java.lang.Throwable: Element component@ImageManagerSetting.option.WeiboOssState.option.@name=password probably contains sensitive information (file: ~/Library/Preferences/IntelliJIdea2019.1/options/other.xml) ``` 打开上面的文件, 删除 `` 节点 ![20241229154732_peWpXmpm.webp](https://cdn.dong4j.site/source/image/20241229154732_peWpXmpm.webp) ![20241229154732_dswSMgMG.webp](https://cdn.dong4j.site/source/image/20241229154732_dswSMgMG.webp) ![20241229154732_saTOZqvU.webp](https://cdn.dong4j.site/source/image/20241229154732_saTOZqvU.webp) ![20241229154732_RvypwKMm.webp](https://cdn.dong4j.site/source/image/20241229154732_RvypwKMm.webp) ## [apidoc基础教程:从零开始编写API文档](https://blog.dong4j.site/posts/b0674c16.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## @api 被 @api 标记后, 会解析成 api 文档 ``` @api {method} path [title] ``` | Name | Description | | :------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | method | Request method name: `DELETE`, `GET`, `POST`, `PUT`, ... More info [Wikipedia HTTP-Request_methods](http://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Request_methods) | | path | Request Path. | | titleoptional | A short title. (used for navigation and article header) | Example: ``` /** * @api {get} /user/:id 获取用户详情 */ ``` ![20241229154732_jjly4yCg.webp](https://cdn.dong4j.site/source/image/20241229154732_jjly4yCg.webp) ## @apiDefine 用来定义全局描述, 比如服务器内部错误描述, 权限描述等 ``` @apiDefine name [title] [description] ``` | Name | Description | | :------------------ | :---------------------------------------------------------------------------------------------------------------------------- | | name | Unique name for the block / value. Same name with different `@apiVersion` can be defined. | | titleoptional | A short title. Only used for named functions like `@apiPermission` or `@apiParam (name)`. | | descriptionoptional | Detailed Description start at the next line, multiple lines can be used. Only used for named functions like `@apiPermission`. | Example: ``` /** * @apiDefine MyError * @apiError UserNotFound The id of the User was not found. */ /** * @api {get} /user/:id * [下面引入全局定义] * @apiUse MyError */ ``` ``` /** * @apiDefine admin User access only * This optional description belong to to the group admin. */ /** * @api {get} /user/:id * [下面引入全局定义] * @apiPermission admin */ ``` ![20241229154732_T80hZbeD.webp](https://cdn.dong4j.site/source/image/20241229154732_T80hZbeD.webp) ## @apiDescription 用于定义接口描述信息 ``` @apiDescription text ``` | Name | Description | | :--- | :-------------------------- | | text | Multiline description text. | Example: ``` /** * @api {get} /user/{id} 获取用户详情 * @apiDescription 接口描述信息 * 可以有多行 * @apiUse MyError * @apiPermission admin */ ``` ![20241229154732_p8JZlRiJ.webp](https://cdn.dong4j.site/source/image/20241229154732_p8JZlRiJ.webp) ## @apiError 定义错误的描述信息 ``` @apiError [(group)] [{type}] field [description] ``` | Name | Description | | :------------------ | :----------------------------------------------------------------------------------------------------- | | (group)optional | All parameters will be grouped by this name. Without a group, the default `Error 4xx` is set. | | {type}optional | Return type, e.g. `{Boolean}`, `{Number}`, `{String}`,`{Object}`, `{String[]}` (array of strings), ... | | field | Return Identifier (returned error code). | | descriptionoptional | Description of the field. | ## @apiErrorExample 错误信息示例 ``` @apiErrorExample [{type}] [title] example ``` | Name | Description | | :------------ | :------------------------------------ | | typeoptional | Response format. | | titleoptional | Short title for the example. | | example | Detailed example, multilines capable. | Example: ``` /** * @api {get} /user/{id} 获取用户详情 * @apiDescription 接口描述信息 * 可以有多行 * @apiUse MyError * @apiPermission admin * @apiErrorExample {json} Error-Response: * HTTP/1.1 404 Not Found * { * "error": "UserNotFound" * } */ ``` ![20241229154732_QASOCI5x.webp](https://cdn.dong4j.site/source/image/20241229154732_QASOCI5x.webp) ## @apiExample api 请求示例 ``` @apiExample [{type}] title example ``` | Name | Description | | :----------- | :------------------------------------ | | typeoptional | Code language. | | title | Short title for the example. | | example | Detailed example, multilines capable. | Example: ``` /** * @api {get} /user/{id} 获取用户详情 * @apiDescription 接口描述信息 * 可以有多行 * @apiUse MyError * @apiPermission admin * @apiErrorExample {json} Error-Response: * HTTP/1.1 404 Not Found * { * "error": "UserNotFound" * } * @apiExample {curl} Example usage: * curl -i http://dev.yyyyy.com/test/api/user/1024 */ ``` ![20241229154732_qtKaaxz9.webp](https://cdn.dong4j.site/source/image/20241229154732_qtKaaxz9.webp) ## @apiGroup 定义方法文档块属于哪个组。组将用于生成输出中的主导航。结构定义不需要 `@apigroup` ``` @apiGroup name ``` | Name | Description | | :--- | :------------------------------------------------ | | name | Name of the group. Also used as navigation title. | Example: ``` /** * @api {get} /user/{id} 获取用户详情 * @apiDescription 接口描述信息 * 可以有多行 * @apiGroup User * @apiUse MyError * @apiPermission admin * @apiErrorExample {json} Error-Response: * HTTP/1.1 404 Not Found * { * "error": "UserNotFound" * } * @apiExample {curl} Example usage: * curl -i http://dev.yyyyy.com/test/api/user/1024 */ ``` ![20241229154732_zC1OHZF6.webp](https://cdn.dong4j.site/source/image/20241229154732_zC1OHZF6.webp) ## @apiHeader 描述传递给 API 头部的参数,例如用于授权。 ``` @apiHeader [(group)] [{type}] [field=defaultValue] [description] ``` Usage: `@apiHeader (MyHeaderGroup) {String} authorization Authorization value.` | Name | Description | | :-------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------- | | (group)optional | All parameters will be grouped by this name. Without a group, the default `Parameter` is set. | | You can set a title and description with [@apiDefine](http://apidocjs.com/#param-api-define). | | {type}optional | Parameter type, e.g. `{Boolean}`, `{Number}`, `{String}`,`{Object}`, `{String[]}` (array of strings), ... | | field | Variablename. | | [field] | Fieldname with brackets define the Variable as optional. | | =defaultValueoptional | The parameters default value. | | descriptionoptional | Description of the field. | Examples: ``` /** * @api {get} /user/:id * @apiHeader {String} Content-Type=application/json 浏览器以 json 方式发送数据给服务器 * @apiHeader {String} Accept=application/json;charset=UTF-8 浏览器接收 json 数据 */ ``` ![20241229154732_dsaCNEfA.webp](https://cdn.dong4j.site/source/image/20241229154732_dsaCNEfA.webp) 使用 @apiDefine 定义全局 header 描述 ``` /** * @apiDefine Header * @apiHeader {String} Content-Type=application/json 浏览器以 json 方式发送数据给服务器 * @apiHeader {String} Accept=application/json;charset=UTF-8 浏览器接收 json 数据 */ ``` 引入 header 描述 ``` /** * @api {get} /user/{id} 获取用户详情 * @apiDescription 接口描述信息 * 可以有多行 * @apiGroup User * @apiUse MyError * @apiPermission admin * @apiUse Header * @apiErrorExample {json} Error-Response: * HTTP/1.1 404 Not Found * { * "error": "UserNotFound" * } * @apiExample {curl} Example usage: * curl -i http://dev.yyyyy.com/test/api/user/1024 */ ``` ## @apiHeaderExample header 参数 示例 ``` @apiHeaderExample [{type}] [title] example ``` | Name | Description | | :------------ | :------------------------------------ | | typeoptional | Request format. | | titleoptional | Short title for the example. | | example | Detailed example, multilines capable. | Example: ``` /** * @apiDefine Header * @apiHeader {String} Content-Type=application/json 浏览器以 json 方式发送数据给服务器 * @apiHeader {String} Accept=application/json;charset=UTF-8 浏览器接收 json 数据 * @apiHeaderExample {json} Header-Example: * { * "Content-Type": "Content-Type:application/json", * "Accept": "Accept:application/json;charset=UTF-8" * } */ ``` ![20241229154732_li2cBiFi.webp](https://cdn.dong4j.site/source/image/20241229154732_li2cBiFi.webp) ## @apiIgnore 用于定义未完成的接口描述, 不会被显示出来 **必须在 @api 之前定义** ``` @apiIgnore [hint] ``` Usage: `@apiIgnore Not finished Method` | Name | Description | | :----------- | :-------------------------------------------------- | | hintoptional | Short information why this block should be ignored. | Example: ``` /** * @apiIgnore Not finished Method * @api {get} /user/:id */ ``` ## @apiName 定义方法文档块的名称。将在生成的输出中使用子导航名。结构定义不需要 `@apiName`。 ``` @apiName name ``` | Name | Description | | :--- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | name | Unique name of the method. Same name with different `@apiVersion` can be defined. Format: *method* + *path* (e.g. Get + User), only a proposal, you can name as you want. | Example: ``` /** * @api {get} /user/:id * @apiName GetUser */ ``` ## @apiParam 描述传递给 API 方法的参数 Usage: `@apiParam (MyGroup) {Number} id Users unique ID.` | Name | Description | | :--------------------------- | :------------------------------------------------------------------------------------ | | (group)optional | 可定义一个 group ,用于抽象出公共的参数 ()表示可以不用定义 group | | {type}optional | 参数类型 | | {type{size}}optional | 参数长度 `{string{..5}}` `{string{2..5}}` `{number{100-999}}`  | | {type=allowedValues}optional | 参数允许的值 `{string="small"}` `{string {..5}="small","huge"}`  `{number=1,2,3,99}`  | | field | 字段名称 | | [field=defaultValueoptional] | 参数默认值 (有一个默认标识) [] 标识有一个可选标识 | | descriptionoptional | 字段描述 | Examples: ``` /** * @api {get} /user/:id * @apiParam {Number} id Users unique ID. */ /** * @api {post} /user/ * @apiParam {String} [firstname] Optional Firstname of the User. * @apiParam {String} lastname Mandatory Lastname. * @apiParam {String} country="DE" Mandatory with default value "DE". * @apiParam {Number} [age=18] Optional Age with default 18. * * @apiParam (Login) {String} pass Only logged in users can post this. * In generated documentation a separate * "Login" Block will be generated. */ ``` ![20241229154732_5SroB3m0.webp](https://cdn.dong4j.site/source/image/20241229154732_5SroB3m0.webp) ## @apiParamExample 请求参数示例 ``` @apiParamExample [{type}] [title] example ``` | Name | Description | | :------------ | :------------------------------------ | | typeoptional | Request format. | | titleoptional | Short title for the example. | | example | Detailed example, multilines capable. | Example: ``` /** * @api {get} /user/:id * @apiParamExample {json} Request-Example: * { * "id": 4711 * } */ ``` ![20241229154732_EutaILi9.webp](https://cdn.dong4j.site/source/image/20241229154732_EutaILi9.webp) ## @apiPermission 定义接口权限 ``` @apiPermission name ``` | Name | Description | | :--- | :----------------------------- | | name | Unique name of the permission. | Example: ``` /** * @api {get} /user/:id * @apiPermission none */ ``` ## @apiPrivate 将 API 定义为私有的,允许创建两个 API 规范文档:一个不包含私有 api,另一个包含它们。 ``` @apiPrivate ``` Usage: `@apiPrivate` Command line usage to exclude/include private APIs: `--private false|true` Example: ``` /** * @api {get} /user/:id * @apiPrivate */ ``` 默认不显示私有接口 如果需要包含私有接口 使用一下命令 ``` apidoc -i . -o apidoc/ --private true ``` ## @apiSampleRequest 定义是否显示接口调用相关部分 在与 apidoc.json 配置参数 [ sampleurl ]结合使用此参数 1. 如果在 `apidoc.json` 定义了 `sampleUrl`, 全部的 api 都会有一个测试表单 2. 如果没有定义 `sampleUrl` 字段, 只有标识了 `@apiSampleRequest` 的接口才会有测试表单 3. 如果定义了 `sampleUrl` 字段, api 中也有 `@apiSampleRequest url`, 且 url 已 http 开头, 则 api 的 url 会覆盖全局的 `sampleUrl` 4. 5. 如果定义了 `sampleUrl` 字段, 但是某个 api 不需要测试表单, 则可以使用 `@apiSampleRequest off` ``` @apiSampleRequest url ``` ### 定义 package.json ``` { "name": "xxx-api", "version": "1.0.0", "description": "xxx-api 接口文档", "apidoc": { "title": "", "url" : "", "sampleUrl":"http://dev.yyyyy.com/test/api" } } ``` 所有 api 都会出现 测试表单 ![20241229154732_CPaV8ODH.webp](https://cdn.dong4j.site/source/image/20241229154732_CPaV8ODH.webp) 同时定义测试 url, 且 `@apiSampleRequest` 已 http 开头, 则会覆盖 `sampleUrl` ``` @apiSampleRequest http://127.0.0.1/test/api/user ``` ![20241229154732_RoWPWBjx.webp](https://cdn.dong4j.site/source/image/20241229154732_RoWPWBjx.webp) 如果 `@apiSampleRequest` 只是定义 URI, 则会组合 apiSampleRequest 和 sampleUrl, 生成最终的 测试地址 ``` @apiSampleRequest /user ``` ![20241229154732_oMIXu9N6.webp](https://cdn.dong4j.site/source/image/20241229154732_oMIXu9N6.webp) ## @apiSuccessExample 定义正确的返回结果 ``` @apiSuccessExample [{type}] [title] example ``` | Name | Description | | :------------ | :------------------------------------ | | typeoptional | Response format. | | titleoptional | Short title for the example. | | example | Detailed example, multilines capable. | Example: ``` /** * @apiDefine CODE_200 调用成功 * @apiSuccess (Reponse 200) {number} code 200 * @apiSuccess (Reponse 200) {json} [data='""'] * @apiSuccessExample {json} Response 200 Example * HTTP/1.1 200 OK * { * "rescode": 1200, * "message": "操作成功", * "timestamp": 1513920968552 * } */ ``` ![20241229154732_Q24XCnZO.webp](https://cdn.dong4j.site/source/image/20241229154732_Q24XCnZO.webp) ## @apiUse 定义 使用 `@apiDefine` 定义的全局定义 ``` @apiUse name ``` Usage: `@apiUse MySuccess` | Name | Description | | :--- | :------------------------- | | name | Name of the defined block. | Example: ``` /** * @apiDefine MySuccess * @apiSuccess {string} firstname The users firstname. * @apiSuccess {number} age The users age. */ /** * @api {get} /user/:id * @apiUse MySuccess */ ``` ## @apiVersion 定义 api 版本, 可用于对比不同的版本变化 ``` @apiVersion version ``` Usage: `@apiVersion 1.6.2` | Name | Description | | :------ | :------------------------------------------------------------------------------------------------------------------------------ | | version | Simple versioning supported (major.minor.patch). More info on [Semantic Versioning Specification (SemVer)](http://semver.org/). | Example: ``` /** * @api {get} /user/{id} * @apiVersion 1.6.2 */ ``` **历史版本需要拷贝的 -apidoc.js 文件中** ## [日志追踪系统详解:从自动埋点到动态修改级别](https://blog.dong4j.site/posts/9cba1244.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 通过扩展 dubbo Filter, 拦截 RPC 请求的方式, 将在请求 API 时通过 SnowFlake 算法生成的全局唯一 traceId 存入到 RpcContext 中, 传递给下一个服务. **dubbo 服务调用日志** > 通过扩展 dubbo Filter, 拦截 RPC 请求的方式, 将在请求 API 时通过 SnowFlake 算法生成的全局唯一 traceId 存入到 RpcContext 中, 传递给下一个服务. **业务日志** > 通过 [Load Time wearing](https://docs.spring.io/spring/docs/4.0.4.RELEASE/spring-framework-reference/htmlsingle/#aop-aj-ltw-environment-generic) > 技术自动埋点, 在进入方法时, 通过 MDC 获取 traceId ## 接入追踪系统 1. dubbo 追踪接入 ``` com.xxx xxx-trace-client 0.2 ``` 2. http 追踪接入 ``` controllerFilter com.xxx.trace.client.rest.LoggerFilter controllerFilter /* ``` 3. 方法日志自动埋点 ``` ``` ## 实现原理 ### 自动埋点 自动埋点使用 代码织入 (AOP) #### 代码织入实现方式 1. 静态代理 1. AspectJ 织入器 weaver) 1. compile-time weaving 使用 aspectj 编译器进行编译源码 2. post-compile weaving 对 class 文件进行织入 3. load-time weaving(LTW) 当 class loader 加载类的时候,进行织入 2. 动态代理 1. JDK 动态代理 (接口) 2. CGlib(类) 这里使用 [Load Time wearing](https://docs.spring.io/spring/docs/4.0.4.RELEASE/spring-framework-reference/htmlsingle/#aop-aj-ltw-environment-generic) 实现, 这种方式在类加载器织入代码. **编译器织入**, 会造成编译速度变慢, 而且必须使用 ajc 编译器 **动态代理**会生成大量代理类, 加速内存消耗 使用 **类加载期织入**相对于其他两种方式, 更加轻便. #### 具体实现 ![20241229154732_9vtdBCZC.webp](https://cdn.dong4j.site/source/image/20241229154732_9vtdBCZC.webp) ##### 定义切面 ``` @Aspect public class TraceAspect { private static final Logger log = LoggerFactory.getLogger(TraceAspect.class); @Pointcut("execution(* com.xxx.server.rest.resource.impl..*.*(..))") public void profileMethod() { } @Around("profileMethod()") public Object profile(ProceedingJoinPoint jp) { Object result = ""; String methodName = jp.getSignature().getName(); log.error("前置通知"); // 执行目标方法 try { // 前置通知 log.error("The method " + methodName + " begins with " + Arrays.asList(jp.getArgs())); result = jp.proceed(); // 返回通知 log.error("The method " + methodName + " ends with " + Arrays.asList(jp.getArgs())); } catch (Throwable e) { // 异常通知 log.error("The method " + methodName + " occurs expection : " + e); throw new RuntimeException(e); } log.error("get MDC {}", MDC.get(Span.TRACE_ID)); return result; } } ``` ##### 准备 aop.xml 这个文件要求放在 META-INF/aop.xml 路径下,以告知 AspectJ Weaver 我们需要把 ProfilingAspect 织入到应用的哪些类中 ``` ``` ##### maven 依赖 ``` org.aspectj aspectjweaver 1.8.9 org.springframework spring-aop ${spring-version} ``` ##### 配置 applicationContext.xml aspectj-weaving = on / off / auto-detect 如果设置为 auto-detect(默认), spring 将会在 classpath 中查找 aspejct 需要的 META-INF/aop.xml, 如果找到则开启 aspectj weaving ``` ``` ##### VM 参数 开发时, idea 设置 **tomcat7** ``` -javaagent:/path/to/spring-instrument-4.3.3.RELEASE.jar -javaagent:/path/to/aspectjweaver-1.8.9.jar ``` **tomcat8** ``` -javaagent:/path/to/aspectjweaver-1.8.9.jar ``` ##### tomcat 设置 javaagent catalina.sh 最前面添加 ``` JAVA_OPTS="-javaagent:/path/to/spring-instrument-4.3.3.RELEASE.jar -javaagent:/path/to/aspectjweaver-1.8.9.jar" ``` ### 调用链日志 #### 扩展 Filter @Activate 是一个 Duboo 框架提供的注解。在 Dubbo 官方文档上有记载: 对于集合类扩展点,比如:Filter, InvokerListener, ExportListener, TelnetHandler, StatusChecker 等, 可以同时加载多个实现。 主要用处是标注在插件接口实现类上,用来配置该扩展实现类激活条件。 在 Dubbo 框架里面的 Filter 的各种实现类都通过 Activate 标注,用来描述该 Filter 什么时候生效。 用 @Activate 来实现一些 Filter ,可以具体如下: 1. 无条件自动激活 直接使用默认的注解即可 ``` import com.alibaba.dubbo.common.extension.Activate; import com.alibaba.dubbo.rpc.Filter; @Activate // 无条件自动激活 public class XxxFilter implements Filter { // ... } ``` 2. 配置 xxx 参数,并且参数为有效值时激活,比如配了 cache=”lru”,自动激活 CacheFilter ``` import com.alibaba.dubbo.common.extension.Activate; import com.alibaba.dubbo.rpc.Filter; @Activate("xxx") // 当配置了 xxx 参数,并且参数为有效值时激活,比如配了 cache="lru",自动激活 CacheFilter。 public class XxxFilter implements Filter { // ... } ``` 3. 只对提供方激活,group 可选 provider 或 consumer ``` import com.alibaba.dubbo.common.extension.Activate; import com.alibaba.dubbo.rpc.Filter; @Activate(group = {Constants.PROVIDER, Constants.CONSUMER}) // 只对提供方激活,group 可选"provider"或"consumer" public class XxxFilter implements Filter { // ... } ``` 在 `resourves/META-INF/dubbo/com.alibaba.dubbo.prc.Filter` 文件中添加自定义 Filter 全类名 ``` tracingFilter=com.xxx.trace.client.dubbo.TracingFilter ``` #### 自定义参数在 RPC 请求的传递 使用 aop, 在 调用 dubbo 服务之前, 通过 `RpcContext.getContext().setAttachments` 保存自定义参数 在服务端使用 `RpcContext.getContext().getAttachment` 获取自定义参数 ![20241229154732_iMzKs24Y.webp](https://cdn.dong4j.site/source/image/20241229154732_iMzKs24Y.webp) > RpcContext 是一个 ThreadLocal 的临时状态记录器,当接收到 RPC 请求,或发起 RPC 请求 时,RpcContext 的状态都会变化。比如:A 调 B,B 再调 C,则 B > 机器上,在 B 调 C 之 前,RpcContext 记录的是 A 调 B 的信息,在 B 调 C 之后,RpcContext 记录的是 B 调 C 的 信息。 ``` /** * 在调用 service 的接口之前,加入一些 dubbo 的隐式参数 * 2017-12-13 17:34 dong4j */ @Aspect @Component public class DubboServiceContextAop { @Pointcut("execution(* com.xxx.xxx.service.*.*(..))") public void serviceApi() { } @Before("serviceApi()") public void dubboContext(JoinPoint jp) { Map context = new HashMap<>(); // todo you want do RpcContext.getContext().setAttachments(context); } } ``` ``` public class DubboContextFilter implements Filter { @Override public Result invoke(Invoker invoker, Invocation invocation) throws RpcException { String var= RpcContext.getContext().getAttachment( 从 Aop 中放入的); // todo 其他相关处理 return invoker.invoke(invocation); } } ``` **RpcContext 相关 API** ``` // 远程调用 xxxService.xxx(); // 本端是否为消费端,这里会返回 true boolean isConsumerSide = RpcContext.getContext().isConsumerSide(); // 获取最后一次调用的提供方 IP 地址 String serverIP = RpcContext.getContext().getRemoteHost(); // 获取当前服务配置信息,所有配置信息都将转换为 URL 的参数 String application = RpcContext.getContext().getUrl().getParameter("application"); // 注意:每发起 RPC 调用,上下文状态会变化 yyyService.yyy(); ``` ``` public class XxxServiceImpl implements XxxService { public void xxx() { // 本端是否为提供端,这里会返回 true boolean isProviderSide = RpcContext.getContext().isProviderSide(); // 获取调用方 IP 地址 String clientIP = RpcContext.getContext().getRemoteHost(); // 获取当前服务配置信息,所有配置信息都将转换为 URL 的参数 String application = RpcContext.getContext().getUrl().getParameter("applicatio n"); // 注意:每发起 RPC 调用,上下文状态会变化 yyyService.yyy(); // 此时本端变成消费端,这里会返回 false boolean isProviderSide = RpcContext.getContext().isProviderSide(); } } ``` #### traceId 的传递过程 ##### 生成 traceId 在处理前端请求之前, 使用 `LoggerFilter` 拦截请求, 通过 SnowFlake 生成 traceId, 并存入 MDC 中 ```java @Slf4j public class LoggerFilter extends AbstractRequestLoggingFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, final FilterChain filterChain) throws ServletException, IOException { final String traceId = "" + new SnowflakeId(0).generate(); MDC.put(Span.TRACE_ID, traceId); ... super.doFilterInternal(request, response, filterChain); ... log.error(ApiLog.buildApiLog(EventType.invoke_interface, request.getRequestURI(), request.getHeader("token"), response.getStatus(), EventLog.MONITOR_STATUS_SUCCESS, "我是mock api成功日志").toString()); // 请求处理完成后清理 MDC 中的值 MDC.remove(Span.TRACE_ID); } @Override protected void beforeRequest(HttpServletRequest request, String message) { } @Override protected void afterRequest(HttpServletRequest request, String message) { } } ``` ##### 服务间传递 traceId 存放在 MDC 中的值只有在同一个线程中才能共享, 当发起 Rpc 调用后, 肯定不是同一个线程, 因此使用 RpcContext 来传递 Rpc traceId **服务调用之前, 消费者端** 通过 `RpcContext.getContext().setAttachments("traceId",MDC.get("traceId"))` 将 traceId 存入 RpcContext **服务调用之后, 提供者端** 通过 `RpcContext.rpcContext.getAttachment("traceId")` 从 RpcContext 中获取 traceId, 并使用 `MDC.put("traceId", traceId)` 将 traceId 存入当前线程中, 便于业务日志打印 ##### 删除 traceId 请求完成后, dubbo 服务线程自动销毁, 只需要在 `LoggerFilter` 中调用 `MDC.clear()` 清除 MDC ## 传输日志 kafka http://blog.csdn.net/honglei915/article/details/37563647 ## 动态修改日志级别 这里选择使用 JMX 来实现日志级别动态修改. ### 具体实现 #### 监听容器启动 当容器启动时, 获取应用名, 然后创建 zookeeper 临时节点 以前使用 `ServerListener` 实现, 但是这种方式需要修改 web.xml, 添加一个自定义 ServerListener 监听器. 这里重构下, 将监听容器启动然后创建 zookeeper 节点的逻辑迁入到 xxx-trace 模块中 ```java @Component @Slf4j public class ApplicationEventHandle implements ApplicationListener, ApplicationContextAware { @Autowired private TraceConfig traceConfig; // 注入 ServletContext @Autowired private ServletContext servletContext; /** * Sets application context. * 获取应用上下文, 从而获取 speing 管理的 bean * @param applicationContext the application context * @throws BeansException the beans exception */ @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { } /** * On application event. * 监听应用启动事件 * @param applicationEvent the application event */ @Override public void onApplicationEvent(ApplicationEvent applicationEvent) { if (applicationEvent instanceof ContextRefreshedEvent) { ContextRefreshedEvent contextRefreshedEvent = (ContextRefreshedEvent) applicationEvent; // root application context 没有 parent,他就是老大. if (contextRefreshedEvent.getApplicationContext().getParent()== null) { // 需要执行的逻辑代码,当 spring 容器初始化完成后就会执行该方法。 log.error("spring 容器初始化完成: {}", applicationEvent.getClass()); // 获取 ServletContext 容器未初始化完成, 使用这种方式会报空指针 // WebApplicationContext webApplicationContext = ContextLoader.getCurrentWebApplicationContext(); // ServletContext servletContext = webApplicationContext.getServletContext(); DynamicChangeLogLevel.initZookeeperNode(traceConfig.getZookeeperHost(), servletContext .getServletContextName()); } } // 应用启动,需要在代码动态添加监听器才可捕获 else if (applicationEvent instanceof ContextStartedEvent) { log.error("应用启动事件: {}", applicationEvent.getClass()); } // 应用停止 (context.stop();) else if (applicationEvent instanceof ContextStoppedEvent) { log.error("应用停止事件: {}", applicationEvent.getClass()); // 防止动态修改日志级别时内存溢出 LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); loggerContext.stop(); } // 应用关闭 (强制 stop) else if (applicationEvent instanceof ContextClosedEvent) { log.error("应用关闭事件: {}", applicationEvent.getClass()); } else { log.error("其他事件: {}", applicationEvent.getClass()); } } } ``` #### zk 节点组成 - loglevel - applicationName1 - serviceHost1:servicePort1 --> data = defaultLogLevel - serviceHost2:servicePort2 --> data = defaultLogLevel - applicationName2 - serviceHost1:servicePort1 --> data = defaultLogLevel root 节点下, 根据 **应用名** 区分不同应用 集群部署时, 相同应用名根据 host 和 port 区分, 修改某个节点, 不会影响其他节点. ![20241229154732_ckNH2G92.webp](https://cdn.dong4j.site/source/image/20241229154732_ckNH2G92.webp) #### 其他模块接入 pom.xml 配置 添加 `name` 标签, 用于统一标识应用名, 使用 `${project.name}` 获取 `name` 值 ``` 应用名 ``` 比如在 logback.xml 中, 向 JMX 注册 MBean, 需要标识当前应用名 logback.xml 配置 ```xml ${project.name} [%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] %-5level %logger{5} - %X{traceId} - %X{platformType} - %X{clientVersion} - %msg%n UTF-8 ``` 只需要在 web.xml 添加以下配置, 用于标识当前应用 ```xml 应用名 webAppRootKey 应用名 ``` 为了便于管理应用名, 这里使用 pom.xml 中的 name 标签来设置 web.xml 中的设置 在 pom.xml 中添加 `maven-war-plugin` 插件 ```xml  org.apache.maven.plugins maven-war-plugin 2.6 src/main/webapp true ``` 然后 web.xml 的配置就可以修改为 ```xml ${project.name} webAppRootKey ${project.name} ``` 最后在 applicationContext.xml 引入 xxx-config.xml ``` ``` ### 日志节点监控和修改日志接口 ### 监控 ### 接口 1. 获取所有应用列表 2. 应用的当前日志级别 (root, com.xxx) 3. 修改某个应用的日志 (root, com.xxx) 4. 修改全部应用的日志 (root, com.xxx) 5. 重置全部日志级别 ## 日志类型 | 日志类型 | 说明 | | :--------------- | :----------------------------------------- | | normal | 正常入库日志 | | invoke_interface | api 调用日志 | | middleware_opt | 中间件操作日志 (目前仅支持 hbase 和 mongo) | | job_execute | job 执行日志 | | rpc_trace | rpc trace 跟踪日志 | | custom_log | 自定义埋点日志 | | thirdparty_call | 第三方系统调用日志 | ### 正常日志 ```shell LOGGER.info("我是测试日志打印") ``` ### api 日志 ```shell // 参数依次为 EventType(事件类型)、api、账号、请求耗时、成功还是失败、具体自定义的日志内容 LOGGER.info(ApiLog.buildApiLog(EventType.invoke_interface, "/app/status", "800001", 100, EventLog.MONITOR_STATUS_SUCCESS, "我是mock api成功日志").toString()); LOGGER.info(ApiLog.buildApiLog(EventType.invoke_interface, "/app/status", "800001", 10, EventLog.MONITOR_STATUS_FAILED, "我是mock api失败日志").toString()); ``` ### 中间件日志 ```shell // 参数依次为 EventType(事件类型)、MiddleWare(中间件名称)、操作耗时、成功还是失败、具体自定义的日志内容 LOGGER.info(EventLog.buildEventLog(EventType.middleware_opt, MiddleWare.HBASE.symbol(), 100, EventLog.MONITOR_STATUS_SUCCESS, "我是mock middle ware成功日志").toString()); LOGGER.info(EventLog.buildEventLog(EventType.middleware_opt, MiddleWare.MONGO.symbol(), 10, EventLog.MONITOR_STATUS_FAILED, "我是mock middle ware失败日志").toString()); ``` ### job 执行日志 ``` // job 执行仅仅处理失败的日志(成功的不做处理,所以只需要构造失败的日志), 参数依次为 EventType(事件类型)、job 的 id 号、操作耗时、失败、具体自定义的日志内容 LOGGER.info(EventLog.buildEventLog(EventType.job_execute, "application_1477705439920_0544", 10, EventLog.MONITOR_STATUS_FAILED, "我是mock job exec失败日志").toString()); ``` ### 第三方请求日志 ``` // 参数依次为 EventType(事件类型)、第三方名称、操作耗时、成功还是失败、具体自定义的日志内容 LOGGER.info(EventLog.buildEventLog(EventType.thirdparty_call, "xx1", 100, EventLog.MONITOR_STATUS_FAILED, "我是mock third 失败日志").toString()); LOGGER.info(EventLog.buildEventLog(EventType.thirdparty_call, "xx1", 100, EventLog.MONITOR_STATUS_SUCCESS, "我是mock third 成功日志").toString()); LOGGER.info(EventLog.buildEventLog(EventType.thirdparty_call, "xx2", 100, EventLog.MONITOR_STATUS_SUCCESS, "我是mock third 成功日志").toString()); LOGGER.info(EventLog.buildEventLog(EventType.thirdparty_call, "xx2", 100, EventLog.MONITOR_STATUS_FAILED, "我是mock third 失败日志").toString()); ``` ## [声网Agora SDK集成教程:Java客户端API详解](https://blog.dong4j.site/posts/7ef4d1ad.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 声网主要产品 声网主要提供以下几类产品: **1. 实时互动基础能力**: - **语音通话**: 一对一,多人实时语音通话。 - **视频通话**: 一对一,多人实时视频通话。 - **直播**: 提供低延时、强同步、大并发、高质量的互动直播能力。 - **实时消息**: 超低延迟的全球信令与消息云服务。 - **即时通讯 IM**: 单聊、群聊、聊天室、系统通知等 IM 功能。 - **媒体流加速**: 具备 QoS 保障的全球端到端加速服务。 **2. 实时互动扩展能力**: - **水晶球**: 实时监控、告警通知、通话调查、数据洞察。 - **互动白板**: H5 课件、动态 PPT、轨迹与音视频同步。 - **实时录制**: 云端录制、本地服务端录制、页面录制。 - **凤鸣 AI 引擎**: 新一代音频技术智能引擎,包括空间音频、AI 降噪、虚拟声卡等功能。 - **Status Page**: 集中展示声网主要产品及服务的综合服务质量及可用性信息。 **3. 低代码应用平台**: - **灵动课堂**: 15 分钟上线自由品牌互动教学平台。 - **灵隼物联网云平台**: 「耳聪目明」智能硬件音视频体验升级。 ## 主要解决的问题 声网产品主要解决以下问题: - **低延迟、高可靠**: 提供低延迟、高可靠的实时互动能力,满足各类实时互动应用的需求。 - **可扩展性**: 产品支持海量用户同时在线,可扩展性强,满足不同规模的应用需求。 - **安全性**: 产品具备数据安全保障和个人隐私保护机制,确保用户数据安全。 - **易用性**: 产品提供丰富的 API 和开发文档,方便开发者快速集成和使用。 - **定制化**: 产品支持定制化开发,满足不同场景的个性化需求。 ## 业务场景 手机端和 PC 端需要进行语音聊天, 主要功能为: 1. Web 端 - 呼叫功能 - 弹出新窗口, 呼叫按钮置灰 - 实时监控呼叫 - 我的车辆呼叫 - 第三方货主派车呼叫 - 司机管理呼叫 - 货主接收抢单呼叫 - 运单作业呼叫 - 呼叫记录 - 呼出记录 - 呼入记录(查) 2. 客户端接口 - 建群 - 解散群 - 添加成员 - 成员列表 - 验证消息 - 踢人 - 群列表 - 用户 id 加密 - 声网 证书 token 处理 3. 日志 - 群组管理记录 - 呼叫记录(写入) ## 声网名词解释 关键词: **App ID** 开发者在我们官网注册后,可以创建多个项目,每一个项目对应的唯一标识就是 App ID。 如果有人非法获取了你的 App ID,他将可以在 Agora 提供的 SDK 中使用你的 App ID,如果他知道你的频道名字,甚至有可能干扰你正常的通话。 所以建议仅在测试阶段或对安全性要求不高的场景里使用 App ID。 使用不同 App ID 的应用程序是不能互通的。如果已在通话中,用户必须调用 leaveChannel() 退出当前通话,才能进入下一个频道 **Dynamic Key** 当项目准备正式上线运营,建议开发者采用 Dynamic Key,这是一个更为安全的用户身份验证方案。针对不同的服务,Dynamic Key 有不同的名称: 1. Channel Key 用于加入频道; 2. Signaling Key 用于登录信令系统; **App Certificate** 将您的 App Certificate 保存在服务器端,且对任何客户端均不可见。 通常 App Certificate 在启用一小时后生效。 当项目的 App Certificate 被启用后,您必须使用 Dynamic Key。例如: 在启用 App Certificate 前,您可以使用 App ID 加入频道。但启用了 App Certificate 后,您必须使用 Channel Key 加入频道。 **channel** 标识通话的频道名称,长度在 64 字节以内的字符串 可以理解为房间名 要进行语音或者视频, 不同的用户都必须在同一个 channel 中 ![20241229154732_jLP435rJ.webp](https://cdn.dong4j.site/source/image/20241229154732_jLP435rJ.webp) **channel key** 安全要求不高: 将值设为 null 安全要求高: 将值设置为 Channel Key。 如果你已经启用了 App Certificate, 请务必使用 Channel Key。 **UID** 用户位移表示 同一个频道里不能出现两个相同的 UID。如果你的 App 支持多设备同时登录,即同一个用户账号可以在不同的设备上同时登录 (例如微信支持在 PC 端和移动端同时登录),请保证传入的 UID 不相同。 例如你之前都是用同一个用户标识作为 UID, 建议从现在开始加上设备 ID, 以保证传入的 UID 不相同 。如果你的 App 不支持多设备同时登录,例如在电脑上登录时,手机上会自动退出,这种情况下就不需要在 UID 上添加设备 ID。 **信令 和 通信** > 频道,可以理解成通讯的房间,信令和通信中的频道是一个意思,只不过,信令是确认进入房间,通信是在房间内聊天。 https://dev.agora.io/cn/question/1780 **群组** **通话统计** ### App 的用户之间要建立和发起一个呼叫,整个流程是怎样的? 以 A 呼叫 B 为例,一般呼叫流程如下: 1. A 向信令服务器发起呼叫请求。 2. 信令服务器检查 B 是否在线: - 如不在线,向 A 返回 B 不在线错误。 - 如在线,信令服务器生成频道名,返回给 A;并向 B 投递呼叫信令。 3. A 收到信令服务器返回的频道名,准备加入语音频道。此时为加快进频道速度,可以提前进入频道待命: - A 调用 muteLocalAudioStream(true) 和 muteLocalVideoStream(true)(如有视频功能)禁止发送音视频数据。 - 调用 joinChannel 进入频道。 4. B 收到信令服务器投递过来的 A 的呼叫请求。 - B 响铃。 为加快进频道速度,可以提交进入频道待命。 - B 调用 muteLocalAudioStream(true) 和 muteLocalVideoStream(true)(如有视频功能)禁止发送音视频数据。 5. A 调用 joinChannel 进入频道: - 如 B 拒绝请求: - B 调用 leaveChannel 退出频道 - B 向信令服务器返回拒绝应答 - 信令服务器向 A 返回 B 拒绝应答信令 - A 调用 leaveChannel 退出频道 - 如 B 接受请求: - B 调用 muteLocalAudioStream(false) 和 muteLocalVideoStream(false) 开始发送音视频数据 - B 向信令服务器返回接受应答信令 - 调用 muteLocalAudioStream(false) 和 muteLocalVideoStream(false) 开始发送音视频数据 ![20241229154732_2cqjFJCm.webp](https://cdn.dong4j.site/source/image/20241229154732_2cqjFJCm.webp) 呼叫失败或者成功, 客户端需要调用 xxx 接口写日志 ![20241229154732_GzUptBTW.webp](https://cdn.dong4j.site/source/image/20241229154732_GzUptBTW.webp) ![20241229154732_OPhFa2GG.webp](https://cdn.dong4j.site/source/image/20241229154732_OPhFa2GG.webp) 客户端呼叫 货主 (TMS/货主 app) 需要发起 2 个呼叫, 当一方接通时, 回调中关闭另一个呼叫 4 ## 声网客户端 API ### 账号消息系统 #### 登录 ```java public void login(String appId,String account,String token,int uid,String deviceID); public void login2(String appId,String account,String token,int uid,String deviceID,int retry_time_in_s, int retry_count); ``` #### 登出 ```java public void logout(); ``` #### 检查当前是否在线 (isOnline) ```java public int isOnline(); ``` #### 发送点对点消息 (messageInstantSend) ```java public void messageInstantSend(String account,int uid,String msg,String msgID); ``` #### 设置用户属性 (set_attr) ```java public void set_attr(String name,String value); ``` #### 获取自己的属性 (get_attr) ```java public void get_attr(String name); ``` #### 获取自己的全部属性 (get_attr_all) ```java public void get_attr_all(); ``` #### 获取某个用户的属性 (get_user_attr) ```java public void get_user_attr(String account,String name); ``` #### 获取某个用户的所有属性 (get_user_attr_all) ```java public void get_user_attr_all(String account); ``` ### 频道系统 ### 加入频道 (channelJoin) ```java public void channelJoin(String channelID); ``` 让用户加入指定频道。用户一次只能加入一个频道。如加入指定频道时已在其他频道中,将自动从其他频道退出。 用户加入频道成功后,自己将收到回调 onChannelJoined,其他同一频道内用户将收到回调 onChannelUserJoined。用户加入失败后,自己将收到回调 onChannelJoinFailed。 #### 离开频道 (channelLeave) ```java public void channelLeave(String channelID); ``` #### 查询频道用户数 (channelQueryUserNum) ```java public void channelQueryUserNum(String channelID); ``` #### 设置频道属性 (channelSetAttr) ```java public void channelSetAttr(String channelID,String name,String value); ``` #### 删除频道属性 (channelDelAttr) ```java public void channelDelAttr(String channelID,String name); ``` #### 删除所有频道属性 (channelClearAttr) ```java public void channelClearAttr(String channelID); ``` #### 发送频道消息 (messageChannelSend) ```java public void messageChannelSend(String channelID,String msg,String msgID); ``` ### 呼叫系统 #### 发起呼叫 (channelInviteUser) ```java public void channelInviteUser(String channelID,String account,int uid); public void channelInviteUser2(String channelID,String account,String extra); ``` 该方法用于发起呼叫,即邀请某用户加入某个频道。呼叫和加入频道,是两个独立的过程。用户必须自己再另行加入频道,用户可以选择:先加入频道,再发送呼叫邀请或先发送呼叫邀请,对方接受后再加入频道。如果呼叫失败,会回调 onInviteFailed。可能的原因有: 1. 对方不在线; 2. 本端网络不通; 3. 服务器异常 #### 接受呼叫 (channelInviteAccept) ```java public void channelInviteAccept(String channelID,String account,int uid); ``` #### 拒绝呼叫 (channelInviteRefuse) ```java public void channelInviteRefuse(String channelID,String account,int uid); ``` #### 结束呼叫 (channelInviteEnd) ```java public void channelInviteEnd(String channelID,String account,int uid); ``` ## 声网 SDK 集成 ### API demo **Android** ```java RtcEngine rtcEngine = RtcEngine.create(mContext, appId, mEngineEventHandler.mRtcEventHandler); rtcEngine.joinChannel(null, channel, "Extraoptional data", uid); mRtcEngine.leaveChannel(); ``` **Web** ```javascript var client = AgoraRTC.createRtcClient(); client.init( appId, function () { client.join(appId, channel, undefined, successCallback, errorCallback); }, errorCallback ); ``` **iOS** ```javascript let engine = AgoraRtcEngineKit.sharedEngineWithAppId("AppId", delegate: self) engine.enableVideo() engine.joinChannelByKey(nil, channelName: "channelName", info: nil, uid: 0, joinSuccess: nil) ``` ## [声网集成:掌握App ID、Dynamic Key和UID,打造安全通话环境](https://blog.dong4j.site/posts/d8c7081e.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 声网集成相关 关键词: **App ID** 开发者在我们官网注册后,可以创建多个项目,每一个项目对应的唯一标识就是 App ID。 如果有人非法获取了你的 App ID,他将可以在 Agora 提供的 SDK 中使用你的 App ID,如果他知道你的频道名字,甚至有可能干扰你正常的通话。 所以建议仅在测试阶段或对安全性要求不高的场景里使用 App ID。 使用不同 App ID 的应用程序是不能互通的。如果已在通话中,用户必须调用 leaveChannel() 退出当前通话,才能进入下一个频道 **Dynamic Key** 当项目准备正式上线运营,建议开发者采用 Dynamic Key,这是一个更为安全的用户身份验证方案。针对不同的服务,Dynamic Key 有不同的名称: 1. Channel Key 用于加入频道; 2. Signaling Key 用于登录信令系统; **App Certificate** 将您的 App Certificate 保存在服务器端,且对任何客户端均不可见。 通常 App Certificate 在启用一小时后生效。 当项目的 App Certificate 被启用后,您必须使用 Dynamic Key。例如: 在启用 App Certificate 前,您可以使用 App ID 加入频道。但启用了 App Certificate 后,您必须使用 Channel Key 加入频道。 **channel** 标识通话的频道名称,长度在 64 字节以内的字符串 可以理解为房间名 要进行语音或者视频, 不同的用户都必须在同一个 channel 中 ![20241229154732_t6m0Iacx.webp](https://cdn.dong4j.site/source/image/20241229154732_t6m0Iacx.webp) **channel key** 安全要求不高: 将值设为 null 安全要求高: 将值设置为 Channel Key。 如果你已经启用了 App Certificate, 请务必使用 Channel Key。 **UID** 用户位移表示 同一个频道里不能出现两个相同的 UID。如果你的 App 支持多设备同时登录,即同一个用户账号可以在不同的设备上同时登录 (例如微信支持在 PC 端和移动端同时登录),请保证传入的 UID 不相同。 例如你之前都是用同一个用户标识作为 UID, 建议从现在开始加上设备 ID, 以保证传入的 UID 不相同 。如果你的 App 不支持多设备同时登录,例如在电脑上登录时,手机上会自动退出,这种情况下就不需要在 UID 上添加设备 ID。 **信令 和 通信** > 频道,可以理解成通讯的房间,信令和通信中的频道是一个意思,只不过,信令是确认进入房间,通信是在房间内聊天。 https://dev.agora.io/cn/question/1780 **群组** **通话统计** ### App 的用户之间要建立和发起一个呼叫,整个流程是怎样的? 以 A 呼叫 B 为例,一般呼叫流程如下: 1. A 向信令服务器发起呼叫请求。 2. 信令服务器检查 B 是否在线: - 如不在线,向 A 返回 B 不在线错误。 - 如在线,信令服务器生成频道名,返回给 A;并向 B 投递呼叫信令。 3. A 收到信令服务器返回的频道名,准备加入语音频道。此时为加快进频道速度,可以提前进入频道待命: - A 调用 muteLocalAudioStream(true) 和 muteLocalVideoStream(true)(如有视频功能)禁止发送音视频数据。 - 调用 joinChannel 进入频道。 4. B 收到信令服务器投递过来的 A 的呼叫请求。 - B 响铃。 为加快进频道速度,可以提交进入频道待命。 - B 调用 muteLocalAudioStream(true) 和 muteLocalVideoStream(true)(如有视频功能)禁止发送音视频数据。 5. A 调用 joinChannel 进入频道: - 如 B 拒绝请求: - B 调用 leaveChannel 退出频道 - B 向信令服务器返回拒绝应答 - 信令服务器向 A 返回 B 拒绝应答信令 - A 调用 leaveChannel 退出频道 - 如 B 接受请求: - B 调用 muteLocalAudioStream(false) 和 muteLocalVideoStream(false) 开始发送音视频数据 - B 向信令服务器返回接受应答信令 - 调用 muteLocalAudioStream(false) 和 muteLocalVideoStream(false) 开始发送音视频数据 ![20241229154732_RLznNSiG.webp](https://cdn.dong4j.site/source/image/20241229154732_RLznNSiG.webp) 呼叫失败或者成功, 客户端需要调用 xxx 接口写日志 ![20241229154732_omNQ5ZtM.webp](https://cdn.dong4j.site/source/image/20241229154732_omNQ5ZtM.webp) ![20241229154732_hTbqt3js.webp](https://cdn.dong4j.site/source/image/20241229154732_hTbqt3js.webp) 客户端呼叫 货主 (TMS/ 货主 app) 需要发起 2 个呼叫, 当一方接通时, 回调中关闭另一个呼叫 4 ## [轻松搞定Maven多环境配置:实战技巧大放送](https://blog.dong4j.site/posts/53d86ec9.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Maven 多环境配置打包方式 ## 使用 profiles ```xml local local LOCAL dev dev DEV true test test TEST production production PRODUCTION ``` ## 修改 build 添加 filter ```xml src/main/filters/${environment}.yyyyy.properties src/main/resources true **/*.xml **/*.properties **/*.banner **/dubbo/* rebel.xml src/test/resources true **/*.xml **/*.properties **/*.banner ``` ## 使用命令打包 ```bash mvn clean install -Dmaven.test.skip=true -Pdev ``` ## 使用 @ 代替 $ 如果 resource 中存在 js, 且与 maven 内置变量相同, 会将 $ 全部替换为 maven 的参数 所有这里使用 @ 替换掉 $. ## 原理 maven 会使用 filter 中的文件属性替换所有 resource include 的文件中的占位符 # 使用 context:property-placeholder 有些参数在某些阶段中是常量。 1. 在开发阶段我们连接数据库时的 url,username,password 等信息 2. 分布式应用中 client 端的 server 地址,端口等 这些参数在不同阶段之间又往往需要改变 期望:有一种方案可以方便我们在一个阶段内不需要频繁写一个参数的值,而在不同阶段间又可以方便的切换参数的配置信息 解决:spring3 中提供了一种简便的方式就是 `` 元素 只需要在 spring 配置文件中添加一句: ```xml ``` 或者 ```xml jdbc.properties ``` 即可,这里的 location 值为参数配置文件的位置,配置文件通常放到 src 目录下,参数配置文件的格式即键值对的形式, ```bash #jdbc 配置 driverClassName=com.mysql.jdbc.Driver url=jdbc:mysql://localhost:3306/test username=root password=root ``` 行内 #号后面部分为注释 这样一来就可以为 spring 配置的 bean 的属性设置值了,比如 spring 有一个数据源的类 ```xml ``` 甚至可以将 ${} 这种形式的变量用在 spring 提供的注解当中,为注解的属性提供值(下面会讲到) Spring 容器采用反射扫描的发现机制,在探测到 Spring 容器中有一个 org.springframework.beans.config.PropertyPlaceholderConfigurer 的 Bean 就会停止对剩余 PropertyPlaceholderConfigurer 的扫描, 换句话说,即 Spring 容器仅允许最多定义一个 PropertyPlaceholderConfigurer 或 `` 其余的会被 Spring 忽略 由于 Spring 容器只能有一个 PropertyPlaceholderConfigurer,如果有多个属性文件,这时就看谁先谁后了,先的保留 ,后的忽略。 还有一种情况,是 Spring 自动注入 properties 文件中的配置:要自动注入 properties 文件中的配置,需要在 Spring 配置文件中添加 `org.springframework.beans.factory.config.PropertiesFactoryBean` 和 `org.springframework.beans.factory.config.PreferencesPlaceholderConfigurer` 的实例配置 ```xml classpath*:application.properties ``` 在这个配置文件中我们配置了注解扫描,和 configProperties 实例和 propertyConfigurer 实例,这样我们就可以在 java 类中自动注入配置了 ```java @Component public class Test{ @Value("#{configProperties['userName']}") private String userName; public String getUserName(){ return userName; } } ``` 自动注入需要使用 @Value 这个注解,这个注解的格式 `#{configProperties['userName']}` 其中 configProperties 是我们在配置文件中配置的 bean 的 id, userName 是在配置文件中配置的项 在 Spring 中的 xml 中使用 > 标签导入配置文件时,想要导入多个 properties 配置文件,如下: ```xml ``` 结果发现不行,第二个配置文件始终读取不到,后来发现 `` 标签在 Spring 配置文件中只能存在一份!!!Spring 容器是采用反射扫描的发现机制,通过标签的命名空间实例化实例,当 Spring 探测到容器中有一个 `org.springframework.beans.factory.config.PropertyPlaceholderCVonfigurer` 的 Bean 就会停止对剩余 PropertyPlaceholderConfigurer 的扫描,即只能存在一个实例。 ```xml ``` 那如果有多个配置文件怎么办呢?那就多个文件之间以“,”分隔,如下: ```xml ``` 值得注意的是:多个配置文件将依次加载,如果后一个文件中有和前面某一个文件中属性名是相同的,最终取的值是后加载的值。 ## [提高开发效率,提升代码质量:CheckStyle的实战应用](https://blog.dong4j.site/posts/e5eb73b9.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 根据 「阿里巴巴开发规范」 配置 IDEA 开发环境 google-code-checks-xxx.xml 根据 google-code-checks.xml 修改 按照官方文档加上中文注释 具体的 checkstyle 规则可以查看 google-code-checks.xml ## 命名规约 通过 checkstyle 控制, 所有不符合要求的 包名, 类名, 方法名, 局部变量, 静态变量, 常量 都会提示 ![name.gif](https://cdn.dong4j.site/source/image/name.gif) 1. 接口类中的方法和属性不要加任何修饰符号(public 也不要加),保持代码的简洁 性,并加上有效的 Javadoc 注释 ![20241229154732_Gp3mXzIv.webp](https://cdn.dong4j.site/source/image/20241229154732_Gp3mXzIv.webp) idea 对于多余的修饰符也会标记, 这里使用 checkstyle 提示 ### 常量定义 1. 重复的字符串出现 2 次以上, 就会提示, 应该使用常量字符串代替 ![20241229154732_g3xmU12z.webp](https://cdn.dong4j.site/source/image/20241229154732_g3xmU12z.webp) 2. 当定义一个常量时, 希望使用大写的 L 来代替小写的 l, 原因是小写的 l 和数字 1 很象 3. long 或者 Long 初始赋值时,必须使用大写的 L,不能是小写的 l,小写容易跟数字 1 混淆,造成误解 ![20241229154732_E6dayluU.webp](https://cdn.dong4j.site/source/image/20241229154732_E6dayluU.webp) ## 格式规约 全部使用 checkstyle 约束 如果违反规则, 则会给出提示, 按照相应提示修改即可, 修改之前先全部格式化, 会减少很多提示 1. 缩进采用 4 个空格,禁止使用 tab 字符 2. 单行字符数限制不超过 3. 单行字符数限制不超过 120 个,超出需要换行,换行时 遵循如下原则: 1. 第二行相对第一行缩进 4 个空格 2. 运算符与下文一起换行。 3. 方法调用的点符号与下文一起换行。 4. 在多个参数超长,逗号后进行换行。 5. 在括号前不要换行 格式化文件和 checkstyle 都已经设置为 120 个字符长度, 如果超长, 使用快捷键格式化, 然后按照上面的 5 点原则修改即可 4. IDE 的 text file encoding 设置为 UTF-8; IDE 中文件的换行符使用 Unix 格式,不要使用 windows 格式。 ![20241229154732_p0zIZSOx.webp](https://cdn.dong4j.site/source/image/20241229154732_p0zIZSOx.webp) 5. Object 的 equals 方法容易抛空指针异常,应使用常量或确定有值的对象来调用 equals。 6. 不使用 System.out[err].print[ln] ![20241229154732_LTNkgVyJ.webp](https://cdn.dong4j.site/source/image/20241229154732_LTNkgVyJ.webp) 改为 ![20241229154732_BQdn0DGp.webp](https://cdn.dong4j.site/source/image/20241229154732_BQdn0DGp.webp) ## 注释规约 1. 规范 todo 和 fixme 标记 ![todo.gif](https://cdn.dong4j.site/source/image/todo.gif) todo 和 fixme 后 跟 - 名字 谁标记谁处理 ``` fixme-dong4j : ($date$ $time$ [ 说明] [预计处理时间]) todo-dong4j : ($date$ $time$ [ 说明] [预计处理时间]) ``` ![20241229154732_S0bgDTGj.webp](https://cdn.dong4j.site/source/image/20241229154732_S0bgDTGj.webp) ![20241229154732_sT7NwYnw.webp](https://cdn.dong4j.site/source/image/20241229154732_sT7NwYnw.webp) 2. 类, 方法, 字段注释要求 > 创建类时, 使用模板自动生成类注释 > 方法注释使用 javadoc[插件] 自动生成 > 字段注释使用 `/ ** 注释内容 */` 的方式, 当使用 F1 查看字段时, 即可看见注释说明, 不需要跳转到对应的类查看 ![class.gif](https://cdn.dong4j.site/source/image/class.gif) ![method.gif](https://cdn.dong4j.site/source/image/method.gif) ![20241229154732_emSXa0iL.webp](https://cdn.dong4j.site/source/image/20241229154732_emSXa0iL.webp) ![20241229154732_HiwlLvwj.webp](https://cdn.dong4j.site/source/image/20241229154732_HiwlLvwj.webp) ``` /** *

Title: $packagename$

*

Company: xxx 公司

*

Copyright © 2014 x'x'x All Rights Reserved

*

Description: $END$

* author: dong4j * emali: dong4j@gmail.com * version: 1.0 * date: $date$ $time$ * updatetime: * reason: */ ``` ![20241229154732_qEnNI6Yb.webp](https://cdn.dong4j.site/source/image/20241229154732_qEnNI6Yb.webp) ## checkstyle 使用方法 1. 安装 CheckStyle-IDEA 插件 2. 导入 google-code-checks-xxx.xml 3. 然后导入 google-code-style.xml 4. 最后关联 2 个文件 ![20241229154732_AAxyu2Sv.webp](https://cdn.dong4j.site/source/image/20241229154732_AAxyu2Sv.webp) ### checkstyle 插件的使用 安装好插件后, 第一次打开项目, 就会扫面全部文件的代码, 会时时提示规则 1. 手动检查当前文件 右键 --> check current file ![20241229154732_u37mHagb.webp](https://cdn.dong4j.site/source/image/20241229154732_u37mHagb.webp) 2. 手动扫描 module 3. 手动扫面 project ![20241229154732_jeXfXrJv.webp](https://cdn.dong4j.site/source/image/20241229154732_jeXfXrJv.webp) #### 警告提示 ![20241229154732_e3azFvY8.webp](https://cdn.dong4j.site/source/image/20241229154732_e3azFvY8.webp) 这个不晓得是不是 bug, 暂时还没有找到解决办法, 所以暂时忽略 ![20241229154732_oB9m6YSz.webp](https://cdn.dong4j.site/source/image/20241229154732_oB9m6YSz.webp) checkstyle 取消勾选即可 ## [走进Python核心:揭秘元类和类的创建过程](https://blog.dong4j.site/posts/8f42e00b.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) # python 面向对象(进阶篇) 原文出处:[武沛齐 cnblog](http://www.cnblogs.com/wupeiqi/p/4766801.html) - 面向对象是一种编程方式,此编程方式的实现是基于对**类**和**对象**的使用 - 类 是一个模板,模板中包装了多个“函数”供使用(可以讲多函数中公用的变量封装到对象中) - 对象,根据模板创建的实例(即:对象),实例用于调用被包装在类中的函数 - 面向对象三大特性:封装、继承和多态 本篇将详细介绍 Python 类的成员、成员修饰符、类的特殊成员。 ## 类的成员 类的成员可以分为三大类:字段、方法和属性 ![20241229154732_jg2T9uao.webp](https://cdn.dong4j.site/source/image/20241229154732_jg2T9uao.webp) - 注:所有成员中,只有普通字段的内容保存对象中,即:根据此类创建了多少对象,在内存中就有多少个普通字段。而其他的成员,则都是保存在类中,即:无论对象的多少,在内存中只创建一份 ### 字段 字段包括:普通字段和静态字段,他们在定义和使用中有所区别,而最本质的区别是内存中保存的位置不同, - 普通字段属于 **对象** - 静态字段属于 **类** ```python class Province: # 静态字段 country = '中国' def __init__(self, name): # 普通字段 self.name = name # 直接访问普通字段 obj = Province('河北省') print obj.name # 直接访问静态字段 Province.country ``` #### 字段的定义和使用 由上述代码可以看出【普通字段需要通过对象来访问】【静态字段通过类访问】,在使用上可以看出普通字段和静态字段的归属是不同的。其在内容的存储方式类似如下图: ![20241229154732_j4ReiyvB.webp](https://cdn.dong4j.site/source/image/20241229154732_j4ReiyvB.webp) 由上图可是: - 静态字段在内存中只保存一份 - 普通字段在每个对象中都要保存一份 应用场景: 通过类创建对象时,如果每个对象都具有相同的字段,那么就使用静态字段 ### 方法 方法包括:普通方法、静态方法和类方法,三种方法在 **内存中都归属于类**,区别在于调用方式不同。 - 普通方法:由 **对象**调用;至少一个 **self** 参数;执行普通方法时,自动将调用该方法的 **对象** 赋值给 **self**; - 类方法:由 **类** 调用; 至少一个 **cls** 参数;执行类方法时,自动将调用该方法的 **类** 复制给 **cls**; - 静态方法:由 **类** 调用;无默认参数; ```python class Foo: def __init__(self, name): self.name = name def ord_func(self): """ 定义普通方法,至少有一个self参数 """ # print self.name print '普通方法' @classmethod def class_func(cls): """ 定义类方法,至少有一个cls参数 """ print '类方法' @staticmethod def static_func(): """ 定义静态方法 ,无默认参数""" print '静态方法' # 调用普通方法 f = Foo() f.ord_func() # 调用类方法 Foo.class_func() # 调用静态方法 Foo.static_func() ``` #### 方法的定义和使用 ![20241229154732_h6vrNQ1q.webp](https://cdn.dong4j.site/source/image/20241229154732_h6vrNQ1q.webp) **相同点**对于所有的方法而言,均属于类(非对象)中,所以,在内存中也只保存一份。 **不同点** 方法调用者不同、调用方法时自动传入的参数不同。 ### 属性 如果你已经了解 Python 类中的方法,那么属性就非常简单了,因为 Python 中的属性其实是 **普通方法** 的变种。 对于属性,有以下三个知识点: - 属性的基本使用 - 属性的两种定义方式 #### 属性的基本使用 ```python # ############### 定义 ############### class Foo: def func(self): pass # 定义属性 @property def prop(self): pass # ############### 调用 ############### foo_obj = Foo() foo_obj.func() foo_obj.prop #调用属性 ``` #### 属性的定义和使用 ![20241229154732_mim7LQLK.webp](https://cdn.dong4j.site/source/image/20241229154732_mim7LQLK.webp) 由属性的定义和调用要注意一下几点: - 定义时,在普通方法的基础上添加**@property**装饰器; - 定义时,属性 **仅有一个 **self 参数 - 调用时,无需 **括号 ** 方法:foo_obj.func() 属性:foo_obj.prop 注意:属性存在意义是:访问属性时可以制造出和访问字段完全相同的假象 属性由方法变种而来,如果 Python 中没有属性,方法完全可以代替其功能。 实例:对于主机列表页面,每次请求不可能把数据库中的所有内容都显示到页面上,而是通过分页的功能局部显示,所以在向数据库中请求数据时就要显示的指定获取从第 m 条到第 n 条的所有数据(即:limit m,n),这个分页的功能包括: - 根据用户请求的当前页和总数据条数计算出 m 和 n - 根据 m 和 n 去数据库中请求数据 ```python # ############### 定义 ############### class Pager: def __init__(self, current_page): # 用户当前请求的页码(第一页、第二页...) self.current_page = current_page # 每页默认显示 10 条数据 self.per_items = 10 @property def start(self): val = (self.current_page - 1) * self.per_items return val @property def end(self): val = self.current_page * self.per_items return val # ############### 调用 ############### p = Pager(1) p.start 就是起始值,即:m p.end 就是结束值,即:n ``` 从上述可见,Python 的属性的功能是:属性内部进行一系列的逻辑计算,最终将计算结果返回。 #### 属性的两种定义方式 属性的定义有两种方式: - 装饰器 即:在方法上应用装饰器 - 静态字段 即:在类中定义值为 property 对象的静态字段 **装饰器方式:在类的普通方法上应用 @property 装饰器 ** 我们知道 Python 中的类有经典类和新式类,新式类的属性比经典类的属性丰富。( 如果类继 object,那么该类是新式类 ) **经典类 **,具有一种 @property 装饰器(如上一步实例) ```python # ############### 定义 ############### class Goods: @property def price(self): return "wupeiqi" # ############### 调用 ############### obj = Goods() result = obj.price # 自动执行 @property 修饰的 price 方法,并获取方法的返回值 ``` **新式类 **,具有三种 @property 装饰器 ```python # ############### 定义 ############### class Goods(object): @property def price(self): print '@property' @price.setter def price(self, value): print '@price.setter' @price.deleter def price(self): print '@price.deleter' # ############### 调用 ############### obj = Goods() obj.price # 自动执行 @property 修饰的 price 方法,并获取方法的返回值 obj.price = 123 # 自动执行 @price.setter 修饰的 price 方法,并将 123 赋值给方法的参数 del obj.price # 自动执行 @price.deleter 修饰的 price 方法 ``` > 注:经典类中的属性只有一种访问方式,其对应被 @property 修饰的方法 > 新式类中的属性有三种访问方式,并分别对应了三个被 @property、@方法名.setter、@方法名.deleter 修饰的方法 > > 由于新式类中具有三种访问方式,我们可以根据他们几个属性的访问特点,分别将三个方法定义为对同一个属性:获取、修改、删除 ```python class Goods(object): def __init__(self): # 原价 self.original_price = 100 # 折扣 self.discount = 0.8 @property def price(self): # 实际价格 = 原价 * 折扣 new_price = self.original_price * self.discount return new_price @price.setter def price(self, value): self.original_price = value @price.deltter def price(self, value): del self.original_price obj = Goods() obj.price # 获取商品价格 obj.price = 200 # 修改商品原价 del obj.price # 删除商品原价 ``` 实例 **静态字段方式,创建值为 property 对象的静态字段 ** 当使用静态字段的方式创建属性时,经典类和新式类无区别 ```python class Foo: def get_bar(self): return 'wupeiqi' BAR = property(get_bar) obj = Foo() reuslt = obj.BAR # 自动调用 get_bar 方法,并获取方法的返回值 print reuslt ``` > property 的构造方法中有个四个参数 > > - 第一个参数是 **方法名 **,调用`对象. 属性`时自动触发执行方法 > - 第二个参数是 **方法名 **,调用` 对象. 属性 = XXX`时自动触发执行方法 > - 第三个参数是 **方法名 **,调用`del 对象. 属性 `时自动触发执行方法 > - 第四个参数是 **字符串 **,调用` 对象. 属性.__doc__`,此参数是该属性的描述信息 ```python class Foo: def get_bar(self): return 'wupeiqi' # * 必须两个参数 def set_bar(self, value): return return 'set value' + value def del_bar(self): return 'wupeiqi' BAR = property(get_bar, set_bar, del_bar, 'description...') obj = Foo() obj.BAR # 自动调用第一个参数中定义的方法:get_bar obj.BAR = "alex" # 自动调用第二个参数中定义的方法:set_bar 方法,并将“alex”当作参数传入 del Foo.BAR # 自动调用第三个参数中定义的方法:del_bar 方法 obj.BAE.__doc__ # 自动获取第四个参数中设置的值:description... ``` > 由于静态字段方式创建属性具有三种访问方式,我们可以根据他们几个属性的访问特点,分别将三个方法定义为对同一个属性:获取、修改、删除 ```python class Goods(object): def __init__(self): # 原价 self.original_price = 100 # 折扣 self.discount = 0.8 def get_price(self): # 实际价格 = 原价 * 折扣 new_price = self.original_price * self.discount return new_price def set_price(self, value): self.original_price = value def del_price(self, value): del self.original_price PRICE = property(get_price, set_price, del_price, '价格属性描述...') obj = Goods() obj.PRICE # 获取商品价格 obj.PRICE = 200 # 修改商品原价 del obj.PRICE # 删除商品原价 ``` > 实例 > 注意:Python WEB 框架 Django 的视图中 request.POST 就是使用的静态字段的方式创建的属性 ```python class WSGIRequest(http.HttpRequest): def __init__(self, environ): script_name = get_script_name(environ) path_info = get_path_info(environ) if not path_info: # Sometimes PATH_INFO exists, but is empty (e.g. accessing # the SCRIPT_NAME URL without a trailing slash). We really need to # operate as if they'd requested '/'. Not amazingly nice to force # the path like this, but should be harmless. path_info = '/' self.environ = environ self.path_info = path_info self.path = '%s/%s' % (script_name.rstrip('/'), path_info.lstrip('/')) self.META = environ self.META['PATH_INFO'] = path_info self.META['SCRIPT_NAME'] = script_name self.method = environ['REQUEST_METHOD'].upper() _, content_params = cgi.parse_header(environ.get('CONTENT_TYPE', '')) if 'charset' in content_params: try: codecs.lookup(content_params['charset']) except LookupError: pass else: self.encoding = content_params['charset'] self._post_parse_error = False try: content_length = int(environ.get('CONTENT_LENGTH')) except (ValueError, TypeError): content_length = 0 self._stream = LimitedStream(self.environ['wsgi.input'], content_length) self._read_started = False self.resolver_match = None def _get_scheme(self): return self.environ.get('wsgi.url_scheme') def _get_request(self): warnings.warn('`request.REQUEST` is deprecated, use `request.GET` or ' '`request.POST` instead.', RemovedInDjango19Warning, 2) if not hasattr(self, '_request'): self._request = datastructures.MergeDict(self.POST, self.GET) return self._request @cached_property def GET(self): # The WSGI spec says 'QUERY_STRING' may be absent. raw_query_string = get_bytes_from_wsgi(self.environ, 'QUERY_STRING', '') return http.QueryDict(raw_query_string, encoding=self._encoding) # ############### 看这里看这里 ############### def _get_post(self): if not hasattr(self, '_post'): self._load_post_and_files() return self._post # ############### 看这里看这里 ############### def _set_post(self, post): self._post = post @cached_property def COOKIES(self): raw_cookie = get_str_from_wsgi(self.environ, 'HTTP_COOKIE', '') return http.parse_cookie(raw_cookie) def _get_files(self): if not hasattr(self, '_files'): self._load_post_and_files() return self._files # ############### 看这里看这里 ############### POST = property(_get_post, _set_post) FILES = property(_get_files) REQUEST = property(_get_request) ``` > Django 源码 所以,定义属性共有两种方式,分别是【装饰器】和【静态字段】,而【装饰器】方式针对经典类和新式类又有所不同。 ### 类成员的修饰符 类的所有成员在上一步骤中已经做了详细的介绍,对于每一个类的成员而言都有两种形式: - 公有成员,在任何地方都能访问 - 私有成员,只有在类的内部才能方法 **私有成员和公有成员的定义不同 **:私有成员命名时,前两个字符是下划线。(特殊成员除外,例如:**init**、**call**、**dict** 等) ```python class C: def __init__(self): self.name = '公有字段' self.__foo = "私有字段" ``` **私有成员和公有成员的访问限制不同 **: #### 静态字段 - 公有静态字段:类可以访问;类内部可以访问;派生类中可以访问 - 私有静态字段:仅类内部可以访问; ```python class C: name = "公有静态字段" def func(self): print C.name class D(C): def show(self): print C.name C.name # 类访问 obj = C() obj.func() # 类内部可以访问 obj_son = D() obj_son.show() # 派生类中可以访问 ``` ##### 公有静态字段 ```python class C: __name = "公有静态字段" def func(self): print C.__name class D(C): def show(self): print C.__name C.__name # 类访问 错误 obj = C() obj.func() # 类内部可以访问 正确 obj_son = D() obj_son.show() # 派生类中可以访问 错误 ``` ##### 私有静态字段 #### 普通字段 - 公有普通字段:对象可以访问;类内部可以访问;派生类中可以访问 - 私有普通字段:仅类内部可以访问; ps:如果想要强制访问私有字段,可以通过 【`对象._类名` 私有字段名】访问(如 `obj._C**foo`),不建议强制访问私有成员。 ```python class C: def __init__(self): self.foo = "公有字段" def func(self): print self.foo  # 类内部访问 class D(C): def show(self): print self.foo # 派生类中访问 obj = C() obj.foo # 通过对象访问 obj.func() # 类内部访问 obj_son = D(); obj_son.show() # 派生类中访问 ``` ##### 公有字段 ```python class C: def __init__(self): self.__foo = "私有字段" def func(self): print self.foo  # 类内部访问 class D(C): def show(self): print self.foo # 派生类中访问 obj = C() obj.__foo # 通过对象访问 错误 obj.func() # 类内部访问 正确 obj_son = D(); obj_son.show() # 派生类中访问 错误 ``` ##### 私有字段 方法、属性的访问于上述方式相似,即:私有成员只能在类内部使用 ### 类的特殊成员 上文介绍了 Python 的类成员以及成员修饰符,从而了解到类中有字段、方法和属性三大类成员,并且成员名前如果有两个下划线,则表示该成员是私有成员,私有成员只能由类内部调用。无论人或事物往往都有不按套路出牌的情况,Python 的类成员也是如此,存在着一些具有特殊含义的成员,详情如下: #### . **doc** 表示类的描述信息 ```python class Foo: """ 描述类信息,这是用于看片的神奇 """ def func(self): pass print Foo.__doc__ #输出:类的描述信息 ``` #### . **module** 和 **class** **module** 表示当前操作的对象在那个模块 **class**表示当前操作的对象的类是什么 ```python #!/usr/bin/env python # -*- coding:utf-8 -*- class C: def __init__(self): self.name = 'wupeiqi' ``` lib/aa.py ```python from lib.aa import C obj = C() print obj.__module__ # 输出 lib.aa,即:输出模块 print obj.__class__ # 输出 lib.aa.C,即:输出类 ``` index.py #### . **init** 构造方法,通过类创建对象时,自动触发执行。 ```python class Foo: def __init__(self, name): self.name = name self.age = 18 obj = Foo('wupeiqi') # 自动执行类中的 __init__ 方法 ``` #### . **del** 析构方法,当对象在内存中被释放时,自动触发执行。 注:此方法一般无须定义,因为 Python 是一门高级语言,程序员在使用时无需关心内存的分配和释放,因为此工作都是交给 Python 解释器来执行,所以,析构函数的调用是由解释器在进行垃圾回收时自动触发执行的。 ```python class Foo: def __del__(self): pass ``` #### . **call** 对象后面加括号,触发执行。 注:构造方法的执行是由创建对象触发的,即:对象 = 类名 ();而对于 **call** 方法的执行是由对象后加括号触发的,即:对象 () 或者 类 ()() ```python class Foo: def __init__(self): pass def __call__(self, *args, **kwargs): print '__call__' obj = Foo() # 执行 __init__ obj() # 执行 __call__ ``` #### . **dict** 类或对象中的所有成员 上文中我们知道:类的普通字段属于对象;类中的静态字段和方法等属于类,即: ![20241229154732_oYdwaMPb.webp](https://cdn.dong4j.site/source/image/20241229154732_oYdwaMPb.webp) ```python class Province: country = 'China' def __init__(self, name, count): self.name = name self.count = count def func(self, *args, **kwargs): print 'func' # 获取类的成员,即:静态字段、方法、 print Province.__dict__ # 输出:{'country': 'China', '__module__': '__main__', 'func': , '__init__': , '__doc__': None} obj1 = Province('HeBei',10000) print obj1.__dict__ # 获取 对象 obj1 的成员 # 输出:{'count': 10000, 'name': 'HeBei'} obj2 = Province('HeNan', 3888) print obj2.__dict__ # 获取 对象 obj1 的成员 # 输出:{'count': 3888, 'name': 'HeNan'} ``` #### . **str** 如果一个类中定义了 **str** 方法,那么在打印 对象 时,默认输出该方法的返回值。 ```python class Foo: def __str__(self): return 'wupeiqi' obj = Foo() print obj # 输出:wupeiqi ``` #### **getitem** **setitem** **delitem** 用于索引操作,如字典。以上分别表示获取、设置、删除数据 ```python #!/usr/bin/env python # -*- coding:utf-8 -*- class Foo(object): def __getitem__(self, key): print '__getitem__',key def __setitem__(self, key, value): print '__setitem__',key,value def __delitem__(self, key): print '__delitem__',key obj = Foo() result = obj['k1'] # 自动触发执行 __getitem__ obj['k2'] = 'wupeiqi' # 自动触发执行 __setitem__ del obj['k1'] # 自动触发执行 __delitem__ ``` #### **getslice** **setslice** **delslice** 该三个方法用于分片操作,如:列表 ```python #!/usr/bin/env python # -*- coding:utf-8 -*- class Foo(object): def __getslice__(self, i, j): print '__getslice__',i,j def __setslice__(self, i, j, sequence): print '__setslice__',i,j def __delslice__(self, i, j): print '__delslice__',i,j obj = Foo() obj[-1:1] # 自动触发执行 __getslice__ obj[0:1] = [11,22,33,44] # 自动触发执行 __setslice__ del obj[0:2] # 自动触发执行 __delslice__ ``` #### . **iter** 用于迭代器,之所以列表、字典、元组可以进行 for 循环,是因为类型内部定义了 **iter** ```python class Foo(object): pass obj = Foo() for i in obj: print i # 报错:TypeError: 'Foo' object is not iterable ``` 第一步 ```python #!/usr/bin/env python # -*- coding:utf-8 -*- class Foo(object): def __iter__(self): pass obj = Foo() for i in obj: print i # 报错:TypeError: iter() returned non-iterator of type 'NoneType' ``` 第二步 ```python #!/usr/bin/env python # -*- coding:utf-8 -*- class Foo(object): def __init__(self, sq): self.sq = sq def __iter__(self): return iter(self.sq) obj = Foo([11,22,33,44]) for i in obj: print i ``` 第三步 以上步骤可以看出,for 循环迭代的其实是 iter([11,22,33,44]) ,所以执行流程可以变更为: ```python #!/usr/bin/env python # -*- coding:utf-8 -*- obj = iter([11,22,33,44]) for i in obj: print i ``` ```python #!/usr/bin/env python # -*- coding:utf-8 -*- obj = iter([11,22,33,44]) while True: val = obj.next() print val ``` For 循环语法内部 #### . **new** 和 **metaclass** 阅读以下代码: ```python class Foo(object): def __init__(self): pass obj = Foo() # obj 是通过 Foo 类实例化的对象 ``` 上述代码中,obj 是通过 Foo 类实例化的对象,其实,不仅 obj 是一个对象,Foo 类本身也是一个对象,因为在 **Python 中一切事物都是对象 **。 如果按照一切事物都是对象的理论:obj 对象是通过执行 Foo 类的构造方法创建,那么 Foo 类对象应该也是通过执行某个类的 构造方法 创建。 ```python print type(obj) # 输出: 表示,obj 对象由 Foo 类创建 print type(Foo) # 输出: 表示,Foo 类对象由 type 类创建 ``` 所以,**obj 对象是 Foo 类的一个实例 **,**Foo 类对象是 type 类的一个实例 **,即:Foo 类对象 是通过 type 类的构造方法创建。 ## 创建类就可以有两种方式 ### 普通方式 ```python class Foo(object): def func(self): print 'hello wupeiqi' ``` ### 特殊方式(type 类的构造函数) ```python def func(self): print 'hello wupeiqi' Foo = type('Foo',(object,), {'func': func}) #type 第一个参数:类名 #type 第二个参数:当前类的基类 #type 第三个参数:类的成员 ``` ==》 类 是由 type 类实例化产生 那么问题来了,类默认是由 type 类实例化产生,type 类中如何实现的创建类?类又是如何创建对象? 答:类中有一个属性**metaclass**,其用来表示该类由 谁 来实例化创建,所以,我们可以为**metaclass** 设置一个 type 类的派生类,从而查看 类 创建的过程。 ![20241229154732_GNU1bhCj.webp](https://cdn.dong4j.site/source/image/20241229154732_GNU1bhCj.webp) ```python class MyType(type): def __init__(self, what, bases=None, dict=None): super(MyType, self).__init__(what, bases, dict) def __call__(self, *args, **kwargs): obj = self.__new__(self, *args, **kwargs) self.__init__(obj) class Foo(object): __metaclass__ = MyType def __init__(self, name): self.name = name def __new__(cls, *args, **kwargs): return object.__new__(cls, *args, **kwargs) # 第一阶段:解释器从上到下执行代码创建 Foo 类 # 第二阶段:通过 Foo 类创建 obj 对象 obj = Foo() ``` ## [Python OOP 入门:轻松掌握封装、继承和多态](https://blog.dong4j.site/posts/b158ae75.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Python 面向对象基础部分 原文出处: [武沛齐 cnblog](http://www.cnblogs.com/wupeiqi/p/4493506.html) ## 概述 - 面向过程:根据业务逻辑从上到下写垒代码 - 函数式:将某功能代码封装到函数中,日后便无需重复编写,仅调用函数即可 - 面向对象:对函数进行分类和封装,让开发“更快更好更强…” 面向过程编程最易被初学者接受,其往往用一长段代码来实现指定功能,开发过程中最常见的操作就是粘贴复制,即:将之前实现的代码块复制到现需功能处。 ```python while True: if cpu 利用率 > 90%: #发送邮件提醒 连接邮箱服务器 发送邮件 关闭连接 if 硬盘使用空间 > 90%: #发送邮件提醒 连接邮箱服务器 发送邮件 关闭连接 if 内存占用 > 80%: #发送邮件提醒 连接邮箱服务器 发送邮件 关闭连接 ``` 随着时间的推移,开始使用了函数式编程,增强代码的重用性和可读性,就变成了这样 ```python def 发送邮件 (内容) #发送邮件提醒 连接邮箱服务器 发送邮件 关闭连接 while True: if cpu 利用率 > 90%: 发送邮件 ('CPU报警') if 硬盘使用空间 > 90%: 发送邮件 ('硬盘报警') if 内存占用 > 80%: 发送邮件 ('内存报警') ``` 今天我们来学习一种新的编程方式:面向对象编程(Object Oriented Programming,OOP,面向对象程序设计) ## 创建类和对象 面向对象编程是一种编程方式,此编程方式的落地需要使用 “类” 和 “对象” 来实现,所以,面向对象编程其实就是对 “类” 和 “对象” 的使用。 类就是一个模板,模板里可以包含多个函数,函数里实现一些功能 对象则是根据模板创建的实例,通过实例对象可以执行类中的函数 ![20241229154732_wkz3JL10.webp](https://cdn.dong4j.site/source/image/20241229154732_wkz3JL10.webp) - class 是关键字,表示类 - 创建对象,类名称后加括号即可 _ps:类中的函数第一个参数必须是 self(详细见:类的三大特性之封装)_ _类中定义的函数叫做 “方法”_ ```python # 创建类 class Foo: def Bar(self): print 'Bar' def Hello(self, name): print 'i am %s' %name # 根据类 Foo 创建对象 obj obj = Foo() obj.Bar() #执行 Bar 方法 obj.Hello('wupeiqi') #执行 Hello 方法 ``` - 诶,你在这里是不是有疑问了?使用函数式编程和面向对象编程方式来执行一个“方法”时函数要比面向对象简便 - - 面向对象:【创建对象】【通过对象执行方法】 - - 函数编程:【执行函数】 观察上述对比答案则是肯定的,然后并非绝对,场景的不同适合其的编程方式也不同。 总结:函数式的应用场景 –> 各个函数之间是独立且无共用的数据 ### 面向对象三大特性 面向对象的三大特性是指:封装、继承和多态。 #### 封装 封装,顾名思义就是将内容封装到某个地方,以后再去调用被封装在某处的内容。 所以,在使用面向对象的封装特性时,需要: - 将内容封装到某处 - 从某处调用被封装的内容 **第一步:将内容封装到某处 ![20241229154732_KUELWY3L.webp](https://cdn.dong4j.site/source/image/20241229154732_KUELWY3L.webp)** self 是一个形式参数 当执行 obj1 = Foo(‘wupeiqi’, 18) 时,self 等于 obj1 当执行 obj2 = Foo(‘alex’, 78) 时,self 等于 obj2 所以,内容其实被封装到了对象 obj1 和 obj2 中,每个对象中都有 name 和 age 属性,在内存里类似于下图来保存。![20241229154732_CaGPuPCm.webp](https://cdn.dong4j.site/source/image/20241229154732_CaGPuPCm.webp) **第二步:从某处调用被封装的内容** 调用被封装的内容时,有两种情况: - 通过对象直接调用 - 通过 self 间接调用 1、通过对象直接调用被封装的内容 上图展示了对象 obj1 和 obj2 在内存中保存的方式,根据保存格式可以如此调用被封装的内容:对象. 属性名 ```python class Foo: def __init__(self, name, age): self.name = name self.age = age obj1 = Foo('wupeiqi', 18) print obj1.name # 直接调用 obj1 对象的 name 属性 print obj1.age # 直接调用 obj1 对象的 age 属性 obj2 = Foo('alex', 73) print obj2.name # 直接调用 obj2 对象的 name 属性 print obj2.age # 直接调用 obj2 对象的 age 属性 ``` 2、通过 self 间接调用被封装的内容 执行类中的方法时,需要通过 self 间接调用被封装的内容 ```python class Foo: def __init__(self, name, age): self.name = name self.age = age def detail(self): print self.name print self.age obj1 = Foo('wupeiqi', 18) obj1.detail()# Python 默认会将 obj1 传给 self 参数,即:obj1.detail(obj1),所以,此时方法内部的 self = obj1,即:self.name 是 wupeiqi ;self.age 是 18 obj2 = Foo('alex', 73) obj2.detail()# Python 默认会将 obj2 传给 self 参数,即:obj1.detail(obj2),所以,此时方法内部的 self = obj2,即:self.name 是 alex ; self.age 是 78 ``` **综上所述,对于面向对象的封装来说,其实就是使用构造方法将内容封装到 对象 中,然后通过对象直接或者 self 间接获取被封装的内容。** ``` 练习一:在终端输出如下信息 小明,10 岁,男,上山去砍柴 小明,10 岁,男,开车去东北 小明,10 岁,男,最爱大保健 老李,90 岁,男,上山去砍柴 老李,90 岁,男,开车去东北 老李,90 岁,男,最爱大保健 老张... ``` ```python def kanchai(name, age, gender): print "%s,%s岁,%s,上山去砍柴" %(name, age, gender) def qudongbei(name, age, gender): print "%s,%s岁,%s,开车去东北" %(name, age, gender) def dabaojian(name, age, gender): print "%s,%s岁,%s,最爱大保健" %(name, age, gender) kanchai('小明', 10, '男') qudongbei('小明', 10, '男') dabaojian('小明', 10, '男') kanchai('老李', 90, '男') qudongbei('老李', 90, '男') dabaojian('老李', 90, '男') ``` ```python class Foo: def __init__(self, name, age ,gender): self.name = name self.age = age self.gender = gender def kanchai(self): print "%s,%s岁,%s,上山去砍柴" %(self.name, self.age, self.gender) def qudongbei(self): print "%s,%s岁,%s,开车去东北" %(self.name, self.age, self.gender) def dabaojian(self): print "%s,%s岁,%s,最爱大保健" %(self.name, self.age, self.gender) xiaoming = Foo('小明', 10, '男') xiaoming.kanchai() xiaoming.qudongbei() xiaoming.dabaojian() laoli = Foo('老李', 90, '男') laoli.kanchai() laoli.qudongbei() laoli.dabaojian() ``` ``` 练习二:游戏人生程序 1、创建三个游戏人物,分别是: 苍井井,女,18,初始战斗力 1000 东尼木木,男,20,初始战斗力 1800 波多多,女,19,初始战斗力 2500 2、游戏场景,分别: 草丛战斗,消耗 200 战斗力 自我修炼,增长 100 战斗力 多人游戏,消耗 500 战斗力 ``` ```python # -*- coding:utf-8 -*- # ##################### 定义实现功能的类 ##################### class Person: def __init__(self, na, gen, age, fig): self.name = na self.gender = gen self.age = age self.fight =fig def grassland(self): """注释:草丛战斗,消耗200战斗力""" self.fight = self.fight - 200 def practice(self): """注释:自我修炼,增长100战斗力""" self.fight = self.fight + 200 def incest(self): """注释:多人游戏,消耗500战斗力""" self.fight = self.fight - 500 def detail(self): """注释:当前对象的详细情况""" temp = "姓名:%s ; 性别:%s ; 年龄:%s ; 战斗力:%s" % (self.name, self.gender, self.age, self.fight) print temp # ##################### 开始游戏 ##################### cang = Person('苍井井', '女', 18, 1000) # 创建苍井井角色 dong = Person('东尼木木', '男', 20, 1800) # 创建东尼木木角色 bo = Person('波多多', '女', 19, 2500) # 创建波多多角色 cang.incest() #苍井空参加一次多人游戏 dong.practice()# 东尼木木自我修炼了一次 bo.grassland() #波多多参加一次草丛战斗 #输出当前所有人的详细情况 cang.detail() dong.detail() bo.detail() cang.incest() #苍井空又参加一次多人游戏 dong.incest() #东尼木木也参加了一个多人游戏 bo.practice() #波多多自我修炼了一次 #输出当前所有人的详细情况 cang.detail() dong.detail() bo.detail() ``` #### 继承 继承,面向对象中的继承和现实生活中的继承相同,即:子可以继承父的内容。 例如: 猫可以:喵喵叫、吃、喝、拉、撒 狗可以:汪汪叫、吃、喝、拉、撒 如果我们要分别为猫和狗创建一个类,那么就需要为 猫 和 狗 实现他们所有的功能,如下所示: ```python class 猫: def 喵喵叫 (self): print '喵喵叫' def 吃 (self): # do something def 喝 (self): # do something def 拉 (self): # do something def 撒 (self): # do something class 狗: def 汪汪叫 (self): print '喵喵叫' def 吃 (self): # do something def 喝 (self): # do something def 拉 (self): # do something def 撒 (self): # do something ``` 上述代码不难看出,吃、喝、拉、撒是猫和狗都具有的功能,而我们却分别的猫和狗的类中编写了两次。如果使用 继承 的思想,如下实现: 动物:吃、喝、拉、撒 猫:喵喵叫(猫继承动物的功能) 狗:汪汪叫(狗继承动物的功能) ```python class 动物: def 吃 (self): # do something def 喝 (self): # do something def 拉 (self): # do something def 撒 (self): # do something # 在类后面括号中写入另外一个类名,表示当前类继承另外一个类 class 猫 (动物): def 喵喵叫 (self): print '喵喵叫' # 在类后面括号中写入另外一个类名,表示当前类继承另外一个类 class 狗 (动物): def 汪汪叫 (self): print '喵喵叫' ``` ```python class Animal: def eat(self): print "%s 吃 " %self.name def drink(self): print "%s 喝 " %self.name def shit(self): print "%s 拉 " %self.name def pee(self): print "%s 撒 " %self.name class Cat(Animal): def __init__(self, name): self.name = name self.breed = '猫' def cry(self): print '喵喵叫' class Dog(Animal): def __init__(self, name): self.name = name self.breed = '狗' def cry(self): print '汪汪叫' # ######### 执行 ######### c1 = Cat('小白家的小黑猫') c1.eat() c2 = Cat('小黑的小白猫') c2.drink() d1 = Dog('胖子家的小瘦狗') d1.eat() ``` **所以,对于面向对象的继承来说,其实就是将多个类共有的方法提取到父类中,子类仅需继承父类而不必一一实现每个方法。** - 注:除了子类和父类的称谓,你可能看到过 派生类 和 基类 ,他们与子类和父类只是叫法不同而已。![20241229154732_M6gkWMir.webp](https://cdn.dong4j.site/source/image/20241229154732_M6gkWMir.webp) 学习了继承的写法之后,我们用代码来是上述阿猫阿狗的功能: ```python class Animal: def eat(self): print "%s 吃 " %self.name def drink(self): print "%s 喝 " %self.name def shit(self): print "%s 拉 " %self.name def pee(self): print "%s 撒 " %self.name class Cat(Animal): def __init__(self, name): self.name = name self.breed = '猫' def cry(self): print '喵喵叫' class Dog(Animal): def __init__(self, name): self.name = name self.breed = '狗' def cry(self): print '汪汪叫' # ######### 执行 ######### c1 = Cat('小白家的小黑猫') c1.eat() c2 = Cat('小黑的小白猫') c2.drink() d1 = Dog('胖子家的小瘦狗') d1.eat() ``` **那么问题又来了,多继承呢?** - 是否可以继承多个类 - 如果继承的多个类每个类中都定了相同的函数,那么那一个会被使用呢? 1、Python 的类可以继承多个类,Java 和 C# 中则只能继承一个类 2、Python 的类如果继承了多个类,那么其寻找方法的方式有两种,分别是:**深度优先** 和 **广度优先 ![20241229154732_C3em8waV.webp](https://cdn.dong4j.site/source/image/20241229154732_C3em8waV.webp)** - 当类是经典类时,多继承情况下,会按照深度优先方式查找 - 当类是新式类时,多继承情况下,会按照广度优先方式查找 经典类和新式类,从字面上可以看出一个老一个新,新的必然包含了跟多的功能,也是之后推荐的写法,从写法上区分的话,如果**当前类或者父类继承了 object 类**,那么该类便是新式类,否则便是经典类。![20241229154732_AOCRysi0.webp](https://cdn.dong4j.site/source/image/20241229154732_AOCRysi0.webp) ![20241229154732_L3hW7L54.webp](https://cdn.dong4j.site/source/image/20241229154732_L3hW7L54.webp) ```python class D: def bar(self): print 'D.bar' class C(D): def bar(self): print 'C.bar' class B(D): def bar(self): print 'B.bar' class A(B, C): def bar(self): print 'A.bar' a = A() # 执行 bar 方法时 # 首先去 A 类中查找,如果 A 类中没有,则继续去 B 类中找,如果 B 类中么有,则继续去 D 类中找,如果 D 类中么有,则继续去 C 类中找,如果还是未找到,则报错 # 所以,查找顺序:A --> B --> D --> C # 在上述查找 bar 方法的过程中,一旦找到,则寻找过程立即中断,便不会再继续找了 a.bar() ``` ```python class D(object): def bar(self): print 'D.bar' class C(D): def bar(self): print 'C.bar' class B(D): def bar(self): print 'B.bar' class A(B, C): def bar(self): print 'A.bar' a = A() # 执行 bar 方法时 # 首先去 A 类中查找,如果 A 类中没有,则继续去 B 类中找,如果 B 类中么有,则继续去 C 类中找,如果 C 类中么有,则继续去 D 类中找,如果还是未找到,则报错 # 所以,查找顺序:A --> B --> C --> D # 在上述查找 bar 方法的过程中,一旦找到,则寻找过程立即中断,便不会再继续找了 a.bar() ``` 经典类:首先去 **A** 类中查找,如果 A 类中没有,则继续去 **B** 类中找,如果 B 类中么有,则继续去 **D** 类中找,如果 D 类中么有,则继续去 **C** 类中找,如果还是未找到,则报错 新式类:首先去 **A** 类中查找,如果 A 类中没有,则继续去 **B** 类中找,如果 B 类中么有,则继续去 **C** 类中找,如果 C 类中么有,则继续去 **D** 类中找,如果还是未找到,则报错 注意:在上述查找过程中,一旦找到,则寻找过程立即中断,便不会再继续找了 #### 多态 Pyhon 不支持多态并且也用不到多态,多态的概念是应用于 Java 和 C# 这一类强类型语言中,而 Python 崇尚“鸭子类型”。 ```python class F1: pass class S1(F1): def show(self): print 'S1.show' class S2(F1): def show(self): print 'S2.show' # 由于在 Java 或 C# 中定义函数参数时,必须指定参数的类型 # 为了让 Func 函数既可以执行 S1 对象的 show 方法,又可以执行 S2 对象的 show 方法,所以,定义了一个 S1 和 S2 类的父类 # 而实际传入的参数是:S1 对象和 S2 对象 def Func(F1 obj): """Func函数需要接收一个F1类型或者F1子类的类型""" print obj.show() s1_obj = S1() Func(s1_obj) # 在 Func 函数中传入 S1 类的对象 s1_obj,执行 S1 的 show 方法,结果:S1.show s2_obj = S2() Func(s2_obj) # 在 Func 函数中传入 Ss 类的对象 ss_obj,执行 Ss 的 show 方法,结果:S2.show ``` ```python class F1: pass class S1(F1): def show(self): print 'S1.show' class S2(F1): def show(self): print 'S2.show' def Func(obj): print obj.show() s1_obj = S1() Func(s1_obj) s2_obj = S2() Func(s2_obj) ``` ## 总结 以上就是本节对于面向对象初级知识的介绍,总结如下: - 面向对象是一种编程方式,此编程方式的实现是基于对** 类 **和** 对象 **的使用 - 类 是一个模板,模板中包装了多个“函数”供使用 - 对象,根据模板创建的实例(即:对象),实例用于调用被包装在类中的函数 - 面向对象三大特性:封装、继承和多态 **问答专区** ### 问题一:什么样的代码才是面向对象 - 答:从简单来说,如果程序中的所有功能都是用 类 和 对象 来实现,那么就是面向对象编程了。 ### 问题二:函数式编程 和 面向对象 如何选择?分别在什么情况下使用? - 答:须知:对于 C# 和 Java 程序员来说不存在这个问题,因为该两门语言只支持面向对象编程(不支持函数式编程)。而对于 Python 和 PHP 等语言却同时支持两种编程方式,且函数式编程能完成的操作,面向对象都可以实现;而面向对象的能完成的操作,函数式编程不行(函数式编程无法实现面向对象的封装功能)。 - 所以,一般在 Python 开发中,**全部使用面向对象**或**面向对象和函数式混合使用** - 面向对象的应用场景: 1. **多函数需使用共同的值,如:数据库的增、删、改、查操作都需要连接数据库字符串、主机名、用户名和密码 ** ````python class SqlHelper: def __init__(self, host, user, pwd): self.host = host self.user = user self.pwd = pwd def 增 (self): # 使用主机名、用户名、密码(self.host 、self.user 、self.pwd)打开数据库连接 # do something # 关闭数据库连接 def 删 (self): # 使用主机名、用户名、密码(self.host 、self.user 、self.pwd)打开数据库连接 # do something # 关闭数据库连接 def 改 (self): # 使用主机名、用户名、密码(self.host 、self.user 、self.pwd)打开数据库连接 # do something # 关闭数据库连接 def 查 (self): # 使用主机名、用户名、密码(self.host 、self.user 、self.pwd)打开数据库连接 # do something # 关闭数据库连接# do something ``` 2. * 需要创建多个事物,每个事物属性个数相同,但是值的需求 * **如:张三、李四、杨五,他们都有姓名、年龄、血型,但其都是不相同。即:属性个数相同,但值不相同** ```python class Person: def __init__(self, name ,age ,blood_type): self.name = name self.age = age self.blood_type = blood_type def detail(self): temp = "i am %s, age %s , blood type %s " % (self.name, self.age, self.blood_type) print temp zhangsan = Person('张三', 18, 'A') lisi = Person('李四', 73, 'AB') yangwu = Person('杨五', 84, 'A') ```` ### 问题三:类和对象在内存中是如何保存? 答:_ 类以及类中的方法在内存中只有一份,而根据类创建的每一个对象都在内存中需要存一份,大致如下图:_ ![20241229154732_UvmyvrBy.webp](https://cdn.dong4j.site/source/image/20241229154732_UvmyvrBy.webp) - 如上图所示,根据类创建对象时,对象中除了封装 name 和 age 的值之外,还会保存一个 **类对象指针 **,该值指向当前对象的类。 - 当通过 obj1 执行 【方法一】 时,过程如下 1. 根据当前对象中的 类对象指针 找到类中的方法 2. 将对象 obj1 当作参数传给 方法的第一个参数 self ## [日志追踪系统设计:构建高效监控系统](https://blog.dong4j.site/posts/747eedc9.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 一个基于 dubbo filter 的日志追溯系统设计 ## 解决的问题 1. 了解线上系统的运行状态 2. 快速准确定位线上问题 3. 发现系统瓶颈 4. 预警系统潜在风险 5. 挖掘产品最大价值 ## 日志的分类 1. 诊断日志, 典型的有: - 请求入口和出口 - 外部服务调用和返回 - 资源消耗操作: 如读写文件等 - 容错行为: 如云硬盘的副本修复操作 - 程序异常: 如数据库无法连接 - 后台操作:定期执行删除的线程 - 启动、关闭、配置加载 2. 统计日志: - 用户访问统计:用户 IP、上传下载的数据量,请求耗时等 - 计费日志(如记录用户使用的网络资源或磁盘占用,格式较为严格,便于统计) 3. 审计日志: - 管理操作 对于简单的系统,可以将所有的日志输出到同一个日志文件中,并通过不同的关键字进行区分。而对于复杂的系统,将不同需求的日志输出到不同的日志文件中是必要的,通过对不同类型的文件采用不同的日志格式 4. **业务日志 ** ## 分布式日志系统的设计 ![20241229154732_awHDAlCW.webp](https://cdn.dong4j.site/source/image/20241229154732_awHDAlCW.webp) ### 日志采集器 负责把业务系统的方法调用栈和远程服务调用信息从业务系统中传递给处理器 实现方式有: 1. 应用层编写代码主动推送 2. AOP 拦截 3. JavaAgent 字节码增强 **问题:** 这里推送给采集器的日志是统一格式的日志, 并非我们自己写的 log 输出, 自己写的 log 输出可以通过 appender 输出给 logstash. #### 生成调用链 ID 一个系统通常通过 traceId 来对请求进行唯一的标记,目的是可以通过 traceId 将一个请求在系统中的执行过程串联起来。该 traceId 通常会随着响应返回给调用者,如果调用出现问题,调用者也可以通过提供 traceId 帮助服务提供者定位问题。 ![20241229154732_abCFALVC.webp](https://cdn.dong4j.site/source/image/20241229154732_abCFALVC.webp) #### 传递调用链信息 前面生成的 traceId 传递到下一级调用, 从而将调用信息关联起来. 这里分为 5 种传递方式: 1. Java 进程内传递 2. 服务间传递 3. 主子线程间传递 4. 消息队列的传递 5. 缓存, 数据库访问 ![20241229154732_sqTtlpKe.webp](https://cdn.dong4j.site/source/image/20241229154732_sqTtlpKe.webp) ##### Java 进程内传递 在 Java 应用系统中, 通过 ThreadLocal 来传递 TraceId 和 spanId ![20241229154732_1PA0DAtk.webp](https://cdn.dong4j.site/source/image/20241229154732_1PA0DAtk.webp) ##### 服务间传递 在服务间通过 RPC 传递 使用 com.alibaba.dubbo.rpc.RpcInvocation 的 setAttachment 和 getAttachment 来设置和获取自定义上下文数据 ![20241229154732_1Xucebl3.webp](https://cdn.dong4j.site/source/image/20241229154732_1Xucebl3.webp) ##### 主子线程间传递 将一些耗时或者非核心业务通过异步线程处理, 如果要跟踪这部分调用链, 则需要在创建子线程时, 将 traceId 和 spanId 一起传递过去, 并放在子线程的 ThreadLocal 中. ##### 消息队列的传递 对 kafka 消息队列的跟踪 1. 发送消息时, 手工添加 traceId 和 spanId 2. 修改 kafka 源码, 在每次发送消息时, 将 traceId 和 spanId 增加到消息报文中, 在消息队列的处理端的库中先解析报文, 再讲业务报文传递给业务层处理. ##### 缓存, 数据库访问 封装缓存, 数据库的客户端, 将 traceId 和 spanId 与访问的数据进行关联 #### 采集率设置 因为 QPS 越高,需要生成的调用日志也就越高。因此,为了降低整体的输出数据量,根据 traceId 中的顺序数进行采样,提供了多种采样策略搭配: - 100% 采样:它会对业务带来影响,因此最好内置支持客户端自动降级; - 固定阈值采样:全局或租户内统一控制; - 限速采样:在入口处按固定频率采样若干条调用链; - 异常优先采样:调用出错时优先采样; - 个性化采样:按用户 ID、入口 IP、应用、调用链入口、业务标识等配置开启采样。 通过上面这五种采样策略的搭配使用,可以灵活地控制调用链上数据的输出,确保数据量不会过大。 ### 日志处理器 负责从业务系统的采集器中接收服务调用信息并聚合调用链, 将其存储在分布式数据存储中, 以及对调用链进行分析, 并输出给监控和报警系统. 聚合调用链信息, 从中发现调用的问题, 如: 1. 抛出异常 2. 调用超时 3. 统计调用次数等 4. 发送报警短信, 邮件, 微信信息等 ### 日志存储和搜索 存储海量的调用链数据, 并支持灵活的查询和搜索功能 #### 关联业务 Id 在业务日志中记录 traceId、业务事件 id 等信息,从而建立调用链与业务事件日志的关联, 通过业务 Id 反查 traceId, 获取更多的上下文信息 一个兑吧订单(order_234325123124)发现存在问题, 我们可以根据订单号查到与之绑定的 traceId, 根据 traceId 不仅可以查看系统调用的事件, 还可以看到与业务相关的事件, 如用户积分情况等,也就是说根据业务 Id 可以在调用链上查看兑吧商品, 用户积分以及扣除积分等信息,大大提升错误排查速度。 ### 日志展示系统 支持查询调用链, 业务链等功能 为了更直观的查看调用链信息, 需要一个前端展示页面 ### 监控和报警 通过定时的在日志存储和搜索系统中查找服务和某一标准的数据指标, 然后与设置的阈值对比, 如果超出阈值, 则通过短信, 邮件或微信来报警 ## 日志系统优化和最佳实践 1. 打印日志的最佳时机 2. 日志级别设置 3. 日志的数量和大小 4. 日志的切割方式 5. 日志格式设置 ### 日志级别设置 - QA 环境使用 debug 及以下级别日志 - 刚刚上线的引用还没到稳定期, 使用 debug 级别的日志 - 上线稳定后使用 info 级别的日志 - 常年稳定不出问题的应用使用 error 级别的日志 - 选择一台机器开启 DEBUG 级别的日志, 在线上任何问题的时候,都可以通过日志最快的找到问题的根源。 #### 日志级别的使用 对于日志级别的分类,有以下参考: - **FATAL** 表示需要立即被处理的系统级错误。当该错误发生时,表示服务已经出现了某种程度的不可用,系统管理员需要立即介入。这属于最严重的日志级别,因此该日志级别必须慎用,如果这种级别的日志经常出现,则该日志也失去了意义。通常情况下,** 一个进程的生命周期中应该只记录一次 FATAL 级别的日志,即该进程遇到无法恢复的错误而退出时 **。当然,如果某个系统的子系统遇到了不可恢复的错误,那该子系统的调用方也可以记入 FATAL 级别日志,以便通过日志报警提醒系统管理员修复; - **ERROR** 该级别的错误也需要马上被处理,但是紧急程度要低于 FATAL 级别。当 ERROR 错误发生时,已经影响了用户的正常访问。从该意义上来说,实际上 ERROR 错误和 FATAL 错误对用户的影响是相当的。FATAL 相当于服务已经挂了,而 ERROR 相当于好死不如赖活着,然而活着却无法提供正常的服务,只能不断地打印 ERROR 日志。特别需要注意的是,ERROR 和 FATAL 都属于服务器自己的异常,是需要马上得到人工介入并处理的。**而对于用户自己操作不当,如请求参数错误等等,是绝对不应该记为 ERROR 日志的 **; - **WARN**该日志表示系统可能出现问题,也可能没有,这种情况如网络的波动等。对于那些目前还不是错误,然而不及时处理也会变为错误的情况,也可以记为 WARN 日志,例如一个存储系统的磁盘使用量超过阈值,或者系统中某个用户的存储配额快用完等等。对于 WARN 级别的日志,虽然不需要系统管理员马上处理,也是需要及时查看并处理的。因此此种级别的日志也不应太多,能不打 WARN 级别的日志,就尽量不要打; - **INFO**该种日志记录系统的正常运行状态,例如某个子系统的初始化,某个请求的成功执行等等。通过查看 INFO 级别的日志,可以很快地对系统中出现的 WARN,ERROR,FATAL 错误进行定位。INFO 日志不宜过多,通常情况下,INFO 级别的日志应该不大于 TRACE 日志的 10%; - **DEBUG**or**TRACE**这两种日志具体的规范应该由项目组自己定义,该级别日志的主要作用是对系统每一步的运行状态进行精确的记录。通过该种日志,可以查看某一个操作每一步的执 行过程,可以准确定位是何种操作,何种参数,何种顺序导致了某种错误的发生。可以保证在不重现错误的情况下,也可以通过 DEBUG(或 TRACE)级别的日志对问题进行诊断。需要注意的是,**DEBUG 日志也需要规范日志格式 **,应该保证除了记录日志的开发人员自己外,其他的如运维,测试人员等也可以通过 DEBUG(或 TRACE)日志来定位问题; ### 日志数量和大小 控制日志的输出量, 避免由于业务量大而导致服务器磁盘大量输出无用的日志而被占满 - 只打印关键信息, 不要随便把对象 Json 序列化后打印出来 - 单条日志不能超过 1K - 定时任务删除之前的日志 (logback maxHistory 属性设置) ### 切割方式 - 使用日志框架自带的 Appender 将日志按照日期进行切割 (不同系统根据业务量设置不同的日志切割方式) - 将不同级别的日志输出到不同文件中. ### 日志格式的配置 1. 发生时间 2. 日志级别 (DEBUG,INFO,WARN,ERROR,FATAL) 3. 日志输出标记 4. 线程标识 5. 文件名称 (DEBUG 日志需要, 非 DEBUG 日志可以为空) 6. 文件行号 (DEBUG 日志需要, 非 DEBUG 日志可以为空) 7. 函数名称 (DEBUG 日志需要, 非 DEBUG 日志可以为空) 8. 日志描述 ### 开发人员的日志意识 不管是通过 AOP 还是 Filter 来采集日志, 都是按照统一的格式来输出日志, 是为了更好的聚合这些日志, 方便查询. 而在程序中难免需要我们手动添加一些日志, 来调试代码或者在重要的业务中输出信息等, 我们也必须按照统一的格式打印日志, 方便搜索. 1. 统一的日志格式 2. 使用占位符的方式打印日志 3. **使用 MDC** ## [掌握MDC,Java日志追踪新高度](https://blog.dong4j.site/posts/36b6823d.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) log MDC 的使用 ### MDC 介绍 MDC(Mapped Diagnostic Context, 映射调试上下文)是 log4j 和 logback 提供的一种方便在多线程条件下记录日志的功能. 某些应用程序采用多线程的方式来处理多个用户的请求. 在一个用户的使用过程中, 可能有多个不同的线程来进行处理. 典型的例子是 Web 应用服务器. 当用户访问某个页面时, 应用服务器可能会创建一个新的线程来处理该请求, 也可能从线程池中复用已有的线程. 在一个用户的会话存续期间, 可能有多个线程处理过该用户的请求. 这使得比较难以区分不同用户所对应的日志. 当需要追踪某个用户在系统中的相关日志记录时, 就会变得很麻烦. 一种解决的办法是采用自定义的日志格式, 把用户的信息采用某种方式编码在日志记录中. 这种方式的问题在于要求在每个使用日志记录器的类中, 都可以访问到用户相关的信息. 这样才可能在记录日志时使用. 这样的条件通常是比较难以满足的. MDC 的作用是解决这个问题. MDC 可以看成是一个与当前线程绑定的哈希表, 可以往其中添加键值对. MDC 中包含的内容可以被同一线程中执行的代码所访问. 当前线程的子线程会继承其父线程中的 MDC 的内容. 当需要记录日志时, 只需要从 MDC 中获取所需的信息即可. MDC 的内容则由程序在适当的时候保存进去. 对于一个 Web 应用来说, 通常是在请求被处理的最开始保存这些数据. ### MDC 使用案例 相对比较大的项目来说, 一般会有多个开发人员, 如果每个开发人员凭自己的理解打印日志, 那么当用户反馈问题时, 很难通过日志去快速的定位到出错原因, 也会消耗更多的时间. 所以针对这种问题, 一般会定义好整个项目的日志格式, 如果是需要追踪的日志, 开发人员调用统一的打印方法, 在日志配置文件里面定义好相应的字段, 通过 MDC 功能就能很好的解决问题. 比如我们可以事先把用户的 sessionId, 登录用户的用户名, 访问的城市 id, 当前访问商户 id 等信息定义成字段, 线程开始时把值放入 MDC 里面, 后续在其他地方就能直接使用, 无需再去设置了. 使用 MDC 来记录日志, 一来可以规范多开发下日志格式的一致性, 二来可以为后续使用 ELK 对日志进行分析. 所需依赖 ```xml log4j log4j 1.2.17 org.slf4j slf4j-log4j12 1.7.21 ``` log4j.xml 配置样例, 追踪日志自定义格式主要在 name="trance" 的 layout 里面进行设置, 我们使用 %X{userName} 来定义此处会打印 MDC 里面 key 为 userName 的 value, 如果所定义的字段在 MDC 不存在对应的 key, 那么将不会打印, 会留一个占位符. ```xml ``` 日志打印类 ```java import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class TraceLogger { // 此处的 "tranceLog" 为 log4j 中定义的对应的 logger 的 name private static final Logger TRACE_LOGGER = LoggerFactory.getLogger("traceLog"); private TraceLogger() { } public static void info(String message){ TRACE_LOGGER.info(message); } public static void info(String format,Object... arguments){ TRACE_LOGGER.info(format, arguments); } } ``` 最后写个日志打印测试一下效果 ```java @Test public void Test(){ MDC.clear(); MDC.put("sessionId" , "f9e287fad9e84cff8b2c2f2ed92adbe6"); MDC.put("cityId" , 1); MDC.put("siteName" , "北京"); MDC.put("userName" , "userwyh"); TraceLogger. info("测试 MDC 打印一"); MDC.put("mobile" , "110"); TraceLogger. info("测试 MDC 打印二"); MDC.put("mchId" , 12); MDC.put("mchName", "商户名称"); TraceLogger. info("测试 MDC 打印三"); } ``` 执行完后我们可以在定义的日志输出路径下看到以下输出 ``` [2016-10-19 19:20:26.564] - - - 北京 - f9e287fad9e84cff8b2c2f2ed92adbe6 - 1 - userwyh - - 测试 MDC 打印一 [2016-10-19 19:20:26.565] - - - 北京 - f9e287fad9e84cff8b2c2f2ed92adbe6 - 1 - userwyh - 110 - 测试 MDC 打印二 [2016-10-19 19:20:26.565] - 12 - 商户名称 - 北京 - f9e287fad9e84cff8b2c2f2ed92adbe6 - 1 - userwyh - 110 - 测试 MDC 打印三 ``` ## [zheng 框架解析 (二):从环境搭建到部署实战](https://blog.dong4j.site/posts/ffbf019e.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 看到群里有朋友出现了下面的错误, 我也出现了 ``` com.alibaba.dubbo.rpc.RpcException: Forbid consumer 192.168.1.89 access service com.alibaba.dubbo.monitor.MonitorService from registry zkserver:2181 use dubbo version 2.5.3, Please check registry access list (whitelist/blacklist). ``` 在出现上面的错误时, 都会打印下面一段信息 ``` INFO [com.alibaba.dubbo.monitor.dubbo.DubboMonitor] - [DUBBO] Send statistics to monitor zookeeper://zkserver:2181/com.alibaba.dubbo.monitor.MonitorService?dubbo=2.5.3&interface=com.alibaba.dubbo.monitor.MonitorService&pid=43821×tamp=1504769453531, dubbo version: 2.5.3, current host: 192.168.1.89 ``` 消费者向提供者发送统计数据时, 由于注册中心里面找不到那个提供者的信息, 抛出了最上面的错误. `MonitorService`, 一看就知道是跟 dubbo monitor 有关. 查看 `dubb-admin`, 也可以看到, `MonitorService` 没有提供者 ![20241229154732_BwTUq4KI.webp](https://cdn.dong4j.site/source/image/20241229154732_BwTUq4KI.webp) 最后查看 readme.md, 看见 zheng 使用了 `Dubbo-monitor` `dubbo-monitor` 跟 `dubbo-admin` 一样, 需要我们自己部属 ## dubbo-monitor 部属 这里使用 [韩都衣舍](http://git.oschina.net/handu/dubbo-monitor) 的 `Dubbo Monitor for Relational Database` 不为别的, 就因为 `dubbo-monitor-simple` 太丑了 😂 官方的 readme.md 说得已经很清楚了, 照着来就是了 部属成功后 访问 `http://127.0.0.1:9527/dubbo-monitor/` 最后的效果如下: ![20241229154732_Z2cMlmk3.webp](https://cdn.dong4j.site/source/image/20241229154732_Z2cMlmk3.webp) ![20241229154732_zJno8Aw3.webp](https://cdn.dong4j.site/source/image/20241229154732_zJno8Aw3.webp) 然后就不会报错了 ![20241229154732_3U6ku0kX.webp](https://cdn.dong4j.site/source/image/20241229154732_3U6ku0kX.webp) 红色部分不未部署之前, 后面可以看到, 发送统计数据后没有报错了 **另一种解决方案** 在 dubbo.xml 中配置了 monitor 才会发送统计数据 所以删除所有 `` 不使用 monitor 即可 ## dubbo-admin 部署 和 dubbo-monitor 一样 😄 ## 项目部署 本人工作中使用的 Maven 环境跟 zheng 不一样, 为了方便, 这里写了个脚本, 可以切换不同的 Maven 配置 ```bash change(){ file_name='settings.xml.'$1 is_exist="$(find ~/.m2 -name $file_name)" if [$is_exist] then mv ~/.m2/settings.xml ~/.m2/settings.xml.$2; mv ~/.m2/$file_name ~/.m2/settings.xml fi echo "change " $1 'to ' $2 } ``` ### 环境搭建 这里使用 Vagrant 来搭建环境, 一是不想把自己本地的环境搞乱, 二是方便, 环境搭建好之后, 打包一个 package, 到哪儿都能用. Vagrant 里面的依赖都已经安装好了, 配置一下就 ok 了. Vagrant 的 ip 地址为 `2.2.2.2` **zheng 是需要修改 hosts 的, 很多朋友没有看完文档就开始搞, 导致项目运行不起来.** #### Redis 1. 允许远程访问 这里为了方便查看 redis , 这里设置为允许远程访问 ``` # 修改以下配置: # 1. 注释 bind 127.0.0.1 # 2. protected-mode 由 yes --> no ``` 2. 修改密码 我这里修改了 Redis 的密码, 默认是为空的 ``` requirepass 123456 ``` Redis 远程连接成功 ![20241229154732_Ql5bfynw.webp](https://cdn.dong4j.site/source/image/20241229154732_Ql5bfynw.webp) #### Nginx 来一份简单实用的 nginx.conf 配置 ```nginx worker_processes 1; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; server { listen 80; server_name localhost; location / { root /zheng/zheng-cms-web/; index index.html index.htm; add_header Access-Control-Allow-Origin *; } } } ``` ![20241229154732_fDEDLZ26.webp](https://cdn.dong4j.site/source/image/20241229154732_fDEDLZ26.webp) ``` # 修改了 nginx.conf 后, 重新加载配置文件 nginx -s reload # 停止 nginx nginx -s stop # 检查配置文件 nginx -t ``` ##### IDEA 配置服务器, 直接上传文件 让你体会一下什么是 **沉浸式 IDE** 配置 Deployment ![20241229154732_piqBf0lB.webp](https://cdn.dong4j.site/source/image/20241229154732_piqBf0lB.webp) 1. 配置服务器 ![20241229154732_QKP3wVrc.webp](https://cdn.dong4j.site/source/image/20241229154732_QKP3wVrc.webp) 2. 配置本地目录与服务器目录映射关系 ![20241229154732_q4KLzQXy.webp](https://cdn.dong4j.site/source/image/20241229154732_q4KLzQXy.webp) 3. 完成配置 ![20241229154732_9N6ltVcM.webp](https://cdn.dong4j.site/source/image/20241229154732_9N6ltVcM.webp) 4. 上传本地文件到服务器 ![20241229154732_7YUtTE57.webp](https://cdn.dong4j.site/source/image/20241229154732_7YUtTE57.webp) 5. 哦了 ![20241229154732_RiTcJ9NL.webp](https://cdn.dong4j.site/source/image/20241229154732_RiTcJ9NL.webp) 修改了 html, upload, 刷新, so easy ![4444.gif](https://cdn.dong4j.site/source/image/4444.gif) #### Zookeeper Zookeeper 使用默认配置就可以了, 三种配置方式 1. 单机 2. 伪集群 3. 真集群 这里就不说了, 我使用单机配置, 简单 ``` # 心跳检测毫秒数 tickTime=2000 # follower 初始化连接最长忍受的心跳数时间间隔 2000*10=20 秒 initLimit=10 # leader 和 follower 之间发送消息请求和应答时间长度 5*2000=10 秒 syncLimit=5 # 数据持久化的目录 dataDir=~/Develop/logs/zookeeper/data # 日志 dataLogDir=~/Develop/logs/zookeeper/log # 向 client 暴露的端口 clientPort=2181 # 最大客户端连接数 # maxClientCnxns=60 # 快照个数 # autopurge.snapRetainCount=3 # 快照保存时间间隔小时 #autopurge.purgeInterval=1 ``` 下个插件 zookeeper, 能看到 zookeeper 的节点信息 ![20241229154732_JfNznTUq.webp](https://cdn.dong4j.site/source/image/20241229154732_JfNznTUq.webp) #### ActiveMQ 默认配置 略... 😂😂 #### Tomcat 这里只要一台服务器, 所有就搞个单机多实例的配置, 这里要修改 Tomcat 配置, 让一台服务器上跑 2 个 Tomcat, 不为别的, 以为我就一台虚拟机, 意思一下就可以了 😂😂 将解压后的 Tomcat 复制一份 修改 **每个 tomcat 实例中 server.xml 中的端口** ``` .... ... ``` 2 个 server.xml 只要不一样就行了, 只要确保修改的端口没有被其他程序占用就可以了. 然后一个一个启动吧, 如果嫌麻烦, 也可以写脚本一键启动 ![20241229154732_HirA2IKk.webp](https://cdn.dong4j.site/source/image/20241229154732_HirA2IKk.webp) 一个 1111, 另一个 2222 一般都是先把 war 上传到临时目录下, 然后再移动到 webapp 下, 不要在 tomcat 正在运行的时候把 war 直接上传到 webapp 下, 因为一旦上传开始, webapp 下就会有出现 war, 然后 tomcat 就开始解压了, 但是这个 war 并不完整, 会出错的 也可以使用上面的方式, 同步本地文件到服务器, 只限于开发, 生产环境你不一定有账号, 二是谁让你没事一直连着生产服务器的? #### 启动依赖软件 写一个简单的脚本, 一键启动所有依赖 ```bash #!/bin/bash export JAVA_HOME=/usr/local/java export PATH=$JAVA_HOME/bin:$PATH # zookeeper nohup /usr/local/kafka/bin/zookeeper-server-start.sh /usr/local/kafka/config/zookeeper.properties & echo "zookeeper 启动完成" # redis nohup /usr/local/redis/src/redis-server /usr/local/redis/redis.conf & echo "redis 启动完成" # nginx /usr/local/nginx/sbin/nginx echo "nginx 启动完成" # ActiveMQ /usr/local/activemq/bin/activemq start echo "activemq 启动完成" ``` tomcat 就手动启动吧, 敲敲命名也是好的 😂😂 ### 打包 先修改配置文件 由于 Vagrant 里面没有安装 MySQL, 我就连接本地的了, 一般数据库都是单独的服务器, 正好模拟一下 **2017-09-14 11:59 dong4j 更新** 这里吐槽一下, 很多配置是写死的, 比如说 dubbo 里面的 zookeeper 注册地址, 没有写到配置文件中去, 所以这里还是改回使用 hosts 的方式. 只修改了 mysql 的地址, 其他的按照原文档修改 hosts 使用 Maven 打包 ``` mvn clean package -Pprod ``` ### 上传 将 4 个包上传到服务器 ``` scp zheng-cms-rpc-service-assembly.tar.gz root@2.2.2.2:/zheng/upload/ ``` ``` # 内容管理系统, 通过 ZhengUpmsRpcServiceApplication.main 方法启动服务提供者 zheng-upms-rpc-service-assembly.tar.gz # 内容管理系统, 使用 jar 包的方式启动, 通过 ZhengCmsRpcServiceApplication 方法启动服务提供者 zheng-cms-rpc-service-assembly.tar.gz # 用户权限系统及 SSO 服务端 [端口:1111] 消费端 部属到 tomcat1 1111 zheng-upms-server.war # 后台管理 [端口:2222] 消费端 部属到 tomcat2 2222 zheng-cms-admin.war ``` ### 启动 #### 提供者 先解压 2 个包 ``` zheng-cms-rpc-service-assembly.tar.gz zheng-upms-rpc-service-assembly.tar.gz ``` ``` tar -zxvf *.tar.gz ``` 分别启动 `zheng-upms-rpc-service` 和 `zheng-cms-rpc-service` 使用对应目录下的 `bin/start.sh` 脚本 #### 消费者 删除 tomcat1[2]/webapps/ROOT 里的所有文件, 将 war 包分别拷入到 tomcat1 和 tomcat2 下的 webapps/ROOT, 然后使用 unzip 解压 ``` unzip *war ``` 如果直接放入 webapps 下, 启动 tomcat 后会自动解压 war 包, 但是请求应用时, 就必须通过 `http://ip:port/ 应用名 / 资源路径 ` 的方式访问 这里直接解压到 ROOT 目录下, 就不需要知道应用名. ### 完成 由于是虚拟机, 地址为 2.2.2.2, 所以需要修改我 **本地** 的 hosts, 把 域名指向 2.2.2.2 ``` 2.2.2.2 ui.zhangshuzheng.cn 2.2.2.2 upms.zhangshuzheng.cn 2.2.2.2 cms.zhangshuzheng.cn 2.2.2.2 pay.zhangshuzheng.cn 2.2.2.2 ucenter.zhangshuzheng.cn 2.2.2.2 wechat.zhangshuzheng.cn 2.2.2.2 api.zhangshuzheng.cn 2.2.2.2 oss.zhangshuzheng.cn 2.2.2.2 config.zhangshuzheng.cn ``` 系统管理 ![20241229154732_ZWx2ern5.webp](https://cdn.dong4j.site/source/image/20241229154732_ZWx2ern5.webp) 组织管理 ![20241229154732_VrnKvSMq.webp](https://cdn.dong4j.site/source/image/20241229154732_VrnKvSMq.webp) 权限管理 ![20241229154732_Bp1NmEPm.webp](https://cdn.dong4j.site/source/image/20241229154732_Bp1NmEPm.webp) Redis ![20241229154732_OiJg61e1.webp](https://cdn.dong4j.site/source/image/20241229154732_OiJg61e1.webp) Zookeeper ![20241229154732_Wrvd2tyJ.webp](https://cdn.dong4j.site/source/image/20241229154732_Wrvd2tyJ.webp) ActiveMQ ![20241229154732_6CHgup1F.webp](https://cdn.dong4j.site/source/image/20241229154732_6CHgup1F.webp) ## 遇到的问题 ### hosts 本来是想不修改 hosts 来部署 zheng 的, 但是大概看了下代码, 发现很多域名和配置都是写死的, 还需要修改数据库, 改动会很大, 就没有心情修改了. 但是 hosts 还是需要修改一下才能在服务器上使用. 1. 修改 hosts , 将域名指向服务器 ip **这部分是在本地修改** ``` 2.2.2.2 ui.zhangshuzheng.cn 2.2.2.2 upms.zhangshuzheng.cn 2.2.2.2 cms.zhangshuzheng.cn 2.2.2.2 pay.zhangshuzheng.cn 2.2.2.2 ucenter.zhangshuzheng.cn 2.2.2.2 wechat.zhangshuzheng.cn 2.2.2.2 api.zhangshuzheng.cn 2.2.2.2 oss.zhangshuzheng.cn 2.2.2.2 config.zhangshuzheng.cn ``` 2. 将服务器依赖软件的 ip 指向服务器 **这部份是修改服务器的 hosts** zookeeper, redis, activemq 都安装在 2.2.2.2 这台服务器上的 ``` 127.0.0.1 zkserver 127.0.0.1 rdserver # 127.0.0.1 dbserver mysql 的 地址修改为了我本地的 mysql 局域网地址是 192.168.31.28 127.0.0.1 mqserver ``` ### dubbo-monitor [韩都衣舍](http://git.oschina.net/handu/dubbo-monitor) 的 dubbo-monitor 编译后不能直接用于 dubbo 2.5.3 需要修改 pom.xml ``` # 由 2.8.4 修改为 2.5.3 2.5.3 # 排除 dubbo 依赖的 旧版本的 spring com.alibaba dubbo ${dubbo.version} ... spring org.springframework ``` ## [深入解析Zheng框架:搭建与部署实战指南](https://blog.dong4j.site/posts/508e9afc.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 前提条件 群里面有一篇 `zheng- 环境搭建及系统部署文档 20170213(三版).docx`, 里面详细讲解了各个依赖软件的安装和配置, 所以这里不会再讲依赖的搭建过程. 假设你已经将所有的依赖环境搭建完毕. ## 我的环境 | 名称 | 版本 | | ------------- | ---------------------- | | 系统 | MacOS 10.12.6 | | IDE | Intellij IDEA 2017.2.3 | | JDK | 1.8 | | Maven | 3.3.9 | | Dubbo | 2.5.3 | | Zookeepe | 3.4.10 | | Nginx | 1.12.1 | | ActiveMQ | 5.15.0 | | MySQL | 5.7.16 | | Redis | 3.2.3 | | Dubbo-admin | | | Dubbo-monitor | | ### 用到的工具 1. IDEA 2. LICEcap --> gif 制作 3. MWeb --> Markdown 编辑器 4. iTerm --> 替代 Terminal 的工具 ## 环境搭建 我记得去年还是前年, 用 Intellij IDEA 的人已经超过了 Eclipse, 如果你还在用 Eclipse, 我想安利你马上转到 IDEA 来, 不是一般的好用, 我已经安利了不下 50 人 使用 IDEA 了. 之后的系列文章, 会穿插一些 IDEA 的教程, 在实践中学习, 效果杠杠的. 这里强烈推荐一个专门写 IDEA 的教程, 希望能帮助你一些 https://github.com/judasn/IntelliJ-IDEA-Tutorial.git 不要怕不会用, 等你学会了以后, 效率是别人的几倍 ![20241229154732_BGyuUh4B.webp](https://cdn.dong4j.site/source/image/20241229154732_BGyuUh4B.webp) clone 项目, 使用 Intellji IDEA 导入这些就不说了 这步完成的样子是这样的 ![20241229154732_yKJSQxsX.webp](https://cdn.dong4j.site/source/image/20241229154732_yKJSQxsX.webp) 项目结构和命名看着非常舒服 ### 导入 SQL 找到 `project-datamodel --> zheng.sql` , 双击打开 如果是第一次打开这个项目, 你肯定会看到这个 ![20241229154732_FpAPd2IP.webp](https://cdn.dong4j.site/source/image/20241229154732_FpAPd2IP.webp) #### 设置数据库 设置之前你得使用命令或者 navicat 创建一个叫 `zheng` 的数据库 ##### 问题 1: [1067] Invalid default value for 'last_login_time' 导入 SQL 报错 首先复习下 MySQl 中 datetime, timestamp, date 的区别. | 日期类型 | 存储空间 | 日志格式 | 日期范围 | | --------- | -------- | ------------------- | ----------------------------------------- | | datetime | 8 bytes | YYYY-MM-DD HH:MM:SS | 1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 | | timestamp | 4 bytes | YYYY-MM-DD HH:MM:SS | 1970-01-01 00:00:01 ~ 2037-12-31 23:59:59 | | date | 3 bytes | YYYY-MM-DD | 1000-01-01 ~ 9999-12-31 | 这里用 `timestamp` 1. 占用空间小 2. 在进行 `insert`, `update` 数据时,`timestamp` 列会自动以当前时间(CURRENT_TIMESTAMP)填充 / 更新. 但是到 2038-00-00 00:00:00 时, 系统就崩溃了, 想想就刺激, 😂 **然而并不是这个问题** 这种报错多半是你 MySQl 升级到 5.7 而引起的默认值不兼容的问题. 想到可能是类型的默认值被限制了,查看 sql_mode. ```sql show variables like 'sql_mode'; ``` > sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION 果然 NO_ZERO_IN_DATE, NO_ZERO_DATE 这两个参数限制时间不能为 0 ```sql # 临时解决 set session sql_mode='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION'; ``` ``` # 永久方案 # 直接修改 my.cnf 文件 在 [mysqld] 下面添加如下列: sql_mode=STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION ``` ![20241229154732_pW5gOynv.webp](https://cdn.dong4j.site/source/image/20241229154732_pW5gOynv.webp) 风骚的操作, 完美的表名 😁 ### 修改配置文件 密码值使用了 AES 加密,使用 `com.zheng.common.util.AESUtil` 工具类修改这些值 这么多文件怎么找? IDEA 的强大之处 **之一** 就在于它强大的搜索功能. 第一次打开项目时, IDEA 会花点时间创建所有除了 `Exclued Folders` 的文件的索引. 不管这些, 直接使用 ` 双击 shift` 来全局查找. ![2.gif](https://cdn.dong4j.site/source/image/2.gif) 胡悠我, 打开的全是占位符的配置文件, 还要去找主配置才行, 然后一个一个的改? you are so young so simple 全局替换, 秒秒钟的事 ![3.gif](https://cdn.dong4j.site/source/image/3.gif) 其他的照着来 ### 启动依赖 我启动这些依赖贼简单, 不信你看 ![20241229154732_258FNQfX.webp](https://cdn.dong4j.site/source/image/20241229154732_258FNQfX.webp) start, start, start .... 我记得以前用 windows 的时候, 启动这些依赖很恼火, 一个一个启动, 后来写了个批处理, 一建启动, 在虚拟机上, 懒的开了..... #### 配置 nginx 这里先说一个 windows 的坑 nginx.conf 配置文件默认编码是 utf-8, 在 windows 上使用记事本打开 utf-8 编码的文件, 它会在文件前面加上一个 `BOM` 😂, 不知道你们有没有踩过这个坑.... 也是这个坑, 又踩了一次. 是项目上一个 Excel 导出的问题 poi 导出大量数据, 内存溢出, 所以到数据量很大时, 就用导出文本文件的方式 ```java @Test public void test() throws IOException { // 数据存放的位置 String path="/Users/xxx/Develop/test.xls"; // 生成文件 BufferedWriter buff = new BufferedWriter(new FileWriter(path)); // 插入标题 代表 3 列 buff.write("部门名称\t用户\t电话"); // 换行 buff.write("\n"); // 插入 5 万条记录 String s = "中文测试"; for (int i = 0; i < 50000; i++) { buff.write(s + "\tzheng\t1234567890"); buff.write("\n"); } buff.close(); } ``` 如果 windows 的用户使用 Excel 打开就会是乱码, 原因就是上面说到的 改为: ``` @Test public void test() throws IOException { // 数据存放的位置 String path="/Users/codeai/Develop/test.xls"; // 生成文件 BufferedWriter buff = new BufferedWriter(new FileWriter(path)); // 在文件开始加一个 U+FEFF buff.write(new String(new byte[] {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF})); // 插入标题 代表 3 列 buff.write("部门名称,用户,电话"); // 换行 buff.write("\r\n"); // 插入 5 万条记录 String s = "中文测试"; for (int i = 0; i < 50000; i++) { buff.write(s + ",zheng,1234567890"); buff.write("\r\n"); } buff.close(); } ``` 为巨硬感到委屈, 明明是它先,什么都是它先的,VC6 也好,BOM 也罢... 为什么不按照它的来了, 搞得它现在想改都不行了.... 😂 **所以在 windows 下, 不要用 记事本, 用 sublime text 或者 vscode.** 扯远了............. 😅 2 个 server 配置, 没有看到 zheng-config 模块, 所有这里只配置一个反向代理 ```nginx #user nobody; worker_processes 1; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; server { listen 9526; server_name localhost; location / { root /Users/codeai/Develop/codes/Java/zheng/zheng-ui/zheng-cms-web/; index index.html index.htm; add_header Access-Control-Allow-Origin *; } } } ``` ### 启动服务 启动之前 先 `install` 一下 1. zheng-upms-rpc-service 2. zheng-cms-rpc-service 3. zheng-upms-server 4. zheng-cms-admin ![20241229154732_sP9iBIVK.webp](https://cdn.dong4j.site/source/image/20241229154732_sP9iBIVK.webp) ![20241229154732_iLxiIuNt.webp](https://cdn.dong4j.site/source/image/20241229154732_iLxiIuNt.webp) ![20241229154732_JzTMDdjk.webp](https://cdn.dong4j.site/source/image/20241229154732_JzTMDdjk.webp) ## 项目部属 😂 太晚了, 后天要早去去大理拍婚纱照, 留着后面写 只是照着项目的 readme.md 把环境搭好了, 能成功运行了, 里面的代码还没看 像这些环境依赖, 大家可以使用 Vagrant 来搭建, 以后换电脑或者给别的同事搭建环境, 丢一个文件给他就 ok 了. 或者使用 docker, 说得我好像会一样.... 😂, 还没开始看呢 哈哈, 以后大家一起学习 **感谢 zheng 哥给我们开源这么好的学习框架** 如果我没叫错的话, 应该叫 zheng 哥吧... ![20241229154732_zO1CRq26.webp](https://cdn.dong4j.site/source/image/20241229154732_zO1CRq26.webp) ![20241229154732_toybalCB.webp](https://cdn.dong4j.site/source/image/20241229154732_toybalCB.webp) 为什么 0 不代表女? 这样好记啊 向 Facebook 学习, 增加 56 种性别, 那才好玩 😂😂😂 ## [Google Dapper:揭秘分布式跟踪系统的秘密](https://blog.dong4j.site/posts/a69881a6.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) [View project onGitHub](https://github.com/bigbully/Dapper-translation) ### 概述 当代的互联网的服务,通常都是用复杂的、大规模分布式集群来实现的。互联网应用构建在不同的软件模块集上,这些软件模块,有可能是由不同的团队开发、可能使用不同的编程语言来实现、有可能布在了几千台服务器,横跨多个不同的数据中心。因此,就需要一些可以帮助理解系统行为、用于分析性能问题的工具。 Dapper--Google 生产环境下的分布式跟踪系统,应运而生。那么我们就来介绍一个大规模集群的跟踪系统,它是如何满足一个低损耗、应用透明的、大范围部署这三个需求的。当然 Dapper 设计之初,参考了一些其他分布式系统的理念,尤其是 Magpie 和 X-Trace,但是我们之所以能成功应用在生产环境上,还需要一些画龙点睛之笔,例如采样率的使用以及把代码植入限制在一小部分公共库的改造上。 自从 Dapper 发展成为一流的监控系统之后,给其他应用的开发者和运维团队帮了大忙,所以我们今天才发表这篇论文,来汇报一下这两年来,Dapper 是怎么构建和部署的。Dapper 最初只是作为一个自给自足的监控工具起步的,但最终进化成一个监控平台,这个监控平台促生出多种多样的监控工具,有些甚至已经不是由 Dapper 团队开发的了。下面我们会介绍一些使用 Dapper 搭建的分析工具,分享一下这些工具在 google 内部使用的统计数据,展现一些使用场景,最后会讨论一下我们迄今为止从 Dapper 收获了些什么。 ### 1. 介绍 我们开发 Dapper 是为了收集更多的复杂分布式系统的行为信息,然后呈现给 Google 的开发者们。这样的分布式系统有一个特殊的好处,因为那些大规模的低端服务器,作为互联网服务的载体,是一个特殊的经济划算的平台。想要在这个上下文中理解分布式系统的行为,就需要监控那些横跨了不同的应用、不同的服务器之间的关联动作。 下面举一个跟搜索相关的例子,这个例子阐述了 Dapper 可以应对哪些挑战。比如一个前段服务可能对上百台查询服务器发起了一个 Web 查询,每一个查询都有自己的 Index。这个查询可能会被发送到多个的子系统,这些子系统分别用来处理广告、进行拼写检查或是查找一些像图片、视频或新闻这样的特殊结果。根据每个子系统的查询结果进行筛选,得到最终结果,最后汇总到页面上。我们把这种搜索模型称为 “全局搜索”(universal search)。总的来说,这一次全局搜索有可能调用上千台服务器,涉及各种服务。而且,用户对搜索的耗时是很敏感的,而任何一个子系统的低效都导致导致最终的搜索耗时。如果一个工程师只能知道这个查询耗时不正常,但是他无从知晓这个问题到底是由哪个服务调用造成的,或者为什么这个调用性能差强人意。首先,这个工程师可能无法准确的定位到这次全局搜索是调用了哪些服务,因为新的服务、乃至服务上的某个片段,都有可能在任何时间上过线或修改过,有可能是面向用户功能,也有可能是一些例如针对性能或安全认证方面的功能改进。其次,你不能苛求这个工程师对所有参与这次全局搜索的服务都了如指掌,每一个服务都有可能是由不同的团队开发或维护的。再次,这些暴露出来的服务或服务器有可能同时还被其他客户端使用着,所以这次全局搜索的性能问题甚至有可能是由其他应用造成的。举个例子,一个后台服务可能要应付各种各样的请求类型,而一个使用效率很高的存储系统,比如 Bigtable,有可能正被反复读写着,因为上面跑着各种各样的应用。 上面这个案例中我们可以看到,对 Dapper 我们只有两点要求:无所不在的部署,持续的监控。无所不在的重要性不言而喻,因为在使用跟踪系统的进行监控时,即便只有一小部分没被监控到,那么人们对这个系统是不是值得信任都会产生巨大的质疑。另外,监控应该是 7x24 小时的,毕竟,系统异常或是那些重要的系统行为有可能出现过一次,就很难甚至不太可能重现。那么,根据这两个明确的需求,我们可以直接推出三个具体的设计目标: 1. 低消耗:跟踪系统对在线服务的影响应该做到足够小。在一些高度优化过的服务,即使一点点损耗也会很容易察觉到,而且有可能迫使在线服务的部署团队不得不将跟踪系统关停。 2. 应用级的透明:对于应用的程序员来说,是不需要知道有跟踪系统这回事的。如果一个跟踪系统想生效,就必须需要依赖应用的开发者主动配合,那么这个跟踪系统也太脆弱了,往往由于跟踪系统在应用中植入代码的 bug 或疏忽导致应用出问题,这样才是无法满足对跟踪系统 “无所不在的部署” 这个需求。面对当下想 Google 这样的快节奏的开发环境来说,尤其重要。 3. 延展性:Google 至少在未来几年的服务和集群的规模,监控系统都应该能完全把控住。 一个额外的设计目标是为跟踪数据产生之后,进行分析的速度要快,理想情况是数据存入跟踪仓库后一分钟内就能统计出来。尽管跟踪系统对一小时前的旧数据进行统计也是相当有价值的,但如果跟踪系统能提供足够快的信息反馈,就可以对生产环境下的异常状况做出快速反应。 做到真正的应用级别的透明,这应该是当下面临的最挑战性的设计目标,我们把核心跟踪代码做的很轻巧,然后把它植入到那些无所不在的公共组件种,比如线程调用、控制流以及 RPC 库。使用自适应的采样率可以使跟踪系统变得可伸缩,并降低性能损耗,这些内容将在第 4.4 节中提及。结果展示的相关系统也需要包含一些用来收集跟踪数据的代码,用来图形化的工具,以及用来分析大规模跟踪数据的库和 API。虽然单独使用 Dapper 有时就足够让开发人员查明异常的来源,但是 Dapper 的初衷不是要取代所有其他监控的工具。我们发现,Dapper 的数据往往侧重性能方面的调查,所以其他监控工具也有他们各自的用处。 ## 1.1 文献的总结 分布式系统跟踪工具的设计空间已经被一些优秀文章探索过了,其中的 Pinpoint[9]、Magpie[3] 和 X-Trace[12] 和 Dapper 最为相近。这些系统在其发展过程的早期倾向于写入研究报告中,即便他们还没来得及清楚地评估系统当中一些设计的重要性。相比之下,由于 Dapper 已经在大规模生产环境中摸爬滚打了多年,经过这么多生产环境的验证之后,我们认为这篇论文最适合重点阐述在部署 Dapper 的过程中我们有那些收获,我们的设计思想是如何决定的,以及以什么样的方式实现它才会最有用。Dappe 作为一个平台,承载基于 Dapper 开发的性能分析工具,以及 Dapper 自身的监测工具,它的价值在于我们可以在回顾评估中找出一些意想不到的结果。 虽然 Dapper 在许多高阶的设计思想上吸取了 Pinpoint 和 Magpie 的研究成果,但在分布式跟踪这个领域中,Dapper 的实现包含了许多新的贡献。例如,我们想实现低损耗的话,特别是在高度优化的而且趋于极端延迟敏感的 Web 服务中,采样率是很必要的。或许更令人惊讶的是,我们发现即便是 1/1000 的采样率,对于跟踪数据的通用使用层面上,也可以提供足够多的信息。 我们的系统的另一个重要的特征,就是我们能实现的应用级的透明。我们的组件对应用的侵入被先限制在足够低的水平上,即使想 Google 网页搜索这么大规模的分布式系统,也可以直接进行跟踪而无需加入额外的标注 (Annotation) 。虽然由于我们的部署系统有幸是一定程度的同质化的,所以更容易做到对应用层的透明这点,但是我们证明了这是实现这种程度的透明性的充分条件。 ## 2. Dapper 的分布式跟踪 ![20241229154732_Sz5MQBI6.webp](https://cdn.dong4j.site/source/image/20241229154732_Sz5MQBI6.webp) 图 1:这个路径由用户的 X 请求发起,穿过一个简单的服务系统。用字母标识的节点代表分布式系统中的不同处理过程。 分布式服务的跟踪系统需要记录在一次特定的请求后系统中完成的所有工作的信息。举个例子,图 1 展现的是一个和 5 台服务器相关的一个服务,包括:前端(A),两个中间层(B 和 C),以及两个后端(D 和 E)。当一个用户(这个用例的发起人)发起一个请求时,首先到达前端,然后发送两个 RPC 到服务器 B 和 C。B 会马上做出反应,但是 C 需要和后端的 D 和 E 交互之后再返还给 A,由 A 来响应最初的请求。对于这样一个请求,简单实用的分布式跟踪的实现,就是为服务器上每一次你发送和接收动作来收集跟踪标识符 (message identifiers) 和时间戳 (timestamped events)。 为了将所有记录条目与一个给定的发起者(例如,图 1 中的 RequestX)关联上并记录所有信息,现在有两种解决方案,黑盒 (black-box) 和基于标注 ( annotation-based) 的监控方案。黑盒方案 [1,15,2] 假定需要跟踪的除了上述信息之外没有额外的信息,这样使用统计回归技术来推断两者之间的关系。基于标注的方案 [3,12,9,16] 依赖于应用程序或中间件明确地标记一个全局 ID,从而连接每一条记录和发起者的请求。虽然黑盒方案比标注方案更轻便,他们需要更多的数据,以获得足够的精度,因为他们依赖于统计推论。基于标注的方案最主要的缺点是,很明显,需要代码植入。在我们的生产环境中,因为所有的应用程序都使用相同的线程模型,控制流和 RPC 系统,我们发现,可以把代码植入限制在一个很小的通用组件库中,从而实现了监测系统的应用对开发人员是有效地透明。 我们倾向于认为,Dapper 的跟踪架构像是内嵌在 RPC 调用的树形结构。然而,我们的核心数据模型不只局限于我们的特定的 RPC 框架,我们还能跟踪其他行为,例如 Gmail 的 SMTP 会话,外界的 HTTP 请求,和外部对 SQL 服务器的查询等。从形式上看,我们的 Dapper 跟踪模型使用的树形结构,Span 以及 Annotation。 ## 2.1 跟踪树和 span 在 Dapper 跟踪树结构中,树节点是整个架构的基本单元,而每一个节点又是对 span 的引用。节点之间的连线表示的 span 和它的父 span 直接的关系。虽然 span 在日志文件中只是简单的代表 span 的开始和结束时间,他们在整个树形结构中却是相对独立的,任何 RPC 相关的时间数据、零个或多个特定应用程序的 Annotation 的相关内容会在 2.3 节中讨论。 ![20241229154732_HfF91uBJ.webp](https://cdn.dong4j.site/source/image/20241229154732_HfF91uBJ.webp) 图 2:5 个 span 在 Dapper 跟踪树种短暂的关联关系 在图 2 中说明了 span 在一个大的跟踪过程中是什么样的。Dapper 记录了 span 名称,以及每个 span 的 ID 和父 ID,以重建在一次追踪过程中不同 span 之间的关系。如果一个 span 没有父 ID 被称为 root span。所有 span 都挂在一个特定的跟踪上,也共用一个跟踪 id(在图中未示出)。所有这些 ID 用全局唯一的 64 位整数标示。在一个典型的 Dapper 跟踪中,我们希望为每一个 RPC 对应到一个单一的 span 上,而且每一个额外的组件层都对应一个跟踪树型结构的层级。 ![20241229154732_XFQ7JCFQ.webp](https://cdn.dong4j.site/source/image/20241229154732_XFQ7JCFQ.webp) 图 3:在图 2 中所示的一个单独的 span 的细节图 图 3 给出了一个更详细的典型的 Dapper 跟踪 span 的记录点的视图。在图 2 中这种某个 span 表述了两个 “Helper.Call” 的 RPC(分别为 server 端和 client 端)。span 的开始时间和结束时间,以及任何 RPC 的时间信息都通过 Dapper 在 RPC 组件库的植入记录下来。如果应用程序开发者选择在跟踪中增加他们自己的注释(如图中 “foo” 的注释)(业务数据),这些信息也会和其他 span 信息一样记录下来。 记住,任何一个 span 可以包含来自不同的主机信息,这些也要记录下来。事实上,每一个 RPC span 可以包含客户端和服务器两个过程的注释,使得链接两个主机的 span 会成为模型中所说的 span。由于客户端和服务器上的时间戳来自不同的主机,我们必须考虑到时间偏差。在我们的分析工具,我们利用了这个事实:RPC 客户端发送一个请求之后,服务器端才能接收到,对于响应也是一样的(服务器先响应,然后客户端才能接收到这个响应)。这样一来,服务器端的 RPC 就有一个时间戳的一个上限和下限。 ## 2.2 植入点 Dapper 可以以对应用开发者近乎零浸入的成本对分布式控制路径进行跟踪,几乎完全依赖于基于少量通用组件库的改造。如下: - 当一个线程在处理跟踪控制路径的过程中,Dapper 把这次跟踪的上下文的在 ThreadLocal 中进行存储。追踪上下文是一个小而且容易复制的容器,其中承载了 Scan 的属性比如跟踪 ID 和 span ID。 - 当计算过程是延迟调用的或是异步的,大多数 Google 开发者通过线程池或其他执行器,使用一个通用的控制流库来回调。Dapper 确保所有这样的回调可以存储这次跟踪的上下文,而当回调函数被触发时,这次跟踪的上下文会与适当的线程关联上。在这种方式下,Dapper 可以使用 trace ID 和 span ID 来辅助构建异步调用的路径。 - 几乎所有的 Google 的进程间通信是建立在一个用 C++ 和 Java 开发的 RPC 框架上。我们把跟踪植入该框架来定义 RPC 中所有的 span。span 的 ID 和跟踪的 ID 会从客户端发送到服务端。像那样的基于 RPC 的系统被广泛使用在 Google 中,这是一个重要的植入点。当那些非 RPC 通信框架发展成熟并找到了自己的用户群之后,我们会计划对 RPC 通信框架进行植入。 Dapper 的跟踪数据是独立于语言的,很多在生产环境中的跟踪结合了用 C++ 和 Java 写的进程的数据。在 3.2 节中,我们讨论应用程序的透明度时我们会把这些理论的是如何实践的进行讨论。 ## 2.3 Annotation ![20241229154732_P1fOvOJA.webp](https://cdn.dong4j.site/source/image/20241229154732_P1fOvOJA.webp) 上述植入点足够推导出复杂的分布式系统的跟踪细节,使得 Dapper 的核心功能在不改动 Google 应用的情况下可用。然而,Dapper 还允许应用程序开发人员在 Dapper 跟踪的过程中添加额外的信息,以监控更高级别的系统行为,或帮助调试问题。我们允许用户通过一个简单的 API 定义带时间戳的 Annotation,核心的示例代码入图 4 所示。这些 Annotation 可以添加任意内容。为了保护 Dapper 的用户意外的过分热衷于日志的记录,每一个跟踪 span 有一个可配置的总 Annotation 量的上限。但是,应用程序级的 Annotation 是不能替代用于表示 span 结构的信息和记录着 RPC 相关的信息。 除了简单的文本 Annotation,Dapper 也支持的 key-value 映射的 Annotation,提供给开发人员更强的跟踪能力,如持续的计数器,二进制消息记录和在一个进程上跑着的任意的用户数据。键值对的 Annotation 方式用来在分布式追踪的上下文中定义某个特定应用程序的相关类型。 ## 2.4 采样率 低损耗的是 Dapper 的一个关键的设计目标,因为如果这个工具价值未被证实但又对性能有影响的话,你可以理解服务运营人员为什么不愿意部署它。况且,我们想让开发人员使用 Annotation 的 API,而不用担心额外的开销。我们还发现,某些类型的 Web 服务对植入带来的性能损耗确实非常敏感。因此,除了把 Dapper 的收集工作对基本组件的性能损耗限制的尽可能小之外,我们还有进一步控制损耗的办法,那就是遇到大量请求时只记录其中的一小部分。我们将在 4.4 节中讨论跟踪的采样率方案的更多细节。 ![20241229154732_ZrsnqkWv.webp](https://cdn.dong4j.site/source/image/20241229154732_ZrsnqkWv.webp) 图 5:Dapper 收集管道的总览 ## 2.5 跟踪的收集 Dapper 的跟踪记录和收集管道的过程分为三个阶段(参见图 5)。首先,span 数据写入(1)本地日志文件中。然后 Dapper 的守护进程和收集组件把这些数据从生产环境的主机中拉出来(2),最终写到(3)Dapper 的 Bigtable 仓库中。一次跟踪被设计成 Bigtable 中的一行,每一列相当于一个 span。Bigtable 的支持稀疏表格布局正适合这种情况,因为每一次跟踪可以有任意多个 span。跟踪数据收集(即从应用中的二进制数据传输到中央仓库所花费的时间)的延迟中位数少于 15 秒。第 98 百分位的延迟 (The 98th percentile latency) 往往随着时间的推移呈现双峰型; 大约 75% 的时间,第 98 百分位的延迟时间小于 2 分钟,但是另外大约 25% 的时间,它可以增涨到几个小时。 Dapper 还提供了一个 API 来简化访问我们仓库中的跟踪数据。 Google 的开发人员用这个 API,以构建通用和特定应用程序的分析工具。第 5.1 节包含更多如何使用它的信息。 ## 2.5.1 带外数据跟踪收集 tip1: 带外数据: 传输层协议使用带外数据 (out-of-band,OOB) 来发送一些重要的数据, 如果通信一方有重要的数据需要通知对方时, 协议能够将这些数据快速地发送到对方。为了发送这些数据,协议一般不使用与普通数据相同的通道, 而是使用另外的通道。 tip2: 这里指的 in-band 策略是把跟踪数据随着调用链进行传送,out-of-band 是通过其他的链路进行跟踪数据的收集,Dapper 的写日志然后进行日志采集的方式就属于 out-of-band 策略 Dapper 系统请求树树自身进行跟踪记录和收集带外数据。这样做是为两个不相关的原因。首先,带内收集方案 -- 这里跟踪数据会以 RPC 响应头的形式被返回 -- 会影响应用程序网络动态。在 Google 里的许多规模较大的系统中,一次跟踪成千上万的 span 并不少见。然而,RPC 回应大小 -- 甚至是接近大型分布式的跟踪的根节点的这种情况下 -- 仍然是比较小的:通常小于 10K。在这种情况下,带内 Dapper 的跟踪数据会让应用程序数据和倾向于使用后续分析结果的数据量相形见绌。其次,带内收集方案假定所有的 RPC 是完美嵌套的。我们发现,在所有的后端的系统返回的最终结果之前,有许多中间件会把结果返回给他们的调用者。带内收集系统是无法解释这种非嵌套的分布式执行模式的。 ## 2.6 安全和隐私考虑 记录一定量的 RPC 有效负载信息将丰富 Dapper 的跟踪能力,因为分析工具能够在有效载荷数据(方法传递的参数)中找到相关的样例,这些样例可以解释被监控系统的为何表现异常。然而,有些情况下,有效载荷数据可能包含的一些不应该透露给未经授权用户 ( 包括正在 debug 的工程师) 的内部信息。 由于安全和隐私问题是不可忽略的,dapper 中的虽然存储 RPC 方法的名称,但在这个时候不记录任何有效载荷数据。相反,应用程序级别的 Annotation 提供了一个方便的可选机制:应用程序开发人员可以在 span 中选择关联那些为以后分析提供价值的数据。 Dapper 还提供了一些安全上的便利,是它的设计者事先没有预料到的。通过跟踪公开的安全协议参数,Dapper 可以通过相应级别的认证或加密,来监视应用程序是否满足安全策略。例如。Dapper 还可以提供信息,以基于策略的的隔离系统按预期执行,例如支撑敏感数据的应用程序不与未经授权的系统组件进行了交互。这样的测算提供了比源码审核更强大的保障。 ## 3. Dapper 部署状况 Dapper 作为我们生产环境下的跟踪系统已经超过两年。在本节中,我们会汇报系统状态,把重点放在 Dapper 如何满足了我们的目标——无处不在的部署和应用级的透明。 ## 3.1 Dapper 运行库 也许 Dapper 代码中中最关键的部分,就是对基础 RPC、线程控制和流程控制的组件库的植入,其中包括 span 的创建,采样率的设置,以及把日志写入本地磁盘。除了做到轻量级,植入的代码更需要稳定和健壮,因为它与海量的应用对接,维护和 bug 修复变得困难。植入的核心代码是由未超过 1000 行的 C++ 和不超过 800 行 Java 代码组成。为了支持键值对的 Annotation 还添加了额外的 500 行代码。 ## 3.2 生产环境下的涵盖面 Dapper 的渗透可以总结为两个方面:一方面是可以创建 Dapper 跟踪的过程 (与 Dapper 植入的组件库相关),和生产环境下的服务器上在运行 Dapper 跟踪收集守护进程。Dapper 的守护进程的分布相当于我们服务器的简单的拓扑图,它存在于 Google 几乎所有的服务器上。这很难确定精确的 Dapper-ready 进程部分,因为过程即便不产生跟踪信息 Dapper 也是无从知晓的。尽管如此,考虑到无处不在 Dapper 组件的植入库,我们估计几乎每一个 Google 的生产进程都是支持跟踪的。 在某些情况下 Dapper 的是不能正确的跟踪控制路径的。这些通常源于使用非标准的控制流,或是 Dapper 的错误的把路径关联归到不相关的事件上。Dapper 提供了一个简单的库来帮助开发者手动控制跟踪传播作为一种变通方法。目前有 40 个 C++ 应用程序和 33 个 Java 应用程序需要一些手动控制的追踪传播,不过这只是上千个的跟踪中的一小部分。也有非常小的一部分程序使用的非组件性质的通信库(比如原生的 TCP Socket 或 SOAP RPC),因此不能直接支持 Dapper 的跟踪。但是这些应用可以单独接入到 Dapper 中,如果需要的话。 考虑到生产环境的安全,Dapper 的跟踪也可以关闭。事实上,它在部署的早起就是默认关闭的,直到我们对 Dapper 的稳定性和低损耗有了足够的信心之后才把它开启。Dapper 的团队偶尔会执行审查寻找跟踪配置的变化,来看看那些服务关闭了 Dapper 的跟踪。但这种情况不多见,而且通常是源于对监控对性能消耗的担忧。经过了对实际性能消耗的进一步调查和测量,所有这些关闭 Dapper 跟踪都已经恢复开启了,不过这些已经不重要了。 ## 3.3 跟踪 Annotation 的使用 程序员倾向于使用特定应用程序的 Annotation,无论是作为一种分布式调试日志文件,还是通过一些应用程序特定的功能对跟踪进行分类。例如,所有的 Bigtable 的请求会把被访问的表名也记录到 Annotation 中。目前,70%的 Dapper span 和 90%的所有 Dapper 跟踪都至少有一个特殊应用的 Annotation。 41 个 Java 应用和 68 个 C++ 应用中都添加自定义的 Annotation 为了更好地理解应用程序中的 span 在他们的服务中的行为。值得注意的是,迄今为止我们的 Java 开发者比 C++ 开发者更多的在每一个跟踪 span 上采用 Annotation 的 API。这可能是因为我们的 Java 应用的作用域往往是更接近最终用户 (C++ 偏底层); 这些类型的应用程序经常处理更广泛的请求组合,因此具有比较复杂的控制路径。 ## 4. 处理跟踪损耗 跟踪系统的成本由两部分组成: 1. 正在被监控的系统在生成追踪和收集追踪数据的消耗导致系统性能下降, 2. 需要使用一部分资源来存储和分析跟踪数据。虽然你可以说一个有价值的组件植入跟踪带来一部分性能损耗是值得的,我们相信如果基本损耗能达到可以忽略的程度,那么对跟踪系统最初的推广会有极大的帮助。 在本节中,我们会展现一下三个方面:Dapper 组件操作的消耗,跟踪收集的消耗,以及 Dapper 对生产环境负载的影响。我们还介绍了 Dapper 可调节的采样率机制如何帮我们处理低损耗和跟踪代表性之间的平衡和取舍。 ## 4.1 生成跟踪的损耗 生成跟踪的开销是 Dapper 性能影响中最关键的部分,因为收集和分析可以更容易在紧急情况下被关闭。Dapper 运行库中最重要的跟踪生成消耗在于创建和销毁 span 和 annotation,并记录到本地磁盘供后续的收集。根 span 的创建和销毁需要损耗平均 204 纳秒的时间,而同样的操作在其他 span 上需要消耗 176 纳秒。时间上的差别主要在于需要在跟 span 上给这次跟踪分配一个全局唯一的 ID。 如果一个 span 没有被采样的话,那么这个额外的 span 下创建 annotation 的成本几乎可以忽略不计,他由在 Dapper 运行期对 ThreadLocal 查找操作构成,这平均只消耗 9 纳秒。如果这个 span 被计入采样的话,会用一个用字符串进行标注 -- 在图 4 中有展现 -- 平均需要消耗 40 纳秒。这些数据都是在 2.2GHz 的 x86 服务器上采集的。 在 Dapper 运行期写入到本地磁盘是最昂贵的操作,但是他们的可见损耗大大减少,因为写入日志文件和操作相对于被跟踪的应用系统来说都是异步的。不过,日志写入的操作如果在大流量的情况,尤其是每一个请求都被跟踪的情况下就会变得可以察觉到。我们记录了在 4.3 节展示了一次 Web 搜索的负载下的性能消耗。 ## 4.2 跟踪收集的消耗 读出跟踪数据也会对正在被监控的负载产生干扰。表 1 展示的是最坏情况下,Dapper 收集日志的守护进程在高于实际情况的负载基准下进行测试时的 cpu 使用率。在生产环境下,跟踪数据处理中,这个守护进程从来没有超过 0.3% 的单核 cpu 使用率,而且只有很少量的内存使用(以及堆碎片的噪音)。我们还限制了 Dapper 守护进程为内核 scheduler 最低的优先级,以防在一台高负载的服务器上发生 cpu 竞争。 Dapper 也是一个带宽资源的轻量级的消费者,每一个 span 在我们的仓库中传输只占用了平均 426 的 byte。作为网络行为中的极小部分,Dapper 的数据收集在 Google 的生产环境中的只占用了 0.01% 的网络资源。 ![20241229154732_jRzkp5pe.webp](https://cdn.dong4j.site/source/image/20241229154732_jRzkp5pe.webp) 表 1:Dapper 守护进程在负载测试时的 CPU 资源使用率 ## 4.3 在生产环境下对负载的影响 每个请求都会利用到大量的服务器的高吞吐量的线上服务,这是对有效跟踪最主要的需求之一;这种情况需要生成大量的跟踪数据,并且他们对性能的影响是最敏感的。在表 2 中我们用集群下的网络搜索服务作为例子,我们通过调整采样率,来衡量 Dapper 在延迟和吞吐量方面对性能的影响。 ![20241229154732_NRNUPfjs.webp](https://cdn.dong4j.site/source/image/20241229154732_NRNUPfjs.webp) 表 2:网络搜索集群中,对不同采样率对网络延迟和吞吐的影响。延迟和吞吐的实验误差分别是 2.5% 和 0.15%。 我们看到,虽然对吞吐量的影响不是很明显,但为了避免明显的延迟,跟踪的采样还是必要的。然而,延迟和吞吐量的带来的损失在把采样率调整到小于 1/16 之后就全部在实验误差范围内。在实践中,我们发现即便采样率调整到 1/1024 仍然是有足够量的跟踪数据的用来跟踪大量的服务。保持 Dapper 的性能损耗基线在一个非常低的水平是很重要的,因为它为那些应用提供了一个宽松的环境使用完整的 Annotation API 而无惧性能损失。使用较低的采样率还有额外的好处,可以让持久化到硬盘中的跟踪数据在垃圾回收机制处理之前保留更长的时间,这样为 Dapper 的收集组件给了更多的灵活性。 ## 4.4 可变采样 任何给定进程的 Dapper 的消耗和每个进程单位时间的跟踪的采样率成正比。Dapper 的第一个生产版本在 Google 内部的所有进程上使用统一的采样率,为 1/1024。这个简单的方案是对我们的高吞吐量的线上服务来说是非常有用,因为那些感兴趣的事件 (在大吞吐量的情况下) 仍然很有可能经常出现,并且通常足以被捕捉到。 然而,在较低的采样率和较低的传输负载下可能会导致错过重要事件,而想用较高的采样率就需要能接受的性能损耗。对于这样的系统的解决方案就是覆盖默认的采样率,这需要手动干预的,这种情况是我们试图避免在 dapper 中出现的。 我们在部署可变采样的过程中,参数化配置采样率时,不是使用一个统一的采样方案,而是使用一个采样期望率来标识单位时间内采样的追踪。这样一来,低流量低负载自动提高采样率,而在高流量高负载的情况下会降低采样率,使损耗一直保持在控制之下。实际使用的采样率会随着跟踪本身记录下来,这有利于从 Dapper 的跟踪数据中准确的分析。 ## 4.5 应对积极采样 (Coping with aggressive sampling) 新的 Dapper 用户往往觉得低采样率 -- 在高吞吐量的服务下经常低至 0.01%-- 将会不利于他们的分析。我们在 Google 的经验使我们相信,对于高吞吐量服务,积极采样 (aggressive sampling) 并不妨碍最重要的分析。如果一个显着的操作在系统中出现一次,他就会出现上千次。低吞吐量的服务 -- 也许是每秒请求几十次,而不是几十万 -- 可以负担得起跟踪每一个请求,这是促使我们下决心使用自适应采样率的原因。 ## 4.6 在收集过程中额外的采样 上述采样机制被设计为尽量减少与 Dapper 运行库协作的应用程序中明显的性能损耗。Dapper 的团队还需要控制写入中央资料库的数据的总规模,因此为达到这个目的,我们结合了二级采样。 目前我们的生产集群每天产生超过 1TB 的采样跟踪数据。Dapper 的用户希望生产环境下的进程的跟踪数据从被记录之后能保存至少两周的时间。逐渐增长的追踪数据的密度必须和 Dapper 中央仓库所消耗的服务器及硬盘存储进行权衡。对请求的高采样率还使得 Dapper 收集器接近写入吞吐量的上限。 为了维持物质资源的需求和渐增的 Bigtable 的吞吐之间的灵活性,我们在收集系统自身上增加了额外的采样率的支持。我们充分利用所有 span 都来自一个特定的跟踪并分享同一个跟踪 ID 这个事实,虽然这些 span 有可能横跨了数千个主机。对于在收集系统中的每一个 span,我们用 hash 算法把跟踪 ID 转成一个标量 Z,这里 0<=Z<=1。如果 Z 比我们收集系统中的系数低的话,我们就保留这个 span 信息,并写入到 Bigtable 中。反之,我们就抛弃他。通过在采样决策中的跟踪 ID,我们要么保存、要么抛弃整个跟踪,而不是单独处理跟踪内的 span。我们发现,有了这个额外的配置参数使管理我们的收集管道变得简单多了,因为我们可以很容易地在配置文件中调整我们的全局写入率这个参数。 如果整个跟踪过程和收集系统只使用一个采样率参数确实会简单一些,但是这就不能应对快速调整在所有部署的节点上的运行期采样率配置的这个要求。我们选择了运行期采样率,这样就可以优雅的去掉我们无法写入到仓库中的多余数据,我们还可以通过调节收集系统中的二级采样率系数来调整这个运行期采样率。Dapper 的管道维护变得更容易,因为我们就可以通过修改我们的二级采样率的配置,直接增加或减少我们的全局覆盖率和写入速度。 ## 5. 通用的 Dapper 工具 几年前,当 Dapper 还只是个原型的时候,它只能在 Dapper 开发者耐心的支持下使用。从那时起,我们逐渐迭代的建立了收集组件,编程接口,和基于 Web 的交互式用户界面,帮助 Dapper 的用户独立解决自己的问题。在本节中,我们会总结一下哪些的方法有用,哪些用处不大,我们还提供关于这些通用的分析工具的基本的使用信息。 ## 5.1 Dapper Depot API Dapper 的 “Depot API” 或称作 DAPI,提供在 Dapper 的区域仓库中对分布式跟踪数据一个直接访问。DAPI 和 Dapper 跟踪仓库被设计成串联的,而且 DAPI 意味着对 Dapper 仓库中的元数据暴露一个干净和直观的的接口。我们使用了以下推荐的三种方式去暴露这样的接口: - 通过跟踪 ID 来访问:DAPI 可以通过他的全局唯一的跟踪 ID 读取任何一次跟踪信息。 - 批量访问:DAPI 可以利用的 MapReduce 提供对上亿条 Dapper 跟踪数据的并行读取。用户重写一个虚拟函数,它接受一个 Dapper 的跟踪信息作为其唯一的参数,该框架将在用户指定的时间窗口中调用每一次收集到的跟踪信息。 - 索引访问:Dapper 的仓库支持一个符合我们通用调用模板的唯一索引。该索引根据通用请求跟踪特性 (commonly-requested trace features) 进行绘制来识别 Dapper 的跟踪信息。因为跟踪 ID 是根据伪随机的规则创建的,这是最好的办法去访问跟某个服务或主机相关的跟踪数据。 所有这三种访问模式把用户指向不同的 Dapper 跟踪记录。正如第 2.1 节所述的,Dapper 的由 span 组成的跟踪数据是用树形结构建模的,因此,跟踪数据的数据结构,也是一个简单的由 span 组成遍历树。Span 就相当于 RPC 调用,在这种情况下,RPC 的时间信息是可用的。带时间戳的特殊的应用标注也是可以通过这个 span 结构来访问的。 选择一个合适的自定义索引是 DAPI 设计中最具挑战性的部分。压缩存储要求在跟踪数据种建立一个索引的情况只比实际数据小 26%,所以消耗是巨大的。最初,我们部署了两个索引:第一个是主机索引,另一个是服务名的索引。然而,我们并没有找到主机索引和存储成本之间的利害关系。当用户对每一台主机感兴趣的时候,他们也会对特定的服务感兴趣,所以我们最终选择把两者相结合,成为一个组合索引,它允许以服务名称,主机,和时间戳的顺序进行有效的查找。 ## 5.1.1 DAPI 在 Google 内部的使用 DAPI 在谷歌的使用有三类:使利用 DAPI 的持续的线上 Web 应用,维护良好的可以在控制台上调用的基于 DAPI 的工具,可以被写入,运行、不过大部分已经被忘记了的一次性分析工具。我们知道的有 3 个持久性的基于 DAPI 的应用程序,8 个额外的按需定制的基于 DAPI 分析工具,以及使用 DAPI 框架构建的约 15~20 一次性的分析工具。在这之后的工具就这是很难说明了,因为开发者可以构建、运行和丢弃这些项目,而不需要 Dapper 团队的技术支持。 ## 5.2 Dapper 的用户接口 绝大多数用户使用发生在基于 web 的用户交互接口。篇幅有限,我们不能列出每一个特点,而只能把典型的用户工作流在图 6 中展示。 ![20241229154732_W4wKk8eh.webp](https://cdn.dong4j.site/source/image/20241229154732_W4wKk8eh.webp) 1. 用户描述的他们关心的服务和时间,和其他任何他们可以用来区分跟踪模板的信息(比如,span 的名称)。他们还可以指定与他们的搜索最相关的成本度量 ( cost metric)(比如,服务响应时间)。 2. 一个关于性能概要的大表格,对应确定的服务关联的所有分布式处理图表。用户可以把这些执行图标排序成他们想要的,并选择一种直方图去展现出更多的细节。 3. 一旦某个单一的分布式执行部分被选中后,用户能看到关于执行部分的的图形化描述。被选中的服务被高亮展示在该图的中心。 4. 在生成与步骤 1 中选中的成本度量 (cost metric) 维度相关的统计信息之后,Dapper 的用户界面会提供了一个简单的直方图。在这个例子中,我们可以看到一个大致的所选中部分的分布式响应时间分布图。用户还会看到一个关于具体的跟踪信息的列表,展现跟踪信息在直方图中被划分为的不同区域。在这个例子中,用户点击列表种第二个跟踪信息实例时,会在下方看到这个跟踪信息的详细视图 ( 步骤 5)。 5. 绝大多数 Dapper 的使用者最终的会检查某个跟踪的情况,希望能收集一些信息去了解系统行为的根源所在。我们没有足够的空间来做跟踪视图的审查,但我们使用由一个全局时间轴(在上方可以看到),并能够展开和折叠树形结构的交互方式,这也很有特点。分布式跟踪树的连续层用内嵌的不同颜色的矩形表示。每一个 RPC 的 span 被从时间上分解为一个服务器进程中的消耗(绿色部分)和在网络上的消耗(蓝色部分)。用户 Annotation 没有显示在这个截图中,但他们可以选择性的以 span 的形式包含在全局时间轴上。 为了让用户查询实时数据,Dapper 的用户界面能够直接与 Dapper 每一台生产环境下的服务器上的守护进程进行交互。在该模式下,不可能指望能看到上面所说的系统级的图表展示,但仍然可以很容易基于性能和网络特性选取一个特定的跟踪。在这种模式下,可在几秒钟内查到实时的数据。 根据我们的记录,大约有 200 个不同的 Google 工程师在一天内使用的 Dapper 的 UI; 在一周的过程中,大约有 750-1000 不同的用户。这些用户数,在新功能的内部通告上,是按月连续的。通常用户会发送特定跟踪的连接,这将不可避免地在查询跟踪情况时中产生很多一次性的,持续时间较短的交互。 ## 6. 经验 Dapper 在 Google 被广泛应用,一部分直接通过 Dapper 的用户界面,另一部分间接地通过对 Dapper API 的二次开发或者建立在基于 api 的应用上。在本节中,我们并不打算罗列出每一种已知的 Dapper 使用方式,而是试图覆盖 Dapper 使用方式的 “基本向量”,并努力来说明什么样的应用是最成功的。 ## 6.1 在开发中使用 Dapper Google AdWords 系统是围绕一个大型的关键词定位准则和相关文字广告的数据库搭建的。当新的关键字或广告被插入或修改时,它们必须通过服务策略术语的检查(如检查不恰当的语言,这个过程如果使用自动复查系统来做的话会更加有效)。 当轮到从头重新设计一个广告审查服务时,这个团队迭代的从第一个系统原型开始使用 Dapper,并且,最终用 Dapper 一直维护着他们的系统。Dapper 帮助他们从以下几个方面改进了他们的服务: - 性能:开发人员针对请求延迟的目标进行跟踪,并对容易优化的地方进行定位。Dapper 也被用来确定在关键路径上不必要的串行请求 -- 通常来源于不是开发者自己开发的子系统 -- 并促使团队持续修复他们。 - 正确性:广告审查服务围绕大型数据库系统搭建。系统同时具有只读副本策略(数据访问廉价)和读写的主策略(访问代价高)。Dapper 被用来在很多种情况中确定,哪些查询是无需通过主策略访问而可以采用副本策略访问。Dapper 现在可以负责监控哪些主策略被直接访问,并对重要的系统常量进行保障。 - 理解性:广告审查查询跨越了各种类型的系统,包括 BigTable—之前提到的那个数据库,多维索引服务,以及其他各种 C++ 和 Java 后端服务。Dapper 的跟踪用来评估总查询成本,促进重新对业务的设计,用以在他们的系统依赖上减少负载。 - 测试:新的代码版本会经过一个使用 Dapper 进行跟踪的 QA 过程,用来验证正确的系统行为和性能。在跑测试的过程中能发现很多问题,这些问题来自广告审查系统自身的代码或是他的依赖包。 广告审查团队广泛使用了 Dapper Annotation API。Guice[13] 开源的 AOP 框架用来在重要的软件组件上标注 “@Traced”。这些跟踪信息可以进一步被标注,包含:重要子路径的输入输出大小、基础信息、其他调试信息,所有这些信息将会额外发送到日志文件中。 同时,我们也发现了一些广告审查小组在使用方面的不足。比如:他们想根据他们所有跟踪的 Annotation 信息,在一个交互时间段内进行搜索,然而这就必须跑一个自定义的 MapReduce 或进行每一个跟踪的手动检查。另外,在 Google 还有一些其他的系统在也从通用调试日志中收集和集中信息,把那些系统的海量数据和 Dapper 仓库整合也是有价值的。 总的来说,即便如此,广告审查团队仍然对 Dapper 的作用进行了以下评估,通过使用 Dapper 的跟踪平台的数据分析,他们的服务延迟性已经优化了两个数量级。 ## 6.1.1 与异常监控的集成 Google 维护了一个从运行进程中不断收集并集中异常信息报告的服务。如果这些异常发生在 Dapper 跟踪采样的上下文中,那么相应的跟踪 ID 和 span 的 ID 也会作为元数据记录在异常报告中。异常监测服务的前端会提供一个链接,从特定的异常信息的报告直接导向到他们各自的分布式跟踪。广告审查团队使用这个功能可以了解 bug 发生的更大范围的上下文。通过暴露基于简单的唯一 ID 构建的接口,Dapper 平台被集成到其他事件监测系统会相对容易。 ## 6.2 解决延迟的长尾效应 考虑到移动部件的数量、代码库的规模、部署的范围,调试一个像全文搜索那样服务(第 1 节里提到过)是非常具有挑战性的。在这节,我们描述了我们在减轻全文搜索的延迟分布的长尾效应上做的各种努力。Dapper 能够验证端到端的延迟的假设,更具体地说,Dapper 能够验证对于搜索请求的关键路径。当一个系统不仅涉及数个子系统,而是几十个开发团队的涉及到的系统的情况下,端到端性能较差的根本原因到底在哪,这个问题即使是我们最好的和最有经验的工程师也无法正确回答。在这种情况下,Dapper 可以提供急需的数据,而且可以对许多重要的性能问题得出结论。 ![20241229154732_MdxLbMYD.webp](https://cdn.dong4j.site/source/image/20241229154732_MdxLbMYD.webp) 图 7:全局搜索的跟踪片段,在不常遇到高网络延迟的情况下,在沿着关键路径的端到端的请求延迟,如图所示。 在调试延迟长尾效应的过程中,工程师可以建立一个小型库,这个小型库可以根据 DAPI 跟踪对象来推断关键路径的层级结构。这些关键路径的结构可以被用来诊断问题,并且为全文搜索提供可优先处理的预期的性能改进。Dapper 的这项工作导致了下列发现: - 在关键路径上的短暂的网络性能退化不影响系统的吞吐量,但它可能会对延迟异常值产生极大的影响。在图 7 中可以看出,大部分的全局搜索的缓慢的跟踪都来源于关键路径的网络性能退化。 - 许多问题和代价很高的查询模式来源于一些意想不到的服务之间的交互。一旦发现,往往容易纠正它们,但是 Dapper 出现之前想找出这些问题是相当困难的。 - 通用的查询从 Dapper 之外的安全日志仓库中收取,并使用 Dapper 唯一的跟踪 ID,与 Dapper 的仓库做关联。然后,该映射用来建立关于在全局搜索中的每一个独立子系统都很慢的实例查询的列表。 ## 6.3 推断服务依赖 在任何给定的时间内,Google 内部的一个典型的计算集群是一个汇集了成千上万个逻辑 “任务” 的主机,一套的处理器在执行一个通用的方法。Google 维护着许多这样的集群,当然,事实上,我们发现在一个集群上计算着的这些任务通常依赖于其他的集群上的任务。由于任务们之间的依赖是动态改变的,所以不可能仅从配置信息上推断出所有这些服务之间的依赖关系。不过,除了其他方面的原因之外,在公司内部的各个流程需要准确的服务依赖关系信息,以确定瓶颈所在,以及计划服务的迁移。Google 的可称为 “Service Dependencies” 的项目是通过使用跟踪 Annotation 和 DAPI MapReduce 接口来实现自动化确定服务依赖归属的。 Dapper 核心组件与 Dapper 跟踪 Annotation 一并使用的情况下,“Service Dependencies” 项目能够推算出任务各自之间的依赖,以及任务和其他软件组件之间的依赖。比如,所有的 BigTable 的操作会加上与受影响的表名称相关的标记。运用 Dapper 的平台,Service Dependencies 团队就可以自动的推算出依赖于命名的不同资源的服务粒度。 ## 6.4 不同服务的网络使用率 Google 投入了大量的人力和物力资源在他的网络结构上。从前网络管理员可能只关注独立的硬件信息、常用工具及以及搭建出的各种全局网络鸟瞰图的 dashboard 上的信息。网络管理员确实可以一览整个网络的健康状况,但是,当遇到问题时,他们很少有能够准确查找网络负载的工具,用来定位应用程序级别的罪魁祸首。 虽然 Dapper 不是设计用来做链路级的监控的,但是我们发现,它是非常适合去做集群之间网络活动性的应用级任务的分析。Google 能够利用 Dapper 这个平台,建立一个不断更新的控制台,来显示集群之间最活跃的网络流量的应用级的热点。此外,使用 Dapper 我们能够为昂贵的网络请求提供指出的构成原因的跟踪,而不是面对不同服务器之间的信息孤岛而无所适从。建立一个基于 Dapper API 的 dashboard 总共没花超过 2 周的时间。 ## 6.5 分层和共享存储系统 在 Google 的许多存储系统是由多重独立复杂层级的分布式基础设备组成的。例如,Google 的 App Engine[5] 就是搭建在一个可扩展的实体存储系统上的。该实体存储系统在基于 BigTable 上公开某些 RDBMS 功能。 BigTable 的同时使用 Chubby[7](分布式锁系统)及 GFS。再者,像 BigTable 这样的系统简化了部署,并更好的利用了计算资源。 在这种分层的系统,并不总是很容易确定最终用户资源的消费模式。例如,来自于一个给定的 BigTable 单元格的 GFS 大信息量主要来自于一个用户或是由多个用户产生,但是在 GFS 层面,这两种明显的使用场景是很难界定。而且,如果缺乏一个像 Dapper 一样的工具的情况下,对共享服务的竞争可能会同样难于调试。 第 5.2 节中所示的 Dapper 的用户界面可以聚合那些调用任意公共服务的多个客户端的跟踪的性能信息。这就很容易让提供这些服务的源从多个维度给他们的用户排名。(例如,入站的网络负载,出站的网络负载,或服务请求的总时间) ## 6.6 Dapper 的救火能力 (Firefighting) 对于一些 “救火” 任务,Dapper 可以处理其中的一部分。“救火” 任务在这里是指一些有风险很高的在分布式系统上的操作。通常情况下,Dapper 用户当正在进行 “救火” 任务时需要使用新的数据,并且没有时间写新的 DAPI 代码或等待周期性的报告运行。 对于那些高延迟,不,可能更糟糕的那些在正常负载下都会响应超时的服务,Dapper 用户界面通常会把这些延迟瓶颈的位置隔离出来。通过与 Dapper 守护进程的直接通信,那些特定的高延迟的跟踪数据轻易的收集到。当出现灾难性故障时,通常是没有必要去看统计数据以确定根本原因,只查看示例跟踪就足够了 ( 因为前文提到过从 Dapper 守护进程中几乎可以立即获得跟踪数据)。 但是,如在 6.5 节中描述的共享的存储服务,要求当用户活动过程中突然中断时能尽可能快的汇总信息。对于事件发生之后,共享服务仍然可以利用汇总的的 Dapper 数据,但是,除非收集到的 Dapper 数据的批量分析能在问题出现 10 分钟之内完成,否则 Dapper 面对与共享存储服务相关的 “救火” 任务就很难按预想的那般顺利完成。 ## 7. 其他收获 虽然迄今为止,我们在 Dapper 上的经验已经大致符合我们的预期,但是也出现了一些积极的方面是我们没有充分预料到的。首先,我们获得了超出预期的 Dapper 使用用例的数量,对此我们可谓欢心鼓舞。另外,在除了几个的在第 6 节使用经验中提到过的一些用例之外,还包括资源核算系统,对指定的通讯模式敏感的服务的检查工具,以及一种对 RPC 压缩策略的分析器,等等。我们认为这些意想不到的用例一定程度上是由于我们向开发者以一种简单的编程接口的方式开放了跟踪数据存储的缘故,这使得我们能够充分利用这个大的多的社区的创造力。除此之外,Dapper 对旧的负载的支持也比预期的要简单,只需要在程序中引入一个用新版本的重新编译过的公共组件库 (包含常规的线程使用,控制流和 RPC 框架) 即可。 Dapper 在 Google 内部的广泛使用还为我们在 Dapper 的局限性上提供了宝贵的反馈意见。下面我们将介绍一些我们已知的最重要的 Dapper 的不足: - 合并的影响:我们的模型隐含的前提是不同的子系统在处理的都是来自同一个被跟踪的请求。在某些情况下,缓冲一部分请求,然后一次性操作一个请求集会更加有效。(比如,磁盘上的一次合并写入操作)。在这种情况下,一个被跟踪的请求可以看似是一个大型工作单元。此外,当有多个追踪请求被收集在一起,他们当中只有一个会用来生成那个唯一的跟踪 ID,用来给其他 span 使用,所以就无法跟踪下去了。我们正在考虑的解决方案,希望在可以识别这种情况的前提下,用尽可能少的记录来解决这个问题。 - 跟踪批处理负载:Dapper 的设计,主要是针对在线服务系统,最初的目标是了解一个用户请求产生的系统行为。然而,离线的密集型负载,例如符合 MapReduce[10] 模型的情况,也可以受益于性能挖潜。在这种情况下,我们需要把跟踪 ID 与一些其他的有意义的工作单元做关联,诸如输入数据中的键值(或键值的范围),或是一个 MapReduce shard。 - 寻找根源:Dapper 可以有效地确定系统中的哪一部分致使系统整个速度变慢,但并不总是能够找出问题的根源。例如,一个请求很慢有可能不是因为它自己的行为,而是由于队列中其他排在它前面的 ( queued ahead of) 请求还没处理完。程序可以使用应用级的 annotation 把队列的大小或过载情况写入跟踪系统。此外,如果这种情况屡见不鲜,那么在 ProfileMe[11] 中提到的成对的采样技术可以解决这个问题。它由两个时间重叠的采样率组成,并观察它们在整个系统中的相对延迟。 - 记录内核级的信息:一些内核可见的事件的详细信息有时对确定问题根源是很有用的。我们有一些工具,能够跟踪或以其他方式描述内核的执行,但是,想用通用的或是不那么突兀的方式,是很难把这些信息到捆绑到用户级别的跟踪上下文中。我们正在研究一种妥协的解决方案,我们在用户层面上把一些内核级的活动参数做快照,然后绑定他们到一个活动的 span 上。 ## 8. 相关产品 在分布式系统跟踪领域,有一套完整的体系,一部分系统主要关注定位到故障位置,其他的目标是针对性能进行优化。 Dapper 确实被用于发现系统问题,但它更通常用于探查性能不足,以及提高全面大规模的工作负载下的系统行为的理解。 与 Dapper 相关的黑盒监控系统,比如 Project5[1],WAP5[15] 和 Sherlock[2] ,可以说不依赖运行库的情况下,黑盒监控系统能够实现更高的应用级透明。黑盒的缺点是一定程度上不够精确,并可能在统计推断关键路径时带来更大的系统损耗。 对于分布式系统监控来说,基于 Annotation 的中间件或应用自身是一个可能是更受欢迎的解决办法. 拿 Pip[14] 和 Webmon[16] 系统举例,他们更依赖于应用级的 Annotation,而 X-Trace[12],Pinpoint[9] 和 Magpie[3] 大多集中在对库和中间件的修改。Dapper 更接近后者。像 Pinpoint,X-Trace,和早期版本的 Magpie 一样,Dapper 采用了全局标识符把分布式系统中各部分相关的事件联系在一起。和这些系统类似,Dapper 尝试避免使用应用级 Annotation,而是把的植入隐藏在通用组件模块内。Magpie 放弃使用全局 ID,仍然试图正确的完成请求的正确传播,他通过采用应用系统各自写入的事件策略,最终也能精确描述不同事件之间关系。但是目前还不清楚 Magpie 在实际环境中实现透明性这些策略到底多么有效。 X-Trace 的核心 Annotation 比 Dapper 更有野心一些,因为 X-Trace 系统对于跟踪的收集,不仅在跟踪节点层面上,而且在节点内部不同的软件层也会进行跟踪。而我们对于组件的低性能损耗的要求迫使我们不能采用 X-Trace 这样的模型,而是朝着把一个请求连接起来完整跟踪所能做到的最小代价而努力。而 Dapper 的跟踪仍然可以从可选的应用级 Annotation 中获益。 ## 9. 总结 在本文中,我们介绍 Dapper 这个 Google 的生产环境下的分布式系统跟踪平台,并汇报了我们开发和使用它的相关经验。 Dapper 几乎在部署在所有的 Google 系统上,并可以在不需要应用级修改的情况下进行跟踪,而且没有明显的性能影响。Dapper 对于开发人员和运维团队带来的好处,可以从我们主要的跟踪用户界面的广泛使用上看出来,另外我们还列举了一些 Dapper 的使用用例来说明 Dapper 的作用,这些用例有些甚至都没有 Dapper 开发团队参与,而是被应用的开发者开发出来的。 据我们所知,这是第一篇汇报生产环境下分布式系统跟踪框架的论文。事实上,我们的主要贡献源于这个事实:论文中回顾的这个系统已经运行两年之久。我们发现,结合对开发人员提供简单 API 和对应用系统完全透明来增强跟踪的这个决定,是非常值得的。 我们相信,Dapper 比以前的基于 Annotation 的分布式跟踪达到更高的应用透明度,这一点已经通过只需要少量人工干预的工作量得以证明。虽然一定程度上得益于我们的系统的同质性,但它本身仍然是一个重大的挑战。最重要的是,我们的设计提出了一些实现应用级透明性的充分条件,对此我们希望能够对更错杂环境下的解决方案的开发有所帮助。 最后,通过开放 Dapper 跟踪仓库给内部开发者,我们促使更多的基于跟踪仓库的分析工具的产生,而仅仅由 Dapper 团队默默的在信息孤岛中埋头苦干的结果远达不到现在这么大的规模,这个决定促使了设计和实施的展开。 ### Acknowledgments We thank Mahesh Palekar, Cliff Biffle, Thomas Kotzmann, Kevin Gibbs, Yonatan Zunger, Michael Kleber, and Toby Smith for their experimental data and feedback about Dapper experiences. We also thank Silvius Rus for his assistance with load testing. Most importantly, though, we thank the outstanding team of engineers who have continued to develop and improve Dapper over the years; in order of appearance, Sharon Perl, Dick Sites, Rob von Behren, Tony DeWitt, Don Pazel, Ofer Zajicek, Anthony Zana, Hyang-Ah Kim, Joshua MacDonald, Dan Sturman, Glenn Willen, Alex Kehlenbeck, Brian McBarron, Michael Kleber, Chris Povirk, Bradley White, Toby Smith, Todd Derr, Michael De Rosa, and Athicha Muthitacharoen. They have all done a tremendous amount of work to make Dapper a day-to-day reality at Google. ### References [1] M. K. Aguilera, J. C. Mogul, J. L. Wiener, P. Reynolds, and A. Muthitacharoen. Performance Debugging for Distributed Systems of Black Boxes. In Proceedings of the 19th ACM Symposium on Operating Systems Principles, December 2003. [2] P. Bahl, R. Chandra, A. Greenberg, S. Kandula, D. A. Maltz, and M. Zhang. Towards Highly Reliable Enterprise Network Services Via Inference of Multi-level Dependencies. In Proceedings of SIGCOMM, 2007. [3] P. Barham, R. Isaacs, R. Mortier, and D. Narayanan. Magpie: online modelling and performance-aware systems. In Proceedings of USENIX HotOS IX, 2003. [4] L. A. Barroso, J. Dean, and U. Holzle. Web Search for a Planet: The Google Cluster Architecture. IEEE Micro, 23(2):22–28, March/April 2003. [5] T. O. G. Blog. Developers, start your engines. http://googleblog.blogspot.com/2008/04/developers-start-your-engines.html,2007. [6] T. O. G. Blog. Universal search: The best answer is still the best answer. http://googleblog.blogspot.com/2007/05/universal-search-best-answer-is-still.html, 2007. [7] M. Burrows. The Chubby lock service for loosely-coupled distributed systems. In Proceedings of the 7th USENIX Symposium on Operating Systems Design and Implementation, pages 335 – 350, 2006. [8] F. Chang, J. Dean, S. Ghemawat, W. C. Hsieh, D. A. Wallach, M. Burrows, T. Chandra, A. Fikes, and R. E. Gruber. Bigtable: A Distributed Storage System for Structured Data. In Proceedings of the 7th USENIX Symposium on Operating Systems Design and Implementation (OSDI’06), November 2006. [9] M. Y. Chen, E. Kiciman, E. Fratkin, A. fox, and E. Brewer. Pinpoint: Problem Determination in Large, Dynamic Internet Services. In Proceedings of ACM International Conference on Dependable Systems and Networks, 2002. [10] J. Dean and S. Ghemawat. MapReduce: Simplified Data Processing on Large Clusters. In Proceedings of the 6th USENIX Symposium on Operating Systems Design and Implementation (OSDI’04), pages 137 – 150, December 2004. [11] J. Dean, J. E. Hicks, C. A. Waldspurger, W. E. Weihl, and G. Chrysos. ProfileMe: Hardware Support for Instruction-Level Profiling on Out-of-Order Processors. In Proceedings of the IEEE/ACM International Symposium on Microarchitecture, 1997. [12] R. Fonseca, G. Porter, R. H. Katz, S. Shenker, and I. Stoica. X-Trace: A Pervasive Network Tracing Framework. In Proceedings of USENIX NSDI, 2007. [13] B. Lee and K. Bourrillion. The Guice Project Home Page. http://code.google.com/p/google-guice/, 2007. [14] P. Reynolds, C. Killian, J. L. Wiener, J. C. Mogul, M. A. Shah, and A. Vahdat. Pip: Detecting the Unexpected in Distributed Systems. In Proceedings of USENIX NSDI, 2006. [15] P. Reynolds, J. L. Wiener, J. C. Mogul, M. K. Aguilera, and A. Vahdat. WAP5: Black Box Performance Debugging for Wide-Area Systems. In Proceedings of the 15th International World Wide Web Conference, 2006. [16] P. K. G. T. Gschwind, K. Eshghi and K. Wurster. WebMon: A Performance Profiler for Web Transactions. In E-Commerce Workshop, 2002. [](https://github.com/bigbully/Dapper-translation)is maintained by[bigbully](https://github.com/bigbully). This page was generated by[GitHub Pages](http://bigbully.github.io/Dapper-translation/pages.github.com)using the Architect theme by[Jason Long](http://twitter.com/jasonlong). ## [IntelliJ IDEA 翻译插件开发 - 从概念到实践](https://blog.dong4j.site/posts/d2f9713d.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 相对于 Hello World 版的插件, 我们可以学习一个 API 的用法. 在接下来的博文中将创建一个翻译插件, 相对于 Hello World 版的插件, 我们可以学习一个 API 的用法. 在写之前, 先补充一下上一篇博文中存在的问题. ## 使用 Gradle 开发 Intellij IDEA Plugin 的问题 当我们使用 Intellij 自带的 Intellij Platform Plugin 创建插件项目后, 我们可以通过 Intellij 以图形化界面创建 Action, Module Component 等, 就像这样: ![20241229154732_agqWr29W.webp](https://cdn.dong4j.site/source/image/20241229154732_agqWr29W.webp) ![20241229154732_oLiFqQVA.webp](https://cdn.dong4j.site/source/image/20241229154732_oLiFqQVA.webp) OK 之后, 就会自动创建一个 `GenerateActionByGui` 继承 AnAction 的类, 并且在 plugin.xml 中自动写入插件的配置: ```xml ``` 简单快捷方便, 将程序猿偷懒的精神展现的淋漓尽致, 那么问题来了....... 当我们使用 Gradle 或者 Maven 这种项目管理软件创建 插件项目时, 你会发现这些功能没有了, 不信你看.. ![20241229154732_WEwAIx7S.webp](https://cdn.dong4j.site/source/image/20241229154732_WEwAIx7S.webp) 没有选择 Action 的按钮了, 而且还要一个问题 ![20241229154732_MODMbmRJ.webp](https://cdn.dong4j.site/source/image/20241229154732_MODMbmRJ.webp) Use classpath of module 一直为 [none] , 这时候该怎么办呢? ### 解决方法 这里首先应该想到, 为什么创建 Intellij Platform Plugin 项目和创建 Gradle 项目存在区别? IDEA 应该会根据不同的项目类别显示不同的可用功能按钮. 对 Intellij IDEA 了解的朋友应该知道, 当创建或者打开一个 IDEA 工程时, 会自动创建 .idea 文件夹 和 .iml 文件, iml 是 Intellij IDEA 的工程配置文件,里面是当前 projec 的一些配置信息. 既然是工程配置文件, 应该会标识当前项目是属于哪种类型的工程, 顺着这种思路, 还真被我找到了. 使用 Gradle 创建的项目中, 在 .idea/moudle/ 下有 3 个 .iml 文件 ![20241229154732_AuprBGdq.webp](https://cdn.dong4j.site/source/image/20241229154732_AuprBGdq.webp) 我们需要改的就是 `_main.iml` 原来的文件内容: ```xml ...... ``` 改过之后的内容: ```xml ...... ``` 区别就是将 `` 标签中的 type 属性由原来的 JAVA_MODULE 改为 PLUGIN_MODULE. 修改之后整个项目其实已经是一个 Plugin 项目了 ![20241229154732_Y8lFfaE8.webp](https://cdn.dong4j.site/source/image/20241229154732_Y8lFfaE8.webp) 图标都变成 Plugin 特有的了, 看到这个我就放心了..... 如果现在通过 GUI 创建一个 Action, 到最后你会发现会报一个找不到 plugin.xml 的错误, 所以还需要添加一个行 ```xml ``` 让 IDEA 找到 plugin.xml, 并在我们创建好 Action 后, 向 plugin.xml 中写入 Action 配置. 还有一个问题没有解决, 虽然我们已经能配置 ![20241229154732_fgyzTJ2T.webp](https://cdn.dong4j.site/source/image/20241229154732_fgyzTJ2T.webp) 但是当运行的时候, 却会报错, 因为我们的依赖是通过 Gradle 管理的, 这样直接运行虽然能打开容器, 但却不能调试我们的插件, 我们还是通过 Gradle 运行 ```shell gradle runI ``` 如果从 GitHub 上 clone 下来一个优秀的插件学习的话, clone 下来的包中可能没有 .idea 这个文件夹, 当导入到 IDEA 中时, 只有 ![20241229154732_hB7EeLXq.webp](https://cdn.dong4j.site/source/image/20241229154732_hB7EeLXq.webp) 这 3 个选择, 如果不是上述项目, 我选择直接打开项目, 等 IDEA 自动创建好 .idea 后, 通过修改 .iml 文件, 将项目改为 plugin 项目, 然后就可以创建 Plugin 的运行环境了. 这种方法适合没有通过 Gradle 或者 Maven 项目管理的 Plugin 项目. 问题解决, 接下来是一个翻译插件的开发过程 ## 翻译插件开发 愉快的用 GUI 创建一个 Action ![20241229154732_OeiDbFYr.webp](https://cdn.dong4j.site/source/image/20241229154732_OeiDbFYr.webp) ### 有道 API 有道翻译 API HTTP 地址: > [http://openapi.youdao.com/api](http://openapi.youdao.com/api) 有道翻译 API HTTPS 地址: > [https://openapi.youdao.com/api](https://openapi.youdao.com/api) ## 接口调用参数 [](http://ai.youdao.com/docs/api.s#id2 "永久链接至标题") 调用 API 需要向接口发送以下字段来访问服务。 | 字段名 | 类型 | 含义 | 必填 | 备注 | | :----- | :--- | :---------------------------------------- | :--- | :-------------------------------------------------------------- | | q | text | 要翻译的文本 | True | 必须是 UTF-8 编码 | | from | text | 源语言 | True | [语言列表](http://ai.youdao.com/docs/api.s#id5) (可设置为 auto) | | to | text | 目标语言 | True | [语言列表](http://ai.youdao.com/docs/api.s#id5) (可设置为 auto) | | appKey | text | 应用 ID | True | 可在 [应用管理](http://ai.youdao.com/appmgr.s) 查看 | | salt | text | 随机数 | True | | | sign | text | 签名,通过 md5(appKey+q+salt + 密钥) 生成 | True | appKey+q+salt + 密钥的 MD5 值 | 签名生成方法如下: 1、将请求参数中的 appKey,翻译文本 query(q, 注意为 UTF-8 编码),随机数 (salt) 和密钥 (可在 [应用管理](http://ai.youdao.com/appmgr.s) 查看), 按照 appid+q+salt + 密钥 的顺序拼接得到字符串 str。 2、对字符串 str 做 md5,得到 32 位小写的 sign[(参考 Java 生成 MD5 示例)](http://ai.youdao.com/docs/api.s#java-demo) 注意: 1、请先将需要翻译的文本转换为 UTF-8 编码 2、在发送 HTTP 请求之前需要对各字段做 URL encode。 3、在生成签名拼接 appKey+q+salt 字符串时,q 不需要做 URL encode,在生成签名之后,发送 HTTP 请求之前才需要对要发送的待翻译文本字段 q 做 URL encode。 ## 输出结果 [](http://ai.youdao.com/docs/api.s#id3 "永久链接至标题") 返回的结果是 json 格式,包含字段与 FROM 和 TO 的值有关,具体说明如下: | 字段名 | 类型 | 含义 | 备注 | | :---------- | :--- | :----------- | :------------------------- | | errorCode | text | 错误返回码 | 一定存在 | | query | text | 源语言 | 查询正确时,一定存在 | | speakUrl | text | 输入发音地址 | 输入发音地址,一定存在 | | tSpeakUrl | text | 翻译发音地址 | 翻译发音地址,一定存在 | | translation | text | 翻译结果 | 查询正确时一定存在 | | basic | text | 词义 | 基本词典, 查词时才有 | | web | text | 词义 | 网络释义,该结果不一定存在 | ## 总结 1. 怎么获取选择的对象? 2. Action ID : 代表当前 Action 的唯一 id Class Name : 类名 Name : 插件按钮显示在菜单上的名称 Description : 鼠标悬浮在按钮上时, 界面底部显得的描述 Add to Group : 功能按钮添加的位置 Groups : 所属的分组 Action : 设置在组中的位置 Keyboard shortcuts : 功能按钮的快捷键 #### 插件开发一些 API 获取当前编辑的文件 `PsiFile psiFile = event.getData(LangDataKeys.PSI_FILE);` 可以通过下面两种方式对文件的进行操作: ``` new WriteCommandAction.Simple(event.getProject(), psiFile) { @Override protected void run() throws Throwable { // do something } }.execute(); ``` ``` WriteCommandAction.runWriteCommandAction(event.getProject(), new Runnable() { @Override public void run() { // do something } }); ``` 获取当前编辑的 class 对象 ``` PsiElement element = psiFile.findElementAt(editor.getCaretModel().getOffset()); PsiClass psiClass = PsiTreeUtil.getParentOfType(element, PsiClass.class); ``` 获取类名 ``` String className = psiClass.getNameIdentifier().getText(); ``` 获得 PsiElementFactory 对象 可以通过这个工厂类创建成员变量 方法 类等等 ``` PsiElementFactory elementFactory = JavaPsiFacade.getElementFactory(project); ``` 添加一个方法 ``` String methodText = buildMethodText(className); PsiMethod psiMethod = elementFactory.createMethodFromText(methodText, psiClass); psiClass.add(psiMethod); ``` ``` private String buildMethodText (String className){ return "public static " + className + " getInstance() {\n" + " return " + buildFiledText() + ";\n" + " }"; } ``` 添加一个构造方法 ``` PsiMethod constructor = elementFactory.createConstructor(); psiClass.add(constructor); ``` 添加一个成员变量,PsiType 表示变量的类型,PsiModifierList 代表变量的修饰符,可以通过 setInitializer 设置变量初始化方式 ``` PsiType psiType = PsiType.getTypeByName(className, project , GlobalSearchScope.EMPTY_SCOPE); PsiField psiField = elementFactory.createField("mInstance", psiType); PsiExpression psiInitializer = elementFactory.createExpressionFromText("new " + className + "()", psiField); psiField.setInitializer(psiInitializer); PsiModifierList modifierList = psiField.getModifierList(); if (modifierList != null) { modifierList.setModifierProperty(PsiModifier.STATIC, true); } psiClass.add(psiField); ``` 添加一个内部类 ``` PsiClass innerClass = elementFactory.createClass(innerClassName); PsiModifierList classModifierList = innerClass.getModifierList(); if (classModifierList != null) { classModifierList.setModifierProperty(PsiModifier.PRIVATE, true); classModifierList.setModifierProperty(PsiModifier.STATIC, true); } psiClass.add(innerClass); ``` 其他 ``` // 创建枚举 PsiClass anEnum = elementFactory.createEnum("TestEnum"); // 创建一个枚举常量 PsiEnumConstant enumConstant= elementFactory.createEnumConstantFromText("TEST",anEnum); // 创建接口 elementFactory.createInterface("TestInterface"); ``` 格式化代码 ``` CodeStyleManager.getInstance(project).reformat(psiClass); ``` 插件的 UI 都是使用 java Swing 来实现,比如创建一个 Dialog:src>new>Dialog; 然后会生成一个 JDialog 的 java 类和一个 Dialog 的 from 文件。然后在 Action 中使用: ``` TestDialog dialog = new TestDialog(); dialog.setBounds(new Rectangle(300, 200)); // 让 dialog 居中 dialog.setLocationRelativeTo(null); dialog.setVisible(true); ``` ## [掌握IntelliJ插件开发:从Gradle入门到高级实战](https://blog.dong4j.site/posts/7dc8ea5f.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) IDEA Plugin 开发记录 hello world 篇. 为什么没有 Intellij 插件开发入门, 因为网上已经有很多入门的教程了, 随便一搜, 大部分都是 Hello World 入门教程, 这里再写就没有意义了, 接下来的几篇都将围绕自己开发的几个插件, 将用到的没有用到的都写出来, 一是做一个记录, 二是希望能帮助那些想开发自己的插件的老铁. 前期主要是为插件开发做准备, 搜索了很多 Intellij 插件开发的博文, 如果搜到的不满意, 可以尝试以 "Android Stutio 插件开发" 关键字进行搜索, 毕竟 Android Stutio 是根据 Intellij IDEA 进行二次开发的. 这里现将看到的有价值的博文地址分享一些, 谢谢他们的分享. [街头客 - 简书](http://www.jianshu.com/u/5239b57bf75e) 一共 9 篇插件开发的博文, 非常好. ## 使用 Gradle 开发 Intellij 插件 官方使用 Intellij 的 Intellij Platform Plugin 来创建插件项目, 用惯了 Maven, 没用项目管理工具, 感觉一下子回到了解放前, 这里不用 Maven 而用 Gradle, 是为了学习下 Gradle. ### Gradle 版的 Intellij IDEA Plugin Hello World #### 安装 Gradle ```shell brew install gradle ``` 查看安装后的信息 ```shell ~ brew info gradle gradle: stable 3.4.1 Build system based on the Groovy language https://www.gradle.org/ /usr/local/Cellar/gradle/2.14.1 (171 files, 47.4MB) * Built from source on 2016-09-28 at 10:22:44 From: https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/homebrew-core.git/Formula/gradle.rb ==> Requirements Required: java >= 1.7 ✔ ==> Options --with-all Installs Javadoc, examples, and source in addition to the binaries ``` 设置环境变量 (zsh) ```shell GRADLE_HOME="/usr/local/Cellar/gradle/2.14.1" export GRADLE_HOME export PATH=$PATH:$GRADLE_HOME/bin ``` 记得 ```shell source ~/.zshrc ``` 查看版本信息 ```shell ✘  ~  gradle -version ------------------------------------------------------------ Gradle 2.14.1 ------------------------------------------------------------ Build time: 2016-07-18 06:38:37 UTC Revision: d9e2113d9fb05a5caabba61798bdb8dfdca83719 Groovy: 2.4.4 Ant: Apache Ant(TM) version 1.9.6 compiled on June 29 2015 JVM: 1.8.0_72 (Oracle Corporation 25.72-b15) OS: Mac OS X 10.12.4 x86_64 ``` #### 创建插件项目 这里直接使用 Intellij 创建 Gradle 项目 ![20241229154732_CgdCH5r9.webp](https://cdn.dong4j.site/source/image/20241229154732_CgdCH5r9.webp) 接下需要设置下 "三要素", 同 Maven 一样, 然后一路 next 然后设置 gradle ![20241229154732_JfSqPXtV.webp](https://cdn.dong4j.site/source/image/20241229154732_JfSqPXtV.webp) 最后创建完成的样子 ![20241229154732_wRkxsSlT.webp](https://cdn.dong4j.site/source/image/20241229154732_wRkxsSlT.webp) src 目录结构是我自己加的, 直接创建目录就行了, gradle 会自动识别文件结构 (目录结构同 Maven) #### 设置 build.gradle ``` buildscript { repositories { mavenCentral() maven { url 'http://dl.bintray.com/jetbrains/intellij-plugin-service'} } } // 添加 intellij build plugins 仓库地址 plugins { id "org.jetbrains.intellij" version "0.1.10" } repositories { mavenCentral() } tasks.withType(JavaCompile) { options.encoding = "UTF-8" } // 使用 intellij idea 的插件 apply plugin: 'java' apply plugin: 'idea' apply plugin: 'org.jetbrains.intellij' sourceCompatibility = 1.8 // 设置运行插件的 intellij 版本以及沙箱地址 intellij { version 'IC-14.1.4' sandboxDirectory = project.rootDir.canonicalPath + "/.sandbox" // 插件生成的临时文件的地址 } dependencies { testCompile group: 'junit', name: 'junit', version: '4.11' } ``` #### 添加 plugin.xml ```xml com.your.company.unique.plugin.id Plugin display name here 1.0 YourCompany most HTML tags may be used ]]> most HTML tags may be used ]]> ``` 最后的目录结构 ![20241229154732_xjjHuxuj.webp](https://cdn.dong4j.site/source/image/20241229154732_xjjHuxuj.webp) #### 运行 还是写个 Hello World 吧, 不然怎么知道这个是否可行呢? 当初按照官方的教程, 照着写了一遍 Hello World, 然后使用 Gradle 再来一遍, 可是 "User classpath of module" 一直为 [none] ![20241229154732_tFJzJDh8.webp](https://cdn.dong4j.site/source/image/20241229154732_tFJzJDh8.webp) 搞毛啊, 没有这个启动不了插件, 就调试不了啊, 后来想了想 Maven, Maven 有很多插件, 什么 `maven-source-plugin` , `maven-compiler-plugin` , `maven-deploy-plugin` 什么的 ![20241229154732_7Aaxt2nN.webp](https://cdn.dong4j.site/source/image/20241229154732_7Aaxt2nN.webp) 再看看 Gradle ![20241229154732_ka8UZZM4.webp](https://cdn.dong4j.site/source/image/20241229154732_ka8UZZM4.webp) 右键 运行 或者 debug 或者 直接 ``` gradle runI ``` ![20241229154732_hoY1G8Zs.webp](https://cdn.dong4j.site/source/image/20241229154732_hoY1G8Zs.webp) ![20241229154732_ZOp06YCn.webp](https://cdn.dong4j.site/source/image/20241229154732_ZOp06YCn.webp) [gradle-demo 源码](https://github.com/dong4j/gradle-demo) #### 遇到的问题 ###### Gradle 路径设置 由于我使用 brew 安装 Gradle, 使用 环境变量中的 `/usr/local/Cellar/gradle/2.14.1` 路径会造成 ``` intellij gradle cannot save settings ``` 错误. 这里贴出 解决方法: 在任意目录下创建一个 build.gradle 文件 , 里面写入 ```shell task getHomeDir << { println gradle.gradleHomeDir } ``` 在 build.gradle 目录下执行 ``` gradle getHomeDir ``` 输出: ```shell :getHomeDir /usr/local/Cellar/gradle/2.14.1/libexec ``` 这里的输出才是真正的 gradle 目录 这里说一下 通过 brew 安装的软件的情况. 通过 brew 安装的软件会会在 `/usr/local/Cellar` 目录下 以 Tomcat 为例: ``` /usr/local/Cellar/tomcat └── 8.5.9 ├── bin │ ├── catalina -> ../libexec/bin/catalina.sh └── libexec ├── bin ``` 在不同的版本目录下会有 **bin** 和 **libexec** 这 2 个重要的目录 **bin** 目录下其实是一个软连接, 真正的执行文件是 **../libexec/bin/catalina.sh** 而 **libexec** 才是软件真正的安装目录. 大多数使用 brew 安装的软件都是这样的. ###### Gradle Intellij 依赖 由于众所周知的原因, Gradle 下载很慢, 所以这里使用迅雷先把 `ideaIC-14.1.4.zip` 和 `ideaIC-14.1.4-sources.jar` 下载下来, 放在 ``` /Users/xxxx/.gradle/caches/modules-2/files-2.1/com.jetbrains.intellij.idea/ideaIC/14.1.4/xxxxxxxxxxxx/ ``` #### 参考 - [Gradle 10 分钟上手指南](http://www.cnblogs.com/yjmyzz/p/gradle-getting-start.html) - [使用 gradle 来构建 intellij 插件](http://blog.qianlicao.cn/technology/2016/11/03/build-plugins-with-gradle/) - [Setting up Gradle Plugin For IntelliJ](http://www.rabblemedia.net/blog/setting-up-gradle-plugin-for-intellij/) ## [ZooKeeper配置中心:简化你的开发部署过程](https://blog.dong4j.site/posts/ffa1ef45.md) 基于 Zookeeper 实现的一个配置中心 ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 现在架构存在的问题 1. 配置分散 比如修改日志配置需要修改 xxx-api, xxx-webgis, ..., xxx-server. 2. 只有 dev, test, pro 本地开发时需要修改 dubbo.server.version, 提交时忘记改回来造成服务调用出错. 3. 配置类太多, 且有重复的配置类, 不好管理 4. 非开发环境下应用配置在 war 包中,关键配置明文显示 5. 修改配置后, 需要重新打包部署 6. 关键配置随时可修改, 一不小心就会造成生产事故 **为了统一管理配置 **. 现将配置从 pom 中迁入到 xxx-common-config, 使用 maven filter 实现根据不同环境从 ${env}.yyyyy.properties 获取配置替换 application.properties 配置 (@占位符替换) 使用 `` 配合 `@Value("${}")` 实现自动注入配置到配置类. 新增 local 环境用于本地开发 但是仍然解决不了敏感配置的安全, 配置不能统一管理等问题. ## 解决方案 使用配置中心, 统一管理配置, 将敏感配置隔离出来. 借助 ZooKeeper 我们实现的配置信息存储方案具有的优点如下: 1. 简单。尽管前期搭建 ZooKeeper 服务器集群较为麻烦, 但是实现该方案后, 修改配置整个过程变得简单很多。用户只要修改配置, 无需进行其他任何操作, 配置自动生效。 2. 可靠。ZooKeeper 服务集群具有无单点失效的特性, 使整个系统更加可靠。即使 ZooKeeper 集群中的一台机器失效, 也不会影响整体服务, 更不会影响分布式应用配置信息的更新。 3. 实时。ZooKeeper 的数据更新通知机制, 可以在数据发生变化后, 立即通知给分布式应用程序, 具有很强的变化响应能力。 **能解决的问题 ** - 保证不同部署环境下应用配置的 **隔离性 ** - 保证非开发环境下应用配置的 **保密性 ** - 保证不同部署节点上同一应用配置的 **一致性 ** - 实现分布式环境下应用配置的 **可管理性 ** ## 配置分类 ### 现有配置 ``` # 环境变量 env # api 测试使用 ignore.auth.flag # 日志配置 # JDBC # 图片上传配置 # SSDB # kafka 配置信息 # activemq 配置信息 # dubbo 配置 # 兑吧 ... ``` **来源 ** 1. 本地配置文件 2. 数据库 3. Redis 4. Zookeeper **读写频率 ** 1. 单次读取型 1. dubbo.service.version 2. Logback 2. 多次读取型 (本地开发时动态切换环境) 1. MongoDB 2. Kafka 3. Zookeeper 4. JDBC 5. Redis 6. SSDB 3. 动态读取型 1. **字典数据 ** 2. waybill.upload 3. 保险 4. 支付 5. 企查查 6. 声网 7. 上传地址 8. 定位、话费、礼品兑换开关配置 9. 商务端推送配置 10. 兑吧配置 11. 个推 12. 中交配置 13. 容联云配置 ## 总体设计 ### 系统架构 ![20241229154732_SnRlXtfr.webp](https://cdn.dong4j.site/source/image/20241229154732_SnRlXtfr.webp) ### 基础模型 ![20241229154732_dPGKve6u.webp](https://cdn.dong4j.site/source/image/20241229154732_dPGKve6u.webp) - 用户通过配置中心修改 zk 节点配置 - client 监听到节点数据被修改, 获取 spring 容器配置类, 动态修改配置 ### 序列图 #### Config center 功能: 1. 统一管理配置 2. 修改日志级别 3. 修改日志采集率 4 查看应用状态 (是否在线, 上次部署时间等) 后期考虑增加的功能 1. 应用下线邮件 (短信 / 微信) 提示 2. kafka 操作, 查看信息 3. dubbo 服务注册信息 ![20241229154732_AI9g85IB.webp](https://cdn.dong4j.site/source/image/20241229154732_AI9g85IB.webp) #### Client 启动时读取配置,运行时根据配置调整行为 ![20241229154732_pdeGNmRL.webp](https://cdn.dong4j.site/source/image/20241229154732_pdeGNmRL.webp) ### Zookeeper 配置节点设计 ![20241229154732_k6F5M08C.webp](https://cdn.dong4j.site/source/image/20241229154732_k6F5M08C.webp) **只有 local 环境才有人员节点 ** 只有 local 是本地开发配置, 因为开发需要, 有时会切换不同配置, 因此将配置分配到具体开发者身上, 这样修改一个配置时, 只会对某个开发者生效, 不影响其他人 **节点不能共用 ** 意思是不能抽取出公共的配置, 因为修改了公共配置, 监听此节点的 client 都会修改配置. ## 模块说明 ### Client 端 Client 端暂时放在 trace 模块中, 方便开发测试 ### 配置中心 admin ```lua . ├── common-parent # 所有模块的父模块, 负责版本与依赖控制 ├── common-utils # 公共工具类 ├── xxx-api-manager # 未来要做的 api 管理系统 │ └── api-manager-rest # api 管理系统的 rest 接口 ├── xxx-config-server # 配置中心父模块 │ ├── xxx-config-admin # 配置中心前端 │ └── xxx-config-rest # 配置中心 rest 接口 └── spring-boot-starter-curator # 封装的 curator spring boot start ``` ## 技术选型 **Curator(馆长, 管理员)** 替代 Zkclient 的另一个简单强大的 Zookeeper 客户端, (Curator)馆长与 (Zookeeper) 动物园, 天生一对 🤣🤣 Curator 包含了几个包: - curator-framework:对 zookeeper 的底层 api 的一些封装 - curator-client:提供一些客户端的操作,例如重试策略等 - curator-recipes:封装了一些高级特性,如:Cache 事件监听、选举、分布式锁、分布式计数器、分布式 Barrier 等 **Spring Boot** 用于开发配置中心的 Web 端框架, 实现快速开发, 简单部署. ### 开发配置管理解决方案 1. clone 代码到本地 (git 并没有管理 xxx-common-config 中的 application.yml) 2. maven profile 选择 **local(默认)** 3. 执行 xxx-common-config 的 **pullConfig()** 方法, 用于拉取配置 4. 完成后会在 resource 目录下生成 application.yml 文件, 用于编译时替换占位符 (**这里是考虑使用 @Bean 注入 jdbc, redis, kafka, mongodb 等对象, 但是 dubbo 和 logback 注入有问题, 以后会优化这里**) ```lua env: local ``` # zookeeper 配置 zookeeper: connect: list: 192.168.2.8:2181 # dubbo 配置 dubbo: service: version: 5.2.8 ... ```` 其他配置这会根据 maven profile , 启动 app 时动态注入. 本地开发时, 以本地配置优先, 没有配置的则读取 zk 配置. 如果从 local 切换到 test 环境, 需要再次运行 **pullConfig() **, 只有以下配置 **不会** 被替换 ```lua # zookeeper 配置 zookeeper: connect: list: 192.168.2.8:2181 # dubbo 配置 dubbo: service: version: 5.2.8 # 日志配置 logback: log: level: DEBUG root: level: ERROR appender: one: STDOUT two: logstash: ip: 192.168.2.121:8801 path: /Users/codeai/Develop/logs/xxx/mnt/syslogs/tomcat # 上传配置 uploadPath: /data/devuploads/ ```` 这样可以避免切换环境后还要手动修改配置 ### 开发时切换配置 可能有这样的需求, 我们开始在 local 环境开发, test 环境有出现 bug, 为了复现 bug, 我们可能会切换到 test 环境. 这个的解决方案: 我们不需要重启应用, 直接在 Web 端切换这个开发者的配置即可. 原理: 切换功能只会将 test 的配置替换到当前开发者节点下的 local 配置, 然后自动调用 zk 监听机制, 动态修改配置即可. ### Dev Test Prod 环境部署解决方案 当需要部署到非 local 环境的服务器时, 需要执行 xxx-common-config 中的 **pullDeployConfig()** 方法 **pullConfig()** 与 **pullDeployConfig()** 的区别: 因开发需要切换环境时, 调用 **pullConfig()** 后不会覆盖 application.yml 中自定义配置. 因为部署时, 并不需要本地自定义配置, 因此 **pullDeployConfig()** 会将原来的 application.yml 重命名为 application.yml.local( 不会被打包到 war 中), 然后拉取配置生成 application.yml ### 第三方公司配置解决方案 第三方公司配置由本公司统一管理, 只需要在 configs 节点下增加一个子节点, 比如 chengtong, 然后再添加配置节点 打包时, 切换到 chengtong 分支, maven 选择 chengtong profile, 如果需要个性化配置, 在本地新建 application.yml 配置即可, 默认会以本地优先 ( 只针对第三方公司) 如果是 xxx 公司, 以 dev,test,prod 环境打包时, 则会忽略本地配置文件. 原因是开发时拉取配置, 个性化配置后 (修改 dubbo service version, 修改日志输出等), 不小心提交了本地配置, 打包时不影响, 依然是以 zk 配置为主. ## Todo list 1. 迁移项目中的配置文件及类到 `xxx-common-config` 中 1. 使用 @Value("${...}") 替换 xml 的 bean 配置 2. 整理 xxx-common-config 中的配置映射到 zk 的节点 3. 实现一键创建 zk 节点 1. shell 命令或者 Go 实现 4. 从数据源(zk、mysql、redis)读取配置信息写入 properties 1. 实现 pullConfig() 方法 1. 合并本地配置, 切换环境时, 哪些配置不需要覆盖 2. 实现 pullDeployConfig() 方法 1. 文件重命名 2. 重新生成 properties .yml 5. mvn 打包前,读取配置替换 xml 中的占位符(dubbo、logstash) 1. 删除多余环境配置, 只保留 application.yml 2. 修改 maven 的 profiles 3. 修改 maven 的 build filter 6. 从 Java 对象中映射到 Env 环境配置类中 1. 启动时读取 zk(mysql, redis) 配置 (不包括 jdbc, redis, logback..) 2. 在实例化 bean 之前, 将配置动态注入到 Spring Environment 对象中 3. 启动完成后添加 listener, 监听配置变化 4. 实现 callback, 动态修改配置 ## [Redis集成攻略:从Jedis到ShardedJedisPool](https://blog.dong4j.site/posts/4fa65dbc.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Redis 的几种集成方式 缓存服务组件 依赖于: 1. jedis 2. spring-data-redis 3. spring-session-data-redis redis 集群使用的是 ShardedJedisPool, redis 3.x 后自带集群负载 ## jar 中重要的类 1. JedisConnectionFactory 用于获取 jedis 实例, 从而操作 redis 2. ShardedJedisPool 用于连接 redis 集群 ## cache 重要的类 1. RedisDataSource 使用 JedisConnectionFactory 从 ShardedJedisPool 连接池中获取 jedis 2. RedisClientTemplate 依赖 RedisDataSource 操作 redis 的具体模板方法 3. RedisCacheServiceImpl 对 RedisClientTemplate 再次封装 ## JedisPool(非切片链接池) 和 ShardedJedisPool(切片链接池) 有什么区别 JedisPool 连一台 Redis, ShardedJedisPool 连 Redis 集群, 通过一致性哈希算法决定把数据存到哪台上, 算是一种客户端负载均衡, 所以添加是用这个(Redis 3.0 之后支持服务端负载均衡) 删除那个问题的答案就显而易见了, 总不可能随机找一个 Redis 服务端去删吧 ## 集群 session 共享机制 现在集群中使用的 Session 共享机制有两种, 分别是 session 复制和 session 粘性. **Session 复制** 该种方式下, 负载均衡器会根据各个 node 的状态, 把每个 request 进行分发, 使用这样的测试, 必须在多个 node 之间复制用户的 session, 实时保持整个集群中用户的状态同步. 其中 jboss 的实现原理是使用拦截器, 根据用户的同步策略拦截 request, 做完同步处理后再交给 server 产生响应. 优点: session 不会被绑定到具体的 node, 只要有一个 node 存活, 用户状态就不会丢失, 集群能够正常工作. 缺点: node 之间通信频繁, 响应速度有影响, 高并发情况下性能下降比较厉害. **Session 粘性** 该种方式下, 当用户发出第一个 request 后, 负载均衡器动态的把该用户分配至到某个节点, 并记录该节点的 jvm 路由, 以后该用户的所有的 request 都会绑定到这个 jvm 路由, 用户只会和该 server 交互. 优点: 响应速度快, 多个节点之间无需通信 缺点: 某个 node 死掉之后, 它负责的所有用户都会丢失 session. 改进: servlet 容器在新建、更新或维护 session 时, 向其它 no de 推送修改信息. 这种方式在高并发情况下同样会影响效率. 以上这两种方式都需要负载均衡器和 Servlet 容器的支持, 在部署时需要单独配置负载均衡器和 Servelt 容器. **基于分布式缓存的 session 共享机制** 将会话 Session 统一存储在分布式缓存中, 并使用 Cookie 保持客户端和服务端的联系, 每一次会话开始就生成一个 GUID 作为 SessionID, 保存在客户端的 Cookie 中, 在服务端通过 SessionID 向分布式缓存中获取 session. 实现思路: 通过一个 Filter 过滤所有的 request 请求, 在 Filter 创建 request 和 session 的代理, 通过代理使用分布式缓存对 session 进行操作. 这样实现对现有应用中对 request 对象的操作透明 扩展指定 server 利用 Servlet 容器提供的插件功能, 自定义 HttpSession 的创建和管理策略, 并通过配置的方式替换掉默认的策略. 不过这种方式有个缺点, 就是需要耦合 Tomcat/Jetty 等 Servlet 容器的代码. 这方面其实早就有开源项目了, 例如 memcached-session-manager , 以及 tomcat-redis-session-manager . 暂时都只支持 Tomcat6/Tomcat7. 设计一个 Filter 利用 HttpServletRequestWrapper, 实现自己的 getSession() 方法, 接管创建和管理 Session 数据的工作. spring-session 就是通过这样的思路实现的. **spring-session** SpringSession 的几个关键类 1. SessionRepositoryFilter(order 是 Integer.MIN_VALUE + 50) 2. SessionRepositoryRequestWrapper 与 SessionRepositoryResponseWrapper, 通过 SessionRepository 去操纵 session 3. SessionRepository 4. CookieHttpSessionStrategy ## redis 过期数据的删除方式 1. 定时删除: 在设置键的过期时间的同时, 创建一个定时器 (timer), 让定时器在键的过期时间来临时, 立即执行对键的删除操作. 2. 惰性删除: 放任键过期不管, 但是每次从键空间中获取键时, 都检查取得的键是否过期, 如果过期的话, 就删除该键;如果没有过期, 就返回该键. 3. 定期删除: 每隔一段时间, 程序就对数据库进行一次检查, 删除里面的过期键. 至于要删除多少过期键, 以及要检查多少个数据库, 则由算法决定. redis 实际是以懒性删除 + 定期删除这种策略组合来实现过期键删除的, 导致 Spring 需要采用及时删除的策略(定时轮询), 在过期的时候, 访问一下该 key, 然后及时触发惰性删除 Spring 的轮询如何保证时效性 ```java @Scheduled(cron = "0 * * * * *") // 每分钟跑一次, 每次清除前一分钟的过期键 public void cleanExpiredSessions() { long now = system.currentTimeMillis(); long prevMin = roundDownMinute(now); if (logger.isDebugEnabled()) { logger.debug("Cleaning up sessions expiring at " + new Date(prevMin)); } String expirationKey = getExpirationKey(prevMin); Set < String > sessionsToExpire = expirationRedisOperations.boundSetOps(expirationKey).members(); expirationRedisOperations.delete(expirationKey); for (String session: sessionsToExpire) { String sessionKey = getSessionKey(session); touch(sessionKey); } } ``` 这里的 touch 操作就是访问该 key, 然后触发 redis 删除. ```java /** * By trying to access the session we only trigger a deletion if it the TTL is expired. This is done to handle * https://github.com/spring-projects/spring-session/issues/93 * * @param key */ private void touch(String key) { sessionRedisOperations.hasKey(key); } ``` 主动删除 session ```java public void onDelete(ExpiringSession session) { long toExpire = roundUpToNextMinute(expiresInMillis(session)); String expireKey = getExpirationKey(toExpire); expirationRedisOperations.boundSetOps(expireKey).remove(session.getId()); } ``` 延长 session 过期时间 ```java public void onExpirationupdated(Long originalExpirationTimeInMilli, ExpiringSession session) { if (originalExpirationTimeInMilli != null) { long originalRoundedUp = roundUpToNextMinute(originalExpirationTimeInMilli); String expireKey = getExpirationKey(originalRoundedUp); expirationRedisOperations.boundSetOps(expireKey).remove(session.getId()); } long toExpire = roundUpToNextMinute(expiresInMillis(session)); String expireKey = getExpirationKey(toExpire); BoundSetOperations < String, String > expireOperations = expirationRedisOperations.boundSetOps(expireKey); expireOperations.add(session.getId()); long sessionExpireInSeconds = session.getMaxInactiveIntervalInSeconds(); String sessionKey = getSessionKey(session.getId()); expireOperations.expire(sessionExpireInSeconds + 60, TimeUnit.SECONDS); sessionRedisOperations.boundHashOps(sessionKey).expire(sessionExpireInSeconds, TimeUnit.SECONDS); } ``` ## 替换序列化方式 使用 GenericJackson2JsonRedisSerializer 替换 JdkSerializationRedisSerializer 使得存入 redis 的数据显示更友好 存在的问题 数据迁移 原来存在于 redis 中的数据 不能使用 GenericJackson2JsonRedisSerializer 反序列化 ## jedisPool 与 RedisTemplate 的区别 jedisPool 是直接通过获取 jedis 来操作 redis 而 RedisTemplate 是通过 spring 由 IOC 来配置依赖关系 ## Spring 提供的 redis 序列化方式的区别 1. JdkSerializationRedisSerializer 2. GenericJackson2JsonRedisSerializer ## [RESTEasy入门:JAX-RS实践指南](https://blog.dong4j.site/posts/9659e30c.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) **RESTEasy** RESTEasy 是 JBoss 的一个开源项目,提供各种框架帮助你构建 RESTful Web Services 和 RESTful Java 应用程序。它是 JAX-RS 规范的一个完整实现并通过 JCP 认证。作为一个 JBOSS 的项目,它当然能和 JBOSS 应用服务器很好地集成在一起。但是,它也能在任何运行 JDK5 或以上版本的 Servlet 容器中运行。RESTEasy 还提供一个 RESTEasy JAX-RS 客户端调用框架。能够很方便与 EJB、Seam、Guice、Spring 和 Spring MVC 集成使用。支持在客户端与服务器端自动实现 GZIP 解压缩。 RESTEasy  项目是  JAX-RS  的一个实现,集成的一些亮点: - 不需要配置文件,只要把 JARs 文件放到类路径里面,添加  @Path  标注就可以了。 - 完全的把 RESTEeasy 配置作为 Seam 组件来看待。 - HTTP 请求由 Seam 来提供,不需要一个额外的 Servlet。 - Resources 和 providers 可以作为 Seam components (JavaBean or EJB),具有全面的 Seam injection,lifecycle, interception, 等功能支持。 - 支持在客户端与服务器端自动实现 GZIP 解压缩。 **JAX-RS:** Java API for RESTful Web Services 是一个 Java 编程语言的应用程序接口, 支持按照 表象化状态转变 (REST) 架构风格创建 Web 服务 Web 服务 [1]. JAX-RS 使用了 Java SE 5 引入的 Java 标注来简化 Web 服务客户端和服务端的开发和部署。 JAX-RS 提供了一些标注将一个资源类,一个 POJOJava 类,封装为 Web 资源。标注包括: 1. @Path,标注资源类或方法的相对路径 2. @GET,@PUT,@POST,@DELETE,标注方法是用的 HTTP 请求的类型 3. @Produces,标注返回的 MIME 媒体类型 4. @Consumes,标注可接受请求的 MIME 媒体类型 5. @PathParam,@QueryParam,@HeaderParam,@CookieParam,@MatrixParam,@FormParam, 分别标注方法的参数来自于 HTTP 请求的不同位置,例如 @PathParam 来自于 URL 的路径,@QueryParam 来自于 URL 的查询参数,@HeaderParam 来自于 HTTP 请求的头信息,@CookieParam 来自于 HTTP 请求的 Cookie。 ## 配置 web.xml 配置 ```xml Archetype Created Web Application Resteasy org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher javax.ws.rs.Application com.restfully.shop.services.ShoppingApplication Resteasy /* ``` 使用 ServletContextListener 来配置 ```xml org.jboss.resteasy.plugins.server.servlet.ResteasyBootstrap Resteasy org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher Resteasy /resteasy/* ``` 使用 ServletFilter 来配置 ```xml Resteasy org.jboss.resteasy.plugins.server.servlet.FilterDispatcher javax.ws.rs.Application com.restfully.shop.services.ShoppingApplication Resteasy /* ``` 与 Spring 集成 ```xml Archetype Created Web Application org.jboss.resteasy.plugins.server.servlet.ResteasyBootstrap org.jboss.resteasy.plugins.spring.SpringContextLoaderListener Resteasy org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher Resteasy /* ``` ## 使用 @Path,@Get,@Post 等标注 ```java @Path("/library") public class Library { @GET @Path("/books") public String getBooks() {...} @GET @Path("/book/{isbn}") public String getBook(@PathParam("isbn") String id) { // search my database and get a string representation and return it } @PUT @Path("/book/{isbn}") public void addBook(@PathParam("isbn") String id, @QueryParam("name") String name) {...} @DELETE @Path("/book/{id}") public void removeBook(@PathParam("id") String id {...} } ``` 以下操作都是针对 library 这个资源的 1. GET http://myhost.com/services/library/books 意思为获得所有的 books 调用的方法为 getBooks 2. GET http://myhost.com/services/library/book/333 意思为获得 ID 为 333 的 book 调用的方法为 getBook 3. PUT http://myhost.com/services/library/book/333 新增一个 ID 为 333 的 book 调用的方法为 addBook 4. DELETE http://myhost.com/services/library/book/333 删除一个 ID 为 333 的 book 调用的方法为 removeBook **@Path ** 这个标注可以在类上也可以在方法上, 如果**类和方法上都有的话, 那么方法上的路径是级联的**如上面例子的 / library/book/ @Path 和使用正则表达式匹配路径 @Path 不仅仅接收简单的路径表达式, 也可以使用正则表达式: ```java @Path("/resources) public class MyResource { @GET @Path("{var:.*}/stuff") public String get() {...} } ``` 如下操作就能获得该资源: GET /resources/stuff GET /resources/foo/stuff GET /resources/on/and/on/stuff 表达式的格式为: `"{" variable-name [ ":" regular-expression] "}"` 正则表达式部分是可选的, 当未提供时, 则会匹配一个默认的表达式 "(`[]*`)" @Path("/resources/{var}/stuff") 会匹配如下路径: GET /resources/foo/stuff GET /resources/bar/stuff 下面的则不会匹配 GET /resources/a/bunch/of/stuff **@QueryParam** @QueryParam 这个标注是给通过? 的方式传参获得参数值的, 如: `GET /books?num=5&index=1` ```java @GET public String getBooks(@QueryParam("num") int num,@QueryParam("index") int index) { ... } ``` 这里同上面的 @PathParam, 参数类型可以是任意类型 **@HeaderParam** 这个标注时用来获得保存在 HttpRequest 头里面的参数信息的, 如: ```java @PUT public void put(@HeaderParam("Content-Type") MediaType contentType, ...) ``` 这里同上面的 @PathParam, 参数类型可以是任意类型 **@CookieParam** 用来获取保存在 Cookie 里面的参数, 如: ```java @GET public String getBooks(@CookieParam("sessionid") int id) { ... } ``` **@FormParam** 用来获取 Form 中的参数值, 如: 页面代码: ```html
First name:
Middle name: Last name:
``` 后台代码 ```java @Path("/") public class NameRegistry { @Path("/resources/service") @POST public void addName(@FormParam("firstname") String first, @FormParam("lastname") String last) {...} } ``` 标注了 @FormParam, 会把表达里面的值自动映射到方法的参数上去. 如果要取得 Form 里面的所有属性, 可以通过在方法上增加一个 MultivaluedMap form 这样的对象来获得, 如下: ```java @Path("/resources/service") @POST public void addName(@FormParam("firstname") String first, @FormParam("lastname") String last,MultivaluedMap form) {...} ``` **@Form** 上面已经说的几种标注都是一个属性对应一个参数的, 那么如果属性多了, 定义的方法就会变得不好阅读, 此时最好有个东西能够把上面的几种自动标注自动封装成一个对象,@Form 这个标注就是用来实现这个功能的, 如: ```java public class MyForm { @FormParam("stuff") private int stuff; @HeaderParam("myHeader") private String header; @PathParam("foo") public void setFoo(String foo) {...} } ``` **@POST** ```java @Path("/myservice") public void post(@Form MyForm form) {...} ``` **@DefaultValue** 在以上标注使用的时候, 有些参数值在没有值的情况下如果需要有默认值, 则使用这个标注, 如: ```java @GET public String getBooks(@QueryParam("num") @DefaultValue("10") int num) {...} ``` ## 满足 JAX-RS 规范的 Resource Locators 和子资源 资源处理类定义的某个方法可以处理某个请求的一部分, 剩余部分由子资源处理类来处理, 如: ```java @Path("/") public class ShoppingStore { @Path("/customers/{id}") public Customer getCustomer(@PathParam("id") int id) { Customer cust = ...; // Find a customer object return cust; } } public class Customer { @GET public String get() {...} @Path("/address") public String getAddress() {...} } ``` 当我们发起 GET /customer/123 这样的请求的时候, 程序会先调用 ShoppingStore 的 getCustomer 这个方法, 然后接着调用 Customer 里面的 get 方法 当我们发起 GET /customer/123/address 这样的请求的时候, 程序会先调用 ShoppingStore 的 getCustomer 这个方法, 然后接着调用 Customer 里面的 getAddress 方法 ## JAX-RS Content Negotiation **@Consumes** 我们从页面提交数据到后台的时候, 数据的类型可以是 text 的, xml 的, json 的, 但是我们在请求资源的时候想要请求到同一个资源路径上面去, 此时怎么来区分处理呢? 使用 @Consumes 标注, 下面的例子将说明: ```java @Consumes("text/*") @Path("/library") public class Library { @POST public String stringBook(String book) {...} @Consumes("text/xml") @POST public String jaxbBook(Book book) {...} } ``` 当客户端发起请求的时候, 系统会先找到所有匹配路径的方法, 然后根据 content-type 找到具体的处理方法, 比如: ``` POST /library content-type: text/plain ``` 就会执行上面的 **stringBook** 这个方法, 因为这个方法上面没有标注 @ Consumes, 程序找了所有的方法没有找到标注 @ Consumes(“text/plain”) 这个类型的, 所以就执行这个方法了. 如果请求的 content-type=xml, 比如: ``` POST /library content-type: text/xml ``` 此时就会执行 jaxbBook 这个方法 **@Produces** 当服务器端实行完成相关的逻辑需要返回对象的时候, 程序会根据 @Produces 返回相应的对象类型 ```java @Produces("text/*") @Path("/library") public class Library { @GET @Produces("application/json") public String getJSON() {...} @GET public String get() {...} } ``` 如果客户端发起如下请求 ``` GET /library ``` 那么则会调用到 get 方法并且返回的格式是 json 类型的 这些标注能不能写多个呢? 答案是可以的, 但是系统只认第一个 ## [Spring生态下的Logback:实现结构化日志的秘诀](https://blog.dong4j.site/posts/b6d58107.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) # [logback 使用,自定义输出格式](http://www.ulewo.com/user/10001/blog/550) 说到 log4j,基本人人都知道,但是 logback,估计用的人不多,其实这两个都是 sl4j 的实现,而且是一个作者写的。 logback 比 log4j 更加好用,而且效率更高。 如何配置 logback。 ```xml ch.qos.logback logback-classic 1.1.3 ``` 配置文件:logback.xml ```xml %d{yyyy-MM-dd HH:mm:ss} [%p][%c][%M][%L]-> %m%n ${LOG_HOME}/log.%d{yyyy-MM-dd}(%i).log true 10MB utf-8 %d{yyyy-MM-dd HH:mm:ss} [%p][%c][%M][%L]-> %m%n false false ``` 将这个文件放到资源目录根目录下,服务器启动时,logback 会根据 logback 这个名称自己去匹配加载 这里如果要输出项目中的 SQL 很简单,只需要将日志级别改成 debug 就可以了(mybatis 是这样的,其他的没试过) 今天主要说的是日志格式 `%d{yyyy-MM-dd HH:mm:ss} [%p][%c][%M][%L]-> %m%n    ` 这里就是配置格式的,以下是各个参数的说明 | 参数 | 说明 | | :--: | :----------------------------------------------------------------------------------------------------------------------------------------------------- | | %m | 输出代码中指定的消息 | | %p | 输出优先级,即 DEBUG,INFO,WARN,ERROR,FATAL | | %r | 输出自应用启动到输出该 log 信息耗费的毫秒数 | | %c | 输出所属的类目,通常就是所在类的全名 | | %t | 输出产生该日志事件的线程名 | | %n | 输出一个回车换行符,Windows 平台为 `\r\n`,Unix 平台为 `\n` | | %d | 输出日志时间点的日期或时间,默认格式为 ISO8601,也可以在其后指定格式,比如:%d{yyy MMM dd HH:mm:ss,SSS},输出类似:2002 年 10 月 18 日 22:10:28,921 | | %l | 输出日志事件的发生位置,包括类目名、发生的线程,以及在代码中的行数。举例:Testlog4.main(TestLog4.java:10) | 这个时候,日志就会输出 时间,日志优先级,类名,方法名,行数,日志内容 基本就是这样的了 ``` 2016-09-02 14:45:53 [DEBUG][com.ulewo.mapper.SignInMapper.selectCount][debug][132]-> ==> Preparing: select count(1) from ulewo_sign_in WHERE sign_date = DATE_FORMAT(?,'%Y-%m-%d') 2016-09-02 14:45:53 [DEBUG][com.ulewo.mapper.SignInMapper.selectCount][debug][132]-> ==> Parameters: 2016-09-02 14:45:53.098(Timestamp) 2016-09-02 14:45:53 [DEBUG][com.ulewo.mapper.SignInMapper.selectCount][debug][132]-> <== Total: 1 ``` 这里输出了 SQL。 为什么要讲输出格式,本来这样输出挺好的呀,是的,现在在我们的生产环境中,总共有 16 台服务器,有时候查问题,要一台台的查,因为不知道请求到底是大到那个服务器上,这样查问题非常的痛苦,于是项目引入了 graylog 一个日志收集工具,可以将各个服务器的日志收集到一起,这样查问题就方便多了。但是 graylog 要求日志必须是 json 格式的,那么按照我上面格式就无法使用了,所以要修改日志输出格式。 查了一番资料发现,只要重写 ClassicConverter 和 PatternLayout 这两个类就可以了 **重新 Converter 类** ```java public class NetbarLogerConvert extends ClassicConverter { long lastTimestamp = -1; String timestampStrCache = null; SimpleDateFormat simpleFormat = null; String businessName = null; static String hostName; static String localIp; static { InetAddress ia = null; try { ia = ia.getLocalHost(); hostName = ia.getHostName(); localIp = ia.getHostAddress(); } catch (Exception e) { // TODO Auto-generated catch block e.printStackTrace(); } } @Override public String convert(ILoggingEvent le) { LogObject log = new LogObject(); log.setBusiness(businessName); log.setIp(localIp); log.setHostName(hostName); log.setTime(getTime(le)); log.setLeave(le.getLevel().toString()); log.setClassName(getFullyQualifiedName(le)); log.setMethodName(getMethodName(le)); log.setLine(getLineNumber(le)); log.setMessage(le.getFormattedMessage()); return JacksonUtil.writJson(log); } public void start() { businessName = getFirstOption(); businessName = businessName == null ? "未设置产品线" : businessName; String datePattern = DateStyle.YYYY_MM_DD_HH_MM_SS.getValue(); try { simpleFormat = new SimpleDateFormat(datePattern); // maximumCacheValidity = // CachedDateFormat.getMaximumCacheValidity(pattern); } catch (IllegalArgumentException e) { addWarn("Could not instantiate SimpleDateFormat with pattern " + datePattern, e); // default to the ISO8601 format simpleFormat = new SimpleDateFormat(CoreConstants.ISO8601_PATTERN); } List optionList = getOptionList(); // if the option list contains a TZ option, then set it. if (optionList != null && optionList.size() > 1) { TimeZone tz = TimeZone.getTimeZone((String) optionList.get(1)); simpleFormat.setTimeZone(tz); } } private String getTime(ILoggingEvent le) { long timestamp = le.getTimeStamp(); synchronized (this) { // if called multiple times within the same millisecond // return cache value if (timestamp == lastTimestamp) { return timestampStrCache; } else { lastTimestamp = timestamp; // SimpleDateFormat is not thread safe. // See also http://jira.qos.ch/browse/LBCLASSIC-36 timestampStrCache = simpleFormat.format(new Date(timestamp)); return timestampStrCache; } } } private String getFullyQualifiedName(ILoggingEvent le) { StackTraceElement[] cda = le.getCallerData(); if (cda != null && cda.length > 0) { return cda[0].getClassName(); } else { return CallerData.NA; } } private String getLineNumber(ILoggingEvent le) { StackTraceElement[] cda = le.getCallerData(); if (cda != null && cda.length > 0) { return Integer.toString(cda[0].getLineNumber()); } else { return CallerData.NA; } } private String getMethodName(ILoggingEvent le) { StackTraceElement[] cda = le.getCallerData(); if (cda != null && cda.length > 0) { return cda[0].getMethodName(); } else { return CallerData.NA; } } public class LogObject { /** * 产品线 */ private String business; /** * 主机名 */ private String hostName; /** * IP */ private String ip; /** * 时间 */ private String time; /** * 日志级别 */ private String leave; /** * 类名 */ private String className; /** * 方法名 */ private String methodName; /** * 行数 */ private String line; /** * 日志内容 */ private String message; public String getTime() { return time; } public void setTime(String time) { this.time = time; } public String getLeave() { return leave; } public void setLeave(String leave) { this.leave = leave; } public String getClassName() { return className; } public void setClassName(String className) { this.className = className; } public String getMethodName() { return methodName; } public void setMethodName(String methodName) { this.methodName = methodName; } public String getLine() { return line; } public void setLine(String line) { this.line = line; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public String getBusiness() { return business; } public void setBusiness(String business) { this.business = business; } public String getIp() { return ip; } public void setIp(String ip) { this.ip = ip; } public String getHostName() { return hostName; } public void setHostName(String hostName) { this.hostName = hostName; } } } ``` **重写 layout 类** ```java public class NetbarLoggerPatternLayout extends PatternLayout { static { defaultConverterMap.put("netbarLoggerPattern", NetbarLogerConvert.class.getName()); } } ``` 这里如何获取 方法名,行数,甚至还有其他的一些信息可以参考 logback 这个类: ```java public class PatternLayout extends PatternLayoutBase { public static final Map defaultConverterMap = new HashMap(); static { defaultConverterMap.put("d", DateConverter.class.getName()); defaultConverterMap.put("date", DateConverter.class.getName()); defaultConverterMap.put("r", RelativeTimeConverter.class.getName()); defaultConverterMap.put("relative", RelativeTimeConverter.class.getName()); defaultConverterMap.put("level", LevelConverter.class.getName()); defaultConverterMap.put("le", LevelConverter.class.getName()); defaultConverterMap.put("p", LevelConverter.class.getName()); defaultConverterMap.put("t", ThreadConverter.class.getName()); defaultConverterMap.put("thread", ThreadConverter.class.getName()); defaultConverterMap.put("lo", LoggerConverter.class.getName()); defaultConverterMap.put("logger", LoggerConverter.class.getName()); defaultConverterMap.put("c", LoggerConverter.class.getName()); defaultConverterMap.put("m", MessageConverter.class.getName()); defaultConverterMap.put("msg", MessageConverter.class.getName()); defaultConverterMap.put("message", MessageConverter.class.getName()); defaultConverterMap.put("C", ClassOfCallerConverter.class.getName()); defaultConverterMap.put("class", ClassOfCallerConverter.class.getName()); defaultConverterMap.put("M", MethodOfCallerConverter.class.getName()); defaultConverterMap.put("method", MethodOfCallerConverter.class.getName()); defaultConverterMap.put("L", LineOfCallerConverter.class.getName()); defaultConverterMap.put("line", LineOfCallerConverter.class.getName()); defaultConverterMap.put("F", FileOfCallerConverter.class.getName()); defaultConverterMap.put("file", FileOfCallerConverter.class.getName()); defaultConverterMap.put("X", MDCConverter.class.getName()); defaultConverterMap.put("mdc", MDCConverter.class.getName()); defaultConverterMap.put("ex", ThrowableProxyConverter.class.getName()); defaultConverterMap.put("exception", ThrowableProxyConverter.class .getName()); defaultConverterMap.put("throwable", ThrowableProxyConverter.class .getName()); defaultConverterMap.put("xEx", ExtendedThrowableProxyConverter.class.getName()); defaultConverterMap.put("xException", ExtendedThrowableProxyConverter.class .getName()); defaultConverterMap.put("xThrowable", ExtendedThrowableProxyConverter.class .getName()); defaultConverterMap.put("nopex", NopThrowableInformationConverter.class .getName()); defaultConverterMap.put("nopexception", NopThrowableInformationConverter.class.getName()); defaultConverterMap.put("cn", ContextNameAction.class.getName()); defaultConverterMap.put("contextName", ContextNameConverter.class.getName()); defaultConverterMap.put("caller", CallerDataConverter.class.getName()); defaultConverterMap.put("marker", MarkerConverter.class.getName()); defaultConverterMap.put("property", PropertyConverter.class.getName()); defaultConverterMap.put("n", LineSeparatorConverter.class.getName()); } public PatternLayout() { this.postCompileProcessor = new EnsureExceptionHandling(); } public Map getDefaultConverterMap() { return defaultConverterMap; } public String doLayout(ILoggingEvent event) { if (!isStarted()) { return CoreConstants.EMPTY_STRING; } return writeLoopOnConverters(event); } } ``` 这里有各个参数 convert 的实现,直接拷贝过来就可以了。 然后 logback 这里的配置修改下 ``` appender name="stdot" class="ch.qos.logback.core.ConsoleAppender">         layout class="com.stnts.netbar.logger.NetbarLoggerPatternLayout"> pattern>%netbarLoggerPattern{XXX系统} %npattern>         layout> appender> ``` 上面 layout 的 class 指定为你重写的 class,pattern 中用你自己定义的 pattern 名后面大括号是定义产品线的 这个时候日志就是这样输出的: ``` {"message":"[微信公众帐号][定时刷新 AccessToken 的定时器] redis 中获取的 accessToken 的过期时间: 7200 秒","methodName":"refresh","className":"xxx.xxx.xxx.class","hostName":"pcname","time":"2016-09-02 14:40:00","leave":"INFO","line":"50","business":"xxxx 系统","ip":"192.168.32.115"} ``` 就是一个完整的 json 了。 当然你觉得这样的日志格式,你看起来还不舒服,可以自己去定义了。 ## [告别keys *,掌握Redis scan系列命令的精髓](https://blog.dong4j.site/posts/958ae77b.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 1. 介绍 `scan`命令的作用和`keys *`的作用类似,主要用于查找 redis 中的键,但是在正式的生产环境中一般不会直接使用`keys *` 这个命令,因为他会返回所有的键,如果键的数量很多会导致查询时间很长,进而导致服务器阻塞,所以需要 scan 来进行更细致的查找 `scan`总共有这几种命令:`scan`、`sscan`、`hscan`、`zscan`,分别用于迭代数据库中的:数据库中所有键、集合键、哈希键、有序集合键,命令具体结构如下: ```bash scan cursor [MATCH pattern] [COUNT count] [TYPE type] sscan key cursor [MATCH pattern] [COUNT count] hscan key cursor [MATCH pattern] [COUNT count] zscan key cursor [MATCH pattern] [COUNT count] ``` ## 2. scan `scan cursor [MATCH pattern] [COUNT count] [TYPE type]`,cursor 表示游标,指查询开始的位置,count 默认为 10,查询完后会返回下一个开始的游标,当返回 0 的时候表示所有键查询完了 ```bash 127.0.0.1:6379[2]> scan 0 1) "3" 2) 1) "mystring" 2) "myzadd" 3) "myhset" 4) "mylist" 5) "myset2" 6) "myset1" 7) "mystring1" 8) "mystring3" 9) "mystring4" 10) "myset" 127.0.0.1:6379[2]> scan 3 1) "0" 2) 1) "myzadd1" 2) "mystring2" 3) "mylist2" 4) "myhset1" 5) "mylist1" ``` MATCH 可以采用模糊匹配找出自己想要查找的键,这里的逻辑是先查出 20 个,再匹配,而不是先匹配再查询,这里加上 count 20 是因为默认查出的 10 个数中可能不能包含所有的相关项,所以把范围扩大到查 20 个,我这里测试的键总共有 15 个 ```bash 127.0.0.1:6379[2]> scan 0 match mylist* count 20 1) "0" 2) 1) "mylist" 2) "mylist2" 3) "mylist1" ``` TYPE 可以根据具体的结构类型来匹配该类型的键 ```bash 127.0.0.1:6379[2]> scan 0 count 20 type list 1) "0" 2) 1) "mylist" 2) "mylist2" 3) "mylist1" ``` ## 3. sscan `sscan key cursor [MATCH pattern] [COUNT count]`,sscan 的第一个参数总是集合类型的 key ```bash 127.0.0.1:6379[2]> sadd myset1 a b c d (integer) 4 127.0.0.1:6379[2]> smembers myset1 1) "d" 2) "a" 3) "c" 4) "b" 127.0.0.1:6379[2]> sscan myset1 0 1) "0" 2) 1) "d" 2) "c" 3) "b" 4) "a" 127.0.0.1:6379[2]> sscan myset1 0 match a 1) "0" 2) 1) "a" ``` ## 4. hscan `hscan key cursor [MATCH pattern] [COUNT count]`,sscan 的第一个参数总是哈希类型的 key ```bash 127.0.0.1:6379[2]> hset myhset1 kk1 vv1 kk2 vv2 kk3 vv3 (integer) 3 127.0.0.1:6379[2]> hgetall myhset1 1) "kk1" 2) "vv1" 3) "kk2" 4) "vv2" 5) "kk3" 6) "vv3" 127.0.0.1:6379[2]> hscan myhset1 0 1) "0" 2) 1) "kk1" 2) "vv1" 3) "kk2" 4) "vv2" 5) "kk3" 6) "vv3" ``` ## 5. zscan `zscan key cursor [MATCH pattern] [COUNT count]`,sscan 的第一个参数总是有序集合类型的 key ```bash 127.0.0.1:6379[2]> zadd myzadd1 1 zz1 2 zz2 3 zz3 (integer) 3 127.0.0.1:6379[2]> zrange myzadd1 0 -1 withscores 1) "zz1" 2) "1" 3) "zz2" 4) "2" 5) "zz3" 6) "3" 127.0.0.1:6379[2]> zscan myzadd1 0 1) "0" 2) 1) "zz1" 2) "1" 3) "zz2" 4) "2" 5) "zz3" 6) "3" ``` ## [原型模式揭秘:从Linux命令scp到设计模式的实践应用](https://blog.dong4j.site/posts/91ad9ce3.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) #### 每天一个 Linux 命令 **scp** 加密的方式在本地主机和远程主机之间复制文件 用于在 Linux 下进行远程拷贝文件的命令, 和它类似的命令有 cp, 不过 cp 只是在本机进行拷贝不能跨服务器, 而且 scp 传输是加密的. 可能会稍微影响一下速度. 当你服务器硬盘变为只读 read only system 时, 用 scp 可以帮你把文件移出来. 另外, scp 还非常不占资源, 不会提高多少系统负荷, 在这一点上, rsync 就远远不及它了. 虽然 rsync 比 scp 会快一点, 但当小文件众多的情况下, rsync 会导致硬盘 I/O 非常高, 而 scp 基本不影响系统正常使用. ``` -1: 使用 ssh 协议版本 1; -2: 使用 ssh 协议版本 2; -4: 使用 ipv4; -6: 使用 ipv6; -B: 以批处理模式运行; -C: 使用压缩; -F: 指定 ssh 配置文件; -l: 指定宽带限制; -o: 指定使用的 ssh 选项; -P: 指定远程主机的端口号; -p: 保留文件的最后修改时间, 最后访问时间和权限模式; -q: 不显示复制进度; -r: 以递归方式复制. ``` **从远程复制文件到本地目录** ```bash scp root@10.10.10.10:/opt/soft/nginx-0.5.38.tar.gz /opt/soft/ ``` **上传本地目录到远程机器指定目录** ```bash scp -r /opt/soft/mongodb root@10.10.10.10:/opt/soft/scptest ``` # 创建者模式之四: 原型模式 使用原型实例指定创建对象的种类, 并且通过拷贝这些原型创建新的对象 将一个原型对象传给那个要发动创建的对象, 这个要发动创建的对象通过请求原型对象拷贝自己来实现创建过程. 由于在软件系统中我们经常会遇到需要创建多个相同或者相似对象的情况, 因此原型模式在真实开发中的使用频率还是非常高的. 原型模式是一种 “另类” 的创建型模式, 创建克隆对象的工厂就是原型类自身, 工厂方法由克隆方法来实现. 需要注意的是通过克隆方法所创建的对象是全新的对象, 它们在内存中拥有新的地址, 通常对克隆所产生的对象进行修改对原型对象不会造成任何影响, 每一个克隆对象都是相互独立的. 通过不同的方式修改可以得到一系列相似但不完全相同的对象. ## UML ![20241229154732_3EW1ousA.webp](https://cdn.dong4j.site/source/image/20241229154732_3EW1ousA.webp) **Prototype (抽象原型类)** 声明克隆方法的接口, 是所有具体原型类的公共父类 **ConcretePrototype (具体原型类)** 实现在抽象原型类中声明的克隆方法, 在克隆方法中返回自己的一个克隆对象 **Client (客户类)** 让一个原型对象克隆自身从而创建一个新的对象, 在客户类中只需要直接实例化或通过工厂方法等方式创建一个原型对象, 再通过调用该对象的克隆方法即可得到多个相同的对象 ### 两种通用方法实现原型模式 **1. 在具体原型类的克隆方法中实例化一个与自身类型相同的对象并将其返回, 并将相关的参数传入新创建的对象中, 保证它们的成员属性相同** ```java class ConcretePrototype implements Prototype{ private String attr; public void setAttr(String attr){ this.attr = attr; } public String getAttr(){ return this.attr; } public Prototype clone(){ Prototype prototype = new ConcretePrototype(); prototype.setAttr(this.attr); return prototype; } } ``` 在 clone 方法中新创建一个实例, 并设置相应的参数再返回, 返回的实例是一个完全不同但是类型一样的对象 如果将 clone() 方法改为: ```java public Prototype clone() { return this; } ``` 这样返回的对象是自身, 根本不是一个全新的对象. **客户端调用方式** ```java Prototype obj1 = new ConcretePrototype(); obj1.setAttr("Sunny"); Prototype obj2 = obj1.clone(); ``` ## 引用 -[http://blog.csdn.net/lovelion/article/details/7424559](http://blog.csdn.net/lovelion/article/details/7424559) ## [Java编程进阶:单例模式与反射挑战](https://blog.dong4j.site/posts/f1601c3e.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) #### 每天一个 Linux 命令 **less 命令** less 命令 的作用与 more 十分相似, 都可以用来浏览文字档案的内容, 不同的是 less 命令允许用户向前或向后浏览文件, 而 more 命令只能向前浏览. 用 less 命令显示文件时, 用 PageUp 键向上翻页, 用 PageDown 键向下翻页. 要退出 less 程序, 应按 Q 键. ``` -e: 文件内容显示完毕后, 自动退出; -f: 强制显示文件; -g: 不加亮显示搜索到的所有关键词, 仅显示当前显示的关键字, 以提高显示速度; -l: 搜索时忽略大小写的差异; -N: 每一行行首显示行号; -s: 将连续多个空行压缩成一行显示; -S: 在单行显示较长的内容, 而不换行显示; -x< 数字 >: 将 TAB 字符显示为指定个数的空格字符. ``` ## 抽象工厂模式练习 Sunny 软件公司欲推出一款新的手机游戏软件, 该软件能够支持 Symbian、Android 和 Windows Mobile 等多个智能手机操作系统平台, 针对不同的手机操作系统, 该游戏软件提供了不同的游戏操作控制 (OperationController) 类和游戏界面控制 (InterfaceController) 类, 并提供相应的工厂类来封装这些类的初始化过程. 软件要求具有较好的扩展性以支持新的操作系统平台, 为了满足上述需求, 试采用抽象工厂模式对其进行设计. ### UML ![20241229154732_s52IuBOz.webp](https://cdn.dong4j.site/source/image/20241229154732_s52IuBOz.webp) ### 代码 抽象工厂 ```java public interface GameFactory { OperationController createOperationController(); InterfaceController createInterfaceController(); } ``` 具体工厂 ```java public class SymbianGameFactory implements GameFactory{ @Override public OperationController createOperationController() { return new SymbianOperationController(); } @Override public InterfaceController createInterfaceController() { return new SymbianInterfaceController(); } } public class AndroidGameFactory implements GameFactory{ @Override public OperationController createOperationController() { return new AndroidOperationController(); } @Override public InterfaceController createInterfaceController() { return new AndroidInterfaceController(); } } public class WMGameFactory implements GameFactory{ @Override public OperationController createOperationController() { return new WMOperationController(); } @Override public InterfaceController createInterfaceController() { return new WMInterfaceController(); } } ``` 抽象产品 ```java public interface OperationController { void play(); } public interface InterfaceController { void show(); } ``` 具体产品 ```java public class SymbianOperationController implements OperationController { @Override public void play() { System.out.println("Symbian 系统操作"); } } public class SymbianInterfaceController implements InterfaceController { @Override public void show() { System.out.println("Symbian 显示"); } } public class AndroidOperationController implements OperationController { @Override public void play() { System.out.println("Android 操作"); } } public class AndroidInterfaceController implements InterfaceController { @Override public void show() { System.out.println("Android 显示"); } } public class WMOperationController implements OperationController { @Override public void play() { System.out.println("WM 操作"); } } public class WMInterfaceController implements InterfaceController { @Override public void show() { System.out.println("WM 显示"); } } ``` 添加配置 ``` gameType=com.dong4j.homework.WMGameFactory ``` 测试类 ```java @Test public void gameTest() throws IllegalAccessException, InstantiationException, ClassNotFoundException { GameFactory gameFactory; OperationController operationController; InterfaceController interfaceController; gameFactory = (GameFactory) ConfigUtil.getType("gameType"); operationController = gameFactory.createOperationController(); interfaceController = gameFactory.createInterfaceController(); operationController.play(); interfaceController.show(); } ``` # 创建者模式之三: 单例模式 确保某一个类只有一个实例, 而且自行实例化并向整个系统提供这个实例, 这个类称为单例类, 它提供全局访问的方法 ## 单例模式的几种实现 ### 饿汉模式 ```java public class EagerSingleton { private static final EagerSingleton instance = new EagerSingleton(); private EagerSingleton(){} public static EagerSingleton getINstance(){ return instance; } } ``` ### 懒汉模式 ```java public class LazySingleton { private static LazySingleton instance = null; private LazySingleton(){} public static LazySingleton getInstance(){ return new LazySingleton(); } } ``` #### 多线程优化 ```java public class SynchronizedLazySingleton { private static SynchronizedLazySingleton instance = null; private SynchronizedLazySingleton(){} public static SynchronizedLazySingleton getInstance(){ if(instance == null){ synchronized (SynchronizedLazySingleton.class){ instance = new SynchronizedLazySingleton(); } } return instance; } } ``` 存在的问题: 当 A, B 2 个线程调用 getInstance() 时, 进入 if 判断, 如果此时为 null, 怎么排队进入创建对象的同步块, 当 A 创建完并返回了一个单例对象时, 线程 B 进入同步块, 再次创建一个新的对象. **双锁机制** ```java public class DoubleSynchronizedLazySingleton { private volatile static DoubleSynchronizedLazySingleton instance = null; private DoubleSynchronizedLazySingleton(){} public static DoubleSynchronizedLazySingleton getInstance(){ if(instance == null){ synchronized (DoubleSynchronizedLazySingleton.class){ if(instance == null){ instance = new DoubleSynchronizedLazySingleton(); } } } return instance; } } ``` instance 必须用 volatile 修饰, volatile 在这里的作用是禁止重排序 ### 静态内部类 单例 ```java public class SynchronizedLazySingleton { private static SynchronizedLazySingleton instance = null; private SynchronizedLazySingleton(){} public static SynchronizedLazySingleton getInstance(){ if(instance == null){ synchronized (SynchronizedLazySingleton.class){ instance = new SynchronizedLazySingleton(); } } return instance; } } ``` 使用 classload 机制来保证初始化 instance 时只有一个线程, 只有在显示调用 getInstance() 时才会创建单例对象 ### 枚举 单例 ```java public enum EnumSingleton { INSTANCE; } ``` 使用 EnumSingleton.INSTANCE 来访问 ### 防止单例模式被 反射, 反序列化, 克隆破坏 #### 反射破坏单例模式 **防止反射实例化对象** 利用反射生成对象 ```java // 使用反射破坏单例模式 Class c = Class.forName(Singleton.class.getName()); Constructor constructor = c.getDeclaredConstructor(); // 能访问私有构造方法 constructor.setAccessible(true); // 利用私有构造方法创建一个新的单例对象, 破坏单例模式 Singleton singleton = (Singleton)ct.newInstance(); ``` **解决方法** 在私有构造中抛出异常 ```java public class LazySingleton { private static LazySingleton instance = null; private LazySingleton() throws Exception { throw new Exception(); } public static LazySingleton getInstance() throws Exception { return new LazySingleton(); } } ``` #### 反序列化破坏单例模式 ```java import java.io.Serializable; /** * 使用双重校验锁方式实现单例 */ public class Singleton implements Serializable{ private volatile static Singleton singleton; private Singleton (){} public static Singleton getSingleton() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } } ``` ```java public class SerializableDemo1 { // 为了便于理解, 忽略关闭流操作及删除文件操作. 真正编码时千万不要忘记 // Exception 直接抛出 public static void main(String[] args) throws IOException, ClassNotFoundException { // Write Obj to file ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("tempFile")); oos.writeObject(Singleton.getSingleton()); // Read Obj from file File file = new File("tempFile"); ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file)); Singleton newInstance = (Singleton) ois.readObject(); // 判断是否是同一个对象 System.out.println(newInstance == Singleton.getSingleton()); } } ``` **防止序列化反序列化破坏单例的方法:** 添加 **readResolve** 方法 ```java private Object readResolve() { return singleton; } ``` #### 克隆破坏单例模式 由克隆我们可以想到原型模式, 原型模式就是通过 clone 方法实现对象的创建的, clone 方式是 Object 方法, 每个对象都有, 那我使用一个单例模式类的对象, 调用 clone 方法, 再创建一个新的对象了, 那岂不是上面说的单例模式失效了. 当然答案是否定, 某一个对象直接调用 clone 方法, 会抛出异常, 即并不能成功克隆一个对象. 调用该方法时, 必须实现一个 Cloneable 接口. 这也就是原型模式的实现方式. 还有即如果该类实现了 cloneable 接口, 尽管构造函数是私有的, 他也可以创建一个对象. 即 clone 方法是不会调用构造函数的, 他是直接从内存中 copy 内存区域的. **所以单例模式的类是不可以实现 cloneable 接口的**. #### 利用枚举防止破坏 ```java /** * Singleton pattern example using Java Enumj */ public enum EasySingleton{ INSTANCE; } ``` **使用反射破解枚举单例:** 运行结果是抛出异常:Exception in thread "main" java.lang.NoSuchMethodException: cn.xing.test.Weekday.() 明明 Weekday 有一个无参的构造函数, 为何不能通过暴力反射访问? 最新的 Java Language Specification (§8.9) 规定: Reflective instantiation of enum types is prohibited. 这是 java 语言的内置规范. **使用 clone 破解枚举单例** 所有的枚举类都继承自 java.lang.Enum 类, 而不是 Object 类. 在 java.lang.Enum 类中 clone 方法如下: ```java protected final Object clone() throws CloneNotSupportedException { throw new CloneNotSupportedException(); } ``` 调用该方法将抛出异常, 且 final 意味着子类不能重写 clone 方法, 所以通过 clone 方法获取新的对象是不可取的. **使用序列化破解枚举单例** java.lang.Enum 类的 readObject 方法如下: ```java private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { throw new InvalidObjectException("can't deserialize enum"); } private void readObjectNoData() throws ObjectStreamException { throw new InvalidObjectException("can't deserialize enum"); } ``` 同暴力反射一样, Java Language Specification (§8.9) 有着这样的规定: the special treatment by the serialization mechanism ensures that duplicate instances are never created as a result of deserialization. ## 引用 - [http://blog.csdn.net/lovelion/article/details/7420883](http://blog.csdn.net/lovelion/article/details/7420883) - [http://blog.csdn.net/chao_19/article/details/51112962](http://blog.csdn.net/chao_19/article/details/51112962) ## [抽象工厂模式:构建灵活的软件系统](https://blog.dong4j.site/posts/9b7d6e62.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) #### 每天一个 Linux 命令 **more 命令** more 命令是一个基于 vi 编辑器文本过滤器, 它以全屏幕的方式按页显示文本文件的内容, 支持 vi 中的关键字定位操作. ``` 按 空格 键: 显示文本的下一屏内容. 按 回车 键: 只显示文本的下一行内容. /: 接着输入一个模式, 可以在文本中寻找下一个相匹配的模式. 按 h 键: 显示帮助屏, 该屏上有相关的帮助信息. 按 b 键: 显示上一屏内容. 按 q 键: 退出 rnore 命令 ``` ``` -< 数字 >: 指定每屏显示的行数; -d: 显示“[press space to continue,'q' to quit.]”和“[Press 'h' for instructions]”; -c: 不进行滚屏操作. 每次刷新这个屏幕; -s: 将多个空行压缩成一行显示; -u: 禁止下划线; +< 数字 >: 从指定数字的行开始显示. ``` ``` # 显示文件 file 的内容, 但在显示之前先清屏, 并且在屏幕的最下方显示完核的百分比. more -dc file # 显示文件 file 的内容, 每 10 行显示一次, 而且在显示之前先清屏. more -c -10 file ``` ## 工厂方法回顾 工厂方式通过引入工厂等级结构, 解决了简单工厂模式中工厂类责任太重的问题, 当由于工厂方法模式中的每一个工厂只生产一类产品, 可能导致系统中存在大量的工厂类, 增加了系统的开销. **优点:** 1. 增加具体产品时不需要修改原来的代码, 只需要增加一个具体工厂类和一个具体产品类即可, 是系统易于扩展. **缺点:** 1. 由于内个具体工厂只能生产出一个具体产品, 增加了类的个数, 增加了系统开销. **练习** 使用工厂方法模式设计一个程序来读取各种不同类型的图片格式, 针对每一种图片格式都设计一个图片读取器, 如 GIF 图片读取器用于读取 GIF 格式的图片、JPG 图片读取器用于读取 JPG 格式的图片. 需充分考虑系统的灵活性和可扩展性. ![20241229154732_SWFLolwS.webp](https://cdn.dong4j.site/source/image/20241229154732_SWFLolwS.webp) 代码: 抽象工厂接口 ```java public interface PictureFactory { public Picture getPicture(); } ``` 具体工厂类 ```java class GifPictureFactory implements PictureFactory{ @Override public Picture getPicture() { return new GifPicture(); } } class JpgPictureFactory implements PictureFactory { @Override public Picture getPicture() { return new JpgPicture(); } } ``` 抽象产品 ```java public interface Picture { void readInfo(); } ``` 具体产品类 ```java class GifPicture implements Picture{ @Override public void readInfo(){ System.out.println("读取 gif 图片信息"); } } class JpgPicture implements Picture{ @Override public void readInfo(){ System.out.println("读取 jpg 图片信息"); } } ``` 测试类 ```java @Test public void homeworkTest() throws IllegalAccessException, InstantiationException, ClassNotFoundException { PictureFactory pictureFactory = (PictureFactory) ConfigUtil.getType("pictureType"); pictureFactory.getPicture().readInfo(); } ``` # 创建者模式之三: 抽象工厂模式 ## 案例 开发一套界面皮肤库, 可以对 Java 桌面软件进行界面美化. 为了保护版权, 该皮肤库源代码不打算公开, 而只向用户提供已打包为 jar 文件的 class 字节码文件. 用户在使用时可以通过菜单来选择皮肤, 不同的皮肤将提供视觉效果不同的按钮、文本框、组合框等界面元素 ![20241229154732_wBzkJ0LQ.webp](https://cdn.dong4j.site/source/image/20241229154732_wBzkJ0LQ.webp) 使用工厂方法模式 UML 结构 ![20241229154732_kZpVcxk4.webp](https://cdn.dong4j.site/source/image/20241229154732_kZpVcxk4.webp) 使用工厂方法模板存在的问题: 1. 如果需要添加具体组件时, 都需要添加一个具体工厂, 类的个数增大. 2. 由于统一风格的具体界面组件需要一起显示, 如果某个具体工厂选择出错将导致界面混乱. 3. 客户端和配置文件复杂. 如何减少类的个数和保证每次初始化时只是用一种风格的具体界面的组件? 是用抽象工厂模式解决. ## 抽象工厂模式 抽象工厂提个一个创建一系列相关或相互依赖对象的接口, 而无需指定他们具体的类. ![20241229154732_ryqmwULr.webp](https://cdn.dong4j.site/source/image/20241229154732_ryqmwULr.webp) - AbstractFactory 抽象工厂 : 声明了一组用于创建同一类产品的方法, 每个方法对应一种产品. - ConcreteFactory 具体工厂 : 实现了抽象工厂中声明的创建产品的方法,, 生产一组具体的产品. - AbstractProduct 抽象产品 : 每种产品的接口 - ConcreteProduct 具体产品 : 具体工厂生产的具体产品, 实现了抽象产品中的声明的业务方法. ### 使用抽象方法模式实现案例 ![20241229154732_dnsOOLUh.webp](https://cdn.dong4j.site/source/image/20241229154732_dnsOOLUh.webp) 代码 皮肤抽象工厂接口 ```java public interface SkinFactory { Button createButton(); TextField createTextField(); Combobox createCombobox(); } ``` Spring 风格皮肤工厂实现 ```java public class SpringSkinFactory implements SkinFactory{ @Override public Button createButton() { return new SpringButton(); } @Override public TextField createTextField() { return new SpringTextField(); } @Override public Combobox createCombobox() { return new SpringCombobox(); } } ``` Summer 风格皮肤工厂实现 ```java public class SummerSkinFactory implements SkinFactory{ @Override public Button createButton() { return new SummerButton(); } @Override public TextField createTextField() { return new SummerTextField(); } @Override public Combobox createCombobox() { return new SummerCombobox(); } } ``` 删个控件接口 ```java public interface Button { void display(); } public interface Combobox { void display(); } public interface TextField { void display(); } ``` Spring 风格系列控件实现 ```java public class SpringButton implements Button{ @Override public void display() { System.out.println("spring 风格按钮"); } } public class SpringCombobox implements Combobox{ @Override public void display() { System.out.println("spring 风格下拉列表框"); } } public class SpringTextField implements TextField{ @Override public void display() { System.out.println("spring 风格文本输入框"); } } ``` Summer 风格系列控件实现 ```java public class SummerButton implements Button{ @Override public void display() { System.out.println("summer 风格按钮"); } } public class SummerCombobox implements Combobox{ @Override public void display() { System.out.println("summer 风格下拉列表框"); } } public class SummerTextField implements TextField{ @Override public void display() { System.out.println("summer 风格文本输入框"); } } ``` 配置文件 ``` skinType=com.dong4j.factory.SpringSkinFactory ``` 测试代码 ```java public class AbstractFactoryTest { @Test public void abstractFactoryTest() throws IllegalAccessException, InstantiationException, ClassNotFoundException { // 使用抽象层定义 SkinFactory factory; Button bt; TextField tf; Combobox cb; factory = (SkinFactory) ConfigUtil.getType("skinType"); bt = factory.createButton(); tf = factory.createTextField(); cb = factory.createCombobox(); bt.display(); tf.display(); cb.display(); } } ``` 1. 如果需要增加新的皮肤, 只需要添加一个皮肤工厂, 然后添加组件的实现即可, 修改配置文件即可使用新的皮肤, 原有代码无须修改, 符合 “开闭原则”. 2. 如果需要增加新的控件, 需要修改抽象工厂, 添加一个组件抽象接口, 然后添加两种风格的实现, 还要修改皮肤具体工厂. ## 总结 **优点:** 1. 抽象工厂模式隔离了具体类的生成, 使得客户不需要知道什么被创建. 由于隔离性, 是的更换一个具体工厂变得容易, 所以的具体工厂都实现了抽象工厂的定义的方法, 因此只需要改变具体工厂的实例, 就可以某种程度上改变整个软件系统的行为. 2. 当一个产品族中的多个对象被设计成一起工作时, 它能保证客户端始终只使用同一个产品族中的对象. 3. 增加新的产品族很方便, 符合开闭原则. **缺点:** 1. 增加新的产品登记结构需要修改众多代码, 违背开闭原则. **适用场景** 1. 一个系统不应当依赖于产品类实例如何被创建、组合和表达的细节, 这对于所有类型的工厂模式都是很重要的, 用户无须关心对象的创建过程, 将对象的创建和使用解耦. 2. 系统中有多于一个的产品族, 而每次只使用其中某一产品族. 可以通过配置文件等方式来使得用户可以动态改变产品族, 也可以很方便地增加新的产品族. 3. 属于同一个产品族的产品将在一起使用, 这一约束必须在系统的设计中体现出来. 同一个产品族中的产品可以是没有任何关系的对象, 但是它们都具有一些共同的约束, 如同一操作系统下的按钮和文本框, 按钮与文本框之间没有直接关系, 但它们都是属于某一操作系统的, 此时具有一个共同的约束条件: 操作系统的类型. 4. 产品等级结构稳定, 设计完成之后, 不会向系统中增加新的产品等级结构或者删除已有的产品等级结构. ## 引用 - [http://blog.csdn.net/lovelion/article/details/9319181](http://blog.csdn.net/lovelion/article/details/9319181) - [http://blog.csdn.net/lovelion/article/details/9319323](http://blog.csdn.net/lovelion/article/details/9319323) - [http://blog.csdn.net/lovelion/article/details/9319423](http://blog.csdn.net/lovelion/article/details/9319423) - [http://blog.csdn.net/lovelion/article/details/9319481](http://blog.csdn.net/lovelion/article/details/9319481) - [http://blog.csdn.net/lovelion/article/details/9319571](http://blog.csdn.net/lovelion/article/details/9319571) ## [开闭原则的实践:工厂方法模式的案例解析](https://blog.dong4j.site/posts/8ec7c960.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) #### 每天一个 Linux 命令 **cat** cat 命令连接文件并打印到标准输出设备上, cat 经常用来显示文件的内容, 类似于下的 type 命令. ``` -n 或 -number: 有 1 开始对所有输出的行数编号; -b 或 --number-nonblank: 和 -n 相似, 只不过对于空白行不编号; -s 或 --squeeze-blank: 当遇到有连续两行以上的空白行, 就代换为一行的空白行; -A: 显示不可打印字符, 行尾显示“$”; -e: 等价于"-vE"选项; -t: 等价于"-vT"选项; ``` ``` cat file1 # 显示 文件 file1 中的内容 cat file1 file2 # 同时显示 file1 和 file2 的内容 cat file1 file2 > file # 将文件 file1 和 file2 合并和放入 file 中 ``` # 回顾 简单工厂模式 优点: 1. 将创建和使用分离, 遵循单一原则 2. 引入配置文件, 遵循开闭原则 3. 使用别名代替复杂的类名, 简化代码 缺点: 1. 如果要添加新的产品类时, 必须修改工厂类逻辑, 不方便扩展 2. 引入过多的类, 造成结构复杂, 难于理解 关键代码: ```java public static 产品类父类 create(String type){ switch (type) { case "具体产品类1标识" : return new 具体产品类 1(); case "具体产品类2标识" : return new 具体产品类 2(); ..... } } ``` 例子: 使用简单工厂模式设计一个可以创建不同几何形状(如圆形、方形和三角形等)的绘图工具, 每个几何图形都具有绘制 draw()和擦除 erase() 两个方法, 要求在绘制不支持的几何图形时, 提示一个 UnSupportedShapeException. ![20241229154732_OjjqHtJZ.webp](https://cdn.dong4j.site/source/image/20241229154732_OjjqHtJZ.webp) 使用简化的简单工厂模式, 即将静态工厂放入到父类中. 代码: 图形父类 ```java public class Graphics { public void draw(){ System.out.println("画图方法"); } public void erase(){ System.out.println("擦除方法"); } public static Graphics createGraphics(String type) throws UnSupportedShapeException { switch (type){ case "rot" : return new Rotundity(); case "squ" : return new Squareness(); case "tri" : return new Trigon(); default : throw new UnSupportedShapeException(); } } } ``` 具体图形类 ```java class Rotundity extends Graphics { @Override public void draw(){ System.out.println("画圆"); } @Override public void erase(){ System.out.println("擦除圆形"); } } class Squareness extends Graphics { @Override public void draw(){ System.out.println("画方形"); } @Override public void erase(){ System.out.println("擦除方形"); } } class Trigon extends Graphics { @Override public void draw(){ System.out.println("画三角形"); } @Override public void erase(){ System.out.println("擦除三角形"); } } ``` 异常类 ```java public class UnSupportedShapeException extends Exception{ public UnSupportedShapeException(){ super("不支持的图形"); } } ``` config 配置 ``` type=tri ``` 测试类 ```java @Test public void graphicsTest() throws UnSupportedShapeException { String type = ConfigUtil.getType("type"); Graphics graphics = Graphics.createGraphics(type); graphics.draw(); graphics.erase(); } ``` 生活中的例子: 银行办理业务, 比如开户, 挂失, 补办等 我只要给大堂经理说我需要办理的业务 (具体产品), 然后填写相应的信息 (初始化具体产品的信息), 然后就可以开始办理 (使用具体产品). 如果银行不支持我办理的业务, 比如说我要办小学入学代缴费, 就只有等待银行向上级反映, 做好相关工作后支持这项业务后才能使用. # 创建者模式之二: 工厂方法模式 使用简单工厂最大的一个缺点就是新增具体产品时, 必须修改静态工厂, 违背了开闭原则. **如果实现增加新产品时不影响原来的代码?** 抽象出一个工厂接口, 里面有一个创建具体产品的方法, 然后让具体工厂实现这个接口, 使用具体产品时, new 一个对应的工厂获取具体产品 ( 或者使用配置文件) 定义一个用于创建对象的接口, 让子类决定实例化哪一个类. 工厂方法使一个类的实例化延迟到其子类. ![20241229154732_P3yYepqE.webp](https://cdn.dong4j.site/source/image/20241229154732_P3yYepqE.webp) - 抽象工厂: 申明了工厂方法, 用于返回一个产品. 所有的具体工厂类都要事先这个接口 - 具体工厂: 是抽象工厂的子类, 实现了工厂方法, 并由客户端调用, 返回一个具体产品类实例. - 抽象产品: 所有产品类的父类 - 具体产品: 实现了抽象产品类, 和具体工厂一一对应 与简单工厂相比, 工厂方法引入了抽象工厂, 抽象工厂可以是接口, 抽象类或者具体类. ```java interface Factory{ public Product createProduct(); } ``` 具体工厂实现抽象工厂, 产生对应的具体产品实例. ```java class ConcteteFactory implements Factory{ public Product createProduct(){ return new ConcreteProduct(); } } ``` 在客户端只需要关心工厂类即可 ```java Factory factory = new ConcreteFactory(); // 可通过配置文件实现 Product product = factory.createProduct(); ``` ## 案例 记录 log 的方式现在有 2 种, 一种是写入文件, 一种是写入数据库 ![20241229154732_htZN3W1q.webp](https://cdn.dong4j.site/source/image/20241229154732_htZN3W1q.webp) 代码: 抽象工厂接口 ```java public interface LoggerFactory { public Logger createLogger(); } ``` 具体工厂类 ```java class DatabaseLoggerFactory implements LoggerFactory{ @Override public Logger createLogger() { return new DatabaseLogger(); } } class FileLoggerFactory implements LoggerFactory{ @Override public Logger createLogger() { return new FileLogger(); } } ``` 抽象产品类 ```java public interface Logger { void writeLog(); } ``` 具体产品类 ```java class FileLogger implements Logger{ @Override public void writeLog(){ System.out.println("将日志写入文件"); } } class DatabaseLogger implements Logger{ @Override public void writeLog(){ System.out.println("将日志写入数据库"); } } ``` 通过配置文件获取具体产品工厂 ```java public class ConfigUtil { public static Object getType(String type) throws ClassNotFoundException, IllegalAccessException, InstantiationException { Properties properties = new Properties(); try { // config.properties 必须放在 classpath 路径下才能加载. properties.load(ConfigUtil.class.getClassLoader().getResourceAsStream("config.properties")); } catch (IOException e) { e.printStackTrace(); } Class obj = Class.forName(properties.getProperty(type)); return obj.newInstance(); } } ``` 测试类 ```java public class FactoryTest { @Test public void factoryTest() throws ClassNotFoundException, IllegalAccessException, InstantiationException { LoggerFactory loggerFactory = (LoggerFactory)ConfigUtil.getType("loggerType"); Logger logger = loggerFactory.createLogger(); logger.writeLog(); } } ``` 添加新的日志记录方式的步骤: 1. 让新的日志记录类继承抽象日志记录器类 Logger; 2. 添加一个对应的具体工厂类, 继承抽象日志记录工厂 LoggerFactory, 并实现其中的工厂方法 createLogger(); 3. 修改 config.properties, 将原来的全类名修改为新的全类名 4. 编译新增的两个类, 原来的代码不做任何修改, 运行测试类即可. 通过工厂模式使得系统更加灵活, 新增产品时只需要添加两个类即可, 不需要修改原有代码. ## 简化工厂模式 将抽象工厂接口改为抽象类 ```java public abstract class LoggerFactory { public abstract Logger createLogger(); // 在工厂类中直接调用日志记录器类的业务方法 writeLogger() public void writeLog(){ Logger logger = this.createLogger(); logger.writeLog(); } } ``` ## 总结 工厂方法是简单工厂的延伸, 修复了简单工厂添加新产品时必须修改源代码的问题 **优点:** 1. 使用具体工厂类创建对应的产品, 想客户端隐藏了具体实现, 用户只需要关系所需的产品即可, 甚至不需要知道产品类名. 2. 增加新产品时, 只需要添加一个具体工厂类和一个具体产品类, 无需修改原来的代码, 使系统更具扩展性. **缺点:** 1. 因为具体产品类和具体工厂类要一一对应, 所以增加了类的个数, 给系统造成了一定的复杂度. 2. 为了更好的遵循开闭原则, 使用配置文件, 反射, 增加了系统复杂度 **适用场景** 1. 客户端不知道它所需要的对象的类. 在工厂方法模式中, 客户端不需要知道具体产品类的类名, 只需要知道所 对应的工厂即可, 具体的产品对象由具体工厂类创建, 可将具体工厂类的类名存储在配置文件或数据库中. 2. 抽象工厂类通过其子类来指定创建哪个对象. 在工厂方法模式中, 对于抽象工厂类只需要提供一个创建产品的 接口, 而由其子类来确定具体要创建的对象, 利用面向对象的多态性和里氏代换原则, 在程序运行时, 子类对象 将覆盖父类对象, 从而使得系统更容易扩展. ## 引用 - [http://blog.csdn.net/lovelion/article/details/9306457](http://blog.csdn.net/lovelion/article/details/9306457) - [http://blog.csdn.net/lovelion/article/details/9306745](http://blog.csdn.net/lovelion/article/details/9306745) - [http://blog.csdn.net/lovelion/article/details/9307137](http://blog.csdn.net/lovelion/article/details/9307137) - [http://blog.csdn.net/lovelion/article/details/9307561](http://blog.csdn.net/lovelion/article/details/9307561) ## [掌握设计模式精髓:五大核心原则深度解析](https://blog.dong4j.site/posts/7244523f.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 单一职责原则 (Single Responsibility Principle, SRP) **定义:** 一个类只负责一个功能领域中相应职责 (对一个类而言, 应该只有一个引起它变化的原因) **作用:** 实现高内聚, 低耦合 ### 案例: 客户信息图形统计模块 ![20241229154732_pLo4amG4.webp](https://cdn.dong4j.site/source/image/20241229154732_pLo4amG4.webp) 违背单一职责原则 如果修改数据库连接方式或者修改图标显示方式都需要修改这个类; 不能重用数据库连接的代码 ### 重构 ![20241229154732_2jnU7yLX.webp](https://cdn.dong4j.site/source/image/20241229154732_2jnU7yLX.webp) **代码实现:** ```java class CustomerDataChart { private CustomerDao customerDao; public void createChart(){ System.out.println("创建图表"); } public void displayChart(){ System.out.println("显示图表"); } } class CustomerDao { private DBUtil dbUtil; public List findCustomers() { System.out.println("获取全部的客户列表"); } } class DBUtil { public Connection getConnection() { System.out.println("获取数据库连接"); } } ``` --- ## 开闭原则 (Open-Closed Principle, OCP) **定义:** 一个软件实体应当对扩展开放, 对修改关闭 (尽量不修改原来的代码, 而是添加新代码实现需求) **作用:** 使系统拥有适应性和灵活性, 同时具备较好的稳定性和延续性 ### 案例 ![20241229154732_sxgIpcu4.webp](https://cdn.dong4j.site/source/image/20241229154732_sxgIpcu4.webp) 代码 ```java ...... if (type.equals("pie")) { PieChart chart = new PieChart(); chart.display(); } else if (type.equals("bar")) { BarChart chart = new BarChart(); chart.display(); } ...... ``` 问题: 当需要新增一种图表显示时, 必须修改源代码, 增加判断语句, 违背开闭原则 ### 重构 ![20241229154732_IKIGT8WG.webp](https://cdn.dong4j.site/source/image/20241229154732_IKIGT8WG.webp) 代码: ```java class ChartDisplay { private AbstractChart chart; public void setChart(AbstractChart chart) { this.chart = chart; } public void display(){ chart.display(); } } abstract class AbstractChart { public abstract void display(); } class PieChart extends AbstractChart { public void display() { System.out.println("圆饼图形显示"); } } class BarChart extends AbstractChart { public void display() { System.out.println("条形图形显示"); } } // 新增显示类, 不需要修改原有代码 class CurveChart extends AbstractChart { public void display() { System.out.println("曲线图形显示"); } } public class OCPTest { public static void main(String[] args) { ChartDisplay c = new ChartDisplay(); // 修改客户端代码, 或者使用 xml 或者 properties 配置文件, 修改配置文件实现类字符串实现 AbstractChart chart = new CurveChart(); c.setChart(chart); c.display(); } } ``` --- ## 里氏替换 (Liskov Substitution Principle, LSP) **定义:** 所有引用基类的地方必须能透明的使用其子类的对象 在软件中将一个基类对象替换成它的子类对象, 程序将不会产生任何错误和异常, 反过来则不成立, 如果一个软件实体使用的是一个子类对象的话, 那么它不一定能够使用基类对象. **作用:** 里氏代换原则是实现开闭原则的重要方式之一, 由于使用基类对象的地方都可以使用子类对象, 因此在程序中尽量使用基类类型来对对象进行定义, 而在运行时再确定其子类类型, 用子类对象来替换父类对象. **在使用里氏代换原则时需要注意如下几个问题**: 1. 子类的所有方法必须在父类中声明, 或子类必须实现父类中声明的所有方法. 根据里氏代换原则, 为了保证系统的扩展性, 在程序中通常使用父类来进行定义, 如果一个方法只存在子类中, 在父类中不提供相应的声明, 则无法在以父类定义的对象中使用该方法. 2. 我们在运用里氏代换原则时, 尽量把父类设计为抽象类或者接口, 让子类继承父类或实现父接口, 并实现在父类中声明的方法, 运行时, 子类实例替换父类实例, 我们可以很方便地扩展系统的功能, 同时无须修改原有子类的代码, 增加新的功能可以通过增加一个新的子类来实现. 里氏代换原则是开闭原则的具体实现手段之一. 3. Java 语言中, 在编译阶段, Java 编译器会检查一个程序是否符合里氏代换原则, 这是一个与实现无关的、纯语法意义上的检查, 但 Java 编译器的检查是有局限的. ### 案例 ![20241229154732_C3xVQGJs.webp](https://cdn.dong4j.site/source/image/20241229154732_C3xVQGJs.webp) ### 重构 ![20241229154732_a9HhpLh9.webp](https://cdn.dong4j.site/source/image/20241229154732_a9HhpLh9.webp) 代码 ```java class EmailSender { public void send(Customer customer){ System.out.print(customer.getName() + "发送邮件"); } } abstract class Customer { protected String name; protected String email; public void setName(String name){ this.name = name; } public String getName(){ return name; } public void setEmail(String email){ this.email = email; } public String getEmail(){ return email; } } class CommonCustomer extends Customer{ } class VIPCustomer extends Customer{ } public class LSPTest { public static void main(String[] args) { Customer customer = new CommonCustomer(); customer.setName("普通用户"); new EmailSender().send(customer); } } ``` --- ## 依赖倒转 (Dependency Inversion Principle, DIP) **定义:** 抽象不应该依赖于细节, 细节应当依赖于抽象 (针对接口编程, 而不是针对实现编程) 依赖倒转原则要求我们在程序代码中传递参数时或在关联关系中, 尽量引用层次高的抽象层类, 即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明, 以及数据类型的转换等, 而不要用具体类来做这些事情. **作用:** 在引入抽象层后, 系统将具有很好的灵活性, 在程序中尽量使用抽象层进行编程, 而将具体类写在配置文件中, 这样一来, 如果系统行为发生变化, 只需要对抽象层进行扩展, 并修改配置文件, 而无须修改原有系统的源代码, 在不修改的情况下来扩展系统的功能, 满足开闭原则的要求 ### 案例 ![20241229154732_2jnU7yLX.webp](https://cdn.dong4j.site/source/image/20241229154732_2jnU7yLX.webp) 代码 ```java class CustomerDao { public void addCustomers(TXTDataConvertor convertor){ convertor.readFile(); System.out.println("存入数据库"); } } class TXTDataConvertor { public void readFile(){ System.out.println("从文本转换数据"); } } class ExcelDataConvertor { public void readFile(){ System.out.println("从excle转换数据"); } } class DIPTest { public static void main(String[] args) { CustomerDao customerDao = new CustomerDao(); customerDao.addCustomers(new TXTDataConvertor()); } } ``` 当需要从 excel 文件转换数据时, 必须修改 CustomerDao 实现代码, 违背开闭原则. ### 重构 ![20241229154732_zejrvvRa.webp](https://cdn.dong4j.site/source/image/20241229154732_zejrvvRa.webp) 代码 ```java class CustomerDao { // 改成抽象类 public void addCustomers(DataConvertor convertor){ convertor.readFile(); System.out.println("存入数据库"); } } abstract class DataConvertor { public abstract void readFile(); } class TXTDataConvertor extends DataConvertor { public void readFile(){ System.out.println("从文本转换数据"); } } class ExcelDataConvertor extends DataConvertor { public void readFile(){ System.out.println("从excle转换数据"); } } class DIPTest { public static void main(String[] args) { CustomerDao customerDao = new CustomerDao(); // 这里可以从配置文件中读取类名, 然后使用反射创建对象 customerDao.addCustomers(new TXTDataConvertor()); } } ``` **里氏代换原则是基础, 依赖倒转原则是手段, 开闭原则是目标** ## 接口隔离 (Interface Segregation Principle, ISP) **定义:** 使用多个专门的接口, 而不使用单一的中接口 (客户端不应该依赖那些不需要的接口) **作用:** 避免实现不需要的功能, 造成类过大 ### 案例 ![20241229154732_9YohyQXM.webp](https://cdn.dong4j.site/source/image/20241229154732_9YohyQXM.webp) 实现 CustomerDataDisplay , 必须全部实现里面的抽象方法, 但是显示类有时候并不需要某些方法, 这是因为 CustomerDataDisplay 声明了太多抽象方法 ### 重构 ![20241229154732_RxaRuJtG.webp](https://cdn.dong4j.site/source/image/20241229154732_RxaRuJtG.webp) 显示类本身有 3 个方法, 如果需要其他功能, 可以实现对应功能的接口即可 在使用接口隔离原则时, 我们需要注意控制接口的粒度, 接口不能太小, 如果太小会导致系统中接口泛滥, 不利于维护;接口也不能太大, 太大的接口将违背接口隔离原则, 灵活性较差, 使用起来很不方便. 一般而言, 接口中仅包含为某一类用户定制的方法即可, 不应该强迫客户依赖于那些它们不用的方法. ## 合成复用原则 (Composite Reuse Principle, CRP) **定义:** 尽量使用对象组合, 而不是进程来达到复用的目的 在面向对象设计中, 可以通过两种方法在不同的环境中复用已有的设计和实现, 即通过组合 / 聚合关系或通过继承, 但首先应该考虑使用组合 / 聚合, 组合 / 聚合可以使系统更加灵活, 降低类与类之间的耦合度, 一个类的变化对其他类造成的影响相对较少;其次才考虑继承, 在使用继承时, 需要严格遵循里氏代换原则, 有效使用继承会有助于对问题的理解, 降低复杂度, 而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度, 因此需要慎重使用继承复用. **作用:** 降低耦合度 is-a 关系 (继承): 一个类是另一个的**一种** has-a 关系 (组合 / 聚合): 某个角色具有某一项责任 ### 案例 ![20241229154732_d1buxWKK.webp](https://cdn.dong4j.site/source/image/20241229154732_d1buxWKK.webp) 将获取数据库连接的方法提取到 DBUtil 中, CustomerDao 继承 DBUtil 当需要更换数据库时, 必须修改 DBUtil 代码, 违反了开闭原则, 或者修改 CustomerDao, 继承另一个获取数据库连接实现类, 同样潍坊了开闭原则 ### 重构 ![20241229154732_wW9BZmoq.webp](https://cdn.dong4j.site/source/image/20241229154732_wW9BZmoq.webp) 使用关联关系 代码 ```java class DBUtil { public Connection getConnection(){ System.out.println("得到数据库连接"); return null; } } class OracleDBUtil extends DBUtil { public Connection getConnection(){ System.out.println("得到Oracle数据库连接"); return null; } } class CustomerDao { private DBUtil dbUtil; public CustomerDao(DBUtil dbUtil){ this.dbUtil = dbUtil; } public void addCustomerDao(){ dbUtil.getConnection(); System.out.println("添加操作"); } } public class CRPTest { public static void main(String[] args) { DBUtil dbUtil = new OracleDBUtil(); // 可以从配置文件中获取数据库实现类, 然后使用反射创建对象 CustomerDao customerDao = new CustomerDao(dbUtil); customerDao.addCustomerDao(); } } ``` 这样重构后, 当再次更换数据库时, 只需要添加一个获取数据库的实现类, 然后继承 DBUtil 即可. 不需要更改任何代码 ## 迪米特法则 (Law of Demeter, LoD) **定义:** 一个软件实体应当尽可能少地与其他实体发生相互作用. 如果一个系统符合迪米特法则, 那么当其中某一个模块发生修改时, 就会尽量少地影响其他模块, 扩展会相对容易, 这是对软件实体之间通信的限制, 迪米特法则要求限制软件实体之间通信的宽度和深度. 迪米特法则可降低系统的耦合度, 使类与类之间保持松散的耦合关系. **作用:** 使系统更容易扩展, 降低耦合度 迪米特法则还有几种定义形式, 包括: 不要和 “陌生人” 说话、只与你的直接朋友通信等, 在迪米特法则中, 对于一个对象, 其朋友包括以下几类: 1. 当前对象本身 (this); 2. 以参数形式传入到当前对象方法中的对象; 3. 当前对象的成员对象; 4. 如果当前对象的成员对象是一个集合, 那么集合中的元素也都是朋友; 5. 当前对象所创建的对象. ## 引用 - [面向对象设计原则之单一职责原则](http://blog.csdn.net/lovelion/article/details/7536542) - [面向对象设计原则之开闭原则](http://blog.csdn.net/lovelion/article/details/7537584) - [面向对象设计原则之里氏代换原则](http://blog.csdn.net/lovelion/article/details/7540445) - [面向对象设计原则之依赖倒转原则](http://blog.csdn.net/lovelion/article/details/7562783) - [面向对象设计原则之接口隔离原则](http://blog.csdn.net/lovelion/article/details/7562842) - [面向对象设计原则之合成复用原则](http://blog.csdn.net/lovelion/article/details/7563441) - [面向对象设计原则之迪米特法则](http://blog.csdn.net/lovelion/article/details/7563445) ## [从零到一,掌握Java简单工厂模式](https://blog.dong4j.site/posts/6b3bea20.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) #### 每天一个 Linux 命令 **ls 命令** ``` -a: 显示所有档案及目录(ls 内定将档案名或目录名称为“.”的视为影藏, 不会列出); -A: 显示除影藏文件“.”和“..”以外的所有文件列表; -C: 多列显示输出结果. 这是默认选项; -l: 与“-C”选项功能相反, 所有输出信息用单列格式输出, 不输出为多列; -F: 在每个输出项后追加文件的类型标识符, 具体含义: “*”表示具有可执行权限的普通文件, “/”表示目录, “@”表示符号链接, “|”表示命令管道 FIFO, “=”表示 sockets 套接字. 当文件为普通文件时, 不输出任何标识符; -b: 将文件中的不可输出的字符以反斜线“”加字符编码的方式输出; -c: 与“-lt”选项连用时, 按照文件状态时间排序输出目录内容, 排序的依据是文件的索引节点中的 ctime 字段. 与“-l”选项连用时, 则排序的一句是文件的状态改变时间; -d: 仅显示目录名, 而不显示目录下的内容列表. 显示符号链接文件本身, 而不显示其所指向的目录列表; -f: 此参数的效果和同时指定“aU”参数相同, 并关闭“lst”参数的效果; -i: 显示文件索引节点号(inode). 一个索引节点代表一个文件; --file-type: 与“-F”选项的功能相同, 但是不显示“*”; -k: 以 KB(千字节)为单位显示文件大小; -l: 以长格式显示目录下的内容列表. 输出的信息从左到右依次包括文件名, 文件类型、权限模式、硬连接数、所有者、组、文件大小和文件的最后修改时间等; -m: 用“,”号区隔每个文件和目录的名称; -n: 以用户识别码和群组识别码替代其名称; -r: 以文件名反序排列并输出目录内容列表; -s: 显示文件和目录的大小, 以区块为单位; -t: 用文件和目录的更改时间排序; -L: 如果遇到性质为符号链接的文件或目录, 直接列出该链接所指向的原始文件或目录; -R: 递归处理, 将指定目录下的所有文件及子目录一并处理; --full-time: 列出完整的日期与时间; --color[=WHEN]: 使用不同的颜色高亮显示不同类型的. ``` ```bash ls -sail # 正则匹配 匹配一个字符 ls -l fileName? # 匹配零个或多个字符 ls -l fileName* ``` ## 简单工厂 简单工厂跟名字一样, 简单. 但是它却不属于设计模式中的一种, 只是因为软件开发中用的比较多, 而且够简单, 所以只是作为学习其他设计模式的例子, 慢慢深入. --- ## 没有使用设计模式的例子 设计一个计算器, 根据传入的操作符和待运算的数字, 得出结果. code: ```java public class Operation { private double numberA; private double numberB; private String operateType; public Operation(String operateType, double numberA, double numberB){ // 对参数进行检查操作 this.numberA = numberA; this.numberB = numberB; this.operateType = operateType; } public double getResult(){ double result = 0; switch (operateType) { case "+": return numberA + numberB; case "-": return numberA - numberB; case "*": return numberA * numberB; case "/": if(numberB == 0) return 0; return numberA / numberB; } return result; } } ``` 根据输入的操作符做 switch 分支, 不同的操作符对应不同的计算, 最后返回结果. ### 缺点 1. 构造方法中负责的太多操作, 造成代码冗长 2. 客户端只能使用 new 关键字来创建操作类对象, 与客户端耦合较高, 对象的创建和使用没有分离. ## 重构 使用简单工厂进行重构. 抽象出一个操作类, 将都需要的属性封装到这个类中, 其他操作类继承这个操作类, 并且实现不同的操作. 然后使用简单工厂根据传入的操作类型创建不懂的操作类, 是创建和使用分离, 客户端只需要调用工厂类的工厂方法传入相应的参数即可得到一个具体的操作类进行操作. 简单工厂定义: 简单工厂模式 (Simple Factory Pattern): 定义一个工厂类, 它可以根据参数的不同返回不同类的实例, 被创建的实例通常都具有共同的父类. 因为在简单工厂模式中用于创建实例的方法是静态 (static) 方法, 因此简单工厂模式又被称为静态工厂方法 (Static Factory Method) 模式, 它属于类创建型模式. ### UML ![20241229154732_y9VrKljK.webp](https://cdn.dong4j.site/source/image/20241229154732_y9VrKljK.webp) ### 代码实现 Operation.java ```java public class Operation { private double numberA; private double numberB; public Operation(){ } public double getNumberA() { return numberA; } public void setNumberA(double numberA) { this.numberA = numberA; } public double getNumberB() { return numberB; } public void setNumberB(double numberB) { this.numberB = numberB; } public double getResult() throws Exception { return (double) 0; } } ``` 具体操作类: ```java class OperationAdd extends Operation{ @Override public double getResult(){ return getNumberA()+ getNumberB(); } } class OperationSub extends Operation{ @Override public double getResult(){ return getNumberA()- getNumberB(); } } class OperationMul extends Operation{ @Override public double getResult(){ return getNumberA()* getNumberB(); } } class OperationDiv extends Operation{ @Override public double getResult() throws Exception { if(getNumberB() == 0){ throw new Exception("除数不能为0"); } return getNumberA()/ getNumberB(); } } ``` OperationFactory.java 简单工厂类, 负责创建具体操作类对象 ```java public class OperationFactory { public static Operation createOperation(String operationType) throws Exception { switch (operationType){ case "+": return new OperationAdd(); case "-": return new OperationSub(); case "*": return new OperationMul(); case "/": return new OperationDiv(); default: throw new Exception("操作不允许"); } } } ``` 测试类使用 ```java public class OperationTest { /** * Simple factory test. * 使用简单工厂获取操作类对象进行计算 * @throws Exception the exception */ @Test public void simpleFactoryTest() throws Exception { com.dong4j.simple.Operation operation = OperationFactory.createOperation("+"); operation.setNumberA(101.0); operation.setNumberB(10.0); System.out.println(operation.getResult()); operation = OperationFactory.createOperation("-"); operation.setNumberA(-0.01); operation.setNumberB(100.0); System.out.println(operation.getResult()); operation = OperationFactory.createOperation("*"); operation.setNumberA(20); operation.setNumberB(0.0); System.out.println(operation.getResult()); operation = OperationFactory.createOperation("/"); operation.setNumberA(10.0); operation.setNumberB(0.0); System.out.println(operation.getResult()); } } ``` ### 优点 使用 Factory 来生产我们需要的具体操作类, 不需要在使用 new 来创建对象, 将创建过程和使用过程分开. 但是每次进行其他操作时, 还是需要传入具体操作类型获取对应的操作类对象, 即要修改客户端代码. 所以这里使用 properties 格式的配置文件, 将需要的参数写在配置文件中, 以后只需要修改配置文件即可 (也可以使用 xml) #### 步骤 添加 config.properties 文件 ``` operationType=+ ``` 使用 ConfigUtil 读取配置文件 ```java public class ConfigUtil { public static String getOperationType(){ Properties properties = new Properties(); try { // config.properties 必须放在 classpath 路径下才能加载. properties.load(ConfigUtil.class.getClassLoader().getResourceAsStream("config.properties")); } catch (IOException e) { e.printStackTrace(); } return properties.getProperty("operationType"); } } ``` 测试 ```java public class OperationTest { /** * Config test. * 使用配置工具类读取配置文件信息, 避免修改客户端代码 * @throws Exception the exception */ @Test public void configTest() throws Exception { // 使用配置文件获取操作符 String operationType = ConfigUtil.getOperationType(); com.dong4j.simple.Operation operation = OperationFactory.createOperation(operationType); operation.setNumberA(101.0); operation.setNumberB(10.0); System.out.println(operation.getResult()); } } ``` 此时只需要修改配置文件即可获取不同操作类型. ### 简单工厂的简化 可以将静态工厂方法移动到 Operation 类中, 客户端执行通过父类的静态工厂方法, 根据参数的不同创建不同类型的子类. ```java public class Operation { private double numberA; private double numberB; public Operation(){ } public double getNumberA() { return numberA; } public void setNumberA(double numberA) { this.numberA = numberA; } public double getNumberB() { return numberB; } public void setNumberB(double numberB) { this.numberB = numberB; } public double getResult() throws Exception { return (double) 0; } // 再次优化, 将创建具体操作类的方法放在父类中, 不再使用简单工厂 public static Operation createOperation(String operationType) throws Exception { switch (operationType){ case "+": return new OperationAdd(); case "-": return new OperationSub(); case "*": return new OperationMul(); case "/": return new OperationDiv(); default: throw new Exception("操作不允许"); } } } ``` 测试 ```java public class OperationTest { /** * Operation static test. * 将创建具体操作类的静态方法简化到 Operation 类中, 去掉 OperationFactory. * @throws Exception the exception */ @Test public void operationStaticTest() throws Exception { // 使用配置文件获取操作符 String operationType = ConfigUtil.getOperationType(); com.dong4j.simple.Operation operation = com.dong4j.simple.Operation.createOperation(operationType); operation.setNumberA(110.0); operation.setNumberB(11.0); System.out.println(operation.getResult()); } } ``` ### 总结 简单工厂模式专门提供了工厂类用于创建对象, 将对象的创建和对象的使用分开 **优点:** 1. 工厂类负责创建具体产品类, 客户端可以不用创建对象, 直接使用即可. 2. 客户端无需知道所创建具体产品名称, 只需要知道具体产品类所对应的参数即可, 减少了对于复杂类名的记忆. 3. 通过引入配置文件, 可以不修改客户端的情况加更换具体产品类. 提高了系统灵活性 **缺点:** 1. 使用简单工厂增加了类的个数, 是系统变得复杂, 不便于理解. 2. 系统扩展困难, 如果需要新增操作类型, 将不得不修改工厂类的逻辑, 增加新的判断. 不利于系统的扩展和维护. ## 引用 - [http://blog.csdn.net/lovelion/article/details/9300337](http://blog.csdn.net/lovelion/article/details/9300337) - [http://blog.csdn.net/lovelion/article/details/9300549](http://blog.csdn.net/lovelion/article/details/9300549) - [http://blog.csdn.net/lovelion/article/details/9300657](http://blog.csdn.net/lovelion/article/details/9300657) - [http://blog.csdn.net/lovelion/article/details/9300731](http://blog.csdn.net/lovelion/article/details/9300731) ## 代码下载 [https://github.com/dong4j/pattern_code](https://github.com/dong4j/pattern_code) ## [使用 validate API 验证 Elasticsearch 查询语句的合法性与解释](https://blog.dong4j.site/posts/f20ffdb.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 验证查询 查询语句可以变得非常复杂,特别是与不同的分析器和字段映射相结合后,就会有些难度。 validate API 可以验证一条查询语句是否合法。 ```json GET /gb/tweet/_validate/query { "query": { "tweet" : { "match" : "really powerful" } } } ``` 以上请求的返回值告诉我们这条语句是非法的: ```json { "valid": false, "_shards": { "total": 1, "successful": 1, "failed": 0 } } ``` ### 理解错误信息 想知道语句非法的具体错误信息,需要加上 explain 参数: ```json GET /gb/tweet/_validate/query?explain <1> { "query": { "tweet" : { "match" : "really powerful" } } } ``` > <1> explain 参数可以提供语句错误的更多详情。 > 很显然,我们把 query 语句的 match 与字段名位置弄反了: ```json { "valid" : false, "_shards" : { ... }, "explanations" : [ { "index" : "gb", "valid" : false, "error" : "org.elasticsearch.index.query.QueryParsingException: [gb] No query registered for [tweet]" } ] } ``` ### 理解查询语句 如果是合法语句的话,使用 explain 参数可以返回一个带有查询语句的可阅读描述, 可以帮助了解查询语句在 ES 中是如何执行的: ```json GET /_validate/query?explain { "query": { "match" : { "tweet" : "really powerful" } } } ``` explanation 会为每一个索引返回一段描述,因为每个索引会有不同的映射关系和分析器: ```json { "valid" : true, "_shards" : { ... }, "explanations" : [ { "index" : "us", "valid" : true, "explanation" : "tweet:really tweet:powerful" }, { "index" : "gb", "valid" : true, "explanation" : "tweet:really tweet:power" } ] } ``` 从返回的 explanation 你会看到 match 是如何为查询字符串 "really powerful" 进行查询的, 首先,它被拆分成两个独立的词分别在 tweet 字段中进行查询。 而且,在索引 us 中这两个词为 "really" 和 "powerful",在索引 gb 中被拆分成 "really" 和 "power"。 这是因为我们在索引 gb 中使用了 english 分析器。 ## [掌握Elasticsearch,从理解查询和过滤开始](https://blog.dong4j.site/posts/f56310d4.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 查询语句和过滤语句合并 查询语句和过滤语句可以放在各自的上下文中。 在 ElasticSearch API 中我们会看到许多带有 query 或 filter 的语句。 这些语句既可以包含单条 query 语句,也可以包含一条 filter 子句。 换句话说,这些语句需要首先创建一个 query 或 filter 的上下文关系。 复合查询语句可以加入其他查询子句,复合过滤语句也可以加入其他过滤子句。 通常情况下,一条查询语句需要过滤语句的辅助,全文本搜索除外。 所以说,查询语句可以包含过滤子句,反之亦然。 以便于我们切换 query 或 filter 的上下文。这就要求我们在读懂需求的同时构造正确有效的语句。 ### 带过滤的查询语句 过滤一条查询语句 比如说我们有这样一条查询语句: ```json { "match": { "email": "business opportunity" } } ``` 然后我们想要让这条语句加入 term 过滤,在收信箱中匹配邮件: ```json { "term": { "folder": "inbox" } } ``` search API 中只能包含 query 语句,所以我们需要用 filtered 来同时包含 "query" 和 "filter" 子句: ```json { "filtered": { "query": { "match": { "email": "business opportunity" } }, "filter": { "term": { "folder": "inbox" } } } } ``` 我们在外层再加入 query 的上下文关系: ```json GET /_search { "query": { "filtered": { "query": { "match": { "email": "business opportunity" }}, "filter": { "term": { "folder": "inbox" }} } } } ``` ### 单条过滤语句 在 query 上下文中,如果你只需要一条过滤语句,比如在匹配全部邮件的时候,你可以 省略 query 子句: ```json GET /_search { "query": { "filtered": { "filter": { "term": { "folder": "inbox" }} } } } ``` 如果一条查询语句没有指定查询范围,那么它默认使用 match_all 查询,所以上面语句 的完整形式如下: ```json GET /_search { "query": { "filtered": { "query": { "match_all": {}}, "filter": { "term": { "folder": "inbox" }} } } } ``` ### 查询语句中的过滤 有时候,你需要在 filter 的上下文中使用一个 query 子句。下面的语句就是一条带有查询功能 的过滤语句, 这条语句可以过滤掉看起来像垃圾邮件的文档: ```json GET /_search { "query": { "filtered": { "filter": { "bool": { "must": { "term": { "folder": "inbox" }}, "must_not": { "query": { <1> "match": { "email": "urgent business proposal" } } } } } } } } ``` > <1> 过滤语句中可以使用 query 查询的方式代替 bool 过滤子句。 > 提示: 我们很少用到的过滤语句中包含查询,保留这种用法只是为了语法的完整性。 只有在过滤中用到全文本匹配的时候才会使用这种结构。 ## [Elasticsearch进阶:高效的过滤条件与精确查询](https://blog.dong4j.site/posts/8b9030ff.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 查询过滤关键字 ### term 过滤 term 主要用于精确匹配哪些值,比如数字,日期,布尔值或 not_analyzed 的字符串 (未经分析的文本数据类型): ```json { "term": { "age": 26 }} { "term": { "date": "2014-09-01" }} { "term": { "public": true }} { "term": { "tag": "full_text" }} ``` ### terms 过滤 terms 跟 term 有点类似,但 terms 允许指定多个匹配条件。 如果某个字段指定了多个值,那么文档需要一起去做匹配: ```json { "terms": { "tag": ["search", "full_text", "nosql"] } } ``` ### range 过滤 (范围过滤) range 过滤允许我们按照指定范围查找一批数据: ```json { "range": { "age": { "gte": 20, "lt": 30 } } } ``` 范围操作符包含: gt :: 大于 gte:: 大于等于 lt :: 小于 lte:: 小于等于 ### exists 和 missing 过滤 exists 和 missing 过滤可以用于查找文档中是否包含指定字段或没有某个字段,类似于 SQL 语句中的 IS_NULL 条件 ```json { "exists": { "field": "title" } } ``` 这两个过滤只是针对已经查出一批数据来,但是想区分出某个字段是否存在的时候使用。 ### bool 过滤 bool 过滤可以用来合并多个过滤条件查询结果的布尔逻辑,它包含一下操作符: must :: 多个查询条件的完全匹配,相当于 and。 must_not :: 多个查询条件的相反匹配,相当于 not。 should :: 至少有一个查询条件匹配, 相当于 or。 这些参数可以分别继承一个过滤条件或者一个过滤条件的数组: ```json { "bool": { "must": { "term": { "folder": "inbox" } }, "must_not": { "term": { "tag": "spam" } }, "should": [{ "term": { "starred": true } }, { "term": { "unread": true } }] } } ``` ### bool 查询 bool 查询与 bool 过滤相似,用于合并多个查询子句。不同的是,bool 过滤可以直接给出是否匹配成功, 而 bool 查询要计算每一个查询子句的 `_score` (相关性分值)。 ``` must:: 查询指定文档一定要被包含。 must_not:: 查询指定文档一定不要被包含。 should:: 查询指定文档,有则可以为文档相关性加分。 ``` 以下查询将会找到 title 字段中包含 "how to make millions",并且 "tag" 字段没有被标为 spam。 如果有标识为 "starred" 或者发布日期为 2014 年之前,那么这些匹配的文档将比同类网站等级高: ```json { "bool": { "must": { "match": { "title": "how to make millions" } }, "must_not": { "match": { "tag": "spam" } }, "should": [ { "match": { "tag": "starred" } }, { "range": { "date": { "gte": "2014-01-01" } } } ] } } ``` > 提示: 如果 bool 查询下没有 must 子句,那至少应该有一个 should 子句。但是 如果有 must 子句,那么没有 should 子句也可以进行查询。 ### match_all 查询 使用 match_all 可以查询到所有文档,是没有查询条件下的默认语句。 ```json { "match_all": {} } ``` 此查询常用于合并过滤条件。 比如说你需要检索所有的邮箱,所有的文档相关性都是相同的,所以得到的 `_score` 为 1 ### match 查询 match 查询是一个标准查询,不管你需要全文本查询还是精确查询基本上都要用到它。 如果你使用 match 查询一个全文本字段,它会在真正查询之前用分析器先分析 match 一下查询字符: ```json { "match": { "tweet": "About Search" } } ``` 如果用 match 下指定了一个确切值,在遇到数字,日期,布尔值或者 not_analyzed 的字符串时,它将为你搜索你给定的值: ```json { "match": { "age": 26 }} { "match": { "date": "2014-09-01" }} { "match": { "public": true }} { "match": { "tag": "full_text" }} ``` > 提示: 做精确匹配搜索时,你最好用过滤语句,因为过滤语句可以缓存数据。 不像我们在《简单搜索》中介绍的字符查询,match 查询不可以用类似 "+usid:2 +tweet:search" 这样的语句。 它只能就指定某个确切字段某个确切的值进行搜索,而你要做的就是为它指定正确的字段名以避免语法错误。 ### multi_match 查询 multi_match 查询允许你做 match 查询的基础上同时搜索多个字段: ```json { "multi_match": { "query": "full text search", "fields": ["title", "body"] } } ``` ## [关系型数据库与Elasticsearch对比:索引、类型和文档解析](https://blog.dong4j.site/posts/24a1ab9d.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 关系型数据库和 ES 对比 ``` Relational DB -> Databases -> Tables -> Rows -> Columns Elasticsearch -> Indexing -> Types -> Documents -> Fields ``` Elasticsearch 集群可以包含多个索引 (indices)(数据库),每一个索引可以包含多个类型 (types)(表),每一个类型包含多个文档 (documents)(行),然后每个文档包含多个字段 (Fields)(列) > # 「索引」含义的区分 > 你可能已经注意到索引 (index) 这个词在 Elasticsearch 中有着不同的含义,所以有必要在此做一下区分: > 索引(名词) 如上文所述,一个索引 (index) 就像是传统关系数据库中的数据库,它是相关文档存储的地方,index 的复数是 indices 或 indexes。 > 索引(动词) 「索引一个文档」表示把一个文档存储到索引(名词)里,以便它可以被检索或者查询。这很像 SQL 中的 INSERT 关键字,差别是,如果文档已经存在,新的文档将覆盖旧的文档。 > 倒排索引 传统数据库为特定列增加一个索引,例如 B-Tree 索引来加速检索。Elasticsearch 和 Lucene 使用一种叫做倒排索引 (inverted index) 的数据结构来达到相同目的。 创建一个员工信息 ```json PUT /megacorp/employee/1 { "first_name" : "John", "last_name" : "Smith", "age" : 25, "about" : "I love to go rock climbing", "interests": [ "sports", "music" ] } ``` **删除使用** delete **查询** 使用 get **查询全部** 在最后加上 `_search` **自动生成 id** 使用 post > 自动生成的 ID 有 22 个字符长,URL-safe, Base64-encoded string universally unique identifiers, 或者叫 UUIDs。 **根据关键字查询** `GET /megacorp/employee/_search?q=last_name:Smith` 我们在请求中依旧使用 `_search` 关键字,然后将查询语句传递给参数 q=。 Elasticsearch 致力于隐藏分布式系统的复杂性。以下这些操作都是在底层自动完成的: > 将你的文档分区到不同的容器或者分片 (shards) 中,它们可以存在于一个或多个节点中。 > 将分片均匀的分配到各个节点,对索引和搜索做负载均衡。 > 冗余每一个分片,防止硬件故障造成的数据丢失。 > 将集群中任意一个节点上的请求路由到相应数据所在的节点。 > 无论是增加节点,还是移除节点,分片都可以做到无缝的扩展和迁移。 ## 创建一个新文档 > 当索引一个文档,我们如何确定是完全创建了一个新的还是覆盖了一个已经存在的呢? > 请记住 `_index`、`_type`、`_id` 三者唯一确定一个文档。所以要想保证文档是新加入的,最简单的方式是使用 POST 方法让 Elasticsearch 自动生成唯一 `_id`: ```json POST /website/blog/ { ... } ``` 然而,如果想使用自定义的 `_id`,我们必须告诉 Elasticsearch 应该在 `_index`、`_type`、`_id` 三者都不同时才接受请求。为了做到这点有两种方法,它们其实做的是同一件事情。你可以选择适合自己的方式:1 1. 第一种方法使用 op_type 查询参数: ```json PUT /website/blog/123?op_type=create { ... } ``` 2. 或者第二种方法是在 URL 后加 `/_create` 做为端点: ```json PUT /website/blog/123/_create { ... } ``` 如果请求成功的创建了一个新文档,Elasticsearch 将返回正常的元数据且响应状态码是 201 Created。 另一方面,如果包含相同的 `_index`、`_type` 和 `_id` 的文档已经存在,Elasticsearch 将返回 409 Conflict 响应状态码,错误信息类似如下: ```json { "error" : "DocumentAlreadyExistsException[[website][4] [blog][123]: document already exists]", "status" : 409 } ``` ## 多索引和多类型搜索 ``` /_search 在所有索引的所有类型中搜索 /gb/_search 在索引gb的所有类型中搜索 /gb,us/_search 在索引gb和us的所有类型中搜索 /g*,u*/_search 在以g或u开头的索引的所有类型中搜索 /gb/user/_search 在索引gb的类型user中搜索 /gb,us/user,tweet/_search 在索引gb和us的类型为user和tweet中搜索 /_all/user,tweet/_search ``` ## 分页 ```json GET /_search?size=5 GET /_search?size=5&from=5 GET /_search?size=5&from=10 ``` 深度分页问题 ## 查询方式 1. 简单查询 直接在 url 后面拼接参数 ```json GET /_all/tweet/_search?q=tweet:elasticsearch ``` "+" 前缀表示语句匹配条件必须被满足。类似的 "-" 前缀表示条件必须不被满足。所有条件如果没有 + 或 - 表示是可选的——匹配越多,相关的文档就越多。 ## [Java 基础:一些常用的代码片段四](https://blog.dong4j.site/posts/1553cb8c.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 外部排序:通过 Comparator 实现个性化排序 ### 使用 Comparator 的基本步骤 1. **定义一个实现了 `Comparator` 接口的类**,其中需要实现 `compare` 方法。 2. **使用 Collections.sort(List list, Comparator c)** 对列表进行排序。 ### 示例代码: #### Person 类 ```java public class Person { private int age; private String name; public Person(int age, String name) { this.age = age; this.name = name; } public int getAge() { return age; } @Override public String toString() { return "Person{" + "age=" + age + ", name='" + name + '\'' + '}'; } } ``` #### PersonComparator 类(实现了 Comparator 接口) ```java import java.util.Comparator; public class PersonComparator implements Comparator { @Override public int compare(Person o1, Person o2) { return Integer.compare(o1.getAge(), o2.getAge()); } } ``` #### TestComparator 类(测试排序逻辑) ```java import java.util.*; public class TestComparator { public static String outCollection(Collection coll) { StringBuffer sb = new StringBuffer(); for (Object obj : coll) { sb.append(obj + "\n"); } System.out.println(sb.toString()); return sb.toString(); } public static void main(String[] args) { test1(); } public static void test1() { System.out.println("----------test1()---------"); System.out.println("升序排序测试:"); List listPerson = new ArrayList<>(); Person person1 = new Person(34, "lavasoft"); Person person2 = new Person(12, "lavasoft"); Person person3 = new Person(23, "leizhimin"); Person person4 = new Person(13, "sdg"); listPerson.add(person1); listPerson.add(person2); listPerson.add(person3); listPerson.add(person4); Comparator ascComparator = new PersonComparator(); System.out.println("原集合为:"); outCollection(listPerson); System.out.println("排序后集合为:"); Collections.sort(listPerson, ascComparator); outCollection(listPerson); } } ``` ### 输出结果 ``` ----------test1()--------- 升序排序测试: 原集合为: Person{age=34, name='lavasoft'} Person{age=12, name='lavasoft'} Person{age=23, name='leizhimin'} Person{age=13, name='sdg'} 排序后集合为: Person{age=12, name='lavasoft'} Person{age=13, name='sdg'} Person{age=23, name='leizhimin'} Person{age=34, name='lavasoft'} ``` ## 内部排序:通过实现 Comparable 接口 ### 使用 Comparable 的基本步骤 1. **在需要排序的类中实现 `Comparable` 接口**,并重写 `compareTo` 方法。 2. **使用 Collections.sort(List list)** 对列表进行默认升序或调用 `Collections.reverseOrder()` 进行降序。 ### 示例代码: #### Cat 类 ```java public class Cat implements Comparable { private int age; private String name; public Cat(int age, String name) { this.age = age; this.name = name; } public int getAge() { return age; } @Override public String toString() { return "Cat{" + "age=" + age + ", name='" + name + '\'' + '}'; } @Override public int compareTo(Cat o) { return Integer.compare(this.getAge(), o.getAge()); } } ``` #### TestComparable 类(测试排序逻辑) ```java import java.util.*; public class TestComparable { public static String outCollection(Collection coll) { StringBuffer sb = new StringBuffer(); for (Object obj : coll) { sb.append(obj + "\n"); } System.out.println(sb.toString()); return sb.toString(); } public static void main(String[] args) { test(); } public static void test() { System.out.println("----------test()---------"); System.out.println("升序排序测试:"); List listCat = new ArrayList<>(); Cat cat1 = new Cat(34, "hehe"); Cat cat2 = new Cat(12, "haha"); Cat cat3 = new Cat(23, "leizhimin"); Cat cat4 = new Cat(13, "lavasoft"); listCat.add(cat1); listCat.add(cat2); listCat.add(cat3); listCat.add(cat4); System.out.println("原集合为:"); outCollection(listCat); System.out.println("调用Collections.sort(List list)排序:"); Collections.sort(listCat); outCollection(listCat); System.out.println("逆序排列元素:"); Collections.sort(listCat, Collections.reverseOrder()); outCollection(listCat); } } ``` ### 输出结果 ``` ----------test()--------- 升序排序测试: 原集合为: Cat{age=34, name='hehe'} Cat{age=12, name='haha'} Cat{age=23, name='leizhimin'} Cat{age=13, name='lavasoft'} 调用Collections.sort(List list)排序: Cat{age=12, name='haha'} Cat{age=13, name='lavasoft'} Cat{age=23, name='leizhimin'} Cat{age=34, name='hehe'} 逆序排列元素: Cat{age=34, name='hehe'} Cat{age=23, name='leizhimin'} Cat{age=13, name='lavasoft'} Cat{age=12, name='haha'} ``` ## 总结 通过上述示例,我们可以看到使用 `Comparator` 和实现 `Comparable` 接口分别实现了外部排序和内部排序。两种方法各有优势: - **外部排序(使用 Comparator)**: - 高度灵活,可以为一个类定义多种不同的比较方式。 - 对于不希望修改原始类的代码结构的情况特别有用。 - **内部排序(实现 Comparable 接口)**: - 简洁且直接在对象内部定义排序逻辑,保持了单个类的一致性。 - 对于需要频繁排序的对象特别合适,并且可以通过 `Collections.reverseOrder()` 快速反转排序顺序。 ## 使用迭代器删除集合元素 ### 场景描述 当你需要遍历一个集合并根据某些条件移除其中的元素时,直接在循环体内进行删除操作会导致 `ConcurrentModificationException`。这是因为直接修改集合(如调用 `List.remove()`)会破坏迭代器的状态。 为了避免这种情况,我们可以使用 Java 的 `Iterator` 接口来安全地执行删除操作。 ### 示例代码 假设我们有一个订单列表,并希望根据店铺 ID 移除不属于特定店铺的商品项。 ```java for (OrderDto orderDto : list) { Iterator productItemDtoIterator = orderDto.getProductItems().iterator(); while (productItemDtoIterator.hasNext()) { ProductItemDto productItemDto = productItemDtoIterator.next(); if (!productItemDto.getShopId().equals(shopId)) { // 通过调用迭代器的 remove 方法来安全地移除元素 productItemDtoIterator.remove(); } } } ``` 这里,我们使用了 `Iterator` 的 `remove()` 方法而不是直接删除集合中的元素。此方法确保了操作的安全性,并且避免了并发修改异常。 ### 注意事项 - **不要在循环中调用其他可能改变列表的方法**。 - 当需要同时进行增删改查等复杂操作时,建议使用迭代器来遍历和修改数据结构。 ## JDBC 中的事务管理 ### 事务基础概念 数据库事务是一组数据库操作指令集,这些操作要么全部完成(成功),要么全部都不执行(失败)。JDBC 提供了对 SQL 语句执行的控制能力,并且可以进行显式地提交或回滚当前事务。 ### 示例代码 以下是一个简单的示例,展示了如何使用 JDBC 管理数据库中的事务: ```java Connection con = null; Statement st = null; ResultSet rs = null; PreparedStatement ps = null; public void startTransaction() { try { // 获取连接对象 con = DBCManager.getConnect(); // 设置事务的提交方式为非自动提交,确保需要的所有操作都成功后才进行提交。 con.setAutoCommit(false); // 准备并执行 SQL 语句 String sql1 = "delete from me where id = 7"; ps = con.prepareStatement(sql1); ps.executeUpdate(); String sql2 = "update me set name='chengong', age=34 where id=4"; ps = con.prepareStatement(sql2); ps.executeUpdate(); // 如果事务成功执行,则提交事务。 con.commit(); } catch (SQLException e) { try { // 出现异常时,回滚当前的数据库操作。 if(con != null) con.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } // 打印错误信息 e.printStackTrace(); } finally { // 释放资源 DBCManager.release(rs, ps, con); } } ``` ### 输出结果与解释 如果上述操作在执行过程中没有任何异常,则整个事务会被提交到数据库。如果有任何 SQL 操作失败,那么 `commit` 调用将不会发生,并且之前的更改将会被回滚。 ## 总结 通过使用 Java 的迭代器接口来安全地修改集合中的元素是处理并发访问时的一个关键技巧;而在执行多个操作以确保数据的一致性时,事务管理则是 JDBC 编程中的重要组成部分。这两种技术都提供了强大的工具来帮助我们编写更加健壮的应用程序代码。 ## [Java 基础:一些常用代码片段三](https://blog.dong4j.site/posts/5981a792.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 动态获取加载的 Jar 包 有时我们需要在运行时确定一个特定类所对应的 JAR 包或目录的位置。为此我们提供了`ClassLocationUtils.where(cls)`这个静态工具方法来帮助识别指定类的来源文件。 ```java import java.io.File; import java.net.MalformedURLException; import java.net.URL; import java.security.CodeSource; import java.security.ProtectionDomain; public class ClassLocationUtils { public static String where(final Class cls) { if (cls == null)throw new IllegalArgumentException("null input: cls"); URL result = null; final String clsAsResource = cls.getName().replace('.', '/').concat(".class"); final ProtectionDomain pd = cls.getProtectionDomain(); if (pd != null) { final CodeSource cs = pd.getCodeSource(); if (cs != null) result = cs.getLocation(); if ("file".equals(result.getProtocol())) { try { if (result.toExternalForm().endsWith(".jar") || result.toExternalForm().endsWith(".zip")) result = new URL("jar:".concat(result.toExternalForm()) .concat("!/").concat(clsAsResource)); else if (new File(result.getFile()).isDirectory()) result = new URL(result, clsAsResource); } catch (MalformedURLException ignore) { } } } if (result == null) { final ClassLoader clsLoader = cls.getClassLoader(); result = clsLoader != null ? clsLoader.getResource(clsAsResource) : ClassLoader.getSystemResource(clsAsResource); } return result.toString(); } } ``` ### 方法解释 - **获取类的资源表示**:首先,将提供的类名转换成资源路径的形式(例如,`com/example/MyClass.class`)。 - **获取代码源位置**:通过调用`ProtectionDomain.getCodeSource()`方法可以找到该类所在 JAR 或目录的位置。 - **处理文件 URL**:如果 URL 协议是“file”,则进一步检查它是否指向一个 ZIP/JAR 文件,如果是,则构建一个新的 URL 来表示内部的资源路径;如果不是 ZIP/JAR,并且是一个目录,则直接使用`URL.openConnection()`方法尝试获取到相应的类文件。 ### 使用场景 在进行调试或分析时,此工具可以非常有用。例如,在 IDE 中设置断点后输入 `ClassLocationUtils.where(xxx.class)` 可以快速得到当前被加载的 JAR 包路径。 ## 实现定时任务的三种方法 Java 提供了多种实现定时执行任务的方式,这里介绍了其中最常见的三种方式: ### 方法一:使用普通 Thread 这种方式是通过创建一个线程并让其在无限循环中执行指定的任务,并且在每次运行之间设置一定的延时。虽然简单易行,但它缺乏对任务启动和停止的控制。 ```java public class Task1 { public static void main(String[] args) { final long timeInterval = 1000; // 每秒执行一次 new Thread(() -> { while (true) { System.out.println("Hello !!"); try { Thread.sleep(timeInterval); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); } } ``` ### 方法二:使用`java.util.Timer` 这种方法提供了对任务启动和取消的控制能力,并允许指定延迟执行的时间间隔。缺点是仅支持单线程操作。 ```java import java.util.Timer; import java.util.TimerTask; public class Task2 { public static void main(String[] args) { Timer timer = new Timer(); TimerTask task = () -> System.out.println("Hello !!!"); // 第二个参数为延迟时间,第三个为每次执行任务的时间间隔 timer.scheduleAtFixedRate(task, 0, 1000); } } ``` ### 方法三:使用`java.util.concurrent.ScheduledExecutorService` 这是 Java SE5 引入的一个高级定时任务处理类。它提供了更灵活的任务调度方式,支持多线程执行,并且能方便地设置首次执行的延迟时间。 ```java import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; public class Task3 { public static void main(String[] args) { ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor(); // 第二个参数为首次执行的延时时间,第三个为每次执行任务的时间间隔 service.scheduleAtFixedRate(() -> System.out.println("Hello !!"), 10, 1, TimeUnit.SECONDS); } } ``` ## 拼接字符串时去除最后一个多余的逗号 当我们在 Java 中进行字符串拼接操作时,可能会遇到需要移除最后一个多余逗号的情况。下面的例子展示了如何使用 `StringBuffer` 类来实现这一功能: ```java String str[] = { "hello", "beijing", "world", "shenzhen" }; StringBuffer buf = new StringBuffer(); for (int i = 0; i < str.length; i++) { buf.append(str[i]).append(","); } if (buf.length() > 0) { //方法一 : substring System.out.println(buf.substring(0, buf.length()-1)); //方法二 :replace System.out.println(buf.replace(buf.length() - 1, buf.length(), "")); //方法三: deleteCharAt System.out.println(buf.deleteCharAt(buf.length()-1)); } ``` 上述代码中,我们通过 `substring`、`replace` 和 `deleteCharAt` 方法实现了对最后一个逗号的移除。 ## 将 List 对象转换为带分隔符的字符串 有时我们需要将一个 `List` 类型的对象转换成包含特定分隔符的字符串。这里提供了一些实现此功能的方法: ```java // 方法一: public String listToString(List list, char separator) { StringBuilder sb = new StringBuilder(); for (int i = 0; i list, Separator separator) { StringBuilder sb = new StringBuilder(); for (String s : list) { if (s != null && !"".equals(s)) { sb.append(separator.get()).append(s); } } return sb.toString(); } // 方法五: public String listToString5(List list, char separator) { return org.apache.commons.lang.StringUtils.join(list.toArray(),separator); } ``` 这些方法各具特点,可以根据具体需求选择最合适的实现。 ## 利用反射机制根据完整类名获取类对象 Java 的反射机制允许我们动态地创建、访问和操作类及其成员。以下是一个通过反射加载并使用配置文件中指定的类实例的例子: ```java public class Test { public static void main(String[] args) throws Exception { Properties properties = new Properties(); properties.load(Test.class.getClassLoader().getResourceAsStream("com/lsd/beanfactory/ApplicationContext.properties"));//根据配置文件的路径加载对象 String key = properties.getProperty("vehicle");//根据key获取value Class c = Class.forName(key); System.out.println(key); Object object = c.newInstance(); Vehicle vehicle = (Vehicle) object; vehicle.run(); } } ``` 这段代码首先从 `Properties` 对象中读取配置文件中的类名,然后使用反射机制加载指定的类,并实例化对象最后调用该对象的方法。这在框架设计和依赖注入场景下非常有用。 ## 使用多条件排序 ArrayList 当需要根据多个属性进行复杂的排序时,Java 提供了灵活的机制来实现这一点。下面的例子展示了如何使用自定义比较器对包含员工信息的 `ArrayList` 进行排序: ```java import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; public class TestComparator { public static void main(String[] args) { ArrayList persons = new ArrayList<>(); persons.add(new Person(10, 1000, 4)); persons.add(new Person(1, 1020, 5)); persons.add(new Person(1, 1020, 4)); persons.add(new Person(13, 1100, 2)); persons.add(new Person(1, 1020, 5)); // 打印排序前的列表 for (Person person : persons) { System.out.println(person); } System.out.println(); // 使用自定义比较器进行多条件排序(级别、薪资和年份) Collections.sort(persons, new Comparator() { @Override public int compare(Person o1, Person o2) { if (o1.level == o2.level) { if (o1.salary == o2.salary) { return Integer.compare(o2.years, o1.years); } else { return Integer.compare(o2.salary, o1.salary); } } else { return Integer.compare(o2.level, o1.level); } } }); // 打印排序后的列表 for (Person person : persons) { System.out.println(person); } } static class Person { int level; // 级别 int salary; // 薪资 int years; // 入职年数 public Person(int level, int salary, int years) { this.level = level; this.salary = salary; this.years = years; } @Override public String toString() { return "Person{" + "level=" + level + ", salary=" + salary + ", years=" + years + '}'; } } } ``` 上述代码展示了如何根据多个属性进行排序。我们首先定义了一个 `Comparator`,并在其中实现了多条件的比较逻辑:先按照级别降序排列;如果级别相同,则按薪资降序排列;最后,在级别和薪资都相同时按入职年数升序排列。 ## 遍历 Map 的几种方法 在 Java 中遍历 `Map` 对象有多种方式,以下是常用的四种方法: ### 使用 for 循环迭代 ```java Map map = new HashMap<>(); map.put("username", "qq"); map.put("password", "123"); // 注意此处拼写修正 map.put("userID", "1"); map.put("email", "qq@qq.com"); for (Map.Entry entry : map.entrySet()) { System.out.println(entry.getKey() + "-->" + entry.getValue()); } ``` ### 使用 Iterator 进行迭代 ```java Set> set = map.entrySet(); Iterator> iterator = set.iterator(); while (iterator.hasNext()) { Map.Entry entry = iterator.next(); System.out.println(entry.getKey() + "==" + entry.getValue()); } ``` ### 使用 keySet 进行迭代 ```java Iterator it = map.keySet().iterator(); while(it.hasNext()){ String key = it.next(); String value = map.get(key); System.out.println(key+"--"+value); } ``` ### 使用 entrySet 进行迭代 ```java Iterator> it = map.entrySet().iterator(); System.out.println(map.entrySet().size()); while(it.hasNext()){ Map.Entry entry = it.next(); String key = entry.getKey(); String value = entry.getValue(); System.out.println(key+"===="+value); } ``` 每种方法都有其适用的场景,根据实际需求选择合适的遍历方式可以提高代码效率和可读性。例如,在需要频繁访问 `entry` 对象时,直接使用 `for-each` 循环是最简单且高效的;如果对性能有较高要求,则可以通过提前获取 `Iterator` 对象来减少创建实例的开销。 ## 使用 `SimpleDateFormat` 进行日期和字符串转换 在处理日期时,Java 提供了强大的 API 来进行格式化操作。下面的例子展示了如何将 `Date` 对象转化为指定格式的字符串: ### 字符串转日期 ```java public static Date stringToDate(String dateStr) throws ParseException { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); return sdf.parse(dateStr); } ``` 给定一个日期时间字符串(例如 "2002-10-8 15:30:22"),可以使用 `SimpleDateFormat` 的 `parse` 方法将其解析为 `Date` 对象: ```java try { Date date = sdf.parse("2002-10-8 15:30:22"); } catch (ParseException e) { // 处理异常情况 } ``` ### 日期转字符串 ```java public static String dateToString(Date time){ SimpleDateFormat formatter; formatter = new SimpleDateFormat ("yyyy-MM-dd HH:mm:ss"); return formatter.format(time); } ``` 要将当前时间格式化为字符串,可以使用 `SimpleDateFormat` 的 `format` 方法: ```java String datestr = sdf.format(new Date()); // 输出类似:2002-10-08 14:55:38 ``` ## Math 类常用方法 Java 提供了丰富的数学计算工具类,即 `Math`。以下是一个简单的示例: ```java class MathTest { public static void main(String[] args) { System.out.println("ceil 向上取整 " + Math.ceil(11.2)); System.out.println("floor 向下取整 " + Math.floor(11.8)); System.out.println("rint 四舍五入取浮点 " + Math.rint(-11.1)); System.out.println("round 四舍五入取整 " + Math.round(-11.1)); } } ``` 输出结果如下: - `ceil` 方法将数值向上取整,即向正无穷方向取最近的整数; - `floor` 方法将数值向下取整,即向负无穷方向取最近的整数; - `rint` 返回最接近参数的整数值的双精度浮点数; - `round` 将参数四舍五入到最邻近的整数。 ## 单例模式实现 单例模式是一种常用的软件设计模式,确保一个类只有一个实例,并提供全局访问点。以下是几种常见的实现方式: ### 懒汉式(线程不安全) ```java public class Singleton { private static Singleton instance; private Singleton() {} public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } } ``` 这种方式在多线程环境中可以正常工作,但效率较低。99%的情况下不需要同步。 ### 饿汉式 ```java public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } } ``` 此方式利用类加载机制避免了多线程问题,但在加载时会立即创建实例。 ### 静态内部类实现 ```java public class Singleton { private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } private Singleton() {} public static final Singleton getInstance() { return SingletonHolder.INSTANCE; } } ``` 这种方式使用了 `classloader` 机制确保线程安全,并且在需要时才创建实例。 ### 枚举实现 ```java public enum Singleton { INSTANCE; public void whateverMethod() {} } ``` 枚举单例模式是高效且线程安全的,但可能会让人感觉生疏,不常用。 ### 双重检查锁实现(JDK 1.5+) ```java public class Singleton { private volatile static Singleton singleton; private Singleton() {} public static Singleton getSingleton() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } } ``` 这种方式结合了延迟加载和线程安全的优点,但在某些情况下可能因 `volatile` 而影响性能。 ## 注意事项 1. **类装载器**:如果单例由不同的类装载器装入,则有可能创建多个实例。例如,在一些 Servlet 容器中对每个 Servlet 使用完全不同的类装载器。 2. **序列化与反序列化**: - 如果实现了 `Serializable` 接口,可以添加一个 `readResolve()` 方法来确保单例模式在反序列化后仍只有一个实例。 ### 修复示例 ```java private static Class getClass(String classname) throws ClassNotFoundException { ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); if (classLoader == null) classLoader = Singleton.class.getClassLoader(); return classLoader.loadClass(classname); } ``` ## [Java基础:一些常用代码片二](https://blog.dong4j.site/posts/1bdd319f.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 在 Java 开发中,我们经常需要进行字符串的拼接和处理集合中的元素。此外,在某些特定场景下,如性能监控或代码调试时,也需要查看 GC 日志以及字节码信息。本文将详细介绍这些常用技术及其最佳实践。 ## 字符串拼接方法 ### 使用 `org.apache.commons.lang.StringUtils` 拼接 ```java import org.apache.commons.lang3.StringUtils; String result = StringUtils.join(array, "-"); ``` 该方法使用指定的分隔符(如这里的'-')将数组中的元素连接成一个字符串。 ### 使用 Google Guava 的 `Joiner` 类 ```java import com.google.common.base.Joiner; String result = Joiner.on('-').join(array); ``` Guava 库提供的`Joiner`类可以更加灵活地拼接字符串,支持多种分隔符和自定义转换函数。 ## 获取集合中的第一个元素 ### 使用 `Iterables.getOnlyElement` 方法 ```java import com.google.common.collect.Iterables; Object first = Iterables.getOnlyElement(collection); ``` 该方法用于确保集合中只有一个元素时获取该元素,如果多个或为空则会抛出异常。适用于确认集合内容的情况下。 ### 获取第一个元素并允许指定默认值 ```java Object firstOrDefault = Iterables.getFirst(collection, defaultValue); ``` `Iterables.getFirst` 方法允许在集合为空时返回一个默认值,增加了代码的灵活性和健壮性。 ### 直接从迭代器中获取第一个元素 ```java Object first = collection.iterator().next(); ``` 直接使用`iterator()`并调用`next()`是最基本的方法。适用于快速而简单的场景。 ## 查看 GC 日志 为了调试应用程序中的内存问题,可以通过以下参数启动 Java 虚拟机(JVM)以查看详细的垃圾收集(GC)活动: ```bash java -XX:+PrintGCDetails -jar yourApp.jar ``` 此命令会输出每次 GC 事件的详细信息。 ## 查看字节码 为了更好地理解类的工作原理或者用于反编译目的,可以使用`javap`工具查看类文件的内容。例如: ```bash javap -c class_path ``` ## 集合操作:并集、交集和差集等 ### 示例代码展示集合的各种操作 ```java public static void main(String[] args) { List list1 = new ArrayList<>(); list1.add("A"); list1.add("B"); list1.add("C"); List list2 = new ArrayList<>(); list2.add("C"); list2.add("B"); list2.add("D"); // 并集 List unionList = new ArrayList<>(list1); unionList.addAll(list2); // 去重复并集 Set tempSet = new HashSet<>(); tempSet.addAll(unionList); unionList.clear(); unionList.addAll(tempSet); System.out.println("去重后的并集: " + unionList); // 交集 List intersectionList = new ArrayList<>(list1); intersectionList.retainAll(list2); System.out.println("交集: " + intersectionList); // 差集 List diffList = new ArrayList<>(list1); diffList.removeAll(list2); System.out.println("差集: " + diffList); } ``` ## 通过枚举属性获取枚举实例 ### 枚举示例代码展示 ```java enum Gender { MALE(0), FEMALE(1); private int code; private static final Map MAP = new HashMap<>(); static { for (Gender item : Gender.values()) { MAP.put(item.code, item); } } public int getCode() { return code; } private Gender(int code) { this.code = code; } } public class EnumUtil { @SuppressWarnings("unchecked") public static > E getByCode(Class enumClass, int code) { try { for (E e : enumClass.getEnumConstants()) { Field field = e.getClass().getField("code"); Object value = field.get(e); if (value.equals(code)) { return e; } } } catch (Exception ex) { throw new RuntimeException("根据code获取枚举实例异常" + enumClass.getName() + " code:" + code, ex); } return null; } public static void main(String[] args) { System.out.println(EnumUtil.getByCode(Gender.class, 0)); } } ``` ## 国际化处理 ### 使用 .properties 文件实现国际化 在 Java 程序中,我们可以通过创建和使用`.properties`文件来支持多种语言的国际化的操作。这些文件通常包含键值对的形式存储文本信息,而当需要特定语言版本时,则加载相应的`.properties`文件。 #### `.properties`文件中文字符与 Unicode 字符之间的转换 - 使用`native2ascii.exe`工具可以将`.properties`文件中的中文字符转为 Unicode 格式。 ```bash native2ascii resources.properties tmp.properties 或者 native2ascii -encoding Unicode resources.properties tmp.properties ``` - 若需要再将其转化为中文,使用如下命令: ```bash native2ascii -reverse -encoding GB2312 resources.properties tmp.properties ``` ## `switch` vs. `if-else` ### 性能比较 在 Java 中,`switch-case`结构常用于多分支选择。它与`if-else`语句在功能上有一定的重叠。然而,在实际应用时,两者在效率上存在区别: 1. **二叉树算法**:`switch`使用了 Binary Tree 算法而`if-else`顺序比较。 2. **跳转表生成**:对于多个分支的场景下(大于等于四个),`switch`会创建一个最大 case 常量+1 大小的 jump table,这比简单的线性查找效率更高。 ## 反射与注解 ### 获取方法上的注解 从 Java 7 开始,可以通过反射机制获取类上或方法上的注解信息。下面展示了在 JDK 版本为 7 和 8 时的不同代码实现: ```java // JDK7方式: RequestMapping methodDeclaredAnnotation = method.getAnnotation(RequestMapping.class); // JDK8方式: RequestMapping methodDeclaredAnnotation = method.getDeclaredAnnotation(RequestMapping.class); ``` ## 阅读文件中的内容 使用 Java 标准库可以轻松从资源或文件中读取数据,例如: ```java InputStream stream = this.getClass().getClassLoader().getResourceAsStream("test.md"); BufferedReader reader = new BufferedReader(new InputStreamReader(stream, "utf-8")); List list = reader.lines().collect(Collectors.toList()); String content = Joiner.on("\n").join(list); ``` ## Java 枚举类的特性 ### 为什么`Enum`不能被继承? 在 Java 中,当一个类使用`enum`关键字定义后,默认会继承`java.lang.Enum`。这意味着该枚举类型是不可再扩展的,并且会被编译器标记为 final 以防止任何子类型的创建。 ## Timer 类高级用法 ### 六种执行任务的方法 ```java import java.util.Calendar; import java.util.Date; import java.util.Timer; import java.util.TimerTask; public class TimerUtil { public static void main(String[] args) throws Exception { System.out.println(new Date().getTime() + "-------开始定时任务--------"); timer1(); timer2(); timer3(); timer4(); timer5(); timer6(); } // 第一种方法:指定任务task在指定时间time执行 // schedule(TimerTask task, Date time) public static void timer1() { Calendar calendar = Calendar.getInstance(); calendar.set(Calendar.HOUR_OF_DAY, 0); // 控制时 calendar.set(Calendar.MINUTE, 0); // 控制分 calendar.set(Calendar.SECOND, 0); // 控制秒 Date time = calendar.getTime(); // 得出执行任务的时间,此处为今天的00:00:00 Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { System.out.println(new Date().getTime() + "-------定时任务1--------"); } }, time); } // 第二种方法:指定任务task在指定延迟delay后执行 // schedule(TimerTask task, long delay) public static void timer2() { Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { System.out.println(new Date().getTime() + "-------定时任务2--------"); } }, 2000); // 指定延迟2000毫秒后执行 } // 第三种方法:指定任务task在指定时间firstTime执行后,进行重复固定延迟频率period的执行 // schedule(TimerTask task, Date firstTime, long period) public static void timer3() { Calendar calendar = Calendar.getInstance(); calendar.set(Calendar.HOUR_OF_DAY, 0); // 控制时 calendar.set(Calendar.MINUTE, 0); // 控制分 calendar.set(Calendar.SECOND, 0); // 控制秒 Date time = calendar.getTime(); // 得出执行任务的时间,此处为今天的00:00:00 Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { System.out.println(new Date().getTime() + "-------定时任务3--------"); } }, time, 1000 * 60 * 60 * 24); } // 第四种方法:指定任务task在指定延迟delay后,进行重复固定延迟频率period的执行 // schedule(TimerTask task, long delay, long period) public static void timer4() { Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { System.out.println(new Date().getTime() + "-------定时任务4--------"); } }, 1000, 5000); } // 第五种方法:指定任务task在指定时间firstTime执行后,进行重复固定延迟频率period的执行 // scheduleAtFixedRate(TimerTask task, Date firstTime, long period) public static void timer5() { Calendar calendar = Calendar.getInstance(); calendar.set(Calendar.HOUR_OF_DAY, 0); // 控制时 calendar.set(Calendar.MINUTE, 0); // 控制分 calendar.set(Calendar.SECOND, 0); // 控制秒 Date time = calendar.getTime(); // 得出执行任务的时间,此处为今天的00:00:00 Timer timer = new Timer(); timer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { System.out.println(new Date().getTime() + "-------定时任务5--------"); } }, time, 1000 * 60 * 60 * 24); } // 第六种方法:指定任务task在指定延迟delay后,进行重复固定延迟频率period的执行 // scheduleAtFixedRate(TimerTask task, long delay, long period) public static void timer6() { Timer timer = new Timer(); timer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { System.out.println(new Date().getTime() + "-------定时任务6--------"); } }, 1000, 2000); } } ``` ## Cron 表达式详解 Cron 表达式的格式由至少 6 个部分组成,每一部分使用空格分隔: 1. 秒 (0-59) 2. 分钟 (0-59) 3. 小时 (0-23) 4. 日期 (0-31) 5. 月份 (0-11 或 JAN-DEC) 6. 星期几 (0-7 或 SUN-SAT,其中 0 和 7 都表示周日) ### 常用实例 #### 每隔若干时间执行 - 每隔 5 秒:`*/5 * * * * ?` - 每隔 1 分钟:`0 */1 * * * ?` #### 定时任务 - 每天 23 点执行:`0 0 23 * * ?` - 每日凌晨 1 点执行:`0 0 1 * * ?` - 每月 1 号凌晨 1 点执行:`0 0 1 1 * ?` #### 复杂任务调度 - 每天的 0 点、13 点、18 点和 21 点触发一次任务:`0 0 0,13,18,21 * * ?` - 在每天上午 10:15 执行:`0 15 10 * * ?` ### Cron 表达式高级用法 - 每月最后一天晚上 11 点触发一次任务:`0 0 23 L * ?` - 每年的某个特定星期几(如每周三)的某时执行:`0 0 12 ? * WED` ### 特殊字符的意义 - `*`: 所有可能值。 - `,`: 分隔列表中的多个值。 - `-`: 表示区间,如 `6-8/2` 表示在第 6、8 分钟时执行任务。 - `/`: 指定增量步长。 ## 解决`java.lang.OutOfMemoryError: PermGen space` ### 常见的解决方案 ```bash -server -XX:PermSize=256M -XX:MaxPermSize=512m ``` 这行命令可以通过调整永久代(Permanent Generation)的空间大小来解决内存溢出问题。 ## 使用 CXF 生成客户端代码 Apache CXF 提供了 wsdl2java 工具用于从 WSDL 文件自动生成 Java 客户端代码。其基本使用方法如下: ```bash /opt/apache-cxf-3.2.5/bin/wsdl2java -client -encoding utf-8 'http://172.16.4.207/iavpwebservice/CallOut.asmx?wsdl' ``` ## JDK 8 中推荐使用的日期处理类 JDK 8 引入了新的日期和时间 API,包括`Instant`, `LocalDateTime`, 和 `DateTimeFormatter`等。这些新工具比旧的`Date`和`Calendar`更强大且易于使用。 ### 示例代码 ```java // 得到小时 private int getHour(Date date) { Calendar calendar = Calendar.getInstance(); calendar.setTime(date); return calendar.get(Calendar.HOUR_OF_DAY); } // 得到分钟 private int getMinute(Date date) { Calendar calendar = Calendar.getInstance(); calendar.setTime(date); return calendar.get(Calendar.MINUTE); } // 设置小时 private Date setHour(int hour, Date date) { Calendar calendar = Calendar.getInstance(); calendar.setTime(date); calendar.set(Calendar.HOUR_OF_DAY, hour); return calendar.getTime(); } // 设置分钟 private Date setMinute(int minute, Date date) { Calendar calendar = Calendar.getInstance(); calendar.setTime(date); calendar.set(Calendar.MINUTE, minute); return calendar.getTime(); } ``` ## 打印 GC 详情 为了能够获得详细的 GC 信息,包括每次垃圾回收的时间、持续时间和应用程序暂停时间等细节数据,可以在启动 JVM 时添加以下选项: ```bash java -XX:+PrintGCTimeStamps -XX:-PrintClassHistogram -XX:+PrintHeapAtGC -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCApplicationStoppedTime -XX:+HeapDumpOnOutOfMemoryError ``` 这些选项的作用分别是: 1. `-XX:+PrintGCTimeStamps` - 每次垃圾回收都记录时间戳; 2. `-XX:-PrintClassHistogram` - 打印类的直方图(通常在 Full GC 后打印); 3. `-XX:+PrintHeapAtGC` - 在每次垃圾回收之前输出堆信息; 4. `-verbose:gc` - 输出简要的 GC 日志信息; 5. `-XX:+PrintGCDetails` - 打印详细的 GC 日志。 ## 移除字符串中的所有空格 在处理文本数据时,经常需要去除不必要的空白字符。以下是几种有效的移除方法: ### 使用 Apache Commons Lang 库 最简单直接的方法是使用第三方库如 Apache Commons Lang 的`StringUtils.deleteWhitespace()`函数。 ```java import org.apache.commons.lang3.StringUtils; public class StringProcessor { public static void main(String[] args) { System.out.println(StringUtils.deleteWhitespace(" a b c d ")); } } ``` ### 手动实现去除所有空格 1. 使用正则表达式替换: ```java public class MainClass { public static void main(String[] args) { String str = " hell o "; System.out.println(str.replaceAll("\\s+", "")); } } ``` 2. 遍历字符串中的每个字符,并使用`StringBuilder`构建新字符串: ```java public class MainClass { public static void main(String[] args) { String str = " a b c d "; StringBuilder sb = new StringBuilder(); for (int i = 0; i < str.length(); i++) { if (!Character.isWhitespace(str.charAt(i))) { sb.append(str.charAt(i)); } } System.out.println(sb.toString()); } } ``` 3. 使用`replaceAll()`方法处理连续的空格: ```java public class MainClass { public static void main(String[] args) { String str = " hell o "; str = str.replaceAll(" +", ""); System.out.println(str); } } ``` ## 遍历 Properties 对象 `Properties`类提供了多种遍历其内部数据的方式,以下是一个简单的示例: ```java public static void printProp(Properties properties) { System.out.println("---------(方式一)------------"); for (String key : properties.stringPropertyNames()) { System.out.println(key + "=" + properties.getProperty(key)); } System.out.println("---------(方式二)------------"); Set keys = properties.keySet(); for (Object key : keys) { System.out.println(key.toString() + "=" + properties.get(key)); } System.out.println("---------(方式三)------------"); Set> entrySet = properties.entrySet(); for (Map.Entry entry : entrySet) { System.out.println(entry.getKey() + "=" + entry.getValue()); } System.out.println("---------(方式四)------------"); Enumeration e = properties.propertyNames(); while (e.hasMoreElements()) { String key = (String) e.nextElement(); String value = properties.getProperty(key); System.out.println(key + "=" + value); } } ``` ## [Java 基础:一些常见的代码片段一](https://blog.dong4j.site/posts/205d1f36.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 本文将详细介绍几种常见的 Java 编程技术及其相关知识点,并提供一些最佳实践和注意事项。这些内容包括面向切面编程(AOP)、使用 Jackson 进行泛型反序列化、单次执行逻辑的实现方法,以及防止主线程退出等。 ## 面向切面编程 (Aspect-Oriented Programming, AOP) ### 代码片段展示 ```java try { try { doBefore(); // 对应@Before注解的方法切面逻辑 method.invoke(); } finally { doAfter(); //对应@After注解的方法切面逻辑 } doAfterReturning(); //对应@AfterReturning注解的方法切面逻辑 } catch (Exception e) { doAfterThrowing(); // 对应@AfterThrowing注解的方法切面逻辑 } ``` ### 解释与注意事项 AOP 是一种编程范式,用于将横切关注点(如日志、事务处理等)从核心业务逻辑中分离出来。上述代码展示了如何通过 try-catch-finally 结构来模拟 AOP 中的通知执行顺序。 - **最佳实践**: - 确保`doBefore()`方法在主要业务逻辑开始之前被调用。 - `doAfterReturning()`应在业务逻辑成功返回时被触发,表示正常结束。 - `catch`块内的`doAfterThrowing()`用于捕获并处理任何异常。 - **注意事项**: - 考虑到代码可维护性与扩展性,在实际应用中应使用 Spring AOP 框架或其他成熟的 AOP 工具库来实现这些逻辑,而不是手动复制粘贴上述结构。 ## 使用 Jackson 进行泛型反序列化 ### 代码片段展示 ```java CollectionType javaType = mapper.getTypeFactory().constructCollectionType(List.class, MyDto.class); List asList = mapper.readValue(jsonArray, javaType); ``` ### 解释与注意事项 当需要将 JSON 字符串解析为带有泛型类型的列表时,Jackson 库提供了一种简便的方法。 - **最佳实践**: - 使用`constructCollectionType()`方法来指定具体的集合类型及元素类型。 - 尽可能保持序列化和反序列化的数据结构一致,以避免出现不必要的错误或不兼容问题。 ## 单次执行逻辑的实现 ### 代码片段展示 ```java AtomicBoolean WARNED_TOO_MANY_INSTANCES = new AtomicBoolean(); if(WARNED_TOO_MANY_INSTANCES.compareAndSet(false, true)){ xxxx //具体操作逻辑 } ``` ### 解释与注意事项 此段代码使用了`AtomicBoolean`类来确保某段特定的执行逻辑在整个程序生命周期中仅被执行一次。 - **最佳实践**: - 使用原子性数据类型如`AtomicBoolean`可以有效避免多线程环境下的竞态条件问题。 ## 分割字符串为迭代器 ### 代码片段展示 ```java Splitter.on(",").trimResults().omitEmptyStrings().split(str) ``` - **最佳实践**: - 使用 Guava 库的`Splitter`类可以更灵活地处理复杂的文本分割任务,支持多种配置选项(如忽略空字符串、修剪结果等)。 ## Slf4j 占位符格式化 ### 代码片段展示 ```java FormattingTuple ft = MessageFormatter.arrayFormat(appendLogPattern, appendLogArguments); ``` - **最佳实践**: - 使用`MessageFormatter`类可以避免硬编码字符串中的占位符,从而提高日志信息的可读性和易管理性。 ## 防止 Java 主线程退出 ### 实现代码展示 ```java public class StartMain { private static final ReentrantLock LOCK = new ReentrantLock(); private static final Condition STOP = LOCK.newCondition(); public static void main(String[] args) { AbstractApplicationContext applicationContext = new ClassPathXmlApplicationContext("applicationContext.xml"); applicationContext.start(); logger.info("service start success !~"); addHook(applicationContext); try { LOCK.lock(); STOP.await(); } catch (InterruptedException e) { logger.warn(" service stopped, interrupted by other thread!", e); } finally { LOCK.unlock(); } } private static void addHook(AbstractApplicationContext applicationContext) { Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { applicationContext.stop(); } catch (Exception e) { logger.error("StartMain stop exception ", e); } logger.info("jvm exit, all service stopped."); LOCK.lock(); STOP.signal(); }, "StartMain-shutdown-hook")); } } ``` - **最佳实践**: - 在应用程序的入口点注册一个`shutdown hook`,确保在 JVM 退出前能够正常关闭服务。 ## BeanDefinitionRegistryPostProcessor 的应用 ### 示例代码展示 ```java @Component public class RegistryDemo implements BeanDefinitionRegistryPostProcessor { @Override public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry beanDefinitionRegistry) throws BeansException { GenericBeanDefinition definition = new GenericBeanDefinition(); definition.setBeanClass(Demo.class); //设置类 definition.setScope("singleton"); //设置scope definition.setLazyInit(false); //设置是否懒加载 definition.setAutowireCandidate(true); //设置是否可以被其他对象自动注入 beanDefinitionRegistry.registerBeanDefinition("demo", definition); } } ``` - **最佳实践**: - 使用`BeanDefinitionRegistryPostProcessor`可以在 Spring 容器启动之前对 bean 的定义进行自定义修改。 ## instanceof, isInstance, isAssignableFrom 的区别 ### 解释与实例分析 ```java String s = new String("javaisland"); // true,自身实例或子类实例 instanceof 自身类 System.out.println(s instanceof String); // 使用Class类的isInstance(Object obj)方法进行测试。 // 正确的测试应为:自身类.class.isInstance(自身实例或子类实例) String s = new String("javaisland"); System.out.println(String.class.isInstance(s)); // true // Class 类的 isAssignableFrom (Class cls) 方法 System.out.println(ArrayList.class.isAssignableFrom(Object.class)); // false System.out.println(Object.class.isAssignableFrom(ArrayList.class)); // true ``` - **最佳实践**: - 在编程中,根据具体的使用场景选择合适的方法来检测类型关系。对于对象实例的直接判断,`instanceof`运算符通常是最简单直接的选择。 ## Dubbo 百分数与 Double 互转 ### 百分数转 Double ```java // import java.text.NumberFormat; // import java.text.ParseException; try { // 接口返回的是Number对象,但是实际是Double类型 Double num = (Double) NumberFormat.getInstance().parse("67.89%"); // 转换的结果是67.89 // 使用 getPercentInstance() 方法来正确解析百分比字符串为Double类型的值,结果会转换成真正的百分比值,即0.6789 Double num2 = (Double) NumberFormat.getPercentInstance().parse("67.89%"); // 转换的结果是0.6789 System.out.println(num); } catch (ParseException e) { e.printStackTrace(); } ``` ### Double 转百分数 ```java // import java.text.NumberFormat; try { NumberFormat percentInstance = NumberFormat.getPercentInstance(); // 创建一个格式化为百分比的NumberFormatter对象 percentInstance.setMaximumFractionDigits(2); // 设置最多保留两位小数 String formattedValue = percentInstance.format(0.81247); // 将Double类型转换为包含两位小数的字符串,例如"81.25%" System.out.println(formattedValue); } catch (Exception e) { // 这里不需要捕获ParseException,因为format方法不会抛出该异常 } ``` ## Lombok 注解详解 Lombok 是一个强大的 Java 库,用于减少样板代码。 - `@val`: 类似于 final 的变量声明。 - `@var`: 与 JDK10 中的`var`关键字类似。 - `@Data`: 自动为类生成`get`/`set`方法,并且实现`equals`、`hashCode`和`toString`等方法。 - `@Setter`, `@Getter`: 分别用于自动生成所有或特定字段的 setter/getter 方法。 - `@NotNull`: 指定参数不能为 null,否则抛出异常。 - `@Synchronized`: 对于指定的方法添加同步控制。 - `@Builder`: 使用 builder 模式来创建对象实例。 - `@NoArgsConstructor`, `@AllArgsConstructor`: 分别提供无参构造器和全参构造器。 - `@Accessors(chain = true)`: 使得 setter 方法返回当前对象本身,用于链式调用。 - `@RequiredArgsConstructor`: 为类生成一个带有所有 final 字段或标记为`@NonNull`的构造函数。 - `@UtilityClass`, `@ExtensionMethod`, `@FieldDefaults`: 提供工具类、扩展父类和设置属性的访问级别等特性。 - `@Cleanup`: 确保在使用完流资源后自动关闭它们。 ### 注意事项 当使用 Lombok 的 `Builder` 注解时,如果想要为某个字段指定默认值,并且希望这个默认值不会被清除,则需要额外添加 `@lombok.Builder.Default` 注解到该属性上。这允许你定义一个初始值,即使在未提供特定构造参数的情况下也能保持其有效性。 ## 使用 Lombok @Builder 导致默认值无效 ```java // 示例代码展示如何正确使用 @Builder 和 @lombok.Builder.Default 来确保默认值不被清除。 public class User { private String name; private int age; // 使用 Lombok 的 Builder 注解来创建用户对象 @Builder public User(String name, int age) { this.name = name; this.age = age; } // 为 'age' 字段添加默认值,同时使用 @lombok.Builder.Default 确保其不会在构建时被清除。 @Builder.Default private int defaultAge = 20; // 设置一个默认年龄 public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } } ``` ## Optional 类的使用 `Optional`类提供了处理可能为空的对象的方式,避免了空指针异常。 ```java public class Java8Tester { public static void main(String args[]){ Java8Tester java8Tester = new Java8Tester(); // 传入的两个参数值分别是 null 和一个整数实例化后的对象 Integer value1 = null; Integer value2 = new Integer(10); // Optional.ofNullable - 允许传递为 null 参数,用于创建Optional实例。 Optional a = Optional.ofNullable(value1); // Optional.of - 如果传递的参数是null, 抛出异常 NullPointerException Optional b = Optional.of(value2); System.out.println(java8Tester.sum(a,b)); } public Integer sum(Optional a, Optional b){ boolean valueExistsA = a.isPresent(); // 判断值是否存在 boolean valueExistsB = b.isPresent(); System.out.println(" 第一个参数值存在: " + valueExistsA); System.out.println(" 第二个参数值存在: " + valueExistsB); Integer valA = a.orElse(new Integer(0)); // 如果值存在,返回它;否则使用默认值。 Integer valB = b.get(); // 获取值, 值需要存在 return valA + valB; } } ``` ## Spring 读取 Classpath 下的文件 ### 方法一:利用 `this.getClass().getResource()` 或 `this.getClass().getResourceAsStream()` 这两种方式适用于从当前类资源路径中获取指定文件或流对象。 ```java // 获取文件或流,注意"/"开头代表根目录下的位置 String path = this.getClass().getResource("/"+fileName).getPath(); InputStream inputStream = this.getClass().getResourceAsStream(failName); // 注意变量名拼写错误,应该是 fileName ``` ### 方法二:通过 `org.springframework.util.ResourceUtils.getFile()` 此方法返回一个`File`对象,方便文件操作。 ```java // 获取资源路径下的文件 File file = org.springframework.util.ResourceUtils.getFile("classpath:test.txt"); ``` ### 方法三:使用 `ClassPathResource` 类 这个类提供了更强大的功能去访问 classpath 中的资源,并且能够直接获取到文件或输入流。 ```java import org.springframework.core.io.ClassPathResource; // 获取资源路径下的文件 ClassPathResource classPathResource = new ClassPathResource("test.txt"); File file = classPathResource.getFile(); InputStream inputStream = classPathResource.getInputStream(); ``` ### 特别注意:读取 Jar 包中的文件 如果你的文件被存放在 jar 包中,可以通过类加载器来获取资源。 ```java // 通过当前线程的上下文类装载器获取资源 InputStream io = Thread.currentThread().getContextClassLoader().getResourceAsStream("test.txt"); // 或者使用当前类的类加载器 InputStream io = getClass().getClassLoader().getResourceAsStream("test.txt"); ``` ## BigDecimal 的基本操作与运算 `BigDecimal` 是 Java 中用于精确浮点数计算的重要工具,特别适合于金融或科学计算等领域。它提供了丰富的构造方法和操作方法。 ### 常见的构造器 - `BigDecimal(int)`:创建一个具有指定整数值的对象。 - `BigDecimal(double)`:使用 double 类型参数初始化对象。(注意,double 转换为 BigDecimal 时会丢失精度) - `BigDecimal(long)`:创建一个具有指定长整数值的对象。 - `BigDecimal(String)`:基于字符串表示的值来构造。 ### 常见的方法 - `add(BigDecimal)`: 合并两个 BigDecimal 对象的值,返回一个新的对象。 - `subtract(BigDecimal)`: 从第一个参数中减去第二个参数的值,得到的结果存储在新的对象中。 - `multiply(BigDecimal)`: 将两个参数相乘后的结果放在一个新对象里返回。 - `divide(BigDecimal)`: 返回除法运算后的新 BigDecimal 对象。需要特别注意的是,这个方法会抛出 ArithmeticException 异常,除非调用者指定了适当的舍入模式。 - `toString()`: 把 BigDecimal 转换为 String 类型的表示形式。 - `doubleValue()`, `floatValue()`, `longValue()`, `intValue()`:分别将值转换成 double, float, long 和 int 型数据。 ### 精确浮点数运算工具类 下面是一个实现精确算术操作,包括加法、减法、乘法以及除法(可指定精度)的示例。 ```java import java.math.BigDecimal; /** * 提供精确的浮点数运算, 包括加减乘除及四舍五入等。 */ public class ArithUtil { // 默认的除法运算结果保留10位小数 private static final int DEF_DIV_SCALE = 10; private ArithUtil() {} /** * 加法运算 * * @param v1 被加数 * @param v2 加数 * @return 和值 */ public static double add(double v1, double v2) { BigDecimal b1 = new BigDecimal(Double.toString(v1)); BigDecimal b2 = new BigDecimal(Double.toString(v2)); return b1.add(b2).doubleValue(); } /** * 减法运算 * * @param v1 被减数 * @param v2 减数 * @return 差值 */ public static double sub(double v1, double v2) { BigDecimal b1 = new BigDecimal(Double.toString(v1)); BigDecimal b2 = new BigDecimal(Double.toString(v2)); return b1.subtract(b2).doubleValue(); } /** * 乘法运算 * * @param v1 被乘数 * @param v2 乘数 * @return 积 */ public static double mul(double v1, double v2) { BigDecimal b1 = new BigDecimal(Double.toString(v1)); BigDecimal b2 = new BigDecimal(Double.toString(v2)); return b1.multiply(b2).doubleValue(); } /** * 除法运算,返回商,保留默认的小数位 * * @param v1 被除数 * @param v2 除数 * @return 商值(四舍五入) */ public static double div(double v1, double v2) { return div(v1, v2, DEF_DIV_SCALE); } /** * 除法运算,返回商,根据指定的小数位数进行四舍五入 * * @param v1 被除数 * @param v2 除数 * @param scale 精度(保留几位小数) * @return 商值(四舍五入) */ public static double div(double v1, double v2, int scale) { if (scale < 0) throw new IllegalArgumentException("The scale must be a positive integer or zero"); BigDecimal b1 = new BigDecimal(Double.toString(v1)); BigDecimal b2 = new BigDecimal(Double.toString(v2)); return b1.divide(b2, scale, BigDecimal.ROUND_HALF_UP).doubleValue(); } /** * 四舍五入,保留指定的小数位 * * @param v 需要四舍五入的数字 * @param scale 小数点后保留几位 * @return 修约后的结果 */ public static double round(double v, int scale) { if (scale < 0) throw new IllegalArgumentException("The scale must be a positive integer or zero"); BigDecimal b = new BigDecimal(Double.toString(v)); return b.setScale(scale, BigDecimal.ROUND_HALF_UP).doubleValue(); } } ``` ### 注意事项 - `BigDecimal` 的构造函数不接受 `float` 或 `double` 类型,因为这些类型通常无法准确表示十进制数。 - 对于除法运算,务必指定合适的舍入模式(如 ROUND_HALF_UP)来避免出现异常或获得意外结果。 ## [探索 Java 8 Stream API:从排序到过滤的全面指南](https://blog.dong4j.site/posts/845cfa4f.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Java 8 引进了强大的 Stream API,使得集合操作更加简洁优雅。本文将详细介绍如何使用 Java 8 的 Stream API 进行 List 转 Map、List 排序、列表过滤及 Map 中的条件操作。 ## 使用 Java 8 Lambda 将 list 转为 map ### 常用方式 要将一个 List 转换为 Map,我们可以利用 `Collectors.toMap()` 方法。例如: ```java public Map getIdNameMap(List accounts) { return accounts.stream().collect(Collectors.toMap(Account::getId, Account::getUsername)); } ``` ### 收集成实体本身 map 有时候我们可能希望将列表中的对象直接映射到 Map 中作为值,可以这样做: ```java public Map getIdAccountMap(List accounts) { return accounts.stream().collect(Collectors.toMap(Account::getId, account -> account)); } ``` `account -> account` 是一个返回本身的 lambda 表达式。使用 `Function.identity()` 会使得代码更简洁: ```java public Map getIdAccountMap(List accounts) { return accounts.stream().collect(Collectors.toMap(Account::getId, Function.identity())); } ``` ### 处理重复 key 的情况 当需要根据可能有重复值(如 name)的字段来创建 map 时,可能会遇到 `java.lang.IllegalStateException: Duplicate key` 异常。为了处理这种情况,可以给 `toMap()` 方法传入一个合并函数: ```java public Map getNameAccountMap(List accounts) { return accounts.stream().collect(Collectors.toMap(Account::getUsername, Function.identity(), (key1, key2) -> key2)); } ``` 这里只是简单的使用后者覆盖前者。`toMap()` 还允许我们指定一个具体的 `Map` 实现类来收集数据: ```java public Map getNameAccountMap(List accounts) { return accounts.stream().collect(Collectors.toMap(Account::getUsername, Function.identity(), (key1, key2) -> key2, LinkedHashMap::new)); } ``` ## JDK 8 对 List 和 Map 的排序 ### Java 8 更新前的 List 排序操作 在使用 Java 8 之前,我们通常需要创建一个匿名内部类来实现排序: ```java Collections.sort(humans, new Comparator() { @Override public int compare(Human h1, Human h2) { return h1.getName().compareTo(h2.getName()); } }); ``` ### 使用 Lambda 表达式的 List 排序 Java 8 中,我们可以直接使用 lambda 表达式来定义比较器: ```java humans.sort((Human h1, Human h2) -> h1.getName().compareTo(h2.getName())); ``` 还可以进一步简化为: ```java humans.sort((h1, h2) -> h1.getName().compareTo(h2.getName())); ``` 或者使用静态方法引用: ```java humans.sort(Human::compareByNameThenAge); ``` 以及比较器提供的辅助方法: ```java Collections.sort(humans, Comparator.comparing(Human::getName)); ``` ### 反序排序 Java 8 提供了 `Comparator.reversed()` 方法来实现反向排序: ```java humans.sort(comparator.reversed()); ``` ### 使用多个条件进行排序 可以使用链式操作来进行多级排序: ```java humans.sort(Comparator.comparing(Human::getName).thenComparing(Human::getAge)); ``` ## 利用 Stream API 进行 List 的过滤和 map 操作 ### 列表的过滤处理 使用 `stream().filter()` 可以方便地对列表进行过滤: ```java List list2 = list1.stream().filter(s -> s != "1").collect(Collectors.toList()); ``` 输出结果是 `[2, 3]`。 ### List 转换为另一个 List 可以利用 `stream().map()` 来转换列表中的每个元素,然后收集到新的列表中: ```java List list2 = list1.stream().map(string -> "stream ().map () 处理之后:" + string).collect(Collectors.toList()); ``` ### 数据聚合 以下是一些利用 Stream 对列表中的用户对象 `User` 进行各种数据聚合的方法: - **求和**:`list.stream().mapToDouble(User::getHeight).sum()` 此方法返回用户集合中所有用户的身高总和。 - **最大值**:`list.stream().mapToDouble(User::getHeight).max()` 获取用户对象中的最高身高的用户,返回的是 Optional 类型。 - **最小值**:`list.stream().mapToDouble(User::getHeight).min()` 返回用户集合中最矮的用户,也是返回 Optional 类型。 - **平均值**:`list.stream().mapToDouble(User::getHeight).average()` 计算所有用户的身高平均值,并以 `Optional` 形式返回。 ### 列表拆分与合并 在 Java8 中,我们可以使用 Stream 的方法来完成字符串的拆分以及列表的合并。例如: - **分割**:`Stream.of(string.split(","))` 将原始字符串按逗号分割,并将结果转换为 List。 - **拼接**:`asList.stream().collect(Collectors.joining())` 可以把一个集合里的元素使用指定的符号连接起来,形成一个新的字符串。 ### 提取字段转换成列表 有几种方法可以用于提取 `appPermissionVoList` 列表中的用户 ID: 1. 使用 Stream 的方式: ```java List userIds = appPermissionVoList.stream() .map(appPermissionVo -> appPermissionVo.getUserId()) .collect(Collectors.toList()); ``` 2. 使用 Guava 库的 `Lists.transform()` 方法: ```java List usrIds = Lists.transform(appPermissionVoList,appPerm->appPerm.getUserId()); ``` ### 分组 分组操作是一种常见的需求。例如: ```java Map> groupBySex = userList.stream() .collect(Collectors.groupingBy(User::getSex)); ``` 此代码将根据用户性别进行列表的分组处理。 ## HashMap 的 `compute`, `computeIfAbsent` 及 `computeIfPresent` 这三者都是用来对映射中的元素进行修改。它们的区别在于: - `computeIfPresent(K key, BiFunction remappingFunction)` 仅当指定的键存在时才执行操作。 - `computeIfAbsent(K key, Function mappingFunction)` 当给定的键不存在于该映射中,则计算其对应的值,并将其放入该映射中,然后返回该值;否则直接返回当前值。 - `compute(K key, BiFunction remappingFunction)` 不管是新插入还是已存在的键都可以操作。 ```java Object key2 = map.computeIfAbsent("key", k -> new Object()); ``` ## 注意事项与最佳实践 1. **性能考虑**:尽管 Stream API 提供了极大的便利性,但过度使用可能会导致性能问题。因此,需要权衡代码的可读性和效率。 2. **空值检查**:在处理数据流时一定要注意可能出现的异常情况,特别是来自外部的数据源可能包含 null 值的情况,这可能导致 `NullPointerException` 异常。 3. **并行流的使用**: 对于大数据集,考虑使用并行流来加速计算。但要注意,并非所有场景都适合并行化处理。 ## [Zookeeper 概述:核心概念与应用场景](https://blog.dong4j.site/posts/5f2cc770.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Zookeeper 是 Google 的 Chubby 一个开源的实现,是 Hadoop 的分布式协调服务 自 2010 年 10 月升级成 Apache Software Foundation(ASF) 顶级项目 ### 分布式协调服务, 提供以下功能: 1. 组管理服务 2. 分布式配置服务 3. 分布式同步服务 4. 分布式命名服务 --- ## 谁在使用 Zookeeper **开源软件** - HBase 开源的非关系型分布式数据库 - Solr Apache Lucene 项目的开源企业搜索平台 - Storm 分布式计算框架 - Neo4j 高性能的,Nosql 图形数据库 - ... **公司** - Yahoo - LinkedIn - Twitter - Taobao - ... --- ## Zookeeper 架构 ![20241229154732_2Z8F9pu0.webp](https://cdn.dong4j.site/source/image/20241229154732_2Z8F9pu0.webp) --- - 客户端随机连接集群中的任何一台 server - 集群内所有的 server 基于 Zab(ZooKeeper Atomic Broadcast) 协议通信 - 集群内部根据算法自动选举出 leader, 负责向 follower 广播所有变化消息 - 集群中每个 follower 都和 leader 通信 - follower 接收来自 leader 的所有变化消息, 保存在自己的内存中 - follower 转发来自客户端的写请求给 leader - 客户端的读请求会在 follower 端直接处理, 无需转发给 leader --- ![20241229154732_SIC1xgXk.webp](https://cdn.dong4j.site/source/image/20241229154732_SIC1xgXk.webp) --- ![20241229154732_SIC1xgXk.webp](https://cdn.dong4j.site/source/image/20241229154732_SIC1xgXk.webp) --- ## Zookeeper 数据模型 - 基于树形结构的命名空间, 与文件系统类似 - 节点 (znode) 都可以存储数据, 可以有子节点 - 节点不支持重命名 - 数据大小不超过 1MB(可配置) - 数据读写保持完整性 ![20241229154732_a4CQ0fJe.webp](https://cdn.dong4j.site/source/image/20241229154732_a4CQ0fJe.webp) --- ## Zookeeper 基本 API ![20241229154732_nrNPSnH5.webp](https://cdn.dong4j.site/source/image/20241229154732_nrNPSnH5.webp) --- ## Znode 节点类型 - PERSISTENT - PERSISTENT_SEQUENTIAL - EPHEMERAL - EPHEMERAL_SEQUENTIAL ```java PERSISTENT(0, false, false), PERSISTENT_SEQUENTIAL(2, false, true), EPHEMERAL(1, true, false), EPHEMERAL_SEQUENTIAL(3, true, true); ``` --- ## Sequential/non-sequential 节点类型 - Non-sequential 节点不能有重名 - Sequential 节点 - 创建时可重名 - 实际生成节点名末尾自动添加一个 10 位长度, 左边以 0 填充的单递增数字 ![20241229154732_hNPyK838.webp](https://cdn.dong4j.site/source/image/20241229154732_hNPyK838.webp) --- ## Ephemeral/Persistent 节点类型 - Ephemeral 节点在客户端 session 结束或超时后自动删除 - Persistent 节点生命周期和 session 无关, 只能显式删除 ![20241229154732_guwkHMoJ.webp](https://cdn.dong4j.site/source/image/20241229154732_guwkHMoJ.webp) ![20241229154732_oUQNhGe8.webp](https://cdn.dong4j.site/source/image/20241229154732_oUQNhGe8.webp) **注意 **:EPHEMERAL 类型的目录节点不能有子节点目录 --- ## ZooKeeper Session - 客户端和 server 间采用长连接 - 连接建立后, server 生成 session id(64 位) 返还给客户端 - 客户端定期发送 ping 包来检查和保存和 server 的连接 - 一旦 session 结束或超时, 所有 ephemeral 节点会被删除 - 客户端可根据情况设置合适的 session 超时时间 --- ## Zookeeper Watcher - Watch 是客户端安装在 server 的事件监听方法 - 当监听的节点发生变化, server 将通知所有注册的客户端 - 客户端使用单线程对所有时间按顺序同步回调 - 触发回调条件: - 客户端连接, 断开连接 - 节点数据发生变化 - 节点本身发生变化 **注意:** 1. Watch 是单次的, 每次触发后会被自动删除 2. 如果需要再次监听时间, 必须重新安装 Watch --- ## Watch 的创建和触发规则 - 在读操作 exists、getChildren 和 getData 上可以设置观察,这些观察可以被写操作 create、delete 和 setData 触发
![20241229154732_kKfwednt.webp](https://cdn.dong4j.site/source/image/20241229154732_kKfwednt.webp) --- # 第二部分:Zooleeper 应用 - 分布式配置管理 - 高可用分布式集群 - 分布式队列 - 分布式锁 --- ## 分布式配置实现 - 将配置文件信息保存在 Server 某个目录节点中, 然后所有需要修改的应用及其监控配置信息的状态 - 一旦配置信息发生变化, 每台应用将会收到 server 的通知, 然后从 server 获取最新的配置信息到系统中 ![20241229154732_KUKMb9jl.webp](https://cdn.dong4j.site/source/image/20241229154732_KUKMb9jl.webp) --- ## 生产配置发布系统解决方案 ![20241229154732_MfI0NAzu.webp](https://cdn.dong4j.site/source/image/20241229154732_MfI0NAzu.webp) --- ## Zookeeper + Dubbo ### 高可用分布式集群方案 (下回分解) ![20241229154732_FW4WDGHW.webp](https://cdn.dong4j.site/source/image/20241229154732_FW4WDGHW.webp) --- ## 集群管理 ![20241229154732_e96bHff5.webp](https://cdn.dong4j.site/source/image/20241229154732_e96bHff5.webp) --- ## 数据库宕机检测方案 1. 使用定时任务监控 oracle 进程 (端口), 开机自启动; 2. 如果监测到有 oracle 进程,调用 monitor service, 退出定时任务; - monitor service 负责监听 oracle 端口; - monitor 依赖于 zookeeperclient service; - zookeeperclient service 负责连接 Zookeeper server 创建临时节点; --- 4. 当 oracle 进程退出时,monitor service 停止 zookeeperclient service - zookeeperclient service 与 server 断开后,server 自动删除临时节点; - monitor service 向管理员邮箱发送数据库宕机邮件, 写入日志; 5. 当 数据库服务器宕机时,zookeeperclient service 与 Server 断开连接,自动删除临时节点 --- ## 具体实现 #### zookeeperclient.sh ```shell #!/bin/bash # /etc/init.d/zookeeperclient.sh case "$1" in start) echo "Starting Zookeeper Client" python /home/master/zookeeperclient.py & ;; stop) echo "Stopping Zookeeper Client" #killall zookeeperclient.py kill $(ps aux | grep -m 1 'python /home/master/zookeeperclient.py' | awk '{ print $2 }') ;; *) echo "Usage: service Client start|stop" exit 1 ;; esac exit 0 ``` --- #### zookeeperclient.py ```python #!/usr/local/bin/python3 from kazoo.client import KazooClient import time zk = KazooClient(hosts = '192.168.43.125:2181',connection_retry = True) zk.start() zk.create("/datasource/datasource1", b"DATASOURCE1", None, True, False, True) while True: try: time.sleep(2) except (KeyboardInterrupt, SystemExit): zk.stop() ``` --- #### monitor.sh ```shell #!/bin/bash # /etc/init.d/monitor.sh case "$1" in start) python3 /Users/codeai/Desktop/monitor.py & sleep 2 if [ "$(ps -ef | grep zookeeperclient.py | grep -v grep | awk '{ print $2 }')" = ""] then exit 1 else python3 /Users/codeai/Desktop/zookeeperclient.py & fi ;; stop) kill $(ps -ef | grep zookeeperclient.py | grep -v grep | awk '{ print $2 }') sleep 2 kill $(ps -ef | grep monitor.py | grep -v grep | awk '{ print $2 }') ;; *) echo "Usage: service command start|stop" exit 1 ;; esac exit 0 ``` --- #### monitor.py ```python def socketmronitor(): line = "127.0.0.1 8080" ip = line.split()[0] port = int(line.split()[1]) while True: try: sc = socket.socket(socket.AF_INET, socket.SOCK_STREAM) print(ip, port) sc.settimeout(2) sc.connect((ip, port)) timenow = time.localtime() datenow = time.strftime('%Y-%m-%d %H:%M:%S', timenow) print("%s:%s 连接成功->%s \n" % (ip, port, datenow)) sc.close() time.sleep(3) except Exception as e: file = open("socketmonitor_err.log", "a") timenow = time.localtime() datenow = time.strftime('%Y-%m-%d %H:%M:%S', timenow) file.write("%s:%s 连接失败->%s \n" % (ip, port, datenow)) file.close() send_mail(['348055827@qq.com'], 'Oracle quit', sysinfo()) exit(-1) ``` --- #### server 端监听节点变化 ```java Watcher wh = new Watcher() { public void process(WatchedEvent event) { // 如果发生了"/datasource"节点下的子节点变化事件, 更新 server 列表, 并重新注册监听 if (event.getType() == Event.EventType.NodeChildrenChanged && ("/" + groupNode).equals(event.getPath())) { try { updateServerList(); } catch (Exception e) { e.printStackTrace(); } } } }; zk = new ZooKeeper("127.0.0.1:2181", Integer.MAX_VALUE, wh); ``` --- ## 分布式队列 先说一个通俗易懂的例子: > 一个老中医每天只看 5 个病人, 当第一个病人已经挂号 (向 Service 注册并且创建了一个节点), 但是并未达到 5 个人的要求, 所以这个病人就等待, 知道第 > 5 个人挂号后, 这 5 个人才会到老中医那儿去看病. > 这个就可以简单的理解为分布式队列,5 个人是不同 client, 他们都必须等到所有人都向 Zookeeper Server 创建了一个节点,( 他们通过对节点的监控从而知晓是否达到 > 5 个人 ), 当创建了 5 个节点后, 通过 Watcher 创建一个 start 的节点标识, 告知所有的 client, 这个队列已经可以使用了. --- ## 具体实现 创建一个父目录 /synchronizing,每个成员都监控目录 /synchronizing/start 是否存在,然后每个成员都加入这个队列(创建 /synchronizing/member_i 的临时目录节点),然后每个成员获取 / synchronizing 目录的所有目录节点,判断 i 的值是否已经是成员的个数,如果小于成员个数等待 /synchronizing/start 的出现,如果已经相等就创建 /synchronizing/start --- ## 分布式锁 还是先说一个通俗易懂的例子: > 有个科室的女医生很漂亮, 很多人慕名前来, 但是她一次只会给一个人看病, 也是有这么 5 个人, 先去挂号 (向 Server 注册), 然后每个人拿到了 > P1,P2...P5 编号的牌子. 护士从小号开始喊, 叫到谁谁就去找女医生玩儿 ( 听护士喊话, 然后对比自己手上的号拍, 如果叫到的跟自己手上的号牌一样, > 则说明获得了锁 ), 其他人则等待, > 当上一个病人看完病离开之后, 将手上的号牌扔掉, 护士继续喊号, 重复上面的过程. --- ## 具体实现 Server 创建一个 EPHEMERAL_SEQUENTIAL 目录节点,然后调用 getChildren 方法获取当前的目录节点列表中最小的目录节点是不是就是自己创建的目录节点,如果正是自己创建的,那么它就获得了这个锁,如果不是那么它就调用 exists(String path, boolean watch) 方法并监控 Zookeeper 上目录节点列表的变化,一直到自己创建的节点是列表中最小编号的目录节点,从而获得锁,释放锁很简单,只要删除前面它自己所创建的目录节点就行了。 --- ## 总结 1. 文件系统是一个树形的文件系统,但比 linux 系统简单,不区分文件和文件夹,所有的文件统一称为 znode 2. znode 的作用:存放数据,但上限是 1M ; 存放 ACL(access control list) 访问控制列表 3. 数据模型:短暂 znode(zkCli.sh 客户端断掉连接之后,就不存在了) 和持久 znode 4. 顺序号:每个 znode 在创建的时候都会编一个号,按照那他们就会按照创建的时间编号,同样的父节点的 znode 共用一套编号 5. 观察(watch):观察子节点的变化,类似于我们传统关系型数据库中的触发器 6. 操作:create delete setData getData exists(znode 是否存在并且查询他的 data) getACL setACL 7. 集群是无中心的,只要有超过一半以上的节点没有 down 掉就能工作,出于这个原因,我们 zookeeper 的节点数通常要设置成奇数。 8. 在整个集群没有 down 掉之前,至少有一个节点是最新的状态,这是通过 Zap, 该 zap 协议是包括 2 个无限重复的阶段: 1. 选举(选举出一个 leader,其他节点就变成了 follower) 2. 原子广播(所有写请求转发给 leader,再由 leader 广播给各个 follower,当半数以上 follower 将修改持久化后,leader 才会提交这个更新,接下来客户端才会收到一个更新成功的响应) 9. 应用: 1. 配置文件的同步(放一个 watch, 检测到数据改动后,立刻同步到其他节点,确保配置文件的一致性) 2. 锁机制的实现,比如很多客户端访问一个 znode 的资源,先给这个 znode 设置一个观察,观察节点的删除,其他客户端进来后,会添加一个对应的短暂 znode,因为 zookeeper 的顺序号机制,给每个要访问资源的客户端对应的 znode 分配一个顺序号,通过对比顺序号,决定能不能使用这个资源 --- 完整代码 [GitHub](https://github.com/dong4j/dubbo_demo) ## [Python自动化脚本:公司WiFi监控与邮件通知](https://blog.dong4j.site/posts/958f9ffe.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Python 面向对象基础部分 中秋在家没事, 写了一个很久以前就想写的脚本 如果下午 6 点 10 分还连接着公司的 wifi, 就发邮件给老婆说要加班 为什么要发邮件而不发短信呢, 因为短信接口要钱.... 最近买了个 4k 的显示器, 拿来外接 mac 看代码, 爽翻了, 不过有点蛮烦的就是每次都要拖动鼠标到另一个屏幕上去, 不过全国最大的同性交友网站 GitHub 上面有一款开源的软件叫 `CatchMouse` 解决了这个问题 下载地址: [CatchMouse](http://https://github.com/ROUND/CatchMouse) 另一个问题是如果想把软件从 mac 屏幕放到外接显示屏的话, 还是要拖过去, 但是.... 另一款神器 Moon 能帮我们快速的把当前应用移动到外接显示器上, ![20241229154732_6XddkZWb.webp](https://cdn.dong4j.site/source/image/20241229154732_6XddkZWb.webp) 光标定位到需要移动的 app 上, 快捷键 contro+` 即可 接下来是脚本 我的思路是: 先检测当前连接的是哪里的 wifi 如果是公司的 wifi, 且当前时间大于 6 点 10 分, 则给老婆发送邮件 如果连接的是家里的 wifi, 则检测是否连接了外接显示器, 如果连接了, 则检测是否开启了 CatchMouse.app, 没有则打开. 接下来开始撸代码: ```python # Created by: dong4j # Description: 检查连接的 wifi, 如果是家里的, 检查是否连接外接显示器 # 如果连接了, 则启动 CatchMouse.app, 切换音频输出到外接显示器 # 如果连接公司的 wifi, 则检测 6 点 10 分是否还是连接的公司 wifi # 如果是, 则发送邮件给老婆, 说要加班 # coding=utf-8 import socket import subprocess import datetime import smtplib import time from email.mime.text import MIMEText from email.header import Header mail_host = "smtp.163.com" mail_user = "用户名" mail_pass = "密码" mail_postfix = "163.com" log_dir = "smartlife.log" # 全局变量, 标识是否已经发送过邮件 global_flag = True def whereami(flag): myname = socket.getfqdn(socket.gethostname()) save_log(myname) myaddr = socket.gethostbyname(myname) # 家里有一个极路由和一个小米路由器, 所以有 2 个网段 count_spring = myaddr.count("192.168.31.") count_hiwifi = myaddr.count("192.168.199.") # 您阿姐任意一个则表示连接的是家里的 wifi if count_hiwifi + count_spring == 1: save_log("家里的wifi") # 调用检查外接显示器的方法 display() else: save_log("公司的wifi") now_time = datetime.datetime.now() save_log("当前小时是 %s" % now_time.hour) save_log("当前分钟是 %s" % now_time.minute) if now_time.hour == 18 and now_time.minute >= 10 and flag: send_mail(['269321381@qq.com'], '来自老公的邮件', "老婆 我要加班,你先吃") global global_flag global_flag = False # exit() def display(): # 使用 shell 命令检查是否连接了外接显示器, 如果有两个 DisplayProductID, 则表示连接外接显示器, 还可以使用 system_profiler SPDisplaysDataType | grep Resolution out_bytes = subprocess.check_output("ioreg -l | grep 'DisplayProductID'", shell = True) str1 = str(out_bytes, encoding = "utf-8") count = str1.count("DisplayProductID") str2 = str(subprocess.check_output("ps -ef | grep CatchMouse.app | grep -v grep | awk '{ print $2 }'", shell = True), encoding = "utf-8") save_log("CatchMouse.app pid: " + str2) # 如果你有多个显示器, 这里要修改为你自己的显示器个数 if count == 2 and str2 == "": save_log("开启CatchMouse") retcode = subprocess.call("open -a CatchMouse.app", shell = True) if retcode == 0: log = " 开启CatchMouse.app成功" else: log = " 开启CatchMouse.app失败" save_log(log) # 发送邮件 def send_mail(to_list, title, context): me = mail_user + "<" + mail_user + "@" + mail_postfix + ">" msg = MIMEText(context, 'plain', 'utf-8') msg['Subject'] = Header(title, 'utf-8') msg['From'] = me msg['To'] = ";".join(to_list) try: s = smtplib.SMTP() s.connect(mail_host) s.login(mail_user, mail_pass) s.sendmail(me, to_list, msg.as_string()) s.quit() save_log("邮件发送成功") except Exception as e: save_log(str(e)) # 保存 log def save_log(log_str): write_log = open(log_dir, 'a') log = '[%s]--> %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), log_str) print(log) write_log.write(log) write_log.close() if __name__ == '__main__': while (True): whereami(global_flag) time.sleep(10) ``` 脚本完成了 受到 [超过 90 秒的任务不自动化, 你好意思说自己是黑客?](http://http://blog.jobbole.com/95222/) 这篇文章的启发, 所有有了这个脚本 灵感来源于生活....... ## [自定义SSH密钥,高效管理多个GitHub和GitLab账户](https://blog.dong4j.site/posts/32516dfc.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) > ERROR: Permission to ArrayDsj/git-test.git denied to dong4j. > fatal: Could not read from remote repository. > Please make sure you have the correct access rights > and the repository exists. 目前有 2 个 github 账号, 一个公司的 gitlab 账号 有一次遇到了 ``` ERROR: Permission to XXX.git denied to user ``` 错误, 整理了一下,这里做一个记录 ## 错误前提 很久以前使用 ssh-keygen 生成一对默认名称的公私匙, 直接导入 github 中就能使用, 这是只在一个用户的情况下, 后台又申请了一个 github 账号, 还是使用 id_rsa.pub 这个默认的公匙, 这就造成了上面的错误. ## 解决方法 为不同的账号生成不同的公私匙 **1. 生成密匙对** ```shell # 生成密匙对 名称为 user1-github-ssh-key ssh-keygen -t rsa -f ~/.ssh/user1-github-ssh-key -b 4096 -C "user1@mail.com" # 生成密匙对 名称为 user2-github-ssh-key ssh-keygen -t rsa -f ~/.ssh/user2-github-ssh-key -C user2@mail.com # 因为默认只读取 id_rsa,为了让 SSH 识别新的私钥,需将其添加到 SSH agent 中: ssh-add ~/.ssh/user1-github-ssh-key ssh-add ~/.ssh/user2-github-ssh-key # 登陆2个不同的Github账号, 点击 网页右上侧的 Account Setting 按钮 - 选择 ssh-keys 点击 Add SSH Key ``` **2. 设置 ssh config** ```shell # Default github user(user1@mail.com) Host github.com HostName github.com User git IdentityFile ~/.ssh/user1-github-ssh-key # second user(user2@mail.com) # 建一个github别名,新建的帐号使用这个别名做克隆和更新 Host github2 HostName github.com User git IdentityFile ~/.ssh/user2-github-ssh-key.pub ``` **3. 连接测试** ```shell $ ssh -T git@github.com Hi user1! You've successfully authenticated, but GitHub does not provide shell access. $ ssh -T github2 Hi user2! You've successfully authenticated, but GitHub does not provide shell access. ``` **4. 使用 repo 设置代替 git 全局设置** ```shell # 查看 git config 配置 格式:git config [--local| --global | --system] -l 查看仓库级的 config,即 .git/config,命令:git config --local -l 查看全局级的 config,即 ~/.gitconfig,命令:git config --global -l # 使用 homebrew 安装的 git 的配置文件路径 查看系统级的 config,即 /usr/local/etc/gitconfig,命令:git config --system -l 查看当前生效的配置,命令:git config -l ``` ```shell # 删除全局配置 git config --global --unset user.email # 在 repo 下使用局部配置 git config --local user.name "user1" git config --local user.email user1@mail.com ``` **5. git 操作测试** ```shell # 本地新建项目 git init git add . git commit -t 'init' git remote add origin git@github.com:dong4j/dubbo_demo.git # push 到远程仓库 git push -u origin master # 从远程仓库更新 git pull origin master ``` ## GitLab 使用自定义密匙 修改 .ssh/config ``` Host gitlab服务器ip地址 RSAAuthentication yes IdentityFile ~/.ssh/your-ssh-key ``` ## [JVM内部机制大揭秘:Java类加载全过程](https://blog.dong4j.site/posts/cd5f7bae.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 当程序使用某个类时, 如果该类还没被初始化, 加载到内存中, 则系统会通过加载、连接、初始化三个过程来对该类进行初始化. 该过程就被称为类的初始化 ### 类加载 指将类的 class 文件读入内存, 并为之创建一个 java.lang.Class 的对象 #### 类文件来源 - 从本地文件系统加载的 class 文件 - 从 JAR 包加载 class 文件 - 从网络加载 class 文件 - 把一个 Java 源文件动态编译, 并执行加载 类加载器通常无须等到“首次使用”该类时才加载该类, JVM 允许系统预先加载某些类 #### 类加载器 类加载器就是负责加载所有的类, 将其载入内存中, 生成一个 java.lang.Class 实例. 一旦一个类被加载到 JVM 中之后, 就不会再次载入了. ![20241229154732_YsqIonrn.webp](https://cdn.dong4j.site/source/image/20241229154732_YsqIonrn.webp) - 根类加载器(Bootstrap ClassLoader): 其负责加载 Java 的核心类, 比如 String、System 这些类 - 拓展类加载器(Extension ClassLoader): 其负责加载 JRE 的拓展类库 - 系统类加载器(System ClassLoader): 其负责加载 CLASSPATH 环境变量所指定的 JAR 包和类路径 - 用户类加载器: 用户自定义的加载器, 以类加载器为父类 > 类加载器之间的父子关系并不是继承关系, 是类加载器实例之间的关系 ```java public static void main(String[] args) throws IOException { ClassLoader systemLoader = ClassLoader.getSystemClassLoader(); System.out.println("系统类加载"); Enumeration em1 = systemLoader.getResources(""); while (em1.hasMoreElements()) { System.out.println(em1.nextElement()); } ClassLoader extensionLader = systemLoader.getParent(); System.out.println("拓展类加载器" + extensionLader); System.out.println("拓展类加载器的父" + extensionLader.getParent()); } ``` `结果` ``` 系统类加载 file:/E:/gaode/em/bin/ 拓展类加载器 sun.misc.Launcher$ExtClassLoader@6d06d69c 拓展类加载器的父 null ``` **为什么根类加载器为 NULL?** > 根类加载器并不是 Java 实现的, 而且由于程序通常须访问根加载器, 因此访问扩展类加载器的父类加载器时返回 NULL #### JVM 类加载机制 - 全盘负责, 当一个类加载器负责加载某个 Class 时, 该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入, 除非显示使用另外一个类加载器来载入 - 父类委托, 先让父类加载器试图加载该类, 只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类 - 缓存机制, 缓存机制将会保证所有加载过的 Class 都会被缓存, 当程序中需要使用某个 Class 时, 类加载器先从缓存区寻找该 Class, 只有缓存区不存在, 系统才会读取该类对应的二进制数据, 并将其转换成 Class 对象, 存入缓存区. 这就是为什么修改了 Class 后, 必须重启 JVM, 程序的修改才会生效 #### URLClassLoader 类 URLClassLoader 为 ClassLoader 的一个实现类, 该类也是系统类加载器和拓展类加载器的父类(继承关系). 它既可以从本地文件系统获取二进制文件来加载类, 也可以远程主机获取二进制文件来加载类. 两个构造器 - `URLClassLoader(URL[] urls)`: 使用默认的父类加载器创建一个 ClassLoader 对象, 该对象将从 urls 所指定的路径来查询并加载类 - `URLClassLoader(URL[] urls,ClassLoader parent)`: 使用指定的父类加载器创建一个 ClassLoader 对象, 其他功能与前一个构造器相同 ```java import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; import java.sql.Connection; import java.sql.SQLException; import java.util.Properties; import com.mysql.jdbc.Driver; public class GetMysql { private static Connection conn; public static Connection getConn(String url,String user,String pass) throws MalformedURLException, InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException{ if(conn==null){ URL[]urls={new URL("file:mysql-connector-java-5.1.18.jar")}; URLClassLoader myClassLoader=new URLClassLoader(urls); Driver driver=(Driver) myClassLoader.loadClass("com.mysql.jdbc.Driver").newInstance(); Properties pros=new Properties(); pros.setProperty("user", user); pros.setProperty("password", pass); conn=driver.connect(url, pros); } return conn; } public static method1 getConn() throws MalformedURLException, InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException{ URL[]urls={new URL("file:com.arraydsj@163.com")}; URLClassLoader myClassLoader=new URLClassLoader(urls); method1 driver=(method1) myClassLoader.loadClass("com.arraydsj@163.com.method1").newInstance(); return driver; } public static void main(String[] args) throws MalformedURLException, InstantiationException, IllegalAccessException, ClassNotFoundException, SQLException { System.out.println(getConn("jdbc:mysql://10.10.16.11:3306/auto?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true", "jiji", "jiji")); System.out.println(getConn()); } } ``` 获得 URLClassLoader 对象后, 调用 **loanClass()** 方法来加载指定的类 #### 自定义类加载器 ```java import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.lang.reflect.Method; public class CompileClassLoader extends ClassLoader{ // 读取一个文件的内容 @SuppressWarnings("resource") private byte[] getBytes(String filename) throws IOException{ File file = new File(filename); long len = file.length(); byte[] raw = new byte[(int) len]; FileInputStream fin = new FileInputStream(file); // 一次读取 class 文件的全部二进制数据 int r = fin.read(raw); if (r != len) throw new IOException("无法读取全部文件" + r + "!=" + len); fin.close(); return raw; } // 定义编译指定 java 文件的方法 private boolean compile(String javaFile) throws IOException { System.out.println("CompileClassLoader:正在编译" + javaFile + "…….."); // 调用系统的 javac 命令 Process p = Runtime.getRuntime().exec("javac" + javaFile); try { // 其它线程都等待这个线程完成 p.waitFor(); } catch (InterruptedException ie){ System.out.println(ie); } // 获取 javac 的线程的退出值 int ret = p.exitValue(); // 返回编译是否成功 return ret == 0; } // 重写 Classloader 的 findCLass 方法 protected Class findClass(String name) throws ClassNotFoundException{ Class clazz = null; // 将包路径中的. 替换成斜线 / String fileStub = name.replace(".", "/"); String javaFilename = fileStub + ".java"; String classFilename = fileStub + ".class"; File javaFile = new File(javaFilename); File classFile = new File(classFilename); // 当指定 Java 源文件存在, 且 class 文件不存在, 或者 Java 源文件的修改时间比 class 文件 // 修改时间晚时, 重新编译 if (javaFile.exists() && (!classFile.exists()) || javaFile.lastModified()> classFile.lastModified()) { try { // 如果编译失败, 或该 Class 文件不存在 if (!compile(javaFilename) || !classFile.exists()) { throw new ClassNotFoundException("ClassNotFoundException:" + javaFilename); } } catch (IOException ex) { ex.printStackTrace(); } } // 如果 class 文件存在, 系统负责将该文件转化成 class 对象 if (classFile.exists()) { try { // 将 class 文件的二进制数据读入数组 byte[] raw = getBytes(classFilename); // 调用 Classloader 的 defineClass 方法将二进制数据转换成 class 对象 clazz = defineClass(name, raw, 0, raw.length); } catch (IOException ie) { ie.printStackTrace(); } } // 如果 claszz 为 null, 表明加载失败, 则抛出异常 if (clazz == null) { throw new ClassNotFoundException(name); } return clazz; } // 定义一个主方法 public static void main(String[] args) throws Exception { // 如果运行该程序时没有参数, 即没有目标类 if (args.length < 1) { System.out.println("缺少运行的目标类, 请按如下格式运行java源文件: "); System.out.println("java CompileClassLoader ClassName"); } // 第一个参数是需要运行的类 String progClass = args[0]; // 剩下的参数将作为运行目标类时的参数, 所以将这些参数复制到一个新数组中 String progargs[] = new String[args.length - 1]; System.arraycopy(args, 1, progargs, 0, progargs.length); CompileClassLoader cl = new CompileClassLoader(); // 加载需要运行的类 Class clazz = cl.loadClass(progClass); // 获取需要运行的类的主方法 Method main = clazz.getMethod("main", (new String[0]).getClass()); Object argsArray[] = { progargs}; main.invoke(null, argsArray); } } ``` JVM 中除了根类加载器之外的所有类的加载器都是 ClassLoader 子类的实例, 通过重写 ClassLoader 中的方法, 实现自定义的类加载器 loadClass(String name,boolean resolve): 为 ClassLoader 的入口点, 根据指定名称来加载类, 系统就是调用 ClassLoader 的该方法来获取制定累对应的 Class 对象 findClass(String name): 根据指定名称来查找类 > 推荐使用 findClass 方法 ### 类的链接 当类被加载后, 系统会为之生成一个 Class 对象, 接着将会进入连接阶段, 链接阶段负责把类的二进制数据合并到 JRE 中 **三个阶段** 验证: 检验被加载的类是否有正确的内部结构, 并和其他类协调一致 准备: 负责为类的类变量分配内存. 并设置默认初始值 解析: 将类的二进制数据中的符号引用替换成直接引用 ### 类的初始化 JVM 负责对类进行初始化, 主要对类变量进行初始化 在 Java 中对类变量进行初始值设定有两种方式: ① 声明类变量是指定初始值 ② 使用静态代码块为类变量指定初始值 **JVM 初始化步骤** 1. 假如这个类还没有被加载和连接, 则程序先加载并连接该类 2. 假如该类的直接父类还没有被初始化, 则先初始化其直接父类 3. 假如类中有初始化语句, 则系统依次执行这些初始化语句 ### 类初始化时机 1. 创建类实例. 也就是 new 的方式 2. 调用某个类的类方法 3. 访问某个类或接口的类变量, 或为该类变量赋值 4. 使用反射方式强制创建某个类或接口对应的 java.lang.Class 对象 5. 初始化某个类的子类, 则其父类也会被初始化 6. 直接使用 java.exe 命令来运行某个主类 [类加载机制(类加载过程和类加载器)](http://blog.csdn.net/boyupeng/article/details/47951037) ## [掌握 Java 并发精髓 - 理解JMM与Happens-before原则](https://blog.dong4j.site/posts/4d06da5c.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 介绍 Java 内存模型 3 大核心 - 原子性 - 可见性 - 顺序性 [原文出处 https://segmentfault.com/a/1190000000435392](https://segmentfault.com/a/1190000000435392) ## 并发编程模型的分类 在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。 同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。 Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的 Java 程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。 ## Java 内存模型的抽象 在 java 中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java 语言规范称之为 formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。 Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java 内存模型的抽象示意图如下: ![20241229154732_Z3p8GoC9.webp](https://cdn.dong4j.site/source/image/20241229154732_Z3p8GoC9.webp) 从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤: 1. 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。 2. 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。 下面通过示意图来说明这两个步骤: ![20241229154732_zwPLe6LN.webp](https://cdn.dong4j.site/source/image/20241229154732_zwPLe6LN.webp) 如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。 从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。 ## 重排序 在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型: 1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。 2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。 3. 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。 从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序: ![20241229154732_sfrpZLks.webp](https://cdn.dong4j.site/source/image/20241229154732_sfrpZLks.webp) 上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。 JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。 ## 处理器重排序与内存屏障指令 现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读 / 写操作的执行顺序,不一定与内存实际发生的读 / 写操作顺序一致!为了具体说明,请看下面示例: Processor A Processor B ``` a = 1; //A1 x = b; //A2 b = 2; //B1 y = a; //B2 ``` 初始状态:`a = b = 0` 处理器允许执行后得到结果:`x = y = 0` 假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0 的结果。具体的原因如下图所示: ![20241229154732_VqOWkkYt.webp](https://cdn.dong4j.site/source/image/20241229154732_VqOWkkYt.webp) 这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。 从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:`A1->A2` ,但内存操作实际发生的顺序却是:`A2->A1`。此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样,这里就不赘述了)。 这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写 - 读操作重排序。 下面是常见处理器允许的重排序类型的列表: | | Load-Load | Load-Store | Store-Store | Store-Load | 数据依赖 | | --------- | --------- | ---------- | ----------- | ---------- | -------- | | sparc-TSO | N | N | N | Y | N | | x86 | N | N | N | Y | N | | ia64 | Y | Y | Y | Y | N | | PowerPC | Y | Y | Y | Y | N | 上表单元格中的“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。 从上表我们可以看出:常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO 和 x86 拥有相对较强的处理器内存模型,它们仅允许对写 - 读操作做重排序(因为它们都使用了写缓冲区)。 ※注 1:sparc-TSO 是指以 TSO(Total Store Order) 内存模型运行时,sparc 处理器的特性。 ※注 2:上表中的 x86 包括 x64 及 AMD64。 ※注 3:由于 ARM 处理器的内存模型与 PowerPC 处理器的内存模型非常类似,本文将忽略它。 ※注 4:数据依赖性后文会专门说明。 为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类: | 屏障类型 | 指令示例 | 说明 | | -------- | -------- | ---- | | | | | | LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。| | StoreStore Barriers | Store1; StoreStore; Store2 | 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。| | LoadStore Barriers | Load1; LoadStore; Store2 | 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。| | StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。| StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。 ## happens-before 从 JDK5 开始,java 使用新的 JSR -133 内存模型(本文除非特别说明,针对的都是 JSR- 133 内存模型)。JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的 happens-before 规则如下: - 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。 - 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。 - volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。 - 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。 注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。happens- before 的定义很微妙,后文会具体说明 happens-before 为什么要这么定义。 happens-before 与 JMM 的关系如下图所示: ![20241229154732_HrSV4B0G.webp](https://cdn.dong4j.site/source/image/20241229154732_HrSV4B0G.webp) 如上图所示,一个 happens-before 规则通常对应于多个编译器和处理器重排序规则。对于 java 程序员来说,happens-before 规则简单易懂,它避免 java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。 ## [同步与异步的较量:如何在Spring Boot中优化执行效率](https://blog.dong4j.site/posts/603b8025.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) “异步调用”对应的是“同步调用”,*同步调用*指程序按照定义顺序依次执行,每一行程序都必须等待上一行程序执行完成之后才能执行;*异步调用*指程序在顺序执行时,不等待异步调用的语句返回结果就执行后面的程序。 ## 同步调用 下面通过一个简单示例来直观的理解什么是同步调用: - 定义 Task 类,创建三个处理函数分别模拟三个执行任务的操作,操作消耗时间随机取(10 秒内) ```java @Component public class Task { public static Random random =new Random(); public void doTaskOne() throws Exception { System.out.println("开始做任务一"); long start = System.currentTimeMillis(); Thread.sleep(random.nextInt(10000)); long end = System.currentTimeMillis(); System.out.println("完成任务一,耗时:" + (end - start) + "毫秒"); } public void doTaskTwo() throws Exception { System.out.println("开始做任务二"); long start = System.currentTimeMillis(); Thread.sleep(random.nextInt(10000)); long end = System.currentTimeMillis(); System.out.println("完成任务二,耗时:" + (end - start) + "毫秒"); } public void doTaskThree() throws Exception { System.out.println("开始做任务三"); long start = System.currentTimeMillis(); Thread.sleep(random.nextInt(10000)); long end = System.currentTimeMillis(); System.out.println("完成任务三,耗时:" + (end - start) + "毫秒"); } } ``` - 在单元测试用例中,注入 Task 对象,并在测试用例中执行 `doTaskOne`、`doTaskTwo`、`doTaskThree` 三个函数。 ```java @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = Application.class) public class ApplicationTests { @Autowired private Task task; @Test public void test() throws Exception { task.doTaskOne(); task.doTaskTwo(); task.doTaskThree(); } } ``` - 执行单元测试,可以看到类似如下输出: ``` 开始做任务一 完成任务一,耗时:4256毫秒 开始做任务二 完成任务二,耗时:4957毫秒 开始做任务三 完成任务三,耗时:7173毫秒 ``` 任务一、任务二、任务三顺序的执行完了,换言之 `doTaskOne`、`doTaskTwo`、`doTaskThree` 三个函数顺序的执行完成。 ## 异步调用 上述的同步调用虽然顺利的执行完了三个任务,但是可以看到执行时间比较长,若这三个任务本身之间不存在依赖关系,可以并发执行的话,同步调用在执行效率方面就比较差,可以考虑通过异步调用的方式来并发执行。 在 Spring Boot 中,我们只需要通过使用 `@Async` 注解就能简单的将原来的同步函数变为异步函数,Task 类改在为如下模式: ```java @Component public class Task { @Async public void doTaskOne() throws Exception { // 同上内容,省略 } @Async public void doTaskTwo() throws Exception { // 同上内容,省略 } @Async public void doTaskThree() throws Exception { // 同上内容,省略 } } ``` 为了让@Async 注解能够生效,还需要在 Spring Boot 的主程序中配置@EnableAsync,如下所示: ```java @SpringBootApplication @EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } } ``` 此时可以反复执行单元测试,您可能会遇到各种不同的结果,比如: - 没有任何任务相关的输出 - 有部分任务相关的输出 - 乱序的任务相关的输出 原因是目前 `doTaskOne`、`doTaskTwo`、`doTaskThree` 三个函数的时候已经是异步执行了。主程序在异步调用之后,主程序并不会理会这三个函数是否执行完成了,由于没有其他需要执行的内容,所以程序就自动结束了,导致了不完整或是没有输出任务相关内容的情况。 **注: @Async 所修饰的函数不要定义为 static 类型,这样异步调用不会生效** ## 异步回调 为了让 `doTaskOne`、`doTaskTwo`、`doTaskThree` 能正常结束,假设我们需要统计一下三个任务并发执行共耗时多少,这就需要等到上述三个函数都完成调动之后记录时间,并计算结果。 那么我们如何判断上述三个异步调用是否已经执行完成呢?我们需要使用 `Future` 来返回异步调用的结果,就像如下方式改造 `doTaskOne` 函数: ```java @Async public Future doTaskOne() throws Exception { System.out.println("开始做任务一"); long start = System.currentTimeMillis(); Thread.sleep(random.nextInt(10000)); long end = System.currentTimeMillis(); System.out.println("完成任务一,耗时:" + (end - start) + "毫秒"); return new AsyncResult<>("任务一完成"); } ``` 按照如上方式改造一下其他两个异步函数之后,下面我们改造一下测试用例,让测试在等待完成三个异步调用之后来做一些其他事情。 ```java @Test public void test() throws Exception { long start = System.currentTimeMillis(); Future task1 = task.doTaskOne(); Future task2 = task.doTaskTwo(); Future task3 = task.doTaskThree(); while(true) { if(task1.isDone() && task2.isDone() && task3.isDone()) { // 三个任务都调用完成,退出循环等待 break; } Thread.sleep(1000); } long end = System.currentTimeMillis(); System.out.println("任务全部完成,总耗时:" + (end - start) + "毫秒"); } ``` 看看我们做了哪些改变: - 在测试用例一开始记录开始时间 - 在调用三个异步函数的时候,返回 `Future` 类型的结果对象 - 在调用完三个异步函数之后,开启一个循环,根据返回的 `Future` 对象来判断三个异步函数是否都结束了。若都结束,就结束循环;若没有都结束,就等 1 秒后再判断。 - 跳出循环之后,根据结束时间 - 开始时间,计算出三个任务并发执行的总耗时。 执行一下上述的单元测试,可以看到如下结果: ``` 开始做任务一 开始做任务二 开始做任务三 完成任务三,耗时:37毫秒 完成任务二,耗时:3661毫秒 完成任务一,耗时:7149毫秒 任务全部完成,总耗时:8025毫秒 ``` 可以看到,通过异步调用,让任务一、二、三并发执行,有效的减少了程序的总运行时间。 ## [SpringBoot 退出服务时调用自定义销毁方法的两种方式](https://blog.dong4j.site/posts/442823ed.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 我们在工作中有时候可能会遇到这样场景,需要在退出容器的时候执行某些操作。SpringBoot 中有两种方法可以供我们来选择(其实就是 spring 中我们常用的方式。只是 destory-method 是在 XML 中配置的,SpringBoot 是去配置化。所以这里就不提这种方式了),一种是实现 DisposableBean 接口,一种是使用@PreDestroy 注解。OK,下面我写两个例子看一下: ## DisposableBean 接口 我们可以通过实现这个接口来在容器退出的时候执行某些操作。例子如下: ```java @Component public class TestImplDisposableBean implements DisposableBean, ExitCodeGenerator { @Override public void destroy() throws Exception { System.out.println("<<<<<<<<<<<我被销毁了......................>>>>>>>>>>>>>>>"); System.out.println("<<<<<<<<<<<我被销毁了......................>>>>>>>>>>>>>>>"); } @Override public int getExitCode() { return 5; } } ``` ## @PreDestroy 注解 我们可以在需要的类的方法上添加这个注解,同样可以满足我们的需求。 ```java @Component public class TestAnnotationPreDestroy { @PreDestroy public void destory() { System.out.println("我被销毁了、、、、、我是用的@PreDestory的方式、、、、、、"); System.out.println("我被销毁了、、、、、我是用的@PreDestory的方式、、、、、、"); } } ``` ## TIPS: 退出你可以通过 Ide 中的功能来退出。这里我启动的时候是在 CMD 中用 jar 启动的,启动命令如下:java -jar LearnSpringBoot-0.0.1-SNAPSHOT.jar,所以我在这里退出的时候是用的 Ctrl+C 来执行的退出操作。如果你用的 mvn spring-boot:run 来启动运行的话,可能不会执行销毁的操作。 ## [基于Token的RESTful API,Spring Security入门](https://blog.dong4j.site/posts/31d0a22c.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 在大部分时候,我们讨论 API 的设计时,会从功能的角度出发定义出完善的,易用的 API。而很多时候,非功能需求如安全需求则会在很晚才加入考虑。而往往这部分会涉及很多额外的工作量,比如与外部的 SSO 集成,Token 机制等等。 这篇文章会以一个简单的例子,从应用程序和部署架构上分别讨论几种常见的模型。这篇文章是这个系列的第一篇,会讨论两个简单的主题: - 基于 Session 的用户认证 - 基于 Token 的 RESTful API(使用 Spring Security) ### 使用 Session 由于 HTTP 协议本身是无状态的,服务器需要某种机制来区分每个请求。比如在返回给客户的响应中加入一些 ID,客户端再次请求时带上这个 ID,这样服务器就可以区分出来每个请求,并完成事务性的操作(完成订单的创建,更新,商品派送等等)。 在多数 Web 容器中,这种机制通过 Session 来实现。Web 容器会为每个首次请求创建一个 Session,并将 Session 的 ID 以浏览器 Cookie 的方式返回给客户端。客户端(常常是浏览器)在后续的请求中带上这个 Session 的 ID 来表明自己的身份。这种机制同样被用在了鉴权方面,用户登录系统之后,系统分配一个 Session ID 给他。 除非 Session 过期,或者用户从客户端的 Cookie 中主动删了 Session ID,否则在服务器端来看,用户的信息会和这个 Session 绑定起来。后台系统也可以随时知道请求某个资源的真实用户是谁,并以此来判断该用户时候真的有权限这么做。 ```java HttpSession session = request.getSession(); String user = (String)session.getAttribute("user"); if(user != null) { // } ``` #### Session 的问题 这种做法在小规模应用中工作良好,随着用户的增多,企业往往需要部署多台服务器形成集群来对外提供服务。在集群模式下,当某个节点挂掉之后,由于 Session 默认是保存在部署 Web 容器中的,用户会被误判为未登录,后续的请求会被重定向到登陆页面,影响用户体验。 这种将应用程序状态内置的方法已经完全无法满足应用的扩展,因此在工程实践中,我们会采用将 Session 外置的方式来解决这个问题。即集群中的所有节点都将 Session 保存在一个公用的键值数据库中: ```java @Configuration @EnableRedisHttpSession public class HttpSessionConfig { } ``` 上面这个例子是在 Spring Boot 中使用 Redis 来外置 Session。Spring 会拦截所有对 HTTPSession 对象的操作,后续的对 Session 的操作,Spring 都会自动转换为与后台的 Redis 服务器的交互,从而避免节点挂掉之后 Session 丢失的问题。 ```java spring.redis.host=192.168.99.100 spring.redis.password= spring.redis.port=6379 ``` 如果你跟我一样懒的话,直接启动一个 redis 的 docker container 就可以: ```java $ docker run --name redis-server -d redis ``` 这样,多个应用共享这一个实例,任何一个节点的终止、异常都不会产生 Session 的问题。 ### 基于 Token 的安全机制 上面说到的场景中,使用 Session 需要额外部署一个组件(或者引入更加复杂的 Session 同步机制),这会带来另外的问题,比如如何保证这个节点的高可用,除了 Production 环境之外,Staging 和 QA 环境也需要这个组件的配置、测试和维护。 很多项目现在会采用另外一种更加简单的方式:基于 Token 的安全机制。即不使用 Session,用户在登陆之后,会获得一个 Token,这个 Token 会以 HTTP Header 的方式发送给客户,同样,客户再后续的资源请求中也需要带着这个 Token。通常这个 Token 还会有过期时间的限制(比如只能使用 1 周,一周之后需要重新获取)。 基于 Token 的机制更加简单,和 RESTful 风格的 API 一起使用更加自然,相较于传统的 Web 应用,RESTful 的消费者可能是人,也可能是 Mobile App,也可能是系统中另外的 Service。也就是说,并不是所有的消费者都可以处理一个登陆表单! #### Restful API 我们通过一个实例来看使用 Spring Security 保护受限制访问资源的场景。 对于 Controller: ```java @RestController @RequestMapping("/protected") public class ProtectedResourceController { @RequestMapping("/{id}") public Message getOne(@PathVariable("id") String id) { return new Message("Protected resource "+id); } } ``` 我们需要所有请求上都带有一个`X-Auth-Token`的 Header,简单起见,如果这个 Header 有值,我们就认为这个请求已经被授权了。我们在 Spring Security 中定义这样的一个配置: ```java @Override protected void configure(HttpSecurity http) throws Exception { http. csrf().disable(). sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS). and(). authorizeRequests(). anyRequest(). authenticated(). and(). exceptionHandling(). authenticationEntryPoint(new RestAuthenticationEntryPoint()); } ``` 我们使用`SessionCreationPolicy.STATELESS`无状态的 Session 机制(即 Spring 不使用 HTTPSession),对于所有的请求都做权限校验,这样 Spring Security 的拦截器会判断所有请求的 Header 上有没有 "X-Auth-Token"。对于异常情况(即当 Spring Security 发现没有),Spring 会启用一个认证入口:`new RestAuthenticationEntryPoint`。在我们这个场景下,这个入口只是简单的返回一个 401 即可: ```java @Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException ) throws IOException { response.sendError( HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized" ); } } ``` 这时候,如果我们请求这个受限制的资源: ```java $ curl http://api.kanban.com:9000/api/protected/1 -s | jq . { "timestamp": 1462621552738, "status": 401, "error": "Unauthorized", "message": "Unauthorized", "path": "/api/protected/1" } ``` #### 过滤器(Filter)及预认证(PreAuthentication) 为了让 Spring Security 可以处理用户登录的 case,我们需要提供一个`Filter`。当然,Spring Security 提供了丰富的`Filter`机制,我们这里使用一个预认证的`Filter`(即假设用户已经在别的外部系统如 SSO 中登录了): ```java public class KanBanPreAuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter { public static final String SSO_TOKEN = "X-Auth-Token"; public static final String SSO_CREDENTIALS = "N/A"; public KanBanPreAuthenticationFilter(AuthenticationManager authenticationManager) { setAuthenticationManager(authenticationManager); } @Override protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) { return request.getHeader(SSO_TOKEN); } @Override protected Object getPreAuthenticatedCredentials(HttpServletRequest request) { return SSO_CREDENTIALS; } } ``` 过滤器在获得 Header 中的 Token 后,Spring Security 会尝试去认证用户: ```java @Override protected void configure(AuthenticationManagerBuilder builder) throws Exception { builder.authenticationProvider(preAuthenticationProvider()); } private AuthenticationProvider preAuthenticationProvider() { PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider(); provider.setPreAuthenticatedUserDetailsService(new KanBanAuthenticationUserDetailsService()); return provider; } ``` 这里的`KanBanAuthenticationUserDetailsService`是一个实现了 Spring Security 的 UserDetailsService 的类: ```java public class KanBanAuthenticationUserDetailsService implements AuthenticationUserDetailsService { @Override public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) throws UsernameNotFoundException { String principal = (String) token.getPrincipal(); if(!StringUtils.isEmpty(principal)) { return new KanBanUserDetails(new KanBanUser(principal)); } return null; } } ``` 这个类的职责是,查看从`KanBanPreAuthenticationFilter`返回的`PreAuthenticatedAuthenticationToken`,如果不为空,则表示该用户在系统中存在,并正常加载用户。如果返回 null,则表示该认证失败,这时根据配置,Spring Security 会重定向到认证入口`RestAuthenticationEntryPoint`。 加上这个过滤器的配置之后: ```java @Override protected void configure(HttpSecurity http) throws Exception { //... http.addFilter(headerAuthenticationFilter()); } @Bean public KanBanPreAuthenticationFilter headerAuthenticationFilter() throws Exception { return new KanBanPreAuthenticationFilter(authenticationManager()); } ``` 这样,当我们在 Header 上加上`X-Auth-Token`之后,就会访问到受限的资源了: ```java $ curl -H "X-Auth-Token: juntao" http://api.kanban.com:9000/api/protected/1 -s | jq . { "content": "Protected resource for 1" } ``` ## 前后端分离之后 前后端分离之后,在部署上通过一个反向代理就可以实现动静态分离,跨域问题的解决等。但是一旦引入鉴权,则又会产生新的问题。通常来说,鉴权是对于后台 API/API 背后的资源的保护,即**未经授权的用户不能访问受保护资源**。 要实现这个功能有很多种方式,在应用程序之外设置完善的安全拦截器是最常见的方式。不过有点不够优雅的是,一些不太纯粹的、非功能性的代码和业务代码混在同一个代码库中。 另一方面,各个业务系统都可能需要某种机制的鉴权,所以很多企业都会搭建 SSO 机制,即 [Single Sign-On](https://en.wikipedia.org/wiki/Single_sign-on)。这样可以避免人们在多个系统创建不同账号,设置不同密码,不同的超时时间等等。如果 SSO 系统已经先于系统存在了很久,那么新开发的系统完全不需要自己再配置一套用户管理机制了(一般 SSO 只会完成**鉴权**中**鉴别**的部分,**授权**还是需要各个业务系统自行处理)。 本文中,我们使用基础设施(反向代理)的一些配置,来完成**保护未授权资源**的目的。在这个例子中,我们假设系统由这样几个服务器组成: ### 系统组成 这个实例中,我们的系统分为三部分 1. `kanban.com:8000`(业务系统前端) 2. `api.kanban.com:9000`(业务系统后端 API) 3. `sso.kanban.com:8100` (单点登录系统,登陆界面) 前端包含了 HTML/JS/CSS 等资源,是一个纯静态资源,所以本地磁盘即可。后端 API 则是一组需要被保护的 API(比如查询工资详情,查询工作经历等)。最后,单点登录系统是一个简单的表单,用户填入用户名和密码后,如果登录成功,单点登录会将用户重定向到登录前的位置。 我们举一个具体场景的例子: 1. 未登录用户访问`http://kanba.com:8000/index.html` 2. 系统会重定向用户到`http://sso.kanban.com:8100/sso?return=http://kanba.com:8000/index.html` 3. 用户看到登录页面,输入用户名、密码登录 4. 用户被重定向回`http://kanba.com:8000/index.html` 5. 此外,`index.htm`l 页面上的`app.js`对`api.kanban.com:9000`的访问也得到了授权 #### 环境设置 简单起见,可以通过修改 / etc/hosts 文件来设置服务器环境: ```java 127.0.0.1 sso.kanban.com 127.0.0.1 api.kanban.com 127.0.0.1 kanban.com ``` ### nginx 及 auth_request 反向代理 nginx 有一个 auth_request 的模块。在一个虚拟 host 中,每个请求会先发往一个内部`location`,这个内部的`location`可以指向一个可以做鉴权的 Endpoint。如果这个请求得到的结果是 200,那么 nginx 会返回用户本来请求的内容,如果返回 401,则将用户重定向到一个预定义的地址: ```java server { listen 8000; server_name kanban.com; root /usr/local/var/www/kanban/; error_page 401 = @error401; location @error401 { return 302 http://sso.kanban.com:8100/sso?return=$scheme://$http_host$request_uri; } auth_request /api/auth; location = /api/auth { internal; proxy_pass http://api.kanban.com:9000; proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header X-Original-URI $request_uri; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; if ($http_cookie ~* "w3=(\w+)") { set $token "$1"; } proxy_set_header X-KANBAN-TOKEN $token; } } ``` 比如上面这个例子中,`auth_request`的 URL 为`/api/auth`,它是一个内部的 location,外部无法访问。在这个`locaiton`中,请求会被转发到`http://api.kanban.com:9000`,根据 nginx 的正则语法,请求将会被转发到`http://api.kanban.com:9000/api/auth`(我们随后可以看到这个 Endpoint 的定义)。 我们设置了请求的原始头信息,并禁用了 request_body,如果 cookie 中包含了`w3=(\w+)`字样,则将这个 w3 的值抽取出来,并赋值给一个`X-KANBAN-TOKEN`的 HTTP 头。 #### 权限 Endpoint 对应的`/api/auth`的定义如下: ```java @RestController @RequestMapping("/auth") public class AuthController { @RequestMapping public ResponseEntity simpleAuth(@RequestHeader(value="X-KANBAN-TOKEN", defaultValue = "") String token) { if(StringUtils.isEmpty(token)) { return new ResponseEntity<>("Unauthorized", HttpStatus.UNAUTHORIZED); } else { return new ResponseEntity<>("Authorized", HttpStatus.OK); } } } ``` 如果 HTTP 头上有`X-KANBAN-TOKEN`且值不为空,则返回 200,否则返回 401。 当这个请求得到 401 之后,用户被重定向到`http://sso.kanban.com:8100/sso` ```java error_page 401 = @error401; location @error401 { return 302 http://sso.kanban.com:8100/sso?return=$scheme://$http_host$request_uri; } ``` ### SSO 组件(简化版) 这里用`sinatra`定义了一个简单的 SSO 服务器(去除了实际的校验部分) ```java require 'sinatra' require 'uri' set :return_url, '' set :bind, '0.0.0.0' get '/sso' do settings.return_url = params[:return] send_file 'public/index.html' end post '/login' do credential = params[:credential] # check credential against database uri = URI.parse(settings.return_url) response.set_cookie("w3", { :domain => ".#{uri.host}", :expires => Time.now + 2400, :value => "#{credential['name']}", :path => '/' }) redirect settings.return_url, 302 end ``` `/sso`对应的 Login Form 是: ```java
``` 当用户提交表单之后,我们只是简单的设置了`cookie`,并重定向用户到跳转前的 URL。 ### 前端页面 这个应用的前端应用非常简单,我们只需要将这些静态文件放到`/usr/local/var/www/kanban`目录下: ```java $ tree /usr/local/var/www/kanban ├── index.html └── scripts ├── app.js └── jquery.min.js ``` 其中`index.html`中引用的`app.js`会请求一个受保护的资源: ```java $(function() { $.get('/api/protected/1').done(function(message) { $('#message').text(message.content); }); }); ``` 从下图中的网络请求可以看到重定向的流程: ![20241229154732_GAUYMvQ9.webp](https://cdn.dong4j.site/source/image/20241229154732_GAUYMvQ9.webp) ### 总结 本文我们通过配置反向代理,将多个 Endpoint 组织起来。这个过程可以在应用程序中通过代码实现,也可以在基础设施中通过配置实现,通常来讲,如果可以通过配置来实现的,就尽量将其与负责业务逻辑的代码隔离出来。这样可以保证各个组件的独立性,也可以使得优化和定位问题更加容易。 ## [Java 程序员必学:classpath 文件读取技巧](https://blog.dong4j.site/posts/a5564900.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 写 Java 程序时会经常从 classpath 下读取文件, 是时候该整理一下了, 并在不断深入的过程中, 陆续补充上. 现在 Java project 都以 maven 项目居多, 比如像下面这样的一个项目结构: ![20241229154732_YWDwQbhz.webp](https://cdn.dong4j.site/source/image/20241229154732_YWDwQbhz.webp) 编译后的 class 文件都到了 target 目录, 如下面的结构: ![20241229154732_BvAjGpSn.webp](https://cdn.dong4j.site/source/image/20241229154732_BvAjGpSn.webp) 看代码: ```java import java.io.File; import java.net.URL; public class Poem { public static void main(String[] args) { Poem poem = new Poem(); poem.getFile("extObj.txt"); } private void getFile(String fileName) { ClassLoader classLoader = getClass().getClassLoader(); /** getResource() 方法会去 classpath 下找这个文件, 获取到 url resource, 得到这个资源后, 调用 url.getFile 获取到 文件 的绝对路径 */ URL url = classLoader.getResource(fileName); /** * url.getFile() 得到这个文件的绝对路径 */ System.out.println(url.getFile()); File file = new File(url.getFile()); System.out.println(file.exists()); } } ``` 通过上面这种方式就可以获取到这个文件资源. 在一个 static method 里可以直接通过类的 ClassLoader 对象获取文件资源. ```java URL url = Poem.class.getClassLoader().getResource("extObj.txt"); File file = new File(url.getFile()); ``` ```java // 直接获取到输入流 // fileName 就是 resources 里的文件名 InputStream in = Poem.class.getClassLoader().getResourceAsStream(fileName); ``` 综上述, 类里的 getClassLoader 去寻找 fileName 都是从 classpath 去找的, 毕竟是 ClassLoader 嘛. 如果一个包里面有一个配置文件, 那该怎么获取呢? 如图: ![20241229154732_unL2GHSm.webp](https://cdn.dong4j.site/source/image/20241229154732_unL2GHSm.webp) 第一个 dbconfig.properties 在类 package 下, 第二个 dbconfig.properties 在 resources 目录下, 那怎么获取到 package 下的 dbconfig properties 文件呢? here goes code: ```java package com.getfilefromclasspath; import java.io.IOException; import java.io.InputStream; import java.util.Properties; public class ClassLoaderDemo { public static void main(String[] args) throws IOException { ClassLoaderDemo demo = new ClassLoaderDemo(); demo.loadProperties(); } public void loadProperties() throws IOException { InputStream input = null; try { /** /dbconfig.properties 绝对路径, 取到的文件是 classpath 下的 resources/dbconfig.properties 相对路径 获取文件流 */ // 获取到 classpath 下的文件 input = Class.forName(ClassLoaderDemo.class.getName()).getResourceAsStream("/dbconfig.properties"); // 获取到 package 下的文件 // input = Class.forName(ClassLoaderDemo.class.getName()).getResourceAsStream("resources/dbconfig.properties"); } catch (ClassNotFoundException e) { e.printStackTrace(); } printProperties(input); } private void printProperties(InputStream input) throws IOException { Properties properties = new Properties(); properties.load(input); System.out.println(properties.getProperty("username")); } } ``` 不使用 Class.forName(), 通过具体对象获取到 Class 对象: ```java // also can be this way: input = this.getClass().getResourceAsStream("resources/dbconfig.properties"); // 对应 package 下的文件 input = this.getClass().getResourceAsStream("/dbconfig.properties"); // 对应 resources 下的文件 ``` Class 对象还有 getResource() 的方法去获取文件资源, 使用规则和上面的一样. maven 项目还要注意一点, maven 的 compiler 插件在编译时是不会将 package 下的文本文件给编译到 target 下的, 下图是我在用 mybatis 框架的时候将 xml 的 mapper 给放到 package 编译后的效果: ![20241229154732_GlI663O1.webp](https://cdn.dong4j.site/source/image/20241229154732_GlI663O1.webp) 这个得在 pom.xml 加对应的配置 ( 这是在使用 mybatis 时遇到的坑): ```java java-io src/main/java **/*.properties src/main/resources maven-compiler-plugin 1.8 1.8 ``` ## [Java并发利器:ThreadPoolExecutor 高效使用指南](https://blog.dong4j.site/posts/f1e67b7e.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 有时候,系统需要处理非常多的执行时间很短的请求,如果每一个请求都开启一个新线程的话,系统就要不断的进行线程的创建和销毁,有时花在创建和销毁线程上的时间会比线程真正执行的时间还长。而且当线程数量太多时,系统不一定能受得了。 使用线程池主要为了解决一下几个问题: - 通过重用线程池中的线程,来减少每个线程创建和销毁的性能开销。 - 对线程进行一些维护和管理,比如定时开始,周期执行,并发数控制等等。 # Executor Executor 是一个接口,跟线程池有关的基本都要跟他打交道。下面是常用的 ThreadPoolExecutor 的关系。 ![20241229154732_0gAWOlv1.webp](https://cdn.dong4j.site/source/image/20241229154732_0gAWOlv1.webp) Executor 接口很简单,只有一个 execute 方法。 ExecutorService 是 Executor 的子接口,增加了一些常用的对线程的控制方法,之后使用线程池主要也是使用这些方法。 AbstractExecutorService 是一个抽象类。ThreadPoolExecutor 就是实现了这个类。 # ThreadPoolExecutor ## 构造方法 ThreadPoolExecutor 是线程池的真正实现,他通过构造方法的一系列参数,来构成不同配置的线程池。常用的构造方法有下面四个: ![20241229154732_MPBClhGO.webp](https://cdn.dong4j.site/source/image/20241229154732_MPBClhGO.webp) ```java ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue) ``` ```java ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory) ``` ```java ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, RejectedExecutionHandler handler) ``` ```java ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) ``` ## 构造方法参数说明 - corePoolSize 核心线程数,默认情况下核心线程会一直存活,即使处于闲置状态也不会受存 `keepAliveTime` 限制。除非将 `allowCoreThreadTimeOut` 设置为 `true`。 - maximumPoolSize 线程池所能容纳的最大线程数。超过这个数的线程将被阻塞。当任务队列为没有设置大小的 LinkedBlockingDeque 时,这个值无效。 - keepAliveTime 非核心线程的闲置超时时间,超过这个时间就会被回收。 - unit 指定 `keepAliveTime` 的单位,如 `TimeUnit.SECONDS`。当将 `allowCoreThreadTimeOut` 设置为 `true` 时对 corePoolSize 生效。 - workQueue 线程池中的任务队列. 常用的有三种队列,`SynchronousQueue`,`LinkedBlockingDeque`,`ArrayBlockingQueue`。 - threadFactory 线程工厂,提供创建新线程的功能。ThreadFactory 是一个接口,只有一个方法 ```java public interface ThreadFactory { Thread newThread(Runnable r); } ``` 通过线程工厂可以对线程的一些属性进行定制。 默认的工厂: ```java static class DefaultThreadFactory implements ThreadFactory { private static final AtomicInteger poolNumber = new AtomicInteger(1); private final ThreadGroup group; private final AtomicInteger threadNumber = new AtomicInteger(1); private final String namePrefix; DefaultThreadFactory() { SecurityManager var1 = System.getSecurityManager(); this.group = var1 != null?var1.getThreadGroup():Thread.currentThread().getThreadGroup(); this.namePrefix = "pool-" + poolNumber.getAndIncrement() + "-thread-"; } public Thread newThread(Runnable var1) { Thread var2 = new Thread(this.group, var1, this.namePrefix + this.threadNumber.getAndIncrement(), 0L); if(var2.isDaemon()) { var2.setDaemon(false); } if(var2.getPriority() != 5) { var2.setPriority(5); } return var2; } } ``` - RejectedExecutionHandler `RejectedExecutionHandler` 也是一个接口,只有一个方法 ```java public interface RejectedExecutionHandler { void rejectedExecution(Runnable var1, ThreadPoolExecutor var2); } ``` 当线程池中的资源已经全部使用,添加新线程被拒绝时,会调用 RejectedExecutionHandler 的 rejectedExecution 方法。 ## 线程池规则 线程池的线程执行规则跟任务队列有很大的关系。 - 下面都假设任务队列没有大小限制: 1. 如果线程数量核心线程数,但核心线程数,但核心线程数,并且 > 最大线程数,当任务队列是 LinkedBlockingDeque,会将超过核心线程的任务放在任务队列中排队。也就是当任务队列是 LinkedBlockingDeque 并且没有大小限制时,线程池的最大线程数设置是无效的,他的线程数最多不会超过核心线程数。 2. 如果线程数量 > 核心线程数,并且 > 最大线程数,当任务队列是 SynchronousQueue 的时候,会因为线程池拒绝添加任务而抛出异常。 - 任务队列大小有限时 1. 当 LinkedBlockingDeque 塞满时,新增的任务会直接创建新线程来执行,当创建的线程数量超过最大线程数量时会抛异常。 2. SynchronousQueue 没有数量限制。因为他根本不保持这些任务,而是直接交给线程池去执行。当任务数量超过最大线程数时会直接抛异常。 ## 规则验证 ### 前提 所有的任务都是下面这样的,睡眠两秒后打印一行日志: ```java Runnable myRunnable = new Runnable() { @Override public void run() { try { Thread.sleep(2000); System.out.println(Thread.currentThread().getName() + " run"); } catch (InterruptedException e) { e.printStackTrace(); } } }; ``` 所有验证过程都是下面这样,先执行三个,再执行三个,8 秒后,各看一次信息 ```java executor.execute(myRunnable); executor.execute(myRunnable); executor.execute(myRunnable); System.out.println("---先开三个---"); System.out.println("核心线程数" + executor.getCorePoolSize()); System.out.println("线程池数" + executor.getPoolSize()); System.out.println("队列任务数" + executor.getQueue().size()); executor.execute(myRunnable); executor.execute(myRunnable); executor.execute(myRunnable); System.out.println("---再开三个---"); System.out.println("核心线程数" + executor.getCorePoolSize()); System.out.println("线程池数" + executor.getPoolSize()); System.out.println("队列任务数" + executor.getQueue().size()); Thread.sleep(8000); System.out.println("----8秒之后----"); System.out.println("核心线程数" + executor.getCorePoolSize()); System.out.println("线程池数" + executor.getPoolSize()); System.out.println("队列任务数" + executor.getQueue().size()); ``` ### 验证 1 1. 核心线程数为 6,最大线程数为 10。超时时间为 5 秒 ```java ThreadPoolExecutor executor = new ThreadPoolExecutor(6, 10, 5, TimeUnit.SECONDS, new SynchronousQueue()); ``` ``` --- 先开三个 --- 核心线程数 6 线程池线程数 3 队列任务数 0 --- 再开三个 --- 核心线程数 6 线程池线程数 6 队列任务数 0 pool-1-thread-1 run pool-1-thread-6 run pool-1-thread-5 run pool-1-thread-3 run pool-1-thread-4 run pool-1-thread-2 run ----8 秒之后 ---- 核心线程数 6 线程池线程数 6 队列任务数 0 ``` 可以看到每个任务都是是直接启动一个核心线程来执行任务,一共创建了 6 个线程,不会放入队列中。8 秒后线程池还是 6 个线程,核心线程默认情况下不会被回收,不收超时时间限制。 ### 验证 2 1. 核心线程数为 3,最大线程数为 6。超时时间为 5 秒, 队列是 LinkedBlockingDeque ```java ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 6, 5, TimeUnit.SECONDS, new LinkedBlockingDeque()); ``` ``` --- 先开三个 --- 核心线程数 3 线程池线程数 3 队列任务数 0 --- 再开三个 --- 核心线程数 3 线程池线程数 3 队列任务数 3 pool-1-thread-3 run pool-1-thread-1 run pool-1-thread-2 run pool-1-thread-3 run pool-1-thread-1 run pool-1-thread-2 run ----8 秒之后 ---- 核心线程数 3 线程池线程数 3 队列任务数 0 ``` 当任务数超过核心线程数时,会将超出的任务放在队列中,只会创建 3 个线程重复利用。 ### 验证 3 1. 核心线程数为 3,最大线程数为 6。超时时间为 5 秒, 队列是 SynchronousQueue ```java ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 6, 5, TimeUnit.SECONDS, new SynchronousQueue()); ``` ``` --- 先开三个 --- 核心线程数 3 线程池线程数 3 队列任务数 0 --- 再开三个 --- 核心线程数 3 线程池线程数 6 队列任务数 0 pool-1-thread-2 run pool-1-thread-3 run pool-1-thread-6 run pool-1-thread-4 run pool-1-thread-5 run pool-1-thread-1 run ----8 秒之后 ---- 核心线程数 3 线程池线程数 3 队列任务数 0 ``` 当队列是 SynchronousQueue 时,超出核心线程的任务会创建新的线程来执行,看到一共有 6 个线程。但是这些线程是费核心线程,收超时时间限制,在任务完成后限制超过 5 秒就会被回收。所以最后看到线程池还是只有三个线程。 ### 验证 4 1. 核心线程数是 3,最大线程数是 4,队列是 LinkedBlockingDeque ```java ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 4, 5, TimeUnit.SECONDS, new LinkedBlockingDeque()); ``` ```java --- 先开三个 --- 核心线程数 3 线程池线程数 3 队列任务数 0 --- 再开三个 --- 核心线程数 3 线程池线程数 3 队列任务数 3 pool-1-thread-3 run pool-1-thread-1 run pool-1-thread-2 run pool-1-thread-3 run pool-1-thread-1 run pool-1-thread-2 run ----8 秒之后 ---- 核心线程数 3 线程池线程数 3 队列任务数 0 ``` LinkedBlockingDeque 根本不受最大线程数影响。 但是当 LinkedBlockingDeque 有大小限制时就会受最大线程数影响了 4.1 比如下面,将队列大小设置为 2. ```java ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 4, 5, TimeUnit.SECONDS, new LinkedBlockingDeque(2)); ``` ```java --- 先开三个 --- 核心线程数 3 线程池线程数 3 队列任务数 0 --- 再开三个 --- 核心线程数 3 线程池线程数 4 队列任务数 2 pool-1-thread-2 run pool-1-thread-1 run pool-1-thread-4 run pool-1-thread-3 run pool-1-thread-1 run pool-1-thread-2 run ----8 秒之后 ---- 核心线程数 3 线程池线程数 3 队列任务数 0 ``` 首先为三个任务开启了三个核心线程 1,2,3,然后第四个任务和第五个任务加入到队列中,第六个任务因为队列满了,就直接创建一个新线程 4,这是一共有四个线程,没有超过最大线程数。8 秒后,非核心线程收超时时间影响回收了,因此线程池只剩 3 个线程了。 4.2 将队列大小设置为 1 ```java ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 4, 5, TimeUnit.SECONDS, new LinkedBlockingDeque(1)); ``` ```java Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task com.sunlinlin.threaddemo.Main$1@677327b6 rejected from java.util.concurrent.ThreadPoolExecutor@14ae5a5[Running, pool size = 4, active threads = 4, queued tasks = 1, completed tasks = 0] at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2047) at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:823) at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1369) at com.sunlinlin.threaddemo.Main.main(Main.java:35) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147) --- 先开三个 --- 核心线程数 3 线程池线程数 3 队列任务数 0 pool-1-thread-1 run pool-1-thread-2 run pool-1-thread-3 run pool-1-thread-4 run pool-1-thread-1 run ``` 直接出错在第 6 个 execute 方法上。因为核心线程是 3 个,当加入第四个任务的时候,就把第四个放在队列中。加入第五个任务时,因为队列满了,就创建新线程执行,创建了线程 4。当加入第六个线程时,也会尝试创建线程,但是因为已经达到了线程池最大线程数,所以直接抛异常了。 ### 验证 5 1. 核心线程数是 3 ,最大线程数是 4,队列是 SynchronousQueue ```java ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 4, 5, TimeUnit.SECONDS, new SynchronousQueue()); ``` ``` Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task com.sunlinlin.threaddemo.Main$1@14ae5a5 rejected from java.util.concurrent.ThreadPoolExecutor@7f31245a[Running, pool size = 4, active threads = 4, queued tasks = 0, completed tasks = 0] at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2047) at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:823) at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1369) at com.sunlinlin.threaddemo.Main.main(Main.java:34) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147) --- 先开三个 --- 核心线程数 3 线程池线程数 3 队列任务数 0 pool-1-thread-2 run pool-1-thread-3 run pool-1-thread-4 run pool-1-thread-1 run ``` 这次在添加第五个任务时就报错了,因为 SynchronousQueue 各奔不保存任务,收到一个任务就去创建新线程。所以第五个就会抛异常了。 ![死循环懵逼.gif](https://cdn.dong4j.site/source/image/%E6%AD%BB%E5%BE%AA%E7%8E%AF%E6%87%B5%E9%80%BC.gif) ## [Java线程池深度解析:四种创建方式详解](https://blog.dong4j.site/posts/c7d0ca57.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 总结线程池的使用方式 Java 通过 Executors 提供四种线程池, 分别为: 1. newCachedThreadPool 创建一个可缓存线程池, 如果线程池长度超过处理需要, 可灵活回收空闲线程, 若无可回收, 则新建线程. 2. newFixedThreadPool 创建一个定长线程池, 可控制线程最大并发数, 超出的线程会在队列中等待. 3. newScheduledThreadPool 创建一个定长线程池, 支持定时及周期性任务执行. 4. newSingleThreadExecutor 创建一个单线程化的线程池, 它只会用唯一的工作线程来执行任务, 保证所有任务按照指定顺序 (FIFO, LIFO, 优先级) 执行. 线程池比较单线程的优势在于: - 重用存在的线程, 减少对象创建、消亡的开销, 性能佳. - 可有效控制最大并发线程数, 提高系统资源的使用率, 同时避免过多资源竞争, 避免堵塞. - 提供定时执行、定期执行、单线程、并发数控制等功能. newCachedThreadPool ```java public static void main(String[] args) { ExecutorService cachedThreadPool = Executors.newCachedThreadPool(); for (int i = 0; i < 10; i++) { final int index = i; try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } cachedThreadPool.execute(new Runnable() { public void run() { System.out.println(index); } }); } } ``` ```java public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue()); } ``` 创建一个可缓存线程池, 如果线程池长度超过处理需要, 可灵活回收空闲线程, 若无可回收, 则新建线程. 这里的线程池是无限大的, 当一个线程完成任务之后, 这个线程可以接下来完成将要分配的任务, 而不是创建一个新的线程, java api 1.7 will reuse previously constructed threads when they are available. newFixedThreadPool ```java public static void main(String[] args) { ExecutorService fixedThreadPool = Executors.newFixedThreadPool(3); for (int i = 0; i < 10; i++) { final int index = i; fixedThreadPool.execute(new Runnable() { public void run() { try { System.out.println(index); Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } } }); } } ``` ```java public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue()); } ``` 创建一个定长线程池, 可控制线程最大并发数, 超出的线程会在队列中等待 定长线程池的大小最好根据系统资源进行设置. 如 Runtime.getRuntime().availableProcessors() ```java public static void main(String[] args) { ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(5); for (int i = 0; i < 10; i++) { scheduledThreadPool.schedule(new Runnable() { public void run() { System.out.println("delay 3 seconds"); } }, 3, TimeUnit.SECONDS); } } ``` ```java public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); } public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue) { this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, Executors.defaultThreadFactory(), defaultHandler); } ``` newSingleThreadExecutor ```java public static void main(String[] args) { ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); for (int i = 0; i < 10; i++) { final int index = i; singleThreadExecutor.execute(new Runnable() { public void run() { try { System.out.println(index); Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } }); } } ``` ```java public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue())); } ``` 按顺序来执行线程任务 但是不同于单线程, 这个线程池只是只能存在一个线程, 这个线程死后另外一个线程会补上 ```java /** * ThreadPoolExecutor 类的使用方法 * 实现高并发: 在线程类中的 run()方法内设置 Thread.sleep(long delta); delta 取值为: (并发开始时间戳 - 线程开始时间戳) * Created by Administrator on 2016/11/19. */ public class ThreadPoolExecutorTest { public static void main(String[] args) { // 设置核心池大小 int corePoolSize = 5; // 设置线程池最大能接受多少线程 // 当前线程数大于 corePoolSize、小于 maximumPoolSize 时, 超出 corePoolSize 的线程数的生命周期 long keepActiveTime = 200; // 设置时间单位, 秒 TimeUnit timeUnit = TimeUnit.SECONDS; // 设置线程池缓存队列的排队策略为 FIFO, 并且指定缓存队列大小为 5 BlockingQueue workQueue = new ArrayBlockingQueue(5); // 创建 ThreadPoolExecutor 线程池对象, 并初始化该对象的各种参数 ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepActiveTime, timeUnit,workQueue); // 往线程池中循环提交线程 for (int i = 0; i < 15; i++) { // 创建线程类对象 MyTask myTask = new MyTask(i); // 开启线程 executor.execute(myTask); // 获取线程池中线程的相应参数 System.out.println("线程池中线程数目: " +executor.getPoolSize() + ", 队列中等待执行的任务数目: "+executor.getQueue().size() + ", 已执行完的任务数目: "+executor.getCompletedTaskCount()); } // 待线程池以及缓存队列中所有的线程任务完成后关闭线程池. executor.shutdown(); } } /** * 线程类 */ class MyTask implements Runnable { private int num; public MyTask(int num) { this.num = num; } @Override public void run() { System.out.println("正在执行task " + num ); try { Thread.currentThread().sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("task " + num + "执行完毕"); } /** * 获取(未来时间戳 - 当前时间戳)的差值, * 也即是: (每个线程的睡醒时间戳 - 每个线程的入睡时间戳) * 作用: 用于实现多线程高并发 * @return * @throws ParseException */ public long getDelta() throws ParseException { // 获取当前时间戳 long t1 = new Date().getTime(); // 获取未来某个时间戳(自定义, 可写入配置文件) String str = "2016-11-11 15:15:15"; SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); long t2 = simpleDateFormat.parse(str).getTime(); return t2 - t1; } } ``` ## [掌握Java GC:深入理解并高效配置GC参数](https://blog.dong4j.site/posts/29d7a15f.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 在 Java 中, GC 的对象是堆空间和永久区 ### 引用计数算法 ![20241229154732_Xidtz6g4.webp](https://cdn.dong4j.site/source/image/20241229154732_Xidtz6g4.webp) - Java 不再使用 - Python,COM,ActionScript3 使用 - 性能差 - 不能解决循环引用问题 ### 标记 - 清除算法 #### 标记阶段 在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象 #### 清除阶段 清除所有未被标记的对象 ### 标记 - 压缩算法 ![20241229154732_8Sh9Pelm.webp](https://cdn.dong4j.site/source/image/20241229154732_8Sh9Pelm.webp) 标记 - 压缩算法适合用于存活对象较多的场合,如老年代. 它在标记 - 清除算法的基础上做了一些优化. #### 标记阶段 从根节点开始,对所有可达对象做一次标记 #### 压缩阶段 将所有存活对象压缩到内存一端, 然后清除边界外的所有空间 ### 复制算法 ![20241229154732_GNfE6QNt.webp](https://cdn.dong4j.site/source/image/20241229154732_GNfE6QNt.webp) - 与标记 - 清除算法相比, 复制算法是一种相对高效的回收方式 - 不适合存活对象较多的场合, 如老年代 - 将原来的内存分为相同大小的两块, 每次只是用其中一块, 在垃圾回收时, 将正在是用的内存中的对象复制到未使用的内存块中, 之后清除正在是用的内存中的所有对象, 交换两个内存的角色, 完成垃圾回收 问题: - 空间浪费, 只是用了一半 是用标记清理和复制算法配置回收垃圾 ![20241229154732_xecT49yQ.webp](https://cdn.dong4j.site/source/image/20241229154732_xecT49yQ.webp) 1. 在最上面那块大的区域产生新对象。 2. 大对象不太适合在复制空间,因为复制空间的容量是有限的,所以需要一个大的空间做担保,所以让老年代做担保。这样产生的大对象直接进入老年代。 3. 每一次 GC,对象的年龄就会 +1,一个对象在几次 GC 后仍然没有被回收,则这个对象就是一个老年对象。老年对象是一个长期被引用的对象,老年对象将被放入老年代。 4. 步骤 1 中产生的小对象,将进入到复制空间。原先复制空间中的新对象也将被复制到另一块复制空间 5. 清空垃圾对象 ![20241229154732_2Cv7w0uQ.webp](https://cdn.dong4j.site/source/image/20241229154732_2Cv7w0uQ.webp) 一个堆分为 new generation(新生代) , tenured generation(老年代) 和 compacting perm gen。 而 new generation 分为 eden space,from space(有些地方称为 s0 和 s1,表示幸存代) , to space。 eden space 就是上面那种图中,对象产生的地方。 from space 和 to space 是两块大小一样的区域,是上图中的复制空间。 new generation 的可用总空间就是 eden space+ 一块复制空间(另一块不算),但是根据 new generation 的地址访问可以算出是 eden space + 两块复制空间区域,所以复制算法浪费了一部分空间。 ## 分代思想 依据对象的存活周期进行分类,短命对象归为新生代,长命对象归为老年代。 根据不同代的特点,选取合适的收集算法 - 少量对象存活,适合复制算法 - 大量对象存活,适合标记清理或者标记压缩 - 进入老年代的对象有两种情况: 1. 新生代空间不够,老年代做担保存放一些大对象 2. 某些对象多次 GC 后仍然存在,进入老年代。 老年代的大多数对象都是第 2 种情况,所以老年代的对象的生命周期比较长,GC 的发生也比较少,会有大量对象存活,所以不用复制算法,而改为标记清理或者标记压缩。 所有的算法,需要能够识别一个垃圾对象,因此需要给出一个可触及性的定义 ### 可触及性 从根节点可以触及到这个对象 可复活的 一旦所有引用被释放,就是可复活状态 因为在 finalize() 中可能复活该对象 不可触及的 在 finalize() 后,可能会进入不可触及状态 不可触及的对象不可能复活 可以回收 下面举个例子来说明可复活这个状态: ```java public class CanReliveObj{ public static CanReliveObj obj; @Override protected void finalize() throws Throwable{ super.finalize(); System.out.println("CanReliveObj finalize called"); obj = this; } @Override public String toString(){ return "I am CanReliveObj"; } public static void main(String[] args) throws InterruptedException{ obj = new CanReliveObj(); obj = null; // 可复活 System.gc(); Thread.sleep(1000); if (obj == null){ System.out.println("obj 是 null"); } else{ System.out.println("obj 可用"); } System.out.println("第二次gc"); obj = null; // 不可复活 System.gc(); Thread.sleep(1000); if (obj == null){ System.out.println("obj 是 null"); } else{ System.out.println("obj 可用"); } } } ``` 输出: ``` CanReliveObj finalize called obj 可用 第二次 gc obj 是 null ``` 一般我们认为,对象赋值 null 后,对象就可以被 GC 了,在上述实例中,在 finalize 中,又将 obj=this,使对象复活。因为 finalize 只能调用一次,所以第二次 GC 时,obj 被回收。 因此对于 finalize 会有这样的建议: - 经验:避免使用 finalize(),操作不慎可能导致错误。 - finalize 优先级低,何时被调用(在 GC 时被调用,何时发生 GC 不确定) 不确定 - 可以使用 try-catch-finally 来替代它 另外在之前,我们一直在提到从根出发,那么根是指哪些对象呢? - 栈中引用的对象 - 方法区中静态成员或者常量引用的对象(全局对象) - JNI 方法栈中引用对象 ## Stop-The-World Stop-The-World 是 Java 中一种全局暂停的现象。 全局停顿,所有 Java 代码停止,native 代码可以执行,但不能和 JVM 交互 多半由于 GC 引起,当然 Dump 线程、死锁检查、堆 Dump 都有可能引起 Stop-The-World **GC 时为什么会有全局停顿?** 类比在聚会时打扫房间,聚会时很乱,又有新的垃圾产生,房间永远打扫不干净,只有让大家停止活动了,才能将房间打扫干净。 **危害** - 长时间服务停止,没有响应 - 遇到 HA 系统,可能引起主备切换,严重危害生产环境。 **新生代的 GC(Minor GC),停顿时间比较短** **老年代的 GC(Full GC),停顿时间可能比较长** ## 串行收集器 ![20241229154732_z9Ogdnsj.webp](https://cdn.dong4j.site/source/image/20241229154732_z9Ogdnsj.webp) 串行收集器是最古老,最稳定以及效率高的收集器 可能会产生较长的停顿,只使用一个线程去回收 -XX:+UseSerialGC - 新生代、老年代使用串行回收 - 新生代复制算法 - 老年代标记 - 压缩 ![20241229154732_fKWU1kDv.webp](https://cdn.dong4j.site/source/image/20241229154732_fKWU1kDv.webp) ## 并行收集器 ### ParNew ![20241229154732_msvwo0y6.webp](https://cdn.dong4j.site/source/image/20241229154732_msvwo0y6.webp) - -XX:+UseParNewGC(new 代表新生代,所以适用于新生代) - 新生代并行 - 老年代串行 - Serial 收集器新生代的并行版本 - 复制算法 - 多线程,需要多核支持 - -XX:ParallelGCThreads 限制线程数量 ### Parallel ![20241229154732_HBBpLaFF.webp](https://cdn.dong4j.site/source/image/20241229154732_HBBpLaFF.webp) - 类似 ParNew - 新生代复制算法 - 老年代 标记 - 压缩 - 更加关注吞吐量 - -XX:+UseParallelGC - 使用 Parallel 收集器 + 老年代串行 - -XX:+UseParallelOldGC - 使用 Parallel 收集器 + 并行老年代 - -XX:MaxGCPauseMills - 最大停顿时间,单位毫秒 - GC 尽力保证回收时间不超过设定值 - -XX:GCTimeRatio - 0-100 的取值范围 - 垃圾收集时间占总时间的比 - 默认 99,即最大允许 1% 时间做 GC - 这两个参数是矛盾的。因为停顿时间和吞吐量不可能同时调优 ### CMS 收集器 ![20241229154732_XvTdgL4p.webp](https://cdn.dong4j.site/source/image/20241229154732_XvTdgL4p.webp) - Concurrent Mark Sweep 并发标记清除 - 标记 - 清除算法 - 与标记 - 压缩相比 - 并发阶段会降低吞吐量 - 老年代收集器(新生代使用 ParNew) - -XX:+UseConcMarkSweepGC - 初始标记 - 根可以直接关联到的对象 - 速度快 - 并发标记(和用户线程一起) - 主要标记过程,标记全部对象 - 重新标记 - 由于并发标记时,用户线程依然运行,因此在正式清理前,再做修正 - 并发清除(和用户线程一起) - 基于标记结果,直接清理对象 #### 特点 - 尽可能降低停顿 - 会影响系统整体吞吐量和性能 - 比如,在用户线程运行过程中,分一半 CPU 去做 GC,系统性能在 GC 阶段,反应速度就下降一半 - 清理不彻底 - 因为在清理阶段,用户线程还在运行,会产生新的垃圾,无法清理 - 因为和用户线程一起运行,不能在空间快满时再清理 - -XX:CMSInitiatingOccupancyFraction 设置触发 GC 的阈值 - 如果不幸内存预留空间不够,就会引起 concurrent mode failure - -XX:+ UseCMSCompactAtFullCollection Full GC 后,进行一次整理 - 整理过程是独占的,会引起停顿时间变长 - -XX:+CMSFullGCsBeforeCompaction - 设置进行几次 Full GC 后,进行一次碎片整理 - -XX:ParallelCMSThreads - 设定 CMS 的线程数量 CMS 的提出是想改善 GC 的停顿时间,在 GC 过程中的确做到了减少 GC 时间,但是同样导致产生大量内存碎片,又需要消耗大量时间去整理碎片,从本质上并没有改善时间。 ## GC 参数整理 -XX:+UseSerialGC:在新生代和老年代使用串行收集器 -XX:SurvivorRatio:设置 eden 区大小和 survivior 区大小的比例 -XX:NewRatio: 新生代和老年代的比 -XX:+UseParNewGC:在新生代使用并行收集器 -XX:+UseParallelGC :新生代使用并行回收收集器 -XX:+UseParallelOldGC:老年代使用并行回收收集器 -XX:ParallelGCThreads:设置用于垃圾回收的线程数 -XX:+UseConcMarkSweepGC:新生代使用并行收集器,老年代使用 CMS+ 串行收集器 -XX:ParallelCMSThreads:设定 CMS 的线程数量 -XX:CMSInitiatingOccupancyFraction:设置 CMS 收集器在老年代空间被使用多少后触发 -XX:+UseCMSCompactAtFullCollection:设置 CMS 收集器在完成垃圾收集后是否要进行一次内存碎片的整理 -XX:CMSFullGCsBeforeCompaction:设定进行多少次 CMS 垃圾回收后,进行一次内存压缩 -XX:+CMSClassUnloadingEnabled:允许对类元数据进行回收 -XX:CMSInitiatingPermOccupancyFraction:当永久区占用率达到这一百分比时,启动 CMS 回收 -XX:UseCMSInitiatingOccupancyOnly:表示只在到达阀值的时候,才进行 CMS 回收 ## [volatile 知识点汇总:掌握并发编程利器](https://blog.dong4j.site/posts/8705625e.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) volatile 关键字的 2 个语义 - 内存可见性 - 阻止重排序 volatile 不能保证原子性 **volatile 关键字的 2 层含义:** 用 volatile 修饰的变量, 线程在每次使用变量的时候, 都会读取变量修改后的最新的值. 作为指令关键字, 确保本条指令不会因编译器的优化而省略, 且要求每次直接读值. ## 可见性 可见性是指 当一个线程修改了一个共享变量, 其他线程能够立刻得知这个修改. 这里有必要了解一下 Java 的内存模型 ![20241229154732_INvb9WUG.webp](https://cdn.dong4j.site/source/image/20241229154732_INvb9WUG.webp) 被 volatile 修饰的变量, 当线程需要使用这个变量时, 回去主内存中读取, 然后加载到自己的工作线程中, 工作线程中的变量只是主存变量的一个拷贝, 当使用完这个变量后, 会刷新会主存中. ![20241229154732_dptrw0mD.webp](https://cdn.dong4j.site/source/image/20241229154732_dptrw0mD.webp) 当数据中主内存复制到工作内存存储时, 必须出现两个动作: 1. 由主内存执行的 read 操作 2. 有工作内存执行相应的 load 操作 当数据从工作内存拷贝到主内存时, 也会有两个操作: 1. 用工作内存执行的 store 操作 2. 用主内存执行相应的 write 操作 volatile 的特殊规则就是 read、load、use 必须连续出现. assign、store、write 动作必须连续出现. 所以使用 volatile 变量能够保证必须先从主内存刷新最新的值, 每次修改后必须立即同步回主内存当中. 所以 colatile 的可见性很适合用来控制并发: ```java public class VolatileDemo { private static volatile boolean flag; public static void shutdown(){ flag=true; } public static void main(String[] args) throws InterruptedException{ VolatileDemo m = new VolatileDemo(); for(int i=0;i<20;i++){ new Thread(new Runnable() { public void run() { while(!flag){ System.out.println("aaa"); } } }).start(); } Thread.sleep(2000); shutdown(); } } ``` 当调用 shutdown() 时, 能保证所有线程立刻停止. ## volatile 的禁止指令重排序 指令重排是 编译器和 cup 在不影响执行结果的情况下, 进行的一种优化策略. 在 Java 中普遍的变量仅仅会保证在该方法的执行过程中所有依赖的赋值结果的地方都能获取到正确的结果, 而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致. 因为在一个线程的方法执行过程中无法感知到这点, 这也就是 Java 内存模型中描述的所谓“线程内表现为串行的语义”. ### 有序性 计算机在执行代码时, 不一定按照程序的顺序来执行. ```java class OrderExample { int a = 0; boolean flag = false; public void writer() { a = 1; flag = true; } public void reader() { if (flag) { int i = a +1; } } } ``` 比如上述代码, 两个方法分别被两个线程调用. 按照常理, 写线程应该先执行 a=1, 再执行 flag=true. 当读线程进行读的时候, i=2; 但是因为 a=1 和 flag=true, 并没有逻辑上的关联. 所以有可能执行的顺序颠倒, 有可能先执行 flag=true, 再执行 a=1. 这时当 flag=true 时, 切换到读线程, 此时 a=1 还没有执行, 那么读线程将 i=1. **指令重排可以使流水线更加顺畅** 当然指令重排的原则是不能破坏串行程序的语义, 例如 a=1,b=a+1, 这种指令就不会重排了, 因为重排的串行结果和原先的不同. 在 Java 里面, 可以通过 volatile 关键字来保证一定的“有序性”. 另外可以通过 synchronized 和 Lock 来保证有序性, 很显然, synchronized 和 Lock 保证每个时刻是有一个线程执行同步代码, 相当于是让线程顺序执行同步代码, 自然就保证了有序性. ### Happen-Before Java 内存模型具备一些先天的“有序性”, 即不需要通过任何手段就能够得到保证的有序性, 这个通常也称为 happen-before 原则. 如果两个操作的执行次序无法从 happen-before 原则推导出来, 那么它们就不能保证它们的有序性, 虚拟机可以随意地对它们进行重排序. - 程序顺序原则: 一个线程内保证语义的串行性 (写在前面的先发生, 用来保证单线程结果的正确性) - volatile 规则: volatile 变量的写, 先发生于读, 这保证了 volatile 变量的可见性 - 锁规则: 解锁(unlock)必然发生在随后的加锁(lock)前 - 传递性: A 先于 B, B 先于 C, 那么 A 必然先于 C - 线程的 start() 方法先于它的每一个动作 - 线程的所有操作先于线程的终结(Thread.join()) - 线程的中断(interrupt())先于被中断线程的代码 - 对象的构造函数执行结束先于 finalize() 方法 **为什么 Happen-Before 原则不被指令重排影响?** 例如你让一个 volatile 的 integer 自增(i++), 其实要分成 3 步: 1)读取 volatile 变量值到 local; 2)增加变量的值;3)把 local 的值写回, 让其它的线程可见. 这 3 步的 jvm 指令为: ``` mov 0xc(%r10),%r8d ; Load inc %r8d ; Increment mov %r8d,0xc(%r10) ; Store lock addl $0x0,(%rsp) ; StoreLoad Barrier ``` StoreLoad Barrier 就是内存屏障 内存屏障(memory barrier)是一个 CPU 指令. 基本上, 它是这样一条指令: 1. 确保一些特定操作执行的顺序; 2. 影响一些数据的可见性 (可能是某些指令执行后的结果). 编译器和 CPU 可以在保证输出结果一样的情况下对指令重排序, 使性能得到优化. 插入一个内存屏障, 相当于告诉 CPU 和编译器先于这个命令的必须先执行, 后于这个命令的必须后执行. 内存屏障另一个作用是强制更新一次不同 CPU 的缓存. 例如, 一个写屏障会把这个屏障前写入的数据刷新到缓存, 这样任何试图读取该数据的线程将得到最新值, 而不用考虑到底是被哪个 cpu 核心或者哪颗 CPU 执行的. **内存屏障和 volatile 的关系** 上面的虚拟机指令里面有提到, 如果你的字段是 volatile, Java 内存模型将在写操作后插入一个 **写屏障指令**, 在读操作前插入一个 **读屏障指令**. 这意味着如果你对一个 volatile 字段进行写操作, 你必须知道: 1. 一旦你完成写入, 任何访问这个字段的线程将会得到最新的值. 2. 在你写入前, 会保证所有之前发生的事已经发生, 并且任何更新过的数据值也是可见的, 因为内存屏障会把之前的写入值都刷新到缓存. 明白了内存屏障这个 CPU 指令, 回到前面的 JVM 指令: 从 load 到 use 到 assign 到 store 到内存屏障, 一共 4 步, 其中最后一步 jvm 让这个最新的变量的值在所有线程可见, 也就是最后一步让所有的 CPU 内核都获得了最新的值, 但中间的几步(从 Load 到 Store)是不安全的, 中间如果其他的 CPU 修改了值将会丢失. ### volatile 禁止指令重排的两层含义 1. 当程序执行到 volatile 变量的读操作或者写操作时, 在其前面的操作肯定已经全部进行, 且结果对后面的操作可见; 且后面的操作还没有进行; 2. 在进行指令优化时, 不能将对 volatile 变量的访问语句放在气候执行, 也不能把 volatile 变量后面的语句放在其前面执行. ```java // x、y 为非 volatile 变量 // flag 为 volatile 变量 x = 2; // 语句 1 y = 0; // 语句 2 flag = true; // 语句 3 x = 4; // 语句 4 y = -1; // 语句 5 ``` 由于 flag 变量为 volatile 变量, 那么在进行指令重排序的过程的时候, 不会将语句 3 放到语句 1、语句 2 前面, 也不会讲语句 3 放到语句 4、语句 5 后面. 但是要注意语句 1 和语句 2 的顺序、语句 4 和语句 5 的顺序是不作任何保证的. 并且 volatile 关键字能保证, 执行到语句 3 时, 语句 1 和语句 2 必定是执行完毕了的, 且语句 1 和语句 2 的执行结果对语句 3、语句 4、语句 5 是可见的 ## vloatile 的并发问题 volatile 能保证可见性和禁止指令重排, 但是却不能保证原子性, 其实通过上面的分析也能得出这个结论. 比如 i++ 操作: 1. 读取 volatile 变量值到当前线程的工作内存中 2. 进行 i+1 计算 3. 将工作内存中的值写会到主内存, 让其他线程可见 现在有 2 个线程同时对 volatile 变量进行操作, 当第一个变量从主内存中读取了变量, 但是还未进行 i+1 操作, 此时第二个线程也从主存中读取了这个变量, 但是和线程一读取的值一样, 因为线程一还未将计算过的值刷新到主内存中, 此时 2 个线程都对变量进行 +1 操作, 然后刷新到主内存, 此时, 主内存中的值只是做了一次 +1 操作, 而不是 2 次. **某个线程将一个共享值优化到了内存中, 而另一个线程将这个共享值优化到了缓存中, 当修改内存中值的时候, 缓存中的值是不知道这个修改的.** ## [掌握Java同步机制:生产者消费者模式实战](https://blog.dong4j.site/posts/d3a1d32e.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 生产者消费者问题是研究多线程程序时绕不开的经典问题之一, 它描述是有一块缓冲区作为仓库, 生产者可以将产品放入仓库, 消费者则可以从仓库中取走产品 ## 生产者消费者问题 生产者消费者问题是研究多线程程序时绕不开的经典问题之一, 它描述是有一块缓冲区作为仓库, 生产者可以将产品放入仓库, 消费者则可以从仓库中取走产品. 解决生产者 / 消费者问题的方法可分为两类: 1. 采用某种机制保护生产者和消费者之间的同步; 2. 在生产者和消费者之间建立一个管道. 第一种方式有较高的效率, 并且易于实现, 代码的可控制性较好, 属于常用的模式. 第二种管道缓冲区不易控制, 被传输数据对象不易于封装等, 实用性不强. 同步问题核心在于: 如何保证同一资源被多个线程并发访问时的完整性. 常用的同步方法是采用信号或加锁机制, 保证资源在任意时刻至多被一个线程访问. Java 语言在多线程编程上实现了完全对象化, 提供了对同步机制的良好支持. 在 Java 中一共有五种方法支持同步, 其中前四个是同步方法, 一个是管道方法. - wait()/ notify() 方法 - await()/ signal() 方法 - BlockingQueue 阻塞队列方法 - Semaphore 方法 - PipedInputStream / PipedOutputStream ### wait()/ notify() 方法 wait()/ nofity() 方法是基类 Object 的两个方法, 也就意味着所有 Java 类都会拥有这两个方法, 这样, 我们就可以为任何对象实现同步机制. wait() 方法: 当缓冲区已满 / 空时, 生产者 / 消费者线程停止自己的执行, 放弃锁, 使自己处于等等状态, 让其他线程执行. notify() 方法: 当生产者 / 消费者向缓冲区放入 / 取出一个产品时, 向其他等待的线程发出可执行的通知, 同时放弃锁, 使自己处于等待状态. ```java public class Hosee { private static Integer count = 0; private static final Integer FULL = 10; private static final String LOCK = "LOCK"; // 生产者 class Producer implements Runnable { public void run() { for (int i = 0; i < 10; i++) { try { Thread.sleep(3000); } catch (Exception e) { e.printStackTrace(); } synchronized (LOCK) { while (count.equals(FULL)) { try { // 释放锁, 进去等待池等待唤醒, 生产者停止生产商品 LOCK.wait(); } catch (Exception e) { e.printStackTrace(); } } count++; System.out.println(Thread.currentThread().getName() + "生产者生产, 目前总共有" + count); // 唤醒等待池中的所有线程, 这里唤醒消费者消费商品 LOCK.notifyAll(); } } } } // 消费者 class Consumer implements Runnable { public void run() { for (int i = 0; i < 10; i++) { try { // 不放弃同步锁 Thread.sleep(3000); } catch (InterruptedException e1) { e1.printStackTrace(); } synchronized (LOCK) { while (count == 0) { try { // 放弃锁, 进入等待池等待被唤醒, 消费者停止消费商品 LOCK.wait(); } catch (Exception e) { e.printStackTrace(); } } count--; System.out.println(Thread.currentThread().getName() + "消费者消费, 目前总共有" + count); // 唤醒生产者生产商品 LOCK.notifyAll(); } } } } public static void main(String[] args) throws Exception { Hosee hosee = new Hosee(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); } } ``` ### await()/ signal() 方法 wait()和 notify() 必须在 synchronized 的代码块中使用 因为只有在获取当前对象的锁时才能进行这两个操作 否则会报异常 而 await()和 signal() 一般与 Lock() 配合使用. wait 是 Object 的方法, 而 await 只有部分类有, 如 Condition. await()/signal() 和新引入的锁定机制 Lock 直接挂钩, 具有更大的灵活性. ```java public class Test2 { private static Integer count = 0; private final Integer FULL = 10; final Lock lock = new ReentrantLock(); final Condition NotFull = lock.newCondition(); final Condition NotEmpty = lock.newCondition(); class Producer implements Runnable { public void run() { for (int i = 0; i < 10; i++) { try { Thread.sleep(3000); } catch (Exception e) { e.printStackTrace(); } lock.lock(); try { while (count == FULL) { try { NotFull.await(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } count++; System.out.println(Thread.currentThread().getName() + "生产者生产, 目前总共有" + count); NotEmpty.signal(); } finally { lock.unlock(); } } } } class Consumer implements Runnable { public void run() { for (int i = 0; i < 10; i++) { try { Thread.sleep(3000); } catch (InterruptedException e1) { e1.printStackTrace(); } lock.lock(); try { while (count == 0) { try { NotEmpty.await(); } catch (Exception e) { // TODO: handle exception e.printStackTrace(); } } count--; System.out.println(Thread.currentThread().getName() + "消费者消费, 目前总共有" + count); NotFull.signal(); } finally { lock.unlock(); } } } } public static void main(String[] args) throws Exception { Hosee hosee = new Hosee(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); } } ``` ### BlockingQueue 阻塞队列方法 put() 方法: 类似于我们上面的生产者线程, 容量达到最大时, 自动阻塞. take() 方法: 类似于我们上面的消费者线程, 容量为 0 时, 自动阻塞. ```java public class Hosee { private static Integer count = 0; final BlockingQueue bq = new ArrayBlockingQueue(10); class Producer implements Runnable { public void run() { for (int i = 0; i < 10; i++) { try { Thread.sleep(3000); } catch (Exception e) { e.printStackTrace(); } try { bq.put(1); count++; System.out.println(Thread.currentThread().getName() + "生产者生产, 目前总共有" + count); } catch (InterruptedException e) { e.printStackTrace(); } } } } class Consumer implements Runnable { public void run() { for (int i = 0; i < 10; i++) { try { Thread.sleep(3000); } catch (InterruptedException e1) { e1.printStackTrace(); } try { bq.take(); count--; System.out.println(Thread.currentThread().getName() + "消费者消费, 目前总共有" + count); } catch (Exception e) { e.printStackTrace(); } } } } public static void main(String[] args) throws Exception { Hosee hosee = new Hosee(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); } } ``` ### Semaphore 方法 Semaphore 信号量, 就是一个允许实现设置好的令牌. 也许有 1 个, 也许有 10 个或更多. 谁拿到令牌 (acquire) 就可以去执行了, 如果没有令牌则需要等待. 执行完毕, 一定要归还 (release) 令牌, 否则令牌会被很快用光, 别的线程就无法获得令牌而执行下去了 ```java public class Hosee{ int count = 0; final Semaphore notFull = new Semaphore(10); final Semaphore notEmpty = new Semaphore(0); final Semaphore mutex = new Semaphore(1); class Producer implements Runnable{ public void run(){ for (int i = 0; i < 10; i++){ try{ Thread.sleep(3000); } catch (Exception e){ e.printStackTrace(); } try{ notFull.acquire();// 顺序不能颠倒, 否则会造成死锁. mutex.acquire(); count++; System.out.println(Thread.currentThread().getName() + "生产者生产, 目前总共有" + count); } catch (Exception e){ e.printStackTrace(); } finally{ mutex.release(); notEmpty.release(); } } } } class Consumer implements Runnable{ public void run(){ for (int i = 0; i < 10; i++){ try{ Thread.sleep(3000); } catch (InterruptedException e1){ e1.printStackTrace(); } try{ notEmpty.acquire();// 顺序不能颠倒, 否则会造成死锁. mutex.acquire(); count--; System.out.println(Thread.currentThread().getName() + "消费者消费, 目前总共有" + count); } catch (Exception e){ e.printStackTrace(); } finally{ mutex.release(); notFull.release(); } } } } public static void main(String[] args) throws Exception{ Hosee hosee = new Hosee(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); } } ``` ### PipedInputStream / PipedOutputStream 这个类位于 java.io 包中, 是解决同步问题的最简单的办法, 一个线程将数据写入管道, 另一个线程从管道读取数据, 这样便构成了一种生产者 / 消费者的缓冲区编程模式. PipedInputStream/PipedOutputStream 只能用于多线程模式, 用于单线程下可能会引发死锁. ```java public class Hosee { final PipedInputStream pis = new PipedInputStream(); final PipedOutputStream pos = new PipedOutputStream(); { try { pis.connect(pos); } catch (IOException e) { e.printStackTrace(); } } class Producer implements Runnable { @Override public void run() { try{ while(true){ int b = (int) (Math.random() * 255); System.out.println("Producer: a byte, the value is " + b); pos.write(b); pos.flush(); } }catch(Exception e){ e.printStackTrace(); }finally{ try{ pos.close(); pis.close(); }catch(IOException e){ System.out.println(e); } } } } class Consumer implements Runnable { @Override public void run() { try{ while(true){ int b = pis.read(); System.out.println("Consumer: a byte, the value is " + String.valueOf(b)); } }catch(Exception e){ e.printStackTrace(); }finally{ try{ pos.close(); pis.close(); }catch(IOException e){ System.out.println(e); } } } } public static void main(String[] args) throws Exception { Hosee hosee = new Hosee(); new Thread(hosee.new Producer()).start(); new Thread(hosee.new Consumer()).start(); } } ``` ## [Java 多线程入门:从概念到实战](https://blog.dong4j.site/posts/71aa5641.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 几个多线程概念的介绍 线程状态转换 ![20241229154732_YGh7IluL.webp](https://cdn.dong4j.site/source/image/20241229154732_YGh7IluL.webp) - 新建 (new): 新创建一个线程对象 - 可运行 (runnable):线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start() 方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 cpu 的使用权 。 - 运行 (running):可运行状态(runnable) 的线程获得了 cpu 时间片(timeslice) ,执行程序代码。 - 阻塞 (block):阻塞状态是指线程因为某种原因放弃了 cpu 使用权,也即让出了 cpu timeslice,暂时停止运行。直到线程进入可运行(runnable) 状态,才有机会再次获得 cpu timeslice 转到运行 (running) 状态。阻塞的情况分三种: - 等待阻塞:运行 (running) 的线程执行 o.wait()方法,JVM 会把该线程放入等待队列 (waitting queue) 中。 - 同步阻塞:运行 (running) 的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则 JVM 会把该线程放入锁池 (lock pool) 中。 - 其他阻塞:运行 (running) 的线程执行 Thread.sleep(long ms)或 t.join()方法,或者发出了 I/O 请求时,JVM 会把该线程置为阻塞状态。当 sleep() 状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入可运行 (runnable) 状态。 - 死亡 (dead):线程 run()、main() 方法执行结束,或者因异常退出了 run() 方法,则该线程结束生命周期。死亡的线程不可再次复生。 ## 新建线程 ```java Thread thread = new Thread(); thread.start(); ``` 这样就开启了一个线程。 有一点需要注意的是 ```java Thread thread = new Thread(); thread.run(); ``` 直接调用 run 方法是无法开启一个新线程的。 start 方法其实是在一个新的操作系统线程上面去调用 run 方法。换句话说,直接调用 run 方法而不是调用 start 方法的话,它并不会开启新的线程,而是在调用 run 的当前的线程当中执行你的操作。 ```java Thread thread = new Thread("t1"){ @Override public void run(){ System.out.println(Thread.currentThread().getName()); } }; thread.start(); ``` 如果调用 start,则输出是 t1 ```java Thread thread = new Thread("t1"){ @Override public void run(){ System.out.println(Thread.currentThread().getName()); } }; thread.run(); ``` 如果是 run, 则输出 main。(直接调用 run 其实就是一个普通的函数调用而已,并没有达到多线程的作用) run 方法的实现有两种方式 第一种方式,直接覆盖 run 方法,就如刚刚代码中所示,最方便的用一个匿名类就可以实现。 ```java Thread thread = new Thread("t1") { @Override public void run() { // TODO Auto-generated method stub System.out.println(Thread.currentThread().getName()); } }; ``` 第二种方式 ```java # CreateThread3() 实现了 Runnable 接口。 Thread t1=new Thread(new CreateThread3()); ``` ## 终止线程 > Thread.stop() 不推荐使用。它会释放所有 monitor 在源码中已经明确说明 stop 方法被 Deprecated,在 Javadoc 中也说明了原因。 原因在于 stop 方法太过"暴力"了,无论线程执行到哪里,它将会立即停止掉线程。 ![20241229154732_QX4dTHlh.webp](https://cdn.dong4j.site/source/image/20241229154732_QX4dTHlh.webp) 当写线程得到锁以后开始写入数据,写完 id = 1,在准备将 name = 1 时被 stop, 释放锁。读线程获得锁进行读操作,读到的 id 为 1,而 name 还是 0,导致了数据不一致。 最重要的是这种错误不会抛出异常,将很难被发现。 ## 线程中断 ```java public void Thread.interrupt() // 中断线程 public boolean Thread.isInterrupted() // 判断是否被中断 public static boolean Thread.interrupted() // 判断是否被中断,并清除当前中断状态 ``` > Java 的中断是一种协作机制。也就是说调用线程对象的 interrupt 方法并不一定就中断了正在运行的线程,它只是要求线程自己在合适的时机中断自己。每个线程都有一个 > boolean 的中断状态(不一定就是对象的属性,事实上,该状态也确实不是 Thread 的字段),interrupt 方法仅仅只是将该状态置为 true。对于非阻塞中的线程, > 只是改变了中断状态, 即 Thread.isInterrupted() 将返回 true,并不会使程序停止; 优雅的终止线程 ```java public void run(){ while(true){ if(Thread.currentThread().isInterrupted()){ System.out.println("Interruted!"); break; } Thread.yield(); } } ``` 对于可取消的阻塞状态中的线程, 比如等待在这些函数上的线程, Thread.sleep(), Object.wait(), Thread.join(), 这个线程收到中断信号后, 会抛出 InterruptedException, 同时会把中断状态置回为 false. 对于取消阻塞状态中的线程: ```java public void run(){ while(true){ if(Thread.currentThread().isInterrupted()){ System.out.println("Interruted!"); break; } try { Thread.sleep(2000); } catch (InterruptedException e) { System.out.println("Interruted When Sleep"); // 设置中断状态,抛出异常后会清除中断标记位 Thread.currentThread().interrupt(); } Thread.yield(); } } ``` ## 线程挂起 挂起(suspend)和继续执行(resume)线程 - suspend() 不会释放锁 - 如果加锁发生在 resume() 之前 ,则死锁发生 这两个方法都是 Deprecated 方法,不推荐使用。 原因在于,suspend 不释放锁,因此没有线程可以访问被它锁住的临界区资源,直到被其他线程 resume。因为无法控制线程运行的先后顺序,如果其他线程的 resume 方法先被运行,那则后运行的 suspend,将一直占有这把锁,造成死锁发生。 使用代码模拟: ```java public class Test{ static Object u = new Object(); static TestSuspendThread t1 = new TestSuspendThread("t1"); static TestSuspendThread t2 = new TestSuspendThread("t2"); public static class TestSuspendThread extends Thread{ public TestSuspendThread(String name){ setName(name); } @Override public void run(){ synchronized (u){ System.out.println("in " + getName()); Thread.currentThread().suspend(); } } } public static void main(String[] args) throws InterruptedException{ t1.start(); Thread.sleep(100); t2.start(); t1.resume(); t2.resume(); t1.join(); t2.join(); } } ``` 让 t1,t2 同时争夺一把锁,争夺到的线程 suspend,然后再 resume,按理来说,应该某个线程争夺后被 resume 释放了锁,然后另一个线程争夺掉锁,再被 resume。 ``` in t1 in t2 ``` 说明两个线程都争夺到了锁,但是控制台的红灯还是亮着的,说明 t1,t2 一定有线程没有执行完。 ## join 和 yeild yeild 是个 native 静态方法,这个方法是想把自己占有的 cpu 时间释放掉,然后和其他线程一起竞争 (注意 yeild 的线程还是有可能争夺到 cpu,注意与 sleep 区别)。在 javadoc 中也说明了,yeild 是个基本不会用到的方法,一般在 debug 和 test 中使用。 join 方法的意思是等待其他线程结束,就如 suspend 那节的代码,想让主线程等待 t1,t2 结束以后再结束。没有结束的话,主线程就一直阻塞在那里。 ```java public class Test{ public volatile static int i = 0; public static class AddThread extends Thread{ @Override public void run(){ for (i = 0; i < 10000000; i++) ; } } public static void main(String[] args) throws InterruptedException{ AddThread at = new AddThread(); at.start(); at.join(); System.out.println(i); } } ``` 如果把上述代码的 at.join 去掉,则主线程会直接运行结束,i 的值会很小。如果有 join, 打印出的 i 的值一定是 10000000。 join 的本质: ```java while(isAlive()) { wait(0); } ``` join() 方法也可以传递一个时间,意为有限期地等待,超过了这个时间就自动唤醒。 这样就有一个问题,谁来 notify 这个线程呢,在 thread 类中没有地方调用了 notify? 在 javadoc 中,找到了相关解释。当一个线程运行完成终止后,将会调用 notifyAll 方法去唤醒等待在当前线程实例上的所有线程, 这个操作是 jvm 自己完成的。 所以 javadoc 中还给了我们一个建议,不要使用 wait 和 notify/notifyall 在线程实例上。因为 jvm 会自己调用,有可能与你调用期望的结果不同。 ## 守护线程 - 在后台默默地完成一些系统性的服务,比如垃圾回收线程、JIT 线程就可以理解为守护线程。 - 当一个 Java 应用内,所有非守护进程都结束时,Java 虚拟机就会自然退出。 开启守护进程: ```java Thread t=new DaemonT(); t.setDaemon(true); t.start() ``` ## 线程优先级 ``` public final static int MIN_PRIORITY = 1; public final static int NORM_PRIORITY = 5; public final static int MAX_PRIORITY = 10; ``` 线程优先级只是表示获取锁的概率大小 ## 基本的线程同步操作 synchronized 有三种加锁方式: - 指定加锁对象:对给定对象加锁,进入同步代码前要获得给定对象的锁。 - 直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。 - 直接作用于静态方法:相当于对当前类加锁,进入同步代码前要获得当前类的锁。 作用于实例方法,则不要 new 两个不同的实例 作用于静态方法,只要类一样就可以了,因为加的锁是类.class,可以 new 两个不同实例。 wait 和 notify 的用法: 用什么锁住,就用什么调用 wait 和 notify ## [Java内存管理:方法区与常量池](https://blog.dong4j.site/posts/42f151c0.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 总结方法区和运行时常量池 ## 相关特征 ### 方法区特征 - 同 Java 堆一样, 方法区也是全局共享的一块内存区域 - 方法区的作用是存储 Java 类的结构信息, 当我们创建对象实例后, **对象的类型信息存储在方法堆之中, 实例数据存放在堆中;实例数据指的是在 Java 中创建的各种实例对象以及它们的值, 类型信息指的是定义在 Java 代码中的常量、静态变量、以及在类中声明的各种方法、方法字段等等;同事可能包括即时编译器编译后产生的代码数据.** - JVMS 不要求该区域实现自动的内存管理, 但是商用 JVM 一般都已实现该区域的自动内存管理. - 方法区分配内存可以不连续, 可以动态扩展. - 该区域并非像 JMM 规范描述的那样数据一旦放进去就属于 “永久代”; **在该区域进行内存回收的主要目的是对常量池的回收和对内存数据的卸载;一般来说这个区域的内存回收效率比起 Java 堆要低得多.** - 当方法区无法满足内存需求时, 将抛出 OutOfMemoryError 异常. ### 运行时常量池的特征 - **运行时常量池是方法区的一部分,** 所以也是全局共享的. - **其作用是存储 Java 类文件常量池中的符号信息.** - **class 文件中存在常量池 (非运行时常量池), 其在编译阶段就已经确定;JVM 规范对 class 文件结构有着严格的规范, 必须符合此规范的 class 文件才会被 JVM 认可和装载.** - **运行时常量池** 中保存着一些 class 文件中描述的符号引用, 同时还会将这些符号引用所翻译出来的直接引用存储在 **运行时常量池** 中. - **运行时常量池相对于 class 常量池一大特征就是其具有动态性, Java 规范并不要求常量只能在运行时才产生, 也就是说运行时常量池中的内容并不全部来自 class 常量池, class 常量池并非运行时常量池的唯一数据输入口;在运行时可以通过代码生成常量并将其放入运行时常量池中** - 同方法区一样, 当运行时常量池无法申请到新的内存时, 将抛出 OutOfMemoryError 异常. ## HotSpot 方法区变迁 ### JDK1.2 ~ JDK6 在 JDK1.2 ~ JDK6 的实现中, HotSpot 使用永久代实现方法区;HotSpot 使用 GC 分代实现方法区带来了很大便利; ### JDK7 由于 GC 分代技术的影响, 使之许多优秀的内存调试工具无法在 Oracle HotSpot 之上运行, 必须单独处理;并且 Oracle 同时收购了 BEA 和 Sun 公司, 同时拥有 JRockit 和 HotSpot, 在将 JRockit 许多优秀特性移植到 HotSpot 时由于 GC 分代技术遇到了种种困难, **所以从 JDK7 开始 Oracle HotSpot 开始移除永久代.** **JDK7 中符号表被移动到 Native Heap 中, 字符串常量和类引用被移动到 Java Heap 中.** ### JDK8 **在 JDK8 中, 永久代已完全被元空间 (Meatspace) 所取代.** ## 永久代变迁产生的影响 #### 测试代码 1 ```java public class Test1 { public static void main(String[] args) { String s1 = new StringBuilder("漠").append("然").toString(); System.out.println(s1.intern() == s1); String s2 = new StringBuilder("漠").append("然").toString(); System.out.println(s2.intern() == s2); } } ``` 以上代码, 在 JDK6 下执行结果为 false、false, 在 JDK7 以上执行结果为 true、false. **首先明确两点**: 1. 在 Java 中直接使用双引号展示的字符串将会在常量池中直接创建. 2. String 的 intern 方法首先将尝试在常量池中查找该对象, 如果找到则直接返回该对象在常量池中的地址;找不到则将该对象放入常量池后再返回其地址. **JDK6 常量池在方法区, 频繁调用该方法可能造成 OutOfMemoryError.** **产生两种结果的原因**: 在 JDK6 下 s1、s2 指向的是新创建的对象, **该对象将在 Java Heap 中创建, 所以 s1、s2 指向的是 Java Heap 中的内存地址;** 调用 intern 方法后将尝试在常量池中查找该对象, 没找到后将其放入常量池并返回, **所以此时 s1/s2.intern()指向的是常量池中的地址, JDK6 常量池在方法区, 与堆隔离, ;所以 s1.intern()==s1 返回 false.** #### 测试代码 2 ```java public class Test2 { public static void main(String[] args) { /** * 首先设置 持久代最大和最小内存占用 (限定为 10M) * VM args: -XX:PermSize=10M -XX:MaxPremSize=10M */ List list = new ArrayList(); // 无限循环 使用 list 对其引用保证 不被 GC intern 方法保证其加入到常量池中 int i = 0; while (true) { // 此处永久执行, 最多就是将整个 int 范围转化成字符串并放入常量池 list.add(String.valueOf(i++).intern()); } } } ``` 以上代码在 JDK6 下会出现 Perm 内存溢出, JDK7 or high 则没问题. **原因分析**: **JDK6 常量池存在方法区, 设置了持久代大小后, 不断 while 循环必将撑满 Perm 导致内存溢出;JDK7 常量池被移动到 Native Heap(Java Heap), 所以即使设置了持久代大小, 也不会对常量池产生影响;不断 while 循环在当前的代码中, 所有 int 的字符串相加还不至于撑满 Heap 区, 所以不会出现异常.** ## [深入浅出Java并发基础:从概念到实践](https://blog.dong4j.site/posts/ef65c071.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 几个并发概念的介绍 - 同步(synchronous)和异步(asynchronous) 同步调用会等待方法的返回, 异步调用会马上返回, 但是异步调用返回并不代表人任务已经完成, 它会在后台启个线程继续进行任务 - 并发(Concurrency)和并行(Parallelism) 并发和并行在外在表象来说, 是差不多的. 由图所示, 并行则是两个任务同时进行, 而并发呢, 则是一会做一个任务一会又切换做另一个任务. 所以单个 cpu 是不能做并行的, 只能是并发. - 临界区 临界区用来表示一种公共资源或者说是共享数据, 可以被多个线程使用, 但是每一次, 只能有一个线程使用它, 一旦临界区资源被占用, 其他线程要想使用这个资源, 就必须等待. - 阻塞(Blocking)和非阻塞(Non-Blocking) - 阻塞和非阻塞通常用来形容多线程间的相互影响. 比如一个线程占用了临界区资源, 那么其它所有需要 这个资源的线程就必须在这个临界区中进行等待, 等待会导致线程挂起. 这种情况就是阻塞. 此时, 如 果占用资源的线程一直不愿意释放资源, 那么其它所有阻塞在这个临界区上的线程都不能工作. - 非阻塞允许多个线程同时进入临界区 所以阻塞的方式, 一般性能不会太好. 根据一般的统计, 如果一个线程在操作系统层面被挂起, 做了上下文切换了, 通常情况需要 8W 个时间周期来做这个事情. - 死锁(Deadlock)、饥饿(Starvation)和活锁(Livelock) - 死锁: 是指两个或两个以上的进程在执行过程中, 由于竞争资源或者由于彼此通信而造成的一种阻塞的现象, 若无外力作用, 它们都将无法推进下去. 此时称系统处于死锁状态或系统产生了死锁, 这些永远在互相等待的进程称为死锁进程. - 但是死锁虽说是不好的现象, 但是它是一个静态的问题, 一旦发生死锁, 进程被卡死, cpu 占有率也是 0, 它不会占用 cpu, 它会被调出去. 相对来说还是比较好发现和分析的. - 活锁: 指事物 1 可以使用资源, 但它让其他事物先使用资源;事物 2 可以使用资源, 但它也让其他事物先使用资源, 于是两者一直谦让, 都无法使用资源. - 为了避免死锁把自己持有的资源都放弃掉. 如果另外一个线程也做了同样的事情, 他们需要相同的资源, 比如 A 持有 a 资源, B 持有 b 资源, 放弃了资源以后, A 又获得了 b 资源, B 又获得了 a 资源, 如此反复, 则发生了活锁. - 饥饿: 指某一个或者多个线程因为种种原因无法获得所需要的资源, 导致一直无法执行. - 并行的级别 - 阻塞 (当一个线程进入临界区后, 其他线程必须等待) - 非阻塞 - 无障碍阻塞 - 无障碍阻塞是一种最弱的非阻塞调度 - 自由出入临界区 - 无竞争时, 有限步内完成操作 - 有竞争时, 回滚数据. - 无锁 - 是无保障的 - 保证有一个线程可以胜出 - 无等待 - 无锁额 - 要求所有的线程都必须在有限步内完成 - 无饥饿的 阻塞调度是一种悲观的策略, 它会认为说一起修改数据是很有可能把数据改坏的. 而非阻塞调度呢, 是一种乐观的策略, 它认为大家修改数据未必把数据改坏. 但是它是一种宽进严出的策略, 当它发现一个进程在临界区内发生了数据竞争, 产生了冲突, 那么无障碍的调度方式则会回滚这条数据. 在这个无障碍的调度方式当中, 所有的线程都相当于在拿去一个系统当前的一个快照. 他们一直会尝试拿去的快照是有效的为止. 无障碍并不保证有竞争时一定能完成操作, 因为如果它发现每次操作都会产生冲突, 那它则会不停地尝试. 如果临界区内的线程互相干扰, 则会导致所有的线程会卡死在临界区, 那么系统性能则会有很大的影响. 而无锁增加了一个新的条件, 保证每次竞争有一个线程可以胜出, 则解决了无障碍的问题. 至少保证了所有线程都顺利执行下去. 首先无等待的前提是无锁的基础上的, 无锁它只保证了临界区肯定有进也有出, 但是如果进的优先级都很高, 那么临界区内的某些优先级低的线程可能发生饥饿, 一直出不了临界区. 那么无等待解决了这个问题, 它保证所有的线程都必须在有限步内完成, 自然是无饥饿的. 无等待是并行的最高级别, 它能使这个系统达到最优状态. 无等待的典型案例: 如果只有读线程, 没有线线程, 那么这个则必然是无等待的. 如果既有读线程又有写线程, 而每个写线程之前, 都把数据拷贝一份副本, 然后修改这个副本, 而不是修改原始数据, 因为修改副本, 则没有冲突, 那么这个修改的过程也是无等待的. 最后需要做同步的只是将写完的数据覆盖原始数据. ## [从零开始:Tmux会话管理实战](https://blog.dong4j.site/posts/88c12565.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 本文将带你深入了解 tmux 的基本操作和使用技巧,让你能够更高效地使用终端。我们将从安装、配置、常用命令以及插件等方面进行详细介绍。 ## 一、什么是 tmux? tmux 是一个终端复用软件,它可以将多个终端窗口合并到一个窗口中,从而提高工作效率。简单来说,它可以让你在一个终端窗口中同时打开多个会话(session)、窗口(window)和面板(panel),并进行自由切换和管理。 ## 二、安装与配置 1. 安装 ```bash git clone https://github.com/gpakosz/.tmux.git .oh-my-tmux ln -s -f .oh-my-tmux/.tmux.conf ~/.tmux.conf cp .oh-my-tmux/.tmux.conf.local ~/.tmux.conf.local ``` 2. 插件安装 ```bash git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm ``` ## 三、常用命令 1. 会话管理 - 新建会话:`tmux new -s my_session_name` - 切换会话:`tmux at -t session_name` - 删除会话:`tmux kill-session -t session_name` - 退出会话:`d` 2. 窗口管理 - 新建窗口:`c` - 删除窗口:`exit` 或 `&` - 切换窗口:`hjkl` 或 `q window数字` - 重命名窗口:`, new_window_name` 3. 面板管理 - 垂直分割:`"` - 水平分割:`%` - 切换面板:`hjkl` 或 `q panel数字` - 关闭面板:`x` - 调整面板大小:`shift + hjkl` 或 `ctrl + 方向键` 4. 其他常用命令 - 查看会话列表:`tmux ls` - 保存当前会话:`Ctrl+b d`(推荐使用快捷键) - 进入上次保存的会话:`tmux attach` - 杀死最近使用的会话:`tmux kill-session` ## 四、插件介绍 1. tpm (The Prime Minister) tpm 是一个强大的 tmux 插件管理器,它可以方便地安装、更新和管理其他 tmux 插件。使用方法如下: ```bash ~/.tmux/plugins/tpm/tpm install ``` 2. copycat copycat 是一个方便的复制粘贴插件,可以将面板内容复制到剪贴板或粘贴到面板中。 3. tree tree 是一个列出目录结构的插件,可以方便地查看和管理文件和文件夹。 ## 五、总结 通过本文的学习,相信你已经对 tmux 有了一定的了解。tmux 可以大大提高你的终端工作效率,让你在多个会话、窗口和面板之间自由切换和管理。希望这篇文章能够帮助你更好地使用 tmux,提升你的工作效率。 ## [掌握 Homebrew,轻松管理你的软件包](https://blog.dong4j.site/posts/711ab194.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Homebrew 是 macOS 和 Linux 上的包管理器,允许用户通过命令行轻松安装、更新和管理软件包。它极大地简化了软件包的获取和维护过程,尤其适合开发者。本文将深入探讨 Homebrew 的功能、安装方式、核心命令以及一些进阶用法,帮助你快速上手并高效管理开发环境。 ## 什么是 Homebrew? Homebrew 是一个开源项目,由 Max Howell 在 2009 年发布,旨在为 macOS 用户提供类似 Linux 包管理器的体验。Homebrew 的设计哲学是“将复杂的事情简单化”,它能自动解决依赖关系并优化安装过程,为开发者提供了一种轻量级、无 GUI 的方式来安装各种开发工具。现在,Homebrew 也扩展支持了 Linux 系统,使其成为跨平台的工具。 ## 为什么使用 Homebrew? macOS 自带的系统工具和开发环境比较有限,Homebrew 通过一系列命令行工具简化了软件包的安装和管理流程,为 macOS 和 Linux 用户提供了一套完善的包管理方案。相比于手动下载和配置软件,Homebrew 能自动配置依赖项、路径和更新管理等工作,让用户可以专注于开发而不是环境配置。 ## Homebrew vs. Fink vs. MacPorts - **Flink**:提供直接编译好的二进制包,但容易出现依赖库问题。 - **MacPorts**:下载所有依赖库的源代码,在本地编译安装,过程繁琐且耗时。 - **Homebrew**:优先查找本地依赖库,然后下载包源代码编译安装,既合理又高效。 ## 安装 Homebrew 安装 Homebrew 非常简单,只需在终端中运行以下命令: ```bash /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" ``` 安装完成后,确保在`.zshrc`或`.bash_profile`文件中添加了 Homebrew 的环境变量。 ## Homebrew 的基本使用 ### 更新 formula 和 Homebrew ```bash brew update ``` ### 显示已安装的软件列表 ```bash brew list ``` ### 去除依赖 ```bash brew leaves ``` ### 搜索软件 ```bash brew search wget ``` ### 安装软件 ```bash brew install wget ``` ### 查看需要升级的软件 ```bash brew outdated ``` ### 升级所有软件 ```bash brew upgrade ``` ### 升级特定软件 ```bash brew upgrade wget ``` ### 删除软件 ```bash brew uninstall wget --force ``` ### 查看软件包信息 ```bash brew info wget ``` ### 列出软件包的依赖关系 ```bash brew deps wget ``` ### 出错处理 - **升级 Homebrew**: ```bash brew update ``` - **检查 Homebrew 状况**: ```bash brew doctor ``` ### brew services 1. 启动服务:使用 brew services start 启动某个服务,并设置为开机自动启动。 2. 停止服务:使用 brew services stop 停止正在运行的服务。 3. 重启服务:使用 brew services restart 重新启动服务,适合更改配置后重新加载服务。 4. 查看服务状态:使用 brew services list 查看当前通过 Homebrew 安装的所有服务及其状态(是否正在运行、是否开机启动)。 ## 总结 Homebrew 为 Mac OS X 用户提供了非常方便的软件安装方式,解决了包的依赖问题,不再需要烦人的 sudo 权限,一键式编译,无参数困扰。由于其安装方式可能会更新,建议用户访问官方网站以获取最新的安装方法和文档。 通过本文,您应该已经掌握了 Homebrew 的基本使用方法。无论是安装、升级还是卸载软件,Homebrew 都能让您的工作更加高效。开始使用 Homebrew,让您的 Mac OS X 体验更加顺畅吧! --- 注:由于 Homebrew 的安装方式可能会变化,请到官方网站查看最新的方法和文档。 ## [macOS 上使用 IDEA 搭建 SSM 项目](https://blog.dong4j.site/posts/23cbb67c.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 项目搭建采用技术栈为:Spring+Spring MVC+Hibernate+Jsp+Gradle + tomcat+mysql5.6 搭建环境文档目录结构说明: 1. 使用 Intellj Idea 搭建项目过程详解 2. 项目各配置文件讲解及部署 3. 各层包功能讲解 & 项目搭建完毕最终效果演示图 4. 项目中重要代码讲解 5. 配置 tomcat 运行环境 6. webapp 文件夹下分层详解 ### **1. 使用 Intellj Idea 搭建项目过程详解** **1.1 打开 Intellj Idea** ![20241229154732_UmUYAM5B.webp](https://cdn.dong4j.site/source/image/20241229154732_UmUYAM5B.webp) **1.2 操作 Intellj Idea 工具栏 新建项目** ![20241229154732_dQUO0QRI.webp](https://cdn.dong4j.site/source/image/20241229154732_dQUO0QRI.webp) ![20241229154732_4hecK3or.webp](https://cdn.dong4j.site/source/image/20241229154732_4hecK3or.webp) ![20241229154732_OQM0gIFM.webp](https://cdn.dong4j.site/source/image/20241229154732_OQM0gIFM.webp) ![20241229154732_pjRYBW6i.webp](https://cdn.dong4j.site/source/image/20241229154732_pjRYBW6i.webp) ![20241229154732_HiyaFYc8.webp](https://cdn.dong4j.site/source/image/20241229154732_HiyaFYc8.webp) ![20241229154732_XTyTDehX.webp](https://cdn.dong4j.site/source/image/20241229154732_XTyTDehX.webp) ![20241229154732_uWlJZ4Oz.webp](https://cdn.dong4j.site/source/image/20241229154732_uWlJZ4Oz.webp) 需要说明的是,最初创建的项目视图是不完整的,包括 webapp 文件夹下没有 web.xml,以及 src 包下缺少 Java 文件夹 (放置 java 源代码文件),Resources 文件夹(放置项目配置文件)。 我们继续做以下操作,使得项目的结构符合 web 应用项目的层级标准。 ![20241229154732_w031AEdD.webp](https://cdn.dong4j.site/source/image/20241229154732_w031AEdD.webp) 出现如下视图: ![20241229154732_xQkY98hW.webp](https://cdn.dong4j.site/source/image/20241229154732_xQkY98hW.webp) ![20241229154732_jXXkzc48.webp](https://cdn.dong4j.site/source/image/20241229154732_jXXkzc48.webp) 接下来:单击 main 文件夹按照如下操作: ![20241229154732_FpSXWcZK.webp](https://cdn.dong4j.site/source/image/20241229154732_FpSXWcZK.webp) 屏幕快照 2016-11-20 下午 4.44.33.png ![20241229154732_cSVKsRzs.webp](https://cdn.dong4j.site/source/image/20241229154732_cSVKsRzs.webp) 点击 ok,再按照上图操作操作一遍,输入文件名为 **resources** 最终的结构图如下图所示: ![20241229154732_kSY0ppsp.webp](https://cdn.dong4j.site/source/image/20241229154732_kSY0ppsp.webp) ### **2. 项目各配置文件讲解及部署** 完成了项目的初始化结构创建,接下来我们需要来创建配置文件。 首先是 resources 文件夹下的配置文件 **2.1resources 下资源文件截图:(最终配置的结果)** ![20241229154732_ETRD79L5.webp](https://cdn.dong4j.site/source/image/20241229154732_ETRD79L5.webp) **2.2 data-access-applicationContext.xml** 主要管理数据库访问组件 ``` ${dataSource.hibernate.dialect} ${dataSource.hibernate.show_sql} true com.fxmms.*.*.domain com.fxmms.*.domain ``` **2.3 service-applicationContext.xml** 主要管理业务逻辑组件,包括对数据库访问的事务控制,以及定时任务。 ``` ``` **2.4default-servlet.xml** 设置 springmvc-applicationContext.xml, 前端控制器将请求转发到相应的 controller 层中的处理方法上。 ``` ``` **2.5 spring-security.xml** 设置 spring-security 权限控制配置文件,项目中权限的控制统一在此配置文件中配置,包括从数据库中获取用户的相关信息,以及配置相应 pattern 的请求过滤规则。 ``` ``` **2.6 db.properties** 数据库访问配置文件 ``` jdbc.user=root jdbc.password=feixun*123 jdbc.driverClass=com.mysql.jdbc.Driver #jdbc.jdbcUrl=jdbc:mysql://localhost/fxmms?useUnicode=true&characterEncoding=UTF-8 jdbc.jdbcUrl=jdbc:mysql://222.73.156.132:13306/fxmms?useUnicode=true&characterEncoding=UTF-8 jdbc.initPoolSize=5 jdbc.maxPoolSize=20 dataSource.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect ####################### ## local ## ####################### dataSource.hibernate.show_sql=true ``` **2.7 log4j.properties** 配置项目日志文件,日志输出模式为 Console ``` ########################################################################### # Properties file for the log4j logger system # # Note: During the uPortal build, the file at /properties/Logger.properties is copied # to the log4j standard location /WEB-INF/classes/log4j.properties . This means that editing the file # at /properties/Logger.properties in a deployed uPortal will have no effect. # # Please read the instructions for the Log4J logging system at # http://jakarta.apache.org/log4j/ if you want to modify this. ########################################################################### # You should probably replace the word "debug" with "info" in the # following line after everything is running. This will turn off # the tons of debug messages, and leave only INFO, WARN, ERROR, etc. # log4j.rootLogger=info, stdout # Console output log4j.appender.stdout=org.apache.log4j.ConsoleAppender log4j.appender.stdout.layout=org.apache.log4j.PatternLayout log4j.appender.stdout.layout.ConversionPattern=%d{mm:ss,SSS} %p [%l] - <%m>%n ``` **2.8 web.xml** ``` contextConfigLocation classpath:data-access-applicationContext.xml;classpath:spring-security.xml;classpath:service-applicationContext.xml log4jConfigLocation classpath:log4j.properties org.springframework.web.util.Log4jConfigListener org.springframework.web.context.ContextLoaderListener springSecurityFilterChain org.springframework.web.filter.DelegatingFilterProxy springSecurityFilterChain /* CharacterEncodingFilter org.springframework.web.filter.CharacterEncodingFilter encoding UTF-8 forceEncoding true CharacterEncodingFilter /* HiddenHttpMethodFilter org.springframework.web.filter.HiddenHttpMethodFilter HiddenHttpMethodFilter /* defaultDispatcherServlet org.springframework.web.servlet.DispatcherServlet contextConfigLocation classpath:default-servlet.xml 1 defaultDispatcherServlet / ``` **2.9 build.gradle** 项目构建脚本 ``` group 'com.fxmms' version '1.0-SNAPSHOT' apply plugin: 'java' apply plugin: 'idea' apply plugin: 'war' sourceCompatibility = 1.8 repositories { maven { url "http://maven.aliyun.com/nexus/content/groups/public/" } mavenLocal() jcenter() maven { url "http://repo.maven.apache.org/maven2/"} maven { url 'https://repo.spring.io/libs-milestone'} mavenCentral() } dependencies { testCompile group: 'junit', name: 'junit', version: '4.12' // servlet-api compile group: 'javax.servlet', name: 'servlet-api', version: '2.5' //spring相关 compile group: 'org.springframework', name: 'spring-webmvc', version: '4.3.3.RELEASE' compile group: 'org.springframework', name: 'spring-orm', version: '4.3.3.RELEASE' compile group: 'org.springframework', name: 'spring-aspects', version: '4.3.3.RELEASE' compile group: 'org.springframework.security', name: 'spring-security-config', version: '3.2.0.RELEASE' compile group: 'org.springframework.security', name: 'spring-security-taglibs', version: '3.2.0.RELEASE' compile 'org.springframework.security:spring-security-web:3.2.0.RELEASE' //hibernate相关 compile 'org.hibernate:hibernate-core:4.3.6.Final' //c3p0连接池 compile group: 'org.hibernate', name: 'hibernate-c3p0', version: '4.3.6.Final' //ehcahe二级缓存 compile group: 'org.hibernate', name: 'hibernate-ehcache', version: '4.3.6.Final' //mysql compile group: 'mysql', name: 'mysql-connector-java', version: '5.1.39' //springData compile group: 'org.springframework.data', name: 'spring-data-jpa', version: '1.10.3.RELEASE' // https://mvnrepository.com/artifact/log4j/log4j日志 compile group: 'log4j', name: 'log4j', version: '1.2.17' //json解析相关 compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.5.4' compile group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: '2.5.4' //迅雷接口有关jar 包 compile 'org.apache.httpcomponents:httpclient:4.4' compile 'org.json:json:20141113' compile group: 'org.apache.clerezza.ext', name: 'org.json.simple', version: '0.4' //https://mvnrepository.com/artifact/org.apache.commons/commons-io 读取文件相关 compile group: 'org.apache.commons', name: 'commons-io', version: '1.3.2' // https://mvnrepository.com/artifact/org.apache.poi/poi 文件读取相关 apache-poi compile group: 'org.apache.poi', name: 'poi', version: '3.9' // https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml 解决execl 版本差异 compile group: 'org.apache.poi', name: 'poi-ooxml', version: '3.9' // https://mvnrepository.com/artifact/commons-io/commons-io 文件上传 compile group: 'commons-io', name: 'commons-io', version: '1.3.1' // https://mvnrepository.com/artifact/commons-fileupload/commons-fileupload compile group: 'commons-fileupload', name: 'commons-fileupload', version: '1.2.2' } ``` ### **3. 各层包功能讲解 & 项目搭建完毕最终效果演示图** **3.1 项目中各层包功能讲解** 项目中 Java 源代码层级结构如下图所示: ![20241229154732_XHVuaqIU.webp](https://cdn.dong4j.site/source/image/20241229154732_XHVuaqIU.webp) 对于 www 包中的各分层,我们对照上图重点说明: controller: 用于路由各种 http 访问,其中可以实现对前台页面参数的对象化绑定,这个功能的实现是依赖于 spring mvc 中的参数绑定功能,以及返回向前端页面返回数据。也可以实现基于 Restful 风格 API 的编写。 dao:用于实现对数据库的操作,包中的代码继承并实现自 common 中的 dao 层代码,采用的是类的适配器模式实现的,这里的代码值得细细品味,可以说是整个项目的灵魂所在之处。 domain: 项目的实体类都存在于这个包中,其中的类与数据库表相对应。 dto: 实现了序列化的数据传输层对象,用于接收前台参数,并封装成 dto 对象传输至后台,也负责从数据库中查询数据的封装。 qo: 模糊查询对象所在的包,用于封装 QBC 动态查询参数。 rowmapper:用于对应 jdbcTemplate 查询数据库返回对象的数据集,并将数据集依照此对象进行封装。 schedulejob:定时任务类所在的包,其中类要加上 @Service 注解,因为定时任务注解配置在 service-applicationContext.xml 中,包扫描组件的规则是只扫描有 @Service 注解的组件类 service: 业务逻辑层,主要完成业务逻辑的书写,其中调用了 dao 实现类中的方法,并且每个有关于数据库操作的方法上都加上了 @Transaction 注解,@Transaction 是 Spring Framework 对 AOP 的另一种区别于拦截器的自定义注解实现。 ### **4. 项目中重要代码讲解** 主要讲解一下 Dao 层中代码对适配器设计模式的应用: **4.1 首先看下 commom 层中 BaseDao.java** ``` package com.fxmms.common.dao; import com.fxmms.common.ro.Dto; import com.fxmms.common.ro.DtoResultWithPageInfo; import com.fxmms.common.ro.PageQo; import org.hibernate.Criteria; import org.springframework.stereotype.Repository; import java.io.Serializable; import java.util.List; import java.util.Map; /** * * @param * @usage 数据库公共操作接口 */ @Repository public interface BaseDao { /** * * * @param id * @usage 根据id获取数据库中唯一纪录,封装成java对象并返回 * @return T */ public T getById(Serializable id); /** * * * @param id * @usage 根据id懒加载数据库中唯一纪录,封装成java对象并返回 * @return T */ public T load(Serializable id); /** * * * @param columnName * * @param value * * @usage 根据列名,以及对应的值获取数据库中惟一纪录,封装成Java对象并返回 * * @return */ public T getByUniqueKey(String columnName, Object value); /** * * * @param nameValuePairs * * @return T */ public T getUniqueResult(Map nameValuePairs); /** * * * @param columnName * * @param value * * @param sort * * @param order * asc/desc * @return List */ public List getListByColumn(String columnName, Object value, String sort, String order); public List getListByColumn(String columnName, Object value); /** * ͨ * * @param nameValuePairs * * @param sort * * @param order * asc/desc * @return List */ public List getListByColumns(Map nameValuePairs, String sort, String order); public List getListByColumns(Map nameValuePairs); /** * * * @return List */ public List getAll(); /** * * * @param t * @return Serializable id */ public Serializable save(T t); /** * * * @param t */ public void update(T t); /** * * * @param t */ public void delete(T t); /** * QBC * @return */ public Criteria createCriteria(); /** * @param * @param * @param criteria * @param pageNo * @param pageSize * @param dtoClazz * @return */ public DtoResultWithPageInfo queryPageListByCriteria( Criteria criteria, int pageNo, int pageSize, Class dtoClazz); /** * @param * @param * @param criteria * @param qo * @param class1 * @return */ public DtoResultWithPageInfo queryPageListByCriteriaWithQo(PageQo qo, Class dtoClazz); } ``` 其中定义了一些对数据库的抽象公共操作方法,代码中有注释,可以对照理解。 **4.2 看下 HibernateTemplateDao.java 对 BaseDao.java 的抽象实现** ``` package com.fxmms.common.dao.hib; import com.fxmms.common.dao.BaseDao; import com.fxmms.common.ro.Dto; import com.fxmms.common.ro.DtoResultWithPageInfo; import com.fxmms.common.ro.PageInfo; import com.fxmms.common.ro.PageQo; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.hibernate.Criteria; import org.hibernate.Session; import org.hibernate.SessionFactory; import org.hibernate.criterion.Order; import org.hibernate.criterion.Projections; import org.hibernate.criterion.Restrictions; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import java.io.Serializable; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * * @param * @usage 应用数据访问的灵魂,抽象出各种模型类进行数据库访问的公共操作。 * 主要使用到QBC动态查询。主要思想是利用反射。 */ @Repository public abstract class HibernateTemplateDao implements BaseDao { protected static final Log log = LogFactory .getLog(HibernateTemplateDao.class); //通过反射,可以实现对不同类对应的数据表的操作 protected abstract Class getEntityClass(); protected SessionFactory sessionFactory; @Autowired @Qualifier("sessionFactory") public void setSessionFactory(SessionFactory sessionFactory) { this.sessionFactory = sessionFactory; } public Session getSession() { return sessionFactory.getCurrentSession(); } public Session openNewSession() { return sessionFactory.openSession(); } @Override @SuppressWarnings("unchecked") public T getById(Serializable id) { return (T) getSession().get(getEntityClass(), id); } @Override @SuppressWarnings("unchecked") public T getByUniqueKey(String columnName, Object value) { return (T) getSession().createCriteria(getEntityClass()) .add(Restrictions.eq(columnName, value)).uniqueResult(); } @Override @SuppressWarnings("unchecked") public List getListByColumn(String columnName, Object value,String sort,String order) { Criteria criteria = getSession().createCriteria(getEntityClass()); criteria.add(Restrictions.eq(columnName, value)); if(StringUtils.hasText(sort) && StringUtils.hasText(order)){ if("asc".equals(order)){ criteria.addOrder(Order.asc(sort)); }else if("desc".equals(order)){ criteria.addOrder(Order.desc(sort)); } } List list = criteria.list(); return list; } @Override @SuppressWarnings("unchecked") public List getListByColumn(String columnName, Object value) { Criteria criteria = getSession().createCriteria(getEntityClass()); criteria.add(Restrictions.eq(columnName, value)); List list = criteria.list(); return list; } @Override @SuppressWarnings("unchecked") public List getListByColumns(Map nameValuePairs,String sort,String order){ Criteria criteria = getSession().createCriteria(getEntityClass()); for (Map.Entry entry : nameValuePairs.entrySet()) { criteria.add(Restrictions.eq(entry.getKey(), entry.getValue())); } if(StringUtils.hasText(sort) && StringUtils.hasText(order)){ if("asc".equals(order)){ criteria.addOrder(Order.asc(sort)); }else if("desc".equals(order)){ criteria.addOrder(Order.desc(sort)); } } List list = criteria.list(); return list; } @Override @SuppressWarnings("unchecked") public List getListByColumns(Map nameValuePairs){ Criteria criteria = getSession().createCriteria(getEntityClass()); for (Map.Entry entry : nameValuePairs.entrySet()) { criteria.add(Restrictions.eq(entry.getKey(), entry.getValue())); } List list = criteria.list(); return list; } @Override @SuppressWarnings("unchecked") public List getAll() { return getSession().createCriteria(getEntityClass()).list(); } @Override @SuppressWarnings("unchecked") public T getUniqueResult(Map nameValuePairs) { Criteria criteria = getSession().createCriteria(getEntityClass()); for (Map.Entry entry : nameValuePairs.entrySet()) { criteria.add(Restrictions.eq(entry.getKey(), entry.getValue())); } return (T) criteria.uniqueResult(); } @Override @SuppressWarnings("unchecked") public T load(Serializable id){ return (T) getSession().load(getEntityClass(), id); } @Override public Serializable save(T t) { return getSession().save(t); } @Override public void update(T t) { Session session = this.getSession(); session.update(t); //强制刷新缓存中数据至数据库中,防止大批量数据更新之后出现脏数据 session.flush(); } @Override public void delete(T t) { this.getSession().delete(t); } /** * QO DtoResultWithPageInfolist+ҳϢ * * @param page * @param pageSize * @param qo * @param dtoClazz * @return */ /* public DtoResultWithPageInfo queryPageListByQueryObject( int page, int pageSize,Q qo, Class dtoClazz){ Criteria criteria = QueryObjectHelper.buildCriteria(qo, getSession()); return queryPageListByCriteria(criteria, page, pageSize, dtoClazz); }*/ /** * QO List * @param qo * @param dtoClazz * @return */ /*public List queryListByQueryObject( Q qo, Class dtoClazz){ Criteria criteria = QueryObjectHelper.buildCriteria(qo, getSession()); @SuppressWarnings("unchecked") List list = criteria.list(); List resultsDtoList = new ArrayList(); for(E entity:list){ try { D dto = dtoClazz.newInstance(); BeanUtils.copyProperties(entity, dto); resultsDtoList.add(dto); } catch (InstantiationException e) { log.error("dtoʵ쳣ExMsg==>"+e.getMessage()); } catch (IllegalAccessException e) { log.error("dtoʵ쳣ExMsg==>"+e.getMessage()); } } return resultsDtoList; }*/ /** * queryPageListByCriteria * * ͨcriteria DtoResultWithPageInfolist+ҳϢ * * @param criteria * ѯ * @param pageNo * ǰҳ * @param pageSize * ÿҳʾ * @param dtoClass * ݴݶclass * */ /*public DtoResultWithPageInfo queryPageListByCriteria( Criteria criteria, int pageNo, int pageSize, Class dtoClazz) { PageInfo pageInfo = getInstancePageInfoWithCriteria(criteria, pageNo, pageSize); criteria.setProjection(null);// ͶӰ criteria.setFirstResult(pageInfo.getFirstResultNum()); criteria.setMaxResults(pageInfo.getPageSize()); @SuppressWarnings("unchecked") List resultsList = criteria.list(); List resultsDtoList = new ArrayList(); for (E result : resultsList) { D dto; try { dto = dtoClazz.newInstance(); try { BeanUtils.copyProperties(result, dto); } catch (Exception e) { log.error("ҳѯ쳣bean쳣"); e.printStackTrace(); } } catch (InstantiationException e) { log.error("ҳѯ쳣dtoʼ쳣"); e.printStackTrace(); dto = null; } catch (IllegalAccessException e) { log.error("ҳѯ쳣dtoʼ쳣"); e.printStackTrace(); dto = null; } resultsDtoList.add(dto); } DtoResultWithPageInfo resultWithPageInfo = new DtoResultWithPageInfo( resultsDtoList, pageInfo); return resultWithPageInfo; }*/ /** * ͨcriteria List * * @param criteria * @param dtoClazz * @return */ /*public List queryListByCriteria( Criteria criteria,Class dtoClazz) { @SuppressWarnings("unchecked") List resultsList = criteria.list(); List resultsDtoList = new ArrayList(); for (E result : resultsList) { D dto; try { dto = dtoClazz.newInstance(); try { BeanUtils.copyProperties(result, dto); } catch (Exception e) { log.error("ҳѯ쳣bean쳣"); e.printStackTrace(); } } catch (InstantiationException e) { log.error("ҳѯ쳣dtoʼ쳣"); e.printStackTrace(); dto = null; } catch (IllegalAccessException e) { log.error("ҳѯ쳣dtoʼ쳣"); e.printStackTrace(); dto = null; } resultsDtoList.add(dto); } return resultsDtoList; }*/ /*public DataTablePageList queryDataTablePageListByCriteria( Criteria criteria, String displayStart, String displayLength) { // ܼ¼ long totalRecords = 0L; criteria.setProjection(Projections.rowCount()); totalRecords = (Long) criteria.uniqueResult(); // criteria.setProjection(null); criteria.setFirstResult(Integer.parseInt(displayStart)); criteria.setMaxResults(Integer.parseInt(displayLength)); @SuppressWarnings("rawtypes") List resultsList = criteria.list(); DataTablePageList dtpl = new DataTablePageList( String.valueOf((int) totalRecords), resultsList); return dtpl; } */ /** * @param criteria * @param pageNo * @param pageSize * @return *//* private PageInfo getInstancePageInfoWithCriteria(Criteria criteria, int pageNo, int pageSize) { long totalQuantity = 0L; criteria.setProjection(Projections.rowCount()); totalQuantity = (Long) criteria.uniqueResult(); PageInfo pageInfo = PageInfo.getInstance(pageNo, pageSize, totalQuantity); return pageInfo; }*/ @Override public Criteria createCriteria() { // TODO Auto-generated method stub return getSession().createCriteria(getEntityClass()); } /** * queryPageListByCriteria * * ͨcriteria DtoResultWithPageInfolist+ҳϢ * * @param criteria * ѯ * @param pageNo * ǰҳ * @param pageSize * ÿҳʾ * @param dtoClass * ݴݶclass * ص DtoResultWithPageInfo * * Ϊ queryPageListByCriteria */ @Override public DtoResultWithPageInfo queryPageListByCriteria( Criteria criteria, int pageNo, int pageSize, Class dtoClazz) { //˷ĵãpageinfoѾfirstResult maxresult PageInfo pageInfo = getInstancePageInfoWithCriteria(criteria, pageNo, pageSize); criteria.setProjection(null);// ͶӰ criteria.setFirstResult(pageInfo.getFirstResultNum()); criteria.setMaxResults(pageInfo.getPageSize()); @SuppressWarnings("unchecked") List resultsList = criteria.list(); List resultsDtoList = new ArrayList(); for (E result : resultsList) { D dto; try { dto = dtoClazz.newInstance(); try { BeanUtils.copyProperties(result, dto); } catch (Exception e) { log.error("ҳѯ쳣bean쳣"); e.printStackTrace(); } } catch (InstantiationException e) { log.error("ҳѯ쳣dtoʼ쳣"); e.printStackTrace(); dto = null; } catch (IllegalAccessException e) { log.error("ҳѯ쳣dtoʼ쳣"); e.printStackTrace(); dto = null; } resultsDtoList.add(dto); } DtoResultWithPageInfo resultWithPageInfo = new DtoResultWithPageInfo( resultsDtoList, pageInfo); return resultWithPageInfo; } /** * queryPageListByCriteriaWithQo * * ͨcriteria DtoResultWithPageInfolist+ҳϢ * * @param criteria * ѯ * @param pageNo * ǰҳ * @param pageSize * ÿҳʾ * @param dtoClass * ݴݶclass * ص DtoResultWithPageInfo * * Ϊ queryPageListByCriteria */ @Override public DtoResultWithPageInfo queryPageListByCriteriaWithQo(PageQo qo, Class dtoClazz) { //˷ĵãpageinfoѾfirstResult maxresult Criteria criteria = this.createCriteria(); qo.add(criteria); PageInfo pageInfo = getInstancePageInfoWithCriteria(criteria, qo.getPage(),qo.getRows()); criteria.setProjection(null);// ͶӰ criteria.setFirstResult(pageInfo.getFirstResultNum()); criteria.setMaxResults(pageInfo.getPageSize()); @SuppressWarnings("unchecked") List resultsList = criteria.list(); List resultsDtoList = new ArrayList(); for (E result : resultsList) { D dto; try { dto = dtoClazz.newInstance(); try { BeanUtils.copyProperties(result, dto); } catch (Exception e) { log.error("ҳѯ쳣bean쳣"); e.printStackTrace(); } } catch (InstantiationException e) { log.error("ҳѯ쳣dtoʼ쳣"); e.printStackTrace(); dto = null; } catch (IllegalAccessException e) { log.error("ҳѯ쳣dtoʼ쳣"); e.printStackTrace(); dto = null; } resultsDtoList.add(dto); } DtoResultWithPageInfo resultWithPageInfo = new DtoResultWithPageInfo( resultsDtoList, pageInfo); return resultWithPageInfo; } /** * ͨѯʼҳϢ * * @param criteria * @param pageNo * @param pageSize * @return */ private PageInfo getInstancePageInfoWithCriteria(Criteria criteria, int pageNo, int pageSize) { long totalQuantity = 0L; // ܵtotalQuality criteria.setProjection(Projections.rowCount()); totalQuantity = (Long) criteria.uniqueResult(); PageInfo pageInfo = PageInfo.getInstance(pageNo, pageSize, totalQuantity); return pageInfo; } } ``` 这个方法是极为重要的 **protected abstract Class getEntityClass();** 后续介绍,现在暂时有个印象。 在 www 中的 dao 层有与各具体类 (数据表) 相对应的数据库操作实现: ![20241229154732_tHo8ryx3.webp](https://cdn.dong4j.site/source/image/20241229154732_tHo8ryx3.webp) 上图声明了三个具体类对应的接口声明:AdminDao、MacDao、TaskDao。 对应三个接口有三个具体的实现类:AdminDaoImpl、MacDaoImpl、TaskDaoImpl。 我们以与 Admin 类相关的 dao 层操作为例: **Admin.java** ``` package com.fxmms.www.domain; import org.hibernate.annotations.GenericGenerator; import javax.persistence.*; /** * Created by mark on 16/11/2. * @usage 管理员实体类,与数据库中表相对应 */ @Entity @Table(name = "mms_admin") public class Admin { @Id @GeneratedValue(generator = "increment") @GenericGenerator(name = "increment", strategy = "increment") @Column private int id; @Column private String userName; @Column private String password; @Column private String role; @Column private int enable; @Column private int isDelete; public int getId() { return id; } public void setId(int id) { this.id = id; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getRole() { return role; } public void setRole(String role) { this.role = role; } public int getEnable() { return enable; } public void setEnable(int enable) { this.enable = enable; } public int getIsDelete() { return isDelete; } public void setIsDelete(int isDelete) { this.isDelete = isDelete; } } ``` AdminDao.java ``` package com.fxmms.www.dao; import com.fxmms.common.dao.BaseDao; import com.fxmms.www.domain.Admin; /** * Created by mark on 16/10/31. * @usage 操作管理员数据库访问接口 */ public interface AdminDao extends BaseDao { } ``` **AdminDaoImpl.java** ``` package com.fxmms.www.dao.hib; import com.fxmms.common.dao.hib.HibernateTemplateDao; import com.fxmms.www.dao.AdminDao; import com.fxmms.www.domain.Admin; /** * Created by mark on 16/11/2. * @usage 使用适配器模式,将common层中定义的公共访问数据库方法实现嫁接到Admin类的接口中。 */ public class AdminDaoImpl extends HibernateTemplateDao implements AdminDao { @Override protected Class getEntityClass() { // TODO Auto-generated method stub return Admin.class; } } ``` 可以看到,在具体类相关的数据库操作实现类中,我们只需要实现 HibernateTemplateDao 中抽象方法 protected Class getEntityClass();即可。 给我们的感觉就是这个方法的实现是画龙点睛之笔。 回过头去看,在 HibernateTemplateDao 类中所有与数据库操作有关的方法: 例如: ``` @Override @SuppressWarnings("unchecked") public T getByUniqueKey(String columnName, Object value) { return (T) getSession().createCriteria(getEntityClass()) .add(Restrictions.eq(columnName, value)).uniqueResult(); } ``` getEntityClass() 方法最终都会被具体的类所实现。这个设计真的是很巧妙。 ### **5. 配置 tomcat 运行环境** 项目搭建已经完毕,接下来需要做的就是配置项目的运行环境了,这里我们采用 tomcat 来充当应用服务器。 **5.1 去官网下载** **tomcat 8.0** : **5.2 配置 tomcat 服务器:** 点击 Edit Configurations ![20241229154732_tHo8ryx3.webp](https://cdn.dong4j.site/source/image/20241229154732_tHo8ryx3.webp) 点击 **+** , 并选择 Tomcat Server 中 local 选项 ![20241229154732_ojYcFsU5.webp](https://cdn.dong4j.site/source/image/20241229154732_ojYcFsU5.webp) 添加启动任务名称,默认为 unnamed ![20241229154732_ig9DUzOL.webp](https://cdn.dong4j.site/source/image/20241229154732_ig9DUzOL.webp) 配置 Application Server ![20241229154732_fIKJvfBo.webp](https://cdn.dong4j.site/source/image/20241229154732_fIKJvfBo.webp) 装载开发版 (exploded) 应用 war 包, 此步骤有两种方式: 第一种方式:选择 Deploy at the server startup 下方的 **+** ,入下图所示: ![20241229154732_lD9SgjpR.webp](https://cdn.dong4j.site/source/image/20241229154732_lD9SgjpR.webp) 接下来在 Select Artifacts Deploy 弹出框中 选择 exploded 属性的 war 包 ![20241229154732_BJd5P2dY.webp](https://cdn.dong4j.site/source/image/20241229154732_BJd5P2dY.webp) 接下来选择 apply-> ok ,最终的结果是: ![20241229154732_PcOBGRdB.webp](https://cdn.dong4j.site/source/image/20241229154732_PcOBGRdB.webp) ![20241229154732_hYLbPDmx.webp](https://cdn.dong4j.site/source/image/20241229154732_hYLbPDmx.webp) 最终点击启动按钮启动应用 ![20241229154732_c5xOPjVC.webp](https://cdn.dong4j.site/source/image/20241229154732_c5xOPjVC.webp) 最终的启动效果如下所示 ![20241229154732_YWeoAGWC.webp](https://cdn.dong4j.site/source/image/20241229154732_YWeoAGWC.webp) ### **6.webapp 文件夹下分层详解** webapp 下有 res 文件夹,用于存储静态文件,WEB-INF 文件夹下有 view 文件夹表示 关于项目中应用到的 JNI 技术,会在后面讲解,主要侧重点是在代码层面解决 JNI link library 的问题。 ## [使用Axios拦截器统一处理HTTP请求和响应](https://blog.dong4j.site/posts/d9e151a4.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 要想统一处理所有 http 请求和响应,就得用上 axios 的拦截器。通过配置 http response inteceptor,当后端接口返回 401 Unauthorized(未授权),让用户重新登录。 ```javascript // http request 拦截器 axios.interceptors.request.use( config => { if (store.state.token) { // 判断是否存在token,如果存在的话,则每个http header都加上token config.headers.Authorization = `token ${store.state.token}`; } return config; }, err => { return Promise.reject(err); }); // http response 拦截器 axios.interceptors.response.use( response => { return response; }, error => { if (error.response) { switch (error.response.status) { case 401: // 返回 401 清除token信息并跳转到登录页面 store.commit(types.LOGOUT); router.replace({ path: 'login', query: {redirect: router.currentRoute.fullPath} }) } } return Promise.reject(error.response.data) // 返回接口返回的错误信息 }); 首先我们要明白设置拦截器的目的是什么,当我们需要统一处理http请求和响应时我们通过设置拦截器处理方便很多. 这个项目我引入了element ui框架,所以我是结合element中loading和message组件来处理的.我们可以单独建立一个http的js文件处理axios,再到main.js中引入. /** * http配置 */ // 引入axios以及element ui中的loading和message组件 import axios from 'axios' import { Loading, Message } from 'element-ui' // 超时时间 axios.defaults.timeout = 5000 // http请求拦截器 var loadinginstace axios.interceptors.request.use(config => { // element ui Loading方法 loadinginstace = Loading.service({ fullscreen: true }) return config }, error => { loadinginstace.close() Message.error({ message: '加载超时' }) return Promise.reject(error) }) // http响应拦截器 axios.interceptors.response.use(data => {// 响应成功关闭loading loadinginstace.close() return data }, error => { loadinginstace.close() Message.error({ message: '加载失败' }) return Promise.reject(error) }) export default axios ``` ## [掌握Sublime Text高效快捷键,提升编程效率](https://blog.dong4j.site/posts/350ed0bd.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Sublime Text 是一个强大的文本编辑器,提供了丰富的快捷键来提高编程效率。以下是 Sublime Text 中一些常用的快捷键。 ## 基本操作 - **打开命令面板**:`Shift + Cmd + P` - **控制台**:`Ctrl + ` (反引号) - **新建标签页**:`Cmd + N` - **切换标签页(通过数字)**:`Cmd + 数字` - **分成两屏显示**:`Cmd + Option + 2` ## 查找与替换 - **查找文本**:`Cmd + F` - **查找并替换文本**:`Option + Cmd + F` - **跳转到函数或方法**:`Cmd + R` - **添加/删除选中行的注释**:`Cmd + /` ## 编辑与格式化 - **智能缩进(增加或减少缩进)**:`Cmd + [ 或 Cmd + ]` - **显示/隐藏侧边栏**:`Cmd + K, B` - **删除光标前所有字符 (Mac)**:`Cmd + Delete` ### JSON 和 HTML 格式化 - **格式化 JSON 文件**:`Ctrl + Cmd + J` - **格式化 HTML 文件**:`Shift + Cmd + H` ## 具体命令列表 以下是一些具体操作对应的快捷键: - **文件跳转**: `Cmd + T` - **行号跳转 (类似 Vim 的 num + gg)**: `Ctrl + G` - **函数/方法跳转**: `Cmd + R` - **给选中行添加或去掉注释**: `Cmd + /` - **删除当前行**: `Ctrl + Shift + K` - **转换为大写**: `Cmd + K, U` - **转换为小写**: `Cmd + K, L` - **粘贴并缩进文本**: `Cmd + Shift + V` ### 页面操作 - **新建页面**:`Cmd + N` - **切换到指定页 (通过数字键)**:`Cmd + 数字` - **搜索跳转到对应页**:`Cmd + P` - **关闭页面**:`Cmd + W` - **合并一行**: `Cmd + J` ### 复制、粘贴与删除 - **选中当前单词,继续敲可以选中多个相同单词**:`Cmd + D` - **选择当前光标整行**: `Cmd + L` - **撤销操作**:`Cmd + Z` - **复制文本**:`Cmd + C` - **粘贴文本**:`Cmd + V` ### 其他 - **保存文件**:`Cmd + S` - **删除光标定位到当前行起始的一块**: `Cmd + Delete` - **缩进/回缩当前行**: `Cmd+]` 或 `Cmd+[` - **向下开辟一行**: `Cmd + Enter` - **向上开辟一行**: `Cmd + Shift + Enter` - **查询内容**:`Cmd + F` - **全局查找并替换**: `Cmd + Shift + F` ### 选择操作 - **查询到的内容下一个匹配项**:`Cmd + G` - **多点编辑(通过鼠标右键)** - **调出控制台**:`Cmd + ~` - **前往匹配的括号**:`Ctrl + M` ### 光标移动与选择 - **光标定位到当前行最前 (Mac)**: `Cmd + 左箭头` - 加上 `Shift`: 选中从当前位置至行首的部分 - **光标定位到当前行最后**: `Cmd + 右箭头` - 加上 `Shift`: 选中从当前位置至行尾的部分 - **纵向选择编辑 (Mac)**: `Option + 左键点击并拖动` ### 导航与切换标签页 - **跳转到指定行号**:`Ctrl + G` - **切换单个标签页(向前和向后)**: - `Ctrl + Tab`: 切换至下一个标签页 - `Ctrl + Shift + Tab`: 切换至上一个标签页 ### 删除操作 - **删除光标定位到当前单词起始的一块 (PC)**: `Ctrl + Delete` ## [系统级操作与第三方软件:macOS 快捷键全解](https://blog.dong4j.site/posts/6dad3a8f.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 在 macOS 操作系统中,快捷键是提高工作和生活效率的重要工具之一。掌握这些常用快捷键能让你更加得心应手地操作电脑。本文将详细介绍 macOS 系统、应用程序以及开发工具相关的快捷键。 ## 常用 macOS 快捷键 ### 系统级快捷键 1. **控制 + shift + 关机键** - 让 Mac 进入休眠模式。 2. **开机时按住 shift+enter** - 忽略自启动软件,直接加载系统。 3. **控制 + f2** - 在任务栏(Dock)上移动焦点。 4. **控制 + f3** - 选择 dock 中的应用或图标。 5. **opt + cmd + 右上角** - 锁定屏幕。 6. **option + command + esc** - 强制退出未响应的应用程序。 7. **control + 右上角** - 调出关机帮助界面。 8. **Ctrl + 关机键** - 显示关机提示对话框,可以取消关闭操作。 9. **Ctrl + Opt + 关机键** - 安全地关闭 Mac 电脑。 10. **Ctrl + Cmd + 关机键** - 重启 Mac 电脑。 11. **Shift + Ctrl + Opt + 关机键** - 立即断电关闭电脑,不进行任何保存操作,请谨慎使用此快捷键。 12. **opt + `\`** - 打开 Finder 窗口。 13. 当有外部显示器时,可以通过按 **control + `\`** 将当前激活的应用程序移动到外接屏幕上。 14. 使用 **control + 1 ~ 9** 可以直接打开 Dock 栏中的应用程序。 ### 设置.app --> 键盘 --> 快捷键 - 显示 mission control: **control + 上箭头 / option 4 指上滑** - 打开应用窗口: **control + 下箭头 / option 4 指向下滑动** - 切换桌面: **control + 左/右/1/2/3/4/5/6 或者使用 TotalSpaces2 插件的选项,可以使用 4 手指上下左右滑动** - 切换输入法: **command + 空格键** - 将文件添加到 Yoink 应用:**Hybrid (Ctrl+Opt+Cmd+Shift) + y** - 显示 Finder 搜索窗口:**option + command +空格** ## 第三方软件快捷键 ### eudic.app 1. 展示/隐藏窗口: **hybrid(Control+Option+Command+Shift)+9** 2. 翻译选中的内容: **hybrid + 0** 3. 取词:控制+选项+命令 ### Karabiner.app - 定义了 Hybrid 作为 Ctrl+Opt+Cmd+Shift。 1. 使用 **right_shift + a/c/d/e/f/s/t/m** 分别快速打开 Activity Monitor, Chrome, Dash, Sublime Text, Finder, Safari, iTerm 和 MWeb 应用程序. 2. 在应用内切换标签页: **right_command + l/h** 3. 调出 TextExpander:**hybrid + t** ### Alfred.app 1. 打开 Alfred 界面:**option + 空格键** 2. 访问剪贴板历史记录:双击 command 键。 3. 打开 iTunes 迷你播放器窗口: **hybrid + enter** 4. 在 SnippetsLab 中搜索条目:**hybrid + s** ### Dropzone.app 1. 快速启动 Dropzone:**Hybrid + F12** ### Things.app 1. 添加新待办事项到列表:**hybrid + 空格** ### Snappy.app 1. 截取屏幕快照: **command + shift + 2** 2. 关闭 Snappy: command + W 或者双击 3. 移动截图或选择区域:左键点击并拖动 4. 按像素移动选区:按住 alt,然后上下左右移动光标。 5. 调整截屏大小:按住 alt+ctrl,然后拖动角或者边框。 6. 增加/减少截图的尺寸:command + + 或者 - 7. 更改透明度:0-9 8. 恢复默认状态:command 0 ### TotalFinder 插件 1. 使用 **双击 control** 打开 Finder 窗口. 2. 使用 control+`快捷键来打开 finder ### CmdTap 插件 1. 使用**option + tab**切换应用。 2. 使用**双击 option** 切换到下一个应用程序. ### HyperDock 插件 - 调整窗口大小:hybrid+ 方向键/数字键(例如:向左或向右调整) - 自定义窗口尺寸:在拖动时按住 hybrid+ 左键 ### Bartender 插件 1. 通过 **hybrid + b** 显示隐藏的应用图标。 ### Sublime Text.app、Idea.app 和 VSCode.app 所有这三个文本编辑器/IDE 都提供了格式化代码的快捷键(使用不同的命令),但它们都支持 **option+command+l** 进行快速格式化。 - 在 Sublime 中,您还需要先设置文件扩展名才能使用该快捷方式。 ## [快速上手 iTerm2:掌握这些必备快捷键](https://blog.dong4j.site/posts/6bf37004.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 在使用终端模拟器和 tmux 时,掌握一些快捷键可以帮助你提高工作效率。以下是 iTerm2 和 tmux 的一些常用快捷键。 ## iTerm2 快捷键 ### 基本操作 - **打开/关闭 iTerm**:`F12` - **快速标记**:`Cmd+Shift+m` - **回到标记位置**:`Cmd+Shift+j` ### 搜索和历史记录 - **正则表达式搜索**:`Cmd+f` - **剪切板历史**:`Cmd+Shift+h` - **显示命令历史记录**:`Cmd+;` ### 切换 Tab 和 Pane - **切换 tab**: `Cmd+←`, `Cmd+→`, `Cmd+{`, `Cmd+}` - **新建 tab**: `Cmd+t` - **顺序切换 pane**: `Cmd+[`, `Cmd+]` - **按方向切换 pane**: `Cmd+Option+方向键` ### 切分屏幕 - **水平切分**: `Cmd+d` - **垂直切分**: `Cmd+Shift+d` ### 其他快捷操作 - **高亮显示鼠标指针**:`Cmd+/` - **快照返回**: `Cmd+Option+b` ### 文本编辑相关 - `Ctrl+q`: 清空当前行 - `⌃ + u`: 删除从当前位置到行首的内容 - `⌃ + a`: 移动到行首 - `⌃ + e`: 移动到行尾 - `⌃ + f`: 向前移动一个字符 - `⌃ + b`: 向后移动一个字符 - `⌃ + p`: 上一条命令 - `⌃ + n`: 下一条命令 - `⌃ + r`: 搜索历史命令 - `⌃ + y`: 召回最近删除的文字 - `⌃ + h`: 删除光标前的一个字符 - `⌃ + d`: 删除光标指向的字符 - `⌃ + w`: 删除光标前的一个单词 - `⌃ + k`: 删除从当前位置到行尾的内容 - `⌃ + t`: 交换当前光标位置和前一个字符 ### 输入模式 - **大写字母输入**:`Cmd+Shift+u` ## tmux 快捷键 在 tmux 中,大多数快捷操作都需先按 `Ctrl+b` 激活。 ### 系统操作 - **列出所有快捷键**: `?` - **脱离当前会话**: `d` - **选择要脱离的会话**: `D` - **挂起当前会话**: `Ctrl+z` - **强制重绘未脱离的会话**: `r` - **选择并切换会话**: `s` - **进入命令行模式**: `:` - 例如,`kill-server` 关闭服务器 - **进入复制模式**(此时的操作与 vi/emacs 相同): `[` - **列出提示信息缓存**: `~` ### 窗口操作 - **创建新窗口**: `c` - **关闭当前窗口**: `&` - **切换至指定窗口**: 按数字键 (`1` 到 `9`) - **切换至上一窗口**: `p` - **切换至下一窗口**: `n` - **在前后两个窗口间互相切换**: `l` - **通过窗口列表切换窗口**: `w` - **重命名当前窗口**: `,` - **修改当前窗口编号**(相当于窗口重新排序): `.` - **在所有窗口中查找指定文本**: `f` ### 面板操作 - **将当前面板平分为上下两块**: `"` - **将当前面板平分为左右两块**: `%` - **关闭当前面板**: `x` - **将当前面板置于新窗口**(新建一个窗口,其中仅包含当前面板): `!` - **以 1 个单元格为单位移动边缘调整当前面板大小**: `Ctrl+方向键` - **以 5 个单元格为单位移动边缘调整当前面板大小**: `Alt+方向键` - **在预置的面板布局中循环切换**:`Space` - 包括 even-horizontal, even-vertical, main-horizontal, main-vertical, tiled - **显示面板编号**: `q` - **选择下一面板(当前窗口)**: `o` - **移动光标以选择面板**: 方向键 - **向前置换当前面板**: `{` - **向后置换当前面板**: `}` - **逆时针旋转当前窗口的面板**: `Alt+o` - **顺时针旋转当前窗口的面板**: `Ctrl+o` ## [打造个性化 IntelliJ IDEA 编辑环境](https://blog.dong4j.site/posts/eb599575.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) IntelliJ IDEA 是一款功能强大的集成开发环境(IDE),它提供了丰富的快捷键来提高编程效率。下面是一些常用的编辑器背景色、行号栏颜色以及控制台背景色的设置方法,同时涵盖了自定义代码折叠和一些 IDEA 常用及自定义快捷键。 ## 设置编辑器颜色 1. **编辑器背景色**: - 前往 `Preference` -> `Editor` -> `Color & Fonts` -> `Editor` -> `General` -> `Text` -> `Default text` 2. **行号栏的颜色**: - 前往 `Preference` -> `Editor` -> `Color & Fonts` -> `General` -> `Editor` -> `Gutter background` 3. **控制台背景色设置**: - 在 `Setting` 中找到 `Editor`,然后选择 `color Scheme` -> `console Colors`。右侧的 Console 部分可以调整背景颜色。 4. 其他编辑器颜色设置: - 调整光标当前行颜色:前往 `Editor` -> `Colors & Fonts` -> `General` -> `Editor` -> `Caret row` - 修改行背景色:前往 `Editor` -> `Colors & Fonts` -> `General` -> `Gutter` -> `Background` ## 自定义代码折叠 在需要自定义代码折叠的情况下,可以使用以下语法: ```java //region 描述 Your code goes here... //endregion ``` ## IntelliJ IDEA 常用快捷键 - **插入**:`control+enter` - **排错**:`option+enter` - **下方插入一行**:`shift+enter` - **上方插入一行**:`command + enter` - **创建测试用例**:`shift + command -- > 创建测试用例` - **运行代码**: `shift + control + r` - **快速折叠/展开代码段**:使用 `option + command + (+/-)` - **关闭/打开当前折叠**:`command + >` - **返回上一跳转位置**:`command + [` - **跳到下一个位置**:`command + ]` 此外,还有: - 生成代码模板:`shift+command+j` - 查看文档:使用 `F1` - 搜索关键字:使用 `shift+command+f` ### Debugging 快捷键 - 开始调试:`control+d` - 调试时查看选中值:`alt+f8` - 单步执行到下一个断点:`f8` - 强制进入代码:`alt+shift+f7` - 运行 Java 类(非 debug): `ctrl+shift+f10` ### 翻译快捷键 - 选中文本翻译:使用 `option + 数字键` - 全句翻译并替换:`option+r` ## 自定义 IntelliJ IDEA VM Options 在启动 IntelliJ IDEA 时可以自定义 VM 选项,以优化性能: ```properties -Xverify:none # 关闭Java字节码验证, 加快类装入速度 -server # 启用服务器模式 -XX:+UseG1GC # 使用 G1 垃圾回收器 -Dfile.encoding=UTF-8 # 设置文件编码为UTF-8 -XX:MaxMetaspaceSize=512m # 设置元空间最大大小 -javaagent:JetbrainsCrack-3.1-release-enc.jar # 启用 Jetbrains 装饰工具 ``` ### 使用 HTTP 代理 若需要设置全局的 HTTP 代理,请在 `idea.exe.vmoptions` 及 `idea64.exe.vmoptions` 文件中添加如下内容: ```properties -DproxySet=true -Dhttp.proxyHost=127.0.0.1 # 这里是你的 HTTP 代理服务器地址 -Dhttp.proxyPort=1080 # 这里是你代理的端口号 ``` ## [从入门到精通:IDEA 的所有快捷键汇总](https://blog.dong4j.site/posts/72a223e0.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 文件和编辑相关 - **control+enter** - 插入(与 alt+insert 相同) - **option+enter** - 排错(与 alt+enter 相同) - **shift+enter** - 在当前行下面插入新行 - **command + enter** - 在光标前一行插入新行 - **shift + command** - 创建测试用例 - **shift + control + r** - 运行配置的运行/调试任务 - **option + commmand + (+/-)** - 快速折叠代码段或打开折叠的部分 (Ctrl+Shift+/) - **command + > 或 <** - 关闭或展开当前文件夹内的所有折叠部分(仅针对文件夹) - **command + [ 或 ]** - 跳转到上次编辑的位置 - **shift + command + F** - 在项目中查找关键字 - **option + command + l** - 代码格式化 (Ctrl+Alt+L) - **shift+command+j** - 插入代码模板(Live Templates) - **command + j** - Acejump: 快速定位到行首、变量或方法等 - **shift + command + A** - 在全局范围内查找操作或设置选项 - **Class... 或 File...** - 跳转到某个类或文件 (Ctrl+Shift+N) - **File structure** - 可跳转至代码内部的特定部分,如函数、变量等 (Ctrl+F12) - **Declaration** - 快速定位到所选符号或类型的定义处(Ctrl+B) - **Back/Front** - 返回/前进至上一次跳转的位置 - **Search everywhere** - 跳转至任意位置 - **command + y** - 展示当前光标所在函数或类的全部内容 (Alt+F7) - **shift+f12** - 关闭所有非编辑区域,回到编辑器界面(Ctrl+Shift+F12) - **cmd + e 或 cmd + shift + e** - 跳转到最近使用的文件或最近编辑过的文件 - **opt + f12** - 打开命令行窗口 (Alt+F12) ## 代码重构与调试相关 - **alt+command+v** - 快速生成方法返回类型 (Ctrl+Alt+V) - **alt+command+m** - 将选中的代码段提炼成新方法(Refactor -> Extract Method) (Ctrl+Alt+M) - **shift+f8** - 跳转到下一个断点 - **f8** - 单步执行调试程序 - **control+d** - 开始或停止调试会话(Debug) - **alt+f8** - 在运行时查看表达式的值 (Eval Expression) - **f7** - 进入代码内部的函数 (Step Into) - **alt+shift+f7** - 强制进入函数(Force Step into) - **ctrl+shift+f9** - 重新启动当前会话(默认为最后一个运行过的会话) (Run) - **ctrl+shift+f10** - 在 IDEA 中直接运行或调试 Java 程序 - **command+f2** - 停止正在执行的代码(停止任务) - **control + h 或 control + opt + h** - 显示方法层级图 (Ctrl+H) - **opt + cmd + U** - 生成类、接口或枚举的 UML 图 (Alt+Insert -> Diagrams -> Class Diagram for File) ## 方法和使用相关 - **opt+f7 或 opt+alt+f7** - 查找当前函数或变量被使用的所有地方(Find Usages)(Ctrl+F7) - **ctrl+u** - 跳转到父类或接口的定义处 (Alt+Up/Down Arrows) ## 书签与任务 - **f3** - 新建不带标签的书签 - **opt+f3** - 新建带标签的书签 - **cmd + f3** - 打开书签浏览器窗口 (Ctrl+F11) - **opt+shift+n** - 在当前文件中新建任务或标记位置(对于 CVS 用户)(Alt+Insert -> Task) ## 文档查看相关 - **f1** - 查看文档帮助 (Quick Documentation Lookup) (Alt+Q) - **shift+command+d** - 在 Dash 中快速查找对应类或方法的文档 ## 翻译功能快捷键 IntelliJ IDEA 还支持多种语言的翻译。 - **alt+1, 2 或 3** - 执行选中文本翻译,分别选择最小范围、最大范围和单词级别 (Alt+Shift+Insert) - **alt+r** - 将翻译后的文本自动替换掉源代码 - **alt+t** - 翻译一些界面元素内的文字(如提示气泡等) - **alt+0** - 打开反应窗口,显示已翻泽的内容 ## [常量池迁移史:从永久代到堆,JVM内存的变迁](https://blog.dong4j.site/posts/4453ea00.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 字符串常量归常量池管理,那比如 String str = "abc"; "abc" 这个对象是放在内存中的哪个位置,是字符串常量池中还是堆? ”这句代码的 abc 当然在常量池中,只有 new String("abc") 这个对象才在堆中创建“,他们大概是这么回答。 “abc”这个东西,是放在常量池中,这个答案是错误的。 **字符串“abc" 的本体、实例,应该是存在于 Java 堆中。** 可能还真的有部分同学对这个知识点不熟悉,今天和大家聊聊字符串这个问题 ~ 初学 Java 时,学到字符串这一部分,有一段代码 ```java String str1 = "hello"; String str2 = new String("hello"); 复制代码 ``` > 书上的解释是:执行第一行的时候,已经把 "hello" 字符串放到了常量池中,执行第二行代码时,会将常量池中已经存在的 "hello" 复制一份到堆内存中,创建一个的新的 String 对象。虽然值一样,但他们是不同的对象。 当时看完这个解释,我产生了很多疑惑。因为在此之前已经知道字符串的底层是 char 数组实现的。我很疑惑: - 他 copy 一份过去,是 copy 了 char 数组呢? - 还是 copy 整个 String 对象? - "hello" 这个对象实例真的存放在常量池中吗? 当时在网上搜了一些文章和答案,各有说辞,大部分回答都是 "str" 这个对象在常量池中,但也有认为字符串常量实例(或叫对象)是在堆中创建,只是将其引用放到字符串常量池中,交给常量池管理。 ## JAVA 内存区域 — 运行时数据区 理清这个问题前,需要梳理一下前置知识。 从一个经典的示意图讲起,以 hotspot 虚拟机为例,此内存模型需建立在 JDK1.7 之前的版本来讨论,JDK1.7 之后有所改变,但是原理还是一样的。 ![20241229154732_uaxPxuHQ.webp](https://cdn.dong4j.site/source/image/20241229154732_uaxPxuHQ.webp) Java 虚拟机管理的内存是运行时数据区那一部分,简单概括一下其中各个区域的区别: - **虚拟机栈**:线程私有,生命周期与线程相同,即每条线程都一个独立的栈(VM Stack)。每个方法执行时都会创建一个栈帧,也就是说,当有一条线程执行了多个方法时,就会有一个栈,栈中有多个栈帧。 - **本地方法栈**:线程私有 - **程序计数器**:线程私有 - **堆 Heap**:线程共享,是 Java 虚拟机所管理的内存中最大的一块,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。**在 Java 虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。 ** *(原文:The heap is the runtime data area from which memory for all class instances and arrays is allocated)* 但有特殊情况,随着 JIT 编译器的发展,逃逸分析和标量替换技术的逐渐成熟,对象也可以在**栈上**分配。另外,虽说堆是线程共享,但其中也可以划分出多个线程私有的分配缓冲区 _(Thread Local Allocation Buffer,TLAB)_。 - **方法区**:线程共享,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。 ## JAVA 的三种常量池 此外,Java 有三种常量池,即**字符串常量池(又叫全局字符串池)、class 文件常量池、运行时常量池**。 ![20241229154732_i9lS5QCd.webp](https://cdn.dong4j.site/source/image/20241229154732_i9lS5QCd.webp) **1. 字符串常量池(也叫全局字符串池、string pool、string literal pool)** 字符串常量池在每个 VM 中只有一份,他在内存中的位置如图,红色箭头所指向的区域 Interned Strings **2. 运行时常量池(runtime constant pool)** 当程序运行到某个类时,class 文件中的信息就会被解析到内存的方法区里的运行时常量池中。看图可清晰感知到每一个类被加载进来都会产生一个**运行时常量池**,由此可知,每个类都有一个运行时常量池。它在内存中的位置如图,蓝色箭头所指向的区域,方法区中的 Class Date 中的运行时常量池(Run-Time Constant Pool) ![20241229154732_qETfdsVS.webp](https://cdn.dong4j.site/source/image/20241229154732_qETfdsVS.webp) **3. class 文件常量池(class constant pool)** class 常量池是在编译后每个 class 文件都有的,class 文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是  **常量池**(constant pool table)_,用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。_ 字面量就是我们所说的常量概念,如文本字符串、被声明为 final 的常量值等. 他在 class 文件中的位置如上图所示,Constant Pool 中。 ## 个人理解 ```java public static void main(String[] args) { String str = "hello"; } 复制代码 ``` 回到一开始说到的这句代码,可以来总结一下它的执行过程了。 1. 首先,字面量 "hello" 在编译期,就会被记录在 class 文件的  **class 常量池**中。 2. 而当 class 文件被加载到内存中后,JVM 就会将 class 常量池中的**大部分内容存放到运行时常量池**中,但是**字符串 "hello" 的本体**(对象)和其他所有对象一样,是**会在堆中创建**,**再将引用放到字符串常量池**,也就是图一的 Interned Strings 的位置。(RednaxelaFX 的文章里,测试结果是在新生代的 Eden 区。但因为一直有一个引用驻留在字符串常量池,所以不会被 GC 清理掉) 3. 而到了 String str = "hello" 这步,JVM 会去字符串常量池中找,如果找到了,JVM 会在栈中的局部变量表里创建 str 变量,然后把字符串常量池中的(hello 对象的)**引用**复制给 str 变量。 在《深入理解 Java 虚拟机》这本书中也有字符串相关的解释,举其中几个例子: **例子 1** > (原文)运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,**这部分内容将在类加载后进入方法区的运行时常量池中存放。** 最后一句描述不太准确,编译期生成的各种字面量并不是全部进入方法区的运行时常量池中。**字符串字面量就不进入运行时常量池**,而是**在堆中创建了对象**,**再将引用驻留到字符串常量池中。** **例子 2** ```java //代码清单2-7 String.intern()返回引用的测试 public class RuntimeConstantPoolOOM{ public static void main(String[]args){ String str1=new StringBuilder("计算机").append("软件").toString(); System.out.println(str1.intern()==str1); String str2=new StringBuilder("ja").append("va").toString(); System.out.println(str2.intern()==str2); } } 复制代码 ``` > (原文)这段代码在 JDK 1.6 中运行,会得到两个 false,而在 JDK 1.7 中运行,会得到一个 true 和一个 false。产生差异的原因是:在 JDK 1.6 中,intern()方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由 StringBuilder 创建的字符串实例在 Java 堆上,所以必然不是同一个引用,将返回 false。而 JDK 1.7(以及部分其他虚拟机,例如 JRockit)的 intern()实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此 intern()返回的引用和由 StringBuilder 创建的那个字符串实例是同一个。对 str2 比较返回 false 是因为 “java” 这个字符串在执行 StringBuilder.toString()之前已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串则是首次出现的,因此返回 true。 原文解释也不太准确,我觉得在 JDK 1.6 中,intern()并不会把首次遇到的字符串实例复制到永久代中,而是会将实例再复制一份到堆(heap)中,然后将其引用放入字符串常量池中进行管理,所以此代码返回 false。而 JDK1.7 中的 intern()不会再复制实例,直接将首次遇到的此字符串实例的引用,放入字符串常量池,于是返回 true。关于此观点,还没看到大神文章实锤,欢迎讨论。 最后再延伸一点,大家都知道,字符串的 value 是 final 修饰的 char 数组,那么以下这段代码: ```java // private final char value[]; String str1 = "hello world"; String str2 = new String("hello world"); String str3 = new String("hello world"); 复制代码 ``` str1、str2、str3 三个变量所指向的都是不同的对象。(str1 != str2 != str3) 那么,这三个对象里的 char 数组是否是同一个数组?相信大家都有答案了。 --- 此文所讨论的 Java 内存模型是建立在 JDK1.7 之前。JDK 7 开始 Hotspot 虚拟机把字符串常量池(Interned String 位置)从永久代(PermGen)挪到 Heap 堆,JDK 8 又彻底取消 PermGen,把诸如 class 之类的元数据都挪到 GC 堆之外管理。但不管怎样,基本原理还是不变的,字面量 ”hello“ 等依旧不是放在 Interned String 中。 --- **推荐文章:** - [请别再拿 “String s = new String("xyz"); 创建了多少个 String 实例” 来面试了吧](https://link.juejin.im/?target=https%3A%2F%2Frednaxelafx.iteye.com%2Fblog%2F774673) - [借 HSDB 来探索 HotSpot VM 的运行时数据](https://link.juejin.im/?target=https%3A%2F%2Frednaxelafx.iteye.com%2Fblog%2F1847971) *作者:RednaxelaFX,曾为《深入理解 Java 虚拟机》提推荐语* - [java 用这样的方式生成字符串:String str = "Hello",到底有没有在堆中创建对象?](https://link.juejin.im/?target=https%3A%2F%2Fwww.zhihu.com%2Fquestion%2F29884421%2Fanswer%2F113785601) - 胖君的回答 - 知乎 ## [Docker基问题记录](https://blog.dong4j.site/posts/79a8d4c5.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 一、升级到最新版 ### 1. 检查当前已安装的 docker 相关软件包 ```shell rpm -qa | grep docker ``` ### 2. 卸载旧版本 执行以下命令卸载所有列出的相关软件: ```shell yum remove docker- yum remove docker-client- yum remove docker-common- # 示例: # yum remove docker-1.13.1-53.git774336d.el7.centos.x86_64 ``` ### 3. 升级到最新版 使用 curl 命令安装最新的 Docker 版本: ```shell curl -fsSL https://get.docker.com/ | sh ``` ### 4. 启动和设置开机自启 Docker 服务 - 重启 Docker ```shell systemctl restart docker ``` - 设置启动项,确保 Docker 在系统启动时自动运行: ```shell systemctl enable docker ``` ## 二、解决升级后容器启动错误 若从旧版本(如 1.13.1)直接升级到新版本(例如 18.06.1),可能会遇到如下报错信息,当尝试启动某些使用`docker-runc`运行时创建的容器时: ```shell Error response from daemon: Unknown runtime specified docker-runc ``` 解决方法是搜索并替换所有提到`docker-runc`为`runc`: ```shell grep -rl 'docker-runc' /var/lib/docker/containers/ | xargs sed -i 's/docker-runc/runc/g' systemctl restart docker docker start f5eb78732bcc # 单独重启容器 ``` ## 三、安装 Docker Compose 若您的系统未安装 Docker compose,执行以下步骤进行安装: 1. 确认 pip 已正确安装。 ```shell pip -V # 若没有输出,请按照下述命令进行安装: yum -y install epel-release yum -y install python-pip pip install --upgrade pip ``` 2. 安装 Docker Compose 本身: ```shell pip install docker-compose docker-compose -version # 检查是否正确安装了Docker Compose ``` ## 四、Docker 磁盘清理与镜像管理 ### 系统垃圾清理: 使用以下命令清理不需要的容器,网络和镜像等: ```shell docker system prune ``` 查看 Docker 所占用的空间大小, 使用: ```shell docker system df ``` ### 镜像文件存储和加载 - 保存 Docker 镜像至 tar 文件: ```shell docker save -o xxxx.tar {imageId} ``` - 加载已保存的镜像: ```shell docker load -i xxxx.tar ``` ## 其他问题处理 ### MySQL 文件目录设置 当使用外部存储路径时,务必指定`/var/lib/mysql-files` 的完整路径。例如: ```shell -v /home/mysql/mysql-files:/var/lib/mysql-files/ ``` ### Docker 权限问题: 对于一些需要 root 权限的操作,可以添加 `--privileged=true` 参数。 ## 五、查看容器日志与设置自动启动 - 查看 Docker 容器的日志信息: ```shell docker logs -t --tail=n [容器id] ``` 要确保 Docker 容器在重启后仍能正常运行, 可以在启动时使用`--restart`参数,支持以下值: - `no`: 不尝试重新启动已退出的容器。 - `on-failure[:N]`: 当容器非零状态退出时才重启。可选地限制为最多 N 次重启 - `always`: 总是重启该容器 例如: ```shell docker run --restart=on-failure:10 redis # 在失败后尝试重新启动10次。 ``` 如果已经创建了容器但需要更改其`--restart`策略,使用更新命令: ```shell docker update --restart=always xxxxx_containername ``` ## [Docker基础教程:镜像、容器与仓库](https://blog.dong4j.site/posts/fa8faa2e.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) # Docker 入门指南 Docker 是一个开源的应用容器引擎,让开发者能够将软件及其运行时环境封装起来以方便地进行移植和部署。通过使用 Docker,可以快速打包、发布以及运行应用程序在几乎任何地方(包括物理机或虚拟机上)。它利用 Linux 内核的资源隔离特性来实现轻量级的操作系统级虚拟化,使得开发人员能够创建和管理容器化的应用和服务。 ## 安装 Docker ### 在 Ubuntu 上安装 Docker 1. 更新包索引: ```shell $ sudo apt-get update ``` 2. 安装必要的软件包以允许 `apt` 使用 HTTPS 来获取安全存储库: ```shell $ sudo apt-get install apt-transport-https ca-certificates curl software-properties-common ``` 3. 添加 Docker 的官方 GPG 密钥: ```shell $ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - ``` 4. 利用该密钥来验证 Docker 存储库中的所有软件包的完整性和真实性: ```shell $ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" ``` 5. 再次更新你的存储库索引: ```shell $ sudo apt-get update ``` 6. 安装 Docker CE(Community Edition): ```shell $ sudo apt-get install docker-ce ``` 7. 验证安装是否成功,运行一个简单的容器来输出 "Hello from Docker" 消息: ```shell $ sudo docker run hello-world ``` 如果一切正常,则你已经正确安装了 Docker。 ### Windows 和 macOS 安装 对于 Windows 或 macOS 用户,可以下载并使用 Docker Desktop for Mac 或 Docker Desktop for Windows。这提供了图形界面和命令行工具来管理容器。 ## 基本概念与术语 - **Image(镜像)**:Docker 镜像是一个轻量级、独立的、包含所有必要的代码以及依赖关系的打包文件,可以用来创建容器。 - **Container(容器)**:基于镜像生成的一个运行实例。每个容器都拥有自己的一组进程和资源。 - **Repository(仓库)**:存储和共享 Docker 镜像的地方。 ## 基本命令 ### 搜索镜像 ```shell $ docker search [image_name] ``` ### 下载镜像 ```shell $ docker pull [image_name] ``` ### 列出本地所有镜像 ```shell $ docker images ``` ### 运行容器 ```shell $ docker run -it --name my_container [image_name] /bin/bash # 或者,启动一个在后台运行的容器: $ docker run -d --name my_container [image_name] ``` `-it`: 交互模式加上分配终端 ### 列出正在运行的所有容器 ```shell $ docker ps ``` ### 列出所有容器(包括已经停止) ```shell $ docker ps -a ``` ### 进入一个已启动的容器内 ```shell $ docker exec -it [container_name] /bin/bash ``` `-it`: 交互模式加上分配终端 ### 查看日志 ```shell $ docker logs [container_id] ``` ### 停止一个运行中的容器 ```shell $ docker stop [container_id] ``` ### 删除容器 ```shell $ docker rm [container_name] ``` 如果该容器正在运行,需要先停止它。 ### 删除镜像 ```shell $ docker rmi [image_name] ``` 如果该镜像被某个容器使用,则必须先删除其关联的容器。可以使用`-f`强制删除。 ```shell $ docker rmi -f [image_name] ``` ## 构建自己的 Docker 镜像 假设你有一个 Dockerfile 文件来描述你的应用程序,你可以通过以下命令构建一个镜像: ```shell $ docker build -t my_image . # 如果需要指定标签(tag),可以使用-t参数 $ docker build --tag=my_image:version . ``` ## Docker Compose Docker Compose 是用于定义和运行多容器 Docker 应用程序的工具。通过一个名为`docker-compose.yml` 的文件,你可以描述一组服务、网络及卷。 ```yaml version: "3" services: web: build: . ports: - "5000:5000" redis: image: "redis:alpine" ``` 在配置好此文件后: - 使用`docker-compose up` 来启动服务。 - 若要停止容器,可以运行 `docker-compose down`。 ## [JUnit4 入门:掌握 Java 单元测试的艺术](https://blog.dong4j.site/posts/6961d5gq.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) JUnit 是一个开放源代码的 Java 测试框架,用于编写和运行可重复的测试。他是用于单元测试框架体系 xUnit 的一个实例(用于 java 语言)。 ## 为什么使用 Junit 我们以前测试一个类的步骤: 1. 新建一个 test 类 2. 创建 main() 方法 3. 在 main 类 new 一个我们要测试的类的实例 4. 然后调用这个类的方法,输出一个结果 当测试的类有多个方法时,我们必须调用所有的方法,为了不让上一次的方法调用对下一次的调用产生影响,我们会在 new 一个实例出来,或者将上一次的代码注释掉. 则将造成整个测试代码的混乱. 这个时候我们希望如果可以有多个 mian() 方法,每个 main() 方法内只调用一个需要测试的类的方法, 这样显得调理清晰.但是这是不可能的,一个程序只能有一个入口 这个时候,Junit 站了出来,它大声的说它可以做到. ## 怎么使用 Junit 主要步骤: 1. 新建一个 java 项目 2. 在 src 下新建一个 util 包,编写一个普通的类 ```java /** * 对名称,地址等字符串格式的内容进行格式检查 * 或者格式化的工具类 */ public class WordDeanUtil { /** * 将Java对象名称(每个单词的头字符大写)按照 * 数据库命名的习惯进行格式化 * 格式化后的数据为小写字母,并且使用下划线分割命名单词 * 例如:employeeInfo-->employee_info * * @param name Java对象名称 */ public static String wordFormat4DB(String name){ //使用给定的正则表达式创建Pattern对象(将给定的正则表达式编译到模式中。) Pattern p = Pattern.compile("[A-Z]"); //创建 字符串和模式匹配的匹配器 Matcher m = p.matcher(name); StringBuffer sb = new StringBuffer(); //拿着name中的字符一个一个的去和正则表达式对比,成功返回true while(m.find()){ //找大写字母 m.appendReplacement(sb, "_" + m.group()); } return m.appendTail(sb).toString().toLowerCase(); } } ``` 3. 在 src 下新建一个 tests 文件夹,将它设置为测试专用文件夹 - 在工程名上按 f4 - 找到 Modules-->Sources - 找到 tests 文件夹,然后 Mark as Tests ![20241229154732_szYRVb9s.webp](https://cdn.dong4j.site/source/image/20241229154732_szYRVb9s.webp) 4. 选中我们要测试的类的类名,然后 Ctrl+shift+t --> Create new test 5. 选择 Junit4,然后选择要测试的类的方法 (method),setUp 和 tearDown 后面再介绍 ![20241229154732_CjwnCDQD.webp](https://cdn.dong4j.site/source/image/20241229154732_CjwnCDQD.webp) 6. 点击 OK 后,如果前面的 test 测试文件夹没有出错的话,会在 tests 文件夹下生成一个包,这个包和我们要测试的类的包一样,还有一个以测试类名 +Test 的类 (不同的 IDE 有不同的规则,Myeclipse 就是在前面加 test 的),我们主要在这个类中操作 (单元测试代码和被测试代码使用一样的包,不同的目录) ![20241229154732_2bT5TNG7.webp](https://cdn.dong4j.site/source/image/20241229154732_2bT5TNG7.webp) 7. 下面来写一个简单的测试方法 测试方法书写规范: - 测试方法必须使用注解 org.junit.Test 修饰 - 测试方法必须使用 public void 修饰,而且不能带有任何参数 - 测试方法名一般以 test+ 被测试的方法名书写 ![20241229154732_EF3hDbgY.webp](https://cdn.dong4j.site/source/image/20241229154732_EF3hDbgY.webp) - 说明: 1. 我们只需要要这个测试方法当成一个 main() 方法,在这个方法里面书写我们以前在 main() 方法内写过的测试代码. - new 一个我们要测试的类的实例 - 然后调用这个类的方法,输出一个结果 2. 在这个例子中我们只有一个需要测试的方法,而且是静态的.所以直接就使用类名 + 方法名调用我们要测试的方法了 3. `assertEquals("employee_info",reslut)` 的意思是第一个参数时我们能预测的想要的结果,第二个参数是我们要测试的方法返回的结果,如果这两个字符串相同,整个测试通过. 4. 我们完全可以不使用 Junit 提供的这个方法 ![20241229154732_cN5eJ3Gv.webp](https://cdn.dong4j.site/source/image/20241229154732_cN5eJ3Gv.webp) 5. assertEquals 是由 JUnit 提供的一系列判断测试结果是否正确的静态断言方法(位于类 org.junit.Assert 中)之一 6. Junit 给我们提供了大量的静态方法让我们编写少量的代码就可以完成测试.我们干嘛不用呢? **单元测试不是用来证明你是对的,而是为了证明你没有错** 虽然上面的测试运行通过了,但是并不代表代码通过单元测试,因为单元测试不是证明你是对的而设计的,我们得想方设法来证明我们的代码没有错. 所有我们得考虑到所有得情况来证明我们得代码没有错误: **上一个测试的补充:** 1. 测试 null 时的处理情况 2. 测试空字符串的处理情况 3. 测试单首字母大写时的情况 4. 测试多个相连字母大写时的情况 完成测试代码: ```java public class WordDeanUtilTest { @Before public void setUp() throws Exception { System.out.println("测试开始"); } @After public void tearDown() throws Exception { System.out.println("测试结束"); } @Test public void testWordFormat4DB() { String target = "employeeInfo"; String reslut = WordDeanUtil.wordFormat4DB(target); assertEquals("employee_info", reslut); } //测试null时的处理情况 @Test public void wordFormat4DBNull(){ String target = null; String reslut = WordDeanUtil.wordFormat4DB(target); assertNull(reslut); } //测试空字符串的处理情况 @Test public void wordFormat4DBEmpty(){ String target = ""; String reslut = WordDeanUtil.wordFormat4DB(target); assertEquals("", reslut); } //测试当首字母大写时的处理情况 @Test public void wordFormat4DBBegin() { String target = "EmployeeInfo"; String reslut = WordDeanUtil.wordFormat4DB(target); assertEquals("employee_info", reslut); } //测试尾字母大写时的处理情况 @Test public void wordFormat4DBEnd() { String target = "employeeInfoA"; String reslut = WordDeanUtil.wordFormat4DB(target); assertEquals("employee_info_a", reslut); } //测试多个项链字母大写时的处理情况 @Test public void wordFormat4DBTogether() { String target = "employeeAInfo"; String reslut = WordDeanUtil.wordFormat4DB(target); assertEquals("employee_a_info", reslut); } } ``` 再次运行上面的测试代码时,你会发现测试未通过 ![20241229154732_N6WdzrGS.webp](https://cdn.dong4j.site/source/image/20241229154732_N6WdzrGS.webp) ![20241229154732_YxaXWhDE.webp](https://cdn.dong4j.site/source/image/20241229154732_YxaXWhDE.webp) 有一个空指针异常,由此可见我们的 wordFormat4DB() 方法没有对 null 做出处理 还有一个处理结果和我们预期的不一样,这就是一个 bug,被 Junit 找出来了 修改被测试的代码: ```java public class WordDeanUtil { /** * 将Java对象名称(每个单词的头字符大写)按照 * 数据库命名的习惯进行格式化 * 格式化后的数据为小写字母,并且使用下划线分割命名单词 * 例如:employeeInfo-->employee_info * * @param name Java对象名称 */ public static String wordFormat4DB(String name){ //增加null验证 if(name == null){ return null; } //使用给定的正则表达式创建Pattern对象(将给定的正则表达式编译到模式中。) Pattern p = Pattern.compile("[A-Z]"); //创建 字符串和模式匹配的匹配器 Matcher m = p.matcher(name); StringBuffer sb = new StringBuffer(); //拿着name中的字符一个一个的去和正则表达式对比,成功返回true while(m.find()){ //增加首字母大写验证 if(m.start() != 0){ m.appendReplacement(sb, ("_" + m.group()).toLowerCase()); } } return m.appendTail(sb).toString().toLowerCase(); } } ``` 再次运行,测试通过~~~~ --- ## Junit 深入理解 ### Fixture 当编写测试方法时,必须先初始化数据,每个测试方法都需要这么做,就会造成重复的代码,所以 Junit 提出了 Fixture 解决方案 Fixture: 整治执行一个或者多个测试方法时需要的一系列公共资源或者数据. 意思就是初始化多个测试方法都需要使用到的数据 设置 Fixture: 1. 使用注解 org.junit.Before 修饰用于初始化的方法 2. 使用注解 org.junit.After 修饰用于注销的方法 3. 保证这两种方法都是用 public void 修饰,而且不能带任何参数 方法级别的 Fixture 设置方法: ```java //初始化方法 @Before public void init(){...} //注销方法 @After public void destroy(){...} ``` 在**每个**测试方法执行之前,都会执行 init 方法; 测试方法执行完毕之后,都会执行 destroy() 方法; 这种方式保证了各个独立测试之间互不干扰,一面其他测试代码修改测试环境或者测试数据影响到其他测试代码的准确性 方法级别 Fixture 执行示意图 ![20241229154732_4JtLsecj.webp](https://cdn.dong4j.site/source/image/20241229154732_4JtLsecj.webp) 下面是具体的测试结果: ![20241229154732_7WIqoyMi.webp](https://cdn.dong4j.site/source/image/20241229154732_7WIqoyMi.webp) 跟描述的一样~~~~ 但是这种方式效率低下,每个测试方法都要初始化一次,关闭一次,对数据库连接来说是一场噩梦; 而且对于不会发生变化的测试环境或者测试数据来说,是不会影响到执行结果的,页就没必要每次都初始化和销毁; 因此 Junit4 引入了 **级别的 Fixture 设置方法:** 1. 使用注解 org.junit.BeforeClass 修饰用于初始化的方法 2. 使用注解 org.junit.AfterClass 修饰用于注销的方法 3. 保证这两种方法都是用 public static void 修饰,而且不能带任何参数 类级别的 Fixture 仅会在测试类中所有测试方法执行之前执行初始化,并且在全部测试方法测试完毕后执行注销方法 ```java @BeforeClass public void static init(){...} @AfterClass public void static destroy(){...} ``` 下面是具体的测试结果: ![20241229154732_SO1IaVeg.webp](https://cdn.dong4j.site/source/image/20241229154732_SO1IaVeg.webp) ### 异常和时间测试 注解 org.junit.Test 中有两个非常有用的参数: expected 和 timeout。 #### expected 代表测试方法期望抛出指定的异常,如果运行测试并没有抛出这个异常,则 JUnit 会认为这个测试没有通过。这为验 证被测试方法在错误的情况下是否会抛出预定的异常提供了便利。 举例来说,方法 supportDBChecker 用于检查用户使用的数据库版本是否在系统的支持的范围之内,如果用户使用了不被支持的数据库版本, 则会抛出运行时异常 UnsupportedDBVersionException。测试方法 supportDBChecker 在数据库版 本不支持时是否会抛出指定异常的单元测试方法大体如下: ```java @Test(expected=UnsupportedDBVersionException.class) public void unsupportedDBCheck(){ …… } ``` #### timeout 指定被测试方法被允许运行的最长时间应该是多少,如果 测试方法运行时间超过了指定的毫秒数,则 JUnit 认为测试失败。这个参数对于性能测试有一定的帮助。 例如,如果解析一份自定义的 XML 文档花费了多于 1 秒的时间,就需要重新考虑 XML 结构的设计, 那单元测试方法可以这样来写: ```java @Test(timeout=1000) public void selfXMLReader(){ …… } ``` ### 忽略测试方法 JUnit 提供注解 org.junit.Ignore 用于暂时忽略某个测试方法,因为有时候由于测试环境受限,并不能 保证每一个测试方法都能正确运行。例如下面的代码便表示由于没有了数据库链接,提示 JUnit 忽略测试方法 unsupportedDBCheck: ```java @Ignore(“db is down”) @Test(expected=UnsupportedDBVersionException.class) public void unsupportedDBCheck(){ …… } ``` 但是一定要小心。注解 org.junit.Ignore 只能用于暂时的忽略测试,如果需要永远忽略这些测试,一定 要确认被测试代码不再需要这些测试方法,以免忽略必要的测试点。 ### 测试套件 在实际开发中,单元测试类会越来越多,这个时候我们再一个一个的运行测试类就悲剧了. 所幸的是 Junit 为我们提供了一种批量运行测试类的方法,叫测试套件. 写法: 1. 创建一个空类作为测试套件的入口 2. 使用注解 org.junit.ranner.RunWith 和 org.junit.runners.Suite.SuiteClasses 修饰这个空类 3. 将 org.junit.runners.Suite 作为参数传入注解 RunWith,以提示 Junit 为此类使用套件运行期执行 4. 将需要放入此测试套件的测试类组成数组作为注解 SuiteClasses 的参数 5. 保证这个空类使用 public 修饰,而且存在公开的不带任何参数的构造函数 ## JUnit 和 Ant ant 提供了两个 target : junit 和 junitreport 运行所有测试用例,并生成 html 格式的报表 具体操作如下: 1.将 junit.jar 放在 ANT_HOMElib 目录下 2.修改 build.xml ,加入如下 内容: -------------- One or more tests failed, check the report for detail… ----------------------------- 运行 这个 target ,ant 会运行每个 TestCase,在 report 目录下就有了 很多 `TEST*.xml` 和 一些网页打开 report 目录下的 index.html 就可以看到很直观的测试运行报告,一目了然。 在 Eclipse 中开发、运行 JUnit 测试相当简单。因为 Eclipse 本身集成了 JUnit 相关组件,并对 JUnit 的运行提供了无缝的支持。 ## 总结 下面是一些具体的编写测试代码的技巧或较好的实践方法: 1. 不要用 TestCase 的构造函数初始化 Fixture,而要用 setUp() 和 tearDown() 方法。 2. 不要依赖或假定测试运行的顺序,因为 JUnit 利用 Vector 保存测试方法。所以不同的平台会按不同的顺序从 Vector 中取出测试方法。 3. 避免编写有副作用的 TestCase。例如:如果随后的测试依赖于某些特定的交易数据,就不要提交交易数据。简单的回滚就可以了。 4. 当继承一个测试类时,记得调用父类的 setUp() 和 tearDown() 方法。 5. 将测试代码和工作代码放在一起,一边同步编译和更新。(使用 Ant 中有支持 junit 的 task.) 6. 测试类和测试方法应该有一致的命名方案。如在工作类名前加上 test 从而形成测试类名。 7. 确保测试与时间无关,不要依赖使用过期的数据进行测试。导致在随后的维护过程中很难重现测试。 8. 如果你编写的软件面向国际市场,编写测试时要考虑国际化的因素。不要仅用母语的 Locale 进行测试。 9. 尽可能地利用 JUnit 提供地 assert/fail 方法以及异常处理的方法,可以使代码更为简洁。 10. 测试要尽可能地小,执行速度快。 11. 不要硬性规定数据文件的路径。 12. 利用 Junit 的自动异常处理书写简洁的测试代码 事实上在 Junit 中使用 try-catch 来捕获异常是没有必要的,Junit 会自动捕获异常。那些没有被捕获的异常就被当成错误处理。 13. 充分利用 Junit 的 assert/fail 方法 - assertSame() 用来测试两个引用是否指向同一个对象 - assertEquals() 用来测试两个对象是否相等 14. 确保测试代码与时间无关 15. 使用文档生成器做测试文档。 ### junit3.x 1. 使用 junit3.x 版本进行单元测试时,测试类必须要继承于 TestCase 父类; 2. 测试方法需要遵循的原则: - public 的 - void 的 - 无方法参数 - 方法名称必须以 test 开头 3. 不同的 Test Case 之间一定要保持完全的独立性,不能有任何的关联。 4. 我们要掌握好测试方法的顺序,不能依赖于测试方法自己的执行顺序。 demo: ```java public class TestMyNumber extends TestCase { private MyNumber myNumber; public TestMyNumber(String name) { super(name); } // 在每个测试方法执行 [之前] 都会被调用 @Override public void setUp() throws Exception { // System.out.println("欢迎使用Junit进行单元测试…"); myNumber = new MyNumber(); } // 在每个测试方法执行 [之后] 都会被调用 @Override public void tearDown() throws Exception { // System.out.println("Junit单元测试结束…"); } public void testDivideByZero() { Throwable te = null; try { myNumber.divide(6, 0); Assert.fail("测试失败"); } catch (Exception e) { e.printStackTrace(); te = e; } Assert.assertEquals(Exception.class, te.getClass()); Assert.assertEquals("除数不能为 0 ", te.getMessage()); } } ``` ### junit4.x 1. 使用 junit4.x 版本进行单元测试时,不用测试类继承 TestCase 父类,因为,junit4.x 全面引入了 Annotation 来执行我们编写的测试。[3] 2. junit4.x 版本,引用了注解的方式,进行单元测试; 3. junit4.x 版本我们常用的注解: - @Before 注解:与 junit3.x 中的 setUp() 方法功能一样,在每个测试方法之前执行; - @After 注解:与 junit3.x 中的 tearDown() 方法功能一样,在每个测试方法之后执行; - @BeforeClass 注解:在所有方法执行之前执行; - @AfterClass 注解:在所有方法执行之后执行; - @Test(timeout = xxx) 注解:设置当前测试方法在一定时间内运行完,否则返回错误; - @Test(expected = Exception.class) 注解:设置被测试的方法是否有异常抛出。抛出异常类型为:Exception.class; - @Ignore 注解:注释掉一个测试方法或一个类,被注释的方法或类,不会被执行。 demo: ```java public class TestMyNumber { private MyNumber myNumber; @BeforeClass // 在所有方法执行之前执行 public static void globalInit() { System.out.println("init all method..."); } @AfterClass // 在所有方法执行之后执行 public static void globalDestory() { System.out.println("destory all method..."); } @Before // 在每个测试方法之前执行 public void setUp() { System.out.println("start setUp method"); myNumber = new MyNumber(); } @After // 在每个测试方法之后执行 public void tearDown() { System.out.println("end tearDown method"); } @Test(timeout=600)// 设置限定测试方法的运行时间 如果超出则返回错误 public void testAdd() { System.out.println("testAdd method"); int result = myNumber.add(2, 3); assertEquals(5, result); } @Test public void testSubtract() { System.out.println("testSubtract method"); int result = myNumber.subtract(1, 2); assertEquals(-1, result); } @Test public void testMultiply() { System.out.println("testMultiply method"); int result = myNumber.multiply(2, 3); assertEquals(6, result); } @Test public void testDivide() { System.out.println("testDivide method"); int result = 0; try { result = myNumber.divide(6, 2); } catch (Exception e) { fail(); } assertEquals(3, result); } @Test(expected = Exception.class) public void testDivide2() throws Exception { System.out.println("testDivide2 method"); myNumber.divide(6, 0); fail("test Error"); } public static void main(String[] args) { } } ``` ## [Maven 命令精讲:从入门到精通](https://blog.dong4j.site/posts/4f5fa1df.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## Maven 添加 IntelliJ IDEA 项目文件 在使用 Maven 构建 Java 项目时,若需生成与 IntelliJ IDEA 兼容的项目文件,可以通过以下命令进行: ```bash mvn idea:idea ``` **`idea:idea`** 插件的目标执行了另外三个目标:project、module 和 workspace。 - **`idea:project`**: 用于生成 IntelliJ IDEA 项目的配置文件(`*.ipr`)。 - **`idea:module`**: 用于生成 IntelliJ IDEA 模块的配置文件(`*.iml`)。 - **`idea:workspace`**: 用于生成工作区文件(`*.iws`),此目标在大多数情况下不会直接使用,因为默认会包含在 `idea:idea` 中。 ## 删除指定依赖 如果你想从本地仓库中删除特定版本的依赖,可以使用以下命令: ```bash mvn com.xxx:xxx-assist-maven-plugin:2.0.0-SNAPSHOT:delete-v5-dependence -Dversion=1.7.1 ``` 或者首先下载指定的依赖项,然后删除它: ```bash mvn dependency:get -Dartifact=com.xxx:xxx-assist-maven-plugin:2.0.0-SNAPSHOT mvn com.xxx:xxx-assist-maven-plugin:2.0.0-SNAPSHOT:delete-v5-dependence -Dversion=1.7.1 # 对于另一个版本的依赖项也适用: mvn dependency:get -Dartifact=com.xxx:xxx-assist-maven-plugin:1.8.0-SNAPSHOT mvn com.xxx:xxx-assist-maven-plugin:1.8.0-SNAPSHOT:delete-v5-dependence -Dversion=1.7.1 ``` ## 设置日志级别 如果你想调整 Maven 的日志输出级别,可以在执行 Maven 命令时添加 `-Dorg.slf4j.simpleLogger.defaultLogLevel` 参数。例如: ```bash mvn clean install -Dorg.slf4j.simpleLogger.defaultLogLevel=warn ``` 若要永久设置日志级别,编辑 Maven 执行脚本(通常是 `${MAVEN_HOME}/bin/mvn`),并新增以下配置行: ```shell MAVEN_OPTS="-Dorg.slf4j.simpleLogger.defaultLogLevel=info" ``` 或者在执行 `mvn` 命令时使用参数 `-q`, 但这样只会输出错误信息。 ## Maven 参数说明 Maven 提供了丰富的命令行参数,以支持不同的构建需求: - **指定 settings 文件**: ```bash mvn install --settings xxx.xml ``` 上述命令中 `xxx.xml` 是自定义的 settings 配置文件路径。 - **指定 pom.xml 文件**: ```bash mvn install --file pom.xml ``` ## 不改变原有依赖,引入新依赖 有时需要在不修改现有项目配置的基础上新增一个依赖,可以通过以下示例 XML 结构来实现: ```xml 4.0.0 org.mapstruct mapstruct-parent 1.1.0.RC1 ../parent/pom.xml mapstruct-jdk7 MapStruct Core JDK 7 已弃用的 MapStruct 艺术品,包含用于 JDK 8 及更高版本的注释 - 移至 mapstruct 中。 mapstruct ``` ## 依赖的版本范围说明 在 Maven 中,可以通过使用特定的符号来指定依赖项的版本范围。以下是一些常见的版本范围及其含义: - `[1.0]`:表示精确匹配 `1.0` 版本。 - `[1.0,)` 或者 `[1.0,`:表示从 1.0 及其之后的所有版本,即 >= 1.0。 - `(1.0,)` 或者 `(1.0,` : 表示从大于 1.0 的所有版本。 例如: ```xml org.twitter4j twitter4j-core [2.2,) org.twitter4j twitter4j-stream [2.2,) ``` 上述依赖项表示我们使用 `twitter4j` 的核心库和流媒体库的版本均必须大于等于 2.2。 ## Maven 帮助命令 ### 检查 Maven 配置 - **查看当前 Maven 环境启用的文件**: ```bash mvn help:effective-settings ``` - **查看当前项目的 pom.xml 配置,包括所有依赖项**: ```bash mvn help:effective-pom ``` - **查看当前处于激活状态的 profile**: ```bash mvn help:active-profiles ``` ### 执行 Maven 命令时指定配置文件 若你有一个自定义的 `settings.xml` 文件,可以使用以下命令来执行带有特定设置的 Maven 目标: ```bash mvn -s ``` 例如: ```bash mvn -s ~/.m2/settings_local.xml clean deploy ``` ### 查看环境详细信息 - **查看当前项目的所有 mvn 配置**: ```bash mvn -X ``` - **打印所有可用的环境变量和 Java 系统属性**: ```bash mvn help:system ``` ## 依赖分析与树状展示 为了更好地理解项目的依赖关系,可以使用以下命令来生成依赖项树: ```bash mvn -X dependency:tree>tree.txt ``` 这将输出项目的所有依赖项并将其保存到 `tree.txt` 文件中。 ## Maven 环境变量引用 ### settings.xml 属性 Maven 支持通过 `settings.xml` 文件中的属性来引用设置值,例如: ```xml ${settings.localRepository} ``` 表示本地仓库的地址。 ### Java 系统属性 所有 Java 系统属性都可以在 Maven 中使用如下语法引用: - 使用 `mvn help:system` 命令查看所有的 Java 系统属性。 - 通过 `System.getProperties()` 方法获取所有 Java 属性。 例如: ```xml ${user.home} ``` 表示用户目录。 ### 环境变量 可以通过以 `env.` 开头的属性引用环境变量。同样地,可以使用以下命令查看所有可用的环境变量: ```bash mvn help:system ``` 如: ```xml ${env.JAVA_HOME} ``` 代表 JAVA_HOME 环境变量值。 ## 解决缓存依赖问题 Maven 有时会将远程依赖项缓存在本地仓库中,这可能导致无法正确更新依赖。为了解决这个问题,可以采取以下措施: - **删除缓存目录**: 删除 `~/.m2/repository/` 目录或其子目录下的所有 `.lastUpdated` 文件。 - **强制更新快照版本插件或依赖项**: 在执行 Maven 命令时加上 `-U` 参数,如: ```bash mvn package -U ``` 此参数会强制从远程仓库中获取最新版本。 - **调整 updatePolicy 属性**: 可以在 `repository` 的配置中新增或修改 `updatePolicy` 属性值来改变更新策略。例如,设置为 `always`、`daily`(默认)、`interval:XXX` 或 `never`。 ## Maven 命令下载 jar 使用以下命令可以从远程仓库下载特定的 Jar 文件: ```bash mvn dependency:get -DremoteRepositories=http://repo1.maven.org/maven2/ -DgroupId=org.benf -DartifactId=cfr -Dversion=0.139 ``` ### Maven 依赖配置示例 ```xml org.benf cfr 0.139 ``` ## 安装第三方 jar 包到本地仓库 ### 命令安装 将第三方 Jar 包添加至本地 Maven 仓库,可以使用以下命令: ```bash mvn install:install-file -Dfile=jar包的位置 -DgroupId=上面的groupId -DartifactId=上面的artifactId -Dversion=上面的version -Dpackaging=jar # 示例安装 Java Memcached Jar 包: mvn install:install-file -Dfile=F:\comm\youboy\java_memcached-release_2.6.6\java_memcached-release_2.6.6.jar -DpomFile=F:\comm\youboy\java_memcached-release_2.6.6\pom.xml ``` ### 安装到私有服务器(私服) 若需将 Jar 包部署至内部的 Maven 仓库,可使用以下命令: ```bash mvn deploy:deploy-file -DgroupId=com.xy.oracle -DartifactId=ojdbc14 -Dversion=10.2.0.4.0 -Dpackaging=jar -Dfile=E:\ojdbc14.jar -Durl=http://localhost:9090/nexus-2.2-01/content/repositories/thirdparty/ -DrepositoryId=thirdparty ``` 其中: - `groupId` 和 `artifactId` 构成该 Jar 包在 pom.xml 文件中的唯一标识。 - `file` 参数表示需要上传的 Jar 包的绝对路径。 - `url` 为私有仓库的位置,可在 Nexus 界面中找到相关路径配置。 - `repositoryId` 是服务器 ID,在 Nexus 配置中可以看到。 ### 快速安装或略过测试 1. 使用命令跳过测试: ```bash mvn install -Dmaven.test.skip=true ``` 2. 在 pom.xml 文件中通过 `` 标签指定是否进行测试编译,如: ```xml true ``` 或 ```xml true ``` 3. 使用插件配置跳过测试: ```xml org.apache.maven.plugins maven-surefire-plugin 2.18.1 true ``` ## Maven 依赖分析 - 查看项目已解析的依赖: ```bash mvn dependency:list ``` 这会输出所有经过 Maven 解析后的依赖列表。 - 输出项目的完整依赖树至文件 `tree.txt` 中: ```bash mvn dependency:tree > tree.txt ``` - 分析依赖项并显示未声明但已使用的依赖或已声明但未使用的依赖: ```bash mvn dependency:analyze ``` 该命令的输出通常会分为两部分: 1. **Used undeclared dependencies**: 表示项目中使用到却未在 pom.xml 中声明的依赖。 2. **Unused declared dependencies**: 表示项目中虽声明但并未使用的依赖。 - 分析冲突的 jar 包路径: ```bash mvn dependency:tree -Dverbose -Dincludes=commons-logging:commons-loggging ``` 此命令将输出所有涉及特定依赖包(如 `commons-logging`)的完整依赖路径,这对于解决版本冲突特别有用。 ### Maven 使用 systemPath 警告 当使用 `` 时会收到警告:“jar should not point at files within the project directory”。为避免这种警告及潜在问题,应考虑将第三方 jar 包部署到公司仓库或安装到本地仓库。 ## dependencies 和 dependencyManagement 的区别 在顶层 pom 中定义的 `` 和 `` 是两个不同的概念: - **``**:直接管理项目的依赖项。 - **``**:主要用来统一管理子模块中不同版本的问题,指定子模块所使用的依赖项的具体版本号。 ### Maven Scope 解释 在 Maven 中,通过 `scope` 属性来控制依赖的范围。以下是常用的几种类型及其使用场景: 1. **compile**: 默认情况下编译、测试和运行时都可见。 2. **provided**: 容器已提供,适用于如 servlet-api 等容器内建组件,在打包时不会包含这些 jar 包。 3. **runtime**: 运行时需要的依赖项在编译阶段不需要,但打包时要包括进去(例如 JDBC 驱动)。 4. **test**: 测试场景使用,只用于测试代码,并不在最终的构建输出中。 5. **system**: 类似于 provided,但系统范围的依赖必须通过 `` 明确指定本地路径。由于这种机制不推荐使用,因此通常建议将库添加到本地或组织级的 Maven 仓库。 ### 实践心得 - `provided` 的依赖没有传递性。 - `provided` 具有继承性,在多模块项目中可以统一配置通用的 provided 依赖来简化子项目的 pom.xml 文件内容。 ## 继承 jar 包示例 在父 pom 中定义依赖管理: ```xml junit junit 3.9 test ``` 在子模块 pom.xml 文件中,只需引用: ```xml junit junit ``` ### 添加外部依赖包 ```xml ldapjdk ldapjdk system 1.0 ${basedir}\src\lib\ldapjdk.jar ``` ### 创建 Maven Web 项目 使用以下命令创建一个基本的 web 项目的骨架: ```bash mvn archetype:generate -DgroupId=com.code -DartifactId=spring-web-project -DarchetypeArtifactId=maven-archetype-webapp # 或者,创建一个简单的 Java 应用程序模板: mvn archetype:generate -DgroupId=com.code -DartifactId=redis-java -DarchetypeArtifactId=maven-archetype-helloworld ``` ## [从零开始构建Spring Boot Starter](https://blog.dong4j.site/posts/6961d5da.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) spring boot 之所以能够自动配置 bean,是通过基于条件来配置 Bean 的能力实现的。 常用的条件注解如下 ``` 1. @ConditionalOnBean:当容器里存在指定的Bean的条件下 2. @ConditionalOnClass:当前类路径下存在指定的类的条件下 3. @ConditionalOnExpression:基于SpEL表达式作为判断条件 4. @ConditionalOnJava:基于JVM版本作为判断条件 5. @ConditionalOnJndi:在JNDI存在的条件下查找指定的位置 6. @ConditionalOnMissingBean:当容器里没有指定的Bean的条件下 7. @ConditionalOnMissingClass:当前类路径下没有指定的类的条件下 8. @ConditionalOnNotWebApplication:当前项目不是web项目的条件下 9. @ConditionalOnProperty:指定的属性是否有指定的值的条件下 10. @ConditionalOnResource:类路径下是否有指定的值 11. @ConditionalOnSingleCandidate:指定Bean在容器中只有一个,或者虽然有多个但是指定首选的Bean 12. @ConditionalOnWebApplication:当前项目是Web项目的条件下 ``` 这些注解都组合了@Conditional 注解,只是使用了不同的条件。 接下来我们自己写一个自动配置,并且封装成 starter pom。 首先创建 maven 工程,导入以下依赖: ```xml org.springframework.boot spring-boot-autoconfigure 1.3.8.RELEASE ``` 创建 AuthorProperties 类,作为配置信息的载体 ```java import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix="author")//指定配置内容的前缀 public class AuthorProperties { private static final String NAME = "微儿博客"; private static final int AGE = 18; private String name = NAME;//默认为微儿博客 private int age = AGE;//默认为18 } ``` 创建 AuthorService 类,作为 Bean ```java public class AuthorService { private String name; private int age; } ``` 创建配置类 AuthorServiceAutoConfiguration,负责配置 Bean ```java import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration//声明配置类 @EnableConfigurationProperties(AuthorProperties.class) @ConditionalOnClass(AuthorService.class)//类路径下存在AuthorService类的条件下 @ConditionalOnProperty(prefix="author",value="enabled",matchIfMissing=true)//在前缀为author的配置为enabled的情况下,即author=enabled,没有配置默认为enabled public class AuthorServiceAutoConfiguration { @Autowired private AuthorProperties authorProperties; @Bean @ConditionalOnMissingBean(AuthorService.class)//容器中没有AuthorService的Bean的条件下配置该Bean public AuthorService authorService(){ AuthorService authorService = new AuthorService(); authorService.setName(authorProperties.getName()); authorService.setAge(authorProperties.getAge()); return authorService; } } ``` 若想自动配置生效,需要注册自动配置类。在 src/main/resources 下创建 META-INF/spring.factories 文件,并写入以下内容: ```lua org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.springboot.springboot_starter_hello.AuthorServiceAutoConfiguration ``` 若有多个自动配置类,则用 "," 隔开,此处`\` 是为了换行后仍能读到属性。 以上操作完成后执行 maven install。 然后在 Spring Boot 项目中引入该依赖: ```xml com.springboot springboot-starter-hello 0.0.1-SNAPSHOT ``` 新建 AutoConfig 类: ```java import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.springboot.springboot_starter_hello.AuthorService; @SpringBootApplication @RestController public class AutoConfig { @Autowired private AuthorService authorService; @RequestMapping("/") public String who(){ return authorService.who(); } public static void main(String[] args) { SpringApplication.run(AutoConfig.class, args); } } ``` 运行该类,访问 localhost:8080,出现如下结果:name: 微儿博客,age:18 接下来在 src/main/resources 下新建 application.properties 文件,写入以下内容: ``` author.name=weare author.age=19 ``` 重新运行 AutoConfig 类:name:weare,age:19 ## [数据库相关的零碎记录](https://blog.dong4j.site/posts/166cf996.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 一、SQL Mode 设置 ### 查看当前 SQL 模式 为了查看 MySQL 当前使用的 SQL 模式,请执行以下查询: ```sql SELECT @@sql_mode; ``` ### 修改 SQL 模式 - **会话级别设置** 若要在当前会话中临时修改 SQL 模式,可使用如下命令: ```sql SET sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION'; ``` - **全局设置** 若要永久更改 SQL 模式,需要在 MySQL 的配置文件(如 `my.cnf` 或 `my.ini`)中添加以下内容: ```ini sql-mode = "STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION" ``` ## 二、修改数据库和表的字符编码 ### 修改整个数据库的编码 ```sql ALTER DATABASE nacos_config CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci`; ``` ### 更新指定表的字符集 如果需要更改特定表的字符设置,可使用如下 SQL 语句: ```sql ALTER TABLE config_info CONVERT TO CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci`; -- 其他表类似修改 ``` 请根据实际需求对每张表执行相应的命令。 ## 三、存储表情符号支持 为了正确地显示和保存包含特殊字符(如表情)的数据,确保数据库及其表使用兼容的编码类型。一种方法是手动设置: ```sql ALTER DATABASE database_name CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; ``` 另一种是在连接池配置中指定字符集,例如在 MyBatis 配置文件中添加如下属性: ```java @Bean(name = "dataSource") @Primary public DataSource dataSource() { DruidDataSource dataSource = new DruidDataSource(); //... dataSource.setConnectionInitSqls(Arrays.asList("set names utf8mb4")); } ``` 或直接通过 SQL 语句设置字符集: ```sql SET NAMES 'utf8mb4'; ``` 在 MyBatis 映射文件中可以使用 @Update 注解来执行此操作: ```xml set names utf8mb4 ``` ## 四、外网访问 MySQL 服务器 为允许从外部网络访问数据库,需更改 MySQL 用户的 `host` 属性并调整防火墙设置: 1. 登录到 MySQL 并更新用户表: ```sql mysql -u root -p1234; use mysql; update user set host='%' where user='root'; ``` 然后刷新权限设置以使更改生效: ```sql FLUSH PRIVILEGES; ``` 2. 编辑配置文件 `mysqld.cnf` 并注释掉或删除 `bind-address = 127.0.0.1` 行。 3. 确保 MySQL 的端口(默认为 3306)已开放在防火墙中: ```bash sudo ufw allow 3306/tcp ``` ## 五、Oracle 和 MySQL 批量处理示例 ### Oracle 中的批量插入和更新语句 #### 批量插入示例: ```xml INSERT ALL INTO USERINFO(userid,username) VALUES(#{userList.userid},#{userList.username}) SELECT 1 FROM DUAL ``` #### 批量更新示例: ```xml UPDATE USERINFO T SET T.USERID = #{userlist.userid,jdbcType=VARCHAR}, T.USERNAME = #{userlist.username,jdbcType=VARCHAR} WHERE T.USERID = #{userlist.userid,jdbcType=VARCHAR} ``` ### MySQL 中的批量处理语句 #### 插入示例: ```xml MERGE INTO RES_SCHOOL_CLUB t USING ( select #{item.id,jdbcType=VARCHAR} ID, #{item.clsSchoolId,jdbcType=VARCHAR} CLS_SCHOOL_ID, #{item.originSchoolId,jdbcType=VARCHAR} ORIGIN_SCHOOL_ID, #{item.resourceId,jdbcType=VARCHAR} RESOURCE_ID, #{item.clsClubId,jdbcType=VARCHAR} CLS_CLUB_ID, #{item.baseAreaId,jdbcType=VARCHAR} BASE_AREA_ID, #{item.createTime,jdbcType=TIMESTAMP} CREATE_TIME from dual ) t1 ON (t.CLS_SCHOOL_ID = t1.CLS_SCHOOL_ID AND t.RESOURCE_ID = t1.RESOURCE_ID) WHEN MATCHED THEN UPDATE SET t.CREATE_TIME = t1.CREATE_TIME WHEN NOT MATCHED THEN INSERT( ID, CLS_SCHOOL_ID, ORIGIN_SCHOOL_ID, RESOURCE_ID, CLS_CLUB_ID, BASE_AREA_ID, CREATE_TIME ) VALUES ( t1.ID, t1.CLS_SCHOOL_ID, t1.ORIGIN_SCHOOL_ID, t1.RESOURCE_ID, t1.CLS_CLUB_ID, t1.BASE_AREA_ID, t1.CREATE_TIME ) ``` #### 更新示例: ```xml update la_t_advfinished t1 set t1.list_stat='07', t1.modify_time=systimestamp where t1.id in( '${id}' ) ``` #### 删除示例: ```xml delete from ATTRACTIONS WHERE id IN ( #{item.id} ) ``` ## Oracle 数据库中的主键自增实现 在 Oracle 数据库中,可以使用序列和触发器来模拟自动增长的 ID。首先创建一个序列: ```sql CREATE SEQUENCE seq_log_kl_s MINVALUE 1 MAXVALUE 99999999999 START WITH 1 INCREMENT BY 1 CACHE 300 ORDER; ``` 然后通过触发器在插入操作时使用序列值来生成新记录的 ID: ```sql CREATE OR REPLACE TRIGGER trg_seq_log_kl_s BEFORE INSERT ON MS50_LOG.LOG_KNOWLEDGE_STAT FOR EACH ROW BEGIN SELECT seq_log_kl_s.NEXTVAL INTO :NEW.ID FROM DUAL; END; ``` ## 分页计算公式 ### 计算当前页的起始索引: ```java start = (currentPage - 1) * pageSize ``` 其中,`pageSize`为每页显示的数据条数;`currentPage`为要访问的页面编号。 ### 总页数计算方法: 提供 5 种不同的写法来实现总页数的计算功能。假设已知总记录数`totalCount`与每页大小`pageSize`,则: 1. `pageCount = (totalCount + pageSize - 1) / pageSize;` 2. `pageCount = (totalCount - 1) / pageSize + 1;` 3. `pageCount = (int)Math.Ceiling((double)totalCount / pageSize);` 4. `pageCount = totalCount%pageSize == 0 ? totalCount/pageSize : totalCount/pageSize + 1;` ### Oracle 获取前一天和后一天时间: 要获取当前日期的前一天或后一天的时间戳,可使用以下查询语句: - **前一天开始时刻:** ```sql SELECT to_date(to_char(TRUNC(SYSDATE - 1), 'yyyy-mm-dd') || '00:00:00', 'yyyy-mm-dd hh24:mi:ss') FROM DUAL; ``` - **后一天最后时刻:** ```sql SELECT to_date(to_char(TRUNC(SYSDATE + 1) - 1/86400, 'yyyy-mm-dd') || '23:59:59', 'yyyy-mm-dd hh24:mi:ss') FROM DUAL; ``` ## 解决 Oracle 驱动程序已过时问题 当遇到`oracle.jdbc.driver.OracleDriver is deprecated.`警告时,需要将`DriverClassName`由`oracle.jdbc.driver.OracleDriver`更新为`oracle.jdbc.OracleDriver`。例如: ``` jdbc:mysql://localhost:3306/testdb?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=UTC ``` ## MySQL 数据库中的批量更新方法 除了常规的 MyBatis 映射语句外,还可以使用`REPLACE INTO`、`INSERT ... ON DUPLICATE KEY UPDATE`等方式完成数据集的批量修改任务。这些方法各有优势,适用于不同的应用场景。 - **REPLACE INTO**:替换已存在的记录或插入新行。 ```sql REPLACE INTO test_tbl (id,dr) VALUES (1,'2'),(2,'3'); ``` - **INSERT ... ON DUPLICATE KEY UPDATE**:在遇到重复键时更新现有记录的值,否则插入新数据。 ```sql INSERT INTO test_tbl (id,dr) VALUES (1,'4') ON DUPLICATE KEY UPDATE dr='5'; ``` ## MySQL 字符串函数 在 MySQL 中,提供了多种字符串处理的内置函数来方便用户操作数据。以下介绍几个常用的字符串处理函数: ### 1. substring() 截取字符串 - **功能**:用于从指定位置开始截取一定长度的字符串。 ```sql SELECT SUBSTRING('Hello, world!',3); // 输出 'llo, world!' ``` ### 2. find_in_set(str1, str2) - **描述**: 查找 str1 在用逗号分隔的 str2 中的位置,返回位置索引。 ```sql SELECT FIND_IN_SET('a', 'a,b,c,d'); // 输出 1 ``` ### 3. locate(substr, str) - **功能**:如果字符串包含子串,则返回大于 0 的位置值;否则返回 0。 ```sql UPDATE site SET url = CONCAT('http://',url) WHERE LOCATE('http://', url)=0; ``` 以上代码用于检查 site 表中的 URL 字段是否含有'http://',如果没有则在该字段开头添加"http://"前缀。 ## 查看 MySQL 读取配置文件的顺序 如果想查看 MySQL 服务器启动时使用的配置文件路径列表,可以使用以下命令: ```bash /usr/local/mysql/bin/mysqld --verbose --help | grep -A 1 'Default options' ``` 此命令将列出 MySQL 默认加载的所有选项及其对应的值。 ## MySQL 数据库本地文件位置 MySQL 在 Linux 系统上安装时会在`/usr/local/var/mysql`目录下创建数据库的数据文件,其中: - `.frm` 文件包含表结构定义。 - `.ibd` 文件是实际存储数据的文件。 ### 获取数据库驱动 Class.forName() 在 Java 中使用 JDBC 连接到 MySQL 等数据库时,通常会用到 `Class.forName()` 方法来加载数据库驱动类。此方法用于动态地将指定的类(如 MySQL JDBC 驱动)加载进 Java 虚拟机 (JVM),并执行静态块内的初始化代码。 - **返回值**: - `Class.forName("")` 返回的是指定名称对应的类对象。 - `Class.forName("").newInstance()` 返回一个新的实例,如果该类有公共的无参数构造器。 ### MySQL 驱动实现 MySQL JDBC 驱动通过继承 `NonRegisteringDriver` 类并实现 `java.sql.Driver` 接口来注册自身到 `DriverManager` ,从而可以在应用程序中使用它。 ```java public class Driver extends NonRegisteringDriver implements java.sql.Driver { public Driver() throws SQLException { super(); } static { try { DriverManager.registerDriver(new Driver()); } catch (SQLException var1) { throw new RuntimeException("Can\'t register driver!"); } } } ``` ### Java 调用存储过程 使用 `CallableStatement` 调用数据库中的存储过程,如下代码所示: ```java // 获取 CallableStatement 对象 CallableStatement c = con.prepareCall("{call getCustomerName(?,?)}"); c.setString(1, "1"); // 设置参数 1 的值为字符串 '1' c.registerOutParameter(2, java.sql.Types.VARCHAR); // 注册第二个参数为输出类型 c.execute(); // 执行存储过程 // 获取执行结果的返回值,这里是 VARCHAR 类型 String result = c.getString(2); ``` ### MySQL 日期时间函数 MySQL 提供了多种日期时间函数,用于处理和操作日期和时间。以下是一些常用的日期时间函数: - **NOW()**:返回当前日期和时间。 ```sql SELECT NOW(); // 输出 '2023-10-01 12:34:56' ``` - **CURDATE()**:返回当前日期。 ```sql SELECT CURDATE(); // 输出 '2023-10-01' ``` - **CURTIME()**:返回当前时间。 ```sql ``` ## [Maven基础教程:构建自动化和依赖管理](https://blog.dong4j.site/posts/a6380364.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Apache Maven 是一个项目管理和构建自动化工具,主要用于 Java 项目的构建、依赖管理和文档生成。它通过定义项目的配置文件 pom.xml(Project Object Model),使开发者能够以声明式的方式描述他们的项目,从而简化了构建过程。 Maven 最重要的特性之一是它的插件系统,提供了丰富的功能来满足开发中各种需求,如编译代码、运行测试、打包和部署应用等。此外,它还支持多种项目类型,包括 Java Web 应用、单元测试等,并且可以集成持续集成工具(例如 Jenkins)以实现自动化构建。 ## 安装 Maven ### Linux 系统: 1. 下载最新的 Maven 发行版: ```bash wget https://downloads.apache.org/maven/maven-3/3.3.3/binaries/apache-maven-3.3.3-bin.tar.gz ``` 2. 解压下载的文件: ```bash tar -xzf apache-maven-3.3.3-bin.tar.gz mv apache-maven-3.3.3 /opt/maven ``` 3. 设置环境变量(编辑 `~/.bashrc` 或 `/etc/profile`): ```bash export M2_HOME=/opt/maven export PATH=$M2_HOME/bin:$PATH source ~/.bashrc # 或者 source /etc/profile ``` 4. 验证 Maven 是否安装成功: ```bash mvn -v ``` ### Windows 系统: 1. 访问官网下载页面并下载最新版的 Maven。 2. 解压下载文件到一个目录,例如 `C:\Program Files\Apache Software Foundation\apache-maven-3.3.3`. 3. 将解压缩后的 Maven bin 目录添加到系统环境变量 Path 中,确保命令行可以识别 mvn 指令。 - 右键点击“此电脑”或“计算机”,选择属性 -> 高级系统设置 -> 环境变量 - 在用户变量或系统变量的 Path 中添加 `C:\Program Files\Apache Software Foundation\apache-maven-3.3.3\bin` 4. 打开命令提示符并输入: ```bash mvn -v ``` ## Maven 项目结构 一个标准的 Maven 项目包含以下几部分: - **pom.xml**: 项目的配置文件,定义了构建过程中的所有元素。 - **src/main/java**: Java 源代码目录。 - **src/main/resources**: 存放项目的资源文件(如图片、数据库配置文件等)。 - **src/test/java**: 单元测试代码存放位置。 - **src/test/resources**: 测试阶段使用的资源文件。 ## 基本命令 ### 项目构建 ```bash mvn clean install # 清理并安装项目,即编译、运行测试和打包等所有步骤 ``` ### 单独执行某一步骤 - 编译源代码: ```bash mvn compile ``` - 执行单元测试: ```bash mvn test ``` - 生成 jar 文件: ```bash mvn package ``` ## pom.xml 配置 ### 基本元素定义 ```xml 4.0.0 com.example my-project 1.0-SNAPSHOT junit junit 3.12 test org.apache.maven.plugins maven-compiler-plugin 3.3.0 1.7 1.7 ``` ### 插件配置 插件允许自定义 Maven 的构建生命周期,包括编译、测试、打包等。示例中已展示如何配置`maven-compiler-plugin`。 ## 使用仓库管理器 为了提高依赖下载速度,建议设置本地和远程仓库地址: ```xml central https://repo.maven.apache.org/maven2/ nexus http://your-nexus-server/repository/public-releases/ internal.repo Internal Repository http://localhost:8081/nexus/content/repositories/releases/ snapshots-repo Snapshots Repository http://localhost:8081/nexus/content/repositories/snapshots/ ``` ## [让资源关闭更简单:Java 7 try-with-resources](https://blog.dong4j.site/posts/85a56b56.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) [原文链接](http://tutorials.jenkov.com/java-exception-handling/try-with-resources.html "Try-with-resources in Java 7")作者: Jakob Jenkov 译者: fangqiang08(fangqiang08@gmail.com) ## 利用 Try-Catch-Finally 管理资源(旧的代码风格) 在 java7 以前, 程序中使用的资源需要被明确地关闭, 这个体验有点繁琐. 下面的方法读取文件, 然后用 System.out 打印: ```java private static void printFile() throws IOException { InputStream input = null; try { input = new FileInputStream("file.txt"); int data = input.read(); while(data != -1){ System.out.print((char) data); data = input.read(); } } finally { if(input != null){ input.close(); } } } ``` 上面代码中黑体字的程序可能会抛出异常. 正如你所看到的, try 语句块中有 3 个地方能抛出异常, finally 语句块中有一个地方会能出异常. 不论 try 语句块中是否有异常抛出, finally 语句块始终会被执行. 这意味着, 不论 try 语句块中发生什么, InputStream 都会被关闭, 或者说都会试图被关闭. 如果关闭失败, InputStream’s close() 方法也可能会抛出异常. 假设 try 语句块抛出一个异常, 然后 finally 语句块被执行. 同样假设 finally 语句块也抛出了一个异常. 那么哪个异常会根据调用栈往外传播? 即使 try 语句块中抛出的异常与异常传播更相关, 最终还是 finally 语句块中抛出的异常会根据调用栈向外传播. 在 java7 中, 对于上面的例子可以用 try-with-resource 结构这样写: ```java private static void printFileJava7() throws IOException { try(FileInputStream input = new FileInputStream("file.txt")) { int data = input.read(); while(data != -1){ System.out.print((char) data); data = input.read(); } } } ``` 注意方法中的第一行: ```java try(FileInputStream input = new FileInputStream("file.txt")) { ``` 这就是 try-with-resource 结构的用法. FileInputStream 类型变量就在 try 关键字后面的括号中声明. 而且一个 FileInputStream 类型被实例化并被赋给了这个变量. 当 try 语句块运行结束时, FileInputStream 会被自动关闭. 这是因为 FileInputStream 实现了 java 中的 java.lang.AutoCloseable 接口. 所有实现了这个接口的类都可以在 try-with-resources 结构中使用. 当 try-with-resources 结构中抛出一个异常, 同时 FileInputStreami 被关闭时(调用了其 close 方法)也抛出一个异常, try-with-resources 结构中抛出的异常会向外传播, 而 FileInputStreami 被关闭时抛出的异常被抑制了. 这与文章开始处利用旧风格代码的例子(在 finally 语句块中关闭资源)相反. ## 使用多个资源 你可以在块中使用多个资源而且这些资源都能被自动地关闭. 下面是例子: ```java private static void printFileJava7() throws IOException { try( FileInputStream input = new FileInputStream("file.txt"); BufferedInputStream bufferedInput = new BufferedInputStream(input) ) { int data = bufferedInput.read(); while(data != -1){ System.out.print((char) data); data = bufferedInput.read(); } } } ``` 上面的例子在 try 关键字后的括号里创建了两个资源——FileInputStream 和 BufferedInputStream. 当程序运行离开 try 语句块时, 这两个资源都会被自动关闭. 这些资源将按照他们被创建顺序的逆序来关闭. 首先 BufferedInputStream 会被关闭, 然后 FileInputStream 会被关闭. ## 自定义 AutoClosable 实现 这个 try-with-resources 结构里不仅能够操作 java 内置的类. 你也可以在自己的类中实现 java.lang.AutoCloseable 接口, 然后在 try-with-resources 结构里使用这个类. AutoClosable 接口仅仅有一个方法, 接口定义如下: ```java public interface AutoClosable { public void close() throws Exception; } ``` 任何实现了这个接口的方法都可以在 try-with-resources 结构中使用. 下面是一个简单的例子: ```java public class MyAutoClosable implements AutoCloseable { public void doIt() { System.out.println("MyAutoClosable doing it!"); } @Override public void close() throws Exception { System.out.println("MyAutoClosable closed!"); } } ``` doIt() 是方法不是 AutoClosable 接口中的一部分, 之所以实现这个方法是因为我们想要这个类除了关闭方法外还能做点其他事. 下面是 MyAutoClosable 在 try-with-resources 结构中使用的例子: ```java private static void myAutoClosable() throws Exception { try(MyAutoClosable myAutoClosable = new MyAutoClosable()){ myAutoClosable.doIt(); } } ``` 当方法 myAutoClosable.doIt() 被调用时, 下面是打印到 System.out 的输出: ```java MyAutoClosable doing it! MyAutoClosable closed! ``` 通过上面这些你可以看到, 不论 try-catch 中使用的资源是自己创造的还是 java 内置的类型, try-with-resources 都是一个能够确保资源能被正确地关闭的强大方法. ## [自定义Logback输出格式:掌握JSON日志的奥秘](https://blog.dong4j.site/posts/d2b51e2a.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 说到 log4j, 基本人人都知道, 但是 logback, 估计用的人不多, 其实这两个都是 sl4j 的实现, 而且是一个作者写的. logback 比 log4j 更加好用, 而且效率更高. 如何配置 logback. ```xml ch.qos.logback logback-classic 1.1.3 ``` 配置文件: logback.xml ```xml %d{yyyy-MM-dd HH:mm:ss} [%p][%c][%M][%L]-> %m%n ${LOG_HOME}/log.%d{yyyy-MM-dd}(%i).log true 10MB utf-8 %d{yyyy-MM-dd HH:mm:ss} [%p][%c][%M][%L]-> %m%n false false ``` 将这个文件放到资源目录根目录下, 服务器启动时, logback 会根据 logback 这个名称自己去匹配加载 这里如果要输出项目中的 SQL 很简单, 只需要将日志级别改成 debug 就可以了(mybatis 是这样的, 其他的没试过) 今天主要说的是日志格式 `%d{yyyy-MM-dd HH:mm:ss} [%p][%c][%M][%L]-> %m%n` 这里就是配置格式的, 以下是各个参数的说明 | 参数 | 说明 | | :--: | :----------------------------------------------------------------------------------------------------------------------------------------------------- | | %m | 输出代码中指定的消息 | | %p | 输出优先级, 即 DEBUG, INFO, WARN, ERROR, FATAL | | %r | 输出自应用启动到输出该 log 信息耗费的毫秒数 | | %c | 输出所属的类目, 通常就是所在类的全名 | | %t | 输出产生该日志事件的线程名 | | %n | 输出一个回车换行符, Windows 平台为 `\r\n`, Unix 平台为 `\n` | | %d | 输出日志时间点的日期或时间, 默认格式为 ISO8601, 也可以在其后指定格式, 比如: %d{yyy MMM dd HH:mm:ss,SSS}, 输出类似: 2002 年 10 月 18 日 22: 10: 28, 921 | | %l | 输出日志事件的发生位置, 包括类目名、发生的线程, 以及在代码中的行数. 举例: Testlog4.main(TestLog4.java:10) | 这个时候, 日志就会输出 时间, 日志优先级, 类名, 方法名, 行数, 日志内容 基本就是这样的了 ``` 2016-09-02 14:45:53 [DEBUG][com.ulewo.mapper.SignInMapper.selectCount][debug][132]-> ==> Preparing: select count(1) from ulewo_sign_in WHERE sign_date = DATE_FORMAT(?,'%Y-%m-%d') 2016-09-02 14:45:53 [DEBUG][com.ulewo.mapper.SignInMapper.selectCount][debug][132]-> ==> Parameters: 2016-09-02 14:45:53.098(Timestamp) 2016-09-02 14:45:53 [DEBUG][com.ulewo.mapper.SignInMapper.selectCount][debug][132]-> <== Total: 1 ``` 这里输出了 SQL. 为什么要讲输出格式, 本来这样输出挺好的呀, 是的, 现在在我们的生产环境中, 总共有 16 台服务器, 有时候查问题, 要一台台的查, 因为不知道请求到底是大到那个服务器上, 这样查问题非常的痛苦, 于是项目引入了 graylog 一个日志收集工具, 可以将各个服务器的日志收集到一起, 这样查问题就方便多了. 但是 graylog 要求日志必须是 json 格式的, 那么按照我上面格式就无法使用了, 所以要修改日志输出格式. 查了一番资料发现, 只要重写 ClassicConverter 和 PatternLayout 这两个类就可以了 **重新 Converter 类** ```java public class NetbarLogerConvert extends ClassicConverter { long lastTimestamp = -1; String timestampStrCache = null; SimpleDateFormat simpleFormat = null; String businessName = null; static String hostName; static String localIp; static { InetAddress ia = null; try { ia = ia.getLocalHost(); hostName = ia.getHostName(); localIp = ia.getHostAddress(); } catch (Exception e) { // TODO Auto-generated catch block e.printStackTrace(); } } @Override public String convert(ILoggingEvent le) { LogObject log = new LogObject(); log.setBusiness(businessName); log.setIp(localIp); log.setHostName(hostName); log.setTime(getTime(le)); log.setLeave(le.getLevel().toString()); log.setClassName(getFullyQualifiedName(le)); log.setMethodName(getMethodName(le)); log.setLine(getLineNumber(le)); log.setMessage(le.getFormattedMessage()); return JacksonUtil.writJson(log); } public void start() { businessName = getFirstOption(); businessName = businessName == null ? "未设置产品线" : businessName; String datePattern = DateStyle.YYYY_MM_DD_HH_MM_SS.getValue(); try { simpleFormat = new SimpleDateFormat(datePattern); // maximumCacheValidity = // CachedDateFormat.getMaximumCacheValidity(pattern); } catch (IllegalArgumentException e) { addWarn("Could not instantiate SimpleDateFormat with pattern " + datePattern, e); // default to the ISO8601 format simpleFormat = new SimpleDateFormat(CoreConstants.ISO8601_PATTERN); } List optionList = getOptionList(); // if the option list contains a TZ option, then set it. if (optionList != null && optionList.size() > 1) { TimeZone tz = TimeZone.getTimeZone((String) optionList.get(1)); simpleFormat.setTimeZone(tz); } } private String getTime(ILoggingEvent le) { long timestamp = le.getTimeStamp(); synchronized (this) { // if called multiple times within the same millisecond // return cache value if (timestamp == lastTimestamp) { return timestampStrCache; } else { lastTimestamp = timestamp; // SimpleDateFormat is not thread safe. // See also http://jira.qos.ch/browse/LBCLASSIC-36 timestampStrCache = simpleFormat.format(new Date(timestamp)); return timestampStrCache; } } } private String getFullyQualifiedName(ILoggingEvent le) { StackTraceElement[] cda = le.getCallerData(); if (cda != null && cda.length > 0) { return cda[0].getClassName(); } else { return CallerData.NA; } } private String getLineNumber(ILoggingEvent le) { StackTraceElement[] cda = le.getCallerData(); if (cda != null && cda.length > 0) { return Integer.toString(cda[0].getLineNumber()); } else { return CallerData.NA; } } private String getMethodName(ILoggingEvent le) { StackTraceElement[] cda = le.getCallerData(); if (cda != null && cda.length > 0) { return cda[0].getMethodName(); } else { return CallerData.NA; } } public class LogObject { /** * 产品线 */ private String business; /** * 主机名 */ private String hostName; /** * IP */ private String ip; /** * 时间 */ private String time; /** * 日志级别 */ private String leave; /** * 类名 */ private String className; /** * 方法名 */ private String methodName; /** * 行数 */ private String line; /** * 日志内容 */ private String message; public String getTime() { return time; } public void setTime(String time) { this.time = time; } public String getLeave() { return leave; } public void setLeave(String leave) { this.leave = leave; } public String getClassName() { return className; } public void setClassName(String className) { this.className = className; } public String getMethodName() { return methodName; } public void setMethodName(String methodName) { this.methodName = methodName; } public String getLine() { return line; } public void setLine(String line) { this.line = line; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public String getBusiness() { return business; } public void setBusiness(String business) { this.business = business; } public String getIp() { return ip; } public void setIp(String ip) { this.ip = ip; } public String getHostName() { return hostName; } public void setHostName(String hostName) { this.hostName = hostName; } } } ``` **重写 layout 类** ```java public class NetbarLoggerPatternLayout extends PatternLayout { static { defaultConverterMap.put("netbarLoggerPattern", NetbarLogerConvert.class.getName()); } } ``` 这里如何获取 方法名, 行数, 甚至还有其他的一些信息可以参考 logback 这个类: ```java public class PatternLayout extends PatternLayoutBase { public static final Map defaultConverterMap = new HashMap(); static { defaultConverterMap.put("d", DateConverter.class.getName()); defaultConverterMap.put("date", DateConverter.class.getName()); defaultConverterMap.put("r", RelativeTimeConverter.class.getName()); defaultConverterMap.put("relative", RelativeTimeConverter.class.getName()); defaultConverterMap.put("level", LevelConverter.class.getName()); defaultConverterMap.put("le", LevelConverter.class.getName()); defaultConverterMap.put("p", LevelConverter.class.getName()); defaultConverterMap.put("t", ThreadConverter.class.getName()); defaultConverterMap.put("thread", ThreadConverter.class.getName()); defaultConverterMap.put("lo", LoggerConverter.class.getName()); defaultConverterMap.put("logger", LoggerConverter.class.getName()); defaultConverterMap.put("c", LoggerConverter.class.getName()); defaultConverterMap.put("m", MessageConverter.class.getName()); defaultConverterMap.put("msg", MessageConverter.class.getName()); defaultConverterMap.put("message", MessageConverter.class.getName()); defaultConverterMap.put("C", ClassOfCallerConverter.class.getName()); defaultConverterMap.put("class", ClassOfCallerConverter.class.getName()); defaultConverterMap.put("M", MethodOfCallerConverter.class.getName()); defaultConverterMap.put("method", MethodOfCallerConverter.class.getName()); defaultConverterMap.put("L", LineOfCallerConverter.class.getName()); defaultConverterMap.put("line", LineOfCallerConverter.class.getName()); defaultConverterMap.put("F", FileOfCallerConverter.class.getName()); defaultConverterMap.put("file", FileOfCallerConverter.class.getName()); defaultConverterMap.put("X", MDCConverter.class.getName()); defaultConverterMap.put("mdc", MDCConverter.class.getName()); defaultConverterMap.put("ex", ThrowableProxyConverter.class.getName()); defaultConverterMap.put("exception", ThrowableProxyConverter.class .getName()); defaultConverterMap.put("throwable", ThrowableProxyConverter.class .getName()); defaultConverterMap.put("xEx", ExtendedThrowableProxyConverter.class.getName()); defaultConverterMap.put("xException", ExtendedThrowableProxyConverter.class .getName()); defaultConverterMap.put("xThrowable", ExtendedThrowableProxyConverter.class .getName()); defaultConverterMap.put("nopex", NopThrowableInformationConverter.class .getName()); defaultConverterMap.put("nopexception", NopThrowableInformationConverter.class.getName()); defaultConverterMap.put("cn", ContextNameAction.class.getName()); defaultConverterMap.put("contextName", ContextNameConverter.class.getName()); defaultConverterMap.put("caller", CallerDataConverter.class.getName()); defaultConverterMap.put("marker", MarkerConverter.class.getName()); defaultConverterMap.put("property", PropertyConverter.class.getName()); defaultConverterMap.put("n", LineSeparatorConverter.class.getName()); } public PatternLayout() { this.postCompileProcessor = new EnsureExceptionHandling(); } public Map getDefaultConverterMap() { return defaultConverterMap; } public String doLayout(ILoggingEvent event) { if (!isStarted()) { return CoreConstants.EMPTY_STRING; } return writeLoopOnConverters(event); } } ``` 这里有各个参数 convert 的实现, 直接拷贝过来就可以了. 然后 logback 这里的配置修改下 ``` %netbarLoggerPattern{XXX 系统} ``` 上面 layout 的 class 指定为你重写的 class,pattern 中用你自己定义的 pattern 名后面大括号是定义产品线的 这个时候日志就是这样输出的: ``` {"message":"[微信公众帐号][定时刷新 AccessToken 的定时器] redis 中获取的 accessToken 的过期时间: 7200 秒","methodName":"refresh","className":"xxx.xxx.xxx.class","hostName":"pcname","time":"2016-09-02 14:40:00","leave":"INFO","line":"50","business":"xxxx 系统","ip":"192.168.32.115"} ``` 就是一个完整的 json 了. 当然你觉得这样的日志格式, 你看起来还不舒服, 可以自己去定义了. ## [掌握Log4j配置,日志管理更高效!](https://blog.dong4j.site/posts/b533df62.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ```shell ### set log levels ### log4j.rootLogger = debug ,  stdout ,  D ,  E ### 输出到控制台 ### log4j.appender.stdout = org.apache.log4j.ConsoleAppender log4j.appender.stdout.Target = System.out log4j.appender.stdout.layout = org.apache.log4j.PatternLayout log4j.appender.stdout.layout.ConversionPattern =  %d{ABSOLUTE} %5p %c{ 1 }:%L - %m%n ### 输出到日志文件 ### log4j.appender.D = org.apache.log4j.DailyRollingFileAppender log4j.appender.D.File = logs/log.log log4j.appender.D.Append = true log4j.appender.D.Threshold = DEBUG ## 输出DEBUG级别以上的日志 log4j.appender.D.layout = org.apache.log4j.PatternLayout log4j.appender.D.layout.ConversionPattern = %-d{yyyy-MM-dd HH:mm:ss}  [ %t:%r ] - [ %p ]  %m%n ### 保存异常信息到单独文件 ### log4j.appender.D = org.apache.log4j.DailyRollingFileAppender log4j.appender.D.File = logs/error.log ## 异常日志文件名 log4j.appender.D.Append = true log4j.appender.D.Threshold = ERROR ## 只输出ERROR级别以上的日志!!! log4j.appender.D.layout = org.apache.log4j.PatternLayout log4j.appender.D.layout.ConversionPattern = %-d{yyyy-MM-dd HH:mm:ss}  [ %t:%r ] - [ %p ]  %m%n ``` ## 参数意义说明 ### 输出级别的种类 `ERROR、WARN、INFO、DEBUG` 1. ERROR 为严重错误 主要是程序的错误 2. WARN 为一般警告,比如 session 丢失 3. INFO 为一般要显示的信息,比如登录登出 4. DEBUG 为程序的调试信息 ### 配置日志信息输出目的地 `log4j.appender.appenderName = fully.qualified.name.of.appender.class` 1. org.apache.log4j.ConsoleAppender(控制台) 2. org.apache.log4j.FileAppender(文件) 3. org.apache.log4j.DailyRollingFileAppender(每天产生一个日志文件) 4. org.apache.log4j.RollingFileAppender(文件大小到达指定尺寸的时候产生一个新的文件) 5. org.apache.log4j.WriterAppender(将日志信息以流格式发送到任意指定的地方) ### 配置日志信息的格式 `log4j.appender.appenderName.layout =fully.qualified.name.of.layout.class` 1. org.apache.log4j.HTMLLayout(以 HTML 表格形式布局), 2. org.apache.log4j.PatternLayout(可以灵活地指定布局模式), 3. org.apache.log4j.SimpleLayout(包含日志信息的级别和信息字符串), 4. org.apache.log4j.TTCCLayout(包含日志产生的时间、线程、类别等等信息) ### 控制台选项 Threshold=DEBUG: 指定日志消息的输出最低层次。 ImmediateFlush=true: 默认值是 true,意谓着所有的消息都会被立即输出。 Target=System.err: 默认情况下是:System.out,指定输出控制台 #### FileAppender 选项 Threshold=DEBUF: 指定日志消息的输出最低层次。 ImmediateFlush=true: 默认值是 true,意谓着所有的消息都会被立即输出。 File=mylog.txt: 指定消息输出到 mylog.txt 文件。 Append=false: 默认值是 true,即将消息增加到指定文件中,false 指将消息覆盖指定的文件内容。 #### RollingFileAppender 选项 Threshold=DEBUG: 指定日志消息的输出最低层次。 ImmediateFlush=true: 默认值是 true,意谓着所有的消息都会被立即输出。 File=mylog.txt: 指定消息输出到 mylog.txt 文件。 Append=false: 默认值是 true,即将消息增加到指定文件中,false 指将消息覆盖指定的文件内容。 MaxFileSize=100KB: 后缀可以是 KB, MB 或者是 GB. 在日志文件到达该大小时,将会自动滚动,即将原来的内容移到 mylog.log.1 文件。 MaxBackupIndex=2: 指定可以产生的滚动文件的最大数。 `log4j.appender.A1.layout.ConversionPattern=%-4r %-5p %d{yyyy-MM-dd HH:mm:ssS} %c %m%n` ### 日志信息格式中几个符号所代表的含义: -X 号: X 信息输出时左对齐; %p: 输出日志信息优先级,即 DEBUG,INFO,WARN,ERROR,FATAL, %d: 输出日志时间点的日期或时间,默认格式为 ISO8601,也可以在其后指定格式,比如:%d{yyy MMM dd HH:mm:ss,SSS},输出类似:2002 年 10 月 18 日 22:10:28,921 %r: 输出自应用启动到输出该 log 信息耗费的毫秒数 %c: 输出日志信息所属的类目,通常就是所在类的全名 %t: 输出产生该日志事件的线程名 %l: 输出日志事件的发生位置,相当于%C.%M(%F:%L) 的组合,包括类目名、发生的线程,以及在代码中的行数。举例:Testlog4.main (TestLog4.java:10) %x: 输出和当前线程相关联的 NDC(嵌套诊断环境),尤其用到像 java servlets 这样的多客户多线程的应用中。 %%: 输出一个 "%" 字符 %F: 输出日志消息产生时所在的文件名称 %L: 输出代码中的行号 %m: 输出代码中指定的消息,产生的日志具体信息 %n: 输出一个回车换行符,Windows 平台为 "/r/n",Unix 平台为 "/n" 输出日志信息换行 可以在% 与模式字符之间加上修饰符来控制其最小宽度、最大宽度、和文本的对齐方式。如: 1. %20c:指定输出 category 的名称,最小的宽度是 20,如果 category 的名称小于 20 的话,默认的情况下右对齐。 2. %-20c: 指定输出 category 的名称,最小的宽度是 20,如果 category 的名称小于 20 的话,"-" 号指定左对齐。 3. %.30c: 指定输出 category 的名称,最大的宽度是 30,如果 category 的名称大于 30 的话,就会将左边多出的字符截掉,但小于 30 的话也不会有空格。 4. %20.30c: 如果 category 的名称小于 20 就补空格,并且右对齐,如果其名称长于 30 字符,就从左边较远输出的字符截掉。 ## 文件配置 Sample1 ```shell log4j.rootLogger=DEBUG,A1,R # ConsoleAppender 输出 log4j.appender.A1=org.apache.log4j.ConsoleAppender log4j.appender.A1.layout=org.apache.log4j.PatternLayout log4j.appender.A1.layout.ConversionPattern=%-d{yyyy-MM-dd HH:mm:ss,SSS} [%c]-[%p] %m%n # File 输出 一天一个文件,输出路径可以定制,一般在根路径下 log4j.appender.R=org.apache.log4j.DailyRollingFileAppender log4j.appender.R.File=blog_log.txt log4j.appender.R.MaxFileSize=500KB log4j.appender.R.MaxBackupIndex=10 log4j.appender.R.layout=org.apache.log4j.PatternLayout log4j.appender.R.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss,SSS} [%t] [%c] [%p] - %m%n ``` ## 文件配置 Sample2 ```shell 下面给出的Log4J配置文件实现了输出到控制台,文件,回滚文件,发送日志邮件,输出到数据库日志表,自定义标签等全套功能。 log4j.rootLogger=DEBUG,CONSOLE,A1,im #DEBUG,CONSOLE,FILE,ROLLING_FILE,MAIL,DATABASE log4j.addivity.org.apache=true ################### # Console Appender ################### log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender log4j.appender.Threshold=DEBUG log4j.appender.CONSOLE.Target=System.out log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout log4j.appender.CONSOLE.layout.ConversionPattern=[framework] %d - %c -%-4r [%t] %-5p %c %x - %m%n #log4j.appender.CONSOLE.layout.ConversionPattern=[start]%d{DATE}[DATE]%n%p[PRIORITY]%n%x[NDC]%n%t[THREAD] n%c[CATEGORY]%n%m[MESSAGE]%n%n ##################### # File Appender ##################### log4j.appender.FILE=org.apache.log4j.FileAppender log4j.appender.FILE.File=file.log log4j.appender.FILE.Append=false log4j.appender.FILE.layout=org.apache.log4j.PatternLayout log4j.appender.FILE.layout.ConversionPattern=[framework] %d - %c -%-4r [%t] %-5p %c %x - %m%n # Use this layout for LogFactor 5 analysis ######################## # Rolling File ######################## log4j.appender.ROLLING_FILE=org.apache.log4j.RollingFileAppender log4j.appender.ROLLING_FILE.Threshold=ERROR log4j.appender.ROLLING_FILE.File=rolling.log log4j.appender.ROLLING_FILE.Append=true log4j.appender.ROLLING_FILE.MaxFileSize=10KB log4j.appender.ROLLING_FILE.MaxBackupIndex=1 log4j.appender.ROLLING_FILE.layout=org.apache.log4j.PatternLayout log4j.appender.ROLLING_FILE.layout.ConversionPattern=[framework] %d - %c -%-4r [%t] %-5p %c %x - %m%n #################### # Socket Appender #################### log4j.appender.SOCKET=org.apache.log4j.RollingFileAppender log4j.appender.SOCKET.RemoteHost=localhost log4j.appender.SOCKET.Port=5001 log4j.appender.SOCKET.LocationInfo=true # Set up for Log Facter 5 log4j.appender.SOCKET.layout=org.apache.log4j.PatternLayout log4j.appender.SOCET.layout.ConversionPattern=[start]%d{DATE}[DATE]%n%p[PRIORITY]%n%x[NDC]%n%t[THREAD]%n%c[CATEGORY]%n%m[MESSAGE]%n%n ######################## # Log Factor 5 Appender ######################## log4j.appender.LF5_APPENDER=org.apache.log4j.lf5.LF5Appender log4j.appender.LF5_APPENDER.MaxNumberOfRecords=2000 ######################## # SMTP Appender ####################### log4j.appender.MAIL=org.apache.log4j.net.SMTPAppender log4j.appender.MAIL.Threshold=FATAL log4j.appender.MAIL.BufferSize=10 log4j.appender.MAIL.From=chenyl@yeqiangwei.com log4j.appender.MAIL.SMTPHost=mail.hollycrm.com log4j.appender.MAIL.Subject=Log4J Message log4j.appender.MAIL.To=chenyl@yeqiangwei.com log4j.appender.MAIL.layout=org.apache.log4j.PatternLayout log4j.appender.MAIL.layout.ConversionPattern=[framework] %d - %c -%-4r [%t] %-5p %c %x - %m%n ######################## # JDBC Appender ####################### log4j.appender.DATABASE=org.apache.log4j.jdbc.JDBCAppender log4j.appender.DATABASE.URL=jdbc:mysql://localhost:3306/test log4j.appender.DATABASE.driver=com.mysql.jdbc.Driver log4j.appender.DATABASE.user=root log4j.appender.DATABASE.password= log4j.appender.DATABASE.sql=INSERT INTO LOG4J (Message) VALUES ('[framework] %d - %c -%-4r [%t] %-5p %c %x - %m%n') log4j.appender.DATABASE.layout=org.apache.log4j.PatternLayout log4j.appender.DATABASE.layout.ConversionPattern=[framework] %d - %c -%-4r [%t] %-5p %c %x - %m%n log4j.appender.A1=org.apache.log4j.DailyRollingFileAppender log4j.appender.A1.File=SampleMessages.log4j log4j.appender.A1.DatePattern=yyyyMMdd-HH'.log4j' log4j.appender.A1.layout=org.apache.log4j.xml.XMLLayout ################### #自定义Appender ################### log4j.appender.im = net.cybercorlin.util.logger.appender.IMAppender log4j.appender.im.host = mail.cybercorlin.net log4j.appender.im.username = username log4j.appender.im.password = password log4j.appender.im.recipient = corlin@yeqiangwei.com log4j.appender.im.layout=org.apache.log4j.PatternLayout log4j.appender.im.layout.ConversionPattern =[framework] %d - %c -%-4r [%t] %-5p %c %x - %m%n ``` ### 高级使用 实验目的: 1. 把 FATAL 级错误写入 2000NT 日志 2. WARN,ERROR,FATAL 级错误发送 email 通知管理员 3.其他级别的错误直接在后台输出 实验步骤: 输出到 2000NT 日志 1. 把 Log4j 压缩包里的 NTEventLogAppender.dll 拷到 WINNT/SYSTEM32 目录下 2. 写配置文件 log4j.properties ```shell # 在2000系统日志输出 log4j.logger.NTlog=FATAL, A8 # APPENDER A8 log4j.appender.A8=org.apache.log4j.nt.NTEventLogAppender log4j.appender.A8.Source=JavaTest log4j.appender.A8.layout=org.apache.log4j.PatternLayout log4j.appender.A8.layout.ConversionPattern=%-4r %-5p [%t] %37c %3x - %m%n ``` 3.调用代码: ```shell Logger logger2 = Logger.getLogger("NTlog"); //要和配置文件中设置的名字相同 logger2.debug("debug!!!"); logger2.info("info!!!"); logger2.warn("warn!!!"); logger2.error("error!!!"); //只有这个错误才会写入2000日志 logger2.fatal("fatal!!!"); ``` ### 发送 email 通知管理员 ```java // 1. 首先下载JavaMail和JAF, http://java.sun.com/j2ee/ja/javamail/index.html http://java.sun.com/beans/glasgow/jaf.html 在项目中引用mail.jar和activation.jar。 // 2. 写配置文件 # 将日志发送到email log4j.logger.MailLog=WARN,A5 # APPENDER A5 log4j.appender.A5=org.apache.log4j.net.SMTPAppender log4j.appender.A5.BufferSize=5 log4j.appender.A5.To=chunjie@yeqiangwei.com log4j.appender.A5.From=error@yeqiangwei.com log4j.appender.A5.Subject=ErrorLog log4j.appender.A5.SMTPHost=smtp.263.net log4j.appender.A5.layout=org.apache.log4j.PatternLayout log4j.appender.A5.layout.ConversionPattern=%-4r %-5p [%t] %37c %3x - %m%n // 3.调用代码:把日志发送到mail Logger logger3 = Logger.getLogger("MailLog"); logger3.warn("warn!!!"); logger3.error("error!!!"); logger3.fatal("fatal!!!"); ``` ### 在后台输出所有类别的错误 ```java 1. 写配置文件 # 在后台输出 log4j.logger.console=DEBUG, A1 # APPENDER A1 log4j.appender.A1=org.apache.log4j.ConsoleAppender log4j.appender.A1.layout=org.apache.log4j.PatternLayout log4j.appender.A1.layout.ConversionPattern=%-4r %-5p [%t] %37c %3x - %m%n 2.调用代码 Logger logger1 = Logger.getLogger("console"); logger1.debug("debug!!!"); logger1.info("info!!!"); logger1.warn("warn!!!"); logger1.error("error!!!"); logger1.fatal("fatal!!!"); ``` 全部配置文件:log4j.properties ```shell # 在后台输出 log4j.logger.console=DEBUG, A1 # APPENDER A1 log4j.appender.A1=org.apache.log4j.ConsoleAppender log4j.appender.A1.layout=org.apache.log4j.PatternLayout log4j.appender.A1.layout.ConversionPattern=%-4r %-5p [%t] %37c %3x - %m%n # 在2000系统日志输出 log4j.logger.NTlog=FATAL, A8 # APPENDER A8 log4j.appender.A8=org.apache.log4j.nt.NTEventLogAppender log4j.appender.A8.Source=JavaTest log4j.appender.A8.layout=org.apache.log4j.PatternLayout log4j.appender.A8.layout.ConversionPattern=%-4r %-5p [%t] %37c %3x - %m%n # 将日志发送到email log4j.logger.MailLog=WARN,A5 # APPENDER A5 log4j.appender.A5=org.apache.log4j.net.SMTPAppender log4j.appender.A5.BufferSize=5 log4j.appender.A5.To=chunjie@yeqiangwei.com log4j.appender.A5.From=error@yeqiangwei.com log4j.appender.A5.Subject=ErrorLog log4j.appender.A5.SMTPHost=smtp.263.net log4j.appender.A5.layout=org.apache.log4j.PatternLayout log4j.appender.A5.layout.ConversionPattern=%-4r %-5p [%t] %37c %3x - %m%n ``` ### 全部代码:Log4jTest.java ```java public class Log4jTest { public static void main(String args[]) { PropertyConfigurator.configure("log4j.properties"); //在后台输出 Logger logger1 = Logger.getLogger("console"); logger1.debug("debug!!!"); logger1.info("info!!!"); logger1.warn("warn!!!"); logger1.error("error!!!"); logger1.fatal("fatal!!!"); //在NT系统日志输出 Logger logger2 = Logger.getLogger("NTlog"); //NTEventLogAppender nla = new NTEventLogAppender(); logger2.debug("debug!!!"); logger2.info("info!!!"); logger2.warn("warn!!!"); logger2.error("error!!!"); //只有这个错误才会写入2000日志 logger2.fatal("fatal!!!"); //把日志发送到mail Logger logger3 = Logger.getLogger("MailLog"); //SMTPAppender sa = new SMTPAppender(); logger3.warn("warn!!!"); logger3.error("error!!!"); logger3.fatal("fatal!!!"); } } ``` 写在服务器路径的配置如下: ``` log4j.logger.iplog= INFO, infoFile6 log4j.appender.infoFile6=org.apache.log4j.FileAppender log4j.appender.infoFile6.File=${catalina.base}/logs/ipFilter.log log4j.appender.infoFile6.layout=org.apache.log4j.PatternLayout log4j.appender.infoFile6.layout.ConversionPattern=%m %d{yyyy-MM-dd HH:mm:ss} %n log4j.appender.infoFile6.encoding=UTF-8 ``` ## [轻松掌握Java数据库操作:JDBC入门](https://blog.dong4j.site/posts/5a428776.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 在 Java 中,我们可以使用 JDBC(Java Database Connectivity)来连接、查询和操作关系型数据库。以下是一个详细的指南,展示如何进行基本的数据库操作,包括连接到 MySQL 服务器、执行 SQL 语句以及预处理数据。 ## 数据库操作的基本步骤: ### 1. 导入必要的包 ```java import java.sql.*; ``` ### 2. 定义一个主类来初始化和测试数据库操作 #### 基本的数据库连接与查询: 首先,我们定义一些基本的变量用于存储数据库的信息。 ```java public class DataBasePractice { public static void main(String[] args) { Connection con = null; // MySQL驱动程序名 String driver = "com.mysql.jdbc.Driver"; // 数据库URL String url = "jdbc:mysql://localhost:3306/mydata"; // 用户名和密码 String user = "root"; String password = "root"; try { Class.forName(driver); con = DriverManager.getConnection(url, user, password); if (!con.isClosed()) System.out.println("Succeeded connecting to the Database!"); Statement statement = con.createStatement(); // SQL查询语句 ResultSet rs = statement.executeQuery("select * from student"); String name, id; while(rs.next()){ name = rs.getString("stuname").trim(); id = rs.getString("stuid").trim(); // 字符集转换(如果需要的话) name = new String(name.getBytes("ISO-8859-1"), "gb2312"); System.out.println(id + "\t" + name); } } catch (ClassNotFoundException e) { System.out.println("Sorry, can't find the Driver!"); e.printStackTrace(); } catch (SQLException e) { // 数据库连接失败异常处理 e.printStackTrace(); } finally{ try{ if(con != null) con.close(); }catch(SQLException se){ se.printStackTrace(); } System.out.println("数据库数据成功获取!!"); } } } ``` ### 3. 使用`PreparedStatement`进行预处理操作 #### 插入、更新和删除数据: ```java // 假设在主类中已经有了一个Connection con对象。 try { // 插入新记录到student表 PreparedStatement psql = con.prepareStatement("insert into student values(?,?)"); psql.setInt(1, 8); psql.setString(2, "xiaogang"); psql.executeUpdate(); // 更新数据 psql = con.prepareStatement("update student set stuname = ? where stuid = ?"); psql.setString(1,"xiaowang"); psql.setInt(2, 10); psql.executeUpdate(); // 删除记录 psql = con.prepareStatement("delete from student where stuid = ?"); psql.setInt(1, 5); psql.executeUpdate(); System.out.println("执行增加、修改、删除后的数据"); res = psql.executeQuery(); while(res.next()){ name = res.getString("stuname").trim(); id = res.getString("stuid").trim(); // 字符集转换(如果需要的话) name = new String(name.getBytes("ISO-8859-1"), "gb2312"); System.out.println(id + "\t" + name); } } catch (SQLException e) { e.printStackTrace(); } ``` ## 环境准备 ### 数据库配置属性文件`db.properties` 该配置文件中包含必要的数据库连接参数,如驱动名称、URL 地址等。 ```properties # Oracle 连接信息 driver=oracle.jdbc.driver.OracleDriver url=jdbc:oracle:thin:@localhost:1521:vill username=vill password=villvill # MySQL 连接信息(注释状态) # driver=com.mysql.cj.jdbc.Driver # 使用最新MySQL驱动版本 # url=jdbc:mysql://localhost:3306/vill?useSSL=false&serverTimezone=UTC # username=root # password=root ``` ### 实用工具类`DBUtil.java` 该类提供了一个静态的数据库连接获取方法,并在程序结束时关闭所有数据库相关资源(如连接、语句等)。 ```java import java.io.IOException; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.Statement; import java.util.Properties; /** * 数据库操作工具类,封装了创建和释放数据库连接的逻辑。 */ public class DBUtil { // 静态变量用于保存从配置文件读取到的数据 private static String driver, url, user, pwd; // 使用静态块初始化这些变量 static { try { Properties properties = new Properties(); properties.load(DBUtil.class.getClassLoader().getResourceAsStream("vill/util/db.properties")); // 加载资源路径下的属性文件 driver = properties.getProperty("driver"); url = properties.getProperty("url"); user = properties.getProperty("username"); // 读取配置的用户名和密码信息 pwd = properties.getProperty("password"); } catch (IOException e) { e.printStackTrace(); } } /** * 获取与数据库建立的连接。 * * @return 数据库连接对象,如果发生异常,则返回null。 */ public static Connection getConnection() throws Exception{ Class.forName(driver); // 动态加载驱动 return DriverManager.getConnection(url, user, pwd); } /** * 关闭所有与数据库相关的资源:包括结果集、语句和连接等。 * * @param conn 数据库连接对象 * @param stm SQL执行语句对象 * @param rs 查询结果集对象 */ public static void closeConnection(Connection conn, Statement stm, ResultSet rs) { try { if (conn != null) conn.close(); if (stm != null) stm.close(); if (rs != null) rs.close(); } catch (Exception e) { // 捕获异常,防止资源释放过程中出现错误中断程序运行 e.printStackTrace(); } } public static void main(String[] args) { try { System.out.println(new DBUtil().getConnection().getClass().getName()); } catch (Exception e) { e.printStackTrace(); // 输出异常信息以便调试 } } } ``` ### 实现解析 1. **配置文件读取** - 利用`Properties`类从资源路径中加载并解析`.properties`格式的属性文件,从而得到数据库连接所需的驱动和 URL 等参数。 2. **数据库连接获取方法** - `getConnection()`:首先通过反射机制动态地注册数据库驱动(如 Oracle 或 MySQL),然后使用这些配置信息建立与数据库的实际连接。 3. **资源管理** - 为了防止内存泄漏,`closeConnection()`提供了关闭数据库操作过程中可能打开的多种类型对象的功能。这包括但不限于`ResultSet`, `Statement`以及最后是`Connection`本身。 4. **异常处理** - 在`getConnection()`和`closeConnection()`方法中均存在捕获并打印错误信息的机制。这样可以确保即使在资源释放失败的情况下,程序也能继续执行,并且开发者能明确看到问题发生的位置。 ### 注意事项 - 确保你的项目包含了所使用的 JDBC 驱动库(例如 Oracle 或 MySQL 的 JDBC jar 文件)。 - 配置文件`db.properties`应放置于项目的类路径中,以便通过`ClassLoader.getResourceAsStream()`正确加载。 - 建议使用 try-with-resources 语句来简化资源管理和异常处理逻辑。 ## [深入浅出:Java日志系统的演变](https://blog.dong4j.site/posts/10ce2820.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## Logging System [Log4j](http://logging.apache.org/log4j/) 较早出现的比较成功的日志系统是 Log4j. Log4j 开创的日志系统模型(Logger/Appender/Level)行之有效, 并一直延用至今. [JUL](http://download.oracle.com/javase/6/docs/technotes/guides/logging/overview.html) JDK1.4 是第一个自带日志系统的 JDK, 简称(JUL). JUL 并没有明显的优势来战胜 Log4j, 反而造成了标准的混乱 —— 采用不同日志系统的应用程序无法和谐共存. [Logback](http://logback.qos.ch/) 是较新的日志系统. 它是 Log4j 的作者吸取多年的经验教训以后重新做出的一套系统. 它的使用更方便, 功能更强, 而且性能也更高. Logback 不能单独使用, 必须配合日志框架 SLF4J 来使用. ## Logging Framework [JCL (Jakarta Commons Logging)](http://commons.apache.org/logging/) 这是目前最流行的一个日志框架, 由 Apache Jakarta 社区提供. Spring 框架、许多老应用都依赖于 JCL. [SLF4J](http://www.slf4j.org/) 这是一个最新的日志框架, 由 Log4j 的作者推出. SLF4J 提供了新的 API, 特别用来配合 Logback 的新功能. 但 SLF4J 同样兼容 Log4j. (全称是 Simple Loging Facade For Java) 是一个为 Java 程序提供日志输出的统一接口, 并不是一个具体的日志实现方案, 就好像我们经常使用的 JDBC 一样, 只是一种规则而已. 因此单独的 slf4j 是不能工作的, 它必须搭配其他具体的日志实现方案, 比如 apache 的 org.apache.log4j.Logger, jdk 自带的 java.util.logging.Logger 等等. 其中对与 jar 包: 1. slf4j-log4j12-x.x.x.jar 是使用 org.apache.log4j.Logger 提供的驱动 2. slf4j-jdk14-x.x.x.jar 是使用 java.util.logging 提供的驱动 3. slf4j-simple-x.x.x.jar 直接绑定 System.err 4. slf4j-jcl-x.x.x.jar 是使用 commons-logging 提供的驱动 5. logback-classic-x.x.x.jar 是使用 logback 提供的驱动 ## Commons-logging+log4j 经典的一个日志实现方案. 出现在各种框架里. 如 spring 、webx 、ibatis 等等. 直接使用 log4j 即可满足我们的日志方案. 但是一般为了避免直接依赖具体的日志实现, 一般都是结合 commons-logging 来实现. 常见代码如下: ```java import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; private static Log logger = LogFactory.getLog(CommonsLoggingTest.class); ``` 代码上, 没有依赖任何的 log4j 内部的类. 那么 log4j 是如何被装载的? Log 是一个接口声明. LogFactory 的内部会去装载具体的日志系统, 并获得实现该 Log 接口的实现类. 而内部有一个 Log4JLogger 实现类对 Log 接口同时内部提供了对 log4j logger 的代理. LogFactory 内部装载日志系统流程: 1. 首先, 寻找 org.apache.commons.logging.LogFactory 属性配置 2. 否则, 利用 JDK1.3 开始提供的 service 发现机制, 会扫描 classpah 下的 META-INF/services/org.apache.commons.logging.LogFactory 文件, 若找到则装载里面的配置, 使用里面的配置. 3. 否则, 从 Classpath 里寻找 commons-logging.properties , 找到则根据里面的配置加载. 4. 否则, 使用默认的配置: 如果能找到 Log4j 则默认使用 log4j 实现, 如果没有则使用 JDK14Logger 实现, 再没有则使用 commons-logging 内部提供的 SimpleLog 实现. 从上述加载流程来看, 如果没有做任何配置, 只要引入了 log4j 并在 classpath 配置了 log4j.xml , 则 commons-logging 就会使 log4j 使用正常, 而代码里不需要依赖任何 log4j 的代码. ## Commons-logging+log4j+slf4j 如果在原有 commons-logging 系统里, 如果要迁移到 slf4j, 使用 slf4j 替换 commons-logging , 也是可以做到的. 原理使用到了上述 commons-logging 加载的第二点. 需要引入 org.slf4j.jcl-over-slf4j-1.5.6.jar . 这个 jar 包提供了一个桥接, 让底层实现是基于 slf4j . 原理是在该 jar 包里存放了配置 `META-INF/services/org.apache.commons.logging.LogFactory =org.apache.commons.logging.impl.SLF4JLogFactory`, 而 commons-logging 在初始化的时候会找到这个 serviceId , 并把它作为 LogFactory . 完成桥接后, 那么那么简单日志门面 SLF4J 内部又是如何来装载合适的 log 呢? 原理是 SLF4J 会在编译时会绑定 import org.slf4j.impl.StaticLoggerBinder; 该类里面实现对具体日志方案的绑定接入. 任何一种基于 slf4j 的实现都要有一个这个类. 如: org.slf4j.slf4j-log4j12-1.5.6: 提供对 log4j 的一种适配实现. Org.slf4j.slf4j-simple-1.5.6: 是一种 simple 实现, 会将 log 直接打到控制台. …… 那么这个地方就要注意了: 如果有任意两个实现 slf4j 的包同时出现, 那就有可能酿就悲剧, 你可能会发现日志不见了、或都打到控制台了. 原因是这两个 jar 包里都有各自的 org.slf4j.impl.StaticLoggerBinder , 编译时候绑定的是哪个是不确定的. 这个地方要特别注意!!出现过几次因为这个导致日志错乱的问题. ## 简单使用 log4j **Maven 依赖** ```xml log4j log4j 1.2.9 ``` **log4j.properties 配置** ```properties ### set log levels ### log4j.rootLogger = debug , stdout ### 输出到控制台 ### log4j.appender.stdout = org.apache.log4j.ConsoleAppender log4j.appender.stdout.Target = System.out log4j.appender.stdout.layout = org.apache.log4j.PatternLayout log4j.appender.stdout.layout.ConversionPattern = %-d{yyyy-MM-dd HH:mm:ss} [%t:%r] - [%p] %m%n ### 输出到日志文件 ### log4j.appender.D = org.apache.log4j.DailyRollingFileAppender log4j.appender.D.File = logs/log.log log4j.appender.D.Append = true log4j.appender.D.Threshold = DEBUG ## 输出 DEBUG 级别以上的日志 log4j.appender.D.layout = org.apache.log4j.PatternLayout log4j.appender.D.layout.ConversionPattern = %-d{yyyy-MM-dd HH:mm:ss} [%t:%r] - [%p] %m%n ### 保存异常信息到单独文件 ### log4j.appender.D = org.apache.log4j.DailyRollingFileAppender log4j.appender.D.File = logs/error.log ## 异常日志文件名 log4j.appender.D.Append = true log4j.appender.D.Threshold = ERROR ## 只输出 ERROR 级别以上的日志!!! log4j.appender.D.layout = org.apache.log4j.PatternLayout log4j.appender.D.layout.ConversionPattern = %-d{yyyy-MM-dd HH:mm:ss} [%t:%r] - [%p] %m%n ``` ```java public static void main(String[] args) throws Exception { Logger logger = Logger.getLogger(UserDaoTest.class); logger.debug("开始"); example2(); logger.debug("结束"); } ``` ## 简单使用 log4j2 **Maven 依赖** ```xml org.apache.logging.log4j log4j-api 2.6.2 org.apache.logging.log4j log4j-core 2.6.2 ``` **log4j2.xml 配置** ```xml /Users/hanhan.zhang/logs %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %class{36} %L %M -- %msg%xEx%n %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %class{36} %L %M -- %msg%xEx%n ``` ```java import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; private static Logger logger_ = LogManager.getLogger(DateUtils2Joda.class); ``` ## 简单使用 slf4j **pom** slf4j-simple 中包含了 slf4j-api ```java private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(HelloAspect.class); ``` ```xml org.slf4j slf4j-simple 1.7.7 ``` ## log4j + slf4j 配置 1. slf4j-api:Simple Logging Facade for Java-api, 为 Java 提供的简单日志 Facade. Facade: 门面, 更底层一点说就是接口. slf4j 入口就是众多接口的集合, 他不负责具体的日志实现, 只在编译时负责寻找合适的日志系统进行绑定. 2. slf4j-log4j12: 链接 slf4j-api 和 log4j 中间的适配器. 它实现了 slf4j-api 中 StaticLoggerBinder 接口, 从而使得在编译时绑定的是 slf4j-log4j12 的 getSingleton() 方法. 3. log4j: 这个是具体的日志系统. 通过 slf4j-log4j12 初始化 Log4j, 达到最终日志的输出. **pom** 如果没有更高版本的 slf4j-api 和 log4j 要求, 则只添加第一条依赖就可以, 因为 slf4j-log4j12 依赖会包含 slf4j-api 和 log4j 依赖 ```xml org.slf4j slf4j-log4j12 1.7.21 org.slf4j slf4j-api 1.7.22 log4j log4j 1.2.17 ``` **log4j.properties (log4j.xml)** ``` log4j.rootLogger=debug,consoleAppender,fileAppender log4j.category.ETTAppLogger=debug, ettAppLogFile log4j.appender.consoleAppender=org.apache.log4j.ConsoleAppender log4j.appender.consoleAppender.Threshold=TRACE log4j.appender.consoleAppender.layout=org.apache.log4j.PatternLayout log4j.appender.consoleAppender.layout.ConversionPattern=%-d{yyyy-MM-dd HH:mm:ss SSS} ->[%t]--[%-5p]--[%c{1}]--%m%n log4j.appender.fileAppender=org.apache.log4j.DailyRollingFileAppender log4j.appender.fileAppender.File=c:/temp/nstd/debug1.log log4j.appender.fileAppender.DatePattern='_'yyyy-MM-dd'.log' log4j.appender.fileAppender.Threshold=TRACE log4j.appender.fileAppender.Encoding=BIG5 log4j.appender.fileAppender.layout=org.apache.log4j.PatternLayout log4j.appender.fileAppender.layout.ConversionPattern=%-d{yyyy-MM-dd HH:mm:ss SSS}-->[%t]--[%-5p]--[%c{1}]--%m%n log4j.appender.ettAppLogFile=org.apache.log4j.DailyRollingFileAppender log4j.appender.ettAppLogFile.File=c:/temp/nstd/ettdebug.log log4j.appender.ettAppLogFile.DatePattern='_'yyyy-MM-dd'.log' log4j.appender.ettAppLogFile.Threshold=DEBUG log4j.appender.ettAppLogFile.layout=org.apache.log4j.PatternLayout log4j.appender.ettAppLogFile.layout.ConversionPattern=%-d{yyyy-MM-dd HH:mm:ss SSS}-->[%t]--[%-5p]--[%c{1}]--%m%n ``` ### slf4j 工作原理 slf4j-api 作为 slf4j 的接口类, 使用在程序代码中, 这个包提供了一个 Logger 类和 LoggerFactory 类, Logger 类用来打日志, LoggerFactory 类用来获取 Logger;slf4j-log4j 是连接 slf4j 和 log4j 的桥梁, 怎么连接的呢?我们看看 slf4j 的 LoggerFactory 类的 getLogger 函数的源码: ```java /** * Return a logger named according to the name parameter using the statically * bound {@link ILoggerFactory} instance. * * @param name * The name of the logger. * @return logger */ public static Logger getLogger(String name) { ILoggerFactory iLoggerFactory = getILoggerFactory(); return iLoggerFactory.getLogger(name); } /** * Return a logger named corresponding to the class passed as parameter, using * the statically bound {@link ILoggerFactory} instance. * * @param clazz * the returned logger will be named after clazz * @return logger */ public static Logger getLogger(Class clazz) { return getLogger(clazz.getName()); } /** * Return the {@link ILoggerFactory} instance in use. * *

* ILoggerFactory instance is bound with this class at compile time. * * @return the ILoggerFactory instance in use */ public static ILoggerFactory getILoggerFactory() { if (INITIALIZATION_STATE == UNINITIALIZED) { INITIALIZATION_STATE = ONGOING_INITILIZATION; performInitialization(); } switch (INITIALIZATION_STATE) { case SUCCESSFUL_INITILIZATION: return StaticLoggerBinder.getSingleton().getLoggerFactory(); case NOP_FALLBACK_INITILIZATION: return NOP_FALLBACK_FACTORY; case FAILED_INITILIZATION: throw new IllegalStateException(UNSUCCESSFUL_INIT_MSG); case ONGOING_INITILIZATION: // support re-entrant behavior. // See also http://bugzilla.slf4j.org/show_bug.cgi?id=106 return TEMP_FACTORY; } throw new IllegalStateException("Unreachable code"); } ``` 查找到现在, 我们发现 LoggerFactory.getLogger() 首先获取一个 ILoggerFactory 接口, 然后使用该接口获取具体的 Logger. 获取 ILoggerFactory 的时候用到了一个 StaticLoggerBinder 类, 仔细研究我们会发现 StaticLoggerBinder 这个类并不是 slf4j-api 这个包中的类, 而是 slf4j-log4j 包中的类, 这个类就是一个中间类, 它用来将抽象的 slf4j 变成具体的 log4j, 也就是说具体要使用什么样的日志实现方案, 就得靠这个 StaticLoggerBinder 类. ```java /** * The ILoggerFactory instance returned by the {@link #getLoggerFactory} * method should always be the same object */ private final ILoggerFactory loggerFactory; private StaticLoggerBinder() { loggerFactory = new Log4jLoggerFactory(); try { Level level = Level.TRACE; } catch (NoSuchFieldError nsfe) { Util.report("This version of SLF4J requires log4j version 1.2.12 or later. See also http://www.slf4j.org/codes.html#log4j_version"); } } public ILoggerFactory getLoggerFactory() { return loggerFactory; } public String getLoggerFactoryClassStr() { return loggerFactoryClassStr; } ``` 可以看到 slf4j-log4j 中的 StaticLoggerBinder 类创建的 ILoggerFactory 其实是一个 org.slf4j.impl.Log4jLoggerFactory, 这个类的 getLogger 函数代码如下: ```java /* * (non-Javadoc) * * @see org.slf4j.ILoggerFactory#getLogger(java.lang.String) */ public Logger getLogger(String name) { Logger slf4jLogger = null; // protect against concurrent access of loggerMap synchronized (this) { slf4jLogger = (Logger) loggerMap.get(name); if (slf4jLogger == null) { org.apache.log4j.Logger log4jLogger; if(name.equalsIgnoreCase(Logger.ROOT_LOGGER_NAME)) { log4jLogger = LogManager.getRootLogger(); } else { log4jLogger = LogManager.getLogger(name); } slf4jLogger = new Log4jLoggerAdapter(log4jLogger); loggerMap.put(name, slf4jLogger); } } return slf4jLogger; } ``` 就在其中创建了真正的 org.apache.log4j.Logger, 也就是我们需要的具体的日志实现方案的 Logger 类. 就这样, 整个绑定过程就完成了. ## log4j2 + slf4j 配置 Log4j 1.x 在高并发情况下出现死锁导致 cpu 使用率异常飙升, 而 Log4j2.0 基于 LMAX Disruptor 的异步日志在多线程环境下性能会远远优于 Log4j 1.x 和 logback(官方数据是 10 倍以上), 这里分享 slf4j + Log4j2 的使用方法. **pom 配置** 删除以往依赖 Log4j1.x 的依赖项, 比如 slf4j-log4j12、log4j 等包. 可以到项目的根目录, 执行: mvn dependency:tree > tree.log , 查看之后 cat tree.log | grep log4j 查找. ```xml org.slf4j slf4j-log4j12 log4j log4j ``` 然后在工程的 pom.xml 新增以下 log4j2 的依赖关系: ```xml org.slf4j slf4j-api 1.7.13 org.slf4j jcl-over-slf4j 1.7.13 runtime org.apache.logging.log4j log4j-api 2.4.1 org.apache.logging.log4j log4j-core 2.4.1 org.apache.logging.log4j log4j-slf4j-impl 2.4.1 org.apache.logging.log4j log4j-web 2.4.1 runtime com.lmax disruptor 3.2.0 ``` **web.xml** web 工程的 web.xml 文件中添加(Servlet3.0 不需要): ```xml org.apache.logging.log4j.web.Log4jServletContextListener log4jServletFilter org.apache.logging.log4j.web.Log4jServletFilter log4jServletFilter /* REQUEST FORWARD INCLUDE ERROR log4jConfiguration /WEB-INF/classes/log4j2.xml ``` **log4j2.xml** ```xml /opt/logs/gct/shoppromo/logs error ``` ## logback + slf4j 配置 Logback 分为三个模块: logback-core, logback-classic, logback-access 1. logback-core 是核心; 2. logback-classic 改善了 log4j, 且自身实现了 SLF4J API, 所以即使用 Logback 你仍然可以使用其他的日志实现, 如原始的 Log4J, java.util.logging 等; 3. logback-access 让你方便的访问日志信息, 如通过 http 的方式 **pom** ```xml org.slf4j slf4j-api 1.7.24 jar compile ch.qos.logback logback-core 1.1.11 jar ch.qos.logback logback-classic 1.1.11 jar ``` **配置文件** ```xml %d{mm:ss} %-5level %logger{36} >>> %msg%n logs/logFile.%d{yyyy-MM-dd}.log 30 %-4relative [%thread] %-5level %logger{35} - %msg%n ``` ## log4j 转 logback 配置 **pom** ```xml ch.qos.logback logback-core 1.1.2 ch.qos.logback logback-access 1.1.2 ch.qos.logback logback-classic 1.1.2 org.slf4j log4j-over-slf4j 1.7.7 ``` 删除 src 下的 log4j.properties 文件, 在 src 下创建 logback.xml 配置文件 ## Spring Framework 依赖 SLF4J ## 总结 **slf4j 桥接到具体日志** 可以看到 slf4j 与具体日志框架结合的方案有很多种. 当然, 每种方案的最上层(绿色的应用层)都是统一的, 它们向下都是直接调用 slf4j 提供的 API(浅蓝色的抽象 API 层), 依赖 slf4j-api.jar. 然后 slf4j API 向下再怎么做就非常自由了, 几乎可以使用所有的具体日志框架. 注意图中的第二层是浅蓝色的, 看左下角的图例可知这代表抽象日志 API, 也就是说它们不是具体实现. 如果像左边第一种方案那样下层没有跟任何具体日志框架实现相结合, 那么日志是无法输出来的(这里不确定是否可能会默认输出到标准输出). 图中第三层明显就不如第一、二层那么整齐划一了, 因为这里已经开始涉及到了具体的日志框架. 首先看第三层中间的两个湖蓝色块, 这是适配层, 也就是桥接器. 左边的 slf4j-log4j12.jar 桥接器看名字就知道是 slf4j 到 log4j 的桥接器, 同样, 右边的 slf4j-jdk14.jar 就是 slf4j 到 Java 原生日志实现的桥接器了. 它们的下一层分别是对应的日志框架实现, log4j 的实现代码是 log4j.jar, 而 jul 实现代码已经包含在了 JVM runtime 中, 不需要单独的 jar 包. 再看第三层其余的三个深蓝色块. 它们三个也是具体的日志框架实现, 但是却不需要桥接器, 因为它们本身就已经直接实现了 slf4j API. slf4j-simple.jar 和 slf4j-nop.jar 这两个不用多说, 看名字就知道一个是 slf4j 的简单实现, 一个是 slf4j 的空实现, 平时用处也不大. 而 logback 之所以也实现了 slf4j API, 据说是因为 logback 和 slf4j 出自同一人之手, 这人同时也是 log4j 的作者. 第三层所有的灰色 jar 包都带有红框, 这表示它们都直接实现了 slf4j API, 只是湖蓝色的桥接器对 slf4j API 的实现并不是直接输出日志, 而是转去调用别的日志框架的 API. **其他日志框架转调回 slf4j** 上图展示了目前为止能安全地从别的日志框架 API 转调回 slf4j 的所有三种情形. 以左上角第一种情形为例, 当 slf4j 底层桥接到 logback 框架的时候, 上层允许桥接回 slf4j 的日志框架 API 有 log4j 和 jul. jcl 虽然不是什么日志框架的具体实现, 但是它的 API 仍然是能够被转调回 slf4j 的. 要想实现转调, 方法就是图上列出的用特定的桥接器 jar 替换掉原有的日志框架 jar. 需要注意的是这里不包含 logback API 到 slf4j API 的转调, 因为 logback 本来就是 slf4j API 的实现. 看完三种情形以后, 会发现几乎所有其他日志框架的 API, 包括 jcl 的 API, 都能够随意的转调回 slf4j. 但是有一个唯一的限制就是转调回 slf4j 的日志框架不能跟 slf4j 当前桥接到的日志框架相同. 这个限制就是为了防止 A-to-B.jar 跟 B-to-A.jar 同时出现在类路径中, 从而导致 A 和 B 一直不停地互相递归调用, 最后堆栈溢出. 目前这个限制并不是通过技术保证的, 仅仅靠开发者自己保证, 这也是为什么 slf4j 官网上要强调所有合理的方式只有上图的三种情形. 到这里, 在开始所展示的那个异常的原理基本已经清楚了. 此外, 通过上图还可以看出可能会出现类似异常的组合不仅仅是 log4j-over-slf4j 和 slf4j-log4j12, slf4j 官网还指出了另外一对: jcl-over-slf4j.jar 和 slf4j-jcl.jar ## [Servlet 方法大揭秘:sendRedirect vs forward](https://blog.dong4j.site/posts/a886bfdb.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## sendRedirect()方法和 forward() 方法的区别 ### sendRedirect() 方法原理 1. 客户端发送请求, Servlet1 做出处理. 2. Servlet1 调用 sendReadirect() 方法, 将客户端的请求重新定位到 Servlet2. 3. 客户端浏览器访问 Servlet2. 4. Servlet2 对客户端浏览器做出响应. ### forward() 方法原理 1. 客户端发送请求, Servlet1 做出处理. 2. Servlet1 调用 forward() 方法, 将请求转发给 Servlet2 来处理请求, 为客户端服务. 3. Servlet2 对客户端浏览器做出响应. #### 区别 1. 定位与转发 - sendRedirect(): 重新定位到另外一个资源来处理请求, URL 会重新定位, 让客户端重新访问另外一个资源. - forward(): 转发到另外一个资源来处理请求. URL 不会变化. 隐藏了处理对象的变化. 2. 处理请求的资源的范围 sendReadirect(): 可以跨 WEB 应用程序和服务器重新定位资源来处理请求. forward(): 只能在应用程序内部转发. ## encodeURL()和 encodeRedirectURL() 的区别 当用 URL 重写方式来管理 Session 的时候, 通过以上两个方法把 session ID 写到 URL 中. 不同点是: 两个方法确定是否需要包含 session ID 的逻辑不同. 在调用 HttpServletResponse.sendRedirect 前, 应该先调用 encodeRedirectURL() 方法, 否则可能会丢失 Sesssion 信息. 1. `java.lang.String encodeRedirectURL(java.lang.String url)` 对 sendRedirect 方法使用的指定 URL 进行编码. 如果不需要编码, 就直接返回这个 URL. 之所以提供这个附加的编码方法, 是因为在 redirect 的情况下, 决定是否对 URL 进行编码 的规则和一般情况有所不同. 所给的 URL 必须是一个绝对 URL. 相对 URL 不能被接收, 会抛 出一个 IllegalArgumentException. 所有提供给 sendRedirect 方法的 URL 都应通过 这个方法运行, 这样才能确保会话跟踪能够在所有浏览器中正常运行. 2. `java.lang.String encodeURL(java.lang.String url)` 对包含 session ID 的 URL 进行编码. 如果不需要编码, 就直接返回这个 URL. Servlet 引 擎必须提供 URL 编码方法, 因为在有些情况下, 我们将不得不重写 URL, 例如, 在响应对应的 请求中包含一个有效的 session, 但是这个 session 不能被非 URL 的(例如 cookie)的手 段来维持. 所有提供给 Servlet 的 URL 都应通过这个方法运行, 这样才能确保会话跟踪能够 在所有浏览器中正常运行. ## seAttribute 与 s etParameter 的区别 getAttribute 表示从 request 范围取得设置的属性, 必须要先 setAttribute 设置属性, 才能通过 getAttribute 来取得, 设置与取得的为 Object 对象类型 getParameter 表示接收参数, 参数为页面提交的参数, 包括: 表单提交的参数、URL 重写(就是 xxx?id=1 中的 id)传的参数等, 因此这个并没有设置参数的方法(没有 setParameter), 而且接收参数返回的不是 Object, 而是 String 类型 HttpServletRequest 类既有 getAttribute()方法, 也由 getParameter() 方法, 这两个方法有以下区别: - HttpServletRequest 类有 setAttribute()方法, 而没有 setParameter() 方法 - 当两个 Web 组件之间为链接关系时, 被链接的组件通过 getParameter() 方法来获得请求参数, 例如假定 welcome.jsp 和 authenticate.jsp 之间为链接关系, welcome.jsp 中有以下代码: ```html authenticate.jsp ``` 或者: ```html

请输入用户姓名:
``` 在 authenticate.jsp 中通过 request.getParameter("username") 方法来获得请求参数 username: `<% String username=request.getParameter("username"); %>` - 当两个 Web 组件之间为转发关系时, 转发目标组件通过 getAttribute() 方法来和转发源组件共享 request 范围内的数据. 假定 authenticate.jsp 和 hello.jsp 之间为转发关系. authenticate.jsp 希望向 hello.jsp 传递当前的用户名字, 如何传递这一数据呢?先在 authenticate.jsp 中调用 setAttribute() 方法: ```xml <% String username=request.getParameter("username"); request.setAttribute("username", username); %> ``` 在 hello.jsp 中通过 getAttribute() 方法获得用户名字: ```xml <% String username=(String)request.getAttribute("username"); %> Hello: <%=username %> ``` 从更深的层次考虑 - request.getParameter()方法传递的数据, 会从 Web 客户端传到 Web 服务器端, 代表 HTTP 请求数据. request.getParameter() 方法返回 String 类型的数据. - request.setAttribute()和 getAttribute() 方法传递的数据只会存在于 Web 容器内部, 在具有转发关系的 Web 组件之间共享. 这两个方法能够设置 Object 类型的共享数据. - request.getParameter()取得是通过容器的实现来取得通过类似 post, get 等方式传入的数据, request.setAttribute() 和 getAttribute() 只是在 web 容器内部流转, 仅仅是请求处理阶段, 这个的确是正解. - getAttribute()是返回对象, getParameter() 返回字符串 - request.getAttribute()方法返回 request 范围内存在的对象, 而 request.getParameter() 方法是获取 http 提交过来的数据. ## [Java程序员必备技能:性能优化的35个实用技巧](https://blog.dong4j.site/posts/692bb3c4.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 代码优化, 一个很重要的课题. 可能有些人觉得没用, 一些细小的地方有什么好修改的, 改与不改对于代码的运行效率有什么影响呢? 这个问题我是这么考虑的, 就像大海里面的鲸鱼一样, 它吃一条小虾米有用吗?没用, 但是, 吃的小虾米一多之后, 鲸鱼就被喂饱了. 代码优化也是一样, 如果项目着眼于尽快无 BUG 上线, 那么此时可以抓大放小, 代码的细节可以不精打细磨. 如果有足够的时间开发、维护代码, 这时候就必须考虑每个可以优化的细节了, 一个一个细小的优化点累积起来, 对于代码的运行效率绝对是有提升的. ## 代码优化的目标是 1. 减小代码的体积 2. 提高代码运行的效率 ## 代码优化细节: ### 1. 尽量指定类、方法的 final 修饰符 带有 final 修饰符的类是不可派生的. 在 Java 核心 API 中, 有许多应用 final 的例子, 例如 java.lang.String, 整个类都是 final 的. 为类指定 final 修饰符可以让类不可以被继承, 为方法指定 final 修饰符可以让方法不可以被重写. 如果指定了一个类为 final, 则该类所有的方法都是 final 的. Java 编译器会寻找机会内联所有的 final 方法, 内联对于提升 Java 运行效率作用重大, 具体参见 Java 运行期优化. 此举能够使性能平均提高 50%. ### 2. 尽量重用对象 特别是 String 对象的使用, 出现字符串连接时应该使用 StringBuilder/StringBuffer 代替. 由于 Java 虚拟机不仅要花时间生成对象, 以后可能还需要花时间对这些对象进行垃圾回收和处理, 因此, 生成过多的对象将会给程序的性能带来很大的影响. 、尽可能使用局部变 ### 3. 尽可能使用局部变量 调用方法时传递的参数以及在调用中创建的临时变量都保存在栈中速度较快, 其他变量, 如静态变量、实例变量等, 都在堆中创建, 速度较慢. 另外, 栈中创建的变量, 随着方法的运行结束, 这些内容就没了, 不需要额外的垃圾回收. 4. 及时关闭流 Java 编程过程中, 进行数据库连接、I/O 流操作时务必小心, 在使用完毕后, 及时关闭以释放资源. 因为对这些大对象的操作会造成系统大的开销, 稍有不慎, 将会导致严重的后果. 5. 尽量减少对变量的重复计算 明确一个概念, 对方法的调用, 即使方法中只有一句语句, 也是有消耗的, 包括创建栈帧、调用方法时保护现场、调用方法完毕时恢复现场等. 所以例如下面的操作: ```java for (int i = 0; i < list.size(); i++){ ... } ``` 建议替换为: ```java for (int i = 0, int length = list.size(); i < length; i++){ ... } ``` 这样, 在 list.size() 很大的时候, 就减少了很多的消耗 6. 尽量采用懒加载的策略, 即在需要的时候才创建 例如: ```java String str = "aaa";if (i == 1){ list.add(str); } ``` 建议替换为: ```java if (i == 1){ String str = "aaa"; list.add(str); } ``` 7. 慎用异常 异常对性能不利. 抛出异常首先要创建一个新的对象, Throwable 接口的构造函数调用名为 fillInStackTrace()的本地同步方法, fillInStackTrace() 方法检查堆栈, 收集调用跟踪信息. 只要有异常被抛出, Java 虚拟机就必须调整调用堆栈, 因为在处理过程中创建了一个新的对象. 异常只能用于错误处理, 不应该用来控制程序流程. 8. 不要在循环中使用 try…catch…, 应该把其放在最外层 除非不得已. 如果毫无理由地这么写了, 只要你的领导资深一点、有强迫症一点, 八成就要骂你为什么写出这种垃圾代码来了 9. 如果能估计到待添加的内容长度, 为底层以数组方式实现的集合、工具类指定初始长度 比如 ArrayList、LinkedLlist、StringBuilder、StringBuffer、HashMap、HashSet 等等, 以 StringBuilder 为例: - StringBuilder()// 默认分配 16 个字符的空间 - StringBuilder(int size)// 默认分配 size 个字符的空间 - StringBuilder(String str)// 默认分配 16 个字符 +str.length() 个字符空间 可以通过类(这里指的不仅仅是上面的 StringBuilder)的来设定它的初始化容量, 这样可以明显地提升性能. 比如 StringBuilder 吧, length 表示当前的 StringBuilder 能保持的字符数量. 因为当 StringBuilder 达到最大容量的时候, 它会将自身容量增加到当前的 2 倍再加 2. 无论何时只要 StringBuilder 达到它的最大容量, 它就不得不创建一个新的字符数组然后将旧的字符数组内容拷贝到新字符数组中—- 这是十分耗费性能的一个操作. 试想, 如果能预估到字符数组中大概要存放 5000 个字符而不指定长度, 最接近 5000 的 2 次幂是 4096, 每次扩容加的 2 不管, 那么: (1)在 4096 的基础上, 再申请 8194 个大小的字符数组, 加起来相当于一次申请了 12290 个大小的字符数组, 如果一开始能指定 5000 个大小的字符数组, 就节省了一倍以上的空间 (2)把原来的 4096 个字符拷贝到新的的字符数组中去 这样, 既浪费内存空间又降低代码运行效率. 所以, 给底层以数组实现的集合、工具类设置一个合理的初始化容量是错不了的, 这会带来立竿见影的效果. 但是, 注意, 像 HashMap 这种是以数组 + 链表实现的集合, 别把初始大小和你估计的大小设置得一样, 因为一个 table 上只连接一个对象的可能性几乎为 0. 初始大小建议设置为 2 的 N 次幂, 如果能估计到有 2000 个元素, 设置成 new HashMap(128)、new HashMap(256) 都可以. 10. 当复制大量数据时, 使用 System.arraycopy() 命令 11. 乘法和除法使用移位操作 例如: ```java for (val = 0; val < 100000; val += 5){ a = val * 8; b = val / 2; } ``` 用移位操作可以极大地提高性能, 因为在计算机底层, 对位的操作是最方便、最快的, 因此建议修改为: ```java for (val = 0; val < 100000; val += 5){ a = val << 3; b = val >> 1; } ``` 移位操作虽然快, 但是可能会使代码不太好理解, 因此最好加上相应的注释. 12. 循环内不要不断创建对象引用 例如: ```java for (int i = 1; i <= count; i++){ Object obj = new Object(); } ``` 这种做法会导致内存中有 count 份 Object 对象引用存在, count 很大的话, 就耗费内存了, 建议为改为: ```java Object obj = null; for (int i = 0; i <= count; i++) { obj = new Object(); } ``` 这样的话, 内存中只有一份 Object 对象引用, 每次 new Object() 的时候, Object 对象引用指向不同的 Object 罢了, 但是内存中只有一份, 这样就大大节省了内存空间了. 13. 基于效率和类型检查的考虑, 应该尽可能使用 array, 无法确定数组大小时才使用 ArrayList 用 14. 尽量使用 HashMap、ArrayList、StringBuilder, 除非线程安全需要, 否则不推荐使用 Hashtable、Vector、StringBuffer, 后三者由于使用同步机制而导致了性能开销查的考虑, 应该尽可能使用 array, 无法确定数组大 15. 不要将数组声明为 public static final 因为这毫无意义, 这样只是定义了引用为 static final, 数组的内容还是可以随意改变的, 将数组声明为 public 更是一个安全漏洞, 这意味着这个数组可以被外部类所改变 16. 尽量在合适的场合使用单例 使用单例可以减轻加载的负担、缩短加载的时间、提高加载的效率, 但并不是所有地方都适用于单例, 简单来说, 单例主要适用于以下三个方面: - 控制资源的使用, 通过线程同步来控制资源的并发访问 - 控制实例的产生, 以达到节约资源的目的 - 控制数据的共享, 在不建立直接关联的条件下, 让多个不相关的进程或线程之间实现通信 17. 尽量避免随意使用静态变量 要知道, 当某个对象被定义为 static 的变量所引用, 那么 gc 通常是不会回收这个对象所占有的堆内存的, 如: ```java public class A{ private static B b = new B(); } ``` 此时静态变量 b 的生命周期与 A 类相同, 如果 A 类不被卸载, 那么引用 B 指向的 B 对象会常驻内存, 直到程序终止 18. 及时清除不再需要的会话 为了清除不再活动的会话, 许多应用服务器都有默认的会话超时时间, 一般为 30 分钟. 当应用服务器需要保存更多的会话时, 如果内存不足, 那么操作系统会把部分数据转移到磁盘. 应用服务器也可能根据 MRU(最近最频繁使用)算法把部分不活跃的会话转储到磁盘, 甚至可能抛出内存不足的异常. 如果会话要被转储到磁盘, 那么必须要先被序列化, 在大规模集群中, 对对象进行序列化的代价是很昂贵的. 因此, 当会话不再需要时, 应当及时调用 HttpSession 的 invalidate() 方法清除会话. 19. 实现 RandomAccess 接口的集合比如 ArrayList, 应当使用最普通的 for 循环而不是 foreach 循环来遍历 这是 JDK 推荐给用户的. JDK API 对于 RandomAccess 接口的解释是: 实现 RandomAccess 接口用来表明其支持快速随机访问. 此接口的主要目的是允许一般的算法更改其行为, 从而将其应用到随机或连续访问列表时能提供良好的性能. 实际经验表明, 实现 RandomAccess 接口的类实例, 假如是随机访问的, 使用普通 for 循环效率将高于使用 foreach 循环. 反过来, 如果是顺序访问的, 则使用 Iterator 会效率更高. 可以使用类似如下的代码作判断: ```java if (list instanceof RandomAccess){ for (int i = 0; i < list.size(); i++){} }else{ Iterator iterator = list.iterable(); while (iterator.hasNext()){ iterator.next() } } ``` foreach 循环的底层实现原理就是迭代器 Iterator, 参见 Java 语法糖 1: 可变长度参数以及 foreach 循环原理. 所以后半句”反过来, 如果是顺序访问的, 则使用 Iterator 会效率更高”的意思就是顺序访问的那些类实例, 使用 foreach 循环去遍历. 20. 使用同步代码块替代同步方法 这点在多线程模块中的 synchronized 锁方法块一文中已经讲得很清楚了, 除非能确定一整个方法都是需要进行同步的, 否则尽量使用同步代码块, 避免对那些不需要进行同步的代码也进行了同步, 影响了代码执行效率. 21. 将常量声明为 static final, 并以大写命名 这样在编译期间就可以把这些内容放入常量池中, 避免运行期间计算生成常量的值. 另外, 将常量的名字以大写命名也可以方便区分出常量与变量 22. 不要创建一些不使用的对象, 不要导入一些不使用的类 这毫无意义, 如果代码中出现”The value of the local variable i is not used”、”The import java.util is never used”, 那么请删除这些无用的内容 23. 程序运行过程中避免使用反射 关于, 请参见反射. 反射是 Java 提供给用户一个很强大的功能, 功能强大往往意味着效率不高. 不建议在程序运行过程中使用尤其是频繁使用反射机制, 特别是 Method 的 invoke 方法. 如果确实有必要, 一种建议性的做法是将那些需要通过反射加载的类在项目启动的时候通过反射实例化出一个对象并放入内存—- 用户只关心和对端交互的时候获取最快的响应速度, 并不关心对端的项目启动花多久时间. 24. 使用数据库连接池和线程池 这两个池都是用于重用对象的, 前者可以避免频繁地打开和关闭连接, 后者可以避免频繁地创建和销毁线程 25. 使用带缓冲的输入输出流进行 IO 操作 带缓冲的输入输出流, 即 BufferedReader、BufferedWriter、BufferedInputStream、BufferedOutputStream, 这可以极大地提升 IO 效率 26. 顺序插入和随机访问比较多的场景使用 ArrayList, 元素删除和中间插入比较多的场景使用 LinkedList 这个, 理解 ArrayList 和 LinkedList 的原理就知道了 27. 不要让 public 方法中有太多的形参 public 方法即对外提供的方法, 如果给这些方法太多形参的话主要有两点坏处: - 违反了面向对象的编程思想, Java 讲求一切都是对象, 太多的形参, 和面向对象的编程思想并不契合 - 参数太多势必导致方法调用的出错概率增加 至于这个”太多”指的是多少个, 3、4 个吧. 比如我们用 JDBC 写一个 insertStudentInfo 方法, 有 10 个学生信息字段要插如 Student 表中, 可以把这 10 个参数封装在一个实体类中, 作为 insert 方法的形参 28. 字符串变量和字符串常量 equals 的时候将字符串常量写在前面 这是一个比较常见的小技巧了, 如果有以下代码: ```java String str = "123"; if (str.equals("123")) { ... } ``` 建议修改为: ```java String str = "123"; if ("123".equals(str)){ ... } ``` 这么做主要是可以避免空指针异常 29. 请知道, 在 java 中 if (i == 1) 和 if (1 == i) 是没有区别的, 但从阅读习惯上讲, 建议使用前者 平时有人问, ”if (i == 1)”和”if (1== i)”有没有区别, 这就要从 C/C++ 讲起. 在 C/C++ 中, ”if (i == 1)”判断条件成立, 是以 0 与非 0 为基准的, 0 表示 false, 非 0 表示 true, 如果有这么一段代码: ```java int i = 2; if (i == 1){ ... }else{ ... } ``` C/C++ 判断”i==1″不成立, 所以以 0 表示, 即 false. 但是如果: ```java int i = 2; if (i = 1) { ... }else{ ... } ``` 万一程序员一个不小心, 把”if (i == 1)”写成”if (i = 1)”, 这样就有问题了. 在 if 之内将 i 赋值为 1, if 判断里面的内容非 0, 返回的就是 true 了, 但是明明 i 为 2, 比较的值是 1, 应该返回的 false. 这种情况在 C/C++ 的开发中是很可能发生的并且会导致一些难以理解的错误产生, 所以, 为了避免开发者在 if 语句中不正确的赋值操作, 建议将 if 语句写为: ```java int i = 2; if (1 == i) { ... }else{ ... } ``` 这样, 即使开发者不小心写成了”1 = i”, C/C++ 编译器也可以第一时间检查出来, 因为我们可以对一个变量赋值 i 为 1, 但是不能对一个常量赋值 1 为 i. 但是, 在 Java 中, C/C++ 这种”if (i = 1)”的语法是不可能出现的, 因为一旦写了这种语法, Java 就会编译报错”Type mismatch: cannot convert from int to boolean”. 但是, 尽管 Java 的”if (i == 1)”和”if (1 == i)”在语义上没有任何区别, 但是从阅读习惯上讲, 建议使用前者会更好些 30. 不要对数组使用 toString() 方法 看一下对数组使用 toString() 打印出来的是什么: ```java public static void main(String[] args){ int[] is = new int[]{1, 2, 3}; System.out.println(is.toString()); } 结果是: [I@18a992f ``` 本意是想打印出数组内容, 却有可能因为数组引用 is 为空而导致空指针异常. 不过虽然对数组 toString()没有意义, 但是对集合 toString() 是可以打印出集合里面的内容的, 因为集合的父类 `AbstractCollections` 重写了 Object 的 toString() 方法. 31. 不要对超出范围的基本数据类型做向下强制转型 这绝不会得到想要的结果: ```java public static void main(String[] args){ long l = 12345678901234L; int i = (int)l; System.out.println(i); } ``` 我们可能期望得到其中的某几位, 但是结果却是: 1942892530 解释一下. Java 中 long 是 8 个字节 64 位的, 所以 12345678901234 在计算机中的表示应该是: 0000 0000 0000 0000 0000 1011 0011 1010 0111 0011 1100 1110 0010 1111 1111 0010 一个 int 型数据是 4 个字节 32 位的, 从低位取出上面这串二进制数据的前 32 位是: 0111 0011 1100 1110 0010 1111 1111 0010 这串二进制表示为十进制 1942892530, 所以就是我们上面的控制台上输出的内容. 从这个例子上还能顺便得到两个结论: - 整型默认的数据类型是 int, long l = 12345678901234L, 这个数字已经超出了 int 的范围了, 所以最后有一个 L, 表示这是一个 long 型数. 顺便, 浮点型的默认类型是 double, 所以定义 float 的时候要写成”"float f = 3.5f” - 接下来再写一句”int ii = l + i;”会报错, 因为 long + int 是一个 long, 不能赋值给 int 32. 公用的集合类中不使用的数据一定要及时 remove 掉 如果一个集合类是公用的(也就是说不是方法里面的属性), 那么这个集合里面的元素是不会自动释放的, 因为始终有引用指向它们. 所以, 如果公用集合里面的某些数据不使用而不去 remove 掉它们, 那么将会造成这个公用集合不断增大, 使得系统有内存泄露的隐患. 33. 把一个基本数据类型转为字符串, 基本数据类型.toString()是最快的方式、String.valueOf( 数据) 次之、数据 +”" 最慢 把一个基本数据类型转为一般有三种方式, 我有一个 Integer 型数据 i, 可以使用 i.toString()、String.valueOf(i)、i+”" 三种方式, 三种方式的效率如何, 看一个测试: ```java public static void main(String[] args){ int loopTime = 50000; Integer i = 0; long startTime = System.currentTimeMillis(); for (int j = 0; j < loopTime; j++){ String str = String.valueOf(i); } System.out.println("String.valueOf(): " + (System.currentTimeMillis() - startTime) + "ms"); startTime = System.currentTimeMillis(); for (int j = 0; j < loopTime; j++){ String str = i.toString(); } System.out.println("Integer.toString(): " + (System.currentTimeMillis() - startTime) + "ms"); startTime = System.currentTimeMillis(); for (int j = 0; j < loopTime; j++){ String str = i + ""; } System.out.println("i + \"\": " + (System.currentTimeMillis() - startTime) + "ms"); } ``` 运行结果为: String.valueOf(): 11ms Integer.toString(): 5ms i + "": 25ms 所以以后遇到把一个基本数据类型转为 String 的时候, 优先考虑使用 toString() 方法. 至于为什么, 很简单: 1.String.valueOf()方法底层调用了 Integer.toString() 方法, 但是会在调用前做空判断 2.Integer.toString() 方法就不说了, 直接调用了 3.i + “”底层使用了 StringBuilder 实现, 先用 append 方法拼接, 再用 toString() 方法获取字符串 三者对比下来, 明显是 2 最快、1 次之、3 最慢 34. 使用最有效率的方式去遍历 Map 遍历 Map 的方式有很多, 通常场景下我们需要的是遍历 Map 中的 Key 和 Value, 那么推荐使用的、效率最高的方式是: ```java public static void main(String[] args){ HashMap hm = new HashMap(); hm.put("111", "222"); Set> entrySet = hm.entrySet(); Iterator> iter = entrySet.iterator(); while (iter.hasNext()){ Map.Entry entry = iter.next(); System.out.println(entry.getKey() + "\t" + entry.getValue()); } } ``` 如果你只是想遍历一下这个 Map 的 key 值, 那用 `Set keySet = hm.keySet();` 会比较合适一些 35. 对资源的 close()建议分开操作 35、对资源的 close() 建议分开操作 意思是, 比如我有这么一段代码: ```java try{ XXX.close(); YYY.close(); }catch (Exception e){ ... } ``` 建议修改为: ```java try{ XXX.close(); }catch (Exception e) { ... }try{ YYY.close(); }catch (Exception e) {...} ``` 虽然有些麻烦, 却能避免资源泄露. 我们想, 如果没有修改过的代码, 万一 XXX.close() 抛异常了, 那么就进入了 cath 块中了. YYY.close() 不会执行, YYY 这块资源就不会回收了, 一直占用着, 这样的代码一多, 是可能引起资源句柄泄露的. 而改为下面的写法之后, 就保证了无论如何 XXX 和 YYY 都会被 close 掉. ## [JVM 内存新篇章:从持久代到元空间](https://blog.dong4j.site/posts/7f48a7fb.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### 持久代 持久代中包含了虚拟机中所有可通过反射获取到的数据, 比如 Class 和 Method 对象. 不同的 Java 虚拟机之间可能会进行类共享, 因此持久代又分为只读区和读写区. JVM 用于描述应用程序中用到的类和方法的元数据也存储在持久代中. JVM 运行时会用到多少持久代的空间取决于应用程序用到了多少类. 除此之外, Java SE 库中的类和方法也都存储在这里. 如果 JVM 发现有的类已经不再需要了, 它会去回收(卸载)这些类, 将它们的空间释放出来给其它类使用. Full GC 会进行持久代的回收. - JVM 中类的元数据在 Java 堆中的存储区域. - Java 类对应的 HotSpot 虚拟机中的内部表示也存储在这里. - 类的层级信息, 字段, 名字. - 方法的编译信息及字节码. - 变量 - 常量池和符号解析 #### 持久代的大小 - 它的上限是 MaxPermSize, 默认是 64M - Java 堆中的连续区域 : 如果存储在非连续的堆空间中的话, 要定位出持久代到新对象的引用非常复杂并且耗时. 卡表(card table), 是一种记忆集(Remembered Set), 它用来记录某个内存代中普通对象指针(oops)的修改. - 持久代用完后, 会抛出 OutOfMemoryError "PermGen space" 异常. 解决方案: 应用程序清理引用来触发类卸载;增加 MaxPermSize 的大小. - 需要多大的持久代空间取决于类的数量, 方法的大小, 以及常量池的大小. #### 为什么移除持久代 - 它的大小是在启动时固定好的——很难进行调优. -XX:MaxPermSize, 设置成多少好呢? - HotSpot 的内部类型也是 Java 对象: 它可能会在 Full GC 中被移动, 同时它对应用不透明, 且是非强类型的, 难以跟踪调试, 还需要存储元数据的元数据信息(meta-metadata). - 简化 Full GC: 每一个回收器有专门的元数据迭代器. - 可以在 GC 不进行暂停的情况下并发地释放类数据. - 使得原来受限于持久代的一些改进未来有可能实现 #### 那么 JVM 的元数据都去哪儿了? ### 元空间 (metaspace) 持久代的空间被彻底地删除了, 它被一个叫元空间的区域所替代了. 持久代删除了之后, 很明显, JVM 会忽略 PermSize 和 MaxPermSize 这两个参数, 还有就是你再也看不到 java.lang.OutOfMemoryError: PermGen error 的异常了. JDK 8 的 HotSpot JVM 现在使用的是本地内存来表示类的元数据, 这个区域就叫做元空间. 元空间的特点: - 充分利用了 Java 语言规范中的好处: 类及相关的元数据的生命周期与类加载器的一致. - 每个加载器有专门的存储空间 - 只进行线性分配 - 不会单独回收某个类 - 省掉了 GC 扫描及压缩的时间 - 元空间里的对象的位置是固定的 - 如果 GC 发现某个类加载器不再存活了, 会把相关的空间整个回收掉 #### 元空间的内存分配模型 - 绝大多数的类元数据的空间都从本地内存中分配 - 用来描述类元数据的类也被删除了 - 分元数据分配了多个虚拟内存空间 - 给每个类加载器分配一个内存块的列表. 块的大小取决于类加载器的类型; sun / 反射 / 代理对应的类加载器的块会小一些 - 归还内存块, 释放内存块列表 - 一旦元空间的数据被清空了, 虚拟内存的空间会被回收掉 - 减少碎片的策略 我们来看下 JVM 是如何给元数据分配虚拟内存的空间的 你可以看到虚拟内存空间是如何分配的 (vs1,vs2,vs3) , 以及类加载器的内存块是如何分配的. CL 是 Class Loader 的缩写. #### 理解 mark 和 klass 指针 要想理解下面这张图, 你得搞清楚这些指针都是什么东西. JVM 中, 每个对象都有一个指向它自身类的指针, 不过这个指针只是指向具体的实现类, 而不是接口或者抽象类. 对于 32 位的 JVM: `_mark` : 4 字节常量 `_klass`: 指向类的 4 字节指针 对象的内存布局中的第二个字段 (`_klass`, 在 32 位 JVM 中, 相对对象在内存中的位置的偏移量是 4, 64 位的是 8) 指向的是内存中对象的类定义. 64 位的 JVM: `_mark` : 8 字节常量 `_klass`: 指向类的 8 字节的指针 开启了指针压缩的 64 位 JVM: `_mark` : 8 字节常量 `_klass`: 指向类的 4 字节的指针 #### Java 对象的内存布局 类指针压缩空间(Compressed Class Pointer Space) 只有是 64 位平台上启用了类指针压缩才会存在这个区域. 对于 64 位平台, 为了压缩 JVM 对象中的 `_klass` 指针的大小, 引入了类指针压缩空间(Compressed Class Pointer Space). 压缩指针后的内存布局 指针压缩概要 - 64 位平台上默认打开 - 使用 - XX:+UseCompressedOops 压缩对象指针 "oops" 指的是普通对象指针 ("ordinary" object pointers). Java 堆中对象指针会被压缩成 32 位. 使用堆基地址(如果堆在低 26G 内存中的话, 基地址为 0) - 使用 - XX:+UseCompressedClassPointers 选项来压缩类指针 - 对象中指向类元数据的指针会被压缩成 32 位 - 类指针压缩空间会有一个基地址 #### 元空间和类指针压缩空间的区别 - 类指针压缩空间只包含类的元数据, 比如 InstanceKlass, ArrayKlass 仅当打开了 UseCompressedClassPointers 选项才生效 为了提高性能, Java 中的虚方法表也存放到这里 这里到底存放哪些元数据的类型, 目前仍在减少 - 元空间包含类的其它比较大的元数据, 比如方法, 字节码, 常量池等. ### 元空间的调优 使用 - XX:MaxMetaspaceSize 参数可以设置元空间的最大值, 默认是没有上限的, 也就是说你的系统内存上限是多少它就是多少. -XX:MetaspaceSize 选项指定的是元空间的初始大小, 如果没有指定的话, 元空间会根据应用程序运行时的需要动态地调整大小. #### MaxMetaspaceSize 的调优 - -XX:MaxMetaspaceSize={unlimited} - 元空间的大小受限于你机器的内存 - 限制类的元数据使用的内存大小, 以免出现虚拟内存切换以及本地内存分配失败. 如果怀疑有类加载器出现泄露, 应当使用这个参数;32 位机器上, 如果地址空间可能会被耗尽, 也应当设置这个参数. - 元空间的初始大小是 21M——这是 GC 的初始的高水位线, 超过这个大小会进行 Full GC 来进行类的回收. - 如果启动后 GC 过于频繁, 请将该值设置得大一些 - 可以设置成和持久代一样的大小, 以便推迟 GC 的执行时间 #### CompressedClassSpaceSize 的调优 - 只有当 - XX:+UseCompressedClassPointers 开启了才有效 - -XX:CompressedClassSpaceSize=1G - 由于这个大小在启动的时候就固定了的, 因此最好设置得大点. - 没有使用到的话不要进行设置 - JVM 后续可能会让这个区可以动态的增长. 不需要是连续的区域, 只要从基地址可达就行;可能会将更多的类元信息放回到元空间中;未来会基于 PredictedLoadedClassCount 的值来自动的设置该空间的大小 #### 元空间的一些工具 - jmap -permstat 改成了 jmap -clstats. 它用来打印 Java 堆的类加载器的统计数据. 对每一个类加载器, 会输出它的名字, 是否存活, 地址, 父类加载器, 以及它已经加载的类的数量及大小. 除此之外, 驻留的字符串(intern)的数量及大小也会打印出来. - jstat -gc, 这个命令输出的是元空间的信息而非持久代的 - jcmd GC.class_stats 提供类元数据大小的详细信息. 使用这个功能启动程序时需要加上 - XX:+UnlockDiagnosticVMOptions 选项. #### 提高 GC 的性能 如果你理解了元空间的概念, 很容易发现 GC 的性能得到了提升. - Full GC 中, 元数据指向元数据的那些指针都不用再扫描了. 很多复杂的元数据扫描的代码(尤其是 CMS 里面的那些)都删除了. - 元空间只有少量的指针指向 Java 堆. 这包括: 类的元数据中指向 java/lang/Class 实例的指针; 数组类的元数据中, 指向 java/lang/Class 集合的指针. - 没有元数据压缩的开销 - 减少了根对象的扫描(不再扫描虚拟机里面的已加载类的字典以及其它的内部哈希表) - 减少了 Full GC 的时间 - G1 回收器中, 并发标记阶段完成后可以进行类的卸载 #### 总结 - Hotspot 中的元数据现在存储到了元空间里. mmap 中的内存块的生命周期与类加载器的一致. - 类指针压缩空间(Compressed class pointer space)目前仍然是固定大小的, 但它的空间较大 - 可以进行参数的调优, 不过这不是必需的. - 未来可能会增加其它的优化及新特性. 比如, 应用程序类数据共享;新生代 GC 优化, G1 回收器进行类的回收;减少元数据的大小, 以及 JVM 内部对象的内存占用量. ## [从零开始:JUnit4.x 的实践指南](https://blog.dong4j.site/posts/64d008b.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 为什么使用 Junit 我们以前测试一个类的步骤: 1. 新建一个 test 类 2. 创建 main() 方法 3. 在 main 类 new 一个我们要测试的类的实例 4. 然后调用这个类的方法, 输出一个结果 当测试的类有多个方法时, 我们必须调用所有的方法, 为了不让上一次的方法调用对下一次的调用产生影响, 我们会在 new 一个实例出来, 或者将上一次的代码注释掉. 则将造成整个测试代码的混乱. 这个时候我们希望如果可以有多个 mian()方法, 每个 main() 方法内只调用一个需要测试的类的方法, 这样显得调理清晰. 但是这是不可能的, 一个程序只能有一个入口 这个时候, Junit 站了出来, 它大声的说它可以做到. ## 怎么使用 Junit 主要步骤: 1. 新建一个 java 项目 2. 在 src 下新建一个 util 包, 编写一个普通的类 ```java /** * 对名称, 地址等字符串格式的内容进行格式检查 * 或者格式化的工具类 * @author CodeA */ public class WordDeanUtil { /** * 将 Java 对象名称 (每个单词的头字符大写) 按照 * 数据库命名的习惯进行格式化 * 格式化后的数据为小写字母, 并且使用下划线分割命名单词 * 例如:employeeInfo-->employee_info * * @param name Java 对象名称 */ public static String wordFormat4DB(String name){ // 使用给定的正则表达式创建 Pattern 对象 (将给定的正则表达式编译到模式中.) Pattern p = Pattern.compile("[A-Z]"); // 创建 字符串和模式匹配的匹配器 Matcher m = p.matcher(name); StringBuffer sb = new StringBuffer(); // 拿着 name 中的字符一个一个的去和正则表达式对比, 成功返回 true while(m.find()){ // 找大写字母 m.appendReplacement(sb, "_" + m.group()); } return m.appendTail(sb).toString().toLowerCase(); } } ``` 3. 在 src 下新建一个 tests 文件夹, 将它设置为测试专用文件夹 - 在工程名上按 f4 - 找到 Modules-->Sources - 找到 tests 文件夹, 然后 Mark as Tests ![20241229154732_DLDvtPTF.webp](https://cdn.dong4j.site/source/image/20241229154732_DLDvtPTF.webp) 4. 选中我们要测试的类的类名, 然后 Ctrl+shift+t --> Create new test 5. 选择 Junit4, 然后选择要测试的类的方法 (method), setUp 和 tearDown 后面再介绍 ![20241229154732_Jghwc7S9.webp](https://cdn.dong4j.site/source/image/20241229154732_Jghwc7S9.webp) 6. 点击 OK 后, 如果前面的 test 测试文件夹没有出错的话, 会在 tests 文件夹下生成一个包, 这个包和我们要测试的类的包一样, 还有一个以测试类名 +Test 的类 (不同的 IDE 有不同的规则, Myeclipse 就是在前面加 test 的), 我们主要在这个类中操作 (单元测试代码和被测试代码使用一样的包, 不同的目录) ![20241229154732_fWUaDcLs.webp](https://cdn.dong4j.site/source/image/20241229154732_fWUaDcLs.webp) 7. 下面来写一个简单的测试方法 测试方法书写规范: - 测试方法必须使用注解 org.junit.Test 修饰 - 测试方法必须使用 public void 修饰, 而且不能带有任何参数 - 测试方法名一般以 test+ 被测试的方法名书写 ![20241229154732_YCpaDQtm.webp](https://cdn.dong4j.site/source/image/20241229154732_YCpaDQtm.webp) - 说明: 1. 我们只需要要这个测试方法当成一个 main()方法, 在这个方法里面书写我们以前在 main() 方法内写过的测试代码. - new 一个我们要测试的类的实例 - 然后调用这个类的方法, 输出一个结果 2. 在这个例子中我们只有一个需要测试的方法, 而且是静态的. 所以直接就使用类名 + 方法名调用我们要测试的方法了 3. `assertEquals("employee_info", reslut)` 的意思是第一个参数时我们能预测的想要的结果, 第二个参数是我们要测试的方法返回的结果, 如果这两个字符串相同, 整个测试通过. 4. 我们完全可以不使用 Junit 提供的这个方法 ![20241229154732_AxkWTz3W.webp](https://cdn.dong4j.site/source/image/20241229154732_AxkWTz3W.webp) 5. assertEquals 是由 JUnit 提供的一系列判断测试结果是否正确的静态断言方法(位于类 org.junit.Assert 中)之一 6. Junit 给我们提供了大量的静态方法让我们编写少量的代码就可以完成测试. 我们干嘛不用呢? **单元测试不是用来证明你是对的, 而是为了证明你没有错** 虽然上面的测试运行通过了, 但是并不代表代码通过单元测试, 因为单元测试不是证明你是对的而设计的, 我们得想方设法来证明我们的代码没有错. 所有我们得考虑到所有得情况来证明我们得代码没有错误: **上一个测试的补充:** 1. 测试 null 时的处理情况 2. 测试空字符串的处理情况 3. 测试单首字母大写时的情况 4. 测试多个相连字母大写时的情况 完成测试代码: ```java public class WordDeanUtilTest { @Before public void setUp() throws Exception { System.out.println("测试开始"); } @After public void tearDown() throws Exception { System.out.println("测试结束"); } @Test public void testWordFormat4DB() { String target = "employeeInfo"; String reslut = WordDeanUtil.wordFormat4DB(target); assertEquals("employee_info", reslut); } // 测试 null 时的处理情况 @Test public void wordFormat4DBNull(){ String target = null; String reslut = WordDeanUtil.wordFormat4DB(target); assertNull(reslut); } // 测试空字符串的处理情况 @Test public void wordFormat4DBEmpty(){ String target = ""; String reslut = WordDeanUtil.wordFormat4DB(target); assertEquals("", reslut); } // 测试当首字母大写时的处理情况 @Test public void wordFormat4DBBegin() { String target = "EmployeeInfo"; String reslut = WordDeanUtil.wordFormat4DB(target); assertEquals("employee_info", reslut); } // 测试尾字母大写时的处理情况 @Test public void wordFormat4DBEnd() { String target = "employeeInfoA"; String reslut = WordDeanUtil.wordFormat4DB(target); assertEquals("employee_info_a", reslut); } // 测试多个项链字母大写时的处理情况 @Test public void wordFormat4DBTogether() { String target = "employeeAInfo"; String reslut = WordDeanUtil.wordFormat4DB(target); assertEquals("employee_a_info", reslut); } } ``` 再次运行上面的测试代码时, 你会发现测试未通过 ![20241229154732_kSvPKuvR.webp](https://cdn.dong4j.site/source/image/20241229154732_kSvPKuvR.webp) ![20241229154732_nU9WauPf.webp](https://cdn.dong4j.site/source/image/20241229154732_nU9WauPf.webp) 有一个空指针异常, 由此可见我们的 wordFormat4DB() 方法没有对 null 做出处理 还有一个处理结果和我们预期的不一样, 这就是一个 bug, 被 Junit 找出来了 修改被测试的代码: ```java public class WordDeanUtil { /** * 将 Java 对象名称 (每个单词的头字符大写) 按照 * 数据库命名的习惯进行格式化 * 格式化后的数据为小写字母, 并且使用下划线分割命名单词 * 例如:employeeInfo-->employee_info * * @param name Java 对象名称 */ public static String wordFormat4DB(String name){ // 增加 null 验证 if(name == null){ return null; } // 使用给定的正则表达式创建 Pattern 对象 (将给定的正则表达式编译到模式中.) Pattern p = Pattern.compile("[A-Z]"); // 创建 字符串和模式匹配的匹配器 Matcher m = p.matcher(name); StringBuffer sb = new StringBuffer(); // 拿着 name 中的字符一个一个的去和正则表达式对比, 成功返回 true while(m.find()){ // 增加首字母大写验证 if(m.start() != 0){ m.appendReplacement(sb, ("_" + m.group()).toLowerCase()); } } return m.appendTail(sb).toString().toLowerCase(); } } ``` 再次运行, 测试通过 ~~~~ --- ## Junit 深入理解 ### Fixture 当编写测试方法时, 必须先初始化数据, 每个测试方法都需要这么做, 就会造成重复的代码, 所以 Junit 提出了 Fixture 解决方案 Fixture: 整治执行一个或者多个测试方法时需要的一系列公共资源或者数据. 意思就是初始化多个测试方法都需要使用到的数据 设置 Fixture: 1. 使用注解 org.junit.Before 修饰用于初始化的方法 2. 使用注解 org.junit.After 修饰用于注销的方法 3. 保证这两种方法都是用 public void 修饰, 而且不能带任何参数 方法级别的 Fixture 设置方法: ```java // 初始化方法 @Before public void init(){...} // 注销方法 @After public void destroy(){...} ``` 在 **每个** 测试方法执行之前, 都会执行 init 方法; 测试方法执行完毕之后, 都会执行 destroy() 方法; 这种方式保证了各个独立测试之间互不干扰, 一面其他测试代码修改测试环境或者测试数据影响到其他测试代码的准确性 方法级别 Fixture 执行示意图 ![20241229154732_Lt3nYDKp.webp](https://cdn.dong4j.site/source/image/20241229154732_Lt3nYDKp.webp) 下面是具体的测试结果: ![20241229154732_kaHthhOm.webp](https://cdn.dong4j.site/source/image/20241229154732_kaHthhOm.webp) 跟描述的一样 ~~~~ 但是这种方式效率低下, 每个测试方法都要初始化一次, 关闭一次, 对数据库连接来说是一场噩梦; 而且对于不会发生变化的测试环境或者测试数据来说, 是不会影响到执行结果的, 页就没必要每次都初始化和销毁; 因此 Junit4 引入了 **级别的 Fixture 设置方法:** 1. 使用注解 org.junit.BeforeClass 修饰用于初始化的方法 2. 使用注解 org.junit.AfterClass 修饰用于注销的方法 3. 保证这两种方法都是用 public static void 修饰, 而且不能带任何参数 类级别的 Fixture 仅会在测试类中所有测试方法执行之前执行初始化, 并且在全部测试方法测试完毕后执行注销方法 ```java @BeforeClass public void static init(){...} @AfterClass public void static destroy(){...} ``` 下面是具体的测试结果: ![20241229154732_B5IRCONd.webp](https://cdn.dong4j.site/source/image/20241229154732_B5IRCONd.webp) ### 异常和时间测试 注解 org.junit.Test 中有两个非常有用的参数: expected 和 timeout. #### expected 代表测试方法期望抛出指定的异常, 如果运行测试并没有抛出这个异常, 则 JUnit 会认为这个测试没有通过. 这为验 证被测试方法在错误的情况下是否会抛出预定的异常提供了便利. 举例来说, 方法 supportDBChecker 用于检查用户使用的数据库版本是否在系统的支持的范围之内, 如果用户使用了不被支持的数据库版本, 则会抛出运行时异常 UnsupportedDBVersionException. 测试方法 supportDBChecker 在数据库版 本不支持时是否会抛出指定异常的单元测试方法大体如下: ```java @Test(expected=UnsupportedDBVersionException.class) public void unsupportedDBCheck(){ …… } ``` #### timeout 指定被测试方法被允许运行的最长时间应该是多少, 如果 测试方法运行时间超过了指定的毫秒数, 则 JUnit 认为测试失败. 这个参数对于性能测试有一定的帮助. 例如, 如果解析一份自定义的 XML 文档花费了多于 1 秒的时间, 就需要重新考虑 XML 结构的设计, 那单元测试方法可以这样来写: ```java @Test(timeout=1000) public void selfXMLReader(){ …… } ``` ### 忽略测试方法 JUnit 提供注解 org.junit.Ignore 用于暂时忽略某个测试方法, 因为有时候由于测试环境受限, 并不能 保证每一个测试方法都能正确运行. 例如下面的代码便表示由于没有了数据库链接, 提示 JUnit 忽略测试方法 unsupportedDBCheck: ```java @Ignore(“db is down”) @Test(expected=UnsupportedDBVersionException.class) public void unsupportedDBCheck(){ …… } ``` 但是一定要小心. 注解 org.junit.Ignore 只能用于暂时的忽略测试, 如果需要永远忽略这些测试, 一定 要确认被测试代码不再需要这些测试方法, 以免忽略必要的测试点. ### 测试套件 在实际开发中, 单元测试类会越来越多, 这个时候我们再一个一个的运行测试类就悲剧了. 所幸的是 Junit 为我们提供了一种批量运行测试类的方法, 叫测试套件. 写法: 1. 创建一个空类作为测试套件的入口 2. 使用注解 org.junit.ranner.RunWith 和 org.junit.runners.Suite.SuiteClasses 修饰这个空类 3. 将 org.junit.runners.Suite 作为参数传入注解 RunWith, 以提示 Junit 为此类使用套件运行期执行 4. 将需要放入此测试套件的测试类组成数组作为注解 SuiteClasses 的参数 5. 保证这个空类使用 public 修饰, 而且存在公开的不带任何参数的构造函数 ## JUnit 和 Ant ant 提供了两个 target : junit 和 junitreport 运行所有测试用例, 并生成 html 格式的报表 具体操作如下: 1. 将 junit.jar 放在 ANT_HOMElib 目录下 2. 修改 build.xml , 加入如下 内容: -------------- One or more tests failed, check the report for detail... ----------------------------- 运行 这个 target , ant 会运行每个 TestCase, 在 report 目录下就有了 很多 `TEST*.xml` 和 一些网页打开 report 目录下的 index.html 就可以看到很直观的测试运行报告, 一目了然. 在 Eclipse 中开发、运行 JUnit 测试相当简单. 因为 Eclipse 本身集成了 JUnit 相关组件, 并对 JUnit 的运行提供了无缝的支持. ## 总结 下面是一些具体的编写测试代码的技巧或较好的实践方法: 1. 不要用 TestCase 的构造函数初始化 Fixture, 而要用 setUp()和 tearDown() 方法. 2. 不要依赖或假定测试运行的顺序, 因为 JUnit 利用 Vector 保存测试方法. 所以不同的平台会按不同的顺序从 Vector 中取出测试方法. 3. 避免编写有副作用的 TestCase. 例如: 如果随后的测试依赖于某些特定的交易数据, 就不要提交交易数据. 简单的回滚就可以了. 4. 当继承一个测试类时, 记得调用父类的 setUp()和 tearDown() 方法. 5. 将测试代码和工作代码放在一起, 一边同步编译和更新. (使用 Ant 中有支持 junit 的 task.) 6. 测试类和测试方法应该有一致的命名方案. 如在工作类名前加上 test 从而形成测试类名. 7. 确保测试与时间无关, 不要依赖使用过期的数据进行测试. 导致在随后的维护过程中很难重现测试. 8. 如果你编写的软件面向国际市场, 编写测试时要考虑国际化的因素. 不要仅用母语的 Locale 进行测试. 9. 尽可能地利用 JUnit 提供地 assert/fail 方法以及异常处理的方法, 可以使代码更为简洁. 10. 测试要尽可能地小, 执行速度快. 11. 不要硬性规定数据文件的路径. 12. 利用 Junit 的自动异常处理书写简洁的测试代码 事实上在 Junit 中使用 try-catch 来捕获异常是没有必要的, Junit 会自动捕获异常. 那些没有被捕获的异常就被当成错误处理. 13. 充分利用 Junit 的 assert/fail 方法 - assertSame() 用来测试两个引用是否指向同一个对象 - assertEquals() 用来测试两个对象是否相等 14. 确保测试代码与时间无关 15. 使用文档生成器做测试文档. ### junit3.x 1. 使用 junit3.x 版本进行单元测试时, 测试类必须要继承于 TestCase 父类; 2. 测试方法需要遵循的原则: - public 的 - void 的 - 无方法参数 - 方法名称必须以 test 开头 3. 不同的 Test Case 之间一定要保持完全的独立性, 不能有任何的关联. 4. 我们要掌握好测试方法的顺序, 不能依赖于测试方法自己的执行顺序. demo: ```java public class TestMyNumber extends TestCase { private MyNumber myNumber; public TestMyNumber(String name) { super(name); } // 在每个测试方法执行 [之前] 都会被调用 @Override public void setUp() throws Exception { // System.out.println("欢迎使用Junit进行单元测试…"); myNumber = new MyNumber(); } // 在每个测试方法执行 [之后] 都会被调用 @Override public void tearDown() throws Exception { // System.out.println("Junit单元测试结束…"); } public void testDivideByZero() { Throwable te = null; try { myNumber.divide(6, 0); Assert.fail("测试失败"); } catch (Exception e) { e.printStackTrace(); te = e; } Assert.assertEquals(Exception.class, te.getClass()); Assert.assertEquals("除数不能为 0 ", te.getMessage()); } } ``` ### junit4.x 1. 使用 junit4.x 版本进行单元测试时, 不用测试类继承 TestCase 父类, 因为, junit4.x 全面引入了 Annotation 来执行我们编写的测试. [3] 2. junit4.x 版本, 引用了注解的方式, 进行单元测试; 3. junit4.x 版本我们常用的注解: - @Before 注解: 与 junit3.x 中的 setUp() 方法功能一样, 在每个测试方法之前执行; - @After 注解: 与 junit3.x 中的 tearDown() 方法功能一样, 在每个测试方法之后执行; - @BeforeClass 注解: 在所有方法执行之前执行; - @AfterClass 注解: 在所有方法执行之后执行; - @Test(timeout = xxx) 注解: 设置当前测试方法在一定时间内运行完, 否则返回错误; - @Test(expected = Exception.class) 注解: 设置被测试的方法是否有异常抛出. 抛出异常类型为: Exception.class; - @Ignore 注解: 注释掉一个测试方法或一个类, 被注释的方法或类, 不会被执行. demo: ```java public class TestMyNumber { private MyNumber myNumber; @BeforeClass // 在所有方法执行之前执行 public static void globalInit() { System.out.println("init all method..."); } @AfterClass // 在所有方法执行之后执行 public static void globalDestory() { System.out.println("destory all method..."); } @Before // 在每个测试方法之前执行 public void setUp() { System.out.println("start setUp method"); myNumber = new MyNumber(); } @After // 在每个测试方法之后执行 public void tearDown() { System.out.println("end tearDown method"); } @Test(timeout=600)// 设置限定测试方法的运行时间 如果超出则返回错误 public void testAdd() { System.out.println("testAdd method"); int result = myNumber.add(2, 3); assertEquals(5, result); } @Test public void testSubtract() { System.out.println("testSubtract method"); int result = myNumber.subtract(1, 2); assertEquals(-1, result); } @Test public void testMultiply() { System.out.println("testMultiply method"); int result = myNumber.multiply(2, 3); assertEquals(6, result); } @Test public void testDivide() { System.out.println("testDivide method"); int result = 0; try { result = myNumber.divide(6, 2); } catch (Exception e) { fail(); } assertEquals(3, result); } @Test(expected = Exception.class) public void testDivide2() throws Exception { System.out.println("testDivide2 method"); myNumber.divide(6, 0); fail("test Error"); } public static void main(String[] args) { } } ``` ## [Java面试必知:final、finally和finalize的区别解析](https://blog.dong4j.site/posts/c8f7e252.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## Hibernate 的理解 1. 面向对象设计的软件内部运行过程可以理解成就是在不断创建各种新对象、建立对象之间的关系, 调用对象的方法来改变各个对象的状态和对象消亡的过程, 不管程序运行的过程和操作怎么样, 本质上都是要得到一个结果, 程序上一个时刻和下一个时刻的运行结果的差异就表现在内存中的对象状态发生了变化. 2. 为了在关机和内存空间不够的状况下, 保持程序的运行状态, 需要将内存中的对象状态保存到持久化设备和从持久化设备中恢复出对象的状态, 通常都是保存到关系数据库来保存大量对象信息. 从 Java 程序的运行功能上来讲, 保存对象状态的功能相比系统运行的其他功能来说, 应该是一个很不起眼的附属功能, java 采用 jdbc 来实现这个功能, 这个不起眼的功能却要编写大量的代码, 而做的事情仅仅是保存对象和恢复对象, 并且那些大量的 jdbc 代码并没有什么技术含量, 基本上是采用一套例行公事的标准代码模板来编写, 是一种苦活和重复性的工作. 3. 通过数据库保存 java 程序运行时产生的对象和恢复对象, 其实就是实现了 java 对象与关系数据库记录的映射关系, 称为 ORM(即 Object Relation Mapping), 人们可以通过封装 JDBC 代码来实现了这种功能, 封装出来的产品称之为 ORM 框架, Hibernate 就是其中的一种流行 ORM 框架. 使用 Hibernate 框架, 不用写 JDBC 代码, 仅仅是调用一个 save 方法, 就可以将对象保存到关系数据库中, 仅仅是调用一个 get 方法, 就可以从数据库中加载出一个对象. 4. 使用 Hibernate 的基本流程是: 配置 Configuration 对象、产生 SessionFactory、创建 session 对象, 启动事务, 完成 CRUD 操作, 提交事务, 关闭 session. 5. 使用 Hibernate 时, 先要配置 hibernate.cfg.xml 文件, 其中配置数据库连接信息和方言等, 还要为每个实体配置相应的 hbm.xml 文件, hibernate.cfg.xml 文件中需要登记每个 hbm.xml 文件. 6. 在应用 Hibernate 时, 重点要了解 Session 的缓存原理, 级联, 延迟加载和 hql 查询. ## Spring 的理解 1. Spring 实现了工厂模式的工厂类(在这里有必要解释清楚什么是工厂模式), 这个类名为 BeanFactory(实际上是一个接口), 在程序中通常 BeanFactory 的子类 ApplicationContext. Spring 相当于一个大的工厂类, 在其配置文件中通过 `` 元素配置用于创建实例对象的类名和实例对象的属性. 2. Spring 提供了对 IOC 良好支持, IOC 是一种编程思想, 是一种架构艺术, 利用这种思想可以很好地实现模块之间的解耦. IOC 也称为 DI(Depency Injection), 什么叫依赖注入呢? 譬如, ```java Class Programmer { Computer computer = null; public void code() { // Computer computer = new IBMComputer(); // Computer computer = beanfacotry.getComputer(); computer.write(); } public void setComputer(Computer computer) { this.computer = computer; } } ``` 另外两种方式都由依赖, 第一个直接依赖于目标类, 第二个把依赖转移到工厂上, 第三个彻底与目标和工厂解耦了. 在 spring 的配置文件中配置片段如下: ```xml ``` 3. Spring 提供了对 AOP 技术的良好封装, AOP 称为面向切面编程, 就是系统中有很多各不相干的类的方法, 在这些众多方法中要加入某种系统功能的代码, 例如, 加入日志, 加入权限判断, 加入异常处理, 这种应用称为 AOP. 实现 AOP 功能采用的是代理技术, 客户端程序不再调用目标, 而调用代理类, 代理类与目标类对外具有相同的方法声明, 有两种方式可以实现相同的方法声明, 一是实现相同的接口, 二是作为目标的子类在, JDK 中采用 Proxy 类产生动态代理的方式为某个接口生成实现类, 如果要为某个类生成子类, 则可以用 CGLI B. 在生成的代理类的方法中加入系统功能和调用目标类的相应方法, 系统功能的代理以 Advice 对象进行提供, 显然要创建出代理对象, 至少需要目标类和 Advice 类. spring 提供了这种支持, 只需要在 spring 配置文件中配置这两个元素即可实现代理和 aop 功能, 例如, ```xml ``` ## iBatis 与 Hibernate 有什么不同? 相同点: 屏蔽 jdbc api 的底层访问细节, 使用我们不用与 jdbc api 打交道, 就可以访问数据. jdbc api 编程流程固定, 还将 sql 语句与 java 代码混杂在了一起, 经常需要拼凑 sql 语句, 细节很繁琐. ibatis 的好处: 屏蔽 jdbc api 的底层访问细节;将 sql 语句与 java 代码进行分离; 提供了将结果集自动封装称为实体对象和对象的集合的功能, queryForList 返回对象集合, 用 queryForObject 返回单个对象;提供了自动将实体对象的属性传递给 sql 语句的参数. Hibernate 是一个全自动的 orm 映射工具, 它可以自动生成 sql 语句,ibatis 需要我们自己在 xml 配置文件中写 sql 语句, hibernate 要比 ibatis 功能负责和强大很多. 因为 hibernate 自动生成 sql 语句, 我们无法控制该语句, 我们就无法去写特定的高效率的 sql. 对于一些不太复杂的 sql 查询, hibernate 可以很好帮我们完成, 但是, 对于特别复杂的查询, hibernate 就很难适应了, 这时候用 ibatis 就是不错的选择, 因为 ibatis 还是由我们自己写 sql 语句. # final、finally 和 finalize 的区别是什么? 这是一道再经典不过的面试题了, 我们在各个公司的面试题中几乎都能看到它的身影. final、finally 和 finalize 虽然长得像孪生三兄弟一样, 但是它们的含义和用法却是大相径庭. 这一次我们就一起来回顾一下这方面的知识. **_final 关键字_** 我们首先来说说 final. 它可以用于以下四个地方: 1. 定义变量, 包括静态的和非静态的. 2. 定义方法的参数. 3. 定义方法. 4. 定义类. 我们依次来回顾一下每种情况下 final 的作用. 首先来看第一种情况, 如果 final 修饰的是一个基本类型, 就表示这个变量被赋予的值是不可变 的, 即它是个常量;如果 final 修饰的是一个对象, 就表示这个变量被赋予的引用是不可变的, 这里需要提醒大家注意的是, 不可改变的只是这个变量所保存的 引用, 并不是这个引用所指向的对象. 在第二种情况下, final 的含义与第一种情况相同. 实际上对于前两种情况, 有一种更贴切的表述 final 的含义的描 述, 那就是, 如果一个变量或方法参数被 final 修饰, 就表示它只能被赋值一次, 但是 JAVA 虚拟机为变量设定的默认值不记作一次赋值. 被 final 修饰的变量必须被初始化. 初始化的方式有以下几种: 1. 在定义的时候初始化. 2. final 变量可以在初始化块中初始化, 不可以在静态初始化块中初始化. 3. 静态 final 变量可以在静态初始化块中初始化, 不可以在初始化块中初始化. 4. final 变量还可以在类的构造器中初始化, 但是静态 final 变量不可以. 通过下面的代码可以验证以上的观点: ```java public class FinalTest { // 在定义时初始化 public final int A = 10; public final int B; // 在初始化块中初始化 { B = 20; } // 非静态 final 变量不能在静态初始化块中初始化 // public final int C; // static { // C = 30; // } // 静态常量, 在定义时初始化 public static final int STATIC_D = 40; public static final int STATIC_E; // 静态常量, 在静态初始化块中初始化 static { STATIC_E = 50; } // 静态变量不能在初始化块中初始化 // public static final int STATIC_F; // { // STATIC_F = 60; // } public final int G; // 静态 final 变量不可以在构造器中初始化 // public static final int STATIC_H; // 在构造器中初始化 public FinalTest() { G = 70; // 静态 final 变量不可以在构造器中初始化 // STATIC_H = 80; // 给 final 的变量第二次赋值时, 编译会报错 // A = 99; // STATIC_D = 99; } // final 变量未被初始化, 编译时就会报错 // public final int I; // 静态 final 变量未被初始化, 编译时就会报错 // public static final int STATIC_J; } ``` 我们运行上面的代码之后出了可以发现 final 变量(常量)和静态 final 变量(静态常量)未被初始化时, 编译会报错. 用 final 修饰的变量(常量)比非 final 的变量(普通变量)拥有更高的效率, 因此我们在实际编程中应该尽可能多的用常量来代替普通变量, 这也是一个很好的编程习惯. 当 final 用来定义一个方法时, 会有什么效果呢?正如大家所知, 它表示这个方法不可以被子类重写, 但是它这不影响它被子类继承. 我们写段代码来验证一下: ```java class ParentClass { public final void TestFinal() { System.out.println("父类 -- 这是一个 final 方法"); } } public class SubClass extends ParentClass { /** * 子类无法重写(override)父类的 final 方法, 否则编译时会报错 */ // public void TestFinal() { // System.out.println("子类 -- 重写 final 方法"); // } public static void main(String[] args) { SubClass sc = new SubClass(); sc.TestFinal(); } } ``` 这里需要特殊说明的是, 具有 private 访问权限的方法也可以增加 final 修饰, 但是由于子类无法继承 private 方法, 因此也无法重写 它. 编译器在处理 private 方法时, 是按照 final 方法来对待的, 这样可以提高该方法被调用时的效率. 不过子类仍然可以定义同父类中的 private 方法具有同样结构的方法, 但是这并不会产生重写的效果, 而且它们之间也不存在必然联系. 最后我们再来回顾一下 final 用于类的情况. 这个大家应该也很熟悉了, 因为我们最常用的 String 类就是 final 的. 由于 final 类不允 许被继承, 编译器在处理时把它的所有方法都当作 final 的, 因此 final 类比普通类拥有更高的效率. final 的类的所有方法都不能被重写, 但这并不 表示 final 的类的属性(变量)值也是不可改变的, 要想做到 final 类的属性值不可改变, 必须给它增加 final 修饰, 请看下面的例子: ```java public final class FinalTest { int i = 10; public static void main(String[] args) { FinalTest ft = new FinalTest(); ft.i = 99; System.out.println(ft.i); } } ``` 运行上面的代码试试看, 结果是 99, 而不是初始化时的 10. **_finally 语句_** 接下来我们一起回顾一下 finally 的用法. 这个就比较简单了, 它只能用在 try/catch 语句中, 并且附带着一个语句块, 表示这段语句最终总是被执行. 请看下面的代码: ```java public final class FinallyTest { public static void main(String[] args) { try { throw new NullPointerException(); } catch (NullPointerException e) { System.out.println("程序抛出了异常"); } finally { System.out.println("执行了 finally 语句块"); } } } ``` 运行结果说明了 finally 的作用: 1. 程序抛出了异常 2. 执行了 finally 语句块 请大家注意, 捕获程序抛出的异常之后, 既不加处理, 也不继续向上抛出异常, 并不是良好的编程习惯, 它掩盖了程序执行中发生的错误, 这里只是方便演示, 请不要学习. 那么, 有没有一种情况使 finally 语句块得不到执行呢?大家可能想到了 return、continue、break 这三个可以打乱代码顺序执行语句的规律. 那我们就来试试看, 这三个语句是否能影响 finally 语句块的执行: ```java public final class FinallyTest { // 测试 return 语句 public ReturnClass testReturn() { try { return new ReturnClass(); } catch (Exception e) { e.printStackTrace(); } finally { System.out.println("执行了 finally 语句"); } return null; } // 测试 continue 语句 public void testContinue() { for (int i = 0; i < 3; i++) { try { System.out.println(i); if (i == 1) { continue; } } catch (Exception e) { e.printStackTrace(); } finally { System.out.println("执行了 finally 语句"); } } } // 测试 break 语句 public void testBreak() { for (int i = 0; i < 3; i++) { try { System.out.println(i); if (i == 1) { break; } } catch (Exception e) { e.printStackTrace(); } finally { System.out.println("执行了 finally 语句"); } } } public static void main(String[] args) { FinallyTest ft = new FinallyTest(); // 测试 return 语句 ft.testReturn(); System.out.println(); // 测试 continue 语句 ft.testContinue(); System.out.println(); // 测试 break 语句 ft.testBreak(); } } class ReturnClass { public ReturnClass() { System.out.println("执行了 return 语句"); } } ``` 上面这段代码的运行结果如下: ``` 执行了 return 语句 执行了 finally 语句 0 执行了 finally 语句 1 执行了 finally 语句 2 执行了 finally 语句 0 执行了 finally 语句 1 执行了 finally 语句 ``` 很明显, return、continue 和 break 都没能阻止 finally 语句块的执行. 从输出的结果来看, return 语句似乎在 finally 语句块之前执行了, 事实真的如此吗?我们来想想看, return 语句的作用是什么呢?是退出当前的方法, 并将值或对象返回. 如果 finally 语句块是在 return 语句之后执行的, 那么 return 语句被执行后就已经退出当前方法了, finally 语句块又如何能被执行呢?因 此, 正确的执行顺序应该是这样的: 编译器在编译 return new ReturnClass(); 时, 将它分成了两个步骤, new ReturnClass() 和 return, 前一个创建对象的语句是在 finally 语句块之前被执行的, 而后一个 return 语句是在 finally 语 句块之后执行的, 也就是说 finally 语句块是在程序退出方法之前被执行的. 同样, finally 语句块是在循环被跳过(continue)和中断 (break)之前被执行的. **_finalize 方法_** 最后, 我们再来看看 finalize, 它是一个方法, 属于 java.lang.Object 类, 它的定义如下: `protected void finalize() throws Throwable { } ` 众所周知, finalize() 方法是 GC(garbage collector)运行机制的一部分, 关于 GC 的知识我们将在后续的章节中来回顾. 在此我们只说说 finalize() 方法的作用是什么呢? finalize() 方法是在 GC 清理它所从属的对象时被调用的, 如果执行它的过程中抛出了无法捕获的异常(uncaught exception), GC 将终止对改对象的清理, 并且该异常会被忽略;直到下一次 GC 开始清理这个对象时, 它的 finalize() 会被再次调用. 请看下面的示例: ```java public final class FinallyTest { // 重写 finalize() 方法 protected void finalize() throws Throwable { System.out.println("执行了 finalize() 方法"); } public static void main(String[] args) { FinallyTest ft = new FinallyTest(); ft = null; System.gc(); } } ``` 运行结果如下: - 执行了 finalize() 方法 程序调用了 java.lang.System 类的 gc()方法, 引起 GC 的执行, GC 在清理 ft 对象时调用了它的 finalize() 方法, 因此才有了上面的输出结果. 调用 System.gc() 等同于调用下面这行代码: `Runtime.getRuntime().gc(); ` 调用它们的作用只是建议垃圾收集器(GC)启动, 清理无用的对象释放内存空间, 但是 GC 的启动并不是一定的, 这由 JAVA 虚拟机来决定. 直到 JAVA 虚拟机停止运行, 有些对象的 finalize() 可能都没有被运行过, 那么怎样保证所有对象的这个方法在 JAVA 虚拟机停止运行之前一定被调用 呢?答案是我们可以调用 System 类的另一个方法: ```java public static void runFinalizersOnExit(boolean value) { //other code } ``` 给这个方法传入 true 就可以保证对象的 finalize() 方法在 JAVA 虚拟机停止运行前一定被运行了, 不过遗憾的是这个方法是不安全的, 它会导致有用的对象 finalize() 被误调用, 因此已经不被赞成使用了. 由于 finalize() 属于 Object 类, 因此所有类都有这个方法, Object 的任意子类都可以重写(override)该方法, 在其中释放系统资源或者做其它的清理工作, 如关闭输入输出流. 通过以上知识的回顾, 我想大家对于 final、finally、finalize 的用法区别已经很清楚了. ## [Java基础:一些常用代码片段](https://blog.dong4j.site/posts/b3298821.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 获取环境变量 ```java System.getenv("PATH"); System.getenv("JAVA_HOME"); ``` ## 获取系统属性 ```java System.getProperty("pencil color"); // 得到属性值 java -Dpencil color=green System.getProperty("java.specification.version"); // 得到 Java 版本号 Properties p = System.getProperties(); // 得到所有属性值 p.list(System.out); ``` ## String Tokenizer ```java // 能够同时识别, 和 | StringTokenizer st = new StringTokenizer("Hello, World|of|Java", ", |"); while (st.hasMoreElements()) { st.nextToken(); } // 把分隔符视为 token StringTokenizer st = new StringTokenizer("Hello, World|of|Java", ", |", true); ``` ## StringBuffer(同步) 和 StringBuilder(非同步) ```java StringBuilder sb = new StringBuilder(); sb.append("Hello"); sb.append("World"); sb.toString(); new StringBuffer(a).reverse(); // 反转字符串 ``` ## 数字 ```java // 数字与对象之间互相转换 - Integer 转 int Integer.intValue(); // 浮点数的舍入 Math.round() // 数字格式化 NumberFormat // 整数 -> 二进制字符串 toBinaryString()或 valueOf() // 整数 -> 八进制字符串 toOctalString() // 整数 -> 十六进制字符串 toHexString() // 数字格式化为罗马数字 RomanNumberFormat() // 随机数 Random r = new Random(); r.nextDouble(); r.nextInt(); ``` ## 日期和时间 ```java // 查看当前日期 Date today = new Date(); Calendar.getInstance().getTime(); // 格式化默认区域日期输出 DateFormat df = DateFormat.getInstance(); df.format(today); // 格式化制定区域日期输出 DateFormat df_cn = DateFormat.getDateInstance(DateFormat.FULL, Locale.CHINA); String now = df_cn.format(today); // 按要求格式打印日期 SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"); sdf.format(today); // 设置具体日期 GregorianCalendar d1 = new GregorianCalendar(2009, 05, 06); // 6 月 6 日 GregorianCalendar d2 = new GregorianCalendar(); // 今天 Calendar d3 = Calendar.getInstance(); // 今天 d1.getTime(); // Calendar 或 GregorianCalendar 转成 Date 格式 d3.set(Calendar.YEAR, 1999); d3.set(Calendar.MONTH, Calendar.APRIL); d3.set(Calendar.DAY_OF_MONTH, 12); // 字符串转日期 SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"); Date now = sdf.parse(String); // 日期加减 Date now = new Date(); long t = now.getTime(); t += 700*24*60*60*1000; Date then = new Date(t); Calendar now = Calendar.getInstance(); now.add(Calendar.YEAR, -2); // 计算日期间隔 (转换成 long 来计算) today.getTime()- old.getTime(); // 比较日期 Date 类型, 就使用 equals(), before(), after() 来计算 long 类型, 就使用 ==, <, > 来计算 // 第几日 使用 Calendar 的 get() 方法 Calendar c = Calendar.getInstance(); c.get(Calendar.YEAR); // 记录耗时 long start = System.currentTimeMillis(); long end = System.currentTimeMillis(); long elapsed = end - start; System.nanoTime(); // 毫秒 // 长整形转换成秒 Double.toString(t/1000D); ``` ## 结构化数据 ```java // 数组拷贝 System.arrayCopy(oldArray, 0, newArray, 0, oldArray.length); // ArrayList add(Object o) // 在末尾添加给定元素 add(int i, Object o) // 在指定位置插入给定元素 clear() // 从集合中删除全部元素 Contains(Object o) // 如果 Vector 包含给定元素, 返回真值 get(int i) // 返回指定位置的对象句柄 indexOf(Object o) // 如果找到给定对象, 则返回其索引值;否则, 返回 -1 remove(Object o) // 根据引用删除对象 remove(int i) // 根据位置删除对象 toArray() // 返回包含集合对象的数组 // Iterator List list = new ArrayList(); Iterator it = list.iterator(); while (it.hasNext()) Object o = it.next(); // 链表 LinkedList list = new LinkedList(); ListIterator it = list.listIterator(); while (it.hasNext()) Object o = it.next(); // HashMap HashMap hm = new HashMap(); hm.get(key); // 通过 key 得到 value hm.put("No1", "Hexinyu"); hm.put("No2", "Sean"); // 方法 1: 获取全部键值 Iterator it = hm.values().iterator(); while (it.hasNext()) { String myKey = it.next(); String myValue = hm.get(myKey); } // 方法 2: 获取全部键值 for (String key : hm.keySet()) { String myKey = key; String myValue = hm.get(myKey); } // Preferences - 与系统相关的用户设置, 类似名 - 值对 Preferences prefs = Preferences.userNodeForPackage(ArrayDemo.class); String text = prefs.get("textFontName", "lucida-bright"); String display = prefs.get("displayFontName", "lucida-balckletter"); System.out.println(text); System.out.println(display); // 用户设置了新值, 存储回去 prefs.put("textFontName", "new-bright"); prefs.put("displayFontName", "new-balckletter"); // Properties - 类似名 - 值对, key 和 value 之间, 可以用"=", ":"或空格分隔, 用"#"和"!"注释 InputStream in = MediationServer.class.getClassLoader().getResourceAsStream("msconfig.properties"); Properties prop = new Properties(); prop.load(in); in.close(); prop.setProperty(key, value); prop.getProperty(key); // 排序 1. 数组: Arrays.sort(strings); 2. List: Collections.sort(list); 3. 自定义类: class SubComp implements Comparator 然后使用 Arrays.sort(strings, new SubComp()) // 两个接口 1. java.lang.Comparable: 提供对象的自然排序, 内置于类中 int compareTo(Object o); boolean equals(Object o2); 2. java.util.Comparator: 提供特定的比较方法 int compare(Object o1, Object o2) // 避免重复排序, 可以使用 TreeMap TreeMap sorted = new TreeMap(unsortedHashMap); // 排除重复元素 Hashset hs - new HashSet(); // 搜索对象 binarySearch(): 快速查询 - Arrays, Collections contains(): 线型搜索 - ArrayList, HashSet, Hashtable, linkedList, Properties, Vector containsKey(): 检查集合对象是否包含给定 - HashMap, Hashtable, Properties, TreeMap containsValue(): 主键 ( 或给定值) - HashMap, Hashtable, Properties, TreeMap indexOf(): 若找到给定对象, 返回其位置 - ArrayList, linkedList, List, Stack, Vector search(): 线型搜素 - Stack // 集合转数组 toArray(); // 集合总结 Collection: Set - HashSet, TreeSet Collection: List - ArrayList, Vector, LinkedList Map: HashMap, HashTable, TreeMap ``` ## 泛型与 foreach ```java // 泛型 List myList = new ArrayList(); // foreach for (String s : myList) { System.out.println(s); } ``` ## 面向对象 ```java // toString() 格式化 public class ToStringWith { int x, y; public ToStringWith(int anX, int aY) { x = anX; y = aY; } public String toString() { return "ToStringWith[" + x + "," + y + "]"; } public static void main(String[] args) { System.out.println(new ToStringWith(43, 78)); } } // 覆盖 equals 方法 public boolean equals(Object o) { if (o == this) // 优化 return true; if (!(o instanceof EqualsDemo)) // 可投射到这个类 return false; EqualsDemo other = (EqualsDemo)o; // 类型转换 if (int1 != other.int1) // 按字段比较 return false; if (!obj1.equals(other.obj1)) return false; return true; } // 覆盖 hashcode 方法 private volatile int hashCode = 0; // 延迟初始化 public int hashCode() { if (hashCode == 0) { int result = 17; result = 37 * result + areaCode; } return hashCode; } // Clone 方法 要克隆对象, 必须先做两步: 1. 覆盖对象的 clone() 方法; 2. 实现空的 Cloneable 接口 public class Clone1 implements Cloneable { public Object clone() { return super.clone(); } } // Finalize 方法 Object f = new Object() { public void finalize() { System.out.println("Running finalize()"); } }; Runtime.getRuntime().addShutdownHook(new Thread() { public void run() { System.out.println("Running Shutdown Hook"); } }); 在调用 System.exit(0); 的时候, 这两个方法将被执行 // Singleton 模式 // 实现 1 public class MySingleton() { public static final MySingleton INSTANCE = new MySingleton(); private MySingleton(){} } // 实现 2 public class MySingleton() { public static MySingleton instance = new MySingleton(); private MySingleton(){} public static MySingleton getInstance() { return instance; } } // 自定义异常 Exception: 编译时检查 RuntimeException: 运行时检查 public class MyException extends RuntimeException { public MyException() { super(); } public MyException(String msg) { super(msg); } } ``` ## 输入和输出 ```java // Stream, Reader, Writer Stream: 处理字节流 Reader/Writer: 处理字符, 通用 Unicode // 从标准输入设备读数据 1. 用 System.in 的 BufferedInputStream() 读取字节 int b = System.in.read(); System.out.println("Read data: " + (char)b); // 强制转换为字符 2. BufferedReader 读取文本 如果从 Stream 转成 Reader, 使用 InputStreamReader 类 BufferedReader is = new BufferedReader(new InputStreamReader(System.in)); String inputLine; while ((inputLine = is.readLine()) != null) { System.out.println(inputLine); int val = Integer.parseInt(inputLine); // 如果 inputLine 为整数 } is.close(); // 向标准输出设备写数据 1. 用 System.out 的 println() 打印数据 2. 用 PrintWriter 打印 PrintWriter pw = new PrintWriter(System.out); pw.println("The answer is " + myAnswer + " at this time."); // Formatter 类 格式化打印内容 Formatter fmtr = new Formatter(); fmtr.format("%1$04d - the year of %2$f", 1951, Math.PI); 或者 System.out.printf(); 或者 System.out.format(); // 原始扫描 void doFile(Reader is) { int c; while ((c = is.read()) != -1) { System.out.println((char)c); } } // Scanner 扫描 Scanner 可以读取 File, InputStream, String, Readable try { Scanner scan = new Scanner(new File("a.txt")); while (scan.hasNext()) { String s = scan.next(); } } catch (FileNotFoundException e) { e.printStackTrace(); } } // 读取文件 BufferedReader is = new BufferedReader(new FileReader("myFile.txt")); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("bytes.bat")); is.close(); bos.close(); // 复制文件 BufferedIutputStream is = new BufferedIutputStream(new FileIutputStream("oldFile.txt")); BufferedOutputStream os = new BufferedOutputStream(new FileOutputStream("newFile.txt")); int b; while ((b = is.read()) != -1) { os.write(b); } is.close(); os.close(); // 文件读入字符串 StringBuffer sb = new StringBuffer(); char[] b = new char[8192]; int n; // 读一个块, 如果有字符, 加入缓冲区 while ((n = is.read(b)) > 0) { sb.append(b, 0, n); } return sb.toString(); // 重定向标准流 String logfile = "error.log"; System.setErr(new PrintStream(new FileOutputStream(logfile))); // 读写不同字符集文本 BufferedReader chinese = new BufferedReader(new InputStreamReader(new FileInputStream("chinese.txt"), "ISO8859_1")); PrintWriter standard = new PrintWriter(new OutputStreamWriter(new FileOutputStream("standard.txt"), "UTF-8")); // 读取二进制数据 DataOutputStream os = new DataOutputStream(new FileOutputStream("a.txt")); os.writeInt(i); os.writeDouble(d); os.close(); // 从指定位置读数据 RandomAccessFile raf = new RandomAccessFile(fileName, "r"); // r 表示已只读打开 raf.seek(15); // 从 15 开始读 raf.readInt(); raf.radLine(); // 串行化对象 对象串行化, 必须实现 Serializable 接口 // 保存数据到磁盘 ObjectOutputStream os = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(FILENAME))); os.writeObject(serialObject); os.close(); // 读出数据 ObjectInputStream is = new ObjectInputStream(new FileInputStream(FILENAME)); is.readObject(); is.close(); // 读写 Jar 或 Zip 文档 ZipFile zippy = new ZipFile("a.jar"); Enumeration all = zippy.entries(); // 枚举值列出所有文件清单 while (all.hasMoreElements()) { ZipEntry entry = (ZipEntry)all.nextElement(); if (entry.isFile()) println("Directory: " + entry.getName()); // 读写文件 FileOutputStream os = new FileOutputStream(entry.getName()); InputStream is = zippy.getInputStream(entry); int n = 0; byte[] b = new byte[8092]; while ((n = is.read(b)) > 0) { os.write(b, 0, n); is.close(); os.close(); } } // 读写 gzip 文档 FileInputStream fin = new FileInputStream(FILENAME); GZIPInputStream gzis = new GZIPInputStream(fin); InputStreamReader xover = new InputStreamReader(gzis); BufferedReader is = new BufferedReader(xover); String line; while ((line = is.readLine()) != null) System.out.println("Read: " + line); ``` ## 目录和文件操作 ```java // 获取文件信息 exists(): 如果文件存在, 返回 true getCanonicalPath(): 获取全名 getName(): 文件名 getParent(): 父目录 canRead(): 如果文件可读, 返回 true canWrite(): 如果文件可写, 返回 true lastModified(): 文件更新时间 length(): 文件大小 isFile(): 如果是文件, 返回 true ifDirectory(): 如果是目录, 返回 true 要调用文件的这些方法, 必须 File f = new File(fileName); // 创建文件 File f = new File("c:\\test\\mytest.txt"); f.createNewFile(); // 创建 mytest.txt 文件到 test 目录下 // 修改文件名 File f = new File("c:\\test\\mytest.txt"); f.renameTo(new File("c:\\test\\google.txt")); 把 mytest.txt 修改成 google.txt // 删除文件 File f = new File("c:\\test\\mytest.txt"); f.delete(); // 临时文件 File f = new File("C:\\test"); // 指定一个文件夹 // 在 test 文件夹中创建 foo 前缀, tmp 后缀的临时文件 File tmp = File.createTempFile("foo", "tmp", f); tmp.deleteOnExit(); // 在程序结束时删除该临时文件 // 更改文件属性 setReadOnly(): 设置为只读 setlastModified(): 设置最后更改时间 // 列出当前文件夹的文件列表 String[] dir = new java.io.File(".").list(); java.util.Arrays.sort(dir); for (int i = 0; i < dir.length; i++) { System.out.println(dir[i]); } // 过滤文件列表 class OnlyJava implements FilenameFilter { public boolean accept(File dir, String s) { if (s.endsWith(".java") || s.endsWith(".class") || s.endsWith(".jar")) return true; } } // 获取根目录 File[] rootDir = File.listRoots(); for (int i = 0; i < rootDir.length; i++) { System.out.println(rootDir[i]); } // 创建新目录 new File("/home/ian/bin").mkdir(); // 如果"/home/ian"存在, 则可以创建 bin 目录 new File("/home/ian/bin").mkdirs(); // 如果"/home/ian"不存在, 会创建所有的目录 ``` ## 国际化和本地化 ```java // I18N 资源 ResourceBundle rb = ResourceBundle.getBundle("Menus"); String label = rb.getString("exit.label"); // ResourceBundle 相当于名值对, 获取 Menus 按钮的区域属性 Menus_cn.properties: 不同区域的属性文件 // 列出有效区域 Locale[] list = Locale.getAvailableLocales(); // 指定区域 Locale cnLocale = Locale.CHINA; // 设置默认区域 Locale.setDefault(Locale.CHINA); // 格式化消息 public class MessageFormatDemo { static Object[] data = { new java.util.Date(), "myfile.txt", "could nto be opened" }; public static void main(String[] args) { String result = MessageFormat.format("At {0,time} on {0,date}, {1} {2}.", data); System.out.println(result); } } 输出: At 10:10:08 on 2009-6-18, myfile.txt could nto be opened. // 从资源文件中读消息 Widgets.properties 在 com.sean.cook.chap11 下 ResourceBundle rb = ResourceBundle.getBundle("com.sean.cook.chap11.Widgets"); String propt = rb.getString("filedialogs.cantopen.string"); String result = MessageFormat.format(rb.getString("filedialogs.cantopen.format"), data); ``` ## 网络客户端 ```java // 访问服务器 Socket socket = new Socket("127.0.0.1", 8080); // todo something socket.close(); // 查找网络地址 InetAddress.getByName(hostName).getHostAddress()); // 根据主机名得到 IP 地址 InetAddress.getByName(ipAddr).getHostName()); // 根据 IP 地址得到主机名 // 连接具体异常 UnknownHostException NoRouteToHostException ConnectException // Socket 读写文本数据 BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); String remoteTime = in.readline(); PrintWriter out = new PrintWriter(socket.getOutputStream(), true); out.print("send message to client \r\n"); out.flush(); // Socket 读写二进制数据 DataInputStream in = new DataInputStream(new BufferedInputStream(socket.getInputStream())); long remoteTime = (long)(in.readUnsignedByte() << 24); DataOutputStream out = new DataOutputStream(socket.getOutputStream(), true); // Socket 读写串行化数据 ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(socket.getInputStream())); Object o = in.readObject(); if (o instanceof Date) // 验证对象类型 ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream(), true); // UDP 数据报 private final static int PACKET_SIZE = 1024; String host = "EV001B389673DE"; InetAddress serverAddr = InetAddress.getByName(host); DatagramSocket socket = new DatagramSocket(); byte[] buffer = new byte[PACKET_SIZE]; // 分配数据缓冲空间 DatagramPacket packet = new DatagramPacket(buffer, PACKET_SIZE, serverAddr, 8080); packet.setLength(PACKET_SIZE-1); // 设置数据长度 socket.send(packet); socket.receive(packet); // 接收数据 ``` ## 服务器端: Socket ```java // 创建 ServerSocket ServerSocket serverSocket; Socket clientSocket; serverSocket = new ServerSocket(9999); while ((clientSocket = serverSocket.accept()) != null) { System.out.println("Accept from client " + s.getInetAddress()); s.close(); } // 监听内部网 public static final short PORT = 9999; public static final String INSIDE_HOST = "acmewidgets-inside"; // 网络接口名 public static final int BACKLOG = 10; // 待发数 serverSocket = new ServerSocket(PORT, BACKLOG, InetAddress.getByName(INSIDE_HOST)); // 返回相应对象 ServerSocket serverSocket = new ServerSocket(9999);; Socket clientSocket; BufferedReader in = null; PrintWriter out = null; while (true) { clientSocket = serverSocket.accept(); in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream(), "8859_1")); out = new PrintWriter(new OutputStreamWriter(clientSocket.getOutputStream(), "8859_1"), true); String echoLine; while ((echoLine = in.readLine()) != null) { System.out.println("Read " + echoLine); out.print(echoLine + "\r\n"); } } 以上例子返回字符串, 如果返回二进制, 则使用 DataOutputStream;返回对象, 使用 ObjectOutputStream // 处理多客户端 需要把接收数据的处理放入多线程中 public class EchoServerThreaded { public static final int ECHOPORT = 7; public static final int NUM_THREADS = 4; public static void main(String[] av) { new EchoServerThreaded(ECHOPORT, NUM_THREADS); } public EchoServerThreaded2(int port, int numThreads) { ServerSocket servSock; Socket clientSocket; try { servSock = new ServerSocket(ECHOPORT); } catch(IOException e) { throw new RuntimeException("Could not create ServerSocket " + e); } for (int i = 0; i < numThreads; i++) { new Handler(servSock, i).start(); } } } class Handler extends Thread { ServerSocket servSock; int threadNumber; Handler(ServerSocket s, int i) { super(); servSock = s; threadNumber = i; setName("Thread " + threadNumber); } public void run() { while (true) { try { System.out.println(getName() + " waiting"); Socket clientSocket; synchronized (servSock) { clientSocket = servSock.accept(); } System.out.println(getName() + " starting, IP=" + clientSocket.getInetAddress()); BufferedReader is = new BufferedReader(new InputStreamReader( clientSocket.getInputStream())); PrintStream os = new PrintStream(clientSocket.getOutputStream(), true); String line; while ((line = is.readLine()) != null) { os.print(line + "\r\n"); os.flush(); } System.out.println(getName() + " ENDED "); clientSocket.close(); } catch (IOException ex) { System.out.println(getName() + ": IO Error on socket " + ex); return; } } } } // 使用 SSL 和 JSSE 保护 Web 服务器 SSLServerSocketFactory ssf = (SSLServerSocketFactory)SSLServerSocketFactory.getDefault(); ServerSocket serverSocket = ssf.createServerSocket(8080); // Log4j Level 级别: DEBUG < INFO < WARN < ERROR < FATAL < OFF Appender: 输出信息 ConsoleAppender: 输出控制台 System.out // 找到网络接口 Enumeration list = NetworkInterface.getNetworkInterfaces(); while (list.hasMoreElements()) { NetworkInterface iface = (NetworkInterface)list.nextElement(); System.out.println(iface.getDisplayName()); Enumeration addrs = iface.getInetAddresses(); while (addrs.hasMoreElements()) { InetAddress addr = (InetAddress)addrs.nextElement(); System.out.println(addr); } } ``` ## Java Mail ```java // 发送 Mail protected String msgRecIp = "hxydream@163.com"; protected String msgSubject = "babytree"; protected String msgCc = "nobody@erewhon.com"; protected String msgBody = "test body"; protected Session session; protected Message msg; public void doSend() { // 创建属性文件 Properties props = new Properties(); props.put("mail.smtp.host", "mailhost"); // 创建 Session 对象 session = Session.getDefaultInstance(props, null); session.setDebug(true); msg = new MimeMessage(session); // 创建邮件 msg. setFrom(new InternetAddress("nobody@host.domain")); InternetAddress toAddr = new InternetAddress(msgRecIp); msg.addRecipient(Message.RecipientType.TO, toAddr); InternetAddress ccAddr = new InternetAddress(msgCc); msg.addRecipient(Message.RecipientType.CC, ccAddr); msg.setSubject(msgSubject); msg.setText(msgBody); Transport.send(msg); } // 发送 MIME 邮件 Multipart mp = new MimeMultipart(); BodyPart textPart = new MimeBodyPart(); textPart.setText(message_body); // 设置类型"text/plain" BodyPart pixPart = new MimeBodyPart(); pixPart.setContent(html_data, "text/html"); mp.addBodyPart(textPart); mp.addBodyPart(pixPart); mesg.setContent(mp); Transport.send(mesg); // 读 Mail Store store = session.getStore(protocol); store.connect(host, user, password); Folder rf; rf = store.getFolder(root); rf = store.getDefaultFolder(); rf.open(Folder.READ_WRITE); ``` ## 数据库访问 ```java // JDO Properties p = new Properties(); p.load(new FileInputStream("jdo.properties")); PersistenceManagerFactory pmf = JDOHelper.getPersistenceManagerFactory(p); PersistenceManager pm = pmf.getPersistenceManager(); // 提交数据 pm.currentTransaction().begin(); if (o instanceof Collection) { pm.makePersistentAll((Collection) o); } else { pm.makePersistent(o); } pm.currentTransaction().commit(); pm.close(); // 取出数据 Object[] data = new Object[3]; pm.retrieveAll(data); for (int i = 0; i < data.length; i++) { System.out.println(data[i]); } pm.close(); // 数据操作 Class clz = Class.forName("oracle.jdbc.driver.OracleDriver"); String dbUrl = "jdbc:oracle:thin:@192.168.0.23:1521#:nms"; Connection conn = DriverManager.getConnection(dbUrl, "su", "1234"); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("select * from pmtable"); while (rs.next()) { String name = rs.getString(1); String otherName = rs.getString("name"); } // 使用 PreparedStatement 提高性能, 除了查询, 都使用 executeUpdate 执行操作 PreparedStatement pstmt = conn.prepareStatement("select * from pmtable where name = ?"); pstmt.setString(1, "sean"); ResultSet rs = pstmt.executeQuery(); // 调用存储过程 CallableStatement cs = conn.prepareCall("{ call ListDefunctUsers }"); ResultSet rs = cs.executeQuery(); // 显示数据库表信息 DatabaseMetaData meta = conn.getMetaData(); meta.getDatabaseProductName(); meta.getDatabaseProductVersion(); meta.getDefaultTransactionIsolation(); ``` ## XML ```java /* SAX: 在读取文档提取相应的标记事件 (元素起始、元素结束、文档起始) DOM: 在内存中构造与文档中元素相应的树, 可以遍历、搜索、修改 DTD: 验证文档是否正确 JAXP: 用于 XML 处理的 Java API Castor: 开源项目, 用于 Java 对象与 XML 映射 */ // 从对象中生成 XML private final static String FILENAME = "serial.xml"; public static void main(String[] args) throws IOException { String a = "hard work and best callback"; new SerialDemoXML().write(a); new SerialDemoXML().dump(); } public void write(Object obj) throws IOException { XMLEncoder os = new XMLEncoder(new BufferedOutputStream(new FileOutputStream(FILENAME))); os.writeObject(obj); os.close(); } public void dump() throws IOException { XMLDecoder out = new XMLDecoder(new BufferedInputStream(new FileInputStream(FILENAME))); System.out.println(out.readObject()); out.close(); } serial.xml 格式内容如下: hard work and best callback 控制台输出 hard work and best callback // XSLT 转换 XML XSLT 可以用来对输出格式进行各种控制 Transformer tx = TransformerFactory.newInstance().newTransformer(new StreamSource("people.xml")); tx.transform(new StreamSource("people.xml"), new StreamResult("people.html")); // 用 SAX 解析 XML - 主要用于查找关键元素, 不用全文遍历 public SaxLister() throws SAXException, IOException { XMLReader parser = XMLReaderFactory.createXMLReader("org.apache.xerces.parsers.SAXParser"); parser.setContentHandler(new PeopleHandler()); parser.parse("C:\\StudySource\\javacooksrc2\\xml\\people.xml"); } class PeopleHandler extends DefaultHandler { boolean parent = false; boolean kids = false; public void startElement(String nsURI, String localName, String rawName, Attributes attr) throws SAXException { System.out.println("startElement: " + localName + "," + rawName); if (rawName.equalsIgnoreCase("name")) parent = true; if (rawName.equalsIgnoreCase("children")) kids = true; } public void characters(char[] ch, int start, int length) { if (parent) { System.out.println("Parent: " + new String(ch, start, length)); parent = false; } else if (kids) { System.out.println("Children: " + new String(ch, start, length)); kids = false; } } public PeopleHandler() throws SAXException { super(); } } // DOM 解析 XML - 遍历整个树 String uri = "file:" + new File("C:\\StudySource\\javacooksrc2\\xml\\people.xml").getAbsolutePath(); DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = factory.newDocumentBuilder(); Document doc = builder.parse(uri); NodeList nodes = doc.getChildNodes(); for (int i = 0; i < nodes.getLength(); i++) { Node n = nodes.item(i); switch (n.getNodeType()) { case Node.ELEMENT_NODE: // todo break; case Node.TEXT_NODE: // todo break; } } // 使用 DTD 或者 XSD 验证 定义好 DTD 或 XSD 文件 XmlDocument doc = XmlDocument.createXmlDocument(uri, true); // 用 DOM 生成 XML DocumentBuilderFactory fact = DocumentBuilderFactory.newInstance(); DocumentBuilder parser = fact.newDocumentBuilder(); Document doc = parser.newDocument(); Node root = doc.createElement("Poem"); doc.appendChild(root); Node stanza = doc.createElement("Stanza"); root.appendChild(stanza); Node line = doc.createElement("Line"); stanza.appendChild(line); line.appendChild(doc.createTextNode("Once, upon a midnight dreary")); line = doc.createElement("Line"); stanza.appendChild(line); line.appendChild(doc.createTextNode("While I pondered, weak and weary")); ``` ## RMI ```java a. 定义客户端与服务器之间的通信接口 public interface RemoteDate extends Remote { public Date getRemoteDate() throws RemoteException; public final static String LOOKUPNAME = "RemoteDate"; } b. 编写 RMI 服务器 public class RemoteDateImpl extends UnicastRemoteObject implements RemoteDate { public RemoteDateImpl() throws RemoteException { super(); } public Date getRemoteDate() throws RemoteException { return new Date(); } } RemoteDateImpl im = new RemoteDateImpl(); System.out.println("DateServer starting..."); Naming.rebind(RemoteDate.LOOKUPNAME, im); System.out.println("DateServer ready."); c. 运行 rmic 生成 stub javac RemoteDateImpl.java rmic RemoteDateImpl d. 编写客户端 netConn = (RemoteDate)Naming.lookup(RemoteDate.LOOKUPNAME); Date today = netConn.getRemoteDate(); System.out.println(today.toString()); e. 确保 RMI 注册表运行 rmiregistry f. 启动服务器 java RemoteDateImpl g. 运行客户端 java DateClient // 19. 包和包装机制 jar cvf /tmp/test.jar . // 当前目录压缩到 test.jar 中 jar xvf /tmp/test.jar // 把 test.jar 解压到当前目录 从指定 class 运行 jar 文件 a. Main-Class: HelloWord // 注意中间有一个空格 b. jar cvmf manifest.mf hello.jar HelloWorld.class c. java -jar hello.jar */ // 停止线程 - 不要使用 stop() 方法 private boolean done = false; public void run() { while (!done) { // todo } } public void shutDown() { done = true; } 可以调用 shutDown() 方法来结束线程 // 如果读取 IO 的时候出现堵塞, 那么可以使用下面方法 public void shutDown() throws IOException { if (io != null) io.close(); } // 启动一线程, 等待控制台输入, 使用 join() 方法来暂停当前线程, 直到其他线程调用 Thread t = new Thread() { public void run() { System.out.println("Reading"); try { System.in.read(); } catch (IOException e) { System.err.println(e); } System.out.println("Thread finished."); } }; System.out.println("Starting"); t.start(); System.out.println("Joining"); try { t.join(); } catch (InterruptedException e) { System.out.println("Who dares imterrupt my sleep?"); } System.out.println("Main finished."); // 加锁保证同步 Lock lock = new ReentrantLock(); try { lock.lock(); // todo } finally { lock.unlock(); } 线程通信 wait(), notify(), notifyAll() 生产者 - 消费者模式 Executors ``` ## Java 线程 ```java // 停止线程 - 不要使用 stop() 方法 private boolean done = false; public void run() { while (!done) { // todo } } public void shutDown() { done = true; } 可以调用 shutDown() 方法来结束线程 // 如果读取 IO 的时候出现堵塞, 那么可以使用下面方法 public void shutDown() throws IOException { if (io != null) io.close(); } // 启动一线程, 等待控制台输入, 使用 join() 方法来暂停当前线程, 直到其他线程调用 Thread t = new Thread() { public void run() { System.out.println("Reading"); try { System.in.read(); } catch (IOException e) { System.err.println(e); } System.out.println("Thread finished."); } }; System.out.println("Starting"); t.start(); System.out.println("Joining"); try { t.join(); } catch (InterruptedException e) { System.out.println("Who dares imterrupt my sleep?"); } System.out.println("Main finished."); // 加锁保证同步 Lock lock = new ReentrantLock(); try { lock.lock(); // todo } finally { lock.unlock(); } 线程通信 wait(), notify(), notifyAll() 生产者 - 消费者模式 Executors ``` ## 内省或“命令类的类” ```java // 反射 Class c = Class.forName("java.lang.String"); Constructor[] cons = c.getConstructors(); for (int i = 0; i < cons.length; i++) { System.out.println(cons[i].toString()); } Method[] meths = c.getMethods(); for (int i = 0; i < meths.length; i++) { System.out.println(meths[i].toString()); } // 动态装载类 Class c = Class.forName("java.lang.String"); Object obj = c.newInstance(); // 通过反射调用类的方法 class X { public void master(String s) { System.out.println("Working on \"" + s + "\""); } } Class clx = X.class; Class[] argTypes = {String.class}; Method worker = clx.getMethod("master", argTypes); Object[] theData = {"Chocolate chips"}; worker.invoke(new X(), theData); 输出: Working on "Chocolate chips" ``` ## Java 与其他语言的结合 ```java // 执行 CMD 命令, 在 Eclipse 控制台输出 Process p = Runtime.getRuntime().exec("C:/StudySource/ver.cmd"); p.waitFor(); // 等待命令执行完 BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream())); String s; while ((s = br.readLine()) != null) System.out.println(s); // 调用 Jython - 计算 22.0/7 BSFManager manager = new BSFManager(); String[] fntypes = {".py"}; manager.registerScriptingEngine("jython", "org.apache.bsf.engines.jython.JythonEngine", fntypes); Object r = manager.eval("jython", "testString", 0, 0, "22.0/7"); System.out.println("Result type is " + r.getClass().getName()); System.out.println("Result value is " + r); // 调用 C++, 使用 JNI ``` ## [Ja编程:知识点总结二](https://blog.dong4j.site/posts/569377f1.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## equals 的写法 ```java public boolean equals(Object o){ if(this == o) return true; if(o == null) return false; if(!o instanceof strudent) return false; student s = (student)o; if(s.name.equals(this.name) && s.age == this.age) return true; else return false; } ``` ## 说说 & 和 && 的区别. & 和 && 都可以用作逻辑与的运算符, 表示逻辑与(and), 当运算符两边的表达式的结果都为 true 时, 整个运算结果才为 true, 否则, 只要有一方为 false, 则结果为 false. && 还具有短路的功能, 即如果第一个表达式为 false, 则不再计算第二个表达式, 例如, 对于 if(str != null && !str.equals(“”)) 表达式, 当 str 为 null 时, 后面的表达式不会执行, 所以不会出现 NullPointerException 如果将 && 改为 &, 则会抛出 NullPointerException 异常. If(x==33 & ++y>0) y 会增长, If(x==33 && ++y>0) 不会增长 & 还可以用作位运算符, 当 & 操作符两边的表达式不是 boolean 类型时, & 表示按位与操作, 我们通常使用 0x0f 来与一个整数进行 & 运算, 来获取该整数的最低 4 个 bit 位, 例如, 0x31 & 0x0f 的结果为 0x01. 备注: 这道题先说两者的共同点, 再说出 && 和 & 的特殊之处, 并列举一些经典的例子来表明自己理解透彻深入、实际经验丰富. ## short s1 = 1; s1 = s1 + 1; 有什么错? short s1 = 1; s1 += 1; 有什么错? 对于 short s1 = 1; s1 = s1 + 1; 由于 s1+1 运算时会自动提升表达式的类型, 所以结果是 int 型, 再赋值给 short 类型 s1 时, 编译器将报告需要强制转换类型的错误. 对于 short s1 = 1; s1 += 1; 由于 += 是 java 语言规定的运算符, java 编译器会对它进行特殊处理, 因此可以正确编译. ## char 型变量中能不能存贮一个中文汉字? 为什么? char 型变量是用来存储 Unicode 编码的字符的, unicode 编码字符集中包含了汉字, 所以, char 型变量中当然可以存储汉字啦. 不过, 如果某个特殊的汉字没有被包含在 unicode 编码字符集中, 那么, 这个 char 型变量中就不能存储这个特殊汉字. 补充说明: unicode 编码占用两个字节, 所以, char 类型的变量也是占用两个字节. ## 使用 final 关键字修饰一个变量时, 是引用不能变, 还是引用的对象不能变? 使用 final 关键字修饰一个变量时, 是指引用变量不能变, 引用变量所指向的对象中的内容还是可以改变的. 例如, 对于如下语句: final StringBuffer a=new StringBuffer("immutable"); 执行如下语句将报告编译期错误: a=new StringBuffer(""); 但是, 执行如下语句则可以通过编译: a.append("broken!"); 有人在定义方法的参数时, 可能想采用如下形式来阻止方法内部修改传进来的参数对象: public void method(final StringBuffer param) { } 实际上, 这是办不到的, 在该方法内部仍然可以增加如下代码来修改参数对象: param.append("a"); ## "=="和 equals 方法究竟有什么区别? == 操作符专门用来比较两个变量的值是否相等, 也就是用于比较变量所对应的内存中所存储的数值是否相同, 要比较两个基本类型的数据或两个引用变量是否相等, 只能用 == 操作符. 如果一个变量指向的数据是对象类型的, 那么, 这时候涉及了两块内存, 对象本身占用一块内存(堆内存), 变量也占用一块内存, 例如 Objet obj = new Object(); 变量 obj 是一个内存, new Object() 是另一个内存, 此时, 变量 obj 所对应的内存中存储的数值就是对象占用的那块内存的首地址. 对于指向对象类型的变量, 如果要比较两个变量是否指向同一个对象, 即要看这两个变量所对应的内存中的数值是否相等, 这时候就需要用 == 操作符进行比较. equals 方法是用于比较两个独立对象的内容是否相同, 就好比去比较两个人的长相是否相同, 它比较的两个对象是独立的. 例如, 对于下面的代码: String a=new String("foo"); String b=new String("foo"); 两条 new 语句创建了两个对象, 然后用 a,b 这两个变量分别指向了其中一个对象, 这是两个不同的对象, 它们的首地址是不同的, 即 a 和 b 中存储的数值是不相同的, 所以, 表达式 a==b 将返回 false, 而这两个对象中的内容是相同的, 所以, 表达式 a.equals(b) 将返回 true. 在实际开发中, 我们经常要比较传递进行来的字符串内容是否等, 例如, String input = …;input.equals(“quit”), 许多人稍不注意就使用 == 进行比较了, 这是错误的, 随便从网上找几个项目实战的教学视频看看, 里面就有大量这样的错误. 记住, 字符串的比较基本上都是使用 equals 方法. 如果一个类没有自己定义 equals 方法, 那么它将继承 Object 类的 equals 方法, Object 类的 equals 方法的实现代码如下: boolean equals(Object o){ return this==o; } 这说明, 如果一个类没有自己定义 equals 方法, 它默认的 equals 方法(从 Object 类继承的)就是使用 == 操作符, 也是在比较两个变量指向的对象是否是同一对象, 这时候使用 equals 和使用 == 会得到同样的结果, 如果比较的是两个独立的对象则总返回 false. 如果你编写的类希望能够比较该类创建的两个实例对象的内容是否相同, 那么你必须覆盖 equals 方法, 由你自己写代码来决定在什么情况即可认为两个对象的内容是相同的. ## 静态变量和实例变量的区别? 在语法定义上的区别: 静态变量前要加 static 关键字, 而实例变量前则不加. 在程序运行时的区别: 实例变量属于某个对象的属性, 必须创建了实例对象, 其中的实例变量才会被分配空间, 才能使用这个实例变量. 静态变量不属于某个实例对象, 而是属于类, 所以也称为类变量, 只要程序加载了类的字节码, 不用创建任何实例对象, 静态变量就会被分配空间, 静态变量就可以被使用了. 总之, 实例变量必须创建对象后才可以通过这个对象来使用, 静态变量则可以直接使用类名来引用. 例如, 对于下面的程序, 无论创建多少个实例对象, 永远都只分配了一个 staticVar 变量, 并且每创建一个实例对象, 这个 staticVar 就会加 1;但是, 每创建一个实例对象, 就会分配一个 instanceVar, 即可能分配多个 instanceVar, 并且每个 instanceVar 的值都只自加了 1 次. ```java public class VariantTest{ public static int staticVar = 0; public int instanceVar = 0; public VariantTest(){ staticVar++; instanceVar++; System.out.println(“staticVar=” + staticVar + ”,instanceVar=” + instanceVar); } } ``` ## 是否可以从一个 static 方法内部发出对非 static 方法的调用? 不可以. 因为非 static 方法是要与对象关联在一起的, 必须创建一个对象后, 才可以在该对象上进行方法调用, 而 static 方法调用时不需要创建对象, 可以直接调用. 也就是说, 当一个 static 方法被调用时, 可能还没有创建任何实例对象, 如果从一个 static 方法中发出对非 static 方法的调用, 那个非 static 方法是关联到哪个对象上的呢?这个逻辑无法成立, 所以, 一个 static 方法内部发出对非 static 方法的调用. ## Math.round(11.5) 等於多少? Math.round(-11.5) 等於多少? Math 类中提供了三个与取整有关的方法: ceil、floor、round, 这些方法的作用与它们的英文名称的含义相对应, 例如, ceil 的英文意义是天花板, 该方法就表示向上取整, 所以, Math.ceil(11.3) 的结果为 12,Math.ceil(-11.3) 的结果是 -11;floor 的英文意义是地板, 该方法就表示向下取整, 所以, Math.floor(11.6) 的结果为 11,Math.floor(-11.6) 的结果是 -12;最难掌握的是 round 方法, 它表示“四舍五入”, 算法为 Math.floor(x+0.5), 即将原来的数字加上 0.5 后再向下取整, 所以, Math.round(11.5) 的结果为 12, Math.round(-11.5) 的结果为 -11. ## 类的加载机制 类初始化时机: 只有当对类的主动使用的时候才会导致类的初始化, 类的主动使用包括以下六种: - 创建类的实例, 也就是 new 的方式 - 访问某个类或接口的静态变量, 或者对该静态变量赋值 - 调用类的静态方法 - 反射(如 Class.forName(“com.shengsiyuan.Test”)) - 初始化某个类的子类, 则其父类也会被初始化 - Java 虚拟机启动时被标明为启动类的类(Java Test), 直接使用 java.exe 命令来运行某个主类 在如下几种情况下, Java 虚拟机将结束生命周期 - 执行了 System.exit() 方法 - 程序正常执行结束 - 程序在执行过程中遇到了异常或错误而异常终止 - 由于操作系统出现错误而导致 Java 虚拟机进程终止 **启动类加载器** Bootstrap ClassLoader, 负责加载存放在 `JDK\jre\lib`(JDK 代表 JDK 的安装目录, 下同) 下, 或被 -Xbootclasspath 参数指定的路径中的, 并且能被虚拟机识别的类库(如 rt.jar, 所有的 `java.*` 开头的类均被 Bootstrap ClassLoader 加载). 启动类加载器是无法被 Java 程序直接引用的. **扩展类加载器** Extension ClassLoader, 该加载器由 sun.misc.Launcher$ExtClassLoader 实现, 它负责加载 `JDK\jre\lib\ext` 目录中, 或者由 java.ext.dirs 系统变量指定的路径中的所有类库(如 `javax.*` 开头的类), 开发者可以直接使用扩展类加载器. **应用程序类加载器** Application ClassLoader, 该类加载器由 sun.misc.Launcher$AppClassLoader 来实现, 它负责加载用户类路径(ClassPath)所指定的类, 开发者可以直接使用该类加载器, 如果应用程序中没有自定义过自己的类加载器, 一般情况下这个就是程序中默认的类加载器. ### 双亲委派模型 双亲委派模型的工作流程是: 如果一个类加载器收到了类加载的请求, 它首先不会自己去尝试加载这个类, 而是把请求委托给父加载器去完成, 依次向上, 因此, 所有的类加载请求最终都应该被传递到顶层的启动类加载器中, 只有当父加载器在它的搜索范围中没有找到所需的类时, 即无法完成该加载, 子加载器才会尝试自己去加载该类. **双亲委派机制** 1. 当 AppClassLoader 加载一个 class 时, 它首先不会自己去尝试加载这个类, 而是把类加载请求委派给父类加载器 ExtClassLoader 去完成. 2. 当 ExtClassLoader 加载一个 class 时, 它首先也不会自己去尝试加载这个类, 而是把类加载请求委派给 BootStrapClassLoader 去完成. 3. 如果 BootStrapClassLoader 加载失败(例如在 $JAVA_HOME/jre/lib 里未查找到该 class), 会使用 ExtClassLoader 来尝试加载; 4. 若 ExtClassLoader 也加载失败, 则会使用 AppClassLoader 来加载, 如果 AppClassLoader 也加载失败, 则会报出异常 ClassNotFoundException. ```java public Class loadClass(String name)throws ClassNotFoundException { return loadClass(name, false); } protected synchronized Class loadClass(String name, boolean resolve)throws ClassNotFoundException { // 首先判断该类型是否已经被加载 Class c = findLoadedClass(name); if (c == null) { // 如果没有被加载, 就委托给父类加载或者委派给启动类加载器加载 try { if (parent != null) { // 如果存在父类加载器, 就委派给父类加载器加载 c = parent.loadClass(name, false); } else { // 如果不存在父类加载器, 就检查是否是由启动类加载器加载的类, 通过调用本地方法 native Class findBootstrapClass(String name) c = findBootstrapClass0(name); } } catch (ClassNotFoundException e) { // 如果父类加载器和启动类加载器都不能完成加载任务, 才调用自身的加载功能 c = findClass(name); } } if (resolve) { resolveClass(c); } return c; } ``` **双亲委派模型意义** - 系统类防止内存中出现多份同样的字节码 - 保证 Java 程序安全稳定运行 **自定义加载器** ```java package com.neo.classloader; import java.io.*; public class MyClassLoader extends ClassLoader { private String root; protected Class findClass(String name) throws ClassNotFoundException { byte[] classData = loadClassData(name); if (classData == null) { throw new ClassNotFoundException(); } else { return defineClass(name, classData, 0, classData.length); } } private byte[] loadClassData(String className) { String fileName = root + File.separatorChar + className.replace('.', File.separatorChar) + ".class"; try { InputStream ins = new FileInputStream(fileName); ByteArrayOutputStream baos = new ByteArrayOutputStream(); int bufferSize = 1024; byte[] buffer = new byte[bufferSize]; int length = 0; while ((length = ins.read(buffer)) != -1) { baos.write(buffer, 0, length); } return baos.toByteArray(); } catch (IOException e) { e.printStackTrace(); } return null; } public String getRoot() { return root; } public void setRoot(String root) { this.root = root; } public static void main(String[] args) { MyClassLoader classLoader = new MyClassLoader(); classLoader.setRoot("E:\\temp"); Class testClass = null; try { testClass = classLoader.loadClass("com.neo.classloader.Test2"); Object object = testClass.newInstance(); System.out.println(object.getClass().getClassLoader()); } catch (ClassNotFoundException e) { e.printStackTrace(); } catch (InstantiationException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } } } ``` ## 进程间的通信 1. 管道 (pipe) 2. 有名管道 (namedpipe) 3. 信号量 (semophore) 4. 消息队列 (messagequeue) 5. 信号 (sinal) 6. 共享内存 (shared memory) 7. 套接字 (socket) ## 线程间的通信 1. 锁机制: 包括互斥锁、条件变量、读写锁 - 互斥锁提供了以排他方式防止数据结构被并发修改的方法. - 读写锁允许多个线程同时读共享数据, 而对写操作是互斥的. - 条件变量可以以原子的方式阻塞进程, 直到某个特定条件为真为止. 对条件的测试是在互斥锁的保护下进行的. 条件变量始终与互斥锁一起使用. 2. 信号量机制 (Semaphore): 包括无名线程信号量和命名线程信号量 3. 信号机制 (Signal): 类似进程间的信号处理 线程间的通信目的主要是用于线程同步, 所以线程没有像进程通信中的用于数据交换的通信机制. ## 写 clone() 方法时, 通常都有一行代码, 是什么? clone 有缺省行为, `super.clone();` 因为首先要把父类中的成员复制到位, 然后才是复制自己的成员 ## 非静态内部类初始化方式 Outer outer = new Outer(); outer.inner inner = outer.new Inner(); ## 静态内部类初始化方式 Outer.Inner inner = new Outer.Inner(); ## String、StringBuffer 与 StringBuilder 之间区别 - StringBuilder > StringBuffer > String - StringBuilder: 线程非安全的 - StringBuffer: 线程安全的 - String 覆盖了 equals 方法和 hashCode 方法, 而 StringBuffer,StringBuilder 没有覆盖 equals 方法和 hashCode 方法, 所以, 将 StringBuffer,StringBuilder 对象存储进 Java 集合类中时会出现问题. ## length 和 length()和 size() 数组是 length 属性 字符串是 length() 方法 集合是 size() 方法 ## String s="a"+"b"+"c"+"d"; ```java String s1 = "a"; String s2 = s1 + "b"; String s3 = "a" + "b"; System.out.println(s2 == "ab"); // false System.out.println(s3 == "ab"); // true String s = "a" + "b" + "c" + "d"; // javac 编译可以对字符串常量直接相加的表达式进行优化, 不必要等到运行期去进行加法运算处理, // 而是在编译时去掉其中的加号, 直接将其编译成一个这些常量相连的结果. System.out.println(s == "abcd"); // true ``` ## final, finally, finalize 的区别. [final、finally 和 finalize 的区别是什么?](mweblib://14837192590620) ### final - final 关键字可以用于成员变量、本地变量、方法以及类. - final 成员变量必须在声明的时候初始化或者在构造器中初始化, 否则就会报编译错误. - 你不能够对 final 变量再次赋值. - 本地变量必须在声明时赋值. - 在匿名类中所有变量都必须是 final 变量. - final 方法不能被重写. - final 类不能被继承. - final 关键字不同于 finally 关键字, 后者用于异常处理. - final 关键字容易与 finalize() 方法搞混, 后者是在 Object 类中定义的方法, 是在垃圾回收之前被 JVM 调用的方法. - 接口中声明的所有变量本身是 final 的. - final 和 abstract 这两个关键字是反相关的, final 类就不可能是 abstract 的. - final 方法在编译阶段绑定, 称为静态绑定 (static binding). - 没有在声明时初始化 final 变量的称为空白 final 变量 (blank final variable), 它们必须在构造器中初始化, 或者调用 this() 初始化. 不这么做的话, 编译器会报错“final 变量 (变量名) 需要进行初始化”. - 将类、方法、变量声明为 final 能够提高性能, 这样 JVM 就有机会进行估计, 然后优化. - final 修饰的引用变量的指针不可变, 但是引用对象中的值是可以改变的 内存屏障问题 ### finally finally 是异常处理语句结构的一部分, 表示总是执行. **return 的先后问题** ```java public class Test { public static void main(String[] args) { System.out.println(test()); } private static int test() { int x = 1; try { return x; } finally { ++x; } } } ``` 返回 1 在执行到 return x 时, 已经将值返回, 放入到内存栈中, finally 只是执行了 +1 操作, 并没有改变内存栈中的值 ```java public class Test { public static void main(String[] args) { System.out.println(test()); } private static int test() { int x = 1; try { return x; } finally { return ++x; } } } ``` 返回 2 finally 保存程序会执行, 第一个 return 返回值, 放入内存栈中, 然后 finally 再次返回值, 覆盖原来的值 ```java public class Test1 { public static void main(String[] args) { // TODO Auto-generated method stub System.out.println(test()); } private static int test() { try { return func1(); } finally { return func2(); } } private static int func1() { System.out.println("func1"); return 1; } private static int func2() { System.out.println("func2"); return 2; } } ``` 返回结果 ``` func1 func2 2 ``` ### finalize finalize() 是 Object 类的一个方法, 在垃圾收集器执行的时候会调用被回收对象的此方法, 可以覆盖此方法提供垃圾收集时的其他资源回收, 例如关闭文件等. JVM 不保证此方法总被调用 并且 finalize() 只会被执行一次, 所以对象有可能被复活一次 ```java public class CanReliveObj { private static CanReliveObj obj; @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("CanReliveObj finalize called"); obj = this; } @Override public String toString() { return "I am CanReliveObj"; } public static void main(String[] args) throws InterruptedException { obj = new CanReliveObj(); obj = null; // 可复活 System.gc(); Thread.sleep(1000); if (obj == null) { System.out.println("obj 是 null"); } else { System.out.println("obj 可用"); } System.out.println("第二次gc"); obj = null; // 不可复活 System.gc(); Thread.sleep(1000); if (obj == null) { System.out.println("obj 是 null"); } else { System.out.println("obj 可用"); } } } ``` 返回结果 ``` CanReliveObj finalize called obj 可用 第二次 gc obj 是 null ``` ## stop()和 suspend() 方法为何不推荐使用? 反对使用 stop(), 是因为它不安全. > 它会解除由线程获取的所有锁定, 而且如果对象处于一种不连贯状态, 那么其他线程能在那种状态下检查和修改它们. 结果很难检查出真正的问题所在. suspend() 方法容易发生死锁. > 调用 suspend() 的时候, 目标线程会停下来, 但却仍然持有在这之前获得的锁定. > 此时, 其他任何线程都不能访问锁定的资源, 除非被"挂起"的线程恢复运行. > 对任何线程来说, 如果它们想恢复目标线程, 同时又试图使用任何一个锁定的资源, 就会造成死锁. 所以不应该使用 suspend(), 而应在自己的 Thread > 类中置入一个标志, 指出线程应该活动还是挂起. 若标志指出线程应该挂起, 便用 wait()命其进入等待状态. 若标志指出线程应当恢复, 则用一个 notify() > 重新启动线程. ## sleep()和 wait() 有什么区别? sleep 就是正在执行的线程主动让出 cpu, cpu 去执行其他线程, 在 sleep 指定的时间过后, cpu 才会回到这个线程上继续往下执行, 如果当前线程进入了同步锁, sleep 方法并不会释放锁, 即使当前线程使用 sleep 方法让出了 cpu, 但其他被同步锁挡住了的线程也无法得到执行. wait 是指在一个已经进入了同步锁的线程内, 让自己暂时让出同步锁, 以便其他正在等待此锁的线程可以得到同步锁并运行, 只有其他线程调用了 notify 方法(notify 并不释放锁, 只是告诉调用过 wait 方法的线程可以去参与获得锁的竞争了, 但不是马上得到锁, 因为锁还在别人手里, 别人还没释放. 如果 notify 方法后面的代码还有很多, 需要这些代码执行完后才会释放锁, 可以在 notfiy 方法后增加一个等待和一些代码, 看看效果), 调用 wait 方法的线程就会解除 wait 状态和程序可以再次得到锁后继续向下运行 ```java public class MultiThread { public static void main(String[] args) { new Thread(new Thread1()).start(); try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } new Thread(new Thread2()).start(); } private static class Thread1 implements Runnable { @Override public void run() { // 由于这里的 Thread1 和下面的 Thread2 内部 run 方法要用同一对象作为监视器, // 我们这里不能用 this, 因为在 Thread2 里面的 this 和这个 Thread1 的 this 不是同一个对象. 我们用 MultiThread.class 这个字节码对象, // 当前虚拟机里引用这个变量时, 指向的都是同一个对象. synchronized (MultiThread.class) { System.out.println("enter thread1..."); System.out.println("thread1 is waiting"); try { // 释放锁有两种方式, 第一种方式是程序自然离开监视器的范围, 也就是离开了 synchronized 关键字管辖的代码范围, // 另一种方式就是在 synchronized 关键字管辖的代码内部调用监视器对象的 wait 方法. 这里, 使用 wait 方法释放锁. MultiThread.class.wait(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("thread1 is going on..."); System.out.println("thread1 is being over!"); } } } private static class Thread2 implements Runnable { @Override public void run() { synchronized (MultiThread.class) { System.out.println("enter thread2..."); System.out.println("thread2 notify other thread can release wait status.."); // 由于 notify 方法并不释放锁, 即使 thread2 调用下面的 sleep 方法休息了 10 毫秒, // 但 thread1 仍然不会执行, 因为 thread2 没有释放锁, 所以 Thread1 无法得不到锁. MultiThread.class.notify(); System.out.println("thread2 is sleeping ten millisecond..."); try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("thread2 is going on..."); System.out.println("thread2 is being over!"); } } } } ``` ## 多线程有几种实现方法? 同步有几种实现方法? 多线程有两种实现方法, 分别是继承 Thread 类与实现 Runnable 接口 同步的实现方面有两种, 分别是 synchronized,wait 与 notify wait(): 使一个线程处于等待状态, 并且释放所持有的对象的 lock. sleep(): 使一个正在运行的线程处于睡眠状态, 是一个静态方法, 调用此方法要捕捉 InterruptedException 异常. notify(): 唤醒一个处于等待状态的线程, 注意的是在调用此方法的时候, 并不能确切的唤醒某一个等待状态的线程, 而是由 JVM 确定唤醒哪个线程, 而且不是按优先级. Allnotity(): 唤醒所有处入等待状态的线程, 注意并不是给所有唤醒线程一个对象的锁, 而是让它们竞争. ## 当一个线程进入一个对象的一个 synchronized 方法后, 其它线程是否可进入此对象的其它方法? 分几种情况: 1. 其他方法前是否加了 synchronized 关键字, 如果没加, 则能. 2. 如果这个方法内部调用了 wait, 则可以进入其他 synchronized 方法. 3. 如果其他个方法都加了 synchronized 关键字, 并且内部没有调用 wait, 则不能. 4. 如果其他方法是 static, 它用的同步锁是当前类的字节码, 与非静态的方法不能同步, 因为非静态的方法用的是 this. ## Java 锁的种类 1. 自旋锁 ```java public class SpinLock { private AtomicReference sign =new AtomicReference<>(); public void lock(){ Thread current = Thread.currentThread(); while(!sign.compareAndSet(null, current)){ } } public void unlock (){ Thread current = Thread.currentThread(); sign.compareAndSet(current, null); } } ``` 2. 自旋锁的其他种类 3. 阻塞锁 - synchronized 关键字(其中的重量锁) - ReentrantLock ```java Lock lock = new ReentrantLock(); lock.lock(); try { // update object state } finally { lock.unlock(); } ``` - `Object.wait()/notify()` - `LockSupport.park()/unpart()` 4. 可重入锁 - ReentrantLockß 5. 读写锁 6. 互斥锁 7. 悲观锁 8. 乐观锁 9. 公平锁 10. 非公平锁 11. 偏向锁 12. 对象锁 13. 线程锁 14. 锁粗化 15. 轻量级锁 16. 锁消除 17. 锁膨胀 18. 信号量 ## 简述 synchronized 和 java.util.concurrent.locks.Lock 的异同 ? 主要相同点: Lock 能完成 synchronized 所实现的所有功能 主要不同点: Lock 有比 synchronized 更精确的线程语义和更好的性能. synchronized 会自动释放锁, 而 Lock 一定要求程序员手工释放, 并且必须在 finally 从句中释放. Lock 还有更强大的功能, 例如, 它的 tryLock 方法可以非阻塞方式去拿锁. ```java import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class ThreadTest { private int j; private Lock lock = new ReentrantLock(); public static void main(String[] args) { ThreadTest tt = new ThreadTest(); for (int i = 0; i < 2; i++) { new Thread(tt.new Adder()).start(); new Thread(tt.new Subtractor()).start(); } } private class Subtractor implements Runnable { public void run() { while (true) { synchronized (ThreadTest.this) { System.out.println("j--=" + j--); } // lock.lock(); // try { // System.out.println("j--=" + j--); // } finally { // lock.unlock(); // } } } } private class Adder implements Runnable { public void run() { while (true) { synchronized (ThreadTest.this) { System.out.println("j++=" + j++); } // lock.lock(); // try { // System.out.println("j++=" + j++); // } finally { // lock.unlock(); // } } } } } ``` ## 设计 4 个线程, 其中两个线程每次对 j 增加 1, 另外两个线程对 j 每次减少 1. 写出程序. ```java public class ThreadTest1 { private int j; public static void main(String args[]) { ThreadTest1 tt = new ThreadTest1(); Inc inc = tt.new Inc(); Dec dec = tt.new Dec(); for (int i = 0; i < 2; i++) { Thread t = new Thread(inc); t.start(); t = new Thread(dec); t.start(); } } class Inc implements Runnable { public void run() { for (int i = 0; i < 2; i++) { inc(); } } } class Dec implements Runnable { public void run() { for (int i = 0; i < 2; i++) { dec(); } } } private synchronized void inc() { j++; System.out.println(Thread.currentThread().getName() + "-inc:" + j); } private synchronized void dec() { j--; System.out.println(Thread.currentThread().getName() + "-dec:" + j); } } ``` ## 子线程循环 2 次, 接着主线程循环 5 次, 接着又回到子线程循环 2 次, 接着再回到主线程又循环 5 次, 如此循环 5 次, 请写出程序. ```java public class ThreadTest2 { public static void main(String[] args) { new ThreadTest2().init(); } public void init() { final Business business = new Business(); new Thread( new Runnable() { public void run() { for (int i = 0; i < 5; i++) { // 执行子线程 business.subThread(i); } } } ).start(); for (int i = 0; i < 5; i++) { business.mainThread(i); } } private class Business { boolean flag = true;// 这里相当于定义了控制该谁执行的一个信号灯 // 主线程开始执行 由于 flag=false 直接输出 5 次 public synchronized void mainThread(int i) { if (flag) try { this.wait(); } catch (InterruptedException e) { e.printStackTrace(); } for (int j = 0; j < 5; j++) { System.out.println(Thread.currentThread().getName() + ":i=" + i + ",j=" + j); } flag = true; // 唤醒子线程 this.notify(); } // 多个线程执行此段代码时, 获得锁的才能进入执行 // 子线程先获得锁, 然后信号为 true, 执行输出 2 次, 最后信号, 唤醒其他线程 // 第二个线程进入时, 由于 flag= false 进入阻塞状态 public synchronized void subThread(int i) { if (!flag) { try { this.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } // 执行 2 次子线程 for (int j = 0; j < 2; j++) { System.out.println(Thread.currentThread().getName() + ":i=" + i + ",j=" + j); } // 子线程执行完了, 将信号设置为关闭 flag = false; // 唤醒另外的线程 this.notify(); } } } ``` ```java public class ThreadTest3 { private static boolean flag = false; public static void main(String[] args) { new Thread( new Runnable() { public void run() { for (int i = 0; i < 5; i++) { synchronized (ThreadTest3.class) { // 第二个子线程要等到主线程执行完一次后才能执行 if (flag) { try { ThreadTest3.class.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } for (int j = 0; j < 2; j++) { System.out.println( Thread.currentThread().getName() + "i=" + i + ",j=" + j); } flag = true; ThreadTest3.class.notify(); } } } } ).start(); for (int i = 0; i < 5; i++) { synchronized (ThreadTest3.class) { if (!flag) { try { ThreadTest3.class.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } for (int j = 0; j < 5; j++) { System.out.println( Thread.currentThread().getName() + "i=" + i + ",j=" + j); } flag = false; ThreadTest3.class.notify(); } } } } ``` ```java import java.util.concurrent.Executors; import java.util.concurrent.ExecutorService; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.Condition; public class ThreadTest4 { private static Lock lock = new ReentrantLock(); private static Condition subThreadCondition = lock.newCondition(); private static boolean flag = true; public static void main(String[] args) { ExecutorService threadPool = Executors.newFixedThreadPool(3); threadPool.execute(new Runnable() { public void run() { for (int i = 0; i < 5; i++) { // 获得锁 lock.lock(); try { if (!flag) subThreadCondition.await(); for (int j = 0; j < 2; j++) { System.out.println(Thread.currentThread().getName() + ",j=" + j); } flag = false; subThreadCondition.signal(); } catch (Exception e) { } finally { lock.unlock(); } } } }); threadPool.shutdown(); for (int i = 0; i < 5; i++) { lock.lock(); try { if (flag) subThreadCondition.await(); for (int j = 0; j < 5; j++) { System.out.println(Thread.currentThread().getName() + ",j=" + j); } flag = true; subThreadCondition.signal(); } catch (Exception e) { } finally { lock.unlock(); } } } } ``` ## 数据库驱动为什么要使用 Class.forName() 在 Java 开发特别是数据库开发中, 经常会用到 Class.forName()这个方法. 通过查询 Java Documentation 我们会发现使用 Class.forName() 静态方法的目的是为了动态加载类. 在加载完成后, 一般还要调用 Class 下的 newInstance() 静态方法来实例化对象以便操作. 因此, 单单使用 Class.forName() 是动态加载类是没有用的, 其最终目的是为了实例化对象. Class.forName("") 返回的是类 Class.forName("").newInstance() 返回的是 object 刚才提到, Class.forName(""); 的作用是要求 JVM 查找并加载指定的类, 如果在类中有静态初始化器的话, JVM 必然会执行该类的静态代码 段. 而在 JDBC 规范中明确要求这个 Driver 类必须向 DriverManager 注册自己, 即任何一个 JDBC Driver 的 Driver 类的代码都必须类似如下: public class MyJDBCDriver implements Driver {static {DriverManager.registerDriver(new MyJDBCDriver());}} 既然在静态初始化器的中已经进行了注册, 所以我们在使用 JDBC 时只需要 Class.forName(XXX.XXX); 就可以了. ``` we just want to load the driver to jvm only, but not need to user the instance of driver, so call Class.forName(xxx.xx.xx) is enough, if you call Class.forName(xxx.xx.xx).newInstance(), the result will same as calling Class.forName(xxx.xx.xx), because Class.forName(xxx.xx.xx).newInstance() will load driver first, and then create instance, but the instacne you will never use in usual, so you need not to create it. ``` 总结: jdbc 数据库驱动程序最终的目的, 是为了程序员能拿到数据库连接, 而进行 jdbc 规范的数据库操作. 拿到连接的过程是不需要你自己来实例化驱动程序的, 而是通过 DriverManger.getConnection(string str); . 因此一般情况下, 对于程序员来说, 除非特别需求, 是不会自己去实例化一个数据库驱动使用里面的方法的. ## [Java面试必知:HashMap原理与常见问题解答](https://blog.dong4j.site/posts/b944ce65.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## Java 集合 - Collection - List - LinkedList - ArrayList - CopyOnWriteArrayList - Vetor - Stack - Set - HashSet - LinkedHashSet - TreeSet - CopyOnWriteArraySet - Map - ConcurrentHashMap - ConcurrentShipListMap - EnumMap - HashMap - HashTable - LinkedHashMap - Properties - TreeMap - WeakHashMap ## 单线程集合 ### List #### ArrayList ![20241229154732_bANP5iIE.webp](https://cdn.dong4j.site/source/image/20241229154732_bANP5iIE.webp) - 底层基于泛型数组 - 它允许所有元素,包括 null - ArrayList 实际上是通过一个数组去保存数据的。当我们构造 ArrayList 时;若使用默认构造函数,则 ArrayList 的默认容量大小是 10。 - 当 ArrayList 容量不足以容纳全部元素时,ArrayList 会重新设置容量:新的容量 =“(原始容量 x3)/2 + 1”。 - ArrayList 的克隆函数,即是将全部元素克隆到一个数组中。 - ArrayList 实现 java.io.Serializable 的方式。当写入到输出流时,先写入“容量”,再依次写入“每一个元素”;当读出输入流时,先读取“容量”,再依次读取“每一个元素”。 ##### 遍历方式 使用迭代器遍历 ```java Integer value = null; Iterator iter = list.iterator(); while (iter.hasNext()) { value = (Integer)iter.next(); } ``` 使用 RandomAccess 提供的 get()方法遍历 ( 最快) ```java Integer value = null; int size = list.size(); for (int i=0; i list) { if (list == null) return ; // 记录开始时间 long start = System.currentTimeMillis(); for(Iterator iter = list.iterator(); iter.hasNext();) iter.next(); // 记录结束时间 long end = System.currentTimeMillis(); long interval = end - start; System.out.println("iteratorLinkedListThruIterator:" + interval+" ms"); } /** * 通过快速随机访问遍历 LinkedList */ private static void iteratorLinkedListThruForeach(LinkedList list) { if (list == null) return ; // 记录开始时间 long start = System.currentTimeMillis(); int size = list.size(); for (int i=0; i list) { if (list == null) return ; // 记录开始时间 long start = System.currentTimeMillis(); for (Integer integ:list) ; // 记录结束时间 long end = System.currentTimeMillis(); long interval = end - start; System.out.println("iteratorThroughFor2:" + interval+" ms"); } /** * 通过 pollFirst() 来遍历 LinkedList */ private static void iteratorThroughPollFirst(LinkedList list) { if (list == null) return ; // 记录开始时间 long start = System.currentTimeMillis(); while(list.pollFirst() != null) ; // 记录结束时间 long end = System.currentTimeMillis(); long interval = end - start; System.out.println("iteratorThroughPollFirst:" + interval+" ms"); } /** * 通过 pollLast() 来遍历 LinkedList */ private static void iteratorThroughPollLast(LinkedList list) { if (list == null) return ; // 记录开始时间 long start = System.currentTimeMillis(); while(list.pollLast() != null) ; // 记录结束时间 long end = System.currentTimeMillis(); long interval = end - start; System.out.println("iteratorThroughPollLast:" + interval+" ms"); } /** * 通过 removeFirst() 来遍历 LinkedList */ private static void iteratorThroughRemoveFirst(LinkedList list) { if (list == null) return ; // 记录开始时间 long start = System.currentTimeMillis(); try { while(list.removeFirst() != null) ; } catch (NoSuchElementException e) { } // 记录结束时间 long end = System.currentTimeMillis(); long interval = end - start; System.out.println("iteratorThroughRemoveFirst:" + interval+" ms"); } /** * 通过 removeLast() 来遍历 LinkedList */ private static void iteratorThroughRemoveLast(LinkedList list) { if (list == null) return ; // 记录开始时间 long start = System.currentTimeMillis(); try { while(list.removeLast() != null) ; } catch (NoSuchElementException e) { } // 记录结束时间 long end = System.currentTimeMillis(); long interval = end - start; System.out.println("iteratorThroughRemoveLast:" + interval+" ms"); } } ``` 返回结果 ```java iteratorLinkedListThruIterator:8 ms iteratorLinkedListThruForeach:3724 ms iteratorThroughFor2:5 ms iteratorThroughPollFirst:8 ms iteratorThroughPollLast:6 ms iteratorThroughRemoveFirst:2 ms iteratorThroughRemoveLast:2 ms ``` - 采用 ArrayList 对随机访问比较快,而 for 循环中的 get() 方法,采用的即是随机访问的方法,因此在 ArrayList 里,for 循环较快 - 采用 LinkedList 则是顺序访问比较快,iterator 中的 next() 方法,采用的即是顺序访问的方法,因此在 LinkedList 里,使用 iterator 较快 - 从数据结构角度分析,for 循环适合访问顺序结构, 可以根据下标快速获取指定元素. 而 Iterator 适合访问链式结构, 因为迭代器是通过 next()和 Pre() 来定位的. 可以访问没有顺序的集合. #### Vector ![20241229154732_61WSEF2R.webp](https://cdn.dong4j.site/source/image/20241229154732_61WSEF2R.webp) - 线程安全 - Vector 实际上是通过一个数组去保存数据的。当我们构造 Vecotr 时;若使用默认构造函数,则 Vector 的默认容量大小是 10。 - 当 Vector 容量不足以容纳全部元素时,Vector 的容量会增加。若容量增加系数 >0,则将容量的值增加“容量增加系数”;否则,将容量大小增加一倍。 - Vector 的克隆函数,即是将全部元素克隆到一个数组中。 ##### 遍历方式 ```java import java.util.*; /* * @desc Vector 遍历方式和效率的测试程序。 * * @author skywang */ public class VectorRandomAccessTest { public static void main(String[] args) { Vector vec= new Vector(); for (int i=0; i<100000; i++) vec.add(i); iteratorThroughRandomAccess(vec) ; iteratorThroughIterator(vec) ; iteratorThroughFor2(vec) ; iteratorThroughEnumeration(vec) ; } private static void isRandomAccessSupported(List list) { if (list instanceof RandomAccess) { System.out.println("RandomAccess implemented!"); } else { System.out.println("RandomAccess not implemented!"); } } public static void iteratorThroughRandomAccess(List list) { long startTime; long endTime; startTime = System.currentTimeMillis(); for (int i=0; i LinkedList: 通过 add(int index, E element) 向 LinkedList 插入元素时。先是在双向链表中找到要插入节点的位置 index;找到之后,再插入一个新节点。 > 双向链表查找 index 位置的节点时,有一个加速动作:若 index < 双向链表长度的 1/2,则从前向后查找; 否则,从后向前查找。 > ArrayList: ensureCapacity(size+1) 的作用是“确认 ArrayList 的容量,若容量不够,则增加容量。” > 真正耗时的操作是 System.arraycopy(elementData, index, elementData, index + 1, size - index); > System.arraycopy(elementData, index, elementData, index + 1, size - index); 会移动 index 之后所有元素即可。这就意味着,ArrayList 的 add(int > index, E element) 函数,会引起 index 之后所有元素的改变! **LinkedList 中随机访问很慢,而 ArrayList 中随机访问很快** > LinkedList: 通过 get(int index) 获取 LinkedList 第 index 个元素时。先是在双向链表中找到要 index 位置的元素;找到之后再返回。 > 双向链表查找 index 位置的节点时,有一个加速动作:若 index < 双向链表长度的 1/2,则从前向后查找; 否则,从后向前查找。 > ArrayList: 通过 get(int index) 获取 ArrayList 第 index 个元素时。直接返回数组中 index 位置的元素,而不需要像 LinkedList 一样进行查找。 ##### Vector 和 ArrayList 比较 相同之处: - 它们都继承于 AbstractList,并且实现 List 接口。 ```java // ArrayList 的定义 public class ArrayList extends AbstractList implements List, RandomAccess, Cloneable, java.io.Serializable // Vector 的定义 public class Vector extends AbstractList implements List, RandomAccess, Cloneable, java.io.Serializable {} ```` - 它们都实现了 RandomAccess 和 Cloneable 接口 - 它们都是通过数组实现的,本质上都是动态数组 - 它们的默认数组容量是 10 - 它们都支持 Iterator 和 listIterator 遍历 不同之处: - 线程安全性不一样,ArrayList 适用于单线程,Vector 适用于多线程 - 构造函数个数不同 - 容量增加方式不同 - 逐个添加元素时,若 ArrayList 容量不足时,“新的容量”=“(原始容量 x3)/2 + 1”。 - Vector - 增长系数 > 0 时 :“新的容量”=“原始容量 + 增长系数” - 增长系数 <= 0 时: “新的容量”=“原始容量 x 2” - 对 Enumeration 的支持不同。Vector 支持通过 Enumeration 去遍历,而 List 不支持 ### Map - Map 是映射接口,Map 中存储的内容是键值对 (key-value)。 - AbstractMap 是继承于 Map 的抽象类,它实现了 Map 中的大部分 API。其它 Map 的实现类可以通过继承 AbstractMap 来减少重复编码。 - SortedMap 是继承于 Map 的接口。SortedMap 中的内容是排序的键值对,排序的方法是通过比较器 (Comparator)。 - NavigableMap 是继承于 SortedMap 的接口。相比于 SortedMap,NavigableMap 有一系列的导航方法;如"获取大于/等于某对象的键值对"、“获取小于 / 等于某对象的键值对”等等。 - TreeMap 继承于 AbstractMap,且实现了 NavigableMap 接口;因此,TreeMap 中的内容是“有序的键值对”! - HashMap 继承于 AbstractMap,但没实现 NavigableMap 接口;因此,HashMap 的内容是“键值对,但不保证次序”! - Hashtable 虽然不是继承于 AbstractMap,但它继承于 Dictionary(Dictionary 也是键值对的接口),而且也实现 Map 接口;因此,Hashtable 的内容也是“键值对,也不保证次序”。但和 HashMap 相比,Hashtable 是线程安全的,而且它支持通过 Enumeration 去遍历。 - WeakHashMap 继承于 AbstractMap。它和 HashMap 的键类型不同,WeakHashMap 的键是“弱键”。 在 JDK1.6 中, 新添加的节点是放在头节点, JDK1.8 则是放在尾节点的 JDK1.8 中, 如果链表足够大时, 将自动转换成红黑树保存 #### API ```java abstract void clear() abstract boolean containsKey(Object key) abstract boolean containsValue(Object value) abstract Set> entrySet() abstract boolean equals(Object object) abstract V get(Object key) abstract int hashCode() abstract boolean isEmpty() abstract Set keySet() abstract V put(K key, V value) abstract void putAll(Map map) abstract V remove(Object key) abstract int size() abstract Collection values() ```` 说明: - Map 提供接口分别用于返回 键集、值集或键 - 值映射关系集。 - entrySet() 用于返回键 - 值集的 Set 集合 - keySet() 用于返回键集的 Set 集合 - values() 用户返回值集的 Collection 集合 - 因为 Map 中不能包含重复的键;每个键最多只能映射到一个值。所以,键 - 值集、键集都是 Set,值集时 Collection。 - Map 提供了“键 - 值对”、“根据键获取值”、“删除键”、“获取容量大小”等方法。 #### HashMap HashMap 是一个散列表,它存储的内容是键值对 (key-value) 映射。 HashMap 继承于 AbstractMap,实现了 Map、Cloneable、java.io.Serializable 接口。 HashMap 的实现不是同步的,这意味着它不是线程安全的。它的 key、value 都可以为 null。此外,HashMap 中的映射不是有序的。 HashMap 的实例有两个参数影响其性能:“初始容量” 和 “加载因子”。容量 是哈希表中桶的数量,初始容量 只是哈希表在创建时的容量。加载因子 是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。 通常,默认加载因子是 0.75, 这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。 ![20241229154732_a4vnHC05.webp](https://cdn.dong4j.site/source/image/20241229154732_a4vnHC05.webp) ```java package java.util; import java.io.*; public class HashMap extends AbstractMap implements Map, Cloneable, Serializable { // 默认的初始容量是 16,必须是 2 的幂。 static final int DEFAULT_INITIAL_CAPACITY = 16; // 最大容量(必须是 2 的幂且小于 2 的 30 次方,传入容量过大将被这个值替换) static final int MAXIMUM_CAPACITY = 1 << 30; // 默认加载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 存储数据的 Entry 数组,长度是 2 的幂。 // HashMap 是采用拉链法实现的,每一个 Entry 本质上是一个单向链表 transient Entry[] table; // HashMap 的大小,它是 HashMap 保存的键值对的数量 transient int size; // HashMap 的阈值,用于判断是否需要调整 HashMap 的容量(threshold = 容量 * 加载因子) int threshold; // 加载因子实际大小 final float loadFactor; // HashMap 被改变的次数 transient volatile int modCount; // 指定“容量大小”和“加载因子”的构造函数 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); // HashMap 的最大容量只能是 MAXIMUM_CAPACITY if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); // 找出“大于 initialCapacity”的最小的 2 的幂 int capacity = 1; while (capacity < initialCapacity) capacity <<= 1; // 设置“加载因子” this.loadFactor = loadFactor; // 设置“HashMap 阈值”,当 HashMap 中存储数据的数量达到 threshold 时,就需要将 HashMap 的容量加倍。 threshold = (int)(capacity * loadFactor); // 创建 Entry 数组,用来保存数据 table = new Entry[capacity]; init(); } // 指定“容量大小”的构造函数 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } // 默认构造函数。 public HashMap() { // 设置“加载因子” this.loadFactor = DEFAULT_LOAD_FACTOR; // 设置“HashMap 阈值”,当 HashMap 中存储数据的数量达到 threshold 时,就需要将 HashMap 的容量加倍。 threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR); // 创建 Entry 数组,用来保存数据 table = new Entry[DEFAULT_INITIAL_CAPACITY]; init(); } // 包含“子 Map”的构造函数 public HashMap(Map m) { this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR); // 将 m 中的全部元素逐个添加到 HashMap 中 putAllForCreate(m); } static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } // 返回索引值 // h & (length-1) 保证返回值的小于 length static int indexFor(int h, int length) { return h & (length-1); } public int size() { return size; } public boolean isEmpty() { return size == 0; } // 获取 key 对应的 value public V get(Object key) { if (key == null) return getForNullKey(); // 获取 key 的 hash 值 int hash = hash(key.hashCode()); // 在“该 hash 值对应的链表”上查找“键值等于 key”的元素 for (Entry e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) return e.value; } return null; } // 获取“key 为 null”的元素的值 // HashMap 将“key 为 null”的元素存储在 table[0] 位置! private V getForNullKey() { for (Entry e = table[0]; e != null; e = e.next) { if (e.key == null) return e.value; } return null; } // HashMap 是否包含 key public boolean containsKey(Object key) { return getEntry(key) != null; } // 返回“键为 key”的键值对 final Entry getEntry(Object key) { // 获取哈希值 // HashMap 将“key 为 null”的元素存储在 table[0] 位置,“key 不为 null”的则调用 hash() 计算哈希值 int hash = (key == null) ? 0 : hash(key.hashCode()); // 在“该 hash 值对应的链表”上查找“键值等于 key”的元素 for (Entry e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; } // 将“key-value”添加到 HashMap 中 public V put(K key, V value) { // 若“key 为 null”,则将该键值对添加到 table[0] 中。 if (key == null) return putForNullKey(value); // 若“key 不为 null”,则计算该 key 的哈希值,然后将其添加到该哈希值对应的链表中。 int hash = hash(key.hashCode()); int i = indexFor(hash, table.length); for (Entry e = table[i]; e != null; e = e.next) { Object k; // 若“该 key”对应的键值对已经存在,则用新的 value 取代旧的 value。然后退出! if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } // 若“该 key”对应的键值对不存在,则将“key-value”添加到 table 中 modCount++; addEntry(hash, key, value, i); return null; } // putForNullKey()的作用是将“key 为 null”键值对添加到 table[0] 位置 private V putForNullKey(V value) { for (Entry e = table[0]; e != null; e = e.next) { if (e.key == null) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } // 这里的完全不会被执行到! modCount++; addEntry(0, null, value, 0); return null; } // 创建 HashMap 对应的“添加方法”, // 它和 put()不同。putForCreate() 是内部方法,它被构造函数等调用,用来创建 HashMap // 而 put() 是对外提供的往 HashMap 中添加元素的方法。 private void putForCreate(K key, V value) { int hash = (key == null) ? 0 : hash(key.hashCode()); int i = indexFor(hash, table.length); // 若该 HashMap 表中存在“键值等于 key”的元素,则替换该元素的 value 值 for (Entry e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { e.value = value; return; } } // 若该 HashMap 表中不存在“键值等于 key”的元素,则将该 key-value 添加到 HashMap 中 createEntry(hash, key, value, i); } // 将“m”中的全部元素都添加到 HashMap 中。 // 该方法被内部的构造 HashMap 的方法所调用。 private void putAllForCreate(Map m) { // 利用迭代器将元素逐个添加到 HashMap 中 for (Iterator> i = m.entrySet().iterator(); i.hasNext(); ) { Map.Entry e = i.next(); putForCreate(e.getKey(), e.getValue()); } } // 重新调整 HashMap 的大小,newCapacity 是调整后的单位 void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } // 新建一个 HashMap,将“旧 HashMap”的全部元素添加到“新 HashMap”中, // 然后,将“新 HashMap”赋值给“旧 HashMap”。 Entry[] newTable = new Entry[newCapacity]; transfer(newTable); table = newTable; threshold = (int)(newCapacity * loadFactor); } // 将 HashMap 中的全部元素都添加到 newTable 中 void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { Entry e = src[j]; if (e != null) { src[j] = null; do { Entry next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null); } } } // 将"m"的全部元素都添加到 HashMap 中 public void putAll(Map m) { // 有效性判断 int numKeysToBeAdded = m.size(); if (numKeysToBeAdded == 0) return; // 计算容量是否足够, // 若“当前实际容量 < 需要的容量”,则将容量 x2。 if (numKeysToBeAdded > threshold) { int targetCapacity = (int)(numKeysToBeAdded / loadFactor + 1); if (targetCapacity > MAXIMUM_CAPACITY) targetCapacity = MAXIMUM_CAPACITY; int newCapacity = table.length; while (newCapacity < targetCapacity) newCapacity <<= 1; if (newCapacity > table.length) resize(newCapacity); } // 通过迭代器,将“m”中的元素逐个添加到 HashMap 中。 for (Iterator> i = m.entrySet().iterator(); i.hasNext(); ) { Map.Entry e = i.next(); put(e.getKey(), e.getValue()); } } // 删除“键为 key”元素 public V remove(Object key) { Entry e = removeEntryForKey(key); return (e == null ? null : e.value); } // 删除“键为 key”的元素 final Entry removeEntryForKey(Object key) { // 获取哈希值。若 key 为 null,则哈希值为 0;否则调用 hash() 进行计算 int hash = (key == null) ? 0 : hash(key.hashCode()); int i = indexFor(hash, table.length); Entry prev = table[i]; Entry e = prev; // 删除链表中“键为 key”的元素 // 本质是“删除单向链表中的节点” while (e != null) { Entry next = e.next; Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { modCount++; size--; if (prev == e) table[i] = next; else prev.next = next; e.recordRemoval(this); return e; } prev = e; e = next; } return e; } // 删除“键值对” final Entry removeMapping(Object o) { if (!(o instanceof Map.Entry)) return null; Map.Entry entry = (Map.Entry) o; Object key = entry.getKey(); int hash = (key == null) ? 0 : hash(key.hashCode()); int i = indexFor(hash, table.length); Entry prev = table[i]; Entry e = prev; // 删除链表中的“键值对 e” // 本质是“删除单向链表中的节点” while (e != null) { Entry next = e.next; if (e.hash == hash && e.equals(entry)) { modCount++; size--; if (prev == e) table[i] = next; else prev.next = next; e.recordRemoval(this); return e; } prev = e; e = next; } return e; } // 清空 HashMap,将所有的元素设为 null public void clear() { modCount++; Entry[] tab = table; for (int i = 0; i < tab.length; i++) tab[i] = null; size = 0; } // 是否包含“值为 value”的元素 public boolean containsValue(Object value) { // 若“value 为 null”,则调用 containsNullValue() 查找 if (value == null) return containsNullValue(); // 若“value 不为 null”,则查找 HashMap 中是否有值为 value 的节点。 Entry[] tab = table; for (int i = 0; i < tab.length ; i++) for (Entry e = tab[i] ; e != null ; e = e.next) if (value.equals(e.value)) return true; return false; } // 是否包含 null 值 private boolean containsNullValue() { Entry[] tab = table; for (int i = 0; i < tab.length ; i++) for (Entry e = tab[i] ; e != null ; e = e.next) if (e.value == null) return true; return false; } // 克隆一个 HashMap,并返回 Object 对象 public Object clone() { HashMap result = null; try { result = (HashMap)super.clone(); } catch (CloneNotSupportedException e) { // assert false; } result.table = new Entry[table.length]; result.entrySet = null; result.modCount = 0; result.size = 0; result.init(); // 调用 putAllForCreate() 将全部元素添加到 HashMap 中 result.putAllForCreate(this); return result; } // Entry 是单向链表。 // 它是 “HashMap 链式存储法”对应的链表。 // 它实现了 Map.Entry 接口,即实现 getKey(), getValue(), setValue(V value), equals(Object o), hashCode() 这些函数 static class Entry implements Map.Entry { final K key; V value; // 指向下一个节点 Entry next; final int hash; // 构造函数。 // 输入参数包括"哈希值(h)", "键(k)", "值(v)", "下一节点(n)" Entry(int h, K k, V v, Entry n) { value = v; next = n; key = k; hash = h; } public final K getKey() { return key; } public final V getValue() { return value; } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } // 判断两个 Entry 是否相等 // 若两个 Entry 的“key”和“value”都相等,则返回 true。 // 否则,返回 false public final boolean equals(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry e = (Map.Entry)o; Object k1 = getKey(); Object k2 = e.getKey(); if (k1 == k2 || (k1 != null && k1.equals(k2))) { Object v1 = getValue(); Object v2 = e.getValue(); if (v1 == v2 || (v1 != null && v1.equals(v2))) return true; } return false; } // 实现 hashCode() public final int hashCode() { return (key==null ? 0 : key.hashCode()) ^ (value==null ? 0 : value.hashCode()); } public final String toString() { return getKey() + "=" + getValue(); } // 当向 HashMap 中添加元素时,绘调用 recordAccess()。 // 这里不做任何处理 void recordAccess(HashMap m) { } // 当从 HashMap 中删除元素时,绘调用 recordRemoval()。 // 这里不做任何处理 void recordRemoval(HashMap m) { } } // 新增 Entry。将“key-value”插入指定位置,bucketIndex 是位置索引。 void addEntry(int hash, K key, V value, int bucketIndex) { // 保存“bucketIndex”位置的值到“e”中 Entry e = table[bucketIndex]; // 设置“bucketIndex”位置的元素为“新 Entry”, // 设置“e”为“新 Entry 的下一个节点” table[bucketIndex] = new Entry(hash, key, value, e); // 若 HashMap 的实际大小 不小于 “阈值”,则调整 HashMap 的大小 if (size++ >= threshold) resize(2 * table.length); } // 创建 Entry。将“key-value”插入指定位置,bucketIndex 是位置索引。 // 它和 addEntry 的区别是: // (01) addEntry() 一般用在 新增 Entry 可能导致“HashMap 的实际容量”超过“阈值”的情况下。 // 例如,我们新建一个 HashMap,然后不断通过 put() 向 HashMap 中添加元素; // put()是通过 addEntry() 新增 Entry 的。 // 在这种情况下,我们不知道何时“HashMap 的实际容量”会超过“阈值”; // 因此,需要调用 addEntry() // (02) createEntry() 一般用在 新增 Entry 不会导致“HashMap 的实际容量”超过“阈值”的情况下。 // 例如,我们调用 HashMap“带有 Map”的构造函数,它绘将 Map 的全部元素添加到 HashMap 中; // 但在添加之前,我们已经计算好“HashMap 的容量和阈值”。也就是,可以确定“即使将 Map 中 // 的全部元素添加到 HashMap 中,都不会超过 HashMap 的阈值”。 // 此时,调用 createEntry() 即可。 void createEntry(int hash, K key, V value, int bucketIndex) { // 保存“bucketIndex”位置的值到“e”中 Entry e = table[bucketIndex]; // 设置“bucketIndex”位置的元素为“新 Entry”, // 设置“e”为“新 Entry 的下一个节点” table[bucketIndex] = new Entry(hash, key, value, e); size++; } // HashIterator 是 HashMap 迭代器的抽象出来的父类,实现了公共了函数。 // 它包含“key 迭代器 (KeyIterator)”、“Value 迭代器 (ValueIterator)”和“Entry 迭代器 (EntryIterator)”3 个子类。 private abstract class HashIterator implements Iterator { // 下一个元素 Entry next; // expectedModCount 用于实现 fast-fail 机制。 int expectedModCount; // 当前索引 int index; // 当前元素 Entry current; HashIterator() { expectedModCount = modCount; if (size > 0) { // advance to first entry Entry[] t = table; // 将 next 指向 table 中第一个不为 null 的元素。 // 这里利用了 index 的初始值为 0,从 0 开始依次向后遍历,直到找到不为 null 的元素就退出循环。 while (index < t.length && (next = t[index++]) == null) ; } } public final boolean hasNext() { return next != null; } // 获取下一个元素 final Entry nextEntry() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); Entry e = next; if (e == null) throw new NoSuchElementException(); // 注意!!! // 一个 Entry 就是一个单向链表 // 若该 Entry 的下一个节点不为空,就将 next 指向下一个节点; // 否则,将 next 指向下一个链表 (也是下一个 Entry) 的不为 null 的节点。 if ((next = e.next) == null) { Entry[] t = table; while (index < t.length && (next = t[index++]) == null) ; } current = e; return e; } // 删除当前元素 public void remove() { if (current == null) throw new IllegalStateException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); Object k = current.key; current = null; HashMap.this.removeEntryForKey(k); expectedModCount = modCount; } } // value 的迭代器 private final class ValueIterator extends HashIterator { public V next() { return nextEntry().value; } } // key 的迭代器 private final class KeyIterator extends HashIterator { public K next() { return nextEntry().getKey(); } } // Entry 的迭代器 private final class EntryIterator extends HashIterator> { public Map.Entry next() { return nextEntry(); } } // 返回一个“key 迭代器” Iterator newKeyIterator() { return new KeyIterator(); } // 返回一个“value 迭代器” Iterator newValueIterator() { return new ValueIterator(); } // 返回一个“entry 迭代器” Iterator> newEntryIterator() { return new EntryIterator(); } // HashMap 的 Entry 对应的集合 private transient Set> entrySet = null; // 返回“key 的集合”,实际上返回一个“KeySet 对象” public Set keySet() { Set ks = keySet; return (ks != null ? ks : (keySet = new KeySet())); } // Key 对应的集合 // KeySet 继承于 AbstractSet,说明该集合中没有重复的 Key。 private final class KeySet extends AbstractSet { public Iterator iterator() { return newKeyIterator(); } public int size() { return size; } public boolean contains(Object o) { return containsKey(o); } public boolean remove(Object o) { return HashMap.this.removeEntryForKey(o) != null; } public void clear() { HashMap.this.clear(); } } // 返回“value 集合”,实际上返回的是一个 Values 对象 public Collection values() { Collection vs = values; return (vs != null ? vs : (values = new Values())); } // “value 集合” // Values 继承于 AbstractCollection,不同于“KeySet 继承于 AbstractSet”, // Values 中的元素能够重复。因为不同的 key 可以指向相同的 value。 private final class Values extends AbstractCollection { public Iterator iterator() { return newValueIterator(); } public int size() { return size; } public boolean contains(Object o) { return containsValue(o); } public void clear() { HashMap.this.clear(); } } // 返回“HashMap 的 Entry 集合” public Set> entrySet() { return entrySet0(); } // 返回“HashMap 的 Entry 集合”,它实际是返回一个 EntrySet 对象 private Set> entrySet0() { Set> es = entrySet; return es != null ? es : (entrySet = new EntrySet()); } // EntrySet 对应的集合 // EntrySet 继承于 AbstractSet,说明该集合中没有重复的 EntrySet。 private final class EntrySet extends AbstractSet> { public Iterator> iterator() { return newEntryIterator(); } public boolean contains(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry e = (Map.Entry) o; Entry candidate = getEntry(e.getKey()); return candidate != null && candidate.equals(e); } public boolean remove(Object o) { return removeMapping(o) != null; } public int size() { return size; } public void clear() { HashMap.this.clear(); } } // java.io.Serializable 的写入函数 // 将 HashMap 的“总的容量,实际容量,所有的 Entry”都写入到输出流中 private void writeObject(java.io.ObjectOutputStream s) throws IOException { Iterator> i = (size > 0) ? entrySet0().iterator() : null; // Write out the threshold, loadfactor, and any hidden stuff s.defaultWriteObject(); // Write out number of buckets s.writeInt(table.length); // Write out size (number of Mappings) s.writeInt(size); // Write out keys and values (alternating) if (i != null) { while (i.hasNext()) { Map.Entry e = i.next(); s.writeObject(e.getKey()); s.writeObject(e.getValue()); } } } private static final long serialVersionUID = 362498820763181265L; // java.io.Serializable 的读取函数:根据写入方式读出 // 将 HashMap 的“总的容量,实际容量,所有的 Entry”依次读出 private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException { // Read in the threshold, loadfactor, and any hidden stuff s.defaultReadObject(); // Read in number of buckets and allocate the bucket array; int numBuckets = s.readInt(); table = new Entry[numBuckets]; init(); // Give subclass a chance to do its thing. // Read in size (number of Mappings) int size = s.readInt(); // Read the keys and values, and put the mappings in the HashMap for (int i=0; i m; // Backing Map final Object mutex; // Object on which to synchronize SynchronizedMap(Map m) { if (m==null) throw new NullPointerException(); this.m = m; mutex = this; } SynchronizedMap(Map m, Object mutex) { this.m = m; this.mutex = mutex; } public int size() { synchronized (mutex) {return m.size();} } public boolean isEmpty() { synchronized (mutex) {return m.isEmpty();} } public boolean containsKey(Object key) { synchronized (mutex) {return m.containsKey(key);} } public boolean containsValue(Object value) { synchronized (mutex) {return m.containsValue(value);} } public V get(Object key) { synchronized (mutex) {return m.get(key);} } public V put(K key, V value) { synchronized (mutex) {return m.put(key, value);} } public V remove(Object key) { synchronized (mutex) {return m.remove(key);} } public void putAll(Map map) { synchronized (mutex) {m.putAll(map);} } public void clear() { synchronized (mutex) {m.clear();} } ``` ComcurrentHashMap ```java public V put(K key, V value) { Segment s; if (value == null) throw new NullPointerException(); int hash = hash(key); int j = (hash >>> segmentShift) & segmentMask; if ((s = (Segment)UNSAFE.getObject // nonvolatile; recheck (segments, (j << SSHIFT) + SBASE))== null) // in ensureSegment s = ensureSegment(j); return s.put(key, hash, value, false); } ```` 在 ConcurrentHashMap 内部有一个 Segment 段,它将大的 HashMap 切分成若干个段(小的 HashMap),然后让数据在每一段上 Hash,这样多个线程在不同段上的 Hash 操作一定是线程安全的,所以只需要同步同一个段上的线程就可以了,这样实现了锁的分离,大大增加了并发量。 在使用 ConcurrentHashMap.size 时会比较麻烦,因为它要统计每个段的数据和,在这个时候,要把每一个段都加上锁,然后再做数据统计。这个就是把锁分离后的小小弊端,但是 size 方法应该是不会被高频率调用的方法。 ![20241229154732_RZWdx7ak.webp](https://cdn.dong4j.site/source/image/20241229154732_RZWdx7ak.webp) #### 强引用, 软引用, 弱引用, 虚引用 ### Set ![20241229154732_eus534la.webp](https://cdn.dong4j.site/source/image/20241229154732_eus534la.webp) Set 都是基于 Map 实现的 , HashSet 是通过 HashMap 实现的,TreeSet 是通过 TreeMap 实现的 #### HashSet ![20241229154732_NWia6SZS.webp](https://cdn.dong4j.site/source/image/20241229154732_NWia6SZS.webp) ```java public class HashSet extends AbstractSet implements Set, Cloneable, java.io.Serializable { static final long serialVersionUID = -5024744406713321676L; // HashSet 是通过 map(HashMap 对象) 保存内容的 private transient HashMap map; // PRESENT 是向 map 中插入 key-value 对应的 value // 因为 HashSet 中只需要用到 key,而 HashMap 是 key-value 键值对; // 所以,向 map 中添加键值对时,键值对的值固定是 PRESENT private static final Object PRESENT = new Object(); // 默认构造函数 public HashSet() { // 调用 HashMap 的默认构造函数,创建 map map = new HashMap(); } .... } ``` #### 遍历方式 通过 Iterator 遍历 HashSet ```java // 假设 set 是 HashSet 对象 for(Iterator iterator = set.iterator(); iterator.hasNext();) { iterator.next(); } ``` 通过 for-each 遍历 HashSet ```java // 假设 set 是 HashSet 对象,并且 set 中元素是 String 类型 String[] arr = (String[])set.toArray(new String[0]); for (String str:arr) System.out.printf("for each : %s\n", str); ``` #### TreeSet ##### 遍历方式 ```java for(Iterator iter = set.iterator(); iter.hasNext();) { iter.next(); } for(Iterator iter = set.descendingIterator(); iter.hasNext();) { iter.next(); } // 假设 set 是 TreeSet 对象,并且 set 中元素是 String 类型 String[] arr = (String[])set.toArray(new String[0]); for (String str:arr) System.out.printf("for each : %s\n", str); ``` TreeSet 不支持快速随机遍历,只能通过迭代器进行遍历! ### java.util.Arrays Array 是 Java 特有的数组, 而 Arrays 是处理数据的工具类 - Arrays.asList:可以从 Array 转换成 List。可以作为其他集合类型构造器的参数。 - Arrays.binarySearch:在一个已排序的或者其中一段中快速查找。 - Arrays.copyOf:如果你想扩大数组容量又不想改变它的内容的时候可以使用这个方法。 - Arrays.copyOfRange:可以复制整个数组或其中的一部分。 - Arrays.deepEquals、Arrays.deepHashCode:Arrays.equals/hashCode 的高级版本,支持子数组的操作。 - Arrays.equals:如果你想要比较两个数组是否相等,应该调用这个方法而不是数组对象中的 equals 方法(数组对象中没有重写 equals() 方法,所以这个方法之比较引用而不比较内容)。这个方法集合了 Java 5 的自动装箱和无参变量的特性,来实现将一个变量快速地传给 equals() 方法——所以这个方法在比较了对象的类型之后是直接传值进去比较的。 - Arrays.fill:用一个给定的值填充整个数组或其中的一部分。 - Arrays.hashCode:用来根据数组的内容计算其哈希值(数组对象的 hashCode() 不可用)。这个方法集合了 Java 5 的自动装箱和无参变量的特性,来实现将一个变量快速地传给 Arrays.hashcode 方法——只是传值进去,不是对象。 - Arrays.sort:对整个数组或者数组的一部分进行排序。也可以使用此方法用给定的比较器对对象数组进行排序。 - Arrays.toString:打印数组的内容。 如果想要复制整个数组或其中一部分到另一个数组,可以调用 System.arraycopy 方法。此方法从源数组中指定的位置复制指定个数的元素到目标数组里。这无疑是一个简便的方法。(有时候用 ByteBuffer bulk 复制会更快。 ### java.util.Collections ### fail-fast 问题 > fail-fast 机制是 java 集合 (Collection) 中的一种错误机制。当多个线程对同一个集合的内容进行操作时,就可能会产生 fail-fast 事件。 > 例如:当某一个线程 A 通过 iterator 去遍历某集合的过程中,若该集合的内容被其他线程所改变了;那么线程 A 访问集合时,就会抛出 > ConcurrentModificationException 异常,产生 fail-fast 事件。 #### 发生原因 在调用 next()和 remove() 时,都会执行 checkForComodification()。若 “modCount 不等于 expectedModCount”,则抛出 ConcurrentModificationException 异常,产生 fail-fast 事件。 #### 解决方法 只需要将 ArrayList 替换成 java.util.concurrent 包下对应的类即可。 即,将代码 `private static List list = new ArrayList();` 替换为 `private static List list = new CopyOnWriteArrayList(); #### 解决原理 - 和 ArrayList 继承于 AbstractList 不同,CopyOnWriteArrayList 没有继承于 AbstractList,它仅仅只是实现了 List 接口。 - ArrayList 的 iterator() 函数返回的 Iterator 是在 AbstractList 中实现的;而 CopyOnWriteArrayList 是自己实现 Iterator。 - ArrayList 的 Iterator 实现类中调用 next()时,会“调用 checkForComodification() 比较‘expectedModCount’和‘modCount’的大小”;但是,CopyOnWriteArrayList 的 Iterator 实现类中,没有所谓的 checkForComodification(),更不会抛出 ConcurrentModificationException 异常! ## 并发集合 ### List #### CopyOnWriteArrayList ### Set #### ConcurrentSkipListSet #### CopyOnWriteArraySet ### Map #### ConcurrentHashMap #### ConcurrentSkipListMap ## 集合问题 ### Iterater 和 ListIterator 之间有什么区别? - 使用 Iterator 来遍历 Set 和 List 集合,而 ListIterator 只能遍历 List。 - iterator 只可以向前遍历,而 LIstIterator 可以双向遍历。 - ListIterator 从 Iterator 接口继承,然后添加了一些额外的功能,比如添加一个元素、替换一个元素、获取前面或后面元素的索引位置。 ### 通过迭代器 fail-fast 属性,你明白了什么? 每次我们尝试获取下一个元素的时候,Iterator fail-fast 属性检查当前集合结构里的任何改动。如果发现任何改动,它抛出 ConcurrentModificationException。Collection 中所有 Iterator 的实现都是按 fail-fast 来设计的(ConcurrentHashMap 和 CopyOnWriteArrayList 这类并发集合类除外)。 ### fail-fast 与 fail-safe 有什么区别? Iterator 的 fail-fast 属性与当前的集合共同起作用,因此它不会受到集合中任何改动的影响。Java.util 包中的所有集合类都被设计为 fail-fast 的,而 java.util.concurrent 中的集合类都为 fail-safe 的。Fail-fast 迭代器抛出 ConcurrentModificationException,而 fail-safe 迭代器从不抛出 ConcurrentModificationException。 ### UnsupportedOperationException 是什么? UnsupportedOperationException 是用于表明操作不支持的异常。在 JDK 类中已被大量运用,在集合框架 java.util.Collections.UnmodifiableCollection 将会在所有 add 和 remove 操作中抛出这个异常。 ### 在 Java 中,HashMap 是如何工作的? HashMap 在 Map.Entry 静态内部类实现中存储 key-value 对。HashMap 使用哈希算法,在 put 和 get 方法中,它使用 hashCode()和 equals() 方法。当我们通过传递 key-value 对调用 put 方法的时候,HashMap 使用 Key hashCode() 和哈希算法来找出存储 key-value 对的索引。Entry 存储在 LinkedList 中,所以如果存在 entry,它使用 equals() 方法来检查传递的 key 是否已经存在,如果存在,它会覆盖 value,如果不存在,它会创建一个新的 entry 然后保存。当我们通过传递 key 调用 get 方法时,它再次使用 hashCode()来找到数组中的索引,然后使用 equals() 方法找出正确的 Entry,然后返回它的值。下面的图片解释了详细内容。 其它关于 HashMap 比较重要的问题是容量、负荷系数和阀值调整。HashMap 默认的初始容量是 32,负荷系数是 0.75。阀值是为负荷系数乘以容量,无论何时我们尝试添加一个 entry,如果 map 的大小比阀值大的时候,HashMap 会对 map 的内容进行重新哈希,且使用更大的容量。容量总是 2 的幂,所以如果你知道你需要存储大量的 key-value 对,比如缓存从数据库里面拉取的数据,使用正确的容量和负荷系数对 HashMap 进行初始化是个不错的做法。 ### hashCode()和 equals() 方法有何重要性? HashMap 使用 Key 对象的 hashCode()和 equals() 方法去决定 key-value 对的索引。当我们试着从 HashMap 中获取值的时候,这些方法也会被用到。如果这些方法没有被正确地实现,在这种情况下,两个不同 Key 也许会产生相同的 hashCode()和 equals() 输出,HashMap 将会认为它们是相同的,然后覆盖它们,而非把它们存储到不同的地方。同样的,所有不允许存储重复数据的集合类都使用 hashCode()和 equals() 去查找重复,所以正确实现它们非常重要。equals()和 hashCode() 的实现应该遵循以下规则: - 如果 o1.equals(o2) 为 true,那么 o1.hashCode()== o2.hashCode() 总是为 true 的。 - 如果 o1.hashCode()== o2.hashCode(),并不意味着 o1.equals(o2) 会为 true。 ### 如何决定选用 HashMap 还是 TreeMap? 对于在 Map 中插入、删除和定位元素这类操作,HashMap 是最好的选择。然而,假如你需要对一个有序的 key 集合进行遍历,TreeMap 是更好的选择。基于你的 collection 的大小,也许向 HashMap 中添加元素会更快,将 map 换为 TreeMap 进行有序 key 的遍历。 ### Comparable 和 Comparator 接口是什么? 如果我们想使用 Array 或 Collection 的排序方法时,需要在自定义类里实现 Java 提供 Comparable 接口。Comparable 接口有 compareTo(T OBJ) 方法,它被排序方法所使用。我们应该重写这个方法,如果“this”对象比传递的对象参数更小、相等或更大时,它返回一个负整数、0 或正整数。但是,在大多数实际情况下,我们想根据不同参数进行排序。比如,作为一个 CEO,我想对雇员基于薪资进行排序,一个 HR 想基于年龄对他们进行排序。这就是我们需要使用 Comparator 接口的情景,因为 Comparable.compareTo(Object o) 方法实现只能基于一个字段进行排序,我们不能根据对象排序的需要选择字段。 Comparator 接口的 compare(Object o1, Object o2) 方法的实现需要传递两个对象参数,若第一个参数比第二个小,返回负整数;若第一个等于第二个,返回 0;若第一个比第二个大,返回正整数。 ### 当一个集合被作为参数传递给一个函数时,如何才可以确保函数不能修改它? 在作为参数传递之前,我们可以使用 **Collections.unmodifiableCollection(Collection c)** 方法创建一个只读集合,这将确保改变集合的任何操作都会抛出 UnsupportedOperationException。 ### 大写的 O 是什么?举几个例子? 大写的 O 描述的是,就数据结构中的一系列元素而言,一个算法的性能。Collection 类就是实际的数据结构,我们通常基于时间、内存和性能,使用大写的 O 来选择集合实现。 比如: 例子 1:ArrayList 的 get(index i) 是一个常量时间操作,它不依赖 list 中元素的数量。所以它的性能是 O(1)。 例子 2:一个对于数组或列表的线性搜索的性能是 O(n),因为我们需要遍历所有的元素来查找需要的元素。 ### 与 Java 集合框架相关的有哪些最好的实践? - 根据需要选择正确的集合类型。比如,如果指定了大小,我们会选用 Array 而非 ArrayList。如果我们想根据插入顺序遍历一个 Map,我们需要使用 TreeMap。如果我们不想重复,我们应该使用 Set。 - 一些集合类允许指定初始容量,所以如果我们能够估计到存储元素的数量,我们可以使用它,就避免了重新哈希或大小调整。 - 基于接口编程,而非基于实现编程,它允许我们后来轻易地改变实现。 - 总是使用类型安全的泛型,避免在运行时出现 ClassCastException。 - 使用 JDK 提供的不可变类作为 Map 的 key,可以避免自己实现 hashCode()和 equals()。 - 尽可能使用 Collections 工具类,或者获取只读、同步或空的集合,而非编写自己的实现。它将会提高代码重用性,它有着更好的稳定性和可维护性。 ### HashMap 连环问题 #### 你用过 HashMap 吗?什么是 HashMap?你为什么用到它? HashMap 可以接受 null 键值和值,而 Hashtable 则不能; HashMap 是非 synchronized;HashMap 很快; 以及 HashMap 储存的是键值对等等。 ### 你知道 HashMap 的工作原理吗? 你知道 HashMap 的 get() 方法的工作原理吗? HashMap 是基于 hashing 的原理,我们使用 put(key, value) 存储对象到 HashMap 中,使用 get(key) 从 HashMap 中获取对象。 当我们给 put()方法传递键和值时,我们先对键调用 hashCode() 方法,返回的 hashCode 用于找到 bucket 位置来储存 Entry 对象。 ### 当两个对象的 hashcode 相同会发生什么? hashcode 相同,所以它们的 bucket 位置相同,‘碰撞’会发生。 因为 HashMap 使用链表存储对象,这个 Entry(包含有键值对的 Map.Entry 对象) 会存储在链表中。 ### 如果两个键的 hashcode 相同,你如何获取值对象? 当我们调用 get() 方法,HashMap 会使用键对象的 hashcode 找到 bucket 位置,然后获取值对象。 面试官提醒他如果有两个值对象储存在同一个 bucket,他给出答案: 将会遍历链表直到找到值对象。面试官会问因为你并没有值对象去比较,你是如何确定确定找到值对象的?除非面试者直到 HashMap 在链表中存储的是键值对,否则他们不可能回答出这一题。 其中一些记得这个重要知识点的面试者会说,找到 bucket 位置之后,会调用 keys.equals() 方法去找到链表中正确的节点,最终找到要找的值对象。完美的答案! 许多情况下,面试者会在这个环节中出错,因为他们混淆了 hashCode()和 equals() 方法。因为在此之前 hashCode()屡屡出现,而 equals() 方法仅仅在获取值对象的时候才出现。一些优秀的开发者会指出使用不可变的、声明作 final 的对象,并且采用合适的 equals()和 hashCode() 方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的 hashcode,这将提高整个获取对象的速度,使用 String,Interger 这样的 wrapper 类作为键是非常好的选择。 ### 如果 HashMap 的大小超过了负载因子 (load factor) 定义的容量,怎么办? 默认的负载因子大小为 0.75,也就是说,当一个 map 填满了 75% 的 bucket 时候,和其它集合类 (如 ArrayList 等) 一样,将会创建原来 HashMap 大小的两倍的 bucket 数组,来重新调整 map 的大小,并将原来的对象放入新的 bucket 数组中。这个过程叫作 rehashing,因为它调用 hash 方法找到新的 bucket 位置。 ### 你了解重新调整 HashMap 大小存在什么问题吗? 可能产生条件竞争 (race condition)。 当重新调整 HashMap 大小的时候,确实存在条件竞争,因为如果两个线程都发现 HashMap 需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的 bucket 位置的时候,HashMap 并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历 (tail traversing) 。如果条件竞争发生了,那么就死循环了。这个时候,你可以质问面试官,为什么这么奇怪,要在多线程的环境下使用 HashMap 呢 ### 为什么 String, Interger 这样的 wrapper 类适合作为键? String, Interger 这样的 wrapper 类作为 HashMap 的键是再适合不过了,而且 String 最为常用。因为 String 是不可变的,也是 final 的,而且已经重写了 equals()和 hashCode() 方法了。其他的 wrapper 类也有这个特点。不可变性是必要的,因为为了要计算 hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的 hashcode 的话,那么就不能从 HashMap 中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个 field 声明成 final 就能保证 hashCode 是不变的,那么请这么做吧。因为获取对象的时候要用到 equals()和 hashCode() 方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的 hashcode 的话,那么碰撞的几率就会小些,这样就能提高 HashMap 的性能。 ### 我们可以使用自定义的对象作为键吗? 这是前一个问题的延伸。当然你可能使用任何对象作为键,只要它遵守了 equals()和 hashCode() 方法的定义规则,并且当对象插入到 Map 中之后将不会再改变了。如果这个自定义对象时不可变的,那么它已经满足了作为键的条件,因为当它创建之后就已经不能改变了。 ### 我们可以使用 CocurrentHashMap 来代替 Hashtable 吗? 这是另外一个很热门的面试题,因为 ConcurrentHashMap 越来越多人用了。我们知道 Hashtable 是 synchronized 的,但是 ConcurrentHashMap 同步性能更好,因为它仅仅根据同步级别对 map 的一部分进行上锁。ConcurrentHashMap 当然可以代替 HashTable,但是 HashTable 提供更强的线程安全性。 - hashing 的概念 - HashMap 中解决碰撞的方法 - equals()和 hashCode() 的应用,以及它们在 HashMap 中的重要性 - 不可变对象的好处 - HashMap 多线程的条件竞争 - 重新调整 HashMap 的大小 ## 总结 HashMap 基于 hashing 原理,我们通过 put()和 get() 方法储存和获取对象。当我们将键值对传递给 put()方法时,它调用键对象的 hashCode() 方法来计算 hashcode,让后找到 bucket 位置来储存值对象。当获取对象时,通过键对象的 equals() 方法找到正确的键值对,然后返回值对象。HashMap 使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap 在每个链表节点中储存键值对对象。 当两个不同的键对象的 hashcode 相同时会发生什么? 它们会储存在同一个 bucket 位置的链表中。键对象的 equals() 方法用来找到键值对。 ## [Java基础:知识点总结一](https://blog.dong4j.site/posts/b89d16dd.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## serializable 的意义 1. 比如说你的内存不够用了, 那计算机就要将内存里面的一部分对象暂时的保存到硬盘中, 等到要用的时候再读入到内存中, 硬盘的那部分存储空间就是所谓的虚拟内存. 在比如过你要将某个特定的对象保存到文件中, 我隔几天在把它拿出来用, 那么这时候就要实现 Serializable 接口; 2. 在进行 java 的 Socket 编程的时候, 你有时候可能要传输某一类的对象, 那么也就要实现 Serializable 接口;最常见的你传输一个字符串, 它是 JDK 里面的类, 也实现了 Serializable 接口, 所以可以在网络上传输. 3. 如果要通过远程的方法调用(RMI)去调用一个远程对象的方法, 如在计算机 A 中调用另一台计算机 B 的对象的方法, 那么你需要通过 JNDI 服务获取计算机 B 目标对象的引用, 将对象从 B 传送到 A, 就需要实现序列化接口. 例如: 在 web 开发中, 如果对象被保存在了 Session 中, tomcat 在重启时要把 Session 对象序列化到硬盘, 这个对象就必须实现 Serializable 接口. 如果对象要经过分布式系统 进行网络传输或通过 rmi 等远程调用, 这就需要在网络上传输对象, 被传输的对象就必 须实现 Serializable 接口. ## 单例模式 1. 懒汉模式 2. 饿汉模式 3. 同步锁 4. 双锁机制 5. 枚举实现 6. 静态内部类实现 因为加载外部类时, 是不会加载内部类的 ```java // 一个延迟实例化的内部类的单例模式 public final class Singleton { // 一个内部类的容器, 调用 getInstance 时, JVM 加载这个类 private static final class SingletonHolder { static final Singleton singleton = new Singleton(); } private Singleton(){} public static Singleton getInstance() { return SingletonHolder.singleton; } } ``` **防止反射实例化对象** 利用反射生成对象 ```java // 使用反射破坏单例模式 Class c = Class.forName(Singleton.class.getName()); Constructor constructor = c.getDeclaredConstructor(); constructor.setAccessible(true); Singleton singleton = (Singleton)ct.newInstance(); ``` 调用私有构造方法抛出异常 **防止反序列化实例化对象** ```java import java.io.Serializable; /** * Created by hollis on 16/2/5. * 使用双重校验锁方式实现单例 */ public class Singleton implements Serializable{ private volatile static Singleton singleton; private Singleton (){} public static Singleton getSingleton() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } } ``` ```java public class SerializableDemo1 { // 为了便于理解, 忽略关闭流操作及删除文件操作. 真正编码时千万不要忘记 // Exception 直接抛出 public static void main(String[] args) throws IOException, ClassNotFoundException { // Write Obj to file ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("tempFile")); oos.writeObject(Singleton.getSingleton()); // Read Obj from file File file = new File("tempFile"); ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file)); Singleton newInstance = (Singleton) ois.readObject(); // 判断是否是同一个对象 System.out.println(newInstance == Singleton.getSingleton()); } } ``` 防止序列化反序列化破坏单例的方法: 添加 readResolve 方法 ```java private Object readResolve() { return singleton; } ``` 利用枚举创建单例 ```java /** * Singleton pattern example using Java Enumj */ public enum EasySingleton{ INSTANCE; } ``` **使用反射破解枚举单例:** 运行结果是抛出异常: `Exception in thread "main" java.lang.NoSuchMethodException: cn.xing.test.Weekday.()` 明明 Weekday 有一个无参的构造函数, 为何不能通过暴力反射访问? 最新的 Java Language Specification (§8.9) 规定: Reflective instantiation of enum types is prohibited. 这是 java 语言的内置规范. **使用 clone 破解枚举单例** 所有的枚举类都继承自 java.lang.Enum 类, 而不是 Object 类. 在 java.lang.Enum 类中 clone 方法如下: ```java protected final Object clone() throws CloneNotSupportedException { throw new CloneNotSupportedException(); } ``` 调用该方法将抛出异常, 且 final 意味着子类不能重写 clone 方法, 所以通过 clone 方法获取新的对象是不可取的. **使用序列化破解枚举单例** java.lang.Enum 类的 readObject 方法如下: ```java private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { throw new InvalidObjectException("can't deserialize enum"); } private void readObjectNoData() throws ObjectStreamException { throw new InvalidObjectException("can't deserialize enum"); } ``` 同暴力反射一样, Java Language Specification (§8.9) 有着这样的规定: the special treatment by the serialization mechanism ensures that duplicate instances are never created as a result of deserialization. ## fork/join ![20241229154732_FBhCkAUC.webp](https://cdn.dong4j.site/source/image/20241229154732_FBhCkAUC.webp) fork/join 类似 MapReduce 算法, 两者区别是: Fork/Join 只有在必要时如任务非常大的情况下才分割成一个个小任务, 而 MapReduce 总是在开始执行第一步进行分割. 看来, Fork/Join 更适合一个 JVM 内线程级别, 而 MapReduce 适合分布式系统. ## NIO 和 AIO NIO : 1. NIO 会将数据准备好后, 再交由应用进行处理, 数据的读取 / 写入过程依然在应用线程中完成, 只是将等待的时间剥离到单独的线程中去. 2. 节省数据准备时间(因为 Selector 可以复用) AIO: 1. 读完了再通知我 2. 不会加快 IO, 只是在读完后进行通知 3. 使用回调函数, 进行业务处理 ## 序列化和反序列化 - 只有实现了 Serializable 和 Externalizable 接口的类的对象才能被序列化. Externalizable 接口继承自 Serializable 接口, 实现 Externalizable 接口的类完全由自身来控制序列化的行为, 而仅实现 Serializable 接口的类可以采用默认的序列化方式 . - 默认实现 Serializable 接口的序列化是对于一个类的非 static, 非 transient 的实例变量进行序列化与反序列化. 刚刚上面也说了, 如果要对 static 实例变量进行序列化就要使用 Externalizable 接口, 手动实现. - serialVersionUID 的作用 - 父类的序列化 - 要想将父类对象也序列化, 就需要让父类也实现 Serializable 接口. 如果父类不实现的话的, 就需要有默认的无参的构造函数. 在父类没有实现 Serializable 接口时, 虚拟机是不会序列化父对象的, 而一个 Java 对象的构造必须先有父对象, 才有子对象, 反序列化也不例外. 所以反序列化时, 为了构造父对象, 只能调用父类的无参构造函数作为默认的父对象. 因此当我们取父对象的变量值时, 它的值是调用父类无参构造函数后的值. 如果你 kao 虑到这种序列化的情况, 在父类无参构造函数中对变量进行初始化, 否则的话, 父类变量值都是默认声明的值, 如 int 型的默认是 0, string 型的默认是 null. - 关键字 transient - 当持久化对象时, 可能有一个特殊的对象数据成员, 我们不想用 serialization 机制来保存它. 为了在一个特定对象的一个域上关闭 serialization, 可以在这个域前加上关键字 transient. transient 是 Java 语言的关键字, 用来表示一个域不是该对象序列化的一部分. 当一个对象被序列化的时候, transient 型变量的值不包括在序列化的表示中, 然而非 transient 型的变量是被包括进去的 ## Integer ```java int i = 0; Integer j = new Integer(0); System.out.println(i==j); System.out.println(j.equals(i)); ``` 在 JDK1.5 以前, 会报错 在 JDK1.5 后, 由于引入了自动装箱和拆箱, 会输入 true,true ## 进制 如果下列的公式成立: 78+78=123. 则采用的是()进制表示的? 解析一: 设进制数为 x, 根据题设公式展开为 7*x+8+7*x+8=1*x^2+2*x+3, 由于进制数必须为正整数, 得到 x=13. 解析二: 等式左边个位数相加为 16, 等式右边个位数为 3, 即 16 mod x=3, x=13. ## == 和 equals ```java String i = "0"; String j = new String("0"); System.out.println(i==j); // 比值 System.out.println(j.equals(i)); // 比内容 ``` false,true ## 强引用 软引用 弱引用 虚引用 ### 强引用 - 如果一个对象具有强引用, GC 绝不会回收它; - 当内存空间不足, JVM 宁愿抛出 OutOfMemoryError 错误. - 一般 new 出来的对象都是强引用, 如下 ```java // 强引用 User strangeReference=new User(); ``` > 以前我们使用的大部分引用实际上都是强引用, 这是使用最普遍的引用. 如果一个对象具有强引用, 那就类似于必不可少的生活用品, 垃圾回收器绝不会回收它. > 当内存空 间不足, Java 虚拟机宁愿抛出 OutOfMemoryError 错误, 使程序异常终止, 也不会靠随意回收具有强引用的对象来解决内存不足问题. ### 软引用 如果一个对象具有软引用, 当内存空间不足, GC 会回收这些对象的内存, 使用软引用构建敏感数据的缓存. 在 JVM 中, 软引用是如下定义的, 可以通过一个时间戳来回收, 下面引自 JVM: ```java public class SoftReference extends Reference { /** * Timestamp clock, updated by the garbage collector */ static private long clock; /** * Timestamp updated by each invocation of the get method. The VM may use * this field when selecting soft references to be cleared, but it is not * required to do so. */ private long timestamp; /** * Creates a new soft reference that refers to the given object. The new * reference is not registered with any queue. * * @param referent object the new soft reference will refer to */ public SoftReference(T referent) { super(referent); this.timestamp = clock; } /** * Creates a new soft reference that refers to the given object and is * registered with the given queue. * * @param referent object the new soft reference will refer to * @param q the queue with which the reference is to be registered, * or null if registration is not required * */ public SoftReference(T referent, ReferenceQueue q) { super(referent, q); this.timestamp = clock; } /** * Returns this reference object's referent. If this reference object has * been cleared, either by the program or by the garbage collector, then * this method returns null. * * @return The object to which this reference refers, or * null if this reference object has been cleared */ public T get() { T o = super.get(); if (o != null && this.timestamp != clock) this.timestamp = clock; return o; } } ``` 软引用的声明的借助强引用或者匿名对象, 使用泛型 SoftReference;可以通过 get 方法获得强引用. 具体如下: ```java // 软引用 SoftReferencesoftReference=new SoftReference(new User()); strangeReference=softReference.get();// 通过 get 方法获得强引用 ``` 如果一个对象只具有软引用, 那就类似于可有可物的生活用品. 如果内存空间足够, 垃圾回收器就不会回收它, 如果内存空间不足了, 就会回收这些对象的内存. 只要垃圾回收器没有回收它, 该对象就可以被程序使用. 软引用可用来实现内存敏感的高速缓存. 软引用可以和一个引用队列(ReferenceQueue)联合使用, 如果软引用所引用的对象被垃圾回收, JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中. ### 弱引用 如果一个对象具有弱引用, 在 GC 线程扫描内存区域的过程中, 不管当前内存空间足够与否, 都会回收内存, 使用弱引用 构建非敏感数据的缓存. 在 JVM 中, 弱引用是如下定义的, 下面引自 JVM: ```java public class WeakReference extends Reference { /** * Creates a new weak reference that refers to the given object. The new * reference is not registered with any queue. * * @param referent object the new weak reference will refer to */ public WeakReference(T referent) { super(referent); } /** * Creates a new weak reference that refers to the given object and is * registered with the given queue. * * @param referent object the new weak reference will refer to * @param q the queue with which the reference is to be registered, * or null if registration is not required */ public WeakReference(T referent, ReferenceQueue q) { super(referent, q); } } ``` 弱引用的声明的借助强引用或者匿名对象, 使用泛型 `WeakReference`, 具体如下: ```java // 弱引用 WeakReferenceweakReference=new WeakReference(new User()); ``` 如果一个对象只具有弱引用, 那就类似于可有可物的生活用品. 弱引用与软引用的区别在于: 只具有弱引用的对象拥有更短暂的生命周期. 在垃圾回收器线程扫描它 所管辖的内存区域的过程中, 一旦发现了只具有弱引用的对象, 不管当前内存空间足够与否, 都会回收它的内存. 不过, 由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象. 弱引用可以和一个引用队列(ReferenceQueue)联合使用, 如果弱引用所引用的对象被垃圾回收, Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中. ### 虚引用 如果一个对象仅持有虚引用, 在任何时候都可能被垃圾回收, 虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列联合使用, 虚引用主要用来 **跟踪对象被垃圾回收的活动**. 在 JVM 中, 虚引用是如下定义的, 下面引自 JVM: ```java public class PhantomReference extends Reference { /** * Returns this reference object's referent. Because the referent of a * phantom reference is always inaccessible, this method always returns * null. * * @return null */ public T get() { return null; } /** * Creates a new phantom reference that refers to the given object and * is registered with the given queue. * *

It is possible to create a phantom reference with a null * queue, but such a reference is completely useless: Its get * method will always return null and, since it does not have a queue, it * will never be enqueued. * * @param referent the object the new phantom reference will refer to * @param q the queue with which the reference is to be registered, * or null if registration is not required */ public PhantomReference(T referent, ReferenceQueue q) { super(referent, q); } } ``` 虚引用 `PhantomReference` 的声明的借助强引用或者匿名对象, 结合泛型 `ReferenceQueue` 初始化, 具体如下: ```java // 虚引用 PhantomReference phantomReference=new PhantomReference(new User(), ``` "虚引用"顾名思义, 就是形同虚设, 与其他几种引用都不同, 虚引用并不会决定对象的生命周期. 如果一个对象仅持有虚引用, 那么它就和没有任何引用一样, 在任何时候都可能被垃圾回收. 虚引用主要用来 **跟踪对象被垃圾回收的活动**. 虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用. 当垃 圾回收器准备回收一个对象时, 如果发现它还有虚引用, 就会在回收对象的内存之前, 把这个虚引用加入到与之关联的引用队列中. 程序可以通过判断引用队列中是 否已经加入了虚引用, 来了解 被引用的对象是否将要被垃圾回收. 程序如果发现某个虚引用已经被加入到引用队列, 那么就可以在所引用的对象的内存被回收之前采取必要的行动. ```java import java.lang.ref.*; import java.util.HashSet; import java.util.Set; class User { private String name; public User() {} public User(String name) { this.name=name; } @Override public String toString() { return name; } public void finalize(){ System.out.println("Finalizing ... "+name); } } /** * Created by jinxu on 15-4-25. */ public class ReferenceDemo { private static ReferenceQueue referenceQueue = new ReferenceQueue(); private static final int size = 10; public static void checkQueue(){ /* Reference reference = null; while((reference = referenceQueue.poll())!=null){ System.out.println("In queue : "+reference.get()); }*/ Reference reference = referenceQueue.poll(); if(reference!=null){ System.out.println("In queue : "+reference.get()); } } public static void testSoftReference() { Set> softReferenceSet = new HashSet>(); for (int i = 0; i < size; i++) { SoftReference ref = new SoftReference(new User("Soft " + i), referenceQueue); System.out.println("Just created: " + ref.get()); softReferenceSet.add(ref); } System.gc(); checkQueue(); } public static void testWeaKReference() { Set> weakReferenceSet = new HashSet>(); for (int i = 0; i < size; i++) { WeakReference ref = new WeakReference(new User("Weak " + i), referenceQueue); System.out.println("Just created: " + ref.get()); weakReferenceSet.add(ref); } System.gc(); checkQueue(); } public static void testPhantomReference() { Set> phantomReferenceSet = new HashSet>(); for (int i = 0; i < size; i++) { PhantomReference ref = new PhantomReference(new User("Phantom " + i), referenceQueue); System.out.println("Just created: " + ref.get()); phantomReferenceSet.add(ref); } System.gc(); checkQueue(); } public static void main(String[] args) { testSoftReference(); testWeaKReference(); testPhantomReference(); } } ``` ## 谈谈, Java GC 是在什么时候, 对什么东西, 做了什么事情 地球人都知道, Java 有个东西叫垃圾收集器, 它让创建的对象不需要像 c/cpp 那样 delete、free 掉, 你能不能谈谈, GC 是在什么时候, 对什么东西, 做了什么事情? **一. 回答: 什么时候?** 1. **系统空闲的时候**. 分析: 这种回答大约占 30%, 遇到的话一般我就会准备转向别的话题, 譬如算法、譬如 SSH 看看能否发掘一些他擅长的其他方面. 2. **系统自身决定, 不可预测的时间 / 调用 System.gc() 的时候.** 分析: 这种回答大约占 55%, 大部分应届生都能回答到这个答案, 起码不能算错误是吧, 后续应当细分一下到底是语言表述导致答案太笼统, 还是本身就只有这样一个模糊的认识. 3. **能说出新生代、老年代结构, 能提出 minor gc/full gc** 分析: 到了这个层次, 基本上能说对 GC 运作有概念上的了解, 譬如看过《深入 JVM 虚拟机》之类的. 这部分不足 10%. 4. **能说明 minor gc/full gc 的触发条件、OOM 的触发条件, 降低 GC 的调优的策略**. > 分析: 列举一些我期望的回答: eden 满了 minor gc, 升到老年代的对象大于老年代剩余空间 full gc, 或者小于时被 HandlePromotionFailure 参数强制 > full gc;gc 与非 gc 时间耗时超过了 GCTimeRatio(GC 时间占总时间的比率, 默认值为 99, 即允许 1% 的 GC 时间, 仅在使用 Parallel Scavenge > 收集器时生效)的限制引发 OOM, 调优诸如通过 NewRatio 控制新生代老年代比例, 通过 MaxTenuringThreshold 控制进入老年前生存次数等……能回答道这个阶段就会给我带来比较高的期望了, > 当然面试的时候正常人都不会记得每个参数的拼写, 我自己写这段话的时候也是翻过手册的. 回答道这部分的小于 2%. > 总结: 程序员不能具体控制时间, 系统在不可预测的时间调用 System.gc() 函数的时候;当然可以通过调优, 用 NewRatio 控制 newObject 和 oldObject > 的比例, > 用 MaxTenuringThreshold 控制进入 oldObject 的次数, 使得 oldObject 存储空间延迟达到 full gc, 从而使得计时器引发 gc 时间延迟 OOM 的时间延迟, > 以延长对象生存期. **二. 回答: 对什么东西** 1. 不使用的对象. 分析: 相当于没有回答, 问题就是在问什么对象才是“不使用的对象”. 大约占 30%. 2 . 超出作用域的对象 / 引用计数为空的对象. 分析: 这 2 个回答站了 60%, 相当高的比例, 估计学校教 java 的时候老师就是这样教的. 第一个回答没有解决我的疑问, gc 到底怎么判断哪些对象在不在作用域的?至于引用计数来判断对象是否可收集的, 我可以会补充一个下面这个例子让面试者分析一下 2. **从 gc root 开始搜索, 搜索不到的对象.** 分析: 根对象查找、标记已经算是不错了, 小于 5% 的人可以回答道这步, 估计是引用计数的方式太“深入民心”了. 基本可以得到这个问题全部分数. PS: 有面试者在这个问补充强引用(类似 new Object(), 只要强引用还在就不会被回收)、弱引用(还有用但并非必须的对象, 在系统将要发生 OOM 之前, 才会将这些对象回收)、软引用(只能生存到下一次垃圾收集之前)、幻影引用(无法通过幻影引用得到对象, 和对象的生命周期无关, 唯一目的就是能在这个对象被回收时收到一个系统通知)区别等, 不是我想问的答案, 但可以加分. 3. 从 root 搜索不到, 而且经过第一次标记、清理后, 仍然没有复活的对象. 分析: 我期待的答案. 但是的确很少面试者会回答到这一点, 所以在我心中回答道第 3 点我就给全部分数. > 超出了作用域或引用计数为空的对象;从 gc root 开始搜索找不到的对象, 而且经过一次标记、清理, 仍然没有复活的对象. **三. 回答: 做什么** 1. 删除不使用的对象, 腾出内存空间. 分析: 同问题 2 第一点. 40%. 2. 补充一些诸如停止其他线程执行、运行 finalize 等的说明. 分析: 起码把问题具体化了一些, 如果像答案 1 那样我很难在回答中找到话题继续展开, 大约占 40% 的人. 3. 能说出诸如新生代做的是复制清理、from survivor、to survivor 是干啥用的、老年代做的是标记清理、标记清理后碎片要不要整理、复制清理和标记清理有有什么优劣势等. 分析: 也是看过《深入 JVM 虚拟机》的基本都能回答道这个程度, 其实到这个程度我已经比较期待了. 同样小于 10%. 4. 除了 3 外, 还能讲清楚串行、并行(整理 / 不整理碎片)、CMS 等搜集器可作用的年代、特点、优劣势, 并且能说明控制 / 调整收集器选择的方式. > 总结: 删除不使用的对象, 回收内存空间;运行默认的 finalize, JVM 用 from survivor、to survivor 对它进行标记清理, 对象序列化后也可以使它复活. ## [Git基础命令全解析:掌握版本控制的艺术](https://blog.dong4j.site/posts/6952e88b.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## .gitignore 规则写法 .gitignore 文件用于指定哪些类型的文件应被 Git 忽略。以下是一些常用的忽略规则: 1. **在已忽略文件夹中不忽略特定的子文件夹**: ``` /node_modules/* !/node_modules/layer/ ``` 2. **在已忽略文件夹中不忽略特定的文件**: ``` /node_modules/* !/node_modules/layer/layer.js ``` 注意:要使这些规则生效,被忽略的文件或目录需要以 `/*` 结尾。此外,请参考以下规则写法: - 以斜杠 `/` 开头表示目录; - 星号 `*` 可匹配多个字符; - 问号 `?` 匹配单个字符; - 方括号 `[]` 内包含单个字符的匹配列表; - 大叹号 `!` 表示不忽略(跟踪)匹配到的文件或目录; ## 取消跟踪已版本控制的文件 你可以使用 `git update-index --assume-unchanged ` 命令来取消对一个文件的跟踪。这适用于你希望暂时停止 Git 监控特定文件变动的情况。 ```bash git update-index --assume-unchanged your_file_path ``` ## 从版本库中删除文件或目录 如果你想将某个目录(如 `app/test`)从 Git 仓库中移除但不删除本地的文件,你可以按照以下步骤进行操作: 1. **查看要删除的列表**: ```bash git rm -r -n --cached app/test ``` 2. **执行实际删除**: ```bash git rm -r --cached app/test ``` 3. 修改 `.gitignore` 文件,增加忽略规则以防止 Git 再次跟踪该目录下的文件。例如,在 `.gitignore` 文件中添加如下内容: ```markdown /app/test/ ``` 4. **提交更改**: ```bash git commit -m "不再跟踪test文件夹" ``` 5. 如果有远程分支,将改动推送到远程。 如果你同时想要删除本地的测试目录中的所有文件,则可以使用以下命令: 1. **查看要删除的内容**: ```bash git rm -r -n test ``` 2. **执行实际删除**: ```bash git rm -r test ``` ## 创建和推送标签 ### 创建标签 ```bash git tag ``` 或者带有描述信息的标签: ```bash git tag -a v0.1 -m "版本 0.1 发布" d5a65e9 -a: 指定标签名;-m: 提供附注说明 ``` ### 查看所有标签 直接运行 `git tag` 命令即可查看当前工作区的所有标签。 ### 查看特定标签的描述信息 ```bash git show ``` ### 推送标签 #### 单个标签 ```bash git push origin ``` #### 所有标签 使用以下任意一种命令来推送所有的标签: ```bash git push --tags 或者 git push origin --tags ``` ## 删除标签 删除本地标签: ```bash git tag -d ``` 远程标签的删除需要通过 `git push` 命令来完成,具体操作为: ```bash git push origin :refs/tags/ ``` 例如:要删除名为 "V3.0.1-Release" 的标签: ```bash git push origin :refs/tags/V3.0.1-Release ``` ## 同步标签 为了同步你的本地和远程仓库的标签,可以先在本地删除所有旧标签后重新拉取所有的最新标签。 ```bash # 删除本地所有标签 git tag -l | xargs git tag -d # 从远程仓库拉取消除不再存在的引用(分支或标签) git fetch origin --prune # 拉取并更新标签 git fetch origin --tags ``` ## Git 统计提交次数及代码量变化 统计指定作者的提交次数: ```bash git log --author=某人名 --since="起始时间,如:2019-01-01" --no-merges | grep -e 'commit [a-zA-Z0-9]*' | wc -l ``` 统计指定作者增加和删除的代码量: ```bash git log --author=某人名 --pretty=tformat: --numstat | awk '{ add += $1; subs += $2; loc += $1 - $2 } END { printf "添加行数: %s, 删除行数: %s,总行数变化:%s\n", add, subs, loc }' - ``` ## 同时向多处推送代码 ### 添加第二个远程地址 如果你希望你的项目可以同时推送到多个远程仓库,首先需要添加额外的远程地址。例如: ```bash git remote set-url --add origin git@github.com:morethink/programming.git ``` 然后你可以通过 `git remote -v` 查看所有的远端分支及其对应地址。 ### 同时推送至多个远程 一旦你设置了多个远程仓库,当你执行 `git push origin master` 时,Git 会自动将代码推送到所有已配置的远程仓库。示例如下: ```bash Everything up-to-date Everything up-to-date ``` ## 强制提交更改 有时候我们可能需要强制推送我们的改动到远程分支(尤其是当我们本地版本较新或希望覆盖远程版本时),这时可以使用 `git push` 命令的 `-f` 或 `--force` 选项: ```bash git push origin master --force ``` ## 克隆指定标签的仓库 如果想要克隆一个特定标签的仓库,可以直接在命令行中执行如下操作: ```bash git clone --branch [tags标签] [git地址] ``` 例如:将某标签下的代码库拉取到本地 ```bash git clone --branch v2.0.3 https://github.com/example/project.git ``` ## 配置代理和取消代理设置 有时候为了访问远程仓库,我们可能需要配置 HTTP/HTTPS 的代理服务器。可以通过以下命令进行设置: 设置代理: ```bash git config --global http.proxy 'socks5://127.0.0.1:$端口号' ``` 或者对于 https 的请求: ```bash git config --global https.proxy 'socks5://127.0.0.1:$端口号' ``` 取消代理设置,可以使用以下命令移除全局配置中的 http 和 https 代理: ```bash git config --global --unset http.proxy git config --global --unset https.proxy ``` 或者创建一些别名来简化操作: ```bash alias fuckgit="git config --global http.proxy 'socks5://127.0.0.1:8889' && git config --global https.proxy 'socks5://127.0.0.1:8889'" alias unfuckgit="git config --global --unset http.proxy && git config --global --unset https.proxy" ``` ## 初始化项目并上传到 GitHub 为了使用 Git,你需要首先全局设置用户信息: ```bash # 设置用户名和邮箱 git config --global user.name "dong4j" git config --global user.email "dong4j@gmail.com" ``` 然后按照以下步骤进行操作: 1. 进入你的项目目录,并初始化一个新的 Git 仓库。 2. 添加你想要版本控制的文件到暂存区(staging area)。 3. 提交更改并添加描述信息。 ```bash git init git add fileName git commit -m '说明' ``` 接着,将本地仓库与 GitHub 上的新建仓库关联起来: ```bash # 关联远程仓库 git remote add origin git@github.com:dong4j/dubbo_demo.git ``` 最后上传你的代码到 GitHub: ```bash git push -u origin master ``` ### 强制提交更改并同步本地分支 你可以使用相同的方式推送更新,并且在必要时强制覆盖远程的版本。 ```bash # 同步本地与远程仓库 git pull origin master ``` 或者如果需要强制推送更改,则使用: ```bash git push -u origin master --force ``` ## 创建及同步分支 创建新分支的操作相当简单: ```bash # 创建并切换到新的分支 git checkout -b new_branch_name ``` 之后,你可以将这个本地分支推送到远程仓库,并将其与一个远端分支关联起来。完成这些操作后,你就可以在 GitHub 上看到你的分支了。 ## 处理大小写不匹配的文件名 如果需要修改文件名中的大小写(这通常是一个敏感的操作),可以使用以下命令: ```bash git mv --force filename.java FileName.java ``` ## 移除版本控制下的某个文件或目录 如果你想要移除某特定文件或者整个目录的 Git 跟踪信息,你可以采用如下的步骤: 首先,在不实际删除任何内容的情况下预览将要执行的操作: ```bash # 仅显示将会被忽略的内容列表(-n 参数) git rm -r -n --cached "bin/" ``` 然后执行最终命令来移除 Git 跟踪: ```bash git rm -r --cached "bin/" // 移除目录的Git跟踪信息,但保留文件在本地。 git commit -m" remove bin folder all file out of control" ``` 最后将变更推送到远程仓库: ```bash git push origin master ``` ## 修改文件夹名称或移除版本控制下的空文件夹 添加 .gitkeep 文件至所有未被 Git 跟踪的空文件夹: ```bash find . −type d −empty -and −not −regex ./\.git.∗ -exec touch {}/.gitkeep \; ``` ## 删除远端分支 要删除远程仓库中的一个特定分支,请遵循以下步骤: 1. 查看所有本地及远程分支。 2. 如果需要,先在本地移除指定的分支。 3. 使用 `git push` 命令从原始远程仓库中删除该分支。 ```bash # 查看所有本地和远程分支(-a 表示所有) git branch -a # 删除本地分支 git branch -d branchname # 从远端删除一个分支 git push origin :branchname ``` ## 修改远程仓库的地址 修改 Git 远程仓库地址有几种方式: 1. 使用 `set-url` 命令: ```bash git remote set-url origin [新的URL] ``` 2. 删除旧的远端并重新添加一个新远端: ```bash git remote rm origin git remote add origin [新的URL] ``` 3. 直接修改 `.git/config` 文件,定位到正确的部分并更新 URL。 ## 修改文件名大小写 若需改变文件名称的大小写(请注意这在某些系统上可能不会生效),可以使用: ```bash git mv --force filename.java FileName.java ``` 这样可以在保留历史记录的同时更改文件名字。 ## [掌握Git核心技能:必备的命令和操作](https://blog.dong4j.site/posts/858a1037.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 新建代码库 在开始任何项目前,我们通常需要创建一个新的代码库。这可以通过以下方式完成: ```bash # 在当前目录创建新的git仓库 $ git init # 初始化一个新目录作为Git代码库(如果这个目录已存在) $ git init [project-name] # 克隆远程代码库至本地 $ git clone [url] ``` ### 注意事项: - 请确保选择合适的位置和项目名称,避免与现有文件或目录冲突。 - 如果要克隆一个项目,请确认你有访问权限以及所选 URL 是正确的。 ## 配置 配置 Git 有助于确保你在提交时信息准确且一致: ```bash # 查看当前Git的全局配置 $ git config --list # 编辑本地或全球的gitconfig文件 $ git config -e [--global] # 设置提交代码时的基本用户信息(推荐使用--global) $ git config [--global] user.name "[name]" $ git config [--global] user.email "[email address]" ``` ### 注意事项: - 使用`--global`标志设置配置项,可以确保在所有项目中都保持一致性。 - 确保你的用户名和邮箱是独一无二的,并且与你在代码托管平台上使用的账户信息一致。 ## 增加/删除文件 这些命令用于管理仓库中的文件: ```bash # 添加指定文件到暂存区 $ git add [file1] [file2] ... # 将目录及其子目录的所有更改添加到暂存区 $ git add [dir] # 将工作区中所有的改动都提交至暂存区(包括新添和修改过的文件) $ git add . # 使用交互模式,每次变更前提示确认是否要将该文件的部分或全部变更提交(可选) $ git add -p # 从工作区删除文件,并将这次删除放入暂存区 $ git rm [file1] [file2] ... # 停止追踪指定文件,但保留在本地(不从磁盘中删除) $ git rm --cached [file] # 更改文件名并将其更改提交到暂存区 $ git mv [file-original] [file-renamed] ``` ### 注意事项: - 使用`git add .`时要特别小心,确保你了解所有变化。 - 在执行`rm`命令前,最好先确认被删除的文件是否是你想要移除的。 ## 代码提交 这些命令用于将暂存区的内容提交到本地仓库中: ```bash # 提交暂存区的所有更改至仓库(需要填写一条commit信息) $ git commit -m [message] # 提交特定文件或目录至仓库,附带消息 $ git commit [file1] [file2] ... -m [message] # 直接提交工作区内所有已追踪过的改动至仓库区 $ git commit -a # 在commit时显示详细变更信息(diff) $ git commit -v # 使用新的commit覆盖上一次的提交,适用于没有实际内容变化但需修改commit信息的情况 $ git commit --amend -m [message] # 对特定文件进行二次提交(在已有的commit基础上) $ git commit --amend [file1] [file2] ... ``` ### 注意事项: - 使用`git commit --amend`仅当没有推送过代码时是安全的,否则可能会引起冲突。 - 提交信息应简明扼要且具有描述性。 ## 分支 分支管理对于大型项目来说至关重要: ```bash # 列出所有本地分支名称 $ git branch # 查看远程仓库的所有分支及其最新提交记录 $ git branch -r # 显示所有本地和远程分支列表 $ git branch -a # 在当前目录创建一个新分支,但不切换到该分支上 $ git branch [branch-name] # 创建并立即检查出新的分支(相当于执行两次命令) $ git checkout -b [branch] # 根据特定的commit建立一个新的分支 $ git branch [branch] [commit] # 远程分支的追踪关系配置 $ git branch --track [branch] [remote-branch] # 切换到指定的分支,并更新工作目录中的文件 $ git checkout [branch-name] # 快速切换回之前活跃过的最后一个分支 $ git checkout - # 为现有本地分支和远程仓库上的一个分支建立追踪关系 $ git branch --set-upstream-to=[remote/branch] [local-branch] # 合并指定分支的内容到当前工作区中 $ git merge [branch] # 从其他提交引入变更至当前分支(不会修改历史记录) $ git cherry-pick [commit] # 删除本地的分支 $ git branch -d [branch-name] # 移除远程仓库上的一个分支 $ git push origin --delete [branch-name] 或者 git branch -dr [remote/branch] ``` ### 注意事项: - 切换分支前,请确保你已保存并提交所有更改。 - 删除分支时务必谨慎,这将永久移除所有未推送的变更。 ## 标签 标签用于标记重要的版本或里程碑: ```bash # 显示全部标签列表 $ git tag # 在当前提交创建一个轻量级标签(默认) $ git tag [tag] # 创建一个带有注释信息的附带对象引用的标签(可选) $ git tag -a [tag] -m "blablabla" # 删除本地的标签 $ git tag -d [tag] # 在远程仓库中删除标签,需要先推送后移除 $ git push origin --delete [tagName] # 查看指定标签的信息及其提交历史记录 $ git show [tag] # 将所有本地标签推送到远程 $ git push [remote] --tags # 也可以指定要推送的某个标签 $ git push [remote] [tagname] ``` ### 注意事项: - 删除标签时需要小心,确保你不需要这个版本的历史记录。 - 推送标签之前,请确认该操作不会覆盖任何重要的历史标记。 ## 查看信息 这些命令用于检查仓库的状态和变化: ```bash # 显示工作区中哪些文件有变更或尚未追踪(git status) $ git status # 以时间线形式展示当前分支的历史记录(可以使用各种格式化选项,如--pretty=format:等) $ git log # 展示每次提交的差异及其涉及的文件列表(git log --stat) $ git log --stat # 查找包含某个关键词的commit历史纪录 $ git log -S [keyword] # 显示自指定标签以来的所有变更,每条记录一行(git log tag...HEAD --pretty=format:%s) $ git log [tag] HEAD --pretty=format:%s # 筛选并显示符合特定模式或文本的提交信息摘要(--grep=feature相当于-S feature但匹配所有文件而非仅限于单个文件) $ git log [tag] HEAD --grep feature # 查看某文件在不同版本之间的变更历史,包括名称更改记录(git whatchanged等同于log --follow但以更简洁的方式展示) $ git log --follow [file] $ git whatchanged [file] # 以diff的形式输出指定文件的详细提交历史记录 $ git log -p [file] # 显示最近五次提交信息,格式简练(git log -5相当于log --oneline --no-walk HEAD^5..HEAD) $ git log -5 --pretty --oneline # 统计所有提交者的活跃度排名和提交总数(类似shortlog但显示作者名而非commit hash) $ git shortlog -sn # 查找并列出某个文件的修改者及其最后一次编辑时间 $ git blame [file] # 比较工作目录与暂存区之间的差异,显示尚未添加至暂存或已移除的内容(git diff等同于diff-index --cached HEAD --) $ git diff # 显示暂存区相较于上一个提交的修改内容(git diff --staged) $ git diff --cached [file] # 对比工作区中的文件与最近一次commit的差异(git diff HEAD...) $ git diff HEAD # 生成两个分支之间的diff报告,展示它们合并后可能产生的冲突或区别 $ git diff [first-branch]...[second-branch] # 统计某段时间内的代码变更行数(比如今天的工作量) $ git diff --shortstat "@{0 day ago}" # 查看某个commit的详细信息和提交内容 $ git show [commit] # 列出指定commit所涉及的所有文件列表,不显示差异或元数据 $ git show --name-only [commit] # 显示某次特定版本中某文件的内容(git cat-file blob等价于show) $ git show [commit]:[filename] # 查看最近几次本地提交记录简要信息 $ git reflog ``` ### 注意事项: - 使用`git log`查看历史时,可以结合各种参数和选项来定制输出内容。 - `git blame`是一个强大的工具,但频繁使用可能会对性能产生影响。 ## 远程同步 远程操作是与团队成员共享代码的关键: ```bash # 从远程仓库下载最新变动到本地暂存区(不会合并到工作目录) $ git fetch [remote] # 显示所有已配置的远程仓库及其URL等信息 $ git remote -v # 查看特定远程分支的所有配置详情 $ git remote show [remote] # 添加新的远程仓库,并指定别名或简称用于后续操作 $ git remote add [shortname] [url] # 从远程仓库下载最新代码并合并到本地分支(通常与pull命令结合使用) $ git pull [remote] [branch] # 同步本地指定分支至对应的远程分支,确保两者一致 $ git push [remote] [branch] # 强制推送当前分支的更改至远程仓库,忽略任何可能存在的冲突或问题 $ git push [remote] --force # 将所有本地分支同步推送到远程服务器上(git push --all等同于遍历所有分支并执行push命令) $ git push [remote] --all # 指定将本地master分支推送至远程仓库的主干分支,首次推送时设置默认关联关系 $ git push -u origin master ``` ### 注意事项: - 强制推送到远程通常意味着覆盖他人的工作,务必在沟通后进行。 - 使用`git pull`之前,最好先确保自己的代码已完全提交。 ## 撤销操作 这些命令用于撤销错误的更改或恢复特定状态: ```bash # 从暂存区还原指定文件至工作目录(相当于checkout) $ git checkout [file] # 将特定commit中某个文件的状态重置到暂存区,同时保留其在工作目录中的当前版本 $ git checkout [commit] [file] # 还原所有已添加到暂存区的变更至工作区 $ git checkout . # 仅将指定文件从暂存区恢复至上一次提交状态(保持工作区不变) $ git reset [file] # 同时重置暂存区和工作区,使得与上一次commit完全一致 $ git reset --hard # 将当前分支的指向回溯到某个commit,并同步更新暂存区,但保留工作目录中的最新修改(不推荐此操作) $ git reset [commit] # 强制重置当前HEAD至指定commit状态,覆盖所有未提交过的改动且不留记录 $ git reset --hard [commit] # 仅将分支的指向移动到某个历史位置,同时保持暂存区和工作目录中的全部修改 $ git reset --keep [commit] # 创建一个新的commit用于撤销之前的一个或多个更改(git revert等同于创建新提交而不仅仅是回滚) $ git revert [commit] # 暂时存储尚未提交的更改至栈中,以便稍后恢复使用 $ git stash # 将最新的stash弹出并应用于工作区 $ git stash pop ``` ### 注意事项: - `git reset --hard`是一个危险的操作,请确保了解其影响后再执行。 - 使用`revert`命令可以创建一个新的 commit 来撤销特定的更改,适合需要记录历史的情况。 ## 其他 还有一些额外的功能和操作: ```bash # 生成一个压缩文件包用于发布或分发代码 $ git archive ``` ### 注意事项: - `git archive`是一个非常有用的工具,特别是当你需要创建归档版而不带任何 Git 元数据时。 ## [轻松掌握SSH服务配置:Ubuntu入门必学技能](https://blog.dong4j.site/posts/ab98e58e.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 在 Ubuntu 系统的使用过程中,熟练掌握图形界面与命令行模式的切换,以及配置 SSH 服务是非常有用的技能。下面我将分享一些基本的操作步骤,帮助 Ubuntu 初学者们更好地管理自己的系统。 ## 图形界面与命令行模式切换 在 Ubuntu 系统中,你可以通过以下快捷键在图形界面和命令行模式之间进行切换: - **切换到命令行模式**:使用 `Ctrl + Alt + F2` 到 `F6`。每个组合键会打开一个新的命令行界面。 - **切换回图形界面**:使用 `Ctrl + Alt + F7`。 ## 安装 openssh-server 为了能够远程登录到你的 Ubuntu 系统,你需要安装 openssh-server。 1. 首先,更新你的系统包列表: ```bash sudo apt-get update ``` 2. 接着,安装 openssh-server: ```bash sudo apt-get install openssh-server ``` ## 查看和开启 SSH 服务 安装完成后,你可以检查 SSH 服务是否已经开启。 1. 查看当前运行的进程,确认 SSH 服务是否在运行: ```bash sudo ps -e | grep ssh ``` 2. 如果 SSH 服务没有运行,你可以通过以下命令启动它: ```bash sudo service ssh start ``` ## 查看系统 IP 地址 在配置 SSH 服务之前,你可能需要知道你的系统 IP 地址。可以使用以下命令查看: ```bash ifconfig ``` ## 修改 SSH 配置文件 为了允许 root 用户通过 SSH 登录,你需要修改 SSH 的配置文件。 1. 打开终端窗口,使用以下命令编辑配置文件: ```bash sudo gedit /etc/ssh/sshd_config ``` 2. 在打开的编辑器中,找到以下行: ``` PermitRootLogin without-password ``` 在这一行的前面加上 `#` 号,将其注释掉。 3. 在文件的合适位置(通常是文件的末尾),添加以下行: ``` PermitRootLogin yes ``` 4. 保存文件并关闭编辑器。这样,你就允许了 root 用户通过 SSH 登录。 ## 结语 通过以上步骤,你现在应该能够熟练地在 Ubuntu 系统的图形界面和命令行模式之间切换,并且成功配置了 SSH 服务。这些技能将帮助你更好地管理和远程访问你的 Ubuntu 系统。如果你是 Ubuntu 新手,这些操作将是一个很好的开始。继续探索和学习,祝你在 Ubuntu 的世界旅程愉快! ## [掌握Java泛型的力量:从基础到高级用法](https://blog.dong4j.site/posts/28ed4a7f.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) # 深入理解泛型 ## 什么是泛型 **泛型就是变量类型的参数化**。 在使用泛型前,存入集合中的元素可以是任何类型的,当从集合中取出时,所有的元素都是 Object 类型,需要进行向下的强制类型转换,转换到特定的类型。而强制类型转换容易引起运行时错误。 泛型类型参数只能被类或接口类型赋值,不能被原生数据类型赋值,原生数据类型需要使用对应的包装类。 1. 不适用泛型 ```java class Test { private Object ob; // 定义一个通用类型成员 public Test(Object ob) { this.ob = ob; } public Object getOb() { return ob; } public void setOb(Object ob) { this.ob = ob; } public void showTyep() { System.out.println("T的实际类型是: " + ob.getClass().getName()); } } public class TestDemo { public static void main(String[] args) { // 定义类Gen2的一个Integer版本 Test intOb = new Test(new Integer(88)); intOb.showTyep(); int i = (Integer) intOb.getOb(); System.out.println("value= " + i); System.out.println("---------------------------------"); // 定义类Gen2的一个String版本 Test strOb = new Test("Hello Gen!"); strOb.showTyep(); String s = (String) strOb.getOb(); System.out.println("value= " + s); } } ``` 2. 使用泛型 ```java class Test { private T ob; // 定义泛型成员变量 public Test(T ob) { this.ob = ob; } public T getOb() { return ob; } public void setOb(T ob) { this.ob = ob; } public void showType() { System.out.println("T的实际类型是: " + ob.getClass().getName()); } } public class TestDemo { public static void main(String[] args) { // 定义泛型类Gen的一个Integer版本 Test intOb = new Test(88); intOb.showType(); int i = intOb.getOb(); System.out.println("value= " + i); System.out.println("----------------------------------"); // 定义泛型类Gen的一个String版本 Test strOb = new Test("Hello Gen!"); strOb.showType(); String s = strOb.getOb(); System.out.println("value= " + s); } } ``` ``` T 的实际类型是: java.lang.Integer value= 88 T 的实际类型是: java.lang.String value= Hello Gen! ``` ## 深入 有两个类如下,要构造两个类的对象,并打印出各自的成员 x。 ```java public class StringDemo { private String x; public StringDemo(String x) { this.x = x; } public String getX() { return x; } public void setX(String x) { this.x = x; } } public class DoubleDemo { private Double x; public DoubleDemo(Double x) { this.x = x; } public Double getX() { return x; } public void setX(Double x) { this.x = x; } } ``` **重构** 因为上面的类中,成员和方法的逻辑都一样,就是类型不一样,因此考虑重构。Object 是所有类的父类,因此可以考虑用 Object 做为成员类型,这样就可以实现通用了,实际上就是 “Object 泛型”. ```java public class ObjectDemo { private Object x; public ObjectDemo(Object x) { this.x = x; } public Object getX() { return x; } public void setX(Object x) { this.x = x; } } public class ObjectDemoDemo { public static void main(String args[]) { ObjectDemo strDemo = new ObjectDemo(new StringDemo("Hello Generics!")); ObjectDemo douDemo = new ObjectDemo(new DoubleDemo(new Double("33"))); ObjectDemo objDemo = new ObjectDemo(new Object()); System.out.println("strFoo.getX=" + (StringDemo) strDemo.getX()); System.out.println("douFoo.getX=" + (DoubleDemo) douDemo.getX()); System.out.println("objFoo.getX=" + objDemo.getX()); } } ``` ``` 运行结果如下: strDemo.getX=StringDemo@5d748654 douDemo.getX=DoubleDemo@d1f24bb objDemo.getX=java.lang.Object@19821f ``` 在 Java 5 之前,为了让类有通用性,往往将参数类型、返回类型设置为 Object 类型,当获取这些返回类型来使用时候,必须将其 “强制” 转换为原有的类型或者接口,然后才可以调用对象上的方法。 强制类型转换很麻烦,我还要事先知道各个 Object 具体类型是什么,才能做出正确转换。否则,要是转换的类型不对,比如将 “Hello Generics!” 字符串强制转换为 Double, 那么编译的时候不会报错,可是运行的时候就挂了。那有没有不强制转换的办法 ---- 有,改用 Java5 泛型来实现。 ```java class GenericsTest { private T x; public GenericsTest(T x) { this.x = x; } public T getX() { return x; } public void setX(T x) { this.x = x; } } public class GenericsTestDemo { public static void main(String args[]) { GenericsTest strFoo = new GenericsTest("Hello Generics!"); GenericsTest douFoo = new GenericsTest(new Double("33")); GenericsTest objFoo = new GenericsTest(new Object()); System.out.println("strTest.getX=" + strTest.getX()); System.out.println("douTest.getX=" + douTest.getX()); System.out.println("objTest.getX=" + objTest.getX()); } } ``` ``` 运行结果: strTest.getX=Hello Generics! douTest.getX=33.0 objTest.getX=java.lang.Object@19821f ``` 和使用 “Object 泛型” 方式实现结果的完全一样,但是这个 Demo 简单多了,里面没有强制类型转换信息。 下面解释一下上面泛型类的语法: 使用 来声明一个类型持有者名称,然后就可以把 T 当作一个类型代表来声明成员、参数和返回值类型。 当然 T 仅仅是个名字,这个名字可以自行定义。 class GenericsTest 声明了一个泛型类,这个 T 没有任何限制,实际上相当于 Object 类型,实际上相当于 class GenericsTest。 与 Object 泛型类相比,使用泛型所定义的类在声明和构造实例的时候,可以使用 “<实际类型>” 来一并指定泛型类型持有者的真实类型。 类如 `GenericsTest douTest=new GenericsTest(new Double("33"));` 当然,也可以在构造对象的时候不使用尖括号指定泛型类型的真实类型,但是你在使用该对象的时候,就需要强制转换了。 比如:`GenericsTest douTest=new GenericsTest(new Double("33"));` 实际上,当构造对象时不指定类型信息的时候,默认会使用 Object 类型,这也是要强制转换的原因. ## 高级用法 **限制泛型** 在上面的例子中,由于没有限制 class GenericsTest 类型持有者 T 的范围,实际上这里的限定类型相当于 Object,这和 “Object 泛型” 实质是一样的。 比如我们要限制 T 为集合接口类型。只需要这么做: `class GenericsTest` 这样类中的泛型 T 只能是 Collection 接口的实现类,传入非 Collection 接口编译会出错。 注意: 这里的限定使用关键字 extends,后面可以是类也可以是接口。 但这里的 extends 已经不是继承的含义了, **应该理解为 T 类型是实现 Collection 接口的类型**,或者 **T 是继承了 XX 类的类型**。 下面继续对上面的例子改进,我只要实现了集合接口的类型: ```java public class CollectionGenTest { private T x; public CollectionGenTest(T x) { this.x = x; } public T getX() { return x; } public void setX(T x) { this.x = x; } } ``` 实例化的时候可以这么写 ```java public class CollectionGenTestDemo { public static void main(String args[]) { CollectionGenTest listTest = new CollectionGenTest(new ArrayList(10)); System.out.println(listTest.getX().size()); } } ``` **多接口限制** 虽然 Java 泛型简单的用 extends 统一的表示了原有的 extends 和 implements 的概念,但仍要遵循应用的体系,Java 只能继承一个类,但可以实现多个接口,所以你的某个类型需要用 extends 限定,且有多种类型的时候,只能存在一个是类,并且类写在第一位,接口列在后面,也就是: ``` ``` 这里的例子仅演示了泛型方法的类型限定,对于泛型类中类型参数的限制用完全一样的规则,只是加在类声明的头部,如: ```java public class Demo { // T类型就可以用Comparable声明的方法和Seriablizable所拥有的特性了 } ``` **通配符泛型** 为了解决类型被限制死了不能动态根据实例来确定的缺点,引入了 “通配符泛型”,针对上面的例子,使用通配泛型格式为 "?" 代表未知类型,这个类型是实现 Collection 接口。那么上面实现的方式可以写为: ```java public class CollectionGenTestDemo { public static void main(String args[]) { CollectionGenTest listTest= new CollectionGenTest(new ArrayList()); System.out.println("实例化成功!"); } } ``` 1. 如果只指定了 ,而没有 extends,则默认是允许 Object 及其下的任何 Java 类了。也就是任意类。 2. 通配符泛型不单可以向上限制,如 ,还可以向下限制,如 ,表示类型只能接受 Double 及其上层父类类型,如 Number、Object 类型的实例。 3. 泛型类定义可以有多个泛型参数,中间用逗号隔开,还可以定义泛型接口,泛型方法。这些都与泛型类中泛型的使用规则类似。 ### 泛型方法 是否拥有泛型方法,与其所在的类是否泛型没有关系。要定义泛型方法,只需将泛型参数列表置于返回值前。如: ```java public class ExampleA { private void f(T x) { System.out.println(x.getClass().getName()); } public static void main(String[] args) { ExampleA ea = new ExampleA(); ea.f(" "); ea.f(10); ea.f('a'); ea.f(ea); } } ``` 使用泛型方法时,不必指明参数类型,编译器会自己找出具体的类型。泛型方法除了定义不同,调用就像普通方法一样。 需要注意,一个 static 方法,无法访问泛型类的类型参数,所以,若要 static 方法需要使用泛型能力,必须使其成为泛型方法。 比如: ```java public class Demo{ public static T test(T a){ return a; } } ``` **List 和 List 的区别** 类型参数 "" 和无界通配符 "" **声明一个泛型类或泛型方法。** ** 使用泛型类或泛型方法。** **Java 泛型 中 super 怎么 理解?与 extends 有何不同** ## [Git基础入门:从安装到高级实战指南](https://blog.dong4j.site/posts/8d8f656a.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Git 是一种分布式版本控制系统,它广泛用于软件开发和项目管理领域,提供了一个简单高效的工具来跟踪文件的修改记录,并允许多个开发者协作。本文档将从基础知识开始介绍,逐步深入到高级用法。 ## 1. 安装 Git ### Linux: 在大多数 Linux 发行版中可以直接通过包管理器安装 Git。 ```bash sudo apt-get update && sudo apt-get install git #对于Debian/Ubuntu用户 ``` 或者 ```bash sudo yum install git #对于CentOS/RHEL用户 ``` ### Windows 和 macOS: 建议从官网下载 Git 最新版本的安装程序进行安装:https://git-scm.com/downloads ## 2. 配置 Git 在第一次使用前,需要配置你的用户名和邮箱。这是重要的识别信息。 ```bash git config --global user.name "Your Name" git config --global user.email you@example.com ``` 可以查看已有的配置: ```bash git config --list ``` ## 3. 初始化 Git 仓库 在当前目录下初始化一个新的仓库或检查一个现存的仓库是否存在。 ```bash # 在新目录中创建新的 Git 仓库 mkdir new_repo && cd $_ git init # 检查现有目录是否为 Git 仓库 git rev-parse --is-inside-work-tree ``` ## 4. 基本操作 ### 添加文件到暂存区 ```bash git add # 或者将整个目录下的所有文件添加进来 git add . ``` ### 提交更改 提交已添加到暂存区的变更。 ```bash git commit -m "Initial commit" ``` ## 5. 查看状态和历史记录 检查仓库的状态,包括未跟踪、修改等信息。 ```bash git status ``` 查看提交日志以了解项目的历史。 ```bash git log # 或者更详细的输出 git log --oneline --graph --decorate --all ``` ## 6. 分支管理 ### 创建新分支并切换至该分支 ```bash git branch featureA # 创建新的分支 featureA git checkout featureA # 切换到 featureA # 或者结合命令一起使用: git checkout -b featureB ``` ### 合并分支 将一个分支合并到当前工作区。 ```bash git merge featureA ``` ## 7. 远程仓库操作 ### 克隆远程仓库 克隆一个项目至本地。 ```bash git clone # 指定一个不同的目录名: git clone my_project_name ``` ### 推送代码到远程分支 将本地更改推送到远程主分支(main/master)或任何其他分支。 ```bash git push origin main # 推送到名为 "main" 的远程分支 # 如果需要推送一个新创建的分支: git push -u origin featureA ``` ### 拉取更新 从远程仓库拉取最新的代码到本地。 ```bash git pull origin main ``` ## 8. 解决冲突 当合并或拉取时遇到文件冲突,需手动解决。编辑相关文件后添加并提交变更。 ```bash # 添加解决的文件 git add . # 提交更改 git commit -m "Resolved conflicts in file" ``` 详细查看冲突内容可直接打开文件查看标记,或使用命令行工具如: ```bash git diff --name-status ``` ## 9. 其他有用的命令和功能 - 查看远程仓库详情:`git remote -v` - 删除本地分支(不会从远程删除):`git branch -d featureB` - 撤销对暂存区的修改:`git reset HEAD ` - 恢复工作目录中的文件到上次提交的状态:`git checkout -- ` ## 10. 使用 Git 的最佳实践 ### 分支策略 采用明确的工作流,如 GitFlow 或 GitHub Flow。 - **GitFlow**: 主分支维护稳定版本,开发在其他独立的分支中进行;功能完成后再合并至开发主分支(develop)。 - **GitHub Flow**: 所有工作都在非主分支上完成,并通过拉取请求(pull request)的方式提出合并到主分支。 ### 良好的提交信息 确保每次提交都包含有意义的信息,以便于他人理解其目的和内容。遵循标准格式: ``` fix: 修改错误 feat: 添加新特性 docs: 更新文档 ``` ## [Nginx入门必备:高效Web服务器的探索之旅](https://blog.dong4j.site/posts/d73767c3.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Nginx(发音为 “Engine-X”)是一个高性能的 HTTP 和反向代理服务器,同时也是一个 IMAP/POP3 代理服务器。由俄罗斯工程师 Igor Sysoev 开发,最早发布于 2004 年。Nginx 的轻量和高并发处理能力让它在高流量网站中迅速流行,目前被广泛用于各类服务器环境中。 ## 为什么选择 Nginx? Nginx 具有以下主要优势: 1. **高并发性能**:Nginx 采用事件驱动(异步)的非阻塞架构,能够高效处理成千上万的并发连接,特别适合高流量应用。 2. **资源效率**:与其他服务器(如 Apache)相比,Nginx 占用的内存和 CPU 资源更少,提供更好的资源利用率。 3. **功能丰富**:Nginx 支持静态文件服务、反向代理、负载均衡、缓存、SSL/TLS 加密等功能,适用多种场景。 4. **高度可扩展**:Nginx 支持模块化配置,可通过模块扩展功能。其配置文件简单明了,便于管理和扩展。 ## Nginx 的应用场景 Nginx 具备多种应用场景,常见的包括: 1. **静态文件服务器**:适合静态内容(如 HTML、CSS、JavaScript、图片和视频)的高效分发。 2. **反向代理服务器**:作为代理服务器,Nginx 接收客户端请求并转发到后端服务器(如应用服务器、数据库服务器)。 3. **负载均衡**:在多个后端服务器之间分配流量,以实现均匀负载,提升性能和可靠性。 4. **缓存服务器**:Nginx 可以缓存静态和动态内容,提高页面加载速度,减轻后端服务器负载。 5. **Web 应用加速**:通过 SSL/TLS 加速、压缩、缓存等功能提升网站性能。 6. **邮件代理服务器**:支持 IMAP、POP3 和 SMTP 协议的代理。 ## Nginx 的安装 在大多数系统上,Nginx 都可以通过包管理器直接安装。 - **Ubuntu/Debian**: ```bash sudo apt update sudo apt install nginx ``` - **CentOS/RHEL**: ```bash sudo yum install nginx ``` - **macOS**(使用 Homebrew): ```bash brew install nginx ``` 安装完成后,可以启动 Nginx 服务: ```bash sudo systemctl start nginx # Ubuntu/CentOS 等现代发行版 ``` 验证 Nginx 是否运行: ``` curl -I http://localhost ``` 如果 Nginx 运行正常,终端会显示状态代码 200 OK。 ## Nginx 的核心配置 Nginx 的主要配置文件通常位于 /etc/nginx/nginx.conf(在不同操作系统可能有所不同),核心配置由若干“块”组成,包括 events、http、server、location 等。 配置文件结构 ``` # 全局配置 user www-data; worker_processes auto; # 事件配置 events { worker_connections 1024; } # HTTP 配置 http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; # 服务器配置 server { listen 80; server_name example.com; # 路径匹配 location / { root /var/www/html; index index.html index.htm; } # 反向代理 location /api/ { proxy_pass http://backend_server; } } } ``` • 全局配置:设置用户权限、工作进程数等。 • 事件块:设置工作进程的最大连接数等。 • HTTP 块:包含静态文件处理、缓存、压缩等。 • Server 块:设置服务器的域名、端口等。 • Location 块:定义不同 URL 路径的处理方式。 ## 常用的 Nginx 功能配置 ### 1. 静态文件服务 Nginx 可直接作为静态文件服务器,通过 location 指定目录来提供文件服务: ``` server { listen 80; server_name example.com; location / { root /var/www/html; index index.html index.htm; } } ``` ### 2. 反向代理 反向代理是 Nginx 的重要功能,常用于将请求转发到后端服务器: ``` location /api/ { proxy_pass http://backend_server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } ``` 在上述配置中,/api/ 路径下的请求会被转发到 http://backend_server。 ### 3. 负载均衡 Nginx 支持多种负载均衡算法(如轮询、IP 哈希),用于分发请求到多个后端服务器: ``` http { upstream backend_servers { server backend1.example.com; server backend2.example.com; } server { listen 80; location / { proxy_pass http://backend_servers; } } } ``` ### 4. 缓存设置 缓存可以大幅提升性能,特别是在高流量网站上。Nginx 可以缓存反向代理的内容: ``` proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=my_cache:10m max_size=10g; server { location / { proxy_cache my_cache; proxy_pass http://backend_server; proxy_cache_valid 200 1h; } } ``` ### 5. SSL/TLS 配置 Nginx 提供强大的 SSL/TLS 支持,用于加密网站流量: ``` server { listen 443 ssl; server_name example.com; ssl_certificate /etc/nginx/ssl/example.com.crt; ssl_certificate_key /etc/nginx/ssl/example.com.key; location / { root /var/www/html; } } ``` ## Nginx 性能优化 1. 调整工作进程数:worker_processes 应设置为 CPU 的核心数,以最大化性能。 2. 优化连接数:worker_connections 控制每个进程的最大连接数,通常设置为 1024 或更高。 3. 启用 Gzip 压缩:可以通过 gzip on; 启用内容压缩,减少带宽占用。 4. 缓存静态文件:利用 expires 指令设置缓存时间,以减少重复请求。 ## 常见问题和解决方案 ### 1. Nginx 启动失败 检查配置文件是否存在错误: ``` sudo nginx -t ``` ### 2. 502 Bad Gateway 错误 通常是由于后端服务器不可达或配置错误导致,检查 proxy_pass 地址是否正确,确保后端服务运行正常。 ### 3. 访问过慢 可以通过启用缓存、Gzip 压缩或升级硬件资源来优化性能。 ## Nginx 与 Apache 的对比 ## Nginx 与 Apache 的对比 | 特性 | Nginx | Apache | | ------------------ | ------------------------------------------- | -------------------------------------- | | **架构** | 事件驱动、异步 | 线程/进程驱动 | | **性能** | 高并发处理,性能优秀 | 适合小规模并发,性能较低 | | **静态文件处理** | 高效,适合大量并发请求 | 相对较慢,适合少量静态文件请求 | | **动态内容处理** | 通常通过 FastCGI、代理实现 | 原生支持(如通过 `mod_php`) | | **内存和资源占用** | 占用少,资源利用效率高 | 占用相对较多 | | **配置文件** | 简洁,模块化,易于管理 | 配置较为复杂,需要手动调整 | | **负载均衡** | 原生支持多种负载均衡算法(轮询、IP 哈希等) | 需要额外的模块支持(如 `mod_proxy`) | | **SSL/TLS 支持** | 强大且高效 | 需要额外配置或模块支持(如 `mod_ssl`) | | **反向代理** | 支持并行处理,性能出色 | 通过 `mod_proxy` 实现反向代理功能 | | **模块扩展性** | 通过编译时选择模块,动态加载有限 | 支持大量第三方模块,灵活配置 | | **平台兼容性** | 支持类 Unix 和 Windows 系统 | 支持类 Unix 和 Windows 系统 | | **社区与支持** | 社区活跃,文档完善 | 社区庞大,支持范围广 | | **使用场景** | 高流量、并发、负载均衡和缓存 | 传统的 Web 服务器和动态内容处理 | ## [SQL 基础:常用的 SQ操作](https://blog.dong4j.site/posts/ac8eb277.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 1. SQL 连接操作简介 在数据库查询中,`JOIN` 操作是一种非常强大的工具。它允许我们将来自不同表的数据结合起来。下面是几种常见的 `JOIN` 类型及其用途: - **INNER JOIN**(内连接):返回两个表中匹配的行。 - **LEFT JOIN**(左连接):返回左表所有记录,右表无匹配时返回 NULL。 - **RIGHT JOIN**(右连接):返回右表所有记录,左表无匹配时返回 NULL。 ## 2. 使用 CASE WHEN 在 SQL 中处理条件逻辑 在 SQL 查询中,`CASE WHEN` 是一种强大的工具,它允许你在查询结果中根据特定条件添加不同的值。以下是一个示例: ```sql SELECT *, CASE WHEN (A - B) = 0 THEN 'T' WHEN (A - B) < 0 THEN 'WRONG' ELSE CASE WHEN (A IS NULL OR B IS NULL) THEN 'F' ELSE 'T' END END FROM 临时表; ``` 此查询返回了原始列以及一个新列,该列基于条件 `A - B` 的值。 ## 3. 删除具有重复 `shop_id` 记录的行 在某些情况下,你可能需要删除具有重复 `shop_id` 的记录。以下是一个使用子查询来查找具有多个相同 `shop_id` 的记录并删除它们的示例: ```sql DELETE FROM ec_shop_bg_image WHERE shop_id NOT IN ( SELECT MAX(shop_id) FROM ec_shop_bg_image GROUP BY shop_id HAVING COUNT(shop_id) > 1 ); ``` 这段代码通过首先找到每个 `shop_id` 的最大值,然后从原表中删除除了这些最大值之外的所有记录。 ## 4. 设置和查看 SQL 模式 在 MySQL 中,你可以使用 `SET sql_mode` 来设置数据库的运行模式。一个常用的模式是 `ONLY_FULL_GROUP_BY`,它要求在 `GROUP BY` 后面显式列出所有选定的列。下面是如何查看和设置这个模式的示例: ```sql -- 查看 SQL 模式 SELECT version(), @@sql_mode; -- 设置 SQL 模式 SET sql_mode = 'STRICT_TRANS_TABLES'; ``` ## 5. 在 XML 中避免 `<=>` 错误 当处理包含 SQL 语句的 XML 数据时,应使用 `` 来避免与 XML 标签冲突的情况。 ```xml ``` ## 6. 模糊查询的使用 在 SQL 中执行模糊查询时,你可以使用 `LIKE` 和 `%` 通配符来查找匹配特定模式的数据。以下是一个示例: ```sql LIKE CONCAT('%', #{name} ,'%'); ``` 这个表达式会找到所有以指定名称开头的行。 ## 7. 使用 mycli 工具查询数据库 `mycli` 是一个交互式命令行工具,用于连接 MySQL 数据库并执行查询。以下是如何使用 `mycli` 的示例: ```bash mycli -h localhost -u root 数据库名 ``` 然后,你可以输入 SQL 查询来获取数据。 ## 8. MyBatis 动态 SQL 中的 #{ } 和 ${ } 区别 在使用 MyBatis 进行动态 SQL 编写时,理解 `#{ }` 和 `${ }` 的区别非常重要。以下是它们的主要区别: - **`#{ }`**: 解析为一个 JDBC 预编译语句(prepared statement)的参数标记符。它提供了预处理和参数替换的功能,以防止 SQL 注入。 ```sql select * from user where name = #{name}; ``` 解析为: ```sql select * from user where name = ?; ``` - **`${ }`**: 仅仅为一个字符串替换。在动态 SQL 解析阶段将会进行变量替换。 ```sql select * from user where name = ${name}; ``` 解析为: ```sql select * from user where name = 'ruhua'; ``` ## 9. 使用 MyBatis 动态 SQL 的最佳实践 在编写 MyBatis 动态 SQL 时,遵循以下最佳实践: - **优先使用 `#{ }`**: 当你正在处理参数时。 - **仅当需要字符串替换时使用 `${ }`**: 例如表名或列名。 - **避免在表名作为变量时使用 `#{ }`**: 因为这会带上单引号,导致语法错误。 通过遵循这些最佳实践,你可以创建安全、高效且易于维护的动态 SQL。 ## MyBatis:单个参数和多个参数的例子 在 MyBatis 中,你可以定义接口方法来执行数据库操作,并通过 XML 映射文件将这些方法映射到实际的 SQL 语句。以下是如何处理单个和多个参数的例子。 ### 单个参数 假设我们有一个接口方法 `getAgeById`,它根据用户 ID 查询用户的年龄: ```java // 接口方法 int getAgeById(Integer id); ``` 对应的 XML 映射文件中的 SQL 语句如下: ```xml ``` ### 多个参数 当需要传递多个参数时,可以使用 `@Param` 注解来指定每个参数的名称。例如,`login` 方法: ```java // 接口方法 User login(@Param("username") String username, @Param("password") String password); ``` 对应的 XML 映射文件: ```xml ``` ## MyBatis 动态 SQL 处理数组参数和 List 参数 在处理数组或 List 类型的参数时,MyBatis 提供了 `foreach` 元素来构建 SQL 查询。下面是如何使用 `foreach` 来处理这两种类型的例子。 ### 数组参数 如果我们有一个接口方法 `selectByIds` 接受一个整型数组作为 ID: ```java // 接口方法 ArrayList selectByIds(Integer[] ids); ``` 对应的 XML 映射文件中的 SQL 语句: ```xml ``` ### List 参数 如果 `ids` 是一个 List 类型: ```java // 接口方法 ArrayList selectByIds(List ids); ``` 对应的 XML 映射文件中的 SQL 语句: ```xml ``` ## [Shell脚本入门:Linux文件管理系统秘籍](https://blog.dong4j.site/posts/10f60db1.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 通过端口号获取对应的 PID ```bash /usr/sbin/lsof -n -P -t -i :$TOMCAT_WEB_PORT ``` ## 根据名称获取 PID ```bash ps -ef | grep -w java | grep -v grep | awk '{print $2}' -w 全匹配名字 -v 忽略名字 ``` ## tar ``` .tar.gz 和 .tgz 解压:tar zxvf FileName.tar.gz 压缩:tar zcvf FileName.tar.gz DirName ``` ## shell 执行多个命令 1. 每个命令之间用 `;` 隔开 说明:各命令的执行给果,不会影响其它命令的执行。换句话说,各个命令都会执行, 但不保证每个命令都执行成功。 2. 每个命令之间用 `&&` 隔开 说明:若前面的命令执行成功,才会去执行后面的命令。这样可以保证所有的命令执行完毕后,执行过程都是成功的。 3. 每个命令之间用 `||` 隔开 说明:||是或的意思,只有前面的命令执行失败后才去执行下一条命令,直到执行成功 一条命令为止 ## 查看历史命令 ```bash history | awk '{print $2}' | sort | uniq -c | sort -rn | head -10 ``` ## 批量杀进程 ```bash ps -ef|grep LOCAL=NO | grep -v grep | awk '{print $2}' |xargs kill -9 ``` 管道符 "|" 用来隔开两个命令,管道符左边命令的输出会作为管道符右边命令的输入。下面说说用管道符联接起来的 几个命令: - "ps - ef": 是 Red Hat 里查看所有进程的命令。这时检索出的进程将作为下一条命令 "grep LOCAL=NO" 的输入。 - "grep LOCAL=NO" : 的输出结果是,所有含有关键字 "LOCAL=NO" 的进程,这是 Oracle 数据库中远程连接进程的共同特点。 - "grep -v grep" : 是在列出的进程中去除含有关键字 "grep" 的进程。 - "xargs kill -9" : 中的 xargs 命令是用来把前面命令的输出结果(PID)作为 "kill -9" 命令的参数,并执行该令。 - "kill -9" : 会强行杀掉指定进程,这样就成功清除了 oracle 的所有远程连接进程。其它类似的任务,只需要修改 "grep LOCAL=NO" 中的关键字部分就可以了。 ```bash ps -ef|grep /usr/local/apache-tomcat-document/|grep -v grep|cut -c 9-15|xargs kill -9 ``` ## 批量删除 ### 1. 删除指定文件 要删除一个特定名称的文件,可以使用以下 `rm` 命令: ```bash rm 文件名 ``` 如果要删除一个目录及该目录下的所有文件和子目录,可以带上 `-r` 或 `-R` 选项(它们是等价的): ```bash rm -rf 目录名 ``` ### 2. 使用 `find` 命令 `find` 命令可以用来查找文件或目录。结合其他命令如 `grep` 或 `awk`,你可以进一步筛选出需要删除的文件。 例如,要删除当前目录下所有 `.txt` 文件,除了 `test.txt`,可以使用以下命令: ```bash rm `ls *.txt | egrep -v 'test.txt'` ``` 或者使用 `awk`: ```bash rm `ls *.txt | awk '{if($0 != "test.txt") print $0}'` ``` 要排除多个文件,你可以用管道符 `|` 结合 `egrep` 命令: ```bash rm `ls *.txt | egrep -v '(test.txt|fff.txt|ppp.txt)'` ``` ### 3. 删除日志文件 如果你需要删除特定日期范围内的 `.log` 文件,可以使用 `find` 和 `grep`: ```bash rm -f `find . -name '*.log' | grep '2010-09-06.log'` ``` ### 4. 删除目录下的所有 `.exe` 文件 要删除指定目录下所有的 `.exe` 文件,可以使用以下命令: ```bash find . -name '*.exe' -type f -print -exec rm -rf {} ; ``` 在这个命令中: - `.` 表示从当前目录开始递归查找。 - `-name '*.exe'` 根据名称来查找,要查找所有以 `.exe` 结尾的文件或目录。 - `-type f` 查找的类型为文件。 - `-print` 输出查找的文件目录名。 - `-exec rm -rf {} ;` 执行删除指令。 ### 5. 删除指定目录下的 `.svn` 目录 要删除指定目录下所有的 `.svn` 目录,可以使用以下命令: ```bash find 要查找的目录名 -name .svn | xargs rm -rf ``` 或者: ```bash find -type d | grep '.svn$' | xargs rm -r ``` 这个命令通过 `grep` 查找包含 `.svn` 的目录,然后使用 `xargs` 传递给 `rm -rf` 命令进行删除。 ### 6. 使用正则表达式 在处理文件时,经常需要使用正则表达式来精确匹配文件名。`egrep` 是 `grep` 的一个扩展版本,它支持正则表达式元字符,如 `+`, `?`, `|`, `()` 等。 例如,要删除当前目录下所有包含 "test" 或 "fff" 或 "ppp" 的 `.txt` 文件,可以使用以下命令: ```bash rm `find . -name '*.txt' | egrep -v '(test.txt|fff.txt|ppp.txt)'` ``` ### 7. 注意事项 在使用 `rm` 和 `find` 命令时要格外小心,因为这些操作是不可逆的。在执行删除操作之前,请确保有足够的备份,并且已经确认了正确的文件和目录。 ## find 命令使用说明 ### 用法 `find path -option [ -print ] [ -exec -ok command ] {} \;` ### 使用说明 将档案系统内符合 expression 的档案列出来。你可以指定档案的名称、类别、时间、大小、权限等不同资讯的组合,只有完全相符的才会被列出来。find 根据下列规则判断 path 和 expression,在命令列上第一个 `-`、`(`、`)`、`!` 之前的部份为 path,之后的是 expression。如果 path 是空字串则使用当前路径,如果 expression 是空字串则使用 `-print` 为默认 expression。 expression 中可使用的选项有二三十个之多,在此只介绍最常用的部分。 - `-mount`, `-xdev`: 只检查和指定目录在同一个档案系统下的档案,避免列出其他档案系统中的档案 - `-amin n`: 在过去 n 分钟内被读取过 - `-anewer file`: 比档案 file 更晚被读取过的档案 - `-atime n`: 在过去 n 天过读取过的档案 - `-cmin n`: 在过去 n 分钟内被修改过 - `-cnewer file`: 比档案 file 更新的档案 - `-ctime n`: 在过去 n 天过修改过的档案 - `-empty`: 空的档案 - `-gid n` 或 `-group name`: gid 是 n 或是 group 名称是 name - `-ipath p`, `-path p`: 路径名称符合 p 的档案,ipath 会忽略大小写 - `-name name`, `-iname name`: 档案名称符合 name 的档案。iname 会忽略大小写 - `-size n`: 档案大小是 n 单位,b 代表 512 位元组的区块,c 表示字元数,k 表示 kilo bytes,w 是二个位元组。 - `-type c`: 档案类型是 c 的档案。 - `d`: 目录 - `c`: 字型装置档案 - `b`: 区块装置档案 - `p`: 具名贮列 - `f`: 一般档案 - `l`: 符号连结 - `s`: socket - `-pid n`: process id 是 n 的档案 你可以使用 `()` 将运算式分隔,并使用下列运算。 - `exp1 -and exp2` - `! expr` - `-not expr` - `exp1 -or exp2` - `exp1, exp2` ### 范例 将当前目录及其子目录下所有扩展名是 `.c` 的档案列出来。 ```bash find . -name "*.c" ``` 将当前目录及其子目录中所有一般档案列出。 ```bash find . -type f ``` 将当前目录及其子目录下所有最近 20 分钟内更新过的档案列出。 ```bash find . -ctime -20 ``` 查找当前目录及其子目录下所有文件中包含字符串 `xxx` 的文件,并分页显示。 ```bash find . -name "*" -exec grep 'xxx' {} \; -print | more ``` 注意:在最后一个例子中,我修复了命令中的错误,将 `xxx` 替换为 `'xxx'`,并在 `grep` 命令后面添加了反斜杠 `\` 来转义分号。 ## ${} 默认值 ```bash #!/bin/bash # 脚本名称 SCRIPT=$(basename$0) # 使用函数检查参数 function usage() { echo -e "\nUSAGE: $SCRIPT file\n" exit 1 } # 检查是否提供了文件名 if [ -z "$1" ]; then usage fi # 检查文件是否存在 FILENAME="$1" if [ ! -f "$FILENAME" ]; then echo "Error: File '$FILENAME' does not exist." usage fi # 逐行读取文件并执行 wget while read -r LINE; do wget "$LINE" done < "$FILENAME" ``` 执行 ``` ./shell文件名.sh 读取的文件 ``` ## [Spring Boot 入门指南:从基础到实践](https://blog.dong4j.site/posts/f2cb7e19.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## Spring Boot 为什么建议将 main 类放在所有类所在包的顶层 > 通常建议将应用的 main 类放到其他类所在包的顶层 (root package),并 将 @EnableAutoConfiguration 注解到你的 main 类上,这样就隐式地定义了一个 基础的包搜索路径(search package),以搜索某些特定的注解实体(比如 @Service,@Component 等) 。例如,如果你正在编写一个 JPA 应用,Spring 将 搜索 @EnableAutoConfiguration 注解的类所在包下的 @Entity 实体。 采用 root package 方式,你就可以使用 @ComponentScan 注解而不需要指 定 basePackage 属性,也可以使用 @SpringBootApplication 注解,只要将 main 类放到 root package 中。 @SpringBootApplication 等同于**以默认属性**使用一下注解: 1. @EnableAutoConfiguration 2. @ComponentScan 扫描所有 Spring 组件 (@Component , @Service , @Repository , @Controller),包括 @Configuration 类。 3. @Configuration **自定义属性** @SpringBootApplication(exclude = {}, excludeName = {}, scanBasePackages = {}, scanBasePackageClasses = {}) ## 构造器注入 1. @Autowired 可省略 2. 注入的 bean 可以为 final ```java @Service public class DatabaseAccountService implements AccountService { private final RiskAssessor riskAssessor; @Autowired public DatabaseAccountService(RiskAssessor riskAssessor) { this.riskAssessor = riskAssessor; } } ``` ## 开发者工具 ```xml org.springframework.boot spring-boot-devtools true ``` application.properties 配置 ``` #添加那个目录的文件需要restart spring.devtools.restart.additional-paths=src/main/java #排除那个目录的文件不需要restart spring.devtools.restart.exclude=static/**,public/** ``` > 在运行一个完整的,打包过的应用时,开发者工具(devtools)会被自动禁用。 如果应用使用 java -jar 或特殊的类加载器启动,都会被认为是一个产品级的应 用(production application),从而禁用开发者工具。为了防止 devtools 传递到项 目中的其他模块,设置该依赖级别为 optional 是个不错的实践 **Spring Loaded 或 JRebel 项目** ## spring boot 远程调试 ## FailureAnalyzer ## 自定义 Banner ## 自定义 SpringApplication ```java public static void main(String[] args) { SpringApplication app = new SpringApplication(MySpringConfig.uration.class); app.setBannerMode(Banner.Mode.OFF); app.run(args); } ``` ## Application 事件和监听器 ## Admin 特性 > 通过设置 spring.application.admin.enabled 属性可以启用管理相关的 (admin-related)特性,这将暴露 SpringApplicationAdminMXBean 到平台 的 MBeanServer ,你可以使用该特性远程管理 Spring Boot 应用,这对任何 service 包装器(wrapper)实现也有用。 注 通过 local.server.port 可以获取该应用运行的 HTTP 端口。启用该特性时需 要注意 MBean 会暴露一个方法去关闭应用。 ## Application 属性文件 1. 当前目录下的 /config 子目录。 2. 当前目录。 3. classpath 下的 /config 包。 4. classpath 根路径(root)。 ## 配置文件 通过设置启动参数来选择环境, 只需要打一次包, 就可以在不同环境运行 ## 日志 Spring Boot 默认日志框架为 logback, 默认控制台输出 根据日志配置文件的名称选择日志系统 | 日志系统 | 定制配置 | | ----------------------- | ------------------------------------------------------------------- | | Logback | logback-spring.xml logback-spring.groovy logback.xml logback.groovy | | Log4j | log4j.properties log4j.xml | | Log4j2 | log4j2-spring.xml log4j2.xml | | JDK (Java Util Logging) | logging.properties | ## [Java并发编程的利器:volatile的关键技巧和应用](https://blog.dong4j.site/posts/da43da6e.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) **volatile 关键字的 2 层含义:** > 用 volatile 修饰的变量,线程在每次使用变量的时候,都会读取变量修改后的最新的值. > 作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值. ## 可见性 可见性是指 当一个线程修改了一个共享变量,其他线程能够立刻得知这个修改. 这里有必要了解一下 Java 的内存模型 ![20241229154732_UNw0TFwI.webp](https://cdn.dong4j.site/source/image/20241229154732_UNw0TFwI.webp) 被 volatile 修饰的变量,当线程需要使用这个变量时,回去主内存中读取,然后加载到自己的工作线程中, 工作线程中的变量只是主存变量的一个拷贝,当使用完这个变量后,会刷新会主存中. ![20241229154732_Lg19Egdh.webp](https://cdn.dong4j.site/source/image/20241229154732_Lg19Egdh.webp) 当数据中主内存复制到工作内存存储时,必须出现两个动作: 1. 由主内存执行的 read 操作 2. 有工作内存执行相应的 load 操作 当数据从工作内存拷贝到主内存时,也会有两个操作: 1. 用工作内存执行的 store 操作 2. 用主内存执行相应的 write 操作 volatile 的特殊规则就是 read、load、use 必须连续出现。assign、store、write 动作必须连续出现。所以使用 volatile 变量能够保证必须先从主内存刷新最新的值,每次修改后必须立即同步回主内存当中。 所以 colatile 的可见性很适合用来控制并发: ```java public class VolatileDemo { private static volatile boolean flag; public static void shutdown(){ flag=true; } public static void main(String[] args) throws InterruptedException{ VolatileDemo m = new VolatileDemo(); for(int i=0;i<20;i++){ new Thread(new Runnable() { public void run() { while(!flag){ System.out.println("aaa"); } } }).start(); } Thread.sleep(2000); shutdown(); } } ``` 当调用 shutdown() 时,能保证所有线程立刻停止. ## volatile 的禁止指令重排序 指令重排是 编译器和 cup 在不影响执行结果的情况下,进行的一种优化策略. > 在 Java 中普遍的变量仅仅会保证在该方法的执行过程中所有依赖的赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是 Java 内存模型中描述的所谓“线程内表现为串行的语义”。 ### 有序性 计算机在执行代码时,不一定按照程序的顺序来执行. ```java class OrderExample { int a = 0; boolean flag = false; public void writer() { a = 1; flag = true; } public void reader() { if (flag) { int i = a +1; } } } ``` 比如上述代码,两个方法分别被两个线程调用。 按照常理,写线程应该先执行 a=1,再执行 flag=true。 当读线程进行读的时候,i=2; 但是因为 a=1 和 flag=true,并没有逻辑上的关联。 所以有可能执行的顺序颠倒,有可能先执行 flag=true,再执行 a=1。 这时当 flag=true 时,切换到读线程,此时 a=1 还没有执行,那么读线程将 i=1。 **指令重排可以使流水线更加顺畅** 当然指令重排的原则是不能破坏串行程序的语义,例如 a=1,b=a+1,这种指令就不会重排了,因为重排的串行结果和原先的不同。 在 Java 里面,可以通过 volatile 关键字来保证一定的“有序性”。另外可以通过 synchronized 和 Lock 来保证有序性,很显然,synchronized 和 Lock 保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。 ### Happen-Before Java 内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happen-before 原则。如果两个操作的执行次序无法从 happen-before 原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。 - 程序顺序原则:一个线程内保证语义的串行性 (写在前面的先发生,用来保证单线程结果的正确性) - volatile 规则:volatile 变量的写,先发生于读,这保证了 volatile 变量的可见性 - 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前 - 传递性:A 先于 B,B 先于 C,那么 A 必然先于 C - 线程的 start() 方法先于它的每一个动作 - 线程的所有操作先于线程的终结(Thread.join()) - 线程的中断(interrupt())先于被中断线程的代码 - 对象的构造函数执行结束先于 finalize() 方法 **为什么 Happen-Before 原则不被指令重排影响?** 例如你让一个 volatile 的 integer 自增(i++),其实要分成 3 步:1)读取 volatile 变量值到 local; 2)增加变量的值;3)把 local 的值写回,让其它的线程可见。这 3 步的 jvm 指令为: ``` mov 0xc(%r10),%r8d ; Load inc %r8d ; Increment mov %r8d,0xc(%r10) ; Store lock addl $0x0,(%rsp) ; StoreLoad Barrier ``` StoreLoad Barrier 就是内存屏障 > 内存屏障(memory barrier)是一个 CPU 指令。基本上,它是这样一条指令: > > 1. 确保一些特定操作执行的顺序; > 2. 影响一些数据的可见性 (可能是某些指令执行后的结果)。 > 编译器和 CPU 可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。 > 插入一个内存屏障,相当于告诉 CPU 和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。 > 内存屏障另一个作用是强制更新一次不同 CPU 的缓存。 > 例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个 cpu 核心或者哪颗 CPU 执行的。 **内存屏障和 volatile 的关系** 上面的虚拟机指令里面有提到,如果你的字段是 volatile,Java 内存模型将在写操作后插入一个**写屏障指令**,在读操作前插入一个**读屏障指令**。 这意味着如果你对一个 volatile 字段进行写操作,你必须知道: 1. 一旦你完成写入,任何访问这个字段的线程将会得到最新的值。 2. 在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。 明白了内存屏障这个 CPU 指令,回到前面的 JVM 指令:从 load 到 use 到 assign 到 store 到内存屏障,一共 4 步,其中最后一步 jvm 让这个最新的变量的值在所有线程可见,也就是最后一步让所有的 CPU 内核都获得了最新的值,但中间的几步(从 Load 到 Store)是不安全的,中间如果其他的 CPU 修改了值将会丢失。 ### volatile 禁止指令重排的两层含义 1. 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作肯定已经全部进行,且结果对后面的操作可见; 且后面的操作还没有进行; 2. 在进行指令优化时,不能将对 volatile 变量的访问语句放在气候执行,也不能把 volatile 变量后面的语句放在其前面执行. ```java //x、y为非volatile变量 //flag为volatile变量 x = 2; //语句1 y = 0; //语句2 flag = true; //语句3 x = 4; //语句4 y = -1; //语句5 ``` 由于 flag 变量为 volatile 变量,那么在进行指令重排序的过程的时候,不会将语句 3 放到语句 1、语句 2 前面,也不会讲语句 3 放到语句 4、语句 5 后面。但是要注意语句 1 和语句 2 的顺序、语句 4 和语句 5 的顺序是不作任何保证的。 并且 volatile 关键字能保证,执行到语句 3 时,语句 1 和语句 2 必定是执行完毕了的,且语句 1 和语句 2 的执行结果对语句 3、语句 4、语句 5 是可见的 ## vloatile 的并发问题 volatile 能保证可见性和禁止指令重排,但是却不能保证原子性, 其实通过上面的分析也能得出这个结论. 比如 i++ 操作: 1. 读取 volatile 变量值到当前线程的工作内存中 2. 进行 i+1 计算 3. 将工作内存中的值写会到主内存,让其他线程可见 现在有 2 个线程同时对 volatile 变量进行操作, 当第一个变量从主内存中读取了变量,但是还未进行 i+1 操作, 此时第二个线程也从主存中读取了这个变量,但是和线程一读取的值一样,因为线程一还未将计算过的值刷新到主内存中,此时 2 个线程都对变量进行 +1 操作,然后刷新到主内存,此时,主内存中的值只是做了一次 +1 操作,而不是 2 次. **某个线程将一个共享值优化到了内存中,而另一个线程将这个共享值优化到了缓存中,当修改内存中值的时候,缓存中的值是不知道这个修改的。** ## [《Log4j 2 官方文档》多余性(Additivity)](https://blog.dong4j.site/posts/d6e26bb5.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 如果我们希望输出 `com.foo.Bar` 的 TRACE 等级的日志,而不像影响其他日志的输出。简单的改变日志等级是不能达到我们想要的目的;但是修改也很简单,只要我们添加一个新的 Logger 定义就可以达到目标。 ```xml ``` 这个配置达到了我们想要的目标,所有 `com.foo.Bar` 的日志都会被输出,而其他组件的日志仅仅会输出 `ERROR` 等级的日志。 在上面的例子,所有 `com.foo.Bar` 的日志都会被输出到控制台。这是因为为 `com.foo.Bar`  配置的 `Logger` 没有设定任何的 `Appender`。 请看如下的配置 ```xml ``` 将会输出 ``` 17:13:01.540 [main] TRACE com.foo.Bar - entry 17:13:01.540 [main] TRACE com.foo.Bar - entry 17:13:01.540 [main] ERROR com.foo.Bar - Did it again! 17:13:01.540 [main] TRACE com.foo.Bar - exit (false) 17:13:01.540 [main] TRACE com.foo.Bar - exit (false) 17:13:01.540 [main] ERROR MyApp - Didn't do it. ``` 注意 `com.foo.bar` 的 `TRACE` 日志被输出了两次。 首先 `com.foo.Bar` 关联的 `Logger` 执行了一次,直接输出到控制台。接下来这个 `Logger` 的父节点,也就是 `Root Logge`r 执行了另一次输出,这是因为日志在 `com.foo.Bar` 已经被输出,所以也会被父自动输出到控制台。这就是**多余性**,有的时候**多余性**的确是非常便捷的功能(前面的例子,我们增加了一个 `Logger`,但是没有设置 `Appender`,但是却正常工作了),有的时候却不是很方便,因此这个功能在 `Logger` 中是可以通过 `additivity` 的属性进行关闭的(设置成 false)。 > 译者注: > > 首先 Additivity 的确不知道该翻译成什么更合适,感觉什么“附加性”“额外性”都不是很合适,最后觉得“多余性”更贴切些,如果有好的建议望指正。 > > 其次这个**多余性**的特点,个人认为主要是让我们使用 `Log4j2` 的时候不用为每一个 Logger 指定 `Appender` 方便配置;当然如果想单独指定 `Appender`,`Log4j2` 也是支持的。而且可以设置开关。 ```xml ``` 上面配置的输出(译者的输出): ``` 16:41:37.116 [main] TRACE com.foo.Bar - Enter 16:41:37.118 [main] ERROR com.foo.Bar - Did it again! 16:41:37.119 [main] TRACE com.foo.Bar - Exit with(false) 16:41:37.119 [main] ERROR com.foo.MyApp - Didn't do it. ``` 一旦一个日志输出到一个 `Logger`,这个 `Logger` 的 `additivity` 设置为 `false`,那么这个日志不会再继续向父 `Logger` 进行传递,忽略其他 `Logger` 的 `additivity` 的设置。 ## [如何更安全地删除文件:使用自定义`rmtrash`脚本](https://blog.dong4j.site/posts/ae39e951.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 在 Linux 系统中,`rm` 命令是一个非常强大的工具,它可以永久删除文件和目录。然而,这种强大的能力也带来了风险,因为一旦文件被删除,就很难恢复。为了防止因误操作而丢失重要数据,我们可以创建一个自定义的回收站来保存被删除的文件。以下是实现这一功能的详细步骤和脚本。 ## 背景 作为 Ubuntu 的用户,我们经常需要在命令行下执行文件删除操作。但是,命令行下的 `rm` 命令一旦执行,被删除的文件几乎不可能恢复。为了避免这种情况,我们编写了一个简单的脚本,将删除操作改为移动操作,从而实现了一个类似于 Windows 系统中回收站的功能。 ## 脚本介绍 下面提供的脚本 `rmtrash` 可以作为 `rm` 命令的替代品。它将用户指定的文件或目录移动到一个特定的“回收站”目录中,而不是直接删除它们。这样,如果需要,用户可以从回收站中恢复这些文件。 ### 脚本功能 - **移动文件到回收站**:而不是直接删除文件,脚本会将它们移动到用户家目录下的 `.rmtrash/` 目录。 - **记录删除操作**:脚本会记录所有删除操作的详细信息,包括删除的文件路径和时间,以便于恢复。 - **恢复文件**:可以从回收站中恢复文件到原始位置。 - **清空回收站**:当确认不再需要回收站中的文件时,可以清空回收站。 - **查看回收站内容**:可以列出回收站中的所有文件和目录。 - **详细日志**:可以查看已删除文件的详细日志。 ## 脚本 ```shell #!/bin/bash ###trash目录define realrm="/bin/rm" trash_dir=~/.rmtrash/ trash_log=~/.rmtrash.log ###判断trash目录是否存在,不存在则创建 if [ ! -d $trash_dir ] ;then mkdir -v $trash_dir fi ####function define ###usage function rm_usage () { cat <> $trash_log && \ echo -e "\033[31m\033[05m $file 从 $file_fullpath 被删除\033[0m" #cat $trash_log #fi ###done } ###rm list function rm_list () { echo ---------------------------- echo list trash_dir contents: ls $trash_dir } ###rm restore function rm_restore () { echo ---------------------------- echo -en "请选择要恢复的文件名(多个文件中间空格分隔,取消ctl+c):" read reply for file in $reply ;do ###判断原始位置的是否有同名文件存在 originalpath=`cat $trash_log|grep /$file$|awk '{print $5}'` if [[ `ls $originalpath` ]];then echo -en "originalpath:$originalpath 已经存在。是否继续覆盖(y/n):" read ack if [[ $ack == y ]];then echo restore: elif [[ $ack == n ]];then echo bye && exit else echo 输入非法 && exit fi fi ### mv $trash_dir$file $originalpath && \ ###linux和mac下sed的用法有细微差别,故需通过操作系统类型进行选择对应的sed格式 if [[ $os_type == Darwin ]];then sed -i .bak "/\/$file$/d" $trash_log echo os_type=Darwin elif [[ $os_type == Linux ]];then sed -i.bak "/\/$file$/d" $trash_log echo os_type=Linux fi && \ echo -e "\033[32m\033[05m$file 从 $originalpath 恢复成功\033[0m" done } ### rm show delete log function rm_infolog () { echo ---------------------------- echo detailed deleted file log: cat $trash_log } ###rm empty trash function rm_empty () { echo ---------------------------- echo -en "空回收站,回收站中的所有备份将被删除,是否继续(y/n):" read ack if [[ $ack == y ]];then echo begin to empty trash: elif [[ $ack == n ]];then echo bye && exit else echo 输入非法 && exit fi /bin/rm -fr ${trash_dir} && \ echo >$trash_log && \ echo -e "\033[31m\033[05m 垃圾桶已经被清空了\033[0m" } ###rm delete function rm_delete () { echo ---------------------------- echo -en "请选择trash中要删除的文件名(多个文件中间空格分隔,取消ctl+c):" read reply for file in $reply ;do ###if file exist then delete it from trash if [[ `ls ${trash_dir}$file` ]];then /bin/rm -fr ${trash_dir}$file && \ ###linux和mac下sed的用法有细微差别,故需通过操作系统类型进行选择对应的sed格式 if [[ $os_type == Darwin ]];then sed -i .bak "/\/$file$/d" $trash_log echo os_type=Darwin elif [[ $os_type == Linux ]];then sed -i.bak "/\/$file$/d" $trash_log echo os_type=Linux fi && \ echo -e "\033[32m\033[05m$file 从 ${trash_dir}$file 被删除\033[0m" else echo $file is not exist in $trash_dir fi done } ###跨分区的问题 #####主程序开始 ###参数个数为0,输出help if [ $# -eq 0 ] ;then rm_usage ;fi ###根据用户输入选项执行相应动作 ###通过非显示的方式(加入fr选项,但在case里不做匹配操作,遇到含-fr/-rf/-f/-r时直接删除)支持很多用户的使用习惯rm -fr file,rm -rf file while getopts lRiedhfr option ;do case "$option" in l) rm_list;; R) rm_list rm_restore;; i) rm_infolog;; h) rm_usage;; e) rm_empty;; d) rm_list rm_delete;; \?)rm_usage exit 1;; esac done shift $((OPTIND-1)) ###将文件名的参数依次传递给rm_mv函数 while [ $# -ne 0 ];do file=$1 echo file=$file rm_mv shift done ``` ### 安装脚本 1. 将上面的脚本保存为一个文件,例如 `rmtrash`。 2. 将该脚本移动到 `/usr/local/bin/` 目录下,使其全局可执行: ```bash sudo mv rmtrash /usr/local/bin/ sudo chmod +x /usr/local/bin/rmtrash ``` 3. 使用别名替换原 `rm` 命令: ```bash alias rm='rmtrash' ``` 将上述别名添加到你的 `.bashrc` 或 `.bash_profile` 文件中,以便在每次登录时自动设置。 ### 使用脚本 现在,你可以像使用 `rm` 命令一样使用 `rmtrash`。例如,删除一个文件: ```bash rm file.txt ``` 这将把 `file.txt` 移动到回收站,而不是直接删除它。 ### 恢复文件 如果你想从回收站恢复文件,可以使用 `-R` 选项: ```bash rm -R file.txt ``` 这将提示你选择要恢复的文件。 ### 清空回收站 当你确定回收站中的文件不再需要时,可以使用 `-e` 选项清空回收站: ```bash rm -e ``` ## [Log4j2深度剖析:从基础到异步优化](https://blog.dong4j.site/posts/d9189394.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) > Apache Log4j 2 是对 Log4j 的升级,它比其前身 Log4j 1.x 提供了重大改进,并提供了 Logback 中可用的许多改进,同时修复了 Logback 架构中的一些固有问题。 log4j2 是 log4j 1.x 的升级版,参考了 logback 的一些优秀的设计,并且修复了一些问题,因此带来了一些重大的提升,主要有: 主要特点 ![20241229154732_VGcdp3Tk.webp](https://cdn.dong4j.site/source/image/20241229154732_VGcdp3Tk.webp) - 异常处理,在的 logback 中,追加程序中的异常不会被应用感知到,但是在 log4j2 中,提供了一些异常处理机制。 - 性能提升,log4j2 相比于 log4j 1 和 logback 都具有很明显的性能提升,后面会有官方测试的数据。 - 自动重载配置,参考了的 logback 的设计,当然会提供自动刷新参数配置,最实用的就是我们在生产上可以动态的修改日志的级别而不需要重启应用 - 那对监控来说,是非常敏感的。 - 无垃圾机制,log4j2 在大部分情况下,都可以使用其设计的一套无垃圾机制,避免频繁的日志收集导致的 jvm gc。 ### 一些概念 之前看官方文档摘抄了一些概念,这里懒得翻译了,使用的 log4j 的都应该清楚,这里只是标记下。 ![20241229154732_2lG4dJzx.webp](https://cdn.dong4j.site/source/image/20241229154732_2lG4dJzx.webp) #### 举个栗子 ```xml target/test.log %d %p %C{1.} [%t] %m%n ``` ## 异步日志 log4j2 最大的特点就是异步日志,其性能的提升主要也是从异步日志中受益,我们来看看如何使用 log4j2 的异步日志。 Log4j2 提供了两种实现日志的方式,一个是通过 AsyncAppender,一个是通过 AsyncLogger,分别对应前面我们说的追加程序组件和记录器组件。注意这是两种不同的实现方式,在设计和源码上都是不同的体现。 ### AsyncAppender 方式 > AsyncAppender 接受对其他 Appender 的引用,并使 LogEvents 在单独的 Thread 上写入它们。请注意,写入这些 Appender 时的异常将从应用程序中隐藏。应该在它引用的 appender 之后配置 AsyncAppender 以允许它正确关闭。 > 默认情况下,AsyncAppender 使用 java.util.concurrent.ArrayBlockingQueue,它不需要任何外部库。请注意,多线程应用程序在使用此 appender 时应小心:阻塞队列容易受到锁争用的影响,并且我们的测试表明,当更多线程同时记录时性能可能会变差。考虑使用无锁异步记录器以获得最佳性能。 AsyncAppender 是通过引用别的 Appender 来实现的,当有日志事件到达时,会开启另外一个线程来处理它们。需要注意的是,如果在 Appender 的时候出现异常,对应用来说是无法感知的.AsyncAppender 应该在它引用的 Appender 之后配置,默认使用 java.util.concurrent.ArrayBlockingQueue 实现而不需要其它外部的类库。当使用此 Appender 的时候,在多线程的环境下需要注意,阻塞队列容易受到锁争用的影响,这可能会对性能产生影响。这时候,我们应该考虑使用无所的异步记录器(AsyncLogger)。 #### 举个栗子 ```xml %d %p %c{1.} [%t] %m%n ``` AsyncAppender 有一些配置项,如下: ![20241229154732_DE0JrUkE.webp](https://cdn.dong4j.site/source/image/20241229154732_DE0JrUkE.webp) 除此之外还有一些其他的细节,如果感兴趣可以参考官网文档,这里就不一一列举了。 ### AsyncLogger 方式 AsyncLogger 才是 log4j2 的重头戏,也是官方推荐的异步方式。它可以使得调用 Logger.log 返回的更快。你可以有两种选择:全局异步和混合异步。 - **异步全局**就是,所有的日志都异步的记录,在配置文件上不用做任何改动,只需要在 JVM 启动的时候增加一个参数; - **异步混合**就是,你可以在应用中同时使用同步日志和异步日志,这使得日志的配置方式更加灵活。因为 Log4j 的文档中也说了,虽然 Log4j2 提供以一套异常处理机制,可以覆盖大部分的状态,但是还是会有一小部分的特殊情况是无法完全处理的,比如我们如果是记录审计日志,那么官方就推荐使用同步日志的方式,而对于其他的一些仅仅是记录一个程序日志的地方,使用异步日志将大幅提升性能,减少对应用本身的影响。混合异步的方式需要通过修改配置文件来实现,使用 AsyncLogger 标记配置。 #### 举个栗子 **全局异步** 配置文件不用动: ```xml %d %p %c{1.} [%t] %m %ex%n ``` 在系统初始化的时候,增加全局参数配置: ```java System.setProperty("log4j2.contextSelector, "org.apache.logging.log4j.core.async.AsyncLoggerContextSelector"); ``` 你可以在你第一次获取记录器之前设置,也可以加载 JVM 启动参数里,类似 ```shell java -Dog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector ``` **混合异步** 混合异步只需要修改配置文件即可: ```xml %d %p %class{1.} [%t] %location %m %ex%n ``` 在上面示例的配置中,root logger 就是同步的,但是 com.foo.Bar 的 logger 就是异步的。 ### 使用 Log4j 的日志的注意事项 在使用异步日志的时候需要注意一些事项,如下: 1. 不要同时使用 AsyncAppender 和 AsyncLogger,也就是在配置中不要在配置追加程序的时候,使用异步标识的同时,又配置 AsyncLogger,这不会报错,但是对于性能提升没有任何好处。 2. 不要在开启了全局同步的情况下,仍然使用 AsyncAppender 和 AsyncLogger。这和上一条是同一个意思,也就是说,如果使用异步日志,AsyncAppender,AsyncLogger 和全局日志,不要同时出现。 3. 如果不是十分必须,不管是同步异步,都设置 immediateFlush 为假,这会对性能提升有很大帮助。 4,如果不是确实需要,不要打印位置信息,比如 HTML 的位置,或者模式模式里的%C 或 $ class,%F 或%文件,%l 或%位置,%L 或%行,%M 或%方法,等,因为 Log4j 需要在打印日志的时候做一次栈的快照才能获取这些信息,这对于性能来说是个极大的损耗。 ## 性能提升 关于性能测试,大家可以直奔官网,哪里有很详细的数据,这里给个图: ![20241229154732_wKN6woCU.webp](https://cdn.dong4j.site/source/image/20241229154732_wKN6woCU.webp) ![20241229154732_odaxOX5b.webp](https://cdn.dong4j.site/source/image/20241229154732_odaxOX5b.webp) 虽然我测下来,在 immediateFlush 设置为假的情况下,同步异步差不了多少,但可能是我的测试条件不符合官方的,从设计和原理上来说,异步日志,无疑是个最优的选择。 ## 小结 总的来说,看了一遍的 log4j 的官网文档,对日志系统有了个比较全面的了解,以前只是复制配置来改改,没关注过很多细节,这次算是扫盲了一次。文章也只是做了个介绍,在实际使用中,还是要细细研究下配置。 另外,个人觉得异步模式无非就是在原来同步写盘的前提下,增加消息队列作为缓存,或者交个另一个线程去做,这理论上除了带来一些额外的,较小的 CPU 和内存的开销,应该会在高流量的时候带来不小的性能提升,对比下来,log4j2 无疑是当下最值得使用的日志组件来,且可以使用其异步模式。 当然了,也不能说异步就一定好,如果日志的流量不是特别大,磁盘性能又跟得上,没有必要一定使用异步日志。 ## [Node.js 版本管理:从入门到精通](https://blog.dong4j.site/posts/19e627d5.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行时环境,广泛用于构建网络应用程序。它允许开发者使用 JavaScript 来编写服务器端代码。本文将为你提供 Node.js 的入门步骤以及一些实用工具的使用。 ## 安装 Node.js ### 1. 安装 n 模块 首先,你需要安装 `n` 模块,这是一个强大的版本控制工具,可以方便地管理多个 Node.js 版本。 ```bash sudo npm install -g n ``` ### 2. 更新到最新稳定版 使用 `n` 可以轻松升级到最新的 Node.js 稳定版: ```bash sudo n stable ``` ### 3. 升级到最新版本 如果你想要安装最新版本的 Node.js,无论是稳定版还是长期支持(LTS)版,可以使用以下命令: ```bash sudo n latest ``` ### 4. 安装指定版本 `n` 还允许你安装任意历史版本的 Node.js。你可以使用版本号来升级到特定的版本: ```bash sudo n v0.10.26 或 sudo n 0.10.26 ``` ### 5. 切换版本 要切换到某个特定的 Node.js 版本,可以使用以下命令: ```bash sudo n 7.10.0 ``` ### 6. 删除指定版本 如果你想要删除一个不再需要的旧版本的 Node.js,可以使用 `rm` 命令: ```bash sudo n rm 7.10.0 ``` ### 7. 使用指定版本运行脚本 当你需要使用某个特定版本的 Node.js 来执行脚本时,可以使用 `use` 命令: ```bash n use 7.10.0 some.js ``` ## 使用 http-server 模块 http-server 是一个简单的静态文件服务器模块,你可以将它安装到你的项目中来方便地测试和部署网站。 ### 安装 http-server 首先,确保你有一个 Node.js 环境。然后,在终端中运行以下命令: ```bash npm install -g http-server ``` ### 启动服务 一旦安装完毕,你可以在任何目录下启动一个简单的 Web 服务器。假设当前目录为 `project`,你可以通过下面的命令来启动服务: ```bash http-server . ``` 这将使得你的浏览器能够访问 `127.0.0.1:8080` 来查看你的静态文件。 ## 使用 nvm 管理 Node.js 版本 `nvm`(Node Version Manager)是一个流行的工具,用于在单个系统上安装多个 Node.js 版本并切换它们。下面是如何使用它: ### 安装 nvm 首先,你需要从 GitHub 克隆 `nvm` 的源代码仓库到你的系统中。 ```bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash ``` ### 安装指定版本 安装特定的 Node.js 版本: ```bash nvm install v10 ``` ### 切换版本 切换到已安装的特定版本: ```bash nvm use v10 ``` ## [掌握 CentOS 环境下的 Python 技巧](https://blog.dong4j.site/posts/44bde9f9.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 在 Linux 系统中,Python 是一个非常流行的编程语言,被广泛应用于 Web 开发、数据分析、人工智能等多个领域。本文将详细介绍如何在 CentOS 操作系统中安装和配置 Python 环境。 ## 安装步骤 ### 1. 解压 Python 包 首先,你需要下载 Python 的源代码包并将其解压到你的服务器上。由于你要求不使用外部工具安装 Python,我们将手动进行这一步。 ```bash # 假设你已经下载了 Python 的源码压缩包到 /usr/local/src 目录下 tar -xzf Python-3.8.x.tar.gz -C /usr/local/src ``` ### 2. 进入解压后的文件夹 接下来,进入 Python 的源代码目录: ```bash cd /usr/local/src/Python-3.8.x ``` ### 3. 配置和安装 Python 现在,我们可以执行 configure 脚本来配置 Python 的编译选项。如果你需要指定安装目录,可以添加 `--prefix` 参数。 ```bash ./configure --prefix=/opt/python38 make && make install ``` 这个步骤会编译并安装 Python 到指定的目录 `/opt/python38`。 ## 查看已安装的模块 进入 Python 交互环境: ```bash python3 ``` 然后,使用 `help("modules")` 命令来查看所有已经安装的 Python 模块。 ## 查看 pip 版本 要查看当前系统上安装的 pip 版本,可以直接在命令行运行以下命令: ```bash pip -V ``` 或者,如果你使用的是 Python 3.x 版本,你可能需要使用以下命令: ```bash python3 -m pip -V ``` ## 使用 httpstat 模块测试 HTTP 响应 `httpstat` 是一个 Python 模块,可以用来检查 HTTP 请求的响应。下面是如何使用它来测试一个本地服务的例子: ```bash export HTTPSTAT_SHOW_BODY=true python /path/to/httpstat.py http://127.0.0.1:8080/json ``` 确保将 `/path/to/httpstat.py` 替换为 `httpstat.py` 文件的正确路径。 ## 自动生成和安装 requirements.txt 依赖 在处理 Python 项目时,`requirements.txt` 文件是非常重要的。它列出了项目依赖的所有模块及其版本号。以下是自动创建和使用这个文件的方法: ### 生成 requirements.txt 文件 ```bash pip freeze > requirements.txt ``` ### 安装 requirements.txt 列出的依赖 ```bash pip install -r requirements.txt ``` 这样,你就可以确保在不同的环境中使用相同版本的依赖项。 ## 运行简单的 Web 服务器 Python 内置了一个非常方便的 HTTP 服务器模块 `SimpleHTTPServer`,可以用来快速测试你的本地文件。下面是如何启动一个简单的 HTTP 服务器的示例: ```bash python -m SimpleHTTPServer 7777 ``` 或者使用 Python 3.x 版本: ```bash python -m http.server 7777 ``` 这些命令将在你指定的端口(在这个例子中是 7777)上启动一个简单的 HTTP 服务器。你可以通过浏览器或工具如 Postman 来访问这个服务器。 ## [Redis 数据结构详解:从基本到高级](https://blog.dong4j.site/posts/ae6109b1.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Redis 是一个开源的内存数据结构存储系统,它提供了多种数据类型和丰富的操作命令,使得它在处理快速读写和高并发场景下表现出色。下面将详细介绍 Redis 的主要特性和应用场景。 ## 1. 支持持久化 ### RDB 持久化 RDB 是 Redis 的本地持久化方式,通过周期性地将内存中的数据写入磁盘来保存数据状态。在 `redis.conf` 配置文件中,可以通过设置定时任务或手动触发 RDB 快照。 ### AOF 持久化 AOF(Append Only File)是另一种持久化方法,它记录每次写操作到文件中。当 Redis 重启时,它会重新执行这些命令来重建数据状态。开启 AOF 持久化对于防止数据丢失非常有用,但它可能会消耗更多磁盘空间。 ## 2. 丰富的数据类型 Redis 支持多种数据类型,包括: - **String**:最简单的键值对存储。 - **List**:列表,可以用于消息队列等场景。 - **Set**:集合,支持成员的唯一性和操作如求交集、并集等。 - **Sorted Set**:有序集合,常用于排行榜应用。 - **Hash**:哈希表,适合用于存储结构化数据。 这些数据类型提供了高效的数据结构和丰富的操作命令,使得 Redis 可以适应各种不同的场景和需求。 ## 3. 高性能 Redis 以内存操作为主,其读写速度远远超过传统的磁盘存储系统。它减少了磁头寻道、数据读取等开销,因此在需要高速读写操作的场景下非常有用。 ## 4. Replication(复制) Redis 支持主从复制,允许从一个主节点同步数据到一个或多个从节点。这种复制方式类似于 MySQL 的主从复制,可以保证数据的冗余和高可用性。Redis 主从复制的过程是增量复制,即只复制主节点的写命令到从节点。 ## 5. 更新快 Redis 是一个活跃的开源项目,其版本更新频率较高。这得益于 Redis 作者的积极维护和社区的贡献。Redis 的最新版本通常会修复已知问题并引入新的功能。 ## 6. 允许远程访问 默认情况下,Redis 只允许本机访问。如果你需要远程访问 Redis,可以通过修改 `redis.conf` 文件中的 `bind` 参数来开启远程连接。例如: ```bash bind 127.0.0.1 ``` 可以将上述配置改为: ```bash # bind 127.0.0.1 # 可以替换为允许的 IP 地址,如 bind 192.168.1.100 ``` 此外,Redis 还可以通过 `protected-mode` 参数来控制是否开启保护模式。 ## 7. Redis 验证 如果你需要安全地访问 Redis,可以使用密码验证。在启动 Redis 服务时,可以使用 `-a` 参数指定密码: ```bash redis-server -a 密码 ``` 在客户端连接 Redis 时,也需要提供正确的密码才能成功连接。 ## 8. Redis Sdiff 命令 Redis 的 `Sdiff` 命令用于返回给定集合之间的差集。这个命令可以用来找出不同集合之间不共有的元素。 ## 9. 批量删除指定前缀的 key 如果你需要批量删除具有特定前缀的键,可以使用 `keys` 命令配合管道(pipe)和 `del` 命令来实现: ```bash redis-cli -h 172.31.205.58 -p 6379 keys 'mobileprefix:*' | xargs redis-cli -h 172.31.205.58 -p 6379 del ``` ## [RESTful API 入门指南:从概念到实战](https://blog.dong4j.site/posts/1fce8c1c.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) **RESTful API 是一种网络应用架构风格,强调通过统一的接口和资源操作设计 API。它使用 HTTP 协议中的不同方法来创建、读取、更新和删除资源,让开发者和用户能够直观地理解和操作应用程序。** ### RESTful API 设计原则 #### 1. 资源导向 RESTful API 以资源为中心进行设计。每个资源都有一个唯一的 URI,客户端可以通过这个 URI 访问相应的资源。 - **URL 示例**: - GET /zoos:查看所有动物园 - POST /zoos:创建一个新动物园 - GET /zoos/1:获取指定动物园的信息 #### 2. 方法使用 RESTful API 使用 HTTP 协议中的不同方法来执行不同的操作: - **GET**:从服务器检索资源。 - **POST**:在服务器上新建一个资源。 - **PUT**:更新资源(客户端提供改变后的完整资源)。 - **PATCH**:部分更新资源(客户端提供改变的属性)。 - **DELETE**:删除资源。 #### 3. 状态码使用 RESTful API 使用标准的 HTTP 状态码来表示请求的结果: - 200 OK:成功检索数据 - 201 Created:创建新资源成功 - 204 No Content:删除或更新资源后,响应体为空 - 400 Bad Request:客户端请求有误 - 404 Not Found:资源不存在 #### 4. 内容类型和编码 `consumes` 和 `produces` 用于指定请求和返回的数据格式: - `consumes`: 指定处理请求的提交内容类型,如 application/json, text/html。 - `produces`: 指定返回值类型,不仅可以设置返回值类型,还可以设定返回值的字符编码。 ### 实例解析 以下是一些 RESTful API 的实际应用示例: #### 动物园资源操作 - **获取所有动物园**: - HTTP 方法: GET - URI: `/zoos` - **创建一个新动物园**: - HTTP 方法: POST - URI: `/zoos` - **获取指定动物园信息**: - HTTP 方法: GET - URI: `/zoos/1` - **更新指定动物园信息**: - HTTP 方法: PUT - URI: `/zoos/1` - **部分更新指定动物园信息**: - HTTP 方法: PATCH - URI: `/zoos/1` - **删除特定动物园**: - HTTP 方法: DELETE - URI: `/zoos/1` #### 员工资源操作 - **获取指定动物园的员工列表**: - HTTP 方法: GET - URI: `/zoos/1/employees` - **为指定动物园添加新员工**: - HTTP 方法: POST - URI: `/zoos/1/employees` #### 动物资源操作 - **获取所有动物信息**: - HTTP 方法: GET - URI: `/animals` - **创建一个新的动物**: - HTTP 方法: POST - URI: `/animals` - **更新特定动物的信息(全量)**: - HTTP 方法: PUT - URI: `/animals/1` #### 票务资源操作 - **获取所有票务信息**: - HTTP 方法: GET - URI: `/tickets` - **查看某个具体的票务信息**: - HTTP 方法: GET - URI: `/tickets/12` - **新建一个票务信息**: - HTTP 方法: POST - URI: `/tickets` - **更新特定票务信息**: - HTTP 方法: PUT - URI: `/tickets/12` - **删除某个特定的票务信息**: - HTTP 方法: DELETE - URI: `/tickets/12` ### 总结 RESTful API 设计应遵循资源导向、方法使用规范、状态码的合理分配以及内容类型和编码的设置等原则。这些最佳实践有助于创建清晰、易于理解和使用的 API,从而提升开发效率和用户体验。 ## [从零开始:掌握Web会话管理的四种方式](https://blog.dong4j.site/posts/b1cd2b93.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Web 服务器一旦发出响应,一个请求响应过程就结束了. 当再次发出请求时,Web 服务器不记得曾就做过的请求,也不记得给用户发出过响应.,这就是 http 的无状态模式 当需要跨多个请求需要保留与客户端会话状态时,我们有 4 种解决方案 ## 表单隐藏字段 `` 作用: 1. 对用户在网上的访问进行会话跟踪 2. 为服务器提供预定义的输入 3. 存储动态产生的网页的上下文信息 缺点: 只有当每个网页是动态生成的才有效 ## Cookie 将数据已键值对的形式通过响应保存在客户端 方法: 1. Cookie(name,value) 2. get/setComment(String comment): 注释 3. get/setDomain(String domainPattern): 得到/设置应用 Cookie 的域 4. setMaxAge(int lifetime) 设置过期时间,默认为负数,表示在关闭浏览器后过期 5. getMaxAge() 6. get/setName(String name) 7. get/setValue(String value) 将 Cookie 发送到客户端的步骤 1. 创建一个或多个 Cookie 2. 使用 setXXX 方法设置 Cookie 的可选属性 3. 使用 HttpServletResponse 对象的 addCookie() 方法将 Cookie 插入到响应头中 读取客户端传递过来的 Cookie 的步骤 1. 使用 HttpServletRequset 对象的 getCookie 方法返回一个 Cookie 对象数组 2. Servlet 遍历该数组 (getName()),直到找到名称相匹配的 Cookie 值 ## Session 在服务器端为客户端创建一个唯一的 Session 对象,用于存放数据. 在创建 Session 对象的同时,服务器会为该对象创建一个唯一的 SesionID,存储在客户端的 Cookie 当中 HttpSession 接口中的方法 1. get/setAttribute(String key,Object obj) 2. removeAttribute(String key) 3. getCreationTime() : 返回第一次创建会话的时间 4. getLastAccessedTime() : 返回容器最后一次得到该会话 ID 的请求时间 5. get/setMaxInactiveInterval(int time) : 存活时间 6. invalidate(): 结束会话 7. getId() ### 会话超时管理 结束会话的 3 种情况 1. 服务器重启或崩溃 2. 调用 invalidate() 方法 3. 会话超时 在 web.xml 中设置会话时间 单位是分钟 ```xml 15 ``` 在程序中设置超时时间的单位是秒 ### Application 和 Session 域范围的属性 Application ```java ServletContext sc = this.getServletConfig().getServletContext(); ``` ### Session 持久化 Tomcat 提供的 2 个类来管理 Session 对象 1. StandardManager(默认) - 当服务器关闭或应用重新加载的时候在 work/Catalina/localhost/web/应用名 下创建名为 session.ser 的文件,将 session 信息写入,当宠幸加载或重启时读取这个文件,然后删除 2. PersistentManager ## URL 重写 由于 Session 依赖于 Cookie,当浏览器关闭 Cookie 时将不能使用 Cookie 和 Session,当这种情况发生时,URL 重写就派上用场了. URL 重写是将 SessionID 写入到 URL 当中,就不需要 Cookie 保存 SessionID URL;jsessionid=id; 重定向中使用 URL 重写 ```java HttpSession session = requset.getSession(); String url = resquest.encodeRedirectURL("/aa/bb.html"); request.sendRedirect(url); ``` encodeURL() 是本应用级别的,encodeRedirectURL() 是跨应用的。 1. response.encodeRedirectURL(url) 是一个进行 URL 重写的方法, 使用这个方法的作用是为了在原来的 url 后面追加上 Jsessionid 。 目的是保证即使在客户端浏览器禁止了 cookie 的情况下,服务器端仍然能够对其进行事务跟踪. 2. response.sendRedirect(url) 是一个 url 重定向的方法, 服务器端的通过该方法,“告诉”客户端的浏览器去访问 url 所指向的资源 ## 总结 Http 协议使用的是无状态连接 相当于每一个请求都是来自于新客户 服务器不保存客户的信息 解决方案 1. 表单隐藏字段 hidden 缺点 : 类型被限制,只能使用字符串 要一层一层的传递,对不需要使用信息的网页也需要使用 hidden 2. Cookie (客户端) ```java Cookie cookie = new Cookie("cool","tiger"); addCookie(); ``` 3. Session (服务端) SessionID 存到客户端的 Cookie 中 4. URL 重写 把 SessionID 加到 URL 后面 ## [Spring MVC 优化:敏感词过滤与Token验证的集成](https://blog.dong4j.site/posts/f335982.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### APP 服务端的 Token 验证 通过拦截器对使用了 @Authorization 注解的方法进行请求拦截,从 http header 中取出 token 信息,验证其是否合法。非法直接返回 401 错误,合法将 token 对应的 user key 存入 request 中后继续执行。具体实现代码: ```java public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //如果不是映射到方法直接通过 if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; Method method = handlerMethod.getMethod(); //从header中得到token String token = request.getHeader(httpHeaderName); if (token != null && token.startsWith(httpHeaderPrefix) && token.length() > 0) { token = token.substring(httpHeaderPrefix.length()); //验证token String key = manager.getKey(token); if (key != null) { //如果token验证成功,将token对应的用户id存在request中,便于之后注入 request.setAttribute(REQUEST_CURRENT_KEY, key); return true; } } //如果验证token失败,并且方法注明了Authorization,返回401错误 if (method.getAnnotation(Authorization.class) != null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setCharacterEncoding("gbk"); response.getWriter().write(unauthorizedErrorMessage); response.getWriter().close(); return false; } //为了防止以某种直接在REQUEST_CURRENT_KEY写入key,将其设为null request.setAttribute(REQUEST_CURRENT_KEY, null); return true; } ``` 通过拦截器后,使用解析器对修饰了 @CurrentUser 的参数进行注入。从 request 中取出之前存入的 user key,得到对应的 user 对象并注入到参数中。具体实现代码: ```java @Override public boolean supportsParameter(MethodParameter parameter) { Class clazz; try { clazz = Class.forName(userModelClass); } catch (ClassNotFoundException e) { return false; } //如果参数类型是User并且有CurrentUser注解则支持 if (parameter.getParameterType().isAssignableFrom(clazz) && parameter.hasParameterAnnotation(CurrentUser.class)) { return true; } return false; } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { //取出鉴权时存入的登录用户Id Object object = webRequest.getAttribute(AuthorizationInterceptor.REQUEST_CURRENT_KEY, RequestAttributes.SCOPE_REQUEST); if (object != null) { String key = String.valueOf(object); //从数据库中查询并返回 Object userModel = userModelRepository.getCurrentUser(key); if (userModel != null) { return userModel; } //有key但是得不到用户,抛出异常 throw new MissingServletRequestPartException(AuthorizationInterceptor.REQUEST_CURRENT_KEY); } //没有key就直接返回null return null; } ``` ### 使用别名接受对象的参数 请求中的参数名和代码中定义的参数名不同是很常见的情况,对于这种情况[spring](http://lib.csdn.net/base/javaee "Java EE知识库")提供了几种原生的方法: 对于 @RequestParam 可以直接指定 value 值为别名( @RequestHeader 也是一样),例如: ```java public String home(@RequestParam("user_id") long userId) { return "hello " + userId; } ``` 对于 @RequestBody ,由于其使使用 Jackson 将 Json 转换为对象,所以可以使用 @JsonProperty 的 value 指定别名,例如: ```java public String home(@RequestBody User user) { return "hello " + user.getUserId(); } class User { @JsonProperty("user_id") private long userId; } ``` 但是使用对象的属性接受参数时,就无法直接通过上面的办法指定别名了,例如: ```java public String home(User user) { return "hello " + user.getUserId(); } ``` 这时候需要使用 DataBinder 手动绑定属性和别名,我在 StackOverFlow 上找到的  [这篇文章](http://stackoverflow.com/questions/8986593/how-to-customize-parameter-names-when-binding-spring-mvc-command-objects)  是个不错的办法,这里就不重复造轮子了。 ### 关闭默认通过请求的后缀名判断 Content-Type 之前接手的项目的开发习惯是使用.html 作为请求的后缀名,这在 Struts2 上是没有问题的(因为本身 Struts2 处理 Json 的几种方法就都很烂)。但是我接手换成 Spring MVC 后,使用 @ResponseBody 返回对象时就会报找不到转换器错误。 这是因为 Spring MVC 默认会将后缀名为.html 的请求的 Content-Type 认为是 text/html ,而 @ResponseBody 返回的 Content-Type 是 application/json ,没有任何一种转换器支持这样的转换。所以需要手动将通过后缀名判断 Content-Type 的设置关掉,并将默认的 Content-Type 设置为 application/json : ```java @Configuration public class WebMvcConfig extends WebMvcConfigurerAdapter { @Override public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { configurer.favorPathExtension(false). defaultContentType(MediaType.APPLICATION_JSON); } } ``` ### 更改默认的 Json 序列化方案 项目中有时候会有自己独特的 Json 序列化方案,例如比较常用的使用 0 / 1 替代 false / true ,或是通过 "" 代替 null ,由于 @ResponseBody 默认使用的是 MappingJackson2HttpMessageConverter ,只需要将自己实现的 ObjectMapper 传入这个转换器: ```java public class CustomObjectMapper extends ObjectMapper { public CustomObjectMapper() { super(); this.getSerializerProvider().setNullValueSerializer(new JsonSerializer() { @Override public void serialize(Object value, JsonGenerator jgen, SerializerProvider provider) throws IOException { jgen.writeString(""); } }); SimpleModule module = new SimpleModule(); module.addSerializer(boolean.class, new JsonSerializer() { @Override public void serialize(Boolean value, JsonGenerator jgen, SerializerProvider provider) throws IOException { jgen.writeNumber(value ? 1 : 0); } }); this.registerModule(module); } } ``` ### 自动加密/解密请求中的 Json 涉及到 @RequestBody 和 @ResponseBody 的类型转换问题一般都在 MappingJackson2HttpMessageConverter 中解决,想要自动加密/解密只需要继承这个类并重写 readInternal / writeInternal 方法即可: ```java @Override protected Object readInternal(Class clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { //解密 String json = AESUtil.decrypt(inputMessage.getBody()); JavaType javaType = getJavaType(clazz, null); //转换 return this.objectMapper.readValue(json, javaType); } @Override protected void writeInternal(Object object, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { //使用Jackson的ObjectMapper将Java对象转换成Json String ObjectMapper mapper = new ObjectMapper(); String json = mapper.writeValueAsString(object); //加密 String result = AESUtil.encrypt(json); //输出 outputMessage.getBody().write(result.getBytes()); } ``` ### 基于注解的敏感词过滤功能 项目需要对用户发布的内容进行过滤,将其中的敏感词替换为 `*` 等特殊字符。大部分 Web 项目在处理这方面需求时都会选择过滤器( Filter ),在过滤器中将 Request 包上一层 Wrapper ,并重写其 getParameter 等方法,例如: ```java public class SafeTextRequestWrapper extends HttpServletRequestWrapper { public SafeTextRequestWrapper(HttpServletRequest req) { super(req); } @Override public Map getParameterMap() { Map paramMap = super.getParameterMap(); for (String[] values : paramMap.values()) { for (int i = 0; i < values.length; i++) { values[i] = SensitiveUtil.filter(values[i]); } } return paramMap ; } @Override public String getParameter(String name) { return SensitiveUtil.filter(super.getParameter(name)); } } public class SafeTextFilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { SafeTextRequestWrapper safeTextRequestWrapper = new SafeTextRequestWrapper((HttpServletRequest) request); chain.doFilter(safeTextRequestWrapper, response); } @Override public void destroy() { } } ``` 但是这样做会有一些明显的问题,比如无法控制具体对哪些信息进行过滤。如果用户注册的邮箱或是密码中也带有 fuck 之类的敏感词,那就属于误伤了。 所以改用 Spring MVC 的 Formatter 进行拓展,只需要在 @RequestParam 的参数上使用 @SensitiveFormat 注解,Spring MVC 就会在注入该属性时自动进行敏感词过滤。既方便又不会误伤,实现方法如下: 声明 @SensitiveFormat 注解: ```java @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface SensitiveFormat { } ``` 创建 SensitiveFormatter 类。实现 Formatter 接口,重写 parse 方法(将接收到的内容转换成对象的方法),在该方法中对接收内容进行过滤: ```java public class SensitiveFormatter implements Formatter { @Override public String parse(String text, Locale locale) throws ParseException { return SensitiveUtil.filter(text); } @Override public String print(String object, Locale locale) { return object; } } ``` 创建 SensitiveFormatAnnotationFormatterFactory 类,实现 AnnotationFormatterFactory 接口,将 @SensitiveFormat 与 SensitiveFormatter 绑定: ```java public class SensitiveFormatAnnotationFormatterFactory implements AnnotationFormatterFactory { @Override public Set> getFieldTypes() { Set> fieldTypes = new HashSet<>(); fieldTypes.add(String.class); return fieldTypes; } @Override public Printer getPrinter(SensitiveFormat annotation, Class fieldType) { return new SensitiveFormatter(); } @Override public Parser getParser(SensitiveFormat annotation, Class fieldType) { return new SensitiveFormatter(); } } ``` 最后将 SensitiveFormatAnnotationFormatterFactory 注册到 Spring MVC 中: ```java @Configuration public class WebMvcConfig extends WebMvcConfigurerAdapter { @Override public void addFormatters(FormatterRegistry registry) { registry.addFormatterForFieldAnnotation(new SensitiveFormatAnnotationFormatterFactory()); super.addFormatters(registry); } } ``` ### 记录请求的返回内容 这里提供一种比较通用的方法,基于过滤器实现,所以在非 Spring MVC 的项目也可以使用。 首先导入 commons-io : ```java commons-io commons-io 2.4 ``` 需要用到这个库中的 TeeOutputStream ,这个类可以将一个将内容同时输出到两个分支的输出流,将其封装为 ServletOutputStream : ```java public class TeeServletOutputStream extends ServletOutputStream { private final TeeOutputStream teeOutputStream; public TeeServletOutputStream(OutputStream one, OutputStream two) { this.teeOutputStream = new TeeOutputStream(one, two); } @Override public boolean isReady() { return false; } @Override public void setWriteListener(WriteListener listener) { } @Override public void write(int b) throws IOException { this.teeOutputStream.write(b); } @Override public void flush() throws IOException { super.flush(); this.teeOutputStream.flush(); } @Override public void close() throws IOException { super.close(); this.teeOutputStream.close(); } } ``` 然后创建一个过滤器,将原有的 response 的 getOutputStream 方法重写: ```java public class LoggingFilter implements Filter { private static final Logger LOGGER = LoggerFactory.getLogger(LoggingFilter.class); @Override public void init(FilterConfig filterConfig) throws ServletException { } public void doFilter(ServletRequest request, final ServletResponse response, FilterChain chain) throws IOException, ServletException { final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); HttpServletResponseWrapper responseWrapper = new HttpServletResponseWrapper((HttpServletResponse) response) { private TeeServletOutputStream teeServletOutputStream; @Override public ServletOutputStream getOutputStream() throws IOException { return new TeeServletOutputStream(super.getOutputStream(), byteArrayOutputStream); } }; chain.doFilter(request, responseWrapper); String responseLog = byteArrayOutputStream.toString(); if (LOGGER.isInfoEnabled() && !StringUtil.isEmpty(responseLog)) { LOGGER.info(responseLog); } } @Override public void destroy() { } } ``` 将 super.getOutputStream() 和 ByteArrayOutputStream 分别作为两个分支流,前者会将内容返回给客户端,后者使用 toString 方法即可获得输出内容。 ## [基于 Spring 的 Token 鉴权系统搭建教程](https://blog.dong4j.site/posts/2c481faa.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### 什么是 REST REST (Representational State Transfer) 是一种软件架构风格。它将服务端的信息和功能等所有事物统称为资源,客户端的请求实际就是对资源进行操作,它的主要特点有: - 每一个资源都会对应一个独一无二的 url - 客户端通过 HTTP 的 GET、POST、PUT、DELETE 请求方法对资源进行查询、创建、修改、删除操作 - 客户端与服务端的交互必须是无状态的 ### 使用 Token 进行身份鉴权 网站应用一般使用 Session 进行登录用户信息的存储及验证,而在移动端使用 Token 则更加普遍。它们之间并没有太大区别,Token 比较像是一个更加精简的自定义的 Session。Session 的主要功能是保持会话信息,而 Token 则只用于登录用户的身份鉴权。所以在移动端使用 Token 会比使用 Session 更加简易并且有更高的安全性,同时也更加符合 RESTful 中无状态的定义。 ### 交互流程 1. 客户端通过登录请求提交用户名和密码,服务端验证通过后生成一个 Token 与该用户进行关联,并将 Token 返回给客户端。 2. 客户端在接下来的请求中都会携带 Token,服务端通过解析 Token 检查登录状态。 3. 当用户退出登录、其他终端登录同一账号(被顶号)、长时间未进行操作时 Token 会失效,这时用户需要重新登录。 ### 程序示例 服务端生成的 Token 一般为随机的非重复字符串,根据应用对安全性的不同要求,会将其添加时间戳(通过时间判断 Token 是否被盗用)或 url 签名(通过请求地址判断 Token 是否被盗用)后加密进行传输。在本文中为了演示方便,仅是将 User Id 与 Token 以 `_` 进行拼接。 ```java /** * Token 的 Model 类,可以增加字段提高安全性,例如时间戳、url 签名 */ public class TokenModel { // 用户 id private long userId; // 随机生成的 uuid private String token; public TokenModel (long userId, String token) { this.userId = userId; this.token = token; } public long getUserId () { return userId; } public void setUserId (long userId) { this.userId = userId; } public String getToken () { return token; } public void setToken (String token) { this.token = token; } } ``` Redis 是一个 Key-Value 结构的内存数据库,用它维护 User Id 和 Token 的映射表会比传统数据库速度更快,这里使用 Spring-Data-Redis 封装的 TokenManager 对 Token 进行基础操作: ```java /** * 对 token 进行操作的接口 */ public interface TokenManager { /** * 创建一个 token 关联上指定用户 * @param userId 指定用户的 id * @return 生成的 token */ public TokenModel createToken (long userId); /** * 检查 token 是否有效 * @param model token * @return 是否有效 */ public boolean checkToken (TokenModel model); /** * 从字符串中解析 token * @param authentication 加密后的字符串 * @return */ public TokenModel getToken (String authentication); /** * 清除 token * @param userId 登录用户的 id */ public void deleteToken (long userId); } /** * 通过 Redis 存储和验证 token 的实现类 */ @Component public class RedisTokenManager implements TokenManager { private RedisTemplate redis; @Autowired public void setRedis (RedisTemplate redis) { this.redis = redis; // 泛型设置成 Long 后必须更改对应的序列化方案 redis.setKeySerializer (new JdkSerializationRedisSerializer ()); } public TokenModel createToken (long userId) { // 使用 uuid 作为源 token String token = UUID.randomUUID ().toString ().replace ("-", ""); TokenModel model = new TokenModel (userId, token); // 存储到 redis 并设置过期时间 redis.boundValueOps (userId).set (token, Constants.TOKEN_EXPIRES_HOUR, TimeUnit.HOURS); return model; } public TokenModel getToken (String authentication) { if (authentication == null || authentication.length () == 0) { return null; } String [] param = authentication.split ("_"); if (param.length != 2) { return null; } // 使用 userId 和源 token 简单拼接成的 token,可以增加加密措施 long userId = Long.parseLong (param [0]); String token = param [1]; return new TokenModel (userId, token); } public boolean checkToken (TokenModel model) { if (model == null) { return false; } String token = redis.boundValueOps (model.getUserId ()).get (); if (token == null || !token.equals (model.getToken ())) { return false; } // 如果验证成功,说明此用户进行了一次有效操作,延长 token 的过期时间 redis.boundValueOps (model.getUserId ()).expire (Constants.TOKEN_EXPIRES_HOUR, TimeUnit.HOURS); return true; } public void deleteToken (long userId) { redis.delete (userId); } } ``` RESTful 中所有请求的本质都是对资源进行 CRUD 操作,所以登录和退出登录也可以抽象为对一个 Token 资源的创建和删除,根据该想法创建 Controller: ```java /** * 获取和删除 token 的请求地址,在 Restful 设计中其实就对应着登录和退出登录的资源映射 */ @RestController @RequestMapping ("/tokens") public class TokenController { @Autowired private UserRepository userRepository; @Autowired private TokenManager tokenManager; @RequestMapping (method = RequestMethod.POST) public ResponseEntity login (@RequestParam String username, @RequestParam String password) { Assert.notNull (username, "username can not be empty"); Assert.notNull (password, "password can not be empty"); User user = userRepository.findByUsername (username); if (user == null || // 未注册 !user.getPassword ().equals (password)) { // 密码错误 // 提示用户名或密码错误 return new ResponseEntity (ResultModel.error (ResultStatus.USERNAME_OR_PASSWORD_ERROR), HttpStatus.NOT_FOUND); } // 生成一个 token,保存用户登录状态 TokenModel model = tokenManager.createToken (user.getId ()); return new ResponseEntity (ResultModel.ok (model), HttpStatus.OK); } @RequestMapping (method = RequestMethod.DELETE) @Authorization public ResponseEntity logout (@CurrentUser User user) { tokenManager.deleteToken (user.getId ()); return new ResponseEntity (ResultModel.ok (), HttpStatus.OK); } } ``` 这个 Controller 中有两个自定义的注解分别是`@Authorization`和`@CurrentUser`,其中`@Authorization`用于表示该操作需要登录后才能进行: ```java /**  * 在 Controller 的方法上使用此注解,该方法在映射时会检查用户是否登录,未登录返回 401 错误  * @author ScienJus  * @date 2015/7/31.  */ @Target (ElementType.METHOD) @Retention (RetentionPolicy.RUNTIME) public @interface Authorization { }/** * 在 Controller 的方法上使用此注解,该方法在映射时会检查用户是否登录,未登录返回 401 错误 */ @Target (ElementType.METHOD) @Retention (RetentionPolicy.RUNTIME) public @interface Authorization { } ``` 这里使用 Spring 的拦截器完成这个功能,该拦截器会检查每一个请求映射的方法是否有`@Authorization`注解,并使用 TokenManager 验证 Token,如果验证失败直接返回 401 状态码(未授权): ```java /** * 自定义拦截器,判断此次请求是否有权限 */ @Component public class AuthorizationInterceptor extends HandlerInterceptorAdapter { @Autowired private TokenManager manager; public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 如果不是映射到方法直接通过 if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; Method method = handlerMethod.getMethod (); // 从 header 中得到 token String authorization = request.getHeader (Constants.AUTHORIZATION); // 验证 token TokenModel model = manager.getToken (authorization); if (manager.checkToken (model)) { // 如果 token 验证成功,将 token 对应的用户 id 存在 request 中,便于之后注入 request.setAttribute (Constants.CURRENT_USER_ID, model.getUserId ()); return true; } // 如果验证 token 失败,并且方法注明了 Authorization,返回 401 错误 if (method.getAnnotation (Authorization.class) != null) { response.setStatus (HttpServletResponse.SC_UNAUTHORIZED); return false; } return true; } } ``` `@CurrentUser`注解定义在方法的参数中,表示该参数是登录用户对象。这里同样使用了 Spring 的解析器完成参数注入: ```java /** * 在 Controller 的方法参数中使用此注解,该方法在映射时会注入当前登录的 User 对象 */ @Target (ElementType.PARAMETER) @Retention (RetentionPolicy.RUNTIME) public @interface CurrentUser { } /** * 增加方法注入,将含有 CurrentUser 注解的方法参数注入当前登录用户 */ @Component public class CurrentUserMethodArgumentResolver implements HandlerMethodArgumentResolver { @Autowired private UserRepository userRepository; @Override public boolean supportsParameter (MethodParameter parameter) { // 如果参数类型是 User 并且有 CurrentUser 注解则支持 if (parameter.getParameterType ().isAssignableFrom (User.class) && parameter.hasParameterAnnotation (CurrentUser.class)) { return true; } return false; } @Override public Object resolveArgument (MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { // 取出鉴权时存入的登录用户 Id Long currentUserId = (Long) webRequest.getAttribute (Constants.CURRENT_USER_ID, RequestAttributes.SCOPE_REQUEST); if (currentUserId != null) { // 从数据库中查询并返回 return userRepository.findOne (currentUserId); } throw new MissingServletRequestPartException (Constants.CURRENT_USER_ID); } } ``` ### 一些细节 - 登录请求一定要使用 HTTPS,否则无论 Token 做的安全性多好密码泄露了也是白搭 - Token 的生成方式有很多种,例如比较热门的有 JWT(JSON Web Tokens)、OAuth 等。 ## [@Autowired 和 @Resource:哪种方式更适合你的项目?](https://blog.dong4j.site/posts/499585b.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 1. **作用**: 两者都可以用来装配 bean,可以写在字段上或写在 setter 方法上。 2. **默认注入方式**: - **@Autowired (Spring 注解)**: 默认按类型 (byType) 注入。 - **@Resource (J2EE 注解)**: 默认按名称 (byName) 注入。 3. **required 属性**: - **@Autowired**: 默认要求依赖对象必须存在,可以设置 required=false 允许 null 值。 - **@Resource**: 默认情况下,找不到匹配的 bean 会抛出异常。 4. **指定名称**: - **@Autowired**: 可以结合 @Qualifier 注解指定 bean 的名称。 - **@Resource**: 可以直接在注解中通过 name 属性指定 bean 的名称。 5. **代码示例**: ```java // 使用 @Autowired @Autowired @Qualifier("baseDao") private BaseDao baseDao; // 使用 @Resource @Resource(name="baseDao") private BaseDao baseDao; ``` ## Spring 注解 @Resource 和 @Autowired 区别对比 1. **共同点**: - 两者都可以写在字段和 setter 方法上。 - 如果都写在字段上,则不需要再写 setter 方法。 2. **不同点**: - **@Autowired**: - 默认按类型 (byType) 注入。 - 可以设置 required 属性允许 null 值。 - 可以结合 @Qualifier 注解指定 bean 的名称。 - **@Resource**: - 默认按名称 (byName) 注入。 - 可以直接在注解中通过 name 属性指定 bean 的名称。 - 也可以通过 type 属性按类型注入。 - 如果既不指定 name 也不指定 type,则默认按 byName 注入。 3. **注入顺序**: - **@Resource**: 1. 如果指定了 name 和 type,则查找唯一匹配的 bean。 2. 如果指定了 name,则查找名称匹配的 bean。 3. 如果指定了 type,则查找类型匹配的 bean。 4. 如果既不指定 name 也不指定 type,则先按 byName 注入,找不到则按 byType 注入。 ## 总结 - **选择建议**: - 如果更倾向于按类型注入,建议使用 @Autowired。 - 如果更倾向于按名称注入,或者希望减少与 Spring 的耦合,建议使用 @Resource。 - **使用场景**: _ @Autowired 适用于大多数情况,尤其是在依赖类型明确的场景下。 _ @Resource 适用于依赖名称明确的场景,或者希望减少与 Spring 耦合的场景。 希望以上信息对您有所帮助! ## [从零开始:IntelliJ IDEA 中构建 SpringMVC+Spring+MyBatis 项目](https://blog.dong4j.site/posts/aca063f7.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 使用 Intellij idea 整合 SpringMVC+Spring+Mybatis 框架,基于 Annotation. ## intellij 配置 1.新建一个 Spring 工程,勾选 SpringMVC 和 Web Application 支持,不选择下载 jar 包,而是自己导入 jar 包 ![20241229154732_3vhq41x6.webp](https://cdn.dong4j.site/source/image/20241229154732_3vhq41x6.webp) 2.新建好项目之后的包结构如下: ![20241229154732_KQqH4hJT.webp](https://cdn.dong4j.site/source/image/20241229154732_KQqH4hJT.webp) 3.在 WEB-INF 目录下新建 classes 和 lib 这 2 个文件夹 F4 进入项目设置页面. a. 设置编译后的 class 文件输出路径 ![20241229154732_NgDzUonq.webp](https://cdn.dong4j.site/source/image/20241229154732_NgDzUonq.webp) b. 将 lib 添加到构建路径中 ![2015-11-23_12-35-15.gif](https://cdn.dong4j.site/source/image/2015-11-23_12-35-15.gif) c. 添加 Tomcat jar 包 ![2015-11-23_12-37-18.gif](https://cdn.dong4j.site/source/image/2015-11-23_12-37-18.gif) 4.配置 Tomcat 服务器 因为以前设置过默认配置,这里就简单的设置一下就可以了 ![2015-11-23_12-39-56.gif](https://cdn.dong4j.site/source/image/2015-11-23_12-39-56.gif) 5.将需要的 jar 包复制到 lib 文件夹下 ![20241229154732_97GweWsL.webp](https://cdn.dong4j.site/source/image/20241229154732_97GweWsL.webp) --- ## xml 配置 1.在 src 目录下新建: spring-mvc.xml SpringMVC 使用 spring-config.xml Spring 容器使用 mybatis-config.xml Mybatis 配置文件 2.web.xml 的配置 a.配置 SpringMVC 的核心控制器,告知 mySpring-cofnig.xml 的存在 b.配置 Spring 容器的监听器 当服务器启动时加载 spring 容器 ```xml org.springframework.web.context.ContextLoaderListener contextConfigLocation classpath:spring-config.xml spring org.springframework.web.servlet.DispatcherServlet contextConfigLocation classpath:spring-mvc.xml 1 spring *.spring 15 login.jsp index.html ``` 启动服务器,如果没报错,我们继续 --- ### 实现最简单的登录功能,使用 spring-mvc.xml 客户端请求 -->controller 在 src 下新建需要的包 ![20241229154732_CJkpw0wZ.webp](https://cdn.dong4j.site/source/image/20241229154732_CJkpw0wZ.webp) bean: 实体 bean controller: 业务控制器 mapper: 相当于一起的 dao,用来放 mapper.xml 映射文件和对表的操作接口类 service: 业务接口和业务接口实现类 util: 工具包 1.bean 类和数据库表信息 ```java package com.code.ssm.bean; import java.io.Serializable; public class UserBean implements Serializable{ private static final long serialVersionUID = 9018124883408166898L; private int id; private String username; private String password; private int account; public UserBean() {} public UserBean(String username, String password, int account) { this.username = username; this.password = password; this.account = account; } public int getId() {return id;} public void setId(int id) {this.id = id;} public String getUsername() {return username;} public void setUsername(String username) {this.username = username;} public String getPassword() {return password;} public void setPassword(String password) {this.password = password;} public int getAccount() {return account;} public void setAccount(int account) {this.account = account;} @Override public String toString() { return "UserBean{" + "id=" + id + ", username='" + username + '\'' + ", password='" + password + '\'' + ", account=" + account + '}'; } } ``` ```sql -- ---------------------------- -- Table structure for t_user -- ---------------------------- DROP TABLE IF EXISTS `t_user`; CREATE TABLE `t_user` ( `pk_id` int(11) NOT NULL AUTO_INCREMENT, `f_name` varchar(255) DEFAULT NULL, `f_pwd` varchar(255) DEFAULT NULL, `f_account` varchar(255) DEFAULT NULL, PRIMARY KEY (`pk_id`) ) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8; -- ---------------------------- -- Records of t_user -- ---------------------------- INSERT INTO `t_user` VALUES ('1', 'updatrezhang3', '12345', '3000'); INSERT INTO `t_user` VALUES ('2', 'updatrezhang3', '12345', '3000'); INSERT INTO `t_user` VALUES ('3', 'zhang3', '12345', '1000'); INSERT INTO `t_user` VALUES ('4', 'zhang4', '12345', '3000'); ``` 2.spring-mvc.xml 配置 ```xml ``` 3.新建 LoginController.java ```java package com.code.ssm.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; @Controller public class LoginController { @RequestMapping("/login") public String login(String username, String password) { System.out.println(username +" " + password); return null; } } ``` 4.在 WEB-INF 目录下新建 login.jsp 文件 ```html <%@ page language="java" pageEncoding="utf-8"%> <%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %> <%@ taglib prefix="springForm" uri="http://www.springframework.org/tags/form" %> <% String path = request.getContextPath(); String basePath = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + path + "/"; %> My JSP 'login.jsp' starting page

``` 5.打开服务器,登录 `http://localhost:8080/SSM/login.jsp` 随便输入用户名和密码,提交之后看控制台是否输出,如果正确输出,我们继续 **总结一下:** 1.在 web.xml 中配置 spring 容器监听器,当服务器启动时加载 spring-config.xml,开启 spring 容器 2.在 web.xml 中配置前端控制器 DispatcherServlet,拦截全部以.spring 结尾的请求,然后分发给 controller, 3.在 spring-mvc.xml 中配置开启注解,自动扫描@RequestMapping 注解, @Controller: 告知 spring 容器这是一个控制器组件;@RequestMapping("/login"): 告知该方法时针对/login 请求的处理方法 --- ### 使用 spring-config.xml,让 spring 容器来管理 bean 之间的关系 上面只是实现了 spring_mvc 的功能,接下来实现 Controller-->Service 1.spring-config.xml 配置 ```xml ``` 2.新建 UserService.java 和实现类 UserServiceImp.java ```java //UserService.java package com.code.ssm.service; import com.code.ssm.bean.UserBean; public interface UserService { UserBean login(String username, String password); } ``` ```java //UserServiceImp.java package com.code.ssm.service.imp; import com.code.ssm.bean.UserBean; import com.code.ssm.service.UserService; import org.springframework.stereotype.Service; //告知spring容器,我是loginController类的依赖 @Service("userService") public class UserServiceImp implements UserService { @Override public UserBean login(String username, String password) { System.out.println(username + " " + password); return null; } } ``` 3.向 LoginController 中注入 UserServiceImp 依赖 ```java package com.code.ssm.controller; import com.code.ssm.service.UserService; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import javax.annotation.Resource; @Controller public class LoginController { ////将UserService的实现类注入到controller中 @Resource(name="userService") private UserService userService; @RequestMapping("/login") public String login(String username, String password) { //调用login()方法 userService.login(username,password); return null; } } ``` 4.打开服务器,登录 `http://localhost:8080/SSM/login.jsp` 随便输入用户名和密码,提交之后看控制台是否输出,如果正确输出,我们继续 --- **总结一下:** 1.在 spring-config.xml 中配置自动扫描,扫描包下面的注解,管理 bean 之间的依赖关系 ### 实现数据库连接 controller-->service-->dao 1.在 mapper 包下新建 UserMapper.java ```java package com.code.ssm.mapper; import com.code.ssm.bean.UserBean; import org.apache.ibatis.annotations.Param; public interface UserMapper { UserBean getUserByNamePwd(@Param("username") String username, @Param("password") String password); } ``` 2.在 mapper 包下新建 UserMapper.xml 映射文件 ```xml ``` 3.在 spring-config.xml 中配置数据源和 SessionFactory ```xml ``` 3.配置 mybatis-config.xml ```xml ``` 4.修改 UserServiceImp.java ```java package com.code.ssm.service.imp; import com.code.ssm.bean.UserBean; import com.code.ssm.mapper.UserMapper; import com.code.ssm.service.UserService; import org.springframework.stereotype.Service; import javax.annotation.Resource; @Service("userService") public class UserServiceImp implements UserService{ //注入依赖 @Resource private UserMapper userMapper; @Override public UserBean login(String username, String password) { return userMapper.getUserByNamePwd(username,password); } } ``` 5.修改 LoginController.java ```java package com.code.ssm.controller; import com.code.ssm.bean.UserBean; import com.code.ssm.service.UserService; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import javax.annotation.Resource; @Controller public class LoginController { @Resource(name="userService") private UserService userService; @RequestMapping("/login") public String login(String username, String password){ UserBean userBean = userService.login(username,password); if(userBean != null) return "success"; else return "login"; } } ``` 6.打开服务器,登录 `http://localhost:8080/SSM/login.jsp` 输入正确的用户名和密码,跳转到 success.jsp 页面则成功.整个过程结束 ## [5分钟掌握:RESTful API的异常处理艺术](https://blog.dong4j.site/posts/954087b4.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## SpringMVC ### SimpleMappingExceptionResolver 在 Spring 的配置文件 applicationContext.xml 中增加以下内容: ```xml error-business error-parameter ``` 使用 SimpleMappingExceptionResolver 进行异常处理,具有集成简单、有良好的扩展性、对已有代码没有入侵性等优点,但该方法仅能获取到异常信息,若在出现异常时,对需要获取除异常以外的数据的情况不适用。 ### HandlerExceptionResolver 1. 增加 HandlerExceptionResolver 接口的实现类 MyExceptionHandler,代码如下: ```java public class MyExceptionHandler implements HandlerExceptionResolver { private static final Logger LOGGER = LoggerFactory.getLogger(MyExceptionHandler.class); public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { try(PrintWriter writer = new PrintWriter(response.getWriter(), true)){ if (ex instanceof DataValidateException) { writer.println(ex.getMessage()); } else if (ex instanceof RuntimeException){ writer.println(ex.getMessage()); LOGGER.error("Errors.OPTION_ERROR", ex); } } catch (IOException e) { LOGGER.error("getWriter error ", e); } return null; } } ``` 2. 在 Spring 的配置文件 applicationContext.xml 中增加以下内容: ```xml ``` 使用实现 HandlerExceptionResolver 接口的异常处理器进行异常处理,具有集成简单、有良好的扩展性、对已有代码没有入侵性等优点,同时,在异常处理时能获取导致出现异常的对象,有利于提供更详细的异常处理信息 ### @ExceptionHandler 1. 增加 BaseController 类,并在类中使用 @ExceptionHandler 注解声明异常处理,代码如下: ```java @ExceptionHandler public String exp(HttpServletRequest request, HttpServletResponse response, Exception ex) { try(PrintWriter writer = new PrintWriter(response.getWriter(), true)){ if (ex instanceof DataValidateException) { writer.println(ex.getMessage()); } else if (ex instanceof SSDBHelperException){ writer.println(Errors.OPTION_ERROR); } else { // 未知异常都报服务器异常 writer.println(RespCode.EX_SERVER_INNER.getMessage()); LOGGER.error("Errors.OPTION_ERROR", ex); } } catch (IOException e) { LOGGER.error("getWriter error ", e); } return null; } ``` 2. 修改代码,使所有需要异常处理的 Controller 都继承该类,如下所示,修改后的 TestController 类继承于 BaseController 使用 @ExceptionHandler 注解实现异常处理,具有集成简单、有扩展性好(只需要将要异常处理的 Controller 类继承于 BaseController 即可)、不需要附加 Spring 配置等优点,但该方法对已有代码存在入侵性(需要修改已有代码,使相关类继承于 BaseController),在异常处理时不能获取除异常以外的数据 ### @ControllerAdvice 全局异常处理类 ```java @ControllerAdvice public class GlobalExceptionHandler { private Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); @ResponseBody # 返回 json 数据 @ExceptionHandler(Exception.class) public Object handleException(Exception e) { logger.error(ExceptionUtils.getFullStackTrace(e)); // 记录错误信息 String msg = e.getMessage(); if (msg == null || msg.equals("")) { msg = "服务器出错"; } JSONObject jsonObject = new JSONObject(); jsonObject.put("message", msg); return jsonObject; } } ``` @ExceptionHandler 表示该方法可以处理的异常,可以多个,比如 @ExceptionHandler({ NullPointerException.class, DataAccessException.class}) 也可以针对不同的异常写不同的方法。@ExceptionHandler(Exception.class) 可以处理所有的异常类型。 #### @ControllerAdvice 和 @PropertySource ##### @ControllerAdvice @ ControllerAdvice 是一个 @ Component,用于定义 @ ExceptionHandler 的,@InitBinder 和 @ModelAttribute 方法,适用于所有使用 @ RequestMapping 方法。 Spring4 之前,@ ControllerAdvice 在同一调度的 Servlet 中协助所有控制器。Spring4 已经改变:@ ControllerAdvice 支持配置控制器的子集,而默认的行为仍然可以利用。 在 Spring4 中, @ControllerAdvice 通过 annotations(), basePackageClasses(), basePackages() 方法定制用于选择控制器子集: ```java @ControllerAdvice(annotations = RestController.class) class ApiExceptionHandlerAdvice {     /**      * Handle exceptions thrown by handlers.      */     @ExceptionHandler(value = Exception.class)     @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)     @ResponseBody     public ApiError exception(Exception exception, WebRequest request) {         return new ApiError(Throwables.getRootCause(exception).getMessage());     } } ``` ##### @PropertySource Spring 4 是和 @Configuration 协同,提供一个增加 name/value 属性的配置方式。 ```java @Configuration @**PropertySource**("classpath:/datasource.properties") public class DefaultDataSourceConfig implements DataSourceConfig {     @Autowired     private Environment env;     @Override     @Bean     public DataSource dataSource() {         DriverManagerDataSource dataSource = new DriverManagerDataSource();         dataSource.setDriverClassName(env.getRequiredProperty("dataSource.driverClassName"));         dataSource.setUrl(env.getRequiredProperty("dataSource.url"));         dataSource.setUsername(env.getRequiredProperty("dataSource.username"));         dataSource.setPassword(env.getRequiredProperty("dataSource.password"));         return dataSource;     } } ``` ## RESTEasy RESTEasy 是 JBoss 提供的一个 Restful 基础框架,使用它我们可以很方便的构建我们的 Restful 服务,而且它也完全符合  [Java](http://lib.csdn.net/base/java "Java 知识库")  的 JAX-RS2.0 标准,很多第三方 Restful 框架也都是基于 RESTEasy 开发的。 在任何框架中都不可避免的涉及到异常处理,Restful 框架也是如此。按照我们一般传统异常处理方式,在 Restful 的最外层,我们一般会对所有的业务调用都加上 try catch,以免异常被用户接收到,比如我们有这么一个 Restful 服务: ```java import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.core.Response; import org.jboss.resteasy.spi.validation.ValidateRequest; @Path("/rest") public class UserApi { @Autowire UserService userService; @Path("/users/{id}") @GET @ValidateRequest public Response getUserBId ( @PathParam("id") String id ) throws RuntimeException { try{ User user=userService.getUser(id); } catch(IllegalArgumentException e) { //if exception occured return Response.status(Status.BAD_REQUEST).entity(exception.getMessage()).build(); } catch(Exception e) { //if exception occured return Response.status(Status.BAD_REQUEST).entity(exception.getMessage()).build(); } //if success return Response.ok().entity("User with ID " + id + " found !!").build(); } } ``` 上面 UserApi 接口中的 getUserBId() 方法调用了 userService.getUser() 服务,这个服务会抛出一些异常,UserApi 需要捕获异常并返回客户的一个错误的响应。还有一点我们一般会在 API 层 catch 一个 Exception 异常,也就是捕获所有可能发生的异常情况,以免前端出现不友好的错误提示。 这么做也没什么问题,但是我们的接口不只是一个,每个接口需要进行 try catch 来处理异常,这么做显然不符合我们的编程思想,我们希望把所有异常集中到一个地方处理。 如果我们的 Restful 框架是基于 RESTEasy 的,那么我们就可以使用 ExceptionMapper 来实现一个通用异常处理类。 ### ExceptionMapper ExceptionMapper 是 provider 的一个协议,它会将 Java 的异常映射到 Response 对象。所以要进行通用异常处理,我们只需要写一个类来实现 ExceptionMapper 接口,并把它声明为一个 provider 即可: ```java import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; @Provider public class MyApplicationExceptionHandler implements ExceptionMapper { @Override public Response toResponse(Exception exception) { return Response.status(Status.BAD_REQUEST).entity(exception.getMessage()).build(); } } ``` 上面的 ExceptionMapper 的实现已经写好了,下面我们写个 Restful API 来 [测试](http://lib.csdn.net/base/softwaretest "软件测试知识库") 下: ```java import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.core.Response; import org.jboss.resteasy.spi.validation.ValidateRequest; @Path("/rest") public class UserApi { @Autowire UserService userService; @Path("/users/{id}") @GET @ValidateRequest public Response getUserBId ( @PathParam("id") String id ) throws RuntimeException { try{ User user=userService.getUser(id); } catch(IllegalArgumentException e) { throw new RuntimeException("id is not a number !!"); } return Response.ok().entity("User with ID " + id + " found !!").build(); } } ``` 在这个接口中,我们并没有对异常做特殊处理,也没有 catch 一个 Exception 异常,仅仅是把异常抛出,而所有的异常处理都集中在了 MyApplicationExceptionHandler 中。 ## [三层架构的魅力:界面层、业务逻辑层与数据访问层](https://blog.dong4j.site/posts/a13702d6.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 框架模式和设计模式的区别 有很多程序员往往把框架模式和设计模式混淆,认为 MVC 是一种设计模式。实际上它们完全是不同的概念。 框架、设计模式这两个概念总容易被混淆,其实它们之间还是有区别的。框架通常是代码重用,而设计模式是设计重用,架构则介于两者之间,部分代码重用,部分设计重用,有时分析也可重用。在软件生产中有三种级别的重用:内部重用,即在同一应用中能公共使用的抽象块; 代码重用,即将通用模块组合成库或工具集,以便在多个应用和领域都能使用;应用框架的重用,即为专用领域提供通用的或现成的基础结构,以获得最高级别的重用性。 框架与设计模式虽然相似,但却有着根本的不同。设计模式是对在某种环境中反复出现的问题以及解决该问题的方案的描述,它比框架更抽象;框架可以用代码表示,也能直接执行或复用,而对模式而言只有实例才能用代码表示; 设计模式是比框架更小的元素,一个框架中往往含有一个或多个设计模式,框架总是针对某一特定应用领域,但同一模式却可适用于各种应用。可以说,框架是软件,而设计模式是软件的知识。 框架模式有哪些? MVC、MTV、MVP、CBD、ORM 等等; 框架有哪些? C++ 语言的 QT、MFC、gtk,Java 语言的 SSH 、SSI,php 语言的 smarty(MVC 模式),python 语言的 django(MTV 模式) 等等 设计模式有哪些? 工厂模式、适配器模式、策略模式等等 简而言之:设计模式是大智慧,用来对软件设计进行分工;框架模式是小技巧,对具体问题提出解决方案,以提高代码复用率,降低耦合度。 ## MVC 框架 (MVC 模式) SUN 公司推出 JSP 技术后推荐的两种 web 应用开发模式: 1. JSP + JavaBean (Model1) ![20241229154732_vap4M5ys.webp](https://cdn.dong4j.site/source/image/20241229154732_vap4M5ys.webp) > 1. 在这种模式中,JSP 页面独自响应请求并将处理结果返回客户,所有的数据库操作通过 JavaBean 来实现。 > 2. 大量地使用这种模式,常会导致在 JSP 页面中嵌入大量的 Java 代码,当需要处理的商业逻辑非常复杂时,这种情况就会变得很糟糕。大量的 Java 代码使得 JSP 页面变得非常臃肿。前端的页面设计人员稍有不慎,就有可能破坏关系到商业逻辑的代码。 > 3. 这种情况在大型项目中经常出现,造成了代码开发和维护的困难,同时会导致项目管理的困难。因此这种模式只适用于中小规模的项目。 > 4. JSP+JavaBean 模式适合开发业务逻辑不太复杂的 web 应用程序,这种模式下,JavaBean 用于封装业务数据,JSP 即负责处理用户请求,又显示数据。 2. JSP + Servlet + JavaBean (Model2) --> MVC ![20241229154732_WW8coFNM.webp](https://cdn.dong4j.site/source/image/20241229154732_WW8coFNM.webp) > MVC 全名是 Model View Controller,是模型 (model)-视图 (view)-控制器 (controller) 的缩写,一种软件设计典范,用一种业务逻辑、数据、界面显示分离的方法组织代码,将业务逻辑聚集到一个部件里面,在改进和个性化定制界面及用户交互的同时,不需要重新编写业务逻辑。MVC 被独特的发展起来用于映射传统的输入、处理和输出功能在一个逻辑的图形化用户界面的结构中。 MVC 是一个框架模式,它强制性的使应用程序的输入、处理和输出分开。使用 MVC 应用程序被分成三个核心部件:模型、视图、控制器。它们各自处理自己的任务。 1. 模型部分包含了应用程序的业务逻辑和业务数据 - 封装应用状态 -->数据封装 (vo) - 响应状态查询 -->获取数据 (vo) - 暴露应用的功能 -->逻辑层 API 2. 视图部分封装了应用程序的输出形式 - 产生 html 响应 -->展示数据 - 请求模型的更新 -->触发事件 - 提供 html form 用于用户请求 -->人机交互 3. 控制器部分负责协调模型和视图,根据用户请求来选择要调用哪个模型来处理业务,以及追踪由哪个视图为用户做出应答 - 接收并验证 http 请求的数据 -->收集数据,封装数据 - 将用户数据与模型的更新相映射 -->调用逻辑层 API - 选择用于响应的视图 -->根据返回值选择下一个页面 Servlet = Java + html -->拼字符太麻烦 JSP = html + java 脚本 -->页面和逻辑太过于混杂 使用纯 jsp 写 web 应用时,在 jsp 页面上写的代码太多,尤其是控制代码,页面和逻辑混杂在一起,因此需要引入一个中间层 -->控制器来控制处理控制代码 ![20241229154732_sNGiJBH5.webp](https://cdn.dong4j.site/source/image/20241229154732_sNGiJBH5.webp) MVC 的组件关系图描述了模型,视图,控制器的交互关系 1. 首先是展示视图给用户,用户在这个视图上进行操作,并填写一些业务数据 2. 然后用户点击提交按钮,发出请求 (login.av-->LoginServlet) 3. 视图发出的请求会被控制器拦截,请求中包含了想要完成什么样的业务功能和相关数据 (从 request 中取得表单数据) 4. 控制器会处理用户请求,会把请求中的数据进行封装 (封装成一个 JavaBean),然后选择调用合适的模型,请求模型进行状态更新 (调用逻辑层 API,根据不同的返回结果调用调用不同的页面显示信息),然后选择接下来要展示给用户的视图 5. 模型会去处理用户请求的业务功能,同时进行模型状态的维护和更新 6. 当模型状态改变的时候,模型会通知相应的视图告诉自己发生了改变 7. 视图接收到模型的通知后,会向模型进行状态查询,获取需要展示的数据,然后按照本身的展示方式,把这些数据展示出来,等待用户的下一次请求. ### Model2 开发步骤 1. 定义一系列 Bean 来表示数据 2. 使用 Servlet 处理请求 3. 在 Servlet 中填充数据 4. 在 Servlet 中,将 Bean 存储到请求,会话或者 Applaction 作用域中 5. 将请求转发到 jsp 页面 6. 在 jsp 页面中,从 Bean 中提取数据 ## 三层架构 三层架构 (3-tier architecture) 通常意义上的三层架构就是将整个业务应用划分为:界面层(User Interface layer)、业务逻辑层(Business Logic Layer)、数据访问层(Data access layer)。区分层次的目的即为了“高内聚低耦合”的思想。在软件体系架构设计中,分层式结构是最常见,也是最重要的一种结构。微软推荐的分层式结构一般分为三层,从下至上分别为:数据访问层、业务逻辑层(又或称为领域层)、表示层。 ![20241229154732_TxieTy0r.webp](https://cdn.dong4j.site/source/image/20241229154732_TxieTy0r.webp) ## [使用Spring MVC与MyBatis实现用户数据管理,包括注册、分页和删除](https://blog.dong4j.site/posts/22eab19d.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) SSH 整合时实现了简单的登录功能,这次实现简单的注册功能. 需要改动的地方下面会提到,没有提到的地方就不改动 ## 要求 1.登录 register.jsp 页面,填入必要信息,点击注册; 2.如果成功,跳转到 success.jsp,并显示出所有的用户信息; 3.如果注册失败,则跳转到 error.jsp 页面 4.实现分页功能,实现删除功能 ## 实现 1.新建 t_user 表 ```sql -- ---------------------------- -- Table structure for t_user -- ---------------------------- DROP TABLE IF EXISTS `t_user`; CREATE TABLE `t_user` ( `user_id` int(11) NOT NULL AUTO_INCREMENT, `f_name` varchar(255) DEFAULT NULL, `f_password` varchar(255) DEFAULT NULL, `f_age` int(11) DEFAULT NULL, `f_sex` varchar(255) DEFAULT NULL, `f_city` varchar(255) DEFAULT NULL, PRIMARY KEY (`user_id`) ) ENGINE=InnoDB AUTO_INCREMENT=87 DEFAULT CHARSET=utf8; -- ---------------------------- -- Records of t_user -- ---------------------------- INSERT INTO `t_user` VALUES ('14', 'rooterger', 'dus', '23', 'rth', 'jngfnm'); INSERT INTO `t_user` VALUES ('31', 'root2', 'dus010', '234', 'erh', 'tyjty'); INSERT INTO `t_user` VALUES ('32', 'er', 'du010', '23', 'rth', 'tym'); INSERT INTO `t_user` VALUES ('33', 'rootehr', 'trj', '23', 'segv', 'rtj'); INSERT INTO `t_user` VALUES ('34', 'refw', 'dus82010', '23', 'erg', 'tg'); INSERT INTO `t_user` VALUES ('45', 'ergrth', 'tyj', '2', 'tyj', 'erh'); INSERT INTO `t_user` VALUES ('53', 'ergfgnfgn', 'erg', '213', 'erg', 'gfnfg'); INSERT INTO `t_user` VALUES ('54', 'ghn', 'ghmghm', '213', 'ghm', 'fgn'); INSERT INTO `t_user` VALUES ('55', 'hj,', 'hj,', '324', 'jh,', 'rtbh'); INSERT INTO `t_user` VALUES ('56', 'erg', 'erg', '23', 'erg', 'fdbfgn'); INSERT INTO `t_user` VALUES ('74', 'tgym', 'ghm', '234', 'hgmghm', 'ghm'); INSERT INTO `t_user` VALUES ('76', 'dfbfd', 'dfb', '23', 'dfb', 'dfnb'); INSERT INTO `t_user` VALUES ('77', 'edhrtjytjk', 'hykm', '23', 'edb', 'dfb'); INSERT INTO `t_user` VALUES ('79', 'dfnb', 'fgn', '234', 'fgn', 'fn'); INSERT INTO `t_user` VALUES ('80', 'yuk', 'yukm', '32', 'db', 'fgn'); INSERT INTO `t_user` VALUES ('81', 'rthrthtrh', 'rth', '234', 'rthrt', 'rtnghm'); INSERT INTO `t_user` VALUES ('82', 'fgngffgn', 'fgnfgn', '23', 'sv', 'fgn'); INSERT INTO `t_user` VALUES ('84', 'dfbdf', 'dfnf', '32', 'gngfn', 'gfm'); INSERT INTO `t_user` VALUES ('85', 'ergergtyj', 'tyjkyk', '2', 'wefwe', 'rthntj'); INSERT INTO `t_user` VALUES ('86', 'fgnfgfgmhgm', 'hgmgh', '32', 'sdv', 'fgnghm'); ``` 2.新建 UserBean.java ```java package com.code.ssm.bean; import java.io.Serializable; public class UserBean implements Serializable{ private static final long serialVersionUID = -6971764181923020401L; private int id; private String username; private String password; private int age; private String sex; private String city; public UserBean() {} public UserBean(String username, String password, int age, String sex, String city) { this.username = username; this.password = password; this.age = age; this.sex = sex; this.city = city; } public int getId() {return id;} public void setId(int id) {this.id = id;} public String getUsername() {return username;} public void setUsername(String username) {this.username = username;} public String getPassword() {return password;} public void setPassword(String password) {this.password = password;} public int getAge() {return age;} public void setAge(int age) {this.age = age;} public String getSex() { return sex;} public void setSex(String sex) {this.sex = sex;} public String getCity() {return city;} public void setCity(String city) {this.city = city;} @Override public String toString() { return "UserBean{" + "id=" + id + ", username='" + username + '\'' + ", password='" + password + '\'' + ", age=" + age + ", sex='" + sex + '\'' + ", city='" + city + '\'' + '}'; } } ``` 3.新建 UserMapper.java ```java package com.code.ssm.mapper; import com.code.ssm.bean.UserBean; import org.apache.ibatis.annotations.Param; import java.util.List; public interface UserMapper { List getUserByName(@Param("username")String username); int addUser(UserBean userBean); List getAllUsers(); //分页实现 List getAllUsersLimit(@Param("pageNow")int pageNow,@Param("pageSize")int pageSize); int getCounts(); int delUser(@Param("id") int id); } ``` 4.新建 UserMapper.xml ```xml insert into t_user(f_name,f_password,f_age,f_sex,f_city) values(#{username},#{password},#{age},#{sex},#{city}) delete from t_user where user_id=#{id} ``` 5.UserService.java ```java package com.code.ssm.service; import com.code.ssm.bean.UserBean; import java.util.List; public interface UserService { public boolean register(UserBean userBean); public List getAllUsers(int pageNow, int pageSize); public boolean delUser(int id); int getCounts(); } ``` 6.UserServiceImp.java ```java package com.code.ssm.service.imp; import com.code.ssm.bean.UserBean; import com.code.ssm.mapper.UserMapper; import com.code.ssm.service.UserService; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.List; @Service("userService") public class UserServiceImp implements UserService { @Resource private UserMapper userMapper; @Override public boolean register(UserBean userBean) { if(userMapper.getUserByName(userBean.getUsername()).size() == 0){ userMapper.addUser(userBean); return true; }else return false; } @Override public List getAllUsers(int pageNow,int pageSize) { int a = (pageNow - 1) * pageSize; return userMapper.getAllUsersLimit(a,pageSize); } @Override public boolean delUser(int id) { return 1 == userMapper.delUser(id); } @Override public int getCounts() { return userMapper.getCounts(); } } ``` 7.导入 jQuery+bootstrap 包 8.新建 register.jsp ```html <%@ page contentType = "text/html;charset=UTF-8" pageEncoding = "UTF-8" language = "java" %> 注册

注册

<%--顶部颜色条--%>
``` 9.接下来开始 Controller 的书写 由于使用了 ajax 异步加载表格数据,所以需要导入将 Bean 转换成 json 数据格式的工具包 fastjson-1.1.15.jar jsoup-1.8.1.jar ```java package com.code.ssm.controller; import com.alibaba.fastjson.JSONArray; import com.code.ssm.bean.UserBean; import com.code.ssm.service.UserService; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; import java.util.List; @Controller public class UserController { //分页大小 private static final int PAGESIZE = 5; @Resource(name="userService") private UserService userService; @RequestMapping("/register") //当请求register.spring时,自动将表单中的数据封装成UserBean对象,前提是控件name属性要和UserBean属性名相同 public String register(UserBean userBean, HttpServletRequest request){ //调用register()实现向数据库中添加数据 //先执行查询,如果数据库中有同名的数据,则注册失败,跳转到error.jsp //如果没有相同数据,则执行insert语句,跳转到success.jsp //这时将再次查询数据库,返回所有的用户信息(分页) if(userService.register(userBean)){ //初始化总页数 int counts = userService.getCounts(); int pageNum = (int)Math.ceil(counts / (PAGESIZE * 1.0)); //得到分页数据 List allUsers = userService.getAllUsers(1,PAGESIZE); //放requset //request.setAttribute("pageNum",pageNum); //放session都一样 request.getSession().setAttribute("pageNum",pageNum); //初始化当前页为第一页 request.setAttribute("pageNow",1); //将所有用户数据返回给jsp显示 request.getSession().setAttribute("allUsers",allUsers); return "success"; }else return "error"; } //分页请求数据 @RequestMapping("/limitPage") //当点击下一个/上一页/按页数跳转时调用此方法 public void limitPage(HttpServletRequest request,HttpServletResponse response) throws IOException { //先得到当前页数 int pageNow = Integer.parseInt(request.getParameter("pageNow")); //根据当前页在数据库中查询数据 List allUsers = userService.getAllUsers(pageNow,PAGESIZE); PrintWriter out = response.getWriter(); //将List转换成json格式数据 out.print(JSONArray.toJSONString(allUsers,true)); } //当点击"删除"是调用此方法 @RequestMapping(value = "/delUser") public void delUser(HttpServletRequest request,HttpServletResponse response, @RequestParam("id")int id) throws IOException { PrintWriter out = response.getWriter(); if(userService.delUser(id)){ //删除之后总页数要变 int counts = userService.getCounts(); int pageNum = (int)Math.ceil(counts / (PAGESIZE * 1.0)); int pageNow = Integer.parseInt(request.getParameter("pageNow")); List allUsers = userService.getAllUsers(pageNow,PAGESIZE); //将总页数放到集合中的最后一个元素 //因为是ajax局部加载表格,这里将变化后的总页数放到了数据集合中一起传回给jsp //在jsp中取集合最后一个元素,然后拆分数据就可以得到总页数 UserBean pageNumBean = new UserBean(); pageNumBean.setUsername("pageNum&" + pageNum); allUsers.add(pageNumBean); out.print(JSONArray.toJSONString(allUsers,true)); }else out.print("'message':'failed'"); } } ``` 10.新建 success.jsp ```html <%-- Created by IntelliJ IDEA. User: Code.Ai To change this template use File | Settings | File Templates. --%> <%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %> <%@ taglib prefix = "c" uri = "http://java.sun.com/jsp/jstl/core" %> success

全部用户信息

姓名 年龄 性别 籍贯 操作
${user.username} ${user.age} ${user.sex} ${user.city} 删除
<%--分页按钮--%>
``` ## 效果 由于没有配置编码过滤器,所以添加进去的是乱码 ## [Mybatis入门五:探索Mapper Java接口与Map参数的灵活结合](https://blog.dong4j.site/posts/7bb33d78.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 1.mapper.xml 配置 ```xml update t_student f_java = #{java}, f_web = #{web}, f_db = #{db}, f_html = #{html}, where student_id = #{id} update t_student f_java = #{java}, f_web = #{web}, f_db = #{db}, f_html = #{html}, where student_id = #{id} ``` 2.编写 mapper.java ```java package com.code.mapper; import java.util.List; import org.apache.ibatis.annotations.Param; import com.code.bean.UserBean; public interface UserMapper { public int addUser(@Param("user")UserBean user); public int deleteUserByID(@Param("userID")int userID); public int updateUserNameByID(@Param("name")String name, @Param("id")int id); public int updateUserByID(@Param("user")UserBean user,@Param("id")int id); public UserBean getUserByID(@Param("id")int id); public List getAllUsers(@Param("colName")String colName); public List getAllUsersLikeName(@Param("name")String name); } ``` **当使用 Map 做为参数时,可以用 `_parameter.containsKey`(变量名)来判 断 map 中是否包含有些变量:** ```xml ``` ## [MyBatis二级缓存实战教程](https://blog.dong4j.site/posts/66515f3d.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 正如大多数持久层框架一样,MyBatis 同样提供了一级缓存和二级缓存的支持 1. 一级缓存: 基于 PerpetualCache 的 HashMap 本地缓存,其存储作用域为 Session,当 Session flush 或 close 之后,该 Session 中的所有 Cache 就将清空。 2. 二级缓存与一级缓存其机制相同,默认也是采用 PerpetualCache,HashMap 存储,不同在于其存储作用域为 Mapper(Namespace),并且可自定义存储源,如 Ehcache。 3. 对于缓存数据更新机制,当某一个作用域 (一级缓存 Session/二级缓存 Namespaces) 的进行了 C/U/D 操作后,默认该作用域下所有 select 中的缓存将被 clear。 ### 一级缓存测试 ```java import me.gacl.domain.User; import me.gacl.util.MyBatisUtil; import org.apache.ibatis.session.SqlSession; import org.junit.Test; /** * 测试一级缓存 */ public class TestOneLevelCache { /* * 一级缓存: 也就Session级的缓存(默认开启) */ @Test public void testCache1() { SqlSession session = MyBatisUtil.getSqlSession(); String statement = "me.gacl.mapping.userMapper.getUser"; User user = session.selectOne(statement, 1); System.out.println(user); /* * 一级缓存默认就会被使用 */ user = session.selectOne(statement, 1); System.out.println(user); session.close(); /* 1. 必须是同一个Session,如果session对象已经close()过了就不可能用了 */ session = MyBatisUtil.getSqlSession(); user = session.selectOne(statement, 1); System.out.println(user); /* 2. 查询条件是一样的 */ user = session.selectOne(statement, 2); System.out.println(user); /* 3. 没有执行过session.clearCache()清理缓存 */ //session.clearCache(); user = session.selectOne(statement, 2); System.out.println(user); /* 4. 没有执行过增删改的操作(这些操作都会清理缓存) */ session.update("me.gacl.mapping.userMapper.updateUser", new User(2, "user", 23)); user = session.selectOne(statement, 2); System.out.println(user); } } ``` ### 二级缓存测试 开启二级缓存,在 userMapper.xml 文件中添加如下配置 ```xml ``` ```java import me.gacl.domain.User; import me.gacl.util.MyBatisUtil; import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSessionFactory; import org.junit.Test; /** * @author * 测试二级缓存 */ public class TestTwoLevelCache { /* * 测试二级缓存 * 使用两个不同的SqlSession对象去执行相同查询条件的查询,第二次查询时不会再发送SQL语句,而是直接从缓存中取出数据 */ @Test public void testCache2() { String statement = "me.gacl.mapping.userMapper.getUser"; SqlSessionFactory factory = MyBatisUtil.getSqlSessionFactory(); //开启两个不同的SqlSession SqlSession session1 = factory.openSession(); SqlSession session2 = factory.openSession(); //使用二级缓存时,User类必须实现一个Serializable接口===> User implements Serializable User user = session1.selectOne(statement, 1); session1.commit();//不懂为啥,这个地方一定要提交事务之后二级缓存才会起作用 System.out.println("user="+user); //由于使用的是两个不同的SqlSession对象,所以即使查询条件相同,一级缓存也不会开启使用 user = session2.selectOne(statement, 1); //session2.commit(); System.out.println("user2="+user); } } ``` ### 总结 1. 映射语句文件中的所有 select 语句将会被缓存。    2. 映射语句文件中的所有 insert,update 和 delete 语句会刷新缓存。    3. 缓存会使用 Least Recently Used(LRU,最近最少使用的)算法来收回。    4. 缓存会根据指定的时间间隔来刷新。    5. 缓存会存储 1024 个对象 cache 标签常用属性: ```xml flushInterval="60000" size="512" readOnly="true"/> ``` ## [MyBatis 一对多、一对一查询详解与配置示例](https://blog.dong4j.site/posts/2a833323.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 一对一关联 1.要求 假设一间房子只有一把锁,要求通过锁找到房子 2.创建表和数据 ```sql SET FOREIGN_KEY_CHECKS=0; -- ---------------------------- -- Table structure for t_lock -- ---------------------------- DROP TABLE IF EXISTS `t_lock`; CREATE TABLE `t_lock` ( `lock_id` int(11) NOT NULL AUTO_INCREMENT, `f_type` varchar(255) DEFAULT NULL, `fk_home_id` int(11) DEFAULT NULL, PRIMARY KEY (`lock_id`), KEY `fk_home_id` (`fk_home_id`), CONSTRAINT `t_lock_ibfk_1` FOREIGN KEY (`fk_home_id`) REFERENCES `t_home` (`home_id`) ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8; -- ---------------------------- -- Records of t_lock -- ---------------------------- INSERT INTO `t_lock` VALUES ('1', '防盗锁', '2'); INSERT INTO `t_lock` VALUES ('2', '铁锁', '1'); INSERT INTO `t_lock` VALUES ('3', '铜锁', '3'); -- ---------------------------- -- Table structure for t_home -- ---------------------------- DROP TABLE IF EXISTS `t_home`; CREATE TABLE `t_home` ( `home_id` int(11) NOT NULL AUTO_INCREMENT, `f_address` varchar(255) DEFAULT NULL, PRIMARY KEY (`home_id`) ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8; -- ---------------------------- -- Records of t_home -- ---------------------------- INSERT INTO `t_home` VALUES ('1', '1号家庭'); INSERT INTO `t_home` VALUES ('2', '2号家庭'); INSERT INTO `t_home` VALUES ('3', '3号家庭'); ``` 3.定义实体类 - HomeBean.java ```java package com.code.bean; public class HomeBean { private int id; private String address; public int getId() { return id; } public void setId(int id) { this.id = id; } public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } @Override public String toString() { return "HomeBean [id=" + id + ", address=" + address + "]"; } } ``` - LockBean.java ```java package com.code.bean; import java.util.ArrayList; import java.util.List; public class LockBean { private int id; private String type; /** * lock表中有一个fk_home_id字段, * 所以在lock中定义一个myHome属性, * 用来维护2个表之间一对一关系 * 通过这个属性就可以知道这把锁死哪家的 */ private HomeBean myHome; public int getId() { return id; } public void setId(int id) { this.id = id; } public String getType() { return type; } public void setType(String type) { this.type = type; } public HomeBean getMyHome() { return myHome; } public void setMyHome(HomeBean myHome) { this.myHome = myHome; } @Override public String toString() { return "LockBean [id=" + id + ", type=" + type + ", myHome=" + myHome + ", keyLst=" + keyLst + "]"; } } ``` 4.定义 sql 映射文件 lockMapper.xml ```xml ``` db.propertiespe 文件 ``` jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/mybatis?useUnicode=true&characterEncoding=UTF-8 jdbc.username=code jdbc.password=8998 ``` mybatis-config.xml 配置 ```xml ``` DBUtil.java 跟以前的一样 5.MyBatis 一对一关联查询总结 MyBatis 中使用 association 标签来解决一对一的关联查询,association 标签可用的属性如下: * property:对象属性的名称 * javaType:对象属性的类型 * column:所对应的外键字段名称 * select:使用另一个查询封装的结果 ## 一对一双向关联关系 只需要在 home 中页设置一个 lockBean 的属性即可 通过 homeid 找到 lock 跟通过 lockid 找 home 的配置相同 ## 一对多关联关系 一个 lock 可以有多把钥匙 也有 2 中配置方式 只需要将 association 换成 collection ```xml ``` 7.MyBatis 一对多关联查询总结 MyBatis 中使用 collection 标签来解决一对多的关联查询,ofType 属性指定集合中元素的对象类型。 ## 继承关联关系 使用 discriminator ```xml ``` ## [MyBatis CRUD操作指南](https://blog.dong4j.site/posts/68dfa52e.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 上一节中对 Mybatis 的基本操作有了初步的了解, 这一节中将使用 Mybatis 对数据表进行简单的 CRUD 操作. 使用的测试环境和上一篇博客一样. ## 使用 Mybatis 对表执行 CRUD 操作 -- 基于 XML 实现 1.定义 UserMapper.java ```java package com.code.mapper; import com.code.bean.UserBean; import org.apache.ibatis.annotations.Param; import java.util.List; public interface UserMapper { //根据ID查找用户 UserBean getUserById(@Param("userID")int userID); //添加用户 int addUser(@Param("userBean")UserBean userBean); //删除用户 int deleteUser(@Param("userID")int userID); //修改用户 int updateUser(@Param("userBean")UserBean userBean); //查询全部用户 List getAllUsers(); //查询全部用户,模糊查询 List getAllUsersLikeName(@Param("name")String name); } ``` 2.定义 sql 映射文件 userMapper.xml 文件内容如下: ```xml insert into t_user values(null,#{userBean.name},#{userBean.age}) delete from t_user where user_id = #{userID} update t_user set f_name = #{userBean.name},f_age = #{userBean.age} where user_id=#{userBean.id} ``` 3.测试类 ```java package com.code.test; import com.code.bean.UserBean; import com.code.mapper.UserMapper; import com.code.util.DBUtil; import org.apache.ibatis.session.SqlSession; import org.junit.Test; import java.util.List; public class Test2 { @Test public void addUserTest() { SqlSession sqlSession = DBUtil.getSession(); UserMapper userMapper = sqlSession.getMapper(UserMapper.class); UserBean newUser = new UserBean("张三", 26); System.out.println(userMapper.addUser(newUser)); //手动提交事物 sqlSession.commit(); System.out.println(newUser.getId()); //关闭session sqlSession.close(); } @Test public void delUserTest() { SqlSession sqlSession = DBUtil.getSession(); UserMapper userMapper = sqlSession.getMapper(UserMapper.class); System.out.println(userMapper.deleteUser(2)); sqlSession.commit(); sqlSession.close(); } @Test public void updateUserTest() { SqlSession sqlSession = DBUtil.getSession(); UserMapper userMapper = sqlSession.getMapper(UserMapper.class); UserBean updateUser = new UserBean(1, "李四", 25); System.out.println(userMapper.updateUser(updateUser)); ; sqlSession.commit(); sqlSession.close(); } @Test public void getAllUsersTest() { SqlSession sqlSession = DBUtil.getSession(); UserMapper userMapper = sqlSession.getMapper(UserMapper.class); List allUsers = userMapper.getAllUsers(); System.out.println(allUsers); } @Test public void getAllUsersLikeNameTest() { SqlSession sqlSession = DBUtil.getSession(); UserMapper userMapper = sqlSession.getMapper(UserMapper.class); List allUsers = userMapper.getAllUsersLikeName("c"); System.out.println(allUsers); } } ``` ## 使用 Mybatis 对表执行 CRUD 操作 -- 基于 Annotation 实现 1.UserMapper.java ```java package com.code.mapper; /** * @author Code.Ai (Email: Code.Ai@outlook.com) * @link http://blog.csdn.net/codeai * @date 2015/11/19 * @version 0.1 * @describe 用于操作数据库的接口 */ import com.code.bean.UserBean; import org.apache.ibatis.annotations.*; import java.util.List; /** * 需要说明的是,我们不需要针对UserMapperI接口去编写具体的实现类代码, * 这个具体的实现类由MyBatis帮我们动态构建出来,我们只需要直接拿来使用即可。 */ public interface UserMapper { //根据ID查找用户 @Select("select * from t_user where user_id = #{userID}") UserBean getUserById(@Param("userID") int userID); //添加用户 @Insert("insert into t_user values(null,#{userBean.name},#{userBean.age})") int addUser(@Param("userBean") UserBean userBean); //删除用户 @Delete(" delete from t_user where user_id = #{userID}") int deleteUser(@Param("userID") int userID); //修改用户 @Update(" update t_user set f_name = #{userBean.name},f_age = #{userBean.age} where user_id=#{userBean.id}") int updateUser(@Param("userBean") UserBean userBean); //查询全部用户 @Select("select * from t_user") List getAllUsers(); //查询全部用户,模糊查询 @Select("select * from t_user where f_name like '%${name}%'") List getAllUsersLikeName(@Param("name") String name); } ``` ## 将数据库连接配置信息写入到 properties 文件中 1.db.properties ``` driver=com.mysql.jdbc.Driver url=jdbc:mysql://localhost:3306/mybatis name=code password=8998 ``` 2.在 mybatis 中引入 db.properties 文件 ```xml ``` ## 为实体类定义别名,简化 sql 映射文件的引用 之前已经配置过 1.在 mybatis-config.xml 文件中配置如下 ```xml ``` 2.第二种方式: ```xml ``` ## 解决字段名和实体类属性名不相同造成查询不到结果 解决办法一: 通过在查询的 sql 语句中定义字段名的别名,让字段名的别名和实体类的属性名一致,这样就可以表的字段名和实体类的属性名一一对应上了,这种方式是通过在 sql 语句中定义别名来解决字段名和属性名的映射关系的。 ```sql select user_id id,f_name name, f_age age from t_user where user_id=#{userID} ``` 解决办法二: 通过来映射字段名和实体类属性名的一一对应关系。这种方式是使用 MyBatis 提供的解决方式来解决字段名和属性名的映射关系的。 ```xml ``` ## [MyBatis 入门必备:如何配置和运行第一个程序](https://blog.dong4j.site/posts/535f8b87.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) MyBatis 本是 apache 的一个开源项目 iBatis, 2010 年这个项目由 apache software foundation 迁移到了 google code,并且改名为 MyBatis 。2013 年 11 月迁移到 Github。 iBATIS 一词来源于“internet”和“abatis”的组合,是一个基于 Java 的持久层框架。iBATIS 提供的持久层框架包括 SQL Maps 和 Data Access Objects(DAO) MyBatis 是一个支持普通 SQL 查询,存储过程和高级映射的优秀持久层框架。MyBatis 消除了几乎所有的 JDBC 代码和参数的手工设置以及对结果集的检索封装。MyBatis 可以使用简单的 XML 或注解用于配置和原始映射,将接口和 Java 的 POJO(Plain Old Java Objects,普通的 Java 对象)映射成数据库中的记录。 ## Mybatis 快速入门 ### 准备开发环境 1. 创建测试项目,普通的 Java 项目或者 JavaWeb 项目均可 2. 添加相应的 jar 包 - Mybatis 核心包 mybatis-3.30.jar - 数据库驱动 mysql-connector-5.1.19-bin.jar 3. 创建数据库和表 ```sql create table `t_user` ( `user_id` int(11) not null auto_increment, `f_name` varchar(255) default null, `f_age` int(11) default null, primary key (`user_id`) ) engine=InnoDB default charset=utf-8 insert into t_user(f_name,f_age) values('CODE',26); insert into t_user(f_name,f_age) values('array',26); ``` ### 使用 Mybatis 查询表中数据 1. 添加 Mybatis 配置文件 mybatis-config.xml ```sql ``` 2. 建立实体 Bean ```java package com.code.bean; public class UserBean { private int id; private String name; private int age; public UserBean() { } public UserBean(String name, int age) { this.name = name; this.age = age; } public UserBean(int id, String name, int age) { this.id = id; this.name = name; this.age = age; } public int getId() { return id; } public void setId(int id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } @Override public String toString() { return "UserBean{" + "id=" + id + ", name='" + name + '\'' + ", age=" + age + '}'; } } ``` 3. 建立操作数据表的 sql 映射文件 userMapper.xml - 创建一个包 com.code.mapper,用来放映射文件和 xxxMapper.java - UserMapper.java ```java package com.code.mapper; import com.code.bean.UserBean; import org.apache.ibatis.annotations.Param; public interface UserMapper { //@Param为参数设置别名,在select语句的就用写 #{0} 了,而是 #{userID} UserBean getUserById(@Param("userID")int userID); } ``` - userMapper.xml 配置 ```xml ``` 4. 在配置文件中注册映射文件 - 在 mybaits-config.xml 文件中添加 ```xml ``` 5. 新建一个 DBUtil.java 工具类,位于 com.code.util 包下 ```java package com.code.util; import org.apache.ibatis.io.Resources; import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSessionFactory; import org.apache.ibatis.session.SqlSessionFactoryBuilder; import java.io.IOException; import java.io.InputStream; public class DBUtil { private static SqlSessionFactory sqlSessionFactory; //初始化 static { InputStream in = null; try { //加载配置文件 in = Resources.getResourceAsStream("mybatis-config.xml"); //得到sqlSessionFactory工厂 sqlSessionFactory = new SqlSessionFactoryBuilder().build(in); } catch (IOException e) { e.printStackTrace(); } finally { try { if (in != null) { in.close(); } } catch (IOException e) { e.printStackTrace(); } } } public static SqlSession getSession() { //返回能执行映射文件中sql的sqlSession return sqlSessionFactory.openSession(); } } ``` 6. 编写测试代码,执行 select 操作 ```java package com.code.test; import com.code.bean.UserBean; import com.code.mapper.UserMapper; import com.code.util.DBUtil; import org.apache.ibatis.session.SqlSession; import org.junit.Test; public class Test1 { @Test public void testGetUserById(){ SqlSession sqlSession = DBUtil.getSession(); UserMapper userMapper = sqlSession.getMapper(UserMapper.class); UserBean userBean = userMapper.getUserById(1); System.out.println(userBean); } } ``` ## [从零开始:深入理解Hibernate框架的奥秘](https://blog.dong4j.site/posts/f0fe1ae8.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Hibernate 框架主要是实现数据库与实体类间的映射,使的操作实体类相当与操作 hibernate 框架。 只要实体类写好配置文件配好,就能实现和数据库的映射,其中实体类对应表,类的属性对应数据库的表字段。 这样就不用管数据库的相关操作了。 ## 什么是 Hibernate 如今,大多数是关系型数据库,而 Java 是面向对象的编程语言,使用面向对象的语言结合关系型数据库相当麻烦,所以有了 Hibernate Hibernate 将对象模型和基于 sql 的关系模型映射起来,使得开发者可以采用面向对象的方式开发程序. Hibernate 是关系型数据库和面向对象的程序设计语言之间的桥梁,它允许开发者使用面向对象的语言操作关系型数据库 以前是应用程序直接访问底层数据库,而采用 ORM 框架之后,应用程序以面向对象的方式操作持久化对象,而 ORM 框架则将这些面向对象的操作转换为底层的 SQL 操作. ## Hibernate 和 JDBC 1. jdbc 的优点和缺点 - 缺点 - 查询代码特别繁琐 - 重复性代码特别多,频繁的 try,catch - 数据的缓存 - sql 的移植性不好 - 优点 - 速度比较快 - 把控性比较好 2. ormapping 框架:数据库的操作框架 - 优点 - 比较简单 - 数据缓存:一级缓存 二级缓存 查询缓存 - 移植性比较好 - 缺点 - 因为 sql 语句是 hibernate 内部生成的,所以程序员干预不了,不可控 - 如果数据库特别大,不适合用 hibernate ### .hbm.xml(映射文件) 类与表的对应关系 类上的属性的名称和表中的字段的名称对应关系 类上的属性的类型和表中的字段的类型对应关系 把一对多和多对多的关系转化成面向对象的关系 ### Hibernate.cfg.xml(配置文件) hibernate 的配置文件: 作用是用来连接数据库的 ## hibernate 的组成部分 1. 持久化类 - 实现对应的序列化接口 - 必须有默认的构造函数 - 持久化类的属性不能使用关键字 2. hibernate 的流程 - Configuraction - 加载了配置文件 - SessionFactory - 配置文件的信息、映射文件的信息、持久化类的信息 - Session 1、crud 的操作都是由 session 完成的 2、事务是由 session 开启的 3、两个不同的 session 只能用各自的事务 4、session 决定了对象的状态 5、创建完一个 session,相当于打开了一个数据库的链接 - Transaction 1、事务默认不是自动提交的 2、必须由 session 开启 3、必须和当前的 session 绑定 (两个 session 不可能共用一个事务) 3. 对象的状态的转化 4. hibernate 的原理: 根据客户端的代码,参照映射文件,生成 sql 语句,利用 jdbc 技术进行数据库的操作 ## Hibernate 核心开发接口 1. Configuration configure() configure(String 配置文件路径) - 读取配置文件 - 通过配置文件产生数据库连接 - 产生 SessionFactory 2. SessionFactory 维护数据库连接池 - sessionFactory.opeSession() - 得到一个新的 session - sessionFactory.getCurrentSession() - 如果当前环境 (线程) 中有 session,则直接使用,否则打开一个新的 session - 不需要写 session.close() - 当 session.commit() 之后,session 自动关闭 - 界定事务边界 - current-session_context_class(JTA thread ) - thread 使用 connection 本身数据库中 - JTA(Java Transaction API) 需要 application service 支持 - 跨服务器事务管理 - 分布式事务 (跨数据库事务,跨服务器事务) 3. 对象的三种状态 - 瞬时 (Transient) - new 出来时,内存中有,缓存没有,数据库没有 (id) - 持久化 (persistent) - save() saveOrUpdate() - 缓存有,数据库有 (id) - 托管 (Detached) - evict() - close() - clear() - 缓存没有 (已经被销毁了),数据库有 (id) - 区别: - 有没有 id transient - id 是否在数据库中 persistens - 是否在内存中 (Session 缓存) 4. Session 管理一个数据库的任务单元,执行 CRUD 操作 Session 缓存 session 对象中有一个 map key 是 id,value 是持久化对象的引用. get 和 load 的区别 - 不存在对应记录时 get 返回 null - load 报异常 - load 返回代理对象,等到真正要使用对象的内容时才发出 sql 语句 - get 直接从数据库加载,不会延迟 - 都会先到缓存中查询,如果没有才进数据库查 5. 代理模式 javassist -->直接生成二进制码 动态改变类的结构,或者动态生成类。 为什么那个 id 必须是 serializable 接口了 hibernate 不知道我们标识符的类型是什么,所以用一个接口类型作为一个参数,只要实现了 serializable 的数据类型都可以作为参数传进入,然后下午我问的是为什么我的类没有实现 serializable 接口,也能传 id 进去,这跟类没关系,只要参数是 serializable 的实现就可以了,正好穿的参数是 int,它自动包装为 Integer 了,然后 Integer 实现了 serializable 接口 update 方法 - 用来更新 detached 对象,,更新完成转为 persistent 状态 - 更新 transient 对象会报错 - 更新自己设定的 id 的 transient 对象可以 (前提是数据库有对应的数据记录) - 更新部分更改的字段 解决更新一个字段,hibernate 其他字段也会更新的问题 O/R Mapping -->对象关系映射 将程序中的 java 对象和使用的关系型数据库中的表对应 使用元数据 (描述数据格式的数据) 描述对象和数据库间的映射 好处: 1. 移植性好 2. 简单 3. 使用缓存技术 为什么集合类型的属性只能定义为接口类型 Hibernate 底层没有使用 JCF 框架中的实现类,而是使用 Apache 自己的集合实现类 必须提供一个标示属性和数据表的主键对应 因为 Hibernate 是通过用标识符和表中的主键对应,从而判定是表中的哪条记录 get 和 load 方法区别 1. 当查询一个不存在的对象时,get 方法返回 null,load 不返回 null,而是直接抛出 ObjectNotFound 的异常 2. get 方法不支持延迟加载;load 支持 ## Hibernate 实体关系映射 1. one-to-one - 唯一外键关联 - 主键关联 2. one-to-maney - 单项关联 - 双向关联 3. maney-to-maney 4. 继承 映射文件中 class 有几个元素依赖于属性的个数 谁 to 谁 前面是当前对象.后面是关联对象 cascade 级联操作 对当前对象的操作也会发生在关联对象上 get 方法的性能优化 对于引用对象,hibernate 默认是延迟加载 ## 1-N 单向关联 用户 用户地址 (家庭,公司) 1 端有 N 端的集合对象 通过 1 端找 N 端 外键在 N 端 在 Hibernate 中,各表的映射文件….hbm.xml 可以通过工具生成,例如在使用 MyEclipse 开发时,它提供了自动生成映射文件的工具。本节简单的讲述一下这些配置文件的配置。 配置文件的基本结构如下: ```xml …… ``` 1.主键(id) Hibernate 的主键生成策略有如下几种: 1)assigned 主键由外部程序负责生成,在 save() 之前必须指定一个。Hibernate 不负责维护主键生成。与 Hibernate 和底层数据库都无关,可以跨数据库。在存储对象前,必须要使用主键的 setter 方法给主键赋值,至于这个值怎么生成,完全由自己决定,这种方法应该尽量避免。 ```xml ``` “id”是自定义的策略名,人为起的名字,后面均用“ud”表示。 特点:可以跨数据库,人为控制主键生成,应尽量避免。 2. hilo hilo(高低位方式 high low)是 hibernate 中最常用的一种生成方式,需要一张额外的表保存 hi 的值。保存 hi 值的表至少有一条记录(只与第一条记录有关),否则会出现错误。可以跨数据库。 ```xml hibernate_hilo 指定保存hi值的表名 next_hi 指定保存hi值的列名 100 指定低位的最大值 ``` 也可以省略 table 和 column 配置,其默认的表为 hibernate_unique_key,列为 next_hi hilo 生成器生成主键的过程(以 hibernate_unique_key 表,next_hi 列为例): 1. 获得 hi 值:读取并记录数据库的 hibernate_unique_key 表中 next_hi 字段的值,数据库中此字段值加 1 保存。 2. 获得 lo 值:从 0 到 max_lo 循环取值,差值为 1,当值为 max_lo 值时,重新获取 hi 值,然后 lo 值继续从 0 到 max_lo 循环。 3. 根据公式 hi _ (max_lo + 1) + lo 计算生成主键值。 注意:当 hi 值是 0 的时候,那么第一个值不是 0_(max_lo+1)+0=0,而是 lo 跳过 0 从 1 开始,直接是 1、2、3…… 那 max_lo 配置多大合适呢? 这要根据具体情况而定,如果系统一般不重启,而且需要用此表建立大量的主键,可以吧 max_lo 配置大一点,这样可以减少读取数据表的次数,提高效率;反之,如果服务器经常重启,可以吧 max_lo 配置小一点,可以避免每次重启主键之间的间隔太大,造成主键值主键不连贯。 特点:跨数据库,hilo 算法生成的标志只能在一个数据库中保证唯一。 3. seqhilo 与 hilo 类似,通过 hi/lo 算法实现的主键生成机制,只是将 hilo 中的数据表换成了序列 sequence,需要数据库中先创建 sequence,适用于支持 sequence 的数据库,如 Oracle。 ```xml hibernate_seq 100 ``` 特点:与 hilo 类似,只能在支持序列的数据库中使用。 4. increment 由 Hibernate 从数据库中取出主键的最大值(每个 session 只取 1 次),以该值为基础,每次增量为 1,在内存中生成主键,不依赖于底层的数据库,因此可以跨数据库。 ```xml ``` Hibernate 调用 org.hibernate.id.IncrementGenerator 类里面的 generate() 方法,使用 select max(idColumnName) from tableName 语句获取主键最大值。该方法被声明成了 synchronized,所以在一个独立的 Java 虚拟机内部是没有问题的,然而,在多个 JVM 同时并发访问数据库 select max 时就可能取出相同的值,再 insert 就会发生 Dumplicate entry 的错误。所以只能有一个 Hibernate 应用进程访问数据库,否则就可能产生主键冲突,所以不适合多进程并发更新数据库,适合单一进程访问数据库,不能用于群集环境。 官方文档:只有在没有其他进程往同一张表中插入数据时才能使用,在集群下不要使用。 特点:跨数据库,不适合多进程并发更新数据库,适合单一进程访问数据库,不能用于群集环境。 5. identity identity 由底层数据库生成标识符。identity 是由数据库自己生成的,但这个主键必须设置为自增长,使用 identity 的前提条件是底层数据库支持自动增长字段类型,如 DB2、SQL Server、MySQL、Sybase 和 HypersonicSQL 等,Oracle 这类没有自增字段的则不支持。 ```xml ``` 例:如果使用 MySQL 数据库,则主键字段必须设置成 auto_increment。 id int(11) primary key auto_increment 特点:只能用在支持自动增长的字段数据库中使用,如 MySQL。 6. sequence 采用数据库提供的 sequence 机制生成主键,需要数据库支持 sequence。如 oralce、DB、SAP DB、PostgerSQL、McKoi 中的 sequence。MySQL 这种不支持 sequence 的数据库则不行(可以使用 identity)。 ```xml hibernate_id ``` hibernate_id 指定sequence的名称 Hibernate生成主键时,查找sequence并赋给主键值,主键值由数据库生成,Hibernate不负责维护,使用时必须先创建一个sequence,如果不指定sequence名称,则使用Hibernate默认的sequence,名称为hibernate_sequence,前提要在数据库中创建该sequence。 特点:只能在支持序列的数据库中使用,如Oracle。 7. native native 由 hibernate 根据使用的数据库自行判断采用 identity、hilo、sequence 其中一种作为主键生成方式,灵活性很强。如果能支持 identity 则使用 identity,如果支持 sequence 则使用 sequence。 例如 MySQL 使用 identity,Oracle 使用 sequence 注意:如果 Hibernate 自动选择 sequence 或者 hilo,则所有的表的主键都会从 Hibernate 默认的 sequence 或 hilo 表中取。并且,有的数据库对于默认情况主键生成测试的支持,效率并不是很高。 使用 sequence 或 hilo 时,可以加入参数,指定 sequence 名称或 hi 值表名称等,如 hibernate_id 特点:根据数据库自动选择,项目中如果用到多个数据库时,可以使用这种方式,使用时需要设置表的自增字段或建立序列,建立表等。 8. uuid UUID:Universally Unique Identifier,是指在一台机器上生成的数字,它保证对在同一时空中的所有机器都是唯一的。按照开放软件基金会 (OSF) 制定的标准计算,用到了以太网卡地址、纳秒级时间、芯片 ID 码和许多可能的数字,标准的 UUID 格式为: xxxxxxxx-xxxx-xxxx-xxxxxx-xxxxxxxxxx (8-4-4-4-12) 其中每个 x 是 0-9 或 a-f 范围内的一个十六进制的数字。 Hibernate 在保存对象时,生成一个 UUID 字符串作为主键,保证了唯一性,但其并无任何业务逻辑意义,只能作为主键,唯一缺点长度较大,32 位(Hibernate 将 UUID 中间的“-”删除了)的字符串,占用存储空间大,但是有两个很重要的优点,Hibernate 在维护主键时,不用去数据库查询,从而提高效率,而且它是跨数据库的,以后切换数据库极其方便。 特点:uuid 长度大,占用空间大,跨数据库,不用访问数据库就生成主键值,所以效率高且能保证唯一性,移植非常方便,推荐使用。 9. guid GUID:Globally Unique Identifier 全球唯一标识符,也称作 UUID,是一个 128 位长的数字,用 16 进制表示。算法的核心思想是结合机器的网卡、当地时间、一个随即数来生成 GUID。从理论上讲,如果一台机器每秒产生 10000000 个 GUID,则可以保证(概率意义上)3240 年不重复。 Hibernate 在维护主键时,先查询数据库,获得一个 uuid 字符串,该字符串就是主键值,该值唯一,缺点长度较大,支持数据库有限,优点同 uuid,跨数据库,但是仍然需要访问数据库。 注意:长度因数据库不同而不同 MySQL 中使用 select uuid() 语句获得的为 36 位(包含标准格式的“-”) Oracle 中,使用 select rawtohex(sys_guid()) from dual 语句获得的为 32 位(不包含“-”) 特点:需要数据库支持查询 uuid,生成时需要查询数据库,效率没有 uuid 高,推荐使用 uuid。 10. foreign 使用另外一个相关联的对象的主键作为该对象主键。主要用于一对一关系中。 ```xml user ``` 该例使用 domain.User 的主键作为本类映射的主键。 特点:很少使用,大多用在一对一关系中。。 2.普通属性(property) 开发人员可以打开网址: 来查看 hibernate3.0 的 dtd 信息,可看到 property 的定义如下: 它的各属性中比较常用的有:name(对应的java类的属性名称)、column(对应的表中的字段)、tyope(属性的类型,eg.java.lang.String)、not-null(设置该属性是否为空,为true时表示非空,默认为false)和length(字段的长度限制)。 Eg1. Eg2. Eg3. 3.一对多关系(``) 一对多关系一般是用在一个表与另一个表存在外键关联的时候,例如用户表的组织 id 与组织表存在外键关联,则“一”方为组织表,“多”方为用户表,因为一个组织可以包含多个用户,而一个用户只能隶属于一个组织。 对于存在一对多关系和多对一关系的双方,需要在…hbm.xml 中进行相应配置,这时在“一”方(例如:组织)需要在映射文件中添加元素,因为它包含多个“多”方的对象,一般的格式如下: ```xml ``` “多”方(例如:用户)隶属于一个“一”方对象,一般的格式如下: 4.一对一关系(``) 一对一关系相对一对多关系来说比较少见,但也在某些情况下要用到,例如有一个用户的基本信息表(USER)和一个用户的密码表(PASSWD)就存在一对一的关系。下面来看一下一对一关系在 Hibernate 的配置。 其中主表(eg. 用户的基本信息表)的配置如下: ```xml ``` 子表(eg. 用户的密码表)的配置如下: ```xml ``` 5.多对多关系() 在数据库设计时,一般将多对多关系转换为两个一对多(或多对一)关系,例如在基于角色的权限系统中,用户和角色存在的关系就是典型的多对多关系,即一个用户可以具有多个角色,而一个角色又可以为多个用户所有,一般在设计时,都会加一个用户与角色的关联表,该表与用户表以及角色表都存在外键关联。 在本小节中讲述的是没有分解的多对多关系在 Hibernate 中如何配置。设置格式如下: ```xml ``` 6.完整实例 在本小节中举一些.hbm.xml 映射文件的例子,让开发人员对其有一个感性的认识。接下来讲述一个用户表(tbl_user)、用户与角色关联表(tbl_user_role)、角色表(tbl_role)以及组织表(tbl_organization)的例子。 (1)tbl_user ```xml ``` (2)tbl_organization ```xml ``` (3)tbl_user_role ```xml ``` (4)`tbl_role` ```xml ``` ```xml com.mysql.jdbc.Driver jdbc:mysql://localhost/hibernate root 123456 20 true 50 23 false true gbk org.hibernate.dialect.MySQLDialect //连接驱动 //连接url, //连接用户名 //连接密码 //hibernate配置文件位置 WEB-INF/hibernate.cfg.xml //针对oracle数据库的方言,特定的关系数据库生成优化的SQL org.hibernate.dialect.OracleDialect //选择HQL解析器的实现 org.hibernate.hql.ast.ASTQueryTranslatorFactory //是否在控制台打印sql语句 true //在Hibernate系统参数中hibernate.use_outer_join被打开的情况下,该参数用来允许使用outer join来载入此集合的数据。 true //默认打开,启用cglib反射优化。cglib是用来在Hibernate中动态生成PO字节码的,打开优化可以加快字节码构造的速度 true //输出格式化后的sql,更方便查看 true //“useUnicode”和“characterEncoding”决定了它是否在客户端和服务器端传输过程中进行Encode,以及如何进行Encode true //允许查询缓存, 个别查询仍然需要被设置为可缓存的. false 16 //连接池的最大活动个数 100 //当连接池中的连接已经被耗尽的时候,DBCP将怎样处理(0 = 失败,1 = 等待,2 = 增长) 1 //最大等待时间 1200 //没有人用连接的时候,最大闲置的连接个数 10 ##以下是对prepared statement的处理,同上。 100 1 1200 10 ``` ## [探索Spring MVC注解驱动:从基础到高级](https://blog.dong4j.site/posts/aab1eaf1.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}})ver) 多个 Spring 配置文件处理 1. 采用逗号将多个配置文件分开 2. 使用通配符 开启注解: `` 开启自动扫描 `对请求参数进行过滤 3. method -->代表请求方式 通过提交方式进行过滤请求 请求处理方法可以接受参数 HttpServletRequest HttpServletRespones HttpServletSession @RequsetParam 注解 restful representational state transfer 表现层状态转移 请求处理方法可以返回的值 ## 数据绑定 1. 基本数据类型,String String[]绑定 - 只需要让 jsp 中的 name 值和 controller 中的属性名相同即可 2. 绑定到一个 Bean 当中去 - 只要保证 Bean 中属性的名字和表单数据的 name 一样 3. List 绑定 ## 类型转换 @InitBinber Converter-->类型转换器 ## 输入验证 Hibernate Validator ## 文件上传 ## 拦截器 实现 HandlerInterceptor 1. perHandler -->Controller 前 2. postHandler -->Controller 后 3. alterCompletion -->一般进行一些清理工作 继承 HandlerInterptorAdaptor ## [Spring 入门:EJB vs Spring 容器大比拼](https://blog.dong4j.site/posts/90f41285.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 1. EJB 缺点 EJB 糟糕的代码和代码框架 分布式模型滥用 单元测试难以实现 不要的复杂度导致性能降低 容器的可移植性 2. 重量级容器缺点 带有侵入性的 API(代码依赖于 EJB) 对容器的依赖 (代码不能再 EJB 容器之外工作) 提供固定的一组机能,不具备配置能力 不同的产品,部署过程不同,不易通用 启动时间长 轻量级容器: 3. Spring 容器 负责 POJO 的生成和绑定关系 IoC 和 AOP 容器的功效由 IoC 完成 根据 Web 应用,小应用,桌面程序的不同,对容器的依赖程度不同 Spring 将管理的 Bean 作为 POJO 进行控制,通过 AOP Interceptor 能够增强其他功能 Spring 框架图 1. Spring AOP 2. Spring ORM 3. Spring DAO 4. Spring Web 5. Spring Context 6. Spring Web MVC 7. Spring Core ## IoC<-->DI 控制反转<-->依赖注入 整个应用程序的表现结果是由外部容器的 **配置** 来控制整个应用程序的运行效果 在系统运行时,由容器 (操作系统) 将依赖关系 (USB 外部设备) 注入到组件中 作用: 1. 协调各组件间相互的依赖关系,同时大大提高了组件的可移植性 2. 其实就是在调用 Service 是通过接口指向实现,把那个实现类写到了配置文件中,在程序运行的时候动态的通过配置文件来找到到底要实现那个接口实现类; 通过修改配置文件就可以实现组件的可扩展性 (修改配置文件中的实现类名) Spring API Bean 工厂 先读取配置文件 然后调用 getBean(),然后产生 Bean 对象 所有的容器负责创建 Bean 对象,负责销毁单例的 Bean,非单例的不负责销毁 在整个应用中,出了 DTO 对象,其他 bean 全部由 Spring 管理 spring 配置文件中的 id 属性和 name 属性的区别 id 属性只允许使用数字,字母,下划线和 $,而且数字不能开头; name 属性可以随便 所有当需要其他特殊字符作为名字是必须使用 name ## AOP 面向切面 (方向) 编程 通过动态代理实现 一个对象运行起来后,可以动态的添加执行代码 (属性,方法) 但是 java 只能在方法前后添加代码,因为 java 并不是完全的动态语言 ## IoC 用白话来讲,就是由容器控制程序之间的关系,而非传统实现中,由程序代码直接操控。这也就是所谓“控制反转”的概念所在:控制权由应用代码中转到了外部容器,控制权的转移,是所谓反转 ## DI 即组件之间的依赖关系由容器在运行期决定,形象的来说,即由容器动态的将某种依赖关系注入到组件之中。 ## [RabbitMQ基础教程:Fanout交换器与多消费者模型](https://blog.dong4j.site/posts/e954a744.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 使用场景:发布、订阅模式,发送端发送广播消息,多个接收端接收。 前面我们实现了工作队列,并且我们的工作队列中的一个任务只会发给一个工作者,除非某个工作者未完成任务意外被杀死,会转发给另外的工作者。这篇博客中,我们会做一些改变,就是把一个消息发给多个消费者,这种模式称之为发布/订阅(类似观察者模式)。 为了验证这种模式,我们准备构建一个简单的日志系统。这个系统包含两类程序,一类程序发动日志,另一类程序接收和处理日志。          在我们的日志系统中,每一个运行的接收者程序都会收到日志。然后我们实现,一个接收者将接收到的数据写到硬盘上,与此同时,另一个接收者把接收到的消息展现在屏幕上。          本质上来说,就是发布的日志消息会转发给所有的接收者。 ## 转发器(Exchanges) 前面的博客中我们主要的介绍都是发送者发送消息给队列,接收者从队列接收消息。下面我们会引入**Exchanges**,展示 RabbitMQ 的完整的消息模型。 RabbitMQ 消息模型的核心理念是生产者永远不会直接发送任何消息给队列,一般的情况生产者甚至不知道消息应该发送到哪些队列。 相反的,生产者只能发送消息给转发器(Exchange)。转发器是非常简单的,一边接收从生产者发来的消息,另一边把消息推送到队列中。转发器必须清楚的知道消息如何处理它收到的每一条消息。是否应该追加到一个指定的队列?是否应该追加到多个队列?或者是否应该丢弃?这些规则通过转发器的类型进行定义。 下面列出一些可用的转发器类型: ### Direct 完全根据 key 进行投递 任何发送到 Direct Exchange 的消息都会被转发到 RouteKey 中指定的 Queue。 1. 一般情况可以使用 rabbitMQ 自带的 Exchange:”"(该 Exchange 的名字为空字符串,下文称其为 default Exchange)。 2. 这种模式下不需要将 Exchange 进行任何绑定 (binding) 操作 3. 消息传递时需要一个“RouteKey”,可以简单的理解为要发送到的队列名字。 4. 如果 vhost 中不存在 RouteKey 中指定的队列名,则该消息会被抛弃。 ### Topic 对 key 进行模式匹配后进行投递 任何发送到 Topic Exchange 的消息都会被转发到所有关心 RouteKey 中指定话题的 Queue 上 1. 这种模式较为复杂,简单来说,就是每个队列都有其关心的主题,所有的消息都带有一个“标题”(RouteKey),Exchange 会将消息转发到所有关注主题能与 RouteKey 模糊匹配的队列。 2. 这种模式需要 RouteKey,也许要提前绑定 Exchange 与 Queue。 3. 在进行绑定时,要提供一个该队列关心的主题,如“#.log.#”表示该队列关心所有涉及 log 的消息 (一个 RouteKey 为”MQ.log.error”的消息会被转发到该队列)。 4. “#”表示 0 个或若干个关键字,“_”表示一个关键字。如“log._”能与“log.warn”匹配,无法与“log.warn.timeout”匹配;但是“log.#”能与上述两者匹配。 5. 同样,如果 Exchange 没有发现能够与 RouteKey 匹配的 Queue,则会抛弃此消息。 ### Headers ### Fanout 不需要 key,采取广播模式,一个消息进来时,投递到与该交换机绑定的所有队列。 任何发送到 Fanout Exchange 的消息都会被转发到与该 Exchange 绑定 (Binding) 的所有 Queue 上。 1. 可以理解为路由表的模式 2. 这种模式不需要 RouteKey 3. 这种模式需要提前将 Exchange 与 Queue 进行绑定,一个 Exchange 可以绑定多个 Queue,一个 Queue 可以同多个 Exchange 进行绑定。 4. 如果接受到消息的 Exchange 没有与任何 Queue 绑定,则消息会被抛弃。 目前我们关注最后一个 fanout,声明转发器类型的代码: `channel.exchangeDeclare("logs","fanout");` fanout 类型转发器特别简单,把所有它介绍到的消息,广播到所有它所知道的队列。不过这正是我们前述的日志系统所需要的。 ### 匿名转发器(nameless exchange) 前面说到生产者只能发送消息给转发器(Exchange),但是我们前两篇博客中的例子并没有使用到转发器,我们仍然可以发送和接收消息。这是因为我们使用了一个默认的转发器,它的标识符为””。之前发送消息的代码: `channel.basicPublish("", QUEUE_NAME,MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());` 第一个参数为转发器的名称,我们设置为”” : 如果存在 routingKey(第二个参数),消息由 routingKey 决定发送到哪个队列。 现在我们可以指定消息发送到的转发器: `channel.basicPublish( "logs","", null, message.getBytes());` ### 临时队列(Temporary queues) 前面的博客中我们都为队列指定了一个特定的名称。能够为队列命名对我们来说是很关键的,我们需要指定消费者为某个队列。当我们希望在生产者和消费者间共享队列时,为队列命名是很重要的。不过,对于我们的日志系统我们并不关心队列的名称。我们想要接收到所有的消息,而且我们也只对当前正在传递的数据的感兴趣。为了满足我们的需求,需要做两件事: 1. 无论什么时间连接到 Rabbit 我们都需要一个新的空的队列。为了实现,我们可以使用随机数创建队列,或者更好的,让服务器给我们提供一个随机的名称。 2. 一旦消费者与 Rabbit 断开,消费者所接收的那个队列应该被自动删除。Java 中我们可以使用 queueDeclare() 方法,不传递任何参数,来创建一个非持久的、唯一的、自动删除的队列且队列名称由服务器随机产生。 `String queueName = channel.queueDeclare().getQueue();`一般情况这个名称与 amq.gen-JzTY20BRgKO-HjmUJj0wLg 类似。 ### 绑定(Bindings) 我们已经创建了一个 fanout 转发器和队列,我们现在需要通过 binding 告诉转发器把消息发送给我们的队列。 `channel.queueBind(queueName, “logs”, ””)` 参数 1:队列名称 ;参数 2:转发器名称 ### 完整的例子 生产者: ```java public class EmitLog { private final static String EXCHANGE_NAME = "ex_log"; public static void main(String[] args) throws IOException { // 创建连接和频道 ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); // 声明转发器和类型 channel.exchangeDeclare(EXCHANGE_NAME, "fanout"); String message = new Date().toLocaleString() + " : log something"; // 往转发器上发送消息 channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes()); System.out.println(" [x] Sent '" + message + "'"); channel.close(); connection.close(); } } ``` 消费者 1: ```java public class ReceiveLogsToSave { private final static String EXCHANGE_NAME = "ex_log"; public static void main(String[] argv) throws java.io.IOException, java.lang.InterruptedException { // 创建连接和频道 ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); channel.exchangeDeclare(EXCHANGE_NAME, "fanout"); // 创建一个非持久的、唯一的且自动删除的队列 String queueName = channel.queueDeclare().getQueue(); // 为转发器指定队列,设置binding channel.queueBind(queueName, EXCHANGE_NAME, ""); System.out.println(" [*] Waiting for messages. To exit press CTRL+C"); QueueingConsumer consumer = new QueueingConsumer(channel); // 指定接收者,第二个参数为自动应答,无需手动应答 channel.basicConsume(queueName, true, consumer); while (true) { QueueingConsumer.Delivery delivery = consumer.nextDelivery(); String message = new String(delivery.getBody()); print2File(message); } } private static void print2File(String msg) { try { String dir = ReceiveLogsToSave.class.getClassLoader().getResource("").getPath(); System.out.println(dir); String logFileName = new SimpleDateFormat("yyyy-MM-dd") .format(new Date()); File file = new File(dir, logFileName + ".txt"); FileOutputStream fos = new FileOutputStream(file, true); fos.write((msg + "\r\n").getBytes()); fos.flush(); fos.close(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } } ``` 消费者 2: ```java public class ReceiveLogsToConsole { private final static String EXCHANGE_NAME = "ex_log"; public static void main(String[] argv) throws java.io.IOException, java.lang.InterruptedException { // 创建连接和频道 ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); channel.exchangeDeclare(EXCHANGE_NAME, "fanout"); // 创建一个非持久的、唯一的且自动删除的队列 String queueName = channel.queueDeclare().getQueue(); // 为转发器指定队列,设置binding channel.queueBind(queueName, EXCHANGE_NAME, ""); System.out.println(" [*] Waiting for messages. To exit press CTRL+C"); QueueingConsumer consumer = new QueueingConsumer(channel); // 指定接收者,第二个参数为自动应答,无需手动应答 channel.basicConsume(queueName, true, consumer); while (true) { QueueingConsumer.Delivery delivery = consumer.nextDelivery(); String message = new String(delivery.getBody()); System.out.println(" [x] Received '" + message + "'"); } } } ``` ## [从轮询到公平:RabbitMQ的消息队列策略](https://blog.dong4j.site/posts/ab083149.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) > 工作队列的主要任务是:避免立刻执行资源密集型任务,然后必须等待其完成。相反地,我们进行任务调度:我们把任务封装为消息发送给队列。工作进行在后台运行并不断的从队列中取出任务然后执行。当你运行了多个工作进程时,任务队列中的任务将会被工作进程共享执行。 > 这样的概念在 web 应用中极其有用,当在很短的 HTTP 请求间需要执行复杂的任务 生产者: ```java public class NewTask { //队列名称 private final static String QUEUE_NAME = "workqueue"; public static void main(String[] args) throws IOException { //创建连接和频道 ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); //声明队列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); //发送10条消息,依次在消息后面附加1-10个点 for (int i = 0; i < 10; i++) { String dots = ""; for (int j = 0; j <= i; j++) { dots += "."; } String message = "helloworld" + dots+dots.length(); channel.basicPublish("", QUEUE_NAME, null, message.getBytes()); System.out.println(" [x] Sent '" + message + "'"); } //关闭频道和资源 channel.close(); connection.close(); } } ``` 消费者 1: ```java public class Work_1 { //队列名称 private final static String QUEUE_NAME = "workqueue"; public static void main(String[] argv) throws java.io.IOException, java.lang.InterruptedException { //区分不同工作进程的输出 int hashCode = Work.class.hashCode(); //创建连接和频道 ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); //声明队列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); System.out.println(hashCode + " [*] Waiting for messages. To exit press CTRL+C"); QueueingConsumer consumer = new QueueingConsumer(channel); // 指定消费队列 // 关闭消息应答机制 boolean ack = true; channel.basicConsume(QUEUE_NAME, ack, consumer); while (true) { QueueingConsumer.Delivery delivery = consumer.nextDelivery(); String message = new String(delivery.getBody()); System.out.println(hashCode + " [x] Received '" + message + "'"); doWork(message); System.out.println(hashCode + " [x] Done"); } } /** * 每个点耗时1s * @param task * @throws InterruptedException */ private static void doWork(String task) throws InterruptedException { for (char ch : task.toCharArray()) { if (ch == '.') Thread.sleep(1000); } } } ``` 消费者 2: ```java public class Work_2 { //队列名称 private final static String QUEUE_NAME = "workqueue"; public static void main(String[] argv) throws java.io.IOException, java.lang.InterruptedException { //区分不同工作进程的输出 int hashCode = Work.class.hashCode(); //创建连接和频道 ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); //声明队列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); System.out.println(hashCode + " [*] Waiting for messages. To exit press CTRL+C"); QueueingConsumer consumer = new QueueingConsumer(channel); // 指定消费队列 // 关闭消息应答机制 boolean ack = true; channel.basicConsume(QUEUE_NAME, ack, consumer); while (true) { QueueingConsumer.Delivery delivery = consumer.nextDelivery(); String message = new String(delivery.getBody()); System.out.println(hashCode + " [x] Received '" + message + "'"); doWork(message); System.out.println(hashCode + " [x] Done"); } } /** * 每个点耗时1s * @param task * @throws InterruptedException */ private static void doWork(String task) throws InterruptedException { for (char ch : task.toCharArray()) { if (ch == '.') Thread.sleep(1000); } } } ``` 执行结果 消费者 1: ` ``` 895328852 [*] Waiting for messages. To exit press CTRL+C 895328852 [x] Received 'helloworld.1' 895328852 [x] Done 895328852 [x] Received 'helloworld…3' 895328852 [x] Done 895328852 [x] Received 'helloworld…..5' 895328852 [x] Done 895328852 [x] Received 'helloworld…….7' 895328852 [x] Done 895328852 [x] Received 'helloworld………9' ``` 消费者 2: ``` 1581781576 [*] Waiting for messages. To exit press CTRL+C 1581781576 [x] Received 'helloworld..2' 1581781576 [x] Done 1581781576 [x] Received 'helloworld….4' 1581781576 [x] Done 1581781576 [x] Received 'helloworld……6' 1581781576 [x] Done 1581781576 [x] Received 'helloworld……..8' 1581781576 [x] Done 1581781576 [x] Received 'helloworld……….10' 1581781576 [x] Done ``` ### 值得注意的几点 1. 默认的,RabbitMQ 会一个一个的发送信息给下一个消费者 (consumer),而不考虑每个任务的时长等等,且是**一次性分配**,并非一个一个分配。平均的每个消费者将会获得相等数量的消息。这样分发消息的方式叫做**round-robin**,即**轮询调度**。 2. 执行一个任务需要花费几秒钟。你可能会担心当一个工作者在执行任务时发生中断。我们上面的代码,一旦 RabbItMQ 交付了一个信息给消费者,会马上从内存中移除这个信息。在这种情况下,如果杀死正在执行任务的某个工作者,我们会丢失它正在处理的信息。我们也会丢失已经转发给这个工作者且它还未执行的消息。 3. 当我们杀死一个正在工作的进程时,分配给该进程的任务就不会执行,从而丢失消息.为了防止这种情况发生, RabbitMQ 使用了消息应答(message acknowledgments),消费者发送应答给 RabbitMQ,告诉它信息已经被接收和处理,然后 RabbitMQ 可以自由的进行信息删除。 ## 消息应答机制 (message acknowledgments) 如果消费者被杀死而没有发送应答,RabbitMQ 会认为该信息没有被完全的处理,然后将会**重新转发给别的消费者**。通过这种方式,你可以确认信息不会被丢失,即使消者偶尔被杀死。 这种机制并没有超时时间这么一说,RabbitMQ 只有在消费者连接断开时重新转发此信息。如果消费者处理一个信息需要耗费特别特别长的时间是允许的。 boolean ack = false; // 开启消息应答 修改后的输出结果: 消费者 1:(中途中断) ``` 895328852 [*] Waiting for messages. To exit press CTRL+C 895328852 [x] Received 'helloworld.1' 895328852 [x] Done 895328852 [x] Received 'helloworld…3' ``` Process finished with exit code 130 消费者 2: ``` 1581781576 [*] Waiting for messages. To exit press CTRL+C 1581781576 [x] Received 'helloworld..2' 1581781576 [x] Done 1581781576 [x] Received 'helloworld….4' 1581781576 [x] Done 1581781576 [x] Received 'helloworld……6' 1581781576 [x] Done 1581781576 [x] Received 'helloworld……..8' 1581781576 [x] Done 1581781576 [x] Received 'helloworld……….10' 1581781576 [x] Done 1581781576 [x] Received 'helloworld.1' 1581781576 [x] Done 1581781576 [x] Received 'helloworld…3' 1581781576 [x] Done 1581781576 [x] Received 'helloworld…..5' 1581781576 [x] Done 1581781576 [x] Received 'helloworld…….7' 1581781576 [x] Done 1581781576 [x] Received 'helloworld………9' 1581781576 [x] Done ``` ### 值得注意的几点: 1. 在消费者 1 执行到第 2 个任务时中断了,消费者执行完分配的任务后,开始执行分配给消费者 1 的任务 2. 任务重复执行了 3. 当开启了消息应答的消费者任务中断后,此消费者的任务会重新分配给其他消费者,且是一次分配,且不管是否已经完成的工作,都会分配给其他消费者 ## 消息持久化(Message durability) 通过消息应答机制,即使消费者被杀死,消息也不会被丢失。但是如果此时 RabbitMQ 服务被停止,我们的消息仍然会丢失。 当 RabbitMQ 退出或者异常退出,将会丢失所有的队列和信息,除非你告诉它不要丢失。我们需要做两件事来确保信息不会被丢失:我们需要给所有的队列和消息设置持久化的标志。 1. 我们需要确认 RabbitMQ 永远不会丢失我们的队列。为了这样,我们需要声明它为持久化的。 boolean durable = true; channel.queueDeclare("task_queue", durable, false, false, null); 注:RabbitMQ 不允许使用不同的参数重新定义一个队列,所以已经存在的队列,我们无法修改其属性。 2. 我们需要标识我们的信息为持久化的。通过设置 MessageProperties(implements BasicProperties)值为 PERSISTENT_TEXT_PLAIN。 channel.basicPublish("", "task_queue",MessageProperties.PERSISTENT_TEXT_PLAIN,message.getBytes()); 现在你可以执行一个发送消息的程序,然后关闭服务,再重新启动服务,运行消费者程序做下实验。**只有第一个消费者才会处理消息**. ## 公平转发(Fair dispatch) 或许会发现,目前的消息转发机制(Round-robin)并非是我们想要的。例如,这样一种情况,对于两个消费者,有一系列的任务,奇数任务特别耗时,而偶数任务却很轻松,这样造成一个消费者一直繁忙,另一个消费者却很快执行完任务后等待。 造成这样的原因是因为 RabbitMQ 仅仅是当消息到达队列进行转发消息。并不在乎有多少任务消费者并未传递一个应答给 RabbitMQ。仅仅盲目转发所有的奇数给一个消费者,偶数给另一个消费者。 为了解决这样的问题,我们可以使用 basicQos 方法,传递参数为 prefetchCount = 1。这样告诉 RabbitMQ 不要在同一时间给一个消费者超过一条消息。换句话说,只有在消费者空闲的时候会发送下一条信息。 生产者: ```java public class NewTask { // 队列名称 private final static String QUEUE_NAME = "workqueue_persistence"; public static void main(String[] args) throws IOException { // 创建连接和频道 ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); // 声明队列 boolean durable = true;// 1、设置队列持久化 channel.queueDeclare(QUEUE_NAME, durable, false, false, null); // 发送10条消息,依次在消息后面附加1-10个点 for (int i = 5; i > 0; i--) { String dots = ""; for (int j = 0; j <= i; j++) { dots += "."; } String message = "helloworld" + dots + dots.length(); // MessageProperties 2、设置消息持久化 channel.basicPublish("", QUEUE_NAME, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes()); System.out.println(" [x] Sent '" + message + "'"); } // 关闭频道和资源 channel.close(); connection.close(); } } ``` 消费者 1: ```java public class Work_1 { // 队列名称 private final static String QUEUE_NAME = "workqueue_persistence"; public static void main(String[] argv) throws java.io.IOException, java.lang.InterruptedException { // 区分不同工作进程的输出 int hashCode = Work.class.hashCode(); // 创建连接和频道 ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); // 声明队列 boolean durable = true; channel.queueDeclare(QUEUE_NAME, durable, false, false, null); System.out.println(hashCode + " [*] Waiting for messages. To exit press CTRL+C"); //设置最大服务转发消息数量 int prefetchCount = 1; channel.basicQos(prefetchCount); QueueingConsumer consumer = new QueueingConsumer(channel); // 指定消费队列 boolean ack = false; // 打开应答机制 channel.basicConsume(QUEUE_NAME, ack, consumer); while (true) { QueueingConsumer.Delivery delivery = consumer.nextDelivery(); String message = new String(delivery.getBody()); System.out.println(hashCode + " [x] Received '" + message + "'"); doWork(message); System.out.println(hashCode + " [x] Done"); //channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); } } /** * 每个点耗时1s * * @param task * @throws InterruptedException */ private static void doWork(String task) throws InterruptedException { for (char ch : task.toCharArray()) { if (ch == '.') Thread.sleep(1000); } } } ``` 消费者 2: ```java public class Work_2 { // 队列名称 private final static String QUEUE_NAME = "workqueue_persistence"; public static void main(String[] argv) throws java.io.IOException, java.lang.InterruptedException { // 区分不同工作进程的输出 int hashCode = Work.class.hashCode(); // 创建连接和频道 ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); // 声明队列 boolean durable = true; channel.queueDeclare(QUEUE_NAME, durable, false, false, null); System.out.println(hashCode + " [*] Waiting for messages. To exit press CTRL+C"); //设置最大服务转发消息数量 int prefetchCount = 1; channel.basicQos(prefetchCount); QueueingConsumer consumer = new QueueingConsumer(channel); // 指定消费队列 boolean ack = false; // 打开应答机制 channel.basicConsume(QUEUE_NAME, ack, consumer); while (true) { QueueingConsumer.Delivery delivery = consumer.nextDelivery(); String message = new String(delivery.getBody()); System.out.println(hashCode + " [x] Received '" + message + "'"); doWork(message); System.out.println(hashCode + " [x] Done"); //channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); } } /** * 每个点耗时1s * * @param task * @throws InterruptedException */ private static void doWork(String task) throws InterruptedException { for (char ch : task.toCharArray()) { if (ch == '.') Thread.sleep(1000); } } } ``` 此时并没有按照之前的 Round-robin 机制进行转发消息,而是当消费者不忙时进行转发。且这种模式下支持动态增加消费者,因为消息并没有发送出去,动态增加了消费者马上投入工作。而默认的转发机制会造成,即使动态增加了消费者,此时的消息已经分配完毕,无法立即加入工作,即使有很多未完成的任务。 ## [RabbitMQ入门:深入理解消息队列核心概念](https://blog.dong4j.site/posts/90881fe0.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) RabbitMQ 是一个由 erlang 开发的 AMQP(Advanced Message Queue )的开源实现。AMQP 的出现其实也是应了广大人民群众的需求,虽然在同步消息通讯的世界里有很多公开标准(如 COBAR 的 IIOP ,或者是 SOAP 等),但是在异步消息处理中却不是这样,只有大企业有一些商业实现(如微软的 MSMQ ,IBM 的 Websphere MQ 等),因此,在 2006 年的 6 月,Cisco 、Redhat、iMatix 等联合制定了 AMQP 的公开标准。 RabbitMQ 是由 RabbitMQ Technologies Ltd 开发并且提供商业支持的。该公司在 2010 年 4 月被 SpringSource(VMWare 的一个部门)收购。在 2013 年 5 月被并入 Pivotal。其实 VMWare,Pivotal 和 EMC 本质上是一家的。不同的是 VMWare 是独立上市子公司,而 Pivotal 是整合了 EMC 的某些资源,现在并没有上市。 RabbitMQ 的官网是 ## 应用 1. RabbitMQ Server: 也叫 broker server,它不是运送食物的卡车,而是一种传输服务。原话是 RabbitMQisn’t a food truck, it’s a delivery service. 他的角色就是维护一条从 Producer 到 Consumer 的路线,保证数据能够按照指定的方式进行传输。但是这个保证也不是 100% 的保证,但是对于普通的应用来说这已经足够了。当然对于商业系统来说,可以再做一层数据一致性的 guard,就可以彻底保证系统的一致性了。 2. Client A & B: 也叫 Producer,数据的发送方。createmessages and publish (send) them to a broker server (RabbitMQ).一个 Message 有两个部分:payload(有效载荷)和 label(标签)。payload 顾名思义就是传输的数据。label 是 exchange 的名字或者说是一个 tag,它描述了 payload,而且 RabbitMQ 也是通过这个 label 来决定把这个 Message 发给哪个 Consumer。AMQP 仅仅描述了 label,而 RabbitMQ 决定了如何使用这个 label 的规则。 3. Client 1,2,3:也叫 Consumer,数据的接收方。Consumersattach to a broker server (RabbitMQ) and subscribe to a queue。把 queue 比作是一个有名字的邮箱。当有 Message 到达某个邮箱后,RabbitMQ 把它发送给它的某个订阅者即 Consumer。当然可能会把同一个 Message 发送给很多的 Consumer。在这个 Message 中,只有 payload,label 已经被删掉了。对于 Consumer 来说,它是不知道谁发送的这个信息的。就是协议本身不支持。但是当然了如果 Producer 发送的 payload 包含了 Producer 的信息就另当别论了。 ### 为什么使用 Channel,而不是直接使用 TCP 连接? 对于 OS 来说,建立和关闭 TCP 连接是有代价的,频繁的建立关闭 TCP 连接对于系统的性能有很大的影响,而且 TCP 的连接数也有限制,这也限制了系统处理高并发的能力。但是,在 TCP 连接中建立 Channel 是没有上述代价的。对于 Producer 或者 Consumer 来说,可以并发的使用多个 Channel 进行 Publish 或者 Receive。有实验表明,1s 的数据可以 Publish10K 的数据包。当然对于不同的硬件环境,不同的数据包大小这个数据肯定不一样,但是我只想说明,对于普通的 Consumer 或者 Producer 来说,这已经足够了。如果不够用,你考虑的应该是如何细化 split 你的设计。 ## 创建步骤 生产者: 1. 创建连接 2. 创建频道 3. 创建消息体 4. 发送消息 消费者: 1. 创建连接 (循环等待连接) 2. 创建频道 3. 接收消息 使用场景:简单的发送与接收,没有特别的处理。 Producer: 生产者 发送方 ```java public class Send { //队列名称 private final static String QUEUE_NAME = "hello"; public static void main(String[] argv) throws java.io.IOException, InterruptedException { /** * 创建连接连接到MabbitMQ */ ConnectionFactory factory = new ConnectionFactory(); //设置MabbitMQ所在主机ip或者主机名 factory.setHost("localhost"); //创建一个连接 Connection connection = factory.newConnection(); //创建一个频道 Channel channel = connection.createChannel(); //指定一个队列 channel.queueDeclare(QUEUE_NAME, false, false, false, null); //发送的消息 String message = ""; //往队列中发出一条消息 for(int i = 0; i < 10; i++){ message = "hello world!" + i; channel.basicPublish("", QUEUE_NAME, null, message.getBytes()); System.out.println(" [x] Sent '" + message + "'"); Thread.sleep(1000); } //关闭频道和连接 channel.close(); connection.close(); } } ``` Consumer: 消费者 ```java public class Recv { //队列名称 private final static String QUEUE_NAME = "hello"; public static void main(String[] argv) throws java.io.IOException, java.lang.InterruptedException { //打开连接和创建频道,与发送端一样 ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); //声明队列,主要为了防止消息接收者先运行此程序,队列还不存在时创建队列。 channel.queueDeclare(QUEUE_NAME, false, false, false, null); System.out.println(" [*] Waiting for messages. To exit press CTRL+C"); //创建队列消费者 QueueingConsumer consumer = new QueueingConsumer(channel); //指定消费队列 channel.basicConsume(QUEUE_NAME, true, consumer); while (true) { //nextDelivery是一个阻塞方法(内部实现其实是阻塞队列的take方法) QueueingConsumer.Delivery delivery = consumer.nextDelivery(); String message = new String(delivery.getBody()); System.out.println(" [x] Received '" + message + "'"); } } } ``` ### 值得注意的几点 1. 队列只会在它不存在的时候创建,多次声明并不会重复创建 2. 生产者和消费者可以不再同一台机子上 3. 消息队列与 socket 类似 都是要有接受者和发送者 接受者循环等待发送者发送的消息 4. 接受者通过队列名来区分到底接收哪里发送过来的消息 5. 发送者发送消息时必须要用队列名标识,接受者通过队列名接收指定的消息 6. 发送者可以向相同的队列发送消息,多个接受者可以接收同一个队列的消息 ### 几个概念说明: **Broker**:简单来说就是消息队列服务器实体。 **Exchange**:消息交换机,它指定消息按什么规则,路由到哪个队列。 **Queue**:消息队列载体,每个消息都会被投入到一个或多个队列。 **Binding**:绑定,它的作用就是把 exchange 和 queue 按照路由规则绑定起来。 **Routing Key**:路由关键字,exchange 根据这个关键字进行消息投递。 **vhost**:虚拟主机,一个 broker 里可以开设多个 vhost,用作不同用户的权限分离。 **producer**:消息生产者,就是投递消息的程序。 **consumer**:消息消费者,就是接受消息的程序。 **channel**:消息通道,在客户端的每个连接里,可建立多个 channel,每个 channel 代表一个会话任务。 消息队列的使用过程大概如下: (1)客户端连接到消息队列服务器,打开一个 channel。 (2)客户端声明一个 exchange,并设置相关属性。 (3)客户端声明一个 queue,并设置相关属性。 (4)客户端使用 routing key,在 exchange 和 queue 之间建立好绑定关系。 (5)客户端投递消息到 exchange。 exchange 接收到消息后,就根据消息的 key 和已经设置的 binding,进行消息路由,将消息投递到一个或多个队列里。 exchange 也有几个类型,完全根据 key 进行投递的叫做 Direct 交换机,例如,绑定时设置了 routing key 为”abc”,那么客户端提交的消息,只有设置了 key 为”abc”的才会投递到队列。对 key 进行模式匹配后进行投递的叫做 Topic 交换机,符号”#”匹配一个或多个词,符号”_”匹配正好一个词。例如”abc.#”匹配”abc.def.ghi”,”abc._”只匹配”abc.def”。还有一种不需要 key 的,叫做 Fanout 交换机,它采取广播模式,一个消息进来时,投递到与该交换机绑定的所有队列。 RabbitMQ 支持消息的持久化,也就是数据写在磁盘上,为了数据安全考虑,我想大多数用户都会选择持久化。消息队列持久化包括 3 个部分: (1)exchange 持久化,在声明时指定 durable => 1 (2)queue 持久化,在声明时指定 durable => 1 (3)消息持久化,在投递时指定 delivery_mode => 2(1 是非持久化) 如果 exchange 和 queue 都是持久化的,那么它们之间的 binding 也是持久化的。如果 exchange 和 queue 两者之间有一个持久化,一个非持久化,就不允许建立绑定。 ## [掌握这6种算法,成为负载均衡高手!](https://blog.dong4j.site/posts/f1119903.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 1. 轮询法 将请求按顺序轮流地分配到后端服务器上,它均衡地对待后端的每一台服务器,而不关心服务器实际的连接数和当前的系统负载。 ## 2.随机法 通过系统的随机算法,根据后端服务器的列表大小值来随机选取其中的一台服务器进行访问。由概率统计理论可以得知,随着客户端调用服务端的次数增多, 其实际效果越来越接近于平均分配调用量到后端的每一台服务器,也就是轮询的结果。 ## 3. 源地址哈希法 源地址哈希的思想是根据获取客户端的 IP 地址,通过哈希函数计算得到的一个数值,用该数值对服务器列表的大小进行取模运算,得到的结果便是客服端要访问服务器的序号。采用源地址哈希法进行负载均衡,同一 IP 地址的客户端,当后端服务器列表不变时,它每次都会映射到同一台后端服务器进行访问。 ## 4. 加权轮询法 不同的后端服务器可能机器的配置和当前系统的负载并不相同,因此它们的抗压能力也不相同。给配置高、负载低的机器配置更高的权重,让其处理更多的请;而配置低、负载高的机器,给其分配较低的权重,降低其系统负载,加权轮询能很好地处理这一问题,并将请求顺序且按照权重分配到后端。 ## 5. 加权随机法 与加权轮询法一样,加权随机法也根据后端机器的配置,系统的负载分配不同的权重。不同的是,它是按照权重随机请求后端服务器,而非顺序。 ## 6. 最小连接数法 最小连接数算法比较灵活和智能,由于后端服务器的配置不尽相同,对于请求的处理有快有慢,它是根据后端服务器当前的连接情况,动态地选取其中当前 积压连接数最少的一台服务器来处理当前的请求,尽可能地提高后端服务的利用效率,将负责合理地分流到每一台服务器。 ## [Zookeeper Curator高级特性实战指南](https://blog.dong4j.site/posts/348a7282.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Curator 是 Netflix 公司开源的一套 zookeeper 客户端框架,解决了很多 Zookeeper 客户端非常底层的细节开发工作,包括连接重连、反复注册 Watcher 和 NodeExistsException 异常等等。Patrixck Hunt(Zookeeper)以一句“Guava is to Java that Curator to Zookeeper”给 Curator 予高度评价。 **引子和趣闻:** Zookeeper 名字的由来是比较有趣的,下面的片段摘抄自《从 PAXOS 到 ZOOKEEPER 分布式一致性原理与实践》一书: Zookeeper 最早起源于雅虎的研究院的一个研究小组。在当时,研究人员发现,在雅虎内部很多大型的系统需要依赖一个类似的系统进行分布式协调,但是这些系统往往存在分布式单点问题。所以雅虎的开发人员就试图开发一个通用的无单点问题的分布式协调框架。在立项初期,考虑到很多项目都是用动物的名字来命名的 (例如著名的 Pig 项目),雅虎的工程师希望给这个项目也取一个动物的名字。时任研究院的首席科学家 Raghu Ramakrishnan 开玩笑说:再这样下去,我们这儿就变成动物园了。此话一出,大家纷纷表示就叫动物园管理员吧——因为各个以动物命名的分布式组件放在一起,雅虎的整个分布式系统看上去就像一个大型的动物园了,而 Zookeeper 正好用来进行分布式环境的协调——于是,Zookeeper 的名字由此诞生了。 Curator 无疑是 Zookeeper 客户端中的瑞士军刀,它译作 " 馆长 " 或者 '' 管理者 '',不知道是不是开发小组有意而为之,笔者猜测有可能这样命名的原因是说明 Curator 就是 Zookeeper 的馆长 (脑洞有点大:Curator 就是动物园的园长)。 Curator 包含了几个包: **curator-framework:**对 zookeeper 的底层 api 的一些封装 **curator-client:**提供一些客户端的操作,例如重试策略等 **curator-recipes:**封装了一些高级特性,如:Cache 事件监听、选举、分布式锁、分布式计数器、分布式 Barrier 等 Maven 依赖 (使用 curator 的版本:2.12.0,对应 Zookeeper 的版本为:3.4.x,**如果跨版本会有兼容性问题,很有可能导致节点操作失败**): ```xml org.apache.curator curator-framework 2.12.0 org.apache.curator curator-recipes 2.12.0 ``` # Curator 的基本 Api ## 创建会话 ### 1.使用静态工程方法创建客户端 一个例子如下: ```java RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3); CuratorFramework client = CuratorFrameworkFactory.newClient( connectionInfo, 5000, 3000, retryPolicy); ``` newClient 静态工厂方法包含四个主要参数: | 参数名 | 说明 | | :------------------ | :---------------------------------------------------------: | | connectionString | 服务器列表,格式 host1:port1,host2:port2,… | | retryPolicy | 重试策略,内建有四种重试策略,也可以自行实现 RetryPolicy 接口 | | sessionTimeoutMs | 会话超时时间,单位毫秒,默认 60000ms | | connectionTimeoutMs | 连接创建超时时间,单位毫秒,默认 60000ms | ### 2.使用 Fluent 风格的 Api 创建会话 核心参数变为流式设置,一个列子如下: ```java RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3); CuratorFramework client = CuratorFrameworkFactory.builder() .connectString(connectionInfo) .sessionTimeoutMs(5000) .connectionTimeoutMs(5000) .retryPolicy(retryPolicy) .build(); ``` ### 3.创建包含隔离命名空间的会话 为了实现不同的 Zookeeper 业务之间的隔离,需要为每个业务分配一个独立的命名空间(**NameSpace**),即指定一个 Zookeeper 的根路径(官方术语:**_为 Zookeeper 添加“Chroot”特性_**)。例如(下面的例子)当客户端指定了独立命名空间为“/base”,那么该客户端对 Zookeeper 上的数据节点的操作都是基于该目录进行的。通过设置 Chroot 可以将客户端应用与 Zookeeper 服务端的一课子树相对应,在多个应用共用一个 Zookeeper 集群的场景下,这对于实现不同应用之间的相互隔离十分有意义。 ```java RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3); CuratorFramework client = CuratorFrameworkFactory.builder() .connectString(connectionInfo) .sessionTimeoutMs(5000) .connectionTimeoutMs(5000) .retryPolicy(retryPolicy) .namespace("base") .build(); ``` ## 启动客户端 当创建会话成功,得到 client 的实例然后可以直接调用其 start( ) 方法: ```java client.start(); ``` ## 数据节点操作 ### 创建数据节点 **Zookeeper 的节点创建模式:** - PERSISTENT:持久化 - PERSISTENT_SEQUENTIAL:持久化并且带序列号 - EPHEMERAL:临时 - EPHEMERAL_SEQUENTIAL:临时并且带序列号 **创建一个节点,初始内容为空** ```java client.create().forPath("path"); ``` 注意:如果没有设置节点属性,节点创建模式默认为持久化节点,内容默认为空 **创建一个节点,附带初始化内容** ```java client.create().forPath("path","init".getBytes()); ``` **创建一个节点,指定创建模式(临时节点),内容为空** ```java client.create().withMode(CreateMode.EPHEMERAL).forPath("path"); ``` **创建一个节点,指定创建模式(临时节点),附带初始化内容** ```java client.create().withMode(CreateMode.EPHEMERAL).forPath("path","init".getBytes()); ``` **创建一个节点,指定创建模式(临时节点),附带初始化内容,并且自动递归创建父节点** ```java client.create() .creatingParentContainersIfNeeded() .withMode(CreateMode.EPHEMERAL) .forPath("path","init".getBytes()); ``` 这个 creatingParentContainersIfNeeded() 接口非常有用,因为一般情况开发人员在创建一个子节点必须判断它的父节点是否存在,如果不存在直接创建会抛出 NoNodeException,使用 creatingParentContainersIfNeeded() 之后 Curator 能够自动递归创建所有所需的父节点。 ### 删除数据节点 **删除一个节点** ```java client.delete().forPath("path"); ``` 注意,此方法只能删除**叶子节点**,否则会抛出异常。 **删除一个节点,并且递归删除其所有的子节点** ```java client.delete().deletingChildrenIfNeeded().forPath("path"); ``` **删除一个节点,强制指定版本进行删除** ```java client.delete().withVersion(10086).forPath("path"); ``` **删除一个节点,强制保证删除** ```java client.delete().guaranteed().forPath("path"); ``` guaranteed() 接口是一个保障措施,只要客户端会话有效,那么 Curator 会在后台持续进行删除操作,直到删除节点成功。 **注意:**上面的多个流式接口是可以自由组合的,例如: ```java client.delete().guaranteed().deletingChildrenIfNeeded().withVersion(10086).forPath("path"); ``` ### 读取数据节点数据 **读取一个节点的数据内容** ```java client.getData().forPath("path"); ``` 注意,此方法返的返回值是 byte[ ]; **读取一个节点的数据内容,同时获取到该节点的 stat** ```java Stat stat = new Stat(); client.getData().storingStatIn(stat).forPath("path"); ``` ### 更新数据节点数据 **更新一个节点的数据内容** ```java client.setData().forPath("path","data".getBytes()); ``` 注意:该接口会返回一个 Stat 实例 **更新一个节点的数据内容,强制指定版本进行更新** ```java client.setData().withVersion(10086).forPath("path","data".getBytes()); ``` ### 检查节点是否存在 ```java client.checkExists().forPath("path"); ``` 注意:该方法返回一个 Stat 实例,用于检查 ZNode 是否存在的操作. 可以调用额外的方法 (监控或者后台处理) 并在最后调用 forPath( ) 指定要操作的 ZNode ### 获取某个节点的所有子节点路径 ```java client.getChildren().forPath("path"); ``` 注意:该方法的返回值为 List,获得 ZNode 的子节点 Path 列表。 可以调用额外的方法 (监控、后台处理或者获取状态 watch, background or get stat) 并在最后调用 forPath() 指定要操作的父 ZNode ### 事务 CuratorFramework 的实例包含 inTransaction( ) 接口方法,调用此方法开启一个 ZooKeeper 事务. 可以复合 create, setData, check, and/or delete 等操作然后调用 commit() 作为一个原子操作提交。一个例子如下: ```java client.inTransaction().check().forPath("path") .and() .create().withMode(CreateMode.EPHEMERAL).forPath("path","data".getBytes()) .and() .setData().withVersion(10086).forPath("path","data2".getBytes()) .and() .commit(); ``` ### 异步接口 上面提到的创建、删除、更新、读取等方法都是同步的,Curator 提供异步接口,引入了**BackgroundCallback**接口用于处理异步接口调用之后服务端返回的结果信息。**BackgroundCallback**接口中一个重要的回调值为 CuratorEvent,里面包含事件类型、响应吗和节点的详细信息。 **CuratorEventType** | 事件类型 | 对应 CuratorFramework 实例的方法 | | :------: | :------------------------------: | | CREATE | #create() | | DELETE | #delete() | | EXISTS | #checkExists() | | GET_DATA | #getData() | | SET_DATA | #setData() | | CHILDREN | #getChildren() | | SYNC | #sync(String,Object) | | GET_ACL | #getACL() | | SET_ACL | #setACL() | | WATCHED | #Watcher(Watcher) | | CLOSING | #close() | **响应码 (#getResultCode())** | 响应码 | 意义 | | :----: | :--------------------------------------: | | 0 | OK,即调用成功 | | -4 | ConnectionLoss,即客户端与服务端断开连接 | | -110 | NodeExists,即节点已经存在 | | -112 | SessionExpired,即会话过期 | 一个异步创建节点的例子如下: ```java Executor executor = Executors.newFixedThreadPool(2); client.create() .creatingParentsIfNeeded() .withMode(CreateMode.EPHEMERAL) .inBackground((curatorFramework, curatorEvent) -> { System.out.println(String.format("eventType:%s,resultCode:%s",curatorEvent.getType(),curatorEvent.getResultCode())); },executor) .forPath("path"); ``` 注意:如果#inBackground() 方法不指定 executor,那么会默认使用 Curator 的 EventThread 去进行异步处理。 ## Curator 食谱 (高级特性) **提醒:首先你必须添加 curator-recipes 依赖,下文仅仅对 recipes 一些特性的使用进行解释和举例,不打算进行源码级别的探讨** ```xml org.apache.curator curator-recipes 2.12.0 ``` **重要提醒:强烈推荐使用 ConnectionStateListener 监控连接的状态,当连接状态为 LOST,curator-recipes 下的所有 Api 将会失效或者过期,尽管后面所有的例子都没有使用到 ConnectionStateListener。** ### 缓存 Zookeeper 原生支持通过注册 Watcher 来进行事件监听,但是开发者需要反复注册 (Watcher 只能单次注册单次使用)。Cache 是 Curator 中对事件监听的包装,可以看作是对事件监听的本地缓存视图,能够自动为开发者处理反复注册监听。Curator 提供了三种 Watcher(Cache) 来监听结点的变化。 #### Path Cache Path Cache 用来监控一个 ZNode 的子节点. 当一个子节点增加, 更新,删除时, Path Cache 会改变它的状态, 会包含最新的子节点, 子节点的数据和状态,而状态的更变将通过 PathChildrenCacheListener 通知。 实际使用时会涉及到四个类: - PathChildrenCache - PathChildrenCacheEvent - PathChildrenCacheListener - ChildData 通过下面的构造函数创建 Path Cache: ```java public PathChildrenCache(CuratorFramework client, String path, boolean cacheData) ``` 想使用 cache,必须调用它的 `start` 方法,使用完后调用 `close` 方法。 可以设置 StartMode 来实现启动的模式, StartMode 有下面几种: 1. NORMAL:正常初始化。 2. BUILD_INITIAL_CACHE:在调用 `start()` 之前会调用 `rebuild()`。 3. POST_INITIALIZED_EVENT: 当 Cache 初始化数据后发送一个 PathChildrenCacheEvent.Type#INITIALIZED 事件 `public void addListener(PathChildrenCacheListener listener)` 可以增加 listener 监听缓存的变化。 `getCurrentData()` 方法返回一个 `List` 对象,可以遍历所有的子节点。 **设置/更新、移除其实是使用 client (CuratorFramework) 来操作, 不通过 PathChildrenCache 操作:** ```java public class PathCacheDemo { private static final String PATH = "/example/pathCache"; public static void main(String[] args) throws Exception { TestingServer server = new TestingServer(); CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.start(); PathChildrenCache cache = new PathChildrenCache(client, PATH, true); cache.start(); PathChildrenCacheListener cacheListener = (client1, event) -> { System.out.println("事件类型:" + event.getType()); if (null != event.getData()) { System.out.println("节点数据:" + event.getData().getPath() + " = " + new String(event.getData().getData())); } }; cache.getListenable().addListener(cacheListener); client.create().creatingParentsIfNeeded().forPath("/example/pathCache/test01", "01".getBytes()); Thread.sleep(10); client.create().creatingParentsIfNeeded().forPath("/example/pathCache/test02", "02".getBytes()); Thread.sleep(10); client.setData().forPath("/example/pathCache/test01", "01_V2".getBytes()); Thread.sleep(10); for (ChildData data : cache.getCurrentData()) { System.out.println("getCurrentData:" + data.getPath() + " = " + new String(data.getData())); } client.delete().forPath("/example/pathCache/test01"); Thread.sleep(10); client.delete().forPath("/example/pathCache/test02"); Thread.sleep(1000 * 5); cache.close(); client.close(); System.out.println("OK!"); } } ``` **注意:**如果 new PathChildrenCache(client, PATH, true) 中的参数 cacheData 值设置为 false,则示例中的 event.getData().getData()、data.getData() 将返回 null,cache 将不会缓存节点数据。 **注意:**示例中的 Thread.sleep(10) 可以注释掉,但是注释后事件监听的触发次数会不全,这可能与 PathCache 的实现原理有关,不能太过频繁的触发事件! #### Node Cache Node Cache 与 Path Cache 类似,Node Cache 只是监听某一个特定的节点。它涉及到下面的三个类: - `NodeCache` - Node Cache 实现类 - `NodeCacheListener` - 节点监听器 - `ChildData` - 节点数据 **注意:**使用 cache,依然要调用它的 `start()` 方法,使用完后调用 `close()` 方法。 getCurrentData() 将得到节点当前的状态,通过它的状态可以得到当前的值。 ```java public class NodeCacheDemo { private static final String PATH = "/example/cache"; public static void main(String[] args) throws Exception { TestingServer server = new TestingServer(); CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.start(); client.create().creatingParentsIfNeeded().forPath(PATH); final NodeCache cache = new NodeCache(client, PATH); NodeCacheListener listener = () -> { ChildData data = cache.getCurrentData(); if (null != data) { System.out.println("节点数据:" + new String(cache.getCurrentData().getData())); } else { System.out.println("节点被删除!"); } }; cache.getListenable().addListener(listener); cache.start(); client.setData().forPath(PATH, "01".getBytes()); Thread.sleep(100); client.setData().forPath(PATH, "02".getBytes()); Thread.sleep(100); client.delete().deletingChildrenIfNeeded().forPath(PATH); Thread.sleep(1000 * 2); cache.close(); client.close(); System.out.println("OK!"); } } ``` **注意:**示例中的 Thread.sleep(10) 可以注释,但是注释后事件监听的触发次数会不全,这可能与 NodeCache 的实现原理有关,不能太过频繁的触发事件! **注意:**NodeCache 只能监听一个节点的状态变化。 #### Tree Cache Tree Cache 可以监控整个树上的所有节点,类似于 PathCache 和 NodeCache 的组合,主要涉及到下面四个类: - TreeCache - Tree Cache 实现类 - TreeCacheListener - 监听器类 - TreeCacheEvent - 触发的事件类 - ChildData - 节点数据 ```java public class TreeCacheDemo { private static final String PATH = "/example/cache"; public static void main(String[] args) throws Exception { TestingServer server = new TestingServer(); CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.start(); client.create().creatingParentsIfNeeded().forPath(PATH); TreeCache cache = new TreeCache(client, PATH); TreeCacheListener listener = (client1, event) -> System.out.println("事件类型:" + event.getType() + " | 路径:" + (null != event.getData() ? event.getData().getPath() : null)); cache.getListenable().addListener(listener); cache.start(); client.setData().forPath(PATH, "01".getBytes()); Thread.sleep(100); client.setData().forPath(PATH, "02".getBytes()); Thread.sleep(100); client.delete().deletingChildrenIfNeeded().forPath(PATH); Thread.sleep(1000 * 2); cache.close(); client.close(); System.out.println("OK!"); } } ``` **注意:**在此示例中没有使用 Thread.sleep(10),但是事件触发次数也是正常的。 **注意:**TreeCache 在初始化 (调用 `start()` 方法) 的时候会回调 `TreeCacheListener` 实例一个事 TreeCacheEvent,而回调的 TreeCacheEvent 对象的 Type 为 INITIALIZED,ChildData 为 null,此时 `event.getData().getPath()` 很有可能导致空指针异常,这里应该主动处理并避免这种情况。 ### Leader 选举 在分布式计算中, **leader elections**是很重要的一个功能, 这个选举过程是这样子的: 指派一个进程作为组织者,将任务分发给各节点。 在任务开始前, 哪个节点都不知道谁是 leader(领导者) 或者 coordinator(协调者). 当选举算法开始执行后, 每个节点最终会得到一个唯一的节点作为任务 leader. 除此之外, 选举还经常会发生在 leader 意外宕机的情况下,新的 leader 要被选举出来。 在 zookeeper 集群中,leader 负责写操作,然后通过 Zab 协议实现 follower 的同步,leader 或者 follower 都可以处理读操作。 Curator 有两种 leader 选举的 recipe,分别是**LeaderSelector**和**LeaderLatch**。 前者是所有存活的客户端不间断的轮流做 Leader,大同社会。后者是一旦选举出 Leader,除非有客户端挂掉重新触发选举,否则不会交出领导权。某党? #### LeaderLatch LeaderLatch 有两个构造函数: ```java public LeaderLatch(CuratorFramework client, String latchPath) public LeaderLatch(CuratorFramework client, String latchPath, String id) ``` LeaderLatch 的启动: **leaderLatch.start( );** 一旦启动,LeaderLatch 会和其它使用相同 latch path 的其它 LeaderLatch 交涉,然后其中一个最终会被选举为 leader,可以通过 `hasLeadership` 方法查看 LeaderLatch 实例是否 leader: **leaderLatch.hasLeadership( );** //返回 true 说明当前实例是 leader 类似 JDK 的 CountDownLatch, LeaderLatch 在请求成为 leadership 会 block(阻塞),一旦不使用 LeaderLatch 了,必须调用 `close` 方法。 如果它是 leader,会释放 leadership, 其它的参与者将会选举一个 leader。 ```java public void await() throws InterruptedException,EOFException /*Causes the current thread to wait until this instance acquires leadership unless the thread is interrupted or closed.*/ public boolean await(long timeout,TimeUnit unit)throws InterruptedException ``` **异常处理:** LeaderLatch 实例可以增加 ConnectionStateListener 来监听网络连接问题。 当 SUSPENDED 或 LOST 时, leader 不再认为自己还是 leader。当 LOST 后连接重连后 RECONNECTED,LeaderLatch 会删除先前的 ZNode 然后重新创建一个。LeaderLatch 用户必须考虑导致 leadership 丢失的连接问题。 强烈推荐你使用 ConnectionStateListener。 一个 LeaderLatch 的使用例子: ```java public class LeaderLatchDemo extends BaseConnectionInfo { protected static String PATH = "/francis/leader"; private static final int CLIENT_QTY = 10; public static void main(String[] args) throws Exception { List clients = Lists.newArrayList(); List examples = Lists.newArrayList(); TestingServer server=new TestingServer(); try { for (int i = 0; i < CLIENT_QTY; i++) { CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(20000, 3)); clients.add(client); LeaderLatch latch = new LeaderLatch(client, PATH, "Client #" + i); latch.addListener(new LeaderLatchListener() { @Override public void isLeader() { // TODO Auto-generated method stub System.out.println("I am Leader"); } @Override public void notLeader() { // TODO Auto-generated method stub System.out.println("I am not Leader"); } }); examples.add(latch); client.start(); latch.start(); } Thread.sleep(10000); LeaderLatch currentLeader = null; for (LeaderLatch latch : examples) { if (latch.hasLeadership()) { currentLeader = latch; } } System.out.println("current leader is " + currentLeader.getId()); System.out.println("release the leader " + currentLeader.getId()); currentLeader.close(); Thread.sleep(5000); for (LeaderLatch latch : examples) { if (latch.hasLeadership()) { currentLeader = latch; } } System.out.println("current leader is " + currentLeader.getId()); System.out.println("release the leader " + currentLeader.getId()); } finally { for (LeaderLatch latch : examples) { if (null != latch.getState()) CloseableUtils.closeQuietly(latch); } for (CuratorFramework client : clients) { CloseableUtils.closeQuietly(client); } } } } ``` 可以添加 test module 的依赖方便进行测试,不需要启动真实的 zookeeper 服务端: ```xml org.apache.curator curator-test 2.12.0 ``` 首先我们创建了 10 个 LeaderLatch,启动后它们中的一个会被选举为 leader。 因为选举会花费一些时间,start 后并不能马上就得到 leader。 通过 `hasLeadership` 查看自己是否是 leader, 如果是的话返回 true。 可以通过 `.getLeader().getId()` 可以得到当前的 leader 的 ID。 只能通过 `close` 释放当前的领导权。 `await` 是一个阻塞方法, 尝试获取 leader 地位,但是未必能上位。 #### LeaderSelector LeaderSelector 使用的时候主要涉及下面几个类: - LeaderSelector - LeaderSelectorListener - LeaderSelectorListenerAdapter - CancelLeadershipException 核心类是 LeaderSelector,它的构造函数如下: ```java public LeaderSelector(CuratorFramework client, String mutexPath,LeaderSelectorListener listener) public LeaderSelector(CuratorFramework client, String mutexPath, ThreadFactory threadFactory, Executor executor, LeaderSelectorListener listener) ``` 类似 LeaderLatch,LeaderSelector 必须 `start`: `leaderSelector.start();`  一旦启动,当实例取得领导权时你的 listener 的 `takeLeadership()` 方法被调用。而 takeLeadership() 方法只有领导权被释放时才返回。 当你不再使用 LeaderSelector 实例时,应该调用它的 close 方法。 **异常处理** LeaderSelectorListener 类继承 ConnectionStateListener。LeaderSelector 必须小心连接状态的改变。如果实例成为 leader, 它应该响应 SUSPENDED 或 LOST。 当 SUSPENDED 状态出现时, 实例必须假定在重新连接成功之前它可能不再是 leader 了。 如果 LOST 状态出现, 实例不再是 leader, takeLeadership 方法返回。 **重要**: 推荐处理方式是当收到 SUSPENDED 或 LOST 时抛出 CancelLeadershipException 异常.。这会导致 LeaderSelector 实例中断并取消执行 takeLeadership 方法的异常.。这非常重要, 你必须考虑扩展 LeaderSelectorListenerAdapter. LeaderSelectorListenerAdapter 提供了推荐的处理逻辑。 下面的一个例子摘抄自官方: ```java public class LeaderSelectorAdapter extends LeaderSelectorListenerAdapter implements Closeable { private final String name; private final LeaderSelector leaderSelector; private final AtomicInteger leaderCount = new AtomicInteger(); public LeaderSelectorAdapter(CuratorFramework client, String path, String name) { this.name = name; leaderSelector = new LeaderSelector(client, path, this); leaderSelector.autoRequeue(); } public void start() throws IOException { leaderSelector.start(); } @Override public void close() throws IOException { leaderSelector.close(); } @Override public void takeLeadership(CuratorFramework client) throws Exception { final int waitSeconds = (int) (5 * Math.random()) + 1; System.out.println(name + " is now the leader. Waiting " + waitSeconds + " seconds..."); System.out.println(name + " has been leader " + leaderCount.getAndIncrement() + " time(s) before."); try { Thread.sleep(TimeUnit.SECONDS.toMillis(waitSeconds)); } catch (InterruptedException e) { System.err.println(name + " was interrupted."); Thread.currentThread().interrupt(); } finally { System.out.println(name + " relinquishing leadership.\n"); } } } ``` 你可以在 takeLeadership 进行任务的分配等等,并且不要返回,如果你想要要此实例一直是 leader 的话可以加一个死循环。调用  `leaderSelector.autoRequeue();` 保证在此实例释放领导权之后还可能获得领导权。 在这里我们使用 AtomicInteger 来记录此 client 获得领导权的次数, 它是”fair”, 每个 client 有平等的机会获得领导权。 ```java public class LeaderSelectorDemo { protected static String PATH = "/francis/leader"; private static final int CLIENT_QTY = 10; public static void main(String[] args) throws Exception { List clients = Lists.newArrayList(); List examples = Lists.newArrayList(); TestingServer server = new TestingServer(); try { for (int i = 0; i < CLIENT_QTY; i++) { CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(20000, 3)); clients.add(client); LeaderSelectorAdapter selectorAdapter = new LeaderSelectorAdapter(client, PATH, "Client #" + i); examples.add(selectorAdapter); client.start(); selectorAdapter.start(); } System.out.println("Press enter/return to quit\n"); new BufferedReader(new InputStreamReader(System.in)).readLine(); } finally { System.out.println("Shutting down..."); for (LeaderSelectorAdapter exampleClient : examples) { CloseableUtils.closeQuietly(exampleClient); } for (CuratorFramework client : clients) { CloseableUtils.closeQuietly(client); } CloseableUtils.closeQuietly(server); } } } ``` 对比可知,LeaderLatch 必须调用 `close()` 方法才会释放领导权,而对于 LeaderSelector,通过 `LeaderSelectorListener` 可以对领导权进行控制, 在适当的时候释放领导权,这样每个节点都有可能获得领导权。从而,LeaderSelector 具有更好的灵活性和可控性,建议有 LeaderElection 应用场景下优先使用 LeaderSelector。 ### 分布式锁 **提醒:** 1.推荐使用 ConnectionStateListener 监控连接的状态,因为当连接 LOST 时你不再拥有锁 2.分布式的锁全局同步, 这意味着任何一个时间点不会有两个客户端都拥有相同的锁。 #### 可重入共享锁—Shared Reentrant Lock **Shared 意味着锁是全局可见的**, 客户端都可以请求锁。 Reentrant 和 JDK 的 ReentrantLock 类似,即可重入, 意味着同一个客户端在拥有锁的同时,可以多次获取,不会被阻塞。 它是由类 `InterProcessMutex` 来实现。 它的构造函数为: ```java public InterProcessMutex(CuratorFramework client, String path) ``` 通过 `acquire()` 获得锁,并提供超时机制: ```java public void acquire() Acquire the mutex - blocking until it's available. Note: the same thread can call acquire re-entrantly. Each call to acquire must be balanced by a call to release() public boolean acquire(long time,TimeUnit unit) Acquire the mutex - blocks until it's available or the given time expires. Note: the same thread can call acquire re-entrantly. Each call to acquire that returns true must be balanced by a call to release() Parameters: time - time to wait unit - time unit Returns: true if the mutex was acquired, false if not ``` 通过 `release()` 方法释放锁。 InterProcessMutex 实例可以重用。 **Revoking** ZooKeeper recipes wiki 定义了可协商的撤销机制。 为了撤销 mutex, 调用下面的方法: ```java public void makeRevocable(RevocationListener listener) 将锁设为可撤销的. 当别的进程或线程想让你释放锁时Listener会被调用。 Parameters: listener - the listener ``` 如果你请求撤销当前的锁, 调用 `attemptRevoke()` 方法,注意锁释放时 `RevocationListener` 将会回调。 ```java public static void attemptRevoke(CuratorFramework client,String path) throws Exception Utility to mark a lock for revocation. Assuming that the lock has been registered with a RevocationListener, it will get called and the lock should be released. Note, however, that revocation is cooperative. Parameters: client - the client path - the path of the lock - usually from something like InterProcessMutex.getParticipantNodes() ``` **二次提醒:错误处理**  还是强烈推荐你使用 `ConnectionStateListener` 处理连接状态的改变。 当连接 LOST 时你不再拥有锁。 首先让我们创建一个模拟的共享资源, 这个资源期望只能单线程的访问,否则会有并发问题。 ```java public class FakeLimitedResource { private final AtomicBoolean inUse = new AtomicBoolean(false); public void use() throws InterruptedException { // 真实环境中我们会在这里访问/维护一个共享的资源 //这个例子在使用锁的情况下不会非法并发异常IllegalStateException //但是在无锁的情况由于sleep了一段时间,很容易抛出异常 if (!inUse.compareAndSet(false, true)) { throw new IllegalStateException("Needs to be used by one client at a time"); } try { Thread.sleep((long) (3 * Math.random())); } finally { inUse.set(false); } } } ``` 然后创建一个 `InterProcessMutexDemo` 类, 它负责请求锁, 使用资源,释放锁这样一个完整的访问过程。 ```java public class InterProcessMutexDemo { private InterProcessMutex lock; private final FakeLimitedResource resource; private final String clientName; public InterProcessMutexDemo(CuratorFramework client, String lockPath, FakeLimitedResource resource, String clientName) { this.resource = resource; this.clientName = clientName; this.lock = new InterProcessMutex(client, lockPath); } public void doWork(long time, TimeUnit unit) throws Exception { if (!lock.acquire(time, unit)) { throw new IllegalStateException(clientName + " could not acquire the lock"); } try { System.out.println(clientName + " get the lock"); resource.use(); //access resource exclusively } finally { System.out.println(clientName + " releasing the lock"); lock.release(); // always release the lock in a finally block } } private static final int QTY = 5; private static final int REPETITIONS = QTY * 10; private static final String PATH = "/examples/locks"; public static void main(String[] args) throws Exception { final FakeLimitedResource resource = new FakeLimitedResource(); ExecutorService service = Executors.newFixedThreadPool(QTY); final TestingServer server = new TestingServer(); try { for (int i = 0; i < QTY; ++i) { final int index = i; Callable task = new Callable() { @Override public Void call() throws Exception { CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); try { client.start(); final InterProcessMutexDemo example = new InterProcessMutexDemo(client, PATH, resource, "Client " + index); for (int j = 0; j < REPETITIONS; ++j) { example.doWork(10, TimeUnit.SECONDS); } } catch (Throwable e) { e.printStackTrace(); } finally { CloseableUtils.closeQuietly(client); } return null; } }; service.submit(task); } service.shutdown(); service.awaitTermination(10, TimeUnit.MINUTES); } finally { CloseableUtils.closeQuietly(server); } } } ``` 代码也很简单,生成 10 个 client, 每个 client 重复执行 10 次 请求锁–访问资源–释放锁的过程。每个 client 都在独立的线程中。 结果可以看到,锁是随机的被每个实例排他性的使用。 既然是可重用的,你可以在一个线程中多次调用 `acquire()`,在线程拥有锁时它总是返回 true。 **你不应该在多个线程中用同一个 `InterProcessMutex`**, 你可以在每个线程中都生成一个新的 InterProcessMutex 实例,它们的 path 都一样,这样它们可以共享同一个锁。 #### 不可重入共享锁—Shared Lock 这个锁和上面的 `InterProcessMutex` 相比,就是少了 Reentrant 的功能,也就意味着它不能在同一个线程中重入。这个类是 `InterProcessSemaphoreMutex`,使用方法和 `InterProcessMutex` 类似 ```java public class InterProcessSemaphoreMutexDemo { private InterProcessSemaphoreMutex lock; private final FakeLimitedResource resource; private final String clientName; public InterProcessSemaphoreMutexDemo(CuratorFramework client, String lockPath, FakeLimitedResource resource, String clientName) { this.resource = resource; this.clientName = clientName; this.lock = new InterProcessSemaphoreMutex(client, lockPath); } public void doWork(long time, TimeUnit unit) throws Exception { if (!lock.acquire(time, unit)) { throw new IllegalStateException(clientName + " 不能得到互斥锁"); } System.out.println(clientName + " 已获取到互斥锁"); if (!lock.acquire(time, unit)) { throw new IllegalStateException(clientName + " 不能得到互斥锁"); } System.out.println(clientName + " 再次获取到互斥锁"); try { System.out.println(clientName + " get the lock"); resource.use(); //access resource exclusively } finally { System.out.println(clientName + " releasing the lock"); lock.release(); // always release the lock in a finally block lock.release(); // 获取锁几次 释放锁也要几次 } } private static final int QTY = 5; private static final int REPETITIONS = QTY * 10; private static final String PATH = "/examples/locks"; public static void main(String[] args) throws Exception { final FakeLimitedResource resource = new FakeLimitedResource(); ExecutorService service = Executors.newFixedThreadPool(QTY); final TestingServer server = new TestingServer(); try { for (int i = 0; i < QTY; ++i) { final int index = i; Callable task = new Callable() { @Override public Void call() throws Exception { CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); try { client.start(); final InterProcessSemaphoreMutexDemo example = new InterProcessSemaphoreMutexDemo(client, PATH, resource, "Client " + index); for (int j = 0; j < REPETITIONS; ++j) { example.doWork(10, TimeUnit.SECONDS); } } catch (Throwable e) { e.printStackTrace(); } finally { CloseableUtils.closeQuietly(client); } return null; } }; service.submit(task); } service.shutdown(); service.awaitTermination(10, TimeUnit.MINUTES); } finally { CloseableUtils.closeQuietly(server); } Thread.sleep(Integer.MAX_VALUE); } } ``` 运行后发现,有且只有一个 client 成功获取第一个锁 (第一个 `acquire()` 方法返回 true),然后它自己阻塞在第二个 `acquire()` 方法,获取第二个锁超时;其他所有的客户端都阻塞在第一个 `acquire()` 方法超时并且抛出异常。 这样也就验证了 `InterProcessSemaphoreMutex` 实现的锁是不可重入的。 #### 可重入读写锁—Shared Reentrant Read Write Lock 类似 JDK 的**ReentrantReadWriteLock**。一个读写锁管理一对相关的锁。一个负责读操作,另外一个负责写操作。读操作在写锁没被使用时可同时由多个进程使用,而写锁在使用时不允许读 (阻塞)。 此锁是可重入的。**一个拥有写锁的线程可重入读锁,但是读锁却不能进入写锁**。这也意味着**写锁可以降级成读锁, 比如请求写锁 --->请求读锁 --->释放读锁 ---->释放写锁**。从读锁升级成写锁是不行的。 可重入读写锁主要由两个类实现:`InterProcessReadWriteLock`、`InterProcessMutex`。使用时首先创建一个 `InterProcessReadWriteLock` 实例,然后再根据你的需求得到读锁或者写锁,读写锁的类型是 `InterProcessMutex`。 ```java public class ReentrantReadWriteLockDemo { private final InterProcessReadWriteLock lock; private final InterProcessMutex readLock; private final InterProcessMutex writeLock; private final FakeLimitedResource resource; private final String clientName; public ReentrantReadWriteLockDemo(CuratorFramework client, String lockPath, FakeLimitedResource resource, String clientName) { this.resource = resource; this.clientName = clientName; lock = new InterProcessReadWriteLock(client, lockPath); readLock = lock.readLock(); writeLock = lock.writeLock(); } public void doWork(long time, TimeUnit unit) throws Exception { // 注意只能先得到写锁再得到读锁,不能反过来!!! if (!writeLock.acquire(time, unit)) { throw new IllegalStateException(clientName + " 不能得到写锁"); } System.out.println(clientName + " 已得到写锁"); if (!readLock.acquire(time, unit)) { throw new IllegalStateException(clientName + " 不能得到读锁"); } System.out.println(clientName + " 已得到读锁"); try { resource.use(); // 使用资源 Thread.sleep(1000); } finally { System.out.println(clientName + " 释放读写锁"); readLock.release(); writeLock.release(); } } private static final int QTY = 5; private static final int REPETITIONS = QTY ; private static final String PATH = "/examples/locks"; public static void main(String[] args) throws Exception { final FakeLimitedResource resource = new FakeLimitedResource(); ExecutorService service = Executors.newFixedThreadPool(QTY); final TestingServer server = new TestingServer(); try { for (int i = 0; i < QTY; ++i) { final int index = i; Callable task = new Callable() { @Override public Void call() throws Exception { CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); try { client.start(); final ReentrantReadWriteLockDemo example = new ReentrantReadWriteLockDemo(client, PATH, resource, "Client " + index); for (int j = 0; j < REPETITIONS; ++j) { example.doWork(10, TimeUnit.SECONDS); } } catch (Throwable e) { e.printStackTrace(); } finally { CloseableUtils.closeQuietly(client); } return null; } }; service.submit(task); } service.shutdown(); service.awaitTermination(10, TimeUnit.MINUTES); } finally { CloseableUtils.closeQuietly(server); } } } ``` #### 信号量—Shared Semaphore 一个计数的信号量类似 JDK 的 Semaphore。 JDK 中 Semaphore 维护的一组许可 (**permits**),而 Curator 中称之为租约 (**Lease**)。 有两种方式可以决定 semaphore 的最大租约数。第一种方式是用户给定 path 并且指定最大 LeaseSize。第二种方式用户给定 path 并且使用 `SharedCountReader` 类。**如果不使用 SharedCountReader, 必须保证所有实例在多进程中使用相同的 (最大) 租约数量,否则有可能出现 A 进程中的实例持有最大租约数量为 10,但是在 B 进程中持有的最大租约数量为 20,此时租约的意义就失效了。** 这次调用 `acquire()` 会返回一个租约对象。 客户端必须在 finally 中 close 这些租约对象,否则这些租约会丢失掉。 但是, 但是,如果客户端 session 由于某种原因比如 crash 丢掉, 那么这些客户端持有的租约会自动 close, 这样其它客户端可以继续使用这些租约。 租约还可以通过下面的方式返还: ```java public void returnAll(Collection leases) public void returnLease(Lease lease) ``` 注意你可以一次性请求多个租约,如果 Semaphore 当前的租约不够,则请求线程会被阻塞。 同时还提供了超时的重载方法。 ```java public Lease acquire() public Collection acquire(int qty) public Lease acquire(long time, TimeUnit unit) public Collection acquire(int qty, long time, TimeUnit unit) ``` Shared Semaphore 使用的主要类包括下面几个: - `InterProcessSemaphoreV2` - `Lease` - `SharedCountReader` ```java public class InterProcessSemaphoreDemo { private static final int MAX_LEASE = 10; private static final String PATH = "/examples/locks"; public static void main(String[] args) throws Exception { FakeLimitedResource resource = new FakeLimitedResource(); try (TestingServer server = new TestingServer()) { CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.start(); InterProcessSemaphoreV2 semaphore = new InterProcessSemaphoreV2(client, PATH, MAX_LEASE); Collection leases = semaphore.acquire(5); System.out.println("get " + leases.size() + " leases"); Lease lease = semaphore.acquire(); System.out.println("get another lease"); resource.use(); Collection leases2 = semaphore.acquire(5, 10, TimeUnit.SECONDS); System.out.println("Should timeout and acquire return " + leases2); System.out.println("return one lease"); semaphore.returnLease(lease); System.out.println("return another 5 leases"); semaphore.returnAll(leases); } } } ``` 首先我们先获得了 5 个租约, 最后我们把它还给了 semaphore。 接着请求了一个租约,因为 semaphore 还有 5 个租约,所以请求可以满足,返回一个租约,还剩 4 个租约。 然后再请求一个租约,因为租约不够,**阻塞到超时,还是没能满足,返回结果为 null(租约不足会阻塞到超时,然后返回 null,不会主动抛出异常;如果不设置超时时间,会一致阻塞)。** 上面说讲的锁都是公平锁 (fair)。 总 ZooKeeper 的角度看, 每个客户端都按照请求的顺序获得锁,不存在非公平的抢占的情况。 #### 多共享锁对象 —Multi Shared Lock Multi Shared Lock 是一个锁的容器。 当调用 `acquire()`, 所有的锁都会被 `acquire()`,如果请求失败,所有的锁都会被 release。 同样调用 release 时所有的锁都被 release(**失败被忽略**)。 基本上,它就是组锁的代表,在它上面的请求释放操作都会传递给它包含的所有的锁。 主要涉及两个类: - `InterProcessMultiLock` - `InterProcessLock` 它的构造函数需要包含的锁的集合,或者一组 ZooKeeper 的 path。 ```java public InterProcessMultiLock(List locks) public InterProcessMultiLock(CuratorFramework client, List paths) ``` 用法和 Shared Lock 相同。 ```java public class MultiSharedLockDemo { private static final String PATH1 = "/examples/locks1"; private static final String PATH2 = "/examples/locks2"; public static void main(String[] args) throws Exception { FakeLimitedResource resource = new FakeLimitedResource(); try (TestingServer server = new TestingServer()) { CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.start(); InterProcessLock lock1 = new InterProcessMutex(client, PATH1); InterProcessLock lock2 = new InterProcessSemaphoreMutex(client, PATH2); InterProcessMultiLock lock = new InterProcessMultiLock(Arrays.asList(lock1, lock2)); if (!lock.acquire(10, TimeUnit.SECONDS)) { throw new IllegalStateException("could not acquire the lock"); } System.out.println("has got all lock"); System.out.println("has got lock1: " + lock1.isAcquiredInThisProcess()); System.out.println("has got lock2: " + lock2.isAcquiredInThisProcess()); try { resource.use(); //access resource exclusively } finally { System.out.println("releasing the lock"); lock.release(); // always release the lock in a finally block } System.out.println("has got lock1: " + lock1.isAcquiredInThisProcess()); System.out.println("has got lock2: " + lock2.isAcquiredInThisProcess()); } } } ``` 新建一个 `InterProcessMultiLock`, 包含一个重入锁和一个非重入锁。 调用 `acquire()` 后可以看到线程同时拥有了这两个锁。 调用 `release()` 看到这两个锁都被释放了。 **最后再重申一次, 强烈推荐使用 ConnectionStateListener 监控连接的状态,当连接状态为 LOST,锁将会丢失。** ### 分布式计数器 顾名思义,计数器是用来计数的, 利用 ZooKeeper 可以实现一个集群共享的计数器。 只要使用相同的 path 就可以得到最新的计数器值, 这是由 ZooKeeper 的一致性保证的。Curator 有两个计数器, 一个是用 int 来计数 (`SharedCount`),一个用 long 来计数 (`DistributedAtomicLong`)。 #### 分布式 int 计数器—SharedCount 这个类使用 int 类型来计数。 主要涉及三个类。 - SharedCount - SharedCountReader - SharedCountListener `SharedCount` 代表计数器, 可以为它增加一个 `SharedCountListener`,当计数器改变时此 Listener 可以监听到改变的事件,而 `SharedCountReader` 可以读取到最新的值, 包括字面值和带版本信息的值 VersionedValue。 ```java public class SharedCounterDemo implements SharedCountListener { private static final int QTY = 5; private static final String PATH = "/examples/counter"; public static void main(String[] args) throws IOException, Exception { final Random rand = new Random(); SharedCounterDemo example = new SharedCounterDemo(); try (TestingServer server = new TestingServer()) { CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.start(); SharedCount baseCount = new SharedCount(client, PATH, 0); baseCount.addListener(example); baseCount.start(); List examples = Lists.newArrayList(); ExecutorService service = Executors.newFixedThreadPool(QTY); for (int i = 0; i < QTY; ++i) { final SharedCount count = new SharedCount(client, PATH, 0); examples.add(count); Callable task = () -> { count.start(); Thread.sleep(rand.nextInt(10000)); System.out.println("Increment:" + count.trySetCount(count.getVersionedValue(), count.getCount() + rand.nextInt(10))); return null; }; service.submit(task); } service.shutdown(); service.awaitTermination(10, TimeUnit.MINUTES); for (int i = 0; i < QTY; ++i) { examples.get(i).close(); } baseCount.close(); } Thread.sleep(Integer.MAX_VALUE); } @Override public void stateChanged(CuratorFramework arg0, ConnectionState arg1) { System.out.println("State changed: " + arg1.toString()); } @Override public void countHasChanged(SharedCountReader sharedCount, int newCount) throws Exception { System.out.println("Counter's value is changed to " + newCount); } } ``` 在这个例子中,我们使用 `baseCount` 来监听计数值 (`addListener` 方法来添加 SharedCountListener )。 任意的 SharedCount, 只要使用相同的 path,都可以得到这个计数值。 然后我们使用 5 个线程为计数值增加一个 10 以内的随机数。相同的 path 的 SharedCount 对计数值进行更改,将会回调给 `baseCount` 的 SharedCountListener。 ```java count.trySetCount(count.getVersionedValue(), count.getCount() + rand.nextInt(10)) ``` 这里我们使用 `trySetCount` 去设置计数器。 **第一个参数提供当前的 VersionedValue,如果期间其它 client 更新了此计数值, 你的更新可能不成功, 但是这时你的 client 更新了最新的值,所以失败了你可以尝试再更新一次。 而 `setCount` 是强制更新计数器的值**。 注意计数器必须 `start`,使用完之后必须调用 `close` 关闭它。 强烈推荐使用 `ConnectionStateListener`。 在本例中 `SharedCountListener` 扩展 `ConnectionStateListener`。 #### 分布式 long 计数器—DistributedAtomicLong 再看一个 Long 类型的计数器。 除了计数的范围比 `SharedCount` 大了之外, 它首先尝试使用乐观锁的方式设置计数器, 如果不成功 (比如期间计数器已经被其它 client 更新了), 它使用 `InterProcessMutex` 方式来更新计数值。 可以从它的内部实现 `DistributedAtomicValue.trySet()` 中看出: ```java AtomicValue trySet(MakeValue makeValue) throws Exception { MutableAtomicValue result = new MutableAtomicValue(null, null, false); tryOptimistic(result, makeValue); if ( !result.succeeded() && (mutex != null) ) { tryWithMutex(result, makeValue); } return result; } ``` 此计数器有一系列的操作: - get(): 获取当前值 - increment(): 加一 - decrement(): 减一 - add(): 增加特定的值 - subtract(): 减去特定的值 - trySet(): 尝试设置计数值 - forceSet(): 强制设置计数值 你**必须**检查返回结果的 `succeeded()`, 它代表此操作是否成功。 如果操作成功, `preValue()` 代表操作前的值, `postValue()` 代表操作后的值。 ```java public class DistributedAtomicLongDemo { private static final int QTY = 5; private static final String PATH = "/examples/counter"; public static void main(String[] args) throws IOException, Exception { List examples = Lists.newArrayList(); try (TestingServer server = new TestingServer()) { CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.start(); ExecutorService service = Executors.newFixedThreadPool(QTY); for (int i = 0; i < QTY; ++i) { final DistributedAtomicLong count = new DistributedAtomicLong(client, PATH, new RetryNTimes(10, 10)); examples.add(count); Callable task = () -> { try { AtomicValue value = count.increment(); System.out.println("succeed: " + value.succeeded()); if (value.succeeded()) System.out.println("Increment: from " + value.preValue() + " to " + value.postValue()); } catch (Exception e) { e.printStackTrace(); } return null; }; service.submit(task); } service.shutdown(); service.awaitTermination(10, TimeUnit.MINUTES); Thread.sleep(Integer.MAX_VALUE); } } } ``` ### 分布式队列 使用 Curator 也可以简化 Ephemeral Node (**临时节点**) 的操作。Curator 也提供 ZK Recipe 的分布式队列实现。 利用 ZK 的 PERSISTENTS_EQUENTIAL 节点, 可以保证放入到队列中的项目是按照顺序排队的。 如果单一的消费者从队列中取数据, 那么它是先入先出的,这也是队列的特点。 如果你严格要求顺序,你就的使用单一的消费者,可以使用 Leader 选举只让 Leader 作为唯一的消费者。 但是, 根据 Netflix 的 Curator 作者所说, ZooKeeper 真心不适合做 Queue,或者说 ZK 没有实现一个好的 Queue,详细内容可以看  [Tech Note 4](https://cwiki.apache.org/confluence/display/CURATOR/TN4), 原因有五: 1. ZK 有 1MB 的传输限制。 实践中 ZNode 必须相对较小,而队列包含成千上万的消息,非常的大。 2. 如果有很多节点,ZK 启动时相当的慢。 而使用 queue 会导致好多 ZNode. 你需要显著增大 initLimit 和 syncLimit. 3. ZNode 很大的时候很难清理。Netflix 不得不创建了一个专门的程序做这事。 4. 当很大量的包含成千上万的子节点的 ZNode 时, ZK 的性能变得不好 5. ZK 的数据库完全放在内存中。 大量的 Queue 意味着会占用很多的内存空间。 尽管如此, Curator 还是创建了各种 Queue 的实现。 如果 Queue 的数据量不太多,数据量不太大的情况下,酌情考虑,还是可以使用的。 #### 分布式队列—DistributedQueue DistributedQueue 是最普通的一种队列。 它设计以下四个类: - QueueBuilder - 创建队列使用 QueueBuilder,它也是其它队列的创建类 - QueueConsumer - 队列中的消息消费者接口 - QueueSerializer - 队列消息序列化和反序列化接口,提供了对队列中的对象的序列化和反序列化 - DistributedQueue - 队列实现类 QueueConsumer 是消费者,它可以接收队列的数据。处理队列中的数据的代码逻辑可以放在 QueueConsumer.consumeMessage() 中。 正常情况下先将消息从队列中移除,再交给消费者消费。但这是两个步骤,不是原子的。可以调用 Builder 的 lockPath() 消费者加锁,当消费者消费数据时持有锁,这样其它消费者不能消费此消息。如果消费失败或者进程死掉,消息可以交给其它进程。这会带来一点性能的损失。最好还是单消费者模式使用队列。 ```java public class DistributedQueueDemo { private static final String PATH = "/example/queue"; public static void main(String[] args) throws Exception { TestingServer server = new TestingServer(); CuratorFramework clientA = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); clientA.start(); CuratorFramework clientB = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); clientB.start(); DistributedQueue queueA; QueueBuilder builderA = QueueBuilder.builder(clientA, createQueueConsumer("A"), createQueueSerializer(), PATH); queueA = builderA.buildQueue(); queueA.start(); DistributedQueue queueB; QueueBuilder builderB = QueueBuilder.builder(clientB, createQueueConsumer("B"), createQueueSerializer(), PATH); queueB = builderB.buildQueue(); queueB.start(); for (int i = 0; i < 100; i++) { queueA.put(" test-A-" + i); Thread.sleep(10); queueB.put(" test-B-" + i); } Thread.sleep(1000 * 10);// 等待消息消费完成 queueB.close(); queueA.close(); clientB.close(); clientA.close(); System.out.println("OK!"); } /** * 队列消息序列化实现类 */ private static QueueSerializer createQueueSerializer() { return new QueueSerializer() { @Override public byte[] serialize(String item) { return item.getBytes(); } @Override public String deserialize(byte[] bytes) { return new String(bytes); } }; } /** * 定义队列消费者 */ private static QueueConsumer createQueueConsumer(final String name) { return new QueueConsumer() { @Override public void stateChanged(CuratorFramework client, ConnectionState newState) { System.out.println("连接状态改变: " + newState.name()); } @Override public void consumeMessage(String message) throws Exception { System.out.println("消费消息(" + name + "): " + message); } }; } } ``` 例子中定义了两个分布式队列和两个消费者,因为 PATH 是相同的,会存在消费者抢占消费消息的情况。 #### 带 Id 的分布式队列—DistributedIdQueue DistributedIdQueue 和上面的队列类似,**但是可以为队列中的每一个元素设置一个 ID**。 可以通过 ID 把队列中任意的元素移除。 它涉及几个类: - QueueBuilder - QueueConsumer - QueueSerializer - DistributedQueue 通过下面方法创建: ```java builder.buildIdQueue() ``` 放入元素时: ```java queue.put(aMessage, messageId); ``` 移除元素时: ```java int numberRemoved = queue.remove(messageId); ``` 在这个例子中, 有些元素还没有被消费者消费前就移除了,这样消费者不会收到删除的消息。 ```java public class DistributedIdQueueDemo { private static final String PATH = "/example/queue"; public static void main(String[] args) throws Exception { TestingServer server = new TestingServer(); CuratorFramework client = null; DistributedIdQueue queue = null; try { client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.getCuratorListenable().addListener((client1, event) -> System.out.println("CuratorEvent: " + event.getType().name())); client.start(); QueueConsumer consumer = createQueueConsumer(); QueueBuilder builder = QueueBuilder.builder(client, consumer, createQueueSerializer(), PATH); queue = builder.buildIdQueue(); queue.start(); for (int i = 0; i < 10; i++) { queue.put(" test-" + i, "Id" + i); Thread.sleep((long) (15 * Math.random())); queue.remove("Id" + i); } Thread.sleep(20000); } catch (Exception ex) { } finally { CloseableUtils.closeQuietly(queue); CloseableUtils.closeQuietly(client); CloseableUtils.closeQuietly(server); } } private static QueueSerializer createQueueSerializer() { return new QueueSerializer() { @Override public byte[] serialize(String item) { return item.getBytes(); } @Override public String deserialize(byte[] bytes) { return new String(bytes); } }; } private static QueueConsumer createQueueConsumer() { return new QueueConsumer() { @Override public void stateChanged(CuratorFramework client, ConnectionState newState) { System.out.println("connection new state: " + newState.name()); } @Override public void consumeMessage(String message) throws Exception { System.out.println("consume one message: " + message); } }; } } ``` #### 优先级分布式队列—DistributedPriorityQueue 优先级队列对队列中的元素按照优先级进行排序。 **Priority 越小, 元素越靠前, 越先被消费掉**。 它涉及下面几个类: - QueueBuilder - QueueConsumer - QueueSerializer - DistributedPriorityQueue 通过 builder.buildPriorityQueue(minItemsBeforeRefresh) 方法创建。 当优先级队列得到元素增删消息时,它会暂停处理当前的元素队列,然后刷新队列。minItemsBeforeRefresh 指定刷新前当前活动的队列的最小数量。 主要设置你的程序可以容忍的不排序的最小值。 放入队列时需要指定优先级: ```java queue.put(aMessage, priority); ``` 例子: ```java public class DistributedPriorityQueueDemo { private static final String PATH = "/example/queue"; public static void main(String[] args) throws Exception { TestingServer server = new TestingServer(); CuratorFramework client = null; DistributedPriorityQueue queue = null; try { client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.getCuratorListenable().addListener((client1, event) -> System.out.println("CuratorEvent: " + event.getType().name())); client.start(); QueueConsumer consumer = createQueueConsumer(); QueueBuilder builder = QueueBuilder.builder(client, consumer, createQueueSerializer(), PATH); queue = builder.buildPriorityQueue(0); queue.start(); for (int i = 0; i < 10; i++) { int priority = (int) (Math.random() * 100); System.out.println("test-" + i + " priority:" + priority); queue.put("test-" + i, priority); Thread.sleep((long) (50 * Math.random())); } Thread.sleep(20000); } catch (Exception ex) { } finally { CloseableUtils.closeQuietly(queue); CloseableUtils.closeQuietly(client); CloseableUtils.closeQuietly(server); } } private static QueueSerializer createQueueSerializer() { return new QueueSerializer() { @Override public byte[] serialize(String item) { return item.getBytes(); } @Override public String deserialize(byte[] bytes) { return new String(bytes); } }; } private static QueueConsumer createQueueConsumer() { return new QueueConsumer() { @Override public void stateChanged(CuratorFramework client, ConnectionState newState) { System.out.println("connection new state: " + newState.name()); } @Override public void consumeMessage(String message) throws Exception { Thread.sleep(1000); System.out.println("consume one message: " + message); } }; } } ``` 有时候你可能会有错觉,优先级设置并没有起效。那是因为优先级是对于队列积压的元素而言,如果消费速度过快有可能出现在后一个元素入队操作之前前一个元素已经被消费,这种情况下 DistributedPriorityQueue 会退化为 DistributedQueue。 #### 分布式延迟队列—DistributedDelayQueue JDK 中也有 DelayQueue,不知道你是否熟悉。 DistributedDelayQueue 也提供了类似的功能, 元素有个 delay 值, 消费者隔一段时间才能收到元素。 涉及到下面四个类。 - QueueBuilder - QueueConsumer - QueueSerializer - DistributedDelayQueue 通过下面的语句创建: ```java QueueBuilder builder = QueueBuilder.builder(client, consumer, serializer, path); ... more builder method calls as needed ... DistributedDelayQueue queue = builder.buildDelayQueue(); ``` 放入元素时可以指定 `delayUntilEpoch`: ```java queue.put(aMessage, delayUntilEpoch); ``` 注意 `delayUntilEpoch` 不是离现在的一个时间间隔, 比如 20 毫秒,而是未来的一个时间戳,如 System.currentTimeMillis() + 10 秒。 如果 delayUntilEpoch 的时间已经过去,消息会立刻被消费者接收。 ```java public class DistributedDelayQueueDemo { private static final String PATH = "/example/queue"; public static void main(String[] args) throws Exception { TestingServer server = new TestingServer(); CuratorFramework client = null; DistributedDelayQueue queue = null; try { client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.getCuratorListenable().addListener((client1, event) -> System.out.println("CuratorEvent: " + event.getType().name())); client.start(); QueueConsumer consumer = createQueueConsumer(); QueueBuilder builder = QueueBuilder.builder(client, consumer, createQueueSerializer(), PATH); queue = builder.buildDelayQueue(); queue.start(); for (int i = 0; i < 10; i++) { queue.put("test-" + i, System.currentTimeMillis() + 10000); } System.out.println(new Date().getTime() + ": already put all items"); Thread.sleep(20000); } catch (Exception ex) { } finally { CloseableUtils.closeQuietly(queue); CloseableUtils.closeQuietly(client); CloseableUtils.closeQuietly(server); } } private static QueueSerializer createQueueSerializer() { return new QueueSerializer() { @Override public byte[] serialize(String item) { return item.getBytes(); } @Override public String deserialize(byte[] bytes) { return new String(bytes); } }; } private static QueueConsumer createQueueConsumer() { return new QueueConsumer() { @Override public void stateChanged(CuratorFramework client, ConnectionState newState) { System.out.println("connection new state: " + newState.name()); } @Override public void consumeMessage(String message) throws Exception { System.out.println(new Date().getTime() + ": consume one message: " + message); } }; } } ``` #### SimpleDistributedQueue 前面虽然实现了各种队列,但是你注意到没有,这些队列并没有实现类似 JDK 一样的接口。 `SimpleDistributedQueue` 提供了和 JDK 基本一致的接口 (但是没有实现 Queue 接口)。 创建很简单: ```java public SimpleDistributedQueue(CuratorFramework client,String path) ``` 增加元素: ```java public boolean offer(byte[] data) throws Exception ``` 删除元素: ```java public byte[] take() throws Exception ``` 另外还提供了其它方法: ```java public byte[] peek() throws Exception public byte[] poll(long timeout, TimeUnit unit) throws Exception public byte[] poll() throws Exception public byte[] remove() throws Exception public byte[] element() throws Exception ``` 没有 `add` 方法, 多了 `take` 方法。 `take` 方法在成功返回之前会被阻塞。 而 `poll` 方法在队列为空时直接返回 null。 ```java public class SimpleDistributedQueueDemo { private static final String PATH = "/example/queue"; public static void main(String[] args) throws Exception { TestingServer server = new TestingServer(); CuratorFramework client = null; SimpleDistributedQueue queue; try { client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.getCuratorListenable().addListener((client1, event) -> System.out.println("CuratorEvent: " + event.getType().name())); client.start(); queue = new SimpleDistributedQueue(client, PATH); Producer producer = new Producer(queue); Consumer consumer = new Consumer(queue); new Thread(producer, "producer").start(); new Thread(consumer, "consumer").start(); Thread.sleep(20000); } catch (Exception ex) { } finally { CloseableUtils.closeQuietly(client); CloseableUtils.closeQuietly(server); } } public static class Producer implements Runnable { private SimpleDistributedQueue queue; public Producer(SimpleDistributedQueue queue) { this.queue = queue; } @Override public void run() { for (int i = 0; i < 100; i++) { try { boolean flag = queue.offer(("zjc-" + i).getBytes()); if (flag) { System.out.println("发送一条消息成功:" + "zjc-" + i); } else { System.out.println("发送一条消息失败:" + "zjc-" + i); } } catch (Exception e) { e.printStackTrace(); } } } } public static class Consumer implements Runnable { private SimpleDistributedQueue queue; public Consumer(SimpleDistributedQueue queue) { this.queue = queue; } @Override public void run() { try { byte[] datas = queue.take(); System.out.println("消费一条消息成功:" + new String(datas, "UTF-8")); } catch (Exception e) { e.printStackTrace(); } } } } ``` 但是实际上发送了 100 条消息,消费完第一条之后,后面的消息无法消费,目前没找到原因。查看一下官方文档推荐的 demo 使用下面几个 Api: ```java Creating a SimpleDistributedQueue public SimpleDistributedQueue(CuratorFramework client, String path) Parameters: client - the client path - path to store queue nodes Add to the queue public boolean offer(byte[] data) throws Exception Inserts data into queue. Parameters: data - the data Returns: true if data was successfully added Take from the queue public byte[] take() throws Exception Removes the head of the queue and returns it, blocks until it succeeds. Returns: The former head of the queue NOTE: see the Javadoc for additional methods ``` 但是实际使用发现还是存在消费阻塞问题。 ### 分布式屏障—Barrier 分布式 Barrier 是这样一个类: 它会阻塞所有节点上的等待进程,直到某一个被满足, 然后所有的节点继续进行。 比如赛马比赛中, 等赛马陆续来到起跑线前。 一声令下,所有的赛马都飞奔而出。 #### DistributedBarrier `DistributedBarrier` 类实现了栅栏的功能。 它的构造函数如下: ```java public DistributedBarrier(CuratorFramework client, String barrierPath) Parameters: client - client barrierPath - path to use as the barrier ``` 首先你需要设置栅栏,它将阻塞在它上面等待的线程: ```java setBarrier(); ``` 然后需要阻塞的线程调用方法等待放行条件: ```java public void waitOnBarrier() ``` 当条件满足时,移除栅栏,所有等待的线程将继续执行: ```java removeBarrier(); ``` **异常处理** DistributedBarrier 会监控连接状态,当连接断掉时 `waitOnBarrier()` 方法会抛出异常。 ```java public class DistributedBarrierDemo { private static final int QTY = 5; private static final String PATH = "/examples/barrier"; public static void main(String[] args) throws Exception { try (TestingServer server = new TestingServer()) { CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.start(); ExecutorService service = Executors.newFixedThreadPool(QTY); DistributedBarrier controlBarrier = new DistributedBarrier(client, PATH); controlBarrier.setBarrier(); for (int i = 0; i < QTY; ++i) { final DistributedBarrier barrier = new DistributedBarrier(client, PATH); final int index = i; Callable task = () -> { Thread.sleep((long) (3 * Math.random())); System.out.println("Client #" + index + " waits on Barrier"); barrier.waitOnBarrier(); System.out.println("Client #" + index + " begins"); return null; }; service.submit(task); } Thread.sleep(10000); System.out.println("all Barrier instances should wait the condition"); controlBarrier.removeBarrier(); service.shutdown(); service.awaitTermination(10, TimeUnit.MINUTES); Thread.sleep(20000); } } } ``` 这个例子创建了 `controlBarrier` 来设置栅栏和移除栅栏。 我们创建了 5 个线程,在此 Barrier 上等待。 最后移除栅栏后所有的线程才继续执行。 如果你开始不设置栅栏,所有的线程就不会阻塞住。 #### 双栅栏—DistributedDoubleBarrier 双栅栏允许客户端在计算的开始和结束时同步。当足够的进程加入到双栅栏时,进程开始计算, 当计算完成时,离开栅栏。 双栅栏类是 `DistributedDoubleBarrier`。 构造函数为: ```java public DistributedDoubleBarrier(CuratorFramework client, String barrierPath, int memberQty) Creates the barrier abstraction. memberQty is the number of members in the barrier. When enter() is called, it blocks until all members have entered. When leave() is called, it blocks until all members have left. Parameters: client - the client barrierPath - path to use memberQty - the number of members in the barrier ``` `memberQty` 是成员数量,当 `enter()` 方法被调用时,成员被阻塞,直到所有的成员都调用了 `enter()`。 当 `leave()` 方法被调用时,它也阻塞调用线程,直到所有的成员都调用了 `leave()`。 就像百米赛跑比赛, 发令枪响, 所有的运动员开始跑,等所有的运动员跑过终点线,比赛才结束。 DistributedDoubleBarrier 会监控连接状态,当连接断掉时 `enter()` 和 `leave()` 方法会抛出异常。 ```java public class DistributedDoubleBarrierDemo { private static final int QTY = 5; private static final String PATH = "/examples/barrier"; public static void main(String[] args) throws Exception { try (TestingServer server = new TestingServer()) { CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3)); client.start(); ExecutorService service = Executors.newFixedThreadPool(QTY); for (int i = 0; i < QTY; ++i) { final DistributedDoubleBarrier barrier = new DistributedDoubleBarrier(client, PATH, QTY); final int index = i; Callable task = () -> { Thread.sleep((long) (3 * Math.random())); System.out.println("Client #" + index + " enters"); barrier.enter(); System.out.println("Client #" + index + " begins"); Thread.sleep((long) (3000 * Math.random())); barrier.leave(); System.out.println("Client #" + index + " left"); return null; }; service.submit(task); } service.shutdown(); service.awaitTermination(10, TimeUnit.MINUTES); Thread.sleep(Integer.MAX_VALUE); } } } ``` **参考资料:** 《从 PAXOS 到 ZOOKEEPER 分布式一致性原理与实践》 《 跟着实例学习 ZooKeeper 的用法》博客系列 ## [Redis高效处理:字符串、哈希、列表和有序集合](https://blog.dong4j.site/posts/5567a46d.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 基础数据结构 ### 字符串 > 字符串类型的值实际可以 是字符串(简单的字符串、复杂的字符串(例如 JSON、XML))、数字 (整数、浮点数),甚至是二进制(图片、音频、视频),但是值最大不能 超过 512MB。 ![20241229154732_N5bzodwZ.webp](https://cdn.dong4j.site/source/image/20241229154732_N5bzodwZ.webp) ```shell set key value [ex seconds] [px milliseconds] [nx|xx] setex key seconds value setnx key value ``` set 命令有几个选项: - ex seconds:为键设置秒级过期时间。 - px milliseconds:为键设置毫秒级过期时间。 - nx:键必须不存在,才可以设置成功,用于添加。 - xx:与 nx 相反,键必须存在,才可以设置成功,用于更新。 #### set、setnx、set xx 的区别 ```shell redis> get name dong4j redis> setnx name dong # 因为 name 已存在, 设置失败 0 redis> get name dong4j redis> set name dong4j xx # name 存在才能使用 xx OK redis> get name dong4j ``` [使用 setnx 实现分布式锁](https://www.cnblogs.com/linjiqin/p/8003838.html) #### 批量处理 ```shell redis> mset a 1 b 2 c 3 OK redis> mget a b c d 0 1 1 2 2 3 3 null ``` #### 计数操作 ```shell # 自增 incr key # 自减 decr key # 自增指定数字 incrby key increment # 自减指定数字 decrby key decrement # 自增浮点数 incrbyfloat key increment ``` #### 应用场景 由于 Redis 的单线程命令处理机制,如果有多个客户端同时执行 setnx key value, 根据 setnx 的特性只有一个客户端能设置成功,setnx 可以作为分布式锁的一种实现方案 1. 缓存功能 ![20241229154732_bEHqjfIe.webp](https://cdn.dong4j.site/source/image/20241229154732_bEHqjfIe.webp) 2. 计数 ```shell long incrVideoCounter(long id) { key = "video:playCount:" + id; return redis.incr(key); } ``` 3. 共享 Session ![20241229154732_faMHn3jj.webp](https://cdn.dong4j.site/source/image/20241229154732_faMHn3jj.webp) 4. 限速 限制获取验证码的频率 一分钟不能超过 5 次 ```shell phoneNum = "138xxxxxxxx"; key = "shortMsg:limit:" + phoneNum; // SET key value EX 60 NX isExists = redis.set(key,1,"EX 60","NX"); if(isExists != null || redis.incr(key) <=5) { // 通过 } else { // 限速 } ``` ### 哈希 ![20241229154732_kwSAJr0I.webp](https://cdn.dong4j.site/source/image/20241229154732_kwSAJr0I.webp) #### 常用操作 ```shell # 设置值 hset key field value # 获取值 hget key field # 删除 field hdel key field [field ...] # 计算 field 个数 hlen key # 批量设置或获取 field-value hmget key field [field ...] # 批量获取 field-value hmset key field value [field value ...] # 判断 field 是否存在 hexists key field # 获取所有field hkeys key # 获取所有value hvals key # 获取所有的field-value hgetall key # 对 field 自增 hincrby key field hincrbyfloat key field # 计算value的字符串长度 hstrlen key field ``` #### 使用场景 ![20241229154732_W36e7jqG.webp](https://cdn.dong4j.site/source/image/20241229154732_W36e7jqG.webp) ```java UserInfo getUserInfo(long id) { // 用户id作为key后缀 userRedisKey = "user:info:" + id; // 使用hgetall获取所有用户信息映射关系 userInfoMap = redis.hgetAll(userRedisKey); UserInfo userInfo; if (userInfoMap != null) { // 将映射关系转换为UserInfo userInfo = transferMapToUserInfo(userInfoMap); } else { // 从MySQL中获取用户信息 userInfo = mysql.get(id); // 将userInfo变为映射关系使用hmset保存到Redis中 redis.hmset(userRedisKey, transferUserInfoToMap(userInfo)); // 添加过期时间 redis.expire(userRedisKey, 3600); } return userInfo; } ``` ### 列表 列表中的每个字符串 称为元素(element),一个列表最多可以存储 2 32 -1 个元素。 在 Redis 中,可 以对列表两端插入(push)和弹出(pop),还可以获取指定范围的元素列 表、获取指定索引下标的元素等 ![20241229154732_ZXix2Mna.webp](https://cdn.dong4j.site/source/image/20241229154732_ZXix2Mna.webp) ![20241229154732_Q8kTkP5h.webp](https://cdn.dong4j.site/source/image/20241229154732_Q8kTkP5h.webp) #### 常用操作 | 操作类型 | 操作 | | :------- | :------------------- | | 添加 | rpush lpush linsert | | 查询 | lrange lindex llen | | 删除 | lpop rpop lrem ltrim | | 修改 | lset | | 阻塞操作 | blpop brpop | ```shell # 向 poivt 前或后插入元素 linsert key before|after pivot value ``` #### 使用场景 **1. 消息队列** Redis 的 lpush+brpop 命令组合即可实现阻塞队列,生产 者客户端使用 lrpush 从列表左侧插入元素,多个消费者客户端使用 brpop 命令 阻塞式的“抢”列表尾部的元素,多个客户端保证了消费的负载均衡和高可用性。 ![20241229154732_p0w8fDqH.webp](https://cdn.dong4j.site/source/image/20241229154732_p0w8fDqH.webp) **2. 文章列表** 场景使用口诀 - lpush+lpop=Stack(栈) - lpush+rpop=Queue(队列) - lpsh+ltrim=Capped Collection(有限集合) - lpush+brpop=Message Queue(消息队列) ### 集合 ![20241229154732_EH8AHkt5.webp](https://cdn.dong4j.site/source/image/20241229154732_EH8AHkt5.webp) ### 有序集合 ## 键的管理 ## [Redis背后的技术:单线程如何实现高性能?](https://blog.dong4j.site/posts/1556580b.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## Redis 的 5 种数据结构 ![20241229154732_VjP0mAw9.webp](https://cdn.dong4j.site/source/image/20241229154732_VjP0mAw9.webp) ## Redis 数据结构和内部编码 ![20241229154732_kNOvBCjM.webp](https://cdn.dong4j.site/source/image/20241229154732_kNOvBCjM.webp) ## Redis 的单线程模型 Redis 使用单线程处理命令, 所以一条命令从客户端达到服务端不会立即执行, 所有命令都会进入一个队列, 然后逐个被执行. **Redis 单线程处理速度快的原因** 1. 纯内存访问, 内存的响应时长约为 100 纳秒 2. 非阻塞 I/O, Redis 使用 epoll 作为 I/O 多路复用技术的实现, 再加上 Redis 自身的事件处理模型将 epoll 中的连接、读写、关闭都转换为事件,不 在网络 I/O 上浪费过多的时间 ![20241229154732_vK47Dk4P.webp](https://cdn.dong4j.site/source/image/20241229154732_vK47Dk4P.webp) 3. 单线程避免了线程切换和竞态产生的消耗 **存在的问题** 对于每个命令的执行时间有要求. 如果某个命令执行时间过长, 会造成其他命令阻塞 ## [Redis命令速查手册:常用指令详解](https://blog.dong4j.site/posts/570ace06.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## Redis 可执行文件说明 | 可执行文件 | 作用 | | :--------------- | :--------------------------------- | | redis-server | 启动 Redis | | redis-cli | Redis 命令行客户端 | | redis-benchmark | Redis 基准测试工具 | | redis-check-aof | Redis AOF 持久化文件检测和修复工具 | | redis-check-dump | Redis RDB 持久化文件检测和修复工具 | | redis-sentinel | 启动 Redis Sentinel | ## 启动方式 **1. 默认配置** ```shell redis-server ``` **2. 运行参数** ```shell # 格式 redis-server --configKey1 configValue1 --configKey2 configValue2 # 使用 6380 端口启动 redis redis-server --port 6380 ``` **3. 配置文件** ```shell redis-server /usr/local/redis/redis.conf ``` **3.1 基础配置** | 配置名 | 说明 | | :-------- | :---------------------------------------- | | port | 端口 | | logfile | 日志文件 | | dir | Redis 工作目录 (存放持久化文件和日志文件) | | daemonize | 是否以守护进程的方式启动 Redis | ## Redis 命令行客户端 **1. 交互式** ```shell redis-cli -h 127.0.0.1 -p 6379 ``` **2. 命令方式** ```shell redus-cli -h 127.0.0.1 -p 6379 get hello ``` ## 停止 Redis 服务 ```shell redis-cli shutdown # 关闭之前是否生成持久化文件 redis-cli shutdown nosave|save ``` ## 外放访问 Redis 1. 开发 Redis 端口 2. 修改 redis.conf ```shell bind 127.0.0.1 protected-mode yes ``` 修改为 ```shell # bind 127.0.0.1 protected-mode no ``` ## 修改持久化路径和日志路径 ```shell # 日志路径 logfile /data/redis_cache/logs/redis.log # 持久化路径,修改后 记得要把dump.rdb持久化文件 dir /data/redis_cache dbsize # 删除所有数据库中的key flushall # 删除当前数据库中的所有Key flushdb ``` ## 全局命令 **1. dbsize** dbsize 命令在计算键总数时不会遍历所有键,而是直接获取 Redis 内置的 键总数变量,所以 dbsize 命令的时间复杂度是 O(1) **2. keys** keys 命令会遍历所 有键,所以它的时间复杂度是 O(n),当 Redis 保存了大量键时,线上环境 禁止使用。 **3. exists key** 检查 key 是否存在 **4. del key1 [key2]** 删除 key del 是一个通用命令,无论值是什么数据结构类型,del 命令都可以将其 删除 **5. expire key seconds** Redis 支持对键添加过期时间,当超过过期时间后,会自动删除键 ```shell redis> expire name 10 1 redis> ttl name 3 redis> ttl name 0 redis> ttl name # 返回 -2 时, 说明 key 已被删除 -2 redis> get name null ``` **6. type (key 的数据结构)** ```shell redis> set name dong4j OK redis> type name string redis> type aaa none redis> rpush list ab cd ef 3 redis> type list list ``` ## [从零开始:安装并配置CentOS Nginx服务](https://blog.dong4j.site/posts/779e9fe6.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 系统 Centos 64 位 ## 第一步,首先下载 Nginx 的 tar 包及安装依赖的工具 tar 包。 Nginx: [http://nginx.org/en/download.html](http://nginx.org/en/download.html) Nginx 需要依赖下面 3 个包 gzip 模块需要 zlib 库 ( 下载: [http://www.zlib.net/](http://www.zlib.net/) ) rewrite 模块需要 pcre 库 ( 下载: [http://www.pcre.org/](http://www.pcre.org/) ) ssl 功能需要 openssl 库 ( 下载: [http://www.openssl.org/](http://www.openssl.org/) ) 分别解压。 具体命令: ```shell wget http://nginx.org/download/nginx-1.13.2.tar.gz wget http://www.zlib.net/zlib-1.2.11.tar.gz wget https://ftp.pcre.org/pub/pcre/pcre-8.40.tar.gz wget https://www.openssl.org/source/openssl-fips-2.0.16.tar.gz tar zxvf openssl-fips-2.0.16.tar.gz tar zxvf nginx-1.13.2.tar.gz tar zxvf zlib-1.2.11.tar.gz tar zxvf pcre-8.40.tar.gz ``` --- ## 第二步 编译安装 安装顺序:先安装三个依赖包再安装 nginx ```shell cd 到各个解压目录下运行 ./configuer && make && make install ``` 安装 c++ 编译环境 ```shell yum install gcc-c++ ``` --- ## 第三步 运行 nginx 安装好的 nginx 路径在: ```shell /usr/local/nginx ``` 默认的配置文件的路径在: ```shell /usr/local/nginx/conf/nginx.conf ``` 运行 nginx: ```shell /usr/local/nginx/sbin/nginx ``` 通过浏览器访问服务器 ip,出现以下标志就是启动成功了: ```shell Welcome to nginx! If you see this page, the nginx web server is successfully installed and working. Further configuration is required. For online documentation and support please refer to nginx.org. Commercial support is available at nginx.com. Thank you for using nginx. ``` 有问题之处烦请在留言中指出,非常感谢。 ## 问题 Nginx 启动提示找不到 libpcre.so.1 解决方法 WebServer 2012-08-26 Nginx,libpcre 启动 nginx 提示:error while loading shared libraries: libpcre.so.1: cannot open shared object file: No such file or directory,意思是找不到 libpcre.so.1 这个模块,而导致启动失败。 ```shell [root@lee ~]# /usr/local/webserver/nginx/sbin/nginx nginx: error while loading shared libraries: libpcre.so.1: cannot open shared object file: No such file or directory ``` 经过搜索资料,发现部分 linux 系统存有的通病。要解决这个方法非常容易 如果是 32 位系统 ```shell [root@lee ~]# ln -s /usr/local/lib/libpcre.so.1 /lib ``` 如果是 64 位系统 ```shell [root@lee ~]# ln -s /usr/local/lib/libpcre.so.1 /lib64 ``` 然后在启动 nginx 就 OK 了 ```shell [root@lee ~]# /usr/local/webserver/nginx/sbin/nginx ``` ## [Redis入门:掌握基础,开启高效数据存储之旅](https://blog.dong4j.site/posts/6c8ae0af.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## Redis 的 3 种用法 1. 内存缓存 2. 定量数据指标 3. 发布/订阅模型 ### 作为内存环境 #### 数据结构 **1. 字符串 string** ```shell // 设置字符串类型 set mystr "hello world!" // 读取字符串类型 get mystr // 通过字符串类型进行数值操作 在遇到数值操作时,redis 会将字符串类型转换成数值。 127.0.0.1:6379> set mynum "2" OK 127.0.0.1:6379> get mynum "2" 127.0.0.1:6379> incr mynum (integer) 3 127.0.0.1:6379> get mynum "3" ``` > INCR 等指令本身就具有原子操作的特性,所以我们完全可以利用 redis 的 INCR、INCRBY、DECR、DECRBY 等指令来实现原子计数的效果,假如,在某种场景下有 3 个客户端同时读取了 mynum 的值(值为 2),然后对其同时进行了加 1 的操作,那么,最后 mynum 的值一定是 5。不少网站都利用 redis 的这个特性来实现业务上的统计计数需求。 **2. 列表 list** Redis list 使用链表实现, 所以插入速度快, 查询速度慢 ```shell // 新建一个 list 叫做 mylist,并在列表头部插入元素 "1" 127.0.0.1:6379> lpush mylist "1" // 返回当前 mylist 中的元素个数 (integer) 1 // 在 mylist 右侧插入元素 "2" 127.0.0.1:6379> rpush mylist "2" (integer) 2 // 在 mylist 左侧插入元素 "0" 127.0.0.1:6379> lpush mylist "0" (integer) 3 // 列出 mylist 中从编号 0 到编号 1 的元素 127.0.0.1:6379> lrange mylist 0 1 1) "0" 2) "1" // 列出 mylist 中从编号 0 到倒数第一个元素 127.0.0.1:6379> lrange mylist 0 -1 1) "0" 2) "1" 3) "2" ``` lists 的应用相当广泛,随便举几个例子: 1. 我们可以利用 lists 来实现一个消息队列,而且可以确保先后顺序,不必像 MySQL 那样还需要通过 ORDER BY 来进行排序。 2. 利用 LRANGE 还可以很方便的实现分页的功能。 3. 在博客系统中,每片博文的评论也可以存入一个单独的 list 中。 **3. 无序集合 set** ```shell // 向集合 myset 中加入一个新元素 "one" 127.0.0.1:6379> sadd myset "one" (integer) 1 127.0.0.1:6379> sadd myset "two" (integer) 1 // 列出集合 myset 中的所有元素 127.0.0.1:6379> smembers myset 1) "one" 2) "two" // 判断元素 1 是否在集合 myset 中,返回 1 表示存在 127.0.0.1:6379> sismember myset "one" (integer) 1 // 判断元素 3 是否在集合 myset 中,返回 0 表示不存在 127.0.0.1:6379> sismember myset "three" (integer) 0 // 新建一个新的集合 yourset 127.0.0.1:6379> sadd yourset "1" (integer) 1 127.0.0.1:6379> sadd yourset "2" (integer) 1 127.0.0.1:6379> smembers yourset 1) "1" 2) "2" // 对两个集合求并集 127.0.0.1:6379> sunion myset yourset 1) "1" 2) "one" 3) "2" 4) "two" ``` > 对于集合的使用,也有一些常见的方式,比如,QQ 有一个社交功能叫做 “好友标签”,大家可以给你的好友贴标签,比如 “大美女”、“土豪”、“欧巴” 等等,这时就可以使用 redis 的集合来实现,把每一个用户的标签都存储在一个集合之中。 **4. 有序集合 zset** ```shell 127.0.0.1:6379> zadd myzset 1 baidu.com (integer) 1 // 向 myzset 中新增一个元素 360.com,赋予它的序号是 3 127.0.0.1:6379> zadd myzset 3 360.com (integer) 1 // 向 myzset 中新增一个元素 google.com,赋予它的序号是 2 127.0.0.1:6379> zadd myzset 2 google.com (integer) 1 // 列出 myzset 的所有元素,同时列出其序号,可以看出 myzset 已经是有序的了。 127.0.0.1:6379> zrange myzset 0 -1 with scores 1) "baidu.com" 2) "1" 3) "google.com" 4) "2" 5) "360.com" 6) "3" // 只列出 myzset 的元素 127.0.0.1:6379> zrange myzset 0 -1 1) "baidu.com" 2) "google.com" 3) "360.com" ``` **5. 哈希 hashes** 哈希是从 redis-2.0.0 版本之后才有的数据结构。 hashes 存的是字符串和字符串值之间的映射,比如一个用户要存储其全名、姓氏、年龄等等,就很适合使用哈希。 ```shell // 建立哈希,并赋值 127.0.0.1:6379> HMSET user:001 username dong4j password 1234 age 29 OK // 列出哈希的内容 127.0.0.1:6379> HGETALL user:001 1) "username" 2) "dong4j" 3) "password" 4) "1234" 5) "age" 6) "29" // 更改哈希中的某一个值 127.0.0.1:6379> HSET user:001 password 12345 (integer) 0 // 再次列出哈希的内容 127.0.0.1:6379> HGETALL user:001 1) "username" 2) "dong4j" 3) "password" 4) "12345" 5) "age" 6) "29" ``` **6. HyperLogLog** Redis 的 HyperLogLog 使用随机化,以提供唯一的元素数目近似的集合只使用一个常数,并且体积小,少量内存的算法。 HyperLogLog 提供,即使每个使用了非常少量的内存(12 千字节),标准误差为集合的基数非常近似,没有限制的条目数,可以指定,除非接近 264 个条目。 ```shell redis 127.0.0.1:6379> PFADD tutorials "redis" 1) (integer) 1 redis 127.0.0.1:6379> PFADD tutorials "mongodb" 1) (integer) 1 redis 127.0.0.1:6379> PFADD tutorials "mysql" 1) (integer) 1 redis 127.0.0.1:6379> PFCOUNT tutorials (integer) 3 ``` ### 作为定量数据指标 ### 发布/订阅模型 Redis 的订阅实现了邮件系统,发送者(在 Redis 的术语中被称为发布者)发送的邮件,而接收器(用户)接收它们。由该消息传送的链路被称为通道。 在 Redis 客户端可以订阅任何数目的通道。 ```shell redis 127.0.0.1:6379> SUBSCRIBE redisChat Reading messages... (press Ctrl-C to quit) 1) "subscribe" 2) "redisChat" 3) (integer) 1 ``` ```shell redis 127.0.0.1:6379> PUBLISH redisChat "Redis is a great caching technique" (integer) 1 redis 127.0.0.1:6379> PUBLISH redisChat "Learn redis by tutorials point" (integer) 1 1) "message" 2) "redisChat" 3) "Redis is a great caching technique" 1) "message" 2) "redisChat" 3) "Learn redis by tutorials point" ``` ## Redis 事务 Redis 事务让一组命令在单个步骤执行。事务中有两个属性,说明如下: 1. 在一个事务中的所有命令按顺序执行作为单个隔离操作。通过另一个客户端发出的请求在 Redis 的事务的过程中执行,这是不可能的。 2. Redis 的事务具有原子性。原子意味着要么所有的命令都执行或都不执行。 > Redis 的事务由指令多重发起,然后需要传递在事务,而且整个事务是通过执行命令 EXEC 执行命令列表。 ```shell redis 127.0.0.1:6379> MULTI OK List of commands here redis 127.0.0.1:6379> EXEC ``` ```shell redis 127.0.0.1:6379> MULTI OK redis 127.0.0.1:6379> SET tutorial redis QUEUED redis 127.0.0.1:6379> GET tutorial QUEUED redis 127.0.0.1:6379> INCR visitors QUEUED redis 127.0.0.1:6379> EXEC 1) OK 2) "redis" 3) (integer) 1 ``` ## [CentOS7 MongoDB 3.0 安装全攻略](https://blog.dong4j.site/posts/53cebd27.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### 1. 下载 & 安装 MongoDB 3.0 正式版本发布! 这标志着 MongoDB 数据库进入了一个全新的发展阶段,提供强大、灵活而且易于管理的数据库管理系统。MongoDB 宣称,3.0 新版本不只提升 7 到 10 倍的写入效率以及增加 80% 的数据压缩率,还能减少 95% 的运维成本。     MongoDB 3.0 主要新特性包括:    · 可插入式的存储引擎 API    · 支持 WiredTiger 存储引擎     ·MMAPv1 提升     · 复制集全面提升     · 集群方面的改进     · 提升了安全性     · 工具的提升   WiredTiger 存储引擎是一项难以置信的技术实现,提供无门闩、非堵塞算法来利用先进的硬件平台 (如大容量芯片缓存和线程化架构) 来提升性能。通过 WiredTiger,MongoDB 3.0 实现了文档级别的并发控制,因此大幅提升了大并发下的写负载。 MongoDB 提供了 centos yum 安装方式。 vi /etc/yum.repos.d/mongodb-org-3.0.repo ```shell [mongodb-org-3.0] name=MongoDB Repository baseurl=http://repo.mongodb.org/yum/redhat/$releasever/mongodb-org/3.0/x86_64/ gpgcheck=0 enabled=1 ``` 安装 mongodb ```shell yum install -y mongodb-org ``` 安装了所有相关服务。 ```shell ...... Running transaction Installing : mongodb-org-shell-3.0.2-1.el7.x86_64 1/5 Installing : mongodb-org-tools-3.0.2-1.el7.x86_64 2/5 Installing : mongodb-org-mongos-3.0.2-1.el7.x86_64 3/5 Installing : mongodb-org-server-3.0.2-1.el7.x86_64 4/5 Installing : mongodb-org-3.0.2-1.el7.x86_64 5/5 Verifying : mongodb-org-3.0.2-1.el7.x86_64 1/5 Verifying : mongodb-org-server-3.0.2-1.el7.x86_64 2/5 Verifying : mongodb-org-mongos-3.0.2-1.el7.x86_64 3/5 Verifying : mongodb-org-tools-3.0.2-1.el7.x86_64 4/5 Verifying : mongodb-org-shell-3.0.2-1.el7.x86_64 5/5 ``` 配置文件在:/etc/mongod.conf  数据文件在:/var/lib/mongo  日志文件在:/var/log/mongodb  mongodb 服务使用 ```shell #启动 service mongod start #停止 service mongod stop #重启 service mongod restart #增加开机启动 chkconfig mongod on ``` ### 2. MongoDB CRUD 连接到 MongoDB,很简单,执行 mongo 就可以了。 ```shell # mongo MongoDB shell version: 3.0.2 connecting to: test Welcome to the MongoDB shell. For interactive help, type "help". For more comprehensive documentation, see http://docs.mongodb.org/ Questions? Try the support group http://groups.google.com/group/mongodb-user Server has startup warnings: I STORAGE [initandlisten] I STORAGE [initandlisten] ** WARNING: Readahead for /var/lib/mongo is set to 4096KB I STORAGE [initandlisten] ** We suggest setting it to 256KB (512 sectors) or less I STORAGE [initandlisten] ** http://dochub.mongodb.org/core/readahead I CONTROL [initandlisten] I CONTROL [initandlisten] ** WARNING: /sys/kernel/mm/transparent_hugepage/enabled is 'always'. I CONTROL [initandlisten] ** We suggest setting it to 'never' I CONTROL [initandlisten] I CONTROL [initandlisten] ** WARNING: /sys/kernel/mm/transparent_hugepage/defrag is 'always'. I CONTROL [initandlisten] ** We suggest setting it to 'never' I CONTROL [initandlisten] I CONTROL [initandlisten] ** WARNING: soft rlimits too low. rlimits set to 4096 processes, 64000 files. Number of processes should be at least 32000 : 0.5 times number of files. I CONTROL [initandlisten] > ``` #### 2.1. 创建数据: http://docs.mongodb.org/manual/tutorial/insert-documents/  http://docs.mongodb.org/manual/reference/method/db.collection.insert/ ```shell > db.users.insert( ... { ... name:"zhang san", ... age:26, ... city:"bei jing" ... } ... ) WriteResult({ "nInserted" : 1 }) > db.users.insert( ... { ... _id:1, ... name:"zhang san", ... age:26, ... city:"bei jing" ... } ... ) WriteResult({ "nInserted" : 1 }) > db.users.insert( ... { ... _id:1, ... name:"zhang san", ... age:26, ... city:"bei jing" ... } ... ) WriteResult({ "nInserted" : 0, "writeError" : { "code" : 11000, "errmsg" : "E11000 duplicate key error index: test.users.$_id_ dup key: { : 1.0 }" } }) > db.users.insert( ... { ... _id:2, ... name:"li si", ... age:28, ... city:"shang hai" ... } ... ) WriteResult({ "nInserted" : 1 }) ``` 数据可以没有主键 id,如果没有,会自动生成一个。如果设置了 `_id` 主键,就必须不重复。  否则报主键冲突:`E11000 duplicate key error index: test.users.$_id* dup key: { : 1.0}` #### 2.2. 更新数据: http://docs.mongodb.org/manual/tutorial/modify-documents/ ```shell > db.users.update( ... {_id:2}, ... { ... $set: { ... city:"guang zhou" ... } ... } ... ) WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 }) > db.users.update( ... {_id:3}, ... { ... $set: { ... city:"si chuan" ... } ... }, ... { upsert: true } ... ) WriteResult({ "nMatched" : 0, "nUpserted" : 1, "nModified" : 0, "_id" : 3 }) ``` 更新使用 update,如果增加 {upsert: true},则表示没有查询到数据直接插入。 #### 2.3. 删除: http://docs.mongodb.org/manual/tutorial/remove-documents/ ```shell > db.users.remove({_id:3}) WriteResult({ "nRemoved" : 1 }) > db.users.remove({_id:4}) WriteResult({ "nRemoved" : 0 }) ``` 查询到数据才进行删除,并且返回删除数量。 #### 2.4. 查询: http://docs.mongodb.org/manual/tutorial/query-documents/ ```shell > db.users.find({age:{ $gt: 26}}) { "_id" : 2, "name" : "li si", "age" : 28, "city" : "guang zhou" } > db.users.find({age:{ $gt: 25}}) { "_id" : ObjectId("5540adf29b0f52a6786de216"), "name" : "zhang san", "age" : 26, "city" : "bei jing" } { "_id" : 1, "name" : "zhang san", "age" : 26, "city" : "bei jing" } { "_id" : 2, "name" : "li si", "age" : 28, "city" : "guang zhou" } #查询全部数据 > db.users.find() { "_id" : ObjectId("5540adf29b0f52a6786de216"), "name" : "zhang san", "age" : 26, "city" : "bei jing" } { "_id" : 1, "name" : "zhang san", "age" : 26, "city" : "bei jing" } { "_id" : 2, "name" : "li si", "age" : 28, "city" : "guang zhou" } ``` #### 2.5. 更多方法 db.collection.aggregate()  db.collection.count()  db.collection.copyTo()  db.collection.createIndex()  db.collection.getIndexStats()  db.collection.indexStats()  db.collection.dataSize()  db.collection.distinct()  db.collection.drop()  db.collection.dropIndex()  db.collection.dropIndexes()  db.collection.ensureIndex()  db.collection.explain()  db.collection.find()  db.collection.findAndModify()  db.collection.findOne()  db.collection.getIndexes()  db.collection.getShardDistribution()  db.collection.getShardVersion()  db.collection.group()  db.collection.insert()  db.collection.isCapped()  db.collection.mapReduce()  db.collection.reIndex()  db.collection.remove()  db.collection.renameCollection()  db.collection.save()  db.collection.stats()  db.collection.storageSize()  db.collection.totalSize()  db.collection.totalIndexSize()  db.collection.update()  db.collection.validate() ### 3. MongoDB 可视化工具 http://www.robomongo.org/ 使用可视化工具,方便使用 MongoDB 管理。  首先要修改下端口和 ip  vi /etc/mongod.conf ```shell port=27017 dbpath=/var/lib/mongo # location of pidfile pidfilepath=/var/run/mongodb/mongod.pid # Listen to local interface only. Comment out to listen on all interfaces. bind_ip=192.168.1.36 ``` 然后重启 MongoDB ```shell service mongod restart ``` ### 4. 总结 MongoDB 3.0 操作起来还是很方便的。能高效的使用。  同时 MongoDB 扩展也很方便。接下来研究。  对应互联网业务来说没有复杂的 join 查询。只追求高效,快速访问。 ## [在CentOS上享受更高效的Shell体验:oh-my-zsh](https://blog.dong4j.site/posts/ac4909f7.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 使用 root 用户登录,下面的操作基本都没有 root 的困扰,如果非 root 用户请切换至 root 用户操作。 **1、查看系统当前的 shell** ```shell echo $SHELL ``` 返回结果如下: ```shell /bin/bash ``` _PS. 默认的 shell 一般都是 bash_ --- **2、查看 bin 下是否有 zsh 包** ```shell cat /etc/shells ``` 返回结果如下: ```shell /bin/sh /bin/bash /sbin/nologin /bin/dash /bin/tcsh /bin/csh ``` _PS. 默认没有安装 zsh_ --- **3、安装 zsh 包** ```shell yum -y install zsh ``` 安装完成后查看 shell 列表: ```shell cat /etc/shells ``` 返回结果如下: ```shell /bin/sh /bin/bash /sbin/nologin /bin/dash /bin/tcsh /bin/csh /bin/zsh ``` _现在 zsh 已经安装完成了,需要把系统默认的 shell 由 bash 切换为 zsh_ --- **3、切换 shell 至 zsh,代码如下:** ```shell chsh -s /bin/zsh ``` chsh 用法请自行查找,返回结果如下: ```shell Changing shell for root. Shell changed. ``` 按提示所述,shell 已经更改为 zsh 了,现在查看一下系统当前使用的 shell, ```shell echo $SHELL ``` 返回结果如下: ```shell /bin/bash ``` 看样子还没切换过来,需要重启一下服务器,我的习惯做法是在 ECS 的 web 管理平台重启,`reboot`到底好不好使还没试过,大家可以试试 重启过后,使用代码查看当前使用的 shell ```shell echo $SHELL ``` 返回结果: ```shell /bin/zsh ``` 得到如此结果,证明 shell 已经切换成功了。 --- **下面开始安装 oh-my-zsh**     *oh-my-zsh 源码是放在 github 上的,所以先要安装 git* **4、安装 git:** ```shell yum -y install git ``` **5、安装 oh-my-zsh:** ```shell wget https://github.com/robbyrussell/oh-my-zsh/raw/master/tools/install.sh -O - | sh ``` 如果显示如下界面表示成功: ```shell __ __ ____ / /_ ____ ___ __ __ ____ _____/ /_ / __ \/ __ \ / __ `__ \/ / / / /_ / / ___/ __ \ / /_/ / / / / / / / / / / /_/ / / /_(__ ) / / / \____/_/ /_/ /_/ /_/ /_/\__, / /___/____/_/ /_/ /____/ ....is now installed! Please look over the ~/.zshrc file to select plugins, themes, and options. p.s. Follow us at https://twitter.com/ohmyzsh. p.p.s. Get stickers and t-shirts at http://shop.planetargon.com. ``` 如果添加插件、更改 themes 请修改~/.zshrc 或自行查询其它资料。 至此,zsh 安装完毕,开始享受 oh-my-zsh 吧,如果执行命令时提示`warning: cannot set LC_CTYPE locale`可用以下方法解决: 修改 profile: ```shell vi /etc/profile ``` 在 profile 末尾添加以下代码: ```shell export LC_ALL=en_US.UTF-8 export LC_CTYPE=en_US.UTF-8 ``` 引用更改后的 profile: ```shell source /etc/profile ``` 此时 bash 已切换至 zsh。 ## [掌握 Shell 编程:Linux 必备技能](https://blog.dong4j.site/posts/441dc39d.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 简述 使用 linux 就离不开 shell,那么也就是说也离不开 shell 编程。很多时候服务器都需要编写一些计划任务来定时运行的,所以掌握一些基本的 shell 编程基础很有必要。 本文是我在网上收集的一些资料,主要目的是帮助自己更好的了解掌握 shell 编程的一些基础知识。 ## 什么是 Shell 脚本 示例看个例子吧: ```shell #!/bin/sh cd ~ mkdir shell_tut cd shell_tut for ((i=0; i<10; i++)); do touch test_$i.txt done ``` 示例解释: - 第 1 行:指定脚本解释器,这里是用/bin/sh 做解释器的 - 第 2 行:切换到当前用户的 home 目录 - 第 3 行:创建一个目录 shell_tut - 第 4 行:切换到 shell_tut 目录 - 第 5 行:循环条件,一共循环 10 次 - 第 6 行:创建一个 test_1…10.txt 文件 - 第 7 行:循环体结束 ```shell cd, mkdir, touch 都是系统自带的程序,一般在/bin或者/usr/bin目录下。 for, do, done 是sh脚本语言的关键字。 ``` ## shell 和 shell 脚本的概念 shell 是指一种应用程序,这个应用程序提供了一个界面,用户通过这个界面访问操作系统内核的服务。 Ken Thompson 的 sh 是第一种 Unix Shell,Windows Explorer 是一个典型的图形界面 Shell。 shell 脚本(shell script),是一种为 shell 编写的脚本程序。 业界所说的 shell 通常都是指 shell 脚本,但读者朋友要知道,shell 和 shell script 是两个不同的概念。 由于习惯的原因,简洁起见,本文出现的“shell 编程”都是指 shell 脚本编程,不是指开发 shell 自身(如 Windows Explorer 扩展开发)。 环境 shell 编程跟 java、php 编程一样,只要有一个能编写代码的文本编辑器和一个能解释执行的脚本解释器就可以了。 OS 当前主流的操作系统都支持 shell 编程,本文档所述的 shell 编程是指 Linux 下的 shell,讲的基本都是 POSIX 标准下的功能,所以,也适用于 Unix 及 BSD(如 Mac OS)。 LinuxLinux 默认安装就带了 shell 解释器。 Mac OSMac OS 不仅带了 sh、bash 这两个最基础的解释器,还内置了 ksh、csh、zsh 等不常用的解释器。 Windows 上的模拟器 windows 出厂时没有内置 shell 解释器,需要自行安装,为了同时能用 grep, awk, curl 等工具,最好装一个 cygwin 或者 mingw 来模拟 linux 环境。 - cygwin - mingw ## 脚本解释器 sh 即 Bourne shell,POSIX(Portable Operating System Interface)标准的 shell 解释器,它的二进制文件路径通常是/bin/sh,由 Bell Labs 开发。 bashBash 是 Bourne shell 的替代品,属 GNU Project,二进制文件路径通常是/bin/bash。业界通常混用 bash、sh、和 shell,比如你会经常在招聘运维工程师的文案中见到:熟悉 Linux Bash 编程,精通 Shell 编程。 在 CentOS 里,/bin/sh 是一个指向/bin/bash 的符号链接: ```shell [root@centosraw ~]# ls -l /bin/*sh -rwxr-xr-x. 1 root root 903272 Feb 22 05:09 /bin/bash -rwxr-xr-x. 1 root root 106216 Oct 17 2012 /bin/dash lrwxrwxrwx. 1 root root 4 Mar 22 10:22 /bin/sh -> bash ``` 但在 Mac OS 上不是,/bin/sh 和/bin/bash 是两个不同的文件,尽管它们的大小只相差 100 字节左右: ```shell iMac:~ wuxiao$ ls -l /bin/*sh -r-xr-xr-x 1 root wheel 1371648 6 Nov 16:52 /bin/bash -rwxr-xr-x 2 root wheel 772992 6 Nov 16:52 /bin/csh -r-xr-xr-x 1 root wheel 2180736 6 Nov 16:52 /bin/ksh -r-xr-xr-x 1 root wheel 1371712 6 Nov 16:52 /bin/sh -rwxr-xr-x 2 root wheel 772992 6 Nov 16:52 /bin/tcsh -rwxr-xr-x 1 root wheel 1103984 6 Nov 16:52 /bin/zsh ``` ## 如何选择 shell 编程语言 熟悉 vs 陌生如果你已经掌握了一门编程语言(如 PHP、Python、Java、JavaScript),建议你就直接使用这门语言编写脚本程序,虽然某些地方会有点啰嗦,但你能利用在这门语言领域里的经验(单元测试、单步调试、IDE、第三方类库)。 新增的学习成本很小,只要学会怎么使用 shell 解释器(Jshell、AdaScript)就可以了。 简单 vs 高级如果你觉得自己熟悉的语言(如 Java、C)写 shell 脚本实在太啰嗦,你只是想做一些备份文件、安装软件、下载数据之类的事情,学着使用 sh,bash 会是一个好主意。 shell 只定义了一个非常简单的编程语言,所以,如果你的脚本程序复杂度较高,或者要操作的数据结构比较复杂,那么还是应该使用 Python、Perl 这样的脚本语言,或者是你本来就已经很擅长的高级语言。 因为 sh 和 bash 在这方面很弱,比如说: - 它的函数只能返回字串,无法返回数组 - 它不支持面向对象,你无法实现一些优雅的设计模式 - 它是解释型的,一边解释一边执行,连 PHP 那种预编译都不是,如果你的脚本包含错误 (例如调用了不存在的函数),只要没执行到这一行,就不会报错 环境兼容性如果你的脚本是提供给别的用户使用,使用 sh 或者 bash,你的脚本将具有最好的环境兼容性,perl 很早就是 linux 标配了,python 这些年也成了一些 linux 发行版的标配,至于 mac os,它默认安装了 perl、python、ruby、php、java 等主流编程语言。 第一个 shell 脚本 编写打开文本编辑器,新建一个文件,扩展名为 sh(sh 代表 shell),扩展名并不影响脚本执行,见名知意就好,如果你用 php 写 shell 脚本,扩展名就用 php 好了。 输入一些代码,第一行一般是这样: ```shell #!/bin/bash #!/usr/bin/php ``` “#!”是一个约定的标记,它告诉系统这个脚本需要什么解释器来执行。 ## 运行 运行 Shell 脚本有两种方法: **1、作为可执行程序** 将上面的代码保存为 test.sh,并 cd 到相应目录: ```shell chmod +x ./test.sh #使脚本具有执行权限 ``` ```shell ./test.sh #执行脚本 ``` 注意: - 一定要写成./test.sh,而不是 test.sh,运行其它二进制的程序也一样。 - 直接写 test.sh,linux 系统会去 PATH 里寻找有没有叫 test.sh 的,只有/bin, /sbin, /usr/bin,/usr/sbin 等在 PATH 里。 - 你的当前目录通常不在 PATH 里,所以写成 test.sh 是会找不到命令的,要用./test.sh 告诉系统说,就在当前目录找。 **2、作为解释器参数** 这种运行方式是,直接运行解释器,其参数就是 shell 脚本的文件名,如: ```shell /bin/sh test.sh ``` ```shell /bin/php test.php ``` 这种方式运行的脚本,不需要在第一行指定解释器信息,写了也没用。 ## 变量 定义变量定义变量时,变量名不加美元符号($),如: ```shell your_name="qinjx" ``` 注意,变量名和等号之间不能有空格,这可能和你熟悉的所有编程语言都不一样: - 首个字母必须为字母(a-z,A-Z)。 - 中间不能有空格,可以使用下划线。 - 不能使用表单符号。 - 不能使用 bash 里的关键字(如果不清楚,可以用 help 命令查看 bash 的保留关键字)。 除了显式地直接赋值,还可以用语句给变量赋值,如: ```shell for file in `ls /etc` ``` - 说明:上面的命令的意思是实现将 /etc 下目录的文件名循环出来。 **使用变量** 使用一个定义过得变量,只要在变量名之前加上美元符号 $ 即可,比如: ```shell your_name="qinjx" echo $your_name echo ${your_name} ``` 变量名外面的花括号是可选的,可加可不加,加花括号是为了帮助解释器识别变量的边界,比如当遇到下面的情况时: ```shell for skill in Ada Coffe Action Java; do echo "I am good at ${skill}Script" done ``` 如果不给 skill 变量加上花括号,变量写成 skillScript,那么解释器就识别不出 skill 了,那么代码的最终结果就跟我的预期相差甚远。 推荐给所有变量加上花括号,这是 shell 编程的好习惯。 对于已定义的变量,可以被重新定义,比如: ```shell your_name="tom" echo $your_name your_name="alibaba" echo $your_name ``` 注意第二次赋值的时候不能写成: ```shell $your_name="alibaba" ``` 只有在使用变量的时候才加美元符 $。 **只读变量** 使用 readonly 命令可以将变量定义为只读变量,只读变量的值不能被改变。 下面的例子尝试更改只读变量,结果报错: ```shell #!/bin/bash myUrl="http://www.w3cschool.cc" readonly myUrl myUrl="http://www.runoob.com" ``` 运行脚本,结果如下: ```shell /bin/sh: NAME: This variable is read only. ``` **删除变量** 使用 unset 命令可以删除变量。语法: ```shell unset variable_name ``` 变量被删除后不能再次使用。unset 命令不能删除只读变量。 案例: ```shell #!/bin/sh myUrl="http://www.baodu.com" unset myUrl echo $myUrl ``` 上面的程序执行将没有任何输出。 **变量类型** 运行 shell 程序时,会同时存在三种变量: 1. 局部变量: 局部变量在脚本或命令中定义,仅在当前 shell 实例中有效,其他 shell 启动的程序不能访问局部变量。 2. 环境变量: 所有的程序,包括 shell 启动的程序,都能访问环境变量,有些程序需要环境变量来保证其正常运行。必要的时候 shell 脚本也可以定义环境变量。 3. shell 变量:shell 变量是由 shell 程序设置的特殊变量。shell 变量中有一部分是环境变量,有一部分是局部变量,这些变量保证了 shell 的正常运行 ## 字符串 字符串是 shell 编程中最常用最有用的数据类型(除了数字和字符串,也没啥其它类型好用了)。 字符串可以用单引号,也可以用双引号,也可以不用引号。单双引号的区别跟 PHP 类似。 **单引号** ```shell str='this is a string' ``` 单引号字符串的限制: - 单引号里的任何字符都会原样输出,单引号字符串中的变量是无效的。 - 单引号字串中不能出现单引号(对单引号使用转义符后也不行)。 **双引号** ```shell your_name='qinjx' str="Hello, I know your are \"$your_name\"! \n" ``` 双引号的优点: - 双引号里可以有变量。 - 双引号里可以出现转义字符。 **拼接字符串** ```shell your_name="qinjx" greeting="hello, "$your_name" !" greeting_1="hello, ${your_name} !" echo $greeting $greeting_1 ``` **获取字符串长度** ```shell string="abcd" echo ${#string} #输出 4 ``` **提取子字符串** 以下实例从字符串第 2 个字符开始截取 4 个字符: ```shell string="runoob is a great site" echo ${string:1:4} # 输出 unoo ``` **查找子字符串** 查找字符 "i 或 s" 的位置: ```shell string="runoob is a great company" echo `expr index "$string" is` # 输出 8 ``` 注意: 以上脚本中 "`" 是反引号,而不是单引号 "'",千万不要看错了。 ## 数组 Bash Shell 只支持一维数组(不支持多维数组),初始化时不需要定义数组大小(与 PHP 类似)。 获取数组中的元素要利用下标,下标可以是整数或算术表达式,其值应大于或等于 0。 数组中可以存放多个值,与大部分编程语言类似,数组元素的下标由 0 开始。 **定义数组** 在 Shell 中,用括号来表示数组,数组元素用 "空格 "符号分割开。 定义数组的一般形式为: ```shell 数组名=(值1 值2 ... 值n) ``` 例如: ```shell array_name=(value0 value1 value2 value3) ``` 或者 ```shell array_name=( value0 value1 value2 value3 ) ``` 还可以单独定义数组的各个分量: ```shell array_name[0]=value0 array_name[1]=value1 array_name[n]=valuen ``` 可以不使用连续的下标,而且下标的范围没有限制。 **读取数组** 读取数组元素值的一般格式是: ```shell ${数组名[下标]} ``` 例如: ```shell valuen=${array_name[n]} ``` shell 编程实战: ```shell #!/bin/bash my_array=(A B "C" D) echo "第一个元素为: ${my_array[0]}" echo "第二个元素为: ${my_array[1]}" echo "第三个元素为: ${my_array[2]}" echo "第四个元素为: ${my_array[3]}" ``` 执行脚本,输出结果如下所示: ```shell $ chmod +x test.sh $ ./test.sh 第一个元素为: A 第二个元素为: B 第三个元素为: C 第四个元素为: D ``` 使用 `@` 或者 `*` 符号可以获取数组中的所有元素,例如: ```shell echo ${array_name[@]} ``` shell 编程实战: ```shell #!/bin/bash my_array[0]=A my_array[1]=B my_array[2]=C my_array[3]=D echo "数组的元素为: ${my_array[*]}" echo "数组的元素为: ${my_array[@]}" ``` 执行脚本,输出结果如下所示: ```shell $ chmod +x test.sh $ ./test.sh 数组的元素为: A B C D 数组的元素为: A B C D ``` **获取数组的长度** 获取数组长度的方法与获取字符串长度的方法相同,例如: ```shell # 取得数组元素的个数 length=${#array_name[@]} # 或者 length=${#array_name[*]} # 取得数组单个元素的长度 lengthn=${#array_name[n]} ``` shell 实战编程: ```shell #!/bin/bash my_array[0]=A my_array[1]=B my_array[2]=C my_array[3]=D echo "数组元素个数为: ${#my_array[*]}" echo "数组元素个数为: ${#my_array[@]}" ``` 执行脚本,输出结果如下所示: ```shell $ chmod +x test.sh $ ./test.sh 数组元素个数为: 4 数组元素个数为: 4 ``` ## 注释 以 "#"开头的行就是注释,会被解释器忽略。 sh 里没有多行注释,只能每一行加一个#号。只能像这样: ```shell #-------------------------------------------- # 这是一个注释 #-------------------------------------------- ##### 用户配置区 开始 ##### # # # 这里可以添加脚本描述信息 # # ##### 用户配置区 结束 ##### ``` 如果在开发过程中,遇到大段的代码需要临时注释起来,过一会儿又取消注释,怎么办呢? 每一行加个#符号太费力了,可以把这一段要注释的代码用一对花括号括起来,定义成一个函数,没有地方调用这个函数,这块代码就不会执行,达到了和注释一样的效果。 ## 传递参数 我们可以在执行 Shell 脚本时,向脚本传递参数,脚本内获取参数的格式为:$n。 ```shell n 代表一个数字,1 为执行脚本的第一个参数,2 为执行脚本的第二个参数,以此类推…… ``` **实例** 以下实例我们向脚本传递三个参数,并分别输出,其中 $0 为执行的文件名: ```shell #!/bin/bash echo "Shell 传递参数实例!"; echo "执行的文件名:$0"; echo "第一个参数为:$1"; echo "第二个参数为:$2"; echo "第三个参数为:$3"; ``` 为脚本设置可执行权限,并执行脚本,输出结果如下所示: ```shell $ chmod +x test.sh $ ./test.sh 1 2 3 Shell 传递参数实例! 执行的文件名:test.sh 第一个参数为:1 第二个参数为:2 第三个参数为:3 ``` 另外,还有几个特殊字符用来处理参数: ```shell 参数处理 说明 $# 传递到脚本的参数个数 $* 以一个单字符串显示所有向脚本传递的参数。 如"$*"用「"」括起来的情况、以"$1 $2 … $n"的形式输出所有参数。 $ 脚本运行的当前进程ID号 $! 后台运行的最后一个进程的ID号 $@ 与$*相同,但是使用时加引号,并在引号中返回每个参数。 如"$@"用「"」括起来的情况、以"$1" "$2" … "$n" 的形式输出所有参数。 $- 显示Shell使用的当前选项,与set命令功能相同。 $? 显示最后命令的退出状态。0表示没有错误,其他任何值表明有错误。 ``` ```shell #!/bin/bash echo "Shell 传递参数实例!"; echo "第一个参数为:$1"; echo "参数个数为:$#"; echo "传递的参数作为一个字符串显示:$*"; ``` 执行脚本,输出结果如下所示: ```shell $ chmod +x test.sh $ ./test.sh 1 2 3 Shell 传递参数实例! 第一个参数为:1 参数个数为:3 传递的参数作为一个字符串显示:1 2 3 ``` ∗ 与@ 区别: - 相同点:都是引用所有参数。 - 不同点:只有在双引号中体现出来。 ```shell 假设在脚本运行时写了三个参数 1、2、3,,则 " * " 等价于 "1 2 3"(传递了一个参数),而 "@" 等价于 "1" "2" "3"(传递了三个参数)。 ``` ```shell #!/bin/bash echo "-- \$* 演示 ---" for i in "$*"; do echo $i done echo "-- \$@ 演示 ---" for i in "$@"; do echo $i done ``` 执行脚本,输出结果如下所示: ```shell $ chmod +x test.sh $ ./test.sh 1 2 3 -- $* 演示 --- 1 2 3 -- $@ 演示 --- 1 2 3 ``` ## 基本运算符 Shell 和其他编程语言一样,支持多种运算符,包括: - 算数运算符 - 关系运算符 - 布尔运算符 - 字符串运算符 - 文件测试运算符 原生 bash 不支持简单的数学运算,但是可以通过其他命令来实现,例如 awk 和 expr,expr 最常用。 expr 是一款表达式计算工具,使用它能完成表达式的求值操作。 例如,两个数相加 (特别注意:使用的是反引号 ` 而不是单引号 '): ```shell #!/bin/bash val=`expr 2 + 2` echo "两数之和为 : $val" ``` 执行脚本,输出结果如下所示: ```shell 两数之和为 : 4 ``` 两点注意: - 表达式和运算符之间要有空格,例如 2+2 是不对的,必须写成 2 + 2,这与我们熟悉的大多数编程语言不一样。 - 完整的表达式要被 包含,注意这个字符不是常用的单引号,在 Esc 键下边。 **算术运算符** 下面列出了常用的算术运算符,假定变量 a 为 10,变量 b 为 20: ```shell 运算符 说明 举例 + 加法 `expr $a + $b` 结果为 30。 + 减法 `expr $a - $b` 结果为 10。 + 乘法 `expr $a \* $b` 结果为 200。 / 除法 `expr $b / $a` 结果为 2。 % 取余 `expr $b % $a` 结果为 0。 = 赋值 a=$b 将把变量 b 的值赋给 a。 == 相等。用于比较两个数字,相同则返回 true。 [ $a == $b ] 返回 false。 != 不相等。用于比较两个数字,不相同则返回 true。 [ $a != $b ] 返回 true。 ``` - 注意:条件表达式要放在方括号之间,并且要有空格,例如: [a==b] 是错误的,必须写成 [ a==b ]。 **示例** 算术运算符实例如下: ```shell #!/bin/bash a=10 b=20 val=`expr $a + $b` echo "a + b : $val" val=`expr $a - $b` echo "a - b : $val" val=`expr $a \* $b` echo "a * b : $val" val=`expr $b / $a` echo "b / a : $val" val=`expr $b % $a` echo "b % a : $val" if [ $a == $b ] then echo "a 等于 b" fi if [ $a != $b ] then echo "a 不等于 b" fi ``` 执行脚本,输出结果如下所示: ```shell a + b : 30 a - b : -10 a * b : 200 b / a : 2 b % a : 0 a 不等于 b ``` 注意: - 乘号 (`*`) 前边必须加反斜杠 (`\`) 才能实现乘法运算。 - if…then…fi 是条件语句,后续会有说明。 **关系运算符** 关系运算符只支持数字,不支持字符串,除非字符串的值是数字。 下面列出了常用的关系运算符,假定变量 a 为 10,变量 b 为 20: ```shell 运算符 说明 举例 -eq 检测两个数是否相等,相等返回 true。 [ $a -eq $b ] 返回 false。 -ne 检测两个数是否相等,不相等返回 true。 [ $a -ne $b ] 返回 true。 -gt 检测左边的数是否大于右边的,如果是,则返回 true。 [ $a -gt $b ] 返回 false。 -lt 检测左边的数是否小于右边的,如果是,则返回 true。 [ $a -lt $b ] 返回 true。 -ge 检测左边的数是否大等于右边的,如果是,则返回 true。 [ $a -ge $b ] 返回 false。 -le 检测左边的数是否小于等于右边的,如果是,则返回 true。 [ $a -le $b ] 返回 true。 ``` **实例** 关系运算符实例如下: ```shell #!/bin/bash a=10 b=20 if [ $a -eq $b ] then echo "$a -eq $b : a 等于 b" else echo "$a -eq $b: a 不等于 b" fi if [ $a -ne $b ] then echo "$a -ne $b: a 不等于 b" else echo "$a -ne $b : a 等于 b" fi if [ $a -gt $b ] then echo "$a -gt $b: a 大于 b" else echo "$a -gt $b: a 不大于 b" fi if [ $a -lt $b ] then echo "$a -lt $b: a 小于 b" else echo "$a -lt $b: a 不小于 b" fi if [ $a -ge $b ] then echo "$a -ge $b: a 大于或等于 b" else echo "$a -ge $b: a 小于 b" fi if [ $a -le $b ] then echo "$a -le $b: a 小于或等于 b" else echo "$a -le $b: a 大于 b" fi ``` 执行脚本,输出结果如下所示: ```shell 10 -eq 20: a 不等于 b 10 -ne 20: a 不等于 b 10 -gt 20: a 不大于 b 10 -lt 20: a 小于 b 10 -ge 20: a 小于 b 10 -le 20: a 小于或等于 b ``` **布尔运算符** 下面列出了常用的布尔运算符,假定变量 a 为 10,变量 b 为 20: ```shell 运算符 说明 举例 ! 非运算,表达式为 true 则返回 false,否则返回 true。 [ ! false ] 返回 true。 -o 或运算,有一个表达式为 true 则返回 true。 [ $a -lt 20 -o $b -gt 100 ] 返回 true。 -a 与运算,两个表达式都为 true 才返回 true。 [ $a -lt 20 -a $b -gt 100 ] 返回 false。 ``` **实例** 布尔运算符实例如下: ```shell #!/bin/bash a=10 b=20 if [ $a != $b ] then echo "$a != $b : a 不等于 b" else echo "$a != $b: a 等于 b" fi if [ $a -lt 100 -a $b -gt 15 ] then echo "$a -lt 100 -a $b -gt 15 : 返回 true" else echo "$a -lt 100 -a $b -gt 15 : 返回 false" fi if [ $a -lt 100 -o $b -gt 100 ] then echo "$a -lt 100 -o $b -gt 100 : 返回 true" else echo "$a -lt 100 -o $b -gt 100 : 返回 false" fi if [ $a -lt 5 -o $b -gt 100 ] then echo "$a -lt 100 -o $b -gt 100 : 返回 true" else echo "$a -lt 100 -o $b -gt 100 : 返回 false" fi ``` 执行脚本,输出结果如下所示: ```shell 10 != 20 : a 不等于 b 10 -lt 100 -a 20 -gt 15 : 返回 true 10 -lt 100 -o 20 -gt 100 : 返回 true 10 -lt 100 -o 20 -gt 100 : 返回 false ``` **逻辑运算符** 以下介绍 Shell 的逻辑运算符,假定变量 a 为 10,变量 b 为 20: ```shell 运算符 说明 举例 && 逻辑的 AND [[ $a -lt 100 && $b -gt 100 ]] 返回 false || 逻辑的 OR [[ $a -lt 100 || $b -gt 100 ]] 返回 true ``` **实例** 逻辑运算符实例如下: ```shell #!/bin/bash a=10 b=20 if [[ $a -lt 100 && $b -gt 100 ]] then echo "返回 true" else echo "返回 false" fi if [[ $a -lt 100 || $b -gt 100 ]] then echo "返回 true" else echo "返回 false" fi ``` 执行脚本,输出结果如下所示: ```shell 返回 false 返回 true ``` **字符串运算符** 下面列出了常用的字符串运算符,假定变量 a 为 "abc",变量 b 为 "efg": ```shell 运算符 说明 举例 = 检测两个字符串是否相等,相等返回 true。 [ $a = $b ] 返回 false。 != 检测两个字符串是否相等,不相等返回 true。 [ $a != $b ] 返回 true。 -z 检测字符串长度是否为0,为0返回 true。 [ -z $a ] 返回 false。 -n 检测字符串长度是否为0,不为0返回 true。 [ -n $a ] 返回 true。 str 检测字符串是否为空,不为空返回 true。 [ $a ] 返回 true。 ``` **实例** ```shell #!/bin/bash a="abc" b="efg" if [ $a = $b ] then echo "$a = $b : a 等于 b" else echo "$a = $b: a 不等于 b" fi if [ $a != $b ] then echo "$a != $b : a 不等于 b" else echo "$a != $b: a 等于 b" fi if [ -z $a ] then echo "-z $a : 字符串长度为 0" else echo "-z $a : 字符串长度不为 0" fi if [ -n $a ] then echo "-n $a : 字符串长度不为 0" else echo "-n $a : 字符串长度为 0" fi if [ $a ] then echo "$a : 字符串不为空" else echo "$a : 字符串为空" fi ``` 执行脚本,输出结果如下所示: ```shell abc = efg: a 不等于 b abc != efg : a 不等于 b -z abc : 字符串长度不为 0 -n abc : 字符串长度不为 0 abc : 字符串不为空 ``` **文件测试运算符** 文件测试运算符用于检测 Unix 文件的各种属性。 属性检测描述如下: ```shell 操作符 说明 举例 -b file 检测文件是否是块设备文件,如果是,则返回 true。 [ -b $file ] 返回 false。 -c file 检测文件是否是字符设备文件,如果是,则返回 true。 [ -c $file ] 返回 false。 -d file 检测文件是否是目录,如果是,则返回 true。 [ -d $file ] 返回 false。 -f file 检测文件是否是普通文件(既不是目录,也不是设备文件),如果是,则返回 true。 [ -f $file ] 返回 true。 -g file 检测文件是否设置了 SGID 位,如果是,则返回 true。 [ -g $file ] 返回 false。 -k file 检测文件是否设置了粘着位(Sticky Bit),如果是,则返回 true。 [ -k $file ] 返回 false。 -p file 检测文件是否是具名管道,如果是,则返回 true。 [ -p $file ] 返回 false。 -u file 检测文件是否设置了 SUID 位,如果是,则返回 true。 [ -u $file ] 返回 false。 -r file 检测文件是否可读,如果是,则返回 true。 [ -r $file ] 返回 true。 -w file 检测文件是否可写,如果是,则返回 true。 [ -w $file ] 返回 true。 -x file 检测文件是否可执行,如果是,则返回 true。 [ -x $file ] 返回 true。 -s file 检测文件是否为空(文件大小是否大于0),不为空返回 true。 [ -s $file ] 返回 true。 -e file 检测文件(包括目录)是否存在,如果是,则返回 true。 [ -e $file ] 返回 true。 ``` **实例** 变量 file 表示文件 "/var/www/runoob/test.sh",它的大小为 100 字节,具有 rwx 权限。下面的代码,将检测该文件的各种属性: ```shell #!/bin/bash file="/var/www/runoob/test.sh" if [ -r $file ] then echo "文件可读" else echo "文件不可读" fi if [ -w $file ] then echo "文件可写" else echo "文件不可写" fi if [ -x $file ] then echo "文件可执行" else echo "文件不可执行" fi if [ -f $file ] then echo "文件为普通文件" else echo "文件为特殊文件" fi if [ -d $file ] then echo "文件是个目录" else echo "文件不是个目录" fi if [ -s $file ] then echo "文件不为空" else echo "文件为空" fi if [ -e $file ] then echo "文件存在" else echo "文件不存在" fi ``` 执行脚本,输出结果如下所示: ```shell 文件可读 文件可写 文件可执行 文件为普通文件 文件不是个目录 文件不为空 文件存在 ``` ## echo 命令 Shell 的 echo 指令与 PHP 的 echo 指令类似,都是用于字符串的输出。 命令格式: ```shell echo string ``` 您可以使用 echo 实现更复杂的输出格式控制。 **1.显示普通字符串:** ```shell echo "It is a test" ``` 这里的双引号完全可以省略,以下命令与上面实例效果一致: ```shell echo It is a test ``` **2.显示转义字符** ```shell echo "\"It is a test\"" ``` 结果将是: ```shell "It is a test" ``` 同样,双引号也可以省略 **3.显示变量** read 命令从标准输入中读取一行,并把输入行的每个字段的值指定给 shell 变量: ```shell #!/bin/sh read name echo "$name It is a test" ``` 以上代码保存为 test.sh,name 接收标准输入的变量,结果将是: ```shell [root@www ~]# sh test.sh OK #标准输入 OK It is a test #输出 ``` **4.显示换行** ```shell echo -e "OK! \n" # -e 开启转义 echo "It it a test" ``` 输出结果: ```shell OK! It it a test ``` **5.显示不换行** ```shell #!/bin/sh echo -e "OK! \c" # -e 开启转义 \c 不换行 echo "It is a test" ``` 输出结果: ```shell OK! It is a test ``` **6.显示结果定向至文件** ```shell echo "It is a test" > myfile ``` **7.原样输出字符串,不进行转义或取变量 (用单引号)** ```shell echo '$name\"' ``` 输出结果: ```shell $name\" ``` **8.显示命令执行结果** ```shell echo `date` ``` 结果将显示当前日期 ```shell Thu Jul 24 10:08:46 CST 2014 ``` ## printf 命令 printf 命令模仿 C 程序库(library)里的 printf() 程序。 标准所定义,因此使用 printf 的脚本比使用 echo 移植性好。 printf 使用引用文本或空格分隔的参数,外面可以在 printf 中使用格式化字符串,还可以制定字符串的宽度、左右对齐方式等。 默认 printf 不会像 echo 自动添加换行符,我们可以手动添加 n。 printf 命令的语法: ```shell printf format-string [arguments...] ``` 参数说明: - format-string: 为格式控制字符串 - arguments: 为参数列表。 **实例 1** ```shell $ echo "Hello, Shell" Hello, Shell $ printf "Hello, Shell\n" Hello, Shell $ ``` 接下来,我来用一个脚本来体现 printf 的强大功能: ```shell #!/bin/bash printf "%-10s %-8s %-4s\n" 姓名 性别 体重kg printf "%-10s %-8s %-4.2f\n" 郭靖 男 66.1234 printf "%-10s %-8s %-4.2f\n" 杨过 男 48.6543 printf "%-10s %-8s %-4.2f\n" 郭芙 女 47.9876 ``` 执行脚本,输出结果如下所示: ```shell 姓名 性别 体重kg 郭靖 男 66.12 杨过 男 48.65 郭芙 女 47.99 ``` ```shell %s %c %d %f都是格式替代符。 %-10s 指一个宽度为10个字符(-表示左对齐,没有则表示右对齐),任何字符都会被显示在10个字符宽的字符内,如果不足则自动以空格填充,超过也会将内容全部显示出来。 %-4.2f 指格式化为小数,其中.2指保留2位小数。 ``` **实例 2** ```shell #!/bin/bash # format-string为双引号 printf "%d %s\n" 1 "abc" # 单引号与双引号效果一样 printf '%d %s\n' 1 "abc" # 没有引号也可以输出 printf %s abcdef # 格式只指定了一个参数,但多出的参数仍然会按照该格式输出,format-string 被重用 printf %s abc def printf "%s\n" abc def printf "%s %s %s\n" a b c d e f g h i j # 如果没有 arguments,那么 %s 用NULL代替,%d 用 0 代替 printf "%s and %d \n" ``` 执行脚本,输出结果如下所示: ```shell 1 abc 1 abc abcdefabcdefabc def a b c d e f g h i j and 0 ``` **printf 的转义序列** ```shell 序列 说明 \a 警告字符,通常为ASCII的BEL字符 \b 后退 \c 抑制(不显示)输出结果中任何结尾的换行字符(只在%b格式指示符控制下的参数字符串中有效),而且,任何留在参数里的字符、任何接下来的参数以及任何留在格式字符串中的字符,都被忽略 \f 换页(formfeed) \n 换行 \r 回车(Carriage return) \t 水平制表符 \v 垂直制表符 \\ 一个字面上的反斜杠字符 \ddd 表示1到3位数八进制值的字符。仅在格式字符串中有效 \0ddd 表示1到3位的八进制值字符 ``` **实例** ```shell $ printf "a string, no processing:<%s>\n" "A\nB" a string, no processing: $ printf "a string, no processing:<%b>\n" "A\nB" a string, no processing: $ printf "www.runoob.com \a" www.runoob.com $ #不换行 ``` ## test 命令 Shell 中的 test 命令用于检查某个条件是否成立,它可以进行数值、字符和文件三个方面的测试。 **数值测试** ```shell 参数 说明 -eq 等于则为真 -ne 不等于则为真 -gt 大于则为真 -ge 大于等于则为真 -lt 小于则为真 -le 小于等于则为真 ``` **实例** ```shell num1=100 num2=100 if test $[num1] -eq $[num2] then echo '两个数相等!' else echo '两个数不相等!' fi ``` 输出结果: ```shell 两个数相等! ``` **字符串测试** ```shell 参数 说明 = 等于则为真 != 不相等则为真 -z 字符串 字符串的长度为零则为真 -n 字符串 字符串的长度不为零则为真 ``` **实例** ```shell num1="runoob" num2="runoob" if test num1=num2 then echo '两个字符串相等!' else echo '两个字符串不相等!' fi ``` 输出结果: ```shell 两个字符串相等! ``` **文件测试** ```shell 参数 说明 -e 文件名 如果文件存在则为真 -r 文件名 如果文件存在且可读则为真 -w 文件名 如果文件存在且可写则为真 -x 文件名 如果文件存在且可执行则为真 -s 文件名 如果文件存在且至少有一个字符则为真 -d 文件名 如果文件存在且为目录则为真 -f 文件名 如果文件存在且为普通文件则为真 -c 文件名 如果文件存在且为字符型特殊文件则为真 -b 文件名 如果文件存在且为块特殊文件则为真 ``` **实例** ```shell cd /bin if test -e ./bash then echo '文件已存在!' else echo '文件不存在!' fi ``` 输出结果: ```shell 文件已存在! ``` ```shell 另外,Shell还提供了与( -a )、或( -o )、非( ! )三个逻辑操作符用于将测试条件连接起来,其优先级为:"!"最高,"-a"次之,"-o"最低。 ``` 例如: ```shell cd /bin if test -e ./notFile -o -e ./bash then echo '有一个文件存在!' else echo '两个文件都不存在' fi ``` 输出结果: ```shell 有一个文件存在! ``` ## 流程控制 和 Java、PHP 等语言不一样,sh 的流程控制不可为空,如 (以下为 PHP 流程控制写法): ```shell 退出' echo -n '输入你最喜欢的电影名: ' while read FILM do echo "是的!$FILM 是一部好电影" done ``` 运行脚本,输出类似下面: ```shell 按下 退出 输入你最喜欢的电影名: w3cschool菜鸟教程 是的!w3cschool菜鸟教程 是一部好电影 ``` ## 无限循环 无限循环语法格式: ```shell while : do command done ``` 或者 ```shell while true do command done ``` 或者 ```shell for (( ; ; )) ``` ## until 循环 until 循环执行一系列命令直至条件为真时停止。 until 循环与 while 循环在处理方式上刚好相反。 一般 while 循环优于 until 循环,但在某些时候—也只是极少数情况下,until 循环更加有用。 until 语法格式: ```shell until condition do command done ``` 条件可为任意测试条件,测试发生在循环末尾,因此循环至少执行一次—请注意这一点。 ## case Shell case 语句为多选择语句。 可以用 case 语句匹配一个值与一个模式,如果匹配成功,执行相匹配的命令。 case 语句格式如下: ```shell case 值 in 模式1) command1 command2 ... commandN ;; 模式2) command1 command2 ... commandN ;; esac ``` case 工作方式如上所示。 取值后面必须为单词 in,每一模式必须以右括号结束。 取值可以为变量或常数。 匹配发现取值符合某一模式后,其间所有命令开始执行直至 ;;。 取值将检测匹配的每一个模式。 一旦模式匹配,则执行完匹配模式相应命令后不再继续其他模式。 如果无一匹配模式,使用星号 `*` 捕获该值,再执行后面的命令。 下面的脚本提示输入 1 到 4,与每一种模式进行匹配: ```shell echo '输入 1 到 4 之间的数字:' echo '你输入的数字为:' read aNum case $aNum in 1) echo '你选择了 1' ;; 2) echo '你选择了 2' ;; 3) echo '你选择了 3' ;; 4) echo '你选择了 4' ;; *) echo '你没有输入 1 到 4 之间的数字' ;; esac ``` 输入不同的内容,会有不同的结果,例如: ```shell 输入 1 到 4 之间的数字: 你输入的数字为: 3 你选择了 3 ``` **esac** case 的语法和 C family 语言差别很大,它需要一个 esac(就是 case 反过来)作为结束标记,每个 case 分支用右圆括号,用两个分号表示 break。 ## 跳出循环 在循环过程中,有时候需要在未达到循环结束条件时强制跳出循环,Shell 使用两个命令来实现该功能:break 和 continue。 **break 命令** break 命令允许跳出所有循环(终止执行后面的所有循环)。 下面的例子中,脚本进入死循环直至用户输入数字大于 5。 要跳出这个循环,返回到 shell 提示符下,需要使用 break 命令。 ```shell #!/bin/bash while : do echo -n "输入 1 到 5 之间的数字:" read aNum case $aNum in 1|2|3|4|5) echo "你输入的数字为 $aNum!" ;; *) echo "你输入的数字不是 1 到 5 之间的! 游戏结束" break ;; esac done ``` 执行以上代码,输出结果为: ```shell 输入 1 到 5 之间的数字:3 你输入的数字为 3! 输入 1 到 5 之间的数字:7 你输入的数字不是 1 到 5 之间的! 游戏结束 ``` **continue** continue 命令与 break 命令类似,只有一点差别,它不会跳出所有循环,仅仅跳出当前循环。 对上面的例子进行修改: ```shell #!/bin/bash while : do echo -n "输入 1 到 5 之间的数字: " read aNum case $aNum in 1|2|3|4|5) echo "你输入的数字为 $aNum!" ;; *) echo "你输入的数字不是 1 到 5 之间的!" continue echo "游戏结束" ;; esac done ``` 运行代码发现,当输入大于 5 的数字时,该例中的循环不会结束,语句 echo "Game is over!" 永远不会被执行。 ## 函数 linux shell 可以用户定义函数,然后在 shell 脚本中可以随便调用。 shell 中函数的定义格式如下: ```shell [ function ] funname [()] { action; [return int;] } ``` 说明: 1. 可以带 function fun() 定义,也可以直接 fun() 定义,不带任何参数。 2. 参数返回,可以显示加:return 返回,如果不加,将以最后一条命令运行结果,作为返回值。 return 后跟数值 n(0-255 下面的例子定义了一个函数并进行调用: ```shell #!/bin/bash demoFun(){ echo "这是我的第一个 shell 函数!" } echo "-----函数开始执行-----" demoFun echo "-----函数执行完毕-----" ``` 输出结果: ```shell -----函数开始执行----- 这是我的第一个 shell 函数! -----函数执行完毕----- ``` 下面定义一个带有 return 语句的函数: ```shell #!/bin/bash funWithReturn(){ echo "这个函数会对输入的两个数字进行相加运算..." echo "输入第一个数字: " read aNum echo "输入第二个数字: " read anotherNum echo "两个数字分别为 $aNum 和 $anotherNum !" return $(($aNum+$anotherNum)) } funWithReturn echo "输入的两个数字之和为 $? !" ``` 输出类似下面: ```shell 这个函数会对输入的两个数字进行相加运算... 输入第一个数字: 1 输入第二个数字: 2 两个数字分别为 1 和 2 ! 输入的两个数字之和为 3 ! ``` 函数返回值在调用该函数后通过 $? 来获得。 注意: - 所有函数在使用前必须定义。 - 这意味着必须将函数放在脚本开始部分,直至 shell 解释器首次发现它时,才可以使用。 - 调用函数仅使用其函数名即可。 **函数参数** 在 Shell 中,调用函数时可以向其传递参数。 在函数体内部,通过 n 的形式来获取参数的值,例如,1 表示第一个参数,$2 表示第二个参数… 带参数的函数示例: ```shell #!/bin/bash funWithParam(){ echo "第一个参数为 $1 !" echo "第二个参数为 $2 !" echo "第十个参数为 $10 !" echo "第十个参数为 ${10} !" echo "第十一个参数为 ${11} !" echo "参数总数有 $# 个!" echo "作为一个字符串输出所有参数 $* !" } funWithParam 1 2 3 4 5 6 7 8 9 34 73 ``` 输出结果: ```shell 第一个参数为 1 ! 第二个参数为 2 ! 第十个参数为 10 ! 第十个参数为 34 ! 第十一个参数为 73 ! 参数总数有 11 个! 作为一个字符串输出所有参数 1 2 3 4 5 6 7 8 9 34 73 ! ``` 注意,10 不能获取第十个参数,获取第十个参数需要{10}。当 n>=10 时,需要使用 ${n}来获取参数。 另外,还有几个特殊字符用来处理参数: ```shell 参数处理 说明 $# 传递到脚本的参数个数 $* 以一个单字符串显示所有向脚本传递的参数 $ 脚本运行的当前进程ID号 $! 后台运行的最后一个进程的ID号 $@ 与$*相同,但是使用时加引号,并在引号中返回每个参数。 $- 显示Shell使用的当前选项,与set命令功能相同。 $? 显示最后命令的退出状态。0表示没有错误,其他任何值表明有错误。 ``` ## 输入/输出重定向 大多数 UNIX 系统命令从你的终端接受输入并将所产生的输出发送回 ​​ 到您的终端。 一个命令通常从一个叫标准输入的地方读取输入,默认情况下,这恰好是你的终端。 同样,一个命令通常将其输出写入到标准输出,默认情况下,这也是你的终端。 重定向命令如下: ```shell 命令 说明 command > file 将输出重定向到 file。 command < file 将输入重定向到 file。 command >> file 将输出以追加的方式重定向到 file。 n > file 将文件描述符为 n 的文件重定向到 file。 n >> file 将文件描述符为 n 的文件以追加的方式重定向到 file。 n >& m 将输出文件 m 和 n 合并。 n <& m 将输入文件 m 和 n 合并。 << tag 将开始标记 tag 和结束标记 tag 之间的内容作为输入。 ``` ```shell 需要注意的是文件描述符 0 通常是标准输入(STDIN),1 是标准输出(STDOUT),2 是标准错误输出(STDERR)。 ``` **输出重定向** 重定向一般通过在命令间插入特定的符号来实现。特别的,这些符号的语法如下所示: ```shell command1 > file1 ``` 上面这个命令执行 command1 然后将输出的内容存入 file1。 注意:任何 file1 内的已经存在的内容将被新内容替代。如果要将新内容添加在文件末尾,请使用>>操作符。 **实例** 执行下面的 who 命令,它将命令的完整的输出重定向在用户文件中 (users): ```shell $ who > users ``` 执行后,并没有在终端输出信息,这是因为输出已被从默认的标准输出设备(终端)重定向到指定的文件。 你可以使用 cat 命令查看文件内容: ```shell $ cat users _mbsetupuser console Oct 31 17:35 tianqixin console Oct 31 17:35 tianqixin ttys000 Dec 1 11:33 ``` 输出重定向会覆盖文件内容,请看下面的例子: ```shell $ echo "测试测试:www.runoob.com" > users $ cat users 测试测试:www.runoob.com $ ``` 如果不希望文件内容被覆盖,可以使用 >> 追加到文件末尾,例如: ```shell $ echo "测试测试:www.runoob.com" >> users $ cat users 测试测试:www.runoob.com 测试测试:www.runoob.com $ ``` **输入重定向** 和输出重定向一样,Unix 命令也可以从文件获取输入,语法为: ```shell command1 < file1 ``` 这样,本来需要从键盘获取输入的命令会转移到文件读取内容。 注意:输出重定向是大于号 (>),输入重定向是小于号 ( outfile 同时替换输入和输出,执行 command1,从文件 infile 读取内容,然后将输出写入到 outfile 中。 **重定向深入理解** 一般情况下,每个 Unix/Linux 命令运行时都会打开三个文件: - 标准输入文件 (stdin):stdin 的文件描述符为 0,Unix 程序默认从 stdin 读取数据。 - 标准输出文件 (stdout):stdout 的文件描述符为 1,Unix 程序默认向 stdout 输出数据。 - 标准错误文件 (stderr):stderr 的文件描述符为 2,Unix 程序会向 stderr 流中写入错误信息。 默认情况下,command > file 将 stdout 重定向到 file,command ## [Shell脚本编写全攻略:从零开始](https://blog.dong4j.site/posts/a77b2cc.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 定义变量 ```shell #!/bin/bash # 定义 变量名和等号之间不能有空格 your_name="dong4j" # 使用 echo ${your_name} # 循环输出 for skill in Ada Coffe Action Java do echo "I am good at ${skill} Script" done ``` ## 只读变量 readonly 变量名 ## 删除变量 unset 变量名 ## 特殊变量 ``` $0 当前脚本的文件名 $n 传递给脚本或函数的参数。n 是一个数字,表示第几个参数。例如,第一个参数是$1,第二个参数是$2。 $# 传递给脚本或函数的参数个数。 $* 传递给脚本或函数的所有参数。 $@ 传递给脚本或函数的所有参数。被双引号(" ")包含时,与 $* 稍有不同,下面将会讲到。 $? 上个命令的退出状态,或函数的返回值。 $$ 当前 Shell 进程 ID。对于 Shell 脚本,就是这些脚本所在的进程 ID。 ``` ## shell 替换 ### 命令替换 ``` #!/bin/bash DATE=`date` echo "Date is $DATE" USERS=`who | wc -l` echo "Logged in user are $USERS" UP=`date ; uptime` echo "Uptime is $UP" ``` ### 变量替换 ``` #!/bin/bash # 如果变量为空或被删除(unset),则返回 word,不改变 var的值 echo ${var:-"Variable is not set"} echo "1 - Value of var is ${var}" # 如果变量为空或被删除(unset),则返回 word,改变 var的值为 word echo ${var:="Variable is not set"} echo "2 - Value of var is ${var}" unset var # 如果变量 var 被定义,那么返回 word,但不改变 var 的值。 echo ${var:+"This is default value"} echo "3 - Value of var is ${var}" var="Prefix" echo ${var:+"This is default value"} echo "4 - Value of var is ${var}" # 如果变量 var 为空或已被删除(unset),那么将消息 message 送到标 准错误输出,可以用来检测变 量 var 是否可以被正常赋值。若此替换出现在 Shell 脚本中,那么脚本将停止运行。 echo ${var:?"Print this message"} echo "5 - Value of var is ${var}" ``` ## shell 运算符 ### 算数运算符 ```bash #!/bin/bash a=10 b=20 val=`expr $a + $b` echo "a + b : $val" val=`expr $a - $b` echo "a - b : $val" val=`expr $a * $b` echo "a * b : $val" val=`expr $b / $a` echo "b / a : $val" val=`expr $b % $a` echo "b % a : $val" # 条件表达式放在[]中,且前后必须留空格 if [ $a == $b ] then echo "a is equal to b" fi if [ $a != $b ] then echo "a is not equal to b" fi ``` ### 关系运算符 ``` #!/bin/bash a=10 b=20 if [ $a -eq $b ] then echo "$a -eq $b : a is equal to b" else echo "$a -eq $b: a is not equal to b" fi ``` `-eq`: 检测两个数是否相等,相等返回 true。 ```shell [ $a -eq $b ] ``` 返回 true。 `-ne`: 检测两个数是否相等,不相等返回 true。 ```shell [ $a -ne $b ] ``` 返回 true。 `-gt`: 检测左边的数是否大于右边的,如果是,则返回 true。 ```shell [ $a -gt $b ] ``` 返回 false。 `-lt`: 检测左边的数是否小于右边的,如果是,则返回 true。 ```shell [ $a -lt $b ] ``` 返回 true。 `-ge`: 检测左边的数是否大等于右边的,如果是,则返回 true。 ```shell [ $a -ge $b ] ``` 返回 false。 `-le`: 检测左边的数是否小于等于右边的,如果是,则返回 true。 ```shell [ $a -le $b ] ``` 返回 true。 ### 布尔运算符 ! : 非 -o : 或 有一个为 true 则返回 true -a : 与 两个表达式都为 true 才返回 true ### 字符串运算符 `=` : 检测两个字符串是否相等,相等返回 true。 `[ $a = $b ]` 返回 false。 `!=` : 检测两个字符串是否相等,不相等返回 true。`[ $a != $b ]` 返回 true。 `-z` : 字符串长度为 0 时返回 true `[ -z ${str}] ` `-n` : 长度不为零时返回 true `str` 检测字符串是否为空, 不为空返回 true `[ ${str} ]` ### 文件测试运算符 -b : 是否为块设备文件 -c : 是否为字符设备文件 -d : 是否为目录 -f : 是否为普通文件 (不是目录也不是设备文件) -g : 是否设置了 SGID 位 -k : 是否设置了沾着位 (Sticky Bit) -p : 是否是具名管道 -u : 是否设置了 SUID 位 -r : 是否可读 -w : 是否可写 -x : 是否可执行 -s : 文件大小是否为空 不为空返回 true -e : 文件 (包括目录) 是否存在,存在返回 true ## shell 字符串处理 ### 单引号 `str='this is a string'` 单引号字符串的限制: 单引号里的任何字符都会原样输出,单引号字符串中的变量是无效的; 单引号字串中不能 出现单引号 (对单引号使用转义符后也不行)。 ### 双引号 ```shell your_name='qinjx' str="Hello, I know your are \"${your_name}\"! \n" ``` 双引号的优点: 双引号里可以有变量 双引号里可以出现转义字符 ### 获取字符串长度 ```shell string="abcd" # 输出4 echo ${#string} ``` ### 提取子字符串 ```shell string="alibaba is a great company" # 输出 liba 有点像 python 的切片 echo ${string:1:4} ``` ### 查询子字符串 ```shell string="alibaba is a great company" echo `expr index "${string}" is` ``` ## 数组 ### 定义 使用空格分割元素 `array_name=(value1 value2….)` 或者 ```shell array_name=( value0 value1 value2 ... ) ``` 或者单独设置 ```shell array_name[0]=value0 array_name[1]=value1 array_name[2]=value2 ``` ### 读取数组数据 1. 获取指定元素 `${array_name[index]}` 2. 获取全部元素 `${array_name[*]}` 或者 `${array_name[@]}` ### 获取数组长度 ```shell length=${#array_name[*]} # 或者 length=${#array_name[@]} # 获取指定元素的长度 lengthn=${#array_name[index]} ``` ## echo 命令 `echo "\"It is a test\"" --> "It is a test"` 显示结果重定向至文件 `echo "It is a test" > myfile` 原样输出字符串 使用单引号 `echo '@name\" '` 显示命令执行结果 `echo `date`` ## printf 命令 格式化输出语句 ```shell # format-string 为双引号 $ printf "%d %s\n" 1 "abc" 1 abc # 单引号与双引号效果一样 $ printf '%d %s\n' 1 "abc" 1 abc # 没有引号也可以输出 $ printf %s abcdef abcdef # 格式只指定了一个参数,但多出的参数仍然会按照该格式输出,format-string 被重用 $ printf %s abc def abcdef $ printf "%s\n" abc def abc def $ printf "%s %s %s\n" a b c d e f g h i j abc def ghi j # 如果没有 arguments,那么 %s 用 NULL 代替,%d 用0代替 $ printf "%s and %d \n" and 0 # 如果以 %d 的格式来显示字符串,那么会有警告,提示无效的数字,此时默认置为0 $ printf "The first program always prints'%s,%d\n'" Hello Shell -bash: printf: Shell: invalid number The first program always prints 'Hello,0' $ ``` ## if else 语句 ### if .. eles ```shell if [ expression ] then do something if ``` ### if .. else .. fi ```shell if [ expression ] then Statement(s) to be executed if expression is true else Statement(s) to be executed if expression is not true fi ``` ### if .. elif .. fi ```shell if [ expression 1 ] then Statement(s) to be executed if expression 1 is true elif [ expression 2 ] then Statement(s) to be executed if expression 2 is true elif [ expression 3 ] then Statement(s) to be executed if expression 3 is true else Statement(s) to be executed if no expression is true fi ``` ## shell case esac (switch case) ```shell case 值 in 模式1) command1 command2 command3 ;; 模式2) command1 command2 command3 ;; *) command1 command2 command3 ;; esac ``` tomcat 命令 ```shell #!/bin/bash # $0 代表 sh 文件名 # $1 第一个参数 如果为 start 则 case $1 in start) sh /Library/Tomcat/bin/startup.sh ;; stop) sh /Library/Tomcat/bin/shutdown.sh ;; restart) sh /Library/Tomcat/bin/shutdown.sh sh /Library/Tomcat/bin/startup.sh ;; *) echo “Usage: start|stop|restart” ;; esac exit 0 ``` ## shell for ```shell for 变量 in 列表 do command1 command2 ... done ``` 列表可以是一组值 (数字 字符串等) 组成的序列,四通空格分隔 ## shell while ```shell while command do Statement(s) to be executed if command is true done ``` ```shell COUNTER=0 while [ $COUNTER -lt 5 ] do COUNTER='expr $COUNTER+1' echo $COUNTER done ``` ```shell # 将从键盘读到的值给 FILM while read FILM do echo "Yeah! great film the $FILM" done ``` ## shell until 循环 与 while 相反 ```shell until command do Statement(s) to be executed until command is true done ``` ## shell break 和 continue ```shell #!/bin/bash while : do echo -n "Input a number between 1 to 5: " read aNum case $aNum in 1|2|3|4|5) echo "Your number is $aNum!" ;; *) echo "You do not select a number between 1 to 5, game is over!" break ;; esac done # 跳出第二层循环 从内往外数 for var1 in 1 2 3 do for var2 in 0 5 do if [ $var1 -eq 2 -a $var2 -eq 0 ] then break 2 else echo "$var1 $var2" fi done done ``` continue 与其他语言一样 ## shell 中的函数 先定义 后使用 ```shell (function) function_name () { list of commands # 如果不加 return 语句,默认将最后一条命令的结果作为返回值 [ return value ] } ``` ```shell #!/bin/bash funWithReturn(){ echo "The function is to get the sum of two numbers..." echo -n "Input first number: " read aNum echo -n "Input another number: " read anotherNum echo "The two numbers are $aNum and $anotherNum !" return $(($aNum+$anotherNum)) } funWithReturn # Capture value returnd by last command ret=$1 echo "The sum of two numbers is $ret !" ``` 删除函数 `unset.f function_name` 如果你希望直接从终端调用函数,可以将函数定义在主目录下的 .profile 文件,这样每次登录后,在命令提示符 后面输入函数名字就可以立即调用。 ## shell 函数参数 在 Shell 中,调用函数时可以向其传递参数。在函数体内部,通过 $n 的形式来获取参数的值,例如,$1 表示第 一个参数,$2 表示第二个参数… ```shell #!/bin/bash funWithParam(){ echo "The value of the first parameter is $1 !" echo "The value of the second parameter is $2 !" echo "The value of the tenth parameter is $10 !" echo "The value of the tenth parameter is ${10} !" echo "The value of the eleventh parameter is ${11} !" echo "The amount of the parameters is $# !" # 参数个数 echo "The string of the parameters is $* !" # 传递给函数的所有参数 } funWithParam 2 2 3 4 5 6 7 8 9 34 73 ``` 注意,$10 不能获取第十个参数,获取第十个参数需要 ${10}。当 n>=10 时,需要使用 ${n} 来获取参数。 ``` $# 传递给函数的参数个数。 $_ 显示所有传递给函数的参数。 $@ 与 $_ 相同,但是略有区别,请查看 Shell 特殊变量。 $? 函数的返回值。 ``` ## shell 输出重定向 /dev/null Unix 命令默认从标准输入设备 (stdin) 获取输入,将结果输出到标准输出设备 (stdout) 显示。一般情况下,标准输 入设备就是键盘,标准输出设备就是终端,即显示器。 `>` filename 重定向到文件 `>>` filename 追加内容到文件 `<` filename 从文件获取输入 command > file 将输出重定向到 file。 command < file 将输入重定向到 file。 command >> file 将输出以追加的方式重定向到 file。 n > file 将文件描述符为 n 的文件重定向到 file。 n >> file 将文件描述符为 n 的文件以追加的方式重定向到 file。 n >& m 将输出文件 m 和 n 合并。 n <& m 将输入文件 m 和 n 合并。 << tag 将开始标记 tag 和结束标记 tag 之间的内容作为输入。 ## shell here document ```shell command << delimiter document delimiter ``` ```shell #!/bin/sh filename=test.txt vi $filename < /dev/null /dev/null 是一个特殊的文件,写入到它的内容都会被丢弃; 如果尝试从该文件读取内容,那么什么也读不到。但 是 /dev/null 文件非常有用,将命令的输出重定向到它,会起到”禁止输出“的效果。 ## shell 包含其他 sh 文件 像其他语言一样,Shell 也可以包含外部脚本,将外部脚本的内容合并到当前脚本。 ```shell # sub.sh my_name = "dong4j" # main.sh . sub.sh echo $my_name ``` 注意: 被包含脚本不需要有执行权限。 ## [Web app开发:成本与体验的权衡之道](https://blog.dong4j.site/posts/390ff665.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 最近在做一个社交化的在线商店, 有 Android 和 iOS 客户端, 后台使用 java, 我主要负责接口设计和实现. 目前产品已经上线, 现在主要是前端的业务流程优化, 后台的优化和需求更改. 客户端使用 Web app + Native app 的形式. 对于菜鸟级我的来说, 以前的小项目都是 10 多张表就搞定了, 现在这个项目有 170 多张表, 想来要完全熟悉全部的业务流程需要花写功夫了. 以前都是写的 Java Web 项目, 现在一下来写 Web app 项目有点不习惯, 主要是测试有点麻烦. 接下来, 我将写写来对项目的总体认识以及 Web app 开发和 Java Web 开发的区别 (其实也没多大区别), 算是做一个总结吧. ### Web app 和 Native app 的区别 **Native App:** 1. 开发成本非常大。一般使用的开发语言为 Java、C++、Objective-C。 2. 更新体验较差、同时也比较麻烦。每一次发布新的版本,都需要做版本打包,且需要用户手动更新(有些应用程序即使不需要用户手动更新,但是也需要有一个恶心的提示)。 3. 非常酷。因为 Native app 可以调用 iOS 中的 UI 控件以 UI 方法,它可以实现 Web app 无法实现的一些非常酷的交互效果。 4. Native app 是被 Apple 认可的。Native app 可以被 Apple 认可为一款可信任的独立软件,可以放在 Apple Stroe 出售,但是 Web app 却不行。 **Web App:** 1. 开发成本较低。使用 Web 开发技术就可以轻松的完成 Web app 的开发。 2. 升级较简单。升级不需要通知用户,在服务端更新文件即可,用户完全没有感觉。 3. 维护比较轻松。和一般的 Web 一样,维护比较简单,它其实就是一个站点。 Web app 说白了就是一个针对 iPhone、Android 优化后的 Web 站点,它使用的技术无非就是 HTML 或 HTML5、CSS3、JavaScript,服务端技术 Java、PHP、ASP。 说到这里, 我曾经想过, 为什么不直接写一个 Java Web 项目, 然后做一个 Web app 直接访问这个网站, 只要网站是响应式的, 这样就不需要做多少更改让手机和 PC 都能以较好的方式访问, 不用针对手机特定来开发一款 app. 但是查了下资料, 这种方式有很多弊端: 1. 最最重要的一点, 这种 app 其实就是一个浏览器, 虽然开发速度快, 但是不可能通过 AppStore 的审核, 国内几个较大的 Android 市场也通过不了; 2. 做 app 就是做良心, 给人以最好的用户体验, 如果网络环境不佳的话, 就是一个大白页, 用户体验值为 0; 3. 因为 CSS 和 JS 资源不在本地, 需要远程载入, 如果使用比较大的前端框架, 如 bootstrap 之类时, 加上现在 4G 的普及, 用户使用 app 逛下商城, 买买东西, 一套房子就没用了…. 4. 在网页中经常使用的 jQuery 框架在 webview 里的速度并不理想, 再如果不是 ajax 的网页请求, 每次操作都要条抓和网页渲染, 想想我也是醉了. 所以说为了尽快上线而不管用户体验的老板都不是司机. 我们公司的老板还行, 没用使用这种方式, 而是使用 Hrbird app(老板声音有点像杰森 · 斯坦森, 不知道是不是嗨歌嗨到嗓子哑了….) ### Java Web 与 Web app 数据交互的区别 对于 Java Web 开发, 客户端向服务器发出请求, 给上一些参数, 然后查询数据库, 返回数据到前台, 这个流程也同样适用于 Web app, 只是 Web app 更多的是直接返回 json 格式的数据. 我们常说的 app 接口开发, 对于初学者, 可能一下就想到了 interface(其实我也是这么想的), 但是这是不准确的. 比如现在我负责的接口开发, 是要负责从 Controller 到 Service 到数据库最后返回 json 数据这么一整条流程的开发. 当用户点击 app 上一个按钮或者查看详细信息时, app 会调用我们写好的接口 (其实说白了就是一 URL), 然后经过 DispatcherServlet 根据 URL 调用特定的 Controller 进行处理, 最后返回 json 数据给 app. **举个最简单的例子, 用户登录.** 一个 web 系统, 用户通过浏览器把用户名密码传递到 web 服务端, 也就是后台, 那么这里就有个 URL 比如叫 `XXX\user\login.action?userid=zhangsan&pwd=123` 然后你后台就能获取到 userid 和 pwd 然后就能返回给浏览器成功与否. 同样的道理, app 调用 `XXX\user\login.action?userid=zhangsan&pwd=123` 你后台一样能获取到 userid 和 pwd 那么你就可以给安卓 APP 返回信息告诉他登录成功与否. 所以这里所谓的接口, 就是一个 URL , 接口返回 json 或 xml 就可以了,然后你开发的当然是知道接口的 url 了,还有接口的传参,这样就可以让前端调用了。 告诉前端,你的 url 地址,需要给这个接口传什么参数,返回参数是什么(返回他们可以测试得到,不过最好还是先告诉他们),字段说明,这样就可以交互了. ### 说说客户端的登录 来公司的第一天就看的实现登录的代码, 与 Java web 最大的区别就是一般 web 是使用 session 验证登录状态,而 app 则使用 token 来验证登录状态(token 是自己定义的一个和用户 ID 相关的加密字符串,传入后台后从数据库查询用户信息)。还有如果对安全性要求较高,app 传输数据时可能会对数据进行加密,而 web 一般没有这一步,web 的加密一般是使用 https。 #### 不安全的方式 **在 app 中保存登录数据, 每次调用接口时传输** 为了偷懒, 程序员什么都干得出来. 在用户登录之后, 直接把用户名和密码保存在本地, 然后每次调用后端接口时作为参数传递, 但是这种方式及其不安全, 随便那个嗅探器就可以把用户名密码弄到手, 如果用户习惯所有的地方都使用同一用户名和密码, 那么黑客通过撞库的方式能把用户的所有信息一锅端 (这也是为什么不要使用公共场合的 wifi). #### 安全的登录方式 **登录时请求一次 token, 之后用 token 调用接口 (公司就是这么搞的)** 大体的流程: 1. 用户输入电话号码和密码 2. 在数据库中查询有无此电话号码, 没有则提示错误; 有则再验证密码是否正确; 3. 如果登录成功, 调用 api 生成 token(使用 Base64 加密, 使用 URLEncoder 解决中文参数问题) ; 4. 将 key = 客户 ID,value=token 放入 redis 缓存 5. 返回客户信息 以后用户请求时, 都会带上 token 这个参数, 当 token 过时后将跳转到登录界面重新登录 ### 遇到的问题 #### 在 windows 下搭建 redis 服务器 项目中用到了 redis 作用缓存服务器, 以前从来没有使用过, 所以学习了下载 windows 下搭建 redis 服务器. redis 是一个开源的、使用 C 语言编写的、支持网络交互的、可基于内存也可持久化的 Key-Value 数据库。这里就不做过多介绍了, 有兴趣可以去百度一下. 由于 Redis 官网不支持 windows 的, 只是 Microsoft Open Tech group 在 GitHub 上开发了一个 Win64 的版本, 项目地址是: [https://github.com/MSOpenTech/redis](https://github.com/MSOpenTech/redis) 打开以后,可以直接使用浏览器下载,或者 git 克隆。 可以在项目主页右边找到 zip 包下载 下载解压,没什么好说的,在解压后的 bin 目录下有以下这些文件: ``` redis-benchmark.exe #基准测试 redis-check-aof.exe # aof redis-check-dump.exe # dump redis-cli.exe # 客户端 redis-server.exe # 服务器 redis.windows.conf # 配置文件 ``` 启动服务器命令: redis-server redis.windows.conf 可以写一个批处理命令, 以后直接双击启动, 关闭 cmd 窗口就关闭了服务器 我第一次启动报错了 > redis-server.exe redis.windows.conf > [7736] 10 Aug 21:39:42.974 # > The Windows version of Redis allocates a large memory mapped file for sharing > the heap with the forked process used in persistence operations. This file > will be created in the current working directory or the directory specified by > the ‘dir’ directive in the .conf file. Windows is reporting that there is > insufficient disk space available for this file (Windows error 0x70). > > You may fix this problem by either reducing the size of the Redis heap with > the –maxheap flag, or by starting redis from a working directory with > sufficient space available for the Redis heap. > > Please see the documentation included with the binary distributions for more > details on the –maxheap flag. > > Redis can not continue. Exiting. 根据提示,是 maxheap 标识有问题, 打开配置文件 redis.windows.conf , 搜索 maxheap , 然后直接指定好内容即可. ``` ....... # # maxheap maxheap 1024000000 ....... ``` 再次启动服务器 ![20241229154732_5mnRTEhd.webp](https://cdn.dong4j.site/source/image/20241229154732_5mnRTEhd.webp) 然后使用自带的客户端工具进行测试 ![20241229154732_mA8ER0ZT.webp](https://cdn.dong4j.site/source/image/20241229154732_mA8ER0ZT.webp) 成功了, 玩儿去吧 #### svn 版本控制问题 整个工程使用 Maven, 并使用 svn 进行版本控制, 但是当导入工程时报 svn 错误, 开始怀疑配置不正确, 进入 intellij 的 svn 设置, 去掉所有的复选框 ![20241229154732_baVEVepE.webp](https://cdn.dong4j.site/source/image/20241229154732_baVEVepE.webp) 还是报错, 查询资料后发现 1.8 使用了命令工具(command line client tools)默认安装 SVN 时是未安装此客户端的。 ![20241229154732_8P0pRg2X.webp](https://cdn.dong4j.site/source/image/20241229154732_8P0pRg2X.webp) 重新安装乌龟, 然后把命令工具选上, svn 配置不变, 都不勾选, 然后重启 IDEA 就可以了. ## [JDK版本差异分析:方法区和运行时常量池的演变](https://blog.dong4j.site/posts/27c94bc.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ## 相关特征 ### 方法区特征 - 同 Java 堆一样,方法区也是全局共享的一块内存区域 - 方法区的作用是存储 Java 类的结构信息,当我们创建对象实例后, **对象的类型信息存储在方法堆之中,实例数据存放在堆中;实例数据指的是在 Java 中创建的各种实例对象以及它们的值,类型信息指的是定义在 Java 代码中的常量、静态变量、以及在类中声明的各种方法、方法字段等等;同事可能包括即时编译器编译后产生的代码数据。** - JVMS 不要求该区域实现自动的内存管理,但是商用 JVM 一般都已实现该区域的自动内存管理。 - 方法区分配内存可以不连续,可以动态扩展。 - 该区域并非像 JMM 规范描述的那样数据一旦放进去就属于 “永久代”; **在该区域进行内存回收的主要目的是对常量池的回收和对内存数据的卸载;一般来说这个区域的内存回收效率比起 Java 堆要低得多。** - 当方法区无法满足内存需求时,将抛出 OutOfMemoryError 异常。 ### 运行时常量池的特征 - **运行时常量池是方法区的一部分,** 所以也是全局共享的。 - **其作用是存储 Java 类文件常量池中的符号信息。** - **class 文件中存在常量池 (非运行时常量池),其在编译阶段就已经确定;JVM 规范对 class 文件结构有着严格的规范,必须符合此规范的 class 文件才会被 JVM 认可和装载。** - **运行时常量池** 中保存着一些 class 文件中描述的符号引用,同时还会将这些符号引用所翻译出来的直接引用存储在 **运行时常量池** 中。 - **运行时常量池相对于 class 常量池一大特征就是其具有动态性,Java 规范并不要求常量只能在运行时才产生,也就是说运行时常量池中的内容并不全部来自 class 常量池,class 常量池并非运行时常量池的唯一数据输入口;在运行时可以通过代码生成常量并将其放入运行时常量池中。** - 同方法区一样,当运行时常量池无法申请到新的内存时,将抛出 OutOfMemoryError 异常。 ## HotSpot 方法区变迁 ### JDK1.2 ~ JDK6 在 JDK1.2 ~ JDK6 的实现中,HotSpot 使用永久代实现方法区;HotSpot 使用 GC 分代实现方法区带来了很大便利; ### JDK7 由于 GC 分代技术的影响,使之许多优秀的内存调试工具无法在 Oracle HotSpot 之上运行,必须单独处理;并且 Oracle 同时收购了 BEA 和 Sun 公司,同时拥有 JRockit 和 HotSpot,在将 JRockit 许多优秀特性移植到 HotSpot 时由于 GC 分代技术遇到了种种困难, **所以从 JDK7 开始 Oracle HotSpot 开始移除永久代。** **JDK7 中符号表被移动到 Native Heap 中,字符串常量和类引用被移动到 Java Heap 中。** ### JDK8 **在 JDK8 中,永久代已完全被元空间 (Meatspace) 所取代。** ## 永久代变迁产生的影响 #### 测试代码 1 ```java public class Test1 { public static void main(String[] args) { String s1 = new StringBuilder("漠").append("然").toString(); System.out.println(s1.intern() == s1); String s2 = new StringBuilder("漠").append("然").toString(); System.out.println(s2.intern() == s2); } } ``` 以上代码,在 JDK6 下执行结果为 false、false,在 JDK7 以上执行结果为 true、false。 **首先明确两点:** 1、在 Java 中直接使用双引号展示的字符串将会在常量池中直接创建。 2、String 的 intern 方法首先将尝试在常量池中查找该对象,如果找到则直接返回该对象在常量池中的地址;找不到则将该对象放入常量池后再返回其地址。**JDK6 常量池在方法区,频繁调用该方法可能造成 OutOfMemoryError。** **产生两种结果的原因:** 在 JDK6 下 s1、s2 指向的是新创建的对象, **该对象将在 Java Heap 中创建,所以 s1、s2 指向的是 Java Heap 中的内存地址;** 调用 intern 方法后将尝试在常量池中查找该对象,没找到后将其放入常量池并返回, **所以此时 s1/s2.intern() 指向的是常量池中的地址,JDK6 常量池在方法区,与堆隔离,;所以 `s1.intern()==s1 返回 false` 。** #### 测试代码 2 ```java public class Test2 { public static void main(String[] args) { /** * 首先设置 持久代最大和最小内存占用(限定为10M) * VM args: -XX:PermSize=10M -XX:MaxPremSize=10M */ List list = new ArrayList(); // 无限循环 使用 list 对其引用保证 不被GC intern 方法保证其加入到常量池中 int i = 0; while (true) { // 此处永久执行,最多就是将整个 int 范围转化成字符串并放入常量池 list.add(String.valueOf(i++).intern()); } } } ``` 以上代码在 JDK6 下会出现 Perm 内存溢出,JDK7 or high 则没问题。 **原因分析:** **JDK6 常量池存在方法区,设置了持久代大小后,不断 while 循环必将撑满 Perm 导致内存溢出;JDK7 常量池被移动到 Native Heap(Java Heap),所以即使设置了持久代大小,也不会对常量池产生影响;不断 while 循环在当前的代码中,所有 int 的字符串相加还不至于撑满 Heap 区,所以不会出现异常。** ## [final、finally和finalize:Java编程必备知识](https://blog.dong4j.site/posts/697d5753.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) final、finally 和 finalize 虽然长得像孪生三兄弟一样,但是它们的含义和用法却是大相径庭。这一次我们就一起来回顾一下这方面的知识。 **_final 关键字_** 我们首先来说说 final。它可以用于以下四个地方: 1. 定义变量,包括静态的和非静态的。 2. 定义方法的参数。 3. 定义方法。 4. 定义类。 我们依次来回顾一下每种情况下 final 的作用。首先来看第一种情况,如果 final 修饰的是一个基本类型,就表示这个变量被赋予的值是不可变 的,即它是个常量;如果 final 修饰的是一个对象,就表示这个变量被赋予的引用是不可变的,这里需要提醒大家注意的是,不可改变的只是这个变量所保存的 引用,并不是这个引用所指向的对象。在第二种情况下,final 的含义与第一种情况相同。实际上对于前两种情况,有一种更贴切的表述 final 的含义的描 述,那就是,如果一个变量或方法参数被 final 修饰,就表示它只能被赋值一次,但是 JAVA 虚拟机为变量设定的默认值不记作一次赋值。 被 final 修饰的变量必须被初始化。初始化的方式有以下几种: 1. 在定义的时候初始化。 2. final 变量可以在初始化块中初始化,不可以在静态初始化块中初始化。 3. 静态 final 变量可以在静态初始化块中初始化,不可以在初始化块中初始化。 4. final 变量还可以在类的构造器中初始化,但是静态 final 变量不可以。 通过下面的代码可以验证以上的观点: ```java public class FinalTest { // 在定义时初始化 public final int A = 10; public final int B; // 在初始化块中初始化 { B = 20; } // 非静态 final 变量不能在静态初始化块中初始化 // public final int C; // static { // C = 30; // } // 静态常量,在定义时初始化 public static final int STATIC_D = 40; public static final int STATIC_E; // 静态常量,在静态初始化块中初始化 static { STATIC_E = 50; } // 静态变量不能在初始化块中初始化 // public static final int STATIC_F; // { // STATIC_F = 60; // } public final int G; // 静态 final 变量不可以在构造器中初始化 // public static final int STATIC_H; // 在构造器中初始化 public FinalTest() { G = 70; // 静态 final 变量不可以在构造器中初始化 // STATIC_H = 80; // 给 final 的变量第二次赋值时,编译会报错 // A = 99; // STATIC_D = 99; } // final 变量未被初始化,编译时就会报错 // public final int I; // 静态 final 变量未被初始化,编译时就会报错 // public static final int STATIC_J; } ``` 我们运行上面的代码之后出了可以发现 final 变量(常量)和静态 final 变量(静态常量)未被初始化时,编译会报错。 用 final 修饰的变量(常量)比非 final 的变量(普通变量)拥有更高的效率,因此我们在实际编程中应该尽可能多的用常量来代替普通变量,这也是一个很好的编程习惯。 当 final 用来定义一个方法时,会有什么效果呢?正如大家所知,它表示这个方法不可以被子类重写,但是它这不影响它被子类继承。我们写段代码来验证一下: ```java class ParentClass { public final void TestFinal() { System.out.println("父类 -- 这是一个 final 方法"); } } public class SubClass extends ParentClass { /** * 子类无法重写(override)父类的 final 方法,否则编译时会报错 */ // public void TestFinal() { // System.out.println("子类 -- 重写 final 方法"); // } public static void main(String[] args) { SubClass sc = new SubClass(); sc.TestFinal(); } } ``` 这里需要特殊说明的是,具有 private 访问权限的方法也可以增加 final 修饰,但是由于子类无法继承 private 方法,因此也无法重写 它。编译器在处理 private 方法时,是按照 final 方法来对待的,这样可以提高该方法被调用时的效率。不过子类仍然可以定义同父类中的 private 方法具有同样结构的方法,但是这并不会产生重写的效果,而且它们之间也不存在必然联系。 最后我们再来回顾一下 final 用于类的情况。这个大家应该也很熟悉了,因为我们最常用的 String 类就是 final 的。由于 final 类不允 许被继承,编译器在处理时把它的所有方法都当作 final 的,因此 final 类比普通类拥有更高的效率。final 的类的所有方法都不能被重写,但这并不 表示 final 的类的属性(变量)值也是不可改变的,要想做到 final 类的属性值不可改变,必须给它增加 final 修饰,请看下面的例子: ```java public final class FinalTest { int i = 10; public static void main(String[] args) { FinalTest ft = new FinalTest(); ft.i = 99; System.out.println(ft.i); } } ``` 运行上面的代码试试看,结果是 99,而不是初始化时的 10。 **_finally 语句_** 接下来我们一起回顾一下 finally 的用法。这个就比较简单了,它只能用在 try/catch 语句中,并且附带着一个语句块,表示这段语句最终总是被执行。请看下面的代码: ```java public final class FinallyTest { public static void main(String[] args) { try { throw new NullPointerException(); } catch (NullPointerException e) { System.out.println("程序抛出了异常"); } finally { System.out.println("执行了 finally 语句块"); } } } ``` 运行结果说明了 finally 的作用: 1. 程序抛出了异常 2. 执行了 finally 语句块 请大家注意,捕获程序抛出的异常之后,既不加处理,也不继续向上抛出异常,并不是良好的编程习惯,它掩盖了程序执行中发生的错误,这里只是方便演示,请不要学习。 那么,有没有一种情况使 finally 语句块得不到执行呢?大家可能想到了 return、continue、break 这三个可以打乱代码顺序执行语句的规律。那我们就来试试看,这三个语句是否能影响 finally 语句块的执行: ```java public final class FinallyTest { // 测试 return 语句 public ReturnClass testReturn() { try { return new ReturnClass(); } catch (Exception e) { e.printStackTrace(); } finally { System.out.println("执行了 finally 语句"); } return null; } // 测试 continue 语句 public void testContinue() { for (int i = 0; i < 3; i++) { try { System.out.println(i); if (i == 1) { continue; } } catch (Exception e) { e.printStackTrace(); } finally { System.out.println("执行了 finally 语句"); } } } // 测试 break 语句 public void testBreak() { for (int i = 0; i < 3; i++) { try { System.out.println(i); if (i == 1) { break; } } catch (Exception e) { e.printStackTrace(); } finally { System.out.println("执行了 finally 语句"); } } } public static void main(String[] args) { FinallyTest ft = new FinallyTest(); // 测试 return 语句 ft.testReturn(); System.out.println(); // 测试 continue 语句 ft.testContinue(); System.out.println(); // 测试 break 语句 ft.testBreak(); } } class ReturnClass { public ReturnClass() { System.out.println("执行了 return 语句"); } } ``` 上面这段代码的运行结果如下: ``` 执行了 return 语句 执行了 finally 语句 0 执行了 finally 语句 1 执行了 finally 语句 2 执行了 finally 语句 0 执行了 finally 语句 1 执行了 finally 语句 ``` 很明显,return、continue 和 break 都没能阻止 finally 语句块的执行。从输出的结果来看,return 语句似乎在 finally 语句块之前执行了,事实真的如此吗?我们来想想看,return 语句的作用是什么呢?是退出当前的方法,并将值或对象返回。如果 finally 语句块是在 return 语句之后执行的,那么 return 语句被执行后就已经退出当前方法了,finally 语句块又如何能被执行呢?因 此,正确的执行顺序应该是这样的:编译器在编译 return new ReturnClass(); 时,将它分成了两个步骤,new ReturnClass() 和 return,前一个创建对象的语句是在 finally 语句块之前被执行的,而后一个 return 语句是在 finally 语 句块之后执行的,也就是说 finally 语句块是在程序退出方法之前被执行的。同样,finally 语句块是在循环被跳过(continue)和中断 (break)之前被执行的。 **_finalize 方法_** 最后,我们再来看看 finalize,它是一个方法,属于 java.lang.Object 类,它的定义如下: `protected void finalize() throws Throwable { }  ` 众所周知,finalize() 方法是 GC(garbage collector)运行机制的一部分,关于 GC 的知识我们将在后续的章节中来回顾。 在此我们只说说 finalize() 方法的作用是什么呢? finalize() 方法是在 GC 清理它所从属的对象时被调用的,如果执行它的过程中抛出了无法捕获的异常(uncaught exception),GC 将终止对改对象的清理,并且该异常会被忽略;直到下一次 GC 开始清理这个对象时,它的 finalize() 会被再次调用。 请看下面的示例: ```java public final class FinallyTest { // 重写 finalize() 方法 protected void finalize() throws Throwable { System.out.println("执行了 finalize() 方法"); } public static void main(String[] args) { FinallyTest ft = new FinallyTest(); ft = null; System.gc(); } } ``` 运行结果如下: - 执行了 finalize() 方法 程序调用了 java.lang.System 类的 gc() 方法,引起 GC 的执行,GC 在清理 ft 对象时调用了它的 finalize() 方法,因此才有了上面的输出结果。调用 System.gc() 等同于调用下面这行代码: `Runtime.getRuntime().gc();  ` 调用它们的作用只是建议垃圾收集器(GC)启动,清理无用的对象释放内存空间,但是 GC 的启动并不是一定的,这由 JAVA 虚拟机来决定。直到 JAVA 虚拟机停止运行,有些对象的 finalize() 可能都没有被运行过,那么怎样保证所有对象的这个方法在 JAVA 虚拟机停止运行之前一定被调用 呢?答案是我们可以调用 System 类的另一个方法: ```java public static void runFinalizersOnExit(boolean value) {       //other code   }   ``` 给这个方法传入 true 就可以保证对象的 finalize() 方法在 JAVA 虚拟机停止运行前一定被运行了,不过遗憾的是这个方法是不安全的,它会导致有用的对象 finalize() 被误调用,因此已经不被赞成使用了。 由于 finalize() 属于 Object 类,因此所有类都有这个方法,Object 的任意子类都可以重写(override)该方法,在其中释放系统资源或者做其它的清理工作,如关闭输入输出流。 通过以上知识的回顾,我想大家对于 final、finally、finalize 的用法区别已经很清楚了。 ## [从零到一学习SQL:内连接与外连接实战](https://blog.dong4j.site/posts/f9bb212c.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) #### 聚合函数 - count 返回结果集中行的数目 - sum 返回结果集中所有值得总和 - avg 返回结果集中所有值得平均值 - max 返回结果集中所有值中最大值 - min 返回结果集中所有值中最小值 ##### count > select count([* | all | distinct] expr) from 表名; - - : 计算所有选择的行, 包括 null - all : 计算指定列所有的非空行, 默认选项 - distinct : 计算指定列所有唯一非空值 #### sum > select sum([all | distinct] expr) as 别名 from 表名; #### avg > select avg(mark) as ‘平均成绩’ from sutdent > where studentID = 10; > 输出表 student 中 studentID 为 10 的所有 mark 的平均值 #### 最大值和最小值 ```sql select max(mark) as '最高成绩', min(mark) as '最低成绩' from student where examID = 6; ``` #### 常用数据库函数 [mysql 常用函数](http://www.cnblogs.com/cocos/archive/2011/05/06/2039469.html) ### 数据分组 将表按条件分组, 然后在组中查找需要的值 ```sql select 列A, 聚合函数(聚合函数规范) from 表名 where 过滤条件 group by 列A; ``` 当需要分组查询时需要使用 GROUP BY 子句,例如查询每个部门的工资和,这说明要使用部分来分组。 - 查询每个部门的部门编号和每个部门的工资和: ```sql SELECT deptno, SUM(sal) FROM emp GROUP BY deptno; ``` - 查询每个部门的部门编号以及每个部门的人数: ```sql SELECT deptno,COUNT(*) FROM emp GROUP BY deptno; ``` - 查询每个部门的部门编号以及每个部门工资大于 1500 的人数: ```sql SELECT deptno,COUNT(*) FROM emp WHERE sal>1500 GROUP BY deptno; ``` #### HAVING 子句 - 查询工资总和大于 9000 的部门编号以及工资和 ```sql SELECT deptno, SUM(sal) FROM emp GROUP BY deptno HAVING SUM(sal) > 9000; ``` ##### 子句顺序问题 如果 where 在 group by 前面, 并不是从分组中查询 如果 where 在 group by 后面, 则是从分组中查询需要的结果 select from where 条件中不能有聚合函数 group by [where] having 条件中都是聚合函数, 就在选出的分组中再次筛选 order by 如果需要, 最后进行排序 注意,WHERE 是对分组前记录的条件,如果某行记录没有满足 WHERE 子句的条件,那么这行记录不会参加分组;而 HAVING 是对分组后数据的约束。 #### 分页查询 LIMIT 用来限定查询结果的起始行,以及总行数。 - 查询 5 行记录,起始行从 0 开始 > `SELECT * FROM emp LIMIT 0, 5;` 注意,起始行从 0 开始,即第一行开始! - 查询 10 行记录,起始行从 3 开始 > `SELECT * FROM emp LIMIT 3, 10;` 如果一页记录为 10 条,希望查看第 3 页记录应该怎么查呢? - 第一页记录起始行为 0,一共查询 10 行; - 第二页记录起始行为 10,一共查询 10 行; - 第三页记录起始行为 20,一共查询 10 行; 查询页的下标 = `(要查询的页数 - 1) * 每页记录的行数` ### 单表查询 ```sql select 列名1[, 列名2,...] from 数据源 [where condition]; ``` - 数据源可以是表也可以是视图; - 列名 和 where 关键字选出的行确定选择的查询结果 - 没有 where 关键字则选出所有的行 - 如果不指定选择的列, 使用 `*` 代表所有的列 #### 字符串的连接 ```sql select concat(teacher_name,'xxx') from teacher_table; ``` 在 mysql 中, 字符串和 null 连接会返回 null, 对于其他一些数据库, 如果让字符串和 null 连接, 会把 null 当成空字符串处理 #### 去除重复行 ```sql select distinct student_name ,java_teacher from student_table; ``` #### where 关键字 | 运算符 | 含义 | | :------------------------------ | :---------------------------------------- | | `expr1 between expr2 and expr3` | 要求 `expr2 <= expr1 <= expr3` | | `expr1 in (expr2, expr3, ..)` | 要求 `expr1` 等于括号内任意表达式的值 | | `like` | 字符串匹配,`like` 后面的字符串支持通配符 | | `is null` | 要求指定值等于 `null` | | `=` | 是否相等 | | `<>` | 不相等 | | `:=` | 赋值 | ##### like 模糊匹配 SQL 支持 2 个通配符 - 下划线 `_` : 代表一个任意的字符 - 百分号 %: 代表任意多个字符 当查询中需要使用到下划线或百分号时, 可以使用 `\` 转义, 也可使用 `escape` 关键字 ```sql select * from student_table where student_name like '_%' escape '\'; ``` ##### 条件组合查询 SQL 提供了 `and` 和 `or` 来组合 2 个条件, 并提供 `not` 来对逻辑表达式求否. ```sql select * from student_table where stduent_name like '__' and student_id > 3; ``` ##### 比较运算符和逻辑运算符的优先级 | 运算符 | 优先级 (小的优先) | | :--------------- | :---------------: | | 所有的比较运算符 | 1 | | not (非) | 2 | | and (且) | 3 | | or (或者) | 4 | 括号的优先级最高, 所以可以使用括号来改变逻辑运算符优先级 ```sql select * from student_table where (student_id > 3 or student_name > '张') and java_teacher > 1; ``` ##### 查询结果排序 查询结果默认按插入顺序排序, 如果需要查询结果按某列值得大小进行排序, 则可以使用 `order by` ```sql order by 列名1 [desc] ,列名2,列名3,...; ``` 上面语法进行排序时可以采用列名, 列序号和列别名. 进行排序时默认进行升序排序, 如果想按降序排列, 可以写上 `desc` 关键字 与之对应的是 `asc` 关键字 下面的语句选出 student_table 中所有的记录, 然后按 java_teacher 列的升序排列 ```sql select * from student_table order by java_teacher; ``` 如果需要按多行排序, 则每行的 asc 和 desc 必须单独指定, 如果指定了多个排序列, 则第一个排序列是又要排序列, 只要当第一列中存在多个相同的值时, 第二列才会起作用 ```sql select * from student_table order by java_teacher desc , student_name; ``` 当 java_teacher 列的值相同时, 按照 student_name 列的升序排列. ### 多表查询 - 合并结果集 - 连接查询 - 内连接 - 外链接 - 左外连接 - 右外连接 - 外全连接 (mysql 不支持, 使用左外, 右外结合) - 自然连接 - 子查询 #### 合并结果集 1. 作用: 合并结果集就是把两个 select 语句的查询结果合并到一起 2. 两种方式: - union: 去除重复记录 > select _ from t1 union select _ from t2; - union all : 不去除重读记录 > select _ from t1 union all select _ from t2; ![20241229154732_Qcswql68.webp](https://cdn.dong4j.site/source/image/20241229154732_Qcswql68.webp) ![20241229154732_hLYA8bVL.webp](https://cdn.dong4j.site/source/image/20241229154732_hLYA8bVL.webp) 被合并的 2 个结果必须列数, 类型一致 #### 连接查询 连接查询就是求出多个表的乘积, 例如 t1 和 t2 连接, 那个查询出的结果就是 `t1*t2` ![20241229154732_iX48n3Ju.webp](https://cdn.dong4j.site/source/image/20241229154732_iX48n3Ju.webp) 就这是笛卡尔积 所以查询出来的结果有重复的, 所以需要去除无用信息 ![20241229154732_K19FdTdE.webp](https://cdn.dong4j.site/source/image/20241229154732_K19FdTdE.webp) > `select * from emp,dept where emp.deptno = dept.depton;` ![20241229154732_FCkyRd8h.webp](https://cdn.dong4j.site/source/image/20241229154732_FCkyRd8h.webp) ##### 内连接 标准格式: ```sql select * from empe inner join dept on empe.deptno = dept.deptno; ``` ###### 自然连接 ```sql select * from 表1 natural join 表2 ``` 系统自动找到两张连接的表中名称和类型完全一致的列 ##### 外连接 ###### 左外连接 先查询出左表, 然后查询右表, 右表中满足条件的显示出来, 不满足的显示为 null ```sql SELECT * FROM emp e LEFT OUTER JOIN dept d ON e.deptno=d.deptno; ``` ###### 右外连接 同左外连接的效果相反, 先把右表中所有的记录查出来, 然后左表满足条件的显示, 不满足的显示为 null ```sql SELECT * FROM emp e RIGHT OUTER JOIN dept d ON e.deptno=d.deptno; ``` ###### 外全连接 在 mysql 中实现外全连接 ```sql SELECT * FROM emp e LEFT OUTER JOIN dept d ON e.deptno=d.deptno; union SELECT * FROM emp e RIGHT OUTER JOIN dept d ON e.deptno=d.deptno; ``` 连接不限与两张表,连接查询也可以是三张、四张,甚至 N 张表的连接查询。通常连接查询不可能需要整个笛卡尔积,而只是需要其中一部分,那么这时就需要使用条件来去除不需要的记录。这个条件大多数情况下都是使用主外键关系去除。 两张表的连接查询一定有一个主外键关系,三张表的连接查询就一定有两个主外键关系,所以在大家不是很熟悉连接查询时,首先要学会去除无用笛卡尔积,那么就是用主外键关系作为条件来处理。如果两张表的查询,那么至少有一个主外键条件,三张表连接至少有两个主外键条件。 ##### 子查询 子查询就是嵌套查询,即 SELECT 中包含 SELECT,如果一条语句中存在两个,或两个以上 SELECT,那么就是子查询语句了 1. 子查询出现的位置: - where 后,作为条件的一部分; - from 后,作为被查询的一条表; 2. 当子查询出现在 where 后作为条件时,还可以使用如下关键字: - any - all 3. 子查询结果集的形式 - 单行单列(用于条件): > `select * from 表 1 where 列 1 [=,>,<,>=,<=,!=] (select 列 from 表 where 条件)` - 单行多列(用于条件): > `select * from 表 where (列 1, 列 2) in (select 列 1, 列 2 from 表 where 条件)` - 多行单列(用于条件): > `select * from 表 where 列 1 [in ,all ,any] (select 列 from 表 where 条件)` - 多行多列(用于表): > select _ from 表 1,(select _ from …) where 条件 ## [SQL初学者必看:DDL与DML的区别与应用](https://blog.dong4j.site/posts/c3ab3ba4.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 操作表结构 #### create (创建) - 创建表 ```sql create table [if not exists] 表名( 列名 列类型, 列名 列类型, ... ); ``` - 创建表并从其他表中复制数据 ```sql create table 表名 as select * from 表名 ``` #### alter (修改) - 添加列定义 (列名必须是原表中不存在的) ```sql alter table 表名 add( 列名 数据类型 , ... ); ``` - 修改列定义 (列名必须是原表中存在的) ```sql alter table 表名 modify 列名 数据类型; ``` MySQL 中 一次只能修改一个列定义 如果修改数据列的默认值, 只会对以后插入操作有作用, 对以前已经存在的数据不会有任何影响 - 删除列 ```sql alter table 表名 drop 列名; ``` - 重命名数据表 ```sql alter table 旧表名 rename to/as 新表名; ``` - 改列名 ```sql alter table 表名 change 旧列名 新列名 type; ``` - 改列名并修改数据类型 ```sql alter table 表名 change 旧表名 新表名 type [default expr] [first | after 列名]; ``` 1. 修改数据库成 utf8 的. `mysql> alter database 数据库名 character set utf8;` 2. 修改表默认用 utf8. `mysql> alter table 表名 character set utf8;` 3. 修改字段用 utf8 `mysql> alter table 表名 modify type_name varchar(50) CHARACTER SET utf8;` #### drop (删除) - 删除表 ```sql drop table 表名 ``` 1. 表结构被删除, 表对象不再存在 2. 表里的数据也被删除 3. 该表的所有相关索引, 约束也被删除 ##### drop 和 delete 的区别 - drop 只是针对表结构 `drop table 表名;` 从数据库中删除这张表 - delete 针对表数据 `delete table 表名;` 清空这张表中的所有数据 #### truncate ```sql truncate 表名; ``` 删除表中的全部数据, 但保留表数据. 相对于 delete 而言, 速度更快, 不能像 delete 能删除指定的记录, truncate 只能一次性删除整个表中的全部记录 ### DML (数据操作语言) 操作数据表中的数据 #### insert (插入数据) - 按列的地理顺序插入 ```sql insert into students values( 1,'张三','男',25,'123467890' ); ``` - 当只需要插入部分数据, 或者不按照顺序插入 ```sql insert into students (name,sex,age) values('田七','男',30); ``` - 从其他表中一次插入多条数据 ```sql inster into 表名 (列名1,列名2,...) select 列名 from 表名; ``` 要求选择出来的数据列和插入目的地的数据列个数相同, 类型匹配. MySQL 提供了一种扩展语句, 可以一次插入多条语句 ```sql insert into students values (1,'张三','男',25,'123467890'), (2,'李四','男',22,'0987654321'), (3,'王五','男',20,'0984324321'), (4,'赵六','男',29,'0944654321'); ``` #### update (修改已有数据) upadte 用于修改数据表的记录, 每次可以修改多条记录. 通过使用 `where` 子句限定修改哪些记录. `where` 类似于 Java 中的 `if`, 只有符合条件的记录才会被修改, 没有 `where` 限定的值总是为 `true`, 表示该表的的所有记录都会被修改 ```sql update 表名 set 列名1=值1, 列名2=值2, ... [where 条件]; ``` #### delete from(删除表中数据) `delete from` 语句用于删除指定数据表记录, 使用 `delete from` 时不需要指定列名, 因为总是整行的删除. ```sql delete from 表名 [where 条件]; ``` 使用 `where` 指定删除满足条件的记录, 不使用 `where` 时删除全部记录 ### 数据库约束 - 通过约束可以更好的保证数据表中数据的完整性. - 约束是在表上强制执行的数据校验规则. - 当表中数据存在相互依赖性时, 可以保护相关的数据不被错误的删除. - 约束分为表级约束和列级约束 - 约束类型包括 - not null (非空约束); - primary key (主键约束); - unique key (唯一约束); - default (默认约束); - foreign key (外键约束); - check (检查) mysql 中不支持 #### 空值与非空 - NULL , 字段值可以为空 - NOT NULL, 字段值不允许为空 - 创建表的时候添加约束 ```sql create table tb( username varchar(20) not null, age tinyint unsigned null ); ``` - 表创建好之后增加删除非空约束 ```sql --增加非空约束 alter table tb modify age tinyint unsigned not null; --删除非空约束 alter table tb modify username carchar(20) null; --取消非空约束并制定默认值 alter tabel tb modify age tinyint unsigned null default 90; ``` #### primary key - 主键约束相当于非空约束和唯一约束, 即不能出现重复值, 也不允许出现 null 值 - 每张数据表只能存在一个主键 - 主键保证记录的唯一性 - 主键自动为 `not null` - 创建表时使用列级约束语法 ```sql create table primary_test( --创建主键约束 test_id int primary key, test_name varchar(255) ) ``` - 创建表时使用表级约束语法 ```sql create table primary_tset2( test_id not null, test_name varchar(255), test_pass carchar(255), --指定主键约束名为test2_pk,对大部分数据库有效,但对mysql无效, --mysql数据库中该主键约束名依然是PRI constraint test2_pk primary key(test_id) ); ``` - 创建表时建立组合主键约束 ```sql create table primary_test3( test_name varchar(255), test_pass varchar(255), primary key(test_name,test_pass) ); ``` - 表被创建好之后添加主键约束 ```sql --使用add关键字 alter table primary_tset3 add primary key(test_name,test_pass); --使用modify关键字 --为单独的列添加主键约束 alter table primary_test3 modify test_name varchar(255) primary key; ``` - 删除数据表的主键约束 ```sql alter table primary_test3 drop primary key; ``` #### auto_increment - 自动编号, 且必须和主键组合使用 - 默认情况下, 起始值为 1, 每次的增量为 1 ```sql create table tb2( id smallint unsigned auto_increment primary key, username varchar(20) not null ); ``` 一旦指定了某列具有自增长特性, 则向该表插入记录的时候不可为该列指定值, 该列的值由数据库系统自动生成 #### unique - 唯一约束保证了记录的唯一性 - 唯一约束的字段可以为空 - 每张数据表可以存在多个唯一约束 - 建表时创建唯一约束 使用列级约束语法 ```sql create tabel unique_test( test_id int not null , --建立唯一约束,test_name不能出现相同的值 test_name varchar(255) unique ); ``` - 建表时使用表级约束为多列组合建立唯一约束 ```sql create table nuique_test2( test_id int not null, test_name varchar(255), test_pass varchar(255), --使用表级约束语法建立唯一约束 unique (test_name), --使用表级约束语法建立唯一约束,并指定约束名 constraint test2_nk unique (test_pass) ); ``` 上面分别为 name 和 pass 建立唯一约束, 意味着这两列都不能出现重复值 - 建表时建立组合唯一约束 ```sql create table nuique_test3( test_id int not null, test_name varchar(255), test_pass vatchar(255), --使用表级约束语法建立唯一约束,指定两列组合不能为空 constraint test3_nk unique (test_name,test_pass) ); ``` 上面的情况要求 name 和 pass 组合的值不能出现重复 - 表创建以后修改删除唯一约束 ```sql --添加唯一约束 alter table unique_test3 add unique(test_name,test_pass); --使用modify关键字为单列采用列级约束语法添加唯一约束 alter table student modify test_name varchar(255) unique; --删除unique_tset3表上的test3_uk唯一约束 alter table unique_test3 drop index test3_uk; ``` #### foreign key - 保证数据一致性, 完整性 - 实现一对一或一对多关系 - 外键约束的要求 - 父表和子表必须使用相同的存储引擎, 而且禁止使用临时表; - 数据表的存储引擎只能是 InnoDB - 外键列和参照列必须具有相似的数据类型, 其中数字的长度或者是有符号位必须相同; 而字符的长度则可以不同 - 外键列和参照列必须创建索引, 如果外键列不存在索引, MySQL 将自动创建索引 子表: 有 foreign key 的表 父表: 被参照的表 ![20241229154732_cdTiRaSI.webp](https://cdn.dong4j.site/source/image/20241229154732_cdTiRaSI.webp) - 采用列级约束语法建立外键约束直接使用 references 关键字, references 指定该列参照哪个主表, 以及参照主表的哪一个列 ```sql --为了保证从表参照的主表存在,通常应该先建立主表 create table teacher_table1 ( teacher_id int auto_increment, teacher_name carchar(255), primary key(teacher_id) ); create table student_table1 ( --为本表建立主键约束 sutdent_id int auto_increment primary key, sutdent_name carchar(255), --指定java_teacher参照到teacher_table1的teahcer_id列 java_teahcer int references teacher_table1 (teacher_id) ); ``` 上面这种方法在 mysql 中不会生效, 仅仅是为了和标准的 SQL 保持良好的兼容性, 因此如果要使 mysql 中的外键约束生效, 则必须使用表级约束语法 - 表级约束语法 ```sql create table teacher_table ( teacher_id int auto_increment, teacher_name varchar(255), primary key(teacher_id) ); create table student_table ( student_id int auto_increment primary key, student_name varchar(255), java_teacher int, foreign key(java_teacher) references teacher_table(teacher_id) ); ``` 以上这种方法没有指定约束名, mysql 则会为该外键约束名命名为 table_name_ibfk_n, 其中 table_name 时从表的表名, 而 n 时从 1 开始的整数 建立多列组合的外键约束 ```sql foreign key(列名1,列名2,...) references 引用的表名(引用的列名1,引用的列名2,...) ``` - 表级约束语法, 并指定外键约束名 ```sql create table teacher_table ( teacher_id int auto_increment, teacher_name varchar(255), primary key(teacher_id) ); create table student_table ( student_id int auto_increment primary key, student_name varchar(255), java_teacher int, constraint student_teacher_fk foreign key(java_teacher) references teacher_table(teacher_id) ); ``` - 添加外键约束 ```sql alter table 表名 add foreign key(列名1[,列名2,...]) references 引用的表名(引用的列名1[,引用的列名2,...]); ``` - 删除外键约束语法 ```sql alter table 表名 drop foreign key 约束名; ``` ##### 级联删除 如果想定义删除主表记录时, 从表记录也被删除, 则需要在建立外键约束后添加 `on delete cascade` 或者 `on delete set null` 第一种是删除主表记录时, 把参照该主表记录的从表记录全部级联删除 第二种是指定当删除主表记录时, 把参照该主表记录的从表记录的外键设置为 null #### default - 默认值 - 当插入记录时, 如果没有明确为字段赋值, 则自动赋值为默认值 ### 索引 索引总是从属于数据表, 但它也和数据表一样属于数据库对象. 创建索引的唯一作用就是加快对表的查询, 索引通过四通快速路径访问方法来快速定位数据, 从而减少磁盘 I/O. **创建索引的两种方式** - 自动: 当在表上定义主键, 唯一和外键约束时, 系统自动为该数据列创建对应索引 - 手动: 通过 `create index..` 语句创建索引 **删除索引的两种方式** - 自动: 数据表被删除时, 该表上的索引自动被删除 - 手动: 通过 `drop index..` 语句删除索引 - 创建索引 ```sql create index index_name on table_name (列名1[,列名2,...]); ``` - 删除索引 ```sql drop index index_name on 表名; ``` 在 mysql 数据库中, 以为只要求同一个表内索引不能同名, 所以删除索引时必须指定表名 而在例如 Oracle 数据库中要求索引名必须不同, 所以无须指定表名. ## [MySQL 基础教程:从创建到备份恢复](https://blog.dong4j.site/posts/329422b3.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) - 表头 (header): 每一列的名称; - 列 (row): 具有相同数据类型的数据的集合; - 行 (col): 每一行用来描述某个人 / 物的具体信息; - 值 (value): 行的具体信息, 每个值必须与该列的数据类型相同; - 键 (key): 表中用来识别某个特定的人或物的方法, 键的值在当前列中具有唯一性 ### SQL 语句的分类 - DQL 查询语句 select - DML 操作语句 insert update delete - DDL 定义语句 create alter drop truncate - DCL 控制语句 grant revoke - 实务控制语句 commit rollback savepoint ### MySQL 服务的启动、停止与卸载 在 Windows 命令提示符下运行: 启动: net start MySQL 停止: net stop MySQL 卸载: sc delete MySQL ### MySQL 的登录 #### MySQL 参数 | 参数 | 描述 | | :------------------- | -----------------: | | -D, –fatabase = name | 打开指定数据库 | | –delimiter = name | 指定分隔符 | | -h, –host = name | 服务器名字 | | -p, –password[=name] | 密码 | | -P, –port=# | 端口号 | | –prompt=name | 设置提示符 | | -u, –user=name | 用户名 | | -V, –version | 输出版本信息并退出 | ![20241229154732_mIOdOceD.webp](https://cdn.dong4j.site/source/image/20241229154732_mIOdOceD.webp) 如果登录本地服务器, 则可以不写端口号和 IP 地址 #### cmd 登录 mysql > mysql -h 主机名 -u 用户名 -p (回车) ##### 登录时选择使用哪个数据库 > mysql -D 选择的数据库名 -h 主机名 -u 用户名 -p - h : 该命令用于指定客户端所要登录的 MySQL 主机名, 登录当前机器该参数可以省略; - u : 所要登录的用户名; - p : 告诉服务器将会使用一个密码来登录, 如果所要登录的用户名密码为空, 可以忽略此选项 #### 修改 MySQL 提示符 - 连接客户端时通过参数指定 > mysql -uroot - p –prompt 提示符 ![20241229154732_11dDPovH.webp](https://cdn.dong4j.site/source/image/20241229154732_11dDPovH.webp) `--prompt` 后面跟的 `\h` 参数表示服务器名字 - 连上客户端后通过 `prompt` 命令 > mysql > prompt 提示符 ![20241229154732_DKXsoFjh.webp](https://cdn.dong4j.site/source/image/20241229154732_DKXsoFjh.webp) 提示符从原来的 `loaclhost` 变成了 `mysql>` - 或者 `\R` > mysql > `\R` 提示符 ![20241229154732_uzTrtuvG.webp](https://cdn.dong4j.site/source/image/20241229154732_uzTrtuvG.webp) 参看 mysql 的命令知道: > prompt (`\R`) Change your mysql prompt. 可以使用 `\R` 代替 `prompt` 我们也可以使用提示符参数来增加提示符信息 ##### Mysql 提示符 | 参数 | 描述 | | :--- | ---------: | | `\D` | 完整的日期 | | `\d` | 当前数据库 | | `\h` | 服务器名称 | | `\u` | 当前用户 | ![20241229154732_ZnBVWt71.webp](https://cdn.dong4j.site/source/image/20241229154732_ZnBVWt71.webp) 使用 `\d` 参数时, 必须使用 `use 数据库名` 切换到指定数据库 不然会显示为 null ![20241229154732_yJ1tKd1l.webp](https://cdn.dong4j.site/source/image/20241229154732_yJ1tKd1l.webp) ### 退出 MySQL - mysql > exit - mysql > quit - mysql > `\q` ### MySQL 常用命令 - 显示当前服务器版本 - `SELECT VERSION();` - 显示当前日期 - `SELECT NOW();` - 显示当前用户 - `SELECT USER();` - 添加管理员账户 - `grant all on *.* to user@localhost identified by "password";` ![20241229154732_nIq4S1eJ.webp](https://cdn.dong4j.site/source/image/20241229154732_nIq4S1eJ.webp) #### 修改密码 > mysqladmin -u root -p password 新密码 执行后提示输入旧密码完成密码修改, 当旧密码为空时直接按回车键确认即可. ### MySQL 语句的规范 - 关键字与函数名称全部大写 - 数据库名称, 表名称, 字段名称小写 - SQL 语句必须以分号结尾 MySQL 也可以全程使用小写 (为了方便查看, 本文中全部使用小写命令) ### MySQL 数据库的操作 #### 显示所有存在的数据库 > show databases; #### 创建数据库 > create{database| schema} > [if not exists] db_name –如果不存在 `db_name` 这个数据库就创建 db_name 数据库 > [defalut] character set [=] charset_name; –设置编码格式 ![20241229154732_NJFvISw9.webp](https://cdn.dong4j.site/source/image/20241229154732_NJFvISw9.webp) - **在登录的时候创建** > mysqladmin -u root -p create demo_db #### 删除数据库 > drop {database | schema} [if exists] db_name; #### 使用数据库 > use 数据库名称; 显示当前选择的数据库 > select database(); ## 数据类型和操作数据表 ### 数据类型 数据类型指的是列, 存储过程参数, 表达式和局部变量的数据特征, 他决定了数据的存储格式, 代表了不同的信心类型 ### 整型 | 数据类型 | 字节 | | :-------- | ---: | | tinyint | 1 | | smallint | 2 | | mediumint | 3 | | int | 4 | | bigint | 8 | ### 浮点型 | 数据类型 | 描述 | | :------------ | :------------------------------------------ | | float[(m,d)] | m 是数字中位数, d 是小数位数 | | double[(m,d)] | 如果省略 m 和 d, 根据硬件允许的限制来保存值 | ### 日期类型 | 列类型 | 存储需求 | 描述 | | :-------- | :------: | :----------------------------------------------------------------------- | | year | 1 | 默认存储 4 位数字 | | time | 3 | -8385959 到 8385959 | | date | 3 | 1000 年 1 月 1 日到 9999 年 12 月 31 日之间的日期 | | datetime | 8 | 1000 年 1 月 1 日 0 点 0 分 0 秒到 9999 年 12 月 31 日 59 点 59 分 59 秒 | | timestamp | 4 | 时间戳: 1970.1.1 到 2037 年 12.31 | ### 字符型 | 列类型 | 描述 | | :------------------------ | :----------------------------------------------------------------- | | char(m) | (定长) m 个字节, 0<=m<=255 | | varchar(m) | (变长) L+1 个字节, L<=m 且 0<=m<=65535 | | tinytext | L+1 个字节, L<2 的 8 次方 | | text | L+2 个字节, L<2 的 16 次方 | | mediumtext | L+3 个字节, L<2 的 24 次方 | | longtext | L+4 个字节, L<2 的 32 次方 | | enum(‘value1’,’value2’,…) | 1 或 2 个字节, 取决于枚举值的个数 (最多 65535 个值) | | set(‘value1’,’value2’,…) | (集合) 1,2,3,4 或 8 个字节, 取决于 set 成员的数目 (最多 64 个成员) | #### 创建数据表 ``` create table [if not exists] table_name( cloumn_name1 data_type, cloumn_name2 data_type, ... ); ``` 最后一条语句不需要加 `,`; ``` create table tb1( username varchar(20), age tinyint unsigned, --无符号 salary float(8,2) unsigned ); ``` 以创建 students 表为例, 表中将存放 学号 (id)、姓名 (name)、性别 (sex)、年龄 (age)、联系电话 (tel) 这些内容: ``` create table students ( id int unsigned not null auto_increment primary key, name char(8) not null, sex char(4) not null, age tinyint unsigned not null, tel char(13) null default '-' ); ``` (提示: 1. 如果连接远程主机请加上 -h 指令; 2. createtable.sql 文件若不在当前工作目录下需指定文件的完整路径。) 语句解说: - create table tablename(columns) 为创建数据库表的命令, 列的名称以及该列的数据类型将在括号内完成; 括号内声明了 5 列内容, id、name、sex、age、tel 为每列的名称, 后面跟的是数据类型描述, 列与列的描述之间用逗号 (,) 隔开; 以 “id int unsigned not null auto_increment primary key” 行进行介绍: - “id” 为列的名称; - “int” 指定该列的类型为 int(取值范围为 -8388608 到 8388607), 在后面我们又用 - “unsigned” 加以修饰, 表示该类型为无符号型, 此时该列的取值范围为 0 到 16777215; - “not null” 说明该列的值不能为空, 必须要填, 如果不指定该属性, 默认可为空; - “auto_increment” 需在整数列中使用, 其作用是在插入数据时若该列为 NULL, MySQL 将自动产生一个比现存值更大的唯一标识符值。在每张表中仅能有一个这样的值且所在列必须为索引列。 - “primary key” 表示该列是表的主键, 本列的值必须唯一, MySQL 将自动索引该列。 下面的 char(8) 表示存储的字符长度为 8, tinyint 的取值范围为 -127 到 128, default 属性指定当该列值为空时的默认值。 #### 参看数据表列表 > show tables [from db_name] [like ‘pattern’ | where expr]; 如果直接写 `show tables` 会显示当前所选择的数据库中的所有数据表 如果加上了 `from`, 会显示特定数据库中的表 但是当前所在数据库的位置没有改变 #### 参看数据表结构 > show columns from tb_name; > 或者 > describe (desc) tb_name; #### 插入记录 > insert [into] tb_name [(col_name1,col_name2,…)] values(val1,val2,..); ``` insert into students values (1,'张三','男',25,'123467890'), (2,'李四','男',22,'0987654321'), (3,'王五','男',20,'0984324321'), (4,'赵六','男',29,'0944654321'); ``` 当只需要插入部分数据, 或者不按照顺序插入 > insert into students (name,sex,age) values(‘田七’,’男’,30); #### 更新记录 > update students set age = 50 where name = ‘张三’; #### 删除记录 > delete from students where id > 1; –删除 ID 大于 1 的所有记录 > > delete from students where id = 10 limit 1 –限制删除掉 1 条 id=10 的记录 > > delete from students; –删除全表记录 > > drop table tablename1, tablename2, ….; –删除多个表 #### 更改表名 > alter table t1 rename t2; #### 修改字段属性 > alter table tablename modify int(10) unsigned auto_increment primary key not null; #### 修改默认值 > alter table tablename alter id default 0; #### 给字段添加主键 > alter table tablename add primary key(id); #### 删除主键 > alter table tablename drop primary key; 或者 > drop primary key on tablename; #### 修改表数据引擎 > alter table tablename ENGINE = MyISAM(InnoDB); #### 增加新字段 (列) > alter table tablename add column single char(1); 或者 > alter table tablename add field int unsigned not null; #### 数据的备份和恢复 ##### 备份 > mysqldump -u root -p demo_db students > `C:\Users\CodeA\Desktop\abc.sql` ##### 恢复 进入 mysql > create database school; > use school; > source school.sql; ##### 导出数据库 mysqldump –databases db1 db2 >db1.db2.sql; ## [轻松掌握Java Properties文件操作,从入门到实践](https://blog.dong4j.site/posts/c89e3217.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) `public class Propertiesextends Hashtable` Properties 类表示了一个持久的属性集。Properties 可保存在流中或从流中加载。属性列表中每个键及其对应值都是一个字符串。 新建一个文件 内容为 键 1 = 值 1 键 2 = 值 2 … 使用此类可以对文件数据进行持久化保存 ### 方法的使用 - `load(InputStream inStream);` 从输入流中读取属性列表(键和元素对)。 ``` Properties pro = new Properties(); pro.load(new FileInputStream(相对路径或绝对路径));//此方法将抛出FileNotFoundException ``` - `getPropertiy(String key)` 使用指定的键在此属性列表中搜索属性, 返回 String - `getProperty(String key, String defaultValue)` 用指定的键在属性列表中搜索属性。返回 String - `setProperty(String key, String value)` 如果存在了这个键, 则更改对应的值, 如果不存在, 则添加这个属性, 这个方法是调用 Hashtable 的 put 的结果 - `public void store(OutputStream out, String comments) throws IOException` 写入文件中 ```java props.store(new FileOutputStream(相对路径或绝对路径), 说明); ``` ### Properties 综合使用 ```java import java.io.*; import java.util.*; public class PropertiesTest { static Properties props = new Properties(); public static void main(String[] args) { //读文件 Read(); System.out.println(props.getProperty("Teacher")); //添加数据 //使用setProperties()方法,如果文件中没有相同的键,则添加,如果有相同的,就覆盖 props.setProperty("Teacher","胡老大"); props.setProperty("Student","Code"); System.out.println(props.getProperty("Student")); Save(); //删除全部数据 //直接在没有加载文件的情况下使用store()方法存入空数据到文件中 Read(); //存入Sudtent对象 for(int i = 0 ; i < 3; i++){ Scanner scan = new Scanner(System.in); System.out.println("请输入学生名字:"); String name = scan.next(); System.out.println("请输入学生年龄:"); int age = scan.nextInt(); System.out.println("请输入学生成绩"); int score = scan.nextInt(); Student stu = new Student(name,age,score); props.setProperty(stu.getName(), stu.getName() + "&" + stu.getAge() + "&" + stu.getScore()); } Save(); //删除指定的键 直接调用Map类的remove方法 props.remove("Student"); Save(); //构造学生对象 //把值取出来分解,然后赋值给学生 //调用继承Map的values()方法,取得所有的值 Collection cl = props.values(); for(String str : cl){ System.out.println(str); //拆分字符串 } } public static void Read(){ try { props.load(new FileInputStream("data.properties")); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } //存文件 public static void Save(){ try { props.store(new FileOutputStream("data.properties"), null); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } ``` ## [HashSet、TreeSet、HashMap:Java集合类的核心](https://blog.dong4j.site/posts/220ed6be.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Java Conllection Frame ![20241229154732_HsNl759p.webp](https://cdn.dong4j.site/source/image/20241229154732_HsNl759p.webp) 在集合框架中,分为两种 API: 1. 装载数据的集合类 - HashSet 类 - ArrayList 类 - LinkedList 类 - HashMap 类 - …. 2. 操作集合的工具类 - Arrays 类 - Collections 类 ### Iterator 接口 java.util `public interface Iterator` Iterator 模式是用于遍历集合类的标准访问方法。 它可以把访问逻辑从不同类型的集合类中抽象出来,从而避免向客户端暴露集合的内部结构 ```java public interface Iterator{ boolean hasNext();//如果仍有元素可以迭代,则返回 true Object next();//返回迭代的下一个元素。 NoSuchElementException - 没有元素可以迭代。 void remove();//从迭代器指向的 collection 中移除迭代器返回的最后一个元素(可选操作)。 } ``` ### Iterable 接口 java.lang `public interface Iterable` 实现这个接口允许对象成为 “foreach” 语句的目标。 Iterable 只有一个方法 `iterator()` 返回一个迭代器 实现类包括今天所学的 ArrayList,HashSet,LinkedList 等类 然而并没有被 HashMap 类实现. ### 泛型 泛型(Generic type 或者 generics)是对 Java 语言的类型系统的一种扩展,以支持创建可以按类型进行参数化的类。可以把类型参数看作是使用参数化类型时指定的类型的一个占位符,就像方法的形式参数是运行时传递的值的占位符一样。 可以在集合框架(Collection framework)中看到泛型的动机。例如,Map 类允许您向一个 Map 添加任意类的对象,即使最常见的情况是在给定映射(map)中保存某个特定类型(比如 String)的对象。 因为 Map.get() 被定义为返回 Object,所以一般必须将 Map.get() 的结果强制类型转换为期望的类型,如下面的代码所示: ``` HashMap m = new HashMap(); m.put("key", "blarg"); String s = (String) m.get("key"); ``` 要让程序通过编译,必须将 get() 的结果强制类型转换为 String,并且希望结果真的是一个 String。但是有可能某人已经在该映射中保存了不是 String 的东西,这样的话,上面的代码将会抛出 ClassCastException。 ``` HashMap m = new HashMap(); m.put("key", "blarg"); String s = m.get("key"); ``` 理想情况下,可能会得出这样一个观点,即 m 是一个 Map,它将 String 键映射到 String 值。这可以**消除代码中的强制类型转换**,**同时获得一个附加的类型检查层**,该检查层可以防止有人将错误类型的键或值保存在集合中。这就是泛型所做的工作。 ### Collection 接口 `public interface Collection extends Iterable{}` > Collection 层次结构 中的根接口。Collection 表示一组对象,这些对象也称为 collection 的元素。一些 collection 允许有重复的元素,而另一些则不允许。一些 collection 是有序的,而另一些则是无序的。JDK 不提供此接口的任何直接 实现:它提供更具体的子接口(如 Set 和 List)实现。此接口通常用来传递 collection,并在需要最大普遍性的地方操作这些 collection。 Collection 接口定义了 Collection 对象公有的一些基本方法, 这些方法分为: - 基本操作 - int size() - isEmpty() - boolean contains(Object obj) 判断集合中是否包含指定元素 - boolean add(Object obj) 向集合中添加元素 - boolea remove(Object obj) 删除某元素 - Iterator iterator() 返回一个遍历器, 用来访问集合中的各个元素 - 批量操作 - containsAll(Collection c) 判断集合中是否包含指定集合 - …. - 数组操作 - Object[] toArray() 返回一个包含集合所有元素的 array 数组. #### List 接口 一列数据,数据内容可以重复,以元素安插的次序来放置元素,不会重新排列。 - 特点 - 其中的元素是有顺序的 - 允许重复元素 - 支持 null 元素 - 可以通过索引访问元素 - List 接口的实现类具有共同的方法: - add() —— 向集合中添加元素(增) - remove() – 将元素从集合中移除(删) - get() —— 从集合中获取元素(查) - set() —— 修改集合中的元素(改) - size() —— 查看集合长度 ##### ArrayList 特点: - 数组实现 - 可以添加相同的元素 - 使用最广泛,集合元素增加或删除操作不频繁时使用。最适合查询。 ```java ArrayList lst = new ArrayList(); lst.add(new Student("zhang3",25,80)); lst.add(new Student("li4",21,90)); Student stu = lst.get(1); lst.size(); for(Student stuTmp : lst){ System.out.println(stuTmp.getName()+ stuTmp.getAge()+ stuTmp.getScore()); } ``` ##### LinkedList 特点: - 双向链表实现 - 方法和 ArrayList 一样 - 当需要在集合的中间位置,频繁增加或删除元素时使用。 ```java //使用遍历器遍历各个元素 Iterator it = al.iterator(); while(it.hasNext()){ Object objtmp = it.next(); System.out.println(objtmp); } ``` ##### Vector 与 ArrayList 类似,但 Vector 是线程安全的,所以性能要低于 ArrayList #### Set 接口 一列数据,**数据内容不能重复**,使用自己内部的一个排列机制放置元素。 - 特点: - 不能包含重复元素 , 当加入一个元素到容器中时, 要比较元素的内容是否存在重复, 所以加入容器中的对象必须重写 equals() 方法; - 元素可能有序, 也可能无序 - 因为元素可能无序, 所以不能用索引访问 Set 中的元素 - Set 接口的实现类具有共同的方法: - add() —— 向集合中添加元素(增) - remove(Object o) – 将元素从集合中移除(删) - size() —— 查看集合长度 ##### HashSet 特点: - 速度快,不排序。 - 当遍历 HashSet 时, 其中的元素是没有顺序的 - HashSet 中不允许出现重复的元素 (重复的元素是指由相同的 hashCode, 并且 equals() 比较是, 返回 true 的两个对象) - 允许包含 null 的元素 ```java import java.util.HashSet; import java.util.Iterator; public class TestSet { public static void main(String[] args) { HashSet set = new HashSet(); set.add(new Student("zhang3",25,80)); set.add(new Student("li4",21,90)); set.add(new Student("wang5",27,70)); Student stu = new Student("zhao6",22,65); set.add(stu); set.add(stu);//Set集合不能放入重复元素 System.out.println(set.size()); //要取出元素只能有遍历的方式,而且不支持普通for循环(因为没有下标) //迭代器 Iterator it = set.iterator(); while(it.hasNext()){ Student stutmp = it.next(); System.out.println(stutmp.getName()); } for(Student stutmp : set){ System.out.println(stutmp.getName()); } //没有修改方法 //删除--只能按对象移除,没有按位置移除 set.remove(stu); } } ``` ##### TreeSet 速度慢,排序。 ### Map 接口 一列数据对,使用自己内部的一个排列机制放置元素 Map 接口用于维护 键 / 值对 (key/value pairs)。 每个条目包括单独的两部分 : - key - Value 特点: - 在 Map 中不允许出现重复的键, 但是可以有重复的值 - key 和 value 可以是任何类的实例 共同的方法: - put() 将键值对存入集合 - get() 根据键取出元素的值 - keySet() 将 Map 中的所有键取出形成一个 Set - values() 将 Map 中的所有值取出形成一个 Collection - remove() 根据键移除值 - containsKey 判断 HashMap 中是否存在这个键 - containsValue 判断 HashMap 中是否存在这个值 #### HashMap - 速度快,不排序。 ```java import java.util.Collection; import java.util.HashMap; import java.util.Set; public class TestMain { public static void main(String[] args) { HashMap map = new HashMap(); //放元素 map.put("001", new Student("zhang3",25,80)); map.put("002", new Student("li4",21,90)); map.put("003", new Student("wang5",27,70)); System.out.println(map.size()); Student stu = new Student("zhao6",22,65); map.put("004", stu); map.put("005", stu); System.out.println(map.size()); //向重复的键放入新的值,相当于在做修改动作. map.put("005", new Student("chen7",18,62)); //取元素是根据键去取值 Student stu1 = map.get("003"); System.out.println(stu1.getName()); //删除元素也给根据键去删除 map.remove("002"); System.out.println(map.size()); //遍历 Set ks = map.keySet();//得到所有的键 for(String key : ks){ System.out.println(map.get(key).getName()); } Collection cl = map.values();//得到所有的值 for(Student stutmp : cl){ System.out.println(stutmp.getName()); } //获取某个键是否存在于Map对象当中 System.out.println(map.containsKey("007")); //获取某个值是否存在于Map对象当中 System.out.println(map.containsValue(stu)); } } ``` #### Properties 方便操作属性文件) ![20241229154732_BjJ8f4hM.webp](https://cdn.dong4j.site/source/image/20241229154732_BjJ8f4hM.webp) ## [Java编程基础:类型转换和操作技巧详解](https://blog.dong4j.site/posts/eee067eb.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 1. 看代码说结果: ```java public static void main(String[] args){ char x = ‘b’; int i = 0; 1. System.out.println(true?x:0); //这个0是short类型 2. System.out.println(true?x:1111111110); 3. System.out.println(false?i:x); } ``` **要类型转换** **直接写出的 0 是 short 类型** > b > 98 > 98 解释: 条件运算符的 3 个核心要点 - 如果第二个和第三个操作数具有相同的类型, 那么他就是条件表达式的类型. - 如果一个操作数的类型是 byte,shotr 或 char, 而另一个操作数类型是 int 的常量表达式, 条件表达式的值是可以用类型 byte,short 或 char 表示的, - 否则, 将操作数类型运用二进制数字提升 (向上转型), 二表达式的类型就是第二个和第三个操作数提升后的类型 1. `System.out.println(true?x:0);` 对于语句 1 中的条件表达式, 第二个操作数是 char 类型, 第三个操作数是一个整型常量, 符合核心要点的第二点. 其实这里的常量 0, 实质是一个 short 类型的常量, 和 char 类型一样都占 2 个字节, 所以不会发生转型. 当执行 print 语句的时候, 将调用 PrintStream.print(char) 这个被重载的方法, 输出 b 2. `System.out.println(true?x:1111111110);` 这条语句同样适用于核心要点的第二点, 将调用 PrintStream.print(long) 方法, 输出 98 3. `System.out.println(false?i:x)` 因为 x 是 int 类型的, 所以必定放生向上转型, char 自动转型为 int i 的值变为 98, 调用 PrintStream.print(int) 方法, 输出 98. ## “+” 运算符和 “+=” 运算符 ```java char a = 'A'; 1. a = a + 1;//报错 2. a += 1; ``` 1.`a = a + 1;//报错` 这条语句为什么会报错? 在 Java 编程思想中有这样一句话 > 加号的唯一作用就是将较小数据类型的操作数提升为 **int** 这句话我们可以的得知, 只要是比 int 小的基本数据类型, 用加号与一个整数常量连接的时候, 会被自动转型为 int, 这也是为什么 `a = a + 1` 会报 _int 类型不能转换成 char 类型 _ 的原因了 2.`a += 1;` 为什么这句有没有报错? 因为 += 运算符存在隐式强转, 这条语句等价于: `a = (char)((int)a + 1 )` ## 交换值的问题 有 a,b 个 int 类型的变量, 要求不通过临时变量交换 2 个变量的值 ```java public class Change{ public static void main(String[] args){ int a = 7; int b = 5; a = a ^ b; b = b ^ a; a = a ^ b; System.out.println(a + " " + b); } } ``` 这种方法是最正确也是最高效的 另一种方法 ```java a = a + b ; b = a - b ; a = a - b ; ``` 这种方法看似可以, 但是却不正确, 因为没有考虑到数据溢出问题 题上并没有说 a 和 b 的值有多大, a 和 b 都是 int 类型, 最大存储范围为 `2147483647` 到 `-2147483648` ```java class Test { public static void main(String[] args) { System.out.println(Integer.MAX_VALUE); System.out.println(Integer.MIN_VALUE); } } ``` 问题出在 `a = a + b;` 如果当 `a+b` 的值大于了 `2147483647` 就会发生数据溢出, 从而导致后面的语句得不到正确的值. ## [Java编程进阶之路:学习Collections和Arrays的强大功能](https://blog.dong4j.site/posts/d366874c.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### Collections 类 此类方法众多, 只介绍几个常用方法 - sort() 对元素进行排序, 可以传入比较器 - max() 返回最大元素, 可以传入比较器 - min() 返回最小元素, 可以传比较器 - reverse() 反排序 - shuffle() 混排 ### Arrays 类 此类包含用来操作数组(比如排序和搜索)的各种方法。 方法更多, 还是看 API 吧 ### 比较器 #### Comparable 内部比较器 `public interface Comparable` 此接口强行对实现它的每个类的对象进行整体排序。这种排序被称为类的自然排序,类的 compareTo 方法被称为它的自然比较方法。 `int compareTo(T o)` 一个类继承了 Comparable 接口后, 重写 compareTo() 方法, 就能按照我们自己的规则排序 例子: ```java public class Student implements Comparable{ private String name; private int age; private int score; public Student() { } public Student(String name, int age, int score) { super(); this.name = name; this.age = age; this.score = score; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } public int getScore() { return score; } public void setScore(int score) { this.score = score; } /** * 比较规则是按照年龄来排 */ @Override public int compareTo(Student stu) { // TODO Auto-generated method stub if(this.age > stu.getAge()){ return 1; }else if(this.age < stu.getAge()){ return -1; }else{ return 0; } } @Override public String toString() { // TODO Auto-generated method stub return "名字:" + this.name + ";年龄:" + this.age + ";成绩:" + this.score; } } ``` 当把 Student 对象存储在 List 集合中的时候, 可以使用 Collections 工具类对这个 Student 集合进行排序. - 如果调用 Collections 的 `sort(List list)` 方法, 就会根据元素的自然顺序 对指定列表按升序进行排序 - 当调用 `sort(List list, Comparator c)` 方法时, 当我们传入一个比较器的时候, 就会根据我们自己定义的规则进行排序 ```java ArrayList arrayStudent = new ArrayList(); arrayStudent.add(new Student("张三",26,90)); arrayStudent.add(new Student("李四",20,76)); arrayStudent.add(new Student("王武",24,68)); Collections.sort(arrayStudent); ``` 因为在 Student 类继承 Comparable 接口, 重写了此接口的 compareTo() 方法, 当对 arrayStudent 集合进行排序的时候, 会按照我们重写后的规则进行排序 #### Comparator 外部比较器 接着上面的例子 重新定义一个实现了 Comparator 接口的类 ```java public class StudentComparator implements Comparator{ @Override public int compare(Student stu1, Student stu2) { // TODO Auto-generated method stub if(stu1.getName().length() > stu2.getName().length()){ return 1; }else if(stu1.getName().length() < stu2.getName().length()){ return -1; }else{ return 0; } } } ``` 然后调用 `Collections.sort(arrayStudent,new StudentComparator());` 将按照我们的规则进行排序. 在以前我们只能比较基本数据类型和字符串 现在学习了比较器之后, 我们就可以比较 (排序) 对象了, 只要继承了 Comparable 或者传入一个实现了 Comparator 接口的自己定义的比较规则的对象, 就可以按照我们的想法对元素进行排序. ## [Java 编程:抽象类与接口的使用指南](https://blog.dong4j.site/posts/64ccc6a0.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### 抽象类 > 把多个类中相同的方法声明给抽取出来。定义到一个类中。 - 一个方法如果只有方法声明,没有方法体,那么该方法必须定义为抽象方法。 - 而一个类中如果有抽象方法,那么,该类必须定义为抽象类。 #### 抽象类的特点: - 抽象方法和抽象类都必须用 abstract 表示。 - 一个类继承抽象类的时候; - 要么:本身是一个抽象类。 - 要么:实现抽象类中的所有抽象方法。 - 抽象类有构造方法, 但是不能实例化, 可以按照多态的使用方式使用。 - 成员特点: - 成员变量 - 可以是变量,也可以是常量。 - 构造方法 - 有 用于子类访问父类构造方法,初始化父类数据 - 成员方法 - 可以是抽象,也可以非抽象。 - 抽象类的好处: - 抽象类中的非抽象方法提高了代码的复用性。 - 抽象类中的抽象方法强制要求子类必须重写某些方法。 - 抽象类中的几个小问题: - 抽象类不能实例化,构造方法有什么用呢? - 用于子类访问父类数据的初始化。 - 如果一个类中没有抽象方法,而类却被定义为了抽象类,请问为什么? - 不让创建对象。 - abstract 不能和哪些关键字共存: - private: 冲突 - final: 冲突 - static: 无意义 ### 接口 (掌握) > 如果一个抽象类中的所有成员方法都是抽象的,java 就提高了一种更抽象的表达方式:接口。 #### 接口的特点: - 接口用 interface 定义。 类实现接口用 implements 关键字。 - 一个类要实现接口: - 本身是抽象类。 - 实现接口中的所有抽象方法。 - 接口不能实例化。可以按照多态的使用方式使用。 - 成员特点: - 成员变量:只能是常量。 - 默认修饰符:public static final - 成员方法:只能是抽象方法。 - 默认修饰符:public abstract #### 使用接口的原则 1. 使用接口解决多继承 2. 使用接口为外部类添加功能 3. 以面向对象的角度考虑, 将一个类与生俱来的特征和行为和依赖于外部的可选的特征和行为分离, 让类尽可能的单纯, 即解耦 接口的优点 1. 将设计和实现分离, 对外 (调用者) 隐藏了实现 (而通常调用者也不需要关心实现) 2. 面向接口编程是 OOP 的核心 #### 类与接口的关系: - 类与类的关系 - 继承: 单继承。 - 类与接口的关系 - 实现: 单实现,多实现。 - 继承一个类的同时实现多个接口。 - 接口与接口的关系 - 继承: 单继承,多继承。 #### 抽象类和接口的区别: - 成员区别 - 抽象类: - 成员变量 - 可以是变量,也可以是常量。 - 构造方法 - 有 - 成员方法 - 可以是抽象,也可以非抽象。 - 接口: - 成员变量:只能是常量。 - 默认修饰符:public static final - 成员方法:只能是抽象方法。 - 默认修饰符:public abstract - 关系区别 - 类与类的关系 - 继承,单继承。 - 类与接口的关系 - 实现,单实现,多实现。 - 继承一个类的同时实现多个接口。 - 接口与接口的关系 - 继承,单继承,多继承。 - 设计理念不同 - 抽象类被继承体现的是:is a 的关系。抽象类中定义的是继承体系共性功能。 - 接口被实现体现的是:like a 的关系。接口中定义的是继承体系的扩展功能。 什么时候把抽象方法写到抽象类中–> 与生俱来的行为 什么时候把抽象方法写到接口中 –> 附属添加的第三方的行为 ### 总结 1. 抽象类在 java 语言中所表示的是一种继承关系,一个子类只能存在一个父类,但是可以存在多个接口。 2. 在抽象类中可以拥有自己的成员变量和非抽象类方法,但是接口中只能存在静态的不可变的成员数据(不过一般都不在接口中定义成员数据),而且它的所有方法都是抽象的。 3. 抽象类和接口所反映的设计理念是不同的,抽象类所代表的是 “is-a” 的关系,而接口所代表的是 “like-a” 的关系。 抽象类和接口是 java 语言中两种不同的抽象概念,他们的存在对多态提供了非常好的支持,虽然他们之间存在很大的相似性。但是对于他们的选择往往反应了您对问题域的理解。只有对问题域的本质有良好的理解,才能做出正确、合理的设计。 ## [理解 Java 多态性:编译时和运行时的区别](https://blog.dong4j.site/posts/ac4294eb.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) > **相同的行为, 不同的实现** 多态多态, 多种状态, 而多种状态的体现就在不同的实现上面, 也可以说是不同的效果. 举个例子: 我这里有 3 个杯子, 里面都装了白酒, 你并不知道它们都是什么酒, 只有等你喝过之后才能知道, 第一个杯子是二锅头, 第二个是五粮液, 第三个是茅台. ``` Maotai mo = new Maotai(); Wine w = mo; ``` 这里我们可以这样理解, 茅台是酒类的一种, 它们之间是继承关系, 当 a 指向了 mo 这个对象的时候, 它会自动向上转型为 Wine, 这也就是说 w 是可以指向茅台这个实例. 这样做的好处就是, 在继承中我们知道子类是父类的扩展, 它可以提供比父类更加强大的功能,如果我们定义了一个指向子类的父类引用类型,那么它除了能够引用父类的共性外,还可以使用子类强大的功能 但是向上转型存在一些缺憾,那就是它必定会导致一些方法和属性的丢失,而导致我们不能够获取它们 所以对于多态我们可以总结如下: - 指向子类的父类引用由于向上转型了,它只能访问父类中拥有的方法和属性,而对于子类中存在而父类中不存在的方法,该引用是不能使用的,尽管是重载该方法。若子类重写了父类中的某些方法,在调用这些方法的时候,必定是使用子类中定义的这些方法(动态连接、动态调用)。 不过还是可以调用子类中特有的方法, 后文中将会讲到. - 对于面向对象而已,多态分为编译时多态和运行时多态。 - 编辑时多态是静态的,主要是指方法的重载,它是根据参数列表的不同来区分不同的函数,通过编辑之后会变成两个不同的函数,在运行时谈不上多态。 - 运行时多态是动态的,它是通过动态绑定来实现的,也就是我们所说的多态性。 #### 多态的分类 1. 静态多态 (编译期确定要执行的效果, 编译器只是做语法检查) - 重载 (Overload) 实现 2. 动态多态 (编译期不知道运行效果, 而是运行期根据绑定对象的不同确定结果) - 重写 (Override) - 动态绑定 当一个父类引用指向子类对象的时候 为什么父类不能调用子类重载之后的方法? 为什么父类调用的方法的实现是子类重写后的实现? - 因为重载是对于一个类而言的, 当子类继承父类, 然后在本类中重载了父类的这个方法, 对于父类而言是看不到这个在子类中重载之后的方法的, 因为它只属于子类, 所以当父类调用这个方法的时候, 因为父类中根本就没有这个方法, 所以编译器会报错; - 然而当父类调用被子类重写的过后的方法时, 因为父类中存在同名方法, 编译器不会报错, 然后通过动态绑定, 将被调用的这个方法和子类关联起来, 所以方法实现的是子类重写过后的效果. ##### 动态绑定 - 将一个方法调用与该方法所在的类关联起来; - 在运行时候根据具体对象的类型绑定; - 当父类引用指向子类的对象, 调用重写的方法时, 是调用子类中重写的方法; ##### 转型技术 (类之间必须要有继承关系) 左右两边数据类型不一致 - 向上转型 –> 自动 `Person p = new Man();` 此时的引用 p 只能看见父类的方法, 而看不到子类所特有的方法 - 向下转型 –> 强制 `Man m = (Man)p` 此时 m 引用可以看到全部的方法, 因为 m 指向了一个 Man 对象. 这样就可以调用子类对象中访问修饰符允许的方法和属性了 #### 多态的引用–异构 集合 创建一个不是同一类型, 但是有共同父类的数据集合, 不同对象的集合称为异构集合 #### 多态的好处和弊端: A: 好处 提高了程序的可维护性和可扩展性。 维护性:继承保证 扩展性:多态保证 B: 弊端 父类 / 父接口不能访问子类特有功能。 #### 多态的体现形式: - 具体类多态 ```java class Fu {} class Zi extends Fu {} Fu f = new Zi(); ``` - 抽象类多态 ```java abstract class Fu {} class Zi extends Fu {} Fu f = new Zi(); ``` - 接口多态 ```java interface Inter {} //接口的实现类命名:接口名+Impl class InterImpl implements Inter{} Inter i = new InterImpl(); ``` ### 总结 - 使用父类类型的引用指向子类的对象; - 该引用只能调用父类中定义的方法, 不能调用子类的特有的属性和方法; - 如果子类中重写父类的方法, 那么调用该方法, 将会调用子类中重写后的实现; - 在多态中, 子类可以调用父类的所有非私有方法; - 父类的引用可以指向子类的对象, 子类的引用不能指向父类的对象; ## [Java继承的多态魅力:向上转型的艺术](https://blog.dong4j.site/posts/11d8b32d.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Java 继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。这种技术使得复用以前的代码非常容易,能够大大缩短开发周期,降低开发费用。比如可以先定义一个类叫车,车有以下属性:车体大小,颜色,方向盘,轮胎,而又由车这个类派生出轿车和卡车两个类,为轿车添加一个小后备箱,而为卡车添加一个大货箱。 1. 子类拥有父类非 private 的属性和方法。 2. 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。 3. 子类可以用自己的方式实现父类的方法。(重载) ### 继承 (类与类的关系) 有相同的属性和相同的行为时, 定义为父类, 其他的 子类 只需要写特有的属性和方法. 在面向对象中, 可以通过扩展一个已有的类, 并继承该类的属性和行为, 来创建一个新的类, 从而达到代码的复用. 继承是所有 OOP 语言不可缺少的部分, 在 java 中使用 `extends` 关键字来表示继承关系。 当创建一个类时,总是在继承,如果没有明确指出要继承的类, 就总是隐式地从根类 Object 进行继承。 继承不会继承父类的构造方法 因为: 1. 语法上如果继承来了父类的构造方法, 但是类名是父类名, 跟子类名不一样 2. 在面向对象的思想上说不通 **类与类的关系** - has a (组合) - 属性 - is a (继承) - use a (使用) - 参数或局部变量 **关于私有属性 有没有继承 能不能继承 算不算继承?** **私有属性在子类的存储空间当中他是切实存在的** 子类可以继承父类的所有成员跟方法,继承下来不代表可以访问,要访问得看访问控制规则。私有属性也可以继承,不过根据访问控制规则,私有属性虽继承下来却不可以访问的,只有通过 public 的方法才能访问继承下来的私有属性。 B 继承 A 类,。A 类中的私有属性,到了 B 会怎么样,能继承、访问吗? 答案是:如果 A 中的属性有增加 setter getter 方法,可以访问的: 比如下面这段代码: ```java class Person { private String name; private int age; public Person() { } public Person(String name, int age){ this.name = name; this.age = age; } public void setName(String name){ this.name = name; } public String getName(){ return name; } public void setAge(int age){ this.age = age; } public int getAge(){ return age; } } class Man extends Person { } ``` 类 Man 继承于 Person 类,这样一来的话, Person 类称为父类(基类),Man 类称为子类(导出类 / 派生类)。 如果两个类存在继承关系,则子类会自动继承父类的方法和变量, 在子类中可以调用父类范围访问修饰符允许的方法和变量。 在上面的例子中, 子类实例可以通过 setter 方法访问父类的私有属性, 所以就证明了子类是继承了父类的全部属性和方法的. 只是这样就破坏了封装性, 在 java 中,只允许单继承,也就是说 一个类最多只能显示地继承于一个父类。 但是一个类却可以被多个类继承,一个类可以拥有多个子类。 #### 子类继承父类的成员变量 当子类继承了某个类之后,便可以使用父类中的成员变量, 但是并不是完全继承父类的所有成员变量。具体的原则如下: - 能够继承父类的 public 和 protected 成员变量;能够继承父类的 private 成员变量 (用 setter,getter 方法来使用 private 属性); - 对于父类的包访问权限成员变量,如果子类和父类在同一个包下,则子类能够继承;否则,子类不能够继承; - 对于子类可以继承的父类成员变量,如果在子类中出现了同名称的成员变量,则会发生隐藏现象,即子类的成员变量会屏蔽掉父类的同名成员变量。如果要在子类中访问父类中同名成员变量,需要使用 `super` 关键字来进行引用 #### 子类继承父类的方法 - 对于父类的包访问权限成员方法,如果子类和父类在同一个包下,则子类能够继承;否则,子类不能够继承; - 对于子类可以继承的父类成员方法,如果在子类中出现了同名称的成员方法,则称为覆盖,即子类的成员方法会覆盖掉父类的同名成员方法。如果要在子类中访问父类中同名成员方法,需要使用 super 关键字来进行引用。 #### 构造器 子类是不能够继承父类的构造器,它只能够被调用.   值得注意的是,如果父类的构造器都是带有参数的,**则必须在子类的构造器中显示地通过 super 关键字调用父类的构造器并配以适当的参数列表。**如果父类有无参构造器,则在子类的构造器中用 super 关键字调用父类构造器不是必须的,如果没有使用 super 关键字,系统会自动调用父类的无参构造器。 **这是为什么呢?:** 因为使用子类时候, 实例化一个子类对象时, 然后就可以调用父类的 public 属性和方法; 但是在调用父类属性和方法之前, 我们并没有实例化父类对象, 所以这个时候, 必须在子类构造器中调用父类构造器; - `子类 a = new 子类();` - 如果父类没有无参构造器, 则在子类构造器中必须显示的用 super 调用父类构造器. - 如果父类有无参构造, 则可以不写 super, 系统自动调用父类无参构造; 当 new 一个子类时, 调用子类构造器, 然后子类构造器中调用父类构造器, 看下面这个例子就清楚了: ```java class Shape { protected String name; public Shape(){ //无参构造方法 作用是初始化name name = "shape"; } public Shape(String name) { //有参构造方法 new shape( "张三") this.name = name; } } class Circle extends Shape { //Circle 继承 Shape private double radius; public Circle() { //无参构造方法 radius = 0; } public Circle(double radius) { this.radius = radius; } public Circle(double radius,String name) { this.radius = radius; this.name = name; } } ``` 这样的代码是没有问题的,如果把父类的无参构造器去掉,则下面的代码必然会出错: 改成下面这样就行了: ```java class Shape { protected String name; //public Shape(){ //name = "shape"; //} public Shape(String name) { this.name = name; } } class Circle extends Shape { private double radius; public Circle() { radius = 0; } public Circle(double radius) { this.radius = radius; } public Circle(double radius,String name) { super(name); this.radius = radius; this.name = name; } } ``` 子类的构造方法,不管这个构造方法带不带参数,默认的它都会先去寻找父类的不带参数的构造方法。如果父类没有不带参数的构造方法,那么子类必须用 supper 关键子来调用父类带参数的构造方法,否则编译不能通过 ##### super super 主要有两种用法: 1. super. 成员变量 / super. 成员方法; 2. super(parameter1,parameter2….) 第一种用法主要用来在子类中调用父类的同名成员变量或者方法;第二种主要用在子类的构造器中显示地调用父类的构造器,要注意的是,如果是用在子类构造器中,则必须是子类构造器的第一个语句。 [this 和 super 关键字的区别](http://blog.csdn.net/codeai/article/details/46851293) 可以在我的另一篇博客中看到 #### 向上转型 在上面的继承中我们谈到继承是 is-a 的相互关系,猫继承与动物,所以我们可以说猫是动物,或者说猫是动物的一种。这样将猫看做动物就是向上转型。如下: ```java public class Person { public void display(){ System.out.println("Play Person..."); } static void display(Person person){ person.display(); } } public class Husband extends Person{ public static void main(String[] args) { Husband husband = new Husband(); Person.display(husband); //向上转型 } } ``` 在这我们通过 Person.display(husband)。这句话可以看出 husband 是 person 类型。 将子类转换成父类,在继承关系上面是向上移动的,所以一般称之为向上转型。由于向上转型是从一个叫专用类型向较通用类型转换,所以它总是安全的,唯一发生变化的可能就是属性和方法的丢失。这就是为什么编译器在 “未曾明确表示转型” 或“未曾指定特殊标记”的情况下,仍然允许向上转型的原因。 这也是多态的一种, 关于多态, 将在下一篇博客中总结. **常见的面试笔试题** 1. 下面这段代码的输出结果是什么? ```java public class Test { public static void main(String[] args) { new Circle(); } } class Draw { public Draw(String type) { System.out.println(type+" draw constructor"); } } class Shape { private Draw draw = new Draw("shape"); public Shape(){ System.out.println("shape constructor"); } } class Circle extends Shape { private Draw draw = new Draw("circle"); public Circle() { System.out.println("circle constructor"); } } ``` > 输出 1.shape draw construtor > 2.shape construtor > 3.circle draw constructor > 4.circle constructor 这道题目主要考察的是类继承时构造器的调用顺序和初始化顺序。要记住一点:父类的构造器调用以及初始化过程一定在子类的前面。由于 Circle 类的父类是 Shape 类,所以 Shape 类先进行初始化,然后再执行 Shape 类的构造器。接着才是对子类 Circle 进行初始化,最后执行 Circle 的构造器。 类继承时构造器的调用顺序和初始化顺序 ![20241229154732_11OcFtmH.webp](https://cdn.dong4j.site/source/image/20241229154732_11OcFtmH.webp) 在子类中访问一个变量时的查找顺序 (查找方法一样) ![20241229154732_RJx49v3S.webp](https://cdn.dong4j.site/source/image/20241229154732_RJx49v3S.webp) ### 重写 Override 发生在继承的时候, 子类方法跟父类方法重名, 发生了覆盖, 重写子类方法的实现; 1. 方法名相同 2. 参数列表相同 3. 返回类型相同 4. 访问修饰符必须大于等于父类 5. 抛出的异常不能比父类多 #### 隐藏和覆盖的区别 - 隐藏发生在子类属性名跟父类属性名同名的时候, 在子类中调用重名的属性的时候, 只能访问到子类的那个属性; 如果要调用父类的同名属性, 必须用 super. 属性名调用 - 覆盖发生在子类方法名跟父类方法名相同的时候, 也要用 super. 方法名 () 来调用父类中的同名方法; #### 重载和重写 重写: 是有继承关系的 2 个类发生的 重载: 一个类中有多个相同的方法名 ### 总结: 继承: 把具有相同属性, 相同行为的类抽取出来, 作为一个父类, 然后另一个类去继承这个类, 那么这个类就有了父类的属性和方法 **关系:** is a **特点:** 内存上, 叠加 **效果:** 子类拥有父类的属性和行为方法, 但是不继承父类的构造方法; **构造调用顺序:** 先父类构造, 再子类构造 修改父类的行为–> 重写 **谨慎继承** 上面讲了继承所带来的诸多好处,那我们是不是就可以大肆地使用继承呢?送你一句话:慎用继承。 首先我们需要明确,继承存在如下缺陷: 1、父类变,子类就必须变。 2、继承破坏了封装,对于父类而言,它的实现细节对与子类来说都是透明的。 3、继承是一种强耦合关系。 所以说当我们使用继承的时候,我们需要确信使用继承确实是有效可行的办法。那么到底要不要使用继承呢?《Think in java》中提供了解决办法:问一问自己是否需要从子类向父类进行向上转型。如果必须向上转型,则继承是必要的,但是如果不需要,则应当好好考虑自己是否需要继承。 ### Object 类 Object 类是所有类的直接父类, 如果声明一个类时没有显式的继承另一个类, 那么这个类就会继承 Object 类, 从而拥有了 Object 类中的所有方法, 数组也不例外. #### Object 类的中方法 Object 类一共有 11 个方法, 作为祖宗类, 而这 11 个方法还得以保留, 说明这些方法的通用性很高, 所以我们都必须掌握. 1. `protected Object clone()` 创建并返回此对象的一个副本 2. `boolean equals(Object obj)` 指示其他某个对象是否与此对象 “相等”。 - 对于 Object 类中, equals 的底层实现就是使用的 `==` 运算符, 而这并不能满足我们对对象的比较, 所以必须重写, 在 String 类中就重写了此方法 - 基本思路: - 先判断是否是同一个类的实例, 如果是就返回 true; - 如果不是, 再分别比较每个属性值, 如果有一个不同, 就返回 flase - 如果都相同, 就返回 true 3. `protected void finalize()` 当垃圾回收器确定不存在对该对象的更多引用时,由对象的垃圾回收器调用此方法。 4. `Class getClass()` 返回此 Object 的运行时类 (反射中用到)。 5. `int hash Code()` 返回该对象的哈希码值 6. `void notify()` 唤醒在此对象监视器上等待的单个线程 7. `void notifyAll()` 唤醒在此对象监视器上等待的所有线程 8. `String toString()` 返回该对象的字符串表示 - 此方法的实现: - `getClass().getName() + '@' + Integer.toHexString(hashCode())` - 如果不重写这个此方法, 返回的是 `类名@引用值`, 对于我们来说没用, 所以一般使用 toString 方法时都需要重写 9. `void wait()` 在其他线程调用此对象的 notify() 方法或 notifyAll() 方法前,导致当前线程等待。 10. `void wait(long timeout)` 在其他线程调用此对象的 notify() 方法或 notifyAll() 方法,或者超过指定的时间量前,导致当前线程等待 11. `void wait(long timeout, int nanos)` 在其他线程调用此对象的 notify() 方法或 notifyAll() 方法,或者其他某个线程中断当前线程,或者已超过某个实际时间量前,导致当前线程等待。 ## [封装与多态:Java编程的核心](https://blog.dong4j.site/posts/d60eb45.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### 什么是封装 - 隐藏对象的属性和实现细节,仅对外公开接口; - 控制在程序中属性的读和修改的访问级别; - 将抽象得到的数据和行为(或功能)相结合,形成一个有机的整体,也就是将数据与操作数据的源代码进行有机的结合,形成 “类”,其中数据和函数都是类的成员。 **通俗的说就是:** 利用抽象数据类型将数据和基于数据的操作组合在一起, 是其构成一个不可分割的独立实体, 数据被保护在抽象数据类型的内部, 尽可能的隐藏内部的细节, 只保留一些对外的接口使之与外部发生联系. 用户无需知道内部的实现细节, 但可以通过该对象对外提供的接口来访问该对象. #### 封装的好处 1. 良好的封装能够减少耦合 2. 类内部的结构可以自由修改 3. 可以对成员进行更精确的控制 4. 隐藏信息, 实现细节 #### JavaBean **JavaBean 规范:** 标准 java 类 Sun 公司规定 为私有属性提供符合命名规范的 set 和 get 方法 2. JavaBean 必须声明为 public class 3. JavaBean 的所有属性必须声明为 private 4. 通过 setter 和 getter 方法设值和取值 5. 通过 JSP 调用是, 则需要一个无参的构造方法 6. 编写代码要严格遵循 Java 程序的命名规范 #### geter 和 setter 更深入理解 - 当把类的属性和方法访问权限设置为 private 时, 一般都提供上面 2 个公共方法; 在这 2 个方法体内, 可以添加一些限制代码; 比如说 setter 方法, 需要用户输入账号密码才有权限修改 getter 也可以这么做, 还可以在返回值的时候做一些动作; 这就是隐藏. - 也可以用 private 修饰 setter 方法或不提供, 那么这个属性就是只读; - 用 private 修饰 getter 方法或不提供, 这个属性就是只写; ##### 一个简单的封装 ```java class Person { private String name; private int age; private double weight; private boolean genger; private boolean count = true; public Person(){ } public Person(String name, int age, double weight, boolean genger){ this.name = name; this.age = age ; this.weight = weight; this.genger = genger; } public void setName(String name){ //访问修饰符是public,说明可以被外界调用 //但是有条件,值允许被调用一次,意思是可以修改一次姓名; if(count){ this.name = name; count = false; }else System.out.println("姓名只允许被修改一次"); } //外界只能取得名字 public String getName(){ return name; } //setAge,让外界不能调用此方法, //表示一个人的年龄是只读的,别人无权更改 private void setAge(int age){ this.age = age; } public int getAge(){ return age; } private void setWeight(double weight){ this.weight = weight; } public double getWeight(){ return weight; } //此处没有setGenger方法,表示人的性别不能被更改 public String getGenger(){ return genger ? "男" : "女"; } } public class EncapsulationDemo{ public static void main(String[] args) { Person p = new Person("张三",26,62.8,true); System.out.println("姓名:" + p.getName() + "\n" + "年龄:" + p.getAge() + "岁\n" + "体重:" + p.getWeight() + "Kg\n" + "性别:" + p.getGenger() ); } } ``` **数组的封装** ``` public class ArrayClass { private int[] array; // 下标 private int index; // 新建数组长度 private int size; // 调用无参构造的时候,默认初始大小为1; public ArrayClass() { size = 1; array = new int[size]; index = 0; } // 调用有参构造的时候,用户自定义数字大小 public ArrayClass(int size) { this.size = size; array = new int[size]; index = 0; } } ``` ### 方法的重载 (Overload) **同一个类中的方法名相同, 参数列表不同; 和返回值类型无关** 重载是静态多态的前提 我是这样理解重载的 > 妈, 我饿了, 我想吃蛋炒饭 –> 然后我妈给我炒了蛋炒饭吃 > 我, 我饿了, 我想吃饺子 –> 然后我妈给我煮了饺子吃 > 我, 我饿了 –> 然后我妈给了我一杯白开水….. ```java //伪代码 //妈妈类 class Mon{ public void 煮饭的方法(){ System.out.println("多喝点开水就不饿了"); } public void 煮饭的方法(蛋炒饭){ System.out.println("乖儿子来吃蛋炒饭"); } public void 煮饭的方法(饺子){ System.out.println("乖儿子来吃饺子"); } } public class Test{ public static void main(String[] args){ Mon m = new Mon(); m.煮饭的方法(饺子); m.煮饭的方法(蛋炒饭); m.煮饭的方法(); } } ``` 我调用我妈妈的煮饭的方法, 传入不同的参数, 我妈妈做出不同的反应. 就这样吧, 这就是重载, 不知道懂了没有…… **为什么不能用返回值类型类区分重载** 比如下面两个方法,虽然他们有同样的名字和形式,但却很容易区分它们: Java 代码 `void f(){}` `int f(){reurn1;}` 只要编译器可以根据语境明确判断出语义,比如在 int x =f() 中,那么的确可以据此区分重载方法。 不过,有时你并不关心方法的返回值,你想要的是方法调用的其他效果,这时你可能会调用方法而忽略其返回值。 所以,如果像下面这样调用方法:f();此时 Java 如何才能判断该调用哪一个 f() 呢? 因此,根据方法的返回值来区分重载方法是行不通的 ## [Java基础到进阶:Math类与包装类的学习之路](https://blog.dong4j.site/posts/5d1ffcf3.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### Math 类 Math 类中所有的属性和方法都是静态的, 也就是说全露都可以可用 Math. 属性和 Math. 方法名调用属性和方法. - 属性 - `static double E`: 比任何值都接近 e 的 double 值 - `static double PI`: 比任何值都接近 pi 的 double 值 - 几个常用的方法 - `ceil(double a)` : 返回大于等于这个参数的整数 - Math.ceil(-12.1) 返回 - 12 ; Math.ceil(12.8) 返回 13; - `floor(double a)`: 返回小于等于参数的整数; - Math.floor(-12.1) 返回 - 13;Math.floor(12.9) 返回 12; - `rint(double a)`: 返回接近参数并等于某一整数的 double 值 - Math.floor(-12.1) 返回 - 12;Math.floor(12.9) 返回 13; - `random()`: 返回 `[0,1)` 之间的 double 值; ### 随机数 获得随机数的 3 种方法 1. 通过 `System.currentTimeMillis()` 获取当前时间毫秒数的 long 型数字作为随机数 2. 使用 `Math.random()` 3. 通过 `Random` 类产生一个随机数 - `Random r = new Random()` - 默认使用当前时间 `System.currentTimeMillis()` 作为生成器的种子, 每次产生的随机数都不同 - `Random r1 = new Random(10)` - 使用固定的种子, 每次生成的随机数都相同 ### 包装类 为了贯彻执行 **一切皆是对象** 的指导方针, 对于不是对象的 8 种基本类型, 我们也要想办法给他们找对象, 所以包装类这个媒婆就出现了. | 基本数据类型 | 包装类 | | :----------- | --------: | | boolean | Boolean | | byte | Byte | | char | Character | | short | Short | | int | Integer | | long | Long | | float | Float | | double | Double | 值得注意的是: - 所有包装类都是 final 类型, 不能创建子类; - 包装类是不可变的, 一旦创建了一个包装类对象, 那么它包含的基本类型数据就不能改变 #### 基本数据类型, 包装类和 String 类之间的转换 **基本 转 包装** 1. 自动装箱; `Integer in = 10;` 2. 调用包装类的带参构造方法; `Integer in = new Integer(10);` 3. 调用包装内的静态方法 valueOf(int i) `int a = Integer.valueOf(10);` **包装 转 基本** 1. 自动拆包; `Integer in = 10;` `int a = in;` 2. 调用包装类对象的 xxxValue() 方法; `Integer in = 10;` `int a = in.intValue();` **基本类型 转 String** ```java int a = 10; String str = "" ; ``` 1. 用 “+”; `str = str + a;` 2. 用包装类的工具类转换成对象, 然后用对应包装类的 toString(变量) 转; `new Integer(a).toString();` 或者 `Integer.toString(a);` **String 转 基本类型** ```java String s = "12345"; int a = Integer.parseInt(s); ``` **String 转 包装类** 1. 调用包装类的带参构造 ```java String str = "200"; Integer in = new Integer(str); ``` 2. 调用包装类的 valueOf() 方法 `Integer in = Integer.valueOf(str);` **包装类 转 String** ```java Integer in = new Integer(10); String s = ""; ``` `s = in.toString();` ## [不可变与可变,解析Java三种字符串类差异](https://blog.dong4j.site/posts/58a301ae.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 字符串操作在编程中我们会大量使用, 所以掌握字符串相关类对我们来说很重要. Java 为我们提供了 3 种操作字符串的类. 由于 `String` 类的特殊, 有必要了解一下 Java 运行时内存的概念, 才能更好的理解字符串相关类的底层操作. ### Java 中内存知识 在反射中我们学到, 对于每一个被 JVM 加载到内存中的类, 都会在方法区保存一份这个类的信息; 包括: - 类的基本信息 - 类的全名, 直接父类的全民 - 该类的接口 - 该类的访问修饰符 - 类的详细信息 - 运行时常量池 –> 字符串, 常量, 类名和方法名常量. - 字段信息 –> 字段名, 类型, 修饰符 - 方法信息 –> 方法名, 返回值类型, 参数类型, 修饰符, 异常, 方法的字节码 - 静态变量 –> 在方法区中的静态区保存被类的所有实例共享的变量和静态快 - 到类 classloader 的引用 - 到类 class 的引用 JVM 为每一个被加载到内存的类型创建一个 class 实例, 用实例代表这个被加载的类 由此引出反射的概念: 在加载类的时候, 加入方法区的所有信息, 最后都会影城 Class 类的实例, 代表这个被加载的类. 方法去中的所有信息, 都是可以通过这个 Class 类对象反射得到. #### Java 运行时数据区的划分 - 程序计数器(Program Counter Register) - 本地方法栈 (Native Method Stacks) - Java 虚拟机栈 (Java Visual Machine Stacks) - 局部变量, 引用名等保存在栈中 - 不共享, 每个线程会有属于自己的栈 - Java 堆 (Java Heap) - 被 `new` 关键字所创建出来的所有实例对象 - 被各个线程共享 - 方法区 (Method Area) - 被各个线程共享 - 存储类信息, 常量, 静态变量, 以及编译后的代码等数据 ### String 类 #### String 类的声明 `public final class String extends Object implements Serializable, Comparable, CharSequence` String 类声明中, 是被 final 修饰, 所以可以看出字符串的内容是不可变的, 而且是不能被继承的类; 一旦一个 String 对象被创建, 包含在这个对象中的内容是不可改变的, 直至这个对象被回收; 其实 String 类在底层就是通过字符数组来实现的, 对于数组, 我们知道它的长度不可变, 自然而然的 String 类也不可变了. #### String 类在内存中的表现形式 1. 隐式创建 `String str1 = "helloworld";` - 在栈中声明一个 str1 的引用, 然后在**常量区**中查找是否有”helloworld” 这个常量; - 如果有, 则直接返回一个引用给 str1; - 如果没有, 则先在常量区中创建 ‘helloworld” 字符串, 然后返回引用; 2. 显式创建 `String str2 = new String("张三");` - 在栈中声明一个 str2 的引用, 然后在**堆**中创建一个字符串对象, 里面的值是” 张三”; - 返回这个对象的引用; 然后我们可以通过 对象调用 String 类的方法, 但是问题来了, `"helloworld".toCharArray()` 这句代码不会报错, 说明 `"helloworld"` 可以当一个对象来使用, 那么堆中的 `"helloworld"` 和常量池中的 `"helloworld"` 是什么关系呢? 那就让我们来说说他们 2 个之间不得不说的故事. ```java 1. String str1 = "helloworld";//helloworld在常量区中 2. String str2 = new String("helloworld");//对象在堆中 ``` ![20241229154732_ZOosCdOB.webp](https://cdn.dong4j.site/source/image/20241229154732_ZOosCdOB.webp) 对于上面 2 行代码, 会在不同的区域创建 2 个对象. - 执行第一行时, - 在栈中声明一个 str1 的引用, 然后在**常量区**中查找是否有”helloworld” 这个常量; - 如果有, 则直接返回一个引用给 str1; - 如果没有, 则先在常量区中创建 ‘helloworld” 字符串, 然后返回引用; - 执行第二行时, 会在堆中创建一个字符串对象, 这个对象的值为 `"helloworld"`, 然后再去常量区参看是否有 `"helloworld"` 这个常量字符串; - 如果没有, 就在常量区创建一个 `"helloworld"` 字符串常量, 然后与这个对象关联; - 如果有, 则把常量区的 `"helloworld"` 引用同堆中对象关联起来; 无论如何, 只要用 new 关键字实例化一个个字符串对象, 都会在堆中创建实例, 而隐式创建的话则不一定新建一个字符串常量; 那么问题来了 `String str2 = new String("helloworld");` 会在内存中创建几个对象, 几个引用? 答案是 3 个对象, 2 个引用; - 对象 - String 这个类的对象 (反射机制) - String 实例化对象 (堆中) - 常量区中的字符串对象 - 引用 - 堆中字符串对象的引用 - 常量区中字符串的引用 我们可以通过 String 类的 intern() 方法来实验一下 当调用 intern 方法时,如果常量池中已经包含一个等于此 String 对象的字符串(用 equals(Object) 方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并返回此 String 对象的引用 ```java public static void main (String[] args) { String s1 = "osEye.net"; String s2 = new String ("osEye.net"); if (s1 == s2) { System.out.println ("字符串引用s1和字符串引用s2所指向的是同一个对象"); } else { System.out.println ("字符串引用s1和字符串引用s2所指向的不是同一个对象"); if (s1.intern() == s2.intern() ) { System.out.println ("字符串引用s1和字符串引用s2在字符串常量池中联系的是同一个对象"); } else { System.out.println ("字符串引用s1和字符串引用s2在字符串常量池中联系的不是同一个对象"); } } } ``` > 输出 > 字符串引用 s1 和字符串引用 s2 所指向的不是同一个对象 > 字符串引用 s1 和字符串引用 s2 在字符串常量池中联系的是同一个对象 这种机制很好的实现了共享和节省内存空间, 当我需要使用字符串时, 如果内存中有这个字符串了, 就不会再创建, 而是直接返回已存在的字符串的引用. 对于 `String s = "hello" + "world"` 的分析 在 JDK1.5 之前, 这条语句会生成 2 个字符串常量, 但是在 JDK1.5 之后, 编译器对此做出了优化, 最终只有一个字符串常量”helloworld” 存入常量区中. 这种优化就是默认的调用了 StringBuilder.append() 方法; #### String 类的使用 介绍几个常用的字符串操作的方法 - `charAt(int index)` 返回指定索引处的 char 值。 - `equals(Object anObject)` 将此字符串与指定的对象比较。 - `equalsIgnoreCase(String anotherString)` 将此 String 与另一个 String 比较,不考虑大小写 - `indexOf(int ch)` 返回指定字符在此字符串中第一次出现处的索引。 - `length()` 返回此字符串的长度。 - `matches(String regex)` 告知此字符串是否匹配给定的正则表达式。 - `replace(char oldChar, char newChar)` 返回一个新的字符串,它是通过用 newChar 替换此字符串中出现的所有 oldChar 得到的。 - `split(String regex)` 根据给定正则表达式的匹配拆分此字符串。 - `substring(int beginIndex)` 返回一个新的字符串,它是此字符串的一个子字符串。 - `trim()` 返回字符串的副本,忽略前导空白和尾部空白。 - `toCharArray()` 将此字符串转换为一个新的字符数组。 判断是否为空字符串 `s != null && s != “”; ` **注意**判断不能为空必须放在前面, 因为可能会报空指针异常 ### StringBuffer 类 - 为了能让 String 里面的内容能够改变, 所以用 StringBuffer 类封装了 String 类; - 使用 append() 方法向字符串末尾添加字符串, - 线程安全的, 效率比 String 高 - 经常使用在字符串拼接的地方, 比如说数据库语句 ### StringBuilder 类 #### 与 StringBuffer 类的对比 ```java //StringBuilder类的append()方法 public synchronized StringBuffer append(String str){ super.append(str); return this; } //StringBuffer类的append()方法 public StringBuffer append(String str){ super.append(str); return this; } ``` StringBuffer 是线程不安全的, 效率比 StringBuffer 高, 在不考虑线程安全的时候优先考虑使用 StringBuffer. ### 3 种字符串对比 - 异同点 - 都是 `final` 类, 都不允许被继承; - `String` 长度不可改变, 其他 2 种长度可变; - `StringBuffer` 是线程安全的, 但是效率低, `StringBuilder` 线程不安全但是效率高 - 性能: `StringBuilder > StringBuffer > String` - 使用策略 - 基本原则: - 如果操作少量的数据, 用 `String` ; - 单线程操作大量数据, 使用 `StringBuilder` ; - 多线程操作大量数据, 使用 `StringBuffer` ; - 不建议使用 `String` 类的 “+” 进行频繁的字符串拼接, 而是使用 `StringBuilder` 或者 `StringBuffer` 类; - `StringBuilder` 一般使用在方法内部来完成类似”+” 功能, 因为线程不安全, 用完后丢弃; - `StringBuffer` 主要用在全局变量中; - 相同情况下使用 `StirngBuilder` 相比使用 `StringBuffer` 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。而在现实的模块化编程中,负责某一模块的程序员不一定能清晰地判断该模块是否会放入多线程的环境中运行,因此:除非确定系统的瓶颈是在 `StringBuffer` 上,并且确定你的模块不会运行在多线程模式下,才可以采用 `StringBuilder` ;否则还是用 `StringBuffer` 。 ## [Java异常处理全解析:掌握try-catch-finally,开启更安全的编程之旅](https://blog.dong4j.site/posts/28877bf.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### 概述 - Java 异常是 Java 提供的一种识别及响应错误的一致性机制。 - Java 异常机制可以使程序中异常处理代码和正常业务代码分离,保证程序代码更加优雅,并提高程序健壮性。 - 在有效使用异常的情况下,异常能清晰的回答 what, where, why 这 3 个问题: - **异常类型** –>” 什么” 被抛出; - **异常堆栈跟踪** –>” 在哪” 抛出; - **异常信息** –>” 为什么” 会抛出; ### Throwable 类 ![20241229154732_Fgnc2ygU.webp](https://cdn.dong4j.site/source/image/20241229154732_Fgnc2ygU.webp) Throwable 是 Java 语言中所有错误或异常的超类。 Throwable 包含两个子类: Error 和 Exception。它们通常用于指示发生了异常情况。 Throwable 包含了其线程创建时线程执行堆栈的快照,它提供了 printStackTrace() 等接口用于获取堆栈跟踪数据等信息。 #### Exception Exception 及其子类是 Throwable 的一种形式,它指出了合理的应用程序想要捕获的条件。 #### RuntimeException RuntimeException 是那些可能在 Java 虚拟机正常运行期间抛出的异常的超类。 编译器不会检查 RuntimeException 异常。例如,除数为零时,抛出 ArithmeticException 异常。RuntimeException 是 ArithmeticException 的超类。当代码发生除数为零的情况时,倘若既” 没有通过 throws 声明抛出 ArithmeticException 异常”,也” 没有通过 try…catch… 处理该异常”,也能通过编译。这就是我们所说的” 编译器不会检查 RuntimeException 异常”! 如果代码会产生 RuntimeException 异常,则需要通过修改代码进行避免。例如,若会发生除数为零的情况,则需要通过代码避免该情况的发生! #### Error 和 Exception 一样,Error 也是 Throwable 的子类。它用于指示合理的应用程序不应该试图捕获的严重问题,大多数这样的错误都是异常条件。 和 RuntimeException 一样,编译器也不会检查 Error。 ### 异常分类 (Exception): Java 将可抛出 (Throwable) 的结构分为三种类型: 1. 被检查的异常 (CheckedException) 2. 运行时异常 (RuntimeException) 3. 错误 (Error) #### **运行时异常** - 定义: RuntimeException 及其子类都被称为运行时异常。 - 特点: Java 编译器不会检查它。也就是说,当程序中可能出现这类异常时,倘若既” 没有通过 throws 声明抛出它”,也” 没有用 try-catch 语句捕获它”,还是会编译通过。例如,除数为零时产生的 ArithmeticException 异常,数组越界时产生的 IndexOutOfBoundsException 异常,fail-fail 机制产生的 ConcurrentModificationException 异常等,都属于运行时异常。 虽然 Java 编译器不会检查运行时异常,但是我们也可以通过 throws 进行声明抛出,也可以通过 try-catch 对它进行捕获处理。 如果产生运行时异常,则需要通过修改代码来进行避免。例如,若会发生除数为零的情况,则需要通过代码避免该情况的发生! #### **被检查的异常** (编译期异常) - 定义: Exception 类本身,以及 Exception 的子类中除了” 运行时异常” 之外的其它子类都属于被检查异常。 - 特点: Java 编译器会检查它。此类异常,要么通过 throws 进行声明抛出,要么通过 try-catch 进行捕获处理,否则不能通过编译。例如,CloneNotSupportedException 就属于被检查异常。当通过 clone() 接口去克隆一个对象,而该对象对应的类没有实现 Cloneable 接口,就会抛出 CloneNotSupportedException 异常。 被检查异常通常都是可以恢复的。 #### **错误** - 定义: Error 类及其子类。 - 特点: 和运行时异常一样,编译器也不会对错误进行检查。 当资源不足、约束失败、或是其它程序无法继续运行的条件发生时,就产生错误。程序本身无法修复这些错误的。例如,VirtualMachineError 就属于错误。 按照 Java 惯例,我们是不应该实现任何新的 Error 子类的! 对于上面的 3 种结构,我们在抛出异常或错误时,到底该哪一种?《Effective Java》中给出的建议是: > **对于可以恢复的条件使用被检查异常,对于程序错误使用运行时异常**。 ### 异常处理关键字 - **try** –> 用于监听。将要被监听的代码 (可能抛出异常的代码) 放在 try 语句块之内,当 try 语句块内发生异常时,异常就被抛出。 - **catch** –> 用于捕获异常。catch 用来捕获 try 语句块中发生的异常。 - **finally** –> finally 语句块总是会被执行。它主要用于回收在 try 块里打开的物力资源 (如数据库连接、网络连接和磁盘文件)。只有 finally 块,执行完成之后,才会回来执行 try 或者 catch 块中的 return 或者 throw 语句,如果 finally 中使用了 return 或者 throw 等终止方法的语句,则就不会跳回执行,直接停止。 > finalize() 是 Object 类的一个方法, 用来回收没有被引用的对象, 被 GC 调用; - **throw** –> 用于抛出异常。 - **throws** –> 用在方法签名中,用于声明该方法可能抛出的异常。 ### 异常的控制流程 在 java 中, 异常是被一个方法抛出的对象. 当一个方法抛出异常时, 该方法从调用栈中被弹出, 同时把产生的异常的对象抛给栈中的钱一个方法. #### 异常处理 - 捕获这个异常, 不然它沿着调用栈继续向下抛出 - 捕获这个异常, 并继续向下抛出 - 不捕获这个异常, 从而导致方法从调用栈中被弹出, 异常对象继续抛出给调用栈的下面的方法 - Try 程序块里面的语句是按顺序执行的语句 - 当 try 程序块里面的语句抛出一个异常的时候,程序的控制转向了相匹配的 catch 程序块,catch 程序块里面的语句被执行。 - 当异常发生后,程序执行将忽略 try 程序块中剩余的语句,继续执行程序块后面的语句。 - 如果在 try 程序块中没有抛出异常,那么 catch 块将被忽略。程序将继续执行 try-catch 下面的语句 - 在一个 try-catch 语句中,当有多个 catch 块的时候,它们被顺序检查 - 在检查过程中,注意异常的匹配关系是很重要的 - 当一个异常被抛出,与它相匹配的 catch 块被执行,其它的 catch 块,就被忽略掉不再执行 - 如果没有 catch 块匹配抛出的异常,那么系统会在堆栈中搜索,找到一个匹配的捕获方法。 - 如果仍然没有找到,那么系统将处理抛出异常 - 即使在 try 程序块内部有一个 return 语句,finally 语句块也要被执行 - 当在 try 程序块中,遇到 return 语句,finally 块中的语句在方法返回之前被执行 ### 异常处理流程 1. 如果程序产生了异常, 那么会自动的由 JVM 根据异常的类型, 实例化一个指定异常类的对象; 2. 如果没有处理异常的操作, - 就交给 JVM 默认处理–> 输出异常信息 3. 如果有异常处理, - 则由 try 语句捕获异常类对象; - 然后匹配后面的 catch 语句, - 如果成功, 则使用指定的处理语句 - 如果都不匹配, 则由 JVM 默认处理 4. 不管是否有异常, 都会执行 finally 语句, 如果没有异常, 执行完 finally, 则会继续执行之后的其他语句; 如果此时有异常没有处理 (没有 catch 匹配), 那么拽会执行 finally 语句, 但是执行完 finally 后, 将默认交给 JVM 进行异常的输出, 并且程序中断. ![20241229154732_ngGAJMzB.webp](https://cdn.dong4j.site/source/image/20241229154732_ngGAJMzB.webp) ### 异常的抛出 #### **throws** 一个完整的方法声明: `访问修饰符 可选修饰符 返回类型 方法名(参数列表) throws 异常类型` 作用: - 通知本方法的调用者, 本方法**有可能**有某种或几种类型的异常 - 子类重写的方法不能抛出比父类更多 (范围) 的异常; 3 种类: - 表示层类: 主要负责 UI 的, 跟用户打交道, 放在最外面的一层; - 业务层类: 处理表示层接受到的数据; - 持久层类: 存储数据; #### **throw** 抛出异常对象 throw new 异常类 (); 执行到 throw 时一定会有异常的抛出, 就会执行异常抛出执行流程 ### 自定义异常类 - 继承 Exception 类; - 重载 Exception 的构造方法, 一般都是构造 3 个带参构造方法; - 目的是把其他异常的信息封装到自定义异常信息中; - 自定义需要的业务上的异常欣喜; - 添加自定义异常的特有方法; - 大概思路: - 新定义一个异常类, 继承 Exception 类 - 在 B 内中的方法中声明抛出自定义异常 - 在 B 方法内会出现异常的语句用 try - catch 包起来 - 在 catch 语句中 throw new 自定义异常类名 (参数列表); - 如果 A 类是 B 类的方法的调用者, 如果不处理异常, 必须在方法中声明 throws 自定义异常类名 - 如果 mian 方法是 A 的调用者, 如果不抛出异常, 就必须处理这个异常; - 如果抛出异常, 可以不处理, 交给 JVM 处理; ### finall 关键字 ```java public classTest { public static void main (String[] args) { System.out.println (new Test().test() ); } static int test() { int x = 1; try { return x; } finally { ++x; } } } ``` > 输出 –>1 ```java public class smallT { public static void main (String args[]) { smallT t = new smallT(); int b = t.get(); System.out.println (b); } public int get() { try { return 1 ; } finally { return 2 ; } } } ``` > 输出 –>2 ```java public classTest { public static voidmain (String[] args) { System.out.println (newTest().test() );; } int test() { try { return func1(); } finally { return func2(); } } int func1() { System.out.println ("func1"); return 1; } int func2() { System.out.println ("func2"); return 2; } } ``` > 输出: > func1 > func2 > 2 ## [Java 关键字解析:new、static 与 final](https://blog.dong4j.site/posts/fe7c7d2d.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### new 要谈 new 关键字, 就必须先说说构造方法, 它们 2 个就是一对好基友. #### 构造方法 ##### 定义 - 无参构造方法: `访问修饰符 类名(){ }` - 带参构造方法 `访问修饰符 类名(参数列表){ }` ##### 说明 1. 构造方法可以被重载; 2. 没有返回类型 (void 也算返回类型, 只是没有返回值); 3. 方法名和类名必须一样; 4. 如果不写, 系统会默认提供一个无参构造方法; 5. 如果写了, 系统就不会提供任何构造方法; 6. 子类不能继承父类的构造方法; 7. 接口没有构造方法; ##### 作用 **产生对象** new 一个对象的时候构造器所要做的事 1. 创建空间; 2. 划分属性; 3. 初始化值; 4. 执行构造方法内的语句; #### 实例化一个对象 用 new 关键字实例化一个对象时, 所用做的事: `People p = new People();` 1. 加载 People,class 文件进入内存; 2. 在栈内存为 p 变量申请一个空间 (4 个字节); 3. 在堆内存为 People 对象申请空间 (大小为类中所有成员变量的大小); 4. 对类中的成员变量进行默认初始化; 5. (如果有) 对类的成员变量进行显示初始化; 6. 有构造代码块, 先执行构造代码块中的语句; 7. 执行构造方法中的语句; 8. 把堆内存的引用赋给 p 变量; ### static (类级别的, 与对象无关) 可以修饰属性, 方法, 代码块和内部类; #### static 修饰属性 (类变量) - 那么这个属性就可以用 `类名.属性名` 来访问, 也就是使这个属性称为本领的类变量, 为本类对象所共享; #### static 修饰方法 (静态方法) - 会使这个方法称为整个类所公有的方法, 可以用 `类名.方法名` 访问; - static 修饰的方法, 不能直接访问本类中的非静态成员, 但本类的非静态方法可以访问本类的静态方法; - 在静态方法中不能出现 `this` 关键字; - 父类中是静态方法, 子类中不能覆盖为非静态方法; - 在符合覆盖规则的前提下, 在父子类中, 父类中的静态方法可以被子类中的静态方法覆盖, 但是没有多态; - 在使用对象调用静态方法时, 其实是在调用编译时类型的静态方法; #### static 修饰初始化代码块 - 此时初始化代码块叫做静态初始化代码块, 这个代码块只在类加载时被执行一次; - 可以用静态初始化代码块初始化一个类; - 动态初始化代码块, 些在类体的 `{}` 中, 这个代码块在生成对象时运行; **例子** ```java class StaticTest{ static int a = 3; static int b ; static void method(int x){ System.out.println("x = " + x); System.out.println("a = " + a); System.out.println("b = " + b); } static { System.out.println("静态初始化代码块"); b = a * 4; } public static void main(String[] args){ method(5); } } ``` > 输出结果: > 静态初始化代码块 > x = 5 > a = 3 > b = 12 **执行顺序** 1. 首先加载 `StaticTest` 类, 在静态区开辟空间, 给 `a` 赋值为 `3`,`b` 赋值为 `0`; 2. 接着执行静态初始化块, 先打印一条语句, 然后把 `a*4` 的值赋给 `b`; 3. 然后把 `main()` 方法调入栈中,`main()` 调用 `method()` 方法, 把 `3` 传给 `x`; 4. 然后打印; **注意**: 在一个 static 方法中引用任何实例变量都是非法的. #### main() 中的 static 一般情况下,主方法是静态方法,所以可调用静态方法,主方法为静态方法是因为它是整个软件系统的入口,而进入入口时系统中没有任何对象,只能使用类调用。 #### 说明 在有 static 定义的类的外面, static 方法和变量能独立于任何对象而被使用. 例如想从类的外部调用一个 static 方法: `className.method();` 或者调用一个 static 属性: `className.属性名;` 这就是 Jaba 如何实现全局功能和全局变量的一个控制版本. **例子** ```java class StaticDemo{ static int a = 42; static int b = 99; static void callMe(){ System.out.println("a = " + a); } } class StaticByName{ public static void main(String[] args){ StaticDemo.callMe(); System.out.println("b = " + StaticDemo.b); } } ``` > 输出结果: > a = 42 > b = 99 最后一点: static 成员不能被其所在 class 创建的实例访问的. **简单来说** 1. 如果不加 static 修饰的成员是对象成员, 也就是归每个对象所有; 2. 加 static 修饰的成员是类成员, 就是可以由一个对象直接调用, 为所有对象公有. #### 从内存的角度来看 static 类加载的过程中, 类本身也是保存在文件中的 (字节码文件保存着类的信息), java 通过 I/O 流把类文件读入 JVM, 这个过程称为类的加载. JVM 会通过类路径 (classpath) 来找字节码文件. 需要的时候才会进行类的加载, 生成对象时是先加载后构造. 类变量, 会在加载时自动初始化, 初始化规则和实例变量相同. **注意**: 类中的实例变量是在创建对象时被初始化的. static 修饰的属性, 是在类加载时被创建并进行初始化, 类加载的过程只有一次, 也就是类变量只会被创建一次. #### 静态非静态成员的调用 - 静态成员能直接调用静态成员; - 非静态成员不能调用静态成员, 能调用非静态成员; **原因:** 从内存的角度, static 修饰的成员是随着类的加载而创建的, 如果此时用静态成员调用非静态成员, 则会报错, 因为在内存中还没有这个非静态成员; 非静态成员只有在实例化对象的时候才在内存中创建, 所以非静态成员可以调用静态成员. 说白了就一句话: > 先出生的不能娶还没有出生的;(指腹为婚不在讨论的范围之内) > 后出生的长大了可以娶先出生的 (现在很流行啊), 也可以娶一起出生的; ### final 最终的意思, 不允许改变; 可以修饰变量, 方法和类. #### final 修饰变量 - 一旦被 final 修饰的变量, 就会变成常量, 不能被重新赋值. - 常量可以在初始化的时候直接赋值, 也可以在构造方法里赋值, - 只能二选一, 不能为常量重新赋值; - 常量没有默认值; - 锁定栈, 使栈中的数据不可以改变; - 静态常量只能在初始化时直接赋值; ##### 关于引用的问题 **问题:** 使用 final 修饰的变量, 是引用不能变还是引用的对象不能变? ```java final StringBuffer a = new StringBuffer("finalTest"); a = new StringBuffer("");//报编译器错误 a.append("finalTest");//这样可以 ``` 所以被 final 修饰的变量, 是引用变量不能变, 引用变量所指向的对象中的内容还是可以改变的. #### final 修饰方法 被 final 修饰的方法就不能被子类重写. #### final 修饰类 - 被 final 修饰的类将不能被继承. - final 类中的方法也都是 final 的. **注意**: final 不能用来修饰构造方法. > public static final int MAX = 100; 常量属性定义为 static, 节约空间 final: 因为用 final 修饰, 所以是个常量 public: 因为是常量, 所以不能更改, public 修饰给别人看也没事 static: 共享, 这样就不用 new 出来才能使用; **补充说明:** 内部类要访问局部变量时, 局部变量必须定义为 final. **为什么局部内部类不能访问没有被 final 修饰的局部变量?** > 从内存中看,当方法里的局部变量所在方法结束时,该变量即在栈内存中消失;而内部类其实是一个类,只有内存中对它的所有引用都消失后,该内部类才”死亡”,即内部类的生命周期可能比局部变量长。如果局部内部类能访问一般的局部变量,则在多线程中,可能当方法结束后,局部内部类 (是线程类时) 还在使用局部变量。为了避免方法内的变量脱离方法而存在的现象发生,于是 java 规定局部内部类不能访问一般的局部变量。但能访问被 final 修饰的局部变量。因为常量放在堆内存中的数据段的常量池中 ### finally 是异常处理语句的一个部分, 是异常的同意出口, 表示总是执行. 就算是没有 catch 语句同时又抛出异常的情况下, finally 代码块仍然会被执行。 最后要说的是,finally 代码块主要用来释放资源,比如:I/O 缓冲区,数据库连接 ### finalize() 是 Object 类的一个方法, 在垃圾收集器执行的时候, 会调用被回收对象的此方法, 可以覆盖此方法提供垃圾收集时的其他资源回收, 例如关闭文件等. JVM 不保证此方法总被执行. ### abstract - 抽象类不能实例化对象, 但是有构造方法, 抽象类中可以包含属性, 抽象类中的构造方法是用来初始化成员属性的; - 所有抽象对象必须被重写, 除非子类也是抽象类; - 可以存在没有抽象方法的抽象类; - 有抽象方法的类必须定义为抽象类; - `abstract type name_method();` - 只能修饰方法和类; 剩下的详细总结将在面向对象的特征中介绍. ### instanceof A instanceof B A 对象是否是 B 对象的实例 ```java public boolean equals(Object obj){ if(obj instanceof Student){ if(student.name.equals(name)) return true; } return false; } ``` ### this 1. 对当前类的引用; 2. 每个对象都有一个隐含的 this 变量, 它可以访问类的所有信息; 3. 构造方法的相互调用, 必须放在构造方法内的第一个语句; 在重载的构造器中, 为了重复利用代码 ```java class Gril { private int age; private String name; //构造方法 public Gril(int age ,String name){ this(name); this.age = age; //this.name = name; } public Gril(String name){ this.name = name; } public Gril(){ } //睡觉的方法 public void sleep(Car car){ System.out.println("在车上睡觉"); } public void sleep(Bed bed){ System.out.println("在床上睡觉"); } public void sleep(){ System.out.println("在野外睡觉"); } } ``` ### super this 和 super 详解 1. this this.–> 此时的 this 代表当前对象, 可以操作当前类中的所有属性和方法; 也可以操作当前类父类访问修饰符允许的方法和属性 1. super 代表当前对象的父类, 只能操作当前类父类中访问修饰符允许的属性和方法; - this 不能看到的时候, super 也看不到, this 能看到的, super 还是看不到; - 只有在发生重写的时候, 才用 super 调用当前对象的父类中同名的方法和属性; - this() 调用本类的其他构造方法; - super() 调用当前对象的父类的构造方法; - 都只能放在构造方法的第一行; - this() 没有默认; - super() 为默认公共无参构造; 关于 super 更详细的介绍将在面向对象之继承中见到. ## [Java编程基础:掌握四种访问修饰符(public、protected、default和private)](https://blog.dong4j.site/posts/f941d9b7.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) ### public > 谁都可以操作 > > - 当在方法或属性前面显显式的给定 public 限定符的时候, 其具有该权限控制. > - public 权限是最为宽松的一种权限控制, 对包的内, 外部都是完全可见的. > - java 最多只允许一个 java 文件中出现一个 public 类.(该类向外间提供接口, 并与该 java 文件的名称完全一致) > - 当一个 java 文件中无一个 public 类时, 表明其仅供包访问, 对外界不可见. > - **注意:** 类只有包访问权限和 public 访问权限. ### 默认访问修饰符 (friendly) > 同一个包内可以操作 - 当方法或属性未给定访问修饰符时, 其默认具有该权限. - 具有该权限的方法和成员, 其包内是完全可见的, 而包外不可见. ```java //Animal.java和Test.java在同一包下; //Animal.java public class Animal{ void eat(){ System.out.println("Animal吃东西的方法"); } } //Test.java public class Test{ public static void main(String[] args){ Animal a = new Animal(); a.eat(); } } ``` 由于 Animal 类和 Test 类都被打包在同一个 package 下, Animal 类中的 eat() 方法为默认访问权限, 故对 Test 可见. 对 java 文件中的类也是如此, 如果未指定访问权限, 其默认为包访问权限, 只能在包内被访问. 包外是无法利用其实例化对象的. **注意:** 当决定一个类对包外可见的时候, 除了要将类的访问修饰符改为 public 外, 自定义的构造方法限定符也必须改为 public, 不然将导致外部不可见. ### protected > 同包或者导出类可以操作 > > - protected 权限是一种严格程度介于 public 和 private 之间的访问修饰符. > - 具有 protected 权限的或方法只能对自身和导出类可见. ### private > 除了自己, 谁都不能操作 > > - private 是访问修饰符中最为严格的一种权限. > - 当方法或属性为 private 权限的时候, 表明其只针对该类的内部可见, 类的外部 (包括同包内的其他类) 都是不可以见的. ```java //Animal.java和Test.java在同一包下; //Animal.java public class Animal{ private void eat(){ System.out.println("Animal吃东西的方法"); } } //Test.java public class Test{ public static void main(String[] args){ Animal a = new Animal(); a.eat();//将造成编译错误,eat()方法为private,仅对Animal类内部可见,现在在Test类内部,所以不可见. } } ``` ### 总结 - 在面向对象的设计当中, 最常见的为 public 和 private 权限修饰符. - 一般情况下将定义为 private, 将方法定义为 public. 外界使用该类时, 通过 public 方法是用其接口, 而具体的成员则对外屏蔽, 只能通过类提供的节后间接访问. ```java public class Dog{ private int age ; public void setAge(int num){ age = num + 1 ; } } ``` 此处, age 对外部不可见, 要想对其进行操作, 必须使用 Dog 类提供的接口 setAge(int num). 此处, age 对外部不可见, 要想对其进行操作, 必须使用 Dog 类提供的接口 setAge(int num). | 作用域 | 当前类 | 同包 | 子类 | 其他包 | | :-------: | :----: | :--: | :--: | :----: | | public | √ | √ | √ | √ | | protected | √ | √ | √ | × | | 默认 | √ | √ | × | × | | private | √ | × | × | × | 全文完 ## [在Java中正确使用long类型:避免计算时意外丢失精度](https://blog.dong4j.site/posts/b0abc087.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 话说有这样一个小例子: MICROS_PER_DAY 表示一天的微秒数 MILLIS_PER_DAY 表示一天的毫秒数 然后下面例子的结果是多少呢? ```java public class Test3 { public static void main(String[] args) { final long MICROS_PER_DAY = 24 * 60 * 60 * 1000 * 1000; final long MILLIS_PER_DAY = 24 * 60 * 60 * 1000; System.out.println(MICROS_PER_DAY / MILLIS_PER_DAY); } } ``` So easy 数据类型为 `long` , 很容易保存这两个乘积不产生溢出. 因此, 结果肯定是 `1000`! but….. 结果是: ![20241229154732_wTbwOPoH.webp](https://cdn.dong4j.site/source/image/20241229154732_wTbwOPoH.webp) **解释:** 为什么答案与我们想象不一样呢? 因为数据溢出了… 你在逗我? 但我没学过 java? long 能表示 -2 的 63 次方到 2 的 63 次方 - 1 的整数. 数都数不过来, 怎么会溢出? 哈哈, 小心陷阱啊, 虽然我们定义的是 `long` 类型, 准确的说最终的结果应该是 `long` 类型的. 我们看看表达式右边, `24 * 60 * 60 * 1000 * 1000` 这个表达式是以 `int` 类型作为运算的, `int` 跟 `int` 类型相乘, 结果还是 `int` 类型, 最终结果超过 int 所能保存的范围, 所以数据溢出了, 然后才被 `long` 所保存; **改进** `24 * 60 * 60 * 1000 * 1000`–>`24L * 60 * 60 * 1000 * 1000` 在表达式随便哪个数值后面加上一个 `l` 或者 `L` 就搞定了, 其结果会自动转换为 `long` 而不是 `int` 了, 然后再保存到 `long` 类型变量中. 就是这么简单, 就是这么任性. ## [Java编程基础:如何正确判断一个数字是奇数还是偶数](https://blog.dong4j.site/posts/2363f1e9.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 下面的方法意图确定它那唯一的参数是否是一个奇数. 这个方法能够正确运转吗? ```java import javax.swing.JOptionPane; public class Test1 { public static void main(String[] args) { String s = JOptionPane.showInputDialog("请输入一个整数"); int a = Integer.parseInt(s); System.out.println(a); if (isOdd(a)) System.out.println("奇数"); else { System.out.println("偶数"); } } public static boolean isOdd(int i) { return i % 2 == 1; } } ``` 结果: 1. 当输入正整数时, 可以正确判断奇偶性; 2. 当输入负整数时, 结构都为 false; 解释: % 这个运算符的结果, 是根据前一个数值的正负性来判断, 如果是负数, 结果就是负数; 所以当输入的值是负数时, 结果都是负数, 返回的结果是 false; 改进: 1. 只要 `i % 2` 的结果不为零, i 就是一个奇数; ```java public static boolean isOdd(int i) { return i % 2 != 0; } ``` 2. 用按位于 (&) 运算符 `i & 1` 结果如果为 `1`, 则 `i` 为奇数, 结果为 `0`, 则 `i` 为偶数 ```java public static boolean isOdd(int i) { return i & 1 == 1; } ``` **% 操作符详解:** 如果有: a: int 类型 b: 非 0 的 int 类型 则有恒等式:`(a / b)*b + (a % b) == a;` 成立 - 如果 `a` 为正整数,`b` 为正整数; 等式成立; - 如果 `a` 为正整数,`b` 为负整数; 为了保证等式成立,`a % b` 的结果必须为正数; - 如果 `a` 为负整数,`b` 为正整数; 为了保证等式成立,`a % b` 的结果必须为负数; - 如果 `a` 为负整数,`b` 为负整数; 为了保证等式成立,`a % b` 的结果必须为负数; 由此可以得出, % 运算符得出的结果都是根据 `%` 前面的数值的正负性给定结果的正负性. ## [Java编程:结构化与面向对象的对决](https://blog.dong4j.site/posts/c8b315d3.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) - 理解结构化编程和面向对象编程的区别 - 掌握如何编写 java 类 - 掌握如何实例化对象 - 掌握如何访问对象的属性和方法 - 理解 this 引用 - 掌握如何使用包组织类 ##### 结构化编程 按照步骤来编写代码 围绕要解决的任务来设计 ##### 面向对象编程 (Object Oriented Programming) 程序不是围绕要解决任务来设计, 而是围绕要解决的问题中的对象来设计 建立对象模型, 将问题域化为不同的对象去处理 **万物皆对象, 对象因关注而产生** 对象组成 1. 属性 - 对象身上的值数据 2. 行为 - 该对象能够做什么 类是对象的抽象, 对象是类的实例 类是具有相同属性和行为的一组对象的抽象 #### 面向对象特点 1. 封装 2. 继承 3. 多态 4. 方法重载 5. 方法覆盖 **基本数据类型和引用类型的传递的特点** - 值传递:(形式参数类型是基本数据类型):方法调用时,实际参数把它的值传递给对应的形式参数,形式参数只是用实际参数的值初始化自己的存储单元内容,是两个不同的存储单元,所以方法执行中形式参数值的改变不影响实际参数的值。 - 引用传递:(形式参数类型是引用数据类型参数):也称为传地址。方法调用时,实际参数是对象 (或数组),这时实际参数与形式参数指向同一个地址,在方法执行中,对形式参数的操作实际上就是对实际参数的操作,这个结果在方法结束后被保留了下来,所以方法执行中形式参数的改变将会影响实际参数 **对象的销毁** - 产生了对象, 用完之后, 自然要关心它的销毁 - 如果一个对象没有任何引用, 那么就具备了被垃圾回收机制回收的条件 - 如果我们想主动通知垃圾回收机制一个对象, 只需要 –> 对象 = null; 只用一次的对象, 可以用匿名对象, 就是没有引用指向这个对象的对象 **访问修饰符** public: 谁都可以操控 private: 除了自己, 谁都不能操作 默认: 同一个包内可以操作 protected: 同包或者之内可以操作 **this:** 1. 对当前类的引用; 2. 每个对象都有一个隐含的 this 变量, 它可以访问类的所有信息; 3. 构造方法的相互调用, 必须放在构造方法内的第一个语句; this 关键字的第二种用法 在重载的构造器中, 为了重复利用代码 ```java class Gril { private int age; private String name; //构造方法 public Gril(int age ,String name){ this(name); this.age = age; //this.name = name; } public Gril(String name){ this.name = name; } public Gril(){ } //睡觉的方法 public void sleep(Car car){ System.out.println("在车上睡觉"); } public void sleep(Bed bed){ System.out.println("在床上睡觉"); } public void sleep(){ System.out.println("在野外睡觉"); } } ``` **JavaBean 规范:** 为私有属性提供符合命名规范的 set 和 get 方法 1. JavaBean 必须方法一个包中 2. JavaBean 必须声明为 public class 3. JavaBean 的所有属性必须声明为 private 4. 通过 setter 和 getter 方法设值和取值 5. 通过 JSP 调用是, 则需要一个无参的构造方法 6. 编写代码要严格遵循 Java 程序的命名规范 **对象和对象的关系** 1. **has a**(拥有一个): 如果 a 对象和 b 对象是这种关系, 则把 b 作为 a 的属性 2. **use a**(使用一个): 如果 a 只是使用一下 b 对象, 则作为局部变量 2 种方法: new 一个新对象; 通过参数传递 ### 目标 1. 掌握方法的申明与调用 2. 理解方法调用栈 3. 理解方法重载 4. 理解构造器 ### 1. 方法调用栈 (stack) - 选择语句 - 循环语句 - 方法的调用 (先进后出) - 当一个方法正在执行时的三种情况 - 方法返回一个值, 在这种情况下, 一个基本数据类型或引用类型被传回给方法的调用者 - 方法不返回, 返回值被声明为 void - 方法抛出一个异常给方法的调用者 ### 2. 方法的生命和调用 #### 方法签名 - 方法的签名包括方法名, 参数列表, 返回值的数据类型 `public static void main(String[] args)` - 访问修饰符 - 可选修饰符 - 返回类型 - 方法名 - 形参列表 - 个数, 顺序, 类型 - 抛出的 - 异常列表 ### 3. 方法重载 同一个类中的方法名相同, 参数列表不同; 和返回值类型无关 **为什么不能用返回值类型类区分重载** 比如下面两个方法,虽然他们有同样的名字和形式,但却很容易区分它们: Java 代码 `void f(){}` `int f(){reurn1;}` 只要编译器可以根据语境明确判断出语义,比如在 int x =f() 中,那么的确可以据此区分重载方法。 不过,有时你并不关心方法的返回值,你想要的是方法调用的其他效果,这时你可能会调用方法而忽略其返回值。 所以,如果像下面这样调用方法:f();此时 Java 如何才能判断该调用哪一个 f() 呢? 因此,根据方法的返回值来区分重载方法是行不通的 ### 4. 构造器 `访问修饰符 类名(){}` 1. 没有返回类型 2. 方法名和类名一样 3. 如果不写, 系统会默认提供一个构造方法 4. 如果写了, 系统就不会提供任何构造方法 **作用:** 产生对象 new 一个对象的时候构造器所要做的事 1. 创建空间 2. 划分属性 3. 初始化值 4. 执行构造方法内的语句 ## [Java揭秘:深入浅出局部变量与常量池](https://blog.dong4j.site/posts/aa0f783a.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 被访问被调用的范围 - public : 全部 - 默认 : 同包 - protected : 同包或有继承关系的类 - private : 只有同类 #### 在 A 类中定义属性和方法: - public: 这个类的实例化对象可以访问; - 默认: 如果在同包下定义一个主类, 然后在主类的主方法里面 new A 的一个对象出来, 则这个对象可以访问被默认修饰的属性和方法; - protected: 在同一个包下的类中 new 的 A 类对象和继承了 A 类的相同或不同包下的子类中 new A 类对象, 这个对象能调用 A 类中所有的属性和方法 (不同包下没有继承关系的类不能访问) - private: 只有这个类中的属性和成员方法能访问 ### geter 和 setter 更深入理解 - 当把类的属性和方法访问权限设置为 private 时, 一般都提供上面 2 个公共方法; 在这 2 个方法体内, 可以添加一些限制代码; 比如说 setter 方法, 需要用户输入账号密码才有权限修改 getter 也可以这么做, 还可以在返回值的时候做一些动作; 这就是隐藏. - 也可以用 private 修饰 setter 方法, 那么这个属性就是只读; - 用 private 修饰 getter 方法, 这个属性就是只写; #### 封装的好处 1. 类的成员变量可以成为只读或只写 2. 类可以对存储在其成员变量中的内容有一个整体的控制 3. 类的用户不需要知道类是如何存储数据的 ### static 关键字 (类级别的, 与对象无关) 1. static 修饰的全局变量叫静态变量, 也叫类变量, 被所有的对象所共享, 用类名. 属性访问; 2. static 修饰的方法叫静态方法, 即类方法; 可以直接用类名. 方法访问; 3. 类只能使用类方法, 而不能使用静态变量; 4. 类加载以后就存在内存中了, 在 mian 方法执行之前; 5. 用 static 修饰的变量存在堆内存的数据段的静态区中 > public static final int MAX = 100; 常量属性定义为 static, 节约空间 final: 因为用 final 修饰, 所以是个常量 public: 因为是常量, 所以不能更改, public 修饰给别人看也没事 static: 共享, 这样就不用 new 出来才能使用; - 静态方法只能访问或调用静态属性和静态方法 - 非静态方法法既能访问调用非静态属性和方法, 也能访问静态方法和属性 原因: 静态属性和方法在类加载的时候就加载到内存中了, 而非静态属性和方法必须绑定在对象上, 而对象必须先实例化才能调用; 如果用静态方法访问非静态属性, 但是内存中却不存在非静态属性, 所以编译器会报错. ### 代码块 #### 静态初始化块 - 语法: static {语句 1; 语句 2;…;}; - 调用顺序: 当类被 JVM 的类加载器加载的时候执行, 只加载一次, 因为 JVM 只加载一次类. #### 实例化初始化块 - 语法:{语句 1; 语句 2;…;}; - 调用顺序: 在类的对象每次实例化的时候执行一次, 在构造器之前调用; ```java class A { public String name = "成员属性" ; static { System.out.println("静态初始化块"); } { this.name = "name"; this.age = 26; System.out.println("实例初始化块内"); System.out.println(this.name); System.out.println(this.age); } public int age = 10; public A ( ) { System.out.println("构造方法内"); System.out.println(name); System.out.println(age); } } //主方法 class ClassName { public static void main(String[] args) { new A(); } } 输出结果 : 静态初始化块 实例初始化块 name 构造方法内 name ``` 步骤: 1. 执行到 `new A();` 时, 加载 A.class 到内存中; 2. static 随着类的加载而创建 (在堆内存的数据段中的静态区中, 存放的是静态变量和静态方法, 常量存放在堆内存的数据段的常量池中) 3. 输出 `静态初始化代码块` 4. 执行构造方法时要做的事: - 在堆内存中申请内存;(size = int + String) - 分配这块内存;(给 name 分配 4 字节空间, 因为它是一个引用; 给 age 分配 4 个字节); - 初始化这块内存中的变量,`;age=10;` - 调用实例初始化代码块, 执行代码块中的语句 `name = name,age=26;` - 执行实例代码块后面的语句;`public int age = 10;` - 执行构造方法内的语句 输出 `name` 和 `age` 的值; ### 内部类 (先大概了解) - 在类的里面再定义一个类, 这个类就叫内部类; - 它是一个独立的类; - 可以产生对象使用; - 编译后又独立的 class 文件; - classA$1classB –> 是局部内部类;(比成员内部类少了一个数字, 因为一个类有多个方法, 在不同的方法里面可以定义相同名字的内部类, 为了区分, 编译器自动为我们添加你一个数字作为区分) - classA$classB –> 是成员内部类;(把成员内部类作为外部类的属性, 属性当然不能重名了, 所以成员内部类的名字不能相同) #### 分类 - 成员内部类 –> 定义在外部类中方法外面的类; - 静态内部类:(成员内部类的一种) - 局部内部类 –> 定义在外部类的方法里面, 定义的时候不能加访问修饰符 - 匿名内部类: 没名字, 主要用于实现接口的方法. **为什么局部内部类不能用访问修饰符修饰?** > 从内存中看,当方法里的局部变量所在方法结束时,该变量即在栈内存中消失;而内部类其实是一个类,只有内存中对它的所有引用都消失后,该内部类才”死亡”,即内部类的生命周期可能比局部变量长。如果局部内部类能访问一般的局部变量,则在多线程中,可能当方法结束后,局部内部类 (是线程类时) 还在使用局部变量。为了避免方法内的变量脱离方法而存在的现象发生,于是 java 规定局部内部类不能访问一般的局部变量。但能访问被 final 修饰的局部变量。因为常量放在堆内存中的数据段的常量池中 ## [掌握Java数组:入门到进阶指南](https://blog.dong4j.site/posts/340249a9.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 1. 数组是一个连续的内存空间, 存储了多个相同数据类型的数据, 是对这些数据的统一管理; 里面的元素可以是任何类型, 包括基本类型和引用类型; 2. 数组中的元素的变量是引用类型, 数组也可以看成是对象,数组中的每个元素相当于该对象的成员变量; 3. 数组变量存放的是连续空间第一个元素的地址; 4. 为什么数组下标要从 0 开始 - 数组是一段连续的空间,要求 a[i] 就是求它的地址,然后找到它。如果从 0 开始,则 a[i] 的地址 = 首地址 + i _ 每个数据所占的长度;如果从 1 开始, 则 a[i] 的地址 = 首地址 + (i-1)_ 每个数据所占的长度 5. 数组中变量的类型 , 就是声明数组时定义的类型. 6. java 中声明数组是不能指定长度; 数组创建后, 长度不可变化. 7. java 中数组不能越界, 否则编译器会报数组越界错误.(ArrayIndexOutOfBoundsException) 8. 数组存储在 Java 堆的连续内存空间,所以如果你创建一个大的索引,你可以有足够的堆空间直到抛出 OutofmemoryError,因为请求的内存大小在连续的内存空间不可用。 9. 数组创建后, 每个元素都会自动初始化 | 类型 | byet | short | int | long | float | double | char | booble | 引用类型 | | :----- | ---: | :---: | :-: | :--: | :---: | :----: | :-----: | :----: | -------- | | 默认值 | 0 | 0 | 0 | 0 | 0.0f | 0.0d | `u0000` | false | null | #### 一维数组 1. 定义 `type[] arrayName`; 或者 `type arrayName[];` 例如: `int[] intArray;` `double doubleArray[];` 2. 初始化 - 静态初始化 `int[] intArray = {1,2,3,4};` `String stringArray[] = {"hello","world"};` `char[] charArray = new char[]{'a','b','c'};` - 动态初始化 - 简单类型的数组 `int[] intArray;` `intArray = new int[5];` - 复合类型的数组 ```java String[] stringArray = new String[3]; stringArray[0]= new String("How"); stringArray[1]= new String("are"); stringArray[2]= new String("you"); ``` 3. 数组元素的引用 数组元素的引用方式为:       arrayName[index] index 为数组下标,它可以为整型常数或表达式,下标从 0 开始。每个数组都有一个属性 length 指明它的长度,例如:intArray.length 指明数组 intArray 的长度 **代码举例** **1. 数组对象的创建** 数组名 = new 数组元素类型 [数组元素个数] 例如: ```java public class TestArray{ public static void main(String args[]){ int[] arr; arr = new int[5]; for(int i=0;i<5;i++){ arr[i] = i; System.out.println(arr[i]); } } } ``` **元素为引用类型的数据(注意:元素为引用数据类型的数组中的每一个元素都需要实例化)** ```java public class TestArray{ public static void main(String args[]){ Student[] student; student = new student[3]; for(int i=0; i<3; i++){ student[i] = new Student("李四",89,9527); System.out.println("姓名:" +student[i].name +" 分数:"+student[i].score +" 学号:"+student[i].ID); } } } class Student{ int name,score,ID; public Student(String name, int score, int ID){ this.name = name; this.score = score; this.ID = ID; } } ``` **2. 数组初始化:** 1. 动态初始化: 数组定义与为数组元素分配空间和赋值的操作分开进行, 例如: ```java public class TestArray{ public static void main(String args[]){ int[] arr = new int[3]; arr[0] = 1 ; arr[1] = 2 ; arr[2] = 3 ; Student[] student = new Student[3]; student[0] = new Student("张三",87,0528); student[1] = new Student("李四",76,0529); student[2] = new Student("赵六",91,0530); } } class Student{ int name,score,ID; public Student(String name,int score,int ID){ this.name = name; this.score = score; this.ID = ID; } } ``` 1. 静态初始化 在定义数组的同时就为数组元素分配空间并赋值, 例如: ```java public class TestArray{ public static void main(String args[]){ int[] arr = {1,2,3} Student[] student = {new Student("张三",87,0528), new Student("李四",76,0529), new Student("赵六",91,0530)}; } } class Student{ int name,score,ID; public Student(String name,int score,int ID){ this.name = name; this.score = score; this.ID = ID; } } ``` **注意:** - int[] a ; 等价于 int a[]; –> 定义一个 int 类型数组的引用 a; - int[] a = new int[5]; –> 定义一个 int 类型引用并且分配空间, 但是元素没有初始化, 系统根据数组类型自动初始化 - int[] a = {1,2,2,3,4}; 等价于 int[] a = new int[]{1,2,2,3,4}–> 定义并初始化; - 每个数组都有一个静态的属性, length, 它是这个数组的长度; 它是一个成员属性, 没有加 (), 同 String 求字符串长度的 length() 方法有区别 **字符串数组** ```java String[] s = {"ss","bb","cc","rr"}; for(int i = 0 ; i < s.length ; i++){ System.out.println(s[i]); } ``` **字符串** ```java String s = "aabbccdd"; int length = s.length(); ``` **主方法中的字符串数组** `public static void main(String[] args){}` 我们每个类中的主函数也有一个数组,名叫 args. 那么这个数组时干嘛用的呢? 这个数组就好比,我们在命令行中注入 ipconfig -all 中的 all. 我们可以在输入 `java 类名 23,12,aa,bbb` 这个跟几个参数。然后可以在代码中输出来看到 ```java class ClassName { public static void main(String[] args) { for(int i = 0 ; i < args.length;i ++){ System.out.println(args[i]); } } } ``` 运行的时候 用命名 `java ClassName 1,2,3,4,5`, 就会在控制台输出 `1,2,3,4,5` 这句命令的意思是: args[0] = “1,2,3,4,5”; args.length 的值为 1 如果我们输入 `java ClassName 1 2 3 4 5`; 这句命令的意思是: args[0] = “1”; args[1] = “2”; …… args.length 的值为 5. 举一个 args[] 参数和基础类型包装类一起使用的例子,用来计算 +-x/: ```java public class ClassName{ public static void main(String args[]){ if(args.length<3){//必须要输入3个字符,中间字符是运算符 //其他2个字符是要运算的值 System.out.println("error~~~"); System.exit(0); } //把字符串转换为double类型 double b1 = Double.parseDouble(args[0]); double b2 = Double.parseDouble(args[2]); double b = 0; if(args[1].equals("+")){ b = b1 + b2; }else if(args[1].equals("-")){ b = b1-b2; }else if(args[1].equals("x")){ b = b1*b2; }else if(args[1].equals("/")){ b = b1/b2; }else{ System.out.println("error operation!!!"); } System.out.println(b); } } ``` 下面举一个用 ars 输入 10 个数,并且用选择排序,从小到大排序的示例: ```java public class TestSortInt{ public static void main(String args[]){ int[] a = new int[args.length]; for(int i=0; ia[j]){ k=j;//k存储的是最小值的下标 } } if(k!=i){ temp = a[i]; a[i] = a[k]; a[k] = temp; } } for(int i=0; i=1; i--){ for(int j=0; j0){ Date temp = a[j+1]; a[j+1] = a[j]; a[j] = temp; } } } return a; } } class Date{ private int year,month,day; public Date(int year,int month,int day){ this.year = year; this.month = month; this.day = day; } public int compare(Date date){ return year>date.year?1 :yeardate.month?1 :monthdate.day?1 :day 1){ if(arr[index] == true){ count++; if(count == 3){ count = 0; arr[index] = false; leftCount --; } } index ++; if(index == arr.length){ index=0; } } for(int i=0; isearchNum){ startFlag = m+1; } m = (startFlag+endFlag)/2; } return -1; } } ``` #### 多维数组 1.二维数组的定义 ```   type arrayName[][]; type [][]arrayName; ``` 2.二维数组的初始化 - 静态初始化 `int intArray[][]={{1,2},{2,3},{3,4,5}};` Java 语言中,由于把二维数组看作是数组的数组,数组空间不是连续分配的,所以不要求二维数组每一维的大小相同。 - 动态初始化 - 直接为每一维分配空间,格式如下: ```java arrayName = new type[arrayLength1][arrayLength2]; int a[][] = new int[2][3]; ``` - 从最高维开始,分别为每一维分配空间: ```java arrayName = new type[arrayLength1][ ]; arrayName[0] = new type[arrayLength20]; arrayName[1] = new type[arrayLength21]; … arrayName[arrayLength1-1] = new type[arrayLength2n]; ``` 1. 例:   二维简单数据类型数组的动态初始化如下: ```java int a[][] = new int[2][ ]; a[0] = new int[3]; a[1] = new int[5]; ``` 对二维复合数据类型的数组,必须首先为最高维分配引用空间,然后再顺次为低维分配空间。   而且,必须为每个数组元素单独分配空间。 例如: ```java String s[][] = new String[2][ ]; s[0]= new String[2];// 为最高维分配引用空间 s[1]= new String[2]; // 为最高维分配引用空间 s[0][0]= new String(“Good”);// 为每个数组元素单独分配空间 s[0][1]= new String(“Luck”);// 为每个数组元素单独分配空间 s[1][0]= new String(“to”);// 为每个数组元素单独分配空间 s[1][1]= new String(“You”);// 为每个数组元素单独分配空间 ``` 3.二维数组元素的引用 对二维数组中的每个元素,引用方式为:arrayName[index1][index2]   例如: num[1][0];   两个矩阵交换: ```java class twoArrayTest { public static void main(String[] args) { int arry[][] = {{1,2,3},{4,5,6},{7,8,9}}; for(int i = 0 ; i <= 2 ; i++){ for(int j = 0 ; j <= 2 ; j++){ System.out.print(arry[i][j]+" "); } System.out.println(); } for(int i = 0 ; i <= 2 ; i++){ for(int j = i; j <= 2 ; j++){ int temp = arry[i][j]; arry[i][j] = arry[j][i]; arry[j][i] = temp; } } System.out.println("转换后"); for(int i = 0 ; i <= 2 ; i++){ for(int j = 0 ; j <= 2 ; j++){ System.out.print(arry[i][j]+" "); } System.out.println(); } } } ``` ### 2. 位运算 #### 按位与 & (AND) **都为 1 才为 1, 否则为 0** ```java public class Demo1 { public static void main(String[] args) { int a = 4 ;// 00000000 00000000 00000000 00000100 int b = 7 ;// 00000000 00000000 00000000 00000111 int c = a & b ; // 00000100 System.out.println(c); } } ``` 应用: 1. 迅速清零 `int a = 4 ;` `a = a & 0; //结果为0` 2. 保留指定位数据 `int a = 409; // 00000000 00000000 00000001 10011001` `int b = 255; // 00000000 00000000 00000000 11111111` // 取 a 的低 8 位 `a = a & b ; // 00000000 00000000 00000000 10011001` 3. 判断奇偶性 int a = 整数 ; `int b = a & 1;` 如果 b = 1, 则 a 是奇数; 如果 b = 0, 则 a 是偶数; 解释: 把一个整数用二进制表示, 如果最低位是 1 的话, 则这个整数肯定是奇数; 这个整数跟 1 按位与的时候, 即最低位跟二进制 001 按位与, 如果结果为 1, 则这个整 数对应二进制位的最低位必定也为 1, 所以这个整数必定是奇数. #### 按位或 | (OR) **有一个为 1, 结果就为 1; 全为 0, 结果才为 0** ```java public class Demo2 { public static void main(String[] args) { int a = 9 ;// 00000000 00000000 00000000 00001001 int b = 5 ;// 00000000 00000000 00000000 00000101 int c = a | b ; // 00001101 System.out.println(c); } } ``` 应用: 1. 设定数据的指定位置 将 Demo2 中 a 的低 8 位全部设置为 1 `a = a | 0xFF;`//0xFF–>255–>10000000 #### 按位异或 ^ (XOR) **当对应位互斥的时候, 结果才为 1, 否则为 0** ```java public class Demo3 { public static void main(String[] args) { int a = 10;// 00000000 00000000 00000000 00001010 int b = 7 ;// 00000000 00000000 00000000 00000111 int c = a ^ b ; // 00001101 System.out.println(c); } } ``` - 性质: 1. 交换律 2. 结合律 3. 对于任何数 x,都有 x^x=0,x^0=x 4. 自反性 A XOR B XOR B = A xor 0 = A - 应用: 1. 定位反转 `a = a ^ x;` //(x 是一个跟 a 对应的二进制有相同位数的全为一的二进制数) 2. 数值交换 `a = a ^ b;` `b = b ^ a;` `a = a ^ b;` - 练习: 1-1000 放在含有 1001 个元素的数组中,只有唯一的一个元素值重复,其它均只出现一次。每个数组元素只能访问一次,设计一个算法,将它找出来;不用辅助存储空 间,能否设计一个算法实现? - 算法一: 把所有数加起来再减去 1+2+..+1000 的和, 剩下的就是那个数 缺点是如果数据足够大, 会超过数据存储范围 ```java public static int methode (int[] a) { int arraySum = 0 ; int sum = 0; for(int i = 0 ; i < a.length ; i ++){ arraySum += a[i]; } for(int j = 1 ; j <= 1000 ; j ++){ sum += j; } return arraySum - sum; } ``` - 算法二: 将所有的数全部异或,得到的结果与 1^2^3^…^1000 的结果进行异或,得到的结果就是重复数 证明: 由 ^ 操作符的 4 个性质, 假设多出的那个数为 n, 则 1^2^…^n^n^…^1000 = (1^2^..^1000)^n^n= a (这个结果里面没有 n) 然后把这个结果同从 1 异或到 1000 的结果异或, 假设 b = 1^2^3^…^1000(有 n)–> b = a ^ n 则 a ^ b = a ^ (a ^ n) 就等于 n (对于任何数 x,都有 x^x=0,x^0=x) ```java public static int method(int[] a) { int resultA = 0; int resultB = 0; for(int i = 0 ; i < a.length ; i++){ resultA ^= a[i]; } for(int j = 1 ; j <= 1000 ; j++){ resultB ^= j; } return resultA ^ resultB; } ``` #### 取反 ~ ~(00001011) #### 左移 <<右移>> 左移 n 位, 就是乘以 2 的 n 次方 右移 n 位, 就是除以 2 的 n 次方 ```java public class Demo4 { public static void main(String[] args) { int a = 10;// 00000000 00000000 00000000 00001010 int c = a << 4 ; // 11010000 System.out.println(c); } } ``` ## [Java虚拟机探秘:字节码与类文件](https://blog.dong4j.site/posts/c69e2769.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) - 在一条计算机上由软件或硬件模拟的计算机或硬件模拟的计算机. JVM 读取并处理经编译后的平台无关的字节码 class 文件 - java 编译器针对 java 虚拟机产生的 class 文件, 因此是独立于平台的 - java 解释器负责将 java 虚拟机的代码在特定的平台上运行 ### **类的定义** ```java public class HelloWorld { public static void mian(String[] args) { System.out.println("HelloWorld"); } } ``` `[public] class 类名称 {}` 对于类的定义有两种形式: 1. public class 定义: 内名称必须和文件名称一致, 在一个 java 文件里只能有一个 public 修饰的类 2. class 定义: 类名称可以和文件名不一致, 但是生成的是 class 定义的名称, 在一个 java 程序之中可以同时存在多个 class 的定义, 编译之后会分为不同 class 文件. ### **主方法** 主方法表示的是一个程序的起点, 要放在一个类中, 主方法定义格式如下: `public static void main(String[] args){}` ### **系统输出** 可以直接在屏幕上显示输出信息 输出不加换行: `System.out.print(输出内容);` 输出加换行 :`System.out.println(输出内容);` ### **classPath 配置** 如果要要想执行某一个 java 程序, 那么一定要进入到程序所在的路径下才可以. 如果要想执行这个文件夹中的所有 class 文件, 则需要进入到此目录项执行, 那么如果现在希望在不同的目录下执行呢? 会提示用户找不到这个类., 这时候就需要配置 classpath `SET CLASSPATH=*.class文件所在路径` 比如: classpath 配置到 `d:\testjava` 目录之中 `SET CLASSPATH=D:\testjava` **结论**: 当使用 java 命令执行一个类的时候, 会首先通过 classpath 找到指定的路径, 而后在此路径下加载所需要的 class 文件 但是, 如果这样到处指定 classpath 太乱也太麻烦, 所以最好的做法还是从当前路径下加载 class 文件, 那么这个额时候往往将 classpath 设置为 “.” - **面试题**: 请解释 classpath 和 path 的区别 - path: 是操作系统的环境属性, 指的是可以执行命令的程序路径 - classpath: 是所有 class 文件的执行路径, java 命令执行的时候将利用此路径加载所需要的 class 文件 ### 标识符和关键字 - **标识符**: 由字母, 数字,`_`,$ 组成, 其中不能以数字开头, 而且不能是 java 的保留字 - **定义变量 (标识符) 或方法时**: 第一个单词的首字母小写, 之后每个单词的首字母大写. –> String studentName ; - **定义类的时候**: 第一个单词的首字母大写, 之后每个单词的首字母大写 –> class TestDemo {} - **关键字** 常用的几种类型: - int: - 取得最大值:`Integer.MAX_VALUE;` - 取得最小值:`Integer.MIN_VALUE;` - 最大值 + 1 = 最小值 最小值 - 1 = 最大值 (数据溢出) - double - byte - 在为 byte 赋值的时候, 如果给出的数值范围在 byte 范围内, 则自动将 int 类型变为 byte 类型, 这种操作只是在直接赋值的情况下 ```java class Test { public static void main(String[] args) { byte = 20 ;//20是int类型 System.out.println(b); } } ``` - boolean : true 和 false - 布尔型主要表示一种逻辑判断, 布尔是一个人命, 他发明了逻辑运算 (AND,NOT,OR) - long - char - 在各个语言中, char 和 int 可以相互转换, 在 c 语言中转换的编码是 ASCII 码, 范围: - 大写字母范围: 65-90 - 小写字母范围: 97-122 - java 在定义字符的时候所使用的不是 ASCII 编码, 而是 UNICODE 编码, 这是一种使用十六进制定义的编码, 可以表示任何文字, 其中包含了中文 掌握类型之间的转换: - 自动转型 (从小到大):byte–>short–>int–>long–>float–>double - 强制转型 (从大到小):double–>float–>long–>int–>short–>byte **初见 String** String 本事不属于 java 基本数据类型, 因为它属于一个类 (应用型数据), 但是这个类型使用起来可以像基本类型一样方便, 而且使用也很多 - 字符串的定义 : - `String str = "aabbccdd";` - 字符串数组: - `String[] str = {"aa","bb","cc"};` - 字符串连接: - `String str = "Hello"; str = str + " World";` **s += 1.0 和 s = s + 1.0 的区别** - s += 1.0 隐含了强制转换,, 如果 s 是 int 类型, 则这句不会报错, 自动把结果强制转换为 int 类型的 - 而 s = s + 1.0 则没有强制转换, 如果 s 同样是 int 类型的变量, 则结果是 double 类型, 大类型转换为小类型会发生精度丢失, 如果我们没有显示强制转换, 编译器就会报错 ### 程序逻辑 - 顺序结构 - 所有代码按修后顺序执行 - 选择结构 - (多) 条件判断: if , if…else, if..else if …else ; - (多) 数值判断: switch ….case ; - 循环结构 - for 循环, 知道循环次数的时候用, 用可以不用知道, 直接满足条件的时候用 break 跳出循环 - while 循环 , 知道条件不满足的时候跳出循环 - do…while 循环, 先循环一次, 然后判断循环条件是否满足 - foreach 循环: 一种 for 循环的简单写法, 一般用来循环遍历数组. ```java //输出一个乘法表 class MultiplicationTable { public static void main(String[] args) { for(int i = 1 ; i <= 9 ; i ++){ for(int j = 1 ; j <= i ; j ++){ System.out.print(j+"*"+i+"="+j*i+" "); } System.out.println(" "); } } } ``` 打印一个三角 ```java int i,j,k; for(i = 1 ; i <= 7; i++){ //打印前面的空格 for(j = 1;j < 7 - i+1;j++) System.out.print(" "); for(k = 1 ;k <= i*2 -1 ;k++) System.out.print("*"); //循环完一次,换行 System.out.println(); } ``` ### 练习 1. 输入成绩, 判断等级 ```java //输入成绩,给出等级 import javax.swing.JOptionPane; import java.util.regex.Matcher; //正则表达式的类 class Demo { public static void main(String[] args) { while(true){ String s = JOptionPane.showInputDialog(null,"请输入成绩"); //如果输入的是数字,则判断成绩; if(s.matches("^[0-9]{1,3}$")){ int num = Integer.parseInt(s); //System.out.println(num); if(num >= 90 ) JOptionPane.showMessageDialog(null,"等级:A"); else if(num >= 80) JOptionPane.showMessageDialog(null,"等级:B"); else if(num >= 70) JOptionPane.showMessageDialog(null,"等级:C"); else if(num >= 60) JOptionPane.showMessageDialog(null,"等级:D"); else if(num <= 59 ) JOptionPane.showMessageDialog(null,"不及格"); } //输入quit时退出程序 else if(s.equals("quit")){ return ; } else JOptionPane.showMessageDialog(null,"输入错误,请重新输入"); } } } ``` 1. 判断一个数是不是水仙花数 ```java //判断一个数是不是水仙花数 import javax.swing.JOptionPane; class Demo4 { public static void main(String[] args) { while(true){ String s = JOptionPane.showInputDialog(null,"请输入三位数"); int num = Integer.parseInt(s); //百位 num / 100 = 153 / 100 = 1 //个位 num % 10 = 3 //十位 (num /10)%10 = 5 int a = num / 100 ; int b = num % 10 ; int c = (num / 10 )% 10 ; if(a*a*a + b*b*b + c*c*c == num){ JOptionPane.showMessageDialog(null,"是水仙花书数"); return; } else JOptionPane.showMessageDialog(null,"不是水仙花数"); } } } ``` 1. 求 100-999 之间的水仙花数 ```java //求100-999之间的水仙花数 class Demo5 { public static void main(String[] args) { int a ; int b ; int c ; for(int i = 100 ; i <=999 ; i++){ a = i / 100 ; b = i % 10 ; c = (i / 10 )% 10; if(a*a*a + b*b*b +c*c*c == i) System.out.println(i); } } } ``` 1. 运算 ```java import javax.swing.JOptionPane; public class Test2 { public static void main(String[] args) { String s1 = JOptionPane.showInputDialog(null,"请输入第一个数字"); String aa = JOptionPane.showInputDialog(null,"请输入运算符号"); String s2 = JOptionPane.showInputDialog(null,"请输入第二个数字"); int x = Integer.parseInt(s1); int y = Integer.parseInt(s2); switch (aa) { case "+": { JOptionPane.showMessageDialog(null,x+"+"+y+"="+(x+y)); break; } case "-" :{ JOptionPane.showMessageDialog(null,x+"-"+y+"="+(x-y)); break; } case "*" :{ JOptionPane.showMessageDialog(null,x+"*"+y+"="+(x*y)); break; } case "/" :{ JOptionPane.showMessageDialog(null,x+"/"+y+"="+(x/y)); break; } } } } ``` 1. JOptionPane 类 ```java import javax.swing.JOptionPane; class JOptionPaneTest { public static void main(String[] args) { //显示一个错误对话框,该对话框显示的 message 为 'alert': JOptionPane.showMessageDialog(null, "alert", "alert", JOptionPane.ERROR_MESSAGE); //显示一个内部信息对话框,其 message 为 'information': JOptionPane.showInternalMessageDialog(frame, "information", "information", JOptionPane.INFORMATION_MESSAGE); //显示一个信息面板,其 options 为 "yes/no",message 为 'choose one': JOptionPane.showConfirmDialog(null, "choose one", "choose one", JOptionPane.YES_NO_OPTION); //显示一个内部信息对话框,其 options 为 "yes/no/cancel",message 为 'please choose one',并具有 title 信息: JOptionPane.showInternalConfirmDialog(frame, "please choose one", "information", JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.INFORMATION_MESSAGE); //显示一个警告对话框,其 options 为 OK、CANCEL,title 为 'Warning',message 为 'Click OK to continue': Object[] options = { "OK", "CANCEL" }; JOptionPane.showOptionDialog(null, "Click OK to continue", "Warning", JOptionPane.DEFAULT_OPTION, JOptionPane.WARNING_MESSAGE, null, options, options[0]); //显示一个要求用户键入 String 的对话框: String inputValue = JOptionPane.showInputDialog("Please input a value"); //显示一个要求用户选择 String 的对话框: Object[] possibleValues = { "First", "Second", "Third" }; Object selectedValue = JOptionPane.showInputDialog(null, "Choose one", "Input", JOptionPane.INFORMATION_MESSAGE, null, possibleValues, possibleValues[0]); } } ``` 1. 2 ```java import javax.swing.JOptionPane; class xunhuanTest { public static void main(String[] args) { /*int i = 0 ; int j = 0; while(i < 10){ while(j < 10){ System.out.print("*"); j++; } j = 0; i++; System.out.println(""); }*/ /*int count = 0; String s ; int num = 0; int sum = 0; while (count < 5){ count++; s= JOptionPane.showInputDialog(null,"请输入第"+count+"个人的成绩"); num = Integer.parseInt(s); sum += num; } JOptionPane.showMessageDialog(null,"平局成绩为:"+sum / 5.0);*/ /*int sum = 0; int i = 1; while(i < 101){ sum = sum + i ; i++; } System.out.println(sum);*/ /* int i = 1 ; int sum = 0; while (i < 100){ sum = sum + i; System.out.println(i); i += 2 ; } System.out.println(i); System.out.println(sum);*/ //3的倍数和带3的数都要敲桌子其他的报数 /*int i = 0; while (i < 50) { i++; if(i % 3 == 0 || i/10 == 3 || i%10 == 3 ) System.out.println("敲桌子"); else System.out.println(i); }*/ /*//输入5个数,给出最大值 int count = 1; String str = ""; int[] intArray = new int[5]; int i = 0 ; int num ; while(count <=5 ){ String s = JOptionPane.showInputDialog(null,"请输入第"+count+"个数"); count++; //str = str + s; num = Integer.parseInt(s); intArray[i] = num; i++; } int mix = intArray[0] ; for(int j = 0 ; j < intArray.length - 1; j++){ if(mix < intArray[j+1]){ mix = intArray[j+1]; } } System.out.println(mix);*/ } } ``` 1. 异或 ```java class Demo2 { public static void main(String[] args) { int a = 5; int b = 8; //异或性质 :1.交换律;2.结合律;3.x^x = 0 , x^0 = x;4.自反性 a = a^b; b = b^a; a = a^b; System.out.println("a = "+a+" b = "+b); } } ``` 1. 输出 3-100 之间的素数 ```java //输出3-100之间的素数 class Test4 { public static void main(String[] args) { for(int i = 3 ; i <= 100 ; i++){ boolean on_off = true ; for(int j = 2 ; j < i ; j++){ if(i % j == 0){ on_off = false ; //结束内层循环 break ; } } if(on_off){ System.out.println(i); } } } } ``` 1. 求 2 个数的最大公约数 ```java import javax.swing.JOptionPane; class Test3 { public static void main(String[] args) { int a = Integer.parseInt(JOptionPane.showInputDialog("第一个数")); int b = Integer.parseInt(JOptionPane.showInputDialog("第二个数")); //求2个数的最大公约数 int a1 = 0 ; for(int i = 1; i <= a && i <= b; i++){ //找出能被a整除和b整除的数 if(a % i == 0 && b % i == 0){ //找出最大的公约数 a1 = i ; } } System.out.println(a1); } } ``` 1. 数组的定义及初始化 ```java //数组的定义 赋值 import javax.swing.JOptionPane ; class Test1 { public static void main(String[] args) { int[] a = new int[10]; for(int i = 0 ; i <= 9 ; i++) a[i] = i+1; for(int j = 0 ; j <= 9 ; j++) System.out.println(a[j]); int[] a1 = new int[4]; for(int i = 0 ; i < a1.length ; i++){ a1[i] = Integer.parseInt(JOptionPane.showInputDialog("请输入第"+(i+1)+"个数")); } for(int i = 0 ;i < a1.length; i++){ System.out.println(a1[i]); } } } ``` 1. 字符串数组的定义及赋值 ```java import javax.swing.JOptionPane; class Test2 { public static void main(String[] args) { String[] name = new String[3]; //String name ; for(int i = 0 ; i < name.length; i++){ name[i] = JOptionPane.showInputDialog("请输入姓名"); } for(int i = 0 ; i < name.length ; i++) System.out.println(name[i]); } } ``` 1. 求数组中最大值 ```java class Test3 { public static void main(String[] args) { int[] a = {4,58,34,50,21,42}; //找出数组中最大的数 int count = 0; int max = a[0]; for(int i = 1 ; i < a.length - 1 ; i ++){ if(max < a[i]){ max = a[i]; count = i; } } System.out.println("最大的数是: "+ max+"最大数的下标:"+count); } } ``` 1. 查找字符串数组中的特定值 ```java import javax.swing.JOptionPane; class Test4 { public static void main(String[] args) { String[] str = {"张三","李四","王六加二","赵六","田七"}; String s = JOptionPane.showInputDialog("找谁?"); int count = -1 ; //boolean on_off = true; for(int i = 0 ; i < str.length ; i ++){ //on_off = false; if(s.equals(str[i])){ count = i; JOptionPane.showMessageDialog(null,"找到了"+"位置在:"+(count+1)); //on_off = true ; break ; } } if(count == -1) JOptionPane.showMessageDialog(null,"未找到"); } } ``` 1. 5. ```java import javax.swing.JOptionPane; class Test5 { public static void main(String[] args) { String[] nameArray = new String[4]; int[] moneyArray = new int[4]; for(int i = 0 ; i < nameArray.length ; i ++){ nameArray[i] = JOptionPane.showInputDialog("请输入第"+(i+1)+"个人的名字"); moneyArray[i] = Integer.parseInt(JOptionPane.showInputDialog("请输入第"+(i+1)+"个人的工资")); } int max = -1 ; int bb = -1 ; for(int k = 0 ; k < moneyArray.length ; k ++){ if(max < moneyArray[k]){ max = moneyArray[k]; bb = k ; } } String s = "姓名: 工资\n"; for(int j = 0 ; j < nameArray.length; j++){ /*System.out.print(nameArray[j]+" "); System.out.print(moneyArray[j]); System.out.println();*/ s +=nameArray[j] + " "+moneyArray[j]+"\n"; } JOptionPane.showMessageDialog(null,s); JOptionPane.showMessageDialog(null,"工资最高:"+ max +"人:"+nameArray[bb]); } } ``` 1. 6. ```java //查找工资 import javax.swing.JOptionPane; class Test6 { public static void main(String[] args) { String[] nameArray = {"阿一","阿二","阿三","阿四","阿五","阿六"}; int[] moneyArray = {3500,8500,6412,8964,5105,6982}; //要求:输入姓名,输出他的工资 String s = JOptionPane.showInputDialog("请输入要查找的姓名:"); int index = -1 ; for(int i = 0 ; i < nameArray.length ; i ++) { if(s.equals(nameArray[i])){ index = i; break ; } } if(index == -1 ) JOptionPane.showMessageDialog(null,"无此人, 滚粗"); else JOptionPane.showMessageDialog(null,"姓名:"+nameArray[index] + " 工资:"+moneyArray[index]); } } ``` 1. 删除数组中某个元素 ```java //删除数组中的某个元素 import javax.swing.JOptionPane; class Test7 { public static void main(String[] args) { int[] a = {3,5,7,9,12}; int length = a.length; for(int i = 0 ; i < a.length ; i ++) System.out.println(a[i]); int deletNum = Integer.parseInt(JOptionPane.showInputDialog("请输入一个数")); for(int j = 0 ; j < length ; j ++){ //找出那个数,删除 if(deletNum == a[j]) { //用后一个数覆盖前面的数 for(int k = j ; k < length -1; k ++){ a[k] = a[k+1]; } } } for(int i = 0 ; i < a.length-1 ; i ++) System.out.println(a[i]); } } ``` 1. 排序 ```java class Test9 { public static void main(String[] args) { int[] a = {3,8,2,1,6}; for(int i = 0 ; i < a.length ; i ++){ for(int j = i + 1 ; j < a.length ; j ++){ //第一轮,找出最小的数,放到0号位 if(a[i] > a[j]) { a[i] = a[i] ^ a[j]; a[j] = a[j] ^ a[i]; a[i] = a[i] ^ a[j]; } } } for(int k = 0 ; k < a.length; k++) System.out.println(a[k]); } } ``` ```java import javax.swing.JOptionPane; class Demo { public static void main(String[] args) { //初始化数组 String[] nameArray = new String[4]; int[] moneyArray = new int[4]; for(int i = 0 ; i < nameArray.length ; i ++){ nameArray[i] = JOptionPane.showInputDialog("请输入第"+(i+1)+"个人的名字"); moneyArray[i] = Integer.parseInt(JOptionPane.showInputDialog("请输入第"+(i+1)+"个人的工资")); } String s = "姓名: 工资:"+"\n"; for(int j = 0 ; j < nameArray.length ; j++){ System.out.println(nameArray[j]+" "+moneyArray[j]); s += nameArray[j]+" "+moneyArray[j]+"\n"; } JOptionPane.showMessageDialog(null,"排序前:\n"+s); //排序 按照工资排名名字,并且用弹出框弹出来. //因为存储名字的数组和工资的数组长度一样,只要将工资数组排序,顺带将名字也排序了,然后打印输出 //moneyArray数组-->由大到小排序 //第一次循环,把第一个元素同后面的元素一一对比,将第一大的数排到第一位 String temp = null; for(int i = 0 ; i < moneyArray.length ; i ++) { for(int j = i + 1 ; j < moneyArray.length ; j++) { //交换数值,把大数放第一位 if(moneyArray[i] < moneyArray[j]) { //这是排的工资 moneyArray[i] = moneyArray[i] ^ moneyArray[j]; moneyArray[j] = moneyArray[j] ^ moneyArray[i]; moneyArray[i] = moneyArray[i] ^ moneyArray[j]; //排名字 temp = nameArray[i] ; nameArray[i] = nameArray[j]; nameArray[j] = temp ; } } } String str = "姓名: 工资:"+"\n"; for(int j = 0 ; j < moneyArray.length ; j++) str += nameArray[j]+" "+moneyArray[j]+"\n"; JOptionPane.showMessageDialog(null,"排序后:\n"+str); } } ``` ## [Java数据类型转换:实战案例解析](https://blog.dong4j.site/posts/3daa4bf4.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) double > float > long > int > short > byte A. 小类型转大类型是自动转换 (向上转型); B. 大类型转小类型会发生精度丢失, 也有可能发生数据溢出, 所以编译器要求我们必须强制转换, 否则会有编译错误. ``` int i = 1 , j ; //正确:对于 j 这里只是定义,没有初始化. float f1 = 0.1 ; //错误:在java中,有小数的数值默认为double类型,所以结果为double类型,看B解释 float f2 = 123; //正确: 结果为int类型,自动转换成float类型,A解释 double d1 = 2e20,d2 = 123; //正确:A解释 byte b1 = 1,b2 = 2 ,b3 = 129; //错误: b1,b2没有错,A解释;b3超过范围. j = j + 10; //错误:只有定义 没有初始化 i= i / 10; //正确: i = i * 0.1; //错误:i先自动转型为double,所以结果为double类型,看B解释. char c1 = 'a',c2 = 125; //正确: byte b = b1 - b2; //错误:结果为in类型 char c = c1 + c2 -1; //错误:结果为int类型,看B解释 float f3 = f1 + f2 ; //正确:在f1和f2为float为前提条件下 float f4 = f1 + f2*0.1; //错误:先是f2转换成double,然后f1转换为double,所以结果为double类型,看B解释 double d = d1*i + j; //错误:j未初始化. float f = (float)(d1*5 + d2); //正确:结果为double,但是强制转换成了float,看B解释. ``` ## **if 判断语句** 1. if - else ```java if(判断条件) { 执行语句; }else { 执行语句; } ``` 1. if - else if ```java if(判断条件){ 执行语句; }else if(判断条件) { 执行语句; } ... else if(判断条件) { 执行语句; } ``` 3. ```java if(判断条件){ 执行语句; }else if(判断条件) { 执行语句; } ... else if(判断条件) { 执行语句; }else { 执行语句; } ``` ### **练习:** 要求: - 输入成绩, 给出等级; - A:100-90; - B:89-80 - C:79-70 - D:69-60 - 不及格: 小于 59 - 输入 quit 退出程序; ```java import javax.swing.JOptionPane; import java.util.regex.Matcher; //正则表达式的类 class Demo { public static void main(String[] args) { while(true){ String s = JOptionPane.showInputDialog(null,"请输入成绩"); //如果输入的是数字,则判断成绩; if(s.matches("^[0-9]{1,3}$")){ int num = Integer.parseInt(s); //System.out.println(num); if(num >= 90 ) JOptionPane.showMessageDialog(null,"等级:A"); else if(num >= 80) JOptionPane.showMessageDialog(null,"等级:B"); else if(num >= 70) JOptionPane.showMessageDialog(null,"等级:C"); else if(num >= 60) JOptionPane.showMessageDialog(null,"等级:D"); else if(num <= 59 ) JOptionPane.showMessageDialog(null,"不及格"); } //输入quit时退出程序 else if(s.equals("quit")){ return ; } else JOptionPane.showMessageDialog(null,"输入错误,请重新输入"); } } } ``` ### **作业一** ```java /* * 输入两个数,然后弹出选择框:1.加,2.减,3.乘,4.除.然后根据选择操作符,计算结果 * 两个数可以是正负数,可以是小数 */ import javax.swing.JOptionPane; class Operation { public static void Result (double number1, double number2 ,String operate) { switch (operate){ case "1":{ JOptionPane.showMessageDialog(null,number1+" + "+number2+" = "+(number1+number2)); break; } case "2":{ JOptionPane.showMessageDialog(null,number1+" - "+number2+" = "+(number1-number2)); break; } case "3":{ JOptionPane.showMessageDialog(null,number1+" * "+number2+" = "+(number1*number2)); break; } case "4":{ if(number2 != 0){ JOptionPane.showMessageDialog(null,number1+" / "+number2+" = "+(number1/number2)); }else JOptionPane.showMessageDialog(null,"被除数不能为0"); break; } } } } class OperationTest { public static void main(String[] args) { while(true){ try{//try catch 语句是为了判断输入是否符合要求,如果不是一个数字,则转到catch语句 String s1 = JOptionPane.showInputDialog(null,"请输入第一个数"); String s2 = JOptionPane.showInputDialog(null,"请输入第二个数"); String s = JOptionPane.showInputDialog(null,"请选择算法:1.加法,2.减法,3.乘法,4.除法;\"quit\"退出"); if (s.equals("quit")) return ; //System.out.println(s); double firstNumber = Double.parseDouble(s1); double secondNumber = Double.parseDouble(s2); Operation.Result(firstNumber,secondNumber,s); }catch(Exception e){ JOptionPane.showMessageDialog(null,"输入有误,请重新输入"); } } } } ``` ### **作业二** ```java // 键盘输入一个五位数,判断这个数是否为回文(1.它的是数字;2.是正整数;3.5位数;4.是否为回文数) import javax.swing.JOptionPane; class PalindromicNumber { public static void main(String[] args) { String s ; int num ; JOptionPane.showMessageDialog(null,"输入 quit 退出,回车继续"); //判断这个数是否符合要求 先判断它是不是一个数字 while(true){ s = JOptionPane.showInputDialog(null,"请输入一个5位整数"); try { //判断它是不是一个5位数,2种方法:1.9 <= num/10000 >= 1; 2.toCharArray.length == 5 num = Integer.parseInt(s); //这个是为了try catch语句判断是不是一个数字 if(num < 0 ){ JOptionPane.showMessageDialog(null,"请输入正整数"); continue ; } //System.out.println(num); //用第二中方法判断它是不是一个5位数 char[] charArray = s.toCharArray(); System.out.println(charArray.length); if(5 == charArray.length){ //判断是不是回文数 //for(int i = 0 ; i < charArray.length ; i++) // System.out.println( charArray[i] );//打印输入数组 boolean on_off = false ; for(int j = 0 ; j < charArray.length/2 ;j++) { if(charArray[j] == charArray[charArray.length -1 - j]) on_off = true ; } if (on_off) JOptionPane.showMessageDialog(null,"是回文数"); else JOptionPane.showMessageDialog(null,"不是回文数"); }else JOptionPane.showMessageDialog(null,"请输入一个5位的整数"); }catch (Exception e){ if(s.equals("quit")) return ; JOptionPane.showMessageDialog(null,"请正确输入一个5位整数"); } } } } ``` ### **回文数练习** 不用数组的方法打印 1-100000 之间所有的回文数 ```java class PalindromicNumber2 { public static void main(String[] args) { int k = 0,num; for(int i = 0 ; i <= 1000000 ; i++){ num = i ; while (num != 0){ //将num的数反转存储到k中 k = k*10 + num%10; num = num/10; //System.out.println("k= "+k+"\n"+"i = "+i+"\n"); } if(k == i){ System.out.println(i); } k = 0; } } } ``` ## [Java入门必备:环境配置与高效开发技巧](https://blog.dong4j.site/posts/ff9e7ddd.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言。Java 技术具有卓越的通用性、高效性、平台移植性和安全性,广泛应用于 PC、数据中心、游戏控制台、科学超级计算机、移动电话和互联网,同时拥有全球最大的开发者专业社群。 ### **背景** Java 是由 Sun Microsystems 公司推出的 Java 面向对象程序设计语言(以下简称 Java 语言)和 Java 平台的总称。由 James Gosling 和同事们共同研发,并在 1995 年正式推出。Java 最初被称为 Oak,是 1991 年为消费类电子产品的嵌入式芯片而设计的。1995 年更名为 Java,并重新设计用于开发 Internet 应用程序。用 Java 实现的 HotJava 浏览器(支持 Java applet)显示了 Java 的魅力:跨平台、动态 Web、Internet 计算。从此,Java 被广泛接受并推动了 Web 的迅速发展,常用的浏览器均支持 Javaapplet。另一方面,Java 技术也不断更新。Java 自面世后就非常流行,发展迅速,对 C++ 语言形成有力冲击。在全球云计算和移动互联网的产业环境下,Java 更具备了显著优势和广阔前景。2010 年 Oracle 公司收购 Sun Microsystems。 ### **体系** java 分三个体系 1. JavaSE (Java 2 Platform, Standard Edition) 2. JavaEE (Java 2 Platform, Enterprise Edition) 3. JavaME (Java 2 Platform, Micro Edition) ### **特点** - 简单性 - Java 容易学习,相对于 C++ 而言更加纯净。 - 小,基本的解释器以及类支持仅有 40k. - 面向对象的语言(OO) - 封装 - 继承 - 多肽 - 方法重载 - 方法覆盖 - 一种与平台无关的语言 - 解释性语言,一次编译,到处运行(JVM) - 健壮性和安全性 - 删除了指针和释放内存等 C++ 功能,避免了非法内存操作。 - 通过 Java 的安全体系架构来确保 Java 代码的安全性。 - 多线程 - 多线程应用程序能够同时运行多项任务。 - Java 中实现多线程相对于其他语言具有独特的优势。 - 垃圾回收 - 垃圾收集机制(Garbage collection) - 什么是垃圾? 无任何引用的对象占据的内存空间 C/C++ 是由程序员负责回收无用内存, **java 中程序员无法控制和干预,只能由系统决定什么时候回收垃圾** ### **关键字** | 关键字 | 描述 | | ------------ | -------------------------------------------- | | abstract | 抽象方法,抽象类的修饰符 | | assert | 断言条件是否满足 | | continue | 不执行循环体剩余部分 | | default | switch 语句中的默认分支 | | do-while | 循环语句,循环体至少会执行一次 | | double | 64-bit 双精度浮点数 | | else | if 条件不成立时执行的分支 | | enum | 枚举类型 | | extends | 表示一个类是另一个类的子类 | | final | 表示定义常量 | | finally | 无论有没有异常发生都执行代码 | | float | 32-bit 单精度浮点数 | | for | for 循环语句 | | goto | 用于流程控制 | | if | 条件语句 | | implements | 表示一个类实现了接口 | | import | 导入类 | | implements | 测试一个对象是否是某个类的实例 | | int | 32 位整型数 | | interface | 接口,一种抽象的类型,仅有方法和常量的定义 | | long | 64 位整型数 | | native | 表示方法用非 java 代码实现 | | new | 分配新的类实例 | | package | 一系列相关类组成一个包 | | private | 表示私有字段,或者方法等,只能从类内部访问 | | protected | 表示保护类型字段 | | public | 表示共有属性或者方法 | | return | 方法返回值 | | short | 16 位数字 | | static | 表示在类级别定义,所有实例共享的 | | strictfp | 浮点数比较使用严格的规则 | | super | 表示基类 | | switch | 选择语句 | | while | while 循环 | | volatile | 标记字段可能会被多个线程同时访问,而不做同步 | | void | 标记方法不返回任何值 | | try | 表示代码块要做异常处理 | | transient | 修饰不要序列化的字段 | | throw | 抛出异常 | | this | 调用当前实例或者调用另一个构造函数 | | synchronized | 表示同一时间只能由一个线程访问的代码块 | ### **JDK 和 JRE** - JDK 叫做 java 开发工具集。包括 java 的编译环境、运行环境、调试环境,以及基础类库。 - JRE 叫做 java 运行环境。包括虚拟机、核心类库,以及链接文件。 **JDK 包含 JRE** ## **数据类型、变量、常量** ### **数据类型** - 数据类型是一种易于人类阅读的标记,用来表示计算机内存的特定用法。 - 在程序中使用时,数据类型规定所使用内存的大小以及在该内存中可存放的有效值。 - Java 是一种强类型编程语言,这意味着在 Java 程序中用到的所有变量都必须有明确定义的数据类型 #### **基本数据类型** ##### 数值型 | 类型 | A 占用存储空间 | 范围 | | ------ | --------------- | ---------------------------------------------------------------------- | | byte | 1 字节(8 位) | −27−27 到 27−127−1 | | short | 2 字节(16 位) | −2  的  15  次方 −2 的 15 次方   到  2  的  15  次方 −12 的 15 次方 −1 | | int | 4 字节(32 位) | −2  的  31  次方 −2 的 31 次方到 2  的  31  次方 −12 的 31 次方 −1 | | long | 8 字节(64 位) | −2  的  63  次方 −2 的 63 次方到 2  的  63  次方 −12 的 63 次方 −1 | | float | 4 字节(32 位) | −2  的  31  次方 −2 的 31 次方到 2  的  31  次方 −12 的 31 次方 −1 | | double | 8 字节(64 位) | −2  的  63  次方 −2 的 63 次方到 2  的  63  次方 −12 的 63 次方 −1 | ##### **字符型** - char 型数据用来表示通常意义上 “字符”。 - 字符常量为用单引号括起来的单个字符。 - Java 字符采用 Unicode 编码,每个字符占两个字节。 - ‘A’的编码是:65 依次类推 - ‘a’的编码是:97 依次类推 - Java 语言中还允许使用转义符 `\` 来将其后的字符转变为其它的含义。例如 `\n`,`\t`。 ##### **boolean** - boolean 类型适于逻辑运算,一般用于程序流程控制。 - boolean 类型数据只允许取值 true 或 false, 不可以 0 或非 0 的整数替代 false 和 true ,这点和 C 语言不同。 - 用法举例: boolean flag = true; flag = 5 > 3; ##### **声明和创建变量** - Java 变量是程序中最基本的存储单元,其要素包括数据类型,变量名和变量值。 - Java 程序中每一个变量都属于特定的数据类型,在使用前必须对其声明,声明格式为: 数据类型 变量名,变量值; - 变量其实是内存中的一小块区域,使用变量名来访问这块区域。 ##### 变量的初始化 - 声明变量后,应该对变量进行初始化(养成习惯) - 初始化的语法: 数据类型 变量名 = 初始值; - 比如: int i = 0; 等同于: int i; i = 0; ##### **变量的内存组织方式** ##### **常量** - Java 的常量值用字符串表示,区分为不同的数据类型。 - 如整型常量 123 - 实型常量 3.14 - 字符常量’a’ - 逻辑常量 true、false - 字符串常量”helloworld” - 注意:区分字符常量和字符串常量。 - Java 浮点型常量默认为 double 型,如要声明一个常量为 float 型,则需在数字后面加 f 或 F。 - 我们也可以给常量命一个标示符名 - 语法如下: - final 数据类型 标示符 = 常量值; - 符号常量声明的时候必须赋值 - 在整个程序中不能改变不能重新赋值 ###### **为什么使用常量** - 使用符号常量的好处是: - 含义清楚。如上面的程序中,看程序时从 PRICE 就可知道它代表价格。 - 在需要改变一个常量时能做到 “一改全改”。例如在程序中多处用到某物品的价格,如果价格用常数表示,则在价格调整时,就需要在程序中作作出修改,若用符号常量 PRICE 代表价格,只需改动一处即可。 ##### **标识符** - Java 标识符命名规则: - 标识符由字母、下划线 `_`、美元符“$” 或数字组成。 - 标识符应以字母、下划线、美元符开头。 - Java 标识符大小写敏感,长度无限制。 - “见名知意” 。 - 约定俗成的规则 ### **java 环境变量配置** #### 什么是环境变量 环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数,如:临时文件夹位置和系统文件夹位置等。 环境变量是在操作系统中一个具有特定名字的对象,它包含了一个或者多个应用程序所将使用到的信息。例如 Windows 和 DOS 操作系统中的 path 环境变量,当要求系统运行一个程序而没有告诉它程序所在的完整路径时,系统除了在当前目录下面寻找此程序外,还应到 path 中指定的路径去找。用户通过设置环境变量,来更好的运行进程。 可以在用户变量和系统环境变量设置 **区别**: 用户变量只能是当前登录的用户才有效果 系统变量是你电脑上的全部用户都可以使用 **第一步**:设置 JAVA_HOME 变量 JAVA_HOME 的值是你安装 java 的目录路径 比如我的 java 安装路径 `D:\java\jdk1.8.0_45` **设置:** 变量名:**JAVA_HONE** 值:`D:\java\jdk1.8.0_45` 第二步:设置 classpath 变量 设置 变量名:**classpath** 值:`.;%JAVA_HOME%\lib\dt.jar;%JAVA_HOME%\lib\tooles.jar` 注意:一定要在最前面加上 .; 表示在当前路径下查找 第三步:设置 Path 变量 (环境变量有 Path 变量,只需要在后面添加值即可) 设置: 变量名:**Path** 值: `;%JAVA_HOME%\bin;%JAVA_HOME%\jre\bin;(添加到最后面,注意分号)` **dt.jar** 和 **tools.jar** 是两个 java 最基本的包,里面包含了从 java 最重要的 lang 包到各种高级功能如可视化的 swing 包,是 java 必不可少的。 而 path 下面的 bin 里面都是 java 的可执行的编译器及其工具,如 java,javadoc 等,你在任意的文件夹下面运行 cmd 键入 javac,系统就能自动召见 java 的编译器就是归功于这个环境变量的设置 **rt.jar** 是 JAVA 基础类库,**dt.jar** 是关于运行环境的类库,**tools.jar** 是工具类库 ### **把 javac 和 java 命令添加到鼠标右键运行** 最常用的方法就是在 java 文件上右键打开方式选 javac.exe 打开这个 java 文件,如果没有语法错误,会生成一个 XXX.class 文件 但是有两个问题: - 当这个 java 文件有错误的时候,右键用 jacac.exe 打开会出现 cmd 窗口,但是它会马上关闭,可以说是一闪而过,我们看不到错误信息。 - 如果我们编译通过了,然后还要右键那个 class 文件,然后用 java.exe 打开,这样很麻烦。 所以我推荐下面这种方法 不是简单的用 java 和 javac 命令打开 这样我们就不用每次都进入 cmd 然后进去到 java 文件所在路径,然后在输入 javac xxx.java 编译 和 java xxx 运行了 直接鼠标右键 单击 编译并运行 就可以直接编译并且输出结果。如果有错误,也会显示完整错误信息,不会一闪而过。 - 新建 runJava.txt,改后缀为 bat,用记事本打开,复制以下代码到这个文件中 然后保存并把这个文件放在一个不经常动的地方 我放在了 F 盘的 java 文件夹下 ``` @ECHO OFF cd %~dp1 %转到当前目录下% IF EXIST %~n1.class ( %如果有xxx.class 则执行 删除命令,将原来旧的class文件 % DEL %~n1.class ) ECHO Compiling %~nx1 %在控制台显示 Compiling xxx.java% javac -d . %~nx1 %执行javac命令 -d 参数的意思是 指定放置生成的类文件的位置 . 的意思是放在当前目录下 % @ECHO OFF IF EXIST %~n1.class ( %如果存在xxx.class 说明编译成功 则执行java命令 % ECHO ---------Out---------- java %~n1 %执行java命令% ) pause ``` - **添加到鼠标右键**:新建 java.txt 文件,改后缀为 reg,用记事本打开 复制以下代码到文件中 **说明**:`@=”\”F:\Java\runJava.bat\” \”%1\””` 这一行需要改成第一步中 runJava.bat 所在文件夹目录 ``` Windows Registry Editor Version 5.00 [HKEY_CLASSES_ROOT\*\shell\java] @="编译并运行" [HKEY_CLASSES_ROOT\*\shell\java\Command] @="\"F:\\Java\\runJava.bat\" \"%1\"" ``` 然后双击这个文件,将这个键值添加到注册表中。 **注意**:这里涉及到改注册表,有风险,如果信得过我,只管点就是,我就是这个做的。 ## [Java程序设计基础入门指南](https://blog.dong4j.site/posts/ca7b1ce7.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) 计算机程序(Computer program),也称为软件(software),简称程序(Program)是指一组指示计算机或其他具有信息处理能力装置每一步动作的指令,通常用某种程序设计语言编写,运行于某种目标体系结构上。就跟我们日常生活中做饭一样,一道菜的完成需要从买菜,洗菜,炒菜,出锅等步骤完成。 **程序:为了让计算机执行某些操作或解决某个问题而编写的一系列有序指令的集合,通过程序实现人机对话的过程** ### **进制转换** #### **什么是二进制** 二进制是计算技术中广泛采用的一种数制。二进制数据是用 0 和 1 两个数码来表示的数。它的基数为 2,进位规则是 “逢二进一”,借位规则是 “借一当二”,由 18 世纪德国数理哲学大师莱布尼兹发现。当前的计算机系统使用的基本上是二进制系统,数据在计算机中主要是以补码的形式存储的。计算机中的二进制则是一个非常微小的开关,用 “开” 来表示 1,“关”来表示 0。 [[百度百科]](http://baike.baidu.com/view/18536.htm) #### **特点** ##### **优点** 数字装置简单可靠,所用元件少; 只有两个数码 0 和 1,因此它的每一位数都可用任何具有两个不同稳定状态的元件来表示; 基本运算规则简单,运算操作方便。 ##### **缺点** 用二进制表示一个数时,位数多。因此实际使用中多采用送入数字系统前用十进制,送入机器后再转换成二进制数,让数字系统进行运算,运算结束后再将二进制转换为十进制供人们阅读。 ##### **采用原因** 1. 技术实现简单,计算机是由逻辑电路组成,逻辑电路通常只有两个状态,开关的接通与断开,这两种状态正好可以用 “1” 和“0”表示。 2. 简化运算规则:两个二进制数和、积运算组合各有三种,运算规则简单,有利于简化计算机内部结构,提高运算速度。 3. 适合逻辑运算:逻辑代数是逻辑运算的理论依据,二进制只有两个数码,正好与逻辑代数中的 “真” 和“假”相吻合。 4. 易于进行转换,二进制与十进制数易于互相转换。 5. 用二进制表示数据具有抗干扰能力强,可靠性高等优点。因为每位数据只有高低两个状态,当受到一定程度的干扰时,仍能可靠地分辨出它是高还是低。 ##### 为什么不采用电压高低来模拟十进制 因为电阻受热后会发生改变,导致电压不稳,造成存储的数据不确定性。 ##### **代码实现十进制转二进制** ```java class toBinary { //定义用于连接余数的字符串。 private static String s = ""; public static void IntToBinary(int num){ //当num等于1的时候,是最后一位数。 while(num >= 1){ s = num % 2 + s; num = num / 2; } System.out.println(s); } } public class toBinaryTest{ public static void main(String[] args){ //用十进制129测试一下这个类 toBinary.IntToBinary(129); } } ``` ### **程序,软件和计算机语言的关系** - 可以被计算机连续执行的指令集合称为计算机程序 - 软件是为了完成某些特定功能而编写的一到多个程序文件的集合 - 计算机语言是人们发明的可以和计算机进行沟通交流的一种工具 ### **计算机语言的分类** - 机器语言 - 计算机唯一能接受和执行的语言 - 由二进制组成 - 每一串二进制称为一条指令,一条指令规定了计算机执行的一个动作 - 一台计算机所能懂的指令的全体,叫做这个主计算机的指令系统 - 不同型号的计算机的指令系统不同 **特点** 1. 编写出来的程序全部都是由 0 和 1 组成‘ 2. 计算机可以直接识别。 3. 机器语言对不同型号的计算机一般不同,所以又被称为面向机器的语言 **缺点** - 指令难以记忆。 - 代码实现复杂,开发周期长 - 不便于推广,交流,合作。 - 严重的依赖具体的计算机,可移植性差,重用性差。 - 汇编语言 - 用助记符表示指令功能的计算机语言 - 汇编语言编写的代码计算机无法直接识别 - 高级语言 - 与自然语言现金并为计算机所接受和执行的计算机语言 *** ### 翻译程序 计算机并不能直接地接受和执行用高级语言编写的源程序, 源程序在输入计算机时, 通过 “翻译程序” 翻译成机器语言形式的目标程序, 计算机才能识别和执行。 翻译分成了两种形式 - 编译 - 编译型语言的首先将源代码编译生成机器语言,再由机器运行机器码(二进制)。像 C/C++ 等都是编译型语言。 - 解释 - 解释性语言在运行程序的时候才翻译,比如解释性 java 语言,专门有一个解释器能够直接执行 java 程序,每个语句都是执行的时候才翻译。这样解释性语言每执行一次就要翻译一次,效率比较低。 ### 算法 算法:解决问题的具体方法和步骤 例如: 计算长方形的面积 1. 接收用户输入的长方形长度和宽度两个值; 2. 判断长度和宽度的值是否大于零; 3. 如果大于零,将长度和宽度两个值相乘得到面积,否则显示输入错误; 4. 显示面积。 #### 算法的基本特征 **有穷性**:一个算法必须在执行有限个操作步骤后终止。 **确定性**:算法中每一步的含义必须是确切的,不可出现任何二义性。 **有效性**:算法中的每一步操作都应该能有效执行,一个不可执行的操作是无效的。例如,一个数被 0 除的操作就是无效的,应当避免这种操作。 **有零个或多个输入**:这里的输入是指在算法开始之前所需要的初始数据。这些输入的多少取决于特定的问题。 **有一个或多个输出**:所谓输出是指与输入有某种特定关系的量,在一个完整的算法中至少会有一个输出。例如,要计算从 1 累加到 100,如果这个程序没有输出结果,那么它将变得毫无意义。 ### 程序设计的五个步骤 - 分析 - 清楚业务流程(做什么) - 清楚输入输出要求(已知什么 & 要得到什么) - 清楚开发期限 - 设计 - 优秀的程序在开发前以及开发中,都要精心设计 - 对于程序员而言,最重要的是确立算法。(怎么做) - 在编程之前,应该先设计一下。试图不遵循设计而编写程序,就好像没有房屋平面图就建造房屋,我们无法确定最终会做成什么样子。 - 编码实现 - 编写源代码 - 将原代码编译成字节码 - 让计算机运行我们的程序 - 调试 - 程序中最易出现的几种不同类型错误是语法错误、逻辑错误和运行错误。 - 维护 #### 用 java 实现猜数字 第一种 ```java /** * @describe 猜数字 用户给出的数字跟电脑随机给出的数字对比, * 知道猜中为止。最后给出猜数的次数。 */ import java.util.Scanner; class GuessNumber{ public static void main(String[] args) { int count = 0; int num = (int)(Math.random()*100)+1; System.out.println(num); Scanner sc = new Scanner(System.in); int user ; while(true){ System.out.println("请输入你猜的数字"); user = sc.nextInt(); count++; if(num > user) { System.out.println("小了"); }else if(num < user){ System.out.println("大了"); }else{ System.out.println("猜中了"); break ; } } if(count <= 5) System.out.println("天才猜了 "+ count+ " 次"); else System.out.println("笨蛋猜了 "+ count+ " 次"); } } ``` 第二种 ```java /** * @describe 猜数字改进版 让计算机帮我们猜数字 * 实现:1.随机给出一个1到100的数字 * 2.定义一个数组,里面存放从1到100的数字 * 3.在数组中用二分法查找这个值 */ class GuessNumber2 { //二分法查找法(折半查找) public static int checkNumber(int[] arr, int a ) { //low指向数组的低位 int low = 0; //指向数组的中间 int middle; //hight指向数组的高位 int hight = arr.length - 1; //次数 int count = 0; //当指向低位的low大于指向高位的hight时,说明 while(low <= hight) { count++; middle = (low + hight) / 2; if(a > arr[middle]){ System.out.println("第 "+count+" 次查找的数字为 "+arr[middle]); //如果计算机找出的值比随机值小,就应该在数组的后半部分去查找。 low = middle + 1; } else if(a < arr[middle]) { System.out.println("第 "+count+" 次查找的数字为 "+arr[middle]); //如果找出的值比随机数大,则在数组的前半部找。 hight = middle - 1 ; } //如果arr[middle] == a ,则找到了随机数。 else { System.out.println("查找了 "+ count +" 次"); return arr[middle]; } } return -1 ; } } public class GuessNumberTest { public static void main(String[] args) { //给出一个1到100的随机数 int a = (int)(Math.random() * 100) + 1; System.out.println("计算机随机给出的值:" + a); //定义一个数组,存放1到100的数字 int[] arr = new int[100]; for(int i = 0 ;i < arr.length ;i++) { arr[i] = i + 1; //System.out.println(arr[i]); } int value = GuessNumber2.checkNumber(arr,a); if ( value != -1) System.out.println("计算机猜到随机数的值= "+ value ); else System.out.println("未猜到"); } } ``` ## [打造个性化编程环境:Sublime Text插件深度体验](https://blog.dong4j.site/posts/b5d8b5d5.md) ![random-pic-api](https://api.dong4j.ink:1024/cover?spm={{spm}}) Sublime Text 是一款款平台代码编辑器, 很适合前端开发使用. 它的插件足够丰富, 我只是拿来当 java 编辑器使用, 因为我觉得它的界面很好看. 但是它对中文支持不够好 (是不是跨平台的编辑器对中文支持都不好?). 下面我就对我使用 Sublime 期间遇到的问题和解决问题做一个备忘录. ### 编辑器的选择 我是一个初学编程的人, 对编辑器的选择不是很多, 就用过 Eclipse 和 Sublime. Eclipse 对于初学者的我来说, 显得过于庞大, 平时就联系一些小例子, 还用不上这么高大上的编辑器. 为什么选择 Sublime? 我觉得它性感, 界面好看, 插件丰富, 教程也多, 所以选择了它. 虽然 Sublime 不是免费的, 但是在伟大的天朝, 一切皆有可能. ### 插件 #### Package Control Package Control 是 Sublime 必装的插件之一, 它就像一个管家, 下载, 管理其它插件 - **使用 Ctrl+` 打开 Sublime Text 控制台** - **复制下面代码到控制台** ``` import urllib.request,os,hashlib; h = '7183a2d3e96f11eeadd761d777e62404' + 'e330c659d4bb41d3bdf022e94cab3cd0'; pf = 'Package Control.sublime-package'; ipp = sublime.installed_packages_path(); urllib.request.install_opener( urllib.request.build_opener( urllib.request.ProxyHandler()) ); by = urllib.request.urlopen( 'http://sublime.wbond.net/' + pf.replace(' ', '%20')).read(); dh = hashlib.sha256(by).hexdigest(); print('Error validating download (got %s instead of %s), please try manual install' % (dh, h)) if dh != h else open(os.path.join( ipp, pf), 'wb' ).write(by) ``` - **等待 Package Control 安装完成, 然后使用`Ctrl+Shift+P`打开命令面板, 输入 intsall, 回车,然后选择或者输入要安装的插件** #### 一些常用的插件 1. ConvertToUTF8:解决中文乱码问题 2. Alignment:主要用于代码对齐 3. Autoprefixer:自动添加兼容前缀 4. BracketHighlighter:能为 ST 提供括号,引号这类高亮功能. 5. AutoFileName:自动补全文件名 6. Terminal:唤起终端控制台 7. SublimeLinter:代码提示高亮 8. CodeFormatter:代码格式化 9. Colorcoder:高亮所有变量 10. ColorPicker:调色盘 : 11. SideBarEnhancements:实用的右键菜单增强插件 12. Sublime​Code​Intel:是一个代码提示、补全插件 13. Git 插件集成了 git 的常用功能 14. Emmet:前身是大名鼎鼎的 Zen coding,如果你从事 Web 前端开发的话,对该插件一定不会陌生。它使用仿 CSS 选择器的语法来生成代码,大大提高了 HTML/CSS 代码编写的速度. 15. FileHeader:当打开一个空文件时, 判断文件后缀自动添加语法模板. 16. GoSublime:是一个 sublime 的 go 语言插件提供自动补全和其他 IDE 特性。 17. SublimeLinter:一个支持 lint 语法的插件,ctrl+alt+l 呼出(与 qq 的锁定冲突,自己去改热键吧)可以高亮 linter 认为有错误的代码行 18. IMESupport:输入中文时, 让输入框跟随 19. markdownPreview:sublime 中的 MoarkDwn 编辑器 ### Sublime 下搭建 Java 开发环境 - 安装目录下 Packages–>Java.sublime-package 文件改后缀名 zip 解压找到 Java.sublime-build 用记事本打开复制以下代码 ``` { "cmd": "runJava.bat \"$file\"", "file_regex": "^(...*?):([0-9]*):?([0-9]*)", "selector": "source.java", "encoding":"GBK" } ``` - 在 JDK 安装目录下的 bin 目录下新建一个 **runJava.bat** - 复制一下代码 ``` @ECHO OFF ECHO Compiling %~nx1 IF EXIST %~n1.class ( DEL %~n1.class ) javac -encoding UTF-8 %~nx1 ::如果正确 打开cmd输出 IF EXIST %~n1.class ( ECHO ---------Out---------- start cmd /k java %~n1 ) ``` - 注意名字要跟 Java.sublime-build 里面的一样 ### 快捷键 #### 选择类 Ctrl+D 选中光标所占的文本,继续操作则会选中下一个相同的文本。 Alt+F3 选中文本按下快捷键,即可一次性选择全部的相同文本进行同时编辑。举个栗子:快速选中并更改所有相同的变量名、函数名等。 Ctrl+L 选中整行,继续操作则继续选择下一行,效果和 Shift+↓ 效果一样。 Ctrl+Shift+L 先选中多行,再按下快捷键,会在每行行尾插入光标,即可同时编辑这些行。 Ctrl+Shift+M 选择括号内的内容(继续选择父括号)。举个栗子:快速选中删除函数中的代码,重写函数体代码或重写括号内里的内容。 Ctrl+M 光标移动至括号内结束或开始的位置。 Ctrl+Enter 在下一行插入新行。举个栗子:即使光标不在行尾,也能快速向下插入一行。 Ctrl+Shift+Enter 在上一行插入新行。举个栗子:即使光标不在行首,也能快速向上插入一行。 Ctrl+Shift+[ 选中代码,按下快捷键,折叠代码。 Ctrl+Shift+] 选中代码,按下快捷键,展开代码。 Ctrl+K+0 展开所有折叠代码。 Ctrl+← 向左单位性地移动光标,快速移动光标。 Ctrl+→ 向右单位性地移动光标,快速移动光标。 shift+↑ 向上选中多行。 shift+↓ 向下选中多行。 Shift+← 向左选中文本。 Shift+→ 向右选中文本。 Ctrl+Shift+← 向左单位性地选中文本。 Ctrl+Shift+→ 向右单位性地选中文本。 Ctrl+Shift+↑ 将光标所在行和上一行代码互换(将光标所在行插入到上一行之前)。 Ctrl+Shift+↓ 将光标所在行和下一行代码互换(将光标所在行插入到下一行之后)。 Ctrl+Alt+↑ 向上添加多行光标,可同时编辑多行。 Ctrl+Alt+↓ 向下添加多行光标,可同时编辑多行。 #### 编辑类 Ctrl+J 合并选中的多行代码为一行。举个栗子:将多行格式的 CSS 属性合并为一行。 Ctrl+Shift+D 复制光标所在整行,插入到下一行。 Tab 向右缩进。 Shift+Tab 向左缩进。 Ctrl+K+K 从光标处开始删除代码至行尾。 Ctrl+Shift+K 删除整行。 Ctrl+/ 注释单行。 Ctrl+Shift+/ 注释多行。 Ctrl+K+U 转换大写。 Ctrl+K+L 转换小写。 Ctrl+Z 撤销。 Ctrl+Y 恢复撤销。 Ctrl+U 软撤销,感觉和 Gtrl+Z 一样。 Ctrl+F2 设置书签 Ctrl+T 左右字母互换。 F6 单词检测拼写 #### 搜索类 Ctrl+F 打开底部搜索框,查找关键字。 Ctrl+shift+F 在文件夹内查找,与普通编辑器不同的地方是 sublime 允许添加多个文件夹进行查找,略高端,未研究。 Ctrl+P 打开搜索框。举个栗子:1、输入当前项目中的文件名,快速搜索文件,2、输入 @和关键字,查找文件中函数名,3、输入:和数字,跳转到文件中该行代码,4、输入 #和关键字,查找变量名。 Ctrl+G 打开搜索框,自动带:,输入数字跳转到该行代码。举个栗子:在页面代码比较长的文件中快速定位。 Ctrl+R 打开搜索框,自动带 @,输入关键字,查找文件中的函数名。举个栗子:在函数较多的页面快速查找某个函数。 Ctrl+: 打开搜索框,自动带 #,输入关键字,查找文件中的变量名、属性名等。 Ctrl+Shift+P 打开命令框。场景栗子:打开命名框,输入关键字,调用 sublime text 或插件的功能,例如使用 package 安装插件。 Esc 退出光标多行选择,退出搜索框,命令框等。 #### 显示类 Ctrl+Tab 按文件浏览过的顺序,切换当前窗口的标签页。 Ctrl+PageDown 向左切换当前窗口的标签页。 Ctrl+PageUp 向右切换当前窗口的标签页。 Alt+Shift+1 窗口分屏,恢复默认 1 屏(非小键盘的数字) Alt+Shift+2 左右分屏 - 2 列 Alt+Shift+3 左右分屏 - 3 列 Alt+Shift+4 左右分屏 - 4 列 Alt+Shift+5 等分 4 屏 Alt+Shift+8 垂直分屏 - 2 屏 Alt+Shift+9 垂直分屏 - 3 屏 Ctrl+K+B 开启 / 关闭侧边栏。 F11 全屏模式 Shift+F11 免打扰模式