在基于 Spring MVC 的 REST 服务中使用 Google Protocol Buffers

工程 | Josh Long | 2015 年 3 月 22 日 | ...

本周我在巴西圣保罗参加 QCon SP 并做演讲。我与一位热爱 Spring REST 技术栈的人进行了有趣的讨论,他想知道是否有比简单的 JSON 更高效的东西。确实有!我经常被问及 Spring 对高速二进制消息编码的支持。Spring 长期以来一直支持 Hessian、Burlap 等 RPC 编码,而 Spring Framework 4.1 引入了对 Google Protocol Buffers 的支持,后者也可用于 REST 服务。

摘自 Google Protocol Buffer 网站

Protocol buffers 是谷歌开发的一种语言中立、平台中立、可扩展的结构化数据序列化机制——可以把它想象成 XML,但更小、更快、更简单。你只需定义一次数据结构,然后就可以使用特殊生成的源代码轻松地将你的结构化数据写入和读取到各种数据流中,并使用各种语言进行操作...

谷歌在其内部、以服务为中心的架构中广泛使用了 Protocol Buffers。

一个 .proto 文档描述了要编码的类型(消息),并包含一种定义语言,对于使用过 C 语言 struct 的人来说应该很熟悉。在文档中,你定义了类型、这些类型中的字段,以及它们在类型中相对于彼此的顺序(内存偏移量!)。

这些 .proto 文件不是实现——它们是对可能通过网络传输的消息的声明性描述。它们可以对编码和解码的消息规定和验证约束——例如给定字段的类型或该字段的基数。你必须使用 Protobuf 编译器为你选择的语言生成相应的客户端代码。

你可以随意使用 Google Protocol Buffers,但在本文中,我们将探讨如何将其用作编码 REST 服务载荷(payloads)的一种方式。这种方法非常强大:你可以使用内容协商(content negotiation)向接受它的客户端(可以是任意数量的语言)提供高速的 Protocol Buffer 载荷,而对于不接受它的客户端则提供更传统的方式,如 JSON。

与典型的 JSON 编码消息相比,Protocol Buffer 消息提供了许多改进,特别是在微服务采用不同技术实现但需要以一致、长期的方式理解服务间通信的多语言系统(polyglot system)中。

Protocol Buffers 具有几个促进 API 稳定的优点

  • Protocol Buffers 提供免费的向后兼容性。在 Protocol Buffer 中,每个字段都有编号,因此你无需修改现有代码的行为即可维护与旧客户端的向后兼容性。不知道新字段的客户端不会尝试解析它们。
  • Protocol Buffers 提供了一个通过 requiredoptionalrepeated 关键字指定验证的天然场所。每个客户端以自己的方式强制执行这些约束。
  • Protocol Buffers 是多语言的,并且支持各种技术。仅在本博客的示例代码中,就包含针对演示的 Java 服务的 Ruby、Python 和 Java 客户端。这只需要使用众多受支持的编译器之一即可实现。

你可能会认为在同构服务环境中可以直接使用 Java 内置的序列化机制,但是,正如 Protocol Buffers 团队在首次引入这项技术时迅速指出的那样,即使是它也存在一些问题。Java 语言大师 Josh Bloch 的巨著《Effective Java》在第 213 页提供了更多详细信息。

我们首先来看一下我们的 .proto 文档

package demo;

option java_package = "demo";
option java_outer_classname = "CustomerProtos";

message Customer {
    required int32 id = 1;
    required string firstName = 2;
    required string lastName = 3;

    enum EmailType {
        PRIVATE = 1;
        PROFESSIONAL = 2;
    }

    message EmailAddress {
        required string email = 1;
        optional EmailType type = 2 [default = PROFESSIONAL];
    }

    repeated EmailAddress email = 5;
}

message Organization {
    required string name = 1;
    repeated Customer customer = 2;
}

然后将此定义传递给 protoc 编译器,并指定输出类型,如下所示

protoc -I=$IN_DIR --java_out=$OUT_DIR $IN_DIR/customer.proto

这是我编写的用于代码生成各种客户端的小 Bash 脚本

#!/usr/bin/env bash


