一個小作業,初次嘗試華為云存儲,一點分享
原項目采用Spring Cloud Alibaba微服務技術、Spring Boot框架技術、VueJS前端框架開發技術,nacos注冊中心,數據庫為mysql
下面看一下沒有運用云存儲的原項目(可跳過):
原項目概覽:
關鍵代碼
application.yaml
server:port: 8007service:ipAddr: http://localhost
download:address: F:\file\spring:application:name: uploadService8007servlet:multipart:max-file-size: 20MBmax-request-size: 30MBresources:static-locations: file:f:/file/mvc:static-path-pattern: /file/**cloud:nacos:discovery:server-addr: localhost:8848
management:endpoints:web:exposure:include: '*'
其中定義了網關地址(在編碼過程中為“http://localhost”地址),文件請求最大大小為20MB,以及最大請求大小為30MB,文件映射地址,此處將/file/**映射為本地F盤下的file文件(此處可自定義本地盤路徑,自定義后將項目圖片素材解壓并存放至對應路徑中
啟動類
在src/main/Java目錄下添加com.qst.upload包,并創建啟動類port8007_upload,并添加啟動方法
@Import(value = WebMvcConfig.class)
@EnableDiscoveryClient@SpringBootApplication
public class port8007_upload {public static void main(String[] args) {SpringApplication.run(port8007_upload.class,args);}}
文件上傳控制器
在com.qst.upload包下創建文件上傳控制器UploadController,并添加圖片上傳,音頻文件上傳以及歌詞文件上傳接口方法
@RestController@RequestMapping("/upload")
public class UploadController {@AutowiredUploadService uploadService;@PostMapping("upImage")public Mess upLoadImage(MultipartFile file){if(CheckUtil.isImage(file.getOriginalFilename())) {String url = uploadService.uploadImage(file);return Mess.success().mess("文件上傳成功").data("url", url);}return Mess.fail().mess("文件格式錯誤");}@PostMapping("upLyric")public Mess upLoadLyric(MultipartFile file){if(CheckUtil.isLyric(file.getOriginalFilename())) {String url = uploadService.uploadLyric(file);return Mess.success().mess("文件上傳成功").data("url", url);}return Mess.fail().mess("文件格式錯誤");}@PostMapping("upMusic")public Mess upLoadMusic(MultipartFile file){if (CheckUtil.isMusic(file.getOriginalFilename())){Integer length= MusicUtil.getDuration(file);String url=uploadService.uploadMusic(file);return Mess.success().mess("上傳成功").data("url",url).data("timelength",length);}return Mess.fail().mess("文件格式錯誤");}}
實現文件上傳服務類
在com.qst.upload中創建文件上傳服務類UploadService,并添加生成文件名方法,將文件保存到本地方法,上傳歌曲方法,上傳歌詞方法以及上傳圖片方法
@Service
public class UploadService {@Value("${download.address}")String address;@Value("${service.ipAddr}")String ip;public String uploadImage(MultipartFile file){return upLoadFile(file,"image");}public String uploadLyric(MultipartFile file){return upLoadFile(file,"lyric");}public String uploadMusic(MultipartFile file){return upLoadFile(file,"music");}public String upLoadFile(MultipartFile file,String type){OutputStream os = null;InputStream is = null;String newName=getNewName(file);try {is=file.getInputStream();} catch (IOException e) {e.printStackTrace();}try {byte[] bs=new byte[1024];int length;File tempFile = new File(address.concat(type));if (!tempFile.exists()){tempFile.mkdirs();}os = new FileOutputStream(tempFile.getPath().concat(File.separator).concat(newName));while ((length = is.read(bs)) != -1) {os.write(bs, 0, length);}return ip.concat("/file/").concat(type).concat("/").concat(newName);}catch (Exception e){System.out.println(e.getMessage());}finally {try {os.close();is.close();}catch (Exception e){}}return null;}public String getNewName(MultipartFile file){String uuid= UUID.randomUUID().toString();String filename=file.getOriginalFilename();String newName=uuid+filename.substring(filename.lastIndexOf("."));return newName;}}
修改網關配置
網關微服務中添加上傳微服務服務名。
添加路由轉發,首先將上傳請求轉發到文件上傳模塊微服務中,由于配置了文件映射,所以將請求一級路徑(文件訪問請求)為“file”的請求轉發到文件上傳模塊微服務中
service:upload: uploadService8007
- id: uploaduri: lb://${service.upload}predicates:- Path=/upload/**- id: fileuri: lb://${service.upload}predicates:- Path=/file/**
測試
圖片上傳接口
請求地址:“localhost/upload/upImage”
請求類型:Post
請求體:file:{上傳文件}
響應:
{"code":?20,"message":?"文件上傳成功","success":?true,"data":?{"url":?"http://localhost/file/image/a062734c-ce62-49c3-938c-2ecf9337a267.jpg"}
}
在postman中點擊地址url可直接發送get請求,獲取剛剛上傳的文件?
其他上傳功能同理
把官方文檔過一過腦子
官方開發流程概覽:
序號 | 任務 | 說明 |
---|---|---|
1 | 創建項目 | 項目是您在AppGallery Connect(以下簡稱AGC)資源的組織實體。當您需要使用云存儲服務時,您需要先在AGC中創建您的項目。 說明 您可以通過創建不同的項目,實現分別在測試環境和開發環境使用云存儲。 |
2 | 開通云存儲服務 | - |
3 | 獲取API授權 | 在向AGC服務端發起REST API請求前,您需要先獲得AGC服務端的授權。 |
4 | 管理文件 | 通過調用云存儲REST API,您可以進行上傳文件、下載文件、刪除文件、文件元數據管理以及獲取云端某個目錄下的文件列表等操作。 |
其中第一步和第二步都是傻瓜式操作
其中第三步獲取API授權
創建API客戶端
API客戶端是AGC用于管理用戶訪問AppGallery Connect API的身份憑據。在訪問某個API前,必須創建有權訪問該API的API客戶端。
- 登錄AppGallery Connect,點擊“開發與服務”。
- 在項目列表中選擇需要獲取憑證的項目,在“項目設置”頁面點擊“Server SDK”頁簽。
- 點擊認證憑據區域內“API客戶端”旁的“創建”。
- 在彈出的提示框內點擊“確認”,完成認證憑據創建,點擊“下載認證憑據”下載json文件,獲取文件中的client_id和client_secret信息。?
將下載后的認證憑據文件agc-apiclient-*.json放置到您的Server服務器中指定路徑下,后續初始化SDK時將會使用到該文件?
獲取訪問API的Token
創建完API客戶端后需要到華為AGC平臺進行鑒權,鑒權通過后將獲得用于訪問AppGallery Connect API的Access Token。用戶憑借該Access Token即可訪問REST API。您可以調用獲取Token接口來獲取Access Token。
功能介紹
在使用API客戶端方式調用Connect API的接口前,需要通過華為開放平臺進行鑒權,并獲取認證通過后的Token。
接口原型
承載協議 | HTTPS POST |
---|---|
接口方向 | 開發者服務器 -> 華為服務器 |
接口URL | https://{domain}/api/oauth2/v1/token
|
數據格式 | 請求:Content-Type: application/json 響應:Content-Type: application/json |
請求參數
請求參數以JSON格式傳入,包含參數如下。
參數名稱 | 必選(M)/可選(O) | 數據類型 | 參數說明 |
---|---|---|---|
grant_type | M | String(256) | 固定傳入“client_credentials”。 |
client_id | M | String(256) | 客戶端ID,即下載項目級憑證agc-apiclient-*.json文件中的client_id。 |
client_secret | M | String(2048) | 客戶端密鑰,即下載項目級憑證agc-apiclient-*.json文件中的client_secret。 |
請求示例
- POST /api/oauth2/v1/token
- Host: connect-api.cloud.huawei.com
- Content-Type: application/json
- {
- "grant_type":"client_credentials",
- "client_id":"26********20",
- "client_secret":"************************"
- }
響應參數
返回值為JSON格式的字符串,包含參數如下。
參數名稱 | 必選(M)/可選(O) | 數據類型 | 參數說明 |
---|---|---|---|
access_token | O | String | 認證Token,用于AppGallery Connect API接口調用。 此參數只在獲取成功時返回。 |
expires_in | O | Long | access_token的有效期,單位秒。您需要在過期時間到達時重新調用本接口獲取新的access_token。 有效期為48小時,如果在有效期內再次調用接口獲取access_token時,新老access_token都是有效的。 此參數只在獲取成功時返回。 |
ret | O | String(100) | 獲取Token失敗時的錯誤信息,包含錯誤碼及描述信息的JSON字符串,格式為{"code":retcode, "msg": "description"},retcode為錯誤碼,description為錯誤碼描述信息。 |
?postman在線調試接口(有助于理解截止目前的操作)
HMS Core | Postman API Network
下載文件接口:
?
功能介紹
此接口用于使用用戶身份認證方式下載文件。
接口原型
承載協議 | HTTPS GET |
---|---|
接口方向 | 開發者服務器->華為服務器 |
接口URL | https://{domain}/{bucket_name}/{object_name} 注意
|
數據格式 | 請求:Content-Type: application/json 響應:Content-Type: application/json |
請求參數
Header
參數 | 類型 | 必選(M)/可選(O) | 說明 |
---|---|---|---|
Authorization | String | M | 認證信息,格式為“Authorization: Bearer ${access_token}”。access_token為獲取Token中獲取的access_token。 |
client_id | String | M | 客戶端ID,獲取方法參考創建API客戶端。 |
productId | String | M | 項目ID,查詢方法可參見查詢項目ID。 |
Range | String | O | HTTP標準協議頭,用于指定第一個字節和最后一個字節的位置,告訴服務器想獲取的文件范圍。如未指定,則獲取整個文件。 例如一個文件有900個字節,其范圍為0-899,則: Range: bytes=500- 表示讀取該文件的500-899字節。 Range: bytes=500-699 表示讀取該文件的500-699字節。 |
If-Modified-Since | String | O | 一個條件式請求首部。 服務器只在所請求的資源在給定的時間之后對內容進行過修改的情況下才會將資源返回,狀態碼為200 。如果請求的資源在給定的時間之后未經修改,則返回一個不帶消息主體的304響應。 格式:<day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT 舉例:Mon, 10 Apr 2023 07:28:00 GMT 說明 If-Modified-Since只可以用在GET或HEAD請求中。當與If-None-Match 一同出現時,If-Modified-Since會被忽略,除非服務器不支持If-None-Match。 |
If-None-Match | String | O | 一個條件式請求首部。 對于GET和HEAD方法,當且僅當服務器上沒有任何資源的ETag屬性值與這個首部中列出的相匹配時,服務器端會才返回所請求的資源,響應碼為200 ,否則返回304。其他方法暫不實現。 格式:If-None-Match: "<etag_value>" 不支持通配符*,不支持弱比較算法。 |
X-Agc-Trace-Id | String | O | 單個文件請求的Traceid。可不填或者隨機生成,用于日志打印跟蹤。 |
請求示例
- GET /v0/testagc-02reg/test3.jpg
- client_id: 8490****0232064
- productId: 7364****4461998
- Content-Type: application/json
- X-Agc-Trace-Id: 123456789
- Authorization: Bearer ****
- User-Agent: PostmanRuntime/7.6.0
- Accept: */*
- Host: ops-server-drcn.agcstorage.link
- accept-encoding: gzip, deflate
響應參數
參數 | 類型 | 必選(M)/可選(O) | 說明 |
---|---|---|---|
status | Integer | M | HTTP響應碼,200表示成功。 |
X-Agc-Trace-Id | String | O | 單個文件請求的Traceid,與請求參數X-Agc-Trace-Id保持一致。 |
Last-Modified | String | O | 文件的最近修改時間,配合If-Modified-Since使用。 格式:<day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT 舉例:Wed, 21 Oct 2015 07:28:00 GMT |
Etag | String | O | HTTP標準Header,配合If-None-Match使用。 格式:"<etag_value>" 舉例:"33a64df551425fcc55e4d42a148795d9f25f89d4" |
Cache-Control | String | O | HTTP標準Header,通過指定指令來實現緩存機制。 |
代碼實現/項目實踐
開始之前需要導入需要的依賴(集成Java Server SDK)
云存儲Java Server SDK發布在Maven倉庫,您需要在pom.xml文件中添加Maven倉庫地址和云存儲服務SDK依賴。
- 在項目中找到pom.xml文件,并添加Maven倉庫地址。
- <repositories>
- <repository>
- <id>sz-maven-public</id>
- <name>sz-maven-public</name>
- <url>https://developer.huawei.com/repo/</url>
- </repository>
- </repositories>
- 添加云存儲服務SDK的依賴。
- <dependency>
- <groupId>com.huawei.agconnect.server</groupId>
- <artifactId>agconnect-storage</artifactId>
- <version>1.2.0.101</version>
- </dependency>
上傳控制器不用修改
修改配置文件
server:port: 8007
service:ipAddr: http://localhost
download:address: D:\file\
spring:application:name: uploadService8007servlet:multipart:max-file-size: 20MBmax-request-size: 30MBcloud:nacos:discovery:server-addr: localhost:8848
management:endpoints:web:exposure:include: '*'huawei:agc:credential-path: D:\Download\agc-apiclient-*** # 憑據文件路徑bucket-name: *** # 存儲桶名稱region: CN # 區域標識(CN/DE/SG/RU)storage-url: https://ops-server-drcn.agcstorage.link/v0/ # 存儲服務URLclient-id: 17233***93184 # 客戶端ID(從憑據文件獲取)project-id: 46132***67964 # 項目ID(從憑據文件獲取)
修改上傳UploadService ,仍不是重點
package com.qst.upload;import com.qst.domain.entity.Log;
import com.qst.upload.huawei.HuaweiStorageUploadService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.UUID;@Service
public class UploadService {@Value("${huawei.agc.bucket-name}")private String bucketName;@Autowiredprivate HuaweiStorageUploadService huaweiStorageUploadService;public String uploadImage(MultipartFile file) {return upLoadFile(file, "image");}public String uploadLyric(MultipartFile file) {return upLoadFile(file, "lyric");}public String uploadMusic(MultipartFile file) {return upLoadFile(file, "music");}public String upLoadFile(MultipartFile file, String type) {try {// 生成新文件名String newFilename = getNewName(file);String objectPath = type + "/" + newFilename;// 上傳到華為云存儲huaweiStorageUploadService.uploadFile(file, objectPath);// 返回本地代理下載URLreturn "/upload/download/" + type + "/" + newFilename;} catch (Exception e) {throw new RuntimeException("文件上傳失敗", e);}}public String getNewName(MultipartFile file) {String uuid = UUID.randomUUID().toString();String filename = file.getOriginalFilename();return uuid + filename.substring(filename.lastIndexOf("."));}}
新增HuaweiStorageUploadService(重點)
package com.qst.upload.huawei;import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
//
///**
// * 華為云存儲文件上傳服務
// */
@Service
public class HuaweiStorageUploadService {private static final Logger log = LoggerFactory.getLogger(HuaweiStorageUploadService.class);@Value("${huawei.agc.region}")private String region;@Value("${huawei.agc.client-id}")private String clientId;@Value("${huawei.agc.project-id}")private String projectId;@Value("${huawei.agc.credential-path}")private String credentialPath;@Value("${huawei.agc.bucket-name}")private String bucketName;@Autowiredprivate RestTemplate restTemplate;@Autowiredprivate ObjectMapper objectMapper;/*** 上傳文件到華為云存儲*/public String uploadFile(MultipartFile file, String objectPath) {try {// 構建上傳URLString url = buildUploadUrl(objectPath);log.info("華為云文件上傳URL: {}", url);// 準備請求頭HttpHeaders headers = new HttpHeaders();headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);headers.set("client_id", clientId);headers.set("productId", projectId);headers.set("Authorization", "Bearer " + getAccessToken());headers.set("X-Agc-File-Size", String.valueOf(file.getSize()));headers.set("X-Agc-Trace-Id", UUID.randomUUID().toString());// 使用字節數組創建請求實體HttpEntity<byte[]> requestEntity = new HttpEntity<>(file.getBytes(), headers);// 執行PUT請求ResponseEntity<String> response = restTemplate.exchange(url,HttpMethod.PUT,requestEntity,String.class);// 處理響應if (response.getStatusCode() == HttpStatus.OK) {log.info("文件上傳成功,華為云URL: {}", url);return url;} else {log.error("華為云文件上傳失敗,狀態碼: {}", response.getStatusCodeValue());throw new RuntimeException("文件上傳失敗");}} catch (Exception e) {log.error("華為云文件上傳異常", e);throw new RuntimeException("文件上傳失敗", e);}}/*** 構建上傳URL*/private String buildUploadUrl(String objectPath) {String domain = getDomainByRegion(region);return "https://" + domain + "/" + bucketName + "/" + objectPath;}/*** 根據區域獲取域名*/private String getDomainByRegion(String region) {Map<String, String> domainMap = Map.of("CN", "ops-server-drcn.agcstorage.link/v0","DE", "ops-server-dre.agcstorage.link/v0","SG", "ops-server-dra.agcstorage.link/v0","RU", "ops-server-drru.agcstorage.link/v0");String domain = domainMap.get(region.toUpperCase());if (domain == null) {throw new IllegalArgumentException("不支持的區域: " + region);}return domain;}/*** 獲取訪問令牌*/private String getAccessToken() {try {String authUrl = getAuthUrlByRegion(region);log.info("獲取華為云Token URL: {}", authUrl);// 讀取憑據文件String content = new String(Files.readAllBytes(Paths.get(credentialPath)));Map<String, String> credential = objectMapper.readValue(content, new TypeReference<Map<String, String>>() {});// 準備請求頭HttpHeaders headers = new HttpHeaders();headers.setContentType(MediaType.APPLICATION_JSON);headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));// 準備請求體Map<String, String> requestBody = new HashMap<>();requestBody.put("grant_type", "client_credentials");requestBody.put("client_id", credential.get("client_id"));requestBody.put("client_secret", credential.get("client_secret"));// 創建請求實體HttpEntity<Map<String, String>> requestEntity = new HttpEntity<>(requestBody, headers);// 發送請求(使用exchange方法)ResponseEntity<Map> response = restTemplate.exchange(authUrl,HttpMethod.POST,requestEntity,Map.class);// 處理響應if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {String token = (String) response.getBody().get("access_token");log.info("成功獲取華為云Token");return token;} else {log.error("獲取華為云Token失敗,狀態碼: {}", response.getStatusCodeValue());throw new RuntimeException("獲取華為云Token失敗");}} catch (Exception e) {log.error("獲取華為云Token異常", e);throw new RuntimeException("獲取Token失敗", e);}}/*** 根據區域獲取Token接口URL*/private String getAuthUrlByRegion(String region) {Map<String, String> authUrlMap = Map.of("CN", "https://connect-api.cloud.huawei.com/api/oauth2/v1/token","DE", "https://connect-api-dre.cloud.huawei.com/api/oauth2/v1/token","SG", "https://connect-api-dra.cloud.huawei.com/api/oauth2/v1/token","RU", "https://connect-api-drru.cloud.huawei.com/api/oauth2/v1/token");String url = authUrlMap.get(region.toUpperCase());if (url == null) {throw new IllegalArgumentException("不支持的區域: " + region);}return url;}private String buildDownloadUrl(String objectPath) {String domain = getDomainByRegion(region);return "https://" + domain + "/" + bucketName + "/" + objectPath;}
}
HuaweiStorageUploadService解析(重點)
- getDomainByRegion()根據不同的地區返回不同的華為服務器接口url。一般都是CN,也可不要這個方法
- buildUploadUrl()根據官方文檔的指導,拼接接口URL:https://{domain}/{bucket_name}/{object_name}
- getAccessToken()獲取訪問API的Token
????????需要訪問https://{domain}/api/oauth2/v1/token
????????傳入合適的參數給該接口即可得到access_token,即認證Token,用于AppGallery Connect API接口調用
? ? ? ? 從下載的這個文件讀取并解析client_secret和client_id等
// 準備請求頭HttpHeaders headers = new HttpHeaders();headers.setContentType(MediaType.APPLICATION_JSON);headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));// 準備請求體Map<String, String> requestBody = new HashMap<>();requestBody.put("grant_type", "client_credentials");requestBody.put("client_id", credential.get("client_id"));requestBody.put("client_secret", credential.get("client_secret"));// 創建請求實體HttpEntity<Map<String, String>> requestEntity = new HttpEntity<>(requestBody, headers);
然后準備http請求,之后獲取token
之后在uploadFile()方法
調用buildUploadUrl構建上傳URL
準備請求頭,其中調用剛剛說到的getAccessToken方法獲取token
?然后執行put請求并處理響應
新增HuaweiStorageDownloadService
package com.qst.upload.huawei;import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.Resource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;@Service
public class HuaweiStorageDownloadService {private static final Logger log = LoggerFactory.getLogger(HuaweiStorageDownloadService.class);@Value("${huawei.agc.region}")private String region;@Value("${huawei.agc.client-id}")private String clientId;@Value("${huawei.agc.project-id}")private String projectId;@Value("${huawei.agc.bucket-name}")private String bucketName;@Autowiredprivate RestTemplate restTemplate;@Autowiredprivate ObjectMapper objectMapper;@Value("${huawei.agc.credential-path}")private String credentialPath;public ResponseEntity<StreamingResponseBody> downloadFile(String type,String filename,HttpHeaders originalHeaders) {try {// 1. 構建華為云下載URLString downloadUrl = buildDownloadUrl(type, filename);System.out.println("華為云下載URL: "+downloadUrl);log.info("代理下載華為云文件: {}", downloadUrl);// 2. 創建流式響應體StreamingResponseBody stream = outputStream -> {HttpURLConnection connection = null;InputStream input = null;try {// 3. 創建HTTP連接URL url = new URL(downloadUrl);connection = (HttpURLConnection) url.openConnection();connection.setRequestMethod("GET");// 4. 設置請求頭connection.setRequestProperty("client_id", clientId);connection.setRequestProperty("productId", projectId);connection.setRequestProperty("Authorization", "Bearer " + getAccessToken());connection.setRequestProperty("X-Agc-Trace-Id", UUID.randomUUID().toString());// 5. 設置Range頭部(如果存在)if (originalHeaders.containsKey(HttpHeaders.RANGE)) {String range = originalHeaders.getRange().get(0).toString();connection.setRequestProperty("Range", range);}// 6. 連接并檢查響應connection.connect();int status = connection.getResponseCode();if (status >= 300) {log.error("華為云下載失敗: {}", status);throw new RuntimeException("下載失敗,狀態碼: " + status);}// 7. 獲取輸入流input = connection.getInputStream();// 8. 流式傳輸數據byte[] buffer = new byte[8192];int bytesRead;while ((bytesRead = input.read(buffer)) != -1) {try {outputStream.write(buffer, 0, bytesRead);} catch (IOException e) {// 處理客戶端斷開連接log.warn("客戶端可能已斷開連接: {}", e.getMessage());break;}}outputStream.flush();} catch (Exception e) {log.error("下載處理失敗", e);throw new RuntimeException(e);} finally {// 9. 確保關閉資源if (input != null) {try {input.close();} catch (IOException e) {log.warn("關閉輸入流失敗", e);}}if (connection != null) {connection.disconnect();}}};// 10. 設置響應頭HttpHeaders responseHeaders = new HttpHeaders();responseHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);responseHeaders.setContentDispositionFormData("attachment", filename);// 11. 返回響應return ResponseEntity.ok().headers(responseHeaders).body(stream);} catch (Exception e) {log.error("代理下載失敗", e);return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();}}// 構建下載URLprivate String buildDownloadUrl(String type, String filename) {String domain = getDomainByRegion(region);return "https://" + domain + "/" + bucketName + "/" + type + "/" + filename;}// 獲取區域域名(與上傳服務相同)private String getDomainByRegion(String region) {Map<String, String> domainMap = Map.of("CN", "ops-server-drcn.agcstorage.link/v0","DE", "ops-server-dre.agcstorage.link/v0","SG", "ops-server-dra.agcstorage.link/v0","RU", "ops-server-drru.agcstorage.link/v0");String domain = domainMap.get(region.toUpperCase());if (domain == null) {throw new IllegalArgumentException("不支持的區域: " + region);}return domain;}// 獲取訪問令牌(與上傳服務相同)private String getAccessToken() {try {String authUrl = getAuthUrlByRegion(region);log.info("獲取華為云Token URL: {}", authUrl);// 讀取憑據文件String content = new String(Files.readAllBytes(Paths.get(credentialPath)));Map<String, String> credential = objectMapper.readValue(content, new TypeReference<Map<String, String>>() {});// 準備請求頭HttpHeaders headers = new HttpHeaders();headers.setContentType(MediaType.APPLICATION_JSON);headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));// 準備請求體Map<String, String> requestBody = new HashMap<>();requestBody.put("grant_type", "client_credentials");requestBody.put("client_id", credential.get("client_id"));requestBody.put("client_secret", credential.get("client_secret"));// 創建請求實體HttpEntity<Map<String, String>> requestEntity = new HttpEntity<>(requestBody, headers);// 發送請求(使用exchange方法)ResponseEntity<Map> response = restTemplate.exchange(authUrl,HttpMethod.POST,requestEntity,Map.class);// 處理響應if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {String token = (String) response.getBody().get("access_token");log.info("成功獲取華為云Token");return token;} else {log.error("獲取華為云Token失敗,狀態碼: {}", response.getStatusCodeValue());throw new RuntimeException("獲取華為云Token失敗");}} catch (Exception e) {log.error("獲取華為云Token異常", e);throw new RuntimeException("獲取Token失敗", e);}}private String getAuthUrlByRegion(String region) {Map<String, String> authUrlMap = Map.of("CN", "https://connect-api.cloud.huawei.com/api/oauth2/v1/token","DE", "https://connect-api-dre.cloud.huawei.com/api/oauth2/v1/token","SG", "https://connect-api-dra.cloud.huawei.com/api/oauth2/v1/token","RU", "https://connect-api-drru.cloud.huawei.com/api/oauth2/v1/token");String url = authUrlMap.get(region.toUpperCase());if (url == null) {throw new IllegalArgumentException("不支持的區域: " + region);}return url;}
}
訪問云存儲與上傳service類似,主要在于下載的代碼,換了幾個傳輸流,都有bug。
圖文結合詳解(重點)
最后這個流式傳輸才沒有沖突,具體沒有細究。
?數據庫存儲的歌曲信息中包含了音頻url,封面url等
url為/upload/download/music/9fb58a26-a351-4d78-9d72-66f255d80f74.mp3,是用來請求后端的
再由后端進行訪問華為云
那為什么不把華為云接口直接存數據庫,這樣前端只要得到musicDetail就能直接去華為云拿數據了,不用經過后端來回倒騰了
因為華為云安全機制很嚴格,訪問接口需要攜帶三個必須請求頭:
Authorization | String | M | 認證信息,格式為“Authorization: Bearer ${access_token}”。access_token為獲取Token中獲取的access_token。 |
client_id | String | M | 客戶端ID,獲取方法參考創建API客戶端。 |
productId | String | M | 項目ID,查詢方法可參見查詢項目ID。 |
只能通過后端添加請求頭來進行訪問。
一個小問題
開發途中又發現拖動播放進度條的功能受限。原因是
音頻文件不支持流式播放的進度跳轉(seekable)。當通過代理服務提供音頻流時,瀏覽器無法直接跳轉到音頻的中間位置,因為代理服務沒有正確支持 HTTP Range 請求(即支持部分內容請求)。解決方案
修改?HuaweiStorageDownloadService
,添加對 Range 請求的完整支持
public ResponseEntity<StreamingResponseBody> downloadFile(String type,String filename,HttpHeaders originalHeaders) {try {// 1. 構建華為云下載URLString downloadUrl = buildDownloadUrl(type, filename);log.info("代理下載華為云文件: {}", downloadUrl);// 2. 創建HTTP連接URL url = new URL(downloadUrl);HttpURLConnection connection = (HttpURLConnection) url.openConnection();connection.setRequestMethod("GET");// 3. 設置認證頭connection.setRequestProperty("client_id", clientId);connection.setRequestProperty("productId", projectId);connection.setRequestProperty("Authorization", "Bearer " + getAccessToken());connection.setRequestProperty("X-Agc-Trace-Id", UUID.randomUUID().toString());// 4. 處理Range請求if (originalHeaders.containsKey(HttpHeaders.RANGE)) {String rangeHeader = originalHeaders.getFirst(HttpHeaders.RANGE);connection.setRequestProperty("Range", rangeHeader);}// 5. 連接服務器connection.connect();// 6. 獲取響應狀態碼int statusCode = connection.getResponseCode();// 7. 準備響應頭HttpHeaders responseHeaders = new HttpHeaders();// 8. 添加內容類型(根據文件類型)String contentType = getContentType(filename);responseHeaders.setContentType(MediaType.parseMediaType(contentType));// 9. 添加支持Range的響應頭responseHeaders.set("Accept-Ranges", "bytes");// 10. 處理部分內容響應(206)if (statusCode == HttpURLConnection.HTTP_PARTIAL) {String contentRange = connection.getHeaderField("Content-Range");responseHeaders.set("Content-Range", contentRange);}// 11. 添加內容長度(如果可用)String contentLength = connection.getHeaderField("Content-Length");if (contentLength != null) {responseHeaders.setContentLength(Long.parseLong(contentLength));}// 12. 創建流式響應體InputStream inputStream = connection.getInputStream();StreamingResponseBody stream = outputStream -> {try (inputStream) {byte[] buffer = new byte[8192];int bytesRead;while ((bytesRead = inputStream.read(buffer)) != -1) {outputStream.write(buffer, 0, bytesRead);}} catch (IOException e) {log.warn("客戶端可能已斷開連接: {}", e.getMessage());} finally {connection.disconnect();}};// 13. 返回響應return ResponseEntity.status(statusCode).headers(responseHeaders).body(stream);} catch (Exception e) {log.error("代理下載失敗", e);return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();}
}// 根據文件擴展名獲取內容類型private String getContentType(String filename) {String extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();switch (extension) {case "mp3": return "audio/mpeg";case "wav": return "audio/wav";case "ogg": return "audio/ogg";case "flac": return "audio/flac";case "m4a": return "audio/mp4";default: return "application/octet-stream";}}