提示:抖音的接口,需要client_key、client_secret等信息,若想调用需要申请。
最近,在做一个后台服务,该服务需要给车机端app提供一系列功能,比如,获得用户抖音账号的授权、视频的自动分享、token自动续期等。所有的功能中,我主要讲一下在视频上传时的一些过程(需要先拿到授权)。如下图所示,将视频上传到用户的抖音账号,对于单个视频,分两步:①上传视频;②创建视频。
从视频上传的相关参数要求可以看出,这是一个表单提交请求,url中携带2个参数,表单的请求体是上传视频的字节数组,且请求体也需要添加 Content-Disposition 和 Content-Type 属性。
分析完了抖音接口的相关功能后,剩下的就是敲代码了。这是一个后台服务,要操作的视频是存储在阿里的OSS中,我需要通过流的方式将OSS中的视频,直接上传到抖音服务器。这是一个SpringBoot项目,我计划通过RestTemplate来调用都有的API接口。
按照接口规则,需要在url中传递open_id和access_token,为了方便扩展,我写了一个工具类:
public abstract class DouYinUriUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(DouYinUriUtil.class);
private static final String BASE_URL = "https://open.douyin.com/";
public static final String VIDEO_UPLOAD_URL = BASE_URL + "video/upload/";
public static URI forUpload(String openId, String accessToken) {
MultiValueMap params = paramsToMap(openId, accessToken);
return UriComponentsBuilder.fromHttpUrl(VIDEO_UPLOAD_URL)
.queryParams(params)
.build()
.toUri();
}
private static MultiValueMap paramsToMap(String openId, String accessToken) {
Assert.notNull(openId, "必须提供openId");
Assert.notNull(accessToken, "必须提供accessToken");
MultiValueMap params = new LinkedMultiValueMap<>();
params.add("open_id", openId);
params.add("access_token", accessToken);
return params;
}
public static URI forPartUploadSecondStep(String openId, String accessToken, String uploadId, long partNumber) {
MultiValueMap params = openIdAndAccessTokenToMap(openId, accessToken);
Assert.notNull(uploadId, "必须提供uploadId");
try {
// 抖音要求:upload_id作为url参数时,必须encode,只对upload_id进行encode
String encodedUploadId = URLEncoder.encode(uploadId, "utf-8");
params.add("upload_id", encodedUploadId);
params.add("part_number", String.valueOf(partNumber));
String encodedUri = UriComponentsBuilder.fromHttpUrl(VIDEO_PART_UPLOAD_URL)
.queryParams(params)
.build()
.toString();
return new URI(encodedUri);
} catch (UnsupportedEncodingException | URISyntaxException e) {
LOGGER.info("对 uploadId {} 进行编码是出错:{}", uploadId, e.getMessage());
throw new RuntimeException(e);
}
}
private static MultiValueMap openIdAndAccessTokenToMap(String openId, String accessToken) {
Assert.notNull(openId, "必须提供openId");
Assert.notNull(accessToken, "必须提供accessToken");
MultiValueMap params = new LinkedMultiValueMap<>();
params.add("open_id", openId);
params.add("access_token", accessToken);
return params;
}
}
在构建请求体的时候,踩了一个坑,抖音官方文档要求,必须在请求体的请求头中有Content-Type这个参数,没有强调Content-Disposition的要求,由于技术能力有限,对表单提交的相关参数不是很熟悉,所以没有重视Content-Disposition参数的,当时接口迟迟调不通,后来反复实验,发现Content-Type这个参数实际是可有可无的,反而是Content-Disposition这个参数,必须包含name和filaname字段,且name字段的值必须为video。(这块不要喷我,当时确实对表单提交的请求体的参数要求不是很了解,后来恶补了一下http相关的知识,才发现这个就是表单实现文件上传的标准格式)
private ResponseEntity uploadWithInputStream(String openId, String accessToken, String fileName, InputStream inputStream) {
// 构建请求头
URI uri = DouYinUriUtil.forUpload(openId, accessToken);
RequestEntity.BodyBuilder builder = RequestEntity.method(HttpMethod.POST, uri);
builder.accept(MediaType.APPLICATION_JSON);
builder.contentType(MediaType.MULTIPART_FORM_DATA);
builder.header("User-Agent", "Java-SDK");
// 构建请求体
final MultiValueMap formParams = new LinkedMultiValueMap<>();
Resource resource = new InputStreamResource(inputStream);
MultiValueMap header = new LinkedMultiValueMap<>();
header.put("Content-Disposition", Collections.singletonList("form-data; name=\"video\"; filename=\"" + fileName + "\""));
header.put("Content-Type", Collections.singletonList("video/mp4"));
HttpEntity
抖音支持分片上传,但是并没有提文件应该如何分片,经过验证发现,如果是本地文件,通过linux的split命令将一个大文件进行分割,分隔后的文件一一上传后是可以合并成完整视频并成功发布的。另外,他提到单个分片建议20M,最小是5M,这个5M经过实践是指所有的分片都不能小于5M,所以一个给定的文件要分成几片需要琢磨一下,才采用的方法已经在代码里。
本地文件可以手工分片,但是我的视频在OSS里,是用户上传的一个完整视频,无论我是我提前将视频分好片存在OSS里还是下载到本地自己分片都是不可行的方案。找了很多资料没有解决,刚好我看到了OSS的API也支持分片上传,跟踪了一下他的源码发现,文件分片上传到OSS,是在一个文件上获得了多个输入流,每个流并截取不同的位置、并限定了大小,实现了分片上传。OSS的这个方式启发了我,我可以从OSS的一个文件获取多个流,然后通过BoundedInputStream控制每一个流的大小,按照我已经写完的单个文件流上传的方式将每个流分别上传。说干就干,经过测试,这个方式确实可行,直接上代码:
private void doUploadByPart(String openId, String accessToken, String uploadId, String ossKey) throws UpLoadByPartSecondStepFailedException {
Assert.hasLength(uploadId, "uploadId 不能为空");
Assert.hasLength(ossKey, "ossKey 不能为空");
// 获取文件大小
long fileSize = OssUtil.getFileSize(ossKey);
// 根据抖音的最小分片要求,计算分片数量
int fileCount = (int) (1 + fileSize / DouYinConstants.VIDEO_PART_SIZE);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("OSS文件 {} 大小:{},分片数量:{}", ossKey, fileSize, fileCount);
}
CountDownLatch taskCount = new CountDownLatch(fileCount);
// 用来存储每个任务的运行结果
boolean[] failedFlags = new boolean[fileCount];
for (int i = 0; i < fileCount; i++) {
long tempLength = DouYinConstants.VIDEO_PART_SIZE;
if (i == fileCount - 1) {
tempLength = fileSize % tempLength;
}
final long length = tempLength;
final int index = i;
final long partNumber = i + 1;
CompletableFuture.runAsync(() -> {
try {
// 为了上传的更快,我采用了多线程技术,每一个分片都是从oss里获取一个新的流
InputStream inputStream = OssUtil.getFileInputStream(ossKey);
long skipLength = index * DouYinConstants.VIDEO_PART_SIZE;
// 根据第几个分片,确定需要跳过的字节数
inputStream.skip(skipLength);
// 将InputStream包装成BoundedInputstream
BoundedInputStream boundedInputStream = new BoundedInputStream(inputStream, length);
ResponseEntity uploadResponse
= uploadWithInputStreamByPart(openId, accessToken, uploadId, partNumber, ossKey, boundedInputStream);
LOGGER.info("分 {} 片上传,第 {} 片,请求抖音返回信息:{}", fileCount, partNumber, uploadResponse);
Result uploadResultObj = responseHandler.submit(uploadResponse);
if (uploadResultObj.isFail()) {
throw new Exception(ossKey + "上传抖音失败:" + uploadResultObj.getErrMsg());
}
failedFlags[index] = false;
} catch (Exception e) {
LOGGER.error("分片上传任务失败,原因:", e.getMessage());
failedFlags[index] = true;
} finally {
// 完成 1 个任务计数减 1
taskCount.countDown();
}
}, threadPoolExecutor);
}
try {
// 2.2 等待所有任务完成
taskCount.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 2.3 判断是否所有任务都成功
for (boolean isFailed : failedFlags) {
if (isFailed) {
throw new UpLoadByPartSecondStepFailedException("异步任务失败");
}
}
}
private ResponseEntity uploadWithInputStreamByPart(String openId,
String accessToken,
String uploadId,
Long partNumber,
String fileName,
InputStream inputStream) {
// 构建请求头
URI uri = DouYinUriUtil.forPartUploadSecondStep(openId, accessToken, uploadId, partNumber);
RequestEntity> requestEntity = buildUploadHeaderAndBody(fileName, inputStream, uri);
// 上传至抖音服务器
ResponseEntity responseEntity = restTemplate.exchange(requestEntity, VideoCreateAwemeCreateInlineResponse2003.class);
// 关闭连接,否则会造成连接泄露,导致无连接可用
try {
inputStream.close();
} catch (IOException e) {
LOGGER.error("关闭Oss输入流时发生异常:{}", e.getMessage(), e);
throw new RuntimeException(e.getMessage());
}
return responseEntity;
}
private RequestEntity> buildUploadHeaderAndBody(String fileName, InputStream inputStream, URI uri) {
RequestEntity.BodyBuilder builder = RequestEntity.method(HttpMethod.POST, uri);
builder.accept(MediaType.APPLICATION_JSON);
builder.contentType(MediaType.MULTIPART_FORM_DATA);
builder.header("User-Agent", "Java-SDK");
// 构建请求体
final MultiValueMap formParams = new LinkedMultiValueMap<>();
Resource resource = new InputStreamResource(inputStream);
MultiValueMap header = new LinkedMultiValueMap<>();
header.put("Content-Disposition", Collections.singletonList("form-data; name=\"video\"; filename=\"" + fileName + "\""));
header.put("Content-Type", Collections.singletonList("video/mp4"));
HttpEntity
上述代码是经过我优化后的最终结果。其实整个过程并不顺利,细心的你有可能发现,抖音要求的请求体是字节数组,而我提供的是流,这个地方也是经历了一个采坑的过程的。通过将文件读到字节数组里,然后封装到请求体力确实可以实现上传,但我觉得当服务器遇到多个并发大文件上传的请求时,比较耗费内存,所以尝试通过流的方式进行写入,最后经过验证将InputStream包装成Resource是可行的。所以你看到了上边的代码。