頭圖

如何提高代碼的可讀性 學習筆記

薯條
本文整理自 taowen 師傅在滴滴內部的分享。

1.Why

對一線開發人員來說,每天工作內容大多是在已有項目的基礎上繼續堆代碼。當項目實在堆不動時就需要尋找收益來重構代碼。既然我們的大多數時間都花在坐在顯示器前讀寫代碼這件事上,那可讀性不好的代碼都是在謀殺自己or同事的生命,所以不如一開始就提煉技巧,努力寫好代碼; )

2.How

為提高代碼可讀性,先來分析代碼實際運行環境。代碼實際運行于兩個地方:cpu人腦。對于cpu,代碼優化需理解其工作機制,寫代碼時為針對cpu特性進行優化;對于人腦,我們在讀代碼時,它像解釋器一樣,一行一行運行代碼,從這個角度來說,要提高代碼的可讀性首先需要知道大腦的運行機制。

image.png

下面來看一下人腦適合做的事情和不適合做的事情:

大腦擅長做的事情

名稱圖片說明
對象識別image.png不同于機器學習看無數張貓片之后可能還是不能準確識別貓這個對象,人腦在看過幾只貓之后就可以很好的識別。
空間分解image.png人腦不需要標注,可以直觀感受到空間中的不同物體。
時序預測image.png你的第一感覺是不是這個哥們要被車撞了?
時序記憶image.png作為人類生存本能之一,我們多次走過某個地方時,人腦會對這個地方形成記憶。
類比推測image.png人腦還有類比功能,比如說這道題大多數人會選擇C吧。

大腦不擅長做的事情

名稱圖片例子
無法映射到現實生活經驗的抽象概念image.png人腦看到左圖時,會比較輕松想到通關方式,但是如果換成右圖這種抽象的概念,里面的對象換成了嘿嘿的像素,我們就不知道這是什么鬼了。比如說代碼里如果充斥著Z,X,C,V 這樣的變量名,你可能就看懵了。
冗長的偵探推理image.png這種需要遞歸(or循環)去檢查所有可能性最后找到解法的場景,人腦同樣不擅長。
跟蹤多個同時變化的過程image.png大腦是個單線程的CPU,不擅長左手畫圓,右手畫圈。

代碼優化理論

了解人腦的優缺點后,寫代碼時就可以根據人腦的特點對應改善代碼的可讀性了。這里提取出三種理論:

  1. Align Models ,匹配模型:代碼中的數據和算法模型 應和人腦中的 心智模型對應
  2. Shorten Process , 簡短處理:寫代碼時應 縮短 “福爾摩斯探案集” 的流程長度,即不要寫大段代碼
  3. Isolate Process,隔離處理:寫代碼一個流程一個流程來處理,不要同時描述多個流程的演進過程

下面通過例子詳細解釋這三種模型:

Align Models

在代碼中,模型無外乎就是數據結構算法,而在人腦中,對應的是心智模型,所謂心智模型就是人腦對于一個物體 or 一件事情的想法,我們平時說話就是心智模型的外在表現。寫代碼時應把代碼中的名詞與現實名詞對應起來,減少人腦從需求文檔到代碼的映射成本。比如對于“銀行賬戶”這個名詞,很多變量名都可以體現這個詞,比如:bankAccount、bank_account、account、BankAccount、BA、bank_acc、item、row、record、model,編碼中應統一使用和現實對象能鏈接上的變量名。

代碼命名技巧

起變量名時候取其實際含義,沒必要隨便寫個變量名然后在注釋里面偷偷用功。

// bad
var d int // elapsed time in days

// good
var elapsedTimeInDays int // 全局使用

起函數名 動詞+名詞結合,還要注意標識出你的自定義變量類型:

// bad
func getThem(theList [][]int) [][]int {
    var list1 [][]int // list1是啥,不知道
    for _, x := range theList {
        if x[0] == 4 { // 4是啥,不知道
            list1 = append(list1, x)
        }
    }
    return list1
}

// good
type Cell []int // 標識[]int作用

func (cell Cell) isFlagged() bool { // 說明4的作用
    return cell[0] == 4
}

