[python]Gunicorn加持,轻松提升Flask超7倍性能
前言
之前学习和实际生产环境的flask都是用app.run()
的默认方式启动的,因为只是公司内部服务,请求量不高,一直也没出过什么性能问题。最近接管其它小组的服务时,发现他们的服务使用Gunicorn + Flask的方式运行的,本地开发用的gevent的WSGIServer。对于Gunicorn之前只是耳闻,没实际用过,正好捣鼓下看看到底能有多少性能提升。本文简单记录flask在各种配置参数和运行方式的性能,后面也会跟其他语言和框架做个对比。
- python版本:3.11
- flask版本:3.0.3
- Gunicorn:23.0.0
- wrk作为性能测试工具
- 运行环境:vbox虚拟机,debian 12, 4C4G的硬件配置
wrk测试脚本
wrk支持用lua脚本对请求的响应结果进行验证,以下脚本对响应码和响应内容进行校验
wrk.method = "GET"
wrk.host = "127.0.0.1:8080"
wrk.path = "/health"
wrk.timeout = 1.0
response = function(status, headers, body)
if status ~= 200 then
print("Error: expected 200 but got " .. status)
end
if not body:find("ok") then
print("Error: response does not contain expected content.")
end
end
Flask框架的测试记录
- 先测试默认运行方式,且没有sleep的情况下的并发性能。
from flask import Flask
app = Flask(__name__)
@app.get("/health")
def health():
return "ok"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
使用命令nohup python demo.py > /dev/null
启动,以下为wrk测试结果,可以看到已经出现超时请求。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 65.47ms 81.07ms 1.99s 97.69%
Req/Sec 575.99 107.20 1.08k 66.71%
137538 requests in 1.00m, 22.82MB read
Socket errors: connect 0, read 114, write 0, timeout 144
Requests/sec: 2288.49
Transfer/sec: 388.86KB
- 还是默认启动方式,增加等待时间,模拟处理任务的时间消耗。后续测试都会增加等待时间。
from flask import Flask
from time import sleep
app = Flask(__name__)
@app.get("/health")
def health():
sleep(0.1)
return "ok"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
wrk测试结果,不出所料性能会有所下降。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 201.90ms 239.99ms 2.00s 95.05%
Req/Sec 479.79 185.87 1.82k 68.19%
114440 requests in 1.00m, 18.99MB read
Socket errors: connect 0, read 2, write 0, timeout 1833
Requests/sec: 1904.45
Transfer/sec: 323.62KB
- flask 更新到版本2后支持使用异步函数(需要安装异步相关依赖
python -m pip install -U flask[async]
)
from flask import Flask
import asyncio
app = Flask(__name__)
@app.route('/health')
async def health():
await asyncio.sleep(0.1)
return "ok"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
wrk测试结果,性能相较于同步函数甚至还下降了,QPS几乎砍半,看来异步版Flask还有待增强。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 275.49ms 190.22ms 2.00s 95.46%
Req/Sec 242.86 104.25 720.00 65.91%
57896 requests in 1.00m, 9.61MB read
Socket errors: connect 0, read 48, write 0, timeout 611
Requests/sec: 964.31
Transfer/sec: 163.86KB
- 接管的Flask应用在本地使用gevent的WSGIServer运行,所以也来试试。
from gevent.pywsgi import WSGIServer
from flask import Flask
from time import sleep
app = Flask(__name__)
@app.route('/health')
def health():
sleep(0.1)
return "ok"
if __name__ == "__main__":
http_server = WSGIServer(('0.0.0.0', 8080), app)
http_server.serve_forever()
wrk测试结果,惨不忍睹,像是单线程在挨个处理请求,每个请求都会阻塞住。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 185.36ms 221.17ms 1.93s 96.30%
Req/Sec 5.54 3.39 10.00 46.55%
592 requests in 1.00m, 67.64KB read
Socket errors: connect 0, read 0, write 0, timeout 322
Requests/sec: 9.85
Transfer/sec: 1.13KB
- 按网上搜的结果加上了monkey patch
from gevent.pywsgi import WSGIServer
from gevent import monkey
from flask import Flask
from time import sleep
monkey.patch_all()
app = Flask(__name__)
@app.route('/health')
def health():
sleep(0.1)
return "ok"
if __name__ == "__main__":
http_server = WSGIServer(('0.0.0.0', 8080), app)
http_server.serve_forever()
wrk测试结果,加上monkey patch后似乎也没什么作用。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 182.89ms 209.48ms 1.82s 96.07%
Req/Sec 5.55 3.50 10.00 47.89%
592 requests in 1.00m, 67.64KB read
Socket errors: connect 0, read 0, write 0, timeout 312
Requests/sec: 9.85
Transfer/sec: 1.13KB
- 正式上gunicorn,代码没有任何改动,也不需要引用gevent的WSGServer。
from flask import Flask
from time import sleep
app = Flask(__name__)
@app.get("/health")
def health():
sleep(0.1)
return "ok"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
运行命令。指定-k gevent
。demo:app
中的demo
是代码文件名。--worker-connections
默认为1000
gunicorn demo:app -b 0.0.0.0:8080 -w 4 -k gevent --worker-connections 2000
wrk测试结果。性能相较于默认启动方式有了接近10倍的提升,请求响应时间也很稳定,最大响应时间也只有310.48。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 126.18ms 9.52ms 310.48ms 84.68%
Req/Sec 3.98k 165.70 4.53k 77.34%
948506 requests in 1.00m, 143.83MB read
Requests/sec: 15799.31
Transfer/sec: 2.40MB
其它框架和语言
在t4c2000的wrk配置下,flask+unicorn的每个进程基本都占用了85+%的CPU,再提高就得加CPU核心数了,不过这样的性能已经能满足公司内部服务的需求了,而且实际业务中,短板更可能是网络IO。
这里也测测其它语言和框架,看看Flask在Gunicorn的加持下能否打出python的牌面。
Golang
上来先试试最熟悉的Go, version: 1.22.4,使用标准库。(编译打包出来就能直接运行,不需要jvm这样的虚拟机,也不需要python这样的解释器,更不需要docker这样的容器运行时,特喜欢Go这一点)
package main
import (
"fmt"
"net/http"
"time"
)
func MyHandler(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Millisecond * 100)
w.Write([]byte("ok"))
}
func main() {
http.HandleFunc("/health", MyHandler)
err := http.ListenAndServe("0.0.0.0:8080", nil)
if err != nil {
fmt.Println(err)
}
}
wrk结果如下,请求量是目前测试以来第一个突破百万,而且也没有timeout的出现。使用top观察资源消耗,CPU只占用了约30%,而且还只有一个进程。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 101.56ms 1.48ms 121.62ms 77.58%
Req/Sec 4.94k 191.07 5.05k 93.49%
1180108 requests in 1.00m, 132.80MB read
Requests/sec: 19643.94
Transfer/sec: 2.21MB
不断加大连接,直到系统平均负载到达4(虚拟机CPU核心数为4)。连接数加了10倍,QPS差不多也是10倍于Flask + Gunicorn。这时候实际上wrk也占用了不少CPU资源,服务端的性能并没到瓶颈。
$ wrk -s bm.lua -t 4 -c20000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 20000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 167.56ms 43.45ms 397.95ms 60.37%
Req/Sec 29.48k 8.84k 48.26k 63.05%
6867733 requests in 1.00m, 772.85MB read
Requests/sec: 114258.70
Transfer/sec: 12.86MB
FastAPI
Go的性能已经很不错了,就性能来说还不是Flask+Gunicorn能媲美的。再来试试号称性能并肩Go的FastAPI(官网features里面写的)。FastAPI版本:0.115.4
纯uvicorn启动,用的是同步函数。
from fastapi import FastAPI
import uvicorn
from fastapi.responses import PlainTextResponse
from time import sleep
app = FastAPI()
@app.get("/health")
def index():
sleep(0.1)
return PlainTextResponse(status_code=200,content="ok")
if __name__ == '__main__':
uvicorn.run(app, host="127.0.0.1", port=8080, access_log=False)
wrk测试结果,可以看到相当低下,甚至还不如flask的默认运行方式,超时请求数都过2w了。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.22s 438.35ms 1.95s 60.00%
Req/Sec 115.03 57.31 410.00 86.69%
23494 requests in 1.00m, 3.02MB read
Socket errors: connect 0, read 0, write 0, timeout 22894
Requests/sec: 390.92
Transfer/sec: 51.54KB
改用异步函数再试试。
from fastapi import FastAPI
import uvicorn
from fastapi.responses import PlainTextResponse
from time import sleep
import asyncio
app = FastAPI()
@app.get("/health")
async def health():
await asyncio.sleep(0.1)
return PlainTextResponse(status_code=200,content="ok")
if __name__ == '__main__':
uvicorn.run(app, host="127.0.0.1", port=8080, access_log=False)
wrk测试结果,可以看到性能好很多了,而且没有timeout。QPS是Flask默认启动方式的2倍,但实际性能应该不止2倍。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 484.53ms 63.18ms 654.48ms 61.26%
Req/Sec 1.09k 698.29 3.13k 63.78%
246744 requests in 1.00m, 31.77MB read
Requests/sec: 4106.80
Transfer/sec: 541.43KB
uvicorn支持指定worker数,这里设置为CPU核心数。
from fastapi import FastAPI
import uvicorn
from fastapi.responses import PlainTextResponse
from time import sleep
import asyncio
app = FastAPI()
@app.get("/health")
async def health():
await asyncio.sleep(0.1)
return PlainTextResponse(status_code=200,content="ok")
if __name__ == '__main__':
uvicorn.run(app="demo2:app", host="127.0.0.1", port=8080, access_log=False, workers=4)
wrk测试结果,响应时间还是非常稳的,完全没有timeout的情况,延迟还更低。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 164.52ms 13.57ms 273.27ms 73.49%
Req/Sec 3.05k 544.20 4.64k 68.67%
727517 requests in 1.00m, 93.73MB read
Requests/sec: 12123.17
Transfer/sec: 1.56MB
gunicorn也支持uvicorn,看看fastapi在gunicorn的加持下会有怎样的性能表现。
gunicorn demo2:app -b 127.0.0.1:8080 -w 4 -k uvicorn.workers.UvicornWorker --worker-connections 2000
wrk测试结果,相较于unicorn运行方式,性能提升并不多。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 146.43ms 21.21ms 263.13ms 69.52%
Req/Sec 3.43k 508.71 4.88k 71.40%
818281 requests in 1.00m, 105.35MB read
Requests/sec: 13620.16
Transfer/sec: 1.75MB
Sanic
之前用过一段时间Sanic,也是个python异步框架,版本:24.6.0
from sanic import Sanic
from sanic.response import text
import asyncio
app = Sanic("HelloWorld")
@app.get("/health")
async def hello_world(request):
await asyncio.sleep(0.1)
return text("ok")
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8080, fast=True, debug=False, access_log=False)
wrk测试结果。虽然QPS比FastAPI高,但是有timeout的情况,不是很稳定。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 104.05ms 45.30ms 1.82s 99.59%
Req/Sec 4.84k 538.65 5.07k 95.98%
1154792 requests in 1.00m, 115.64MB read
Socket errors: connect 0, read 0, write 0, timeout 88
Requests/sec: 19218.08
Transfer/sec: 1.92MB
Openresty
openresty基于nginx,通过集成lua,也可以用来写api。配置如下,只是增加了一个location,稍微调整下nginx的参数
worker_processes auto;
worker_cpu_affinity auto;
events {
worker_connections 65535;
}
http {
include mime.types;
default_type application/octet-stream;
access_log off;
sendfile on;
keepalive_timeout 65;
server {
listen 8080 deferred;
server_name localhost;
location /health {
content_by_lua_block {
ngx.sleep(0.1)
ngx.print("ok")
}
}
location / {
root html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
wrk测试结果,和Go语言相当。
$ wrk -s bm.lua -t 4 -c2000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://192.168.0.201:8080/health
4 threads and 2000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 101.43ms 1.62ms 136.59ms 88.97%
Req/Sec 4.94k 213.51 5.33k 92.65%
1178854 requests in 1.00m, 211.36MB read
Requests/sec: 19619.86
Transfer/sec: 3.52MB
top
观察openresty的cpu占用并不高,加大连接再试试。连接数达到25000后,系统平均负载已经基本满了,而且wrk也占用了不少CPU资源。和Go差不多,并没有到服务端的性能瓶颈,而是受到系统资源限制。
$ wrk -s bm.lua -t 4 -c25000 -d60s http://127.0.0.1:8080/health
Running 1m test @ http://127.0.0.1:8080/health
4 threads and 25000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 149.17ms 36.61ms 659.40ms 71.22%
Req/Sec 41.00k 6.64k 59.03k 65.95%
9330399 requests in 1.00m, 1.63GB read
Requests/sec: 155277.58
Transfer/sec: 27.84MB
小结
整理下测试数据汇总成表格如下
项目 | 总请求量 | 每秒请求量 | 平均响应时间 | 最大响应时间 | 备注 |
---|---|---|---|---|---|
Flask-no sleep | 137538 | 2288.49 | 65.47ms | 1.99s | 有响应超时情况 |
Flask-同步方式 | 114440 | 1904.45 | 201.90ms | 2.00s | 有响应超时情况 |
Flask-异步函数 | 57896 | 964.31 | 275.49ms | 2.00s | 有响应超时情况 |
Flask+gevent | 592 | 9.85 | 185.36ms | 1.93s | 有响应超时情况 |
Flask+gevent(monkeypatch) | 592 | 9.85 | 182.89ms | 1.82s | 有响应超时情况 |
Flask+gevent+unicorn | 948506 | 15799.31 | 126.18ms | 310.48ms | |
Golang | 1180108 | 19643.94 | 101.56ms | 121.62ms | |
Golang | 6867733 | 114258.70 | 167.56ms | 397.95ms | wrk的配置为t4c20000 |
FastAPI-同步函数 | 23494 | 390.92 | 1.22s | 1.95s | 有响应超时情况 |
FastAPI-异步函数 | 246744 | 4106.80 | 484.53ms | 654.48ms | |
FastAPI-多worker | 727517 | 12123.17 | 164.52ms | 273.27ms | |
FastAPI+Gunicorn | 818281 | 13620.16 | 146.43ms | 273.27ms | |
Sanic | 1154792 | 19218.08 | 104.05ms | 1.82s | 有响应超时情况,不是很稳定 |
OpenResty | 1178854 | 19619.86 | 101.43ms | 136.59ms | |
OpenResty | 9330399 | 155277.58 | 149.17ms | 659.40ms | wrk的配置为t4c25000 |
根据测试结果,测试的三个Python Web框架中,Flask+gevent+unicorn综合最佳,不低的QPS,而且没有请求超时的情况,也不需要将代码修改成异步方式。Sanic的QPS虽高,但是有响应超时的情况,说明并不稳定,而且代码需要是异步的。FastAPI+Gunicorn的表现也不差,在不使用Gunicorn的情况下也能提供不错的性能,但代码同样需要改成异步方式。对于Sanic和FastAPI,Gunicorn的加持并不必要,而Gunicorn对Flask的性能提升至少7倍,而且能避免请求超时的情况,生产环境下应该尽量使用Gunicorn来运行Flask。
Go比各个Python框架的性能都更好,资源占用也更低,运行方式还更简单,不需要依赖编程语言环境和其他组件,非要说缺点的话就是开发没有Python快。
OpenResty的性能在测试中是最高的,主要是nginx本身性能良好。缺点是开发更麻烦。虽然是用lua开发,但lua作为动态语言,既不如Python极其灵活,还有动态语言本身代码不够清晰的缺点。以前尝试过用openresty实现一个crud服务,后来连自己都懒得维护就放弃了,干脆只用来当网关。
鱼与熊掌不可兼得,开发速度跟运行速度往往相斥,除非代码以后都是AI来写。就公司目前这服务的使用情况来说,Flask+Gunicorn的性能已经足够,还不需要改代码,实乃社畜良伴。而且现在啥都上k8s了,服务扩展也简单,性能不够就加实例嘛
[python]Gunicorn加持,轻松提升Flask超7倍性能的更多相关文章
- 啊哈C!思考快你一步——用编程轻松提升逻辑力
啊哈C!思考快你一步——用编程轻松提升逻辑力(双色)(每个人都应该学习如何编程,因为它教会你如何思考.——史蒂夫.乔布斯) 啊哈磊著 ISBN 978-7-121-21336-6 2013年9月出版 ...
- Python轻量Web框架Flask使用
http://blog.csdn.net/jacman/article/details/49098819 目录(?)[+] Flask安装 Python开发工具EclipsePyDev准备 Flask ...
- [ 转载 ] Python Web 框架:Django、Flask 与 Tornado 的性能对比
本文的数据涉及到我面试时遇到过的问题,大概一次 http 请求到收到响应需要多少时间.这个问题在实际工作中与框架有比较大的关系,因此特别就框架的性能做了一次分析. 这里使用 2016 年 6 月 9 ...
- Web开发入门教程:Pycharm轻松创建Flask项目
Web开发入门教程:Pycharm轻松创建Flask项目 打开Pycharm的file,选择创建新的项目,然后弹出对话框,我们可以看到里面有很多的案例,Flask.Django等等,我们选择生成Fla ...
- (数据科学学习手札90)Python+Kepler.gl轻松制作时间轮播图
本文示例代码及数据已上传至我的Github仓库https://github.com/CNFeffery/DataScienceStudyNotes 1 简介 Kepler.gl作为一款强大的开源地理信 ...
- 快速上手python的简单web框架flask
目录 简介 web框架的重要组成部分 快速上手flask flask的第一个应用 flask中的路由 不同的http方法 静态文件 使用模板 总结 简介 python可以做很多事情,虽然它的强项在于进 ...
- 提升 web 应用程序的性能(一)
提升 web 应用程序的性能,找出瓶颈,加快客户端内容的速度. 作为 web 用户,我们知道页面加载或刷新的速度对其成功至关重要.本文将帮助您更好地理解影响 web 应用程序性能的因素.学 ...
- 开启 NFS 文件系统提升 Vagrant 共享目录的性能
Vagrant 默认的 VirtualBox 共享目录方式读写性能表现并不好,好在 Vagrant 支持 NFS 文件系统方式的共享,我们可以启用 NFS 提升性能 开启方法 首先要把虚拟机的网络设置 ...
- C#不用union,而是有更好的方式实现 .net自定义错误页面实现 .net自定义错误页面实现升级篇 .net捕捉全局未处理异常的3种方式 一款很不错的FLASH时种插件 关于c#中委托使用小结 WEB网站常见受攻击方式及解决办法 判断URL是否存在 提升高并发量服务器性能解决思路
C#不用union,而是有更好的方式实现 用过C/C++的人都知道有个union,特别好用,似乎char数组到short,int,float等的转换无所不能,也确实是能,并且用起来十分方便.那C# ...
- 【转】提升你的Java应用性能:改善数据处理
提升你的Java应用性能:改善数据处理 作者:贾小骏 发布于07月26日 10:17 许多应用程序在压力测试阶段或在生产环境中都会遇到性能问题.如果我们看一下性能问题背后的原因,会发现很多是由数据处 ...
随机推荐
- esphome esp8266刷写遇到的问题
问题描述: 在尝试打开串口时出现以下错误信息: Failed to execute 'open' on 'SerialPort': Failed to open serial port. 起因: 莫名 ...
- java_String方法大全
1 String a = "abcdefg"; 2 3 String a = new String(); 4 String a = new String("abcdefg ...
- LaTeX 插入表格
普通表格 \begin{table}[h] % h: here \begin{center} % 一个字母代表一列 \begin{tabular}{|c|cccc|} % c: center, l: ...
- 关于SQLServer数据库的READ_COMMITTED_SNAPSHOT隔离级别
默认情况下,SQL Server的事务隔离级别是READ COMMITED.刚开始我理解这个模式就是读已经提交的,那也就是说并发一个事务去更新,一个事务查询同一条数据应该是像Mysql.Oracle不 ...
- 13 Python面向对象编程:装饰器
本篇是 Python 系列教程第 13 篇,更多内容敬请访问我的 Python 合集 Python 装饰器是一种强大的工具,用于修改或增强函数或方法的行为,而无需更改其源代码.装饰器本质上是一个接收函 ...
- manim边学边做--角度标记
manim中绘制一个角度其实就是绘制两条直线,本篇介绍的不是绘制角度,而是绘制角度标记. 对于锐角和钝角,角度标记是一个弧,弧的度数与角的度数一样: 对于直角,角度标记是一个垂直的拐角. manim中 ...
- Vue.js 异步组件传参
本文主要展示一下如何给异步组件进行参数传递: 通过 h 函数就可以啦 versions: vue@3.2.13 子组件 Async.vue <template> <div> & ...
- JavaScript中class的静态属性和静态方法
我们可以把一个方法赋值给类的函数本身,而不是赋给它的 "prototype" .这样的方法被称为 静态的(static). 例如这样: class Animal { static ...
- CSS – border-radius (Rounded Corners)
前言 之前的文章 CSS – W3Schools 学习笔记 (3), 这篇独立出来写, 作为整理. 参考: Youtube – Advanced CSS Border-Radius Tutorial ...
- Asp.net core 学习笔记之 globalization & localization 复习篇
更新: 2022-03-22 修订版: ASP.NET Core – Globalization & Localization 更新: 2021-06-15 之前有说过, 我没有使用默认的 f ...