乐优商城个人笔记上-主要框架、基础知识、管理系统代码

SpringBoot

  • 内置tomcat 提供了自动配置,搭建spring应用的脚手架
  • 复杂的配置,混乱的依赖关系

入门项目

1.引入依赖

 
        org.springframework.boot
        spring-boot-starter-parent
        2.0.6.RELEASE
    

    
        
            org.springframework.boot
            spring-boot-starter-web
        
    

2. 编写Controller

3. 优化controller

  • 旧的都要 main和@EnableAutoConfiguration:
  • 创建引导类
  • 除了EAC再加@ComponentScan 扫描包下所有Controller (必须在类的主或子包才可扫到)

4.新增配置

  • 引入依赖

    com.github.drtrang
    druid-spring-boot2-starter
    1.1.10

  • resources创建properties文件
  • java中创建JdbcConfiguration类,用于注入数据源
@Configuration
@PropertySource("classpath:jdbc.properties")
public class JdbcConfiguration {

    @Value("${jdbc.url}")
    String url;
    @Value("${jdbc.driverClassName}")
    String driverClassName;
    @Value("${jdbc.username}")
    String username;
    @Value("${jdbc.password}")
    String password;

    @Bean
    public DataSource dataSource() {
        DruidDataSource dataSource = new DruidDataSource();
        dataSource.setUrl(url);
        dataSource.setDriverClassName(driverClassName);
        dataSource.setUsername(username);
        dataSource.setPassword(password);
        return dataSource;
    }
}

属性注入

  • 新建JdbcProperties,用来进行属性注入
@ConfigurationProperties(prefix = "jdbc")
public class JdbcProperties {
    private String url;
    private String driverClassName;
    private String username;
    private String password;
    // ... 略
    // getters 和 setters
}
  • 注意:没有指定属性文件的地址,SpringBoot默认会读取文件名为application.properties的资源文件,所以我们把jdbc.properties名称改为application.properties

  • SpringBoot的四种属性注入方式:用于在JdbcConfiguration中注入 JdbcProperties.class

  1. @Autowired注入
@Autowired
    private JdbcProperties jdbcProperties;
  1. 构造方法注入
private JdbcProperties jdbcProperties;

    public JdbcConfiguration(JdbcProperties jdbcProperties){
        this.jdbcProperties = jdbcProperties;
    }
  1. @Bean方法形参注入
    • 直接在@Bean 方法上使用
@Configuration
@EnableConfigurationProperties(JdbcProperties.class)
public class JdbcConfiguration {

    @Bean
    public DataSource dataSource(JdbcProperties jdbcProperties) {
        // ...
    }
}
  • 重要注释
    • @RestController
    • @EnableAutoConfiguration
    • @ComponentScan
    • @SpringBootConfiruration
    • @SpringBootApplication √
    • @PropertySource
    • @Bean √
    • @Value
    • @ConfigurationProperties √
    • @EnableConfigurationProperties√

SpringBoot 实战

创建工程

  • maven
  • 无父工程
  • 路径要在demo工程目录下
  • 导入基本依赖
  • 创建控制类
  • 创建启动类

整合springMVC

  • 配置端口 application.properties server.port
  • 访问静态页面:ResourceProperties类中定义静态资源默认查找路径
    • classpath:/META-INF/resources/
    • classpath:/resources/
    • classpath:/static/ (常用此)
    • classpath:/public/
  • 添加拦截器:创建interceptors文件夹创建类实现HandlerInterceptor接口
@Component
public class MyInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        System.out.println("preHandle method is running!");
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        System.out.println("postHandle method is running!");
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        System.out.println("afterCompletion method is running!");
    }
}
  • 通过实现WebMvcConfigurer并添加@Configuration注解来实现自定义部分SpringMvc配置。
@Configuration
public class MvcConfiguration implements WebMvcConfigurer {

    @Autowired
    private HandlerInterceptor myInterceptor;

    /**
     * 重写接口中的addInterceptors方法,添加自定义拦截器
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(myInterceptor).addPathPatterns("/**");
    }
}
  • 运行查看日志只有打印信息,因为springMVC记录的log级别是debug,springboot默认是显示info以上,我们需要进行配置。
    • application.properties中写logging.level.org.springframework=debug

整合连接池

  • 导入启动器和连接池


    org.springframework.boot
    spring-boot-starter-jdbc



    mysql
    mysql-connector-java

  • 配置连接池参数
# 连接四大参数
spring.datasource.url=jdbc:mysql://localhost:3306/heima
spring.datasource.username=root
spring.datasource.password=root
# 连接池参数,可省略,SpringBoot自行判断
spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.hikari.idle-timeout=60000
spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.minimum-idle=10

整合mybatis

  • 导入依赖


    org.mybatis.spring.boot
    mybatis-spring-boot-starter
    1.3.2

  • 配置
# mybatis 别名扫描
mybatis.type-aliases-package=cn.itcast.pojo
# mapper.xml文件位置,如果没有映射文件,请注释掉
mybatis.mapper-locations=classpath:mappers/*.xml
  • 创建pojo包-User类
  • 没有配置mapper接口扫描包,因此每个Mapper接口都需要添加@Mapper注解
  • 创建Mapper包-UserMapper类
@Mapper
public interface UserMapper extends tk.mybatis.mapper.common.Mapper{
}
  • 配置 通用mapper启动器


    tk.mybatis
    mapper-spring-boot-starter
    2.0.2

整合事务

  • 无需引入依赖
  • 创建service-UserService
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public User queryById(Long id){
        return this.userMapper.selectByPrimaryKey(id);
    }

    @Transactional
    public void deleteById(Long id){
        this.userMapper.deleteByPrimaryKey(id);
    }
}

启动测试

  • Controller中注入service 以及修改方法注入参数
@RestController
@RequestMapping("user")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("{id}")
    @ResponseBody
    public User queryUserById(@PathVariable("id")Long id){
        return this.userService.queryById(id);
    }

    @GetMapping("hello")
    public String test(){
        return "hello ssm";
    }
}

Thymeleaf 快速入门

  • Thymeleaf 是一个跟 Velocity、FreeMarker 类似的模板引擎,它可以完全替代 JSP
  • 特点:
    • 动静结合:有无网络都可用
    • 开箱即用
    • 多方言支持
    • 与SpringBoot完美整合

引入启动器

  • 与解析JSP的InternalViewResolver类似,Thymeleaf也会根据前缀和后缀来确定模板文件的位置:
    • 默认前缀:classpath:/templates/
    • 默认后缀:.html

    org.springframework.boot
    spring-boot-starter-thymeleaf

静态页面

  • 注意,把html 的名称空间,改成:xmlns:th=“http://www.thymeleaf.org”
  • ${} :类似el表达式,但其实是ognl的语法,更加强大
  • th-指令:th-是利用了Html5中的自定义属性来实现的。如果不支持H5,可以用data-th-来代替
    • th:each:类似于c:foreach 遍历集合,但是语法更加简洁
    • th:text:声明标签中的文本

模板缓存

# 开发阶段关闭thymeleaf的模板缓存
spring.thymeleaf.cache=false
  • 在Idea中,我们需要在修改页面后按快捷键:Ctrl + Shift + F9 对项目进行rebuild才可以。

SpringCloud

架构的演变

  • 集中式结构存在的问题:
    • 代码耦合,开发维护困难
    • 无法针对不同模块进行针对性优化
    • 无法水平扩展
    • 单点容错率低,并发能力差
  • 垂直拆分:功能拆分
  • 分布式服务:对基础服务进行抽取,相互调用,维护难
  • 流动计算架构:SOA
  • 微服务架构:单一职责,粒度小,面向服务、自治

服务调用方式

服务的远程调用方式-RPC与HTTP

  • RPC:Remote Produce Call远程过程调用,类似的还有RMI。自定义数据格式,基于原生TCP通信,速度快,效率高。早期的webservice,现在热门的dubbo,都是RPC的典型代表
  • HTTP:http其实是一种网络传输协议,基于TCP,规定了数据传输的格式。现在客户端浏览器与服务端通信基本都是采用Http协议,也可以用来进行远程服务调用。缺点是消息封装臃肿,优势是对服务的提供和调用方没有任何技术限定,自由灵活,更符合微服务理念。

HTTP客户端工具

  • HttpClient
  • OKHttp
  • URLConnection
  • 不过这些不同的客户端API各不相同

Spring的RestTemplate

  • 对基于Http的客户端进行了封装,并且实现了对象与json的序列化和反序列化
  • 支持常用三种客户端类型
    • HttpClient
    • OkHttp
    • JDK原生的URLConnection(默认的)
  1. 首先注册RestTemplate对象,启动类位置注册
@SpringBootApplication
public class HttpDemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(HttpDemoApplication.class, args);
	}

	@Bean
	public RestTemplate restTemplate() {
   
		return new RestTemplate();
	}
}
  1. 测试类中注入
@RunWith(SpringRunner.class)
@SpringBootTest(classes = HttpDemoApplication.class)
public class HttpDemoApplicationTests {

	@Autowired
	private RestTemplate restTemplate;

	@Test
	public void httpGet() {
        // 调用springboot案例中的rest接口
		User user = this.restTemplate.getForObject("http://localhost/user/1", User.class);
		System.out.println(user);
	}
}
  1. 通过RestTemplate的getForObject()方法,传递url地址及实体类的字节码,RestTemplate会自动发起请求,接收响应,并且帮我们对响应结果进行反序列化。

初识SpringCloud

  • SpringCloud整合了大量框架,实现了诸如:配置管理,服务发现,智能路由,负载均衡,熔断器,控制总线,集群状态等等功能。其主要涉及的组件包括:
    • Eureka:服务治理组件,包含服务注册中心,服务注册与发现机制的实现。(服务治理,服务注册/发现)
    • Zuul:网关组件,提供智能路由,访问过滤功能
    • Ribbon:客户端负载均衡的服务调用组件(客户端负载)
    • Feign:服务调用,给予Ribbon和Hystrix的声明式服务调用组件 (声明式服务调用)
    • Hystrix:容错管理组件,实现断路器模式,帮助服务依赖中出现的延迟和为故障提供强大的容错能力。(熔断、断路器,容错)
      乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第1张图片
  • 版本:
    • 版本名:是伦敦的地铁名
    • 版本号:SR(Service Releases)是固定的 ,大概意思是稳定版本。后面会有一个递增的数字。

微服务场景模拟

  • 搭建两个工程:
    • 服务提供方:使用mybatis操作数据库,实现对数据的增删改查;并对外提供rest接口服务。
    • 服务消费方:使用restTemplate远程调用服务提供方的rest接口服务,获取数据。

服务提供者

  • 搭建一个项目:itcast-service-provider,对外提供根据id查询用户的服务。

Spring脚手架创建工程

  • new Module-SpringInitializr-√Default
  • next填写项目信息:Group:cn.itcast.service Artifact:itcast-service-provider Package:cn.itcast.service
  • next-添加web依赖 Web-√Web
  • 添加mybatis依赖:SQL-√MySQL√JDBC√MyBatis
  • next-填写项目位置-生成的项目结构已经包含了引导类itcastServiceProviderApplication
  • 依赖也已经自动导入
  • 要使用mapper 手动添加一条依赖

    tk.mybatis
    mapper-spring-boot-starter
    2.0.4

RunDashboard-show,可以快速启动。如果取消则在leyou资料里idea快速上手指南打开

编写代码

  • controller-UserController
  • mapper-UserMapper
  • pojo-User
  • servcieUserService
  • application.yml
server:
  port: 8081
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mybatis #你学习mybatis时,使用的数据库地址
    username: root
    password: root
mybatis:
  type-aliases-package: cn.itcast.service.pojo

服务调用者

创建工程

  • next填写项目信息:Group:cn.itcast.service Artifact:itcast-service-consumer Package:cn.itcast.service
  • next-添加web依赖 Web-√Web

编写代码

  • 引导类中注册RestTemplate:
@SpringBootApplication
public class ItcastServiceConsumerApplication {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }

    public static void main(String[] args) {
        SpringApplication.run(ItcastServiceConsumerApplication.class, args);
    }
}
  • 编写配置(application.yml)

  • controller-UserController

  • mapper-UserMapper

  • pojo-User

  • servcieUserService

  • 启动测试

存在的问题

  • 在consumer中,我们把url地址硬编码到了代码中,不方便后期维护
  • consumer需要记忆provider的地址,如果出现变更,可能得不到通知,地址将失效
  • consumer不清楚provider的状态,服务宕机也不知道
  • provider只有1台服务,不具备高可用性
  • 即便provider形成集群,consumer还需自己实现负载均衡

其实上面说的问题,概括一下就是分布式服务必然要面临的问题:

  • 服务管理
    • 如何自动注册和发现
    • 如何实现状态监管
    • 如何实现动态路由
  • 服务如何实现负载均衡
  • 服务如何解决容灾问题
  • 服务如何实现统一配置

以上的问题,我们都将在SpringCloud中得到答案。

Eureka注册中心

原理图

在这里插入图片描述

  • Eureka:就是服务注册中心(可以是一个集群),对外暴露自己的地址
  • 提供者:启动后向Eureka注册自己信息(地址,提供什么服务)
  • 消费者:向Eureka订阅服务,Eureka会将对应服务的所有提供者地址列表发送给消费者,并且定期更新
  • 心跳(续约):提供者定期通过http方式向Eureka刷新自己的状态

入门案例

搭建EurekaServer

引入启动器
  • 使用spring快速搭建工具
  • 填写项目信息:Group:cn.itcast.eureka Artifact:itcast-eureka Package:cn.itcast.eureka
  • New Module-Cloud Discovery-√Eureka Server
  • 选择依赖:EurekaServer-服务注册中心依赖,Eureka Discovery-服务提供方和服务消费方。因为,对于eureka来说:服务提供方和服务消费方都属于客户端
覆盖默认配置
  • 编写application.yml配置:
server:
  port: 10086 # 端口
spring:
  application:
    name: eureka-server # 应用名称,会在Eureka中显示
eureka:
  client:
    service-url: # EurekaServer的地址,现在是自己的地址,如果是集群,需要加上其它Server的地址。
      defaultZone: http://127.0.0.1:${server.port}/eureka
修改引导类
@SpringBootApplication
@EnableEurekaServer // 声明当前springboot应用是一个eureka服务中心
public class ItcastEurekaApplication {

    public static void main(String[] args) {
        SpringApplication.run(ItcastEurekaApplication.class, args);
    }
}

服务端注册到Eureka

  • 注册服务就是在服务上添加Eureka的客户端依赖,客户端代码会自动把服务注册到Eureka Server 中
  • 修改itcast-service-provider工程
1. 在pom.xml中,添加springcloud的相关依赖。
  • 先添加依赖


    
        
            org.springframework.cloud
            spring-cloud-dependencies
            Finchley.SR2
            pom
            import
        
    

  • 然后是Eureka的客户端

    org.springframework.cloud
    spring-cloud-starter-netflix-eureka-client

  • 版本号-properties
2. 在application.yml中,添加springcloud的相关依赖。
server:
  port: 8081
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/heima
    username: root
    password: root
    driverClassName: com.mysql.jdbc.Driver
  application:
    name: service-provider # 应用名称,注册到eureka后的服务名称
mybatis:
  type-aliases-package: cn.itcast.service.pojo
eureka:
  client:
    service-url: # EurekaServer地址
      defaultZone: http://127.0.0.1:10086/eureka
  • 注意:这里我们添加了spring.application.name属性来指定应用名称,将来会作为应用的id使用。
3. 在引导类上添加注解,把服务注入到eureka注册中心。
@SpringBootApplication
@MapperScan("cn.itcast.service.mapper")//mapper 接口包扫描
@EnableDiscoveryClient //启动Eureka的客户端  @EnableEurekaClient 也可以,不常用
public class ItcastServiceProviderApplication {

    public static void main(String[] args) {
        SpringApplication.run(ItcastServiceApplication.class, args);
    }
}

从Eureka获取服务

  • 接下来我们修改itcast-service-consumer,尝试从EurekaServer获取服务。
1.prom.xml
  • 先添加依赖


    
        
            org.springframework.cloud
            spring-cloud-dependencies
            Finchley.SR2
            pom
            import
        
    

  • 然后是Eureka的客户端

    org.springframework.cloud
    spring-cloud-starter-netflix-eureka-client

  • 版本号-properties
2.application.yml
server:
  port: 80
spring:
  application:
    name: service-consumer
eureka:
  client:
    service-url: 
      defaultZone: http://localhost:10086/eureka
3.启动类开启Eureka客户端
@SpringBootApplication
@EnableDiscoveryClient // 开启Eureka客户端
public class ItcastServiceConsumerApplication {

    @Bean
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }

    public static void main(String[] args) {
        SpringApplication.run(ItcastServiceConsumerApplication.class, args);
    }
}
4.修改UserController代码,用DiscoveryClient类的方法,根据服务名称,获取服务实例:
@Controller
@RequestMapping("consumer/user")
public class UserController {

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    private DiscoveryClient discoveryClient; // eureka客户端,可以获取到eureka中服务的信息

    @GetMapping
    @ResponseBody
    public User queryUserById(@RequestParam("id") Long id){
        // 根据服务名称,获取服务实例。有可能是集群,所以是service实例集合
        List instances = discoveryClient.getInstances("service-provider");
        // 因为只有一个Service-provider。所以获取第一个实例
        ServiceInstance instance = instances.get(0);
        // 获取ip和端口信息,拼接成服务地址
        String baseUrl = "http://" + instance.getHost() + ":" + instance.getPort() + "/user/" + id;
        User user = this.restTemplate.getForObject(baseUrl, User.class);
        return user;
    }

}

Eureka详解

高可用Eureka

  • 可注册多个可用的Eureka,形成集群。某个节点被访问时,会将服务信息同步给集群中每个节点实现数据同步。
  • EurekaApplication右键 copy configuration 重新配置启动器
  • 启动第一个eurekaServer,我们修改原来的EurekaServer配置:
server:
  port: 10086 # 端口
spring:
  application:
    name: eureka-server # 应用名称,会在Eureka中显示
eureka:
  client:
    service-url: # 配置其他Eureka服务的地址,而不是自己,比如10087
      defaultZone: http://127.0.0.1:10087/eureka
  • 启动另一个eurekaServer,再次修改itcast-eureka的配置:
server:
  port: 10087 # 端口
spring:
  application:
    name: eureka-server # 应用名称,会在Eureka中显示
eureka:
  client:
    service-url: # 配置其他Eureka服务的地址,而不是自己,比如10087
      defaultZone: http://127.0.0.1:10086/eureka
  • 访问时会相互自动跳转

服务提供者

服务注册

  • 服务提供者在启动时,会检测配置属性中的:eureka.client.register-with-eureka=true参数是否正确,事实上默认就是true。如果值确实为true,则会向EurekaServer发起一个Rest请求,并携带自己的元数据信息,Eureka Server会把这些信息保存到一个双层Map结构中。
  • Map>
    • 服务Id,一般是配置中的spring.application.name属性
    • 服务的实例id。一般host+ serviceId + port,例如:locahost:service-provider:8081
    • 实例对象,也就是说一个服务,可以同时启动多个不同实例,形成集群。

服务续约

  • 在注册服务完成以后,服务提供者会维持一个心跳(定时向EurekaServer发起Rest请求),告诉EurekaServer:“我还活着”。这个我们称为服务的续约(renew);
eureka:
  instance:
    lease-expiration-duration-in-seconds: 90
    lease-renewal-interval-in-seconds: 30
  • lease-renewal-interval-in-seconds:服务续约(renew)的间隔,默认为30秒
  • lease-expiration-duration-in-seconds:服务失效时间,默认值90秒

服务消费者

  • 获取服务列表
  • 当服务消费者启动时,会检测eureka.client.fetch-registry=true参数的值,如果为true,则会拉取Eureka Server服务的列表只读备份,然后缓存在本地。并且每隔30秒会重新获取并更新数据。我们可以通过下面的参数来修改:
eureka:
  client:
    registry-fetch-interval-seconds: 5

生产环境中,我们不需要修改这个值。

但是为了开发环境下,能够快速得到服务的最新状态,我们可以将其设置小一点。

失效提出和自我保护

  • 服务下线。正常关闭触发RESR请求给Eureka Server,收到后设为下线状态
  • 失效剔除:服务无法正常工作,会剔出服务列表。因此它会开启一个定时任务,每隔60秒对所有失效的服务(超过90秒未响应)进行剔除。
    • server中的yml的eureka.server.eviction-interval-timer-in-ms参数对其进行修改,单位是毫秒,生产环境不要修改。
  • 自我保护:心跳失败实例比例超标,但是不予剔除。开发阶段都会关闭
eureka:
  server:
    enable-self-preservation: false # 关闭自我保护模式(缺省为打开)
    eviction-interval-timer-in-ms: 1000 # 扫描失效服务的间隔时间(缺省为60*1000ms)

负载均衡Ribbon

  • 有助于控制HTTP和TCP客户端的行为。面对provider的集群,可基于某种负载均衡算法自动帮助服务消费者请求。
  • 启动两个provider:复制configuration,启动一次之后改端口号,再启动一次

开启负载均衡

  • 修改itcast-service-consumer的引导类,在RestTemplate的配置方法上添加@LoadBalanced注解:
  • 修改调用方式,不再手动获取ip和端口,而是通过服务器名称调用。
@Controller
@RequestMapping("consumer/user")
public class UserController {

    @Autowired
    private RestTemplate restTemplate;

    //@Autowired
    //private DiscoveryClient discoveryClient; // 注入discoveryClient,通过该客户端获取服务列表

    @GetMapping
    @ResponseBody
    public User queryUserById(@RequestParam("id") Long id){
        // 通过client获取服务提供方的服务列表,这里我们只有一个
        // ServiceInstance instance = discoveryClient.getInstances("service-provider").get(0);
        String baseUrl = "http://service-provider/user/" + id;
        User user = this.restTemplate.getForObject(baseUrl, User.class);
        return user;
    }

}

负载均衡策略

  • 修改负载均衡规则配置
  • 在在itcast-service-consumer的application.yml中添加如下配置:
server:
  port: 80
spring:
  application:
    name: service-consumer
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:10086/eureka
service-provider:
  ribbon:
    NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule
  • 格式是:{服务名称}.ribbon.NFLoadBalancerRuleClassName,值就是IRule的实现类。

Hystrix

  • 英文豪猪
  • Hystix是Netflix开源的一个延迟和容错库,用于隔离访问远程服务、第三方库,防止出现级联失败。
  • 微服务中,服务间调用关系错综复杂,一个请求,可能需要调用多个微服务接口才能实现,会形成非常复杂的调用链路
  • Hystix解决雪崩问题的手段有两个:
    • 线程隔离
    • 服务熔断

线程隔离,服务降级

  • Hystrix为每个依赖服务调用分配一个小的线程池,如果线程池已满调用将被立即拒绝,默认不采用排队.加速失败判定时间。
  • 用户的请求将不再直接访问服务,而是通过线程池中的空闲线程来访问服务,如果线程池已满,或者请求超时,则会进行降级处理,返回提示信息
  • 服务降级:优先保证核心服务,而非核心服务不可用或弱可用。
  • 触发Hystix服务降级的情况:
    • 线程池已满
    • 请求超时

引入依赖

  • 首先在itcast-service-consumer的pom.xml中引入Hystrix依赖:

    org.springframework.cloud
    spring-cloud-starter-netflix-hystrix

开启熔断

@SpringCloudApplication
public class ItcastServiceConsumerApplication {

    @Bean
    @LoadBalanced
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }

    public static void main(String[] args) {
        SpringApplication.run(ItcastServiceConsumerApplication.class, args);
    }
}
  • @SpringCloudApplication=@SpringBootApplication+@EnableDiscoveryClient+@EnableCircuitBreaker(开启熔断)

编写降级逻辑

  • 我们改造itcast-service-consumer,当目标服务的调用出现故障,我们希望快速失败,给用户一个友好提示。因此需要提前编写好失败时的降级处理逻辑,要使用HystixCommond来完成:
@Controller
@RequestMapping("consumer/user")
//@DefaultProperties(defaultFallback = "fallBackMethod") // 指定一个类的全局熔断方法
public class UserController {

    @Autowired
    private RestTemplate restTemplate;

    @GetMapping
    @ResponseBody
    @HystrixCommand(fallbackMethod = "queryUserByIdFallBack")
    public String queryUserById(@RequestParam("id") Long id) {
        String user = this.restTemplate.getForObject("http://service-provider/user/" + id, String.class);
        return user;
    }

    public String queryUserByIdFallBack(Long id){
        return "请求繁忙,请稍后再试!";
    }
}
  • 我们可以把Fallback配置加在类上,实现默认fallback,不用任何参数,可以匹配多方法,但是返回值一定一致
  • 此时@HystrixCommand:在方法上直接使用该注解,使用默认的剪辑方法。不用写括号与其中的内容
  • 定义熔断方法:
    • 局部(要和被熔断的方法返回值和参数列表一致)
    • 全局(返回值类型要被熔断的方法一致,参数列表必须为空)

设置超时

  • consumer中的yml
  • hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds # 单位ms

服务熔断

  • 熔断器,也叫断路器,其英文单词为:Circuit Breaker
  • 熔断状态机3个状态:
    • Closed:关闭状态,所有请求都正常访问。
    • Open:打开状态,所有请求都会被降级。Hystix会对请求情况计数,当一定时间内失败请求百分比达到阈值,则触发熔断,断路器会完全打开。默认失败比例的阈值是50%,请求次数最少不低于20次。
    • Half Open:半开状态,open状态不是永久的,打开后会进入休眠时间(默认是5S)。随后断路器会自动进入半开状态。此时会释放部分请求通过,若这些请求都是健康的,则会完全关闭断路器,否则继续保持打开,再次进行休眠计时

实践内容

  • 我们在consumer的调用业务中加入一段逻辑:
@GetMapping("{id}")
@HystrixCommand
public String queryUserById(@PathVariable("id") Long id){
    if(id == 1){
        throw new RuntimeException("太忙了");
    }
    String user = this.restTemplate.getForObject("http://service-provider/user/" + id, String.class);
    return user;
}
  • 这样如果参数是id为1,一定失败,其它情况都成功。(不要忘了清空service-provider中的休眠逻辑)
    不过,默认的熔断触发要求较高,休眠时间窗较短,为了测试方便,我们可以通过配置修改熔断策略:
circuitBreaker.requestVolumeThreshold=10
circuitBreaker.sleepWindowInMilliseconds=10000
circuitBreaker.errorThresholdPercentage=50

解读:

  • requestVolumeThreshold:触发熔断的最小请求次数,默认20
  • errorThresholdPercentage:触发熔断的失败请求最小占比,默认50%
  • sleepWindowInMilliseconds:休眠时长,默认是5000毫秒

Feign

Feign可以把Rest的请求进行隐藏,伪装成类似SpringMVC的Controller一样。你不用再自己拼接url,拼接参数等等操作,一切都交给Feign去做。

快速入门

导入依赖


    org.springframework.cloud
    spring-cloud-starter-openfeign

开启Feign功能

启动类(consumer的)上添加注解开启Feign功能

@SpringCloudApplication
@EnableFeignClients // 开启feign客户端
public class ItcastServiceConsumerApplication {
    
    public static void main(String[] args) {
        SpringApplication.run(ItcastServiceConsumerApplication.class, args);
    }
}

删除RestTemplate:feign已经自动集成了Ribbon负载均衡的RestTemplate。所以,此处不需要再注册RestTemplate。

Feign的客户端

在itcast-service-consumer工程中,添加client/UserClient接口:

@FeignClient(value = "service-provider") // 标注该类是一个feign接口
public interface UserClient {

    @GetMapping("user/{id}")
    User queryById(@PathVariable("id") Long id);
}
  • 首先这是一个接口,Feign会通过动态代理,帮我们生成实现类。这点跟mybatis的mapper很像
  • @FeignClient,声明这是一个Feign客户端,类似@Mapper注解。同时通过value属性指定服务名称
  • 接口中的定义方法,完全采用SpringMVC的注解,Feign会根据注解帮我们生成URL,并访问获取结果
@Controller
@RequestMapping("consumer/user")
public class UserController {

    @Autowired
    private UserClient userClient;

    @GetMapping
    @ResponseBody
    public User queryUserById(@RequestParam("id") Long id){
        User user = this.userClient.queryUserById(id);
        return user;
    }

}

负载均衡

  • 默认置入
  • 因此我们不需要额外引入依赖,也不需要再注册RestTemplate对象。

Hystrix支持

  • 默认集成,但是默认关闭
  • 通过下面的参数来开启:(在itcast-service-consumer工程添加配置内容)
feign:
  hystrix:
    enabled: true # 开启Feign的熔断功能
  • 但是,Feign中的Fallback配置不像hystrix中那样简单了。

1)首先,我们要定义一个类UserClientFallback(在client文件夹中),实现刚才编写的UserClient,作为fallback的处理类

@Component
public class UserClientFallback implements UserClient {

    @Override
    public User queryById(Long id) {
        User user = new User();
        user.setUserName("服务器繁忙,请稍后再试!");
        return user;
    }
}

2)然后在UserFeignClient中,指定刚才编写的实现类

@FeignClient(value = "service-provider", fallback = UserClientFallback.class) // 标注该类是一个feign接口
public interface UserClient {

    @GetMapping("user/{id}")
    User queryUserById(@PathVariable("id") Long id);
}

请求压缩

日志级别

ZUUl网关

我们使用Spring Cloud Netflix中的Eureka实现了服务注册中心以及服务注册与发现;而服务间通过Ribbon或Feign实现服务的消费以及均衡负载。为了使得服务集群更为健壮,使用Hystrix的融断机制来避免在微服务架构中个别服务出现异常时引起的故障蔓延。

在该架构中,我们的服务集群包含:内部服务Service A和Service B,他们都会注册与订阅服务至Eureka Server,而Open Service是一个对外的服务,通过均衡负载公开至服务调用方。我们把焦点聚集在对外服务这块,直接暴露我们的服务地址,这样的实现是否合理,或者是否有更好的实现方式呢?

  • 架构的不足
    • 破坏了服务无状态特点
    • 无法直接复用既有接口
      为了解决上面这些问题,我们需要将权限控制这样的东西从我们的服务单元中抽离出去,而最适合这些逻辑的地方就是处于对外访问最前端的地方,我们需要一个更强大一些的均衡负载器的 服务网关。
      服务网关是微服务架构中一个不可或缺的部分。通过服务网关统一向外系统提供REST API的过程中,除了具备服务路由均衡负载功能之外,它还具备了权限控制等功能。
      乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第2张图片

快速入门

新建工程

  • New Moudle——Group:cn.itcast.zuul Artifact:itcast-zuul Package:cn.itcast.zuul
  • 添加Zuul依赖-Cloud Routing-√Zuul

编写配置

server:
  port: 10010 #服务端口
spring:
  application:
    name: api-gateway #指定服务名

编写引导类

通过@EnableZuulProxy注解开启Zuul的功能:

@SpringBootApplication
@EnableZuulProxy // 开启网关功能
public class ItcastZuulApplication {

    public static void main(String[] args) {
        SpringApplication.run(ItcastZuulApplication.class, args);
    }
}

编写路由规则

映射规则

server:
  port: 10010 #服务端口
spring:
  application:
    name: api-gateway #指定服务名
zuul:
  routes:
    service-provider: # 这里是路由id,随意写
      path: /service-provider/** # 这里是映射路径
      url: http://127.0.0.1:8081 # 映射路径对应的实际url地址

面向服务的路由

在刚才的路由规则中,我们把路径对应的服务地址写死了

添加Eureka客户端依赖

<dependency>
    <groupId>org.springframework.cloudgroupId>
    <artifactId>spring-cloud-starter-netflix-eureka-clientartifactId>
dependency>

添加Eureka配置,获取服务信息

eureka:
  client:
    registry-fetch-interval-seconds: 5 # 获取服务列表的周期:5s
    service-url:
      defaultZone: http://127.0.0.1:10086/eureka

开启Eureka客户端发现功能

@SpringBootApplication
@EnableZuulProxy // 开启Zuul的网关功能
@EnableDiscoveryClient
public class ZuulDemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(ZuulDemoApplication.class, args);
	}
}

修改映射配置,通过服务名称获取

因为已经有了Eureka客户端,我们可以从Eureka获取服务的地址信息,因此映射时无需指定IP地址,而是通过服务名称来访问,而且Zuul已经集成了Ribbon的负载均衡功能。

zuul:
  routes:
    service-provider: # 这里是路由id,随意写
      path: /service-provider/** # 这里是映射路径
      serviceId: service-provider # 指定服务名称

简化的路由配置(常用)

在刚才的配置中,我们的规则是这样的:

  • zuul.routes..path=/xxx/**: 来指定映射路径。是自定义的路由名
  • zuul.routes..serviceId=service-provider:来指定服务名。

而大多数情况下,我们的路由名称往往和服务名会写成一样的。因此Zuul就提供了一种简化的配置语法:zuul.routes.=

比方说上面我们关于service-provider的配置可以简化为一条:

zuul:
  routes:
    service-provider: /service-provider/** # 这里是映射路径

省去了对服务名称的配置。

默认的路由规则

在使用Zuul的过程中,上面讲述的规则已经大大的简化了配置项。但是当服务较多时,配置也是比较繁琐的。因此Zuul就指定了默认的路由规则:

  • 默认情况下,一切服务的映射路径就是服务名本身。例如服务名为:service-provider,则默认的映射路径就 是:/service-provider/**

也就是说,刚才的映射规则我们完全不配置也是OK的,不信就试试看。

路由前缀

配置示例:

zuul:
  routes:
    service-provider: /service-provider/**
    service-consumer: /service-consumer/**
  prefix: /api # 添加路由前缀

我们通过zuul.prefix=/api来指定了路由的前缀,这样在发起请求时,路径就要以/api开头。

过滤器

ZuulFilter

ZuulFilter是过滤器的顶级父类。在这里我们看一下其中定义的4个最重要的方法:

public abstract ZuulFilter implements IZuulFilter{

    abstract public String filterType();

    abstract public int filterOrder();
    
    boolean shouldFilter();// 来自IZuulFilter

    Object run() throws ZuulException;// IZuulFilter
}
  • shouldFilter:返回一个Boolean值,判断该过滤器是否需要执行。返回true执行,返回false不执行。
  • run:过滤器的具体业务逻辑。
  • filterType:返回字符串,代表过滤器的类型。包含以下4种:
    • pre:请求在被路由之前执行
    • route:在路由请求时调用
    • post:在route和errror过滤器之后调用
    • error:处理请求时发生错误调用
  • filterOrder:通过返回的int值来定义过滤器的执行顺序,数字越小优先级越高。

过滤器执行生命周期

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第3张图片

  • 正常:pre-route-post
  • 异常
    • pre或route出现异常,直接进入error处理完毕后给post
    • error异常,最终也会进入post
    • post异常,转到error。不会再到post

使用场景

  • 请求鉴权:一般放在pre类型,如果发现没有访问权限,直接就拦截了
  • 异常处理:一般会在error类型和post类型过滤器中结合来处理。
  • 服务调用时长统计:pre和post结合使用。

自定义过滤器

  • 定义过滤器类 创建在zuul/filter下
@Component
public class LoginFilter extends ZuulFilter {
    /**
     * 过滤器类型,前置过滤器
     * @return
     */
    @Override
    public String filterType() {
        return "pre";
    }

