Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

理解HTTP协议中的multipart/form-data和客户端实现 #232

Open
soapgu opened this issue Nov 30, 2023 · 0 comments
Open

理解HTTP协议中的multipart/form-data和客户端实现 #232

soapgu opened this issue Nov 30, 2023 · 0 comments
Labels
安卓 安卓

Comments

@soapgu
Copy link
Owner

soapgu commented Nov 30, 2023

  • 起源,Restful中的非主流

本篇博客篇幅是安排是知识性和具体技术细节对半开。
目前我们几乎所有前端对接后端的api基本都遵循Restful的原则。不知道你有没有碰到过这样的场景。

  • 既要上传一个二进制的文件
  • 又要附加一些具体信息

既要又要,熟悉的节奏。往往这个时候我们设计协议就会陷入困境。
有个折中一点的方式就是把信息带到url的path里面去。但是如果信息数据多,而且分成好多个字段怎么办?

  • Content-Type: multipart/form-data闪亮登场

RFC文档里面有对multipart/form-data详细定义

不过看文档实在是太枯燥了,我们看下大致格式

# 请求体
--${Boundary}
Content-Disposition: form-data; name="name of file"
Content-Type: application/octet-stream

bytes of file
--${Boundary}
Content-Disposition: form-data; name="name of pdf"; filename="pdf-file.pdf"
Content-Type: application/octet-stream

bytes of pdf file
--${Boundary}
Content-Disposition: form-data; name="key"
Content-Type: text/plain;charset=UTF-8

text encoded in UTF-8
--${Boundary}--

  • 一些姿势点

Boundary:Part的分界,由一段随机字符串组成
Part:由 Content-Disposition: form-data; name="{part name}"作为头内容,和HTTP的头相似
Other attributes:比如二进制文件,我们需要定义filename,否则不会被解析为文件!
Charset of text in form data:每一个part有自己独立的Content-Type可以加上charset支持不同的编码规则。

  • multipart/form-data的起源

其实 multipart/form-data在90年代就已经提出了!
可以从RFC2046里面看出
当时我们网页用的都是html,对于填写表单上传的场景。我们除了填写姓名、性别、学历 balabala等等信息以外,很可能还要上传一张报名照。这种二进制文件和结构化数据正好对应multipart/form-data的应用场景

  • 似曾相识的multipart/form-data

multipart/form-data并非第一次见,其实以前已经在使用很多年了!

以前用.net的时候用过WebClient上传文件,如果你用截包的方式查看报文,你可以发现用的就是multipart/form-data格式

图片 不过API确是一次只能上传一个文件,其实完全可以支持一次性上传多个文件。白瞎了multipart协议啊!

后面的HttpClient的内可以支持对multipart form更精细的控制,这里暂时不介绍了。
我们接下来操作相对陌生一点的安卓领域。

  • 安卓使用retrofit2组建完成multipart/form-data数据交互

retrofit对multipart的文档写得特别的简单

Multipart requests are used when @multipart is present on the method. Parts are declared using the @part annotation.

@multipart
@put("user/photo")
Call updateUser(@part("photo") RequestBody photo, @part("description") RequestBody description);

需要和源码和注释一起看,比如Part的注释

Denotes a single part of a multi-part request.
The parameter type on which this annotation exists will be processed in one of three ways:

  • If the type is okhttp3.MultipartBody.Part the contents will be used directly. Omit the name from the annotation (i.e., @part MultipartBody.Part part).

  • If the type is RequestBody the value will be used directly with its content type. Supply the part name in the annotation (e.g., @part("foo") RequestBody foo).

  • Other object types will be converted to an appropriate representation by using a converter. Supply the part name in the annotation (e.g., @part("foo") Image photo).

Values may be null which will omit them from the request body.

这里详细解释一下
Part注解根据参数类型分为三种情况

  1. okhttp3.MultipartBody.Part:这个类型(okhttp组件的)直接交给OKHttp组件处理,不做任何干预,Value可以省略不填(会转为name)。但是okhttp3.MultipartBody.Part里面的name必须要设置好
  2. okhttp3.RequestBody:content type会直接转为Part的content type,Value必填转为Part的name。
  3. 其他类型:会被类型工厂转换为相关的类型
    如果不举例子可能会“没头没脑”
图片

这里retrofit使用了Json转换器,这样任何其他对象都会转为类型为application/json,内容是json字符串。

  • 实战部分收尾

比如有个上传日志的api,既要上传设备详细信息,又要上传二进制日志文件。

那么retrofit的api的接口就要定义如下

public interface ILogApi {
    //other api...

    @Multipart
    @POST( "api/v1/logs" )
    Completable upload(@Part("device")RequestBody device, @Part("date")RequestBody date, @Part("filename")RequestBody fileName,@Part("os")RequestBody os ,@Part("version")RequestBody version , @Part("extraInfo")RequestBody extraInfo, @Part MultipartBody.Part file);
}
  1. 这里device、date、filename、os、version、extraInfo都通过普通文本处理,这里类型用的RequestBody,所以里面的Value就是name,是必填的
  2. file作为二进制文件,这里类型用的MultipartBody.Part,这里就不用写Value。

接下来看看参数生成部分代码

public Completable upload(LogInfo info, File file){
        RequestBody filePart = RequestBody.create( MediaType.get("application/octet-stream"),file );
        MultipartBody.Part body =
                MultipartBody.Part.createFormData("file", file.getName(), filePart);
        return this.getApi().upload(  createFormData( info.getDevice() ) ,
                createFormData( info.getDate() ) ,
                createFormData( info.getFilename()),
                createFormData(info.getOs()),
                createFormData( info.getVersion()),
                createFormData( info.getExtraInfo() ),
                        body );
    }

    private RequestBody createFormData( String value ){
        return RequestBody.create( MediaType.get("text/plain"), value  );
    }
  • 可以看到文本字段都用"text/plain"生成RequestBody搞定啦,注意这里charset没有设,retrofit会自动帮我设置为UTF-8格式

  • 二进制部分MultipartBody.Part生成的时候,需要自己设置name和filename,content-type使用application/octet-stream

  • 关于filename的问题

其实一开始我文件不是直接用okhttp3.MultipartBody.Part而是用okhttp3.RequestBody生成的。主要问题是filename不会帮我自动加,造成服务端不能正确解析为文件。

这里retrofit的issue区里面有激烈讨论,有一个”非常难看“的解决方案
图片
这个filename是“硬”加上去的。

相关链接

而且我的filename是“动态”的,权衡利弊下还是用回我的okhttp3.MultipartBody.Part吧!

@soapgu soapgu added the 安卓 安卓 label Nov 30, 2023
soapgu added a commit that referenced this issue Dec 1, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
安卓 安卓
Projects
None yet
Development

No branches or pull requests

1 participant