android: 使用okhttp可能引发OOM的一个点

遇到一个问题: 需要给所有的请求加签名校验以防刷接口;传入请求url及body生成一个文本串作为一个header传给服务端;已经有现成的签名检验方法 String doSignature(String url, byte[] body); 当前网络库基于 com.squareup.okhttp3:okhttp:3.14.2 .

这很简单了,当然是写一个 interceptor 然后将request对象的url及body传入就好.于是有:

public class SignInterceptor implements Interceptor {
    @NonNull
    @Override
    public Response intercept(@NonNull Chain chain) throws IOException {
        Request request = chain.request();
        RequestBody body = request.body();
        byte[] bodyBytes = null;
        if (body != null) {
            final Buffer buffer = new Buffer();
            body.writeTo(buffer);
            bodyBytes = buffer.readByteArray();
        }

        Request.Builder builder = request.newBuilder();
        HttpUrl oldUrl = request.url();
        final String url = oldUrl.toString();
        final String signed = doSignature(url, bodyBytes));
        if (!TextUtils.isEmpty(signed)) {
            builder.addHeader(SIGN_KEY_NAME, signed);
        }
        return chain.proceed(builder.build());
    }
}

okhttp的 ReqeustBody 是一个抽象类,内容输出只有 writeTo 方法,将内容写入到一个 BufferedSink 接口实现体里,然后再将数据转成 byte[] 也就是内存数组.能达到目的的类只有 Buffer ,它实现了 BufferedSink 接口并能提供转成内存数组的方法 readByteArray . 这貌似没啥问题呀,能造成OOM?

是的,要看请求类型,如果是一个上传文件的接口呢?如果这个文件比较大呢?上传接口有可能会用到 public static RequestBody create(final @Nullable MediaType contentType, final File file) 方法,如果是针对文件的实现体它的 writeTo 方法是 sink.writeAll(source); 而我们传给签名方法时用到的 Buffer.readByteArray 是将缓冲中的所有内容转成了内存数组, 这意味着文件中的所有内容被转成了内存数组, 就是在这个时机容易造成OOM! RequestBody.create 源码如下:

public static RequestBody create(final @Nullable MediaType contentType, final File file) {
    if (file == null) throw new NullPointerException("file == null");

    return new RequestBody() {
      @Override public @Nullable MediaType contentType() {
        return contentType;
      }

      @Override public long contentLength() {
        return file.length();
      }

      @Override public void writeTo(BufferedSink sink) throws IOException {
        try (Source source = Okio.source(file)) {
          sink.writeAll(source);
        }
      }
    };
  }

可以看到实现体持有了文件, Content-Length 返回了文件的大小, 内容全部转给了 Source 对象。

这确实是以前非常容易忽略的一个点,很少有对请求体作额外处理的操作,而一旦这个操作变成一次性的大内存分配, 非常容易造成OOM. 所以要如何解决呢? 签名方法又是如何处理的呢? 原来这个签名方法在这里偷了个懒——它只读取传入body的前4K内容,然后只针对这部分内容进行了加密,至于传入的这个内存数组本身多大并不考虑,完全把风险和麻烦丢给了外部(优秀的SDK!).

快速的方法当然是罗列白名单,针对上传接口服务端不进行加签验证, 但这容易挂一漏万,而且增加维护成本, 要签名方法sdk的人另写合适的接口等于要他们的命, 所以还是得从根本解决. 既然签名方法只读取前4K内容,我们便只将内容的前4K部分读取再转成方法所需的内存数组不就可了? 所以我们的目的是: 期望 RequestBody 能够读取一部分而不是全部的内容 . 能否继承 RequestBody 重写它的 writeTo ? 可以,但不现实,不可能全部替代现有的 RequestBody 实现类, 同时ok框架也有可能创建私有的实现类. 所以只能针对 writeTo 的参数 BufferedSink 作文章, 先得了解 BufferedSink 又是如何被okhttp框架调用的.

BufferedSink 相关的类包括 Buffer, Source ,都属于okio框架,okhttp只是基于okio的一坨, okio没有直接用java的io操作,而是另行写了一套io操作,具体是数据缓冲的操作.接上面的描述, Source 是怎么创建, 同时又是如何操作 BufferedSink 的? 在 Okio.java 中:

public static Source source(File file) throws FileNotFoundException {
    if (file == null) throw new IllegalArgumentException("file == null");
    return source(new FileInputStream(file));
  }
 
  public static Source source(InputStream in) {
    return source(in, new Timeout());
  }

  private static Source source(final InputStream in, final Timeout timeout) {
    return new Source() {
      @Override public long read(Buffer sink, long byteCount) throws IOException {
        try {
          timeout.throwIfReached();
          Segment tail = sink.writableSegment(1);
          int maxToCopy = (int) Math.min(byteCount, Segment.SIZE - tail.limit);
          int bytesRead = in.read(tail.data, tail.limit, maxToCopy);
          if (bytesRead == -1) return -1;
          tail.limit += bytesRead;
          sink.size += bytesRead;
          return bytesRead;
        } catch (AssertionError e) {
          if (isAndroidGetsocknameError(e)) throw new IOException(e);
          throw e;
        }
      }

      @Override public void close() throws IOException {
        in.close();
      }

      @Override public Timeout timeout() {
        return timeout;
      }
    };
  }

