阿里云对象存储服务(Object Storage Service,简称OSS),是阿里云对外提供的海量、安全、低成本、高可靠的云存储服务。

目前通过Web端直传文件(Object)到OSS,有两种方案:

一、利用OSS Browser.js SDK将文件上传到OSS。该方案通过OSS Browser.js SDK直传数据到OSS,支持断点续传,支持各种主流浏览器,可以将File对象、Blob数据以及OSS Buffer上传OSS,该方案还支持下载和删除

二、利用OSS提供的PostObject接口来实现表单上传,不支持断点续传,支持h5,小程序,支持uniapp的uni.uploadFile接口

方案一:使用阿里云SDK上传

由于前端环境不安全,为避免暴露阿里云账号访问密钥(AccessKey ID和AccessKey Secret),该方案需要搭建STS服务获取临时访问密钥(AccessKey ID和AccessKey Secret)和安全令牌(SecurityToken),需要先开通STS服务,参考官方文档

后端

后端需要导入aliyun-sdk-oss包,用于获取前端需要的key和secret

JDK版本:jdk8

<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>2.0.7</version>
    <!--<version>3.15.0</version>最新-->
</dependency>

如果是java9及以上版本,则需要添加jaxb相关依赖。添加jaxb相关依赖示例代码如下:

<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.1</version>
</dependency>
<dependency>
    <groupId>javax.activation</groupId>
    <artifactId>activation</artifactId>
    <version>1.1.1</version>
</dependency>
<!-- no more than 2.3.3-->
<dependency>
    <groupId>org.glassfish.jaxb</groupId>
    <artifactId>jaxb-runtime</artifactId>
    <version>2.3.3</version>
</dependency>

搭建STS服务(部分代码)

	// STS接入地址,例如sts.cn-hangzhou.aliyuncs.com。
  	@Value("${ramEndpoint}")
    private String ramEndpoint;
    // 访问密钥AccessKey ID和AccessKey Secret
    @Value("${ramAccessKeyId}")
    private String ramAccessKeyId;
    @Value("${ramAccessKeySecret}")
    private String ramAccessKeySecret;
    // 角色ARN
    @Value("${ramRoleArn}")
    // 自定义角色会话名称,用来区分不同的令牌,例如可填写为SessionTest
    private String ramRoleArn;
    @Value("${ramRoleSessionName}")
    private String ramRoleSessionName;

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    RedisTemplate redisTemplate;

    /**
     * 通过RAM子账号获取stsToken,作为临时凭据
     */
    @RequestMapping(value = "getStsToken", method = {RequestMethod.GET, RequestMethod.POST})
    public Object getStsToken() {
        ResponseVo responseVo = new ResponseVo();
        AssumeRoleResponse response = null;
        Object redisToken = null;
        try {
            redisToken = redisTemplate.opsForValue().get("stsToken");
        } catch (Exception e) {
            logger.error(e.getMessage());
            responseVo.setError(GlobalErrorCode.SYS_RUN_ERROR.getCode());
            return responseVo;
        }
        if (redisToken != null) {
            response = JSONObject.parseObject(redisToken.toString(), AssumeRoleResponse.class);
            responseVo.setSuccess(response);
            return responseVo;
        } else {
            String policy = "{\n" +
                    "    \"Version\": \"1\", \n" +
                    "    \"Statement\": [\n" +
                    "        {\n" +
                    "            \"Action\": [\n" +
                    "                \"oss:*\"\n" +
                    "            ], \n" +
                    "            \"Resource\": [\n" +
                    "                \"acs:oss:*:*:*\" \n" +
                    "            ], \n" +
                    "            \"Effect\": \"Allow\"\n" +
                    "        }\n" +
                    "    ]\n" +
                    "}";
            try {
                DefaultProfile.addEndpoint("", "", "Sts", ramEndpoint);
                // 构造default profile(参数留空,无需添加region ID)
                IClientProfile profile = DefaultProfile.getProfile("", ramAccessKeyId, ramAccessKeySecret);
                // 用profile构造client
                DefaultAcsClient client = new DefaultAcsClient(profile);
                final AssumeRoleRequest request = new AssumeRoleRequest();
                request.setMethod(MethodType.POST);
                request.setRoleArn(ramRoleArn);
                request.setRoleSessionName(ramRoleSessionName);
                request.setPolicy(policy); // 若policy为空,则用户将获得该角色下所有权限
                request.setDurationSeconds(20 * 60L); // 设置凭证有效时间,单位秒
                //获取凭证
                response = client.getAcsResponse(request);
                /*
                 * 缓存该凭证,凭证失效后才从OSS再次获取凭证
                 * 凭证有效时间为20分钟,Redis里只缓存10分钟
                 */
                redisTemplate.opsForValue().set("stsToken", JSONObject.toJSONString(response), 10 * 60, TimeUnit.SECONDS);
                responseVo.setSuccess(response);
                return responseVo;
            } catch (ClientException e) {
                logger.error(e.getErrMsg());
                responseVo.setError(GlobalErrorCode.SYS_RUN_ERROR.getCode());
                return responseVo;
            }
        }

    }

