整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

Go语言有个“好爹”反而被程序员讨厌?

Go语言有个“好爹”反而被程序员讨厌?

o (Golang) 是 Google 开发的一种编译型、并发型,并具有垃圾回收功能的编程语言,于 2009 年 11 月正式宣布推出成为开源项目,2012 年发布 1.0 版本。

如今,谷歌仍在继续投资该语言,最新的稳定版本是 1.22.5。

在最新的 TIOBE 7 月榜单中,Go 排名第七。与其他所有编程语言一样,有人喜欢 Go 语言也有人讨厌,同样的功能既会带来诋毁也会带来赞美。

InfoWorld 撰稿分析了开发人员喜欢或讨厌 Go 语言的 8 个原因,具体如下。

1、易于学习

Go 的设计者特意打造了一种易于学习的语言,没有太多复杂的功能或特异之处。

喜欢的点在于:对于新程序员和团队成员来说,更简单的语言更容易学习和掌握。由于老程序员可以很快学会 Go 的新技巧,因此项目人员配备也更容易。不仅如此,相关代码通常也更容易阅读。

讨厌的点在于:太过简单反而束缚了手脚。“一个女巫会选择一本简略的咒语书吗?四分卫会选择只有几个战术的战术书吗?一些程序员认为,用 Go 编程就像一只手被绑在背后。这种语言缺乏其他语言设计者向世界展示的所有聪明才智,而这是要付出高昂代价的。”

2、Go 不会偏袒任何一方

最初开发人员希望创建一种小型语言,为此他们牺牲了其他语言中许多受欢迎的功能。Go 是一种精简的语言,即可以满足用户的需求,又省去了一些繁琐。

喜欢的点在于:许多开发人员都称赞 Go 的简单性。Go 不需要他们掌握或保持数十种功能的专业知识才能熟练使用。

讨厌的点在于:每个人都有一些喜欢的功能和技巧,但 Go 很可能不提供这些功能和技巧。开发人员有时会抱怨,他们只需用 COBOL 或 Java 或其他喜欢的语言写一行代码,就可以完成在 Go 中可以完成的相同任务。

3、基于 C 的语法

Go 的设计团队确实基于传统 C 语言改进了一些缺陷,并简化了一些细节,使其看起来和感觉更现代。但在大多数情况下,Go 完全继承了始于 C 语言的传统。

喜欢的点在于:在 C 语言风格中成长起来的程序员会直观地理解 Go 的大部分内容。他们将能够非常快速地学习语法,并且可以花时间学习 Go 相较 C 或 Java 的一些改进之处。

讨厌的点在于:很多方面,Python 的设计都是与 C 截然相反的。对于喜欢 Python 方法的人而言,会觉得 Go 有很多让人讨厌的地方。

4、Go 的规则太多了

从一开始,Go 的创建者就希望不仅定义语法,还定义语言的大部分风格和使用模式。

喜欢的点在于:Go 的强惯用规则确保代码更容易理解,团队将减少对风格的争论。

讨厌的点在于:所有这些额外的规则和惯例都像束缚。“程序员在生活中拥有一点自由有那么糟糕吗?”

5、Go 有额外的错误处理

喜欢的点在于:Go 方法承认错误存在,并鼓励程序员制定处理错误的计划。这就鼓励程序员提前计划,并建立起一种弹性,从而开发出更好的软件。

讨厌的点在于:不必要的错误处理会让 Go 函数变得更长、更难理解。通常情况下,deep chain 中的每个函数都必须包含类似的代码,这些代码或多或少会执行相同的操作,并产生相同的错误。其他语言(如 Java 或 Python)鼓励程序员将错误 "throw" 到链上的特定代码块中,以 "catch" 它们,从而使代码更简洁。

6、标准库

喜欢的点在于:当许多标准功能由默认库处理时,大多数代码更易于阅读。因为没有人会编写自己的版本,或争论哪个软件包或第三方库更好。

讨厌的点在于:一些人认为,竞争能更好的推动需求和创新。有些语言支持多个软件包来处理相同的任务,表明大家对此确实有着浓厚的兴趣和丰富的文化。

7、可执行文件的大小

Go 团队的目标之一是让部署 Go 程序变得更容易,他们通过将所有程序打包成一个可执行文件来实现这一目标。

喜欢的点在于:磁盘空间很便宜。当安装了不同版本的库时,在陌生的位置部署代码可能是一场噩梦。Go 开发人员只需构建一个可执行文件就可以节省大量时间。

