Golang 中的良好代码与糟糕代码

最近,有人要求我详细解释在 Golang 中什么是好的代码和坏的代码。我觉得这个练习非常有趣。实际上,足够有趣以至于我写了一篇关于这个话题的文章。为了说明我的回答,我选择了我在空中交通管理(ATM)领域遇到的一个具体用例。

img

背景

首先,简要解释一下实现的背景。

欧洲航空管制组织(Eurocontrol)是管理欧洲各国航空交通的组织。Eurocontrol 与航空导航服务提供商(ANSP)之间交换数据的通用网络称为 AFTN。这个网络主要用于交换两种不同类型的消息:ADEXP 和 ICAO 消息。每种消息类型都有自己的语法,但在语义上,这两种类型是等价的(或多或少)。在这个上下文中,性能 必须是实现的关键要素。

该项目需要提供两种基于 Go 解析 ADEXP 消息的实现(ICAO 没有在这个练习中处理):

  • 一个糟糕的实现(包名:bad)
  • 一个重构后的实现(包名:good)

可以在 这里 找到 ADEXP 消息的示例。

在这个练习中,解析器只处理了 ADEXP 消息中的一部分字段。但这仍然是相关的,因为它可以说明常见的 Golang 错误。

解析

简而言之,ADEXP 消息是一组令牌。令牌类型可以是:一组令牌的重复列表。每行包含一组令牌子列表(在本示例中为 GEOID、LATTD、LONGTD)。

考虑到这个背景,重要的是要实现一个可以利用并行性的版本。所以算法如下:

  • 预处理步骤来清理和重新排列输入消息(我们必须清除潜在的空格,重新排列多行的令牌,如 COMMENT 等)。
  • 然后在一个给定的 goroutine 中拆分每一行。每个 goroutine 将负责处理一行并返回结果。
  • 最后,收集结果并返回一个 Message 结构。这个结构是一个通用的结构,无论消息类型是 ADEXP 还是 ICAO。

每个包都包含一个 adexp.go 文件,暴露了主要的函数 ParseAdexpMessage()。

逐步比较

现在,让我们逐步看看我认为是糟糕代码的部分,以及我是如何重构它的。

字符串 vs []byte

糟糕的实现仅处理字符串输入。由于 Go 提供了对字节操作的强大支持(基本操作如修剪、正则表达式等),并且考虑到输入很可能是 []byte(考虑到 AFTN 消息是通过 TCP 接收的),实际上没有理由强制使用字符串输入。

错误处理

糟糕的实现中的错误处理有些糟糕。
我们可以找到一些潜在错误返回的情况,而第二个参数中的错误甚至没有被处理:

preprocessed, _ := preprocess(string)

优秀的实现处理了每一个可能的错误:

preprocessed, err := preprocess(bytes)
if err != nil {
  return Message{}, err
}

我们还可以在糟糕的实现中找到一些错误,就像下面的代码中所示:

if len(in) == 0 {
  return "", fmt.Errorf("Input is empty")
}

第一个错误是语法错误。根据 Go 的规范,错误字符串既不应该大写,也不应该以标点结束。

第二个错误是因为如果一个错误字符串是一个简单的常量(不需要格式化),使用 errors.New() 更为高效。

优秀的实现看起来是这样的:

if len(in) == 0 {
	return nil, errors.New("input is empty")
}

避免嵌套

mapLine() 函数是一个避免嵌套调用的良好示例。糟糕的实现:

func mapLine(msg *Message, in string, ch chan string) {
    if !startWith(in, stringComment) {
        token, value := parseLine(in)
        if token != "" {
            f, contains := factory[string(token)]
            if !contains {
                ch <- "ok"
            } else {
                data := f(token, value)
                enrichMessage(msg, data)
                ch <- "ok"
            }
        } else {
            ch <- "ok"
            return
        }
    } else {
        ch <- "ok"
        return
    }
}

相反,优秀的实现是一个扁平的表示方式:

