golang微服务

golang微服务总结

微服务简介

单体应用

  • 所谓的单体应用就是指一个war包包含了项目的所有功能。比如上述举例的打车应用,尽管有一个逻辑模块化架构,但应用程序被作为一个单体进行打包和部署。例如,我们所熟知的许多Java应用程序被打包成WAR文件部署在如Tomcat或者Jetty之类的应用服务器上。其他Java应用程序被打包成可执行JAR包。

  • 单体应用的特点:

    • 容易开发:开发者只需要在专用的开发工具上比如(EclipsemyEclipse)等就可以管理整个项目代码,完成代码开发工作。
    • 容易运行和测试:既然我们能够在本地开发工具上完成整个项目的功能开发和调试,自然也就很容易在我们本地环境上进行测试调试。
    • 容易部署:正如我们在举例单体应用构建项目时所说,当应用程序开发,调试,测试完成以后,我们只需要将代码进行打包,然后将打包好的应用程序拷贝到服务器上进行部署即可。
  • 局限性

  • 但是,随着系统业务的不断增长,代码量不断增加,我们维护创建的单体应用系统的性能和维护成本都会受到限制,限制了我们继续新增新业务的能力。

  • 一旦应用程序成为了一个庞大、复杂的单体应用,整个开发团队可能就会陷入一个痛苦的境地,可能会遇到很多很多的问题,比如说:

    • 敏捷开发受挫:主要问题是:应用程序实在非常复杂,其对于任何一个开发人员来说显得过于庞大。最终,正确修复 bug 和实现新功能变得非常困难而耗时。就拿应用启动时间这一项指标来说,应用程序越大,启动时间越长。曾经听说过某应用程序启动需要 40 分钟以上的怪事。如果开发人员经常要重启应用服务器,那么很大一部分时间都是在等待中度过,这将极大的影响我们的工作效率。
    • 持续部署受挫:另一个大问题是,复杂的单体应用本身就是持续部署的障碍。如今,SaaSSoftware-as-a-Service软件即服务)应用发展到了可以每天多次将变更推送到生产环境。这对于复杂的单体来说非常困难,因为这需要重新部署整个应用程序才能更新其中任何一部分。联想到之前提到的漫长启动时间,这也不会是什么好事。此外,因变更所产生的影响通常不是很明确,开发者很可能需要做大量的手工测试。比如,我们仅仅是修改某个部分的代码,但是因为是全部部署,因此我们必须要重新将整个系统进行全链路测试,这样将耗费非常多额外的时间。因此,持续部署是不可能做到的。
    • 应用难以扩展:当不同模块存在资源需求冲突时,单体应用可能难以扩展。例如,一个模块可能会进行密集型图像处理逻辑,理想情况下是部署在云服务器A实例中;另一个模块可能是一个内存数据库,最适合部署到云服务器B实例中。但是,由于这些模块是属于同一个应用,只能被部署在一起,此时就要求运维人员必须在硬件选择上做出妥协和让步,因此就使得原本我们设计的系统处理业务的能力会受到硬件环境的限制。
    • 可靠性低:单体应用的另一个问题是可靠性低。因为所有模块都运行在同一进程中。任何模块的一个bug,比如内存泄漏,可能会拖垮整个进程。此外,由于应用程序的所有实例都是相同的,该错误将影响到整个应用的可用性,对整个应用都造成影响。
    • 技术升级困难:单体应用因为提及庞大,使得采用新框架和语言变得非常困难。假设有50万行代码使用了某个框架编写。如果使用较新的一个框架来重写整个应用,这将非常昂贵(在时间和成本方面)。因此,这对于团队采用新技术,对系统进行技术升级来说是一个非常大的障碍。
  • 最后,经过了上面几个方面的问题的罗列,我们总结一下:当我们开发个业务量小,功能适量的一个项目应用时,通过单体应用的开发,就可以满足我们的开发需求,实现业务功能。当业务量快速增长,系统持续开发迭代时,我们的应用体积和业务复杂程度会越来越高,以至于影响开发人员的开发效率,提高了项目的维护成本,我们的项目会遇到各种瓶颈问题,应用程序的持续扩展能力受到限制,性能也因此受到影响。

  • 既然实际生产环境中遇到了这样的难题,作为项目管理者和项目开发者,就必须想办法解决出现的这些问题。

  • 我们如何做才能解决项目持续迭代后遇到的扩展能力受限和各种瓶颈问题呢?答案是微服务

如何解决复杂问题

  • 许多大公司如阿里巴巴,腾讯,微博,滴滴等,已经采用现在所谓的微服务架构模式解决了我们前文所提到的单体应用遇到的种种问题。主要的思路:将应用程序分解成一套较小的互连服务。

