开发实习小结


本文是作者实习一段时间后对 Go 开发的一些心得

1. GO 开发

1.1. 接口

1.1.1 为什么要用接口?

这里讲一下怎么做的好处:

  1. 代码复用率提高

  2. 代码解耦

代码复用率提高

举个栗子:在我自己的项目中,有一块需要用到多级缓存,多级缓存的配置往往比较复杂,需要考虑缓存的更新顺序,缓存的命中率,数据一致性等诸多问题,因此我将多个不同的缓存实例中使用到多级缓存的部分进行了抽象:

原始代码:https://github.com/zouxingyuks/SnapLink/blob/master/pkg/cache/kvCache.go

// IKVCache 多级 KV 缓存接口
type IKVCache interface {
	Get(ctx context.Context, key string) (string, error)
	Set(ctx context.Context, key string, value string, ttl time.Duration) error
	SetCacheWithNotFound(ctx context.Context, key string, ttl time.Duration) error
	Del(ctx context.Context, key string) error
}

抽象之后,创建一个新的多级缓存便变成了

package cache

import (
	"SnapLink/internal/custom_err"
	"SnapLink/internal/model"
	cache2 "SnapLink/pkg/cache"
	"context"
	"encoding/json"
	"github.com/pkg/errors"
	"github.com/zhufuyi/sponge/pkg/logger"
	"sync"
	"time"
)

const (
	RedirectsExpireTime    = 10 * time.Minute
	RedirectCachePrefixKey = "redirect"
)

var redirectInstance = new(redirectsCache)

func Redirect() *redirectsCache {
	redirectInstance.once.Do(func() {
		var err error
		if redirectInstance.kvCache, err = cache2.NewKVCache(model.GetRedisCli(), cache2.NewKeyGenerator(RedirectCachePrefixKey), LocalCache()); err != nil {
			logger.Panic(errors.Wrap(custom_err.ErrCacheInitFailed, "RedirectsCache").Error())
		}
	})
	return redirectInstance
}

var emptyRedirect = new(model.Redirect)

// redirectsCache define a cache struct
type redirectsCache struct {
	kvCache cache2.IKVCache
	once    sync.Once
}

// Set write to cache
func (c *redirectsCache) Set(ctx context.Context, uri string, redirect *model.Redirect, ttl time.Duration) error {
	jsonBytes, err := json.Marshal(redirect)
	if err != nil {
		return errors.Wrap(custom_err.ErrCacheSetFailed, err.Error())
	}
	if err = c.kvCache.Set(ctx, uri, string(jsonBytes), ttl); err != nil {
		return errors.Wrap(custom_err.ErrCacheSetFailed, err.Error())
	}
	return nil
}

// Get 获取缓存
func (c *redirectsCache) Get(ctx context.Context, uri string) (*model.Redirect, error) {
	value, err := c.kvCache.Get(ctx, uri)
	if errors.Is(err, cache2.ErrKVCacheNotFound) {
		return nil, custom_err.ErrCacheNotFound
	}
	if value == cache2.EmptyValue {
		return emptyRedirect, nil
	}
	redirect := new(model.Redirect)
	err = json.Unmarshal([]byte(value), redirect)
	if err != nil {
		return nil, errors.Wrap(custom_err.ErrCacheGetFailed, err.Error())
	}
	return redirect, nil
}

// Del 删除缓存
func (c *redirectsCache) Del(ctx context.Context, uri string) error {
	if err := c.kvCache.Del(ctx, uri); err != nil {
		return errors.Wrap(custom_err.ErrCacheDelFailed, err.Error())
	}
	return nil
}

// SetCacheWithNotFound 设置不存在的缓存,以防止缓存穿透,默认过期时间 10 分钟
func (c *redirectsCache) SetCacheWithNotFound(ctx context.Context, uri string) error {
	if err := c.kvCache.SetCacheWithNotFound(ctx, uri, RedirectsExpireTime); err != nil {
		return errors.Wrap(custom_err.ErrCacheSetFailed, err.Error())
	}
	return nil
}
代码解耦