SRC_DIR=`pwd`
DST_DIR=`pwd`/../src/main/

echo source:            $SRC_DIR
echo destination root:  $DST_DIR

function ensure_implementations(){

    # Ruby and Go aren't natively supported it seems
    # Java and Python are

    gem list | grep ruby-protocol-buffers || sudo gem install ruby-protocol-buffers
    go get -u github.com/golang/protobuf/{proto,protoc-gen-go}
}

function gen(){
    D=$1
    echo $D
    OUT=$DST_DIR/$D
    mkdir -p $OUT
    protoc -I=$SRC_DIR --${D}_out=$OUT $SRC_DIR/customer.proto
}

ensure_implementations

gen java
gen python
gen ruby

这将在 src/main/{java,ruby,python} 文件夹中生成相应的客户端类。我们首先来看一下 Spring MVC REST 服务本身。

一个 Spring MVC REST 服务

在我们的示例中,我们将注册一个 Spring Framework 4.1 的 org.springframework.http.converter.protobuf.ProtobufHttpMessageConverter 实例。这种类型是一个 HttpMessageConverterHttpMessageConverter 用于对 REST 服务调用中的请求和响应进行编码和解码。它们通常在发生某种内容协商(content negotiation)后激活:例如,如果客户端指定了 Accept: application/x-protobuf,那么我们的 REST 服务将返回 Protocol Buffer 编码的响应。

package demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.converter.protobuf.ProtobufHttpMessageConverter;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @Bean
    ProtobufHttpMessageConverter protobufHttpMessageConverter() {
        return new ProtobufHttpMessageConverter();
    }

    private CustomerProtos.Customer customer(int id, String f, String l, Collection<String> emails) {
        Collection<CustomerProtos.Customer.EmailAddress> emailAddresses =
                emails.stream().map(e -> CustomerProtos.Customer.EmailAddress.newBuilder()
                        .setType(CustomerProtos.Customer.EmailType.PROFESSIONAL)
                        .setEmail(e).build())
                        .collect(Collectors.toList());

        return CustomerProtos.Customer.newBuilder()
                .setFirstName(f)
                .setLastName(l)
                .setId(id)
                .addAllEmail(emailAddresses)
                .build();
    }

    @Bean
    CustomerRepository customerRepository() {
        Map<Integer, CustomerProtos.Customer> customers = new ConcurrentHashMap<>();
        // populate with some dummy data
        Arrays.asList(
                customer(1, "Chris", "Richardson", Arrays.asList("[email protected]")),
                customer(2, "Josh", "Long", Arrays.asList("[email protected]")),
                customer(3, "Matt", "Stine", Arrays.asList("[email protected]")),
                customer(4, "Russ", "Miles", Arrays.asList("[email protected]"))
        ).forEach(c -> customers.put(c.getId(), c));

        // our lambda just gets forwarded to Map#get(Integer)
        return customers::get;
    }

}

interface CustomerRepository {
    CustomerProtos.Customer findById(int id);
}


@RestController
class CustomerRestController {

    @Autowired
    private CustomerRepository customerRepository;

    @RequestMapping("/customers/{id}")
    CustomerProtos.Customer customer(@PathVariable Integer id) {
        return this.customerRepository.findById(id);
    }
}

这部分代码大部分都相当直观。这是一个 Spring Boot 应用。Spring Boot 会自动注册 HttpMessageConverter bean,因此我们只需要定义 ProtobufHttpMessageConverter bean,它就会被适当地配置。@Configuration 类注入了一些模拟数据和一个模拟的 CustomerRepository 对象。我不会在这里重现我们的 Protocol Buffer 的 Java 类型 demo/CustomerProtos.java,因为它是由代码生成的位操作和解析代码;读起来并不是很有趣。一个便利之处在于,Java 实现会自动提供 builder 方法,用于在 Java 中快速创建这些类型的实例。

代码生成的类型是类似 struct 的简单对象。它们适用于用作 DTO,但不应将其用作 API 的基础。请不要使用 Java 继承来扩展它们以引入新功能;这会破坏实现,而且无论如何这也是不良的 OOP 实践。如果你想保持代码更整洁,只需根据需要进行包装和适配,例如在包装器中适当处理从 ORM 实体到 Protocol Buffer 客户端类型的转换。