微服务架构

  • 一个服务通常实现了一组不同的特性或功能,例如订单管理、客户管理等。每一个微服务都是一个小型迷你应用,在需要依赖的地方,通过REST API连接其他所需要的服务之星业务逻辑。

  • 具体的表现为:应用程序的每个功能区域现在都由自己的微服务实现。例如,以我们的出租车系统为例,一个是乘客的应用,一个是司机的应用。这使得它更容易地为特定的用户、司机、设备或者专门的用例部署不同的场景。每个后端服务暴露一个REST API,大部分服务消费的API由其他服务提供。例如,Driver Management 使用了 Notification 服务器来通知可用司机一个可选路程。UI服务调用了其他服务来渲染页面。

微服务与数据库

  • 既然我们将微服务架构模式明显影响到了应用程序与数据库之间的关系,与其他共享单个数据库模式服务有所不同,其每一个服务都有自己的数据库模式。一方面,这种做法与企业级数据库数据模型的想法相背,此外,它经常导致部分数据冗余。对于微服务架构而言,每一个服务都应该有自己的数据库模式,因为它能实现松耦合。

微服务与数据库

  • 这种模式下设计架构的特点是每个服务都拥有各自的数据库。而且,服务可以使用一种最适合其需求、号称多语言持久架构的数据库。比如,Driver Management要找到与潜在乘客接近的司机,就必须使用支持高效地理查询的数据库。

微服务的优缺点

微服务优点

  • 解决复杂问题:微服务架构把可能会变得庞大的单体应用程序分解成一套服务。虽然功能数量不变,但是应用程序已经被分解成可管理的块或者服务。每个服务都有一个明确定义边界的方式,如远程过程调用(RPC)驱动或API。微服务架构模式强制一定程度的模块化,实际上,使用单体代码来实现是极其困难的。因此,使用微服务架构模式,个体服务能被更快地开发,并更容易理解与维护。
  • 团队分工协作更容易:微服务这种架构使得每个服务都可以由一个团队独立专注开发。开发者可以自由选择任何符合服务API的技术。当然,更多的组织是希望通过技术选型限制来避免完全混乱的状态。然而,这种自由意味着开发人员不再有可能在这种自由的新项目开始时使用过时的技术。当编写一个新服务时,他们可以选择当前的技术。此外,由于微服务体积较小,使用当前技术重写旧服务将变得更加可行。
  • 独立部署:微服务架构模式可以实现每个微服务独立部署。开发人员根本不需要去协调部署本地变更到服务。这些变更一经测试即可立即部署。比如,UI团队可以经过自己的开发和测试,并快速迭代UI变更。微服务架构模式使每个聚焦于自己核心业务的团队有了更多的自主权。
  • 程序扩展能力强:微服务架构模式使得每个服务能够独立扩展。开发者可以仅部署满足每个服务的容量和可用性约束的实例数目,另外每个开发团队还可以使用与服务资源要求最匹配的硬件。比如,我们可以在云服务器A实例上部署一个图像处理服务,而在云服务器B实例上部署一个内存数据库服务。微服务架构下使得我们分开部署实例变成了现实。
  • 以上几点是,我们描述了微服务的优点。和单体应用相比,微服务有其优点,当然也会有不足。

