Go最佳实践总结

State & Behavior

面向对象编程(OOP)中,将对象抽象为两个重要的属性:

  • 状态(State)

  • 行为( Behaivor)

GO语言是通过type来实现:

  • 状态 (State)- 数据结构(Data structure)

  • 行为(Behavior)- 方法(Method)

interface

接口(interface),GO非常重要的类型,它是用来定义一类方法集,只表示对象的行为(Behavior),GO语言的接口和实现不需要显示关联(也就是常说的duck类型),只要实现了接口所有方法,就可以当做该接口的一个实现,赋值给所有引用该接口的变量,从而满足面向对象编程(OOP)中的两个非常重要原则:依赖倒置、里氏替换。

也正由于这个特点,所以GO接口最佳的实践是:接口尽量的小,根据实际的需求定义的接口大小。

例如:io包体的Reader/Writer

type Reader interface {
   Read(p []byte) (n int, err error)
}
type Writer interface {
   Write(p []byte) (n int, err error)
}

更大的接口:net/http

type File interface {
   io.Closer
   io.Reader
   io.Seeker
   Readdir(count int) ([]fs.FileInfo, error)
   Stat() (fs.FileInfo, error)
}

善于合理运用interface,可以使你的代码简洁,更好的解耦,从而提高程序的扩展性,比如:在涉及到input/output设计的时候,引入io.Reader/io.Writer是一个不错的选择。

借用Steve Francia分享的静态网站生成器HUGO的例子:

Bad

func (page *Page) saveSourceAs(path) {
  b := new(bytes.Buffer)
  b.Write(page.Resource.Content)
  page.saveSource(b.Bytes(), path)
}
// by 数组需要通过bytes.NewReader转化后储存
func (p *Page) saveSource(by []byte, inPath string) {
  saveToDisk(inPath,bytes.NewReader(by))
}

Good

func (page *Page) saveSourceAs(path) {
  b := new(bytes.Buffer)
  b.Write(page.Resource.Content)
  page.saveSource(b, path)
}
// by 数组需要通过bytes.NewReader转化后储存
func (p *Page) saveSource(b io.Reader, inPath string) {
  saveToDisk(inPath, b)
}

注:saveSource方法中定义的接收参数是io.Reader,清晰简洁, 易扩展,该方法可以接收所有实现该接口的实例。

除了bytes.Buffer实现了Read方法

func (b *Buffer) Read(p []byte) (n int, err error) {
   b.lastRead = opInvalid
   if b.empty() {
      // Buffer is empty, reset to recover space.
      b.Reset()
      if len(p) == 0 {
         return 0, nil
      }
      return 0, io.EOF
   }
   n = copy(p, b.buf[b.off:])
   b.off += n
   if n > 0 {
      b.lastRead = opRead
   }
   return n, nil
}

还可以是Conn(http包),实现从网络读取数据:

type Conn interface {
   Read(b []byte) (n int, err error)

或是文件对象File(os包),从本地磁盘读取

func (f *File) Read(b []byte) (n int, err error) {
   if err := f.checkValid("read"); err != nil {
      return 0, err
   }
   n, e := f.read(b)
   return n, f.wrapErr("read", e)
}

另外,注意的是接口粒度以满足需求为准,不要有额外的方法,否则会导致依赖不清晰。

例如:我们在网络编程中经常会用到net包的Conn接口:

type Conn interface {
   Read(b []byte) (n int, err error)
   Write(b []byte) (n int, err error)
   Close() error
   LocalAddr() Addr
   RemoteAddr() Addr
   SetDeadline(t time.Time) error
   SetReadDeadline(t time.Time) error
   SetWriteDeadline(t time.Time) error
}

通常我们收到一个conn的时候,会开启一个协程读取数据: Bad

func handleConn(c net.Conn) {

这里我们使用net.Conn,实际上只是read数据,不会调用Conn其他方法,用io.Reader接口就足够:

func handleConn(r io.Reader)

如果还涉及关闭连接就用io.ReadCloser

func handerConn(rc io.ReadCloser)

颗粒度小的接口可以清晰依赖的同时,也方便单测对接口进行mock,更好的聚焦于目标逻辑的测试。

那要在哪定义iterface呢?!

首先:一般会把接口定义放在需要使用该接口的package下,而不是实现包下,通常实现类返回的是结构体或是对应指针,这样实现类扩展新的方法就不需要修改对应的接口。同样以io包中的io.Reader/io.Wirter为例,接口定义在io包中,而他的实现bytes.Buffer、os.File等都是在不同包中。

其次:这就关于接口定义的时机。

一般来说是由依赖方驱动。

在当前模块有依赖外部服务的时候,这时候就会定义一个接口,来对外部的依赖资源进行抽象解耦,屏蔽接口的实现。相反,依赖的提供方在不知道使用方需求的时候,定义接口也就没什么意义。

Methods VS Functions

方法和函数,这个也是比较让人困惑,什么是函数:

  • 处理输入值,然后输出最终结果

  • 相同的输入,始终输出相同的结果

  • 不依赖状态

所以函数应该是无状态的,幂等的,无隐式依赖的,输入的参数就是依赖的边界,我们可以将一些依赖定义成接口参数,在调用的时候将实现传入。

类似这样的逻辑是不建议:

Bad

var counter = 0
func ToJson(data interface) ([] byte, error){
  counter ++ // 不合理!!
  return json.Marshal(data)
}

Good

// 累加接口
type Increaser interface {
  Increase()
}
func ToJson(data interface, inc Increaser) {
  inc.Increase() // 累加逻辑由调用方决定
  return json.Marshal(data)
}

那什么又是方法(method):

  • 方法是类型(type)的行为(behavior)

  • 一般会有相应的状态(state)

  • 用来建立逻辑上的关联

所以方法(method)会归属于特定的类型(type),一般会定义一个结构体或是的基本类型,然后绑定一系列相关的方法:

// user_service.go: 列表list、保存save方法
type UserService struct {
  db *DB
}
func (srv UserService) List(){
  // dosomething
}
func (srv UserService) Save(user User){
  // dosomething
}
// ints.go
type Ints [] int
​
func (it Ints) Contain(v int) bool {
  for _, _v := range it {
    if _v == v {
      return true
    }
  }
  return false
}
func (it Ints) Index(v int) (int, error) {
  for i, _v := range it {
    if _v == v {
      return i, nil
    }
  }
  return -1, errors.New("no match")
}

另外,和函数(function)不一样,方法(method)还会用涉及操作type中的状态。

Pointer VS Values

GO语言中参数传递都是值拷贝,那我们是用Pointer还是Values,

首先,需要知道的是:一般我们要考虑的不是性能,而是共享状态。

通常会认为string或slice可能会比较大,需要传*string或*slice,这个是没必要的,大家都知道切片实际上也是结构体,主要存储的也只是指向数组的指针,字符串也类似,只是指向的是不可变的byte数组指针,所以就算是值拷贝也只是拷贝结构体中的指针,以目前计算机性能,可以忽略该部分的性能损失,所有我们更多的是考虑是要不要共享状态,函数(function)或方法(method)会不会对一些共享的资源做修改,例如:

不需要修改

// net/url包中的url.go
type EscapeError string
​
func (e EscapeError) Error() string { // 不会要修改EscapeError,所以不需要用*EscapeError
  return "invalid URL escape " + strconv.Quote(string(e))
}

需要修改

func (u *URL) setPath(p string) error { // 需要修改path
  path, err := unescape(p, encodePath)
  if err != nil {
    return err
  }
  u.Path = path
  if escp := escape(path, encodePath); p == escp {
    // Default encoding is fine.
    u.RawPath = ""
  } else {
    u.RawPath = p
  }
  return nil
}

当然,这个建议不适用于结构体确实很大,或是虽然是小结构体,但是会一直增长的情况。

另外提一下:指针方式的共享会存在线程安全的问题,如果多线程并发,还是需要考虑线程同步问题。

error as string

GO语言中error经常会被单纯的看做是一个字符串类型,实际上它是一个error接口:

type error interface {
   Error() string
}

我们经常使用的errors.New(..)只是该接口的一个实现:

type errorString struct {
  s string
}
​
func (e *errorString) Error() string {
  return e.s
}
func New(text string) error {
   return &errorString{text}
}

可以看到它实现的非常简单,只有一个s的字符串属性,没有其他上下文信息,包括堆栈信息,并且还有一个关键问题:如何判断error,由于标识是字符串,所以判断只能用字符串匹配的方式来判断:

if err.Error() !="no row" {
  // do something
}

这种方式非常生硬,并且不好维护,一旦修改内容,判断就会失效。对此我们做一些改进:

  • 创建全局异常实例

例如:go的io包定义的异常

var EOF = errors.New("EOF")
var ErrUnexpectedEOF = errors.New("unexpected EOF")
var ErrNoProgress = errors.New("multiple Read calls return no data or error")
​
// 实际使用
func (l *LimitedReader) Read(p []byte) (n int, err error) {
  if l.N <= 0 {
    return 0, EOF
  }
}
// 判断
if err == io.EOF {
  // do something
}
  • 自定义异常

我们可以自定义异常实现,增加额外的上下文信息,例如(堆栈,异常编码等),然后实现error接口,自定义异常可以为异常的跟踪和逻辑处理提供额外的条件。定义不同的类型的异常,又可以在类型上进行区分。例如:docker的自定义异常

type Error struct{
  Code ErrorCode
  Message string
  Detail interface{}
}
func (e Error) Error() string{
  return fmt.Sprintf("%s: %s", strings.ToLower(strings.Replace(e.Code.String(), "_", "", -1)), e.Message)
}

os包中的自定义异常

type PathError struct {
  Op   string
  Path string
  Err  error
}
​
func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
​
func (e *PathError) Unwrap() error { return e.Err }
​
// Timeout reports whether this error represents a timeout.
func (e *PathError) Timeout() bool {
  t, ok := e.Err.(interface{ Timeout() bool })
  return ok && t.Timeout()
}

使用的时候就可以这样

if pErr, ok := e.Err.(*fs.PathError); ok && strings.HasSuffix(e.URL, pErr.Path) {
   // Remove the redundant copy of the path.
   err = pErr.Err
}

Safe

并发编程的时候共享资源的安全问题是绕不开的话题,上面提到的指针(pointer)共享的数据就是不安全的,值(values)的方式由于每次调用都是操作副本,所以是安全的。

还有一种情况,就是开发的是类库,主要提供给其他模块使用的时候,这个是否要考虑并发安全的问题?

实际上大多情况下不需要去处理线程安全的问题,主要基于以下几点的考虑:

  • 性能损耗。

    要保证线程安全,难免就会损失性能,在很多场景下是不必要的。

  • 行为强加给客户端

    如果类库内部实现了线程安全,而客户端就不得不接收安全的性能损耗,不管需不需要。

  • 让客户端自行决定是否要并发安全

例如:go语言内置的map,它的设计就是线程不安全。

因为大多情况下我们是在一个协程中操作map,或是本身就是安全的操作,例如:在一个方法内声明,作用域是在方法体里,就不存在安全问题,没必要在map中做额外的线程安全保护, 客户端如果需要的话,可以通过Atomic/Mutex或是channel来保证访问的顺序性,从而避免并发问题。

参考:

https://www.bilibili.com/video/av70471550/

https://github.com/golang/go/wiki/CodeReviewComments#interfaces

我的博客:https://itart.cn/blogs/%E5%AE%9E%E8%B7%B5%E6%80%BB%E7%BB%93/2021/go-best-practice.html

 

 

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值