    /**
     * 过滤器的执行顺序
     * @return
     */
    @Override
    public int filterOrder() {
        return 1;
    }

    /**
     * 该过滤器是否生效
     * @return
     */
    @Override
    public boolean shouldFilter() {
        return true;
    }

    /**
     * 登陆校验逻辑
     * @return
     * @throws ZuulException
     */
    @Override
    public Object run() throws ZuulException {
        // 获取zuul提供的上下文对象
        RequestContext context = RequestContext.getCurrentContext();
        // 从上下文对象中获取请求对象
        HttpServletRequest request = context.getRequest();
        // 获取token信息
        String token = request.getParameter("access-token");
        // 判断
        if (StringUtils.isBlank(token)) {
            // 过滤该请求,不对其进行路由
            context.setSendZuulResponse(false);
            // 设置响应状态码,401
            context.setResponseStatusCode(HttpStatus.SC_UNAUTHORIZED);
            // 设置响应信息
            context.setResponseBody("{\"status\":\"401\", \"text\":\"request error!\"}");
        }
        // 校验通过,把登陆信息放入上下文信息,继续向后执行
        context.set("token", token);
        return null;
    }
}

负载均衡和熔断

Zuul中默认就已经集成了Ribbon负载均衡和Hystix熔断机制。但是所有的超时策略都是走的默认值,比如熔断超时时间只有1S,很容易就触发了。因此建议我们手动进行配置:

hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 2000 # 设置hystrix的超时时间为6000ms

项目搭建

项目分类

传统项目

各种企业里面用的管理系统(ERP、HR、OA、CRM、物流管理系统。。。。。。。)

  • 需求方:公司、企业内部
  • 盈利模式:项目本身卖钱
  • 技术侧重点:业务功能

互联网项目

门户网站、电商网站:baidu.com、qq.com、taobao.com、jd.com …

  • 需求方:广大用户群体
  • 盈利模式:虚拟币、增值服务、广告收益…
  • 技术侧重点:网站性能、业务功能

而我们今天要聊的就是互联网项目中的重要角色:电商

  • 技术特点:
    • 技术范围广
    • 技术新
    • 高并发(分布式、静态化技术、缓存技术、异步并发、池化、队列)
    • 高可用(集群、负载均衡、限流、降级、熔断)
    • 数据量大、业务复杂、数据安全
  • 常见电商模式
    • B2C:商家对个人,如:亚马逊、当当等
    • C2C平台:个人对个人,如:闲鱼、拍拍网、ebay
    • B2B平台:商家对商家,如:阿里巴巴、八方资源网等
    • O2O:线上和线下结合,如:饿了么、电影票、团购等
    • P2P:在线金融,贷款,如:网贷之家、人人聚财等。
    • B2C平台:天猫、京东、一号店等
  • 一些专业术语
    • SaaS:软件即服务

    • SOA:面向服务

    • RPC:远程过程调用

    • RMI:远程方法调用

    • PV:(page view),即页面浏览量;用户每1次对网站中的每个网页访问均被记录1次。用户对同一页面的多次访问,访问量累计

    • UV:(unique visitor),独立访客。指访问某个站点或点击某条新闻的不同IP地址的人数。在同一天内,uv只记录第一次进入网站的具有独立IP的访问者,在同一天内再次访问该网站则不计数。

    • PV与带宽:
      - 计算带宽大小需要关注两个指标:峰值流量和页面的平均大小。
      - 计算公式是:网站带宽= ( PV * 平均页面大小(单位MB)* 8 )/统计时间(换算到秒)
      - 为什么要乘以8?
      - 网站大小为单位是字节(Byte),而计算带宽的单位是bit,1Byte=8bit
      - 这个计算的是平均带宽,高峰期还需要扩大一定倍数

    • PV、QPS、并发

      • QPS:每秒处理的请求数量。
        • 比如你的程序处理一个请求平均需要0.1S,那么1秒就可以处理10个请求。QPS自然就是10,多线程情况下,这个数字可能就会有所增加。
      • 由PV和QPS如何需要部署的服务器数量?
        • 根据二八原则,80%的请求集中在20%的时间来计算峰值压力:
        • (每日PV * 80%) / (3600s * 24 * 20%) * 每个页面的请求数 = 每个页面每秒的请求数量
        • 然后除以服务器的QPS值,即可计算得出需要部署的服务器数量
  • 项目开发流程
    • 项目经理:管人
    • 技术经理:
    • 产品经理:设计需求原型
    • 测试:
    • 前端:大前端:UI 前端页面。mongodb\nodejs\reactjs\vuejs
    • 后端:
    • 移动端:
      乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第4张图片

乐优商城介绍

项目介绍

  • 乐优商城是一个全品类的电商购物网站(B2C)。
  • 用户可以在线购买商品、加入购物车、下单
  • 可以评论已购买商品
  • 管理员可以在后台管理商品的上下架、促销活动
  • 管理员可以监控商品销售状况
  • 客服可以在后台处理退款操作
  • 希望未来3到5年可以支持千万用户的使用

系统架构

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第5张图片

  • 后台管理:

    • 后台系统主要包含以下功能:
      • 商品管理,包括商品分类、品牌、商品规格等信息的管理
      • 销售管理,包括订单统计、订单退款处理、促销活动生成等
      • 用户管理,包括用户控制、冻结、解锁等
      • 权限管理,整个网站的权限控制,采用JWT鉴权方案,对用户及API进行权限控制
      • 统计,各种数据的统计分析展示
    • 后台系统会采用前后端分离开发,而且整个后台管理系统会使用Vue.js框架搭建出单页应用(SPA)。
  • 前台门户

    • 前台门户面向的是客户,包含与客户交互的一切功能。例如:
      • 搜索商品
      • 加入购物车
      • 下单
      • 评价商品等等
    • 前台系统我们会使用Thymeleaf模板引擎技术来完成页面开发。出于SEO优化的考虑,我们将不采用单页应用。

无论是前台还是后台系统,都共享相同的微服务集群,包括:

  • 商品微服务:商品及商品分类、品牌、库存等的服务
  • 搜索微服务:实现搜索功能
  • 订单微服务:实现订单相关
  • 购物车微服务:实现购物车相关功能
  • 用户中心:用户的登录注册等功能
  • Eureka注册中心
  • Zuul网关服务

项目搭建

技术选型

前端技术:

  • 基础的HTML、CSS、JavaScript(基于ES6标准)
  • JQuery
  • Vue.js 2.0以及基于Vue的框架:Vuetify(UI框架)
  • 前端构建工具:WebPack
  • 前端安装包工具:NPM
  • Vue脚手架:Vue-cli
  • Vue路由:vue-router
  • ajax框架:axios
  • 基于Vue的富文本框架:quill-editor

后端技术:

  • 基础的SpringMVC、Spring 5.x和MyBatis3
  • Spring Boot 2.0.7版本
  • Spring Cloud 最新版 Finchley.SR2
  • Redis-4.0
  • RabbitMQ-3.4
  • Elasticsearch-6.3
  • nginx-1.14.2
  • FastDFS - 5.0.8
  • MyCat
  • Thymeleaf
  • mysql 5.6

开发环境

  • IDE:我们使用Idea 2017.3 版本
  • JDK:统一使用JDK1.8
  • 项目构建:maven3.3.9以上版本即可(3.5.2)
  • 版本控制工具:git

域名

我们在开发的过程中,为了保证以后的生产、测试环境统一。尽量都采用域名来访问项目。

一级域名:www.leyou.com,leyou.com leyou.cn

二级域名:manage.leyou.com/item , api.leyou.com

我们可以通过switchhost工具来修改自己的host对应的地址,只要把这些域名指向127.0.0.1,那么跟你用localhost的效果是完全一样的。

创建父工程

  • new project-maven Group:com.leyou.parent Artifact:leyou Version:1.0.0-SNAPSHOT
  • projectname leyou
  • 引入依赖
<?xml version="1.0" encoding="UTF-8"?>
://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">
	>4.0.0>

	>com.leyou.parent>
	>leyou>
	>1.0.0-SNAPSHOT>
	>pom>

	>leyou>
	>Demo project for Spring Boot>

	>
		>org.springframework.boot>
		>spring-boot-starter-parent>
		>2.0.7.RELEASE>
		> <!-- lookup parent from repository -->
	

	>
		>UTF-8>
		>UTF-8>
		>1.8>
		-cloud.version>Finchley.SR2-cloud.version>
		>1.3.2>
		>2.0.2>
		>1.1.9>
		>5.1.32>
		>1.2.3>
		>1.0.0-SNAPSHOT>
		>1.26.1-RELEASE>
	>

	>
		>
			<!-- springCloud -->
			
				org.springframework.cloud
				spring-cloud-dependencies
				${spring-cloud.version}
				pom
				import
			
			
			
				org.mybatis.spring.boot
				mybatis-spring-boot-starter
				${mybatis.starter.version}
			
			
			
				tk.mybatis
				mapper-spring-boot-starter
				${mapper.starter.version}
			
			
			
				com.github.pagehelper
				pagehelper-spring-boot-starter
				${pageHelper.starter.version}
			
			
			
				mysql
				mysql-connector-java
				${mysql.version}
			
			
			
				com.github.tobato
				fastdfs-client
				${fastDFS.client.version}
			
          >
	>

	>
		>
			>
				>org.springframework.boot>
				>spring-boot-maven-plugin>
			>
		>
	>
>

创建EurekaServer

创建工程

  • new Module-maven
  • Group:com.leyou.registery Artifact:leyou-registery Version:1.0.0-SNAPSHOT
  • next Module name:leyou-registery content-root: leyou\leyou-registery

添加依赖

<?xml version="1.0" encoding="UTF-8"?>
://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">
    >
        >leyou>
        >com.leyou.parent>
        >1.0.0-SNAPSHOT>
    >
    >4.0.0>

    >com.leyou.common>
    >leyou-registry>
    >1.0.0-SNAPSHOT>

    >
        >
            >org.springframework.cloud>
            >spring-cloud-starter-netflix-eureka-server>
        >

    >
>

注意:今天在运行程序的时候,一直报“java.lang.TypeNotPresentException: Type javax.xml.bind.JAXBContext not present”的错误,
百度原因,发现是因为用了jdk12的缘故。因为JAXB-API是java ee的一部分,在jdk12中没有在默认的类路径中。从jdk9开始java引入了模块的概念, 可以使用模块命令–add-modles java.xml.bind引入jaxb-api。也可以选择另一种解决方法,在maven里面加入下面依赖,可以解决这个问题:

 
        javax.xml.bind
        jaxb-api
        2.3.0
    
    
        com.sun.xml.bind
        jaxb-impl
        2.3.0
    
    
        org.glassfish.jaxb
        jaxb-runtime
        2.3.0
    
    
        javax.activation
        activation
        1.1.1
    