微服务缺点

  • 规模难以界定:如同微服务的名字一样,以微服务架构为主的设计模式重点过于强调拆分和微型,以至于会导致一个大型项目会被拆分出很多的微服务实例。在拆分的过程中,如何定义微服务迷你应用的规模,往往存在着不同的标准,有人是以核心功能为标准,有人主张以代码量为标准。总之,衡量标准不一。我们需要强调的是:微服务仅仅是一种技术手段,而不是主要目标。微服务的目标在于充分分解应用程序以方便应用敏捷开发和部署。
  • 增加系统复杂度:因为微服务是独立部署,拆分成各个功能服务实例。因此,原本的单体应用就变成了一个分布式系统,如何管理这个分布式系统,无形之中增加了原有应用程序的复杂度。在分布式系统中,要实现各个微服务实例之间的通信和业务调用,开发者需要选择和实现基于消息或者RPC框架的进程间通信机制来实现微服务间通信。另外,多个微服务调用之间的请求,调试,协调等问题,也会增加团队间的沟通成本,相比起单体应用来说,微服务架构在这一点上也要更复杂。
  • 分区数据库架构难题:当我们在进行正常的业务开发时,往往需要涉及到多个模块之间的数据调用和数据更新。在原来的单体应用下,比较容易实现,我们只需要操作不同的实体表就可以实现,但是在微服务架构中,因为数据存储是独立存储和部署,这导致我们在某个业务中更新多个模块数据时,我们需要调用不同的微服务接口API依次更新。在实际的开发和实践过程中,这个问题也会成为开发人员的主要困扰之一。
  • 项目测试难度增加:在原来的单体应用中,开发者可以通过在统一的测试目录下统一编写测试用例和测试方法,然后启动应用服务,进行测试系统功能,对开发人员来说这是常见的做法也是比较熟悉的做法。相比较而言,如果我们要进行微服务的功能测试,就需要将所依赖的所有的微服务都要启动起来才能执行。虽然这个工作难度不大,但是要知道,一旦我们的某个微服务实例依赖的其他实例数量较多时,对开发者调试的工作量来说,是直线上升的。
  • 多服务修改更加困难:比如在原本的单体应用中存在三个模块,分别为:模块A、模块B和模块C。当我们有业务逻辑需要进行修改时,我们只需要在同一个应用程序中协作修改三个模块,完成业务功能的调整,然后测试并上线。但是在微服务中,当我们需要横框多个微服务实例间进行业务调整时,就需要仔细认真规划和协调,以便完成整个业务的调整。这样对开发者来说,迭代维护的成本也会增加。
  • 微服务需要进行多次部署:原来的单体应用在部署时,可以通过将所有的项目模块打包到一个整体的部署包内,然后部署到一台或者一组服务器上。相比较之下,将程序改为微服务架构以后,应用程序的数量就变多了,这样在整体运行时就需要部署多次,这是一个不小的工作量。
  • 以上几点是微服务架构不足的地方。我们必须要辩证的看待和使用微服务,本质上它是一项技术,我们要利用微服务技术解决现实项目中的问题。

微服务的定义

  • 结合我们之前所了解到的文章和相关知识,并结合相关资料。我们借鉴微服务之父Martin先生给微服务的定义:将一个单体应用拆分成一组微小的服务组件,每个微小的服务组件运行在自己的进程上,组件之间通过如RESTful API这样的轻量级机制进行交互,这些服务以业务能力为核心,用自动化部署机制独立部署,另外,这些服务可以用不同的语言进行研发,用不同技术来存储数据。通过以上的定义描述,我们可以基本确定给出微服务的节特征,如下所示:
    • 在分布式环境中,将单体应用拆分为一系列服务,共同组成整个系统。
    • 每个服务都轻量级,单独部署,运行在自己的进程中。
    • 每个微服务注重自己的核心能力的开发,微服务组件之间采用轻量级通信方式进行通信,包括但不限于RESTful API。
    • 按照业务边界进行划分。
    • 微服务是一种编程架构思想,有不同的语言实现。

微服务实践要解决的问题

  • 用微服务来进行实践到生产项目中,首先要解决一些问题。比如下图的微服务业务架构:

微服务架构

  • 在上图图表展示的架构图中,我们假设将业务商户服务A、订单服务B和产品服务C分别拆分为一个微服务应用,单独进行部署。此时,我们面临很多要可能出现的问题要解决,比如:
    1. 客户端如何访问这些服务?
    2. 每个服务之间如何进行通信?
    3. 多个微服务,应如何实现?
    4. 如果服务出现异常宕机,该如何解决?

客户端如何访问服务

  • 在单体应用开发中,所有的服务都是本地的,前端UI界面,移动端APP程序可以直接访问后端服务器程序。
  • 现在按功能拆分成独立的服务,跑在独立的进程中。如下图所示:

多服务部署

  • 此时,后台有N个服务,前台就需要记住管理N个服务,一个服务下线、更新、升级,前台和移动端APP就要重新部署或者重新发包,这明显不服务我们拆分的理念。尤其是对当下业务需求的飞速发展,业务的变更是非常频繁的。

  • 除了访问管理出现困难以外,N个小服务的调用也是一个不小的网络开销。另外,一般微服务在系统内部,通常是无状态的,而我们的用户在进行业务操作时,往往是跨业务模块进行操作,且需要是有状态的,在此时的这个系统架构中,也无法解决这个问题。传统的用来解决用户登录信息和权限管理通常有一个统一的地方维护管理(OAuth),我们称之为授权管理。

  • 基于以上列出的问题,我们采用一种叫做网关(英文为API Gateway)的技术方案来解决这些问题,网关的作用主要包括:

    • 提供统一服务入口,让微服务对前台透明
    • 聚合后台的服务,节省流量,提升性能
    • 提供安全,过滤,流控等API管理功能
  • 网关(API Gateway)可以有很多广义的实现办法,可以是一个软硬一体的盒子,也可以是一个简单的MVC框架,甚至是一个Node.js的服务端。他们最重要的作用是为前台(通常是移动应用)提供后台服务的聚合,提供一个统一的服务出口,解除他们之间的耦合,不过API Gateway也有可能成为单点故障点或者性能的瓶颈。

  • 最终,添加了网关(API Gateway)的业务架构图变更为如下所示:

网关

服务之间如何通信

  • 所有的微服务都是独立部署,运行在自己的进程容器中,所以微服务与微服务之间的通信就是IPC(Inter Process Communication),翻译为进程间通信。进程间通信的方案已经比较成熟了,现在最常见的有两大类:同步调用、异步消息调用。

同步调用

  • 同步调用比较简单,一致性强,但是容易出调用问题,性能体验上也会差些,特别是调用层次多的时候。同步调用的有两种实现方式:分别是REST和RPC
    • REST:REST基于HTTP,实现更容易,各种语言都支持,同时能够跨客户端,对客户端没有特殊的要求,只要具备HTTP的网络请求库功能就能使用。
    • RPC:rpc的特点是传输效率高,安全性可控,在系统内部调用实现时使用的较多。
  • 基于REST和RPC的特点,我们通常采用的原则为:向系统外部暴露采用REST,向系统内部暴露调用采用RPC方式。

异步消息调用

  • 异步消息的方式在分布式系统中有特别广泛的应用,他既能减低调用服务之间的耦合,又能成为调用之间的缓冲,确保消息积压不会冲垮被调用方,同时能保证调用方的服务体验,继续干自己该干的活,不至于被后台性能拖慢。需要付出的代价是一致性的减弱,需要接受数据最终一致性,所谓的最终一致性就是只可能不会立刻同步完成,会有延时,但是最终会完成数据同步;还有就是后台服务一般要实现幂等性,因为消息送出于性能的考虑一般会有重复(保证消息的被收到且仅收到一次对性能是很大的考验)。最后就是必须引入一个独立的 Broker,作为中间代理池。
  • 常见的异步消息调用的框架有:Kafaka、Notify、MessageQueue。
  • 最终,大部分的服务间的调用架构实现如下所示:

服务间通信

如何实现众多微服务

  • 在微服务架构中,一般每一个服务都是有多个拷贝,来做负载均衡。一个服务随时可能下线,也可能应对临时访问压力增加新的服务节点。这就出现了新的问题:

  • 服务之间如何相互感知?例如有新的服务实例上线,已上线的实例如何知道并与之通信。

  • 服务如何管理?服务实例数量多了,也面临着如何管理的问题。

  • 这就是服务的发现、识别与管理问题。解决多服务之间的识别,发现的问题一般是通过注册的方式来进行。

  • 具体来说:当服务上线时,服务提供者将自己的服务注册信息注册到某个专门的框架中,并通过心跳维持长链接,实时更新链接信息。服务调用者通过服务管理框架进行寻址,根据特定的算法,找到对应的服务,或者将服务的注册信息缓存到本地,这样提高性能。当服务下线时,服务管理框架会发送服务下线的通知给其他服务。

  • 常见的服务管理框架有:Zookeeper等框架。

  • 如上的问题解决方案有两种具体的实现,分别是:基于客户端的服务注册与发现、基于服务端的服务注册与发现。

  • 基于客户端的服务注册与发现

    • 优点是架构简单,扩展灵活,只对服务注册器依赖。缺点是客户端要维护所有调用服务的地址,有技术难度,一般大公司都有成熟的内部框架支持。
      基于客户端的服务注册与发现
  • 基于服务端的服务注册与发现

    • 优点是简单,所有服务对于前台调用方透明,一般在小公司在云服务上部署的应用采用的比较多。

基于服务端的服务注册与发现

服务宕机等异常情况的处理

  • 前面提到,单体应用开发一个很大的风险是,把所有鸡蛋放在一个篮子里,一荣俱荣,一损俱损。而分布式最大的特性就是网络是不可靠的。通过微服务拆分能降低这个风险,不过如果没有特别的保障,结局肯定是噩梦。
  • 因此,当我们的系统是由一系列的服务调用链组成的时候,我们必须确保任一环节出问题都不至于影响整体链路。相应的手段有很多,比如说:
    • 重试机制
    • 限流
    • 熔断机制
    • 负载均衡
    • 降级(本地缓存)

RPC服务

RPC简介

