本文參加又拍云原創(chuàng)技術(shù)征文活動https://www.upyun.com/tech/article/551/1.html
公司此前一直使用七牛云,最近為了向海外用戶提供更優(yōu)質(zhì)的網(wǎng)絡(luò)訪問服務(wù),開始入手又拍云,官方在客戶端新js框架(react/vue/angular)下提供的參考文檔和代碼都不夠完整,摸索了好久,最終在官方的在線支持下,實現(xiàn)了客戶端js(目前僅是react)配合Java服務(wù)端實現(xiàn)文件上傳的功能。
官方支持node.js處理上傳的代碼在github上有一個項目:https://www.npmjs.com/package/upyun,其中有兩行“精煉”的代碼:
const service = new upyun.Service('your service name')
const client = new upyun.Client(service, getSignHeader);
實際上,‘your service name'就是你的桶名;可能在又拍云某段時間的概念里,對象存儲的“桶”稱為服務(wù)(service)。那么這個是你的運維人員在又拍云控制臺里配置的,而getSignHeader是對應(yīng)服務(wù)端接口的Promise函數(shù),官方舉了一個node.js服務(wù)端實現(xiàn)的例子,其返回值又說得不明不白,在此處摸索了好一些時間,最終是在客服不間斷的支持下解決的……汗!具體請參考我的代碼:
……
import upyun from 'upyun';
……
const bucketName = "<YOUR-BUCKET-NAME>";
const service = new upyun.Bucket(bucketName);
const client = new upyun.Client(service, getUpyunUploadHeader);
……
// 返回 Promise
export async function getUpyunUploadHeader(bucket, method = "POST", path) {
console.log('upyun bucket', bucket, method, path)
return request(`${apiDomain}/upyun/sign/head?bucket=${bucket.bucketName}&method=${method}&path=${path}`);
}
注意:這個返回Promise的函數(shù)的入?yún)?,是由upyun包控制的:它給第1個參數(shù)bucket傳入一個對象,第2個參數(shù)傳入的值是PUT,第三個參數(shù)是文件上傳的目標路徑,如/demo/file1.pdf。
request函數(shù)可以在antd pro腳手架示例項目代碼里找到,文末附。
客戶端js部分主要就是這樣了,client.putFile(……)相關(guān)的代碼,比較簡單,根據(jù)官方例子寫就可以了。
服務(wù)端接口/upyun/sign/head返回什么樣的數(shù)據(jù)呢?官方也沒有說,事實上應(yīng)該返回一個json對象,里面至少需要包含Authoriztion這個值,即最后生成的簽名描述符(形式為UPYUN <用戶名>:<簽名>)——事實上還需要一個日期字符串,詳見下文。為快捷起見,我就直接用了Map,代碼如下:
// 生成upyun js sdk需要的上傳參數(shù)
public Map<String, String> uploadHeader(String bucket, String uri, String method) throws Exception {
logger.debug("UPYUN uploadHeader: bucket={}, uri/path={}, method={}", bucket, uri, method);
String key = username;
String secret = md5(password);
String date = getRfc1123Time();
// 上傳,處理,內(nèi)容識別有存儲
String s = sign(key, secret, method, "/" + bucket + uri, date, "", "");
logger.debug("Generated {}", s);
Map<String, String> map = new HashMap<>();
map.put("x-date", date);
map.put("Authorization", s);
return map;
}
其中md5、getRfc1123Time、sign這三個方法,官方API中Java部分有,直接照搬即可。要注意的是,參與簽名的path,是要拼上桶名的("/" + bucket + uri),最終生成的資源URL中也是帶桶名的(域名+桶名+資源路徑),這一點與七牛云不一樣。這個getRfc1123Time也有一些繞,某段官方文檔里是說,參與簽名計算的時間用于表示請求的有效期限,最好是半年……所以一開始調(diào)測失敗,我把這個時間用當前時間加上了一個月,后來又發(fā)現(xiàn)只需要用當前時間戳就可以……無語。
服務(wù)端接口返回的這兩個數(shù)據(jù)(x-date與authorization),將直接被客戶端加入請求頭中。使用x-date是因為date會被瀏覽器屏蔽(認為是個危險的頭),而x-date的值是參與了簽名計算,所以必須提供給客戶端用于請求(putFile)。
附一:md5、getRfc1123Time、sign三個方法的Java實現(xiàn):
private static final String HMAC_SHA1_ALGORITHM = "HmacSHA1";
private static String md5(String string) {
byte[] hash;
try {
hash = MessageDigest.getInstance("MD5").digest(string.getBytes("UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("UTF-8 is unsupported", e);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MessageDigest不支持MD5Util", e);
}
StringBuilder hex = new StringBuilder(hash.length * 2);
for (byte b : hash) {
if ((b & 0xFF) < 0x10) hex.append("0");
hex.append(Integer.toHexString(b & 0xFF));
}
return hex.toString();
}
private static byte[] hashHmac(String data, String key)
throws SignatureException, NoSuchAlgorithmException, InvalidKeyException {
SecretKeySpec signingKey = new SecretKeySpec(key.getBytes(), HMAC_SHA1_ALGORITHM);
Mac mac = Mac.getInstance(HMAC_SHA1_ALGORITHM);
mac.init(signingKey);
return mac.doFinal(data.getBytes());
}
private static String sign(String key, String secret, String method, String uri, String date, String policy,
String md5) throws Exception {
String value = method + "&" + uri + "&" + date;
if (policy != null && policy.length() > 0) {
value = value + "&" + policy;
}
if (md5 != null && md5.length() > 0) {
value = value + "&" + md5;
}
byte[] hmac = hashHmac(value, secret);
String sign = Base64.getEncoder().encodeToString(hmac);
return "UPYUN " + key + ":" + sign;
}
private static String getRfc1123Time() {
Calendar calendar = Calendar.getInstance();
SimpleDateFormat dateFormat = new SimpleDateFormat(
"EEE, dd MMM yyyy HH:mm:ss z", Locale.US);
dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
logger.debug("upyun token time (format) {}", calendar);
return dateFormat.format(calendar.getTime());
}
附二:客戶端request函數(shù):
/**
* Requests a URL, returning a promise.
*
* @param {string} url The URL we want to request
* @param {object} [options] The options we want to pass to "fetch"
* @return {object} An object containing either "data" or "err"
*/
export default function request(url, options, isLogin = false) {
const defaultOptions = {
// credentials: 'include',
};
const newOptions = { ...defaultOptions, ...options };
if (
newOptions.method === 'POST' ||
newOptions.method === 'PUT' ||
newOptions.method === 'DELETE'
) {
if (!(newOptions.data instanceof FormData)) {
newOptions.headers = {
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
...newOptions.headers,
};
newOptions.body = JSON.stringify(newOptions.data);
} else {
// newOptions.body is FormData
newOptions.headers = {
Accept: 'application/json',
...newOptions.headers,
};
newOptions.body = newOptions.data;
}
}
if (!isLogin) {
// 請求的時候,如果storage有token,則攜帶token訪問
const session = getSession();
if (session) {
newOptions.headers = {
'Access-Token': session.data.accessToken,
...newOptions.headers,
}
}
}
return fetch(url, newOptions)
.then(checkStatus)
.then(response => {
// 處理圖片、PDF的情況
const contentType = response.headers.get('Content-Type');
if (contentType.indexOf('image') > -1 || contentType.indexOf('pdf') > -1) {
return response.blob();
}
if (newOptions.method === 'DELETE' || response.status === 204) {
return response.text();
}
// 返回的時候,如果服務(wù)端返回令牌過期,則清除令牌并返回轉(zhuǎn)到登錄頁面
// code=20180/20181
const p = Promise.resolve(response.json());
p.then( r => {
const { code } = r;
if (code !== undefined && code !== 1) {
// message.error(msg);
if (code === 20180 || code === 20181) {
clearSession();
const { dispatch } = store;
dispatch(routerRedux.push('/user/login'));
}
}
});
return p;
})
.catch(e => {
const { dispatch } = store;
const status = e.name;
if (status === 401) {
dispatch({
type: 'login/logout',
});
return;
}
if (status === 403) {
dispatch(routerRedux.push('/exception/403'));
return;
}
if (status <= 504 && status >= 500) {
dispatch(routerRedux.push('/exception/500'));
return;
}
if (status >= 404 && status < 422) {
dispatch(routerRedux.push('/exception/404'));
}
});
}