本文共 18423 字,大约阅读时间需要 61 分钟。
昨天我们完成了品牌的查询,接下来就是新增功能。点击新增品牌按钮
还是一样,先分析四个内容:
代码:
/** * 新增品牌 * @param brand * @param cids */ @PostMapping public ResponseEntitysaveBrand(Brand brand, @RequestParam("cids") List cids){ this.brandService.saveBrand(brand, cids); return ResponseEntity.status(HttpStatus.CREATED).build(); }
这里要注意,我们不仅要新增品牌,还要维护品牌和商品分类的中间表。
/** * 新增品牌 * * @param brand * @param cids */ @Transactional public void saveBrand(Brand brand, Listcids) { // 先新增brand this.brandMapper.insertSelective(brand); // 在新增中间表 cids.forEach(cid -> { this.brandMapper.insertCategoryAndBrand(cid, brand.getId()); }); }
这里调用了brandMapper中的一个自定义方法,来实现中间表的数据新增
通用Mapper只能处理单表,也就是Brand的数据,因此我们手动编写一个方法及sql,实现中间表的新增:
public interface BrandMapper extends Mapper{ /** * 新增商品分类和品牌中间表数据 * @param cid 商品分类id * @param bid 品牌id * @return ${修改字符时会引起sql注入} */ @Insert("INSERT INTO tb_category_brand(category_id, brand_id) VALUES (#{cid},#{bid})") int insertBrandAndCategory(@Param("cid") Long cid, @Param("bid") Long bid);}
我们填写表单并提交,发现报错了。查看控制台的请求详情:
发现请求的数据格式是JSON格式。
原因分析:
axios处理请求体的原则会根据请求数据的格式来定:
如果请求体是对象:会转为json发送
如果请求体是String:会作为普通表单请求发送,但需要我们自己保证String的格式是键值对。
如:name=jack&age=12
QS是一个第三方库,我们可以用npm install qs --save
来安装。不过我们在项目中已经集成了,大家无需安装:
这个工具的名字:QS,即Query String,请求参数字符串。
什么是请求参数字符串?例如: name=jack&age=21
QS工具可以便捷的实现 JS的Object与QueryString的转换。
在我们的项目中,将QS注入到了Vue的原型对象中,我们可以通过this.$qs
来获取这个工具:
我们将this.$qs
对象打印到控制台:
created(){ console.log(this.$qs);}
发现其中有3个方法:
这里我们要使用的方法是stringify,它可以把Object转为QueryString。
测试一下,使用浏览器工具,把qs对象保存为一个临时变量temp1,然后调用stringify方法:
成功将person对象变成了 name=zhangsan&age=30的字符串了
修改页面,对参数处理后发送:
我们发现有一个问题:新增不管成功还是失败,窗口都一致在这里,不会关闭。
这样很不友好,我们希望如果新增失败,窗口保持;但是新增成功,窗口关闭才对。
因此,我们需要在新增的ajax请求完成以后,关闭窗口
但问题在于,控制窗口是否显示的标记在父组件:MyBrand.vue中。子组件如何才能操作父组件的属性?或者告诉父组件该关闭窗口了?
之前我们讲过一个父子组件的通信,有印象吗?
this.$emit
调用父组件的函数:BrandForm.vue测试一下,保存成功:
我们优化一下,关闭的同时重新加载数据:
closeWindow(){ // 关闭窗口 this.show = false; // 重新加载数据 this.getDataFromServer();}
刚才的新增实现中,我们并没有上传图片,接下来我们一起完成图片上传逻辑。
文件的上传并不只是在品牌管理中有需求,以后的其它服务也可能需要,因此我们创建一个独立的微服务,专门处理各种上传。
我们需要EurekaClient和web依赖:
leyou com.leyou.parent 1.0.0-SNAPSHOT 4.0.0 com.leyou.upload leyou-upload 1.0.0-SNAPSHOT org.springframework.cloud spring-cloud-starter-netflix-eureka-client org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-test
server: port: 8082spring: application: name: upload-service servlet: multipart: max-file-size: 5MB # 限制文件上传的大小# Eurekaeureka: client: service-url: defaultZone: http://127.0.0.1:10086/eureka instance: lease-renewal-interval-in-seconds: 5 # 每隔5秒发送一次心跳 lease-expiration-duration-in-seconds: 10 # 10秒不发送就过期
需要注意的是,我们应该添加了限制文件大小的配置
@SpringBootApplication@EnableDiscoveryClientpublic class LeyouUploadApplication { public static void main(String[] args) { SpringApplication.run(LeyouUploadApplication.class, args); }}
结构:
文件上传功能,也是自定义组件完成的,参照自定义组件用法指南
编写controller需要知道4个内容:结合用法指南
代码如下:
@Controller@RequestMapping("upload")public class UploadController { @Autowired private UploadService uploadService; /** * 图片上传 * @param file * @return */ @PostMapping("image") public ResponseEntityuploadImage(@RequestParam("file") MultipartFile file){ String url = this.uploadService.upload(file); if (StringUtils.isBlank(url)) { return ResponseEntity.badRequest().build(); } return ResponseEntity.status(HttpStatus.CREATED).body(url); }}
在上传文件过程中,我们需要对上传的内容进行校验:
文件大小在Spring的配置文件中设置,因此已经会被校验,我们不用管。
具体代码:
@Servicepublic class UploadService { private static final ListCONTENT_TYPES = Arrays.asList("image/jpeg", "image/gif"); private static final Logger LOGGER = LoggerFactory.getLogger(UploadService.class); public String upload(MultipartFile file) { String originalFilename = file.getOriginalFilename(); // 校验文件的类型 String contentType = file.getContentType(); if (!CONTENT_TYPES.contains(contentType)){ // 文件类型不合法,直接返回null LOGGER.info("文件类型不合法:{}", originalFilename); return null; } try { // 校验文件的内容 BufferedImage bufferedImage = ImageIO.read(file.getInputStream()); if (bufferedImage == null){ LOGGER.info("文件内容不合法:{}", originalFilename); return null; } // 保存到服务器 file.transferTo(new File("C:\\leyou\\images\\" + originalFilename)); // 生成url地址,返回 return "http://image.leyou.com/" + originalFilename; } catch (IOException e) { LOGGER.info("服务器内部错误:{}", originalFilename); e.printStackTrace(); } return null; }}
这里有一个问题:为什么图片地址需要使用另外的url?
我们通过RestClient工具来测试:
图片上传是文件的传输,如果也经过Zuul网关的代理,文件就会经过多次网路传输,造成不必要的网络负担。在高并发时,可能导致网络阻塞,Zuul网关不可用。这样我们的整个系统就瘫痪了。
所以,我们上传文件的请求就不经过网关来处理了。
Zuul中提供了一个ignored-patterns属性,用来忽略不希望路由的URL路径,示例:
zuul.ignored-patterns: /upload/**
路径过滤会对一切微服务进行判定。
Zuul还提供了ignored-services
属性,进行服务过滤:
zuul.ignored-services: upload-servie
我们这里采用忽略服务:
zuul: ignored-services: - upload-service # 忽略upload-service服务
上面的配置采用了集合语法,代表可以配置多个。
现在,我们修改页面的访问路径:
查看页面的请求路径:
可以看到这个地址不对,依然是去找Zuul网关,因为我们的系统全局配置了URL地址。怎么办?
有同学会想:修改页面请求地址不就好了。
注意:原则上,我们是不能把除了网关以外的服务对外暴露的,不安全。
既然不能修改页面请求,那么就只能在Nginx反向代理上做文章了。
我们修改nginx配置,将以/api/upload开头的请求拦截下来,转交到真实的服务地址:
location /api/upload { proxy_pass http://127.0.0.1:8082; proxy_connect_timeout 600; proxy_read_timeout 600;}
这样写大家觉得对不对呢?
显然是不对的,因为ip和端口虽然对了,但是路径没变,依然是:http://127.0.0.1:8002/api/upload/image
前面多了一个/api
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,否则以新的路径/upload/image来匹配,就不会被正确的匹配到8082端口了
修改完成,输入nginx -s reload
命令重新加载配置。然后再次上传试试。
重启nginx,再次上传,发现跟上次的状态码已经不一样了,但是依然报错:
不过庆幸的是,这个错误已经不是第一次见了,跨域问题。
我们在upload-service中添加一个CorsFilter即可:
@Configurationpublic class LeyouCorsConfiguration { @Bean public CorsFilter corsFilter() { //1.添加CORS配置信息 CorsConfiguration config = new CorsConfiguration(); //1) 允许的域,不要写*,否则cookie就无法使用了 config.addAllowedOrigin("http://manage.leyou.com"); //3) 允许的请求方式 config.addAllowedMethod("OPTIONS"); config.addAllowedMethod("POST"); // 4)允许的头信息 config.addAllowedHeader("*"); //2.添加映射路径,我们拦截一切请求 UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource(); configSource.registerCorsConfiguration("/**", config); //3.返回新的CorsFilter. return new CorsFilter(configSource); }}
再次测试:
不过,非常遗憾的是,访问图片地址,却没有响应。
这是因为我们并没有任何服务器对应image.leyou.com这个域名。。
这个问题,我们暂时放下,回头再来解决。
先思考一下,现在上传的功能,有没有什么问题?
上传本身没有任何问题,问题出在保存文件的方式,我们是保存在服务器机器,就会有下面的问题:
这个时候,最好使用分布式文件存储来代替本地文件存储。
分布式文件系统(Distributed File System)是指文件系统管理的物理存储资源不一定直接连接在本地节点上,而是通过计算机网络与节点相连。
通俗来讲:
FastDFS是由淘宝的余庆先生所开发的一个轻量级、高性能的开源分布式文件系统。用纯C语言开发,功能丰富:
适合有大容量存储需求的应用或系统。同类的分布式文件系统有谷歌的GFS、HDFS(Hadoop)、TFS(淘宝)等。
先上图:
FastDFS两个主要的角色:Tracker Server 和 Storage Server 。
上传
下载
余庆先生提供了一个Java客户端,但是作为一个C程序员,写的java代码可想而知。而且已经很久不维护了。
这里推荐一个开源的FastDFS客户端,支持最新的SpringBoot2.0。
配置使用极为简单,支持连接池,支持自动生成缩略图,狂拽酷炫吊炸天啊,有木有。
地址:
接下来,我们就用FastDFS改造leyou-upload工程。
在父工程中,我们已经管理了依赖,版本为:
1.26.2
因此,这里我们直接在taotao-upload工程的pom.xml中引入坐标即可:
com.github.tobato fastdfs-client
纯java配置:
@Configuration@Import(FdfsClientConfig.class)// 解决jmx重复注册bean的问题@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)public class FastClientImporter { }
在application.yml配置文件中追加如下内容:
fdfs: so-timeout: 1501 # 超时时间 connect-timeout: 601 # 连接超时时间 thumb-image: # 缩略图 width: 60 height: 60 tracker-list: # tracker地址:你的虚拟机服务器地址+端口(默认是22122) - 192.168.56.101:22122
将来通过域名:image.leyou.com这个域名访问fastDFS服务器上的图片资源。所以,需要代理到虚拟机地址:
配置hosts文件,使image.leyou.com可以访问fastDFS服务器
192.168.56.101创建测试类:
把以下内容copy进去:
@SpringBootTest@RunWith(SpringRunner.class)public class FastDFSTest { @Autowired private FastFileStorageClient storageClient; @Autowired private ThumbImageConfig thumbImageConfig; @Test public void testUpload() throws FileNotFoundException { // 要上传的文件 File file = new File("C:\\Users\\joedy\\Pictures\\xbx1.jpg"); // 上传并保存图片,参数:1-上传的文件流 2-文件的大小 3-文件的后缀 4-可以不管他 StorePath storePath = this.storageClient.uploadFile( new FileInputStream(file), file.length(), "jpg", null); // 带分组的路径 System.out.println(storePath.getFullPath()); // 不带分组的路径 System.out.println(storePath.getPath()); } @Test public void testUploadAndCreateThumb() throws FileNotFoundException { File file = new File("C:\\Users\\joedy\\Pictures\\xbx1.jpg"); // 上传并且生成缩略图 StorePath storePath = this.storageClient.uploadImageAndCrtThumbImage( new FileInputStream(file), file.length(), "png", null); // 带分组的路径 System.out.println(storePath.getFullPath()); // 不带分组的路径 System.out.println(storePath.getPath()); // 获取缩略图路径 String path = thumbImageConfig.getThumbImagePath(storePath.getPath()); System.out.println(path); }}
结果:
group1/M00/00/00/wKg4ZVsWl5eAdLNZAABAhya2V0c424.jpgM00/00/00/wKg4ZVsWl5eAdLNZAABAhya2V0c424.jpg
group1/M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772.pngM00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772.pngM00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772_60x60.png
@Servicepublic class UploadService { @Autowired private FastFileStorageClient storageClient; private static final ListCONTENT_TYPES = Arrays.asList("image/jpeg", "image/gif"); private static final Logger LOGGER = LoggerFactory.getLogger(UploadService.class); public String upload(MultipartFile file) { String originalFilename = file.getOriginalFilename(); // 校验文件的类型 String contentType = file.getContentType(); if (!CONTENT_TYPES.contains(contentType)){ // 文件类型不合法,直接返回null LOGGER.info("文件类型不合法:{}", originalFilename); return null; } try { // 校验文件的内容 BufferedImage bufferedImage = ImageIO.read(file.getInputStream()); if (bufferedImage == null){ LOGGER.info("文件内容不合法:{}", originalFilename); return null; } // 保存到服务器 // file.transferTo(new File("C:\\leyou\\images\\" + originalFilename)); String ext = StringUtils.substringAfterLast(originalFilename, "."); StorePath storePath = this.storageClient.uploadFile(file.getInputStream(), file.getSize(), ext, null); // 生成url地址,返回 return "http://image.leyou.com/" + storePath.getFullPath(); } catch (IOException e) { LOGGER.info("服务器内部错误:{}", originalFilename); e.printStackTrace(); } return null; }}
只需要把原来保存文件的逻辑去掉,然后上传到FastDFS即可。
通过RestClient测试:
修改的难点在于回显。
当我们点击编辑按钮,希望弹出窗口的同时,看到原来的数据:
这个比较简单,修改show属性为true即可实现,我们绑定一个点击事件:
edit
然后编写事件,改变show 的状态:
如果仅仅是这样,编辑按钮与新增按钮将没有任何区别,关键在于,如何回显呢?
回显数据,就是把当前点击的品牌数据传递到子组件(MyBrandForm)。而父组件给子组件传递数据,通过props属性。
第一步:在编辑时获取当前选中的品牌信息,并且记录到data中
先在data中定义属性,用来接收用来编辑的brand数据:
我们在页面触发编辑事件时,把当前的brand传递给editBrand方法:
编辑
然后在editBrand中接收数据,赋值给oldBrand:
editItem(oldBrand){ // 使编辑窗口可见 this.dialog = true; // 初始化编辑的数据 this.oldBrand = oldBrand;}
第二步:把获取的brand数据 传递给子组件
第三步:在子组件(MyBrandForm.vue)中通过props接收要编辑的brand数据,Vue会自动完成回显
接收数据:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-diduTVtH-1586523104251)(assets/1528211066645.png)]
通过watch函数监控oldBrand的变化,把值copy到本地的brand:
watch: { oldBrand: { // 监控oldBrand的变化 handler(val) { if(val){ // 注意不要直接赋值,否则这边的修改会影响到父组件的数据,copy属性即可 this.brand = Object.deepCopy(val) }else{ // 为空,初始化brand this.brand = { name: '', letter: '', image: '', categories: [] } } }, deep: true }}
测试:发现数据回显了,除了商品分类以外:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vhFF24TJ-1586523104251)(assets/1528211235872.png)]
为什么商品分类没有回显?
因为品牌中并没有商品分类数据。我们需要在进入编辑页面之前,查询商品分类信息:
controller
/** * 通过品牌id查询商品分类 * @param bid * @return */@GetMapping("bid/{bid}")public ResponseEntity
> queryByBrandId(@PathVariable("bid") Long bid) { List list = this.categoryService.queryByBrandId(bid); if (list == null || list.size() < 1) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } return ResponseEntity.ok(list);}
Service
public ListqueryByBrandId(Long bid) { return this.categoryMapper.queryByBrandId(bid);}
mapper
因为需要通过中间表进行子查询,所以这里要手写Sql:
/** * 根据品牌id查询商品分类 * @param bid * @return */@Select("SELECT * FROM tb_category WHERE id IN (SELECT category_id FROM tb_category_brand WHERE brand_id = #{bid})")ListqueryByBrandId(Long bid);
我们在编辑页面打开之前,先把数据查询完毕:
editBrand(oldBrand){ // 根据品牌信息查询商品分类 this.$http.get("/item/category/bid/" + oldBrand.id) .then(({ data}) => { // 控制弹窗可见: this.dialog = true; // 获取要编辑的brand this.oldBrand = oldBrand // 回显商品分类 this.oldBrand.categories = data; })}
再次测试:数据成功回显了
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rTUIjo7d-1586523104252)(assets/1526222999115.png)]
但是,此时却产生了新问题:新增窗口竟然也有数据?
原因:
如果之前打开过编辑,那么在父组件中记录的oldBrand会保留。下次再打开窗口,如果是编辑窗口到没问题,但是新增的话,就会再次显示上次打开的品牌信息了。
解决:
新增窗口打开前,把数据置空。
addBrand() { // 控制弹窗可见: this.dialog = true; // 把oldBrand变为null this.oldBrand = null;}
新增和修改是同一个页面,我们该如何判断?
父组件中点击按钮弹出新增或修改的窗口,因此父组件非常清楚接下来是新增还是修改。
因此,最简单的方案就是,在父组件中定义变量,记录新增或修改状态,当弹出页面时,把这个状态也传递给子组件。
第一步:在父组件中记录状态:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ysy8Gru3-1586523104252)(assets/1526224372366.png)]
第二步:在新增和修改前,更改状态:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mKAff7gW-1586523104252)(assets/1526224447288.png)]
第三步:传递给子组件
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-v9Ek5str-1586523104253)(assets/1526224495244.png)]
第四步,子组件接收标记:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Npn9zCVH-1586523104253)(assets/1526224563838.png)]
标题的动态化:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B0mpvjbr-1586523104253)(assets/1526224628514.png)]
表单提交动态:
axios除了除了get和post外,还有一个通用的请求方式:
// 将数据提交到后台// this.$http.post('/item/brand', this.$qs.stringify(params))this.$http({ method: this.isEdit ? 'put' : 'post', // 动态判断是POST还是PUT url: '/item/brand', data: this.$qs.stringify(this.brand)}).then(() => { // 关闭窗口 this.$emit("close"); this.$message.success("保存成功!");}) .catch(() => { this.$message.error("保存失败!");});
转载地址:http://yxxab.baihongyu.com/