本文是作者实习一段时间后对 Go 开发的一些心得
1. GO 开发
1.1. 接口
1.1.1 为什么要用接口?
这里讲一下怎么做的好处:
代码复用率提高
代码解耦
代码复用率提高
举个栗子:在我自己的项目中,有一块需要用到多级缓存,多级缓存的配置往往比较复杂,需要考虑缓存的更新顺序,缓存的命中率,数据一致性等诸多问题,因此我将多个不同的缓存实例中使用到多级缓存的部分进行了抽象:
原始代码: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
那么只需要做下面两件事:
在上述代码段中补充
func RegisterServers() []app.IServer { // *** // 不变 // *** // creating crontabService crontabService := service.NewCrontabASideService() servers = append(servers, crontabService) return servers }
实现代码
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 不要什么函数都用接口进行封装
尽管接口有其用途,但并非所有代码都适合使用接口。例如,某些代码片段可能由于其独立性强、依赖性大或扩展性有限,更适合直接使用结构体而不是接口。
使用接口进行封装时可能面临的问题包括:
代码复杂性增加:当代码遵循接口到实例的层次结构(如
A接口->A实例->B接口->B实例->C接口->C实例
)时,虽然表面上看似规整,但如果A、B、C的实现是唯一的并且未来不太可能扩展,这种结构就显得过于复杂。数据访问问题:由于返回的是接口,获取结构体内部数据时需额外编写获取方法,如 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。
下面这个写法的优点如下:
- 当报错时,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 的提交规范:Commit message 和 Change log 编写指南
当然公司内部肯定是有自己的流程规范的,具体情况具体分析嘛
分支策略调整:
- 原先的做法是代码有变更就提交一次,虽然这样可以即时保存工作进度,但可能导致提交日志显得混乱。
- 改进后的做法是从当前分支拉出一个新分支进行工作,即使是微小的修改,如删除几个空格,也可以进行提交。
- 完成功能需求后,再将这个新分支的变更进行压缩合并(应该是压缩提交,合并可能有冲突问题?)到原始分支。这样,虽然实际提交次数可能很多,但合并后的主分支日志会显得更加整洁。
下面是个例子:
在这个分支里面我们一样可以改一点做一点,就是 你删除了几个空格一样可以 commit 一次(未免有些过于极端)
而后在此次功能需求开发完成后,我们对这个分支上新增的变动进行压缩,然后提交到原本的分支上,最终呈现效果如下:
虽然看起来只有三次提交,但是鬼知道我 commit 了多少次,这样子我们可以保留原本随手保存的习惯(代码写完 Ctrl+S 才不会一天白干,又不会污染整个 commit 日志