Java 程序员学习 Go 指南

2018-04-24 13:47:37 +08:00
 suyuanhxx

Java 程序员学习 Go 指南

从事 java 编码也有三年多了,写的Java代码也很多。Go语言,我是无意间接触到的。在去年 12 月份左右的时候比特币大涨到 1w 刀,就想着研究下比特币,而同时有听说 Go 语言在区块链中非常火爆。就抱着学着看看的心情了解了Go,不知不觉喜欢上Go语言的简洁和优雅了。
Go语言是谷歌推出的一种全新的编程语言,可以在不损失应用程序性能的情况下降低代码的复杂性。
Go语言的高并发支持,语法的简洁性,指针的自动垃圾回收,可以让开发人员将精力放在业务处理上。Go语言能够将开发人员带入更生层次的去了解底层操作系统,而不仅局限于语言本身。Go语言的"Less is more",需要在编码过程中去体会这种设计哲学
以数据交换为例子,Go 语言实现:
array[i] , array[j] = array[j] , array[i] // Go
Java实现:

int temp = array[i];
array[i] = array[j];
array[j] = temp;

语法

在语法上Go相对Java有着很多开发人员非常喜欢的特性。可以说这些特性,设计的非常人性化。大大简化了开发人员的工作。语法上从以下四点介绍JavaGo区别

  1. 变量,赋值操作
    在变量命名规则上JavaGo基本相同,都是大小写字母数字,下划线,不能以数字开头。但在Go中以大写字母开头的变量、方法、函数、结构体都表示public(对所有类可见)类型,小写表示private(本类可见)类型(这里不讨论下划线开头)。而Java中用public private关键字明确表示,没有明确表示的为default(包可见)类型,有比较严格的访问级别。Go语言特意淡化了此点,可以节省很多代码。但也为初学者留下了一些小坑,比如JSON序列化时,属性字段必须大写才能序列化。
    例如:(Go语言以换行符表示一行代码结束,Java;表示结束,此法对Go依然适用)

    type Response struct {
        Message      string        `json:"message"`//将 Message 字段序列化成 JSON 中的 message
        userName     string        `json:"userName"`//首字母小写,无法被序列化
    }
    

    在变量声明赋值上,Go语言采用现代化“脚本语言”声明方式。Go支持类型自动推断,故在声明变量时可以将变量类型省略。以下一组代码说明变量a b c d的多种声明赋值方式,当多个变量赋值时推荐使用最后一种方式,代码量最少。相对于Java一行代码只能声明一个变量,要便捷很多。
    函数外部申明必须使用var,不要采用:=

    var a int
    a = 10
    var b int = 10
    var c = 10
    d := 10
    var a, b, c, d = 10
    a, b, c, d := 10
    a, b, c, d := 10, 10, 11, 12
    
  2. 数据类型
    Go语言中的数据类型与Java差距比较大,Go常用基本数据类型继承了C/C++语言特点。
    Java中有包装类型,包装类型为对象,Go没有此功能。Go中还包含一些其他的数据类型简写: byte => uint8int => uintuint => uint32uint64rune => int32uintptr表示无符号整型,用于存放一个指针。
    下面表格具体表示了GoJava数据类型对比:

    Go的数据类型比Java更加丰富,但使用起来上两者相差不多。Go中没有包装类型,大大减少Java中思考使用包装类型还是简单类型的时间。Go中也引入了无符号,有符号数据类型的选择。
    在处理中文字符上,GoJava一样方便。

  3. 指针,slicemap

    • 指针
      Go语言仍采用了C/C++中的指针,相对于Java稍显复杂。但由于Go中能够自动垃圾回收,只需要学会灵活使用指针就能够大幅减少内存操作时间,简化代码。可以说 Go 语言指针是C/C++Java的中合。能够享受指针的便捷,同时又省去手动释放指针的麻烦。 指针变量可以指向任何一个值所在的内存地址。Go中使用*来声明一个指针,同时也是取指针值操作符。&用来取内存地址,16 进制,例如0xc420014608,不同的机器,不同的环境内存地址都会不同。
      var p *int//声明一个指针
      a:=10
      p=&a //指针赋值
      fmt.Println(p)  //打印指针所指向内存地址 0xc420014608
      fmt.Println(&a) //a 与 p 所指向内存中同一地址 0xc420014608
      fmt.Println(&p) //取变量 p 地址,会变, 0xc42000e048
      fmt.Println(*p) //打印指针内容, 10
      
    • slice
      sliceJava中的List很像,都是能够自动扩容的数组集合。GoList实现了栈和队列的功能,和Java List差距比较大。在结构上Go slice为动态素组,可以自动根据当前元素和声明的容量进行扩容。使用起来非常方便,但可能会带来性能的消耗,频繁的扩容将使代码运行速率下降。 slice有两个额外的属性:len(长度),capacity(容量), var c = make([]int, capacity)
      同时需要注意由数组创建的slice为数组引用,改变slice的值会导致原数组也会发生变化。这种现象在Go中非常常见,使用时要格外注意。
      var a [4]int      // 数组
      a = [4]int{1, 2, 3, 4}// 数组赋值
      b := a[1:3] //slice,数组 a 的引用,从 a 数组下标 1 到下标 2
      b[1] = 5 //a={1,2,5,4}, b={2,5}
      
      而在Java中其实也是存在这种现象的,不过 Java 中“一切皆是对象”的原则,我们在创建一个新的变量或者对象时,都是使用 new 操作,然后再赋值,所以这种对象引用的该变导致原对象也被改变的情况比较少。变量作为参数时,这种情况发生较多,以下 Java 代码也时有发生:
      List<Integer> a = new ArrayList<>();
      a.add(1);
      a.add(2);
      List<Integer> b = a;
      b.add(5);
      
      slice有两个非常重要的函数copy()append()append函数用于给slice追加元素,当 slice 容量不够时会自动扩容,例如b = append(b, 3)copy函数用于将一个数组拷贝到另一个数组,不会自动扩容,例如:
      copy(b, []int{7,3,8}) // b={7,3},由于 b 的长度只有 2,copy 只会复制前两位
      
    • map
      mapJava中的HashMap就设计思想有很多相似的地方,都是非线程安全的数据结构。JavaHashMap底层采用数组(bucket)+链表+红黑树(红黑树为JDK1.8新增部分)的数据结构。都是考虑到Map的使用场景,牺牲线程安全提升访问效率的实现方式。并发情况下使用ConcurrentHashMapGo map使用方式相比Java更为简单,任何数据类型都可以作为keyJavakey必须为对象,基本数据类型的封装类型才能作为 key ),例如:
      var m map[int]string  // 声明
      m[1]="1"              // 赋值
      fmt.Println(m[1])     // 取值
      
      如何用 Go 实现一个线程安全的 map ?最直接的方式就是在 map 读写的时候加上读写锁...
      type ConcurrentHashMap struct {
          lock *sync.RWMutex //Read and write Lock
          cm   map[interface{}]interface{}
      }
      func (m *ConcurrentHashMap) Get(k interface{}) interface{} {
          m.lock.RLock() //Read Lock
          defer m.lock.RUnlock()
          if val, ok := m.cm[k]; ok {
              return val
          }
          return nil
      }
      func (m *ConcurrentHashMap) Set(k interface{}, v interface{}) bool {
          m.lock.Lock() // Write Lock
          defer m.lock.Unlock()
          if val, ok := m.cm[k]; !ok {
              m.cm[k] = v
          } else if val != v {
              m.cm[k] = v
          } else {
              return false
          }
          return true
      }
      
      Go maphash结构的,意味着平均访问时间是 O(1)的。同传统的hashmap一样,由一个个bucket组成,bucket内部又由一个指针数组组成。按key的类型采用相应的 hash 算法得到keyhash值。将hash值的低位当作 Hmap 结构体中 buckets 数组的 index,找到 key 所在的 bucket。将 hash 的高 8 位存储在了buckettophash中。注意,这里高 8 位不是用来当作key/value 在bucket内部的offset的,而是作为一个主键,在查找时对tophash数组的每一项进行顺序匹配的。先比较hash值高位与buckettophash[i]是否相等,如果相等则再比较bucket的第i个的key与所给的key是否相等。如果相等,则返回其对应的value,反之,在overflow buckets中按照上述方法继续寻找。

      数组,slicemap的遍历都可直接采用range关键字,foreach的形式遍历,也可采用for循环方式遍历。foreach遍历和Java相同,都不宜在遍历过程中改变原值。
  4. 方法函数
    对于习惯了 Java 的开发人员来说,总是会将方法和函数当成一个概念,但在 Go 中这两个确有一些不同之处。但形式差不多,功能相同。

    • 函数,由关键字func定义,完成特定功能,可以有多个返回值。例如:func Add(a int, b int) int { ... }。形式上和 Java 有一定差距,功能作用和 Java 一样。不过 Go 中能够有多个返回值,这极大的简化了实际功能的完成。一下几个例子说明方法的声明:
    func Add(a int, b int) int { ... }
    func Sub(a int, b int) (result int) { ... }// result 为返回对象,可在方法体中对 result 直接赋值
    func Multi(a int, b int)  (int, string) { ... } //多个返回值必须是用括号
    func Divide(a int, b int) (result int, s tring) { ... }//如果多个返回值中其中一个有返回值变量,其他的也要有返回值变量
    
    • 方法,函数和方法的定义相差不大。函数一般都是继承某个接口而来,相当于 Java 中某个类中定义的方法。一个方法就是一个包含了接受者的函数,接受者可以是命名类型或者结构体类型的一个值或者是一个指针。所有给定类型的方法属于该类型的方法集。例如:
      func (user *User) getUserName() string { ... }示例中的User为一个结构体,getUserName为结构体的一个方法。
  5. 面向对象
    Go 中面向对象要简单很多,去除了 Java,C/C++中复杂的继承关系,保留了接口interfacestructinterface中包含的是方法,struct中只能定义属性。strcut能够实现interface中的方法(函数)。两者相结合使用实现面向对象的思想,相比 Java 和 C/C++简洁了不少,概念也减少了很多。面向对象的思想还在,仍存在对象关联(父子类)关系。

        type People interface {
            getUserName() string
        }
        type User struct {
            Name string
        }
        func (user *User) getUserName() string {
            return user.Name
        }
        func main() {
            var p People
            p = &User{Name: "zhangshan"} //使用 User 初始化 p,p 为接口 People 的实现,只包含方法 getUserName()
            fmt.Print(p.getUserName())  // print "zhangshan"
        }
    

    Go代码中我们要防止接口的滥用,相比Java中让人头疼的继承和接口实现,Go要相对简单,但我们在使用interface时需要考虑是否必要。

