FastDFS Docker化部署 以及 Java SpringMVC实践

FastDFS Docker化部署 以及 Java SpringMVC实践

简介

FastDFS是一个轻量级分布式文件系统。可以对文件进行管理,功能包括:文件存储、文件同步、文件访问(文件上传、文件下载)等,而且可以集群部署,有高可用保障。相应的竞品有Ceph、TFS等。相比而言FastDFS对硬件的要求比较低,所以适合中小型公司。

概念

FastDFS服务端由两个重要部分组成:跟踪器(Tracker)和存储节点(Storage)。

Tracker主要做调度工作,在访问上起负载均衡的作用。Tracker可以做集群部署,各个节点之间是平等的,客户端请求时采用轮询机制,某个Tracker不能提供服务时就换另一个。Storage启动后会连接到Tracker Server告知自己的Group信息,形成映射关联,并采用心跳机制保持状态。
Storage存储节点负责文件的存储,Storage可以集群部署。

Storage集群有以下特点:

  • 以组(Group)为单位(也有称呼为卷 Volume的),集群的总容量为所有组的集合。
  • 一个卷(组)内storage server之间相互通信,文件进行同步,保证卷内storage完全一致,所以一个卷的容量以最小的服务器为准。不同的卷之间相互不通信。
  • 当某个卷的压力较大时可以添加storage server(纵向扩展),如果系统容量不够可以添加卷(横向扩展)。

上传流程

此章节根据资料整理,可能随着版本有所改变,这里只介绍大致的,以便了解整个运作流程。如果需要深入研究,建议还是以官方文档为标准。

一,客户端请求会打到负载均衡层,到tracker server时,由于每个server之间是对等的关系,所以可以任意选择一个tracker server。

二,到storage层:tracker server接收到upload file请求时,会为该请求分配一个可以存储该文件的group。

分配group规则:

  • Round robin 轮询
  • Specified group 指定一个group
  • Load balance 剩余存储空间多的group优先

三,确定group后,tracker会在group内选择一个storage server给客户端。

在group内选择storage server时规则:

  • Round robin 轮询
  • First server ordered by ip 按ip排序
  • First server ordered by priority,按优先级排序(优先级在storage上配置)

四,选择storage path:当分配好storage server后,客户端向storage发送写文件请求,storage将会为文件分配一个数据存储目录,支持规则如下:

  • round robin 轮询
  • 剩余存储空间最多的优先

五,生成File id:选定存储目录之后,storage会为文件生成一个File id。规则如下:
由storage server ip、文件创建时间、文件大小,文件crc32和一个随机数拼接而成,然后将这个二进制串进程base64编码,转换为可打印的字符串。

六,选择两级目录:每个存储目录下有两级256 * 256的子目录,storage会按文件Field进行两次hash,路由到其中的一个目录,然后将文件以file id为文件名存储到该子目录下。

一个文件路径最终由如下组成:组名/磁盘/目录/文件名

七,客户端upload file成功后,会拿到一个storage生成的文件名,接下来客户端根据这个文件名即可访问到该文件。

下载流程

下载流程如下:

一,选择tracker server:和upload file一样,在download file时随机选择tracker server。

二,选择group:tracker发送download请求给某个tracker,必须带上文件名信息,tracker从文件名中解析出group、大小、创建时间等信息,根据group信息获取对于的group。

三,选择storage server:从group中选择一个storage用来服务读请求。由于group内的文件同步时在后台异步进行的,所以有可能出现在读到的时候,文件还没有同步到某些storage server上,为了尽量避免反问道这样的storage,tracker按照一定的规则选择group内可读的storage。

文件HTTP预览服务

Storage还可以结合nginx的fastdfs-nginx-module提供http服务,以实现图片等预览功能。

这个部分这里不做介绍,后续可能单独写篇文章,因为我发现对fastDFS集群提供http服务还是挺复杂,包括我下面找的docker镜像都不完善,主要是规划的问题,包括衍生的服务,缓存,以及对图片的处理(nginx+lua)这些,后续打算研究下,重新开源个docker构建镜像。

