Web 后端

对比 Flask,学习 FastAPI

本文假定你对 Flask 等 web framework 已经比较熟悉。不过多解释为什么要这么做,直接上干货。FastAPI 的一些优势大概有:类型检查、自动 swagger 文档、支持 asyncio、强大的依赖注入系统,我在 这个回答 里具体写过,就不展开了。

安装

注意一定要加上这个 [all], 免得后续缺什么依赖。

pip install fastapi[all]

Hello, World

# main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def hello():
    return "Hello, World!"

在命令行开启调试服务器:

uvicorn main:app --reload --port 8000

然后访问 http://localhost:8000 就可以看到 hello world 啦。更妙的是,访问 http://localhost:8000/docs 就可以看到自动生成,可以交互的 Swagger UI 文档了。

注意在上面的装饰器中,直接使用了 app.get 来定义 GET 访问的路径,其他的 post/put/delete 也都同理。Flask 在 1.x 中还必须使用 app.route,在 2.0 中才赶了上来。

同步和异步

# 同步函数
@app.get("/sync")
def hello():
    return "Hello, World!"

# 异步函数
@app.get("/async")
async def hello2():
    return "Hello, World!"

FastAPI 原生支持同时同步和异步函数,而且是完全一样的用法。使用异步函数肯定性能最好,但是有好多库,尤其是访问数据库的库用不了。使用同步函数的话,也不会把整个 Event Loop 锁死,而是会在线程池中执行。所以我个人的建议是,如果你已经习惯了写 Async Python,那么完全可以全部都写异步函数。如果你刚刚开始接触 asyncio 这一套,那么完全可以大部分都写同步函数,通过 profile 找到调用最多,或者对性能影响最大的地方,把这部分切换到异步函数。

请求参数

废话不多说,看请求参数都有哪些。

路径参数