Source 把文件作为输入流 inputstream 进行了各种读操作, 但是它的 read 方法参数却是个 Buffer 实例,它又是从哪来的,又怎么和 BufferedSink 关联的? 只好再继续看 BufferedSink.writeAll 的实现体。

BufferedSink 的实现类就是 Buffer , 然后它的 writeAll 方法:

@Override public long writeAll(Source source) throws IOException {
    if (source == null) throw new IllegalArgumentException("source == null");
    long totalBytesRead = 0;
    for (long readCount; (readCount = source.read(this, Segment.SIZE)) != -1; ) {
      totalBytesRead += readCount;
    }
    return totalBytesRead;
  }

原来是显式的调用了 Source.read(Buffer,long) 方法,这样就串起来了,那个 Buffer 参数原来就是自身。

基本可以确定只要实现 BufferedSink 接口类, 然后判断读入的内容超过指定大小就停止写入就返回就可满足目的, 可以名之 FixedSizeSink .

然而麻烦的是 BufferedSink 的接口非常多, 将近30个方法, 不知道框架会在什么时机调用哪个方法,只能全部都实现! 其次是接口方法的参数有很多okio的类, 这些类的用法需要了解, 否则一旦用错了效果适得其反. 于是对一个类的了解变成对多个类的了解, 没办法只能硬着头皮写.

第一个接口就有点蛋疼: Buffer buffer(); BufferedSink 返回一个 Buffer 实例供外部调用, BufferedSink 的实现体即是 Buffer , 然后再返回一个 Buffer ?! 看了半天猜测 BufferedSink 是为了提供一个可写入的缓冲对象, 但框架作者也懒的再搞接口解耦的那一套了(唉,大家都是怎么简单怎么来). 于是 FixedSizeSink 至少需要持有一个 Buffer 对象, 它作实际的数据缓存,同时可以在需要 Source.read(Buffer ,long) 的地方作为参数传过去.

同时可以看到 RequestBody 的一个实现类 FormBody , 用这个 Buffer 对象直接写入一些数据:

private long writeOrCountBytes(@Nullable BufferedSink sink, boolean countBytes) {
    long byteCount = 0L;

    Buffer buffer;
    if (countBytes) {
      buffer = new Buffer();
    } else {
      buffer = sink.buffer();
    }

    for (int i = 0, size = encodedNames.size(); i < size; i++) {
      if (i > 0) buffer.writeByte('&');
      buffer.writeUtf8(encodedNames.get(i));
      buffer.writeByte('=');
      buffer.writeUtf8(encodedValues.get(i));
    }

    if (countBytes) {
      byteCount = buffer.size();
      buffer.clear();
    }

    return byteCount;
  }

有这样的操作就有可能限制不了缓冲区大小变化!不过数据量应该相对小一些而且这种用法场景相对少,我们指定的大小应该能覆盖的了这种情况。

接着还有一个接口 BufferedSink write(ByteString byteString) , 又得了解 ByteString 怎么使用, 真是心力交瘁啊...

@Override public Buffer write(ByteString byteString) {
    byteString.write(this);
    return this;
  }

Buffer 实现体里可以直接调用 ByteString.write(Buffer) 因为是包名访问,自己实现的 FixedSizeSink 声明在和同一包名 package okio; 也可以这样使用,如果是其它包名只能先转成 byte[] 了, ByteString 应该不大不然也不能这么搞(没有找到ByteString读取一段数据的方法):

@Override
    public BufferedSink write(@NotNull ByteString byteString) throws IOException {
        byte[] bytes = byteString.toByteArray();
        this.write(bytes);
        return this;
    }

总之就是把这些对象转成内存数组或者 Buffer 能够接受的参数持有起来!

重点关心的 writeAll 反而相对好实现一点, 我们连续读取指定长度的内容直到内容长度达到我们的阈值就行.

还有一个蛋疼的点是各种对象的read/write数据流方向:

Caller.read(Callee)/Caller.write(Callee) , 有的是从Caller到Callee, 有的是相反,被一个小类整的有点头疼……

最后上完整代码, 如果发现什么潜在的问题也可以交流下~:

public class FixedSizeSink implements BufferedSink {
    private static final int SEGMENT_SIZE = 4096;
    private final Buffer mBuffer = new Buffer();
    private final int mLimitSize;

    private FixedSizeSink(int size) {
        this.mLimitSize = size;
    }

    @Override
    public Buffer buffer() {
        return mBuffer;
    }

    @Override
    public BufferedSink write(@NotNull ByteString byteString) throws IOException {
        byte[] bytes = byteString.toByteArray();
        this.write(bytes);
        return this;
    }