实战

安装、部署规划

FastDFS安装方法网上有很多教程,这里不多讲,我建议使用docker来运行FastDFS,可以自己根据安装步骤构建自己的镜像。然后在需要的机器直接运行,后续扩容也方便,再启动一个storage容器就可以了。

详细版安装推荐篇文章:https://segmentfault.com/a/1190000008674582

Docker集群搭建

我这里从github上找的一个别人构建好的镜像,可以直接使用。地址:https://github.com/luhuiguo/fastdfs-docker

使用方法也很简单

1
2
3
4
5
6
7
8
9
10
11
12

# 启动一个tracker服务器
docker run -dti --network=host --name tracker -v /var/fdfs/tracker:/var/fdfs luhuiguo/fastdfs tracker

# 启动storage0
docker run -dti --network=host --name storage0 -e TRACKER_SERVER=10.1.5.85:22122 -v /var/fdfs/storage0:/var/fdfs luhuiguo/fastdfs storage

# 再启动一个storage1
docker run -dti --network=host --name storage1 -e TRACKER_SERVER=10.1.5.85:22122 -v /var/fdfs/storage1:/var/fdfs luhuiguo/fastdfs storage

# 启动一个新组的storage
docker run -dti --network=host --name storage2 -e TRACKER_SERVER=10.1.5.85:22122 -e GROUP_NAME=group2 -e PORT=22222 -v /var/fdfs/storage2:/var/fdfs luhuiguo/fastdfs storage

部署注意点

1,原github地址上的usage介绍,启动storage0和storage1有一个参数错误(多一个-e),以我上面发的命令为准。
2,这里的TRACKER_SERVER注意改为你自己的,同一个网段内网ip。

3,实际上这里docker容器之间还是同一个物理主机上部署的(根据network而言),虽然后续可以通过加硬盘,然后新建storage绑定到新加硬盘mount上,但是如果是大公司的生产环境还是推荐建立一个overlay网络,具体见:https://www.cnblogs.com/bigberg/p/8521542.html,这样可以直接扩物理机集群了。另外这里也提供docker-compose方式启动服务,实际也不推荐使用,因为tracker和storage server以后必然是分开的,所以还是推荐单个docker容器保持灵活性。这里高级点可以用k8s进行自动扩容(后续打算重新开源个镜像)。

Java实践

导入需要包

这里使用官方的客户端包:https://github.com/happyfish100/fastdfs-client-java

1
2
3
4
5
6
7
8
9
10
11
12
13
# 下载源码
git clone https://github.com/happyfish100/fastdfs-client-java.git

cd fastdfs-client-java

# 打jar包
mvn clean install

# 输出目录
cd target

# 导入到本地仓库 注意这里version根据实际生成的来
mvn install:install-file -DgroupId=org.csource -DartifactId=fastdfs-client-java -Dversion=1.27-SNAPSHOT -Dpackaging=jar -Dfile=fastdfs-client-java-1.27-SNAPSHOT.jar

在pom.xml中引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<dependency>
<groupId>org.csource</groupId>
<artifactId>fastdfs-client-java</artifactId>
<version>1.27-SNAPSHOT</version>
</dependency>

<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.3.1</version>
</dependency>

<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.2</version>
</dependency>

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.1</version>
</dependency>

添加Client配置

在resource目录下,添加conf/fdfs_client.conf配置文件

1
2
3
4
5
6
7
8
connect_timeout = 2
network_timeout = 30
charset = UTF-8
http.tracker_http_port = 80
http.anti_steal_token = no
http.secret_key = FastDFS1234567890

tracker_server = 192.168.1.163:22122

测试时实际上只需关注tracker_server,并且改为你自己的tracker server

添加文件上传bean

applicationContext.xml配置中添加文件上传bean

