Skip to content

Latest commit

 

History

History
300 lines (211 loc) · 13 KB

File metadata and controls

300 lines (211 loc) · 13 KB

八、日志和测试

在前一章中,我们讨论了将应用程序责任委托给可通过 API 访问的网络服务和进程内通信,以及由消息队列处理的网络服务。

这种方法模仿了将大型单片应用程序拆分为更小块的新兴趋势;因此,允许开发人员利用不协调的语言、框架和设计。

我们列出了这种方法的一些优点和缺点;虽然大多数的优势在于保持开发的敏捷性和精益性,同时防止可能导致整个应用程序崩溃的灾难性和级联错误,但一个很大的缺点是每个组件都很脆弱。例如,如果我们的电子邮件微服务在大型应用程序中包含错误代码,那么错误会很快被发现,因为它几乎肯定会对另一个组件产生直接和可检测的影响。但通过将流程作为微服务的一部分进行隔离,我们也隔离了它们的状态和状态。

这就是本章内容发挥作用的地方。在 Go 应用程序中测试和记录的能力是语言设计的优势。通过在我们的应用程序中利用这些,它将发展为包含更多的微服务;因此,我们可以更好地跟踪系统中 cog 的任何问题,而不会给整个应用程序带来太多额外的复杂性。

在本章中,我们将介绍以下主题:

  • 引入登录 Go
  • 登录到 IO
  • 格式化输出
  • 使用恐慌和致命错误
  • 在 Go 中引入测试

引入登录 Go

Go 提供了无数种方式来显示stdout的输出,最常见的是fmt包的PrintPrintln。事实上,您可以完全避开fmt包,只使用print()println()

在成熟的应用程序中,你不太可能看到太多这样的内容,因为仅仅显示一个输出而没有能力将其存储在某个地方进行调试或以后的分析是很少见的,而且缺乏很多实用性。即使您只是向用户输出少量反馈,这样做通常是有意义的,并保持将其保存到文件或其他地方的能力,这就是log包发挥作用的地方。由于这个原因,本书中的大多数例子都使用了log.Println来代替fmt.Println。如果在某个时刻,您选择用其他(或附加的)io.Writer取代stdout,那么做出这种改变是微不足道的。

记录到 IO

到目前为止,我们已经将登录到stdout,但是您可以利用任何io.Writer来接收日志数据。事实上,如果希望将输出路由到多个位置,可以使用多个io.Writers

多个记录器

大多数成熟的应用程序将写入多个日志文件,以区分需要保留的各种类型的消息。

最常见的用例是在 web 服务器中。他们通常保存一个access.logerror.log文件,以允许分析所有成功的请求;但是,它们还维护不同类型消息的单独日志记录。

在以下示例中,我们修改了日志记录概念,以包括错误和警告:

package main

import (
  "log"
  "os"
)
var (
  Warn   *log.Logger
  Error  *log.Logger
  Notice *log.Logger
)
func main() {
  warnFile, err := os.OpenFile("warnings.log", os.O_RDWR|os.O_APPEND, 0660)
  defer warnFile.Close()
  if err != nil {
    log.Fatal(err)
  }
  errorFile, err := os.OpenFile("error.log", os.O_RDWR|os.O_APPEND, 0660)
  defer errorFile.Close()
  if err != nil {
    log.Fatal(err)
  }

  Warn = log.New(warnFile, "WARNING: ", Log.LstdFlags
)

  Warn.Println("Messages written to a file called 'warnings.log' are likely to be ignored :(")

  Error = log.New(errorFile, "ERROR: ", log.Ldate|log.Ltime)
  Error.SetOutput(errorFile)
  Error.Println("Error messages, on the other hand, tend to catch attention!")
}

我们可以采用这种方法来存储各种信息。例如,如果我们想要存储注册错误,我们可以创建一个特定的注册错误记录器,并在该过程中遇到错误时允许类似的方法,如图所示:

  res, err := database.Exec("INSERT INTO users SET user_name=?, user_guid=?, user_email=?, user_password=?", name, guid, email, passwordEnc)

  if err != nil {
    fmt.Fprintln(w, err.Error)
    RegError.Println("Could not complete registration:", err.Error)
  } else {
    http.Redirect(w, r, "/page/"+pageGUID, 301)
  }

格式化您的输出