RPC通信

  • 对于单独部署,独立运行的微服务实例而言,在业务需要时,需要与其他服务进行通信,这种通信方式是进程之间的通讯方式(inter-process communication,简称IPC)。
  • 前文已经描述过,IPC有两种实现方式,分别为:同步过程调用、异步消息调用。在同步过程调用的具体实现中,有一种实现方式为RPC通信方式,远程过程调用(英语:Remote Procedure Call,缩写为 RPC)。
  • 远程过程调用(英语:Remote Procedure Call,缩写为RPC)是一个计算机通信协议。该协议允许运行于一台计算机的程序调用另一台计算机的子程序,而程序员无需额外地为这个交互作用编程。如果涉及的软件采用面向对象编程,那么远程过程调用亦可称作远程调用或远程方法调用,例:Java RMI。简单地说就是能使应用像调用本地方法一样的调用远程的过程或服务。很显然,这是一种client-server的交互形式,调用者(caller)是client,执行者(executor)是server。典型的实现方式就是request–response通讯机制。

RPC设计组成

  • RPC技术在架构设计上有四部分组成,分别是:客户端、客户端存根、服务端、服务端存根。
  • 这里提到了客户端和服务端的概念,其属于程序设计架构的一种方式,在现代的计算机软件程序架构设计上,大方向上分为两种方向,分别是:B/S架构、C/S架构。B/S架构指的是浏览器到服务器交互的架构方式,另外一种是在计算机上安装一个单独的应用,称之为客户端,与服务器交互的模式。
  • 由于在服务的调用过程中,有一方是发起调用方,另一方是提供服务方。因此,我们把服务发起方称之为客户端,把服务提供方称之为服务端。以下是对RPC的四种角色的解释和说明:
    • 客户端(Client):服务调用发起方,也称为服务消费者。
    • 客户端存根(Client Stub):该程序运行在客户端所在的计算机机器上,主要用来存储要调用的服务器的地址,另外,该程序还负责将客户端请求远端服务器程序的数据信息打包成数据包,通过网络发送给服务端Stub程序;其次,还要接收服务端Stub程序发送的调用结果数据包,并解析返回给客户端。
    • 服务端(Server):远端的计算机机器上运行的程序,其中有客户端要调用的方法。
    • 服务端存根(Server Stub):接收客户Stub程序通过网络发送的请求消息数据包,并调用服务端中真正的程序功能方法,完成功能调用;其次,将服务端执行调用的结果进行数据处理打包发送给客户端Stub程序。

RPC 实现步骤

  • 一个正常的RPC过程可以分为一下几个步骤:
    1. client调用client stub,这是一次本地过程调用。
    2. client stub将参数打包成一个消息,然后发送这个消息。打包过程也叫做marshalling。
    3. client所在的系统将消息发送给server。
    4. server的的系统将收到的包传给server stub。
    5. server stub解包得到参数。 解包也被称作 unmarshalling。
    6. server stub调用服务过程。返回结果按照相反的步骤传给client。
  • 在上述的步骤实现远程接口调用时,所需要执行的函数是存在于远程机器中,即函数是在另外一个进程中执行的。因此,就带来了几个新问题:
    • Call ID映射。远端进程中间可以包含定义的多个函数,本地客户端该如何告知远端进程程序调用特定的某个函数呢?因此,在RPC调用过程中,所有的函数都需要有一个自己的ID。开发者在客户端(调用端)和服务端(被调用端)分别维护一个{函数<–>Call ID}的对应表。两者的表不一定完全相同,但是相同的函数对应的Call ID必须相同。当客户端需要进行远程调用时,调用者通过映射表查询想要调用的函数的名称,找到对应的Call ID,然后传递给服务端,服务端也通过查表,来确定客户端所需要调用的函数,然后执行相应函数的代码。
    • 序列化与反序列化。客户端如何把参数传递给远程调用的函数呢?在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。甚至有时候客户端和服务端使用的都不是同一种语言(比如服务端用C++,客户端用Java或者Python)。这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。这个过程叫序列化和反序列化。同理,从服务端返回的值也需要序列化反序列化的过程。
    • 网络传输。远程调用往往用在网络上,客户端和服务端是通过网络连接的。所有的数据都需要通过网络传输,因此就需要有一个网络传输层。网络传输层需要把Call ID和序列化后的参数字节流传递给服务端,然后在把序列化后的调用结果传回给客户端,完成这种数据传递功能的被成为传输层。大部分的网络传输成都使用TCP协议,属于长连接。

RPC原理图