HttpMessageConverter 也可用于 Spring 的 REST 客户端 RestTemplate。这是相应的 Java 语言单元测试

package demo;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.IntegrationTest;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.protobuf.ProtobufHttpMessageConverter;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.web.client.RestTemplate;

import java.util.Arrays;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = DemoApplication.class)
@WebAppConfiguration
@IntegrationTest
public class DemoApplicationTests {

    @Configuration
    public static class RestClientConfiguration {

        @Bean
        RestTemplate restTemplate(ProtobufHttpMessageConverter hmc) {
            return new RestTemplate(Arrays.asList(hmc));
        }

        @Bean
        ProtobufHttpMessageConverter protobufHttpMessageConverter() {
            return new ProtobufHttpMessageConverter();
        }
    }

    @Autowired
    private RestTemplate restTemplate;

    private int port = 8080;

    @Test
    public void contextLoaded() {

        ResponseEntity<CustomerProtos.Customer> customer = restTemplate.getForEntity(
                "http://127.0.0.1:" + port + "/customers/2", CustomerProtos.Customer.class);

        System.out.println("customer retrieved: " + customer.toString());

    }

}

一切都按预期工作,不仅在 Java 和 Spring 中,而且在 Ruby 和 Python 中也是如此。为了完整性,这里是一个使用 Ruby 的简单客户端(省略了客户端类型)

#!/usr/bin/env ruby

require './customer.pb'
require 'net/http'
require 'uri'

uri = URI.parse('http://localhost:8080/customers/3')
body = Net::HTTP.get(uri)
puts Demo::Customer.parse(body)

…这里是一个 Python 客户端(省略了客户端类型)


#!/usr/bin/env python

import urllib
import customer_pb2

if __name__ == '__main__':
    customer = customer_pb2.Customer()
    customers_read = urllib.urlopen('http://localhost:8080/customers/1').read()
    customer.ParseFromString(customers_read)
    print customer

接下来做什么

如果你需要支持多种语言且速度非常快的消息编码,Protocol Buffers 是一个引人注目的选择。还有其他编码技术,如 AvroThrift,但它们都没有 Protocol Buffers 那样成熟和根深蒂固。你也不一定非要将 Protocol Buffers 用于 REST。如果这是你的风格,你可以将其集成到某种 RPC 服务中。客户端实现几乎和 Cloud Foundry 的 buildpack 一样多——所以你几乎可以在 Cloud Foundry 上运行任何东西,并在所有服务中享受到同样高速、一致的消息传递!

本示例的代码也已在线提供,所以不要犹豫,快去看看吧!

另外...

大家好,2015 年,我一直在努力每周写一篇随机的技术技巧类文章,基于我在社区中看到或在 Pivotal 博客上看到的引起大家兴趣的事物。我利用这些每周发布的(好吧!好吧!——像《This Week in Spring》那样规律地发布并不容易,但到目前为止我还没有错过任何一周!:-))文章,作为一个机会,不是专注于某个特定的新版本本身,而是关注 Spring 在某些社区用例中的应用,这些用例可能是跨领域的,或者可能只是受益于被 spotlight 关注到。到目前为止,我们已经探讨了各种各样的主题——Vaadin、Activiti、12 要素应用风格配置、更智能的服务间调用、Couchbase 等等。我们还准备了一些有趣的内容。不过,我想知道大家还想看到讨论哪些话题。如果你对想看的内容有什么想法,或者想贡献自己的社区文章,可以通过 Twitter (@starbuxman) 或电子邮件 (jlong [at] pivotal [dot] io) 联系我。我将一如既往地为您服务。

订阅 Spring 新闻通讯

订阅 Spring 新闻通讯,保持联系

订阅

领先一步

VMware 提供培训和认证,助力你的职业发展。

了解更多

获取支持

Tanzu Spring 通过一个简单的订阅即可为 OpenJDK™、Spring 和 Apache Tomcat® 提供支持和二进制文件。

了解更多

即将举办的活动

查看 Spring 社区所有即将举办的活动。

查看全部