Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

简介

欢迎来到 Kaxiluo's Blog!

这里是个人技术博客,记录开发过程中遇到的问题和解决方案,涵盖 Web 开发、数据库等技术领域。

博客同步发布于 www.kxler.com。

Tip

小提示:右上角可以切换亮色 / 暗色主题,左上角按钮可收起目录。

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

下载安装

  1. 进入Tree for Windows

  2. 选择 Binaries ZIP 下载

  3. 解压,找到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官方中文文档

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。

演示

在线体验

dmn-js-vue-example

源码

源码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

参考

uv 官方文档

uvicorn 部署文档

FastAPI 部署文档

2023 年文章

记录 2023 年的技术文章,涵盖 PHP、Vue、Docker 等技术领域。

设想一下,当需求是生成一个样式排版复杂的Excel文件,如果直接使用开源库的API来实现各种样式,那这也忒难了。好在,我们可以预制好一个Excel模板,再将数据填充到对应的位置,这样一来就只需要关注数据,从而轻松的生成了复杂的Excel文件。

分享一个以前写的Excel模板文件渲染工具,模板中支持定义各种变量、自定义渲染行为、设置回调函数,轻松实现复杂的Excel生成。

演示

Example

演示代码如下:

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>

在线体验

CodePen在线体验

Table设置固定列后,固定列错位

由于El-Tbale固定列的实现是复制一份table,再隐藏其他列。VClamp组件的展开没能触发另一个Table视图更新,导致了固定列错位,高度未自动计算。

更改数据来驱动视图更新,以解决该问题

具体方法 -> ElementUI Table VueClamp Fixed

关于 vue-clamp

可以选择限制行数与/或最大高度,无需指定行高。

支持在布局变化时自动更新。

支持展开/收起被截断部分内容。

支持自定义截断文本前后内容,并且进行响应式更新。

支持在文本末尾、中间或开始位置进行截断

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远程开发时踩的坑

  • 默认同步选项配置未勾选 '本地删除后删除远程文件',然后我在本地重命名或删除了文件,结果远程还保留了之前的文件,导致程序出错了。

  • 开发过程中,远程生成的一些辅助文件(代理类),忘记同步到本地,导致本地代码提示不全。

参考

Docker Storage

Shared Volumes Slow

博客 | Docker桌面WSL2最佳实践

2024 年文章

记录 2024 年的技术文章,涵盖 Golang、Redis、Socket.IO 等技术领域。

Golang Gin + Redis 队列:轻松应对高并发写入数据库

为了应对高并发写入数据库场景下的性能瓶颈,可以采用先写入队列,再批量读取写入MySQL的方案。

近期工作上需要统计用户事件达成率、留存等数据,对此我选择了Golang Gin框架和Redis队列来实现,以降低数据库负载,提高并发写入性能。

方案

  1. 对于这种非核心业务的数据统计,简单起见直接用Redis队列写入元数据

  2. 使用Redis Bitmap记录已上报的用户ID,防止重复上报

  3. 定时刷新队列消息到数据库,根据业务需求选择合适的持久化频率

伪代码

下面以收集用户事件元数据为例,给出部分伪代码(为了简洁忽略错误处理)

主要字段有appcode、date、eventId、userId

  1. 元数据写入队列
// 检查当日是否已经上报过
// 使用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)
}
  1. 定时持久化队列消息到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存储

数据结构KeyValue说明
房间信息Hashrooms:{room_no}{"id": 1, "state": "created", "game_number": 1, "sid":"6626326272d7b#1"}存储房间ID、状态等基本信息
玩家集合Setrooms:{room_no}:playerIdsplayer1,player2存储房间内所有玩家ID
玩家状态Hashrooms:{room_no}:players:{player_id}{"id": 1, "nickname": "卡夕洛", "avatar": "", "state":"ready", "score":0, "answer_count":0,"time":1713779921}存储玩家的ID、昵称、状态、得分等信息
暂存房间Stringstaging_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 + 蓝绿部署

蓝绿部署是一种发布新版本软件的策略,它将新版本软件部署在一个与生产环境完全隔离的环境中,称为“蓝环境”。经过测试验证后,再将流量从“绿环境”切换到“蓝环境”,完成新版本软件的发布。

每次发版切换流量后,客户端与旧版本之间建立的长连接不受影响,待等到旧版本上没有连接后再关闭服务即可。