RPC涉及到的相关技术

  • 通过上文一系列的文字描述和讲解,我们已经了解了RPC的由来和RPC整个调用过程。我们可以看到RPC是一系列操作的集合,其中涉及到很多对数据的操作,以及网络通信。因此,我们对RPC中涉及到的技术做一个总结和分析:
    1. 动态代理技术: 上文中我们提到的Client Stub和Sever Stub程序,在具体的编码和开发实践过程中,都是使用动态代理技术自动生成的一段程序。
    2. 序列化和反序列化: 在RPC调用的过程中,我们可以看到数据需要在一台机器上传输到另外一台机器上。在互联网上,所有的数据都是以字节的形式进行传输的。而我们在编程的过程中,往往都是使用数据对象,因此想要在网络上将数据对象和相关变量进行传输,就需要对数据对象做序列化和反序列化的操作。
    • 序列化:把对象转换为字节序列的过程称为对象的序列化,也就是编码的过程。
    • 反序列化:把字节序列恢复为对象的过程称为对象的反序列化,也就是解码的过程。

golang官方RPC库

  • 在Go语言官方网站的pkg说明中,提供了官方支持的rpc包,具体链接。官方提供的rpc包完整的包名是:net/rpc。根据官方的解释,rpc包主要是提供通过网络访问一个对象方法的功能。

Protobuf

  • 在上述RPC的步骤实现中,可以看到其中有对传递的数据进行序列化和反序列化的操作,这就是我们本节内容开始要学习的内容:Protobuf。

Protobuf简介

  • Google Protocol Buffer( 简称 Protobuf)是Google公司内部的混合语言数据标准,他们主要用于RPC系统和持续数据存储系统。

Protobuf应用场景

  • Protocol Buffers 是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,或者说序列化。它很适合做数据存储或RPC数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。
  • 简单来说,Protobuf的功能类似于XML,即负责把某种数据结构的信息,以某种格式保存起来。主要用于数据存储、传输协议等使用场景。
  • 为什么已经有了XLM,JSON等已经很普遍的数据传输方式,还要设计出Protobuf这样一种新的数据协议呢?

Protobuf 优点

  • 性能好/效率高
    • 时间维度:采用XML格式对数据进行序列化时,时间消耗上性能尚可;对于使用XML格式对数据进行反序列化时的时间花费上,耗时长,性能差。
      空间维度:XML格式为了保持较好的可读性,引入了一些冗余的文本信息。所以在使用XML格式进行存储数据时,也会消耗空间。
      整体而言,Protobuf以高效的二进制方式存储,比XML小3到10倍,快20到100倍。

代码生成机制

  • 代码生成机制的含义:在Go语言中,可以通过定义结构体封装描述一个对象,并构造一个新的结构体对象。比如定义Person结构体,并存放于Person.go文件:
type Person struct{
Name string
Age int
Sex int
}
  • 在分布式系统中,因为程序代码时分开部署,比如分别为A、B。A系统在调用B系统时,无法直接采用代码的方式进行调用,因为A系统中不存在B系统中的代码。因此,A系统只负责将调用和通信的数据以二进制数据包的形式传递给B系统,由B系统根据获取到的数据包,自己构建出对应的数据对象,生成数据对象定义代码文件。这种利用编译器,根据数据文件自动生成结构体定义和相关方法的文件的机制被称作代码生成机制。

  • 代码生成机制的优点:首先,代码生成机制能够极大解放开发者编写数据协议解析过程的时间,提高工作效率;其次,易于开发者维护和迭代,当需求发生变更时,开发者只需要修改对应的数据传输文件内容即可完成所有的修改。

  • 支持“向后兼容”和“向前兼容”

    • 向后兼容:在软件开发迭代和升级过程中,"后"可以理解为新版本,越新的版本越靠后;而“前”意味着早起的版本或者先前的版本。向“后”兼容即是说当系统升级迭代以后,仍然可以处理老版本的数据业务逻辑。
    • 向前兼容:向前兼容即是系统代码未升级,但是接受到了新的数据,此时老版本生成的系统代码可以处理接收到的新类型的数据。
  • 支持前后兼容是非常重要的一个特点,在庞大的系统开发中,往往不可能统一完成所有模块的升级,为了保证系统功能正常不受影响,应最大限度保证通讯协议的向前兼容和向后兼容。

  • 支持多种编程语言,Protobuf不仅仅Google开源的一个数据协议,还有很多种语言的开源项目实现。在Google官方发布的Protobuf的源代码中包含了C++、Java、Python三种语言。本系列课程中,我们学习如何实现Golang语言中的功能实现。

Protobuf 缺点

  • 可读性较差:为了提高性能,Protobuf采用了二进制格式进行编码。二进制格式编码对于开发者来说,是没办法阅读的。在进行程序调试时,比较困难。
  • 缺乏自描述:诸如XML语言是一种自描述的标记语言,即字段标记的同时就表达了内容对应的含义。而Protobuf协议不是自描述的,Protobuf是通过二进制格式进行数据传输,开发者面对二进制格式的Protobuf,没有办法知道所对应的真实的数据结构,因此在使用Protobuf协议传输时,必须配备对应的proto配置文件。

