通过客户端js上传文件到又拍云(upyun)

本文参加又拍云原创技术征文活动https://www.upyun.com/tech/article/551/1.html

公司此前一直使用七牛云,最近为了向海外用户提供更优质的网络访问服务,开始入手又拍云,官方在客户端新js框架(react/vue/angular)下提供的参考文档和代码都不够完整,摸索了好久,最终在官方的在线支持下,实现了客户端js(目前仅是react)配合Java服务端实现文件上传的功能。
官方支持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'就是你的桶名;可能在又拍云某段时间的概念里,对象存储的“桶”称为服务(service)。那么这个是你的运维人员在又拍云控制台里配置的,而getSignHeader是对应服务端接口的Promise函数,官方举了一个node.js服务端实现的例子,其返回值又说得不明不白,在此处摸索了好一些时间,最终是在客服不间断的支持下解决的……汗!具体请参考我的代码:

……
import upyun from 'upyun';
……
const bucketName = "";
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的函数的入参,是由upyun包控制的:它给第1个参数bucket传入一个对象,第2个参数传入的值是PUT,第三个参数是文件上传的目标路径,如/demo/file1.pdf。
request函数可以在antd pro脚手架示例项目代码里找到,文末附。
客户端js部分主要就是这样了,client.putFile(……)相关的代码,比较简单,根据官方例子写就可以了。
服务端接口/upyun/sign/head返回什么样的数据呢?官方也没有说,事实上应该返回一个json对象,里面至少需要包含Authoriztion这个值,即最后生成的签名描述符(形式为UPYUN <用户名>:<签名>)——事实上还需要一个日期字符串,详见下文。为快捷起见,我就直接用了Map,代码如下:

    // 生成upyun js sdk需要的上传参数
    public Map 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();
        // 上传,处理,内容识别有存储
        String s = sign(key, secret, method, "/" + bucket + uri, date, "", "");
        logger.debug("Generated {}", s);
        Map map = new HashMap<>();
        map.put("x-date", date);
        map.put("Authorization", s);
        return map;
    }

其中md5、getRfc1123Time、sign这三个方法,官方API中Java部分有,直接照搬即可。要注意的是,参与签名的path,是要拼上桶名的("/" + bucket + uri),最终生成的资源URL中也是带桶名的(域名+桶名+资源路径),这一点与七牛云不一样。这个getRfc1123Time也有一些绕,某段官方文档里是说,参与签名计算的时间用于表示请求的有效期限,最好是半年……所以一开始调测失败,我把这个时间用当前时间加上了一个月,后来又发现只需要用当前时间戳就可以……无语。
服务端接口返回的这两个数据(x-date与authorization),将直接被客户端加入请求头中。使用x-date是因为date会被浏览器屏蔽(认为是个危险的头),而x-date的值是参与了签名计算,所以必须提供给客户端用于请求(putFile)。

附一:md5、getRfc1123Time、sign三个方法的Java实现:

    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函数:

/**
 * 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();
      }
      // 返回的时候,如果服务端返回令牌过期,则清除令牌并返回转到登录页面
      // 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'));
      }
    });
}

你可能感兴趣的:(通过客户端js上传文件到又拍云(upyun))