错误处理

Go语言中的错误处理相比Java中的try catch要相对简单一点,但从另一方面 Go 中的Errors are values却又麻烦很多!习惯了Java中的try抛出异常,catch中处理异常的开发人员来说,可能对于Go中的errorpanic处理方式会有点不能适应。
Java中我们通常处理异常(错误)的方式为:将一段可能出错的业务代码使用try catch包裹。try中进行正常业务逻辑,在catch模块对业务逻辑进行补偿操作,事务回滚等,在finally模块对资源进行释放。看起来是一段相对严谨的处理逻辑,大多数开发人员处理到这就结束了。但这其中却包含很多不确定性,请看如下代码:

try{
    A.close();
}catch(Exception e){
    try{
        B.close();
    }catch(Exception e){
        B.close();
    }finally{
        B.close();
    }
}finally{
    try{
        C.close();
    }catch(Exception e){
        C.close();
    }finally{
        C.close();
    }
}

对于不确定的值,开发人员都会尝试去捕捉,并做出处理,但上述冗长的代码,感觉非常糟糕,最终的情况可能是 ABC 三个链接都没法正常关闭。Javatry catch给我们代码方便的同时,却留下很大的“操作空间”,这可能就陷入了一个死胡同。偷懒的开发人员可能会直接放弃处理,而且大多数开发人员都会如此。因为这实在是太冗长了。在实际开发过程中,catch 中往往只做了打印异常的功能,很多开发人员补偿都不会做!
Go就将这种情况摆在开发人员的面前(Java中开发人员能够睁一只眼闭一只眼),你无法忽视这个问题。Goerror设计是,错误也是一种合法的值——“ Errors are values ”