go语言实现Protobuf

  • Go语言中有对应的实现Protobuf协议的库,Github地址

环境准备

  1. 安装protobuf编译器。
  • 可以在地址选择适合自己系统的Proto编译器程序进行下载并解压,如图:

Protoc编译器

  1. 配置环境变量
  • protoc编译器正常运行需要进行环境变量配置,将protocke执行文件所在目录添加到当前系统的环境变量中。windows系统下可以直接在Path目录中进行添加;macOS系统下可以将protoc可执行文件拷贝至/usr/local/include目录下。具体的对应的系统的环境变量配置可以阅读解压后与bin目录同级的readme.txt的文件内容。

安装

  • 通过如下命令安装protoc-gen-go库:
go get github.com/golang/protobuf/protoc-gen-go
  • 安装完成以后,protoc-gen-go*可执行文件在本地环境GOPATH/bin目录下,如下图所示:
    protoc-gen-go执行文件

Protobuf 协议语法

Protobuf 协议的格式

  • Protobuf协议规定:使用该协议进行数据序列化和反序列化操作时,首先定义传输数据的格式,并命名为以".proto"为扩展名的消息定义文件。

message 定义一个消息

  • 先来看一个非常简单的例子。假设想定义一个“订单”的消息格式,每一个“订单"都含有一个订单号ID、订单金额Num、订单时间TimeStamp字段。可以采用如下的方式来定义消息类型的.proto文件:
message Order{
required string order_id = 1;
required int64 num = 2;
optional int32 timestamp = 3;
}
  • Order消息格式有3个字段,在消息中承载的数据分别对应每一个字段。其中每个字段都有一个名字和一种类型。

    • 指定字段类型:在proto协议中,字段的类型包括字符串(string)、整形(int32、int64…)、枚举(enum)等数据类型
    • 分配标识符:在消息字段中,每个字段都有唯一的一个标识符。最小的标识号可以从1开始,最大到536870911。不可以使用其中的[19000-19999]的标识号, Protobuf协议实现中对这些进行了预留。如果非要在.proto文件中使用这些预留标识号,编译时就会报警。
    • 指定字段规则:字段的修饰符包含三种类型,分别是:
      • required:一个格式良好的消息一定要含有1个这种字段。表示该值是必须要设置的;
      • optional:消息格式中该字段可以有0个或1个值(不超过1个)。
      • repeated:在一个格式良好的消息中,这种字段可以重复任意多次(包括0次)。重复的值的顺序会被保留。表示该值可以重复,相当于Go中的slice。
  • 【注意:】使用required弊多于利;在实际开发中更应该使用optional和repeated而不是required。

    • 添加更多消息类型:在同一个.proto文件中,可以定义多个消息类型。多个消息类型分开定义即可。

使用 Protobuf 的步骤

  • 创建扩展名为.proto的文件,并编写代码。比如创建person.proto文件,内容如下:
syntax = "proto2";
package example;
message Person {
required string Name = 1;
required int32 Age = 2;
required string From = 3;
}
  • 编译.proto文件,生成Go语言文件。执行如下命令:
protoc --go_out = . test.proto
  • 执行protoc –go_out=. test.proto生成对应的person.pb.go文件。并构建对应的example目录,存放生成的person.pb.go文件。

  • 在程序中使用Protobuf
    在程序中有如下代码:

package main
import (
"fmt"
"ProtocDemo/example"
"github.com/golang/protobuf/proto"
"os"
)
func main() {
fmt.Println("Hello World. \n")
msg_test := &example.Person{
Name: proto.String("Davie"),
Age: proto.Int(18),
From: proto.String("China"),
}
//序列化
msgDataEncoding, err := proto.Marshal(msg_test)
if err != nil {
panic(err.Error())
return
}
msgEntity := example.Person{}
err = proto.Unmarshal(msgDataEncoding, &msgEntity)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
return
}
fmt.Printf("姓名:%s\n\n", msgEntity.GetName())
fmt.Printf("年龄:%d\n\n", msgEntity.GetAge())
fmt.Printf("国籍:%s\n\n", msgEntity.GetFrom())
}
  • 执行程序

执行程序