1
2
3
4
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
<property name="maxUploadSize" value="62914560" />
<property name="defaultEncoding" value="UTF-8" />
</bean>

建一个Client封装

建一个简单的client封装(勿作生产使用)
FastDFSClient.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package com.rootrl.fastDFSDemo.utiles;

import org.apache.commons.lang3.StringUtils;
import org.csource.common.NameValuePair;
import org.csource.fastdfs.*;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;

public class FastDFSClient {

private static StorageClient1 storageClient1 = null;

static {
try {
// 获取配置文件
String classPath = new File(FastDFSClient.class.getResource("/").getFile()).getCanonicalPath();
String CONF_FILENAME = classPath + File.separator + "conf" + File.separator + "fdfs_client.conf";
ClientGlobal.init(CONF_FILENAME);
// 获取触发器
TrackerClient trackerClient = new TrackerClient(ClientGlobal.g_tracker_group);
TrackerServer trackerServer = trackerClient.getConnection();
// 获取存储服务器
StorageServer storageServer = trackerClient.getStoreStorage(trackerServer);
storageClient1 = new StorageClient1(trackerServer, storageServer);
} catch (Exception e) {
System.out.println(e);
}
}

/**
* 上传文件
* @param fis 文件输入流
* @param fileName 文件名称
* @return
*/
public static String uploadFile(InputStream fis, String fileName) {
try {
NameValuePair[] meta_list = null;

//将输入流写入file_buff数组
byte[] file_buff = null;
if (fis != null) {
int len = fis.available();
file_buff = new byte[len];
fis.read(file_buff);
}

String fileid = storageClient1.upload_file1(file_buff, getFileExt(fileName), meta_list);
return fileid;
} catch (Exception ex) {
return null;
} finally {
if (fis != null) {
try {
fis.close();
} catch (IOException e) {
System.out.println(e);
}
}
}
}


/**
* 获取文件后缀
* @param fileName
* @return
*/
private static String getFileExt(String fileName) {
if (StringUtils.isBlank(fileName) || !fileName.contains(".")) {
return "";
} else {
return fileName.substring(fileName.lastIndexOf(".") + 1);
}
}
}

建立控制器

然后建立一个File控制器,做测试用
FileController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.rootrl.fastDFSDemo.controller;

import com.rootrl.fastDFSDemo.utiles.FastDFSClient;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;

@Controller
@RequestMapping("fastdfs")
public class FileController {

@RequestMapping(value = "upload")
@ResponseBody
public String uploadFileSample(@RequestParam MultipartFile file){

try {
String fileId = FastDFSClient.uploadFile(file.getInputStream(), file.getOriginalFilename());

return fileId;

} catch (Exception e) {
System.out.println(e.getMessage());
return "error";
}
}

}

然后使用postman客户端测试,url为:http://localhost:8080/fastdfs/upload.do(依据自己实际情况变更)

注意postman使用post请求,然后切换到body/form-data标签项,添加一个Key为file,类型为file,然后value就可以上传文件了。成功会返回文件id,类似:group1/M00/00/00/wKgBo1zjxnOAT-k1AAAoMlb3hzU996.png

参考

https://blog.csdn.net/yxflovegs2012/article/details/53868362

Share Comments

Intellij Idea 中进行 Mybatis逆向工程

开篇

Mybatis有个实用的功能就是逆向工程,能根据表结构反向生成实体类,这样能避免手工生成出错。市面上的教程大多都很老了,大部分都是针对mysql5的,以下为我执行mysql8时的经验。

引入工程

这里使用的是maven包管理工具,在pom.xml添加以下配置,以引入mybatis.generator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<build>
<finalName>SpringMVCBasic</finalName>
<!-- 添加mybatis-generator-maven-plugin插件 -->
<plugins>
<plugin>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
<version>1.3.2</version>
<configuration>
<verbose>true</verbose>
<overwrite>true</overwrite>
</configuration>
</plugin>
</plugins>
</build>

配置文件

在maven项目下的src/main/resources 目录下新建generatorConfig.xml和generator.properties文件