讨厌的点在于:我的磁盘上有多少份 Go 库?如果我有 100 个程序,那就意味着 100 份。在某种程度上,效率是一个考虑因素。没错,磁盘空间比以往任何时候都便宜,但内存带宽和缓存仍然是影响执行速度的首要问题。

8、背靠“好爹”谷歌

Go 由谷歌开发,这家大公司一直是 Go 的主要支持者之一。大多数情况下,Go 开发工作都直接来自 Google 内部。

喜欢的点在于:如今,大量的工作涉及为服务器和客户端编写代码,而这类工作在 Google 的工作量中占了很大一部分。如果 Go 对谷歌有利,那么对我们这些以同样方式工作的人也有好处。如果谷歌的工程师们能开发出自己喜欢的东西,那么任何有类似项目的人都会同样喜欢它。

讨厌的点在于:这并不是说人们不喜欢谷歌本身,而是程序员不信任中心化组织、供应商锁定和缺乏控制等问题,对任何试图管理技术堆栈的人来说都是严重的问题。谷歌的慷慨仍然让程序员们心存疑虑,尤其是当其他语言都拥有了围绕它们构建的庞大的开源社区。


Reference

https://www.infoworld.com/article/2514123/8-reasons-developers-love-go-and-8-reasons-they-dont.html

[1]Mateusz Piorowski[2] - 2023.07.24

先来了解一下我的背景吧。我是一名软件开发人员,有大约十年的工作经验,最初使用 PHP,后来逐渐转向 JavaScript。

大约五年前,我开始使用 TypeScript,从那时起,我就再也没有使用过 JavaScript。从开始使用 TypeScript 的那一刻起,我就认为它是有史以来最好的编程语言。每个人都喜欢它,每个人都在使用它……它就是最好的,对吗?对吧?对不对?

是的,然后我开始接触其他的语言,更现代化的语言。首先是 Go,然后我慢慢地把 Rust 也加了进来。

当你不知道存在不同的事物时,就很难错过它们。

我在说什么?Go 和 Rust 的共同点是什么?Error,这是最让我印象深刻的一点。更具体地说,这些语言是如何处理错误的。

JavaScript 依靠抛出异常来处理错误,而 Go 和 Rust 则将错误视为值。你可能会觉得这没什么大不了的......但是,好家伙,这听起来似乎微不足道;然而,它却改变了游戏规则。

让我们来了解一下它们。我们不会深入研究每种语言,只是想了解一般的处理方式。

让我们从 JavaScript/TypeScript 和一个小游戏开始。

给自己五秒钟的时间来查看下面的代码,并回答为什么我们需要用 try/catch 来包裹它。

try {
  const request={ name: “test”, value: 2n };
  const body=JSON.stringify(request);
  const response=await fetch("https://example.com", {
    method: “POST”,
    body,
  });
  if (!response.ok) {
    return;
  }
  // 处理响应
} catch (e) {
  // 处理错误
  return;
}

那么,我想你们大多数人都猜到了,尽管我们检查了 response.ok,但 fetch 方法仍然可能抛出一个异常。response.ok 只能“捕获” 4xx 和 5xx 的网络错误。但是,当网络本身失败时,它会抛出一个异常。

但我不知道有多少人猜到 JSON.stringify 也会抛出一个异常。原因是请求对象包含 bigint (2n) 变量,而 JSON 不知道如何将其序列化为字符串。

所以,第一个问题是,我个人认为这是 JavaScript 最大的问题:我们不知道什么可能会抛出一个异常。从 JavaScript 错误的角度来看,它与下面的情况是一样的:

try {
  let data=“Hello”;
} catch (err) {
  console.error(err);
}

JavaScript 不知道;JavaScript 也不在乎。你应该知道。

第二个问题,这是完全可行的代码:

const request={ name: “test”, value: 2n };
const body=JSON.stringify(request);
const response=await fetch("https://example.com", {
  method: “POST”,
  body,
});
if (!response.ok) {
  return;
}

没有错误,没有语法检查,尽管这可能会导致你的应用程序崩溃。

现在,在我的脑海中,我听到的是:“有什么问题,在任何地方使用 try/catch 就可以了”。这就引出了第三个问题:我们不知道哪个异常被抛出。当然,我们可以通过错误信息来猜测,但对于规模较大、可能发生错误的地方较多的服务/函数来说,又该怎么办呢?你确定用一个 try/catch 就能正确处理所有错误吗?