@app.get("/{name})
def hello(name: str):
    return "Hello " + name

访问 http://127.0.0.1:8000/docs 就可以看到 OpenAPI 的文档了。

如果在文档中需要使用 Enum 的话,那么创建一个 Enum 的子类:

from enum import Enum

class ModelName(str, Enum):
   alexnet = "alexnet"
   resnet = "resnet"

@app.get("/models/{model_name}")
def get_model(model_name: ModelName):
    ...

值得注意的一点是,对于结尾的 “/”, FastAPI 不会强制要求匹配。如果你的请求和定义的不一样,那么 FastAPI 会 307 重定向到定义的那一个。比如定义了 app.get("/items/"), 而请求是 requests.get("/items"), 那么会 307 到 /items/. 但是如果部署在代理后面,重定向的地址可能不对,这个可能会引起问题,建议还是前后端统一一下。

另一个需要注意的地方,顺序是很重要的,FastAPI 按照定义的顺序来匹配路由。比如说你定义了两个路径 /users/me/users/{id}, 那么,一定要把 /users/me 放在前边,否则就会匹配到 users/{id}.

url query 参数

GET 参数是 FastAPI 和 Flask 不一样的地方。FastAPI 中统一用函数参数获取,这样也是为了更好地实现类型检查:

fake_items_db = [{"item_name": "Foo"}, {"item_name": "Bar"}, {"item_name": "Baz"}]

# GET /items/?skip=100&limit=10
@app.get("/items/")
async def read_item(skip: int = 0, limit: int = 10):
    return fake_items_db[skip : skip + limit]

对于 bool 类型,URL 中的 1/true/True/on/yes 都会被转化为 True, 其他都是 False.

http body 参数

POST 参数统一使用 json, 需要使用 PyDantic 的 BaseModel 来定义,只要参数是 BaseModel 类型的,FastAPI 就会从 body 中获取:

from pydantic import BaseModel

class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float
    tax: Optional[float] = None

@app.post("/items/")
async def create_item(item: Item):
    return item

@app.put("/items/{item_id}")
async def create_item(item_id: int, item: Item):
    return {"item_id": item_id, **item.dict()}

如果不想定义 Model 验证,只想直接读取请求,那么可以使用 Request 对象,不过 Request 对象只有 async 版本,要想在同步函数中使用,需要一点点 work-around,这算是一个小小的坑吧:

from fastapi import Request, Depends

async def get_json(req: Request):
    return await req.json()

@app.get("/", response_model=None)
def get(req=Depends(get_json)):
    print(req)

其中用得到的 Depends 是 FastAPI 中非常强大的依赖注入系统,后面有时间了再详细讲。

Request 的文档详见:https://www.starlette.io/requests/, 参考:https://github.com/tiangolo/fastapi/issues/2574

Cookie 参数

使用 Cookie 来定义一个从 Cookie 中读取的参数:

from fastapi import Cookie, FastAPI

@app.get("/items")
def read_items(ads_id: Optional[str] = Cookie(None)):
    return {"ads_id": ads_id}

这里有个坑爹的地方,限于浏览器的安全模型,在生成的 Swagger UI 中 Cookie 参数是不能使用的。这是上游 Swagger/OpenAPI 项目的 bug(https://github.com/tiangolo/fastapi/issues/880), 所以在 FastAPI 这里也无解。

一个简单的 work-around 是:如果你有生成 Cookie 的 API, 那么先调用一下生成 Cookie 的 API, 再调用需要 Cookie 认证的 API 的时候就会自动附上这个 Cookie.

Header 参数

和 Cookie 是类似的。FastAPI 会自动把 “User-Agent” 转化成 user_agent 的形式。

from fastapi import FastAPI, Header

@app.get("/items/")
async def read_items(user_agent: Optional[str] = Header(None)):
    return {"User-Agent": user_agent}

参数验证

如果需要对参数进行验证,需要使用 Query, Path 等对象来指定,分别对应 GET 参数和路径:

from fastapi import FastAPI, Query, Path

@app.get("/items")
def read_items(q: Optional[str] = Query(None, min_length=3, max_length=50, regex="^fixedquery$"))
    ...

# 如果不使用默认值的话,可以使用特殊对象 `...`
def read_items(q: Optional[str] = Query(..., max_length=50))
# 数字可以使用 lt/le 或者 gt/ge 定义
def read_items(item_id: int = Path(..., gt=0, lt=100))

响应参数

需要在 app.get 中的 response_model 参数指定响应的 BaseModel:

from pydantic import BaseModel

class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float
    tax: Optional[float] = None
    tags: List[str] = []

@app.post("/items/", response_model=Item)
async def create_item(item: Item):
    return item

请求的 body 和响应的 body 经常公用一个 Model, 有些敏感信息可能不适合在响应的 body 中返回,这时候可以使用 response_model_includeresponse_model_exclude 来指定需要包含或者需要过滤出去的字段。

@app.get(
    "/items/{item_id}/name",
    response_model=Item,
    response_model_include=["name", "description"],
    response_model_exclued=["password"]
)
async def read_item_name(item_id: str):
    return items[item_id]

使用 status_code 参数可以更改返回的状态码:

@app.get("/", status_code=201)
def get():
    ...

更新 Cookie

使用 Reponse 对象的 set_cookiedelete_cookie 方法实现增删 Cookie. 注意的是,只需要对 response 对象进行操作,不需要 return response 对象。

from fastapi import FastAPI, Response

@app.post("/cookie-and-object/")
def create_cookie(response: Response):
    response.set_cookie(key="fakesession", value="fake-cookie-session-value")
    response.delete_cookie(key)
    return {"message": "Come to the dark side, we have cookies"}

产生错误

直接 raise HTTPException 就好了

from fastapi import HTTPException

@app.get("/")
def hello():
    raise HTTPException(status_code=404, detail="item not found")

使用路由模块化

FastAPI 中的 router 相当于 Flask 中的 Blueprint, 用来切分应用到不同的模块。

# views/users.py
from fastapi import APIRouter

router = APIRouter()

@router.get("/me")
def myinfo():
    ...

# main.py
from fastapi import FastAPI
from views import users

app = FastAPI()

app.include_router(users.router, prefix="/users")

就到这里吧,简单写了下基本的请求响应和 Router 的使用,这是这个系列的第一篇,敬请期待后续。最后说一句,FastAPI 的官方文档非常详尽,值得一看。不过对于已经有 web 开发知识的人来说稍显啰嗦。

访问日志

FastAPI 默认的日志似乎有一些问题,不会显示出访问日志来

参考

FastAPI 官方文档

Rails 学习笔记

因为工作的关系,需要接受一个 RoR 的项目,花一下午时间学习一下 Ruby 和 Rails. 还好在大学的时候读过关于 Ruby 的一本书,语法基本还是知道一些的。

Rails 是一个典型的 MVC 的 web 框架。

Controller 需要继承 ApplicationController 基类。对于每一个路径 http://xxx.com/my-controller/action 都对应了 app/controllers/my_controller.rb 下的 MyController 类的 action 方法,然后会渲染 app/views/my-contoller/action.html.erb 文件。即使方法是空的,也会渲染对应的 erb/haml 模板。

rails 会把 controller 中所有的类变量都传递到模板中,所以如果有需要渲染的变量,直接使用类变量就行了,而不用显式传递。

可以使用 before_action 来增加一些钩子函数,比如要求登录之类的。

目录结构

  • app/ 目录是主要的代码目录。
    • app/controllers/ 存放 controllers
    • app/views/ 存放 views, 也就是 erb 或者 haml 的模板代码
  • lib/tasks/*.rake 中存放的是可以通过 rake 调用的任务。
  • 路由表位于 config/routes.rb 文件
  • config/schedule.rb 文件存放 whenever 管理的 cron 任务。

路由

config/routes.rb 中的 DSL 可以使用不同的方法定义路由:

  • resources 定义一个典型的 REST 资源。

Ruby 语法基础

Ruby 的 Slice 和 Python 是不同的,有以下两点:

  • Ruby 使用 .. 而不是 :. string[0..8]
  • Ruby 的 slice 表示的是闭区间,而不是其他语言的前开后闭区间。

一般语言中会使用 is_valid 来表示一个布尔值,而 ruby 中习惯使用 valid?, 其中的 ? 就是一个普通的字符。

字符串的方法

len(s) -> s.length
s.replace() -> s.sub() or s.gsub()
f"hello {name}" -> hell #{name}

符号

我实在不知道符号这个东西有什么用处,string 本身不就应该是 internized 的么

ruby 算是比较有创新精神,可以使用 n.times 来表示一个循环。

5.times do
  puts "Hello, World!"
end

块还可以接收参数,使用 |

5.times do |i|
  puts "#{i}: Hello, World!"
end

在 Python 中如果你输入 import braces 那么会得到 not a chance 错误,但是在 ruby 中,我们是可以使用大括号的。😂

数组和字典

<< 可以用来 append
.sort 不会改变原数组。
还可以使用 .each + 块来遍历数组。

字典

使用字符串作为 key

prices = {"apples" => 3, "oranges" => 1, "carrots" => 12}

使用符号作为 key

{apples: 3, oranges: 1, carrots: 12}

ruby 中使用 if/elsif/else 语句,注意其中多了一个 s.

ruby 中使用 &&|| 来表示 andor.

nil 表示 None.

Ruby 中字符串是可变的

Ruby 中除了 false, nil 以外都是真的,也就是 0 "" 也是真的

面向对象

class 中使用 attr_accessor 来设置属性。方法签名中不需要使用 self 参数,函数体中也不需要使用 self 来访问属性。使用 attr_accessor 定义的属性可以认为是 public 的,而如果想要 private 的属性的话,可以使用 @var 语法,@variable 不需要声明,直接使用就行。

创建实例的话需要使用 .new 方法。MyClass.new. Ruby 中的构造函数是 initialize, 相比 __init__ 来说,太难拼写了。

class Student
  attr_accessor :first_name, :last_name, :primary_phone_number

  def introduction  # 这里没有 self
    puts "Hi, I'm #{first_name}!"  # 这里不用 self
  end
end

frank = Student.new
puts frank.first_name
frank.first_name = "Max"
frank.introduction  # 函数调用不用括号

Ruby 中的 require 使用来实现其他语言的 inlcude 或者 import 操作的,而 include 关键字是用来实现 mixin 的。

捕获异常

Ruby 中使用 begin/rescue/ensure 来表示 try/except/finally 的逻辑。有个语法糖,如果在函数中的话,可以直接以 def 未开始,而不用显式地 begin 了。

begin
  # ...
rescue
  # ...
ensure
  # this always runs
end

参考资料

  1. https://www.jianshu.com/p/99b4552b512f
  2. https://stackoverflow.com/questions/12924080/difference-between-instance-variable-and-attr-accessor
  3. https://www.ruby-lang.org/en/documentation/quickstart/
  4. https://guides.rubyonrails.org/getting_started.html
  5. https://docs.ruby-lang.org/en/2.4.0/syntax/exceptions_rdoc.html
  6. https://www.rubyguides.com/2019/02/ruby-rake/

为什么不要使用 ORM?

连外键都不用的今天,再用 ORM 还有什么意义呢?别人云亦云,认真思考下这个问题。

  • 兼容不同数据库?一般一个项目几乎是不可能更换数据库的。而开发和线上使用不同的数据库是一种非常危险的行为。
  • 比使用存储过程好?我从来没用过存储过程。
  • 减少了很多复制属性的重复工作?对于静态语言,感觉自动生成的 DAL 更好,对于动态语言,直接一个字典搞定,根本不存在这个问题。
  • 自动转换类型?这个也不应该是 ORM 的一部分,而应该是 DB API 的一部分。

总之,ORM 就是计算机科学界的越南战争泥坑(美国视角), SQL 和面向对象本来就是不 match 的,非要强行 map 起来,能舒服了才怪。

ORM 的缺点则很致命:无法控制生成的 SQL.

ORM 隐藏了 SQL 语句,使得我们没法使用 SQL 的方式思考,容易写出性能较低的代码。ORM 生成的 SQL 有的也很低效。

在我们编写 SQL 相关的程序的时候很容易犯的一个错误是 “N+1” 查询,也就是说本来应该用一个语句实现的查询,我们却使用了 N+1 个查询。

读取的数据过多。一般情况下,我们可能只需要读取一两个字段,但是 ORM 默认的确是 select *, 导致性能下降。

如果使用 ORM 的话,实际上你在学习一门新的 DSL, 而且这个 DSL 还不是很通用。这点其实是最最重要的问题了,sql 的语法是 universal 的,是到处可用的,作为一个程序员你必须也不可能绕过去。

ORM 库往往和数据库的链接池,也就是 IO 部分耦合在一起,导致在需要不同的链接管理方式的时候非常难以修改。

参考

  1. https://stackoverflow.com/questions/494816/using-an-orm-or-plain-sql
  2. https://medium.com/@mithunsasidharan/should-i-or-should-i-not-use-orm-4c3742a639ce
  3. https://stackoverflow.com/questions/448684/why-should-you-use-an-orm
  4. https://web.archive.org/web/20090528082618/http://www.cforcoding.com/2009/05/orm-or-sql.html
  5. https://blog.codinghorror.com/object-relational-mapping-is-the-vietnam-of-computer-science/

WordPress 的数据库设计

WordPress 中共有 11 个表,设计可以说是非常经典了。

wp_options

存储了 WordPress 的设置选项。

| option_id | option_name        | option_value        | autoload |
+-----------+--------------------+---------------------+----------+
|         1 | siteurl            | https://yifei.me    | yes      |
|         2 | home               | https://yifei.me    | yes      |
|         3 | blogname           | ???????             | yes      |
|         4 | blogdescription    | ???????????????     | yes      |
|         5 | users_can_register | 0                   | yes      |
|         6 | admin_email        | [email protected] | yes      |
|         7 | start_of_week      | 1                   | yes      |
|         8 | use_balanceTags    | 0                   | yes      |
|         9 | use_smilies        | 1                   | yes      |
|        10 | require_name_email | 1                   | yes      |
+-----------+--------------------+---------------------+----------+
CREATE TABLE `wp_options` (
  `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `option_name` varchar(191) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `option_value` longtext COLLATE utf8mb4_unicode_520_ci NOT NULL,
  `autoload` varchar(20) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT 'yes',
  PRIMARY KEY (`option_id`),
  UNIQUE KEY `option_name` (`option_name`),
  KEY `autoload` (`autoload`)
) ENGINE=InnoDB AUTO_INCREMENT=343925 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci

wpusers 和 wpusermeta

这两个表存储了 WordPress 的用户信息。wpusermeta 中存储了用户的各种属性信息,比如说 userid=1, metakey=firstname 指定了一号用户的 first name。

CREATE TABLE `wp_users` (
  `ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `user_login` varchar(60) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `user_pass` varchar(255) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `user_nicename` varchar(50) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `user_email` varchar(100) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `user_url` varchar(100) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `user_registered` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `user_activation_key` varchar(255) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `user_status` int(11) NOT NULL DEFAULT '0',
  `display_name` varchar(250) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  PRIMARY KEY (`ID`),
  KEY `user_login_key` (`user_login`),
  KEY `user_nicename` (`user_nicename`),
  KEY `user_email` (`user_email`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;

CREATE TABLE `wp_usermeta` (
  `umeta_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) unsigned NOT NULL DEFAULT '0',
  `meta_key` varchar(255) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL,
  `meta_value` longtext COLLATE utf8mb4_unicode_520_ci,
  PRIMARY KEY (`umeta_id`),
  KEY `user_id` (`user_id`),
  KEY `meta_key` (`meta_key`(191))
) ENGINE=InnoDB AUTO_INCREMENT=68 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci

wpposts 和 wppostmeta

这两个表可以说是 WordPress 中最核心的两个表了。wpposts 中根据 posttype 的不同,可以表达不同的意思。posttype 包括了:post, page, attachment, revision, navmenu_item 等等。

wppostmeta 和 wpusermeta 类似,都是存储拓展信息的。

CREATE TABLE `wp_posts` (
  `ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `post_author` bigint(20) unsigned NOT NULL DEFAULT '0',
  `post_date` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `post_date_gmt` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `post_content` longtext COLLATE utf8mb4_unicode_520_ci NOT NULL,  -- 正文 HTML
  `post_title` text COLLATE utf8mb4_unicode_520_ci NOT NULL, -- 标题
  `post_excerpt` text COLLATE utf8mb4_unicode_520_ci NOT NULL,
  `post_status` varchar(20) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT 'publish',
  `comment_status` varchar(20) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT 'open',
  `ping_status` varchar(20) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT 'open',
  `post_password` varchar(255) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `post_name` varchar(200) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `to_ping` text COLLATE utf8mb4_unicode_520_ci NOT NULL,
  `pinged` text COLLATE utf8mb4_unicode_520_ci NOT NULL,
  `post_modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `post_modified_gmt` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `post_content_filtered` longtext COLLATE utf8mb4_unicode_520_ci NOT NULL,  -- 插件存储的正文其他格式,可能是 Markdown 等
  `post_parent` bigint(20) unsigned NOT NULL DEFAULT '0',
  `guid` varchar(255) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `menu_order` int(11) NOT NULL DEFAULT '0',
  `post_type` varchar(20) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT 'post',
  `post_mime_type` varchar(100) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `comment_count` bigint(20) NOT NULL DEFAULT '0',  -- 这里直接存储了评论的数量,从而避免了每次都要 count group by 的开销
  PRIMARY KEY (`ID`),
  KEY `post_name` (`post_name`(191)),
  KEY `type_status_date` (`post_type`,`post_status`,`post_date`,`ID`),
  KEY `post_parent` (`post_parent`),
  KEY `post_author` (`post_author`)
) ENGINE=InnoDB AUTO_INCREMENT=874 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;

CREATE TABLE `wp_postmeta` (
  `meta_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `post_id` bigint(20) unsigned NOT NULL DEFAULT '0',
  `meta_key` varchar(255) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL,
  `meta_value` longtext COLLATE utf8mb4_unicode_520_ci,
  PRIMARY KEY (`meta_id`),
  KEY `post_id` (`post_id`),
  KEY `meta_key` (`meta_key`(191))
) ENGINE=InnoDB AUTO_INCREMENT=9847 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;

wpterms、wptermrelationships、wptermmeta 和 wptermtaxonomy

wpterms 用来存储 WordPress 中的 Category, Tag 等信息。wpterm_taxonomy 用来存储每个 term 的介绍信息(是不是有点冗余?)。

wptermrelationships 用来存储 wppost 和 wptermtaxonomy 之间的映射。(为什么不直接映射到 wpterm?)

wptermmeta 用来存储 wpterms 的其他信息,和 wp_usermeta 表结构类似。

CREATE TABLE `wp_terms` (
  `term_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(200) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `slug` varchar(200) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `term_group` bigint(10) NOT NULL DEFAULT '0',
  PRIMARY KEY (`term_id`),
  KEY `slug` (`slug`(191)),
  KEY `name` (`name`(191))
) ENGINE=InnoDB AUTO_INCREMENT=61 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci

wpcomments 和 wpcommentmeta

这两个表用来存储评论信息。

CREATE TABLE `wp_comments` (
  `comment_ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `comment_post_ID` bigint(20) unsigned NOT NULL DEFAULT '0',
  `comment_author` tinytext COLLATE utf8mb4_unicode_520_ci NOT NULL,
  `comment_author_email` varchar(100) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `comment_author_url` varchar(200) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `comment_author_IP` varchar(100) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `comment_date` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `comment_date_gmt` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `comment_content` text COLLATE utf8mb4_unicode_520_ci NOT NULL,
  `comment_karma` int(11) NOT NULL DEFAULT '0',
  `comment_approved` varchar(20) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '1',
  `comment_agent` varchar(255) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `comment_type` varchar(20) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
  `comment_parent` bigint(20) unsigned NOT NULL DEFAULT '0',
  `user_id` bigint(20) unsigned NOT NULL DEFAULT '0',
  PRIMARY KEY (`comment_ID`),
  KEY `comment_post_ID` (`comment_post_ID`),
  KEY `comment_approved_date_gmt` (`comment_approved`,`comment_date_gmt`),
  KEY `comment_date_gmt` (`comment_date_gmt`),
  KEY `comment_parent` (`comment_parent`),
  KEY `comment_author_email` (`comment_author_email`(10))
) ENGINE=InnoDB AUTO_INCREMENT=19444 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci

从以上的表结构中我们可以观察到一些特点:WordPress 中普遍使用一个主表存储核心信息,然后使用一个 meta 表来存储附加信息。
meta 实际上是把 MySQL 当做了 KV 存储来使用,存储的三个字段一般是:objectid, metakey, meta_value.
这样极大地增加了系统的灵活度,也没有引入更复杂的依赖。

其他——多站点相关表

参考

  1. https://codex.wordpress.org/Database_Description
  2. https://blogvault.net/wordpress-database-schema/
  3. https://developer.wordpress.org/themes/basics/post-types/
  4. https://wordpress.org/support/topic/why-is-postcontentfiltered-in-the-wp_posts-db-table-used/

flask 全家桶学习笔记

更新:已弃坑,转投 FastAPI。可在本站搜索相关教程。

看到标题有的同学可能就问了,flask 是一个微框架,哪儿来的全家桶啊?其实作为一个框架来说,除非你提供的只有静态页面,那么肯定要和数据库打交道的,也要有后台登录管理以及提供 API 等等一大堆常规工作要做的。当提供这些功能的时候,就需要各种全家桶组件了,那么这篇文章里介绍的就是 flask + plugins + uwsgi 等等一系列的工具。

hello world

from flask import Flask, request

app = Flask(__name__)

@app.route("/")
def hello():
    return "hello, world"

app.url_map.strict_slashes = False  # 关闭 url 结尾的 `/` 检查
app.run()

Flask 中直接使用 request 对象访问请求的数据,比如:

  • request.form, Post 的数据字典
  • request.args, GET 参数
  • request.values, 以上两个都包括
  • request.json 属性,或者 request.get_json() 函数,不过 flask 会要求请求 header 必须是 Content-Type: application/json 类型的,这个是一个坑。

基于类的视图

Flask 默认采用的是基于函数和装饰器的视图。而有时候对于一些 CRUD 的资源,还是使用类更清晰一些,Flask 也是支持的。

from flask.views import MethodView

class PostView(MethodView):
    def get(self, id):
        if id is None:
            ...  # return a list of objects
        else:
            ...  # return a single object

    def post(self):
        ...

    def put(self, id):
        ...

    def delete(self, id):
        ...

def add_resource_view(app, view, url):
    view_func = view.as_view(view.__class__.__name__)
    url = url.rstrip("/")
    app.add_url_rule(url, defaults={"id": None}, view_func=view_func, methods=["GET"])
    app.add_url_rule(url, view_func=view_func, methods=["POST"])
    app.add_url_rule(url + "/<id>", view_func=view_func, methods=["GET", "PUT", "DELETE"])

add_resource_view(app, PostView, "/post")

使用 blueprints

在 Flask 中如果需要切分应用的话,那么需要使用 Blueprints. 比如说,我们有下面这样的目录:

app.py
views/
  posts.py

两个文件的内容分别是:

# posts.py
from flask import Blueprint, render_template

posts_bp = Blueprint('posts', __name__)

@posts_bp.route("/")
def index():
    return [{"title": "hello world"}, ...]
# app.py
from views.posts import posts_bp

app = Flask(__name__)
app.register_blueprint(posts_bp, "/posts")

一般在刚开始搭建的时候,也没必要一定要分 blueprints, 等函数太多了再分也不迟。

App Factory Pattern

在前面的例子中,我们都直接在模块中 app = Flask(__name__) 了,这样做实际上是有问题的。官方推荐使用 app factory pattern。

app factory pattern 其实也很简单,就是把 app 的创建包装在了 app.py 的 create_app 函数中,然后在这个函数中创建一个 Flask 实例,并调用各个插件的 init_app 函数。

这样做的好处主要有两点:

方便多环境部署

直接导入 app 的话,已经初始化了,无法再更改 app 的配置

from example import app

如果把 app 的创建包装在一个函数中,可以在创建 app 的时候传递不同的参数,可以区分开发测试等不同环境。

def create_app(**kwargs):
    app = Flask(**kwargs)
    return app

from example import create_app
app = create_app(DB_CONN="production")

方便依赖管理

默认情况下,代码可能是这样的,所有的代码都得依赖 app.py

# app.py
app = Flask(__name__)
db = SQLAlchemy(app)

# models.py
from example import db

class User(db.Model):
    pass

使用了 app factory pattern 之后,每个模块都可以不依赖 app.py,而是使用自己的 blueprint

def create_app():
    app = Flask(__name__)
    from example.models import db
    db.init_app(app)
    return app

# models.py
db = SQLAlchemy()

class User(db.Model):
    pass

Context

一般来说,Application Context 和 Request Context 有相同的生命时间。

目录结构

app.py       # 主要是 create_app 函数
db.py        # 创建数据库链接池的单例
wsgi.py      # 服务的入口,调用 create_app 创建
views/       # 存放各种 blueprint views
  admin.py
  home.py
  users.py

参考文献

  1. https://blog.csdn.net/u010466329/article/details/78522992
  2. https://blog.csdn.net/qq_21794823/article/details/78194164
  3. http://www.manongjc.com/article/48448.html
  4. https://juejin.im/post/5964ce816fb9a06bb21abb23
  5. https://www.cnblogs.com/whitewolf/p/4686154.html
  6. 为什么要使用 APP Factory Pattern
  7. https://flask-login.readthedocs.io/en/latest/
  8. https://flasksession.readthedocs.io/en/latest/

uwsgi 的使用和性能优化配置

更新:建议使用 gunicorn

假设我们编写了如下的 flask 应用,要用 uwsgi 部署,希望性能越高越好,那么下面是一份还说得过去的配置。

from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
    return "world"

if __name__ == "__main__":
    app.run()

对应的 uwsgi 配置

[uwsgi]
wsgi-file=app.py  # 应用的主文件
callable=app  # 应用中的 flask 实例
chdir=/opt/app  # chdir 到给定目录
env= XXX=XXX  # 额外的环境变量

# 以下三者任选其一
http=0.0.0.0:5000  # 如果直接暴露 uwsgi 的话用这个
http-socekt=0.0.0.0:5001  # 如果用 nginx 反向代理的话,用这个
socket=:3031  # 在 3031 使用 uwsgi 协议,nginx 中使用 uwsgi_pass 更高效

chmod-socket = 664

pidfile=xxx  # pid 文件路径
venv=xxx  # 虚拟环境路径
logto = /var/log/www.log

# 并发设置
workers = 2  # 一般为 CPU 核数 * 2
threads = 2  # 线程比进程开销更小一点。如果没有使用 threads 那么 thread 直接不工作的,必须使用 enable_threads。
max-requests = 100000  # 处理过多少个请求后重启进程,目的是防止内存泄露
master = true  # 使用 max-requests 必须采用这个选项
listen = 65536  # 每个进程排队的请求数量,默认为 100 太小了。并发数 = procsses * threads * listen
buffer-size = 65536  # header 的 buffer 大小,默认是 4 k
thunder-lock = true  # 避免惊群效应
uid=www-data
gid=www-data
harakiri=30  # 所有进程在 30s 没有响应后傻屌
log-slow=3000  # 记录满于 3000 毫秒的请求
# lazy-apps  # 不使用 prefork,而是在需要时才启动进程

# 监控设置
stats = 127.0.0.1:9191  # 可以使用 uwsgi top 监控
python-autoreload=1  # 自动重载,开发时非常方便

# 静态文件
check-static = /var/static  # 尝试从该目录下加载静态文件
static-map = /static=/var/static  # 把对应目录映射
route = /static/(.*)\.png static:/var/www/images/pngs/$1/highres.png  # 使用高级路由模式
offload-threads = 10  # 让 uwsgi 启动额外的进程处理

参考

  1. https://blog.zengrong.net/post/2568.html
  2. https://stackoverflow.com/questions/34255044/why-use-uwsgi-max-requests-option/34255744
  3. https://blog.csdn.net/apple9005/article/details/76232852、
  4. https://mhl.xyz/Python/uwsgi.html
  5. https://stackoverflow.com/questions/34824487/when-is-thunder-lock-beneficial

使用 caddy 部署网站

caddy 支持自动的 SSL 证书获取,这个非常方便,个人站的话,没必要用 nginx 了。Caddy 2 是最新的版本,并且和 1 不太兼容,本文讨论的是 Caddy 2.

caddy 的配置可以用的自己的语法:Caddyfile(注意必须大写), 不过新版本都支持用 json 了。相比于 nginx 的功能丰富但是又显得有点复杂的配置文件来说,caddy 的配置比较少,也就比较简单。

使用 Caddy 来部署一个 PHP 应用

Caddyfile 是分区的,每个地址对应一个区,可以用大括号包围起来,还有一个全局的配置区。

# 全局配置
{
  email [email protected]
}

yifei.me {
  encode gzip
  root * /var/www/html
  php_fastcgi unix//run/php/php-fpm.sock
  file_server
}

www.yifei.me {
  redir https://yifei.me{uri}
}

安装 PHP

sudo apt -y install php-fpm php-mysql php-xml

增加一个反向代理的 Python/Node/Java 应用

假设我们现在在端口 5002 部署了一个另外一个应用,然后想通过 super-cool.yifei.me 来访问,这时候只需要在 Caddyfile 中增加如下配置就可以了。

super-cool.yifei.me {
  reverse_proxy localhost:5002
}

参考

  1. Caddy Official Docs

云时代的个人存储搭建

昨天想用 iPad 上的 GoodReader 看一本书,但是从 iCloud 同步的时候出了些问题,进度始终为零。由于国内糟糕的网络环境,这种同步失败的问题时有发生。虽然可以直接通过 WiFi 把书从电脑上传过来,但是因为偶尔需要在另一个 iPad 上查看,为了同步进度,还是最终决定还是自己搭建一套云存储设施。

ftp 与 webdav

ftp 协议有诸多问题,现在用的已经很少了。WebDav 协议基于 HTTP,相比 FTP 有不少有点,可以参见文章 1。另外不少开源的网盘客户端也支持 webdav。NextCloud 支持 webdav,后面会讲到

sftp 和 sshfs

sftp 则和 ftp 是完全独立的两个东西,虽然最终目的是一样的。好比海豚和鲨鱼都是在海里的生物,但是一个是哺乳动物,而一个是鱼类。sftp 基于 ssh 协议。

sshfs 相比 sftp 则更近了一步,通过 sftp 把远程的文件系统直接映射到本地,从而无缝衔接。

搭建

sftp 直接基于 linux 的用户和文件权限系统。

  1. 添加相应的用户和分组,以用户名 sftp,分组名 ftpaccess 为例。
% sudo groupadd ftpaccess
% sudo useradd -m sftp -g ftpaccess -s /usr/sbin/nologin
% sudo passwd sftp  # 更改密码
% sudo mkdir /var/sftp
% sudo chown root /var/sftp  # 这一步非常坑,切记不可省略,后面讲为什么
% sudo mkdir -p /var/sftp/files
% sudo chown sftp:ftpaccess /var/sftp/files
  1. 修改 /etc/ssh/sshd_config 文件

注释掉这一行 Subsystem sftp /usr/lib/openssh/sftp-server

然后在文件的结尾添加

Subsystem sftp internal-sftp
Match group ftpaccess
ChrootDirectory /var/sftp  # 这里可以随便指定你想要的顶级目录
X11Forwarding no
AllowTcpForwarding no
ForceCommand internal-sftp
PasswordAuthentication yes

ssh 的安全配置要求 ChrootDirectory 本身必须是 root 所有的,所以登录都的根目录我们是不可写的,但是可以在新建的目录中读写。

  1. 重启 ssh 服务
% sudo systemctl restart ssh

可以使用客户端链接啦~ 如果需要使用 publickey 登录的话,只需要像普通的用户一样,把文件传到 ~sftp 的对应目录就可以了。

使用 sshfs mount 到本地

% brew install sshfs
% brew cask install osxfuse
% sshfs -o allow_other,defer_permissions -o volname=sftp_files [email protected]:/files $HOME/sftp_files

nextcloud

未完待续

参考资料

  1. https://stackoverflow.com/questions/11216884/which-file-access-is-the-best-webdav-or-ftp
  2. SSHFS
  3. 搭建 sftp 服务器
  4. sftp 的一个坑

Django 中使用多个数据库

有时候我们的表并不都在一个数据库中,需要使用多个数据库,django 支持配置并使用多个数据库。

定义多个数据库

首先,在 DATABASES 中定义需要使用的多个数据库:

DATABASES = {
    "default": {},
    "users": {
        "NAME": "user_data",
        "ENGINE": "django.db.backends.mysql",
        "USER": "mysql_user",
        "PASSWORD": "superS3cret"
    },
    "customers": {
        "NAME": "customer_data",
        "ENGINE": "django.db.backends.mysql",
        "USER": "mysql_cust",
        "PASSWORD": "[email protected]"
    }
}

注意其中 default 是必须的,不过用不到的话,留空也行。

在使用 manage.py 的时候可以使用 --database=xxx 里指定数据库。

数据库路由

可以通过实现 Database Router 来让 django 自动选择应该使用的数据库。

DB router 需要实现下面四个方法,用来指定不同的 Model 对应的模型。

  1. db_for_read(model, **hints) 用来读取表时,查找对应的数据库。返回数据库配置名(DATABASES 中定义的)
  2. db_for_write(model, **hints) 用来写入表时,查找对应的数据库。
  3. allow_relation
  4. allow_migrate

最后使用 DATABASE_ROUTERS 安装对应的路由:

DATABASE_ROUERS = ["path.to.router"]

你能听懂的 OAuth2 协议简介

今天有个项目需要用到 OAuth2 来处理一些东西,然而中文互联网有时候真是很难找到像样的文档,搜索 “OAuth 教程” 的到排名前两位的 都是翻译自一个英文教程,翻译质量奇差无比就不说了,这个英文教程本身就是有问题的,无奈只好搜索 “OAuth tutorial” 才找到几个看得过去的英文教程,总结一下放在这里,算是为中文互联网引入一些正确的知识。

看到 OAuth2 这个词,一般人肯定会想,是不是还有个 OAuth 1 协议呢?是的,有 OAuth 1 协议,但是因为协议搞得太复杂了,所以没人用,市面上的基本都是根据 OAuth 2 来的。所以看到 OAuth 你就认为是 2 就行了。

为什么要使用 OAuth2

大家最熟悉的例子就是第三方登录了。假设有个论坛叫做“91 论坛”你没有注册过,也懒得填写邮箱密码注册,那么这时候可以使用第三方登录,比如 QQ 登录。那么问题来了,当你点击 “用 QQ 登录” 这个按钮的时候,论坛怎么安全地知道你的身份呢?会有下面两个问题:

  1. 如果你随便输入一个 QQ 号,然后 91 论坛就信任了,那你直接说自己 QQ 号是 10000 (麻花藤的 QQ 号)得了。
  2. 如果你提供给论坛你的 QQ 号和密码,论坛自己去找 QQ 验证一下。但是这样论坛就有了你 QQ 号的所有权限,比如偷偷在你的 QQ 空间发推广消息。

现在陷入了两难境界,论坛无法信任你自己提供 QQ 号,你也不能信任论坛拿走你的账户密码。如果这时候能让 QQ 作为中间人只提供给论坛部分信息就好了,OAuth 就是用来做这个的。

OAuth2

简单来说,方案如下:

  1. 91 论坛在 QQ 上注册一个开发者账户;
  2. 你在 QQ 上登录获得一个令牌,然后给到 91 论坛;
  3. 91 论坛利用这个令牌从 QQ 读取你的信息。

总体来说,就是这么简单,协议的细节也不用过分仔细追究,毕竟你也不会从头实现一套验证流程,而一定会用第三方的 OAuth 库。把协议过一遍,掌握协议的要领,遇到问题了再查文档也不迟。

Anyway, 在浏览器中,具体解决方案大致如下:

一、91 论坛的开发者在 QQ 处申请一个开发者账户,获得一个开发者标识,并提供了一个回调接口:

{
    'client_id': 91bbs,
    'client_secret': 123456,
    'callback': "http://91bbs.com/login_callback"
}

二、你在 91 论坛上点击用 QQ 登录,然后页面跳转到 QQ 域 (qq.com) 下,这样你可以安全的输入 QQ 密码,而不用被 91 论坛知道。用 QQ 登录对应的地址:

https://api.qq.com/v1/auth?
    response_type=code&
    client_id=91bbs&
    callback=http://91bbs.com/login_callback&
    scope=read

注意其中标识了论坛在上一步 client_id。在这个页面上可能写着你是否授权 XX 论坛访问你的个人信息等等。

  1. response_type 表示授权的类型,后面会讲到
  2. client_id 向 QQ 表明是要登录 91 论坛这个网站
  3. callback 指明了下一步 QQ 要回调 91 论坛的地址
  4. scope 指定了当前授权的权限范围

三、登录 QQ 后,点击授权通过,然后 QQ 会把你重定向到 redirect_uri 对应的页面,并附加参数 code=xxx,这个是一个临时的一次性授权码。重定向到的页面:

http://91bbs.com/login_callback&code=xxxxxx

四、访问这个页面,就会把这个 code 传递给 91 论坛,但是 91 论坛有了这个 code 还不能直接向 QQ 询问关于你的具体信息。进行下一步操作。

五、91 论坛使用这个 code 向 QQ 申请一个长期有效的 refresh_token,再使用这个 refresh token 获得一个 access token, 这样就可以获取你的 QQ 号等信息,具体获得什么信息,是在第二步的 scope 页面指定的。

POST https://api.qq.com/v1/token
    grant_type=authorization_code&
    code=AUTH_CODE_HERE&
    redirect_uri=REDIRECT_URI&
    client_id=CLIENT_ID&
    client_secret=CLIENT_SECRET

grant_type 指定了授权的类型,这里我们使用上一步获得的 authorization code 来获取 access token,所以 grant type 就是 authorization code. QQ 返回给 91 论坛的信息:

{
    "access_token":"ACCESS_TOKEN",
    "token_type":"bearer",
    "expires_in":2592000,
    "refresh_token":"REFRESH_TOKEN",
    "scope":"read",
}
// 除了 Authorization Code 这个 grant_type 以外,后面还会讲到 password grant_type

因为这个 access token 可以随时用来访问你的信息,所以设定了过期时间,这样即使泄露了攻击的时间窗口也不会很长。当 access token 过期后,还可以使用 refresh token 刷新,获得新的有效的 access token,而不需要用户再次登录。refresh token 可以没有过期时间,或者过期时间远比 access token 长,但是因为使用次数少,所以也是相对比较安全的。

POST https://cloud.digitalocean.com/v1/oauth/token?
    grant_type=refresh_token&
    client_id=CLIENT_ID&
    client_secret=CLIENT_SECRET&
    refresh_token=REFRESH_TOKEN

六、91 论坛使用 access token 访问你的信息。access token 通常是放在 Authorization 这个 header 中。比如使用 curl 来表示这个访问:

curl -H 'Authorization: Bearer 1.1Zgwxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=' \
'http://api.qq.com/v1/user/123456'

如果 token 正确无误的话,QQ 服务器会返回相应的信息。

七、论坛根据从 QQ 服务器得到的消息,从而知道你真的是 QQ 为 123456 的用户,然后为你创建账户。以后你需要登录也可以重复上面的流程,就可以证明你的身份了。

OAuth 中的术语

在上面的过程中,一共出现了四种角色:

  1. 第三方程序,也就是 91 论坛
  2. 资源所有人,也就是用户
  3. 授权服务器,也就是 QQ
  4. 资源服务器,还是 QQ

其中资源指的就是用户的 QQ 信息,而授权服务器和资源服务器在复杂的结构中往往是分开的。

  1. access_token,最常用的 token,直接用来访问获得授权的信息。特点是过期时间短,丢了也就丢了
  2. refreshtoken,当 accesstoken 过期的时候,可以用 refresh token 获取一个新的 access token。过期时间长,如果被窃取应该注销该 token。
  3. Authorization Code,第三方应用认证时使用的一次性代码

全部使用 OAuth2

假设我们要从头实现 QQ,本来我们有自己第一方(官方)App 的资源访问方式,现在还要搞个 OAuth2 实现第三方访问,岂不是要维护两套系统?聪明的你一定已经想到方案了,干嘛还搞自己的资源控制方式?把自己的 App 也视作第三方应用,直接都走 OAuth2 得了。现在的不少 App 也确实是这么搞得。

当第一方应用使用 OAuth 的时候,可以使用另一种更加简单的 grant_type:password。作为第一方,我们可以直接把用户名和密码提交上去,然后获得 access token 和 refresh token。实际上,这和传统的提交用户名和密码,然后设置一个 Cookie 的登录方式完全是一致的,只不过在这里我们使用了 OAuth 约定的一些参数名称罢了。更重要的是,你可以使用现成的 OAuth 库来实现

参考

  1. OAuth2 Simplified
  2. Introduction to OAuth2
  3. Refresh token
  4. RFC 6749