Golang 使用过程中遇到的一些问题(1)
前段时间使用 golang 做了一些项目,总结一下遇到的问题。
没有枚举
在需要用到枚举的地方,Go 语言通常是用一组常量来模拟,这会带来几个问题:
- 类型安全问题
在 Go 中,模拟实际上是基本类型 int
的别名,这意味着来自不同枚举集的值可以混用,因为它们本质上是相同的底层类型。请看下面的代码片段:
const (
UserStatusAvailable = 1
UserStatusSuspended = 9
UserStatusDeleted = 10
)
const (
EmailStatusAvailable = 1
EmailStatusToBeVerified = 2
)
const (
LocalUserSource = 0
RemoteUserSource = 10
)
if exist && userInfo.Source == entity.UserStatusDeleted {
fmt.Println("remote user already exist: ", userInfo)
resp, err := us.buildLoginResp(ctx, userInfo, token.AccessToken)
return c.RemoteAuthEnable, resp, err
}
本来需要比较的是用户的 Source,结果真正比较的是用户的 Status,这不会有任何错误,但是在逻辑上是不允许这样比较的,所以在使用常量进行比较的时候要格外小心,这回带来额外的心智负担。
- 要特别注意声明的顺序
type Color int
const (
Red Color = iota
Green
Blue
)
在Go语言中,使用iota来模拟枚举时,值是顺序递增的。iota是Go语言的一个特殊常量,它在每当定义一个新的常量时会自动递增。它通常在const块中使用,用于生成一系列相关的值。
在定义枚举类型时,iota的初始值为0,每定义一个新常量,它的值就会增加1。这种方式非常适合于创建一系列递增的常量,通常用于模拟枚举类型。
如果一个新手来修改这个代码,想新加一种 Color,结果把代码改成了如下的样子:
type Color int
const (
Red Color = iota
Black
Green
Blue
)
那么,Green 和 Blue 的值都会改变,如果这个值需要持久化到数据库,这会带来灾难性的后果。
发生这个问题的根本原因在于上面的枚举值是隐式分配的,所以通常的建议是在声明的时候就指定常量的值,不要使用隐式分配的结果。
- 缺乏自描述性
我们在 Go 语言里打印这些枚举值的时候,打印出的都是基本值,看不出枚举值包含的枚举信息,这也降低了代码的可读性于可维护性。
- 不支持枚举方法
与一些其他语言不同,在Go中不能直接为枚举类型定义方法,因为它们实际上是基础类型的别名。
错误处理写到吐
一个新手学习 Go 语言,最先接触到的也许就是错误处理了,可以随手贴出一段代码来看看:
func GetUserInformationByBearer(token string) (*schema.UserAccountInfoResp, error) {
c, err := readConfig(cli.GetConfigFilePath())
if err != nil {
return nil, err
}
remoteEndpoint := c.RemoteAuth + "/api/user/profile-direct"
req, err := http.NewRequest("GET", remoteEndpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
//fmt.Println("resp", resp, err)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("received non-200 response status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var userEntity schema.UserAccountInfoResp
err = json.Unmarshal(body, &userEntity)
fmt.Println("userEntity", userEntity)
if err != nil {
return nil, err
}
return &userEntity, nil
}
目测下来有接近一半的代码都是 if err != nil
这种代码,特别是在一些网络编程领域,可能也三分之二的代码都是处理错误。
因为 Go 声称不支持异常,所以可能有错误的函数都要在结尾返回一个 error
作为结果,这种冗长的方式在 Go 语言的代码中极为普遍,这就造成在实际的编码过程中引入很多噪音,很难找到实际起作用的代码。错误处理代码经常与业务逻辑代码混在一起,这可能导致业务逻辑的阅读和理解变得更加困难。
Go语言没有提供类似于异常处理机制的统一错误处理方式,这可能导致在处理多个不同来源的错误时代码变得冗杂和重复。
在某些情况下,Go中的函数可能会返回过多的错误,甚至是在不需要错误处理的情况下也返回错误。这种设计可能导致错误处理的滥用和代码的臃肿。
Go 的错误处理在实际语义上也存在一些问题,主要是错误信息的丢失。
在错误被向上层传递的过程中,原始错误信息可能会丢失或被覆盖,特别是在不正确地封装错误时。这会导致调试和定位问题变得更加困难。
return fmt.Errorf("failed to process: %v", err)
在复杂的应用中,错误可能需要通过多个层级传递,每一层都可能增加额外的封装或日志记录。这可能导致复杂的错误链,使得最终的错误难以理解和处理。
context 传递的问题
在使用 Gin 进行 Web 开发的时候,常常将 context
(如*gin.Context)从控制器(controller)一直传递到存储库(repository),这种做法非常普遍。
这会引入一些问题,框架本身对业务代码的侵入性过大,这绝不是只有 Gin 才存在的问题。
这就导致在整个调用链上,每个方法默认第一个参数就是 *gin.Context,导致 controller 和 repo 的过度耦合,也降低代码的可读性,给单元测试带来额外的复杂性。当context被深入传递时,对那些依赖于context的函数或方法进行单元测试可能变得更加复杂。你可能需要模拟更多的context内容,这可能导致测试代码的冗余。
请看下面的代码:
func (uc *UserController) UserLogout(ctx *gin.Context) {
accessToken := middleware.ExtractToken(ctx)
if len(accessToken) == 0 {
handler.HandleResponse(ctx, nil, nil)
return
}
_ = uc.authService.RemoveUserCacheInfo(ctx, accessToken)
_ = uc.authService.RemoveAdminUserCacheInfo(ctx, accessToken)
uc.userService.UserRemoteLogout(ctx, accessToken)
uc.userService.ClearAccessCookieToken(ctx)
handler.HandleResponse(ctx, nil, nil)
}
func (as *AuthService) RemoveUserCacheInfo(ctx context.Context, accessToken string) (err error) {
return as.authRepo.RemoveUserCacheInfo(ctx, accessToken)
}
func (ar *authRepo) RemoveUserCacheInfo(ctx context.Context, accessToken string) (err error) {
err = ar.data.Cache.Del(ctx, constant.UserTokenCacheKey+accessToken)
if err != nil {
return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
return nil
}
在上面的代码中,不管函数本身是不是需要 context
这个参数,在调用的过程中都必须携带这个参数。