好了,是时候停止对 JS 的挑剔,转而讨论其他问题了。让我们从这段 Go 代码开始:

f, err :=os.Open(“filename.ext”)
if err !=nil {
  log.Fatal(err)
}
// 对打开的 *File f 进行一些操作

我们正在尝试打开一个返回文件或错误的文件。你会经常看到这种情况,主要是因为我们知道哪些函数总是返回错误,你绝不会错过任何一个。这是第一个将错误视为值的例子。你可以指定哪个函数可以返回错误值,然后返回错误值,分配错误值,检查错误值,处理错误值。

这也是 Go 被诟病的地方之一——“错误检查代码”,其中 if err !=nil { … 有时候的代码行数比其他部分还要多。

if err !=nil {
  …
  if err !=nil {
    …
    if err !=nil {
      …
    }
  }
}
if err !=nil {
  …
}
…
if err !=nil {
  …
}

尽管如此,相信我,这些努力还是值得的。

最后,让我们看看 Rust:

let greeting_file_result=File::open(“hello.txt”);
let greeting_file=match greeting_file_result {
  Ok(file)=> file,
  Err(error)=> panic!("Problem opening the file: {:?}", error),
};

这里显示的是三种错误处理中最冗长的一种,具有讽刺意味的是,它也是最好的一种。首先,Rust 使用其神奇的枚举(它们与 TypeScript 的枚举不同!)来处理错误。这里无需赘述,重要的是它使用了一个名为 Result 的枚举,有两个变量:OkErr。你可能已经猜到,Ok 包含一个值,而 Err 包含……没错,一个错误 :D。

它也有很多更方便的处理方式来缓解 Go 的问题。最知名的一个是 ? 操作符。

let greeting_file_result=File::open(“hello.txt")?;

这里的总结是,Go 和 Rust 总是知道哪里可能会出错。它们强迫你在错误出现的地方(大部分情况下)立即处理它。没有隐藏的错误,不需要猜测,也不会因为意外的错误而导致应用程序崩溃。

而这种方法就是更好,好得多。

好了,是时候实话实说了;我撒了点小谎。我们无法让 TypeScript 的错误像 Go / Rust 那样工作。限制因素在于语言本身,它没有合适的工具来做到这一点。

但我们能做的就是尽量使其相似。并且让它变得简单。

从这里开始:

exporttype Safe<T>=| {
          success: true;
          data: T;
      }
    | {
          success: false;
          error: string;
      };

这里没有什么花哨的东西,只是一个简单的通用类型。但这个小东西却能彻底改变代码。你可能会注意到,这里最大的不同就是我们要么返回数据,要么返回错误。听起来熟悉吗?

另外......第二个谎言是,我们确实需要一些 try/catch。好在我们只需要两个,而不是十万个。

exportfunction safe<T>(promise: Promise<T>, err?: string): Promise<Safe<T>>;
exportfunction safe<T>(func: ()=> T, err?: string): Safe<T>;
exportfunction safe<T>(
    promiseOrFunc: Promise<T> | (()=> T),
    err?: string
): Promise<Safe<T>> | Safe<T> {
    if (promiseOrFunc instanceofPromise) {
        return safeAsync(promiseOrFunc, err);
    }
    return safeSync(promiseOrFunc, err);
}

asyncfunction safeAsync<T>(
    promise: Promise<T>,
    err?: string
): Promise<Safe<T>> {
    try {
        const data=await promise;
        return { data, success: true };
    } catch (e) {
        console.error(e);
        if (err !==undefined) {
            return { success: false, error: err };
        }
        if (e instanceofError) {
            return { success: false, error: e.message };
        }
        return { success: false, error: "Something went wrong" };
    }
}

function safeSync<T>(func: ()=> T, err?: string): Safe<T> {
    try {
        const data=func();
        return { data, success: true };
    } catch (e) {
        console.error(e);
        if (err !==undefined) {
            return { success: false, error: err };
        }
        if (e instanceofError) {
            return { success: false, error: e.message };
        }
        return { success: false, error: "Something went wrong" };
    }
}

“哇,真是个天才。他为 try/catch 创建了一个包装器。” 是的,你说得没错;这只是一个包装器,我们的 Safe 类型作为返回类型。但有时候,简单的东西就是你所需要的。让我们将它们与上面的例子结合起来。

旧的(16 行)示例:

try {
  const request={ name: “test”, value: 2n };
  const body=JSON.stringify(request);
  const response=await fetch("https://example.com", {
    method: “POST”,
    body,
  });
  if (!response.ok) {
    // 处理网络错误
    return;
  }
  // 处理响应
} catch (e) {
  // 处理错误
  return;
}

新的(20 行)示例:

const request={ name: “test”, value: 2n };
const body=safe(
  ()=>JSON.stringify(request),
  “Failed to serialize request”,
);
if (!body.success) {
  // 处理错误(body.error)
  return;
}
const response=await safe(
  fetch("https://example.com", {
    method: “POST”,
    body: body.data,
  }),
);
if (!response.success) {
  // 处理错误(response.error)
  return;
}
if (!response.data.ok) {
  // 处理网络错误
  return;
}
// 处理响应(body.data)

是的,我们的新解决方案更长,但性能更好,原因如下:

  • 没有 try/catch
  • 我们在错误发生的地方处理每个错误
  • 我们可以为特定函数指定一个错误信息
  • 我们有一个很好的自上而下的逻辑,所有错误都在顶部,然后底部只有响应

但现在王牌来了,如果我们忘记检查这个:

if (!body.success) {
    // 处理错误 (body.error)
    return;
}

事实是……我们不能忘记。是的,我们必须进行这个检查。如果我们不这样做,body.data 将不存在。LSP 会通过抛出 “Property ‘data’ does not exist on type ‘Safe’” 错误来提醒我们。这都要归功于我们创建的简单的 Safe 类型。它同样适用于错误信息,我们在检查 !body.success 之前无法访问 body.error

这是我们应该欣赏 TypeScript 以及它如何改变 JavaScript 世界的时刻。

以下也同样适用:

if (!response.success) {
    // 处理错误 (response.error)
    return;
}

我们不能移除 !response.success 检查,否则,response.data 将不存在。

当然,我们的解决方案也不是没有问题。最大的问题是你必须记住要用我们的 safe 包装器包装可能抛出异常的 Promise/函数。这个 “我们需要知道” 是我们无法克服的语言限制。

这听起来很难,但其实并不难。你很快就会意识到,你代码中的几乎所有 Promises 都会出错,而那些会出错的同步函数你也知道,而且它们的数量并不多。

不过,你可能会问,这样做值得吗?我们认为值得,而且在我们团队中运行得非常好:)。当你看到一个更大的服务文件,没有任何 try/catch,每个错误都在出现的地方得到了处理,逻辑流畅......它看起来就很不错。

这是一个使用 SvelteKit FormAction 的真实例子:

exportconst actions={
  createEmail: async ({ locals, request })=> {
    const end=perf(“CreateEmail”);
    const form=await safe(request.formData());
    if (!form.success) {
      return fail(400, { error: form.error });
    }
    const schema=z
      .object({
        emailTo: z.string().email(),
        emailName: z.string().min(1),
        emailSubject: z.string().min(1),
        emailHtml: z.string().min(1),
      })
    .safeParse({
      emailTo: form.data.get("emailTo"),
      emailName: form.data.get("emailName"),
      emailSubject: form.data.get("emailSubject"),
      emailHtml: form.data.get("emailHtml"),
    });
    if (!schema.success) {
      console.error(schema.error.flatten());
      return fail(400, { form: schema.error.flatten().fieldErrors });
    }
    const metadata=createMetadata(URI_GRPC, locals.user.key)
    if (!metadata.success) {
      return fail(400, { error: metadata.error });
    }
    const response=awaitnewPromise<Safe<Email__Output>>((res)=> {
      usersClient.createEmail(schema.data, metadata.data, grpcSafe(res));
    });
    if (!response.success) {
      return fail(400, { error: response.error });
    }
    end();
    return {
      email: response.data,
    };
  },
} satisfies Actions;

这里有几点需要指出:

  • 我们的自定义函数 grpcSafe 可以帮助我们处理 gGRPC 回调。
  • createMetadata 内部返回 Safe,因此我们不需要对其进行封装。
  • zod 库使用相同的模式 :) 如果我们不进行 schema.success 检查,我们就无法访问 schema.data

