通过客户端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 = "<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的函数的入参,是由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<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();
        // 上传,处理,内容识别有存储
        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也有一些绕,某段官方文档里是说,参与签名计算的时间用于表示请求的有效期限,最好是半年……所以一开始调测失败,我把这个时间用当前时间加上了一个月,后来又发现只需要用当前时间戳就可以……无语。
服务端接口返回的这两个数据(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'));
      }
    });
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,907评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,987评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,298评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,586评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,633评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,488评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,275评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,176评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,619评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,819评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,932评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,655评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,265评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,871评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,994评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,095评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,884评论 2 354