generator.properties

1
2
3
4
5
jdbc.driverLocation=F:\\maven-repository\\mysql\\mysql-connector-java\\8.0.16\\mysql-connector-java-8.0.16.jar
jdbc.driverClass=com.mysql.cj.jdbc.Driver
jdbc.connectionURL=jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf-8
jdbc.userId=test
jdbc.password=test123

注意:
1,generator.properties里面的jdbc.driverLocation指向是你本地maven库对应mysql-connector地址
2,与老版本不同,这里driversClass为com.mysql.cj.jdbc.Driver

generatorConfig.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
"http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">

<generatorConfiguration>

<!--导入属性配置-->
<properties resource="generator.properties"></properties>

<!--指定特定数据库的jdbc驱动jar包的位置(绝对路径)-->
<classPathEntry location="${jdbc.driverLocation}"/>

<context id="default" targetRuntime="MyBatis3">

<!-- optional,旨在创建class时,对注释进行控制 -->
<commentGenerator>
<!--是否去掉自动生成的注释 true:是-->
<property name="suppressDate" value="true"/>
<property name="suppressAllComments" value="true"/>
</commentGenerator>

<!--jdbc的数据库连接:驱动类、链接地址、用户名、密码-->
<jdbcConnection
driverClass="${jdbc.driverClass}"
connectionURL="${jdbc.connectionURL}"
userId="${jdbc.userId}"
password="${jdbc.password}">
</jdbcConnection>

<!-- 非必需,类型处理器,在数据库类型和java类型之间的转换控制-->
<javaTypeResolver>
<property name="forceBigDecimals" value="false"/>
</javaTypeResolver>

<!-- Model模型生成器,用来生成含有主键key的类,记录类 以及查询Example类
targetPackage 指定生成的model生成所在的包名
targetProject 指定在该项目下所在的路径
-->
<javaModelGenerator targetPackage="com.ifly.outsourcing.entity"
targetProject="src/main/java">
<property name="enableSubPackages" value="true"/>
<property name="trimStrings" value="true"/>
</javaModelGenerator>


<!--Mapper映射文件生成所在的目录 为每一个数据库的表生成对应的SqlMap文件 -->
<sqlMapGenerator targetPackage="mappers"
targetProject="src/main/resources">
<property name="enableSubPackages" value="false"/>
</sqlMapGenerator>

<!-- 客户端代码,生成易于使用的针对Model对象和XML配置文件 的代码
type="ANNOTATEDMAPPER",生成Java Model 和基于注解的Mapper对象
type="MIXEDMAPPER",生成基于注解的Java Model 和相应的Mapper对象
type="XMLMAPPER",生成SQLMap XML文件和独立的Mapper接口
-->

<javaClientGenerator type="XMLMAPPER" targetPackage="com.ifly.outsourcing.dao"
targetProject="src/main/java">
<property name="enableSubPackages" value="true"/>
</javaClientGenerator>

<!-- 数据表进行生成操作 tableName:表名; domainObjectName:对应的DO -->
<table tableName="user" domainObjectName="user"
enableCountByExample="false" enableUpdateByExample="false"
enableDeleteByExample="false" enableSelectByExample="false"
selectByExampleQueryId="false">
</table>

</context>
</generatorConfiguration>

注意:这里主要注意修改对应的javaModelGenerator ,sqlMapGenerator,javaClientGenerator 为自己的生成路径。以及添加自己的数据表。

在Intellij IDEA添加一个“Run运行”选项

点击菜单栏的run,新建一个选项为maven的configurations,name为自己方便看,比如generator,commnd line注意写为:

1
mybatis-generator:generate -e

点击run即可生成对应文件。

Share Comments

关于七牛云正确使用姿势探索

业务场景

需求