看起来是不是很简洁?那就试试吧!也许它也非常适合你 :)

感谢阅读。

附注:下面的代码对比是不是看起来很像?

f, err :=os.Open(“filename.ext”)
if err !=nil {
  log.Fatal(err)
}
// 使用打开的 *File f 做一些事情
const response=await safe(fetch(“https://example.com"));
if (!response.success) {
  console.error(response.error);
  return;
}
// 使用 response.data 做一些事情

参考资料

[1] 原文: https://betterprogramming.pub/typescript-with-go-rust-errors-no-try-catch-heresy-da0e43ce5f78

[2] Mateusz Piorowski: https://medium.com/@mateuszpiorowski

一篇文章Go设计模式(2)-面向对象分析与设计里讲过,做设计最重要的是保留合适的扩展点。如何才能设计出合适的扩展点呢?

这篇文章会讲解一下经典的设计原则。这些设计原则大家可能都听过,但可能没有想过为什么会提炼出这些原则,它们有什么作用。对内一个设计原则,我会尽量找到一个实例,说明它的重要性。通过实例来感受原则,比起只看枯燥的文字有效的多。

在这里需要说明一点,设计原则是一种思想,设计模式是这种思想的具象化。所以当我们真正领悟到这种思想后,设计的时候会事半功倍。

本文要阐述的原则如下:

  1. 单一职责原则
  2. 开放-封闭原则
  3. 里氏替换原则
  4. 接口隔离原则
  5. 依赖倒转原则
  6. 迪米特法则

单一职责原则

理解原则

单一职责原则(SRP):一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。

实施

不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:

  • 类中的代码行数、函数或者属性过多;
  • 类依赖的其他类过多,或者依赖类的其他类过多;
  • 私有方法过多;
  • 比较难给类起一个合适的名字;
  • 类中大量的方法都是集中操作类中的某几个属性。

实例

假设我们要做一个在手机上玩的俄罗斯方块游戏,Game类可以设计如下:

type Game struct {
   x int64
   y int64
}
func (game *Game) Show() {
   fmt.Println(game.x, game.y)
}
func (game *Game) Move() {
   game.x--
   game.y++
}

游戏的显示和移动都放在类Game里。后面需求变更了,不但要在手机上显示,还需要在电脑上显示,而且还有两人对战模式,这些更改主要和显示有关。

这时最好将Show和Move拆分到两个函数,这样不但可以复用Move的逻辑,而且今后无论如何更改Show,都不会影响Move所在的类。

但因为一开始Game职责不单一,整个系统中很多位置使用同一个Game变量调用Show和Move,对这些位置的改动和测试是十分浪费时间的。

开放-封闭原则

理解原则

对扩展开放、修改关闭(OCP):添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。

  • 第一点,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。
  • 第二点,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。

实施

我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。

很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是23种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。

实例

假设我们要做一个API接口监控告警,如果TPS或Error超过指定值,则根据不同的紧急情况通过不同方式(邮箱、电话)通知相关人员。根据Go设计模式(2)-面向对象分析与设计里讲的方案,我们先找出类。

业务实现流程为:

  1. 获取异常指标
  2. 获取异常数据,和异常指标进行比较
  3. 通知相关人员

所以,我们可以设置三个类,AlertRules存放报警规则,Notification用来通知,Alert用来比较。

//存储报警规则
type AlertRules struct {
}

func (alertRules *AlertRules) GetMaxTPS(api string) int64 {
   if api=="test" {
      return 10
   }
   return 100
}
func (alertRules *AlertRules) GetMaxError(api string) int64 {
   if api=="test" {
      return 10
   }
   return 100
}

const (
   SERVRE="SERVRE"
   URGENT="URGENT"
)

//通知类
type Notification struct {
}

func (notification *Notification) Notify(notifyLevel string) bool {
   if notifyLevel==SERVRE {
      fmt.Println("打电话")
   } else if notifyLevel==URGENT {
      fmt.Println("发短信")
   } else {
      fmt.Println("发邮件")
   }
   return true
}

//检查类
type Alert struct {
   alertRules   *AlertRules
   notification *Notification
}

func CreateAlert(a *AlertRules, n *Notification) *Alert {
   return &Alert{
      alertRules:   a,
      notification: n,
   }
}
func (alert *Alert) Check(api string, tps int64, errCount int64) bool {
   if tps > alert.alertRules.GetMaxTPS(api) {
      alert.notification.Notify(URGENT)
   }
   if errCount > alert.alertRules.GetMaxError(api) {
      alert.notification.Notify(SERVRE)
   }
   return true
}
func main() {
   alert :=CreateAlert(new(AlertRules), new(Notification))
   alert.Check("test", 20, 20)
}

虽然程序比较简陋,但是是面向对象的,而且能跑。

对于这个需求,有很多可能的变动点,最可能变的是增加新的报警指标。现在新需求来了,如果每秒内接口超时量超过指定值,也需要报警,我们需要怎么做?

如果在原有代码上修改,我们需要

  1. AlertRules上添加新的规则
  2. Check函数增加新的入参timeoutCount
  3. Check函数中增加新的判断逻辑if timeoutCount > alert.alertRules.GetMaxTimeoutCount(api) {
    alert.notification.Notify(SERVRE)
    }

这会导致一些问题,一是Check可能在多个地方被引用,所以这些位置都需要进行修改,二是更改了Check逻辑,需要重新做这部分的测试。如果说我们做第一版没有预料到这些变化,但现在我们找到了可能的变更点,我们是否有好的方案能够做好扩展,让下次改动量最小?

我们把Alert中Check做的事情拆散,放到对应的类里,这些类都实现了AlertHandler接口。

//优化
type ApiStatInfo struct {
   api          string
   tps          int64
   errCount     int64
   timeoutCount int64
}

type AlertHandler interface {
   Check(apiStatInfo ApiStatInfo) bool
}

type TPSAlertHandler struct {
   alertRules   *AlertRules
   notification *Notification
}

func CreateTPSAlertHandler(a *AlertRules, n *Notification) *TPSAlertHandler {
   return &TPSAlertHandler{
      alertRules:   a,
      notification: n,
   }
}

func (tPSAlertHandler *TPSAlertHandler) Check(apiStatInfo ApiStatInfo) bool {
   if apiStatInfo.tps > tPSAlertHandler.alertRules.GetMaxTPS(apiStatInfo.api) {
      tPSAlertHandler.notification.Notify(URGENT)
   }
   return true
}

type ErrAlertHandler struct {
   alertRules   *AlertRules
   notification *Notification
}

func CreateErrAlertHandler(a *AlertRules, n *Notification) *ErrAlertHandler {
   return &ErrAlertHandler{
      alertRules:   a,
      notification: n,
   }
}

func (errAlertHandler *ErrAlertHandler) Check(apiStatInfo ApiStatInfo) bool {
   if apiStatInfo.errCount > errAlertHandler.alertRules.GetMaxError(apiStatInfo.api) {
      errAlertHandler.notification.Notify(SERVRE)
   }
   return true
}

type TimeOutAlertHandler struct {
   alertRules   *AlertRules
   notification *Notification
}

func CreateTimeOutAlertHandler(a *AlertRules, n *Notification) *TimeOutAlertHandler {
   return &TimeOutAlertHandler{
      alertRules:   a,
      notification: n,
   }
}

func (timeOutAlertHandler *TimeOutAlertHandler) Check(apiStatInfo ApiStatInfo) bool {
   if apiStatInfo.timeoutCount > timeOutAlertHandler.alertRules.GetMaxTimeOut(apiStatInfo.api) {
      timeOutAlertHandler.notification.Notify(SERVRE)
   }
   return true
}

Alert类增加成员变量handlers []AlertHandler,并添加如下函数

//版本2
func (alert *Alert) AddHanler(alertHandler AlertHandler) {
   alert.handlers=append(alert.handlers, alertHandler)
}
func (alert *Alert) CheckNew(apiStatInfo ApiStatInfo) bool {
   for _, h :=range alert.handlers {
      h.Check(apiStatInfo)
   }
   return true
}

调用方式如下:

func main() {
   alert :=CreateAlert(new(AlertRules), new(Notification))
   alert.Check("test", 20, 20)
   //版本2,alert其实已经不需要有成员变量AlertRules和Notification了
   a :=new(AlertRules)
   n :=new(Notification)
   alert.AddHanler(CreateTPSAlertHandler(a, n))
   alert.AddHanler(CreateErrAlertHandler(a, n))
   alert.AddHanler(CreateTimeOutAlertHandler(a, n))
   apiStatInfo :=ApiStatInfo{
      api:          "test",
      timeoutCount: 20,
      errCount:     20,
      tps:          20,
   }
   alert.CheckNew(apiStatInfo)
}

这样今后无论增加多少报警指标,只需要创建新的Handler类,放入到alert中即可。代码改动量极小,而且不需要重复测试。

系统还有许多改动点,大家可以自己尝试去改动一下,所有代码位置:https://github.com/shidawuhen/asap/blob/master/controller/design/3principle.go

里式替换原则

理解原则

里氏替换原则(LSP):子类对象能够替换程序(program)中父类对象出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

多态与里氏替换原则的区别:多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。

实施

里式替换原则不仅仅是说子类可以替换父类,它有更深层的含义。

子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。所以我们可以通过几个点判断是否违反里氏替换原则:

  • 子类违背父类声明要实现的功能:如排序函数,父类按照金额排序,子类按照时间排序
  • 子类违背父类对输入、输出、异常的约定
  • 子类违背父类注释中所罗列的任何特殊说明

实例

里氏替换原则可以提高代码可扩展性。假设我们需要做一个发送信息的功能,最初只需要发送站内信。

type Message struct {
}
func (message *Message) Send() {
   fmt.Println("message send")
}
func LetDo(notify *Message) {
  notify.Send()
}
func main() {
  LetDo(new(Message))
}

实现完成后,许多地方都调用LetDo发送信息。后面想用SMS替换站内信,处理起来就很麻烦了。所以最好的方案是使用里氏替换原则,丝毫不影响新的通知方法接入。

//里氏替换原则
type Notify interface {
  Send()
}
type Message struct {
}

func (message *Message) Send() {
  fmt.Println("message send")
}

type SMS struct {
}

func (sms *SMS) Send() {
  fmt.Println("sms send")
}

func LetDo(notify Notify) {
  notify.Send()
}

func main() {
  //里氏替换原则
  LetDo(new(Message))
}

接口隔离原则

理解原则

接口隔离原则(ISP):客户端不应该强迫依赖它不需要的接口

接口隔离原则与单一职责原则的区别:单一职责原则针对的是模块、类、接口的设计。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

实施

如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。如果把“接口”理解为单个API接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。如果把“接口”理解为OOP中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。

实例

假设项目用到三个外部系统:Redis、MySQL、Kafka。其中Redis和Kafaka支持配置热更新。MySQL和Redis有显示监控功能。对于这个需求,我们需要怎么设计接口?

一种方式是将所有功能放到一个接口中,另一种方式是将这两个功能放到不同的接口中。下面的代码按照接口隔离原则编写:

//接口隔离原则
type Updater interface {
   Update() bool
}

type Shower interface {
   Show() string
}

type RedisConfig struct {
}

func (redisConfig *RedisConfig) Connect() {
   fmt.Println("I am Redis")
}

func (redisConfig *RedisConfig) Update() bool {
   fmt.Println("Redis Update")
   return true
}

func (redisConfig *RedisConfig) Show() string {
   fmt.Println("Redis Show")
   return "Redis Show"
}

type MySQLConfig struct {
}

func (mySQLConfig *MySQLConfig) Connect() {
   fmt.Println("I am MySQL")
}

func (mySQLConfig *MySQLConfig) Show() string {
   fmt.Println("MySQL Show")
   return "MySQL Show"
}

type KafkaConfig struct {
}

func (kafkaConfig *KafkaConfig) Connect() {
   fmt.Println("I am Kafka")
}

func (kafkaConfig *KafkaConfig) Update() bool {
   fmt.Println("Kafka Update")
   return true
}

func ScheduleUpdater(updater Updater) bool {
   return updater.Update()
}
func ServerShow(shower Shower) string {
   return shower.Show()
}

func main() {
   //接口隔离原则
   fmt.Println("接口隔离原则")
   ScheduleUpdater(new(RedisConfig))
   ScheduleUpdater(new(KafkaConfig))
   ServerShow(new(RedisConfig))
   ServerShow(new(MySQLConfig))
}

这种方案比起将Update和Show放在一个interface中有如下好处:

  1. 不需要做无用功。MySQL不需要写热更新函数,Kafka不需要写监控显示函数
  2. 复用性、扩展性好。如果接入新的系统,只需要监控显示函数,只需要实现Shower接口,就能复用ServerShow的功能。

依赖倒转原则

理解原则

依赖倒转原则(DIP):高层模块不要依赖低层模块。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。

实施

在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。核心思想是:要面向接口编程,不要面向实现编程。

实践

这个可以直接用里式替换中的例子来讲解。LetDo就使用了依赖倒转原则,提高了代码的扩展性,可以灵活地替换依赖的类。

迪米特法则

理解原则

迪米特法则(LOD):不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口

实施

迪米特法则主要用来实现高内聚低耦合。

高内聚:就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中

松耦合:在代码中,类与类之间的依赖关系简单清晰

减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。

实践

假设我们要做一个搜索引擎爬取网页的功能,功能点为

  1. 发起请求
  2. 下载网页
  3. 分析网页

所以我们设置三个类NetworkTransporter负责底层网络、用于获取数据,HtmlDownloader下载网页,Document用于分析网页。下面是符合迪米特法则的代码

//迪米特法则
type Transporter interface {
   Send(address string, data string) bool
}
type NetworkTransporter struct {
}

func (networkTransporter *NetworkTransporter) Send(address string, data string) bool {
   fmt.Println("NetworkTransporter Send")
   return true
}

type HtmlDownloader struct {
   transPorter Transporter
}

func CreateHtmlDownloader(t Transporter) *HtmlDownloader {
   return &HtmlDownloader{transPorter: t}
}

func (htmlDownloader *HtmlDownloader) DownloadHtml() string {
   htmlDownloader.transPorter.Send("123", "test")
   return "htmDownloader"
}

type Document struct {
   html string
}

func (document *Document) SetHtml(html string) {
   document.html=html
}

func (document *Document) Analyse() {
   fmt.Println("document analyse " + document.html)
}

func main() {
   //迪米特法则
   fmt.Println("迪米特法则")
   htmlDownloader :=CreateHtmlDownloader(new(NetworkTransporter))
   html :=htmlDownloader.DownloadHtml()
   doc :=new(Document)
   doc.SetHtml(html)
   doc.Analyse()
}

这种写法可以对应迪米特法则的两部分

  1. 不该有直接依赖关系的类之间,不要有依赖。Document不需要依赖HtmlDownloader,Document作用是分析网页,怎么得到网页是不需要关心的。这样做的好处是无论HtmlDownloader怎么变动,Document都不需要关心。
  2. 有依赖关系的类之间,尽量只依赖必要的接口。HtmlDownloader下载网页必须依赖NetworkTransporter,此处使用接口是为将来如果有更好的底层网络功能,可以迅速替换。当然,此处有点过渡设计的感觉,主要为了契合一下迪米特法则。具体是否需要这么设计,还是根据具体情况来判断。

总结

终于写完了这6个原则,不过对我的好处也很明显,重新梳理知识结构,对原则的理解也更深了一步。宏观上看,这些原则都是为了实现可复用、可扩展、高内聚、低耦合的目的。现在大家在掌握了Go面向对象语法、如何做面向对象分析与设计、面向对象设计原则的基础上,可以做一些面向对象的事情了。

原则是道,设计模式是术,后面会写一些设计模式相关的内容。

本文所有代码位置为:https://github.com/shidawuhen/asap/blob/master/controller/design/3principle.go

资料

  1. 设计模式-golang实现之七大设计原则https://blog.csdn.net/liuyonglun/article/details/103768269
  2. 设计模式之美https://time.geekbang.org/column/intro/100039001

最后

大家如果喜欢我的文章,可以关注我的公众号(程序员麻辣烫)

我的个人博客为:https://shidawuhen.github.io/


技术

  1. Go设计模式(2)-面向对象分析与设计
  2. 支付接入常规问题
  3. HTTP2.0基础教程
  4. Go设计模式(1)
  5. MySQL开发规范
  6. HTTPS配置实战
  7. Go通道实现原理
  8. Go定时器实现原理
  9. HTTPS连接过程
  10. 限流实现2
  11. 秒杀系统
  12. 分布式系统与一致性协议
  13. 微服务之服务框架和注册中心
  14. Beego框架使用
  15. 浅谈微服务
  16. TCP性能优化
  17. 限流实现1
  18. Redis实现分布式锁
  19. Golang源码BUG追查
  20. 事务原子性、一致性、持久性的实现原理
  21. CDN请求过程详解
  22. 常用缓存技巧
  23. 如何高效对接第三方支付
  24. Gin框架简洁版
  25. InnoDB锁与事务简析
  26. 算法总结

读书笔记

  1. 原则
  2. 资治通鉴
  3. 敏捷革命
  4. 如何锻炼自己的记忆力
  5. 简单的逻辑学-读后感
  6. 热风-读后感
  7. 论语-读后感
  8. 孙子兵法-读后感

思考

  1. 服务端团队假期值班方案
  2. 项目流程管理
  3. 对项目管理的一些看法
  4. 对产品经理的一些思考
  5. 关于程序员职业发展的思考
  6. 关于代码review的思考
  7. Markdown编辑器推荐-typora