依赖注入与控制反转:优化Go语言REST API客户端

TimLiuDream · · 276 次点击 · 开始浏览    置顶

> 关注公众号【爱发白日梦的后端】分享技术干货、读书笔记、开源项目、实战经验、高效开发工具等,您的关注将是我的更新动力! 在这篇文章中,我将探讨依赖注入(DI)和控制反转(IoC)是什么,以及它们的重要性。作为示例,我将使用Monibot的REST API客户端。让我们开始吧: ### 一个简单的客户端实现 我们从一个简单的客户端实现开始,允许调用者访问Monibot的REST API,具体来说,是为了发送指标值。客户端的实现可能如下所示: ```go package monibot type Client struct { } func NewClient() *Client { return &Client{} } func (c *Client) PostMetricValue(value int) { body := fmt.Sprintf("value=%d", value) http.Post("https://monibot.io/api/metric", []byte(body)) } ``` 这里有一个客户端,提供了`PostMetricValue`方法,该方法用于将指标值上传到Monibot。我们的库的用户可能像这样使用它: ```go import "monibot" func main() { // 初始化API客户端 client := monibot.NewClient() // 发送指标值 client.PostMetricValue(42) } ``` ### 依赖注入 现在假设我们想对客户端进行单元测试。当所有HTTP发送代码都是硬编码的时候,我们如何测试客户端呢?对于每次测试运行,我们都需要一个“真实”的HTTP服务器来回答我们发送给它的所有请求。不可取!我们可以做得更好:让我们将HTTP处理作为“依赖”;让我们发明一个 `Transport` 接口: ```go package monibot // Transport传输请求。 type Transport interface { Post(url string, body []byte) } ``` 让我们再发明一个具体的使用HTTP作为通信协议的`Transport`: ```go package monibot // HTTPTransport是一个使用HTTP协议传输请求的Transport。 type HTTPTransport struct { } func (t HTTPTransport) Post(url string, data []byte) { http.Post(url, data) } ``` 然后让我们重写客户端,使其“依赖”于一个`Transport` 接口: ```go package monibot type Client struct { transport Transport } func NewClient(transport Transport) *Client { return &Client{transport} } func (c *Client) PostMetricValue(value int) { body := fmt.Sprintf("value=%d", value) c.transport.Post("https://monibot.io/api/metric", []byte(body)) } ``` 现在,客户端将请求转发到它的Transport依赖。当创建客户端时,`transport`(客户端的依赖项)被“注入”到客户端中。调用者可以这样初始化一个客户端: ```go import "monibot" func main() { // 初始化API客户端 var transport monibot.HTTPTransport client := monibot.NewClient(transport) // 发送指标值 client.PostMetricValue(42) } ``` ### 单元测试 现在我们可以编写一个使用“伪造”Transport的单元测试: ```go // TestPostMetricValue确保客户端向REST API发送正确的POST请求。 func TestPostMetricValue(t *testing.T) { transport := &fakeTransport{} client := NewClient(transport) client.PostMetricValue(42) if len(transport.calls) != 1 { t.Fatal("期望1次传输调用,但是是%d次", len(transport.calls)) } if transport.calls[0] != "POST https://monibot.io/api/metric, body=\\"value=42\\"" { t.Fatal("错误的传输调用 %q", transport.calls[0]) } } // 伪造的Transport是单元测试中使用的Transport。 type fakeTransport struct { calls []string } func (f *fakeTransport) Post(url string, body []byte) { f.calls = append(f.calls, fmt.Sprintf("POST %v, body=%q", url, string(body))) } ``` ### 添加更多的Transport函数 现在假设我们库的其他部分,也使用了`Transport`功能,需要比POST更多的HTTP方法。对于它们,我们必须扩展我们的`Transport`接口: ```go package monibot // Transport传输请求。 type Transport interface { Get(url string) []byte // 添加,因为health-monitor需要 Post(url string, body []byte) Delete(url string) // 添加,因为resource-monitor需要 } ``` 现在我们有一个问题。编译器抱怨我们的`fakeTransport`不再满足`Transport`接口。所以让我们通过添加缺失的函数来解决它: ```go // 伪造的Transport是单元测试中使用的Transport。 type fakeTransport struct { calls []string } func (f *fakeTransport) Get(url string) []byte { panic("不使用") } func (f *fakeTransport) Post(url string, body []byte) { f.calls = append(f.calls, fmt.Sprintf("POST %v, body=%q", url, string(body))) } func (f *fakeTransport) Delete(url string) { panic("不使用") } ``` 我们做了什么?由于在单元测试中我们不需要新的`Get()`和`Delete()`函数,如果它们被调用,我们就抛出异常。这里有一个问题:每次在`Transport`中添加新函数时,我们都会破坏现有的`fakeTransport`实现。对于大型代码库来说,这将导致维护噩梦。我们能做得更好吗? ### 控制反转 问题在于我们的客户端(和相应的单元测试)依赖于一个它们不能控制的类型。在这种情况下,它是`Transport`接口。为了解决这个问题,让我们通过引入一个未导出的接口,该接口仅声明了我们的客户端所需的内容,来反转控制: ```go package monibot // clientTransport传输Client的请求。 type clientTransport interface { Post(url string, body []byte) } type Client struct { transport clientTransport } func NewClient(transport clientTransport) *Client { return &Client{transport} } func (c *Client) PostMetricValue(value int) { body := fmt.Sprintf("value=%d", value) c.transport.Post("https://monibot.io/api/metric", []byte(body)) } ``` 现在让我们将我们的单元测试更改为使用假的`clientTransport`: ```go // TestPostMetricValue确保客户端向REST API发送正确的POST请求。 func TestPostMetricValue(t *testing.T) { transport := &fakeTransport{} client := NewClient(transport) client.PostMetricValue(42) if len(f.calls) != 1 { t.Fatal("期望1次传输调用,但是是%d次", len(f.calls)) } if f.calls[0] != "POST https://monibot.io/api/metric, body=\\"value=42\\"" { t.Fatal("错误的传输调用 %q", f.calls[0]) } } // 伪造的Transport是在单元测试中使用的clientTransport。 type fakeTransport struct { calls []string } func (f *fakeTransport) Post(url string, body []byte) { f.calls = append(f.calls, fmt.Sprintf("POST %v, body=%q", url, string(body))) } ``` 由于Go的隐式接口实现(如果愿意,可以称之为'鸭子类型'),我们库的用户什么也不需要改变: ```go import "monibot" func main() { // 初始化API客户端 var transport monibot.HTTPTransport client := monibot.NewClient(transport) // 发送指标值 client.PostMetricValue(42) } ``` ### 重新审视Transport 如果我们使IoC成为规范(正如我们应该做的那样),就不再需要导出`Transport`接口了。为什么呢?因为如果消费者需要一个接口,让他们在自己的作用域中定义它,就像我们对'`clientTransport`'做的那样。 **不要导出接口。导出具体实现。如果消费者需要接口,让他们在自己的作用域中定义。** ### 总结 在这篇文章中,我展示了如何以及为什么在Go中使用DI和IoC。正确使用DI/IoC可以导致更易于测试和维护的代码,特别是在代码库不断增长时。虽然代码示例是用Go编写的,但这里描述的原则同样适用于其他编程语言。

有疑问加站长微信联系(非本文作者)

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

276 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传