如何写出可维护的代码

在现代软件开发的世界里,可维护性不仅是代码质量的标志,更是项目成功的关键。想象一下,一段精心编写的代码,它能够在不断变化的需求和技术前景中稳如泰山,这不仅减少了维护成本,也极大提高了软件的生命周期。而当代码运行在生产环境中,面临着不断的挑战和意外情况时,可维护性也显得尤为重要。一段易于理解和修改的代码,可以让开发者在面对紧急问题,如性能瓶颈、安全漏洞或突发故障时,迅速定位并解决问题。这不仅提升了应对紧急情况的效率,也确保了系统的稳定性和用户体验的连续性。

本文整理出了编写可维护性代码的20个建议,希望在日后的工作中能够给予自己和大家一些帮助。

建议一:提前判断空值

对于参数,提前判断空值总是一个明智的做法。能够让方法在后续的执行过程中避免出现各种幺蛾子。不同语言对于空值的定义都有些不同,如null、nil、None等等,像go的话还有零值。不管怎样,根据自身语言的特点,提前针对空值的情况做好处理总不是坏事。

另外在方法也需要返回空值的场景时,返回一个空对象会显得更加合理,例如是List就返回一个空List,而不是返回nil,尤其是返回给前端的时候。

func QueryUserList(userID int)(error, []Users) {
    if userID == 0 {
        return errors.New("userID should not empty"), nil
    }

    users, err := service.QueryUsersFromDB(userID)
    // 如果没有查到用户列表不要返回错误,可以返回空列表,go的话可以直接返回切片的零值nil
    // 其他语言如python可以返回空数组[]
    if err != nil { 
        if err == EmptyUsersErr {
            return nil, nil
        }
        return err, nil
    }

    return users, nil
}

建议二:保持变量不可变

尽量让变量不可变,不可变的变量可以避免被意外的篡改,尤其是并发编程的场景下,使用不可变变量可以让编译器或者运行环境提前知道变量的状态可以运行的更有效率。

在go语言可以使用const来声明不可变变量。

const str = "foo"

建议三:使用类型提示和静态类型检查器

动态语言能够让我们快速的写出很多代码,但缺少类型检查和类型提示,很容易引发潜在的bug。配合使用一些类型提示和检查的工具能帮助避免写出含糊不清的代码。而现在很多动态语言也对类型提示和静态类型检查增加了强大的支持,如写Javascript的可以通过Typescript来实现类似静态语言的编程能力,而python也从3.5开始支持了定义变量类型和函数返回值类型的写法。

def foo(param: str) -> str:
    return "bar"

建议四:验证输入

永远记住:不要相信客户端传给你的任何输入参数。即使不是黑客恶意攻击,也可能来自测试人员的有意给出的存在恶意的测试输入数据。一定要对参数做合法性校验,例如邮箱参数就验证是否满足邮箱的规则,密码参数是否符合字符要求,IP地址参数是否是一个有效的IP地址,字符串是否包含不合法的特殊字符等等。

一种合理的做法是,最大限度的限制参数的取值范围,可以使用一些开源的校验库来实现,例如go语言有 go-validator。还有字符串前后的空格也记得处理。

建议五:不要返回奇怪的异常

笔者经常在一些项目中看到在需要返回一些特殊错误或者异常的时候返回了像-1、0这样模棱两可的信息。这种错误在对于后续维护的人来说将会是个灾难,它无法理解返回-1是个什么场景,也清楚是否需要处理,有时因为方法没有返回error错误,当成了正常情况来处理,甚至不处理,进而埋下了潜在的bug。如果进而还有-2、-3、-4等等值,那就更加混乱了,光是记住这些返回值都是一个维护成本。

记住:不要使用特殊的返回值来标识错误类型(如null、0、-1)。现代的编程语言都支持了异常或有标准异常处理的模式,例如Go语言就有error类型,甚至可以自定义error。

