Frodo的第一個(gè)版本已經(jīng)實(shí)現(xiàn)了,在下一個(gè)版本前,我將目前的開發(fā)思路整理成三篇文章,分別是數(shù)據(jù)篇、通信篇、異步篇。
簡(jiǎn)要系統(tǒng)分析
數(shù)據(jù)庫(kù)設(shè)計(jì)是緊跟需求來(lái)的,在我本科學(xué)UML時(shí),數(shù)據(jù)庫(kù)設(shè)計(jì)是在需求分析和系統(tǒng)分析之后,架構(gòu)設(shè)計(jì)之前的設(shè)計(jì)。但博客項(xiàng)目的需求比較簡(jiǎn)單,主要大需求:
- 內(nèi)容管理(文章、用戶、標(biāo)簽、評(píng)論、反饋、動(dòng)態(tài)的增刪改查)
- 管理員用戶的驗(yàn)證、評(píng)論人用戶的驗(yàn)證
- 小功能:邊欄組件、歸檔、分類等
再簡(jiǎn)單地做一個(gè)系統(tǒng)分析:
- 博客前臺(tái)頁(yè)面(不需要認(rèn)證,內(nèi)容展示)
- 博文內(nèi)容
- 博客作者
- 標(biāo)簽
- 訪問量
- 管理頁(yè)面(需要登錄認(rèn)證進(jìn)行內(nèi)容管理)
- 動(dòng)態(tài)頁(yè)面(需要認(rèn)證)
- 評(píng)論(訪問者需要登錄認(rèn)證)
接下來(lái)的工作就是根據(jù)功能需求設(shè)計(jì)前后臺(tái)API,一般如果你是全棧自己開發(fā)的話,API形式可以隨意些,因?yàn)楹罄m(xù)還可以靈活調(diào)整。如果需要和前端同事合作的話,你需要嚴(yán)格按照restful風(fēng)格編寫,接口的參數(shù)、命名、方法和返回體的構(gòu)造上嚴(yán)格體現(xiàn)需求。
API 格式理應(yīng)最大化地體現(xiàn)功能需求
前后臺(tái)API的形式也取決于所用技術(shù),F(xiàn)rodo前臺(tái)頁(yè)面是選擇模板渲染的,后臺(tái)是使用Vue, 那么模板就可以在頁(yè)面上編程,可以實(shí)時(shí)決定上下文,可以不事先指定。
后臺(tái)API
| url | method | params | response | info |
|---|---|---|---|---|
| api/posts | GET | limit:1 page: 頁(yè)面數(shù) with_tag |
{'items': [post.*.,], 'total': number} | 查詢Posts 需要登錄 |
| api/posts/new | POST | FormData title slug summary content is_page can_comment author_id status |
x | x |
| api/post/<post_id> | GET/PUT/DELETE | x | items.*.created_at items.*.author_id items.*.slug items.*.id items.*.title items.*.type items.*._pageview items.*.summary status items.*.can_comment items.*.author_name items.*.tags.* total |
需要登錄 |
| api/users | GET | x | {'items':[user.*.,], 'total': num} | 需要登錄 |
| api/user/new | POST | FormData active name password avatar: avatar.png |
x | 需要登錄 |
| api/user/<user_id> | GET/PUT | x | user.created_at user.avatar user.id user.active user.email user.name user.url(/user/3/) ok (true) |
需要登錄 |
| api/upload | POST/OPTIONS | x | x | na |
| api/user/search | GET | name | items.*.id items.*.name |
需要登錄 |
| api/tags | GET | x | items.*.name | 需要登錄 |
| api/user/info | GET | user (token) | user{'name', 'avartar'} | 相當(dāng)于current_user |
| api/get_url_info | POST | url | x | na |
| api/status | POST | text, url, fids = ["png", ...] | r, msg, activity.id, activity.layout, activity.n_comments, activity.n_likes, activity.created_at, activity.can_comment, activity.attachments.*.layout, activity.attachments.*.url, activity.attachments.*.title, activity.attachments.*.size |
數(shù)據(jù)庫(kù)設(shè)計(jì)
設(shè)計(jì)數(shù)據(jù)庫(kù)就是設(shè)計(jì)表、表字段、表關(guān)系。嚴(yán)格上要先繪制E-R模型圖,他金石停留在邏輯層面的關(guān)系圖。下一步根據(jù)E-R圖,結(jié)合使用的數(shù)據(jù)庫(kù)類型(關(guān)系、Nosql、KV還是圖數(shù)據(jù)庫(kù))設(shè)計(jì)表關(guān)系圖,隨后要考慮如下幾個(gè)方面:
- 數(shù)據(jù)存儲(chǔ)在哪里?
- 小型記錄數(shù)據(jù)存儲(chǔ)在mysql (查詢較快)
- 長(zhǎng)數(shù)據(jù)如「博客內(nèi)容」查詢較慢 適合存儲(chǔ)在內(nèi)存數(shù)據(jù)庫(kù)
- 分布式還是單一式存儲(chǔ)?
- 那些是高頻使用數(shù)據(jù)?
- 經(jīng)常需要做查詢的
- 需要經(jīng)常累加、統(tǒng)計(jì)計(jì)算的
- 經(jīng)常不變化的
- 持久化方案
- 數(shù)據(jù)庫(kù)如何定期備份?
- KV數(shù)據(jù)庫(kù)的過期策略、定期存儲(chǔ)策略
其實(shí)博客項(xiàng)目很多都不需要考慮,但再大的項(xiàng)目這些都需要考慮了。其實(shí)還應(yīng)該考慮的是數(shù)據(jù)庫(kù)并發(fā)訪問的問題,這涉及到鎖與同步機(jī)制,這部分我再通信 部分闡述。
思考過上述問題后,大致有如下圖形:
[圖片上傳失敗...(image-c76c8e-1592272635588)]
上圖中不同的顏色字段考慮了不同的特點(diǎn),分別是:
- 數(shù)據(jù)庫(kù)存儲(chǔ),選用mysql
- KV存儲(chǔ),選用redis
- 高頻字段項(xiàng),需要緩存選用redis或memcached
ORM類設(shè)計(jì)模式
ORM 是簡(jiǎn)化SQL操作的產(chǎn)物,python將其做的最好的就是Django框架,主要做兩件事:
- 類到數(shù)據(jù)庫(kù)表的映射(通過改造元類實(shí)現(xiàn),達(dá)到創(chuàng)建這些類時(shí)便有了
table屬性,注意不是類實(shí)例) - 提供簡(jiǎn)化的面向?qū)ο蟮膕ql操作接口
表結(jié)構(gòu)與表遷移
表結(jié)構(gòu)在類中體現(xiàn),F(xiàn)rodo使用的sqlalchemy是采用Column()類的形式。在類比較多是,建議先寫一個(gè)基類,規(guī)定共有字段,在會(huì)面還可以規(guī)定共有方法。
from sqlalchemy import Column, Integer, String, DateTime, and_, desc
@as_declarative()
class Base():
__name__: str
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()
@property
def url(self):
return f'/{self.__class__.__name__.lower()}/{self.id}/'
@property
def canonical_url(self):
pass
上述的基類就規(guī)定了表名稱為類名稱的小寫。接下來(lái)可以規(guī)定一些公共字段和方法:
id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime)
@classmethod
def to_dict(cls,
results: Union[RowProxy, List[RowProxy]]) -> Union[List[dict], dict]:
if not isinstance(results, list):
return {col: val for col, val in zip(results.keys(), results)}
list_dct = []
for row in results:
dct = {col: val for col, val in zip(row.keys(), row)}
list_dct.append(dct)
return list_dct
如id和created_at 都是公共字段,而to_dict是非常常用的序列化方法。
接下來(lái)就是單獨(dú)的表,如Post表:
class Post(BaseModel, CommentMixin, ReactMixin):
STATUSES = (
STATUS_UNPUBLISHED,
STATUS_ONLINE
) = range(2)
status = Column(SmallInteger(), default=STATUS_UNPUBLISHED)
(TYPE_ARTICLE, TYPE_PAGE) = range(2)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
title = Column(String(100), unique=True)
author_id = Column(Integer())
slug = Column(String(100))
summary = Column(String(255))
can_comment = Column(Boolean(), default=True)
type = Column(Integer(), default=TYPE_ARTICLE)
pageview = Column(Integer(), default=0)
kind = config.K_POST
這其中是Column的類屬性才是對(duì)應(yīng)到數(shù)據(jù)庫(kù)的屬性,其他的是類其他功能需要而設(shè)定的。
需要注意的Post類重寫了created_at字段, 這是規(guī)定默認(rèn)的創(chuàng)建日期。
為什么繼承的是
Basemodel,這一點(diǎn)采用了一些元類編程方法,主要原因是異步,Basemodel類的設(shè)計(jì)在「異步篇」闡述。
接下來(lái)就是遷移到數(shù)據(jù)庫(kù)了,你可以直接使用sqlalchemy的metadata.create(engine),但這不利于調(diào)試,alembic是單獨(dú)做數(shù)據(jù)庫(kù)遷移管理的。把你寫好的類都導(dǎo)入到models/__init__.py中:
from .base import Base
from .user import User, GithubUser
from .post import Post, PostTag, Tag
from .comment import Comment
from .react import ReactItem, ReactStats
from .activity import Status, Activity
在alembic的env.py文件中導(dǎo)入Base, 再規(guī)定遷移產(chǎn)生的行為。這樣后連每次修改類(增加字段、更新字段屬性等)可以使用alembic migrate來(lái)自動(dòng)遷移。
類設(shè)計(jì)模式
數(shù)據(jù)庫(kù)表建立完成,接下來(lái)就是最重要的,編寫數(shù)據(jù)類,涉及到增刪改查的基本操作和類特定的一些方法。此時(shí)從「需求」到「接口」再到「類方法」的設(shè)計(jì)需要考慮如下兩點(diǎn):
- 語(yǔ)言的特性可以帶來(lái)什么,比如Python類中的
@property,__get__、__call__等特色函數(shù)能付發(fā)揮作用? - 類設(shè)計(jì)的思考,類方法,實(shí)例方法 甚至是 虛擬方法?
- 設(shè)計(jì)模式的使用,比如Frodo使用到的Mixin模式
本篇這是從類方法的功能設(shè)計(jì)來(lái)講的,具體實(shí)現(xiàn)細(xì)節(jié)牽涉到的東西,比如負(fù)責(zé)通信的一些方法細(xì)節(jié)將在「通信篇」介紹。
接下來(lái)我們都能大致地畫一個(gè)圖:
[圖片上傳失敗...(image-767c74-1592272635589)]
上圖挑選了幾個(gè)代表性的類設(shè)計(jì),不同的顏色表示不同的設(shè)計(jì)思路,當(dāng)然了這些都是根據(jù)需求場(chǎng)景來(lái)的,這一步也可以在開發(fā)過程中不斷調(diào)整:
Classmethod: 類方法,不需要實(shí)例化的方法,因?yàn)閿?shù)據(jù)庫(kù)字段屬性都是類屬性,因此很多數(shù)據(jù)操作的方法都不需要實(shí)例化,適合設(shè)計(jì)為類方法
Property: 屬性方法,適合的場(chǎng)景多是需要頻繁訪問,但又需要數(shù)據(jù)io的情況,比如很多類都依賴作者id:
await cls.get_user_id()
await cls.user
- Cached Decorator: 需要將結(jié)果緩存在
redis的方法使用此類裝飾器,例如:
@classmethod
@cache(MC_KEY_ALL_POSTS % '{with_page}')
async def get_all(cls, with_page=True):
if with_page:
posts = await Post.async_filter(status=Post.STATUS_ONLINE)
else:
posts = await Post.async_filter(status=Post.STATUS_ONLINE,
type=Post.TYPE_ARTICLE)
return sorted(posts, key=lambda p: p['created_at'], reverse=True)
@cache的處理規(guī)則將在「通信篇」介紹。
- Cached Property: 需要將結(jié)果緩存在內(nèi)存的方法使用此類裝飾器,他的場(chǎng)景是在一個(gè)調(diào)用過程中需要反復(fù)使用的數(shù)據(jù),但獲取昂貴。
@cached_property
async def target(self) -> dict:
kls = None
if self.target_kind == config.K_POST:
kls = Post
data = await kls.cache(ident=self.target_id)
elif self.target_kind == config.K_STATUS:
kls = Status
data = await kls.cache(id=self.target_id)
if kls is None:
return
return await kls(**data).to_async_dict(**data)
例如一個(gè)請(qǐng)求中需要多次使用到await self.target 而target的獲取是十分昂貴的,此時(shí)可以存儲(chǔ)在程序內(nèi)存中。當(dāng)然了這一特性早已進(jìn)入python的標(biāo)準(zhǔn)庫(kù)functools.lri_cached, 但還沒支持異步,@cached_property是參考別人的項(xiàng)目創(chuàng)造的類裝飾器,他的實(shí)現(xiàn)在models/utils.py.
總結(jié):數(shù)據(jù)庫(kù)設(shè)計(jì)是十分重要的第一步,后續(xù)API的開發(fā)效率很大程度取決于此。而數(shù)據(jù)關(guān)系到具體的語(yǔ)言實(shí)現(xiàn)又需要綜合考慮場(chǎng)景的多種特性。
PS: 寫此文時(shí),F(xiàn)rodo下一步的打算是Golang重寫后臺(tái)API,算是把Go真正用起來(lái)。Frodo的前端我沒有全程手寫,因此添加新功能模塊有些困難,說(shuō)到底我還只是后端工程師-.-..., 不過向全棧邁出一小步也算是進(jìn)步吧~