简介
欢迎来到 Kaxiluo's Blog!
这里是个人技术博客,记录开发过程中遇到的问题和解决方案,涵盖 Web 开发、数据库等技术领域。
博客同步发布于 www.kxler.com。
2022 年文章
记录 2022 年的技术文章,涵盖 WordPress、MySQL、Redis、FastAPI 等技术领域。
WordPress作为全球最流行的博客系统,拥有丰富的主题和插件,是搭建博客的不二选择。下面将开始介绍从零搭建个人博客的步骤。
技术选型
- debian:版本号buster,由于华为云的公共镜还没出debian11,目前只有采用debian10
- nginx:当前最新稳定版1.22.0
- php:版本7.4,考虑到插件可能有php8不兼容,最好还是php7.4
- mysql:版本5.7,mysql8采用了新的认证插件,可能旧的客户端驱动不兼容,还是选择了5.7
前置操作
-
购买云服务器,这里我选择了华为云
-
购买一个域名,我的域名是 www.kxler.com
-
打开terminal或powershell或其他客户端工具登录远程服务器
ssh root@your ip
安装基础软件
如果采用的是华为云服务器,替换源镜像地址和设置时区可以忽略。因为华为云服务器默认使用了华为云debian镜像,并且设置了上海时区。
# 使用阿里云debian镜像
cp /etc/apt/sources.list /etc/apt/sources.list.bak \
&& sed -i -E 's/(deb|security).debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list
# 设置时区
ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' > /etc/timezone
# 常规操作,更新和升级软件库
apt update -y && apt upgrade -y
# 安装常用工具
apt install -y vim git wget curl \
&& apt install -y apt-utils telnet net-tools procps iputils-ping lsb-release
安装Nginx
# 添加nginx apt存储库
# 也可以使用apt-key add添加源,不过debian11将要废弃
echo "deb http://nginx.org/packages/debian/ $(lsb_release -sc) nginx" >> /etc/apt/sources.list \
&& echo "deb-src http://nginx.org/packages/debian/ $(lsb_release -sc) nginx" >> /etc/apt/sources.list
apt install -y gnupg2 \
&& curl -s https://nginx.org/keys/nginx_signing.key | gpg --no-default-keyring --keyring gnupg-ring:/etc/apt/trusted.gpg.d/nginx.gpg --import \
&& chown _apt /etc/apt/trusted.gpg.d/nginx.gpg
# 安装
apt update -y
# 如果此时使用 apt search ^nginx$ 可以看到nginx已经是最新的稳定版本了
apt install nginx
# 确认安装成功
nginx -v
安装PHP
目前debian11的默认php版本7.4,而debian10默认php版本7.3。我选择的debian10,这里需要添加php apt储存库。
# 添加php apt存储库
echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" | tee /etc/apt/sources.list.d/php.list
wget -O /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg
apt update -y
# 此时使用 apt search php7.4 确认已有php7.4版本
apt install -y php7.4 php7.4-fpm
# 确认安装成功
php -v
php-fpm7.4 -v
安装MySQL
# 下载包
cd /tmp && wget https://dev.mysql.com/get/Downloads/MySQL-5.7/mysql-server_5.7.39-1debian10_amd64.deb-bundle.tar
# 解压
tar -xvf mysql-server_5.7.39-1debian10_amd64.deb-bundle.tar
# 安装依赖
apt install -y libaio1 libatomic1 libmecab2 libnuma1 psmisc libncurses6
# 依次安装,期间有提示输入和确认密码
dpkg -i mysql-common_5.7.39-1debian10_amd64.deb \
&& dpkg -i mysql-community-client_5.7.39-1debian10_amd64.deb \
&& dpkg -i mysql-client_5.7.39-1debian10_amd64.deb \
&& dpkg -i mysql-community-server_5.7.39-1debian10_amd64.deb \
&& dpkg -i mysql-server_5.7.39-1debian10_amd64.deb
# 配置文件 /etc/mysql/mysql.conf.d/mysqld.cnf
启动服务
# 启动 nginx php-fpm mysql
service nginx start
service php7.4-fpm start
service mysql start
# 使用 ps aux 查看进程是都启动
安装wordpress源码
这里将wordpress源码安装到/workspace/wordpress
cd /tmp && wget https://cn.wordpress.org/latest-zh_CN.tar.gz \
&& mkdir /workspace \
&& tar -xvf latest-zh_CN.tar.gz -C /workspace
配置
配置nginx server
调整nginx.conf如下:
/etc/nginx/nginx.conf
#user nginx;
user www-data;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
#long time
#check_shm_size 5M;
# Allow the server to close the connection after a client stops responding.
reset_timedout_connection on;
client_header_timeout 15;
# Send the client a "request timed out" if the body is not loaded by this time.
client_body_timeout 10;
# If the client stops reading data, free up the stale client connection after this much time.
send_timeout 15;
# Timeout for keep-alive connections. Server will close connections after this time.
keepalive_timeout 30;
# Number of requests a client can make over the keep-alive connection.
keepalive_requests 30;
client_body_buffer_size 128k;
client_max_body_size 10m;
proxy_read_timeout 180s;
# Compression.
gzip on;
gzip_min_length 10240;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml;
gzip_disable "msie6";
# Sendfile copies data between one FD and other from within the kernel.
sendfile on;
# Don't buffer data-sends (disable Nagle algorithm).
tcp_nodelay on;
# Causes nginx to attempt to send its HTTP response head in one packet, instead of using partial frames.
tcp_nopush on;
# Hide web server information
# server_tokens off;
# server_info off;
# server_tag off;
# redirect server error pages to the static page
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
include /etc/nginx/conf.d/*.conf;
}
新增server配置如下:
/etc/nginx/conf.d/wordpress.conf
server {
listen 80 default_server;
listen [::]:80 default_server ipv6only=on;
server_name kxler.com www.kxler.com;
root /workspace/wordpress;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
index index.html index.htm index.php;
charset utf-8;
fastcgi_connect_timeout 300;
fastcgi_read_timeout 300;
fastcgi_send_timeout 300;
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location / {
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
# 取决于配置文件/etc/php/7.4/fpm/pool.d/www.conf中listen是unix socket还是tcp
# fastcgi_pass 127.0.0.1:9000;
fastcgi_pass unix:/run/php/php7.4-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf|mp3|mp4|ico|woff|woff2|ttf)$ {
expires 30d;
log_not_found off;
access_log off;
}
location ~ .*\.(js|css)?$ {
expires 12h;
access_log off;
}
location ~ /\.(?!well-known).* {
deny all;
}
access_log /var/log/nginx/access_wordpress-blog.log;
error_log /var/log/nginx/error_wordpress-blog.log error;
}
检查&重启
# 检查配置是否正确
nginx -t
# 重启nginx
nginx -s reload
MySQL建库、新增用户并授权
使用之前的输入的密码登录mysql root账户
mysql -uroot -p
执行以下sql创建数据库和用户
CREATE DATABASE wordpress DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;
# 新增用户,用户名wordpress 密码123456。请自行替换成更加复杂的密码
CREATE USER 'wordpress'@'%' IDENTIFIED BY "123456";
GRANT ALL ON wordpress.* TO 'wordpress'@'%';
准备就绪,开始访问网站,下面记录首次访问网站报的错与解决方法
踩坑和埋坑
我申请的域名备案此时还没通过审核,无法用域名访问我的网站,这里直接访问云服务器的公网IP(由于前面nginx配置中已经将wordpress设置为了默认server)
网站报错502
查看nginx日志发现 connect() to unix:/run/php/php7.4-fpm.sock failed (13: Permission denied)。原来php-fpm默认配置listen采用unix socket通信,默认用户是www-data,创建出来的的sock文件身份是www-data,而nginx默认用户是nginx,造成权限问题。
解决:
将/etc/nginx/nginx.conf的user改为www-data (前面给出的nginx配置已改)
再重启nginx:nginx -s reload
网站提示:您的PHP似乎没有安装运行WordPress所必需的MySQL扩展
# 安装php扩展mysqli gd,额外安装wordpress官方推荐的扩展imagick curl zip dom intl
apt install -y php7.4-mysqli php7.4-gd php7.4-mbstring php7.4-curl php7.4-imagick php7.4-zip php7.4-dom php7.4-intl
# 重启php-fpm
service php7.4-fpm restart
网站提示:无法写入wp-config.php文件
一看就是权限问题,将整个wordpress源码授予用户www-data
chown -R www-data:www-data /workspace/wordpress
调整上传最大尺寸配置
php配置 /etc/php/7.4/fpm/php.ini,将默认的文件上传尺寸2M调高到8M
upload_max_filesize = 8M
nginx配置 /etc/nginx/nginx.conf(前面给出的nginx配置已改)
client_max_body_size 8m;
一切就绪
-
访问网站IP,跟着wordpress安装向导,填写数据库连接配置、网站基本配置。
-
接下来就是选择主题,安装插件。
-
大约过了10天,域名备案过审后,此时在云服务商后台将域名解析到云服务器IP,再到wordpress后台
设置>常规修改站点URL配置。 -
访问网站 www.kxler.com ,享受吧!
额外的
头像不显示? wordpress头像地址默认是Gravatar,国内被qiang了,写个插件将其替换为国内镜像地址。 参考自定义方法插件
最近我的博客上线了,接下里我准备给它安装ssl证书。此外,我在网站的访问日志中,发现了大量恶意的请求,主要是尝试登录、尝试执行恶意脚本、获取敏感信息等。虽然WordPress核心总体来说已经足够安全,但是我还是决定禁用xmlrpc,隐藏后台登录入口,以提高网站的安全性。
安装免费的ssl证书-Let's Encrypt
Certbot是一个开源免费的工具,主要功能是为网站自动安装基于Let’s Encrypt服务的SSL证书。
- 安装Certbot
apt install certbot
apt install python3-certbot-nginx
- 验证网站所有权,生成证书文件
certbot certonly --nginx
# 或者让Certbot自动编辑您的nginx配置
# certbot --nginx
默认生成证书的目录 /etc/letsencrypt/live/$domain/
- 编辑nginx配置,手动配置证书
server {
listen 80;
server_name kxler.com www.kxler.com;
# http强制到https
return 301 https://$host$request_uri;
}
server {
listen 443 ssl default_server;
server_name kxler.com www.xkler.com;
root /workspace/wordpress;
# ssl证书
ssl_certificate /etc/letsencrypt/live/kxler.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/kxler.com/privkey.pem;
ssl_session_timeout 30m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS';
#以下省略...
}
重启nginx
# 测试配置是否正确
nginx -t
# 重启nginx
nginx -s reload
此时网站后台无法使用https域名登录,需要使用IP或http登录到wordpress后台,在设置>常规修改站点URL配置为https地址https://www.kxler.com。
- 关于自动续订
Let's Encrypt颁发的是短期证书(90 天),需要3个月内至少续订一次证书。
大多数Certbot安装都带有开箱即用的自动续订功能,Cerbot使用 certbot renew 完成自动续订,生成的续订定时任务在 /etc/cron.d/certbot。
如果您仍不确定,可以按照官网手动配置自动续订。 Certbot自动续订
禁用xmlrpc.php
最近查看nginx访问日志,发现了大量关于xmlrpc.php的恶意请求。经查资料得知,XML-RPC是支持WordPress与其他系统之间通信的规范,如今已被REST API取代。所以决定禁用它。
禁用方式有很多种,这里选择修改nginx配置文件,在wordpress.conf配置中添加
# 务必加在 location ~ \.php${...} 前面的位置
location ~* ^/xmlrpc.php$ {
return 403;
}
重启nginx
# 测试配置是否正确
nginx -t
# 重启nginx
nginx -s reload
隐藏登录入口
此外,在nginx访问日志,也发现了大量尝试登录的请求,这里直接修改登录入口,以提高安全性。
首先安装插件WPS Hide Login,然后在后台配置登录入口。
另外,如果觉得有必要还可安装限制登录频率的插件。
在日常工作中,我们可能会遇到根据数据存在/不存在,再决定是否插入数据的场景。例如不存在则插入数据,存在则更新数据(或不变)。然而在高并发情况下,可能会重复插入或因唯一约束导致的报错,下面给出写数据时的处理方案。
示例:每天生成一张背景图,创建背景图表,在date字段创建唯一索引。
CREATE TABLE `backgrounds` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`date` date NOT NULL COMMENT '日期',
`url` varchar(255) NOT NULL DEFAULT '' COMMENT '地址',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `udx_date` (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
对于存在的定义:主键或UNIQUE KEY在表中已存在
不存在插入,存在不变
INSERT IGNORE INTO
不存在时:影响1行; 存在时:影响0行;
INSERT IGNORE INTO backgrounds(`date`, `url`)
VALUES('2022-08-18', 'https://1.png');
INSERT INTO NOT EXISTS
不存在时:影响1行; 存在时:影响0行;
INSERT INTO backgrounds(`date`, `url`)
SELECT '2022-08-18' as `date`, 'https://1.png' as `url` FROM backgrounds
WHERE NOT EXISTS (SELECT 1 FROM backgrounds WHERE `date` = '2022-08-18');
不存在插入,存在则更新
ON DUPLICATE KEY UPDATE
不存在时:影响1行; 存在时:若新增数据与原数据有变化影响2行,mysql内部先执行了delete,然后再insert;
INSERT INTO backgrounds(`date`, `url`)
VALUES('2022-08-19', 'https://123.png')
ON DUPLICATE KEY UPDATE `url` = 'https://123.png';
REPLACE INTO
不存在时:影响1行; 存在时:影响2行,mysql内部先执行了delete,然后再insert;
REPLACE INTO backgrounds(`date`, `url`)
VALUES('2022-08-18', 'https://1.png');
其他方案
-
插入数据时,锁定表的读写
LOCK TABLES,但是会降低并发性。 -
根据Redis集合元素的唯一性判断是否存在。
-
借助外部锁,例如Redis锁,插入时获取锁。
最近在工作中有一个统计图片浏览量和保存次数的需求,由于性能问题,我们不能频繁的操作MySQL数据库,在这里就可以使用redis作为计数器,再定期更新到数据库。下面我总结了几种常见的PV、UV、IP统计方案。
- PV(Page View)页面浏览量
- UV(Unique Visitor)独立访客数量
- IP(Internet Protocol)独立IP数
Redis INCR
简单的key-value计数器,key包含要统计对象的标识,value为访问量。
命令介绍:
INCR:将key值递增1,返回最新的值。
INCR是原子增量,即使多个客户端针对同一key发出 INCR,也永远不会进入争用状态。例如,客户端 1 读取“10”,客户端 2 同时读取“10”,两者都递增为 11,并将新值设置为 11,这种情况永远不会发生。最终值将始终为 12。
使用示例:
# 新增文章123的访问量
>incr article:123:view
1
>incr article:123:view
2
# 获取文章123的访问量
>get article:123:view
2
适用于:
PV、无需去重的统计
Redis Sets
redis集合元素具有唯一性,可以轻松实现去重统计。
命令介绍:
SADD:将新成员添加到集合中。SREM:从集合中删除指定的成员。SISMEMBER:判断元素是否是集合的成员。SINTER:返回两个或多个集合的交集。SCARD:返回集合的大小(又名基数)。
使用示例:
# 文章123被用户2访问
>sadd article:123:user 2
1
# 文章123被用户6访问
>sadd article:123:user 6
1
# 检查用户2是否浏览了文章123
>sismember article:123:user 2
1
# 文章123的总用户访问量
>scard article:123:user
2
假设每个文章需要存储100w个用户id(id依次递增1-1000000),那么内存占用量为: 6543216 bytes = 6543216/1024/1024 MB = 6.24 MB。 单个对象需要的内存不多,但是如果有1000个对象(文章),那么存储量将是 6.24 * 1000 = 6240 MB,这就很离谱了。
适用于:
少量数据、精确的统计、独立IP数
性能:
大多数集合操作(包括添加、删除和检查项目是否为集合成员)都是 O(1)。这意味着它们非常高效。但是,对于具有数十万或更多成员的大型集合,在运行SMEMBERS命令时应格外小心。此命令为 O(n),并在单个响应中返回整个集合。作为替代方法,请考虑SSCAN,它允许您以迭代方式检索集合的所有成员。
对大型数据集的集合成员资格检查可能会占用大量内存。如果您担心内存使用情况并且不需要完美的精度,请考虑使用Bloom过滤器或Cuckoo过滤器作为集合的替代方案。
Redis Bitmap
位图,用一个bit位来表示某个元素对应的状态(0或1)。
位图的最大优点之一是,在存储信息时,它们通常可以节省极大的空间。仅使用512MB 内存即可记住40亿用户的布尔信息(例如,知道用户是否想要接收新闻稿)。
命令介绍:
SETBIT:对key所储存的字符串值,设置或清除指定偏移量上的位(bit)。返回值为偏移位上的原始值。
具体语法:setbit key offset value,其中offset是偏移位,通常可以是对象标识ID;value表示对偏移位清除或设置,即0或1。
-
BITCOUNT:返回key所储存的字符串值设置为 1 的位数。 -
GETBIT:获取指定偏移量上的位值(0或1)。 -
BITOP operation destkey key [key ...]:在多个字符串key之间执行按位运算(AND、OR、XOR 和 NOT),并将结果存储在目标键中。 -
BITPOS key bit [start [end [BYTE | BIT]]]:返回字符串中设置为 1 或 0 的第一个位的位置。
使用示例:
# 文章123被用户6访问
>setbit article:123:user 6 1
0
# 文章123被用户9访问
>setbit article:123:user 9 1
0
>setbit article:123:user 9 1
1
# 检查用户9是否浏览了文章123
>getbit article:123:user 9
1
# 文章123的总用户访问量
>bitcount article:123:user
2
假设每个文章需要存储100w个用户id,占用的内存是 1000000/8/1024/1024=0.11920929 MB。 如果有1000个文章,那么存储量将是 0.11920929 * 1000 = 119.20929 MB。 由此可见,当统计较多对象时还是比较消耗内存的。
适用于:
集合成员对应整数0-n、精确统计、 统计对象key单一或较少、用户月签到记录等
Redis HyperLogLog
HyperLogLog是用来做基数统计的算法。它的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。
Redis HyperLogLog 牺牲数据的精准性来节省内存的占用空间,只需要12K内存就能统计2^64个数据, 0.81% 的标准误差。
命令介绍:
PFADD:将项目添加到 HyperLogLog 中。PFCOUNT:返回集合中项目数的估计值。PFMERGE:将两个或多个HyperLogsLogs合并为一个。
使用示例:
# 文章123被用户3访问
>pfadd article:123:view 3
1
# 文章123被用户95访问
>pfadd article:123:view 95
1
# 被添加元素可以是字符串
>pfadd article:123:view abc
1
# 获取访问量
>pfcount article:123:view
3
# 批量添加元素
>pfadd hll a b c d e f g
1
>pfcount hll
7
同步策略
-
在业务低频的时候同步到数据库。
-
对于实时性要求高的,后台可以直接读取redis的数据。
-
在key加上日期,每次持久化只处理过去时间的、key的值不会再变化的,如此一来,可以保证临界点数据(读出redis数据,然后写入数据库,最后重置或删除key期间产生的新数据)不丢失。
想想埋点,会不会是更好地方案呢~
如果我们想在Markdown中展示文件夹的树状格式,可以使用Windows自带的tree命令输出文件树,然而自带的tree命令不支持排除指定目录等高级功能。这时候就需要安装一个加强版的tree命令 - Tree for Windows,它提供了排除目录、排序规则等丰富的参数。
先来看看Windows自带的tree命令用法
# 查看使用说明
PS C:\Users\Administrator> tree /?
TREE [drive:][path] [/F] [/A]
/F 显示每个文件夹中文件的名称。
/A 使用 ASCII 字符,而不使用扩展字符。
自带的tree命令十分简单,显然不能满足我们排除目录的需求,此时我们就需要安装强化版的tree命令。
使用强化版的Tree命令 - Tree for Windows
下载安装
-
选择 Binaries ZIP 下载
-
解压,找到bin目录下的可执行文件
tree.exe,复制到任意目录(我常常将它们放在C:\bin)
强化版Tree命令的使用说明
# 在tree.exe所在目录执行查看使用说明的命令
> ./tree.exe --help
usage: tree [-adfghilnpqrstuvxACDFNS] [-H baseHREF] [-T title ] [-L level [-R]]
[-P pattern] [-I pattern] [-o filename] [--version] [--help] [--inodes]
[--device] [--noreport] [--nolinks] [--dirsfirst] [--charset charset]
[--filelimit #] [<directory list>]
-a All files are listed.
-d List directories only.
-l Follow symbolic links like directories.
-f Print the full path prefix for each file.
-i Don\'t print indentation lines.
-q Print non-printable characters as '?'.
-N Print non-printable characters as is.
-p Print the protections for each file.
-u Displays file owner or UID number.
-g Displays file group owner or GID number.
-s Print the size in bytes of each file.
-h Print the size in a more human readable way.
-D Print the date of last modification.
-F Appends '/', '=', '*', or '|' as per ls -F.
-v Sort files alphanumerically by version.
-r Sort files in reverse alphanumeric order.
-t Sort files by last modification time.
-x Stay on current filesystem only.
-L level Descend only level directories deep.
-A Print ANSI lines graphic indentation lines.
-S Print with ASCII graphics indentation lines.
-n Turn colorization off always (-C overrides).
-C Turn colorization on always.
-P pattern List only those files that match the pattern given.
-I pattern Do not list files that match the given pattern.
-H baseHREF Prints out HTML format with baseHREF as top directory.
-T string Replace the default HTML title and H1 header with string.
-R Rerun tree when max dir level reached.
-o file Output to file instead of stdout.
--inodes Print inode number of each file.
--device Print device ID number to which each file belongs.
--noreport Turn off file/directory count at end of tree listing.
--nolinks Turn off hyperlinks in HTML output.
--dirsfirst List directories before files.
--charset X Use charset X for HTML and indentation line output.
--filelimit # Do not descend dirs with more than # files in them.
使用例子
查看目录E:\testdir下的文件树,排除目录cachedir,输出排序规则以目录优先。
> ./tree.exe E:\testdir\ -I cachedir --dirsfirst
E:\testdir\
|-- app
| |-- code
| | `-- test.txt
| |-- emptydir
| `-- index.txt
|-- bar
| `-- 233.txt
|-- 1.txt
|-- 2.txt
`-- a.txt
本文成文于 2022 年,2026-09 随项目 2.0 重构全面更新,内容与当前仓库保持一致。
这是一个开箱即用的 FastAPI 异步脚手架,集成了 ORM 模型、数据库迁移、JWT 认证、结构化日志、统一错误契约、路由注册、系统配置、调度任务、双层测试等常用模块。
FastAPI 是一个用于构建 API 的现代、高性能的 Python Web 框架,由于它只提供 app 核心,因此需要我们自己组织项目结构,通常得编写一些 Web API 项目必备的功能模块。对此,我在 github 上搜索相关的最佳实践,然而没有找到心仪的脚手架,所以决定封装一个 FastAPI 骨架项目。
设计思想
- 分层契约清晰:入口层 > 服务层 > 仓储层 > 模型层,由工具链强制约束
- 简洁优雅
- 易于扩展
- 开箱即用
技术栈
- Python 3.13+,依赖与虚拟环境由 uv 管理
- SQLAlchemy 2.0 异步 ORM + aiomysql 驱动,Alembic 管理迁移
- pydantic-settings 读取
.env配置 - structlog 结构化日志
- PyJWT + pwdlib(bcrypt)认证
- APScheduler 调度任务,redis(验证码存储、限流)
项目结构
源码GitHub地址 -> kaxiluo/fastapi-skeleton
/fastapi-skeleton/
|-- main.py ----- Web 入口:创建 app、注册中间件/异常处理/路由
|-- scheduler.py ----- 调度器入口
|-- Makefile ----- 常用命令封装
|-- pyproject.toml / uv.lock ----- 依赖与工具链配置(ruff/pytest/import-linter)
|-- .env.example ----- 环境变量模板
|-- alembic/ ----- 数据库迁移
| `-- versions/ ----- 迁移脚本(内置 users 表与种子用户)
|-- config/ ----- 配置目录(pydantic-settings)
| |-- settings.py ----- app 基础配置 + 运行时安全校验
| |-- database.py / redis.py ----- 存储连接配置
| |-- auth.py ----- JWT 与验证码配置
| `-- logging.py ----- 日志配置
|-- routes/
| `-- api.py ----- 路由聚合,统一 /api/v1 前缀
|-- app/
| |-- core/ ----- 基础设施(叶子模块,禁止依赖业务层)
| | |-- db.py / redis.py ----- 连接与 session 工厂
| | |-- lifespan.py ----- 应用启动/关闭钩子
| | |-- logging.py ----- structlog 配置
| | `-- middleware.py ----- 请求上下文中间件
| |-- http/ ----- 入口层
| | |-- deps.py ----- 依赖:事务边界、当前登录用户、redis
| | `-- api/ ----- 控制器:auth / users / demo / health
| |-- jobs/ ----- 调度任务
| | |-- base_job.py ----- 任务基类 + JobConfig
| | |-- demo_job.py ----- 示例任务
| | `-- job_registry.py ----- 显式任务注册表
| |-- services/ ----- 业务逻辑层
| | |-- auth/ ----- 认证:grant / jwt / hashing / 限流
| | `-- sms/ ----- 短信发送抽象
| |-- repositories/ ----- 仓储层
| |-- models/ ----- SQLAlchemy 模型
| |-- schemas/ ----- 请求/响应模型(datetime 统一时区格式化)
| |-- exceptions/ ----- AppError 体系 + 全局异常处理器
| `-- utils/ ----- 公共方法
|-- tests/
| |-- unit/ ----- 默认执行,不依赖真实中间件
| `-- integration/ ----- 需 .env.test 与真实 MySQL/Redis
`-- storage/logs/ ----- 日志输出目录
核心设计
分层契约
app.http / app.jobs > app.services > app.repositories > app.models,由 import-linter 强制(make lint 或 uv run lint-imports),反向依赖直接报错。app.core / app.exceptions / app.utils / app.schemas 为叶子模块,禁止依赖任何业务层。项目长大了也不会被随手 import 搅成一锅粥。
事务边界只在入口层
HTTP 请求由 deps.get_db_session 统一 commit/rollback:请求正常结束提交,抛异常回滚后重新抛出;调度 Job 自管 session。service 与 repository 永不 commit,业务代码只管做事,不操心事务。
统一错误契约
错误响应统一为 {"type", "title", "detail"}(RFC7807 风格)。业务代码抛 AppError 子类(NotFoundError / AuthenticationError / AuthorizationError / ConflictError / BusinessError 等)即可,全局处理器负责渲染;FastAPI 参数校验 422 的 detail 为错误数组;生产环境 500 不泄露内部 detail。
集成的模块
- 系统配置
基于 pydantic-settings,使用 .env 文件设置环境变量,配置按模块拆分为 app 基础配置、数据库配置(MySQL + Redis)、日志配置、认证配置。启动时做运行时安全校验:AUTH_JWT_SECRET 缺失或过短直接拒绝启动;生产环境若未清空万能验证码同样拒绝启动。
- 数据库 ORM 与迁移
SQLAlchemy 2.0 全异步(aiomysql 驱动),Alembic 管理迁移:make migrate m="msg" 生成、make upgrade 执行。初始迁移内置 users 表与种子用户(fake_user1 / fake_user2,密码 123456),可直接登录测试认证。
- 日志系统
集成 structlog 结构化日志,console / json 两种渲染可切换,请求上下文中间件把请求标识注入每条日志,可选文件轮转与保留天数。
- 异常处理
AppError 异常体系 + 全局 Exception Handler,统一错误契约见上文。
- 路由注册
路由在 routes/ 集中聚合,按模块划分为不同的文件,统一 /api/v1 前缀,代码层次结构清晰。
- 中间件
默认注册了请求上下文中间件与全局 CORS 中间件。
- JWT 认证
提供账号密码和手机号验证码两种认证方式:
| 端点 | 说明 |
|---|---|
POST /api/v1/auth/token | 用户名 + 密码 |
POST /api/v1/auth/cellphone/token | 手机号 + 验证码(通过即自动注册) |
POST /api/v1/auth/cellphone/verification_code | 发码(限流:60s 间隔 + 每日上限) |
注:验证码的存储依赖 redis。万能验证码仅 APP_ENV ∈ {local, testing} 且 AUTH_DEBUG_VERIFICATION_CODE 非空时生效,方便本地联调。
- 调度任务
基于 APScheduler(AsyncIOScheduler)。任务继承 BaseJob 并声明 JobConfig(cron / interval 触发、时区感知、misfire 策略),在 job_registry 显式注册,执行失败由监听器写入结构化日志。
注:定时任务与 api 是分开启动的
- 测试
unit / integration 双层:unit 默认执行(make test),不依赖真实中间件;integration(make test-integration)走真实 MySQL/Redis,覆盖认证全流程。代码质量工具链:ruff(lint + format)+ import-linter(分层契约)。
快速开始
uv sync # 安装依赖
cp .env.example .env # 填 DB/Redis/AUTH_JWT_SECRET
make upgrade # alembic 建库(种子用户 fake_user1/2,密码 123456)
make dev # uvicorn main:app --reload
启动后访问 http://127.0.0.1:8000/docs 查看接口文档,用种子用户登录测试认证。
调度器单独启动:
uv run python scheduler.py
参考
FastAPI作者的全栈项目脚手架 full-stack-fastapi-postgresql
代码结构组织风格参考 Laravel框架
分享一个工作中使用dmn-js的实践案例。需求背景是在绩效系统中实现量化打分、用户可自定义得分规则。对此我们应用到了DMN,其XML由决策引擎Camunda直接执行,前端方面集成了dmn-js。
考虑到编写规则表和打分功能的易用性,系统管理了规则属性列表,用户在设置规则时,只需选择规则属性,就会自动生成DMN关系图,然后填充相应的规则表达式即可。
- DMN(Decision Model and Notation)决策模型标记
- Camunda 工作流引擎
- dmn-js 实现在浏览器中查看和编辑DMN关系图
本文主要是分享在vue应用中,使用dmn-js实现在浏览器中选择规则属性自动生成对应的DMN关系图骨架和XML。
演示

源码
源码GitHub地址 -> kaxiluo/dmn-js-vue
本文原写成于 2022 年(venv + gunicorn 方案),2026-09 随项目 2.0 重构更新为 uv + uvicorn 多 worker 方案。
把 fastapi 项目部署到云主机:uv 安装依赖,systemd 守护 uvicorn 多 worker,nginx 反向代理,开机自启。
依赖与初始化
curl -LsSf https://astral.sh/uv/install.sh | sh
cd /path/to/fastapi-skeleton
uv sync --frozen --no-dev # 按锁文件装生产依赖,创建 .venv
cp .env.example .env # 填 DB/Redis/AUTH_JWT_SECRET
uv run alembic upgrade head # 建库
systemd 服务
新建 /etc/systemd/system/fastapi.service(worker 数一般取 CPU 核数):
[Unit]
Description=fastapi-skeleton - uvicorn
After=network.target
[Service]
Type=simple
WorkingDirectory=/path/to/fastapi-skeleton
ExecStart=/path/to/fastapi-skeleton/.venv/bin/uvicorn main:app --workers 4 --host 127.0.0.1 --port 8080
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl start fastapi
nginx 反向代理
server {
listen 443 ssl http2;
server_name demo.fastapi.com;
# ...省略ssl部分...
charset utf-8;
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
location / {
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_pass http://127.0.0.1:8080;
}
location ~ /\.(?!well-known).* {
deny all;
}
access_log /var/log/nginx/access_fastapi-demo.log;
error_log /var/log/nginx/error_fastapi-demo.log error;
}
发布与开机自启
git pull && uv sync --frozen --no-dev && uv run alembic upgrade head
systemctl restart fastapi # uvicorn 优雅停机,配合 nginx 中断窗口很小
systemctl enable fastapi
参考
2023 年文章
记录 2023 年的技术文章,涵盖 PHP、Vue、Docker 等技术领域。
设想一下,当需求是生成一个样式排版复杂的Excel文件,如果直接使用开源库的API来实现各种样式,那这也忒难了。好在,我们可以预制好一个Excel模板,再将数据填充到对应的位置,这样一来就只需要关注数据,从而轻松的生成了复杂的Excel文件。
分享一个以前写的Excel模板文件渲染工具,模板中支持定义各种变量、自定义渲染行为、设置回调函数,轻松实现复杂的Excel生成。
演示

演示代码如下:
use Kaxiluo\PhpExcelTemplate\CellVars\CallbackContext;
use Kaxiluo\PhpExcelTemplate\CellVars\CellArray2DVar;
use Kaxiluo\PhpExcelTemplate\CellVars\CellArrayVar;
use Kaxiluo\PhpExcelTemplate\CellVars\RenderDirection;
use Kaxiluo\PhpExcelTemplate\PhpExcelTemplate;
$data = [
['个人任务执行情况', '完成X系统开发', '40', '35', '任务延期一次'],
['技术贡献', '主持基础设施项目建设', '30', '30', ''],
['素质与能力', '认真负责,积极工作', '20', '20', ''],
['防疫工作', '严格执行防疫措施,按要求填写防疫信息统计表', '10', '10', ''],
];
$items = new CellArray2DVar(
$data,
true,
false,
function (CallbackContext $context) use ($data) {
if ($context->getLoopColKey() === 3) {
if ($context->getValue() < $data[$context->getLoopRowKey()][2]) {
$context->getStyle()->getFont()->getColor()->setARGB('FFFF0000');
}
}
}
);
// 模板变量定义
$vars = [
'username' => 'lyy',
'department' => 'IT中心',
'dateRange' => '2022-03-01 - 2022-03-31',
'leader' => 'Tim',
'items' => $items,
'totalScore' => '=SUM(D5:D' . (4 + count($data)) . ')',
'x' => new CellArrayVar(['Peace', 'and', 'Love'], RenderDirection::DOWN, false),
];
// 保存文件
PhpExcelTemplate::save('./example-kpi.xlsx', './example-kpi-output.xlsx', $vars);
// 或者浏览器下载
// PhpExcelTemplate::download('./example-kpi.xlsx', 'example-kpi-output.xlsx', $vars);
安装
使用composer安装
composer require kaxiluo/php-excel-template
GitHub地址 -> kaxiluo/php-excel-template
功能点
- 支持字符串变量渲染
- 支持[一维数组变量]渲染,自定义渲染方向(向下的行或向右的列)、是否插入新的行或列
- 支持[[二维数组变量]]渲染,自定义向下是否插入新的行、向右是否插入新的列
- 支持设置回调函数,定制渲染样式或其他特殊行为
模板中定义变量
模板中的变量名只允许特定字符(字母数字-_.)
字符串变量(CellStringVar)
1)在模板中使用 {yourStringVarName} 声明字符串变量
2)用法如下:
use Kaxiluo\PhpExcelTemplate\CellVars\CellStringVar;
use Kaxiluo\PhpExcelTemplate\CellVars\CallbackContext;
use Kaxiluo\PhpExcelTemplate\PhpExcelTemplate;
$vars = [
// 默认
'var1' => 'value1',
'var2' => new CellStringVar('value2'),
// 设置回调
'var3' => new CellStringVar('i was red color', function (CallbackContext $context) {
$context->getStyle()->getFont()->getColor()->setARGB('FFFF0000');
}),
];
PhpExcelTemplate::save('/path/to/templateFile.xlsx', '/path/to/outputFile.xlsx', $vars);
一维数组变量(CellArrayVar)
1)在模板中使用 [yourArrayVarName] 声明一维数组变量
2)用法如下:
use Kaxiluo\PhpExcelTemplate\CellVars\CellArrayVar;
use Kaxiluo\PhpExcelTemplate\CellVars\CallbackContext;
use Kaxiluo\PhpExcelTemplate\CellVars\RenderDirection;
use Kaxiluo\PhpExcelTemplate\PhpExcelTemplate;
$vars = [
// 默认向下渲染,插入新的行
'var1' => ['x1', 'x2'],
'var2' => new CellArrayVar(['x', 'x']),
'var3' => new CellArrayVar(['x', 'x'], RenderDirection::DOWN, true),
// 向右渲染,插入新的列
'var4' => new CellArrayVar(['x', 'x'], RenderDirection::RIGHT),
// 向右渲染,不插入新的列
'var5' => new CellArrayVar(['x', 'x'], RenderDirection::RIGHT, false),
// 向下渲染,不插入新的行
'var6' => new CellArrayVar(['x', 'x'], RenderDirection::DOWN, false),
// 设置回调
'var7' => new CellArrayVar(
['x7-1', 'x7-2', 'x7-3'],
RenderDirection::RIGHT,
true,
function (CallbackContext $context) {
// $context->getWorksheet()
// $context->getValue()
// 设置第二条数据(x7-2)加粗
if ($context->getLoopColKey() === 1) {
$context->getStyle()->getFont()->setBold(true);
}
}
),
];
PhpExcelTemplate::save('/path/to/templateFile.xlsx', '/path/to/outputFile.xlsx', $vars);
二维数组变量(CellArray2DVar)
1)在模板中使用 [[yourArray2DVarName]] 声明二位数组变量
2)用法如下:
use Kaxiluo\PhpExcelTemplate\CellVars\CellArray2DVar;
use Kaxiluo\PhpExcelTemplate\CellVars\CallbackContext;
use Kaxiluo\PhpExcelTemplate\PhpExcelTemplate;
$vars = [
// 默认向右-下方渲染,下方插入新的行,右边不插入新的列
'var1' => [['a1', 'b1'], ['a2', 'b2']],
'var2' => new CellArray2DVar([['c1', 'd1'], ['c2', 'd2']]),
'var3' => new CellArray2DVar([['a5', 'b5'], ['a6', 'b6']], true, false),
// 插入新的行和列
'var4' => new CellArray2DVar([['a5', 'b5'], ['a6', 'b6']], true, true),
// 不插入新的行和列
'var5' => new CellArray2DVar([['a5', 'b5'], ['a6', 'b6']], false, false),
// 不插入新的行,插入新的列
'var6' => new CellArray2DVar([['a5', 'b5'], ['a6', 'b6']], false, true),
// 设置回调
'var7' => new CellArray2DVar(
[['x', '88'], ['y', '59'], ['z', '95']],
true,
false,
function (CallbackContext $context) {
// 第二列小于60则字体标红
if ($context->getLoopColKey() === 1 && $context->getValue() < 60) {
$context->getStyle()->getFont()->getColor()->setARGB('FFFF0000');
}
}
),
];
PhpExcelTemplate::save('/path/to/templateFile.xlsx', '/path/to/outputFile.xlsx', $vars);
其他
如果Excel模板中同一个行上的多个变量,均定义为要插入新的行,本程序会自动处理需要插入的最大行数,不会插入额外的行;列同理。用户需要考虑多个变量均为插入新行或列,其产生的相互影响。
当表格中内容过长、占用多行,十分影响用户体验。如何实现文本自动截断、展开、收起呢?vue-clamp可以轻松实现。
安装 vue-clamp
npm i --save vue-clamp
封装组件,方便在 Element-UI Table 中使用
<template>
<el-table-column :prop="prop" :label="label">
<template slot-scope="scope">
<v-clamp autoresize :max-lines="maxLines">{{scope.row[prop]}}
<template #after="{ toggle, expanded, clamped }">
<el-button plain v-if="expanded || clamped"
@click="toggle"
style="margin-left: 5px"
size="small">{{ expanded ? '隐藏' : '展开' }}</el-button>
</template>
</v-clamp>
</template>
</el-table-column>
</template>
<script>
import VClamp from 'vue-clamp'
export default {
components: {
VClamp
},
name: "table-column-v-clamp",
props: {
prop: String,
label: String,
maxLines: { type: Number, default: 2 }
}
}
</script>
轻松使用,一行搞定
<el-table
:data="dataList"
border
style="width: 100%">
<el-table-column prop="id" label="ID"></el-table-column>
<el-table-column prop="name" label="名字"></el-table-column>
<table-column-v-clamp prop="content" label="内容"></table-column-v-clamp>
</el-table>
在线体验
Table设置固定列后,固定列错位
由于El-Tbale固定列的实现是复制一份table,再隐藏其他列。VClamp组件的展开没能触发另一个Table视图更新,导致了固定列错位,高度未自动计算。
更改数据来驱动视图更新,以解决该问题
具体方法 -> ElementUI Table VueClamp Fixed
关于 vue-clamp
可以选择限制行数与/或最大高度,无需指定行高。
支持在布局变化时自动更新。
支持展开/收起被截断部分内容。
支持自定义截断文本前后内容,并且进行响应式更新。
支持在文本末尾、中间或开始位置进行截断
记录一下多年前我使用Windows Docker作为开发环境,运行基于PHP-FPM的项目性能低下的问题。 开发过程中,通过Bind mounts与容器共享源码目录,结果项目访问速度十分慢,特别是遇到源码文件特别多的项目(例如magento2),速度已经慢得无法忍受。
PHP-FPM(进程管理器):一种传统的PHP运行模式,运行时不同于常驻内存,每次都会加载源码。
Bind mounts(绑定挂载):Docker容器中管理数据文件的一种方式,一般适合开发过程中,容器与主机共享源码。
探寻问题原因
- 借助工具分析性能
利用XHProf(一个轻量级的分层性能测量分析器PHP扩展) + Xhgui(搭建非侵入式监控平台),跟踪到了程序大部分的运行时间消耗在 require_once,该语句的作用是引入php文件。由此确定源码文件的读取是性能瓶颈。
- 绑定挂载导致I/O性能低
再做个对比测试,把源码复制到容器中再访问,果然速度变快了。由此确定绑定挂载导致源码读取速度变慢。 查询资料得知,Docker的绑定挂载通过SMB共享来实现的,再加上跨操作系统的文件转换,从而I/O性能变得极低。
综上,因为源码在windows主机上,通用绑定挂载共享到Docker容器中,文件的I/O性能低下,项目基于php-fpm,每次运行消耗大量时间加载源码,从而导致项目速度变慢。
Docker绑定挂载性能测试
分别以Hyper-V和WSL2作为后端引擎,再结合挂载不同位置和配置,测试读写性能和实际项目表现。
准备工作
- 安装WSL2
参见 安装WSL
因为我之前安装过WSL1,所以我只需要升级Linux发行版到WSL2
# 升级内核
wsl --update
# 升级到wsl2
wsl --set-version Ubuntu-22.04 2
- 测试I/O工具
通过dd命令测试I/O性能
- 测试项目实际表现
一个干净的Laravel8项目,仅输出‘hello world’
结果记录
基于机械硬盘,Windows Docker, Linux Container
- Hyper-V + 源码拷贝到容器中
# 运行容器
> docker run --name test001 -it --rm -p 8000:8000 php:7.4-fpm /bin/bash
# 主机中执行命令,复制源码到容器中
> docker cp E:/laravel8 test001:/var/www/html
# 在容器中开始测试
> cd /var/www/html/laravel8/
# 写性能测试
> dd if=/dev/zero of=test.dat bs=1024 count=100000
100000+0 records in
100000+0 records out
102400000 bytes (102 MB, 98 MiB) copied, 0.153936 s, 665 MB/s
# 读性能测试
> dd if=test.dat of=/dev/null bs=1024 count=100000
100000+0 records in
100000+0 records out
102400000 bytes (102 MB, 98 MiB) copied, 0.0692353 s, 1.5 GB/s
# 启动内置Web服务,测试项目访问速度
> php artisan serve --host=0.0.0.0 --port=8000
PHP 7.4.27 Development Server (http://0.0.0.0:8000) started
# 浏览器中访问 http://127.0.0.1:8000
第一次 52 ms
第二次 49 ms
第三次 48 ms
# 安装opcache后,再重启Web服务试试
> docker-php-ext-install opcache
# 再次访问,记录耗时
第一次 115 ms
第二次 11 ms
第三次 11 ms
# 退出并自动销毁容器
> exit
- Hyper-V + Bind mounts
# 运行容器
> docker run -it --rm -v E:/laravel8:/var/www/html -p 8000:8000 php:7.4-fpm /bin/bash
# 写性能测试
> dd if=/dev/zero of=test.dat bs=1024 count=100000
100000+0 records in
100000+0 records out
102400000 bytes (102 MB, 98 MiB) copied, 17.4388 s, 5.9 MB/s
# 读性能测试
> dd if=test.dat of=/dev/null bs=1024 count=100000
100000+0 records in
100000+0 records out
102400000 bytes (102 MB, 98 MiB) copied, 0.328262 s, 312 MB/s
# 启动内置Web服务,测试项目访问速度
> php artisan serve --host=0.0.0.0 --port=8000
PHP 7.4.27 Development Server (http://0.0.0.0:8000) started
# 浏览器中访问 http://127.0.0.1:8000
第一次 1.41 s
第二次 656 ms
第三次 616 ms
# 安装opcache后,再重启Web服务试试
> docker-php-ext-install opcache
# 再次访问,记录耗时
第一次 701 ms
第二次 36 ms
第三次 37 ms
# 退出并自动销毁容器
> exit
- Hyper-V + Bind mounts + 一致性选项
绑定挂载的一致性选项:consistent、delegated、cached。
但是这个选项仅仅适用于Docker for Mac。
切换Docker引擎 ‘Use the WSL 2 based engine’,重启计算机
- WSL2 + Bind mounts
# 运行容器
> docker run -it --rm -v E:/laravel8:/var/www/html -p 8000:8000 php:7.4-fpm /bin/bash
# 写性能测试
> dd if=/dev/zero of=test.dat bs=1024 count=100000
100000+0 records in
100000+0 records out
102400000 bytes (102 MB, 98 MiB) copied, 6.20594 s, 16.5 MB/s
# 读性能测试
> dd if=test.dat of=/dev/null bs=1024 count=100000
100000+0 records in
100000+0 records out
102400000 bytes (102 MB, 98 MiB) copied, 5.87995 s, 17.4 MB/s
# 启动内置Web服务,测试项目访问速度
> php artisan serve --host=0.0.0.0 --port=8000
PHP 7.4.27 Development Server (http://0.0.0.0:8000) started
# 浏览器中访问 http://127.0.0.1:8000
第一次 1.73 s
第二次 801 ms
第三次 764 ms
# 安装opcache后,再重启Web服务试试
> docker-php-ext-install opcache
# 再次访问,记录耗时
第一次 1.75 s
第二次 586 ms
第三次 574 ms
# 退出并自动销毁容器
> exit
- WSL2 + Bind mounts + 挂载源目录改为
/mnt/e/
## 进入子系统,以下命令均在子系统执行
# 运行容器
> docker run -it --rm -v /mnt/e/laravel8:/var/www/html -p 8000:8000 php:7.4-fpm /bin/bash
# 写性能测试
> dd if=/dev/zero of=test.dat bs=1024 count=100000
100000+0 records in
100000+0 records out
102400000 bytes (102 MB, 98 MiB) copied, 6.97992 s, 14.7 MB/s
# 读性能测试
> dd if=test.dat of=/dev/null bs=1024 count=100000
100000+0 records in
100000+0 records out
102400000 bytes (102 MB, 98 MiB) copied, 6.67416 s, 15.3 MB/s
# 启动内置Web服务,测试项目访问速度
> php artisan serve --host=0.0.0.0 --port=8000
PHP 7.4.27 Development Server (http://0.0.0.0:8000) started
# 浏览器中访问 http://127.0.0.1:8000
第一次 1.71 s
第二次 761 ms
第三次 760 ms
# 安装opcache后,再重启Web服务试试
> docker-php-ext-install opcache
# 再次访问,记录耗时
第一次 1.72 s
第二次 579 ms
第三次 583 ms
# 退出并自动销毁容器
> exit
- WSL2 + Bind mounts + 挂载源目录改为
//wsl$
WSL2作为后端引擎,Docker Desktop安装了两个专用的内部Linux发行版和,分别用于运行Docker引擎、存储容器和镜像。两者都不能用于一般的开发。
文件资源管理器中输入 \\wsl$ 查看
# 尝试把源码放在 \\wsl$\docker-desktop-data\laravel8
# 无法挂载...
总结
在Windows中,对于源码文件不多的php-fpm项目,选择Hyper-V + Bind mounts 再启用opcache ,基本可以愉快的开发。
但是遇到源码文件特别多的项目,例如magento2,我还是选择远程开发!
选择远程开发
为了方便在Windows编写代码,Linux容器中运行,选择远程开发也是一个很好的方式。如今的IDE对远程开发的支持已经很完美了。
只需在容器中安装ssh服务,映射22端口到主机即可。下面给个例子供大家参考。
Docekerfile
FROM debian:buster
RUN set -xe \
&& apt-get update -y \
&& apt-get install -y --no-install-recommends \
openssh-server
RUN mkdir -p /var/run/sshd \
&& sed -i "s/#PermitRootLogin prohibit-password/PermitRootLogin yes/" /etc/ssh/sshd_config \
&& echo root:123456 | chpasswd
WORKDIR /workspace
CMD ["/usr/sbin/sshd", "-D"]
EXPOSE 22
构建镜像&运行
docker build -t debian-remote-dev-test .
docker run -d -p 2202:22 debian-remote-dev-test
测试SSH连接
ssh root@127.0.0.1 -p 2202
...连接成功...
记录一下我使用PhpStorm远程开发时踩的坑
-
默认同步选项配置未勾选 '本地删除后删除远程文件',然后我在本地重命名或删除了文件,结果远程还保留了之前的文件,导致程序出错了。
-
开发过程中,远程生成的一些辅助文件(代理类),忘记同步到本地,导致本地代码提示不全。
参考
2024 年文章
记录 2024 年的技术文章,涵盖 Golang、Redis、Socket.IO 等技术领域。
Golang Gin + Redis 队列:轻松应对高并发写入数据库
为了应对高并发写入数据库场景下的性能瓶颈,可以采用先写入队列,再批量读取写入MySQL的方案。
近期工作上需要统计用户事件达成率、留存等数据,对此我选择了Golang Gin框架和Redis队列来实现,以降低数据库负载,提高并发写入性能。
方案
-
对于这种非核心业务的数据统计,简单起见直接用Redis队列写入元数据
-
使用Redis Bitmap记录已上报的用户ID,防止重复上报
-
定时刷新队列消息到数据库,根据业务需求选择合适的持久化频率
伪代码
下面以收集用户事件元数据为例,给出部分伪代码(为了简洁忽略错误处理)
主要字段有appcode、date、eventId、userId
- 元数据写入队列
// 检查当日是否已经上报过
// 使用redis位图检查userId在当日是否上报了事件3
// 此外:为了更准确也可再次查询数据库
bitKey := appcode + ":user_reach:" + nowDate + ":e" + strconv.FormatInt(eventId, 10)
exist, _ := Redis.GetBit(ctx, bitKey, userId).Result()
// 请求转为Json字符串,写入队列
// 此外:写入队列之前,可以先对数据进行压缩,以减少队列占用空间
data, err := json.Marshal(request)
Redis.RPush(ctx, QueueKey, data).Result()
// Bitmap记录用户ID已上报
exist, err := Redis.Exists(ctx, bitKey).Result()
Redis.SetBit(ctx, bitKey, userId, 1)
// Bitmap缓存一天
if exists == 0 {
Redis.Expire(ctx, bitKey, 24*time.Hour)
}
- 定时持久化队列消息到MySQL
// 从队列中取出1000条
// 此外:应该定时检查队列长度,超长时取出消费
values, err := Redis.LRange(ctx, QueueKey, 0, 999).Result()
// 处理数据 转换为model结构体
var data []models.UserReachMeta
for _, value := range values {
var meta models.UserReachMeta
json.Unmarshal([]byte(value), &meta)
data = append(data, meta)
}
// 插入数据库
insertCount := DB.Create(&data).RowsAffected
// 从队列中删除已处理的元素
if insertCount > 0 {
Redis.LTrim(ctx, QueueKey, insertCount, -1)
}
源码
Github kaxiluo/gin-data-statistics-api
压测
工具 jmeter
实战指南:使用Socket.IO和Redis构建实时多人在线PK系统
近期开发了一个知识PK系统,主要包括创建房间、加入房间、开始游戏、答题等功能。对此我选用了SocketIO,实现客户端和服务器之间低延迟,双向和基于事件的通信。
技术方案
- Hyperf框架
一个基于Swoole的PHP协程框架
- SocketIO组件
Socket.IO是一款非常流行的应用层实时通讯协议和框架,可以轻松实现应答、分组、广播
组件基于WebSocket实现,房间适配器基于Redis驱动,可以适应多进程乃至分布式场景
- Redis
存储房间和玩家状态
- Postman
一个强大的客户端工具,支持Socket.IO连接,用它来做功能测试十分方便
状态存储
Redis存储
| 数据结构 | Key | Value | 说明 | |
|---|---|---|---|---|
| 房间信息 | Hash | rooms:{room_no} | {"id": 1, "state": "created", "game_number": 1, "sid":"6626326272d7b#1"} | 存储房间ID、状态等基本信息 |
| 玩家集合 | Set | rooms:{room_no}:playerIds | player1,player2 | 存储房间内所有玩家ID |
| 玩家状态 | Hash | rooms:{room_no}:players:{player_id} | {"id": 1, "nickname": "卡夕洛", "avatar": "", "state":"ready", "score":0, "answer_count":0,"time":1713779921} | 存储玩家的ID、昵称、状态、得分等信息 |
| 暂存房间 | String | staging_room:{user_id} | room_no_xx | 房主断线后保留房间,X秒过期,重连后直接使用该房间 |
Context-连接上下文
实例化后的Roomer/Player服务类可以存储在连接上下文
事件
基本流程
房主:创建房间 - 即将开始 - 开始游戏 - 答题 - 对局完成 - 返回房间/解散房间
玩家:加入房间 - 准备/取消准备/离开房间 - 答题 - 对局完成 - 返回房间/离开房间
| 事件 | 说明 |
|---|---|
| create-room | 房主:创建房间并加入,响应玩家列表和房间信息 |
| join-room | 玩家:加入房间,响应玩家列表和房间信息,向房间内广播player-join |
| leave-room | 玩家:离开房间,向房间内广播player-leave |
| player-ready | 玩家:准备,向房间内广播player-ready |
| player-cancel | 玩家:取消准备,向房间内广播player-cancel |
| game-ready | 房主:游戏即将开始,向房间内广播game-ready |
| game-start | 房主:游戏开始,向房间内广播game-start,消息为PK的题目 |
| answer | 答题,向房间内广播分数等信息 |
| return-room | 返回房间,响应为玩家列表和房间信息 |
| room-dissolve | 房主:销毁房间,向房间内广播room-dissolve |
掉线情况
心跳机制来检测玩家是否掉线,如果客户端在一段时间内没有发送心跳包,则认为客户端已掉线
Socket.IO服务器
2.x版本,客户端与服务器建立连接时,服务器向客户端发送pingInterval和pingTimeout参数,用来控制客户端发送ping消息的频率和超时时间
-
玩家掉线后,直接向房间内广播
player-leave -
房主掉线后,在某些情况下(如APP切换到后台被系统杀掉),保留房间一段时间
启动一个定时器,X秒后执行销毁房间和广播事件。如果客户端重新连接并创建房间,优先判断是否有保留房间,有则直接加入房间并清除定时器
对局记录持久化
每场PK结束后需成绩排名,并把对局记录存储到数据库,即结算
- 所有玩家答题完毕
当玩家答题到最后一道时,判断是否所有玩家都已答完,若是则结算
这里可能会有多人同时触发结算,我们使用Redis分布式锁避免并发问题,只有加锁成功的玩家才能进入结算逻辑
- 所有玩家掉线
当玩家掉线时,如果房间状态为PK中,判断该玩家是否为房间最后一人,若是则结算
- 定时任务
定时巡查结算超时的房间
部署
经过测算,每名玩家约占用1kbRedis内存
Docker + 蓝绿部署
蓝绿部署是一种发布新版本软件的策略,它将新版本软件部署在一个与生产环境完全隔离的环境中,称为“蓝环境”。经过测试验证后,再将流量从“绿环境”切换到“蓝环境”,完成新版本软件的发布。
每次发版切换流量后,客户端与旧版本之间建立的长连接不受影响,待等到旧版本上没有连接后再关闭服务即可。