var NumZeroErr = errors.New("number should not be 0")

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, NumZeroErr
    }
    return a/b
}

建议六:尽可能的复用已有的异常

大多数语言和类库都会内置一些异常类型,如空指针异常(NullPointerException),文件没有找到的异常(FileNotFoundException),如果内置的异常已经能够满足需要,就不要再创建自定义的异常。而当你需要自定义异常时,也不要把他们定义的太通用,因为开发人员并不知道他们正面临的是什么样的具体问题。所以尽可能的描述清楚你定义的是什么异常类型,可以极大的帮助开发排查问题。

有使用gorm的同学,应该就知道这个开源库定义了很多异常类型。

// https://github.com/go-gorm/gorm/blob/master/errors.go
var (
    // ErrRecordNotFound record not found error
    ErrRecordNotFound = logger.ErrRecordNotFound
    // ErrInvalidTransaction invalid transaction when you are trying to `Commit` or `Rollback`
    ErrInvalidTransaction = errors.New("invalid transaction")
    // ErrNotImplemented not implemented
    ErrNotImplemented = errors.New("not implemented")
    // ErrMissingWhereClause missing where clause
    ErrMissingWhereClause = errors.New("WHERE conditions required")
    // ErrUnsupportedRelation unsupported relations
    ErrUnsupportedRelation = errors.New("unsupported relations")
    ...
)

建议七:早抛晚捕

处理异常要遵循“早抛晚捕”。也就是说在尽可能接近错误的地方引发异常,这样才能方便开发人员迅速定位异常代码。要极力避免应该引发异常时却延迟抛出,导致后续的代码继续执行,进而可能导致更多的异常,此时接二连三的错误会将异常混淆在一起,导致更难定位问题。

而至于“晚捕”指的就是尽可能的晚捕获异常,让异常尽可能的传递到程序定义的处理异常的层级。过早的捕获并处理异常可能会导致上游调用方无法识别到这个异常而继续进行后续的执行流程。我们写Web服务的时候通常会定义一个errorHander的中间件来统一捕获和处理异常。

当然这也是因功能而议,如果实现的是文档异步保存可能我们就需要提前处理异常。

建议八:合理重试

有时在遇到错误时,重试不失为一种有效的措施,尤其是在发起网络调用的时候。但是重试也是有原则的,如果你遇到错误则立即重试,很有可能导致再次失败,例如网络的异常到恢复可能就需要几十毫秒,盲目的重试可能会导致没有等到网络恢复的时刻。

合理的做法应该使用一种叫做“退避”的重试策略(retry backoff strategies),简单来说就是让程序休眠一段时间再进行重试,至于休眠多久则依据退避策略来决定。常见的退避策略有:

  • 固定间隔重试(Fixed Interval Retry)

    • 这是最简单的重试策略,就是每次重试的时间都是固定的,例如重试3次,每次间隔20毫秒。
    • 如果失败的原因是系统过载,这种方法可能会加剧问题。
  • 线性退避(Linear Backoff)

    • 每次重试间隔的时间都线性增加,如第一次是10毫秒,第二次是20毫秒,第三次是30毫秒。
    • 比固定间隔稍好,但是在遇到高延迟或高错误率的情况下仍可能不够高效。
  • 指数退避(Exponential Backoff)。

    • 每次重试间隔的时间都呈指数级增长,通常是平方级别增长(retry number)^2,例如第一次是2^0,即1秒,第二次是2^1,即2秒,第三次是2^2,即4秒,以此类推后续为8秒、16秒等。
    • 这是最常用的退避策略,尤其适用于高负载环境下,有效减少对系统和网络的冲击,更能提高成功的可能性。
  • 指数退避加抖动(Exponential Backoff with Jitter)

    • 在指数退避的基础上再加入一些随机数值(抖动值)来进一步的分散重试请求。
    • 这种方式可以有效的避免多个客户端同时重试而造成的系统冲击(即惊群效应)。