Protobuf 协议语法

  • message:Protobuf中定义一个数据结构需要用到关键字message,这一点和Java的class,Go语言中的struct类似。

  • 标识号:在消息的定义中,每个字段等号后面都有唯一的标识号,用于在反序列化过程中识别各个字段的,一旦开始使用就不能改变。标识号从整数1开始,依次递增,每次增加1,标识号的范围为1~2^29 – 1,其中[19000-19999]为Protobuf协议预留字段,开发者不建议使用该范围的标识号;一旦使用,在编译时Protoc编译器会报出警告。

  • 字段规则:字段规则有三种:

    1. required:该规则规定,消息体中该字段的值是必须要设置的。
    2. optional:消息体中该规则的字段的值可以存在,也可以为空,optional的字段可以根据defalut设置默认值。
    3. repeated:消息体中该规则字段可以存在多个(包括0个),该规则对应java的数组或者go语言的slice。
  • 数据类型:常见的数据类型与protoc协议中的数据类型映射如下:

数据类型映射

  • 枚举类型:proto协议支持使用枚举类型,和正常的编程语言一样,枚举类型可以使用enum关键字定义在.proto文件中:
enum Age{
male=1;
female=2;
}
  • 字段默认值:.proto文件支持在进行message定义时设置字段的默认值,可以通过default进行设置,如下所示:
message Address {
required sint32 id = 1 [default = 1];
required string name = 2 [default = '北京'];
optional string pinyin = 3 [default = 'beijing'];
required string address = 4;
required bool flag = 5 [default = true];
}
  • 导入:如果需要引用的message是写在别的.proto文件中,可以通过import "xxx.proto"来进行引入:

  • 嵌套:message与message之间可以嵌套定义,比如如下形式:

syntax = "proto2";
package example;
message Person {
required string Name = 1;
required int32 Age = 2;
required string From = 3;
optional Address Addr = 4;
message Address {
required sint32 id = 1;
required string name = 2;
optional string pinyin = 3;
required string address = 4;
}
}
  • message更新规则:message定义以后如果需要进行修改,为了保证之前的序列化和反序列化能够兼容新的message,message的修改需要满足以下规则:
    • 不可以修改已存在域中的标识号。
    • 所有新增添的域必须是 optional 或者 repeated。
    • 非required域可以被删除。但是这些被删除域的标识号不可以再次被使用。
    • 非required域可以被转化,转化时可能发生扩展或者截断,此时标识号和名称都是不变的。
    • sint32和sint64是相互兼容的。
    • fixed32兼容sfixed32。 fixed64兼容sfixed64。
    • optional兼容repeated。发送端发送repeated域,用户使用optional域读取,将会读取repeated域的最后一个元素。
    • Protobuf 序列化后所生成的二进制消息非常紧凑,这得益于 Protobuf 采用的非常巧妙的 Encoding 方法。接下来看一看Protobuf协议是如何实现高效编码的。

Protobuf序列化原理

  • 之前已经做过描述,Protobuf的message中有很多字段,每个字段的格式为:修饰符 字段类型 字段名 = 域号;

Varint

  • Varint是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。

  • Varint中的每个byte的最高位bit有特殊的含义,如果该位为1,表示后续的byte也是该数字的一部分,如果该位为0,则结束。其他的7个bit都用来表示数字。因此小于128的数字都可以用一个byte表示。大于128的数字,比如300,会用两个字节来表示:1010 1100 0000 0010。下图演示了 Google Protocol Buffer 如何解析两个bytes。注意到最终计算前将两个byte的位置相互交换过一次,这是因为 Google Protocol Buffer 字节序采用little-endian的方式。
    小端位序排列的Varint编码

  • 在序列化时,Protobuf按照TLV的格式序列化每一个字段,T即Tag,也叫Key;V是该字段对应的值value;L是Value的长度,如果一个字段是整形,这个L部分会省略。

  • 序列化后的Value是按原样保存到字符串或者文件中,Key按照一定的转换条件保存起来,序列化后的结果就是 KeyValueKeyValue…依次类推的样式,示意图如下所示:

序列化后的Value

  • 采用这种Key-Pair结构无需使用分隔符来分割不同的Field。对于可选的Field,如果消息中不存在该field,那么在最终的Message Buffer中就没有该field,这些特性都有助于节约消息本身的大小。比如,我们有消息order1:
Order.id = 10;
Order.desc = "bill";
  • 则最终的 Message Buffer 中有两个Key-Value对,一个对应消息中的id;另一个对应desc。Key用来标识具体的field,在解包的时候,Protocol Buffer根据Key就可以知道相应的Value应该对应于消息中的哪一个field。
  • Key 的定义如下:
(field_number << 3) | wire_type
  • 可以看到 Key 由两部分组成。第一部分是 field_number,比如消息lm.helloworld中field id 的field_number为1。第二部分为wire_type。表示 Value的传输类型。而wire_type有以下几种类型:

wire_type类型