ZzzZ

ZzzZ 查看完整档案

济南编辑  |  填写毕业院校  |  填写所在公司/组织 no.no 编辑
编辑

简介简介简介

个人动态

ZzzZ 赞了文章 · 8月27日

Protobuf 的 import 功能在 Go 项目中的实践

业务场景

我们会有这样的需求:在不同的文件夹中定义了不同的 proto 文件,这些不同的文件夹可能是一些不同的 gRPC 服务。因为不想重复定义某一个 message,所以其中一个服务可能会用到其他服务中定义的 message,那么这个时候就需要使用到 proto 文件的 import 功能。

接下来说说我在 Go 项目中使用 protobuf 的 import 时所遇到的坑。

案例

首先,我们来创建一个实验项目作为案例,便以说明,结构如下:

image.png

文件 go.mod 中声明了该项目模块名 module github.com/xvrzhao/pb-demo,proto 文件夹中含有两个 gRPC 服务,分别为 article 和 user,我们在这两个文件夹中定义各自所需要的 messages 和 services。

一般情况下,我们会将编译生成的 pb.go 文件生成在与 proto 文件相同的目录,这样我们就不需要再创建相同的目录层级结构来存放 pb.go 文件了。由于同一文件夹下的 pb.go 文件同属于一个 package,所以在定义 proto 文件的时候,相同文件夹下的 proto 文件也应声明为同一的 package,并且和文件夹同名,这是因为生成的 pb.go 文件的 package 是取自 proto package 的。

同属于一个包内的 proto 文件之间的引用也需要声明 import ,因为每个 proto 文件都是相互独立的,这点不像 Go(包内所有定义均可见)。我们的项目 user 模块下 service.proto 就需要用到 message.proto 中的 message 定义,代码是这样写的:

user/service.proto:

syntax = "proto3";  
package user;  // 声明所在包
option go_package = "github.com/xvrzhao/pb-demo/proto/user";  // 声明生成的 go 文件所属的包
  
import "proto/user/message.proto";  // 导入同包内的其他 proto 文件
import "proto/article/message.proto";  // 导入其他包的 proto 文件
  
service User {  
    rpc GetUserInfo (UserID) returns (UserInfo);  
    rpc GetUserFavArticle (UserID) returns (article.Articles.Article);  
}

user/message.proto:

syntax = "proto3";  
package user;  
option go_package = "github.com/xvrzhao/pb-demo/proto/user";  
  
message UserID {  
    int64 ID = 1;  
}  
  
message UserInfo {  
    int64 ID = 1;  
    string Name = 2;  
    int32 Age = 3;  
    gender Gender = 4;  
    enum gender {  
        MALE = 0;  
        FEMALE = 1;  
    }  
}

可以看到,我们在每个 proto 文件中都声明了 packageoption go_package,这两个声明都是包声明,到底两者有什么关系,这也是我开始比较迷惑的。

我是这样理解的,package 属于 proto 文件自身的范围定义,与生成的 go 代码无关,它不知道 go 代码的存在(但 go 代码的 package 名往往会取自它)。这个 proto 的 package 的存在是为了避免当导入其他 proto 文件时导致的文件内的命名冲突。所以,当导入非本包的 message 时,需要加 package 前缀,如 service.proto 文件中引用的 Article.Articles,点号选择符前为 package,后为 message。同包内的引用不需要加包名前缀

article/message.proto:

syntax = "proto3";  
package article;  
option go_package = "github.com/xvrzhao/pb-demo/proto/article";  
  
message Articles {  
    repeated Article Articles = 1;  
    message Article {  
        int64 ID = 1;  
        string Title = 2;  
    }  
}

option go_package 的声明就和生成的 go 代码相关了,它定义了生成的 go 文件所属包的完整包名,所谓完整,是指相对于该项目的完整的包路径,应以项目的 Module Name 为前缀。如果不声明这一项会怎么样?最开始我是没有加这项声明的,后来发现 依赖这个文件的 其他包的 proto 文件 所生成的 go 代码 中(注意断句,已用斜体和正体标示),引入本文件所生成的 go 包时,import 的路径并不是基于项目 Module 的完整路径,而是在执行 protoc 命令时相对于 --proto_path 的包路径,这在 go build 时是找不到要导入的包的。这里听起来可能有点绕,建议大家亲自尝试一下。

protoc 命令

另外,我们说说编译 proto 文件时的命令参数。

首先 protoc 编译生成 go 代码所用的插件 protoc-gen-go 是不支持多包同时编译的,执行一次命令只能同时编译一个包,关于该讨论可以查看该项目的 issue#39