这里再举一个新的例子:

这段代码是我用来注册一些后台服务的,需要实现一个新的服务,比如

func RegisterServers() []app.IServer {
   var cfg = config.Get()
   var servers []app.IServer

   // creating http service
   httpAddr := ":" + strconv.Itoa(cfg.HTTP.Port)
   httpRegistry, httpInstance := registryService("http", cfg.App.Host, cfg.HTTP.Port)
   httpServer := server.NewHTTPServer(httpAddr,
      server.WithHTTPReadTimeout(time.Second*time.Duration(cfg.HTTP.ReadTimeout)),
      server.WithHTTPWriteTimeout(time.Second*time.Duration(cfg.HTTP.WriteTimeout)),
      server.WithHTTPRegistry(httpRegistry, httpInstance),
      server.WithHTTPIsProd(cfg.App.Env == "prod"),
   )
   servers = append(servers, httpServer)

   // creating sentinelService
   sentinelService := service.NewSentinelService()
   servers = append(servers, sentinelService)

   // creating cacheAsideService
   cacheAsideService := service.NewCacheASideService()
   servers = append(servers, cacheAsideService)
   return servers
}

比如我现在需要创建一个新的 service 名为 crontabService 那么只需要做下面两件事:

  1. 在上述代码段中补充

    func RegisterServers() []app.IServer {
       // ***
       // 不变
       // ***
    
       // creating crontabService 
       crontabService := service.NewCrontabASideService()
       servers = append(servers, crontabService)
       return servers
    }
  2. 实现代码internal/service/crontabService

    package service
    
    import (
    	"github.com/zhufuyi/sponge/pkg/app"
    )
    
    var _ app.IServer = (*CrontabSideService)(nil)
    type CrontabSideService struct {
    }
    
    func (c CrontabSideService) Start() error {
    	//TODO implement me
    	panic("implement me")
    }
    
    func (c CrontabSideService) Stop() error {
    	//TODO implement me
    	panic("implement me")
    }
    
    func (c CrontabSideService) String() string {
    	//TODO implement me
    	panic("implement me")
    }
    

这样子,拓展性强,对原始代码的破坏小

PS:其实如果只是这种接口的方式还有更好的方法,比如将创建逻辑用

1.1.2 如果不是流程要求/自动生成,请先实例后接口

虽然我们在 1.1 节的讨论中可以抽象出一个完整的接口来概括缓存行为,但在实际开发过程中,往往难以一开始就确立完整的逻辑。代码在开发过程中可能需要经历多次修改,包括输入和输出逻辑的大幅变动。而涉及到了输入输出的变动,就要重新修改上层代码的接口依赖。

基于这种情况,更合理的方法是在业务逻辑完全实现并稳定后再定义接口。这样做的好处是可以避免在开发过程中频繁修改接口,而且在业务逻辑明确后进行接口定义可以使整体代码结构更加清晰。等到实现细节稳定并对业务有全面理解后,再进行二次重构以定义接口,可以更有效地组织代码,减少不必要的工作。

1.1.3 不要什么函数都用接口进行封装

尽管接口有其用途,但并非所有代码都适合使用接口。例如,某些代码片段可能由于其独立性强、依赖性大或扩展性有限,更适合直接使用结构体而不是接口。

使用接口进行封装时可能面临的问题包括:

  1. 代码复杂性增加:当代码遵循接口到实例的层次结构(如A接口->A实例->B接口->B实例->C接口->C实例 )时,虽然表面上看似规整,但如果A、B、C的实现是唯一的并且未来不太可能扩展,这种结构就显得过于复杂。

  2. 数据访问问题:由于返回的是接口,获取结构体内部数据时需额外编写获取方法,如 Java 中常见的 GET 函数。这种做法可能显得不够直接,因为实现接口的对象通常应当将内部逻辑与外部逻辑分离。在需要访问内部数据的情况下使用接口违背了设计原则,直接使用结构体并实现必要的方法会更加合理。

    如果你在 Go 中这么写了,你肯定是在摸鱼,在水代码( •̀ ω •́ )✧