func mapLine(in []byte, ch chan interface{}) {
    // Filter empty lines and comment lines
    if len(in) == 0 || startWith(in, bytesComment) {
        ch <- nil
        return
    }

    token, value := parseLine(in)
    if token == nil {
        ch <- nil
        log.Warnf("Token name is empty on line %v", string(in))
        return
    }

    sToken := string(token)
    if f, contains := factory[sToken]; contains {
        ch <- f(sToken, value)
        return
    }

    log.Warnf("Token %v is not managed by the parser", string(in))
    ch <- nil
}

这样做在我看来使代码更易读。此外,这种扁平的表示方式也必须应用到错误管理中。举个例子:

a, err := f1()
if err == nil {
    b, err := f2()
    if err == nil {
        return b, nil
    } else {
        return nil, err
    }
} else {
    return nil, err
}

应该被替换为:

a, err := f1()
if err != nil {
    return nil, err
}
b, err := f2()
if err != nil {
    return nil, err
}
return b, nil

再次,第二个代码版本更容易阅读。

传递数据是按引用还是按值传递

在糟糕的实现中,预处理函数的签名是:

func preprocess(in container) (container, error) {
}

考虑到这个项目的背景(性能很重要),并考虑到消息可能会相当庞大,更好的选择是传递对容器结构的指针。否则,在先前的示例中,每次调用都会复制容器值。

优秀的实现并不面临这个问题,因为它处理切片(无论底层数据如何,都是一个简单的 24 字节结构)。

func preprocess(in []byte) ([][]byte, error) {
}

糟糕的实现基于一个很好的初始想法:利用 goroutine 并行处理数据(每行一个 goroutine)。

这是通过在循环遍历行数的过程中,为每一行启动一个 mapLine() 调用的 goroutine 完成的。

for i := 0; i < len(lines); i++ {
    go mapLine(&msg, lines[i], ch)
}

因为结构中包含一些切片,这些切片可能会被并发地修改(由两个或更多的 goroutine 同时修改),在糟糕的实现中,我们不得不处理互斥锁。

例如,Message 结构包含一个 Estdata []estdata
通过添加另一个 estdata 来修改切片必须这样做:

mutexEstdata.Lock()
for _, v := range value {
    fl := extractFlightLevel(v[subtokenFl])
    msg.Estdata = append(msg.Estdata, estdata{v[subtokenPtid], v[subtokenEto], fl})
}
mutexEstdata.Unlock()

现实情况是,除非是非常特殊的用例,必须在 goroutine 中使用互斥锁可能是代码存在问题的迹象。

  • 缺点 #2:伪共享

跨线程/协程共享内存并不是一个好主意,因为可能存在伪共享(一个 CPU 核心缓存中的缓存行可能会被另一个 CPU 核心缓存无效)。这意味着,如果线程/协程意图对其进行更改,我们应该尽量避免在线程/协程之间共享相同的变量。

在这个例子中,我认为伪共享影响不大,因为输入文件相当轻量级(在 Message 结构中添加填充字段并进行性能测试得到的结果大致相同)。然而,在我看来,这始终是一件需要牢记的重要事情。

现在让我们看一下好的实现是如何处理并行处理的:

for _, line := range in {
    go mapLine(line, ch)
}

现在,mapLine() 只接收两个输入:

  • 当前行
  • 一个通道。这次,这个通道不仅用于在行处理完成时发送通知,还用于发送实际结果。这意味着不应该由 goroutine 来修改最终的 Message 结构。

父 goroutine(生成单独的 goroutine 中的 mapLine() 调用的那个)通过以下方式收集结果:

msg := Message{}

for range in {
    data := <-ch

    switch data.(type) {
        // Modify msg variable
    }
}

这个实现更符合 Go 的原则,只通过通信来共享内存。Message 变量由单个 Goroutine 修改,以防止潜在的并发切片修改和错误共享。

