此博客用于个人学习,来源于网上,对知识点进行一个整理。
1. 品牌的新增:
点击新增品牌按钮,Brand.vue 页面有一个提交按钮,绑定了一个点击触发函数 addBrand
该函数触发后,会使得 isEdit 为 false,show 为 true
当 show 为 true 时,使得添加窗口出现,而该窗口中的表单绑定了一个子组件
在页面中可以查找到该子组件 BrandFrom
1.1 页面实现:
1)重置表单:
v-form 组件已经提供了 reset 方法,用来清空表单数据。只要拿到表单组件对象,就可以调用方法了,可以通过 $refs 内置对象来获取表单组件。
首先,在表单上定义 ref 属性:
我们在 clear 中来获取表单对象并调用 reset 方法,这里得手动把 this.categories 清空了,因为级联选择组件并没有跟表单结合起来,需要手动清空。
clear() {
// 重置表单
this.$refs.myBrandForm.reset();
// 需要手动清空商品分类
this.categories = [];
}
2)表单校验:
Vuetify 的表单校验,是通过 rules 属性来指定的,其中需要注意的是:
- 规则是一个数组
- 数组中的元素是一个函数,该函数接收表单项的值作为参数,函数返回值两种情况:
- 返回 true,代表成功
- 返回错误提示信息,代表失败
我们有四个字段:
- name:做非空校验和长度校验,长度必须大于1
- letter:首字母,校验长度为1,非空。
- image:图片,不做校验,图片可以为空
- categories:非空校验,自定义组件已经帮我们完成,不用写了
data() {
return {
valid: false, // 表单校验结果标记
brand: {
name: '', // 品牌名称
letter: '', // 品牌首字母
image: '',// 品牌logo
categories: [], // 品牌所属的商品分类数组
},
nameRules: [
v => !!v || "品牌名称不能为空",
v => v.length > 1 || "品牌名称至少2位"
],
letterRules: [
v => !!v || "首字母不能为空",
v => /^[a-zA-Z]{1}$/.test(v) || "品牌字母只能是1个字母"
]
}
},
然后,在页面标签中指定:
<v-text-field v-model="brand.name" label="请输入品牌名称" hint="例如:oppo" :rules="[rules.required, rules.nameLength]"></v-text-field>
<v-text-field v-model="brand.letter" label="请输入品牌首字母" hint="例如:O" :rules="[rules.letter]"></v-text-field>
3)表单提交:
在 submit 方法中添加表单提交的逻辑:
submit() {
// 表单校验
if (this.$refs.myBrandForm.validate()) {
// 定义一个请求参数对象,通过解构表达式来获取brand中的属性
const {categories, letter, ...params} = this.brand;
// 数据库中只要保存分类的id即可,因此我们对categories的值进行处理,只保留id,并转为字符串
params.cids = categories.map(c => c.id).join(",");
// 将字母都处理为大写
params.letter = letter.toUpperCase();
// 将数据提交到后台
// this.$http.post('/item/brand', this.$qs.stringify(params))
this.$http({
method: this.isEdit ? 'put' : 'post',
url: '/item/brand',
data: this.params
}).then(() => {
// 关闭窗口
this.$emit("close");
this.$message.success("保存成功!");
})
.catch(() => {
this.$message.error("保存失败!");
});
}
- 通过 this.$refs.myBrandForm 选中表单,然后调用表单的 validate 方法,进行表单校验。返回 boolean 值,true 代表校验通过
- 通过解构表达式来获取 brand 中的值,categories 需要处理,单独获取,其它的存入params对象中
- 品牌和商品分类的中间表只保存两者的 id,而 brand.categories 中保存的是对象数组,里面有 id 和 name 属性,因此这里通过数组的 map 功能转为 id 数组,然后通过 join 方法拼接为字符串
- 发起请求
- 弹窗提示成功还是失败,这里用到的是我们的自定义组件功能 message 组件:
这个插件把 $message 对象绑定到了Vue的原型上,因此我们可以通过 this.$message 来直接调用。
包含以下常用方法:
- info、error、success、warning 等,弹出一个带有提示信息的窗口,色调与为普通(灰)、错误(红色)、成功(绿色)和警告(黄色)。使用方法:this.$message.info(“msg”)
- confirm:确认框。用法: this.$message.confirm(“确认框的提示信息”) ,返回一个 Promise
1.2 后台实现新增:
1)controller:
还是一样,先分析四个内容:
- 请求方式:POST
- 请求路径:/brand
- 请求参数:brand对象,外加商品分类的id数组cids
- 返回值:无,只需要响应状态码
/**
* 新增品牌
* @param brand
* @param cids
* @return
*/
@PostMapping
public ResponseEntity<Void> saveBrand(Brand brand,@RequestParam("cids")List<Long> cids){
this.brandService.saveBrand(brand,cids);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
2)service:
我们不仅要新增品牌,还要维护品牌和商品分类的中间表。
/**
* 新增品牌,还要维护品牌和商品分类的中间表
* @param brand
* @param cids
*/
@Transactional
public void saveBrand(Brand brand, List<Long> cids) {
//先新增brand
this.brandMapper.insertSelective(brand);
//再新增中间表
cids.forEach(cid -> {
this.brandMapper.insertCategoryAndBrand(cid,brand.getId());
});
}
调用了 brandMapper 中的一个自定义方法,来实现中间表的数据新增。
3)Mapper:
通用 Mapper 只能处理单表,也就是 Brand 的数据,因此我们手动编写一个方法及 sql,实现中间表的新增:
public interface BrandMapper extends Mapper<Brand> {
/**
* 新增商品分类和品牌中间表数据
* @param cid 商品分类id
* @param bid 品牌id
* @return
*/
@Insert("INSERT INTO tb_category_brand(category_id, brand_id) VALUES (#{cid},#{bid})")
int insertBrandAndCategory(@Param("cid") Long cid, @Param("bid") Long bid);
}
我们填写表单并提交,发现报错了——400。
1.3 解决请求参数不合法:
查看控制台的请求详情,发现请求的数据格式是 JSON 格式。
1)原因:
axios 处理请求体的原则会根据请求数据的格式来定:
-
如果请求体是对象:会转为 json 发送
-
如果请求体是 String:会作为普通表单请求发送,但需要我们自己保证 String 的格式是键值对。
如:name=jack&age=12
2)QS:
QS 是一个第三方库,即 Query String,请求参数字符串。
什么是请求参数字符串?例如: name=jack&age=21,QS 工具可以便捷的实现 JS 的 Object 与 QueryString 的转换。
将 QS 注入到了 Vue 的原型对象中,我们可以通过 this.$qs 来获取这个工具:
里面包含三个方法:stringify,parse,formats,要使用的方法是 stringify,它可以把 Object 转为 QueryString。
于是,修改页面,对参数处理后发送:
data: this.$qs.stringify(params)
1.4 新增完成后关闭窗口:
这个出现一个问题:不论是新增成功还是错误,都不会关闭页面,但这个时候更希望是新增失败保留页面,新增成功则关闭页面。
需要在新增的ajax请求完成以后,关闭窗口,但控制窗口是否显示的标记在父组件:MyBrand.vue中。这个时候问题就变成了:子组件如何才能操作父组件的属性?或者告诉父组件该关闭窗口了?
第一步:在父组件中定义一个函数,用来关闭窗口,不过之前已经定义过了。父组件在使用子组件时,绑定事件,关联到这个函数:Brand.vue
<!--对话框的内容,表单-->
<v-card-text class="px-5" style="height:400px">
<brand-form @close="closeWindow" :oldBrand="oldBrand" :isEdit="isEdit"/>
</v-card-text>
第二步,子组件通过 this.$emit 调用父组件的函数:BrandForm.vue,优化一下,关闭的同时重新加载数据:
closeWindow(){
// 关闭窗口
this.show = false;
// 重新加载数据
this.getDataFromServer();
}
2. 实现图片上传:
刚才的新增实现中,我们并没有上传图片,接下来我们一起完成图片上传逻辑。
文件的上传并不只是在品牌管理中有需求,以后的其它服务也可能需要,因此我们创建一个独立的微服务,专门处理各种上传。
2.1 搭建项目:
1)创建 module:
以 leyou 为父工程,建立子模块,命名为 leyou-upload。
2)导入依赖:
我们需要 EurekaClient 和 web 依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou</artifactId>
<groupId>com.leyou.parent</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.upload</groupId>
<artifactId>leyou-upload</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>com.github.tobato</groupId>
<artifactId>fastdfs-client</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
</dependencies>
</project>
3)编写配置:
由于上传文件,需要添加限制文件大小的配置:
server:
port: 8082
spring:
application:
name: upload-service
servlet:
multipart:
max-file-size: 5MB
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
instance:
lease-renewal-interval-in-seconds: 5
lease-expiration-duration-in-seconds: 15
4)引导类:
@SpringBootApplication
@EnableDiscoveryClient
public class LeyouUploadApplication {
public static void main(String[] args) {
SpringApplication.run(LeyouUploadApplication.class);
}
}
2.2 编写上传功能:
在 BrandFrom 组件中绑定该功能:
<v-upload v-model="brand.image" url="/upload/image" :multiple="false" :pic-width="250" :pic-height="90"/>
1)controller:
- 请求方式:上传肯定是POST
- 请求路径:/upload/image
- 请求参数:文件,参数名是 file,SpringMVC 会封装为一个接口:MultipartFile
- 返回结果:上传成功后得到的文件的 url 路径,也就是返回 String
@Controller
@RequestMapping("upload")
public class UploadController {
@Autowired
private UploadService uploadService;
@PostMapping("image")
public ResponseEntity<String> uploadImage(@RequestParam("file")MultipartFile file){
String url = this.uploadService.uploadImage(file);
if (StringUtils.isBlank(url)){
return ResponseEntity.badRequest().build();
}
return ResponseEntity.status(HttpStatus.CREATED).body(url);
}
}
2)service:
在上传文件过程中,需要对上传的内容进行校验:
- 校验文件大小
- 校验文件的媒体类型
- 校验文件的内容
文件大小在 Spring 的配置文件中设置,因此已经会被校验,不需要在 java 代码中进行校验。
@Service
public class UploadService {
private static final List<String> content_types = Arrays.asList("image/gif","image/jpeg");
private static final Logger LOGGER = LoggerFactory.getLogger(UploadService.class);
@Autowired
private FastFileStorageClient storageClient;
public String uploadImage(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
//校验文件类型
String contentType = file.getContentType();
if (!content_types.contains(contentType)){
LOGGER.info("文件类型不合法:{}",originalFilename);
return null;
}
try {
//校验文件内容
BufferedImage bufferedImage = ImageIO.read(file.getInputStream());
if (bufferedImage == null){
LOGGER.info("文件内容不合法:{}",originalFilename);
return null;
}
//保存到文件的服务器
//file.transferTo(new File("D:\\java\\images\\"+originalFilename));
String ext = StringUtils.substringAfterLast(originalFilename, ".");
StorePath storePath = this.storageClient.uploadFile(file.getInputStream(), file.getSize(), ext, null);
//返回url,进行回写
//return "http://image.leyou.com/" + originalFilename;
return "http://image.leyou.com/" + storePath.getFullPath();
} catch (IOException e) {
LOGGER.info("服务器内部错误:"+originalFilename);
e.printStackTrace();
}
return null;
}
}
这里需要注意的是图片地址需要使用另外的 url,原因是:
- 图片不能保存在服务器内部,这样会对服务器产生额外的加载负担
- 一般静态资源都应该使用独立域名,这样访问静态资源时不会携带一些不必要的 cookie,减小请求的数据量
2.3 绕过网关:
图片上传是文件的传输,如果也经过 Zuul 网关的代理,文件就会经过多次网路传输,造成不必要的网络负担。在高并发时,可能导致网络阻塞,Zuul 网关不可用。这样我们的整个系统就瘫痪了。所以,上传文件的请求就不经过网关来处理了。
1)Zuul 的路由过滤:
Zuul 中提供了一个 ignored-patterns 属性,用来忽略不希望路由的 URL 路径,示例:
zuul.ignored-patterns: /upload/**
路径过滤会对一切微服务进行判定。
Zuul 还提供了 ignored-services 属性,进行服务过滤:
zuul.ignored-services: upload-servie
我们这里采用忽略服务:
zuul:
ignored-services:
- upload-service # 忽略upload-service服务
上面的配置采用了集合语法,代表可以配置多个。
2)Nginx 的 rewrite 指令:
修改页面的访问路径:
<v-upload
v-model="brand.image"
url="/upload/image"
:multiple="false"
:pic-width="250" :pic-height="90"
/>
查看页面的请求路径:http://api.leyou.com/api/upload/image
可以看到这个地址不对,依然是去找 Zuul 网关,因为我们的系统全局配置了 URL 地址,这个时候可能会想到修改页面的地址,但原则上,我们是不能把除了网关以外的服务对外暴露的,不安全。
既然不能修改页面请求,那么就只能在 Nginx 反向代理上做文章了。我们可以修改 nginx 配置,将以 /api/upload 开头的请求拦截下来,转交到真实的服务地址,但这个时候访问的地址依然是上面那个,并没有做到正确的转移。
Nginx 提供了 rewrite 指令,用于对地址进行重写,语法规则:
rewrite "用来匹配路径的正则" 重写后的路径 [指令];
进行修改:
server {
listen 80;
server_name api.leyou.com;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 上传路径的映射
location /api/upload {
proxy_pass http://127.0.0.1:8082;
proxy_connect_timeout 600;
proxy_read_timeout 600;
rewrite "^/api/(.*)$" /$1 break;
}
location / {
proxy_pass http://127.0.0.1:10010;
proxy_connect_timeout 600;
proxy_read_timeout 600;
}
}
-
首先,我们映射路径是 /api/upload,而下面一个映射路径是 / ,根据最长路径匹配原则,/api/upload 优先级更高。也就是说,凡是以 /api/upload 开头的路径,都会被第一个配置处理
-
proxy_pass :反向代理,这次我们代理到8082端口,也就是 upload-service 服务
-
rewrite “^/api/(.*)$” /$1 break ,路径重写:
-
“^/api/(.*)$” :匹配路径的正则表达式,用了分组语法,把 /api/ 以后的所有部分当做1组
-
/$1 :重写的目标路径,这里用$1引用前面正则表达式匹配到的分组(组编号从1开始),即 /api/ 后面的所有。这样新的路径就是除去 /api/ 以外的所有,就达到了去除 /api 前缀的目的
-
break :指令,常用的有2个,分别是:last、break
- last:重写路径结束后,将得到的路径重新进行一次路径匹配
- break:重写路径结束后,不再重新匹配路径。
-
我们这里不能选择 last,否则以新的路径 /upload/image 来匹配,就不会被正确的匹配到8082端口了。
2.4 跨域问题:
重启 nginx,再次上传,发现跟上次的状态码已经不一样了,但是依然报错,原因是跨域了。
在 upload-service 中添加一个 CorsFilter 即可:
@Configuration
public class LeyouCorsConfiguration {
@Bean
public CorsFilter corsFilter(){
//初始化cors配置对象
CorsConfiguration configuration = new CorsConfiguration();
//允许跨域的域名,如果要携带cookie,不能写*,*:代表所有域名都可以跨域访问
configuration.addAllowedOrigin("http://manage.leyou.com");
configuration.setAllowCredentials(true);//允许携带cookie
configuration.addAllowedMethod("*");//代表所有的请求方法:GET POST PUT Delete。。。
configuration.addAllowedHeader("*");//允许携带任何头信息
//初始化cors配资源对象
UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
configurationSource.registerCorsConfiguration("/**",configuration);
//返回corsFilter实例,参数:cors配资源对象
return new CorsFilter(configurationSource);
}
}
2.5 存在的问题:
上传本身没有任何问题,问题出在保存文件的方式,我们是保存在服务器机器,就会有下面的问题:
- 单机器存储,存储能力有限
- 无法进行水平扩展,因为多台机器的文件无法共享,会出现访问不到的情况
- 数据没有备份,有单点故障风险
- 并发能力差