err := Sub()
if err != nil { //与常规处理思路相反,优先处理错误
    fmt.Print(err)
    return err
}
...//正常业务逻辑

Go中的异常panic会导致整个Go程序 crash (进一步说明Go中的异常必须处理,不可忽略),防止异常导致整个程序崩溃,我们要使用recover()来进行恢复。 同时引入关键字defer来延迟执行 defer 后面的函数,非常适合用来处理异常和错误。多条 defer 函数的处理顺序和声明顺序相反。Go 中有很多正确处理错误的实践方式,需要在实际编码过程中体会。

并发

Go 的并发模型设计来自 CSP 模型。Golang 借用 CSP 模型的一些概念为之实现并发进行理论支持,其实从实际上出发,go 语言并没有完全实现了 CSP 模型的所有理论。仅仅是借用了 process 和 channel 这两个概念。相对于 Java 的并发设计,使用起来要方便很多,因此 Go 可以轻易的起成千上万个协程( Goroutine )。(这里并不会讲述 Go 调度器模型)
Goroutine 是实际并发执行的实体,它底层是使用协程(coroutine)实现并发。coroutine 是一种运行在用户态的用户线程,类似于 greenthread,go 底层选择使用 coroutine 的出发点是因为,它具有以下特点:

goroutine 是在 golang 层面提供了调度器,并且对网络 IO 库进行了封装,屏蔽了复杂的细节,对外提供统一的语法关键字支持,简化了并发程序编写的成本。Go 中并发编程示例:

  1. 使用channel控制并发,channel又分为带缓冲buffer和不带缓冲buffer。不带缓冲的 channel 不能在同一个 gorutine 读写,否者发生死锁。带缓冲的 channel 可以。
    func main() {
        ch := make(chan int) // 声明一个不带缓冲的 channel,ch := make(chan int,2) 带缓冲 channel,缓冲为 2
        go func() {   // go 关键字表示启动一个协程 goroutine
            ch <- 1   // channel <- 表示向 channel 中写数据
        }()
        fmt.Println(<-ch) // i:=<- channle 表示读取 channel 中的数据到变量 i
    }
    
  2. 使用sync.WaitGroup控制并发
    fun main(){
        var wg sync.WaitGroup
        var urls = []string{"http://www.golang.org/", "http://www.google.com/"}
        for _, url := range urls {
            wg.Add(1)     //每有一个 goroutinie,wg+1
            go func(url string) {
                defer wg.Done()   // 函数最后将该协程 goroutine 标记为完成
                http.Get(url)
            }(url)
        }
        wg.Wait()  // 等待所有的 goroutine 完成后再执行下面的代码
    }
    
  3. 使用context实现并发控制。context主要是用来处理goroutine中又开启其他gourine,达到跟踪goroutine的解决方案。主要是用来处理多个goroutine之间共享数据,及多个goroutine的管理。