参考阿里云文档

前端

安装

$ npm install ali-oss --save

部分代码

onLoad() {
	this.getStsToken()
},

methods

/**
* @param {String} pathAndName Object完整路径。Object完整路径中不能包含Bucket名称("exampledir/exampleobject.txt")
* @param {Object} data (file对象、Blob数据或者OSS Buffer)
*/
async putObject(pathAndName, data) {
    try {
        // 您可以通过自定义文件名(例如exampleobject.txt)或文件完整路径(例如exampledir/exampleobject.txt)的形式实现将数据上传到当前Bucket或Bucket中的指定目录。
        const result = await this.getClient().put(
            pathAndName,
            data
        );
        console.log('result:', result);
    } catch (e) {
        console.log(e);
    }
},
    getClient() {
        if (this.client) {
            return this.client
        }
        const OSS = require('ali-oss');

        const client = new OSS({
            // yourRegion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。
            region: 'oss-cn-qingdao',
            // 从STS服务获取的临时访问密钥(AccessKey ID和AccessKey Secret)。
            accessKeyId: this.stsToken.credentials.accessKeyId,
            accessKeySecret: this.stsToken.credentials.accessKeySecret,
            // 从STS服务获取的安全令牌(SecurityToken)。
            stsToken: this.stsToken.credentials.securityToken,
            refreshSTSToken: async () => {
                // 向您搭建的STS服务获取临时访问凭证。
                let info = await this.$post(GET_STS_TOKEN)
                info = info.data
                console.log('-----------refresh--token')

                return {
                    accessKeyId: info.credentials.accessKeyId,
                    accessKeySecret: info.credentials.accessKeySecret,
                    stsToken: info.credentials.securityToken
                }
            },
            // 刷新临时访问凭证的时间间隔,单位为毫秒。每隔一段时间定时器会自动掉后台接口刷新token
            refreshSTSTokenInterval: 600000,
            // 填写Bucket名称。
            bucket: 'zxxxxth-bucket'
        });

        this.client = client
        return this.client
    },
        getStsToken() {
            //从后台获取stsToken(改成自己的前端请求接口)
            this.$post(GET_STS_TOKEN).then(rsp => {
                if (rsp.success) {
                    this.stsToken = rsp.data;
                    // 初始化一下client让定时任务启动,自动刷新token(避免过期)
                    this.getClient()
                    console.log('this.stsToken:', this.stsToken)
                } else {
                    uni.showToast({
                        title: rsp.message,
                        duration: 2000
                    });
                }
            })
        },              
                

refreshSTSToken参数说明:当初始化new OSS()时,定时器会启动,当时间到了refreshSTSTokenInterval所设置的值时,并不会立即调用后台接口获取token,只有手动触发put()接口时,才会调用后台接口获取token

参考阿里云文档

开通STS服务步骤

方案二:使用PostObject接口来实现表单上传

这个方案支持小程序上传,uniapp上传。无需开通STS服务

后端

获取postObject接口需要的policy,OSSAccessKeyId,signature 参考官方文档

这里签名使用后端签名,所以不需要申请开通STS服务