    @Override
    public BufferedSink write(@NotNull byte[] source) throws IOException {
        this.write(source, 0, source.length);
        return this;
    }

    @Override
    public BufferedSink write(@NotNull byte[] source, int offset,
            int byteCount) throws IOException {
        long available = mLimitSize - mBuffer.size();
        int count = Math.min(byteCount, (int) available);
        android.util.Log.d(TAG, String.format("FixedSizeSink.offset=%d,"
                         "count=%d,limit=%d,size=%d",
                offset, byteCount, mLimitSize, mBuffer.size()));
        if (count > 0) {
            mBuffer.write(source, offset, count);
        }
        return this;
    }

    @Override
    public long writeAll(@NotNull Source source) throws IOException {
        this.write(source, mLimitSize);
        return mBuffer.size();
    }

    @Override
    public BufferedSink write(@NotNull Source source, long byteCount) throws IOException {
        final long count = Math.min(byteCount, mLimitSize - mBuffer.size());
        final long BUFFER_SIZE = Math.min(count, SEGMENT_SIZE);
        android.util.Log.d(TAG, String.format("FixedSizeSink.count=%d,limit=%d"
                         ",size=%d,segment=%d",
                byteCount, mLimitSize, mBuffer.size(), BUFFER_SIZE));
        long totalBytesRead = 0;
        long readCount;
        while (totalBytesRead < count && (readCount = source.read(mBuffer, BUFFER_SIZE)) != -1) {
            totalBytesRead = readCount;
        }
        return this;
    }

    @Override
    public int write(ByteBuffer src) throws IOException {
        final int available = mLimitSize - (int) mBuffer.size();
        if (available < src.remaining()) {
            byte[] bytes = new byte[available];
            src.get(bytes);
            this.write(bytes);
            return bytes.length;
        } else {
            return mBuffer.write(src);
        }
    }

    @Override
    public void write(@NotNull Buffer source, long byteCount) throws IOException {
        mBuffer.write(source, Math.min(byteCount, mLimitSize - mBuffer.size()));
    }

    @Override
    public BufferedSink writeUtf8(@NotNull String string) throws IOException {
        mBuffer.writeUtf8(string);
        return this;
    }

    @Override
    public BufferedSink writeUtf8(@NotNull String string, int beginIndex, int endIndex)
            throws IOException {
        mBuffer.writeUtf8(string, beginIndex, endIndex);
        return this;
    }

    @Override
    public BufferedSink writeUtf8CodePoint(int codePoint) throws IOException {
        mBuffer.writeUtf8CodePoint(codePoint);
        return this;
    }

    @Override
    public BufferedSink writeString(@NotNull String string,
            @NotNull Charset charset) throws IOException {
        mBuffer.writeString(string, charset);
        return this;
    }

    @Override
    public BufferedSink writeString(@NotNull String string, int beginIndex, int endIndex,
            @NotNull Charset charset) throws IOException {
        mBuffer.writeString(string, beginIndex, endIndex, charset);
        return this;
    }

    @Override
    public BufferedSink writeByte(int b) throws IOException {
        mBuffer.writeByte(b);
        return this;
    }

    @Override
    public BufferedSink writeShort(int s) throws IOException {
        mBuffer.writeShort(s);
        return this;
    }

    @Override
    public BufferedSink writeShortLe(int s) throws IOException {
        mBuffer.writeShortLe(s);
        return this;
    }

    @Override
    public BufferedSink writeInt(int i) throws IOException {
        mBuffer.writeInt(i);
        return this;
    }

    @Override
    public BufferedSink writeIntLe(int i) throws IOException {
        mBuffer.writeIntLe(i);
        return this;
    }

    @Override
    public BufferedSink writeLong(long v) throws IOException {
        mBuffer.writeLong(v);
        return this;
    }

    @Override
    public BufferedSink writeLongLe(long v) throws IOException {
        mBuffer.writeLongLe(v);
        return this;
    }

    @Override
    public BufferedSink writeDecimalLong(long v) throws IOException {
        mBuffer.writeDecimalLong(v);
        return this;
    }

    @Override
    public BufferedSink writeHexadecimalUnsignedLong(long v) throws IOException {
        mBuffer.writeHexadecimalUnsignedLong(v);
        return this;
    }

    @Override
    public void flush() throws IOException {
        mBuffer.flush();
    }

    @Override
    public BufferedSink emit() throws IOException {
        mBuffer.emit();
        return this;
    }

    @Override
    public BufferedSink emitCompleteSegments() throws IOException {
        mBuffer.emitCompleteSegments();
        return this;
    }

    @Override
    public OutputStream outputStream() {
        return mBuffer.outputStream();
    }

    @Override
    public boolean isOpen() {
        return mBuffer.isOpen();
    }

    @Override
    public Timeout timeout() {
        return mBuffer.timeout();
    }

    @Override
    public void close() throws IOException {
        mBuffer.close();
    }
}
我来评几句
登录后评论

已发表评论数()

相关站点

热门文章