You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hc.apache.org by ol...@apache.org on 2019/12/30 09:58:06 UTC

[httpcomponents-client] branch master updated: HTTPCORE-615: Implement HTTP-based cache serializer-deserializer. (#192)

This is an automated email from the ASF dual-hosted git repository.

olegk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/httpcomponents-client.git


The following commit(s) were added to refs/heads/master by this push:
     new f765a81  HTTPCORE-615: Implement HTTP-based cache serializer-deserializer. (#192)
f765a81 is described below

commit f765a81b31d9cbb25c2653f5bb881505ab2bc2ae
Author: Scott Gifford <sg...@suspectclass.com>
AuthorDate: Mon Dec 30 04:57:58 2019 -0500

    HTTPCORE-615: Implement HTTP-based cache serializer-deserializer. (#192)
    
    HTTPCORE-615: Implement HTTP-based cache serializer-deserializer.
---
 .gitattributes                                     |   1 +
 .../cache/HttpByteArrayCacheEntrySerializer.java   | 409 +++++++++++++++++++++
 ...HttpByteArrayCacheEntrySerializerTestUtils.java | 342 +++++++++++++++++
 .../TestHttpByteArrayCacheEntrySerializer.java     | 397 ++++++++++++++++++++
 .../test/resources/ApacheLogo.httpbytes.serialized | Bin 0 -> 35161 bytes
 .../src/test/resources/ApacheLogo.png              | Bin 0 -> 34983 bytes
 .../resources/escapedHeader.httpbytes.serialized   |  13 +
 .../resources/invalidHeader.httpbytes.serialized   |   8 +
 .../resources/missingHeader.httpbytes.serialized   |   7 +
 .../src/test/resources/noBody.httpbytes.serialized |   8 +
 .../resources/simpleObject.httpbytes.serialized    |   9 +
 .../test/resources/variantMap.httpbytes.serialized |  13 +
 .../variantMapMissingKey.httpbytes.serialized      |   9 +
 .../variantMapMissingValue.httpbytes.serialized    |   9 +
 pom.xml                                            |   1 +
 15 files changed, 1226 insertions(+)

diff --git a/.gitattributes b/.gitattributes
index 1e21b19..cd443a1 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -21,3 +21,4 @@
 *.html   text diff=html
 *.css    text
 *.js     text
+*.serialized binary
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/HttpByteArrayCacheEntrySerializer.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/HttpByteArrayCacheEntrySerializer.java
new file mode 100644
index 0000000..d5b77fd
--- /dev/null
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/HttpByteArrayCacheEntrySerializer.java
@@ -0,0 +1,409 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation.  For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+
+package org.apache.hc.client5.http.impl.cache;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
+import org.apache.hc.client5.http.cache.HttpCacheEntry;
+import org.apache.hc.client5.http.cache.HttpCacheEntrySerializer;
+import org.apache.hc.client5.http.cache.HttpCacheStorageEntry;
+import org.apache.hc.client5.http.cache.Resource;
+import org.apache.hc.client5.http.cache.ResourceIOException;
+import org.apache.hc.core5.annotation.Experimental;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.HttpVersion;
+import org.apache.hc.core5.http.ProtocolVersion;
+import org.apache.hc.core5.http.impl.io.AbstractMessageParser;
+import org.apache.hc.core5.http.impl.io.AbstractMessageWriter;
+import org.apache.hc.core5.http.impl.io.DefaultHttpResponseParser;
+import org.apache.hc.core5.http.impl.io.SessionInputBufferImpl;
+import org.apache.hc.core5.http.impl.io.SessionOutputBufferImpl;
+import org.apache.hc.core5.http.io.SessionInputBuffer;
+import org.apache.hc.core5.http.io.SessionOutputBuffer;
+import org.apache.hc.core5.http.message.BasicHttpRequest;
+import org.apache.hc.core5.http.message.BasicLineFormatter;
+import org.apache.hc.core5.http.message.StatusLine;
+import org.apache.hc.core5.util.CharArrayBuffer;
+
+/**
+ * Cache serializer and deserializer that uses an HTTP-like format.
+ *
+ * Existing libraries for reading and writing HTTP are used, and metadata is encoded into HTTP
+ * pseudo-headers for storage.
+ */
+@Experimental
+public class HttpByteArrayCacheEntrySerializer implements HttpCacheEntrySerializer<byte[]> {
+    public static final HttpByteArrayCacheEntrySerializer INSTANCE = new HttpByteArrayCacheEntrySerializer();
+
+    private static final String SC_CACHE_ENTRY_PREFIX = "hc-";
+
+    private static final String SC_HEADER_NAME_STORAGE_KEY = SC_CACHE_ENTRY_PREFIX + "sk";
+    private static final String SC_HEADER_NAME_RESPONSE_DATE = SC_CACHE_ENTRY_PREFIX + "resp-date";
+    private static final String SC_HEADER_NAME_REQUEST_DATE = SC_CACHE_ENTRY_PREFIX + "req-date";
+    private static final String SC_HEADER_NAME_NO_CONTENT = SC_CACHE_ENTRY_PREFIX + "no-content";
+    private static final String SC_HEADER_NAME_VARIANT_MAP_KEY = SC_CACHE_ENTRY_PREFIX + "varmap-key";
+    private static final String SC_HEADER_NAME_VARIANT_MAP_VALUE = SC_CACHE_ENTRY_PREFIX + "varmap-val";
+
+    private static final String SC_CACHE_ENTRY_PRESERVE_PREFIX = SC_CACHE_ENTRY_PREFIX + "esc-";
+
+    private static final int BUFFER_SIZE = 8192;
+
+    public HttpByteArrayCacheEntrySerializer() {
+    }
+
+    @Override
+    public byte[] serialize(final HttpCacheStorageEntry httpCacheEntry) throws ResourceIOException {
+        if (httpCacheEntry.getKey() == null) {
+            throw new IllegalStateException("Cannot serialize cache object with null storage key");
+        }
+        // content doesn't need null-check because it's validated in the HttpCacheStorageEntry constructor
+
+        // Fake HTTP request, required by response generator
+        // Use request method from httpCacheEntry, but as far as I can tell it will only ever return "GET".
+        final HttpRequest httpRequest = new BasicHttpRequest(httpCacheEntry.getContent().getRequestMethod(), "/");
+
+        final CacheValidityPolicy cacheValidityPolicy = new NoAgeCacheValidityPolicy();
+        final CachedHttpResponseGenerator cachedHttpResponseGenerator = new CachedHttpResponseGenerator(cacheValidityPolicy);
+
+        final SimpleHttpResponse httpResponse = cachedHttpResponseGenerator.generateResponse(httpRequest, httpCacheEntry.getContent());
+
+        try(final ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+            escapeHeaders(httpResponse);
+            addMetadataPseudoHeaders(httpResponse, httpCacheEntry);
+
+            final byte[] bodyBytes = httpResponse.getBodyBytes();
+            final int resourceLength;
+
+            if (bodyBytes == null) {
+                // This means no content, for example a 204 response
+                httpResponse.addHeader(SC_HEADER_NAME_NO_CONTENT, Boolean.TRUE.toString());
+                resourceLength = 0;
+            } else {
+                resourceLength = bodyBytes.length;
+            }
+
+            // Use the default, ASCII-only encoder for HTTP protocol and header values.
+            // It's the only thing that's widely used, and it's not worth it to support anything else.
+            final SessionOutputBufferImpl outputBuffer = new SessionOutputBufferImpl(BUFFER_SIZE);
+            final AbstractMessageWriter<SimpleHttpResponse> httpResponseWriter = makeHttpResponseWriter(outputBuffer);
+            httpResponseWriter.write(httpResponse, outputBuffer, out);
+            outputBuffer.flush(out);
+            final byte[] headerBytes = out.toByteArray();
+
+            final byte[] bytes = new byte[headerBytes.length + resourceLength];
+            System.arraycopy(headerBytes, 0, bytes, 0, headerBytes.length);
+            if (resourceLength > 0) {
+                System.arraycopy(bodyBytes, 0, bytes, headerBytes.length, resourceLength);
+            }
+            return bytes;
+        } catch(final IOException|HttpException e) {
+            throw new ResourceIOException("Exception while serializing cache entry", e);
+        }
+    }
+
+    @Override
+    public HttpCacheStorageEntry deserialize(final byte[] serializedObject) throws ResourceIOException {
+        try (final InputStream in = makeByteArrayInputStream(serializedObject);
+             final ByteArrayOutputStream bytesOut = new ByteArrayOutputStream(serializedObject.length) // this is bigger than necessary but will save us from reallocating
+        ) {
+            final SessionInputBufferImpl inputBuffer = new SessionInputBufferImpl(BUFFER_SIZE);
+            final AbstractMessageParser<ClassicHttpResponse> responseParser = makeHttpResponseParser();
+            final ClassicHttpResponse response = responseParser.parse(inputBuffer, in);
+
+            // Extract metadata pseudo-headers
+            final String storageKey = getCachePseudoHeaderAndRemove(response, SC_HEADER_NAME_STORAGE_KEY);
+            final Date requestDate = getCachePseudoHeaderDateAndRemove(response, SC_HEADER_NAME_REQUEST_DATE);
+            final Date responseDate = getCachePseudoHeaderDateAndRemove(response, SC_HEADER_NAME_RESPONSE_DATE);
+            final boolean noBody = getCachePseudoHeaderBooleanAndRemove(response, SC_HEADER_NAME_NO_CONTENT);
+            final Map<String, String> variantMap = getVariantMapPseudoHeadersAndRemove(response);
+            unescapeHeaders(response);
+
+            final Resource resource;
+            if (noBody) {
+                // This means no content, for example a 204 response
+                resource = null;
+            } else {
+                copyBytes(inputBuffer, in, bytesOut);
+                resource = new HeapResource(bytesOut.toByteArray());
+            }
+
+            final HttpCacheEntry httpCacheEntry = new HttpCacheEntry(
+                    requestDate,
+                    responseDate,
+                    response.getCode(),
+                    response.getHeaders(),
+                    resource,
+                    variantMap
+            );
+
+            return new HttpCacheStorageEntry(storageKey, httpCacheEntry);
+        } catch (final IOException|HttpException e) {
+            throw new ResourceIOException("Error deserializing cache entry", e);
+        }
+    }
+
+    /**
+     * Helper method to make a new HTTP response writer.
+     * <p>
+     * Useful to override for testing.
+     */
+    protected AbstractMessageWriter<SimpleHttpResponse> makeHttpResponseWriter(final SessionOutputBuffer outputBuffer) {
+        return new SimpleHttpResponseWriter();
+    }
+
+    /**
+     * Helper method to make a new ByteArrayInputStream.
+     * <p>
+     * Useful to override for testing.
+     */
+    protected InputStream makeByteArrayInputStream(final byte[] bytes) {
+        return new ByteArrayInputStream(bytes);
+    }
+
+    /**
+     * Helper method to make a new HTTP Response parser.
+     * <p>
+     * Useful to override for testing.
+     */
+    protected AbstractMessageParser<ClassicHttpResponse> makeHttpResponseParser() {
+        return new DefaultHttpResponseParser();
+    }
+
+    /**
+     * Modify the given response to escape any header names that start with the prefix we use for our own pseudo-headers,
+     * prefixing them with an escape sequence we can use to recover them later.
+     *
+     * @param httpResponse HTTP response object to escape headers in
+     * @see #unescapeHeaders(HttpResponse) for the corresponding un-escaper.
+     */
+    private static void escapeHeaders(final HttpResponse httpResponse) {
+        final Header[] headers = httpResponse.getHeaders();
+        for (final Header header : headers) {
+            if (header.getName().startsWith(SC_CACHE_ENTRY_PREFIX)) {
+                httpResponse.removeHeader(header);
+                httpResponse.addHeader(SC_CACHE_ENTRY_PRESERVE_PREFIX + header.getName(), header.getValue());
+            }
+        }
+    }
+
+    /**
+     * Modify the given response to remove escaping from any header names we escaped before saving.
+     *
+     * @param httpResponse HTTP response object to un-escape headers in
+     * @see #unescapeHeaders(HttpResponse) for the corresponding escaper
+     */
+    private void unescapeHeaders(final HttpResponse httpResponse) {
+        final Header[] headers = httpResponse.getHeaders();
+        for (final Header header : headers) {
+            if (header.getName().startsWith(SC_CACHE_ENTRY_PRESERVE_PREFIX)) {
+                httpResponse.removeHeader(header);
+                httpResponse.addHeader(header.getName().substring(SC_CACHE_ENTRY_PRESERVE_PREFIX.length()), header.getValue());
+            }
+        }
+    }
+
+    /**
+     * Modify the given response to add our own cache metadata as pseudo-headers.
+     *
+     * @param httpResponse HTTP response object to add pseudo-headers to
+     */
+    private void addMetadataPseudoHeaders(final HttpResponse httpResponse, final HttpCacheStorageEntry httpCacheEntry) {
+        httpResponse.addHeader(SC_HEADER_NAME_STORAGE_KEY, httpCacheEntry.getKey());
+        httpResponse.addHeader(SC_HEADER_NAME_RESPONSE_DATE, Long.toString(httpCacheEntry.getContent().getResponseDate().getTime()));
+        httpResponse.addHeader(SC_HEADER_NAME_REQUEST_DATE, Long.toString(httpCacheEntry.getContent().getRequestDate().getTime()));
+
+        // Encode these so map entries are stored in a pair of headers, one for key and one for value.
+        // Header keys look like: {Accept-Encoding=gzip}
+        // And header values like: {Accept-Encoding=gzip}https://example.com:1234/foo
+        for (final Map.Entry<String, String> entry : httpCacheEntry.getContent().getVariantMap().entrySet()) {
+            // Headers are ordered
+            httpResponse.addHeader(SC_HEADER_NAME_VARIANT_MAP_KEY, entry.getKey());
+            httpResponse.addHeader(SC_HEADER_NAME_VARIANT_MAP_VALUE, entry.getValue());
+        }
+    }
+
+    /**
+     * Get the string value for a single metadata pseudo-header, and remove it from the response object.
+     *
+     * @param response Response object to get and remove the pseudo-header from
+     * @param name     Name of metadata pseudo-header
+     * @return Value for metadata pseudo-header
+     * @throws ResourceIOException if the given pseudo-header is not found
+     */
+    private static String getCachePseudoHeaderAndRemove(final HttpResponse response, final String name) throws ResourceIOException {
+        final String headerValue = getOptionalCachePseudoHeaderAndRemove(response, name);
+        if (headerValue == null) {
+            throw new ResourceIOException("Expected cache header '" + name + "' not found");
+        }
+        return headerValue;
+    }
+
+    /**
+     * Get the string value for a single metadata pseudo-header if it exists, and remove it from the response object.
+     *
+     * @param response Response object to get and remove the pseudo-header from
+     * @param name     Name of metadata pseudo-header
+     * @return Value for metadata pseudo-header, or null if it does not exist
+     */
+    private static String getOptionalCachePseudoHeaderAndRemove(final HttpResponse response, final String name) {
+        final Header header = response.getFirstHeader(name);
+        if (header == null) {
+            return null;
+        }
+        response.removeHeader(header);
+        return header.getValue();
+    }
+
+    /**
+     * Get the date value for a single metadata pseudo-header, and remove it from the response object.
+     *
+     * @param response Response object to get and remove the pseudo-header from
+     * @param name     Name of metadata pseudo-header
+     * @return Value for metadata pseudo-header
+     * @throws ResourceIOException if the given pseudo-header is not found, or contains invalid data
+     */
+    private static Date getCachePseudoHeaderDateAndRemove(final HttpResponse response, final String name) throws ResourceIOException{
+        final String value = getCachePseudoHeaderAndRemove(response, name);
+        response.removeHeaders(name);
+        try {
+            final long timestamp = Long.parseLong(value);
+            return new Date(timestamp);
+        } catch (final NumberFormatException e) {
+            throw new ResourceIOException("Invalid value for header '" + name + "'", e);
+        }
+    }
+
+    /**
+     * Get the boolean value for a single metadata pseudo-header, and remove it from the response object.
+     *
+     * @param response Response object to get and remove the pseudo-header from
+     * @param name     Name of metadata pseudo-header
+     * @return Value for metadata pseudo-header
+     */
+    private static boolean getCachePseudoHeaderBooleanAndRemove(final ClassicHttpResponse response, final String name) {
+        // parseBoolean does not throw any exceptions, so no try/catch required.
+        return Boolean.parseBoolean(getOptionalCachePseudoHeaderAndRemove(response, name));
+    }
+
+
+    /**
+     * Get the variant map metadata pseudo-header, and remove it from the response object.
+     *
+     * @param response Response object to get and remove the pseudo-header from
+     * @return Extracted variant map
+     * @throws ResourceIOException if the given pseudo-header is not found, or contains invalid data
+     */
+    private static Map<String, String> getVariantMapPseudoHeadersAndRemove(final HttpResponse response) throws ResourceIOException {
+        final Header[] headers = response.getHeaders();
+        final Map<String, String> variantMap = new HashMap<>(0);
+        String lastKey = null;
+        for (final Header header : headers) {
+            if (header.getName().equals(SC_HEADER_NAME_VARIANT_MAP_KEY)) {
+                lastKey = header.getValue();
+                response.removeHeader(header);
+            } else if (header.getName().equals(SC_HEADER_NAME_VARIANT_MAP_VALUE)) {
+                if (lastKey == null) {
+                    throw new ResourceIOException("Found mismatched variant map key/value headers");
+                }
+                variantMap.put(lastKey, header.getValue());
+                lastKey = null;
+                response.removeHeader(header);
+            }
+        }
+
+        if (lastKey != null) {
+            throw new ResourceIOException("Found mismatched variant map key/value headers");
+        }
+
+        return variantMap;
+    }
+
+    /**
+     * Copy bytes from the given source buffer and input stream to the given output stream until end-of-file is reached.
+     *
+     * @param srcBuf Buffered input source
+     * @param src Unbuffered input source
+     * @param dest Output destination
+     * @throws IOException if an I/O error occurs
+     */
+    private static void copyBytes(final SessionInputBuffer srcBuf, final InputStream src, final OutputStream dest) throws IOException {
+        final byte[] buf = new byte[BUFFER_SIZE];
+        int lastBytesRead;
+        while ((lastBytesRead = srcBuf.read(buf, src)) != -1) {
+            dest.write(buf, 0, lastBytesRead);
+        }
+    }
+
+    /**
+     * Writer for SimpleHttpResponse.
+     *
+     * Copied from DefaultHttpResponseWriter, but wrapping a SimpleHttpResponse instead of a ClassicHttpResponse
+     */
+    // Seems like the DefaultHttpResponseWriter should be able to do this, but it doesn't seem to be able to
+    private class SimpleHttpResponseWriter extends AbstractMessageWriter<SimpleHttpResponse> {
+
+        public SimpleHttpResponseWriter() {
+            super(BasicLineFormatter.INSTANCE);
+        }
+
+        @Override
+        protected void writeHeadLine(
+                final SimpleHttpResponse message, final CharArrayBuffer lineBuf) {
+            final ProtocolVersion transportVersion = message.getVersion();
+            BasicLineFormatter.INSTANCE.formatStatusLine(lineBuf, new StatusLine(
+                    transportVersion != null ? transportVersion : HttpVersion.HTTP_1_1,
+                    message.getCode(),
+                    message.getReasonPhrase()));
+        }
+    }
+
+    /**
+     * Cache validity policy that always returns an age of 0.
+     *
+     * This prevents the Age header from being written to the cache (it does not make sense to cache it),
+     * and is the only thing the policy is used for in this case.
+     */
+    private class NoAgeCacheValidityPolicy extends CacheValidityPolicy {
+        @Override
+        public long getCurrentAgeSecs(final HttpCacheEntry entry, final Date now) {
+            return 0L;
+        }
+    }
+}
diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/HttpByteArrayCacheEntrySerializerTestUtils.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/HttpByteArrayCacheEntrySerializerTestUtils.java
new file mode 100644
index 0000000..2b71e84
--- /dev/null
+++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/HttpByteArrayCacheEntrySerializerTestUtils.java
@@ -0,0 +1,342 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation.  For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+
+package org.apache.hc.client5.http.impl.cache;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Map;
+
+import org.apache.hc.client5.http.cache.HttpCacheEntry;
+import org.apache.hc.client5.http.cache.HttpCacheEntrySerializer;
+import org.apache.hc.client5.http.cache.HttpCacheStorageEntry;
+import org.apache.hc.client5.http.cache.Resource;
+import org.apache.hc.client5.http.cache.ResourceIOException;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.message.BasicHeader;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+
+class HttpByteArrayCacheEntrySerializerTestUtils {
+    private final static String TEST_RESOURCE_DIR = "src/test/resources/";
+    static final String TEST_STORAGE_KEY = "xyzzy";
+
+    /**
+     * Template for incrementally building a new HttpCacheStorageEntry test object, starting from defaults.
+     */
+    static class HttpCacheStorageEntryTestTemplate {
+        Resource resource;
+        Date requestDate;
+        Date responseDate;
+        int responseCode;
+        Header[] responseHeaders;
+        Map<String, String> variantMap;
+        String storageKey;
+
+        /**
+         * Return a new HttpCacheStorageEntryTestTemplate instance with all default values.
+         *
+         * @return new HttpCacheStorageEntryTestTemplate instance
+         */
+        static HttpCacheStorageEntryTestTemplate makeDefault() {
+            return new HttpCacheStorageEntryTestTemplate(DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE);
+        }
+
+        /**
+         * Convert this template to a HttpCacheStorageEntry object.
+         * @return HttpCacheStorageEntry object
+         */
+        HttpCacheStorageEntry toEntry() {
+            return new HttpCacheStorageEntry(storageKey,
+                    new HttpCacheEntry(
+                            requestDate,
+                            responseDate,
+                            responseCode,
+                            responseHeaders,
+                            resource,
+                            variantMap));
+        }
+
+        /**
+         * Create a new template with all null values.
+         */
+        private HttpCacheStorageEntryTestTemplate() {
+        }
+
+        /**
+         * Create a new template values copied from the given template
+         *
+         * @param src Template to copy values from
+         */
+        private HttpCacheStorageEntryTestTemplate(final HttpCacheStorageEntryTestTemplate src) {
+            this.resource = src.resource;
+            this.requestDate = src.requestDate;
+            this.responseDate = src.responseDate;
+            this.responseCode = src.responseCode;
+            this.responseHeaders = src.responseHeaders;
+            this.variantMap = src.variantMap;
+            this.storageKey = src.storageKey;
+        }
+    }
+
+    /**
+     * Template with all default values.
+     *
+     * Used by HttpCacheStorageEntryTestTemplate#makeDefault()
+     */
+    private static final HttpCacheStorageEntryTestTemplate DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE = new HttpCacheStorageEntryTestTemplate();
+    static {
+        DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.resource = new HeapResource("Hello World".getBytes(StandardCharsets.UTF_8));
+        DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.requestDate = new Date(165214800000L);
+        DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.responseDate = new Date(2611108800000L);
+        DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.responseCode = 200;
+        DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.responseHeaders = new Header[]{
+                new BasicHeader("Content-type", "text/html"),
+                new BasicHeader("Cache-control", "public, max-age=31536000"),
+        };
+        DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.variantMap = Collections.emptyMap();
+        DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.storageKey = TEST_STORAGE_KEY;
+    }
+
+    /**
+     * Test serializing and deserializing the given object with the given factory.
+     * <p>
+     * Compares fields to ensure the deserialized object is equivalent to the original object.
+     *
+     * @param serializer Factory for creating serializers
+     * @param httpCacheStorageEntry    Original object to serialize and test against
+     * @throws Exception if anything goes wrong
+     */
+    static void testWithCache(final HttpCacheEntrySerializer<byte[]> serializer, final HttpCacheStorageEntry httpCacheStorageEntry) throws Exception {
+        final byte[] testBytes = serializer.serialize(httpCacheStorageEntry);
+        verifyHttpCacheEntryFromBytes(serializer, httpCacheStorageEntry, testBytes);
+    }
+
+    /**
+     * Verify that the given bytes deserialize to the given storage key and an equivalent cache entry.
+     *
+     * @param serializer Deserializer
+     * @param httpCacheStorageEntry Cache entry to verify
+     * @param testBytes Bytes to deserialize
+     * @throws Exception if anything goes wrong
+     */
+    static void verifyHttpCacheEntryFromBytes(final HttpCacheEntrySerializer<byte[]> serializer, final HttpCacheStorageEntry httpCacheStorageEntry, final byte[] testBytes) throws Exception {
+        final HttpCacheStorageEntry testEntry = httpCacheStorageEntryFromBytes(serializer, testBytes);
+
+        assertCacheEntriesEqual(httpCacheStorageEntry, testEntry);
+    }
+
+    /**
+     * Verify that the given test file deserializes to a cache entry equivalent to the one given.
+     *
+     * @param serializer Deserializer
+     * @param httpCacheStorageEntry    Cache entry to verify
+     * @param testFileName  Name of test file to deserialize
+     * @param reserializeFiles If true, test files will be regenerated and saved to disk
+     * @throws Exception if anything goes wrong
+     */
+    static void verifyHttpCacheEntryFromTestFile(final HttpCacheEntrySerializer<byte[]> serializer,
+                                                 final HttpCacheStorageEntry httpCacheStorageEntry,
+                                                 final String testFileName,
+                                                 final boolean reserializeFiles) throws Exception {
+        if (reserializeFiles) {
+            final File toFile = makeTestFileObject(testFileName);
+            saveEntryToFile(serializer, httpCacheStorageEntry, toFile);
+        }
+
+        final byte[] bytes = readTestFileBytes(testFileName);
+
+        verifyHttpCacheEntryFromBytes(serializer, httpCacheStorageEntry, bytes);
+    }
+
+    /**
+     * Get the bytes of the given test file.
+     *
+     * @param testFileName Name of test file to get bytes from
+     * @return Bytes from the given test file
+     * @throws Exception if anything goes wrong
+     */
+    static byte[] readTestFileBytes(final String testFileName) throws Exception {
+        final File testFile = makeTestFileObject(testFileName);
+        try(final FileInputStream testStream = new FileInputStream(testFile)) {
+            return readFullyStrict(testStream, testFile.length());
+        }
+    }
+
+    /**
+     * Create a new cache object from the given bytes.
+     *
+     * @param serializer Deserializer
+     * @param testBytes         Bytes to deserialize
+     * @return Deserialized object
+     */
+    static HttpCacheStorageEntry httpCacheStorageEntryFromBytes(final HttpCacheEntrySerializer<byte[]> serializer, final byte[] testBytes) throws ResourceIOException {
+        return serializer.deserialize(testBytes);
+    }
+
+    /**
+     * Assert that the given objects are equivalent
+     *
+     * @param expected Expected cache entry object
+     * @param actual   Actual cache entry object
+     * @throws Exception if anything goes wrong
+     */
+    static void assertCacheEntriesEqual(final HttpCacheStorageEntry expected, final HttpCacheStorageEntry actual) throws Exception {
+        assertEquals(expected.getKey(), actual.getKey());
+
+        final HttpCacheEntry expectedContent = expected.getContent();
+        final HttpCacheEntry actualContent = actual.getContent();
+
+        assertEquals(expectedContent.getRequestDate(), actualContent.getRequestDate());
+        assertEquals(expectedContent.getResponseDate(), actualContent.getResponseDate());
+        assertEquals(expectedContent.getStatus(), actualContent.getStatus());
+
+        assertArrayEquals(expectedContent.getVariantMap().keySet().toArray(), actualContent.getVariantMap().keySet().toArray());
+        for (final String key : expectedContent.getVariantMap().keySet()) {
+            assertEquals("Expected same variantMap values for key '" + key + "'",
+                    expectedContent.getVariantMap().get(key), actualContent.getVariantMap().get(key));
+        }
+
+        // Verify that the same headers are present on the expected and actual content.
+        for(final Header expectedHeader: expectedContent.getHeaders()) {
+            final Header actualHeader = actualContent.getFirstHeader(expectedHeader.getName());
+
+            if (actualHeader == null) {
+                if (expectedHeader.getName().equalsIgnoreCase("content-length")) {
+                    // This header is added by the cache implementation, and can be safely ignored
+                } else {
+                    fail("Expected header " + expectedHeader.getName() + " was not found");
+                }
+            } else {
+                assertEquals(expectedHeader.getName(), actualHeader.getName());
+                assertEquals(expectedHeader.getValue(), actualHeader.getValue());
+            }
+        }
+
+        if (expectedContent.getResource() == null) {
+            assertNull("Expected null resource", actualContent.getResource());
+        } else {
+            final byte[] expectedBytes = readFullyStrict(
+                    expectedContent.getResource().getInputStream(),
+                    (int) expectedContent.getResource().length()
+            );
+            final byte[] actualBytes = readFullyStrict(
+                    actualContent.getResource().getInputStream(),
+                    (int) actualContent.getResource().length()
+            );
+            assertArrayEquals(expectedBytes, actualBytes);
+        }
+    }
+
+    /**
+     * Get a File object for the given test file.
+     *
+     * @param testFileName Name of test file
+     * @return File for this test file
+     */
+    static File makeTestFileObject(final String testFileName) {
+        return new File(TEST_RESOURCE_DIR + testFileName);
+    }
+
+    /**
+     * Save the given cache entry serialized to the given file.
+     *
+     * @param serializer Serializer
+     * @param httpCacheStorageEntry Cache entry to serialize and save
+     * @param outFile Output file to write to
+     * @throws Exception if anything goes wrong
+     */
+    static void saveEntryToFile(final HttpCacheEntrySerializer<byte[]> serializer, final HttpCacheStorageEntry httpCacheStorageEntry, final File outFile) throws Exception {
+        final byte[] bytes = serializer.serialize(httpCacheStorageEntry);
+
+        OutputStream out = null;
+        try {
+            out = new FileOutputStream(outFile);
+            out.write(bytes);
+        } finally {
+            if (out != null) {
+                out.close();
+            }
+        }
+    }
+
+    /**
+     * Copy bytes from the given input stream to the given destination buffer until the buffer is full,
+     * or end-of-file is reached, and return the number of bytes read.
+     *
+     * @param src Input stream to read from
+     * @param dest Output buffer to write to
+     * @return Number of bytes read
+     * @throws IOException if an I/O error occurs
+     */
+    private static int readFully(final InputStream src, final byte[] dest) throws IOException {
+        final int destPos = 0;
+        final int length = dest.length;
+        int totalBytesRead = 0;
+        int lastBytesRead;
+
+        while (totalBytesRead < length && (lastBytesRead = src.read(dest, destPos + totalBytesRead, length - totalBytesRead)) != -1) {
+            totalBytesRead += lastBytesRead;
+        }
+        return totalBytesRead;
+    }
+
+    /**
+     * Copy bytes from the given input stream to a new buffer until the given length is reached,
+     * and returns the new buffer.  If end-of-file is reached first, an IOException is thrown
+     *
+     * @param src Input stream to read from
+     * @param length Maximum bytes to read
+     * @return All bytes from file
+     * @throws IOException if an I/O error occurs or end-of-file is reached before the requested
+     *                     number of bytes have been read
+     */
+    static byte[] readFullyStrict(final InputStream src, final long length) throws IOException {
+        if (length > Integer.MAX_VALUE) {
+            throw new IllegalArgumentException(String.format("Length %d is too large to fit in an array", length));
+        }
+        final int intLength = (int) length;
+        final byte[] dest = new byte[intLength];
+        final int bytesRead = readFully(src, dest);
+
+        if (bytesRead == intLength) {
+            return dest;
+        } else {
+            throw new IOException(String.format("Expected to read %d bytes but only got %d", intLength, bytesRead));
+        }
+    }
+}
diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestHttpByteArrayCacheEntrySerializer.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestHttpByteArrayCacheEntrySerializer.java
new file mode 100644
index 0000000..4c333e7
--- /dev/null
+++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestHttpByteArrayCacheEntrySerializer.java
@@ -0,0 +1,397 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation.  For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+
+package org.apache.hc.client5.http.impl.cache;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
+import org.apache.hc.client5.http.cache.HttpCacheEntrySerializer;
+import org.apache.hc.client5.http.cache.HttpCacheStorageEntry;
+import org.apache.hc.client5.http.cache.ResourceIOException;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.impl.io.AbstractMessageParser;
+import org.apache.hc.core5.http.impl.io.AbstractMessageWriter;
+import org.apache.hc.core5.http.io.SessionInputBuffer;
+import org.apache.hc.core5.http.io.SessionOutputBuffer;
+import org.apache.hc.core5.http.message.BasicHeader;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import static org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializerTestUtils.makeTestFileObject;
+import static org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializerTestUtils.httpCacheStorageEntryFromBytes;
+import static org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializerTestUtils.readTestFileBytes;
+import static org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializerTestUtils.testWithCache;
+import static org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializerTestUtils.verifyHttpCacheEntryFromTestFile;
+import static org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializerTestUtils.HttpCacheStorageEntryTestTemplate;
+
+public class TestHttpByteArrayCacheEntrySerializer {
+    private static final String SERIALIAZED_EXTENSION = ".httpbytes.serialized";
+
+    private static final String FILE_TEST_SERIALIZED_NAME = "ApacheLogo" + SERIALIAZED_EXTENSION;
+    private static final String SIMPLE_OBJECT_SERIALIZED_NAME = "simpleObject" + SERIALIAZED_EXTENSION;
+    private static final String VARIANTMAP_TEST_SERIALIZED_NAME = "variantMap" + SERIALIAZED_EXTENSION;
+    private static final String ESCAPED_HEADER_TEST_SERIALIZED_NAME = "escapedHeader" + SERIALIAZED_EXTENSION;
+    private static final String NO_BODY_TEST_SERIALIZED_NAME = "noBody" + SERIALIAZED_EXTENSION;
+    private static final String MISSING_HEADER_TEST_SERIALIZED_NAME = "missingHeader" + SERIALIAZED_EXTENSION;
+    private static final String INVALID_HEADER_TEST_SERIALIZED_NAME = "invalidHeader" + SERIALIAZED_EXTENSION;
+    private static final String VARIANTMAP_MISSING_KEY_TEST_SERIALIZED_NAME = "variantMapMissingKey" + SERIALIAZED_EXTENSION;
+    private static final String VARIANTMAP_MISSING_VALUE_TEST_SERIALIZED_NAME = "variantMapMissingValue" + SERIALIAZED_EXTENSION;
+
+    private static final String TEST_CONTENT_FILE_NAME = "ApacheLogo.png";
+
+    private HttpCacheEntrySerializer<byte[]> serializer;
+
+    // Manually set this to true to re-generate all of the serialized files
+    private final boolean reserializeFiles = false;
+
+    @Before
+    public void before() {
+        serializer = HttpByteArrayCacheEntrySerializer.INSTANCE;
+    }
+
+    /**
+     * Serialize and deserialize a simple object with a tiny body.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Test
+    public void simpleObjectTest() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        testWithCache(serializer, testEntry);
+    }
+
+    /**
+     * Serialize and deserialize a larger object with a binary file for a body.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Test
+    public void fileObjectTest() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        cacheObjectValues.resource = new FileResource(makeTestFileObject(TEST_CONTENT_FILE_NAME));
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        testWithCache(serializer, testEntry);
+    }
+
+    /**
+     * Serialize and deserialize a cache entry with no headers.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Test
+    public void noHeadersTest() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        cacheObjectValues.responseHeaders = new Header[0];
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        testWithCache(serializer, testEntry);
+    }
+
+    /**
+     * Serialize and deserialize a cache entry with an empty body.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Test
+    public void emptyBodyTest() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        cacheObjectValues.resource = new HeapResource(new byte[0]);
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        testWithCache(serializer, testEntry);
+    }
+
+    /**
+     * Serialize and deserialize a cache entry with no body.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Test
+    public void noBodyTest() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        cacheObjectValues.resource = null;
+        cacheObjectValues.responseCode = 204;
+
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        testWithCache(serializer, testEntry);
+    }
+
+    /**
+     * Serialize and deserialize a cache entry with a variant map.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Test
+    public void testSimpleVariantMap() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        final Map<String, String> variantMap = new HashMap<>();
+        variantMap.put("{Accept-Encoding=gzip}","{Accept-Encoding=gzip}https://example.com:1234/foo");
+        variantMap.put("{Accept-Encoding=compress}","{Accept-Encoding=compress}https://example.com:1234/foo");
+        cacheObjectValues.variantMap = variantMap;
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        testWithCache(serializer, testEntry);
+    }
+
+    /**
+     * Ensures that if the server uses our reserved header names we don't mix them up with our own pseudo-headers.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Test
+    public void testEscapedHeaders() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        cacheObjectValues.responseHeaders = new Header[] {
+                new BasicHeader("hc-test-1", "hc-test-1-value"),
+                new BasicHeader("hc-sk", "hc-sk-value"),
+                new BasicHeader("hc-resp-date", "hc-resp-date-value"),
+                new BasicHeader("hc-req-date-date", "hc-req-date-value"),
+                new BasicHeader("hc-varmap-key", "hc-varmap-key-value"),
+                new BasicHeader("hc-varmap-val", "hc-varmap-val-value"),
+        };
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        testWithCache(serializer, testEntry);
+    }
+
+    /**
+     * Attempt to store a cache entry with a null storage key.
+     *
+     * @throws Exception is expected
+     */
+    @Test(expected = IllegalStateException.class)
+    public void testNullStorageKey() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        cacheObjectValues.storageKey = null;
+
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+        serializer.serialize(testEntry);
+    }
+
+    /**
+     * Deserialize a simple object, from a previously saved file.
+     *
+     * Ensures that if the serialization format changes in an incompatible way, we'll find out in a test.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Test
+    public void simpleTestFromPreviouslySerialized() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        verifyHttpCacheEntryFromTestFile(serializer, testEntry, SIMPLE_OBJECT_SERIALIZED_NAME, reserializeFiles);
+    }
+
+    /**
+     * Deserialize a larger object with a binary body, from a previously saved file.
+     *
+     * Ensures that if the serialization format changes in an incompatible way, we'll find out in a test.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Test
+    public void fileTestFromPreviouslySerialized() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        cacheObjectValues.resource = new FileResource(makeTestFileObject(TEST_CONTENT_FILE_NAME));
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        verifyHttpCacheEntryFromTestFile(serializer, testEntry, FILE_TEST_SERIALIZED_NAME, reserializeFiles);
+    }
+
+    /**
+     * Deserialize a cache entry with a variant map, from a previously saved file.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Test
+    public void variantMapTestFromPreviouslySerialized() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        final Map<String, String> variantMap = new HashMap<>();
+        variantMap.put("{Accept-Encoding=gzip}","{Accept-Encoding=gzip}https://example.com:1234/foo");
+        variantMap.put("{Accept-Encoding=compress}","{Accept-Encoding=compress}https://example.com:1234/foo");
+        cacheObjectValues.variantMap = variantMap;
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        verifyHttpCacheEntryFromTestFile(serializer, testEntry, VARIANTMAP_TEST_SERIALIZED_NAME, reserializeFiles);
+    }
+
+    /**
+     * Deserialize a cache entry with headers that use our pseudo-header prefix and need escaping.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Test
+    public void escapedHeaderTestFromPreviouslySerialized() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        cacheObjectValues.responseHeaders = new Header[] {
+                new BasicHeader("hc-test-1", "hc-test-1-value"),
+                new BasicHeader("hc-sk", "hc-sk-value"),
+                new BasicHeader("hc-resp-date", "hc-resp-date-value"),
+                new BasicHeader("hc-req-date-date", "hc-req-date-value"),
+                new BasicHeader("hc-varmap-key", "hc-varmap-key-value"),
+                new BasicHeader("hc-varmap-val", "hc-varmap-val-value"),
+        };
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        verifyHttpCacheEntryFromTestFile(serializer, testEntry, ESCAPED_HEADER_TEST_SERIALIZED_NAME, reserializeFiles);
+    }
+
+    /**
+     * Deserialize a cache entry with no body, from a previously saved file.
+     *
+     * @throws Exception if anything goes wrong
+     */
+    @Test
+    public void noBodyTestFromPreviouslySerialized() throws Exception {
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        cacheObjectValues.resource = null;
+        cacheObjectValues.responseCode = 204;
+
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        verifyHttpCacheEntryFromTestFile(serializer, testEntry, NO_BODY_TEST_SERIALIZED_NAME, reserializeFiles);
+    }
+
+    /**
+     * Deserialize a cache entry in a bad format, expecting an exception.
+     *
+     * @throws Exception is expected
+     */
+    @Test(expected = ResourceIOException.class)
+    public void testInvalidCacheEntry() throws Exception {
+        // This file is a JPEG not a cache entry, so should fail to deserialize
+        final byte[] bytes = readTestFileBytes(TEST_CONTENT_FILE_NAME);
+        httpCacheStorageEntryFromBytes(serializer, bytes);
+    }
+
+    /**
+     * Deserialize a cache entry with a missing header, from a previously saved file.
+     *
+     * @throws Exception is expected
+     */
+    @Test(expected = ResourceIOException.class)
+    public void testMissingHeaderCacheEntry() throws Exception {
+        // This file hand-edited to be missing a necessary header
+        final byte[] bytes = readTestFileBytes(MISSING_HEADER_TEST_SERIALIZED_NAME);
+        httpCacheStorageEntryFromBytes(serializer, bytes);
+    }
+
+    /**
+     * Deserialize a cache entry with an invalid header value, from a previously saved file.
+     *
+     * @throws Exception is expected
+     */
+    @Test(expected = ResourceIOException.class)
+    public void testInvalidHeaderCacheEntry() throws Exception {
+        // This file hand-edited to have an invalid header
+        final byte[] bytes = readTestFileBytes(INVALID_HEADER_TEST_SERIALIZED_NAME);
+        httpCacheStorageEntryFromBytes(serializer, bytes);
+    }
+
+    /**
+     * Deserialize a cache entry with a missing variant map key, from a previously saved file.
+     *
+     * @throws Exception is expected
+     */
+    @Test(expected = ResourceIOException.class)
+    public void testVariantMapMissingKeyCacheEntry() throws Exception {
+        // This file hand-edited to be missing a VariantCache key
+        final byte[] bytes = readTestFileBytes(VARIANTMAP_MISSING_KEY_TEST_SERIALIZED_NAME);
+        httpCacheStorageEntryFromBytes(serializer, bytes);
+    }
+
+    /**
+     * Deserialize a cache entry with a missing variant map value, from a previously saved file.
+     *
+     * @throws Exception is expected
+     */
+    @Test(expected = ResourceIOException.class)
+    public void testVariantMapMissingValueCacheEntry() throws Exception {
+        // This file hand-edited to be missing a VariantCache value
+        final byte[] bytes = readTestFileBytes(VARIANTMAP_MISSING_VALUE_TEST_SERIALIZED_NAME);
+        httpCacheStorageEntryFromBytes(serializer, bytes);
+    }
+
+    /**
+     * Test an HttpException being thrown while serializing.
+     *
+     * @throws Exception is expected
+     */
+    @Test(expected = ResourceIOException.class)
+    public void testSerializeWithHTTPException() throws Exception {
+        final AbstractMessageWriter<SimpleHttpResponse> throwyHttpWriter = Mockito.mock(AbstractMessageWriter.class);
+        Mockito.
+                doThrow(new HttpException("Test Exception")).
+                when(throwyHttpWriter).
+                write(Mockito.any(SimpleHttpResponse.class), Mockito.any(SessionOutputBuffer.class), Mockito.any(OutputStream.class));
+
+        final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
+        final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
+
+        final HttpByteArrayCacheEntrySerializer testSerializer = new HttpByteArrayCacheEntrySerializer() {
+            protected AbstractMessageWriter<SimpleHttpResponse> makeHttpResponseWriter(final SessionOutputBuffer outputBuffer) {
+                return throwyHttpWriter;
+            }
+        };
+        testSerializer.serialize(testEntry);
+    }
+
+    /**
+     * Test an IOException being thrown while deserializing.
+     *
+     * @throws Exception is expected
+     */
+    @Test(expected = ResourceIOException.class)
+    public void testDeserializeWithIOException() throws Exception {
+        final AbstractMessageParser<ClassicHttpResponse> throwyParser = Mockito.mock(AbstractMessageParser.class);
+        Mockito.
+                doThrow(new IOException("Test Exception")).
+                when(throwyParser).
+                parse(Mockito.any(SessionInputBuffer.class), Mockito.any(InputStream.class));
+
+        final HttpByteArrayCacheEntrySerializer testSerializer = new HttpByteArrayCacheEntrySerializer() {
+            @Override
+            protected AbstractMessageParser<ClassicHttpResponse> makeHttpResponseParser() {
+                return throwyParser;
+            }
+        };
+        testSerializer.deserialize(new byte[0]);
+    }
+}
diff --git a/httpclient5-cache/src/test/resources/ApacheLogo.httpbytes.serialized b/httpclient5-cache/src/test/resources/ApacheLogo.httpbytes.serialized
new file mode 100644
index 0000000..1f6ccd6
Binary files /dev/null and b/httpclient5-cache/src/test/resources/ApacheLogo.httpbytes.serialized differ
diff --git a/httpclient5-cache/src/test/resources/ApacheLogo.png b/httpclient5-cache/src/test/resources/ApacheLogo.png
new file mode 100644
index 0000000..c6daa67
Binary files /dev/null and b/httpclient5-cache/src/test/resources/ApacheLogo.png differ
diff --git a/httpclient5-cache/src/test/resources/escapedHeader.httpbytes.serialized b/httpclient5-cache/src/test/resources/escapedHeader.httpbytes.serialized
new file mode 100644
index 0000000..ca7d018
--- /dev/null
+++ b/httpclient5-cache/src/test/resources/escapedHeader.httpbytes.serialized
@@ -0,0 +1,13 @@
+HTTP/1.1 200 OK
+Content-Length: 11
+hc-esc-hc-test-1: hc-test-1-value
+hc-esc-hc-sk: hc-sk-value
+hc-esc-hc-resp-date: hc-resp-date-value
+hc-esc-hc-req-date-date: hc-req-date-value
+hc-esc-hc-varmap-key: hc-varmap-key-value
+hc-esc-hc-varmap-val: hc-varmap-val-value
+hc-sk: xyzzy
+hc-resp-date: 2611108800000
+hc-req-date: 165214800000
+
+Hello World
\ No newline at end of file
diff --git a/httpclient5-cache/src/test/resources/invalidHeader.httpbytes.serialized b/httpclient5-cache/src/test/resources/invalidHeader.httpbytes.serialized
new file mode 100644
index 0000000..80f3102
--- /dev/null
+++ b/httpclient5-cache/src/test/resources/invalidHeader.httpbytes.serialized
@@ -0,0 +1,8 @@
+HTTP/1.1 200 OK
+Content-type: text/html
+Cache-control: public, max-age=31536000
+hc-sk: xyzzy
+hc-resp-date: badbadbad
+hc-req-date: 165214800000
+
+Hello World
diff --git a/httpclient5-cache/src/test/resources/missingHeader.httpbytes.serialized b/httpclient5-cache/src/test/resources/missingHeader.httpbytes.serialized
new file mode 100644
index 0000000..c73e8ca
--- /dev/null
+++ b/httpclient5-cache/src/test/resources/missingHeader.httpbytes.serialized
@@ -0,0 +1,7 @@
+HTTP/1.1 200 OK
+Content-type: text/html
+Cache-control: public, max-age=31536000
+hc-resp-date: 2611108800000
+hc-req-date: 165214800000
+
+Hello World
diff --git a/httpclient5-cache/src/test/resources/noBody.httpbytes.serialized b/httpclient5-cache/src/test/resources/noBody.httpbytes.serialized
new file mode 100644
index 0000000..f2ec4c4
--- /dev/null
+++ b/httpclient5-cache/src/test/resources/noBody.httpbytes.serialized
@@ -0,0 +1,8 @@
+HTTP/1.1 204 No Content
+Content-type: text/html
+Cache-control: public, max-age=31536000
+hc-sk: xyzzy
+hc-resp-date: 2611108800000
+hc-req-date: 165214800000
+hc-no-content: true
+
diff --git a/httpclient5-cache/src/test/resources/simpleObject.httpbytes.serialized b/httpclient5-cache/src/test/resources/simpleObject.httpbytes.serialized
new file mode 100644
index 0000000..c214ddc
--- /dev/null
+++ b/httpclient5-cache/src/test/resources/simpleObject.httpbytes.serialized
@@ -0,0 +1,9 @@
+HTTP/1.1 200 OK
+Content-type: text/html
+Cache-control: public, max-age=31536000
+Content-Length: 11
+hc-sk: xyzzy
+hc-resp-date: 2611108800000
+hc-req-date: 165214800000
+
+Hello World
\ No newline at end of file
diff --git a/httpclient5-cache/src/test/resources/variantMap.httpbytes.serialized b/httpclient5-cache/src/test/resources/variantMap.httpbytes.serialized
new file mode 100644
index 0000000..a383bf2
--- /dev/null
+++ b/httpclient5-cache/src/test/resources/variantMap.httpbytes.serialized
@@ -0,0 +1,13 @@
+HTTP/1.1 200 OK
+Content-type: text/html
+Cache-control: public, max-age=31536000
+Content-Length: 11
+hc-sk: xyzzy
+hc-resp-date: 2611108800000
+hc-req-date: 165214800000
+hc-varmap-key: {Accept-Encoding=gzip}
+hc-varmap-val: {Accept-Encoding=gzip}https://example.com:1234/foo
+hc-varmap-key: {Accept-Encoding=compress}
+hc-varmap-val: {Accept-Encoding=compress}https://example.com:1234/foo
+
+Hello World
\ No newline at end of file
diff --git a/httpclient5-cache/src/test/resources/variantMapMissingKey.httpbytes.serialized b/httpclient5-cache/src/test/resources/variantMapMissingKey.httpbytes.serialized
new file mode 100644
index 0000000..62f79ff
--- /dev/null
+++ b/httpclient5-cache/src/test/resources/variantMapMissingKey.httpbytes.serialized
@@ -0,0 +1,9 @@
+HTTP/1.1 200 OK
+Content-type: text/html
+Cache-control: public, max-age=31536000
+hc-sk: xyzzy
+hc-resp-date: 2611108800000
+hc-req-date: 165214800000
+hc-varmap-val: {Accept-Encoding=compress}https://example.com:1234/foo
+
+Hello World
diff --git a/httpclient5-cache/src/test/resources/variantMapMissingValue.httpbytes.serialized b/httpclient5-cache/src/test/resources/variantMapMissingValue.httpbytes.serialized
new file mode 100644
index 0000000..12858e1
--- /dev/null
+++ b/httpclient5-cache/src/test/resources/variantMapMissingValue.httpbytes.serialized
@@ -0,0 +1,9 @@
+HTTP/1.1 200 OK
+Content-type: text/html
+Cache-control: public, max-age=31536000
+hc-sk: xyzzy
+hc-resp-date: 2611108800000
+hc-req-date: 165214800000
+hc-varmap-key: {Accept-Encoding=gzip}
+
+Hello World
diff --git a/pom.xml b/pom.xml
index ab17af6..cc3e1c0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -303,6 +303,7 @@
           <excludes>
             <exclude>src/docbkx/resources/**</exclude>
             <exclude>src/test/resources/*.truststore</exclude>
+            <exclude>src/test/resources/*.serialized</exclude>
             <exclude>.checkstyle</exclude>
             <exclude>.externalToolBuilders/**</exclude>
             <exclude>maven-eclipse.xml</exclude>