简化版,无回调
/**
     * 利用OSS提供的PostObject接口,通过表单上传的方式将文件上传到OSS。
     * 该方案兼容大部分浏览器,但在网络状况不好的时候,如果单个文件上传失败,
     * 只能重试上传。上传的Object大小不能超过5 GB。
     * @return ResponseVo{success:true,message:'',data:{},code:200}
     */
    @RequestMapping(value = "getPostObjectParams", method = {RequestMethod.GET, RequestMethod.POST})
    public Object getPostObjectParams() {
        ResponseVo responseVo = new ResponseVo();
        responseVo.setSuccess(OSSServer.getPostObjectParams());
        return responseVo;
    }

OSSServer.class

	public static OSSClient getOSSClient() {
		if (null == ossClient) {
			ossClient = new OSSClient(endpoint, accessKeyId, accessKeySecret);
		}
		return ossClient;
	}	

		/**
	 * 获取表单上传的方式的PostObject参数
	 * @return
	 */
	public static Map<String, Object> getPostObjectParams() {
		Map<String, Object> respMap = new LinkedHashMap();
		// 限制参数的生效时间,单位为分钟,默认值为20。
		int expireTime = 20;
		// 限制上传文件的大小,单位为MB,默认值为100。
		int maxSize = 100;
		// 设置上传到OSS文件的前缀,可置空此项。置空后,文件将上传至Bucket的根目录下。
		// 如果值为"test"那么前端的key参数必须以"test"开头,如test/*、test1.jpg、test/comment/11.jpg
		String dir = "";

		// 创建OSSClient实例。
		OSS ossClient = getOSSClient();
		try {
			long expireEndTime = System.currentTimeMillis() + expireTime * 1000 * 60;
			Date expiration = new Date(expireEndTime);
			PolicyConditions policyConds = new PolicyConditions();
			policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, maxSize * 1024 * 1024);
			policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);

			String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
			byte[] binaryData = postPolicy.getBytes("utf-8");
			String encodedPolicy = BinaryUtil.toBase64String(binaryData);
			String postSignature = ossClient.calculatePostSignature(postPolicy);


			respMap.put("accessKeyId", accessKeyId);
			respMap.put("policy", encodedPolicy);
			respMap.put("signature", postSignature);

			respMap.put("expire", expireEndTime / 1000);
		} catch (Exception e) {
			log.error("getPostObjectParams", e);
		}

		return respMap;
	}

大多数情况下,用户上传文件后,应用服务器需要知道用户上传了哪些文件以及文件名;如果上传了图片,还需要知道图片的大小等,为此OSS提供了上传回调方案。

流程图:

在这里插入图片描述

当用户要上传一个文件到OSS,而且希望将上传的结果返回给应用服务器时,需要设置一个回调函数,将请求告知应用服务器。用户上传完文件后,不会直接得到返回结果,而是先通知应用服务器,再把结果转达给用户。

有回调的PostObject参数
/**
	 * 获取表单上传的方式的PostObject参数【有回调】
	 * @return
	 */
	public static Map<String, Object> getPostObjectParams() {
		Map<String, Object> respMap = new LinkedHashMap();
		// 限制参数的生效时间,单位为分钟,默认值为20。
		int expireTime = 20;
		// 限制上传文件的大小,单位为MB,默认值为10。
		int maxSize = 10;
		// 设置上传到OSS文件的前缀,可置空此项。置空后,文件将上传至Bucket的根目录下。
		// 如果值为"test"那么前端的key参数必须以"test"开头,如test/*、test1.jpg、test/comment/11.jpg
		// 可以让用户没有办法上传到其他的目录,从而保证了数据的安全性
		String dir = "";
		// 设置上传回调URL,即回调服务器地址,用于处理应用服务器与OSS之间的通信。OSS会在文件上传完成后,把文件上传信息通过此回调URL发送给应用服务器。
		String callbackUrl = "https://jmt.xxx.cn/common/postObjectCallBack/";

		// 创建OSSClient实例。
		OSS ossClient = getOSSClient();
		try {
			long expireEndTime = System.currentTimeMillis() + expireTime * 1000 * 60;
			Date expiration = new Date(expireEndTime);
			PolicyConditions policyConds = new PolicyConditions();
			policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, maxSize * 1024 * 1024);
			policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);

			String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
			byte[] binaryData = postPolicy.getBytes("utf-8");
			String encodedPolicy = BinaryUtil.toBase64String(binaryData);
			String postSignature = ossClient.calculatePostSignature(postPolicy);


			respMap.put("accessKeyId", accessKeyId);
			respMap.put("policy", encodedPolicy);
			respMap.put("signature", postSignature);
			respMap.put("expire", expireEndTime / 1000);
			// 配置回调地址
			JSONObject jasonCallback = new JSONObject();
			jasonCallback.put("callbackUrl", callbackUrl);
