写出可测试的 Go 代码,SOLID原则,go方法和接口
写出可测试的 Go 代码
https://mp.weixin.qq.com/s/addWJ6zVj1vZgNjh3xeRQg
剔除干扰因素
假设我们现在有一个根据时间判断报警信息发送速率的模块,白天工作时间允许大量发送报警信息,而晚上则减小发送速率,凌晨不允许发送报警短信。
// judgeRate 报警速率决策函数
func judgeRate() int {
now := time.Now()
switch hour := now.Hour(); {
case hour >= 8 && hour < 20:
return 10
case hour >= 20 && hour <= 23:
return 1
}
return -1
}
函数现在隐式包含了一个不确定因素——时间
(当然可以使用打桩工具对time.Now
进行打桩,但那不是本文要强调的重点)。
// judgeRateByTime 报警速率决策函数
func judgeRateByTime(now time.Time) int {
switch hour := now.Hour(); {
case hour >= 8 && hour < 20:
return 10
case hour >= 20 && hour <= 23:
return 1
}
return -1
}
接口抽象进行解耦
假设我们实现了一个获取店铺客单价的需求,它完成的功能就像下面的示例函数。
// GetAveragePricePerStore 每家店的人均价
func GetAveragePricePerStore(storeName string) (int64, error) {
res, err := http.Get("https://liwenzhou.com/api/orders?storeName=" + storeName)
if err != nil {
return 0, err
}
defer res.Body.Close()
var orders []Order
if err := json.NewDecoder(res.Body).Decode(&orders); err != nil {
return 0, err
}
if len(orders) == 0 {
return 0, nil
}
var (
p int64
n int64
)
for _, order := range orders {
p += order.Price
n += order.Num
}
return p / n, nil
}
在之前的章节中我们介绍了如何为上面的代码编写单元测试,但是我们如何避免每次单元测试时都发起真实的HTTP请求呢?亦或者后续我们改变了获取数据的方式(直接读取缓存或改为RPC调用)这个函数该怎么兼容呢?
我们将函数中获取数据的部分抽象为接口类型来优化我们的程序,使其支持模块化的数据源配置。
// OrderInfoGetter 订单信息提供者
type OrderInfoGetter interface {
GetOrders(string) ([]Order, error)
}
然后定义一个API类型,它拥有一个通过HTTP请求获取订单数据的GetOrders
方法,正好实现OrderInfoGetter
接口。
// HttpApi HTTP API类型
type HttpApi struct{}
// GetOrders 通过HTTP请求获取订单数据的方法
func (a HttpApi) GetOrders(storeName string) ([]Order, error) {
res, err := http.Get("https://liwenzhou.com/api/orders?storeName=" + storeName)
if err != nil {
return nil, err
}
defer res.Body.Close()
var orders []Order
if err := json.NewDecoder(res.Body).Decode(&orders); err != nil {
return nil, err
}
return orders, nil
}
将原来的 GetAveragePricePerStore
函数修改为以下实现。
// GetAveragePricePerStore 每家店的人均价
func GetAveragePricePerStore(getter OrderInfoGetter, storeName string) (int64, error) {
orders, err := getter.GetOrders(storeName)
if err != nil {
return 0, err
}
if len(orders) == 0 {
return 0, nil
}
var (
p int64
n int64
)
for _, order := range orders {
p += order.Price
n += order.Num
}
return p / n, nil
}
经过这番改动之后,我们的代码就能很容易地写出单元测试代码。例如,对于不方便直接请求的HTTP API, 我们就可以进行 mock 测试。
依赖注入代替隐式依赖
我们可能经常会看到类似下面的代码,在应用程序中使用全局变量的方式引入日志库或数据库连接实例等。
package main
import (
"github.com/sirupsen/logrus"
)
var log = logrus.New()
type App struct{}
func (a *App) Start() {
log.Info("app start ...")
}
func (a *app) Start() {
a.Logger.Info("app start ...")
// ...
}
func main() {
app := &App{}
app.Start()
}
我们应该将依赖项解耦出来,并且将依赖注入到我们的 App 实例中,而不是在其内部隐式调用全局变量。
type App struct {
Logger
}
func (a *App) Start() {
a.Logger.Info("app start ...")
// ...
}
// NewApp 构造函数,将依赖项注入
func NewApp(lg Logger) *App {
return &App{
Logger: lg, // 使用传入的依赖项完成初始化
}
}
上面的代码就很容易 mock log实例,完成单元测试。
依赖注入就是指在创建组件(Go 中的 struct)的时候接收它的依赖项,而不是它的初始化代码中引用外部或自行创建依赖项。
// Config 配置项结构体
type Config struct {
// ...
}
// LoadConfFromFile 从配置文件中加载配置
func LoadConfFromFile(filename string) *Config {
return &Config{}
}
// Server server 程序
type Server struct {
Config *Config
}
// NewServer Server 构造函数
func NewServer() *Server {
return &Server{
// 隐式创建依赖项
Config: LoadConfFromFile("./config.toml"),
}
}
上面的代码片段中就通过在构造函数中隐式创建依赖项,这样的代码强耦合、不易扩展,也不容易编写单元测试。我们完全可以通过使用依赖注入的方式,将构造函数中的依赖作为参数传递给构造函数。
// NewServer Server 构造函数
func NewServer(conf *Config) *Server {
return &Server{
// 隐式创建依赖项
Config: conf,
}
}
不要隐式引用外部依赖(全局变量、隐式输入等),而是通过依赖注入的方式引入依赖。经过这样的修改之后,构造函数NewServer
的依赖项就很清晰,同时也方便我们编写 mock 测试代码。
使用依赖注入的方式能够让我们的代码看起来更清晰,但是过多的构造函数也会让主函数的代码迅速膨胀,好在Go 语言提供了一些依赖注入工具(例如 wire ,可以帮助我们更好的管理依赖注入的代码。
SOLID原则
最后我们补充一个程序设计的SOLID
原则,我们在程序设计时践行以下几个原则会帮助我们写出可测试的代码。
首字母 | 指代 | 概念 |
---|---|---|
S | 单一职责原则 | 每个类都应该只有一个职责。 |
O | 开闭原则 | 一个软件实体,如类、模块和函数应该对扩展开放,对修改关闭。 |
L | 里式替换原则 | 认为“程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换的”的概念。 |
I | 接口隔离原则 | 许多特定于客户端的接口优于一个通用接口。 |
D | 依赖反转原则 | 应该依赖抽象,而不是某个具体示例。 |
有时候在写代码之前多考虑一下代码的设计是否符合上述原则。
go方法和接口
方法
方法与对象绑定,简单的来讲只是将对象传递给函数使其成为一种特殊(只属于该对象)的函数,因为Golang
是没有类
这个概念(在Golang
里,结构体
是类
的简化版),所以也可以将方法理解为类的成员函数
,但需要注意的是,在Golang
里几乎所有数据类型都可以与方法绑定。
指针或者值作为绑定对象的区别
指针和值都可以绑定方法,并且我们不需要手动区分,这是因为Golang
会自动解引用。
只读对象的内部变量
指针和值是没有区别的,下面的代码分别使用了值和指针绑定:
func (t *Test1) Sum() int {
return t.aaa + t.bbb
}
func (t Test1) Mul() int {
return t.aaa * t.bbb
}
然后我们定义一个对象来分别调用上面的两个方法:
ttt := Test1{aaa: 5, bbb: 2}
fmt.Println("Sum:", ttt.Sum())
fmt.Println("Mul:", ttt.Mul())
// output:
// Sum: 7
// Mul: 10
修改对象的内部变量
如果需要修改对象的内部变量,就必须在对象的指针类型上定义该方法,下面的代码分别使用了值和指针绑定:
func (t *Test1) modifyByAddr(a int) {
t.aaa = a
}
func (t Test1) modifyByValue(a int) {
t.aaa = a
}
然后我们定义一个对象来分别调用上面的两个方法:
fmt.Println("old value:", ttt)
ttt.modifyByValue(222)
fmt.Println("modifyByValue:", ttt)
ttt.modifyByAddr(111)
fmt.Println("modifyByAddr:", ttt)
// output
// old value: aaa:5, bbb:2
// modifyByValue: aaa:5, bbb:2
// modifyByAddr: aaa:111, bbb:2
函数与方法的区别
通过上面的例子来说明
函数
将变量当做参数传入Test1Sum(ttt)
方法
是被变量调用ttt.Mul()
和ttt.Sum()
接口
接口定义了一组方法,但这些方法并没有实现,使用该接口的前提是对象实现了接口内部的方法,这里需要特别注意,对象必须实现接口里的所以方法,或者会报错。
package main
import (
"fmt"
"testing"
)
type Test1 struct {
aaa int
bbb int
}
type TestInterface interface {
Sum() int
modify(int, int)
}
func (t *Test1) modify(a, b int) {
t.aaa = a
t.bbb = b
}
func (t *Test1) Sum() int {
return t.aaa + t.bbb
}
func (t Test1) Mul() int {
return t.aaa * t.bbb
}
func (t *Test1) modifyByAddr(a int) {
t.aaa = a
}
func (t Test1) modifyByValue(a int) {
t.aaa = a
}
func TestAmain(t *testing.T) {
// ttt := Test1{aaa: 5, bbb: 2}
// fmt.Println("Sum:", ttt.Sum())
// fmt.Println("Mul:", ttt.Mul())
// ttt := Test1{aaa: 5, bbb: 2}
// fmt.Println("old value:", ttt)
// ttt.modifyByValue(222)
// fmt.Println("modifyByValue:", ttt)
// ttt.modifyByAddr(111)
// fmt.Println("modifyByAddr:", ttt)
ttt := new(Test1)
ttt.aaa = 5
ttt.bbb = 2
var test1Face TestInterface
test1Face = ttt
test1Face.modify(123, 456)
fmt.Println("test1Face", test1Face)
}