编写启动类

@SpringBootApplication
@EnableEurekaServer
public class LeyouRegistryApplication {

    public static void main(String[] args) {
        SpringApplication.run(LeyouRegistryApplication.class, args);
    }
}

配置文件

server:
  port: 10086
spring:
  application:
    name: leyou-registry
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:${server.port}/eureka
    register-with-eureka: false # 把自己注册到eureka服务列表
    fetch-registry: false # 拉取eureka服务信息
  server:
    enable-self-preservation: false # 关闭自我保护
    eviction-interval-timer-in-ms: 5000 # 每隔5秒钟,进行一次服务列表的清理

创建Zuul网关

创建工程

  • new Module-maven
    Group:com.leyou.gateway Artifact:leyou-gateway Version:1.0.0-SNAPSHOT
    Module name:leyou-gateway

添加依赖



    
        leyou
        com.leyou.parent
        1.0.0-SNAPSHOT
    
    4.0.0

    com.leyou.common
    leyou-gateway
    1.0.0-SNAPSHOT

    
        
            org.springframework.cloud
            spring-cloud-starter-netflix-zuul
        
        
            org.springframework.cloud
            spring-cloud-starter-netflix-eureka-client
        
        
        
            org.springframework.boot
            spring-boot-starter-actuator
        
    

编写启动类

@SpringBootApplication
@EnableDiscoveryClient
@EnableZuulProxy
public class LeyouGatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(LeyouGatewayApplication.class, args);
    }
}

配置文件

server:
  port: 10010
spring:
  application:
    name: leyou-gateway
eureka:
  client:
    registry-fetch-interval-seconds: 5
    service-url:
      defaultZone: http://127.0.0.1:10086/eureka
zuul:
  prefix: /api # 路由路径前缀

创建商品微服务

包含对商品相关的一系列内容管理

  • 商品分类管理
  • 品牌管理
  • 商品规格参数管理
  • 商品管理
  • 库存管理

微服务的结构

为了方便微服务之间相互调用,使用聚合工程将要提供的接口及相关实体类放到独立子工程中
在leyou-item中创建两个子工程:

  • leyou-item-interface:主要是对外暴露的接口及相关实体类
  • leyou-item-service:所有业务逻辑及内部使用接口
    乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第6张图片

构建

  • new Module-maven
    Group:com.leyou.item Artifact:leyou-item Version:1.0.0-SNAPSHOT
    Module name:leyou-item
    依赖
    因为是聚合工程,所以把项目打包方式设置为pom


    
        leyou
        com.leyou.parent
        1.0.0-SNAPSHOT
    
    4.0.0

    com.leyou.item
    leyou-item
    1.0.0-SNAPSHOT
    
    pom


leyou-item-interface
在leyou-item工程上点击右键,选择new --> module:
依然是使用maven构建,注意父工程是leyou-item:
Group:com.leyou.item Artifact:leyou-item-interface Version:1.0.0-SNAPSHOT
Module name:leyou-item-interface
注意:目录结构,保存到leyou-item下的leyou-item-interface目录中:
leyou-item-service
在leyou-item工程上点击右键,选择new --> module:
依然是使用maven构建,注意父工程是leyou-item:
Group:com.leyou.item Artifact:leyou-item-service Version:1.0.0-SNAPSHOT
Module name:leyou-item-service
注意:目录结构,保存到leyou-item下的leyou-item-service目录中:

添加依赖

  • 考虑需要什么
    • Eureka客户端
    • web启动器
    • mybatis启动器
    • 通用mapper启动器
    • 分页助手启动器
    • 连接池,我们用默认的Hykira
    • mysql驱动
    • 千万不能忘了,我们自己也需要ly-item-interface中的实体类

这些依赖,我们在顶级父工程:leyou中已经添加好了。所以直接引入即可:


<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-itemartifactId>
        <groupId>com.leyou.itemgroupId>
        <version>1.0.0-SNAPSHOTversion>
    parent>
    <modelVersion>4.0.0modelVersion>

    <groupId>com.leyou.itemgroupId>
    <artifactId>leyou-item-serviceartifactId>
    <version>1.0.0-SNAPSHOTversion>

    <dependencies>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>
        
        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-netflix-eureka-clientartifactId>
        dependency>
        
        <dependency>
            <groupId>org.mybatis.spring.bootgroupId>
            <artifactId>mybatis-spring-boot-starterartifactId>
        dependency>
        
        <dependency>
            <groupId>tk.mybatisgroupId>
            <artifactId>mapper-spring-boot-starterartifactId>
        dependency>
        
        <dependency>
            <groupId>com.github.pagehelpergroupId>
            <artifactId>pagehelper-spring-boot-starterartifactId>
        dependency>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-jdbcartifactId>
        dependency>
        
        <dependency>
            <groupId>mysqlgroupId>
            <artifactId>mysql-connector-javaartifactId>
        dependency>
        <dependency>
            <groupId>com.leyou.itemgroupId>
            <artifactId>leyou-item-interfaceartifactId>
            <version>1.0.0-SNAPSHOTversion>
        dependency>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-actuatorartifactId>
        dependency>
    dependencies>

project>

leyou-item-interface中需要什么我们暂时不清楚,所以先不管。以后需要什么依赖,再引入。

编写启动和配置

在整个leyou-item工程中,只有leyou-item-service是需要启动的。因此在其中编写启动类即可:

@SpringBootApplication
@EnableDiscoveryClient
public class LeyouItemServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(LeyouItemServiceApplication.class, args);
    }
}

然后是全局属性文件:

server:
  port: 8081
spring:
  application:
    name: item-service
  datasource:
    url: jdbc:mysql://localhost:3306/leyou
    username: root
    password: root
    hikari:
      max-lifetime: 28830000 # 一个连接的生命时长(毫秒),超时而且没被使用则被释放(retired),缺省:30分钟,建议设置比数据库超时时长少30秒,参考MySQL wait_timeout参数(show variables like '%timeout%';)
      maximum-pool-size: 9 # 连接池中允许的最大连接数。缺省值:10;推荐的公式:((core_count * 2) + effective_spindle_count)
eureka:
  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秒不发送就过期

添加商品微服务的路由规则

既然商品微服务已经创建,接下来肯定要添加路由规则到Zuul中,我们不使用默认的路由规则。

修改leyou-gateway工程的application.yml配置文件:

zuul:
  prefix: /api # 路由路径前缀
  routes:
    item-service: /item/** # 商品微服务的映射路径

测试路由规则为了测试路由规则是否畅通,我们是不是需要在item-service中编写一个controller接口呢?

其实不需要,SpringBoot提供了一个依赖:actuator

只要我们添加了actuator的依赖,它就会为我们生成一系列的访问接口:

  • /info
  • /health
  • /refresh

添加依赖:

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-actuatorartifactId>
dependency>

重启后访问Eureka控制台:

添加通用工具模块

右键leyou工程,使用maven来构建module:
Group:com.leyou.common Artifact:leyou-common Version:1.0.0-SNAPSHOT
Module name:leyou-common

结构
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第7张图片

ES6语法指南

就是ECMAScript第6版标准。
新建空工程 demo-es6
新建模块、Static Web -StaticWeb

let 和 const 命令

var 定义的变量可能变为循环变量
let定义的变量只在代码块内有效
const声明的变量是常量,不能被修改

字符串扩展

新API

  • includes():返回布尔值,表示是否找到了参数字符串。
  • startsWith():返回布尔值,表示参数字符串是否在原字符串的头部。
  • endsWith():返回布尔值,表示参数字符串是否在原字符串的尾部。

字符串模板:在两个`之间的部分都会被作为字符串的值,不管你任意换行,甚至加入js脚本

解构表达式

数组解构
比如对一个数组 原本智能通过角标访问
现在可以直接赋值
let arr = [1,2,3]
const [x,y,z] = arr;// x,y,z将与arr中的每个位置对应来取值
// 然后打印
console.log(x,y,z); //得1 2 3

对象解构
const person = {
name:“jack”,
age:21,
language: [‘java’,‘js’,‘css’]
}
const {name,age,language} = person; 各自完成赋值
如果要用其他变量接收,需要额外指定别名
const {name:n} =person
{name:n}:name是person中的属性名,冒号后面的n是解构后要赋值给的变量。

函数优化

参数默认值

在ES6以前,我们无法给一个函数参数设置默认值,只能采用变通写法:

    function add(a , b) {
        // 判断b是否为空,为空就给默认值1
        b = b || 1;
        return a + b;
    }
    // 传一个参数
    console.log(add(10));
    //now
    function add(a , b = 1) {
    return a + b;
}
// 传一个参数
console.log(add(10));
    

箭头参数
一个参数时:

var print = function (obj) {
    console.log(obj);
}
// 简写为:
var print2 = obj => console.log(obj);

多个参数:

// 两个参数的情况:
var sum = function (a , b) {
    return a + b;
}
// 简写为:
var sum2 = (a,b) => a+b;

代码不止一行,可以用{}括起来

var sum3 = (a,b) => {
    return a + b;
}

对象的函数属性简写

比如一个Person对象,里面有eat方法:

let person = {
    name: "jack",
    // 以前:
    eat: function (food) {
        console.log(this.name + "在吃" + food);
    },
    // 箭头函数版:
    eat2: food => console.log(person.name + "在吃" + food),// 这里拿不到this
    // 简写版:
    eat3(food){
        console.log(this.name + "在吃" + food);
    }
}

箭头函数结合解构表达式

比如有一个函数:

const person = {
    name:"jack",
    age:21,
    language: ['java','js','css']
}

function hello(person) {
    console.log("hello," + person.name)
}

如果用箭头函数和解构表达式

var hi = ({name}) =>  console.log("hello," + name);

map和reduce

map

map():接收一个函数,将原数组中的所有元素用这个函数处理后放入新数组返回。

举例:有一个字符串数组,我们希望转为int数组

let arr = ['1','20','-5','3'];
console.log(arr)

arr = arr.map(s => parseInt(s));
console.log(arr)

reduce

reduce():接收一个函数(必须)和一个初始值(可选)。

第一个参数(函数)接收两个参数:

  • 第一个参数是上一次reduce处理的结果
  • 第二个参数是数组中要处理的下一个元素

reduce()会从左到右依次把数组中的元素用reduce处理,并把处理的结果作为下次reduce的第一个参数。如果是第一次,会把前两个元素作为计算参数,或者把用户指定的初始值作为起始参数

举例:

const arr = [1,20,-5,3]
没有初始值:
arr.reduce((a,b)=> a+b)   //19
指定初始值:
arr.reduce((a,b)=> a*b,1)   //-300

对象扩展

ES6给Object拓展了许多新的方法,如:

  • keys(obj):获取对象的所有key形成的数组
  • values(obj):获取对象的所有value形成的数组
  • entries(obj):获取对象的所有key和value形成的二维数组。格式:[[k1,v1],[k2,v2],...]
  • assign(dest, …src) :将多个src对象的值 拷贝到 dest中(浅拷贝)。

数组扩展

  • find(callback):数组实例的find方法,用于找出第一个符合条件的数组成员。它的参数是一个回调函数,所有数组成员依次执行该回调函数,直到找出第一个返回值为true的成员,然后返回该成员。如果没有符合条件的成员,则返回undefined。
  • findIndex(callback):数组实例的findIndex方法的用法与find方法非常类似,返回第一个符合条件的数组成员的位置,如果所有成员都不符合条件,则返回-1。
  • includes(数组元素):与find类似,如果匹配到元素,则返回true,代表找到了。

Vue入门

MVVM

基于事件循环的异步IO框架:Node.js
在node的基础上
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第8张图片

都是MVC的分支

  • M:Model,模型,包括数据和一些基础操作
  • V:即View,视图、页面渲染结果
  • VM:View-Model,模型与视图间的双向操作(无需开发人员干涉)
    在MVVM之前,开发人员从后端获取需要的数据模型,然后要通过DOM操作Model渲染到View中。而后当用户操作视图,我们还需要通过DOM获取View中的数据,然后同步到Model中。
    而MVVM中的VM要做的事情就是把DOM操作完全封装起来,开发人员不用再关心Model和View之间是如何互相影响的:
  • 只要我们Model发生了改变,View上自然就会表现出来。
  • 当用户修改了View,Model中的数据也会跟着改变。

认识Vue

Vue (读音 /vjuː/,类似于 view) 用于构建用户界面的渐进式框架

Node和NPM

NPM是Node提供的模块管理工具,可以非常方便的下载安装很多前端框架,包括Jquery、AngularJS、VueJs都有。为了后面学习方便,我们先安装node及NPM工具。

下载Node.js

https://nodejs.org/en/
查看node版本信息:控制台输入node-v
node自带NPM //dos 用命令 npm-v 可查看
npm默认的仓库地址是在国外网站,速度较慢,建议大家设置到淘宝镜像。但是切换镜像是比较麻烦的。推荐一款切换镜像的工具:nrm

  • dos中输入 npm install nrm -g //-g 代表全局安装
  • 通过nrm ls命令查看npm的仓库列表,带*的就是当前选中的镜像仓库:
  • 通过nrm use taobao来指定要使用的镜像源:
  • 然后通过nrm test npm来测试速度:

入门案例

new module static web
执行命令方式

  • 可进入目录下 用窗口执行命令 hm49\code\demo-es6\hello-vue 打开cmd窗口
  • 工具执行命令:Terminal cd hello-vue
  • package.json 其中内容相当于依赖
  • npm install vue -save //本地安装

Vue声明式渲染

<body>

    <div id="app">
     
        <h2>{{name}},非常帅!!!h2>
    div>
body>
<script src="node_modules/vue/dist/vue.js" >script>    
<script>
    // 创建vue实例
    var app = new Vue({
        el:"#app", // el即element,该vue实例要渲染的页面元素
        data:{ // 渲染页面需要的数据
            name: "峰哥"
        }
    });

script>
  • 首先通过 new Vue()来创建Vue实例
  • 然后构造函数接收一个对象,对象中有一些属性:
    • el:是element的缩写,通过id选中要渲染的页面元素,本例中是一个div
    • data:数据,数据是一个对象,里面有很多属性,都可以渲染到视图中
      • name:这里我们指定了一个name属性
  • 页面中的h2元素中,我们通过{{name}}的方式,来渲染刚刚定义的name属性。

双向绑定

我们对刚才的案例进行简单修改:

<body>
    <div id="app">
        <input type="text" v-model="num">
        <h2>
            {{name}},非常帅!!!有{{num}}位女神为他着迷。
        h2>
    div>
body>
<script src="node_modules/vue/dist/vue.js" >script>
<script>
    // 创建vue实例
    var app = new Vue({
        el: "#app", // el即element,该vue实例要渲染的页面元素
        data: { // 渲染页面需要的数据
            name: "峰哥",
            num: 5
        },
        methods:{
	        incr(){
	        	this.num++;
	        }
        }
    });

script>
  • 我们在data添加了新的属性:num
  • 在页面中有一个input元素,通过v-modelnum进行绑定。
  • 同时通过{{num}}在页面输出

我们可以观察到,输入框的变化引起了data中的num的变化,同时页面输出也跟着变化。

  • input与num绑定,input的value值变化,影响到了data中的num值
  • 页面{{num}}与数据num绑定,因此num值变化,引起了页面效果变化。

没有任何dom操作,这就是双向绑定的魅力。

事件处理

我们在页面添加一个按钮:

<button v-on:click="num++">点我button>
  • 这里用v-on指令绑定点击事件,而不是普通的onclick,然后直接操作num
  • 普通click是无法直接操作num的。
  • num也可以是一个方法名

Vue实例

创建Vue实例

每个 Vue 应用都是通过用 Vue 函数创建一个新的 Vue 实例开始的:

var vm = new Vue({
  // 选项
})

在构造函数中传入一个对象,并且在对象中声明各种Vue需要的数据和方法,包括:

  • el
  • data
  • methods

等等

模板元素

每个Vue实例都需要关联一段Html模板,Vue会基于此模板进行视图渲染。
我们可以通过el属性来指定。

数据

当Vue实例被创建时,它会尝试获取在data中定义的所有属性,用于视图的渲染,并且监视data中的属性变化,当data发生改变,所有相关的视图都将重新渲染,这就是“响应式“系统。

  • name的变化会影响到input的值
  • input中输入的值,也会导致vm中的name发生改变

方法

Vue实例中除了可以定义data属性,也可以定义方法,并且在Vue实例的作用范围内使用。

生命周期钩子

生命周期

每个 Vue 实例在被创建时都要经过一系列的初始化过程 :创建实例,装载模板,渲染模板等等。Vue为生命周期中的每个状态都设置了钩子函数(监听函数)。每当Vue实例处于不同的生命周期时,对应的函数就会被触发调用。
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第9张图片

钩子函数

beforeCreated:我们在用Vue时都要进行实例化,因此,该函数就是在Vue实例化时调用,也可以将他理解为初始化函数比较方便一点,在Vue1.0时,这个函数的名字就是init。

created(常用):在创建实例之后进行调用。

beforeMount:页面加载完成,没有渲染。如:此时页面还是{{name}}

mounted:我们可以将他理解为原生js中的window.οnlοad=function({.,.}),或许大家也在用jquery,所以也可以理解为jquery中的$(document).ready(function(){….}),他的功能就是:在dom文档渲染完毕之后将要执行的函数,该函数在Vue1.0版本中名字为compiled。 此时页面中的{{name}}已被渲染成峰哥

beforeDestroy:该函数将在销毁实例前进行调用 。

destroyed:改函数将在销毁实例时进行调用。

beforeUpdate:组件更新之前。

updated:组件更新之后。

指令

指令 (Directives) 是带有 v- 前缀的特殊特性。指令特性的预期值是:单个 JavaScript 表达式。指令的职责是,当表达式的值改变时,将其产生的连带影响,响应式地作用于 DOM。

插值表达式:声明式渲染

花括号

格式:

{{表达式}}

说明:

  • 该表达式支持JS语法,可以调用js内置函数(必须有返回值)
  • 表达式必须有返回结果。例如 1 + 1,没有结果的表达式不允许使用,如:var a = 1 + 1;
  • 可以直接获取Vue实例中定义的数据或函数

插值闪烁

花括号缺陷:使用{{}}方式在网速较慢时会出现问题。在数据未加载完成时,页面会显示出原始的{{}},加载完毕后才显示正确数据,我们称为插值闪烁。

v-text和v-html

使用v-text和v-html指令来替代{{}}

说明:

  • v-text:将数据输出到元素内部,如果输出的数据有HTML代码,会作为普通文本输出
  • v-html:将数据输出到元素内部,如果输出的数据有HTML代码,会被渲染

并且不会出现插值闪烁,当没有数据时,会显示空白。

<div id="app">
    v-text:<span v-text="hello">span> <br/>
    v-html:<span v-html="hello">span>
div>

JS:

var vm = new Vue({
    el:"#app",
    data:{
        hello: "

大家好,我是峰哥

"
} })

v-model:双向绑定

刚才的v-text和v-html可以看做是单向绑定,数据影响了视图渲染,但是反过来就不行。接下来学习的v-model是双向绑定,视图(View)和模型(Model)之间会互相影响。
既然是双向绑定,一定是在视图中可以修改数据,这样就限定了视图的元素类型。目前v-model的可使用元素有:

  • input
  • select
  • textarea
  • checkbox
  • radio
  • components(Vue中的自定义组件)

基本上除了最后一项,其它都是表单的输入项。

举例:

<div id="app">
    <input type="checkbox" v-model="language" value="Java" />Java<br/>
    <input type="checkbox" v-model="language" value="PHP" />PHP<br/>
    <input type="checkbox" v-model="language" value="Swift" />Swift<br/>
    <h1>
        你选择了:{{language.join(',')}}
    h1>
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    var vm = new Vue({
        el:"#app",
        data:{
            language: []
        }
    })
script>
  • 多个CheckBox对应一个model时,model的类型是一个数组,单个checkbox值默认是boolean类型
  • radio对应的值是input的value值
  • texttextarea 默认对应的model是字符串
  • select单选对应字符串,多选对应也是数组

v-on:事件

基本用法

v-on指令用于给页面元素绑定事件。

语法:

v-on:事件名="js片段或函数名"

示例:

<div id="app">
    
    <button v-on:click="num++">增加一个button><br/>
    
    <button v-on:click="decrement">减少一个button><br/>
    <h1>有{{num}}个女神迷恋峰哥h1>
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    var app = new Vue({
        el:"#app",
        data:{
            num:100
        },
        methods:{
            decrement(){
                this.num--;
            }
        }
    })
script>

另外,事件绑定可以简写,例如v-on:click='add'可以简写为@click='add'

事件修饰符

在事件处理程序中调用 event.preventDefault()event.stopPropagation() 是非常常见的需求。尽管我们可以在方法中轻松实现这点,但更好的方式是:方法只有纯粹的数据逻辑,而不是去处理 DOM 事件细节。

为了解决这个问题,Vue.js 为 v-on 提供了事件修饰符。修饰符是由点开头的指令后缀来表示的。

  • .stop :阻止事件冒泡到父元素
  • .prevent:阻止默认事件发生*
  • .capture:使用事件捕获模式
  • .self:只有元素自身触发事件才执行。(冒泡或捕获的都不执行)
  • .once:只执行一次

阻止默认事件

<div id="app">
    
    <button v-on:contextmenu.prevent="num++">增加一个button>
    <br/>
    
    <button v-on:contextmenu="decrement($event)">减少一个button>
    <br/>
    <h1>有{{num}}个女神迷恋峰哥h1>
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    var app = new Vue({
        el: "#app",
        data: {
            num: 100
        },
        methods: {
            decrement(ev) {
                // ev.preventDefault();
                this.num--;
            }
        }
    })
script>

效果:(右键“增加一个”,不会触发默认的浏览器右击事件;右键“减少一个”,会触发默认的浏览器右击事件)

按键修饰符

在监听键盘事件时,我们经常需要检查常见的键值。Vue 允许为 v-on 在监听键盘事件时添加按键修饰符:


<input v-on:keyup.13="submit">

记住所有的 keyCode 比较困难,所以 Vue 为最常用的按键提供了别名:


<input v-on:keyup.enter="submit">


<input @keyup.enter="submit">

全部的按键别名:

  • .enter*
  • .tab
  • .delete (捕获“删除”和“退格”键)
  • .esc
  • .space
  • .up
  • .down
  • .left
  • .right

组合按钮

可以用如下修饰符来实现仅在按下相应按键时才触发鼠标或键盘事件的监听器。

  • .ctrl
  • .alt
  • .shift

例如:


<input @keyup.alt.67="clear">


<div @click.ctrl="doSomething">Do somethingdiv>

v-for:遍历

遍历数组

语法:

v-for="item in items"
  • items:要遍历的数组,需要在vue的data中定义好。
  • item:迭代得到的数组元素的别名

示例

<div id="app">
    <ul>
        <li v-for="user in users">
            {{user.name}} - {{user.gender}} - {{user.age}}
        li>
    ul>
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    var app = new Vue({
        el: "#app",
        data: {
            users:[
                {name:'柳岩', gender:'女', age: 21},
                {name:'峰哥', gender:'男', age: 18},
                {name:'范冰冰', gender:'女', age: 24},
                {name:'刘亦菲', gender:'女', age: 18},
                {name:'古力娜扎', gender:'女', age: 25}
            ]
        },
    })
script>

数组角标

在遍历的过程中,如果我们需要知道数组角标,可以指定第二个参数:
语法

v-for="(item,index) in items"
  • items:要迭代的数组
  • item:迭代得到的数组元素别名
  • index:迭代到的当前元素索引,从0开始。

示例

    <ul>
        <li v-for="(user, index) in users">
            {{index + 1}}. {{user.name}} - {{user.gender}} - {{user.age}}
        li>
    ul>

遍历对象

v-for除了可以迭代数组,也可以迭代对象。语法基本类似

语法:

v-for="value in object"
v-for="(value,key) in object"
v-for="(value,key,index) in object"
  • 1个参数时,得到的是对象的属性值
  • 2个参数时,第一个是属性值,第二个是属性名
  • 3个参数时,第三个是索引,从0开始

示例:

<div id="app">
    <ul>
        <li v-for="(value, key, index) in user">
            {{index + 1}}. {{key}} - {{value}}
        li>
    ul>
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    var vm = new Vue({
        el:"#app",
        data:{
            user:{name:'峰哥', gender:'男', age: 18}
        }
    })
script>

key

当 Vue.js 用 v-for 正在更新已渲染过的元素列表时,它默认用“就地复用”策略。如果数据项的顺序被改变,Vue 将不会移动 DOM 元素来匹配数据项的顺序, 而是简单复用此处每个元素,并且确保它在特定索引下显示已被渲染过的每个元素。

这个功能可以有效的提高渲染的效率。

但是要实现这个功能,你需要给Vue一些提示,以便它能跟踪每个节点的身份,从而重用和重新排序现有元素,你需要为每项提供一个唯一 key 属性。理想的 key 值是每项都有的且唯一的 id。

示例:

<ul>
    <li v-for="(item,index) in items" :key=index>li>
ul>
  • 这里使用了一个特殊语法::key="" 我们后面会讲到,它可以让你读取vue中的属性,并赋值给key属性
  • 这里我们绑定的key是数组的索引,应该是唯一的

v-if和v-show:判断

基本使用

v-if,顾名思义,条件判断。当得到结果为true时,所在的元素才会被渲染。

语法:

v-if="布尔表达式"

示例:

<div id="app">
    <button v-on:click="show = !show">点我呀button>
    <br>
    <h1 v-if="show">
        看到我啦?!
    h1>
    <h1 v-show="show">
        看到我啦?!show
    h1>
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    var app = new Vue({
        el: "#app",
        data: {
            show: true
        }
    })
script>

与v-for结合

当v-if和v-for出现在一起时,v-for优先级更高。也就是说,会先遍历,再判断条件。

修改v-for中的案例,添加v-if:

    <ul>
        <li v-for="(user, index) in users" v-if="user.gender == ''">
            {{index + 1}}. {{user.name}} - {{user.gender}} - {{user.age}}
        li>
    ul>

v-else

你可以使用 v-else 指令来表示 v-if 的“else 块”:

<div id="app">
    <h1 v-if="Math.random() > 0.5">
        看到我啦?!if
    h1>
    <h1 v-else>
        看到我啦?!else
    h1>
div>

v-else 元素必须紧跟在带 v-if 或者 v-else-if 的元素的后面,否则它将不会被识别。

v-else-if,顾名思义,充当 v-if 的“else-if 块”,可以连续使用:

<div id="app">
    <button v-on:click="random=Math.random()">点我呀button><span>{{random}}span>
    <h1 v-if="random >= 0.75">
        看到我啦?!if
    h1>
    <h1 v-else-if="random > 0.5">
        看到我啦?!if 0.5
    h1>
    <h1 v-else-if="random > 0.25">
        看到我啦?!if 0.25
    h1>
    <h1 v-else>
        看到我啦?!else
    h1>
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    var app = new Vue({
        el: "#app",
        data: {
            random: 1
        }
    })
script>

类似于 v-elsev-else-if 也必须紧跟在带 v-if 或者 v-else-if 的元素之后。

v-show

另一个用于根据条件展示元素的选项是 v-show 指令。用法大致一样:

Hello!

不同的是带有 v-show 的元素始终会被渲染并保留在 DOM 中。v-show 只是简单地切换元素的 CSS 属性 display

示例:

    <div id="app">
        
        <button v-on:click="show = !show">点击切换button><br/>
        <h1 v-if="show">
            你好
        h1>
    div>
    <script src="./node_modules/vue/dist/vue.js">script>
    <script type="text/javascript">
        var app = new Vue({
            el:"#app",
            data:{
                show:true
            }
        })
    script>

v-bind:绑定属性 简写:

html属性不能使用双大括号形式绑定,只能使用v-bind指令。
表明属性内的值是动态值
在将 v-bind 用于 classstyle 时,Vue.js 做了专门的增强。表达式结果的类型除了字符串之外,还可以是对象或数组。

<div id="app">
    
    <div v-bind:title="title" style="border: 1px solid red; width: 50px; height: 50px;">div>
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    var app = new Vue({
        el: "#app",
        data: {
            title: "title",
        }
    })
script>

绑定class样式

HTML:

<div id="app">
    <div v-bind:class="activeClass">div>
    <div v-bind:class="errorClass">div>
    <div v-bind:class="[activeClass, errorClass]">div>
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    var app = new Vue({
        el: "#app",
        data: {
            activeClass: 'active',
            errorClass: ['text-danger', 'text-error']
        }
    })
script>

渲染后的效果:(具有active和hasError的样式)

对象语法

我们可以传给 v-bind:class 一个对象,以动态地切换 class:

<div v-bind:class="{ active: isActive }">div>

上面的语法表示 active 这个 class 存在与否将取决于数据属性 isActive 的 truthiness(所有的值都是真实的,除了false,0,“”,null,undefined和NaN)。

你可以在对象中传入更多属性来动态切换多个 class。此外,v-bind:class 指令也可以与普通的 class 属性共存。如下模板:

<div class="static"
     v-bind:class="{ active: isActive, 'text-danger': hasError }">
div>

和如下 data:

data: {
  isActive: true,
  hasError: false
}

结果渲染为:

<div class="static active">div>

active样式和text-danger样式的存在与否,取决于isActive和hasError的值。本例中isActive为true,hasError为false,所以active样式存在,text-danger不存在。

绑定style样式

数组语法

数组语法可以将多个样式对象应用到同一个元素上:

<div v-bind:style="[baseStyles, overridingStyles]">div>

数据:

data: {
    baseStyles: {'background-color': 'red'},
    overridingStyles: {border: '1px solid black'}
}

渲染后的结果:

<div style="background-color: red; border: 1px solid black;">div>

对象语法

v-bind:style 的对象语法十分直观——看着非常像 CSS,但其实是一个 JavaScript 对象。CSS 属性名可以用驼峰式 (camelCase) 或短横线分隔 (kebab-case,记得用单引号括起来) 来命名:

<div v-bind:style="{ color: activeColor, fontSize: fontSize + 'px' }">div>

数据:

data: {
  activeColor: 'red',
  fontSize: 30
}

效果:

<div style="color: red; font-size: 30px;">div>

简写

v-bind:class可以简写为:class

计算属性

在插值表达式中使用js表达式是非常方便的,而且也经常被用到。

但是如果表达式的内容很长,就会显得不够优雅,而且后期维护起来也不方便,例如下面的场景,我们有一个日期的数据,但是是毫秒值:

data:{
    birthday:1529032123201 // 毫秒值
}

我们在页面渲染,希望得到yyyy-MM-dd的样式:

<h1>您的生日是:{{
    new Date(birthday).getFullYear() + '-'+ new Date(birthday).getMonth()+ '-' + new Date(birthday).getDay()
    }}
h1>

在使用方法时需要加括号

虽然能得到结果,但是非常麻烦。

Vue中提供了计算属性,来替代复杂的表达式:

var vm = new Vue({
    el:"#app",
    data:{
        birthday:1429032123201 // 毫秒值
    },
    computed:{
        birth(){// 计算属性本质是一个方法,但是必须返回结果
            const d = new Date(this.birthday);
            return d.getFullYear() + "-" + d.getMonth() + "-" + d.getDay();
        }
    }
})
  • 计算属性本质就是方法,但是一定要返回数据。然后页面渲染时,可以把这个方法当成一个变量来使用。

页面使用:

    <div id="app">
       <h1>您的生日是:{{birth}} h1>
    div>

我们可以将同一函数定义为一个方法而不是一个计算属性。两种方式的最终结果确实是完全相同的。然而,不同的是计算属性是基于它们的依赖进行缓存的。计算属性只有在它的相关依赖发生改变时才会重新求值。这就意味着只要birthday还没有发生改变,多次访问 birthday 计算属性会立即返回之前的计算结果,而不必再次执行函数。

watch

watch可以让我们监控一个值的变化。从而做出相应的反应。
watch中的方法名必须和监听的数据名一致
示例:

<div id="app">
    <input type="text" v-model="message">
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    var vm = new Vue({
        el:"#app",
        data:{
            message:""
        },
        watch:{
            message(newVal, oldVal){
                console.log(newVal, oldVal);
            }
        }
    })
script>

组件化

在大型应用开发的时候,页面可以划分成很多部分。往往不同的页面,也会有相同的部分。例如可能会有相同的头部导航。

但是如果每个页面都独自开发,这无疑增加了我们开发的成本。所以我们会把页面的不同部分拆分成独立的组件,然后在不同页面就可以共享这些组件,避免重复开发。

在vue里,所有的vue实例都是组件

全局组件

我们通过Vue的component方法来定义一个全局组件。

<div id="app">
    
    <counter>counter>
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    // 定义全局组件,两个参数:1,组件名称。2,组件参数
    Vue.component("counter",{
        template:'',
        data(){
            return {
                count:0
            }
        }
    })
    var app = new Vue({
        el:"#app"
    })
script>
  • 组件其实也是一个Vue实例,因此它在定义时也会接收:data、methods、生命周期函数等
  • 不同的是组件不会与页面的元素绑定(不会相互影响),否则就无法复用了,因此没有el属性。
  • 但是组件渲染需要html模板,所以增加了template属性,值就是HTML模板
  • 全局组件定义完毕,任何vue实例都可以直接在HTML中通过组件名称来使用组件了。
  • data必须是一个函数,不再是一个对象。

组件的复用

定义好的组件,可以任意复用多次:

<div id="app">
    
    <counter>counter>
    <counter>counter>
    <counter>counter>
div>

你会发现每个组件互不干扰,都有自己的count值。怎么实现的?

组件的data属性必须是函数

当我们定义这个 组件时,它的data 并不是像之前直接提供一个对象:

data: {
  count: 0
}

取而代之的是,一个组件的 data 选项必须是一个函数,因此每个实例可以维护一份被返回对象的独立的拷贝:

data: function () {
  return {
    count: 0
  }
}

如果 Vue 没有这条规则,点击一个按钮就会影响到其它所有实例!

局部组件

一旦全局注册,就意味着即便以后你不再使用这个组件,它依然会随着Vue的加载而加载。

因此,对于一些并不频繁使用的组件,我们会采用局部注册。

我们先在外部定义一个对象,结构与创建组件时传递的第二个参数一致:

const counter = {
    template:'',
    data(){
        return {
            count:0
        }
    }
};

然后在Vue中使用它:

var app = new Vue({
    el:"#app",
    components:{
        counter:counter // 将定义的对象注册为组件
    }
})
  • components就是当前vue对象子组件集合。
    • 其key就是子组件名称
    • 其值就是组件对象名
  • 效果与刚才的全局注册是类似的,不同的是,这个counter组件只能在当前的Vue实例中使用

组件通信

props(父向子传递)

  1. 父组件使用子组件时,自定义属性(属性名任意,属性值为要传递的数据)
  2. 子组件通过props接收父组件数据,通过自定义属性的属性名

父组件使用子组件,并自定义了title属性:

<div id="app">
    <h1>打个招呼:h1>
    
    <introduce title="大家好,我是锋哥"/>
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    Vue.component("introduce",{
        // 直接使用props接收到的属性来渲染页面
        template:'

{{title}}

'
, props:['title'] // 通过props来接收一个父组件传递的属性 }) var app = new Vue({ el:"#app" })
script>

props验证

我们定义一个子组件,并接收复杂数据:

    const myList = {
        template: '\
        
    \
  • {{item.id}} : {{item.name}}
  • \
\ '
, props: { items: { type: Array, default: [], required: true } } };
  • 这个子组件可以对 items 进行迭代,并输出到页面。
  • props:定义需要从父组件中接收的属性
    • items:是要接收的属性名称
      • type:限定父组件传递来的必须是数组
      • default:默认值
      • required:是否必须

当 prop 验证失败的时候,(开发环境构建版本的) Vue 将会产生一个控制台的警告。

我们在父组件中使用它:

<div id="app">
    <h2>传智播客已开设如下课程:h2>
    
    <my-list :items="lessons"/>
div>
var app = new Vue({
    el:"#app",
    components:{
        myList // 当key和value一样时,可以只写一个
    },
    data:{
        lessons:[
            {id:1, name: 'java'},
            {id:2, name: 'php'},
            {id:3, name: 'ios'},
        ]
    }
})

动态静态传递

给 prop 传入一个静态的值:

<introduce title="大家好,我是锋哥"/>

给 prop 传入一个动态的值: (通过v-bind从数据模型中,获取title的值)

<introduce :title="title"/>

静态传递时,我们传入的值都是字符串类型的,但实际上任何类型的值都可以传给一个 props。



<blog-post v-bind:likes="42">blog-post>


<blog-post v-bind:likes="post.likes">blog-post>

子向父的通信

来看这样的一个案例:

<div id="app">
    <h2>num: {{num}}h2>
    
    <counter :num="num">counter>
div>
<script src="./node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    Vue.component("counter", {// 子组件,定义了两个按钮,点击数字num会加或减
        template:'\
            
\ \ \
'
, props:['num']// count是从父组件获取的。 }) var app = new Vue({ el:"#app", data:{ num:0 } })
script>
  • 子组件接收父组件的num属性
  • 子组件定义点击按钮,点击后对num进行加或减操作

我们尝试运行,好像没问题,点击按钮试试:

子组件接收到父组件属性后,默认是不允许修改的。怎么办?

既然只有父组件能修改,那么加和减的操作一定是放在父组件:

var app = new Vue({
    el:"#app",
    data:{
        num:0
    },
    methods:{ // 父组件中定义操作num的方法
        increment(){
            this.num++;
        },
        decrement(){
            this.num--;
        }
    }
})

但是,点击按钮是在子组件中,那就是说需要子组件来调用父组件的函数,怎么做?

我们可以通过v-on指令将父组件的函数绑定到子组件上:

<div id="app">
    <h2>num: {{num}}h2>
    <counter :count="num" @inc="increment" @dec="decrement">counter>
div>

在子组件中定义函数,函数的具体实现调用父组件的实现,并在子组件中调用这些函数。当子组件中按钮被点击时,调用绑定的函数:

        Vue.component("counter", {
            template:'\
                
\ \ \
'
, props:['count'], methods:{ plus(){ this.$emit("inc"); }, reduce(){ this.$emit("dec"); } } })
  • vue提供了一个内置的this.$emit()函数,用来调用父组件绑定的函数

效果:

路由v-router

编写登录注册页面

一个页面,包含登录和注册,点击不同按钮,实现登录和注册页切换:

编写父组件

为了让接下来的功能比较清晰,我们先新建一个文件夹:src

然后新建一个HTML文件,作为入口:index.html

然后编写页面的基本结构:

<div id="app">
    <span>登录span>
    <span>注册span>
    <hr/>
    <div>
        登录页/注册页
    div>
div>
<script src="../node_modules/vue/dist/vue.js">script>
<script type="text/javascript">
    var vm = new Vue({
        el:"#app"
    })
script>

编写登录注册组件

接下来我们来实现登录组件,以前我们都是写在一个文件中,但是为了复用性,开发中都会把组件放入独立的JS文件中,我们新建一个user目录以及login.js及register.js:

编写组件,这里我们只写模板,不写功能。

login.js内容如下:

const loginForm = {
    template:'\
    
\

登录页

\ 用户名:
\ 密码:
\
\ '
}

register.js内容:

const registerForm = {
    template:'\
    
\

注册页

\ 用 户 名:
\ 密  码:
\ 确认密码:
\
\ '
}

在父组件中引用

<div id="app">
    <span>登录span>
    <span>注册span>
    <hr/>
    <div>
        
        
        <login-form>login-form>
        <register-form>register-form>
    div>
div>
<script src="../node_modules/vue/dist/vue.js">script>
<script src="user/login.js">script>
<script src="user/register.js">script>
<script type="text/javascript">
    var vm = new Vue({
        el: "#app",
        components: {
            loginForm,
            registerForm
        }
    })
script>

问题:
我们期待的是,当点击登录或注册按钮,分别显示登录页或注册页,而不是一起显示。

但是,如何才能动态加载组件,实现组件切换呢?

虽然使用原生的Html5和JS也能实现,但是官方推荐我们使用vue-router模块。

vue-router简介和安装

使用vue-router和vue可以非常方便的实现 复杂单页应用的动态路由功能。

官网:https://router.vuejs.org/zh-cn/

使用npm安装:npm install vue-router --save

在index.html中引入依赖:

<script src="../node_modules/vue-router/dist/vue-router.js">script>

快速入门

新建vue-router对象,并且指定路由规则:

// 创建VueRouter对象
const router = new VueRouter({
    routes:[ // 编写路由规则
        {
            path:"/login", // 请求路径,以“/”开头
            component:loginForm // 组件名称
        },
        {
            path:"/register",
            component:registerForm
        }
    ]
})
  • 创建VueRouter对象,并指定路由参数
  • routes:路由规则的数组,可以指定多个对象,每个对象是一条路由规则,包含以下属性:
    • path:路由的路径
    • component:组件名称

在父组件中引入router对象:

var vm = new Vue({
    el:"#app",
    components:{// 引用登录和注册组件
        loginForm,
        registerForm
    },
    router // 引用上面定义的router对象
})

页面跳转控制:

<div id="app">
    
    <span><router-link to="/login">登录router-link>span>
    <span><router-link to="/register">注册router-link>span>
    <hr/>
    <div>
        
        <router-view>router-view>
    div>
div>
  • 通过来指定一个锚点,当路由的路径匹配时,vue-router会自动把对应组件放到锚点位置进行渲染
  • 通过指定一个跳转链接,当点击时,会触发vue-router的路由功能,路径中的hash值会随之改变

注意:单页应用中,页面的切换并不是页面的跳转。仅仅是地址最后的hash值变化。

事实上,我们总共就一个HTML:index.html

Vue总结

html模板

  • 插值表达式:声明式渲染
    • {{js表达式、数据模型}}:js表达式必须有返回值,出现插值闪烁
      v-text:通常使用该方式
      v-html:解析html,js,css。安全隐患
  • 双向渲染:双向绑定
    • v-model=“数据模型”;在表单元素中使用才有意义
  • 事件:v-on 简写@
    • @click 点击事件
      @contextMenu 右击事件,事件修饰符:.prevent:禁用默认事件
      @keyup:键盘事件
      • .enter(13):回车键盘事件
        组合事件
  • v-for: 遍历集合或者对象
    • 数组:v-for="(item,index) in items"
      对象:v-for="(val, key, index) in user"
      :key提高渲染速度
  • v-if:判断
    • v-if=“布尔表达式”:true-渲染,false-不渲染
      v-show=“布尔表达式”:总是渲染:false-display:none
      v-else-if=“布尔表达式”
      v-else 一定要紧跟在if之后
  • v-bind:绑定属性:简写(
    • :class="{active:布尔表达式}"

vue实例js

  • el 选择器,对应html模板
  • data:数据类型
  • methods:定义方法
  • computed:定义计算属性,计算本质就是方法,但是方法必须有返回值,计算属性可以像数据一样使用。如果计算属性的依赖项没有改变,直接从缓存中命中
  • watch:监听,方法名是要监听的数据类型名称 message(newVal,oldVal){}
  • 钩子函数:created,对象初始化之后执行,通常在created中初始化数据
  • components:局部子组件
    • 全局组件:
    Vue.component("组件名",{
    	templete:"html模板",
    	data(){
    		return{
    			数据类型
    		}
    	},
    	methods,watch
    })
    
    • 局部组件:const 组件对象名={同上}
    • 组件的通信:
      1. 父向子通信
        1. 父自定义属性,属性名随便写,属性值是要传递的数据模型
        2. 子通过props接收,参数名是自定义属性的属性名[“属性名”]{属性名:{type default required}}
      2. 子向父通信
        1. 父自定义事件,事件名随便写,属性值是要传递的数据模型
        2. 定义事件调用自己的方法,子的方法中通过this.$emit{“自定义事件名”}

路由

vue-router npm install vue-router --save
引入vue-router组件
实例化:vue-router:

const router=new VueRouter({
 	routes:[
 		{
 			path:路由路径,要以"/"开头
 			component:路由组件
 		},
 		{} 
 	] 
 })

引入到vue实例中:通过router

:锚点

搭建后台前端

解压后的leyou-manage-web (是下一个目录是多个文件的那个,别复制错了)复制到hm49/code打开
其中的pcakage.json中定义了安装的内容
执行npm install 不成功可以多执行几次
实在安装不上复制node_modules 到leyou-manage-web即可
安装完成之后
执行npm start命令 //在pcakage.json 中定义的热部署

  • 项目结构

    • bulid -配置文件
    • config:环境配置
    • dist:打包目录
    • src:要写的东西
      • asset 一些资源
      • components 集中定义全局组件
      • pages:页面 其中存在vue文件,(template)模板、(script)代码、(style)样式分离
      • router 如果只有index.js 一个文件,导入时写到目录即可
    • static :静态资源
      乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第10张图片
      理一下:
  • index.html:html模板文件。定义了空的div,其id为app。

  • main.js:实例化vue对象,并且绑定通过id选择器,绑定到index.html的div中,因此main.js的内容都将在index.html的div中显示。main.js中使用了App组件,即App.vue,也就是说index.html中最终展现的是App.vue中的内容。index.html引用它之后,就拥有了vue的内容(包括组件、样式等),所以,main.js也是webpack打包的入口。

  • index.js:定义请求路径和组件的映射关系。相当于之前的

  • App.vue中也没有内容,而是定义了vue-router的锚点:,我们之前讲过,vue-router路由后的组件将会在锚点展示。

  • 最终结论:一切路由后的内容都将通过App.vue在index.html中显示。

  • 访问流程:用户在浏览器输入路径,例如:http://localhost:9001/#/item/brand --> index.js(/item/brand路径对应pages/item/Brand.vue组件) --> 该组件显示在App.vue的锚点位置 --> main.js使用了App.vue组件,并把该组件渲染在index.html文件中(id为“app”的div中)

layout.vue是我们的布局组件
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第11张图片

  • Layout映射的路径是/
  • 除了Login以为的所有组件,都是定义在Layout的children属性,并且路径都是/的下面
  • 因此当路由到子组件时,会在Layout中定义的锚点中显示。
  • 并且Layout中的其它部分不会变化,这就实现了布局的共享。

Vuetify框架

为什么学习UI

Vue虽然会帮我们进行视图的渲染,但样式还是由我们自己来完成。这显然不是我们的强项,因此后端开发人员一般都喜欢使用一些现成的UI组件,拿来即用,常见的例如:

  • BootStrap
  • LayUI
  • EasyUI
  • ZUI

然而这些UI组件的基因天生与Vue不合,因为他们更多的是利用DOM操作,借助于jQuery实现,而不是MVVM的思想。

而目前与Vue吻合的UI框架也非常的多,国内比较知名的如:

  • element-ui:饿了么出品
  • i-view:某公司出品

然而我们都不用,我们今天推荐的是一款国外的框架:Vuetify

官方网站:https://vuetifyjs.com/zh-Hans/

为什么是Vuetify

有中国的为什么还要用外国的?原因如下:

  • Vuetify几乎不需要任何CSS代码,而element-ui许多布局样式需要我们来编写
  • Vuetify从底层构建起来的语义化组件。简单易学,容易记住。
  • Vuetify基于Material Design(谷歌推出的多平台设计规范),更加美观,动画效果酷炫,且风格统一

怎么用

基于官方文档
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第12张图片
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第13张图片
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第14张图片

使用域名并去除端口(nginx)访问本地项目

统一环境我们现在访问页面使用的是:http://localhost:9001

有没有什么问题?

实际开发中,会有不同的环境:

  • 开发环境:自己的电脑
  • 测试环境:提供给测试人员使用的环境
  • 预发布环境:数据是和生成环境的数据一致,运行最新的项目代码进去测试
  • 生产环境:项目最终发布上线的环境

如果不同环境使用不同的ip去访问,可能会出现一些问题。为了保证所有环境的一致,我们会在各种环境下都使用域名来访问。

我们将使用以下域名:

主域名是:www.leyou.com,
管理系统域名:manage.leyou.com
网关域名:api.leyou.com

但是最终,我们希望这些域名指向的还是我们本机的某个端口。

那么,当我们在浏览器输入一个域名时,浏览器是如何找到对应服务的ip和端口的呢?

域名解析

一个域名一定会被解析为一个或多个ip。这一般会包含两步:

  • 本地域名解析

浏览器会首先在本机的hosts文件中查找域名映射的IP地址,如果查找到就返回IP ,没找到则进行域名服务器解析,一般本地解析都会失败,因为默认这个文件是空的。

  • Windows下的hosts文件地址:C:/Windows/System32/drivers/etc/hosts
  • Linux下的hosts文件所在路径: /etc/hosts
    样式:
# My hosts
127.0.0.1 localhost
0.0.0.0 account.jetbrains.com
127.0.0.1 www.xmind.net
  • 域名服务器解析

本地解析失败,才会进行域名服务器解析,域名服务器就是网络中的一台计算机,里面记录了所有注册备案的域名和ip映射关系,一般只要域名是正确的,并且备案通过,一定能找到。

解决域名解析问题

我们不可能去购买一个域名,因此我们可以伪造本地的hosts文件,实现对域名的解析。修改本地的host为:

127.0.0.1 api.leyou.com
127.0.0.1 manage.leyou.com

这样就实现了域名的关系映射了。

可以用软件修改hosts
资料中的SwitchHosts-win32-x64
解压,复制到hm49/tool 运行文件
新建方案

在cmd中ping该网址可以访问,但从浏览器访问时会出现问题
原因:我们配置了项目访问的路径,虽然manage.leyou.com映射的ip也是127.0.0.1,但是webpack会验证host是否符合配置。
必须在在webpack.dev.conf.js中取消host验证:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第15张图片

因为修改了配置文件,所以需要重新启动
Terminal 中crtl+c 选择Y
重新执行cpm start指令

nginx解决端口问题

域名问题解决了,但是现在要访问后台页面,还得自己加上端口:http://manage.taotao.com:9001。

这就不够优雅了。我们希望的是直接域名访问:http://manage.taotao.com。这种情况下端口默认是80,如何才能把请求转移到9001端口呢?

这里就要用到反向代理工具:Nginx

什么是Nginx

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第16张图片
nginx可以作为web服务器,但更多的时候,我们把它作为网关,因为它具备网关必备的功能:

反向代理
负载均衡
动态路由
请求过滤

nginx作为web服务器

Web服务器分2类:

web应用服务器,如:

  • tomcat
  • resin
  • jetty

web服务器,如:

  • Apache 服务器
  • Nginx
  • IIS

区分:web服务器不能解析jsp等页面,只能处理js、css、html等静态资源。 并发:web服务器的并发能力远高于web应用服务器。

nginx作为反向代理

什么是反向代理?

  • 代理:通过客户机的配置,实现让一台服务器(代理服务器)代理客户机,客户的所有请求都交给代理服务器处理。
  • 反向代理:用一台服务器,代理真实服务器,用户访问时,不再是访问真实服务器,而是代理服务器。

nginx可以当做反向代理服务器来使用:

  • 我们需要提前在nginx中配置好反向代理的规则,不同的请求,交给不同的真实服务器处理
  • 当请求到达nginx,nginx会根据已经定义的规则进行请求的转发,从而实现路由功能

利用反向代理,就可以解决我们前面所说的端口问题

安装和使用

将资料内的nginx压缩包解压后复制到tool文件夹即可
其中
conf:配置目录
contrib:第三方依赖
html:默认的静态资源目录,类似于tomcat的webapps
logs:日志目录
nginx.exe:启动程序。可双击运行,但不建议这么做。

反向代理配置 在conf中的nginx.conf
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第17张图片
nginx中的每个server就是一个反向代理配置,可以有多个server
这里因为网关也需要访问,所以复制了一次server

完整配置:

#user  nobody;
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
   
    keepalive_timeout  65;

    gzip  on;
	server {
        listen       80;
        server_name  manage.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 / {
			proxy_pass http://127.0.0.1:9001;
			proxy_connect_timeout 600;
			proxy_read_timeout 600;
        }
    }
	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 / {
			proxy_pass http://127.0.0.1:10010;
			proxy_connect_timeout 600;
			proxy_read_timeout 600;
        }
    }
}

使用
nginx可以通过命令行来启动,操作命令:
在路径栏里输入cmd 执行

  • 启动:start nginx.exe
  • 停止:nginx.exe -s stop
  • 重新加载:nginx.exe -s reload

启动过程会闪烁一下,启动成功后,任务管理器中会有两个nginx进程:

启动不了的话建议查看这篇文章
https://blog.csdn.net/shanlijia/article/details/109284192
https://blog.csdn.net/weixin_39640298/article/details/107042209

商品分类查询

导入数据

商品分类

CREATE TABLE `tb_category` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '类目id',
  `name` varchar(20) NOT NULL COMMENT '类目名称',
  `parent_id` bigint(20) NOT NULL COMMENT '父类目id,顶级类目填0',
  `is_parent` tinyint(1) NOT NULL COMMENT '是否为父节点,0为否,1为是',
  `sort` int(4) NOT NULL COMMENT '排序指数,越小越靠前',
  PRIMARY KEY (`id`),
  KEY `key_parent_id` (`parent_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1424 DEFAULT CHARSET=utf8 COMMENT='商品类目表,类目和商品(spu)是一对多关系,类目与品牌是多对多关系';

因为商品分类会有层级关系,因此这里我们加入了parent_id字段,对本表中的其它分类进行自关联。

页面实现

页面分析

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第18张图片
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第19张图片

页面模板:


    
        
    

v-card:卡片,是vuetify中提供的组件,提供一个悬浮效果的面板,一般用来展示一组
v-flex:布局容器,用来控制响应式布局。与BootStrap的栅格系统类似,整个屏幕被分为12格。我们可以控制所占的格数来控制宽度:
v-tree:树组件。Vuetify并没有提供树组件,这个是我们自己编写的自定义组件:

树组件内容简述
属性列表:

属性名称 说明 数据类型 默认值
url 用来加载数据的地址,即延迟加载 String -
isEdit 是否开启树的编辑功能 boolean false
treeData 整颗树数据,这样就不用远程加载了 Array -
这里推荐使用url进行延迟加载,每当点击父节点时,就会发起请求,根据父节点id查询子节点信息。

当有treeData属性时,就不会触发url加载

功能实现

url异步请求

给大家的页面中,treeData是假数据,我们删除数据treeData属性,只保留url看看会发生什么:


刷新页面,可以看到:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第20张图片
页面中的树没有了,并且发起了一条请求:http://localhost/api/item/category/list?pid=0

大家可能会觉得很奇怪,我们明明是使用的相对路径,讲道理发起的请求地址应该是:

http://manage.leyou.com/item/category/list

但实际却是:

http://localhost/api/item/category/list?pid=0

这是因为,我们有一个全局的配置文件,对所有的请求路径进行了约定:

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第21张图片
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第22张图片
路径是localhost,并且默认加上了/api的前缀,这恰好与我们的网关设置匹配,我们只需要把地址改成网关的地址即可,因为我们使用了nginx反向代理,这里可以写域名:

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第23张图片
再次查看页面,发现地址已经变成正确地址了
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第24张图片

代码内容

  • 实体类
    在ly-item-interface中添加category实体类:
package com.leyou.item.pojo;

import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Table(name="tb_category")
public class Category {
    @Id
    @GeneratedValue(strategy= GenerationType.IDENTITY)
    private Long id;
    private String name;
    private Long parentId;
    private Boolean isParent;
    private Integer sort;
    // getter和setter略
    // 注意isParent的get和set方法,需要手动加上Is
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Long getParentId() {
        return parentId;
    }

    public void setParentId(Long parentId) {
        this.parentId = parentId;
    }

    public Boolean getIsParent() {
        return isParent;
    }

    public void setIsParent(Boolean parent) {
        isParent = parent;
    }

    public Integer getSort() {
        return sort;
    }

    public void setSort(Integer sort) {
        this.sort = sort;
    }


}

需要注意的是,这里要用到jpa的注解,因此我们在leyou-item-iterface中添加jpa依赖

<dependency>
    <groupId>javax.persistencegroupId>
    <artifactId>persistence-apiartifactId>
    <version>1.0version>
dependency>

后续都是写在leyou-item-service

  • controller

编写一个controller一般需要知道四个内容:

  • 请求方式:决定我们用GetMapping还是PostMapping
  • 请求路径:决定映射路径
  • 请求参数:决定方法的参数
  • 返回值结果:决定方法的返回值

在刚才页面发起的请求中,我们就能得到绝大多数信息:

  • 请求方式:Get,插叙肯定是get请求

  • 请求路径:/api/item/category/list。其中/api是网关前缀,/item是网关的路由映射,真实的路径应该是/category/list

  • 请求参数:pid=0,根据tree组件的说明,应该是父节点的id,第一次查询为0,那就是查询一级类目

  • 返回结果:??

    根据前面tree组件的用法我们知道,返回的应该是json数组:

    [
        { 
            "id": 74,
            "name": "手机",
            "parentId": 0,
            "isParent": true,
            "sort": 2
    	},
         { 
            "id": 75,
            "name": "家用电器",
            "parentId": 0,
            "isParent": true,
            "sort": 3
    	}
    ]
    

    对应的java类型可以是List集合,里面的元素就是类目对象了。也就是List

添加Controller:

package com.leyou.item.controller;

import com.leyou.item.pojo.Category;
import com.leyou.item.service.CategoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@Controller
@RequestMapping("category")
public class CategoryController {
    @Autowired
    private CategoryService categoryService;

    /**
     * 根据父节点id查询子节点
     *
     * @param pid
     * @return
     */
    @GetMapping("list")
    public ResponseEntity<List<Category>> queryCategoryiesByPid(@RequestParam(value = "pid", defaultValue = "0") Long pid) {

            if (pid == null || pid < 0) {
                // 400 param is wrong
                //return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
                return ResponseEntity.badRequest().build();
            }
            List<Category> categories = this.categoryService.queryCategoriesByPid(pid);
            if (CollectionUtils.isEmpty(categories)) {
                //404 is not found
                //return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
                return ResponseEntity.notFound().build();
            }
            //200 query successfully
            return ResponseEntity.ok(categories);
    }
}
  • service
    一般service层我们会定义接口和实现类,不过这里我们就偷懒一下,直接写实现类了:
package com.leyou.item.service;

import com.leyou.item.mapper.CategoryMapper;
import com.leyou.item.pojo.Category;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class CategoryService {
    @Autowired
    private CategoryMapper categoryMapper;

    /**
     * 根据父节点查询子节点
     * @param pid
     * @return
     */
    public List<Category> queryCategoriesByPid(Long pid) {
        Category record = new Category();
        record.setParentId(pid);
        return this.categoryMapper.select(record);
    }
}

  • mapper
    用于做简化开发
package com.leyou.item.mapper;

import com.leyou.item.pojo.Category;
import tk.mybatis.mapper.common.Mapper;

public interface CategoryMapper extends Mapper<Category> {
}

要注意,我们并没有在mapper接口上声明@Mapper注解,那么mybatis如何才能找到接口呢?

我们在启动类上添加一个扫描包功能:

@SpringBootApplication
@EnableDiscoveryClient
@MapperScan("com.leyou.item.mapper") // 扫描mapper包
public class LyItemService {
    public static void main(String[] args) {
        SpringApplication.run(LyItemService.class, args);
    }
}

结构
在这里插入图片描述
运行后出现跨域问题
无法正常运行

跨域问题

跨域:浏览器对于javascript的同源策略的限制 。

以下情况都属于跨域:

跨域原因说明 示例
域名不同 www.jd.comwww.taobao.com
域名相同,端口不同 www.jd.com:8080www.jd.com:8081
二级域名不同 item.jd.commiaosha.jd.com

如果域名和端口都相同,但是请求路径不同,不属于跨域,如:

www.jd.com/item

www.jd.com/goods

http和https也属于跨域

而我们刚才是从manage.leyou.com去访问api.leyou.com,这属于二级域名不同,跨域了。

为什么有跨域问题?

跨域不一定都会有跨域问题。

因为跨域问题是浏览器对于ajax请求的一种安全限制:一个页面发起的ajax请求,只能是与当前页域名相同的路径,这能有效的阻止跨站攻击。

因此:跨域问题 是针对ajax的一种限制

但是这却给我们的开发带来了不便,而且在实际生产环境中,肯定会有很多台服务器之间交互,地址和端口都可能不同,怎么办?

解决跨域问题的方案

目前比较常用的跨域解决方案有3种:

  • Jsonp

    最早的解决方案,利用script标签可以跨域的原理实现。

    限制:

    • 需要服务的支持
    • 只能发起GET请求
  • nginx反向代理

    思路是:利用nginx把跨域反向代理为不跨域,支持各种请求方式

    缺点:需要在nginx进行额外配置,语义不清晰

  • CORS

    规范化的跨域请求解决方案,安全可靠。

    优势:

    • 在服务端进行控制是否允许跨域,可自定义规则
    • 支持各种请求方式

    缺点:

    • 会产生额外的请求

我们这里会采用cors的跨域方案。

cors解决跨域

什么是cors

CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。

它允许浏览器向跨源服务器,发出[XMLHttpRequest]请求,从而克服了AJAX只能同源使用的限制。

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。

  • 浏览器端:

    目前,所有浏览器都支持该功能(IE10以下不行)。整个CORS通信过程,都是浏览器自动完成,不需要用户参与。

  • 服务端:

    CORS通信与AJAX没有任何差别,因此你不需要改变以前的业务逻辑。只不过,浏览器会在请求中携带一些头信息,我们需要以此判断是否允许其跨域,然后在响应头中加入一些信息即可。这一般通过过滤器完成即可。

原理有点复杂

浏览器会将ajax请求分为两类,其处理方案略有差异:简单请求、特殊请求。

简单请求

只要同时满足以下两大条件,就属于简单请求。:

(1) 请求方法是以下三种方法之一:

  • HEAD
  • GET
  • POST

(2)HTTP的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain

当浏览器发现发起的ajax请求是简单请求时,会在请求头中携带一个字段:Origin.

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第25张图片

Origin中会指出当前请求属于哪个域(协议+域名+端口)。服务会根据这个值决定是否允许其跨域。

如果服务器允许跨域,需要在返回的响应头中携带下面信息:

Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Content-Type: text/html; charset=utf-8
  • Access-Control-Allow-Origin:可接受的域,是一个具体域名或者*(代表任意域名)
  • Access-Control-Allow-Credentials:是否允许携带cookie,默认情况下,cors不会携带cookie,除非这个值是true

有关cookie:

要想操作cookie,需要满足3个条件:

  • 服务的响应头中需要携带Access-Control-Allow-Credentials并且为true。
  • 浏览器发起ajax需要指定withCredentials 为true
  • 响应头中的Access-Control-Allow-Origin一定不能为*,必须是指定的域名
特殊请求

不符合简单请求的条件,会被浏览器判定为特殊请求,,例如请求方式为PUT。

预检请求

特殊请求会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight)。

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

一个“预检”请求的样板:

OPTIONS /cors HTTP/1.1
Origin: http://manage.leyou.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.leyou.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

与简单请求相比,除了Origin以外,多了两个头:

  • Access-Control-Request-Method:接下来会用到的请求方式,比如PUT
  • Access-Control-Request-Headers:会额外用到的头信息

预检请求的响应

服务的收到预检请求,如果许可跨域,会发出响应:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Max-Age: 1728000
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

除了Access-Control-Allow-OriginAccess-Control-Allow-Credentials以外,这里又额外多出3个头:

  • Access-Control-Allow-Methods:允许访问的方式
  • Access-Control-Allow-Headers:允许携带的头
  • Access-Control-Max-Age:本次许可的有效时长,单位是秒,过期之前的ajax请求就无需再次进行预检了

如果浏览器得到上述响应,则认定为可以跨域,后续就跟简单请求的处理是一样的了。

实现

虽然原理比较复杂,但是前面说过:

  • 浏览器端都有浏览器自动完成,我们无需操心
  • 服务端可以通过拦截器统一实现,不必每次都去进行跨域判定的编写。

事实上,SpringMVC已经帮我们写好了CORS的跨域过滤器:CorsFilter ,内部已经实现了刚才所讲的判定逻辑,我们直接用就好了。

leyou-gateway中编写一个配置类,并且注册CorsFilter:

package com.leyou.gateway.config;


import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Configuration
public class LeyouCorsConfiguration {

    @Bean
    public CorsFilter corsFilter(){
        //初始化cors配置对象
        CorsConfiguration configuration = new CorsConfiguration();
        //允许跨域的域名,如果要携带cookid,不能写*。*:代表所有域名都可以跨域访问
        configuration.addAllowedOrigin("http://manage.leyou.com");
        configuration.setAllowCredentials(true);//允许携带cookie
        configuration.addAllowedMethod("*");//代表所有的请求方法:GET、PUT、POST、DELETE。。
        configuration.addAllowedHeader("*");//允许携带任何头信息
        //初始化cors配置源对象
        UrlBasedCorsConfigurationSource corsConfigurationSource=new UrlBasedCorsConfigurationSource();
        corsConfigurationSource.registerCorsConfiguration("/**",configuration);
        //反悔CorsFilter实例,参数:cors配置源对象
        return new CorsFilter(corsConfigurationSource);
    }
}

品牌查询

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第26张图片

路由路径:/item/brand

根据路由文件知,对应的页面是:src/pages/item/Brand.vue

页面会发送如下请求:

后台提供查询接口

前台页面已经准备好,接下来就是后台提供数据接口了。

数据库表

CREATE TABLE `tb_brand` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '品牌id',
  `name` varchar(50) NOT NULL COMMENT '品牌名称',
  `image` varchar(200) DEFAULT '' COMMENT '品牌图片地址',
  `letter` char(1) DEFAULT '' COMMENT '品牌的首字母',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=325400 DEFAULT CHARSET=utf8 COMMENT='品牌表,一个品牌下有多个商品(spu),一对多关系';

简单的四个字段,不多解释。

这里需要注意的是,品牌和商品分类之间是多对多关系。因此我们有一张中间表,来维护两者间关系:

CREATE TABLE `tb_category_brand` (
  `category_id` bigint(20) NOT NULL COMMENT '商品类目id',
  `brand_id` bigint(20) NOT NULL COMMENT '品牌id',
  PRIMARY KEY (`category_id`,`brand_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品分类和品牌的中间表,两者是多对多关系';

但是,你可能会发现,这张表中并没有设置外键约束,似乎与数据库的设计范式不符。为什么这么做?

  • 外键会严重影响数据库读写的效率
  • 数据删除时会比较麻烦

在电商行业,性能是非常重要的。我们宁可在代码中通过逻辑来维护表关系,也不设置外键。

实体类

interface中的

package com.leyou.item.pojo;

import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Table(name = "tb_brand")
public class Brand {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String image;
    private Character letter;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getImage() {
        return image;
    }

    public void setImage(String image) {
        this.image = image;
    }

    public Character getLetter() {
        return letter;
    }

    public void setLetter(Character letter) {
        this.letter = letter;
    }
}

leyou-item-service中

  • mapper
    用于做简化开发
package com.leyou.item.mapper;

import com.leyou.item.pojo.Brand;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import tk.mybatis.mapper.common.Mapper;

public interface BrandMapper extends Mapper {

    @Insert("INSERT INTO tb_category_brand (category_id,brand_id) VALUES (#{cid},#{bid})")
    void insertCategoryAndBrand(@Param("cid") Long cid,@Param("bid") Long bid);
}


  • Controller:

编写controller先思考四个问题,参照前端页面的控制台

  • 请求方式:查询,肯定是Get
  • 请求路径:分页查询,/brand/page
  • 请求参数:根据我们刚才编写的页面,有分页功能,有排序功能,有搜索过滤功能,因此至少要有5个参数:
    • page:当前页,int
    • rows:每页大小,int
    • sortBy:排序字段,String
    • desc:是否为降序,boolean
    • key:搜索关键词,String
  • 响应结果:分页结果一般至少需要两个数据
    • total:总条数
    • items:当前页数据
    • totalPage:有些还需要总页数

这里我们封装一个类,来表示分页结果:
需要先在item-common 写封装类

package com.leyou.common.pojo;

import java.util.List;

public class PageResult {
    private Long total;
    private Integer totalPage;
    private List items;

    public PageResult() {
    }

    public PageResult(Long total, List items) {
        this.total = total;
        this.items = items;
    }

    public PageResult(Long total, Integer totalPage, List items) {
        this.total = total;
        this.totalPage = totalPage;
        this.items = items;
    }

    public Long getTotal() {
        return total;
    }

    public void setTotal(Long total) {
        this.total = total;
    }

    public Integer getTotalPage() {
        return totalPage;
    }

    public void setTotalPage(Integer totalPage) {
        this.totalPage = totalPage;
    }

    public List getItems() {
        return items;
    }

    public void setItems(List items) {
        this.items = items;
    }
}

这个PageResult以后可能在其它项目中也有需求,因此我们将其抽取到leyou-common中,提高复用性:
不要忘记在leyou-item-service工程的pom.xml中引入leyou-common的依赖

<dependency>
    <groupId>com.leyou.commongroupId>
    <artifactId>leyou-commonartifactId>
    <version>1.0.0-SNAPSHOTversion>
dependency>

接下来,我们编写Controller

package com.leyou.item.controller;

import com.leyou.common.pojo.PageResult;
import com.leyou.item.pojo.Brand;
import com.leyou.item.service.BrandService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@Controller
@RequestMapping("brand")
public class BrandController {

    @Autowired
    private BrandService brandService;

    /**
     * 根据查询条件分页并排序查询
     * @param key
     * @param page
     * @param rows
     * @param sortBy
     * @param desc
     * @return
     */
    @GetMapping("page")
    public ResponseEntity> queryBrandsByPage(
            @RequestParam(value = "key",required = false)String key,//能确定是默认值是什么内容就用defaultValue,不确定就用required
            @RequestParam(value = "page",defaultValue = "1")Integer page,
            @RequestParam(value = "rows",defaultValue = "5")Integer rows,
            @RequestParam(value = "sortBy",required = false)String sortBy,
            @RequestParam(value = "desc",required = false)Boolean desc
    ){
        PageResult result =this.brandService.queryBrandsByPage(key,page,rows,sortBy,desc);
        if(CollectionUtils.isEmpty(result.getItems())){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(result);
    }
    @PostMapping
    public ResponseEntity saveBrand(Brand brand, @RequestParam("cids")List cids){
        this.brandService.saveBrand(brand,cids);
        return ResponseEntity.status(HttpStatus.CREATED).build();

    }

}
  • service
package com.leyou.item.service;

import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.leyou.common.pojo.PageResult;
import com.leyou.item.mapper.BrandMapper;
import com.leyou.item.pojo.Brand;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import org.springframework.transaction.annotation.Transactional;
import tk.mybatis.mapper.entity.Example;

import java.util.List;

@Service
public class BrandService {

    @Autowired
    private BrandMapper brandMapper;

    /**
     *
     * @param key
     * @param page
     * @param rows
     * @param sortBy
     * @param desc
     * @return
     */
    public PageResult queryBrandsByPage(String key, Integer page, Integer rows, String sortBy, Boolean desc) {
        //初始化example对象去new一个example
        Example example=new Example(Brand.class);
        Example.Criteria criteria=example.createCriteria();
        //根据name模糊查询,或者根据首字母查询
        if(StringUtils.isNotBlank(key)){
            criteria.andLike("name","%"+key+"%").orEqualTo("letter",key);
        }
        //添加分页条件
        PageHelper.startPage(page,rows);
        //添加排序条件
        if(StringUtils.isNotBlank(sortBy)){
            example.setOrderByClause(sortBy+" "+(desc ? "desc" :"asc") );
        }
        List brands = this.brandMapper.selectByExample(example);
        //包装成pageinfo
        PageInfo pageInfo = new PageInfo<>(brands);
        //包装成分页结果返回
        return new PageResult<>(pageInfo.getTotal(), pageInfo.getList());
    }

    /**
     * 新增品牌
     * @param brand
     * @param cids
     */
    @Transactional
    public void saveBrand(Brand brand, List cids) {
        //先新增brand
        //Boolean flag=this.brandMapper.insertSelective(brand)==1;
        this.brandMapper.insertSelective(brand);
        //新增中间表 ,因为加了注解出问题会自动回滚,所以不需要再判断
        //if(flag){
            cids.forEach(cid ->{
                this.brandMapper.insertCategoryAndBrand(cid,brand.getId());
            });
        //}
    }
}

异步查询工具axios

异步查询数据,自然是通过ajax查询,大家首先想起的肯定是jQuery。但jQuery与MVVM的思想不吻合,而且ajax只是jQuery的一小部分。因此不可能为了发起ajax请求而去引用这么大的一个库。

axios入门

Vue官方推荐的ajax请求框架叫做:axios,看下demo:
axios的Get请求语法:

axios.get("/item/category/list?pid=0") // 请求路径和请求参数拼接
    .then(function(resp){
    	// 成功回调函数
	})
    .catch(function(){
    	// 失败回调函数
	})
// 参数较多时,可以通过params来传递参数
axios.get("/item/category/list", {
        params:{
            pid:0
        }
	})
    .then(function(resp){})// 成功时的回调
    .catch(function(error){})// 失败时的回调

axios的POST请求语法:

比如新增一个用户

axios.post("/user",{
    	name:"Jack",
    	age:21
	})
    .then(function(resp){})
    .catch(function(error){})

注意,POST请求传参,不需要像GET请求那样定义一个对象,在对象的params参数中传参。post()方法的第二个参数对象,就是将来要传递的参数

PUT和DELETE请求与POST请求类似

axios的全局配置

而在我们的项目中,已经引入了axios,并且进行了简单的封装,在src下的http.js中:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第27张图片
http.js中对axios进行了一些默认配置:

import Vue from 'vue'
import axios from 'axios'
import config from './config'
// config中定义的基础路径是:http://api.leyou.com/api
axios.defaults.baseURL = config.api; // 设置axios的基础请求路径
axios.defaults.timeout = 2000; // 设置axios的请求时间

Vue.prototype.$http = axios;// 将axios赋值给Vue原型的$http属性,这样所有vue实例都可使用该对象
  • http.js中导入了config的配置,还记得吗?

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第28张图片

  • http.js对axios进行了全局配置:baseURL=config.api,即http://api.leyou.com/api。因此以后所有用axios发起的请求,都会以这个地址作为前缀。

  • 通过Vue.property.$http = axios,将axios赋值给了 Vue原型中的$http。这样以后所有的Vue实例都可以访问到$http,也就是访问到了axios了。

项目中使用

我们在组件MyBrand.vue的getDataFromServer方法,通过$http发起get请求,测试查询品牌的接口,看是否能获取到数据:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第29张图片
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第30张图片
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第31张图片
可以看到,在请求成功的返回结果response中,有一个data属性,里面就是真正的响应数据。

响应结果中与我们设计的一致,包含3个内容:

total:总条数,目前是165
items:当前页数据
totalPage:总页数,我们没有返回

异步加载品牌数据

虽然已经通过ajax请求获取了品牌数据,但是刚才的请求没有携带任何参数,这样显然不对。我们后端接口需要5个参数:

page:当前页,int
rows:每页大小,int
sortBy:排序字段,String
desc:是否为降序,boolean
key:搜索关键词,String
而页面中分页信息应该是在pagination对象中,我们通过浏览器工具,查看pagination中有哪些属性:

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第32张图片

分别是:

descending:是否是降序,对应请求参数的desc
page:当前页,对应参数的page
rowsPerpage:每页大小,对应参数中的rows
sortBy:排序字段,对应参数的sortBy
缺少一个搜索关键词,这个应该是通过v-model与输入框绑定的属性:search。这样,所有参数就都有了。

另外,不要忘了把查询的结果赋值给brands和totalBrands属性,Vuetify会帮我们渲染页面。

接下来,我们在getDataFromServer方法中完善请求参数:

// 发起请求
this.$http.get("/item/brand/page",{
        params:{
            key: this.search, // 搜索条件
            page: this.pagination.page,// 当前页
            rows: this.pagination.rowsPerPage,// 每页大小
            sortBy: this.pagination.sortBy,// 排序字段
            desc: this.pagination.descending// 是否降序
        }
    }).then(resp => { // 这里使用箭头函数
        // 将得到的数据赋值给本地属性
        this.brands = resp.data.items;
        this.totalBrands = resp.data.total;
        // 完成赋值后,把加载状态赋值为false
        this.loading = false;
    })

查看网络请求
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第33张图片

效果
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第34张图片

完成分页和过滤

分页

点击分页,会发起请求,通过浏览器工具查看,会发现pagination对象的属性一直在变化:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第35张图片

我们可以利用Vue的监视功能:watch,当pagination发生改变时,会调用我们的回调函数,我们在回调函数中进行数据的查询!

具体实现:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第36张图片

成功实现分页功能:

过滤

过滤字段对应的是search属性,我们只要监视这个属性即可:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第37张图片

查看网络请求:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第38张图片
页面结果:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第39张图片

品牌新增

页面实现略去,自行学习

后台实现新增

controller

package com.leyou.item.controller;

import com.leyou.common.pojo.PageResult;
import com.leyou.item.pojo.Brand;
import com.leyou.item.service.BrandService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@Controller
@RequestMapping("brand")
public class BrandController {

    @Autowired
    private BrandService brandService;

    /**
     * 根据查询条件分页并排序查询
     * @param key
     * @param page
     * @param rows
     * @param sortBy
     * @param desc
     * @return
     */
    @GetMapping("page")
    public ResponseEntity<PageResult<Brand>> queryBrandsByPage(
            @RequestParam(value = "key",required = false)String key,//能确定是默认值是什么内容就用defaultValue,不确定就用required
            @RequestParam(value = "page",defaultValue = "1")Integer page,
            @RequestParam(value = "rows",defaultValue = "5")Integer rows,
            @RequestParam(value = "sortBy",required = false)String sortBy,
            @RequestParam(value = "desc",required = false)Boolean desc
    ){
        PageResult<Brand> result =this.brandService.queryBrandsByPage(key,page,rows,sortBy,desc);
        if(CollectionUtils.isEmpty(result.getItems())){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(result);
    }
    @PostMapping
    public ResponseEntity<Void> saveBrand(Brand brand, @RequestParam("cids")List<Long> cids){
        this.brandService.saveBrand(brand,cids);
        return ResponseEntity.status(HttpStatus.CREATED).build();

    }

}

service

package com.leyou.item.service;

import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.leyou.common.pojo.PageResult;
import com.leyou.item.mapper.BrandMapper;
import com.leyou.item.pojo.Brand;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import org.springframework.transaction.annotation.Transactional;
import tk.mybatis.mapper.entity.Example;

import java.util.List;

@Service
public class BrandService {

    @Autowired
    private BrandMapper brandMapper;

    /**
     *
     * @param key
     * @param page
     * @param rows
     * @param sortBy
     * @param desc
     * @return
     */
    public PageResult<Brand> queryBrandsByPage(String key, Integer page, Integer rows, String sortBy, Boolean desc) {
        //初始化example对象去new一个example
        Example example=new Example(Brand.class);
        Example.Criteria criteria=example.createCriteria();
        //根据name模糊查询,或者根据首字母查询
        if(StringUtils.isNotBlank(key)){
            criteria.andLike("name","%"+key+"%").orEqualTo("letter",key);
        }
        //添加分页条件
        PageHelper.startPage(page,rows);
        //添加排序条件
        if(StringUtils.isNotBlank(sortBy)){
            example.setOrderByClause(sortBy+" "+(desc ? "desc" :"asc") );
        }
        List<Brand> brands = this.brandMapper.selectByExample(example);
        //包装成pageinfo
        PageInfo<Brand> pageInfo = new PageInfo<>(brands);
        //包装成分页结果返回
        return new PageResult<>(pageInfo.getTotal(), pageInfo.getList());
    }

    /**
     * 新增品牌
     * @param brand
     * @param cids
     */
    @Transactional
    public void saveBrand(Brand brand, List<Long> cids) {
        //先新增brand
        //Boolean flag=this.brandMapper.insertSelective(brand)==1;
        this.brandMapper.insertSelective(brand);
        //新增中间表 ,因为加了注解出问题会自动回滚,所以不需要再判断
        //if(flag){
            cids.forEach(cid ->{
                this.brandMapper.insertCategoryAndBrand(cid,brand.getId());
            });
        //}
    }
}

mapper

通用Mapper只能处理单表,也就是Brand的数据,因此我们手动编写一个方法及sql,实现中间表的新增:

package com.leyou.item.mapper;

import com.leyou.item.pojo.Brand;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import tk.mybatis.mapper.common.Mapper;

public interface BrandMapper extends Mapper {

    @Insert("INSERT INTO tb_category_brand (category_id,brand_id) VALUES (#{cid},#{bid})")
    void insertCategoryAndBrand(@Param("cid") Long cid,@Param("bid") Long bid);
}

请求参数格式错误

原因分析

我们填写表单并提交,发现报错了。查看控制台的请求详情:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第40张图片
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第41张图片

发现请求的数据格式是JSON格式。

原因分析:

axios处理请求体的原则会根据请求数据的格式来定:

  • 如果请求体是对象:会转为json发送

  • 如果请求体是String:会作为普通表单请求发送,但需要我们自己保证String的格式是键值对。

    如:name=jack&age=12

QS工具

QS是一个第三方库,我们可以用npm install qs --save来安装。不过我们在项目中已经集成了,大家无需安装:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第42张图片
这个工具的名字:QS,即Query String,请求参数字符串。

什么是请求参数字符串?例如: name=jack&age=21

QS工具可以便捷的实现 JS的Object与QueryString的转换。

在我们的项目中,将QS注入到了Vue的原型对象中,我们可以通过this.$qs来获取这个工具:

我们将this.$qs对象打印到控制台:

created(){
    console.log(this.$qs);
}

发现其中有3个方法:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第43张图片
这里我们要使用的方法是stringify,它可以把Object转为QueryString。

测试一下,使用浏览器工具,把qs对象保存为一个临时变量temp1,然后调用stringify方法:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第44张图片

成功将person对象变成了 name=jack&age=21的字符串了

解决问题

修改页面,对参数处理后发送:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第45张图片
发送请求成功
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第46张图片
参数格式
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第47张图片

新增完成后关闭窗口

我们发现有一个问题:新增不管成功还是失败,窗口都一致在这里,不会关闭。

这样很不友好,我们希望如果新增失败,窗口保持;但是新增成功,窗口关闭才对。

因此,我们需要在新增的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
    乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第48张图片

实现图片上传

刚才的新增实现中,我们并没有上传图片,接下来我们一起完成图片上传逻辑。

文件的上传并不只是在品牌管理中有需求,以后的其它服务也可能需要,因此我们创建一个独立的微服务,专门处理各种上传。

搭建项目

新建module
GroupId com.leyou.service
ArtifactiId leyou-upload

Module name:leyou-upload

依赖

我们需要EurekaClient和web依赖:


<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>leyouartifactId>
        <groupId>com.leyou.parentgroupId>
        <version>1.0.0-SNAPSHOTversion>
    parent>
    <modelVersion>4.0.0modelVersion>

    <groupId>com.leyou.uploadgroupId>
    <artifactId>leyou-uploadartifactId>
    <version>1.0.0-SNAPSHOTversion>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-netflix-eureka-clientartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-testartifactId>
        dependency>
    dependencies>
project>

编写配置

application.yaml

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
fdfs:
  so-timeout: 1501 # 超时时间
  connect-timeout: 601 # 连接超时时间
  thumb-image: # 缩略图
    width: 60
    height: 60
  tracker-list: # tracker地址:你的虚拟机服务器地址+端口(默认是22122)
    - 192.168.56.101:22122

引导类

package com.leyou;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@SpringBootApplication
@EnableDiscoveryClient
public class LeyouUploadApplication {
    public static void main(String[] args) {
        SpringApplication.run(LeyouUploadApplication.class);
    }
}

编写上传功能

controller

编写controller需要知道4个内容:结合用法指南

  • 请求方式:上传肯定是POST
  • 请求路径:/upload/image
  • 请求参数:文件,参数名是file,SpringMVC会封装为一个接口:MultipartFile
  • 返回结果:上传成功后得到的文件的url路径,也就是返回String

代码如下:

package com.leyou.upload.controller;

import com.leyou.upload.service.UploadService;
import org.apache.commons.lang.StringUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;

@Controller
@RequestMapping("upload")
public class UploadController {

    private UploadService uploadService;
    @PostMapping("image")
    public ResponseEntity uploadImage(@RequestParam("file")MultipartFile file){
        String url=this.uploadService.uploadImage(file);
        if(StringUtils.isNotBlank(url)){
            return ResponseEntity.badRequest().build();
        }
        return ResponseEntity.status(HttpStatus.CREATED).body(url);
    }
}

service

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

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

文件大小在Spring的配置文件中设置,因此已经会被校验,我们不用管。

具体代码:

@Service
public class UploadService {

    private static final List content_types= Arrays.asList("image/gif","image/jpeg");

    private static final Logger LOGGER= LoggerFactory.getLogger(UploadService.class);
    public String uploadImage(MultipartFile file) {
        String originalFilename = file.getOriginalFilename();
        //StringUtils.substringAfterLast(originalFilename,".") 不去使用此方法
        //校验文件类型
        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:\\hm49\\image"+originalFilename));
//        返回url进行回显
            return "http://image.leyou.com/"+originalFilename;
        } catch (IOException e) {
            LOGGER.info("服务器内部错误:"+originalFilename);
            e.printStackTrace();
        }
        return null;
    }
}

这里有一个问题:为什么图片地址需要使用另外的url?

  • 图片不能保存在服务器内部,这样会对服务器产生额外的加载负担
  • 一般静态资源都应该使用独立域名,这样访问静态资源时不会携带一些不必要的cookie,减小请求的数据量

绕过网关

图片上传是文件的传输,如果也经过Zuul网关的代理,文件就会经过多次网路传输,造成不必要的网络负担。在高并发时,可能导致网络阻塞,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服务

上面的配置采用了集合语法,代表可以配置多个

Nginx的rewrite指令

现在,我们修改页面的访问路径:

<v-upload
      v-model="brand.image" 
      url="/upload/image" 
      :multiple="false" 
      :pic-width="250" :pic-height="90"
      />

查看页面的请求路径:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第49张图片

可以看到这个地址不对,依然是去找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:重写路径结束后,将得到的路径重新进行一次路径匹配
      • break:重写路径结束后,不再重新匹配路径。

      我们这里不能选择last,否则以新的路径/upload/image来匹配,就不会被正确的匹配到8082端口了

修改完成,输入nginx -s reload命令重新加载配置。然后再次上传试试。

跨域问题

重启nginx,再次上传,发现报错了:
在这里插入图片描述
不过庆幸的是,这个错误已经不是第一次见了,跨域问题。

我们在upload-service中添加一个CorsFilter即可:

@Configuration
public class GlobalCorsConfig {
@Bean
public CorsFilter corsFilter() {
//1.添加CORS配置信息
CorsConfiguration config = new CorsConfiguration();
//1) 允许的域,不要写*,否则cookie就无法使用了
config.addAllowedOrigin(“http://manage.leyou.com”);
//2) 是否发送Cookie信息
config.setAllowCredentials(false);
//3) 允许的请求方式
config.addAllowedMethod(“OPTIONS”);
config.addAllowedMethod(“POST”);
config.addAllowedHeader("*");

    //2.添加映射路径,我们拦截一切请求
    UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
    configSource.registerCorsConfiguration("/**", config);

    //3.返回新的CorsFilter.
    return new CorsFilter(configSource);
}

}
再次测试:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第50张图片
不过,非常遗憾的是,访问图片地址,却没有响应。
在这里插入图片描述

这是因为我们并没有任何服务器对应image.leyou.com这个域名。。

这个问题,我们暂时放下,回头再来解决。

之前上传的缺陷

先思考一下,之前上传的功能,有没有什么问题?

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

单机器存储,存储能力有限
无法进行水平扩展,因为多台机器的文件无法共享,会出现访问不到的情况
数据没有备份,有单点故障风险
并发能力差
这个时候,最好使用分布式文件存储来代替本地文件存储。

FastDFS

什么是分布式文件系统

分布式文件系统(Distributed File System)是指文件系统管理的物理存储资源不一定直接连接在本地节点上,而是通过计算机网络与节点相连。

通俗来讲:

  • 传统文件系统管理的文件就存储在本机。
  • 分布式文件系统管理的文件存储在很多机器,这些机器通过网络连接,要被统一管理。无论是上传或者访问文件,都需要通过管理中心来访问

什么是FastDFS

FastDFS是由淘宝的余庆先生所开发的一个轻量级、高性能的开源分布式文件系统。用纯C语言开发,功能丰富:

  • 文件存储
  • 文件同步
  • 文件访问(上传、下载)
  • 存取负载均衡
  • 在线扩容

适合有大容量存储需求的应用或系统。同类的分布式文件系统有谷歌的GFS、HDFS(Hadoop)、TFS(淘宝)等。

FastDFS的架构

架构图

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第51张图片

FastDFS两个主要的角色:Tracker Server 和 Storage Server 。

  • Tracker Server:跟踪服务器,主要负责调度storage节点与client通信,在访问上起负载均衡的作用,和记录storage节点的运行状态,是连接client和storage节点的枢纽。
  • Storage Server:存储服务器,保存文件和文件的meta data(元数据),每个storage server会启动一个单独的线程主动向Tracker cluster中每个tracker server报告其状态信息,包括磁盘使用情况,文件同步情况及文件上传下载次数统计等信息
  • Group:文件组,多台Storage Server的集群。上传一个文件到同组内的一台机器上后,FastDFS会将该文件即时同步到同组内的其它所有机器上,起到备份的作用。不同组的服务器,保存的数据不同,而且相互独立,不进行通信。
  • Tracker Cluster:跟踪服务器的集群,有一组Tracker Server(跟踪服务器)组成。
  • Storage Cluster :存储集群,有多个Group组成。

上传和下载流程

上传
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第52张图片

  1. Client通过Tracker server查找可用的Storage server。
  2. Tracker server向Client返回一台可用的Storage server的IP地址和端口号。
  3. Client直接通过Tracker server返回的IP地址和端口与其中一台Storage server建立连接并进行文件上传。
  4. 上传完成,Storage server返回Client一个文件ID,文件上传结束。

下载
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第53张图片

  1. Client通过Tracker server查找要下载文件所在的的Storage server。
  2. Tracker server向Client返回包含指定文件的某个Storage server的IP地址和端口号。
  3. Client直接通过Tracker server返回的IP地址和端口与其中一台Storage server建立连接并指定要下载文件。
  4. 下载文件成功。

安装和使用

安装在虚拟机上的步骤
参考资料进行
记录几个需要记得的点
配置要配置内网ip
查看虚拟机的内网ip用命令ifconfig在终端执行

java客户端

余庆先生提供了一个Java客户端,但是作为一个C程序员,写的java代码可想而知。而且已经很久不维护了。

这里推荐一个开源的FastDFS客户端,支持最新的SpringBoot2.0。

配置使用极为简单,支持连接池,支持自动生成缩略图,狂拽酷炫吊炸天啊,有木有。

地址:tobato/FastDFS_client

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第54张图片

引入依赖

在父工程中,我们已经管理了依赖,版本为:

<fastDFS.client.version>1.26.2fastDFS.client.version>

因此,这里我们直接在taotao-upload工程的pom.xml中引入坐标即可:

<dependency>
    <groupId>com.github.tobatogroupId>
    <artifactId>fastdfs-clientartifactId>
dependency>

引入配置类

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第55张图片

纯java配置:

package com.leyou.upload.config;

import com.github.tobato.fastdfs.FdfsClientConfig;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableMBeanExport;
import org.springframework.context.annotation.Import;
import org.springframework.jmx.support.RegistrationPolicy;

@Configuration
@Import(FdfsClientConfig.class)
// 解决jmx重复注册bean的问题
@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)
public class FastClientImporter {
    
}

编写FastDFS属性

在application.yml配置文件中追加如下内容:

fdfs:
  so-timeout: 15001 # 超时时间
  connect-timeout: 6000 # 连接超时时间
  thumb-image: # 缩略图
    width: 60
    height: 60
  tracker-list: # tracker地址:你的虚拟机服务器地址+端口(默认是22122)
    - 192.168.59.128:22122

配置hosts

将来通过域名:image.leyou.com这个域名访问fastDFS服务器上的图片资源。所以,需要代理到虚拟机地址:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第56张图片

配置hosts文件,使image.leyou.com可以访问fastDFS服务器

测试

创建测试类:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第57张图片

把以下内容copy进去:

package com.leyou;

import com.github.tobato.fastdfs.domain.StorePath;
import com.github.tobato.fastdfs.domain.ThumbImageConfig;
import com.github.tobato.fastdfs.service.FastFileStorageClient;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;

@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\\Public\\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\\Public\\Pictures\\xbx1.jpg");
        // 上传并且生成缩略图
        StorePath storePath = this.storageClient.uploadImageAndCrtThumbImage(
                new FileInputStream(file), file.length(), "jpg", null);
        // 带分组的路径
        System.out.println(storePath.getFullPath());
        // 不带分组的路径
        System.out.println(storePath.getPath());
        // 获取缩略图路径
        String path = thumbImageConfig.getThumbImagePath(storePath.getPath());
        System.out.println(path);
    }
}

改造service中的上传逻辑

package com.leyou.upload.service;

import com.github.tobato.fastdfs.domain.StorePath;
import com.github.tobato.fastdfs.service.FastFileStorageClient;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;

@Service
public class UploadService {

    private static final List 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();
        //StringUtils.substringAfterLast(originalFilename,".") 不去使用此方法
        //校验文件类型
        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:\\hm49\\image"+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;
    }
}

商品规格参数介绍

为了编写后续功能,需要了解商品类的数据库结构

SPU和SKU

SPU:Standard Product Unit (标准产品单位) ,一组具有共同属性的商品集

SKU:Stock Keeping Unit(库存量单位),SPU商品集因具体特性不同而细分的每个商品
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第58张图片

  • 本页的 华为Mate10 就是一个商品集(SPU)
  • 因为颜色、内存等不同,而细分出不同的Mate10,如亮黑色128G版。(SKU)

可以看出:

  • SPU是一个抽象的商品集概念,为了方便后台的管理。
  • SKU才是具体要销售的商品,每一个SKU的价格、库存可能会不一样,用户购买的是SKU而不是SPU

数据库设计分析

分析规格参数

仔细查看每一种商品的规格你会发现:

虽然商品规格千变万化,但是同一类商品(如手机)的规格是统一的,有图为证:

华为的规格

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第59张图片

SKU的特有属性

SPU中会有一些特殊属性,用来区分不同的SKU,我们称为SKU特有属性。如华为META10的颜色、内存属性。

不同种类的商品,一个手机,一个衣服,其SKU属性不相同。

同一种类的商品,比如都是衣服,SKU属性基本是一样的,都是颜色、尺码等。

这样说起来,似乎SKU的特有属性也是与分类相关的?事实上,仔细观察你会发现,SKU的特有属性是商品规格参数的一部分

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第60张图片
也就是说,我们没必要单独对SKU的特有属性进行设计,它可以看做是规格参数中的一部分。这样规格参数中的属性可以标记成两部分:

  • spu下所有sku共享的规格属性(称为全局属性)
  • 每个sku不同的规格属性(称为特有属性)

搜索属性

打开一个搜索页,我们来看看过滤的条件:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第61张图片
你会发现,过滤条件中的屏幕尺寸、运行内存、网路、机身内存、电池容量、CPU核数等,在规格参数中都能找到:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第62张图片

规格参数中的数据,将来会有一部分作为搜索条件来使用。我们可以在设计时,将这部分属性标记出来,将来做搜索的时候,作为过滤条件。要注意的是,无论是SPU的全局属性,还是SKU的特有属性,都有可能作为搜索过滤条件的,并不冲突,而是有一个交集:

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第63张图片

规格参数表

表结构

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第64张图片
可以看到规格参数是分组的,每一组都有多个参数键值对。不过对于规格参数的模板而言,其值现在是不确定的,不同的商品值肯定不同,模板中只要保存组信息、组内参数信息即可。

因此我们设计了两张表:

  • tb_spec_group:组,与商品分类关联 规格组
  • tb_spec_param:参数名,与组关联,一对多 规格参数

意思就是对于一个商品分类手机
他的规格组内包括主体、基本信息等
而规格组之下的主体组中的规格参数包括品牌、型号等

规格组

规格参数分组表:tb_spec_group

CREATE TABLE `tb_spec_group` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `cid` bigint(20) NOT NULL COMMENT '商品分类id,一个分类下有多个规格组',
  `name` varchar(50) NOT NULL COMMENT '规格组的名称',
  PRIMARY KEY (`id`),
  KEY `key_category` (`cid`)
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8 COMMENT='规格参数的分组表,每个商品分类下有多个规格参数组';

规格组有3个字段:

  • id:主键 组编号
  • cid:商品分类id,一个分类下有多个模板
  • name:该规格组的名称。

规格参数

规格参数表:tb_spec_param

CREATE TABLE `tb_spec_param` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `cid` bigint(20) NOT NULL COMMENT '商品分类id',
  `group_id` bigint(20) NOT NULL,
  `name` varchar(255) NOT NULL COMMENT '参数名',
  `numeric` tinyint(1) NOT NULL COMMENT '是否是数字类型参数,true或false',
  `unit` varchar(255) DEFAULT '' COMMENT '数字类型参数的单位,非数字类型可以为空',
  `generic` tinyint(1) NOT NULL COMMENT '是否是sku通用属性,true或false',
  `searching` tinyint(1) NOT NULL COMMENT '是否用于搜索过滤,true或false',
  `segments` varchar(1000) DEFAULT '' COMMENT '数值类型参数,如果需要搜索,则添加分段间隔值,如CPU频率间隔:0.5-1.0',
  PRIMARY KEY (`id`),
  KEY `key_group` (`group_id`),
  KEY `key_category` (`cid`)
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8 COMMENT='规格参数组下的参数名';

按道理来说,我们的规格参数就只需要记录参数名、组id、商品分类id即可。但是这里却多出了很多字段,为什么?

还记得我们之前的分析吧,规格参数中有一部分是 SKU的通用属性,一部分是SKU的特有属性,而且其中会有一些将来用作搜索过滤,这些信息都需要标记出来。

通用属性

用一个布尔类型字段来标记是否为通用:

  • generic来标记是否为通用属性(全局属性):
    • true:代表通用属性 对同一spu下所有sku来说相同
    • false:代表sku特有属性

搜索过滤

与搜索相关的有两个字段:

  • searching:标记是否用作过滤
    • true:用于过滤搜索
    • false:不用于过滤
  • segments:某些数值类型的参数,在搜索时需要按区间划分,这里提前确定好划分区间
    • 比如电池容量,02000mAh,2000mAh3000mAh,3000mAh~4000mAh

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第65张图片

数值类型

某些规格参数可能为数值类型,这样的数据才需要划分区间,我们有两个字段来描述:

  • numberic:是否为数值类型
    • true:数值类型
    • false:不是数值类型
  • unit:参数的单位

商品规格参数管理

页面布局

整体布局

商品分类树我们之前已经做过,所以这里可以直接展示出来。

因为规格是跟商品分类绑定的,因此首先会展现商品分类树,并且提示你要选择商品分类,才能看到规格参数的模板。一起了解下页面的实现:

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第66张图片

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第67张图片
这里使用了v-layout来完成页面布局,并且添加了row属性,代表接下来的内容是行布局(左右)。

可以看出页面分成2个部分:

  • :左侧,内部又分上下两部分:商品分类树及标题
    • v-card-title:标题部分,这里是提示信息,告诉用户要先选择分类,才能看到模板
    • v-tree:这里用到的是我们之前讲过的树组件,展示商品分类树,
  • :右侧:内部是规格参数展示

右侧规格

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第68张图片
可以看到右侧分为上下两部分:

  • 上部:面包屑,显示当前选中的分类
  • 下部:table,显示规格参数信息

页面实现:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第69张图片
可以看到右侧并不是我们熟悉的 v-data-table,而是一个spec-group组件(规格组)和spec-param组件(规格参数),这是我们定义的独立组件:

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第70张图片
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第71张图片

规格组的查询

树节点的点击事件

当我们点击树节点时,要将v-dialog打开,因此必须绑定一个点击事件:(Specification.vue)
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第72张图片
我们来看下handleClick方法:(Specification.vue)
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第73张图片
点击事件发生时,发生了两件事:

  • 记录当前选中的节点,选中的就是商品分类
  • showGroup被置为true,则规格组就会显示了。

同时,我们把被选中的节点(商品分类)的id传递给了SpecGroup组件:(Specification.vue)

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第74张图片

页面查询规格组

来看下SpecGroup.vue中的实现:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第75张图片
我们查看页面控制台,可以看到请求已经发出:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第76张图片

后端代码

实体类

leyou-item-interface中添加实体类:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第77张图片

package com.leyou.item.pojo;

import javax.persistence.*;
import java.util.List;

@Table(name = "tb_spec_group")
public class SpecGroup {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long cid;

    private String name;

    @Transient
    private List<SpecParam> params;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Long getCid() {
        return cid;
    }

    public void setCid(Long cid) {
        this.cid = cid;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<SpecParam> getParams() {
        return params;
    }

    public void setParams(List<SpecParam> params) {
        this.params = params;
    }
// getter和setter省略
}
package com.leyou.item.pojo;

import javax.persistence.*;

@Table(name = "tb_spec_param")
public class SpecParam {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long cid;
    private Long groupId;
    private String name;
    @Column(name = "`numeric`") //列名和字段名不一样的情况下使用
    private Boolean numeric;//numrtic 在mysql中属于关键字 所以加''
    private String unit;
    private Boolean generic;
    private Boolean searching;
    private String segments;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Long getCid() {
        return cid;
    }

    public void setCid(Long cid) {
        this.cid = cid;
    }

    public Long getGroupId() {
        return groupId;
    }

    public void setGroupId(Long groupId) {
        this.groupId = groupId;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Boolean getNumeric() {
        return numeric;
    }

    public void setNumeric(Boolean numeric) {
        this.numeric = numeric;
    }

    public String getUnit() {
        return unit;
    }

    public void setUnit(String unit) {
        this.unit = unit;
    }

    public Boolean getGeneric() {
        return generic;
    }

    public void setGeneric(Boolean generic) {
        this.generic = generic;
    }

    public Boolean getSearching() {
        return searching;
    }

    public void setSearching(Boolean searching) {
        this.searching = searching;
    }

    public String getSegments() {
        return segments;
    }

    public void setSegments(String segments) {
        this.segments = segments;
    }
    // getter和setter ...
}

leyou-item-service中编写业务:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第78张图片

mapper

public interface SpecGroupMapper extends Mapper<SpecGroup> {
}

controller

先分析下需要的东西,在页面的ajax请求中可以看出:

  • 请求方式:get

  • 请求路径:/spec/groups/{cid} ,这里通过路径占位符传递商品分类的id

  • 请求参数:商品分类id

  • 返回结果:页面是直接把resp.data赋值给了groups:
    乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第79张图片
    那么我们返回的应该是规格组SpecGroup的集合

controller

public class SpecificationController {

    @Autowired
    private SpecificationService specificationService;

    /**
     * 根据分类查询参数组
     * @param cid
     * @return
     */
    @GetMapping("groups/{cid}")
    public ResponseEntity> queryGroupsById(@PathVariable("cid") Long cid){
        List groups=this.specificationService.queryGroupsByCid(cid);
        if (CollectionUtils.isEmpty(groups)){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(groups);
    }

    /**
     * 根据gid查参数组
     * @param gid
     * @return
     */
    @GetMapping("params")
    public ResponseEntity> queryParams(@RequestParam("gid") Long gid){
        List params =this.specificationService.queryParams(gid);
        if(CollectionUtils.isEmpty(params)){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(params);
    }
}

service

package com.leyou.item.service;

import com.leyou.item.mapper.SpecGroupMapper;
import com.leyou.item.mapper.SpecParamMapper;
import com.leyou.item.pojo.SpecGroup;
import com.leyou.item.pojo.SpecParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class SpecificationService {
    @Autowired
    private SpecGroupMapper groupMapper;

    @Autowired
    private SpecParamMapper paramMapper;

    /**
     * 根据分类查询属性组
     * @param cid
     * @return
     */
    public List queryGroupsByCid(Long cid) {
        SpecGroup record = new SpecGroup();
        record.setCid(cid);
        return this.groupMapper.select(record);
    }

    public List queryParams(Long gid) {
        SpecParam record = new SpecParam();
        record.setGroupId(gid);
        return this.paramMapper.select(record);
    }
}

规格参数查询

表格切换

点击规格组:如点击主体、基本信息、操作系统中的任意一项
当我们点击规格组,会切换到规格参数显示,肯定是在规格组中绑定了点击事件:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第80张图片
事件处理
在这里插入图片描述
可以看到这里是使用了父子通信,子组件触发了select事件:

再来看下父组件的事件绑定:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第81张图片

事件处理
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第82张图片
这里我们记录了选中的分组,并且把标记设置为false,这样规格组就不显示了,而是显示:SpecParam

并且,我们把group也传递到spec-param组件:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第83张图片

页面查询规格参数

我们来看SpecParam.vue的实现:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第84张图片
查看页面控制台,发现请求已经发出:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第85张图片
报404,因为我们还没有实现后台逻辑,接下来就去实现。

后台实现

SpecificationController

分析:

  • 请求方式:GET
  • 请求路径:/spec/params
  • 请求参数:gid,分组id
  • 返回结果:该分组下的规格参数集合List

代码:

/**
     * 根据条件查询规格参数
     * @param gid
     * @return
     */
@GetMapping("params")
public ResponseEntity<List<SpecParam>> queryParams(@RequestParam("gid")Long gid){
    List<SpecParam>  params = this.specificationService.queryParams(gid);
    if (CollectionUtils.isEmpty(params)){
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok(params);
}

SpecificationService

@Autowired
private SpecParamMapper paramMapper;

/**
     * 根据条件查询规格参数
     * @param gid
     * @return
     */
public List<SpecParam> queryParams(Long gid) {
    SpecParam param = new SpecParam();
    param.setGroupId(gid);
    return this.paramMapper.select(param);
}

SpecParamMapper

public interface SpecParamMapper extends Mapper<SpecParam> {
}
  • 增删改留为作业

SPU和SKU数据结构

SPU表

SPU表:

CREATE TABLE `tb_spu` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'spu id',
  `title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
  `sub_title` varchar(255) DEFAULT '' COMMENT '子标题',
  `cid1` bigint(20) NOT NULL COMMENT '1级类目id',
  `cid2` bigint(20) NOT NULL COMMENT '2级类目id',
  `cid3` bigint(20) NOT NULL COMMENT '3级类目id',
  `brand_id` bigint(20) NOT NULL COMMENT '商品所属品牌id',
  `saleable` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否上架,0下架,1上架',
  `valid` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效,0已删除,1有效',
  `create_time` datetime DEFAULT NULL COMMENT '添加时间',
  `last_update_time` datetime DEFAULT NULL COMMENT '最后修改时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=208 DEFAULT CHARSET=utf8 COMMENT='spu表,该表描述的是一个抽象的商品,比如 iphone8';

与我们前面分析的基本类似,但是似乎少了一些字段,比如商品描述。

我们做了表的垂直拆分,将SPU的详情放到了另一张表:tb_spu_detail

CREATE TABLE `tb_spu_detail` (
  `spu_id` bigint(20) NOT NULL,
  `description` text COMMENT '商品描述信息',
  `generic_spec` varchar(10000) NOT NULL DEFAULT '' COMMENT '通用规格参数数据',
  `special_spec` varchar(1000) NOT NULL COMMENT '特有规格参数及可选值信息,json格式',
  `packing_list` varchar(3000) DEFAULT '' COMMENT '包装清单',
  `after_service` varchar(3000) DEFAULT '' COMMENT '售后服务',
  PRIMARY KEY (`spu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

这张表中的数据都比较大,而且对于同一SPU商品的不同规格来说不会变化。为了不影响主表的查询效率我们拆分出这张表。

需要注意的是这两个字段:generic_spec和special_spec。

前面讲过规格参数与商品分类绑定,一个分类下的所有SPU具有类似的规格参数。SPU下的SKU可能会有不同的规格参数信息,因此我们计划是这样:

  • SPUDetail中保存通用的规格参数信息。
  • SKU中保存特有规格参数。

来看下我们的表如何存储这些信息。

generic_spec字段

首先是generic_spec,其中保存通用规格参数信息的值,这里为了方便查询,使用了json格式:

整体来看:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第86张图片

json结构,其中都是键值对:

  • key:对应的规格参数的spec_param的id
  • value:对应规格参数的值

special_spec字段

我们说spu中只保存通用规格参数,那么为什么有多出了一个special_spec字段呢?

以手机为例,品牌、操作系统等肯定是全局通用属性,内存、颜色等肯定是特有属性。

当你确定了一个SPU,比如小米的:红米4X

全局属性值都是固定的了:

品牌:小米
型号:红米4X

特有属性举例:

颜色:[香槟金, 樱花粉, 磨砂黑]
内存:[2G, 3G]
机身存储:[16GB, 32GB]

颜色、内存、机身存储,作为SKU特有属性,key虽然一样,但是SPU下的每一个SKU,其值都不一样,所以值会有很多,形成数组。

我们在SPU中,会把特有属性的所有值都记录下来,形成一个数组:

里面又有哪些内容呢?

来看数据格式:

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第87张图片

也是json结构:

  • key:规格参数id
  • value:spu属性的数组

那么问题来:特有规格参数应该在sku中记录才对,为什么在spu中也要记录一份?

因为我们有时候需要把所有规格参数都查询出来,而不是只查询1个sku的属性。比如,商品详情页展示可选的规格参数时:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第88张图片

刚好符合我们的结构,这样页面渲染就非常方便了。

SKU表

CREATE TABLE `tb_sku` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'sku id',
  `spu_id` bigint(20) NOT NULL COMMENT 'spu id',
  `title` varchar(255) NOT NULL COMMENT '商品标题',
  `images` varchar(1000) DEFAULT '' COMMENT '商品的图片,多个图片以‘,’分割',
  `price` bigint(15) NOT NULL DEFAULT '0' COMMENT '销售价格,单位为分',
  `indexes` varchar(100) COMMENT '特有规格属性在spu属性模板中的对应下标组合',
  `own_spec` varchar(1000) COMMENT 'sku的特有规格参数,json格式',
  `enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效,0无效,1有效',
  `create_time` datetime NOT NULL COMMENT '添加时间',
  `last_update_time` datetime NOT NULL COMMENT '最后修改时间',
  PRIMARY KEY (`id`),
  KEY `key_spu_id` (`spu_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='sku表,该表表示具体的商品实体,如黑色的64GB的iphone 8';

还有一张表,代表库存:

CREATE TABLE `tb_stock` (
  `sku_id` bigint(20) NOT NULL COMMENT '库存对应的商品sku id',
  `seckill_stock` int(9) DEFAULT '0' COMMENT '可秒杀库存',
  `seckill_total` int(9) DEFAULT '0' COMMENT '秒杀总数量',
  `stock` int(9) NOT NULL COMMENT '库存数量',
  PRIMARY KEY (`sku_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='库存表,代表库存,秒杀库存等信息';

问题:为什么要将库存独立一张表?

因为库存字段写频率较高,而SKU的其它字段以读为主,因此我们将两张表分离,读写不会干扰。

特别需要注意的是sku表中的indexes字段和own_spec字段。sku中应该保存特有规格参数的值,就在这两个字段中。

indexes字段

在SPU表中,已经对特有规格参数及可选项进行了保存,结构如下:

{
    "4": [
        "香槟金",
        "樱花粉",
        "磨砂黑"
    ],
    "12": [
        "2GB",
        "3GB"
    ],
    "13": [
        "16GB",
        "32GB"
    ]
}

这些特有属性如果排列组合,会产生12个不同的SKU,而不同的SKU,其属性就是上面备选项中的一个。

比如:

  • 红米4X,香槟金,2GB内存,16GB存储
  • 红米4X,磨砂黑,2GB内存,32GB存储

你会发现,每一个属性值,对应于SPUoptions数组的一个选项,如果我们记录下角标,就是这样:

  • 红米4X,0,0,0
  • 红米4X,2,0,1

既然如此,我们是不是可以将不同角标串联起来,作为SPU下不同SKU的标示。这就是我们的indexes字段。

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第89张图片

这个设计在商品详情页会特别有用:

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第90张图片

当用户点击选中一个特有属性,你就能根据 角标快速定位到sku。

own_spec字段

看结构:

{"4":"香槟金","12":"2GB","13":"16GB"}

保存的是特有属性的键值对。

SPU中保存的是可选项,但不确定具体的值,而SKU中的保存的就是具体的值。

乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第91张图片

商品查询

商品管理界面
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第92张图片

页面请求

先看整体页面结构(Goods.vue):
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第93张图片
并且在Vue实例挂载后就会发起查询(mounted调用getDataFromServer方法初始化数据):
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第94张图片
我们刷新页面,可以看到浏览器发起已经发起了查询商品数据的请求:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第95张图片

后台代码编写

页面简述
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第96张图片
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第97张图片

实体类

在leyou-item-interface工程中添加实体类:

SPU

@Table(name = "tb_spu")
public class Spu {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long brandId;
    private Long cid1;// 1级类目
    private Long cid2;// 2级类目
    private Long cid3;// 3级类目
    private String title;// 标题
    private String subTitle;// 子标题
    private Boolean saleable;// 是否上架
    private Boolean valid;// 是否有效,逻辑删除用
    private Date createTime;// 创建时间
    private Date lastUpdateTime;// 最后修改时间
	// 省略getter和setter
}

SPU详情

@Table(name="tb_spu_detail")
public class SpuDetail {
    @Id
    private Long spuId;// 对应的SPU的id
    private String description;// 商品描述
    private String specialSpec;// 商品特殊规格的名称及可选值模板
    private String genericSpec;// 商品的全局规格属性
    private String packingList;// 包装清单
    private String afterService;// 售后服务
    // 省略getter和setter
}

mapper

public interface SpuMapper extends Mapper<Spu> {
}

controller

先分析:

  • 请求方式:GET

  • 请求路径:/spu/page

  • 请求参数:

    • page:当前页
    • rows:每页大小
    • key:过滤条件
    • saleable:上架或下架
  • 返回结果:商品SPU的分页信息。

    • 要注意,页面展示的是商品分类和品牌名称,而数据库中保存的是id,怎么办?

      我们可以新建一个类,继承SPU,并且拓展cname和bname属性,写到leyou-item-interface

      public class SpuBo extends Spu {
      
          String cname;// 商品分类名称
          
          String bname;// 品牌名称
          
          // 略 。。
      }
      

编写controller代码:

我们把与商品相关的一切业务接口都放到一起,起名为GoodsController,业务层也是这样

package com.leyou.item.controller;

import com.leyou.common.pojo.PageResult;
import com.leyou.item.bo.Spubo;
import com.leyou.item.service.GoodsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class GoodsController {

    @Autowired
    private GoodsService goodsService;

    /**
     * 根据条件分页查询spu
     * @param key
     * @param saleable
     * @param page
     * @param rows
     * @return
     */
    @GetMapping("spu/page")
    public ResponseEntity> querySpuByPage(
            @RequestParam (value="key",required = false) String key,
            @RequestParam (value="saleable",required = false) Boolean saleable,
            @RequestParam (value="page",defaultValue = "1") Integer page,
            @RequestParam (value="rows",defaultValue = "5") Integer rows
    ){
        PageResult result=this.goodsService.querySpuByPage(key,saleable,page,rows);
        if (result==null|| CollectionUtils.isEmpty(result.getItems())){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(result);

    }

}

service

所有商品相关的业务(包括SPU和SKU)放到一个业务下:GoodsService。

package com.leyou.item.service;

import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.leyou.common.pojo.PageResult;
import com.leyou.item.bo.Spubo;
import com.leyou.item.mapper.BrandMapper;
import com.leyou.item.mapper.SpuDetailMapper;
import com.leyou.item.mapper.SpuMapper;
import com.leyou.item.pojo.Brand;
import com.leyou.item.pojo.Spu;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import tk.mybatis.mapper.entity.Example;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class GoodsService {

    @Autowired
    private SpuMapper spuMapper;

    @Autowired
    private BrandMapper brandMapper;
    @Autowired
    private SpuDetailMapper spuDetailMapper;

    @Autowired
    private CategoryService categoryService;

    /**
     * 根据分页查询spu
     * @param key
     * @param saleable
     * @param page
     * @param rows
     * @return
     */
    public PageResult querySpuByPage(String key, Boolean saleable, Integer page, Integer rows) {
        Example example = new Example(Spu.class);
        Example.Criteria criteria = example.createCriteria();
        //添加查询条件
        if(StringUtils.isNotBlank(key)) {
            criteria.andLike("title", "%" + key + "%");
        }
        //添加上下架的过滤条件
        if(saleable!=null){
            criteria.andEqualTo("saleable",saleable);
        }

        //添加分页
        PageHelper.startPage(page,rows);
        //执行查询,获取spu集合
        List spus = this.spuMapper.selectByExample(example);
        PageInfo pageInfo = new PageInfo<>(spus);
        //spu集合转化成spubo集合
        List spubos = spus.stream().map(spu -> {
            Spubo spubo = new Spubo();
            BeanUtils.copyProperties(spu, spubo);

            //查询品牌名称
            Brand brand = this.brandMapper.selectByPrimaryKey(spu.getBrandId());
            spubo.setBname(brand.getName());
//            查询分类名称
            List names = this.categoryService.queryNamesByIds(Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()));
            spubo.setCname(StringUtils.join(names, "-"));
            return spubo;
        }).collect(Collectors.toList());
        //      返回pageresult
       return new PageResult<>(pageInfo.getTotal(),spubos);
    }
}

Category中拓展查询名称的功能

页面需要商品的分类名称需要在这里查询,因此要额外提供查询分类名称的功能,

在CategoryService中添加功能:

package com.leyou.item.service;

import com.leyou.item.mapper.CategoryMapper;
import com.leyou.item.pojo.Category;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class CategoryService {
    @Autowired
    private CategoryMapper categoryMapper;

    /**
     * 根据父节点查询子节点
     * @param pid
     * @return
     */
    public List queryCategoriesByPid(Long pid) {
        Category record = new Category();
        record.setParentId(pid);
        return this.categoryMapper.select(record);
    }

    public List queryNamesByIds(List ids){
        List categories = this.categoryMapper.selectByIdList(ids);
        return categories.stream().map(category -> category.getName()).collect(Collectors.toList());
    }
}

mapper的selectByIdList方法是来自于通用mapper。不过需要我们在mapper上继承一个通用mapper接口:

public interface CategoryMapper extends Mapper<Category>, SelectByIdListMapper<Category, Long> { 

}

商品新增

点击商品列表页面的商品新增按钮
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第98张图片
里面把商品的数据分为了4部分来填写:

  • 基本信息:主要是一些简单的文本数据,包含了SPU和SpuDetail的部分数据,如
    • 商品分类:是SPU中的cid1cid2cid3属性
    • 品牌:是spu中的brandId属性
    • 标题:是spu中的title属性
    • 子标题:是spu中的subTitle属性
    • 售后服务:是SpuDetail中的afterService属性
    • 包装列表:是SpuDetail中的packingList属性
  • 商品描述:是SpuDetail中的description属性,数据较多,所以单独放一个页面
  • 规格参数:商品规格信息,对应SpuDetail中的genericSpec属性
  • SKU属性:spu下的所有Sku信息

对应到页面中的四个stepper-content
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第99张图片

事件中的基本数据

商品分类

商品分类信息查询我们之前已经做过,所以这里的级联选框已经实现完成:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第100张图片
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第101张图片

品牌选择

品牌也是一个下拉选框,不过其选项是不确定的,只有当用户选择了商品分类,才会把这个分类下的所有品牌展示出来。

所以页面编写了watch函数,监控商品分类的变化,每当商品分类值有变化,就会发起请求,查询品牌列表:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第102张图片
选择商品分类后,可以看到请求发起:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第103张图片
接下来,我们只要编写后台接口,根据商品分类id,查询对应品牌即可。

后台接口
页面需要去后台查询品牌信息,我们自然需要提供:

请求方式:GET

请求路径:/brand/cid/{cid}

请求参数:cid

响应数据:品牌集合

BrandController

@GetMapping("cid/{cid}")
public ResponseEntity<List<Brand>> queryBrandsByCid(@PathVariable("cid")Long cid){
    List<Brand> brands = this.brandService.queryBrandsByCid(cid);
    if (CollectionUtils.isEmpty(brands)) {
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok(brands);
}

BrandService

public List<Brand> queryBrandsByCid(Long cid) {

    return this.brandMapper.selectBrandByCid(cid);
}

BrandMapper

根据分类查询品牌有中间表,需要自己编写Sql:

@Select("SELECT b.* from tb_brand b INNER JOIN tb_category_brand cb on b.id=cb.brand_id where cb.category_id=#{cid}")
List<Brand> selectBrandByCid(Long cid);

效果:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第104张图片
剩余属性都是普通的文本框,直接写即可

商品描述

商品描述信息比较复杂,而且图文并茂,甚至包括视频。

这样的内容,一般都会使用富文本编辑器。

富文本编辑器

通俗来说:富文本,就是比较丰富的文本编辑器。普通的框只能输入文字,而富文本还能给文字加颜色样式等。

富文本编辑器有很多,例如:KindEditor、Ueditor。但并不原生支持vue

但是我们今天要说的,是一款支持Vue的富文本编辑器:vue-quill-editor

Vue-Quill-Editor

GitHub的主页:https://github.com/surmon-china/vue-quill-editor

Vue-Quill-Editor是一个基于Quill的富文本编辑器:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第105张图片

使用指南

使用非常简单:已经在项目中集成。以下步骤不需操作,仅供参考

第一步:安装,使用npm命令:

npm install vue-quill-editor --save

第二步:加载,在js中引入:

全局引入:

import Vue from 'vue'
import VueQuillEditor from 'vue-quill-editor'

const options = {}; /* { default global options } */

Vue.use(VueQuillEditor, options); // options可选

局部引入:

import 'quill/dist/quill.core.css'
import 'quill/dist/quill.snow.css'
import 'quill/dist/quill.bubble.css'

import {quillEditor} from 'vue-quill-editor'

var vm = new Vue({
    components:{
        quillEditor
    }
})

我们这里采用局部引用:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第106张图片
第三步:页面使用:

<quill-editor v-model="goods.spuDetail.description" :options="editorOption"/>

自定义的富文本编辑器

不过这个组件有个小问题,就是图片上传的无法直接上传到后台,因此我们对其进行了封装,支持了图片的上传。
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第107张图片
使用也非常简单:

<v-stepper-content step="2">
    <v-editor v-model="goods.spuDetail.description" upload-url="/upload/image"/>
v-stepper-content>
  • upload-url:是图片上传的路径
  • v-model:双向绑定,将富文本编辑器的内容绑定到goods.spuDetail.description

商品规格参数

规格参数的查询我们之前也已经编写过接口,因为商品规格参数也是与商品分类绑定,所以需要在商品分类变化后去查询,我们也是通过watch监控来实现:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第108张图片
可以看到这里是根据商品分类id查询规格参数:SpecParam。我们之前写过一个根据gid(分组id)来查询规格参数的接口,我们接下来完成根据分类id查询规格参数。

改造查询规格参数接口

我们在原来的根据 gid(规格组id)查询规格参数的接口上,添加一个参数:cid,即商品分类id。

等一下, 考虑到以后可能还会根据是否搜索、是否为通用属性等条件过滤,我们多添加几个过滤条件:SpecificationController
第二个方法改造为

/**
     * 根据gid查参数组
     * @param gid
     * @return
     */
    @GetMapping("params")
    public ResponseEntity<List<SpecParam>> queryParams(
            @RequestParam(value = "gid",required = false) Long gid,
            @RequestParam(value = "cid",required = false) Long cid,
            @RequestParam(value = "generic",required = false) Boolean generic,
            @RequestParam(value = "searching",required = false) Boolean searching
    ){
        List<SpecParam> params =this.specificationService.queryParams(gid,cid,generic,searching);
        if(CollectionUtils.isEmpty(params)){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(params);
    }

改造SpecificationService:

public List queryParams(Long gid,Long cid,Boolean generic,Boolean searching) {
        SpecParam record = new SpecParam();
        record.setGroupId(gid);
        record.setCid(cid);
        record.setGeneric(generic);
        record.setSearching(searching);
        return this.paramMapper.select(record);
    }

如果param中有属性为null,则不会把属性作为查询条件,因此该方法具备通用性,即可根据gid查询,也可根据cid查询。

SKU信息

Sku属性是SPU下的每个商品的不同特征,如图:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第109张图片
当我们填写一些属性后,会在页面下方生成一个sku表格,大家可以计算下会生成多少个不同属性的Sku呢?

当你选择了上图中的这些选项时:

  • 颜色共2种:迷夜黑,勃艮第红,绚丽蓝
  • 内存共2种:4GB,6GB
  • 机身存储1种:64GB,128GB

此时会产生多少种SKU呢? 应该是 3 * 2 * 2 = 12种,这其实就是在求笛卡尔积。

我们会在页面下方生成一个sku的表格:
可以选择是否启用

页面表单提交

前端页面

在sku列表的下方,有一个提交按钮:保存商品信息
并且绑定了点击事件:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第110张图片
点击后会组织数据并向后台提交:

    submit() {
      // 表单校验。
      if(!this.$refs.basic.validate){
        this.$message.error("请先完成表单内容!");
      }
      // 先处理goods,用结构表达式接收,除了categories外,都接收到goodsParams中
      const {
        categories: [{ id: cid1 }, { id: cid2 }, { id: cid3 }],
        ...goodsParams
      } = this.goods;
      // 处理规格参数
      const specs = {};
      this.specs.forEach(({ id,v }) => {
        specs[id] = v;
      });
      // 处理特有规格参数模板
      const specTemplate = {};
      this.specialSpecs.forEach(({ id, options }) => {
        specTemplate[id] = options;
      });
      // 处理sku
      const skus = this.skus
        .filter(s => s.enable)
        .map(({ price, stock, enable, images, indexes, ...rest }) => {
          // 标题,在spu的title基础上,拼接特有规格属性值
          const title = goodsParams.title + " " + Object.values(rest).map(v => v.v).join(" ");
          const obj = {};
          Object.values(rest).forEach(v => {
            obj[v.id] = v.v;
          });
          return {
            price: this.$format(price), // 价格需要格式化
            stock,
            indexes,
            enable,
            title, // 基本属性
            images: images ? images.join(",") : '', // 图片
            ownSpec: JSON.stringify(obj) // 特有规格参数
          };
        });
      Object.assign(goodsParams, {
        cid1,
        cid2,
        cid3, // 商品分类
        skus // sku列表
      });
      goodsParams.spuDetail.genericSpec = JSON.stringify(specs);
      goodsParams.spuDetail.specialSpec = JSON.stringify(specTemplate);

      // 提交到后台
      this.$http({
        method: this.isEdit ? "put" : "post",
        url: "/item/goods",
        data: goodsParams
      })
        .then(() => {
          // 成功,关闭窗口
          this.$emit("close");
          // 提示成功
          this.$message.success("保存成功了");
        })
        .catch(() => {
          this.$message.error("保存失败!");
        });
    }

点击提交,查看控制台提交的数据格式:
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第111张图片
整体是一个json格式数据,包含Spu表所有数据:

  • brandId:品牌id
  • cid1、cid2、cid3:商品分类id
  • subTitle:副标题
  • title:标题
  • spuDetail:是一个json对象,代表商品详情表数据
    • afterService:售后服务
    • description:商品描述
    • packingList:包装列表
    • specialSpec:sku规格属性模板
    • genericSpec:通用规格参数
  • skus:spu下的所有sku数组,元素是每个sku对象:
    • title:标题
    • images:图片
    • price:价格
    • stock:库存
    • ownSpec:特有规格参数
    • indexes:特有规格参数的下标

后台实现

实体类

SPU和SpuDetail实体类已经添加过,添加Sku和Stock对象:

Sku

@Table(name = "tb_sku")
public class Sku {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long spuId;
    private String title;
    private String images;
    private Long price;
    private String ownSpec;// 商品特殊规格的键值对
    private String indexes;// 商品特殊规格的下标
    private Boolean enable;// 是否有效,逻辑删除用
    private Date createTime;// 创建时间
    private Date lastUpdateTime;// 最后修改时间
    @Transient
    private Integer stock;// 库存
}

注意:这里保存了一个库存字段,在数据库中是另外一张表保存的,方便查询。

Stock

@Table(name = "tb_stock")
public class Stock {
    @Id
    private Long skuId;
    private Integer seckillStock;// 秒杀可用库存
    private Integer seckillTotal;// 已秒杀数量
    private Integer stock;// 正常库存
}

GoodsController

结合浏览器页面控制台,可以发现:

请求方式:POST

请求路径:/goods

请求参数:Spu的json格式的对象,spu中包含spuDetail和Sku集合。这里我们该怎么接收?我们之前定义了一个SpuBo对象,作为业务对象。这里也可以用它,不过需要再扩展spuDetail和skus字段:

public class SpuBo extends Spu {

    String cname;// 商品分类名称
    String bname;// 品牌名称
    SpuDetail spuDetail;// 商品详情
    List<Sku> skus;// sku列表
}
  • 返回类型:无
    在GoodsController中添加新增商品的代码:
@PostMapping("goods")
public ResponseEntity<Void> saveGoods(@RequestBody SpuBo spuBo){
    this.goodsService.saveGoods(spuBo);
    return ResponseEntity.status(HttpStatus.CREATED).build();
}

注意:通过@RequestBody注解来接收Json请求

GoodService

这里的逻辑比较复杂,我们除了要对SPU新增以外,还要对SpuDetail、Sku、Stock进行保存

 /**
     * 新增商品
     * @param spubo
     */
    @Transactional
    public void saveGoods(Spubo spubo) {
        //先新增spu
        spubo.setId(null);
        spubo.setSaleable(true);
        spubo.setValid(true);
        spubo.setCreateTime(new Date());
        spubo.setLastUpdateTime(spubo.getCreateTime());
        this.spuMapper.insertSelective(spubo);
//        再新增spudetail
        SpuDetail spuDetail = spubo.getSpuDetail();
        spuDetail.setSpuId(spubo.getId());
        this.spuDetailMapper.insertSelective(spuDetail);

//        新增sku
        spubo.getSkus().forEach(sku -> {
            //新增sku
            sku.setId(null);
            sku.setSpuId(spubo.getId());
            sku.setCreateTime(new Date());
            sku.setLastUpdateTime(sku.getCreateTime());
            this.skuMapper.insertSelective(sku);
            //新增stock
            Stock stock = new Stock();
            stock.setSkuId(sku.getId());
            stock.setStock(sku.getStock());
            this.stockMapper.insertSelective(stock);
        });

//                新增stock

    }

Mapper

都是通用Mapper
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第112张图片

商品修改

编辑按钮点击事件

在商品详情页,每一个商品后面,都会有一个编辑按钮:
点击这个按钮,就会打开一个商品编辑窗口,我们看下它所绑定的点击事件:(在item/Goods.vue)
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第113张图片
对应的方法
乐优商城个人笔记上-主要框架、基础知识、管理系统代码_第114张图片
可以看到这里发起了两个请求,在查询商品详情和sku信息。

因为在商品列表页面,只有spu的基本信息:id、标题、品牌、商品分类等。比较复杂的商品详情(spuDetail)和sku信息都没有,编辑页面要回显数据,就需要查询这些内容。

因此,接下来我们就编写后台接口,提供查询服务接口。

查询spudetail的接口编写> GoodsController

需要分析的内容:

  • 请求方式:GET
  • 请求路径:/spu/detail/{id}
  • 请求参数:id,应该是spu的id
  • 返回结果:SpuDetail对象
@GetMapping("spu/detail/{spuId}")
public ResponseEntity<SpuDetail> querySpuDetailBySpuId(@PathVariable("spuId")Long spuId){
    SpuDetail spuDetail = this.goodsService.querySpuDetailBySpuId(spuId);
    if (spuDetail == null) {
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok(spuDetail);
}

GoodsService

/**
     * 根据spuId查询spuDetail
     * @param spuId
     * @return
     */
public SpuDetail querySpuDetailBySpuId(Long spuId) {

    return this.spuDetailMapper.selectByPrimaryKey(spuId);
}

查询sku的接口编写

分析

  • 请求方式:Get
  • 请求路径:/sku/list
  • 请求参数:id,应该是spu的id
  • 返回结果:sku的集合

GoodsController

@GetMapping("sku/list")
public ResponseEntity<List<Sku>> querySkusBySpuId(@RequestParam("id")Long spuId){
    List<Sku> skus = this.goodsService.querySkusBySpuId(spuId);
    if (CollectionUtils.isEmpty(skus)) {
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok(skus);
}

GoodsService

需要注意的是,为了页面回显方便,我们一并把sku的库存stock也查询出来

/**
     * 根据spuId查询sku的集合
     * @param spuId
     * @return
     */
public List<Sku> querySkusBySpuId(Long spuId) {
    Sku sku = new Sku();
    sku.setSpuId(spuId);
    List<Sku> skus = this.skuMapper.select(sku);
    skus.forEach(s -> {
        Stock stock = this.stockMapper.selectByPrimaryKey(s.getId());
        s.setStock(stock.getStock());
    });
    return skus;
}
  • 点击任意一个编辑按钮,就会发现页面回写完成,旧的数据全部显现
  • 其中保存的按钮和新增按钮是同一个,提交的逻辑也一样

修改的后台实现代码

接下来,我们编写后台,实现修改商品接口。

GoodsController

  • 请求方式:PUT
  • 请求路径:/
  • 请求参数:Spu对象
  • 返回结果:无
@PutMapping("goods")
public ResponseEntity<Void> updateGoods(@RequestBody SpuBo spuBo){
    this.goodsService.updateGoods(spuBo);
    return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}

该方法写在新增下方,方便区分

GoodsService

spu数据可以修改,但是SKU数据无法修改,因为有可能之前存在的SKU现在已经不存在了,或者以前的sku属性都不存在了。比如以前内存有4G,现在没了。

因此这里直接删除以前的SKU,然后新增即可。

笔记代码:

@Transactional
public void update(SpuBo spu) {
    // 查询以前sku
    List<Sku> skus = this.querySkuBySpuId(spu.getId());
    // 如果以前存在,则删除
    if(!CollectionUtils.isEmpty(skus)) {
        List<Long> ids = skus.stream().map(s -> s.getId()).collect(Collectors.toList());
        // 删除以前库存
        Example example = new Example(Stock.class);
        example.createCriteria().andIn("skuId", ids);
        this.stockMapper.deleteByExample(example);

        // 删除以前的sku
        Sku record = new Sku();
        record.setSpuId(spu.getId());
        this.skuMapper.delete(record);

    }
    // 新增sku和库存
    saveSkuAndStock(spuBo);

    // 更新spu
    spu.setLastUpdateTime(new Date());
    spu.setCreateTime(null);
    spu.setValid(null);
    spu.setSaleable(null);
    this.spuMapper.updateByPrimaryKeySelective(spu);

    // 更新spu详情
    this.spuDetailMapper.updateByPrimaryKeySelective(spu.getSpuDetail());
}

视频中的代码

 /**
     * sku数据都是直接删除再新增
     * @param spu
     */
    @Transactional
    public void upDateGoods(Spubo spu) {
        //查询以前sku
        Sku record = new Sku();
        record.setSpuId(spu.getId());
        List<Sku> skus = this.skuMapper.select(record);
        //如果以前存在,则删除
        skus.forEach(sku -> {
            //delete stock
            this.stockMapper.deleteByPrimaryKey(sku.getId());
        });
        //delete sku
        Sku sku = new Sku();
        sku.setSpuId(spu.getId());
        this.skuMapper.delete(sku);
        //新增sku和库存
        saveSkuAndStock(spu);
        //更新spu
        spu.setLastUpdateTime(new Date());
        spu.setCreateTime(null);
        spu.setValid(null);
        spu.setSaleable(null);
        this.spuMapper.updateByPrimaryKeySelective(spu);
        //跟新spu详情
        this.spuDetailMapper.updateByPrimaryKeySelective(spu.getSpuDetail());

    }

其中的方法saveSkuAndStock()采取提取的方式生成,在新增方法中的的内容选中,以右键 Refactor => Extract => Method ,修改名字生成

 private void saveSkuAndStock(Spubo spubo) {
        spubo.getSkus().forEach(sku -> {
            //新增sku
            sku.setId(null);
            sku.setSpuId(spubo.getId());
            sku.setCreateTime(new Date());
            sku.setLastUpdateTime(sku.getCreateTime());
            this.skuMapper.insertSelective(sku);
            //新增stock
            Stock stock = new Stock();
            stock.setSkuId(sku.getId());
            stock.setStock(sku.getStock());
            this.stockMapper.insertSelective(stock);
        });
    }

mapper

与以前一样。

你可能感兴趣的:(spring,boot,spring,java)