//			jasonCallback.put("callbackBody",
//					"filename=${object}&size=${size}&mimeType=${mimeType}&height=${imageInfo.height}&width=${imageInfo.width}");

			jasonCallback.put("callbackBody", "{\"filename\":${object},\"mimeType\":${mimeType}}");

			jasonCallback.put("callbackBodyType", "application/json");//application/x-www-form-urlencoded
			String base64CallbackBody = BinaryUtil.toBase64String(jasonCallback.toString().getBytes());
			respMap.put("callback", base64CallbackBody);

		} catch (Exception e) {
			log.error("getPostObjectParams", e);
		}

		return respMap;
	}
给oss回调的接口
    @RequestMapping(value = "postObjectCallBack", method = RequestMethod.POST)
    public Object postObjectCallBack(HttpServletRequest request, @RequestBody Object callbackBody) throws IOException {

        log.info("---callbackBody={}",callbackBody);

//        "{"filename":"test/comment/tt1.jpg","mimeType":"image/png"}"
         return callbackBody;

    }

前端

小程序
const host = '<host>'; //"https://examplebucket.oss-cn-hangzhou.aliyuncs.com"
const signature = '<signatureString>';
const ossAccessKeyId = '<accessKey>';
const policy = '<policyBase64Str>';
const key = '<object name>';
const securityToken = '<x-oss-security-token>'; 
const filePath = '<filePath>'; // 待上传文件的文件路径。
wx.uploadFile({
  url: host, // 这个是阿里云bucket的根地址,使用这个地址拼接路径可以访问已上传的文件。如果是自己服务器则是开发者服务器的URL。
  filePath: filePath,// 本地文件路径,小程序chooseImage方法返回的路径
  name: 'file', // 必须填file。
  formData: {
    key,
    policy,
    OSSAccessKeyId: ossAccessKeyId,
    signature,
    // 'x-oss-security-token': securityToken // 使用STS签名时必传。
  },
  success: (res) => {
    if (res.statusCode === 204) {
      console.log('上传成功');
    }
  },
  fail: err => {
    console.log(err);
  }
});
uniapp、uView的Upload组件
uni.uploadFile({
    url: 'https://res.xxx.cn', //这个是阿里云bucket的根地址,使用这个地址拼接路径可以访问已上传的文件
    filePath: url,// 本地文件路径,小程序chooseImage方法返回的路径
    name: 'file',// 必须填file
    formData: {
        key: 'test/comment/tt1.jpg',//会把tt1.jpg图片上传至bucket(上方url所指向)的test/comment目录
        policy: this.postObject.policy,
        OSSAccessKeyId: this.postObject.accessKeyId,
        signature: this.postObject.signature,
        // callback: this.postObject.callback
    },
    success: (res) => {
        console.log('uni.uploadFile success:', res)
        if(res.statusCode===204){
            // 上传成功
            console.log('-------------success------------')
        }else if(res.statusCode===403){
            // Policy expired.
            uni.showToast({
                title: '网络超时',
                duration: 2000
            });
            // 续期
            this.getPostObjectParams()
        }else{
            console.log('上传失败')
        }
        // setTimeout(() => {
        resolve(res)
        // }, 1000)
    },
    fail(err) {
        console.error('uni.uploadFile: fail', err)
    }
});


