Frodo 的第一个版本已经实现了,在下一个版本前,我将目前的开发思路整理成三篇文章,分别是数据篇、通信篇、异步篇。

简要系统分析

数据库设计是紧跟需求来的,在我本科学 UML 时,数据库设计是在需求分析和系统分析之后,架构设计之前的设计。但博客项目的需求比较简单,主要大需求:

  • 内容管理(文章、用户、标签、评论、反馈、动态的增删改查)
  • 管理员用户的验证、评论人用户的验证
  • 小功能:边栏组件、归档、分类等

再简单地做一个系统分析:

  • 博客前台页面(不需要认证,内容展示)
    • 博文内容
    • 博客作者
    • 标签
    • 访问量
  • 管理页面(需要登录认证进行内容管理)
  • 动态页面(需要认证)
  • 评论(访问者需要登录认证)

接下来的工作就是根据功能需求设计前后台 API,一般如果你是全栈自己开发的话,API 形式可以随意些,因为后续还可以灵活调整。如果需要和前端同事合作的话,你需要严格按照 restful 风格编写,接口的参数、命名、方法和返回体的构造上严格体现需求。

API 格式理应最大化地体现功能需求

前后台 API 的形式也取决于所用技术,Frodo 前台页面是选择模板渲染的,后台是使用 Vue, 那么模板就可以在页面上编程,可以实时决定上下文,可以不事先指定。

后台 API

url method params response info
api/posts GET limit:1
page: 页面数
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/ 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
email
password
avatar: avatar.png
x 需要登录
api/user/ 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'} 相当于 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

数据库设计

设计数据库就是设计表、表字段、表关系。严格上要先绘制 E-R 模型图,他金石停留在逻辑层面的关系图。下一步根据 E-R 图,结合使用的数据库类型(关系、Nosql、KV 还是图数据库)设计表关系图,随后要考虑如下几个方面:

  • 数据存储在哪里?
    • 小型记录数据存储在 mysql (查询较快)
    • 长数据如「博客内容」查询较慢 适合存储在内存数据库
    • 分布式还是单一式存储?
  • 那些是高频使用数据?
    • 经常需要做查询的
    • 需要经常累加、统计计算的
    • 经常不变化的
  • 持久化方案
    • 数据库如何定期备份?
    • KV 数据库的过期策略、定期存储策略
  • 关系(外键)是否设计

其实博客项目很多都不需要考虑,但再大的项目这些都需要考虑了。其实还应该考虑的是数据库并发访问的问题,这涉及到锁与同步机制,这部分我再通信部分阐述。

思考过上述问题后,大致有如下图形:

上图中不同的颜色字段考虑了不同的特点,分别是:

  • 数据库存储,选用 mysql
  • KV 存储,选用 redis
  • 高频字段项,需要缓存选用 redis 或 memcached

图中的箭头代表类似外键的依赖关系,你可以选择

  • 数据库级实现,为表添加 restict
  • 模拟外键实现

两种方式都有优劣出,大家可以参考这篇文章 为什么数据库不推荐使用外键

ORM 类设计模式

ORM 是简化 SQL 操作的产物,python 将其做的最好的就是Django框架,主要做两件事:

  • 类到数据库表的映射 (通过改造元类实现,达到创建这些_类_时便有了 < code>table 属性,注意不是类实例)
  • 提供简化的面向对象的 sql 操作接口 ### 表结构与表迁移 表结构在类中体现,Frodo 使用的 sqlalchemy 是采用 Column () 类的形式。在类比较多是,建议先写一个 基类 ,规定共有字段,在会面还可以规定共有方法。

    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
    

上述的基类就规定了表名称为类名称的小写。接下来可以规定一些公共字段和方法:

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

idcreated_at都是公共字段,而to_dict是非常常用的序列化方法。

接下来就是单独的表,如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的类属性才是对应到数据库的属性,其他的是类其他功能需要而设定的。

需要注意的Post类重写了created_at字段,这是规定默认的创建日期。

为什么继承的是 Basemodel,这一点采用了一些元类编程方法,主要原因是异步,Basemodel 类的设计在「异步篇」阐述。

接下来就是迁移到数据库了,你可以直接使用sqlalchemymetadata.create(engine),但这不利于调试,alembic是单独做数据库迁移管理的。把你写好的类都导入到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

alembicenv.py文件中导入Base, 再规定迁移产生的行为。这样后连每次修改类(增加字段、更新字段属性等)可以使用alembic migrate来自动迁移。

类设计模式

数据库表建立完成,接下来就是最重要的,编写数据类,涉及到增删改查的基本操作和类特定的一些方法。此时从「需求」到「接口」再到「类方法」的设计需要考虑如下两点:

  • 语言的特性可以带来什么,比如 Python 类中的 @property, __get____call__ 等特色函数能付发挥作用?
  • 类设计的思考,类方法,实例方法 甚至是 虚拟方法?
  • 设计模式的使用,比如 Frodo 使用到的 Mixin 模式

本篇这是从类方法的功能设计来讲的,具体实现细节牵涉到的东西,比如负责通信的一些方法细节将在「通信篇」介绍。

接下来我们都能大致地画一个图:

上图挑选了几个代表性的类设计,不同的颜色表示不同的设计思路,当然了这些都是根据需求场景来的,这一步也可以在开发过程中不断调整:

  • Classmethod: 类方法,不需要实例化的方法,因为数据库字段属性都是类属性,因此很多数据操作的方法都不需要实例化,适合设计为类方法

  • Property: 属性方法,适合的场景多是需要频繁访问,但又需要数据 io 的情况,比如很多类都依赖作者 id:

    await cls.get_user_id()
    await cls.user
    
  • Cached Decorator: 需要将结果缓存在 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 的处理规则将在「通信篇」介绍。

  • Cached Property: 需要将结果缓存在内存的方法使用此类装饰器,他的场景是在一个调用过程中需要反复使用的数据,但获取昂贵。

    @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)
    

    例如一个请求中需要多次使用到 await self.targettarget 的获取是十分昂贵的,此时可以存储在程序内存中。当然了这一特性早已进入 python 的标准库 functools.lri_cached, 但还没支持异步,@cached_property 是参考别人的项目创造的类装饰器,他的实现在 models/utils.py.

总结:数据库设计是十分重要的第一步,后续 API 的开发效率很大程度取决于此。而数据关系到具体的语言实现又需要综合考虑场景的多种特性。

PS: 写此文时,Frodo 下一步的打算是 Golang 重写后台 API,算是把 Go 真正用起来。Frodo 的前端我没有全程手写,因此添加新功能模块有些困难,说到底我还只是后端工程师 -.-..., 不过向全栈迈出一小步也算是进步吧~