go语言可以使用 go-retry 这个开源库,它还实现了发波那契方式的重试策略

package main

import (
  "context"
  "database/sql"
  "log"
  "time"

  "github.com/sethvargo/go-retry"
)

func main() {
  db, err := sql.Open("mysql", "...")
  if err != nil {
    log.Fatal(err)
  }

  ctx := context.Background()
  if err := retry.Fibonacci(ctx, 1*time.Second, func(ctx context.Context) error {
    if err := db.PingContext(ctx); err != nil {
      // This marks the error as retryable
      return retry.RetryableError(err)
    }
    return nil
  }); err != nil {
    log.Fatal(err)
  }
}

建议九:构建幂等系统

当一个系统需要支持数据写入时,写入是否具有幂等性将非常重要。你不会期望一个付款功能,因为出现网络异常,发起重试后导致被扣了两次款。具有幂等性的系统,无论发起多少次调用产生的结果应该都是相同的。

通常我们可以让客户端每次请求都提供一个唯一的id,当发起重试时如果系统已经成功处理了这个请求,那么拿到这个id后便会知道请求已经处理了,可以拒绝处理这个请求。

乐观锁也是在数据库方面实现幂等操作的有效方法。可以在数据库的表中增加一个版本字段(如version),用来追踪每条记录的修改次数。当需要更新数据时,将版本号作为更新条件之一,即只有当版本号与请求提供的版本号相匹配时,才执行更新。更新数据的同时版本号加一。可以通过数据库返回的更新行数是否为0来判断是否更新成功,为0则代表该版本已经被更新过了,可以选择返回错误。

UPDATE account SET balance = balance + 100, version = version + 1 WHERE account_id=1 AND version=[读取到的版本号];

建议十:及时释放资源

当异常发生后,要确保占用的资源得到清理,例如内存、网络套接字、文件句柄这些都是应该及时清理的资源,避免资源泄漏。如果没有清理,像网络套接字在每个系统都有固定数量的限制,没有释放就意味着系统最终会耗尽所有套接字,无法再处理请求。

例如下面的代码就存在很大的泄漏风险:

f, err = open("file.txt")
if err != nil {
    return err
}
... // 其他业务逻辑
f.Close() // 文件句柄泄漏风险点

合理的做法应该是借助defer语句实现函数退出时关闭句柄:

f, err = open("file.txt")
if err != nil {
    return err
}
defer f.Close()
... // 其他业务逻辑

建议十一:为日志分级

给系统选择一个合适的日志框架,并对日志设置一定的分级,当所有处于该级别或高于该级别的日志都将会被发出来,低于该级别的日志将会被忽略。日志的配置通常有全局级别的和包或者类级别的。通用的日志分级有:

  • TRACE。非常精细的日志级别,通常只会用于开发阶段,用于帮助查阅一些输入输出。

  • DEBUG。通常多用于调查产品出故障时使用,如果大量输出这个级别的日志,建议修改为TRACE。

  • INFO。一般用于常规日志的输出,不能用于输出问题和异常信息。通常都是日志框架默认的日志级别。

  • WARN。这个级别一般用于记录那些可能存在的潜在问题,例如资源将要达到上限,内存在不断上涨,网络调用延迟较高等等。看到这类日志应该引发你的关注,你需要判断是否需要作出资源调整,如果这个警告没有可操作性,就应该降级为INFO级别。

  • ERROR。这个级别的日志记录发生的异常和错误,这类日志应该尽可能的详细,以便于开发人员定位高效定位问题,除了错误的原因,还应包括堆栈信息。

  • FATAL。这个级别的日志是属于非常严重的错误了,例如系统资源泄漏导致需要重启,则应该在重启前记录FATAL日志确保问题和操作得到有效的记录。

建议十二:保证日志原子性