onLoad() {
	this.getPostObjectParams()
},
...........
getPostObjectParams() {
    //从后台获取stsToken
    this.$post(GET_POST_OBJECT_PARAMS).then(rsp => {
        if (rsp.success) {
            this.postObject = rsp.data;
            console.log('this.postObject:', this.postObject)
        } else {
            uni.showToast({
                title: rsp.message,
                duration: 2000,
                icon:'none'
            });
        }
    })
},

参考官方文档

举一个uniapp例子

UI库:uView
在这里插入图片描述

<template>
	<!-- 发表评论 -->
	<view class="create-comment">
		<view class="star comment-common">
			<view class="title">评分</view>
			<view class="control">
				<text class="name">游玩体验</text>
				<u-rate :count="5" v-model="starCount" :touchable="false" active-color="#E65526" size="24"></u-rate>
			</view>

		</view>

		<view class="content comment-common">
			<view class="title">评价内容</view>
			<textarea v-model="resourceComment.content" maxlength="200"
				placeholder="游玩的满意吗?大家都想了解这里值得去吗?有什么亮点?期待你精彩的点评!">
			</textarea>
		</view>

		<view class="picture comment-common">
			<view class="title">图片</view>
			<!-- name=1对应fileList1 -->
			<u-upload :fileList="fileList1" @afterRead="afterRead" @delete="deletePic" name="1" multiple :maxCount="15"
				:maxSize="maxSize">
			</u-upload>
		</view>

		<view class="picture comment-common">
			<view class="title">视频</view>
			<!-- name=2对应fileList2 -->
			<u-upload :fileList="fileList2" @afterRead="afterRead" @delete="deletePic" name="2" multiple :maxCount="1"
				:maxSize="maxSize" accept="video" uploadIcon="movie"></u-upload>
		</view>

		<button @click="submit()" type="warn" class="submit" :loading="loading" :disabled="loading">发布</button>
	</view>
</template>

<script>
	import {
		GET_POST_OBJECT_PARAMS
	} from '../../api/api.js'

	export default {
		data() {
			return {
				starCount: 0,
				resourceComment: {
					content: ''
				},
				fileList1: [],
				fileList2: [],
				loading: false,
				postObject: {
					expire: 0
				},
				maxSize: 100 * 1024 * 1024
			}
		},
		onLoad() {
		},
		methods: {
			// -----upload start
			// 新增图片
			async afterRead(event) {
				console.log('event:', event)

				// 当设置 mutiple 为 true 时, file 为数组格式,否则为对象格式
				let lists = [].concat(event.file)
				// console.log('lists:', lists)
				let fileListLen = this[`fileList${event.name}`].length
				// console.log('fileListLen:', fileListLen)
				lists.map((item) => {
					this[`fileList${event.name}`].push({
						...item,
						status: 'uploading',
						message: '上传中'
					})
				})

				let time = new Date().getTime() / 1000
				// console.log('time:', time)
				if (time > this.postObject.expire) {
					// policy过期,续期
					await this.getPostObjectParams()
				}

				for (let i = 0; i < lists.length; i++) {
					const result = await this.uploadFilePromise(lists[i].url)
					let item = this[`fileList${event.name}`][fileListLen]
					this[`fileList${event.name}`].splice(fileListLen, 1, Object.assign(item, {
						status: result ? 'success' : 'failed',
						message: '',
						url: result
					}))
					fileListLen++
				}
			},
			compressJpgImage(src) {
				return new Promise((resolve, reject) => {
					// uni.compressImage({
					//   src: src,
					//   quality: 80,
					//   success: res => {
					//     console.log(res.tempFilePath)
					//   }
					// })
				})
			},
			uploadFilePromise(url) {
				return new Promise((resolve, reject) => {
					let a = uni.uploadFile({
						url: 'https://res.xxxx.cn',
						filePath: url,
						name: 'file', // 必须填file
						formData: {
							key: 'test/comment/tt3.jpg',
							policy: this.postObject.policy,
							OSSAccessKeyId: this.postObject.accessKeyId,
							signature: this.postObject.signature,
							callback: this.postObject.callback
						},
						success: (res) => {
							// 未配置回调 上传成功返回 {date:"",errMsg:"uploadFile:ok",statusCode:204},如果配置了回调data参数才会有值
							// 配置了回调 上传成功返回 {{data:{"filename":"test/comment/tt1.jpg","mimeType":"image/png"},errMsg:"uploadFile:ok",statusCode:200}
							console.log('uni.uploadFile success():', res)
							if (res.statusCode === 204 || res.statusCode === 200) {
								// 上传成功
								console.log('-------------uploaded success')
								resolve(url)
							} else {
								console.log('-------------uploaded failed')
								uni.showToast({
									title: '上传失败',
									duration: 2000,
									icon: 'error'
								});
								resolve()
							}
						},
						fail(err) {
							console.error('uni.uploadFile: fail():', err)
						}
					});
				})
			},
			// 删除图片
			deletePic(event) {
				this[`fileList${event.name}`].splice(event.index, 1)
			},
			// -----upload end

			getPostObjectParams() {
				//从后台获取postObject
				return this.$post(GET_POST_OBJECT_PARAMS,{folderType:'comment'}).then(rsp => {
					// this.postObject = rsp
					if (rsp.success) {
						this.postObject = rsp.data;
						console.log('this.postObject:', this.postObject)
					} else {
						console.error('getPostObjectParams:', rsp.message || '系统错误')
					}
				})
			}
		}
	}