即使是好的代码也可能面临一个潜在的批评,就是为每一行代码都创建一个 Goroutine。这样的实现可以工作,因为 ADEXP 消息不会包含成千上万行的内容。然而,在非常高的吞吐量下,简单的实现每个请求触发一个 Goroutine 的方式并不具有很强的可扩展性。更好的选择可能是创建一个可重用 Goroutine 池。

编辑: 假设(一行代码 = 一个 Goroutine)绝对不是一个好主意,因为它会导致过多的上下文切换。要获取更多信息,请查看 further reading 章节末尾的链接。

处理行的通知

在不好的实现中,如上所述,一旦通过 mapLine() 完成行处理,我们应该通知父 Goroutine。这是通过使用 chan string 通道和调用来实现的:

ch <- "ok"

对于父 Goroutine 实际上并不检查通道发送的值,更好的选择是使用 chan struct{},使用 ch <- struct{}{},甚至更好(对 GC 更友好)的选择是使用 chan interface{},使用 ch <- nil

另一种方法(在我看来更清晰的方法)是使用 sync.WaitGroup,因为父 Goroutine 只需在每个 mapLine() 完成后继续执行。

If

Go 语言的 if 语句允许在条件之前传递一个语句。

对于这段代码的改进版本:

f, contains := factory[string(token)]
if contains {
    // Do something
}

以下实现可以是这样的:

if f, contains := factory[sToken]; contains {
    // Do something
}

它稍微提高了代码的可读性。

Switch

另一个糟糕实现的错误是在以下开关语句中忘记了默认情况:

switch simpleToken.token {
case tokenTitle:
    msg.Title = value
case tokenAdep:
    msg.Adep = value
case tokenAltnz:
    msg.Alternate = value 
// Other cases
}

如果开发者考虑了所有不同的情况,那么默认情况可以是可选的。然而,像以下示例中这样捕捉特定情况肯定更好:

switch simpleToken.token {
case tokenTitle:
    msg.Title = value
case tokenAdep:
    msg.Adep = value
case tokenAltnz:
    msg.Alternate = value
// Other cases    
default:
    log.Errorf("unexpected token type %v", simpleToken.token)
    return Message{}, fmt.Errorf("unexpected token type %v", simpleToken.token)
}

处理默认情况有助于在开发过程中尽快捕获开发人员可能产生的潜在错误。

递归

parseComplexLines() 是一个解析复杂标记的函数。糟糕代码中的算法是使用递归完成的:

func parseComplexLines(in string, currentMap map[string]string, 
	out []map[string]string) []map[string]string {

    match := regexpSubfield.Find([]byte(in))

    if match == nil {
        out = append(out, currentMap)
        return out
    }

    sub := string(match)

    h, l := parseLine(sub)

    _, contains := currentMap[string(h)]

    if contains {
        out = append(out, currentMap)
        currentMap = make(map[string]string)
    }

    currentMap[string(h)] = string(strings.Trim(l, stringEmpty))

    return parseComplexLines(in[len(sub):], currentMap, out)
}

然而,Go 不支持尾递归消除以优化子函数调用。良好的代码产生完全相同的结果,但使用迭代算法:

func parseComplexToken(token string, value []byte) interface{} {
    if value == nil {
        log.Warnf("Empty value")
        return complexToken{token, nil}
    }

    var v []map[string]string
    currentMap := make(map[string]string)

    matches := regexpSubfield.FindAll(value, -1)

    for _, sub := range matches {
        h, l := parseLine(sub)

        if _, contains := currentMap[string(h)]; contains {
            v = append(v, currentMap)
            currentMap = make(map[string]string)
        }

        currentMap[string(h)] = string(bytes.Trim(l, stringEmpty))
    }
    v = append(v, currentMap)

    return complexToken{token, v}
}

第二段代码将比第一段代码更高效。

常量管理

我们必须管理一个常量值以区分 ADEXP 和 ICAO 消息。糟糕的代码是这样做的:

const (
    AdexpType = 0 // TODO constant
    IcaoType  = 1
)

而良好的代码是基于 Go(优雅的)iota 的更优雅的解决方案:

const (
    AdexpType = iota
    IcaoType 
)

它产生完全相同的结果,但减少了潜在的开发人员错误。

接收器函数

每个解析器提供一个函数来确定消息是否涉及更高级别(至少有一个路由点在 350 级以上)。

糟糕的代码是这样实现的:

func IsUpperLevel(m Message) bool {
    for _, r := range m.RoutePoints {
        if r.FlightLevel > upperLevel {
            return true
        }
    }

    return false
}

意味着我们必须将消息作为函数的输入参数传递。
而良好的代码只是一个带有消息接收器的函数:

func (m *Message) IsUpperLevel() bool {
    for _, r := range m.RoutePoints {
        if r.FlightLevel > upperLevel {
            return true
        }
    }

    return false
}

第二种方法更可取。我们只需指示消息结构实现了特定的行为。

这也可能是使用 Go 接口的第一步。例如,如果将来我们需要创建另一个具有相同行为(IsUpperLevel())的结构体,初始代码甚至不需要重构(因为消息已经实现了这个行为)。

注释

这是相当明显的,但糟糕的注释写得很糟糕。

另一方面,我尝试像在实际项目中那样注释良好的代码。尽管我不是喜欢每一行都注释的开发者,但我仍然认为至少对每个函数和复杂函数中的主要步骤进行注释是重要的。

举个例子:

// Split each line in a goroutine
for _, line := range in {
    go mapLine(line, ch)
}

msg := Message{}

// Gather the goroutine results
for range in {
    // ...
}

除了函数注释之外,一个具体的例子也可能非常有用:

// Parse a line by returning the header (token name) and the value. 
// Example: -COMMENT TEST must returns COMMENT and TEST (in byte slices)
func parseLine(in []byte) ([]byte, []byte) {
    // ...
}

这样具体的例子可以帮助其他开发人员更好地理解现有项目。

最后但同样重要的是,根据 Go 的最佳实践,包本身也应进行注释。

/*
Package good is a library for parsing the ADEXP messages.
An intermediate format Message is built by the parser.
*/

package good

日志记录

另一个显而易见的例子是糟糕代码中缺乏生成的日志。因为我不是标准日志包的粉丝,所以在这个项目中我使用了一个名为 logrus 的外部库。

go fmt

Go 提供了一套强大的工具,比如 go fmt。不幸的是,我们忘记在糟糕的代码上应用它,而在良好的代码上已经做了。

DDD

领域驱动设计(DDD)引入了普遍语言的概念,强调了在整个项目参与者(业务专家、开发人员、测试人员等)之间使用共享语言的重要性。在这个例子中无法真正衡量这一点,但保持像 Message 这样的简单结构符合领域边界内部使用的语言也是提高整体项目可维护性的一个好方法。

性能结果

在 i7–7700 4x 3.60Ghz 上,我进行了基准测试来比较两个解析器:

  • 糟糕的实现:60430 纳秒/操作
  • 良好的实现:45996 纳秒/操作

糟糕的代码比良好的代码慢了超过30%。

结论

在我看来,很难给出糟糕代码和良好代码的一般定义。在一个上下文中的代码可能被认为是好的,而在另一个上下文中可能被认为是糟糕的。

良好代码的第一个明显特征是根据给定的功能需求提供正确的解决方案。如果代码不符合需求,即使它很高效,也是相当无用的。

同时,对于开发人员来说,关心简单、易维护和高效的代码也很重要。

性能改进并非凭空而来,它伴随着代码复杂性的增加。

一个优秀的开发人员是能够在特定的上下文中找到这些特性之间的平衡的人。

就像在 DDD 中一样,上下文是关键的 😃

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/178183.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Apache POI简介

三十二、Apache POI 32.1 介绍 Apache POI 是一个处理Miscrosoft Office各种文件格式的开源项目。简单来说就是&#xff0c;我们可以使用POI在Java程序中对Miscrosoft Office各种文件进行读写操作。 一般情况下&#xff0c;POI都是用于操作Excel文件。 Apache POI 的应用场…