当实例化一个新Logger时,您可以传递一些有用的参数和/或帮助字符串,以帮助定义和澄清输出。每个日志条目都可以在前面加上一个字符串,这在查看多种类型的日志条目时非常有用。您还可以定义希望在每个条目上使用的日期和时间格式的类型。

要创建自定义格式的日志,只需使用io.Writer调用New()函数,如图所示:

package main

import (
  "log"
  "os"
)

var (
  Warn   *log.Logger
  Error  *log.Logger
  Notice *log.Logger
)

func main() {
  warnFile, err := os.OpenFile("warnings.log", os.O_RDWR|os.O_APPEND, 0660)
  defer warnFile.Close()
  if err != nil {
    log.Fatal(err)
  }
  Warn = log.New(warnFile, "WARNING: ", log.Ldate|log.Ltime)

  Warn.Println("Messages written to a file called 'warnings.log' are likely to be ignored :(")
  log.Println("Done!")
}

这不仅允许我们利用stdoutlog.Println功能,还可以在名为warnings.log的日志文件中存储更重要的消息。使用os.O_RDWR|os.O_APPEND常量允许我们写入文件并使用附加文件模式,这对于日志记录非常有用。

使用恐慌和致命错误

除了简单地存储来自应用程序的消息外,您还可以创建应用程序恐慌和致命错误,从而阻止应用程序继续运行。这对于任何用例来说都是至关重要的,在该用例中,不停止执行的错误会导致潜在的安全问题、数据丢失或任何其他意外后果。这些类型的机制通常被归为最关键的错误。

何时使用panic()方法并不总是明确的,但在实践中,这应该被归为无法恢复的错误。不可恢复的错误通常指状态变得不明确或无法保证的错误。

例如,无法从数据库返回预期结果的已获取数据库记录上的操作可能被视为不可恢复,因为将来可能会对过期或丢失的数据执行操作。

在下面的示例中,我们可以在无法创建新用户的情况下实现恐慌;这一点很重要,这样我们就不会试图重定向或继续执行任何进一步的创建步骤:

  if err != nil {
    fmt.Fprintln(w, err.Error)
    RegError.Println("Could not complete registration:", err.Error)
    panic("Error with registration,")
  } else {
    http.Redirect(w, r, "/page/"+pageGUID, 301)
  }

请注意,如果要强制执行此错误,可以在查询中故意执行 MySQL 错误:

  res, err := database.Exec("INSERT INTENTIONAL_ERROR INTO users SET user_name=?, user_guid=?, user_email=?, user_password=?", name, guid, email, passwordEnc)

触发此错误后,您将在各自的日志文件或stdout中找到:

Using panics and fatal errors

在前面的示例中,我们使用紧急停止作为硬停止,这将阻止进一步的执行,从而导致进一步的错误和/或数据不一致。如果不需要硬停止,利用recover()功能,您可以在问题得到解决或缓解后重新进入应用程序流。

引入 Go 测试

Go 附带了大量优秀的工具,用于确保代码干净、格式良好、没有竞争条件等等。从go vetgo fmt,您需要以其他语言单独安装的许多 helper 应用程序都是 Go 的一个包。

测试是软件开发的关键步骤。单元测试和测试驱动的开发有助于发现不明显的错误,特别是对开发人员而言。通常,我们与应用程序关系太近,也太熟悉,以至于无法犯下可以调用其他未发现错误的可用性错误。

Go 的测试包允许对实际功能进行单元测试,并确保所有依赖项(网络、文件系统位置)都可用;在不同的环境中进行测试可以让您在用户发现错误之前发现这些错误。

如果您已经在使用单元测试,那么 Go 的实现将是熟悉的,并且很容易入门:

package example

func Square(x int) int {
  y := x * x
  return y
}

保存为example.go。接下来,使用以下代码创建另一个测试平方根功能的 Go 文件:

package example

import (
  "testing"
)

func TestSquare(t *testing.T) {
  if v := Square(4); v != 16 {
    t.Error("expected", 16, "got", v)
  }
}

您可以通过输入目录并简单地键入[T0]来运行此操作。正如预期的那样,这通过了我们的测试输入:

Introducing testing in Go

这个例子显然是微不足道的,但是为了演示如果测试失败,您将看到什么,让我们更改我们的Square()函数,如下所示:

func Square(x int) int {
  y := x
  return y
}

运行测试后,我们再次得到:

Introducing testing in Go

对命令行应用程序运行命令行测试不同于与 Web 交互。我们的应用程序包括标准 HTML 端点和 API 端点;测试它需要比我们之前使用的方法更细微的差别。

幸运的是,Go 还包括一个专门测试 HTTP 应用程序结果的包net/http/httptest

与前面的示例不同,httptest允许我们评估从单个函数返回的大量元数据,这些函数在 HTTP 版本的单元测试中充当处理程序。

因此,让我们来看一种评估 HTTP 服务器可能产生什么的简单方法,通过生成一个简单返回一年中某一天的快速端点。

首先,我们将向 API 添加另一个端点。让我们将此处理程序示例分离到其自己的应用程序中,以隔离其影响:

package main

import (
  "fmt"
  "net/http"
  "time"
)

func testHandler(w http.ResponseWriter, r *http.Request) {
  t := time.Now()
  fmt.Fprintln(w, t.YearDay())
}

func main() {
  http.HandleFunc("/test", testHandler)
  http.ListenAndServe(":8080", nil)
}

这将通过 HTTP 端点/test简单地返回一年中的某一天(1-366)。那么我们如何测试这个呢?

首先,我们需要一个专门用于测试的新文件。当涉及到需要达到多少测试覆盖率时,这通常对开发人员或组织很有帮助,理想情况下,我们希望达到每个端点和方法,以获得相当全面的覆盖率。对于本例,我们将确保其中一个 API 端点返回正确的状态代码,GET请求返回我们希望在开发中看到的内容:

package main

import (
  "io/ioutil"
  "net/http"
  "net/http/httptest"
  "testing"
)

func TestHandler(t *testing.T) {
  res := httptest.NewRecorder()
  path := "http://localhost:4000/test"
  o, err := http.NewRequest("GET", path, nil)
  http.DefaultServeMux.ServeHTTP(res, req)
  response, err := ioutil.ReadAll(res.Body)
  if string(response) != "115" || err != nil {
    t.Errorf("Expected [], got %s", string(response))
  }
}

现在,我们可以通过确保端点通过(200)或失败(404)并返回我们期望它们返回的文本,在实际应用程序中实现这一点。我们还可以自动添加新内容并对其进行验证,在这些示例之后,您应该具备进行验证的能力。

鉴于我们有一个 hello world 端点,让我们编写一个快速测试来验证端点的响应,并看看如何在test.go文件中获得正确的响应:

package main

import (
  "net/http"
  "net/http/httptest"
  "testing"
)

func TestHelloWorld(t *testing.T) {

  req, err := http.NewRequest("GET", "/page/hello-world", nil)
  if err != nil {
    t.Fatal("Creating 'GET /page/hello-world' request failed!")
  }
  rec := httptest.NewRecorder()
  Router().ServeHTTP(rec, req)
}

在这里,我们可以测试我们是否得到了我们期望的状态代码,这不一定是一个简单的测试,尽管它非常简单。在实践中,我们可能还会创建一个应该失败的测试和另一个检查以确保获得预期的 HTTP 响应的测试。但这为更复杂的测试套件(如健全性测试或部署测试)奠定了基础。例如,我们可能会生成仅用于开发的页面,这些页面从模板生成 HTML 内容,并检查输出,以确保页面访问和模板解析工作符合预期。

阅读有关 http 测试和 httptest 包的更多信息,请访问 https://golang.org/pkg/net/http/httptest/ T2

总结

简单地构建一个应用程序甚至还没有完成一半的任务,而作为开发人员的用户测试在测试策略上引入了巨大的差距。在最终用户发现 bug 之前,测试覆盖率是发现 bug 的关键武器。

幸运的是,Go 提供了实现自动化单元测试所需的所有工具以及支持自动化单元测试所需的日志体系结构。

在本章中,我们介绍了记录器和测试选项。通过为不同的消息生成多个记录器,我们能够将警告与内部应用程序故障导致的错误分开。

然后,我们使用测试和httptest包检查了单元测试,以自动检查我们的应用程序,并通过测试潜在的中断更改保持其最新状态。

第 9 章安全中,我们将更深入地了解安全性的实现;从更好的 TLS/SSL,到在我们的应用程序中防止中间人注入和跨站点请求伪造攻击。