</script>

<style lang="scss">
	.create-comment {
		padding: 12px;

		.comment-common {
			margin-bottom: 10px;
			padding: 15px 10px;
			background-color: white;
			border-radius: 10px;
		}

		.title {
			margin-bottom: 10px;
			font-size: 16px;
			font-weight: bold;
		}

		.star {
			.control {
				display: flex;
				align-items: center;

				.name {
					margin-right: 10px;
				}
			}
		}

		.content {
			textarea {
				font-size: 14px;
				width: 100%;
			}
		}

		.picture {}

		.submit {
			margin-top: 20px;
			width: 80%;
			font-size: 15px;
			color: white;
			background-color: #e65526;
		}
	}
</style>


附:根据blob链接获取blob对象

			/**
			 * 根据blob链接获取blob对象
			 * @param {Object} url "blob:http://localhost:8085/d688ce4f-0f5d-418c-85ad-62bcb3f38dee"
			 * @returns Blob(31846) {size: 31846, type: "image/jpeg"}
			 */
			getBlobByUrl(url) {
				return uni.request({
					url: url,
					// 合法值:text、arraybuffer
					responseType: 'arraybuffer'
				}).then(data=>{
					const [error, rsp]  = data;
					if(error){
						console.error(`post-error:${error}, url:${url}`)
						return {message: error.errMsg}
					}else{
						let buffer = rsp.data
						// ArrayBuffer(185) {}
						console.log('buffer:', buffer)
						return new Blob([buffer])
					}
				})
				
				// return new Promise((resolve, reject) => {
					
				// 	let xhr = new XMLHttpRequest()
				// 	xhr.open('GET', url, true)
				// 	xhr.responseType = 'blob'
				// 	xhr.onload = function(e) {
				// 		if (this.status == 200) {
				// 			let myBlob = this.response

				// 			// let file = new window.File(
				// 			// 	[myBlob],
				// 			// 	'myfile.jpg', {
				// 			// 		type: myBlob.type
				// 			// 	}
				// 			// )
				// 			// console.log("files:", file)
				// 			resolve(myBlob)
				// 		} else {
				// 			reject(false)
				// 		}
				// 	}
				// 	xhr.send()
				// })
			},

blobUrl、blob、base64、file相互转化:https://www.cnblogs.com/jing-zhe/p/15402775.html

uniapp选择file

<button @click="submit()" type="warn" class="submit" :loading="loading" :disabled="loading">发布</button>

submit() {
    let utils = new Utils()
    uni.chooseImage({
        count: 6, //默认9
        sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
        sourceType: ['album'], //从相册选择
        success: function(res) {
            console.log(JSON.stringify(res.tempFilePaths));
            console.log(res.tempFiles)

            utils.getFileMD5(res.tempFiles[0], function(md5) {
                console.log('md5:', md5)
            })
        }
    });
   
},
Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