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

2022-05-27 09:02:48

一、總體思路

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

說實話,感覺自己去年入職時上手專案的速度是比較慢的,可能是沒有一些系統的方法論參考吧,這裡看一點,那裡看一點,很快就迷失了方向 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,老外創造概念的能力有點強啊......