接下来讲讲我遇到的另外一个坑。通常情况下我们编译命令是这样的(基于本项目来说,执行命令的 pwd 为项目根目录):

$ protoc --proto_path=. --go_out=. ./proto/user/*.proto # 编译 user 路径下所有 proto 文件

其中,--proto_path 或者 -I 参数用以指定所编译源码(包括直接编译的和被导入的 proto 文件)的搜索路径,proto 文件中使用 import 关键字导入的路径一定是要基于 --proto_path 参数所指定的路径的。该参数如果不指定,默认为 pwd ,也可以指定多个以包含所有所需文件。

其中,--go_out 参数是用来指定 protoc-gen-go 插件的工作方式go 代码目录架构的生成位置,可以向 --go_out 传递很多参数,见 golang/protobuf 文档 。主要的两个参数为 pluginspaths ,代表 生成 go 代码所使用的插件 和 生成的 go 代码的目录怎样架构。--go_out 参数的写法是,参数之间用逗号隔开,最后加上冒号来指定代码目录架构的生成位置,例如:--go_out=plugins=grpc,paths=import:.paths 参数有两个选项,importsource_relative 。默认为 import ,代表按照生成的 go 代码的包的全路径去创建目录层级,source_relative 代表按照 proto 源文件的目录层级去创建 go 代码的目录层级,如果目录已存在则不用创建

在上面的示例命令中,--go_out 默认使用了 paths=import 所以,我的 go 文件都被编译到了 ./github.com/xvrzhao/pb-demo/proto/user/ 下,后来阅读 文档 才发现:

However, the output directory is selected in one of two ways. Let us say we have inputs/x.proto with a go_package option of github.com/golang/protobuf/p . The corresponding output file may be:

  • Relative to the import path:
$ protoc --go_out=. inputs/x.proto
# writes ./github.com/golang/protobuf/p/x.pb.go

( This can work well with --go_out=$GOPATH )

  • Relative to the input file:
$ protoc --go_out=paths=source_relative:. inputs/x.proto
# generate ./inputs/x.pb.go

所以,我们应该将 --go_out 参数改为 --go_out=paths=source_relative:.

请切记 option go_package 声明和 --go_out=paths=source_relative:. 命令行参数缺一不可

  • option go_package 声明 是为了让生成的其他 go 包(依赖方)可以正确 import 到本包(被依赖方)
  • --go_out=paths=source_relative:. 参数 是为了让加了 option go_package 声明的 proto 文件可以将 go 代码编译到与其同目录。

一般用法

为了统一性,我会将所有 proto 文件中的 import 路径写为相对于项目根目录的路径,然后 protoc 的执行总是在项目根目录下进行:

$ protoc --go_out=plugins=grpc,paths=source_relative:. ./proto/user/*.proto 
$ protoc --go_out=plugins=grpc,paths=source_relative:. ./proto/article/*.proto

如果你觉得每个包都需要单独编译,有些麻烦,可以执行脚本( **/* 代表递归获取当前目录下所有的文件和文件夹):

pb-demo 下执行:

$ for x in **/*.proto; do protoc --go_out=plugins=grpc,paths=source_relative:. $x; done

循环依赖

注意,不同包之间的 proto 文件不可以循环依赖,这会导致生成的 go 包之间也存在循环依赖,导致 go 代码编译不通过。

总结

感觉 protobuf 的使用非常繁杂,文档散落在各处( protobuf 官方文档 / golang protobuf 文档 / grpc 文档 ),要注意的细节也很多,需要多加实践,多加总结。

查看原文

赞 6 收藏 2 评论 3

ZzzZ 赞了文章 · 2019-11-08

用etcd实现服务注册和发现

系统中实现服务注册与发现所需的基本功能有

  • 服务注册:同一service的所有节点注册到相同目录下,节点启动后将自己的信息注册到所属服务的目录中。
  • 健康检查:服务节点定时发送心跳,注册到服务目录中的信息设置一个较短的TTL,运行正常的服务节点每隔一段时间会去更新信息的TTL。
  • 服务发现:通过名称能查询到服务提供外部访问的 IP 和端口号。比如网关代理服务时能够及时的发现服务中新增节点、丢弃不可用的服务节点,同时各个服务间也能感知对方的存在。

在分布式系统中,如何管理节点间的状态一直是一个难题,etcd 是由开发并维护的,它使用 Go 语言编写,并通过Raft 一致性算法处理日志复制以保证强一致性。etcd像是专门为集群环境的服务发现和注册而设计,它提供了数据 TTL 失效、数据改变监视、多值、目录监听、分布式锁原子操作等功能,可以方便的跟踪并管理集群节点的状态。

etcd 服务发现.png
我们写两个 Demo 程序,一个服务充当service,一个客户端程序充当网关代理。服务运行后会去etcd 以自己服务名命名的目录中注册服务节点,并定时续租(更新 TTL)。客户端从 etcd查询服务目录中的节点信息代理服务的请求,并且会在协程中实时监控服务目录中的变化,维护到自己的服务节点信息列表中。

// 将服务注册到etcd上
func RegisterServiceToETCD(ServiceTarget string, value string) {
    dir = strings.TrimRight(ServiceTarget, "/") + "/"
 
    client, err := clientv3.New(clientv3.Config{
        Endpoints:   []string{"localhost:2379"},
        DialTimeout: 5 * time.Second,
    })
    if err != nil {
    panic(err)
    }
 
    kv := clientv3.NewKV(client)
    lease := clientv3.NewLease(client)
    var curLeaseId clientv3.LeaseID = 0
 
    for {
        if curLeaseId == 0 {
            leaseResp, err := lease.Grant(context.TODO(), 10)
            if err != nil {
              panic(err)
            }
 
            key := ServiceTarget + fmt.Sprintf("%d", leaseResp.ID)
            if _, err := kv.Put(context.TODO(), key, value, clientv3.WithLease(leaseResp.ID)); err != nil {
                  panic(err)
            }
            curLeaseId = leaseResp.ID
        } else {
      // 续约租约,如果租约已经过期将curLeaseId复位到0重新走创建租约的逻辑
            if _, err := lease.KeepAliveOnce(context.TODO(), curLeaseId); err == rpctypes.ErrLeaseNotFound {
                curLeaseId = 0
                continue
            }
        }
        time.Sleep(time.Duration(1) * time.Second)
    }
}
type HelloService struct {}

func (p *HelloService) Hello(request string, reply *string) error {
    *reply = "hello:" + request
    return nil
}

var serviceTarget = "Hello"
var port = ":1234"
var host = "remote_host"// 伪代码

func main() {
    rpc.RegisterName("HelloService", new(HelloService))

    listener, err := net.Listen("tcp", port)
    if err != nil {
        log.Fatal("ListenTCP error:", err)
    }

    conn, err := listener.Accept()
    if err != nil {
        log.Fatal("Accept error:", err)
    }

    go RegisterServiceToETCD(serviceTarget,  host + port)
    rpc.ServeConn(conn)
}

网关通过 etcd获取到服务目录下的所有节点的信息,将他们初始化到自身维护的可访问服务节点列表中。然后使用Watch机制监听etcd上服务对应的目录的更新,根据通道发送过来的PUT和DELETE事件来增加和删除服务的可用节点列表。

var serviceTarget = "Hello"
type remoteService struct {
  name string
  nodes map[string]string
  mutex sync.Mutex
}

// 获取服务目录下所有key初始化到服务的可用节点列表中
func getService(etcdClient clientv3.Client) *remoteService {
    service = &remoteService {
      name: serviceTarget
    } 
    kv := clientv3.NewKV(etcdClient)
    rangeResp, err := kv.Get(context.TODO(), service.name, clientv3.WithPrefix())
    if err != nil {
       panic(err)
    }
 
    service.mutex.Lock()
    for _, kv := range rangeResp.Kvs {
        service.nodes[string(kv.Key)] = string(kv.Value)
    }
    service.mutex.Unlock()
  
    go watchServiceUpdate(etcdClient, service)
}

// 监控服务目录下的事件
func watchServiceUpdate(etcdClient clientv3.Client, service *remoteService) {
    watcher := clientv3.NewWatcher(client)
    // Watch 服务目录下的更新
    watchChan := watcher.Watch(context.TODO(), service.name, clientv3.WithPrefix())
    for watchResp := range watchChan {
          for _, event := range watchResp.Events {
                service.mutex.Lock()
                switch (event.Type) {
                case mvccpb.PUT://PUT事件,目录下有了新key
                      service.nodes[string(event.Kv.Key)] = string(event.Kv.Value)
            case mvccpb.DELETE://DELETE事件,目录中有key被删掉(Lease过期,key 也会被删掉)
                      delete(service.nodes, string(event.Kv.Key))
                }
                service.mutex.Unlock()
          }
    }
}

func main () {
  client, err := clientv3.New(clientv3.Config{
        Endpoints:   []string{"remote_host:2379"},
        DialTimeout: 5 * time.Second,
    })
  service := getService(client)// 获取服务的可用节点
  ......
  // 每次有请求过来从服务节点中选取一个连接,然后给节点发送请求
  rpcClient, _ = rpc.Dial("tcp", service.nodes[i])
  var reply string
  rpcClient.Call("HelloService.hello", &reply)
  ......
  
}

除了上面说的客户端或者网关发现系统中的已存服务外,系统中的各个服务之间也需要感知到其他角色的存在,服务间的发现方法与上面的例子类似,每个服务都能作为客户端在 etcd 中发现其他服务的存在。

说明:程序为便于理解有很多伪代码,主要是说明思路,想要实际运行起来还需要很多编码工作,欢迎有这方面经验的朋友交流想法。


WX20191117-152623@2x.png

课程推荐:简明高效的Go语言入门和实战指南

查看原文

赞 10 收藏 5 评论 2

ZzzZ 发布了文章 · 2019-09-04

PHP 缓存穿透以及使用Redis进行缓存加锁

本文通过阅读 原文 此文进行整理,看原文的同学们请移步至此

一 缓存穿透

缓存穿透指的是,当我们访问某个缓存KEY想取得对应的数据时,若此KEY不存在于缓存中,则会去查库。如何解决呢?将每次查询的结果都放入缓存不管是不是空。

public function getArticles($key)
{
    $expire = 60 * 3;
    $data = Cache::get($key);
    //注意:此处使用is_null来判断而不是直接使用 (!$data)来判断。
    //使用 (!$data)来判断的弊端是:如果$data的值为空字符串或者空数组,此处也是不成立的,会继续执行查询DB的语句,造成缓存穿透
    if (!is_null($data)) {
        return $data;
    }
    $data = $this->searchDB();
    Cache::put($key, $data, $expire);
    return $data;
}

这样处理的原因是,即使当前查询的key为空字符串,或者空数组,结果也会被缓存起来。当下一次访问时会直接返回,不会造成缓存穿透

二 缓存加锁(Redis)

若系统的并发很高,当缓存过期时,则大量的请求会穿透缓存,同时到DB中查询,那我们可以设置缓存当缓存过期时,只去DB中请求一次并缓存吗?可以,我们可以使用redis的setNx()
setNx($key) 的作用类似于set($key) ,setNx的意思为 set Not Exists 如果$key不存在则设置,存在则不进行任何操作. 设置成功设置返回1,说明当前的请求获得了当前的操作权限,设置失败返回0,说明此资源已经被其他请求获得。使用代码实现的话,思路如下:

  1. 给存入缓存的数据增加一个过期时间字段暂时给这个字段起名字叫$data['expire'](这个过期时间要短于实际的缓存过期时间),方便在缓存过期前执行加锁和缓存更新。
  2. 如果$data['expire']达到过期时间,则执行加锁以及缓存更新。
  3. 此时如果有其他请求进入则返回更新之前的数据。

代码如下:

public function getArticlesLock($key)
{
    $time = time();
    $expire = 10 * 2;
    $lockKey = 'lock:k';
    $data = Cache::get($key);

    if (!is_null($data)) {
        //缓存未过期
        if ($data['expire'] > time()){
            return $data['data'];
        }
        //加锁失败说明已经有请求执行加锁,返回之前的缓存数据
        if (!Redis::setnx($lockKey,1)) {
            return $data['data'];
        }
    }
    sleep(3);
    $datat = $this->searchDB();
    $data = [
        'data' => $datat,
        'expire' => $time + $expire - 10
    ];
    $r = Cache::put($key, $data, $expire);
    //解锁
    Redis::del($lockKey);
    return $data['data'];
}

当然此处也可以使用set()来代替setnx()加锁,以及使用lua脚本解锁。

查看原文

赞 0 收藏 0 评论 0

ZzzZ 赞了文章 · 2019-09-04

Docker Network 容器间的通信

容器间的通信方式

一个分布式应用,多个容器之间往往需要通信,比如 HTTP 服务容器往往需要与数据库容器进行通信。容器的通信方式有以下几种:

  • 通过 docker run 起一个容器时带上 --link 参数指定该容器与其他容器进行相关联 (即将废弃,原理就不描述了)
  • 通过 Docker Network 系统来建立通信
  • 通过 docker-compose 容器编排工具,其本质也是通过 Docker Network 来通信的
  • 更高级别的容器编排工具,如 Kubernetes 等

这次我们来讲解一下最基础的 Docker Network 系统。

Docker Network

网络驱动模式

Docker Network 有多种驱动模式,默认为 bridge,即桥接模式。bridge network 原理是在新建 network 时建立一个 bridge(即容器外的一个 Linux 网络接口,通过 ip a 命令可以查看),所有加入此 network 的容器会通过容器内部的网络接口与外部的 bridge 接口的 veth interface 相连,这样,network 内容器间需要彼此通信时就可以通过 bridge 进行转发。每一个 bridge network(包含其下的容器)都有一个独立的 IP 网段,不同网段间的容器无法通信,这也就是指定 bridge network 进行容器间网络隔离的原理。

docker0.png
我的 Linux 宿主机上的默认 bridge 的 docker0 网络接口

bridge.png
我的 Linux 宿主机上自建的 bridge 驱动模式的 network 的网络接口

默认 Network

$ docker network ls

NETWORK ID          NAME                DRIVER              SCOPE
9c9d4fd4f950        bridge              bridge              local
3ce82c0caf70        host                host                local
f1a111712ad4        none                null                local

Docker 内置了一个默认 bridge network(名为 bridge,第一行),也就是上文中 docker0 接口所属的 network,所有未指定 network 的容器,默认连接到此 network 中,其网段为 172.17.0.1/16。所以,两个未进行任何连接操作的容器是可以通过 IP 地址互相通信的,因为他们同在一个 network 下,但通讯只能通过 IP 地址进行(比如 ping 172.17.0.5),不可以通过容器名通信(比如 ping container-name)。但自定义创建的网络可以通过容器名进行通信。

Screen Shot 2019-10-31 at 11.13.33 PM.png
图中 docker0 即为默认 bridge 网络的网桥,它负责连通所有未指定 network 的容器,并且连通外网

自定义网络

$ docker network create NAME
  • 不指定 -d 参数,默认创建 bridge 驱动模式的 network。
  • 自定义的 bridge network 会有自己专属的一个网段,与其他 network 隔离。
  • 可以通过 docker network connect 指令将容器连接到一个 network,也可以在起容器(docker run 指令)时加入 --network 参数指定即将创建的容器加入到哪个 network,还可以通过 docker network disconnect 命令将容器移出自定义的 network。
  • 加入到同一个自定义 bridge network 的容器间可以通过容器名进行通信,会自动进行 DNS 解析,但前提是需要给容器指定名称,随机分配的容器名无法被解析。也可以通过 IP 进行通信,因为属于同一个网段。
  • 同一个容器可以同时加入到多个 network 下,此时该容器拥有多个网络接口,分别连接到不通的 bridge 上(可以通过 ip a 查看)。

参考

查看原文

赞 4 收藏 1 评论 0

ZzzZ 赞了文章 · 2019-09-04

MySQL 进行 Docker 容器化之体验

原则

Docker 的使用原则是所有容器化的应用程序都应该是无状态的,即容器内部只跑业务逻辑,容器应用的所有配置文件、日志文件和持久化数据都应该挂载到宿主机文件系统,不应该存储在容器内部,以免容器被误删或自身出现问题导致数据丢失。

MySQL 容器化方式

MySQL 属于有状态应用,若将其容器化需将其状态抽离到宿主机,其配置文件、日志文件和持久化数据都应该挂载到宿主机。

容器内各路径

通过 docker pull 命令拉取下来的官方 MySQL 镜像内,有几个重要路径:

  • 配置文件
    镜像内 MySQL 的配置文件路径为 /etc/mysql,路径下包含 my.cnf 配置和一个 conf.d 文件夹,my.cnf 文件内是一些 base setting,我们自定义的配置应该写在 conf.d 文件夹下的配置文件里,从而覆盖 base settingconf.d 文件夹下默认有两个配置文件:docker.cnfmysql.cnf,在后文所讲的挂载配置路径到宿主机之后,应将这两个用户自定义配置文件拷贝到宿主机配置路径下。
  • 持久化数据
    镜像内 MySQL 的持久化数据均存储在 /var/lib/mysql 下。

命令

$ docker run -d --name db \
    -e MYSQL_ROOT_PASSWORD=secret \
    -v /path/to/confdir:/etc/mysql/conf.d \
    -v /path/to/datadir:/var/lib/mysql \
    mysql:tag

可根据需要加入 --restart 参数来指定容器自动重启策略,详见:
https://docs.docker.com/engin...

参数解释

  • --name 容器名称
  • -e 为新容器设置环境变量,容器内 MySQL 会以此初始化 root 用户密码
  • -v 两个 -v 参数分别将容器内 MySQL 的自定义配置文件目录和持久化数据目录挂载到宿主机,冒号前为宿主机目录,后为容器内目录
  • -d 后台启动容器

小技巧

我们可以在宿主机专门建立一个目录用来存放容器状态数据,层级结构为:

docker-container-data
    |
     - container1
    |   |
    |    - conf
    |   |
    |    - logs (其下又可以细分路径)
    |   |
    |    - data
    |
     - container2
    |
     - container3
    |
     ...

灾难措施

将状态数据挂载宿主机的好处就是如果容器发生问题,可以启动一个新容器并将容器状态路径挂载到宿主机上的原状态路径,从而恢复生产。
可以直接将新启动的 MySQL 容器的配置和持久化数据挂载到之前容器的宿主路径,新容器便可以无缝恢复数据(配置、用户、库、表、记录均恢复)。

注意事项

  • 不可以将两个均在运行的容器的状态路径挂载到同一个宿主路径,两个容器同时在一个宿主路径下写状态,容器应用读取状态时会无法识别,导致崩溃。
  • 若新启动的容器要挂载之前容器的宿主数据路径,则会忽略 -e MYSQL_ROOT_PASSWORD=foo 命令参数所设定的 root 用户密码环境变量,因为挂载的数据路径下已经包含了 MySQL 用户信息表,而 root 用户的密码之前已经被设定过了。
查看原文

赞 1 收藏 0 评论 0

ZzzZ 赞了文章 · 2019-08-21

Go 的包与编译时的依赖包查找机制

包机制

  • 同一目录下只能存在一个包;若同一目录下存在多个包,该目录被别的源文件导入时编译时会报错,不可同一路径导入两个包。
  • 目录和目录下源文件的包命名可以不同,目录被导入时只是提供包的路径而已;若目录名称和目录下包名称不同,当目录被导入时不必为包起别名即可在下文中使用包,当然,使用方式不是以目录名为前缀,而是以目录下的包名为前缀,不过这会导致读代码时不知道该包是来自哪个路径。所以一般规范的写法是保持目录和目录下的包名称相同。
  • 不同目录下若存在相同名称的包,若在一个源文件中需同时导入使用这些包,首先一定是需要导入多个目录路径的,所以这些来自不同路径的相同名称的包就被视为了不同的包,但因为名称一样,所以同时导入到一个源文件时会导致命名冲突编译不通过:redeclared as imported package name previous declaration,因此这种情况下需要给包起别名避免冲突。

编译时的依赖包查找机制

  • Go 支持 Go Modules 之后,编译时编译器会从工作目录(当前所在目录)开始并逐级向上查找是否具有 go.mod 文件。

    • 如果有,go.mod 文件中声明的 module 名称就视作 go.mod 所在的路径,然后以指定的 main 包为依赖入口,所有以 go.mod 中声明的 module 名称开头的导入路径都以 go.mod 所在的路径为相对路径进行包的查找导入。所有需要导入的路径中如果在 go.mod 中指定了版本,则从 $GOPATH/pkg/mod/ 下取得相应版本进行导入,如果没有被指定则从 $GOPATH/src/$GOROOT/src/ 中进行查找导入。
    • 如果没有,所有依赖均从 $GOPATH/src/$GOROOT/src/ 中进行查找导入。
  • 编译时,不在乎源文件在哪(只要指定入口,依赖路径便可依次拿到),而是在乎工作目录在哪(从工作目录开始逐级向上查找,是否可以找到 go.mod)。
查看原文

赞 1 收藏 0 评论 0

ZzzZ 赞了文章 · 2019-08-14

Go-Spring : Another Go Style!

Go-Spring 是模仿 Java 的 Spring 全家桶实现的一套 GoLang 的应用程序框架,仍然遵循“习惯优于配置”的原则,提供了依赖注入、自动配置、开箱即用、丰富的第三方类库集成等功能,能够让程序员少写很多的样板代码。

1.
前言

去年年底的时候,我所在的团队由于业务调整,技术栈也随之发生改变,由之前的 PHP +Java 变成了 Golang + Java。初次接触 Golang,颇不适应,首先就是它那不同一般的语法,然后是没有一个成熟好用的开发框架。语法问题时间长了代码写的多了也就慢慢适应了,但是没有顺手的开发框架就太影响开发效率和代码质量了,作为一个资深的 Java + Spring 全家桶开发者,我希望能改变这一现状。经过一段时间的使用和探索,我发现完全可以搞出一套像 Spring 全家桶(Spring Framework + Spring Boot +Spring Cloud)那样的解决方案出来!

Spring 全家桶在 Java 世界的地位自然无需多言,它不仅为 Java 开发者证明了基于注解开发、基于 AOP 开发以及面向接口开发能够给程序带来极大的灵活性,更重要的是带来了依赖注入、声明式事务、统一的异常处理、模块自动化加载、更简单的 Maven 管理、更简单的单元测试等优秀的开发实践。

但是 GoLang 和 Java 毕竟不同,我为什么笃信自己肯定能搞出来呢?要回答这个问题,实际上是在回答另一个问题,即 Java 的哪些语言特性支撑了 Spring 全家桶能够实现那些核心能力,而 GoLang 又有哪些相似的语言特性?

追根溯源,Java 的字节码、反射、注解、包扫描等机制支撑了 Spring 全家桶能够实现 AOP 开发、依赖注入、声明式事务、模块自动化加载等核心特性。GoLang 因为没有字节码,所以不能实现 AOP 。但是 GoLang 有 Tags、Reflection、_ Imports、init() 机制,所以尽管实现起来不一定有 Java 优雅,但是也能实现依赖注入、模块自动化加载这些 Spring 全家桶的最核心特性。而且,尽管 GoLang 无法实现 AOP,但是也可以通过 Middleware 实现同样的功能。

经过一番探索和实践,终于 Go-Spring 诞生了!在我的眼中,Go-Spring 和 GoLang 本身一样,一出生就带着叛逆和创新精神,GoLang 以不同于主流编程语言语法的姿态出现,而 Go-Spring 则在质疑中以面向接口和依赖注入等多种绝对 Java 的特性出现在大家眼前。

2.
特性
Go-Spring 是模仿 Java 的 Spring 全家桶实现的一套 GoLang 的应用程序框架,仍然遵循“习惯优于配置”的原则,提供了依赖注入、自动配置、开箱即用、丰富的第三方类库集成等功能,能够让程序员少写很多的样板代码。总结起来,Go-Spring 至少有以下五大特点:

▍可扩展的启动器框架,帮你优雅的组织代码

下面这张图展示了一个 rtmp 服务器的启动函数,这里只截取了其中的一部分,可以看到启动函数的代码太长了,而且需要精心组织才能保证代码的可读性。

clipboard.png

而使用 Go-Spring 的启动器框架则可以把这些启动过程封装到单独的文件中,使得功能更内聚,代码更清晰。下图展示的就是一个封装好的启动文件。

clipboard.png

在使用了 Go-Spring 的启动器框架之后,程序的启动过程就变成了非常简单的一行代码了!

clipboard.png

▍面向接口+依赖注入,灵活替换实现方案

Go-Spring 为 Redis 服务提供了统一的 API 接口,但是底层实现却有多种方案。用户在使用 Redis 服务编写业务代码时只需要关注 API 接口,而不需要关心底层采用的是哪种方案。

clipboard.png

当然用户最终会选择一个 redis 服务的底层实现,而引入这个实现仅仅只需要一行代码即可!

clipboard.png

如果你想换成其他的 redis 底层实现,也仅仅是一行代码的事。

clipboard.png

▍自动绑定配置项,简化配置文件使用

在使用了 Go-Spring 的代码中只需要为变量设置好要绑定的配置项的名称,并在配置文件中添加该配置项,Go-Spring 就会自动帮你完成变量和配置项的绑定工作。

clipboard.png

Go-Spring 还支持按照运行环境绑定不同的配置文件,比如当检测到线下环境时 Go-Spring 使用 application-test.properties 配置文件,而当检测到线上环境时会使用 application-online.properties 配置文件。

clipboard.png

▍有效地帮助做好项目的依赖管理

Go-Spring 为每一个模块都提供了一个抽象接口,使用者不需要关心接口内部是怎么实现的,这样能非常容易的解决依赖升级的问题。Go-Spring 保证已发布的所有版本的项目依赖都是正确的,而且 Go-Spring 每发布一个版本都会对依赖进行升级,这样使用者只需要关注 Go-Spring 的版本变化,就能享受到其他依赖自动升级的好处!

▍让复杂的单元测试变得更简单

GoLang 的单元测试尤其对 http 的单元测试简直烂的要命!使用 Go-Spring 启动的项目能够在单元测试的时候使用真实的项目运行环境,而不是使用一个 fake 的 http 环境。

3.
组件

Go-Spring 包含了四个核心项目,其中

clipboard.png

go-spring 实现了 IoC 容器和依赖注入等核心功能;

go-spring-boot 提供了自动配置及应用程序的启动框架;

go-spring-cloud 立足开源世界打造人人可用的微服务框架;

go-spring-didi 聚焦滴滴内部技术实现具有滴滴特点的微服务框架。

4.
示例

下面我将通过一个最简单的 http 服务为大家展示如何使用 Go-Spring。

  1. 新建 main.go 文件,创建启动程序,并且指定配置文件所在目录。

clipboard.png

  1. 在程序中引入 echo http 服务。

clipboard.png

  1. 新建 example.go 文件,实现一个示例服务,并且在 InitController() 函数中注册 http 接口的路由。

clipboard.png

  1. 将一个示例服务的对象注册到 Go-Spring 的 IoC 容器里,这样 Go-Spring 就能自动地加载用户注册的 http 接口的路由。

clipboard.png

  1. 在 main.go 文件中引入示例服务所在的包,这样 Go-Spring 框架在启动的时候就能加载示例服务所在的模块。

clipboard.png

通过上面的 5 个步骤我们就得到了一个简单但完整的 http 服务,使用 go run main.go 命令启动程序,并使用 curl http://localhost:8080/ 进行测试,可以看到请求的返回结果如下:

{"code":900001,"msg":"biz error"}

OK,是不是已经开始感觉到 Go-Spring 的威力了!下面我们再来看一下使用了 Go-Spring 框架的项目的单元测试会怎么写。

首先,我们可以编写一个 TestMain 函数用于启动真实的 http 服务器。

clipboard.png

实际上图展示的代码还可以更精简,精简为一行。

然后,我们可以编写一个测试函数发送真实的 http 请求,也不需要 fake 或者 mock。

clipboard.png

执行这个单元测试,你就会发现,你得到了一个完全不用 mock 和 fake ,并且功能完整、可以断点调试的测试环境。

5.
总结

除了上面展示的能够创建 http 服务和单元测试之外,Go-Spring 已经支持了 mysql 服务、redis 服务、kafka 服务、ddmq服务、服务注册和服务发现服务以及多种 rpc 服务,并且更多的新组件和新特性正在源源不断的通过滴滴内源加入进来,未来 Go-Spring 会变得越来越完善,越来越好用!

Another Go Style!我个人认为 Go-Spring 代表了一种新的编程模式,甚至是一种新的生产力方式,我希望大家在使用 Go-Spring 的过程中能够解放思想,提高效率,得到更多的快乐和自由,也多留一些时间给朋友和家人!

本文首发自普惠出行产品技术 (ID:pzcxtech)

图片描述

查看原文

赞 13 收藏 6 评论 15

ZzzZ 赞了回答 · 2019-08-14

解决多条件筛选的情况下,如何优化mysql语句中的大量left join?

我理解这应该是一个产品设计的问题, 不应该因为用户关联了很多数据,就一股脑把所有数据返回到客户端。尤其是移动互联网时代, 一定要考虑当前需要把什么具体数据返回给用户。 如果有必要,可以增加中间表、 统计表。在详细表修改时同时更新中间表(group by 类型的视图)和统计表(count 类型的视图), 在需要时直接查询统计表,而不是用join的方式。在海量数据的前提下, join 只是对小表+大表管用,如果是大表+大表的 join,即便有索引,效率也好不到哪去。

关注 6 回答 4

ZzzZ 赞了问题 · 2019-08-13

解决多条件筛选的情况下,如何优化mysql语句中的大量left join?

要实现的功能:通过用户的特长、兴趣、去过的城市等多条件(可叠加)筛选出相关用户
用户与关联表的关系皆为一对多
描述如下:
clipboard.png

SELECT *
FROM 用户表

LEFT JOIN 用户特长
ON XXX=XXX
LEFT JOIN 用户兴趣
ON XXX=XXX
LEFT JOIN 用户去过的城市
ON XXX=XXX

WHERE 用户特长.特长=AAA
AND 用户兴趣.兴趣=BBB
AND 用户去过的城市.城市=CCC

对于这种情况,如果用left join易实现。
但在数据量较大时,性能会急剧下降。而且不利于日后分库分表,同时一大串sql难维护。
我从网上看到很多说可以在应用层用java/php代码实现join操作,具体要怎么实现,能用这个例子解释一下吗?求解。

关注 6 回答 4

ZzzZ 赞了回答 · 2019-08-13

解决多条件筛选的情况下,如何优化mysql语句中的大量left join?

基本就两种,join ,或者如 一楼 所说,通过程序去做多条查询。

一般来说呢,如果数据量大,是建议使用第二种,并且加上适当的缓存,因为你这些东西的更新频率也不是很高,并不是很要求实时性。

合理的利用缓存,减轻数据库的压力,自然效率就上去了

关注 6 回答 4

认证与成就

  • 获得 12 次点赞
  • 获得 6 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-02-03
个人主页被 610 人浏览