func getFlaggedCells(gameBoard []Cell) []Cell { // 起有意義的變量名
    var flaggedCells []Cell
    for _, cell := range gameBoard {
        if cell.isFlagged() {
            flaggedCells = append(flaggedCells, cell)
        }
    }
    return flaggedCells
}
代碼分解技巧

按照空間分解(Spatial Decomposition):下面這塊代碼都是與Page相關的邏輯,仔細觀察可以根據page的空間分解代碼:

// bad
// …then…and then … and then ... // 平鋪直敘描述整個過程
func RenderPage(request *http.Request) map[string]interface{} {
    page := map[string]interface{}{}
    name := request.Form.Get("name")
    page["name"] = name
    urlPathName := strings.ToLower(name)
    urlPathName = regexp.MustCompile(`['.]`).ReplaceAllString(
        urlPathName, "")
    urlPathName = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(
        urlPathName, "-")
    urlPathName = strings.Trim(urlPathName, "-")
    page["url"] = "/biz/" + urlPathName
    page["date_created"] = time.Now().In(time.UTC)
    return page
}
// good
// 按空間分解,這樣的好處是可以集中精力到關注的功能上
var page = map[string]pageItem{
    "name":         pageName,
    "url":          pageUrl,
    "date_created": pageDateCreated,
}

type pageItem func(*http.Request) interface{}

func pageName(request *http.Request) interface{} { // name 相關過程
    return request.Form.Get("name")
}

func pageUrl(request *http.Request) interface{} { // URL 相關過程
    name := request.Form.Get("name")
    urlPathName := strings.ToLower(name)
    urlPathName = regexp.MustCompile(`['.]`).ReplaceAllString(
        urlPathName, "")
    urlPathName = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(
        urlPathName, "-")
    urlPathName = strings.Trim(urlPathName, "-")
    return "/biz/" + urlPathName
}

func pageDateCreated(request *http.Request) interface{} { // Date 相關過程
    return time.Now().In(time.UTC)
}

按照時間分解(Temporal Decomposition):下面這塊代碼把整個流程的算賬和打印賬單混寫在一起,可以按照時間順序對齊進行分解:

// bad 
func (customer *Customer) statement() string {
    totalAmount := float64(0)
    frequentRenterPoints := 0
    result := "Rental Record for " + customer.Name + "\n"

    for _, rental := range customer.rentals {
        thisAmount := float64(0)
        switch rental.PriceCode {
        case REGULAR:
            thisAmount += 2
        case New_RELEASE:
            thisAmount += rental.rent * 2
        case CHILDREN:
            thisAmount += 1.5
        }
        frequentRenterPoints += 1
        totalAmount += thisAmount
    }
    result += strconv.FormatFloat(totalAmount,'g',10,64) + "\n"
    result += strconv.Itoa(frequentRenterPoints)

    return result
}
// good 邏輯分解后的代碼
func statement(custom *Customer) string {
    bill := calcBill(custom)

    statement := bill.print()

    return statement
}

type RentalBill struct {
    rental Rental
    amount float64
}

type Bill struct {
    customer             *Customer
    rentals              []RentalBill
    totalAmount          float64
    frequentRenterPoints int
}

func calcBill(customer *Customer) Bill {

    bill := Bill{}
    for _, rental := range customer.rentals {
        rentalBill := RentalBill{
            rental: rental,
            amount: calcAmount(rental),
        }
        bill.frequentRenterPoints += calcFrequentRenterPoints(rental)
        bill.totalAmount += rentalBill.amount
        bill.rentals = append(bill.rentals, rentalBill)
    }
    return bill
}

func (bill Bill) print() string {

    result := "Rental Record for " + bill.customer.name + "(n"

    for _, rental := range bill.rentals{
        result += "\t" + rental.movie.title + "\t" +
            strconv.FormatFloat(rental.amount, 'g', 10, 64) + "\n"
    }
    

    result += "Amount owed is " +
        strconv.FormatFloat(bill.totalAmount, 'g', 10, 64) + "\n"

    result += "You earned + " +
        strconv.Itoa(bill.frequentRenterPoints) + "frequent renter points"

    return result
}

func calcAmount(rental Rental) float64 {
    thisAmount := float64(0)
    switch rental.movie.priceCode {
    case REGULAR:
        thisAmount += 2
        if rental.daysRented > 2 {
            thisAmount += (float64(rental.daysRented) - 2) * 1.5
        }
    case NEW_RELEASE:
        thisAmount += float64(rental.daysRented) * 3
    case CHILDRENS:
        thisAmount += 1.5
        if rental.daysRented > 3 {
            thisAmount += (float64(rental.daysRented) - 3) * 1.5
        }
    }
    return thisAmount
}

func calcFrequentRenterPoints(rental Rental) int {
    frequentRenterPoints := 1
    switch rental.movie.priceCode {
    case NEW_RELEASE:
        if rental.daysRented > 1 {
            frequentRenterPointst++
        }
    }
    return frequentRenterPoints
}

按層分解(Layer Decomposition):

// bad
func findSphericalClosest(lat float64, lng float64, locations []Location) *Location {
    var closest *Location
  closestDistance := math.MaxFloat64
  for _, location := range locations {
    latRad := radians(lat)
    lngRad := radians(lng)
    lng2Rad := radians(location.Lat)
    lng2Rad := radians(location.Lng)
    var dist = math.Acos(math.Sin(latRad) * math.Sin(lat2Rad) +  
                         math.Cos(latRad) * math.Cos(lat2Rad) *
                         math.Cos(lng2Rad - lngRad) 
                        )
    if dist < closestDistance {
            closest = &location
      closestDistance = dist
    }
  }
    return closet
}
// good
type Location struct {
}

type compare func(left Location, right Location) int

func min(objects []Location, compare compare) *Location {
    var min *Location
    for _, object := range objects {
        if min == nil {
            min = &object
            continue
        }
        if compare(object, *min) < 0 {
            min = &object
        }
    }
    return min
}

func findSphericalClosest(lat float64, lng float64, locations []Location) *Location {
    isCloser := func(left Location, right Location) int {
        leftDistance := rand.Int()
        rightDistance := rand.Int()
        if leftDistance < rightDistance {
            return -1
        } else {
            return 0
        }
    }
    closet := min(locations, isCloser)
    return closet
}
注釋

注釋不應重復代碼的工作。應該去解釋代碼的模型和心智模型的映射關系,應說明為什么要使用這個代碼模型,下面的例子就是反面教材:

// bad
/** the name. */
var name string
/** the version. */
var Version string
/** the info. */
var info string

// Find the Node in the given subtree, with the given name, using the given depth.
func FindNodeInSubtree(subTree *Node, name string, depth *int) *Node {
}

下面的例子是正面教材:

// Impose a reasonable limit - no human can read that much anyway
const MAX_RSS_SUBSCRIPTIONS = 1000

// Runtime is O(number_tags * average_tag_depth), 
// so watch out for badly nested inputs.
func FixBrokenHTML(HTML string) string {
    // ...
}

Shorten Process

Shorten Process的意思是要縮短人腦“編譯代碼”的流程。應該避免寫出像小白鼠走迷路一樣又長又繞的代碼。所謂又長又繞的代碼表現在,跨表達式跟蹤、跨多行函數跟蹤、跨多個成員函數跟蹤、跨多個文件跟蹤、跨多個編譯單元跟蹤,甚至是跨多個代碼倉庫跟蹤。

對應的手段可以有:引入變量、拆分函數、提早返回、縮小變量作用域,這些方法最終想達到的目的都是讓大腦喘口氣,不要一口氣跟蹤太久。同樣來看一些具體的例子:

例子

下面的代碼,多種復合條件組合在一起,你看了半天繞暈了可能也沒看出到底什么情況下為true,什么情況為false。

// bad
func (rng *Range) overlapsWith(other *Range) bool {
    return (rng.begin >= other.begin && rng.begin < other.end) ||
        (rng.end > other.begin && rng.end <= other.end) ||
        (rng.begin <= other.begin && rng.end >= other.end)
}

但是把情況進行拆解,每種條件進行單獨處理。這樣邏輯就很清晰了。

// good
func (rng *Range) overlapsWith(other *Range) bool {
    if other.end < rng.begin {
        return false // they end before we begin 
    }    
    if other.begin >= rng.end {
        return false // they begin after we end 
    }
  return true // Only possibility left: they overlap
}

再來看一個例子,一開始你寫代碼的時候,可能只有一個if ... else...,后來PM讓加一下權限控制,于是你可以開心的在if里繼續套一層if,補丁打完,開心收工,于是代碼看起來像這樣:

// bad 多層縮進的問題
func handleResult(reply *Reply, userResult int, permissionResult int) {
  if userResult == SUCCESS {
    if permissionResult != SUCCESS {
      reply.WriteErrors("error reading permissions")
        reply.Done()
        return
    }
    reply.WriteErrors("")
  } else {
    reply.WriteErrors("User Result")
  }
  reply.Done()
}

這種代碼也比較好改,一般反向寫if條件返回判否邏輯即可:

// good
func handleResult(reply *Reply, userResult int, permissionResult int) {
  defer reply.Done()
  if userResult != SUCCESS {
    reply.WriteErrors("User Result")
    return 
  }
  if permissionResult != SUCCESS {
    reply.WriteErrors("error reading permissions")
    return
  }
  reply.WriteErrors("")
}

這個例子的代碼問題比較隱晦,它的問題是所有內容都放在了MooDriver這個對象中。

// bad
type MooDriver struct {
    gradient Gradient
  splines []Spline
}
func (driver *MooDriver) drive(reason string) {
  driver.saturateGradient()
  driver.reticulateSplines()
  driver.diveForMoog(reason)
}

比較好的方法是盡可能減少全局scope,而是使用上下文變量進行傳遞。

// good 
type ExplicitDriver struct {
  
}

// 使用上下文傳遞
func (driver *MooDriver) drive(reason string) {
  gradient := driver.saturateGradient()
  splines := driver.reticulateSplines(gradient)
  driver.diveForMoog(splines, reason)
}

Isolate Process

人腦缺陷是不擅長同時跟蹤多件事情,如果”同時跟蹤“事物的多個變化過程,這不符合人腦的構造;但是如果把邏輯放在很多地方,這對大腦也不友好,因為大腦需要”東拼西湊“才能把一塊邏輯看全。所以就有了一句很經典的廢話,每個學計算機的大學生都聽過。你的代碼要做到高內聚,低耦合,這樣就牛逼了!-_-|||,但是你要問說這話的人什么叫高內聚,低耦合呢,他可能就得琢磨琢磨了,下面來通過一些例子來琢磨一下。

首先先來玄學部分,如果你的代碼寫成下面這樣,可讀性就不會很高。

image.png

一般情況下,我們可以根據業務場景努力把代碼修改成這樣:

image.png

舉幾個例子,下面這段代碼非常常見,里面version的含義是用戶端上不同的版本需要做不同的邏輯處理。

func (query *Query) doQuery() {
  if query.sdQuery != nil {
    query.sdQuery.clearResultSet()
  }
  // version 5.2 control
  if query.sd52 {
    query.sdQuery = sdLoginSession.createQuery(SDQuery.OPEN_FOR_QUERY)
  } else {
    query.sdQuery = sdSession.createQuery(SDQuery.OPEN_FOR_QUERY)
  }
  query.executeQuery()
}

這段代碼的問題是由于版本差異多塊代碼流程邏輯Merge在了一起,造成邏輯中間有分叉現象。處理起來也很簡單,封裝一個adapter,把版本邏輯抽出一個interface,然后根據版本實現具體的邏輯。

再來看個例子,下面代碼中根據expiry和maturity這樣的產品邏輯不同 也會造成分叉現象,所以你的代碼會寫成這樣:

// bad
type Loan struct {
    start    time.Time
    expiry   *time.Time
    maturity *time.Time
    rating   int
}

func (loan *Loan) duration() float64 {
    if loan.expiry == nil {
        return float64(loan.maturity.Unix()-loan.start.Unix()) / 365 * 24 * float64(time.Hour)
    } else if loan.maturity == nil {
        return float64(loan.expiry.Unix()-loan.start.Unix()) / 365 * 24 * float64(time.Hour)
    }
    toExpiry := float64(loan.expiry.Unix() - loan.start.Unix())
    fromExpiryToMaturity := float64(loan.maturity.Unix() - loan.expiry.Unix())
    revolverDuration := toExpiry / 365 * 24 * float64(time.Hour)
    termDuration := fromExpiryToMaturity / 365 * 24 * float64(time.Hour)
    return revolverDuration + termDuration
}

func (loan *Loan) unusedPercentage() float64 {
    if loan.expiry != nil && loan.maturity != nil {
        if loan.rating > 4 {
            return 0.95
        } else {
            return 0.50
        }
    } else if loan.maturity != nil {
        return 1
    } else if loan.expiry != nil {
        if loan.rating > 4 {
            return 0.75
        } else {
            return 0.25
        }
    }
    panic("invalid loan")
}

解決多種產品邏輯的最佳實踐是Strategy pattern,代碼入下圖,根據產品類型創建出不同的策略接口,然后分別實現duration和unusedPercentage這兩個方法即可。

// good
type LoanApplication struct {
    expiry   *time.Time
    maturity *time.Time
}

type CapitalStrategy interface {
    duration() float64
    unusedPercentage() float64
}

func createLoanStrategy(loanApplication LoanApplication) CapitalStrategy {
    if loanApplication.expiry != nil && loanApplication.maturity != nil {
        return createRCTL(loanApplication)
    }
    if loanApplication.expiry != nil {
        return createRevolver(loanApplication)
    }
    if loanApplication.maturity != nil {
        return createTermLoan
    }
    panic("invalid loan application")
}

但是現實情況沒有這么簡單,因為不同事物在你眼中就是多進程多線程運行的,比如上面產品邏輯的例子,雖然通過一些設計模式把執行的邏輯隔離到了不同地方,但是代碼中只要含有多種產品,代碼在執行時還是會有一個產品選擇的過程。邏輯發生在同一時間、同一空間,所以“自然而然”就需要寫在了一起:

  • 功能展示時,由于需要展示多種信息,會造成 concurrent process
  • 寫代碼時,業務包括功能性和非功能性需求,也包括正常邏輯和異常邏輯處理
  • 考慮運行效率時,為提高效率我們會考慮異步I/O、多線程/協程
  • 考慮流程復用時,由于版本差異和產品策略也會造成merged concurrent process

對于多種功能雜糅在一起,比如上面的RenderPage函數,對應解法為不要把所有事情合在一起搞,把單塊功能內聚,整體再耦合成為一個單元。

對于多個同步進行的I/O操作,可以通過協程把揉在一起的過程分開來:

// bad 兩個I/O寫到一起了
func sendToPlatforms() {
    httpSend("bloomberg", func(err error) {
        if err == nil {
            increaseCounter("bloomberg_sent", func(err error) {
                if err != nil {
                    log("failed to record counter", err)
                }
            })
        } else {
            log("failed to send to bloom berg", err)
        }
    })
    ftpSend("reuters", func(err error) {
        if err == DIRECTORY_NOT_FOUND {
            httpSend("reuterHelp", err)
        }
    })
}

對于這種并發的I/O場景,最佳解法就是給每個功能各自寫一個計算函數,代碼真正運行的時候是”同時“在運行,但是代碼中是分開的。

//good 協程寫法
func sendToPlatforms() {
    go sendToBloomberg()
    go sendToReuters()
}

func sendToBloomberg() {
    err := httpSend("bloomberg")
    if err != nil {
        log("failed to send to bloom berg", err)
        return
    }
    err := increaseCounter("bloomberg_sent")
    if err != nil {
        log("failed to record counter", err)
    }
}

func sendToReuters() {
    err := ftpSend("reuters")
    if err == nil {
        httpSend("reutersHelp", err)
    }
}

有時,邏輯必須要合并到一個Process里面,比如在買賣商品時必須要對參數做邏輯檢查:

// bad
func buyProduct(req *http.Request) error {
    err := checkAuth(req)
    if err != nil {
        return err
    }
    // ...
}

func sellProduct(req *http.Request) error {
    err := checkAuth(req)
    if err != nil {
        return err
    }
    // ...
}

這種頭部有公共邏輯經典解法是寫個Decorator單獨處理權限校驗邏輯,然后wrapper一下正式邏輯即可:

// good 裝飾器寫法
func init() {
    buyProduct = checkAuthDecorator(buyProduct)
    sellProduct = checkAuthDecorator(sellProduct)
}

func checkAuthDecorator(f func(req *http.Request) error) func(req *http.Request) error {
    return func(req *http.Request) error {
        err := checkAuth(req)
        if err != nil {
            return err
        }
        return f(req)
    }
}

var buyProduct = func(req *http.Request) error {
    // ...
}

var sellProduct = func(req *http.Request) error {
    // ...
}

此時你的代碼會想這樣:

image.png

當然公共邏輯不僅僅存在于頭部,仔細思考一下所謂的strategy、Template pattern,他們是在邏輯的其他地方去做這樣的邏輯處理。

image.png

這塊有一個新的概念叫:信噪比。信噪比是一個相對概念,信息,對有用的;噪音,對沒用的。代碼應把什么邏輯寫在一起,不僅取決于讀者是誰,還取決于這個讀者當時希望完成什么目標。

比如下面這段C++和Python代碼:

void sendMessage(const Message &msg) const {...}
![image.png](/img/bVcOMhy)
def sendMessage(msg):

如果你現在要做業務開發,你可能會覺得Python代碼讀起來很簡潔;但是如果你現在要做一些性能優化的工作,C++代碼顯然能給你帶來更多信息。

再比如下面這段代碼,從業務邏輯上講,這段開發看起來非常清晰,就是去遍歷書本獲取Publisher。

for _, book := range books {
  book.getPublisher()
}

但是如果你看了線上打了如下的SQL日志,你懵逼了,心想這個OOM真**,真就是一行一行執行SQL,這行代碼可能會引起DB報警,讓你的DBA同事半夜起來修DB。

SELECT * FROM Pubisher WHERE PublisherId = book.publisher_id
SELECT * FROM Pubisher WHERE PublisherId = book.publisher_id
SELECT * FROM Pubisher WHERE PublisherId = book.publisher_id
SELECT * FROM Pubisher WHERE PublisherId = book.publisher_id
SELECT * FROM Pubisher WHERE PublisherId = book.publisher_id

所以如果代碼改成這樣,你可能就會更加明白這塊代碼其實是在循環調用實體。

for _, book := range books {
  loadEntity("publisher", book.publisher_id)
}

總結一下:

  • 優先嘗試給每 個Process一個自己的函數,不要合并到一起來算

    • 嘗試界面拆成組件
    • 嘗試把訂單拆成多個單據,獨立跟蹤多個流程
    • 嘗試用協程而不是回調來表達concurrent i/o
  • 如果不得不在一個Process中處理多個相對獨立的事情

    • 嘗試復制一份代碼,而不是復用同一個Process
    • 嘗試顯式插入: state/ adapter/ strategy/template/ visitor/ observer
    • 嘗試隱式插入: decorator/aop
    • 提高信噪比是相對于具體目標的,提高了一個目標的信噪比,就降低了另外一個目標的信噪比

3.總結

當我們吐槽這塊代碼可讀性太差時,不要把可讀性差的原因簡單歸結為注釋不夠 或者不OO,而是可以從人腦特性出發,根據下面的圖片去找到代碼問題,然后試著改進它(跑了幾年的老代碼還是算了,別改一行線上全炸了: )

image.png

閱讀 3.6k

個人微信公眾號:「薯條的自我修養」,歡迎關注

75 聲望
1.1k 粉絲
0 條評論

個人微信公眾號:「薯條的自我修養」,歡迎關注

75 聲望
1.1k 粉絲
宣傳欄
一本到在线是免费观看_亚洲2020天天堂在线观看_国产欧美亚洲精品第一页_最好看的2018中文字幕 <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <文本链> <文本链> <文本链> <文本链> <文本链> <文本链>