【开源】基于JAVA的开放实验室管理系统

项目编号&#xff1a; S 013 &#xff0c;文末获取源码。 \color{red}{项目编号&#xff1a;S013&#xff0c;文末获取源码。} 项目编号&#xff1a;S013&#xff0c;文末获取源码。 目录 一、摘要1.1 项目介绍1.2 项目录屏 二、研究内容2.1 实验室类型模块2.2 实验室模块2.3 实…

LeetCode 2304. 网格中的最小路径代价:DP

【LetMeFly】2304.网格中的最小路径代价&#xff1a;DP 力扣题目链接&#xff1a;https://leetcode.cn/problems/minimum-path-cost-in-a-grid/ 给你一个下标从 0 开始的整数矩阵 grid &#xff0c;矩阵大小为 m x n &#xff0c;由从 0 到 m * n - 1 的不同整数组成。你可以…

#gStore-weekly | ​gAnswer源码分析:基于通用数据集的NE、RE服务开发

PART1 简 介 目前基于知识图谱的问答模式有两种&#xff0c;一种是基于信息检索的方式&#xff0c;一种是基于语义分析的方式。前者较之于后者&#xff0c;没有真正关心语义&#xff0c;主要是ranker算法&#xff0c;擅于处理简单问题&#xff0c;后者则是从语义的角度将用户…

数环通入选中国信通院《高质量数字化转型技术方案集(2023)》,积极推动企业数字化转型

近日&#xff0c;中国信息通信研究院“铸基计划”发布《高质量数字化转型技术方案集&#xff08;2023&#xff09;》&#xff0c;数环通《数字化协同管理解决方案》成功入选。 随着科技的快速发展和市场竞争的日益激烈&#xff0c;数字化转型已成为企业持续发展和提升竞争力的关…

JetLinks设备接入的认识与理解【woodwhales.cn】

为了更好的阅读体验&#xff0c;建议移步至笔者的博客阅读&#xff1a;JetLinks设备接入的认识与理解 1、认识 JetLinks 1.1、官网文档 官网&#xff1a;https://www.jetlinks.cn/ JetLinks 有两个产品&#xff1a;JetLinks-lot和JetLinks-view 官方文档&#xff1a; JetLi…

WPF树形控件TreeView使用介绍

WPF 中的 TreeView 控件用于显示层次结构数据。它是由可展开和可折叠的 TreeViewItem 节点组成的&#xff0c;这些节点可以无限嵌套以表示数据的层次。 TreeView 基本用法 例如实现下图的效果&#xff1a; xaml代码如下&#xff1a; <Window x:Class"TreeView01.Mai…

优秀智慧园区案例 - 上海世博文化公园智慧园区,先进智慧园区建设方案经验

一、项目背景 世博文化公园是上海的绿色新地标&#xff0c;是生态自然永续、文化融合创新、市民欢聚共享的大公园。作为世博地区的城市更新项目&#xff0c;世博文化公园的建设关乎上海城市风貌、上海文化展示、城市生态环境、市民游客体验、上海服务品牌等&#xff0c;被赋予…

防火墙部署模式 -- 镜像流量(旁路模式)

镜像流量&#xff08;旁路模式&#xff09; 如图&#xff0c;与单臂路由模式不同&#xff0c;旁路模式中&#xff0c;PC的流量不会流经防火墙&#xff0c;就算防火墙宕机也不会影他们之间的数据传输。 镜像的原理是交换机把被镜像端口的流量复制一份&#xff0c;发到监听端口&…

打不开clickonce问题解决过程

1.用户电脑user文件夹下有xx和xx.1两个账户,原先安装在xx账户下,后修了电脑原数据保留在xx.1,新创建XX,之后clickonce就打不开了表现为没有反应,,删除注册表和appdata都只能正常安装,但是不能打开,没有任何报错,发现在我的电脑下打开有这样的提示,,在用户电脑上没有 尝试通过修…