垃圾回收

Go语言有指针,也有自动垃圾回收。这一点上与Java一致,都采用了标记清除算法,不过Go中还有另外两种垃圾回收算法:位图标记和内存布局,精确的垃圾回收。
讨论垃圾回收,就需要知道为什么要有垃圾回收,那就需要先了解系统是如何分配内存。操作系统中有一个内存池。首先,它会向操作系统申请大块内存,自己管理这部分内存。然后,它是一个池子,当上层释放内存时它不实际归还给操作系统,而是放回池子重复利用。这样反复的过程中,内存管理中必然会出现内存碎片问题,当代码中需要申请一个较大的对象时,原用的碎片空间已经不够使用,这就出现了垃圾回收。
垃圾回收有着非常长的历史,第一批垃圾回收算法是为单核机器和小内存程序而设计的。那个时候,CPU 和内存价格昂贵,而且用户没有太多的要求,即使有明显的停顿也没有关系。这个时期的算法设计更注重最小化回收器对 CPU 和堆内存的开销。也就是说,除非内存不足,否则 GC 什么事也不做。而当内存不足时,程序会被暂停,堆空间会被标记并清除,部分内存会被尽快释放出来。
分代理论假说,大部分的内存对象“朝生夕死”,它们在分配到内存不久之后就被作为垃圾回收。这就是分代理论假说的基础,它是整个软件产品线 领域最贴合实际的发现。数十年来,在软件行业,这个现象在各种编程语言上表现出惊人的一致性,不管是函数式编程语言、命令式编程语言、没有值类型的编程语言,还是有值类型的编程语言。现代垃圾回收器基本上都是基于分代算法。分代回收器可以加入其它各种特性,一个现代回收器将会集并发、并行、压缩和分代于一身。
例如 Java 中 JVM 的 GC 分为“年轻代”和“老年代”,“年轻代”的对象大多“朝生夕死”,能够存活下来的对象会放到老年代中。是典型的分代回收器。
下面简单分析集中垃圾回收算法:

  1. 标记清除算法
    我们都知道标记清除算法会“ stop the world ”。内存单元并不会在变成垃圾立刻回收,而是保持不可达状态,直到到达某个阈值或者固定时间长度。这个时候系统会挂起用户程序,也就是 STW,转而执行垃圾回收程序。
    该算法中有一个标记初始的 root 区域,以及一个受控堆区。root 区域主要是程序运行到当前时刻的栈和全局数据区域。在受控堆区中,很多数据是程序以后不需要用到的,这类数据就可以被当作垃圾回收了。判断一个对象是否为垃圾,就是看从 root 区域的对象是否有直接或间接的引用到这个对象。如果没有任何对象引用到它,则说明它没有被使用,因此可以安全地当作垃圾回收掉。
    标记清扫算法分为两阶段:标记阶段和清扫阶段。标记阶段( Go 采用的是三色标记法),从 root 区域出发,扫描所有 root 区域的对象直接或间接引用到的对象,将这些对上全部加上标记。在回收阶段,扫描整个堆区,对所有无标记的对象进行回收。)more

  2. 位图标记和内存布局 既然垃圾回收算法要求给对象加上垃圾回收的标记,显然是需要有标记位的。一般的做法会将对象结构体中加上一个标记域,一些优化的做法会利用对象指针的低位进行标记,这都只是些奇技淫巧罢了。Go 没有这么做,它的对象和 C 的结构体对象完全一致,使用的是非侵入式的标记位.

  3. 精确垃圾回收
    通过定位对象的类型信息,得到该类型中的垃圾回收的指令码,通过一个状态机解释这段指令码来执行特定类型的垃圾回收工作。对于堆中任意地址的对象,找到它的类型信息过程为,先通过它在的内存页找到它所属的 MSpan,然后通过 MSpan 中的类型信息找到它的类型信息。more

