13c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller/*
23c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller * Copyright (C) 2014 Square, Inc.
33c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller *
43c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller * Licensed under the Apache License, Version 2.0 (the "License");
53c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller * you may not use this file except in compliance with the License.
63c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller * You may obtain a copy of the License at
73c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller *
83c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller *      http://www.apache.org/licenses/LICENSE-2.0
93c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller *
103c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller * Unless required by applicable law or agreed to in writing, software
113c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller * distributed under the License is distributed on an "AS IS" BASIS,
123c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
133c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller * See the License for the specific language governing permissions and
143c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller * limitations under the License.
153c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller */
163c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerpackage com.squareup.okhttp.benchmarks;
173c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
183c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport com.squareup.okhttp.internal.SslContextBuilder;
193c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport com.squareup.okhttp.internal.Util;
203c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.bootstrap.Bootstrap;
213c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.buffer.ByteBuf;
223c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.buffer.PooledByteBufAllocator;
233c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.channel.Channel;
243c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.channel.ChannelHandlerContext;
253c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.channel.ChannelInitializer;
263c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.channel.ChannelOption;
273c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.channel.ChannelPipeline;
283c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.channel.SimpleChannelInboundHandler;
293c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.channel.nio.NioEventLoopGroup;
303c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.channel.socket.SocketChannel;
313c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.channel.socket.nio.NioSocketChannel;
323c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.handler.codec.http.DefaultFullHttpRequest;
333c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.handler.codec.http.HttpClientCodec;
343c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.handler.codec.http.HttpContent;
353c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.handler.codec.http.HttpContentDecompressor;
363c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.handler.codec.http.HttpHeaders;
373c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.handler.codec.http.HttpMethod;
383c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.handler.codec.http.HttpObject;
393c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.handler.codec.http.HttpRequest;
403c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.handler.codec.http.HttpResponse;
413c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.handler.codec.http.HttpVersion;
423c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.handler.codec.http.LastHttpContent;
433c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport io.netty.handler.ssl.SslHandler;
443c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport java.net.URL;
453c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport java.util.ArrayDeque;
463c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport java.util.Deque;
473c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport java.util.concurrent.TimeUnit;
483c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport javax.net.ssl.SSLContext;
493c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerimport javax.net.ssl.SSLEngine;
503c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
513c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller/** Netty isn't an HTTP client, but it's almost one. */
523c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fullerclass NettyHttpClient implements HttpClient {
533c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  private static final boolean VERBOSE = false;
543c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
553c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  // Guarded by this. Real apps need more capable connection management.
563c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  private final Deque<HttpChannel> freeChannels = new ArrayDeque<HttpChannel>();
573c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  private final Deque<URL> backlog = new ArrayDeque<URL>();
583c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
593c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  private int totalChannels = 0;
603c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  private int concurrencyLevel;
613c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  private int targetBacklog;
623c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  private Bootstrap bootstrap;
633c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
643c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  @Override public void prepare(final Benchmark benchmark) {
653c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    this.concurrencyLevel = benchmark.concurrencyLevel;
663c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    this.targetBacklog = benchmark.targetBacklog;
673c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
683c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    ChannelInitializer<SocketChannel> channelInitializer = new ChannelInitializer<SocketChannel>() {
693c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      @Override public void initChannel(SocketChannel channel) throws Exception {
703c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        ChannelPipeline pipeline = channel.pipeline();
713c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
723c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        if (benchmark.tls) {
733c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller          SSLContext sslContext = SslContextBuilder.localhost();
743c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller          SSLEngine engine = sslContext.createSSLEngine();
753c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller          engine.setUseClientMode(true);
763c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller          pipeline.addLast("ssl", new SslHandler(engine));
773c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        }
783c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
793c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        pipeline.addLast("codec", new HttpClientCodec());
803c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        pipeline.addLast("inflater", new HttpContentDecompressor());
813c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        pipeline.addLast("handler", new HttpChannel(channel));
823c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      }
833c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    };
843c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
853c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    bootstrap = new Bootstrap();
863c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    bootstrap.group(new NioEventLoopGroup(concurrencyLevel))
873c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
883c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        .channel(NioSocketChannel.class)
893c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        .handler(channelInitializer);
903c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  }
913c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
923c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  @Override public void enqueue(URL url) throws Exception {
933c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    HttpChannel httpChannel = null;
943c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    synchronized (this) {
953c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      if (!freeChannels.isEmpty()) {
963c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        httpChannel = freeChannels.pop();
973c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      } else if (totalChannels < concurrencyLevel) {
983c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        totalChannels++; // Create a new channel. (outside of the synchronized block).
993c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      } else {
1003c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        backlog.add(url); // Enqueue this for later, to be picked up when another request completes.
1013c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        return;
1023c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      }
1033c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    }
1043c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    if (httpChannel == null) {
1053c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      Channel channel = bootstrap.connect(url.getHost(), Util.getEffectivePort(url))
1063c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller          .sync().channel();
1073c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      httpChannel = (HttpChannel) channel.pipeline().last();
1083c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    }
1093c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    httpChannel.sendRequest(url);
1103c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  }
1113c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1123c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  @Override public synchronized boolean acceptingJobs() {
1133c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    return backlog.size() < targetBacklog || hasFreeChannels();
1143c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  }
1153c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1163c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  private boolean hasFreeChannels() {
1173c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    int activeChannels = totalChannels - freeChannels.size();
1183c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    return activeChannels < concurrencyLevel;
1193c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  }
1203c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1213c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  private void release(HttpChannel httpChannel) {
1223c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    URL url;
1233c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    synchronized (this) {
1243c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      url = backlog.pop();
1253c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      if (url == null) {
1263c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        // There were no URLs in the backlog. Pool this channel for later.
1273c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        freeChannels.push(httpChannel);
1283c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        return;
1293c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      }
1303c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    }
1313c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1323c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    // We removed a URL from the backlog. Schedule it right away.
1333c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    httpChannel.sendRequest(url);
1343c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  }
1353c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1363c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  class HttpChannel extends SimpleChannelInboundHandler<HttpObject> {
1373c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    private final SocketChannel channel;
1383c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    byte[] buffer = new byte[1024];
1393c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    int total;
1403c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    long start;
1413c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1423c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    public HttpChannel(SocketChannel channel) {
1433c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      this.channel = channel;
1443c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    }
1453c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1463c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    private void sendRequest(URL url) {
1473c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      start = System.nanoTime();
1483c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      total = 0;
1493c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      HttpRequest request = new DefaultFullHttpRequest(
1503c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller          HttpVersion.HTTP_1_1, HttpMethod.GET, url.getPath());
1513c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      request.headers().set(HttpHeaders.Names.HOST, url.getHost());
1523c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      request.headers().set(HttpHeaders.Names.ACCEPT_ENCODING, HttpHeaders.Values.GZIP);
1533c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      channel.writeAndFlush(request);
1543c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    }
1553c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1563c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    @Override protected void channelRead0(
1573c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        ChannelHandlerContext context, HttpObject message) throws Exception {
1583c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      if (message instanceof HttpResponse) {
1593c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        receive((HttpResponse) message);
1603c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      }
1613c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      if (message instanceof HttpContent) {
1623c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        receive((HttpContent) message);
1633c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        if (message instanceof LastHttpContent) {
1643c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller          release(this);
1653c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        }
1663c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      }
1673c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    }
1683c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1693c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception {
1703c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      super.channelInactive(ctx);
1713c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    }
1723c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1733c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    void receive(HttpResponse response) {
1743c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      // Don't do anything with headers.
1753c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    }
1763c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1773c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    void receive(HttpContent content) {
1783c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      // Consume the response body.
1793c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      ByteBuf byteBuf = content.content();
1803c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      for (int toRead; (toRead = byteBuf.readableBytes()) > 0; ) {
1813c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        byteBuf.readBytes(buffer, 0, Math.min(buffer.length, toRead));
1823c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        total += toRead;
1833c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      }
1843c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1853c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      if (VERBOSE && content instanceof LastHttpContent) {
1863c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        long finish = System.nanoTime();
1873c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller        System.out.println(String.format("Transferred % 8d bytes in %4d ms",
1883c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller            total, TimeUnit.NANOSECONDS.toMillis(finish - start)));
1893c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      }
1903c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    }
1913c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller
1923c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    @Override public void exceptionCaught(ChannelHandlerContext context, Throwable cause) {
1933c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller      System.out.println("Failed: " + cause);
1943c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller    }
1953c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller  }
1963c938a3f6b61ce5e2dba0d039b03fe73b89fd26cNeil Fuller}
197