面向 Java 程序员的 Go 教程(三)
七、错误和恐慌
在这一章中,我们将深入探讨 Go 的错误检测和恢复特性,以及它们与 Java 方法的不同之处。当你完成了这一章,你应该能够清楚地识别 Go 和 Java 错误方法之间的相似和不同之处。
代码,尤其是函数中的代码,可以通过几种方式退出:
-
成功-功能如预期完成。
-
失败——由于某种可预见的情况,功能没有按预期完成。
-
严重故障(又名死机)——由于一些意外或异常情况或错误代码,功能没有完成。
在像 Java 这样的每个函数只有一个返回值的语言中,情况 1 和情况 2 通常是结合在一起的,由返回值本身来决定。考虑一下String.indexOf函数,它返回目标的索引或者值< 0 来表示没有找到目标。对于返回对象的函数,通常会返回null来表示失败(如果null是合法的值,这就有问题了)。这通常是许多 NullPointerExceptions 的原因。
Go 错误
Go 函数可以返回零个或多个结果。许多 Go 函数(至少)会返回一个错误值。这是一个常见的例子:
func DoSomething() (err error) { ... }
这表示 DoSomething 函数可以返回一个error(一个内置的 Go 接口类型),在这种情况下(按照惯例和习惯用法)命名为err。err值可以是nil或某个error实例。一个更完整的例子:
func DoSomething() (err error) {
:
err = DoSomePart()
if err != nil {
return
}
:
return
}
Go 有一种常用的、不太冗长的方法来编码这种模式,它结合了赋值和 if 测试:
if err = DoSomePart(); err != nil {
return
}
每个可能失败的函数都遵循这种模式。虽然比利用异常来报告故障的典型 Java 代码冗长得多,但这遵循了 Go 使用的更透明/明显的风格。
请注意,返回没有显式值。这是可行的,因为返回值被命名为err并且err被赋值。另一种选择(作者不太喜欢)是
func DoSomething() error {
:
xxx := DoSomePart() // unconventional name
if xxx != nil {
return xxx // explicitly returned
}
:
return xxx
}
在大多数情况下,Go 更喜欢从函数中返回一个错误值。这种模式在 Java 中通常被认为是不好的做法,因为它迫使调用者测试返回的错误。在 Go 中,这种模式被认为是最佳实践;程序员必须记住测试返回的错误。这是 Go 和 Java 编程风格之间的一个主要区别,许多刚开始用 Go 编程的 Java 程序员在习惯上有一些困难。
对于一些简单的函数,只需要一个成功/失败指示器就足够了,返回的错误值由一个布尔值代替。这通常是 Go 内置操作的情况,比如映射查找和类型断言。
陷入恐慌
在 Java 中,更严重的故障是通过抛出某个异常来指示的。对于什么时候应该抛出异常,什么时候应该返回错误,经常会有混淆(例如,当读取超过文件末尾时),Java(和许多社区)库代码做出这种不一致的选择。
Go 通过使用总是返回一个错误值作为最后(或唯一)返回值的多值函数,使这种行为更加一致。对照nil测试错误值,以确定是否出现错误。一般来说,任何其他返回值只有在没有错误的情况下才有意义。只有当函数灾难性地失败时(内存不足、被零除、索引越界、参数无效等)。)是一个恐慌提出来的。
Java 支持异常的概念(从技术上来说 Throwables ,它是异常的超类)。异常是一个对象,当一个意外的/不寻常的情况出现时,它可以被抛出。一个例子是当零被用作除数时 JVM 抛出的 DivideByZeroException。另一个更严重的例子是当 JVM 不能满足一个new操作时抛出的 OutOfMemoryError 。Java 进程在try语句的catch块中抛出可抛出对象。可抛出的实例在 Java 代码中普遍被抛出和捕获。
Go 有一个类似但不太常用的概念,叫做恐慌。死机很像一个 throwable,可以由代码(您的或某个库)通过使用 Go 内置panic(<value>)函数来引发。该值可以是任何类型(但通常是一个string或(优选的)一个error实例);不应使用nil值。
Go 代码引起恐慌的情况应该很少。在大多数情况下,代码应该返回一个错误。只有在无法预料的情况下才应该使用 panic,因为通过错误来报告它们会很麻烦,例如 Java 的 OutOfMemoryError 的 Go 等价物。
Go 不像 Java 那样有异常类型。相反,它有恐慌参数(更像是 Java 错误混合了一些运行时异常)。Go 对 RuntimeException 和非 RuntimeException throwables 之间的 Java 区别没有概念。所有这些都映射到单个紧急值。永远不要声明函数可能引发的恐慌参数。
Java 有try/finally和try/catch/finally语句集。Go 不会。它使用延迟的函数来达到finally的效果。Go 使用一种不同但相似的机制来捕捉恐慌。
与 Java 非常相似,如果没有被捕获,恐慌通常会导致程序在打印回溯后退出。为了捕捉 Go 中的异常,可以使用内置的recover()函数,该函数返回与最近的异常一起发送的值(在特定的 goroutine 中)。为此,必须在已经延迟的函数中调用recover()。
就像 Java catch子句可以检查抛出的异常一样,deferred 函数可以检查值,进行一些更正,然后再次返回或引发异常。像在 Java 中一样,延迟函数可以在当前调用栈的任何地方。这里有一个简单的例子:
func DoIt() (err error) {
defer func() {
p := recover()
if p != nil { // a panic occurred
// process the panic by (say) testing p value
err = nil // make containing function not return an error
}
}()
:
// any code that can panic
if err != nil {
panic(errors.New(fmt.Sprintf("panic: %v", err)))
// or equivalently
panic(fmt.Errorf("panic: %v", err))
}
:
return
}
一般来说,Go 库和 Go 运行时避免引起恐慌。您的代码还应该。一种常见的情况是利用恐慌。如果一个函数得到一个非法的参数值,它通常会被报告为死机而不是错误返回。这种情况被认为是编程错误,而不是代码应该从中恢复的情况。注意,不是所有的地鼠都遵循这种方法,因此不会验证参数并产生恐慌;其他一些问题通常会在以后出现。该代码依赖于被提供有效的输入。
注意通常应该避免在死机恢复延迟函数中引起新的死机。这就像在 Java 中避免在catch或finally子句中抛出异常一样。
捕捉恐慌的一个关键区域是 goroutines。goroutine 中未处理的死机会导致 Go 程序崩溃。所以,最好不要让它们发生。这需要系统的纪律。为了实现这一点,作者建议所有的 goroutines 都由一个助手函数创建,类似于清单 7-1 。
package main
import (
"errors"
"fmt"
"time"
)
var NoError = errors.New("no error") // special error
func GoroutineLauncher(gr func(), c *(chan error)) {
go func(){
defer func(){
if p := recover(); p != nil {
if c != nil {
// ensure we send an error
if err, ok := p.(error); ok {
*c <- err
return
}
*c <- errors.New(fmt.Sprintf("%v", p))
}
return
}
if c != nil {
*c <- NoError // could also send nil and test for it
}
}()
gr()
}()
}
var N = 5
func main() {
var errchan = make(chan error, N) // N >= 1 based on max active goroutines
// :
GoroutineLauncher (func(){
time.Sleep(2 * time.Second) // simulate complex work
panic("panic happened!")
}, &errchan)
// :
time.Sleep(5 * time.Second) // simulate other work
// :
err := <- errchan // wait for result
if err != NoError {
fmt.Printf("got %q" , err.Error())
}
}
Listing 7-1Capture Panics in a Goroutine Launcher Function
请注意,如果客户端不需要错误报告,可以省略错误通道。
这在运行时产生
got "panic happened!"
图示错误和混乱
内置error类型简单。很多第三方包都扩展了它,比如 JuJu Errors 。 1 清单 7-2 、 7-3 和 7-4 是如何扩展它的一些可能的例子。例如,收集多次出现的错误(比如在处理切片的元素时)。
type MultError []error
func (me MultError) Error() (res string) {
res = "MultError"
sep := " "
for _, e := range me {
res = fmt.Sprintf("%s%s%s", res, sep, e.Error())
sep = "; "
}
return
}
func (me MultError) String() string {
return me.Error()
}
Listing 7-2Multiple Cause Errors
当被使用时
me := MultError(make([]error,0, 10))
for _, v := range []string{"one", "two", "three"} {
me = append(me, errors.New(v))
}
fmt.Printf("MultipleError error: %s\n", me.Error())
fmt.Printf("MultipleError value: %v\n\n", me)
生产
MultipleError error: MultError one; two; three
MultipleError value: MultError one; two; three
或者一个错误是由另一个错误引起的(很像 Java 中所有的 Throwables 都有原因)。
type ErrorWithCause struct {
Err error
Cause error
}
func NewError(err error) *ErrorWithCause {
return NewErrorWithCause(err, nil)
}
func NewErrorWithCause(err error, cause error) *ErrorWithCause {
if err == nil {
err = errors.New("no error supplied")
}
return &ErrorWithCause{err, cause}
}
func (wc ErrorWithCause) Error() string {
xerr := wc.Err
xcause := wc.Cause
if xcause == nil {
xcause = errors.New("no root cause supplied")
}
return fmt.Sprintf("ErrorWithCause{%v %v}", xerr, xcause)
}
func (wc ErrorWithCause) String() string {
return wc.Error()
}
Listing 7-3Error with a Cause
当被使用时
fmt.Printf("ErrorWithCause error: %s\n", ewc.Error())
fmt.Printf("ErrorWithCause value: %v\n\n", ewc)
生产
ErrorWithCause error: ErrorWithCause{error cause}
ErrorWithCause value: ErrorWithCause{error cause}
注意,如下所示的方法使得任何数据类型都可以充当error:
func (x <sometype>) Error() string
这是因为error类型被有效地定义为
type error interface {
Error() string
}
Go errors 2 软件包有几个有用的实用函数:
errors.Is(<error>, <type>)–解开错误,直到它与提供的类型匹配,如果找到则返回true。
errors.As(<error>, <*type>)–展开错误,直到它与提供的变量类型匹配,将错误转换为该类型,设置变量,如果找到,则返回true。
errors.Unwrap(<error>)–返回任何包装的错误(类似于 Java 异常的任何原因);实际的错误类型必须有一个Unwrap(<error>)方法。
在 Go 中模拟 Java 异常行为是可能的。例如,为了引入类似于 Try/Catch/Finally 的行为,可以实现如下的小库。这里,Go 函数取代了 Java Try/Catch、Try/Finally 和 Try/Catch/Finally 语句。
每个 function 子句都是作为(典型的)函数文字提供的。没有像 Java 中那样对每个异常类型都进行捕获,因为 Go 对所有问题都只有一个单一的异常。整体函数返回 try 子句的错误。因为 try 和 catch 子句可能有错误,所以有时会返回错误对类型TryCatchError。
注意直接在延迟函数中而不是在triageRecover(...)函数中发出recover()函数是很重要的。
type TryFunc func() error
type CatchFunc func(error) (rerr error, cerr error)
type FinallyFunc func()
type TryCatchError struct {
tryError error
catchError error
}
func (tce *TryCatchError) Error() string {
return tce.String()
}
func (tce *TryCatchError) String() string {
return fmt.Sprintf("TryCatchError[%v %v]", tce.tryError, tce.catchError)
}
func (tce *TryCatchError) Cause() error {
return tce.tryError
}
func (tce *TryCatchError) Catch() error {
return tce.catchError
}
func TryFinally(t TryFunc, f FinallyFunc) (err error) {
defer func() {
f()
}()
err = t()
if err != nil {
err = &TryCatchError{err, nil}
}
return
}
func triageRecover(p interface{}, c CatchFunc) (err error) {
if p != nil {
var terr, cerr error
if v, ok := p.(error); ok {
terr = v
}
if xrerr, xcerr := c(terr); xrerr != nil {
cerr = xcerr
err = xrerr
}
if terr != nil || cerr != nil {
err = &TryCatchError{terr, cerr}
}
}
return err
}
func TryCatch(t TryFunc, c CatchFunc) (err error) {
defer func() {
if xerr := triageRecover(recover(), c); xerr != nil {
err = xerr
}
}()
err = t()
return
}
func TryCatchFinally(t TryFunc, c CatchFunc, f FinallyFunc) (err error) {
defer func() {
f()
}()
defer func() {
if xerr := triageRecover(recover(), c); xerr != nil {
err = xerr
}
}()
err = t()
return
}
Listing 7-4Try/Catch Emulation Example (Part 1)
这可以如清单 7-5 所示使用。
err := TryCatchFinally(func() error {
fmt.Printf("in try\n")
panic(errors.New("forced panic"))
}, func(e error) (re, ce error) {
fmt.Printf("in catch %v: %v %v\n", e, re, ce)
return
}, func() {
fmt.Printf("in finally\n")
})
fmt.Printf("TCF returned: %v\n", err)
err = TryFinally(func() error {
fmt.Printf("in try\n")
return errors.New("try error")
}, func() {
fmt.Printf("in finally\n")
})
fmt.Printf("TCF returned: %v\n", err)
err = TryCatch(func() error {
fmt.Printf("in try\n")
panic(errors.New("forced panic"))
}, func(e error) (re, ce error) {
fmt.Printf("in catch %v: %v %v\n", e, re, ce)
return
})
fmt.Printf("TCF returned: %v\n", err)
err = TryCatch(func() error {
fmt.Printf("in try\n")
return nil
}, func(e error) (re, ce error) {
fmt.Printf("in catch %v: %v %v\n", e, re, ce)
return
})
fmt.Printf("TCF returned: %v\n", err)
Listing 7-5Try/Catch Emulation Example (Part 2)
这将输出以下内容:
in try
in catch forced panic: <nil> <nil>
in finally
TCF returned: TryCatchError[forced panic <nil>]
in try
in finally
TCF returned: TryCatchError[try error <nil>]
in try
in catch forced panic: <nil> <nil>
TCF returned: TryCatchError[forced panic <nil>]
in try
TCF returned: <nil>
Footnotes 1
2
八、Go 语句
在这一章中,我们将更详细地描述 Go 的各种语言语句。当我们完成这一章时,你应该能够清楚地识别 Go 和 Java 语言语句及其功能之间的异同。
与 Java 非常相似,在 Go 中,计算是基于命令式模型的。计算按顺序执行,并保存在变量中。Go 几乎没有 Java 也支持的函数式编程计算风格。控制流只基于条件语句和循环语句,而不是像 Java 可以用它的流库支持的那样嵌入在函数调用中。关于在 Go 中尝试函数方法的一些讨论可以在 https://github.com/robpike/filter 找到。
Go 有几个条件语句:
-
单向或双向条件句(也可用于构成多路条件句)-if/else
-
多路值条件开关
-
多路通道条件–选择
Go 有一个循环语句(for ),其中包含几个子窗体:
-
无限循环
-
带调整索引的循环
-
当条件为真时循环
-
在集合中循环
Go 可以用不同的方式退出/迭代循环:
-
回路条件测试失败
-
突然退出——中断还是返回
-
前进到下一个迭代–继续
像在 Java 中一样,所有的 Go 代码必须被分组到可重用的单元中,这些单元被称为函数。在 Go 中,最好的做法是保持函数简短(比如几十行,最多几行),并根据需要生成更多的函数。Go 可以通过名字调用函数,也可以通过函数值间接调用函数。Java 只能通过名字调用方法。有些函数 Java 是通过语句做的,Go 是通过内置函数调用做的。
Go 可以为每个函数返回零个或多个结果。Java 只支持零或一。像在 Java 中一样,返回可以出现在函数中的任何地方。
打包和导入语句
像 Java 一样,每个 Go 源文件都需要一个 Package 语句作为第一个声明源代码所属包的语句,比如
package main
一个包中可以有任意数量的源文件。Go 源文件名不需要与包名匹配(如果一个包有多个源文件,通常不需要),但是为了更好地组织代码,建议它们匹配,尤其是对于包含主入口点的目录。例如,建议您使用一个main.go文件来保存包含main()函数的main包源代码。注意main包约定中的main函数是必需的,这样 Go builder 就可以识别出必须构建一个可执行文件。
如果源文件使用另一个包中的任何公共声明,则必须导入该包,例如
import "math"
import "net/http"
或者这样分组:
import (
"math"
"net/http"
)
源文件中可以有多组导入。所有 import 语句必须在 package 语句之后,任何其他语句之前。导入可以按任何顺序进行,但通常按导入路径中的姓氏排序,尤其是在同一个导入组中。如果源文件中没有引用包中的公共项,则不能导入该包(编译器将报告错误)。
导入是在文件级而不是包级完成的,因此必须像在 Java 中一样,在使用导入的每个源文件中重复进行。同一个包中的不同源文件可以并且经常有不同的导入列表。
导入的包中对公共名称的所有引用都必须以包名为前缀,如下所示:
r := new(http.Request)
默认情况下,任何导入的包路径中的姓都用作导入的前缀名。有时,您可能希望对一个包使用不同的(比如说更短的)名称。您可以在导入过程中为包指定一个别名,如下所示:
import net "net/http"
Go 包可以有几个init()功能。有时,即使不使用包中的符号,也需要运行这些函数。为此,在导入中添加空白的别名(下划线),如下所示:
import _ "net/http"
一个包的init()函数只运行一次,不管有多少源文件导入这个包。
赋值语句
也许在 Go 中最基本的动作就是给一个变量赋值。在 Go 中,像在 Java 中一样,这是通过赋值语句显式完成的。也可以通过向函数传递参数或从函数返回值来实现。赋值可以是常量、其他变量或涉及这些项目的表达式。
最基本的任务是
<variable> = <expression>
虽然是声明而不是赋值,但也有一种方便的方法来声明和赋值,类似于赋值语句:
<variable> := <expression>
还有这种形式的扩充(也称为复合)赋值:
<variable> <binaryOperation>= <expression>
它们被解释为
<variable> = <variable> <binaryOperation> <expression>
像在 Java 中一样,并不是所有受支持的二元运算符都可以与赋值运算符结合使用。例如,逻辑运算符(&&和||不能使用,因为它们具有短路行为。
请注意以下声明:
<variable>++相当于<variable> += 1
<variable>--相当于<variable> -= 1
Go 允许以下形式的并行(即元组)赋值:
<variable1>,<variable2>,...,<variableN> = <expression1>,<expression2>,...,<expressionN>
其中 N 在每一侧必须相同。任何(但通常不是全部)<variableX>都可以用下划线(“_”)替换,以忽略表达式位置,这通常是在函数调用结果中进行的。
所有右侧的值必须与左侧的相应变量兼容(能够被赋值),没有任何隐含的转换(除了一些数字文字值)。通常,这意味着相应位置的左侧变量和右侧值必须是同一类型。
如果左侧至少有一个变量是新声明的,则允许使用声明形式:
<variable1>,<variable2>,...,<variableN> := <expression1>,<expression2>,...,<expressionN>
在前面所有的例子中,<variableX>是定义一个可赋值目标(又名左值)的任何表达式。通常,这些是简单的标识符(变量名),但也可以是索引数组、切片、映射或指针变量解引用。
声明变量
Java 允许一次声明一个变量并分组声明。Go 也是如此。在 Java 和 Go 中,任何初始值都是可选的。注:在 Java 中,可以创建没有初始值的块/方法局部变量。同样的情况在 Go 中是不可能的;如果没有指定,所有声明的值都有一个初始值(称为零)。
Java 的声明:
{<vis>} {<mod>}... <type> <id> {= <value>} {, <id> {= <value>}}...;
类型是任何内置或声明的类型(类、接口、枚举等)。).这些值必须可转换为类型。如果省略,则使用默认值(块/方法局部变量除外)。该值可以是一个表达式。
仅在字段声明中允许使用<vis>修饰符。它是public、private、protected中的一个,或者省略(意味着默认或包受保护)。Java 的<mod>修饰符,像abstract和final,通常只允许在字段声明中使用,没有 Go 等价物。
Go 相当于一个声明语句:
var <id> {, <id>}... <type>
或者
var <id> {, <id>}... <type> = <value> {, <value>}...
或者
var <id> {, <id>}... = <value> {, <value>}...
该类型是任何内置或声明的类型。每个值必须属于同一类型。如果该值是文本,则它必须可转换为类型。如果省略,则使用零值。只有当所有值都被省略时,类型才是必需的;如果一个值存在,它的类型将用于推断任何缺少的类型。每个位置的推断类型可能不同。任何值都可以是表达式。id 和值的数量必须相同。
如前所述,Go 没有可见性修改器。如果 id 以大写字母开头,则它是 public 否则,它是包私有的(只能被同一个包中的代码看到)。
Go 允许更简洁的声明形式:
var ({<xxx> {, <xxx>...})
其中 xxx 是没有“var”前缀的声明。结束语“)”通常单独在一行中。这是声明变量的常规方式。
例如:
var (
p = 1
q = "hello"
l int
f float64 = 0
)
在顶级声明中,任何关于 var 的注释都由组中的所有成员共享。
Go 有另一种声明形式用于块局部(非字段)声明:
<id> {, <id>}... := <value> {, <value>}...
其中 id 和值的计数必须匹配。此外,在同一个块中至少不能声明一个 id。id 的类型可以不同,由值来表示。
元组赋值(或声明)有许多用途,但一些常见的用途是
- 不使用临时变量交换值
例如:
- 拆分
range操作的结果
var x, y = 1, 2
x, y = y, x // after x==2, y == 1
例如:
- 拆分函数或运算符返回的结果
for index, next := range collection { ... }
- or -
for _, next := range collection { ... }
例如:
file, err := os.Open(...)
- or -
if v, ok := map[key]; ok { ... }
声明命名常数
Java 允许声明类似常量的 1 ( static final)值。Go 有真常数。Go 支持一次定义一个常量并分组定义。在 Java 和 Go 中,初始值都是必需的。
Java 的声明(在某种类型内部):
{<vis>} static final <type> <id> {= <value>} {, <id> {= <value>}}...;
这些值必须可转换为类型。该值必须是常量表达式。
仅在字段声明中允许使用<vis>。它是public、private、protected中的一个,或者省略(意味着默认或包受保护)。
Go 相当于一个声明语句:
const <id> {, <id>}... <type> = <value> {, <value>}...
或者
const <id> {, <id>}... = <value> {, <value>}...
该类型是任何具有文字初始值设定项的内置或声明的类型。该值必须属于同一类型。如果该值是文本,则它必须可转换为类型。该值必须是可以在编译时计算的表达式(即,所有引用的标识符都指向没有循环引用的其他常数)。id 和值的数量必须相同。
Go 没有可见性修改器。如果 id 以大写字母开头,则它是 public 否则,它是包私有的(只能被同一个包中的代码看到)。
Go 允许更简洁的声明形式:
const ({<xxx> {, <xxx>...})
其中 xxx 是没有“const”前缀的声明。结束语“)”通常单独在一行中。这是声明常数的常规方式。
例如:
const (
p = 1
q = "hello"
f float64 = 0
)
If/Else 语句
If/Else 是最基本的条件测试机制。它允许代码序列中的交替流。
Java 的 if 语句:
if(<cond>) <block>
或者
if(<cond>) <block> else <block>
Java 允许除了块之外的任意可执行语句作为 if/else 目标。
Go 的 if 语句:
if {<simpleStmt>;} <cond> <block>
或者
if {<simpleStmt>;} <cond> <block>
else (<ifStmt>|<block>)
If/else 目标是语句块(这也是 Java 中的最佳实践)。Else 语句还允许另一个 if 语句作为目标;这允许多条件测试。在 Go 中,多条件测试最好通过使用 Switch 语句来完成。
可选的简单语句是
-
空(省略–无分号)语句
-
表达式语句
-
发送(通道
-
Inc/dec 报表
-
分配
-
短变量声明(最常见的选项)
if 语句创建了一个隐含块,所以任何声明都隐藏了这样的名字,使其不包含作用域。例如:
var x, y = 0, 0
if t := x; t < 0 { // t in new scope
var x = 1 // a new x variable; hides x above
y = t + x
} else {
y = -1
}
注意else子句,如果存在,必须在与if块结束相同的行开始。
在惯用的 Go 中,else的使用被最小化。因此,从条件(比如 If)块返回是很常见的。当这样做时,使用else子句是非常规的(这是多余的)。例如:
if t < 0 {
return true
} else {
return false
}
更常规的写法是
if t < 0 {
return true
}
return false
它也可以更简洁地表达为
return t < 0
通过这种方式,惯用的 Go 代码倾向于在包含函数的左边对齐,而不会嵌套太深。如果你的代码嵌套超过(比方说)两层,考虑使用return、break或continue语句或者通过提取深度嵌套的代码作为一个新函数来重写它以减少层数。
Go 有很强的源代码风格规则。一个是如何测试布尔值。考虑(常见的)例子:
if v, ok := aMap[someKey]; !ok {
return
}
和...相对
if v, ok := aMap[someKey]; ok == false {
return
}
第一种形式(直接使用布尔值)是惯用的,通常用在第二种形式(比较布尔值)上。
Java 有一个三元表达式(?:)允许(通常很方便)条件测试。例如:
int x = input < 0 ? -input : input; // a simple abs(input)
这是一个简短的形式
if(input < 0) x = -input; else x = input;
而是作为一种表达(相对于陈述)。
Go 没有这个表达式的对等词。人们必须这样做:
var x int
if input < 0 {
x = -input
} else {
x = input
}
或者
var x int = input // (or x := input)
if input < 0 {
x = -input
}
或者,对于简单的物体(比如变量或常数),更简洁地说:
var x int; if input < 0 { x = -input } else { x = input }
或者
x := input; if input < 0 { x = -input }
注意,即使如前面所示输入,大多数 Go 源代码格式化程序也会在分号处拆分这些行。
交换语句
和 Java 一样,Go 也有 Switch 语句。总的来说,Go 的 Switch 语句更加灵活。Java Switch 语句遵循以下一般形式:
switch (<expr>) {
case <value1>:
:
case <value2>:
<statements>
break;
:
default:
<statements>
}
每组陈述可以由一个或多个案例介绍者进行。将 expr 值与每个 case 值(必须是唯一的)进行匹配(测试相等性),并执行匹配后的任何代码。如果代码中没有提供,流程继续执行以下情况,直到找到break。expr 可以是任何整数类型、字符串类型或任何枚举类型。如果没有匹配,并且default介绍器存在,则运行该代码。
上例的 Go 对应物是
switch <expr> {
case <value> {, <value>}...:
<statements>
default:
<statements>
}
switch 语句和每个 case 都创建了一个隐含的块,所以任何声明都隐藏了这样的名字,使其不包含作用域。
在 Go 案例中,每个案例使用多个匹配值,而不是多个案例介绍器。同样,在 Go 情况下,每组语句的末尾都有一个隐式的break。像在 Java 中一样,这些值必须是不同的。同样,在 Go 中,每个 case 都是它自己的块,就好像它被输入为(这在 Java 中是需要的)
case <value>: {
<statements>
}
这意味着变量可以在那组语句中声明为局部变量。
要获得类似 Java 的无中断失败,请用 fall through 语句结束这组语句,如下所示:
switch <expr> {
case <value1>:
<statements>
fallthrough
case <value2>
<statements>
default:
<statements>
}
Java 支持如下级联 if 语句:
if(<expr1>) {
:
} else if(<expr2>) {
:
} ... else if(<exprN>) {
:
} else {
:
}
Go 也支持这种方法,但是惯用的方法是使用不同形式的开关:
switch {
case <expr1>:
<statements>
case <expr2>
<statements>
:
case <exprN>
<statements>
default:
<statements>
}
表达式可以是任意的,除非它们必须是布尔类型。案例(默认案例除外)按照输入的顺序进行测试。
所以,这个 switch 语句:
var c, ditto rune = 'c', '\0'
switch c {
case 'a', 'b', 'c':
ditto = c
default:
ditto = 'x'
}
和这个 switch 语句是等效的:
var c, ditto rune = 'c', '\0'
switch {
case c == 'a', c == 'b', c == 'c':
ditto = c
default:
ditto = 'x'
}
Java 最近增加了 Switch 语句的表达式(有结果值)形式(一种增强的三元表达式)。开关可以是任何表达式中的术语。Go 没有这样的。这些开关表达式添加了不失败案例样式 Go has。此外,案件创建自己的块像 Go。与这个新的switch相关联的是返回开关值的新的yield语句。
While 语句
While 是一种基本的循环机制。它允许在代码序列中有条件地(预先测试)重复流。
Java 的 while 语句:
while (<cond>) <block>
Java 允许任意可执行语句作为 while 目标。
Go 相当于 while 语句:
for <cond> <block>
该语句的目标是一个块(这是 Java 中的最佳实践)。
例如:
var x, y = 10, 0
for x > 0 {
y++
x--
}
Do-While 语句
Do-While 是一种基本的循环机制。它允许在代码序列中有条件地(事后测试)重复流动。
Java 的 do-while 语句:
do <block> while (<cond>);
Go 没有与 Do-While 语句直接等效的语句。可以通过在块中包含测试的 for 语句生成一个,如下所示:
var x, y = 10, 0
for {
y++
x--
if x < 0 {
break
}
}
For with Index 语句
For 是主要的索引循环机制。它允许一个索引跨越一个范围,并由代码序列中的重复流来操作。Go 的 For 语句提供了与 Java 的 For 语句类似的功能。
Java 的 for 语句:
for({<init>};{<cond>};{<inc>}) <block>
Java 允许任意的可执行语句作为目标。
Go 相当于 for 语句:
for {<init>};{<cond>};{<inc>} <block>
与 Java 不同,Go 不支持在<init>和<inc>子句中使用逗号(",")分隔的表达式。
该语句的目标是一个块(这是 Java 中的最佳实践)。<cond>子句是可选的,如果省略则为true。可选的<init>和<inc>组可以是
-
空(省略–无分号)语句
-
表达式语句
-
发送(通道
-
Inc/dec 报表
-
分配
-
短变量声明
该语句创建了一个隐含块,因此任何声明都隐藏了这样的名称,使其不包含范围。
例如:
var x, y = 10, 0
for i := 0; i < 10; i++ {
y++
}
对于超过一个集合声明
For 是迭代(可能为空)集合(或其他值流)的主要循环机制。它允许通过代码序列中的重复流程一次处理一个集合的元素。处理顺序由集合决定。
Java 的 for 语句:
for(<varDecl>: <iterable>) <block>
或者(更直白地)一个人能做的
Collection<SomeType> c = <some collection>;
Iterator<SomeType> it = c.iterator();
for(; it.hasNext();) { // could use while here instead
<varDecl> = it.next();
:
}
或者(更直白地)一个人可以做的(在可索引集合上)
Collection<SomeType> c = <some collection>;
for(int i = 0, count = c.size(); i < count; i++) {
<varDecl> = c.get(i);
:
}
Go 相当于集合上的 for 语句:
for <indexVar>,<valueVar> := range <collection> <block>
该语句的目标是一个块(这是 Java 中的最佳实践)。可选的(至少需要一个)<indexVar>和<valueVar>接收下一项的索引(或键)和下一项的值。<collection>必须是某种集合或流类型,比如数组、切片、映射或通道。
Go 要求在块体中使用所有声明的变量(在:=的左边)。为了避免这一要求,如果没有引用,可以用下划线(“_”)来替换。
例如:
for _, v := range []string{"x", "y", "z"} {
fmt.Println(v)
}
或者
aMap := make(map[string]string)
:
for k, v := range aMap {
fmt.Printf("%s = %s", k, v)
}
Java 的等价物可能是
Map<String,String> m = <some map>;
for(Iterator<String> it = m.keySet().iterator(); it.hasNext();) {
var k = it.next();
var v = m.get(k);
System.out.printf("%s = %s", k, v);
}
range为地图呈现键的顺序是不确定的,并且对于每个地图实例可以是不同的。这是故意的。要按某种顺序处理键,必须首先对它们进行显式排序,比如说排序。例如:
aMap := make(map[string]string)
:
keys := make([]string, 0, len(aMap)) // note created empty
for k, _ := range aMap {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s = %s", k, aMap[k])
}
注意 Java 的TreeMap类型使这变得简单多了。
钥匙片也可以做成这样:
keys := make([]string, len(aMap)) // note created full size
index := 0
for k, _ := range aMap {
keys[index] = k // "keys[index++] = k" not supported
index++
}
前面的方法可能更节省时间。
永远的声明
For 是主要的无限循环机制。它允许在代码序列中无限重复地流动。
Java 的 for 语句:
for(;;) <block> // while (true) also works
Go 相当于 for 语句:
for <block>
目标是一个块(这是 Java 中的最佳实践)。
例如:
var x, y = 10, 0
for {
y++
x--
if x < 0 {
break
}
}
中断和继续语句
像 Java 一样,Go 也有基本上以相同方式工作的break和continue语句。Break 退出循环,而 continue 移动到循环的下一次迭代。通常,这些语句位于一些条件语句的主体中,如 if 或 f or。语法是
break {<label>}
continue {<label>}
如果标签存在,它一定是附加在某个包含循环上的标签。这允许从多层嵌套循环中退出。如果省略,则假定最嵌套的循环。任何循环都可以标记如下(但是该标记必须由一些 break 或 continue 引用):
{<label> :}... <forStatement>
注意,Java 在 Go 中自动使用switch语句中的break语句来退出案例,因此在 Go 的switch(或select)语句中不需要break来避免失败。可以使用break或continue(比如说if主体)在结束前退出箱子。
Goto 语句
Go 支持 Go-To(无条件跳转)语句;Java 没有(虽然是保留字)。它允许在同一块内跳转(但不允许跳出块或进入嵌套块)。不能使用 Go-To 跳过声明。同一块中任何带标签的语句都可以是目标。格式是
goto <label>
可以用 Go-To 代替更结构化的表单。例如,这种概念形式的循环:
for cur:=0; cur < 10; cur++ {
: body of loop
}
可以这样创建:
cur := 0
L1: if cur >= 10 {
goto L2
}
: body of loop
cur++
goto L1
L2:
注意在作者看来,绝对不应该使用goto语句;if、switch、for提供足够的本地控制流量。您的代码应该遵循结构化编程的原则 2 及其 Goto 3 -less 方法。
返回语句
在 Java 中,每个方法都用一个return语句退出(可能在void方法上隐式运行,因为从void函数的末尾隐式返回)。return 语句提供要返回的值,如下所示:
return {<value>} // <value> present only on non-void methods
在 Go 中,Return 几乎是相同的,只是可以像这样返回多个值:
return {<value>{,<value>...}} // <value>... only on non-void methods
返回值的数量必须与函数原型上声明的返回值的数量相匹配。如果返回值是命名的,return 语句可以省略它们。
例如:
func threeInts() (int, int, int) {
:
return 1, 2, 3 // required explicit return values
}
or:
func threeInts() (x, y, z int) {
:
return 1, 2, 3 // explicit return values (ignore names)
}
or:
func threeInts() (x, y, z int) {
x, y, z = 1, 2, 3 // set return values before returning
:
return // implicit return values
}
这最后一种形式是这位作者普遍推荐的。其他人可能会采取不同的立场。
延期声明
Java 有两种流行的资源清理机制:
-
Try/Finally(或 Try/Catch/Finally)
-
尝试使用资源
try/最后,一般来说,看起来是这样的:
try <block>
finally <block>
无论try子句如何结束,都执行finally子句(通常,通过return或通过某些异常)。
对资源的尝试通常如下所示:
try (<declaration> = <new Resource>{;<declaration> = <new Resource>}...) {
// use the resource(s)
}
当try结束时(通常,通过返回或通过一些异常),在try子句中分配的任何资源被自动释放(在编译器编写的finally子句中)。
Go 有一个类似 try/finally 的特性,但是没有类似 try with resources 的特性。Go 使用 Defer 语句,其行为很像一个finally子句。这个语句看起来像
defer <function call>
每次执行 defer 语句时(即使是在循环中),对所提供的函数的调用都会放在调用堆栈中。当包含 defer 语句的函数退出时,延迟的函数调用以相反的顺序执行。可以有许多延迟函数。即使包含函数以return或死机(相当于抛出异常)结束,也会发生这种情况。
典型的方法如下例所示:
func someFunction() {
// acquire some resource
defer func() {
// release the resource
}() // note the function is called
: use the resource
}
在这个模式中,在获得任何资源之后,立即注册一个释放资源的延迟函数。延迟函数继续运行,最终返回或死机,导致延迟函数被调用。
请注意,延迟函数可以访问延迟函数(它是一个闭包)的局部变量(必须在编码defer之前声明),并且在延迟函数返回到其调用者之前被调用,这允许它更改延迟函数的返回值。这很有用,尤其是在紧急情况下(比如被零除)或其他错误恢复时。例如:
func someFunction() (result int, err error) {
defer func() {
if result == 0 { // default value
result = -1
err = errors.New("bad value")
}
}()
:
result = 1
:
return
}
Go 语句
Go 语句启动一个 goroutine。goroutine 只是一个普通的 Go 函数,通常不返回任何值(如果返回,则被丢弃)。创建一个 goroutine,并使用 Go 语句启动,如下所示:
go <func>({arg, {arg}....})
Go 语句立即返回,函数与调用者异步(可能并行)运行。使用调用方提供的不同 goroutine 中的任何参数调用该函数。
注意,Go 中的所有代码都在某个 goroutine 中运行,包括main()函数。
通常,使用函数文字,而不是预先声明的函数,例如
go func(x int) {
:
}(1) // note the function is called
注作者建议用后缀“Go”(或类似的词)来命名期望与 Go 一起运行的函数,以使这个用例清晰。
选择指令
Go 有一个 Select 语句,在 Java 中没有对应的语句。Select 语句用于处理通过通道接收的项或将项发送到通道。使用“选择”之前,请确保您了解频道。Select 语句看起来很像 Switch 语句:
select {
case <receiver>{, <receiver>}... = <- <channel>:
<statements>
case <identifier>, <var> := <- <channel>:
<statements>
case <channel> <- <expression>:
<statements>
default:
<statements>
}
<receiver>是一个表达式(通常只是一个标识符),它指定一个变量来接收通道的值。Select 语句和每个 case 都创建了一个隐含的块,所以任何声明都隐藏了这样的名字,使其不包含作用域。
前两种情况在可以从渠道接收项目时触发。第三种情况是在可能向通道发送项目时触发(如果接收通道有空间)。所有病例都经过评估/测试。如果触发了任何案例,将随机选择并执行其中一个案例,并完成任何相关的赋值和/或语句。
第二种情况有一个<var>(通常命名为“ok”),用于指示源通道是否关闭。通道关闭时将是false。
如果没有触发其他案例,则触发default案例。经常被省略。没有 default 子句的 Select 语句可以阻止等待接收或发送项目。
Select 语句经常在无限循环中执行,如下所示:
for {
select {
:
}
}
这允许从通道接收项目,并且只要它们被发送就进行处理(即,通道是打开的)。
可以接收两个不同通道的值的示例:
var cchan chan int
var ichan chan int
var schan chan string
var scount, icount int
select {
case <- schan: // receive
scount++ // count receive
case <- ichan: // receive
icount++ // count receive
case cchan <- scount + icount: // send current total
default:
fmt.Println("no match")
}
Footnotes 1
这些不是真正的常量(只存在于编译时),而是不可变的值。
2
https://en.wikipedia.org/wiki/Structured_programming; en。维基百科。org/wiki/Structured _ program _ theory
3
九、接口的应用
在这一章中,我们将讨论 Java 中一些有趣的接口应用,以及它们与 Go 编码的关系。
界面是关键
就像在 Java 中一样,使用接口(通过具体类型)作为参数和返回类型在 Go 中很重要。它支持许多选项,比如用模拟对象 1 代替普通对象,这对于测试来说至关重要。所以,特别是当你把一个结构类型传入或传出一个函数时,看看你是否能用一个接口类型替换这个结构。如果您的函数只使用结构的方法而不使用其字段,这通常是可能的。
如果不存在与您使用的方法匹配的现有接口,请创建一个并发布给其他人使用。例如,给定这种类型:
type Xxx struct {
:
}
func (x *Xxx) DoSomethingGood() {
:
}
func (x *Xxx) DoSomethingBad() (err error) {
:
}
您可以创建接口:
type DoGooder interface {
DoSomethingGood()
}
type DoBader interface {
DoSomethingBad() error
}
然后在某个使用Xxx的客户端,这样说:
func DoWork(xxx *Xxx) {
xxx.DoSomethingGood()
}
你可以把它转换成
func DoWork(dg DoGooder) {
dg.DoSomethingGood()
}
但是现在DoWork调用者可以发送一个Xxx的实例或者任何其他有DoSomethingGood()方法的类型。有时,您需要调用 struct 类型的多个方法。有两个主要选项:
-
给函数多个参数,每个参数对应一个不同的接口类型,调用者为所有参数传递相同的对象。
-
创建组合接口并传入该类型。
选项二通常比选项一更受欢迎。
对于选项一,这可以用作
func DoWork(dg DoGooder, db DoBader) {
dg.DoSomethingGood()
db.DoSomethingBad()
}
可以这样称呼:
var xxx *Xxx
:
DoWork(xxx, xxx)
对于选项二,组合接口可以是
type DoGoodAndBad interface {
DoGooder
DoBader
}
它可以这样使用:
func DoWork(dgb DoGoodAndBad) {
dgb.DoSomethingGood()
dgb.DoSomethingBad()
}
可以这样称呼:
var xxx *Xxx
:
DoWork(xxx)
有点令人惊讶的是,这也可以这样调用(使用一个对象,而不是指向该对象的指针):
var xxx Xxx
:
DoWork(xxx)
Go 编译器检测对象和对象指针的使用,并做正确的事情。只有接口类型的参数才会出现这种情况。
类似地,对于当前返回结构类型的函数,可以将其更改为返回多个接口或返回一个组合接口。
接口有一个问题,可能会很成问题。由于 Go 不允许相同类型的重载(相同的名称,不同的签名)函数,所以您可以很容易地用相同的方法名称创建多个接口,通常使用不同的参数和/或返回类型。但是你不能把它们组合成一个新的界面。这也意味着一个类型不能同时实现这些不同的接口。
对此没有简单的解决办法。因此,在选择接口中的方法名称时要小心,因为您最终可能会为行为保留该名称。例如,io.Writer接口声称Write方法(及其特定的参数)仅仅意味着它认为它意味着什么。在不与该接口冲突的情况下,其他接口无法为其他目的创建名为Write的方法。
例如,您可以创建这样的接口:
type MyWriter interface {
// vs. io.Writer: Write([]byte) (int,error)
Write([]byte, int) error
}
不可能创建同时实现了MyWriter和io.Writer接口的类型。
避免这个问题的一个方法是用更长的,通常是多个单词的名字来创建方法,而把短名字留给 Go 运行时开发人员使用。
依赖注入研究
进一步考虑接口的使用,应该尽可能利用依赖注入 2 (DI)。DI 是一种设计方法,在这种方法中,代码被提供了它的依赖项,而不是为它自己获得它们(换句话说,让别人为你提供你所有的依赖项)。DI 将创建依赖关系的责任从依赖它的代码中分离出来。DI 实现通常要求注入的类型符合某种接口类型。
这种方法提供了更大的灵活性,尤其是在(1)测试代码(可以注入模拟对象)或者(2)配置对象之间的复杂关系时。第二种情况在 Java 开发中非常普遍,以至于一个主要的框架, Spring 、、 3 、??Spring Boot、 4 就是为了提供这种情况而创建的。其他选项也存在,比如谷歌的 Guice。 5
Wikipedia 对 DI 的定义如下:“依赖注入将客户端依赖的创建与客户端的行为分开,这允许程序设计松散耦合,并遵循依赖倒置 6 和单一责任 7 原则。”
Wikipedia 描述 Spring DI:Spring 框架的核心是它的反转控制 8 (IoC)容器,它提供了使用反射配置和管理 Java 对象的一致方法。容器负责管理特定对象的对象生命周期:创建这些对象,调用它们的初始化方法,并通过将它们连接在一起来配置这些对象。由容器创建的对象也称为托管对象或 beans。对象可以通过依赖关系查找或依赖关系注入的方式获得。
那么,什么是依赖呢?它是(至少)一个对象
-
具有状态和/或行为。
-
状态应该被封装(对任何用户隐藏),以便实现可以改变。因此,行为最好用接口来表示。
-
由一些(相关的)代码使用。
在 Spring 的例子中,有阿迪容器,它管理所谓的bean(可以链接在一起的 POJOs 9 )。容器通常像地图一样,提供可以在运行时解析的命名对象。在大多数情况下,容器基于工厂方法的注释(比如说@Bean)或外部定义(比如说 XML)来创建 bean 实例。DI 通常通过注释(比如@Inject或@Wired)来告诉容器将一个源 POJO 链接(注入)到一个目标 POJO。
容器拥有订购先决条件 bean 创建和注入。通常,beans 是单例对象(在应用程序中共享一个实例)。容器通常不是程序执行期间进出的对象的来源。通常,容器扮演主程序的角色,创建 beans,然后在程序启动时将它们“连接”在一起。
Java DI 框架经常使用反射来创建要注入的对象。他们通常采用应用程序开发人员定义的 POJO,并将其包装在一个添加额外功能的代理 10 中,比如日志记录或数据库事务管理。代理概念的关键是,代理的客户端不能仅通过被代理的接口来区分它和它所代理的对象;它完全实现了被代理对象的行为契约,因此它是对象的直接替代。在大多数情况下,POJO 类必须实现一个或多个接口,这些接口可以具有在运行时动态定义的具体实现。
Go 目前不支持这种代理的动态创建,因为似乎不可能在运行时通过使用反射来定义类型,这可以用于实现符合接口的对象。这是经常使用代码生成方法的部分原因。或许这在未来会有所改变。Go 确实支持创建客户端可能知道的类似代理的外观对象。
让我们将术语 POGO 定义为 POJO 的 GO 等价物。POGOs 通常被实现为 Go 结构。
Go 没有标准的 DI 容器实现。Go 社区提供了一些,比如优步的 Dig 11 (或者 Fx 12 )和谷歌的 Wire 。 13
Dig 描述如下:
一个基于反射的依赖注入工具包,适合于:
-
为应用框架提供动力
-
进程启动时解析对象图
Wire 描述如下:使用 依赖注入 自动化连接组件的代码生成工具。组件之间的依赖关系在 Wire 中表示为函数参数,鼓励显式初始化而不是全局变量。因为 Wire 在没有运行时状态或反射的情况下运行,所以编写用于 Wire 的代码即使对于手写的初始化也是有用的。
这两个示例容器举例说明了实现 Go 容器的主要方法:
-
使用反射(像 Spring 一样)来设置 POGOs 中的字段,以将它们连接在一起。
-
使用代码生成来创建逻辑(很像在
main中手动完成的那样)以将弹簧连接在一起。
DI 容器特别适合于提供依赖项,比如日志记录器、数据库连接池、数据缓存、HTTP 客户端和类似的伪全局值。事实上,如果做到极致,容器本身就是应用程序中唯一的公共顶级对象;所有其他的都由容器管理。
在 Go 中,有几个注射选项:
-
实例初始化——在这里,依赖关系是通过在声明实例文字时设置来注入的。
-
constructor/factory——这里,依赖关系是通过传递给一个构造函数(
New...)或其他工厂方法来注入的。通常,这是首选选项。 -
直接字段分配–这里,通过直接分配字段来注入依赖性。通常,该字段必须是公共的(因为依赖类型通常在不同的包中)才能实现这一点。应该避免这种选择。
-
Setter 方法——这里,依赖项是通过传递给“setter”方法来注入的。很少这样做,因为结构通常不为所有私有字段提供 get/set 方法,尤其是作为依赖项的公共接口的一部分。
前两种形式的局限性在于,不可能建立相互循环依赖的 POGOs。一般来说,最好避免这种依赖图;依赖关系应该形成一个层次结构。对于后两种情况,依赖关系是在创建实例之后设置的,因此在没有设置依赖关系时会有一个窗口。
作为手动 DI 的一个例子,考虑清单 9-1 中显示的这三种依赖类型(Cache、HTTPClient和Logger)。图 9-1 中通过浏览器显示的基本功能(无 DI)示例。
图 9-1
使用建议调用请求
package main
import (
"fmt"
"time"
)
type Cache interface {
Get(name string) (interface{}, bool)
Set(name string, value interface{}) error
ClearName(name string)
ClearAll()
}
type MapCache map[string]interface{}
func (c MapCache) Get(name string) (res interface{}, ok bool) {
res, ok = c[name]
return
}
func (c MapCache) Set(name string, value interface{}) (err error) {
c[name] = value
return
}
func (c MapCache) ClearName(name string) {
delete(c, name)
return
}
func (c MapCache) ClearAll() {
for k, _ := range c {
delete(c, k)
}
return
}
type HTTPClient interface {
SendReceive(url, method string, in interface{}) (out interface{},
err error)
}
type EchoHTTPClient struct {
}
func (c *EchoHTTPClient) SendReceive(url, method string, in interface{}) (out interface{},
err error) {
out = fmt.Sprintf("SENT %s %s with %v", method, url, in)
return
}
type Logger interface {
Log(format string, args ...interface{})
}
type StdoutLogger struct {
}
func (l *StdoutLogger) Log(format string, args ...interface{}) {
fmt.Printf("%s - %s\n", time.Now().Format(time.StampMilli), fmt.Sprintf(format, args...))
}
type HTTPService struct { // also a HTTPClient
log Logger
client HTTPClient
cache Cache
// : other fields not using dependencies
}
func NewService(client HTTPClient, log Logger,
cache Cache) (s *HTTPService) {
s = &HTTPService{}
s.log = log
s.client = client
s.cache = cache
// : set other fields
return
}
func (s *HTTPService) SendReceive(url, method string,
in interface{}) (out interface{}, err error) {
key := fmt.Sprintf("%s:%s", method, url)
if xout, ok := s.cache.Get(key); ok {
out = xout
return
}
out, err = s.client.SendReceive(url, method, in)
s.log.Log("SendReceive(%s, %s, %v)=%v", method, url, in, err)
if err != nil {
return
}
err = s.cache.Set(key, out)
return
}
func main() {
log := StdoutLogger{} // concrete type
client := EchoHTTPClient{} // concrete type
cache := MapCache{} // concrete type
// create a service with all dependencies injected
s := NewService(&client, &log, cache)
// :
for i:= 0; i < 5; i++ {
if i % 3 == 0 {
cache.ClearAll()
}
data, err := s.SendReceive("some URL", "GET",
fmt.Sprintf("index=%d", i))
if err != nil {
fmt.Printf("Failed: %v\n", err)
continue
}
fmt.Printf("Received: %v\n", data)
}
// :
}
Listing 9-1Dependency Injection in a Go Example
前面的例子展示了如何定义三个可注入的接口,并为每个接口提供了一个简单的示例(可能称为模拟)实现,然后注入每个实现。这里,main()函数发送五个事务,并在序列中途清空缓存。注意以下输出显示了缓存的影响(五个事务中只有两个被执行):
Jul 20 09:10:40.348 - SendReceive(GET, some URL, index=0)=<nil>
Received: SENT GET some URL with index=0
Received: SENT GET some URL with index=0
Received: SENT GET some URL with index=0
Jul 20 09:10:40.349 - SendReceive(GET, some URL, index=3)=<nil>
Received: SENT GET some URL with index=3
Received: SENT GET some URL with index=3
Go 社区中的一些人认为使用 DI,尤其是当由容器管理时,对于 Go 来说并不习惯。通过容器的 DI 可以隐藏对象之间的关系,而在代码中手动创建它们(如前所示)则更加明显。这一论点有可取之处。但是,随着应用程序复杂性的增长和涉及的部件(POGOs)的增加,手动代码可能会失去控制,自动化 DI 解决方案可能是合适的(甚至是必要的)。
不管你如何结束这场争论,在作者看来,让你的代码能够支持 DI 是更好的方法。此外,如果谷歌和脸书都提供库做 DI,它一定是有用的。
关于面向方面编程
Java 支持一种叫做面向方面编程 14 (AOP)的编程风格。AOP 允许用新的行为(也就是代码)来扩充(用所谓的建议)代码(通常称为基础或原始代码)。维基百科是这样描述的:一种编程范式,旨在通过允许分离横切关注点15【XCC】来增加模块化。它通过向现有代码添加额外的行为(一个建议)来实现这一点,而不修改代码本身,而是通过“切入点”规范单独指定修改哪个代码,例如“当函数名以‘set’开头时,记录所有函数调用”。这允许将对业务逻辑不重要的行为添加到程序中,而不会弄乱作为功能核心的代码。
AOP 中有三个关键概念:
-
切入点——指定在哪里应用建议;通常,一些谓词(通常是一种模式,如正则表达式)选择要建议的代码或数据。切入点通常仅限于匹配一个或多个类型中的一个或多个方法,但是一些 AOP 系统也允许匹配数据字段。许多关节点可以匹配一个切入点。
-
建议——当切入点被触发时该做什么。建议有很多种,但最常见的是之前、之后和周围。
-
连接点——代码中应用建议的实际位置。
切入点和建议代码通常由一个类似类的构造定义,称为方面,这是一种描述切入点和/或所需建议的方法。使用 Java,有几种方法可以在连接点应用建议:
-
静态重写源代码——一些预处理器(在编译之前)编辑基本源代码。
-
静态重写目标代码——一些后处理器(编译后)编辑基本目标代码(这在 Go 中很难做到;如果在需要编译器更改的代码生成阶段完成,会更容易)。
-
动态重写目标代码——一些运行时处理器编辑目标代码,通常是在第一次加载时(这在 Go 中很难做到)。
-
使用动态代理——一些运行时处理器包装代码,通常是在第一次加载时(这在 Go 中很难做到)。
Java 有几种 AOP 实现。最受欢迎的是AspectJ16和 Spring AOP 。 17 AspectJ 更全面,主要使用增强选项二和三。Spring AOP 主要使用增强选项四。
AOP 通常用于向代码中添加行为。常见的例子是向 web API 处理程序添加日志记录、授权检查和事务支持。这些是 XCC 的例子,它们通常不是代码的主线目的或核心关注点的一部分,但是支持上下文的需要。如果主代码中没有杂乱的代码来提供它们就更好了。
Go AOP 选项有限。标准库中没有直接支持。一些社区提供的选择是存在的,但是它们可能还不成熟。它们不像 Java 产品那样全面。目前,似乎没有任何 Go AOP 产品像 Java AOP 那样支持非侵入式地(既不改变客户机也不改变服务代码)向基类型添加通知。
AOP 风格的编程看起来很“神奇”(代码有新的行为,而行为的来源并不总是很明显)。像 DI 容器一样,AOP 风格的编程在 Go 中并不习惯。但是和 DI 一样,它也是增加支持的有力手段。
在 Go 中,类似 AOP 的行为可以通过应用代码来实现,通常称为中间件 18 (又名软件胶水)。这是通过将服务包装在符合服务原型的处理器中,在客户端和服务之间添加的功能(因此称为中间功能)。由于 Go 支持一流的功能,中间件可以相对容易地实现。
注意任何 HTTP 处理程序都必须符合在net/http中定义的接口:
type HandlerFunc func(http.ResponseWriter, *http.Request)
给定这些助手函数,如清单 9-2 和 9-3 所示(又名中间件或 around advice):
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func LogWrapper(f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
method, path := req.Method, req.URL
fmt.Printf("entered handler for %s %s\n", method, path)
f(w, req)
fmt.Printf("exited handler for %s %s\n", method, path)
}
}
func ElapsedTimeWrapper(f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
method, path := req.Method, req.URL
start := time.Now().UnixNano()
f(w, req)
fmt.Printf("elapsed time for %s %s: %dns\n",
method, path, time.Now().UnixNano() - start)
}
}
Listing 9-2Advice/Middleware for HTTP Requests (Part 1)
请注意,这些包装器函数返回调用目标服务时应用的其他函数,而不是调用包装器时应用的函数。这两种方法都是 Around advice(最常见的一种)的示例,因为它们在调用目标服务之前和服务返回之后都采取行动。
如果您需要 around 行为来避免可能出现的混乱,请像这样重写包装器:
:
defer func(){
if p := recover(); p != nil {
fmt.Printf("elapsed time for %s %s failed: %v\n",
method, path, p)
panic(p)
}
}()
f(w, req)
:
例如,让我们看一下向 HTTP 请求处理程序添加日志记录和计时。
var spec = ":8086" // localhost
func main() {
// regular HTTP request handler
handler := func(w http.ResponseWriter, req *http.Request) {
fmt.Printf("in handler %v %v\n", req.Method, req.URL)
time.Sleep(1 * time.Second)
w.Write([]byte(fmt.Sprintf("In handler for %s %s", req.Method, req.URL)))
}
// advised handler
http.HandleFunc("/test", LogWrapper(ElapsedTimeWrapper(handler)))
if err := http.ListenAndServe(spec, nil); err != nil {
log.Fatalf("Failed to start server on %s: %v", spec, err)
}
}
Listing 9-3Advice/Middleware for HTTP Requests (Part 2)
运行者:
它生成以下日志输出:
entered handler for GET /test
in handler GET /test
elapsed time for GET /test: 1000141900ns
exited handler for GET /test
在这里,不同的中间件增加了对日志和计时的关注;原始处理程序不会受到任何影响。HTTP 引擎也不是。可以应用任意数量的包装器(以增加一些执行时间为代价)。一个成熟的 AOP 系统可能会自动应用这样的中间件,但是也可以像前面显示的那样手动应用。
Footnotes 1https://en.wikipedia.org/wiki/Mock_object
2
https://en.wikipedia.org/wiki/Dependency_injection
3
https://en.wikipedia.org/wiki/Spring_Framework;https://spring.io/
4
https://spring.io/projects/spring-boot
5
https://en.wikipedia.org/wiki/Google_Guice
6
https://en.wikipedia.org/wiki/Single-responsibility_principle
7
https://en.wikipedia.org/wiki/Dependency_inversion_principle
8
https://en.wikipedia.org/wiki/Inversion_of_control
9
https://en.wikipedia.org/wiki/Plain_old_Java_object
10
https://en.wikipedia.org/wiki/Proxy_pattern , https://docs.oracle.com/javase/8/docs/api/java/lang/reflect/Proxy.html
11
https://github.com/uber-go/dig
12
https://pkg.go.dev/go.uber.org/fx
13
https://github.com/google/wire
14
https://en.wikipedia.org/wiki/Aspect-oriented_programming
15
https://en.wikipedia.org/wiki/Cross-cutting_concern
16
17
https://howtodoinjava.com/spring-aop-tutorial/
18
https://en.wikipedia.org/wiki/Middleware