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