第十节:品牌新增与图片上传

此博客用于个人学习,来源于网上,对知识点进行一个整理。

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("保存失败!");
	    });
}
  1. 通过 this.$refs.myBrandForm 选中表单,然后调用表单的 validate 方法,进行表单校验。返回 boolean 值,true 代表校验通过
  2. 通过解构表达式来获取 brand 中的值,categories 需要处理,单独获取,其它的存入params对象中
  3. 品牌和商品分类的中间表只保存两者的 id,而 brand.categories 中保存的是对象数组,里面有 id 和 name 属性,因此这里通过数组的 map 功能转为 id 数组,然后通过 join 方法拼接为字符串
  4. 发起请求
  5. 弹窗提示成功还是失败,这里用到的是我们的自定义组件功能 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:

在上传文件过程中,需要对上传的内容进行校验:

  1. 校验文件大小
  2. 校验文件的媒体类型
  3. 校验文件的内容

文件大小在 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 存在的问题:

上传本身没有任何问题,问题出在保存文件的方式,我们是保存在服务器机器,就会有下面的问题:

  • 单机器存储,存储能力有限
  • 无法进行水平扩展,因为多台机器的文件无法共享,会出现访问不到的情况
  • 数据没有备份,有单点故障风险
  • 并发能力差
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值