调用抖音API接口,分享视频

提示:抖音的接口,需要client_key、client_secret等信息,若想调用需要申请。

1. 需求及背景介绍

        最近,在做一个后台服务,该服务需要给车机端app提供一系列功能,比如,获得用户抖音账号的授权、视频的自动分享、token自动续期等。所有的功能中,我主要讲一下在视频上传时的一些过程(需要先拿到授权)。如下图所示,将视频上传到用户的抖音账号,对于单个视频,分两步:①上传视频;②创建视频。

调用抖音API接口,分享视频_第1张图片

        从视频上传的相关参数要求可以看出,这是一个表单提交请求,url中携带2个参数,表单的请求体是上传视频的字节数组,且请求体也需要添加 Content-Disposition Content-Type 属性。

调用抖音API接口,分享视频_第2张图片

        分析完了抖音接口的相关功能后,剩下的就是敲代码了。这是一个后台服务,要操作的视频是存储在阿里的OSS中,我需要通过流的方式将OSS中的视频,直接上传到抖音服务器。这是一个SpringBoot项目,我计划通过RestTemplate来调用都有的API接口。

2. 任务一:单个文件上传至抖音

2.1 构建uri

按照接口规则,需要在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;
    }
}

2.2 构建请求头、请求体,及上传

        在构建请求体的时候,踩了一个坑,抖音官方文档要求,必须在请求体的请求头中有Content-Type这个参数,没有强调Content-Disposition的要求,由于技术能力有限,对表单提交的相关参数不是很熟悉,所以没有重视Content-Disposition参数的,当时接口迟迟调不通,后来反复实验,发现Content-Type这个参数实际是可有可无的,反而是Content-Disposition这个参数,必须包含name和filaname字段,且name字段的值必须为video。(这块不要喷我,当时确实对表单提交的请求体的参数要求不是很了解,后来恶补了一下http相关的知识,才发现这个就是表单实现文件上传的标准格式)

调用抖音API接口,分享视频_第3张图片

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 entity = new HttpEntity<>(resource, header);
    formParams.add("video", entity);
    RequestEntity> requestEntity = builder.body(formParams);

    // 上传至抖音服务器
    ResponseEntity responseEntity = restTemplate.exchange(requestEntity, VideoCreateAwemeCreateInlineResponse200.class);

    // 关闭连接,否则会造成连接泄露,导致无连接可用
    try {
        inputStream.close();
    } catch (IOException e) {
        LOGGER.error("关闭Oss输入流时发生异常:{}", e.getMessage(), e);
        throw new RuntimeException(e.getMessage());
    }
    return responseEntity;
} 
  

3. 任务二:分片上传

        抖音支持分片上传,但是并没有提文件应该如何分片,经过验证发现,如果是本地文件,通过linux的split命令将一个大文件进行分割,分隔后的文件一一上传后是可以合并成完整视频并成功发布的。另外,他提到单个分片建议20M,最小是5M,这个5M经过实践是指所有的分片都不能小于5M,所以一个给定的文件要分成几片需要琢磨一下,才采用的方法已经在代码里。

调用抖音API接口,分享视频_第4张图片

       本地文件可以手工分片,但是我的视频在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 entity = new HttpEntity<>(resource, header);
    formParams.add("video", entity);
    return builder.body(formParams);
}
 
  

4. 写在最后

        上述代码是经过我优化后的最终结果。其实整个过程并不顺利,细心的你有可能发现,抖音要求的请求体是字节数组,而我提供的是流,这个地方也是经历了一个采坑的过程的。通过将文件读到字节数组里,然后封装到请求体力确实可以实现上传,但我觉得当服务器遇到多个并发大文件上传的请求时,比较耗费内存,所以尝试通过流的方式进行写入,最后经过验证将InputStream包装成Resource是可行的。所以你看到了上边的代码。

你可能感兴趣的:(spring,spring,boot)