一、总体思路

昨晚是深夜撰文的阿菌,希望通过这篇文章和大家分享一下,初入职场时,如何才能快速地熟悉一个项目的代码。

说实话,感觉自己去年入职时上手项目的速度是比较慢的,可能是没有一些系统的方法论参考吧,这里看一点,那里看一点,很快就迷失了方向 T_T。

直到最近,我有机会负责一个小项目的开发,感觉自己对一个项目的构建有了更深的体会,得赶紧记录一下,否则以后就忘了。另外要着重感谢导师的指点,入职大半年,他 review 了我的每一行代码,给了我无数代码风格、结构,及工程相关的建议(虽然只能勉强吸收一丢丢皮毛 T_T)。

本文选用服务端项目为例子进行讲解,这个东西感觉触类旁通,或许对刚开始需要熟悉其他类型项目的小伙伴也能有所启发。

其实也是希望通过这个案例分析,把一个较为传统的 web 服务端项目结构梳理一遍。

阿菌先结合自己的心得分享一个参考顺序,罗列出一些事项点供同学们参考,后续我们将用一个实际的例子进行讲解:

  1. 第一步,我们要了解项目是干什么的,用于处理什么样的业务。虽然我们只是码农,但时刻保持基于业务的思考有助于提高我们对项目的整体认识。曾听一位大佬调侃,所谓当架构师,其实是在技术扎实的基础上,逐渐抬头,在技术落地与业务利益中谋求平衡。相信大家工作后也会有所体会。
  2. 第二步,我们要了解项目的部署方式。当下容器化在主流大厂是非常流行的,各种容器编排调度技术助力我们逐渐从物理机时代走向云端。作为开发者,在了解业务背景后,需要进一步了解自己项目的打包部署方式,至少要看一次自己项目的测试、灰度、生产环境。在这个过程中,我们可以重点留意一下参数的配置,毕竟绝大多数项目,都是通过配置来区分环境的。
  3. 第三步,了解公司各个办公区及机房的网络关系。现在的中、大型公司大都不止一片办公区,除了办公区,通常还有各地机房,由于国内互联网迭代发展迅猛,不少公司的网络布局是比较复杂的。新人接触项目的时候经常会出现各种连不上网的情况,这个时候往往会怀疑自己是不是哪里做错了,其实只是因为网络不通,了解清楚网络状况即可。
  4. 第四步,了解手头项目的依赖服务。大厂的项目模块划分通常比较细,自己的项目很可能会依赖不少别的项目模块,适当了解一下有助于我们开发及后续排查问题。
  5. 第五步,了解项目的代码结构。想要把项目跑起来,我们得从项目的入口文件开始看,看完启动的初始化逻辑后不要迷恋,立马把眼光切换至项目全局,根据项目的目录结构,了解项目的模块划分。在这个过程中,要顺便理清楚项目用到了什么技术,比如数据是如何存储的,用到了什么数据库?是否全是同步逻辑,异步处理的话用到了什么中间件?
  6. 第六步,搭建本地开发环境,选取合适的开发工具,配好开发用的数据库以及中间件,尝试创建一个分支,提交几行简单的代码到代码仓库,在这个过程中把一切需要配置的东西配好,从此进入开发状态。

二、具体案例分析

假设我们已经了解完了项目需要处理的业务,并且已经把项目的生产、灰度、测试环境看了个遍,接下来我就和大家分享一下我个人看项目代码的思路:

也希望通过这篇文章把个人当前对一个服务端项目的理解分享给大家

比如下面这个简单后端项目目录结构:

├── 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

提前声明,这样的目录结构不一定规范,但是估计还是比较清晰的。

个人感觉,看项目之前,自己心中得有一个大的框架,这个是和编程语言无关的。

以上的代码结构一眼望去能非常清晰地确认三点:

  1. 项目很可能基于 gitlab 做持续集成与构建,因为有 .gitlab-ci.yml 文件
  2. 项目大概率基于 Docker 部署,公司很可能有相关的容器平台,因为有 Dockerfile 文件
  3. 自己开发的时候可以使用 docker-compose 文件启动容器,app.env 大概率是前开发者留给我们的环境变量配置文件

以前在学校念书的时候,我对持续集成与部署的认知为零,进厂打工后才知道原来有这么有趣的工程化解决方案,这种解决思路其实能在很多传统制造业里看到影子。后来也和不同公司的小伙伴交流过 CICD 实践,发现成熟的研发体系在这一环都会做得比较好。

呃,反了,应该说很多传统工业经过多年大海淘沙留下来的工程思路,都映射到了近代互联网产业中。而互联网产业也在通过它独特的信息化浪潮,不断反哺我们的传统行业,催生了当下互联网+产业的繁荣景象。

1. 了解项目的启动

我们回看上面的目录结构,首先,不管多么大的项目,都是由一行行代码堆出来的,代码的执行总得有一个开始入口,也就是入口文件,比如上面 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 消息队列处理模块,这个处理的逻辑一般是开发自己写的,这个逻辑远没有后端框架那么复杂,所以可以耐心全部看完再动手开发。如果处理消息的逻辑封装好了,我们往往只需要编写业务逻辑。

看完入口文件后,心中应该会对项目的整体运行情况有一个非常清晰的认识,接下来只要把当前项目的业务层划分弄清楚,整个项目的骨架就非常清晰了。

2. 了解业务逻辑的处理划分

在看业务代码划分之前,阿菌先和大家做一个铺垫:

相信大家在初学服务端开发的时候会听过很多分层概念,比如要分视图层,业务层、数据层等等,而且大概率每个老师讲的都不一样,每个企业内部制定的研发规范可能也有所不同。

其实初学的时候,按照规范去操作是挺好的,但我们绝不能只停留在别人给我们圈定的概念里打转,我们要明白为什么有这些概念。

阿菌先举一个简单的例子,假设我们要对外提供一个添加学生信息的功能,如果我们只在一个函数里完成这个添加学生的功能,我们可以这样写(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 文档。

看完分层的目录后,剩下的就是一些工具类和配置类了,就这样,整个项目的轮廓就能了然于胸,剩下的就是啃具体的业务逻辑了。

作者:|胡涂阿菌|,原文链接: https://www.cnblogs.com/tanshaoshenghao/p/16316179.html

文章推荐

【可视化分析案例】用python分析B站Top100排行榜数据

定制ASP.NET 6.0的应用配置

vue2.x版本中computed和watch的使用入门详解-关联和区

Jwt隐藏大坑,通过源码帮你揭秘

基于surging网络组件多协议适配的平台化发展

Spring 中 @EnableXXX 注解的套路

记一次判断值是否存在遇到的神奇问题

python四个性能检测工具,包括函数的运行内存、时间等等...

资深边缘计算架构师:全面解读什么是边缘计算

5分钟站点生成神器——Docusaurus

如何在Linux系统上刷抖音

从0到1使用kubebuiler开发operator