微服务架构

Go作为一个现代化后端程序语言,搭建微服务架构当然也是没有任何问题的。Go 通常采用Go-Kit作微服务框架,Java通常采用Spring Cloud做微服务架构。
微服务的架构主要关键包含以下几点:

  1. 服务拆分
    服务拆分粒度主要看具体业务需求,不易太宽泛,也最好不要太细,不然就会产生几十个微服务,维护成本较大。

  2. 服务治理(服务注册发现)
    服务注册发现又分为两种:

    • 客户端发现模式
      当使用客户端发现模式时,客户端负责决定相应服务实例的网络位置,并且对请求实现负载均衡。客户端从一个服务注册服务中查询,其中是所有可用服务实例的库。客户端使用负载均衡算法从多个服务实例中选择出一个,然后发出请求。服务实例的网络位置是在启动时注册到服务注册表中,并且在服务终止时从注册表中删除。服务实例注册信息一般是使用心跳机制来定期刷新的。Netflix Eureka 是一个服务注册表,为服务实例注册管理和查询可用实例提供了 REST API 接口。Netflix Ribbon 是一种 IPC 客户端,与 Eureka 合同工作实现对请求的负载均衡。
    • 服务端发现模式
      客户端通过负载均衡器向某个服务提出请求,负载均衡器向服务注册表发出请求,将每个请求转发往可用的服务实例。跟客户端发现一样,服务实例在服务注册表中注册或者注销。

    Java 微服务实践中通常使用Spring Cloud框架,使用Eureka作为微服务的服务注册表,spring 封装了Eureka,让 Eureka 即作服务转发又作服务注册表。
    Go 中可以使用Consul(一个用于发现和配置的服务。提供了一个 API 允许客户端注册和发现服务。Consul 可以用于健康检查来判断服务可用性),etcd(一个高可用,分布式的,一致性的,键值表,用于共享配置和服务发现。两个著名案例包括 Kubernetes 和 Cloud Foundry)

  3. 远程调用 RPCRPC方面JavaGo均可采用GRPCApache thrift或者直接采用Restful风格的Http请求。Java也可采用dubbo封装的RPC方式

  4. 高可用,负载均衡
    服务治理能够在服务与服务之间时间负载均衡。 HTTP反向代理和负载据衡器(例如NGINX)可以用于服务发现负载均衡器。服务注册表可以将路由信息推送到NGINX,激活一个实时配置更新;例如,可以使用 Consul TemplateNGINX Plus支持额外的动态重新配置机制,可以使用DNS,将服务实例信息从注册表中拉下来,并且提供远程配置的API

  5. 网关,路由追踪
    理论上说,一个客户端可以直接给多个微服务中的任何一个发起请求。每一个微服务都会有一个对外服务端(https://serviceName.api.company.name)。这个 URL 可能会映射到微服务的负载均衡上,它再转发请求到具体节点上。
    通常来说,一个更好的解决办法是采用API Gateway的方式。API Gateway是一个服务器,也可以说是进入系统的唯一节点。这跟面向对象设计模式中的 Facade 模式很像。API Gateway封装内部系统的架构,并且提供 API 给各个客户端。它还可能有其他功能,如授权、监控、负载均衡、缓存、请求分片和管理、静态响应处理等。
    Java中通常使用zuul来搭建服务器网关。


最后给大家推荐我的几个 Repo

5898 次点击
所在节点    分享创造
21 条回复
muzi
2018-04-28 18:23:27 +08:00
mark 一下

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/449419

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX