微信小程序文件上传二三事

这段时间陆陆续续上了好几个微信小程序,功能上都会用到文件上传功能(头像上传、证件照上传等)。在APP上传文件到云端的正确姿势中,我们介绍了我们认为安全的上传流程:

即将密钥保存在服务器,客户端每次向服务器申请一个一次性的signature,然后使用该signature作为凭证来上传文件。一般情况下,向阿里云OSS上传内容,又拍云作为灾备。

随着大家安全意识的增强,这种上传流程几乎已经成为标准姿势。但是,把这个流程在应用到微信小程序却有很多细节需要调整。这里把踩过的坑记录一下,希望能让有需要的同学少走弯路。

微信小程序无法直接读取文件内容进行上传

在我们第一版的上传流程方案中,我们的cds 签名发放服务只实现了阿里云 PutObject 接口的signature发放. PutObject 上传是直接将需要上传的内容以二进流的方式 PUT 到云储存。

但是,微信小程序提供的文件上传API wx.uploadFile 要求文件通过 filePath 提供:

另一方面,微信小程序的 JS API 当前还比较封闭,无法根据 filePath 读取到文件内容,因此也无法通过 wx.request 直接发起网络请求的方式来实现文件上传。

考虑到 wx.uploadFile 本质上是一个 multipart/form-data 网络请求的封装,因此我们只需要实现一个与之对应的签名发放方式接口。阿里云OSS对应的上传接口是 PostObject, 又拍云对应的是其 FORM API. 以阿里云OSS为例,cds 服务生成signature 代码如下:

func GetDefaultOSSPolicyBase64Str(bucket, key string) string {
    policy := map[string]interface{}{
        "expiration": time.Now().AddDate(3, 0, 0).Format("2006-01-02T15:04:05.999Z"),
        "conditions": []interface{}{
            map[string]string{
                "bucket": bucket,
            },
            []string{"starts-with", "$key", key},
        },
    }
    data, _ := json.Marshal(&policy)
    return base64.StdEncoding.EncodeToString(data)
}

func GetOSSPostSignature(secret string, policyBase64 string) string {
    h := hmac.New(sha1.New, []byte(secret))
    io.WriteString(h, policyBase64)
    return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

小程序端代码如下:

//使用说明
/**
 * 1、引入该文件:const uploadFile = require('../../common/uploadAliyun.js');
 * 2、调用如下:
 * uploadImg: function () {
        const params = {
            _success: this._success
        }
        uploadFile.chooseImg(params);
    },
    _success: function(imgUrl){
        this.setData({
            cover_url: imgUrl,
        })
    },
*/

const uploadFile = {
    _fail: function(desc) {
        wx.showToast({
            icon: "none",
            title: desc
        })
    },
    _success: function() {},
    chooseImg: function(sendData) {
        //先存储传递过来的回调函数
        this._success = sendData._success;
        var that = this;
        wx.chooseImage({
            count: 1,
            sizeType: ['original', 'compressed'],
            sourceType: ["album", "carmera"],
            success: function (res) {
                that.getSign(res.tempFilePaths[0]);
            },
            fail: function (err) {
                wx.showToast({
                    icon: "none",
                    title: "选择图片失败" + err
                })
            }
        })
    },
    //获取阿里上传图片签名
    getSign: function (path) {
        var that = this;
        wx.request({
            url: 'https://somewhere/v2/cds/apply_upload_signature',
            method: 'POST',
            data: {
                "content_type": "image/jpeg",
                "signature_type": "oss_post",
                "business": "xiaochengxu",
                "file_ext": '.jpeg',
                "count": '1'
            },
            success: function (res) {
                let getData = res.data.data[0];
                that.startUpload(getData, path);
            },
            fail: function (err) {
                that._fail("获取签名失败" + JSON.stringify(err))
            }
        })
    },
    //拿到签名后开始上传
    startUpload: function (getData, path) {
        var that = this;
        this.uploadAliYun({
            filePath: path,
            dir: 'wxImg/',
            access_key_id: getData.oss_ext_param.access_key_id,
            policy_base64: getData.oss_ext_param.policy_base64,
            signature: getData.signature,
            upload_url: getData.upload_url,
            object_key: getData.oss_ext_param.object_key,
            content_url: getData.content_url.origin 
        })
    },
    uploadAliYun: function(params) {
        var that = this;
        // if (!params.filePath || params.filePath.length < 9) {
        if (!params.filePath) {
            wx.showModal({
                title: '图片错误',
                content: '请重试',
                showCancel: false,
            })
            return;
        }
        const aliyunFileKey = params.dir + params.filePath.replace('wxfile://', '');

        const aliyunServerURL = params.upload_url;
        const accessid = params.access_key_id;
        const policyBase64 = params.policy_base64;
        const signature = params.signature;
        wx.uploadFile({
            url: aliyunServerURL,
            filePath: params.filePath,
            name: 'file',
            formData: {
                'key': params.object_key,
                'policy': policyBase64,
                'OSSAccessKeyId': accessid,
                'Signature': signature
            },
            success: function (res) {
                if (res.statusCode != 204) {
                    that._fail("上传图片失败");
                    return;
                }
                that._success(params.content_url);
            },
            fail: function (err) {
                that._fail(JSON.stringify(err));
            },
        })
    }
}


module.exports = uploadFile;

使用阿里云OSS域名上传失败

解决签名问题后,发现使用阿里云OSS提供的上传域名无法上传成功,在微信后台尝试添加合法域名的时候,惊奇的发现阿里云OSS的域名直接被微信小程序封禁了:

显然是两个神仙在打架,作为草民只能见招拆招。解决办法就是在阿里云OSS -> bucket -> 域名管理 绑定用户域名:

此外,由于微信小程序已经升级为uploadFile的链接必须是https, 因此还需要在绑定用户域名后设置 证书托管

他山之石,可以攻玉

既然微信能够封禁用阿里云OSS的上传域名,那么微信也可以封禁你自定义的域名。根据以往经验(对天发誓,我们不是有意为之,我们也是受害者……),微信封禁域名一般都是一锅端,即发现一个子域名存在违规内容,那么整个域名都会被封禁。因此,一方面要从技术角度对上传的内容及时检查是否合规(如黄图扫描),另一方面提前做好域名规划,将业务接口域名与自定义的文件上传域名分开,这样即使上传域名被一锅端了,不至于是业务完全不可用。

热眼旁观:老罗与锤子

第一次对老罗的全面了解是本科时候的那趟南戴河毕业旅行:出发的时候随便抓了一本书塞书包里——《我的奋斗》。一群毕业屌丝自然坐的是最便宜,但如今想来却最有意思的绿皮火车去的南戴河。在路上和车站上看完了这本书。

与其说这是一本书,不如说这是老罗在新东方几年上课和演讲的语录。即使按照当时的认知,也不会把它归为推荐一类的书。但这并不重要,重要的是,对这个彪悍且还在继续活跃的胖子有了一个相对完整的了解。如今依然不知道那本书是从哪里来,后来遗失到了哪里去。很多事情,奇妙的偶然性让我知道了这个世界上有个有趣并值得持续关注的灵魂。

一共参加过两次老罗的现场演讲。

第一次是研究生一年级,老罗来学校做一个理想主义者的创业故事II的演讲。那时候老罗还没有开始做手机,演讲中用来插科打诨的公司主体是他的英语培训机构。那时候的自己对商业也没有清晰认识和完整理解。只是自己当时也在参与创业,对「理想主义」和「创业者」这两个词是没有任何抵抗力的。仿佛找到了一起参与一场改变世界革命的战友,惺惺相惜,感同身受。当然,也可见当时万众创业对在校学生荼毒之深😂。

那次分享的很多故事依然记忆犹新,但是要说最深刻的应该是:学会站着赚钱。理所当然的道理遇到浮躁的年代,往往知难行易。感谢老罗的那次演讲,深刻地影响了自己后来创业、工作时候处理棘手事情的底限和原则。

第二次是锤子科技在成都举行的 2017 秋季新品发布会。刚得知消息的时候,还是非常意外的,因为的确没有想到在从帝都回到成都工作的第四个年头,能够在成都听一场老罗的现场相声。

也许近两年成都的热度太高了吧,偌大的成都大魔方演艺厅票务放出即售罄。最后还是因为前东家是锤子科技的股东,因此老同事给置办了一张内部票才得以入场,也算是听相声路上的一段小插曲。那次的发布会发布了两个产品,一个是坚果 Pro 2——目的非常明确,坚果 Pro 是锤子迄今为止最畅销的手机,Pro 2 意图百尺竿头,更进一步; 一个是与成都雾霾非常应景的空气净化器——畅呼吸,算是在产品品类上横向扩充的尝试。

两个产品都很棒,但是即使凭借门票有优惠和优先购买权、手上的 iPhone 6 Plus 廉颇老矣的情况下,自己也没有购买这次发布会上的任何一款产品。很多人说,锤子的产品历来都是叫好不叫座,也许是有原因的😅

回到几天前的鸟巢发布会。有人说坚果 R1 中规中矩,毫无亮点;与之结合的 TNT 工作站就是一个笑话。我不想参与这个结论的辩论,一来自己还没有体验过这两个产品,讨论结论为时过早,毫无意义;二来我想我可能是一群人的典型代表:关注老罗和锤子,只因为这是一家老罗牵头的公司。

R1 是创新吗?当然不是。在手机如此同质化的今天,无论是小厂锤子科技还是巨厂苹果,这几年出的产品整体上都达无法满足大家的心理预期。R1 不过是众多改进型产品中的一员。至于老罗说的全世界第一,哎哟喂,都是成年人了,大家平时都在吹牛X,你不能自己吹完不让别人吹不是?

TNT 是创新吗?当然是。我会购买吗?现在当然不会。

Touch and Talk 的交互方式会是次时代的主流人机沟通方式吗?我不确定,这里没有和稀泥的意思。这个问题可以反过来看:如今嘴里含着触屏手机出生的一代,他们对于 touch 的交互几乎是与生俱来的一种操作直觉,如今的键鼠操作一定不是这一代人所有场景下的最终交互归宿,在很多场景下,一定会有新的交互方式来代替当前的这种操作交互。那么它会是 Touch and Talk 吗?这个真的不知道。超越时代是困难的,而在超越时代的产品真正出现的时候,在大众眼中,这种超越往往又是荒诞的。

我知道很多程序员都会调侃说 TNT 写代码可能会是这样的:

关于这一点,我想说,也许应用在现实的生产工具场景中,也许它……真的就是这样的。但是,这其实杠精了。回想一下老罗介绍的几个 TNT 应用场景:表格数据自动处理、keynote 语音辅助编辑、闪念胶囊自动找图和幻灯片生成…… 没有一个是要取代如今的密集生产工具场景。虽然现场 demo 状况频出,老罗也是汗如雨下,但是,我想你不会否认这么几点:老罗的敏感使其非常善于发现问题,他提到的问题和场景你一定不会陌生,他给出的解决方案也许不是你会选择的方法,但是一定是解决了特定用户群的痛点。

有人说,TNT 工作站这种手机 + Dock 的方式根本不是老罗首创,因为好几年前的摩托罗拉和当前的三星都有对应的产品。单从产品形态来说,TNT 的确没有重新发明轮子。甚至加上 Touch 和语音,整个解决方案也必然不是业界首创。

在这一点上,我比较同意吴军老师的第三眼美女以及发明是以最后一个发明家的名字命名的理论。老罗和他的 TNT 会是这个垂直品类的最后一个发明者以及第三眼美女吗?说实话,我并不确定,但是我愿意将一半的可能性投给老罗。

因为,对于老罗,我想我可能是关注老罗若干群体中的某个群体的典型代表:每次老罗的相声都不会错过,但是却几乎未曾真正触动自己的钱包;表面是老罗的粉丝,但是却自诩为独立思考,不是任何人的拥趸;关注老罗,更多的是他所言所行符合了某一群理想主义者的价值观,视其位精神的知己,行动的马前卒;将老罗及其挂帅的锤子科技看做浮躁创业环境下的一股清流,并由衷乐见其成;经常感怀自己,如果老罗失败了,是这个社会对价值的多元筛选和宽容失败了。

TNT 最终会怎样 boom 我们交给时间评判。但是,老罗一直都是那个埋头做事的人,同时更是平地吹B三丈起的奇才。我依然会是一个旁观者,如同观看一场带有完全主观倾向的比赛,向他投与这场比赛所有的希冀和祝福。

:吴军老师第三眼美女理论

第三眼美女当然是相对第一眼美女和第二眼的美女。第一眼美女有什么特点呢?首先,一眼看上去就很漂亮,但是不属于大众。这里面有很多原因,或许是因为她们本身就认为自己是精英人群而非主流人群,抑或是这些人光芒太扎眼,一般人想接近也接近不了她们。总之结果是,大众只能在远距离去欣赏她们了。其次,人有时会看走眼,乍一看很漂亮,接近以后如果发现没有内涵,看到第二眼、第三眼时,未必还能有最初的好印象了。

第二眼美女未必有第一眼的那么天生丽质,因此她们常常需要更懂得时尚细节才能引来周围人欣赏的眼光,但是这样一来,和一个第二眼美女交往的成本就比较高,大众即使心里痒痒的,未必能得到。即使得到了,第二眼美女的脾气未必好,因此双方的蜜月期一过,可能也就形同陌路了。

第三眼美女是属于大众的,她们未必那么显眼,但是如果仔细观察,她们还是不错的。更重要的是,正是因为她们可能没有光鲜的外表,如果依然能够吸引人,那么必定有某种美德或者价值。而对于欣赏这种美德,或者看重这种价值的人来讲,他们对第三眼美女的喜欢会持续很久,除非这种美德和价值不再存在或者过时了。

容器环境下 go 服务性能诊断方案设计与实现

背景

业务上量以后,对程序进行 profiling 性能诊断对很多后端程序员来说就是家常便饭。一个趁手的工具往往能让这个事情做起来事半功倍。

在这方面,go 有着天然的优势:继承 Google’s pprof C++ profiler 的衣钵,从出生就有 go tool pprof 工具。并且,标准库里面提供 runtime/pprofnet/http/pprof 两个package, 使得 profiling 可编程化。

在非容器环境下,我们的研发同学喜欢使用 net/http/pprof 来提供http接口供 go tool pprof 工具进行 profiling:

import _ "net/http/pprof"

func main(){
    ...
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    ...
}

获取 CPU profile 数据:

go tool pprof http://localhost:6060/debug/pprof/profile

但是,当架构逐步演进为微服务架构并使用k8s等容器化技术进行部署以后,这种这种方式面临的问题也越来越多:

  1. 我们生产环境使用k8s进行容器编排和部署。service类型是 NodePort. 因此研发同学无法直接对某个 service 的特定 pod 进行 profiling. 之前的解决方式是:
    1. 如果要诊断的问题是这个service普遍存在的问题,则直接进行 profiling。
    2. 如果要诊断的问题只出现在这个service的某个特定的pod上,则由运维同学定位到该pod所处的宿主机后登录到该容器中进行profiling。耗时耗力,效率低。
  2. 架构微服务化以后,服务数量呈量级增加。以前那种出现问题再去诊断服务现场的方式越来越难满足频率和数量越来越多的profiling需求(很多情况下,我们才做好profiling的准备,问题可能已经过去了……)。我们迫切的需要一种能够在程序出问题时,自动对程序进行profiling的方案,最大可能获取程序现场数据。
  3. 同时,我们希望这种自动profiling机制对程序性能影响尽可能小,并且可以与现有告警系统集成,直接将诊断结果通知到程序的owner.

方案设计与实现

  • 我们使用 heapster 对k8s的容器集群进行监控。并将监控到的时序数据写入influxDB进行持久化。
  • gopprof 是我们容器环境下对其他 go 服务进行性能诊断的核心服务:
    • 通过对influxDB中的监控数据分析,对于异常的pod自动进行 profiling. 当前设置的策略是如果该pod在两个1分钟分析周期内,资源使用率都超过设定的阈值0.8,则触发profiling。
    • gopprof 作为一个服务部署在k8s集群中主要是使其可以通过内网IP直接访问pod的 http profile接口,已实现对特定pod的profiling:
    go tool pprof http://POD_LAN_IP:NodePort/debug/pprof/profile
    
    • gopprof 完成profiling后,会自动生成 profile svg 调用关系图,并将profile 数据和调用关系图上传云存储,并向服务的owner推送诊断结果通知:

    • 由于 gopprof 依赖工具 go tool pprofgraphivz, 因此gopprof的基础镜像需要预装这两个工具。参考Dockerfile
    # base image contains golang env and graphivz
    
    FROM ubuntu:16.04
    
    MAINTAINER Daniel liudan@codoon.com
    
    RUN apt-get update
    RUN apt-get install wget -y
    RUN wget -O go.tar.gz https://dl.google.com/go/go1.9.2.linux-amd64.tar.gz && \
        tar -C /usr/local -xzf go.tar.gz && \
        rm go.tar.gz
    
    ENV PATH=$PATH:/usr/local/go/bin
    
    RUN go version
    
    RUN apt-get install graphviz -y
    
    • gopprof 向研发同学提供了对特定pod以及特定一组pod进行手动profiling的的接口。在解放运维同学生产力的同时,也让研发同学在出现难以复现的问题时,能够有更大可能性获取到程序现场。
    • 在高可用方面,当前只支持部署一个 gopprof pod, 服务可用性依赖于k8s的的auto restart. 后期如果有这方面的需求,可能会修改为依赖于etcd支持多个gopprof pod部署。

小结

gopprof 服务已经在我们内部落地试运行了一段时间,整个上达到了我们的设计预期,并辅助我们发现和解决了一些之前没有意识到的性能问题。由于有一些内部代码依赖,暂时还无法开源出来。但是整个方案所依赖的组件都是通用的,因此你也可以很容易的实现这个方案。如果你对我们实现中的一些细节感兴趣,欢迎评论和留言。