新入職,如何快速熟悉一個項目的程式碼

一、總體思路

昨晚是深夜撰文的阿菌,希望通過這篇文章和大家分享一下,初入職場時,如何才能快速地熟悉一個項目的程式碼。

說實話,感覺自己去年入職時上手項目的速度是比較慢的,可能是沒有一些系統的方法論參考吧,這裡看一點,那裡看一點,很快就迷失了方向 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 文檔。

看完分層的目錄後,剩下的就是一些工具類和配置類了,就這樣,整個項目的輪廓就能瞭然於胸,剩下的就是啃具體的業務邏輯了。

最後,先在別人定義的概念下學習,然後跳出別人定義的概念去探究本質,這個算是我目前學習編程最大的心得了。其實第一步挺痛苦的,像我現在學 Kubernetes,簡直要醉了,好多概念。不過好在一點都不慫,這些技術其實只是在各種電腦基礎知識上不斷封裝組合,等我學透了再用大白話講透它 T_T,老外創造概念的能力有點強啊……

Tags: