在 package.json 文件中有 scripts 启动脚本配置
打开 Teminal,输入以下命令启动项目
npm start
启动成功
打开浏览器,访问以下地址
http://localhost:9001
Vue 虽然会帮我们进行视图的渲染,但样式还是由我们自己来完成。这显然不是我们的强项,因此后端开发人员一般都喜欢使用一些现成的 UI 组件,拿来即用,常见的例如:
然而这些 UI 组件的基因天生与 Vue 不合,因为他们更多的是利用 DOM 操作,借助于 jQuery 实现,而不是 MVVM 的思想。而目前与 Vue 吻合的 UI 框架也非常的多,国内比较知名的如:
我们使用的是一款国外的框架:Vuetify
官方网站:https://vuetifyjs.com/zh-Hans/
使用 Vuetify 原因如下:
我们现在访问页面使用的地址是:http://localhost:9001
但实际开发中会有不同的环境:
如果不同环境使用不同的 IP 去访问,可能会出现一些问题。为了保证所有环境的一致,我们会在各种环境下都使用域名来访问。
我们将使用以下域名:
当我们在浏览器输入一个域名时,浏览器是如何找到对应服务的 IP 和端口的呢?
本地域名解析
浏览器会首先在本机的 hosts 文件中查找域名映射的 IP 地址,如果查找到就返回 IP 地址,没找到则进行域名服务器解析。
域名服务器解析
本地解析失败,才会进行域名服务器解析,域名服务器就是网络中的一台计算机,里面记录了所有注册备案的域名和 IP 映射关系。
我们还在开发阶段,不可能去买个域名,因此我们可以修改本地的 hosts 文件,实现对域名的解析。
下载并安装 SwitchHosts(这是一个管理 hosts 的工具)
右键 SwitchHosts,以管理员身份运行
添加映射关系,点击左边按钮生效
现在试试能不能 Ping 通
打开 leyou-manage-web 工程,在 webpack.dev.conf.js 中取消 host 验证,添加 disableHostCheck: true
启动 leyou-manage,通过域名访问:http://manage.leyou.com:9001
域名问题解决了,但是现在要访问后台页面,还得自己加上端口:http://manage.leyou.com:9001
这就不够优雅了,我们希望的是直接域名访问:http://manage.leyou.com
这种情况下端口默认是 80,如何才能把请求转移到 9001 端口呢?这里就要用到反向代理工具:Nginx
Nginx 是一个高性能的 Web 和反向代理服务器, 它具有有很多非常优越的特性:
Web 服务器分两类:
二者区分:
Nginx 可以作为 Web 服务器,但更多的时候,我们把它作为网关,因为它具备网关必备的功能:
正向代理介绍
张三找李四借钱,李四觉得张三不可靠,不借钱给张三。于是张三找到王五,请王五去找李四借钱。王五找李四借钱,李四觉得王五可靠,把钱借给了王五。但是李四并不知道这个钱是借给了张三,李四是借给了王五,最后是王五把钱借给了张三。在这个借钱的过程中,王五就是代理,也可以说是正向代理,他隐藏了真实的借钱人。
正向代理的过程中,让一台服务器代理客户端,客户端的所有请求都交给代理服务器处理,这样就隐藏了真实的客户端。
反向代理介绍
我们打电话给 10086 客服,可能一个地区会有几十个客服,我们不知道会是其中的哪一个客服接电话,但一定会有一个客服接电话。在这个打电话给 10086 的过程中,10086 这个总机号码就是反向代理,它隐藏了真实的客服。
反向代理的过程中,让一台服务器代理真实服务器,用户访问时,不再是访问真实服务器,而是代理服务器,这样就隐藏了真实的服务器。
Nginx 作为反向代理服务器
Nginx 可以作为反向代理服务器来使用:
下载并解压 Nginx,以下是目录结构
打开 conf/nginx.conf,看到 server 配置(Nginx 中的每个 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,启动 Nginx
打开浏览器,访问 http://manage.leyou.com
总结实现域名访问的具体流程
商城的核心自然是商品,而商品多了以后,肯定要进行分类,并且不同的商品会有不同的品牌信息,我们需要依次去完成:商品分类、品牌、商品的开发。
导入准备好的 sql 文件到 MySQL 数据库
打开商品分类表 tb_category
CREATE TABLE `tb_category` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '类目id',
`name` varchar(32) 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
字段,对本表中的其它分类进行自关联。
在浏览器点击分类管理菜单,可以看到路由路径 item/category
找到路由文件 index.js,发现页面最终指向 /page/item/Category
打开 Category.vue
商品分类使用了树状结构,这里是自定义了一个树状组件,可以参照文档使用该组件
属性名称 | 说明 | 数据类型 | 默认值 |
---|---|---|---|
url | 用来加载数据的地址,即延迟加载 | String | - |
isEdit | 是否开启树的编辑功能 | boolean | false |
treeData | 整颗树数据,这样就不用远程加载了 | Array | - |
我们发现只要加入 treeData 就可以展示静态数据了,并且不会再去远程加载数据
在 mockDB.js 中已经存在了 treeData 的静态数据
引入 treeData 到 Category.vue 中
打开浏览器,点击商品管理下的分类管理菜单
实现了展示静态数据,我们就知道了分类管理大致要做成什么样子了,接下来就可以取消引入的静态数据了,下面准备实现展示动态数据
点击商品管理下的分类管理菜单,打开浏览器控制台
可以看到页面发起了一条请求:
http://api.leyou.com/api/item/category/list?pid=0
这个请求路径怎么来的呢?
Category.vue 中我们使用了相对路径:
/item/category/list
讲道理发起的请求地址应该是:
http://manage.leyou.com/item/category/list
但实际却发起的请求地址是:
http://api.leyou.com/api/item/category/list?pid=0
这是因为我们有一个全局的配置文件,对所有的请求路径进行了约定:
基本路径是 http://api.leyou.com
,并且默认加上了 /api 的前缀,这恰好与我们的网关设置匹配。
接下来,我们要做的事情就是编写后台接口,返回对应的数据即可。
在 leyou-item-interface 中添加通用 Mapper 依赖
<dependency>
<groupId>tk.mybatisgroupId>
<artifactId>mapper-spring-boot-starterartifactId>
dependency>
在 leyou-item-interface 中添加实体类
@Table(name = "tb_category")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Long parentId;
private Boolean isParent; // 注意 isParent 生成的 getter 和 setter 方法需要手动加上 Is
private Integer sort;
// 构造器、getter 和 setter 方法、toString 方法省略
}
注意:在阿里巴巴约规手册中,规定了"POJO 类中布尔类型的变量,都不要加 is 前缀"
在 leyou-item-service 中添加 Mapper 接口
public interface CategoryMapper extends Mapper<Category> {
}
在启动类上添加扫描 Mapper 注解
@SpringBootApplication
@EnableDiscoveryClient
@MapperScan("com.leyou.item.mapper")
public class LeyouItemServiceApplication {
public static void main(String[] args) {
SpringApplication.run(LeyouItemServiceApplication.class, args);
}
}
在 leyou-item-service 中添加 CategoryService
@Service
public class CategoryService {
@Autowired
private CategoryMapper categoryMapper;
/**
* 根据 ParentId 查询子类目
* @param pid
* @return
*/
public List<Category> queryCategoryById(Long pid) {
Category category = new Category();
category.setParentId(pid);
List<Category> categories = categoryMapper.select(category);
return categories;
}
}
在 leyou-item-service 中添加 CategoryController
@RestController
@RequestMapping("/category")
public class CategoryController {
@Autowired
private CategoryService categoryService;
/**
* 根据 ParentId 查询子类目
* @param pid
* @return
*/
@GetMapping("/list")
public ResponseEntity<List<Category>> queryCategoryById(@RequestParam("pid") Long pid) {
if (pid == null || pid.longValue() < 0) {
return ResponseEntity.badRequest().build(); // 响应 400
}
List<Category> categories = categoryService.queryCategoryById(pid);
if(CollectionUtils.isEmpty(categories)) {
return ResponseEntity.notFound().build(); // 响应 404
}
return ResponseEntity.ok(categories);
}
}
跨域是浏览器对于 javascript 的同源策略的限制。
以下情况都属于跨域:
跨域原因说明 | 示例 |
---|---|
域名不同 | www.jd.com 与 www.taobao.com |
域名相同,端口不同 | www.jd.com:8080 与 www.jd.com:8081 |
二级域名不同 | item.jd.com 与 miaosha.jd.com |
域名和端口都相同,但是请求路径不同,则不属于跨域,如:
www.jd.com/item
www.jd.com/goods
跨域不一定都会有跨域问题。
跨域问题是浏览器对于 ajax 请求的一种安全限制:一个页面发起的 ajax 请求,只能是与当前页域名相同的路径,这能有效的阻止跨站攻击。
但是这却给我们的开发带来了不便,而且在实际生产环境中,肯定会有很多台服务器之间交互,地址和端口都可能不同,下面就来解决跨域问题。
目前比较常用的跨域解决方案有三种:
Jsonp
最早的解决方案,利用 script 标签可以跨域的原理实现。
缺点:
Nginx 反向代理
利用 Nginx 把跨域反向代理为不跨域,支持各种请求方式
缺点:
CORS
规范化的跨域请求解决方案,安全可靠。
优点:
缺点:
CORS 是一个 W3C 标准,全称是"跨域资源共享"(Cross-origin resource sharing)。
它允许浏览器向跨源服务器,发出 XMLHttpRequest 请求,从而克服了 Ajax 只能同源使用的限制。
CORS 需要浏览器和服务器同时支持
浏览器端
目前,所有浏览器都支持该功能(IE10 以下不行)。整个 CORS 通信过程,都是浏览器自动完成,不需要用户参与。
服务端
CORS 通信与 AJAX 没有任何差别,因此你不需要改变以前的业务逻辑。但浏览器会在请求中携带一些头信息,我们需要以此判断是否允许其跨域,然后在响应头中加入一些信息即可。这一般通过过滤器完成即可。
浏览器会将 Ajax 请求分为两类:
两者处理方案略有差异
简单请求的定义
满足以下两个条件,就属于简单请求:
请求方法是以下三种方法之一
HTTP 的头信息不超出以下几种字段
application/x-www-form-urlencoded
,multipart/form-data
,text/plain
发起简单请求
当浏览器发现发起的 Ajax 请求是简单请求时,会在请求头中携带一个字段 Origin,Origin 中会指出当前请求属于哪个域,服务会根据这个值决定是否允许其跨域。
简单请求的响应
如果服务器允许跨域,需要在返回的响应头中携带下面信息:
Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Content-Type: text/html; charset=utf-8
操作 cookie 的条件
要想操作 cookie,需要满足三个条件:
特殊请求的定义
不符合简单请求的条件,会被浏览器判定为特殊请求,例如请求方式为 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 以外,多了两个请求头:
预检请求的响应
服务收到预检请求,如果许可跨域,会发出响应:
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-Origin
和 Access-Control-Allow-Credentials
以外,这里又多出三个响应头:
发起真实请求
如果浏览器得到上述响应,则认定为可以跨域,就可以发出真实请求了。
实现思路
事实上,SpringMVC 已经帮我们写好了 CORS 的跨域过滤器:CorsFilter,内部已经实现了刚才所讲的判定逻辑,我们直接用就好了。
实现
在 leyou-gateway
中编写一个配置类,并且注册 CorsFilter
@Configuration
public class LeyouCorsConfigration {
@Bean
public CorsFilter corsFilter() {
// 初始化 cors 配置对象
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("http://manage.leyou.com"); //允许的域
config.setAllowCredentials(true); //是否发送 Cookie 信息
config.addAllowedMethod("*"); //允许的请求方式
config.addAllowedHeader("*"); //允许的头信息
//初始化 cors 配置源对象
UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
configSource.registerCorsConfiguration("/**", config); //添加映射路径,拦截一切请求
return new CorsFilter(configSource); //返回 CorsFilter
}
}
测试
打开浏览器到后台管理系统的分类管理菜单,成功展示动态数据
点击商品管理下的分类管理菜单,打开浏览器控制台
由此可以得到请求路径及参数
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='商品分类和品牌的中间表,两者是多对多关系';
但是,你可能会发现,这张表中并没有设置外键约束,似乎与数据库的设计范式不符。为什么这么做?
@Table(name = "tb_brand")
public class Brand {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String image;
private Character letter;
// 构造器、getter 和 setter 方法、toString 方法省略
}
public interface BrandMapper extends Mapper<Brand> {
}
编写 Service 之前,先看看前端给我们发来的请求参数和要得到的响应结果:
由此我们可以得知:
分页结果类
由响应结果可知,我们还需要一个分页结果类。另外,这个类以后可能在其它项目中也有需求,因此我们将其抽取到 leyou-common 中。
package com.leyou.common.pojo;
import java.util.List;
public class PageResult<T> {
private Long total; //总条数
private List<T> items; //当前页数据
private Integer totalPage; //总页数
public PageResult() {
}
public PageResult(Long total, List<T> items) {
this.total = total;
this.items = items;
}
public PageResult(Long total, List<T> items, Integer totalPage) {
this.total = total;
this.items = items;
this.totalPage = totalPage;
}
// getter 和 setter 方法、toString 方法省略
}
Service
在 leyou-item-service 中引入 leyou-common 的依赖
<dependency>
<groupId>com.leyou.commongroupId>
<artifactId>leyou-commonartifactId>
<version>1.0-SNAPSHOTversion>
dependency>
编写 service
@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 对象
Example example = new Example(Brand.class);
Example.Criteria criteria = example.createCriteria();
// 根据 name 模糊查询,或根据 letter 查询
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 = brandMapper.selectByExample(example);
// 包装成 pageInfo
PageInfo<Brand> brandPageInfo = new PageInfo<>(brands);
// 包装成分页结果集返回
return new PageResult<Brand>(brandPageInfo.getTotal(), brandPageInfo.getList());
}
}
@RestController
@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,
@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> brandPageResult = brandService.queryBrandsByPage(key, page, rows, sortBy, desc);
if (CollectionUtils.isEmpty(brandPageResult.getItems())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(brandPageResult);
}
}