前后端分离下Laravel上传大文件到oss

思路分析

因为业务调整,原本的系统需要支持上传视频这个需求,视频假设是300M-2G区间大小。

最初的时候,我们的文件都是放到服务器的某个文件夹的,加入了视频,不难发现这样做得不偿失,太占用服务器空间了,于是就像把文件上传到oss;

第一版思路,在不变原来的上传思路情况下,由前端上传文件到服务器,服务器再上传到oss,然后删除服务器上的临时文件,毫无疑问,这真是一个要命的方案,这样想我一定是脑子有问题。

很快我调整了思路,上一版的缺点很多,我主要在意的是上传太慢,于是我想到了分片上传,参考了其他系统的方案,我定下了第二版方案,开放两个接口,第一个接口对上传上来的大文件进行切片,然后切片上传至oss。这个时候我开始尝试了,毫无疑问,不太可行,首先这样做的问题是php.ini配置中设置太大,以及nginx中也有限制,改参数也很愚蠢,因为这样做依旧是需要先把大文件上传上来,这样一来,改参数并不是被赞同的,同时也很容易遇到等待时间过长的问题。

为了解决第二版的问题,我想到了第三版的方案,那就是由前端进行切片,然后在轮询请求上传文件,这样做也遇到了个问题,那就是容易遇到“RequestCoreException: cURL error: Empty reply from server (52)”这个报错,经过查询,这个是因为请求头的大小与实际上传大小有偏差,因为oss是通过php的内置函数filesize()这个函数来获取文件大小的,这个时候因为php缓存问题,会出现大小不一致等问题,当然还有一个大问题就是oss的分片上传我理解错了,导致这一方案被pass了。

因为项目催的紧,我萌生了让前端直接传到oss的念头,这样就解决了很多问题,但是如果把id和key都给前端,这样会相当不安全,很容易出问题,这个方案也被pass了。

就在我一筹莫展的时候,一位好友给我提供了方案,同样是让前端直传到oss,但是是通过签名+回调的方式来实现,这个方案解决了我目前遇到的问题,接下来会详细贴出这个方案的源代码。

源码+说明

这个方案出自阿里云官方给的另外一个示例,地址在这里:https://help.aliyun.com/document_detail/91771.html?spm=a2c4g.11186623.0.0.2fd13967WOYZ0F

他提供了一个示例文件,通过追读源码,理解思路,其实很容易明白其核心,总共分为三步:

第一步:获取签名

第二步:通过将签名返回的内容及文件,直接上传至阿里云的host(地址)

第三步:回调

那么你大概理解了,也许需要看下实际操作,一共有两个接口:

获取签名:

public function get_signature(Request $request){
           $id  = "你的AccessKey ID";
           $key = "你的AccessKey Secret";
           // $host的格式为 bucketname.endpoint,请替换为您的真实信息。
           $host = 'https://test.oss-cn-hangzhou.aliyuncs.com';
           // $callbackUrl为上传回调服务器的URL,请将下面的IP和Port配置为您自己的真实URL信息。
           $callbackUrl = "https://test.com/api/v1/callback";//这是回调函数的路由地址
           // 用户上传文件时指定的前缀,这样文件就只能上传到 test 文件夹下面
           $dir = 'test/';
           $callback_param  = [
               'callbackUrl'  => $callbackUrl,
               /**
                * 回调的 body,里面可以配置一些信息来确定用户身份唯一性
                * 如下设置,上传图片后返回的信息如下:{"uid":"666","name":"shuxiaoyuan","filename":"test\\/20220615\\/K4Ae7eZjHm.jpg","size":"4312532","mimeType":"image\\/jpeg","height":"2160","width":"3840","s":"\\/\\/demo\\/ali\\/oss\\/method4Notify"}
                */

               'callbackBody' =>'filename=${object}&size=${size}&mimeType=${mimeType}&height=${imageInfo.height}&width=${imageInfo.width}',
               'callbackBodyType' => "application/x-www-form-urlencoded"
           ];
           $callback_string = json_encode($callback_param);
           $base64_callback_body = base64_encode($callback_string);
           // 当前时间
           $now = time();
           // 设置该 policy 超时时间(秒),即这个 policy 过了这个有效时间,将不能访问。
           $expire = 36000;
           $end        = $now + $expire;
           $expiration = $this->gmt_iso8601($end);
           // 最大文件大小.用户可以自己设置
           $condition    = array(
               0 => 'content-length-range',
               1 => 0,
               2 => 1024 * 1024 * 2000 // 2000M
           );
           $conditions[] = $condition;
           // 表示用户上传的数据,必须是以$dir开始,不然上传会失败,这一步不是必须项,只是为了安全起见,防止用户通过policy上传到别人的目录。
           $start = array(0 => 'starts-with', 1 => '$key', 2 => $dir);
           $conditions[] = $start;
           $arr = array('expiration' => $expiration, 'conditions' => $conditions);
           $policy = json_encode($arr);
           $base64_policy = base64_encode($policy);
           $string_to_sign = $base64_policy;
           $signature = base64_encode(hash_hmac('sha1', $string_to_sign, $key, true));
           $response              = array();
           $response['accessid']  = $id;
           $response['host']      = $host;
           $response['policy']    = $base64_policy;
           $response['signature'] = $signature;
           $response['expire']    = $end;
           $response['callback']  = $base64_callback_body;
           $response['dir']       = $dir;  // 这个参数是设置用户上传文件时指定的前缀。

           return $response;//返回签名
   }