我们项目有一个文件上传需求,需要从客户端上传到七牛云的对象存储和自己的应用服务器上。这里使用七牛云主要是实现下载分发。应用服务器需要留一份是因为后续需要做文件分析(并且是上传后需要立马分析出结果展现给客户端)。另外,由于是初期项目,暂时没考虑用独立服务器来分析。

所用技术栈

服务器:Centos7
开发语言:PHP
框架:Laravel
前端上传组件:百度的WebUploader

解决方案

准确的说我经过了三个阶段才真正完美的实现了需求(主要解决上传速度)。

一期解决方案及细节

初期面对需求很容易想到的思路是:客户端先上传文件到应用服务器(因为上传完成可以及时做分析),然后再上传到七牛云上。

所以我的解决方案是:前端用webuploader,后端的七牛云文件处理方面使用了Laravel的一个插件:overtrue/flysystem-qiniu (https://github.com/overtrue/flysystem-qiniu),该插件的接口很简洁好用(但是有坑,后面会说到)。

然后为了解决性能问题,我还做了以下工作:
1,使用分片上传
2,后续上传七牛云使用异步的方式(因为文件上传到其他应用来下载这个文件,中间有许多时间来让上传任务的完成)

关于分片上传

这里讲下分片上传的实现思路,客户端主要是把大文件按一定size进行分片,然后上传到服务器,所以会有多个请求,并且每个请求还需带上关键的信息:当前chunk(从0开始)和chunks(总分片数)。由于我用的是webuploader组件,所以客户端不用自己做什么,只需配置下简单信息(是否分片及分片大小)。

服务端处理逻辑为:
客户端一个请求过来,分两种情况:
1,文件总size小于要分片的size,这时候直接处理文件。
2,处理分片情况。

具体逻辑是判断chunk和chunks,如果相等说明为第一种情况,直接处理上传,其他走处理分片逻辑。

处理分片的逻辑为:保存当前分片到临时目录(按分片命名),然后判断当所有分片完成时,就合并文件。具体逻辑是判断 chunk + 1 是否等于chunks。 合并逻辑就是循环读取临时文件,然后写入到一个新的文件(合并后的),这里可以顺便删除临时文件。

所遇的坑:
这里处理碎片文件时,当初图方便使用了Laravel的文件处理接口Storage::append,但是这个接口有个坑就是它自作主张的文件结尾加入换行符。导致合并后的文件还原不成原始文件。解决办法是老老实实使用php的fopen、fwrite、fclose这一套。

关于PHP异步处理

关于PHP的异步实现可以参考鸟哥写的文章:http://www.laruence.com/2008/04/14/318.html

主要方法为:客户端AJAX、popen函数、curl、fsocketopen等

不过这篇文章比较老了,局限性也大,现在有了协程等处理方案(现在Swoole也提供协程方案了,并且client-server task分发这种也可以用swoole的),而且往架构方面考虑可以使用队列等(感觉靠谱的还是队列)。

PS: 我这里前期用的是简单粗暴的popen,后来使用的是Laravel提供的队列。

一期方案的问题

通过上述所说的方案,很容易就实现了一个版本。但是没高兴多久。。,在后续测试时遇到一个诡异bug,当文件过大时,任务脚本上传到七牛云失败。

这里脚本是写在Laravel的artisan中的,当我把脚本命令直接在终端调试时也是没有任何异常(准确讲是看不了任何异常)

。前面我说过七牛这块SDK用的是overtrue/flysystem-qiniu ,并且为了考虑性能问题用的是他的writeStream接口。

1
2
3
4
5
6
7
$disk = Storage::disk('qiniu');
$stream = fopen($localFileName, 'r');
$disk->writeStream($fileName, $stream);

if (is_resource($stream)) {
fclose($stream);
}

代码表面上看起来很理想,用的是文件流上传(怕吃内存)。但结果证明一切只是表面上的。。

当我遇到大文件无法上传到七牛云时,断点调试到$disk->writeStream这里,发现返回的是false。 继而调试到overtrue/flysystem-qiniu这个扩展的源代码。然后发现了一个大坑。。

主要是两个问题:
1,writeStream只是个假的流写入

具体源码在扩展的QiniuAdapter.php文件中,这里贴段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function writeStream($path, $resource, Config $config)
{
$contents = '';

while (!feof($resource)) {
$contents .= fread($resource, 1024);
}

$response = $this->write($path, $contents, $config);

if (false === $response) {
return $response;
}

return compact('path');
}

注意这里的$contents变量,最终还是等价于一个大文件内容的大小(服务器为此变量开辟的内存)。并且后续还要在方法间传递。所以这里是假的流!

2,接口对调试不友好

还有在write方法中,屏蔽了$error,只返回false,这样不便于我们查问题,最终我是断点打印这个$error才知道报的错误是:“invalid multipart format: multipart: message too large”,这个应该是七牛那边真正返回的,但这么重要的信息被这个扩展屏蔽了。

二期解决方案

知道了一期方案的具体问题所在,我就一直在思考(那个扩展就不提了。。我现在怀疑它的存在意义。。),甚至在想也许一开始整个思路就错了(通过SDK上传文件的方案)。后来还真被我找到了,七牛云官方提供一个脚本工具:Qshell(https://github.com/qiniu/qshell)。这个是命令行运行脚本,具体操作看文档就可以了。放到我的项目也是集成到七牛的任务脚本中。

后来测试可以了,整个流程可以跑通。

但是无意中发现二期的重要问题,这个上传走的是服务器的上行带宽!而我们平常付费买的带宽就是买的上行带宽!(下行是一般是免费的)。这还怎么搞!由于我们上传业务是商户端使用的,平时使用频次也不会太少,这会导致在上传时影响前端网站的访问速度。

这里具体讲下服务器带宽问题(网上查询后整理的):

首先对服务器带宽方向的描述一般是用上行和下行,上传和下载是指动作。

上行是指从服务器流出的带宽,如果是在其他机器下载服务器上的文件,用的主要是服务器的上行带宽(这里说下我们平时的网页浏览,其实也是不同客户端从服务器下数据, html文件、css等然后渲染,所以网页浏览占用的也是上行带宽)。

下行是指流入到服务器的带宽,如果是在其他机器上传文件到服务器,比如用FTP上传文件,用的主要是服务器的下行带宽(服务器上下载文件用的也是下行带宽)。

现在的云提供商比如阿里云不限制的是下行带宽,大部分服务器的使用环境,都是上行带宽用的多,下行带宽用的少。

通过对带宽的理解,再回到我们项目的上传实现思路,可以看到一开始就错了(不该用应用服务器作为中转)!

三期(最终)方案

当初为了节省时间,直接跳过官方文档,而使用第三方扩展。 现在看来,不得不又回到官方文档了。

通过把七牛的文档过一遍,发现是有方案可以避开那个占用服务器上行带宽的问题的。

主体思路是要避开应用服务器上行带宽的使用,因为上行带宽很宝贵,尽量使用下行带宽(免费、速度很快!阿里的大概60M多每秒)。

具体实现是通过七牛的表单上传方案直接把客户端的文件先上传到七牛(这一步根本不关应用服务器什么事,所以避开了,而且直接上传到七牛的速度非常快,基本只取决于用户端的网速,而且对于一般需求,七牛提供了对于到我们应用服务器的回调方法)。然后由于我们应用服务器也需要文件,所以方案是直接在我们应用服务器直接下载七牛的文件(这里可以同步阻塞住,前端做个等待效果解决用户体验问题)。因为前面说到流入到服务器占用的是下行带宽。所以这里速度也会非常快(而且是免费的^_^)。

这种方案基本是完美的了。

总结

首先是对个人的反省,前期调研不充足,但是项目初期有点紧,这里也说明投入时间的重要性。

其次关于项目经验:上传第三方云存储,千万不要使用应用服务器做中转!可以直接上传到第三方云服务器,如果有后续处理逻辑的,可以使用他们的回调接口。

Share Comments