Protobuf för serialisering del 2

I Protobuf för serialisering del 1 berättade jag om grunderna i Protobuf som kort kan beskrivas som ett binärt JSON-format. I den här artikeln tänkte jag visa hur man knyter i hop detta med Jersey som är ett snyggt API för RESTfulla webbtjänster. Som grädde på moset gör vi allt i Maven. Koden finns att hämta på https://github.com/cygni-stacktrace/protows.

Utgå i från en maven-archetype-webapp och lägg till stöd för dynamisk kompilering av protobuf-filer. Jag har valt att lägga protobuf-filerna under src/main/resources för att de automagiskt ska skeppas med i slutprodukten. Protofilerna används bara under kompilering, men de kan vara bra att ha som referens.

Under /project/build/plugins i pom.xml lägger vi till nedanstående pluginner för att kompilera proto-filerna till Javaklasser i en egen källkodskatalog. Allt som behövs är att protoc finns i din PATH och att sätta generatedSource.proto till target/generated-sources/proto i /project/properties. Integrationen med m2eclipse är klockren.

<plugin>
    <artifactId>maven-antrun-plugin</artifactId>
    <executions>
        <execution>
            <id>compile-protoc</id>
            <phase>generate-sources</phase>
            <configuration>
                <tasks>
                    <mkdir dir="${generatedSource.proto}" />
                    <path id="proto.path">
                        <fileset dir="src/main/resources">
                            <include name="**/*.proto" />
                        </fileset>
                    </path>
                    <pathconvert pathsep=" " property="proto.files" refid="proto.path" />
                    <exec executable="protoc" failonerror="true">
                        <arg value="--java_out=${generatedSource.proto}" />
                        <arg value="-I${project.basedir}/src/main/resources" />
                        <arg line="${proto.files}" />
                    </exec>
                </tasks>
            </configuration>
            <goals>
                <goal>run</goal>
            </goals>
        </execution>
    </executions>
</plugin>
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>build-helper-maven-plugin</artifactId>
    <version>1.5</version>
    <executions>
        <execution>
            <id>add-proto-source</id>
            <phase>generate-sources</phase>
            <goals>
                <goal>add-source</goal>
            </goals>
            <configuration>
                <sources>
                    <source>${generatedSource.proto}</source>
                </sources>
            </configuration>
        </execution>
    </executions>
</plugin>

Vi behöver också Jetty för att enkelt kunna testa vår webapp. Med kommandot maven jetty:run startar vi webappen under http://localhost:8080/${project.artifactId} (i mitt fall är artifactId protobuf).

<plugin> 
    <groupId>org.mortbay.jetty</groupId> 
    <artifactId>maven-jetty-plugin</artifactId> 
    <version>6.1.26</version> 
</plugin>

Vi behöver också lägga till protobuf och Jersey till /project/dependencies. Repositoryt är nödvändigt för att hitta Jersey-libbarna.

<repositories>
    <repository>
        <id>maven2-repository.dev.java.net</id>
        <name>Java.net Repository for Maven</name>
        <url>http://download.java.net/maven/2/</url>
        <layout>default</layout>
    </repository>
</repositories>
<dependencies>
    <dependency>
        <groupId>com.google.protobuf</groupId>
        <artifactId>protobuf-java</artifactId>
        <version>2.3.0</version>
        <type>jar</type>
        <scope>compile</scope>
    </dependency>
    <dependency>
        <groupId>com.sun.jersey</groupId>
        <artifactId>jersey-server</artifactId>
        <version>1.5</version>
    </dependency>
</dependencies>

Det var allt som behövdes för Maven. Vi behöver också en web.xml för vår webapp. Den definierar en enkel Jerseyservlet som sedan kan styras via annoteringar.

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.4" xmlns="http://java.sun.com/xml/ns/j2ee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd">
    <servlet>
        <servlet-name>Jersey Web Application</servlet-name>
        <servlet-class>com.sun.jersey.spi.container.servlet.ServletContainer</servlet-class>
        <init-param>
            <param-name>com.sun.jersey.config.property.packages</param-name>
            <param-value>se.cygni.protobuf.resource</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>Jersey Web Application</servlet-name>
        <url-pattern>/*</url-pattern>
    </servlet-mapping>
</web-app>

Jerseys annoteringsdrivna konfiguration är mycket trevlig att arbeta med. Nedan ser vi ett enkelt Hello-world-program. Man annoterar sin resurs med @Path för att binda den till en sökväg. Sedan binder man metoder till olika HTTP anrop genom att annotera dem med @GET, @POST, @PUT etc.

package se.cygni.stacktrace.protows;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
 
@Path("/hello")
public class HelloResource {
    @GET
    @Produces("text/plain")
    public String sayHello() {
        return "Hello, world!";
    }
}
$ curl http://localhost:8080/protows/hello
 
Hello, world!

Jersey kommer med stöd för XML via bl.a. StreamSource, SAXSource, DOMSource and Document och JAXB. Det finns även stöd för JSON via POJO eller JAXB-based JSON. I vårt fall behöver vi lägga till eget stöd för protobuf. Detta gör vi genom att förse Jersey med en producent resp konsument av protobuf data. Men innan vi gör det låt oss titta på vår UserAccountResource.

@Path("/account")
public class UserAccountResource {
    @GET
    @Produces("application/x-protobuf")
    public UserAccount getUserAccount() {
        return UserAccountProtos.UserAccount.newBuilder()
            .setId(100)
            .setUserName("jon.edvardsson")
            .setState(AccountState.PENDING_TANDC)
            .addService("BOOKS")
            .addService("MOVIES")
            .addService("MUSIC")
            .build();
    }
 
    @POST
    @Consumes("application/x-protobuf")
    @Produces("application/x-protobuf")
    public UserAccount acceptTermsAndConditions(UserAccount account) {
        return UserAccount.newBuilder(account)
            .setState(AccountState.ACTIVE).build();
    }
}

Vi kan alltså anropa ovanstående resurs med GET resp PUT. Ett GET-anrop kommer att returnera att ett protobuf-kodat konto som inte har accepterat Terms and Conditions. Ett PUT-anrop kommer istället att returnera det i HTTP kroppen givna kontot till aktivt och returnera det.

Tyvärr kommer Jersey inte kunna hantera den här resursen out-of-the-box eftersom det inte finns någon s.k. @Provider för att hantera application/x-protobuf. Vi behöver därför definiera en ProtobufMessageBodyWriter och en ProtobufMessageBodyReader som är ansvariga för att omvandla den interna protobuf-modellen till en byteström och vice versa.

@Provider
 
@Produces("application/x-protobuf")
 
public class ProtobufMessageBodyWriter implements MessageBodyWriter<Message> {
 
    public boolean isWriteable(Class<?> type, Type genericType,
 
            Annotation[] annotations, MediaType mediaType) {
 
        return Message.class.isAssignableFrom(type);
 
    }
 
    public long getSize(Message m, Class<?> type, Type genericType,
 
            Annotation[] annotations, MediaType mediaType) {
 
        return m.getSerializedSize();
 
    }
 
    public void writeTo(Message m, Class type, Type genericType,
 
            Annotation[] annotations, MediaType mediaType,
 
            MultivaluedMap httpHeaders, OutputStream entityStream)
 
            throws IOException, WebApplicationException {
 
        m.writeTo(entityStream);
 
    }
 
}
@Provider
 
@Consumes("application/x-protobuf")
 
public class ProtobufMessageBodyReader implements MessageBodyReader<Message> {
 
    public boolean isReadable(Class<?> type, Type genericType,
 
            Annotation[] annotations, MediaType mediaType) {
 
        return Message.class.isAssignableFrom(type);
 
    }
 
    public Message readFrom(Class<Message> type, Type genericType,
 
            Annotation[] annotations, MediaType mediaType,
 
            MultivaluedMap<String, String> httpHeaders, InputStream entityStream)
 
            throws IOException, WebApplicationException {
 
        try {
 
            Method newBuilder = type.getMethod("newBuilder");
 
            GeneratedMessage.Builder builder = (GeneratedMessage.Builder) newBuilder
 
                    .invoke(type);
 
            return builder.mergeFrom(entityStream).build();
 
        } catch (Exception e) {
 
            throw new WebApplicationException(e);
 
        }
 
    }
 
}

I och med att vi i vår web.xml angivit vilka paket som ska skannas kommer Jersey automatiskt att registrera våra @Provider:s.

Här följer några curl-exempel från bash. Notera att man måste sätta content-type till application/x-protobuf i put-exemplet nedan eftersom det var så vår Jersey-resurs var definierad.

$ curl -sS http://localhost:8080/protows/account
 
djon.edvardsson *♣BOOKS*♠MOVIES*♣MUSIC
 
$ curl -sS http://localhost:8080/protows/account|protoc --decode=UserAccount src/main/resources/UserAccountProtos.proto
 
id: 100
 
user_name: "jon.edvardsson"
 
state: PENDING_TANDC
 
service: "BOOKS"
 
service: "MOVIES"
 
service: "MUSIC"
 
$ curl -sS http://localhost:8080/protows/account | curl -sS -X PUT -H "Content-type: application/x-protobuf" http://localhost:8080/protows/account -T - |  protoc --decode=UserAccount src/main/resources/UserAccountProtos.proto
 
id: 100
 
user_name: "jon.edvardsson"
 
state: ACTIVE
 
service: "BOOKS"
 
service: "MOVIES"
 
service: "MUSIC"
 
$

Som ni ser är det mycket enkelt att komma igång och använda protobuf. I de system som jag jobbat med körde vi dock inte med application/x-protobuf som content-type, utan istället så definierade vi en egen vendor-typ enligt formatet application/vnd.mycompany.myprotocol-version, t.ex. appication/vnd.cygni.useraccount-2.0. På så sätt blir hanteringen av olika versioner lite mer modulär.