原子日志就是指在一行消息中包含所有相关的信息。不要去假设日志会按按照一定的顺序被看到,日志检索系统不一定会按照顺序展示。也不要依赖系统时间戳来排序。另外,日志信息中不要使用折行,日志检索系统可能会把折行后的日志当作新的日志处理。

建议十三:关注日志的性能

日志会被写到系统控制台上,也可能会写到磁盘上,并最终上传到远程的日志系统。在写入日志时,会涉及到字符串的拼接和格式化,而大量的拼接和格式化操作可能会引发系统的性能问题。通常日志框架会提供延迟字符串拼接的机制,只有到最终需要拼接写入日志的时候才执行真正的拼接操作。因此如果你的系统当前存在性能问题,可以先试着看下日志这块是否是影响的其中一个因素。

log.trace("message {}", m)

建议十四:不要记录敏感数据

日志中不应该包含用户的隐私数据,如密码、信用卡号码、电话号码等等,可以使用一些特殊字符来替换这些敏感信息。

建议十五:系统要有监控

监控能让充分掌控系统的状态,请求的时延,并发的数量,数据写入的大小等等。常见的监控指标有:

  • 计数器:测量某个事件发生的次数,可以用于统计请求的数量。

  • 仪表盘:是一个基于时间点的测量值,可以上升也可以下降,可以被用于记录当前并发的大小,内存占用的变化等等。

  • 直方图:根据事件的大小幅度分成不同的范围,每个范围都会有一个计数器,每当事件的值落入其范围时,计数器便会递增,可以用来统计请求的时延。

系统性能通常会以阈值百分比来表示,如P95、P99。如果一个系统反馈说请求时延P99为50毫秒,则说明有99%的请求都能在50毫秒内完成。

监控通常也会搭配告警,也会被用来作为进行系统扩缩容的判断依据。

建议十六:分布式链路跟踪

很多时候,发起一次请求可能会涉及到下游多个系统的调用,如何将这些调用和这一次请求映射起来便是分布式链路跟踪要做的事情。分布式链路跟踪也能让我们对各个系统的调用关系了如指掌。

通常我们会在请求调用发起方分配一个链路ID(Trace ID),并将这个ID传递到下游各个系统重,下游的系统对其他下游发起调用也需要将这个ID传递下去,最终形成完整链路调用图。

建议十七:保证配置的简洁

配置文件是很多系统必不可少的文件,不要试图把配置系统搞的复杂,理想状态应该是单一状态标准格式的静态配置文件。

通常配置文件里的配置发生变化时,需要重启服务来重新读取配置。我们也可以引入远程配置系统,在远程配置系统中来完成系统的动态配置,当配置发生变化可以推送给各个系统。当然,引入一套配置系统也增加了相应的复杂度和风险,配置系统的可靠性也将会影响本系统的稳定,因此是否需要引入配置系统,以及其对应的可靠性是否能得到保证一定要做好评估。

建议十八:记录并校验配置

在程序启动时应该立即记录所有(非敏感的)配置,可以直接在控制台或者日志文件中看到这些配置,保证能直观的判断配置是否得到正确的配置。当然在此刻也可以对配置做一次,也仅有一次的合法性校验。

建议十九:为你的配置提供默认值

确保你的这些配置都能有默认值,这对于第一次拿到你这个服务源码的开发来说将大有帮助,他们可以快速的运行你写的这个系统。例如端口没有配置,则运用8080,日志路径没有指定则采用项目当前目录等等。

建议二十:将配置视为代码

配置即代码,配置应该和代码一样接受管理,尤其是生产环境的配置。错误的配置,可能会带来不可预估的灾难。因此配置的管理也应该和代码一样,接受版本控制、变更评审、测试、构建和发布。

评论

  1. Windows Edge
    9 月前
    2024-2-29 16:23:55

    现在有chatgpt,真是太方便了

    • 博主
      ccbbp
      Windows Chrome
      8 月前
      2024-4-06 5:38:59

      是的,用来辅助整理文档和写代码都挺好的

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