昨晚是深夜撰文的阿菌,希望通过这篇文章和大家分享一下,初入职场时,如何才能快速地熟悉一个项目的代码。
说实话,感觉自己去年入职时上手项目的速度是比较慢的,可能是没有一些系统的方法论参考吧,这里看一点,那里看一点,很快就迷失了方向 T_T。
直到最近,我有机会负责一个小项目的开发,感觉自己对一个项目的构建有了更深的体会,得赶紧记录一下,否则以后就忘了。另外要着重感谢导师的指点,入职大半年,他 review 了我的每一行代码,给了我无数代码风格、结构,及工程相关的建议(虽然只能勉强吸收一丢丢皮毛 T_T)。
本文选用服务端项目为例子进行讲解,这个东西感觉触类旁通,或许对刚开始需要熟悉其他类型项目的小伙伴也能有所启发。
其实也是希望通过这个案例分析,把一个较为传统的 web 服务端项目结构梳理一遍。
阿菌先结合自己的心得分享一个参考顺序,罗列出一些事项点供同学们参考,后续我们将用一个实际的例子进行讲解:
假设我们已经了解完了项目需要处理的业务,并且已经把项目的生产、灰度、测试环境看了个遍,接下来我就和大家分享一下我个人看项目代码的思路:
也希望通过这篇文章把个人当前对一个服务端项目的理解分享给大家
比如下面这个简单后端项目目录结构:
├── README.md
├── .gitignore
├── .gitlab-ci.yml
├── app
│ ├── __init__.py
│ ├── __main__.py
│ ├── views
│ ├── services
│ ├── dao
│ ├── schemas
│ └── utils
│ ├── conf
├── misc
│ ├── Dockerfile
│ ├── app.env
│ ├── compose
│ │ └── docker-compose.yml
│ └── requirements.txt
├── tests
├── scripts
提前声明,这样的目录结构不一定规范,但是估计还是比较清晰的。
个人感觉,看项目之前,自己心中得有一个大的框架,这个是和编程语言无关的。
以上的代码结构一眼望去能非常清晰地确认三点:
gitlab
做持续集成与构建,因为有 .gitlab-ci.yml
文件Docker
部署,公司很可能有相关的容器平台,因为有 Dockerfile
文件docker-compose
文件启动容器,app.env
大概率是前开发者留给我们的环境变量配置文件以前在学校念书的时候,我对持续集成与部署的认知为零,进厂打工后才知道原来有这么有趣的工程化解决方案,这种解决思路其实能在很多传统制造业里看到影子。后来也和不同公司的小伙伴交流过 CICD 实践,发现成熟的研发体系在这一环都会做得比较好。
呃,反了,应该说很多传统工业经过多年大海淘沙留下来的工程思路,都映射到了近代互联网产业中。而互联网产业也在通过它独特的信息化浪潮,不断反哺我们的传统行业,催生了当下
互联网+产业
的繁荣景象。
我们回看上面的目录结构,首先,不管多么大的项目,都是由一行行代码堆出来的,代码的执行总得有一个开始入口,也就是入口文件,比如上面 app 目录下的 __main__.py
。
# 这里列举几行简单的示例代码:
def run_processor(args):
# 运行消息队列的消费者模块
processor.run()
def run_api(args):
# 运行 api 模块
app.run()
def arg_parser():
# 设置参数解析器的具体逻辑
# 当解析到指定 api 服务,则注册 args.func 为 run_api
# 当解析到指定 processor 服务,则注册 args.func 为 run_processor
def main():
# 设置参数解析器
parser = arg_parser()
# 解析命令行参数
args = parser.parse_args()
# 根据参数执行具体的应用
args.func(args)
if __name__ == "__main__":
# 整个程序的入口
main()
在开始入口这,我们往往能了解到本项目划分了多少个单独运行的模块。假设我们的项目既需要对外提供 api,又要处理异步任务,为了能够共用项目中的业务逻辑及元素,往往会在入口文件中对不同模块的启动进行区分。
其实每个服务类型的程序原理都是相通的,通过循环不断接收 / 拉取业务。比如 api 模块,为了方便对外提供 api,我们一般会用现成的后端框架,因为后端框架会帮助我们封装好诸如 http 协议解析、路由转发、中间拦截器等一系列方便我们开发的功能。对于现成的后端框架,一般代码逻辑看到框架启动就够了,我们会在这个过程中会看到一系列关于框架运行的配置,框架的具体使用可以看框架的官方文档。
再如 processor 消息队列处理模块,这个处理的逻辑一般是开发自己写的,这个逻辑远没有后端框架那么复杂,所以可以耐心全部看完再动手开发。如果处理消息的逻辑封装好了,我们往往只需要编写业务逻辑。
看完入口文件后,心中应该会对项目的整体运行情况有一个非常清晰的认识,接下来只要把当前项目的业务层划分弄清楚,整个项目的骨架就非常清晰了。
在看业务代码划分之前,阿菌先和大家做一个铺垫:
相信大家在初学服务端开发的时候会听过很多分层概念,比如要分视图层,业务层、数据层等等,而且大概率每个老师讲的都不一样,每个企业内部制定的研发规范可能也有所不同。
其实初学的时候,按照规范去操作是挺好的,但我们绝不能只停留在别人给我们圈定的概念里打转,我们要明白为什么有这些概念。
阿菌先举一个简单的例子,假设我们要对外提供一个添加学生信息的功能,如果我们只在一个函数里完成这个添加学生的功能,我们可以这样写(demo):
@app.post("/", ......)
async def add_student(student: StudentModel = Body(...)):
# 把学生信息存入数据库中
student = jsonable_encoder(student)
new_student = await db["students"].insert_one(student)
# 根据返回的学生 id 查询这个学生的信息
created_student = await db["students"].find_one({"_id": new_student.inserted_id})
# 把学生的信息返回给客户端
return JSONResponse(status_code=status.HTTP_201_CREATED, content=created_student)
我们可以思考一下这样写有没有什么不好的地方。
我们尝试着提出一个假设:假设我们平时还需要自己写脚本导入学生信息,但我们不希望通过 api 的方式导入数据,我们希望直接基于现有项目的数据库操作往数据库中添加信息,那这个时候我们就要写脚本了,比如脚本可以这样写:
# 把学生信息存入数据库中
student = get_student_from_somewhere()
new_student = await db["students"].insert_one(student)
# 根据返回的学生 id 查询这个学生的信息
created_student = await db["students"].find_one({"_id": new_student.inserted_id})
我们发现,其实这段逻辑和 api 中添加学生的逻辑是完全一样的,我们完全可以把这段逻辑抽取出来呀,比如封装一个类,在类中专门提供添加学生信息的方法:
class StudentService:
@classmethod
async def add_student(cls, student: StudentModel):
# 把学生信息存入数据库中
new_student = await db["students"].insert_one(student)
# 根据返回的学生 id 查询这个学生的信息
created_student = await db["students"].find_one({"_id": new_student.inserted_id})
return created_domain
有了这层封装后,我们的 api 层逻辑就可以这样写了,简单来说就是把操作数据库的逻辑交给了学生信息的代理服务,代码瞬间简洁了很多:
@app.post("/", ......)
async def add_student(student: StudentModel = Body(...)):
# 把学生信息存入数据库中
student = jsonable_encoder(student)
created_student = StudentService.add_student(student)
# 把学生的信息返回给客户端
return JSONResponse(status_code=status.HTTP_201_CREATED, content=created_student)
代码简洁了其实只是其中的一个好处,有了这个学生的代理服务,我们添加学生的脚本也能借用代理服务了,减少了写重复的代码:
# 把学生信息存入数据库中
student = get_student_from_somewhere()
created_student = StudentService.add_student(student)
瞬间我们的脚本也简洁易懂了很多。
其实,这样封装代码的好处远不止于让代码变好看,上面的代码用的是 mongo 数据库,假设有一天,我们要改成 mysql 数据库。如果我们没做这样的封装,我们就要分别改 api 和脚本中操作数据库的逻辑了,如果做了这样的封装,我们只需要在学生信息的代理服务层修改即可,工作量是会大幅减少的。
以后我们很可能还有别的服务代理层,比如班级的代理服务,可能也需要添加学生,这个时候我们就可以在服务代理层之间相互调用了。
不过咧,封装成这样还是差点意思
咱们再进一步思考一下:
假设随着业务发展,项目里的逻辑越来越多,我们不仅要对外提供增加学生的功能,还要提供查询、修改、删除等功能;更进一步,除了需要提供学生的增删改查,还要提供班级的增删该查,学校的增删改查等等。也就是说,操作数据库的地方会越来越多。
但大家会发现,我们对数据库的操作无外乎增删改查,所以其实我们可以在操作数据库这一层再添加一个代理层,把增加数据、删除数据、修改数据、查询数据等一系列操作再作一层封装,简单示例如下:
class DB:
@classmethod
def insert_one(cls, col, doc):
""" 往集合中插入一个文档 """
db = cls.get_db()
return db[col].insert_one(doc)
@classmethod
def find_one_by_id(cls, col, id):
pass
@classmethod
def update_one_by_id(cls, col, id, doc):
pass
@classmethod
def delete_one_by_id(cls, col, id):
pass
有了这层封装后,学生信息代理服务中添加学生的逻辑就可以这样写了:
class StudentService:
col = "students"
@classmethod
async def add_student(cls, student: StudentModel):
# 把学生信息存入数据库中
new_student = DB.insert_one(col=cls.col, doc=student)
# 根据返回的学生 id 查询这个学生的信息
return DB.find_one_by_id(col=cls.col, id=new_student.inserted_id)
按照这样的层级封装代码,我们的代码除了更好维护外,可读性也会大幅提升。
有了以上的铺垫,我们再次回看示例项目的代码结构
相信经过这一番讲解,我们心中对业务代码分层这个事情应该有了一个比较本质的认识,了解了代码为什么要分层后,我们目光回到项目结构,只看核心部分:
├── app
│ ├── __init__.py
│ ├── __main__.py
│ ├── views
│ ├── services
│ ├── dao
│ ├── schemas
│ └── utils
│ ├── conf
现在应该很清晰了,一看到这种目录,类似 views/apis/controllers
这种目录,大概率放的就是 api 层的逻辑,api 层会把业务交给 services
代理服务层去完成,代理服务层操作数据的逻辑大概率会写在类似 dao/dal/db
这类型的目录中。
当然,我们不排除有的工程项目直接就把数据库操作写在 api 层。但只要我们深入了解过为什么要分层,再去看一些追求简便的设计就会变得非常简单。而且我们可以从一个更高纬度的角度去思考,如果要重构这个项目,如何才能做得更好?
当然项目不只有一种,我曾经也有过写前端的经历,按我现在的理解看,前端项目(甚至其他各种各样类型的项目)一样是可以合理分层的,重用代码的优雅封装永不过时,高内聚低耦合 yyds。
除了业务分层,项目里通常还有一个 model
目录,在这个示例里叫 schemas
,其实表示的都是一样的意思,存放代码中用到的实体数据结构,比如学生的结构体,一些响应、请求的结构体等。
阿菌觉得实体数据结构设计要利用好继承关系
比如学生的基本信息类为:
class BaseStudentModel(BaseModel):
# 姓名
name: str = Field(...)
# 年龄
age: int = Field(...)
在更新学生信息的时候可以贯穿使用这个数据结构,避免传递过多的参数。
但在添加学生信息的时候,我们还需要指定一个 id 字段,这个时候就可以用继承(此处是操作 mongo 数据库的示例):
class NewStudentModel(BaseStudentModel):
id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
这样一来,我们就可以在工程中更灵活地使用实体数据结构传递参数了,也方便我们的项目基于类似 swagger 这样的工具自动生成 api 文档。
看完分层的目录后,剩下的就是一些工具类和配置类了,就这样,整个项目的轮廓就能了然于胸,剩下的就是啃具体的业务逻辑了。
最后,先在别人定义的概念下学习,然后跳出别人定义的概念去探究本质,这个算是我目前学习编程最大的心得了。其实第一步挺痛苦的,像我现在学 Kubernetes,简直要醉了,好多概念。不过好在一点都不怂,这些技术其实只是在各种计算机基础知识上不断封装组合,等我学透了再用大白话讲透它 T_T,老外创造概念的能力有点强啊…