代码复杂性增加
package utils

import "regexp"

const (
	phoneReg       = `^1[3456789]\d{9}$`
	emailReg       = `^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$`
	invalidCharReg = `[^\w]`
)

// IsPhone 手机号格式校验
func IsPhone(phone string) bool {
	if !regexp.MustCompile(phoneReg).MatchString(phone) {
		return false
	}
	return true
}

// IsEmail 邮箱格式校验
func IsEmail(email string) bool {
	if !regexp.MustCompile(emailReg).MatchString(email) {
		return false
	}
	return true
}

// LengthCheck 字符串长度校验
func LengthCheck(str string, min, max int) bool {
	l := len(str)
	if l < min || l > max {
		return false
	}
	return true
}

// InvalidCharCheck 字符串非法字符校验
func InvalidCharCheck(str string) bool {
	if regexp.MustCompile(invalidCharReg).MatchString(str) {
		return false
	}
	return true
}
数据访问问题

// todo

1.2 代码拆分

//todo

1.3 多携带上下文

对于并发场景,如果是想要及时停止,那么就要尽量的去携带 ctx。

下面这个写法的优点如下:

  1. 当报错时,ctx 被取消,执行完当前函数就可以停止,尽可能减少了无效执行。(值得注意的是,携带上下文的尽量是单个业务模块,但是太小的操作不适合携带上下文)
func makeBFWithShardingNum(key string, shardingNum int, prefix, param string) bfMakeFn {
	return func(db *gorm.DB) (bool, error) {
		// TODO: 此处改为远程配置
		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()
		if err := cache.BFCache().BFCreate(ctx, key, 0.01, 1e9); err != nil {
			// 记录日志
			return false, err
		}
		errCh := make(chan error, shardingNum)
		wg := sync.WaitGroup{}
		wg.Add(shardingNum)

		for i := 0; i < shardingNum; i++ {
			tableName := fmt.Sprintf("%s-%d", prefix, i)
			go func(id int, tName string) {
				defer wg.Done()
				data, err := getAll(ctx, tableName, db, []string{param})
				if err != nil {
					errCh <- err
					return
				}
				if err := cache.BFCache().BFMAdd(ctx, key, data[param]...); err != nil {
					errCh <- err
					return
				}
				errCh <- nil
			}(i, tableName)
		}
		go func() {
			wg.Wait()
			close(errCh)
		}()
		for err := range errCh {
			if err != nil {
				return false, err
			}
		}
		return true, nil
	}
}

2. Git 的使用策略

以前在进行 git 使用的时候是比较无序的,虽然也做日志记录,但是总是显得混乱。

早期 git 日志

现在经过一段时间的实践,对整个 git 的使用有着更加深刻的体会:

  1. 遵守 git 的提交规范:Commit message 和 Change log 编写指南

    当然公司内部肯定是有自己的流程规范的,具体情况具体分析嘛

  2. 分支策略调整

    • 原先的做法是代码有变更就提交一次,虽然这样可以即时保存工作进度,但可能导致提交日志显得混乱。
    • 改进后的做法是从当前分支拉出一个新分支进行工作,即使是微小的修改,如删除几个空格,也可以进行提交。
    • 完成功能需求后,再将这个新分支的变更进行压缩合并(应该是压缩提交,合并可能有冲突问题?)到原始分支。这样,虽然实际提交次数可能很多,但合并后的主分支日志会显得更加整洁。

    下面是个例子:

    在这个分支里面我们一样可以改一点做一点,就是 你删除了几个空格一样可以 commit 一次(未免有些过于极端)

    而后在此次功能需求开发完成后,我们对这个分支上新增的变动进行压缩,然后提交到原本的分支上,最终呈现效果如下:

    虽然看起来只有三次提交,但是鬼知道我 commit 了多少次,这样子我们可以保留原本随手保存的习惯(代码写完 Ctrl+S 才不会一天白干,又不会污染整个 commit 日志


如果本文帮助到了你,帮我点个广告可以咩(o′┏▽┓`o)


评论
  目录