对上述函数添加路由,请求这个路由就得到了对应的东西,类似这样:

{
    "accessid": "LTAI5tDJqgdgdBAe8rwdFnt",
    "host": "https://test.oss-cn-hangzhou.aliyuncs.com",
    "policy": "eyJleHBpcmF0aW9uIjoiMjgdMi0xMS0xOFQwMjowMjoxNi4wMDBaIiwiY29uZGl0aW9ucyI6W1siY29udGVudC1sZW5ndGgtcmFuZ2UiLDAsMjA5NzE1MjAwMF0sWyJzdGFydHMtd2l0aCIsIiRrZXkiLCJ0cmFpbmluZ192aWRlb1wvIl1dfQ==",
    "signature": "iV4QkB7qgdqEQLV3/W0ceSyrvpzA=",
    "expire": 1668736936,
    "callback": "eyJjYWxsYmFja1Vygd6Imh0dHBzOlwvXC9ocy5zZXIxMTkuY29tXC9hcGlcL3YxXC9jYWxsYmFjayIsImNhbGxiYWNrQm9keSI6ImZpbGVuYW1lPSR7b2JqZWN0fSZzaXplPSR7c2l6ZX0mbWltZVR5cGU9JHttaW1lVHlwZX0maGVpZ2h0PSR7aW1hZ2VJbmZvLmhlaWdodH0md2lkdGg9JHtpbWFnZUluZm8ud2lkdGh9IiwiY2FsbGJhY2tCb2R5VHlwZSI6ImFwcGxpY2F0aW9uXC94LXd3dy1mb3JtLXVybGVuY29kZWQifQ==",
    "dir": "test/"
}

这时候前端就拿到了签名,就可以上传文件了:

image-20221118000402668

这时就结束了吗?不是的,这个时候并没有验证回调,所以你还需要一个函数:

  public function callback(Request $request){
        $response = $request->all();
        // 1.获取OSS的签名header和公钥url header
        $authorizationBase64 = "";
        $pubKeyUrlBase64     = "";
        /*
         * 注意:如果要使用HTTP_AUTHORIZATION头,你需要先在apache或者nginx中设置rewrite,以apache为例,修改
         * 配置文件/etc/httpd/conf/httpd.conf(以你的apache安装路径为准),在DirectoryIndex index.php这行下面增加以下两行
            RewriteEngine On
            RewriteRule .* - [env=HTTP_AUTHORIZATION:%{HTTP:Authorization},last]
         * */
        if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
            $authorizationBase64 = $_SERVER['HTTP_AUTHORIZATION'];
        }
        if (isset($_SERVER['HTTP_X_OSS_PUB_KEY_URL'])) {
            $pubKeyUrlBase64 = $_SERVER['HTTP_X_OSS_PUB_KEY_URL'];
        }
        if ($authorizationBase64 == '' || $pubKeyUrlBase64 == '') {
            return $this->failed("上传失败");
        }
        // 2.获取OSS的签名
        $authorization = base64_decode($authorizationBase64);
        // 3.获取公钥
        $pubKeyUrl = base64_decode($pubKeyUrlBase64);
        $ch        = curl_init();
        curl_setopt($ch, CURLOPT_URL, $pubKeyUrl);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
        $pubKey = curl_exec($ch);
        if ($pubKey == "") {
            return $this->failed("上传失败");
        }
// 4.获取回调body
        $body = file_get_contents('php://input');
        // 5.拼接待签名字符串
        $authStr = '';
        $path    = $_SERVER['REQUEST_URI'];
        $pos     = strpos($path, '?');
        if ($pos === false) {
            $authStr = urldecode($path) . "\n" . $body;
        } else {
            $authStr = urldecode(substr($path, 0, $pos)) . substr($path, $pos, strlen($path) - $pos) . "\n" . $body;
        }

        // 6.验证签名
        $status = openssl_verify($authStr, $authorization, $pubKey, OPENSSL_ALGO_MD5);
        if ($status == 1) {
            header("Content-Type: application/json");
            $url = 'https://anjk-hs.oss-cn-hangzhou.aliyuncs.com';
            $data = [
                // 这个地方应该拼接,不要写死
                'file_url' => $response['filename'],
                'data' => json_encode($response)
            ];
            return $this->success(['data' => $data]);
        } else {
            return $this->failed("验签失败");
        }
    }

为这个函数添加路由,与生成签名里面的: $callbackUrl = “https://test.com/api/v1/callback";//这是回调函数的路由地址,相对应,oss会自动调用你的回调函数,这样就上传成功了。

基本上,oss官网的说辞,如果是小于5G就没有必要使用分片上传,所以到这里就结束了。

158bdfdf-aef3-a468-2f0d-889bf49753ee

本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 (CC BY-NC-ND 4.0) 进行许可。