了解CCC认证流程,确保产品合规通过

CCC认证是指中国强制性产品认证制度&#xff0c;也是中国国家质量监督检验检疫总局实施的一项重要认证制度。对于想要在中国市场销售的产品来说&#xff0c;CCC认证是必不可少的步骤。本文将详细介绍CCC认证的流程&#xff0c;帮助您了解并确保产品顺利通过认证。 第一步&#…

智能监控,高效观测 IT 系统瓶颈

前言 云原生时代的监控系统贯穿于移动端、前端、业务服务端、中间件、应用层、操作系统等&#xff0c;渗透 IT 系统的各个环节。因此&#xff0c;在构建 IT 系统之初&#xff0c;就需要考虑如何打造一个完善的监控系统。当面临大量业务流量数据时&#xff0c;借助监控进行问题…

FreeRTOS列表和列表项

FreeRTOS内核调度使用了大量的列表&#xff08;list&#xff09;和列表项&#xff08;listitem&#xff09;数据结构。它的源码中涉及到很多列表的操作&#xff0c;对于FreeRTOS来说&#xff0c;列表就是它最基础的一部分&#xff0c;列表被用作FreeRTOS调度器使用&#xff0c;…

C语言--判断年月日是否合理

一.题目描述 比如输入2001&#xff0c;2&#xff0c;29&#xff0c;输出&#xff1a; 不合理 。因为平年的二月只有28天 比如输入2000&#xff0c;6&#xff0c;31&#xff0c;输出&#xff1a;不合理。因为6月是小月&#xff0c;只有30天。 二.思路分析 本题主要注意两个问…

Android : ListView + BaseAdapter-2简单应用

​​容器与适配器&#xff1a;​​​​​ http://t.csdnimg.cn/ZfAJ7 实体类 News.java package com.example.mylistviewadapter2.entity;public class News {private String title;private String content;private int img;public News(String title, String conte…

CentOS 7 使用pugixml 库

安装 pugixml Git下载地址&#xff1a;https://github.com/zeux/pugixml 步骤1&#xff1a;首先&#xff0c;你需要下载pugixml 的源代码。你可以从Github或者源代码官方网站下载。并上传至/usr/local/source_code/ 步骤2&#xff1a;下载完成后&#xff0c;需要将源代码解压…

【MySQL】多表查询、子查询、自连接、合并查询详解,包含大量示例,包你会。

复合查询 前言正式开始一些开胃菜多表查询自连接子查询单行子查询多行子查询in关键字all关键字any关键字多列子查询在from中使用子查询 合并查询union 和 union all 前言 我前面博客讲的所有的查询都是在单表中进行的&#xff0c;从这里开始就要专门针对查询这个话题进行进一步…

STM32-标准库和HAL库-不同容量系列的代码移植

使用STM32单片机过程中经常会涉及到不同芯片间的代码转换&#xff0c;手头上熟悉的工程需要稍作处理才能用到新的板子上。常见的是STM32F103xE、STM32F103xC&#xff08;大容量&#xff09;和STM32F103x8、STM32F103xB&#xff08;中容量&#xff09;的转换。这里做一下总结&am…

93.STL-系统内置仿函数

目录 算术仿函数 关系仿函数 逻辑仿函数 C 标准库中提供了一些内置的函数对象&#xff0c;也称为仿函数&#xff0c;它们通常位于 <functional> 头文件中。以下是一些常见的系统内置仿函数&#xff1a; 算术仿函数 功能描述&#xff1a; 实现四则运算其中negate是一元…

PTA-6-45 工厂设计模式-运输工具

题目如下&#xff1a; 工厂类用于根据客户提交的需求生产产品&#xff08;火车、汽车或拖拉机&#xff09;。火车类有两个子类属性&#xff1a;车次和节数。拖拉机类有1个子类方法耕地&#xff0c;方法只需简单输出“拖拉机在耕地”。为了简化程序设计&#xff0c;所有…
最新文章